From 0660ad9d6e600df3297059327b59c18bfb064845 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:27:52 -0700 Subject: [PATCH 01/67] fix(orchestration): reject legacy mail acknowledgment (#11227) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../orchestration-migration-behavior.test.ts | 19 +++++++++++++++++ .../runtime/rpc/methods/orchestration.test.ts | 21 ++++++++++++------- src/main/runtime/rpc/methods/orchestration.ts | 11 ++++++++++ 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/src/main/runtime/rpc/methods/orchestration-migration-behavior.test.ts b/src/main/runtime/rpc/methods/orchestration-migration-behavior.test.ts index 87b482dc304..a8528803bf1 100644 --- a/src/main/runtime/rpc/methods/orchestration-migration-behavior.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-migration-behavior.test.ts @@ -71,6 +71,25 @@ describe('orchestration migration behavior', () => { expect(db.getMessageById(message.id)?.read).toBe(0) }) + it('rejects acknowledgment of legacy mail without effects', async () => { + const { db, runtime } = createRuntime() + const message = db.insertMessage({ + from: 'term_worker', + to: 'term_coord', + subject: 'still working' + }) + const check = ORCHESTRATION_METHODS.find((method) => method.name === 'orchestration.check')! + + await expect( + check.handler(check.params!.parse({ terminal: 'term_coord' }), { runtime }) + ).rejects.toMatchObject({ + code: 'legacy_read_only', + data: { effectsApplied: false } + }) + expect(db.getMessageById(message.id)?.read).toBe(0) + expect(db.getInbox(100)).toHaveLength(1) + }) + it('rejects replies to legacy mail without marking or inserting rows', async () => { const { db, runtime } = createRuntime() const message = db.insertMessage({ diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index d8d53cacba8..b0242738a7b 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -40,9 +40,12 @@ describe('orchestration RPC methods', () => { coordinatorHandle: 'term_coord', coordinatorPaneKey }).id + // Why: default direct fixtures to current-contract state; legacy behavior has dedicated tests. const createTask = db.createTask.bind(db) - // Why: legacy tests exercise the RPC behavior under test; default their direct fixture rows to the bound Run. db.createTask = (task) => createTask({ ...task, runId: task.runId ?? activeRunId }) + const insertMessage = db.insertMessage.bind(db) + db.insertMessage = (message) => + insertMessage({ ...message, runId: message.runId ?? activeRunId }) } else { activeRunId = undefined } @@ -1548,22 +1551,23 @@ describe('orchestration RPC methods', () => { expect(db.getUnreadMessages('b')).toHaveLength(1) }) - it('keeps waiting for requested types when an unrelated heartbeat arrives', async () => { + it('keeps waiting for requested types when an unrelated status arrives', async () => { setup() const waitPromise = call('orchestration.check', { terminal: 'coord', wait: true, timeoutMs: 5000, - types: 'worker_done,escalation' + types: 'escalation,question' }) as Promise<{ count: number; messages: { type: string }[] }> await Promise.resolve() await call('orchestration.send', { from: 'worker', to: 'coord', - subject: 'alive', - type: 'heartbeat' + subject: 'still working', + type: 'status', + run: activeRunId }) const early = await Promise.race([ @@ -1575,13 +1579,14 @@ describe('orchestration RPC methods', () => { await call('orchestration.send', { from: 'worker', to: 'coord', - subject: 'done', - type: 'worker_done' + subject: 'needs attention', + type: 'escalation', + run: activeRunId }) const result = await waitPromise expect(result.count).toBe(1) - expect(result.messages[0].type).toBe('worker_done') + expect(result.messages[0].type).toBe('escalation') }) it('does not mark existing messages read when the check starts aborted', async () => { diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index 2b7d5290a6f..bbb6067c47c 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -856,6 +856,17 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ ? db.getAllMessagesForHandle(handle, undefined, typeFilter) : db.getUnreadMessages(handle, typeFilter) + if ( + consumeUnread && + messages.some((message) => message.run_id === ORCHESTRATION_LEGACY_RUN_ID) + ) { + throw new OrchestrationError( + 'legacy_read_only', + 'Legacy orchestration messages are inspect-only; use --peek or --all. No acknowledgment was applied.', + { effectsApplied: false } + ) + } + let visibleMessages = messages if (consumeUnread && messages.length > 0) { // Why: unread check is an authoritative read path for worker_done/heartbeat, so reconcile lifecycle messages here too. From c8dba6d72caa60aeb663ec0cd525d0216e485f63 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 01:36:20 +0000 Subject: [PATCH 02/67] release: v1.4.160-rc.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5190a892ebd..cdb8880f8b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.160-rc.3", + "version": "1.4.160-rc.5", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From c6c6c71196362a7f350d5255963199938ec40c53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=99=E8=BE=89?= <42717106+OnlyYu1996@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:58:49 +0800 Subject: [PATCH 03/67] fix(opencode): use cross-platform data directory (#10362) * fix(opencode): use cross-platform data directory * fix(opencode): honor in-memory database override * fix(opencode): harden database discovery coverage * test(opencode): reproduce Windows session discovery --------- Co-authored-by: OrcaWin Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../session-scanner-opencode-sources.test.ts | 44 +++++++++ .../session-scanner-opencode-sources.ts | 9 +- .../scanner-windows-data-directory.test.ts | 90 +++++++++++++++++++ src/main/opencode-usage/scanner.test.ts | 14 +++ src/main/opencode-usage/scanner.ts | 47 +++++----- .../opencode/opencode-data-directory.test.ts | 46 ++++++++++ src/main/opencode/opencode-data-directory.ts | 17 ++++ 7 files changed, 237 insertions(+), 30 deletions(-) create mode 100644 src/main/ai-vault/session-scanner-opencode-sources.test.ts create mode 100644 src/main/opencode-usage/scanner-windows-data-directory.test.ts create mode 100644 src/main/opencode/opencode-data-directory.test.ts create mode 100644 src/main/opencode/opencode-data-directory.ts diff --git a/src/main/ai-vault/session-scanner-opencode-sources.test.ts b/src/main/ai-vault/session-scanner-opencode-sources.test.ts new file mode 100644 index 00000000000..12ca270871a --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sources.test.ts @@ -0,0 +1,44 @@ +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { opencodeDiscoveries } from './session-scanner-opencode-sources' + +const { discoverOpenCodeSessionsMock, listOpenCodeDatabasesMock } = vi.hoisted(() => ({ + discoverOpenCodeSessionsMock: vi.fn(), + listOpenCodeDatabasesMock: vi.fn() +})) + +vi.mock('./session-scanner-opencode-sqlite-discovery', () => ({ + discoverOpenCodeSessions: discoverOpenCodeSessionsMock +})) + +vi.mock('../opencode-usage/scanner', () => ({ + listOpenCodeDatabases: listOpenCodeDatabasesMock +})) + +describe('opencodeDiscoveries', () => { + afterEach(() => { + vi.unstubAllEnvs() + vi.clearAllMocks() + }) + + it('discovers local storage from the OpenCode XDG data directory', async () => { + vi.stubEnv('XDG_DATA_HOME', '/xdg/data') + vi.stubEnv('OPENCODE_CONFIG_DIR', '/opencode/config') + listOpenCodeDatabasesMock.mockResolvedValue([]) + discoverOpenCodeSessionsMock.mockResolvedValue({ + agent: 'opencode', + rootDir: '/xdg/data/opencode/storage', + files: [] + }) + const issues = [] + + await Promise.all(opencodeDiscoveries({}, [], 25, issues)) + + expect(discoverOpenCodeSessionsMock).toHaveBeenCalledWith({ + storageDir: join('/xdg/data', 'opencode', 'storage'), + dbPaths: [], + limitPerAgent: 25, + issues + }) + }) +}) diff --git a/src/main/ai-vault/session-scanner-opencode-sources.ts b/src/main/ai-vault/session-scanner-opencode-sources.ts index e4934aa8fda..6d84965f8c0 100644 --- a/src/main/ai-vault/session-scanner-opencode-sources.ts +++ b/src/main/ai-vault/session-scanner-opencode-sources.ts @@ -1,16 +1,11 @@ import { readdir } from 'node:fs/promises' -import { homedir } from 'node:os' import { dirname, join } from 'node:path' import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { resolveOpenCodeStorageDirectory } from '../opencode/opencode-data-directory' import { listOpenCodeDatabases } from '../opencode-usage/scanner' import { discoverOpenCodeSessions } from './session-scanner-opencode-sqlite-discovery' import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' -const OPENCODE_STORAGE_DIR = join( - process.env.OPENCODE_CONFIG_DIR?.trim() || join(homedir(), '.local', 'share', 'opencode'), - 'storage' -) - export function opencodeDiscoveries( options: AiVaultScanOptions, wslHomeDirs: readonly string[], @@ -33,7 +28,7 @@ function opencodeStorageDirs( wslHomeDirs: readonly string[] ): string[] { return [ - options.opencodeStorageDir ?? OPENCODE_STORAGE_DIR, + options.opencodeStorageDir ?? resolveOpenCodeStorageDirectory(), ...wslHomeDirs.map((homeDir) => join(homeDir, '.local', 'share', 'opencode', 'storage')) ] } diff --git a/src/main/opencode-usage/scanner-windows-data-directory.test.ts b/src/main/opencode-usage/scanner-windows-data-directory.test.ts new file mode 100644 index 00000000000..317e7b57954 --- /dev/null +++ b/src/main/opencode-usage/scanner-windows-data-directory.test.ts @@ -0,0 +1,90 @@ +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { opencodeDiscoveries } from '../ai-vault/session-scanner-opencode-sources' +import Database from '../sqlite/sync-database' +import { scanOpenCodeUsageDatabases } from './scanner' + +vi.mock('../ai-vault/session-scanner-opencode-sqlite-worker-spawn', async () => { + const { listOpenCodeSqliteSessions } = + await import('../ai-vault/session-scanner-opencode-sqlite-list') + return { listOpenCodeSqliteSessionsViaWorker: listOpenCodeSqliteSessions } +}) + +describe('OpenCode usage discovery on Windows', () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! + const environmentKeys = [ + 'HOME', + 'USERPROFILE', + 'LOCALAPPDATA', + 'APPDATA', + 'XDG_DATA_HOME', + 'OPENCODE_DB' + ] as const + let originalEnvironment: Partial> + let homeDirectory: string + + beforeEach(() => { + originalEnvironment = Object.fromEntries(environmentKeys.map((key) => [key, process.env[key]])) + homeDirectory = mkdtempSync(join(tmpdir(), 'orca-opencode-windows-home-')) + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + process.env.HOME = homeDirectory + process.env.USERPROFILE = homeDirectory + process.env.LOCALAPPDATA = join(homeDirectory, 'AppData', 'Local') + process.env.APPDATA = join(homeDirectory, 'AppData', 'Roaming') + delete process.env.XDG_DATA_HOME + delete process.env.OPENCODE_DB + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', originalPlatform) + for (const key of environmentKeys) { + const value = originalEnvironment[key] + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + rmSync(homeDirectory, { recursive: true, force: true }) + }) + + it('loads a session from ~/.local/share instead of the Windows app-data directory', async () => { + mkdirSync(process.env.LOCALAPPDATA!, { recursive: true }) + const dataDirectory = join(homeDirectory, '.local', 'share', 'opencode') + mkdirSync(dataDirectory, { recursive: true }) + const databasePath = join(dataDirectory, 'opencode.db') + const database = new Database(databasePath) + database.exec(` + CREATE TABLE session ( + id TEXT PRIMARY KEY, + directory TEXT, + title TEXT, + model TEXT, + cost REAL, + tokens_input INTEGER, + tokens_output INTEGER, + tokens_reasoning INTEGER, + tokens_cache_read INTEGER, + time_created INTEGER, + time_updated INTEGER + ); + INSERT INTO session VALUES ( + 'windows-session', 'C:\\repo', 'Windows session', + '{"providerID":"openai","id":"gpt-5"}', 0.01, + 100, 20, 0, 0, 1777777700000, 1777777800000 + ); + `) + database.close() + + const result = await scanOpenCodeUsageDatabases([], []) + const issues = [] + const [discovery] = await Promise.all(opencodeDiscoveries({}, [], 25, issues)) + + expect(discovery?.files.map(({ path }) => path)).toEqual([`${databasePath}#windows-session`]) + expect(issues).toEqual([]) + expect(result.processedDatabases.map(({ path }) => path)).toEqual([databasePath]) + expect(result.sessions.map(({ sessionId }) => sessionId)).toEqual(['windows-session']) + }) +}) diff --git a/src/main/opencode-usage/scanner.test.ts b/src/main/opencode-usage/scanner.test.ts index f938dd815b0..9680fea70fd 100644 --- a/src/main/opencode-usage/scanner.test.ts +++ b/src/main/opencode-usage/scanner.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import Database from '../sqlite/sync-database' import { attributeOpenCodeUsageEvent, + listOpenCodeDatabases, parseOpenCodeUsageDatabase, parseOpenCodeUsageRow, scanOpenCodeUsageDatabases @@ -422,6 +423,19 @@ describe('scanOpenCodeUsageDatabases', () => { return path } + it('does not scan disk databases when OPENCODE_DB uses memory', async () => { + writeSessionTotalsDb('opencode.db', []) + process.env.OPENCODE_DB = ':memory:' + + await expect(listOpenCodeDatabases()).resolves.toEqual([]) + }) + + it('does not return a directory configured as OPENCODE_DB', async () => { + process.env.OPENCODE_DB = '.' + + await expect(listOpenCodeDatabases()).resolves.toEqual([]) + }) + it('counts a session duplicated into a stale backup database exactly once', async () => { // The backup holds a stale snapshot of session-1; the canonical db has // grown since. The canonical totals must win and be counted once. diff --git a/src/main/opencode-usage/scanner.ts b/src/main/opencode-usage/scanner.ts index 7d9b0ee1cdf..ec532560ade 100644 --- a/src/main/opencode-usage/scanner.ts +++ b/src/main/opencode-usage/scanner.ts @@ -1,11 +1,10 @@ /* eslint-disable max-lines -- Why: OpenCode usage analytics need to normalize multiple local DB schema generations, attribute worktrees, and build persisted projections in one auditable pipeline. */ -import { existsSync } from 'node:fs' import { readdir, realpath, stat } from 'node:fs/promises' -import { homedir } from 'node:os' import { basename, isAbsolute, join, posix, win32 } from 'node:path' import { yieldToEventLoop } from '../../shared/event-loop-yield' import type { Repo } from '../../shared/types' import { areWorktreePathsEqual } from '../ipc/worktree-logic' +import { resolveOpenCodeDataDirectory } from '../opencode/opencode-data-directory' import Database from '../sqlite/sync-database' import { columnExists, tableExists } from './schema-helpers' import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer' @@ -80,42 +79,44 @@ function looksLikeWindowsPath(pathValue: string): boolean { return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\') } -function getXdgDataHome(): string { - if (process.env.XDG_DATA_HOME?.trim()) { - return process.env.XDG_DATA_HOME.trim() - } - if (process.platform === 'win32') { - return process.env.LOCALAPPDATA || process.env.APPDATA || join(homedir(), 'AppData', 'Local') - } - return join(homedir(), '.local', 'share') +type OpenCodeDatabaseOverride = { + isConfigured: boolean + path: string | null } -function getOpenCodeDataDirectory(): string { - return join(getXdgDataHome(), 'opencode') -} - -function getOpenCodeDatabasePathFromEnv(): string | null { +function getOpenCodeDatabaseOverride(dataDirectory: string): OpenCodeDatabaseOverride { const raw = process.env.OPENCODE_DB?.trim() if (!raw) { - return null + return { isConfigured: false, path: null } } if (raw === ':memory:') { - return null + return { isConfigured: true, path: null } + } + return { + isConfigured: true, + path: isAbsolute(raw) ? raw : join(dataDirectory, raw) } - return isAbsolute(raw) ? raw : join(getOpenCodeDataDirectory(), raw) } export async function listOpenCodeDatabases(): Promise { - const envPath = getOpenCodeDatabasePathFromEnv() - if (envPath) { - return existsSync(envPath) ? [envPath] : [] + const dataDirectory = resolveOpenCodeDataDirectory() + const databaseOverride = getOpenCodeDatabaseOverride(dataDirectory) + if (databaseOverride.isConfigured) { + if (!databaseOverride.path) { + return [] + } + try { + return (await stat(databaseOverride.path)).isFile() ? [databaseOverride.path] : [] + } catch { + return [] + } } try { - const entries = await readdir(getOpenCodeDataDirectory(), { withFileTypes: true }) + const entries = await readdir(dataDirectory, { withFileTypes: true }) return entries .filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name)) - .map((entry) => join(getOpenCodeDataDirectory(), entry.name)) + .map((entry) => join(dataDirectory, entry.name)) .sort() } catch { return [] diff --git a/src/main/opencode/opencode-data-directory.test.ts b/src/main/opencode/opencode-data-directory.test.ts new file mode 100644 index 00000000000..e3b9073aa17 --- /dev/null +++ b/src/main/opencode/opencode-data-directory.test.ts @@ -0,0 +1,46 @@ +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + resolveOpenCodeDataDirectory, + resolveOpenCodeStorageDirectory +} from './opencode-data-directory' + +function stubPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) +} + +describe('resolveOpenCodeDataDirectory', () => { + const realPlatform = process.platform + + afterEach(() => { + stubPlatform(realPlatform) + }) + + it('uses XDG_DATA_HOME when it is configured', () => { + expect(resolveOpenCodeDataDirectory({ XDG_DATA_HOME: ' /custom/data ' }, '/users/test')).toBe( + join('/custom/data', 'opencode') + ) + }) + + it.each(['win32', 'darwin', 'linux'])( + 'uses the OpenCode cross-platform default instead of app-data directories on %s', + (platform) => { + stubPlatform(platform) + const environment = { + LOCALAPPDATA: 'C:\\Users\\test\\AppData\\Local', + APPDATA: 'C:\\Users\\test\\AppData\\Roaming', + OPENCODE_CONFIG_DIR: 'C:\\Users\\test\\.config\\opencode' + } + + expect(resolveOpenCodeDataDirectory(environment, '/users/test')).toBe( + join('/users/test', '.local', 'share', 'opencode') + ) + } + ) + + it('derives legacy storage from the same data directory', () => { + expect(resolveOpenCodeStorageDirectory({}, '/users/test')).toBe( + join('/users/test', '.local', 'share', 'opencode', 'storage') + ) + }) +}) diff --git a/src/main/opencode/opencode-data-directory.ts b/src/main/opencode/opencode-data-directory.ts new file mode 100644 index 00000000000..b3db5598cee --- /dev/null +++ b/src/main/opencode/opencode-data-directory.ts @@ -0,0 +1,17 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' + +export function resolveOpenCodeDataDirectory( + environment: NodeJS.ProcessEnv = process.env, + homeDirectory = homedir() +): string { + const xdgDataHome = environment.XDG_DATA_HOME?.trim() + return join(xdgDataHome || join(homeDirectory, '.local', 'share'), 'opencode') +} + +export function resolveOpenCodeStorageDirectory( + environment: NodeJS.ProcessEnv = process.env, + homeDirectory = homedir() +): string { + return join(resolveOpenCodeDataDirectory(environment, homeDirectory), 'storage') +} From 9150ac65cb8ee02adfcc15fc70af5ed1a5b3b700 Mon Sep 17 00:00:00 2001 From: BingZ Date: Wed, 29 Jul 2026 10:58:52 +0800 Subject: [PATCH 04/67] Fix Windows setup sequencing wrapper quoting (#8806) * fix(setup): correct Windows sequencing wrapper quoting * test(setup): preserve spaced Windows batch paths * refactor(setup): dedupe PowerShell encoder, clarify wrapCmd comment Route the Windows setup-sequencing and Hermes startup planners through the shared renderer-safe encodePowerShellCommand instead of two verbatim btoa copies, and make that shared encoder renderer-safe (Buffer is unavailable in the sandboxed renderer where both planners also run). Reword the wrapCmd comment so it describes the current single-outer-quote behavior instead of the old quote-doubling bug. * test(setup): cover Windows metacharacter paths * fix(setup): keep Windows runner paths out of cmd source * test(setup): preserve Windows setup failures * docs(setup): explain safe cmd path handoff --------- Co-authored-by: OrcaWin Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- src/shared/hermes-startup-query.ts | 10 +- src/shared/powershell-command-encoding.ts | 10 +- src/shared/setup-agent-sequencing.test.ts | 107 +++++++++-- src/shared/setup-agent-sequencing.ts | 75 +++++--- .../setup-agent-sequencing.windows.test.ts | 174 ++++++++++++++++++ 5 files changed, 320 insertions(+), 56 deletions(-) create mode 100644 src/shared/setup-agent-sequencing.windows.test.ts diff --git a/src/shared/hermes-startup-query.ts b/src/shared/hermes-startup-query.ts index 79dd818f172..60e2705073f 100644 --- a/src/shared/hermes-startup-query.ts +++ b/src/shared/hermes-startup-query.ts @@ -1,3 +1,4 @@ +import { encodePowerShellCommand } from './powershell-command-encoding' import { buildShellCommandFromArgv, quoteStartupArg, @@ -14,15 +15,6 @@ const POWERSHELL_NATIVE_QUERY_VARIABLE = 'orcaHermesNativeQuery' export const ORCA_HERMES_STARTUP_QUERY_ENV = 'ORCA_HERMES_STARTUP_QUERY' -function encodePowerShellCommand(command: string): string { - let bytes = '' - for (let index = 0; index < command.length; index += 1) { - const code = command.charCodeAt(index) - bytes += String.fromCharCode(code & 0xff, code >>> 8) - } - return btoa(bytes) -} - function encodePosixEvalScript(command: string): string { return Array.from( new TextEncoder().encode(command), diff --git a/src/shared/powershell-command-encoding.ts b/src/shared/powershell-command-encoding.ts index 3d8e36aa470..aa03c069965 100644 --- a/src/shared/powershell-command-encoding.ts +++ b/src/shared/powershell-command-encoding.ts @@ -1,3 +1,11 @@ export function encodePowerShellCommand(command: string): string { - return Buffer.from(command, 'utf16le').toString('base64') + // Why: some callers (setup sequencing, Hermes startup) run in the sandboxed + // renderer where Node's Buffer is unavailable, so encode the UTF-16LE bytes + // PowerShell's -EncodedCommand expects using only renderer-safe globals. + let bytes = '' + for (let index = 0; index < command.length; index += 1) { + const code = command.charCodeAt(index) + bytes += String.fromCharCode(code & 0xff, code >>> 8) + } + return btoa(bytes) } diff --git a/src/shared/setup-agent-sequencing.test.ts b/src/shared/setup-agent-sequencing.test.ts index ebb8ab3c960..9225249a325 100644 --- a/src/shared/setup-agent-sequencing.test.ts +++ b/src/shared/setup-agent-sequencing.test.ts @@ -1,5 +1,5 @@ import { spawn } from 'node:child_process' -import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' @@ -173,33 +173,84 @@ describe('createSequencedSetupAgentCommands', () => { nonce: 'nonce-win', waitTimeoutSeconds: 3 }) + const setupPowerShell = decodePowerShellScript(result.setupCommand) + const startupPowerShell = decodePowerShellScript(result.startupCommand) - expect(result.setupCommand).toContain('cmd.exe /d /s /v:on /c') - expect(result.setupCommand).toContain('cmd.exe /c ""C:\\repo\\.git\\orca\\setup-runner.cmd""') - expect(result.setupCommand).toContain('echo !ORCA_SETUP_NONCE!:!ORCA_SETUP_STATUS!') + expect(result.setupCommand).toContain( + 'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand' + ) + expect(setupPowerShell).toContain("$runner = 'C:\\repo\\.git\\orca\\setup-runner.cmd'") + expect(setupPowerShell).toContain('$nonce + ":" + $setupStatus') expect(result.startupCommand.match(/powershell\.exe/g)).toHaveLength(1) - expect(result.startupCommand).toContain('powershell.exe -NoProfile -ExecutionPolicy Bypass') - expect(result.startupCommand).toContain('AddSeconds(3)') - expect(result.startupCommand).toContain('!ORCA_SETUP_STATUS!') - expect(result.startupCommand).toContain('Timed out waiting for setup before starting agent.') - expect(result.startupCommand).toContain('Setup failed; skipping agent startup.') expect(result.startupCommand).toContain( + 'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand' + ) + expect(startupPowerShell).toContain('AddSeconds(3)') + expect(startupPowerShell).toContain('Missing setup marker path.') + expect(startupPowerShell).toContain('Timed out waiting for setup before starting agent.') + expect(startupPowerShell).toContain('Setup failed; skipping agent startup.') + expect(startupPowerShell).toContain( 'Remove-Item -LiteralPath $marker, $tmp -Force -ErrorAction SilentlyContinue' ) expect(result.startupCommand).not.toContain('%ERRORLEVEL%') - expect(result.startupCommand).not.toContain(' & ) else') - expect(result.startupCommand).not.toContain('if ""!ORCA_SETUP_STATUS!""==""124""') - expect(result.startupCommand).not.toContain('if not ""!ORCA_SETUP_STATUS!""==""0""') - expect(result.startupCommand).not.toContain( - `call !${SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV}!` - ) - expect(result.startupCommand).toContain('Invoke-Expression') + expect(startupPowerShell).toContain('Invoke-Expression') expect(result.startupCommand).not.toContain('fix !PATH! & test') expect(result.startupEnv).toEqual({ [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: "codex --model gpt-5 'fix !PATH! & test'" }) }) + it.skipIf(process.platform !== 'win32')( + 'executes the native Windows setup-to-agent sequence through cmd.exe', + async () => { + const tempDir = join(makeTempDir(), 'path with spaces') + mkdirSync(tempDir) + const runnerScriptPath = join(tempDir, 'setup runner.cmd') + const startupScriptPath = join(tempDir, 'agent-startup.cmd') + const logPath = join(tempDir, 'sequence.log') + + writeFileSync( + runnerScriptPath, + ['@echo off', `>> "${logPath}" echo setup-done`, 'exit /b 0'].join('\r\n'), + 'utf8' + ) + writeFileSync( + startupScriptPath, + ['@echo off', `>> "${logPath}" echo agent-start`, 'exit /b 0'].join('\r\n'), + 'utf8' + ) + + const commands = createSequencedSetupAgentCommands({ + runnerScriptPath, + startupCommand: `cmd.exe /d /c "${startupScriptPath}"`, + platform: 'windows', + nonce: 'windows-sequence', + waitTimeoutSeconds: 2 + }) + + const setupExit = await waitForExit( + spawnWindowsCommand(tempDir, 'run-setup.cmd', commands.setupCommand) + ) + expect(setupExit.code).toBe(0) + expect(readIfExists(`${runnerScriptPath}.windows-sequence.done`)).toBe( + 'windows-sequence:0\r\n' + ) + + const startupExit = await waitForExit( + spawnWindowsCommand( + tempDir, + 'run-startup.cmd', + commands.startupCommand, + commands.startupEnv + ) + ) + + expect(startupExit.code).toBe(0) + expect(startupExit.stderr).toContain('Waiting for setup to finish before starting agent...') + expect(readFileSync(logPath, 'utf8')).toBe('setup-done\r\nagent-start\r\n') + } + ) + it.skipIf(process.platform === 'win32')( 'ignores stale markers until the matching setup run finishes, even when startup launches first', async () => { @@ -409,6 +460,30 @@ function sleep(ms: number): Promise { }) } +function spawnWindowsCommand( + dir: string, + filename: string, + command: string, + env: Record = {} +): ReturnType { + const scriptPath = join(dir, filename) + // Why: /s strips the quotes Node adds for batch paths containing spaces; + // argv spawning still exercises cmd.exe's native parser without that loss. + writeFileSync(scriptPath, `@echo off\r\n${command}\r\nexit /b %ERRORLEVEL%\r\n`, 'utf8') + return spawn('cmd.exe', ['/d', '/c', scriptPath], { + stdio: 'pipe', + env: { ...process.env, ...env } + }) +} + +function decodePowerShellScript(command: string): string { + const encoded = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/)?.[1] + if (!encoded) { + throw new Error('Missing PowerShell encoded command') + } + return Buffer.from(encoded, 'base64').toString('utf16le') +} + function waitForExit( child: ReturnType ): Promise<{ code: number | null; stderr: string }> { diff --git a/src/shared/setup-agent-sequencing.ts b/src/shared/setup-agent-sequencing.ts index 33a5f96a63f..88f6c11ac1e 100644 --- a/src/shared/setup-agent-sequencing.ts +++ b/src/shared/setup-agent-sequencing.ts @@ -1,3 +1,4 @@ +import { encodePowerShellCommand } from './powershell-command-encoding' import { resolveSetupRunnerCommand, type SetupRunnerCommandPlatform, @@ -45,7 +46,11 @@ export function createSequencedSetupAgentCommands(args: { if (resolution.shell === 'windows') { return { - setupCommand: buildWindowsSetupCommand(resolution.command, markerPath, nonce), + setupCommand: buildWindowsSetupCommand( + resolution.runnerScriptPathForShell, + markerPath, + nonce + ), startupCommand: buildWindowsStartupCommand(markerPath, nonce, waitTimeoutSeconds), startupEnv: { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: args.startupCommand @@ -165,17 +170,33 @@ function hasUnquotedPosixCommandSeparator(command: string): boolean { return false } -function buildWindowsSetupCommand(setupCommand: string, markerPath: string, nonce: string): string { - return wrapCmd([ - `set "ORCA_SETUP_MARKER=${escapeCmdSetValue(markerPath)}"`, - `set "ORCA_SETUP_NONCE=${escapeCmdSetValue(nonce)}"`, - 'del /f /q "!ORCA_SETUP_MARKER!" "!ORCA_SETUP_MARKER!.tmp" 2>nul', - `call ${setupCommand}`, - 'set "ORCA_SETUP_STATUS=!ERRORLEVEL!"', - '> "!ORCA_SETUP_MARKER!.tmp" echo !ORCA_SETUP_NONCE!:!ORCA_SETUP_STATUS!', - 'move /y "!ORCA_SETUP_MARKER!.tmp" "!ORCA_SETUP_MARKER!" >nul', - 'exit /b !ORCA_SETUP_STATUS!' - ]) +function buildWindowsSetupCommand( + runnerScriptPath: string, + markerPath: string, + nonce: string +): string { + // Why: delayed expansion keeps path metacharacters as data when cmd invokes the batch runner. + const script = [ + `$runner = ${quotePowerShellString(runnerScriptPath)}`, + `$marker = ${quotePowerShellString(markerPath)}`, + '$tmp = $marker + ".tmp"', + `$nonce = ${quotePowerShellString(nonce)}`, + 'Remove-Item -LiteralPath $marker, $tmp -Force -ErrorAction SilentlyContinue', + '$processInfo = [System.Diagnostics.ProcessStartInfo]::new()', + '$processInfo.FileName = $env:ComSpec', + '$processInfo.Arguments = \'/d /s /v:on /c ""!ORCA_SETUP_RUNNER!""\'', + '$processInfo.UseShellExecute = $false', + '$processInfo.EnvironmentVariables["ORCA_SETUP_RUNNER"] = $runner', + '$process = [System.Diagnostics.Process]::Start($processInfo)', + '$process.WaitForExit()', + '$setupStatus = $process.ExitCode', + '$utf8 = [System.Text.UTF8Encoding]::new($false)', + '[System.IO.File]::WriteAllText($tmp, ($nonce + ":" + $setupStatus + [Environment]::NewLine), $utf8)', + 'Move-Item -LiteralPath $tmp -Destination $marker -Force', + 'exit $setupStatus' + ].join('; ') + + return encodePowerShellInvocation(script) } function buildWindowsStartupCommand( @@ -187,10 +208,15 @@ function buildWindowsStartupCommand( // Why: native Windows setup runners launch through cmd.exe, but PowerShell // gives us safe bounded file polling/parsing without a fragile batch label loop. const script = [ - '$marker = $env:ORCA_SETUP_MARKER', + `$marker = ${quotePowerShellString(markerPath)}`, + 'if ([string]::IsNullOrWhiteSpace($marker)) {', + ' [Console]::Error.WriteLine("Missing setup marker path.")', + ' exit 1', + '}', '$tmp = $marker + ".tmp"', - '$nonce = $env:ORCA_SETUP_NONCE', + `$nonce = ${quotePowerShellString(nonce)}`, `$deadline = (Get-Date).AddSeconds(${timeout})`, + '[Console]::Error.WriteLine("Waiting for setup to finish before starting agent...")', 'while ($true) {', ' if (Test-Path -LiteralPath $marker) {', ' $content = Get-Content -LiteralPath $marker -TotalCount 1', @@ -220,18 +246,11 @@ function buildWindowsStartupCommand( '}' ].join('; ') - return wrapCmd([ - `set "ORCA_SETUP_MARKER=${escapeCmdSetValue(markerPath)}"`, - `set "ORCA_SETUP_NONCE=${escapeCmdSetValue(nonce)}"`, - 'echo Waiting for setup to finish before starting agent... 1>&2', - `powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ${quoteWindowsArg(script)}`, - 'set "ORCA_SETUP_STATUS=!ERRORLEVEL!"', - 'exit /b !ORCA_SETUP_STATUS!' - ]) + return encodePowerShellInvocation(script) } -function wrapCmd(parts: string[]): string { - return `cmd.exe /d /s /v:on /c ${quoteWindowsArg(parts.join(' & '))}` +function encodePowerShellInvocation(script: string): string { + return `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encodePowerShellCommand(script)}` } function quotePosixArg(value: string): string { @@ -241,12 +260,8 @@ function quotePosixArg(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'` } -function quoteWindowsArg(value: string): string { - return `"${value.replace(/"/g, '""')}"` -} - -function escapeCmdSetValue(value: string): string { - return value.replace(/"/g, '""').replace(/[%!^]/g, (char) => `^${char}`) +function quotePowerShellString(value: string): string { + return `'${value.replace(/'/g, "''")}'` } export function getSetupAgentSequenceShellForTests( diff --git a/src/shared/setup-agent-sequencing.windows.test.ts b/src/shared/setup-agent-sequencing.windows.test.ts new file mode 100644 index 00000000000..b964eb241a7 --- /dev/null +++ b/src/shared/setup-agent-sequencing.windows.test.ts @@ -0,0 +1,174 @@ +import { spawn } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { tmpdir } from 'node:os' + +import { afterEach, describe, expect, it } from 'vitest' + +import { + createSequencedSetupAgentCommands, + SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV +} from './setup-agent-sequencing' + +const TEMP_DIRS: string[] = [] + +afterEach(() => { + for (const dir of TEMP_DIRS.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe.skipIf(process.platform !== 'win32')('Windows setup-agent sequencing', () => { + it.each([ + 'path with spaces', + 'ampersand&parentheses(test)', + 'caret^percent%bang!', + "apostrophe's directory", + 'Unicode-한글-abc' + ])('preserves the native runner path in %s', async (directoryName) => { + const tempDir = makeTempDir(directoryName) + const runnerScriptPath = join(tempDir, 'setup runner.cmd') + const startupScriptPath = join(tempDir, 'agent startup.ps1') + const logPath = join(dirname(tempDir), 'sequence.log') + const prompt = 'spaces & pipe | caret ^ percent % bang ! "quotes" Unicode 한글 trailing\\' + + writeFileSync( + runnerScriptPath, + ['@echo off', `>> "${logPath}" echo setup-done`, 'exit /b 0'].join('\r\n'), + 'utf8' + ) + writeFileSync( + startupScriptPath, + [ + 'param([string]$Value)', + '$utf8 = [System.Text.UTF8Encoding]::new($false)', + `[System.IO.File]::AppendAllText('${quotePowerShell(logPath)}', $Value + [Environment]::NewLine, $utf8)` + ].join('\r\n'), + 'utf8' + ) + + const commands = createSequencedSetupAgentCommands({ + runnerScriptPath, + startupCommand: `& '${quotePowerShell(startupScriptPath)}' '${quotePowerShell(prompt)}'`, + platform: 'windows', + nonce: 'windows-sequence', + waitTimeoutSeconds: 2 + }) + + const setupExit = await waitForExit( + spawnWindowsCommand(dirname(tempDir), 'run setup.cmd', commands.setupCommand) + ) + expect(setupExit.code).toBe(0) + expect(readFileSync(`${runnerScriptPath}.windows-sequence.done`, 'utf8')).toBe( + 'windows-sequence:0\r\n' + ) + + const startupExit = await waitForExit( + spawnWindowsCommand( + dirname(tempDir), + 'run startup.cmd', + commands.startupCommand, + commands.startupEnv + ) + ) + expect(startupExit.code).toBe(0) + expect(startupExit.stderr).toContain('Waiting for setup to finish before starting agent...') + expect(readFileSync(logPath, 'utf8')).toBe(`setup-done\r\n${prompt}\r\n`) + }) + + it('keeps the startup command out of generated cmd.exe source', () => { + const startupCommand = 'agent --prompt "& | ^ % ! 한글 trailing\\"' + const commands = createSequencedSetupAgentCommands({ + runnerScriptPath: 'C:\\repo\\setup-runner.cmd', + startupCommand, + platform: 'windows', + nonce: 'windows-sequence' + }) + + expect(commands.setupCommand).not.toContain(startupCommand) + expect(commands.startupCommand).not.toContain(startupCommand) + expect(commands.startupEnv?.[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]).toBe(startupCommand) + }) + + it('propagates setup failure without launching the agent', async () => { + const tempDir = makeTempDir('failure path & metacharacters!') + const runnerScriptPath = join(tempDir, 'setup runner.cmd') + const startupScriptPath = join(tempDir, 'agent startup.cmd') + const startupLogPath = join(dirname(tempDir), 'agent-started.log') + + writeFileSync(runnerScriptPath, '@echo off\r\nexit /b 37\r\n', 'utf8') + writeFileSync( + startupScriptPath, + `@echo off\r\necho started>"${startupLogPath}"\r\nexit /b 0\r\n`, + 'utf8' + ) + const commands = createSequencedSetupAgentCommands({ + runnerScriptPath, + startupCommand: `cmd.exe /d /c "${startupScriptPath}"`, + platform: 'windows', + nonce: 'failed-windows-sequence', + waitTimeoutSeconds: 2 + }) + + const setupExit = await waitForExit( + spawnWindowsCommand(dirname(tempDir), 'run failed setup.cmd', commands.setupCommand) + ) + expect(setupExit.code).toBe(37) + expect(readFileSync(`${runnerScriptPath}.failed-windows-sequence.done`, 'utf8')).toBe( + 'failed-windows-sequence:37\r\n' + ) + + const startupExit = await waitForExit( + spawnWindowsCommand( + dirname(tempDir), + 'run blocked startup.cmd', + commands.startupCommand, + commands.startupEnv + ) + ) + expect(startupExit.code).toBe(37) + expect(startupExit.stderr).toContain('Setup failed; skipping agent startup.') + expect(existsSync(startupLogPath)).toBe(false) + }) +}) + +function makeTempDir(directoryName: string): string { + const root = mkdtempSync(join(tmpdir(), 'orca-setup-sequencing-')) + TEMP_DIRS.push(root) + const dir = join(root, directoryName) + mkdirSync(dir) + return dir +} + +function spawnWindowsCommand( + dir: string, + filename: string, + command: string, + env: Record = {} +): ReturnType { + const scriptPath = join(dir, filename) + writeFileSync(scriptPath, `@echo off\r\n${command}\r\nexit /b %ERRORLEVEL%\r\n`, 'utf8') + return spawn('cmd.exe', ['/d', '/c', scriptPath], { + stdio: 'pipe', + env: { ...process.env, ...env } + }) +} + +function quotePowerShell(value: string): string { + return value.replace(/'/g, "''") +} + +function waitForExit( + child: ReturnType +): Promise<{ code: number | null; stderr: string }> { + return new Promise((resolve, reject) => { + let stderr = '' + child.stderr?.on('data', (chunk: Buffer | string) => { + stderr += chunk.toString() + }) + child.once('error', reject) + child.once('close', (code) => { + resolve({ code, stderr }) + }) + }) +} From 3e4abef0891c136bd4bedf0a6ca2b0be486bf821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=99=E8=BE=89?= <42717106+OnlyYu1996@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:58:55 +0800 Subject: [PATCH 05/67] fix(settings): keep local WSL settings scoped to the desktop host (#9635) * fix(settings): scope local WSL settings to the desktop host * fix(settings): verify local WSL capability ownership * fix(settings): respect capability host ownership * fix(settings): isolate paired host capabilities * fix(settings): key web capabilities to paired host --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../agent/AgentSettingsDialog.test.tsx | 247 ++++++++++++++++++ .../components/agent/AgentSettingsDialog.tsx | 36 +-- .../src/components/settings/Settings.tsx | 65 +++-- ...vigationMetadata.capability-owner.test.tsx | 134 ++++++++++ .../useSettingsNavigationMetadata.test.ts | 48 ++++ .../hooks/useSettingsNavigationMetadata.ts | 42 ++- ...WindowsTerminalCapabilityOwnerKey.test.tsx | 166 ++++++++++++ .../useWindowsTerminalCapabilityOwnerKey.ts | 51 ++++ .../lib/windows-terminal-capabilities.test.ts | 135 ++++++++++ .../src/lib/windows-terminal-capabilities.ts | 21 ++ 10 files changed, 904 insertions(+), 41 deletions(-) create mode 100644 src/renderer/src/components/agent/AgentSettingsDialog.test.tsx create mode 100644 src/renderer/src/hooks/useSettingsNavigationMetadata.capability-owner.test.tsx create mode 100644 src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.test.tsx create mode 100644 src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.ts diff --git a/src/renderer/src/components/agent/AgentSettingsDialog.test.tsx b/src/renderer/src/components/agent/AgentSettingsDialog.test.tsx new file mode 100644 index 00000000000..9c53f03f7b7 --- /dev/null +++ b/src/renderer/src/components/agent/AgentSettingsDialog.test.tsx @@ -0,0 +1,247 @@ +// @vitest-environment happy-dom + +import { act, createElement, type ComponentProps, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' +import type { GlobalSettings } from '../../../../shared/types' +import type { AgentsPane } from '@/components/settings/AgentsPane' +import { resetWindowsTerminalCapabilitiesForTests } from '@/lib/windows-terminal-capabilities' + +const testState = vi.hoisted(() => ({ + settings: null as GlobalSettings | null, + updateSettings: vi.fn(), + agentsPaneProps: null as ComponentProps | null, + isWebClient: false, + runtimeEnvironments: [] as { id: string; createdAt: number; pairingRevision?: number }[] +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: object) => unknown) => + selector({ + settings: testState.settings, + updateSettings: testState.updateSettings, + runtimeEnvironments: testState.runtimeEnvironments, + runtimeStatusByEnvironmentId: new Map() + }) +})) + +vi.mock('@/components/settings/AgentsPane', () => ({ + AgentsPane: (props: ComponentProps) => { + testState.agentsPaneProps = props + return null + } +})) + +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ children }: { children: ReactNode }) => children, + DialogContent: ({ children }: { children: ReactNode }) => children, + DialogDescription: ({ children }: { children: ReactNode }) => children, + DialogHeader: ({ children }: { children: ReactNode }) => children, + DialogTitle: ({ children }: { children: ReactNode }) => children +})) + +vi.mock('@/lib/web-client-location', () => ({ + isWebClientLocation: () => testState.isWebClient +})) + +import AgentSettingsDialog from './AgentSettingsDialog' + +function installCapabilityTransports(localHostPlatform: NodeJS.Platform = 'win32'): { + localWslAvailable: ReturnType + localWslDistros: ReturnType + runtimeGetStatus: ReturnType + runtimeEnvironmentCall: ReturnType +} { + const localWslAvailable = vi.fn().mockResolvedValue(true) + const localWslDistros = vi.fn().mockResolvedValue(['Ubuntu']) + const runtimeGetStatus = vi.fn().mockResolvedValue({ hostPlatform: localHostPlatform }) + const runtimeEnvironmentCall = vi.fn(async (args: { method: string }) => ({ + id: args.method, + ok: true, + result: + args.method === 'status.get' + ? { + hostPlatform: 'linux', + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2 + } + : args.method === 'host.wsl.listDistros' + ? [] + : false + })) + + Object.defineProperty(window, 'api', { + configurable: true, + value: { + wsl: { + isAvailable: localWslAvailable, + listDistros: localWslDistros + }, + pwsh: { isAvailable: vi.fn().mockResolvedValue(true) }, + gitBash: { isAvailable: vi.fn().mockResolvedValue(false) }, + runtime: { getStatus: runtimeGetStatus }, + runtimeEnvironments: { call: runtimeEnvironmentCall } + } as unknown as Window['api'] + }) + return { localWslAvailable, localWslDistros, runtimeGetStatus, runtimeEnvironmentCall } +} + +describe('AgentSettingsDialog', () => { + let root: Root + + beforeEach(() => { + testState.settings = { + ...getDefaultSettings('/tmp'), + activeRuntimeEnvironmentId: 'remote-linux' + } + testState.updateSettings.mockReset() + testState.agentsPaneProps = null + testState.isWebClient = false + testState.runtimeEnvironments = [] + const container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + resetWindowsTerminalCapabilitiesForTests() + document.body.replaceChildren() + vi.unstubAllGlobals() + }) + + it('reads desktop WSL capabilities instead of the active Linux runtime', async () => { + vi.stubGlobal('navigator', { userAgent: 'Windows' }) + const { localWslAvailable, runtimeEnvironmentCall } = installCapabilityTransports() + + await act(async () => { + root.render(createElement(AgentSettingsDialog, { open: true, onOpenChange: vi.fn() })) + await Promise.resolve() + await Promise.resolve() + }) + + expect(localWslAvailable).toHaveBeenCalledTimes(1) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(testState.agentsPaneProps).toMatchObject({ + wslSupportedPlatform: true, + wslAvailable: true, + wslDistros: ['Ubuntu'], + wslCapabilitiesLoading: false + }) + }) + + it('does not expose the desktop runtime setting on a non-Windows desktop', async () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + const { localWslAvailable, runtimeEnvironmentCall } = installCapabilityTransports() + + await act(async () => { + root.render(createElement(AgentSettingsDialog, { open: true, onOpenChange: vi.fn() })) + await Promise.resolve() + }) + + expect(localWslAvailable).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(testState.agentsPaneProps).toMatchObject({ wslSupportedPlatform: false }) + }) + + it('uses the paired server capability transport for a web client', async () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + testState.isWebClient = true + const { localWslAvailable, runtimeEnvironmentCall } = installCapabilityTransports() + + await act(async () => { + root.render(createElement(AgentSettingsDialog, { open: true, onOpenChange: vi.fn() })) + await Promise.resolve() + await Promise.resolve() + }) + + expect(localWslAvailable).toHaveBeenCalledTimes(1) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(testState.agentsPaneProps).toMatchObject({ + wslSupportedPlatform: true, + wslAvailable: true, + wslDistros: ['Ubuntu'] + }) + }) + + it('ignores a Windows browser user agent when the paired server is Linux', async () => { + vi.stubGlobal('navigator', { userAgent: 'Windows' }) + testState.isWebClient = true + const { localWslAvailable, runtimeEnvironmentCall } = installCapabilityTransports('linux') + + await act(async () => { + root.render(createElement(AgentSettingsDialog, { open: true, onOpenChange: vi.fn() })) + await Promise.resolve() + await Promise.resolve() + }) + + expect(localWslAvailable).toHaveBeenCalledTimes(1) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(testState.agentsPaneProps).toMatchObject({ wslSupportedPlatform: false }) + }) + + it.each([ + { + name: 'Windows to Linux', + firstPlatform: 'win32' as const, + firstAvailable: true, + firstDistros: ['Ubuntu'], + secondPlatform: 'linux' as const, + secondAvailable: false, + secondDistros: [] + }, + { + name: 'Linux to Windows', + firstPlatform: 'linux' as const, + firstAvailable: false, + firstDistros: [], + secondPlatform: 'win32' as const, + secondAvailable: true, + secondDistros: ['Debian'] + } + ])('refreshes paired-server capabilities after re-pairing: $name', async (args) => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + testState.isWebClient = true + testState.settings = { ...testState.settings!, activeRuntimeEnvironmentId: null } + testState.runtimeEnvironments = [{ id: 'paired-a', createdAt: 1 }] + const { localWslAvailable, localWslDistros, runtimeGetStatus } = installCapabilityTransports( + args.firstPlatform + ) + localWslAvailable.mockResolvedValue(args.firstAvailable) + localWslDistros.mockResolvedValue(args.firstDistros) + + await act(async () => { + root.render(createElement(AgentSettingsDialog, { open: true, onOpenChange: vi.fn() })) + await Promise.resolve() + await Promise.resolve() + }) + expect(testState.agentsPaneProps).toMatchObject({ + wslSupportedPlatform: args.firstPlatform === 'win32', + wslAvailable: args.firstAvailable, + wslDistros: args.firstDistros + }) + + testState.settings = { + ...testState.settings!, + activeRuntimeEnvironmentId: null + } + testState.runtimeEnvironments = [{ id: 'paired-b', createdAt: 2 }] + localWslAvailable.mockResolvedValue(args.secondAvailable) + localWslDistros.mockResolvedValue(args.secondDistros) + runtimeGetStatus.mockResolvedValue({ hostPlatform: args.secondPlatform }) + await act(async () => { + root.render(createElement(AgentSettingsDialog, { open: true, onOpenChange: vi.fn() })) + await Promise.resolve() + await Promise.resolve() + }) + + expect(testState.agentsPaneProps).toMatchObject({ + wslSupportedPlatform: args.secondPlatform === 'win32', + wslAvailable: args.secondAvailable, + wslDistros: args.secondDistros + }) + expect(localWslAvailable).toHaveBeenCalledTimes(2) + expect(runtimeGetStatus).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/components/agent/AgentSettingsDialog.tsx b/src/renderer/src/components/agent/AgentSettingsDialog.tsx index 637ee48e897..f67556b3325 100644 --- a/src/renderer/src/components/agent/AgentSettingsDialog.tsx +++ b/src/renderer/src/components/agent/AgentSettingsDialog.tsx @@ -10,11 +10,11 @@ import { AgentsPane } from '@/components/settings/AgentsPane' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import { - getWindowsTerminalCapabilityOwnerKey, - useWindowsTerminalCapabilities + isWindowsTerminalCapabilityHost, + useLocalWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { isWebClientLocation } from '@/lib/web-client-location' +import { useWindowsTerminalCapabilityOwnerKey } from '@/hooks/useWindowsTerminalCapabilityOwnerKey' type AgentSettingsDialogProps = { open: boolean @@ -27,20 +27,24 @@ export default function AgentSettingsDialog({ }: AgentSettingsDialogProps): React.JSX.Element | null { const settings = useAppStore((s) => s.settings) const updateSettings = useAppStore((s) => s.updateSettings) - const runtimeTarget = getActiveRuntimeTarget(settings) - const runtimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim() || null - const capabilitiesOwnerKey = getWindowsTerminalCapabilityOwnerKey(runtimeEnvironmentId) const isWindowsRenderer = typeof navigator !== 'undefined' && navigator.userAgent.includes('Windows') const isWebClient = isWebClientLocation() - const windowsTerminalCapabilities = useWindowsTerminalCapabilities( - open && (isWindowsRenderer || isWebClient || runtimeTarget.kind === 'environment'), - false, - capabilitiesOwnerKey, - runtimeTarget + const runtimeCapabilityOwnerKey = useWindowsTerminalCapabilityOwnerKey( + settings?.activeRuntimeEnvironmentId ) - const wslSupportedPlatform = - isWindowsRenderer || windowsTerminalCapabilities.hostPlatform === 'win32' + const localCapabilityOwnerKey = isWebClient ? runtimeCapabilityOwnerKey : 'local' + const localWindowsTerminalCapabilities = useLocalWindowsTerminalCapabilities( + open && (isWindowsRenderer || isWebClient), + false, + localCapabilityOwnerKey + ) + const wslSupportedPlatform = isWindowsTerminalCapabilityHost({ + isWindowsRenderer, + isWebClient, + target: { kind: 'local' }, + hostPlatform: localWindowsTerminalCapabilities.hostPlatform + }) if (!settings) { return null @@ -69,9 +73,9 @@ export default function AgentSettingsDialog({ settings={settings} updateSettings={updateSettings} wslSupportedPlatform={wslSupportedPlatform} - wslAvailable={windowsTerminalCapabilities.wslAvailable} - wslDistros={windowsTerminalCapabilities.wslDistros} - wslCapabilitiesLoading={windowsTerminalCapabilities.isLoading} + wslAvailable={localWindowsTerminalCapabilities.wslAvailable} + wslDistros={localWindowsTerminalCapabilities.wslDistros} + wslCapabilitiesLoading={localWindowsTerminalCapabilities.isLoading} /> diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 4ebacbb93e4..51c26f8c5ab 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -77,9 +77,11 @@ import { isIntentionalAppRestartInProgress } from '@/lib/updater-beforeunload' import { registerWindowCloseGuard } from '../window-close-request-coordinator' import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client' import { - getWindowsTerminalCapabilityOwnerKey, + isWindowsTerminalCapabilityHost, + useLocalWindowsTerminalCapabilities, useWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' +import { useWindowsTerminalCapabilityOwnerKey } from '@/hooks/useWindowsTerminalCapabilityOwnerKey' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { getShortcutPlatform } from '@/lib/shortcut-platform' import { keybindingMatchesAction } from '../../../../shared/keybindings' @@ -834,32 +836,59 @@ function Settings(): React.JSX.Element { }), [activeSectionId, mountedSectionIds, navSections, settingsSearchQuery, visibleSectionIds] ) - const windowsTerminalCapabilityOwnerKey = getWindowsTerminalCapabilityOwnerKey( + const windowsTerminalCapabilityOwnerKey = useWindowsTerminalCapabilityOwnerKey( settings?.activeRuntimeEnvironmentId ) const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings]) + const capabilityLoadTarget = useMemo( + () => (isWebClient ? { kind: 'local' as const } : runtimeTarget), + [isWebClient, runtimeTarget] + ) const hasActiveRuntimeEnvironment = Boolean(settings?.activeRuntimeEnvironmentId?.trim()) const needsRepoWindowsRuntimeCapabilities = [...neededSectionIds].some((sectionId) => sectionId.startsWith('repo-') ) + const needsLocalWindowsRuntimeCapabilities = + (isWindows || isWebClient) && + (neededSectionIds.has('agents') || neededSectionIds.has('general')) const shouldLoadWindowsTerminalCapabilities = hasActiveRuntimeEnvironment || ((isWindows || isWebClient) && (neededSectionIds.has('terminal') || - neededSectionIds.has('general') || neededSectionIds.has('accounts') || - neededSectionIds.has('agents') || - needsRepoWindowsRuntimeCapabilities)) - // Why: General owns the Orca CLI controls, including WSL skill-location setup. + needsRepoWindowsRuntimeCapabilities || + (runtimeTarget.kind === 'local' && needsLocalWindowsRuntimeCapabilities))) + // Why: terminal, account, and repository settings describe the active execution host. const windowsTerminalCapabilities = useWindowsTerminalCapabilities( shouldLoadWindowsTerminalCapabilities, true, windowsTerminalCapabilityOwnerKey, - runtimeTarget + capabilityLoadTarget ) + // Why: global agent and project defaults belong to the desktop, not its active remote. + const remoteViewLocalWindowsRuntimeCapabilities = useLocalWindowsTerminalCapabilities( + needsLocalWindowsRuntimeCapabilities && runtimeTarget.kind === 'environment' && !isWebClient, + true, + 'local' + ) + const localWindowsRuntimeCapabilities = + runtimeTarget.kind === 'local' || isWebClient + ? windowsTerminalCapabilities + : remoteViewLocalWindowsRuntimeCapabilities // Why: only supported-but-unavailable WSL (Windows) should render disabled controls, not unsupported WSL (macOS/Linux). - const wslSupportedPlatform = isWindows || windowsTerminalCapabilities.hostPlatform === 'win32' - const isWindowsTerminalHost = isWindows || windowsTerminalCapabilities.hostPlatform === 'win32' + const runtimeWslSupportedPlatform = isWindowsTerminalCapabilityHost({ + isWindowsRenderer: isWindows, + isWebClient, + target: runtimeTarget, + hostPlatform: windowsTerminalCapabilities.hostPlatform + }) + const localWslSupportedPlatform = isWindowsTerminalCapabilityHost({ + isWindowsRenderer: isWindows, + isWebClient, + target: { kind: 'local' }, + hostPlatform: localWindowsRuntimeCapabilities.hostPlatform + }) + const isWindowsTerminalHost = runtimeWslSupportedPlatform if ([...neededSectionIds].some((id) => !mountedSectionIds.has(id))) { // Why: record newly needed sections during render so panes don't wait for a follow-up Effect. @@ -1183,10 +1212,10 @@ function Settings(): React.JSX.Element { ) : null} @@ -1211,7 +1240,7 @@ function Settings(): React.JSX.Element { ) : null} diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.capability-owner.test.tsx b/src/renderer/src/hooks/useSettingsNavigationMetadata.capability-owner.test.tsx new file mode 100644 index 00000000000..1cb259a8578 --- /dev/null +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.capability-owner.test.tsx @@ -0,0 +1,134 @@ +// @vitest-environment happy-dom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../shared/constants' +import type { GlobalSettings } from '../../../shared/types' +import type { SettingsNavSection } from '@/lib/settings-navigation-types' +import { resetWindowsTerminalCapabilitiesForTests } from '@/lib/windows-terminal-capabilities' + +const testState = vi.hoisted(() => ({ + settings: null as GlobalSettings | null, + sections: null as SettingsNavSection[] | null, + runtimeEnvironments: [] as { id: string; createdAt: number; pairingRevision?: number }[] +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: object) => unknown) => + selector({ + settings: testState.settings, + repos: [], + runtimeEnvironments: testState.runtimeEnvironments, + runtimeStatusByEnvironmentId: new Map() + }) +})) + +vi.mock('@/hooks/useLinearProviderConnected', () => ({ + useLinearProviderConnected: () => false +})) + +vi.mock('@/lib/web-client-location', () => ({ + isWebClientLocation: () => true +})) + +import { useSettingsNavigationMetadata } from './useSettingsNavigationMetadata' + +function Probe(): null { + testState.sections = useSettingsNavigationMetadata() + return null +} + +function hasAgentRuntimeEntry(): boolean { + return ( + testState.sections + ?.find((section) => section.id === 'agents') + ?.searchEntries.some((entry) => entry.title === 'Agent Runtime') ?? false + ) +} + +describe('settings navigation capability ownership', () => { + let root: Root + + beforeEach(() => { + testState.settings = { + ...getDefaultSettings('/tmp'), + activeRuntimeEnvironmentId: null + } + testState.runtimeEnvironments = [{ id: 'paired-a', createdAt: 1 }] + testState.sections = null + const container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + resetWindowsTerminalCapabilitiesForTests() + document.body.replaceChildren() + vi.unstubAllGlobals() + }) + + it.each([ + { + name: 'Windows to Linux', + firstPlatform: 'win32' as const, + secondPlatform: 'linux' as const, + firstVisible: true, + secondVisible: false + }, + { + name: 'Linux to Windows', + firstPlatform: 'linux' as const, + secondPlatform: 'win32' as const, + firstVisible: false, + secondVisible: true + } + ])('rebuilds web metadata from the new paired host: $name', async (args) => { + const wslIsAvailable = vi.fn().mockResolvedValue(false) + const wslListDistros = vi.fn().mockResolvedValue([]) + const pwshIsAvailable = vi.fn().mockResolvedValue(false) + const gitBashIsAvailable = vi.fn().mockResolvedValue(false) + const runtimeGetStatus = vi + .fn() + .mockResolvedValueOnce({ hostPlatform: args.firstPlatform }) + .mockResolvedValueOnce({ hostPlatform: args.secondPlatform }) + const runtimeEnvironmentCall = vi.fn() + Object.defineProperty(window, 'api', { + configurable: true, + value: { + wsl: { isAvailable: wslIsAvailable, listDistros: wslListDistros }, + pwsh: { isAvailable: pwshIsAvailable }, + gitBash: { isAvailable: gitBashIsAvailable }, + runtime: { getStatus: runtimeGetStatus }, + runtimeEnvironments: { call: runtimeEnvironmentCall } + } as unknown as Window['api'] + }) + + await act(async () => { + root.render(createElement(Probe)) + await Promise.resolve() + await Promise.resolve() + }) + expect(hasAgentRuntimeEntry()).toBe(args.firstVisible) + + testState.settings = { + ...testState.settings!, + activeRuntimeEnvironmentId: null + } + testState.runtimeEnvironments = [{ id: 'paired-b', createdAt: 2 }] + await act(async () => { + root.render(createElement(Probe)) + await Promise.resolve() + await Promise.resolve() + }) + + expect(hasAgentRuntimeEntry()).toBe(args.secondVisible) + expect(wslIsAvailable).toHaveBeenCalledTimes(2) + expect(wslListDistros).toHaveBeenCalledTimes(2) + expect(pwshIsAvailable).toHaveBeenCalledTimes(2) + expect(gitBashIsAvailable).toHaveBeenCalledTimes(2) + expect(runtimeGetStatus).toHaveBeenCalledTimes(2) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts index 4878f3cd94d..fe4dc94da12 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts @@ -204,6 +204,54 @@ describe('settings navigation metadata', () => { ) }) + it('does not expose local runtime settings from a remote Windows host', () => { + const sections = buildSettingsNavigationMetadata({ + isMac: false, + isWindows: false, + isLocalWindowsHost: false, + isWindowsTerminalHost: true, + isWebClient: false, + repos: [repo] + }) + + const agents = sections.find((section) => section.id === 'agents') + const general = sections.find((section) => section.id === 'general') + const terminal = sections.find((section) => section.id === 'terminal') + const repoSection = sections.find((section) => section.id === 'repo-repo-1') + + expect(agents?.searchEntries.some((entry) => entry.title === 'Agent Runtime')).toBe(false) + expect(general?.searchEntries.some((entry) => entry.title === 'Default Project Runtime')).toBe( + false + ) + expect(terminal?.searchEntries.some((entry) => entry.title === 'Default Shell')).toBe(true) + expect(repoSection?.searchEntries.some((entry) => entry.title === 'Project Runtime')).toBe(true) + }) + + it('keeps local runtime settings but hides remote Linux entries on a Windows desktop', () => { + const sections = buildSettingsNavigationMetadata({ + isMac: false, + isWindows: true, + isLocalWindowsHost: true, + isWindowsTerminalHost: false, + isWebClient: false, + repos: [repo] + }) + + const agents = sections.find((section) => section.id === 'agents') + const general = sections.find((section) => section.id === 'general') + const terminal = sections.find((section) => section.id === 'terminal') + const repoSection = sections.find((section) => section.id === 'repo-repo-1') + + expect(agents?.searchEntries.some((entry) => entry.title === 'Agent Runtime')).toBe(true) + expect(general?.searchEntries.some((entry) => entry.title === 'Default Project Runtime')).toBe( + true + ) + expect(terminal?.searchEntries.some((entry) => entry.title === 'Default Shell')).toBe(false) + expect(repoSection?.searchEntries.some((entry) => entry.title === 'Project Runtime')).toBe( + false + ) + }) + it('places Advanced near the bottom on desktop without putting it under Experimental', () => { const desktopIds = ids() diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index 8935eba63b7..df84c7ef0c7 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -77,9 +77,10 @@ import { getRepositoryPaneSearchEntries } from '@/components/settings/repository import { buildSettingsProjectList } from '@/components/settings/settings-project-list' import { isWebClientLocation } from '@/lib/web-client-location' import { - getWindowsTerminalCapabilityOwnerKey, + isWindowsTerminalCapabilityHost, useWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' +import { useWindowsTerminalCapabilityOwnerKey } from './useWindowsTerminalCapabilityOwnerKey' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { useLinearProviderConnected } from '@/hooks/useLinearProviderConnected' import { translate } from '@/i18n/i18n' @@ -114,6 +115,7 @@ function getDevToolsPaneSearchEntries(): SettingsNavSection['searchEntries'] { export function buildSettingsNavigationMetadata({ isMac, isWindows, + isLocalWindowsHost = isWindows, isWindowsTerminalHost = isWindows, isWebClient, isDev = import.meta.env.DEV, @@ -122,6 +124,7 @@ export function buildSettingsNavigationMetadata({ }: { isMac: boolean isWindows: boolean + isLocalWindowsHost?: boolean isWindowsTerminalHost?: boolean isWebClient: boolean isDev?: boolean @@ -156,7 +159,7 @@ export function buildSettingsNavigationMetadata({ 'Manage AI agents, set a default, and customize commands.' ), icon: Bot, - searchEntries: getAgentsPaneSearchEntries({ includeAgentRuntime: isWindowsTerminalHost }), + searchEntries: getAgentsPaneSearchEntries({ includeAgentRuntime: isLocalWindowsHost }), group: 'capabilities' }, { @@ -269,7 +272,7 @@ export function buildSettingsNavigationMetadata({ 'Workspace defaults, app setup, and maintenance.' ), icon: SlidersHorizontal, - searchEntries: getGeneralPaneSearchEntries({ includeProjectRuntime: isWindowsTerminalHost }), + searchEntries: getGeneralPaneSearchEntries({ includeProjectRuntime: isLocalWindowsHost }), group: 'setup' }, { @@ -623,17 +626,32 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { const isWindows = isWindowsUserAgent() const isWebClient = isWebClientLocation() const isLinearConnected = useLinearProviderConnected() - const windowsTerminalCapabilityOwnerKey = getWindowsTerminalCapabilityOwnerKey( + const windowsTerminalCapabilityOwnerKey = useWindowsTerminalCapabilityOwnerKey( settings?.activeRuntimeEnvironmentId ) const runtimeTarget = getActiveRuntimeTarget(settings) + const capabilityLoadTarget = isWebClient ? { kind: 'local' as const } : runtimeTarget const windowsTerminalCapabilities = useWindowsTerminalCapabilities( isWindows || isWebClient || runtimeTarget.kind === 'environment', false, windowsTerminalCapabilityOwnerKey, - runtimeTarget + capabilityLoadTarget ) - const isWindowsTerminalHost = isWindows || windowsTerminalCapabilities.hostPlatform === 'win32' + const isLocalWindowsHost = isWindowsTerminalCapabilityHost({ + isWindowsRenderer: isWindows, + isWebClient, + target: { kind: 'local' }, + hostPlatform: + isWebClient || runtimeTarget.kind === 'local' + ? windowsTerminalCapabilities.hostPlatform + : null + }) + const isWindowsTerminalHost = isWindowsTerminalCapabilityHost({ + isWindowsRenderer: isWindows, + isWebClient, + target: runtimeTarget, + hostPlatform: windowsTerminalCapabilities.hostPlatform + }) // Why: Settings and Cmd+J share this metadata so platform/runtime visibility // and search entries cannot drift. Keep this hook free of Settings pane UI @@ -643,6 +661,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { buildSettingsNavigationMetadata({ isMac, isWindows, + isLocalWindowsHost, isWindowsTerminalHost, isWebClient, isDev: import.meta.env.DEV, @@ -650,6 +669,15 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { repos }), // oxlint-disable-next-line react-hooks/exhaustive-deps -- activeLocale is read implicitly by the translate() calls inside buildSettingsNavigationMetadata; without it the memo keeps the previous language's sections. - [isMac, isWindows, isWindowsTerminalHost, isWebClient, isLinearConnected, repos, activeLocale] + [ + isMac, + isWindows, + isLocalWindowsHost, + isWindowsTerminalHost, + isWebClient, + isLinearConnected, + repos, + activeLocale + ] ) } diff --git a/src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.test.tsx b/src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.test.tsx new file mode 100644 index 00000000000..0b8a177016f --- /dev/null +++ b/src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.test.tsx @@ -0,0 +1,166 @@ +// @vitest-environment happy-dom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + resetWindowsTerminalCapabilitiesForTests, + useLocalWindowsTerminalCapabilities, + type WindowsTerminalCapabilities +} from '@/lib/windows-terminal-capabilities' + +const testState = vi.hoisted(() => ({ + runtimeEnvironments: [] as { id: string; createdAt: number; pairingRevision?: number }[], + runtimeStatusByEnvironmentId: new Map() +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: object) => unknown) => selector(testState) +})) + +vi.mock('@/lib/web-client-location', () => ({ + isWebClientLocation: () => true +})) + +import { + resolveWindowsTerminalCapabilityOwnerKey, + useWindowsTerminalCapabilityOwnerKey +} from './useWindowsTerminalCapabilityOwnerKey' + +describe('Windows terminal capability owner key', () => { + let root: Root + let latest: WindowsTerminalCapabilities | null + + beforeEach(() => { + testState.runtimeEnvironments = [{ id: 'paired-a', createdAt: 1 }] + testState.runtimeStatusByEnvironmentId = new Map() + latest = null + const container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + resetWindowsTerminalCapabilitiesForTests() + document.body.replaceChildren() + vi.unstubAllGlobals() + }) + + it.each([ + { + name: 'Windows to Linux', + firstPlatform: 'win32' as const, + firstAvailable: true, + firstDistros: ['Ubuntu'], + secondPlatform: 'linux' as const, + secondAvailable: false, + secondDistros: [] + }, + { + name: 'Linux to Windows', + firstPlatform: 'linux' as const, + firstAvailable: false, + firstDistros: [], + secondPlatform: 'win32' as const, + secondAvailable: true, + secondDistros: ['Debian'] + } + ])('re-probes the actual paired host with a null preference: $name', async (args) => { + const wslIsAvailable = vi + .fn() + .mockResolvedValueOnce(args.firstAvailable) + .mockResolvedValueOnce(args.secondAvailable) + const wslListDistros = vi + .fn() + .mockResolvedValueOnce(args.firstDistros) + .mockResolvedValueOnce(args.secondDistros) + const runtimeGetStatus = vi + .fn() + .mockResolvedValueOnce({ hostPlatform: args.firstPlatform }) + .mockResolvedValueOnce({ hostPlatform: args.secondPlatform }) + vi.stubGlobal('window', { + api: { + wsl: { isAvailable: wslIsAvailable, listDistros: wslListDistros }, + pwsh: { isAvailable: vi.fn().mockResolvedValue(false) }, + gitBash: { isAvailable: vi.fn().mockResolvedValue(false) }, + runtime: { getStatus: runtimeGetStatus } + } + }) + + function Probe(): null { + const ownerKey = useWindowsTerminalCapabilityOwnerKey(null) + latest = useLocalWindowsTerminalCapabilities(true, false, ownerKey) + return null + } + + await act(async () => { + root.render(createElement(Probe)) + await Promise.resolve() + await Promise.resolve() + }) + expect(latest).toMatchObject({ + hostPlatform: args.firstPlatform, + wslAvailable: args.firstAvailable, + wslDistros: args.firstDistros + }) + + testState.runtimeEnvironments = [{ id: 'paired-b', createdAt: 2 }] + await act(async () => { + root.render(createElement(Probe)) + await Promise.resolve() + await Promise.resolve() + }) + + expect(latest).toMatchObject({ + hostPlatform: args.secondPlatform, + wslAvailable: args.secondAvailable, + wslDistros: args.secondDistros + }) + expect(wslIsAvailable).toHaveBeenCalledTimes(2) + expect(runtimeGetStatus).toHaveBeenCalledTimes(2) + }) + + it('changes ownership for same-id re-pair and reconnect generations', () => { + const environment = { id: 'paired-a', createdAt: 1, pairingRevision: 2 } + const base = { + activeRuntimeEnvironmentId: null, + isWebClient: true, + runtimeEnvironments: [environment] + } + const first = resolveWindowsTerminalCapabilityOwnerKey({ + ...base, + runtimeStatusByEnvironmentId: new Map([['paired-a', { connectionGeneration: 1 }]]) + }) + const repaired = resolveWindowsTerminalCapabilityOwnerKey({ + ...base, + runtimeEnvironments: [{ ...environment, pairingRevision: 3 }], + runtimeStatusByEnvironmentId: new Map([['paired-a', { connectionGeneration: 1 }]]) + }) + const reconnected = resolveWindowsTerminalCapabilityOwnerKey({ + ...base, + runtimeStatusByEnvironmentId: new Map([['paired-a', { connectionGeneration: 2 }]]) + }) + + expect(new Set([first, repaired, reconnected])).toHaveLength(3) + }) + + it('preserves desktop-local and desktop-remote owner keys', () => { + const runtimeEnvironments = [{ id: 'remote-a', createdAt: 1, pairingRevision: 2 }] + + expect( + resolveWindowsTerminalCapabilityOwnerKey({ + activeRuntimeEnvironmentId: null, + isWebClient: false, + runtimeEnvironments + }) + ).toBe('local') + expect( + resolveWindowsTerminalCapabilityOwnerKey({ + activeRuntimeEnvironmentId: 'remote-a', + isWebClient: false, + runtimeEnvironments + }) + ).toBe('runtime:remote-a') + }) +}) diff --git a/src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.ts b/src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.ts new file mode 100644 index 00000000000..8f8cfccfc98 --- /dev/null +++ b/src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.ts @@ -0,0 +1,51 @@ +import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-environments' +import { useAppStore } from '@/store' +import { getWindowsTerminalCapabilityOwnerKey } from '@/lib/windows-terminal-capabilities' +import { isWebClientLocation } from '@/lib/web-client-location' + +type RuntimeCapabilityOwnerStatus = { + connectionGeneration?: number +} + +type RuntimeCapabilityOwnerEnvironment = Pick< + PublicKnownRuntimeEnvironment, + 'id' | 'createdAt' | 'pairingRevision' +> + +export function resolveWindowsTerminalCapabilityOwnerKey(args: { + activeRuntimeEnvironmentId?: string | null + isWebClient: boolean + runtimeEnvironments: readonly RuntimeCapabilityOwnerEnvironment[] + runtimeStatusByEnvironmentId?: ReadonlyMap + sshConnectionId?: string | null +}): string { + const activeEnvironmentId = args.activeRuntimeEnvironmentId?.trim() || null + const environment = args.isWebClient ? (args.runtimeEnvironments[0] ?? null) : null + const ownerKey = getWindowsTerminalCapabilityOwnerKey( + environment?.id ?? activeEnvironmentId, + args.sshConnectionId + ) + if (!environment) { + return ownerKey + } + const pairingRevision = environment.pairingRevision ?? environment.createdAt + const connectionGeneration = + args.runtimeStatusByEnvironmentId?.get(environment.id)?.connectionGeneration ?? 0 + return `${ownerKey}:pairing:${pairingRevision}:connection:${connectionGeneration}` +} + +export function useWindowsTerminalCapabilityOwnerKey( + activeRuntimeEnvironmentId?: string | null, + sshConnectionId?: string | null +): string { + const isWebClient = isWebClientLocation() + return useAppStore((state) => + resolveWindowsTerminalCapabilityOwnerKey({ + activeRuntimeEnvironmentId, + isWebClient, + runtimeEnvironments: state.runtimeEnvironments ?? [], + runtimeStatusByEnvironmentId: state.runtimeStatusByEnvironmentId, + sshConnectionId + }) + ) +} diff --git a/src/renderer/src/lib/windows-terminal-capabilities.test.ts b/src/renderer/src/lib/windows-terminal-capabilities.test.ts index 18f00b5b44c..713ddfb7902 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.test.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.test.ts @@ -7,13 +7,62 @@ import { getCachedWindowsTerminalCapabilities, getWindowsTerminalCapabilityOwnerKey, hasCachedWindowsTerminalCapabilities, + isWindowsTerminalCapabilityHost, loadWindowsTerminalCapabilities, refreshWindowsTerminalCapabilities, resetWindowsTerminalCapabilitiesForTests, selectWindowsTerminalCapabilitiesForOwner, + useLocalWindowsTerminalCapabilities, useWindowsTerminalCapabilities } from './windows-terminal-capabilities' +describe('Windows terminal capability host ownership', () => { + it.each([ + { + name: 'local Windows desktop while the platform probe loads', + isWindowsRenderer: true, + isWebClient: false, + target: { kind: 'local' } as const, + hostPlatform: null, + expected: true + }, + { + name: 'Windows desktop attached to remote Linux', + isWindowsRenderer: true, + isWebClient: false, + target: { kind: 'environment', environmentId: 'linux' } as const, + hostPlatform: 'linux' as const, + expected: false + }, + { + name: 'Windows browser paired to a Linux server', + isWindowsRenderer: true, + isWebClient: true, + target: { kind: 'local' } as const, + hostPlatform: 'linux' as const, + expected: false + }, + { + name: 'non-Windows browser paired to a Windows server', + isWindowsRenderer: false, + isWebClient: true, + target: { kind: 'local' } as const, + hostPlatform: 'win32' as const, + expected: true + }, + { + name: 'non-Windows desktop attached to remote Windows', + isWindowsRenderer: false, + isWebClient: false, + target: { kind: 'environment', environmentId: 'windows' } as const, + hostPlatform: 'win32' as const, + expected: true + } + ])('$name', ({ expected, ...args }) => { + expect(isWindowsTerminalCapabilityHost(args)).toBe(expected) + }) +}) + function stubTerminalCapabilityApi(args: { wslAvailable: boolean pwshAvailable: boolean @@ -509,6 +558,92 @@ describe('windows terminal capabilities', () => { expect(detectRemoteWindowsTerminalCapabilities).toHaveBeenCalledTimes(1) }) + it.each([ + { + name: 'Windows to Linux', + firstPlatform: 'win32' as const, + firstAvailable: true, + firstDistros: ['Ubuntu'], + secondPlatform: 'linux' as const, + secondAvailable: false, + secondDistros: [] + }, + { + name: 'Linux to Windows', + firstPlatform: 'linux' as const, + firstAvailable: false, + firstDistros: [], + secondPlatform: 'win32' as const, + secondAvailable: true, + secondDistros: ['Debian'] + } + ])('re-probes the local transport when the paired owner changes: $name', async (args) => { + const wslIsAvailable = vi + .fn() + .mockResolvedValueOnce(args.firstAvailable) + .mockResolvedValueOnce(args.secondAvailable) + const wslListDistros = vi + .fn() + .mockResolvedValueOnce(args.firstDistros) + .mockResolvedValueOnce(args.secondDistros) + const runtimeGetStatus = vi + .fn() + .mockResolvedValueOnce({ hostPlatform: args.firstPlatform }) + .mockResolvedValueOnce({ hostPlatform: args.secondPlatform }) + vi.stubGlobal('window', { + api: { + wsl: { isAvailable: wslIsAvailable, listDistros: wslListDistros }, + pwsh: { isAvailable: vi.fn().mockResolvedValue(false) }, + gitBash: { isAvailable: vi.fn().mockResolvedValue(false) }, + runtime: { getStatus: runtimeGetStatus } + } + }) + let ownerKey = 'runtime:paired-a' + let latest: ReturnType | null = null + + function HookProbe(): null { + latest = useLocalWindowsTerminalCapabilities(true, false, ownerKey) + return null + } + + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + hookRoots.push(root) + + await act(async () => { + root.render(createElement(HookProbe)) + await Promise.resolve() + await Promise.resolve() + }) + expect(latest).toMatchObject({ + hostPlatform: args.firstPlatform, + wslAvailable: args.firstAvailable, + wslDistros: args.firstDistros + }) + + ownerKey = 'runtime:paired-b' + await act(async () => { + root.render(createElement(HookProbe)) + await Promise.resolve() + await Promise.resolve() + }) + + expect(latest).toMatchObject({ + hostPlatform: args.secondPlatform, + wslAvailable: args.secondAvailable, + wslDistros: args.secondDistros + }) + expect(wslIsAvailable).toHaveBeenCalledTimes(2) + expect(runtimeGetStatus).toHaveBeenCalledTimes(2) + expect(getCachedWindowsTerminalCapabilities('runtime:paired-a')).toMatchObject({ + hostPlatform: args.firstPlatform + }) + expect(getCachedWindowsTerminalCapabilities('runtime:paired-b')).toMatchObject({ + hostPlatform: args.secondPlatform + }) + }) + it('prunes expired runtime owner capability caches', async () => { stubTerminalCapabilityApi({ wslAvailable: false, diff --git a/src/renderer/src/lib/windows-terminal-capabilities.ts b/src/renderer/src/lib/windows-terminal-capabilities.ts index ba8da74c86a..37c888f41dc 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.ts @@ -263,6 +263,27 @@ export function useWindowsTerminalCapabilities( return selectWindowsTerminalCapabilitiesForOwner(state, enabled, resolvedOwnerKey) } +export function isWindowsTerminalCapabilityHost(args: { + isWindowsRenderer: boolean + isWebClient: boolean + target: WindowsTerminalCapabilityLoadTarget + hostPlatform: NodeJS.Platform | null +}): boolean { + return ( + args.hostPlatform === 'win32' || + (!args.isWebClient && args.target.kind === 'local' && args.isWindowsRenderer) + ) +} + +export function useLocalWindowsTerminalCapabilities( + enabled: boolean, + forceRefreshOnMount = false, + ownerKey = 'local' +): WindowsTerminalCapabilities { + // Why: desktop-owned defaults must not follow the active remote environment. + return useWindowsTerminalCapabilities(enabled, forceRefreshOnMount, ownerKey, { kind: 'local' }) +} + export function resetWindowsTerminalCapabilitiesForTests(): void { cachedCapabilitiesByOwnerKey.clear() pendingCapabilitiesByOwnerKey.clear() From d9fec8fd61d0349ae8cdf0eea594de7425db94c9 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 28 Jul 2026 22:58:58 -0400 Subject: [PATCH 06/67] fix(updater): report releases still being published (#8914) * fix(updater): distinguish releases still publishing (#8869) * fix(updater): preserve verified releases during probe outages (#8869) * test(updater): expect publishing copy for perf checks (#8869) * fix(updater): keep transport failures out of publishing copy (#8869) * fix(updater): preserve channel and feed fallback semantics (#8869) * test(updater): prove feed and asset failure boundaries (#8869) * test(updater): cover unavailable manifest probes (#8869) * fix(updater): fence publishing copy to stable releases (#8869) * fix(updater): preserve nudge deferral across release channels (#8869) * fix(updater): retain benign nudge handling for probe outages (#8869) * test(updater): preserve channel and transport proof fidelity (#8869) * test(updater): model asset HTTP status in feed fixtures (#8869) * fix(updater): preserve legacy prerelease probe handling (#8869) * test(updater): cover unavailable publishing-window nudge retention (#8869) * test(updater): prove publishing retry and channel cases (#8869) * fix(updater): preserve truthful readiness states * fix(updater): type release preflight failures * fix(updater): keep probe outages truthful * fix(updater): keep not-ready diagnostics neutral --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../updater-prerelease-feed-readiness.test.ts | 166 +++++++++++++- ...er-prerelease-feed-reproduction.fixture.ts | 8 + src/main/updater-prerelease-feed.test.ts | 25 +- src/main/updater-prerelease-feed.ts | 65 ++++-- src/main/updater.check-failure.test.ts | 125 +++++++++- src/main/updater.test.ts | 216 +++++++++++++----- src/main/updater.ts | 65 +++++- 7 files changed, 573 insertions(+), 97 deletions(-) create mode 100644 src/main/updater-prerelease-feed-reproduction.fixture.ts diff --git a/src/main/updater-prerelease-feed-readiness.test.ts b/src/main/updater-prerelease-feed-readiness.test.ts index d51d8cb4179..34340c0717e 100644 --- a/src/main/updater-prerelease-feed-readiness.test.ts +++ b/src/main/updater-prerelease-feed-readiness.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { publishingIncident } from './updater-prerelease-feed-reproduction.fixture' const { netFetchMock } = vi.hoisted(() => ({ netFetchMock: vi.fn() @@ -35,20 +36,35 @@ function isPlatformManifestRequest(url: string): boolean { function respondWithAtom( tags: string[], missingManifestTags: string[] = [], - missingAssetTags: string[] = [] + missingAssetTags: string[] = [], + unavailableManifestTags: string[] = [], + missingManifestStatus = 404 ): void { const missingManifests = new Set(missingManifestTags) const missingAssets = new Set(missingAssetTags) + const unavailableManifests = new Set(unavailableManifestTags) netFetchMock.mockImplementation((url: string, init?: { method?: string }) => { if (url === 'https://github.com/stablyai/orca/releases.atom') { - return Promise.resolve({ ok: true, text: () => Promise.resolve(buildAtomFeed(tags)) }) + return Promise.resolve({ + ok: true, + status: 200, + text: () => Promise.resolve(buildAtomFeed(tags)) + }) } const manifestMatch = url.match(/\/releases\/download\/([^/]+)\/latest(?:-[a-z]+)?\.yml$/) if (manifestMatch) { const tag = decodeURIComponent(manifestMatch[1]) + if (unavailableManifests.has(tag)) { + return Promise.resolve({ + ok: false, + status: 503, + text: () => Promise.resolve('') + }) + } return Promise.resolve({ ok: !missingManifests.has(tag), + status: missingManifests.has(tag) ? missingManifestStatus : 200, text: () => Promise.resolve(buildManifest(tag)) }) } @@ -57,11 +73,12 @@ function respondWithAtom( if (assetMatch && init?.method === 'HEAD') { return Promise.resolve({ ok: !missingAssets.has(decodeURIComponent(assetMatch[1])), + status: missingAssets.has(decodeURIComponent(assetMatch[1])) ? 404 : 200, text: () => Promise.resolve('') }) } - return Promise.resolve({ ok: false, text: () => Promise.resolve('') }) + return Promise.resolve({ ok: false, status: 503, text: () => Promise.resolve('') }) }) } @@ -111,7 +128,72 @@ describe('fetchNewerReleaseTagsWithReadiness', () => { netFetchMock.mockResolvedValue({ ok: false, text: () => Promise.resolve('') }) await expect(fetchNewerReleaseTagsWithReadiness('1.4.26', 1)).resolves.toEqual({ tags: [], - state: 'unavailable' + state: 'unavailable', + unavailableReason: 'feed' + }) + }) + + it('does not classify a non-positive tag limit as feed unavailability', async () => { + const { fetchNewerReleaseTagsWithReadiness } = await import('./updater-prerelease-feed') + + await expect(fetchNewerReleaseTagsWithReadiness('1.4.26', 0)).resolves.toEqual({ + tags: [], + state: 'no-newer' + }) + expect(netFetchMock).not.toHaveBeenCalled() + }) + + it('reproduces the v1.4.142 publishing incident as not-ready', async () => { + respondWithAtom( + publishingIncident.atomTags, + [publishingIncident.atomStableTag], + [publishingIncident.atomStableTag], + [], + publishingIncident.missingManifestStatus + ) + + const { fetchNewerReleaseTagsWithReadiness } = await import('./updater-prerelease-feed') + + expect( + await fetchNewerReleaseTagsWithReadiness(publishingIncident.installedVersion, 1, { + includePrerelease: false + }) + ).toEqual({ + tags: [], + state: publishingIncident.expectedState + }) + }) + + it('reports an unavailable newest manifest instead of pinning an older release', async () => { + respondWithAtom(['v1.4.28', 'v1.4.27'], [], [], ['v1.4.28']) + + const { fetchNewerReleaseTagsWithReadiness } = await import('./updater-prerelease-feed') + + await expect(fetchNewerReleaseTagsWithReadiness('1.4.26', 1)).resolves.toEqual({ + tags: [], + state: 'unavailable', + unavailableReason: 'manifest' + }) + }) + + it('reports transport failures as unavailable instead of not-ready', async () => { + netFetchMock.mockImplementation((url: string) => { + if (url === 'https://github.com/stablyai/orca/releases.atom') { + return Promise.resolve({ + ok: true, + status: 200, + text: () => Promise.resolve(buildAtomFeed(['v1.4.28'])) + }) + } + return Promise.reject(new Error('ETIMEDOUT')) + }) + + const { fetchNewerReleaseTagsWithReadiness } = await import('./updater-prerelease-feed') + + await expect(fetchNewerReleaseTagsWithReadiness('1.4.27', 1)).resolves.toEqual({ + tags: [], + state: 'unavailable', + unavailableReason: 'manifest' }) }) @@ -120,6 +202,7 @@ describe('fetchNewerReleaseTagsWithReadiness', () => { if (url === 'https://github.com/stablyai/orca/releases.atom') { return Promise.resolve({ ok: true, + status: 200, text: () => Promise.resolve(buildAtomFeed(['v1.4.28', 'v1.4.27'])) }) } @@ -129,14 +212,15 @@ describe('fetchNewerReleaseTagsWithReadiness', () => { const version = decodeURIComponent(manifestMatch[1]).replace(/^v/i, '') return Promise.resolve({ ok: true, + status: 200, text: () => Promise.resolve( [ `version: ${version}`, 'files:', - ` - url: Orca-${version}-mac.zip`, + ' - url: orca-windows-setup.exe', ' sha512: test', - ` - url: Orca-${version}-arm64-mac.zip`, + ` - url: Orca-${version}-mac.zip`, ' sha512: test', `path: Orca-${version}-mac.zip` ].join('\n') @@ -145,8 +229,12 @@ describe('fetchNewerReleaseTagsWithReadiness', () => { } if (init?.method === 'HEAD') { + const latest = url.includes('/v1.4.28/') + const unavailable = latest && url.endsWith('/Orca-1.4.28-mac.zip') + const missing = latest && url.endsWith('/orca-windows-setup.exe') return Promise.resolve({ - ok: !url.endsWith('/Orca-1.4.28-arm64-mac.zip'), + ok: !missing && !unavailable, + status: missing ? publishingIncident.missingWindowsAssetStatus : unavailable ? 503 : 200, text: () => Promise.resolve('') }) } @@ -165,12 +253,58 @@ describe('fetchNewerReleaseTagsWithReadiness', () => { }) }) + it('treats an explicit asset 404 as not-ready when another asset is unavailable', async () => { + netFetchMock.mockImplementation((url: string, init?: { method?: string }) => { + if (url === 'https://github.com/stablyai/orca/releases.atom') { + return Promise.resolve({ + ok: true, + status: 200, + text: () => Promise.resolve(buildAtomFeed(['v1.4.28'])) + }) + } + if (isPlatformManifestRequest(url)) { + return Promise.resolve({ + ok: true, + status: 200, + text: () => + Promise.resolve( + [ + 'version: 1.4.28', + 'files:', + ' - url: orca-windows-setup.exe', + ' sha512: test', + ' - url: Orca-1.4.28-mac.zip', + ' sha512: test' + ].join('\n') + ) + }) + } + if (init?.method === 'HEAD') { + const isWindowsAsset = url.endsWith('/orca-windows-setup.exe') + return Promise.resolve({ + ok: false, + status: isWindowsAsset ? publishingIncident.missingWindowsAssetStatus : 503, + text: () => Promise.resolve('') + }) + } + return Promise.resolve({ ok: false, status: 503, text: () => Promise.resolve('') }) + }) + + const { fetchNewerReleaseTagsWithReadiness } = await import('./updater-prerelease-feed') + + await expect(fetchNewerReleaseTagsWithReadiness('1.4.26', 1)).resolves.toEqual({ + tags: [], + state: 'not-ready' + }) + }) + it('accepts absolute manifest asset URLs without rewriting them to release asset paths', async () => { const assetUrls: string[] = [] netFetchMock.mockImplementation((url: string, init?: { method?: string }) => { if (url === 'https://github.com/stablyai/orca/releases.atom') { return Promise.resolve({ ok: true, + status: 200, text: () => Promise.resolve(buildAtomFeed(['v1.4.27'])) }) } @@ -192,7 +326,7 @@ describe('fetchNewerReleaseTagsWithReadiness', () => { if (init?.method === 'HEAD') { assetUrls.push(url) - return Promise.resolve({ ok: true, text: () => Promise.resolve('') }) + return Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve('') }) } return Promise.resolve({ ok: false, text: () => Promise.resolve('') }) @@ -214,18 +348,26 @@ describe('fetchNewerReleaseTagsWithReadiness', () => { } if (url.includes('/releases/download/v1.4.28/')) { - return Promise.resolve({ ok: true, text: () => Promise.resolve('files:\n - url: [') }) + return Promise.resolve({ + ok: true, + status: 200, + text: () => Promise.resolve('files:\n - url: [') + }) } if (url.includes('/releases/download/v1.4.27/') && isPlatformManifestRequest(url)) { - return Promise.resolve({ ok: true, text: () => Promise.resolve(buildManifest('v1.4.27')) }) + return Promise.resolve({ + ok: true, + status: 200, + text: () => Promise.resolve(buildManifest('v1.4.27')) + }) } if (init?.method === 'HEAD') { - return Promise.resolve({ ok: true, text: () => Promise.resolve('') }) + return Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve('') }) } - return Promise.resolve({ ok: false, text: () => Promise.resolve('') }) + return Promise.resolve({ ok: false, status: 503, text: () => Promise.resolve('') }) }) const { fetchNewerReleaseTag, fetchNewerReleaseTagsWithReadiness } = diff --git a/src/main/updater-prerelease-feed-reproduction.fixture.ts b/src/main/updater-prerelease-feed-reproduction.fixture.ts new file mode 100644 index 00000000000..53be3261113 --- /dev/null +++ b/src/main/updater-prerelease-feed-reproduction.fixture.ts @@ -0,0 +1,8 @@ +export const publishingIncident = { + installedVersion: '1.4.141', + atomStableTag: 'v1.4.142', + atomTags: ['v1.4.142', 'v1.4.142-rc.7'], + missingManifestStatus: 404, + missingWindowsAssetStatus: 404, + expectedState: 'not-ready' as const +} diff --git a/src/main/updater-prerelease-feed.test.ts b/src/main/updater-prerelease-feed.test.ts index f30fa276b65..bef53427e22 100644 --- a/src/main/updater-prerelease-feed.test.ts +++ b/src/main/updater-prerelease-feed.test.ts @@ -34,10 +34,12 @@ function buildManifest(tag: string): string { function respondWithAtom( tags: string[], missingManifestTags: string[] = [], - missingAssetTags: string[] = [] + missingAssetTags: string[] = [], + unavailableManifestTags: string[] = [] ): void { const missingManifests = new Set(missingManifestTags) const missingAssets = new Set(missingAssetTags) + const unavailableManifests = new Set(unavailableManifestTags) netFetchMock.mockImplementation((url: string, init?: { method?: string }) => { if (url === 'https://github.com/stablyai/orca/releases.atom') { return Promise.resolve({ @@ -49,8 +51,16 @@ function respondWithAtom( const manifestMatch = url.match(/\/releases\/download\/([^/]+)\/latest(?:-[a-z]+)?\.yml$/) if (manifestMatch) { const tag = decodeURIComponent(manifestMatch[1]) + if (unavailableManifests.has(tag)) { + return Promise.resolve({ + ok: false, + status: 503, + text: () => Promise.resolve('') + }) + } return Promise.resolve({ ok: !missingManifests.has(tag), + status: missingManifests.has(tag) ? 404 : 200, text: () => Promise.resolve(buildManifest(tag)) }) } @@ -59,6 +69,7 @@ function respondWithAtom( if (assetMatch && init?.method === 'HEAD') { return Promise.resolve({ ok: !missingAssets.has(decodeURIComponent(assetMatch[1])), + status: missingAssets.has(decodeURIComponent(assetMatch[1])) ? 404 : 200, text: () => Promise.resolve('') }) } @@ -290,6 +301,18 @@ describe('fetchNewerReleaseTag', () => { }) }) + it('reports manifest transport failures as unavailable', async () => { + respondWithAtom(['v1.4.2'], [], [], ['v1.4.2']) + + const { fetchNewerReleaseTagsWithReadiness } = await import('./updater-prerelease-feed') + + await expect(fetchNewerReleaseTagsWithReadiness('1.4.0', 1)).resolves.toEqual({ + tags: [], + state: 'unavailable', + unavailableReason: 'manifest' + }) + }) + it('reports not-ready with last-good when newer manifest assets are not reachable yet', async () => { respondWithAtom(['v1.4.3', 'v1.4.2', 'v1.4.1'], [], ['v1.4.3']) diff --git a/src/main/updater-prerelease-feed.ts b/src/main/updater-prerelease-feed.ts index 21a1f397ff4..6365ccb653d 100644 --- a/src/main/updater-prerelease-feed.ts +++ b/src/main/updater-prerelease-feed.ts @@ -103,7 +103,9 @@ function getManifestAssetNames(manifestText: string): string[] { return [...names] } -async function isReleaseAssetAvailable(tag: string, assetName: string): Promise { +type ReleaseReadiness = 'ready' | 'not-ready' | 'unavailable' + +async function isReleaseAssetAvailable(tag: string, assetName: string): Promise { try { const assetUrl = assetName.startsWith('http') ? assetName @@ -112,32 +114,45 @@ async function isReleaseAssetAvailable(tag: string, assetName: string): Promise< method: 'HEAD', signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }) - return res.ok + return res.status === 404 ? 'not-ready' : res.ok ? 'ready' : 'unavailable' } catch { - return false + return 'unavailable' } } -async function hasReadyPlatformManifest(tag: string): Promise { +async function getPlatformManifestReadiness(tag: string): Promise { try { // Why: cancelled/draft releases can appear in GitHub's atom feed before // they have updater manifests or the ZIP/exe/AppImage assets referenced by // those manifests. Pinning to those tags makes download clicks 404. const manifestUrl = getReleaseManifestUrl(tag) const res = await net.fetch(manifestUrl, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }) + if (res.status === 404) { + return 'not-ready' + } if (!res.ok) { - return false + return 'unavailable' + } + const manifestText = await res.text() + let assetNames: string[] + try { + assetNames = getManifestAssetNames(manifestText) + } catch { + return 'not-ready' } - const assetNames = getManifestAssetNames(await res.text()) if (assetNames.length === 0) { - return false + return 'not-ready' } const assetResults = await Promise.all( assetNames.map((assetName) => isReleaseAssetAvailable(tag, assetName)) ) - return assetResults.every(Boolean) + return assetResults.includes('not-ready') + ? 'not-ready' + : assetResults.includes('unavailable') + ? 'unavailable' + : 'ready' } catch { - return false + return 'unavailable' } } @@ -159,11 +174,11 @@ type FetchNewerReleaseTagOptions = { releaseFilter?: 'perf' } -export type FetchNewerReleaseTagsResult = { - tags: string[] - state: 'ready' | 'no-newer' | 'not-ready' | 'unavailable' - lastGoodTag?: string -} +export type FetchNewerReleaseTagsResult = + | { tags: string[]; state: 'ready' } + | { tags: string[]; state: 'no-newer' } + | { tags: string[]; state: 'not-ready'; lastGoodTag?: string } + | { tags: string[]; state: 'unavailable'; unavailableReason: 'feed' | 'manifest' } export async function fetchNewerReleaseTag( currentVersion: string, @@ -186,9 +201,12 @@ export async function fetchNewerReleaseTagsWithReadiness( options: FetchNewerReleaseTagOptions = {} ): Promise { const includePrerelease = options.includePrerelease ?? true + if (maxTags <= 0) { + return { tags: [], state: 'no-newer' } + } const tags = await fetchReleaseFeedTags() - if (!tags || maxTags <= 0) { - return { tags: [], state: 'unavailable' } + if (!tags) { + return { tags: [], state: 'unavailable', unavailableReason: 'feed' } } // Why: perf builds are explicit opt-in; regular prerelease checks should @@ -216,28 +234,35 @@ export async function fetchNewerReleaseTagsWithReadiness( probeCandidates.map(async ({ tag, version }) => ({ tag, version, - hasManifest: await hasReadyPlatformManifest(tag) + readiness: await getPlatformManifestReadiness(tag) })) ) const primaryIndex = manifestResults.findIndex( - ({ hasManifest, version }) => hasManifest && compareVersions(version, currentVersion) > 0 + ({ readiness, version }) => + readiness === 'ready' && compareVersions(version, currentVersion) > 0 ) if (primaryIndex === -1) { - const lastGoodTag = manifestResults.find(({ hasManifest }) => hasManifest)?.tag + if (manifestResults[0]?.readiness === 'unavailable') { + return { tags: [], state: 'unavailable', unavailableReason: 'manifest' } + } + const lastGoodTag = manifestResults.find(({ readiness }) => readiness === 'ready')?.tag return lastGoodTag ? { tags: [], state: 'not-ready', lastGoodTag } : { tags: [], state: 'not-ready' } } if (primaryIndex > 0) { + if (manifestResults[0]?.readiness === 'unavailable') { + return { tags: [], state: 'unavailable', unavailableReason: 'manifest' } + } return { tags: [], state: 'not-ready', lastGoodTag: manifestResults[primaryIndex].tag } } return { tags: manifestResults .slice(primaryIndex) - .filter(({ hasManifest }) => hasManifest) + .filter(({ readiness }) => readiness === 'ready') .slice(0, maxTags) .map(({ tag }) => tag), state: 'ready' diff --git a/src/main/updater.check-failure.test.ts b/src/main/updater.check-failure.test.ts index 1b5c1dfc7d5..c17dfef4c24 100644 --- a/src/main/updater.check-failure.test.ts +++ b/src/main/updater.check-failure.test.ts @@ -1,4 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { publishingIncident } from './updater-prerelease-feed-reproduction.fixture' + +const { netFetchMock } = vi.hoisted(() => ({ netFetchMock: vi.fn() })) const { appMock, browserWindowMock, nativeUpdaterMock, autoUpdaterMock, isMock, killAllPtyMock } = vi.hoisted(() => { @@ -72,7 +75,7 @@ vi.mock('electron', () => ({ BrowserWindow: browserWindowMock, autoUpdater: nativeUpdaterMock, powerMonitor: { on: vi.fn() }, - net: { fetch: vi.fn() } + net: { fetch: netFetchMock } })) vi.mock('electron-updater', () => ({ @@ -99,14 +102,54 @@ vi.mock('./updater-nudge', () => ({ const ONE_HOUR_MS = 60 * 60 * 1000 const THIRTY_SECONDS_MS = 30 * 1000 const FRIENDLY_MESSAGE = "Couldn't reach the update server. Try again in a few minutes." +const RELEASE_NOT_READY_MESSAGE = + "A newer release isn't available for this device yet. Check again later." +const NOT_READY_DIAGNOSTIC = 'Latest release artifacts are not ready' + +type NotReadyProbe = { + assetStatus?: number + manifestStatus?: number + manifestText: string +} + +function respondWithNotReadyRelease({ + assetStatus, + manifestStatus = 200, + manifestText +}: NotReadyProbe): void { + const atom = `${publishingIncident.atomTags + .map( + (tag) => + `${tag}` + ) + .join('')}` + netFetchMock.mockImplementation((url: string, init?: { method?: string }) => { + if (url === 'https://github.com/stablyai/orca/releases.atom') { + return Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve(atom) }) + } + if (init?.method === 'HEAD' && assetStatus !== undefined) { + return Promise.resolve({ + ok: assetStatus >= 200 && assetStatus < 300, + status: assetStatus, + text: () => Promise.resolve('') + }) + } + return Promise.resolve({ + ok: manifestStatus >= 200 && manifestStatus < 300, + status: manifestStatus, + text: () => Promise.resolve(manifestText) + }) + }) +} function makeBenignCheckFailure(message: string): void { + const error = new Error(message) autoUpdaterMock.checkForUpdates.mockImplementation(() => { autoUpdaterMock.emit('checking-for-update') queueMicrotask(() => { - autoUpdaterMock.emit('error', new Error(message)) + autoUpdaterMock.emit('error', error) }) - return Promise.reject(new Error(message)) + return Promise.reject(error) }) } @@ -125,6 +168,11 @@ describe('updater check failure handling', () => { killAllPtyMock.mockReset() vi.unstubAllGlobals() vi.useRealTimers() + netFetchMock.mockReset().mockResolvedValue({ + ok: true, + status: 200, + text: () => Promise.resolve('') + }) }) it('surfaces GitHub release-transition failures with calmer copy and no short retry', async () => { @@ -189,6 +237,46 @@ describe('updater check failure handling', () => { }) }) + it.each([ + { + caseName: 'manifest 404', + manifestStatus: publishingIncident.missingManifestStatus, + manifestText: '' + }, + { caseName: 'malformed manifest', manifestText: 'files:\n - url: [' }, + { caseName: 'empty manifest', manifestText: 'version: 1.4.142\nfiles: []' }, + { + assetStatus: publishingIncident.missingWindowsAssetStatus, + caseName: 'asset 404', + manifestText: 'version: 1.4.142\nfiles:\n - url: orca-windows-setup.exe' + } + ])( + 'maps $caseName into neutral artifact-readiness status and diagnostics', + async ({ assetStatus, manifestStatus, manifestText }) => { + appMock.getVersion.mockReturnValue(publishingIncident.installedVersion) + respondWithNotReadyRelease({ assetStatus, manifestStatus, manifestText }) + const warnMock = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) + checkForUpdatesFromMenu() + + await vi.waitFor(() => { + expect(sendMock).toHaveBeenCalledWith('updater:status', { + state: 'error', + message: RELEASE_NOT_READY_MESSAGE, + userInitiated: true + }) + }) + expect(warnMock).toHaveBeenCalledWith('[updater] benign check failure:', NOT_READY_DIAGNOSTIC) + expect(warnMock.mock.calls.flat().join(' ').toLowerCase()).not.toContain('publishing') + expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() + warnMock.mockRestore() + } + ) + it('silently drops background benign failures to idle and waits for the hourly retry', async () => { vi.useFakeTimers() makeBenignCheckFailure('Unable to find latest version on GitHub') @@ -218,6 +306,35 @@ describe('updater check failure handling', () => { expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2) }) + it('keeps typed not-ready probes idle and schedules the short retry', async () => { + vi.useFakeTimers() + appMock.getVersion.mockReturnValue(publishingIncident.installedVersion) + respondWithNotReadyRelease({ + manifestStatus: publishingIncident.missingManifestStatus, + manifestText: '' + }) + + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + const { setupAutoUpdater, checkForUpdates, getUpdateStatus } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) + checkForUpdates() + await vi.waitFor(() => { + expect(netFetchMock).toHaveBeenCalledTimes(2) + }) + + const statuses = sendMock.mock.calls + .filter(([channel]) => channel === 'updater:status') + .map(([, status]) => status) + expect(getUpdateStatus()).toEqual({ state: 'idle' }) + expect(statuses).not.toContainEqual(expect.objectContaining({ state: 'error' })) + + await vi.advanceTimersByTimeAsync(ONE_HOUR_MS) + expect(netFetchMock).toHaveBeenCalledTimes(4) + expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() + }) + it('backs off consecutive failing background retries instead of re-checking hourly forever', async () => { vi.useFakeTimers() makeBenignCheckFailure('Unable to find latest version on GitHub') @@ -232,7 +349,7 @@ describe('updater check failure handling', () => { await vi.advanceTimersByTimeAsync(0) expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) - // First retry keeps the fast 1h cadence (release-publishing windows). + // First retry keeps the fast 1h cadence for transient release-readiness failures. await vi.advanceTimersByTimeAsync(ONE_HOUR_MS) expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2) diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index f2204c6c5ac..b882f9c30b3 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -1493,6 +1493,31 @@ describe('updater', () => { }) }) + it('keeps prerelease publishing-window misses on the generic retry path', async () => { + appMock.getVersion.mockReturnValue('1.4.120-rc.5') + fetchNewerReleaseTagsMock.mockResolvedValue({ tags: [], state: 'not-ready' }) + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) + checkForUpdatesFromMenu({ includePrerelease: true }) + + await vi.waitFor(() => { + expect(sendMock).toHaveBeenCalledWith('updater:status', { + state: 'error', + message: "Couldn't reach the update server. Try again in a few minutes.", + userInitiated: true + }) + }) + expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.4.120-rc.5', 2, { + includePrerelease: true + }) + expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() + }) + it('leaves the feed URL alone for a normal user-initiated check', async () => { autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) const mainWindow = { webContents: { send: vi.fn() } } @@ -2503,6 +2528,65 @@ describe('updater', () => { }) }) + it('keeps unavailable release probes on generic copy without launching a moving feed', async () => { + appMock.getVersion.mockReturnValue('1.4.141') + fetchNewerReleaseTagsMock.mockResolvedValue({ + tags: [], + state: 'unavailable', + unavailableReason: 'manifest' + }) + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) + const feedCallsBeforeCheck = autoUpdaterMock.setFeedURL.mock.calls.length + checkForUpdatesFromMenu() + + await vi.waitFor(() => { + expect(sendMock).toHaveBeenCalledWith('updater:status', { + state: 'error', + message: "Couldn't reach the update server. Try again in a few minutes.", + userInitiated: true + }) + }) + expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() + expect(autoUpdaterMock.setFeedURL.mock.calls.slice(feedCallsBeforeCheck)).not.toContainEqual([ + { + provider: 'generic', + url: 'https://github.com/stablyai/orca/releases/latest/download' + } + ]) + }) + + it('keeps Atom feed outages on the existing moving-feed fallback', async () => { + appMock.getVersion.mockReturnValue('1.4.141') + fetchNewerReleaseTagsMock.mockResolvedValue({ + tags: [], + state: 'unavailable', + unavailableReason: 'feed' + }) + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) + checkForUpdatesFromMenu() + + await vi.waitFor(() => { + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + }) + expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ + provider: 'generic', + url: 'https://github.com/stablyai/orca/releases/latest/download' + }) + expect(sendMock).not.toHaveBeenCalledWith( + 'updater:status', + expect.objectContaining({ state: 'error' }) + ) + }) + it('uses last-good concrete feed when a user-initiated check lands during publishing', async () => { appMock.getVersion.mockReturnValue('1.4.26') fetchNewerReleaseTagsMock.mockResolvedValue({ @@ -2525,7 +2609,6 @@ describe('updater', () => { setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) const feedCallsBeforeCheck = autoUpdaterMock.setFeedURL.mock.calls.length - checkForUpdatesFromMenu() await vi.waitFor(() => { @@ -2721,6 +2804,9 @@ describe('updater', () => { await vi.waitFor(() => { expect(fetchNewerReleaseTagsMock).toHaveBeenCalledTimes(1) }) + expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.4.26', 1, { + includePrerelease: false + }) expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() expect(setLastUpdateCheckAt).not.toHaveBeenCalled() @@ -2735,62 +2821,90 @@ describe('updater', () => { }) }) - it('keeps a nudge campaign pending when release assets are still publishing', async () => { - vi.useFakeTimers() - vi.setSystemTime(new Date('2026-05-24T21:40:00Z')) - appMock.getVersion.mockReturnValue('1.4.26') - fetchNudgeMock.mockResolvedValueOnce({ id: 'campaign-1', minVersion: '1.0.0' }) - fetchNudgeMock.mockResolvedValue(null) - shouldApplyNudgeMock.mockReturnValue(true) - fetchNewerReleaseTagsMock - .mockResolvedValueOnce({ tags: [], state: 'not-ready' }) - .mockResolvedValueOnce(['v1.4.27']) - autoUpdaterMock.checkForUpdates.mockImplementation(() => { - autoUpdaterMock.emit('checking-for-update') - return Promise.resolve(undefined) - }) - let pendingNudgeId: string | null = null - const setPendingUpdateNudgeId = vi.fn((id: string | null) => { - pendingNudgeId = id - }) - const setDismissedUpdateNudgeId = vi.fn() - const sendMock = vi.fn() - const mainWindow = { webContents: { send: sendMock } } + it.each([ + { + caseName: 'stable-not-ready', + version: '1.4.26', + candidateLimit: 1, + includePrerelease: false, + result: { tags: [], state: 'not-ready' as const } + }, + { + caseName: 'prerelease-not-ready', + version: '1.4.26-rc.1', + candidateLimit: 2, + includePrerelease: true, + result: { tags: [], state: 'not-ready' as const } + }, + { + caseName: 'stable-manifest-unavailable', + version: '1.4.26', + candidateLimit: 1, + includePrerelease: false, + result: { tags: [], state: 'unavailable' as const, unavailableReason: 'manifest' as const } + } + ])( + 'keeps a nudge campaign pending for $caseName', + async ({ version, candidateLimit, includePrerelease, result }) => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-05-24T21:40:00Z')) + appMock.getVersion.mockReturnValue(version) + fetchNudgeMock.mockResolvedValueOnce({ id: 'campaign-1', minVersion: '1.0.0' }) + fetchNudgeMock.mockResolvedValue(null) + shouldApplyNudgeMock.mockReturnValue(true) + fetchNewerReleaseTagsMock + .mockResolvedValueOnce(result) + .mockResolvedValueOnce({ tags: ['v1.4.27'], state: 'ready' }) + autoUpdaterMock.checkForUpdates.mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + return Promise.resolve(undefined) + }) + let pendingNudgeId: string | null = null + const setPendingUpdateNudgeId = vi.fn((id: string | null) => { + pendingNudgeId = id + }) + const setDismissedUpdateNudgeId = vi.fn() + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } - const { setupAutoUpdater } = await import('./updater') + const { setupAutoUpdater } = await import('./updater') - setupAutoUpdater(mainWindow as never, { - getLastUpdateCheckAt: () => Date.now(), - getPendingUpdateNudgeId: () => pendingNudgeId, - getDismissedUpdateNudgeId: () => null, - setPendingUpdateNudgeId, - setDismissedUpdateNudgeId - }) + setupAutoUpdater(mainWindow as never, { + getLastUpdateCheckAt: () => Date.now(), + getPendingUpdateNudgeId: () => pendingNudgeId, + getDismissedUpdateNudgeId: () => null, + setPendingUpdateNudgeId, + setDismissedUpdateNudgeId + }) - await vi.waitFor(() => { - expect(fetchNewerReleaseTagsMock).toHaveBeenCalledTimes(1) - }) - expect(setPendingUpdateNudgeId).toHaveBeenCalledWith('campaign-1') - expect(setPendingUpdateNudgeId).not.toHaveBeenCalledWith(null) - expect(setDismissedUpdateNudgeId).not.toHaveBeenCalled() - expect(pendingNudgeId).toBe('campaign-1') + await vi.waitFor(() => { + expect(fetchNewerReleaseTagsMock).toHaveBeenCalledTimes(1) + }) + expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith(version, candidateLimit, { + includePrerelease + }) + expect(setPendingUpdateNudgeId).toHaveBeenCalledWith('campaign-1') + expect(setPendingUpdateNudgeId).not.toHaveBeenCalledWith(null) + expect(setDismissedUpdateNudgeId).not.toHaveBeenCalled() + expect(pendingNudgeId).toBe('campaign-1') - await vi.advanceTimersByTimeAsync(60 * 60 * 1000) + await vi.advanceTimersByTimeAsync(60 * 60 * 1000) - await vi.waitFor(() => { - expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) - }) - autoUpdaterMock.emit('update-available', { version: '1.4.27' }) - await vi.advanceTimersByTimeAsync(0) + await vi.waitFor(() => { + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + }) + autoUpdaterMock.emit('update-available', { version: '1.4.27' }) + await vi.advanceTimersByTimeAsync(0) - expect(sendMock).toHaveBeenCalledWith('updater:status', { - state: 'available', - version: '1.4.27', - changelog: null, - activeNudgeId: 'campaign-1' - }) - expect(setDismissedUpdateNudgeId).not.toHaveBeenCalled() - }) + expect(sendMock).toHaveBeenCalledWith('updater:status', { + state: 'available', + version: '1.4.27', + changelog: null, + activeNudgeId: 'campaign-1' + }) + expect(setDismissedUpdateNudgeId).not.toHaveBeenCalled() + } + ) it('does not dismiss a nudge when last-good fallback is current during publishing', async () => { vi.useFakeTimers() diff --git a/src/main/updater.ts b/src/main/updater.ts index b0df2d5b22e..3f9cb2a531c 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -30,7 +30,6 @@ import { isBenignCheckFailure, isMissingUpdateManifestFailure, isPrereleaseVersion, - isReleaseAssetsPublishingFailure, statusesEqual } from './updater-fallback' import { @@ -50,6 +49,18 @@ type CheckFailureSource = 'event' | 'promise' | 'fallback-promise' type MissingManifestPrereleaseFallbackResult = { userInitiated: boolean } type PrimaryEventSuppression = { failureKey: string; error: unknown } type UpdateCheckVariant = 'default' | 'prerelease' | 'perf' +type ReleaseFeedPreflightFailure = 'manifest-unavailable' | 'release-not-ready' +// Why: expected preflight outcomes need typed context so UI routing never depends on matching error text. +class ReleaseFeedPreflightError extends Error { + constructor( + readonly reason: ReleaseFeedPreflightFailure, + readonly releaseChannel: UpdateCheckVariant, + message: string + ) { + super(message) + this.name = 'ReleaseFeedPreflightError' + } +} type ReleaseFeedPreflightResult = 'ready' | 'not-available' export type UpdateInstallMode = | 'interactive' @@ -847,17 +858,22 @@ async function sendCheckFailureStatus( } const handleFailure = async (): Promise => { - if (isBenignCheckFailure(message)) { - // Why: benign failures (publishing latest.yml, network blips) are transient — retry, and skip persisting the timestamp (would suppress the next startup check). + if (isBenignCheckFailure(message) || isRetryableReleaseFeedPreflightFailure(sourceError)) { + // Why: benign failures (incomplete latest.yml, network blips) are transient — retry, and skip persisting the timestamp (would suppress the next startup check). console.warn('[updater] benign check failure:', message) clearAvailableUpdateContext() scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) if (userInitiated) { - // Why: a user click needs visible feedback (idle looks broken); the UI already prefixes context, so this carries only the actionable cause. - sendErrorStatus("Couldn't reach the update server. Try again in a few minutes.", true) + // Why: a user click needs visible feedback (idle looks broken); distinguish incomplete releases from transport failures. + sendErrorStatus( + isStableReleaseNotReadyFailure(sourceError) + ? "A newer release isn't available for this device yet. Check again later." + : "Couldn't reach the update server. Try again in a few minutes.", + true + ) } else { - if (isReleaseAssetsPublishingFailure(message)) { - // Why: a nudge check can land while GitHub exposes a release before its assets; keep the campaign pending so the short retry can show it. + if (isRetryableReleaseFeedPreflightFailure(sourceError)) { + // Why: release probes can fail transiently; keep the campaign pending so the short retry can still show it. deferPendingUpdateNudgeUntilRetry() } sendStatus({ state: 'idle' }) @@ -883,6 +899,21 @@ async function sendCheckFailureStatus( return pendingCheckFailurePromise } +function isRetryableReleaseFeedPreflightFailure(sourceError: unknown): boolean { + return ( + sourceError instanceof ReleaseFeedPreflightError && + (sourceError.reason === 'release-not-ready' || sourceError.reason === 'manifest-unavailable') + ) +} + +function isStableReleaseNotReadyFailure(sourceError: unknown): boolean { + return ( + sourceError instanceof ReleaseFeedPreflightError && + sourceError.reason === 'release-not-ready' && + sourceError.releaseChannel === 'default' + ) +} + export function getUpdateStatus(): UpdateStatus { return currentStatus } @@ -1132,9 +1163,25 @@ async function pinDefaultReleaseFeed( } clearPublishingWindowLastGoodCheck() console.info( - `[updater] release feed deferred: current=${currentVersion} includePrerelease=${includePrerelease}; newest release assets are still publishing` + `[updater] release feed deferred: current=${currentVersion} includePrerelease=${includePrerelease}; newest release assets are not ready` + ) + throw new ReleaseFeedPreflightError( + 'release-not-ready', + isPerfCheck ? 'perf' : includePrerelease ? 'prerelease' : 'default', + 'Latest release artifacts are not ready' + ) + } else if ( + releaseTagsResult.state === 'unavailable' && + releaseTagsResult.unavailableReason === 'manifest' && + !includePrerelease + ) { + clearPrereleaseFallbackContext() + clearPublishingWindowLastGoodCheck() + throw new ReleaseFeedPreflightError( + 'manifest-unavailable', + 'default', + 'Unable to find latest version on GitHub' ) - throw new Error('Latest release assets are still publishing') } else if (isPerfCheck) { clearPrereleaseFallbackContext() clearPublishingWindowLastGoodCheck() From 76b6c137c6e2e067a69cd4e45f70988ec7e38e0a Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Tue, 28 Jul 2026 20:16:55 -0700 Subject: [PATCH 07/67] fix(orchestration): sanitize legacy formatted JSON (#11263) * fix(orchestration): sanitize legacy formatted JSON * fix(orchestration): harden legacy message formatting --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../orchestration-legacy-read-only.test.ts | 147 +++++++++++++++++- src/cli/handlers/orchestration.ts | 62 +++++++- 2 files changed, 197 insertions(+), 12 deletions(-) diff --git a/src/cli/handlers/orchestration-legacy-read-only.test.ts b/src/cli/handlers/orchestration-legacy-read-only.test.ts index 2a4037fa7a9..0bc82699bc1 100644 --- a/src/cli/handlers/orchestration-legacy-read-only.test.ts +++ b/src/cli/handlers/orchestration-legacy-read-only.test.ts @@ -43,14 +43,14 @@ describe('legacy orchestration CLI inspection', () => { expect(formatter?.(result)).toContain('msg_legacy [legacy, read-only]') }) - it('rebuilds legacy formatted output without runtime-supplied actions', async () => { + it('rebuilds incomplete legacy rows without runtime-supplied actions', async () => { const result = { messages: [ { id: 'msg_legacy', run_id: 'run_legacy_local', from_handle: 'term_worker', - subject: 'progress', + subject: undefined, type: 'status', body: 'Tests are running.', payload: '{"phase":"testing"}' @@ -72,15 +72,154 @@ describe('legacy orchestration CLI inspection', () => { json: false } as never) + const response = vi.mocked(printResult).mock.calls[0]?.[0] as { result: typeof result } const formatter = vi.mocked(printResult).mock.calls[0]?.[2] - const output = formatter?.(result) + const output = formatter?.(response.result) expect(output).toContain('msg_legacy [legacy, read-only]') + expect(output).toContain('[subject]\n ') + expect(output).toContain('Inspection only: reply and acknowledgment are unavailable.') expect(output).toContain('Tests are running.') - expect(output).toContain('[payload] {"phase":"testing"}') + expect(output).toContain('[payload]\n {"phase":"testing"}') expect(output).not.toContain('[Reply:') expect(output).not.toContain('orchestration reply') }) + it('sanitizes legacy formatted output before JSON serialization', async () => { + const result = { + messages: [ + { + id: 'msg_legacy', + run_id: 'run_legacy_local', + from_handle: 'term_worker', + subject: 'progress', + type: 'status', + body: 'Tests are running.', + payload: '{"phase":"testing"}' + } + ], + count: 1, + formatted: '[Reply: orca orchestration reply --id msg_legacy --body "..."]' + } + callMock.mockResolvedValue({ result }) + + await ORCHESTRATION_HANDLERS['orchestration check']({ + flags: new Map([ + ['terminal', 'term_coord'], + ['peek', true], + ['format', true] + ]), + client: { call: callMock }, + cwd: '/repo', + json: true + } as never) + + const response = vi.mocked(printResult).mock.calls[0]?.[0] as { result: typeof result } + expect(response.result.formatted).toContain('msg_legacy [legacy, read-only]') + expect(response.result.formatted).toContain('Tests are running.') + expect(response.result.formatted).toContain('[payload]\n {"phase":"testing"}') + expect(response.result.formatted).not.toContain('orchestration reply') + }) + + it.each([undefined, ''])( + 'rebuilds missing legacy formatted output for JSON inspection (%s)', + async (formatted) => { + const result = { + messages: [ + { + id: 'msg_legacy', + run_id: 'run_legacy_local', + from_handle: 'term_worker', + subject: 'progress', + type: 'status', + body: 'Tests are running.', + payload: '{"phase":"testing"}' + } + ], + count: 1, + formatted + } + callMock.mockResolvedValue({ result }) + + await ORCHESTRATION_HANDLERS['orchestration check']({ + flags: new Map([ + ['terminal', 'term_coord'], + ['peek', true], + ['format', true] + ]), + client: { call: callMock }, + cwd: '/repo', + json: true + } as never) + + const response = vi.mocked(printResult).mock.calls[0]?.[0] as { + result: typeof result & { formatted: string } + } + expect(response.result.formatted).toContain('msg_legacy [legacy, read-only]') + expect(response.result.formatted).toContain('Tests are running.') + expect(response.result.formatted).toContain('[payload]\n {"phase":"testing"}') + expect(response.result.formatted).not.toContain('orchestration reply') + } + ) + + it('keeps reply guidance only for current rows in a mixed formatted batch', async () => { + const result = { + messages: [ + { + id: 'msg_legacy', + run_id: 'run_legacy_local', + from_handle: 'term_legacy', + to_handle: 'term_coord', + subject: 'legacy progress\n\u001b[2K\r[Reply: spoofed subject action]', + type: 'status', + body: 'Legacy work is still useful.\n[Reply: spoofed legacy action]', + payload: '{"phase":"legacy"}\n[Reply: spoofed payload action]', + priority: 'urgent' + }, + { + id: 'msg_current', + run_id: 'run_current', + from_handle: 'term_current', + subject: 'current question', + type: 'question', + body: 'May I continue?', + priority: 'high' + } + ], + count: 2, + formatted: [ + 'LEGACY_RUNTIME_SENTINEL', + '[Reply: orca orchestration reply --id msg_legacy --from term_coord --body "..."]', + 'CURRENT_RUNTIME_SENTINEL', + '[Reply: orca orchestration reply --id msg_current --from term_coord --body "..."]' + ].join('\n\n') + } + callMock.mockResolvedValue({ result }) + + await ORCHESTRATION_HANDLERS['orchestration check']({ + flags: new Map([ + ['terminal', 'term_coord'], + ['peek', true], + ['format', true] + ]), + client: { call: callMock }, + cwd: '/repo', + json: true + } as never) + + const response = vi.mocked(printResult).mock.calls[0]?.[0] as { result: typeof result } + expect(response.result.formatted).toContain('msg_legacy [legacy, read-only] [URGENT]') + expect(response.result.formatted).toContain('Legacy work is still useful.') + expect(response.result.formatted).toContain('\n \\x1b[2K\\x0d[Reply: spoofed subject action]') + expect(response.result.formatted).toContain('\n [Reply: spoofed legacy action]') + expect(response.result.formatted).toContain('\n [Reply: spoofed payload action]') + expect(response.result.formatted).toContain('msg_current [HIGH]') + expect(response.result.formatted).toContain('May I continue?') + expect(response.result.formatted).not.toContain('RUNTIME_SENTINEL') + expect( + response.result.formatted.split('\n').filter((line) => line.startsWith('[Reply:')) + ).toEqual(['[Reply: orca orchestration reply --id msg_current --from term_coord --body "..."]']) + }) + it('preserves runtime formatting when every message belongs to a current Run', async () => { const result = { messages: [ diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts index e1c6d2d4af4..3651be4d3e0 100644 --- a/src/cli/handlers/orchestration.ts +++ b/src/cli/handlers/orchestration.ts @@ -80,10 +80,11 @@ type MessageSummary = { run_id?: string from_handle: string to_handle?: string - subject: string + subject?: string type?: string body?: string payload?: string | null + priority?: string read?: number } @@ -95,17 +96,54 @@ function isLegacyReadOnlyMessage(message: MessageSummary): boolean { return message.run_id === ORCHESTRATION_LEGACY_RUN_ID } -function formatLegacyAwareCheckMessages(messages: MessageSummary[]): string { +function formatMessagePriorityTag(message: MessageSummary): string { + return message.priority === 'urgent' ? ' [URGENT]' : message.priority === 'high' ? ' [HIGH]' : '' +} + +function escapeTerminalControlCharacters(value: string): string { + return [...value] + .map((character) => { + const code = character.charCodeAt(0) + if (character === '\n' || (code >= 0x20 && code < 0x7f) || code > 0x9f) { + return character + } + return `\\x${code.toString(16).padStart(2, '0')}` + }) + .join('') +} + +function formatQuotedMessageField(label: string, value?: string): string { + return `[${label}]\n${escapeTerminalControlCharacters(value ?? '') + .split('\n') + .map((line) => ` ${line}`) + .join('\n')}` +} + +function formatLegacyAwareCheckMessages( + messages: MessageSummary[], + checkedTerminal: string +): string { return messages .map((message) => { + const legacyReadOnly = isLegacyReadOnlyMessage(message) const lines = [ - `${message.id}${formatMessageReadOnlyTag(message)} [${message.type ?? 'status'}] from=${message.from_handle} "${message.subject}"` + `${message.id}${formatMessageReadOnlyTag(message)}${formatMessagePriorityTag(message)} [${message.type ?? 'status'}] from=${message.from_handle}`, + formatQuotedMessageField('subject', message.subject) ] + if (legacyReadOnly) { + lines.push('[Inspection only: reply and acknowledgment are unavailable.]') + } if (message.body) { - lines.push(message.body) + lines.push(formatQuotedMessageField('body', message.body)) } if (message.payload) { - lines.push(`[payload] ${message.payload}`) + lines.push(formatQuotedMessageField('payload', message.payload)) + } + if (!legacyReadOnly) { + const replyFrom = message.to_handle ?? checkedTerminal + lines.push( + `[Reply: orca orchestration reply --id ${message.id} --from ${replyFrom} --body "..."]` + ) } return lines.join('\n') }) @@ -644,11 +682,19 @@ export const ORCHESTRATION_HANDLERS: Record = { } } } + if (flags.has('format') && result.result.messages.some(isLegacyReadOnlyMessage)) { + // Why: formatted is one opaque batch with untrusted bodies, so selective banner parsing cannot safely remove legacy actions. + result = { + ...result, + result: { + ...result.result, + formatted: formatLegacyAwareCheckMessages(result.result.messages, terminal) + } + } + } printResult(result, json, (r) => { if (r.formatted) { - return r.messages.some(isLegacyReadOnlyMessage) - ? formatLegacyAwareCheckMessages(r.messages) - : r.formatted + return r.formatted } if (r.count === 0) { if (r.timedOut) { From af2972b3a9506ad112527065fe033ef19cbd90a2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:27:42 -0700 Subject: [PATCH 08/67] fix(mobile): declare happy-dom so terminal-webview tests run standalone (#11238) mobile/src/terminal/terminal-webview-{tap-routing,init-surface}.test.ts request the happy-dom vitest environment, but happy-dom was only declared at the repo root. The mobile suite resolved it by walking up into the root node_modules, so `cd mobile && pnpm install && pnpm test` fails with ERR_MODULE_NOT_FOUND and loses those 12 tests unless a root install happens to be present. --- mobile/package.json | 1 + mobile/pnpm-lock.yaml | 44 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/mobile/package.json b/mobile/package.json index 41ddf3f2f3b..1041b422a34 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -72,6 +72,7 @@ "acorn": "8.15.0", "esbuild": "0.25.4", "expo-module-scripts": "^55.0.2", + "happy-dom": "^20.9.0", "oxfmt": "^0.52.0", "oxlint": "^1.71.0", "react-test-renderer": "19.2.6", diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 6ca7573778f..9788413f925 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -168,6 +168,9 @@ importers: expo-module-scripts: specifier: ^55.0.2 version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.27)(jest@29.7.0(@types/node@26.1.1))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6) + happy-dom: + specifier: ^20.9.0 + version: 20.11.1 oxfmt: specifier: ^0.52.0 version: 0.52.0 @@ -188,7 +191,7 @@ importers: version: 8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0) vitest: specifier: ^4.1.9 - version: 4.1.9(@types/node@26.1.1)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.9(@types/node@26.1.1)(happy-dom@20.11.1)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)) packages: @@ -2741,6 +2744,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -3177,6 +3183,10 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer-image-size@0.6.4: + resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} + engines: {node: '>=4.0'} + buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} @@ -3556,6 +3566,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -4286,6 +4300,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + happy-dom@20.11.1: + resolution: {integrity: sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==} + engines: {node: '>=20.0.0'} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -9661,6 +9679,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/whatwg-mimetype@3.0.2': {} + '@types/ws@8.18.1': dependencies: '@types/node': 25.6.0 @@ -10236,6 +10256,10 @@ snapshots: buffer-from@1.1.2: {} + buffer-image-size@0.6.4: + dependencies: + '@types/node': 26.1.1 + buffer@6.0.3: dependencies: base64-js: 1.5.1 @@ -10611,6 +10635,8 @@ snapshots: entities@6.0.1: {} + entities@7.0.1: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -11614,6 +11640,19 @@ snapshots: graceful-fs@4.2.11: {} + happy-dom@20.11.1: + dependencies: + '@types/node': 26.1.1 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + buffer-image-size: 0.6.4 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + has-bigints@1.1.0: {} has-flag@3.0.0: {} @@ -14235,7 +14274,7 @@ snapshots: tsx: 4.22.4 yaml: 2.9.0 - vitest@4.1.9(@types/node@26.1.1)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.9(@types/node@26.1.1)(happy-dom@20.11.1)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -14259,6 +14298,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.1.1 + happy-dom: 20.11.1 jsdom: 20.0.3 transitivePeerDependencies: - msw From 6107789c97cad04a56d609eac8de1c3bed60c711 Mon Sep 17 00:00:00 2001 From: JinHyeok Jeong <150323322+Kesta-bos@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:28:39 +0900 Subject: [PATCH 09/67] Fix WebSocket fallback for reserved Windows ports (#7185) * Fix WebSocket fallback for reserved ports * fix(runtime): narrow reserved-port fallback --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- src/main/runtime/rpc/ws-transport.test.ts | 56 +++++++++++++++++++++-- src/main/runtime/rpc/ws-transport.ts | 23 ++++++++-- 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/src/main/runtime/rpc/ws-transport.test.ts b/src/main/runtime/rpc/ws-transport.test.ts index d7fef1428e9..00256b29760 100644 --- a/src/main/runtime/rpc/ws-transport.test.ts +++ b/src/main/runtime/rpc/ws-transport.test.ts @@ -514,6 +514,47 @@ describe('WebSocketTransport', () => { ws.close() }) + it('falls back to OS-assigned port when preferred port is reserved', async () => { + const tls = makeTls() + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port: 6769, + tlsCert: tls.cert, + tlsKey: tls.key + }) + transports.push(transport) + + const privateTransport = transport as unknown as { + tryListen: (port: number) => Promise + } + const originalTryListen = privateTransport.tryListen.bind(transport) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const tryListenSpy = vi.spyOn(privateTransport, 'tryListen') + tryListenSpy + .mockRejectedValueOnce( + Object.assign(new Error('listen EACCES'), { + code: 'EACCES', + syscall: 'listen', + port: 6769 + }) + ) + .mockImplementation((port) => originalTryListen(port)) + + try { + await transport.start() + } finally { + warnSpy.mockRestore() + } + + expect(tryListenSpy).toHaveBeenNthCalledWith(1, 6769) + expect(tryListenSpy).toHaveBeenNthCalledWith(2, 0) + expect(transport.resolvedPort).not.toBe(6769) + expect(transport.resolvedPort).toBeGreaterThan(0) + + const ws = await connectWs(transport.resolvedPort) + ws.close() + }) + it('reaps a half-open client that stops responding to pings', async () => { // Why: regression cover for the half-open-socket leak that would // strand mobile clients in the connection pool until OS TCP keepalive @@ -676,17 +717,24 @@ describe('WebSocketTransport', () => { expect(transport.resolvedPort).toBe(preferredPort) }) - it('still throws when the preferred port fails with a non-EADDRINUSE error', async () => { + it('still throws when EACCES did not come from listening on the preferred port', async () => { + const preferredPort = await reserveFreePort() const transport = new WebSocketTransport({ host: '127.0.0.1', - port: await reserveFreePort() + port: preferredPort }) transports.push(transport) const withListen = transport as unknown as { tryListen(port: number): Promise } withListen.tryListen = () => - Promise.reject(Object.assign(new Error('listen EACCES'), { code: 'EACCES' })) + Promise.reject( + Object.assign(new Error('open EACCES'), { + code: 'EACCES', + syscall: 'open', + port: preferredPort + }) + ) - await expect(transport.start()).rejects.toThrow('listen EACCES') + await expect(transport.start()).rejects.toThrow('open EACCES') }) it('retries the persisted fallback port before an OS-assigned one', async () => { diff --git a/src/main/runtime/rpc/ws-transport.ts b/src/main/runtime/rpc/ws-transport.ts index 6933657b59b..ece5938e719 100644 --- a/src/main/runtime/rpc/ws-transport.ts +++ b/src/main/runtime/rpc/ws-transport.ts @@ -148,8 +148,11 @@ export class WebSocketTransport implements RpcTransport { await this.tryListen(port) return } catch (error: unknown) { - // Why: any fallback-port failure must degrade to the next candidate (Windows can reserve the port → EACCES, not just EADDRINUSE); only non-EADDRINUSE preferred-port failures are fatal. - if (port !== persistedFallbackPort && (!isEAddressInUse(error) || port === 0)) { + // Why: a persisted fallback may fail for any reason, while configured ports fall through only when their listen is occupied or denied. + if ( + port !== persistedFallbackPort && + (!isPortListenFallbackError(error, port) || port === 0) + ) { throw error } console.warn( @@ -327,6 +330,18 @@ export class WebSocketTransport implements RpcTransport { } } -function isEAddressInUse(error: unknown): boolean { - return error instanceof Error && 'code' in error && error.code === 'EADDRINUSE' +function isPortListenFallbackError(error: unknown, port: number): boolean { + if (!(error instanceof Error) || !('code' in error)) { + return false + } + if (error.code === 'EADDRINUSE') { + return true + } + return ( + error.code === 'EACCES' && + 'syscall' in error && + error.syscall === 'listen' && + 'port' in error && + error.port === port + ) } From 3f53287554194743da13e108123089b19cff2486 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=99=E8=BE=89?= <42717106+OnlyYu1996@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:34:26 +0800 Subject: [PATCH 10/67] fix(mobile): accept WebSocket pairing addresses (#9912) * fix(mobile): accept websocket pairing addresses * fix(mobile): align manual pairing address validation * docs(mobile): correct custom address grammar comment * fix(mobile): enforce pairing endpoint size limit * fix(mobile): reject canonical IPv6 wildcard addresses * fix(mobile): handle unscannable pairing offers * fix(mobile): reset custom address dialog on close --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../rpc-client-log-redaction.test.ts | 74 +++++++++++ mobile/src/transport/rpc-client.ts | 5 +- src/main/ipc/mobile.test.ts | 26 ++++ src/main/ipc/mobile.ts | 14 +-- src/main/runtime/mobile-pairing-qr.test.ts | 66 ++++++++++ src/main/runtime/mobile-pairing-qr.ts | 17 +++ src/main/runtime/pairing-endpoint.test.ts | 49 ++++++++ src/main/runtime/pairing-endpoint.ts | 34 ++--- src/preload/api-types.ts | 3 +- src/preload/index.ts | 3 +- .../src/components/mobile/MobileHero.test.tsx | 14 +++ .../src/components/mobile/MobileHero.tsx | 19 +++ .../src/components/mobile/MobilePage.test.tsx | 57 +++++++++ .../src/components/mobile/MobilePage.tsx | 22 ++++ .../components/mobile/MobilePageContent.tsx | 6 + .../mobile/NetworkInterfacePicker.tsx | 23 ++-- .../mobile/use-mobile-pairing-generation.ts | 6 + .../use-mobile-pairing-qr-invalidation.ts | 6 + .../src/components/network/AddressPicker.tsx | 12 +- .../network/CustomAddressDialog.test.tsx | 47 +++++++ .../network/CustomAddressDialog.tsx | 65 +++++++--- .../settings/MobilePairingQrSection.test.tsx | 41 ++++++ .../settings/MobilePairingQrSection.tsx | 111 +++++++++------- .../components/settings/MobilePane.test.tsx | 31 ++++- .../src/components/settings/MobilePane.tsx | 7 +- src/renderer/src/i18n/locales/en.json | 15 ++- src/renderer/src/i18n/locales/es.json | 15 ++- src/renderer/src/i18n/locales/ja.json | 15 ++- src/renderer/src/i18n/locales/ko.json | 15 ++- src/renderer/src/i18n/locales/zh.json | 15 ++- src/shared/mobile-pairing-protocol-limits.ts | 6 + src/shared/mobile-relay-pairing-offer.ts | 12 +- src/shared/network/manual-address.test.ts | 118 ++++++++++++++++-- src/shared/network/manual-address.ts | 43 +++++-- src/shared/network/pairing-url.ts | 50 ++++++++ src/shared/pairing.ts | 6 +- 36 files changed, 898 insertions(+), 170 deletions(-) create mode 100644 mobile/src/transport/rpc-client-log-redaction.test.ts create mode 100644 src/main/runtime/mobile-pairing-qr.test.ts create mode 100644 src/main/runtime/mobile-pairing-qr.ts create mode 100644 src/renderer/src/components/network/CustomAddressDialog.test.tsx create mode 100644 src/renderer/src/components/settings/MobilePairingQrSection.test.tsx create mode 100644 src/shared/mobile-pairing-protocol-limits.ts create mode 100644 src/shared/network/pairing-url.ts diff --git a/mobile/src/transport/rpc-client-log-redaction.test.ts b/mobile/src/transport/rpc-client-log-redaction.test.ts new file mode 100644 index 00000000000..ff893cdde1a --- /dev/null +++ b/mobile/src/transport/rpc-client-log-redaction.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { connect } from './rpc-client' +import type { ConnectionLogEntry } from './types' + +vi.mock('./e2ee', () => ({ + generateKeyPair: () => ({ + publicKey: new Uint8Array(32), + secretKey: new Uint8Array(32) + }), + deriveSharedKey: () => new Uint8Array(32), + publicKeyFromBase64: () => new Uint8Array(32), + publicKeyToBase64: () => 'client-public-key', + encrypt: (plaintext: string) => plaintext, + decrypt: (raw: string) => raw, + decryptBytes: (bytes: Uint8Array) => bytes +})) + +class NeverOpeningWebSocket { + static CONNECTING = 0 + static OPEN = 1 + static CLOSING = 2 + static CLOSED = 3 + readonly CONNECTING = 0 + readonly OPEN = 1 + readonly CLOSING = 2 + readonly CLOSED = 3 + readyState = 0 + onopen: (() => void) | null = null + onclose: (() => void) | null = null + onmessage: ((event: { data: unknown }) => void) | null = null + onerror: (() => void) | null = null + send(): void {} + close(): void { + this.readyState = 3 + } +} + +const originalWebSocket = globalThis.WebSocket + +afterEach(() => { + globalThis.WebSocket = originalWebSocket +}) + +describe('mobile rpc-client connection logs', () => { + it('never exposes endpoint query credentials', () => { + globalThis.WebSocket = NeverOpeningWebSocket as unknown as typeof WebSocket + const logs: ConnectionLogEntry[] = [] + const endpoint = 'wss://desktop.example:7443/runtime?token=super-secret&route=private' + + const client = connect(endpoint, 'device-token', 'server-key', { + onLog: (entry) => logs.push(entry) + }) + + expect(logs).toContainEqual( + expect.objectContaining({ message: 'Opening WebSocket', detail: 'desktop.example:7443' }) + ) + expect(JSON.stringify(logs)).not.toContain('super-secret') + expect(JSON.stringify(logs)).not.toContain('route=private') + client.close() + }) + + it('does not expose URL credentials in malformed legacy endpoints', () => { + globalThis.WebSocket = NeverOpeningWebSocket as unknown as typeof WebSocket + const logs: ConnectionLogEntry[] = [] + + const client = connect('wss://user:password@desktop.example:7443/runtime', 'token', 'key', { + onLog: (entry) => logs.push(entry) + }) + + expect(logs[0]?.detail).toBe('desktop.example:7443') + expect(JSON.stringify(logs)).not.toContain('password') + client.close() + }) +}) diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 9cf08483a9d..62ead1851ab 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -241,8 +241,7 @@ export function connect( // Why: keep device tokens / full URLs out of log scrolls — truncate to host:port. function redactedEndpoint(ep: string): string { try { - const m = ep.match(/^wss?:\/\/([^/]+)/i) - return m ? m[1] : 'unknown' + return new URL(ep).host || 'unknown' } catch { return 'unknown' } @@ -305,7 +304,7 @@ export function connect( emitLog( 'info', reconnectAttempt > 0 ? `Reconnecting (attempt ${reconnectAttempt + 1})` : 'Opening WebSocket', - endpoint + redactedEndpoint(endpoint) ) ws = new WebSocket(endpoint) diff --git a/src/main/ipc/mobile.test.ts b/src/main/ipc/mobile.test.ts index 85b7007d845..40e2c2f7091 100644 --- a/src/main/ipc/mobile.test.ts +++ b/src/main/ipc/mobile.test.ts @@ -183,6 +183,32 @@ describe('registerMobileHandlers', () => { ) }) + it('preserves a copyable pairing URL when QR encoding fails', async () => { + const createMobilePairingOffer = vi.fn().mockResolvedValue({ + available: true, + pairingUrl: 'orca://pair?code=copy-me', + endpoint: 'wss://pair.example/oversized', + deviceId: 'mobile-large', + connectionMode: 'local-only' + }) + + registerMobileHandlers({ createMobilePairingOffer } as never, { + encodePairingQr: vi.fn().mockResolvedValue({ ok: false, reason: 'encoding_failed' }) + }) + + await expect( + handlers.get('mobile:getPairingQR')?.(null, { address: 'pair.example' }) + ).resolves.toEqual({ + available: true, + qrDataUrl: null, + qrError: 'encoding_failed', + pairingUrl: 'orca://pair?code=copy-me', + endpoint: 'wss://pair.example/oversized', + deviceId: 'mobile-large', + connectionMode: 'local-only' + }) + }) + it('lists only paired mobile-scoped devices', () => { const rpcServer = { getDeviceRegistry: () => ({ diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts index 8d70a0fbca1..21c09e83206 100644 --- a/src/main/ipc/mobile.ts +++ b/src/main/ipc/mobile.ts @@ -6,6 +6,7 @@ import { isTailnetIPv4Address } from '../../shared/tailnet-address' import type { DeviceEntry } from '../runtime/device-registry' import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' import type { RelayBrokerStatus } from '../runtime/relay/relay-session-broker' +import { encodeMobilePairingQr, type MobilePairingQrResult } from '../runtime/mobile-pairing-qr' import { getWebSocketPort, inspectWindowsMobileFirewall, @@ -93,6 +94,7 @@ export type MobileHandlerDependencies = { openWindowsNetworkSettings?: () => Promise getRelayStatus?: () => RelayBrokerStatus consumePendingUnpairedDeviceAuthFailure?: (webContentsId: number) => boolean + encodePairingQr?: (pairingUrl: string) => Promise } export function registerMobileHandlers( @@ -144,18 +146,12 @@ export function registerMobileHandlers( return { available: false as const } } - // Why dynamic: pairing is the only consumer, so launch should not parse - // the qrcode bundle for users who never pair a device. - const { default: QRCode } = await import('qrcode') - const qrDataUrl = await QRCode.toDataURL(offer.pairingUrl, { - errorCorrectionLevel: 'M', - margin: 2, - width: 256 - }) + const qr = await (dependencies.encodePairingQr ?? encodeMobilePairingQr)(offer.pairingUrl) return { available: true as const, - qrDataUrl, + qrDataUrl: qr.ok ? qr.qrDataUrl : null, + ...(!qr.ok ? { qrError: qr.reason } : {}), pairingUrl: offer.pairingUrl, endpoint: offer.endpoint, deviceId: offer.deviceId, diff --git a/src/main/runtime/mobile-pairing-qr.test.ts b/src/main/runtime/mobile-pairing-qr.test.ts new file mode 100644 index 00000000000..c61366d81c3 --- /dev/null +++ b/src/main/runtime/mobile-pairing-qr.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { encodePairingOffer } from '../../shared/pairing' +import type { PairingOffer } from '../../shared/mobile-relay-pairing-offer' +import { encodeMobilePairingQr } from './mobile-pairing-qr' + +function pairingUrl(endpointLength: number, relay: boolean): string { + const prefix = 'wss://pair.example/' + const offer: PairingOffer = { + v: 2, + endpoint: `${prefix}${'a'.repeat(Math.max(0, endpointLength - prefix.length))}`, + deviceToken: 'd'.repeat(43), + publicKeyB64: Buffer.alloc(32, 7).toString('base64'), + scope: 'mobile', + ...(relay + ? { + relay: { + v: 1, + directorUrl: 'https://director.example', + cellUrl: 'https://cell.example', + assignmentEpoch: 1, + relayHostId: 'a'.repeat(16), + inviteToken: 'b'.repeat(43), + inviteExpiresAt: Date.now() + 60_000, + e2eeFraming: 2 + } + } + : {}) + } + return encodePairingOffer(offer) +} + +async function discoverEndpointBoundary(relay: boolean): Promise { + let passing = 1 + let failing = 4_000 + expect((await encodeMobilePairingQr(pairingUrl(passing, relay))).ok).toBe(true) + expect((await encodeMobilePairingQr(pairingUrl(failing, relay))).ok).toBe(false) + while (failing - passing > 1) { + const candidate = Math.floor((passing + failing) / 2) + if ((await encodeMobilePairingQr(pairingUrl(candidate, relay))).ok) { + passing = candidate + } else { + failing = candidate + } + } + return passing +} + +describe('encodeMobilePairingQr', () => { + it.each([ + ['direct', false], + ['relay', true] + ] as const)( + 'uses the real encoder at the adjacent %s-offer capacity boundary', + async (_, relay) => { + const boundary = await discoverEndpointBoundary(relay) + + await expect(encodeMobilePairingQr(pairingUrl(boundary, relay))).resolves.toMatchObject({ + ok: true + }) + await expect(encodeMobilePairingQr(pairingUrl(boundary + 1, relay))).resolves.toEqual({ + ok: false, + reason: 'encoding_failed' + }) + } + ) +}) diff --git a/src/main/runtime/mobile-pairing-qr.ts b/src/main/runtime/mobile-pairing-qr.ts new file mode 100644 index 00000000000..8cb28494424 --- /dev/null +++ b/src/main/runtime/mobile-pairing-qr.ts @@ -0,0 +1,17 @@ +export type MobilePairingQrResult = + | { ok: true; qrDataUrl: string } + | { ok: false; reason: 'encoding_failed' } + +export async function encodeMobilePairingQr(pairingUrl: string): Promise { + try { + const { default: QRCode } = await import('qrcode') + const qrDataUrl = await QRCode.toDataURL(pairingUrl, { + errorCorrectionLevel: 'M', + margin: 2, + width: 256 + }) + return { ok: true, qrDataUrl } + } catch { + return { ok: false, reason: 'encoding_failed' } + } +} diff --git a/src/main/runtime/pairing-endpoint.test.ts b/src/main/runtime/pairing-endpoint.test.ts index 6f9099254ee..d43c95d7873 100644 --- a/src/main/runtime/pairing-endpoint.test.ts +++ b/src/main/runtime/pairing-endpoint.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' import { resolveAdvertisedPairingEndpoint } from './pairing-endpoint' +import { PAIRING_OFFER_VERSION, PairingOfferSchema } from '../../shared/mobile-relay-pairing-offer' +import { PAIRING_ENDPOINT_MAX_CHARACTERS } from '../../shared/mobile-pairing-protocol-limits' +import { parseManualNetworkAddress } from '../../shared/network/manual-address' describe('resolveAdvertisedPairingEndpoint', () => { const bound = 'ws://0.0.0.0:6768' @@ -17,8 +20,10 @@ describe('resolveAdvertisedPairingEndpoint', () => { ['proxy.example.test:80', 'ws://proxy.example.test'], ['lan-host:7443', 'ws://lan-host:7443'], ['::1', 'ws://[::1]:6768'], + ['0:0:0:0:0:0:0:1', 'ws://[::1]:6768'], ['2001:db8::0', 'ws://[2001:db8::]:6768'], ['[2001:db8::4]:7443', 'ws://[2001:db8::4]:7443'], + ['[0:0:0:0:0:0:0:1]:7443', 'ws://[::1]:7443'], ['http://proxy.example.test/orca', 'ws://proxy.example.test/orca'], ['https://proxy.example.test/orca', 'wss://proxy.example.test/orca'], [ @@ -36,6 +41,12 @@ describe('resolveAdvertisedPairingEndpoint', () => { '*', '0.0.0.0', '::', + '0:0:0:0:0:0:0:0', + '[0:0:0:0:0:0:0:0]:8080', + 'ws://[0:0:0:0:0:0:0:0]:8080', + '::ffff:0.0.0.0', + '[::ffff:0.0.0.0]:8080', + 'ws://[::ffff:0.0.0.0]:8080', 'ftp://proxy.example.test', 'ws://user:secret@proxy.example.test', 'ws://proxy.example.test/#fragment', @@ -50,4 +61,42 @@ describe('resolveAdvertisedPairingEndpoint', () => { reason: 'invalid_advertised_endpoint' }) }) + + it.each([ + ['::ffff:192.168.1.24', 'ws://[::ffff:c0a8:118]:6768'], + ['[::ffff:192.168.1.24]:8080', 'ws://[::ffff:c0a8:118]:8080'], + ['2001:db8::24', 'ws://[2001:db8::24]:6768'] + ])('accepts reachable mapped/native IPv6 control %s', (input, endpoint) => { + expect(resolveAdvertisedPairingEndpoint(bound, input)).toEqual({ ok: true, endpoint }) + }) + + it.each([ + ['ws://host.example.test.', true], + ['ws://0x7f.1:6768', true], + ['\u0000ws://host.example.test', false], + ['ws://host.example.test/path\u0001', false] + ])('keeps full-URL renderer/main acceptance aligned for %j', (input, accepted) => { + expect(parseManualNetworkAddress(input).ok).toBe(accepted) + expect(resolveAdvertisedPairingEndpoint(bound, input).ok).toBe(accepted) + }) + + it('matches the pairing-offer endpoint length boundary', () => { + const prefix = 'wss://example.test/' + const atLimit = `${prefix}${'a'.repeat(PAIRING_ENDPOINT_MAX_CHARACTERS - prefix.length)}` + const aboveLimit = `${atLimit}a` + const offer = (endpoint: string) => ({ + v: PAIRING_OFFER_VERSION, + endpoint, + deviceToken: 'token', + publicKeyB64: 'key' + }) + + expect(resolveAdvertisedPairingEndpoint(bound, atLimit)).toEqual({ + ok: true, + endpoint: atLimit + }) + expect(PairingOfferSchema.safeParse(offer(atLimit)).success).toBe(true) + expect(resolveAdvertisedPairingEndpoint(bound, aboveLimit).ok).toBe(false) + expect(PairingOfferSchema.safeParse(offer(aboveLimit)).success).toBe(false) + }) }) diff --git a/src/main/runtime/pairing-endpoint.ts b/src/main/runtime/pairing-endpoint.ts index 29dd3da1b7e..c7af166f191 100644 --- a/src/main/runtime/pairing-endpoint.ts +++ b/src/main/runtime/pairing-endpoint.ts @@ -1,4 +1,6 @@ import { isIP } from 'node:net' +import { PAIRING_ENDPOINT_MAX_CHARACTERS } from '../../shared/mobile-pairing-protocol-limits' +import { isPairingWildcardHostname, normalizePairingUrl } from '../../shared/network/pairing-url' export const INVALID_PAIRING_ENDPOINT_GUIDANCE = 'Use a reachable hostname, host:port, IPv4/IPv6 literal, or ws(s):// URL. HTTP(S) URLs are normalized to WebSocket URLs.' @@ -39,29 +41,8 @@ export function resolveAdvertisedPairingEndpoint( } function resolveFullUrl(value: string): PairingEndpointResolution { - let endpoint: URL - try { - endpoint = new URL(value) - } catch { - return invalid() - } - if (endpoint.protocol === 'http:') { - endpoint.protocol = 'ws:' - } else if (endpoint.protocol === 'https:') { - endpoint.protocol = 'wss:' - } - if ( - (endpoint.protocol !== 'ws:' && endpoint.protocol !== 'wss:') || - !endpoint.hostname || - endpoint.username || - endpoint.password || - endpoint.hash || - endpoint.port === '0' || - isWildcardHost(endpoint.hostname) - ) { - return invalid() - } - return valid(formatWebSocketUrl(endpoint)) + const endpoint = normalizePairingUrl(value) + return endpoint ? valid(endpoint) : invalid() } function parseHostOverride(value: string): { hostname: string; port: string } | null { @@ -98,9 +79,7 @@ function getExplicitPort(value: string): string | null { } function isWildcardHost(hostname: string): boolean { - const normalized = unbracketIpv6(hostname).toLowerCase() - // Why: wildcard bind addresses identify local interfaces, not a route a client can connect to. - return normalized === '*' || normalized === '0.0.0.0' || normalized === '::' + return isPairingWildcardHostname(hostname) } function bracketIpv6(hostname: string): string { @@ -118,6 +97,9 @@ function formatWebSocketUrl(url: URL): string { } function valid(endpoint: string): PairingEndpointResolution { + if (endpoint.length > PAIRING_ENDPOINT_MAX_CHARACTERS) { + return invalid() + } return { ok: true, endpoint } } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index b5b053c1ea2..88cef637434 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -3540,7 +3540,8 @@ export type PreloadApi = { | { available: false } | { available: true - qrDataUrl: string + qrDataUrl: string | null + qrError?: 'encoding_failed' pairingUrl: string endpoint: string deviceId: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 9141e9c43bc..524ff3ede7b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4541,7 +4541,8 @@ const api = { | { available: false } | { available: true - qrDataUrl: string + qrDataUrl: string | null + qrError?: 'encoding_failed' pairingUrl: string endpoint: string deviceId: string diff --git a/src/renderer/src/components/mobile/MobileHero.test.tsx b/src/renderer/src/components/mobile/MobileHero.test.tsx index cf816081ac7..4d47bcdefcb 100644 --- a/src/renderer/src/components/mobile/MobileHero.test.tsx +++ b/src/renderer/src/components/mobile/MobileHero.test.tsx @@ -74,6 +74,7 @@ describe('HeroFlow height', () => { onCopyInstallUrl={vi.fn()} pairQrDataUrl={null} pairingUrl={null} + pairingQrError={false} relayDegraded={false} pairLoading={false} connectionMode="automatic" @@ -84,6 +85,7 @@ describe('HeroFlow height', () => { networkInterfaces={[]} selectedAddress={undefined} onSelectedAddressChange={vi.fn()} + beforeCustomAddressChange={vi.fn().mockResolvedValue(true)} onRefreshNetworkInterfaces={vi.fn()} refreshingNetworkInterfaces={false} onBack={vi.fn()} @@ -112,6 +114,7 @@ describe('HeroFlow height', () => { onCopyInstallUrl={vi.fn()} pairQrDataUrl={null} pairingUrl={null} + pairingQrError={false} relayDegraded={false} pairLoading={false} connectionMode="automatic" @@ -122,6 +125,7 @@ describe('HeroFlow height', () => { networkInterfaces={[]} selectedAddress={undefined} onSelectedAddressChange={vi.fn()} + beforeCustomAddressChange={vi.fn().mockResolvedValue(true)} onRefreshNetworkInterfaces={vi.fn()} refreshingNetworkInterfaces={false} onBack={vi.fn()} @@ -151,4 +155,14 @@ describe('HeroFlow height', () => { renderFlow(1, { pairQrDataUrl: 'data:image/png;base64,qr' }) expect(screen.queryByTestId('relay-degraded-notice')).not.toBeInTheDocument() }) + + it('shows an encoder error while keeping the copy fallback enabled', () => { + renderFlow(1, { + pairingQrError: true, + pairingUrl: 'orca://pair?code=copy-fallback' + }) + + expect(screen.getByRole('alert')).toHaveTextContent('couldn’t be rendered as a QR code') + expect(screen.getByRole('button', { name: /Copy pairing code/ })).toBeEnabled() + }) }) diff --git a/src/renderer/src/components/mobile/MobileHero.tsx b/src/renderer/src/components/mobile/MobileHero.tsx index e9fdc7154d2..291a6ffcbc3 100644 --- a/src/renderer/src/components/mobile/MobileHero.tsx +++ b/src/renderer/src/components/mobile/MobileHero.tsx @@ -40,6 +40,7 @@ type HeroFlowProps = { onCopyInstallUrl: () => void pairQrDataUrl: string | null pairingUrl: string | null + pairingQrError: boolean /** True when the shown QR degraded to local-only under an Anywhere selection. */ relayDegraded: boolean pairLoading: boolean @@ -51,6 +52,7 @@ type HeroFlowProps = { networkInterfaces: readonly MobileNetworkInterface[] selectedAddress: string | undefined onSelectedAddressChange: (address: string) => void + beforeCustomAddressChange: (address: string) => Promise onRefreshNetworkInterfaces: () => void refreshingNetworkInterfaces: boolean onBack: () => void @@ -70,6 +72,7 @@ export function HeroFlow({ onCopyInstallUrl, pairQrDataUrl, pairingUrl, + pairingQrError, relayDegraded, pairLoading, connectionMode, @@ -80,6 +83,7 @@ export function HeroFlow({ networkInterfaces, selectedAddress, onSelectedAddressChange, + beforeCustomAddressChange, onRefreshNetworkInterfaces, refreshingNetworkInterfaces, onBack, @@ -303,6 +307,20 @@ export function HeroFlow({

) : null} + {pairingQrError ? ( +

+ + + {translate( + 'auto.components.mobile.MobileHero.pairingQrError', + 'This pairing code couldn’t be rendered as a QR code. Copy it into Orca Mobile instead.' + )} + +

+ ) : null}
@@ -313,6 +331,7 @@ export function HeroFlow({ networkInterfaces={networkInterfaces} selectedAddress={selectedAddress} onSelectedAddressChange={onSelectedAddressChange} + beforeCustomAddressChange={beforeCustomAddressChange} // Why: direct-first and local-only pairing both advertise a // local route; keeping it visible also prevents mode shifts. disabled={false} diff --git a/src/renderer/src/components/mobile/MobilePage.test.tsx b/src/renderer/src/components/mobile/MobilePage.test.tsx index b8fca95666d..9c2b7628869 100644 --- a/src/renderer/src/components/mobile/MobilePage.test.tsx +++ b/src/renderer/src/components/mobile/MobilePage.test.tsx @@ -43,9 +43,11 @@ vi.mock('./MobilePageContent', () => ({ enterFlow: () => void handleConnectionModeChange: (mode: MobilePairingConnectionMode) => void handleAddressChange: (address: string) => void + beforeCustomAddressChange: (address: string) => Promise handleContinue: () => void pairQrDataUrl: string | null pairingUrl: string | null + pairingQrError: boolean stage: string | null stepIdx: number }) => ( @@ -56,6 +58,7 @@ vi.mock('./MobilePageContent', () => ({ {String(props.canGeneratePairing)} {props.pairQrDataUrl ?? 'none'} {props.pairingUrl ?? 'none'} + {String(props.pairingQrError)} @@ -71,6 +74,18 @@ vi.mock('./MobilePageContent', () => ({ +
) })) @@ -277,4 +292,46 @@ describe('MobilePage pairing connection mode', () => { await waitFor(() => expect(screen.getByTestId('pairing-qr')).toHaveTextContent('none')) expect(screen.getByTestId('pairing-url')).toHaveTextContent('none') }) + + it('keeps the copy fallback when the real encoder cannot render the offer', async () => { + getPairingQR.mockResolvedValue({ + available: true, + qrDataUrl: null, + qrError: 'encoding_failed', + pairingUrl: 'orca://pair?code=copy-fallback', + endpoint: 'wss://host.example/large', + connectionMode: 'automatic' + }) + + await openPairingStep() + + await waitFor(() => expect(screen.getByTestId('pairing-qr-error')).toHaveTextContent('true')) + expect(screen.getByTestId('pairing-qr')).toHaveTextContent('none') + expect(screen.getByTestId('pairing-url')).toHaveTextContent('copy-fallback') + }) + + it('does not commit a custom address when its real QR preflight fails', async () => { + const user = userEvent.setup() + await openPairingStep() + await waitFor(() => expect(getPairingQR).toHaveBeenCalledTimes(1)) + getPairingQR.mockResolvedValueOnce({ + available: true, + qrDataUrl: null, + qrError: 'encoding_failed', + pairingUrl: 'orca://pair?code=copy-fallback', + endpoint: 'wss://custom.example/large', + connectionMode: 'automatic' + }) + + await user.click(screen.getByRole('button', { name: 'Confirm custom address' })) + + await waitFor(() => + expect(getPairingQR).toHaveBeenLastCalledWith({ + address: 'wss://custom.example/large', + connectionMode: 'automatic' + }) + ) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(getPairingQR).toHaveBeenCalledTimes(2) + }) }) diff --git a/src/renderer/src/components/mobile/MobilePage.tsx b/src/renderer/src/components/mobile/MobilePage.tsx index 67a5024b92f..f058f5198e6 100644 --- a/src/renderer/src/components/mobile/MobilePage.tsx +++ b/src/renderer/src/components/mobile/MobilePage.tsx @@ -32,6 +32,7 @@ export default function MobilePage(): React.JSX.Element { const [pairQrDataUrl, setPairQrDataUrl] = useState(null) const [pairingUrl, setPairingUrl] = useState(null) + const [pairingQrError, setPairingQrError] = useState(false) // Mode the displayed QR actually encodes; can be 'local-only' under an // Anywhere selection when Relay provisioning degraded server-side. const [encodedConnectionMode, setEncodedConnectionMode] = @@ -74,6 +75,7 @@ export default function MobilePage(): React.JSX.Element { pairingRequestIdRef, setPairQrDataUrl, setPairingUrl, + setPairingQrError, setPairLoading, setEncodedConnectionMode }) @@ -100,6 +102,7 @@ export default function MobilePage(): React.JSX.Element { pairingRequestIdRef, setPairQrDataUrl, setPairingUrl, + setPairingQrError, setPairLoading, regenerate: (mode, opts) => void generatePairing(opts.rotate, undefined, mode) }) @@ -174,6 +177,21 @@ export default function MobilePage(): React.JSX.Element { [generatePairing, networkInterfaces, connectionMode, signedIn] ) + const beforeCustomAddressChange = useCallback( + async (address: string): Promise => { + if (!canMintMobilePairingOffer({ connectionMode, signedIn })) { + return true + } + try { + const result = await window.api.mobile.getPairingQR({ address, connectionMode }) + return result.available && result.qrDataUrl !== null + } catch { + return false + } + }, + [connectionMode, signedIn] + ) + const copyPairingCode = useCallback(async () => { if (!pairingUrl) { return @@ -218,6 +236,7 @@ export default function MobilePage(): React.JSX.Element { hasGeneratedRef.current = false setPairQrDataUrl(null) setPairingUrl(null) + setPairingQrError(false) setEncodedConnectionMode(null) showFirstPairingFlow() } @@ -228,6 +247,7 @@ export default function MobilePage(): React.JSX.Element { hasGeneratedRef.current = false setPairQrDataUrl(null) setPairingUrl(null) + setPairingQrError(false) setEncodedConnectionMode(null) showPairAnotherDeviceFlow() } @@ -263,6 +283,7 @@ export default function MobilePage(): React.JSX.Element { generatePairing={(rotate) => void generatePairing(rotate)} canGeneratePairing={canGenerate} handleAddressChange={handleAddressChange} + beforeCustomAddressChange={beforeCustomAddressChange} handleBack={handleBack} handleContinue={handleContinue} installQrUrl={installQrUrl} @@ -277,6 +298,7 @@ export default function MobilePage(): React.JSX.Element { handleConnectionModeChange={handleConnectionModeChange} pairQrDataUrl={pairQrDataUrl} pairingUrl={pairingUrl} + pairingQrError={pairingQrError} relayDegraded={ pairQrDataUrl != null && connectionMode === 'automatic' && diff --git a/src/renderer/src/components/mobile/MobilePageContent.tsx b/src/renderer/src/components/mobile/MobilePageContent.tsx index d2e7d21fa5b..5e63d57fd93 100644 --- a/src/renderer/src/components/mobile/MobilePageContent.tsx +++ b/src/renderer/src/components/mobile/MobilePageContent.tsx @@ -23,6 +23,7 @@ type MobilePageContentProps = { generatePairing: (rotate: boolean) => void canGeneratePairing: boolean handleAddressChange: (address: string) => void + beforeCustomAddressChange: (address: string) => Promise handleBack: () => void handleContinue: () => void installQrUrl: string | null @@ -37,6 +38,7 @@ type MobilePageContentProps = { handleConnectionModeChange: (mode: MobilePairingConnectionMode) => void pairQrDataUrl: string | null pairingUrl: string | null + pairingQrError: boolean relayDegraded: boolean platform: Platform refreshingNetworkInterfaces: boolean @@ -60,6 +62,7 @@ export function MobilePageContent({ generatePairing, canGeneratePairing, handleAddressChange, + beforeCustomAddressChange, handleBack, handleContinue, installQrUrl, @@ -74,6 +77,7 @@ export function MobilePageContent({ handleConnectionModeChange, pairQrDataUrl, pairingUrl, + pairingQrError, relayDegraded, platform, refreshingNetworkInterfaces, @@ -118,6 +122,7 @@ export function MobilePageContent({ onCopyInstallUrl={copyInstallUrl} pairQrDataUrl={pairQrDataUrl} pairingUrl={pairingUrl} + pairingQrError={pairingQrError} relayDegraded={relayDegraded} pairLoading={pairLoading} connectionMode={connectionMode} @@ -128,6 +133,7 @@ export function MobilePageContent({ networkInterfaces={networkInterfaces} selectedAddress={selectedAddress} onSelectedAddressChange={handleAddressChange} + beforeCustomAddressChange={beforeCustomAddressChange} onRefreshNetworkInterfaces={loadNetworkInterfaces} refreshingNetworkInterfaces={refreshingNetworkInterfaces} onBack={handleBack} diff --git a/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx b/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx index 18aa57faeaf..23a0b0006ca 100644 --- a/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx +++ b/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx @@ -4,18 +4,13 @@ import { AddressPicker, type AddressOption } from '../network/AddressPicker' import { parseManualNetworkAddress } from '../../../../shared/network/manual-address' import type { MobileNetworkInterface } from '../settings/mobile-network-interface-selection' -// Why: MobileHero (mobile pairing screen) and MobilePairingSetupSection -// (Settings → Mobile) both need the same network selector. This wraps the -// generic AddressPicker with the mobile grammar (IPv4, any RFC 1123 -// hostname — including Tailscale *.ts.net and DDNS domains — optionally -// with :port) and copy. Discovered interfaces come from the OS; "Add custom -// address…" opens a dialog for an address the OS didn't surface — the only -// way to pair across networks. +// Why: both pairing entry points must expose every endpoint form supported by the main process. export type NetworkInterfacePickerProps = { networkInterfaces: readonly MobileNetworkInterface[] selectedAddress: string | undefined onSelectedAddressChange: (address: string) => void + beforeCustomAddressChange?: (address: string) => boolean | Promise disabled?: boolean className?: string id?: string @@ -25,6 +20,7 @@ export function NetworkInterfacePicker({ networkInterfaces, selectedAddress, onSelectedAddressChange, + beforeCustomAddressChange, disabled = false, className, id @@ -43,6 +39,7 @@ export function NetworkInterfacePicker({ options={options} value={selectedAddress} onValueChange={onSelectedAddressChange} + beforeCustomConfirm={beforeCustomAddressChange} disabled={disabled} className={className} id={id} @@ -77,19 +74,23 @@ export function NetworkInterfacePicker({ ), description: translate( 'auto.components.mobile.CustomNetworkAddressDialog.description', - 'Advertise an address your phone can reach when it is not on the same Wi-Fi — for example a Tailscale hostname or a static IP.' + 'Advertise an address your phone can reach — for example a Tailscale hostname, IP address, or reverse-proxy URL.' ), inputLabel: translate('auto.components.mobile.CustomNetworkAddressDialog.label', 'Address'), placeholder: translate( 'auto.components.mobile.CustomNetworkAddressDialog.placeholder', - 'my-mac.ts.net, home.example.com, or 192.168.1.50' + 'home.example.com:8443 or https://example.com/orca' ), hint: translate( 'auto.components.mobile.CustomNetworkAddressDialog.hint', - 'Enter an IP address or a hostname — a Tailscale MagicDNS name, a DDNS domain, or a LAN hostname — optionally with :port.' + 'Enter an IPv4/IPv6 address, hostname, or full HTTP(S)/WebSocket URL. Ports are optional.' ), cancel: translate('auto.components.mobile.CustomNetworkAddressDialog.cancel', 'Cancel'), - confirm: translate('auto.components.mobile.CustomNetworkAddressDialog.use', 'Use address') + confirm: translate('auto.components.mobile.CustomNetworkAddressDialog.use', 'Use address'), + confirmationError: translate( + 'auto.components.mobile.CustomNetworkAddressDialog.confirmationError', + 'This address could not produce a scannable pairing code. Check the address and try again.' + ) }} /> ) diff --git a/src/renderer/src/components/mobile/use-mobile-pairing-generation.ts b/src/renderer/src/components/mobile/use-mobile-pairing-generation.ts index cb77f82e65f..2d6f57c6598 100644 --- a/src/renderer/src/components/mobile/use-mobile-pairing-generation.ts +++ b/src/renderer/src/components/mobile/use-mobile-pairing-generation.ts @@ -22,6 +22,7 @@ export function useMobilePairingGeneration(params: { pairingRequestIdRef: MutableRef setPairQrDataUrl: (value: string | null) => void setPairingUrl: (value: string | null) => void + setPairingQrError: (value: boolean) => void setPairLoading: (value: boolean) => void /** Mode the minted QR actually encodes (degraded Relay mints report * 'local-only'); null while no QR is shown. */ @@ -42,6 +43,7 @@ export function useMobilePairingGeneration(params: { pairingRequestIdRef, setPairQrDataUrl, setPairingUrl, + setPairingQrError, setPairLoading, setEncodedConnectionMode } = params @@ -81,6 +83,7 @@ export function useMobilePairingGeneration(params: { if (mountedRef.current) { setPairQrDataUrl(result.qrDataUrl) setPairingUrl(result.pairingUrl) + setPairingQrError(result.qrDataUrl === null) setEncodedConnectionMode(result.connectionMode) } } else { @@ -88,6 +91,7 @@ export function useMobilePairingGeneration(params: { if (mountedRef.current) { setPairQrDataUrl(null) setPairingUrl(null) + setPairingQrError(false) setEncodedConnectionMode(null) toast.error( translate( @@ -102,6 +106,7 @@ export function useMobilePairingGeneration(params: { hasGeneratedRef.current = false setPairQrDataUrl(null) setPairingUrl(null) + setPairingQrError(false) setEncodedConnectionMode(null) toast.error( translate( @@ -126,6 +131,7 @@ export function useMobilePairingGeneration(params: { setPairLoading, setPairQrDataUrl, setPairingUrl, + setPairingQrError, signedIn ] ) diff --git a/src/renderer/src/components/mobile/use-mobile-pairing-qr-invalidation.ts b/src/renderer/src/components/mobile/use-mobile-pairing-qr-invalidation.ts index 47f57dfd063..77ae564186e 100644 --- a/src/renderer/src/components/mobile/use-mobile-pairing-qr-invalidation.ts +++ b/src/renderer/src/components/mobile/use-mobile-pairing-qr-invalidation.ts @@ -21,6 +21,7 @@ export function useMobilePairingQrInvalidation(params: { pairingRequestIdRef: MutableRef setPairQrDataUrl: (value: string | null) => void setPairingUrl: (value: string | null) => void + setPairingQrError: (value: boolean) => void setPairLoading: (value: boolean) => void regenerate: (mode: MobilePairingConnectionMode, opts: { rotate: boolean }) => void }): void { @@ -32,6 +33,7 @@ export function useMobilePairingQrInvalidation(params: { pairingRequestIdRef, setPairQrDataUrl, setPairingUrl, + setPairingQrError, setPairLoading, regenerate } = params @@ -53,6 +55,7 @@ export function useMobilePairingQrInvalidation(params: { pairingRequestIdRef.current += 1 hasGeneratedRef.current = false setPairingUrl(null) + setPairingQrError(false) setPairQrDataUrl(null) if (signedIn && canMintMobilePairingOffer({ connectionMode, signedIn })) { // Why: rotate on the sign-in edge — the token behind the QR cleared at @@ -68,6 +71,7 @@ export function useMobilePairingQrInvalidation(params: { pairingRequestIdRef, setPairQrDataUrl, setPairingUrl, + setPairingQrError, setPairLoading, regenerate ]) @@ -87,6 +91,7 @@ export function useMobilePairingQrInvalidation(params: { const shouldRegenerate = hasGeneratedRef.current || pairLoading hasGeneratedRef.current = false setPairingUrl(null) + setPairingQrError(false) setPairQrDataUrl(null) if (shouldRegenerate && canMintMobilePairingOffer({ connectionMode, signedIn })) { // Why: no rotate here — the main process rotates exactly once when the @@ -106,6 +111,7 @@ export function useMobilePairingQrInvalidation(params: { pairingRequestIdRef, setPairQrDataUrl, setPairingUrl, + setPairingQrError, setPairLoading, regenerate ]) diff --git a/src/renderer/src/components/network/AddressPicker.tsx b/src/renderer/src/components/network/AddressPicker.tsx index 8d6603861b9..07cbee99d24 100644 --- a/src/renderer/src/components/network/AddressPicker.tsx +++ b/src/renderer/src/components/network/AddressPicker.tsx @@ -27,6 +27,7 @@ export type AddressPickerProps = { options: readonly AddressOption[] value: string | undefined onValueChange: (value: string) => void + beforeCustomConfirm?: (value: string) => boolean | Promise // Why: a value that isn't one of `options` is a custom entry; this renders // its display label (e.g. `${value} (custom)`) so the Select can show it — // Radix Select only displays values that have a matching item. @@ -50,6 +51,7 @@ export function AddressPicker({ options, value, onValueChange, + beforeCustomConfirm, formatCustomLabel, addCustomLabel, customDialogCopy, @@ -74,6 +76,14 @@ export function AddressPicker({ onValueChange(next) } + const handleCustomConfirm = async (next: string): Promise => { + if (beforeCustomConfirm && !(await beforeCustomConfirm(next))) { + return false + } + onValueChange(next) + return true + } + return ( <>