diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c87a4799c..36d85d70e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -492,6 +492,7 @@ jobs: - name: Test Windows path, spawn, window, and update contracts run: >- cd apps/desktop && npx vitest run + src/main/services/appControl/appControlLaunchCommand.test.ts src/main/services/appControl/appControlService.test.ts src/main/services/shared/processExecution.test.ts src/main/services/updates/autoUpdateService.test.ts diff --git a/apps/ade-cli/src/cursorCloud.ts b/apps/ade-cli/src/cursorCloud.ts index 1478e0b98..ee7fc7aeb 100644 --- a/apps/ade-cli/src/cursorCloud.ts +++ b/apps/ade-cli/src/cursorCloud.ts @@ -18,6 +18,11 @@ import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { + CURSOR_WINDOWS_ARM_BLOCKER, + isCursorProviderSupported, +} from "../../desktop/src/shared/providerPlatformSupport"; + type CursorSdk = typeof import("@cursor/sdk"); const requireFromRuntime = createRequire( @@ -42,6 +47,12 @@ function isCursorSdkResolutionError(error: unknown): boolean { } async function getSdk(): Promise { + // @cursor/sdk publishes no win32-arm64 runtime, so this import can never + // succeed on Windows on ARM. Fail with the reason instead of a bare + // ERR_MODULE_NOT_FOUND. See desktop/src/shared/providerPlatformSupport.ts. + if (!isCursorProviderSupported(process.platform, process.arch)) { + throw new Error(CURSOR_WINDOWS_ARM_BLOCKER); + } if (!sdkModulePromise) { sdkModulePromise = import("@cursor/sdk") .catch((error) => { diff --git a/apps/ade-cli/src/services/credentials/credentialStore.test.ts b/apps/ade-cli/src/services/credentials/credentialStore.test.ts index dbcce21da..187ce4498 100644 --- a/apps/ade-cli/src/services/credentials/credentialStore.test.ts +++ b/apps/ade-cli/src/services/credentials/credentialStore.test.ts @@ -10,6 +10,11 @@ import { KeytarCredentialStore, createDefaultCredentialStore, } from "./credentialStore"; +import { + readOrCreateWindowsDpapiMaterial, + readOrCreateWindowsDpapiMaterialAsync, + resolveWindowsDpapiPowerShellPath, +} from "./windowsDpapiMaterial"; let tempDir = ""; @@ -22,6 +27,93 @@ afterEach(() => { }); describe("EncryptedFileCredentialStore", () => { + it.runIf(process.platform === "win32")( + "resolves Windows DPAPI PowerShell through kernel SystemRoot despite poisoned environment paths", + () => { + const previousSystemRoot = process.env.SystemRoot; + const previousWinDir = process.env.windir; + process.env.SystemRoot = path.join(tempDir, "attacker-system-root"); + process.env.windir = path.join(tempDir, "attacker-windir"); + try { + const resolved = resolveWindowsDpapiPowerShellPath(); + expect(path.win32.isAbsolute(resolved)).toBe(true); + expect(resolved.toLowerCase()).toMatch( + /\\system32\\windowspowershell\\v1\.0\\powershell\.exe$/, + ); + expect(resolved.toLowerCase()).not.toContain(tempDir.toLowerCase()); + } finally { + if (previousSystemRoot === undefined) delete process.env.SystemRoot; + else process.env.SystemRoot = previousSystemRoot; + if (previousWinDir === undefined) delete process.env.windir; + else process.env.windir = previousWinDir; + } + }, + ); + + it.runIf(process.platform === "win32")( + "binds headless credential encryption to the current Windows account with DPAPI", + async () => { + const previousNodeEnv = process.env.NODE_ENV; + const previousVitest = process.env.VITEST; + delete process.env.NODE_ENV; + delete process.env.VITEST; + try { + const syncDir = path.join(tempDir, "sync-dpapi"); + const syncMaterial = readOrCreateWindowsDpapiMaterial(syncDir); + const protectedKeyPath = path.join(syncDir, ".credential-key.dpapi"); + const protectedKey = fs.readFileSync(protectedKeyPath, "utf8"); + + expect(syncMaterial).toHaveLength(32); + expect(protectedKey).toContain("ADE_WINDOWS_DPAPI_KEY_V1"); + expect(protectedKey).not.toContain(syncMaterial.toString("base64")); + expect(readOrCreateWindowsDpapiMaterial(syncDir)).toEqual(syncMaterial); + + const store = new EncryptedFileCredentialStore({ secretsDir: syncDir }); + store.setSync("account.session.v1", "windows-account-session"); + const credentialsPath = path.join(syncDir, "credentials.json.enc"); + const machineKeyPath = path.join(syncDir, ".machine-key"); + expect(fs.readFileSync(credentialsPath, "utf8")) + .not.toContain("windows-account-session"); + + const explicitPathReader = new EncryptedFileCredentialStore({ + credentialsPath, + machineKeyPath, + }); + expect(explicitPathReader.getSync("account.session.v1")) + .toBe("windows-account-session"); + await expect(explicitPathReader.get("account.session.v1")) + .resolves.toBe("windows-account-session"); + + const customCredentialDir = path.join(tempDir, "custom-credential-dir"); + const customKeyDir = path.join(tempDir, "custom-key-dir"); + const customMachineKeyPath = path.join(customKeyDir, ".machine-key"); + const customStore = new EncryptedFileCredentialStore({ + secretsDir: customCredentialDir, + machineKeyPath: customMachineKeyPath, + }); + customStore.setSync("account.session.v1", "custom-key-location"); + expect(fs.existsSync(path.join(customKeyDir, ".credential-key.dpapi"))).toBe(true); + expect(fs.existsSync(path.join(customCredentialDir, ".credential-key.dpapi"))).toBe(false); + expect(new EncryptedFileCredentialStore({ + credentialsPath: path.join(customCredentialDir, "credentials.json.enc"), + machineKeyPath: customMachineKeyPath, + }).getSync("account.session.v1")).toBe("custom-key-location"); + + const asyncDir = path.join(tempDir, "async-dpapi"); + const asyncMaterial = await readOrCreateWindowsDpapiMaterialAsync(asyncDir); + expect(asyncMaterial).toHaveLength(32); + expect(fs.readFileSync(path.join(asyncDir, ".credential-key.dpapi"), "utf8")) + .not.toContain(asyncMaterial.toString("base64")); + } finally { + if (previousNodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = previousNodeEnv; + if (previousVitest === undefined) delete process.env.VITEST; + else process.env.VITEST = previousVitest; + } + }, + 20_000, + ); + it("persists credentials encrypted on disk", async () => { const store = new EncryptedFileCredentialStore({ secretsDir: tempDir }); @@ -195,6 +287,31 @@ new EncryptedFileCredentialStore({ secretsDir }).setSync(key, value); expect(unbound.getSync("linear.token.v1")).toBeNull(); }); + it("atomically binds legacy Windows ciphertext on the first asynchronous credential read", async () => { + const legacyStore = new EncryptedFileCredentialStore({ + secretsDir: tempDir, + keyMaterialProvider: () => null, + }); + legacyStore.setSync("account.session.v1", "legacy-async-windows-session"); + const credentialsPath = path.join(tempDir, "credentials.json.enc"); + const legacyCiphertext = fs.readFileSync(credentialsPath, "utf8"); + const osMaterial = Buffer.from("windows-async-account-bound-material"); + + const upgraded = new EncryptedFileCredentialStore({ + secretsDir: tempDir, + keyMaterialProvider: () => { + throw new Error("async migration must not use synchronous key access"); + }, + keyMaterialProviderAsync: async () => osMaterial, + }); + await expect(upgraded.get("account.session.v1")).resolves.toBe("legacy-async-windows-session"); + expect(fs.readFileSync(credentialsPath, "utf8")).not.toBe(legacyCiphertext); + expect(new EncryptedFileCredentialStore({ + secretsDir: tempDir, + keyMaterialProvider: () => null, + }).getSync("account.session.v1")).toBeNull(); + }); + it("uses the asynchronous key-material path for asynchronous reads", async () => { const osMaterial = Buffer.from("test-os-material"); new EncryptedFileCredentialStore({ @@ -251,23 +368,21 @@ new EncryptedFileCredentialStore({ secretsDir }).setSync(key, value); expect(asyncProvider).not.toHaveBeenCalled(); }); - it("can read legacy machine-key ciphertext before rewriting with OS-bound key material", async () => { + it("atomically binds legacy Windows ciphertext on the first synchronous credential read", () => { const legacy = new EncryptedFileCredentialStore({ secretsDir: tempDir, keyMaterialProvider: () => null, }); legacy.setSync("agent.token", "legacy_secret"); + const credentialPath = path.join(tempDir, "credentials.json.enc"); + const legacyCiphertext = fs.readFileSync(credentialPath, "utf8"); const upgraded = new EncryptedFileCredentialStore({ secretsDir: tempDir, keyMaterialProvider: () => Buffer.from("test-os-material"), }); expect(upgraded.getSync("agent.token")).toBe("legacy_secret"); - expect(legacy.getSync("agent.token")).toBe("legacy_secret"); - - upgraded.setSync("agent.token", "bound_secret"); - - expect(upgraded.getSync("agent.token")).toBe("bound_secret"); + expect(fs.readFileSync(credentialPath, "utf8")).not.toBe(legacyCiphertext); expect(legacy.getSync("agent.token")).toBeNull(); }); diff --git a/apps/ade-cli/src/services/credentials/credentialStore.ts b/apps/ade-cli/src/services/credentials/credentialStore.ts index 4616f78e7..73329399d 100644 --- a/apps/ade-cli/src/services/credentials/credentialStore.ts +++ b/apps/ade-cli/src/services/credentials/credentialStore.ts @@ -3,6 +3,10 @@ import { execFile, execFileSync, spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { resolveMachineAdeLayout } from "../projects/machineLayout"; +import { + readOrCreateWindowsDpapiMaterial, + readOrCreateWindowsDpapiMaterialAsync, +} from "./windowsDpapiMaterial"; export interface CredentialStore { get(key: string): Promise; @@ -57,6 +61,10 @@ const CREDENTIAL_CHANGE_POLL_INTERVAL_MS = 250; const MACOS_KEYCHAIN_READ_TIMEOUT_MS = 2_000; const MACOS_KEYCHAIN_NEGATIVE_CACHE_MS = 30_000; let cachedDefaultOsBoundKeyMaterial: Buffer | null = null; +// Keyed by resolved secrets directory: DPAPI material is protected per +// directory, so unlike the single macOS keychain item these cannot share a slot. +const windowsDpapiMaterialCache = new Map(); +const windowsDpapiReadInFlight = new Map>(); let defaultOsBoundKeyMaterialReadInFlight: Promise | null = null; let lastMissingDefaultOsBoundKeyMaterialAt = 0; @@ -599,11 +607,28 @@ async function readMacKeychainMaterialAsync(): Promise { }); } -function readDefaultOsBoundKeyMaterial(): Buffer | null { +function readDefaultOsBoundKeyMaterial(secretsDir: string): Buffer | null { const envMaterial = readCredentialPassphraseFromEnv(); if (envMaterial) return envMaterial; if (process.env.ADE_CREDENTIAL_STORE_DISABLE_OS_BINDING === "1") return null; if (process.env.VITEST === "true" || process.env.NODE_ENV === "test") return null; + if (process.platform === "win32") { + // Windows re-spawned `powershell.exe` on every credential read, where macOS + // spawns `security` once and caches. That is a far worse trade than it + // looks: PowerShell 5.1 pays CLR load, System.Security from disk, and + // Defender's on-access scan each time. + // + // The cache must be keyed by directory, unlike macOS. Keychain material is + // one global item, but DPAPI material is protected per secrets directory + // (`/.credential-key.dpapi`), so a single shared slot would + // hand one store another store's key. + const key = path.resolve(secretsDir); + const cached = windowsDpapiMaterialCache.get(key); + if (cached) return cached; + const material = readOrCreateWindowsDpapiMaterial(secretsDir); + if (material) windowsDpapiMaterialCache.set(key, material); + return material; + } if (cachedDefaultOsBoundKeyMaterial) return cachedDefaultOsBoundKeyMaterial; const material = readOrCreateMacKeychainMaterial(); if (material) { @@ -613,11 +638,36 @@ function readDefaultOsBoundKeyMaterial(): Buffer | null { return material; } -async function readDefaultOsBoundKeyMaterialAsync(): Promise { +async function readDefaultOsBoundKeyMaterialAsync(secretsDir: string): Promise { const envMaterial = readCredentialPassphraseFromEnv(); if (envMaterial) return envMaterial; if (process.env.ADE_CREDENTIAL_STORE_DISABLE_OS_BINDING === "1") return null; if (process.env.VITEST === "true" || process.env.NODE_ENV === "test") return null; + if (process.platform === "win32") { + const key = path.resolve(secretsDir); + const cached = windowsDpapiMaterialCache.get(key); + if (cached) return cached; + // In-flight dedup matters more here than it ever did on macOS: without it, + // concurrent credential reads each spawn their own PowerShell, and that + // contention is what makes a cold start slow enough to hit the timeout. + // No negative cache -- a locked keychain is a durable state worth backing + // off from, but a DPAPI failure is usually a transient timeout, and + // suppressing retries would make one slow cold start look permanent. + const pending = windowsDpapiReadInFlight.get(key); + if (pending) return await pending; + const inFlight = readOrCreateWindowsDpapiMaterialAsync(secretsDir).then((material) => { + if (material) windowsDpapiMaterialCache.set(key, material); + return material; + }); + windowsDpapiReadInFlight.set(key, inFlight); + try { + return await inFlight; + } finally { + if (windowsDpapiReadInFlight.get(key) === inFlight) { + windowsDpapiReadInFlight.delete(key); + } + } + } if (cachedDefaultOsBoundKeyMaterial) return cachedDefaultOsBoundKeyMaterial; if ( lastMissingDefaultOsBoundKeyMaterialAt > 0 @@ -675,12 +725,14 @@ export class EncryptedFileCredentialStore implements SyncCredentialStore { const secretsDir = args.secretsDir ?? resolveMachineAdeLayout().secretsDir; this.credentialsPath = args.credentialsPath ?? path.join(secretsDir, DEFAULT_CREDENTIALS_FILE); this.machineKeyPath = args.machineKeyPath ?? path.join(secretsDir, DEFAULT_MACHINE_KEY_FILE); + const osBindingDir = path.dirname(this.machineKeyPath); this.lockPath = args.lockPath ?? defaultLockPath(this.credentialsPath); - this.keyMaterialProvider = args.keyMaterialProvider ?? readDefaultOsBoundKeyMaterial; + this.keyMaterialProvider = args.keyMaterialProvider + ?? (() => readDefaultOsBoundKeyMaterial(osBindingDir)); this.keyMaterialProviderAsync = args.keyMaterialProviderAsync ?? (args.keyMaterialProvider ? async () => args.keyMaterialProvider?.() ?? null - : readDefaultOsBoundKeyMaterialAsync); + : () => readDefaultOsBoundKeyMaterialAsync(osBindingDir)); this.credentialChangePollIntervalMs = args.credentialChangePollIntervalMs === undefined ? CREDENTIAL_CHANGE_POLL_INTERVAL_MS : args.credentialChangePollIntervalMs; @@ -707,7 +759,9 @@ export class EncryptedFileCredentialStore implements SyncCredentialStore { getSync(key: string): string | null { const normalized = normalizeKey(key); - return this.readAll({ allowRewrite: false })[normalized] ?? null; + return this.withLock( + () => this.readAll({ allowRewrite: false, migrateLegacy: true })[normalized] ?? null, + ); } getLastReadState(): CredentialStoreReadState { @@ -774,7 +828,7 @@ export class EncryptedFileCredentialStore implements SyncCredentialStore { return this.readAll({ allowRewrite: false }); } - private readAll(args: { allowRewrite: boolean }): Record { + private readAll(args: { allowRewrite: boolean; migrateLegacy?: boolean }): Record { const credentialsExist = fs.existsSync(this.credentialsPath); const raw = readJsonObject(this.credentialsPath); const machineKey = readOrCreateMachineKey(this.machineKeyPath); @@ -797,12 +851,8 @@ export class EncryptedFileCredentialStore implements SyncCredentialStore { throw error; } this.lastReadState = credentialsExist ? "available" : "missing"; - if (args.allowRewrite) { - try { - this.writeAll(values); - } catch { - // Preserve read compatibility if migration cannot rewrite right now. - } + if (args.allowRewrite || args.migrateLegacy) { + this.writeAllWithKey(values, key); } return values; } @@ -831,7 +881,8 @@ export class EncryptedFileCredentialStore implements SyncCredentialStore { throw new Error("Unsupported ADE credential store format."); } const machineKey = await readOrCreateMachineKeyAsync(this.machineKeyPath); - const key = deriveOsBoundCredentialKey(machineKey, await this.keyMaterialProviderAsync()); + const osMaterial = await this.keyMaterialProviderAsync(); + const key = deriveOsBoundCredentialKey(machineKey, osMaterial); if (!key.equals(machineKey)) { try { const values = deserializeStore(raw, key, { emptyOnDecryptFailure: false }); @@ -839,7 +890,16 @@ export class EncryptedFileCredentialStore implements SyncCredentialStore { return values; } catch { try { - const values = deserializeStore(raw, machineKey, { emptyOnDecryptFailure: false }); + deserializeStore(raw, machineKey, { emptyOnDecryptFailure: false }); + } catch (error) { + this.lastReadState = "unreadable"; + throw error; + } + try { + if (!osMaterial || osMaterial.length === 0) { + throw new Error("OS-bound credential material is unavailable during migration."); + } + const values = this.withLock(() => this.migrateLegacyUnderLock(osMaterial)); this.lastReadState = "available"; return values; } catch (error) { @@ -861,9 +921,26 @@ export class EncryptedFileCredentialStore implements SyncCredentialStore { private writeAll(values: Record): void { const machineKey = readOrCreateMachineKey(this.machineKeyPath); const key = deriveOsBoundCredentialKey(machineKey, this.keyMaterialProvider()); + this.writeAllWithKey(values, key); + } + + private writeAllWithKey(values: Record, key: Buffer): void { writeFileAtomic(this.credentialsPath, `${JSON.stringify(serializeStore(values, key), null, 2)}\n`); } + private migrateLegacyUnderLock(osMaterial: Buffer): Record { + const raw = readJsonObject(this.credentialsPath); + const machineKey = readOrCreateMachineKey(this.machineKeyPath); + const key = deriveOsBoundCredentialKey(machineKey, osMaterial); + try { + return deserializeStore(raw, key, { emptyOnDecryptFailure: false }); + } catch { + const values = deserializeStore(raw, machineKey, { emptyOnDecryptFailure: false }); + this.writeAllWithKey(values, key); + return values; + } + } + private withLock(fn: () => T): T { return withCredentialFileLock(this.lockPath, fn); } diff --git a/apps/ade-cli/src/services/credentials/windowsDpapiMaterial.ts b/apps/ade-cli/src/services/credentials/windowsDpapiMaterial.ts new file mode 100644 index 000000000..83e760406 --- /dev/null +++ b/apps/ade-cli/src/services/credentials/windowsDpapiMaterial.ts @@ -0,0 +1,308 @@ +import crypto from "node:crypto"; +import { spawn, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +const WINDOWS_DPAPI_KEY_FILE = ".credential-key.dpapi"; +const WINDOWS_DPAPI_KEY_MAGIC = "ADE_WINDOWS_DPAPI_KEY_V1"; +/** + * DPAPI itself is a local, sub-millisecond call; essentially the whole budget + * pays for a Windows PowerShell 5.1 cold start. That start is not bounded by + * anything ADE controls - it loads the CLR and the System.Security assembly + * from disk, and Defender's on-access scanner inspects powershell.exe and each + * assembly the first time they are touched. On a contended machine (a CI + * runner, or a laptop right after login) it routinely runs several seconds, + * which a 5s budget turned into a hard "credentials are unavailable" failure + * for a helper that had done nothing wrong. Bound the helper generously + * instead: waiting longer only costs time in the case that was already broken, + * while a tight bound costs the user their credentials. + */ +const WINDOWS_DPAPI_TIMEOUT_MS = 30_000; +const WINDOWS_DPAPI_MAX_OUTPUT_BYTES = 64 * 1024; +const WINDOWS_DPAPI_POWERSHELL_KERNEL_PATH = + "\\\\?\\GLOBALROOT\\SystemRoot\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"; + +const cachedKeyMaterial = new Map(); +const keyMaterialReadInFlight = new Map>(); + +const WINDOWS_DPAPI_SCRIPT = [ + "$ErrorActionPreference = 'Stop'", + "Add-Type -AssemblyName System.Security", + "$inputBytes = [Convert]::FromBase64String([Console]::In.ReadToEnd().Trim())", + "$scope = [Security.Cryptography.DataProtectionScope]::CurrentUser", + "if ($env:ADE_DPAPI_OPERATION -eq 'protect') {", + " $outputBytes = [Security.Cryptography.ProtectedData]::Protect($inputBytes, $null, $scope)", + "} elseif ($env:ADE_DPAPI_OPERATION -eq 'unprotect') {", + " $outputBytes = [Security.Cryptography.ProtectedData]::Unprotect($inputBytes, $null, $scope)", + "} else {", + " throw 'Unknown DPAPI operation.'", + "}", + "[Console]::Out.Write([Convert]::ToBase64String($outputBytes))", +].join("; "); + +function isNodeErrorCode(error: unknown, code: string): boolean { + return typeof error === "object" + && error !== null + && "code" in error + && (error as { code?: unknown }).code === code; +} + +function ensureDirectory(dirPath: string): void { + fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 }); +} + +function parseProtectedKeyFile(raw: string): Buffer { + const [magic, encoded, ...rest] = raw.trim().split(/\r?\n/); + if (magic !== WINDOWS_DPAPI_KEY_MAGIC || !encoded || rest.length > 0) { + throw new Error("ADE Windows credential key has an unsupported format."); + } + const protectedKey = Buffer.from(encoded, "base64"); + if (protectedKey.length === 0) { + throw new Error("ADE Windows credential key is invalid."); + } + return protectedKey; +} + +function decodeDpapiResult(raw: string): Buffer { + const value = raw.trim(); + const decoded = value ? Buffer.from(value, "base64") : Buffer.alloc(0); + if (decoded.length === 0) { + throw new Error("Windows DPAPI returned an empty credential key."); + } + return decoded; +} + +function dpapiChildEnv(operation: "protect" | "unprotect"): NodeJS.ProcessEnv { + const allowed = new Set([ + "comspec", + "path", + "pathext", + "psmodulepath", + "systemroot", + "temp", + "tmp", + "windir", + ]); + const env: NodeJS.ProcessEnv = { ADE_DPAPI_OPERATION: operation }; + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined && allowed.has(key.toLowerCase())) env[key] = value; + } + return env; +} + +function dpapiArguments(): string[] { + return [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + WINDOWS_DPAPI_SCRIPT, + ]; +} + +/** + * Resolve Windows PowerShell through the kernel-owned SystemRoot link. The + * mutable SystemRoot/windir environment and CreateProcess executable search + * are intentionally not involved, so an untrusted project or poisoned launch + * environment cannot redirect the DPAPI helper. + */ +export function resolveWindowsDpapiPowerShellPath(): string { + try { + const resolved = path.win32.normalize( + fs.realpathSync.native(WINDOWS_DPAPI_POWERSHELL_KERNEL_PATH), + ); + const parsed = path.win32.parse(resolved); + const expectedSuffix = "\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"; + if ( + !path.win32.isAbsolute(resolved) + || !/^[A-Za-z]:\\$/.test(parsed.root) + || !resolved.toLowerCase().endsWith(expectedSuffix.toLowerCase()) + || !fs.statSync(resolved).isFile() + ) { + throw new Error("invalid system PowerShell path"); + } + return resolved; + } catch { + throw new Error("Windows DPAPI credential protection is unavailable."); + } +} + +function runDpapiSync(operation: "protect" | "unprotect", value: Buffer): Buffer { + const result = spawnSync(resolveWindowsDpapiPowerShellPath(), dpapiArguments(), { + encoding: "utf8", + env: dpapiChildEnv(operation), + input: value.toString("base64"), + maxBuffer: WINDOWS_DPAPI_MAX_OUTPUT_BYTES, + timeout: WINDOWS_DPAPI_TIMEOUT_MS, + windowsHide: true, + }); + if (result.error) { + // spawnSync folds "could not start" and "ran past the deadline" into the + // same field. They are different diagnoses - one means the helper is + // missing or blocked, the other means the machine was busy - and the async + // path already reports them apart. + if (isNodeErrorCode(result.error, "ETIMEDOUT")) { + throw new Error("Windows DPAPI credential protection timed out."); + } + throw new Error("Windows DPAPI credential protection is unavailable."); + } + if (result.status !== 0) { + throw new Error("Windows DPAPI credential protection failed."); + } + return decodeDpapiResult(result.stdout ?? ""); +} + +function runDpapiAsync(operation: "protect" | "unprotect", value: Buffer): Promise { + return new Promise((resolve, reject) => { + const child = spawn(resolveWindowsDpapiPowerShellPath(), dpapiArguments(), { + stdio: ["pipe", "pipe", "pipe"], + env: dpapiChildEnv(operation), + windowsHide: true, + }); + const stdout: Buffer[] = []; + let stdoutBytes = 0; + let settled = false; + const finish = (error: Error | null, output?: Buffer): void => { + if (settled) return; + settled = true; + clearTimeout(timeout); + if (error) reject(error); + else resolve(output ?? Buffer.alloc(0)); + }; + const timeout = setTimeout(() => { + child.kill(); + finish(new Error("Windows DPAPI credential protection timed out.")); + }, WINDOWS_DPAPI_TIMEOUT_MS); + timeout.unref?.(); + child.once("error", () => { + finish(new Error("Windows DPAPI credential protection is unavailable.")); + }); + child.stdout.on("data", (chunk: Buffer | string) => { + const next = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + stdoutBytes += next.length; + if (stdoutBytes > WINDOWS_DPAPI_MAX_OUTPUT_BYTES) { + child.kill(); + finish(new Error("Windows DPAPI credential protection returned too much data.")); + return; + } + stdout.push(next); + }); + // Drain stderr without retaining it. PowerShell errors can contain host + // details, and diagnostics never need the protected key or credential input. + child.stderr.resume(); + child.stdin.once("error", () => { + finish(new Error("Windows DPAPI credential protection input failed.")); + }); + child.once("close", (code) => { + if (settled) return; + if (code !== 0) { + finish(new Error("Windows DPAPI credential protection failed.")); + return; + } + try { + finish(null, decodeDpapiResult(Buffer.concat(stdout).toString("utf8"))); + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))); + } + }); + child.stdin.end(value.toString("base64")); + }); +} + +function protectedKeyPath(secretsDir: string): string { + return path.resolve(secretsDir, WINDOWS_DPAPI_KEY_FILE); +} + +function unprotectKey(keyPath: string): Buffer { + const material = runDpapiSync( + "unprotect", + parseProtectedKeyFile(fs.readFileSync(keyPath, "utf8")), + ); + if (material.length !== 32) throw new Error("ADE Windows credential key is invalid."); + return material; +} + +async function unprotectKeyAsync(keyPath: string): Promise { + const material = await runDpapiAsync( + "unprotect", + parseProtectedKeyFile(await fs.promises.readFile(keyPath, "utf8")), + ); + if (material.length !== 32) throw new Error("ADE Windows credential key is invalid."); + return material; +} + +/** + * Returns a per-user, per-ADE-home key protected by Windows DPAPI. The random + * key crosses the PowerShell boundary only on stdin/stdout and the persisted + * blob is unusable from another Windows account. + */ +export function readOrCreateWindowsDpapiMaterial(secretsDir: string): Buffer { + const keyPath = protectedKeyPath(secretsDir); + const cached = cachedKeyMaterial.get(keyPath); + if (cached) return cached; + + let material: Buffer; + try { + material = unprotectKey(keyPath); + } catch (error) { + if (!isNodeErrorCode(error, "ENOENT")) throw error; + material = crypto.randomBytes(32); + const protectedKey = runDpapiSync("protect", material); + ensureDirectory(path.dirname(keyPath)); + try { + fs.writeFileSync( + keyPath, + `${WINDOWS_DPAPI_KEY_MAGIC}\n${protectedKey.toString("base64")}\n`, + { flag: "wx", mode: 0o600 }, + ); + } catch (writeError) { + if (!isNodeErrorCode(writeError, "EEXIST")) throw writeError; + material = unprotectKey(keyPath); + } + } + cachedKeyMaterial.set(keyPath, material); + return material; +} + +/** Async counterpart used by brain-facing credential reads. */ +export async function readOrCreateWindowsDpapiMaterialAsync(secretsDir: string): Promise { + const keyPath = protectedKeyPath(secretsDir); + const cached = cachedKeyMaterial.get(keyPath); + if (cached) return cached; + const existing = keyMaterialReadInFlight.get(keyPath); + if (existing) return await existing; + + const read = (async () => { + let material: Buffer; + try { + material = await unprotectKeyAsync(keyPath); + } catch (error) { + if (!isNodeErrorCode(error, "ENOENT")) throw error; + material = crypto.randomBytes(32); + const protectedKey = await runDpapiAsync("protect", material); + await fs.promises.mkdir(path.dirname(keyPath), { recursive: true, mode: 0o700 }); + try { + await fs.promises.writeFile( + keyPath, + `${WINDOWS_DPAPI_KEY_MAGIC}\n${protectedKey.toString("base64")}\n`, + { flag: "wx", mode: 0o600 }, + ); + } catch (writeError) { + if (!isNodeErrorCode(writeError, "EEXIST")) throw writeError; + material = await unprotectKeyAsync(keyPath); + } + } + cachedKeyMaterial.set(keyPath, material); + return material; + })(); + keyMaterialReadInFlight.set(keyPath, read); + try { + return await read; + } finally { + if (keyMaterialReadInFlight.get(keyPath) === read) { + keyMaterialReadInFlight.delete(keyPath); + } + } +} diff --git a/apps/desktop/src/main/services/account/accountBridge.ts b/apps/desktop/src/main/services/account/accountBridge.ts index 1c3f75159..1fb81f55f 100644 --- a/apps/desktop/src/main/services/account/accountBridge.ts +++ b/apps/desktop/src/main/services/account/accountBridge.ts @@ -32,6 +32,7 @@ import type { AdeAccountMachinesResult, AdeAccountPairMachineProgress, AdeAccountLoginPoll, + AdeAccountSessionReadState, AdeAccountStatus, } from "../../../shared/types"; import { @@ -115,6 +116,7 @@ function resolveDirectoryBaseUrl(projectRoot: string | null): string | null { function toAccountStatus( status: AccountAuthStatus, configured: boolean, + sessionReadState?: AdeAccountSessionReadState, ): AdeAccountStatus { return { signedIn: status.signedIn, @@ -125,6 +127,7 @@ function toAccountStatus( provider: status.provider ?? null, imageUrl: status.imageUrl ?? null, configured, + ...(sessionReadState ? { sessionReadState } : {}), }; } @@ -229,7 +232,17 @@ export function createAccountBridge(options: AccountBridgeOptions): AccountBridg }; return { - status: () => toAccountStatus(service().getStatus(), configured()), + status: () => { + const accountService = service(); + // Read the state alongside the status: `signedIn: false` with an + // "unreadable" session is a failed decrypt, not a sign-out, and the + // renderer must be able to tell them apart. + const status = accountService.getStatus(); + // Optional call: a runtime that predates the split simply reports no read + // state, and the renderer then falls back to its previous behaviour. + const readState = accountService.getSessionReadState?.(); + return toAccountStatus(status, configured(), readState); + }, startLogin: () => { // Prioritize the active project's CLERK_* secrets for config resolution. diff --git a/apps/desktop/src/main/services/ai/authDetector.test.ts b/apps/desktop/src/main/services/ai/authDetector.test.ts index 02b375c8c..5fa5ff682 100644 --- a/apps/desktop/src/main/services/ai/authDetector.test.ts +++ b/apps/desktop/src/main/services/ai/authDetector.test.ts @@ -338,7 +338,7 @@ describe("authDetector", () => { ); }); - it("treats droid exec list-tools as a valid authenticated probe", async () => { + it("does not treat droid exec list-tools as proof of authentication", async () => { tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-droid-auth-")); process.env.HOME = tempHomeDir; // Create a fake droid binary in a known bin dir so resolveDroidExecutable @@ -361,14 +361,11 @@ describe("authDetector", () => { if (args[0] === "droid") return fakeChild({ status: 0, stdout: `${fakeDroidPath}\n` }); return fakeChild({ status: 1 }); } + // Faithful to droid v0.186.0: `exec --list-tools` exits 0 and prints the + // local tool policy with no Factory account at all, so it says nothing + // about authentication. if (commandBasename(command) === "droid" && args[0] === "exec" && args[1] === "--list-tools") { - return fakeChild({ status: 0, stdout: "Available tools for Claude Opus 4.6\n" }); - } - if (commandBasename(command) === "droid" && args[0] === "account") { - return fakeChild({ status: 1, stderr: "unknown command 'account'\n" }); - } - if (commandBasename(command) === "droid" && args[0] === "whoami") { - return fakeChild({ status: 1, stderr: "unknown command 'whoami'\n" }); + return fakeChild({ status: 0, stdout: "Available tools for Opus 5\nAutonomy: read-only\n" }); } return fakeChild({ status: 1 }); }); @@ -376,13 +373,25 @@ describe("authDetector", () => { const statuses = await detectCliAuthStatuses({ force: true }); const droid = statuses.find((entry) => entry.cli === "droid"); + // Installed, auth unknown — not "authenticated and verified". The real CLI + // in this state answers `droid exec "say hi"` with "Authentication failed." expect(droid).toEqual({ cli: "droid", installed: true, path: fakeDroidPath, - authenticated: true, - verified: true, + authenticated: false, + verified: false, }); + + // `whoami` and `account status` are not subcommands on v0.186.0, so droid + // took each as a prompt and booted the interactive TUI until the spawn + // timeout. Nothing may spawn them again. + const droidArgs = spawnMock.mock.calls + .filter(([command]) => commandBasename(String(command)) === "droid") + .map(([, args]) => ((args ?? []) as string[]).join(" ")); + expect(droidArgs).not.toContain("whoami"); + expect(droidArgs).not.toContain("account status"); + expect(droidArgs).not.toContain("exec --list-tools"); }); it("skips deep Droid auth probes during default detection without stored credentials", async () => { diff --git a/apps/desktop/src/main/services/ai/authDetector.ts b/apps/desktop/src/main/services/ai/authDetector.ts index 631c1f63b..fea0510d8 100644 --- a/apps/desktop/src/main/services/ai/authDetector.ts +++ b/apps/desktop/src/main/services/ai/authDetector.ts @@ -83,7 +83,11 @@ const CLI_AUTH_PROBES: Record = { ["status", "--json"], ["status"], ], - droid: [["--version"], ["-V"], ["version"]], + // Documented flags only. `droid --help` on v0.186.0 lists exec/daemon/search/ + // update/mcp/plugin/computer/help and nothing else, so anything that is not a + // real subcommand — `version`, `whoami`, `account status` — is taken as a + // *prompt* and boots the full interactive TUI, burning the spawn timeout. + droid: [["--version"], ["-v"]], }; function cliSpawnCommands(cli: CliName): readonly string[] { @@ -108,6 +112,10 @@ const AUTH_INDICATORS = [ const STRONG_UNAUTH_INDICATORS = [ /not logged in/i, /not authenticated/i, + // Droid's real refusal, verbatim from v0.186.0: "Error during droid + // execution: Authentication failed. Please log in using /login or set a valid + // FACTORY_API_KEY environment variable." None of the other patterns match it. + /authentication failed/i, /login required/i, /sign in required/i, /unauthorized/i, @@ -145,59 +153,71 @@ function findExplicitCommandPath(command: string): string | null { return resolveExecutableFromKnownLocations(command)?.path ?? null; } -async function commandExists(command: string): Promise { +/** + * Resolve where a CLI actually lives, or null when it is not installed. + * + * One function answers both "is it installed" and "what do we launch", so the + * Settings card can never advertise a provider the chat runtime cannot spawn: + * `installed` is exactly "this file exists" and `path` is exactly that file, + * which is what flows into {@link DetectedAuth} and on into + * `resolveClaudeCodeExecutable`/`resolveDroidExecutable`. + * + * Windows: never probe by exit code. `spawnAsync` routes extension-less + * commands through `cmd.exe /d /s /c "…"` (see `resolveCliSpawnInvocation`), + * and cmd.exe itself always starts — a missing binary comes back as exit 1 + * with `'claude' is not recognized as an internal or external command`, not as + * the ENOENT spawn error (`status === null`) that means "missing" on + * macOS/Linux. An exit-code probe therefore reports *every* CLI as installed + * on Windows. `where.exe` + the known-install-dir scan (which honours PATHEXT) + * answer the question honestly. + */ +async function resolveCommandLocation(command: string): Promise { const explicitPath = findExplicitCommandPath(command); - if (explicitPath) return true; + if (explicitPath) return explicitPath; - // Strategy 1: Direct spawn — bypasses shell init (.zshrc errors, slow profiles). - // If the binary exists, --version will produce *some* exit code. - // A spawn error (ENOENT) means the binary isn't on PATH → status is null. - try { - const direct = await spawnAsync(command, ["--version"], { timeout: 5_000 }); - if (direct.status !== null) return true; - } catch { - // fall through to shell-based check + if (process.platform === "win32") { + try { + // Spell the lookup `where.exe`, not `where`. `spawnAsync` routes an + // *extensionless* command through `cmd.exe /d /s /c "…"`; the extension + // here keeps the probe a direct spawn with no wrapper. Measured: direct + // `where.exe` 57.7ms vs `cmd + where` 73.7ms per lookup, and because the + // wrapper is what blocks the main thread, the worst *unrelated* IPC + // observed during a probe drops from 1364.5ms to 18.2ms. + const result = await spawnAsync("where.exe", [command], { timeout: 5_000 }); + if (result.status === 0) { + const first = (result.stdout ?? "").trim().split(/\r?\n/)[0]?.trim(); + if (first) return first; + } + } catch { + // Treat a failed lookup as "not installed" rather than guessing. + } + return null; } - // Strategy 2: Shell-based lookup (fallback for edge cases) + // POSIX: a direct spawn bypasses shell init (.zshrc errors, slow profiles), + // and here ENOENT really does surface as `status === null`. try { - if (process.platform === "win32") { - const result = await spawnAsync("where", [command], { timeout: 5_000 }); - return result.status === 0; + const direct = await spawnAsync(command, ["--version"], { timeout: 5_000 }); + if (direct.status !== null) { + const which = await spawnAsync("which", [command], { timeout: 3_000 }); + const line = which.status === 0 ? (which.stdout ?? "").trim() : ""; + return line || command; } - const result = await spawnAsync(getLookupShell(), ["-lc", 'command -v "$1" >/dev/null 2>&1', "--", command], { timeout: 5_000 }); - return result.status === 0; } catch { - // fall through to explicit common-path lookup + // fall through to shell-based lookup } - return explicitPath != null; -} - -async function commandPath(command: string): Promise { try { - if (process.platform === "win32") { - const result = await spawnAsync("where", [command], { timeout: 5_000 }); - if (result.status === 0 && result.stdout?.trim()) { - return result.stdout.trim().split(/\r?\n/)[0] ?? command; - } - return findExplicitCommandPath(command) ?? command; - } - // Try which first (simpler, doesn't load full login shell) - const which = await spawnAsync("which", [command], { timeout: 3_000 }); - if (which.status === 0 && which.stdout?.trim()) { - return which.stdout.trim(); - } - const explicitPath = findExplicitCommandPath(command); - if (explicitPath) { - return explicitPath; - } - // Fallback to login shell lookup const result = await spawnAsync(getLookupShell(), ["-lc", 'command -v "$1"', "--", command], { timeout: 5_000 }); - return result.stdout?.trim() || command; + if (result.status === 0) { + const line = (result.stdout ?? "").trim(); + if (line) return line; + } } catch { - return findExplicitCommandPath(command) ?? command; + // Not installed. } + + return null; } async function refreshProcessPathFromShell(): Promise { @@ -386,6 +406,22 @@ async function inspectCursorCliAuthentication(command: string): Promise<{ return { authenticated: false, verified: false, paidPlan: false }; } +/** + * Best-effort check for a Factory credential ADE can see without launching droid. + * + * KNOWN GAP, do not rediscover from scratch: on v0.186.0 `~/.factory/settings.json` + * holds UI preferences only — the real file on a signed-out machine is + * `{"logoAnimation":"off"}` — and no credential-shaped file exists anywhere under + * `~/.factory` (verified: cache/, certs/, droids/, logs/, sessions/, snapshots/, + * telemetry/, temp/ and four small JSON state files, none of them auth). A stack + * trace in `~/.factory/logs` names a dedicated + * `packages/runtime/auth/src/credentials/CredentialsStorage.ts`, so tokens almost + * certainly live somewhere else in a format we have not seen. Confirming that + * needs a signed-in Factory account, which this machine does not have, so the + * settings.json read stays (harmless, and correct if Factory ever writes there) + * and `false` continues to mean "no credential ADE can see" — never "signed out". + * Callers must not turn a false here into `verified: true`. + */ async function hasDroidConfiguredCredentials(): Promise { if (process.env.FACTORY_API_KEY?.trim()) { return true; @@ -438,42 +474,25 @@ async function inspectDroidCliPresence(command: string, options?: { deep?: boole return { installed: true, authenticated: true, verified: true }; } - try { - const result = await spawnAsync(command, ["exec", "--list-tools"], { timeout: 12_000 }); - const combined = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim(); - const normalized = combined.toLowerCase(); - if (hasPattern(normalized, STRONG_UNAUTH_INDICATORS)) { - return { installed: true, authenticated: false, verified: true }; - } - if (result.status === 0) { - return { installed: true, authenticated: true, verified: true }; - } - if (hasPattern(normalized, AUTH_INDICATORS)) { - return { installed: true, authenticated: true, verified: true }; - } - } catch { - // Current Droid releases may not support this probe or it may time out; fall back. - } - - const authProbes: string[][] = [ - ["account", "status"], - ["whoami"], - ]; - for (const args of authProbes) { - try { - const result = await spawnAsync(command, args, { timeout: 12_000 }); - const combined = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim(); - if (hasPattern(combined, STRONG_UNAUTH_INDICATORS)) { - return { installed: true, authenticated: false, verified: true }; - } - if (hasPattern(combined, AUTH_INDICATORS)) { - return { installed: true, authenticated: true, verified: true }; - } - } catch { - // try next probe - } - } - + // Nothing further to ask. Droid v0.186.0 exposes no cheap auth probe, and the + // three this used to run were all wrong: + // + // `droid exec --list-tools` exits 0 with no account at all — it prints the + // local tool policy and never contacts Factory — so it reported a signed-out + // machine as authenticated *and verified*. Measured: exit 0 and a full tool + // listing here, while `droid exec "say hi"` returns "Authentication failed." + // + // `whoami` and `account status` are not subcommands (see CLI_AUTH_PROBES), + // so each booted the interactive TUI and ran until the spawn timeout — a + // forced refresh spawned a 150MB agent twice to learn nothing. + // + // The only authoritative signal is a real `droid exec` round trip, which costs + // a model call on a signed-in machine and cannot be a detection probe. So stop + // at "installed, auth unknown": `verified: false` keeps this out of the + // explicitly-signed-out state, and buildProviderConnections renders it as + // "installed but no credentials were detected", which is exactly true. This + // now agrees with the shallow path — before, forcing a refresh made the answer + // worse, which is the opposite of what a refresh button should do. return { installed: true, authenticated: false, verified: false }; } @@ -1109,15 +1128,16 @@ export async function detectCliAuthStatuses(options?: { force?: boolean; skipAut const statuses = await Promise.all( cliChecks.map(async (cli) => { let spawnName = cliSpawnCommand(cli); - let installed = false; + let path: string | null = null; for (const candidate of cliSpawnCommands(cli)) { - if (await commandExists(candidate)) { + const location = await resolveCommandLocation(candidate); + if (location) { spawnName = candidate; - installed = true; + path = location; break; } } - const path = installed ? await commandPath(spawnName) : null; + const installed = path !== null; const cmd = path ?? spawnName; if (!installed) { return { @@ -1150,29 +1170,15 @@ export async function detectCliAuthStatuses(options?: { force?: boolean; skipAut }; } if (cli === "droid") { - // Prefer the path we already proved via commandPath() above; only fall - // back to resolveDroidExecutable() when commandPath() failed. - let droidPath: string; - if (path) { - droidPath = path; - } else { - const resolved = resolveDroidExecutable({ env: process.env }); - if (resolved.source === "fallback-command") { - return { - cli, - installed: false, - path: null, - authenticated: false, - verified: false, - }; - } - droidPath = resolved.path; - } - const auth = await inspectDroidCliPresence(droidPath, { deep: options?.force === true }); + // `path` is a file resolveCommandLocation() proved exists, so it is + // strictly better than resolveDroidExecutable(), whose last resort is + // the bare command name. Reached only when installed, so the shallow + // presence check below is asking about credentials, not existence. + const auth = await inspectDroidCliPresence(cmd, { deep: options?.force === true }); return { cli, installed: auth.installed, - path: droidPath, + path, authenticated: auth.authenticated, verified: auth.verified, }; diff --git a/apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts b/apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts index d4788d0ea..33d8cc732 100644 --- a/apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts +++ b/apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts @@ -14,6 +14,15 @@ import { const originalPlatform = process.platform; const originalPathDelimiter = path.delimiter; +/** + * Windows cannot execute an extension-less file, so a fixture that stands in + * for an installed CLI has to carry a PATHEXT extension there — the same shape + * `npm i -g` produces (`codex.cmd` next to the `#!/bin/sh` `codex`). + */ +function executableFileName(command: string): string { + return process.platform === "win32" ? `${command}.cmd` : command; +} + function makeExecutable(filePath: string): void { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, "#!/bin/sh\nexit 0\n", "utf8"); @@ -55,7 +64,7 @@ describe("cliExecutableResolver", () => { tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-path-")); const homeDir = path.join(tempRoot, "home"); const prefixDir = path.join(homeDir, ".npm-global"); - makeExecutable(path.join(prefixDir, "bin", "codex")); + makeExecutable(path.join(prefixDir, "bin", executableFileName("codex"))); fs.mkdirSync(homeDir, { recursive: true }); fs.writeFileSync(path.join(homeDir, ".npmrc"), "prefix=~/.npm-global\n", "utf8"); @@ -79,7 +88,7 @@ describe("cliExecutableResolver", () => { }; expect(resolveExecutableFromKnownLocations("codex", env)).toEqual({ - path: path.join(prefixDir, "bin", "codex"), + path: path.join(prefixDir, "bin", executableFileName("codex")), source: "known-dir", }); }); @@ -99,6 +108,10 @@ describe("cliExecutableResolver", () => { }); it("keeps both Intel and Apple Silicon Homebrew bins on PATH", () => { + // Homebrew is a macOS layout claim, and PATH parsing is delimiter-sensitive, + // so pin the platform instead of inheriting the host's. + setPlatform("darwin"); + setPathDelimiter(":"); const nextPath = augmentPathWithKnownCliDirs("/usr/local/bin:/usr/bin:/bin", { HOME: "/tmp/ade-home", PATH: "/usr/local/bin:/usr/bin:/bin", @@ -115,9 +128,9 @@ describe("cliExecutableResolver", () => { const firstBin = path.join(tempRoot, "first"); const secondBin = path.join(tempRoot, "second"); const knownBin = path.join(homeDir, ".local", "bin"); - makeExecutable(path.join(firstBin, "git")); - makeExecutable(path.join(secondBin, "git")); - makeExecutable(path.join(knownBin, "git")); + makeExecutable(path.join(firstBin, executableFileName("git"))); + makeExecutable(path.join(secondBin, executableFileName("git"))); + makeExecutable(path.join(knownBin, executableFileName("git"))); const realStatSync = fs.statSync; vi.spyOn(fs, "statSync").mockImplementation(((p: fs.PathLike, opts?: any) => { @@ -136,9 +149,9 @@ describe("cliExecutableResolver", () => { }); expect(candidates.slice(0, 3)).toEqual([ - { path: path.join(firstBin, "git"), source: "path" }, - { path: path.join(secondBin, "git"), source: "path" }, - { path: path.join(knownBin, "git"), source: "known-dir" }, + { path: path.join(firstBin, executableFileName("git")), source: "path" }, + { path: path.join(secondBin, executableFileName("git")), source: "path" }, + { path: path.join(knownBin, executableFileName("git")), source: "known-dir" }, ]); }); @@ -234,7 +247,9 @@ describe("cliExecutableResolver", () => { USERPROFILE: userProfile, PATH: "C:\\Windows\\System32", })).toEqual({ - path: path.join(scoopShims, "codex.CMD"), + // statSync is stubbed and the directory does not exist, so the resolver + // cannot read the real on-disk spelling and reports the probed name. + path: path.join(scoopShims, "codex.cmd"), source: "known-dir", }); }); diff --git a/apps/desktop/src/main/services/ai/cliExecutableResolver.ts b/apps/desktop/src/main/services/ai/cliExecutableResolver.ts index 35188885e..c50642835 100644 --- a/apps/desktop/src/main/services/ai/cliExecutableResolver.ts +++ b/apps/desktop/src/main/services/ai/cliExecutableResolver.ts @@ -153,9 +153,28 @@ function getWindowsKnownBinDirs(env: NodeJS.ProcessEnv, command: string): string const voltaHome = env.VOLTA_HOME?.trim(); const pnpmHome = env.PNPM_HOME?.trim(); const asdfDataDir = env.ASDF_DATA_DIR?.trim(); + const codexInstallDir = env.CODEX_INSTALL_DIR?.trim(); return uniqueNonEmpty([ + // `npm i -g` writes `.cmd` / `.ps1` shims straight into %APPDATA%\npm. appData ? path.join(appData, "npm") : "", + // Standalone/native installers put per-tool binaries under %LOCALAPPDATA%\Programs + // or %ProgramFiles%, either directly in the tool directory or in its `bin`. + // Claude Code's Windows installer instead uses %USERPROFILE%\.local\bin + // (`claude.exe`), and WinGet publishes shims into the WinGet\Links dir — both + // are listed below. + ...(localAppData + ? [ + path.join(localAppData, "Programs", command), + path.join(localAppData, "Programs", command, "bin"), + ] + : []), + ...(programFiles + ? [ + path.join(programFiles, command), + path.join(programFiles, command, "bin"), + ] + : []), localAppData ? path.join(localAppData, "Programs", "cursor", "resources", "app", "bin") : "", localAppData ? path.join(localAppData, "Programs", "Microsoft VS Code", "bin") : "", localAppData ? path.join(localAppData, "Microsoft", "WinGet", "Links") : "", @@ -186,8 +205,17 @@ function getWindowsKnownBinDirs(env: NodeJS.ProcessEnv, command: string): string pnpmHome || "", asdfDataDir ? path.join(asdfDataDir, "shims") : "", ...readNpmPrefixBinDirs(env), - command === "codex" && programFiles ? path.join(programFiles, "Codex") : "", - command === "codex" && localAppData ? path.join(localAppData, "Programs", "Codex") : "", + // Codex's standalone Windows installer (chatgpt.com/codex/install.ps1) + // unpacks to $CODEX_HOME\packages\standalone\current and exposes the binary + // through %CODEX_INSTALL_DIR%, defaulting to + // %LOCALAPPDATA%\Programs\OpenAI\Codex\bin. It prepends that to the + // *persisted* user PATH, which an already-running ADE never sees — so a PATH + // lookup alone reports a real install as absent. macOS needs no equivalent + // entry: the Unix default is $HOME/.local/bin, already listed above. + command === "codex" ? (codexInstallDir || "") : "", + command === "codex" && localAppData + ? path.join(localAppData, "Programs", "OpenAI", "Codex", "bin") + : "", ]); } @@ -247,27 +275,69 @@ function isExecutableFile(candidatePath: string): boolean { } } +/** Windows launcher extensions, in the order Windows itself would try them. */ +export function windowsExecutableExtensions(env: NodeJS.ProcessEnv = process.env): string[] { + // PATHEXT is conventionally uppercase while the files on disk are lowercase + // (`claude.exe`, `codex.cmd`). Normalize so resolved paths match the real + // filename; NTFS lookups are case-insensitive either way. + const pathext = uniqueNonEmpty((env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";")) + .map((ext) => (ext.startsWith(".") ? ext : `.${ext}`).toLowerCase()); + // PATHEXT never lists .PS1 (PowerShell resolves scripts itself), but a + // PowerShell-only shim is still a real, launchable install. Try it last so a + // .exe/.cmd sibling always wins — those run under cmd.exe, .ps1 does not. + if (!pathext.some((ext) => ext.toLowerCase() === ".ps1")) pathext.push(".ps1"); + return pathext; +} + +/** + * NTFS lookups ignore case, so a probe for `codex.cmd` succeeds against a file + * actually named `codex.CMD` and vice versa. The resolved path is surfaced in + * Settings and handed to other tools, so report the name as it is spelled on + * disk instead of however PATHEXT happened to be cased. + */ +function withOnDiskCasing(candidatePath: string): string { + if (process.platform !== "win32") return candidatePath; + const dir = path.dirname(candidatePath); + const base = path.basename(candidatePath); + try { + const actual = fs.readdirSync(dir).find((entry) => entry.toLowerCase() === base.toLowerCase()); + return actual ? path.join(dir, actual) : candidatePath; + } catch { + return candidatePath; + } +} + function resolveFromDirs( command: string, dirs: Iterable, env: NodeJS.ProcessEnv = process.env, ): string | null { - const pathext = process.platform === "win32" - ? uniqueNonEmpty((env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")) - .flatMap((ext) => [ext, ext.toLowerCase(), ext.toUpperCase()]) - : []; const commandHasExtension = path.extname(command).length > 0; + const extensions = process.platform === "win32" && !commandHasExtension + ? windowsExecutableExtensions(env) + : []; for (const dir of dirs) { - const candidatePaths = [path.join(dir, command)]; - if (process.platform === "win32" && !commandHasExtension) { - for (const ext of pathext) { - candidatePaths.push(path.join(dir, `${command}${ext}`)); - } - } + // Windows cannot execute an extension-less file. `npm i -g` drops three + // shims side by side — `codex` (a `#!/bin/sh` script for Git Bash), + // `codex.cmd` and `codex.ps1` — and only the latter two are launchable + // here. Trying `path.join(dir, command)` first therefore handed callers the + // sh script: ADE's own spawns survived it because `resolveCliSpawnInvocation` + // wraps extension-less commands in `cmd.exe`, which re-applies PATHEXT, but + // every consumer that spawns the resolved path directly (the Claude Agent + // SDK via `pathToClaudeCodeExecutable`, node-pty, provider SDKs) gets ENOENT. + // Resolve the way Windows does: PATHEXT only. On other platforms the bare + // name is the executable. + const candidatePaths = extensions.length > 0 + // Uppercase second, for the rare case-sensitive Windows directory. + ? extensions.flatMap((ext) => [ + path.join(dir, `${command}${ext}`), + path.join(dir, `${command}${ext.toUpperCase()}`), + ]) + : [path.join(dir, command)]; for (const candidatePath of candidatePaths) { - if (isExecutableFile(candidatePath)) return candidatePath; + if (isExecutableFile(candidatePath)) return withOnDiskCasing(candidatePath); } } return null; @@ -288,10 +358,8 @@ export function augmentPathWithKnownCliDirs( ): string { return mergePathEntries( pathValue, - getKnownBinDirs("claude", env).join(pathListDelimiter()), - getKnownBinDirs("codex", env).join(pathListDelimiter()), - getKnownBinDirs("agent", env).join(pathListDelimiter()), - getKnownBinDirs("opencode", env).join(pathListDelimiter()), + ...["claude", "codex", "agent", "cursor-agent", "droid", "opencode"].map((command) => + getKnownBinDirs(command, env).join(pathListDelimiter())), ); } diff --git a/apps/desktop/src/main/services/ai/codexExecutable.ts b/apps/desktop/src/main/services/ai/codexExecutable.ts index 6e102e33c..de7e68990 100644 --- a/apps/desktop/src/main/services/ai/codexExecutable.ts +++ b/apps/desktop/src/main/services/ai/codexExecutable.ts @@ -1,5 +1,6 @@ import type { DetectedAuth } from "./authDetector"; import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { resolveExecutableFromKnownLocations } from "./cliExecutableResolver"; @@ -37,20 +38,80 @@ function findCodexAuthPath(auth?: DetectedAuth[]): string | null { return null; } -function pathExists(filePath: string): boolean { +function pathExists(filePath: string, platform: NodeJS.Platform = process.platform): boolean { try { fs.accessSync(filePath, fs.constants.X_OK); return true; } catch { try { fs.accessSync(filePath, fs.constants.F_OK); - return process.platform === "win32"; + // Windows has no execute bit; presence is the only signal available. + return platform === "win32"; } catch { return false; } } } +function homeDirFromEnv(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): string | null { + const profile = env.USERPROFILE?.trim(); + const home = env.HOME?.trim(); + if (platform === "win32") return profile || home || os.homedir() || null; + return home || os.homedir() || null; +} + +/** + * Directories used by Codex's own standalone installer + * (`https://chatgpt.com/codex/install.ps1` / `install.sh`). + * + * The installer drops the release under `$CODEX_HOME/packages/standalone/current` + * and exposes it through a "visible bin" directory that it prepends to the + * *persisted* user PATH: + * - Windows: `%CODEX_INSTALL_DIR%` else `%LOCALAPPDATA%\Programs\OpenAI\Codex\bin` + * - macOS/Linux: `$CODEX_INSTALL_DIR` else `$HOME/.local/bin` + * + * A persisted PATH edit is not visible to an already-running login session, so + * ADE must be able to find a standalone install without it. macOS already gets + * this for free because `~/.local/bin` is in the shared known-bin-dir list; the + * Windows equivalent is not, which left standalone Windows installs + * indistinguishable from "Codex is not installed". + */ +function standaloneCodexInstallDirs(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): string[] { + const dirs: string[] = []; + const installDir = env.CODEX_INSTALL_DIR?.trim(); + if (installDir) dirs.push(installDir); + + if (platform === "win32") { + const localAppData = env.LOCALAPPDATA?.trim(); + if (localAppData) dirs.push(path.join(localAppData, "Programs", "OpenAI", "Codex", "bin")); + } else { + const home = homeDirFromEnv(env, platform); + if (home) dirs.push(path.join(home, ".local", "bin")); + } + + const configuredHome = env.CODEX_HOME?.trim(); + const home = homeDirFromEnv(env, platform); + const codexHome = configuredHome || (home ? path.join(home, ".codex") : ""); + if (codexHome) { + const current = path.join(codexHome, "packages", "standalone", "current"); + dirs.push(path.join(current, "bin"), current); + } + + return [...new Set(dirs)]; +} + +function findStandaloneCodexExecutable( + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform, +): string | null { + const binaryName = platform === "win32" ? "codex.exe" : "codex"; + for (const dir of standaloneCodexInstallDirs(env, platform)) { + const candidate = path.join(dir, binaryName); + if (pathExists(candidate, platform)) return candidate; + } + return null; +} + function listDirectories(rootPath: string): string[] { try { return fs.readdirSync(rootPath, { withFileTypes: true }) @@ -68,7 +129,7 @@ function findVendorCodexBinary(packageRoot: string, platform: NodeJS.Platform): path.join(vendorRoot, "bin", binaryName), path.join(vendorRoot, "codex", binaryName), ]) { - if (pathExists(candidate)) return candidate; + if (pathExists(candidate, platform)) return candidate; } } return null; @@ -158,5 +219,10 @@ export function resolveCodexExecutable(args?: { }; } + const standalone = findStandaloneCodexExecutable(env, args?.platform ?? process.platform); + if (standalone) { + return { path: standalone, source: "common-dir" }; + } + return { path: "codex", source: "fallback-command" }; } diff --git a/apps/desktop/src/main/services/ai/cursorSdkLoader.test.ts b/apps/desktop/src/main/services/ai/cursorSdkLoader.test.ts new file mode 100644 index 000000000..8f7ce4fa8 --- /dev/null +++ b/apps/desktop/src/main/services/ai/cursorSdkLoader.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; + +import { + CURSOR_SDK_UNSUPPORTED_PLATFORM_CODE, + assertCursorSdkSupportedOnThisPlatform, + isCursorSdkResolutionError, + loadCursorSdk, +} from "./cursorSdkLoader"; + +// Platform/arch are passed explicitly, so these assertions hold on every runner +// and this file is not a platform-gated test. +describe("assertCursorSdkSupportedOnThisPlatform", () => { + it("rejects win32-arm64 with a message naming the missing @cursor/sdk build", () => { + let thrown: unknown; + try { + assertCursorSdkSupportedOnThisPlatform("win32", "arm64"); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(Error); + expect((thrown as Error).message).toMatch(/win32-arm64/); + expect((thrown as Error).message).toMatch(/@cursor\/sdk/); + expect((thrown as { code?: string }).code).toBe(CURSOR_SDK_UNSUPPORTED_PLATFORM_CODE); + }); + + it("allows win32-x64, darwin and linux on every architecture", () => { + for (const [platform, arch] of [ + ["win32", "x64"], + ["darwin", "arm64"], + ["darwin", "x64"], + ["linux", "arm64"], + ["linux", "x64"], + ] as const) { + expect( + () => assertCursorSdkSupportedOnThisPlatform(platform, arch), + `${platform}-${arch}`, + ).not.toThrow(); + } + }); +}); + +describe("isCursorSdkResolutionError", () => { + it("treats the unsupported-platform failure like an unusable SDK module", () => { + // Callers such as cursorModelsDiscovery drop cached rows and refuse to fall + // back to network discovery when this returns true — which is exactly right + // on a platform where no chat could ever run. + const error = Object.assign(new Error("unsupported"), { + code: CURSOR_SDK_UNSUPPORTED_PLATFORM_CODE, + }); + expect(isCursorSdkResolutionError(error)).toBe(true); + }); + + it("still recognizes genuine module-resolution failures", () => { + expect(isCursorSdkResolutionError( + Object.assign(new Error("nope"), { code: "ERR_MODULE_NOT_FOUND" }), + )).toBe(true); + expect(isCursorSdkResolutionError(new Error("Cannot find package '@cursor/sdk'"))).toBe(true); + expect(isCursorSdkResolutionError(new Error("socket hang up"))).toBe(false); + }); +}); + +describe("loadCursorSdk", () => { + it("fails with the explained blocker on win32-arm64 instead of an opaque import error", async () => { + // The realistic case: settings restored from an x64 machine still name + // Cursor, so something reaches the SDK even though no picker offers it. + const prevPlatform = Object.getOwnPropertyDescriptor(process, "platform")!; + const prevArch = Object.getOwnPropertyDescriptor(process, "arch")!; + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + Object.defineProperty(process, "arch", { value: "arm64", configurable: true }); + try { + await expect(loadCursorSdk()).rejects.toThrow(/win32-arm64/); + } finally { + Object.defineProperty(process, "platform", prevPlatform); + Object.defineProperty(process, "arch", prevArch); + } + }); +}); diff --git a/apps/desktop/src/main/services/ai/cursorSdkLoader.ts b/apps/desktop/src/main/services/ai/cursorSdkLoader.ts index 9e5c92c2b..99a487523 100644 --- a/apps/desktop/src/main/services/ai/cursorSdkLoader.ts +++ b/apps/desktop/src/main/services/ai/cursorSdkLoader.ts @@ -1,6 +1,10 @@ import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import type * as CursorSdkModuleTypes from "@cursor/sdk"; +import { + CURSOR_WINDOWS_ARM_BLOCKER, + isCursorProviderSupported, +} from "../../../shared/providerPlatformSupport"; export type CursorSdkModule = typeof CursorSdkModuleTypes; @@ -16,11 +20,22 @@ function errorText(error: unknown): string { return String(error); } +/** + * Error code stamped on the win32-arm64 platform-gate failure so callers can + * tell it apart from a genuine runtime error and treat it like an unusable SDK + * module rather than a transient fault. + */ +export const CURSOR_SDK_UNSUPPORTED_PLATFORM_CODE = "ADE_CURSOR_SDK_UNSUPPORTED_PLATFORM"; + export function isCursorSdkResolutionError(error: unknown): boolean { const message = errorText(error); const code = error && typeof error === "object" ? String((error as { code?: unknown }).code ?? "") : ""; + // A platform with no @cursor/sdk build is the same situation as a missing + // module for every caller: the SDK cannot be used, so do not fall back to + // network discovery and advertise models that no chat could ever run. + if (code === CURSOR_SDK_UNSUPPORTED_PLATFORM_CODE) return true; return code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND" || /Cannot find package ['"]@cursor\/sdk['"]/i.test(message) @@ -39,7 +54,25 @@ function loadCursorSdkWithRequire(originalError: unknown): CursorSdkModule { } } +/** + * Backstop for the platform gate. Settings restored from an x64 machine, a + * persisted default model, or a deep link can still name Cursor on a host where + * the provider was filtered out of every picker. Those paths reach the SDK + * directly, so fail here with the same explanation the UI would have given + * rather than an opaque ERR_MODULE_NOT_FOUND from deep inside the import. + */ +export function assertCursorSdkSupportedOnThisPlatform( + platform: string = process.platform, + arch: string = process.arch, +): void { + if (isCursorProviderSupported(platform, arch)) return; + const error = new Error(CURSOR_WINDOWS_ARM_BLOCKER); + (error as { code?: string }).code = CURSOR_SDK_UNSUPPORTED_PLATFORM_CODE; + throw error; +} + export async function loadCursorSdk(): Promise { + assertCursorSdkSupportedOnThisPlatform(); if (sdkModule) return sdkModule; if (!sdkModulePromise) { sdkModulePromise = import("@cursor/sdk") diff --git a/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts b/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts index 3b58a058e..f9e3f21f4 100644 --- a/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts +++ b/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts @@ -367,4 +367,92 @@ describe("buildProviderConnections", () => { else process.env.CURSOR_ADMIN_API_KEY = prevAdminKey; } }); + // Cursor is gated out of Windows on ARM because @cursor/sdk publishes no + // win32-arm64 runtime. Platform/arch are forced here rather than read from the + // host, so these assertions run identically on every CI runner — no platform + // gate, no baseline entry needed. + describe("Cursor on Windows on ARM", () => { + async function withTarget( + platform: string, + arch: string, + run: () => Promise, + ): Promise { + const prevPlatform = Object.getOwnPropertyDescriptor(process, "platform")!; + const prevArch = Object.getOwnPropertyDescriptor(process, "arch")!; + Object.defineProperty(process, "platform", { value: platform, configurable: true }); + Object.defineProperty(process, "arch", { value: arch, configurable: true }); + try { + // Must await inside the override: buildProviderConnections reads + // process.arch after its first await point. + return await run(); + } finally { + Object.defineProperty(process, "platform", prevPlatform); + Object.defineProperty(process, "arch", prevArch); + } + } + + it("reports Cursor as hard unavailable on win32-arm64 even with a verified key and a ready runtime", async () => { + const prevKey = process.env.CURSOR_API_KEY; + process.env.CURSOR_API_KEY = "key_live_cursor_agent"; + mockState.getProviderRuntimeHealth.mockImplementation((provider: string) => + provider === "cursor" + ? { state: "ready", message: null, checkedAt: "2026-05-01T12:00:00.000Z" } + : null, + ); + try { + const result = await withTarget("win32", "arm64", () => + buildProviderConnections(mergeCliStatuses([])), + ); + expect(result.cursor.runtimeAvailable).toBe(false); + expect(result.cursor.runtimeDetected).toBe(false); + expect(result.cursor.authAvailable).toBe(false); + expect(result.cursor.usageAvailable).toBe(false); + expect(result.cursor.path).toBeNull(); + expect(result.cursor.sources).toEqual([]); + expect(result.cursor.blocker).toMatch(/win32-arm64/); + } finally { + if (prevKey === undefined) delete process.env.CURSOR_API_KEY; + else process.env.CURSOR_API_KEY = prevKey; + } + }); + + it("leaves Cursor available on win32-x64 and darwin-arm64 with the same inputs", async () => { + const prevKey = process.env.CURSOR_API_KEY; + process.env.CURSOR_API_KEY = "key_live_cursor_agent"; + mockState.getProviderRuntimeHealth.mockImplementation((provider: string) => + provider === "cursor" + ? { state: "ready", message: null, checkedAt: "2026-05-01T12:00:00.000Z" } + : null, + ); + try { + for (const [platform, arch] of [["win32", "x64"], ["darwin", "arm64"], ["darwin", "x64"]]) { + const result = await withTarget(platform!, arch!, () => + buildProviderConnections(mergeCliStatuses([])), + ); + expect(result.cursor.runtimeAvailable, `${platform}-${arch}`).toBe(true); + expect(result.cursor.runtimeDetected, `${platform}-${arch}`).toBe(true); + expect(result.cursor.path, `${platform}-${arch}`).toBe("@cursor/sdk"); + expect(result.cursor.blocker, `${platform}-${arch}`).toBeNull(); + } + } finally { + if (prevKey === undefined) delete process.env.CURSOR_API_KEY; + else process.env.CURSOR_API_KEY = prevKey; + } + }); + + it("does not touch Claude, Codex or Droid on win32-arm64", async () => { + const result = await withTarget("win32", "arm64", () => + buildProviderConnections( + mergeCliStatuses([ + { cli: "claude", installed: true, path: "claude", authenticated: true, verified: true }, + { cli: "codex", installed: true, path: "codex", authenticated: true, verified: true }, + { cli: "droid", installed: true, path: "droid", authenticated: true, verified: true }, + ]), + ), + ); + expect(result.claude.runtimeAvailable).toBe(true); + expect(result.codex.runtimeAvailable).toBe(true); + expect(result.droid.runtimeAvailable).toBe(true); + }); + }); }); diff --git a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts index 81a1a18c1..11bfa3264 100644 --- a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts +++ b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts @@ -8,6 +8,10 @@ import { import { getAllApiKeys } from "./apiKeyStore"; import { getProviderRuntimeHealth } from "./providerRuntimeHealth"; import { isCursorAdminApiKey } from "./utils"; +import { + CURSOR_WINDOWS_ARM_BLOCKER, + isCursorProviderSupported, +} from "../../../shared/providerPlatformSupport"; import { nowIso } from "../shared/utils"; function createUnavailableStatus( @@ -79,7 +83,12 @@ export async function buildProviderConnections( return `${providerLabel} CLI is installed but no login was detected. Run: ${loginHint}`; } if (!flags.runtimeDetected) { - return `Local credentials exist but ADE could not find the ${providerLabel} CLI. ADE checks the app PATH, login-shell PATH, interactive-shell PATH, and common install directories. If ${providerLabel} is installed elsewhere, add that bin directory to your shell PATH and refresh.`; + // The login-shell/interactive-shell PATH probe is a POSIX-only step — + // `augmentProcessPathWithShellAndKnownCliDirs` skips it on Windows — so + // do not claim it happened, and give the right place to fix PATH. + return process.platform === "win32" + ? `Local credentials exist but ADE could not find the ${providerLabel} CLI. ADE checks the app PATH (honouring PATHEXT) and the common Windows install directories: %APPDATA%\\npm, %USERPROFILE%\\.local\\bin, %LOCALAPPDATA%\\Programs, %LOCALAPPDATA%\\Microsoft\\WinGet\\Links. If ${providerLabel} is installed elsewhere, add that folder to your PATH in System Properties -> Environment Variables, reopen ADE, and refresh.` + : `Local credentials exist but ADE could not find the ${providerLabel} CLI. ADE checks the app PATH, login-shell PATH, interactive-shell PATH, and common install directories. If ${providerLabel} is installed elsewhere, add that bin directory to your shell PATH and refresh.`; } if (extraBlocker) return extraBlocker; return null; @@ -174,6 +183,14 @@ export async function buildProviderConnections( health: codexRuntimeHealth, }); + // Windows on ARM ships no @cursor/sdk runtime, so Cursor is reported as hard + // unavailable before any key or runtime-health work happens. See + // shared/providerPlatformSupport.ts for the reason and the revisit condition. + // This is the authoritative decision point: `availableProviders.cursor`, the + // cursor-family model filter and every settings/onboarding surface downstream + // all derive from the connection built here. + const cursorSupported = isCursorProviderSupported(process.platform, process.arch); + const cursorCli = cliStatuses.find((entry) => entry.cli === "cursor") ?? null; const cursorEnvKey = process.env.CURSOR_API_KEY?.trim() ?? ""; const cursorAdminEnvKey = process.env.CURSOR_ADMIN_API_KEY?.trim() ?? ""; @@ -204,16 +221,18 @@ export async function buildProviderConnections( // ready after verification/model discovery proves the SDK can load and the // key can access agent models. const cursorFlags = { - runtimeDetected: true, + runtimeDetected: cursorSupported, cliAuthenticated: false, cliExplicitlyUnauthenticated: false, - localCredsDetected: cursorAuthAvailable, - authAvailable: cursorAuthAvailable, - runtimeAvailable: cursorRuntimeHealth?.state === "ready", + localCredsDetected: cursorSupported && cursorAuthAvailable, + authAvailable: cursorSupported && cursorAuthAvailable, + runtimeAvailable: cursorSupported && cursorRuntimeHealth?.state === "ready", }; let cursorBlocker: string | null; - if (cursorFlags.runtimeAvailable) { + if (!cursorSupported) { + cursorBlocker = CURSOR_WINDOWS_ARM_BLOCKER; + } else if (cursorFlags.runtimeAvailable) { cursorBlocker = null; } else if (cursorSdkAuth) { cursorBlocker = "Verify the Cursor API key to enable Cursor chat."; @@ -232,25 +251,29 @@ export async function buildProviderConnections( authAvailable: cursorFlags.authAvailable, runtimeDetected: cursorFlags.runtimeDetected, runtimeAvailable: cursorFlags.runtimeAvailable, - usageAvailable: cursorUsageAuth, - path: "@cursor/sdk", - sources: [ - { - kind: "local-credentials", - detected: cursorAuthAvailable, - source: cursorCredsSource, - }, - { - kind: "cli", - detected: Boolean(cursorCli?.installed), - authenticated: cursorCli?.authenticated, - verified: cursorCli?.verified, - path: cursorCli?.path ?? null, - }, - ], + usageAvailable: cursorSupported && cursorUsageAuth, + path: cursorSupported ? "@cursor/sdk" : null, + sources: cursorSupported + ? [ + { + kind: "local-credentials", + detected: cursorAuthAvailable, + source: cursorCredsSource, + }, + { + kind: "cli", + detected: Boolean(cursorCli?.installed), + authenticated: cursorCli?.authenticated, + verified: cursorCli?.verified, + path: cursorCli?.path ?? null, + }, + ] + : [], blocker: cursorBlocker, }; - applyRuntimeHealth(cursor, cursorRuntimeHealth); + // Runtime health can only promote the connection, so it must not run once the + // platform gate has decided Cursor is unavailable. + if (cursorSupported) applyRuntimeHealth(cursor, cursorRuntimeHealth); const droidCli = cliStatuses.find((entry) => entry.cli === "droid") ?? null; const factoryEnvAuth = Boolean(process.env.FACTORY_API_KEY?.trim()); diff --git a/apps/desktop/src/main/services/ai/providerTaskRunner.test.ts b/apps/desktop/src/main/services/ai/providerTaskRunner.test.ts index c502e00b1..eea1b696e 100644 --- a/apps/desktop/src/main/services/ai/providerTaskRunner.test.ts +++ b/apps/desktop/src/main/services/ai/providerTaskRunner.test.ts @@ -32,6 +32,37 @@ vi.mock("./codexExecutable", () => ({ })); import { makeCodexCompatibleJsonSchema, runProviderTask } from "./providerTaskRunner"; +import { quoteWindowsCmdArg } from "../shared/processExecution"; + +// `runCommand` launches CLIs through `resolveCliSpawnInvocation`. On Windows an +// extensionless/`.cmd`/`.bat` launcher cannot be handed to CreateProcess, so the +// invocation becomes `%ComSpec% /d /s /c ""` and every +// argument is folded into one string. These helpers assert the same argument +// content on both shapes instead of encoding the POSIX shape only. +const isWindowsLaunch = process.platform === "win32"; + +function expectedLaunchCommand(executablePath: string): string { + return isWindowsLaunch ? (process.env.ComSpec?.trim() || "cmd.exe") : executablePath; +} + +function launchArgvContains(argv: unknown, value: string): boolean { + const args = Array.isArray(argv) ? (argv as string[]) : []; + return isWindowsLaunch + ? args.join(" ").includes(quoteWindowsCmdArg(value)) + : args.includes(value); +} + +function launchArgvValueAfter(argv: unknown, flag: string): string | null { + const args = Array.isArray(argv) ? (argv as string[]) : []; + if (!isWindowsLaunch) { + const index = args.indexOf(flag); + return index >= 0 ? (args[index + 1] ?? null) : null; + } + const match = args + .join(" ") + .match(new RegExp(`${quoteWindowsCmdArg(flag).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} "([^"]+)"`)); + return match?.[1] ?? null; +} type MockSpawnProcess = EventEmitter & { stdout: EventEmitter; @@ -143,9 +174,9 @@ describe("runProviderTask", () => { expect(result.text).toBe("READY"); expect(spawnMock).toHaveBeenCalledTimes(1); const [command, argv, options] = spawnMock.mock.calls[0]!; - expect(command).toBe("C:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd"); - expect(argv).toContain("-p"); - expect(argv).not.toContain("Summarize the worktree state."); + expect(command).toBe(expectedLaunchCommand("C:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd")); + expect(launchArgvContains(argv, "-p")).toBe(true); + expect(launchArgvContains(argv, "Summarize the worktree state.")).toBe(false); expect(options).toMatchObject({ stdio: ["pipe", "pipe", "pipe"], }); @@ -155,8 +186,7 @@ describe("runProviderTask", () => { it("pipes Codex prompts over stdin instead of argv", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-provider-task-runner-")); spawnMock.mockImplementationOnce((_command: unknown, argv: string[]) => { - const outputIndex = argv.indexOf("--output-last-message"); - const outputPath = outputIndex >= 0 ? argv[outputIndex + 1] : null; + const outputPath = launchArgvValueAfter(argv, "--output-last-message"); return createMockProcess({ onStart: () => { if (outputPath) { @@ -187,12 +217,12 @@ describe("runProviderTask", () => { expect(result.text).toBe("DONE"); expect(spawnMock).toHaveBeenCalledTimes(1); const [command, argv, options] = spawnMock.mock.calls[0]!; - expect(command).toBe("C:\\Users\\me\\AppData\\Roaming\\npm\\codex.cmd"); - expect(argv).toContain("exec"); - expect(argv).toContain("-"); - expect(argv).toContain("--image"); - expect(argv).toContain("/tmp/settings.png"); - expect(argv).not.toContain("Fix the Windows launcher."); + expect(command).toBe(expectedLaunchCommand("C:\\Users\\me\\AppData\\Roaming\\npm\\codex.cmd")); + expect(launchArgvContains(argv, "exec")).toBe(true); + expect(launchArgvContains(argv, "-")).toBe(true); + expect(launchArgvContains(argv, "--image")).toBe(true); + expect(launchArgvContains(argv, "/tmp/settings.png")).toBe(true); + expect(launchArgvContains(argv, "Fix the Windows launcher.")).toBe(false); expect(options).toMatchObject({ stdio: ["pipe", "pipe", "pipe"], }); diff --git a/apps/desktop/src/main/services/appControl/appControlLaunchCommand.test.ts b/apps/desktop/src/main/services/appControl/appControlLaunchCommand.test.ts index 6e66d4751..fafdc7d54 100644 --- a/apps/desktop/src/main/services/appControl/appControlLaunchCommand.test.ts +++ b/apps/desktop/src/main/services/appControl/appControlLaunchCommand.test.ts @@ -6,6 +6,7 @@ import { commandForwardsAppControlDebug, commandLooksLikeDirectElectronLaunch, commandLooksLikePackageScriptLaunch, + gitBashPath, insertDebugFlagsIntoDirectElectronCommand, resolveDirectElectronLaunch, resolvePackageScriptElectronLaunch, @@ -98,11 +99,43 @@ describe("appControlLaunchCommand", () => { expect(cmd).toContain('set "PATH='); expect(cmd).toContain(';%PATH%" &&'); expect(cmd).not.toContain(":$PATH"); + + const gitBash = rewritePackageScriptElectronLaunch( + "npm run dev", + DEBUG_FLAGS, + projectRoot, + { platform: "win32", shell: "git-bash" }, + ); + // The fixture's package directory is a host-native temp dir: a drive-letter + // path on Windows, a plain POSIX path on a Linux runner. Deriving the + // expected `cd` target from the fixture keeps this assertion exact on every + // host; the drive-letter -> MSYS rewrite itself is pinned host-independently + // by the `gitBashPath` case below. + const expectedCd = `cd -- ${shellQuote(gitBashPath(projectRoot))} && `; + expect(gitBash?.slice(0, expectedCd.length)).toBe(expectedCd); + expect(gitBash).not.toContain(String.fromCharCode(92)); + expect(gitBash).toContain("/node_modules/.bin'"); + expect(gitBash).toContain(":$PATH"); + expect(gitBash).toContain(" && "); + expect(gitBash).not.toContain("Set-Location"); + expect(gitBash).not.toContain('set "PATH='); } finally { fs.rmSync(projectRoot, { recursive: true, force: true }); } }); + it("rewrites Windows drive-letter paths into MSYS form for Git Bash", () => { + // Git Bash receives MSYS paths, not native Windows ones. This runs on every + // host because the inputs are literals rather than host-native temp dirs, so + // the conversion stays covered on the Linux unit runner as well as Windows. + expect(gitBashPath("C:\\Users\\ade\\my app")).toBe("/c/Users/ade/my app"); + expect(gitBashPath("D:/work/app/node_modules/.bin")).toBe("/d/work/app/node_modules/.bin"); + expect(gitBashPath("c:\\ade")).toBe("/c/ade"); + // Rootless and UNC paths have no drive letter to fold; MSYS takes them as-is. + expect(gitBashPath("/tmp/ade-app-control")).toBe("/tmp/ade-app-control"); + expect(gitBashPath("\\\\server\\share\\app")).toBe("//server/share/app"); + }); + it("detects direct Electron launches and injects debug flags after electron", () => { expect(commandLooksLikeDirectElectronLaunch("FOO=bar npx electron .")).toBe(true); diff --git a/apps/desktop/src/main/services/appControl/appControlLaunchCommand.ts b/apps/desktop/src/main/services/appControl/appControlLaunchCommand.ts index af146205d..559b71c7d 100644 --- a/apps/desktop/src/main/services/appControl/appControlLaunchCommand.ts +++ b/apps/desktop/src/main/services/appControl/appControlLaunchCommand.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { commandArrayToLine, parseCommandLine } from "../../../shared/shell"; +import type { WindowsShellKind } from "../../../shared/types"; export type AppControlDirectLaunch = { command: string; @@ -13,10 +14,9 @@ export type AppControlPackageLaunch = AppControlDirectLaunch & { cwd: string; }; -type WindowsShell = "powershell" | "cmd"; type LaunchOptions = { platform?: NodeJS.Platform; - shell?: WindowsShell; + shell?: WindowsShellKind; }; export function shellQuote(value: string): string { @@ -186,13 +186,31 @@ function quotePowerShellLiteral(value: string): string { return `'${value.replace(/'/g, "''")}'`; } +// `%` is deliberately left alone. Doubling it is only correct inside a batch +// file; this builds a line for a live cmd prompt, where `%%` survives +// literally and turns `100%` into `100%%`. Nothing can escape `%` on a live +// command line -- the caller below relies on that, leaving the trailing +// `%PATH%` outside this function precisely so it still expands. function quoteCmdSetValue(value: string): string { return value - .replace(/%/g, "%%") .replace(/"/g, "\"\"") .replace(/[\r\n]/g, " "); } +/** + * Rewrites a native Windows path into the MSYS form Git Bash expects: + * `C:\Users\ade` becomes `/c/Users/ade`. Only a drive-letter root is rewritten; + * a path that already has no drive (a UNC share, or a POSIX path) is passed + * through with separators normalised, because MSYS understands those as-is and + * inventing a drive letter for them would corrupt them. + */ +export function gitBashPath(value: string): string { + const normalized = value.split(String.fromCharCode(92)).join("/"); + const drivePath = normalized.match(/^([a-z]):\/(.*)$/i); + if (!drivePath) return normalized; + return `/${drivePath[1]!.toLowerCase()}/${drivePath[2]}`; +} + export function rewritePackageScriptElectronLaunch( command: string, debugFlags: string[], @@ -220,7 +238,8 @@ export function rewritePackageScriptElectronLaunch( const platform = options.platform ?? process.platform; if (platform === "win32") { const parsedEnv = takeLeadingEnv(envPrefix).env; - if ((options.shell ?? "powershell") === "cmd") { + const shell = options.shell ?? "powershell"; + if (shell === "cmd") { const assignments = [ `cd /d "${quoteCmdSetValue(packageDir)}"`, `set "PATH=${quoteCmdSetValue(packageBinPath)};%PATH%"`, @@ -231,6 +250,14 @@ export function rewritePackageScriptElectronLaunch( return `${assignments.join(" && ")} && ${rewrittenScript}`; } + if (shell === "git-bash") { + const expandedEnvPrefix = [ + `PATH=${shellQuote(gitBashPath(packageBinPath))}:$PATH`, + ...Object.entries(parsedEnv).map(([key, value]) => `${key}=${shellQuote(value)}`), + ].join(" "); + return `cd -- ${shellQuote(gitBashPath(packageDir))} && ${prependEnvToShellSegments(rewrittenScript, expandedEnvPrefix)}`; + } + const assignments = [ `Set-Location -LiteralPath ${quotePowerShellLiteral(packageDir)}`, `$env:PATH = ${quotePowerShellLiteral(`${packageBinPath};`)} + $env:PATH`, diff --git a/apps/desktop/src/main/services/appControl/appControlService.test.ts b/apps/desktop/src/main/services/appControl/appControlService.test.ts index 07c6b5271..923090e22 100644 --- a/apps/desktop/src/main/services/appControl/appControlService.test.ts +++ b/apps/desktop/src/main/services/appControl/appControlService.test.ts @@ -1,6 +1,10 @@ import type { EventEmitter } from "node:events"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Logger } from "../logging/logger"; +import { gitBashPath, shellQuote } from "./appControlLaunchCommand"; type FakeCdpTarget = { id: string; @@ -196,6 +200,56 @@ describe("appControlService", () => { } }); + it("passes shell-specific Windows package-script commands through to the PTY", async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + const create = vi.fn(async (_input: Record) => ({ + sessionId: "terminal-windows-shells", + ptyId: "pty-windows-shells", + pid: 42, + })); + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-app-control-shells-")); + fs.writeFileSync(path.join(projectRoot, "package.json"), JSON.stringify({ + scripts: { dev: "echo preparing && electron ." }, + }), "utf8"); + const service = createAppControlService({ + projectRoot, + logger: createLogger(), + resolveLaneId: () => "lane-1", + ptyService: { + create, + onExit: vi.fn(() => () => {}), + signalTerminal: vi.fn(), + } as any, + }); + + try { + await service.launch({ command: "npm run dev", cwd: projectRoot }); + + const createArgs = create.mock.calls[0]?.[0] as Record; + expect(createArgs).not.toHaveProperty("command"); + expect(createArgs.windowsStartupCommands.powershell).toContain("Set-Location -LiteralPath"); + expect(createArgs.windowsStartupCommands.powershell).not.toContain(" && "); + expect(createArgs.windowsStartupCommands.cmd).toContain('cd /d "'); + expect(createArgs.windowsStartupCommands.cmd).toContain(" && "); + // `projectRoot` is a host-native temp dir, so its shape differs per runner: + // a drive-letter path on windows-latest, a plain POSIX path on ubuntu. + // Derive the expected MSYS `cd` target from the fixture rather than hard + // coding a drive-letter root; appControlLaunchCommand.test.ts pins the + // drive-letter -> MSYS rewrite itself with literal inputs on every host. + const expectedGitBashCd = `cd -- ${shellQuote(gitBashPath(projectRoot))} && `; + expect(createArgs.windowsStartupCommands["git-bash"].slice(0, expectedGitBashCd.length)) + .toBe(expectedGitBashCd); + expect(createArgs.windowsStartupCommands["git-bash"]).not.toContain(String.fromCharCode(92)); + expect(createArgs.windowsStartupCommands["git-bash"]).toContain(" && "); + expect(createArgs.startupCommand).toBe(createArgs.windowsStartupCommands.powershell); + } finally { + service.dispose(); + fs.rmSync(projectRoot, { recursive: true, force: true }); + Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); + } + }); + it("preserves shell environment expansion for Electron launches outside Windows", async () => { const originalPlatform = process.platform; Object.defineProperty(process, "platform", { value: "darwin", configurable: true }); diff --git a/apps/desktop/src/main/services/appControl/appControlService.ts b/apps/desktop/src/main/services/appControl/appControlService.ts index c58f2ad77..fa07580c5 100644 --- a/apps/desktop/src/main/services/appControl/appControlService.ts +++ b/apps/desktop/src/main/services/appControl/appControlService.ts @@ -27,6 +27,7 @@ import type { AppControlStopArgs, AppControlTarget, AppControlTypeTextArgs, + WindowsShellKind, } from "../../../shared/types"; import type { Logger } from "../logging/logger"; import type { createPtyService } from "../pty/ptyService"; @@ -155,6 +156,7 @@ type ResolvedLaunch = { command?: string; args?: string[]; env?: Record; + windowsStartupCommands?: Partial>; }; function nowIso(): string { @@ -1373,6 +1375,7 @@ export function createAppControlService(args: CreateAppControlServiceArgs) { } } let command = rawCommand; + let windowsStartupCommands: Partial> | undefined; if (!commandForwardsAppControlDebug(command)) { if (process.platform === "win32") { const structuredPackage = resolvePackageScriptElectronLaunch( @@ -1409,15 +1412,26 @@ export function createAppControlService(args: CreateAppControlServiceArgs) { } if (commandLooksLikePackageScriptLaunch(command)) { - command = rewritePackageScriptElectronLaunch( - command, - autoDebugFlags, - cwd, - { + if (process.platform === "win32") { + const originalCommand = command; + windowsStartupCommands = Object.fromEntries( + (["powershell", "cmd", "git-bash"] as const) + .map((shell) => [ + shell, + rewritePackageScriptElectronLaunch(originalCommand, autoDebugFlags, cwd, { + platform: "win32", + shell, + }), + ] as const) + .filter((entry): entry is readonly [WindowsShellKind, string] => Boolean(entry[1])), + ) as Partial>; + command = windowsStartupCommands.powershell + ?? `${originalCommand} -- ${autoDebugFlags.map(shellQuote).join(" ")}`; + } else { + command = rewritePackageScriptElectronLaunch(command, autoDebugFlags, cwd, { platform: process.platform, - shell: process.platform === "win32" ? "powershell" : undefined, - }, - ) ?? `${command} -- ${autoDebugFlags.map(shellQuote).join(" ")}`; + }) ?? `${command} -- ${autoDebugFlags.map(shellQuote).join(" ")}`; + } } else if (commandLooksLikeDirectElectronLaunch(command)) { command = insertDebugFlagsIntoDirectElectronCommand(command, autoDebugFlags); } @@ -1429,6 +1443,9 @@ export function createAppControlService(args: CreateAppControlServiceArgs) { label: launchArgs.label?.trim() || rawCommand, cwd, commandForDisplay: command, + ...(windowsStartupCommands && Object.keys(windowsStartupCommands).length + ? { windowsStartupCommands } + : {}), }; } @@ -1600,6 +1617,9 @@ export function createAppControlService(args: CreateAppControlServiceArgs) { // its parent chat. toolType: "shell", startupCommand: resolved.commandForDisplay, + ...(resolved.windowsStartupCommands + ? { windowsStartupCommands: resolved.windowsStartupCommands } + : {}), ...(resolved.command ? { command: resolved.command, args: resolved.args ?? [] } : {}), diff --git a/apps/desktop/src/main/services/automations/automationPlannerService.ts b/apps/desktop/src/main/services/automations/automationPlannerService.ts index 962dc5da1..75008ea5a 100644 --- a/apps/desktop/src/main/services/automations/automationPlannerService.ts +++ b/apps/desktop/src/main/services/automations/automationPlannerService.ts @@ -452,7 +452,15 @@ async function runCodexExec(args: { } } - cliArgs.push(args.prompt); + // `-` makes Codex read the prompt from stdin. Never put it on the command + // line: when the resolved launcher is a `.cmd`/extensionless npm shim, ADE + // has to route the spawn through `cmd.exe /d /s /c "…"`, and cmd mangles the + // prompt in three separate ways that execve on macOS does not — `%` is + // doubled and environment references are expanded, newlines collapse to + // spaces, and anything past ~8191 characters fails outright with "The command + // line is too long." Planner prompts routinely carry lane branch lists and + // multi-line user intent, so all three are reachable. + cliArgs.push("-"); let codexExecutable: string; try { @@ -480,8 +488,9 @@ async function runCodexExec(args: { const child = spawn(invocation.command, invocation.args, { cwd: args.cwd, env, - stdio: ["ignore", "pipe", "pipe"], + stdio: ["pipe", "pipe", "pipe"], windowsVerbatimArguments: invocation.windowsVerbatimArguments, + windowsHide: true, }); let stderr = ""; @@ -492,7 +501,15 @@ async function runCodexExec(args: { const exitCode = await new Promise((resolve, reject) => { child.on("error", reject); - child.on("exit", (code) => resolve(code)); + // `close` rather than `exit`: on Windows the stdio pipes drain after the + // process is reaped, so settling on `exit` truncates the stderr that ends + // up in the "Codex exited with code N" message. + child.on("close", (code) => resolve(code)); + child.stdin?.on("error", () => { + // Codex can exit before the prompt is fully written; the exit code and + // stderr are the authoritative failure signal. + }); + child.stdin?.end(args.prompt); }); try { @@ -565,6 +582,7 @@ async function runClaudeHeadless(args: { env, stdio: ["ignore", "pipe", "pipe"], windowsVerbatimArguments: invocation.windowsVerbatimArguments, + windowsHide: true, }); let stdout = ""; diff --git a/apps/desktop/src/main/services/automations/automationService.ts b/apps/desktop/src/main/services/automations/automationService.ts index 31cfed7e8..8f3e58926 100644 --- a/apps/desktop/src/main/services/automations/automationService.ts +++ b/apps/desktop/src/main/services/automations/automationService.ts @@ -2419,7 +2419,8 @@ export function createAutomationService({ const child = spawn(shellFile, shellArgs, { cwd: args.cwd, env: process.env, - stdio: ["ignore", "pipe", "pipe"] + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, }); let stdout = ""; let stderr = ""; diff --git a/apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.ts b/apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.ts index 8b5d03e12..aed3e6db6 100644 --- a/apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.ts +++ b/apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.ts @@ -19,6 +19,7 @@ import { import type { Logger } from "../logging/logger"; import { resolveBuiltInBrowserActorCapability } from "./builtInBrowserActorCapabilities"; import type { BuiltInBrowserService } from "./builtInBrowserService"; +import { localIpcListenOptions } from "../../../../../ade-cli/src/services/runtime/localIpcListenOptions"; /** * Side-channel JSON-RPC server that exposes the desktop's @@ -111,7 +112,7 @@ export function startBuiltInBrowserDesktopBridgeServer(args: { }); try { - server.listen(socketPath, () => { + server.listen(localIpcListenOptions(socketPath), () => { if (!isNamedPipe) { try { fs.chmodSync(socketPath, 0o600); diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 8899aa68b..e2c02c00b 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -2308,7 +2308,7 @@ describe("createAgentChatService", () => { allowed: false, state: "exhausted", code: "disk_full", - message: "Your Mac is almost out of storage. ADE paused new agent work to protect your chats and projects. Free up space, then resume.", + message: "Your computer is almost out of storage. ADE paused new agent work to protect your chats and projects. Free up space, then resume.", })), }, onEvent: (event: AgentChatEventEnvelope) => events.push(event), @@ -2338,7 +2338,7 @@ describe("createAgentChatService", () => { allowed: false, state: "exhausted" as const, code: "disk_full" as const, - message: "Your Mac is almost out of storage. ADE paused new agent work to protect your chats and projects. Free up space, then resume.", + message: "Your computer is almost out of storage. ADE paused new agent work to protect your chats and projects. Free up space, then resume.", })); const { service } = createService({ diskPressureMonitor: { canPerform }, @@ -26387,7 +26387,7 @@ describe("createAgentChatService", () => { allowed: false, state: "exhausted", code: "disk_full", - message: "Your Mac is almost out of storage.", + message: "Your computer is almost out of storage.", }), }, onEvent: (event: AgentChatEventEnvelope) => events.push(event), @@ -38107,7 +38107,7 @@ describe("explicit provider-thread continuity recovery", () => { allowed: false, state: "exhausted", code: "disk_full", - message: "Your Mac is almost out of storage. ADE paused new agent work to protect your chats and projects. Free up space, then resume.", + message: "Your computer is almost out of storage. ADE paused new agent work to protect your chats and projects. Free up space, then resume.", })), }, onEvent: (event: AgentChatEventEnvelope) => events.push(event), diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 99b762f72..87622316d 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -26539,6 +26539,7 @@ export function createAgentChatService(args: { stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32", windowsVerbatimArguments: invocation.windowsVerbatimArguments, + windowsHide: true, }); const reader = readline.createInterface({ input: proc.stdout }); diff --git a/apps/desktop/src/main/services/chat/claudeSubprocessReaper.test.ts b/apps/desktop/src/main/services/chat/claudeSubprocessReaper.test.ts index 1fb242940..62becce1b 100644 --- a/apps/desktop/src/main/services/chat/claudeSubprocessReaper.test.ts +++ b/apps/desktop/src/main/services/chat/claudeSubprocessReaper.test.ts @@ -48,10 +48,13 @@ afterEach(() => { vi.useRealTimers(); }); +// These cover the POSIX signal path, which is why each reaper is pinned to +// "darwin": on Windows there are no signals, and the reaper kills the whole +// process tree with taskkill instead. describe("createClaudeSubprocessReaper", () => { it("registers and unregisters Claude subprocesses on exit", () => { const logger = createLogger(); - const reaper = createClaudeSubprocessReaper({ logger }); + const reaper = createClaudeSubprocessReaper({ logger, platform: "darwin" }); const child = createProcess(1234); reaper.register(child, { @@ -87,6 +90,7 @@ describe("createClaudeSubprocessReaper", () => { const spawnProcess = vi.fn(() => child); const reaper = createClaudeSubprocessReaper({ logger, + platform: "darwin", spawnProcess: spawnProcess as any, }); @@ -125,6 +129,7 @@ describe("createClaudeSubprocessReaper", () => { const child = createProcess(2468); const reaper = createClaudeSubprocessReaper({ logger, + platform: "darwin", killGraceMs: 25, }); reaper.register(child, { @@ -151,6 +156,7 @@ describe("createClaudeSubprocessReaper", () => { const otherChild = createProcess(2470); const reaper = createClaudeSubprocessReaper({ logger, + platform: "darwin", killGraceMs: 25, }); reaper.register(matchingChild, { @@ -190,7 +196,7 @@ describe("createClaudeSubprocessReaper", () => { child.killed = true; // Node-faithful: true after the first delivered signal. return true; }); - const reaper = createClaudeSubprocessReaper({ logger, killGraceMs: 25 }); + const reaper = createClaudeSubprocessReaper({ logger, platform: "darwin", killGraceMs: 25 }); reaper.register(child, { sessionId: "chat-hung", laneId: "lane-1", @@ -234,6 +240,7 @@ describe("createClaudeSubprocessReaper", () => { createClaudeSubprocessReaper({ logger, + platform: "darwin", killGraceMs: 25, registryPath, processKill, diff --git a/apps/desktop/src/main/services/chat/claudeSubprocessReaper.ts b/apps/desktop/src/main/services/chat/claudeSubprocessReaper.ts index 3e164a710..1312ae063 100644 --- a/apps/desktop/src/main/services/chat/claudeSubprocessReaper.ts +++ b/apps/desktop/src/main/services/chat/claudeSubprocessReaper.ts @@ -1,10 +1,11 @@ -import { spawn, type ChildProcessByStdio } from "node:child_process"; +import { spawn, spawnSync, type ChildProcessByStdio } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import type { Readable, Writable } from "node:stream"; import type { SpawnOptions, SpawnedProcess } from "@anthropic-ai/claude-agent-sdk"; import type { Logger } from "../logging/logger"; +import { killWindowsProcessTree, resolveCliSpawnInvocation, terminateProcessTree } from "../shared/processExecution"; export type ClaudeSubprocessMetadata = { sessionId: string; @@ -22,6 +23,40 @@ export type ClaudeSubprocessRecord = ClaudeSubprocessMetadata & { }; type ClaudeChildProcess = ChildProcessByStdio; + +/** + * Image names a registered Claude subprocess can legitimately be running under + * on Windows: the resolved executable itself, or the interpreter that a shim + * hands off to (`claude.cmd` runs under cmd.exe, `claude.ps1` under PowerShell, + * an npm-linked entry under node.exe). + */ +function windowsExpectedImageNames(command: string): string[] { + const base = path.win32.basename(command).toLowerCase(); + const stem = base.replace(/\.(cmd|bat|ps1|exe|com)$/u, ""); + return [base, `${stem}.exe`, "cmd.exe", "powershell.exe", "pwsh.exe", "node.exe", "conhost.exe"]; +} + +function windowsPidImageName(pid: number): string | null { + try { + const result = spawnSync("tasklist.exe", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], { + encoding: "utf8", + windowsHide: true, + timeout: 5_000, + }); + if (result.error || result.status !== 0) return null; + // CSV row: "image.exe","1234","Console","1","12,345 K" + const name = String(result.stdout ?? "").trim().match(/^"([^"]+)"/u)?.[1]; + return name ? name.toLowerCase() : null; + } catch { + return null; + } +} + +function windowsPidLooksLikeRecord(record: ClaudeSubprocessRecord): boolean { + const image = windowsPidImageName(record.pid); + if (!image) return true; + return windowsExpectedImageNames(record.command).includes(image); +} type LiveClaudeSubprocess = { record: ClaudeSubprocessRecord; process: SpawnedProcess; @@ -38,8 +73,12 @@ export function createClaudeSubprocessReaper(args: { clearTimer?: typeof clearTimeout; registryPath?: string | null; processKill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean; + /** Overridable so the POSIX and Windows kill paths can each be exercised. */ + platform?: NodeJS.Platform; }) { const logger = args.logger; + const platform = args.platform ?? process.platform; + const isWindows = platform === "win32"; const killGraceMs = args.killGraceMs ?? 5_000; const spawnProcess = args.spawnProcess ?? spawn; const setTimer = args.setTimer ?? setTimeout; @@ -121,6 +160,36 @@ export function createClaudeSubprocessReaper(args: { removeRegistryPid(record.pid); return; } + if (isWindows) { + // This registry outlives the app, and Windows recycles PIDs far faster + // than macOS, so a stale record can point at somebody else's process by + // the time we read it. Only refuse when Windows positively names an image + // that cannot be ours — an unreadable answer still gets reaped, because + // leaking a Claude subprocess is the worse failure. + if (!windowsPidLooksLikeRecord(record)) { + logger.warn("agent_chat.claude_subprocess_pid_reused", { + pid: record.pid, + sessionId: record.sessionId, + reason, + }); + removeRegistryPid(record.pid); + return; + } + logger.warn("agent_chat.claude_subprocess_terminate", { + pid: record.pid, + sessionId: record.sessionId, + reason, + }); + // `process.kill(pid, "SIGTERM")` on Windows is an immediate, ungraceful + // TerminateProcess of that one PID — it leaves the Claude binary's own + // children (ripgrep, MCP servers, node) orphaned. There is no graceful + // stage to escalate from, so kill the whole tree in one step. + killWindowsProcessTree(record.pid, (detail) => { + logger.warn("agent_chat.claude_subprocess_taskkill_failed", { ...detail, sessionId: record.sessionId }); + }); + removeRegistryPid(record.pid); + return; + } logger.warn("agent_chat.claude_subprocess_terminate", { pid: record.pid, sessionId: record.sessionId, @@ -220,11 +289,18 @@ export function createClaudeSubprocessReaper(args: { options: SpawnOptions, metadata: ClaudeSubprocessMetadata, ): SpawnedProcess => { - const child = spawnProcess(options.command, options.args, { + // A `.cmd`/`.bat` shim — what `npm i -g @anthropic-ai/claude-code` puts on + // PATH — cannot be handed to `spawn` directly: since the CVE-2024-27980 fix + // Node refuses it with EINVAL unless it goes through a command interpreter. + // `resolveCliSpawnInvocation` produces the correctly quoted cmd.exe (or + // PowerShell, for `.ps1`) invocation and is a no-op for a plain `.exe`. + const invocation = resolveCliSpawnInvocation(options.command, options.args, options.env, platform); + const child = spawnProcess(invocation.command, invocation.args, { cwd: options.cwd, env: options.env, signal: options.signal, stdio: ["pipe", "pipe", "ignore"], + windowsVerbatimArguments: invocation.windowsVerbatimArguments, windowsHide: true, }) as ClaudeChildProcess; register(child, metadata, options.command, options.args); @@ -247,7 +323,17 @@ export function createClaudeSubprocessReaper(args: { reason, }); try { - child.kill("SIGTERM"); + if (isWindows) { + // taskkill /T /F is the only way to take the Claude binary's own + // children (ripgrep, MCP servers, and the cmd.exe that fronts a `.cmd` + // shim) down with it — `child.kill("SIGTERM")` on Windows terminates + // this one PID and orphans the rest. + terminateProcessTree(child as unknown as Parameters[0], "SIGTERM", (detail) => { + logger.warn("agent_chat.claude_subprocess_taskkill_failed", { ...detail, sessionId: entry.record.sessionId }); + }); + } else { + child.kill("SIGTERM"); + } } catch { // Best effort; the process may already be gone. } diff --git a/apps/desktop/src/main/services/chat/codexSlashCommandDiscovery.ts b/apps/desktop/src/main/services/chat/codexSlashCommandDiscovery.ts index fc4c25b6f..aada91720 100644 --- a/apps/desktop/src/main/services/chat/codexSlashCommandDiscovery.ts +++ b/apps/desktop/src/main/services/chat/codexSlashCommandDiscovery.ts @@ -20,8 +20,26 @@ export type ResolvedCodexSlashCommandInvocation = { argumentsText: string; }; +function codexHomeDir(env: NodeJS.ProcessEnv = process.env): string { + const configured = typeof env.CODEX_HOME === "string" ? env.CODEX_HOME.trim() : ""; + return configured ? path.resolve(configured) : path.join(os.homedir(), ".codex"); +} + +function samePath(left: string, right: string): boolean { + // Windows paths are case-insensitive, and `path.resolve(cwd)` and + // `os.homedir()` routinely disagree on drive-letter case. A case-sensitive + // compare would miss the home-directory stop and keep walking to the depth + // cap, inventing `C:\Users\.codex` and `C:\.codex` prompt roots. + return process.platform === "win32" + ? left.toLowerCase() === right.toLowerCase() + : left === right; +} + function codexPromptRoots(cwd: string): string[] { - const roots: string[] = [path.join(os.homedir(), ".codex", "prompts")]; + // Codex resolves its user-level prompt directory under CODEX_HOME, which its + // own installers set and which is commonly relocated off the roaming profile + // on Windows. Only the per-project `.codex/prompts` walk is cwd-relative. + const roots: string[] = [path.join(codexHomeDir(), "prompts")]; const seen = new Set(roots); const home = os.homedir(); let current = path.resolve(cwd); @@ -34,7 +52,7 @@ function codexPromptRoots(cwd: string): string[] { } const parent = path.dirname(current); if (parent === current) break; - if (current === home) break; + if (samePath(current, home)) break; current = parent; depth += 1; } diff --git a/apps/desktop/src/main/services/chat/crossMachineForkTransport.ts b/apps/desktop/src/main/services/chat/crossMachineForkTransport.ts index 270562120..94edc1cc8 100644 --- a/apps/desktop/src/main/services/chat/crossMachineForkTransport.ts +++ b/apps/desktop/src/main/services/chat/crossMachineForkTransport.ts @@ -75,6 +75,7 @@ export const runCliCapture = ( const child = spawn(bin, args, { cwd: opts.cwd, stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, }); const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; diff --git a/apps/desktop/src/main/services/chat/cursorSdkHooks.test.ts b/apps/desktop/src/main/services/chat/cursorSdkHooks.test.ts index 76148cf4e..8ec6faf0e 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkHooks.test.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkHooks.test.ts @@ -318,9 +318,14 @@ describe("Cursor SDK hook installation", () => { electronPath: String.raw`C:\Users\Ada%20\AppData\Local\ADE.exe`, scriptPath: String.raw`C:\Users\Ada%20\.cursor\hooks\ade-tool-gate.cjs`, }); - expect(fs.readFileSync(commandPath, "utf8")).toContain( - String.raw`"C:\Users\Ada%%20\AppData\Local\ADE.exe" "C:\Users\Ada%%20\.cursor\hooks\ade-tool-gate.cjs"`, + const script = fs.readFileSync(commandPath, "utf8"); + expect(script).toContain( + String.raw`"C:\Users\Ada%%20\AppData\Local\ADE.exe" "C:\Users\Ada%%20\.cursor\hooks\ade-tool-gate.cjs" %*`, ); + // Cursor's own hook arguments must reach the bridge script, the way the + // POSIX wrapper forwards "$@". + expect(script).toContain("%*"); + expect(script).toContain("setlocal"); } finally { fs.rmSync(home, { recursive: true, force: true }); } diff --git a/apps/desktop/src/main/services/chat/cursorSdkHooks.ts b/apps/desktop/src/main/services/chat/cursorSdkHooks.ts index 294334f3c..529a530fc 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkHooks.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkHooks.ts @@ -333,10 +333,14 @@ export function writeCursorSdkHookWindowsCommandScript(args: { scriptPath: string; }): void { ensureDir(path.dirname(args.commandPath)); + // `%*` forwards whatever Cursor passed (notably `--socket `), matching + // the POSIX wrapper's `"$@"`. `setlocal` keeps ELECTRON_RUN_AS_NODE out of + // any parent environment that reuses this cmd instance. const source = [ "@echo off", + "setlocal", "set ELECTRON_RUN_AS_NODE=1", - `${windowsBatchQuote(args.electronPath)} ${windowsBatchQuote(args.scriptPath)}`, + `${windowsBatchQuote(args.electronPath)} ${windowsBatchQuote(args.scriptPath)} %*`, "exit /b %ERRORLEVEL%", "", ].join("\r\n"); diff --git a/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts b/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts index 0f9c62ae9..f9776c110 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts @@ -10,6 +10,7 @@ import { resolveCursorSdkPolicy, summarizeCursorHook, } from "./cursorSdkPolicy"; +import { cursorProjectSlug } from "../../../shared/cursorProjectSlug"; describe("Cursor SDK policy", () => { it("maps Cursor modes to ADE permission policies", () => { @@ -158,6 +159,37 @@ describe("Cursor SDK policy", () => { expect(request.reason).toContain("/etc"); }); + // The guard deliberately leaves backslash tokens alone on POSIX, where `\` is + // a legal filename character, so these escape shapes have no POSIX analogue. + // WINDOWS-GATE: Windows-only shell path syntax; verified green on a native Windows host. + it.runIf(process.platform === "win32")("denies Windows-shell lane escapes written with backslashes or %VAR% expansion", () => { + const policy = resolveCursorSdkPolicy({ cursorModeId: "full-auto" }); + const laneRoot = path.join(path.parse(path.resolve("/")).root, "Users", "admin", "lane"); + const userHomeDir = path.join(path.parse(path.resolve("/")).root, "Users", "admin"); + const cases: Array<[string, string]> = [ + ["type ..\\..\\..\\.ssh\\id_rsa", "outside the active lane"], + ["type .\\..\\..\\secret.txt", "outside the active lane"], + ["type %USERPROFILE%\\.ssh\\id_rsa", "outside the active lane"], + ["Get-Content $env:USERPROFILE\\.aws\\credentials", "outside the active lane"], + ["type .ade\\secrets\\token", "protected by ADE"], + ]; + for (const [command, reason] of cases) { + const request = summarizeCursorHook({ toolName: "shell", toolInput: { command } }, laneRoot); + expect(evaluateCursorSdkHook({ request, policy, laneRoot, userHomeDir })).toBe("deny"); + expect(request.reason).toContain(reason); + } + }); + + it("keeps POSIX-style backslash filenames out of the Windows path heuristics", () => { + const policy = resolveCursorSdkPolicy({ cursorModeId: "full-auto" }); + const laneRoot = path.join(path.parse(path.resolve("/")).root, "tmp", "ade-lane"); + const request = summarizeCursorHook({ + toolName: "shell", + toolInput: { command: "echo hello" }, + }, laneRoot); + expect(evaluateCursorSdkHook({ request, policy, laneRoot })).toBe("allow"); + }); + it("denies shell cwd escapes even when the command text is otherwise safe", () => { const policy = resolveCursorSdkPolicy({ cursorModeId: "full-auto" }); const laneRoot = "/tmp/ade-lane"; @@ -170,10 +202,18 @@ describe("Cursor SDK policy", () => { it("allows Cursor SDK transcript and terminal reads for the active lane only", () => { const policy = resolveCursorSdkPolicy({ cursorModeId: "full-auto" }); - const laneRoot = "/Users/admin/Projects/Versic/.ade/worktrees/private-sharing-5d14c47a"; - const userHomeDir = "/Users/admin"; + // Build the lane root from the platform's own filesystem root: on Windows + // `path.resolve` prefixes the current drive, so a hard-coded POSIX path + // yields a different (drive-prefixed) slug there. + const fsRoot = path.parse(path.resolve("/")).root; + const userHomeDir = path.join(fsRoot, "Users", "admin"); + const laneRoot = path.join(userHomeDir, "Projects", "Versic", ".ade", "worktrees", "private-sharing-5d14c47a"); const slug = cursorProjectSlugForPath(laneRoot); - expect(slug).toBe("Users-admin-Projects-Versic-ade-worktrees-private-sharing-5d14c47a"); + // Cursor's own rule: every non-alphanumeric character becomes a dash, runs + // collapse, leading/trailing dashes are trimmed. The Windows drive letter + // therefore survives as a leading `C-` segment. + expect(slug).toBe(cursorProjectSlug(path.resolve(laneRoot))); + expect(slug).toMatch(/Users-admin-Projects-Versic-ade-worktrees-private-sharing-5d14c47a$/u); const transcript = summarizeCursorHook({ toolName: "read", diff --git a/apps/desktop/src/main/services/chat/cursorSdkPolicy.ts b/apps/desktop/src/main/services/chat/cursorSdkPolicy.ts index c193bbca6..997a3618f 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkPolicy.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkPolicy.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import type { AgentChatSession } from "../../../shared/types"; +import { cursorProjectSlug } from "../../../shared/cursorProjectSlug"; import type { CursorSdkApprovalPolicy, CursorSdkChatMode, @@ -231,6 +232,27 @@ function trimShellToken(token: string): string { return token.trim().replace(/^[=:,]+|[,:]+$/g, ""); } +/** + * Windows shells use `\` as the path separator and `%VAR%` / `$env:VAR` for + * expansion, so the POSIX-only token shapes below never match a Windows command + * line. Backslash handling stays win32-gated because `\` is a legal filename + * character (and a shell escape) on POSIX. + */ +const WINDOWS_HOME_PREFIX = /^(?:%(?:USERPROFILE|HOME)%|\$env:(?:USERPROFILE|HOME)|\$\{?env:(?:USERPROFILE|HOME)\}?)[\\/]/iu; + +function isWindowsPathLikeToken(cleaned: string): boolean { + if (WINDOWS_HOME_PREFIX.test(cleaned)) return true; + if ( + cleaned.startsWith("..\\") + || cleaned.startsWith(".\\") + || cleaned.startsWith("\\") + ) return true; + if (!cleaned.includes("\\")) return false; + // `FOO=bar\baz` is an assignment, not a path argument. + if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(cleaned)) return false; + return true; +} + function looksLikePathToken(token: string): boolean { const cleaned = trimShellToken(token); if (!cleaned || cleaned.startsWith("-") || cleaned.includes("://")) return false; @@ -245,6 +267,7 @@ function looksLikePathToken(token: string): boolean { || cleaned.startsWith("$HOME/") || cleaned.startsWith("${HOME}/") ) return true; + if (process.platform === "win32" && isWindowsPathLikeToken(cleaned)) return true; if (!cleaned.includes("/")) return false; if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(cleaned)) return false; if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(cleaned)) return false; @@ -327,6 +350,14 @@ function isWithinPath(root: string, candidate: string): boolean { } function resolveCandidatePath(candidate: string, cwd: string, userHomeDir?: string | null): string { + // Windows equivalents of `$HOME/…`. Without this the guard resolves + // `%USERPROFILE%\.ssh\id_rsa` literally under the lane root and lets it pass. + if (process.platform === "win32" && userHomeDir?.trim()) { + const windowsHome = WINDOWS_HOME_PREFIX.exec(candidate); + if (windowsHome) { + return path.resolve(userHomeDir, candidate.slice(windowsHome[0].length)); + } + } if (candidate === "~" && userHomeDir?.trim()) return path.resolve(userHomeDir); if (candidate.startsWith("~/") && userHomeDir?.trim()) { return path.resolve(userHomeDir, candidate.slice(2)); @@ -341,12 +372,7 @@ function resolveCandidatePath(candidate: string, cwd: string, userHomeDir?: stri } export function cursorProjectSlugForPath(projectPath: string): string { - return path.resolve(projectPath) - .split(/[\\/]+/) - .filter(Boolean) - .map((component) => component.replace(/^\.+/, "").replace(/[^A-Za-z0-9_-]+/g, "")) - .filter(Boolean) - .join("-"); + return cursorProjectSlug(path.resolve(projectPath)); } function cursorSupportReadRoots(laneRoot: string, userHomeDir?: string | null): string[] { diff --git a/apps/desktop/src/main/services/chat/cursorSdkPool.test.ts b/apps/desktop/src/main/services/chat/cursorSdkPool.test.ts index c212da719..90b74abef 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkPool.test.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkPool.test.ts @@ -7,6 +7,7 @@ import { acquireCursorSdkConnection, buildCursorSdkPaths, buildCursorSdkWorkerEnv, + cleanupCursorSdkRuntimePaths, isCursorSdkPooledAlive, releaseCursorSdkConnection, resolveCursorSdkUserHome, @@ -175,6 +176,48 @@ describe("Cursor SDK pool paths", () => { } }); + it("retries one-shot SDK state removal until the worker releases its handles", async () => { + // Cleanup runs while the worker is still shutting down. On Windows the + // SDK's open `state/index.db` makes the first `rmSync` fail with EBUSY and + // the state directory is leaked; POSIX unlinks it on the first try. + const cacheRoot = makeTempDir("ade-cursor-cleanup-"); + const stateRoot = path.join(cacheRoot, "state"); + fs.mkdirSync(stateRoot, { recursive: true }); + fs.writeFileSync(path.join(stateRoot, "index.db"), "held"); + + const realRm = fs.rmSync; + let busyAttempts = 2; + const rmSpy = vi.spyOn(fs, "rmSync").mockImplementation(((target: fs.PathLike, options?: fs.RmOptions) => { + if (busyAttempts > 0) { + busyAttempts -= 1; + const error = new Error(`EBUSY: resource busy or locked, rmdir '${String(target)}'`) as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return realRm(target, options); + }) as typeof fs.rmSync); + + try { + cleanupCursorSdkRuntimePaths({ cacheRoot, stateRoot, cleanupStateRoot: true }); + const deadline = Date.now() + 5_000; + while (fs.existsSync(cacheRoot) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + expect(busyAttempts).toBe(0); + expect(fs.existsSync(cacheRoot)).toBe(false); + } finally { + rmSpy.mockRestore(); + } + }); + + it("leaves SDK state alone when cleanup was not requested", () => { + const cacheRoot = makeTempDir("ade-cursor-keep-"); + const stateRoot = path.join(cacheRoot, "state"); + fs.mkdirSync(stateRoot, { recursive: true }); + cleanupCursorSdkRuntimePaths({ cacheRoot, stateRoot, cleanupStateRoot: false }); + expect(fs.existsSync(stateRoot)).toBe(true); + }); + it("keeps durable SDK state stable while pool-specific socket paths change", () => { const projectRoot = path.join(os.tmpdir(), "ade-project"); const first = buildCursorSdkPaths({ diff --git a/apps/desktop/src/main/services/chat/cursorSdkPool.ts b/apps/desktop/src/main/services/chat/cursorSdkPool.ts index 78cad8047..be43a9b62 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkPool.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkPool.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import type { Logger } from "../logging/logger"; import { buildPackagedRuntimeNodeModulePaths } from "../runtime/packagedNodePath"; +import { terminateChildProcessTree } from "../shared/utils"; import type { CursorSdkCloudArtifactDescriptor, CursorSdkErrorDetail, @@ -90,6 +91,12 @@ const pools = new Map(); const pendingInits = new Map>(); const STALE_INIT_RETRY_LIMIT = 2; +/** + * How long the worker gets to answer the IPC `dispose` request before the pool + * kills its process tree. It has to cover cancelling an in-flight run and + * closing the SDK agent, and on Windows it is the only orderly path there is. + */ +const CURSOR_SDK_DISPOSE_GRACE_MS = 3_000; const CURSOR_SDK_WORKER_ENV_DENYLIST = [ "CURSOR_API_KEY", "CURSOR_AUTH_TOKEN", @@ -502,6 +509,8 @@ async function createCursorSdkConnection(args: Parameters(); + let disposeTimer: NodeJS.Timeout | null = null; + let killTimer: NodeJS.Timeout | null = null; const bridge: CursorSdkBridge = { onEvent: null, onRunStarted: null, @@ -615,14 +624,22 @@ async function createCursorSdkConnection(args: Parameters { for (const [, waiter] of pending) waiter.reject(new Error("Cursor SDK worker disposed.")); pending.clear(); + // Windows has no graceful SIGTERM: `child.kill()` is TerminateProcess, so + // the worker's own signal handler never runs and the tools the SDK + // spawned (shell commands, the bundled ripgrep) are left behind. The IPC + // `dispose` request is therefore the only orderly shutdown path here, and + // the escalation must kill the whole tree rather than a single pid. + const escalate = (): void => { + if (child.exitCode != null || child.killed) return; + killTimer = terminateChildProcessTree(child, killTimer); + }; const sent = sendWorkerMessage({ type: "dispose", requestId: randomUUID() } as CursorSdkWorkerRequest); - if (!sent && child.exitCode == null && !child.killed) { - child.kill("SIGTERM"); + if (!sent) { + escalate(); return; } - setTimeout(() => { - if (child.exitCode == null && !child.killed) child.kill("SIGTERM"); - }, 800).unref(); + disposeTimer = setTimeout(escalate, CURSOR_SDK_DISPOSE_GRACE_MS); + disposeTimer.unref(); }, }; @@ -771,6 +788,12 @@ async function createCursorSdkConnection(args: Parameters { + // Never let an escalation fire after the worker is gone: on Windows that + // would run `taskkill /T /F` against a recycled pid. + if (disposeTimer) clearTimeout(disposeTimer); + if (killTimer) clearTimeout(killTimer); + disposeTimer = null; + killTimer = null; rejectPending(workerExitedError(code, signal)); cleanupPoolEntry(pooled); }); @@ -820,7 +843,30 @@ async function createCursorSdkConnection(args: Parameters= CURSOR_SDK_CLEANUP_RETRY_LIMIT) return; + setTimeout( + () => removeCursorSdkRuntimePath(target, attempt + 1), + CURSOR_SDK_CLEANUP_RETRY_DELAY_MS, + ).unref(); + } +} + +export function cleanupCursorSdkRuntimePaths(entry: { cacheRoot?: string; stateRoot: string; socketPath?: string; @@ -833,11 +879,7 @@ function cleanupCursorSdkRuntimePaths(entry: { targets.add(path.dirname(entry.socketPath)); } for (const target of targets) { - try { - fs.rmSync(target, { recursive: true, force: true }); - } catch { - // Best effort: stale one-shot SDK state should never break request cleanup. - } + removeCursorSdkRuntimePath(target); } } diff --git a/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts b/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts index 8ff274e5c..857f7e337 100644 --- a/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts +++ b/apps/desktop/src/main/services/chat/droidModelsDiscovery.ts @@ -9,6 +9,7 @@ import { type ModelDescriptor, } from "../../../shared/modelRegistry"; import { spawnAsync } from "../shared/utils"; +import { ensureDroidSpawnsAreWindowless } from "./droidSdkWindowsHide"; export type DroidExecHelpModelRow = { id: string; @@ -327,6 +328,9 @@ function normalizeDroidDiscoveredModel(row: DroidExecHelpModelRow): DroidExecHel async function listDroidModelsFromSdk(droidPath: string): Promise { const now = Date.now(); + // This runs createSession() in-process (not in the worker), so the SDK spawns + // `droid` straight from the Electron main process on a passive warm path. + ensureDroidSpawnsAreWindowless(); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 8_000); try { diff --git a/apps/desktop/src/main/services/chat/droidSdkPool.ts b/apps/desktop/src/main/services/chat/droidSdkPool.ts index b77ff2c7b..4245fa108 100644 --- a/apps/desktop/src/main/services/chat/droidSdkPool.ts +++ b/apps/desktop/src/main/services/chat/droidSdkPool.ts @@ -4,6 +4,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { Logger } from "../logging/logger"; +import { terminateChildProcessTree } from "../shared/utils"; import type { DroidSdkAskUserRequest, DroidSdkAskUserResponse, @@ -48,6 +49,10 @@ let droidSdkGenCounter = 0; const pools = new Map(); const pendingInits = new Map>(); const STALE_INIT_RETRY_LIMIT = 2; +// @factory/droid-sdk's ProcessTransport.close() gives `droid` a 5s grace period +// before escalating to SIGKILL. Force-killing the worker sooner than that tears +// the worker down mid-close and leaves the `droid` process behind. +const WORKER_FORCE_KILL_DELAY_MS = 6_000; const moduleDir = typeof __dirname === "string" ? __dirname @@ -208,8 +213,13 @@ async function createDroidSdkConnection(args: Parameters { - if (child.exitCode == null && !child.killed) child.kill("SIGTERM"); - }, 800).unref(); + if (child.exitCode != null || child.killed) return; + // `child.kill("SIGTERM")` is a single-PID TerminateProcess on Windows, + // so the `droid` process the worker spawned (and anything it spawned in + // turn) is orphaned rather than reaped. terminateChildProcessTree() uses + // `taskkill /T` on win32 and the process group elsewhere. + terminateChildProcessTree(child, null, 1_500).unref(); + }, WORKER_FORCE_KILL_DELAY_MS).unref(); }, }; diff --git a/apps/desktop/src/main/services/chat/droidSdkWindowsHide.test.ts b/apps/desktop/src/main/services/chat/droidSdkWindowsHide.test.ts new file mode 100644 index 000000000..b86ba30bf --- /dev/null +++ b/apps/desktop/src/main/services/chat/droidSdkWindowsHide.test.ts @@ -0,0 +1,58 @@ +import childProcess from "node:child_process"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ensureDroidSpawnsAreWindowless, resetDroidSpawnPatchForTests } from "./droidSdkWindowsHide"; + +const originalSpawn = childProcess.spawn; +const originalPlatform = process.platform; + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { value: platform, configurable: true }); +} + +afterEach(() => { + resetDroidSpawnPatchForTests(originalSpawn); + Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); +}); + +describe("ensureDroidSpawnsAreWindowless", () => { + it("defaults windowsHide for droid spawns on win32", () => { + setPlatform("win32"); + const seen: unknown[][] = []; + (childProcess as { spawn: unknown }).spawn = (...args: unknown[]) => { + seen.push(args); + return {} as unknown; + }; + ensureDroidSpawnsAreWindowless(); + + childProcess.spawn("C:\\Users\\dev\\bin\\droid.exe", ["exec"], { stdio: "pipe" } as never); + childProcess.spawn("droid", ["exec"] as never); + childProcess.spawn("git", ["status"], { stdio: "pipe" } as never); + + expect((seen[0]?.[2] as { windowsHide?: boolean }).windowsHide).toBe(true); + expect((seen[1]?.[2] as { windowsHide?: boolean }).windowsHide).toBe(true); + // Untouched: only the droid executable is matched. + expect((seen[2]?.[2] as { windowsHide?: boolean }).windowsHide).toBeUndefined(); + }); + + it("never overrides an explicit windowsHide", () => { + setPlatform("win32"); + const seen: unknown[][] = []; + (childProcess as { spawn: unknown }).spawn = (...args: unknown[]) => { + seen.push(args); + return {} as unknown; + }; + ensureDroidSpawnsAreWindowless(); + + childProcess.spawn("droid.exe", ["exec"], { windowsHide: false } as never); + + expect((seen[0]?.[2] as { windowsHide?: boolean }).windowsHide).toBe(false); + }); + + it("is a no-op off win32", () => { + setPlatform("darwin"); + const spy = vi.fn(); + (childProcess as { spawn: unknown }).spawn = spy; + ensureDroidSpawnsAreWindowless(); + expect(childProcess.spawn).toBe(spy); + }); +}); diff --git a/apps/desktop/src/main/services/chat/droidSdkWindowsHide.ts b/apps/desktop/src/main/services/chat/droidSdkWindowsHide.ts new file mode 100644 index 000000000..3c28a2775 --- /dev/null +++ b/apps/desktop/src/main/services/chat/droidSdkWindowsHide.ts @@ -0,0 +1,63 @@ +import childProcess from "node:child_process"; +import path from "node:path"; + +let patched = false; + +function isDroidExecutable(command: unknown): boolean { + if (typeof command !== "string" || !command.length) return false; + const base = path.win32.basename(command).toLowerCase(); + return base === "droid" || base === "droid.exe"; +} + +/** + * Force `windowsHide` on the `droid` child process. + * + * @factory/droid-sdk's `ProcessTransport.connect()` spawns the CLI with only + * `{ stdio, cwd, env }` — no `windowsHide` — and exposes no way to pass spawn + * options through `createSession()`. On Windows that means a console is + * allocated for `droid.exe`, and with Windows Terminal as the default console + * host it surfaces as a *visible* window titled with the droid path that lives + * as long as the session, not a brief flash. + * + * Verified against droid v0.186.0 spawned from a CREATE_NO_WINDOW parent: + * without windowsHide -> conhost child + visible WindowsTerminal window + * titled "C:/Users/arul2/bin/droid.exe" + * with windowsHide -> hidden console, no visible window + * + * The match is on the executable basename so no other spawn site in the process + * can be affected, and an explicit `windowsHide` from a caller always wins. + */ +export function ensureDroidSpawnsAreWindowless(): void { + if (patched || process.platform !== "win32") return; + patched = true; + + const originalSpawn = childProcess.spawn; + const patchedSpawn = function spawn(this: unknown, ...args: unknown[]): unknown { + if (isDroidExecutable(args[0])) { + const optionsIndex = args.length >= 2 && typeof args[1] === "object" && !Array.isArray(args[1]) + ? 1 + : args.length >= 3 && typeof args[2] === "object" + ? 2 + : -1; + if (optionsIndex === -1) { + args.push({ windowsHide: true }); + } else { + const options = args[optionsIndex] as Record | null; + if (options && options.windowsHide === undefined) { + args[optionsIndex] = { ...options, windowsHide: true }; + } else if (!options) { + args[optionsIndex] = { windowsHide: true }; + } + } + } + return (originalSpawn as (...callArgs: unknown[]) => unknown).apply(this, args); + }; + + (childProcess as { spawn: unknown }).spawn = patchedSpawn; +} + +/** Test seam: restore the unpatched spawn. */ +export function resetDroidSpawnPatchForTests(original?: typeof childProcess.spawn): void { + if (original) (childProcess as { spawn: typeof childProcess.spawn }).spawn = original; + patched = false; +} diff --git a/apps/desktop/src/main/services/chat/droidSdkWorker.ts b/apps/desktop/src/main/services/chat/droidSdkWorker.ts index 5b72dcf4d..928ac2d97 100644 --- a/apps/desktop/src/main/services/chat/droidSdkWorker.ts +++ b/apps/desktop/src/main/services/chat/droidSdkWorker.ts @@ -12,6 +12,10 @@ import type { } from "./droidSdkProtocol"; import { loadDroidSdk } from "../ai/droidSdkLoader"; import { summarizeDroidAskUser } from "./droidSdkAskUser"; +import { ensureDroidSpawnsAreWindowless } from "./droidSdkWindowsHide"; + +// Must run before the SDK spawns `droid`; see droidSdkWindowsHide.ts. +ensureDroidSpawnsAreWindowless(); type DroidSdkModule = typeof DroidSdkTypes; type DroidSession = Awaited>; diff --git a/apps/desktop/src/main/services/conflicts/conflictService.ts b/apps/desktop/src/main/services/conflicts/conflictService.ts index 8525eda0a..3e6209e61 100644 --- a/apps/desktop/src/main/services/conflicts/conflictService.ts +++ b/apps/desktop/src/main/services/conflicts/conflictService.ts @@ -3560,6 +3560,7 @@ export function createConflictService({ env: process.env, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32", + windowsHide: true, windowsVerbatimArguments: invocation.windowsVerbatimArguments, }); let stdout = ""; diff --git a/apps/desktop/src/main/services/externalSessions/discoverCursor.test.ts b/apps/desktop/src/main/services/externalSessions/discoverCursor.test.ts new file mode 100644 index 000000000..9a836422a --- /dev/null +++ b/apps/desktop/src/main/services/externalSessions/discoverCursor.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { discoverCursorSessions } from "./discoverCursor"; + +import { cursorProjectSlug } from "../../../shared/cursorProjectSlug"; + +function writeTranscript(home: string, slug: string, agentId: string, cwd: string): void { + const dir = path.join(home, ".cursor", "projects", slug, "agent-transcripts", agentId); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, `${agentId}.jsonl`), + `${JSON.stringify({ + type: "user", + timestamp: 1_700_000_000_000, + cwd, + message: { role: "user", content: "hello" }, + })}\n`, + ); +} + +describe("discoverCursorSessions", () => { + it("imports a transcript for an existing scoped workspace", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cursor-import-")); + const home = path.join(root, "home"); + const workspace = path.join(root, "repo"); + fs.mkdirSync(workspace, { recursive: true }); + writeTranscript(home, cursorProjectSlug(workspace), "chat-existing", workspace); + + try { + const records = await discoverCursorSessions({ homeDir: home, scopeRoots: [workspace], limit: 10 }); + expect(records.map((record) => record.id)).toContain("chat-existing"); + expect(records[0]?.cwd).toBe(workspace); + expect(records[0]?.preview).toBe("hello"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("matches Cursor's own project slug rule", () => { + // Byte-for-byte from @cursor/sdk's shipped slug function. + expect(cursorProjectSlug("C:\\Users\\me\\repo")).toBe("C-Users-me-repo"); + expect(cursorProjectSlug("/Users/me/repo")).toBe("Users-me-repo"); + expect(cursorProjectSlug("/Users/me/my.app/node_modules")).toBe("Users-me-my-app-node-modules"); + expect(cursorProjectSlug("C:\\repo\\.ade\\worktrees\\lane-1")).toBe("C-repo-ade-worktrees-lane-1"); + }); + + it("keeps a transcript in scope when its workspace directory no longer exists", async () => { + // The slug-to-cwd resolver cannot help once the directory is gone, so the + // structural slug comparison is the only thing left. + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cursor-import-gone-")); + const home = path.join(root, "home"); + const workspace = path.join(root, "deleted-repo"); + writeTranscript(home, cursorProjectSlug(workspace), "chat-deleted", workspace); + + try { + const records = await discoverCursorSessions({ homeDir: home, scopeRoots: [workspace], limit: 10 }); + expect(records.map((record) => record.id)).toContain("chat-deleted"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/desktop/src/main/services/externalSessions/discoverCursor.ts b/apps/desktop/src/main/services/externalSessions/discoverCursor.ts index 0650349c0..b94d0dc86 100644 --- a/apps/desktop/src/main/services/externalSessions/discoverCursor.ts +++ b/apps/desktop/src/main/services/externalSessions/discoverCursor.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import path from "node:path"; +import { cursorProjectSlug } from "../../../shared/cursorProjectSlug"; import { asEpochMs, asRecord, @@ -44,7 +45,7 @@ function isCursorConversationId(id: string): boolean { } function cursorProjectSlugForCwd(cwd: string): string { - return cwd.replace(/^[/\\]+/u, "").replace(/[\\/]/gu, "-"); + return cursorProjectSlug(cwd); } function cursorWorkspaceHash(cwd: string): string { diff --git a/apps/desktop/src/main/services/externalSessions/discoverDroid.ts b/apps/desktop/src/main/services/externalSessions/discoverDroid.ts index eec93d121..392ca485e 100644 --- a/apps/desktop/src/main/services/externalSessions/discoverDroid.ts +++ b/apps/desktop/src/main/services/externalSessions/discoverDroid.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import path from "node:path"; import { asEpochMs, @@ -14,7 +15,6 @@ import { resolveHomeDir, safeReadDir, sessionFileCandidate, - slashEscapedCwd, slugMatchesScopeRoots, sortDiscoveryRecords, EXTERNAL_SESSION_READ_BUDGET_MULTIPLIER, @@ -23,6 +23,37 @@ import { type ExternalSessionDiscoveryRecord, } from "./discoveryUtils"; +/** + * Name of the per-project directory Droid creates under `~/.factory/sessions`. + * + * This mirrors `sanitizePathToDirectoryName` in @factory/droid-sdk (and the + * `droid` CLI that writes the files), which is *not* a plain separator swap: + * + * posix /Users/dev/ADE -> "-Users-dev-ADE" + * win32 C:\Users\dev\ADE -> "-C-Users-dev-ADE" (drive colon dropped) + * + * A generic separator swap happens to match the posix form, which is why + * macOS worked, but on Windows it yields "C:-Users-dev-ADE" — a name + * that can never exist on NTFS (`:` is reserved) — so every Droid project + * directory failed the scope filter and no CLI sessions were imported. + */ +export function droidProjectSlugForCwd(cwd: string): string { + const absolutePath = path.resolve(cwd); + let canonicalPath = absolutePath; + try { + canonicalPath = fs.realpathSync(absolutePath); + } catch { + canonicalPath = absolutePath; + } + const normalized = canonicalPath.replace(/[\\/]+$/u, ""); + const slug = process.platform === "win32" + ? `-${normalized.replace(/^([A-Za-z]):/u, "$1").replace(/[\\/]+/gu, "-")}` + : `-${normalized.replace(/^\/+/u, "").replace(/\/+/gu, "-")}`; + // Windows paths are case-insensitive; the on-disk directory can differ in + // case from the scope root ADE hands us (drive letter especially). + return process.platform === "win32" ? slug.toLowerCase() : slug; +} + /** * A session directory is the slash-escaped cwd, which for an absolute path always * begins with the escaped separator. Anything else is a shape ADE cannot map back @@ -41,6 +72,10 @@ function moreCompleteDroidCandidate(current: DroidCandidate, next: DroidCandidat return moreCompleteFileCandidate(current, next); } +function droidSlugForComparison(slug: string): string { + return process.platform === "win32" ? slug.toLowerCase() : slug; +} + export async function discoverDroidSessions( args: ExternalSessionDiscoveryArgs = {}, ): Promise { @@ -55,7 +90,15 @@ export async function discoverDroidSessions( for (const projectEntry of safeReadDir(sessionsDir)) { if (!projectEntry.isDirectory()) continue; const namesAPath = droidDirectoryNamesAPath(projectEntry.name); - const inRequestedScope = slugMatchesScopeRoots(projectEntry.name, args.scopeRoots, slashEscapedCwd); + // Must use droidProjectSlugForCwd, not slashEscapedCwd: the latter yields + // "C:-Users-..." on Windows, a name NTFS can never hold, so every project + // directory failed this filter and no Droid session was ever importable. + // Verified byte-exact against droid v0.186.0's own on-disk directory names. + const inRequestedScope = slugMatchesScopeRoots( + droidSlugForComparison(projectEntry.name), + args.scopeRoots, + droidProjectSlugForCwd, + ); // Out-of-project directories are ruled out here, before the mtime cut that // would otherwise let heavy usage elsewhere crowd in-project sessions out. if (namesAPath && !inRequestedScope) continue; diff --git a/apps/desktop/src/main/services/externalSessions/discoverOpenCode.ts b/apps/desktop/src/main/services/externalSessions/discoverOpenCode.ts index 5a7d5beb7..5a985e252 100644 --- a/apps/desktop/src/main/services/externalSessions/discoverOpenCode.ts +++ b/apps/desktop/src/main/services/externalSessions/discoverOpenCode.ts @@ -2,6 +2,7 @@ import { execFile } from "node:child_process"; import path from "node:path"; import { promisify } from "node:util"; import { resolveOpenCodeBinaryPath } from "../opencode/openCodeBinaryManager"; +import { resolveCliSpawnInvocation } from "../shared/processExecution"; import { asEpochMs, asRecord, @@ -48,11 +49,23 @@ export async function discoverOpenCodeSessions( const env: NodeJS.ProcessEnv = { ...process.env, ...(args.env ?? {}), NO_COLOR: "1" }; delete env.FORCE_COLOR; + // `npm i -g opencode-ai` installs `%APPDATA%\npm\opencode.cmd` on Windows, and + // Node refuses to spawn a `.cmd`/`.bat` without a shell (it fails with a bare + // `spawn EINVAL`). The same install is a directly executable script on macOS, + // so this path only breaks on Windows. Route through the shared invocation + // helper, which shims those targets through cmd.exe and leaves a real `.exe` + // untouched. + const invocation = resolveCliSpawnInvocation( + executable, + ["session", "list", "--pure", "--format", "json", "--max-count", String(requestedLimit)], + env, + ); + let stdout: string; try { const result = await execFileAsync( - executable, - ["session", "list", "--pure", "--format", "json", "--max-count", String(requestedLimit)], + invocation.command, + invocation.args, { cwd: path.resolve(cwd), encoding: "utf8", @@ -60,6 +73,8 @@ export async function discoverOpenCodeSessions( killSignal: "SIGTERM", maxBuffer: 2 * 1024 * 1024, env, + windowsHide: true, + windowsVerbatimArguments: invocation.windowsVerbatimArguments, }, ); stdout = String(result.stdout ?? ""); @@ -68,6 +83,11 @@ export async function discoverOpenCodeSessions( } const jsonStart = stdout.indexOf("["); if (jsonStart < 0) { + // `opencode session list --format json` prints nothing at all when there are + // no sessions rather than an empty array, so a user who has simply never run + // the OpenCode CLI is not an error -- it is an empty result. Only treat + // non-empty output with no array in it as a genuine protocol failure. + if (stdout.trim().length === 0) return []; throw new Error("OpenCode session discovery returned no JSON session list."); } diff --git a/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts b/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts index d2308324b..2fe113029 100644 --- a/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts +++ b/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts @@ -5,13 +5,14 @@ import os from "node:os"; import path from "node:path"; import type { DatabaseSync as DatabaseSyncType } from "node:sqlite"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cursorProjectSlug } from "../../../shared/cursorProjectSlug"; import { clearOpenCodeBinaryCache } from "../opencode/openCodeBinaryManager"; import { discoverClaudeSessions } from "./discoverClaude"; import { discoverCodexSessions } from "./discoverCodex"; import { discoverCursorSessions } from "./discoverCursor"; -import { discoverDroidSessions } from "./discoverDroid"; +import { discoverDroidSessions, droidProjectSlugForCwd } from "./discoverDroid"; import { discoverOpenCodeSessions } from "./discoverOpenCode"; -import { claudeProjectSlugForCwd, slashEscapedCwd } from "./discoveryUtils"; +import { claudeProjectSlugForCwd } from "./discoveryUtils"; import { createExternalSessionsService } from "./externalSessionsService"; type DatabaseSyncConstructor = new (dbPath: string) => DatabaseSyncType; @@ -1013,7 +1014,8 @@ describe("external session provider discovery", () => { // Use a writable temp dir (CI can't mkdir under /private/tmp) and derive the slug from it. const cwd = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "adecursorcwd"))); expect(cwd.includes("-"), "temp cwd must be dash-free for the cursor slug round-trip").toBe(false); - const slug = cwd.replace(/^\/+/u, "").replace(/\//gu, "-"); + // Cursor's own slug rule, reimplemented in shared/cursorProjectSlug. + const slug = cursorProjectSlug(cwd); const agentId = "33333333-3333-4333-8333-333333333333"; const sdkAgentId = "agent-44444444-4444-4444-8444-444444444444"; writeJsonl(path.join(homeDir, ".cursor", "projects", slug, "agent-transcripts", agentId, `${agentId}.jsonl`), [ @@ -1080,7 +1082,7 @@ describe("external session provider discovery", () => { const homeDir = path.join(root, "home"); const cwd = path.join(root, "droid-repo"); const id = "44444444-4444-4444-8444-444444444444"; - writeJsonl(path.join(homeDir, ".factory", "sessions", slashEscapedCwd(cwd), `${id}.jsonl`), [ + writeJsonl(path.join(homeDir, ".factory", "sessions", droidProjectSlugForCwd(cwd), `${id}.jsonl`), [ { type: "session_start", id, title: "New Session", cwd }, { type: "message", message: { role: "assistant", content: "untimestamped setup" } }, { type: "message", timestamp: "2026-07-06T10:00:00.000Z", message: { role: "user", content: [{ type: "text", text: "Factory task title" }] } }, @@ -1105,12 +1107,12 @@ describe("external session provider discovery", () => { const cwd = path.join(root, "droid-dupe-repo"); const nestedCwd = path.join(cwd, "apps"); const id = "77777777-7777-4777-8777-777777777777"; - writeJsonl(path.join(homeDir, ".factory", "sessions", slashEscapedCwd(cwd), `${id}.jsonl`), [ + writeJsonl(path.join(homeDir, ".factory", "sessions", droidProjectSlugForCwd(cwd), `${id}.jsonl`), [ { type: "session_start", id, title: "Droid duplicate", cwd }, { type: "message", timestamp: "2026-07-06T10:00:00.000Z", message: { role: "user", content: [{ type: "text", text: "First prompt" }] } }, { type: "message", timestamp: "2026-07-06T10:01:00.000Z", message: { role: "user", content: [{ type: "text", text: "Second prompt" }] } }, ]); - writeJsonl(path.join(homeDir, ".factory", "sessions", slashEscapedCwd(nestedCwd), `${id}.jsonl`), [ + writeJsonl(path.join(homeDir, ".factory", "sessions", droidProjectSlugForCwd(nestedCwd), `${id}.jsonl`), [ { type: "session_start", id, title: "Droid duplicate", cwd: nestedCwd }, ]); @@ -1125,7 +1127,7 @@ describe("external session provider discovery", () => { const cwd = path.join(root, "droid-scoped-repo"); const elsewhere = path.join(root, "droid-elsewhere"); const inProjectId = "88888888-8888-4888-8888-888888888888"; - const inProjectPath = path.join(homeDir, ".factory", "sessions", slashEscapedCwd(cwd), `${inProjectId}.jsonl`); + const inProjectPath = path.join(homeDir, ".factory", "sessions", droidProjectSlugForCwd(cwd), `${inProjectId}.jsonl`); writeJsonl(inProjectPath, [ { type: "session_start", id: inProjectId, title: "In project", cwd }, { type: "message", timestamp: "2026-07-01T10:00:00.000Z", message: { role: "user", content: [{ type: "text", text: "In project prompt" }] } }, @@ -1134,7 +1136,7 @@ describe("external session provider discovery", () => { // Newer sessions elsewhere: one batch under an escaped path that scope rules // out outright, one under a name that only the session's own cwd can place. for (let index = 0; index < 3; index += 1) { - for (const directory of [slashEscapedCwd(elsewhere), "relative-elsewhere"]) { + for (const directory of [droidProjectSlugForCwd(elsewhere), "relative-elsewhere"]) { const id = `9999999${directory.length % 10}-9999-4999-8999-99999999999${index}`; const filePath = path.join(homeDir, ".factory", "sessions", directory, `${id}.jsonl`); writeJsonl(filePath, [ @@ -1154,7 +1156,9 @@ describe("external session provider discovery", () => { const homeDir = path.join(root, "home"); const cwd = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "adecursordupe"))); expect(cwd.includes("-"), "temp cwd must be dash-free for the cursor slug round-trip").toBe(false); - const slug = cwd.replace(/^\/+/u, "").replace(/\//gu, "-"); + // Cursor's own slug rule. A hand-rolled POSIX swap leaves the drive letter + // and backslashes intact on Windows, so the bucket never matches. + const slug = cursorProjectSlug(cwd); const olderId = "aaaaaaaa-1111-4111-8111-111111111111"; const newerId = "bbbbbbbb-2222-4222-8222-222222222222"; @@ -1248,7 +1252,7 @@ describe("external session provider discovery", () => { const homeDir = path.join(root, "home"); const repoRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "adecursorscope"))); expect(repoRoot.includes("-"), "temp cwd must be dash-free for the cursor slug round-trip").toBe(false); - const slug = repoRoot.replace(/^\/+/u, "").replace(/\//gu, "-"); + const slug = cursorProjectSlug(repoRoot); const inProjectId = "ffffffff-6666-4666-8666-666666666666"; writeJsonl(cursorTranscriptPath(homeDir, slug, inProjectId), [ { cwd: repoRoot, role: "user", message: { content: [{ type: "text", text: "In project prompt" }] } }, @@ -1271,7 +1275,27 @@ describe("external session provider discovery", () => { fs.rmSync(repoRoot, { recursive: true, force: true }); }); + it("keeps Droid sessions in scope when the project directory uses the CLI's own slug", async () => { + // droid names the per-project directory with sanitizePathToDirectoryName(), + // which drops the drive colon on Windows ("-C-Users-dev-ADE"). A plain + // separator swap produced "C:-Users-dev-ADE" and filtered every directory + // out before any session was read. + const homeDir = path.join(root, "home-scoped"); + const cwd = path.join(root, "droid-scoped-repo"); + fs.mkdirSync(cwd, { recursive: true }); + const id = "55555555-5555-4555-8555-555555555555"; + writeJsonl(path.join(homeDir, ".factory", "sessions", droidProjectSlugForCwd(cwd), `${id}.jsonl`), [ + { type: "session_start", id, cwd, timestamp: "2026-07-06T10:00:00.000Z" }, + { type: "message", timestamp: "2026-07-06T10:00:01.000Z", message: { role: "user", content: "scoped droid task" } }, + ]); + + const sessions = await discoverDroidSessions({ homeDir, scopeRoots: [cwd], limit: 10 }); + + expect(sessions.map((session) => session.id)).toEqual([id]); + }); + it("uses the OpenCode CLI list command and reports an uninstalled CLI", async () => { + const homeDir = path.join(root, "home"); const cwd = path.join(root, "opencode-repo"); fs.mkdirSync(cwd, { recursive: true }); @@ -1286,13 +1310,28 @@ describe("external session provider discovery", () => { const binDir = path.join(root, "bin"); fs.mkdirSync(binDir, { recursive: true }); - const scriptPath = path.join(binDir, "opencode"); - fs.writeFileSync( - scriptPath, - `#!/bin/sh\nprintf '%s\\n' '[{"id":"open-1","directory":"${cwd}","title":"OpenCode task","created":1783332000000,"updated":1783332060000},{"id":"open-2","directory":"${cwd}","title":"New session - 2026-05-01T17:02:11.923Z","created":1783331000000,"updated":1783331060000},{"id":"open-3","title":"Missing cwd","created":1783330000000,"updated":1783330060000}]'\n`, - "utf8", - ); - fs.chmodSync(scriptPath, 0o755); + // JSON.stringify rather than interpolation: a Windows cwd carries + // backslashes, which are escape sequences inside a JSON string literal. + const listPayload = JSON.stringify([ + { id: "open-1", directory: cwd, title: "OpenCode task", created: 1783332000000, updated: 1783332060000 }, + { id: "open-2", directory: cwd, title: "New session - 2026-05-01T17:02:11.923Z", created: 1783331000000, updated: 1783331060000 }, + { id: "open-3", title: "Missing cwd", created: 1783330000000, updated: 1783330060000 }, + ]); + // Route the payload through node so one file serves both platforms and only + // the shim differs. An extension-less #!/bin/sh file is never executable on + // Windows -- PATHEXT decides that, and chmod is a no-op there -- so a real + // `npm i -g` install always leaves a .cmd shim instead. + const payloadPath = path.join(binDir, "opencode-payload.cjs"); + fs.writeFileSync(payloadPath, `process.stdout.write(${JSON.stringify(listPayload)} + "\\n");\n`, "utf8"); + const scriptPath = process.platform === "win32" + ? path.join(binDir, "opencode.cmd") + : path.join(binDir, "opencode"); + if (process.platform === "win32") { + fs.writeFileSync(scriptPath, `@echo off\r\nnode "%~dp0opencode-payload.cjs"\r\n`, "utf8"); + } else { + fs.writeFileSync(scriptPath, `#!/bin/sh\nexec node "$(dirname "$0")/opencode-payload.cjs"\n`, "utf8"); + fs.chmodSync(scriptPath, 0o755); + } process.env.PATH = `${binDir}${path.delimiter}${previousPath ?? ""}`; clearOpenCodeBinaryCache(); diff --git a/apps/desktop/src/main/services/externalSessions/discoveryUtils.test.ts b/apps/desktop/src/main/services/externalSessions/discoveryUtils.test.ts index 7cd10201d..202519242 100644 --- a/apps/desktop/src/main/services/externalSessions/discoveryUtils.test.ts +++ b/apps/desktop/src/main/services/externalSessions/discoveryUtils.test.ts @@ -11,7 +11,6 @@ import { firstUserTextFromRecords, recentExternalSessionMessagesFromRecords, resolveCursorCwdFromSlug, - slashEscapedCwd, } from "./discoveryUtils"; describe("firstUserTextFromRecords", () => { @@ -88,7 +87,6 @@ describe("external session user text", () => { )).toBe("Create + {machine.customName ? ( + + ) : null} + + + ) : ( + <> + + {accountMachineDisplayName(machine) ?? "Unnamed computer"} + + {thisMac ? ( + + {THIS_MACHINE_NAME} + + ) : null} + + )} {rightText ? ( ) : null} - {thisMac ? ( + {renaming ? ( ) : ( + {/* + Removal stays withheld for the local machine. Signing this + computer out of the account from this computer is what the + sign-out card is for; "remove" here means "evict some other + machine", and pointing it at yourself would be a different + and far more destructive action wearing the same label. + */} + {!isThisMac(openMenuMachine) ? ( + + ) : null} , document.body, ) : null} + {/* + Removal is only reachable from a row's options menu, and that menu is + withheld for the local machine — so this sheet always names some OTHER + machine. It must never borrow THIS_MACHINE_NAME, and it can't assume the + machine on the far end runs macOS. + */} {pendingRemoval ? ( void; signingOut: boolean }) { @@ -801,7 +974,7 @@ function SignOutCard({ onSignOut, signingOut }: { onSignOut: () => void; signing >
-
Signed in on this Mac
+
Signed in on this computer
) : null} diff --git a/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx b/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx index 7923bb419..212c241d5 100644 --- a/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx +++ b/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx @@ -65,7 +65,7 @@ import { WorkspacePicker } from "./WorkspacePicker"; import { CreatePromptModal, SearchOverlay } from "./overlays"; import { setPendingReveal } from "./pendingReveals"; import { COLORS } from "../../lanes/laneDesignTokens"; -import { modifierKeyLabel } from "../../../lib/platform"; +import { modifierKeyLabel, revealLabel } from "../../../lib/platform"; import type { EditorThemeMode } from "./viewers/types"; import { joinDisplayPath } from "./pathDisplay"; @@ -1301,7 +1301,7 @@ export function FilesWorkbench({ items.push({ type: "item", label: "Copy Name", icon: , onClick: () => void window.ade.app.writeClipboardText?.(name) }); items.push({ type: "item", - label: "Reveal in Finder", + label: revealLabel, icon: , onClick: () => void window.ade.app.openPathInEditor?.({ rootPath, relativePath: path, target: "finder" }).catch(() => {}), disabled: !canRevealInFinder, diff --git a/apps/desktop/src/renderer/components/files/v2/viewers/MediaViewer.tsx b/apps/desktop/src/renderer/components/files/v2/viewers/MediaViewer.tsx index 46191bdd2..be59703b9 100644 --- a/apps/desktop/src/renderer/components/files/v2/viewers/MediaViewer.tsx +++ b/apps/desktop/src/renderer/components/files/v2/viewers/MediaViewer.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from "react"; import { ArrowSquareOut, MusicNotes, VideoCamera } from "@phosphor-icons/react"; import { COLORS } from "../../../lanes/laneDesignTokens"; +import { revealLabel } from "../../../../lib/platform"; import { streamFileBytes } from "../streamBytes"; import type { ViewerProps } from "./types"; @@ -57,7 +58,7 @@ export function MediaViewer({ workspaceId, rootPath, tab, content, kind }: Viewe {mimeType} {formatBytes(content.size)} - diff --git a/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.test.tsx b/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.test.tsx index f288978ab..3dc42dd0b 100644 --- a/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.test.tsx +++ b/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.test.tsx @@ -263,7 +263,7 @@ function machine(overrides: Partial & { id: string; name: str }; } -const thisMac = machine({ id: THIS_MACHINE_ID, name: "This Mac", isBound: true }); +const thisMac = machine({ id: THIS_MACHINE_ID, name: "This computer", isBound: true }); const studio = machine({ id: "studio", name: "MacBook Pro (97)" }); describe("CreateLaneDialog machine selection", () => { diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx index 802c67a65..cfcc0b05d 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx @@ -240,7 +240,7 @@ function useLaneGitActionRuntimeState(scopeKey: string | null): LaneGitActionRun // without a translation step. Machines are named absolutely — never "remote". // Imported, not re-typed. The previous hardcoded literals were kept in sync // with laneMachines.ts by a comment; the guard compares machine ids, so a drift -// here makes it warn that This Mac diverged from itself. +// here makes it warn that This computer diverged from itself. import { THIS_MACHINE_ID as THIS_MACHINE_GUARD_ID, THIS_MACHINE_NAME as THIS_MACHINE_GUARD_NAME, diff --git a/apps/desktop/src/renderer/components/lanes/LaneMachineSelector.test.tsx b/apps/desktop/src/renderer/components/lanes/LaneMachineSelector.test.tsx index 1f6e8ea70..b63ce1d57 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneMachineSelector.test.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneMachineSelector.test.tsx @@ -21,7 +21,7 @@ function machine(overrides: Partial & { id: string; name: str } const machines: LaneMachineOption[] = [ - machine({ id: THIS_MACHINE_ID, name: "This Mac", isBound: true, freeBytes: 412 * 1024 ** 3 }), + machine({ id: THIS_MACHINE_ID, name: "This computer", isBound: true, freeBytes: 412 * 1024 ** 3 }), machine({ id: "studio", name: "MacBook Pro (97)", freeBytes: 4 * 1024 ** 3 }), ]; @@ -36,7 +36,7 @@ describe("LaneMachineSelector", () => { ); expect(screen.getByText("Create on")).toBeTruthy(); - expect(screen.getByText("This Mac")).toBeTruthy(); + expect(screen.getByText("This computer")).toBeTruthy(); expect(screen.getByText("MacBook Pro (97)")).toBeTruthy(); const group = screen.getByRole("radiogroup", { name: "Machine for this lane" }); expect(group.textContent?.toLowerCase()).not.toContain("remote"); diff --git a/apps/desktop/src/renderer/components/lanes/LaneMachineSelector.tsx b/apps/desktop/src/renderer/components/lanes/LaneMachineSelector.tsx index bf4fd1db3..a6207db04 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneMachineSelector.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneMachineSelector.tsx @@ -15,7 +15,7 @@ import { * * Deliberately does NOT reuse the dialog's "Remote"/"Local" vocabulary: that * pair already means the git base-branch source a few rows below. Machines are - * named absolutely instead ("This Mac", "MacBook Pro (97)"). + * named absolutely instead ("This computer", "MacBook Pro (97)"). */ export function LaneMachineSelector({ machines, diff --git a/apps/desktop/src/renderer/components/lanes/PushDivergenceGuard.test.tsx b/apps/desktop/src/renderer/components/lanes/PushDivergenceGuard.test.tsx index 832390ce2..bd3f5341b 100644 --- a/apps/desktop/src/renderer/components/lanes/PushDivergenceGuard.test.tsx +++ b/apps/desktop/src/renderer/components/lanes/PushDivergenceGuard.test.tsx @@ -257,7 +257,7 @@ describe("LaneGitActionsPane push divergence guard", () => { it("never warns about this machine's own entry", async () => { renderPane({ otherMachineBranchStates: [ - otherMachine({ machineId: "this-mac", machineName: "This Mac", headSha: null, ahead: 9 }), + otherMachine({ machineId: "this-mac", machineName: "This computer", headSha: null, ahead: 9 }), ], }); await clickPush(); diff --git a/apps/desktop/src/renderer/components/lanes/laneMachines.test.ts b/apps/desktop/src/renderer/components/lanes/laneMachines.test.ts index d6f214ebe..8ac319523 100644 --- a/apps/desktop/src/renderer/components/lanes/laneMachines.test.ts +++ b/apps/desktop/src/renderer/components/lanes/laneMachines.test.ts @@ -156,11 +156,11 @@ describe("deriveLaneMachineOptions", () => { expect(studio?.repoMatch).toBe("matched"); expect(studio?.project?.rootPath).toBe("/Users/x/ADE"); expect(defaultLaneMachineId(options)).toBe("studio"); - // This Mac is no longer the bound machine, and nothing proves the repo is here. + // This computer is no longer the bound machine, and nothing proves the repo is here. expect(options[0]?.repoMatch).toBe("unknown"); }); - it("resolves this Mac's checkout from open local project tabs", () => { + it("resolves this computer's checkout from open local project tabs", () => { const options = deriveLaneMachineOptions({ connections: [connection({ id: "studio" })], boundTargetId: "studio", @@ -263,7 +263,7 @@ describe("deriveLaneMachineOptions", () => { expect(studio?.repoMatch).not.toBe("matched"); }); - it("says the repo is missing from this Mac only when the local tabs are known", () => { + it("says the repo is missing from this computer only when the local tabs are known", () => { const withTabs = deriveLaneMachineOptions({ connections: [connection({ id: "studio" })], boundTargetId: "studio", diff --git a/apps/desktop/src/renderer/components/lanes/laneMachines.ts b/apps/desktop/src/renderer/components/lanes/laneMachines.ts index 65bfa0469..e721cd336 100644 --- a/apps/desktop/src/renderer/components/lanes/laneMachines.ts +++ b/apps/desktop/src/renderer/components/lanes/laneMachines.ts @@ -21,7 +21,7 @@ import type { RecentProjectSummary, RemoteRuntimeConnectionStatus } from "../../ // Machine identity is shared, not per-module: five copies of these constants // with two different id values is what made the divergence guard able to warn -// that This Mac diverged from itself. Re-exported here for existing callers. +// that This computer diverged from itself. Re-exported here for existing callers. import { THIS_MACHINE_ID, THIS_MACHINE_NAME } from "../../../shared/machineIdentity"; export { THIS_MACHINE_ID, THIS_MACHINE_NAME }; @@ -73,7 +73,7 @@ export type LaneMachineOption = { export type LaneMachineDerivationInput = { /** Connections from the remote-runtime snapshot; only `connected` are listed. */ connections: readonly RemoteRuntimeConnectionStatus[]; - /** Target id of the machine the active project is bound to; null = this Mac. */ + /** Target id of the machine the active project is bound to; null = this computer. */ boundTargetId: string | null; /** The bound machine's checkout of this repo, from the active project binding. */ boundProject?: LaneMachineProjectRef | null; @@ -85,11 +85,11 @@ export type LaneMachineDerivationInput = { localProjectRoots?: readonly string[]; /** * Known local projects, including unopened recents. Their git origins let a - * remote-bound tab address the matching checkout on This Mac without forcing + * remote-bound tab address the matching checkout on This computer without forcing * the user to open it once just to establish identity. */ localProjects?: readonly RecentProjectSummary[]; - /** Free disk headroom on this Mac, when a caller already has it. */ + /** Free disk headroom on this computer, when a caller already has it. */ thisMachineFreeBytes?: number | null; }; @@ -203,7 +203,7 @@ function repoMatchFor( } /** - * This Mac. Its checkout can't be matched by git origin — local projects aren't + * This computer. Its checkout can't be matched by git origin — local projects aren't * in the connection snapshot — so it matches on the repo folder name against * the project tabs already open in this window. */ @@ -247,7 +247,7 @@ function thisMachineOption(input: LaneMachineDerivationInput): LaneMachineOption }; } } - // We can only claim the repo is absent from this Mac when we know what we're + // We can only claim the repo is absent from this computer when we know what we're // looking for and have the local project list to look in. const canProveAbsence = !isBound && ( repoIdentity @@ -272,7 +272,7 @@ function thisMachineOption(input: LaneMachineDerivationInput): LaneMachineOption } /** - * Connected machines a lane can be created on, this Mac first. Machines that + * Connected machines a lane can be created on, this computer first. Machines that * are pairing, erroring, or idle are omitted entirely — you can only create a * lane on a machine ADE is talking to right now. */ diff --git a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts index 11ccec5cf..8ce1897df 100644 --- a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts +++ b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts @@ -676,7 +676,7 @@ export function useLaneWorkSessions(laneId: string | null) { // defaults from the other fields (which used to override the caller's // intent — e.g. a custom startupCommand silently displaced by default // command/args). - const launchFields = resolveLaunchFields({ + const launchFields = args.runtimeCliLaunch ? {} : resolveLaunchFields({ profile: args.profile, ...(args.permissionMode !== undefined ? { permissionMode: args.permissionMode } : {}), ...(args.orchestrationRole !== undefined ? { orchestrationRole: args.orchestrationRole } : {}), @@ -698,6 +698,7 @@ export function useLaneWorkSessions(laneId: string | null) { ...(launchFields.initialInput !== undefined ? { initialInput: launchFields.initialInput } : {}), ...(launchFields.initialInputDelayMs !== undefined ? { initialInputDelayMs: launchFields.initialInputDelayMs } : {}), ...(args.linearIssues?.length ? { linearIssues: args.linearIssues } : {}), + ...(args.runtimeCliLaunch ? { runtimeCliLaunch: args.runtimeCliLaunch } : {}), ...launchFields, }; const result = args.pin diff --git a/apps/desktop/src/renderer/components/onboarding/AiRuntimesBand.test.tsx b/apps/desktop/src/renderer/components/onboarding/AiRuntimesBand.test.tsx new file mode 100644 index 000000000..021ed1872 --- /dev/null +++ b/apps/desktop/src/renderer/components/onboarding/AiRuntimesBand.test.tsx @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { availableRuntimes, cursorInstallCommand } from "./AiRuntimesBand"; + +describe("cursorInstallCommand", () => { + it("uses Cursor's PowerShell installer on Windows", () => { + const command = cursorInstallCommand("win32"); + expect(command).toContain("powershell.exe"); + expect(command).toContain("cursor.com/install?win32=true"); + expect(command).not.toContain("curl"); + expect(command).not.toContain("mkdir -p"); + expect(command).not.toContain("$HOME"); + }); + + it("keeps the documented POSIX one-liner elsewhere", () => { + for (const platform of ["darwin", "linux"] as const) { + const command = cursorInstallCommand(platform); + expect(command).toContain("curl https://cursor.com/install -fsS | bash"); + expect(command).not.toContain("powershell"); + } + }); +}); + +// Onboarding must not offer a runtime that cannot be installed usefully: +// @cursor/sdk has no win32-arm64 build. See shared/providerPlatformSupport.ts. +describe("availableRuntimes", () => { + function setRuntimeTarget(platform: string, arch: string) { + (globalThis as { window?: unknown }).window = { + ade: { app: { runtimeTarget: { platform, arch } } }, + }; + } + + afterEach(() => { + delete (globalThis as { window?: unknown }).window; + }); + + it("drops Cursor on Windows on ARM and keeps every other runtime", () => { + setRuntimeTarget("win32", "arm64"); + const ids = availableRuntimes().map((rt) => rt.id); + expect(ids).not.toContain("cursor"); + expect(ids).toEqual(["claude", "codex", "droid", "opencode"]); + }); + + it("keeps Cursor on Windows x64 and on macOS", () => { + for (const [platform, arch] of [["win32", "x64"], ["darwin", "arm64"], ["darwin", "x64"]] as const) { + setRuntimeTarget(platform, arch); + const ids = availableRuntimes().map((rt) => rt.id); + expect(ids, `${platform}-${arch}`).toEqual(["claude", "codex", "cursor", "droid", "opencode"]); + } + }); +}); diff --git a/apps/desktop/src/renderer/components/onboarding/AiRuntimesBand.tsx b/apps/desktop/src/renderer/components/onboarding/AiRuntimesBand.tsx index c02366e45..26b7e793e 100644 --- a/apps/desktop/src/renderer/components/onboarding/AiRuntimesBand.tsx +++ b/apps/desktop/src/renderer/components/onboarding/AiRuntimesBand.tsx @@ -12,6 +12,7 @@ import { COLORS, SANS_FONT, MONO_FONT } from "../lanes/laneDesignTokens"; import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; import { deriveConfiguredModelIds } from "../../lib/modelOptions"; import { openExternalUrl } from "../../lib/openExternal"; +import { cursorProviderAvailable, rendererPlatformAttribute } from "../../lib/platform"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { docs } from "../../onboarding/docsLinks"; import { InputPopover } from "./InputPopover"; @@ -35,14 +36,44 @@ type RuntimeMeta = { authCommand?: string; }; +// Factory publishes a PowerShell installer for its native Windows build; the +// POSIX shell pipeline is not runnable there. The command shown here is the one +// the user is expected to paste into their own shell. +// https://docs.factory.ai/cli/getting-started/quickstart +const DROID_INSTALL_COMMAND = rendererPlatformAttribute() === "win32" + ? "irm https://app.factory.ai/cli/windows | iex" + : "curl -fsSL https://app.factory.ai/cli | sh"; + +/** + * Cursor ships a PowerShell installer for native Windows; the `curl … | bash` + * one-liner is documented for macOS, Linux and WSL only. Keep this in step with + * `cursorInstallCommand()` in apps/ade-cli/src/services/agentRegistry.ts. + */ +export function cursorInstallCommand(platform = rendererPlatformAttribute()): string { + if (platform === "win32") { + return `powershell.exe -NoProfile -Command "irm 'https://cursor.com/install?win32=true' | iex"`; + } + return 'mkdir -p "$HOME/.local/bin" && curl https://cursor.com/install -fsS | bash'; +} + const RUNTIMES: RuntimeMeta[] = [ { id: "claude", label: "Claude Code", brand: BRAND.claude, Logo: ClaudeLogo, docsUrl: docs.multiAgentSetup, installCommand: "npm install -g @anthropic-ai/claude-code", authCommand: "claude /login" }, { id: "codex", label: "Codex", brand: BRAND.codex, Logo: CodexLogo, docsUrl: docs.multiAgentSetup, installCommand: "npm install -g @openai/codex", authCommand: "codex login" }, - { id: "cursor", label: "Cursor", brand: BRAND.cursor, Logo: CursorAgentLogo, docsUrl: docs.multiAgentSetup, installCommand: 'mkdir -p "$HOME/.local/bin" && curl https://cursor.com/install -fsS | bash' }, - { id: "droid", label: "Factory Droid", brand: BRAND.droid, Logo: DroidLogo, docsUrl: "https://docs.factory.ai/cli/getting-started/quickstart", authCommand: "droid login" }, + { id: "cursor", label: "Cursor", brand: BRAND.cursor, Logo: CursorAgentLogo, docsUrl: docs.multiAgentSetup, installCommand: cursorInstallCommand() }, + { id: "droid", label: "Factory Droid", brand: BRAND.droid, Logo: DroidLogo, docsUrl: "https://docs.factory.ai/cli/getting-started/quickstart", installCommand: DROID_INSTALL_COMMAND, authCommand: "droid login" }, { id: "opencode", label: "OpenCode", brand: BRAND.opencode, Logo: OpenCodeLogo, docsUrl: docs.multiAgentSetup }, ]; +/** + * Runtimes offered on this machine. Cursor drops out on Windows on ARM because + * `@cursor/sdk` has no win32-arm64 build, so onboarding must not ask the user to + * install something that cannot run. See shared/providerPlatformSupport.ts. + */ +export function availableRuntimes(): RuntimeMeta[] { + if (cursorProviderAvailable()) return RUNTIMES; + return RUNTIMES.filter((rt) => rt.id !== "cursor"); +} + const FEATURES: Array<{ key: FeatureKey; label: string }> = [ { key: "terminal_summaries", label: "Terminal summaries" }, { key: "pr_descriptions", label: "PR descriptions" }, @@ -73,12 +104,14 @@ export function AiRuntimesBand() { useEffect(() => { void refresh(); }, [refresh]); + const runtimes = useMemo(() => availableRuntimes(), []); + const readyCount = useMemo(() => { if (!status) return 0; let n = 0; if (status.availableProviders.claude.binary.present && status.availableProviders.claude.auth.ready) n++; if (status.providerConnections?.codex?.runtimeAvailable) n++; - if (status.providerConnections?.cursor?.runtimeAvailable) n++; + if (cursorProviderAvailable() && status.providerConnections?.cursor?.runtimeAvailable) n++; if (status.providerConnections?.droid?.runtimeAvailable) n++; if (status.opencodeBinaryInstalled !== false) n++; return n; @@ -176,14 +209,14 @@ export function AiRuntimesBand() {
AI runtimes - {loading ? "Checking…" : `${readyCount} of ${RUNTIMES.length} ready`} + {loading ? "Checking…" : `${readyCount} of ${runtimes.length} ready`}
void refresh(true)} />
- {RUNTIMES.map((rt) => ( + {runtimes.map((rt) => ( ))}
diff --git a/apps/desktop/src/renderer/components/onboarding/LaunchGate.test.tsx b/apps/desktop/src/renderer/components/onboarding/LaunchGate.test.tsx index a14256bc2..6b9daad3c 100644 --- a/apps/desktop/src/renderer/components/onboarding/LaunchGate.test.tsx +++ b/apps/desktop/src/renderer/components/onboarding/LaunchGate.test.tsx @@ -73,7 +73,7 @@ describe("LaunchGate", () => { render(
Application
); expect(await screen.findByRole("button", { name: /continue without an account/i })).toBeTruthy(); - expect(screen.queryByText(/Use ADE on this Mac without an account/i)).toBeNull(); + expect(screen.queryByText(/Use ADE on this computer without an account/i)).toBeNull(); expect(screen.getByTestId("launch-gate-drag-region").getAttribute("data-app-region")).toBe("drag"); expect(screen.queryByText("Application")).toBeNull(); await waitFor(() => { diff --git a/apps/desktop/src/renderer/components/personalChats/PersonalChatsPage.test.tsx b/apps/desktop/src/renderer/components/personalChats/PersonalChatsPage.test.tsx index a21767475..293fdda43 100644 --- a/apps/desktop/src/renderer/components/personalChats/PersonalChatsPage.test.tsx +++ b/apps/desktop/src/renderer/components/personalChats/PersonalChatsPage.test.tsx @@ -771,7 +771,7 @@ describe("PersonalChatsPage", () => { expect(screen.getByRole("menuitem", { name: /MacBook Pro \(97\)/ })).toBeTruthy(); }); - it("names the bound machine when the window runs on another Mac", async () => { + it("names the bound machine when the window runs on another computer", async () => { storeState.projectBinding = { kind: "remote", key: "remote:target-1:project-1", diff --git a/apps/desktop/src/renderer/components/personalChats/PersonalChatsPage.tsx b/apps/desktop/src/renderer/components/personalChats/PersonalChatsPage.tsx index 2a5b7339f..34c899366 100644 --- a/apps/desktop/src/renderer/components/personalChats/PersonalChatsPage.tsx +++ b/apps/desktop/src/renderer/components/personalChats/PersonalChatsPage.tsx @@ -688,7 +688,7 @@ export function PersonalChatsPage({ standalone = false }: { standalone?: boolean return; } if (nextMachineId === LOCAL_MACHINE_ID) { - // The machine is a dimension of THIS repo's tab, so "This Mac" must + // The machine is a dimension of THIS repo's tab, so "This computer" must // resolve to this repo's local checkout — never to whichever local tab // happens to be first. void switchToThisMachineProject({ diff --git a/apps/desktop/src/renderer/components/prs/shared/PrMarkdownEditor.tsx b/apps/desktop/src/renderer/components/prs/shared/PrMarkdownEditor.tsx index 161c29226..77f60b4a7 100644 --- a/apps/desktop/src/renderer/components/prs/shared/PrMarkdownEditor.tsx +++ b/apps/desktop/src/renderer/components/prs/shared/PrMarkdownEditor.tsx @@ -12,6 +12,7 @@ import { } from "@phosphor-icons/react"; import { COLORS, MONO_FONT, SANS_FONT } from "../../lanes/laneDesignTokens"; +import { isMac } from "../../../lib/platform"; import { PrMarkdown } from "./PrMarkdown"; type EditorMode = "write" | "preview"; @@ -103,10 +104,12 @@ export function applyAction(textarea: HTMLTextAreaElement, action: ToolbarAction } } +const shortcutModifier = isMac ? "⌘" : "Ctrl+"; + const TOOLBAR: Array<{ action: ToolbarAction; icon: typeof TextB; title: string }> = [ { action: "heading", icon: TextHOne, title: "Heading" }, - { action: "bold", icon: TextB, title: "Bold (⌘B)" }, - { action: "italic", icon: TextItalic, title: "Italic (⌘I)" }, + { action: "bold", icon: TextB, title: `Bold (${shortcutModifier}B)` }, + { action: "italic", icon: TextItalic, title: `Italic (${shortcutModifier}I)` }, { action: "quote", icon: Quotes, title: "Quote" }, { action: "code", icon: Code, title: "Inline code" }, { action: "codeblock", icon: CodeBlock, title: "Code block" }, diff --git a/apps/desktop/src/renderer/components/remoteTargets/AccountMachineRow.tsx b/apps/desktop/src/renderer/components/remoteTargets/AccountMachineRow.tsx index e7da8f928..89ebf3020 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/AccountMachineRow.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/AccountMachineRow.tsx @@ -48,10 +48,10 @@ function accountMachineStatusLabel( connectionState: ReturnType, ): string { if (connectionState === "unreachable") { - return "Can't reach this Mac right now — make sure it's online and up to date."; + return "Can't reach this computer right now — make sure it's online and up to date."; } if (machine.online) return "Ready to connect"; - return `${relativeLastSeen(machine.lastSeenAt)} · Open ADE on that Mac`; + return `${relativeLastSeen(machine.lastSeenAt)} · Open ADE on that computer`; } export function AccountMachineRow({ @@ -95,7 +95,7 @@ export function AccountMachineRow({ setRenaming(false); onRenamed?.(); } catch (error) { - setRenameError(error instanceof Error ? error.message : "Couldn't rename this Mac."); + setRenameError(error instanceof Error ? error.message : "Couldn't rename this computer."); } finally { setRenameBusy(false); } @@ -275,12 +275,12 @@ export function AccountMachineRow({ {canExplain && detailOpen ? (
- {needsSetup ? "Finish setup on the other Mac" : relativeLastSeen(machine.lastSeenAt)} + {needsSetup ? "Finish setup on the other computer" : relativeLastSeen(machine.lastSeenAt)}
{needsSetup - ? "On that Mac, open ADE and sign in to this same ADE account. Once it's online and up to date, it appears here automatically." - : "This Mac hasn't checked in recently. Open ADE on it, then try again."} + ? "On that computer, open ADE and sign in to this same ADE account. Once it's online and up to date, it appears here automatically." + : "This computer hasn't checked in recently. Open ADE on it, then try again."}
{!needsSetup && machine.reachableEndpoints.length > 0 ? (
diff --git a/apps/desktop/src/renderer/components/remoteTargets/PairMachineForm.tsx b/apps/desktop/src/renderer/components/remoteTargets/PairMachineForm.tsx index 546df692e..08e7d3174 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/PairMachineForm.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/PairMachineForm.tsx @@ -30,10 +30,10 @@ function friendlyPairError(error: unknown): string { .replace(/^Error:\s*/i, "") .trim(); if (/pin|unauthor|forbidden|401|403|invalid code/i.test(message)) { - return "That code didn't work. Check the six digits shown on the other Mac and try again."; + return "That code didn't work. Check the six digits shown on the other computer and try again."; } if (/unreachable|timed out|timeout|ECONN|ENOTFOUND|network|connect|offline/i.test(message)) { - return "Couldn't reach that Mac. Make sure ADE is open there, then try again."; + return "Couldn't reach that computer. Make sure ADE is open there, then try again."; } return message || "Pairing failed."; } @@ -50,7 +50,7 @@ type PairMachineFormProps = { /** * First-time pairing with a nearby Mac: ADE discovered it on the network and * synthesized its pairing URL internally, so the user only confirms the machine - * and types the 6-digit code shown in ADE on that Mac. There is no manual link + * and types the 6-digit code shown in ADE on that computer. There is no manual link * or address entry — nearby discovery is the only entry point. */ export function PairMachineForm({ @@ -64,7 +64,7 @@ export function PairMachineForm({ const [parsing, setParsing] = useState(false); const [pin, setPin] = useState(""); const [error, setError] = useState(null); - const deviceName = defaultDeviceName.trim() || "This Mac"; + const deviceName = defaultDeviceName.trim() || "This computer"; const trimmedInput = initialInput?.trim() ?? ""; @@ -128,10 +128,10 @@ export function PairMachineForm({ return (
- You haven't connected to this Mac before. Enter the pairing code shown in ADE on that Mac. + You haven't connected to this computer before. Enter the pairing code shown in ADE on that computer.
- Nearby Mac + Nearby computer {parsing ? ( Checking… @@ -172,7 +172,7 @@ export function PairMachineForm({ />
- This confirms that you can see the code on the other Mac. + This confirms that you can see the code on the other computer.
diff --git a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetForm.tsx b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetForm.tsx index a18f0e75b..fee6efc4c 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetForm.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetForm.tsx @@ -137,7 +137,7 @@ export function RemoteTargetForm({ setName(event.target.value)} - placeholder="Mac Studio" + placeholder="Development computer" style={fieldStyle} disabled={busy} /> diff --git a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx index 5e178bbf4..a182a85a4 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx @@ -59,7 +59,7 @@ function installAdeMock(): void { remoteRuntimeMock.getLocalPairingInfo.mockResolvedValue({ url: "https://ade-app.dev/pair#payload", pin: "123456", - machineName: "This Mac", + machineName: "This computer", relayAvailable: false, }); remoteRuntimeMock.runDoctor.mockResolvedValue({ checks: [] }); @@ -107,7 +107,7 @@ function getAccountRow(name: string): HTMLElement { return row; } -function openAddMode(label: "Find nearby Macs" | "Add over SSH"): void { +function openAddMode(label: "Find nearby computers" | "Add over SSH"): void { fireEvent.click(screen.getByRole("button", { name: "Add machine" })); fireEvent.click(screen.getByRole("button", { name: new RegExp(`^${label}`) })); } @@ -186,7 +186,7 @@ describe("RemoteTargetList", () => { render(); - openAddMode("Find nearby Macs"); + openAddMode("Find nearby computers"); await waitFor(() => expect(screen.getByText("Studio")).toBeTruthy()); expect(screen.getByText("Found nearby")).toBeTruthy(); @@ -202,7 +202,7 @@ describe("RemoteTargetList", () => { await waitFor(() => expect(remoteRuntimeMock.pairWithMachine).toHaveBeenCalledWith({ input: expect.stringMatching(/^https:\/\/ade-app\.dev\/pair#/), pin: "654321", - deviceName: "This Mac", + deviceName: "This computer", })); await waitFor(() => expect(remoteRuntimeMock.connect).toHaveBeenCalledWith("target-1")); expect(remoteRuntimeMock.saveTarget).not.toHaveBeenCalled(); @@ -237,9 +237,9 @@ describe("RemoteTargetList", () => { render(); - openAddMode("Find nearby Macs"); + openAddMode("Find nearby computers"); - await screen.findByText(/No Macs found/); + await screen.findByText(/No computers found/); expect(screen.queryByText("Linux box")).toBeNull(); expect(screen.queryByText(/SSH/i)).toBeNull(); }); @@ -981,8 +981,8 @@ describe("RemoteTargetList", () => { expect(screen.getByText("CONNECTED")).toBeTruthy(); expect(screen.getByRole("button", { name: "Disconnect" })).toBeTruthy(); - openAddMode("Find nearby Macs"); - expect(screen.getByText(/No Macs found/)).toBeTruthy(); + openAddMode("Find nearby computers"); + expect(screen.getByText(/No computers found/)).toBeTruthy(); expect(screen.queryByText("Windows PC")).toBeNull(); expect(screen.queryByText("Windows — not supported yet")).toBeNull(); }); @@ -1140,7 +1140,7 @@ describe("RemoteTargetList", () => { ), ).toBeTruthy(), ); - expect(screen.getByText("No Macs yet. Choose Add machine to connect one.")).toBeTruthy(); + expect(screen.getByText("No computers yet. Choose Add machine to connect one.")).toBeTruthy(); }); it("adopts a desktop account machine as paired-only instead of saving a broken SSH target", async () => { @@ -1485,7 +1485,7 @@ describe("RemoteTargetList", () => { expect(screen.queryByText("Connected via Tailscale · 12ms")).toBeNull(); }); - it("never lists this Mac as its own remote target (self-filter by machineKey or deviceId)", async () => { + it("never lists this computer as its own remote target (self-filter by machineKey or deviceId)", async () => { remoteRuntimeMock.listTargets.mockResolvedValue([]); remoteRuntimeMock.listDiscoveredMachines.mockResolvedValue({ machines: [], diagnostics: [] }); installAdeMock(); @@ -1504,7 +1504,7 @@ describe("RemoteTargetList", () => { { machineKey: "reinstalled-mk", deviceId: "local-dev", // matches getLocalMachineIdentity().deviceId (pre-reinstall row) - name: "This Mac Before Reinstall", + name: "This computer Before Reinstall", platform: "darwin", deviceType: "desktop", reachableEndpoints: [], @@ -1534,7 +1534,7 @@ describe("RemoteTargetList", () => { await waitFor(() => expect(screen.getByText("Other Studio")).toBeTruthy()); expect(accountMock.getLocalMachineIdentity).toHaveBeenCalled(); expect(screen.queryByText("This Very Mac")).toBeNull(); - expect(screen.queryByText("This Mac Before Reinstall")).toBeNull(); + expect(screen.queryByText("This computer Before Reinstall")).toBeNull(); }); it("explains how to finish setup when an online account Mac has no ready route", () => { @@ -1576,7 +1576,7 @@ describe("RemoteTargetList", () => { onConnect={vi.fn()} />, ); - expect(screen.getByText("Finish setup on the other Mac")).toBeTruthy(); + expect(screen.getByText("Finish setup on the other computer")).toBeTruthy(); expect(screen.getByText(/it appears here automatically/i)).toBeTruthy(); }); diff --git a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx index 091c4bc10..67144c81a 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx @@ -426,7 +426,7 @@ export function RemoteTargetList({ }; }, []); - // This Mac's route-publish health, refreshed periodically so a persisting + // This computer's route-publish health, refreshed periodically so a persisting // failure's "for N min" stays truthful while the panel is open. getInfo is a // cheap one-shot; there is no push event for the publisher's health. useEffect(() => { @@ -1023,7 +1023,7 @@ export function RemoteTargetList({ setAddMode(next); }, []); - // Signed in, account Macs appear in the list automatically, so the add sheet + // Signed in, account computers appear in the list automatically, so the add sheet // only offers Nearby + SSH. Signed out, we lead with the account sign-in. const addChoices = useMemo( () => { @@ -1039,22 +1039,22 @@ export function RemoteTargetList({ key: "signin", icon: UserCircle, label: "Sign in to ADE", - detail: "The easiest way to find and connect to your other Macs.", + detail: "The easiest way to find and connect to your other computers.", onSelect: () => onAccountRequested?.(), }); } choices.push({ key: "nearby", icon: WifiHigh, - label: "Find nearby Macs", - detail: "Search this Wi-Fi for Macs with ADE open.", + label: "Find nearby computers", + detail: "Search this Wi-Fi for computers with ADE open.", onSelect: () => chooseAddMode("nearby"), }); choices.push({ key: "ssh", icon: TerminalWindow, label: "Add over SSH (Advanced)", - detail: "Connect with the Mac's SSH address and private key.", + detail: "Connect with the computer's SSH address and private key.", onSelect: () => chooseAddMode("ssh"), }); return choices; @@ -1107,7 +1107,7 @@ export function RemoteTargetList({ > - Other devices may not reach this Mac — route publish failing for{" "} + Other devices may not reach this computer — route publish failing for{" "} {publishHealthDisplay.minutes} min
@@ -1216,7 +1216,7 @@ export function RemoteTargetList({ {loadingDiscovered ?
Scanning nearby machines…
: null} {!loadingDiscovered && nearbyMachines.length === 0 ? (
- No Macs found. Open ADE on the other Mac and make sure both are on the same Wi-Fi or Tailscale network. + No computers found. Open ADE on the other computer and make sure both are on the same Wi-Fi or Tailscale network.
) : null} {nearbyMachines.map((machine) => ( @@ -1271,14 +1271,14 @@ export function RemoteTargetList({ {accountMachinesState && accountMachinesState !== "ok" && accountMachinesState !== "signed_out" ? (
{accountMachinesState === "not_configured" - ? "Account Macs aren't available yet. Saved and nearby Macs still work." - : "We couldn't load your account Macs. Saved and nearby Macs still work."} + ? "Account computers aren't available yet. Saved and nearby computers still work." + : "We couldn't load your account computers. Saved and nearby computers still work."}
) : null} {!loading && totalRows === 0 && !addMode && !loadingDiscovered ? (
- No Macs yet. Choose Add machine to connect one. + No computers yet. Choose Add machine to connect one.
) : null} {loadingDiscovered ? ( diff --git a/apps/desktop/src/renderer/components/remoteTargets/SavedMachineRow.tsx b/apps/desktop/src/renderer/components/remoteTargets/SavedMachineRow.tsx index ac16567d5..82a2aa3e3 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/SavedMachineRow.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/SavedMachineRow.tsx @@ -239,7 +239,7 @@ export function SavedMachineRow({ ) : null}
- {target.transport === "paired" ? "Paired with this Mac" : "Saved SSH connection"} + {target.transport === "paired" ? "Paired with this computer" : "Saved SSH connection"}
{section === "unavailable" && row.unavailableReason ? ( @@ -441,7 +441,7 @@ export function SavedMachineRow({ Reconnect automatically - ADE will reconnect when the app opens. LAN and Tailscale work without signing in; ADE Relay needs the same account on both Macs. + ADE will reconnect when the app opens. LAN and Tailscale work without signing in; ADE Relay needs the same account on both computers. diff --git a/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts b/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts index a1bea14e1..cb8b42c98 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts +++ b/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts @@ -210,7 +210,7 @@ export function machineMatchesSavedTarget( } // --------------------------------------------------------------------------- -// Local "This Mac" route-publish health +// Local "This computer" route-publish health // --------------------------------------------------------------------------- /** Publish-health slice the runtime status IPC exposes for this machine. */ @@ -228,7 +228,7 @@ export type PublishHealthDisplay = export const PUBLISH_FAILING_ALARM_MS = 2 * 60_000; /** - * Non-publishing states: this Mac isn't advertising routes to the account + * Non-publishing states: this computer isn't advertising routes to the account * directory (sync off, not the host, signed out, …). There is nothing wrong to * surface, so these read as "none" rather than a failure. */ diff --git a/apps/desktop/src/renderer/components/settings/ActivitySection.tsx b/apps/desktop/src/renderer/components/settings/ActivitySection.tsx index 0fb883de3..648734b33 100644 --- a/apps/desktop/src/renderer/components/settings/ActivitySection.tsx +++ b/apps/desktop/src/renderer/components/settings/ActivitySection.tsx @@ -31,8 +31,18 @@ export function ActivitySection() { borderRadius: 10, }} > - Sign in to ADE to sync Activity across your machines. The notch settings - below still apply to this Mac. + {/* + The second sentence is a promise about what still works while + signed out, and it is only true where the notch exists: every other + control below is disabled by `busy` (`loading || signedOut`), and + the notch cards are disabled by `!notchSupported`. Off macOS that + leaves nothing on this page that "still applies", so the sentence + is dropped rather than reworded — a Windows user was being pointed + at a section that is inert for them. + */} + {model.notchSupported + ? "Sign in to ADE to sync Activity across your machines. The notch settings below still apply to this computer." + : "Sign in to ADE to sync Activity across your machines."}
) : null} diff --git a/apps/desktop/src/renderer/components/settings/ActivitySettingsControls.tsx b/apps/desktop/src/renderer/components/settings/ActivitySettingsControls.tsx index 2a0962ea5..bf2e40308 100644 --- a/apps/desktop/src/renderer/components/settings/ActivitySettingsControls.tsx +++ b/apps/desktop/src/renderer/components/settings/ActivitySettingsControls.tsx @@ -17,6 +17,7 @@ import { type AttentionNotchRevealMode, type AttentionPreferences, } from "../../../shared/types"; +import { THIS_MACHINE_NAME } from "../../../shared/machineIdentity"; import { activityNotchSupported, activityNotchSettingsFromPreferences, @@ -387,12 +388,21 @@ export function ActivitySettingsControls({ <> {notchSupported ? (
-

This Mac

+ {/* + These two are scope labels, not macOS prose: the page variant + renders the very same notch row with ``, + and this component exists so the two surfaces cannot say different + things about one setting. A badge reading "This Mac" beside a chip + reading "This computer" would be two names for one machine. The + surrounding section is macOS-only, but the *scope* is not a + platform claim, so it follows `THIS_MACHINE_NAME`. + */} +

{THIS_MACHINE_NAME}

{ expect(screen.queryByRole("dialog", { name: "Connect OpenAI" })).toBeNull(); }); }); + // @cursor/sdk has no win32-arm64 build, so the Cursor card must be absent on + // Windows on ARM rather than present and permanently unconnectable. + // See apps/desktop/src/shared/providerPlatformSupport.ts. + describe("Cursor card platform gating", () => { + function setRuntimeTarget(platform: string, arch: string) { + const ade = window.ade as unknown as { app?: Record }; + ade.app = { ...(ade.app ?? {}), runtimeTarget: { platform, arch } }; + } + + it("hides the Cursor card on Windows on ARM", async () => { + setRuntimeTarget("win32", "arm64"); + const view = renderProvidersSection(); + const current = within(view.container); + + await waitFor(() => { + expect(window.ade.ai.getStatus).toHaveBeenCalledTimes(1); + }); + + expect(current.queryByText("Uses CURSOR_API_KEY.")).toBeNull(); + expect(current.queryByLabelText("Add Cursor API key")).toBeNull(); + expect(current.queryByLabelText("Verify Cursor API key")).toBeNull(); + // The other providers are untouched. + expect((await current.findAllByText("Claude Code")).length).toBeGreaterThan(0); + expect(current.getAllByText("Codex CLI").length).toBeGreaterThan(0); + expect(current.getAllByText("Droid").length).toBeGreaterThan(0); + }); + + it("keeps the Cursor card on Windows x64 and on macOS", async () => { + for (const [platform, arch] of [["win32", "x64"], ["darwin", "arm64"]] as const) { + const getStatusMock = window.ade.ai.getStatus as ReturnType; + getStatusMock.mockReset(); + getStatusMock.mockResolvedValue(buildStatus(true)); + setRuntimeTarget(platform, arch); + const view = renderProvidersSection(); + const current = within(view.container); + + expect( + (await current.findAllByText("Uses CURSOR_API_KEY.")).length, + `${platform}-${arch}`, + ).toBeGreaterThan(0); + expect(current.queryByLabelText("Add Cursor API key"), `${platform}-${arch}`).toBeTruthy(); + cleanup(); + } + }); + }); }); diff --git a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx index 35928b040..9bc3f34e1 100644 --- a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx +++ b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx @@ -44,6 +44,7 @@ import { outlineButton, primaryButton, } from "../lanes/laneDesignTokens"; +import { cursorProviderAvailable, rendererPlatformAttribute } from "../../lib/platform"; import { deriveConfiguredModelIds } from "../../lib/modelOptions"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { invalidateAiDiscoveryCache } from "../../lib/aiDiscoveryCache"; @@ -68,11 +69,28 @@ type ProvidersStatus = AiSettingsStatus & { const KIMI_PROVIDER_ID = "kimi-for-coding"; -const OPENCODE_INSTALL_COMMANDS = [ - "brew install anomalyco/tap/opencode", - "npm i -g opencode-ai", - "curl -fsSL https://opencode.ai/install | bash", -]; +/** + * OpenCode's own documented install methods, per platform. Windows has neither + * Homebrew nor a POSIX shell to pipe the install script into, so it gets the + * package managers OpenCode actually documents for Windows (npm, Scoop, + * Chocolatey) instead of commands that cannot run there. + */ +export function openCodeInstallCommands( + platform: ReturnType = rendererPlatformAttribute(), +): string[] { + if (platform === "win32") { + return [ + "npm i -g opencode-ai", + "scoop install opencode", + "choco install opencode", + ]; + } + return [ + "brew install anomalyco/tap/opencode", + "npm i -g opencode-ai", + "curl -fsSL https://opencode.ai/install | bash", + ]; +} const CUSTOM_PROVIDER_NPM_OPTIONS = [ "@ai-sdk/openai-compatible", @@ -80,12 +98,25 @@ const CUSTOM_PROVIDER_NPM_OPTIONS = [ "@ai-sdk/anthropic", ]; +// Factory ships a native Windows build of `droid` with its own installer and +// its own way of setting an environment variable — a POSIX `export` line and a +// bare docs link leave a Windows user with nothing to run. +// https://docs.factory.ai/cli/getting-started/quickstart +const DROID_INSTALL_HINT = rendererPlatformAttribute() === "win32" + ? "irm https://app.factory.ai/cli/windows | iex — installs droid.exe into %USERPROFILE%\\bin and puts it on PATH" + : "curl -fsSL https://app.factory.ai/cli | sh — ensure `droid` is on PATH"; +const DROID_LOGIN_CMD = rendererPlatformAttribute() === "win32" + ? "setx FACTORY_API_KEY … (or sign in via `droid` interactive login)" + : "export FACTORY_API_KEY=… (or sign in via `droid` interactive login)"; + const CLI_TOOLS: Array<{ cli: CliName; label: string; authStory: string; loginCmd: string; installHint: string; + /** Used instead of installHint on Windows, where the vendor ships a different installer. */ + windowsInstallHint?: string; }> = [ { cli: "claude", @@ -93,6 +124,9 @@ const CLI_TOOLS: Array<{ authStory: "Uses your claude login — Claude Pro/Max subscription or ANTHROPIC_API_KEY.", loginCmd: "claude auth login or set ANTHROPIC_API_KEY", installHint: "npm install -g @anthropic-ai/claude-code", + // Anthropic's documented Windows installs: the PowerShell native installer + // (drops claude.exe in %USERPROFILE%\.localin) or WinGet. + windowsInstallHint: "irm https://claude.ai/install.ps1 | iex (PowerShell), or winget install Anthropic.ClaudeCode", }, { cli: "codex", @@ -112,8 +146,8 @@ const CLI_TOOLS: Array<{ cli: "droid", label: "Droid", authStory: "Uses your Factory login or FACTORY_API_KEY.", - loginCmd: "export FACTORY_API_KEY=… (or sign in via `droid` interactive login)", - installHint: "Install from https://docs.factory.ai/cli/getting-started/quickstart — ensure `droid` is on PATH", + loginCmd: DROID_LOGIN_CMD, + installHint: DROID_INSTALL_HINT, }, ]; @@ -379,6 +413,12 @@ function describeCredentialSource(connection: AiProviderConnectionStatus | null return null; } +const isWindowsRenderer = rendererPlatformAttribute() === "win32"; + +function installHintFor(tool: (typeof CLI_TOOLS)[number]): string { + return (isWindowsRenderer && tool.windowsInstallHint) || tool.installHint; +} + function buildCliMessage(tool: (typeof CLI_TOOLS)[number], connection: AiProviderConnectionStatus | null | undefined): string { if (connection?.runtimeAvailable) { return "Connection verified."; @@ -390,9 +430,12 @@ function buildCliMessage(tool: (typeof CLI_TOOLS)[number], connection: AiProvide return `CLI detected but not signed in. Run: ${tool.loginCmd}`; } if (connection?.authAvailable && !connection.runtimeDetected) { - return `Local credentials exist but CLI not found in PATH. Install: ${tool.installHint}`; + return `Local credentials exist but CLI not found in PATH. Install: ${installHintFor(tool)}`; } - return `CLI not found in PATH. Install: ${tool.installHint}. If already installed, ensure it is on your shell PATH and use Refresh.`; + const pathAdvice = isWindowsRenderer + ? "If already installed, add its folder to your Windows PATH (System Properties -> Environment Variables), reopen ADE, and use Refresh." + : "If already installed, ensure it is on your shell PATH and use Refresh."; + return `CLI not found in PATH. Install: ${installHintFor(tool)}. ${pathAdvice}`; } function formatLocalModelLabel(modelId: string): string { @@ -1085,7 +1128,10 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh })()} {/* ── Cursor ── */} - {(() => { + {/* Hidden entirely on Windows on ARM: @cursor/sdk has no win32-arm64 + build, so the card could only ever offer a provider that cannot + start. See shared/providerPlatformSupport.ts. */} + {!cursorProviderAvailable() ? null : (() => { const tool = CLI_TOOLS.find((t) => t.cli === "cursor")!; const connection = providerConnections?.[tool.cli] ?? null; const credentialSourceDesc = describeCredentialSource(connection); @@ -1267,7 +1313,7 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh OpenCode powers every subscription, API key, and local model below. Install it, then re-check:
- {OPENCODE_INSTALL_COMMANDS.map((cmd) => ( + {openCodeInstallCommands().map((cmd) => ( ))}
diff --git a/apps/desktop/src/renderer/components/settings/SecretsSection.tsx b/apps/desktop/src/renderer/components/settings/SecretsSection.tsx index 193ed4980..b0349f0ab 100644 --- a/apps/desktop/src/renderer/components/settings/SecretsSection.tsx +++ b/apps/desktop/src/renderer/components/settings/SecretsSection.tsx @@ -317,7 +317,7 @@ export function SecretsSection() {
- Import reads a file from this Mac. Export writes an unencrypted .env file containing all project secret values to Downloads on the machine hosting this project. + Import reads a file from this computer. Export writes an unencrypted .env file containing all project secret values to Downloads on the machine hosting this project.
@@ -297,6 +313,22 @@ export function ThisMacCard({
+ {crdtUnavailable ? ( +
+ {status.blockingStateText} +
+ ) : null} + {(host && routeLabels.length > 0) || appInfo ? (
{host && routeLabels.length > 0 ? ( @@ -312,7 +344,7 @@ export function ThisMacCard({
) : null} - {host ? ( + {host && !crdtUnavailable ? ( isRemoteBound ? (
Pairing code
- New nearby devices enter this code the first time they connect to this Mac. + New nearby devices enter this code the first time they connect to this computer.
{!pinConfigured ? (
Pairing code
{stateLine}
Pairing changes aren’t available while this window is connected to{" "} - {boundMachineName ?? "another Mac"}. + {boundMachineName ?? "another computer"}.
); @@ -468,7 +509,7 @@ function PinManagerRemoteNote({ // --------------------------------------------------------------------------- // Section title that names which machine a device list belongs to. The list is -// always scoped to this physical Mac; the "on {name}" line only appears when a +// always scoped to this physical machine; the "on {name}" line only appears when a // remote binding could otherwise make the reader assume it is the bound machine. function ScopedListTitle({ title, sync }: { title: string; sync: SyncConnections }) { return ( @@ -515,7 +556,7 @@ export function PhoneConnectionsTab({ ) : (
- Set up phones on the Mac that hosts your ADE projects. + Set up phones on the computer that hosts your ADE projects.
)}
@@ -526,8 +567,8 @@ function ConnectNewPhone({ status }: { status: SyncRoleSnapshot }) { const pinReadout = status.pairingPin ? status.pairingPin : status.pairingPinConfigured - ? "Pairing code is set — see This Mac above" - : "Set a pairing code in This Mac above"; + ? `Pairing code is set — see ${THIS_MACHINE_NAME} above` + : `Set a pairing code in ${THIS_MACHINE_NAME} above`; return (
@@ -535,7 +576,7 @@ function ConnectNewPhone({ status }: { status: SyncRoleSnapshot }) { Connect a new phone
- Sign in to ADE on your iPhone — this Mac appears automatically. + Sign in to ADE on your iPhone — this computer appears automatically.
{status.pairingConnectInfo ? (
- Or scan this code with your iPhone camera, then enter this Mac's pairing code. + Or scan this code with your iPhone camera, then enter this computer's pairing code.
Pairing code @@ -611,7 +652,7 @@ export function WebConnectionsTab({ {accountSignedIn ? ( <>
- Open the web client and sign in with your ADE account to reach this Mac. + Open the web client and sign in with your ADE account to reach this computer.
{usageReady && hasPressureSignal ? ( diff --git a/apps/desktop/src/renderer/components/settings/useSyncConnections.ts b/apps/desktop/src/renderer/components/settings/useSyncConnections.ts index 734314e43..edda6e757 100644 --- a/apps/desktop/src/renderer/components/settings/useSyncConnections.ts +++ b/apps/desktop/src/renderer/components/settings/useSyncConnections.ts @@ -9,8 +9,8 @@ export type SyncConnections = ReturnType; /** Display name for a machine snapshot, preferring its per-runtime name. */ function machineDisplayName(status: SyncRoleSnapshot | null): string { - if (!status) return "This Mac"; - return status.runtimeName?.trim() || status.localDevice.name || "This Mac"; + if (!status) return "This computer"; + return status.runtimeName?.trim() || status.localDevice.name || "This computer"; } function isRemoteBinding( @@ -110,7 +110,7 @@ export function useSyncConnections() { }, [refresh]); // When remote-bound, routed listDevices() describes that remote machine. - // Derive this Mac's connected devices from the local snapshot's live peers; + // Derive this computer's connected devices from the local snapshot's live peers; // offline-but-paired rows are not available until a local-scoped IPC exists. const isRemoteBound = isRemoteBinding(status, routedStatus); const boundMachineName = isRemoteBound ? machineDisplayName(routedStatus) : null; @@ -189,7 +189,7 @@ export function useSyncConnections() { boundMachineName, /** Display name of this physical Mac (from the local snapshot). */ localMachineName, - /** Whether device/pairing mutations are known to land on this Mac. */ + /** Whether device/pairing mutations are known to land on this computer. */ canManageDevices: status !== null && !isRemoteBound, setPinValue, generatePin, diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx index fff6d8c07..5f3ad4c2f 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx @@ -1545,4 +1545,48 @@ describe("ModelPicker", () => { expect(trigger.getAttribute("aria-expanded")).toBe("false"); }); }); + // The rail lists Cursor unconditionally so it stays reachable before the + // catalog refresh streams in — except on Windows on ARM, where @cursor/sdk has + // no build. See apps/desktop/src/shared/providerPlatformSupport.ts. + describe("Cursor rail platform gating", () => { + function setRuntimeTarget(platform: string, arch: string) { + (globalThis.window as unknown as { ade?: Record }).ade = { + ...((globalThis.window as unknown as { ade?: Record }).ade ?? {}), + app: { runtimeTarget: { platform, arch } }, + }; + } + + it("omits the Cursor rail on Windows on ARM but keeps every other provider", async () => { + const user = userEvent.setup(); + setRuntimeTarget("win32", "arm64"); + renderPicker(); + + await user.click(screen.getByRole("button", { name: /Select model/i })); + + const rail = document.querySelector('[data-model-picker-rail="true"]')!; + const keys = Array.from(rail.querySelectorAll("[data-rail-selection]")) + .map((el) => el.getAttribute("data-rail-selection")); + expect(keys).not.toContain("provider:cursor"); + expect(keys).toContain("provider:anthropic"); + expect(keys).toContain("provider:openai"); + expect(keys).toContain("provider:factory"); + expect(keys).toContain("provider:opencode"); + }); + + it("keeps the Cursor rail on Windows x64 and on macOS", async () => { + for (const [platform, arch] of [["win32", "x64"], ["darwin", "arm64"]] as const) { + const user = userEvent.setup(); + setRuntimeTarget(platform, arch); + renderPicker(); + + await user.click(screen.getByRole("button", { name: /Select model/i })); + + const rail = document.querySelector('[data-model-picker-rail="true"]')!; + const keys = Array.from(rail.querySelectorAll("[data-rail-selection]")) + .map((el) => el.getAttribute("data-rail-selection")); + expect(keys, `${platform}-${arch}`).toContain("provider:cursor"); + cleanup(); + } + }); + }); }); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx index ed5f53913..d652175c0 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx @@ -18,6 +18,7 @@ import { type ProviderFamily, } from "../../../../shared/modelRegistry"; import { cn } from "../../ui/cn"; +import { cursorProviderAvailable } from "../../../lib/platform"; import { ModelListRow } from "./ModelListRow"; import { ModelPickerRail, type RailEntry, type RailSelection, type AuthStatus } from "./ModelPickerRail"; import { useModelFavorites } from "./useModelFavorites"; @@ -254,15 +255,24 @@ export const ModelPickerContent = memo(function ModelPickerContent({ }, [allowRegistryExpansion, authOnly, familyIsReady, models, registryFilter]); const providersPresent = useMemo(() => { + // Cursor drops out of the rail on Windows on ARM, where @cursor/sdk has no + // build — otherwise the rail would offer a provider that can never load. + // See shared/providerPlatformSupport.ts. + const families = cursorProviderAvailable() + ? ALL_PROVIDER_FAMILIES + : ALL_PROVIDER_FAMILIES.filter((family) => family !== "cursor"); const set = new Set(); - for (const m of expandedModels) set.add(m.family); + for (const m of expandedModels) { + if (m.family === "cursor" && !cursorProviderAvailable()) continue; + set.add(m.family); + } // Always include dynamic-only provider families (Cursor, Droid, OpenCode, // local runtimes). Their models may not exist until a catalog refresh runs, // but the rail entry must still be reachable without toggling "Show all models". - for (const family of ALL_PROVIDER_FAMILIES) set.add(family); + for (const family of families) set.add(family); // Stabilize rail order so it doesn't flicker as catalog discovery streams in. - return ALL_PROVIDER_FAMILIES.filter((family) => set.has(family)) - .concat([...set].filter((family) => !ALL_PROVIDER_FAMILIES.includes(family))); + return families.filter((family) => set.has(family)) + .concat([...set].filter((family) => !families.includes(family))); }, [expandedModels]); const railEntries = useMemo(() => { diff --git a/apps/desktop/src/renderer/index.css b/apps/desktop/src/renderer/index.css index f1c299844..896e43af1 100644 --- a/apps/desktop/src/renderer/index.css +++ b/apps/desktop/src/renderer/index.css @@ -121,7 +121,7 @@ /* App shell primitives (shared contract for TopBar + sidebar) */ --shell-header-height: 32px; - --shell-header-padding-start: 80px; + --shell-header-padding-start: 14px; --shell-header-padding-end: 14px; --shell-header-bg: rgba(12, 11, 16, 0.82); --shell-header-fg: var(--color-fg); @@ -138,6 +138,7 @@ --shell-sidebar-item-hover-bg: rgba(255, 255, 255, 0.05); --shell-sidebar-item-active-fg: var(--color-accent); --shell-sidebar-item-active-bg: rgba(167, 139, 250, 0.1); + --shell-sidebar-item-active-rail: var(--color-accent); --shell-sidebar-separator: rgba(255, 255, 255, 0.04); @@ -198,6 +199,36 @@ --work-popover-item-active: rgba(255, 255, 255, 0.08); } +/* ═══════════════════════════════════════════════════════════ + Native title-bar insets (platform chrome clearance) + ═══════════════════════════════════════════════════════════ + + The OS draws its own window controls on top of the title bar: the traffic + lights at the start on macOS, the caption buttons at the end on Windows. The + shell header has to leave room for whichever applies. + + These deliberately do NOT live in the theme token block above. `data-theme` + is set on , , AND the shell wrapper (see App.tsx), so every one + of those elements re-declares the `--shell-header-padding-*` defaults — which + would shadow a platform value inherited from long before it reached + the header. Only carries `data-ade-platform`, so a dedicated property + name declared here survives all the way down. + + Both values are only the pre-paint fallback. `applyShellHeaderInset()` + (renderer/lib/zoom.ts) and `trackWindowsCaptionInset()` + (renderer/lib/windowControlsOverlay.ts) overwrite them with live geometry, + because the native controls are a fixed physical size that does not follow + the renderer's zoom factor. */ + +:root[data-ade-platform="darwin"] { + --shell-header-inset-start: 80px; +} + +:root[data-ade-platform="win32"] { + /* Three 46 DIP caption buttons plus an 8px gutter. */ + --shell-header-inset-end: 146px; +} + /* ═══════════════════════════════════════════════════════════ Light Theme ═══════════════════════════════════════════════════════════ */ @@ -435,8 +466,8 @@ h6 { .ade-shell-header { height: var(--shell-header-height); - padding-left: var(--shell-header-padding-start); - padding-right: var(--shell-header-padding-end); + padding-left: var(--shell-header-inset-start, var(--shell-header-padding-start)); + padding-right: var(--shell-header-inset-end, var(--shell-header-padding-end)); color: var(--shell-header-fg); background: var(--shell-header-bg); backdrop-filter: blur(20px); @@ -662,7 +693,19 @@ h6 { Collapsible Sidebar ═══════════════════════════════════════════════════════════ */ -/* Outer clip container — only width changes, icons never reflow */ +/* Outer clip container — the rail animates its own width in normal flow, so + the shell's main row genuinely reflows and content is pushed aside as the + rail grows. That is the macOS behaviour and it is the behaviour we keep. + Only the icons never reflow, because the inner .ade-sidebar is always at the + expanded width and this box clips it. + + Layout every frame is not what made this expensive on Windows — the + ResizeObserver storm it triggered inside
was. See + renderer/lib/layoutSettle.ts: AppShell holds ResizeObserver delivery inside +
for the length of this transition and flushes once on transitionend, + so react-resizable-panels stops doing a forced synchronous layout plus a + full re-render of every pane on every one of the ~48 frames a 240 Hz display + packs into 200ms. */ .ade-sidebar-clip { width: var(--shell-sidebar-collapsed-width); overflow: hidden; diff --git a/apps/desktop/src/renderer/lib/account.ts b/apps/desktop/src/renderer/lib/account.ts index f805f882a..a84ffbcd5 100644 --- a/apps/desktop/src/renderer/lib/account.ts +++ b/apps/desktop/src/renderer/lib/account.ts @@ -61,6 +61,17 @@ function emit(status: AdeAccountStatus): void { } } +/** + * A status that only says "signed out" because the stored session could not be + * READ. Signing out is an explicit user action and a durable state; a failed + * decrypt is neither. On Windows the OS-bound credential key is unwrapped by a + * PowerShell/DPAPI helper that can transiently time out under load, which is + * how a machine that never signed out ends up rendering as signed out. + */ +function isUnreadableSession(status: AdeAccountStatus): boolean { + return !status.signedIn && status.sessionReadState === "unreadable"; +} + export async function fetchAccountStatus(options?: { force?: boolean }): Promise { const api = accountApi(); if (!api?.status) return SIGNED_OUT_ACCOUNT; @@ -77,9 +88,16 @@ export async function fetchAccountStatus(options?: { force?: boolean }): Promise .status() .then((status) => { const normalized = status ?? SIGNED_OUT_ACCOUNT; + // Hold the last known identity across an unreadable read instead of + // flashing the whole app to signed-out. The next successful read wins, + // and a real sign-out reports "missing", which is never retained. + const previous = cachedStatus?.value; + if (isUnreadableSession(normalized) && previous?.signedIn) return previous; if (serial === fetchSerial) emit(normalized); return normalized; }) + // A failed status call is an unknown state, not a signed-out one. Only + // fabricate SIGNED_OUT_ACCOUNT when nothing was ever known. .catch(() => cachedStatus?.value ?? SIGNED_OUT_ACCOUNT) .finally(() => { if (serial === fetchSerial) inFlight = null; diff --git a/apps/desktop/src/renderer/lib/layoutSettle.test.ts b/apps/desktop/src/renderer/lib/layoutSettle.test.ts new file mode 100644 index 000000000..bd32c11a1 --- /dev/null +++ b/apps/desktop/src/renderer/lib/layoutSettle.test.ts @@ -0,0 +1,166 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + __resetLayoutSettleForTests, + holdLayoutSettle, + installLayoutSettleResizeObserver, +} from "./layoutSettle"; + +/** + * jsdom has no ResizeObserver, and we need to drive delivery by hand anyway, so + * stand in a fake whose `emit` plays the role of the browser's delivery step. + */ +class FakeResizeObserver { + static instances: FakeResizeObserver[] = []; + readonly observed: Element[] = []; + readonly observeCalls: Element[] = []; + constructor(readonly callback: (entries: ResizeObserverEntry[], observer: ResizeObserver) => void) { + FakeResizeObserver.instances.push(this); + } + observe(target: Element): void { + this.observeCalls.push(target); + if (!this.observed.includes(target)) this.observed.push(target); + } + unobserve(target: Element): void { + const idx = this.observed.indexOf(target); + if (idx >= 0) this.observed.splice(idx, 1); + } + disconnect(): void { + this.observed.length = 0; + } + emit(...targets: Element[]): void { + this.callback( + targets.map((target) => ({ target }) as unknown as ResizeObserverEntry), + this as unknown as ResizeObserver, + ); + } +} + +const nativeDescriptor = Object.getOwnPropertyDescriptor(window, "ResizeObserver"); + +function makeScope() { + const scope = document.createElement("main"); + const inner = document.createElement("div"); + scope.appendChild(inner); + document.body.appendChild(scope); + const outside = document.createElement("div"); + document.body.appendChild(outside); + return { scope, inner, outside }; +} + +describe("layoutSettle", () => { + beforeEach(() => { + vi.useFakeTimers(); + FakeResizeObserver.instances = []; + Object.defineProperty(window, "ResizeObserver", { + value: FakeResizeObserver, + configurable: true, + writable: true, + }); + __resetLayoutSettleForTests(); + installLayoutSettleResizeObserver(); + }); + + afterEach(() => { + vi.useRealTimers(); + document.body.innerHTML = ""; + __resetLayoutSettleForTests(); + if (nativeDescriptor) Object.defineProperty(window, "ResizeObserver", nativeDescriptor); + }); + + it("delivers normally when nothing is holding", () => { + const { inner } = makeScope(); + const callback = vi.fn(); + const observer = new window.ResizeObserver(callback); + observer.observe(inner); + FakeResizeObserver.instances[0].emit(inner); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("swallows deliveries inside the held scope and re-observes on release", () => { + const { scope, inner } = makeScope(); + const callback = vi.fn(); + const observer = new window.ResizeObserver(callback); + observer.observe(inner); + const inner1 = FakeResizeObserver.instances[0]; + expect(inner1.observeCalls).toHaveLength(1); + + const release = holdLayoutSettle(scope); + inner1.emit(inner); + inner1.emit(inner); + inner1.emit(inner); + expect(callback).not.toHaveBeenCalled(); + + release(); + // Released by re-observing, so the consumer is handed the element's real + // settled geometry rather than a size it had mid-animation. + expect(inner1.observeCalls).toEqual([inner, inner]); + expect(inner1.observed).toContain(inner); + }); + + it("keeps delivering for targets outside the held scope", () => { + const { scope, inner, outside } = makeScope(); + const callback = vi.fn(); + const observer = new window.ResizeObserver(callback); + observer.observe(inner); + observer.observe(outside); + + holdLayoutSettle(scope); + FakeResizeObserver.instances[0].emit(inner, outside); + + expect(callback).toHaveBeenCalledTimes(1); + const entries = callback.mock.calls[0][0] as ResizeObserverEntry[]; + expect(entries.map((entry) => entry.target)).toEqual([outside]); + }); + + it("releases on the backstop timer when the caller never does", () => { + const { scope, inner } = makeScope(); + const callback = vi.fn(); + const observer = new window.ResizeObserver(callback); + observer.observe(inner); + const fake = FakeResizeObserver.instances[0]; + + holdLayoutSettle(scope, 400); + fake.emit(inner); + expect(fake.observeCalls).toHaveLength(1); + + vi.advanceTimersByTime(400); + expect(fake.observeCalls).toHaveLength(2); + + fake.emit(inner); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("is reference counted, so overlapping holds do not release early", () => { + const { scope, inner } = makeScope(); + const callback = vi.fn(); + const observer = new window.ResizeObserver(callback); + observer.observe(inner); + const fake = FakeResizeObserver.instances[0]; + + const releaseA = holdLayoutSettle(scope); + const releaseB = holdLayoutSettle(scope); + releaseA(); + fake.emit(inner); + expect(callback).not.toHaveBeenCalled(); + + releaseB(); + fake.emit(inner); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("does not resurrect targets that were unobserved while held", () => { + const { scope, inner } = makeScope(); + const observer = new window.ResizeObserver(vi.fn()); + observer.observe(inner); + const fake = FakeResizeObserver.instances[0]; + + const release = holdLayoutSettle(scope); + fake.emit(inner); + observer.unobserve(inner); + release(); + + expect(fake.observed).toHaveLength(0); + expect(fake.observeCalls).toEqual([inner]); + }); +}); diff --git a/apps/desktop/src/renderer/lib/layoutSettle.ts b/apps/desktop/src/renderer/lib/layoutSettle.ts new file mode 100644 index 000000000..e2a7e3325 --- /dev/null +++ b/apps/desktop/src/renderer/lib/layoutSettle.ts @@ -0,0 +1,161 @@ +/** + * Deferred ResizeObserver delivery for short, self-resolving layout animations. + * + * The shell's collapsible tab rail animates its own width (52px -> 140px) as a + * flex item, so every frame of that 200ms transition resizes
and every + * pane group inside it. `react-resizable-panels` observes its group and panel + * elements with a ResizeObserver, and its callback reads `offsetWidth` for the + * group and each panel (a forced synchronous layout) before re-rendering every + * pane through React. The per-frame cost is the same everywhere, but the number + * of frames in a fixed-duration transition scales with the display refresh + * rate: ~12 frames at 60 Hz, ~48 at 240 Hz. On a 240 Hz Windows display that + * pinned the renderer main thread at 99.3% busy for the length of a hover. + * + * Nothing in that intermediate work is load bearing. `react-resizable-panels` + * sizes panels with `flex-grow` ratios (`flexBasis: 0`), so the browser already + * scales them correctly as the container shrinks, for free, on the compositor's + * schedule. The observer callback only re-derives pixel constraints — which + * only matters once, at the size the group actually comes to rest at. + * + * So: install a ResizeObserver wrapper that can hold delivery for observers + * watching elements inside a scope element, and flush once the animation ends. + * Flushing re-observes the held targets rather than replaying stale entries, so + * consumers receive the real, final geometry instead of a size the element had + * mid-transition. + * + * This keeps macOS push semantics — content genuinely moves aside as the rail + * grows, because the browser is still laying it out every frame — while + * removing the forced-layout-plus-React-render pass that made it expensive. + */ + +type ObserverCallback = (entries: ResizeObserverEntry[], observer: ResizeObserver) => void; + +type HeldTargets = Set; + +let nativeResizeObserver: typeof ResizeObserver; +let installed = false; +let suspendCount = 0; +let suspendScope: Element | null = null; +const held = new Map(); + +function isSuspended(target: Element): boolean { + if (suspendCount <= 0) return false; + const scope = suspendScope; + if (!scope) return false; + return scope === target || scope.contains(target); +} + +class SettlingResizeObserver implements ResizeObserver { + readonly #inner: ResizeObserver; + readonly #observed = new Map(); + + constructor(callback: ObserverCallback) { + const NativeObserver = nativeResizeObserver; + this.#inner = new NativeObserver((entries) => { + if (suspendCount <= 0) { + callback(entries, this); + return; + } + const deliver: ResizeObserverEntry[] = []; + let holding: HeldTargets | undefined; + for (const entry of entries) { + if (!isSuspended(entry.target)) { + deliver.push(entry); + continue; + } + holding ??= held.get(this) ?? new Set(); + holding.add(entry.target); + } + if (holding) held.set(this, holding); + if (deliver.length > 0) callback(deliver, this); + }); + } + + observe(target: Element, options?: ResizeObserverOptions): void { + this.#observed.set(target, options); + this.#inner.observe(target, options); + } + + unobserve(target: Element): void { + this.#observed.delete(target); + held.get(this)?.delete(target); + this.#inner.unobserve(target); + } + + disconnect(): void { + this.#observed.clear(); + held.delete(this); + this.#inner.disconnect(); + } + + /** + * Re-observe every target whose callback we swallowed. A fresh `observe()` + * always delivers an initial observation, so the consumer sees the element's + * settled geometry rather than a stale mid-animation box. + */ + flushHeld(targets: HeldTargets): void { + for (const target of targets) { + if (!this.#observed.has(target)) continue; + if (!target.isConnected) continue; + const options = this.#observed.get(target); + this.#inner.unobserve(target); + this.#inner.observe(target, options); + } + } +} + +/** + * Swap `window.ResizeObserver` for the settling wrapper. Must run before the + * observers we want to be able to hold are constructed; `react-resizable-panels` + * reads `ownerDocument.defaultView.ResizeObserver` when a group mounts, so + * installing this at renderer boot is early enough. + */ +export function installLayoutSettleResizeObserver(): void { + if (installed) return; + if (typeof window === "undefined" || typeof window.ResizeObserver !== "function") return; + installed = true; + nativeResizeObserver = window.ResizeObserver; + window.ResizeObserver = SettlingResizeObserver as unknown as typeof ResizeObserver; +} + +function flushAll(): void { + if (held.size === 0) return; + const pending = [...held.entries()]; + held.clear(); + for (const [observer, targets] of pending) observer.flushHeld(targets); +} + +/** + * Hold ResizeObserver delivery for anything inside `scope` until the returned + * release function runs. Reference counted, so overlapping holds are safe. + * + * `maxMs` is a hard backstop: if the release never arrives (a transition that + * gets interrupted, a `transitionend` that never fires because the duration is + * 0), observers resume anyway. It is a correctness guard, not a schedule. + */ +export function holdLayoutSettle(scope: Element | null, maxMs = 400): () => void { + if (!installed || !scope) return () => {}; + suspendScope = scope; + suspendCount += 1; + let released = false; + const timer = window.setTimeout(() => release(), maxMs); + function release(): void { + if (released) return; + released = true; + window.clearTimeout(timer); + suspendCount = Math.max(0, suspendCount - 1); + if (suspendCount === 0) { + suspendScope = null; + flushAll(); + } + } + return release; +} + +/** Test seam. */ +export function __resetLayoutSettleForTests(): void { + installed = false; + suspendCount = 0; + suspendScope = null; + held.clear(); +} diff --git a/apps/desktop/src/renderer/lib/platform.ts b/apps/desktop/src/renderer/lib/platform.ts index aaa84d192..fec12a466 100644 --- a/apps/desktop/src/renderer/lib/platform.ts +++ b/apps/desktop/src/renderer/lib/platform.ts @@ -1,3 +1,5 @@ +import { isCursorProviderSupported } from "../../shared/providerPlatformSupport"; + function getPlatformValue(): string { if (typeof navigator !== "undefined" && typeof navigator.platform === "string") { return navigator.platform; @@ -8,6 +10,35 @@ function getPlatformValue(): string { return ""; } +/** + * Host platform/arch as captured by preload. `navigator.platform` cannot answer + * this — Chromium reports "Win32" on Windows on ARM as well — so anything that + * has to distinguish win32-x64 from win32-arm64 reads the bridge instead. + * Falls back to the browser-mock/dev default when the bridge is absent. + */ +export function rendererRuntimeTarget(): { platform: string; arch: string } { + const bridged = typeof window !== "undefined" + ? (window as { ade?: { app?: { runtimeTarget?: { platform?: unknown; arch?: unknown } } } }).ade + ?.app?.runtimeTarget + : undefined; + const platform = typeof bridged?.platform === "string" && bridged.platform + ? bridged.platform + : rendererPlatformAttribute(); + const arch = typeof bridged?.arch === "string" && bridged.arch ? bridged.arch : "x64"; + return { platform, arch }; +} + +/** + * False only on Windows on ARM, where `@cursor/sdk` has no build — the Cursor + * provider is hidden there rather than shown broken. See + * shared/providerPlatformSupport.ts. Deliberately a function, not a module-scope + * const: the preload bridge must exist before it is read. + */ +export function cursorProviderAvailable(): boolean { + const { platform, arch } = rendererRuntimeTarget(); + return isCursorProviderSupported(platform, arch); +} + export function isMacPlatform(platformValue = getPlatformValue()): boolean { return /mac|darwin/i.test(platformValue); } diff --git a/apps/desktop/src/renderer/lib/windowControlsOverlay.ts b/apps/desktop/src/renderer/lib/windowControlsOverlay.ts new file mode 100644 index 000000000..4582a625e --- /dev/null +++ b/apps/desktop/src/renderer/lib/windowControlsOverlay.ts @@ -0,0 +1,187 @@ +/** + * Windows title-bar (Window Controls Overlay) geometry. + * + * The Windows window is created with `titleBarStyle: "hidden"` + + * `titleBarOverlay` (see `src/main/windowAppearance.ts`), so Chromium draws the + * native minimize/maximize/close buttons on top of the renderer at the trailing + * edge and exposes their geometry through the Window Controls Overlay API. + * + * This is the Windows twin of `shellHeaderInsetPx()` in `./zoom`: macOS reserves + * space at the *start* of the title bar for the traffic lights, Windows must + * reserve space at the *end* for the caption buttons. Both hazards are the same + * shape — the OS draws chrome at a fixed physical size that does not follow the + * renderer's zoom factor — so a static CSS-pixel reservation drifts: it wastes + * space when zoomed in and lets the caption buttons swallow ADE's own controls + * when zoomed out. + * + * Rather than guess, read the real overlay rect. `getTitlebarAreaRect()` returns + * the part of the title bar still owned by the page, in CSS pixels, so the + * caption-button width is simply what is left over at the trailing edge. + */ + +/** + * Deliberately not `--shell-header-padding-end`: `data-theme` is set on , + * , and the shell wrapper, so each of those re-declares the padding + * tokens and would shadow a value inherited from . See the platform-inset + * block in index.css. + */ +export const SHELL_HEADER_INSET_END_PROPERTY = "--shell-header-inset-end"; + +/** + * Caption-button clearance used before the overlay reports real geometry (and + * on any Chromium that does not expose the API). Windows draws three 46 DIP + * caption buttons, so 138 DIP is the true default width. + */ +export const WINDOWS_CAPTION_FALLBACK_PX = 138; + +/** Breathing room between the last ADE control and the first caption button. */ +export const WINDOWS_CAPTION_GUTTER_PX = 8; + +/** + * Title-bar padding when no caption buttons are overlaid (non-Windows, or + * Windows in fullscreen where the overlay hides). Matches the CSS default. + */ +export const SHELL_HEADER_INSET_END_BASE_PX = 14; + +type TitlebarAreaRect = { + x: number; + y: number; + width: number; + height: number; +}; + +export type WindowControlsOverlayLike = { + visible: boolean; + getTitlebarAreaRect: () => TitlebarAreaRect; + addEventListener?: (type: "geometrychange", listener: () => void) => void; + removeEventListener?: (type: "geometrychange", listener: () => void) => void; +}; + +export type CaptionInsetArgs = { + overlay: WindowControlsOverlayLike | null | undefined; + viewportWidth: number; +}; + +/** + * CSS px to reserve at the end of the title bar so the Windows caption buttons + * never cover ADE's trailing controls (feedback reporter, help menu, zoom). + * + * The overlay rect is the page-owned slice of the title bar, so the reserved + * width is `viewport - (rect.x + rect.width)` — whatever the caption buttons + * occupy at the trailing edge, at the current zoom and DPI — plus a small + * gutter. When the overlay is hidden (fullscreen) nothing needs reserving. + * + * Returns `null` for "ask again in a frame". `innerWidth` and the overlay rect + * are updated by different parts of the browser and are briefly inconsistent + * mid-resize: the viewport narrows while the rect still describes the old, + * wider window, which makes the trailing edge look like zero. Taking that at + * face value would mean "no caption buttons here" and drop the reservation to + * nothing — the observed failure is the whole control cluster jumping back + * under the caption buttons after a resize. An unusable answer is better left + * unanswered; the caller keeps the last good value until the geometry settles. + */ +export function windowsCaptionInsetPx(args: CaptionInsetArgs): number | null { + const { overlay, viewportWidth } = args; + const fallback = WINDOWS_CAPTION_FALLBACK_PX + WINDOWS_CAPTION_GUTTER_PX; + if (!overlay || typeof overlay.getTitlebarAreaRect !== "function") { + return fallback; + } + // Fullscreen genuinely hides the caption buttons, and that is a settled + // state rather than a transient one, so it is safe to reclaim the space. + if (!overlay.visible) return SHELL_HEADER_INSET_END_BASE_PX; + + let rect: TitlebarAreaRect; + try { + rect = overlay.getTitlebarAreaRect(); + } catch { + return fallback; + } + if (!rect || !Number.isFinite(rect.width) || !Number.isFinite(rect.x)) { + return fallback; + } + // A zero-width rect means the overlay is present but not laid out yet; the + // fallback is closer to the truth than "reserve the whole window". + if (rect.width <= 0) return fallback; + if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) return fallback; + + const trailing = viewportWidth - (rect.x + rect.width); + if (!Number.isFinite(trailing) || trailing <= 0) return null; + return Math.round(trailing) + WINDOWS_CAPTION_GUTTER_PX; +} + +function readOverlay(): WindowControlsOverlayLike | null { + if (typeof navigator === "undefined") return null; + const overlay = (navigator as Navigator & { + windowControlsOverlay?: WindowControlsOverlayLike; + }).windowControlsOverlay; + return overlay ?? null; +} + +/** + * Push the live caption-button clearance into `--shell-header-inset-end`. + * No-op where there is no overlay to dodge, or while the geometry is mid-flight + * (see `windowsCaptionInsetPx`). Returns whether a value was written. + */ +export function applyWindowsCaptionInset(): boolean { + if (typeof document === "undefined" || typeof window === "undefined") { + return false; + } + const overlay = readOverlay(); + if (!overlay) return false; + const inset = windowsCaptionInsetPx({ + overlay, + viewportWidth: window.innerWidth, + }); + if (inset == null) return false; + document.documentElement.style.setProperty( + SHELL_HEADER_INSET_END_PROPERTY, + `${inset}px`, + ); + return true; +} + +/** + * Apply the clearance now and keep it in sync. `geometrychange` covers + * maximize/restore and fullscreen; `resize` covers renderer zoom changes, which + * alter how many CSS pixels the fixed-DIP caption buttons occupy. + * + * Each trigger retries on the next few frames rather than reading once: the + * viewport and the overlay rect settle independently, and the first read after + * a resize is often the inconsistent one. + */ +export function trackWindowsCaptionInset(): () => void { + if (typeof window === "undefined") return () => {}; + const overlay = readOverlay(); + if (!overlay) return () => {}; + + const RETRY_FRAMES = 6; + let frame: number | null = null; + const cancelFrame = () => { + if (frame != null) window.cancelAnimationFrame(frame); + frame = null; + }; + const settle = (remaining: number) => { + cancelFrame(); + if (remaining <= 0) return; + frame = window.requestAnimationFrame(() => { + frame = null; + // Keep re-reading even after a successful write: a resize produces a run + // of intermediate geometries and only the last one is the real answer. + applyWindowsCaptionInset(); + settle(remaining - 1); + }); + }; + const update = () => { + applyWindowsCaptionInset(); + settle(RETRY_FRAMES); + }; + update(); + + overlay.addEventListener?.("geometrychange", update); + window.addEventListener("resize", update); + return () => { + cancelFrame(); + overlay.removeEventListener?.("geometrychange", update); + window.removeEventListener("resize", update); + }; +} diff --git a/apps/desktop/src/renderer/lib/zoom.ts b/apps/desktop/src/renderer/lib/zoom.ts index 9beecebf4..d134ea8d6 100644 --- a/apps/desktop/src/renderer/lib/zoom.ts +++ b/apps/desktop/src/renderer/lib/zoom.ts @@ -89,13 +89,18 @@ export function shellHeaderInsetPx(displayZoom: number): number { /** * Sync the title bar's start padding to the current zoom so the macOS traffic * lights never overlap the logo. No-op off macOS (no native traffic lights) and - * outside a DOM; there the static `--shell-header-padding-start` default stands. + * outside a DOM; there the static `--shell-header-inset-start` default stands. + * + * `--shell-header-inset-start` (not `--shell-header-padding-start`) because + * `data-theme` is set on , , and the shell wrapper, so each of them + * re-declares the padding tokens and would shadow this value before it reached + * the header. See the platform-inset block in index.css. */ export function applyShellHeaderInset(displayZoom: number): void { if (!isMac) return; if (typeof document === "undefined") return; document.documentElement.style.setProperty( - "--shell-header-padding-start", + "--shell-header-inset-start", `${shellHeaderInsetPx(displayZoom)}px`, ); } diff --git a/apps/desktop/src/renderer/main.tsx b/apps/desktop/src/renderer/main.tsx index 2ac3899c4..596da6a33 100644 --- a/apps/desktop/src/renderer/main.tsx +++ b/apps/desktop/src/renderer/main.tsx @@ -12,9 +12,20 @@ import { useAppStore } from "./state/appStore"; import { logRendererDebugEvent } from "./lib/debugLog"; import { initPerfRuntime } from "./perf/harness"; import { rendererPlatformAttribute } from "./lib/platform"; +import { trackWindowsCaptionInset } from "./lib/windowControlsOverlay"; +import { installLayoutSettleResizeObserver } from "./lib/layoutSettle"; + +// Must run before anything constructs a ResizeObserver — react-resizable-panels +// reads `ownerDocument.defaultView.ResizeObserver` when a pane group mounts. +installLayoutSettleResizeObserver(); document.documentElement.dataset.adePlatform = rendererPlatformAttribute(); +// Windows draws its caption buttons over the trailing edge of the title bar. +// Keep the header's end padding pinned to their real width so the feedback, +// help, and zoom controls always land just to their left. +trackWindowsCaptionInset(); + (function injectFontFaces() { const style = document.createElement("style"); style.dataset.adeFonts = "true"; diff --git a/apps/desktop/src/renderer/state/appStore.test.ts b/apps/desktop/src/renderer/state/appStore.test.ts index 55a6afdbe..2f0013896 100644 --- a/apps/desktop/src/renderer/state/appStore.test.ts +++ b/apps/desktop/src/renderer/state/appStore.test.ts @@ -1547,7 +1547,7 @@ describe("appStore", () => { expect(useAppStore.getState().projectTransitionError).toEqual({ code: "disk_full", - message: "Your Mac ran out of storage while ADE was saving project data. Free up space, then try again.", + message: "Your computer ran out of storage while ADE was saving project data. Free up space, then try again.", detail: "internal database detail", rootPath: "/tmp/project", }); diff --git a/apps/desktop/src/renderer/state/appStore.ts b/apps/desktop/src/renderer/state/appStore.ts index bb86714ed..aa3c96d6d 100644 --- a/apps/desktop/src/renderer/state/appStore.ts +++ b/apps/desktop/src/renderer/state/appStore.ts @@ -1031,7 +1031,7 @@ export type CrossMachineMachineLanes = { /** * Usually a remote target id. `THIS_MACHINE_ID` is stored only while the * active tab is bound remotely, because then `lanes` belongs to that remote - * binding and This Mac is one of the union's other machines. + * binding and This computer is one of the union's other machines. */ machineId: string; /** Absolute machine name ("MacBook Pro (97)"). Never the word "remote". */ @@ -1404,7 +1404,7 @@ function formatProjectTransitionError( } const code = toAdeRecoveryErrorCode(parsed.code); const recoveryMessage = code === "disk_full" - ? "Your Mac ran out of storage while ADE was saving project data. Free up space, then try again." + ? "Your computer ran out of storage while ADE was saving project data. Free up space, then try again." : code === "brain_crash_looping" || code === "migration_incomplete" || code === "migration_unknown_state" ? "ADE's background service needs a repair before this project can open." : code && [ diff --git a/apps/desktop/src/renderer/state/crossMachineLanes.test.ts b/apps/desktop/src/renderer/state/crossMachineLanes.test.ts index 97f60f5f2..8d151df81 100644 --- a/apps/desktop/src/renderer/state/crossMachineLanes.test.ts +++ b/apps/desktop/src/renderer/state/crossMachineLanes.test.ts @@ -276,7 +276,7 @@ describe("offline machines stay in the sidebar, dimmed", () => { }); }); -describe("This Mac counterpart resolution", () => { +describe("This computer counterpart resolution", () => { it("joins only an existing local checkout with the same normalized origin", () => { expect(resolveThisMachineBindingForOrigin([ { @@ -551,7 +551,7 @@ describe("machine marker", () => { }, [THIS_MACHINE_ID]: { machineId: THIS_MACHINE_ID, - machineName: "This Mac", + machineName: "This computer", targetId: null, projectId: null, binding: { @@ -818,7 +818,7 @@ describe("selectOtherMachineBranchStates", () => { const warning = detectPushDivergence({ current: { machineId: THIS_MACHINE_ID, - machineName: "This Mac", + machineName: "This computer", branchRef: "feature/shared", headSha: null, ahead: 1, @@ -935,7 +935,7 @@ describe("selectOtherMachineBranchStates", () => { }, [THIS_MACHINE_ID]: { machineId: THIS_MACHINE_ID, - machineName: "This Mac", + machineName: "This computer", targetId: null, projectId: null, online: true, @@ -1028,7 +1028,7 @@ describe("foreign payload decoding", () => { }); describe("cross-machine refresh scheduling", () => { - it("reads This Mac explicitly while the active tab is bound remotely", async () => { + it("reads This computer explicitly while the active tab is bound remotely", async () => { vi.useFakeTimers(); const localBinding = { kind: "local" as const, diff --git a/apps/desktop/src/renderer/state/crossMachineLanes.ts b/apps/desktop/src/renderer/state/crossMachineLanes.ts index dd4f927d5..078c7f69d 100644 --- a/apps/desktop/src/renderer/state/crossMachineLanes.ts +++ b/apps/desktop/src/renderer/state/crossMachineLanes.ts @@ -651,7 +651,7 @@ export function selectOtherMachineBranchStates( } } // A lane outside the active binding compares against the active binding too; - // `state.lanes` is not necessarily This Mac. + // `state.lanes` is not necessarily This computer. if (subjectMachineId !== activeMachineId) { for (const lane of state.lanes) { if (normalizeBranchRef(lane.branchRef) !== subjectBranch) continue; @@ -684,7 +684,7 @@ export type CrossMachineLaneScope = { repoDisplayName: string | null; /** Verified local origin URL; null means this repo has no usable origin. */ repoOriginUrl: string | null; - /** Target id the tab is bound to; null when the tab is on this Mac. */ + /** Target id the tab is bound to; null when the tab is on this computer. */ boundTargetId: string | null; /** Project id on the bound machine, when the tab is bound to a remote one. */ boundProjectId: string | null; @@ -1133,7 +1133,7 @@ async function readThisMachine( binding, ), MACHINE_READ_TIMEOUT_MS, - "lane.list on This Mac", + "lane.list on This computer", ); return includeStatus ? lanes : withPreservedLaneStatus(THIS_MACHINE_ID, lanes); }; @@ -1165,7 +1165,7 @@ async function readThisMachine( binding, ), MACHINE_READ_TIMEOUT_MS, - "session.list on This Mac", + "session.list on This computer", ), lanesDue ? readPrs() : null, ]); @@ -1317,7 +1317,7 @@ function scheduleRefresh(depth: LaneReadDepth = "identity"): void { /** * The machines that can contribute rows for the current scope: connected AND - * still hosting this repository, excluding This Mac and the tab's own binding + * still hosting this repository, excluding This computer and the tab's own binding * (both of which the primary list already owns). * * Reachability and the read list share this one definition on purpose. When they @@ -1476,7 +1476,7 @@ function applyReachability(): void { ]); for (const machineId of scopedMachineIds) { const entry = store.crossMachineLanesByMachineId[machineId] ?? null; - // This Mac is not a connection target and is always reachable; holding a + // This computer is not a connection target and is always reachable; holding a // drop record for it would leak a map entry nothing can ever clear. if (machineId === THIS_MACHINE_ID) continue; const machine = connectivity.get(machineId); diff --git a/apps/desktop/src/renderer/webclient/adapter/__tests__/connectionsPane.test.tsx b/apps/desktop/src/renderer/webclient/adapter/__tests__/connectionsPane.test.tsx index 71a5fc981..10073ec27 100644 --- a/apps/desktop/src/renderer/webclient/adapter/__tests__/connectionsPane.test.tsx +++ b/apps/desktop/src/renderer/webclient/adapter/__tests__/connectionsPane.test.tsx @@ -62,7 +62,7 @@ describe("hosted Connections pane", () => { , ); - expect(await screen.findByText("No Macs yet. Choose Add machine to connect one.")).toBeTruthy(); + expect(await screen.findByText("No computers yet. Choose Add machine to connect one.")).toBeTruthy(); await expect(window.ade.remoteRuntime.getConnectionSnapshot()).resolves.toEqual({ connections: [], connectedCount: 0, diff --git a/apps/desktop/src/renderer/webclient/adapter/sessionLifecycleSupport.ts b/apps/desktop/src/renderer/webclient/adapter/sessionLifecycleSupport.ts index cf2b0e45c..ff98397b1 100644 --- a/apps/desktop/src/renderer/webclient/adapter/sessionLifecycleSupport.ts +++ b/apps/desktop/src/renderer/webclient/adapter/sessionLifecycleSupport.ts @@ -39,10 +39,10 @@ const REQUIRED_ACTIONS = [ ] as const; export const SESSION_LIFECYCLE_DISCONNECTED_MESSAGE = - "Can't reach this Mac right now, so nothing was changed."; + "Can't reach this computer right now, so nothing was changed."; export const SESSION_LIFECYCLE_UNSUPPORTED_MESSAGE = - "This Mac is running an older ADE that can't settle or snooze sessions."; + "This computer is running an older ADE that can't settle or snooze sessions."; export type SessionLifecycleUnavailableCode = "disconnected" | "unsupported"; diff --git a/apps/desktop/src/renderer/webclient/shell/shellTokens.ts b/apps/desktop/src/renderer/webclient/shell/shellTokens.ts index 940389c6f..be60d7aa9 100644 --- a/apps/desktop/src/renderer/webclient/shell/shellTokens.ts +++ b/apps/desktop/src/renderer/webclient/shell/shellTokens.ts @@ -34,12 +34,12 @@ export function connectionTone(state: SyncConnectionState): ConnectionTone { case "reconnecting": return { label: "Reconnecting", color: COLORS.warning, live: false, fatal: false }; case "auth_failed": - return { label: "Can't reach this Mac", color: COLORS.danger, live: false, fatal: true }; + return { label: "Can't reach this computer", color: COLORS.danger, live: false, fatal: true }; case "error": - return { label: "Can't reach this Mac", color: COLORS.danger, live: false, fatal: false }; + return { label: "Can't reach this computer", color: COLORS.danger, live: false, fatal: false }; case "disconnected": case "idle": default: - return { label: "Can't reach this Mac", color: COLORS.textMuted, live: false, fatal: false }; + return { label: "Can't reach this computer", color: COLORS.textMuted, live: false, fatal: false }; } } diff --git a/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts b/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts index ffcb3701f..e2e65f54e 100644 --- a/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts +++ b/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts @@ -1322,7 +1322,7 @@ describe("browser sync connection and client", () => { script.sockets[0]?.close(4501, "host offline"); await expect(outcome).resolves.toMatchObject({ - message: expect.stringContaining("Can't reach this Mac"), + message: expect.stringContaining("Can't reach this computer"), }); expect(script.sockets[0]?.sent).toEqual([]); expect(connection.getStatus().state).toBe("reconnecting"); @@ -2257,9 +2257,9 @@ describe("browser sync connection and client", () => { }); it.each([ - { code: 4501, expected: "Can't reach this Mac. Retrying…" }, - { code: 4507, expected: "Your Mac couldn't accept the connection. Retrying…" }, - { code: 4503, expected: "Too many active connections to this Mac" }, + { code: 4501, expected: "Can't reach this computer. Retrying…" }, + { code: 4507, expected: "Your computer couldn't accept the connection. Retrying…" }, + { code: 4503, expected: "Too many active connections to this computer" }, { code: 4502, expected: "Connection lost. Reconnecting." }, { code: 4000, expected: "Connection lost. Reconnecting." }, { code: 4505, expected: "Connection lost. Reconnecting." }, diff --git a/apps/desktop/src/renderer/webclient/sync/client.ts b/apps/desktop/src/renderer/webclient/sync/client.ts index be889e4b3..28094b01c 100644 --- a/apps/desktop/src/renderer/webclient/sync/client.ts +++ b/apps/desktop/src/renderer/webclient/sync/client.ts @@ -631,7 +631,7 @@ export class AdeSyncClient { ...transportStatus, state, error: this.readiness === "failed" - ? this.readinessError?.message ?? "Can't reach this Mac." + ? this.readinessError?.message ?? "Can't reach this computer." : transportStatus.error, activeProjectId: this.activeProjectId, selectedEnvId: this.selectedEnvId, @@ -785,7 +785,7 @@ export class AdeSyncClient { || this.terminalInputQueueBytes + byteLength > MAX_QUEUED_TERMINAL_INPUT_BYTES ) { throw new AdeSyncError( - "Terminal input is waiting for this Mac to catch up. Try again in a moment.", + "Terminal input is waiting for this computer to catch up. Try again in a moment.", "terminal_input_queue_full", ); } @@ -972,7 +972,7 @@ export class AdeSyncClient { this.latestHello = payload; if (!this.terminalInputAcksSupported() && this.terminalInputQueue.length > 0) { this.rejectTerminalInputQueue(new AdeSyncError( - "The reconnected Mac cannot safely confirm pending terminal input.", + "The reconnected computer cannot safely confirm pending terminal input.", "terminal_input_ack_unavailable", )); } @@ -1039,7 +1039,7 @@ export class AdeSyncClient { private async awaitCurrentRestoration(): Promise { const restoration = this.restorationPromise; if (!restoration) { - throw new AdeSyncError("The Mac connected without restoring the workspace.", "restoration_missing"); + throw new AdeSyncError("The computer connected without restoring the workspace.", "restoration_missing"); } await restoration; } @@ -1248,7 +1248,7 @@ export class AdeSyncClient { const rawError = (payload as { error?: unknown }).error; if (!rawError || typeof rawError !== "object") { this.failTerminalInput(operation, new AdeSyncError( - "This Mac returned an invalid terminal input response.", + "This computer returned an invalid terminal input response.", "terminal_input_invalid_ack", )); return; @@ -1277,7 +1277,7 @@ export class AdeSyncClient { this.failTerminalInput(operation, new AdeSyncError( typeof inputError.message === "string" && inputError.message.trim() ? inputError.message - : "This Mac rejected terminal input.", + : "This computer rejected terminal input.", `terminal_input_${code}`, { retryable }, )); @@ -1404,7 +1404,7 @@ export class AdeSyncClient { } if (operation.attempts >= this.terminalInputMaxAttempts) { this.failTerminalInput(operation, new AdeSyncError( - "This Mac did not confirm terminal input. Check the terminal before retrying.", + "This computer did not confirm terminal input. Check the terminal before retrying.", "terminal_input_ack_timeout", { attempts: operation.attempts }, )); @@ -1617,7 +1617,7 @@ export class AdeSyncClient { private requireReadyGeneration(): number { if (this.readiness !== "ready" || !this.connection.isConnected()) { - throw new AdeSyncError("Reconnecting to this Mac. Try again when connected.", "not_connected"); + throw new AdeSyncError("Reconnecting to this computer. Try again when connected.", "not_connected"); } return this.clientGeneration; } @@ -1626,7 +1626,7 @@ export class AdeSyncClient { return error instanceof AdeSyncError ? error : new AdeSyncError( - error instanceof Error ? error.message : "Connection to this Mac was lost.", + error instanceof Error ? error.message : "Connection to this computer was lost.", "not_connected", error, ); diff --git a/apps/desktop/src/renderer/webclient/sync/connection.ts b/apps/desktop/src/renderer/webclient/sync/connection.ts index 0992a49bb..ce4b43e2a 100644 --- a/apps/desktop/src/renderer/webclient/sync/connection.ts +++ b/apps/desktop/src/renderer/webclient/sync/connection.ts @@ -79,7 +79,7 @@ const FULL_INVALIDATION_TABLES = [ "rebase", ] as const; export const INVALIDATION_ONLY_V1_HOST_UPDATE_MESSAGE = - "Update ADE on this Mac via Settings > General > Check for Updates, then retry."; + "Update ADE on this computer via Settings > General > Check for Updates, then retry."; function invalidationTables(payload: SyncInvalidationBatchPayload): Set { if (!payload || typeof payload !== "object") return new Set(FULL_INVALIDATION_TABLES); @@ -215,9 +215,9 @@ function protocolVersionMismatchMessage( payload: Extract, ): string { if (payload.updateTarget === "host") { - return "Update ADE on your Mac to connect to this browser."; + return "Update ADE on your computer to connect to this browser."; } - return "Update ADE in this browser to connect to your Mac."; + return "Update ADE in this browser to connect to your computer."; } function hostAcceptedInvalidationOnlyV1(payload: SyncHelloOkPayload): boolean { @@ -1441,9 +1441,9 @@ export class SyncConnection { private errorForClose(event: Pick): SyncConnectionError { switch (event.code) { case 4501: - return new SyncConnectionError("Can't reach this Mac. Retrying…", "relay_host_offline"); + return new SyncConnectionError("Can't reach this computer. Retrying…", "relay_host_offline"); case 4507: - return new SyncConnectionError("Your Mac couldn't accept the connection. Retrying…", "relay_bridge_rejected"); + return new SyncConnectionError("Your computer couldn't accept the connection. Retrying…", "relay_bridge_rejected"); case 4508: return new SyncConnectionError("Connection setup expired. Reconnecting.", "relay_stale_pipe"); case 4509: @@ -1451,7 +1451,7 @@ export class SyncConnection { case 4510: return new SyncConnectionError("Connection was not ready. Reconnecting.", "relay_not_ready"); case 4503: - return new SyncConnectionError("Too many active connections to this Mac", "relay_capacity"); + return new SyncConnectionError("Too many active connections to this computer", "relay_capacity"); case 4502: return new SyncConnectionError("Connection lost. Reconnecting.", "relay_idle"); case 4505: diff --git a/apps/desktop/src/renderer/webclient/sync/wireProtocol.ts b/apps/desktop/src/renderer/webclient/sync/wireProtocol.ts index 7b4d8aaca..072151b17 100644 --- a/apps/desktop/src/renderer/webclient/sync/wireProtocol.ts +++ b/apps/desktop/src/renderer/webclient/sync/wireProtocol.ts @@ -32,8 +32,8 @@ export class BrowserSyncProtocolVersionMismatchError extends Error { : `${SYNC_PROTOCOL_MIN_SUPPORTED}-${SYNC_PROTOCOL_VERSION}`; const updateTarget = receivedVersion < SYNC_PROTOCOL_MIN_SUPPORTED ? "host" : "client"; super(updateTarget === "host" - ? `Update ADE on your Mac. It uses sync protocol ${receivedVersion}; this browser supports ${supported}.` - : `Update ADE in this browser. The Mac uses sync protocol ${receivedVersion}; this browser supports ${supported}.`); + ? `Update ADE on your computer. It uses sync protocol ${receivedVersion}; this browser supports ${supported}.` + : `Update ADE in this browser. The computer uses sync protocol ${receivedVersion}; this browser supports ${supported}.`); this.name = "BrowserSyncProtocolVersionMismatchError"; this.updateTarget = updateTarget; } diff --git a/apps/desktop/src/shared/cliLaunch.ts b/apps/desktop/src/shared/cliLaunch.ts index af778a4e2..730e0d223 100644 --- a/apps/desktop/src/shared/cliLaunch.ts +++ b/apps/desktop/src/shared/cliLaunch.ts @@ -8,6 +8,7 @@ import type { TerminalResumeMetadata, TerminalSessionSummary, TerminalToolType, + WindowsShellKind, } from "./types"; import { ADE_AGENT_SKILLS_DIRS_ENV, @@ -53,10 +54,9 @@ function unquoteWindowsShellPath(value: string | null | undefined): string { return trimmed; } -type WindowsShellKind = "powershell" | "cmd" | "git-bash"; export type WindowsShellLaunchMode = "interactive" | "clean" | "login"; -function windowsShellKind(command: string): WindowsShellKind | null { +export function resolveWindowsShellKind(command: string): WindowsShellKind | null { const normalized = command.replace(/\//g, "\\"); if (/^\\\\(?:wsl\$|wsl\.localhost)\\/i.test(normalized)) return null; const basename = normalized.split("\\").pop()?.toLowerCase() ?? ""; @@ -85,7 +85,7 @@ export function resolveWindowsShellLaunchFields( ): CleanShellLaunchFields | null { const command = unquoteWindowsShellPath(value); if (!command) return null; - const kind = windowsShellKind(command); + const kind = resolveWindowsShellKind(command); const mode = options.mode ?? "interactive"; if (kind === "powershell") { return { @@ -357,7 +357,7 @@ export function resolveCleanShellLaunchFields(args: { // ComSpec is normally cmd.exe even when ADE was launched from PowerShell. // Preserve PowerShell as ADE's default, while still honoring an explicitly // configured PowerShell executable in ComSpec. - if (comSpec && windowsShellKind(comSpec.command) === "powershell") { + if (comSpec && resolveWindowsShellKind(comSpec.command) === "powershell") { return comSpec; } return { command: "powershell.exe", args: ["-NoLogo", "-NoProfile"] }; @@ -622,7 +622,18 @@ export function buildTrackedCliLaunchCommand(args: { ...codexComputerUseMcpFlags(args.codexComputerUse), ...permissionModeToCodexFlags(permissionMode), ]; - const usePromptArg = codexModel === "gpt-5.3-codex"; + // The launcher is the bare command `codex`, which on Windows has no + // extension and therefore always has to be spawned through + // `cmd.exe /d /s /c "…"`. cmd rewrites the command line before Codex sees + // it: `%` is doubled, `%NAME%` is expanded, newlines collapse to spaces, + // and the whole line is capped at ~8191 characters. The work-tab prompt is + // a ~2.4KB multi-line ADE preamble with the user's text appended, so + // passing it as argv corrupts it on every Windows launch. Fall back to the + // post-launch input path that every other Codex model already uses. + const platform = typeof process !== "undefined" && typeof process.platform === "string" + ? process.platform + : ""; + const usePromptArg = codexModel === "gpt-5.3-codex" && platform !== "win32"; if (usePromptArg) commandArgs.push(initialInput); return { command: "codex", diff --git a/apps/desktop/src/shared/cursorProjectSlug.ts b/apps/desktop/src/shared/cursorProjectSlug.ts new file mode 100644 index 000000000..7d1ec1ce5 --- /dev/null +++ b/apps/desktop/src/shared/cursorProjectSlug.ts @@ -0,0 +1,28 @@ +/** + * Cursor names its per-workspace directory under `~/.cursor/projects/` by + * slugging the absolute workspace path. This is a byte-for-byte reimplementation + * of the slug function Cursor ships in `@cursor/sdk` (1.0.23, + * `../utils/dist/index.js`): + * + * function slug(p) { + * return p.replace(/[^a-zA-Z0-9]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, ""); + * } + * function projectDir(home, workspacePath) { + * return `${home}/.cursor/projects/${slug(workspacePath)}`; + * } + * + * Every non-alphanumeric character becomes `-`, runs collapse, and leading and + * trailing dashes are trimmed. Do not "improve" this: matching Cursor exactly is + * the whole point. In particular: + * - `_` and `.` become `-`, they are not preserved or dropped; + * - a Windows drive letter survives as its own segment, so + * `C:\Users\me\repo` is `C-Users-me-repo` (the colon and backslashes each + * become a dash and then collapse), while `/Users/me/repo` is + * `Users-me-repo` because the leading dash is trimmed. + */ +export function cursorProjectSlug(workspacePath: string): string { + return workspacePath + .replace(/[^a-zA-Z0-9]/gu, "-") + .replace(/-+/gu, "-") + .replace(/^-+|-+$/gu, ""); +} diff --git a/apps/desktop/src/shared/laneDivergence.test.ts b/apps/desktop/src/shared/laneDivergence.test.ts index 6bcccb1a7..5c6869908 100644 --- a/apps/desktop/src/shared/laneDivergence.test.ts +++ b/apps/desktop/src/shared/laneDivergence.test.ts @@ -22,7 +22,7 @@ function machine(overrides: Partial = {}): MachineBranchStat const current = machine({ machineId: "machine-this", - machineName: "This Mac", + machineName: "This computer", headSha: "aaaaaaa", ahead: 1, behind: 0, @@ -176,13 +176,13 @@ describe("toMachineBranchState", () => { expect( toMachineBranchState({ machineId: "machine-this", - machineName: "This Mac", + machineName: "This computer", lane: { branchRef: "feature/x", status: { ahead: 2, behind: 1 } }, headSha: "aaaaaaa", }), ).toEqual({ machineId: "machine-this", - machineName: "This Mac", + machineName: "This computer", branchRef: "feature/x", headSha: "aaaaaaa", ahead: 2, @@ -194,12 +194,12 @@ describe("toMachineBranchState", () => { expect( toMachineBranchState({ machineId: "machine-this", - machineName: "This Mac", + machineName: "This computer", lane: { branchRef: "feature/x" }, }), ).toEqual({ machineId: "machine-this", - machineName: "This Mac", + machineName: "This computer", branchRef: "feature/x", headSha: null, ahead: 0, @@ -268,7 +268,7 @@ describe("detectPushDivergence over real lane snapshots", () => { detectPushDivergence({ current: toMachineBranchState({ machineId: "this-mac", - machineName: "This Mac", + machineName: "This computer", lane: here.lane, }), others: [ @@ -297,7 +297,7 @@ describe("detectPushDivergence over real lane snapshots", () => { detectPushDivergence({ current: toMachineBranchState({ machineId: "this-mac", - machineName: "This Mac", + machineName: "This computer", lane: here.lane, }), others: [ @@ -315,7 +315,7 @@ describe("detectPushDivergence over real lane snapshots", () => { const here = laneSnapshot(); const current = toMachineBranchState({ machineId: "this-mac", - machineName: "This Mac", + machineName: "This computer", lane: here.lane, }); diff --git a/apps/desktop/src/shared/laneDivergence.ts b/apps/desktop/src/shared/laneDivergence.ts index b8ca89405..5d7f2be3b 100644 --- a/apps/desktop/src/shared/laneDivergence.ts +++ b/apps/desktop/src/shared/laneDivergence.ts @@ -19,7 +19,7 @@ export type MachineBranchState = { machineId: string; /** - * Absolute machine name as shown to the user ("This Mac", "MacBook Pro (97)"). + * Absolute machine name as shown to the user ("This computer", "MacBook Pro (97)"). * Never a relative word like "remote" — the user has to know *which* machine. */ machineName: string; diff --git a/apps/desktop/src/shared/providerPlatformSupport.test.ts b/apps/desktop/src/shared/providerPlatformSupport.test.ts new file mode 100644 index 000000000..ac188c9e8 --- /dev/null +++ b/apps/desktop/src/shared/providerPlatformSupport.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { + CURSOR_WINDOWS_ARM_BLOCKER, + isCursorProviderSupported, +} from "./providerPlatformSupport"; + +// Platform/arch are passed as literals, so this file asserts the same thing on +// every runner — it is not a platform-gated test and needs no gate annotation. +describe("isCursorProviderSupported", () => { + it("hides Cursor only on win32-arm64, where @cursor/sdk has no build", () => { + expect(isCursorProviderSupported("win32", "arm64")).toBe(false); + }); + + it("leaves Windows x64 untouched", () => { + expect(isCursorProviderSupported("win32", "x64")).toBe(true); + expect(isCursorProviderSupported("win32", "ia32")).toBe(true); + }); + + it("leaves macOS untouched on both architectures", () => { + expect(isCursorProviderSupported("darwin", "arm64")).toBe(true); + expect(isCursorProviderSupported("darwin", "x64")).toBe(true); + }); + + it("leaves Linux untouched, including arm64", () => { + expect(isCursorProviderSupported("linux", "arm64")).toBe(true); + expect(isCursorProviderSupported("linux", "x64")).toBe(true); + }); + + it("names the reason so the gate can be revisited when Cursor ships a build", () => { + expect(CURSOR_WINDOWS_ARM_BLOCKER).toMatch(/win32-arm64/); + expect(CURSOR_WINDOWS_ARM_BLOCKER).toMatch(/@cursor\/sdk/); + }); +}); diff --git a/apps/desktop/src/shared/providerPlatformSupport.ts b/apps/desktop/src/shared/providerPlatformSupport.ts new file mode 100644 index 000000000..9613dfb9b --- /dev/null +++ b/apps/desktop/src/shared/providerPlatformSupport.ts @@ -0,0 +1,40 @@ +/** + * Per-platform availability of agent providers. + * + * Only Cursor is gated today. Claude, Codex, Droid and OpenCode all ship + * runtimes for every target ADE builds, so they are unconditionally supported + * and deliberately absent from this module. + */ + +/** + * `@cursor/sdk` publishes its native runtime as optional platform packages. + * As of `@cursor/sdk@1.0.23` those are `darwin-arm64`, `darwin-x64`, + * `linux-arm64`, `linux-x64` and `win32-x64` — there is no `win32-arm64`. + * ADE's Cursor provider is built entirely on that SDK, so on Windows on ARM the + * provider cannot load at all: every chat, model discovery and auth probe would + * fail at `import("@cursor/sdk")`. + * + * Rather than ship a provider that is present and permanently broken, ADE hides + * Cursor on that one target. This is an SDK packaging gap, not a Cursor gap — + * Cursor's own CLI installer does support Windows ARM64. + * + * REVISIT: when `@cursor/sdk` adds a `win32-arm64` optional dependency, delete + * this gate along with its callers and its `scripts/platform-gate-baseline.json` + * entry. Check with: + * npm view @cursor/sdk optionalDependencies + */ +export const CURSOR_WINDOWS_ARM_BLOCKER = + "Cursor is not available on Windows on ARM — @cursor/sdk has no win32-arm64 build. " + + "Use Claude, Codex, Droid or OpenCode on this machine."; + +/** + * Returns false only on win32/arm64. Every other platform/arch pair — including + * win32/x64 and all of darwin — is unchanged. + * + * Both arguments are injectable so callers in the main process can pass + * `process.platform`/`process.arch` and tests can pass whatever they like; the + * renderer passes the values the preload bridge captured from the main process. + */ +export function isCursorProviderSupported(platform: string, arch: string): boolean { + return !(platform === "win32" && arch === "arm64"); +} diff --git a/apps/desktop/src/shared/shell.ts b/apps/desktop/src/shared/shell.ts index 861f5b0a9..d931dd4cb 100644 --- a/apps/desktop/src/shared/shell.ts +++ b/apps/desktop/src/shared/shell.ts @@ -63,6 +63,163 @@ export function commandArrayToLine(command: string[], options: { platform?: Shel return command.map((arg) => quoteShellArg(arg, options)).join(" "); } +/** + * The native Windows shells ADE can land in have mutually incompatible quoting + * rules, so a command line is only meaningful together with the shell that will + * receive it. `kind` picks the ruleset; `shellPath` and `command` refine the two + * places where PowerShell's behaviour depends on more than the shell family. + */ +export type WindowsShellLineTarget = { + kind: "powershell" | "cmd" | "git-bash"; + /** The shell executable, used to tell Windows PowerShell 5.1 from pwsh 7+. */ + shellPath?: string | null; +}; + +function isWindowsPowerShellFive(shellPath: string | null | undefined): boolean { + const basename = String(shellPath ?? "powershell.exe") + .replace(/\//g, "\\") + .split("\\") + .pop() + ?.toLowerCase() ?? ""; + // pwsh is PowerShell 7+, which builds native command lines correctly. + // Anything else reaching the powershell kind is Windows PowerShell 5.1. + return basename !== "pwsh" && basename !== "pwsh.exe"; +} + +/** + * Windows PowerShell 5.1 builds a native process's command line by wrapping + * arguments that contain whitespace in double quotes, *without* escaping quotes + * already inside the value — so `-c model_reasoning_effort="high"` reaches the + * callee as `model_reasoning_effort=high`. Pre-escaping to CRT rules makes the + * value survive that pass. pwsh 7+ escapes correctly on its own and must not be + * pre-escaped, except when the target is a `.cmd`/`.bat` shim, for which it + * deliberately reverts to the 5.1 behaviour. + */ +function powerShellLegacyNativeValue(value: string): string { + const escaped = value.replace(/(\\*)"/gu, "$1$1\\\""); + return /\s/u.test(escaped) ? escaped.replace(/(\\*)$/u, "$1$1") : escaped; +} + +function quotePowerShellArg(value: string, legacy: boolean): string { + // PowerShell 5.1 drops a bare "" native argument; a literal '""' survives. + if (!value.length) return legacy ? "'\"\"'" : "\"\""; + const native = legacy ? powerShellLegacyNativeValue(value) : value; + const escaped = native + .replace(/`/gu, "``") + .replace(/\$/gu, "`$") + .replace(/"/gu, "`\"") + .replace(/\r/gu, "`r") + .replace(/\n/gu, "`n") + .replace(/\t/gu, "`t"); + return `"${escaped}"`; +} + +/** + * A quoted path is a string expression in PowerShell, not an invocation, so + * `'C:\…\opencode.exe' serve` is a parse error rather than a launch. Anything + * that has to be quoted therefore needs the call operator. + */ +function needsPowerShellCallOperator(command: string): boolean { + return !/^[A-Za-z0-9_.:@+=,\\/-]+$/u.test(command); +} + +function commandArrayToPowerShellLine(command: readonly string[], shellPath: string | null | undefined): string { + const [executable, ...rest] = command; + if (executable == null) return ""; + const legacy = isWindowsPowerShellFive(shellPath) || /\.(?:cmd|bat)$/iu.test(executable); + const head = needsPowerShellCallOperator(executable) + ? `& ${quotePowerShellArg(executable, legacy)}` + : executable; + return [head, ...rest.map((arg) => quotePowerShellArg(arg, legacy))].join(" "); +} + +/** + * cmd.exe expands `%NAME%` before the callee sees argv and offers no escape for + * `%` inside a quoted argument — `%%` and `%^` both survive literally there, so + * neither can suppress the expansion. Newlines cannot be represented on a cmd + * command line at all. cmd is ADE's last-resort shell, reached only when both + * powershell.exe and pwsh.exe fail to spawn, and callers keep user prompts off + * the command line for exactly this reason; what remains is quoted to CRT rules + * so at least spaces, quotes and backslashes survive. + */ +function commandArrayToCmdLine(command: readonly string[]): string { + return command.map((arg) => quoteWindowsArg(arg.replace(/[\r\n]/gu, " "))).join(" "); +} + +/** + * Render an argv array as a command line for one specific native Windows shell. + * Use this at the point the line is written into a terminal, where the receiving + * shell is known — never against a platform constant. + */ +export function commandArrayToWindowsShellLine( + command: readonly string[], + target: WindowsShellLineTarget, +): string { + if (!command.length) return ""; + if (target.kind === "cmd") return commandArrayToCmdLine(command); + if (target.kind === "git-bash") return commandArrayToLine([...command], { platform: "linux" }); + return commandArrayToPowerShellLine(command, target.shellPath); +} + +/** + * A startup command is ADE's canonical POSIX rendering of a launch: it is what + * gets persisted, displayed, and — on macOS — handed to `/bin/bash -lc`, whose + * quoting rules it matches. This recovers the argv behind such a line so a + * caller can spawn it directly instead of typing it at a shell whose rules it + * does not match. + * + * The line is only accepted when re-rendering the recovered argv reproduces it + * byte for byte. That makes the recovery lossless by construction and rejects + * anything that is not plain argv — pipelines, redirections, `&&` chains — as + * well as any line a producer emitted under some other quoting ruleset, rather + * than silently mis-splitting it. + */ +export type CanonicalCommandLineLaunch = { + command: string; + args: string[]; + env?: Record; +}; + +const ENV_ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)=([\s\S]*)$/u; + +export function resolveCanonicalCommandLineLaunch(commandLine: string): CanonicalCommandLineLaunch | null { + const trimmed = commandLine.trim(); + if (!trimmed) return null; + + let parsed: string[]; + try { + parsed = parseCommandLine(trimmed, { platform: "linux" }); + } catch { + return null; + } + + // A POSIX line may open with `NAME=value` assignments that scope environment + // to the command. Those are shell syntax, not argv, so lift them out. + const env: Record = {}; + let index = 0; + while (index < parsed.length) { + const match = ENV_ASSIGNMENT.exec(parsed[index]!); + if (!match) break; + env[match[1]!] = match[2]!; + index += 1; + } + const argv = parsed.slice(index); + const executable = argv[0]; + if (!executable) return null; + + const rendered = [ + ...Object.entries(env).map(([key, value]) => `${key}=${quoteShellArg(value, { platform: "linux" })}`), + commandArrayToLine(argv, { platform: "linux" }), + ].join(" "); + if (rendered !== trimmed) return null; + + return { + command: executable, + args: argv.slice(1), + ...(Object.keys(env).length ? { env } : {}), + }; +} + /** Parse a shell-like command line into an array of arguments. */ export function parseCommandLine(input: string, options: { platform?: ShellPlatform } = {}): string[] { if (isWindowsPlatform(options.platform)) return parseWindowsCommandLine(input); @@ -112,6 +269,16 @@ export function parseCommandLine(input: string, options: { platform?: ShellPlatf currentStarted = true; continue; } + // ANSI-C quoting. quoteShellArg emits this for any argument containing a + // control character, so a parser that cannot read it back cannot round-trip + // the very arguments — multi-line prompts — that most need recovering. + if (ch === "$" && input[i + 1] === "'") { + const decoded = parseAnsiCQuoted(input, i + 2); + current += decoded.value; + currentStarted = true; + i = decoded.end; + continue; + } if (ch === "'" || ch === "\"") { quote = ch; currentStarted = true; @@ -135,6 +302,42 @@ export function parseCommandLine(input: string, options: { platform?: ShellPlatf return out; } +const ANSI_C_ESCAPES: Record = { + n: "\n", + r: "\r", + t: "\t", + v: "\v", + f: "\f", + a: "\u0007", + b: "\b", + e: "\u001b", + "0": "\0", +}; + +/** Decode a `$'…'` body starting at `start`; returns the index of its closing quote. */ +function parseAnsiCQuoted(input: string, start: number): { value: string; end: number } { + let value = ""; + let index = start; + while (index < input.length) { + const ch = input[index]!; + if (ch === "\\") { + const next = input[index + 1]; + if (next == null) { + value += "\\"; + index += 1; + continue; + } + value += ANSI_C_ESCAPES[next] ?? next; + index += 2; + continue; + } + if (ch === "'") return { value, end: index }; + value += ch; + index += 1; + } + throw new Error("Unclosed quote in command line"); +} + function parseWindowsCommandLine(input: string): string[] { const out: string[] = []; let current = ""; diff --git a/apps/desktop/src/shared/types/account.ts b/apps/desktop/src/shared/types/account.ts index b1913e054..f5ef3c562 100644 --- a/apps/desktop/src/shared/types/account.ts +++ b/apps/desktop/src/shared/types/account.ts @@ -26,8 +26,20 @@ export type AdeAccountStatus = { * unavailable. Lets the UI explain "not configured" instead of failing hard. */ configured?: boolean; + /** + * Why `signedIn` is false. "missing" is a real signed-out machine; + * "unreadable" means the stored session could not be decrypted on this read + * (on Windows the OS-bound key comes from a PowerShell/DPAPI helper that can + * transiently time out) and says NOTHING about whether the user is signed in. + * The brain's directory publisher has always split these two — the desktop + * dropped the distinction and rendered every failed read as a sign-out. + */ + sessionReadState?: AdeAccountSessionReadState; }; +/** Mirrors the daemon's `AccountSessionReadState`. */ +export type AdeAccountSessionReadState = "available" | "missing" | "unreadable"; + export type AdeAccountLoginStart = { sessionId: string; authorizeUrl: string; @@ -76,7 +88,7 @@ export type AdeAccountMachinesResult = { message: string | null; }; -/** Stable identities used to recognize this Mac in the account directory. */ +/** Stable identities used to recognize this computer in the account directory. */ export type AdeAccountLocalMachineIdentity = { machineKey: string; deviceId: string; diff --git a/apps/desktop/src/shared/types/sessions.ts b/apps/desktop/src/shared/types/sessions.ts index 8277dfaff..ef588a2d1 100644 --- a/apps/desktop/src/shared/types/sessions.ts +++ b/apps/desktop/src/shared/types/sessions.ts @@ -291,6 +291,8 @@ export type ClaudeSessionPointer = { updatedAt: string; }; +export type WindowsShellKind = "powershell" | "cmd" | "git-bash"; + export type PtyCreateArgs = { sessionId?: string; /** Allow callers to pre-assign a new session id instead of only resuming an existing tracked session. */ @@ -309,6 +311,12 @@ export type PtyCreateArgs = { tracked?: boolean; toolType?: TerminalToolType | null; startupCommand?: string; + /** + * Shell-specific variants of startupCommand. On Windows the PTY selects the + * entry only after a native shell has spawned, so fallback shell selection + * cannot accidentally receive another shell's syntax. + */ + windowsStartupCommands?: Partial>; startupDelayMs?: number; /** Optional input to send to the PTY after the process starts. */ initialInput?: string; diff --git a/apps/ios/ADE/App/ContentView.swift b/apps/ios/ADE/App/ContentView.swift index 36d4d8e13..5a70164cc 100644 --- a/apps/ios/ADE/App/ContentView.swift +++ b/apps/ios/ADE/App/ContentView.swift @@ -66,7 +66,7 @@ struct ContentView: View { guard requestId != nil else { return } // A linear-issue deep link opens the global pane (it consumes the // request once presented). Only reached when a project is active — the - // router bounces the link to the Mac otherwise. + // router bounces the link to the computer otherwise. syncService.closeProjectHub() syncService.linearPanePresented = true } diff --git a/apps/ios/ADE/App/DeepLinkRouter.swift b/apps/ios/ADE/App/DeepLinkRouter.swift index ee8138856..04b208321 100644 --- a/apps/ios/ADE/App/DeepLinkRouter.swift +++ b/apps/ios/ADE/App/DeepLinkRouter.swift @@ -94,7 +94,7 @@ final class DeepLinkRouter { ) case "lane": // Lanes are a local-only desktop concept — the iOS client has no - // counterpart UI, so we surface a "Send to your Mac" card instead of + // counterpart UI, so we surface a "Send to your computer" card instead of // trying to navigate. guard let laneId = pathComponents.first, ADEDeepLinkURLParsing.isValidUUID(laneId) else { return } diff --git a/apps/ios/ADE/Services/SSHBootstrapModels.swift b/apps/ios/ADE/Services/SSHBootstrapModels.swift index cdc048a2a..ec9eadf8d 100644 --- a/apps/ios/ADE/Services/SSHBootstrapModels.swift +++ b/apps/ios/ADE/Services/SSHBootstrapModels.swift @@ -66,7 +66,7 @@ struct SSHBootstrapResponse: Decodable, Equatable { guard ok else { throw SSHBootstrapError.remote( code: error?.code ?? "pairing_failed", - message: error?.message ?? "The Mac could not complete setup." + message: error?.message ?? "The computer could not complete setup." ) } guard let machine, let pairing, let sync, @@ -138,20 +138,20 @@ enum SSHBootstrapError: LocalizedError, Equatable { var errorDescription: String? { switch self { - case .invalidHost: "Enter a Mac address." + case .invalidHost: "Enter a computer address." case .invalidPort: "Enter an SSH port from 1 to 65535." - case .invalidUsername: "Enter the macOS username used for SSH." + case .invalidUsername: "Enter the macOS or Linux username used for SSH." case .invalidPrivateKey: "Paste or import a supported private key." case .unsupportedKey(let detail): detail case .passphraseRequired: "This private key needs its passphrase." case .incorrectPassphrase: "The private-key passphrase is incorrect." - case .hostKeyNotConfirmed: "Compare the Mac's SSH fingerprint, then confirm that it matches." + case .hostKeyNotConfirmed: "Compare the computer's SSH fingerprint, then confirm that it matches." case .hostKeyChanged(let expected, let received): - "This Mac's SSH fingerprint changed. Expected \(expected), but received \(received). Check the fingerprint on the Mac before trying again." - case .cliUnavailable: "ADE is not installed for this user on the Mac. Install ADE, then try again." - case .invalidResponse: "The Mac returned an unexpected response. Update ADE on the Mac, then try again." - case .responseTooLarge: "The Mac returned an unexpectedly large response. Update ADE on the Mac, then try again." - case .timedOut: "The Mac took too long to finish setup. Make sure ADE is open on the Mac, then try again." + "This computer's SSH fingerprint changed. Expected \(expected), but received \(received). Check the fingerprint on the computer before trying again." + case .cliUnavailable: "ADE is not installed for this user on the computer. Install ADE, then try again." + case .invalidResponse: "The computer returned an unexpected response. Update ADE on the computer, then try again." + case .responseTooLarge: "The computer returned an unexpectedly large response. Update ADE on the computer, then try again." + case .timedOut: "The computer took too long to finish setup. Make sure ADE is open on the computer, then try again." case .remote(_, let message): message } } diff --git a/apps/ios/ADE/Services/SSHBootstrapService.swift b/apps/ios/ADE/Services/SSHBootstrapService.swift index 6c4c69fcb..54ba35723 100644 --- a/apps/ios/ADE/Services/SSHBootstrapService.swift +++ b/apps/ios/ADE/Services/SSHBootstrapService.swift @@ -85,7 +85,7 @@ actor SSHBootstrapService { username: input.normalizedUsername ) } catch { - validated.credentialWarning = "Your Mac is paired, but ADE could not save the SSH key. You can pair again later if you need SSH recovery." + validated.credentialWarning = "Your computer is paired, but ADE could not save the SSH key. You can pair again later if you need SSH recovery." } } else { credentialStore.remove(host: input.normalizedHost, port: input.port, username: input.normalizedUsername) diff --git a/apps/ios/ADE/Services/SyncRecoveryPolicy.swift b/apps/ios/ADE/Services/SyncRecoveryPolicy.swift index 359ee2d95..45571de50 100644 --- a/apps/ios/ADE/Services/SyncRecoveryPolicy.swift +++ b/apps/ios/ADE/Services/SyncRecoveryPolicy.swift @@ -334,11 +334,11 @@ func syncSocketCloseError(closeCodeRawValue: Int, reason: String?) -> NSError { case 4004: message = "Connection attempts are paused briefly. Try again shortly." case 4503: - message = "This Mac is handling too many connections. Try again shortly." + message = "This computer is handling too many connections. Try again shortly." case 4000, 4001, 4002, 4008, 4501, 4502, 4505, 4506, 4507: - message = "Can’t reach this Mac right now. Reconnecting now." + message = "Can’t reach this computer right now. Reconnecting now." default: - message = "Can’t reach this Mac right now. Reconnecting now." + message = "Can’t reach this computer right now. Reconnecting now." } var userInfo: [String: Any] = [ NSLocalizedDescriptionKey: message, diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index ae1bb5eb0..6230f03b6 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -440,13 +440,13 @@ struct AccountAdoptionIdentityVerificationError: LocalizedError, Equatable { let machineName: String var errorDescription: String? { - "Couldn't verify that \(machineName)'s identity. Open ADE on that Mac and try again." + "Couldn't verify that \(machineName)'s identity. Open ADE on that computer and try again." } } /// A route this build cannot negotiate -- today, a host that named an adoption /// cipher this client does not implement. That is a version gap, not evidence -/// the Mac is an impostor, so it fails only its own route: another route (or +/// the computer is an impostor, so it fails only its own route: another route (or /// another host build) may negotiate fine, and the user needs "update", not a /// security warning. The unsupported cipher itself is still never used. struct AccountAdoptionRouteCompatibilityError: LocalizedError, Equatable { @@ -959,9 +959,9 @@ enum SyncRelayAuthorizationRequirement: String, Equatable, Error, LocalizedError var errorDescription: String? { switch self { case .signInRequired: - return "Sign in to the same ADE account as this Mac to connect from another network. LAN and Tailscale still work without an account." + return "Sign in to the same ADE account as this computer to connect from another network. LAN and Tailscale still work without an account." case .sameAccountRequired: - return "This Mac's internet connection belongs to another ADE account. Sign in with the same account as the Mac, or connect over LAN or Tailscale." + return "This computer's internet connection belongs to another ADE account. Sign in with the same account as the computer, or connect over LAN or Tailscale." } } } @@ -1609,8 +1609,8 @@ struct SyncProtocolVersionMismatchError: LocalizedError, Equatable { var errorDescription: String? { updateTarget == "host" - ? "Update ADE on your Mac. It uses sync protocol \(receivedVersion); this iPhone supports \(minSupportedVersion)-\(currentVersion)." - : "Update ADE on this iPhone. The Mac uses sync protocol \(receivedVersion); this iPhone supports \(minSupportedVersion)-\(currentVersion)." + ? "Update ADE on your computer. It uses sync protocol \(receivedVersion); this iPhone supports \(minSupportedVersion)-\(currentVersion)." + : "Update ADE on this iPhone. The computer uses sync protocol \(receivedVersion); this iPhone supports \(minSupportedVersion)-\(currentVersion)." } } @@ -1626,8 +1626,8 @@ func syncProtocolMismatchMessage(_ payload: [String: Any]) -> String { versions = "" } return target == "host" - ? "Update ADE on your Mac to connect this iPhone.\(versions)" - : "Update ADE on this iPhone to connect to your Mac.\(versions)" + ? "Update ADE on your computer to connect this iPhone.\(versions)" + : "Update ADE on this iPhone to connect to your computer.\(versions)" } func syncProtocolVersionNumber(_ value: Any?) -> Int? { @@ -2422,7 +2422,7 @@ enum SyncUserFacingError { return "This phone no longer has a saved address for this machine. Open Settings to rediscover it or pair again." } if lowered.contains("the host is offline") || lowered.contains("requires a live connection to the host") { - return "Can’t reach this Mac right now." + return "Can’t reach this computer right now." } if lowered.contains("the host returned incomplete") { return "The machine sent incomplete sync data. Retry the affected area or reconnect the machine." @@ -3104,13 +3104,13 @@ func workStartShellSessionRequest( struct AccountPairingAuthorizationChangedError: LocalizedError, Equatable { var errorDescription: String? { - "Your ADE account changed while this Mac was connecting. Sign in, then try again." + "Your ADE account changed while this computer was connecting. Sign in, then try again." } } struct AccountPairingConnectionSupersededError: LocalizedError, Equatable { var errorDescription: String? { - "A newer Mac connection replaced this attempt." + "A newer computer connection replaced this attempt." } } @@ -3297,13 +3297,13 @@ final class SyncService: ObservableObject { connectionState == .connected || connectionState == .syncing } - /// Human-facing name of the connected machine, or a neutral "your Mac" + /// Human-facing name of the connected machine, or a neutral "your computer" /// fallback. Shared by Linear connect/status copy (and available to other /// surfaces that otherwise re-derive the same fallback). var machineDisplayName: String { let trimmed = hostName?.trimmingCharacters(in: .whitespacesAndNewlines) if let trimmed, !trimmed.isEmpty { return trimmed } - return "your Mac" + return "your computer" } /// Whether this phone currently holds a Tailscale-assigned address on a /// tunnel interface. Drives the "iPhone isn't on Tailscale" connection hint. @@ -4453,7 +4453,7 @@ final class SyncService: ObservableObject { } guard canSendLiveRequests() else { throw NSError(domain: "ADE", code: 14, userInfo: [ - NSLocalizedDescriptionKey: "Can’t reach this Mac right now." + NSLocalizedDescriptionKey: "Can’t reach this computer right now." ]) } } @@ -5995,7 +5995,7 @@ final class SyncService: ObservableObject { return preprocessed.payload case "account_challenge_error": let message = syncNonEmpty((preprocessed.payload as? [String: Any])?["message"] as? String) - ?? "That route could not verify the Mac's identity." + ?? "That route could not verify the computer's identity." throw NSError( domain: "ADE.AdoptChannel", code: 6, @@ -6148,7 +6148,7 @@ final class SyncService: ObservableObject { "supportedAeads": AdoptChannelCrypto.supportedAeads.map(\.rawValue), ], timeoutNanoseconds: AdoptChannelCrypto.challengeTimeoutNanoseconds, - timeoutMessage: "That Mac did not answer the secure identity challenge.", + timeoutMessage: "That computer did not answer the secure identity challenge.", relayAccountOwnerId: nil ) guard isCurrentCandidate() else { @@ -6311,7 +6311,7 @@ final class SyncService: ObservableObject { "auth": auth, ], timeoutNanoseconds: SyncConnectionRaceTiming.overallBudgetNanoseconds, - timeoutMessage: "That Mac did not finish account connection. Try again.", + timeoutMessage: "That computer did not finish account connection. Try again.", relayAccountOwnerId: owner ) guard isCurrentCandidate() else { @@ -6703,7 +6703,7 @@ final class SyncService: ObservableObject { // learning even though we are not redialling. if var existing = activeHostProfile { guard existing.accountOwnerId == nil || existing.accountOwnerId == owner else { - lastError = "This saved Mac belongs to a different signed-in account." + lastError = "This saved computer belongs to a different signed-in account." connectionState = .error ProductAnalytics.shared.captureMachineAdoptionOutcome(.failed) return false @@ -6750,7 +6750,7 @@ final class SyncService: ObservableObject { && tokenForProfile(profile) != nil }) { guard existing.accountOwnerId == nil || existing.accountOwnerId == owner else { - lastError = "This saved Mac belongs to a different signed-in account." + lastError = "This saved computer belongs to a different signed-in account." connectionState = .error ProductAnalytics.shared.captureMachineAdoptionOutcome(.failed) return false @@ -6804,9 +6804,9 @@ final class SyncService: ObservableObject { ) guard !routes.isEmpty else { if signingPublicKey == nil { - lastError = "That Mac is not ready for account connection yet. Open ADE on the Mac and try again." + lastError = "That computer is not ready for account connection yet. Open ADE on the computer and try again." } else { - lastError = "That Mac did not advertise a secure account connection route. Open ADE on the Mac and try again." + lastError = "That computer did not advertise a secure account connection route. Open ADE on the computer and try again." } connectionState = .error ProductAnalytics.shared.captureMachineAdoptionOutcome(.failed) @@ -6924,7 +6924,7 @@ final class SyncService: ObservableObject { throw NSError( domain: "ADE", code: 33, - userInfo: [NSLocalizedDescriptionKey: "This Mac would not hand back a connection for this iPhone. Open ADE on the Mac, remove this iPhone under Settings → Devices, then connect again."] + userInfo: [NSLocalizedDescriptionKey: "This computer would not hand back a connection for this iPhone. Open ADE on the computer, remove this iPhone under Settings → Devices, then connect again."] ) } @@ -7092,7 +7092,7 @@ final class SyncService: ObservableObject { // A blocked navigation used to abort with nothing on screen: the tap // simply did not work. Record it the same way a failed connect does so // the reason is available to whatever surface the user is looking at. - let message = "That Mac is not available in your ADE account." + let message = "That computer is not available in your ADE account." lastError = message lastConnectAttemptFailure = SyncConnectAttemptFailure(message: message) return false @@ -7110,7 +7110,7 @@ final class SyncService: ObservableObject { return true } guard let authorization = AccountService.shared.currentPairingAuthorization else { - let message = "Sign in again to open work from that Mac." + let message = "Sign in again to open work from that computer." lastError = message lastConnectAttemptFailure = SyncConnectAttemptFailure(message: message) return false @@ -7946,8 +7946,8 @@ final class SyncService: ObservableObject { } // Persist BEFORE the hello. The host may commit this secret while the // hello_ok that reports it is still in flight, and a drop right there - // used to leave the phone holding a secret the Mac had already retired -- - // recoverable only by typing another PIN at the Mac. Saving first cannot + // used to leave the phone holding a secret the computer had already retired -- + // recoverable only by typing another PIN at the computer. Saving first cannot // strand the phone the other way: a host that never commits keeps // accepting the previous secret, and this device reconnects within // seconds, far inside that window. @@ -7982,7 +7982,7 @@ final class SyncService: ObservableObject { throw NSError( domain: "ADE", code: 36, - userInfo: [NSLocalizedDescriptionKey: "The Mac did not finish saving this pairing. Try again."] + userInfo: [NSLocalizedDescriptionKey: "The computer did not finish saving this pairing. Try again."] ) } } @@ -8908,7 +8908,7 @@ final class SyncService: ObservableObject { private func sessionLifecycleNotAppliedError(_ action: String) -> NSError { NSError(domain: "ADE", code: 28, userInfo: [ NSLocalizedDescriptionKey: - "This Mac didn’t apply that change — the session may have already changed there.", + "This computer didn’t apply that change — the session may have already changed there.", "adeAction": action, ]) } @@ -10127,7 +10127,7 @@ final class SyncService: ObservableObject { self.terminalInputQueues[sessionId] = queue self.terminalInputTimeoutTasks[sessionId] = nil self.terminalStreamHandlers[sessionId]?(.inputFailure( - message: "The Mac did not confirm whether that terminal input was applied. It was not retried again." + message: "The computer did not confirm whether that terminal input was applied. It was not retried again." )) self.flushTerminalInputQueue(sessionId: sessionId) } @@ -16583,7 +16583,7 @@ final class SyncService: ObservableObject { case "account_challenge_error": let challengeError = payload as? [String: Any] let message = syncNonEmpty(challengeError?["message"] as? String) - ?? "That route could not verify the Mac's identity." + ?? "That route could not verify the computer's identity." resolve(requestId: requestId, result: .failure(NSError( domain: "ADE.AdoptChannel", code: 6, @@ -18005,7 +18005,7 @@ final class SyncService: ObservableObject { throw NSError(domain: "ADE", code: 26, userInfo: [NSLocalizedDescriptionKey: "This action needs the lane's project scope. Refresh lanes and try again."]) } guard canSendLiveRequests() else { - throw NSError(domain: "ADE", code: 14, userInfo: [NSLocalizedDescriptionKey: "Can’t reach this Mac right now."]) + throw NSError(domain: "ADE", code: 14, userInfo: [NSLocalizedDescriptionKey: "Can’t reach this computer right now."]) } let requestId = commandId ?? makeRequestId() let effectiveTimeoutNanoseconds = timeoutNanoseconds ?? SyncRequestTimeout.commandTimeoutNanoseconds(for: action) @@ -18846,7 +18846,7 @@ final class SyncService: ObservableObject { targetProjectId: String? = nil ) async throws -> Any { guard canSendLiveRequests() else { - throw NSError(domain: "ADE", code: 16, userInfo: [NSLocalizedDescriptionKey: "Can’t reach this Mac right now."]) + throw NSError(domain: "ADE", code: 16, userInfo: [NSLocalizedDescriptionKey: "Can’t reach this computer right now."]) } let requestId = makeRequestId() let raw = try await awaitResponse(requestId: requestId) { @@ -19007,11 +19007,11 @@ extension SyncService { } if connectionState.isHostUnreachable || nsError.domain == NSURLErrorDomain { - return "Reconnect to your Mac and try again." + return "Reconnect to your computer and try again." } if nsError.domain == "ADE", nsError.code == 15 { - return "Reconnect to your Mac and try again." + return "Reconnect to your computer and try again." } switch kind { diff --git a/apps/ios/ADE/Services/SyncTerminalInputQueue.swift b/apps/ios/ADE/Services/SyncTerminalInputQueue.swift index 64b1f1123..d8d680301 100644 --- a/apps/ios/ADE/Services/SyncTerminalInputQueue.swift +++ b/apps/ios/ADE/Services/SyncTerminalInputQueue.swift @@ -9,7 +9,7 @@ enum SyncTerminalInputQueueError: Error, Equatable, LocalizedError { case .chunkTooLarge(let maximumBytes): return "That terminal input is too large to send (maximum \(maximumBytes) bytes)." case .overflow(let maximumItems, let maximumBytes): - return "Terminal input is paused because \(maximumItems) queued chunks or \(maximumBytes) bytes are waiting for the Mac." + return "Terminal input is paused because \(maximumItems) queued chunks or \(maximumBytes) bytes are waiting for the computer." } } } diff --git a/apps/ios/ADE/Views/Account/AccountConnectionsSection.swift b/apps/ios/ADE/Views/Account/AccountConnectionsSection.swift index 583d479f3..68f353acf 100644 --- a/apps/ios/ADE/Views/Account/AccountConnectionsSection.swift +++ b/apps/ios/ADE/Views/Account/AccountConnectionsSection.swift @@ -75,7 +75,7 @@ struct AccountSignInPromptCard: View { Text("Continue to ADE") .font(.headline) .foregroundStyle(ADEColor.textPrimary) - Text("Connect to a Mac on another network. Use the same ADE account on your iPhone and Mac.") + Text("Connect to a computer on another network. Use the same ADE account on your iPhone and computer.") .font(.subheadline) .foregroundStyle(ADEColor.textSecondary) .fixedSize(horizontal: false, vertical: true) @@ -217,7 +217,7 @@ struct AccountMachinesList: View { if account.machines.isEmpty { AccountMachinesNote( icon: "desktopcomputer", - text: "No Macs are signed in to this account yet. Open ADE on your Mac and sign in there too." + text: "No computers are signed in to this account yet. Open ADE on your computer and sign in there too." ) } else { machineRows diff --git a/apps/ios/ADE/Views/Account/MobileAccessGateView.swift b/apps/ios/ADE/Views/Account/MobileAccessGateView.swift index 427ba1a3e..24bd4a81f 100644 --- a/apps/ios/ADE/Views/Account/MobileAccessGateView.swift +++ b/apps/ios/ADE/Views/Account/MobileAccessGateView.swift @@ -54,7 +54,7 @@ struct MobileAccessGateView: View { Text("Checking account…") } } else { - Text(accountSignedIn ? "View your Macs" : "Sign in") + Text(accountSignedIn ? "View your computers" : "Sign in") } } .font(.headline) @@ -179,7 +179,7 @@ struct MobileAccessGateView: View { Task { @MainActor in accountConnectionError = nil guard let authorization = AccountService.shared.currentPairingAuthorization else { - accountConnectionError = "Your account session ended. Sign in again, then choose your Mac." + accountConnectionError = "Your account session ended. Sign in again, then choose your computer." return } let connected = await syncService.pairWithAccountMachine( @@ -190,7 +190,7 @@ struct MobileAccessGateView: View { ADEHaptics.medium() onContinue() } else { - accountConnectionError = syncService.lastError ?? "ADE could not connect to that Mac. Try again." + accountConnectionError = syncService.lastError ?? "ADE could not connect to that computer. Try again." } } } diff --git a/apps/ios/ADE/Views/Components/MachineRowView.swift b/apps/ios/ADE/Views/Components/MachineRowView.swift index 75b533022..d8740c35e 100644 --- a/apps/ios/ADE/Views/Components/MachineRowView.swift +++ b/apps/ios/ADE/Views/Components/MachineRowView.swift @@ -193,7 +193,7 @@ func machineDeviceSymbol(deviceType: String?, platform: String?) -> String { } /// The unified status hint for a saved machine. Directory presence is only a -/// routing hint; absence never claims the Mac is powered off. Callers with a +/// routing hint; absence never claims the computer is powered off. Callers with a /// richer route label show that instead and fall back to this. func machineReachabilityText( isConnected: Bool, diff --git a/apps/ios/ADE/Views/Deeplinks/SendToMacCard.swift b/apps/ios/ADE/Views/Deeplinks/SendToMacCard.swift index af13c7b17..369b9f3df 100644 --- a/apps/ios/ADE/Views/Deeplinks/SendToMacCard.swift +++ b/apps/ios/ADE/Views/Deeplinks/SendToMacCard.swift @@ -29,7 +29,7 @@ struct SendToMacTarget: Equatable, Identifiable { /// Best-effort parse of ADE's custom scheme and HTTPS mirror. Unknown /// shapes fall back to `.other` so the card can still render a generic - /// "Open this on your Mac" message rather than refusing to display. + /// "Open this on your computer" message rather than refusing to display. init(url: URL) { self.url = url self.envelope = SendToMacTarget.parseEnvelope(url) @@ -181,7 +181,7 @@ struct SendToMacTarget: Equatable, Identifiable { case .repoBranch(_, _, _): return "Branch shared with you" case .pr: return "Pull request shared with you" case .linearIssue: return "Linear issue shared with you" - case .other: return "Shared from your Mac" + case .other: return "Shared from your computer" } } @@ -295,12 +295,12 @@ struct SendToMacCard: View { .foregroundStyle(ADEColor.accent) .padding(.bottom, 4) - Text("Open on your Mac") + Text("Open on your computer") .font(.system(.title3, design: .rounded).weight(.semibold)) .foregroundStyle(ADEColor.textPrimary) .multilineTextAlignment(.center) - Text("This link works best on the desktop app. Send it to your paired Mac and it'll open there.") + Text("This link works best on the desktop app. Send it to your paired computer and it'll open there.") .font(.system(.footnote, design: .rounded)) .foregroundStyle(ADEColor.textSecondary) .multilineTextAlignment(.center) @@ -378,7 +378,7 @@ struct SendToMacCard: View { .background(ADEColor.recessedBackground, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) } - /// Display name for the paired Mac. Prefers the live `hostName` published + /// Display name for the paired computer. Prefers the live `hostName` published /// by `SyncService`, falls back to a placeholder when no machine is /// attached so the card still reads correctly. The user can still try to /// send; the queueing path inside `SyncService` will surface the offline @@ -391,7 +391,7 @@ struct SendToMacCard: View { // TODO: thread the paired-device record through here once SyncService // exposes a richer "last paired" identity; today `hostName` is the only // stable display string we have. - return "Your Mac" + return "Your computer" } private var machineSecondaryLabel: String? { @@ -526,18 +526,18 @@ struct SendToMacCard: View { let branch = target.envelope?.branch, !repo.isEmpty, !branch.isEmpty { - return "Send to Mac to create a lane from \(branch)" + return "Send to computer to create a lane from \(branch)" } - return "Send to Mac" + return "Send to computer" } private var sendStatusMessage: String? { guard let sendOutcome else { return nil } switch sendOutcome { case .dispatched: - return "Sent to your Mac." + return "Sent to your computer." case .queued: - return "Queued for when your Mac reconnects." + return "Queued for when your computer reconnects." case .dropped(let message): return message.isEmpty ? "This command could not be sent." : message } diff --git a/apps/ios/ADE/Views/Hub/HubQuickConnect.swift b/apps/ios/ADE/Views/Hub/HubQuickConnect.swift index 4486bca07..b6730e4b5 100644 --- a/apps/ios/ADE/Views/Hub/HubQuickConnect.swift +++ b/apps/ios/ADE/Views/Hub/HubQuickConnect.swift @@ -15,7 +15,7 @@ func hubSavedMachineIsRecentlyReachable( /// One-tap connect cards shown on the no-machine home for account and saved /// machines. Directory/discovery presence is only a hint; saved secure records -/// remain attemptable without claiming the Mac is currently reachable. +/// remain attemptable without claiming the computer is currently reachable. struct HubQuickConnectSection: View { @EnvironmentObject private var syncService: SyncService @ObservedObject private var account = AccountService.shared @@ -49,7 +49,7 @@ struct HubQuickConnectSection: View { /// Account and saved records can describe the same Mac. Prefer the account /// card when both stable IDs match, while retaining a live saved card when - /// the account directory currently considers that Mac offline. + /// the account directory currently considers that computer offline. private var targets: [Target] { let accountTargets = accountMachines.map(Target.account) let accountIdentities = Set(accountMachines.compactMap { normalizedIdentity($0.deviceId) }) @@ -195,7 +195,7 @@ struct HubQuickConnectSection: View { errorText = nil Task { @MainActor in guard let authorization = AccountService.shared.currentPairingAuthorization else { - errorText = "Your account session ended. Sign in again, then choose your Mac." + errorText = "Your account session ended. Sign in again, then choose your computer." connectingId = nil return } @@ -209,7 +209,7 @@ struct HubQuickConnectSection: View { onConnectSuccess() } else { ADEHaptics.error() - errorText = syncService.lastError ?? "ADE could not connect to that Mac. Try again." + errorText = syncService.lastError ?? "ADE could not connect to that computer. Try again." } } } diff --git a/apps/ios/ADE/Views/Linear/LinearConnectionScreen.swift b/apps/ios/ADE/Views/Linear/LinearConnectionScreen.swift index 3cbde90b4..b87a1b96c 100644 --- a/apps/ios/ADE/Views/Linear/LinearConnectionScreen.swift +++ b/apps/ios/ADE/Views/Linear/LinearConnectionScreen.swift @@ -160,7 +160,7 @@ struct LinearConnectionScreen: View { } if !supportsReconnect && !supportsDisconnect { - Text("Update ADE on your Mac to manage Linear connections from your phone.") + Text("Update ADE on your computer to manage Linear connections from your phone.") .font(.footnote) .foregroundStyle(ADEColor.textSecondary) .frame(maxWidth: .infinity, alignment: .leading) @@ -325,7 +325,7 @@ struct LinearConnectActions: View { } if !supportsOAuth && !supportsApiKey { - Text("Update ADE on your Mac to manage Linear connections from your phone.") + Text("Update ADE on your computer to manage Linear connections from your phone.") .font(.footnote) .foregroundStyle(ADEColor.textSecondary) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/apps/ios/ADE/Views/Linear/LinearPaneSheet.swift b/apps/ios/ADE/Views/Linear/LinearPaneSheet.swift index 42a2fc1e9..87d059031 100644 --- a/apps/ios/ADE/Views/Linear/LinearPaneSheet.swift +++ b/apps/ios/ADE/Views/Linear/LinearPaneSheet.swift @@ -54,7 +54,7 @@ struct LinearPaneSheet: View { } /// Machine name for connect copy — the connected host's display name when - /// known, else a neutral "your Mac". + /// known, else a neutral "your computer". private var machineName: String { syncService.machineDisplayName } private var connectPrompt: some View { diff --git a/apps/ios/ADE/Views/PRs/PrDetailScreen.swift b/apps/ios/ADE/Views/PRs/PrDetailScreen.swift index 524d38236..500199324 100644 --- a/apps/ios/ADE/Views/PRs/PrDetailScreen.swift +++ b/apps/ios/ADE/Views/PRs/PrDetailScreen.swift @@ -591,7 +591,7 @@ struct PrDetailView: View { title: "Pull request unavailable", message: isLive ? "ADE could not find \(unavailablePrLabel). Refresh the PR list and try again." - : "Reconnect to your Mac to load \(unavailablePrLabel).", + : "Reconnect to your computer to load \(unavailablePrLabel).", icon: "arrow.triangle.merge", tint: ADEColor.warning, actionTitle: "Retry", diff --git a/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift b/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift index 3d8c871e1..7de2ca9c6 100644 --- a/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift +++ b/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift @@ -31,7 +31,7 @@ struct ConnectionSettingsView: View { // Pairing-only entry point (from the no-account gate): connection // status + the pair actions, nothing else. VStack(alignment: .leading, spacing: 12) { - SettingsSectionHeader(label: "MAC", hint: "Your Mac connection") + SettingsSectionHeader(label: "CONNECTION", hint: "Your computer connection") SettingsConnectionHeader( snapshot: presentationModel.connectionSnapshot, @@ -167,7 +167,7 @@ struct ConnectionSettingsView: View { } .background(SettingsAuroraBackground().ignoresSafeArea()) .adeNavigationGlass() - .navigationTitle(pairingOnly ? "Connect a Mac" : "Settings") + .navigationTitle(pairingOnly ? "Connect a computer" : "Settings") .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { @@ -854,7 +854,7 @@ func settingsMachineRowErrorsRetiring( return remaining } -/// The CONNECTIONS machine list: a unified, deduplicated roster of the Macs a +/// The CONNECTIONS machine list: a unified, deduplicated roster of the computers a /// phone can reach — machines on the signed-in account plus previously-paired /// machines — ranked current → online → offline. Shows the top three inline /// with a "See all machines" sheet for the rest. Offline machines render grayed @@ -1165,7 +1165,7 @@ struct SettingsMachinesSection: View { case .account(let machine): guard let authorization = AccountService.shared.currentPairingAuthorization else { connectingId = nil - rowErrors[entry.id] = "Your account session ended. Sign in again, then choose your Mac." + rowErrors[entry.id] = "Your account session ended. Sign in again, then choose your computer." return } let connected = await syncService.pairWithAccountMachine( @@ -1180,7 +1180,7 @@ struct SettingsMachinesSection: View { rowErrors[entry.id] = settingsMachineRowErrorMessage( attemptFailure: syncService.lastConnectAttemptFailure, lastError: syncService.lastError, - fallback: "ADE could not connect to that Mac. Try again." + fallback: "ADE could not connect to that computer. Try again." ) } diff --git a/apps/ios/ADE/Views/Settings/SSHPairingView.swift b/apps/ios/ADE/Views/Settings/SSHPairingView.swift index dec42b439..5dbf3a559 100644 --- a/apps/ios/ADE/Views/Settings/SSHPairingView.swift +++ b/apps/ios/ADE/Views/Settings/SSHPairingView.swift @@ -59,7 +59,7 @@ struct SSHPairingView: View { } } message: { if case .needsHostConfirmation(let fingerprint) = model.state { - Text("Before continuing, compare this fingerprint with the one shown on your Mac:\n\n\(fingerprint)") + Text("Before continuing, compare this fingerprint with the one shown on your computer:\n\n\(fingerprint)") } } .onChange(of: model.state) { _, state in @@ -97,7 +97,7 @@ struct SSHPairingView: View { .autocorrectionDisabled() TextField("SSH port", value: $port, format: .number) .keyboardType(.numberPad) - TextField("Mac username", text: $username) + TextField("Computer username", text: $username) .textInputAutocapitalization(.never) .autocorrectionDisabled() } @@ -151,7 +151,7 @@ struct SSHPairingView: View { passphrase = "" } if let generated = model.generatedKey { - Text("Run this once on the Mac, then return here and pair:") + Text("Run this once on the computer, then return here and pair:") .font(.footnote) .foregroundStyle(.secondary) Text(generated.authorizationCommand) @@ -166,7 +166,7 @@ struct SSHPairingView: View { private var securitySection: some View { Section("Security") { - Label("ADE asks you to compare the Mac's fingerprint before trusting it.", systemImage: "checkmark.shield") + Label("ADE asks you to compare the computer's fingerprint before trusting it.", systemImage: "checkmark.shield") .font(.footnote) Label("SSH is used only for setup. ADE reconnects normally after that.", systemImage: "link.badge.plus") .font(.footnote) @@ -177,7 +177,7 @@ struct SSHPairingView: View { private var statusSection: some View { switch model.state { case .checkingHost: - Section { ProgressView("Checking the Mac…") } + Section { ProgressView("Checking the computer…") } case .pairing: Section { ProgressView("Connecting to ADE…") } case .paired(let machine, let warning): diff --git a/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift b/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift index 324223711..2b21c06a9 100644 --- a/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift +++ b/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift @@ -23,14 +23,14 @@ struct SettingsPairingSection: View { SettingsPairActionRow( icon: "qrcode.viewfinder", title: "Scan a pairing code", - subtitle: "Scan the code shown in ADE on your Mac" + subtitle: "Scan the code shown in ADE on your computer" ) { presentedSheet = .scan } SettingsPairActionRow( icon: "dot.radiowaves.left.and.right", - title: "Find a nearby Mac", + title: "Find a nearby computer", subtitle: discoverSubtitle ) { presentedSheet = .discover @@ -39,7 +39,7 @@ struct SettingsPairingSection: View { SettingsPairActionRow( icon: "terminal", title: "Set up with SSH", - subtitle: "Advanced: use SSH once to create an ADE pairing" + subtitle: "Advanced · macOS or Linux only" ) { presentedSheet = .ssh } @@ -54,7 +54,7 @@ struct SettingsPairingSection: View { .tint(ADEColor.textSecondary) Label( - awayFromMacHelp, + awayFromComputerHelp, systemImage: "network" ) .font(.footnote) @@ -63,23 +63,23 @@ struct SettingsPairingSection: View { } } - private var awayFromMacHelp: String { + private var awayFromComputerHelp: String { if accountService.identity != nil { - return "You're signed in, so your Macs stay reachable from any network." + return "You're signed in, so your computers stay reachable from any network." } - return "Sign in to reach your Macs from any network." + return "Sign in to reach your computers from any network." } private var discoverSubtitle: String? { let count = snapshot.discoveredHostCount let savedCount = snapshot.savedReconnectHostCount if count == 0, savedCount > 0 { - return savedCount == 1 ? "1 saved Mac" : "\(savedCount) saved Macs" + return savedCount == 1 ? "1 saved computer" : "\(savedCount) saved computers" } if count == 0 { - return "Choose your Mac, then enter its ADE PIN" + return "Choose your computer, then enter its ADE PIN" } - return count == 1 ? "1 nearby Mac found · enter its ADE PIN" : "\(count) nearby Macs found" + return count == 1 ? "1 nearby computer found · enter its ADE PIN" : "\(count) nearby computers found" } } @@ -470,7 +470,7 @@ struct DiscoverHostsSheet: View { VStack(spacing: 14) { ADESkeletonView(height: 56, cornerRadius: 14) ADESkeletonView(height: 56, cornerRadius: 14) - Text("Looking for Macs running ADE nearby…") + Text("Looking for computers running ADE nearby…") .font(.caption) .foregroundStyle(ADEColor.textSecondary) .padding(.top, 4) @@ -508,7 +508,7 @@ struct DiscoverHostsSheet: View { } .adeScreenBackground() .adeNavigationGlass() - .navigationTitle("Nearby Macs") + .navigationTitle("Nearby computers") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { diff --git a/apps/ios/ADE/Views/Settings/SettingsPinSheet.swift b/apps/ios/ADE/Views/Settings/SettingsPinSheet.swift index e034a4535..bc5117320 100644 --- a/apps/ios/ADE/Views/Settings/SettingsPinSheet.swift +++ b/apps/ios/ADE/Views/Settings/SettingsPinSheet.swift @@ -59,7 +59,7 @@ struct SettingsPinSheet: View { if noPairingCode { noPairingCodeCard - // Escape hatch: a PIN may have been set on that Mac *after* its QR was + // Escape hatch: a PIN may have been set on that computer *after* its QR was // scanned (or after discovery reported no code). Let the user flip back // to the keypad and try a code anyway instead of dead-ending here. Button { @@ -73,7 +73,7 @@ struct SettingsPinSheet: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .accessibilityHint("Shows the keypad in case a pairing code was set after this Mac was scanned.") + .accessibilityHint("Shows the keypad in case a pairing code was set after this computer was scanned.") } else { pinEntry } @@ -101,7 +101,7 @@ struct SettingsPinSheet: View { .onAppear { // Proactive: if the machine already told us (via discovery or the // scanned payload) that it has no pairing code, skip the keypad and - // show the "set one on that Mac" message — no point asking for a code + // show the "set one on that computer" message — no point asking for a code // that can't exist. if presetSaysNoPairingCode { noPairingCode = true } } @@ -125,7 +125,7 @@ struct SettingsPinSheet: View { .accessibilityLabel("Pairing PIN") .accessibilityValue(pin.isEmpty ? "No digits entered" : "\(pin.count) of 6 digits entered") - Text("You haven't connected to this Mac before. Enter the pairing code shown in ADE on that Mac.") + Text("You haven't connected to this computer before. Enter the pairing code shown in ADE on that computer.") .font(.footnote) .foregroundStyle(ADEColor.textSecondary) @@ -146,7 +146,7 @@ struct SettingsPinSheet: View { .foregroundStyle(ADEColor.warning) .frame(width: 30, height: 30) .background(ADEColor.warning.opacity(0.14), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) - Text("That Mac has no pairing code set — set one in ADE on that Mac.") + Text("That computer has no pairing code set — set one in ADE on that computer.") .font(.subheadline) .foregroundStyle(ADEColor.textPrimary) .fixedSize(horizontal: false, vertical: true) @@ -286,7 +286,7 @@ struct SettingsPinSheet: View { } else if syncService.lastPairingFailure == .pinNotSet || syncService.lastPairingErrorCode == SyncService.pairingPinNotSetCode { // The host has no pairing code — swap the keypad for the friendly - // "set one on that Mac" message (M10) rather than a dead-end red error. + // "set one on that computer" message (M10) rather than a dead-end red error. ADEHaptics.warning() isSubmitting = false pin = "" @@ -295,7 +295,7 @@ struct SettingsPinSheet: View { // Wrong code: shake the boxes and say where the real one lives. ADEHaptics.error() isSubmitting = false - localError = "That code didn't match — it's shown in ADE on that Mac." + localError = "That code didn't match — it's shown in ADE on that computer." pin = "" withAnimation(.default) { shakeTrigger += 1 } } else { diff --git a/apps/ios/ADE/Views/Settings/SettingsPushDeliverySection.swift b/apps/ios/ADE/Views/Settings/SettingsPushDeliverySection.swift index 0a7eccabc..26031926a 100644 --- a/apps/ios/ADE/Views/Settings/SettingsPushDeliverySection.swift +++ b/apps/ios/ADE/Views/Settings/SettingsPushDeliverySection.swift @@ -117,7 +117,7 @@ struct SettingsPushDeliverySection: View { if !snapshot.canEnableNotifications { VStack(alignment: .leading, spacing: 6) { enableNotificationsButton(label: "Enable notifications", enabled: false, action: {}) - Text("Sign in or pair a Mac to enable notifications") + Text("Sign in or pair a computer to enable notifications") .font(.caption) .foregroundStyle(ADEColor.textMuted) .padding(.horizontal, 4) @@ -437,7 +437,7 @@ struct SettingsPushDeliverySection: View { private var refreshButtonLabel: String { if pushService.isRefreshingStatus { return "Checking relay…" } - return snapshot.canRefreshRelayStatus ? "Refresh status" : "Connect a Mac to refresh" + return snapshot.canRefreshRelayStatus ? "Refresh status" : "Connect a computer to refresh" } private var inlineStatusMessage: String? { diff --git a/apps/ios/ADE/Views/Settings/SettingsSupportTypes.swift b/apps/ios/ADE/Views/Settings/SettingsSupportTypes.swift index a359850e3..6a6bd4b8c 100644 --- a/apps/ios/ADE/Views/Settings/SettingsSupportTypes.swift +++ b/apps/ios/ADE/Views/Settings/SettingsSupportTypes.swift @@ -91,7 +91,7 @@ enum SettingsConnectionPresentation { case .connecting: return "Reconnecting" case .unreachable: - return "Can't reach this Mac" + return "Can't reach this computer" case .disconnected: return "Not connected" } diff --git a/apps/ios/ADEClip/ClipPairingClient.swift b/apps/ios/ADEClip/ClipPairingClient.swift index d79ba0b0e..60f7afbc7 100644 --- a/apps/ios/ADEClip/ClipPairingClient.swift +++ b/apps/ios/ADEClip/ClipPairingClient.swift @@ -21,9 +21,9 @@ enum ClipPairingError: LocalizedError, Equatable { case .unreachable: return "Couldn't reach the machine. Make sure your iPhone is on the same network." case .pinNotSet: - return "No pairing PIN is set on the computer. Open ADE on your Mac and set one first." + return "No pairing PIN is set on the computer. Open ADE on your computer and set one first." case .invalidPin: - return "That PIN doesn't match. Check the code shown on your Mac." + return "That PIN doesn't match. Check the code shown on your computer." case .failed(let message): return message } diff --git a/apps/ios/ADEClip/ClipPairingView.swift b/apps/ios/ADEClip/ClipPairingView.swift index 33f3d6038..c7bcfcb13 100644 --- a/apps/ios/ADEClip/ClipPairingView.swift +++ b/apps/ios/ADEClip/ClipPairingView.swift @@ -22,7 +22,7 @@ final class ClipPairingModel: ObservableObject { private let client = ClipPairingClient() var hostName: String { - payload?.hostIdentity.name ?? "your Mac" + payload?.hostIdentity.name ?? "your computer" } func handleInvocation(url: URL) { @@ -127,7 +127,7 @@ struct ClipPairingView: View { ProgressView() .padding(.top, 4) case .invalidInvocation: - Text("Open ADE on your Mac and scan the pairing code it shows in Settings.") + Text("Open ADE on your computer and scan the pairing code it shows in Settings.") .font(.footnote) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -211,11 +211,11 @@ struct ClipPairingView: View { private var subtitle: String { switch model.phase { case .enterPin, .pairing: - return "Enter the PIN shown in ADE on your Mac." + return "Enter the PIN shown in ADE on your computer." case .paired: return "Your iPhone is trusted. ADE picks this pairing up automatically." default: - return "ADE pairs your iPhone with your Mac to control agents from anywhere." + return "ADE pairs your iPhone with your computer to control agents from anywhere." } } } diff --git a/apps/ios/ADEClip/Info.plist b/apps/ios/ADEClip/Info.plist index 106e4ff08..03c1101b3 100644 --- a/apps/ios/ADEClip/Info.plist +++ b/apps/ios/ADEClip/Info.plist @@ -67,7 +67,7 @@ NSLocalNetworkUsageDescription - ADE connects to your Mac on the local network to pair this device. + ADE connects to your computer on the local network to pair this device. NSBonjourServices _ade-sync._tcp diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 6b517537a..45cafe12c 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -407,7 +407,7 @@ final class ADETests: XCTestCase { ] XCTAssertTrue(syncProtocolMismatchMessage( versions.merging(["updateTarget": "host"]) { _, right in right } - ).contains("Update ADE on your Mac")) + ).contains("Update ADE on your computer")) XCTAssertTrue(syncProtocolMismatchMessage( versions.merging(["updateTarget": "client"]) { _, right in right } ).contains("Update ADE on this iPhone")) @@ -1877,7 +1877,7 @@ final class ADETests: XCTestCase { load: .normal, lastFailureMessage: "timeout" )), - "Can't reach this Mac" + "Can't reach this computer" ) XCTAssertEqual( SettingsConnectionPresentation.statusLabel(for: SyncConnectionHealth( @@ -3908,7 +3908,7 @@ final class ADETests: XCTestCase { } // A host naming a cipher this build does not implement is a version gap, not - // evidence the Mac is an impostor: it must cost that route, not the attempt. + // evidence the computer is an impostor: it must cost that route, not the attempt. func testUnsupportedAdoptionCipherFailsOneRouteRatherThanTheWholeAttempt() { let compatibility = AccountAdoptionRouteCompatibilityError(machineName: "Arul's Mac") XCTAssertFalse(syncAccountAdoptionFailureIsFatal(compatibility)) @@ -4166,7 +4166,7 @@ final class ADETests: XCTestCase { code: 2, userInfo: [NSLocalizedDescriptionKey: "The host is offline."] ) - XCTAssertEqual(SyncUserFacingError.message(for: offlineError), "Can’t reach this Mac right now.") + XCTAssertEqual(SyncUserFacingError.message(for: offlineError), "Can’t reach this computer right now.") let authError = NSError( domain: "ADE", @@ -23960,8 +23960,8 @@ final class TerminalLiveTailPinningTests: XCTestCase { final class TerminalSessionInputStatusTests: XCTestCase { func testSuccessfulInputAcceptanceClearsStaleFailureWithoutMaskingRejection() { let controller = TerminalSessionController() - controller.handleStreamEventForTesting(.inputFailure(message: "The Mac did not confirm input.")) - XCTAssertEqual(controller.inputStatusMessage, "The Mac did not confirm input.") + controller.handleStreamEventForTesting(.inputFailure(message: "The computer did not confirm input.")) + XCTAssertEqual(controller.inputStatusMessage, "The computer did not confirm input.") controller.handleInputSubmissionForTesting(.queuedUntilReady(inputId: "stable-input-id")) XCTAssertNil(controller.inputStatusMessage) @@ -23972,7 +23972,7 @@ final class TerminalSessionInputStatusTests: XCTestCase { func testStreamRehydrationClearsStaleInputFailure() { let controller = TerminalSessionController() - controller.handleStreamEventForTesting(.inputFailure(message: "The Mac did not confirm input.")) + controller.handleStreamEventForTesting(.inputFailure(message: "The computer did not confirm input.")) controller.handleStreamEventForTesting(.hydrate( text: "Mac% ", diff --git a/apps/ios/ADETests/PairingAndDpopTests.swift b/apps/ios/ADETests/PairingAndDpopTests.swift index 45e9709f5..060d45f50 100644 --- a/apps/ios/ADETests/PairingAndDpopTests.swift +++ b/apps/ios/ADETests/PairingAndDpopTests.swift @@ -1275,6 +1275,18 @@ final class PairingAndDpopTests: XCTestCase { XCTAssertNil(payload.relayUrl) } + func testParsesWindowsDesktopHostIdentity() throws { + let json = #"{"version":3,"hostIdentity":{"deviceId":"windows-host","name":"Workstation","platform":"windows","deviceType":"desktop"},"port":8787,"addressCandidates":[]}"# + let payload = try XCTUnwrap(PairingQrPayload.parse(json)) + + XCTAssertEqual(payload.hostIdentity.platform, "windows") + XCTAssertEqual(payload.hostIdentity.deviceType, "desktop") + XCTAssertEqual( + machineDeviceSymbol(deviceType: payload.hostIdentity.deviceType, platform: payload.hostIdentity.platform), + "desktopcomputer" + ) + } + func testDropsNonWssRelayUrl() throws { let json = #"{"version":3,"hostIdentity":{"deviceId":"d1","name":"Box"},"port":8787,"addressCandidates":[],"relayUrl":"ws://relay.ade-app.dev/x"}"# let payload = try XCTUnwrap(PairingQrPayload.parse(json)) diff --git a/apps/ios/ADETests/SyncRecoveryPolicyTests.swift b/apps/ios/ADETests/SyncRecoveryPolicyTests.swift index 719394358..9399b7ec5 100644 --- a/apps/ios/ADETests/SyncRecoveryPolicyTests.swift +++ b/apps/ios/ADETests/SyncRecoveryPolicyTests.swift @@ -393,7 +393,7 @@ final class SyncRecoveryPolicyTests: XCTestCase { } func testApplicationCloseCodeTableKeepsPrimaryCopyRouteNeutral() { - let interrupted = "Can’t reach this Mac right now. Reconnecting now." + let interrupted = "Can’t reach this computer right now. Reconnecting now." let cases: [(code: Int, reason: String, expected: String)] = [ (4000, "partner closed", interrupted), (4001, "heartbeat timed out", interrupted), @@ -403,7 +403,7 @@ final class SyncRecoveryPolicyTests: XCTestCase { (4008, "inbound connection stale", interrupted), (4501, "host offline", interrupted), (4502, "relay idle", interrupted), - (4503, "relay capacity", "This Mac is handling too many connections. Try again shortly."), + (4503, "relay capacity", "This computer is handling too many connections. Try again shortly."), (4505, "replaced by newer host", interrupted), (4506, "pre-pipe buffer overflow", interrupted), (4507, "bridge rejected", interrupted), @@ -1734,7 +1734,7 @@ final class SyncRecoveryPolicyTests: XCTestCase { ]) service.setActiveProjectForTesting(projectId: "project-a", rootPath: "/tmp/a") - // This iPhone's clock runs two minutes AHEAD of the Mac it is paired to. + // This iPhone's clock runs two minutes AHEAD of the computer it is paired to. let phoneNow = Date(timeIntervalSince1970: 1_780_000_000) let hostNow = phoneNow.addingTimeInterval(-120) // The machine's own baseline from an earlier snooze of the same row. diff --git a/apps/ios/ADEWidgets/ADELockScreenWidget.swift b/apps/ios/ADEWidgets/ADELockScreenWidget.swift index b25bf850e..70c88c435 100644 --- a/apps/ios/ADEWidgets/ADELockScreenWidget.swift +++ b/apps/ios/ADEWidgets/ADELockScreenWidget.swift @@ -176,7 +176,11 @@ private struct LockScreenPriorityStatus { let metrics = [ needsYou.isEmpty ? nil : Metric(id: "needs", label: "\(needsYou.count) need", symbol: "bell.fill"), live.isEmpty ? nil : Metric(id: "live", label: "\(live.count) live", symbol: "waveform.path.ecg"), - machines.isEmpty ? nil : Metric(id: "machines", label: "\(machines.count) Mac", symbol: "desktopcomputer"), + machines.isEmpty ? nil : Metric( + id: "machines", + label: machines.count == 1 ? "1 computer" : "\(machines.count) computers", + symbol: "desktopcomputer" + ), ].compactMap { $0 } guard let focus = ordered.first else { @@ -199,8 +203,8 @@ private struct LockScreenPriorityStatus { self = .init( kind: .offline, title: hideDetails - ? "Mac offline" - : (machines.count == 1 ? "\(focus.machine.name) offline" : "\(machines.count) Macs offline"), + ? "Computer offline" + : (machines.count == 1 ? "\(focus.machine.name) offline" : "\(machines.count) computers offline"), detail: hideDetails ? "Open ADE for details" : "Last known work · \(focus.project.name)", inlineText: "ADE · offline", count: 0, @@ -280,8 +284,8 @@ private struct LockScreenPriorityStatus { self = .init( kind: .offline, title: hideDetails - ? "Mac offline" - : "\((machine?.isEmpty == false ? machine : nil) ?? "Mac") offline", + ? "Computer offline" + : "\((machine?.isEmpty == false ? machine : nil) ?? "Computer") offline", detail: minutes == 1 ? "Last update 1 minute ago" : "Last update \(minutes) minutes ago", inlineText: "ADE · offline \(minutes)m", count: 0, @@ -408,7 +412,7 @@ private struct LockScreenPriorityStatus { } else if snapshot.connection.lowercased() == "disconnected" { self = .init( kind: .offline, - title: "Mac offline", + title: "Computer offline", detail: "Reconnect to update agents and PRs", inlineText: "ADE · offline", count: 0, diff --git a/apps/web/public/pair/index.html b/apps/web/public/pair/index.html index 29d07e27f..56f9f6b8e 100644 --- a/apps/web/public/pair/index.html +++ b/apps/web/public/pair/index.html @@ -58,11 +58,11 @@

Pair your iPhone with ADE

-

Scan the pairing code shown in ADE on your Mac with your iPhone camera to pair instantly.

+

Scan the pairing code shown in ADE on your computer with your iPhone camera to pair instantly.

  1. Open the Camera app on your iPhone
  2. -
  3. Scan the pairing QR shown in ADE on your Mac
  4. -
  5. Enter the PIN your Mac displays
  6. +
  7. Scan the pairing QR shown in ADE on your computer
  8. +
  9. Enter the PIN your computer displays

Have the ADE app already? Scanning the same code inside the app's Settings works too.

diff --git a/apps/web/src/app/pages/PairPage.tsx b/apps/web/src/app/pages/PairPage.tsx index 8ee6cdb65..a73536d58 100644 --- a/apps/web/src/app/pages/PairPage.tsx +++ b/apps/web/src/app/pages/PairPage.tsx @@ -46,7 +46,7 @@ export function PairPage() {

- Install ADE, then scan the pairing code on your Mac again to connect. + Install ADE, then scan the pairing code on your computer again to connect.

diff --git a/docs/playbooks/windows-pr3-computer-use-proof.md b/docs/playbooks/windows-pr3-computer-use-proof.md new file mode 100644 index 000000000..12b40ce5a --- /dev/null +++ b/docs/playbooks/windows-pr3-computer-use-proof.md @@ -0,0 +1,296 @@ +# Windows desktop, account, and sync Computer Use proof + +Use this runbook to prove the Windows stacked-PR worker scope after the +coordinator has assembled the stack. It is an installed-build acceptance test, +not a source-development checklist. Run it once on Windows 10 22H2 x64 and once +on Windows 11 x64. + +Do not enable the public Windows release or download flags from this runbook. +Windows ARM64, WSL-backed execution, native Windows computer use, and Windows +SSH bootstrap are out of scope. + +## Safety and evidence rules + +- Use disposable repositories and test ADE accounts. Never use a production + repository or a personal account with unrelated machines. +- Do not open, capture, print, or attach files below an ADE `secrets` directory. + Do not capture OAuth query strings, authorization codes, access/refresh + tokens, pairing secrets, DPoP material, cookies, or the contents of credential + files. +- Pause recording before typing a pairing code, account credential, provider + credential, or SSH credential. Resume only after the secret-bearing surface + is gone. +- Browser DevTools Network and Application panels are not proof surfaces for + OAuth. Prove the user-visible redirect and resulting signed-in state only. +- Prefer screenshots for steady state and short videos for transitions. Name + artifacts `win-pr3----` and record the ADE version, + package channel, Windows build, origin host OS, client OS, and route in the + artifact note. +- A pass needs visible product state plus one independent origin-host check. + Logs alone are supporting evidence. Redact usernames, hostnames, repository + remotes, IP addresses, and email addresses before sharing logs. + +## Required test topology + +Prepare these machines or VMs: + +| ID | System | Role | +| --- | --- | --- | +| W | Windows 10/11 x64 standard user | Installed ADE under test; test as both host and controller | +| M | Supported macOS | ADE host/controller and iOS pairing station | +| L | Supported Linux x64 | ADE host/controller | +| I | Physical iPhone on a supported iOS version | Mobile controller | +| B | Chrome/Edge profile with no ADE site data | Hosted-web controller | + +Use the same disposable Git repository on W, M, and L, with a distinct clone on +each machine. Create one branch and one harmless unpushed commit per machine so +machine ownership and divergence are visible. Install Stable and Beta side by +side on W for the isolation phase. + +Record a sanitized matrix before starting: + +```text +ADE commit/version: +Package channel: +Windows edition/build/DPI: +W/M/L machine labels: +Test repository alias: +iOS version: +Expected account owner alias: +``` + +## 1. Windows desktop baseline + +1. Launch installed ADE from the Start menu as a standard user. Capture the + first visible window and confirm no console window flashes or remains open. +2. Exercise minimize, restore, maximize, double-click title-bar maximize, + Windows 11 Snap Layouts, and 100/125/150/200% DPI. Confirm caption buttons, + drag regions, and focus remain usable. +3. Open the disposable repository with the picker. Confirm the project path and + recent-project row use normal Windows paths and no raw IPC error appears. +4. In Lanes, create a lane from the local primary branch, rename its color, and + open Git Actions. Stage an untracked file, commit it, view history and diff, + then restore a stash that includes an untracked file. +5. In Files, create/edit/rename/delete a text fixture, use Quick Open and content + search, open Changes/Staged/Commit views, and copy a Windows path. Confirm + drive-letter and UNC-looking text do not break navigation. +6. In PRs, open the lane's PR detail or the empty/no-PR state, refresh it, and + exercise a non-mutating check/diff control. Do not create or merge a real PR. +7. In Work, open Chat, CLI, and Shell surfaces; switch tab/grid layout; resize the + session list and tools pane; open Git, Files, App Control, and Browser. Confirm + all tools remain reachable at a narrow width. +8. In a PowerShell PTY and a cmd PTY, print a Unicode fixture and a string + containing spaces, quotes, `$`, `%`, `&`, and backticks. Resize, send Ctrl+C, + close, and reopen the session. +9. Exercise fresh launch and resume UI for every installed provider. A provider + that is not configured must show an actionable auth state without exposing a + token. Do not add a real provider credential solely for this proof. +10. Open Browser, navigate between two benign pages, use back/forward/reload, + download a disposable file, inspect a visible element, and capture a browser + proof. Confirm App Control/CDP proof remains offered. +11. Invoke microphone dictation without granting access, confirm Windows privacy + guidance, grant access in Windows Settings, relaunch ADE, and confirm the + denial guidance clears. Do not record actual speech containing private data. +12. Trigger a harmless ADE notification and click it. Confirm it carries ADE's + app identity and returns focus to the correct session. +13. Confirm iOS Simulator, Xcode Preview, macOS Attention Notch, and native OS + computer-use actions are hidden or capability-blocked. Browser/App Control + and proof ingestion must remain available. + +Required evidence: one overview video plus screenshots of Lanes Git, Work tools, +Files, Browser proof, microphone guidance, notification routing, and the +capability-gated Windows UI. + +## 2. Deep links and project ownership + +Use a generated disposable ADE link whose target is already visible in the UI. +Do not include account tokens or pairing data. + +1. With ADE running, paste an `ade://` session or lane link into the Windows Run + dialog. Confirm the existing process focuses and navigates to the exact + target; no second ADE window/process remains. +2. Quit ADE completely and invoke the same link. Confirm cold launch opens the + correct project and target after initialization. +3. Repeat with a file link containing a Windows-relevant path and line number. +4. Connect a remote project, generate an owner-scoped link for a session on that + machine, and invoke it while another project is focused. Confirm ADE selects + or reconnects the owning machine/project rather than opening a same-ID local + row. +5. Repeat hot and cold on Stable and Beta. Stable owns the OS protocol binding; + Beta must not steal it. + +Required evidence: hot-link video, cold-link video, owner-scoped remote target, +and Stable/Beta process list after each invocation. + +## 3. ADE account and Google OAuth + +This is the first phase that performs a real login. The coordinator must run it +only after receiving explicit approval for the disposable account. + +1. Start signed out. Open Account and press the Google sign-in action once. + Confirm ADE opens the system default browser, not the built-in ADE Browser. +2. Pause recording before authentication. Complete Google/Clerk authentication. + Resume after the browser shows ADE's success page and the callback query is + no longer visible. +3. Confirm the desktop changes to signed in without restart and shows the + expected provider/account identity and account machine directory. +4. Close and reopen ADE, then log off/on Windows. Confirm the account session is + still available and no plaintext credential file is shown or inspected. +5. Sign out from Account. Confirm account-owned directory/Relay access closes, + the signed-out UI appears, and directly paired machine trust remains listed. +6. Reauthenticate with the same disposable account. Confirm the machine + directory repopulates and account-owned routes reconnect. +7. Begin another login, cancel before completing it, then finish the stale + browser page. Confirm the cancelled callback cannot silently sign ADE in. +8. Sign in as a second disposable account and confirm machines owned by the + first account do not reappear. Return to the first account only if needed for + later phases. + +Independent Windows check: while signed in, confirm the machine-owned account +session survives desktop exit because the background brain remains running; +after sign-out, confirm Relay closes without stopping local projects, agents, or +PTYs. Do not inspect credential contents. + +## 4. Route matrix and origin-host execution + +For every connection below, create or open a lane on the destination and start +a long-running harmless shell command that prints the destination OS, hostname, +and PID, then waits. From the controller, open its session and perform Git/Files/ +PR/Browser reads. On the destination, independently confirm that PID exists. On +the controller, confirm no matching worker process exists. This proves live +processes stay on the origin host. + +Run all rows: + +| Controller | Origin host | Route | Expected | +| --- | --- | --- | --- | +| W | M | LAN, then Tailscale, then Relay | Same remote project/session; route changes without moving the process | +| W | L | LAN, then Tailscale, then Relay | Same | +| M | W | LAN, then Tailscale, then Relay | Windows project and ConPTY session remain on W | +| L | W | LAN, then Tailscale, then Relay | Windows project and ConPTY session remain on W | + +For each row: + +1. Start with LAN available. Connect from Machines and record the displayed + route. Exercise project catalog, project open, lane list, Work union, Git, + Files, PR snapshot, terminal input/resize, and remote browser preview. +2. Disable only the LAN path while leaving Tailscale available. Wait for the + reconnect state, then confirm the same session resumes over Tailscale. +3. Disable the direct routes while both machines remain signed in. Confirm Relay + becomes the observed route and the same session resumes. +4. Sign the controller out. Relay must close. Restore LAN or Tailscale and + confirm direct device-bound pairing reconnects without account access. +5. Sign back in and confirm Relay becomes eligible again without replacing the + direct pairing record. +6. Restart the destination brain while the controller is open. Confirm the UI + enters reconnecting, returns to connected, rehydrates project/session state, + and does not duplicate the session or execute a command twice. +7. Reboot the destination. Confirm the per-user background brain returns after + login and the controller reconnects within the bounded retry policy. + +Never use WSL or SSH bootstrap to make W look like a Linux host. A Windows host +must advertise platform `windows` and execute through its packaged native brain. + +## 5. Windows Defender Firewall, Tailscale, and Relay + +Run on W as the origin host with I or another desktop as controller. + +1. With the applicable ADE inbound firewall permission allowed, connect by LAN + and record the route. +2. Block ADE inbound traffic in Windows Defender Firewall without stopping ADE. + Confirm LAN fails with actionable connection state; no false connected state + may be shown merely because loopback health is green. +3. With Tailscale running on both machines, confirm the controller reconnects by + Tailscale. Stop Tailscale and confirm that route becomes unavailable. +4. With both machines signed in, confirm Relay reconnects after direct routes + fail. Sign out on W and confirm Relay closes immediately. +5. Restore the firewall rule and LAN. Refresh discovery and confirm LAN becomes + preferred again. +6. Repeat one connection after Windows logoff/logon and one after full reboot. + +Capture the product route/status UI, not firewall rule details containing user +or network identifiers. + +## 6. CRR, sessions, remote commands, web, and iOS compatibility + +Use one Windows host and one macOS/Linux host. Test current-current first, then +repeat with the oldest supported released controller against the current host. + +1. Pair I to W and select the disposable project. Create a lane-local state + change on W that is represented in CRR data; confirm it appears on I. +2. Perform an allowed state mutation on I (for example settle/unsettle or a + harmless draft/state change); confirm it appears on W without duplicate rows. +3. Start a Windows Work chat and PTY. Open both on I; verify transcript hydration, + live events, terminal offsets, input ACK behavior, resize, disconnect, and + replay after reconnect. +4. Invoke only advertised iOS remote commands: list/open project, list lanes and + sessions, open Files/PR detail, settle/unsettle, and one harmless host-executed + command. Confirm unsupported optional actions are hidden or return update + guidance rather than breaking the socket. +5. Open B, sign in, adopt W through the account directory, and select the same + project. Confirm web has no local shell/browser/App Control surface, but live + project reads, Work transcript paging, terminal streaming, lifecycle controls, + Files, and PR snapshot work through the host. +6. Disconnect/reconnect B during chat streaming. Confirm the hydration barrier + produces neither a missing event nor a duplicate assistant row and older-page + Retry preserves its cursor after a transient failure. +7. Run the same iOS and web checks with M or L as host while W remains a connected + desktop controller. Confirm platform labels and machine ownership remain + correct in every client. +8. With the oldest supported controller, confirm additive hello fields are + ignored safely, required mobile actions determine limited mode, optional + actions are feature-detected, and legacy session/CRR rows remain readable. + +Required evidence: Windows CRR roundtrip, iOS command result, web project view, +chat reconnect with no duplication, terminal resume, and compatibility/limited +mode where applicable. + +## 7. Stable/Beta and Windows-user isolation + +1. Run Stable and Beta simultaneously under the same Windows account. Confirm + distinct ADE homes, background brains, account-directory names, sync ports, + runtime pipes, desktop-bridge pipes, projects, and sessions. +2. Invoke Stable's `ade://` link and confirm Beta does not claim it. +3. Sign in or pair only one channel and confirm the other does not inherit the + session or pairing. +4. Repeat launch and local-project checks from a second standard Windows user. + Confirm neither user's project catalog, account state, pairings, or runtime + endpoint is visible to the other. + +Do not prove isolation by opening either user's credential files. Prove it from +the visible product state and process/pipe names with user and hash values +redacted from shared artifacts. + +## 8. Final recovery and negative checks + +1. Quit the desktop while a harmless background brain-owned session is active. + Confirm the process continues on its origin host; reopen ADE and reattach. +2. Restart the brain during an idle chat, an active terminal, and a pending + controller reconnect. Confirm bounded recovery and no duplicate command. +3. Log out/in and reboot W. Confirm account state, project catalog, paired direct + trust, and reconnect policy recover as designed. +4. Uninstall ADE. Confirm its background startup entry, owned terminal shim, and + owned user `PATH` entry are removed, with unrelated user data untouched. +5. Record explicit non-goals: no Windows ARM64 package, no WSL execution path, + no native Windows computer-use backend, no iOS Simulator/Xcode surface, and + no Windows SSH-bootstrap promise. + +## Pass report template + +```text +Result: PASS | FAIL | BLOCKED +ADE version/commit: +Windows versions: +Client/host matrix completed: +Routes completed: LAN | Tailscale | Relay +OAuth/account completed by authorized coordinator: yes/no +CRR current-current: pass/fail +CRR oldest-supported compatibility: pass/fail +Origin-host process proof: pass/fail +Logout/reboot/brain-restart recovery: pass/fail +Stable/Beta/user isolation: pass/fail +Evidence artifact IDs: +Sanitized logs attached: +Defects/blockers with exact reproduction: +Public release flags changed: no +```