diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md index 306038007e9a0..a11eee4e3f4eb 100644 --- a/.agents/skills/test-t3-mobile/SKILL.md +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -43,10 +43,15 @@ prints one JSON line; on failure it names the next command. Mobile verification includes that step unless the user prohibits it. For a device you opened in an agent-device session elsewhere (a device pool, -cloud, or remote host), run `vp run mobile:install --session ` -from the directory you opened the session in. It installs the cached build into -that session (`--abi arm64-v8a` for an ARM Android device; `--metro ` also -opens it against Metro) and never builds. +cloud, or remote host), run `vp run mobile:install --session +--config ` from the directory you opened the session in, passing the +`--config` file from `device_open`'s `targetArgs`. Without it, a device pool's +gateway refuses the install, wrongly saying agent-device is too old. It installs +the cached build into that session (`--abi arm64-v8a` for an ARM Android device; +pool Android emulators are x86_64, the default; `--metro ` also opens it +against Metro). Without a cached build for this checkout it builds first, which can +outlast the lease, so run `vp run mobile:install --prepare` (with +the same `--abi`) before you open the device. Start `vp run dev:client` from `apps/mobile`, or reuse a healthy Metro belonging to this checkout. Open its printed development-client URL with AgentDevice diff --git a/.github/workflows/coil-ci.yml b/.github/workflows/coil-ci.yml index 7d3fe1310cae2..23fdf84541996 100644 --- a/.github/workflows/coil-ci.yml +++ b/.github/workflows/coil-ci.yml @@ -100,8 +100,10 @@ jobs: run: vp run --filter @t3tools/desktop ensure:electron # `@t3tools/monorepo` is the root package; its `test` script is `vp run -r test`. + # The `-t` skips upstream's test that reads desktop-macos-preview-publish.yml, which the fork + # retires. Keep in sync with `scripts/coil/sync-upstream.sh`. - name: Test - run: vp run --filter '!t3' --filter '!@t3tools/monorepo' --filter '!@t3tools/web' test --testTimeout=120000 --hookTimeout=120000 + run: vp run --filter '!t3' --filter '!@t3tools/monorepo' --filter '!@t3tools/web' test --testTimeout=120000 --hookTimeout=120000 -t '^(?!.*packages\sthe\smacOS\spreview.s\sPR\sbundle\sas\suntrusted)' # Split in two because one headless Chromium running all ~585 web files on these runners lost its # page late in the run in 5 of 6 runs on 2026-11-13 (`Browser connection was closed`), with no diff --git a/.github/workflows/mobile-dev-client.yml b/.github/workflows/mobile-dev-client.yml index e204b16f01626..b3e11c1ed6b6b 100644 --- a/.github/workflows/mobile-dev-client.yml +++ b/.github/workflows/mobile-dev-client.yml @@ -219,7 +219,7 @@ jobs: ios: name: iOS simulator if: github.repository == 'pingdotgg/t3code' && (github.event_name != 'workflow_dispatch' || inputs.platform == 'ios') - runs-on: blacksmith-12vcpu-macos-26 + runs-on: blacksmith-12vcpu-macos-27 timeout-minutes: 60 concurrency: group: mobile-dev-client-ios-${{ github.event_name == 'pull_request' && github.event.pull_request.number || inputs.fingerprint || github.sha }} diff --git a/apps/desktop/scripts/ensure-electron-runtime.mjs b/apps/desktop/scripts/ensure-electron-runtime.mjs index 933a763dfa354..741a2d9ee85d8 100644 --- a/apps/desktop/scripts/ensure-electron-runtime.mjs +++ b/apps/desktop/scripts/ensure-electron-runtime.mjs @@ -209,6 +209,10 @@ export function ensureElectronRuntime() { return electronPath; } +export function setup() { + ensureElectronRuntime(); +} + // `file://${argv[1]}` never matches on Windows (drive letters need `file:///C:/`). if (process.argv[1] && NodeURL.pathToFileURL(process.argv[1]).href === import.meta.url) { const electronPath = ensureElectronRuntime(); diff --git a/apps/desktop/src/app/DesktopApp.test.ts b/apps/desktop/src/app/DesktopApp.test.ts index 79e3a47ae522d..faeeb58ab71fb 100644 --- a/apps/desktop/src/app/DesktopApp.test.ts +++ b/apps/desktop/src/app/DesktopApp.test.ts @@ -87,6 +87,7 @@ it.effect.each([false, true])( const historyStarted = yield* Deferred.make(); const releaseHistory = yield* Deferred.make(); const quitting = yield* Ref.make(false); + const tokenRequested = yield* Deferred.make(); const events: string[] = []; const record = (event: string) => Effect.sync(() => { @@ -145,7 +146,7 @@ it.effect.each([false, true])( configure: Effect.void, } as PreviewPasskeys.PreviewPasskeys["Service"]), Effect.provideService(DesktopApplicationMenu.DesktopApplicationMenu, { - configure: Effect.void, + configure: record("menu"), }), Effect.provideService(DesktopUpdates.DesktopUpdates, { configure: Effect.void, @@ -224,7 +225,12 @@ it.effect.each([false, true])( Layer.mock(DesktopClientSettings.DesktopClientSettings, {}), Layer.mock(DesktopConnectionCatalogStore.DesktopConnectionCatalogStore, {}), Layer.mock(DesktopIpc.DesktopIpc, {}), - Layer.mock(DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth, {}), + Layer.mock(DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth, { + getBearerToken: record("token").pipe( + Effect.andThen(Deferred.succeed(tokenRequested, undefined)), + Effect.as("token"), + ), + }), Layer.mock(DesktopManagedPolicy.DesktopManagedPolicy, {}), Layer.succeed(DesktopPrimaryPaste.DesktopPrimaryPaste, { enabled: true }), Layer.mock(DesktopSshEnvironment.DesktopSshEnvironment, {}), @@ -255,7 +261,13 @@ it.effect.each([false, true])( ); yield* Deferred.await(registrationStarted); yield* Deferred.await(windowStarted); - assert.deepEqual(events, ["identity", "ready", "identity", "backend", "window"]); + yield* Deferred.await(tokenRequested); + // The backend spawns before menus, and its token is minted without waiting for the renderer. + assert.deepEqual( + events.filter((event) => event !== "token"), + ["identity", "ready", "identity", "backend", "menu", "window"], + ); + assert.isAbove(events.indexOf("token"), events.indexOf("backend")); assert.isFalse(yield* Deferred.isDone(registrationStopped)); const shutdown = yield* DesktopShutdown.DesktopShutdown; yield* shutdown.request; diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 7ebeaa74a021d..0e7f949bc042c 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -22,6 +22,7 @@ import * as PreviewPasskeys from "../preview/Passkeys.ts"; import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopHomeOwner from "../backend/DesktopHomeOwner.ts"; +import * as DesktopLocalEnvironmentAuth from "../backend/DesktopLocalEnvironmentAuth.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopLegacyLocalStorage from "./DesktopLegacyLocalStorage.ts"; import * as DesktopLifecycle from "./DesktopLifecycle.ts"; @@ -168,13 +169,12 @@ const stopAllPoolInstances = Effect.fn("desktop.app.stopAllPoolInstances")( }, ); -const bootstrap = Effect.gen(function* () { +// Spawns the local backend as soon as the protocol, IPC, and legacy storage that every window needs exist. +const startLocalBackend = Effect.gen(function* () { const state = yield* DesktopState.DesktopState; const environment = yield* DesktopEnvironment.DesktopEnvironment; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const desktopWindow = yield* DesktopWindow.DesktopWindow; - const snapShot = yield* DesktopSnapShot.DesktopSnapShot; - const appActivation = yield* DesktopAppActivation.DesktopAppActivation; yield* logBootstrapInfo("bootstrap start"); const settings = yield* desktopSettings.get; @@ -193,32 +193,13 @@ const bootstrap = Effect.gen(function* () { yield* (yield* DesktopAppIdentity.DesktopAppIdentity).resolveUserDataPath, ); - yield* snapShot.initialize; - // Only the user's own login shell PATH shows every t3 a new terminal runs; a fresh install has - // no cached one. Without it, the command is left to Settings. - const installCli = (yield* DesktopCliCommand.DesktopCliCommand).installOnFirstLaunch; - yield* Effect.forkScoped( - (yield* DesktopShellEnvironment.DesktopShellEnvironment).awaitUserShellDiscovery.pipe( - Effect.flatMap((found) => - found || environment.platform === "win32" - ? installCli - : logBootstrapInfo("skipped installing the t3 command without the shell PATH"), - ), - ), - ); - if (!settings.localEnvironmentEnabled) { yield* logBootstrapInfo("bootstrap skipping local environment (disabled in settings)"); - if (!(yield* Ref.get(state.quitting))) { - yield* desktopWindow.createMainIfBackendReady; - } - return; + return false; } - const pool = yield* DesktopBackendPool.DesktopBackendPool; - const primaryBackend = yield* pool.primary; + const primaryBackend = yield* (yield* DesktopBackendPool.DesktopBackendPool).primary; const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) { return yield* new DesktopDevelopmentBackendPortRequiredError(); @@ -237,7 +218,8 @@ const bootstrap = Effect.gen(function* () { "This T3 home is unavailable", DesktopHomeOwner.homeOwnedMessage(owner), ); - return yield* (yield* ElectronApp.ElectronApp).quit; + yield* (yield* ElectronApp.ElectronApp).quit; + return false; } const backendPortSelection = yield* resolveDesktopBackendPort(environment.configuredBackendPort); @@ -275,29 +257,73 @@ const bootstrap = Effect.gen(function* () { ); } - if (!(yield* Ref.get(state.quitting))) { - if (settings.wslOnly === true && settings.wslBackendEnabled === true) { - yield* desktopWindow.showConnectingSplash; - } - yield* primaryBackend.start; - yield* logBootstrapInfo("bootstrap backend start requested"); - // WSL-only fallback can change the primary endpoint before readiness. - const config = yield* primaryBackend.currentConfig; - if ( - !environment.isDevelopment && - !settings.wslOnly && - Option.isSome(config) && - Option.isNone(config.value.preflightFailure) - ) { - yield* desktopWindow.ensureMain; + if (yield* Ref.get(state.quitting)) { + return false; + } + if (settings.wslOnly === true && settings.wslBackendEnabled === true) { + yield* desktopWindow.showConnectingSplash; + } + yield* primaryBackend.start; + yield* logBootstrapInfo("bootstrap backend start requested"); + // The renderer asks for this token first; minting it at readiness saves a round trip. + yield* Effect.forkScoped( + (yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth).getBearerToken.pipe( + Effect.ignore, + ), + ); + return true; +}).pipe(Effect.withSpan("desktop.startLocalBackend")); + +const bootstrap = Effect.fn("desktop.bootstrap")(function* (backendStarted: boolean) { + const state = yield* DesktopState.DesktopState; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const snapShot = yield* DesktopSnapShot.DesktopSnapShot; + const appActivation = yield* DesktopAppActivation.DesktopAppActivation; + const settings = yield* desktopSettings.get; + if (settings.localEnvironmentEnabled && (!backendStarted || (yield* Ref.get(state.quitting)))) { + return; + } + + yield* snapShot.initialize; + // Only the user's own login shell PATH shows every t3 a new terminal runs; a fresh install has + // no cached one. Without it, the command is left to Settings. + const installCli = (yield* DesktopCliCommand.DesktopCliCommand).installOnFirstLaunch; + yield* Effect.forkScoped( + (yield* DesktopShellEnvironment.DesktopShellEnvironment).awaitUserShellDiscovery.pipe( + Effect.flatMap((found) => + found || environment.platform === "win32" + ? installCli + : logBootstrapInfo("skipped installing the t3 command without the shell PATH"), + ), + ), + ); + + if (!settings.localEnvironmentEnabled) { + if (!(yield* Ref.get(state.quitting))) { + yield* desktopWindow.createMainIfBackendReady; } - yield* appActivation.start.pipe( - Effect.tap(() => logBootstrapInfo("desktop app control socket ready")), - Effect.catch((error) => logStartupError("desktop app control socket unavailable", { error })), - ); - yield* Effect.forkScoped(wslBackend.reconcile); + return; } -}).pipe(Effect.withSpan("desktop.bootstrap")); + + const primaryBackend = yield* (yield* DesktopBackendPool.DesktopBackendPool).primary; + // WSL-only fallback can change the primary endpoint before readiness. + const config = yield* primaryBackend.currentConfig; + if ( + !environment.isDevelopment && + !settings.wslOnly && + Option.isSome(config) && + Option.isNone(config.value.preflightFailure) + ) { + yield* desktopWindow.ensureMain; + } + yield* appActivation.start.pipe( + Effect.tap(() => logBootstrapInfo("desktop app control socket ready")), + Effect.catch((error) => logStartupError("desktop app control socket unavailable", { error })), + ); + yield* Effect.forkScoped((yield* DesktopWslBackend.DesktopWslBackend).reconcile); +}); const startup = Effect.gen(function* () { const appIdentity = yield* DesktopAppIdentity.DesktopAppIdentity; @@ -369,14 +395,20 @@ const startup = Effect.gen(function* () { }); } yield* appIdentity.configure; - yield* previewPasskeys.configure; - yield* applicationMenu.configure; + // Sets canInstallUpdates, which the backend bootstrap payload reads. yield* updates.configure; yield* DesktopRemoteUpdates.listen; yield* DesktopStartupRecovery.listen; + const backendStarted = yield* startLocalBackend.pipe( + Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause)), + ); + yield* previewPasskeys.configure; + yield* applicationMenu.configure; // Portal identity is prepared pre-ready; MIME helpers must not gate the backend or window. yield* linuxUrlHandler.register.pipe(Effect.forkScoped); - yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); + yield* bootstrap(backendStarted).pipe( + Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause)), + ); }).pipe(Effect.withSpan("desktop.startup")); const scopedProgram = Effect.scoped( diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index e9c8336feaf4b..8e8a2812ab245 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -204,6 +204,35 @@ describe("DesktopLinuxUrlHandler", () => { assert.include(entry, "Icon=/home/al ice/icons/T3\\\\x.png"); }); + it.each([ + { from: "T3-Code-0.1.8-x86_64.AppImage", to: "T3-Code-x86_64.AppImage", restored: true }, + { + from: "T3-Code-0.1.9-nightly.20261110.4449-arm64.AppImage", + to: "T3-Code-Nightly-arm64.AppImage", + restored: true, + }, + { from: "T3-Code-0.1.8-x86_64.AppImage", to: "T3-Code-0.1.9-x86_64.AppImage", restored: false }, + { from: "t3code.AppImage", to: "T3-Code-x86_64.AppImage", restored: false }, + { from: "T3-Code-x86_64.AppImage", to: "T3-Code-x86_64.AppImage", restored: false }, + { from: "../T3-Code-0.1.8-x86_64.AppImage", to: "T3-Code-x86_64.AppImage", restored: false }, + ])("finds the launch path an update from $from to $to removed", ({ from, to, restored }) => { + for (const dir of ["/home/alice/Applications", '/home/al ice/My "100%" $Apps\\x']) { + const previous = dir + "/" + from; + const previousEntry = DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({ + displayName: "T3 Code (Beta)", + execTarget: previous, + scheme: "t3code", + }); + assert.equal( + DesktopLinuxUrlHandler.replacedVersionedAppImagePath({ + previousEntry, + appImagePath: dir + "/" + to, + }), + restored ? previous : null, + ); + } + }); + it.effect( "writes the handler entry, refreshes the MIME cache, and claims the scheme default", () => { diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts index 64535c5b3ac11..35fd46dd7ae50 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -96,6 +96,38 @@ export function renderUrlHandlerDesktopEntry(input: { ].join("\n"); } +function parseUrlHandlerExecTarget(entry: string): string | null { + const exec = /^Exec=(.*) %U$/mu.exec(entry)?.[1]; + if (exec === undefined) return null; + const unescaped = exec.replace(/\\([\\nrt])/gu, (_, char: string) => + char === "n" ? "\n" : char === "r" ? "\r" : char === "t" ? "\t" : char, + ); + const quoted = /^"(.*)"$/su.exec(unescaped)?.[1]; + return ( + quoted === undefined ? unescaped : quoted.replace(/\\([\\`$"])/gu, (_, char: string) => char) + ).replaceAll("%%", "%"); +} + +const APPIMAGE_NAME_VERSION = /\d+\.\d+\.\d+/u; + +// An updater from before version-free AppImage names deletes its own file and installs the update +// beside it under the new name. The previous launch's entry still names the path launchers use. +export function replacedVersionedAppImagePath(input: { + readonly previousEntry: string; + readonly appImagePath: string; +}): string | null { + const previous = parseUrlHandlerExecTarget(input.previousEntry); + if (previous === null) return null; + const previousName = previous.slice(previous.lastIndexOf("/") + 1); + const currentName = input.appImagePath.slice(input.appImagePath.lastIndexOf("/") + 1); + return previous.startsWith("/") && + previous.slice(0, -previousName.length) === input.appImagePath.slice(0, -currentName.length) && + APPIMAGE_NAME_VERSION.test(previousName) && + !APPIMAGE_NAME_VERSION.test(currentName) + ? previous + : null; +} + // Docks may look up the Wayland app id without a desktop entry; icon theme lookups are case-sensitive. const LINUX_THEME_ICON_SIZE = "256x256"; diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index 34607e08b844f..45887a928abb7 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -16,6 +16,7 @@ const { copyFileSyncMock, execFileSyncMock, readFileSyncMock, + symlinkSyncMock, } = vi.hoisted(() => ({ appendSwitchMock: vi.fn(), getSwitchValueMock: vi.fn(), @@ -27,6 +28,7 @@ const { copyFileSyncMock: vi.fn(), execFileSyncMock: vi.fn(), readFileSyncMock: vi.fn(), + symlinkSyncMock: vi.fn(), })); vi.mock("electron", () => ({ @@ -51,6 +53,7 @@ vi.mock("node:fs", () => ({ mkdirSync: mkdirSyncMock, writeFileSync: writeFileSyncMock, copyFileSync: copyFileSyncMock, + symlinkSync: symlinkSyncMock, })); vi.mock("node:child_process", () => ({ @@ -72,6 +75,7 @@ describe("DesktopPreReadyPlatform", () => { copyFileSyncMock.mockReset(); execFileSyncMock.mockReset(); readFileSyncMock.mockReset().mockReturnValue("{}"); + symlinkSyncMock.mockReset(); }); it.effect("persists Linux scaling synchronously across relaunches and settings rewrites", () => { @@ -181,6 +185,47 @@ describe("DesktopPreReadyPlatform", () => { ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); }); + it.effect.each([ + { relaunchedByUpdater: "true", restored: true }, + { relaunchedByUpdater: "", restored: false }, + ])( + "restores the versioned AppImage path an update removed (updater relaunch: $relaunchedByUpdater)", + ({ relaunchedByUpdater, restored }) => { + vi.stubEnv("VITE_DEV_SERVER_URL", ""); + getSwitchValueMock.mockReturnValue(""); + const entryPath = "/xdg/applications/com.t3tools.T3Code.desktop"; + const files = new Map([[entryPath, "Exec=/Applications/T3-Code-0.1.8-x86_64.AppImage %U\n"]]); + readFileSyncMock.mockImplementation((path: string) => files.get(path) ?? "{}"); + writeFileSyncMock.mockImplementation((path: string, contents: string | Buffer) => { + files.set(path, String(contents)); + }); + + return Effect.scoped( + Effect.gen(function* () { + yield* Layer.build( + DesktopPreReadyPlatform.layer.pipe( + Layer.provide(Layer.succeed(HostProcess.Platform, "linux")), + Layer.provide( + DesktopConfig.layerTest({ + XDG_DATA_HOME: "/xdg", + APPIMAGE: "/Applications/T3-Code-x86_64.AppImage", + APPIMAGE_SILENT_INSTALL: relaunchedByUpdater, + }), + ), + ), + ); + assert.deepEqual( + symlinkSyncMock.mock.calls, + restored + ? [["T3-Code-x86_64.AppImage", "/Applications/T3-Code-0.1.8-x86_64.AppImage"]] + : [], + ); + assert.include(files.get(entryPath), "Exec=/Applications/T3-Code-x86_64.AppImage %U"); + }), + ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); + }, + ); + it.effect("points the early portal entry at a configured distribution launcher", () => { vi.stubEnv("VITE_DEV_SERVER_URL", ""); getSwitchValueMock.mockReturnValue(""); diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index df8059133fc20..8ac4654636ab6 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -14,7 +14,10 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import * as DesktopEarlyElectronStartup from "./DesktopEarlyElectronStartup.ts"; import { trimmedString } from "./DesktopConfig.ts"; import { resolveDesktopAppBranding } from "./DesktopEnvironment.ts"; -import { renderUrlHandlerDesktopEntry } from "./DesktopLinuxUrlHandler.ts"; +import { + renderUrlHandlerDesktopEntry, + replacedVersionedAppImagePath, +} from "./DesktopLinuxUrlHandler.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; export interface DesktopPreReadyCommandLineReader { @@ -56,6 +59,8 @@ const LinuxDesktopEntryConfig = Config.all({ xdgDataHome: trimmedString("XDG_DATA_HOME"), launcherPath: trimmedString("T3CODE_DESKTOP_LAUNCHER_PATH"), appImagePath: trimmedString("APPIMAGE"), + // electron-updater sets this on the AppImage it relaunches after installing an update. + relaunchedByUpdater: trimmedString("APPIMAGE_SILENT_INSTALL"), }); /** @public */ @@ -116,8 +121,26 @@ export const make = Effect.gen(function* () { ); } catch {} } + const desktopEntryPath = NodePath.posix.join(applicationsDir, linux.linuxDesktopEntryName); + const { appImagePath } = linuxDesktopEntryConfig; + if ( + Option.isSome(appImagePath) && + Option.isNone(linuxDesktopEntryConfig.launcherPath) && + Option.contains(linuxDesktopEntryConfig.relaunchedByUpdater, "true") + ) { + try { + const replacedPath = replacedVersionedAppImagePath({ + previousEntry: NodeFS.readFileSync(desktopEntryPath, "utf8"), + appImagePath: appImagePath.value, + }); + // Relative, so the pair survives a moved folder; symlink never overwrites a file. + if (replacedPath !== null) { + NodeFS.symlinkSync(NodePath.posix.basename(appImagePath.value), replacedPath); + } + } catch {} + } NodeFS.writeFileSync( - NodePath.posix.join(applicationsDir, linux.linuxDesktopEntryName), + desktopEntryPath, renderUrlHandlerDesktopEntry({ displayName: resolveDesktopAppBranding({ isDevelopment: linux.isDevelopment, diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 5d63bc911471f..25b30f82951fc 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -410,7 +410,7 @@ describe("DesktopBackendConfiguration", () => { ); assert.equal(config.runningDistro, "Ubuntu"); - assert.deepEqual(config.args.slice(0, 2), ["-d", "Ubuntu"]); + assert.deepEqual(config.args.slice(0, 5), ["-d", "Ubuntu", "--cd", "~", "--exec"]); assert.deepEqual(observedDistros, ["Ubuntu", "Ubuntu", "Ubuntu"]); assert.isTrue(Option.isNone(config.preflightFailure)); }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), @@ -473,6 +473,8 @@ describe("DesktopBackendConfiguration", () => { assert.deepEqual(config.args, [ "-d", "Ubuntu", + "--cd", + "~", "--exec", "env", `PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${resolvedPath}`, @@ -688,6 +690,8 @@ describe("DesktopBackendConfiguration", () => { assert.deepEqual(config.args, [ "-d", "Ubuntu", + "--cd", + "~", "--exec", "env", "PATH=/home/test user's/.nvm/versions/node/v22.0.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/home/test user/bin:/opt/test's tools/bin:/usr/bin:/bin", @@ -722,6 +726,43 @@ describe("DesktopBackendConfiguration", () => { ), ); + it.effect("gives the Windows and WSL backends one host scope that survives a restart", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + const launch = Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const primary = yield* configuration.resolvePrimary; + const wsl = yield* configuration.resolveWsl({ port: 5050, distro: null }); + assert.equal(wsl.env.T3CODE_DESKTOP_HOST_SCOPE, primary.env.T3CODE_DESKTOP_HOST_SCOPE); + assert.include((wsl.env.WSLENV ?? "").split(":"), "T3CODE_DESKTOP_HOST_SCOPE"); + return primary.env.T3CODE_DESKTOP_HOST_SCOPE; + }).pipe( + Effect.provide( + layerBackendConfiguration().pipe( + Layer.provideMerge(layerServerExposure), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), + Layer.provideMerge( + DesktopWslEnvironment.layerTest({ + isAvailable: true, + windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"), + getDistroIp: () => Option.some("172.27.0.99"), + }), + ), + Layer.provideMerge(layerEnvironment(baseDir, { platform: "win32" })), + ), + ), + ); + + const first = yield* launch; + assert.match(first ?? "", /^[0-9a-f]{32}$/); + assert.equal(yield* launch, first); + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + ); + it.effect("resolveWsl carries the kill switch and resource attributes into the distro", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -787,7 +828,7 @@ describe("DesktopBackendConfiguration", () => { assert.equal(config.env.T3CODE_OTLP_PROTOCOL, "http/protobuf"); assert.equal( config.env.WSLENV, - "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u:ANTHROPIC_API_KEY:T3CODE_OTLP_HEADERS:T3CODE_OTLP_PROTOCOL", + "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u:T3CODE_DESKTOP_HOST_SCOPE:ANTHROPIC_API_KEY:T3CODE_OTLP_HEADERS:T3CODE_OTLP_PROTOCOL", ); }).pipe( Effect.provide( diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 2d1e9918d8dd2..80050b04ede67 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -262,8 +262,38 @@ interface SharedBootstrapInput { readonly bootstrapToken: string; readonly bootstrapSecret: string; readonly observabilitySettings: BackendObservabilitySettings; + readonly hostScope: string; } +// Matches the server's DESKTOP_HOST_SCOPE_ENV. +const DESKTOP_HOST_SCOPE_ENV = "T3CODE_DESKTOP_HOST_SCOPE"; +const HOST_SCOPE_FILE_NAME = "desktop-host-scope"; +const HOST_SCOPE_PATTERN = /^[0-9a-f]{32}$/; + +/** + * Not a credential: it only tells this install's Windows and WSL servers that + * they run on one machine, so usage both read from one Windows directory counts + * once. Persisted so summaries from earlier launches still match. + */ +const readOrCreateHostScope = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const random = yield* SecureRandom.SecureRandom; + const scopePath = environment.path.join(environment.stateDir, HOST_SCOPE_FILE_NAME); + const existing = (yield* fileSystem + .readFileString(scopePath) + .pipe(Effect.orElseSucceed(() => ""))).trim(); + if (HOST_SCOPE_PATTERN.test(existing)) return existing; + const scope = Hex.encode(yield* random.randomBytes(16).pipe(Effect.orDie)); + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }).pipe( + Effect.andThen(fileSystem.writeFileString(scopePath, scope)), + Effect.catch((cause) => + Effect.logWarning("Could not persist the desktop host scope.", { cause }), + ), + ); + return scope; +}); + type WslPreflightRuntime = | { readonly kind: "executable"; @@ -561,6 +591,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv NODE_OPTIONS: backendNodeOptions(env.NODE_OPTIONS), T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING: "1", T3CODE_CLI_PATH: Option.getOrUndefined(input.cliPath), + [DESKTOP_HOST_SCOPE_ENV]: input.hostScope, }, extendEnv: true, bootstrap, @@ -657,8 +688,8 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl const httpBaseUrl = new URL(`http://${rendererHost}:${input.port}`); const distroArgs = distroForConfig ? ["-d", distroForConfig] : []; - const forwardedEnv: Record = {}; - const forwardedEnvNames: string[] = []; + const forwardedEnv: Record = { [DESKTOP_HOST_SCOPE_ENV]: input.hostScope }; + const forwardedEnvNames: string[] = [DESKTOP_HOST_SCOPE_ENV]; for (const name of WSL_FORWARDED_ENV_NAMES) { const value = env[name]; if (value !== undefined && value.length > 0) { @@ -732,6 +763,9 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl ...baseConfig, args: [ ...distroArgs, + // WSL otherwise inherits the Windows-mounted desktop working directory. + "--cd", + "~", "--exec", "env", `PATH=${launchPath}`, @@ -759,6 +793,13 @@ export const make = Effect.gen(function* () { const random = yield* SecureRandom.SecureRandom; // Random generation yields; serialize it so concurrent backends cannot capture distinct bootstrap secrets. const secretRef = yield* SynchronizedRef.make(Option.none()); + const hostScope = yield* Effect.cached( + readOrCreateHostScope.pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), + Effect.provideService(SecureRandom.SecureRandom, random), + ), + ); const getOrCreateBootstrapSecret = SynchronizedRef.modifyEffect(secretRef, (current) => Option.match(current, { onSome: (secret) => Effect.succeed([secret, current] as const), @@ -786,6 +827,7 @@ export const make = Effect.gen(function* () { bootstrapToken, bootstrapSecret, observabilitySettings, + hostScope: yield* hostScope, } satisfies SharedBootstrapInput; }); diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 8a8389082ce89..06a1f768f4bd4 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -378,6 +378,42 @@ describe("DesktopBackendManager", () => { }), ); + it.effect("becomes ready from the server ready line while HTTP probes stall", () => + Effect.scoped( + Effect.gen(function* () { + const probeStarted = yield* Deferred.make(); + const exit = yield* Deferred.make(); + const readyLine = encodeDesktopTelemetryControl({ version: 1, type: "serverReady" }); + const instance = yield* makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + getOutputFd: (fd) => + fd === 5 + ? Stream.fromEffect(Deferred.await(probeStarted)).pipe( + Stream.flatMap(() => Stream.encodeText(Stream.make(`${readyLine}\n`))), + ) + : Stream.empty, + exitCode: Deferred.await(exit), + kill: () => + Deferred.succeed(exit, ChildProcessSpawner.ExitCode(0)).pipe(Effect.asVoid), + }), + ), + ), + ), + httpClientLayer: layerHttpClient(() => + Deferred.succeed(probeStarted, undefined).pipe(Effect.andThen(Effect.never)), + ), + }); + + yield* instance.start; + assert.isTrue(yield* instance.waitForReady(Duration.seconds(15))); + }), + ), + ); + it.effect("continues routing desktop telemetry control messages after an invalid line", () => Effect.gen(function* () { const handled = yield* Deferred.make(); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 89ebc9e862ecf..4acaa8a3c18b8 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -1,5 +1,6 @@ import * as Brand from "effect/Brand"; import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -484,21 +485,23 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( ), ); const outputFibers: Array> = []; + const serverReady = yield* Deferred.make(); yield* options.onStarted?.(handle.pid) ?? Effect.void; - if ( - options.bootstrap.desktopTelemetryControlFd !== undefined && - options.onDesktopTelemetryControl !== undefined - ) { + if (options.bootstrap.desktopTelemetryControlFd !== undefined) { const controlFd = options.bootstrap.desktopTelemetryControlFd; - const handleControl = options.onDesktopTelemetryControl; + const handleControl = options.onDesktopTelemetryControl ?? (() => Effect.void); yield* handle.getOutputFd(controlFd).pipe( Stream.decodeText(), Stream.splitLines, Stream.filter((line) => line.trim().length > 0), Stream.runForEach((line) => decodeDesktopTelemetryControlLine(line).pipe( - Effect.flatMap(handleControl), + Effect.flatMap((message) => + message.type === "serverReady" + ? Deferred.succeed(serverReady, undefined) + : handleControl(message), + ), Effect.catchCause((cause) => logBackendProcessWarning("ignored invalid desktop telemetry control message", { fd: controlFd, @@ -570,14 +573,18 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( ); } // Cold WSL boots across /mnt/c can exceed one probe budget; retry while the backend is alive. + // HTTP polling stays as the fallback for backends without the control fd, such as WSL. const probeReadiness = Effect.fn("desktop.backendProcess.probeReadiness")(() => - waitForHttpReady({ - executablePath: options.executablePath, - entryPath: options.entryPath, - cwd: options.cwd, - httpBaseUrl: options.httpBaseUrl, - timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, - }).pipe( + Effect.raceFirst( + waitForHttpReady({ + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, + timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, + }), + Deferred.await(serverReady), + ).pipe( Effect.flatMap(() => options.onReady?.(handle.pid) ?? Effect.void), Effect.as(true), Effect.catchTags({ diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index 4ff3df6bf10f0..540326346edf8 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -1,4 +1,4 @@ -import type { ContextMenuItem } from "@t3tools/contracts"; +import type { ContextMenuItem, ContextMenuSource } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -17,6 +17,7 @@ export interface ElectronMenuContextInput { readonly window: Electron.BrowserWindow; readonly items: readonly ContextMenuItem[]; readonly position: Option.Option; + readonly source: Option.Option; } export interface ElectronMenuTemplateInput { @@ -259,7 +260,11 @@ export const make = Effect.gen(function* () { callback: () => complete(Option.none()), }), }); - menu.popup(popupOptions); + menu.popup( + Option.isSome(input.source) + ? { ...popupOptions, sourceType: input.source.value } + : popupOptions, + ); } catch (cause) { if (completed) { return; diff --git a/apps/desktop/src/electron/ElectronWindow.test.ts b/apps/desktop/src/electron/ElectronWindow.test.ts index 9eb41ed4e2402..eac944722e73b 100644 --- a/apps/desktop/src/electron/ElectronWindow.test.ts +++ b/apps/desktop/src/electron/ElectronWindow.test.ts @@ -148,6 +148,34 @@ describe("ElectronWindow", () => { }).pipe(Effect.provide(layerTestFor("win32"))), ); + it.effect("reports which windows were created with a title bar overlay", () => + Effect.gen(function* () { + const framed = { isDestroyed: () => false }; + const overlay = { isDestroyed: () => false }; + browserWindowMock + .mockImplementationOnce(function FramedWindowMock() { + return framed; + }) + .mockImplementationOnce(function OverlayWindowMock() { + return overlay; + }); + const electronWindow = yield* ElectronWindow.ElectronWindow; + yield* electronWindow.create({}); + yield* electronWindow.create({ + titleBarStyle: "hidden", + titleBarOverlay: { color: "#01000000", height: 40 }, + }); + getAllWindowsMock.mockReturnValue([framed, overlay, { isDestroyed: () => false }]); + + const seen: Array = []; + yield* electronWindow.syncAllAppearance((_window, { titleBarOverlay }) => + Effect.sync(() => seen.push(titleBarOverlay)), + ); + + assert.deepEqual(seen, [false, true, false]); + }).pipe(Effect.provide(layerTestFor("win32"))), + ); + it.effect("preserves destroy failures and continues with later windows", () => Effect.gen(function* () { const cause = new Error("window destroy failed"); diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index 15a117a52b050..b5f89969e66c7 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -125,7 +125,10 @@ export class ElectronWindow extends Context.Service< readonly sendAll: (channel: string, ...args: readonly unknown[]) => Effect.Effect; readonly destroyAll: Effect.Effect; readonly syncAllAppearance: ( - sync: (window: Electron.BrowserWindow) => Effect.Effect, + sync: ( + window: Electron.BrowserWindow, + appearance: { readonly titleBarOverlay: boolean }, + ) => Effect.Effect, ) => Effect.Effect; } >()("@t3tools/desktop/electron/ElectronWindow") {} @@ -145,6 +148,7 @@ export const make = Effect.gen(function* () { yield* Effect.addFinalizer(() => Effect.sync(() => windowsForegroundFocus?.close())); const mainWindowRef = yield* Ref.make>(Option.none()); const auxiliaryWindows = new WeakSet(); + const titleBarOverlayWindows = new WeakSet(); const listWindows = Effect.try({ try: () => Electron.BrowserWindow.getAllWindows(), @@ -241,6 +245,9 @@ export const make = Effect.gen(function* () { try: () => { const window = new Electron.BrowserWindow(options); if (behavior?.auxiliary === true) auxiliaryWindows.add(window); + if (options.titleBarOverlay !== undefined && options.titleBarOverlay !== false) { + titleBarOverlayWindows.add(window); + } return window; }, catch: (cause) => new ElectronWindowCreateError({ options: diagnosticOptions, cause }), @@ -383,14 +390,17 @@ export const make = Effect.gen(function* () { } }), syncAllAppearance: Effect.fn("desktop.electron.window.syncAllAppearance")(function* ( - sync: (window: Electron.BrowserWindow) => Effect.Effect, + sync: ( + window: Electron.BrowserWindow, + appearance: { readonly titleBarOverlay: boolean }, + ) => Effect.Effect, ) { const windows = yield* listWindows; for (const window of windows) { if (yield* isWindowDestroyed(window)) { continue; } - yield* sync(window); + yield* sync(window, { titleBarOverlay: titleBarOverlayWindows.has(window) }); } }), }); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 89bed268f23b4..a1c041f10c209 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -52,6 +52,7 @@ import { probeRemoteEditors, revealWindow, pickFolder, + toLocalBackendPath, pickProjectFavicon, pickThemeFiles, setTheme, @@ -147,6 +148,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setWslOnly); yield* ipc.handle(pickFolder); + yield* ipc.handle(toLocalBackendPath); yield* ipc.handle(pickProjectFavicon); yield* ipc.handle(pickThemeFiles); yield* ipc.handle(setTheme); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index e5a2565aafe87..a5b284722f9ba 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -1,4 +1,5 @@ export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; +export const TO_LOCAL_BACKEND_PATH_CHANNEL = "desktop:to-local-backend-path"; export const SET_NOTIFICATION_BADGE_CHANNEL = "desktop:set-notification-badge"; export const PICK_PROJECT_FAVICON_CHANNEL = "desktop:pick-project-favicon"; export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 376f85d2baf9d..fbeda6d2f2dcd 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -25,6 +25,7 @@ import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import * as DesktopManagedPolicy from "../../app/DesktopManagedPolicy.ts"; +import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as IpcChannels from "../channels.ts"; @@ -36,6 +37,7 @@ import { probeRemoteEditors, readMedia, syncHtmlFullscreen, + toLocalBackendPath, } from "./window.ts"; const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = { @@ -185,6 +187,47 @@ describe("getLocalEnvironmentBootstraps", () => { }); }); +describe("toLocalBackendPath", () => { + const { runningDistro: _, ...windowsConfig } = readyWslConfig; + it.effect("converts paths through wslpath only for backends running in WSL", () => + Effect.gen(function* () { + const convert = (backendId: string, path = "C:\\Users\\me\\project") => + toLocalBackendPath.handler({ backendId, path }); + assert.strictEqual(yield* convert("primary"), "C:\\Users\\me\\project"); + assert.strictEqual(yield* convert("wsl:default"), "/drives/c/Users/me/project"); + assert.strictEqual(yield* convert("wsl:missing"), null); + assert.strictEqual( + yield* convert("wsl:default", "\\\\wsl.localhost\\ubuntu\\home\\me\\repo"), + "/home/me/repo", + ); + assert.strictEqual(yield* convert("wsl:default", "\\\\wsl$\\Debian\\home\\me\\repo"), null); + assert.strictEqual( + yield* convert("primary", "\\\\wsl$\\Debian\\home\\me\\repo"), + "\\\\wsl$\\Debian\\home\\me\\repo", + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + DesktopBackendPool.layerTest([ + { + ...defaultWslInstance, + id: DesktopBackendManager.BackendInstanceId("primary"), + currentConfig: Effect.succeedSome(windowsConfig), + }, + defaultWslInstance, + ]), + DesktopWslEnvironment.layerTest({ + windowsToWslPath: (distro, windowsPath) => + distro === "Ubuntu" && windowsPath === "C:\\Users\\me\\project" + ? Option.some("/drives/c/Users/me/project") + : Option.none(), + }), + ), + ), + ), + ); +}); + describe("installRelayTelemetryEnabled", () => { // The renderer reads this over sync IPC, so the answer must not wait on the filesystem. const relayTelemetryEnabled = ( diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 53a1138d71a99..cee92e1ead38c 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -1,5 +1,6 @@ import { ContextMenuItemSchema, + ContextMenuSource, DesktopAppBrandingSchema, DesktopEnvironmentBootstrapSchema, DesktopMediaReadSchema, @@ -25,6 +26,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as DesktopBackendConfiguration from "../../backend/DesktopBackendConfiguration.ts"; +import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; import * as DesktopLocalEnvironmentAuth from "../../backend/DesktopLocalEnvironmentAuth.ts"; import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; @@ -58,6 +60,7 @@ const ContextMenuPosition = Schema.Struct({ const ContextMenuInput = Schema.Struct({ items: Schema.Array(ContextMenuItemSchema), position: Schema.optionalKey(ContextMenuPosition), + source: Schema.optionalKey(ContextMenuSource), }); function toWebSocketBaseUrl(httpBaseUrl: URL): string { @@ -204,6 +207,20 @@ export const getLocalEnvironmentBearerToken = DesktopIpc.makeIpcMethod({ }), }); +// Ask the distro: its automount root is configurable, so /mnt/ is only the default. +const windowsPathToWsl = Effect.fn("desktop.ipc.window.windowsPathToWsl")(function* ( + distro: string | null, + windowsPath: string, +) { + const linuxUncPath = wslUncPathToLinuxPath(windowsPath); + if (linuxUncPath !== null) return Option.some(linuxUncPath); + const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; + return yield* wslEnvironment.windowsToWslPath( + extractDistroFromUncPath(windowsPath) ?? distro, + windowsPath, + ); +}); + export const pickFolder = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PICK_FOLDER_CHANNEL, payload: Schema.UndefinedOr(PickFolderOptionsSchema), @@ -250,20 +267,29 @@ export const pickFolder = DesktopIpc.makeIpcMethod({ if (!useWsl) { return selectedPath.value; } - - const linuxUncPath = wslUncPathToLinuxPath(selectedPath.value); - if (linuxUncPath !== null) { - return linuxUncPath; - } - - const converted = yield* wslEnvironment.windowsToWslPath( - extractDistroFromUncPath(selectedPath.value) ?? wslDistro, - selectedPath.value, - ); + const converted = yield* windowsPathToWsl(wslDistro, selectedPath.value); return Option.getOrElse(converted, () => selectedPath.value); }), }); +export const toLocalBackendPath = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.TO_LOCAL_BACKEND_PATH_CHANNEL, + payload: Schema.Struct({ backendId: Schema.String, path: Schema.String }), + result: Schema.NullOr(Schema.String), + handler: Effect.fn("desktop.ipc.window.toLocalBackendPath")(function* ({ backendId, path }) { + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const instance = yield* pool.get(DesktopBackendManager.BackendInstanceId(backendId)); + if (Option.isNone(instance)) return null; + const config = yield* instance.value.currentConfig; + if (Option.isNone(config) || Option.isSome(config.value.preflightFailure)) return null; + const { runningDistro } = config.value; + if (runningDistro === undefined) return path; + const uncDistro = extractDistroFromUncPath(path); + if (uncDistro !== null && uncDistro.toLowerCase() !== runningDistro.toLowerCase()) return null; + return Option.getOrNull(yield* windowsPathToWsl(runningDistro, path)); + }), +}); + export const pickProjectFavicon = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PICK_PROJECT_FAVICON_CHANNEL, payload: Schema.UndefinedOr(Schema.String), @@ -316,6 +342,7 @@ export const showContextMenu = DesktopIpc.makeIpcMethod({ window: window.value, items: input.items, position: Option.fromNullishOr(input.position), + source: Option.fromNullishOr(input.source), }); return Option.getOrNull(selectedItemId); }), diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index c405fb6ae91b4..0bcbadfd89770 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -12,7 +12,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; // @effect-diagnostics-next-line nodeBuiltinImport:off - Version output must flush before Electron exits, without acquiring the runtime. import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -126,6 +128,15 @@ const layerDesktopSshEnvironment = Layer.unwrap( const environment = yield* DesktopEnvironment.DesktopEnvironment; return DesktopSshEnvironment.layer({ resolveCliRunner: Effect.succeed(resolveDesktopSshCliRunner(environment)), + resolveLauncherId: yield* Effect.cached( + DesktopSshEnvironment.readOrCreateLauncherId( + environment.path.join(environment.stateDir, "ssh-launcher-id"), + ).pipe( + Effect.provide( + yield* Effect.context(), + ), + ), + ), }); }), ); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index d4f5e27261eb8..b233d6638b500 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -238,14 +238,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { setWslDistro: (distro) => ipcRenderer.invoke(IpcChannels.SET_WSL_DISTRO_CHANNEL, distro), setWslOnly: (enabled) => ipcRenderer.invoke(IpcChannels.SET_WSL_ONLY_CHANNEL, enabled), pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options), + toLocalBackendPath: (backendId, path) => + ipcRenderer.invoke(IpcChannels.TO_LOCAL_BACKEND_PATH_CHANNEL, { backendId, path }), pickProjectFavicon: (initialPath) => ipcRenderer.invoke(IpcChannels.PICK_PROJECT_FAVICON_CHANNEL, initialPath), pickThemeFiles: () => ipcRenderer.invoke(IpcChannels.PICK_THEME_FILES_CHANNEL, undefined), setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme), - showContextMenu: (items, position) => + showContextMenu: (items, position, source) => ipcRenderer.invoke(IpcChannels.CONTEXT_MENU_CHANNEL, { items, ...(position === undefined ? {} : { position }), + ...(source === undefined ? {} : { source }), }), receiveProviderAuthCallback: (url: string) => ipcRenderer.invoke(IpcChannels.RECEIVE_PROVIDER_AUTH_CALLBACK_CHANNEL, url), diff --git a/apps/desktop/src/preview/CdpRelay.test.ts b/apps/desktop/src/preview/CdpRelay.test.ts index ccc2b868720b4..564b4dc57d6f4 100644 --- a/apps/desktop/src/preview/CdpRelay.test.ts +++ b/apps/desktop/src/preview/CdpRelay.test.ts @@ -9,6 +9,7 @@ import { const makeTarget = ( send: CdpRelayTarget["send"], setDownloadDirectory: CdpRelayTarget["setDownloadDirectory"] = () => {}, + ackScreencastFrame: CdpRelayTarget["ackScreencastFrame"] = () => {}, ): CdpRelayTarget => ({ send, targetId: async () => "GUEST-TARGET", @@ -17,6 +18,7 @@ const makeTarget = ( userAgent: () => "Electron", setDownloadDirectory, receivesScreencast: () => true, + ackScreencastFrame, }); const makeRelay = (target: CdpRelayTarget, write: (raw: string) => void) => @@ -138,9 +140,13 @@ describe("CDP relay", () => { describe("CDP relay extra sessions", () => { it("gives a second page session the page's commands and events", async () => { - const send = vi.fn(async () => ({})); + const send = vi.fn(async () => ({})); const written: Array> = []; - const relay = makeRelay(makeTarget(send), (raw) => written.push(JSON.parse(raw))); + const nativeAcks: Array = []; + const relay = makeRelay( + makeTarget(send, undefined, (frameId) => nativeAcks.push(frameId)), + (raw) => written.push(JSON.parse(raw)), + ); relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} })); relay.receive(JSON.stringify({ id: 2, method: "Target.attachToBrowserTarget" })); await settle(); @@ -170,8 +176,63 @@ describe("CDP relay extra sessions", () => { await settle(); expect(send).toHaveBeenLastCalledWith("Page.startScreencast", {}, undefined, extra); written.length = 0; - relay.event("Page.screencastFrame", { data: "x" }, undefined); - expect(written.map((m) => m["sessionId"])).toEqual(["t3-preview-page", extra]); + const frame = (frameId: number) => + relay.event("Page.screencastFrame", { data: "x", sessionId: frameId }, undefined); + const ack = (sessionId: string, frameId: number) => + relay.receive( + JSON.stringify({ + id: 6, + method: "Page.screencastFrameAck", + params: { sessionId: frameId }, + sessionId, + }), + ); + const delivered = () => { + const frames = written + .filter((m) => m["method"]) + .map((m) => [m["sessionId"], (m["params"] as { sessionId?: number }).sessionId]); + written.length = 0; + return frames; + }; + frame(1); + expect(delivered()).toEqual([ + ["t3-preview-page", 1], + [extra, 1], + ]); + // A session that has not acknowledged its frame gets only the newest one, on its ack. + for (let i = 2; i < 100; i++) frame(i); + expect(delivered()).toEqual([]); + // Frames every session is busy for stay unacknowledged, so Chromium pauses at its in-flight limit. + expect(nativeAcks).toEqual([1]); + ack(extra, 1); + await settle(); + expect(delivered()).toEqual([[extra, 99]]); + // Each consumer ack lets Chromium send one more frame. + expect(nativeAcks).toEqual([1, 2]); + ack(extra, 99); + await settle(); + expect(delivered()).toEqual([]); + expect(nativeAcks).toEqual([1, 2, 3]); + frame(100); + expect(delivered()).toEqual([[extra, 100]]); + ack(extra, 99); + await settle(); + frame(101); + expect(delivered()).toEqual([]); + expect(send.mock.calls.some(([method]) => method === "Page.screencastFrameAck")).toBe(false); + // A restart keeps the unacknowledged frame's slot until its own acknowledgement. + relay.receive(JSON.stringify({ id: 7, method: "Page.stopScreencast", sessionId: extra })); + relay.receive(JSON.stringify({ id: 8, method: "Page.startScreencast", sessionId: extra })); + await settle(); + delivered(); + frame(102); + expect(delivered()).toEqual([]); + ack(extra, 100); + await settle(); + expect(delivered()).toEqual([[extra, 102]]); + // Other events still reach a session while its frame is unacknowledged. + relay.event("Page.loadEventFired", { timestamp: 1 }, undefined); + expect(delivered().map(([session]) => session)).toEqual(["t3-preview-page", extra]); }); }); diff --git a/apps/desktop/src/preview/CdpRelay.ts b/apps/desktop/src/preview/CdpRelay.ts index 658e1cf70623d..e9f3cf509b446 100644 --- a/apps/desktop/src/preview/CdpRelay.ts +++ b/apps/desktop/src/preview/CdpRelay.ts @@ -11,6 +11,8 @@ export interface CdpRelayTarget { readonly sessionDetached?: (owner: string) => void; /** Whether a page session, by owner as `send` names it, gets the page's screencast frames. */ readonly receivesScreencast: (owner: string | undefined) => boolean; + /** Acknowledges a native screencast frame, letting Chromium send the next one. */ + readonly ackScreencastFrame: (frameId: number) => void; /** The page's own CDP target id; Playwright keys the main frame by it. */ readonly targetId: () => Promise; readonly url: () => string; @@ -103,6 +105,20 @@ export function createCdpRelayConnection( let downloadEventsEnabled = false; /** Relay-created page sessions share one real debugger; child frame sessions retain their own ids. */ const sessions = new Map([[PAGE_SESSION_ID, "page"]]); + // Each page session holds one unacknowledged frame and keeps the newest one skipped meanwhile. + const unackedFrames = new Map(); + const skippedFrames = new Map>(); + // Chromium keeps a few frames in flight and reuses one frame id per run, so pacing it means + // leaving frames every session is busy for unacknowledged and releasing one per consumer ack. + const heldNativeFrames: Array = []; + const releaseHeldFramesWithoutHolders = () => { + if (skippedFrames.size === 0) + for (const frameId of heldNativeFrames.splice(0)) target.ackScreencastFrame(frameId); + }; + const sendFrame = (sessionId: string, params: Record) => { + unackedFrames.set(sessionId, params["sessionId"] as number); + send({ method: "Page.screencastFrame", params, sessionId }); + }; let sessionSequence = 0; const newSession = (kind: "browser" | "page") => { const sessionId = `t3-preview-${kind}-${++sessionSequence}`; @@ -170,6 +186,9 @@ export function createCdpRelayConnection( const sessionId = command.params?.["sessionId"]; if (typeof sessionId === "string" && sessionId !== PAGE_SESSION_ID) { sessions.delete(sessionId); + unackedFrames.delete(sessionId); + skippedFrames.delete(sessionId); + releaseHeldFramesWithoutHolders(); target.sessionDetached?.(sessionId); } return {}; @@ -202,6 +221,27 @@ export function createCdpRelayConnection( } // Playwright asks for this on every page; the host emulates focus only while it is in use. if (command.method === "Emulation.setFocusEmulationEnabled") return Promise.resolve({}); + if (command.method === "Page.screencastFrameAck" && child === undefined) { + // A restart keeps the slot too, or restarting would let frames past an unacknowledged one. + const sessionId = command.sessionId!; + if (unackedFrames.get(sessionId) === command.params?.["sessionId"]) { + unackedFrames.delete(sessionId); + const skipped = skippedFrames.get(sessionId); + skippedFrames.delete(sessionId); + if ( + skipped !== undefined && + target.receivesScreencast(sessionId === PAGE_SESSION_ID ? undefined : sessionId) + ) + sendFrame(sessionId, skipped); + const released = heldNativeFrames.shift(); + if (released !== undefined) target.ackScreencastFrame(released); + } + return Promise.resolve({}); + } + if (command.method === "Page.stopScreencast" && child === undefined) { + skippedFrames.delete(command.sessionId!); + releaseHeldFramesWithoutHolders(); + } // The shared debugger keeps Runtime enabled after an earlier connection, and a repeated // enable reports no existing contexts; Playwright waits for them, so reset once per connection. if (command.method === "Runtime.enable" && child === undefined) { @@ -262,15 +302,29 @@ export function createCdpRelayConnection( send({ method, params, sessionId }); return; } - for (const [id, kind] of sessions) { - if (kind !== "page") continue; - if ( - method === "Page.screencastFrame" && - !target.receivesScreencast(id === PAGE_SESSION_ID ? undefined : id) - ) - continue; - send({ method, params, sessionId: id }); + if (method === "Page.screencastFrame") { + const frame = (params ?? {}) as Record; + const frameId = frame["sessionId"]; + if (typeof frameId !== "number") return; + let held = false; + let delivered = false; + for (const [id, kind] of sessions) { + if (kind !== "page") continue; + if (!target.receivesScreencast(id === PAGE_SESSION_ID ? undefined : id)) continue; + if (unackedFrames.has(id)) { + skippedFrames.set(id, frame); + held = true; + } else { + sendFrame(id, frame); + delivered = true; + } + } + if (held && !delivered) heldNativeFrames.push(frameId); + else target.ackScreencastFrame(frameId); + return; } + for (const [id, kind] of sessions) + if (kind === "page") send({ method, params, sessionId: id }); }, }; } diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 393e4cccccf9c..cf6c7736f81d3 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -1157,6 +1157,13 @@ describe("DesktopBrowserHost", () => { debuggee.refuse(6); debuggee.release(); expect([yield* Queue.take(replies), yield* Queue.take(replies)].toSorted()).toEqual([5, 6]); + yield* raw({ + id: 7, + method: "Page.screencastFrameAck", + params: { sessionId: 1 }, + sessionId: "t3-preview-page-1", + }); + expect(yield* Queue.take(replies)).toBe(7); yield* settle; frames.length = 0; debuggee.emit("Page.screencastFrame", { data: "frame", sessionId: 2 }); @@ -1298,6 +1305,41 @@ describe("DesktopBrowserHost", () => { }), ); + it.effect("stops crediting the agent once a person acts through a viewer", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + const viewer = (id: number, method: string, type: string) => + raw({ id, method, params: { type }, sessionId: "t3-preview-page-1" }); + const led = () => host.agentLedPage(debuggee.tab.webContents); + + yield* command(host, 1, "DOM.getDocument"); + expect(led()).toBe(false); + yield* raw({ id: 2, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(2); + yield* raw({ id: 3, method: "Page.startScreencast", sessionId: "t3-preview-page-1" }); + + for (const [method, type] of [ + ["Input.dispatchKeyEvent", "rawKeyDown"], + ["Input.dispatchMouseEvent", "mousePressed"], + ["Input.dispatchTouchEvent", "touchStart"], + ] as const) { + yield* command(host, 4, "Runtime.evaluate"); + expect(led()).toBe(true); + // The viewer's pointer only passes over the page the agent drives. + yield* viewer(5, "Input.dispatchMouseEvent", "mouseMoved"); + yield* viewer(6, "Input.dispatchMouseEvent", "mouseWheel"); + expect(led()).toBe(true); + yield* viewer(7, method, type); + expect(led()).toBe(false); + } + }).pipe(Effect.scoped), + ); + it.effect("detaches only the tab whose CDP event is too large to relay", () => Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index e5f43634d204a..e0326b7b76ec1 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -26,7 +26,9 @@ const AGENT_INPUT_COMMAND = /"method":"(?:Input\.|Page\.navigate"|Runtime\.(?:evaluate|callFunctionOn)")/; /** The same commands as above, by method, for the relay's sends. */ const AGENT_INPUT_METHOD = /^(?:Input\.|Page\.navigate$|Runtime\.(?:evaluate|callFunctionOn)$)/; -const AGENT_DOWNLOAD_WINDOW_MS = 5_000; +const AGENT_INPUT_WINDOW_MS = 5_000; +/** A viewer's pointer travelling over the stream activates nothing on the page. */ +const POINTER_TRAVEL = new Set(["mouseMoved", "mouseWheel"]); const FOCUS_IDLE = "5 seconds"; /** Well inside the server's 10 s DESKTOP_ATTACH_TIMEOUT, for a first page that never reports it is up. */ const FIRST_PAGE_TIMEOUT = "5 seconds"; @@ -60,6 +62,8 @@ interface AttachedTab { downloadDirectory: string | null; pendingDownloadGuid: string | null; agentInputAt: number; + /** When the agent last drove the page with no person's input through a viewer since. */ + agentLedAt: number; focusEmulated: boolean; focusChange: Promise | null; focusUses: number; @@ -98,6 +102,8 @@ export class DesktopBrowserHost extends Context.Service< readonly loadFailed: (key: DesktopBrowserTabKey, failure: PreviewAutomationLoadError) => void; readonly setShown: (key: DesktopBrowserTabKey, shown: boolean | undefined) => void; readonly humanStartedDownload: (source: Electron.WebContents) => boolean; + /** Whether what the page does now follows the agent's input, not a viewer's. */ + readonly agentLedPage: (source: Electron.WebContents) => boolean; readonly placeDownload: (source: Electron.WebContents, item: Electron.DownloadItem) => boolean; readonly pointers: Stream.Stream<{ readonly key: DesktopBrowserTabKey; @@ -351,11 +357,13 @@ export const make = Effect.gen(function* () { if (tab.screencasts.size > 0) return Promise.resolve({}); } // A person's input through a viewer, or Playwright's own housekeeping, is not the agent's. - if ( - AGENT_INPUT_METHOD.test(method) && - (owner === undefined || !streamingOwners.has(owner)) - ) { - tab.agentUsed = true; + if (AGENT_INPUT_METHOD.test(method)) { + if (owner === undefined || !streamingOwners.has(owner)) { + tab.agentUsed = true; + tab.agentLedAt = performance.now(); + } else if (method.startsWith("Input.") && !POINTER_TRAVEL.has(params["type"])) { + tab.agentLedAt = Number.NEGATIVE_INFINITY; + } } // Waits for focus emulation; insertText and other non-CDP input would not queue behind it. const forward = (current?: () => boolean) => { @@ -413,6 +421,10 @@ export const make = Effect.gen(function* () { dropSessionHolds(tab, owner); }, receivesScreencast: (owner) => tab.screencasts.has(owner), + ackScreencastFrame: (frameId) => + void debuggee + .sendCommand("Page.screencastFrameAck", { sessionId: frameId }) + .catch(() => {}), targetId: () => debuggee .sendCommand("Target.getTargetInfo") @@ -490,6 +502,7 @@ export const make = Effect.gen(function* () { downloadDirectory: null, pendingDownloadGuid: null, agentInputAt: Number.NEGATIVE_INFINITY, + agentLedAt: Number.NEGATIVE_INFINITY, focusEmulated: false, focusChange: null, focusUses: 0, @@ -604,7 +617,12 @@ export const make = Effect.gen(function* () { const humanStartedDownload = (source: Electron.WebContents) => { const tab = [...tabs.values()].find((candidate) => candidate.debuggee.webContents === source); - return tab !== undefined && performance.now() - tab.agentInputAt > AGENT_DOWNLOAD_WINDOW_MS; + return tab !== undefined && performance.now() - tab.agentInputAt > AGENT_INPUT_WINDOW_MS; + }; + + const agentLedPage = (source: Electron.WebContents) => { + const tab = [...tabs.values()].find((candidate) => candidate.debuggee.webContents === source); + return tab !== undefined && performance.now() - tab.agentLedAt <= AGENT_INPUT_WINDOW_MS; }; const announceAll = Effect.suspend(() => { @@ -647,6 +665,7 @@ export const make = Effect.gen(function* () { setShown, placeDownload, humanStartedDownload, + agentLedPage, }); }); diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts index 0a9f4cb85c008..1d24079d1947f 100644 --- a/apps/desktop/src/preview/GuestProtocol.ts +++ b/apps/desktop/src/preview/GuestProtocol.ts @@ -5,7 +5,6 @@ export const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; export const ANNOTATION_SEND_ENABLED_CHANNEL = "preview:annotation-send-enabled"; export const HUMAN_INPUT_CHANNEL = "preview:human-input"; -export const MOUSE_NAVIGATE_CHANNEL = "preview:mouse-navigate"; // Sent to the hosting element (ipc-message). The web app mirrors this // literal in HostedBrowserWebview because it cannot import from the desktop app. export const POINTER_DOWN_HOST_CHANNEL = "preview:pointer-down"; @@ -15,6 +14,7 @@ export const RECORDING_POINTER_CHANNEL = "preview:recording-pointer"; export const RECORDING_KEY_CHANNEL = "preview:recording-key"; export const RECORDING_INPUT_CHANNEL = "preview:recording-input"; export const RECORDING_CONTROLLER_CHANNEL = "preview:recording-controller"; +export const THUMB_PRESS_CHANNEL = "preview:thumb-press"; export const PASSKEY_CREATE_CHANNEL = "preview:passkey-create"; export const PASSKEY_GET_CHANNEL = "preview:passkey-get"; export const PASSKEY_BRIDGE_ARGUMENT = "--t3code-preview-passkey-bridge"; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index b344b1ac93b2b..64b584fbed7f6 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -349,6 +349,47 @@ const makeTestPreviewWebContents = ( } as unknown as TestPreviewWebContents; }; +const makeStillImage = (pixels = "fresh-png") => ({ + toPNG: () => Buffer.from(pixels), + toJPEG: () => Buffer.from(pixels), + getSize: () => ({ width: 100, height: 80 }), +}); + +const makeTestStillImageWebContents = (...args: Parameters) => { + const wc = makeTestPreviewWebContents(...args); + Object.assign(wc, { isDevToolsOpened: () => false }); + holdStillImagePaint(wc, Promise.resolve()); + return wc; +}; + +const holdStillImagePaint = ( + wc: TestPreviewWebContents, + paint: Promise | Array>, +) => { + vi.mocked(wc.debugger.sendCommand).mockImplementation(async (method) => { + if (method === "Page.getFrameTree") return { frameTree: { frame: { id: "main" } } }; + if (method === "Page.createIsolatedWorld") return { executionContextId: 7 }; + if (method === "Runtime.evaluate") { + await (Array.isArray(paint) ? paint.shift() : paint); + return { result: { value: true } }; + } + return undefined; + }); +}; + +const setupStill = (manager: PreviewManager.PreviewManager["Service"], paint = Promise.resolve()) => + Effect.gen(function* () { + const image = makeStillImage(); + const capturePage = vi.fn<() => Promise>().mockResolvedValue(image); + const wc = makeTestStillImageWebContents(capturePage); + holdStillImagePaint(wc, paint); + fromId.mockReturnValue(wc); + yield* manager.createTab("tab_still"); + yield* manager.registerWebview("tab_still", wc.id); + yield* manager.setColorScheme("tab_still", "system"); + return { image, capturePage, wc, capture: manager.captureScreenshot("tab_still") }; + }); + const setupRecordingRaceTabs = (manager: PreviewManager.PreviewManager["Service"]) => Effect.gen(function* () { const capturePage = vi.fn(async () => ({ @@ -619,6 +660,7 @@ describe("PreviewManager", () => { setShown: () => {}, loadFailed: () => {}, humanStartedDownload: () => true, + agentLedPage: () => false, placeDownload: () => true, }), Layer.mock(PreviewPasskeys.PreviewPasskeys)({ @@ -745,6 +787,137 @@ describe("PreviewManager", () => { ), ); + effectIt.effect.each([ + "navigate", + "reload", + "goBack", + "goForward", + "refresh", + "hardReload", + ] as const)("%s stops a page script that never yields before it navigates", (operation) => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + // As in Chromium: the hung renderer answers nothing, and a terminate reaches it only + // while no navigation is pending. + let stopped = false; + const probed = yield* Deferred.make(); + const navigatedAfterStop: Array = []; + const navigated = () => { + navigatedAfterStop.push(stopped); + }; + const sendCommand = async (method: string) => { + if (method === "Runtime.evaluate") { + Deferred.doneUnsafe(probed, Effect.void); + await new Promise(() => {}); + } + if (method === "Runtime.terminateExecution") stopped = true; + }; + Object.assign(preview.webContents, { + getURL: () => "http://localhost:5173/busy", + reload: navigated, + reloadIgnoringCache: navigated, + loadURL: async () => navigated(), + navigationHistory: { + canGoBack: () => true, + canGoForward: () => true, + goBack: navigated, + goForward: navigated, + }, + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + }); + fromId.mockReturnValue(preview.webContents); + yield* manager.createTab("tab_busy"); + yield* manager.registerWebview("tab_busy", 42); + + const leaving = yield* ( + operation === "navigate" + ? manager.navigate("tab_busy", "http://localhost:5173/next") + : operation === "reload" + ? manager.navigate("tab_busy", "http://localhost:5173/busy") + : manager[operation]("tab_busy") + ).pipe(Effect.forkChild); + const probing = yield* Effect.raceFirst( + Deferred.await(probed).pipe(Effect.as(true)), + Fiber.await(leaving).pipe(Effect.as(false)), + ); + if (probing) yield* TestClock.adjust(1_000); + yield* Fiber.join(leaving); + + expect(navigatedAfterStop).toEqual([true]); + }), + ), + ); + + effectIt.effect("keeps mouse back and forward buttons inside the preview tab", () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + const goBack = vi.fn(); + const goForward = vi.fn(); + const send = vi.fn(); + let canGoBack = false; + Object.assign(preview.webContents, { + navigationHistory: { + canGoBack: () => canGoBack, + canGoForward: () => true, + goBack, + goForward, + }, + getZoomFactor: () => 2, + send, + }); + fromId.mockReturnValue(preview.webContents); + yield* manager.createTab("tab_mouse_navigation"); + yield* manager.registerWebview("tab_mouse_navigation", 42); + + // Electron reports thumb buttons as "back"/"forward" for every frame of the guest, iframes included. + const beforeMouse = preview.listeners.get("before-mouse-event"); + expect(beforeMouse, "guest has no before-mouse-event listener").toBeDefined(); + const press = (type: "mouseDown" | "mouseUp", button: string) => { + const preventDefault = vi.fn(); + beforeMouse!({ preventDefault } as never, { type, button, x: 10, y: 10 } as never); + return preventDefault; + }; + + // At the start of tab history the press is still swallowed, or the host app goes back instead. + expect(press("mouseDown", "back")).toHaveBeenCalledOnce(); + expect(press("mouseUp", "back")).toHaveBeenCalledOnce(); + expect(goBack).not.toHaveBeenCalled(); + + canGoBack = true; + expect(press("mouseDown", "back")).toHaveBeenCalledOnce(); + expect(goBack).not.toHaveBeenCalled(); + expect(press("mouseUp", "back")).toHaveBeenCalledOnce(); + expect(goBack).toHaveBeenCalledOnce(); + expect(press("mouseDown", "forward")).toHaveBeenCalledOnce(); + expect(press("mouseUp", "forward")).toHaveBeenCalledOnce(); + expect(goForward).toHaveBeenCalledOnce(); + for (const button of ["left", "right"]) { + expect(press("mouseDown", button)).not.toHaveBeenCalled(); + expect(press("mouseUp", button)).not.toHaveBeenCalled(); + } + expect(goBack).toHaveBeenCalledOnce(); + expect(goForward).toHaveBeenCalledOnce(); + // The guest replays dropped presses for human input and recordings, in CSS px. + expect(send.mock.calls.filter(([channel]) => channel === "preview:thumb-press")).toEqual([ + ["preview:thumb-press", "down", 5, 5, 3], + ["preview:thumb-press", "up", 5, 5, 3], + ["preview:thumb-press", "down", 5, 5, 3], + ["preview:thumb-press", "up", 5, 5, 3], + ["preview:thumb-press", "down", 5, 5, 4], + ["preview:thumb-press", "up", 5, 5, 4], + ]); + }), + ), + ); + effectIt.effect("keeps editing and forwarded shortcuts in whichever tab holds focus", () => withManager((manager) => Effect.gen(function* () { @@ -1692,6 +1865,30 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("captures final pixels after warmup fails and paint readiness settles", () => + withManager((manager) => + Effect.gen(function* () { + const paint = Promise.withResolvers(); + const { capturePage, wc, capture } = yield* setupStill(manager, paint.promise); + capturePage + .mockRejectedValueOnce(new Error("UnknownVizError")) + .mockRejectedValueOnce(new Error("UnknownVizError")) + .mockRejectedValueOnce(new Error("UnknownVizError")) + .mockResolvedValue(makeStillImage("final-png")); + const fiber = yield* capture.pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust(300); + expect(capturePage).toHaveBeenCalledTimes(3); + expect(writeFile).not.toHaveBeenCalled(); + expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]); + paint.resolve(); + const artifact = yield* Fiber.join(fiber); + expect(capturePage).toHaveBeenCalledTimes(4); + expect(writeFile.mock.calls).toEqual([[artifact.path, Buffer.from("final-png")]]); + expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); + }), + ), + ); + effectIt.effect("preserves both screenshots of the same site in the same millisecond", () => withManager((manager) => Effect.gen(function* () { @@ -1705,6 +1902,8 @@ describe("PreviewManager", () => { const capturePage = vi .fn<() => Promise>>() .mockResolvedValueOnce(pngImage(firstPng)) + .mockResolvedValueOnce(pngImage(firstPng)) + .mockResolvedValueOnce(pngImage(secondPng)) .mockResolvedValueOnce(pngImage(secondPng)); fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); yield* manager.createTab("tab_1"); @@ -1725,6 +1924,115 @@ describe("PreviewManager", () => { ), ); + effectIt.effect( + "waits for paint before capturing and restores rendering after overlapping requests", + () => + withManager((manager) => + Effect.gen(function* () { + const paints = [Promise.withResolvers(), Promise.withResolvers()]; + const pendingPaints = paints.map((paint) => paint.promise); + const { image, capturePage, wc, capture } = yield* setupStill(manager); + holdStillImagePaint(wc, pendingPaints); + const first = Promise.withResolvers(); + const second = Promise.withResolvers(); + capturePage + .mockResolvedValueOnce(makeStillImage("discarded-warmup")) + .mockResolvedValueOnce(makeStillImage("discarded-warmup")) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + const hostThrottling = vi.fn(); + yield* manager.setMainWindow({ + isDestroyed: () => false, + once: vi.fn(), + webContents: { setBackgroundThrottling: hostThrottling }, + } as never); + const one = yield* capture.pipe(Effect.forkChild({ startImmediately: true })); + const two = yield* capture.pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust(0); + expect(capturePage).toHaveBeenCalledTimes(2); + expect(pendingPaints).toHaveLength(0); + expect(writeFile).not.toHaveBeenCalled(); + expect(hostThrottling.mock.calls).toEqual([[false]]); + expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]); + paints.forEach((paint) => paint.resolve()); + yield* TestClock.adjust(0); + expect(capturePage).toHaveBeenCalledTimes(4); + first.resolve(image); + yield* Fiber.join(one); + expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]); + second.resolve(image); + yield* Fiber.join(two); + const saved = writeFile.mock.calls.map(([, bytes]) => bytes); + expect(saved).toEqual([image.toPNG(), image.toPNG()]); + expect(hostThrottling.mock.calls).toEqual([[false], [true]]); + expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); + }), + ), + ); + + effectIt.effect("keeps picture-in-picture rendering after a still image completes", () => + withManager((manager) => + Effect.gen(function* () { + const { image, capturePage, wc, capture } = yield* setupStill(manager); + const pending = Promise.withResolvers(); + capturePage + .mockResolvedValueOnce(image) + .mockResolvedValueOnce(makeStillImage("discarded-warmup")) + .mockReturnValueOnce(pending.promise); + const { pictureInPictureWindow } = makeTestPictureInPictureWindow(); + browserWindowConstructor.mockImplementation(function () { + return pictureInPictureWindow; + }); + yield* manager.openPictureInPicture("tab_still"); + const still = yield* capture.pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust(0); + expect(capturePage).toHaveBeenCalledTimes(3); + expect(writeFile).not.toHaveBeenCalled(); + pending.resolve(image); + yield* Fiber.join(still); + expect(writeFile.mock.calls.map((call) => call[1])).toEqual([Buffer.from("fresh-png")]); + expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]); + yield* manager.closePictureInPicture("tab_still"); + expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); + }), + ), + ); + + effectIt.effect("keeps a pending still image rendering when recording ends", () => + withManager((manager) => + Effect.gen(function* () { + const { image, capturePage, wc, capture } = yield* setupStill(manager); + const pending = Promise.withResolvers(); + capturePage.mockResolvedValueOnce(image).mockReturnValue(pending.promise); + yield* manager.startRecording("tab_still"); + const fiber = yield* capture.pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust(0); + yield* manager.stopRecording("tab_still"); + expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]); + pending.resolve(image); + yield* Fiber.join(fiber); + expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); + }), + ), + ); + + effectIt.effect("saves available pixels when paint readiness never settles", () => + withManager((manager) => + Effect.gen(function* () { + const paint = Promise.withResolvers(); + const { capturePage, wc, capture } = yield* setupStill(manager, paint.promise); + const first = yield* Effect.exit(capture).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust(1_001); + expect(Exit.isSuccess(yield* Fiber.join(first))).toBe(true); + expect(capturePage).toHaveBeenCalledTimes(2); + expect(writeFile.mock.calls.map((call) => call[1])).toEqual([Buffer.from("fresh-png")]); + expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); + }), + ), + ); + effectIt.effect("keeps every recorded guest unthrottled until its frame capture stops", () => withManager((manager) => Effect.gen(function* () { @@ -2133,6 +2441,71 @@ describe("PreviewManager", () => { }).pipe(Effect.provide(managerLayer())), ); + effectIt.effect( + "opens a new-tab link in the background only when the agent's input led to it", + () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.DesktopBrowserHost; + const serverTab = { threadId: "thread-1", tabId: "server-tab-1" }; + const contents = Object.assign( + makeTestPreviewWebContents(async () => { + throw new Error("unused"); + }, 42), + { + isDevToolsOpened: () => false, + getUserAgent: () => "Electron", + focusedFrame: null, + }, + ); + fromId.mockReturnValue(contents); + const manager = yield* PreviewManager.PreviewManager; + const openLinks = yield* Queue.unbounded(); + yield* manager.subscribeOpenLinks(({ background }) => + Queue.offer(openLinks, background).pipe(Effect.asVoid), + ); + const attached = yield* Deferred.make(); + yield* host.events.pipe( + Stream.runForEach((line) => + new TextDecoder().decode(line).includes('"type":"attached"') + ? Deferred.succeed(attached, undefined) + : Effect.void, + ), + Effect.forkScoped, + ); + yield* manager.createTab("tab_1", { serverTab }); + yield* manager.registerWebview("tab_1", 42); + yield* Deferred.await(attached); + const openWindow = vi.mocked(contents.setWindowOpenHandler).mock.calls[0]![0]; + const openBlankLink = Effect.suspend(() => { + openWindow({ + url: "https://example.com/next", + disposition: "foreground-tab", + } as Electron.HandlerDetails); + return Queue.take(openLinks); + }); + + expect(yield* openBlankLink).toBe(false); + + yield* host.handleCommandLine( + JSON.stringify({ + type: "cdp", + ...serverTab, + message: JSON.stringify({ + id: 1, + method: "Runtime.evaluate", + params: { expression: "document.querySelector('a').click()" }, + sessionId: "t3-preview-page", + }), + }), + ); + expect(yield* openBlankLink).toBe(true); + + // A person clicking in the guest itself gives it a focused frame. + Object.assign(contents, { focusedFrame: {} }); + expect(yield* openBlankLink).toBe(false); + }).pipe(Effect.provide(managerLayer()), Effect.scoped), + ); + effectIt.effect("reports early and subsequent load failures for the current guest", () => Effect.gen(function* () { const host = yield* DesktopBrowserHost.DesktopBrowserHost; @@ -2383,6 +2756,116 @@ describe("PreviewManager", () => { ), ); + effectIt.effect( + "publishes human ownership only on transitions while retaining every recording press", + () => + withManager((manager) => + Effect.gen(function* () { + let humanInput: ((input: unknown) => void) | undefined; + const contents = makeTestPreviewWebContents(async () => ({ + toJPEG: () => Buffer.from("frame"), + getSize: () => ({ width: 800, height: 600 }), + })); + contents.ipc.on = (channel, callback) => { + if (channel === "preview:human-input") + humanInput = (input) => callback({} as Electron.IpcMainEvent, input); + return contents.ipc; + }; + fromId.mockReturnValue(contents); + yield* manager.createTab("tab_human"); + yield* manager.registerWebview("tab_human", 42); + yield* manager.startRecording("tab_human", { + showKeyPresses: true, + showMousePresses: true, + }); + const controllers: string[] = []; + const feedback = yield* Queue.unbounded(); + webviewSend.mockImplementation((channel) => { + if (channel === "preview:recording-controller") Queue.offerUnsafe(feedback, undefined); + }); + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + controllers.push(state.controller); + }), + ); + for (const press of [ + { kind: "pointer", x: 10, y: 20, button: 0 }, + { kind: "pointer", x: 30, y: 40, button: 0 }, + { kind: "key", key: "a", code: "KeyA" }, + ]) { + humanInput?.(press); + yield* Queue.take(feedback); + } + expect(controllers).toEqual(["human"]); + expect( + webviewSend.mock.calls.filter( + ([channel]) => channel === "preview:recording-controller", + ), + ).toEqual([ + ["preview:recording-controller", "human", { x: 10, y: 20 }], + ["preview:recording-controller", "human", { x: 30, y: 40 }], + ["preview:recording-controller", "human", undefined], + ]); + yield* TestClock.adjust(750); + expect(controllers).toEqual(["human", "none"]); + }), + ), + ); + + effectIt.effect("keeps human ownership until the last input's idle deadline", () => + withManager((manager) => + Effect.gen(function* () { + let humanInput: ((input: unknown) => void) | undefined; + const contents = makeTestPreviewWebContents(async () => ({ + toJPEG: () => Buffer.from("frame"), + getSize: () => ({ width: 800, height: 600 }), + })); + contents.ipc.on = (channel, callback) => { + if (channel === "preview:human-input") + humanInput = (input) => callback({} as Electron.IpcMainEvent, input); + return contents.ipc; + }; + fromId.mockReturnValue(contents); + yield* manager.createTab("tab_idle"); + yield* manager.registerWebview("tab_idle", 42); + const controllers: string[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + controllers.push(state.controller); + }), + ); + const press = () => humanInput?.({ kind: "key", key: "a", code: "KeyA" }); + press(); + yield* TestClock.adjust(500); + press(); + yield* TestClock.adjust(749); + expect(controllers).toEqual(["human"]); + yield* TestClock.adjust(1); + expect(controllers).toEqual(["human", "none"]); + + press(); + yield* TestClock.adjust(500); + const staleInput = humanInput; + const replacement = makeTestPreviewWebContents( + async () => ({ + toJPEG: () => Buffer.from("frame"), + getSize: () => ({ width: 800, height: 600 }), + }), + 43, + ); + replacement.ipc.on = contents.ipc.on; + contents.isDestroyed = () => true; + fromId.mockImplementation((id) => (id === 43 ? replacement : null)); + yield* manager.registerWebview("tab_idle", 43); + expect(controllers.at(-1)).toBe("none"); + const count = controllers.length; + staleInput?.({ kind: "key", key: "b", code: "KeyB" }); + yield* TestClock.adjust(750); + expect(controllers).toHaveLength(count); + }), + ), + ); + effectIt.effect("gates recording decorations and isolates failed subscribers", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 0fc48165ffccf..957a00d6a4b6b 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -81,13 +81,13 @@ import { CANCEL_PICK_CHANNEL, ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, - MOUSE_NAVIGATE_CHANNEL, PAGE_UNHANDLED_SHORTCUT_CHANNEL, RECORDING_CURSOR_CHANNEL, RECORDING_INPUT_CHANNEL, RECORDING_CONTROLLER_CHANNEL, RECORDING_POINTER_CHANNEL, START_PICK_CHANNEL, + THUMB_PRESS_CHANNEL, } from "./GuestProtocol.ts"; import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; import { captureFavicon, safeHttpOrigin, selectFaviconCandidates } from "./FaviconCapture.ts"; @@ -136,6 +136,7 @@ const PICTURE_IN_PICTURE_JPEG_QUALITY = 80; const CAPTURE_PAGE_RETRY_ATTEMPTS = 3; const CAPTURE_PAGE_RETRY_DELAY_MS = 120; const CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS = 1_000; +const BUSY_PAGE_PROBE_MS = 1_000; const PICTURE_IN_PICTURE_INITIAL_WIDTH = 480; const PICTURE_IN_PICTURE_INITIAL_HEIGHT = 320; const PICTURE_IN_PICTURE_MIN_WIDTH = 240; @@ -386,7 +387,8 @@ interface ManagedListeners { readonly webContents: Electron.WebContents; } -type FrameCaptureConsumer = "picture-in-picture" | "recording"; +// Each still-image request owns a separate, short-lived rendering lease. +type FrameCaptureConsumer = "picture-in-picture" | "recording" | symbol; interface FrameCaptureSession { readonly recordingInputOptions?: RecordingInputOptions; @@ -887,7 +889,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const update = Effect.fn("PreviewManager.update")(function* ( tabId: string, patch: Partial, - humanPoint?: { readonly x: number; readonly y: number }, ) { const updatedAt = yield* currentIso; const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => { @@ -903,17 +904,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); // syncTabAudible can commit after this update; emitIfCurrent prevents publishing a stale snapshot. if (Option.isSome(next)) { - if (patch.controller !== undefined && next.value.webContentsId != null) { - const capture = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); - const webContentsId = next.value.webContentsId; - if (capture?.consumers.has("recording")) { - yield* attempt({ operation: "recording.controller", tabId }, () => { - const contents = webContents.fromId(webContentsId); - if (contents && !contents.isDestroyed()) - contents.send(RECORDING_CONTROLLER_CHANNEL, patch.controller, humanPoint); - }).pipe(Effect.ignore); - } - } yield* emitIfCurrent(tabId, next.value); } }); @@ -1106,28 +1096,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ), scope, ); - const handleDebuggerMessage = Effect.fnUntraced(function* ( - method: string, - params: Record, - ) { - if (method === "Page.screencastFrame") { - const sessionId = params["sessionId"]; - if (typeof sessionId === "number") { - yield* attemptPromise( - { - operation: "ackScreencastFrame", - webContentsId: wc.id, - }, - () => wcDebugger.sendCommand("Page.screencastFrameAck", { sessionId }), - ).pipe(Effect.ignore); - } - } + const handleDebuggerMessage = Effect.fnUntraced(function* (method: string) { if (method === "Runtime.consoleAPICalled" || method === "Runtime.exceptionThrown") { yield* Queue.offer(consoleReleases, undefined); } }); - const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => { - runFork(handleDebuggerMessage(method, params)); + const onMessage: BrowserControlSession["onMessage"] = (_event, method) => { + runFork(handleDebuggerMessage(method)); }; const control: BrowserControlSession = { webContentsId: wc.id, @@ -1442,21 +1417,75 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ); }; + let humanInputWorker = false; + let humanInputDeadline = 0; + let listening = true; + const publishHumanController = Effect.fn("PreviewManager.publishHumanController")(function* ( + controller: "human" | "none", + humanPoint?: { readonly x: number; readonly y: number }, + ) { + const now = yield* currentMillis; + const updatedAt = yield* currentIso; + const result = yield* SynchronizedRef.modify(tabsRef, (tabs) => { + const current = tabs.get(tabId); + if ( + !current || + current.webContentsId !== wc.id || + (controller === "human" + ? !listening || webContents.fromId(wc.id) !== wc + : current.controller !== "human" || humanInputDeadline > now) + ) + return [undefined, tabs] as const; + const changed = current.controller !== controller; + const state = changed ? { ...current, controller, updatedAt } : current; + return [ + { state, changed }, + changed + ? replaceMap(tabs, (copy) => { + copy.set(tabId, state); + }) + : tabs, + ] as const; + }); + if (!result) return; + const capture = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); + if (capture?.consumers.has("recording")) { + yield* attempt({ operation: "recording.controller", tabId }, () => { + if (!wc.isDestroyed()) wc.send(RECORDING_CONTROLLER_CHANNEL, controller, humanPoint); + }).pipe(Effect.ignore); + } + if (result.changed) yield* emitIfCurrent(tabId, result.state); + }); + yield* Scope.addFinalizer( + scope, + Effect.gen(function* () { + listening = false; + humanInputDeadline = 0; + yield* publishHumanController("none"); + }), + ); const handleHumanInput = Effect.fn("PreviewManager.handleHumanInput")(function* ( rawSignal?: unknown, ) { - yield* update( - tabId, - { controller: "human" }, + if (!listening) return; + humanInputDeadline = (yield* currentMillis) + 750; + if (!humanInputWorker) { + humanInputWorker = true; + yield* Effect.gen(function* () { + let remaining: number; + while ((remaining = humanInputDeadline - (yield* currentMillis)) > 0) { + yield* Effect.sleep(remaining); + } + humanInputWorker = false; + yield* publishHumanController("none"); + }).pipe(Effect.forkIn(scope)); + } + yield* publishHumanController( + "human", isPreviewInputSignal(rawSignal) && rawSignal.kind === "pointer" ? { x: rawSignal.x, y: rawSignal.y } : undefined, ); - yield* Effect.sleep(750); - const tabs = yield* SynchronizedRef.get(tabsRef); - if (tabs.get(tabId)?.controller === "human") { - yield* update(tabId, { controller: "none" }); - } }); const recordingInput = (_event: unknown, input: unknown) => { if (!isRecordingInput(input)) return; @@ -1490,15 +1519,27 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const humanInput = (_event: unknown, rawSignal?: unknown): void => { runFork(handleHumanInput(rawSignal)); }; - const mouseNavigate = (_event: unknown, payload?: unknown): void => { - const direction = - typeof payload === "object" && payload !== null && "direction" in payload - ? (payload as { direction?: unknown }).direction - : undefined; - if (direction !== "back" && direction !== "forward") return; + // Chromium navigates the outermost WebContents (the host app) on thumb buttons, even from guest iframes. + const beforeMouse = (event: Electron.Event, mouse: Electron.MouseInputEvent): void => { + // Electron reports thumb buttons as "back" and "forward", which its typings omit. + const button: string | undefined = mouse.button; + if (button !== "back" && button !== "forward") return; + if (mouse.type !== "mouseDown" && mouse.type !== "mouseUp") return; + event.preventDefault(); + // The page never sees the press, so the guest replays it; these coordinates are DIPs, not CSS px. + const zoom = wc.getZoomFactor(); + const phase = mouse.type === "mouseDown" ? "down" : "up"; + wc.send( + THUMB_PRESS_CHANNEL, + phase, + mouse.x / zoom, + mouse.y / zoom, + button === "back" ? 3 : 4, + ); + if (phase !== "up") return; runFork( attempt({ operation: "mouseNavigate", tabId, webContentsId: wc.id }, () => { - if (direction === "back") { + if (button === "back") { if (wc.navigationHistory.canGoBack()) wc.navigationHistory.goBack(); } else if (wc.navigationHistory.canGoForward()) { wc.navigationHistory.goForward(); @@ -1608,9 +1649,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.off("found-in-page", foundInPage as never); wc.off("did-create-window", windowCreated); wc.off("before-input-event", beforeInput); + wc.off("before-mouse-event", beforeMouse); wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); wc.ipc.off(RECORDING_INPUT_CHANNEL, recordingInput); - wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); wc.ipc.off(PAGE_UNHANDLED_SHORTCUT_CHANNEL, pageUnhandledShortcut); detachPasskeys(); }).pipe(Effect.ignore), @@ -1631,7 +1672,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.on("found-in-page", foundInPage as never); wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); wc.ipc.on(RECORDING_INPUT_CHANNEL, recordingInput); - wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); wc.ipc.on(PAGE_UNHANDLED_SHORTCUT_CHANNEL, pageUnhandledShortcut); detachPasskeys = passkeys.attachGuest(wc); wc.setWindowOpenHandler((details) => { @@ -1645,7 +1685,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function emitOpenLink({ tabId, url: details.url, - background: details.disposition === "background-tab", + // A person's own click or key leaves the guest a focused frame; GuestInput hands + // focus back after the agent's press, so a foreground tab would take it again. + background: + details.disposition === "background-tab" || + (wc.focusedFrame === null && browserHost.agentLedPage(wc)), }), ); return { action: "deny" }; @@ -1659,6 +1703,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); wc.on("did-create-window", windowCreated); wc.on("before-input-event", beforeInput); + wc.on("before-mouse-event", beforeMouse); }); yield* Ref.update(attachedRef, (attached) => replaceMap(attached, (copy) => { @@ -2022,6 +2067,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); + /** + * A same-site navigation reuses the renderer, so it never commits while a page script holds it. + * Runs before the navigation starts: Chromium does not deliver a terminate once one is pending. + */ + const leaveBusyPage = Effect.fn("PreviewManager.leaveBusyPage")(function* ( + wc: Electron.WebContents, + ) { + const control = (yield* SynchronizedRef.get(controlSessionsRef)).get(wc.id); + if (!control) return; + const answered = yield* Effect.promise(() => + control.debugger.sendCommand("Runtime.evaluate", { expression: "0" }).then( + () => undefined, + () => undefined, + ), + ).pipe(Effect.timeoutOption(BUSY_PAGE_PROBE_MS)); + if (Option.isSome(answered)) return; + // Not awaited: a renderer blocked on something other than script may never acknowledge it. + yield* Effect.sync(() => { + void control.debugger.sendCommand("Runtime.terminateExecution").catch(() => undefined); + }); + }); + const navigate = Effect.fn("PreviewManager.navigate")(function* (tabId: string, rawUrl: string) { const url = yield* attempt({ operation: "navigate.normalizeUrl", tabId }, () => normalizePreviewUrl(rawUrl), @@ -2104,6 +2171,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); return; } + yield* leaveBusyPage(wc); if (wc.getURL() === url) { yield* attempt({ operation: "navigate.reload", tabId, webContentsId: wc.id }, () => wc.reload(), @@ -2139,18 +2207,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* attempt({ operation, tabId, webContentsId: wc.id }, () => use(wc)); }); + const leavePage = Effect.fn("PreviewManager.leavePage")(function* ( + operation: string, + tabId: string, + use: (wc: Electron.WebContents) => void, + ) { + const wc = yield* requireWebContents(tabId); + yield* leaveBusyPage(wc); + yield* attempt({ operation, tabId, webContentsId: wc.id }, () => use(wc)); + }); + const goBack = (tabId: string) => - withWebContents("goBack", tabId, (wc) => { + leavePage("goBack", tabId, (wc) => { if (wc.navigationHistory.canGoBack()) wc.navigationHistory.goBack(); }); const goForward = (tabId: string) => - withWebContents("goForward", tabId, (wc) => { + leavePage("goForward", tabId, (wc) => { if (wc.navigationHistory.canGoForward()) wc.navigationHistory.goForward(); }); - const refresh = (tabId: string) => withWebContents("refresh", tabId, (wc) => wc.reload()); + const refresh = (tabId: string) => leavePage("refresh", tabId, (wc) => wc.reload()); const stopLoading = (tabId: string) => withWebContents("stopLoading", tabId, (wc) => wc.stop()); const hardReload = (tabId: string) => - withWebContents("hardReload", tabId, (wc) => wc.reloadIgnoringCache()); + leavePage("hardReload", tabId, (wc) => wc.reloadIgnoringCache()); const findInPage = ( tabId: string, text: string, @@ -2601,7 +2679,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const [createdAt, millis, image] = yield* Effect.all([ currentIso, currentMillis, - capturePageWithRetry( + captureStillImage( { operation: "captureScreenshot.capturePage", tabId, @@ -2649,6 +2727,100 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }; }); + const captureStillImage = Effect.fn("PreviewManager.captureStillImage")(function* ( + errorContext: PreviewOperationContext, + tabId: string, + wc: Electron.WebContents, + ) { + const consumer = Symbol("still-image"); + return yield* Effect.acquireUseRelease( + startFrameCapture(tabId, consumer), + () => + Effect.gen(function* () { + // A DOM mutation can precede its compositor frame even in a shown + // window. Keep the original guest rendering through the next paint; + // a hidden guest otherwise returns old pixels or never completes. + const probe = Effect.fn("PreviewManager.probeStillImagePaint")(function* ( + send: ( + method: string, + params?: Record, + ) => Effect.Effect, + ) { + // CDP executes against the current document without Electron's + // did-stop-loading gate. Isolate scheduling globals from page code. + const tree = (yield* send("Page.getFrameTree")) as + | { frameTree?: { frame?: { id?: string } } } + | undefined; + const frameId = tree?.frameTree?.frame?.id; + if (typeof frameId !== "string") + return yield* new PreviewOperationError({ + ...errorContext, + cause: new Error("The preview paint frame is unavailable."), + }); + const world = (yield* send("Page.createIsolatedWorld", { + frameId, + worldName: "t3-preview-still-image", + })) as { executionContextId?: number } | undefined; + if (typeof world?.executionContextId !== "number") + return yield* new PreviewOperationError({ + ...errorContext, + cause: new Error("The preview paint context is unavailable."), + }); + const response = (yield* send("Runtime.evaluate", { + // Settle and release scheduled work when only timers keep running. + // The outer deadline still bounds a frozen renderer. + expression: `new Promise(resolve => { let frame; const timer = setTimeout(() => { cancelAnimationFrame(frame); resolve(false); }, ${CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS}); frame = requestAnimationFrame(() => { frame = requestAnimationFrame(() => { clearTimeout(timer); resolve(true); }); }); })`, + contextId: world.executionContextId, + awaitPromise: true, + returnByValue: true, + })) as { result?: { value?: unknown }; exceptionDetails?: unknown } | undefined; + if (response?.exceptionDetails || response?.result?.value !== true) + return yield* new PreviewOperationError({ + ...errorContext, + cause: new Error("The preview paint boundary was not confirmed."), + }); + }); + const paintReady = Effect.gen(function* () { + // Only use an existing owned session; never attach or displace + // another debugger to obtain a screenshot. + const control = (yield* SynchronizedRef.get(controlSessionsRef)).get(wc.id); + if (!control) + return yield* new PreviewOperationError({ + ...errorContext, + cause: new Error("The preview paint session is unavailable."), + }); + return yield* probe((method, params) => + attemptPromise(errorContext, () => control.debugger.sendCommand(method, params)), + ); + }).pipe( + Effect.timeout(CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS), + Effect.catch((error) => + Effect.logWarning( + "Preview paint readiness was not confirmed; capturing available pixels with mutation freshness unconfirmed.", + { tabId, webContentsId: wc.id, error }, + ), + ), + ); + // Minimized guests can suspend frames even with throttling disabled. + // A native capture can resume them; discard its pixels and read again + // after the bounded paint probe. + const warmup = capturePageWithRetry(errorContext, tabId, wc).pipe( + Effect.catchTags({ + PreviewOperationError: (error) => + Effect.logWarning("Preview warmup capture failed; continuing with final capture.", { + tabId, + webContentsId: wc.id, + error, + }), + }), + ); + yield* Effect.all([paintReady, warmup], { concurrency: 2, discard: true }); + return yield* capturePageWithRetry(errorContext, tabId, wc); + }), + () => stopFrameCapture(tabId, consumer), + ); + }); + const capturePreviewFrame = Effect.fn("PreviewManager.capturePreviewFrame")(function* ( tabId: string, ) { diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index cbaf873e49332..42867b9d9cb44 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -39,7 +39,6 @@ import { CANCEL_PICK_CHANNEL, ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, - MOUSE_NAVIGATE_CHANNEL, POINTER_DOWN_HOST_CHANNEL, PAGE_UNHANDLED_SHORTCUT_CHANNEL, RECORDING_CURSOR_CHANNEL, @@ -48,6 +47,7 @@ import { RECORDING_INPUT_CHANNEL, RECORDING_CONTROLLER_CHANNEL, START_PICK_CHANNEL, + THUMB_PRESS_CHANNEL, } from "./GuestProtocol.ts"; const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; const INPUT_LAYER_ATTRIBUTE = "data-t3code-annotation-input"; @@ -249,34 +249,28 @@ const isolateOverlayKey = (event: KeyboardEvent): void => { for (const type of ["keydown", "keypress", "keyup"] as const) window.addEventListener(type, isolateOverlayKey, true); -const MOUSE_BUTTON_BACK = 3; -const MOUSE_BUTTON_FORWARD = 4; - -const navigationDirectionForButton = (button: number): "back" | "forward" | null => { - if (button === MOUSE_BUTTON_BACK) return "back"; - if (button === MOUSE_BUTTON_FORWARD) return "forward"; - return null; -}; - -// Chromium routes thumb-button navigation to the focused WebContents; navigate this hovered guest explicitly. -const suppressNavigationButton = (event: MouseEvent): void => { - if (!event.isTrusted || navigationDirectionForButton(event.button) === null) return; - event.preventDefault(); - event.stopImmediatePropagation(); -}; - -const requestNavigationForButton = (event: MouseEvent): void => { - if (!event.isTrusted) return; - const direction = navigationDirectionForButton(event.button); - if (direction === null) return; - event.preventDefault(); - event.stopImmediatePropagation(); - ipcRenderer.send(MOUSE_NAVIGATE_CHANNEL, { direction }); -}; - -window.addEventListener("mousedown", suppressNavigationButton, true); -window.addEventListener("mouseup", requestNavigationForButton, true); -window.addEventListener("auxclick", suppressNavigationButton, true); +let replayedThumbPress: { readonly x: number; readonly y: number } | null = null; +ipcRenderer.on( + THUMB_PRESS_CHANNEL, + (_event, phase: unknown, x: unknown, y: unknown, button: unknown) => { + if (phase !== "down" && phase !== "up") return; + if (typeof x !== "number" || typeof y !== "number" || typeof button !== "number") return; + // Main reports presses inside a frame in that frame's coordinates. + const inFrame = document.querySelector("iframe:hover, frame:hover, object:hover, embed:hover"); + if (phase === "up") { + const pressed = replayedThumbPress; + replayedThumbPress = null; + // A replayed press is always released, at its own point if the release lands in a frame. + if (pressed) recordingCursor?.press(inFrame ? pressed : { x, y }, "up"); + return; + } + if (inFrame) return; + replayedThumbPress = { x, y }; + ipcRenderer.sendToHost(POINTER_DOWN_HOST_CHANNEL); + ipcRenderer.send(HUMAN_INPUT_CHANNEL, { kind: "pointer", x, y, button }); + recordingCursor?.press({ x, y }, "down"); + }, +); const nextId = createAnnotationIdSource(); diff --git a/apps/desktop/src/preview/RecordingCursor.ts b/apps/desktop/src/preview/RecordingCursor.ts index 29f1865de291f..ab74c361b68e0 100644 --- a/apps/desktop/src/preview/RecordingCursor.ts +++ b/apps/desktop/src/preview/RecordingCursor.ts @@ -156,13 +156,15 @@ export function installRecordingCursor( moveHuman(point); pointer(point, "move"); }; + const press = (point: { readonly x: number; readonly y: number }, phase: "down" | "up") => { + if (phase === "down") moveHuman(point); + pointer(point, phase); + }; const pointerDown = (event: PointerEvent) => { - if (event.pointerType === "touch") return; - moveHuman({ x: event.clientX, y: event.clientY }); - pointer({ x: event.clientX, y: event.clientY }, "down"); + if (event.pointerType !== "touch") press({ x: event.clientX, y: event.clientY }, "down"); }; const pointerUp = (event: PointerEvent) => { - if (event.pointerType !== "touch") pointer({ x: event.clientX, y: event.clientY }, "up"); + if (event.pointerType !== "touch") press({ x: event.clientX, y: event.clientY }, "up"); }; const keyDown = (event: KeyboardEvent) => { if (event.isComposing || event.repeat) return; @@ -196,6 +198,7 @@ export function installRecordingCursor( window.addEventListener("blur", hide); return { move, + press, keyPress, setController, setTheme, diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index c00e2ea7c65a2..3486c296da516 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -486,7 +486,7 @@ describe("DesktopShellEnvironment", () => { Effect.gen(function* () { const env: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", - PATH: "/Users/test/.local/bin:/usr/bin", + PATH: " /Users/test/.local/bin:/usr/bin", }; const commands: ChildProcess.Command[] = []; @@ -496,7 +496,7 @@ describe("DesktopShellEnvironment", () => { handler: (command) => { commands.push(command); return envOutput({ - PATH: "/opt/homebrew/bin:/usr/bin", + PATH: "/opt/homebrew/bin:/usr/bin:/opt/bin ", SSH_AUTH_SOCK: "/tmp/secretive.sock", HOMEBREW_PREFIX: "/opt/homebrew", }); @@ -505,7 +505,7 @@ describe("DesktopShellEnvironment", () => { assert.equal(commands.length, 1); assert.equal(commands[0]?._tag === "StandardCommand" ? commands[0].command : "", "/bin/zsh"); - assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin"); + assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin:/opt/bin : /Users/test/.local/bin"); assert.equal(env.SSH_AUTH_SOCK, "/tmp/secretive.sock"); assert.equal(env.HOMEBREW_PREFIX, "/opt/homebrew"); assert.isTrue(prepared); @@ -580,6 +580,39 @@ describe("DesktopShellEnvironment", () => { }), ); + it.effect.each([ + { + name: "hydrates both Android SDK names when neither is inherited", + inherited: {}, + expected: { ANDROID_HOME: "/shell/android-home", ANDROID_SDK_ROOT: "/shell/android-root" }, + }, + { + // The device host reads ANDROID_HOME before ANDROID_SDK_ROOT, so a shell + // ANDROID_HOME next to an inherited ANDROID_SDK_ROOT would change the SDK. + name: "keeps an inherited ANDROID_SDK_ROOT selected over a login-shell ANDROID_HOME", + inherited: { ANDROID_SDK_ROOT: "/inherited/android-sdk" }, + expected: { ANDROID_HOME: undefined, ANDROID_SDK_ROOT: "/inherited/android-sdk" }, + }, + ])("$name", ({ inherited, expected }) => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", PATH: "/usr/bin", ...inherited }; + + yield* runShellEnvironment({ + env, + platform: "darwin", + handler: () => + envOutput({ + PATH: "/usr/bin", + ANDROID_HOME: "/shell/android-home", + ANDROID_SDK_ROOT: "/shell/android-root", + }), + }); + + assert.equal(env.ANDROID_HOME, expected.ANDROID_HOME); + assert.equal(env.ANDROID_SDK_ROOT, expected.ANDROID_SDK_ROOT); + }), + ); + it.effect("keeps mise's PATH record with the login shell PATH", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index f0983ca622490..5389d94c1e8e8 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -3,6 +3,7 @@ import { applyShellEnvironment, buildEnvironmentCaptureCommand, listLoginShellCandidates, + mergePathValues, readUserLoginShell, setWindowsPath, } from "@t3tools/shared/shell"; @@ -88,6 +89,8 @@ export class DesktopShellEnvironment extends Context.Service< const LOGIN_SHELL_ENV_NAMES = [ "PATH", + "ANDROID_HOME", + "ANDROID_SDK_ROOT", "DBUS_SESSION_BUS_ADDRESS", "DISPLAY", "LANG", @@ -114,6 +117,7 @@ const LOGIN_SHELL_ENV_NAMES = [ ] as const; const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const; const LOCALE_ENV_NAMES = ["LANG", "LC_ALL", "LC_CTYPE"] as const; +const ANDROID_SDK_ENV_NAMES = ["ANDROID_HOME", "ANDROID_SDK_ROOT"] as const; const FALLBACK_LC_CTYPE = "en_US.UTF-8"; const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const; const LOGIN_SHELL_TIMEOUT = Duration.seconds(5); @@ -128,10 +132,12 @@ const trimNonEmpty = (value: string | null | undefined): Option.Option = Option.filter((entry) => entry.length > 0), ); -const pathDelimiter = (platform: NodeJS.Platform) => (platform === "win32" ? ";" : ":"); +// POSIX directory names may begin or end with whitespace; mergePathValues trims only on Windows. +const nonEmptyPath = (value: string | null | undefined): Option.Option => + Option.fromNullishOr(value).pipe(Option.filter((entry) => entry.length > 0)); const readEnvPath = (env: NodeJS.ProcessEnv): Option.Option => - trimNonEmpty(env.PATH ?? env.Path ?? env.path); + nonEmptyPath(env.PATH ?? env.Path ?? env.path); const normalizeRuntimeDir = (value: string): string => value.replace(/\/+$/u, ""); @@ -150,39 +156,16 @@ const linuxRuntimeDirCandidates = ( return candidates.filter((candidate) => candidate.length > 0); }; -const pathComparisonKey = (entry: string, platform: NodeJS.Platform) => { - const normalized = entry.trim().replace(/^"+|"+$/g, ""); - return platform === "win32" ? normalized.toLowerCase() : normalized; -}; - -const sanitizePathEntry = (entry: string, platform: NodeJS.Platform) => - platform === "win32" ? entry.replaceAll('"', "") : entry; - const mergePaths = ( platform: NodeJS.Platform, values: ReadonlyArray>, -): Option.Option => { - const delimiter = pathDelimiter(platform); - const entries: string[] = []; - const seen = new Set(); - - for (const value of values) { - if (Option.isNone(value)) continue; - - for (const entry of value.value.split(delimiter)) { - const sanitized = sanitizePathEntry(entry.trim(), platform); - if (sanitized.length === 0) continue; - - const key = pathComparisonKey(sanitized, platform); - if (key.length === 0 || seen.has(key)) continue; - - seen.add(key); - entries.push(sanitized); - } - } - - return entries.length > 0 ? Option.some(entries.join(delimiter)) : Option.none(); -}; +): Option.Option => + Option.fromNullishOr( + values.reduce( + (merged, value) => mergePathValues(merged, Option.getOrUndefined(value), platform), + undefined, + ), + ); const knownWindowsCliDirs = (env: NodeJS.ProcessEnv): ReadonlyArray => [ ...trimNonEmpty(env.APPDATA).pipe( @@ -481,7 +464,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix ? yield* readLaunchctlPath(config.env) : Option.none(); const mergedPath = mergePaths(config.platform, [ - trimNonEmpty(shellEnvironment.PATH).pipe(Option.orElse(() => launchctlPath)), + nonEmptyPath(shellEnvironment.PATH).pipe(Option.orElse(() => launchctlPath)), readEnvPath(config.env), ]); @@ -528,6 +511,17 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix } } + // The device host picks the Android SDK from ANDROID_HOME before ANDROID_SDK_ROOT, + // so a profile-only ANDROID_HOME must not shadow an inherited ANDROID_SDK_ROOT. + if (ANDROID_SDK_ENV_NAMES.every((name) => Option.isNone(trimNonEmpty(config.env[name])))) { + for (const name of ANDROID_SDK_ENV_NAMES) { + const value = trimNonEmpty(shellEnvironment[name]); + if (Option.isSome(value)) { + config.env[name] = value.value; + } + } + } + // Hydrate locale variables together; inherited LC_ALL can override LANG or LC_CTYPE. if ( config.platform === "darwin" && diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index d47984e2bcb59..0693c16cc15fd 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -19,6 +19,7 @@ import { import * as SshTunnel from "@t3tools/ssh/tunnel"; import * as Context from "effect/Context"; import type * as Digest from "effect/crypto/Digest"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -70,8 +71,30 @@ export class DesktopSshEnvironment extends Context.Service< export interface DesktopSshEnvironmentLayerOptions { readonly resolveCliRunner?: Effect.Effect; + readonly resolveLauncherId?: Effect.Effect; } +// Remote servers record this ID as their owner, so it has to outlive restarts, +// updates and edits to a saved connection. Without a stored one there is none. +export const readOrCreateLauncherId = Effect.fn("desktop.ssh.readOrCreateLauncherId")( + function* (filePath: string) { + const fileSystem = yield* FileSystem.FileSystem; + const random = yield* SecureRandom.SecureRandom; + // Any other read failure may hide a stored ID, which a new one would overwrite. + const stored = yield* fileSystem.readFileString(filePath).pipe( + Effect.catchIf( + (cause) => cause.reason._tag === "NotFound", + () => Effect.succeed(""), + ), + ); + if (/^[0-9a-f]{32}$/u.test(stored.trim())) return stored.trim(); + const launcherId = (yield* random.uuidV4()).replace(/-/g, ""); + yield* fileSystem.writeFileString(filePath, `${launcherId}\n`); + return launcherId; + }, + Effect.orElseSucceed(() => null), +); + function discoverDesktopSshHostsEffect(input?: { readonly homeDir?: string }) { return discoverSshHosts(input ?? {}); } @@ -164,10 +187,13 @@ export const make = Effect.gen(function* () { export const layer = (options: DesktopSshEnvironmentLayerOptions = {}) => Layer.effect(DesktopSshEnvironment, make).pipe( Layer.provide( - SshTunnel.SshEnvironmentManager.layer( - options.resolveCliRunner === undefined + SshTunnel.SshEnvironmentManager.layer({ + ...(options.resolveCliRunner === undefined ? {} - : { resolveCliRunner: options.resolveCliRunner }, - ), + : { resolveCliRunner: options.resolveCliRunner }), + ...(options.resolveLauncherId === undefined + ? {} + : { resolveLauncherId: options.resolveLauncherId }), + }), ), ); diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts index 0a2b10e08c5a8..415901878cafe 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts @@ -352,6 +352,8 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { return Queue.offer(updateCommitQueue, message).pipe(Effect.asVoid); case "cancelDesktopUpdate": return Queue.offer(updateCancellationQueue, message).pipe(Effect.asVoid); + case "serverReady": + return Effect.void; } }; const removeControlSource: DesktopTelemetryPublisher["Service"]["removeControlSource"] = ( diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 497b315ef5225..f66586503e24d 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -207,17 +207,20 @@ const layerElectronTheme = Layer.succeed(ElectronTheme.ElectronTheme, { onUpdated: () => Effect.void, } satisfies ElectronTheme.ElectronTheme["Service"]); -const layerDesktopEnvironment = DesktopEnvironment.layer(environmentInput).pipe( - Layer.provide( - Layer.mergeAll( - NodeServices.layer, - DesktopConfig.layerTest({ - T3CODE_PORT: "3773", - VITE_DEV_SERVER_URL: "http://127.0.0.1:5733", - }), +const layerDesktopEnvironmentFor = (platform: NodeJS.Platform) => + DesktopEnvironment.layer({ ...environmentInput, platform }).pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + DesktopConfig.layerTest({ + T3CODE_PORT: "3773", + VITE_DEV_SERVER_URL: "http://127.0.0.1:5733", + }), + ), ), - ), -); + ); + +const layerDesktopEnvironment = layerDesktopEnvironmentFor(environmentInput.platform); const desktopWindowBoundsEquivalence = Schema.toEquivalence( DesktopAppSettings.DesktopWindowBoundsSchema, @@ -243,6 +246,12 @@ function layerTest(input: { readonly previewZoomReapplies?: number[]; readonly onReveal?: (window: Electron.BrowserWindow) => void; readonly focusedWindow?: Electron.BrowserWindow; + readonly fallbackWindow?: Electron.BrowserWindow; + readonly platform?: NodeJS.Platform; + readonly appearanceWindows?: ReadonlyArray<{ + readonly window: Electron.BrowserWindow; + readonly titleBarOverlay: boolean; + }>; readonly primaryPasteEnabled?: boolean; readonly previewPasskeyBridgeEnabled?: boolean; }) { @@ -291,7 +300,9 @@ function layerTest(input: { Effect.as(input.window), ), main: Ref.get(input.mainWindow), - currentMainOrFirst: Ref.get(input.mainWindow), + currentMainOrFirst: Ref.get(input.mainWindow).pipe( + Effect.map(Option.orElse(() => Option.fromNullishOr(input.fallbackWindow ?? null))), + ), focusedMainOrFirst: input.focusedWindow === undefined ? Ref.get(input.mainWindow) @@ -302,7 +313,14 @@ function layerTest(input: { reveal: (window) => Effect.sync(() => input.onReveal?.(window)), sendAll: () => Effect.void, destroyAll: input.destroyAll ?? Effect.void, - syncAllAppearance: (sync) => sync(input.window), + syncAllAppearance: (sync) => + input.appearanceWindows === undefined + ? sync(input.window, { titleBarOverlay: true }) + : Effect.forEach( + input.appearanceWindows, + ({ window, titleBarOverlay }) => sync(window, { titleBarOverlay }), + { discard: true }, + ), } satisfies ElectronWindow.ElectronWindow["Service"]); return DesktopWindow.layer.pipe( @@ -314,7 +332,9 @@ function layerTest(input: { recordMetrics: () => Effect.void, shutdown: Effect.void, }), - layerDesktopEnvironment, + input.platform === undefined + ? layerDesktopEnvironment + : layerDesktopEnvironmentFor(input.platform), layerDesktopAppSettings, layerDesktopClientSettings, layerDesktopServerExposure, @@ -423,7 +443,8 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n reveal: (window) => Ref.update(revealedWindows, (windows) => [...windows, window]), sendAll: () => Effect.void, destroyAll: Effect.void, - syncAllAppearance: (sync) => (fallbackWindow ? sync(fallbackWindow) : Effect.void), + syncAllAppearance: (sync) => + fallbackWindow ? sync(fallbackWindow, { titleBarOverlay: true }) : Effect.void, } satisfies ElectronWindow.ElectronWindow["Service"]; const layer = DesktopWindow.layer.pipe( @@ -826,7 +847,7 @@ describe("DesktopWindow", () => { }), ); - it.effect("reloads and inspects the main window even when a popup has focus", () => + it.effect("routes menu commands to the main window even when a popup has focus", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); const popup = makeFakeBrowserWindow(); @@ -846,7 +867,10 @@ describe("DesktopWindow", () => { yield* desktopWindow.runMainContentsCommand("reload"); yield* desktopWindow.runMainContentsCommand("forceReload"); yield* desktopWindow.runMainContentsCommand("toggleDevTools"); + yield* desktopWindow.dispatchMenuAction("open-settings"); + assert.deepEqual(fakeWindow.send.mock.calls, [["desktop:menu-action", "open-settings"]]); + assert.equal(popup.send.mock.calls.length, 0); assert.equal(fakeWindow.reload.mock.calls.length, 1); assert.equal(fakeWindow.reloadIgnoringCache.mock.calls.length, 1); assert.equal(fakeWindow.toggleDevTools.mock.calls.length, 1); @@ -857,6 +881,38 @@ describe("DesktopWindow", () => { }), ); + it.effect("recreates main for Settings when only an unregistered popup survives", () => + Effect.gen(function* () { + const main = makeFakeBrowserWindow(); + const auxiliary = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + + yield* Effect.gen(function* () { + const windows = yield* DesktopWindow.DesktopWindow; + yield* windows.dispatchMenuAction("open-settings"); + assert.deepEqual(main.send.mock.calls, [["desktop:menu-action", "open-settings"]]); + assert.equal(auxiliary.send.mock.calls.length, 0); + assert.equal(yield* Ref.get(createCount), 1); + assert.strictEqual(yield* windows.ensureMain, main.window); + assert.equal(yield* Ref.get(createCount), 1); + }).pipe( + Effect.provide( + layerTest({ + window: main.window, + createCount, + mainWindow, + fallbackWindow: auxiliary.window, + desktopSettings: { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + localEnvironmentEnabled: false, + }, + }), + ), + ); + }), + ); + it.effect("does not open main or splash windows once quitting has started", () => Effect.gen(function* () { const fake = makeFakeBrowserWindow(); @@ -1170,6 +1226,36 @@ describe("DesktopWindow", () => { }), ); + it.effect("updates title bar overlays only on windows created with one", () => + Effect.gen(function* () { + const framed = makeFakeBrowserWindow(); + vi.mocked(framed.window.setTitleBarOverlay).mockImplementation(() => { + throw new TypeError("Titlebar overlay is not enabled"); + }); + const overlay = makeFakeBrowserWindow(); + const layer = layerTest({ + window: overlay.window, + createCount: yield* Ref.make(0), + mainWindow: yield* Ref.make>(Option.none()), + platform: "win32", + appearanceWindows: [ + { window: framed.window, titleBarOverlay: false }, + { window: overlay.window, titleBarOverlay: true }, + ], + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.syncAppearance; + }).pipe(Effect.provide(layer)); + + assert.deepEqual(vi.mocked(framed.window.setBackgroundColor).mock.calls, [["#ffffff"]]); + assert.lengthOf(vi.mocked(framed.window.setTitleBarOverlay).mock.calls, 0); + assert.deepEqual(vi.mocked(overlay.window.setBackgroundColor).mock.calls, [["#ffffff"]]); + assert.lengthOf(vi.mocked(overlay.window.setTitleBarOverlay).mock.calls, 1); + }), + ); + it.effect.each([ { window: "normal", write: "flush" }, { window: "normal", write: "debounce" }, diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 43404e2d6cfb9..7be14bc720838 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -343,6 +343,7 @@ function getWindowTitleBarOptions( function syncWindowAppearance( window: Electron.BrowserWindow, + hasTitleBarOverlay: boolean, shouldUseDarkColors: boolean, platform: NodeJS.Platform, ): Effect.Effect { @@ -353,7 +354,7 @@ function syncWindowAppearance( window.setBackgroundColor(getInitialWindowBackgroundColor(shouldUseDarkColors)); const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors, platform); - if (typeof titleBarOverlay === "object") { + if (hasTitleBarOverlay && typeof titleBarOverlay === "object") { window.setTitleBarOverlay(titleBarOverlay); } }); @@ -424,7 +425,6 @@ export const make = Effect.gen(function* () { ); const currentMainWindow = electronWindow.currentMainOrFirst.pipe(Effect.flatMap(withoutSplash)); - const focusedMainWindow = electronWindow.focusedMainOrFirst.pipe(Effect.flatMap(withoutSplash)); const zoomWindow = Effect.fn("desktop.window.zoomWindow")(function* ( window: Electron.BrowserWindow, @@ -1018,7 +1018,7 @@ export const make = Effect.gen(function* () { const ensureMain = Effect.gen(function* () { if (yield* Ref.get(desktopState.quitting)) return Option.none(); - const existingWindow = yield* currentMainWindow; + const existingWindow = yield* electronWindow.main; if (Option.isSome(existingWindow)) { return existingWindow; } @@ -1105,7 +1105,8 @@ export const make = Effect.gen(function* () { { reveal = true }: { readonly reveal?: boolean } = {}, ) { if (yield* Ref.get(desktopState.quitting)) return; - const existingWindow = yield* reveal ? focusedMainWindow : electronWindow.main; + // Only the main renderer loads the app preload that listens on these channels. + const existingWindow = yield* electronWindow.main; if (Option.isNone(existingWindow) && (!reveal || (yield* waitingForBackend))) return; const window = Option.isSome(existingWindow) ? existingWindow : yield* ensureMain; if (Option.isNone(window) || window.value.isDestroyed()) return; @@ -1202,8 +1203,8 @@ export const make = Effect.gen(function* () { }), syncAppearance: Effect.gen(function* () { const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; - yield* electronWindow.syncAllAppearance((window) => - syncWindowAppearance(window, shouldUseDarkColors, environment.platform), + yield* electronWindow.syncAllAppearance((window, { titleBarOverlay }) => + syncWindowAppearance(window, titleBarOverlay, shouldUseDarkColors, environment.platform), ); }).pipe(Effect.withSpan("desktop.window.syncAppearance")), }); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index 69962f52e4f18..0787956a72b76 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -1,16 +1,25 @@ // @effect-diagnostics nodeBuiltinImport:off - the executed suite runs the generated install script through a real POSIX shell. +import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Redacted from "effect/Redacted"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { afterAll, expect } from "vite-plus/test"; import * as NodeChildProcess from "node:child_process"; import { ManagedPolicyFileError } from "@t3tools/shared/managedPolicyFile"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopManagedPolicy from "../app/DesktopManagedPolicy.ts"; import { + DesktopWslEnvironment, + layer as layerDesktopWslEnvironment, buildWslRuntimeInstallScript, makeIsAvailable, buildWslRuntimeInvalidateScript, @@ -18,6 +27,7 @@ import { buildWslRuntimeProbeScript, parseResolvedPath, parseWslRuntimeRoot, + windowsToWslPathImpl, } from "./DesktopWslEnvironment.ts"; // The install script only fails the way this file cares about when a real shell @@ -380,6 +390,93 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed expect(selected.status, `${selected.stdout}\n${selected.stderr}`).toBe(0); }); + // Runs the distro side on the local bash, so a Windows dev box using the wsl.exe runner skips it. + describe.skipIf(posixShellRunner?.file !== "bash")("prepareRuntime through wsl.exe", () => { + // Emulates `wsl.exe [-d ] [--cd ] [--|--exec] ` in a distro with automount off. + const layerFakeWslExe = (home: string) => + Layer.effect( + ChildProcessSpawner.ChildProcessSpawner, + Effect.map(ChildProcessSpawner.ChildProcessSpawner, (local) => + ChildProcessSpawner.make((command) => { + if (!ChildProcess.isStandardCommand(command) || command.command !== "wsl.exe") { + return local.spawn(command); + } + const args = command.args.map((word) => + Redacted.isRedacted(word) ? Redacted.value(word) : word, + ); + while (args.length > 0) { + const flag = args[0]; + if (flag === "-d" || flag === "--distribution" || flag === "--cd") { + args.splice(0, 2); + } else if (flag === "--" || flag === "--exec" || flag === "-e") { + args.shift(); + break; + } else { + break; + } + } + const [file = "sh", ...rest] = + args[0] === "wslpath" + ? [ + "sh", + "-c", + 'printf "wslpath: %s\\n" "$1" >&2; exit 1', + "wslpath", + args.at(-1) ?? "", + ] + : args; + return local.spawn( + ChildProcess.make(file, rest, { + ...command.options, + env: { ...command.options.env, HOME: home }, + extendEnv: true, + }), + ); + }), + ), + ); + + const layerWslEnvironment = (home: string) => + layerDesktopWslEnvironment.pipe( + Layer.provide( + Layer.mergeAll( + layerFakeWslExe(home), + DesktopManagedPolicy.layerTest(), + Layer.effect( + DesktopEnvironment.DesktopEnvironment, + Effect.map( + Path.Path, + (path) => + ({ platform: "win32", path }) as DesktopEnvironment.DesktopEnvironment["Service"], + ), + ), + ), + ), + Layer.provide(NodeServices.layer), + ); + + it.live("installs the runtime when wslpath cannot translate the archive's Windows path", () => { + const fixture = createFixture(); + return Effect.gen(function* () { + const wsl = yield* DesktopWslEnvironment; + // The archive's Windows path; with no drives mounted, wslpath cannot map it into the distro. + const translated = yield* wsl.windowsToWslPath("Ubuntu", fixture.archivePath); + expect(translated._tag).toBe("None"); + + const prepared = yield* wsl.prepareRuntime("Ubuntu", { + windowsPath: fixture.archivePath, + runtimeId: fixture.runtimeId, + sha256: fixture.archiveSha, + }); + + expect(prepared).toEqual({ ok: true, linuxAppRoot: fixture.runtimeRoot }); + const version = runShell(`${sh(fixture.serverEntry)} --version`); + expect(version.status, version.stderr).toBe(0); + expect(version.stdout).toBe("t3code wsl runtime test server 0.0.0\n"); + }).pipe(Effect.provide(layerWslEnvironment(`${fixture.work}/home`))); + }); + }); + it("prunes old and markerless caches without touching retained, active, locked, or unrelated roots", () => { const result = runShell( [ @@ -459,3 +556,48 @@ describe("makeIsAvailable", () => { }), ); }); + +describe("windowsToWslPathImpl", () => { + it.effect("runs wslpath without a shell so folder names stay one literal argument", () => + Effect.gen(function* () { + const spawned: Array = []; + const stdout = Stream.make(new TextEncoder().encode("/mnt/c/a$b & c;(d)\n")); + const spawner = ChildProcessSpawner.make((command) => { + spawned.push(command); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout, + stderr: Stream.empty, + all: stdout, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }), + ); + }); + + const converted = yield* windowsToWslPathImpl("Ubuntu", "C:\\a$b & c;(d)").pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + assert.deepStrictEqual(converted, Option.some("/mnt/c/a$b & c;(d)")); + assert.lengthOf(spawned, 1); + const [command] = spawned; + assert(command?._tag === "StandardCommand"); + assert.strictEqual(command.command, "wsl.exe"); + assert.deepStrictEqual(command.args, [ + "-d", + "Ubuntu", + "--exec", + "wslpath", + "-u", + "C:/a$b & c;(d)", + ]); + }), + ); +}); diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index 57d6c61f7c43a..4e5e5b423f20e 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -5,6 +5,8 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import type * as PlatformError from "effect/PlatformError"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; @@ -173,37 +175,19 @@ const buildWslNodeEnvPreamble = ( ensure_remote_node_path || true `; -// wsl.exe re-escaping mangles quotes inside bash -lc scripts. -const runWslShell = ( - distro: string | null, - bashScript: string, +const runWslProcess = ( + args: ReadonlyArray, + stdin: Stream.Stream, timeout: Duration.Duration, - options: { - readonly nodeEngineRange?: string | null; - readonly resolveNode?: boolean; - } = {}, ): Effect.Effect => { const spawner = ChildProcessSpawner.ChildProcessSpawner; - const resolveNode = options.resolveNode !== false; - const command = ChildProcess.make( - "wsl.exe", - resolveNode - ? [...buildDistroArgs(distro), "--", "bash", "-l", "-s"] - : [...buildDistroArgs(distro), "--exec", "sh", "-s"], - { - stdin: Stream.encodeText( - Stream.make( - resolveNode - ? `${buildWslNodeEnvPreamble(options.nodeEngineRange)}${bashScript}` - : bashScript, - ), - ), - stdout: "pipe", - stderr: "pipe", - killSignal: "SIGTERM", - forceKillAfter: PROCESS_TERMINATE_GRACE, - }, - ); + const command = ChildProcess.make("wsl.exe", args, { + stdin, + stdout: "pipe", + stderr: "pipe", + killSignal: "SIGTERM", + forceKillAfter: PROCESS_TERMINATE_GRACE, + }); return Effect.scoped( Effect.gen(function* () { @@ -249,6 +233,32 @@ const runWslShell = ( ); }; +// wsl.exe re-escaping mangles quotes inside bash -lc scripts. +const runWslShell = ( + distro: string | null, + bashScript: string, + timeout: Duration.Duration, + options: { + readonly nodeEngineRange?: string | null; + readonly resolveNode?: boolean; + } = {}, +): Effect.Effect => { + const resolveNode = options.resolveNode !== false; + return runWslProcess( + resolveNode + ? [...buildDistroArgs(distro), "--", "bash", "-l", "-s"] + : [...buildDistroArgs(distro), "--exec", "sh", "-s"], + Stream.encodeText( + Stream.make( + resolveNode + ? `${buildWslNodeEnvPreamble(options.nodeEngineRange)}${bashScript}` + : bashScript, + ), + ), + timeout, + ); +}; + const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; const WSL_RUNTIME_READY_MARKER = ".t3code-wsl-runtime-ready"; @@ -257,13 +267,14 @@ const WSL_RUNTIME_SELECTION_GRACE_MINUTES = 5; const sanitizeWslRuntimeId = (value: string): string => value.replace(/[^A-Za-z0-9._-]/g, "_"); +// A null archive path asks for a staging directory to copy the archive into, unless the cache is ready. export const buildWslRuntimeInstallScript = ( - linuxArchivePath: string, + linuxArchivePath: string | null, runtimeId: string, archiveSha256: string, ): string => { const safeRuntimeId = sanitizeWslRuntimeId(runtimeId); - return [ + const readyCheck = [ "set -eu", 'runtime_parent="$HOME/.t3/wsl-runtime"', `runtime_root="$runtime_parent/${safeRuntimeId}"`, @@ -292,6 +303,17 @@ export const buildWslRuntimeInstallScript = ( ` printf 'runtimeRoot:%s\\n' "$runtime_root"`, " exit 0", "fi", + ]; + if (linuxArchivePath === null) { + return [ + ...readyCheck, + // The .tmp. name lets pruning collect a stage orphaned by a crash. + `archive_stage=$(mktemp -d "$runtime_parent/.${safeRuntimeId}.tmp.XXXXXX")`, + `printf 'archiveStage:%s\\n' "$archive_stage"`, + ].join("\n"); + } + return [ + ...readyCheck, `archive_sha=$(sha256sum ${shellQuote(linuxArchivePath)} | cut -d ' ' -f 1)`, `if [ "$archive_sha" != ${shellQuote(archiveSha256)} ]; then`, ` printf 'WSL runtime archive does not match its recorded SHA-256 (expected %s, got %s)\\n' ${shellQuote(archiveSha256)} "$archive_sha" >&2`, @@ -409,14 +431,17 @@ export const buildWslRuntimeInvalidateScript = (runtimeId: string): string => { ].join("\n"); }; -export const parseWslRuntimeRoot = (stdout: string): string | null => { - const prefix = "runtimeRoot:"; +const parseAbsolutePathField = (stdout: string, field: string): string | null => { + const prefix = `${field}:`; const line = stdout.split("\n").find((candidate) => candidate.startsWith(prefix)); if (line === undefined) return null; - const runtimeRoot = line.slice(prefix.length).replace(/\r$/, ""); - return runtimeRoot.startsWith("/") ? runtimeRoot : null; + const value = line.slice(prefix.length).replace(/\r$/, ""); + return value.startsWith("/") ? value : null; }; +export const parseWslRuntimeRoot = (stdout: string): string | null => + parseAbsolutePathField(stdout, "runtimeRoot"); + const NODE_PTY_BINARY_MISSING_EXIT_CODE = 4; const formatNodePtyProbeFailureReason = (exitCode: number): string | null => @@ -775,25 +800,18 @@ const ensureNodePtyImpl = Effect.fn("DesktopWslEnvironment.ensureNodePtyImpl")(f } as const; }); -const prepareWslRuntimeImpl = Effect.fn("desktop.wsl.prepareRuntimeImpl")(function* ( +type WslRuntimeInstallOutcome = + | { readonly ok: true; readonly stdout: string } + | { readonly ok: false; readonly reason: string }; + +const runWslRuntimeInstall = Effect.fn("desktop.wsl.runRuntimeInstall")(function* ( distro: string | null, archive: WslRuntimeArchive, - windowsToWslPath: ( - distro: string | null, - windowsPath: string, - ) => Effect.Effect>, -): Effect.fn.Return { - const linuxArchivePath = yield* windowsToWslPath(distro, archive.windowsPath); - if (Option.isNone(linuxArchivePath)) { - return { - ok: false, - reason: `wslpath conversion failed for ${archive.windowsPath}`, - } as const; - } - + linuxArchivePath: string | null, +): Effect.fn.Return { const install = yield* runWslShell( distro, - buildWslRuntimeInstallScript(linuxArchivePath.value, archive.runtimeId, archive.sha256), + buildWslRuntimeInstallScript(linuxArchivePath, archive.runtimeId, archive.sha256), RUNTIME_INSTALL_TIMEOUT, { resolveNode: false }, ); @@ -813,14 +831,106 @@ const prepareWslRuntimeImpl = Effect.fn("desktop.wsl.prepareRuntimeImpl")(functi reason: `WSL runtime installation failed (exit ${install.exitCode}): ${trimmedTail || "no stderr captured"}`, } as const; } + return { ok: true, stdout: install.stdout } as const; +}); - const linuxAppRoot = parseWslRuntimeRoot(install.stdout); +const toPrepareWslRuntimeResult = (outcome: WslRuntimeInstallOutcome): PrepareWslRuntimeResult => { + if (!outcome.ok) return outcome; + const linuxAppRoot = parseWslRuntimeRoot(outcome.stdout); return linuxAppRoot === null ? { ok: false, reason: "WSL runtime installation completed without reporting its cache path.", } : { ok: true, linuxAppRoot }; +}; + +// Without drive mounts (`[automount] enabled=false`) wslpath cannot reach the archive, so copy it in over stdin. +const installWslRuntimeFromHostCopy = Effect.fn("desktop.wsl.installRuntimeFromHostCopy")( + function* ( + distro: string | null, + archive: WslRuntimeArchive, + archiveBytes: Stream.Stream, + ): Effect.fn.Return { + const staged = yield* runWslRuntimeInstall(distro, archive, null); + if (!staged.ok || parseWslRuntimeRoot(staged.stdout) !== null) { + return toPrepareWslRuntimeResult(staged); + } + const stage = parseAbsolutePathField(staged.stdout, "archiveStage"); + if (stage === null) { + return { + ok: false, + reason: `wslpath conversion failed for ${archive.windowsPath}, and the distro did not provide a directory to copy the runtime archive into.`, + } as const; + } + const linuxArchivePath = `${stage}/runtime.tar.gz`; + + const copyAndInstall = Effect.gen(function* () { + const readError = yield* Ref.make(Option.none()); + // dd reads fd 0 itself; reopening /dev/stdin fails when WSL relays stdin as a socket. + const copy = yield* runWslProcess( + [...buildDistroArgs(distro), "--exec", "dd", `of=${linuxArchivePath}`, "bs=65536"], + archiveBytes.pipe( + // Ending stdin on a read error lets dd exit instead of waiting out the timeout. + Stream.catch((error) => + Stream.drain(Stream.fromEffect(Ref.set(readError, Option.some(error)))), + ), + ), + RUNTIME_INSTALL_TIMEOUT, + ); + const failedRead = yield* Ref.get(readError); + if (Option.isSome(failedRead)) { + return { + ok: false, + reason: `Could not read the WSL runtime archive at ${archive.windowsPath}: ${failedRead.value.message}`, + } as const; + } + if (copy.transportFailure !== null || copy.exitCode !== 0) { + const trimmedTail = copy.stderr.trim().slice(-500); + return { + ok: false, + reason: `Could not copy the WSL runtime archive into the distro (exit ${copy.exitCode})${trimmedTail ? `: ${trimmedTail}` : ""}`, + } as const; + } + return toPrepareWslRuntimeResult( + yield* runWslRuntimeInstall(distro, archive, linuxArchivePath), + ); + }); + + return yield* copyAndInstall.pipe( + Effect.ensuring( + runWslShell(distro, `rm -rf ${shellQuote(stage)}`, RUNTIME_INVALIDATE_TIMEOUT, { + resolveNode: false, + }), + ), + ); + }, +); + +const prepareWslRuntimeImpl = Effect.fn("desktop.wsl.prepareRuntimeImpl")(function* ( + distro: string | null, + archive: WslRuntimeArchive, + windowsToWslPath: ( + distro: string | null, + windowsPath: string, + ) => Effect.Effect>, +): Effect.fn.Return< + PrepareWslRuntimeResult, + never, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem +> { + const linuxArchivePath = yield* windowsToWslPath(distro, archive.windowsPath); + if (Option.isNone(linuxArchivePath)) { + const fileSystem = yield* FileSystem.FileSystem; + return yield* installWslRuntimeFromHostCopy( + distro, + archive, + fileSystem.stream(archive.windowsPath), + ); + } + return toPrepareWslRuntimeResult( + yield* runWslRuntimeInstall(distro, archive, linuxArchivePath.value), + ); }); const pruneWslRuntimesImpl = Effect.fn("desktop.wsl.pruneRuntimesImpl")(function* ( @@ -925,7 +1035,7 @@ const preWarmImpl = ( }), ).pipe(Effect.timeoutOption(PRE_WARM_TIMEOUT), Effect.ignore); -const windowsToWslPathImpl = ( +export const windowsToWslPathImpl = ( distro: string | null, windowsPath: string, ): Effect.Effect, never, ChildProcessSpawner.ChildProcessSpawner> => { @@ -936,7 +1046,8 @@ const windowsToWslPathImpl = ( const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const command = ChildProcess.make( "wsl.exe", - [...buildDistroArgs(distro), "--", "wslpath", "-u", normalized], + // --exec skips the distro shell, which would expand `$`, `&` or `;` in folder names. + [...buildDistroArgs(distro), "--exec", "wslpath", "-u", normalized], { stdin: "ignore", stdout: "pipe", @@ -1198,7 +1309,11 @@ export const layer = Layer.effect( getUserHome, getDistroIp, prepareRuntime: Effect.fn("desktop.wsl.prepareRuntime")(function (distro, archive) { - return provideSpawner(prepareWslRuntimeImpl(distro, archive, windowsToWslPath)); + return provideSpawner( + prepareWslRuntimeImpl(distro, archive, windowsToWslPath).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + ), + ); }), pruneRuntimes: Effect.fn("desktop.wsl.pruneRuntimes")(function (distro, runtimeId) { return provideSpawner(pruneWslRuntimesImpl(distro, runtimeId)); diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 455901299edc3..3e6fd9531106b 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -164,6 +164,9 @@ export default defineConfig({ // The Windows lane runs workspace suites concurrently; filesystem-heavy // desktop integration tests can exceed Vitest's 5 second default there. testTimeout: 15_000, + // `require("electron")` downloads a missing binary, so concurrent workers race to + // extract it into the same dist/. Install it once before any worker starts. + globalSetup: ["./scripts/ensure-electron-runtime.mjs"], projects: [ { extends: true, diff --git a/apps/marketing/package.json b/apps/marketing/package.json index 04f6830066a1e..8e447d16b2c0e 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -7,7 +7,8 @@ "stage:install-scripts": "node scripts/stage-install-scripts.mjs", "dev": "node scripts/stage-install-scripts.mjs && astro dev", "build": "node scripts/stage-install-scripts.mjs && astro build", - "preview": "astro preview" + "preview": "astro preview", + "test": "vp test run" }, "dependencies": { "@fontsource-variable/inter": "^5.3.0", diff --git a/apps/marketing/src/i18n/de.ts b/apps/marketing/src/i18n/de.ts index 648b35beebe07..96925adb27069 100644 --- a/apps/marketing/src/i18n/de.ts +++ b/apps/marketing/src/i18n/de.ts @@ -27,6 +27,8 @@ export const de: Messages = { downloadForMac: "Für macOS herunterladen", downloadForWindows: "Für Windows herunterladen", downloadForLinux: "Für Linux herunterladen", + downloadForIos: "Für iOS herunterladen", + downloadForAndroid: "Für Android herunterladen", sourceLink: "Klau unseren Code (legal)", alsoOnPhone: "Auch auf deinem Handy:", screenshotAlt: "T3 Code führt Subagents von Claude, Codex, Muse und Gemini in einem Thread aus", diff --git a/apps/marketing/src/i18n/en.ts b/apps/marketing/src/i18n/en.ts index 1769d81184c5b..1a36424a1bfde 100644 --- a/apps/marketing/src/i18n/en.ts +++ b/apps/marketing/src/i18n/en.ts @@ -27,6 +27,8 @@ export const en = { downloadForMac: "Download for macOS", downloadForWindows: "Download for Windows", downloadForLinux: "Download for Linux", + downloadForIos: "Download for iOS", + downloadForAndroid: "Download for Android", sourceLink: "Steal our code (legally)", alsoOnPhone: "Also on your phone:", screenshotAlt: "T3 Code running Claude, Codex, Muse, and Gemini subagents in one thread", diff --git a/apps/marketing/src/i18n/es.ts b/apps/marketing/src/i18n/es.ts index cb4c28e9871fd..90fcf79bd9429 100644 --- a/apps/marketing/src/i18n/es.ts +++ b/apps/marketing/src/i18n/es.ts @@ -28,6 +28,8 @@ export const es: Messages = { downloadForMac: "Descargar para macOS", downloadForWindows: "Descargar para Windows", downloadForLinux: "Descargar para Linux", + downloadForIos: "Descargar para iOS", + downloadForAndroid: "Descargar para Android", sourceLink: "Roba nuestro código (legalmente)", alsoOnPhone: "También en tu teléfono:", screenshotAlt: "T3 Code ejecutando subagentes de Claude, Codex, Muse y Gemini en un mismo hilo", diff --git a/apps/marketing/src/i18n/fr.ts b/apps/marketing/src/i18n/fr.ts index 8fd918efc815b..3d76747d6c833 100644 --- a/apps/marketing/src/i18n/fr.ts +++ b/apps/marketing/src/i18n/fr.ts @@ -27,6 +27,8 @@ export const fr: Messages = { downloadForMac: "Télécharger pour macOS", downloadForWindows: "Télécharger pour Windows", downloadForLinux: "Télécharger pour Linux", + downloadForIos: "Télécharger pour iOS", + downloadForAndroid: "Télécharger pour Android", sourceLink: "Volez notre code (légalement)", alsoOnPhone: "Aussi sur votre téléphone :", screenshotAlt: diff --git a/apps/marketing/src/i18n/ja.ts b/apps/marketing/src/i18n/ja.ts index 55117b609ac08..9f9fdba51129d 100644 --- a/apps/marketing/src/i18n/ja.ts +++ b/apps/marketing/src/i18n/ja.ts @@ -28,6 +28,8 @@ export const ja: Messages = { downloadForMac: "macOS 版をダウンロード", downloadForWindows: "Windows 版をダウンロード", downloadForLinux: "Linux 版をダウンロード", + downloadForIos: "iOS 版をダウンロード", + downloadForAndroid: "Android 版をダウンロード", sourceLink: "コードを盗もう(合法的に)", alsoOnPhone: "スマホでも使えます:", screenshotAlt: diff --git a/apps/marketing/src/i18n/ko.ts b/apps/marketing/src/i18n/ko.ts index f2c09c0af06ac..e2f4d4e6f7c7a 100644 --- a/apps/marketing/src/i18n/ko.ts +++ b/apps/marketing/src/i18n/ko.ts @@ -27,6 +27,8 @@ export const ko: Messages = { downloadForMac: "macOS용 다운로드", downloadForWindows: "Windows용 다운로드", downloadForLinux: "Linux용 다운로드", + downloadForIos: "iOS용 다운로드", + downloadForAndroid: "Android용 다운로드", sourceLink: "코드 훔쳐 가기(합법적으로)", alsoOnPhone: "휴대폰에서도:", screenshotAlt: "하나의 스레드에서 Claude, Codex, Muse, Gemini 서브에이전트를 실행 중인 T3 Code", diff --git a/apps/marketing/src/i18n/pt-BR.ts b/apps/marketing/src/i18n/pt-BR.ts index 33841e839c527..1947884d33516 100644 --- a/apps/marketing/src/i18n/pt-BR.ts +++ b/apps/marketing/src/i18n/pt-BR.ts @@ -27,6 +27,8 @@ export const ptBR: Messages = { downloadForMac: "Baixar para macOS", downloadForWindows: "Baixar para Windows", downloadForLinux: "Baixar para Linux", + downloadForIos: "Baixar para iOS", + downloadForAndroid: "Baixar para Android", sourceLink: "Roube nosso código (legalmente)", alsoOnPhone: "Também no seu celular:", screenshotAlt: diff --git a/apps/marketing/src/i18n/ru.ts b/apps/marketing/src/i18n/ru.ts index 8fdeefdb9fadd..e0d357813bc6e 100644 --- a/apps/marketing/src/i18n/ru.ts +++ b/apps/marketing/src/i18n/ru.ts @@ -27,6 +27,8 @@ export const ru: Messages = { downloadForMac: "Скачать для macOS", downloadForWindows: "Скачать для Windows", downloadForLinux: "Скачать для Linux", + downloadForIos: "Скачать для iOS", + downloadForAndroid: "Скачать для Android", sourceLink: "Украдите наш код (легально)", alsoOnPhone: "Есть и для телефона:", screenshotAlt: "T3 Code запускает субагентов Claude, Codex, Muse и Gemini в одном треде", diff --git a/apps/marketing/src/i18n/zh.ts b/apps/marketing/src/i18n/zh.ts index bae711530051a..1c554b193c401 100644 --- a/apps/marketing/src/i18n/zh.ts +++ b/apps/marketing/src/i18n/zh.ts @@ -27,6 +27,8 @@ export const zh: Messages = { downloadForMac: "下载 macOS 版", downloadForWindows: "下载 Windows 版", downloadForLinux: "下载 Linux 版", + downloadForIos: "下载 iOS 版", + downloadForAndroid: "下载 Android 版", sourceLink: "来偷我们的代码(合法的)", alsoOnPhone: "手机上也能用:", screenshotAlt: "T3 Code 在同一个线程中运行 Claude、Codex、Muse 和 Gemini 子智能体", diff --git a/apps/marketing/src/layouts/Layout.astro b/apps/marketing/src/layouts/Layout.astro index 0f0a864101743..fa7fa30bc0b18 100644 --- a/apps/marketing/src/layouts/Layout.astro +++ b/apps/marketing/src/layouts/Layout.astro @@ -7,8 +7,8 @@ import { ANDROID_PLAY_STORE_URL, GITHUB_REPOSITORY_URL, IOS_APP_STORE_URL, - MARKETING_STATS, } from "../lib/site"; +import { MARKETING_STATS } from "../lib/stats"; import { type JsonLd, jsonLdGraph, organizationJsonLd, websiteJsonLd } from "../lib/seo"; import { messagesFor } from "../i18n"; import { diff --git a/apps/marketing/src/lib/platform.test.ts b/apps/marketing/src/lib/platform.test.ts new file mode 100644 index 0000000000000..d35abb2ac63aa --- /dev/null +++ b/apps/marketing/src/lib/platform.test.ts @@ -0,0 +1,45 @@ +import { expect, it } from "vite-plus/test"; + +import { detectPlatform, getMobileStoreUrl } from "./platform"; +import { ANDROID_PLAY_STORE_URL, IOS_APP_STORE_URL } from "./site"; + +it.each([ + { + // Android user agents also contain "Linux". + name: "Android", + userAgent: + "Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 Chrome/140.0.0.0 Mobile Safari/537.36", + maxTouchPoints: 5, + os: "android", + storeUrl: ANDROID_PLAY_STORE_URL, + }, + { + name: "iPhone", + userAgent: + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1", + maxTouchPoints: 5, + os: "ios", + storeUrl: IOS_APP_STORE_URL, + }, + { + // iPad Safari sends a desktop Mac user agent by default. + name: "iPad with a desktop user agent", + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15", + maxTouchPoints: 5, + os: "ios", + storeUrl: IOS_APP_STORE_URL, + }, + { + name: "macOS", + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15", + maxTouchPoints: 0, + os: "mac", + storeUrl: null, + }, +])("$name visitors get the $os download", ({ userAgent, maxTouchPoints, os, storeUrl }) => { + const platform = detectPlatform(userAgent, maxTouchPoints); + expect(platform?.os).toBe(os); + expect(platform && getMobileStoreUrl(platform)).toBe(storeUrl); +}); diff --git a/apps/marketing/src/lib/platform.ts b/apps/marketing/src/lib/platform.ts new file mode 100644 index 0000000000000..be17e789a6bb2 --- /dev/null +++ b/apps/marketing/src/lib/platform.ts @@ -0,0 +1,22 @@ +import { ANDROID_PLAY_STORE_URL, IOS_APP_STORE_URL } from "./site"; + +export type Platform = { os: "mac" | "win" | "linux" | "ios" | "android" }; + +export function detectPlatform(userAgent: string, maxTouchPoints: number): Platform | null { + // Mobile checks come first: Android user agents contain "Linux" and iOS + // user agents contain "Mac OS X". + if (/Android/i.test(userAgent)) return { os: "android" }; + if (/iPhone|iPad|iPod/i.test(userAgent)) return { os: "ios" }; + if (/Win/i.test(userAgent)) return { os: "win" }; + // iPadOS Safari sends a Mac user agent by default; only the touch screen + // tells it apart from a Mac. + if (/Mac/i.test(userAgent)) return { os: maxTouchPoints > 1 ? "ios" : "mac" }; + if (/Linux/i.test(userAgent)) return { os: "linux" }; + return null; +} + +export function getMobileStoreUrl(platform: Platform): string | null { + if (platform.os === "ios") return IOS_APP_STORE_URL; + if (platform.os === "android") return ANDROID_PLAY_STORE_URL; + return null; +} diff --git a/apps/marketing/src/lib/releases.test.ts b/apps/marketing/src/lib/releases.test.ts new file mode 100644 index 0000000000000..6527342d674e5 --- /dev/null +++ b/apps/marketing/src/lib/releases.test.ts @@ -0,0 +1,18 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { fetchLatestRelease } from "./releases"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("fetchLatestRelease", () => { + it("rejects a GitHub API error response instead of returning it as a release", async () => { + vi.stubGlobal("sessionStorage", { getItem: () => null }); + vi.stubGlobal("fetch", async () => + Response.json({ message: "API rate limit exceeded" }, { status: 403 }), + ); + + await expect(fetchLatestRelease("stable")).rejects.toThrow("403"); + }); +}); diff --git a/apps/marketing/src/lib/releases.ts b/apps/marketing/src/lib/releases.ts index 7dc63bdaed859..f50103972cdd8 100644 --- a/apps/marketing/src/lib/releases.ts +++ b/apps/marketing/src/lib/releases.ts @@ -25,12 +25,18 @@ function cacheKey(channel: ReleaseChannel) { return `t3code-${channel}-release`; } +async function fetchJson(url: string): Promise { + const response = await fetch(url); + if (!response.ok) throw new Error(`GitHub API responded ${response.status}`); + return response.json(); +} + async function fetchStable(): Promise { - return fetch(LATEST_API_URL).then((r) => r.json()); + return fetchJson(LATEST_API_URL); } async function fetchNightly(): Promise { - const list: Release[] = await fetch(LIST_API_URL).then((r) => r.json()); + const list = await fetchJson(LIST_API_URL); const nightly = Array.isArray(list) ? list.find((release) => release.tag_name?.includes("-nightly.")) : undefined; diff --git a/apps/marketing/src/lib/site.ts b/apps/marketing/src/lib/site.ts index c4cc8aca59f22..319f6dd800bcb 100644 --- a/apps/marketing/src/lib/site.ts +++ b/apps/marketing/src/lib/site.ts @@ -5,26 +5,3 @@ export const IOS_APP_STORE_URL = export const ANDROID_PLAY_STORE_URL = "https://play.google.com/store/apps/details?id=com.t3tools.t3code"; - -// Runs at build time, so each deploy picks up the current count. -async function fetchGithubStars(): Promise { - const token = process.env.GITHUB_TOKEN; - try { - const response = await fetch("https://api.github.com/repos/pingdotgg/t3code", { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); - if (!response.ok) throw new Error(`GitHub API responded ${response.status}`); - const { stargazers_count: stars } = (await response.json()) as { stargazers_count: number }; - return stars < 1000 ? String(stars) : `${Math.floor(stars / 1000)}k+`; - } catch (error) { - // A deploy must not ship a made-up count; offline dev should still render. - if (import.meta.env.PROD) throw new Error("Could not fetch GitHub stars", { cause: error }); - console.warn("Could not fetch GitHub stars", error); - return "?"; - } -} - -export const MARKETING_STATS = { - githubStars: await fetchGithubStars(), - users: "500,000", -} as const; diff --git a/apps/marketing/src/lib/stats.ts b/apps/marketing/src/lib/stats.ts new file mode 100644 index 0000000000000..d291f420b06d1 --- /dev/null +++ b/apps/marketing/src/lib/stats.ts @@ -0,0 +1,22 @@ +// Runs at build time, so each deploy picks up the current count. +async function fetchGithubStars(): Promise { + const token = process.env.GITHUB_TOKEN; + try { + const response = await fetch("https://api.github.com/repos/pingdotgg/t3code", { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (!response.ok) throw new Error(`GitHub API responded ${response.status}`); + const { stargazers_count: stars } = (await response.json()) as { stargazers_count: number }; + return stars < 1000 ? String(stars) : `${Math.floor(stars / 1000)}k+`; + } catch (error) { + // A deploy must not ship a made-up count; offline dev should still render. + if (import.meta.env.PROD) throw new Error("Could not fetch GitHub stars", { cause: error }); + console.warn("Could not fetch GitHub stars", error); + return "?"; + } +} + +export const MARKETING_STATS = { + githubStars: await fetchGithubStars(), + users: "500,000", +} as const; diff --git a/apps/marketing/src/pages/95.astro b/apps/marketing/src/pages/95.astro index d6ab25cf867d7..3a9d67f509df9 100644 --- a/apps/marketing/src/pages/95.astro +++ b/apps/marketing/src/pages/95.astro @@ -1,7 +1,8 @@ --- import RetroIcon from "../components/RetroIcon.astro"; import RetroBox from "../components/RetroBox.astro"; -import { GITHUB_REPOSITORY_URL, MARKETING_STATS } from "../lib/site"; +import { GITHUB_REPOSITORY_URL } from "../lib/site"; +import { MARKETING_STATS } from "../lib/stats"; import "../styles/retro.css"; const agents = [ diff --git a/apps/marketing/src/pages/[...locale]/index.astro b/apps/marketing/src/pages/[...locale]/index.astro index 5ae3842180587..3d8d44568d448 100644 --- a/apps/marketing/src/pages/[...locale]/index.astro +++ b/apps/marketing/src/pages/[...locale]/index.astro @@ -6,8 +6,8 @@ import { ANDROID_PLAY_STORE_URL, GITHUB_REPOSITORY_URL, IOS_APP_STORE_URL, - MARKETING_STATS, } from "../../lib/site"; +import { MARKETING_STATS } from "../../lib/stats"; import { tweets } from "../../lib/tweets"; import { softwareJsonLd } from "../../lib/seo"; import { messagesFor } from "../../i18n"; @@ -83,6 +83,8 @@ const mobileEndorsementRows = [ data-label-mac={t.downloadForMac} data-label-win={t.downloadForWindows} data-label-linux={t.downloadForLinux} + data-label-ios={t.downloadForIos} + data-label-android={t.downloadForAndroid} > @@ -413,16 +415,7 @@ const mobileEndorsementRows = [