Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .agents/skills/test-t3-app/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,5 @@ using retained terminal sessions or captured PIDs.
When sharing is requested, start with `vp run dev --share` and give the user
a fresh complete pairing URL that you have not consumed. Keep other credentials
out of screenshots, commits, and replies.

Retain the `tabId` of each tab created for this test. At final teardown, close only those owned tabs with `preview_close`, passing each `tabId` explicitly, before stopping the test environment. Confirm each close returns `tabId: null`.
154 changes: 154 additions & 0 deletions BRANCH_DETAILS.md

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions apps/desktop/src/app/DesktopAppIdentity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,31 @@ const withIdentity = <A, E, R>(
};

describe("DesktopAppIdentity", () => {
it.effect("uses an explicit desktop user-data directory without probing legacy state", () =>
withIdentity(
Effect.gen(function* () {
const identity = yield* DesktopAppIdentity.DesktopAppIdentity;
const userDataPath = yield* identity.resolveUserDataPath;

assert.equal(userDataPath, "/tmp/t3-desktop-profile");
}),
{
environment: {
env: {
T3CODE_DESKTOP_USER_DATA_DIR: " /tmp/t3-desktop-profile ",
},
},
legacyPathProbeError: PlatformError.systemError({
_tag: "PermissionDenied",
module: "FileSystem",
method: "exists",
description: "legacy path must not be probed",
pathOrDescriptor: "/Users/alice/Library/Application Support/T3 Code (Alpha)",
}),
},
),
);

it.effect("keeps using the legacy userData path when it already exists", () =>
withIdentity(
Effect.gen(function* () {
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/app/DesktopAppIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ const normalizeCommitHash = (value: string): Option.Option<string> => {
export const resolveUserDataPath = Effect.gen(function* () {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const fileSystem = yield* FileSystem.FileSystem;
if (Option.isSome(environment.desktopUserDataDir)) {
return environment.desktopUserDataDir.value;
}

const legacyPath = environment.path.join(
environment.appDataDirectory,
environment.legacyUserDataDirName,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/app/DesktopClerk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const makeDesktopClerkLayer = (
stateDir: "/tmp/t3-state",
isDevelopment,
appDataDirectory: "/tmp/app-data",
desktopUserDataDir: Option.none(),
userDataDirName: isDevelopment ? "t3code-dev" : "t3code",
legacyUserDataDirName: isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)",
path: { join: (...parts: ReadonlyArray<string>) => parts.join("/") },
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/app/DesktopConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export const DesktopConfig = Config.all({
xdgDataHome: trimmedString("XDG_DATA_HOME"),
t3Home: trimmedString("T3CODE_HOME"),
devServerUrl: Config.URL("VITE_DEV_SERVER_URL").pipe(Config.option),
desktopUserDataDir: trimmedString("T3CODE_DESKTOP_USER_DATA_DIR"),
appUserModelIdOverride: trimmedString("T3CODE_DESKTOP_APP_USER_MODEL_ID"),
devRemoteT3ServerEntryPath: trimmedString("T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH"),
configuredBackendPort: Config.Port("T3CODE_PORT").pipe(Config.option),
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/app/DesktopEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ describe("DesktopEnvironment", () => {
{},
{
T3CODE_HOME: " /tmp/t3 ",
T3CODE_DESKTOP_USER_DATA_DIR: " /tmp/t3/userdata/electron ",
T3CODE_COMMIT_HASH: " 0123456789abcdef ",
T3CODE_PORT: "4949",
VITE_DEV_SERVER_URL: "http://localhost:5173",
Expand Down Expand Up @@ -84,6 +85,7 @@ describe("DesktopEnvironment", () => {
Option.map(environment.devServerUrl, (url) => url.href),
Option.some("http://localhost:5173/"),
);
assert.deepEqual(environment.desktopUserDataDir, Option.some("/tmp/t3/userdata/electron"));
assert.deepEqual(environment.devRemoteT3ServerEntryPath, Option.some("/remote/server.mjs"));
assert.deepEqual(environment.configuredBackendPort, Option.some(4949));
assert.deepEqual(environment.commitHashOverride, Option.some("0123456789abcdef"));
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/app/DesktopEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export class DesktopEnvironment extends Context.Service<
readonly compileCachePath: string;
readonly appUpdateYmlPath: string;
readonly devServerUrl: Option.Option<URL>;
readonly desktopUserDataDir: Option.Option<string>;
readonly devRemoteT3ServerEntryPath: Option.Option<string>;
readonly configuredBackendPort: Option.Option<number>;
readonly commitHashOverride: Option.Option<string>;
Expand Down Expand Up @@ -228,6 +229,7 @@ const make = Effect.fn("desktop.environment.make")(function* (
? path.join(resourcesPath, "app-update.yml")
: path.join(input.appPath, "dev-app-update.yml"),
devServerUrl,
desktopUserDataDir: Option.map(config.desktopUserDataDir, path.resolve),
devRemoteT3ServerEntryPath: config.devRemoteT3ServerEntryPath,
configuredBackendPort: config.configuredBackendPort,
commitHashOverride: config.commitHashOverride,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
getAppBranding,
getLocalEnvironmentBootstraps,
getLocalEnvironmentBearerToken,
getPreviewAutomationHostMetadata,
getSystemLocale,
getWindowFullscreenState,
openExternal,
Expand Down Expand Up @@ -81,6 +82,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
yield* ipc.handle(AppActivationIpc.complete);

yield* ipc.handleSync(getAppBranding);
yield* ipc.handleSync(getPreviewAutomationHostMetadata);
yield* ipc.handleSync(getSystemLocale);
yield* ipc.handleSync(getWindowFullscreenState);
yield* ipc.handleSync(getLocalEnvironmentBootstraps);
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
export const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
export const UPDATE_CHECK_CHANNEL = "desktop:update-check";
export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";
export const GET_PREVIEW_AUTOMATION_HOST_METADATA_CHANNEL =
"desktop:get-preview-automation-host-metadata";
export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale";
export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps";
export const GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:get-local-environment-enabled";
Expand Down
51 changes: 36 additions & 15 deletions apps/desktop/src/ipc/methods/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ import {
DesktopPreviewAutomationEvaluateInputSchema,
DesktopPreviewAutomationPressInputSchema,
DesktopPreviewAutomationScrollInputSchema,
DesktopPreviewAutomationSnapshotInputSchema,
DesktopPreviewAutomationStatusSchema,
DesktopPreviewAutomationTypeInputSchema,
DesktopPreviewAutomationWaitForInputSchema,
DesktopPreviewConfigInputSchema,
DesktopPreviewNavigateInputSchema,
DesktopPreviewRecordingArtifactSchema,
DesktopPreviewRecordingStartInputSchema,
DesktopPreviewRecordingStopInputSchema,
DesktopPreviewRecordingSaveInputSchema,
DesktopPreviewRegisterWebviewInputSchema,
DesktopPreviewScreenshotArtifactSchema,
Expand Down Expand Up @@ -161,9 +164,13 @@ export const setColorScheme = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL,
payload: DesktopPreviewSetColorSchemeInputSchema,
result: Schema.Void,
handler: Effect.fn("desktop.ipc.preview.setColorScheme")(function* ({ tabId, colorScheme }) {
handler: Effect.fn("desktop.ipc.preview.setColorScheme")(function* ({
tabId,
colorScheme,
timeoutMs,
}) {
const manager = yield* PreviewManager.PreviewManager;
yield* manager.setColorScheme(tabId, colorScheme);
yield* manager.setColorScheme(tabId, colorScheme, timeoutMs);
}),
});
export const setAudioMuted = DesktopIpc.makeIpcMethod({
Expand All @@ -187,24 +194,28 @@ export const cancelPickElement = tabMethod(
);
export const startRecording = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PREVIEW_RECORDING_START_CHANNEL,
payload: DesktopPreviewTabInputSchema,
payload: DesktopPreviewRecordingStartInputSchema,
result: Schema.Void,
handler: Effect.fn("desktop.ipc.preview.startRecording")(function* ({ tabId }) {
handler: Effect.fn("desktop.ipc.preview.startRecording")(function* ({ tabId, timeoutMs }) {
const manager = yield* PreviewManager.PreviewManager;
const store = yield* DesktopClientSettings.DesktopClientSettings;
const settings = yield* store.get;
const options = Option.map(settings, (value) => ({
showKeyPresses: value.browserRecordingShowKeyPresses,
showMousePresses: value.browserRecordingShowMousePresses,
}));
yield* manager.startRecording(tabId, Option.getOrUndefined(options));
yield* manager.startRecording(tabId, Option.getOrUndefined(options), timeoutMs);
}),
});
export const stopRecording = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL,
payload: DesktopPreviewRecordingStopInputSchema,
result: Schema.Void,
handler: Effect.fn("desktop.ipc.preview.stopRecording")(function* ({ tabId, timeoutMs }) {
const manager = yield* PreviewManager.PreviewManager;
yield* manager.stopRecording(tabId, timeoutMs);
}),
});
export const stopRecording = tabMethod(
IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL,
"desktop.ipc.preview.stopRecording",
(manager, tabId) => manager.stopRecording(tabId),
);
export const openPictureInPicture = tabMethod(
IpcChannels.PREVIEW_PICTURE_IN_PICTURE_OPEN_CHANNEL,
"desktop.ipc.preview.openPictureInPicture",
Expand Down Expand Up @@ -404,11 +415,15 @@ export const automationStatus = DesktopIpc.makeIpcMethod({

export const automationSnapshot = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL,
payload: DesktopPreviewTabInputSchema,
payload: DesktopPreviewAutomationSnapshotInputSchema,
result: PreviewAutomationSnapshot,
handler: Effect.fn("desktop.ipc.preview.automationSnapshot")(function* ({ tabId }) {
handler: Effect.fn("desktop.ipc.preview.automationSnapshot")(function* ({
tabId,
background,
timeoutMs,
}) {
const manager = yield* PreviewManager.PreviewManager;
return yield* manager.automationSnapshot(tabId);
return yield* manager.automationSnapshot(tabId, background, timeoutMs);
}),
});

Expand Down Expand Up @@ -476,9 +491,15 @@ export const saveRecording = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PREVIEW_RECORDING_SAVE_CHANNEL,
payload: DesktopPreviewRecordingSaveInputSchema,
result: DesktopPreviewRecordingArtifactSchema,
handler: Effect.fn("desktop.ipc.preview.saveRecording")(function* ({ tabId, mimeType, data }) {
handler: Effect.fn("desktop.ipc.preview.saveRecording")(function* ({
tabId,
mimeType,
data,
idempotencyKey,
timeoutMs,
}) {
const manager = yield* PreviewManager.PreviewManager;
return yield* manager.saveRecording(tabId, mimeType, data);
return yield* manager.saveRecording(tabId, mimeType, data, idempotencyKey, timeoutMs);
}),
});

Expand Down
22 changes: 22 additions & 0 deletions apps/desktop/src/ipc/methods/window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as NodeOS from "node:os";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
Expand All @@ -27,12 +28,33 @@ import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts";
import type { DesktopSettings } from "../../settings/DesktopAppSettings.ts";
import {
getLocalEnvironmentBootstraps,
getPreviewAutomationHostMetadata,
getWindowFullscreenState,
pasteAsText,
pickProjectFavicon,
probeRemoteEditors,
} from "./window.ts";

describe("getPreviewAutomationHostMetadata", () => {
for (const [hostPlatform, previewPlatform] of [
["darwin", "macos"],
["win32", "windows"],
["linux", "linux"],
["freebsd", "unknown"],
] as const) {
it.effect(`maps ${hostPlatform} renderer identity metadata`, () =>
Effect.gen(function* () {
const result = yield* getPreviewAutomationHostMetadata.handler();

assert.deepEqual(result, {
label: NodeOS.hostname(),
platform: previewPlatform,
});
}).pipe(Effect.provideService(HostProcessPlatform, hostPlatform)),
);
}
});

const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = {
executablePath: "wsl.exe",
args: ["-d", "Ubuntu", "--", "node", "/app/bin.mjs"],
Expand Down
24 changes: 24 additions & 0 deletions apps/desktop/src/ipc/methods/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ const ContextMenuInput = Schema.Struct({
position: Schema.optionalKey(ContextMenuPosition),
});

const PreviewAutomationHostMetadata = Schema.Struct({
label: Schema.String,
platform: Schema.Literals(["macos", "windows", "linux", "unknown"]),
});

function toWebSocketBaseUrl(httpBaseUrl: URL): string {
const url = new URL(httpBaseUrl.href);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
Expand All @@ -71,6 +76,25 @@ export const getAppBranding = DesktopIpc.makeSyncIpcMethod({
}),
});

export const getPreviewAutomationHostMetadata = DesktopIpc.makeSyncIpcMethod({
channel: IpcChannels.GET_PREVIEW_AUTOMATION_HOST_METADATA_CHANNEL,
result: PreviewAutomationHostMetadata,
handler: Effect.fn("desktop.ipc.window.getPreviewAutomationHostMetadata")(function* () {
const hostPlatform = yield* HostProcess.HostProcessPlatform;
return {
label: NodeOS.hostname(),
platform:
hostPlatform === "darwin"
? "macos"
: hostPlatform === "win32"
? "windows"
: hostPlatform === "linux"
? "linux"
: "unknown",
} as const;
}),
});

export const getSystemLocale = DesktopIpc.makeSyncIpcMethod({
channel: IpcChannels.GET_SYSTEM_LOCALE_CHANNEL,
result: Schema.String,
Expand Down
32 changes: 23 additions & 9 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ function unwrapEnsureSshEnvironmentResult(result: unknown) {
}

contextBridge.exposeInMainWorld("desktopBridge", {
getPreviewAutomationHostMetadata: () =>
ipcRenderer.sendSync(IpcChannels.GET_PREVIEW_AUTOMATION_HOST_METADATA_CHANNEL) as ReturnType<
NonNullable<DesktopBridge["getPreviewAutomationHostMetadata"]>
>,
getAppBranding: () => {
const result = ipcRenderer.sendSync(IpcChannels.GET_APP_BRANDING_CHANNEL);
if (typeof result !== "object" || result === null) {
Expand Down Expand Up @@ -304,8 +308,12 @@ contextBridge.exposeInMainWorld("desktopBridge", {
zoomOut: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, { tabId }),
resetZoom: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, { tabId }),
hardReload: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, { tabId }),
setColorScheme: (tabId, colorScheme) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, { tabId, colorScheme }),
setColorScheme: (tabId, colorScheme, timeoutMs) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, {
tabId,
colorScheme,
timeoutMs,
}),
setAudioMuted: (tabId, audioMuted) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, { tabId, audioMuted }),
openDevTools: (tabId) =>
Expand Down Expand Up @@ -346,15 +354,17 @@ contextBridge.exposeInMainWorld("desktopBridge", {
return () =>
ipcRenderer.removeListener(IpcChannels.PREVIEW_RECORDING_INPUT_CHANNEL, wrappedListener);
},
startScreencast: (tabId) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_START_CHANNEL, { tabId }),
stopScreencast: (tabId) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL, { tabId }),
save: (tabId, mimeType, data) =>
startScreencast: (tabId, timeoutMs) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_START_CHANNEL, { tabId, timeoutMs }),
stopScreencast: (tabId, timeoutMs) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL, { tabId, timeoutMs }),
save: (tabId, mimeType, data, idempotencyKey, timeoutMs) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_RECORDING_SAVE_CHANNEL, {
tabId,
mimeType,
data,
idempotencyKey,
timeoutMs,
}),
onFrame: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, frame: unknown) => {
Expand All @@ -369,8 +379,12 @@ contextBridge.exposeInMainWorld("desktopBridge", {
automation: {
status: (tabId) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, { tabId }),
snapshot: (tabId) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL, { tabId }),
snapshot: (tabId, background = false, timeoutMs) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL, {
tabId,
background,
timeoutMs,
}),
click: (tabId, input) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL, { tabId, input }),
type: (tabId, input) =>
Expand Down
Loading
Loading