From a2ff15e43899c9506cf902a24decdca1a58a37ff Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:29:05 -0700 Subject: [PATCH] refactor(renderer): split web preload API --- config/max-lines-baseline.txt | 1 - .../web/preload-api/web-agent-accounts-api.ts | 49 + .../web/preload-api/web-agent-hooks-api.ts | 42 + .../web/preload-api/web-agent-status-api.ts | 23 + .../src/web/preload-api/web-ai-vault-api.ts | 93 + .../src/web/preload-api/web-app-api.ts | 55 + .../src/web/preload-api/web-browser-api.ts | 101 + .../src/web/preload-api/web-cli-api.ts | 27 + .../src/web/preload-api/web-clipboard-api.ts | 177 + .../web/preload-api/web-diagnostics-api.ts | 41 + .../src/web/preload-api/web-e2e-api.ts | 10 + .../src/web/preload-api/web-e2e-config.ts | 11 + .../src/web/preload-api/web-fallback-api.ts | 63 + .../src/web/preload-api/web-filesystem-api.ts | 200 + .../src/web/preload-api/web-git-api.ts | 291 ++ .../src/web/preload-api/web-github-api.ts | 193 + .../web/preload-api/web-github-cache-api.ts | 19 + .../src/web/preload-api/web-github-routes.ts | 150 + .../src/web/preload-api/web-gitlab-api.ts | 71 + .../src/web/preload-api/web-gitlab-routes.ts | 71 + .../preload-api/web-host-capability-api.ts | 195 + .../web-keybinding-normalization.ts | 174 + .../web/preload-api/web-keybindings-api.ts | 174 + .../src/web/preload-api/web-macos-tcc-api.ts | 13 + .../src/web/preload-api/web-memory-api.ts | 24 + .../src/web/preload-api/web-mobile-api.ts | 26 + .../web/preload-api/web-native-chat-api.ts | 156 + .../web/preload-api/web-notifications-api.ts | 14 + .../src/web/preload-api/web-onboarding-api.ts | 56 + .../web/preload-api/web-orca-profiles-api.ts | 70 + .../src/web/preload-api/web-platform-api.ts | 16 + .../web-preference-normalization.ts | 164 + .../web/preload-api/web-preferences-store.ts | 297 ++ .../web/preload-api/web-rate-limits-api.ts | 36 + .../web/preload-api/web-repositories-api.ts | 130 + .../src/web/preload-api/web-review-api.ts | 44 + .../src/web/preload-api/web-runtime-api.ts | 21 + .../src/web/preload-api/web-runtime-calls.ts | 120 + .../web-runtime-environments-api.ts | 198 + .../web/preload-api/web-runtime-session.ts | 145 + .../web-runtime-worktree-catalog.ts | 183 + .../src/web/preload-api/web-settings-api.ts | 118 + .../src/web/preload-api/web-shell-api.ts | 30 + .../src/web/preload-api/web-star-nag-api.ts | 21 + .../src/web/preload-api/web-storage.ts | 43 + .../src/web/preload-api/web-telemetry-api.ts | 11 + .../src/web/preload-api/web-terminal-api.ts | 180 + .../src/web/preload-api/web-ui-api.ts | 241 + .../src/web/preload-api/web-updater-api.ts | 34 + .../preload-api/web-workspace-ports-api.ts | 23 + .../preload-api/web-workspace-session-api.ts | 71 + .../src/web/preload-api/web-worktrees-api.ts | 188 + .../src/web/web-preload-api-clipboard.test.ts | 17 +- .../web/web-preload-api-composition.test.ts | 119 + .../src/web/web-preload-api-github.test.ts | 3 +- .../src/web/web-preload-api-gitlab.test.ts | 3 +- .../web/web-preload-api-runtime-calls.test.ts | 206 + ...eb-preload-api-runtime-environment.test.ts | 55 + .../web-preload-api-workspace-catalog.test.ts | 34 + src/renderer/src/web/web-preload-api.ts | 4595 +---------------- .../web-runtime-client-timeout-budget.test.ts | 78 + 61 files changed, 5479 insertions(+), 4535 deletions(-) create mode 100644 src/renderer/src/web/preload-api/web-agent-accounts-api.ts create mode 100644 src/renderer/src/web/preload-api/web-agent-hooks-api.ts create mode 100644 src/renderer/src/web/preload-api/web-agent-status-api.ts create mode 100644 src/renderer/src/web/preload-api/web-ai-vault-api.ts create mode 100644 src/renderer/src/web/preload-api/web-app-api.ts create mode 100644 src/renderer/src/web/preload-api/web-browser-api.ts create mode 100644 src/renderer/src/web/preload-api/web-cli-api.ts create mode 100644 src/renderer/src/web/preload-api/web-clipboard-api.ts create mode 100644 src/renderer/src/web/preload-api/web-diagnostics-api.ts create mode 100644 src/renderer/src/web/preload-api/web-e2e-api.ts create mode 100644 src/renderer/src/web/preload-api/web-e2e-config.ts create mode 100644 src/renderer/src/web/preload-api/web-fallback-api.ts create mode 100644 src/renderer/src/web/preload-api/web-filesystem-api.ts create mode 100644 src/renderer/src/web/preload-api/web-git-api.ts create mode 100644 src/renderer/src/web/preload-api/web-github-api.ts create mode 100644 src/renderer/src/web/preload-api/web-github-cache-api.ts create mode 100644 src/renderer/src/web/preload-api/web-github-routes.ts create mode 100644 src/renderer/src/web/preload-api/web-gitlab-api.ts create mode 100644 src/renderer/src/web/preload-api/web-gitlab-routes.ts create mode 100644 src/renderer/src/web/preload-api/web-host-capability-api.ts create mode 100644 src/renderer/src/web/preload-api/web-keybinding-normalization.ts create mode 100644 src/renderer/src/web/preload-api/web-keybindings-api.ts create mode 100644 src/renderer/src/web/preload-api/web-macos-tcc-api.ts create mode 100644 src/renderer/src/web/preload-api/web-memory-api.ts create mode 100644 src/renderer/src/web/preload-api/web-mobile-api.ts create mode 100644 src/renderer/src/web/preload-api/web-native-chat-api.ts create mode 100644 src/renderer/src/web/preload-api/web-notifications-api.ts create mode 100644 src/renderer/src/web/preload-api/web-onboarding-api.ts create mode 100644 src/renderer/src/web/preload-api/web-orca-profiles-api.ts create mode 100644 src/renderer/src/web/preload-api/web-platform-api.ts create mode 100644 src/renderer/src/web/preload-api/web-preference-normalization.ts create mode 100644 src/renderer/src/web/preload-api/web-preferences-store.ts create mode 100644 src/renderer/src/web/preload-api/web-rate-limits-api.ts create mode 100644 src/renderer/src/web/preload-api/web-repositories-api.ts create mode 100644 src/renderer/src/web/preload-api/web-review-api.ts create mode 100644 src/renderer/src/web/preload-api/web-runtime-api.ts create mode 100644 src/renderer/src/web/preload-api/web-runtime-calls.ts create mode 100644 src/renderer/src/web/preload-api/web-runtime-environments-api.ts create mode 100644 src/renderer/src/web/preload-api/web-runtime-session.ts create mode 100644 src/renderer/src/web/preload-api/web-runtime-worktree-catalog.ts create mode 100644 src/renderer/src/web/preload-api/web-settings-api.ts create mode 100644 src/renderer/src/web/preload-api/web-shell-api.ts create mode 100644 src/renderer/src/web/preload-api/web-star-nag-api.ts create mode 100644 src/renderer/src/web/preload-api/web-storage.ts create mode 100644 src/renderer/src/web/preload-api/web-telemetry-api.ts create mode 100644 src/renderer/src/web/preload-api/web-terminal-api.ts create mode 100644 src/renderer/src/web/preload-api/web-ui-api.ts create mode 100644 src/renderer/src/web/preload-api/web-updater-api.ts create mode 100644 src/renderer/src/web/preload-api/web-workspace-ports-api.ts create mode 100644 src/renderer/src/web/preload-api/web-workspace-session-api.ts create mode 100644 src/renderer/src/web/preload-api/web-worktrees-api.ts create mode 100644 src/renderer/src/web/web-preload-api-composition.test.ts create mode 100644 src/renderer/src/web/web-preload-api-runtime-calls.test.ts create mode 100644 src/renderer/src/web/web-runtime-client-timeout-budget.test.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 940129be633..e9427e54f2e 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -157,7 +157,6 @@ inline src/renderer/src/store/slices/tabs.ts inline src/renderer/src/store/slices/terminals.ts inline src/renderer/src/store/slices/ui.ts inline src/renderer/src/store/slices/workspace-cleanup.ts -inline src/renderer/src/web/web-preload-api.ts inline src/renderer/src/web/web-runtime-client.ts inline src/shared/agent-hook-listener.ts inline src/shared/automation-schedules.ts diff --git a/src/renderer/src/web/preload-api/web-agent-accounts-api.ts b/src/renderer/src/web/preload-api/web-agent-accounts-api.ts new file mode 100644 index 00000000000..921c8ca7a5d --- /dev/null +++ b/src/renderer/src/web/preload-api/web-agent-accounts-api.ts @@ -0,0 +1,49 @@ +import type { PreloadApi } from '../../../../preload/api-types' + +export function createMiniMaxCredentialsApi(): NonNullable< + Partial['minimaxCredentials'] +> { + const notConfigured = { configured: false } + const unsupportedError = new Error('MiniMax cookie storage is only available in the desktop app.') + return { + getStatus: () => Promise.resolve(notConfigured), + saveCookie: () => Promise.reject(unsupportedError), + clearCookie: () => Promise.resolve(notConfigured) + } +} + +export function createGrokAccountsApi(): NonNullable['grokAccounts']> { + const unsigned = { + signedIn: false, + email: null, + teamId: null, + tokenFresh: false, + error: null + } + return { + getStatus: () => Promise.resolve(unsigned) + } +} + +export function createAccountsApi(): never { + const empty = { + accounts: [], + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: {} } + } + return { + list: () => Promise.resolve(empty), + add: () => Promise.resolve(empty), + cancelPendingLogin: () => Promise.resolve(false), + reauthenticate: () => Promise.resolve(empty), + remove: () => Promise.resolve(empty), + select: () => Promise.resolve(empty), + // Why: launch accounts are recorded on the host that owns the PTY, which the + // web client never is — report no stale panes rather than reject the sweep. + listStalePanes: () => Promise.resolve([]), + // Why empty rather than absent: the same host owns both records, so a web + // client has no recorded lane to offer and every pane falls to derivation. + listRecordedPaneLanes: () => Promise.resolve({}), + forgetStalePanes: () => Promise.resolve() + } as never +} diff --git a/src/renderer/src/web/preload-api/web-agent-hooks-api.ts b/src/renderer/src/web/preload-api/web-agent-hooks-api.ts new file mode 100644 index 00000000000..41ed387d559 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-agent-hooks-api.ts @@ -0,0 +1,42 @@ +import type { PreloadApi } from '../../../../preload/api-types' + +export function createAgentHooksApi(): NonNullable['agentHooks']> { + const status = ( + agent: + | 'claude' + | 'openclaude' + | 'codex' + | 'gemini' + | 'antigravity' + | 'amp' + | 'cursor' + | 'droid' + | 'command-code' + | 'grok' + | 'copilot' + | 'hermes' + | 'devin' + ) => + Promise.resolve({ + agent, + state: 'not_installed', + configPath: '', + managedHooksPresent: false, + detail: 'Agent hook status is only available on the Orca server.' + } as const) + return { + claudeStatus: () => status('claude'), + openClaudeStatus: () => status('openclaude'), + codexStatus: () => status('codex'), + geminiStatus: () => status('gemini'), + antigravityStatus: () => status('antigravity'), + ampStatus: () => status('amp'), + cursorStatus: () => status('cursor'), + droidStatus: () => status('droid'), + commandCodeStatus: () => status('command-code'), + grokStatus: () => status('grok'), + copilotStatus: () => status('copilot'), + hermesStatus: () => status('hermes'), + devinStatus: () => status('devin') + } +} diff --git a/src/renderer/src/web/preload-api/web-agent-status-api.ts b/src/renderer/src/web/preload-api/web-agent-status-api.ts new file mode 100644 index 00000000000..bb5a5d61bb6 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-agent-status-api.ts @@ -0,0 +1,23 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { noopUnsubscribe } from './web-storage' + +export function createWebAgentStatusApi(): Partial { + return { + agentStatus: { + onSet: () => noopUnsubscribe, + onClear: () => noopUnsubscribe, + getSnapshot: () => Promise.resolve([]), + inferInterrupt: () => Promise.resolve(false), + inferQuestionAnswered: () => Promise.resolve(false), + onMigrationUnsupported: () => noopUnsubscribe, + onMigrationUnsupportedClear: () => noopUnsubscribe, + onLegacyWorkerTerminalRecovery: () => noopUnsubscribe, + getMigrationUnsupportedSnapshot: () => Promise.resolve([]), + drop: () => {}, + dropByTabPrefix: () => {}, + retirePaneAuthority: () => {}, + restorePaneAuthority: () => {}, + transferPaneAuthority: () => {} + } + } +} diff --git a/src/renderer/src/web/preload-api/web-ai-vault-api.ts b/src/renderer/src/web/preload-api/web-ai-vault-api.ts new file mode 100644 index 00000000000..383a657f7b8 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-ai-vault-api.ts @@ -0,0 +1,93 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import type { + AiVaultPrepareSessionResumeArgs, + AiVaultPrepareSessionResumeResult +} from '../../../../shared/ai-vault-resume-preparation' +import type { AiVaultDeleteSessionArgs } from '../../../../shared/ai-vault-session-deletion' +import type { + AiVaultSessionTitlesArgs, + AiVaultSessionTitlesResult +} from '../../../../shared/ai-vault-session-title' +import type { AiVaultListArgs, AiVaultListResult } from '../../../../shared/ai-vault-types' +import { + normalizeExecutionHostScope, + toRuntimeExecutionHostId +} from '../../../../shared/execution-host' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { callRuntimeResult } from './web-runtime-calls' +import { requireActiveEnvironment } from './web-runtime-session' +import { noopUnsubscribe } from './web-storage' +import { translate } from '@/i18n/i18n' + +export function createWebAiVaultApi(): NonNullable['aiVault']> { + return { + listSessions: (args?: AiVaultListArgs) => { + const environment = requireActiveEnvironment() + const executionHostId = toRuntimeExecutionHostId(environment.id) + const requestedScope = normalizeExecutionHostScope( + args?.executionHostScope ?? executionHostId + ) + if (requestedScope !== 'all' && requestedScope !== executionHostId) { + return Promise.resolve(webAiVaultUnavailableResult(requestedScope)) + } + // Why: no local filesystem in the browser, so every history scan runs on and is stamped as the paired runtime host. + return callRuntimeResult('aiVault.listSessions', { + limit: args?.limit, + force: args?.force, + scopePaths: args?.scopePaths, + executionHostId + }) + }, + resolveSessionTitles: (args: AiVaultSessionTitlesArgs) => { + const environment = requireActiveEnvironment() + const executionHostId = toRuntimeExecutionHostId(environment.id) + if ( + args.executionHostScope && + normalizeExecutionHostScope(args.executionHostScope) !== executionHostId + ) { + return Promise.resolve({ titles: [] }) + } + return callRuntimeResult('aiVault.resolveSessionTitles', { + requests: args.requests + }).catch(() => ({ titles: [] })) + }, + // Why: the runtime RPC transport has no cancel verb, so the in-flight scan + // settles on its own timeout. The renderer's refreshId guard already drops + // the late result; this only means web pays for a scan nobody reads. + cancelListSessions: () => Promise.resolve(), + prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs) => + callRuntimeResult('aiVault.prepareSessionResume', args), + // Why: no server-side RPC for subagent transcript listing yet, so report an empty (not erroring) result. + listSubagentSessions: () => Promise.resolve({ sessions: [], issues: [] }), + // Why: full first-prompt re-parse is local-FS only; web/runtime falls back to preview text. + getFirstUserPrompt: () => Promise.resolve({ prompt: null }), + // Why: session deletion is local-only and has no runtime RPC; a web + // client's sessions are runtime-hosted, so report the same non-local + // rejection the UI already gates on rather than pretend to delete. + deleteSession: (args: AiVaultDeleteSessionArgs) => + Promise.resolve({ + outcome: 'rejected', + agent: args.agent, + reason: 'non-local-host' as const + }), + onWindowFocused: () => noopUnsubscribe + } +} + +export function webAiVaultUnavailableResult(executionHostId: ExecutionHostId): AiVaultListResult { + return { + sessions: [], + issues: [ + { + executionHostId, + agent: 'codex', + path: executionHostId, + message: translate( + 'auto.web.webPreloadApi.aiVaultUnavailableForHost', + 'Agent Session History is not available for this execution host.' + ) + } + ], + scannedAt: new Date().toISOString() + } +} diff --git a/src/renderer/src/web/preload-api/web-app-api.ts b/src/renderer/src/web/preload-api/web-app-api.ts new file mode 100644 index 00000000000..68d9b80eb55 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-app-api.ts @@ -0,0 +1,55 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { sanitizeWebRuntimeWorkspaceSession } from '../web-workspace-session' +import { sessionStorageKeyForHost } from './web-workspace-session-api' +import { mergeWebUIState } from './web-preference-normalization' +import { readLocalWebUIState } from './web-preferences-store' +import { UI_STORAGE_KEY, writeJson } from './web-storage' + +export function createWebAppApi(): Partial { + return { + app: { + getIdentity: () => + Promise.resolve({ + name: 'Orca', + isDev: false, + devLabel: null, + devBranch: null, + devWorktreeName: null, + devRepoRoot: null, + dockBadgeLabel: null + }), + getFeatureWallAssetBaseUrl: () => Promise.resolve('/'), + relaunch: () => Promise.resolve(window.location.reload()), + restart: () => Promise.resolve(window.location.reload()), + reload: () => Promise.resolve(window.location.reload()), + stageBeforeUnloadSync: ({ sessions, ui }) => { + // Why: beforeunload cannot await the paired runtime, so the web adapter + // guarantees immediate browser-local durability for the final snapshot. + for (const { state, hostId } of sessions) { + writeJson(sessionStorageKeyForHost(hostId), sanitizeWebRuntimeWorkspaceSession(state)) + } + writeJson(UI_STORAGE_KEY, mergeWebUIState(readLocalWebUIState(), ui)) + }, + // Staging already wrote through to browser storage, so there is nothing left to join. + awaitBeforeUnloadCheckpoint: () => Promise.resolve(), + awaitFirstWindowStartupServices: () => Promise.resolve(), + recoverLegacyWorkerTerminalsForRendererStartup: () => Promise.resolve(), + startupDiagnostic: () => Promise.resolve(), + getKeyboardInputSourceId: () => Promise.resolve(null), + // The web client cannot inspect local Mission Control shortcuts. + getMacCapturedDigitRowChords: () => Promise.resolve([]), + getKeyboardLayoutSnapshot: () => Promise.resolve(null), + onKeyboardLayoutChanged: () => () => undefined, + setUnreadDockBadgeCount: () => Promise.resolve(), + getFloatingTerminalCwd: () => Promise.resolve(''), + getFloatingMarkdownDirectory: () => Promise.resolve(''), + pickFloatingMarkdownDocument: () => Promise.resolve(null), + pickFloatingWorkspaceDirectory: () => Promise.resolve(null), + // Browser fallback has no app-owned userData dir; reject so the sentinel can't claim sensitive evidence was persisted. + writeTerminalRenderDesyncEvidence: () => + Promise.reject( + new Error('Terminal render evidence is unavailable in the browser fallback.') + ) + } + } +} diff --git a/src/renderer/src/web/preload-api/web-browser-api.ts b/src/renderer/src/web/preload-api/web-browser-api.ts new file mode 100644 index 00000000000..259d7c73a60 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-browser-api.ts @@ -0,0 +1,101 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { translate } from '@/i18n/i18n' +import { noopUnsubscribe } from './web-storage' + +export function createBrowserApi(): NonNullable['browser']> { + return { + registerGuest: () => Promise.resolve(false), + isGuestRegistered: () => Promise.resolve(false), + repairGuestRegistration: () => Promise.resolve(false), + unregisterGuest: () => Promise.resolve(), + openDevTools: () => Promise.resolve(false), + setViewportOverride: () => Promise.resolve(false), + setAnnotationViewportBridge: () => Promise.resolve(false), + onGuestLoadFailed: () => noopUnsubscribe, + onCertificateFailureChanged: () => noopUnsubscribe, + proceedCertificate: () => Promise.resolve({ ok: false, reason: 'missing' }), + onPermissionDenied: () => noopUnsubscribe, + onPopup: () => noopUnsubscribe, + onDownloadRequested: () => noopUnsubscribe, + onDownloadProgress: () => noopUnsubscribe, + onDownloadFinished: () => noopUnsubscribe, + onContextMenuRequested: () => noopUnsubscribe, + onContextMenuDismissed: () => noopUnsubscribe, + onNavigationUpdate: () => noopUnsubscribe, + onActivateView: () => noopUnsubscribe, + onPaneFocus: () => noopUnsubscribe, + onOpenLinkInOrcaTab: () => noopUnsubscribe, + cancelDownload: () => Promise.resolve(false), + setGrabMode: () => + Promise.resolve({ + ok: false, + error: translate( + 'auto.web.web.preload.api.31bea294d5', + 'Grab mode is unavailable in the web client.' + ) + }), + awaitGrabSelection: () => + Promise.resolve({ + ok: false, + error: translate( + 'auto.web.web.preload.api.31bea294d5', + 'Grab mode is unavailable in the web client.' + ) + }), + cancelGrab: () => Promise.resolve(false), + captureSelectionScreenshot: () => + Promise.resolve({ + ok: false, + error: translate( + 'auto.web.web.preload.api.8dfcb7a351', + 'Selection screenshots are unavailable in the web client.' + ) + }), + extractHoverPayload: () => + Promise.resolve({ + ok: false, + error: translate( + 'auto.web.web.preload.api.275a776357', + 'Hover extraction is unavailable in the web client.' + ) + }), + onGrabModeToggle: () => noopUnsubscribe, + onGrabActionShortcut: () => noopUnsubscribe, + sessionListProfiles: () => Promise.resolve([]), + sessionCreateProfile: () => Promise.resolve(null), + sessionDeleteProfile: () => Promise.resolve(false), + sessionImportCookies: () => + Promise.resolve({ + ok: false, + summary: null, + error: translate( + 'auto.web.web.preload.api.67ec964791', + 'Cookie import is unavailable in the web client.' + ) + }), + sessionResolvePartition: () => Promise.resolve(null), + sessionDetectBrowsers: () => Promise.resolve([]), + sessionImportFromBrowser: () => + Promise.resolve({ + ok: false, + summary: null, + error: translate( + 'auto.web.web.preload.api.67ec964791', + 'Cookie import is unavailable in the web client.' + ) + }), + sessionClearDefaultCookies: () => Promise.resolve(false), + notifyActiveTabChanged: () => Promise.resolve(false) + } as unknown as NonNullable['browser']> +} + +export function createEmulatorApi(): NonNullable['emulator']> { + return { + onPaneFocus: () => noopUnsubscribe, + onAutoAttach: () => noopUnsubscribe, + startFrameStream: () => Promise.reject(new Error('Mobile emulator is unavailable on web.')), + stopFrameStream: () => Promise.resolve(), + onFrameStreamFrame: () => noopUnsubscribe, + onFrameStreamError: () => noopUnsubscribe + } as unknown as NonNullable['emulator']> +} diff --git a/src/renderer/src/web/preload-api/web-cli-api.ts b/src/renderer/src/web/preload-api/web-cli-api.ts new file mode 100644 index 00000000000..c85bba87e14 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-cli-api.ts @@ -0,0 +1,27 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { getBrowserPlatform } from './web-storage' + +export function createCliApi(): NonNullable['cli']> { + const status = { + platform: getBrowserPlatform(), + commandName: getBrowserPlatform() === 'linux' ? 'orca-ide' : 'orca', + commandPath: null, + pathDirectory: null, + pathConfigured: false, + launcherPath: null, + installMethod: null, + supported: false, + state: 'unsupported', + currentTarget: null, + unsupportedReason: 'launch_mode_unavailable', + detail: 'CLI registration is managed on the Orca server, not in the web browser.' + } as const + return { + getInstallStatus: () => Promise.resolve(status), + install: () => Promise.resolve(status), + remove: () => Promise.resolve(status), + getWslInstallStatus: (_args?: { distro?: string | null }) => Promise.resolve(status), + installWsl: (_args?: { distro?: string | null }) => Promise.resolve(status), + removeWsl: (_args?: { distro?: string | null }) => Promise.resolve(status) + } as NonNullable['cli']> +} diff --git a/src/renderer/src/web/preload-api/web-clipboard-api.ts b/src/renderer/src/web/preload-api/web-clipboard-api.ts new file mode 100644 index 00000000000..d86ac8c45b3 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-clipboard-api.ts @@ -0,0 +1,177 @@ +import { + CLIPBOARD_IMAGE_MAX_BASE64_CHARS, + CLIPBOARD_IMAGE_MAX_PIXELS, + CLIPBOARD_IMAGE_MAX_SOURCE_BYTES, + CLIPBOARD_IMAGE_TOO_LARGE_ERROR, + assertClipboardImageByteLengthWithinLimit, + assertClipboardImageDimensionsWithinLimit +} from '../../../../shared/clipboard-image' +import { assertClipboardTextWriteWithinLimitWithYield } from '../../../../shared/clipboard-text' +import { copyClipboardTextViaExecCommand } from '../web-clipboard-copy-fallback' +import { callRuntimeEnvelope, callRuntimeResult } from './web-runtime-calls' + +export const MAX_CLIPBOARD_IMAGE_BASE64_CHARS = CLIPBOARD_IMAGE_MAX_BASE64_CHARS + +export const MAX_CLIPBOARD_IMAGE_SOURCE_BYTES = CLIPBOARD_IMAGE_MAX_SOURCE_BYTES + +export const MAX_CLIPBOARD_IMAGE_PIXELS = CLIPBOARD_IMAGE_MAX_PIXELS + +export const CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS = 512 * 1024 + +export const CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS = 256 * 1024 + +export const CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS = 30_000 + +export function blobToBase64(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + const result = typeof reader.result === 'string' ? reader.result : '' + const commaIndex = result.indexOf(',') + resolve(commaIndex === -1 ? result : result.slice(commaIndex + 1)) + } + reader.onerror = () => reject(reader.error ?? new Error('Failed to read clipboard image')) + reader.readAsDataURL(blob) + }) +} + +export function assertClipboardImageBlobWithinLimit(blob: Blob): void { + assertClipboardImageByteLengthWithinLimit(blob.size) +} + +export async function convertImageBlobToPng(blob: Blob): Promise { + assertClipboardImageBlobWithinLimit(blob) + const bitmap = await createImageBitmap(blob) + try { + assertClipboardImageDimensionsWithinLimit(bitmap) + const canvas = document.createElement('canvas') + canvas.width = bitmap.width + canvas.height = bitmap.height + const context = canvas.getContext('2d') + if (!context || canvas.width <= 0 || canvas.height <= 0) { + throw new Error('Clipboard image could not be decoded') + } + context.drawImage(bitmap, 0, 0) + return await new Promise((resolve, reject) => { + canvas.toBlob((png) => { + if (!png) { + reject(new Error('Clipboard image could not be encoded as PNG')) + return + } + try { + assertClipboardImageBlobWithinLimit(png) + } catch (error) { + reject(error) + return + } + resolve(png) + }, 'image/png') + }) + } finally { + bitmap.close() + } +} + +export async function readClipboardImagePngBase64(): Promise { + const clipboard = navigator.clipboard as + | (Clipboard & { read?: () => Promise }) + | undefined + if (!clipboard?.read) { + return null + } + const items = await clipboard.read() + for (const item of items) { + const imageType = item.types.find((type) => type.startsWith('image/')) + if (!imageType) { + continue + } + const blob = await item.getType(imageType) + assertClipboardImageBlobWithinLimit(blob) + const pngBlob = imageType === 'image/png' ? blob : await convertImageBlobToPng(blob) + return blobToBase64(pngBlob) + } + return null +} + +export async function writeWebClipboardText(text: string): Promise { + await assertClipboardTextWriteWithinLimitWithYield(text) + const clipboard = navigator.clipboard + if (typeof clipboard?.writeText === 'function') { + try { + await clipboard.writeText(text) + return + } catch (error) { + // Preserve the current user-activation turn for the synchronous fallback. + if (copyClipboardTextViaExecCommand(text)) { + return + } + throw error + } + } + if (!copyClipboardTextViaExecCommand(text)) { + throw new Error('Clipboard write is unavailable in this browser context') + } +} + +export async function saveClipboardImageAsTempFileInRuntime( + contentBase64: string, + args?: { connectionId?: string | null; runtimeEnvironmentId?: string | null } +): Promise { + if (contentBase64.length > MAX_CLIPBOARD_IMAGE_BASE64_CHARS) { + throw new Error(CLIPBOARD_IMAGE_TOO_LARGE_ERROR) + } + const connectionId = args?.connectionId ?? null + const startResponse = await callRuntimeEnvelope<{ uploadId: string }>( + 'clipboard.startImageUpload', + { expectedBase64Length: contentBase64.length, connectionId }, + CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS + ) + if (!startResponse.ok) { + if ( + startResponse.error.code === 'method_not_found' && + contentBase64.length <= CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS + ) { + return callRuntimeResult( + 'clipboard.saveImageAsTempFile', + { contentBase64, connectionId }, + CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS + ) + } + throw new Error(startResponse.error.message) + } + + const { uploadId } = startResponse.result + try { + for ( + let offset = 0; + offset < contentBase64.length; + offset += CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS + ) { + await callRuntimeResult( + 'clipboard.appendImageUploadChunk', + { + uploadId, + offset, + contentBase64: contentBase64.slice( + offset, + offset + CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS + ) + }, + CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS + ) + } + return await callRuntimeResult( + 'clipboard.commitImageUpload', + { uploadId }, + CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS + ) + } catch (error) { + // Why: after chunked paste holds server-side state, release the bounded slot on failure rather than wait for TTL cleanup. + await callRuntimeResult( + 'clipboard.abortImageUpload', + { uploadId }, + CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS + ).catch(() => {}) + throw error + } +} diff --git a/src/renderer/src/web/preload-api/web-diagnostics-api.ts b/src/renderer/src/web/preload-api/web-diagnostics-api.ts new file mode 100644 index 00000000000..a426edc3a0b --- /dev/null +++ b/src/renderer/src/web/preload-api/web-diagnostics-api.ts @@ -0,0 +1,41 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { translate } from '@/i18n/i18n' + +export function createWebDiagnosticsApi(): Partial { + return { + crashReports: { + getLatestPending: () => Promise.resolve(null), + getLatestReport: () => Promise.resolve(null), + dismiss: () => Promise.resolve(null), + recordRendererError: () => Promise.resolve({ ok: true, report: null, deduped: true }), + recordBreadcrumb: () => {}, + submit: () => + Promise.resolve({ + ok: false, + status: null, + error: translate('auto.web.web.preload.api.fb290366b2', 'Unavailable on web.') + }), + copyLatestDiagnostics: () => + Promise.resolve({ + ok: false, + error: translate('auto.web.web.preload.api.fb290366b2', 'Unavailable on web.') + }), + // Why: no Electron process on web; the caller falls back to performance.memory. + readHeapStatistics: () => null + }, + diagnostics: { + getStatus: () => + Promise.resolve({ + localFileEnabled: false, + bundleEnabled: false, + traceFilePath: '', + traceFamilySize: 0 + }), + collectBundle: () => Promise.reject(new Error('Review files are unavailable on web.')), + openBundlePreview: () => Promise.reject(new Error('Review files are unavailable on web.')), + discardBundlePreview: () => Promise.resolve(), + uploadBundle: () => Promise.reject(new Error('Sending diagnostics is unavailable on web.')), + deleteBundle: () => Promise.reject(new Error('Sent diagnostics are unavailable on web.')) + } + } +} diff --git a/src/renderer/src/web/preload-api/web-e2e-api.ts b/src/renderer/src/web/preload-api/web-e2e-api.ts new file mode 100644 index 00000000000..98a0694eb13 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-e2e-api.ts @@ -0,0 +1,10 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { webE2EConfig } from './web-e2e-config' + +export function createWebE2EApi(): Partial { + return { + e2e: { + getConfig: () => webE2EConfig + } + } +} diff --git a/src/renderer/src/web/preload-api/web-e2e-config.ts b/src/renderer/src/web/preload-api/web-e2e-config.ts new file mode 100644 index 00000000000..cddf982ad5f --- /dev/null +++ b/src/renderer/src/web/preload-api/web-e2e-config.ts @@ -0,0 +1,11 @@ +import { createE2EConfig } from '../../../../shared/e2e-config' + +// Why: this module stays dependency-light so the query/storage snapshot evaluates before runtime state. +const webE2EExposeStore = String(import.meta.env.VITE_EXPOSE_STORE) === 'true' +const webE2EQuery = webE2EExposeStore ? new URLSearchParams(window.location.search) : null + +export const webE2EConfig = createE2EConfig({ + exposeStore: webE2EExposeStore, + terminalParkingDelayMs: Number(webE2EQuery?.get('orcaE2ETerminalParkingDelayMs')) || null, + terminalRetentionLimit: Number(webE2EQuery?.get('orcaE2ETerminalRetentionLimit')) || null +}) diff --git a/src/renderer/src/web/preload-api/web-fallback-api.ts b/src/renderer/src/web/preload-api/web-fallback-api.ts new file mode 100644 index 00000000000..3cfef000ef1 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-fallback-api.ts @@ -0,0 +1,63 @@ +import { noopUnsubscribe } from './web-storage' + +export function withFallback(target: T, path: string[]): T { + return new Proxy(target, { + get(current, property, receiver) { + if (property in current) { + const value = Reflect.get(current, property, receiver) as unknown + if (value && typeof value === 'object' && !Array.isArray(value)) { + return withFallback(value as object, [...path, String(property)]) + } + return value + } + return createFallbackProxy([...path, String(property)]) + } + }) +} + +export function createFallbackProxy( + path: string[], + applyOverride?: (path: string[], args: unknown[]) => unknown +): never { + const fn = () => undefined + return new Proxy(fn, { + get(_target, property) { + if (property === 'then') { + return undefined + } + return createFallbackProxy([...path, String(property)], applyOverride) + }, + apply(_target, _thisArg, args) { + if (applyOverride) { + return applyOverride(path, args) + } + return getFallbackResult(path, args) + } + }) as never +} + +export function getFallbackResult(path: string[], args: unknown[]): unknown { + const name = path.at(-1) ?? '' + if (name.startsWith('on')) { + return noopUnsubscribe + } + if (name.startsWith('is') || name.startsWith('has') || name === 'pathExists') { + return Promise.resolve(false) + } + if (name.startsWith('list') || name.startsWith('detect')) { + return Promise.resolve([]) + } + if (name.startsWith('preview')) { + return Promise.resolve({ found: false, diff: {}, unsupportedKeys: [] }) + } + if (name.startsWith('get') && name.endsWith('Status')) { + return Promise.resolve([]) + } + if (name === 'write' || name === 'resize' || name === 'reportGeometry') { + return undefined + } + if (args.length === 0 && (name === 'getZoomLevel' || name === 'declarePendingPaneSerializer')) { + return 0 + } + return Promise.resolve(undefined) +} diff --git a/src/renderer/src/web/preload-api/web-filesystem-api.ts b/src/renderer/src/web/preload-api/web-filesystem-api.ts new file mode 100644 index 00000000000..dcf93839828 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-filesystem-api.ts @@ -0,0 +1,200 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import type { SearchResult } from '../../../../shared/code-search-types' +import { assertFileMutationOwnershipCapability } from '../../../../shared/file-mutation-ownership' +import type { DirEntry } from '../../../../shared/filesystem-entry-types' +import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { SshConnectionState } from '../../../../shared/ssh-types' +import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' +import { createWebFileMutationMethods } from '../web-file-mutation-methods' +import { callRuntimeResult } from './web-runtime-calls' +import type { WebRuntimeEnvelopeCaller, WebRuntimeResultCaller } from './web-runtime-calls' +import { + getClientForEnvironment, + requireActiveEnvironment, + requireActiveEnvironmentOrNull, + runtimeCallQueuePool, + updateEnvironmentFromResponse, + webRuntimeState +} from './web-runtime-session' +import { isMissingPathError, resolveRuntimeFilePath } from './web-runtime-worktree-catalog' +import { noopUnsubscribe } from './web-storage' + +export function createFileApi(): NonNullable['fs']> { + return { + readDir: async ({ dirPath }) => { + const file = await resolveRuntimeFilePath(dirPath) + return callRuntimeResult('files.readDir', { + worktree: toRuntimeWorktreeSelector(file.worktree.id), + relativePath: file.relativePath + }) + }, + readFile: async ({ filePath }) => { + const file = await resolveRuntimeFilePath(filePath) + return callRuntimeResult('files.readPreview', { + worktree: toRuntimeWorktreeSelector(file.worktree.id), + relativePath: file.relativePath + }) + }, + readLocalLogTail: async () => { + throw new Error('Local log tailing is unavailable in paired web clients.') + }, + startLocalLogTail: async () => { + throw new Error('Local log tailing is unavailable in paired web clients.') + }, + stopLocalLogTail: async () => {}, + onLocalLogTailChanged: () => noopUnsubscribe, + downloadFile: async () => { + throw new Error('Remote file download is unavailable in paired web clients.') + }, + downloadFolder: async () => { + throw new Error('Remote folder download is unavailable in paired web clients.') + }, + saveDownloadedFile: async () => { + throw new Error('Remote file download is unavailable in paired web clients.') + }, + startDownloadedFile: async () => { + throw new Error('Remote file download is unavailable in paired web clients.') + }, + appendDownloadedFileChunk: async () => { + throw new Error('Remote file download is unavailable in paired web clients.') + }, + finishDownloadedFile: async () => { + throw new Error('Remote file download is unavailable in paired web clients.') + }, + cancelDownloadedFile: async () => { + throw new Error('Remote file download is unavailable in paired web clients.') + }, + listMarkdownDocuments: async ({ rootPath }) => { + const file = await resolveRuntimeFilePath(rootPath) + return callRuntimeResult('files.listMarkdownDocuments', { + worktree: toRuntimeWorktreeSelector(file.worktree.id) + }) + }, + ...createWebFileMutationMethods({ + captureSession: captureWebFileMutationSession + }), + authorizeExternalPath: () => Promise.resolve(), + stat: async ({ filePath }) => { + const file = await resolveRuntimeFilePath(filePath) + return callRuntimeResult('files.stat', { + worktree: toRuntimeWorktreeSelector(file.worktree.id), + relativePath: file.relativePath + }) + }, + pathExists: async ({ filePath }) => { + try { + const file = await resolveRuntimeFilePath(filePath) + await callRuntimeResult('files.stat', { + worktree: toRuntimeWorktreeSelector(file.worktree.id), + relativePath: file.relativePath + }) + return true + } catch (error) { + if (isMissingPathError(error)) { + return false + } + throw error + } + }, + listFiles: async ({ rootPath, excludePaths }) => { + const file = await resolveRuntimeFilePath(rootPath) + const result = await callRuntimeResult<{ files: { relativePath: string }[] }>( + 'files.listAll', + { + worktree: toRuntimeWorktreeSelector(file.worktree.id), + excludePaths + } + ) + return result.files.map((entry) => entry.relativePath) + }, + cancelListFiles: async () => { + // Why: paired-web lists files over runtime RPC with its own timeout; there's no host-side scan to abort here. + }, + search: async (args) => { + const file = await resolveRuntimeFilePath(args.rootPath) + return callRuntimeResult('files.search', { + worktree: toRuntimeWorktreeSelector(file.worktree.id), + query: args.query, + caseSensitive: args.caseSensitive, + wholeWord: args.wholeWord, + useRegex: args.useRegex, + includePattern: args.includePattern, + excludePattern: args.excludePattern, + maxResults: args.maxResults + }) + }, + importExternalPaths: async () => ({ results: [] }), + stageExternalPathsForRuntimeUpload: async () => ({ sources: [] }), + resolveDroppedPathsForAgent: async () => ({ resolvedPaths: [], skipped: [], failed: [] }), + watchWorktree: () => Promise.resolve(), + unwatchWorktree: () => Promise.resolve(), + onFsChanged: () => noopUnsubscribe + } +} + +export function captureWebFileMutationSession(): { + resolveFilePath: (filePath: string) => Promise>> + assertMutationSupported: () => Promise + callRuntimeResult: WebRuntimeResultCaller + getSshState: (targetId: string) => Promise +} { + const environment = requireActiveEnvironment() + const client = getClientForEnvironment(environment) + const assertCurrent = (): void => { + if ( + webRuntimeState.activeClient !== client || + requireActiveEnvironmentOrNull()?.id !== environment.id + ) { + throw new Error('Runtime pairing changed; refresh and try again') + } + } + const callBoundRuntimeEnvelope: WebRuntimeEnvelopeCaller = async ( + method: string, + params?: unknown, + timeoutMs?: number + ): Promise> => { + assertCurrent() + const response = await runtimeCallQueuePool.enqueue(environment.id, method, () => { + assertCurrent() + return client.call(method, params, { timeoutMs }) + }) + assertCurrent() + updateEnvironmentFromResponse(environment, response) + return response as RuntimeRpcResponse + } + const callBoundRuntimeResult: WebRuntimeResultCaller = async ( + method: string, + params?: unknown, + timeoutMs?: number + ): Promise => { + const response = await callBoundRuntimeEnvelope(method, params, timeoutMs) + if (!response.ok) { + throw new Error(response.error.message) + } + return response.result as TResult + } + return { + resolveFilePath: (filePath) => + resolveRuntimeFilePath( + filePath, + undefined, + callBoundRuntimeResult, + callBoundRuntimeEnvelope, + false, + environment.id + ), + assertMutationSupported: async () => { + assertFileMutationOwnershipCapability( + await callBoundRuntimeResult('status.get', undefined, 15_000) + ) + }, + callRuntimeResult: callBoundRuntimeResult, + getSshState: async (targetId) => + ( + await callBoundRuntimeResult<{ state: SshConnectionState | null }>('ssh.getState', { + targetId + }) + ).state + } +} diff --git a/src/renderer/src/web/preload-api/web-git-api.ts b/src/renderer/src/web/preload-api/web-git-api.ts new file mode 100644 index 00000000000..f0b0423270a --- /dev/null +++ b/src/renderer/src/web/preload-api/web-git-api.ts @@ -0,0 +1,291 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { callAbortableRuntimeEnvironment } from '../../runtime/abortable-runtime-environment-call' +import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' +import { translate } from '@/i18n/i18n' +import { callRuntimeResult } from './web-runtime-calls' +import { requireActiveEnvironment, updateEnvironmentFromResponse } from './web-runtime-session' +import { + resolveRuntimeFilePath, + resolveRuntimeWorktreeByPath +} from './web-runtime-worktree-catalog' + +export const webGitStatusAbortControllers = new Map() + +export async function callAbortableRuntimeStatus( + requestToken: string, + params: unknown +): Promise { + const environment = requireActiveEnvironment() + webGitStatusAbortControllers.get(requestToken)?.abort() + const controller = new AbortController() + webGitStatusAbortControllers.set(requestToken, controller) + try { + const response = await callAbortableRuntimeEnvironment( + environment.id, + 'git.status', + params, + undefined, + controller.signal + ) + updateEnvironmentFromResponse(environment, response) + if (!response.ok) { + throw new Error(response.error.message) + } + return response.result as TResult + } finally { + if (webGitStatusAbortControllers.get(requestToken) === controller) { + webGitStatusAbortControllers.delete(requestToken) + } + } +} + +export function createGitApi(): NonNullable['git']> { + return { + status: async ({ + worktreePath, + includeIgnored, + bypassEffectiveUpstreamNegativeCache, + reuseLineStats, + branchLineTotalMergeBase, + requestToken + }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + const params = { + worktree: toRuntimeWorktreeSelector(worktree.id), + includeIgnored, + bypassEffectiveUpstreamNegativeCache, + reuseLineStats, + ...(branchLineTotalMergeBase ? { branchLineTotalMergeBase } : {}) + } + // Why: no token = nothing to cancel (pooled); a token routes via the subscription bridge so cancelStatus can abort. + if (!requestToken) { + return callRuntimeResult('git.status', params) + } + return callAbortableRuntimeStatus(requestToken, params) + }, + cancelStatus: async ({ requestToken }) => { + webGitStatusAbortControllers.get(requestToken)?.abort() + }, + setStatusUpstreamRefWatch: async () => {}, + submoduleStatus: async ({ worktreePath, submodulePath, area }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult('git.submoduleStatus', { + worktree: toRuntimeWorktreeSelector(worktree.id), + submodulePath, + area + }) + }, + checkIgnored: async ({ worktreePath, paths }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult('git.checkIgnored', { + worktree: toRuntimeWorktreeSelector(worktree.id), + paths + }) + }, + // Why: the "add huge folder to .gitignore" flow is desktop-only; the web runtime makes no offer, so return no candidates. + findHugeFoldersToIgnore: async () => [], + appendGitignore: async () => false, + history: async ({ worktreePath, limit, baseRef }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult('git.history', { + worktree: toRuntimeWorktreeSelector(worktree.id), + limit, + baseRef + }) + }, + conflictOperation: async ({ worktreePath }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult('git.conflictOperation', { + worktree: toRuntimeWorktreeSelector(worktree.id) + }) + }, + abortMerge: async ({ worktreePath }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + await callRuntimeResult('git.abortMerge', { + worktree: toRuntimeWorktreeSelector(worktree.id) + }) + }, + abortRebase: async ({ worktreePath }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + await callRuntimeResult('git.abortRebase', { + worktree: toRuntimeWorktreeSelector(worktree.id) + }) + }, + diff: async ({ worktreePath, filePath, staged, compareAgainstHead }) => { + const file = await resolveRuntimeFilePath(filePath, worktreePath) + return callRuntimeResult('git.diff', { + worktree: toRuntimeWorktreeSelector(file.worktree.id), + filePath: file.relativePath, + staged, + compareAgainstHead + }) + }, + branchCompare: async ({ worktreePath, baseRef }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult('git.branchCompare', { + worktree: toRuntimeWorktreeSelector(worktree.id), + baseRef + }) + }, + commitCompare: async ({ worktreePath, commitId }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult('git.commitCompare', { + worktree: toRuntimeWorktreeSelector(worktree.id), + commitId + }) + }, + upstreamStatus: async ({ worktreePath, pushTarget }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult('git.upstreamStatus', { + worktree: toRuntimeWorktreeSelector(worktree.id), + pushTarget + }) + }, + fetch: async ({ worktreePath, pushTarget }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + await callRuntimeResult('git.fetch', { + worktree: toRuntimeWorktreeSelector(worktree.id), + pushTarget + }) + }, + syncFork: async ({ worktreePath, expectedUpstream }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult( + 'git.forkSync', + { + worktree: toRuntimeWorktreeSelector(worktree.id), + expectedUpstream + }, + 60_000 + ) + }, + push: async ({ worktreePath, publish, pushTarget }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + await callRuntimeResult('git.push', { + worktree: toRuntimeWorktreeSelector(worktree.id), + publish, + pushTarget + }) + }, + pull: async ({ worktreePath, pushTarget }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + await callRuntimeResult('git.pull', { + worktree: toRuntimeWorktreeSelector(worktree.id), + pushTarget + }) + }, + fastForward: async ({ worktreePath, pushTarget }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + await callRuntimeResult('git.fastForward', { + worktree: toRuntimeWorktreeSelector(worktree.id), + pushTarget + }) + }, + rebaseFromBase: async ({ worktreePath, baseRef }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + await callRuntimeResult('git.rebaseFromBase', { + worktree: toRuntimeWorktreeSelector(worktree.id), + baseRef + }) + }, + branchDiff: async ({ worktreePath, filePath, compare, oldPath }) => { + const file = await resolveRuntimeFilePath(filePath, worktreePath) + return callRuntimeResult('git.branchDiff', { + worktree: toRuntimeWorktreeSelector(file.worktree.id), + filePath: file.relativePath, + compare, + oldPath + }) + }, + commitDiff: async ({ worktreePath, filePath, commitOid, parentOid, oldPath }) => { + const file = await resolveRuntimeFilePath(filePath, worktreePath) + return callRuntimeResult('git.commitDiff', { + worktree: toRuntimeWorktreeSelector(file.worktree.id), + filePath: file.relativePath, + commitOid, + parentOid, + oldPath + }) + }, + commit: async ({ worktreePath, message }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult('git.commit', { + worktree: toRuntimeWorktreeSelector(worktree.id), + message + }) + }, + generateCommitMessage: async () => ({ + success: false, + error: translate( + 'auto.web.web.preload.api.9fc90740b6', + 'Commit message generation is unavailable in the web client.' + ) + }), + discoverCommitMessageModels: async () => ({ + success: false, + error: translate( + 'auto.web.web.preload.api.e57c82d276', + 'Commit message model discovery is unavailable in the web client.' + ) + }), + cancelGenerateCommitMessage: () => Promise.resolve(), + generatePullRequestFields: async () => ({ + success: false, + error: translate( + 'auto.web.web.preload.api.b8a1618172', + 'Pull request detail generation is unavailable in the web client.' + ) + }), + cancelGeneratePullRequestFields: () => Promise.resolve(), + stage: async ({ worktreePath, filePath }) => mutateGitPath('git.stage', worktreePath, filePath), + bulkStage: async ({ worktreePath, filePaths }) => + mutateGitPaths('git.bulkStage', worktreePath, filePaths), + unstage: async ({ worktreePath, filePath }) => + mutateGitPath('git.unstage', worktreePath, filePath), + bulkUnstage: async ({ worktreePath, filePaths }) => + mutateGitPaths('git.bulkUnstage', worktreePath, filePaths), + discard: async ({ worktreePath, filePath }) => + mutateGitPath('git.discard', worktreePath, filePath), + bulkDiscard: async ({ worktreePath, filePaths }) => { + for (const filePath of filePaths) { + await mutateGitPath('git.discard', worktreePath, filePath) + } + }, + remoteFileUrl: async ({ worktreePath, relativePath, line }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult('git.remoteFileUrl', { + worktree: toRuntimeWorktreeSelector(worktree.id), + relativePath, + line + }) + }, + remoteCommitUrl: async ({ worktreePath, sha }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult('git.remoteCommitUrl', { + worktree: toRuntimeWorktreeSelector(worktree.id), + sha + }) + } + } +} + +export async function mutateGitPath( + method: string, + worktreePath: string, + filePath: string +): Promise { + const file = await resolveRuntimeFilePath(filePath, worktreePath) + await callRuntimeResult(method, { + worktree: toRuntimeWorktreeSelector(file.worktree.id), + filePath: file.relativePath + }) +} + +export async function mutateGitPaths( + method: string, + worktreePath: string, + filePaths: string[] +): Promise { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + await callRuntimeResult(method, { worktree: toRuntimeWorktreeSelector(worktree.id), filePaths }) +} diff --git a/src/renderer/src/web/preload-api/web-github-api.ts b/src/renderer/src/web/preload-api/web-github-api.ts new file mode 100644 index 00000000000..ce22933ab30 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-github-api.ts @@ -0,0 +1,193 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { translate } from '@/i18n/i18n' +import { GITHUB_WEB_RPC_METHODS } from './web-github-routes' +import type { WebGitHubRuntimeMethod } from './web-github-routes' +import { mapRepoPathArg } from './web-review-api' +import { callRuntimeResult } from './web-runtime-calls' +import { noopUnsubscribe } from './web-storage' + +export type WebGitHubApi = NonNullable + +export type WebGitHubResult = Awaited> + +export function createGitHubApi(): WebGitHubApi { + const route = (method: WebGitHubRuntimeMethod, args?: unknown): Promise => + callRuntimeResult(method, mapRepoPathArg(args)) + const githubApi = { + viewer: () => Promise.resolve(null), + repoSlug: (args) => route>(GITHUB_WEB_RPC_METHODS.repoSlug, args), + repoUpstream: (args) => + route>(GITHUB_WEB_RPC_METHODS.repoUpstream, args), + prForBranch: (args) => + route>(GITHUB_WEB_RPC_METHODS.prForBranch, args), + refreshPRNow: async ({ candidate }) => { + const acceptMergedFallbackPR = + candidate.linkedPRNumber == null && + candidate.fallbackPRNumber != null && + candidate.fallbackPRSource != null + const pr = await route>(GITHUB_WEB_RPC_METHODS.prForBranch, { + repoPath: candidate.repoPath, + repoId: candidate.repoId, + branch: candidate.branch, + linkedPRNumber: candidate.linkedPRNumber ?? null, + fallbackPRNumber: candidate.fallbackPRNumber ?? null, + currentHeadOid: candidate.currentHeadOid ?? null, + ...(acceptMergedFallbackPR ? { acceptMergedFallbackPR: true } : {}) + }) + return pr + ? { kind: 'found', pr, fetchedAt: Date.now() } + : { kind: 'no-pr', fetchedAt: Date.now() } + }, + enqueuePRRefresh: () => Promise.resolve(false), + reportVisiblePRRefreshCandidates: () => Promise.resolve(false), + onPRRefreshEvent: () => noopUnsubscribe, + issue: (args) => route>(GITHUB_WEB_RPC_METHODS.issue, args), + workItem: (args) => route>(GITHUB_WEB_RPC_METHODS.workItem, args), + workItemByOwnerRepo: ({ repo: ownerRepo, ...args }) => + route>(GITHUB_WEB_RPC_METHODS.workItemByOwnerRepo, { + ...args, + ownerRepo + }), + workItemDetails: (args) => + route>(GITHUB_WEB_RPC_METHODS.workItemDetails, args), + notifyWorkItemMutated: () => Promise.resolve(false), + prFileContents: (args) => + route>(GITHUB_WEB_RPC_METHODS.prFileContents, args), + listIssues: (args) => + route>(GITHUB_WEB_RPC_METHODS.listIssues, args), + createIssue: (args) => + route>(GITHUB_WEB_RPC_METHODS.createIssue, args), + countWorkItems: (args) => + route>(GITHUB_WEB_RPC_METHODS.countWorkItems, args), + listWorkItems: (args) => + route>(GITHUB_WEB_RPC_METHODS.listWorkItems, args), + prChecks: (args) => route>(GITHUB_WEB_RPC_METHODS.prChecks, args), + prCheckDetails: (args) => + route>(GITHUB_WEB_RPC_METHODS.prCheckDetails, args), + rerunPRChecks: (args) => + route>(GITHUB_WEB_RPC_METHODS.rerunPRChecks, args), + prComments: (args) => + route>(GITHUB_WEB_RPC_METHODS.prComments, args), + setPRCommentReaction: (args) => + route>( + GITHUB_WEB_RPC_METHODS.setPRCommentReaction, + args + ), + resolveReviewThread: (args) => + route>( + GITHUB_WEB_RPC_METHODS.resolveReviewThread, + args + ), + setPRFileViewed: (args) => + route>(GITHUB_WEB_RPC_METHODS.setPRFileViewed, args), + updatePRTitle: (args) => + route>(GITHUB_WEB_RPC_METHODS.updatePRTitle, args), + mergePR: (args) => route>(GITHUB_WEB_RPC_METHODS.mergePR, args), + setPRAutoMerge: (args) => + route>(GITHUB_WEB_RPC_METHODS.setPRAutoMerge, args), + updatePRState: (args) => + route>(GITHUB_WEB_RPC_METHODS.updatePRState, args), + requestPRReviewers: (args) => + route>(GITHUB_WEB_RPC_METHODS.requestPRReviewers, args), + removePRReviewers: (args) => + route>(GITHUB_WEB_RPC_METHODS.removePRReviewers, args), + updateIssue: (args) => + route>(GITHUB_WEB_RPC_METHODS.updateIssue, args), + addIssueComment: (args) => + route>(GITHUB_WEB_RPC_METHODS.addIssueComment, args), + addPRReviewCommentReply: (args) => + route>( + GITHUB_WEB_RPC_METHODS.addPRReviewCommentReply, + args + ), + addPRReviewComment: (args) => + route>(GITHUB_WEB_RPC_METHODS.addPRReviewComment, args), + listLabels: (args) => + route>(GITHUB_WEB_RPC_METHODS.listLabels, args), + listAssignableUsers: (args) => + route>( + GITHUB_WEB_RPC_METHODS.listAssignableUsers, + args + ), + onWorkItemMutated: () => noopUnsubscribe, + checkOrcaStarred: () => Promise.resolve(null), + starOrca: () => Promise.resolve(false), + rateLimit: (args) => + route>(GITHUB_WEB_RPC_METHODS.rateLimit, args), + diagnoseAuth: () => + Promise.resolve({ + ok: false, + message: translate('auto.web.web.preload.api.31bfe8ae1a', 'Unavailable in the web client.') + } as never), + listAccessibleProjects: (args) => + route>( + GITHUB_WEB_RPC_METHODS.listAccessibleProjects, + args + ), + resolveProjectRef: (args) => + route>(GITHUB_WEB_RPC_METHODS.resolveProjectRef, args), + listProjectViews: (args) => + route>(GITHUB_WEB_RPC_METHODS.listProjectViews, args), + getProjectViewTable: (args) => + route>( + GITHUB_WEB_RPC_METHODS.getProjectViewTable, + args + ), + projectWorkItemDetailsBySlug: (args) => + route>( + GITHUB_WEB_RPC_METHODS.projectWorkItemDetailsBySlug, + args + ), + updateProjectItemField: (args) => + route>( + GITHUB_WEB_RPC_METHODS.updateProjectItemField, + args + ), + clearProjectItemField: (args) => + route>( + GITHUB_WEB_RPC_METHODS.clearProjectItemField, + args + ), + updateIssueBySlug: (args) => + route>(GITHUB_WEB_RPC_METHODS.updateIssueBySlug, args), + updatePullRequestBySlug: (args) => + route>( + GITHUB_WEB_RPC_METHODS.updatePullRequestBySlug, + args + ), + addIssueCommentBySlug: (args) => + route>( + GITHUB_WEB_RPC_METHODS.addIssueCommentBySlug, + args + ), + updateIssueCommentBySlug: (args) => + route>( + GITHUB_WEB_RPC_METHODS.updateIssueCommentBySlug, + args + ), + deleteIssueCommentBySlug: (args) => + route>( + GITHUB_WEB_RPC_METHODS.deleteIssueCommentBySlug, + args + ), + listLabelsBySlug: (args) => + route>(GITHUB_WEB_RPC_METHODS.listLabelsBySlug, args), + listAssignableUsersBySlug: (args) => + route>( + GITHUB_WEB_RPC_METHODS.listAssignableUsersBySlug, + args + ), + listIssueTypesBySlug: (args) => + route>( + GITHUB_WEB_RPC_METHODS.listIssueTypesBySlug, + args + ), + updateIssueTypeBySlug: (args) => + route>( + GITHUB_WEB_RPC_METHODS.updateIssueTypeBySlug, + args + ) + } satisfies WebGitHubApi + + return githubApi +} diff --git a/src/renderer/src/web/preload-api/web-github-cache-api.ts b/src/renderer/src/web/preload-api/web-github-cache-api.ts new file mode 100644 index 00000000000..1881a74c0d6 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-github-cache-api.ts @@ -0,0 +1,19 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { GITHUB_CACHE_STORAGE_KEY, readJson, writeJson } from './web-storage' + +export function createWebGithubCacheApi(): Partial { + return { + cache: { + getGitHub: () => + Promise.resolve( + readJson(GITHUB_CACHE_STORAGE_KEY, { + pr: {}, + issue: {} + }) + ), + setGitHub: async ({ cache }) => { + writeJson(GITHUB_CACHE_STORAGE_KEY, cache) + } + } + } +} diff --git a/src/renderer/src/web/preload-api/web-github-routes.ts b/src/renderer/src/web/preload-api/web-github-routes.ts new file mode 100644 index 00000000000..ce688832ec5 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-github-routes.ts @@ -0,0 +1,150 @@ +export type WebGitHubRouteKey = + | 'repoSlug' + | 'repoUpstream' + | 'prForBranch' + | 'issue' + | 'workItem' + | 'workItemByOwnerRepo' + | 'workItemDetails' + | 'prFileContents' + | 'listIssues' + | 'createIssue' + | 'countWorkItems' + | 'listWorkItems' + | 'prChecks' + | 'prCheckDetails' + | 'rerunPRChecks' + | 'prComments' + | 'setPRCommentReaction' + | 'resolveReviewThread' + | 'setPRFileViewed' + | 'updatePRTitle' + | 'mergePR' + | 'setPRAutoMerge' + | 'updatePRState' + | 'requestPRReviewers' + | 'removePRReviewers' + | 'updateIssue' + | 'addIssueComment' + | 'addPRReviewCommentReply' + | 'addPRReviewComment' + | 'listLabels' + | 'listAssignableUsers' + | 'rateLimit' + | 'listAccessibleProjects' + | 'resolveProjectRef' + | 'listProjectViews' + | 'getProjectViewTable' + | 'projectWorkItemDetailsBySlug' + | 'updateProjectItemField' + | 'clearProjectItemField' + | 'updateIssueBySlug' + | 'updatePullRequestBySlug' + | 'addIssueCommentBySlug' + | 'updateIssueCommentBySlug' + | 'deleteIssueCommentBySlug' + | 'listLabelsBySlug' + | 'listAssignableUsersBySlug' + | 'listIssueTypesBySlug' + | 'updateIssueTypeBySlug' + +export type WebGitHubRuntimeMethod = + | 'github.repoSlug' + | 'github.repoUpstream' + | 'github.prForBranch' + | 'github.issue' + | 'github.workItem' + | 'github.workItemByOwnerRepo' + | 'github.workItemDetails' + | 'github.prFileContents' + | 'github.listIssues' + | 'github.createIssue' + | 'github.countWorkItems' + | 'github.listWorkItems' + | 'github.prChecks' + | 'github.prCheckDetails' + | 'github.rerunPRChecks' + | 'github.prComments' + | 'github.setPRCommentReaction' + | 'github.resolveReviewThread' + | 'github.setPRFileViewed' + | 'github.updatePRTitle' + | 'github.mergePR' + | 'github.setPRAutoMerge' + | 'github.updatePRState' + | 'github.requestPRReviewers' + | 'github.removePRReviewers' + | 'github.updateIssue' + | 'github.addIssueComment' + | 'github.addPRReviewCommentReply' + | 'github.addPRReviewComment' + | 'github.listLabels' + | 'github.listAssignableUsers' + | 'github.rateLimit' + | 'github.project.listAccessible' + | 'github.project.resolveRef' + | 'github.project.listViews' + | 'github.project.viewTable' + | 'github.project.workItemDetailsBySlug' + | 'github.project.updateItemField' + | 'github.project.clearItemField' + | 'github.project.updateIssueBySlug' + | 'github.project.updatePullRequestBySlug' + | 'github.project.addIssueCommentBySlug' + | 'github.project.updateIssueCommentBySlug' + | 'github.project.deleteIssueCommentBySlug' + | 'github.project.listLabelsBySlug' + | 'github.project.listAssignableUsersBySlug' + | 'github.project.listIssueTypesBySlug' + | 'github.project.updateIssueTypeBySlug' + +export const GITHUB_WEB_RPC_METHODS = { + repoSlug: 'github.repoSlug', + repoUpstream: 'github.repoUpstream', + prForBranch: 'github.prForBranch', + issue: 'github.issue', + workItem: 'github.workItem', + workItemByOwnerRepo: 'github.workItemByOwnerRepo', + workItemDetails: 'github.workItemDetails', + prFileContents: 'github.prFileContents', + listIssues: 'github.listIssues', + createIssue: 'github.createIssue', + countWorkItems: 'github.countWorkItems', + listWorkItems: 'github.listWorkItems', + prChecks: 'github.prChecks', + prCheckDetails: 'github.prCheckDetails', + rerunPRChecks: 'github.rerunPRChecks', + prComments: 'github.prComments', + setPRCommentReaction: 'github.setPRCommentReaction', + resolveReviewThread: 'github.resolveReviewThread', + setPRFileViewed: 'github.setPRFileViewed', + updatePRTitle: 'github.updatePRTitle', + mergePR: 'github.mergePR', + setPRAutoMerge: 'github.setPRAutoMerge', + updatePRState: 'github.updatePRState', + requestPRReviewers: 'github.requestPRReviewers', + removePRReviewers: 'github.removePRReviewers', + updateIssue: 'github.updateIssue', + addIssueComment: 'github.addIssueComment', + addPRReviewCommentReply: 'github.addPRReviewCommentReply', + addPRReviewComment: 'github.addPRReviewComment', + listLabels: 'github.listLabels', + listAssignableUsers: 'github.listAssignableUsers', + rateLimit: 'github.rateLimit', + listAccessibleProjects: 'github.project.listAccessible', + resolveProjectRef: 'github.project.resolveRef', + listProjectViews: 'github.project.listViews', + getProjectViewTable: 'github.project.viewTable', + projectWorkItemDetailsBySlug: 'github.project.workItemDetailsBySlug', + updateProjectItemField: 'github.project.updateItemField', + clearProjectItemField: 'github.project.clearItemField', + updateIssueBySlug: 'github.project.updateIssueBySlug', + updatePullRequestBySlug: 'github.project.updatePullRequestBySlug', + addIssueCommentBySlug: 'github.project.addIssueCommentBySlug', + updateIssueCommentBySlug: 'github.project.updateIssueCommentBySlug', + deleteIssueCommentBySlug: 'github.project.deleteIssueCommentBySlug', + listLabelsBySlug: 'github.project.listLabelsBySlug', + listAssignableUsersBySlug: 'github.project.listAssignableUsersBySlug', + listIssueTypesBySlug: 'github.project.listIssueTypesBySlug', + updateIssueTypeBySlug: 'github.project.updateIssueTypeBySlug' +} as const satisfies Record diff --git a/src/renderer/src/web/preload-api/web-gitlab-api.ts b/src/renderer/src/web/preload-api/web-gitlab-api.ts new file mode 100644 index 00000000000..a183ee88df2 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-gitlab-api.ts @@ -0,0 +1,71 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { GITLAB_WEB_RPC_METHODS } from './web-gitlab-routes' +import type { WebGitLabRuntimeMethod } from './web-gitlab-routes' +import { mapRepoPathArg } from './web-review-api' +import { callRuntimeResult } from './web-runtime-calls' + +export type WebGitLabApi = NonNullable + +export type WebGitLabResult = Awaited> + +export function createGitLabApi(): WebGitLabApi { + const route = (method: WebGitLabRuntimeMethod, args?: unknown): Promise => + callRuntimeResult(method, mapRepoPathArg(args)) + + const gitLabApi = { + viewer: () => Promise.resolve(null), + diagnoseAuth: () => route>(GITLAB_WEB_RPC_METHODS.diagnoseAuth), + rateLimit: (args) => + route>(GITLAB_WEB_RPC_METHODS.rateLimit, args), + projectSlug: () => Promise.resolve(null), + mrForBranch: () => Promise.resolve(null), + mr: () => Promise.resolve(null), + listMRs: (args) => route>(GITLAB_WEB_RPC_METHODS.listMRs, args), + listWorkItems: (args) => + route>(GITLAB_WEB_RPC_METHODS.listWorkItems, args), + issue: () => Promise.resolve(null), + listIssues: (args) => + route>(GITLAB_WEB_RPC_METHODS.listIssues, args), + createIssue: (args) => + route>(GITLAB_WEB_RPC_METHODS.createIssue, args), + updateIssue: (args) => + route>(GITLAB_WEB_RPC_METHODS.updateIssue, args), + addIssueComment: (args) => + route>(GITLAB_WEB_RPC_METHODS.addIssueComment, args), + listLabels: (args) => + route>(GITLAB_WEB_RPC_METHODS.listLabels, args), + listAssignableUsers: () => Promise.resolve([]), + todos: (args) => route>(GITLAB_WEB_RPC_METHODS.todos, args), + workItemDetails: (args) => + route>(GITLAB_WEB_RPC_METHODS.workItemDetails, args), + closeMR: (args) => + route>(GITLAB_WEB_RPC_METHODS.closeMR, { + ...args, + state: 'closed' + }), + reopenMR: (args) => + route>(GITLAB_WEB_RPC_METHODS.reopenMR, { + ...args, + state: 'opened' + }), + mergeMR: (args) => route>(GITLAB_WEB_RPC_METHODS.mergeMR, args), + updateMR: (args) => route>(GITLAB_WEB_RPC_METHODS.updateMR, args), + updateMRReviewers: (args) => + route>(GITLAB_WEB_RPC_METHODS.updateMRReviewers, args), + addMRComment: (args) => + route>(GITLAB_WEB_RPC_METHODS.addMRComment, args), + addMRInlineComment: (args) => + route>(GITLAB_WEB_RPC_METHODS.addMRInlineComment, args), + resolveMRDiscussion: (args) => + route>( + GITLAB_WEB_RPC_METHODS.resolveMRDiscussion, + args + ), + jobTrace: (args) => route>(GITLAB_WEB_RPC_METHODS.jobTrace, args), + retryJob: (args) => route>(GITLAB_WEB_RPC_METHODS.retryJob, args), + workItemByPath: (args) => + route>(GITLAB_WEB_RPC_METHODS.workItemByPath, args) + } satisfies WebGitLabApi + + return gitLabApi +} diff --git a/src/renderer/src/web/preload-api/web-gitlab-routes.ts b/src/renderer/src/web/preload-api/web-gitlab-routes.ts new file mode 100644 index 00000000000..e99f0780015 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-gitlab-routes.ts @@ -0,0 +1,71 @@ +export type WebGitLabRouteKey = + | 'diagnoseAuth' + | 'rateLimit' + | 'listMRs' + | 'listWorkItems' + | 'listIssues' + | 'createIssue' + | 'updateIssue' + | 'addIssueComment' + | 'listLabels' + | 'todos' + | 'workItemDetails' + | 'closeMR' + | 'reopenMR' + | 'mergeMR' + | 'updateMR' + | 'updateMRReviewers' + | 'addMRComment' + | 'addMRInlineComment' + | 'resolveMRDiscussion' + | 'jobTrace' + | 'retryJob' + | 'workItemByPath' + +export type WebGitLabRuntimeMethod = + | 'gitlab.diagnoseAuth' + | 'gitlab.rateLimit' + | 'gitlab.listMRs' + | 'gitlab.listWorkItems' + | 'gitlab.listIssues' + | 'gitlab.createIssue' + | 'gitlab.updateIssue' + | 'gitlab.addIssueComment' + | 'gitlab.listLabels' + | 'gitlab.todos' + | 'gitlab.workItemDetails' + | 'gitlab.updateMRState' + | 'gitlab.mergeMR' + | 'gitlab.updateMR' + | 'gitlab.updateMRReviewers' + | 'gitlab.addMRComment' + | 'gitlab.addMRInlineComment' + | 'gitlab.resolveMRDiscussion' + | 'gitlab.jobTrace' + | 'gitlab.retryJob' + | 'gitlab.workItemByPath' + +export const GITLAB_WEB_RPC_METHODS = { + diagnoseAuth: 'gitlab.diagnoseAuth', + rateLimit: 'gitlab.rateLimit', + listMRs: 'gitlab.listMRs', + listWorkItems: 'gitlab.listWorkItems', + listIssues: 'gitlab.listIssues', + createIssue: 'gitlab.createIssue', + updateIssue: 'gitlab.updateIssue', + addIssueComment: 'gitlab.addIssueComment', + listLabels: 'gitlab.listLabels', + todos: 'gitlab.todos', + workItemDetails: 'gitlab.workItemDetails', + closeMR: 'gitlab.updateMRState', + reopenMR: 'gitlab.updateMRState', + mergeMR: 'gitlab.mergeMR', + updateMR: 'gitlab.updateMR', + updateMRReviewers: 'gitlab.updateMRReviewers', + addMRComment: 'gitlab.addMRComment', + addMRInlineComment: 'gitlab.addMRInlineComment', + resolveMRDiscussion: 'gitlab.resolveMRDiscussion', + jobTrace: 'gitlab.jobTrace', + retryJob: 'gitlab.retryJob', + workItemByPath: 'gitlab.workItemByPath' +} as const satisfies Record diff --git a/src/renderer/src/web/preload-api/web-host-capability-api.ts b/src/renderer/src/web/preload-api/web-host-capability-api.ts new file mode 100644 index 00000000000..2b2849baaf6 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-host-capability-api.ts @@ -0,0 +1,195 @@ +import type { + PreflightStatus, + PreloadApi, + RefreshAgentsResult +} from '../../../../preload/api-types' +import type { + ComputerUsePermissionSetupResult, + ComputerUsePermissionStatusResult +} from '../../../../shared/computer-use-permissions-types' +import type { SkillFreshnessInventory } from '../../../../shared/skill-freshness' +import type { SkillDiscoveryResult } from '../../../../shared/skills' +import { callRuntimeResult } from './web-runtime-calls' +import { requireActiveEnvironmentOrNull } from './web-runtime-session' +import { getBrowserPlatform } from './web-storage' + +export function createPreflightApi(): NonNullable['preflight']> { + const fallbackStatus: PreflightStatus = { + git: { installed: false }, + gh: { installed: false, authenticated: false }, + glab: { installed: false, authenticated: false }, + bitbucket: { configured: false, authenticated: false, account: null }, + azureDevOps: { + configured: false, + authenticated: false, + account: null, + baseUrl: null, + tokenConfigured: false + }, + gitea: { + configured: false, + authenticated: false, + account: null, + baseUrl: null, + tokenConfigured: false + } + } + const fallbackRefreshAgents: RefreshAgentsResult = { + agents: [], + addedPathSegments: [], + shellHydrationOk: false, + pathSource: 'sync_seed_only', + pathFailureReason: 'spawn_error' + } + type WindowsTerminalCapabilityBridgeResult = { + wslAvailable: boolean + wslDistros: string[] + pwshAvailable: boolean + gitBashAvailable: boolean + hostPlatform: NodeJS.Platform | null + } + const fallbackWindowsTerminalCapabilities = { + wslAvailable: false, + wslDistros: [], + pwshAvailable: false, + gitBashAvailable: false, + hostPlatform: null + } + return { + check: async (args) => { + if (!requireActiveEnvironmentOrNull()) { + return fallbackStatus + } + return callRuntimeResult('preflight.check', args) + }, + detectAgents: async () => { + if (!requireActiveEnvironmentOrNull()) { + return [] + } + return callRuntimeResult('preflight.detectAgents').catch(() => []) + }, + refreshAgents: () => + requireActiveEnvironmentOrNull() + ? callRuntimeResult('preflight.refreshAgents') + .then((result) => result as RefreshAgentsResult) + .catch(() => fallbackRefreshAgents) + : Promise.resolve(fallbackRefreshAgents), + detectRemoteAgents: async (args) => + requireActiveEnvironmentOrNull() + ? callRuntimeResult('preflight.detectRemoteAgents', args).catch(() => []) + : [], + detectRemoteWindowsTerminalCapabilities: async (args) => + requireActiveEnvironmentOrNull() + ? callRuntimeResult( + 'preflight.detectRemoteWindowsTerminalCapabilities', + args + ).catch(() => fallbackWindowsTerminalCapabilities) + : Promise.resolve(fallbackWindowsTerminalCapabilities) + } +} + +export function createDeveloperPermissionsApi(): NonNullable< + Partial['developerPermissions'] +> { + return { + getStatus: () => Promise.resolve([]), + request: ({ id }) => + Promise.resolve({ id, status: 'unsupported', openedSystemSettings: false } as const), + openSettings: () => Promise.resolve(), + testLocalNetworkConnection: ({ host, port }) => + Promise.resolve({ + ok: false, + host, + port, + testedAt: Date.now(), + failure: 'unsupported' + } as const) + } +} + +export function createComputerUsePermissionsApi(): NonNullable< + Partial['computerUsePermissions'] +> { + return { + getStatus: () => + callRuntimeResult( + 'computer.permissionsStatus', + {}, + 15_000 + ), + openSetup: (args) => + callRuntimeResult( + 'computer.permissions', + args ?? {}, + 15_000 + ).catch(() => ({ + platform: getBrowserPlatform(), + helperAppPath: null, + openedSettings: false, + launchedHelper: false, + nextStep: 'Computer-use permissions are managed on the Orca server.' + })), + reset: () => + Promise.resolve({ + platform: getBrowserPlatform(), + helperAppPath: null, + helperUnavailableReason: 'web_client', + bundleId: null, + permissions: [] + }) + } +} + +export function createSkillsApi(): NonNullable['skills']> { + return { + discover: (target) => + callRuntimeResult('skills.discover', target, 15_000), + // Why: browser clients have no local skill homes; remote-host freshness stays off until its update rail covers it. + freshnessInventory: (): Promise => + Promise.resolve({ + schemaVersion: 1, + installations: [], + eligibleUpdateNames: [], + scanIssues: [], + scannedAt: Date.now() + }), + // Why: with no local skill homes there is nothing to update, so the run rail + // reports a permanently idle state rather than spawning anything. + startUpdateRun: () => Promise.resolve({ started: false as const, reason: 'invalid-names' }), + cancelUpdateRun: () => Promise.resolve(), + acknowledgeUpdateRun: () => Promise.resolve(), + getUpdateRun: () => Promise.resolve({ state: 'idle' as const }), + prepareShare: () => Promise.reject(new Error('Skill publishing requires the desktop app.')), + publishShare: () => Promise.reject(new Error('Skill publishing requires the desktop app.')), + cancelShare: () => Promise.resolve(), + releaseShare: () => Promise.resolve(), + resolveShare: () => Promise.reject(new Error('Skill share links require the desktop app.')), + installShare: () => Promise.reject(new Error('Skill installation requires the desktop app.')), + installBundleShare: () => + Promise.reject(new Error('Skill installation requires the desktop app.')), + installPackageVersion: () => + Promise.reject(new Error('Skill installation requires the desktop app.')), + installBundlePackageVersion: () => + Promise.reject(new Error('Skill installation requires the desktop app.')), + cancelInstall: () => Promise.resolve({ cancelled: false }), + previewInstall: () => Promise.reject(new Error('Skill installation requires the desktop app.')), + previewBundleInstall: () => + Promise.reject(new Error('Skill installation requires the desktop app.')), + removeInstall: () => Promise.reject(new Error('Skill installation requires the desktop app.')), + listManagedInstalls: () => + Promise.reject(new Error('Skill installation requires the desktop app.')), + getPackage: () => Promise.reject(new Error('Skill installation requires the desktop app.')), + listOwnedShares: () => + Promise.reject(new Error('Skill package management requires the desktop app.')), + revokeShare: () => + Promise.reject(new Error('Skill package management requires the desktop app.')), + deletePackageVersion: () => + Promise.reject(new Error('Skill package management requires the desktop app.')), + deletePackage: () => + Promise.reject(new Error('Skill package management requires the desktop app.')), + listWslDistros: () => Promise.resolve([]), + onInstallProgress: () => () => {}, + onShareProgress: () => () => {}, + onUpdateRun: () => () => {} + } +} diff --git a/src/renderer/src/web/preload-api/web-keybinding-normalization.ts b/src/renderer/src/web/preload-api/web-keybinding-normalization.ts new file mode 100644 index 00000000000..c795bbfa1b1 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-keybinding-normalization.ts @@ -0,0 +1,174 @@ +import { + findKeybindingConflicts, + isKeybindingActionId, + normalizeKeybindingArrayForAction +} from '../../../../shared/keybindings' +import type { + KeybindingActionId, + KeybindingFileDiagnostic, + KeybindingOverrides, + KeybindingPlatform +} from '../../../../shared/keybindings' +import { translate } from '@/i18n/i18n' + +export type WebKeybindingDocument = { + version: 1 + keybindings: KeybindingOverrides + platforms: Partial> +} + +export const WEB_KEYBINDING_PLATFORMS: readonly KeybindingPlatform[] = ['darwin', 'linux', 'win32'] + +export function isJsonObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +export function normalizeStoredWebOverrides( + value: unknown, + section: string, + diagnostics: KeybindingFileDiagnostic[] +): KeybindingOverrides { + if (value === undefined) { + return {} + } + if (!isJsonObject(value)) { + diagnostics.push({ + severity: 'error', + section, + message: translate('auto.web.web.preload.api.d2e43e426a', '{{value0}} must be an object.', { + value0: section + }) + }) + return {} + } + + const overrides: KeybindingOverrides = {} + for (const [actionId, rawBindings] of Object.entries(value)) { + if (!isKeybindingActionId(actionId)) { + diagnostics.push({ + severity: 'warning', + section, + actionId, + message: translate( + 'auto.web.web.preload.api.36761d9604', + 'Unknown keybinding action "{{value0}}" was ignored.', + { value0: actionId } + ) + }) + continue + } + if ( + !Array.isArray(rawBindings) || + !rawBindings.every((binding) => typeof binding === 'string') + ) { + diagnostics.push({ + severity: 'error', + section, + actionId, + message: translate( + 'auto.web.web.preload.api.10898045f3', + 'Shortcut for "{{value0}}" was ignored: Use a string array.', + { value0: actionId } + ) + }) + continue + } + const normalized = normalizeKeybindingArrayForAction(actionId, rawBindings) + if (!Array.isArray(normalized)) { + const error = normalized.ok ? 'Unable to parse shortcut.' : normalized.error + diagnostics.push({ + severity: 'error', + section, + actionId, + message: translate( + 'auto.web.web.preload.api.76122208ca', + 'Shortcut for "{{value0}}" was ignored: {{value1}}', + { value0: actionId, value1: error } + ) + }) + continue + } + overrides[actionId] = normalized + } + return overrides +} + +export function normalizeWebPlatformOverrides( + value: unknown, + diagnostics: KeybindingFileDiagnostic[] +): Partial> { + if (value === undefined) { + return {} + } + if (!isJsonObject(value)) { + diagnostics.push({ + severity: 'error', + section: 'platforms', + message: translate( + 'auto.web.web.preload.api.0a69fcd8bc', + 'platforms must be an object with darwin, linux, or win32 sections.' + ) + }) + return {} + } + + const result: Partial> = {} + for (const [platform, overrides] of Object.entries(value)) { + if (!WEB_KEYBINDING_PLATFORMS.includes(platform as KeybindingPlatform)) { + diagnostics.push({ + severity: 'warning', + section: `platforms.${platform}`, + message: translate( + 'auto.web.web.preload.api.32f15bdb0f', + 'Unknown platform "{{value0}}" was ignored.', + { value0: platform } + ) + }) + continue + } + result[platform as KeybindingPlatform] = normalizeStoredWebOverrides( + overrides, + `platforms.${platform}`, + diagnostics + ) + } + return result +} + +export function removeConflictingWebOverrides( + platform: KeybindingPlatform, + overrides: KeybindingOverrides, + diagnostics: KeybindingFileDiagnostic[] +): KeybindingOverrides { + let next = { ...overrides } + for (let attempt = 0; attempt < 20; attempt++) { + const conflicts = findKeybindingConflicts(platform, next) + const conflictingOverrides = new Set() + for (const conflict of conflicts) { + for (const actionId of conflict.actionIds) { + if (Object.hasOwn(next, actionId)) { + conflictingOverrides.add(actionId) + } + } + } + if (conflictingOverrides.size === 0) { + return next + } + for (const actionId of conflictingOverrides) { + delete next[actionId] + } + diagnostics.push({ + severity: 'error', + message: translate( + 'auto.web.web.preload.api.52bee9d8a0', + 'Conflicting custom shortcuts were ignored: {{value0}}.', + { + value0: Array.from(conflictingOverrides) + .map((actionId) => actionId) + .join(', ') + } + ) + }) + } + return next +} diff --git a/src/renderer/src/web/preload-api/web-keybindings-api.ts b/src/renderer/src/web/preload-api/web-keybindings-api.ts new file mode 100644 index 00000000000..1929d536b9d --- /dev/null +++ b/src/renderer/src/web/preload-api/web-keybindings-api.ts @@ -0,0 +1,174 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { + findKeybindingConflicts, + formatKeybindingList, + getKeybindingPlatform, + isKeybindingActionId, + normalizeKeybindingArrayForAction +} from '../../../../shared/keybindings' +import type { + KeybindingActionId, + KeybindingFileDiagnostic, + KeybindingFileSnapshot, + KeybindingOverrides, + KeybindingPlatform +} from '../../../../shared/keybindings' +import { + isJsonObject, + normalizeStoredWebOverrides, + normalizeWebPlatformOverrides, + removeConflictingWebOverrides +} from './web-keybinding-normalization' +import type { WebKeybindingDocument } from './web-keybinding-normalization' +import { KEYBINDINGS_STORAGE_KEY, getBrowserPlatform, readJson, writeJson } from './web-storage' + +export type WebKeybindingsApi = NonNullable + +export const webKeybindingListeners = new Set<(snapshot: KeybindingFileSnapshot) => void>() + +export function createEmptyWebKeybindingDocument(): WebKeybindingDocument { + return { + version: 1, + keybindings: {}, + platforms: { + darwin: {}, + linux: {}, + win32: {} + } + } +} + +export function getWebKeybindingPlatform(): KeybindingPlatform { + return getKeybindingPlatform(getBrowserPlatform()) +} + +export function readWebKeybindingDocument(): WebKeybindingDocument { + const document = readJson(KEYBINDINGS_STORAGE_KEY, createEmptyWebKeybindingDocument()) + return { + version: 1, + keybindings: isJsonObject(document.keybindings) + ? (document.keybindings as KeybindingOverrides) + : {}, + platforms: isJsonObject(document.platforms) + ? (document.platforms as Partial>) + : {} + } +} + +export function getWebKeybindingSnapshot(): KeybindingFileSnapshot { + const platform = getWebKeybindingPlatform() + const diagnostics: KeybindingFileDiagnostic[] = [] + const document = readWebKeybindingDocument() + const commonOverrides = normalizeStoredWebOverrides( + document.keybindings, + 'keybindings', + diagnostics + ) + const platformOverrides = normalizeWebPlatformOverrides(document.platforms, diagnostics) + const overrides = removeConflictingWebOverrides( + platform, + { + ...commonOverrides, + ...platformOverrides[platform] + }, + diagnostics + ) + + return { + path: 'Browser local storage', + platform, + exists: window.localStorage.getItem(KEYBINDINGS_STORAGE_KEY) !== null, + overrides, + commonOverrides, + platformOverrides, + diagnostics + } +} + +export function writeWebKeybindingAction( + actionId: KeybindingActionId, + bindings: string[] | null +): KeybindingFileSnapshot { + if (!isKeybindingActionId(actionId)) { + throw new Error(`Unknown keybinding action "${String(actionId)}".`) + } + const normalizedBindings = + bindings === null ? null : normalizeKeybindingArrayForAction(actionId, bindings) + if (normalizedBindings !== null && !Array.isArray(normalizedBindings)) { + throw new Error(normalizedBindings.ok ? 'Unable to parse shortcut.' : normalizedBindings.error) + } + + const platform = getWebKeybindingPlatform() + const currentSnapshot = getWebKeybindingSnapshot() + const candidateOverrides = { ...currentSnapshot.overrides } + if (normalizedBindings === null) { + delete candidateOverrides[actionId] + } else { + candidateOverrides[actionId] = normalizedBindings + } + const blockingConflict = findKeybindingConflicts(platform, candidateOverrides).find((conflict) => + conflict.actionIds.includes(actionId) + ) + if (blockingConflict) { + throw new Error( + `${formatKeybindingList([blockingConflict.binding], platform)} conflicts with another shortcut.` + ) + } + + const activePlatform: KeybindingOverrides = { ...currentSnapshot.platformOverrides[platform] } + if (normalizedBindings === null) { + delete activePlatform[actionId] + } else { + activePlatform[actionId] = normalizedBindings + } + + writeJson(KEYBINDINGS_STORAGE_KEY, { + version: 1, + keybindings: currentSnapshot.commonOverrides, + platforms: { + ...currentSnapshot.platformOverrides, + darwin: currentSnapshot.platformOverrides.darwin ?? {}, + linux: currentSnapshot.platformOverrides.linux ?? {}, + win32: currentSnapshot.platformOverrides.win32 ?? {}, + [platform]: activePlatform + } + } satisfies WebKeybindingDocument) + + const snapshot = getWebKeybindingSnapshot() + notifyWebKeybindingListeners(snapshot) + return snapshot +} + +export function notifyWebKeybindingListeners(snapshot: KeybindingFileSnapshot): void { + for (const listener of webKeybindingListeners) { + listener(snapshot) + } +} + +export function createWebKeybindingsApi(): WebKeybindingsApi { + return { + get: () => Promise.resolve(getWebKeybindingSnapshot()), + ensureFile: () => Promise.resolve(getWebKeybindingSnapshot()), + setAction: async ({ actionId, bindings }) => writeWebKeybindingAction(actionId, bindings), + reload: () => { + const snapshot = getWebKeybindingSnapshot() + notifyWebKeybindingListeners(snapshot) + return Promise.resolve(snapshot) + }, + openFile: () => Promise.resolve(getWebKeybindingSnapshot()), + revealFile: () => Promise.resolve(getWebKeybindingSnapshot()), + onChanged: (callback) => { + webKeybindingListeners.add(callback) + const onStorage = (event: StorageEvent): void => { + if (event.key === KEYBINDINGS_STORAGE_KEY) { + callback(getWebKeybindingSnapshot()) + } + } + window.addEventListener('storage', onStorage) + return () => { + webKeybindingListeners.delete(callback) + window.removeEventListener('storage', onStorage) + } + } + } +} diff --git a/src/renderer/src/web/preload-api/web-macos-tcc-api.ts b/src/renderer/src/web/preload-api/web-macos-tcc-api.ts new file mode 100644 index 00000000000..899545822ee --- /dev/null +++ b/src/renderer/src/web/preload-api/web-macos-tcc-api.ts @@ -0,0 +1,13 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { noopUnsubscribe } from './web-storage' + +export function createMacosTccPromptsApi(): NonNullable['macosTccPrompts']> { + // Why: TCC is a macOS-desktop concept; the web client has no log stream to watch. + return { + onThreshold: () => noopUnsubscribe, + consumePending: () => Promise.resolve(null), + acknowledgePending: () => Promise.resolve(), + releasePending: () => Promise.resolve(), + dismiss: () => Promise.resolve() + } +} diff --git a/src/renderer/src/web/preload-api/web-memory-api.ts b/src/renderer/src/web/preload-api/web-memory-api.ts new file mode 100644 index 00000000000..a99fc7ccfa7 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-memory-api.ts @@ -0,0 +1,24 @@ +import type { MemorySnapshot } from '../../../../shared/process-stats-types' +import { getBrowserPlatform } from './web-storage' + +export function createEmptyMemorySnapshot(): MemorySnapshot { + const emptyUsage = { cpu: 0, memory: 0 } + return { + app: { ...emptyUsage, main: emptyUsage, renderer: emptyUsage, other: emptyUsage, history: [] }, + worktrees: [], + host: { + totalMemory: 0, + freeMemory: 0, + availableMemory: 0, + availableMemorySource: 'free-memory', + usedMemory: 0, + memoryUsagePercent: 0, + cpuCoreCount: navigator.hardwareConcurrency || 1, + loadAverage1m: 0 + }, + processMemoryMetric: getBrowserPlatform() === 'win32' ? 'working-set' : 'rss', + totalCpu: 0, + totalMemory: 0, + collectedAt: Date.now() + } +} diff --git a/src/renderer/src/web/preload-api/web-mobile-api.ts b/src/renderer/src/web/preload-api/web-mobile-api.ts new file mode 100644 index 00000000000..6a631c743dd --- /dev/null +++ b/src/renderer/src/web/preload-api/web-mobile-api.ts @@ -0,0 +1,26 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { webRuntimeState } from './web-runtime-session' +import { noopUnsubscribe } from './web-storage' + +export function createWebMobileApi(): Partial { + return { + mobile: { + listNetworkInterfaces: () => Promise.resolve({ interfaces: [] }), + getPairingQR: () => Promise.resolve({ available: false }), + getWindowsFirewallStatus: () => Promise.resolve({ supported: false }), + repairWindowsFirewall: () => Promise.resolve({ ok: false, reason: 'unsupported' }), + openWindowsNetworkSettings: () => Promise.resolve(false), + getRuntimePairingUrl: () => Promise.resolve({ available: false }), + listDevices: () => Promise.resolve({ devices: [] }), + revokeDevice: () => Promise.resolve({ revoked: false }), + listRuntimeAccessGrants: () => Promise.resolve({ grants: [] }), + revokeRuntimeAccess: () => Promise.resolve({ revoked: false }), + isWebSocketReady: () => + Promise.resolve({ ready: Boolean(webRuntimeState.activeEnvironment), endpoint: null }), + getRelayStatus: () => Promise.resolve({ status: 'offline' as const }), + onRelayStatusChanged: () => noopUnsubscribe, + consumePendingUnpairedDeviceAuthFailure: () => Promise.resolve(false), + onUnpairedDeviceAuthFailure: () => noopUnsubscribe + } + } +} diff --git a/src/renderer/src/web/preload-api/web-native-chat-api.ts b/src/renderer/src/web/preload-api/web-native-chat-api.ts new file mode 100644 index 00000000000..31eec6d92fa --- /dev/null +++ b/src/renderer/src/web/preload-api/web-native-chat-api.ts @@ -0,0 +1,156 @@ +import type { NativeChatApi, NativeChatAppendedMessages } from '../../../../preload/api-types' +import { buildNativeChatUnsubscribe } from '../../../../shared/native-chat-stream-unsubscribe' +import { + parseRuntimeNativeChatReadSessionResult, + parseRuntimeNativeChatTurnLifecycle +} from '@/components/native-chat/native-chat-runtime-contract' +import { translate } from '@/i18n/i18n' +import { callRuntimeResult } from './web-runtime-calls' +import { getClientForEnvironment, requireActiveEnvironmentOrNull } from './web-runtime-session' + +export function createWebNativeChatApi(): NativeChatApi { + return { + readSession: async (agent, sessionId, limit, transcriptPath) => + parseRuntimeNativeChatReadSessionResult( + await callRuntimeResult('nativeChat.readSession', { + agent, + sessionId, + limit, + transcriptPath + }) + ), + subscribe: (args, onFrame) => { + // No paired runtime yet: return a no-op teardown so the chat view mounts cleanly; only the not-paired case is swallowed. + const environment = requireActiveEnvironmentOrNull() + if (!environment) { + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + error: translate( + 'components.native-chat.state.pairHost', + 'Pair a host to view agent chat history.' + ) + }) + return () => {} + } + let handle: { unsubscribe: () => void } | null = null + let cancelled = false + let receivedInitial = false + void getClientForEnvironment(environment) + .subscribe( + 'nativeChat.subscribe', + { + agent: args.agent, + sessionId: args.sessionId, + subscriptionId: args.subscriptionId, + transcriptPath: args.transcriptPath, + limit: args.limit + }, + { + onResponse: (response) => { + if (cancelled) { + return + } + if (!response.ok) { + if (!receivedInitial) { + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + error: response.error.message + }) + } + return + } + const result = response.result as { + type?: string + messages?: NativeChatAppendedMessages + hasMore?: boolean + error?: string + lifecycle?: unknown + } + const lifecycle = parseRuntimeNativeChatTurnLifecycle(result?.lifecycle) + if ( + (result?.type === 'appended' || + result?.type === 'snapshot' || + result?.type === 'replacement') && + Array.isArray(result.messages) + ) { + if (!receivedInitial) { + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: result.messages, + hasMore: result.hasMore ?? result.messages.length >= (args.limit ?? 300), + ...(result.error ? { error: result.error } : {}), + ...(lifecycle ? { lifecycle } : {}) + }) + } else if (result.type === 'snapshot') { + onFrame({ + type: 'snapshot', + messages: result.messages, + hasMore: result.hasMore ?? false, + ...(result.error ? { error: result.error } : {}), + ...(lifecycle ? { lifecycle } : {}) + }) + } else { + onFrame( + result.type === 'replacement' + ? { + type: 'replacement', + messages: result.messages, + hasMore: result.hasMore ?? false, + ...(lifecycle ? { lifecycle } : {}) + } + : { + type: 'appended', + messages: result.messages, + ...(lifecycle ? { lifecycle } : {}) + } + ) + } + } else if (!receivedInitial) { + // Why: an unrecognized ok payload never flips receivedInitial, stranding the view on 'loading'; settle it empty instead. + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + ...(result?.error ? { error: result.error } : {}) + }) + } + } + }, + { + // Why: unsubscribe reaps the fs-watcher on view-toggle (leak fix); echo the pane token so two panes don't tear down each other's watcher. + buildUnsubscribe: () => + buildNativeChatUnsubscribe(args.agent, args.sessionId, args.subscriptionId) + } + ) + .then((h) => { + if (cancelled) { + h.unsubscribe() + } else { + handle = h + } + }) + .catch((err: unknown) => { + if (!cancelled && !receivedInitial) { + receivedInitial = true + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + error: err instanceof Error ? err.message : String(err) + }) + } + }) + return () => { + cancelled = true + handle?.unsubscribe() + } + } + } +} diff --git a/src/renderer/src/web/preload-api/web-notifications-api.ts b/src/renderer/src/web/preload-api/web-notifications-api.ts new file mode 100644 index 00000000000..813d694ba82 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-notifications-api.ts @@ -0,0 +1,14 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { getBrowserPlatform } from './web-storage' + +export function createNotificationsApi(): NonNullable['notifications']> { + return { + dispatch: () => Promise.resolve({ delivered: false, reason: 'not-supported' }), + dismiss: () => Promise.resolve({ dismissed: 0 }), + openSystemSettings: () => Promise.resolve(), + getPermissionStatus: () => + Promise.resolve({ supported: false, platform: getBrowserPlatform(), requested: false }), + probeDelivery: () => Promise.resolve({ state: 'unsupported' as const, authoritative: false }), + playSound: () => Promise.resolve({ played: false, reason: 'missing-path' }) + } +} diff --git a/src/renderer/src/web/preload-api/web-onboarding-api.ts b/src/renderer/src/web/preload-api/web-onboarding-api.ts new file mode 100644 index 00000000000..4fcf9593623 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-onboarding-api.ts @@ -0,0 +1,56 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { ONBOARDING_FLOW_VERSION, getDefaultOnboardingState } from '../../../../shared/constants' +import type { OnboardingState } from '../../../../shared/onboarding-state-types' +import { ONBOARDING_STORAGE_KEY, readJson, writeJson } from './web-storage' + +export function getStoredOnboarding(): OnboardingState { + const storedRaw = window.localStorage.getItem(ONBOARDING_STORAGE_KEY) + if (storedRaw) { + const stored = readJson(ONBOARDING_STORAGE_KEY, getDefaultOnboardingState()) + if (stored.checklist.dismissed) { + return stored + } + const closed = closeWebOnboarding(stored) + writeJson(ONBOARDING_STORAGE_KEY, closed) + return closed + } + const closed = closeWebOnboarding(getDefaultOnboardingState()) + // Why: paired clients already have an Orca server; skip desktop first-run onboarding that would probe browser-local tools. + writeJson(ONBOARDING_STORAGE_KEY, closed) + return closed +} + +export function closeWebOnboarding(base: OnboardingState): OnboardingState { + return { + ...base, + flowVersion: ONBOARDING_FLOW_VERSION, + closedAt: Date.now(), + outcome: 'dismissed', + checklist: { + ...base.checklist, + dismissed: true + } + } +} + +export function createWebOnboardingApi(): Partial { + return { + onboarding: { + get: () => Promise.resolve(getStoredOnboarding()), + update: async (updates) => { + const current = getStoredOnboarding() + const next: OnboardingState = { + ...current, + ...updates, + flowVersion: ONBOARDING_FLOW_VERSION, + checklist: { + ...current.checklist, + ...updates.checklist + } + } + writeJson(ONBOARDING_STORAGE_KEY, next) + return next + } + } + } +} diff --git a/src/renderer/src/web/preload-api/web-orca-profiles-api.ts b/src/renderer/src/web/preload-api/web-orca-profiles-api.ts new file mode 100644 index 00000000000..0b552bf15c8 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-orca-profiles-api.ts @@ -0,0 +1,70 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { + DEFAULT_LOCAL_ORCA_PROFILE_ID, + createDefaultLocalOrcaProfile +} from '../../../../shared/orca-profiles' + +export function createWebOrcaProfilesApi(): Partial { + const webOrcaProfileAuthStatus = () => + Promise.resolve({ + activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, + configured: false, + state: 'unconfigured' as const, + persistence: 'none' as const, + setupMessage: 'Orca Cloud sign-in is not available in the browser fallback.' + }) + return { + orcaProfiles: { + list: () => + Promise.resolve({ + activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, + profiles: [createDefaultLocalOrcaProfile(0)], + multiProfileUi: false + }), + authStatus: webOrcaProfileAuthStatus, + createLocal: () => + Promise.resolve({ + activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, + profiles: [createDefaultLocalOrcaProfile(0)], + profile: createDefaultLocalOrcaProfile(0) + }), + createCloudLinked: async () => ({ + status: 'unconfigured', + auth: await webOrcaProfileAuthStatus() + }), + switchProfile: () => Promise.resolve({ status: 'already-active' }), + transferProject: (args) => + Promise.resolve({ + status: 'duplicate-target', + sourceProfileId: args.sourceProfileId, + targetProfileId: args.targetProfileId, + sourceRepoId: args.repoId, + duplicateRepoId: args.repoId + }), + findProjectProfiles: async () => ({ projects: [] }), + connectCurrent: async () => ({ + status: 'unconfigured', + auth: await webOrcaProfileAuthStatus() + }), + refreshAuth: async () => ({ + status: 'unconfigured', + auth: await webOrcaProfileAuthStatus() + }), + signOutCurrent: async () => ({ + status: 'signed-out', + auth: await webOrcaProfileAuthStatus(), + activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, + profiles: [createDefaultLocalOrcaProfile(0)] + }), + selectOrg: async () => ({ + status: 'unconfigured', + auth: await webOrcaProfileAuthStatus() + }), + orgMembersList: async () => ({ status: 'unconfigured' }), + orgMemberInvite: async () => ({ status: 'unconfigured' }), + orgInviteRevoke: async () => ({ status: 'unconfigured' }), + orgMemberChangeRole: async () => ({ status: 'unconfigured' }), + orgMemberRemove: async () => ({ status: 'unconfigured' }) + } + } +} diff --git a/src/renderer/src/web/preload-api/web-platform-api.ts b/src/renderer/src/web/preload-api/web-platform-api.ts new file mode 100644 index 00000000000..22e88ff6671 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-platform-api.ts @@ -0,0 +1,16 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { getBrowserPlatform } from './web-storage' + +export function createWebPlatformApi(): Partial { + return { + platform: { + get: () => ({ + platform: getBrowserPlatform(), + osRelease: '', + arch: '', + shell: '', + displayServer: null + }) + } + } +} diff --git a/src/renderer/src/web/preload-api/web-preference-normalization.ts b/src/renderer/src/web/preload-api/web-preference-normalization.ts new file mode 100644 index 00000000000..e6dfc33e286 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-preference-normalization.ts @@ -0,0 +1,164 @@ +import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../../shared/auto-rename-branch-from-work-settings' +import { + getDefaultSettings, + normalizeAgentActivityDisplayMode, + normalizeWorktreeCardProperties +} from '../../../../shared/constants' +import { normalizeContextualTourIds } from '../../../../shared/contextual-tours' +import type { ContextualTourId } from '../../../../shared/contextual-tours' +import { normalizeFeatureInteractions } from '../../../../shared/feature-interactions' +import type { + FeatureInteractionId, + FeatureInteractionState +} from '../../../../shared/feature-interactions' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { PairedUiState, PairingLocalUiField } from '../../../../shared/pairing-local-ui-fields' +import type { PersistedUIState } from '../../../../shared/persisted-ui-state-types' +import { normalizeStatusBarUsageMode } from '../../../../shared/status-bar-usage-mode' +import { normalizeTerminalCustomThemes } from '../../../../shared/terminal-custom-themes' +import { + normalizeTuiAgentArgsRecord, + normalizeTuiAgentEnvRecord +} from '../../../../shared/tui-agent-launch-defaults' +import { normalizeDisabledTuiAgents } from '../../../../shared/tui-agent-selection' +import { normalizeUiLanguage } from '../../../../shared/ui-language' +import { normalizeUsagePercentageDisplay } from '../../../../shared/usage-percentage-display' +import { mergeWorkspaceCleanupUIState } from '../../../../shared/workspace-cleanup-ui-state' + +export function mergeWebUIState( + base: PersistedUIState, + updates: Partial +): PersistedUIState { + const { featureInteractionTelemetryBuckets: _reserved, ...safeUpdates } = + updates as Partial & { + featureInteractionTelemetryBuckets?: unknown + } + void _reserved + return { + ...base, + ...safeUpdates, + workspaceCleanup: mergeWorkspaceCleanupUIState( + base.workspaceCleanup, + safeUpdates.workspaceCleanup + ), + worktreeCardProperties: normalizeWorktreeCardProperties( + safeUpdates.worktreeCardProperties ?? base.worktreeCardProperties + ), + _worktreeCardModeDefaulted: + safeUpdates._worktreeCardModeDefaulted ?? base._worktreeCardModeDefaulted, + agentActivityDisplayMode: normalizeAgentActivityDisplayMode( + safeUpdates.agentActivityDisplayMode ?? base.agentActivityDisplayMode + ), + usagePercentageDisplay: normalizeUsagePercentageDisplay( + safeUpdates.usagePercentageDisplay ?? base.usagePercentageDisplay + ), + statusBarUsageMode: normalizeStatusBarUsageMode( + safeUpdates.statusBarUsageMode ?? base.statusBarUsageMode + ) + } +} + +export function mergeHostWebUIState( + local: PersistedUIState, + incoming: PairedUiState +): PersistedUIState { + // Why `satisfies Record<...>` rather than a `Pick<...>` annotation: every member is optional in + // PersistedUIState, so Pick would accept a literal that silently skipped a newly added member. + const pinned = { + hideWorkspacesFromOtherDevices: local.hideWorkspacesFromOtherDevices === true, + manualRepoOrder: local.manualRepoOrder, + workspaceHostOrder: local.workspaceHostOrder + } satisfies Record & Partial + return { ...mergeWebUIState(local, incoming), ...pinned } +} + +export function mergeFeatureInteractionState( + current: PersistedUIState['featureInteractions'], + incoming: PersistedUIState['featureInteractions'] +): FeatureInteractionState { + const currentNormalized = normalizeFeatureInteractions(current) + const incomingNormalized = normalizeFeatureInteractions(incoming) + const merged: FeatureInteractionState = { ...currentNormalized } + for (const [id, incomingRecord] of Object.entries(incomingNormalized)) { + const featureId = id as FeatureInteractionId + const currentRecord = currentNormalized[featureId] + merged[featureId] = currentRecord + ? { + firstInteractedAt: Math.min( + currentRecord.firstInteractedAt, + incomingRecord.firstInteractedAt + ), + interactionCount: Math.max( + currentRecord.interactionCount, + incomingRecord.interactionCount + ) + } + : incomingRecord + } + return merged +} + +export function mergeContextualTourSeenIds( + current: PersistedUIState['contextualToursSeenIds'], + incoming: PersistedUIState['contextualToursSeenIds'] +): ContextualTourId[] { + const merged = new Set(normalizeContextualTourIds(current)) + for (const id of normalizeContextualTourIds(incoming)) { + merged.add(id) + } + return [...merged] +} + +export function mergeOsc52ClipboardNoticePending( + current: PersistedUIState, + incoming: PersistedUIState +): boolean { + return ( + current.osc52ClipboardDefaultOnNoticePending === true || + incoming.osc52ClipboardDefaultOnNoticePending === true + ) +} + +export function mergeSettings( + base: GlobalSettings, + updates: Partial, + options: { preserveAutoRenameBranchFromWorkUpdate?: boolean } = {} +): GlobalSettings { + const defaults = getDefaultSettings('~') + const merged = { + ...base, + ...updates, + notifications: { + ...base.notifications, + ...updates.notifications + }, + githubProjects: { + ...(base.githubProjects ?? defaults.githubProjects), + ...updates.githubProjects + } as GlobalSettings['githubProjects'], + disabledTuiAgents: normalizeDisabledTuiAgents( + updates.disabledTuiAgents ?? base.disabledTuiAgents + ), + agentDefaultArgs: normalizeTuiAgentArgsRecord( + updates.agentDefaultArgs ?? base.agentDefaultArgs + ), + agentDefaultEnv: normalizeTuiAgentEnvRecord(updates.agentDefaultEnv ?? base.agentDefaultEnv), + voice: { + ...(base.voice ?? defaults.voice), + ...updates.voice + } as NonNullable, + activeRuntimeEnvironmentId: Object.hasOwn(updates, 'activeRuntimeEnvironmentId') + ? (updates.activeRuntimeEnvironmentId ?? null) + : (base.activeRuntimeEnvironmentId ?? null), + terminalCustomThemes: normalizeTerminalCustomThemes( + updates.terminalCustomThemes ?? base.terminalCustomThemes + ), + uiLanguage: normalizeUiLanguage(updates.uiLanguage ?? base.uiLanguage) + } + return { + ...merged, + ...normalizeAutoRenameBranchFromWorkDefaultOn(merged, { + preserveExplicitValue: options.preserveAutoRenameBranchFromWorkUpdate + }) + } +} diff --git a/src/renderer/src/web/preload-api/web-preferences-store.ts b/src/renderer/src/web/preload-api/web-preferences-store.ts new file mode 100644 index 00000000000..13c755b1d87 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-preferences-store.ts @@ -0,0 +1,297 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../../shared/auto-rename-branch-from-work-settings' +import { + getDefaultSettings, + getDefaultUIState, + getWorktreeCardModeProperties +} from '../../../../shared/constants' +import { normalizeWorktreeVisibilityDefaults } from '../../../../shared/external-worktree-visibility' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { + normalizeOsc52ClipboardDefaultOn, + osc52ClipboardDefaultOnOverridesPersistedOff +} from '../../../../shared/osc52-clipboard-settings' +import type { PersistedUIState } from '../../../../shared/persisted-ui-state-types' +import { + applyPRBotAuthorOverride, + normalizePRBotAuthorOverrides +} from '../../../../shared/pr-bot-author-overrides' +import { normalizeTerminalCursorStyleDefault } from '../../../../shared/terminal-cursor-style-settings' +import { normalizeTerminalCustomThemes } from '../../../../shared/terminal-custom-themes' +import { normalizeUiLanguage } from '../../../../shared/ui-language' +import { readStoredWebRuntimeEnvironment } from '../web-runtime-environment' +import { mergeSettings, mergeWebUIState } from './web-preference-normalization' +import { callRuntimeResult } from './web-runtime-calls' +import { requireActiveEnvironmentOrNull, webRuntimeState } from './web-runtime-session' +import { SETTINGS_STORAGE_KEY, UI_STORAGE_KEY, readJson, writeJson } from './web-storage' + +export type WebSettingsApi = NonNullable + +export function getStoredSettings(): GlobalSettings { + webRuntimeState.activeEnvironment = + webRuntimeState.activeEnvironment ?? readStoredWebRuntimeEnvironment() + const defaults = getDefaultSettings('~') + const rawStoredSettings = window.localStorage.getItem(SETTINGS_STORAGE_KEY) + const stored = readJson>(SETTINGS_STORAGE_KEY, {}) + const migratedStored = { + ...stored, + ...normalizeAutoRenameBranchFromWorkDefaultOn(stored), + ...normalizeTerminalCursorStyleDefault(stored), + ...normalizeOsc52ClipboardDefaultOn(stored), + terminalCustomThemes: normalizeTerminalCustomThemes(stored.terminalCustomThemes), + uiLanguage: normalizeUiLanguage(stored.uiLanguage) + } + if ( + rawStoredSettings && + (stored.autoRenameBranchFromWork !== migratedStored.autoRenameBranchFromWork || + stored.autoRenameBranchFromWorkDefaultedOn !== + migratedStored.autoRenameBranchFromWorkDefaultedOn || + stored.terminalCursorStyle !== migratedStored.terminalCursorStyle || + stored.terminalCursorStyleDefaultedToBlock !== + migratedStored.terminalCursorStyleDefaultedToBlock || + // Kept even though the terminalCustomThemes reference compare below already forces + // this branch for every stored blob: no migration should rely on that accident. + stored.terminalAllowOsc52Clipboard !== migratedStored.terminalAllowOsc52Clipboard || + stored.terminalAllowOsc52ClipboardDefaultedOnForAllUsers !== + migratedStored.terminalAllowOsc52ClipboardDefaultedOnForAllUsers || + stored.terminalCustomThemes !== migratedStored.terminalCustomThemes || + stored.uiLanguage !== migratedStored.uiLanguage) + ) { + try { + const parsed = JSON.parse(rawStoredSettings) as unknown + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + writeJson(SETTINGS_STORAGE_KEY, migratedStored) + if (osc52ClipboardDefaultOnOverridesPersistedOff(stored)) { + // Why a raw merge, not readLocalWebUIState(): that path calls back into + // getStoredSettings(), and writing through it here would recurse. + writeJson(UI_STORAGE_KEY, { + ...readJson>(UI_STORAGE_KEY, {}), + osc52ClipboardDefaultOnNoticePending: true + }) + } + } + } catch { + // Keep readJson's invalid-JSON fallback non-destructive. + } + } + return mergeSettings( + { + ...defaults, + floatingTerminalEnabled: false, + rightSidebarOpenByDefault: false, + activeRuntimeEnvironmentId: null + }, + migratedStored + ) +} + +export function writeStoredSettings( + settings: GlobalSettings, + explicitActiveRuntimeEnvironmentId?: string | null +): void { + const durable = { ...settings } + if (explicitActiveRuntimeEnvironmentId !== undefined) { + durable.activeRuntimeEnvironmentId = explicitActiveRuntimeEnvironmentId + } else { + const stored = readJson>(SETTINGS_STORAGE_KEY, {}) + if (Object.hasOwn(stored, 'activeRuntimeEnvironmentId')) { + durable.activeRuntimeEnvironmentId = stored.activeRuntimeEnvironmentId ?? null + } else { + delete durable.activeRuntimeEnvironmentId + } + } + writeJson(SETTINGS_STORAGE_KEY, durable) +} + +export async function getRuntimeBackedStoredSettings(): Promise { + const local = getStoredSettings() + const requestedEnvironment = requireActiveEnvironmentOrNull() + if (!requestedEnvironment) { + return local + } + try { + const result = await callRuntimeResult<{ settings: Partial }>( + 'settings.get', + undefined, + 15_000 + ) + const runtimeSettings: Partial = {} + const currentEnvironment = requireActiveEnvironmentOrNull() + if (currentEnvironment?.id === requestedEnvironment.id) { + const visibilityDefaults = normalizeWorktreeVisibilityDefaults( + result.settings.worktreeVisibilityDefaults + ) + webRuntimeState.worktreeVisibilityDefaultsRuntimeEnvironmentId = visibilityDefaults + ? requestedEnvironment.id + : null + webRuntimeState.worktreeVisibilityDefaultsRuntimeValue = visibilityDefaults ?? null + } + if (typeof result.settings.experimentalNewWorktreeCardStyle === 'boolean') { + runtimeSettings.experimentalNewWorktreeCardStyle = + result.settings.experimentalNewWorktreeCardStyle + } + if (typeof result.settings.compactWorktreeCards === 'boolean') { + runtimeSettings.compactWorktreeCards = result.settings.compactWorktreeCards + } + if (typeof result.settings.minimaxGroupId === 'string') { + runtimeSettings.minimaxGroupId = result.settings.minimaxGroupId + } + if (typeof result.settings.minimaxUsageModels === 'string') { + runtimeSettings.minimaxUsageModels = result.settings.minimaxUsageModels + } + if (Array.isArray(result.settings.prBotAuthorOverrides)) { + runtimeSettings.prBotAuthorOverrides = normalizePRBotAuthorOverrides( + result.settings.prBotAuthorOverrides + ) + } + // Read-only mirror: the host owns this capability and `syncRuntimeBackedSettings` never + // sends it back, so web shows what the host enforces instead of a local value it ignores. + if (typeof result.settings.artifactSharingEnabled === 'boolean') { + runtimeSettings.artifactSharingEnabled = result.settings.artifactSharingEnabled + } + if (typeof result.settings.agentSkillSharingEnabled === 'boolean') { + runtimeSettings.agentSkillSharingEnabled = result.settings.agentSkillSharingEnabled + } + const next = mergeSettings(local, runtimeSettings) + writeStoredSettings(next) + return settingsForActiveVisibilityOwner(next) + } catch { + // Why: unpaired/offline web clients keep a local settings fallback. + return settingsForActiveVisibilityOwner(local) + } +} + +export function settingsForActiveVisibilityOwner(settings: GlobalSettings): GlobalSettings { + const environment = requireActiveEnvironmentOrNull() + if (!environment) { + return settings + } + if ( + environment.id === webRuntimeState.worktreeVisibilityDefaultsRuntimeEnvironmentId && + webRuntimeState.worktreeVisibilityDefaultsRuntimeValue + ) { + return { + ...settings, + worktreeVisibilityDefaults: webRuntimeState.worktreeVisibilityDefaultsRuntimeValue + } + } + const { worktreeVisibilityDefaults: _unsupported, ...supportedSettings } = settings + return supportedSettings as GlobalSettings +} + +export async function syncRuntimeBackedSettings( + updates: Partial, + localNext: GlobalSettings +): Promise { + const requestedEnvironment = requireActiveEnvironmentOrNull() + if (!requestedEnvironment) { + return localNext + } + const runtimeUpdates: Partial = {} + const visibilityDefaults = normalizeWorktreeVisibilityDefaults(updates.worktreeVisibilityDefaults) + if (visibilityDefaults) { + runtimeUpdates.worktreeVisibilityDefaults = visibilityDefaults + } + if (typeof updates.experimentalNewWorktreeCardStyle === 'boolean') { + runtimeUpdates.experimentalNewWorktreeCardStyle = updates.experimentalNewWorktreeCardStyle + } + if (typeof updates.compactWorktreeCards === 'boolean') { + runtimeUpdates.compactWorktreeCards = updates.compactWorktreeCards + } + if (typeof updates.minimaxGroupId === 'string') { + runtimeUpdates.minimaxGroupId = updates.minimaxGroupId + } + if (typeof updates.minimaxUsageModels === 'string') { + runtimeUpdates.minimaxUsageModels = updates.minimaxUsageModels + } + if (Array.isArray(updates.prBotAuthorOverrides)) { + runtimeUpdates.prBotAuthorOverrides = normalizePRBotAuthorOverrides( + updates.prBotAuthorOverrides + ) + } + if (Object.keys(runtimeUpdates).length === 0) { + return localNext + } + try { + const result = await callRuntimeResult<{ settings: Partial }>( + 'settings.update', + runtimeUpdates, + 15_000 + ) + const runtimeSettings = { ...result.settings } + delete runtimeSettings.activeRuntimeEnvironmentId + const updatedVisibilityDefaults = normalizeWorktreeVisibilityDefaults( + runtimeSettings.worktreeVisibilityDefaults + ) + if ( + requireActiveEnvironmentOrNull()?.id === requestedEnvironment.id && + updatedVisibilityDefaults + ) { + webRuntimeState.worktreeVisibilityDefaultsRuntimeEnvironmentId = requestedEnvironment.id + webRuntimeState.worktreeVisibilityDefaultsRuntimeValue = updatedVisibilityDefaults + } + delete runtimeSettings.worktreeVisibilityDefaults + const next = mergeSettings(localNext, runtimeSettings) + writeStoredSettings(next) + return next + } catch (error) { + if (visibilityDefaults) { + throw error + } + // Why: unpaired/offline web clients still need local settings persistence. + return localNext + } +} + +export async function updateRuntimePRBotAuthorOverride(args: { + author: string + isBot: boolean +}): Promise { + const local = getStoredSettings() + if (requireActiveEnvironmentOrNull()) { + // Why: don't report a successful mark the authoritative runtime failed to persist and will later overwrite. + const result = await callRuntimeResult<{ settings: Partial }>( + 'settings.updatePRBotAuthorOverride', + args, + 15_000 + ) + const next = mergeSettings(local, { + prBotAuthorOverrides: normalizePRBotAuthorOverrides(result.settings.prBotAuthorOverrides) + }) + writeStoredSettings(next) + return next + } + const next = mergeSettings(local, { + prBotAuthorOverrides: applyPRBotAuthorOverride( + local.prBotAuthorOverrides, + args.author, + args.isBot + ) + }) + writeStoredSettings(next) + return next +} + +export function readLocalWebUIState(): PersistedUIState { + const defaults = getDefaultUIState() + // Why settings first: getStoredSettings() runs the OSC 52 migration, which writes the + // notice arm into UI_STORAGE_KEY. Reading before it would snapshot a pre-arm state that + // every caller then writes back, erasing the arm the stamp can never raise again. + const storedSettings = getStoredSettings() + const stored = readJson>(UI_STORAGE_KEY, {}) + const base = { + ...defaults, + // Why: mirror the main-process missing-property seed from legacy card layout mode when runtime ui.get is unavailable. + worktreeCardProperties: getWorktreeCardModeProperties( + storedSettings.compactWorktreeCards ? 'Compact' : 'Default' + ) + } + if (typeof stored.rightSidebarOpen === 'boolean') { + return mergeWebUIState(base, stored) + } + return mergeWebUIState(base, { + ...stored, + // Why: web fallback lacks main-process normalization; migrate the retired setting only when local UI preference is absent. + rightSidebarOpen: storedSettings.rightSidebarOpenByDefault + }) +} diff --git a/src/renderer/src/web/preload-api/web-rate-limits-api.ts b/src/renderer/src/web/preload-api/web-rate-limits-api.ts new file mode 100644 index 00000000000..7587a838660 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-rate-limits-api.ts @@ -0,0 +1,36 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import type { RateLimitState } from '../../../../shared/rate-limit-types' +import { noopUnsubscribe } from './web-storage' + +export function createRateLimitsApi(): NonNullable['rateLimits']> { + const empty: RateLimitState = { + claude: null, + codex: null, + gemini: null, + opencodeGo: null, + kimi: null, + antigravity: null, + minimax: null, + grok: null, + minimaxCookieConfigured: false, + grokAuthConfigured: false, + claudeTarget: { runtime: 'host', wslDistro: null }, + codexTarget: { runtime: 'host', wslDistro: null }, + inactiveClaudeAccounts: [], + inactiveCodexAccounts: [] + } + return { + get: () => Promise.resolve(empty), + refresh: () => Promise.resolve(empty), + refreshCodexForTarget: () => Promise.resolve(empty), + // Why: web clients don't own local Codex auth; report the safe no-credit outcome since redemption is desktop-only. + consumeCodexResetCredit: () => Promise.resolve({ outcome: 'noCredit', state: empty }), + refreshClaudeForTarget: () => Promise.resolve(empty), + setPollingInterval: () => Promise.resolve(), + fetchInactiveClaudeAccounts: () => Promise.resolve(), + fetchInactiveCodexAccounts: () => Promise.resolve(), + refreshMiniMax: () => Promise.resolve(empty), + refreshGrok: () => Promise.resolve(empty), + onUpdate: () => noopUnsubscribe + } +} diff --git a/src/renderer/src/web/preload-api/web-repositories-api.ts b/src/renderer/src/web/preload-api/web-repositories-api.ts new file mode 100644 index 00000000000..cf3974ac04d --- /dev/null +++ b/src/renderer/src/web/preload-api/web-repositories-api.ts @@ -0,0 +1,130 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { legacyBaseRefSearchResult } from '../../../../shared/base-ref-search-result' +import type { Repo } from '../../../../shared/repo-types' +import { getDefaultCreateProjectParent } from '@/components/sidebar/create-project-defaults' +import { + callRuntimeResult, + callRuntimeResultWithOwner, + withRuntimeRepoMutationOwner, + withRuntimeRepoOwner +} from './web-runtime-calls' +import { assertActiveEnvironment, invalidateRuntimeWorktreeCaches } from './web-runtime-session' +import { noopUnsubscribe } from './web-storage' + +export function createReposApi(): NonNullable['repos']> { + return { + list: async () => { + const owned = await callRuntimeResultWithOwner<{ repos: Repo[] }>('repo.list') + return owned.result.repos.map((repo) => withRuntimeRepoOwner(repo, owned.hostId)) + }, + add: async ({ path, kind }) => { + invalidateRuntimeWorktreeCaches() + const owned = await callRuntimeResultWithOwner<{ repo: Repo } | { error: string }>( + 'repo.add', + { path, kind } + ) + return withRuntimeRepoMutationOwner(owned.result, owned.hostId) + }, + remove: async ({ repoId }) => { + await callRuntimeResult('repo.rm', { repo: repoId }) + invalidateRuntimeWorktreeCaches() + }, + // Why: host-scoped forget targets a desktop-owned SSH host; a paired web client has one runtime and no ghost-host state. + removeForHost: () => { + throw new Error('Forgetting a host is unavailable in paired web clients.') + }, + reorder: async ({ orderedIds }) => callRuntimeResult('repo.reorder', { orderedIds }), + // Why: this persists desktop-owned local/SSH rows; paired web clients own one runtime and use repo.reorder directly. + reorderForHost: async () => { + throw new Error('Host-scoped project reordering is unavailable in paired web clients.') + }, + update: async ({ repoId, updates }) => { + const owned = await callRuntimeResultWithOwner<{ repo: Repo }>('repo.update', { + repo: repoId, + updates + }) + return withRuntimeRepoOwner(owned.result.repo, owned.hostId) + }, + pickFolder: () => Promise.resolve(null), + pickFolders: () => Promise.resolve([]), + pickDirectory: () => Promise.resolve(null), + clone: async ({ url, destination }) => { + invalidateRuntimeWorktreeCaches() + const owned = await callRuntimeResultWithOwner<{ repo: Repo }>( + 'repo.clone', + { url, destination }, + 10 * 60_000 + ) + return withRuntimeRepoOwner(owned.result.repo, owned.hostId) + }, + cloneRemote: async () => { + // Why: SSH relay cloning is owned by the desktop main process; paired web clients can't run that local IPC path. + throw new Error('SSH clone is unavailable in paired web clients.') + }, + createRemote: async () => { + // Why: SSH relay project creation is owned by the desktop main process; paired web clients can't use local SSH IPC. + throw new Error('Creating projects on SSH hosts is unavailable in paired web clients.') + }, + cloneAbort: () => Promise.resolve(), + addRemote: async ({ remotePath, displayName, kind }) => { + invalidateRuntimeWorktreeCaches() + const owned = await callRuntimeResultWithOwner<{ repo: Repo }>('repo.add', { + path: remotePath, + kind + }) + const result = { + repo: withRuntimeRepoOwner(owned.result.repo, owned.hostId) + } + if (!displayName) { + return result + } + assertActiveEnvironment(owned.environmentId) + return { + repo: await createReposApi().update({ + repoId: result.repo.id, + updates: { displayName } + }) + } + }, + create: async ({ parentPath, name, kind }) => { + invalidateRuntimeWorktreeCaches() + const owned = await callRuntimeResultWithOwner<{ repo: Repo } | { error: string }>( + 'repo.create', + { parentPath, name, kind } + ) + return withRuntimeRepoMutationOwner(owned.result, owned.hostId) + }, + isGitAvailable: async () => + (await callRuntimeResult<{ available: boolean }>('repo.gitAvailable')).available, + getDefaultCreateProjectParent: async () => { + const result = await callRuntimeResult<{ resolvedPath: string }>('files.browseServerDir', { + path: '~' + }) + return getDefaultCreateProjectParent(result.resolvedPath) + }, + onCloneProgress: () => noopUnsubscribe, + getGitUsername: () => Promise.resolve(''), + getBaseRefDefault: async ({ repoId }) => + callRuntimeResult('repo.baseRefDefault', { repo: repoId }), + searchBaseRefs: async ({ repoId, query, limit }) => + ( + await callRuntimeResult<{ refs: string[] }>('repo.searchRefs', { + repo: repoId, + query, + limit + }) + ).refs, + searchBaseRefDetails: async ({ repoId, query, limit }) => { + const result = await callRuntimeResult<{ + refs: string[] + refDetails?: { refName: string; localBranchName: string }[] + }>('repo.searchRefs', { + repo: repoId, + query, + limit + }) + return result.refDetails ?? result.refs.map(legacyBaseRefSearchResult) + }, + onChanged: () => noopUnsubscribe + } +} diff --git a/src/renderer/src/web/preload-api/web-review-api.ts b/src/renderer/src/web/preload-api/web-review-api.ts new file mode 100644 index 00000000000..4c4541d48d1 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-review-api.ts @@ -0,0 +1,44 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { createFallbackProxy } from './web-fallback-api' +import { callRuntimeResult } from './web-runtime-calls' + +export function createRuntimeNamespaceApi(prefix: string): never { + return createFallbackProxy([prefix], (path, args) => { + const method = `${prefix}.${path.at(-1) ?? ''}` + return callRuntimeResult(method, mapRuntimeNamespaceArg(prefix, args[0])) + }) as never +} + +export function createHooksApi(): NonNullable['hooks']> { + return { + check: async ({ repoId }) => callRuntimeResult('repo.hooksCheck', { repo: repoId }), + inspectSetupScriptImports: async ({ repoId }) => + callRuntimeResult('repo.setupScriptImports', { repo: repoId }), + createIssueCommandRunner: async () => ({ launched: false }) as never, + readIssueCommand: async ({ repoId }) => + callRuntimeResult('repo.issueCommandRead', { repo: repoId }), + writeIssueCommand: async ({ repoId, content }) => { + await callRuntimeResult('repo.issueCommandWrite', { repo: repoId, content }) + } + } +} + +export function mapRepoPathArg(args: unknown): unknown { + if (!args || typeof args !== 'object' || !('repoPath' in args)) { + return args + } + const record = args as Record + const repoId = typeof record.repoId === 'string' && record.repoId.trim() ? record.repoId : null + return { + ...record, + // Why: duplicate checked-out repos make path/name selectors ambiguous; prefer the explicit repo id the renderer passes. + repo: repoId ? `id:${repoId}` : record.repoPath + } +} + +export function mapRuntimeNamespaceArg(prefix: string, args: unknown): unknown { + if (prefix !== 'hostedReview') { + return args + } + return mapRepoPathArg(args) +} diff --git a/src/renderer/src/web/preload-api/web-runtime-api.ts b/src/renderer/src/web/preload-api/web-runtime-api.ts new file mode 100644 index 00000000000..e0c44484768 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-runtime-api.ts @@ -0,0 +1,21 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import type { RuntimeSyncWindowGraph } from '../../../../shared/runtime-types' +import { callRuntimeEnvelope, getRemoteRuntimeStatus } from './web-runtime-calls' +import { noopUnsubscribe } from './web-storage' + +export function createWebRuntimeApi(): NonNullable['runtime']> { + return { + syncWindowGraph: async (_graph: RuntimeSyncWindowGraph) => getRemoteRuntimeStatus(), + getStatus: () => getRemoteRuntimeStatus(), + call: ({ method, params }) => callRuntimeEnvelope(method, params), + getTerminalFitOverrides: () => Promise.resolve([]), + getTerminalDrivers: () => Promise.resolve([]), + getBrowserDrivers: () => Promise.resolve([]), + restoreTerminalFit: () => Promise.resolve({ restored: false }), + reclaimBrowserForDesktop: () => Promise.resolve({ reclaimed: false }), + onTerminalFitOverrideChanged: () => noopUnsubscribe, + onTerminalDriverChanged: () => noopUnsubscribe, + onNativeChatLaunchDraftResolved: () => noopUnsubscribe, + onBrowserDriverChanged: () => noopUnsubscribe + } +} diff --git a/src/renderer/src/web/preload-api/web-runtime-calls.ts b/src/renderer/src/web/preload-api/web-runtime-calls.ts new file mode 100644 index 00000000000..4470b0be37a --- /dev/null +++ b/src/renderer/src/web/preload-api/web-runtime-calls.ts @@ -0,0 +1,120 @@ +import { parseExecutionHostId, toRuntimeExecutionHostId } from '../../../../shared/execution-host' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { Repo } from '../../../../shared/repo-types' +import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { Worktree } from '../../../../shared/worktree/types' +import { + getClientForEnvironment, + manuallyDisconnectedEnvironmentIds, + manuallyDisconnectedResponse, + requireActiveEnvironment, + resolveEnvironment, + runtimeCallQueuePool, + updateEnvironmentFromResponse +} from './web-runtime-session' + +export type WebRuntimeResultCaller = ( + method: string, + params?: unknown, + timeoutMs?: number +) => Promise + +export type WebRuntimeEnvelopeCaller = ( + method: string, + params?: unknown, + timeoutMs?: number +) => Promise> + +export async function callRuntimeEnvelope( + method: string, + params?: unknown, + timeoutMs?: number +): Promise> { + const environment = requireActiveEnvironment() + if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { + return manuallyDisconnectedResponse(environment) + } + const response = await runtimeCallQueuePool.enqueue(environment.id, method, () => { + if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { + return Promise.resolve(manuallyDisconnectedResponse(environment)) + } + return getClientForEnvironment(environment).call(method, params, { timeoutMs }) + }) + if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { + return manuallyDisconnectedResponse(environment) + } + updateEnvironmentFromResponse(environment, response) + return response as RuntimeRpcResponse +} + +export async function callEnvironmentEnvelope( + selector: string, + method: string, + params?: unknown, + timeoutMs?: number +): Promise> { + const environment = resolveEnvironment(selector) + if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { + return manuallyDisconnectedResponse(environment) + } + const response = await runtimeCallQueuePool.enqueue(environment.id, method, () => { + if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { + return Promise.resolve(manuallyDisconnectedResponse(environment)) + } + return getClientForEnvironment(environment).call(method, params, { timeoutMs }) + }) + if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { + return manuallyDisconnectedResponse(environment) + } + updateEnvironmentFromResponse(environment, response) + return response as RuntimeRpcResponse +} + +export async function callRuntimeResult( + method: string, + params?: unknown, + timeoutMs?: number +): Promise { + const response = await callRuntimeEnvelope(method, params, timeoutMs) + if (!response.ok) { + throw new Error(response.error.message) + } + return response.result as TResult +} + +export async function callRuntimeResultWithOwner( + method: string, + params?: unknown, + timeoutMs?: number +): Promise<{ result: TResult; hostId: ExecutionHostId; environmentId: string }> { + const environmentId = requireActiveEnvironment().id + const result = await callRuntimeResult(method, params, timeoutMs) + return { result, hostId: toRuntimeExecutionHostId(environmentId), environmentId } +} + +export function withRuntimeRepoOwner(repo: Repo, hostId: ExecutionHostId): Repo { + return { ...repo, executionHostId: hostId } +} + +export function withRuntimeRepoMutationOwner( + result: { repo: Repo } | { error: string }, + hostId: ExecutionHostId +): { repo: Repo } | { error: string } { + return 'repo' in result ? { ...result, repo: withRuntimeRepoOwner(result.repo, hostId) } : result +} + +export function withRuntimeWorktreeOwner( + worktree: T, + hostId: ExecutionHostId +): T { + const runtimeOwner = parseExecutionHostId(hostId) + if (runtimeOwner?.kind !== 'runtime') { + return worktree + } + return { ...worktree, runtimeOwnerEnvironmentId: runtimeOwner.environmentId } +} + +export async function getRemoteRuntimeStatus(): Promise { + return callRuntimeResult('status.get', undefined, 15_000) +} diff --git a/src/renderer/src/web/preload-api/web-runtime-environments-api.ts b/src/renderer/src/web/preload-api/web-runtime-environments-api.ts new file mode 100644 index 00000000000..378ce38c614 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-runtime-environments-api.ts @@ -0,0 +1,198 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { parseHostAccessLink } from '../../../../shared/remote-pairing-address' +import { verifyRemotePairingRuntimeStatus } from '../../../../shared/remote-pairing-verification' +import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import { parseWebPairingInput } from '../web-pairing' +import { WebRuntimeClient } from '../web-runtime-client' +import { isWebRuntimeUnauthorizedError } from '../web-runtime-client-error' +import { + createStoredWebRuntimeEnvironment, + redactStoredWebRuntimeEnvironment, + saveStoredWebRuntimeEnvironment +} from '../web-runtime-environment' +import { translate } from '@/i18n/i18n' +import { translateHostAccessLinkError } from '@/lib/remote-pairing-copy' +import { callEnvironmentEnvelope } from './web-runtime-calls' +import { + closeActiveRuntimeClients, + disconnectActiveRuntimeEnvironment, + getClientForEnvironment, + manuallyDisconnectedEnvironmentIds, + removeActiveRuntimeEnvironment, + requireActiveEnvironmentOrNull, + resolveEnvironment, + webRuntimeState +} from './web-runtime-session' + +export function createRuntimeEnvironmentsApi(): NonNullable< + Partial['runtimeEnvironments'] +> { + return { + list: async () => { + const environment = requireActiveEnvironmentOrNull() + return environment ? [redactStoredWebRuntimeEnvironment(environment)] : [] + }, + addFromPairingCode: async ({ name, pairingCode }) => { + const offer = parseWebPairingInput(pairingCode) + if (!offer) { + throw new Error('Invalid Orca pairing code.') + } + const previousEnvironment = webRuntimeState.activeEnvironment + closeActiveRuntimeClients() + webRuntimeState.activeEnvironment = createStoredWebRuntimeEnvironment({ + name, + offer, + previousEnvironment + }) + manuallyDisconnectedEnvironmentIds.clear() + saveStoredWebRuntimeEnvironment(webRuntimeState.activeEnvironment) + return { environment: redactStoredWebRuntimeEnvironment(webRuntimeState.activeEnvironment) } + }, + verifyAndAddFromPairingCode: async ({ name, pairingCode, allowLoopback }) => { + const parsed = parseHostAccessLink(pairingCode) + if (!parsed.ok) { + return { + ok: false, + kind: 'access-link-invalid', + message: translateHostAccessLinkError(parsed.kind) + } + } + if (parsed.value.endpointKind === 'loopback' && !allowLoopback) { + return { + ok: false, + kind: 'host-unreachable', + message: translate( + 'auto.web.webPreloadApi.loopbackPairingBlocked', + 'This access link points back to this device.' + ) + } + } + let client: WebRuntimeClient | null = null + let runtimeStatus: RuntimeStatus + try { + client = new WebRuntimeClient(parsed.value.pairing) + const response = (await client.call('status.get', undefined, { + timeoutMs: 15_000 + })) as RuntimeRpcResponse + if (!response.ok) { + return { + ok: false, + kind: 'connection-interrupted', + message: response.error.message + } + } + const statusVerification = verifyRemotePairingRuntimeStatus(response.result) + if (!statusVerification.ok) { + return statusVerification + } + runtimeStatus = statusVerification.runtimeStatus + } catch (error) { + if (error instanceof Error && error.message.startsWith('Invalid public key')) { + return { + ok: false, + kind: 'access-link-invalid', + message: translate( + 'auto.web.webPreloadApi.remotePairingInvalidDetails', + 'This access link contains invalid connection details.' + ) + } + } + if ( + isWebRuntimeUnauthorizedError(error) || + (error instanceof Error && error.message.startsWith('Unauthorized.')) + ) { + return { + ok: false, + kind: 'access-link-invalid', + message: error.message + } + } + return { + ok: false, + kind: 'host-unreachable', + message: translate( + 'auto.web.webPreloadApi.remotePairingUnreachable', + 'Cannot reach Orca at {{endpoint}}.', + { endpoint: parsed.value.displayEndpoint } + ) + } + } finally { + client?.close() + } + const usesSshTunnel = parsed.value.endpointKind === 'loopback' && allowLoopback === true + const nextEnvironment = { + ...createStoredWebRuntimeEnvironment({ + name, + offer: parsed.value.pairing, + previousEnvironment: webRuntimeState.activeEnvironment, + ...(usesSshTunnel ? { connectionDependency: 'ssh-tunnel' as const } : {}) + }), + ...(runtimeStatus.pairedDeviceId ? { pairedDeviceId: runtimeStatus.pairedDeviceId } : {}) + } + // Why: a browser storage failure must leave the currently active host usable. + try { + saveStoredWebRuntimeEnvironment(nextEnvironment) + } catch { + return { + ok: false, + kind: 'environment-save-failed', + message: translate( + 'auto.web.webPreloadApi.remotePairingSaveFailed', + 'Orca verified the host but could not save it. Check browser storage and try again.' + ) + } + } + manuallyDisconnectedEnvironmentIds.clear() + closeActiveRuntimeClients() + webRuntimeState.activeEnvironment = nextEnvironment + return { + ok: true, + environment: redactStoredWebRuntimeEnvironment(nextEnvironment), + runtimeStatus + } + }, + resolve: async ({ selector }) => + redactStoredWebRuntimeEnvironment(resolveEnvironment(selector)), + remove: async ({ selector }) => { + const environment = resolveEnvironment(selector) + if (webRuntimeState.activeEnvironment?.id === environment.id) { + removeActiveRuntimeEnvironment() + } + manuallyDisconnectedEnvironmentIds.delete(environment.id) + return { removed: redactStoredWebRuntimeEnvironment(environment) } + }, + disconnect: async ({ selector }) => { + const environment = resolveEnvironment(selector) + if (webRuntimeState.activeEnvironment?.id === environment.id) { + manuallyDisconnectedEnvironmentIds.add(environment.id) + disconnectActiveRuntimeEnvironment() + } + return { disconnected: redactStoredWebRuntimeEnvironment(environment) } + }, + connect: ({ selector, timeoutMs }) => { + const environment = resolveEnvironment(selector) + manuallyDisconnectedEnvironmentIds.delete(environment.id) + return callEnvironmentEnvelope( + environment.id, + 'status.get', + undefined, + timeoutMs + ) + }, + getStatus: ({ selector, timeoutMs }) => + callEnvironmentEnvelope(selector, 'status.get', undefined, timeoutMs), + call: ({ selector, method, params, timeoutMs }) => + callEnvironmentEnvelope(selector, method, params, timeoutMs), + subscribe: async ({ selector, method, params, timeoutMs }, callbacks) => { + const environment = resolveEnvironment(selector) + const client = getClientForEnvironment(environment) + const subscription = await client.subscribe(method, params, callbacks, { timeoutMs }) + if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { + subscription.unsubscribe() + throw new Error('runtime_manually_disconnected') + } + return subscription + } + } +} diff --git a/src/renderer/src/web/preload-api/web-runtime-session.ts b/src/renderer/src/web/preload-api/web-runtime-session.ts new file mode 100644 index 00000000000..17a19136002 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-runtime-session.ts @@ -0,0 +1,145 @@ +import type { WorktreeVisibilityDefaults } from '../../../../shared/global-settings-types' +import { RuntimeRpcCallQueuePool } from '../../../../shared/runtime-rpc-call-queue' +import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' +import type { Worktree } from '../../../../shared/worktree/types' +import { WebRuntimeClient } from '../web-runtime-client' +import { + clearStoredWebRuntimeEnvironment, + getPreferredWebPairingOffer, + readStoredWebRuntimeEnvironment, + updateStoredEnvironmentRuntimeId +} from '../web-runtime-environment' +import type { StoredWebRuntimeEnvironment } from '../web-runtime-environment' +import { translate } from '@/i18n/i18n' + +export const webRuntimeState: { + activeEnvironment: StoredWebRuntimeEnvironment | null + worktreeVisibilityDefaultsRuntimeEnvironmentId: string | null + worktreeVisibilityDefaultsRuntimeValue: WorktreeVisibilityDefaults | null + activeClient: WebRuntimeClient | null + activeClientEnvironmentId: string | null + cachedWorktrees: { loadedAt: number; worktrees: Worktree[] } | null + cachedDetectedWorktrees: { loadedAt: number; worktrees: Worktree[] } | null +} = { + activeEnvironment: readStoredWebRuntimeEnvironment(), + worktreeVisibilityDefaultsRuntimeEnvironmentId: null, + worktreeVisibilityDefaultsRuntimeValue: null, + activeClient: null, + activeClientEnvironmentId: null, + cachedWorktrees: null, + cachedDetectedWorktrees: null +} + +export const manuallyDisconnectedEnvironmentIds = new Set() + +export const runtimeCallQueuePool = new RuntimeRpcCallQueuePool() + +export function invalidateRuntimeWorktreeCaches(): void { + webRuntimeState.cachedWorktrees = null + webRuntimeState.cachedDetectedWorktrees = null +} + +export function getClientForEnvironment( + environment: StoredWebRuntimeEnvironment +): WebRuntimeClient { + if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { + throw new Error('runtime_manually_disconnected') + } + if ( + !webRuntimeState.activeClient || + webRuntimeState.activeClientEnvironmentId !== environment.id + ) { + webRuntimeState.activeClient?.close() + webRuntimeState.activeClient = new WebRuntimeClient(getPreferredWebPairingOffer(environment)) + webRuntimeState.activeClientEnvironmentId = environment.id + } + return webRuntimeState.activeClient +} + +export function closeActiveRuntimeClients(): void { + webRuntimeState.activeClient?.close() + webRuntimeState.activeClient = null + webRuntimeState.activeClientEnvironmentId = null + invalidateRuntimeWorktreeCaches() +} + +export function disconnectActiveRuntimeEnvironment(): void { + closeActiveRuntimeClients() +} + +export function removeActiveRuntimeEnvironment(): void { + disconnectActiveRuntimeEnvironment() + clearStoredWebRuntimeEnvironment() + webRuntimeState.activeEnvironment = null +} + +export function manuallyDisconnectedResponse( + environment: StoredWebRuntimeEnvironment +): RuntimeRpcResponse { + return { + id: 'runtime.manualDisconnect', + ok: false, + error: { + code: 'runtime_manually_disconnected', + message: translate( + 'auto.web.webPreloadApi.runtimeEnvironmentManuallyDisconnected', + 'Runtime environment is manually disconnected.' + ) + }, + _meta: { runtimeId: environment.runtimeId } + } +} + +export function resolveEnvironment(selector: string): StoredWebRuntimeEnvironment { + const environment = requireActiveEnvironment() + if (selector === environment.id || selector === environment.name || selector === 'active') { + return environment + } + if (environment.compatibleEnvironmentIds?.includes(selector)) { + return environment + } + throw new Error(`Unknown Orca runtime environment: ${selector}`) +} + +export function requireActiveEnvironment(): StoredWebRuntimeEnvironment { + webRuntimeState.activeEnvironment = + webRuntimeState.activeEnvironment ?? readStoredWebRuntimeEnvironment() + if (!webRuntimeState.activeEnvironment) { + throw new Error('Pair this web client with an Orca server first.') + } + return webRuntimeState.activeEnvironment +} + +export function requireActiveEnvironmentOrNull(): StoredWebRuntimeEnvironment | null { + webRuntimeState.activeEnvironment = + webRuntimeState.activeEnvironment ?? readStoredWebRuntimeEnvironment() + return webRuntimeState.activeEnvironment +} + +export function assertActiveEnvironment(environmentId: string): void { + if (requireActiveEnvironment().id !== environmentId) { + throw new Error('The paired Orca server changed while the request was in progress.') + } +} + +export function updateEnvironmentFromResponse( + environment: StoredWebRuntimeEnvironment, + response: RuntimeRpcResponse +): void { + if (webRuntimeState.activeEnvironment?.id !== environment.id) { + return + } + const runtimeId = response.ok ? response._meta.runtimeId : (response._meta?.runtimeId ?? null) + const pairedDeviceId = + response.ok && + typeof response.result === 'object' && + response.result !== null && + typeof (response.result as { pairedDeviceId?: unknown }).pairedDeviceId === 'string' + ? (response.result as { pairedDeviceId: string }).pairedDeviceId + : undefined + webRuntimeState.activeEnvironment = updateStoredEnvironmentRuntimeId( + environment, + runtimeId, + pairedDeviceId + ) +} diff --git a/src/renderer/src/web/preload-api/web-runtime-worktree-catalog.ts b/src/renderer/src/web/preload-api/web-runtime-worktree-catalog.ts new file mode 100644 index 00000000000..1cc6de1273e --- /dev/null +++ b/src/renderer/src/web/preload-api/web-runtime-worktree-catalog.ts @@ -0,0 +1,183 @@ +import { relativePathInsideRoot } from '../../../../shared/cross-platform-path' +import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' +import type { Repo } from '../../../../shared/repo-types' +import type { DetectedWorktreeListResult, Worktree } from '../../../../shared/worktree/types' +import { + callRuntimeEnvelope, + callRuntimeResult, + callRuntimeResultWithOwner, + withRuntimeWorktreeOwner +} from './web-runtime-calls' +import type { WebRuntimeEnvelopeCaller, WebRuntimeResultCaller } from './web-runtime-calls' +import { + assertActiveEnvironment, + requireActiveEnvironment, + webRuntimeState +} from './web-runtime-session' + +export const WEB_RUNTIME_WORKTREE_LIST_LIMIT = 10_000 + +export async function listAllRuntimeWorktrees(): Promise { + if ( + webRuntimeState.cachedWorktrees && + Date.now() - webRuntimeState.cachedWorktrees.loadedAt < 5_000 + ) { + return webRuntimeState.cachedWorktrees.worktrees + } + const owned = await callRuntimeResultWithOwner<{ worktrees: Worktree[] }>('worktree.list', { + limit: WEB_RUNTIME_WORKTREE_LIST_LIMIT + }) + const worktrees = owned.result.worktrees.map((worktree) => + withRuntimeWorktreeOwner(worktree, owned.hostId) + ) + assertActiveEnvironment(owned.environmentId) + webRuntimeState.cachedWorktrees = { loadedAt: Date.now(), worktrees } + return worktrees +} + +export async function listAllRuntimeDetectedWorktrees( + callResult: WebRuntimeResultCaller = callRuntimeResult, + callEnvelope: WebRuntimeEnvelopeCaller = callRuntimeEnvelope, + useCache = true, + expectedEnvironmentId = requireActiveEnvironment().id +): Promise { + if ( + useCache && + webRuntimeState.cachedDetectedWorktrees && + Date.now() - webRuntimeState.cachedDetectedWorktrees.loadedAt < 5_000 + ) { + return webRuntimeState.cachedDetectedWorktrees.worktrees + } + + assertActiveEnvironment(expectedEnvironmentId) + const repos = (await callResult<{ repos: Repo[] }>('repo.list')).repos + const detectedLists = await Promise.all( + repos.map((repo) => + callRuntimeDetectedWorktrees(repo.id, expectedEnvironmentId, callResult, callEnvelope) + ) + ) + const worktrees = detectedLists.flatMap((result) => result.worktrees) + assertActiveEnvironment(expectedEnvironmentId) + if (useCache) { + webRuntimeState.cachedDetectedWorktrees = { loadedAt: Date.now(), worktrees } + } + return worktrees +} + +export async function callRuntimeDetectedWorktrees( + repoId: string, + expectedEnvironmentId = requireActiveEnvironment().id, + callResult: WebRuntimeResultCaller = callRuntimeResult, + callEnvelope: WebRuntimeEnvelopeCaller = callRuntimeEnvelope +): Promise { + assertActiveEnvironment(expectedEnvironmentId) + const hostId = toRuntimeExecutionHostId(expectedEnvironmentId) + const response = await callEnvelope( + 'worktree.detectedList', + { repo: repoId }, + 15_000 + ) + if (response.ok) { + return { + ...response.result, + worktrees: response.result.worktrees.map((worktree) => + withRuntimeWorktreeOwner(worktree, hostId) + ) + } + } + if (response.error.code !== 'method_not_found') { + throw new Error(response.error.message) + } + + assertActiveEnvironment(expectedEnvironmentId) + const legacy = await callResult<{ worktrees: Worktree[] }>( + 'worktree.list', + { repo: repoId, limit: WEB_RUNTIME_WORKTREE_LIST_LIMIT }, + 15_000 + ) + return toLegacyDetectedWorktreeResult( + repoId, + legacy.worktrees.map((worktree) => withRuntimeWorktreeOwner(worktree, hostId)) + ) +} + +export function toLegacyDetectedWorktreeResult( + repoId: string, + worktrees: Worktree[] +): DetectedWorktreeListResult { + return { + repoId, + authoritative: true, + source: 'session-fallback', + worktrees: worktrees.map((worktree) => ({ + ...worktree, + ownership: 'orca-managed', + selectedCheckout: false, + visible: true + })) + } +} + +export function isMissingPathError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + return /\bENOENT\b|not found|no such file/i.test(error.message) +} + +export async function resolveRuntimeWorktreeByPath( + worktreePath: string, + callResult: WebRuntimeResultCaller = callRuntimeResult, + callEnvelope: WebRuntimeEnvelopeCaller = callRuntimeEnvelope, + useDetectedWorktreeCache = true, + expectedEnvironmentId = requireActiveEnvironment().id +): Promise { + // Why: hidden-but-open worktrees must still resolve, but `worktree.list` is sidebar-visible only — resolve via detected rows. + const worktrees = await listAllRuntimeDetectedWorktrees( + callResult, + callEnvelope, + useDetectedWorktreeCache, + expectedEnvironmentId + ) + const match = worktrees + .map((worktree) => ({ + worktree, + relativePath: relativePathInsideRoot(worktree.path, worktreePath) + })) + .filter((entry) => entry.relativePath !== null) + .sort((a, b) => b.worktree.path.length - a.worktree.path.length)[0] + if (!match) { + throw new Error(`No runtime worktree owns ${worktreePath}`) + } + return match.worktree +} + +export async function resolveRuntimeFilePath( + filePath: string, + preferredWorktreePath?: string, + callResult: WebRuntimeResultCaller = callRuntimeResult, + callEnvelope: WebRuntimeEnvelopeCaller = callRuntimeEnvelope, + useDetectedWorktreeCache = true, + expectedEnvironmentId = requireActiveEnvironment().id +): Promise<{ worktree: Worktree; relativePath: string }> { + const worktree = preferredWorktreePath + ? await resolveRuntimeWorktreeByPath( + preferredWorktreePath, + callResult, + callEnvelope, + useDetectedWorktreeCache, + expectedEnvironmentId + ) + : await resolveRuntimeWorktreeByPath( + filePath, + callResult, + callEnvelope, + useDetectedWorktreeCache, + expectedEnvironmentId + ) + const relativePath = relativePathInsideRoot(worktree.path, filePath) + if (relativePath === null) { + throw new Error(`File is outside runtime worktree: ${filePath}`) + } + return { worktree, relativePath } +} diff --git a/src/renderer/src/web/preload-api/web-settings-api.ts b/src/renderer/src/web/preload-api/web-settings-api.ts new file mode 100644 index 00000000000..f032b0fa9ff --- /dev/null +++ b/src/renderer/src/web/preload-api/web-settings-api.ts @@ -0,0 +1,118 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { + computerAwakeSettingsForMode, + normalizeComputerAwakeMode +} from '../../../../shared/computer-awake-mode' +import { normalizeTerminalCursorStyleDefault } from '../../../../shared/terminal-cursor-style-settings' +import { mergeSettings } from './web-preference-normalization' +import { + getRuntimeBackedStoredSettings, + getStoredSettings, + settingsForActiveVisibilityOwner, + syncRuntimeBackedSettings, + updateRuntimePRBotAuthorOverride, + writeStoredSettings +} from './web-preferences-store' +import type { WebSettingsApi } from './web-preferences-store' +import { + requireActiveEnvironmentOrNull, + resolveEnvironment, + webRuntimeState +} from './web-runtime-session' +import { noopUnsubscribe } from './web-storage' + +export function createWebSettingsApi(): Partial { + return { + settings: { + get: async () => getRuntimeBackedStoredSettings(), + // Why: localStorage-backed settings are synchronous, so the pre-hydration kill-switch read works the same as desktop. + getSync: () => settingsForActiveVisibilityOwner(getStoredSettings()), + set: async (updates) => { + const sanitizedUpdates = { ...updates } + const runtimeEnvironment = requireActiveEnvironmentOrNull() + delete sanitizedUpdates.activeRuntimeEnvironmentId + if ( + 'worktreeVisibilityDefaults' in sanitizedUpdates && + runtimeEnvironment && + runtimeEnvironment.id !== webRuntimeState.worktreeVisibilityDefaultsRuntimeEnvironmentId + ) { + delete sanitizedUpdates.worktreeVisibilityDefaults + } + if ('worktreeVisibilityDefaults' in sanitizedUpdates) { + sanitizedUpdates.worktreeVisibilityDefaults = { + ...settingsForActiveVisibilityOwner(getStoredSettings()).worktreeVisibilityDefaults, + ...sanitizedUpdates.worktreeVisibilityDefaults + } + } + if ('computerAwakeMode' in sanitizedUpdates) { + Object.assign( + sanitizedUpdates, + computerAwakeSettingsForMode( + normalizeComputerAwakeMode( + sanitizedUpdates.computerAwakeMode, + sanitizedUpdates.keepComputerAwakeWhileAgentsRun + ) + ) + ) + } else if ('keepComputerAwakeWhileAgentsRun' in sanitizedUpdates) { + Object.assign( + sanitizedUpdates, + computerAwakeSettingsForMode( + sanitizedUpdates.keepComputerAwakeWhileAgentsRun ? 'auto' : 'off' + ) + ) + } + if ('autoRenameBranchFromWorkDefaultedOn' in sanitizedUpdates) { + sanitizedUpdates.autoRenameBranchFromWorkDefaultedOn = true + } + if ('terminalCursorStyle' in sanitizedUpdates) { + Object.assign( + sanitizedUpdates, + normalizeTerminalCursorStyleDefault( + { terminalCursorStyle: sanitizedUpdates.terminalCursorStyle }, + { preserveExplicitValue: true } + ) + ) + } + const localUpdates = { ...sanitizedUpdates } + if (runtimeEnvironment) { + delete localUpdates.worktreeVisibilityDefaults + } + const next = mergeSettings(getStoredSettings(), localUpdates, { + preserveAutoRenameBranchFromWorkUpdate: 'autoRenameBranchFromWork' in sanitizedUpdates + }) + writeStoredSettings(next) + return settingsForActiveVisibilityOwner( + await syncRuntimeBackedSettings(sanitizedUpdates, next) + ) + }, + setActiveRuntimeEnvironmentPreference: async ({ environmentId }) => { + const requestedEnvironmentId = environmentId?.trim() || null + const activeRuntimeEnvironmentId = requestedEnvironmentId + ? resolveEnvironment(requestedEnvironmentId).id + : null + const next = mergeSettings(getStoredSettings(), { + activeRuntimeEnvironmentId + }) + writeStoredSettings(next, activeRuntimeEnvironmentId) + return next + }, + updatePRBotAuthorOverride: (args) => updateRuntimePRBotAuthorOverride(args), + listFonts: () => Promise.resolve([]), + onChanged: () => noopUnsubscribe + } satisfies Partial as unknown as WebSettingsApi, + agentAwake: { + getStatus: async () => { + const settings = getStoredSettings() + return { + mode: normalizeComputerAwakeMode( + settings.computerAwakeMode, + settings.keepComputerAwakeWhileAgentsRun + ), + active: false + } + }, + onChanged: () => noopUnsubscribe + } + } +} diff --git a/src/renderer/src/web/preload-api/web-shell-api.ts b/src/renderer/src/web/preload-api/web-shell-api.ts new file mode 100644 index 00000000000..59b067fb329 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-shell-api.ts @@ -0,0 +1,30 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { resolveRuntimeFilePath } from './web-runtime-worktree-catalog' + +export function createShellApi(): NonNullable['shell']> { + const openResult = { ok: true } as const + return { + openPath: (path) => + Promise.resolve(window.open(path, '_blank', 'noopener,noreferrer') as never), + openInFileManager: () => Promise.resolve(openResult), + openInExternalEditor: () => Promise.resolve(openResult), + openUrl: (url) => Promise.resolve(window.open(url, '_blank', 'noopener,noreferrer') as never), + openFilePath: () => Promise.resolve(false), + openFileUri: (uri) => + Promise.resolve(window.open(uri, '_blank', 'noopener,noreferrer') as never), + pathExists: async (path) => { + try { + await resolveRuntimeFilePath(path) + return true + } catch { + return false + } + }, + pickAttachment: () => Promise.resolve(null), + pickImage: () => Promise.resolve(null), + pickRepoIconImage: () => Promise.resolve(null), + pickAudio: () => Promise.resolve(null), + pickDirectory: () => Promise.resolve(null), + copyFile: () => Promise.resolve() + } +} diff --git a/src/renderer/src/web/preload-api/web-star-nag-api.ts b/src/renderer/src/web/preload-api/web-star-nag-api.ts new file mode 100644 index 00000000000..bbbe722eab3 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-star-nag-api.ts @@ -0,0 +1,21 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { noopUnsubscribe } from './web-storage' + +export function createWebStarNagApi(): Partial { + return { + starNag: { + onShow: () => noopUnsubscribe, + onHide: () => noopUnsubscribe, + dismiss: () => Promise.resolve(), + later: () => Promise.resolve(), + complete: () => Promise.resolve(), + disable: () => Promise.resolve(), + openWeb: () => Promise.resolve(), + starOrca: () => Promise.resolve(false), + forceShow: () => Promise.resolve(), + agentValueMoment: () => Promise.resolve({ status: 'skipped' }), + showAgentValueMoment: () => Promise.resolve(), + onboardingCompleted: () => Promise.resolve() + } + } +} diff --git a/src/renderer/src/web/preload-api/web-storage.ts b/src/renderer/src/web/preload-api/web-storage.ts new file mode 100644 index 00000000000..8f33d0c71ec --- /dev/null +++ b/src/renderer/src/web/preload-api/web-storage.ts @@ -0,0 +1,43 @@ +export const SETTINGS_STORAGE_KEY = 'orca.web.settings.v1' + +export const UI_STORAGE_KEY = 'orca.web.ui.v1' + +export const SESSION_STORAGE_KEY = 'orca.web.workspaceSession.v1' + +export const ONBOARDING_STORAGE_KEY = 'orca.web.onboarding.v1' + +export const GITHUB_CACHE_STORAGE_KEY = 'orca.web.githubCache.v1' + +export const KEYBINDINGS_STORAGE_KEY = 'orca.web.keybindings.v1' + +export function getBrowserPlatform(): NodeJS.Platform { + if (navigator.userAgent.includes('Windows')) { + return 'win32' + } + if (navigator.userAgent.includes('Linux')) { + return 'linux' + } + return 'darwin' +} + +export function readJson(key: string, fallback: T): T { + const raw = window.localStorage.getItem(key) + if (!raw) { + return cloneJson(fallback) + } + try { + return { ...cloneJson(fallback), ...JSON.parse(raw) } as T + } catch { + return cloneJson(fallback) + } +} + +export function writeJson(key: string, value: T): void { + window.localStorage.setItem(key, JSON.stringify(value)) +} + +export function cloneJson(value: T): T { + return JSON.parse(JSON.stringify(value)) as T +} + +export function noopUnsubscribe(): void {} diff --git a/src/renderer/src/web/preload-api/web-telemetry-api.ts b/src/renderer/src/web/preload-api/web-telemetry-api.ts new file mode 100644 index 00000000000..1fce49016be --- /dev/null +++ b/src/renderer/src/web/preload-api/web-telemetry-api.ts @@ -0,0 +1,11 @@ +import type { PreloadApi } from '../../../../preload/api-types' + +export function createWebTelemetryApi(): Partial { + return { + telemetryTrack: () => Promise.resolve(), + telemetrySetOptIn: () => Promise.resolve(), + telemetryGetConsentState: () => + Promise.resolve({ optedIn: false, source: 'default', blockedByEnv: false } as never), + telemetryAcknowledgeBanner: () => Promise.resolve() + } +} diff --git a/src/renderer/src/web/preload-api/web-terminal-api.ts b/src/renderer/src/web/preload-api/web-terminal-api.ts new file mode 100644 index 00000000000..df0c84f262b --- /dev/null +++ b/src/renderer/src/web/preload-api/web-terminal-api.ts @@ -0,0 +1,180 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS } from '../../../../shared/pty-delivery-diagnostics' +import type { SshConnectionState, SshTarget } from '../../../../shared/ssh-types' +import { translate } from '@/i18n/i18n' +import { callRuntimeResult } from './web-runtime-calls' +import { requireActiveEnvironmentOrNull } from './web-runtime-session' +import { noopUnsubscribe } from './web-storage' + +export function createPtyApi(): NonNullable['pty']> { + return { + spawn: () => Promise.reject(new Error('Local PTYs are unavailable in the web client.')), + write: () => {}, + writeAccepted: () => Promise.resolve(false), + resize: () => {}, + claimViewport: () => {}, + reportGeometry: () => {}, + signal: () => {}, + // Web panes clear the host buffer via the terminal.clearBuffer runtime RPC. + clearBuffer: () => {}, + kill: () => Promise.resolve(), + ackColdRestore: () => {}, + ackData: () => {}, + onDeliveryResyncRequest: () => noopUnsubscribe, + respondDeliveryResync: () => {}, + // Why: web terminals bypass main's delivery gate; a zero-in-flight reply keeps the watchdog idle. + reportRendererDeliveryState: () => + Promise.resolve({ inFlightTotalChars: 0, inFlightPtyCount: 0, msSinceLastAck: null }), + getPtyDataListenerCount: () => 0, + rendererDispatcherReady: () => {}, + setActiveRendererPty: () => {}, + setRendererPtyVisible: () => {}, + setHiddenRendererPty: () => {}, + setPtyDeliveryInterest: () => {}, + // Why: remote-runtime PTYs are never hidden-gate markable, so there's no main-side responder to feed. + publishTerminalViewAttributes: () => {}, + hasChildProcesses: () => Promise.resolve(false), + getForegroundProcess: () => Promise.resolve(null), + inspectProcess: () => Promise.reject(new Error('terminal_liveness_unavailable')), + // Why: paired web panes cannot provide a local post-boundary process scan. + confirmForegroundProcess: () => Promise.resolve(null), + getCwd: () => Promise.resolve('~'), + getSize: () => Promise.resolve(null), + listSessions: () => Promise.resolve([]), + getAuthoritativeBufferSnapshotCapabilities: (ids) => + Promise.resolve(ids.map((id) => ({ id, authoritative: false }))), + hasPty: () => Promise.resolve(null), + getMainBufferSnapshot: () => Promise.resolve(null), + // Why: remote-runtime PTYs skip local main (no side-effect source); renderer byte parsing stays authoritative. + onSideEffect: () => noopUnsubscribe, + getSideEffectSnapshot: () => Promise.resolve(null), + getRendererDeliveryDebugSnapshot: () => + Promise.resolve({ + pendingPtyCount: 0, + pendingChars: 0, + maxPendingCharsByPty: 0, + rendererInFlightPtyCount: 0, + rendererInFlightChars: 0, + maxRendererInFlightCharsByPty: 0, + activeRendererPtyCount: 0, + flushScheduled: false, + peakPendingChars: 0, + peakMaxPendingCharsByPty: 0, + peakRendererInFlightChars: 0, + peakMaxRendererInFlightCharsByPty: 0, + ackGatedFlushSkipCount: 0, + hiddenDeliveryGatedPtyCount: 0, + hiddenDeliveryGatedVisiblePtyCount: 0, + hiddenDeliveryGatedActivePtyCount: 0, + deliveryInterestPtyCount: 0, + hiddenDeliveryDroppedChars: 0, + hiddenDeliveryDroppedChunks: 0, + pendingDroppedChars: 0, + diagnostics: EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS, + rendererLifecycleResetCount: 0, + lastLifecycleResetClearedChars: 0, + rendererPtyDispatcherReady: false, + rendererDispatcherReadyForcedCount: 0 + }), + resetRendererDeliveryDebug: () => Promise.resolve(), + onData: () => noopUnsubscribe, + onReplay: () => noopUnsubscribe, + onModelRestoreNeeded: () => noopUnsubscribe, + onExit: () => noopUnsubscribe, + onSpawned: () => noopUnsubscribe, + onSerializeBufferRequest: () => noopUnsubscribe, + onClearBufferRequest: () => noopUnsubscribe, + sendSerializedBuffer: () => {}, + declarePendingPaneSerializer: () => Promise.resolve(0), + settlePaneSerializer: () => Promise.resolve(), + clearPendingPaneSerializer: () => Promise.resolve(), + reportRendererSerializerReady: () => Promise.resolve(), + management: { + listSessions: () => Promise.resolve({ sessions: [], degraded: false }), + killAll: () => Promise.resolve({ killedCount: 0, remainingCount: 0, killedSessionIds: [] }), + killOne: () => Promise.resolve({ success: false }), + restart: () => Promise.resolve({ success: false }), + // Why: web clients can't inspect the host daemon's pid record; 'unknown' keeps the banner hidden. + macTccAttribution: () => Promise.resolve({ health: 'unknown' as const }) + } + } +} + +export function createSshApi(): NonNullable['ssh']> { + return { + // Why: SSH is owned by the paired host; route read/connect to runtime RPC for state/reconnect (STA-1468). Target mgmt is desktop-only. + listTargets: async () => { + if (!requireActiveEnvironmentOrNull()) { + return [] + } + const { targets } = await callRuntimeResult<{ targets: SshTarget[] }>( + 'ssh.listTargetSummaries' + ) + return targets + }, + listRemovedTargetLabels: async () => { + if (!requireActiveEnvironmentOrNull()) { + return {} + } + const { labels } = await callRuntimeResult<{ labels: Record }>( + 'ssh.listRemovedTargetLabels' + ) + return labels + }, + addTarget: () => + Promise.reject(new Error('SSH target management is unavailable in the web client.')), + updateTarget: () => + Promise.reject(new Error('SSH target management is unavailable in the web client.')), + removeTarget: () => Promise.resolve(), + importConfig: () => Promise.resolve({ targets: [], repoReadoptions: [] }), + listConfigHosts: () => + Promise.resolve({ + hosts: [], + totalHostCount: 0, + newHostCount: 0, + matchCount: 0, + hasMore: false + }), + resolveConfigHost: () => Promise.resolve(null), + connect: async (args) => { + const { state } = await callRuntimeResult<{ state: SshConnectionState | null }>( + 'ssh.connect', + { targetId: args.targetId } + ) + return state + }, + disconnect: () => Promise.resolve(), + terminateSessions: () => Promise.resolve(), + resetRelay: () => Promise.resolve(), + getState: async (args) => { + if (!requireActiveEnvironmentOrNull()) { + return null + } + const { state } = await callRuntimeResult<{ state: SshConnectionState | null }>( + 'ssh.getState', + { targetId: args.targetId } + ) + return state + }, + needsPassphrasePrompt: () => Promise.resolve(false), + testConnection: () => + Promise.resolve({ + success: false, + error: translate('auto.web.web.preload.api.31bfe8ae1a', 'Unavailable in the web client.') + }), + onStateChanged: () => noopUnsubscribe, + addPortForward: () => + Promise.reject(new Error('SSH port forwarding is unavailable in the web client.')), + updatePortForward: () => + Promise.reject(new Error('SSH port forwarding is unavailable in the web client.')), + removePortForward: () => Promise.resolve(null), + listPortForwards: () => Promise.resolve([]), + listDetectedPorts: () => Promise.resolve([]), + onPortForwardsChanged: () => noopUnsubscribe, + onDetectedPortsChanged: () => noopUnsubscribe, + browseDir: () => Promise.resolve({ entries: [], resolvedPath: '', pathFlavor: 'posix' }), + onCredentialRequest: () => noopUnsubscribe, + onCredentialResolved: () => noopUnsubscribe, + submitCredential: () => Promise.resolve() + } +} diff --git a/src/renderer/src/web/preload-api/web-ui-api.ts b/src/renderer/src/web/preload-api/web-ui-api.ts new file mode 100644 index 00000000000..974132fc49c --- /dev/null +++ b/src/renderer/src/web/preload-api/web-ui-api.ts @@ -0,0 +1,241 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { assertClipboardTextWithinLimitWithYield } from '../../../../shared/clipboard-text' +import type { ReadClipboardTextOptions } from '../../../../shared/clipboard-text' +import { normalizeFeatureInteractions } from '../../../../shared/feature-interactions' +import type { FeatureInteractionId } from '../../../../shared/feature-interactions' +import { omitPairingLocalUiFields } from '../../../../shared/pairing-local-ui-fields' +import type { PairedUiState } from '../../../../shared/pairing-local-ui-fields' +import { + readClipboardImagePngBase64, + saveClipboardImageAsTempFileInRuntime, + writeWebClipboardText +} from './web-clipboard-api' +import { + mergeContextualTourSeenIds, + mergeFeatureInteractionState, + mergeHostWebUIState, + mergeOsc52ClipboardNoticePending, + mergeWebUIState +} from './web-preference-normalization' +import { readLocalWebUIState } from './web-preferences-store' +import { callRuntimeResult } from './web-runtime-calls' +import { requireActiveEnvironmentOrNull } from './web-runtime-session' +import { UI_STORAGE_KEY, noopUnsubscribe, writeJson } from './web-storage' + +export function createWebUiApi(): NonNullable['ui']> { + let zoomLevel = readLocalWebUIState().uiZoomLevel + return { + get: async () => { + try { + const result = await callRuntimeResult<{ ui: PairedUiState }>('ui.get', undefined, 15_000) + const local = readLocalWebUIState() + const next = { + ...mergeHostWebUIState(local, result.ui), + osc52ClipboardDefaultOnNoticePending: mergeOsc52ClipboardNoticePending(local, result.ui), + featureInteractions: mergeFeatureInteractionState( + local.featureInteractions, + result.ui.featureInteractions + ), + contextualToursSeenIds: mergeContextualTourSeenIds( + local.contextualToursSeenIds, + result.ui.contextualToursSeenIds + ) + } + writeJson(UI_STORAGE_KEY, next) + zoomLevel = next.uiZoomLevel + return next + } catch { + return readLocalWebUIState() + } + }, + set: async (updates) => { + const next = mergeWebUIState(readLocalWebUIState(), updates) + writeJson(UI_STORAGE_KEY, next) + zoomLevel = next.uiZoomLevel + // Why strip here too when the host also strips: an old host predating that strip would + // otherwise persist this browser's runtime:web-* keys over the desktop profile's order. + const hostUpdates = omitPairingLocalUiFields(updates) + try { + await callRuntimeResult('ui.set', hostUpdates, 15_000) + } catch { + // Why: unpaired/offline web clients still need local UI persistence. + } + }, + recordFeatureInteraction: async (id: FeatureInteractionId) => { + const current = readLocalWebUIState() + const featureInteractions = normalizeFeatureInteractions(current.featureInteractions) + const existing = featureInteractions[id] + const optimistic = mergeWebUIState(current, { + featureInteractions: { + ...featureInteractions, + [id]: { + firstInteractedAt: existing?.firstInteractedAt ?? Date.now(), + interactionCount: (existing?.interactionCount ?? 0) + 1 + } + } + }) + writeJson(UI_STORAGE_KEY, optimistic) + try { + const result = await callRuntimeResult<{ ui: PairedUiState }>( + 'ui.recordFeatureInteraction', + id, + 15_000 + ) + const local = readLocalWebUIState() + const next = { + ...mergeHostWebUIState(local, result.ui), + osc52ClipboardDefaultOnNoticePending: mergeOsc52ClipboardNoticePending(local, result.ui), + featureInteractions: mergeFeatureInteractionState( + local.featureInteractions, + result.ui.featureInteractions + ), + contextualToursSeenIds: mergeContextualTourSeenIds( + local.contextualToursSeenIds, + result.ui.contextualToursSeenIds + ) + } + writeJson(UI_STORAGE_KEY, next) + zoomLevel = next.uiZoomLevel + return next + } catch { + return optimistic + } + }, + readClipboardText: async (options?: ReadClipboardTextOptions) => + assertClipboardTextWithinLimitWithYield( + await (navigator.clipboard?.readText?.() ?? ''), + options + ), + readSelectionClipboardText: () => + Promise.reject(new Error('Selection clipboard is unavailable in the web client')), + saveClipboardImageAsTempFile: async (args?: { + connectionId?: string | null + runtimeEnvironmentId?: string | null + }) => { + if (!requireActiveEnvironmentOrNull()) { + return null + } + const contentBase64 = await readClipboardImagePngBase64() + if (!contentBase64) { + return null + } + return saveClipboardImageAsTempFileInRuntime(contentBase64, args) + }, + writeClipboardText: writeWebClipboardText, + writeTerminalClipboardText: writeWebClipboardText, + writeSelectionClipboardText: () => + Promise.reject(new Error('Selection clipboard is unavailable in the web client')), + writeClipboardImage: () => Promise.resolve(), + writeClipboardFile: () => Promise.resolve({ ok: false, reason: 'unsupported-platform' }), + performNativePaste: () => { + document.execCommand?.('paste') + }, + performNativeSelectionAction: (action) => { + document.execCommand?.(action === 'copy' ? 'copy' : 'selectAll') + }, + onExportPdfRequested: () => noopUnsubscribe, + onAppMenuPaste: () => noopUnsubscribe, + onAppMenuSelectionAction: () => noopUnsubscribe, + onEditableContextPaste: () => noopUnsubscribe, + getZoomLevel: () => zoomLevel, + setZoomLevel: (level) => { + zoomLevel = level + }, + isMaximized: () => Promise.resolve(false), + onOpenSettings: () => noopUnsubscribe, + // Why: the web client has no native tray/menu bar, so there's never a queued open-settings intent to consume. + consumePendingOpenSettings: () => Promise.resolve(false), + onOpenSkillShare: () => noopUnsubscribe, + consumePendingSkillShare: () => Promise.resolve(null), + onOpenSetupGuide: () => noopUnsubscribe, + onOpenFeatureTour: () => noopUnsubscribe, + onOpenCrashReport: () => noopUnsubscribe, + // No desktop main process to push state changes; the web client re-reads via ui.get on interaction. + onStateChanged: () => noopUnsubscribe, + onToggleLeftSidebar: () => noopUnsubscribe, + onToggleRightSidebar: () => noopUnsubscribe, + onToggleWorktreePalette: () => noopUnsubscribe, + onToggleFloatingTerminal: () => noopUnsubscribe, + onTerminalShortcutCaptured: () => noopUnsubscribe, + onOpenQuickOpen: () => noopUnsubscribe, + onToggleQuickCommandsMenu: () => noopUnsubscribe, + onOpenTasks: () => noopUnsubscribe, + onOpenNewWorkspace: () => noopUnsubscribe, + onDeleteCurrentWorkspace: () => noopUnsubscribe, + onOpenWorkspaceBoard: () => noopUnsubscribe, + onToggleAgentDashboard: () => noopUnsubscribe, + onJumpToWorktreeIndex: () => noopUnsubscribe, + onJumpToTabIndex: () => noopUnsubscribe, + onWorktreeHistoryNavigate: () => noopUnsubscribe, + onNewBrowserTab: () => noopUnsubscribe, + onNewMarkdownTab: () => noopUnsubscribe, + onNewSimulatorTab: () => noopUnsubscribe, + onRequestTabCreate: () => noopUnsubscribe, + replyTabCreate: () => {}, + onRequestTabSetProfile: () => noopUnsubscribe, + replyTabSetProfile: () => {}, + onRequestTabClose: () => noopUnsubscribe, + replyTabClose: () => {}, + onNewTerminalTab: () => noopUnsubscribe, + onFocusBrowserAddressBar: () => noopUnsubscribe, + onFindInBrowserPage: () => noopUnsubscribe, + onReloadBrowserPage: () => noopUnsubscribe, + onBrowserHistoryNavigate: () => noopUnsubscribe, + onZoomBrowserPage: () => noopUnsubscribe, + onHardReloadBrowserPage: () => noopUnsubscribe, + onCloseActiveTab: () => noopUnsubscribe, + onCloseFloatingItem: () => noopUnsubscribe, + onSelectFloatingIndex: () => noopUnsubscribe, + onSwitchTab: () => noopUnsubscribe, + onSwitchTabAcrossAllTypes: () => noopUnsubscribe, + onSwitchRecentTab: () => noopUnsubscribe, + onSwitchTerminalTab: () => noopUnsubscribe, + onCtrlTabKeyDown: () => noopUnsubscribe, + onCtrlTabKeyUp: () => noopUnsubscribe, + onToggleStatusBar: () => noopUnsubscribe, + onDictationKeyDown: () => noopUnsubscribe, + onActivateWorktree: () => noopUnsubscribe, + onCreateTerminal: () => noopUnsubscribe, + onRequestTerminalCreate: () => noopUnsubscribe, + onRequestTerminalTabMount: () => noopUnsubscribe, + replyTerminalCreate: () => {}, + onSplitTerminal: () => noopUnsubscribe, + onRenameTerminal: () => noopUnsubscribe, + onFocusTerminal: () => noopUnsubscribe, + onFocusEditorTab: () => noopUnsubscribe, + onCloseSessionTab: () => noopUnsubscribe, + onSessionTabCloseRequest: () => noopUnsubscribe, + respondSessionTabClose: () => {}, + onMoveSessionTab: () => noopUnsubscribe, + onOpenFileFromMobile: () => noopUnsubscribe, + onOpenDiffFromMobile: () => noopUnsubscribe, + onMobileMarkdownRequest: () => noopUnsubscribe, + respondMobileMarkdownRequest: () => {}, + onCloseTerminal: () => noopUnsubscribe, + onTerminalTabCloseRequest: () => noopUnsubscribe, + respondTerminalTabClose: () => {}, + onSleepWorktree: () => noopUnsubscribe, + // Why: paired web is a full renderer that wakes on activation; mobile wake is desktop-host-scoped and never reaches web. + onResumeSleepingAgents: () => noopUnsubscribe, + onTerminalZoom: () => noopUnsubscribe, + // Why: a paired web client has no OS sleep signal; occlusion-driven visibilitychange already covers wake recovery. + onSystemResumed: () => noopUnsubscribe, + onFileDrop: () => noopUnsubscribe, + syncTrafficLights: () => {}, + setMarkdownEditorFocused: () => {}, + setRichMarkdownContextMenuTarget: () => {}, + setTerminalInputFocused: () => {}, + setFloatingFocus: () => {}, + setShortcutRecorderFocused: () => {}, + onRichMarkdownContextCommand: () => noopUnsubscribe, + onFullscreenChanged: () => noopUnsubscribe, + minimize: () => {}, + maximize: () => {}, + onMaximizeChanged: () => noopUnsubscribe, + requestClose: () => {}, + popupMenu: () => {}, + onWindowCloseRequested: () => noopUnsubscribe, + confirmWindowClose: () => {}, + notifyWindowRevealed: () => {} + } +} diff --git a/src/renderer/src/web/preload-api/web-updater-api.ts b/src/renderer/src/web/preload-api/web-updater-api.ts new file mode 100644 index 00000000000..18d69f2b8e0 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-updater-api.ts @@ -0,0 +1,34 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { translate } from '@/i18n/i18n' +import { noopUnsubscribe } from './web-storage' + +export function createUpdaterApi(): NonNullable['updater']> { + // Why: the linux-package-install recovery status can only originate in the native main process, so + // the web renderer never reaches these branches — reject loudly rather than resolve a fake result. + // A fresh Error per rejection: one shared instance would carry this function's stack, not the caller's. + const desktopOnlyMessage = 'Linux package install recovery is only available in the desktop app.' + return { + getVersion: () => Promise.resolve('web'), + getStatus: () => Promise.resolve({ state: 'idle' } as never), + check: () => Promise.resolve(), + download: () => Promise.resolve(), + quitAndInstall: () => Promise.resolve(), + dismissNudge: () => Promise.resolve(), + dismissAvailableUpdate: () => Promise.resolve(), + getLinuxPackageInstallInstructions: () => Promise.reject(new Error(desktopOnlyMessage)), + showLinuxPackage: () => Promise.reject(new Error(desktopOnlyMessage)), + // Why: the web client cannot install a desktop build, so channel switching + // reports unavailable rather than an empty list that looks like a fetch miss. + listBuilds: (channel) => + Promise.resolve({ + ok: false, + channel, + message: translate( + 'auto.components.settings.ReleaseChannelSection.webUnavailable', + 'Switching builds is only available in the desktop app.' + ) + }), + onStatus: () => noopUnsubscribe, + onClearDismissal: () => noopUnsubscribe + } +} diff --git a/src/renderer/src/web/preload-api/web-workspace-ports-api.ts b/src/renderer/src/web/preload-api/web-workspace-ports-api.ts new file mode 100644 index 00000000000..dcc18462a94 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-workspace-ports-api.ts @@ -0,0 +1,23 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { getBrowserPlatform, noopUnsubscribe } from './web-storage' + +export function createWebWorkspacePortsApi(): Partial { + return { + workspacePorts: { + // Why: browser-local workspaces have no host process to inspect; return capability state instead of the generic undefined fallback. + scan: () => + Promise.resolve({ + platform: getBrowserPlatform(), + scannedAt: Date.now(), + ports: [], + unavailableReason: 'Workspace port scanning is unavailable for browser-local workspaces.' + }), + kill: () => + Promise.resolve({ + ok: false, + reason: 'Workspace port management is unavailable for browser-local workspaces.' + }), + onAdvertisedUrlChanged: () => noopUnsubscribe + } + } +} diff --git a/src/renderer/src/web/preload-api/web-workspace-session-api.ts b/src/renderer/src/web/preload-api/web-workspace-session-api.ts new file mode 100644 index 00000000000..4150949b802 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-workspace-session-api.ts @@ -0,0 +1,71 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import { getDefaultWorkspaceSession } from '../../../../shared/constants' +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId +} from '../../../../shared/execution-host' +import type { + WorkspaceSessionPatch, + WorkspaceSessionState +} from '../../../../shared/workspace-session-state-types' +import { sanitizeWebRuntimeWorkspaceSession } from '../web-workspace-session' +import { readLocalWebUIState } from './web-preferences-store' +import { requireActiveEnvironmentOrNull } from './web-runtime-session' +import { SESSION_STORAGE_KEY, readJson, writeJson } from './web-storage' + +export function sessionStorageKeyForHost(hostId?: string | null): string { + const resolved = normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID + return resolved === LOCAL_EXECUTION_HOST_ID + ? SESSION_STORAGE_KEY + : `${SESSION_STORAGE_KEY}.${resolved}` +} + +export function getStoredWorkspaceSession(hostId?: string | null): WorkspaceSessionState { + const resolvedHostId = normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID + if (resolvedHostId !== LOCAL_EXECUTION_HOST_ID) { + return sanitizeWebRuntimeWorkspaceSession( + readJson(sessionStorageKeyForHost(resolvedHostId), getDefaultWorkspaceSession()) + ) + } + const localSession = sanitizeWebRuntimeWorkspaceSession( + readJson(SESSION_STORAGE_KEY, getDefaultWorkspaceSession()) + ) + if (!requireActiveEnvironmentOrNull()) { + return localSession + } + const ui = readLocalWebUIState() + // Why: replaying browser-local terminal handles first creates stale remote PTYs; mirror host session-tabs instead. + return sanitizeWebRuntimeWorkspaceSession({ + ...getDefaultWorkspaceSession(), + activeRepoId: ui.lastActiveRepoId, + activeWorktreeId: ui.lastActiveWorktreeId, + lastVisitedAtByWorktreeId: localSession.lastVisitedAtByWorktreeId + }) +} + +export function createWebWorkspaceSessionApi(): Partial { + return { + session: { + // Mirrors desktop bridge: non-local hosts persist under a host-suffixed key so their sessions stay isolated from local. + get: (hostId) => Promise.resolve(getStoredWorkspaceSession(hostId)), + set: async (session, hostId) => { + writeJson(sessionStorageKeyForHost(hostId), sanitizeWebRuntimeWorkspaceSession(session)) + }, + patch: async (patch: WorkspaceSessionPatch, hostId) => { + writeJson( + sessionStorageKeyForHost(hostId), + sanitizeWebRuntimeWorkspaceSession({ + ...getStoredWorkspaceSession(hostId), + ...patch + }) + ) + }, + // localStorage writes synchronously, so there is no deferred web flush. + flush: async () => {}, + readTerminalScrollback: () => null, + setSync: (session, hostId) => { + writeJson(sessionStorageKeyForHost(hostId), sanitizeWebRuntimeWorkspaceSession(session)) + } + } + } +} diff --git a/src/renderer/src/web/preload-api/web-worktrees-api.ts b/src/renderer/src/web/preload-api/web-worktrees-api.ts new file mode 100644 index 00000000000..5d3b9bd1bd9 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-worktrees-api.ts @@ -0,0 +1,188 @@ +import type { PreloadApi } from '../../../../preload/api-types' +import type { + ForceDeleteWorktreeBranchResult, + RemoveWorktreeResult +} from '../../../../shared/worktree/create-types' +import type { WorkspaceLineage, WorktreeLineage } from '../../../../shared/worktree/lineage-types' +import { readRetiredNameRegistryForRepo } from '../../../../shared/worktree/retired-name-cache' +import { EMPTY_RETIRED_NAME_REGISTRY } from '../../../../shared/worktree/retired-name-registry' +import type { Worktree } from '../../../../shared/worktree/types' +import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' +import { + callRuntimeResult, + callRuntimeResultWithOwner, + withRuntimeWorktreeOwner +} from './web-runtime-calls' +import { invalidateRuntimeWorktreeCaches } from './web-runtime-session' +import { + WEB_RUNTIME_WORKTREE_LIST_LIMIT, + callRuntimeDetectedWorktrees, + listAllRuntimeWorktrees +} from './web-runtime-worktree-catalog' +import { noopUnsubscribe } from './web-storage' + +export function createWorktreesApi(): NonNullable['worktrees']> { + return { + list: async ({ repoId }) => { + const owned = await callRuntimeResultWithOwner<{ worktrees: Worktree[] }>('worktree.list', { + repo: repoId, + limit: WEB_RUNTIME_WORKTREE_LIST_LIMIT + }) + return owned.result.worktrees.map((worktree) => + withRuntimeWorktreeOwner(worktree, owned.hostId) + ) + }, + // Why the catch: a host predating this method answers `method_not_found`, and an empty list + // degrades the suggestion to the pre-existing behavior rather than blocking workspace create. + listRetiredNames: async ({ repoId }) => { + try { + return readRetiredNameRegistryForRepo( + await callRuntimeResult('worktree.listRetiredNames', { repo: repoId }), + repoId + ) + } catch { + return EMPTY_RETIRED_NAME_REGISTRY + } + }, + listDetected: async ({ repoId }) => callRuntimeDetectedWorktrees(repoId), + listAll: () => listAllRuntimeWorktrees(), + create: async (args) => { + invalidateRuntimeWorktreeCaches() + const owned = await callRuntimeResultWithOwner<{ worktree: Worktree }>('worktree.create', { + repo: args.repoId, + name: args.name, + // Absent means user-typed, which is what the host must assume — so send it only when true. + ...(args.nameWasGenerated ? { nameWasGenerated: true } : {}), + baseBranch: args.baseBranch, + compareBaseRef: args.compareBaseRef, + branchNameOverride: args.branchNameOverride, + linkedIssue: args.linkedIssue, + linkedPR: args.linkedPR, + linkedLinearIssue: args.linkedLinearIssue, + linkedLinearIssueWorkspaceId: args.linkedLinearIssueWorkspaceId, + linkedLinearIssueOrganizationUrlKey: args.linkedLinearIssueOrganizationUrlKey, + linkedGitLabIssue: args.linkedGitLabIssue, + linkedGitLabMR: args.linkedGitLabMR, + linkedBitbucketPR: args.linkedBitbucketPR, + linkedAzureDevOpsPR: args.linkedAzureDevOpsPR, + linkedGiteaPR: args.linkedGiteaPR, + displayName: args.displayName, + sparseCheckout: args.sparseCheckout, + pushTarget: args.pushTarget, + setupDecision: args.setupDecision, + createdWithAgent: args.createdWithAgent, + pendingFirstAgentMessageRename: args.pendingFirstAgentMessageRename, + ...(args.startup + ? { + startupCommand: args.startup.command, + ...(args.startup.env ? { startupEnv: args.startup.env } : {}), + ...(args.startup.launchConfig + ? { startupLaunchConfig: args.startup.launchConfig } + : {}), + ...(args.startup.startupCommandDelivery + ? { startupCommandDelivery: args.startup.startupCommandDelivery } + : {}), + activate: true + } + : {}), + parentWorkspace: args.parentWorkspace, + workspaceStatus: args.workspaceStatus, + manualOrder: args.manualOrder, + automationProvenanceRequest: args.automationProvenanceRequest + }) + return { + ...owned.result, + worktree: withRuntimeWorktreeOwner(owned.result.worktree, owned.hostId) + } + }, + // Why: adoption verifies a desktop-owned hidden SSH target and is intentionally not a remote-server operation. + adoptProvisionedRoot: () => + Promise.reject( + new Error('Provisioned-root recipes require a direct SSH connection from the desktop app.') + ), + // Why: the runtime create path emits no two-phase progress, so the panel falls back to an indeterminate spinner. + onCreateProgress: () => noopUnsubscribe, + prefetchCreateBase: async ({ repoId, baseBranch }) => { + await callRuntimeResult('worktree.prefetchCreateBase', { + repo: repoId, + baseBranch + }) + }, + resolvePrBase: async ({ repoId, prNumber, headRefName, baseRefName, isCrossRepository }) => + callRuntimeResult('worktree.resolvePrBase', { + repo: repoId, + prNumber, + headRefName, + baseRefName, + isCrossRepository + }), + resolveMrBase: async ({ repoId, mrIid, sourceBranch, targetBranch, isCrossRepository }) => + callRuntimeResult('worktree.resolveMrBase', { + repo: repoId, + mrIid, + sourceBranch, + targetBranch, + isCrossRepository + }), + remove: async ({ worktreeId, hostId, force, allowUnverifiedPtyStop, skipArchive }) => { + invalidateRuntimeWorktreeCaches() + return callRuntimeResult('worktree.rm', { + worktree: toRuntimeWorktreeSelector(worktreeId), + ...(hostId ? { hostId } : {}), + force, + // Why (#11960): the web client renders the same Force Delete affordances, so + // dropping this field here would leave paired clients permanently wedged. + allowUnverifiedPtyStop, + runHooks: skipArchive !== true + }) + }, + // Why: forget-locally clears a desktop workspace pinned to a dead SSH host; a paired web client has no such ghost state. + forgetLocal: () => { + throw new Error('Forgetting a workspace is unavailable in paired web clients.') + }, + forceDeletePreservedBranch: ({ worktreeId, branchName, expectedHead, hostId }) => + callRuntimeResult('worktree.forceDeleteBranch', { + worktree: toRuntimeWorktreeSelector(worktreeId), + branchName, + expectedHead, + ...(hostId ? { hostId } : {}) + }), + updateMeta: async ({ worktreeId, updates }) => { + const rpcUpdates = + Object.hasOwn(updates, 'pushTarget') && updates.pushTarget === undefined + ? { ...updates, pushTarget: null } + : updates + const owned = await callRuntimeResultWithOwner<{ worktree: Worktree }>('worktree.set', { + worktree: toRuntimeWorktreeSelector(worktreeId), + ...rpcUpdates + }) + return withRuntimeWorktreeOwner(owned.result.worktree, owned.hostId) + }, + listLineage: async () => + await callRuntimeResult<{ + lineage: Record + workspaceLineage?: Record + }>('worktree.lineageList'), + updateLineage: async ({ worktreeId, parentWorktreeId, noParent }) => { + invalidateRuntimeWorktreeCaches() + const result = await callRuntimeResult<{ + worktree: Worktree & { lineage?: WorktreeLineage | null } + }>('worktree.set', { + worktree: toRuntimeWorktreeSelector(worktreeId), + parentWorktree: parentWorktreeId, + noParent + }) + return result.worktree.lineage ?? null + }, + persistSortOrder: async ({ orderedIds }) => { + await callRuntimeResult('worktree.persistSortOrder', { orderedIds }) + }, + // Why: the capture lives in desktop main memory, unexposed over pairing; the dialog falls back to the persisted excerpt. + getBranchRenameFailureOutput: async () => null, + onChanged: () => noopUnsubscribe, + onGitStatusMetadataChanged: () => noopUnsubscribe, + onHeadIdentitiesChanged: () => noopUnsubscribe, + onBaseStatus: () => noopUnsubscribe, + onRemoteBranchConflict: () => noopUnsubscribe + } +} diff --git a/src/renderer/src/web/web-preload-api-clipboard.test.ts b/src/renderer/src/web/web-preload-api-clipboard.test.ts index 492a2520e94..f5a6f76f8a0 100644 --- a/src/renderer/src/web/web-preload-api-clipboard.test.ts +++ b/src/renderer/src/web/web-preload-api-clipboard.test.ts @@ -301,8 +301,9 @@ describe('web UI preload API', () => { const globals = installBrowserGlobals('Linux') writeStoredRuntimeEnvironment(globals.storage) - const { CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS, installWebPreloadApi } = - await import('./web-preload-api') + const { installWebPreloadApi } = await import('./web-preload-api') + const { CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS } = + await import('./preload-api/web-clipboard-api') const contentBase64 = `${'A'.repeat(CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS)}AAAA` installClipboardImageBase64(contentBase64) installWebPreloadApi() @@ -414,8 +415,9 @@ describe('web UI preload API', () => { const globals = installBrowserGlobals('Linux') writeStoredRuntimeEnvironment(globals.storage) - const { CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS, installWebPreloadApi } = - await import('./web-preload-api') + const { installWebPreloadApi } = await import('./web-preload-api') + const { CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS } = + await import('./preload-api/web-clipboard-api') installClipboardImageBase64('A'.repeat(CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS + 4)) installWebPreloadApi() @@ -578,8 +580,8 @@ describe('web UI preload API', () => { const globals = installBrowserGlobals('Linux') writeStoredRuntimeEnvironment(globals.storage) - const { MAX_CLIPBOARD_IMAGE_SOURCE_BYTES, installWebPreloadApi } = - await import('./web-preload-api') + const { installWebPreloadApi } = await import('./web-preload-api') + const { MAX_CLIPBOARD_IMAGE_SOURCE_BYTES } = await import('./preload-api/web-clipboard-api') const clipboard = installClipboardImageBlob( new Blob([new Uint8Array(MAX_CLIPBOARD_IMAGE_SOURCE_BYTES + 1)], { type: 'image/png' }) ) @@ -624,7 +626,8 @@ describe('web UI preload API', () => { const globals = installBrowserGlobals('Linux') writeStoredRuntimeEnvironment(globals.storage) - const { MAX_CLIPBOARD_IMAGE_PIXELS, installWebPreloadApi } = await import('./web-preload-api') + const { installWebPreloadApi } = await import('./web-preload-api') + const { MAX_CLIPBOARD_IMAGE_PIXELS } = await import('./preload-api/web-clipboard-api') installClipboardImageBlob(new Blob(['small'], { type: 'image/jpeg' })) vi.stubGlobal( 'createImageBitmap', diff --git a/src/renderer/src/web/web-preload-api-composition.test.ts b/src/renderer/src/web/web-preload-api-composition.test.ts new file mode 100644 index 00000000000..85769ffa45c --- /dev/null +++ b/src/renderer/src/web/web-preload-api-composition.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installBrowserGlobals } from './web-preload-api-test-harness' + +describe('web preload API composition', () => { + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.unstubAllEnvs() + vi.doUnmock('../../../shared/e2e-config') + }) + + it('installs the exact concrete surface without enumerating fallback namespaces', async () => { + const globals = installBrowserGlobals('Linux') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + expect(Object.keys(globals.window.api)).toEqual([ + 'app', + 'starNag', + 'platform', + 'workspacePorts', + 'orcaProfiles', + 'e2e', + 'settings', + 'agentAwake', + 'keybindings', + 'ui', + 'crashReports', + 'diagnostics', + 'session', + 'onboarding', + 'cache', + 'runtime', + 'nativeChat', + 'runtimeEnvironments', + 'repos', + 'worktrees', + 'fs', + 'git', + 'browser', + 'emulator', + 'gh', + 'gl', + 'hostedReview', + 'linear', + 'hooks', + 'stats', + 'memory', + 'aiVault', + 'preflight', + 'notifications', + 'rateLimits', + 'minimaxCredentials', + 'grokAccounts', + 'codexAccounts', + 'claudeAccounts', + 'cli', + 'agentHooks', + 'macosTccPrompts', + 'codexConfigSync', + 'developerPermissions', + 'computerUsePermissions', + 'updater', + 'shell', + 'skills', + 'pty', + 'ssh', + 'wsl', + 'pwsh', + 'gitBash', + 'agentStatus', + 'mobile', + 'telemetryTrack', + 'telemetrySetOptIn', + 'telemetryGetConsentState', + 'telemetryAcknowledgeBanner' + ]) + expect(Object.keys(globals.window.api.projects)).toEqual([]) + expect(Reflect.get(globals.window.api.projects, 'then')).toBeUndefined() + expect(Object.keys(globals.window.electron)).toEqual([]) + }) + + it('snapshots E2E config before runtime storage initialization', async () => { + const evaluationOrder: string[] = [] + const createE2EConfig = vi.fn((config: unknown) => { + evaluationOrder.push('e2e') + return config + }) + vi.doMock('../../../shared/e2e-config', () => ({ createE2EConfig })) + vi.stubEnv('VITE_EXPOSE_STORE', 'true') + const globals = installBrowserGlobals('Linux') + Object.assign(globals.window.location, { + search: '?orcaE2ETerminalParkingDelayMs=23&orcaE2ETerminalRetentionLimit=7' + }) + const readStored = globals.storage.getItem.bind(globals.storage) + vi.spyOn(globals.storage, 'getItem').mockImplementation((key) => { + evaluationOrder.push(`storage:${key}`) + return readStored(key) + }) + + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + expect(evaluationOrder[0]).toBe('e2e') + expect(createE2EConfig).toHaveBeenCalledWith({ + exposeStore: true, + terminalParkingDelayMs: 23, + terminalRetentionLimit: 7 + }) + expect(globals.window.api.e2e.getConfig()).toEqual({ + exposeStore: true, + terminalParkingDelayMs: 23, + terminalRetentionLimit: 7 + }) + }) +}) diff --git a/src/renderer/src/web/web-preload-api-github.test.ts b/src/renderer/src/web/web-preload-api-github.test.ts index 4f0a8312dd4..3483ab8fd40 100644 --- a/src/renderer/src/web/web-preload-api-github.test.ts +++ b/src/renderer/src/web/web-preload-api-github.test.ts @@ -105,7 +105,8 @@ describe('web GitHub preload API', () => { const globals = installBrowserGlobals('Linux') writeStoredRuntimeEnvironment(globals.storage) - const { GITHUB_WEB_RPC_METHODS, installWebPreloadApi } = await import('./web-preload-api') + const { installWebPreloadApi } = await import('./web-preload-api') + const { GITHUB_WEB_RPC_METHODS } = await import('./preload-api/web-github-routes') installWebPreloadApi() const api = globals.window.api const repoPath = '/workspace/repo' diff --git a/src/renderer/src/web/web-preload-api-gitlab.test.ts b/src/renderer/src/web/web-preload-api-gitlab.test.ts index 899b2f4b596..558c3527773 100644 --- a/src/renderer/src/web/web-preload-api-gitlab.test.ts +++ b/src/renderer/src/web/web-preload-api-gitlab.test.ts @@ -56,7 +56,8 @@ describe('web GitLab preload API', () => { const globals = installBrowserGlobals('Linux') writeStoredRuntimeEnvironment(globals.storage) - const { GITLAB_WEB_RPC_METHODS, installWebPreloadApi } = await import('./web-preload-api') + const { installWebPreloadApi } = await import('./web-preload-api') + const { GITLAB_WEB_RPC_METHODS } = await import('./preload-api/web-gitlab-routes') installWebPreloadApi() const api = globals.window.api const repoPath = '/workspace/repo' diff --git a/src/renderer/src/web/web-preload-api-runtime-calls.test.ts b/src/renderer/src/web/web-preload-api-runtime-calls.test.ts new file mode 100644 index 00000000000..6960ba5b83f --- /dev/null +++ b/src/renderer/src/web/web-preload-api-runtime-calls.test.ts @@ -0,0 +1,206 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import { + installBrowserGlobals, + writeStoredRuntimeEnvironment +} from './web-preload-api-test-harness' + +describe('web preload runtime calls', () => { + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + vi.doUnmock('./web-runtime-client') + }) + + it('preserves success and failure envelopes while persisting response runtime metadata', async () => { + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string): Promise> { + if (method === 'runtime.failure') { + return Promise.resolve({ + id: method, + ok: false, + error: { code: 'remote_failure', message: 'Remote failed', data: { retry: false } }, + _meta: { runtimeId: 'runtime-failure' } + }) + } + return Promise.resolve({ + id: method, + ok: true, + result: { value: 42 }, + _meta: { runtimeId: 'runtime-success' } + }) + } + + close(): void {} + } + })) + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage, 'web-server-a') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await expect(globals.window.api.runtime.call({ method: 'runtime.success' })).resolves.toEqual({ + id: 'runtime.success', + ok: true, + result: { value: 42 }, + _meta: { runtimeId: 'runtime-success' } + }) + expect( + JSON.parse(globals.storage.getItem('orca.web.runtimeEnvironment.v1') ?? '{}') + ).toMatchObject({ runtimeId: 'runtime-success' }) + + await expect(globals.window.api.runtime.call({ method: 'runtime.failure' })).resolves.toEqual({ + id: 'runtime.failure', + ok: false, + error: { code: 'remote_failure', message: 'Remote failed', data: { retry: false } }, + _meta: { runtimeId: 'runtime-failure' } + }) + expect( + JSON.parse(globals.storage.getItem('orca.web.runtimeEnvironment.v1') ?? '{}') + ).toMatchObject({ runtimeId: 'runtime-failure' }) + }) + + it('unwraps domain failures to their message after persisting failure metadata', async () => { + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string): Promise> { + return Promise.resolve({ + id: method, + ok: false, + error: { + code: 'repo_unavailable', + message: 'Repository catalog is unavailable', + data: { host: 'runtime-a' } + }, + _meta: { runtimeId: 'runtime-domain-failure' } + }) + } + + close(): void {} + } + })) + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage, 'web-server-a') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + let rejection: unknown + try { + await globals.window.api.repos.list() + } catch (error) { + rejection = error + } + + expect(rejection).toEqual(new Error('Repository catalog is unavailable')) + if (!(rejection instanceof Error)) { + throw new Error('Expected a domain Error rejection') + } + expect(Reflect.get(rejection, 'code')).toBeUndefined() + expect( + JSON.parse(globals.storage.getItem('orca.web.runtimeEnvironment.v1') ?? '{}') + ).toMatchObject({ runtimeId: 'runtime-domain-failure' }) + }) + + it('surfaces per-environment queue overload without invoking the client', async () => { + let release!: () => void + const blocked = new Promise>((resolve) => { + release = () => + resolve({ + id: 'runtime.blocked', + ok: true, + result: null, + _meta: { runtimeId: 'runtime-a' } + }) + }) + const call = vi.fn(() => blocked) + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call = call + close(): void {} + } + })) + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage, 'web-server-a') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + const accepted = Array.from({ length: 264 }, (_, index) => + globals.window.api.runtime.call({ method: `runtime.blocked.${index}` }) + ) + const overloaded = globals.window.api.runtime.call({ method: 'runtime.overloaded' }) + + await expect(overloaded).rejects.toMatchObject({ + code: 'runtime_rpc_queue_overloaded', + scope: 'selector' + }) + expect(call).toHaveBeenCalledTimes(8) + release() + await expect(Promise.all(accepted)).resolves.toHaveLength(264) + }) + + it('does not spend a selected call timeout while queued and forwards it unchanged', async () => { + vi.useFakeTimers() + let release!: () => void + const blocked = new Promise>((resolve) => { + release = () => + resolve({ + id: 'runtime.blocker', + ok: true, + result: null, + _meta: { runtimeId: 'runtime-a' } + }) + }) + const call = vi.fn((method: string): Promise> => { + if (method.startsWith('runtime.blocker')) { + return blocked + } + return Promise.resolve({ + id: method, + ok: true, + result: 'done', + _meta: { runtimeId: 'runtime-a' } + }) + }) + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call = call + close(): void {} + } + })) + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage, 'web-server-a') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + const blockers = Array.from({ length: 8 }, (_, index) => + globals.window.api.runtime.call({ method: `runtime.blocker.${index}` }) + ) + const queued = globals.window.api.runtimeEnvironments.call({ + selector: 'web-server-a', + method: 'runtime.queued-timeout', + timeoutMs: 25 + }) + let settled = false + void queued.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + + await vi.advanceTimersByTimeAsync(60_000) + expect(settled).toBe(false) + expect(call).toHaveBeenCalledTimes(8) + release() + await Promise.all(blockers) + await expect(queued).resolves.toMatchObject({ ok: true, result: 'done' }) + expect(call).toHaveBeenLastCalledWith('runtime.queued-timeout', undefined, { timeoutMs: 25 }) + }) +}) diff --git a/src/renderer/src/web/web-preload-api-runtime-environment.test.ts b/src/renderer/src/web/web-preload-api-runtime-environment.test.ts index 9917a8e97f4..58ecb5e96c0 100644 --- a/src/renderer/src/web/web-preload-api-runtime-environment.test.ts +++ b/src/renderer/src/web/web-preload-api-runtime-environment.test.ts @@ -525,4 +525,59 @@ describe('web runtime environment identity', () => { message: 'Access grant rejected.' }) }) + + it('replaces API closures on reinstall while retaining the runtime client singleton', async () => { + let clientCount = 0 + const call = vi.fn( + (method: string): Promise> => + Promise.resolve({ + id: method, + ok: true, + result: { method }, + _meta: { runtimeId: 'runtime-a' } + }) + ) + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + constructor() { + clientCount += 1 + } + + call = call + + close(): void {} + } + })) + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage, 'web-server-a') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + const firstApi = globals.window.api + const initialZoom = firstApi.ui.getZoomLevel() + firstApi.ui.setZoomLevel(initialZoom + 1) + await expect(firstApi.runtime.call({ method: 'status.first' })).resolves.toEqual({ + id: 'status.first', + ok: true, + result: { method: 'status.first' }, + _meta: { runtimeId: 'runtime-a' } + }) + expect( + JSON.parse(globals.storage.getItem('orca.web.runtimeEnvironment.v1') ?? '{}') + ).toMatchObject({ runtimeId: 'runtime-a' }) + + installWebPreloadApi() + const secondApi = globals.window.api + await secondApi.runtime.call({ method: 'status.second' }) + await firstApi.runtime.call({ method: 'status.old-capture' }) + + expect(secondApi).not.toBe(firstApi) + expect(secondApi.ui.getZoomLevel()).toBe(initialZoom) + expect(firstApi.ui.getZoomLevel()).toBe(initialZoom + 1) + expect(clientCount).toBe(1) + expect(call.mock.calls.map(([method]) => method)).toEqual([ + 'status.first', + 'status.second', + 'status.old-capture' + ]) + }) }) diff --git a/src/renderer/src/web/web-preload-api-workspace-catalog.test.ts b/src/renderer/src/web/web-preload-api-workspace-catalog.test.ts index 5276e741e52..cb6ca0eb3e8 100644 --- a/src/renderer/src/web/web-preload-api-workspace-catalog.test.ts +++ b/src/renderer/src/web/web-preload-api-workspace-catalog.test.ts @@ -343,6 +343,40 @@ describe('web worktree preload API', () => { ]) }) + it('reuses the worktree catalog cache until disconnect invalidates its runtime owner', async () => { + const calls: string[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string): Promise> { + calls.push(method) + return Promise.resolve({ + id: method, + ok: true, + result: + method === 'status.get' + ? { runtimeId: 'runtime-a', version: '1.0.0' } + : { worktrees: [{ id: 'worktree-a', repoId: 'repo-a', path: '/srv/a' }] }, + _meta: { runtimeId: 'runtime-a' } + }) + } + + close(): void {} + } + })) + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage, 'web-server-a') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await globals.window.api.worktrees.listAll() + await globals.window.api.worktrees.listAll() + await globals.window.api.runtimeEnvironments.disconnect({ selector: 'web-server-a' }) + await globals.window.api.runtimeEnvironments.connect({ selector: 'web-server-a' }) + await globals.window.api.worktrees.listAll() + + expect(calls).toEqual(['worktree.list', 'status.get', 'worktree.list']) + }) + it('preserves runtime-routed detected-worktree host ownership in the compatibility shape', async () => { vi.doMock('./web-runtime-client', () => ({ WebRuntimeClient: class { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index f6b5fbd473a..50a63270d80 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1,881 +1,83 @@ -/* eslint-disable max-lines -- Why: browser-side Electron-preload replacement; compatibility surface centralizes here. */ -import type { - PreloadApi, - PreflightStatus, - RefreshAgentsResult, - NativeChatApi, - NativeChatAppendedMessages -} from '../../../preload/api-types' -import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' -import { parseHostAccessLink } from '../../../shared/remote-pairing-address' -import { verifyRemotePairingRuntimeStatus } from '../../../shared/remote-pairing-verification' -import type { AiVaultDeleteSessionArgs } from '../../../shared/ai-vault-session-deletion' -import type { AiVaultListArgs, AiVaultListResult } from '../../../shared/ai-vault-types' -import type { - AiVaultSessionTitlesArgs, - AiVaultSessionTitlesResult -} from '../../../shared/ai-vault-session-title' -import type { - AiVaultPrepareSessionResumeArgs, - AiVaultPrepareSessionResumeResult -} from '../../../shared/ai-vault-resume-preparation' -import { buildNativeChatUnsubscribe } from '../../../shared/native-chat-stream-unsubscribe' -import type { - ComputerUsePermissionSetupResult, - ComputerUsePermissionStatusResult -} from '../../../shared/computer-use-permissions-types' -import type { SearchResult } from '../../../shared/code-search-types' -import type { DirEntry } from '../../../shared/filesystem-entry-types' -import type { - GlobalSettings, - WorktreeVisibilityDefaults -} from '../../../shared/global-settings-types' -import type { OnboardingState } from '../../../shared/onboarding-state-types' -import type { PersistedUIState } from '../../../shared/persisted-ui-state-types' +import type { PreloadApi } from '../../../preload/api-types' +import type { StatsSummary } from '../../../shared/process-stats-types' +import { createWebE2EApi } from './preload-api/web-e2e-api' import { - omitPairingLocalUiFields, - type PairedUiState, - type PairingLocalUiField -} from '../../../shared/pairing-local-ui-fields' -import type { MemorySnapshot, StatsSummary } from '../../../shared/process-stats-types' -import type { Repo } from '../../../shared/repo-types' -import type { - WorkspaceSessionPatch, - WorkspaceSessionState -} from '../../../shared/workspace-session-state-types' -import type { - ForceDeleteWorktreeBranchResult, - RemoveWorktreeResult -} from '../../../shared/worktree/create-types' -import type { WorkspaceLineage, WorktreeLineage } from '../../../shared/worktree/lineage-types' -import type { DetectedWorktreeListResult, Worktree } from '../../../shared/worktree/types' -import type { SkillDiscoveryResult } from '../../../shared/skills' -import type { SkillFreshnessInventory } from '../../../shared/skill-freshness' -import type { SshConnectionState, SshTarget } from '../../../shared/ssh-types' + createAccountsApi, + createGrokAccountsApi, + createMiniMaxCredentialsApi +} from './preload-api/web-agent-accounts-api' +import { createAgentHooksApi } from './preload-api/web-agent-hooks-api' +import { createWebAgentStatusApi } from './preload-api/web-agent-status-api' +import { createWebAiVaultApi } from './preload-api/web-ai-vault-api' +import { createWebAppApi } from './preload-api/web-app-api' +import { createBrowserApi, createEmulatorApi } from './preload-api/web-browser-api' +import { createCliApi } from './preload-api/web-cli-api' +import { createWebDiagnosticsApi } from './preload-api/web-diagnostics-api' +import { createFallbackProxy, withFallback } from './preload-api/web-fallback-api' +import { createFileApi } from './preload-api/web-filesystem-api' +import { createGitApi } from './preload-api/web-git-api' +import { createWebGithubCacheApi } from './preload-api/web-github-cache-api' +import { createGitHubApi } from './preload-api/web-github-api' +import { createGitLabApi } from './preload-api/web-gitlab-api' import { - getDefaultOnboardingState, - getDefaultSettings, - getDefaultUIState, - getDefaultWorkspaceSession, - getWorktreeCardModeProperties, - normalizeAgentActivityDisplayMode, - normalizeWorktreeCardProperties, - ONBOARDING_FLOW_VERSION -} from '../../../shared/constants' -import { - createDefaultLocalOrcaProfile, - DEFAULT_LOCAL_ORCA_PROFILE_ID -} from '../../../shared/orca-profiles' -import { legacyBaseRefSearchResult } from '../../../shared/base-ref-search-result' -import { EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS } from '../../../shared/pty-delivery-diagnostics' -import { createE2EConfig } from '../../../shared/e2e-config' -import { relativePathInsideRoot } from '../../../shared/cross-platform-path' -import { readRetiredNameRegistryForRepo } from '../../../shared/worktree/retired-name-cache' -import { EMPTY_RETIRED_NAME_REGISTRY } from '../../../shared/worktree/retired-name-registry' -import { - applyPRBotAuthorOverride, - normalizePRBotAuthorOverrides -} from '../../../shared/pr-bot-author-overrides' -import { - LOCAL_EXECUTION_HOST_ID, - normalizeExecutionHostScope, - normalizeExecutionHostId, - parseExecutionHostId, - toRuntimeExecutionHostId, - type ExecutionHostId -} from '../../../shared/execution-host' -import { toRuntimeWorktreeSelector } from '../runtime/runtime-worktree-selector' -import { callAbortableRuntimeEnvironment } from '../runtime/abortable-runtime-environment-call' -import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection' -import { - normalizeTuiAgentArgsRecord, - normalizeTuiAgentEnvRecord -} from '../../../shared/tui-agent-launch-defaults' -import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../shared/auto-rename-branch-from-work-settings' -import { normalizeTerminalCursorStyleDefault } from '../../../shared/terminal-cursor-style-settings' -import { - normalizeOsc52ClipboardDefaultOn, - osc52ClipboardDefaultOnOverridesPersistedOff -} from '../../../shared/osc52-clipboard-settings' -import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes' -import { normalizeUiLanguage } from '../../../shared/ui-language' -import { normalizeUsagePercentageDisplay } from '../../../shared/usage-percentage-display' -import { normalizeStatusBarUsageMode } from '../../../shared/status-bar-usage-mode' -import { - computerAwakeSettingsForMode, - normalizeComputerAwakeMode -} from '../../../shared/computer-awake-mode' -import { normalizeWorktreeVisibilityDefaults } from '../../../shared/external-worktree-visibility' -import type { RateLimitState } from '../../../shared/rate-limit-types' -import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../../../shared/runtime-types' -import { assertFileMutationOwnershipCapability } from '../../../shared/file-mutation-ownership' -import { - findKeybindingConflicts, - formatKeybindingList, - getKeybindingPlatform, - isKeybindingActionId, - normalizeKeybindingArrayForAction, - type KeybindingActionId, - type KeybindingFileDiagnostic, - type KeybindingFileSnapshot, - type KeybindingOverrides, - type KeybindingPlatform -} from '../../../shared/keybindings' -import { - clearStoredWebRuntimeEnvironment, - createStoredWebRuntimeEnvironment, - getPreferredWebPairingOffer, - readStoredWebRuntimeEnvironment, - redactStoredWebRuntimeEnvironment, - saveStoredWebRuntimeEnvironment, - updateStoredEnvironmentRuntimeId, - type StoredWebRuntimeEnvironment -} from './web-runtime-environment' -import { parseWebPairingInput } from './web-pairing' -import { copyClipboardTextViaExecCommand } from './web-clipboard-copy-fallback' -import { WebRuntimeClient } from './web-runtime-client' -import { isWebRuntimeUnauthorizedError } from './web-runtime-client-error' -import { RuntimeRpcCallQueuePool } from '../../../shared/runtime-rpc-call-queue' -import { - assertClipboardTextWriteWithinLimitWithYield, - assertClipboardTextWithinLimitWithYield, - type ReadClipboardTextOptions -} from '../../../shared/clipboard-text' -import { - CLIPBOARD_IMAGE_MAX_BASE64_CHARS, - CLIPBOARD_IMAGE_MAX_PIXELS, - CLIPBOARD_IMAGE_MAX_SOURCE_BYTES, - CLIPBOARD_IMAGE_TOO_LARGE_ERROR, - assertClipboardImageByteLengthWithinLimit, - assertClipboardImageDimensionsWithinLimit -} from '../../../shared/clipboard-image' -import { sanitizeWebRuntimeWorkspaceSession } from './web-workspace-session' -import { - normalizeFeatureInteractions, - type FeatureInteractionId, - type FeatureInteractionState -} from '../../../shared/feature-interactions' -import { normalizeContextualTourIds, type ContextualTourId } from '../../../shared/contextual-tours' -import { translate } from '@/i18n/i18n' -import { translateHostAccessLinkError } from '@/lib/remote-pairing-copy' -import { getDefaultCreateProjectParent } from '@/components/sidebar/create-project-defaults' -import { - parseRuntimeNativeChatReadSessionResult, - parseRuntimeNativeChatTurnLifecycle -} from '@/components/native-chat/native-chat-runtime-contract' -import { createWebFileMutationMethods } from './web-file-mutation-methods' -import { mergeWorkspaceCleanupUIState } from '../../../shared/workspace-cleanup-ui-state' - -const SETTINGS_STORAGE_KEY = 'orca.web.settings.v1' -const UI_STORAGE_KEY = 'orca.web.ui.v1' -const SESSION_STORAGE_KEY = 'orca.web.workspaceSession.v1' -const ONBOARDING_STORAGE_KEY = 'orca.web.onboarding.v1' -const GITHUB_CACHE_STORAGE_KEY = 'orca.web.githubCache.v1' -const KEYBINDINGS_STORAGE_KEY = 'orca.web.keybindings.v1' -// Why: paired web clients lack Electron env/preload state; the E2E build gate keeps URL overrides out of releases. -const webE2EExposeStore = String(import.meta.env.VITE_EXPOSE_STORE) === 'true' -const webE2EQuery = webE2EExposeStore ? new URLSearchParams(window.location.search) : null -const webE2EConfig = createE2EConfig({ - exposeStore: webE2EExposeStore, - terminalParkingDelayMs: Number(webE2EQuery?.get('orcaE2ETerminalParkingDelayMs')) || null, - terminalRetentionLimit: Number(webE2EQuery?.get('orcaE2ETerminalRetentionLimit')) || null -}) -// Why: paired clients need parity for large dev sessions; the runtime default stays capped for lower-level RPC callers. -const WEB_RUNTIME_WORKTREE_LIST_LIMIT = 10_000 -const MAX_CLIPBOARD_IMAGE_BASE64_CHARS = CLIPBOARD_IMAGE_MAX_BASE64_CHARS -export const MAX_CLIPBOARD_IMAGE_SOURCE_BYTES = CLIPBOARD_IMAGE_MAX_SOURCE_BYTES -export const MAX_CLIPBOARD_IMAGE_PIXELS = CLIPBOARD_IMAGE_MAX_PIXELS -export const CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS = 512 * 1024 -export const CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS = 256 * 1024 -const CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS = 30_000 - -let activeEnvironment: StoredWebRuntimeEnvironment | null = readStoredWebRuntimeEnvironment() -let worktreeVisibilityDefaultsRuntimeEnvironmentId: string | null = null -let worktreeVisibilityDefaultsRuntimeValue: WorktreeVisibilityDefaults | null = null -let activeClient: WebRuntimeClient | null = null -let activeClientEnvironmentId: string | null = null -const manuallyDisconnectedEnvironmentIds = new Set() -let cachedWorktrees: { loadedAt: number; worktrees: Worktree[] } | null = null -let cachedDetectedWorktrees: { loadedAt: number; worktrees: Worktree[] } | null = null -const runtimeCallQueuePool = new RuntimeRpcCallQueuePool() - -function blobToBase64(blob: Blob): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onload = () => { - const result = typeof reader.result === 'string' ? reader.result : '' - const commaIndex = result.indexOf(',') - resolve(commaIndex === -1 ? result : result.slice(commaIndex + 1)) - } - reader.onerror = () => reject(reader.error ?? new Error('Failed to read clipboard image')) - reader.readAsDataURL(blob) - }) -} - -function assertClipboardImageBlobWithinLimit(blob: Blob): void { - assertClipboardImageByteLengthWithinLimit(blob.size) -} - -async function convertImageBlobToPng(blob: Blob): Promise { - assertClipboardImageBlobWithinLimit(blob) - const bitmap = await createImageBitmap(blob) - try { - assertClipboardImageDimensionsWithinLimit(bitmap) - const canvas = document.createElement('canvas') - canvas.width = bitmap.width - canvas.height = bitmap.height - const context = canvas.getContext('2d') - if (!context || canvas.width <= 0 || canvas.height <= 0) { - throw new Error('Clipboard image could not be decoded') - } - context.drawImage(bitmap, 0, 0) - return await new Promise((resolve, reject) => { - canvas.toBlob((png) => { - if (!png) { - reject(new Error('Clipboard image could not be encoded as PNG')) - return - } - try { - assertClipboardImageBlobWithinLimit(png) - } catch (error) { - reject(error) - return - } - resolve(png) - }, 'image/png') - }) - } finally { - bitmap.close() - } -} - -async function readClipboardImagePngBase64(): Promise { - const clipboard = navigator.clipboard as - | (Clipboard & { read?: () => Promise }) - | undefined - if (!clipboard?.read) { - return null - } - const items = await clipboard.read() - for (const item of items) { - const imageType = item.types.find((type) => type.startsWith('image/')) - if (!imageType) { - continue - } - const blob = await item.getType(imageType) - assertClipboardImageBlobWithinLimit(blob) - const pngBlob = imageType === 'image/png' ? blob : await convertImageBlobToPng(blob) - return blobToBase64(pngBlob) - } - return null -} - -function invalidateRuntimeWorktreeCaches(): void { - cachedWorktrees = null - cachedDetectedWorktrees = null -} - -type WebSettingsApi = NonNullable -type WebKeybindingsApi = NonNullable -type WebGitHubApi = NonNullable -type WebGitHubResult = Awaited> -type WebRuntimeResultCaller = ( - method: string, - params?: unknown, - timeoutMs?: number -) => Promise -type WebRuntimeEnvelopeCaller = ( - method: string, - params?: unknown, - timeoutMs?: number -) => Promise> -type WebGitHubRouteKey = - | 'repoSlug' - | 'repoUpstream' - | 'prForBranch' - | 'issue' - | 'workItem' - | 'workItemByOwnerRepo' - | 'workItemDetails' - | 'prFileContents' - | 'listIssues' - | 'createIssue' - | 'countWorkItems' - | 'listWorkItems' - | 'prChecks' - | 'prCheckDetails' - | 'rerunPRChecks' - | 'prComments' - | 'setPRCommentReaction' - | 'resolveReviewThread' - | 'setPRFileViewed' - | 'updatePRTitle' - | 'mergePR' - | 'setPRAutoMerge' - | 'updatePRState' - | 'requestPRReviewers' - | 'removePRReviewers' - | 'updateIssue' - | 'addIssueComment' - | 'addPRReviewCommentReply' - | 'addPRReviewComment' - | 'listLabels' - | 'listAssignableUsers' - | 'rateLimit' - | 'listAccessibleProjects' - | 'resolveProjectRef' - | 'listProjectViews' - | 'getProjectViewTable' - | 'projectWorkItemDetailsBySlug' - | 'updateProjectItemField' - | 'clearProjectItemField' - | 'updateIssueBySlug' - | 'updatePullRequestBySlug' - | 'addIssueCommentBySlug' - | 'updateIssueCommentBySlug' - | 'deleteIssueCommentBySlug' - | 'listLabelsBySlug' - | 'listAssignableUsersBySlug' - | 'listIssueTypesBySlug' - | 'updateIssueTypeBySlug' -type WebGitHubRuntimeMethod = - | 'github.repoSlug' - | 'github.repoUpstream' - | 'github.prForBranch' - | 'github.issue' - | 'github.workItem' - | 'github.workItemByOwnerRepo' - | 'github.workItemDetails' - | 'github.prFileContents' - | 'github.listIssues' - | 'github.createIssue' - | 'github.countWorkItems' - | 'github.listWorkItems' - | 'github.prChecks' - | 'github.prCheckDetails' - | 'github.rerunPRChecks' - | 'github.prComments' - | 'github.setPRCommentReaction' - | 'github.resolveReviewThread' - | 'github.setPRFileViewed' - | 'github.updatePRTitle' - | 'github.mergePR' - | 'github.setPRAutoMerge' - | 'github.updatePRState' - | 'github.requestPRReviewers' - | 'github.removePRReviewers' - | 'github.updateIssue' - | 'github.addIssueComment' - | 'github.addPRReviewCommentReply' - | 'github.addPRReviewComment' - | 'github.listLabels' - | 'github.listAssignableUsers' - | 'github.rateLimit' - | 'github.project.listAccessible' - | 'github.project.resolveRef' - | 'github.project.listViews' - | 'github.project.viewTable' - | 'github.project.workItemDetailsBySlug' - | 'github.project.updateItemField' - | 'github.project.clearItemField' - | 'github.project.updateIssueBySlug' - | 'github.project.updatePullRequestBySlug' - | 'github.project.addIssueCommentBySlug' - | 'github.project.updateIssueCommentBySlug' - | 'github.project.deleteIssueCommentBySlug' - | 'github.project.listLabelsBySlug' - | 'github.project.listAssignableUsersBySlug' - | 'github.project.listIssueTypesBySlug' - | 'github.project.updateIssueTypeBySlug' -type WebGitLabApi = NonNullable -type WebGitLabResult = Awaited> -type WebGitLabRouteKey = - | 'diagnoseAuth' - | 'rateLimit' - | 'listMRs' - | 'listWorkItems' - | 'listIssues' - | 'createIssue' - | 'updateIssue' - | 'addIssueComment' - | 'listLabels' - | 'todos' - | 'workItemDetails' - | 'closeMR' - | 'reopenMR' - | 'mergeMR' - | 'updateMR' - | 'updateMRReviewers' - | 'addMRComment' - | 'addMRInlineComment' - | 'resolveMRDiscussion' - | 'jobTrace' - | 'retryJob' - | 'workItemByPath' -type WebGitLabRuntimeMethod = - | 'gitlab.diagnoseAuth' - | 'gitlab.rateLimit' - | 'gitlab.listMRs' - | 'gitlab.listWorkItems' - | 'gitlab.listIssues' - | 'gitlab.createIssue' - | 'gitlab.updateIssue' - | 'gitlab.addIssueComment' - | 'gitlab.listLabels' - | 'gitlab.todos' - | 'gitlab.workItemDetails' - | 'gitlab.updateMRState' - | 'gitlab.mergeMR' - | 'gitlab.updateMR' - | 'gitlab.updateMRReviewers' - | 'gitlab.addMRComment' - | 'gitlab.addMRInlineComment' - | 'gitlab.resolveMRDiscussion' - | 'gitlab.jobTrace' - | 'gitlab.retryJob' - | 'gitlab.workItemByPath' -type WebKeybindingDocument = { - version: 1 - keybindings: KeybindingOverrides - platforms: Partial> -} - -export const GITHUB_WEB_RPC_METHODS = { - repoSlug: 'github.repoSlug', - repoUpstream: 'github.repoUpstream', - prForBranch: 'github.prForBranch', - issue: 'github.issue', - workItem: 'github.workItem', - workItemByOwnerRepo: 'github.workItemByOwnerRepo', - workItemDetails: 'github.workItemDetails', - prFileContents: 'github.prFileContents', - listIssues: 'github.listIssues', - createIssue: 'github.createIssue', - countWorkItems: 'github.countWorkItems', - listWorkItems: 'github.listWorkItems', - prChecks: 'github.prChecks', - prCheckDetails: 'github.prCheckDetails', - rerunPRChecks: 'github.rerunPRChecks', - prComments: 'github.prComments', - setPRCommentReaction: 'github.setPRCommentReaction', - resolveReviewThread: 'github.resolveReviewThread', - setPRFileViewed: 'github.setPRFileViewed', - updatePRTitle: 'github.updatePRTitle', - mergePR: 'github.mergePR', - setPRAutoMerge: 'github.setPRAutoMerge', - updatePRState: 'github.updatePRState', - requestPRReviewers: 'github.requestPRReviewers', - removePRReviewers: 'github.removePRReviewers', - updateIssue: 'github.updateIssue', - addIssueComment: 'github.addIssueComment', - addPRReviewCommentReply: 'github.addPRReviewCommentReply', - addPRReviewComment: 'github.addPRReviewComment', - listLabels: 'github.listLabels', - listAssignableUsers: 'github.listAssignableUsers', - rateLimit: 'github.rateLimit', - listAccessibleProjects: 'github.project.listAccessible', - resolveProjectRef: 'github.project.resolveRef', - listProjectViews: 'github.project.listViews', - getProjectViewTable: 'github.project.viewTable', - projectWorkItemDetailsBySlug: 'github.project.workItemDetailsBySlug', - updateProjectItemField: 'github.project.updateItemField', - clearProjectItemField: 'github.project.clearItemField', - updateIssueBySlug: 'github.project.updateIssueBySlug', - updatePullRequestBySlug: 'github.project.updatePullRequestBySlug', - addIssueCommentBySlug: 'github.project.addIssueCommentBySlug', - updateIssueCommentBySlug: 'github.project.updateIssueCommentBySlug', - deleteIssueCommentBySlug: 'github.project.deleteIssueCommentBySlug', - listLabelsBySlug: 'github.project.listLabelsBySlug', - listAssignableUsersBySlug: 'github.project.listAssignableUsersBySlug', - listIssueTypesBySlug: 'github.project.listIssueTypesBySlug', - updateIssueTypeBySlug: 'github.project.updateIssueTypeBySlug' -} as const satisfies Record - -export const GITLAB_WEB_RPC_METHODS = { - diagnoseAuth: 'gitlab.diagnoseAuth', - rateLimit: 'gitlab.rateLimit', - listMRs: 'gitlab.listMRs', - listWorkItems: 'gitlab.listWorkItems', - listIssues: 'gitlab.listIssues', - createIssue: 'gitlab.createIssue', - updateIssue: 'gitlab.updateIssue', - addIssueComment: 'gitlab.addIssueComment', - listLabels: 'gitlab.listLabels', - todos: 'gitlab.todos', - workItemDetails: 'gitlab.workItemDetails', - closeMR: 'gitlab.updateMRState', - reopenMR: 'gitlab.updateMRState', - mergeMR: 'gitlab.mergeMR', - updateMR: 'gitlab.updateMR', - updateMRReviewers: 'gitlab.updateMRReviewers', - addMRComment: 'gitlab.addMRComment', - addMRInlineComment: 'gitlab.addMRInlineComment', - resolveMRDiscussion: 'gitlab.resolveMRDiscussion', - jobTrace: 'gitlab.jobTrace', - retryJob: 'gitlab.retryJob', - workItemByPath: 'gitlab.workItemByPath' -} as const satisfies Record - -const WEB_KEYBINDING_PLATFORMS: readonly KeybindingPlatform[] = ['darwin', 'linux', 'win32'] -const webKeybindingListeners = new Set<(snapshot: KeybindingFileSnapshot) => void>() + createComputerUsePermissionsApi, + createDeveloperPermissionsApi, + createPreflightApi, + createSkillsApi +} from './preload-api/web-host-capability-api' +import { createWebKeybindingsApi } from './preload-api/web-keybindings-api' +import { createMacosTccPromptsApi } from './preload-api/web-macos-tcc-api' +import { createEmptyMemorySnapshot } from './preload-api/web-memory-api' +import { createWebMobileApi } from './preload-api/web-mobile-api' +import { createWebNativeChatApi } from './preload-api/web-native-chat-api' +import { createNotificationsApi } from './preload-api/web-notifications-api' +import { createWebOnboardingApi } from './preload-api/web-onboarding-api' +import { createWebOrcaProfilesApi } from './preload-api/web-orca-profiles-api' +import { createWebPlatformApi } from './preload-api/web-platform-api' +import { createRateLimitsApi } from './preload-api/web-rate-limits-api' +import { createReposApi } from './preload-api/web-repositories-api' +import { createHooksApi, createRuntimeNamespaceApi } from './preload-api/web-review-api' +import { callRuntimeResult } from './preload-api/web-runtime-calls' +import { createWebRuntimeApi } from './preload-api/web-runtime-api' +import { createRuntimeEnvironmentsApi } from './preload-api/web-runtime-environments-api' +import { webRuntimeState } from './preload-api/web-runtime-session' +import { createWebSettingsApi } from './preload-api/web-settings-api' +import { createShellApi } from './preload-api/web-shell-api' +import { createWebStarNagApi } from './preload-api/web-star-nag-api' +import { createWebTelemetryApi } from './preload-api/web-telemetry-api' +import { createPtyApi, createSshApi } from './preload-api/web-terminal-api' +import { createWebUiApi } from './preload-api/web-ui-api' +import { createUpdaterApi } from './preload-api/web-updater-api' +import { createWebWorkspacePortsApi } from './preload-api/web-workspace-ports-api' +import { createWebWorkspaceSessionApi } from './preload-api/web-workspace-session-api' +import { createWorktreesApi } from './preload-api/web-worktrees-api' +import { readStoredWebRuntimeEnvironment } from './web-runtime-environment' export function installWebPreloadApi(): void { - activeEnvironment = readStoredWebRuntimeEnvironment() + webRuntimeState.activeEnvironment = readStoredWebRuntimeEnvironment() const webWindow = window as unknown as { __ORCA_WEB_CLIENT__?: boolean } webWindow.__ORCA_WEB_CLIENT__ = true window.electron = createFallbackProxy(['electron']) as Window['electron'] window.api = withFallback(createWebPreloadApi(), []) as PreloadApi } -async function writeWebClipboardText(text: string): Promise { - await assertClipboardTextWriteWithinLimitWithYield(text) - const clipboard = navigator.clipboard - if (typeof clipboard?.writeText === 'function') { - try { - await clipboard.writeText(text) - return - } catch (error) { - // Preserve the current user-activation turn for the synchronous fallback. - if (copyClipboardTextViaExecCommand(text)) { - return - } - throw error - } - } - if (!copyClipboardTextViaExecCommand(text)) { - throw new Error('Clipboard write is unavailable in this browser context') - } -} - function createWebPreloadApi(): Partial { - const webOrcaProfileAuthStatus = () => - Promise.resolve({ - activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, - configured: false, - state: 'unconfigured' as const, - persistence: 'none' as const, - setupMessage: 'Orca Cloud sign-in is not available in the browser fallback.' - }) - return { - app: { - getIdentity: () => - Promise.resolve({ - name: 'Orca', - isDev: false, - devLabel: null, - devBranch: null, - devWorktreeName: null, - devRepoRoot: null, - dockBadgeLabel: null - }), - getFeatureWallAssetBaseUrl: () => Promise.resolve('/'), - relaunch: () => Promise.resolve(window.location.reload()), - restart: () => Promise.resolve(window.location.reload()), - reload: () => Promise.resolve(window.location.reload()), - stageBeforeUnloadSync: ({ sessions, ui }) => { - // Why: beforeunload cannot await the paired runtime, so the web adapter - // guarantees immediate browser-local durability for the final snapshot. - for (const { state, hostId } of sessions) { - writeJson(sessionStorageKeyForHost(hostId), sanitizeWebRuntimeWorkspaceSession(state)) - } - writeJson(UI_STORAGE_KEY, mergeWebUIState(readLocalWebUIState(), ui)) - }, - // Staging already wrote through to browser storage, so there is nothing left to join. - awaitBeforeUnloadCheckpoint: () => Promise.resolve(), - awaitFirstWindowStartupServices: () => Promise.resolve(), - recoverLegacyWorkerTerminalsForRendererStartup: () => Promise.resolve(), - startupDiagnostic: () => Promise.resolve(), - getKeyboardInputSourceId: () => Promise.resolve(null), - // The web client cannot inspect local Mission Control shortcuts. - getMacCapturedDigitRowChords: () => Promise.resolve([]), - getKeyboardLayoutSnapshot: () => Promise.resolve(null), - onKeyboardLayoutChanged: () => () => undefined, - setUnreadDockBadgeCount: () => Promise.resolve(), - getFloatingTerminalCwd: () => Promise.resolve(''), - getFloatingMarkdownDirectory: () => Promise.resolve(''), - pickFloatingMarkdownDocument: () => Promise.resolve(null), - pickFloatingWorkspaceDirectory: () => Promise.resolve(null), - // Browser fallback has no app-owned userData dir; reject so the sentinel can't claim sensitive evidence was persisted. - writeTerminalRenderDesyncEvidence: () => - Promise.reject( - new Error('Terminal render evidence is unavailable in the browser fallback.') - ) - }, - starNag: { - onShow: () => noopUnsubscribe, - onHide: () => noopUnsubscribe, - dismiss: () => Promise.resolve(), - later: () => Promise.resolve(), - complete: () => Promise.resolve(), - disable: () => Promise.resolve(), - openWeb: () => Promise.resolve(), - starOrca: () => Promise.resolve(false), - forceShow: () => Promise.resolve(), - agentValueMoment: () => Promise.resolve({ status: 'skipped' }), - showAgentValueMoment: () => Promise.resolve(), - onboardingCompleted: () => Promise.resolve() - }, - platform: { - get: () => ({ - platform: getBrowserPlatform(), - osRelease: '', - arch: '', - shell: '', - displayServer: null - }) - }, - workspacePorts: { - // Why: browser-local workspaces have no host process to inspect; return capability state instead of the generic undefined fallback. - scan: () => - Promise.resolve({ - platform: getBrowserPlatform(), - scannedAt: Date.now(), - ports: [], - unavailableReason: 'Workspace port scanning is unavailable for browser-local workspaces.' - }), - kill: () => - Promise.resolve({ - ok: false, - reason: 'Workspace port management is unavailable for browser-local workspaces.' - }), - onAdvertisedUrlChanged: () => noopUnsubscribe - }, - orcaProfiles: { - list: () => - Promise.resolve({ - activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, - profiles: [createDefaultLocalOrcaProfile(0)], - multiProfileUi: false - }), - authStatus: webOrcaProfileAuthStatus, - createLocal: () => - Promise.resolve({ - activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, - profiles: [createDefaultLocalOrcaProfile(0)], - profile: createDefaultLocalOrcaProfile(0) - }), - createCloudLinked: async () => ({ - status: 'unconfigured', - auth: await webOrcaProfileAuthStatus() - }), - switchProfile: () => Promise.resolve({ status: 'already-active' }), - transferProject: (args) => - Promise.resolve({ - status: 'duplicate-target', - sourceProfileId: args.sourceProfileId, - targetProfileId: args.targetProfileId, - sourceRepoId: args.repoId, - duplicateRepoId: args.repoId - }), - findProjectProfiles: async () => ({ projects: [] }), - connectCurrent: async () => ({ - status: 'unconfigured', - auth: await webOrcaProfileAuthStatus() - }), - refreshAuth: async () => ({ - status: 'unconfigured', - auth: await webOrcaProfileAuthStatus() - }), - signOutCurrent: async () => ({ - status: 'signed-out', - auth: await webOrcaProfileAuthStatus(), - activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, - profiles: [createDefaultLocalOrcaProfile(0)] - }), - selectOrg: async () => ({ - status: 'unconfigured', - auth: await webOrcaProfileAuthStatus() - }), - orgMembersList: async () => ({ status: 'unconfigured' }), - orgMemberInvite: async () => ({ status: 'unconfigured' }), - orgInviteRevoke: async () => ({ status: 'unconfigured' }), - orgMemberChangeRole: async () => ({ status: 'unconfigured' }), - orgMemberRemove: async () => ({ status: 'unconfigured' }) - }, - e2e: { - getConfig: () => webE2EConfig - }, - settings: { - get: async () => getRuntimeBackedStoredSettings(), - // Why: localStorage-backed settings are synchronous, so the pre-hydration kill-switch read works the same as desktop. - getSync: () => settingsForActiveVisibilityOwner(getStoredSettings()), - set: async (updates) => { - const sanitizedUpdates = { ...updates } - const runtimeEnvironment = requireActiveEnvironmentOrNull() - delete sanitizedUpdates.activeRuntimeEnvironmentId - if ( - 'worktreeVisibilityDefaults' in sanitizedUpdates && - runtimeEnvironment && - runtimeEnvironment.id !== worktreeVisibilityDefaultsRuntimeEnvironmentId - ) { - delete sanitizedUpdates.worktreeVisibilityDefaults - } - if ('worktreeVisibilityDefaults' in sanitizedUpdates) { - sanitizedUpdates.worktreeVisibilityDefaults = { - ...settingsForActiveVisibilityOwner(getStoredSettings()).worktreeVisibilityDefaults, - ...sanitizedUpdates.worktreeVisibilityDefaults - } - } - if ('computerAwakeMode' in sanitizedUpdates) { - Object.assign( - sanitizedUpdates, - computerAwakeSettingsForMode( - normalizeComputerAwakeMode( - sanitizedUpdates.computerAwakeMode, - sanitizedUpdates.keepComputerAwakeWhileAgentsRun - ) - ) - ) - } else if ('keepComputerAwakeWhileAgentsRun' in sanitizedUpdates) { - Object.assign( - sanitizedUpdates, - computerAwakeSettingsForMode( - sanitizedUpdates.keepComputerAwakeWhileAgentsRun ? 'auto' : 'off' - ) - ) - } - if ('autoRenameBranchFromWorkDefaultedOn' in sanitizedUpdates) { - sanitizedUpdates.autoRenameBranchFromWorkDefaultedOn = true - } - if ('terminalCursorStyle' in sanitizedUpdates) { - Object.assign( - sanitizedUpdates, - normalizeTerminalCursorStyleDefault( - { terminalCursorStyle: sanitizedUpdates.terminalCursorStyle }, - { preserveExplicitValue: true } - ) - ) - } - const localUpdates = { ...sanitizedUpdates } - if (runtimeEnvironment) { - delete localUpdates.worktreeVisibilityDefaults - } - const next = mergeSettings(getStoredSettings(), localUpdates, { - preserveAutoRenameBranchFromWorkUpdate: 'autoRenameBranchFromWork' in sanitizedUpdates - }) - writeStoredSettings(next) - return settingsForActiveVisibilityOwner( - await syncRuntimeBackedSettings(sanitizedUpdates, next) - ) - }, - setActiveRuntimeEnvironmentPreference: async ({ environmentId }) => { - const requestedEnvironmentId = environmentId?.trim() || null - const activeRuntimeEnvironmentId = requestedEnvironmentId - ? resolveEnvironment(requestedEnvironmentId).id - : null - const next = mergeSettings(getStoredSettings(), { - activeRuntimeEnvironmentId - }) - writeStoredSettings(next, activeRuntimeEnvironmentId) - return next - }, - updatePRBotAuthorOverride: (args) => updateRuntimePRBotAuthorOverride(args), - listFonts: () => Promise.resolve([]), - onChanged: () => noopUnsubscribe - } satisfies Partial as unknown as WebSettingsApi, - agentAwake: { - getStatus: async () => { - const settings = getStoredSettings() - return { - mode: normalizeComputerAwakeMode( - settings.computerAwakeMode, - settings.keepComputerAwakeWhileAgentsRun - ), - active: false - } - }, - onChanged: () => noopUnsubscribe - }, + ...createWebAppApi(), + ...createWebStarNagApi(), + ...createWebPlatformApi(), + ...createWebWorkspacePortsApi(), + ...createWebOrcaProfilesApi(), + ...createWebE2EApi(), + ...createWebSettingsApi(), keybindings: createWebKeybindingsApi(), ui: createWebUiApi(), - crashReports: { - getLatestPending: () => Promise.resolve(null), - getLatestReport: () => Promise.resolve(null), - dismiss: () => Promise.resolve(null), - recordRendererError: () => Promise.resolve({ ok: true, report: null, deduped: true }), - recordBreadcrumb: () => {}, - submit: () => - Promise.resolve({ - ok: false, - status: null, - error: translate('auto.web.web.preload.api.fb290366b2', 'Unavailable on web.') - }), - copyLatestDiagnostics: () => - Promise.resolve({ - ok: false, - error: translate('auto.web.web.preload.api.fb290366b2', 'Unavailable on web.') - }), - // Why: no Electron process on web; the caller falls back to performance.memory. - readHeapStatistics: () => null - }, - diagnostics: { - getStatus: () => - Promise.resolve({ - localFileEnabled: false, - bundleEnabled: false, - traceFilePath: '', - traceFamilySize: 0 - }), - collectBundle: () => Promise.reject(new Error('Review files are unavailable on web.')), - openBundlePreview: () => Promise.reject(new Error('Review files are unavailable on web.')), - discardBundlePreview: () => Promise.resolve(), - uploadBundle: () => Promise.reject(new Error('Sending diagnostics is unavailable on web.')), - deleteBundle: () => Promise.reject(new Error('Sent diagnostics are unavailable on web.')) - }, - session: { - // Mirrors desktop bridge: non-local hosts persist under a host-suffixed key so their sessions stay isolated from local. - get: (hostId) => Promise.resolve(getStoredWorkspaceSession(hostId)), - set: async (session, hostId) => { - writeJson(sessionStorageKeyForHost(hostId), sanitizeWebRuntimeWorkspaceSession(session)) - }, - patch: async (patch: WorkspaceSessionPatch, hostId) => { - writeJson( - sessionStorageKeyForHost(hostId), - sanitizeWebRuntimeWorkspaceSession({ - ...getStoredWorkspaceSession(hostId), - ...patch - }) - ) - }, - // localStorage writes synchronously, so there is no deferred web flush. - flush: async () => {}, - readTerminalScrollback: () => null, - setSync: (session, hostId) => { - writeJson(sessionStorageKeyForHost(hostId), sanitizeWebRuntimeWorkspaceSession(session)) - } - }, - onboarding: { - get: () => Promise.resolve(getStoredOnboarding()), - update: async (updates) => { - const current = getStoredOnboarding() - const next: OnboardingState = { - ...current, - ...updates, - flowVersion: ONBOARDING_FLOW_VERSION, - checklist: { - ...current.checklist, - ...updates.checklist - } - } - writeJson(ONBOARDING_STORAGE_KEY, next) - return next - } - }, - cache: { - getGitHub: () => - Promise.resolve( - readJson(GITHUB_CACHE_STORAGE_KEY, { - pr: {}, - issue: {} - }) - ), - setGitHub: async ({ cache }) => { - writeJson(GITHUB_CACHE_STORAGE_KEY, cache) - } - }, - runtime: createRuntimeApi(), - nativeChat: createNativeChatApi(), + ...createWebDiagnosticsApi(), + ...createWebWorkspaceSessionApi(), + ...createWebOnboardingApi(), + ...createWebGithubCacheApi(), + runtime: createWebRuntimeApi(), + nativeChat: createWebNativeChatApi(), runtimeEnvironments: createRuntimeEnvironmentsApi(), repos: createReposApi(), worktrees: createWorktreesApi(), @@ -900,7 +102,7 @@ function createWebPreloadApi(): Partial { memory: { getSnapshot: () => Promise.resolve(createEmptyMemorySnapshot()) }, - aiVault: createAiVaultApi(), + aiVault: createWebAiVaultApi(), preflight: createPreflightApi(), notifications: createNotificationsApi(), rateLimits: createRateLimitsApi(), @@ -911,8 +113,6 @@ function createWebPreloadApi(): Partial { cli: createCliApi(), agentHooks: createAgentHooksApi(), macosTccPrompts: createMacosTccPromptsApi(), - // Why: the desktop derives this from the host filesystem, which the web - // client has no view of; reporting synced keeps the warning banner silent. codexConfigSync: { status: () => Promise.resolve({ state: 'synced', reason: null, systemConfigPath: '' } as const) @@ -934,3663 +134,8 @@ function createWebPreloadApi(): Partial { gitBash: { isAvailable: () => callRuntimeResult('host.gitBash.isAvailable').catch(() => false) }, - agentStatus: { - onSet: () => noopUnsubscribe, - onClear: () => noopUnsubscribe, - getSnapshot: () => Promise.resolve([]), - inferInterrupt: () => Promise.resolve(false), - inferQuestionAnswered: () => Promise.resolve(false), - onMigrationUnsupported: () => noopUnsubscribe, - onMigrationUnsupportedClear: () => noopUnsubscribe, - onLegacyWorkerTerminalRecovery: () => noopUnsubscribe, - getMigrationUnsupportedSnapshot: () => Promise.resolve([]), - drop: () => {}, - dropByTabPrefix: () => {}, - retirePaneAuthority: () => {}, - restorePaneAuthority: () => {}, - transferPaneAuthority: () => {} - }, - mobile: { - listNetworkInterfaces: () => Promise.resolve({ interfaces: [] }), - getPairingQR: () => Promise.resolve({ available: false }), - getWindowsFirewallStatus: () => Promise.resolve({ supported: false }), - repairWindowsFirewall: () => Promise.resolve({ ok: false, reason: 'unsupported' }), - openWindowsNetworkSettings: () => Promise.resolve(false), - getRuntimePairingUrl: () => Promise.resolve({ available: false }), - listDevices: () => Promise.resolve({ devices: [] }), - revokeDevice: () => Promise.resolve({ revoked: false }), - listRuntimeAccessGrants: () => Promise.resolve({ grants: [] }), - revokeRuntimeAccess: () => Promise.resolve({ revoked: false }), - isWebSocketReady: () => - Promise.resolve({ ready: Boolean(activeEnvironment), endpoint: null }), - getRelayStatus: () => Promise.resolve({ status: 'offline' as const }), - onRelayStatusChanged: () => noopUnsubscribe, - consumePendingUnpairedDeviceAuthFailure: () => Promise.resolve(false), - onUnpairedDeviceAuthFailure: () => noopUnsubscribe - }, - telemetryTrack: () => Promise.resolve(), - telemetrySetOptIn: () => Promise.resolve(), - telemetryGetConsentState: () => - Promise.resolve({ optedIn: false, source: 'default', blockedByEnv: false } as never), - telemetryAcknowledgeBanner: () => Promise.resolve() + ...createWebAgentStatusApi(), + ...createWebMobileApi(), + ...createWebTelemetryApi() } } - -function createEmptyWebKeybindingDocument(): WebKeybindingDocument { - return { - version: 1, - keybindings: {}, - platforms: { - darwin: {}, - linux: {}, - win32: {} - } - } -} - -function getWebKeybindingPlatform(): KeybindingPlatform { - return getKeybindingPlatform(getBrowserPlatform()) -} - -function isJsonObject(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value) -} - -function normalizeStoredWebOverrides( - value: unknown, - section: string, - diagnostics: KeybindingFileDiagnostic[] -): KeybindingOverrides { - if (value === undefined) { - return {} - } - if (!isJsonObject(value)) { - diagnostics.push({ - severity: 'error', - section, - message: translate('auto.web.web.preload.api.d2e43e426a', '{{value0}} must be an object.', { - value0: section - }) - }) - return {} - } - - const overrides: KeybindingOverrides = {} - for (const [actionId, rawBindings] of Object.entries(value)) { - if (!isKeybindingActionId(actionId)) { - diagnostics.push({ - severity: 'warning', - section, - actionId, - message: translate( - 'auto.web.web.preload.api.36761d9604', - 'Unknown keybinding action "{{value0}}" was ignored.', - { value0: actionId } - ) - }) - continue - } - if ( - !Array.isArray(rawBindings) || - !rawBindings.every((binding) => typeof binding === 'string') - ) { - diagnostics.push({ - severity: 'error', - section, - actionId, - message: translate( - 'auto.web.web.preload.api.10898045f3', - 'Shortcut for "{{value0}}" was ignored: Use a string array.', - { value0: actionId } - ) - }) - continue - } - const normalized = normalizeKeybindingArrayForAction(actionId, rawBindings) - if (!Array.isArray(normalized)) { - const error = normalized.ok ? 'Unable to parse shortcut.' : normalized.error - diagnostics.push({ - severity: 'error', - section, - actionId, - message: translate( - 'auto.web.web.preload.api.76122208ca', - 'Shortcut for "{{value0}}" was ignored: {{value1}}', - { value0: actionId, value1: error } - ) - }) - continue - } - overrides[actionId] = normalized - } - return overrides -} - -function normalizeWebPlatformOverrides( - value: unknown, - diagnostics: KeybindingFileDiagnostic[] -): Partial> { - if (value === undefined) { - return {} - } - if (!isJsonObject(value)) { - diagnostics.push({ - severity: 'error', - section: 'platforms', - message: translate( - 'auto.web.web.preload.api.0a69fcd8bc', - 'platforms must be an object with darwin, linux, or win32 sections.' - ) - }) - return {} - } - - const result: Partial> = {} - for (const [platform, overrides] of Object.entries(value)) { - if (!WEB_KEYBINDING_PLATFORMS.includes(platform as KeybindingPlatform)) { - diagnostics.push({ - severity: 'warning', - section: `platforms.${platform}`, - message: translate( - 'auto.web.web.preload.api.32f15bdb0f', - 'Unknown platform "{{value0}}" was ignored.', - { value0: platform } - ) - }) - continue - } - result[platform as KeybindingPlatform] = normalizeStoredWebOverrides( - overrides, - `platforms.${platform}`, - diagnostics - ) - } - return result -} - -function removeConflictingWebOverrides( - platform: KeybindingPlatform, - overrides: KeybindingOverrides, - diagnostics: KeybindingFileDiagnostic[] -): KeybindingOverrides { - let next = { ...overrides } - for (let attempt = 0; attempt < 20; attempt++) { - const conflicts = findKeybindingConflicts(platform, next) - const conflictingOverrides = new Set() - for (const conflict of conflicts) { - for (const actionId of conflict.actionIds) { - if (Object.hasOwn(next, actionId)) { - conflictingOverrides.add(actionId) - } - } - } - if (conflictingOverrides.size === 0) { - return next - } - for (const actionId of conflictingOverrides) { - delete next[actionId] - } - diagnostics.push({ - severity: 'error', - message: translate( - 'auto.web.web.preload.api.52bee9d8a0', - 'Conflicting custom shortcuts were ignored: {{value0}}.', - { - value0: Array.from(conflictingOverrides) - .map((actionId) => actionId) - .join(', ') - } - ) - }) - } - return next -} - -function readWebKeybindingDocument(): WebKeybindingDocument { - const document = readJson(KEYBINDINGS_STORAGE_KEY, createEmptyWebKeybindingDocument()) - return { - version: 1, - keybindings: isJsonObject(document.keybindings) - ? (document.keybindings as KeybindingOverrides) - : {}, - platforms: isJsonObject(document.platforms) - ? (document.platforms as Partial>) - : {} - } -} - -function getWebKeybindingSnapshot(): KeybindingFileSnapshot { - const platform = getWebKeybindingPlatform() - const diagnostics: KeybindingFileDiagnostic[] = [] - const document = readWebKeybindingDocument() - const commonOverrides = normalizeStoredWebOverrides( - document.keybindings, - 'keybindings', - diagnostics - ) - const platformOverrides = normalizeWebPlatformOverrides(document.platforms, diagnostics) - const overrides = removeConflictingWebOverrides( - platform, - { - ...commonOverrides, - ...platformOverrides[platform] - }, - diagnostics - ) - - return { - path: 'Browser local storage', - platform, - exists: window.localStorage.getItem(KEYBINDINGS_STORAGE_KEY) !== null, - overrides, - commonOverrides, - platformOverrides, - diagnostics - } -} - -function writeWebKeybindingAction( - actionId: KeybindingActionId, - bindings: string[] | null -): KeybindingFileSnapshot { - if (!isKeybindingActionId(actionId)) { - throw new Error(`Unknown keybinding action "${String(actionId)}".`) - } - const normalizedBindings = - bindings === null ? null : normalizeKeybindingArrayForAction(actionId, bindings) - if (normalizedBindings !== null && !Array.isArray(normalizedBindings)) { - throw new Error(normalizedBindings.ok ? 'Unable to parse shortcut.' : normalizedBindings.error) - } - - const platform = getWebKeybindingPlatform() - const currentSnapshot = getWebKeybindingSnapshot() - const candidateOverrides = { ...currentSnapshot.overrides } - if (normalizedBindings === null) { - delete candidateOverrides[actionId] - } else { - candidateOverrides[actionId] = normalizedBindings - } - const blockingConflict = findKeybindingConflicts(platform, candidateOverrides).find((conflict) => - conflict.actionIds.includes(actionId) - ) - if (blockingConflict) { - throw new Error( - `${formatKeybindingList([blockingConflict.binding], platform)} conflicts with another shortcut.` - ) - } - - const activePlatform: KeybindingOverrides = { ...currentSnapshot.platformOverrides[platform] } - if (normalizedBindings === null) { - delete activePlatform[actionId] - } else { - activePlatform[actionId] = normalizedBindings - } - - writeJson(KEYBINDINGS_STORAGE_KEY, { - version: 1, - keybindings: currentSnapshot.commonOverrides, - platforms: { - ...currentSnapshot.platformOverrides, - darwin: currentSnapshot.platformOverrides.darwin ?? {}, - linux: currentSnapshot.platformOverrides.linux ?? {}, - win32: currentSnapshot.platformOverrides.win32 ?? {}, - [platform]: activePlatform - } - } satisfies WebKeybindingDocument) - - const snapshot = getWebKeybindingSnapshot() - notifyWebKeybindingListeners(snapshot) - return snapshot -} - -function notifyWebKeybindingListeners(snapshot: KeybindingFileSnapshot): void { - for (const listener of webKeybindingListeners) { - listener(snapshot) - } -} - -function createWebKeybindingsApi(): WebKeybindingsApi { - return { - get: () => Promise.resolve(getWebKeybindingSnapshot()), - ensureFile: () => Promise.resolve(getWebKeybindingSnapshot()), - setAction: async ({ actionId, bindings }) => writeWebKeybindingAction(actionId, bindings), - reload: () => { - const snapshot = getWebKeybindingSnapshot() - notifyWebKeybindingListeners(snapshot) - return Promise.resolve(snapshot) - }, - openFile: () => Promise.resolve(getWebKeybindingSnapshot()), - revealFile: () => Promise.resolve(getWebKeybindingSnapshot()), - onChanged: (callback) => { - webKeybindingListeners.add(callback) - const onStorage = (event: StorageEvent): void => { - if (event.key === KEYBINDINGS_STORAGE_KEY) { - callback(getWebKeybindingSnapshot()) - } - } - window.addEventListener('storage', onStorage) - return () => { - webKeybindingListeners.delete(callback) - window.removeEventListener('storage', onStorage) - } - } - } -} - -// Why: web has no IPC for native-chat transcripts, so route readSession/subscribe through runtime RPC (as mobile does). -function createNativeChatApi(): NativeChatApi { - return { - readSession: async (agent, sessionId, limit, transcriptPath) => - parseRuntimeNativeChatReadSessionResult( - await callRuntimeResult('nativeChat.readSession', { - agent, - sessionId, - limit, - transcriptPath - }) - ), - subscribe: (args, onFrame) => { - // No paired runtime yet: return a no-op teardown so the chat view mounts cleanly; only the not-paired case is swallowed. - const environment = requireActiveEnvironmentOrNull() - if (!environment) { - onFrame({ - type: 'snapshot', - messages: [], - hasMore: false, - error: translate( - 'components.native-chat.state.pairHost', - 'Pair a host to view agent chat history.' - ) - }) - return () => {} - } - let handle: { unsubscribe: () => void } | null = null - let cancelled = false - let receivedInitial = false - void getClientForEnvironment(environment) - .subscribe( - 'nativeChat.subscribe', - { - agent: args.agent, - sessionId: args.sessionId, - subscriptionId: args.subscriptionId, - transcriptPath: args.transcriptPath, - limit: args.limit - }, - { - onResponse: (response) => { - if (cancelled) { - return - } - if (!response.ok) { - if (!receivedInitial) { - receivedInitial = true - onFrame({ - type: 'snapshot', - messages: [], - hasMore: false, - error: response.error.message - }) - } - return - } - const result = response.result as { - type?: string - messages?: NativeChatAppendedMessages - hasMore?: boolean - error?: string - lifecycle?: unknown - } - const lifecycle = parseRuntimeNativeChatTurnLifecycle(result?.lifecycle) - if ( - (result?.type === 'appended' || - result?.type === 'snapshot' || - result?.type === 'replacement') && - Array.isArray(result.messages) - ) { - if (!receivedInitial) { - receivedInitial = true - onFrame({ - type: 'snapshot', - messages: result.messages, - hasMore: result.hasMore ?? result.messages.length >= (args.limit ?? 300), - ...(result.error ? { error: result.error } : {}), - ...(lifecycle ? { lifecycle } : {}) - }) - } else if (result.type === 'snapshot') { - onFrame({ - type: 'snapshot', - messages: result.messages, - hasMore: result.hasMore ?? false, - ...(result.error ? { error: result.error } : {}), - ...(lifecycle ? { lifecycle } : {}) - }) - } else { - onFrame( - result.type === 'replacement' - ? { - type: 'replacement', - messages: result.messages, - hasMore: result.hasMore ?? false, - ...(lifecycle ? { lifecycle } : {}) - } - : { - type: 'appended', - messages: result.messages, - ...(lifecycle ? { lifecycle } : {}) - } - ) - } - } else if (!receivedInitial) { - // Why: an unrecognized ok payload never flips receivedInitial, stranding the view on 'loading'; settle it empty instead. - receivedInitial = true - onFrame({ - type: 'snapshot', - messages: [], - hasMore: false, - ...(result?.error ? { error: result.error } : {}) - }) - } - } - }, - { - // Why: unsubscribe reaps the fs-watcher on view-toggle (leak fix); echo the pane token so two panes don't tear down each other's watcher. - buildUnsubscribe: () => - buildNativeChatUnsubscribe(args.agent, args.sessionId, args.subscriptionId) - } - ) - .then((h) => { - if (cancelled) { - h.unsubscribe() - } else { - handle = h - } - }) - .catch((err: unknown) => { - if (!cancelled && !receivedInitial) { - receivedInitial = true - onFrame({ - type: 'snapshot', - messages: [], - hasMore: false, - error: err instanceof Error ? err.message : String(err) - }) - } - }) - return () => { - cancelled = true - handle?.unsubscribe() - } - } - } -} - -function createRuntimeApi(): NonNullable['runtime']> { - return { - syncWindowGraph: async (_graph: RuntimeSyncWindowGraph) => getRemoteRuntimeStatus(), - getStatus: () => getRemoteRuntimeStatus(), - call: ({ method, params }) => callRuntimeEnvelope(method, params), - getTerminalFitOverrides: () => Promise.resolve([]), - getTerminalDrivers: () => Promise.resolve([]), - getBrowserDrivers: () => Promise.resolve([]), - restoreTerminalFit: () => Promise.resolve({ restored: false }), - reclaimBrowserForDesktop: () => Promise.resolve({ reclaimed: false }), - onTerminalFitOverrideChanged: () => noopUnsubscribe, - onTerminalDriverChanged: () => noopUnsubscribe, - onNativeChatLaunchDraftResolved: () => noopUnsubscribe, - onBrowserDriverChanged: () => noopUnsubscribe - } -} - -function createRuntimeEnvironmentsApi(): NonNullable['runtimeEnvironments']> { - return { - list: async () => { - const environment = requireActiveEnvironmentOrNull() - return environment ? [redactStoredWebRuntimeEnvironment(environment)] : [] - }, - addFromPairingCode: async ({ name, pairingCode }) => { - const offer = parseWebPairingInput(pairingCode) - if (!offer) { - throw new Error('Invalid Orca pairing code.') - } - const previousEnvironment = activeEnvironment - closeActiveRuntimeClients() - activeEnvironment = createStoredWebRuntimeEnvironment({ name, offer, previousEnvironment }) - manuallyDisconnectedEnvironmentIds.clear() - saveStoredWebRuntimeEnvironment(activeEnvironment) - return { environment: redactStoredWebRuntimeEnvironment(activeEnvironment) } - }, - verifyAndAddFromPairingCode: async ({ name, pairingCode, allowLoopback }) => { - const parsed = parseHostAccessLink(pairingCode) - if (!parsed.ok) { - return { - ok: false, - kind: 'access-link-invalid', - message: translateHostAccessLinkError(parsed.kind) - } - } - if (parsed.value.endpointKind === 'loopback' && !allowLoopback) { - return { - ok: false, - kind: 'host-unreachable', - message: translate( - 'auto.web.webPreloadApi.loopbackPairingBlocked', - 'This access link points back to this device.' - ) - } - } - let client: WebRuntimeClient | null = null - let runtimeStatus: RuntimeStatus - try { - client = new WebRuntimeClient(parsed.value.pairing) - const response = (await client.call('status.get', undefined, { - timeoutMs: 15_000 - })) as RuntimeRpcResponse - if (!response.ok) { - return { - ok: false, - kind: 'connection-interrupted', - message: response.error.message - } - } - const statusVerification = verifyRemotePairingRuntimeStatus(response.result) - if (!statusVerification.ok) { - return statusVerification - } - runtimeStatus = statusVerification.runtimeStatus - } catch (error) { - if (error instanceof Error && error.message.startsWith('Invalid public key')) { - return { - ok: false, - kind: 'access-link-invalid', - message: translate( - 'auto.web.webPreloadApi.remotePairingInvalidDetails', - 'This access link contains invalid connection details.' - ) - } - } - if ( - isWebRuntimeUnauthorizedError(error) || - (error instanceof Error && error.message.startsWith('Unauthorized.')) - ) { - return { - ok: false, - kind: 'access-link-invalid', - message: error.message - } - } - return { - ok: false, - kind: 'host-unreachable', - message: translate( - 'auto.web.webPreloadApi.remotePairingUnreachable', - 'Cannot reach Orca at {{endpoint}}.', - { endpoint: parsed.value.displayEndpoint } - ) - } - } finally { - client?.close() - } - const usesSshTunnel = parsed.value.endpointKind === 'loopback' && allowLoopback === true - const nextEnvironment = { - ...createStoredWebRuntimeEnvironment({ - name, - offer: parsed.value.pairing, - previousEnvironment: activeEnvironment, - ...(usesSshTunnel ? { connectionDependency: 'ssh-tunnel' as const } : {}) - }), - ...(runtimeStatus.pairedDeviceId ? { pairedDeviceId: runtimeStatus.pairedDeviceId } : {}) - } - // Why: a browser storage failure must leave the currently active host usable. - try { - saveStoredWebRuntimeEnvironment(nextEnvironment) - } catch { - return { - ok: false, - kind: 'environment-save-failed', - message: translate( - 'auto.web.webPreloadApi.remotePairingSaveFailed', - 'Orca verified the host but could not save it. Check browser storage and try again.' - ) - } - } - manuallyDisconnectedEnvironmentIds.clear() - closeActiveRuntimeClients() - activeEnvironment = nextEnvironment - return { - ok: true, - environment: redactStoredWebRuntimeEnvironment(nextEnvironment), - runtimeStatus - } - }, - resolve: async ({ selector }) => - redactStoredWebRuntimeEnvironment(resolveEnvironment(selector)), - remove: async ({ selector }) => { - const environment = resolveEnvironment(selector) - if (activeEnvironment?.id === environment.id) { - removeActiveRuntimeEnvironment() - } - manuallyDisconnectedEnvironmentIds.delete(environment.id) - return { removed: redactStoredWebRuntimeEnvironment(environment) } - }, - disconnect: async ({ selector }) => { - const environment = resolveEnvironment(selector) - if (activeEnvironment?.id === environment.id) { - manuallyDisconnectedEnvironmentIds.add(environment.id) - disconnectActiveRuntimeEnvironment() - } - return { disconnected: redactStoredWebRuntimeEnvironment(environment) } - }, - connect: ({ selector, timeoutMs }) => { - const environment = resolveEnvironment(selector) - manuallyDisconnectedEnvironmentIds.delete(environment.id) - return callEnvironmentEnvelope( - environment.id, - 'status.get', - undefined, - timeoutMs - ) - }, - getStatus: ({ selector, timeoutMs }) => - callEnvironmentEnvelope(selector, 'status.get', undefined, timeoutMs), - call: ({ selector, method, params, timeoutMs }) => - callEnvironmentEnvelope(selector, method, params, timeoutMs), - subscribe: async ({ selector, method, params, timeoutMs }, callbacks) => { - const environment = resolveEnvironment(selector) - const client = getClientForEnvironment(environment) - const subscription = await client.subscribe(method, params, callbacks, { timeoutMs }) - if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { - subscription.unsubscribe() - throw new Error('runtime_manually_disconnected') - } - return subscription - } - } -} - -function createAiVaultApi(): NonNullable['aiVault']> { - return { - listSessions: (args?: AiVaultListArgs) => { - const environment = requireActiveEnvironment() - const executionHostId = toRuntimeExecutionHostId(environment.id) - const requestedScope = normalizeExecutionHostScope( - args?.executionHostScope ?? executionHostId - ) - if (requestedScope !== 'all' && requestedScope !== executionHostId) { - return Promise.resolve(webAiVaultUnavailableResult(requestedScope)) - } - // Why: no local filesystem in the browser, so every history scan runs on and is stamped as the paired runtime host. - return callRuntimeResult('aiVault.listSessions', { - limit: args?.limit, - force: args?.force, - scopePaths: args?.scopePaths, - executionHostId - }) - }, - resolveSessionTitles: (args: AiVaultSessionTitlesArgs) => { - const environment = requireActiveEnvironment() - const executionHostId = toRuntimeExecutionHostId(environment.id) - if ( - args.executionHostScope && - normalizeExecutionHostScope(args.executionHostScope) !== executionHostId - ) { - return Promise.resolve({ titles: [] }) - } - return callRuntimeResult('aiVault.resolveSessionTitles', { - requests: args.requests - }).catch(() => ({ titles: [] })) - }, - // Why: the runtime RPC transport has no cancel verb, so the in-flight scan - // settles on its own timeout. The renderer's refreshId guard already drops - // the late result; this only means web pays for a scan nobody reads. - cancelListSessions: () => Promise.resolve(), - prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs) => - callRuntimeResult('aiVault.prepareSessionResume', args), - // Why: no server-side RPC for subagent transcript listing yet, so report an empty (not erroring) result. - listSubagentSessions: () => Promise.resolve({ sessions: [], issues: [] }), - // Why: full first-prompt re-parse is local-FS only; web/runtime falls back to preview text. - getFirstUserPrompt: () => Promise.resolve({ prompt: null }), - // Why: session deletion is local-only and has no runtime RPC; a web - // client's sessions are runtime-hosted, so report the same non-local - // rejection the UI already gates on rather than pretend to delete. - deleteSession: (args: AiVaultDeleteSessionArgs) => - Promise.resolve({ - outcome: 'rejected', - agent: args.agent, - reason: 'non-local-host' as const - }), - onWindowFocused: () => noopUnsubscribe - } -} - -function webAiVaultUnavailableResult(executionHostId: ExecutionHostId): AiVaultListResult { - return { - sessions: [], - issues: [ - { - executionHostId, - agent: 'codex', - path: executionHostId, - message: translate( - 'auto.web.webPreloadApi.aiVaultUnavailableForHost', - 'Agent Session History is not available for this execution host.' - ) - } - ], - scannedAt: new Date().toISOString() - } -} - -function createReposApi(): NonNullable['repos']> { - return { - list: async () => { - const owned = await callRuntimeResultWithOwner<{ repos: Repo[] }>('repo.list') - return owned.result.repos.map((repo) => withRuntimeRepoOwner(repo, owned.hostId)) - }, - add: async ({ path, kind }) => { - invalidateRuntimeWorktreeCaches() - const owned = await callRuntimeResultWithOwner<{ repo: Repo } | { error: string }>( - 'repo.add', - { path, kind } - ) - return withRuntimeRepoMutationOwner(owned.result, owned.hostId) - }, - remove: async ({ repoId }) => { - await callRuntimeResult('repo.rm', { repo: repoId }) - invalidateRuntimeWorktreeCaches() - }, - // Why: host-scoped forget targets a desktop-owned SSH host; a paired web client has one runtime and no ghost-host state. - removeForHost: () => { - throw new Error('Forgetting a host is unavailable in paired web clients.') - }, - reorder: async ({ orderedIds }) => callRuntimeResult('repo.reorder', { orderedIds }), - // Why: this persists desktop-owned local/SSH rows; paired web clients own one runtime and use repo.reorder directly. - reorderForHost: async () => { - throw new Error('Host-scoped project reordering is unavailable in paired web clients.') - }, - update: async ({ repoId, updates }) => { - const owned = await callRuntimeResultWithOwner<{ repo: Repo }>('repo.update', { - repo: repoId, - updates - }) - return withRuntimeRepoOwner(owned.result.repo, owned.hostId) - }, - pickFolder: () => Promise.resolve(null), - pickFolders: () => Promise.resolve([]), - pickDirectory: () => Promise.resolve(null), - clone: async ({ url, destination }) => { - invalidateRuntimeWorktreeCaches() - const owned = await callRuntimeResultWithOwner<{ repo: Repo }>( - 'repo.clone', - { url, destination }, - 10 * 60_000 - ) - return withRuntimeRepoOwner(owned.result.repo, owned.hostId) - }, - cloneRemote: async () => { - // Why: SSH relay cloning is owned by the desktop main process; paired web clients can't run that local IPC path. - throw new Error('SSH clone is unavailable in paired web clients.') - }, - createRemote: async () => { - // Why: SSH relay project creation is owned by the desktop main process; paired web clients can't use local SSH IPC. - throw new Error('Creating projects on SSH hosts is unavailable in paired web clients.') - }, - cloneAbort: () => Promise.resolve(), - addRemote: async ({ remotePath, displayName, kind }) => { - invalidateRuntimeWorktreeCaches() - const owned = await callRuntimeResultWithOwner<{ repo: Repo }>('repo.add', { - path: remotePath, - kind - }) - const result = { - repo: withRuntimeRepoOwner(owned.result.repo, owned.hostId) - } - if (!displayName) { - return result - } - assertActiveEnvironment(owned.environmentId) - return { - repo: await createReposApi().update({ - repoId: result.repo.id, - updates: { displayName } - }) - } - }, - create: async ({ parentPath, name, kind }) => { - invalidateRuntimeWorktreeCaches() - const owned = await callRuntimeResultWithOwner<{ repo: Repo } | { error: string }>( - 'repo.create', - { parentPath, name, kind } - ) - return withRuntimeRepoMutationOwner(owned.result, owned.hostId) - }, - isGitAvailable: async () => - (await callRuntimeResult<{ available: boolean }>('repo.gitAvailable')).available, - getDefaultCreateProjectParent: async () => { - const result = await callRuntimeResult<{ resolvedPath: string }>('files.browseServerDir', { - path: '~' - }) - return getDefaultCreateProjectParent(result.resolvedPath) - }, - onCloneProgress: () => noopUnsubscribe, - getGitUsername: () => Promise.resolve(''), - getBaseRefDefault: async ({ repoId }) => - callRuntimeResult('repo.baseRefDefault', { repo: repoId }), - searchBaseRefs: async ({ repoId, query, limit }) => - ( - await callRuntimeResult<{ refs: string[] }>('repo.searchRefs', { - repo: repoId, - query, - limit - }) - ).refs, - searchBaseRefDetails: async ({ repoId, query, limit }) => { - const result = await callRuntimeResult<{ - refs: string[] - refDetails?: { refName: string; localBranchName: string }[] - }>('repo.searchRefs', { - repo: repoId, - query, - limit - }) - return result.refDetails ?? result.refs.map(legacyBaseRefSearchResult) - }, - onChanged: () => noopUnsubscribe - } -} - -function createWorktreesApi(): NonNullable['worktrees']> { - return { - list: async ({ repoId }) => { - const owned = await callRuntimeResultWithOwner<{ worktrees: Worktree[] }>('worktree.list', { - repo: repoId, - limit: WEB_RUNTIME_WORKTREE_LIST_LIMIT - }) - return owned.result.worktrees.map((worktree) => - withRuntimeWorktreeOwner(worktree, owned.hostId) - ) - }, - // Why the catch: a host predating this method answers `method_not_found`, and an empty list - // degrades the suggestion to the pre-existing behavior rather than blocking workspace create. - listRetiredNames: async ({ repoId }) => { - try { - return readRetiredNameRegistryForRepo( - await callRuntimeResult('worktree.listRetiredNames', { repo: repoId }), - repoId - ) - } catch { - return EMPTY_RETIRED_NAME_REGISTRY - } - }, - listDetected: async ({ repoId }) => callRuntimeDetectedWorktrees(repoId), - listAll: () => listAllRuntimeWorktrees(), - create: async (args) => { - invalidateRuntimeWorktreeCaches() - const owned = await callRuntimeResultWithOwner<{ worktree: Worktree }>('worktree.create', { - repo: args.repoId, - name: args.name, - // Absent means user-typed, which is what the host must assume — so send it only when true. - ...(args.nameWasGenerated ? { nameWasGenerated: true } : {}), - baseBranch: args.baseBranch, - compareBaseRef: args.compareBaseRef, - branchNameOverride: args.branchNameOverride, - linkedIssue: args.linkedIssue, - linkedPR: args.linkedPR, - linkedLinearIssue: args.linkedLinearIssue, - linkedLinearIssueWorkspaceId: args.linkedLinearIssueWorkspaceId, - linkedLinearIssueOrganizationUrlKey: args.linkedLinearIssueOrganizationUrlKey, - linkedGitLabIssue: args.linkedGitLabIssue, - linkedGitLabMR: args.linkedGitLabMR, - linkedBitbucketPR: args.linkedBitbucketPR, - linkedAzureDevOpsPR: args.linkedAzureDevOpsPR, - linkedGiteaPR: args.linkedGiteaPR, - displayName: args.displayName, - sparseCheckout: args.sparseCheckout, - pushTarget: args.pushTarget, - setupDecision: args.setupDecision, - createdWithAgent: args.createdWithAgent, - pendingFirstAgentMessageRename: args.pendingFirstAgentMessageRename, - ...(args.startup - ? { - startupCommand: args.startup.command, - ...(args.startup.env ? { startupEnv: args.startup.env } : {}), - ...(args.startup.launchConfig - ? { startupLaunchConfig: args.startup.launchConfig } - : {}), - ...(args.startup.startupCommandDelivery - ? { startupCommandDelivery: args.startup.startupCommandDelivery } - : {}), - activate: true - } - : {}), - parentWorkspace: args.parentWorkspace, - workspaceStatus: args.workspaceStatus, - manualOrder: args.manualOrder, - automationProvenanceRequest: args.automationProvenanceRequest - }) - return { - ...owned.result, - worktree: withRuntimeWorktreeOwner(owned.result.worktree, owned.hostId) - } - }, - // Why: adoption verifies a desktop-owned hidden SSH target and is intentionally not a remote-server operation. - adoptProvisionedRoot: () => - Promise.reject( - new Error('Provisioned-root recipes require a direct SSH connection from the desktop app.') - ), - // Why: the runtime create path emits no two-phase progress, so the panel falls back to an indeterminate spinner. - onCreateProgress: () => noopUnsubscribe, - prefetchCreateBase: async ({ repoId, baseBranch }) => { - await callRuntimeResult('worktree.prefetchCreateBase', { - repo: repoId, - baseBranch - }) - }, - resolvePrBase: async ({ repoId, prNumber, headRefName, baseRefName, isCrossRepository }) => - callRuntimeResult('worktree.resolvePrBase', { - repo: repoId, - prNumber, - headRefName, - baseRefName, - isCrossRepository - }), - resolveMrBase: async ({ repoId, mrIid, sourceBranch, targetBranch, isCrossRepository }) => - callRuntimeResult('worktree.resolveMrBase', { - repo: repoId, - mrIid, - sourceBranch, - targetBranch, - isCrossRepository - }), - remove: async ({ worktreeId, hostId, force, allowUnverifiedPtyStop, skipArchive }) => { - invalidateRuntimeWorktreeCaches() - return callRuntimeResult('worktree.rm', { - worktree: toRuntimeWorktreeSelector(worktreeId), - ...(hostId ? { hostId } : {}), - force, - // Why (#11960): the web client renders the same Force Delete affordances, so - // dropping this field here would leave paired clients permanently wedged. - allowUnverifiedPtyStop, - runHooks: skipArchive !== true - }) - }, - // Why: forget-locally clears a desktop workspace pinned to a dead SSH host; a paired web client has no such ghost state. - forgetLocal: () => { - throw new Error('Forgetting a workspace is unavailable in paired web clients.') - }, - forceDeletePreservedBranch: ({ worktreeId, branchName, expectedHead, hostId }) => - callRuntimeResult('worktree.forceDeleteBranch', { - worktree: toRuntimeWorktreeSelector(worktreeId), - branchName, - expectedHead, - ...(hostId ? { hostId } : {}) - }), - updateMeta: async ({ worktreeId, updates }) => { - const rpcUpdates = - Object.hasOwn(updates, 'pushTarget') && updates.pushTarget === undefined - ? { ...updates, pushTarget: null } - : updates - const owned = await callRuntimeResultWithOwner<{ worktree: Worktree }>('worktree.set', { - worktree: toRuntimeWorktreeSelector(worktreeId), - ...rpcUpdates - }) - return withRuntimeWorktreeOwner(owned.result.worktree, owned.hostId) - }, - listLineage: async () => - await callRuntimeResult<{ - lineage: Record - workspaceLineage?: Record - }>('worktree.lineageList'), - updateLineage: async ({ worktreeId, parentWorktreeId, noParent }) => { - invalidateRuntimeWorktreeCaches() - const result = await callRuntimeResult<{ - worktree: Worktree & { lineage?: WorktreeLineage | null } - }>('worktree.set', { - worktree: toRuntimeWorktreeSelector(worktreeId), - parentWorktree: parentWorktreeId, - noParent - }) - return result.worktree.lineage ?? null - }, - persistSortOrder: async ({ orderedIds }) => { - await callRuntimeResult('worktree.persistSortOrder', { orderedIds }) - }, - // Why: the capture lives in desktop main memory, unexposed over pairing; the dialog falls back to the persisted excerpt. - getBranchRenameFailureOutput: async () => null, - onChanged: () => noopUnsubscribe, - onGitStatusMetadataChanged: () => noopUnsubscribe, - onHeadIdentitiesChanged: () => noopUnsubscribe, - onBaseStatus: () => noopUnsubscribe, - onRemoteBranchConflict: () => noopUnsubscribe - } -} - -function createFileApi(): NonNullable['fs']> { - return { - readDir: async ({ dirPath }) => { - const file = await resolveRuntimeFilePath(dirPath) - return callRuntimeResult('files.readDir', { - worktree: toRuntimeWorktreeSelector(file.worktree.id), - relativePath: file.relativePath - }) - }, - readFile: async ({ filePath }) => { - const file = await resolveRuntimeFilePath(filePath) - return callRuntimeResult('files.readPreview', { - worktree: toRuntimeWorktreeSelector(file.worktree.id), - relativePath: file.relativePath - }) - }, - readLocalLogTail: async () => { - throw new Error('Local log tailing is unavailable in paired web clients.') - }, - startLocalLogTail: async () => { - throw new Error('Local log tailing is unavailable in paired web clients.') - }, - stopLocalLogTail: async () => {}, - onLocalLogTailChanged: () => noopUnsubscribe, - downloadFile: async () => { - throw new Error('Remote file download is unavailable in paired web clients.') - }, - downloadFolder: async () => { - throw new Error('Remote folder download is unavailable in paired web clients.') - }, - saveDownloadedFile: async () => { - throw new Error('Remote file download is unavailable in paired web clients.') - }, - startDownloadedFile: async () => { - throw new Error('Remote file download is unavailable in paired web clients.') - }, - appendDownloadedFileChunk: async () => { - throw new Error('Remote file download is unavailable in paired web clients.') - }, - finishDownloadedFile: async () => { - throw new Error('Remote file download is unavailable in paired web clients.') - }, - cancelDownloadedFile: async () => { - throw new Error('Remote file download is unavailable in paired web clients.') - }, - listMarkdownDocuments: async ({ rootPath }) => { - const file = await resolveRuntimeFilePath(rootPath) - return callRuntimeResult('files.listMarkdownDocuments', { - worktree: toRuntimeWorktreeSelector(file.worktree.id) - }) - }, - ...createWebFileMutationMethods({ - captureSession: captureWebFileMutationSession - }), - authorizeExternalPath: () => Promise.resolve(), - stat: async ({ filePath }) => { - const file = await resolveRuntimeFilePath(filePath) - return callRuntimeResult('files.stat', { - worktree: toRuntimeWorktreeSelector(file.worktree.id), - relativePath: file.relativePath - }) - }, - pathExists: async ({ filePath }) => { - try { - const file = await resolveRuntimeFilePath(filePath) - await callRuntimeResult('files.stat', { - worktree: toRuntimeWorktreeSelector(file.worktree.id), - relativePath: file.relativePath - }) - return true - } catch (error) { - if (isMissingPathError(error)) { - return false - } - throw error - } - }, - listFiles: async ({ rootPath, excludePaths }) => { - const file = await resolveRuntimeFilePath(rootPath) - const result = await callRuntimeResult<{ files: { relativePath: string }[] }>( - 'files.listAll', - { - worktree: toRuntimeWorktreeSelector(file.worktree.id), - excludePaths - } - ) - return result.files.map((entry) => entry.relativePath) - }, - cancelListFiles: async () => { - // Why: paired-web lists files over runtime RPC with its own timeout; there's no host-side scan to abort here. - }, - search: async (args) => { - const file = await resolveRuntimeFilePath(args.rootPath) - return callRuntimeResult('files.search', { - worktree: toRuntimeWorktreeSelector(file.worktree.id), - query: args.query, - caseSensitive: args.caseSensitive, - wholeWord: args.wholeWord, - useRegex: args.useRegex, - includePattern: args.includePattern, - excludePattern: args.excludePattern, - maxResults: args.maxResults - }) - }, - importExternalPaths: async () => ({ results: [] }), - stageExternalPathsForRuntimeUpload: async () => ({ sources: [] }), - resolveDroppedPathsForAgent: async () => ({ resolvedPaths: [], skipped: [], failed: [] }), - watchWorktree: () => Promise.resolve(), - unwatchWorktree: () => Promise.resolve(), - onFsChanged: () => noopUnsubscribe - } -} - -// Why: track the in-flight abortable status request per token so cancelStatus can abort it and close its remote context. -const webGitStatusAbortControllers = new Map() - -async function callAbortableRuntimeStatus( - requestToken: string, - params: unknown -): Promise { - const environment = requireActiveEnvironment() - webGitStatusAbortControllers.get(requestToken)?.abort() - const controller = new AbortController() - webGitStatusAbortControllers.set(requestToken, controller) - try { - const response = await callAbortableRuntimeEnvironment( - environment.id, - 'git.status', - params, - undefined, - controller.signal - ) - updateEnvironmentFromResponse(environment, response) - if (!response.ok) { - throw new Error(response.error.message) - } - return response.result as TResult - } finally { - if (webGitStatusAbortControllers.get(requestToken) === controller) { - webGitStatusAbortControllers.delete(requestToken) - } - } -} - -function createGitApi(): NonNullable['git']> { - return { - status: async ({ - worktreePath, - includeIgnored, - bypassEffectiveUpstreamNegativeCache, - reuseLineStats, - branchLineTotalMergeBase, - requestToken - }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - const params = { - worktree: toRuntimeWorktreeSelector(worktree.id), - includeIgnored, - bypassEffectiveUpstreamNegativeCache, - reuseLineStats, - ...(branchLineTotalMergeBase ? { branchLineTotalMergeBase } : {}) - } - // Why: no token = nothing to cancel (pooled); a token routes via the subscription bridge so cancelStatus can abort. - if (!requestToken) { - return callRuntimeResult('git.status', params) - } - return callAbortableRuntimeStatus(requestToken, params) - }, - cancelStatus: async ({ requestToken }) => { - webGitStatusAbortControllers.get(requestToken)?.abort() - }, - setStatusUpstreamRefWatch: async () => {}, - submoduleStatus: async ({ worktreePath, submodulePath, area }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult('git.submoduleStatus', { - worktree: toRuntimeWorktreeSelector(worktree.id), - submodulePath, - area - }) - }, - checkIgnored: async ({ worktreePath, paths }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult('git.checkIgnored', { - worktree: toRuntimeWorktreeSelector(worktree.id), - paths - }) - }, - // Why: the "add huge folder to .gitignore" flow is desktop-only; the web runtime makes no offer, so return no candidates. - findHugeFoldersToIgnore: async () => [], - appendGitignore: async () => false, - history: async ({ worktreePath, limit, baseRef }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult('git.history', { - worktree: toRuntimeWorktreeSelector(worktree.id), - limit, - baseRef - }) - }, - conflictOperation: async ({ worktreePath }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult('git.conflictOperation', { - worktree: toRuntimeWorktreeSelector(worktree.id) - }) - }, - abortMerge: async ({ worktreePath }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - await callRuntimeResult('git.abortMerge', { - worktree: toRuntimeWorktreeSelector(worktree.id) - }) - }, - abortRebase: async ({ worktreePath }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - await callRuntimeResult('git.abortRebase', { - worktree: toRuntimeWorktreeSelector(worktree.id) - }) - }, - diff: async ({ worktreePath, filePath, staged, compareAgainstHead }) => { - const file = await resolveRuntimeFilePath(filePath, worktreePath) - return callRuntimeResult('git.diff', { - worktree: toRuntimeWorktreeSelector(file.worktree.id), - filePath: file.relativePath, - staged, - compareAgainstHead - }) - }, - branchCompare: async ({ worktreePath, baseRef }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult('git.branchCompare', { - worktree: toRuntimeWorktreeSelector(worktree.id), - baseRef - }) - }, - commitCompare: async ({ worktreePath, commitId }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult('git.commitCompare', { - worktree: toRuntimeWorktreeSelector(worktree.id), - commitId - }) - }, - upstreamStatus: async ({ worktreePath, pushTarget }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult('git.upstreamStatus', { - worktree: toRuntimeWorktreeSelector(worktree.id), - pushTarget - }) - }, - fetch: async ({ worktreePath, pushTarget }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - await callRuntimeResult('git.fetch', { - worktree: toRuntimeWorktreeSelector(worktree.id), - pushTarget - }) - }, - syncFork: async ({ worktreePath, expectedUpstream }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult( - 'git.forkSync', - { - worktree: toRuntimeWorktreeSelector(worktree.id), - expectedUpstream - }, - 60_000 - ) - }, - push: async ({ worktreePath, publish, pushTarget }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - await callRuntimeResult('git.push', { - worktree: toRuntimeWorktreeSelector(worktree.id), - publish, - pushTarget - }) - }, - pull: async ({ worktreePath, pushTarget }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - await callRuntimeResult('git.pull', { - worktree: toRuntimeWorktreeSelector(worktree.id), - pushTarget - }) - }, - fastForward: async ({ worktreePath, pushTarget }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - await callRuntimeResult('git.fastForward', { - worktree: toRuntimeWorktreeSelector(worktree.id), - pushTarget - }) - }, - rebaseFromBase: async ({ worktreePath, baseRef }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - await callRuntimeResult('git.rebaseFromBase', { - worktree: toRuntimeWorktreeSelector(worktree.id), - baseRef - }) - }, - branchDiff: async ({ worktreePath, filePath, compare, oldPath }) => { - const file = await resolveRuntimeFilePath(filePath, worktreePath) - return callRuntimeResult('git.branchDiff', { - worktree: toRuntimeWorktreeSelector(file.worktree.id), - filePath: file.relativePath, - compare, - oldPath - }) - }, - commitDiff: async ({ worktreePath, filePath, commitOid, parentOid, oldPath }) => { - const file = await resolveRuntimeFilePath(filePath, worktreePath) - return callRuntimeResult('git.commitDiff', { - worktree: toRuntimeWorktreeSelector(file.worktree.id), - filePath: file.relativePath, - commitOid, - parentOid, - oldPath - }) - }, - commit: async ({ worktreePath, message }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult('git.commit', { - worktree: toRuntimeWorktreeSelector(worktree.id), - message - }) - }, - generateCommitMessage: async () => ({ - success: false, - error: translate( - 'auto.web.web.preload.api.9fc90740b6', - 'Commit message generation is unavailable in the web client.' - ) - }), - discoverCommitMessageModels: async () => ({ - success: false, - error: translate( - 'auto.web.web.preload.api.e57c82d276', - 'Commit message model discovery is unavailable in the web client.' - ) - }), - cancelGenerateCommitMessage: () => Promise.resolve(), - generatePullRequestFields: async () => ({ - success: false, - error: translate( - 'auto.web.web.preload.api.b8a1618172', - 'Pull request detail generation is unavailable in the web client.' - ) - }), - cancelGeneratePullRequestFields: () => Promise.resolve(), - stage: async ({ worktreePath, filePath }) => mutateGitPath('git.stage', worktreePath, filePath), - bulkStage: async ({ worktreePath, filePaths }) => - mutateGitPaths('git.bulkStage', worktreePath, filePaths), - unstage: async ({ worktreePath, filePath }) => - mutateGitPath('git.unstage', worktreePath, filePath), - bulkUnstage: async ({ worktreePath, filePaths }) => - mutateGitPaths('git.bulkUnstage', worktreePath, filePaths), - discard: async ({ worktreePath, filePath }) => - mutateGitPath('git.discard', worktreePath, filePath), - bulkDiscard: async ({ worktreePath, filePaths }) => { - for (const filePath of filePaths) { - await mutateGitPath('git.discard', worktreePath, filePath) - } - }, - remoteFileUrl: async ({ worktreePath, relativePath, line }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult('git.remoteFileUrl', { - worktree: toRuntimeWorktreeSelector(worktree.id), - relativePath, - line - }) - }, - remoteCommitUrl: async ({ worktreePath, sha }) => { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - return callRuntimeResult('git.remoteCommitUrl', { - worktree: toRuntimeWorktreeSelector(worktree.id), - sha - }) - } - } -} - -function createBrowserApi(): NonNullable['browser']> { - return { - registerGuest: () => Promise.resolve(false), - isGuestRegistered: () => Promise.resolve(false), - repairGuestRegistration: () => Promise.resolve(false), - unregisterGuest: () => Promise.resolve(), - openDevTools: () => Promise.resolve(false), - setViewportOverride: () => Promise.resolve(false), - setAnnotationViewportBridge: () => Promise.resolve(false), - onGuestLoadFailed: () => noopUnsubscribe, - onCertificateFailureChanged: () => noopUnsubscribe, - proceedCertificate: () => Promise.resolve({ ok: false, reason: 'missing' }), - onPermissionDenied: () => noopUnsubscribe, - onPopup: () => noopUnsubscribe, - onDownloadRequested: () => noopUnsubscribe, - onDownloadProgress: () => noopUnsubscribe, - onDownloadFinished: () => noopUnsubscribe, - onContextMenuRequested: () => noopUnsubscribe, - onContextMenuDismissed: () => noopUnsubscribe, - onNavigationUpdate: () => noopUnsubscribe, - onActivateView: () => noopUnsubscribe, - onPaneFocus: () => noopUnsubscribe, - onOpenLinkInOrcaTab: () => noopUnsubscribe, - cancelDownload: () => Promise.resolve(false), - setGrabMode: () => - Promise.resolve({ - ok: false, - error: translate( - 'auto.web.web.preload.api.31bea294d5', - 'Grab mode is unavailable in the web client.' - ) - }), - awaitGrabSelection: () => - Promise.resolve({ - ok: false, - error: translate( - 'auto.web.web.preload.api.31bea294d5', - 'Grab mode is unavailable in the web client.' - ) - }), - cancelGrab: () => Promise.resolve(false), - captureSelectionScreenshot: () => - Promise.resolve({ - ok: false, - error: translate( - 'auto.web.web.preload.api.8dfcb7a351', - 'Selection screenshots are unavailable in the web client.' - ) - }), - extractHoverPayload: () => - Promise.resolve({ - ok: false, - error: translate( - 'auto.web.web.preload.api.275a776357', - 'Hover extraction is unavailable in the web client.' - ) - }), - onGrabModeToggle: () => noopUnsubscribe, - onGrabActionShortcut: () => noopUnsubscribe, - sessionListProfiles: () => Promise.resolve([]), - sessionCreateProfile: () => Promise.resolve(null), - sessionDeleteProfile: () => Promise.resolve(false), - sessionImportCookies: () => - Promise.resolve({ - ok: false, - summary: null, - error: translate( - 'auto.web.web.preload.api.67ec964791', - 'Cookie import is unavailable in the web client.' - ) - }), - sessionResolvePartition: () => Promise.resolve(null), - sessionDetectBrowsers: () => Promise.resolve([]), - sessionImportFromBrowser: () => - Promise.resolve({ - ok: false, - summary: null, - error: translate( - 'auto.web.web.preload.api.67ec964791', - 'Cookie import is unavailable in the web client.' - ) - }), - sessionClearDefaultCookies: () => Promise.resolve(false), - notifyActiveTabChanged: () => Promise.resolve(false) - } as unknown as NonNullable['browser']> -} - -function createEmulatorApi(): NonNullable['emulator']> { - return { - onPaneFocus: () => noopUnsubscribe, - onAutoAttach: () => noopUnsubscribe, - startFrameStream: () => Promise.reject(new Error('Mobile emulator is unavailable on web.')), - stopFrameStream: () => Promise.resolve(), - onFrameStreamFrame: () => noopUnsubscribe, - onFrameStreamError: () => noopUnsubscribe - } as unknown as NonNullable['emulator']> -} - -function createGitHubApi(): WebGitHubApi { - const route = (method: WebGitHubRuntimeMethod, args?: unknown): Promise => - callRuntimeResult(method, mapRepoPathArg(args)) - const githubApi = { - viewer: () => Promise.resolve(null), - repoSlug: (args) => route>(GITHUB_WEB_RPC_METHODS.repoSlug, args), - repoUpstream: (args) => - route>(GITHUB_WEB_RPC_METHODS.repoUpstream, args), - prForBranch: (args) => - route>(GITHUB_WEB_RPC_METHODS.prForBranch, args), - refreshPRNow: async ({ candidate }) => { - const acceptMergedFallbackPR = - candidate.linkedPRNumber == null && - candidate.fallbackPRNumber != null && - candidate.fallbackPRSource != null - const pr = await route>(GITHUB_WEB_RPC_METHODS.prForBranch, { - repoPath: candidate.repoPath, - repoId: candidate.repoId, - branch: candidate.branch, - linkedPRNumber: candidate.linkedPRNumber ?? null, - fallbackPRNumber: candidate.fallbackPRNumber ?? null, - currentHeadOid: candidate.currentHeadOid ?? null, - ...(acceptMergedFallbackPR ? { acceptMergedFallbackPR: true } : {}) - }) - return pr - ? { kind: 'found', pr, fetchedAt: Date.now() } - : { kind: 'no-pr', fetchedAt: Date.now() } - }, - enqueuePRRefresh: () => Promise.resolve(false), - reportVisiblePRRefreshCandidates: () => Promise.resolve(false), - onPRRefreshEvent: () => noopUnsubscribe, - issue: (args) => route>(GITHUB_WEB_RPC_METHODS.issue, args), - workItem: (args) => route>(GITHUB_WEB_RPC_METHODS.workItem, args), - workItemByOwnerRepo: ({ repo: ownerRepo, ...args }) => - route>(GITHUB_WEB_RPC_METHODS.workItemByOwnerRepo, { - ...args, - ownerRepo - }), - workItemDetails: (args) => - route>(GITHUB_WEB_RPC_METHODS.workItemDetails, args), - notifyWorkItemMutated: () => Promise.resolve(false), - prFileContents: (args) => - route>(GITHUB_WEB_RPC_METHODS.prFileContents, args), - listIssues: (args) => - route>(GITHUB_WEB_RPC_METHODS.listIssues, args), - createIssue: (args) => - route>(GITHUB_WEB_RPC_METHODS.createIssue, args), - countWorkItems: (args) => - route>(GITHUB_WEB_RPC_METHODS.countWorkItems, args), - listWorkItems: (args) => - route>(GITHUB_WEB_RPC_METHODS.listWorkItems, args), - prChecks: (args) => route>(GITHUB_WEB_RPC_METHODS.prChecks, args), - prCheckDetails: (args) => - route>(GITHUB_WEB_RPC_METHODS.prCheckDetails, args), - rerunPRChecks: (args) => - route>(GITHUB_WEB_RPC_METHODS.rerunPRChecks, args), - prComments: (args) => - route>(GITHUB_WEB_RPC_METHODS.prComments, args), - setPRCommentReaction: (args) => - route>( - GITHUB_WEB_RPC_METHODS.setPRCommentReaction, - args - ), - resolveReviewThread: (args) => - route>( - GITHUB_WEB_RPC_METHODS.resolveReviewThread, - args - ), - setPRFileViewed: (args) => - route>(GITHUB_WEB_RPC_METHODS.setPRFileViewed, args), - updatePRTitle: (args) => - route>(GITHUB_WEB_RPC_METHODS.updatePRTitle, args), - mergePR: (args) => route>(GITHUB_WEB_RPC_METHODS.mergePR, args), - setPRAutoMerge: (args) => - route>(GITHUB_WEB_RPC_METHODS.setPRAutoMerge, args), - updatePRState: (args) => - route>(GITHUB_WEB_RPC_METHODS.updatePRState, args), - requestPRReviewers: (args) => - route>(GITHUB_WEB_RPC_METHODS.requestPRReviewers, args), - removePRReviewers: (args) => - route>(GITHUB_WEB_RPC_METHODS.removePRReviewers, args), - updateIssue: (args) => - route>(GITHUB_WEB_RPC_METHODS.updateIssue, args), - addIssueComment: (args) => - route>(GITHUB_WEB_RPC_METHODS.addIssueComment, args), - addPRReviewCommentReply: (args) => - route>( - GITHUB_WEB_RPC_METHODS.addPRReviewCommentReply, - args - ), - addPRReviewComment: (args) => - route>(GITHUB_WEB_RPC_METHODS.addPRReviewComment, args), - listLabels: (args) => - route>(GITHUB_WEB_RPC_METHODS.listLabels, args), - listAssignableUsers: (args) => - route>( - GITHUB_WEB_RPC_METHODS.listAssignableUsers, - args - ), - onWorkItemMutated: () => noopUnsubscribe, - checkOrcaStarred: () => Promise.resolve(null), - starOrca: () => Promise.resolve(false), - rateLimit: (args) => - route>(GITHUB_WEB_RPC_METHODS.rateLimit, args), - diagnoseAuth: () => - Promise.resolve({ - ok: false, - message: translate('auto.web.web.preload.api.31bfe8ae1a', 'Unavailable in the web client.') - } as never), - listAccessibleProjects: (args) => - route>( - GITHUB_WEB_RPC_METHODS.listAccessibleProjects, - args - ), - resolveProjectRef: (args) => - route>(GITHUB_WEB_RPC_METHODS.resolveProjectRef, args), - listProjectViews: (args) => - route>(GITHUB_WEB_RPC_METHODS.listProjectViews, args), - getProjectViewTable: (args) => - route>( - GITHUB_WEB_RPC_METHODS.getProjectViewTable, - args - ), - projectWorkItemDetailsBySlug: (args) => - route>( - GITHUB_WEB_RPC_METHODS.projectWorkItemDetailsBySlug, - args - ), - updateProjectItemField: (args) => - route>( - GITHUB_WEB_RPC_METHODS.updateProjectItemField, - args - ), - clearProjectItemField: (args) => - route>( - GITHUB_WEB_RPC_METHODS.clearProjectItemField, - args - ), - updateIssueBySlug: (args) => - route>(GITHUB_WEB_RPC_METHODS.updateIssueBySlug, args), - updatePullRequestBySlug: (args) => - route>( - GITHUB_WEB_RPC_METHODS.updatePullRequestBySlug, - args - ), - addIssueCommentBySlug: (args) => - route>( - GITHUB_WEB_RPC_METHODS.addIssueCommentBySlug, - args - ), - updateIssueCommentBySlug: (args) => - route>( - GITHUB_WEB_RPC_METHODS.updateIssueCommentBySlug, - args - ), - deleteIssueCommentBySlug: (args) => - route>( - GITHUB_WEB_RPC_METHODS.deleteIssueCommentBySlug, - args - ), - listLabelsBySlug: (args) => - route>(GITHUB_WEB_RPC_METHODS.listLabelsBySlug, args), - listAssignableUsersBySlug: (args) => - route>( - GITHUB_WEB_RPC_METHODS.listAssignableUsersBySlug, - args - ), - listIssueTypesBySlug: (args) => - route>( - GITHUB_WEB_RPC_METHODS.listIssueTypesBySlug, - args - ), - updateIssueTypeBySlug: (args) => - route>( - GITHUB_WEB_RPC_METHODS.updateIssueTypeBySlug, - args - ) - } satisfies WebGitHubApi - - return githubApi -} - -function createGitLabApi(): WebGitLabApi { - const route = (method: WebGitLabRuntimeMethod, args?: unknown): Promise => - callRuntimeResult(method, mapRepoPathArg(args)) - - const gitLabApi = { - viewer: () => Promise.resolve(null), - diagnoseAuth: () => route>(GITLAB_WEB_RPC_METHODS.diagnoseAuth), - rateLimit: (args) => - route>(GITLAB_WEB_RPC_METHODS.rateLimit, args), - projectSlug: () => Promise.resolve(null), - mrForBranch: () => Promise.resolve(null), - mr: () => Promise.resolve(null), - listMRs: (args) => route>(GITLAB_WEB_RPC_METHODS.listMRs, args), - listWorkItems: (args) => - route>(GITLAB_WEB_RPC_METHODS.listWorkItems, args), - issue: () => Promise.resolve(null), - listIssues: (args) => - route>(GITLAB_WEB_RPC_METHODS.listIssues, args), - createIssue: (args) => - route>(GITLAB_WEB_RPC_METHODS.createIssue, args), - updateIssue: (args) => - route>(GITLAB_WEB_RPC_METHODS.updateIssue, args), - addIssueComment: (args) => - route>(GITLAB_WEB_RPC_METHODS.addIssueComment, args), - listLabels: (args) => - route>(GITLAB_WEB_RPC_METHODS.listLabels, args), - listAssignableUsers: () => Promise.resolve([]), - todos: (args) => route>(GITLAB_WEB_RPC_METHODS.todos, args), - workItemDetails: (args) => - route>(GITLAB_WEB_RPC_METHODS.workItemDetails, args), - closeMR: (args) => - route>(GITLAB_WEB_RPC_METHODS.closeMR, { - ...args, - state: 'closed' - }), - reopenMR: (args) => - route>(GITLAB_WEB_RPC_METHODS.reopenMR, { - ...args, - state: 'opened' - }), - mergeMR: (args) => route>(GITLAB_WEB_RPC_METHODS.mergeMR, args), - updateMR: (args) => route>(GITLAB_WEB_RPC_METHODS.updateMR, args), - updateMRReviewers: (args) => - route>(GITLAB_WEB_RPC_METHODS.updateMRReviewers, args), - addMRComment: (args) => - route>(GITLAB_WEB_RPC_METHODS.addMRComment, args), - addMRInlineComment: (args) => - route>(GITLAB_WEB_RPC_METHODS.addMRInlineComment, args), - resolveMRDiscussion: (args) => - route>( - GITLAB_WEB_RPC_METHODS.resolveMRDiscussion, - args - ), - jobTrace: (args) => route>(GITLAB_WEB_RPC_METHODS.jobTrace, args), - retryJob: (args) => route>(GITLAB_WEB_RPC_METHODS.retryJob, args), - workItemByPath: (args) => - route>(GITLAB_WEB_RPC_METHODS.workItemByPath, args) - } satisfies WebGitLabApi - - return gitLabApi -} - -function createRuntimeNamespaceApi(prefix: string): never { - return createFallbackProxy([prefix], (path, args) => { - const method = `${prefix}.${path.at(-1) ?? ''}` - return callRuntimeResult(method, mapRuntimeNamespaceArg(prefix, args[0])) - }) as never -} - -function createHooksApi(): NonNullable['hooks']> { - return { - check: async ({ repoId }) => callRuntimeResult('repo.hooksCheck', { repo: repoId }), - inspectSetupScriptImports: async ({ repoId }) => - callRuntimeResult('repo.setupScriptImports', { repo: repoId }), - createIssueCommandRunner: async () => ({ launched: false }) as never, - readIssueCommand: async ({ repoId }) => - callRuntimeResult('repo.issueCommandRead', { repo: repoId }), - writeIssueCommand: async ({ repoId, content }) => { - await callRuntimeResult('repo.issueCommandWrite', { repo: repoId, content }) - } - } -} - -function createWebUiApi(): NonNullable['ui']> { - let zoomLevel = readLocalWebUIState().uiZoomLevel - return { - get: async () => { - try { - const result = await callRuntimeResult<{ ui: PairedUiState }>('ui.get', undefined, 15_000) - const local = readLocalWebUIState() - const next = { - ...mergeHostWebUIState(local, result.ui), - osc52ClipboardDefaultOnNoticePending: mergeOsc52ClipboardNoticePending(local, result.ui), - featureInteractions: mergeFeatureInteractionState( - local.featureInteractions, - result.ui.featureInteractions - ), - contextualToursSeenIds: mergeContextualTourSeenIds( - local.contextualToursSeenIds, - result.ui.contextualToursSeenIds - ) - } - writeJson(UI_STORAGE_KEY, next) - zoomLevel = next.uiZoomLevel - return next - } catch { - return readLocalWebUIState() - } - }, - set: async (updates) => { - const next = mergeWebUIState(readLocalWebUIState(), updates) - writeJson(UI_STORAGE_KEY, next) - zoomLevel = next.uiZoomLevel - // Why strip here too when the host also strips: an old host predating that strip would - // otherwise persist this browser's runtime:web-* keys over the desktop profile's order. - const hostUpdates = omitPairingLocalUiFields(updates) - try { - await callRuntimeResult('ui.set', hostUpdates, 15_000) - } catch { - // Why: unpaired/offline web clients still need local UI persistence. - } - }, - recordFeatureInteraction: async (id: FeatureInteractionId) => { - const current = readLocalWebUIState() - const featureInteractions = normalizeFeatureInteractions(current.featureInteractions) - const existing = featureInteractions[id] - const optimistic = mergeWebUIState(current, { - featureInteractions: { - ...featureInteractions, - [id]: { - firstInteractedAt: existing?.firstInteractedAt ?? Date.now(), - interactionCount: (existing?.interactionCount ?? 0) + 1 - } - } - }) - writeJson(UI_STORAGE_KEY, optimistic) - try { - const result = await callRuntimeResult<{ ui: PairedUiState }>( - 'ui.recordFeatureInteraction', - id, - 15_000 - ) - const local = readLocalWebUIState() - const next = { - ...mergeHostWebUIState(local, result.ui), - osc52ClipboardDefaultOnNoticePending: mergeOsc52ClipboardNoticePending(local, result.ui), - featureInteractions: mergeFeatureInteractionState( - local.featureInteractions, - result.ui.featureInteractions - ), - contextualToursSeenIds: mergeContextualTourSeenIds( - local.contextualToursSeenIds, - result.ui.contextualToursSeenIds - ) - } - writeJson(UI_STORAGE_KEY, next) - zoomLevel = next.uiZoomLevel - return next - } catch { - return optimistic - } - }, - readClipboardText: async (options?: ReadClipboardTextOptions) => - assertClipboardTextWithinLimitWithYield( - await (navigator.clipboard?.readText?.() ?? ''), - options - ), - readSelectionClipboardText: () => - Promise.reject(new Error('Selection clipboard is unavailable in the web client')), - saveClipboardImageAsTempFile: async (args?: { - connectionId?: string | null - runtimeEnvironmentId?: string | null - }) => { - if (!requireActiveEnvironmentOrNull()) { - return null - } - const contentBase64 = await readClipboardImagePngBase64() - if (!contentBase64) { - return null - } - return saveClipboardImageAsTempFileInRuntime(contentBase64, args) - }, - writeClipboardText: writeWebClipboardText, - writeTerminalClipboardText: writeWebClipboardText, - writeSelectionClipboardText: () => - Promise.reject(new Error('Selection clipboard is unavailable in the web client')), - writeClipboardImage: () => Promise.resolve(), - writeClipboardFile: () => Promise.resolve({ ok: false, reason: 'unsupported-platform' }), - performNativePaste: () => { - document.execCommand?.('paste') - }, - performNativeSelectionAction: (action) => { - document.execCommand?.(action === 'copy' ? 'copy' : 'selectAll') - }, - onExportPdfRequested: () => noopUnsubscribe, - onAppMenuPaste: () => noopUnsubscribe, - onAppMenuSelectionAction: () => noopUnsubscribe, - onEditableContextPaste: () => noopUnsubscribe, - getZoomLevel: () => zoomLevel, - setZoomLevel: (level) => { - zoomLevel = level - }, - isMaximized: () => Promise.resolve(false), - onOpenSettings: () => noopUnsubscribe, - // Why: the web client has no native tray/menu bar, so there's never a queued open-settings intent to consume. - consumePendingOpenSettings: () => Promise.resolve(false), - onOpenSkillShare: () => noopUnsubscribe, - consumePendingSkillShare: () => Promise.resolve(null), - onOpenSetupGuide: () => noopUnsubscribe, - onOpenFeatureTour: () => noopUnsubscribe, - onOpenCrashReport: () => noopUnsubscribe, - // No desktop main process to push state changes; the web client re-reads via ui.get on interaction. - onStateChanged: () => noopUnsubscribe, - onToggleLeftSidebar: () => noopUnsubscribe, - onToggleRightSidebar: () => noopUnsubscribe, - onToggleWorktreePalette: () => noopUnsubscribe, - onToggleFloatingTerminal: () => noopUnsubscribe, - onTerminalShortcutCaptured: () => noopUnsubscribe, - onOpenQuickOpen: () => noopUnsubscribe, - onToggleQuickCommandsMenu: () => noopUnsubscribe, - onOpenTasks: () => noopUnsubscribe, - onOpenNewWorkspace: () => noopUnsubscribe, - onDeleteCurrentWorkspace: () => noopUnsubscribe, - onOpenWorkspaceBoard: () => noopUnsubscribe, - onToggleAgentDashboard: () => noopUnsubscribe, - onJumpToWorktreeIndex: () => noopUnsubscribe, - onJumpToTabIndex: () => noopUnsubscribe, - onWorktreeHistoryNavigate: () => noopUnsubscribe, - onNewBrowserTab: () => noopUnsubscribe, - onNewMarkdownTab: () => noopUnsubscribe, - onNewSimulatorTab: () => noopUnsubscribe, - onRequestTabCreate: () => noopUnsubscribe, - replyTabCreate: () => {}, - onRequestTabSetProfile: () => noopUnsubscribe, - replyTabSetProfile: () => {}, - onRequestTabClose: () => noopUnsubscribe, - replyTabClose: () => {}, - onNewTerminalTab: () => noopUnsubscribe, - onFocusBrowserAddressBar: () => noopUnsubscribe, - onFindInBrowserPage: () => noopUnsubscribe, - onReloadBrowserPage: () => noopUnsubscribe, - onBrowserHistoryNavigate: () => noopUnsubscribe, - onZoomBrowserPage: () => noopUnsubscribe, - onHardReloadBrowserPage: () => noopUnsubscribe, - onCloseActiveTab: () => noopUnsubscribe, - onCloseFloatingItem: () => noopUnsubscribe, - onSelectFloatingIndex: () => noopUnsubscribe, - onSwitchTab: () => noopUnsubscribe, - onSwitchTabAcrossAllTypes: () => noopUnsubscribe, - onSwitchRecentTab: () => noopUnsubscribe, - onSwitchTerminalTab: () => noopUnsubscribe, - onCtrlTabKeyDown: () => noopUnsubscribe, - onCtrlTabKeyUp: () => noopUnsubscribe, - onToggleStatusBar: () => noopUnsubscribe, - onDictationKeyDown: () => noopUnsubscribe, - onActivateWorktree: () => noopUnsubscribe, - onCreateTerminal: () => noopUnsubscribe, - onRequestTerminalCreate: () => noopUnsubscribe, - onRequestTerminalTabMount: () => noopUnsubscribe, - replyTerminalCreate: () => {}, - onSplitTerminal: () => noopUnsubscribe, - onRenameTerminal: () => noopUnsubscribe, - onFocusTerminal: () => noopUnsubscribe, - onFocusEditorTab: () => noopUnsubscribe, - onCloseSessionTab: () => noopUnsubscribe, - onSessionTabCloseRequest: () => noopUnsubscribe, - respondSessionTabClose: () => {}, - onMoveSessionTab: () => noopUnsubscribe, - onOpenFileFromMobile: () => noopUnsubscribe, - onOpenDiffFromMobile: () => noopUnsubscribe, - onMobileMarkdownRequest: () => noopUnsubscribe, - respondMobileMarkdownRequest: () => {}, - onCloseTerminal: () => noopUnsubscribe, - onTerminalTabCloseRequest: () => noopUnsubscribe, - respondTerminalTabClose: () => {}, - onSleepWorktree: () => noopUnsubscribe, - // Why: paired web is a full renderer that wakes on activation; mobile wake is desktop-host-scoped and never reaches web. - onResumeSleepingAgents: () => noopUnsubscribe, - onTerminalZoom: () => noopUnsubscribe, - // Why: a paired web client has no OS sleep signal; occlusion-driven visibilitychange already covers wake recovery. - onSystemResumed: () => noopUnsubscribe, - onFileDrop: () => noopUnsubscribe, - syncTrafficLights: () => {}, - setMarkdownEditorFocused: () => {}, - setRichMarkdownContextMenuTarget: () => {}, - setTerminalInputFocused: () => {}, - setFloatingFocus: () => {}, - setShortcutRecorderFocused: () => {}, - onRichMarkdownContextCommand: () => noopUnsubscribe, - onFullscreenChanged: () => noopUnsubscribe, - minimize: () => {}, - maximize: () => {}, - onMaximizeChanged: () => noopUnsubscribe, - requestClose: () => {}, - popupMenu: () => {}, - onWindowCloseRequested: () => noopUnsubscribe, - confirmWindowClose: () => {}, - notifyWindowRevealed: () => {} - } -} - -function createPreflightApi(): NonNullable['preflight']> { - const fallbackStatus: PreflightStatus = { - git: { installed: false }, - gh: { installed: false, authenticated: false }, - glab: { installed: false, authenticated: false }, - bitbucket: { configured: false, authenticated: false, account: null }, - azureDevOps: { - configured: false, - authenticated: false, - account: null, - baseUrl: null, - tokenConfigured: false - }, - gitea: { - configured: false, - authenticated: false, - account: null, - baseUrl: null, - tokenConfigured: false - } - } - const fallbackRefreshAgents: RefreshAgentsResult = { - agents: [], - addedPathSegments: [], - shellHydrationOk: false, - pathSource: 'sync_seed_only', - pathFailureReason: 'spawn_error' - } - type WindowsTerminalCapabilityBridgeResult = { - wslAvailable: boolean - wslDistros: string[] - pwshAvailable: boolean - gitBashAvailable: boolean - hostPlatform: NodeJS.Platform | null - } - const fallbackWindowsTerminalCapabilities = { - wslAvailable: false, - wslDistros: [], - pwshAvailable: false, - gitBashAvailable: false, - hostPlatform: null - } - return { - check: async (args) => { - if (!requireActiveEnvironmentOrNull()) { - return fallbackStatus - } - return callRuntimeResult('preflight.check', args) - }, - detectAgents: async () => { - if (!requireActiveEnvironmentOrNull()) { - return [] - } - return callRuntimeResult('preflight.detectAgents').catch(() => []) - }, - refreshAgents: () => - requireActiveEnvironmentOrNull() - ? callRuntimeResult('preflight.refreshAgents') - .then((result) => result as RefreshAgentsResult) - .catch(() => fallbackRefreshAgents) - : Promise.resolve(fallbackRefreshAgents), - detectRemoteAgents: async (args) => - requireActiveEnvironmentOrNull() - ? callRuntimeResult('preflight.detectRemoteAgents', args).catch(() => []) - : [], - detectRemoteWindowsTerminalCapabilities: async (args) => - requireActiveEnvironmentOrNull() - ? callRuntimeResult( - 'preflight.detectRemoteWindowsTerminalCapabilities', - args - ).catch(() => fallbackWindowsTerminalCapabilities) - : Promise.resolve(fallbackWindowsTerminalCapabilities) - } -} - -function createCliApi(): NonNullable['cli']> { - const status = { - platform: getBrowserPlatform(), - commandName: getBrowserPlatform() === 'linux' ? 'orca-ide' : 'orca', - commandPath: null, - pathDirectory: null, - pathConfigured: false, - launcherPath: null, - installMethod: null, - supported: false, - state: 'unsupported', - currentTarget: null, - unsupportedReason: 'launch_mode_unavailable', - detail: 'CLI registration is managed on the Orca server, not in the web browser.' - } as const - return { - getInstallStatus: () => Promise.resolve(status), - install: () => Promise.resolve(status), - remove: () => Promise.resolve(status), - getWslInstallStatus: (_args?: { distro?: string | null }) => Promise.resolve(status), - installWsl: (_args?: { distro?: string | null }) => Promise.resolve(status), - removeWsl: (_args?: { distro?: string | null }) => Promise.resolve(status) - } as NonNullable['cli']> -} - -function createAgentHooksApi(): NonNullable['agentHooks']> { - const status = ( - agent: - | 'claude' - | 'openclaude' - | 'codex' - | 'gemini' - | 'antigravity' - | 'amp' - | 'cursor' - | 'droid' - | 'command-code' - | 'grok' - | 'copilot' - | 'hermes' - | 'devin' - ) => - Promise.resolve({ - agent, - state: 'not_installed', - configPath: '', - managedHooksPresent: false, - detail: 'Agent hook status is only available on the Orca server.' - } as const) - return { - claudeStatus: () => status('claude'), - openClaudeStatus: () => status('openclaude'), - codexStatus: () => status('codex'), - geminiStatus: () => status('gemini'), - antigravityStatus: () => status('antigravity'), - ampStatus: () => status('amp'), - cursorStatus: () => status('cursor'), - droidStatus: () => status('droid'), - commandCodeStatus: () => status('command-code'), - grokStatus: () => status('grok'), - copilotStatus: () => status('copilot'), - hermesStatus: () => status('hermes'), - devinStatus: () => status('devin') - } -} - -function createMacosTccPromptsApi(): NonNullable['macosTccPrompts']> { - // Why: TCC is a macOS-desktop concept; the web client has no log stream to watch. - return { - onThreshold: () => noopUnsubscribe, - consumePending: () => Promise.resolve(null), - acknowledgePending: () => Promise.resolve(), - releasePending: () => Promise.resolve(), - dismiss: () => Promise.resolve() - } -} - -function createDeveloperPermissionsApi(): NonNullable['developerPermissions']> { - return { - getStatus: () => Promise.resolve([]), - request: ({ id }) => - Promise.resolve({ id, status: 'unsupported', openedSystemSettings: false } as const), - openSettings: () => Promise.resolve(), - testLocalNetworkConnection: ({ host, port }) => - Promise.resolve({ - ok: false, - host, - port, - testedAt: Date.now(), - failure: 'unsupported' - } as const) - } -} - -function createComputerUsePermissionsApi(): NonNullable< - Partial['computerUsePermissions'] -> { - return { - getStatus: () => - callRuntimeResult( - 'computer.permissionsStatus', - {}, - 15_000 - ), - openSetup: (args) => - callRuntimeResult( - 'computer.permissions', - args ?? {}, - 15_000 - ).catch(() => ({ - platform: getBrowserPlatform(), - helperAppPath: null, - openedSettings: false, - launchedHelper: false, - nextStep: 'Computer-use permissions are managed on the Orca server.' - })), - reset: () => - Promise.resolve({ - platform: getBrowserPlatform(), - helperAppPath: null, - helperUnavailableReason: 'web_client', - bundleId: null, - permissions: [] - }) - } -} - -function createSkillsApi(): NonNullable['skills']> { - return { - discover: (target) => - callRuntimeResult('skills.discover', target, 15_000), - // Why: browser clients have no local skill homes; remote-host freshness stays off until its update rail covers it. - freshnessInventory: (): Promise => - Promise.resolve({ - schemaVersion: 1, - installations: [], - eligibleUpdateNames: [], - scanIssues: [], - scannedAt: Date.now() - }), - // Why: with no local skill homes there is nothing to update, so the run rail - // reports a permanently idle state rather than spawning anything. - startUpdateRun: () => Promise.resolve({ started: false as const, reason: 'invalid-names' }), - cancelUpdateRun: () => Promise.resolve(), - acknowledgeUpdateRun: () => Promise.resolve(), - getUpdateRun: () => Promise.resolve({ state: 'idle' as const }), - prepareShare: () => Promise.reject(new Error('Skill publishing requires the desktop app.')), - publishShare: () => Promise.reject(new Error('Skill publishing requires the desktop app.')), - cancelShare: () => Promise.resolve(), - releaseShare: () => Promise.resolve(), - resolveShare: () => Promise.reject(new Error('Skill share links require the desktop app.')), - installShare: () => Promise.reject(new Error('Skill installation requires the desktop app.')), - installBundleShare: () => - Promise.reject(new Error('Skill installation requires the desktop app.')), - installPackageVersion: () => - Promise.reject(new Error('Skill installation requires the desktop app.')), - installBundlePackageVersion: () => - Promise.reject(new Error('Skill installation requires the desktop app.')), - cancelInstall: () => Promise.resolve({ cancelled: false }), - previewInstall: () => Promise.reject(new Error('Skill installation requires the desktop app.')), - previewBundleInstall: () => - Promise.reject(new Error('Skill installation requires the desktop app.')), - removeInstall: () => Promise.reject(new Error('Skill installation requires the desktop app.')), - listManagedInstalls: () => - Promise.reject(new Error('Skill installation requires the desktop app.')), - getPackage: () => Promise.reject(new Error('Skill installation requires the desktop app.')), - listOwnedShares: () => - Promise.reject(new Error('Skill package management requires the desktop app.')), - revokeShare: () => - Promise.reject(new Error('Skill package management requires the desktop app.')), - deletePackageVersion: () => - Promise.reject(new Error('Skill package management requires the desktop app.')), - deletePackage: () => - Promise.reject(new Error('Skill package management requires the desktop app.')), - listWslDistros: () => Promise.resolve([]), - onInstallProgress: () => () => {}, - onShareProgress: () => () => {}, - onUpdateRun: () => () => {} - } -} - -function createNotificationsApi(): NonNullable['notifications']> { - return { - dispatch: () => Promise.resolve({ delivered: false, reason: 'not-supported' }), - dismiss: () => Promise.resolve({ dismissed: 0 }), - openSystemSettings: () => Promise.resolve(), - getPermissionStatus: () => - Promise.resolve({ supported: false, platform: getBrowserPlatform(), requested: false }), - probeDelivery: () => Promise.resolve({ state: 'unsupported' as const, authoritative: false }), - playSound: () => Promise.resolve({ played: false, reason: 'missing-path' }) - } -} - -function createRateLimitsApi(): NonNullable['rateLimits']> { - const empty: RateLimitState = { - claude: null, - codex: null, - gemini: null, - opencodeGo: null, - kimi: null, - antigravity: null, - minimax: null, - grok: null, - minimaxCookieConfigured: false, - grokAuthConfigured: false, - claudeTarget: { runtime: 'host', wslDistro: null }, - codexTarget: { runtime: 'host', wslDistro: null }, - inactiveClaudeAccounts: [], - inactiveCodexAccounts: [] - } - return { - get: () => Promise.resolve(empty), - refresh: () => Promise.resolve(empty), - refreshCodexForTarget: () => Promise.resolve(empty), - // Why: web clients don't own local Codex auth; report the safe no-credit outcome since redemption is desktop-only. - consumeCodexResetCredit: () => Promise.resolve({ outcome: 'noCredit', state: empty }), - refreshClaudeForTarget: () => Promise.resolve(empty), - setPollingInterval: () => Promise.resolve(), - fetchInactiveClaudeAccounts: () => Promise.resolve(), - fetchInactiveCodexAccounts: () => Promise.resolve(), - refreshMiniMax: () => Promise.resolve(empty), - refreshGrok: () => Promise.resolve(empty), - onUpdate: () => noopUnsubscribe - } -} - -function createMiniMaxCredentialsApi(): NonNullable['minimaxCredentials']> { - const notConfigured = { configured: false } - const unsupportedError = new Error('MiniMax cookie storage is only available in the desktop app.') - return { - getStatus: () => Promise.resolve(notConfigured), - saveCookie: () => Promise.reject(unsupportedError), - clearCookie: () => Promise.resolve(notConfigured) - } -} - -function createGrokAccountsApi(): NonNullable['grokAccounts']> { - const unsigned = { - signedIn: false, - email: null, - teamId: null, - tokenFresh: false, - error: null - } - return { - getStatus: () => Promise.resolve(unsigned) - } -} - -function createAccountsApi(): never { - const empty = { - accounts: [], - activeAccountId: null, - activeAccountIdsByRuntime: { host: null, wsl: {} } - } - return { - list: () => Promise.resolve(empty), - add: () => Promise.resolve(empty), - cancelPendingLogin: () => Promise.resolve(false), - reauthenticate: () => Promise.resolve(empty), - remove: () => Promise.resolve(empty), - select: () => Promise.resolve(empty), - // Why: launch accounts are recorded on the host that owns the PTY, which the - // web client never is — report no stale panes rather than reject the sweep. - listStalePanes: () => Promise.resolve([]), - // Why empty rather than absent: the same host owns both records, so a web - // client has no recorded lane to offer and every pane falls to derivation. - listRecordedPaneLanes: () => Promise.resolve({}), - forgetStalePanes: () => Promise.resolve() - } as never -} - -function createUpdaterApi(): NonNullable['updater']> { - // Why: the linux-package-install recovery status can only originate in the native main process, so - // the web renderer never reaches these branches — reject loudly rather than resolve a fake result. - // A fresh Error per rejection: one shared instance would carry this function's stack, not the caller's. - const desktopOnlyMessage = 'Linux package install recovery is only available in the desktop app.' - return { - getVersion: () => Promise.resolve('web'), - getStatus: () => Promise.resolve({ state: 'idle' } as never), - check: () => Promise.resolve(), - download: () => Promise.resolve(), - quitAndInstall: () => Promise.resolve(), - dismissNudge: () => Promise.resolve(), - dismissAvailableUpdate: () => Promise.resolve(), - getLinuxPackageInstallInstructions: () => Promise.reject(new Error(desktopOnlyMessage)), - showLinuxPackage: () => Promise.reject(new Error(desktopOnlyMessage)), - // Why: the web client cannot install a desktop build, so channel switching - // reports unavailable rather than an empty list that looks like a fetch miss. - listBuilds: (channel) => - Promise.resolve({ - ok: false, - channel, - message: translate( - 'auto.components.settings.ReleaseChannelSection.webUnavailable', - 'Switching builds is only available in the desktop app.' - ) - }), - onStatus: () => noopUnsubscribe, - onClearDismissal: () => noopUnsubscribe - } -} - -function createShellApi(): NonNullable['shell']> { - const openResult = { ok: true } as const - return { - openPath: (path) => - Promise.resolve(window.open(path, '_blank', 'noopener,noreferrer') as never), - openInFileManager: () => Promise.resolve(openResult), - openInExternalEditor: () => Promise.resolve(openResult), - openUrl: (url) => Promise.resolve(window.open(url, '_blank', 'noopener,noreferrer') as never), - openFilePath: () => Promise.resolve(false), - openFileUri: (uri) => - Promise.resolve(window.open(uri, '_blank', 'noopener,noreferrer') as never), - pathExists: async (path) => { - try { - await resolveRuntimeFilePath(path) - return true - } catch { - return false - } - }, - pickAttachment: () => Promise.resolve(null), - pickImage: () => Promise.resolve(null), - pickRepoIconImage: () => Promise.resolve(null), - pickAudio: () => Promise.resolve(null), - pickDirectory: () => Promise.resolve(null), - copyFile: () => Promise.resolve() - } -} - -function createPtyApi(): NonNullable['pty']> { - return { - spawn: () => Promise.reject(new Error('Local PTYs are unavailable in the web client.')), - write: () => {}, - writeAccepted: () => Promise.resolve(false), - resize: () => {}, - claimViewport: () => {}, - reportGeometry: () => {}, - signal: () => {}, - // Web panes clear the host buffer via the terminal.clearBuffer runtime RPC. - clearBuffer: () => {}, - kill: () => Promise.resolve(), - ackColdRestore: () => {}, - ackData: () => {}, - onDeliveryResyncRequest: () => noopUnsubscribe, - respondDeliveryResync: () => {}, - // Why: web terminals bypass main's delivery gate; a zero-in-flight reply keeps the watchdog idle. - reportRendererDeliveryState: () => - Promise.resolve({ inFlightTotalChars: 0, inFlightPtyCount: 0, msSinceLastAck: null }), - getPtyDataListenerCount: () => 0, - rendererDispatcherReady: () => {}, - setActiveRendererPty: () => {}, - setRendererPtyVisible: () => {}, - setHiddenRendererPty: () => {}, - setPtyDeliveryInterest: () => {}, - // Why: remote-runtime PTYs are never hidden-gate markable, so there's no main-side responder to feed. - publishTerminalViewAttributes: () => {}, - hasChildProcesses: () => Promise.resolve(false), - getForegroundProcess: () => Promise.resolve(null), - inspectProcess: () => Promise.reject(new Error('terminal_liveness_unavailable')), - // Why: paired web panes cannot provide a local post-boundary process scan. - confirmForegroundProcess: () => Promise.resolve(null), - getCwd: () => Promise.resolve('~'), - getSize: () => Promise.resolve(null), - listSessions: () => Promise.resolve([]), - getAuthoritativeBufferSnapshotCapabilities: (ids) => - Promise.resolve(ids.map((id) => ({ id, authoritative: false }))), - hasPty: () => Promise.resolve(null), - getMainBufferSnapshot: () => Promise.resolve(null), - // Why: remote-runtime PTYs skip local main (no side-effect source); renderer byte parsing stays authoritative. - onSideEffect: () => noopUnsubscribe, - getSideEffectSnapshot: () => Promise.resolve(null), - getRendererDeliveryDebugSnapshot: () => - Promise.resolve({ - pendingPtyCount: 0, - pendingChars: 0, - maxPendingCharsByPty: 0, - rendererInFlightPtyCount: 0, - rendererInFlightChars: 0, - maxRendererInFlightCharsByPty: 0, - activeRendererPtyCount: 0, - flushScheduled: false, - peakPendingChars: 0, - peakMaxPendingCharsByPty: 0, - peakRendererInFlightChars: 0, - peakMaxRendererInFlightCharsByPty: 0, - ackGatedFlushSkipCount: 0, - hiddenDeliveryGatedPtyCount: 0, - hiddenDeliveryGatedVisiblePtyCount: 0, - hiddenDeliveryGatedActivePtyCount: 0, - deliveryInterestPtyCount: 0, - hiddenDeliveryDroppedChars: 0, - hiddenDeliveryDroppedChunks: 0, - pendingDroppedChars: 0, - diagnostics: EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS, - rendererLifecycleResetCount: 0, - lastLifecycleResetClearedChars: 0, - rendererPtyDispatcherReady: false, - rendererDispatcherReadyForcedCount: 0 - }), - resetRendererDeliveryDebug: () => Promise.resolve(), - onData: () => noopUnsubscribe, - onReplay: () => noopUnsubscribe, - onModelRestoreNeeded: () => noopUnsubscribe, - onExit: () => noopUnsubscribe, - onSpawned: () => noopUnsubscribe, - onSerializeBufferRequest: () => noopUnsubscribe, - onClearBufferRequest: () => noopUnsubscribe, - sendSerializedBuffer: () => {}, - declarePendingPaneSerializer: () => Promise.resolve(0), - settlePaneSerializer: () => Promise.resolve(), - clearPendingPaneSerializer: () => Promise.resolve(), - reportRendererSerializerReady: () => Promise.resolve(), - management: { - listSessions: () => Promise.resolve({ sessions: [], degraded: false }), - killAll: () => Promise.resolve({ killedCount: 0, remainingCount: 0, killedSessionIds: [] }), - killOne: () => Promise.resolve({ success: false }), - restart: () => Promise.resolve({ success: false }), - // Why: web clients can't inspect the host daemon's pid record; 'unknown' keeps the banner hidden. - macTccAttribution: () => Promise.resolve({ health: 'unknown' as const }) - } - } -} - -function createSshApi(): NonNullable['ssh']> { - return { - // Why: SSH is owned by the paired host; route read/connect to runtime RPC for state/reconnect (STA-1468). Target mgmt is desktop-only. - listTargets: async () => { - if (!requireActiveEnvironmentOrNull()) { - return [] - } - const { targets } = await callRuntimeResult<{ targets: SshTarget[] }>( - 'ssh.listTargetSummaries' - ) - return targets - }, - listRemovedTargetLabels: async () => { - if (!requireActiveEnvironmentOrNull()) { - return {} - } - const { labels } = await callRuntimeResult<{ labels: Record }>( - 'ssh.listRemovedTargetLabels' - ) - return labels - }, - addTarget: () => - Promise.reject(new Error('SSH target management is unavailable in the web client.')), - updateTarget: () => - Promise.reject(new Error('SSH target management is unavailable in the web client.')), - removeTarget: () => Promise.resolve(), - importConfig: () => Promise.resolve({ targets: [], repoReadoptions: [] }), - listConfigHosts: () => - Promise.resolve({ - hosts: [], - totalHostCount: 0, - newHostCount: 0, - matchCount: 0, - hasMore: false - }), - resolveConfigHost: () => Promise.resolve(null), - connect: async (args) => { - const { state } = await callRuntimeResult<{ state: SshConnectionState | null }>( - 'ssh.connect', - { targetId: args.targetId } - ) - return state - }, - disconnect: () => Promise.resolve(), - terminateSessions: () => Promise.resolve(), - resetRelay: () => Promise.resolve(), - getState: async (args) => { - if (!requireActiveEnvironmentOrNull()) { - return null - } - const { state } = await callRuntimeResult<{ state: SshConnectionState | null }>( - 'ssh.getState', - { targetId: args.targetId } - ) - return state - }, - needsPassphrasePrompt: () => Promise.resolve(false), - testConnection: () => - Promise.resolve({ - success: false, - error: translate('auto.web.web.preload.api.31bfe8ae1a', 'Unavailable in the web client.') - }), - onStateChanged: () => noopUnsubscribe, - addPortForward: () => - Promise.reject(new Error('SSH port forwarding is unavailable in the web client.')), - updatePortForward: () => - Promise.reject(new Error('SSH port forwarding is unavailable in the web client.')), - removePortForward: () => Promise.resolve(null), - listPortForwards: () => Promise.resolve([]), - listDetectedPorts: () => Promise.resolve([]), - onPortForwardsChanged: () => noopUnsubscribe, - onDetectedPortsChanged: () => noopUnsubscribe, - browseDir: () => Promise.resolve({ entries: [], resolvedPath: '', pathFlavor: 'posix' }), - onCredentialRequest: () => noopUnsubscribe, - onCredentialResolved: () => noopUnsubscribe, - submitCredential: () => Promise.resolve() - } -} - -async function callRuntimeEnvelope( - method: string, - params?: unknown, - timeoutMs?: number -): Promise> { - const environment = requireActiveEnvironment() - if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { - return manuallyDisconnectedResponse(environment) - } - const response = await runtimeCallQueuePool.enqueue(environment.id, method, () => { - if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { - return Promise.resolve(manuallyDisconnectedResponse(environment)) - } - return getClientForEnvironment(environment).call(method, params, { timeoutMs }) - }) - if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { - return manuallyDisconnectedResponse(environment) - } - updateEnvironmentFromResponse(environment, response) - return response as RuntimeRpcResponse -} - -async function callEnvironmentEnvelope( - selector: string, - method: string, - params?: unknown, - timeoutMs?: number -): Promise> { - const environment = resolveEnvironment(selector) - if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { - return manuallyDisconnectedResponse(environment) - } - const response = await runtimeCallQueuePool.enqueue(environment.id, method, () => { - if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { - return Promise.resolve(manuallyDisconnectedResponse(environment)) - } - return getClientForEnvironment(environment).call(method, params, { timeoutMs }) - }) - if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { - return manuallyDisconnectedResponse(environment) - } - updateEnvironmentFromResponse(environment, response) - return response as RuntimeRpcResponse -} - -async function callRuntimeResult( - method: string, - params?: unknown, - timeoutMs?: number -): Promise { - const response = await callRuntimeEnvelope(method, params, timeoutMs) - if (!response.ok) { - throw new Error(response.error.message) - } - return response.result as TResult -} - -async function callRuntimeResultWithOwner( - method: string, - params?: unknown, - timeoutMs?: number -): Promise<{ result: TResult; hostId: ExecutionHostId; environmentId: string }> { - const environmentId = requireActiveEnvironment().id - const result = await callRuntimeResult(method, params, timeoutMs) - return { result, hostId: toRuntimeExecutionHostId(environmentId), environmentId } -} - -function withRuntimeRepoOwner(repo: Repo, hostId: ExecutionHostId): Repo { - return { ...repo, executionHostId: hostId } -} - -function withRuntimeRepoMutationOwner( - result: { repo: Repo } | { error: string }, - hostId: ExecutionHostId -): { repo: Repo } | { error: string } { - return 'repo' in result ? { ...result, repo: withRuntimeRepoOwner(result.repo, hostId) } : result -} - -function withRuntimeWorktreeOwner(worktree: T, hostId: ExecutionHostId): T { - const runtimeOwner = parseExecutionHostId(hostId) - if (runtimeOwner?.kind !== 'runtime') { - return worktree - } - return { ...worktree, runtimeOwnerEnvironmentId: runtimeOwner.environmentId } -} - -function captureWebFileMutationSession(): { - resolveFilePath: (filePath: string) => Promise>> - assertMutationSupported: () => Promise - callRuntimeResult: WebRuntimeResultCaller - getSshState: (targetId: string) => Promise -} { - const environment = requireActiveEnvironment() - const client = getClientForEnvironment(environment) - const assertCurrent = (): void => { - if (activeClient !== client || requireActiveEnvironmentOrNull()?.id !== environment.id) { - throw new Error('Runtime pairing changed; refresh and try again') - } - } - const callBoundRuntimeEnvelope: WebRuntimeEnvelopeCaller = async ( - method: string, - params?: unknown, - timeoutMs?: number - ): Promise> => { - assertCurrent() - const response = await runtimeCallQueuePool.enqueue(environment.id, method, () => { - assertCurrent() - return client.call(method, params, { timeoutMs }) - }) - assertCurrent() - updateEnvironmentFromResponse(environment, response) - return response as RuntimeRpcResponse - } - const callBoundRuntimeResult: WebRuntimeResultCaller = async ( - method: string, - params?: unknown, - timeoutMs?: number - ): Promise => { - const response = await callBoundRuntimeEnvelope(method, params, timeoutMs) - if (!response.ok) { - throw new Error(response.error.message) - } - return response.result as TResult - } - return { - resolveFilePath: (filePath) => - resolveRuntimeFilePath( - filePath, - undefined, - callBoundRuntimeResult, - callBoundRuntimeEnvelope, - false, - environment.id - ), - assertMutationSupported: async () => { - assertFileMutationOwnershipCapability( - await callBoundRuntimeResult('status.get', undefined, 15_000) - ) - }, - callRuntimeResult: callBoundRuntimeResult, - getSshState: async (targetId) => - ( - await callBoundRuntimeResult<{ state: SshConnectionState | null }>('ssh.getState', { - targetId - }) - ).state - } -} - -async function saveClipboardImageAsTempFileInRuntime( - contentBase64: string, - args?: { connectionId?: string | null; runtimeEnvironmentId?: string | null } -): Promise { - if (contentBase64.length > MAX_CLIPBOARD_IMAGE_BASE64_CHARS) { - throw new Error(CLIPBOARD_IMAGE_TOO_LARGE_ERROR) - } - const connectionId = args?.connectionId ?? null - const startResponse = await callRuntimeEnvelope<{ uploadId: string }>( - 'clipboard.startImageUpload', - { expectedBase64Length: contentBase64.length, connectionId }, - CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS - ) - if (!startResponse.ok) { - if ( - startResponse.error.code === 'method_not_found' && - contentBase64.length <= CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS - ) { - return callRuntimeResult( - 'clipboard.saveImageAsTempFile', - { contentBase64, connectionId }, - CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS - ) - } - throw new Error(startResponse.error.message) - } - - const { uploadId } = startResponse.result - try { - for ( - let offset = 0; - offset < contentBase64.length; - offset += CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS - ) { - await callRuntimeResult( - 'clipboard.appendImageUploadChunk', - { - uploadId, - offset, - contentBase64: contentBase64.slice( - offset, - offset + CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS - ) - }, - CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS - ) - } - return await callRuntimeResult( - 'clipboard.commitImageUpload', - { uploadId }, - CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS - ) - } catch (error) { - // Why: after chunked paste holds server-side state, release the bounded slot on failure rather than wait for TTL cleanup. - await callRuntimeResult( - 'clipboard.abortImageUpload', - { uploadId }, - CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS - ).catch(() => {}) - throw error - } -} - -async function getRemoteRuntimeStatus(): Promise { - return callRuntimeResult('status.get', undefined, 15_000) -} - -function getClientForEnvironment(environment: StoredWebRuntimeEnvironment): WebRuntimeClient { - if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { - throw new Error('runtime_manually_disconnected') - } - if (!activeClient || activeClientEnvironmentId !== environment.id) { - activeClient?.close() - activeClient = new WebRuntimeClient(getPreferredWebPairingOffer(environment)) - activeClientEnvironmentId = environment.id - } - return activeClient -} - -function closeActiveRuntimeClients(): void { - activeClient?.close() - activeClient = null - activeClientEnvironmentId = null - invalidateRuntimeWorktreeCaches() -} - -function disconnectActiveRuntimeEnvironment(): void { - closeActiveRuntimeClients() -} - -function removeActiveRuntimeEnvironment(): void { - disconnectActiveRuntimeEnvironment() - clearStoredWebRuntimeEnvironment() - activeEnvironment = null -} - -function manuallyDisconnectedResponse( - environment: StoredWebRuntimeEnvironment -): RuntimeRpcResponse { - return { - id: 'runtime.manualDisconnect', - ok: false, - error: { - code: 'runtime_manually_disconnected', - message: translate( - 'auto.web.webPreloadApi.runtimeEnvironmentManuallyDisconnected', - 'Runtime environment is manually disconnected.' - ) - }, - _meta: { runtimeId: environment.runtimeId } - } -} - -function resolveEnvironment(selector: string): StoredWebRuntimeEnvironment { - const environment = requireActiveEnvironment() - if (selector === environment.id || selector === environment.name || selector === 'active') { - return environment - } - if (environment.compatibleEnvironmentIds?.includes(selector)) { - return environment - } - throw new Error(`Unknown Orca runtime environment: ${selector}`) -} - -function requireActiveEnvironment(): StoredWebRuntimeEnvironment { - activeEnvironment = activeEnvironment ?? readStoredWebRuntimeEnvironment() - if (!activeEnvironment) { - throw new Error('Pair this web client with an Orca server first.') - } - return activeEnvironment -} - -function requireActiveEnvironmentOrNull(): StoredWebRuntimeEnvironment | null { - activeEnvironment = activeEnvironment ?? readStoredWebRuntimeEnvironment() - return activeEnvironment -} - -function assertActiveEnvironment(environmentId: string): void { - if (requireActiveEnvironment().id !== environmentId) { - throw new Error('The paired Orca server changed while the request was in progress.') - } -} - -function updateEnvironmentFromResponse( - environment: StoredWebRuntimeEnvironment, - response: RuntimeRpcResponse -): void { - if (activeEnvironment?.id !== environment.id) { - return - } - const runtimeId = response.ok ? response._meta.runtimeId : (response._meta?.runtimeId ?? null) - const pairedDeviceId = - response.ok && - typeof response.result === 'object' && - response.result !== null && - typeof (response.result as { pairedDeviceId?: unknown }).pairedDeviceId === 'string' - ? (response.result as { pairedDeviceId: string }).pairedDeviceId - : undefined - activeEnvironment = updateStoredEnvironmentRuntimeId(environment, runtimeId, pairedDeviceId) -} - -function getStoredSettings(): GlobalSettings { - activeEnvironment = activeEnvironment ?? readStoredWebRuntimeEnvironment() - const defaults = getDefaultSettings('~') - const rawStoredSettings = window.localStorage.getItem(SETTINGS_STORAGE_KEY) - const stored = readJson>(SETTINGS_STORAGE_KEY, {}) - const migratedStored = { - ...stored, - ...normalizeAutoRenameBranchFromWorkDefaultOn(stored), - ...normalizeTerminalCursorStyleDefault(stored), - ...normalizeOsc52ClipboardDefaultOn(stored), - terminalCustomThemes: normalizeTerminalCustomThemes(stored.terminalCustomThemes), - uiLanguage: normalizeUiLanguage(stored.uiLanguage) - } - if ( - rawStoredSettings && - (stored.autoRenameBranchFromWork !== migratedStored.autoRenameBranchFromWork || - stored.autoRenameBranchFromWorkDefaultedOn !== - migratedStored.autoRenameBranchFromWorkDefaultedOn || - stored.terminalCursorStyle !== migratedStored.terminalCursorStyle || - stored.terminalCursorStyleDefaultedToBlock !== - migratedStored.terminalCursorStyleDefaultedToBlock || - // Kept even though the terminalCustomThemes reference compare below already forces - // this branch for every stored blob: no migration should rely on that accident. - stored.terminalAllowOsc52Clipboard !== migratedStored.terminalAllowOsc52Clipboard || - stored.terminalAllowOsc52ClipboardDefaultedOnForAllUsers !== - migratedStored.terminalAllowOsc52ClipboardDefaultedOnForAllUsers || - stored.terminalCustomThemes !== migratedStored.terminalCustomThemes || - stored.uiLanguage !== migratedStored.uiLanguage) - ) { - try { - const parsed = JSON.parse(rawStoredSettings) as unknown - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - writeJson(SETTINGS_STORAGE_KEY, migratedStored) - if (osc52ClipboardDefaultOnOverridesPersistedOff(stored)) { - // Why a raw merge, not readLocalWebUIState(): that path calls back into - // getStoredSettings(), and writing through it here would recurse. - writeJson(UI_STORAGE_KEY, { - ...readJson>(UI_STORAGE_KEY, {}), - osc52ClipboardDefaultOnNoticePending: true - }) - } - } - } catch { - // Keep readJson's invalid-JSON fallback non-destructive. - } - } - return mergeSettings( - { - ...defaults, - floatingTerminalEnabled: false, - rightSidebarOpenByDefault: false, - activeRuntimeEnvironmentId: null - }, - migratedStored - ) -} - -function writeStoredSettings( - settings: GlobalSettings, - explicitActiveRuntimeEnvironmentId?: string | null -): void { - const durable = { ...settings } - if (explicitActiveRuntimeEnvironmentId !== undefined) { - durable.activeRuntimeEnvironmentId = explicitActiveRuntimeEnvironmentId - } else { - const stored = readJson>(SETTINGS_STORAGE_KEY, {}) - if (Object.hasOwn(stored, 'activeRuntimeEnvironmentId')) { - durable.activeRuntimeEnvironmentId = stored.activeRuntimeEnvironmentId ?? null - } else { - delete durable.activeRuntimeEnvironmentId - } - } - writeJson(SETTINGS_STORAGE_KEY, durable) -} - -async function getRuntimeBackedStoredSettings(): Promise { - const local = getStoredSettings() - const requestedEnvironment = requireActiveEnvironmentOrNull() - if (!requestedEnvironment) { - return local - } - try { - const result = await callRuntimeResult<{ settings: Partial }>( - 'settings.get', - undefined, - 15_000 - ) - const runtimeSettings: Partial = {} - const currentEnvironment = requireActiveEnvironmentOrNull() - if (currentEnvironment?.id === requestedEnvironment.id) { - const visibilityDefaults = normalizeWorktreeVisibilityDefaults( - result.settings.worktreeVisibilityDefaults - ) - worktreeVisibilityDefaultsRuntimeEnvironmentId = visibilityDefaults - ? requestedEnvironment.id - : null - worktreeVisibilityDefaultsRuntimeValue = visibilityDefaults ?? null - } - if (typeof result.settings.experimentalNewWorktreeCardStyle === 'boolean') { - runtimeSettings.experimentalNewWorktreeCardStyle = - result.settings.experimentalNewWorktreeCardStyle - } - if (typeof result.settings.compactWorktreeCards === 'boolean') { - runtimeSettings.compactWorktreeCards = result.settings.compactWorktreeCards - } - if (typeof result.settings.minimaxGroupId === 'string') { - runtimeSettings.minimaxGroupId = result.settings.minimaxGroupId - } - if (typeof result.settings.minimaxUsageModels === 'string') { - runtimeSettings.minimaxUsageModels = result.settings.minimaxUsageModels - } - if (Array.isArray(result.settings.prBotAuthorOverrides)) { - runtimeSettings.prBotAuthorOverrides = normalizePRBotAuthorOverrides( - result.settings.prBotAuthorOverrides - ) - } - // Read-only mirror: the host owns this capability and `syncRuntimeBackedSettings` never - // sends it back, so web shows what the host enforces instead of a local value it ignores. - if (typeof result.settings.artifactSharingEnabled === 'boolean') { - runtimeSettings.artifactSharingEnabled = result.settings.artifactSharingEnabled - } - if (typeof result.settings.agentSkillSharingEnabled === 'boolean') { - runtimeSettings.agentSkillSharingEnabled = result.settings.agentSkillSharingEnabled - } - const next = mergeSettings(local, runtimeSettings) - writeStoredSettings(next) - return settingsForActiveVisibilityOwner(next) - } catch { - // Why: unpaired/offline web clients keep a local settings fallback. - return settingsForActiveVisibilityOwner(local) - } -} - -function settingsForActiveVisibilityOwner(settings: GlobalSettings): GlobalSettings { - const environment = requireActiveEnvironmentOrNull() - if (!environment) { - return settings - } - if ( - environment.id === worktreeVisibilityDefaultsRuntimeEnvironmentId && - worktreeVisibilityDefaultsRuntimeValue - ) { - return { ...settings, worktreeVisibilityDefaults: worktreeVisibilityDefaultsRuntimeValue } - } - const { worktreeVisibilityDefaults: _unsupported, ...supportedSettings } = settings - return supportedSettings as GlobalSettings -} - -async function syncRuntimeBackedSettings( - updates: Partial, - localNext: GlobalSettings -): Promise { - const requestedEnvironment = requireActiveEnvironmentOrNull() - if (!requestedEnvironment) { - return localNext - } - const runtimeUpdates: Partial = {} - const visibilityDefaults = normalizeWorktreeVisibilityDefaults(updates.worktreeVisibilityDefaults) - if (visibilityDefaults) { - runtimeUpdates.worktreeVisibilityDefaults = visibilityDefaults - } - if (typeof updates.experimentalNewWorktreeCardStyle === 'boolean') { - runtimeUpdates.experimentalNewWorktreeCardStyle = updates.experimentalNewWorktreeCardStyle - } - if (typeof updates.compactWorktreeCards === 'boolean') { - runtimeUpdates.compactWorktreeCards = updates.compactWorktreeCards - } - if (typeof updates.minimaxGroupId === 'string') { - runtimeUpdates.minimaxGroupId = updates.minimaxGroupId - } - if (typeof updates.minimaxUsageModels === 'string') { - runtimeUpdates.minimaxUsageModels = updates.minimaxUsageModels - } - if (Array.isArray(updates.prBotAuthorOverrides)) { - runtimeUpdates.prBotAuthorOverrides = normalizePRBotAuthorOverrides( - updates.prBotAuthorOverrides - ) - } - if (Object.keys(runtimeUpdates).length === 0) { - return localNext - } - try { - const result = await callRuntimeResult<{ settings: Partial }>( - 'settings.update', - runtimeUpdates, - 15_000 - ) - const runtimeSettings = { ...result.settings } - delete runtimeSettings.activeRuntimeEnvironmentId - const updatedVisibilityDefaults = normalizeWorktreeVisibilityDefaults( - runtimeSettings.worktreeVisibilityDefaults - ) - if ( - requireActiveEnvironmentOrNull()?.id === requestedEnvironment.id && - updatedVisibilityDefaults - ) { - worktreeVisibilityDefaultsRuntimeEnvironmentId = requestedEnvironment.id - worktreeVisibilityDefaultsRuntimeValue = updatedVisibilityDefaults - } - delete runtimeSettings.worktreeVisibilityDefaults - const next = mergeSettings(localNext, runtimeSettings) - writeStoredSettings(next) - return next - } catch (error) { - if (visibilityDefaults) { - throw error - } - // Why: unpaired/offline web clients still need local settings persistence. - return localNext - } -} - -async function updateRuntimePRBotAuthorOverride(args: { - author: string - isBot: boolean -}): Promise { - const local = getStoredSettings() - if (requireActiveEnvironmentOrNull()) { - // Why: don't report a successful mark the authoritative runtime failed to persist and will later overwrite. - const result = await callRuntimeResult<{ settings: Partial }>( - 'settings.updatePRBotAuthorOverride', - args, - 15_000 - ) - const next = mergeSettings(local, { - prBotAuthorOverrides: normalizePRBotAuthorOverrides(result.settings.prBotAuthorOverrides) - }) - writeStoredSettings(next) - return next - } - const next = mergeSettings(local, { - prBotAuthorOverrides: applyPRBotAuthorOverride( - local.prBotAuthorOverrides, - args.author, - args.isBot - ) - }) - writeStoredSettings(next) - return next -} - -function getStoredOnboarding(): OnboardingState { - const storedRaw = window.localStorage.getItem(ONBOARDING_STORAGE_KEY) - if (storedRaw) { - const stored = readJson(ONBOARDING_STORAGE_KEY, getDefaultOnboardingState()) - if (stored.checklist.dismissed) { - return stored - } - const closed = closeWebOnboarding(stored) - writeJson(ONBOARDING_STORAGE_KEY, closed) - return closed - } - const closed = closeWebOnboarding(getDefaultOnboardingState()) - // Why: paired clients already have an Orca server; skip desktop first-run onboarding that would probe browser-local tools. - writeJson(ONBOARDING_STORAGE_KEY, closed) - return closed -} - -/** Resolve the localStorage key for a session partition; non-'local' hosts get a host-suffixed key so they never clobber the local one. */ -function sessionStorageKeyForHost(hostId?: string | null): string { - const resolved = normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID - return resolved === LOCAL_EXECUTION_HOST_ID - ? SESSION_STORAGE_KEY - : `${SESSION_STORAGE_KEY}.${resolved}` -} - -function getStoredWorkspaceSession(hostId?: string | null): WorkspaceSessionState { - const resolvedHostId = normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID - if (resolvedHostId !== LOCAL_EXECUTION_HOST_ID) { - return sanitizeWebRuntimeWorkspaceSession( - readJson(sessionStorageKeyForHost(resolvedHostId), getDefaultWorkspaceSession()) - ) - } - const localSession = sanitizeWebRuntimeWorkspaceSession( - readJson(SESSION_STORAGE_KEY, getDefaultWorkspaceSession()) - ) - if (!requireActiveEnvironmentOrNull()) { - return localSession - } - const ui = readLocalWebUIState() - // Why: replaying browser-local terminal handles first creates stale remote PTYs; mirror host session-tabs instead. - return sanitizeWebRuntimeWorkspaceSession({ - ...getDefaultWorkspaceSession(), - activeRepoId: ui.lastActiveRepoId, - activeWorktreeId: ui.lastActiveWorktreeId, - lastVisitedAtByWorktreeId: localSession.lastVisitedAtByWorktreeId - }) -} - -function closeWebOnboarding(base: OnboardingState): OnboardingState { - return { - ...base, - flowVersion: ONBOARDING_FLOW_VERSION, - closedAt: Date.now(), - outcome: 'dismissed', - checklist: { - ...base.checklist, - dismissed: true - } - } -} - -function readLocalWebUIState(): PersistedUIState { - const defaults = getDefaultUIState() - // Why settings first: getStoredSettings() runs the OSC 52 migration, which writes the - // notice arm into UI_STORAGE_KEY. Reading before it would snapshot a pre-arm state that - // every caller then writes back, erasing the arm the stamp can never raise again. - const storedSettings = getStoredSettings() - const stored = readJson>(UI_STORAGE_KEY, {}) - const base = { - ...defaults, - // Why: mirror the main-process missing-property seed from legacy card layout mode when runtime ui.get is unavailable. - worktreeCardProperties: getWorktreeCardModeProperties( - storedSettings.compactWorktreeCards ? 'Compact' : 'Default' - ) - } - if (typeof stored.rightSidebarOpen === 'boolean') { - return mergeWebUIState(base, stored) - } - return mergeWebUIState(base, { - ...stored, - // Why: web fallback lacks main-process normalization; migrate the retired setting only when local UI preference is absent. - rightSidebarOpen: storedSettings.rightSidebarOpenByDefault - }) -} - -function mergeWebUIState( - base: PersistedUIState, - updates: Partial -): PersistedUIState { - const { featureInteractionTelemetryBuckets: _reserved, ...safeUpdates } = - updates as Partial & { - featureInteractionTelemetryBuckets?: unknown - } - void _reserved - return { - ...base, - ...safeUpdates, - workspaceCleanup: mergeWorkspaceCleanupUIState( - base.workspaceCleanup, - safeUpdates.workspaceCleanup - ), - worktreeCardProperties: normalizeWorktreeCardProperties( - safeUpdates.worktreeCardProperties ?? base.worktreeCardProperties - ), - _worktreeCardModeDefaulted: - safeUpdates._worktreeCardModeDefaulted ?? base._worktreeCardModeDefaulted, - agentActivityDisplayMode: normalizeAgentActivityDisplayMode( - safeUpdates.agentActivityDisplayMode ?? base.agentActivityDisplayMode - ), - usagePercentageDisplay: normalizeUsagePercentageDisplay( - safeUpdates.usagePercentageDisplay ?? base.usagePercentageDisplay - ), - statusBarUsageMode: normalizeStatusBarUsageMode( - safeUpdates.statusBarUsageMode ?? base.statusBarUsageMode - ) - } -} - -// Why pin instead of trusting the host's strip: an old host still returns these fields, and for a -// profile a pre-fix drag poisoned they echo back the same runtime:web-* keys this browser minted, -// which then match its own repos and beat every newer drag. -function mergeHostWebUIState(local: PersistedUIState, incoming: PairedUiState): PersistedUIState { - // Why `satisfies Record<...>` rather than a `Pick<...>` annotation: every member is optional in - // PersistedUIState, so Pick would accept a literal that silently skipped a newly added member. - const pinned = { - hideWorkspacesFromOtherDevices: local.hideWorkspacesFromOtherDevices === true, - manualRepoOrder: local.manualRepoOrder, - workspaceHostOrder: local.workspaceHostOrder - } satisfies Record & Partial - return { ...mergeWebUIState(local, incoming), ...pinned } -} - -function mergeFeatureInteractionState( - current: PersistedUIState['featureInteractions'], - incoming: PersistedUIState['featureInteractions'] -): FeatureInteractionState { - const currentNormalized = normalizeFeatureInteractions(current) - const incomingNormalized = normalizeFeatureInteractions(incoming) - const merged: FeatureInteractionState = { ...currentNormalized } - for (const [id, incomingRecord] of Object.entries(incomingNormalized)) { - const featureId = id as FeatureInteractionId - const currentRecord = currentNormalized[featureId] - merged[featureId] = currentRecord - ? { - firstInteractedAt: Math.min( - currentRecord.firstInteractedAt, - incomingRecord.firstInteractedAt - ), - interactionCount: Math.max( - currentRecord.interactionCount, - incomingRecord.interactionCount - ) - } - : incomingRecord - } - return merged -} - -function mergeContextualTourSeenIds( - current: PersistedUIState['contextualToursSeenIds'], - incoming: PersistedUIState['contextualToursSeenIds'] -): ContextualTourId[] { - const merged = new Set(normalizeContextualTourIds(current)) - for (const id of normalizeContextualTourIds(incoming)) { - merged.add(id) - } - return [...merged] -} - -/** Why OR rather than let the host win: the web client migrates its own localStorage - * settings, so an arm raised here has no counterpart on the host, and the plain spread - * would drop it — flipping the opt-out in silence, which is what the notice exists to stop. */ -function mergeOsc52ClipboardNoticePending( - current: PersistedUIState, - incoming: PersistedUIState -): boolean { - return ( - current.osc52ClipboardDefaultOnNoticePending === true || - incoming.osc52ClipboardDefaultOnNoticePending === true - ) -} - -function mergeSettings( - base: GlobalSettings, - updates: Partial, - options: { preserveAutoRenameBranchFromWorkUpdate?: boolean } = {} -): GlobalSettings { - const defaults = getDefaultSettings('~') - const merged = { - ...base, - ...updates, - notifications: { - ...base.notifications, - ...updates.notifications - }, - githubProjects: { - ...(base.githubProjects ?? defaults.githubProjects), - ...updates.githubProjects - } as GlobalSettings['githubProjects'], - disabledTuiAgents: normalizeDisabledTuiAgents( - updates.disabledTuiAgents ?? base.disabledTuiAgents - ), - agentDefaultArgs: normalizeTuiAgentArgsRecord( - updates.agentDefaultArgs ?? base.agentDefaultArgs - ), - agentDefaultEnv: normalizeTuiAgentEnvRecord(updates.agentDefaultEnv ?? base.agentDefaultEnv), - voice: { - ...(base.voice ?? defaults.voice), - ...updates.voice - } as NonNullable, - activeRuntimeEnvironmentId: Object.hasOwn(updates, 'activeRuntimeEnvironmentId') - ? (updates.activeRuntimeEnvironmentId ?? null) - : (base.activeRuntimeEnvironmentId ?? null), - terminalCustomThemes: normalizeTerminalCustomThemes( - updates.terminalCustomThemes ?? base.terminalCustomThemes - ), - uiLanguage: normalizeUiLanguage(updates.uiLanguage ?? base.uiLanguage) - } - return { - ...merged, - ...normalizeAutoRenameBranchFromWorkDefaultOn(merged, { - preserveExplicitValue: options.preserveAutoRenameBranchFromWorkUpdate - }) - } -} - -async function listAllRuntimeWorktrees(): Promise { - if (cachedWorktrees && Date.now() - cachedWorktrees.loadedAt < 5_000) { - return cachedWorktrees.worktrees - } - const owned = await callRuntimeResultWithOwner<{ worktrees: Worktree[] }>('worktree.list', { - limit: WEB_RUNTIME_WORKTREE_LIST_LIMIT - }) - const worktrees = owned.result.worktrees.map((worktree) => - withRuntimeWorktreeOwner(worktree, owned.hostId) - ) - assertActiveEnvironment(owned.environmentId) - cachedWorktrees = { loadedAt: Date.now(), worktrees } - return worktrees -} - -async function listAllRuntimeDetectedWorktrees( - callResult: WebRuntimeResultCaller = callRuntimeResult, - callEnvelope: WebRuntimeEnvelopeCaller = callRuntimeEnvelope, - useCache = true, - expectedEnvironmentId = requireActiveEnvironment().id -): Promise { - if ( - useCache && - cachedDetectedWorktrees && - Date.now() - cachedDetectedWorktrees.loadedAt < 5_000 - ) { - return cachedDetectedWorktrees.worktrees - } - - assertActiveEnvironment(expectedEnvironmentId) - const repos = (await callResult<{ repos: Repo[] }>('repo.list')).repos - const detectedLists = await Promise.all( - repos.map((repo) => - callRuntimeDetectedWorktrees(repo.id, expectedEnvironmentId, callResult, callEnvelope) - ) - ) - const worktrees = detectedLists.flatMap((result) => result.worktrees) - assertActiveEnvironment(expectedEnvironmentId) - if (useCache) { - cachedDetectedWorktrees = { loadedAt: Date.now(), worktrees } - } - return worktrees -} - -async function callRuntimeDetectedWorktrees( - repoId: string, - expectedEnvironmentId = requireActiveEnvironment().id, - callResult: WebRuntimeResultCaller = callRuntimeResult, - callEnvelope: WebRuntimeEnvelopeCaller = callRuntimeEnvelope -): Promise { - assertActiveEnvironment(expectedEnvironmentId) - const hostId = toRuntimeExecutionHostId(expectedEnvironmentId) - const response = await callEnvelope( - 'worktree.detectedList', - { repo: repoId }, - 15_000 - ) - if (response.ok) { - return { - ...response.result, - worktrees: response.result.worktrees.map((worktree) => - withRuntimeWorktreeOwner(worktree, hostId) - ) - } - } - if (response.error.code !== 'method_not_found') { - throw new Error(response.error.message) - } - - assertActiveEnvironment(expectedEnvironmentId) - const legacy = await callResult<{ worktrees: Worktree[] }>( - 'worktree.list', - { repo: repoId, limit: WEB_RUNTIME_WORKTREE_LIST_LIMIT }, - 15_000 - ) - return toLegacyDetectedWorktreeResult( - repoId, - legacy.worktrees.map((worktree) => withRuntimeWorktreeOwner(worktree, hostId)) - ) -} - -function toLegacyDetectedWorktreeResult( - repoId: string, - worktrees: Worktree[] -): DetectedWorktreeListResult { - return { - repoId, - authoritative: true, - source: 'session-fallback', - worktrees: worktrees.map((worktree) => ({ - ...worktree, - ownership: 'orca-managed', - selectedCheckout: false, - visible: true - })) - } -} - -function isMissingPathError(error: unknown): boolean { - if (!(error instanceof Error)) { - return false - } - return /\bENOENT\b|not found|no such file/i.test(error.message) -} - -async function resolveRuntimeWorktreeByPath( - worktreePath: string, - callResult: WebRuntimeResultCaller = callRuntimeResult, - callEnvelope: WebRuntimeEnvelopeCaller = callRuntimeEnvelope, - useDetectedWorktreeCache = true, - expectedEnvironmentId = requireActiveEnvironment().id -): Promise { - // Why: hidden-but-open worktrees must still resolve, but `worktree.list` is sidebar-visible only — resolve via detected rows. - const worktrees = await listAllRuntimeDetectedWorktrees( - callResult, - callEnvelope, - useDetectedWorktreeCache, - expectedEnvironmentId - ) - const match = worktrees - .map((worktree) => ({ - worktree, - relativePath: relativePathInsideRoot(worktree.path, worktreePath) - })) - .filter((entry) => entry.relativePath !== null) - .sort((a, b) => b.worktree.path.length - a.worktree.path.length)[0] - if (!match) { - throw new Error(`No runtime worktree owns ${worktreePath}`) - } - return match.worktree -} - -async function resolveRuntimeFilePath( - filePath: string, - preferredWorktreePath?: string, - callResult: WebRuntimeResultCaller = callRuntimeResult, - callEnvelope: WebRuntimeEnvelopeCaller = callRuntimeEnvelope, - useDetectedWorktreeCache = true, - expectedEnvironmentId = requireActiveEnvironment().id -): Promise<{ worktree: Worktree; relativePath: string }> { - const worktree = preferredWorktreePath - ? await resolveRuntimeWorktreeByPath( - preferredWorktreePath, - callResult, - callEnvelope, - useDetectedWorktreeCache, - expectedEnvironmentId - ) - : await resolveRuntimeWorktreeByPath( - filePath, - callResult, - callEnvelope, - useDetectedWorktreeCache, - expectedEnvironmentId - ) - const relativePath = relativePathInsideRoot(worktree.path, filePath) - if (relativePath === null) { - throw new Error(`File is outside runtime worktree: ${filePath}`) - } - return { worktree, relativePath } -} - -async function mutateGitPath( - method: string, - worktreePath: string, - filePath: string -): Promise { - const file = await resolveRuntimeFilePath(filePath, worktreePath) - await callRuntimeResult(method, { - worktree: toRuntimeWorktreeSelector(file.worktree.id), - filePath: file.relativePath - }) -} - -async function mutateGitPaths( - method: string, - worktreePath: string, - filePaths: string[] -): Promise { - const worktree = await resolveRuntimeWorktreeByPath(worktreePath) - await callRuntimeResult(method, { worktree: toRuntimeWorktreeSelector(worktree.id), filePaths }) -} - -function mapRepoPathArg(args: unknown): unknown { - if (!args || typeof args !== 'object' || !('repoPath' in args)) { - return args - } - const record = args as Record - const repoId = typeof record.repoId === 'string' && record.repoId.trim() ? record.repoId : null - return { - ...record, - // Why: duplicate checked-out repos make path/name selectors ambiguous; prefer the explicit repo id the renderer passes. - repo: repoId ? `id:${repoId}` : record.repoPath - } -} - -function mapRuntimeNamespaceArg(prefix: string, args: unknown): unknown { - if (prefix !== 'hostedReview') { - return args - } - return mapRepoPathArg(args) -} - -function createEmptyMemorySnapshot(): MemorySnapshot { - const emptyUsage = { cpu: 0, memory: 0 } - return { - app: { ...emptyUsage, main: emptyUsage, renderer: emptyUsage, other: emptyUsage, history: [] }, - worktrees: [], - host: { - totalMemory: 0, - freeMemory: 0, - availableMemory: 0, - availableMemorySource: 'free-memory', - usedMemory: 0, - memoryUsagePercent: 0, - cpuCoreCount: navigator.hardwareConcurrency || 1, - loadAverage1m: 0 - }, - processMemoryMetric: getBrowserPlatform() === 'win32' ? 'working-set' : 'rss', - totalCpu: 0, - totalMemory: 0, - collectedAt: Date.now() - } -} - -function getBrowserPlatform(): NodeJS.Platform { - if (navigator.userAgent.includes('Windows')) { - return 'win32' - } - if (navigator.userAgent.includes('Linux')) { - return 'linux' - } - return 'darwin' -} - -function readJson(key: string, fallback: T): T { - const raw = window.localStorage.getItem(key) - if (!raw) { - return cloneJson(fallback) - } - try { - return { ...cloneJson(fallback), ...JSON.parse(raw) } as T - } catch { - return cloneJson(fallback) - } -} - -function writeJson(key: string, value: T): void { - window.localStorage.setItem(key, JSON.stringify(value)) -} - -function cloneJson(value: T): T { - return JSON.parse(JSON.stringify(value)) as T -} - -function withFallback(target: T, path: string[]): T { - return new Proxy(target, { - get(current, property, receiver) { - if (property in current) { - const value = Reflect.get(current, property, receiver) as unknown - if (value && typeof value === 'object' && !Array.isArray(value)) { - return withFallback(value as object, [...path, String(property)]) - } - return value - } - return createFallbackProxy([...path, String(property)]) - } - }) -} - -function createFallbackProxy( - path: string[], - applyOverride?: (path: string[], args: unknown[]) => unknown -): never { - const fn = () => undefined - return new Proxy(fn, { - get(_target, property) { - if (property === 'then') { - return undefined - } - return createFallbackProxy([...path, String(property)], applyOverride) - }, - apply(_target, _thisArg, args) { - if (applyOverride) { - return applyOverride(path, args) - } - return getFallbackResult(path, args) - } - }) as never -} - -function getFallbackResult(path: string[], args: unknown[]): unknown { - const name = path.at(-1) ?? '' - if (name.startsWith('on')) { - return noopUnsubscribe - } - if (name.startsWith('is') || name.startsWith('has') || name === 'pathExists') { - return Promise.resolve(false) - } - if (name.startsWith('list') || name.startsWith('detect')) { - return Promise.resolve([]) - } - if (name.startsWith('preview')) { - return Promise.resolve({ found: false, diff: {}, unsupportedKeys: [] }) - } - if (name.startsWith('get') && name.endsWith('Status')) { - return Promise.resolve([]) - } - if (name === 'write' || name === 'resize' || name === 'reportGeometry') { - return undefined - } - if (args.length === 0 && (name === 'getZoomLevel' || name === 'declarePendingPaneSerializer')) { - return 0 - } - return Promise.resolve(undefined) -} - -function noopUnsubscribe(): void {} diff --git a/src/renderer/src/web/web-runtime-client-timeout-budget.test.ts b/src/renderer/src/web/web-runtime-client-timeout-budget.test.ts new file mode 100644 index 00000000000..4696643e08a --- /dev/null +++ b/src/renderer/src/web/web-runtime-client-timeout-budget.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WebRuntimeClient } from './web-runtime-client' + +class TimeoutBudgetWebSocket { + static readonly CONNECTING = 0 + static readonly OPEN = 1 + readyState = TimeoutBudgetWebSocket.CONNECTING + binaryType = 'arraybuffer' + onopen: (() => void) | null = null + onmessage: ((event: { data: unknown }) => void) | null = null + onclose: (() => void) | null = null + onerror: (() => void) | null = null + close = vi.fn() + send = vi.fn() +} + +describe('WebRuntimeClient timeout budget', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.stubGlobal('window', { + setTimeout, + clearTimeout, + setInterval, + clearInterval, + atob: (value: string) => Buffer.from(value, 'base64').toString('binary'), + btoa: (value: string) => Buffer.from(value, 'binary').toString('base64') + }) + vi.stubGlobal('WebSocket', TimeoutBudgetWebSocket) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('starts a fresh request timeout after the connection wait completes', async () => { + const client = new WebRuntimeClient({ + v: 2, + endpoint: 'ws://127.0.0.1:6768', + deviceToken: 'token', + publicKeyB64: Buffer.alloc(32).toString('base64') + }) + client.close() + let resolveConnection!: () => void + const connection = new Promise((resolve) => { + resolveConnection = resolve + }) + // Test seam: isolate call's connection and request phases from socket handshaking. + const internals = client as unknown as { + waitForConnected: (timeoutMs?: number) => Promise + sendEncrypted: (message: unknown) => boolean + } + internals.waitForConnected = vi.fn(() => connection) + internals.sendEncrypted = vi.fn(() => true) + + const call = client.call('runtime.two-phase-timeout', undefined, { timeoutMs: 25 }) + let settled = false + void call.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + + await vi.advanceTimersByTimeAsync(60_000) + expect(settled).toBe(false) + expect(internals.waitForConnected).toHaveBeenCalledWith(25) + + resolveConnection() + await Promise.resolve() + await vi.advanceTimersByTimeAsync(24) + expect(settled).toBe(false) + await vi.advanceTimersByTimeAsync(1) + await expect(call).rejects.toThrow('Request timed out: runtime.two-phase-timeout') + }) +})