From d1abe28471effc4f3a190811b8aaa312abb86852 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:28:18 -0700 Subject: [PATCH] refactor(preload): split bridge API modules (cherry picked from commit b77e31873bc8bc5683703be1bfdc90b35f13f9f0) --- config/tsconfig.tc.web.json | 11 + src/preload/api/agent-awake-bridge.ts | 13 + src/preload/api/agent-status-bridge.ts | 88 + src/preload/api/agent-trust-bridge.ts | 9 + src/preload/api/ai-vault-bridge.ts | 34 + src/preload/api/app-bridge.ts | 76 + src/preload/api/automations-bridge.ts | 59 + src/preload/api/bitbucket-bridge.ts | 16 + src/preload/api/browser-bridge-part-1.ts | 197 + src/preload/api/browser-bridge-part-2.ts | 165 + src/preload/api/browser-bridge.ts | 4 + src/preload/api/cache-bridge.ts | 7 + src/preload/api/claude-accounts-bridge.ts | 18 + src/preload/api/claude-usage-bridge.ts | 4 + src/preload/api/cli-bridge.ts | 14 + src/preload/api/codex-accounts-bridge.ts | 32 + src/preload/api/codex-config-sync-bridge.ts | 6 + src/preload/api/codex-usage-bridge.ts | 4 + .../api/computer-use-permissions-bridge.ts | 8 + src/preload/api/crash-reports-bridge.ts | 31 + src/preload/api/dashboard-bridge.ts | 74 + .../api/developer-permissions-bridge.ts | 11 + src/preload/api/diagnostics-bridge.ts | 15 + src/preload/api/doc-preview-bridge.ts | 31 + src/preload/api/e2e-bridge.ts | 5 + src/preload/api/emulator-bridge.ts | 98 + src/preload/api/ephemeral-vm-bridge.ts | 25 + src/preload/api/export-bridge.ts | 10 + src/preload/api/feedback-bridge.ts | 13 + src/preload/api/folder-workspaces-bridge.ts | 10 + src/preload/api/fs-bridge.ts | 219 + src/preload/api/gh-bridge-part-1.ts | 193 + src/preload/api/gh-bridge-part-2.ts | 202 + src/preload/api/gh-bridge.ts | 4 + src/preload/api/git-bash-bridge.ts | 5 + src/preload/api/git-bridge.ts | 200 + src/preload/api/gl-bridge.ts | 3 + src/preload/api/grok-accounts-bridge.ts | 6 + src/preload/api/hooks-bridge.ts | 44 + src/preload/api/hosted-review-bridge.ts | 12 + src/preload/api/jira-bridge.ts | 110 + src/preload/api/keybindings-bridge.ts | 20 + src/preload/api/linear-bridge.ts | 143 + .../api/localhost-worktree-labels-bridge.ts | 11 + src/preload/api/macos-tcc-prompts-bridge.ts | 17 + src/preload/api/memory-bridge.ts | 6 + src/preload/api/minimax-credentials-bridge.ts | 10 + src/preload/api/mobile-bridge.ts | 95 + src/preload/api/native-chat-bridge.ts | 40 + src/preload/api/notebook-bridge.ts | 11 + src/preload/api/notifications-bridge.ts | 120 + src/preload/api/onboarding-bridge.ts | 11 + src/preload/api/open-code-usage-bridge.ts | 4 + src/preload/api/orca-profiles-bridge.ts | 21 + src/preload/api/pet-bridge.ts | 11 + src/preload/api/platform-bridge.ts | 17 + src/preload/api/plugins-bridge.ts | 63 + src/preload/api/preflight-bridge.ts | 43 + src/preload/api/project-groups-bridge.ts | 22 + src/preload/api/projects-bridge.ts | 12 + src/preload/api/pty-bridge-part-1.ts | 207 + src/preload/api/pty-bridge-part-2.ts | 141 + src/preload/api/pty-bridge.ts | 4 + src/preload/api/pwsh-bridge.ts | 5 + src/preload/api/rate-limits-bridge.ts | 30 + src/preload/api/remote-workspace-bridge.ts | 19 + src/preload/api/repos-bridge.ts | 87 + src/preload/api/runtime-bridge.ts | 144 + .../api/runtime-environments-bridge.ts | 93 + src/preload/api/session-bridge.ts | 16 + src/preload/api/settings-bridge.ts | 39 + src/preload/api/shell-bridge.ts | 41 + src/preload/api/skills-bridge.ts | 129 + src/preload/api/sparse-presets-bridge.ts | 16 + src/preload/api/speech-bridge.ts | 81 + src/preload/api/ssh-bridge.ts | 183 + src/preload/api/star-nag-bridge.ts | 30 + src/preload/api/stats-bridge.ts | 10 + src/preload/api/terminal-preview-bridge.ts | 33 + src/preload/api/ui-bridge-part-1.ts | 167 + src/preload/api/ui-bridge-part-2.ts | 203 + src/preload/api/ui-bridge-part-3.ts | 214 + src/preload/api/ui-bridge-part-4.ts | 195 + src/preload/api/ui-bridge-part-5.ts | 7 + src/preload/api/ui-bridge.ts | 14 + src/preload/api/updater-bridge.ts | 36 + src/preload/api/workspace-cleanup-bridge.ts | 36 + src/preload/api/workspace-ports-bridge.ts | 16 + src/preload/api/workspace-space-bridge.ts | 17 + src/preload/api/worktrees-bridge.ts | 117 + src/preload/api/wsl-bridge.ts | 6 + src/preload/index.ts | 5518 +---------------- src/preload/preload-runtime-support.ts | 165 + 93 files changed, 5442 insertions(+), 5340 deletions(-) create mode 100644 src/preload/api/agent-awake-bridge.ts create mode 100644 src/preload/api/agent-status-bridge.ts create mode 100644 src/preload/api/agent-trust-bridge.ts create mode 100644 src/preload/api/ai-vault-bridge.ts create mode 100644 src/preload/api/app-bridge.ts create mode 100644 src/preload/api/automations-bridge.ts create mode 100644 src/preload/api/bitbucket-bridge.ts create mode 100644 src/preload/api/browser-bridge-part-1.ts create mode 100644 src/preload/api/browser-bridge-part-2.ts create mode 100644 src/preload/api/browser-bridge.ts create mode 100644 src/preload/api/cache-bridge.ts create mode 100644 src/preload/api/claude-accounts-bridge.ts create mode 100644 src/preload/api/claude-usage-bridge.ts create mode 100644 src/preload/api/cli-bridge.ts create mode 100644 src/preload/api/codex-accounts-bridge.ts create mode 100644 src/preload/api/codex-config-sync-bridge.ts create mode 100644 src/preload/api/codex-usage-bridge.ts create mode 100644 src/preload/api/computer-use-permissions-bridge.ts create mode 100644 src/preload/api/crash-reports-bridge.ts create mode 100644 src/preload/api/dashboard-bridge.ts create mode 100644 src/preload/api/developer-permissions-bridge.ts create mode 100644 src/preload/api/diagnostics-bridge.ts create mode 100644 src/preload/api/doc-preview-bridge.ts create mode 100644 src/preload/api/e2e-bridge.ts create mode 100644 src/preload/api/emulator-bridge.ts create mode 100644 src/preload/api/ephemeral-vm-bridge.ts create mode 100644 src/preload/api/export-bridge.ts create mode 100644 src/preload/api/feedback-bridge.ts create mode 100644 src/preload/api/folder-workspaces-bridge.ts create mode 100644 src/preload/api/fs-bridge.ts create mode 100644 src/preload/api/gh-bridge-part-1.ts create mode 100644 src/preload/api/gh-bridge-part-2.ts create mode 100644 src/preload/api/gh-bridge.ts create mode 100644 src/preload/api/git-bash-bridge.ts create mode 100644 src/preload/api/git-bridge.ts create mode 100644 src/preload/api/gl-bridge.ts create mode 100644 src/preload/api/grok-accounts-bridge.ts create mode 100644 src/preload/api/hooks-bridge.ts create mode 100644 src/preload/api/hosted-review-bridge.ts create mode 100644 src/preload/api/jira-bridge.ts create mode 100644 src/preload/api/keybindings-bridge.ts create mode 100644 src/preload/api/linear-bridge.ts create mode 100644 src/preload/api/localhost-worktree-labels-bridge.ts create mode 100644 src/preload/api/macos-tcc-prompts-bridge.ts create mode 100644 src/preload/api/memory-bridge.ts create mode 100644 src/preload/api/minimax-credentials-bridge.ts create mode 100644 src/preload/api/mobile-bridge.ts create mode 100644 src/preload/api/native-chat-bridge.ts create mode 100644 src/preload/api/notebook-bridge.ts create mode 100644 src/preload/api/notifications-bridge.ts create mode 100644 src/preload/api/onboarding-bridge.ts create mode 100644 src/preload/api/open-code-usage-bridge.ts create mode 100644 src/preload/api/orca-profiles-bridge.ts create mode 100644 src/preload/api/pet-bridge.ts create mode 100644 src/preload/api/platform-bridge.ts create mode 100644 src/preload/api/plugins-bridge.ts create mode 100644 src/preload/api/preflight-bridge.ts create mode 100644 src/preload/api/project-groups-bridge.ts create mode 100644 src/preload/api/projects-bridge.ts create mode 100644 src/preload/api/pty-bridge-part-1.ts create mode 100644 src/preload/api/pty-bridge-part-2.ts create mode 100644 src/preload/api/pty-bridge.ts create mode 100644 src/preload/api/pwsh-bridge.ts create mode 100644 src/preload/api/rate-limits-bridge.ts create mode 100644 src/preload/api/remote-workspace-bridge.ts create mode 100644 src/preload/api/repos-bridge.ts create mode 100644 src/preload/api/runtime-bridge.ts create mode 100644 src/preload/api/runtime-environments-bridge.ts create mode 100644 src/preload/api/session-bridge.ts create mode 100644 src/preload/api/settings-bridge.ts create mode 100644 src/preload/api/shell-bridge.ts create mode 100644 src/preload/api/skills-bridge.ts create mode 100644 src/preload/api/sparse-presets-bridge.ts create mode 100644 src/preload/api/speech-bridge.ts create mode 100644 src/preload/api/ssh-bridge.ts create mode 100644 src/preload/api/star-nag-bridge.ts create mode 100644 src/preload/api/stats-bridge.ts create mode 100644 src/preload/api/terminal-preview-bridge.ts create mode 100644 src/preload/api/ui-bridge-part-1.ts create mode 100644 src/preload/api/ui-bridge-part-2.ts create mode 100644 src/preload/api/ui-bridge-part-3.ts create mode 100644 src/preload/api/ui-bridge-part-4.ts create mode 100644 src/preload/api/ui-bridge-part-5.ts create mode 100644 src/preload/api/ui-bridge.ts create mode 100644 src/preload/api/updater-bridge.ts create mode 100644 src/preload/api/workspace-cleanup-bridge.ts create mode 100644 src/preload/api/workspace-ports-bridge.ts create mode 100644 src/preload/api/workspace-space-bridge.ts create mode 100644 src/preload/api/worktrees-bridge.ts create mode 100644 src/preload/api/wsl-bridge.ts create mode 100644 src/preload/preload-runtime-support.ts diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index ea5b5f31a5c..afe5e83024c 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -6,6 +6,17 @@ "../src/renderer/src/**/*.tsx", "../src/preload/api-types.ts", "../src/preload/api/**/*", + "../src/preload/browser-client-page-renderer-requests.ts", + "../src/preload/browser-find-subscriptions.ts", + "../src/preload/close-active-tab-payload-admission.ts", + "../src/preload/e2e-config.ts", + "../src/preload/gitlab.ts", + "../src/preload/preload-runtime-support.ts", + "../src/preload/renderer-heap-statistics-reader.ts", + "../src/preload/renderer-process-memory-reader.ts", + "../src/preload/renderer-restart-wiring.ts", + "../src/preload/runtime-environment-subscriptions.ts", + "../src/preload/usage-provider-api.ts", "../src/shared/**/*", "../src/main/gitlab/mappers.ts", "../src/main/ipc/worktree-branch-name.ts", diff --git a/src/preload/api/agent-awake-bridge.ts b/src/preload/api/agent-awake-bridge.ts new file mode 100644 index 00000000000..eff4263398e --- /dev/null +++ b/src/preload/api/agent-awake-bridge.ts @@ -0,0 +1,13 @@ +import { ipcRenderer } from 'electron' +import type { ComputerAwakeStatus } from '../../shared/computer-awake-mode' +import type { PreloadApi } from '../api-types' + +export const agentAwakeApi = { + getStatus: (): Promise => ipcRenderer.invoke('agentAwake:getStatus'), + onChanged: (callback: (status: ComputerAwakeStatus) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, status: ComputerAwakeStatus): void => + callback(status) + ipcRenderer.on('agentAwake:changed', listener) + return () => ipcRenderer.removeListener('agentAwake:changed', listener) + } +} satisfies PreloadApi['agentAwake'] diff --git a/src/preload/api/agent-status-bridge.ts b/src/preload/api/agent-status-bridge.ts new file mode 100644 index 00000000000..5bc0757c5ca --- /dev/null +++ b/src/preload/api/agent-status-bridge.ts @@ -0,0 +1,88 @@ +import { ipcRenderer } from 'electron' +import type { + AgentStatusClearIpcPayload, + AgentStatusIpcPayload, + MigrationUnsupportedPtyEntry +} from '../../shared/agent-status-types' +import type { AgentInterruptInferenceRequest } from '../../shared/agent-interrupt-intent' +import type { AgentQuestionAnsweredInferenceRequest } from '../../shared/agent-question-answered-intent' + +export const agentStatusApi = { + /** Listen for agent status updates forwarded from native hook receivers. */ + onSet: (callback: (data: AgentStatusIpcPayload) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: AgentStatusIpcPayload) => + callback(data) + ipcRenderer.on('agentStatus:set', listener) + return () => ipcRenderer.removeListener('agentStatus:set', listener) + }, + onClear: (callback: (data: AgentStatusClearIpcPayload) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: AgentStatusClearIpcPayload) => + callback(data) + ipcRenderer.on('agentStatus:clear', listener) + return () => ipcRenderer.removeListener('agentStatus:clear', listener) + }, + /** Pull cached hook statuses after renderer hydration, so startup replays aren't lost before tabs exist. */ + getSnapshot: (): Promise => + ipcRenderer.invoke('agentStatus:getSnapshot'), + inferInterrupt: (request: AgentInterruptInferenceRequest): Promise => + ipcRenderer.invoke('agentStatus:inferInterrupt', request), + inferQuestionAnswered: (request: AgentQuestionAnsweredInferenceRequest): Promise => + ipcRenderer.invoke('agentStatus:inferQuestionAnswered', request), + onMigrationUnsupported: ( + callback: (entry: MigrationUnsupportedPtyEntry) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, entry: MigrationUnsupportedPtyEntry) => + callback(entry) + ipcRenderer.on('agentStatus:migrationUnsupported', listener) + return () => ipcRenderer.removeListener('agentStatus:migrationUnsupported', listener) + }, + onMigrationUnsupportedClear: (callback: (data: { ptyId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { ptyId: string }) => callback(data) + ipcRenderer.on('agentStatus:migrationUnsupportedClear', listener) + return () => ipcRenderer.removeListener('agentStatus:migrationUnsupportedClear', listener) + }, + onLegacyWorkerTerminalRecovery: ( + callback: (data: { + paneKey: string + resolution: 'adopted' | 'exited' | 'rolled_back' + ptyId?: string + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + paneKey: string + resolution: 'adopted' | 'exited' | 'rolled_back' + ptyId?: string + } + ) => callback(data) + ipcRenderer.on('agentStatus:legacyWorkerTerminalRecovery', listener) + return () => ipcRenderer.removeListener('agentStatus:legacyWorkerTerminalRecovery', listener) + }, + getMigrationUnsupportedSnapshot: (): Promise => + ipcRenderer.invoke('agentStatus:getMigrationUnsupportedSnapshot'), + /** Drop the cached hook status for a paneKey on both sides (memory + on-disk) so a relaunch can't resurrect a dismissed row. */ + drop: (paneKey: string): void => { + ipcRenderer.send('agentStatus:drop', paneKey) + }, + reconcileEndedProcess: (paneKey: string): void => { + ipcRenderer.send('agentStatus:reconcileEndedProcess', paneKey) + }, + /** Drop all cached hook statuses under one terminal tab prefix; fired on explicit tab close even without a local row. */ + dropByTabPrefix: (tabId: string): void => { + ipcRenderer.send('agentStatus:dropByTabPrefix', tabId) + }, + retirePaneAuthority: (paneKey: string): void => { + ipcRenderer.send('agentStatus:retirePaneAuthority', paneKey) + }, + restorePaneAuthority: (paneKey: string): void => { + ipcRenderer.send('agentStatus:restorePaneAuthority', paneKey) + }, + transferPaneAuthority: (args: { + fromPaneKey: string + toPaneKey: string + ptyId?: string + }): void => { + ipcRenderer.send('agentStatus:transferPaneAuthority', args) + } +} diff --git a/src/preload/api/agent-trust-bridge.ts b/src/preload/api/agent-trust-bridge.ts new file mode 100644 index 00000000000..f27146d9cd3 --- /dev/null +++ b/src/preload/api/agent-trust-bridge.ts @@ -0,0 +1,9 @@ +import { ipcRenderer } from 'electron' + +export const agentTrustApi = { + markTrusted: (args: { + preset: 'cursor' | 'copilot' | 'codex' + workspacePath: string + connectionId?: string + }): Promise => ipcRenderer.invoke('agentTrust:markTrusted', args) +} diff --git a/src/preload/api/ai-vault-bridge.ts b/src/preload/api/ai-vault-bridge.ts new file mode 100644 index 00000000000..ea9b2e1be14 --- /dev/null +++ b/src/preload/api/ai-vault-bridge.ts @@ -0,0 +1,34 @@ +import { ipcRenderer } from 'electron' +import type { + AiVaultDeleteSessionArgs, + AiVaultDeleteSessionResult +} from '../../shared/ai-vault-session-deletion' +import type { + AiVaultFirstUserPromptArgs, + AiVaultListArgs, + AiVaultSubagentListArgs +} from '../../shared/ai-vault-types' +import type { AiVaultSessionTitlesArgs } from '../../shared/ai-vault-session-title' +import type { AiVaultPrepareSessionResumeArgs } from '../../shared/ai-vault-resume-preparation' + +export const aiVaultApi = { + listSessions: (args?: AiVaultListArgs): Promise => + ipcRenderer.invoke('aiVault:listSessions', args), + resolveSessionTitles: (args: AiVaultSessionTitlesArgs): Promise => + ipcRenderer.invoke('aiVault:resolveSessionTitles', args), + cancelListSessions: (args: { requestToken: string }): Promise => + ipcRenderer.invoke('aiVault:cancelListSessions', args), + prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs): Promise => + ipcRenderer.invoke('aiVault:prepareSessionResume', args), + listSubagentSessions: (args: AiVaultSubagentListArgs): Promise => + ipcRenderer.invoke('aiVault:listSubagentSessions', args), + getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs): Promise => + ipcRenderer.invoke('aiVault:getFirstUserPrompt', args), + deleteSession: (args: AiVaultDeleteSessionArgs): Promise => + ipcRenderer.invoke('aiVault:deleteSession', args), + onWindowFocused: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('aiVault:windowFocused', listener) + return () => ipcRenderer.removeListener('aiVault:windowFocused', listener) + } +} diff --git a/src/preload/api/app-bridge.ts b/src/preload/api/app-bridge.ts new file mode 100644 index 00000000000..705d24e4bda --- /dev/null +++ b/src/preload/api/app-bridge.ts @@ -0,0 +1,76 @@ +import { ipcRenderer } from 'electron' +import type { AppIdentity } from '../../shared/app-identity' +import type { FloatingTerminalCwdRequest } from '../../shared/ui-chrome-types' +import type { MacCapturedDigitRowChord } from '../../shared/macos-symbolic-hotkeys' +import type { MarkdownDocument } from '../../shared/filesystem-entry-types' +import type { WriteTerminalRenderDesyncEvidenceArgs } from '../../shared/terminal-render-desync-evidence' +import { + KEYBOARD_LAYOUT_CHANGED_CHANNEL, + type KeyboardLayoutChangeEvent +} from '../../shared/keyboard-layout-events' +import { prepareAndInvokeAppRestart } from '../renderer-restart-wiring' +import { awaitBeforeUnloadCheckpoint, startupDiagnosticsEnabled } from '../preload-runtime-support' +import type { PreloadApi } from '../api-types' + +export const appApi = { + getIdentity: (): Promise => ipcRenderer.invoke('app:getIdentity'), + getFeatureWallAssetBaseUrl: (): Promise => + ipcRenderer.invoke('app:getFeatureWallAssetBaseUrl'), + relaunch: (): Promise => + prepareAndInvokeAppRestart( + window, + () => ipcRenderer.invoke('app:relaunch'), + awaitBeforeUnloadCheckpoint + ), + restart: (): Promise => + prepareAndInvokeAppRestart( + window, + () => ipcRenderer.invoke('app:restart'), + awaitBeforeUnloadCheckpoint + ), + reload: (): Promise => + prepareAndInvokeAppRestart( + window, + () => ipcRenderer.invoke('app:reload'), + awaitBeforeUnloadCheckpoint + ), + stageBeforeUnloadSync: (args: Parameters[0]) => { + const result = ipcRenderer.sendSync('app:stage-before-unload-sync', args) as { ok?: unknown } + if (result?.ok !== true) { + throw new Error('Failed to stage renderer state before unload.') + } + }, + awaitFirstWindowStartupServices: (): Promise => + ipcRenderer.invoke('app:awaitFirstWindowStartupServices'), + prepareTerminalStartupRestoration: (): Promise => + ipcRenderer.invoke('app:prepareTerminalStartupRestoration'), + recoverLegacyWorkerTerminalsForRendererStartup: (): Promise => + ipcRenderer.invoke('app:recoverLegacyWorkerTerminalsForRendererStartup'), + startupDiagnostic: (event: string, details?: Record): Promise => + startupDiagnosticsEnabled + ? ipcRenderer.invoke('app:startupDiagnostic', event, details) + : Promise.resolve(), + getKeyboardInputSourceId: (): Promise => + ipcRenderer.invoke('app:getKeyboardInputSourceId'), + getMacCapturedDigitRowChords: (): Promise => + ipcRenderer.invoke('app:getMacCapturedDigitRowChords'), + getKeyboardLayoutSnapshot: () => ipcRenderer.invoke('app:getKeyboardLayoutSnapshot'), + onKeyboardLayoutChanged: (callback: (event: KeyboardLayoutChangeEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, event: KeyboardLayoutChangeEvent): void => + callback(event) + ipcRenderer.on(KEYBOARD_LAYOUT_CHANGED_CHANNEL, listener) + return () => ipcRenderer.removeListener(KEYBOARD_LAYOUT_CHANGED_CHANNEL, listener) + }, + setUnreadDockBadgeCount: (count: number): Promise => + ipcRenderer.invoke('app:setUnreadDockBadgeCount', count), + getFloatingTerminalCwd: (args?: FloatingTerminalCwdRequest): Promise => + ipcRenderer.invoke('app:getFloatingTerminalCwd', args), + getFloatingMarkdownDirectory: (): Promise => + ipcRenderer.invoke('app:getFloatingMarkdownDirectory'), + pickFloatingMarkdownDocument: (): Promise => + ipcRenderer.invoke('app:pickFloatingMarkdownDocument'), + pickFloatingWorkspaceDirectory: (): Promise => + ipcRenderer.invoke('app:pickFloatingWorkspaceDirectory'), + writeTerminalRenderDesyncEvidence: (args: WriteTerminalRenderDesyncEvidenceArgs) => + ipcRenderer.invoke('terminal:writeRenderDesyncEvidence', args) +} diff --git a/src/preload/api/automations-bridge.ts b/src/preload/api/automations-bridge.ts new file mode 100644 index 00000000000..87bec12a8e9 --- /dev/null +++ b/src/preload/api/automations-bridge.ts @@ -0,0 +1,59 @@ +import { ipcRenderer } from 'electron' +import type { ExternalAutomationManagerResult } from '../api-types' +import type { + AutomationDispatchRequest, + AutomationDispatchResult, + ExternalAutomationRunsPage, + AutomationRun, + AutomationPrecheckResult +} from '../../shared/automations-types' +import type { AutomationOwnerRef } from '../../shared/automation-owner-ref' +import type { + ScopedExternalManagerActionRequest, + ScopedExternalManagerCreateRequest, + ScopedExternalManagerListRequest, + ScopedExternalManagerRunsRequest, + ScopedExternalManagerUpdateRequest +} from '../../shared/external-automation-scope' +import type { AutomationsChangedPayload } from '../../shared/runtime-client-events' + +export const automationsApi = { + listExternalManagerForOwner: ( + request: ScopedExternalManagerListRequest + ): Promise => + ipcRenderer.invoke('automations:listExternalManagerForOwner', request), + listExternalRunsForOwner: ( + request: ScopedExternalManagerRunsRequest + ): Promise => + ipcRenderer.invoke('automations:listExternalRunsForOwner', request), + createExternalForOwner: (request: ScopedExternalManagerCreateRequest): Promise => + ipcRenderer.invoke('automations:createExternalForOwner', request), + updateExternalForOwner: (request: ScopedExternalManagerUpdateRequest): Promise => + ipcRenderer.invoke('automations:updateExternalForOwner', request), + runExternalActionForOwner: (request: ScopedExternalManagerActionRequest): Promise => + ipcRenderer.invoke('automations:runExternalActionForOwner', request), + retainExternalScopes: (request: { owners: readonly AutomationOwnerRef[] }): Promise => + ipcRenderer.invoke('automations:retainExternalScopes', request), + runPrecheck: (args: { + automationId: string + runId: string + }): Promise => + ipcRenderer.invoke('automations:runPrecheck', args), + markDispatchResult: (result: AutomationDispatchResult): Promise => + ipcRenderer.invoke('automations:markDispatchResult', result), + snapshotWorkspaceName: (args: { workspaceId: string; displayName: string }): Promise => + ipcRenderer.invoke('automations:snapshotWorkspaceName', args), + rendererReady: (): Promise => ipcRenderer.invoke('automations:rendererReady'), + onDispatchRequested: (callback: (request: AutomationDispatchRequest) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, request: AutomationDispatchRequest) => + callback(request) + ipcRenderer.on('automations:dispatchRequested', listener) + return () => ipcRenderer.removeListener('automations:dispatchRequested', listener) + }, + onChanged: (callback: (payload: AutomationsChangedPayload) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: AutomationsChangedPayload) => + callback(payload) + ipcRenderer.on('automations:changed', listener) + return () => ipcRenderer.removeListener('automations:changed', listener) + } +} diff --git a/src/preload/api/bitbucket-bridge.ts b/src/preload/api/bitbucket-bridge.ts new file mode 100644 index 00000000000..cf51ce453df --- /dev/null +++ b/src/preload/api/bitbucket-bridge.ts @@ -0,0 +1,16 @@ +import { ipcRenderer } from 'electron' + +export const bitbucketApi = { + connect: (args: { + authMode: 'token' | 'basic' + accessToken?: string | null + email?: string | null + apiToken?: string | null + baseUrl?: string | null + }): Promise<{ ok: true; account: string | null } | { ok: false; error: string }> => + ipcRenderer.invoke('bitbucket:connect', args), + + disconnect: (): Promise => ipcRenderer.invoke('bitbucket:disconnect'), + + status: (): Promise => ipcRenderer.invoke('bitbucket:status') +} diff --git a/src/preload/api/browser-bridge-part-1.ts b/src/preload/api/browser-bridge-part-1.ts new file mode 100644 index 00000000000..47883976338 --- /dev/null +++ b/src/preload/api/browser-bridge-part-1.ts @@ -0,0 +1,197 @@ +import { ipcRenderer } from 'electron' +import type { BrowserViewportOverride } from '../../shared/browser-workspace-types' +import type { + BrowserWebAuthnAccountRequest, + BrowserWebAuthnAccountResponse +} from '../../shared/browser-webauthn-account' +import { readBrowserClientHostIdArgument } from '../../shared/browser-client-host-id-argument' +import { browserClientPageRendererRequests } from '../preload-runtime-support' + +export const browserApiPart1 = { + onClientPageRendererRequest: browserClientPageRendererRequests.subscribe, + readClientHostId: (): string | null => readBrowserClientHostIdArgument(process.argv), + registerGuest: (args: { + browserPageId: string + workspaceId: string + worktreeId: string + sessionProfileId?: string | null + webContentsId: number + }): Promise => ipcRenderer.invoke('browser:registerGuest', args), + isGuestRegistered: (args: { browserPageId: string; webContentsId: number }): Promise => + ipcRenderer.invoke('browser:isGuestRegistered', args), + repairGuestRegistration: (args: { + browserPageId: string + workspaceId: string + worktreeId: string + sessionProfileId?: string | null + webContentsId: number + }): Promise => ipcRenderer.invoke('browser:repairGuestRegistration', args), + unregisterGuest: (args: { browserPageId: string }): Promise => + ipcRenderer.invoke('browser:unregisterGuest', args), + onWebAuthnAccountRequest: ( + callback: (request: BrowserWebAuthnAccountRequest) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + request: BrowserWebAuthnAccountRequest + ): void => callback(request) + ipcRenderer.on('browser:webauthn-account-requested', listener) + return () => ipcRenderer.removeListener('browser:webauthn-account-requested', listener) + }, + onWebAuthnAccountRequestClosed: ( + callback: (event: { requestId: string }) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { requestId: string }): void => + callback(data) + ipcRenderer.on('browser:webauthn-account-request-closed', listener) + return () => ipcRenderer.removeListener('browser:webauthn-account-request-closed', listener) + }, + respondWebAuthnAccount: (response: BrowserWebAuthnAccountResponse): Promise => + ipcRenderer.invoke('browser:respondWebAuthnAccount', response), + openDevTools: (args: { browserPageId: string }): Promise => + ipcRenderer.invoke('browser:openDevTools', args), + setViewportOverride: (args: { + browserPageId: string + override: BrowserViewportOverride | null + }): Promise => ipcRenderer.invoke('browser:setViewportOverride', args), + reportViewportScrollState: (args: { + browserPageId: string + state: { + scrollLeft: number + scrollTop: number + maxScrollLeft: number + maxScrollTop: number + } + }): void => ipcRenderer.send('browser:reportViewportScrollState', args), + setAnnotationViewportBridge: (args): Promise => + ipcRenderer.invoke('browser:setAnnotationViewportBridge', args), + publishClientPageMetadata: (args) => + ipcRenderer.invoke('browser:publishClientPageMetadata', args), + onGuestLoadFailed: ( + callback: (args: { + browserPageId: string + loadError: { code: number; description: string; validatedUrl: string } + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + browserPageId: string + loadError: { code: number; description: string; validatedUrl: string } + } + ) => callback(data) + ipcRenderer.on('browser:guest-load-failed', listener) + return () => ipcRenderer.removeListener('browser:guest-load-failed', listener) + }, + onCertificateFailureChanged: (callback): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: Parameters[0] + ): void => callback(data) + ipcRenderer.on('browser:certificate-failure-changed', listener) + return () => ipcRenderer.removeListener('browser:certificate-failure-changed', listener) + }, + proceedCertificate: (args) => ipcRenderer.invoke('browser:proceedCertificate', args), + onPermissionDenied: ( + callback: (event: { browserPageId: string; permission: string; origin: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { browserPageId: string; permission: string; origin: string } + ) => callback(data) + ipcRenderer.on('browser:permission-denied', listener) + return () => ipcRenderer.removeListener('browser:permission-denied', listener) + }, + onPopup: ( + callback: (event: { + browserPageId: string + origin: string + action: 'opened-in-orca' | 'opened-external' | 'blocked' + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + browserPageId: string + origin: string + action: 'opened-in-orca' | 'opened-external' | 'blocked' + } + ) => callback(data) + ipcRenderer.on('browser:popup', listener) + return () => ipcRenderer.removeListener('browser:popup', listener) + }, + onDownloadRequested: ( + callback: (event: { + browserPageId: string + downloadId: string + origin: string + filename: string + totalBytes: number | null + mimeType: string | null + savePath: string + status: 'downloading' + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + browserPageId: string + downloadId: string + origin: string + filename: string + totalBytes: number | null + mimeType: string | null + savePath: string + status: 'downloading' + } + ) => callback(data) + ipcRenderer.on('browser:download-requested', listener) + return () => ipcRenderer.removeListener('browser:download-requested', listener) + }, + onDownloadProgress: ( + callback: (event: { + browserPageId?: string + downloadId: string + receivedBytes: number + totalBytes: number | null + state: 'progressing' | 'interrupted' | null + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + browserPageId?: string + downloadId: string + receivedBytes: number + totalBytes: number | null + state: 'progressing' | 'interrupted' | null + } + ) => callback(data) + ipcRenderer.on('browser:download-progress', listener) + return () => ipcRenderer.removeListener('browser:download-progress', listener) + }, + onDownloadFinished: ( + callback: (event: { + browserPageId?: string + downloadId: string + status: 'completed' | 'canceled' | 'failed' + savePath: string | null + remoteDestination?: { workspaceRelativePath: string; hostLabel: string } + error: string | null + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + browserPageId?: string + downloadId: string + status: 'completed' | 'canceled' | 'failed' + savePath: string | null + remoteDestination?: { workspaceRelativePath: string; hostLabel: string } + error: string | null + } + ) => callback(data) + ipcRenderer.on('browser:download-finished', listener) + return () => ipcRenderer.removeListener('browser:download-finished', listener) + } +} diff --git a/src/preload/api/browser-bridge-part-2.ts b/src/preload/api/browser-bridge-part-2.ts new file mode 100644 index 00000000000..07788c59d2e --- /dev/null +++ b/src/preload/api/browser-bridge-part-2.ts @@ -0,0 +1,165 @@ +import { ipcRenderer } from 'electron' + +export const browserApiPart2 = { + onContextMenuRequested: ( + callback: (event: { + browserPageId: string + x: number + y: number + screenX: number + screenY: number + pageUrl: string + linkUrl: string | null + selectionText: string + canGoBack: boolean + canGoForward: boolean + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + browserPageId: string + x: number + y: number + screenX: number + screenY: number + pageUrl: string + linkUrl: string | null + selectionText: string + canGoBack: boolean + canGoForward: boolean + } + ) => callback(data) + ipcRenderer.on('browser:context-menu-requested', listener) + return () => ipcRenderer.removeListener('browser:context-menu-requested', listener) + }, + onContextMenuDismissed: (callback: (event: { browserPageId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { browserPageId: string }) => + callback(data) + ipcRenderer.on('browser:context-menu-dismissed', listener) + return () => ipcRenderer.removeListener('browser:context-menu-dismissed', listener) + }, + onNavigationUpdate: ( + callback: (event: { browserPageId: string; url: string; title: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { browserPageId: string; url: string; title: string } + ) => callback(data) + ipcRenderer.on('browser:navigation-update', listener) + return () => ipcRenderer.removeListener('browser:navigation-update', listener) + }, + onActivateView: ( + callback: (data: { worktreeId?: string; browserPageId?: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { worktreeId?: string; browserPageId?: string } + ) => callback(data) + ipcRenderer.on('browser:activateView', listener) + return () => ipcRenderer.removeListener('browser:activateView', listener) + }, + onPaneFocus: ( + callback: (data: { worktreeId: string | null; browserPageId: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { worktreeId: string | null; browserPageId: string } + ) => callback(data) + ipcRenderer.on('browser:pane-focus', listener) + return () => ipcRenderer.removeListener('browser:pane-focus', listener) + }, + onOpenLinkInOrcaTab: ( + callback: (event: { browserPageId: string; url: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { browserPageId: string; url: string } + ) => callback(data) + ipcRenderer.on('browser:open-link-in-orca-tab', listener) + return () => ipcRenderer.removeListener('browser:open-link-in-orca-tab', listener) + }, + cancelDownload: (args: { downloadId: string }): Promise => + ipcRenderer.invoke('browser:cancelDownload', args), + setGrabMode: (args: { + browserPageId: string + enabled: boolean + }): Promise<{ ok: true } | { ok: false; reason: string }> => + ipcRenderer.invoke('browser:setGrabMode', args), + awaitGrabSelection: (args: { browserPageId: string; opId: string }): Promise => + ipcRenderer.invoke('browser:awaitGrabSelection', args), + cancelGrab: (args: { browserPageId: string }): Promise => + ipcRenderer.invoke('browser:cancelGrab', args), + captureSelectionScreenshot: (args: { + browserPageId: string + rect: { x: number; y: number; width: number; height: number } + }): Promise<{ ok: true; screenshot: unknown } | { ok: false; reason: string }> => + ipcRenderer.invoke('browser:captureSelectionScreenshot', args), + extractHoverPayload: (args: { + browserPageId: string + }): Promise<{ ok: true; payload: unknown } | { ok: false; reason: string }> => + ipcRenderer.invoke('browser:extractHoverPayload', args), + onGrabModeToggle: (callback: (browserPageId: string) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, browserPageId: string) => + callback(browserPageId) + ipcRenderer.on('browser:grabModeToggle', listener) + return () => ipcRenderer.removeListener('browser:grabModeToggle', listener) + }, + onGrabActionShortcut: ( + callback: (args: { browserPageId: string; key: 'c' | 's' }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { browserPageId: string; key: 'c' | 's' } + ) => callback(data) + ipcRenderer.on('browser:grabActionShortcut', listener) + return () => ipcRenderer.removeListener('browser:grabActionShortcut', listener) + }, + sessionListProfiles: (): Promise => ipcRenderer.invoke('browser:session:listProfiles'), + prepareSshWorkspacePartition: (args: { + targetId: string + browserProfileId?: string + skipProbe?: boolean + }): Promise<{ partition: string }> => + ipcRenderer.invoke('browser:prepareSshWorkspacePartition', args), + sessionCreateProfile: (args: { + scope: 'default' | 'isolated' | 'imported' + label: string + userAgentMode?: 'clean' | 'native' + }): Promise => ipcRenderer.invoke('browser:session:createProfile', args), + sessionDeleteProfile: (args: { profileId: string }): Promise => + ipcRenderer.invoke('browser:session:deleteProfile', args), + sessionImportCookies: (args: { + profileId: string + }): Promise<{ ok: true; profileId: string; summary: unknown } | { ok: false; reason: string }> => + ipcRenderer.invoke('browser:session:importCookies', args), + sessionResolvePartition: (args: { profileId: string | null }): Promise => + ipcRenderer.invoke('browser:session:resolvePartition', args), + sessionDetectBrowsers: (): Promise => + ipcRenderer.invoke('browser:session:detectBrowsers'), + sessionDetectBrowsersForClientHost: (args: { + environmentId: string + }): Promise => + ipcRenderer.invoke('browser:session:detectBrowsersForClientHost', args), + sessionImportFromBrowser: (args: { + profileId: string + browserFamily: string + }): Promise<{ ok: true; profileId: string; summary: unknown } | { ok: false; reason: string }> => + ipcRenderer.invoke('browser:session:importFromBrowser', args), + sessionImportFromBrowserForClientHost: (args: { + environmentId: string + profileId: string + browserFamily: string + browserProfile?: string + }): Promise< + { ok: true; profileId: string; summary: unknown } | { ok: false; reason: string } | null + > => ipcRenderer.invoke('browser:session:importFromBrowserForClientHost', args), + sessionClientRouteImportSources: (args: { + environmentId: string + }): Promise> => + ipcRenderer.invoke('browser:session:clientRouteImportSources', args), + sessionClearDefaultCookies: (): Promise => + ipcRenderer.invoke('browser:session:clearDefaultCookies'), + notifyActiveTabChanged: (args: { browserPageId: string }): Promise => + ipcRenderer.invoke('browser:activeTabChanged', args) +} diff --git a/src/preload/api/browser-bridge.ts b/src/preload/api/browser-bridge.ts new file mode 100644 index 00000000000..9b72b1658d3 --- /dev/null +++ b/src/preload/api/browser-bridge.ts @@ -0,0 +1,4 @@ +import { browserApiPart1 } from './browser-bridge-part-1' +import { browserApiPart2 } from './browser-bridge-part-2' + +export const browserApi = { ...browserApiPart1, ...browserApiPart2 } diff --git a/src/preload/api/cache-bridge.ts b/src/preload/api/cache-bridge.ts new file mode 100644 index 00000000000..73fd9db88c0 --- /dev/null +++ b/src/preload/api/cache-bridge.ts @@ -0,0 +1,7 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const cacheApi = { + getGitHub: () => ipcRenderer.invoke('cache:getGitHub'), + setGitHub: (args) => ipcRenderer.invoke('cache:setGitHub', args) +} satisfies PreloadApi['cache'] diff --git a/src/preload/api/claude-accounts-bridge.ts b/src/preload/api/claude-accounts-bridge.ts new file mode 100644 index 00000000000..8200c17791d --- /dev/null +++ b/src/preload/api/claude-accounts-bridge.ts @@ -0,0 +1,18 @@ +import { ipcRenderer } from 'electron' + +export const claudeAccountsApi = { + list: (): Promise => ipcRenderer.invoke('claudeAccounts:list'), + add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): Promise => + ipcRenderer.invoke('claudeAccounts:add', args), + cancelPendingLogin: (): Promise => + ipcRenderer.invoke('claudeAccounts:cancelPendingLogin'), + reauthenticate: (args: { accountId: string }): Promise => + ipcRenderer.invoke('claudeAccounts:reauthenticate', args), + remove: (args: { accountId: string }): Promise => + ipcRenderer.invoke('claudeAccounts:remove', args), + select: (args: { + accountId: string | null + runtime?: 'host' | 'wsl' + wslDistro?: string | null + }): Promise => ipcRenderer.invoke('claudeAccounts:select', args) +} diff --git a/src/preload/api/claude-usage-bridge.ts b/src/preload/api/claude-usage-bridge.ts new file mode 100644 index 00000000000..0c81e35e3e8 --- /dev/null +++ b/src/preload/api/claude-usage-bridge.ts @@ -0,0 +1,4 @@ +import { ipcRenderer } from 'electron' +import { createUsageProviderApi } from '../usage-provider-api' + +export const claudeUsageApi = createUsageProviderApi(ipcRenderer, 'claudeUsage') diff --git a/src/preload/api/cli-bridge.ts b/src/preload/api/cli-bridge.ts new file mode 100644 index 00000000000..8f811cbdd40 --- /dev/null +++ b/src/preload/api/cli-bridge.ts @@ -0,0 +1,14 @@ +import { ipcRenderer } from 'electron' +import type { CliInstallStatus } from '../../shared/cli-install-types' + +export const cliApi = { + getInstallStatus: (): Promise => ipcRenderer.invoke('cli:getInstallStatus'), + install: (): Promise => ipcRenderer.invoke('cli:install'), + remove: (): Promise => ipcRenderer.invoke('cli:remove'), + getWslInstallStatus: (args?: { distro?: string | null }): Promise => + ipcRenderer.invoke('cli:getWslInstallStatus', args), + installWsl: (args?: { distro?: string | null }): Promise => + ipcRenderer.invoke('cli:installWsl', args), + removeWsl: (args?: { distro?: string | null }): Promise => + ipcRenderer.invoke('cli:removeWsl', args) +} diff --git a/src/preload/api/codex-accounts-bridge.ts b/src/preload/api/codex-accounts-bridge.ts new file mode 100644 index 00000000000..ecd32e4b923 --- /dev/null +++ b/src/preload/api/codex-accounts-bridge.ts @@ -0,0 +1,32 @@ +import { ipcRenderer } from 'electron' + +export const codexAccountsApi = { + list: (): Promise => ipcRenderer.invoke('codexAccounts:list'), + add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): Promise => + ipcRenderer.invoke('codexAccounts:add', args), + reauthenticate: (args: { + accountId: string + activateIfSelectionWasEmpty?: boolean + }): Promise => ipcRenderer.invoke('codexAccounts:reauthenticate', args), + remove: (args: { accountId: string }): Promise => + ipcRenderer.invoke('codexAccounts:remove', args), + select: (args: { + accountId: string | null + runtime?: 'host' | 'wsl' + wslDistro?: string | null + }): Promise => ipcRenderer.invoke('codexAccounts:select', args), + listStalePanes: (args: { + ptyIds: string[] + }): Promise< + { + ptyId: string + launchAccountId: string | null + activeAccountId: string | null + reason?: 'account-change' | 'home-route-change' + }[] + > => ipcRenderer.invoke('codexAccounts:listStalePanes', args), + listRecordedPaneLanes: (args: { ptyIds: string[] }): Promise> => + ipcRenderer.invoke('codexAccounts:listRecordedPaneLanes', args), + forgetStalePanes: (args: { ptyIds: string[] }): Promise => + ipcRenderer.invoke('codexAccounts:forgetStalePanes', args) +} diff --git a/src/preload/api/codex-config-sync-bridge.ts b/src/preload/api/codex-config-sync-bridge.ts new file mode 100644 index 00000000000..e6c8903a698 --- /dev/null +++ b/src/preload/api/codex-config-sync-bridge.ts @@ -0,0 +1,6 @@ +import { ipcRenderer } from 'electron' +import type { CodexConfigSyncStatus } from '../../shared/codex-config-sync-types' + +export const codexConfigSyncApi = { + status: (): Promise => ipcRenderer.invoke('codexConfigSync:status') +} diff --git a/src/preload/api/codex-usage-bridge.ts b/src/preload/api/codex-usage-bridge.ts new file mode 100644 index 00000000000..9dba4b72f82 --- /dev/null +++ b/src/preload/api/codex-usage-bridge.ts @@ -0,0 +1,4 @@ +import { ipcRenderer } from 'electron' +import { createUsageProviderApi } from '../usage-provider-api' + +export const codexUsageApi = createUsageProviderApi(ipcRenderer, 'codexUsage') diff --git a/src/preload/api/computer-use-permissions-bridge.ts b/src/preload/api/computer-use-permissions-bridge.ts new file mode 100644 index 00000000000..be441a8682e --- /dev/null +++ b/src/preload/api/computer-use-permissions-bridge.ts @@ -0,0 +1,8 @@ +import { ipcRenderer } from 'electron' + +export const computerUsePermissionsApi = { + getStatus: (): Promise => ipcRenderer.invoke('computerUsePermissions:getStatus'), + openSetup: (args?: { id?: string }): Promise => + ipcRenderer.invoke('computerUsePermissions:openSetup', args), + reset: (): Promise => ipcRenderer.invoke('computerUsePermissions:reset') +} diff --git a/src/preload/api/crash-reports-bridge.ts b/src/preload/api/crash-reports-bridge.ts new file mode 100644 index 00000000000..19d7044601d --- /dev/null +++ b/src/preload/api/crash-reports-bridge.ts @@ -0,0 +1,31 @@ +import { ipcRenderer } from 'electron' +import type { + CrashReportBreadcrumbData, + CrashReportCopyDiagnosticsArgs, + CrashReportSubmitArgs, + CrashReportSubmitResult, + ReactErrorBoundaryReportArgs, + ReactErrorBoundaryReportResult +} from '../../shared/crash-reporting' +import type { RendererHeapStatistics } from '../../shared/renderer-heap-statistics' +import type { RendererProcessMemory } from '../../shared/renderer-process-memory' +import { readRendererHeapStatistics } from '../renderer-heap-statistics-reader' +import { readRendererProcessMemory } from '../renderer-process-memory-reader' + +export const crashReportsApi = { + getLatestPending: () => ipcRenderer.invoke('crashReports:getLatestPending'), + getLatestReport: () => ipcRenderer.invoke('crashReports:getLatestReport'), + dismiss: (args: { reportId: string }) => ipcRenderer.invoke('crashReports:dismiss', args), + recordRendererError: ( + args: ReactErrorBoundaryReportArgs + ): Promise => + ipcRenderer.invoke('crashReports:recordRendererError', args), + recordBreadcrumb: (args: { name: string; data?: CrashReportBreadcrumbData }): void => + ipcRenderer.send('crashReports:recordBreadcrumb', args), + submit: (args: CrashReportSubmitArgs): Promise => + ipcRenderer.invoke('crashReports:submit', args), + copyLatestDiagnostics: (args?: CrashReportCopyDiagnosticsArgs) => + ipcRenderer.invoke('crashReports:copyLatestDiagnostics', args), + readHeapStatistics: (): RendererHeapStatistics | null => readRendererHeapStatistics(), + readProcessMemory: (): Promise => readRendererProcessMemory() +} diff --git a/src/preload/api/dashboard-bridge.ts b/src/preload/api/dashboard-bridge.ts new file mode 100644 index 00000000000..17241630857 --- /dev/null +++ b/src/preload/api/dashboard-bridge.ts @@ -0,0 +1,74 @@ +import { ipcRenderer } from 'electron' +import type { + DashboardRevealAgentArgs, + DashboardSleepWorkspaceArgs, + DashboardSnapshot, + DashboardSpawnAgentArgs +} from '../../shared/dashboard-snapshot' + +export const dashboardApi = { + // Open the pop-out dashboard window, or focus it if already open. + openPopout: (view?: 'board' | 'map'): Promise => + ipcRenderer.invoke('dashboardPopout:open', view), + + // ── Producer side (main window) ────────────────────────────────────── + publishSnapshot: (snapshot: DashboardSnapshot): Promise => + ipcRenderer.invoke('dashboard:publishSnapshot', snapshot), + getPopoutOpen: (): Promise => ipcRenderer.invoke('dashboard:getPopoutOpen'), + onPopoutOpenChanged: (callback: (open: boolean) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, open: boolean): void => callback(open) + ipcRenderer.on('dashboard:popoutOpenChanged', listener) + return () => ipcRenderer.removeListener('dashboard:popoutOpenChanged', listener) + }, + onSnapshotRequested: (callback: () => void): (() => void) => { + const listener = (): void => callback() + ipcRenderer.on('dashboard:snapshotRequested', listener) + return () => ipcRenderer.removeListener('dashboard:snapshotRequested', listener) + }, + onRevealAgent: (callback: (args: DashboardRevealAgentArgs) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, args: DashboardRevealAgentArgs): void => + callback(args) + ipcRenderer.on('ui:revealDashboardAgent', listener) + return () => ipcRenderer.removeListener('ui:revealDashboardAgent', listener) + }, + onAckAgent: (callback: (paneKey: string) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, paneKey: string): void => callback(paneKey) + ipcRenderer.on('ui:ackDashboardAgent', listener) + return () => ipcRenderer.removeListener('ui:ackDashboardAgent', listener) + }, + onSpawnAgent: (callback: (args: DashboardSpawnAgentArgs) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, args: DashboardSpawnAgentArgs): void => + callback(args) + ipcRenderer.on('ui:spawnDashboardAgent', listener) + return () => ipcRenderer.removeListener('ui:spawnDashboardAgent', listener) + }, + onSleepWorkspace: (callback: (args: DashboardSleepWorkspaceArgs) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, args: DashboardSleepWorkspaceArgs): void => + callback(args) + ipcRenderer.on('ui:sleepDashboardWorkspace', listener) + return () => ipcRenderer.removeListener('ui:sleepDashboardWorkspace', listener) + }, + + // ── Consumer side (pop-out window) ─────────────────────────────────── + requestSnapshot: (): Promise => ipcRenderer.invoke('dashboard:requestSnapshot'), + onSnapshot: (callback: (snapshot: DashboardSnapshot) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, snapshot: DashboardSnapshot): void => + callback(snapshot) + ipcRenderer.on('dashboard:snapshot', listener) + return () => ipcRenderer.removeListener('dashboard:snapshot', listener) + }, + onViewRequested: (callback: (view: 'board' | 'map') => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, view: 'board' | 'map'): void => + callback(view) + ipcRenderer.on('dashboard:viewRequested', listener) + return () => ipcRenderer.removeListener('dashboard:viewRequested', listener) + }, + revealAgent: (args: DashboardRevealAgentArgs): Promise => + ipcRenderer.invoke('dashboardPopout:revealAgent', args), + ackAgent: (paneKey: string): Promise => + ipcRenderer.invoke('dashboardPopout:ackAgent', { paneKey }), + spawnAgent: (args: DashboardSpawnAgentArgs): Promise => + ipcRenderer.invoke('dashboardPopout:spawnAgent', args), + sleepWorkspace: (args: DashboardSleepWorkspaceArgs): Promise => + ipcRenderer.invoke('dashboardPopout:sleepWorkspace', args) +} diff --git a/src/preload/api/developer-permissions-bridge.ts b/src/preload/api/developer-permissions-bridge.ts new file mode 100644 index 00000000000..1aaa51deed3 --- /dev/null +++ b/src/preload/api/developer-permissions-bridge.ts @@ -0,0 +1,11 @@ +import { ipcRenderer } from 'electron' + +export const developerPermissionsApi = { + getStatus: (): Promise => ipcRenderer.invoke('developerPermissions:getStatus'), + request: (args: { id: string }): Promise => + ipcRenderer.invoke('developerPermissions:request', args), + openSettings: (args: { id: string }): Promise => + ipcRenderer.invoke('developerPermissions:openSettings', args), + testLocalNetworkConnection: (args: { host: string; port: number }): Promise => + ipcRenderer.invoke('developerPermissions:testLocalNetworkConnection', args) +} diff --git a/src/preload/api/diagnostics-bridge.ts b/src/preload/api/diagnostics-bridge.ts new file mode 100644 index 00000000000..6d274f9b08a --- /dev/null +++ b/src/preload/api/diagnostics-bridge.ts @@ -0,0 +1,15 @@ +import { ipcRenderer } from 'electron' + +export const diagnosticsApi = { + getStatus: (): Promise => ipcRenderer.invoke('diagnostics:getStatus'), + collectBundle: (lookbackMinutes?: number): Promise => + ipcRenderer.invoke('diagnostics:collectBundle', lookbackMinutes), + openBundlePreview: (bundleSubmissionId: string): Promise => + ipcRenderer.invoke('diagnostics:openBundlePreview', bundleSubmissionId), + discardBundlePreview: (bundleSubmissionId: string): Promise => + ipcRenderer.invoke('diagnostics:discardBundlePreview', bundleSubmissionId), + uploadBundle: (bundleSubmissionId: string): Promise => + ipcRenderer.invoke('diagnostics:uploadBundle', bundleSubmissionId), + deleteBundle: (ticketId: string): Promise => + ipcRenderer.invoke('diagnostics:deleteBundle', ticketId) +} diff --git a/src/preload/api/doc-preview-bridge.ts b/src/preload/api/doc-preview-bridge.ts new file mode 100644 index 00000000000..68a099d5ed2 --- /dev/null +++ b/src/preload/api/doc-preview-bridge.ts @@ -0,0 +1,31 @@ +import { ipcRenderer } from 'electron' +import { + DOC_PREVIEW_EXTERNAL_LINK_CHANNEL, + DOC_PREVIEW_LOAD_FAILURE_CHANNEL, + DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL, + DOC_PREVIEW_MINT_GRANT_CHANNEL, + DOC_PREVIEW_REVOKE_GRANT_CHANNEL, + type DocPreviewFailure +} from '../../shared/doc-preview-scheme' +import type { DocPreviewGrantRequest } from '../api/doc-preview-api' + +export const docPreviewApi = { + mintGrant: (request: DocPreviewGrantRequest): Promise<{ grantId: string; url: string }> => + ipcRenderer.invoke(DOC_PREVIEW_MINT_GRANT_CHANNEL, request), + revokeGrant: (grantId: string): Promise => + ipcRenderer.invoke(DOC_PREVIEW_REVOKE_GRANT_CHANNEL, grantId), + authorizeDirectory: (grantId: string, relativePath: string): Promise => + ipcRenderer.invoke(DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL, grantId, relativePath), + onExternalLink: (callback: (payload: { url: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: { url: string }): void => + callback(payload) + ipcRenderer.on(DOC_PREVIEW_EXTERNAL_LINK_CHANNEL, listener) + return () => ipcRenderer.removeListener(DOC_PREVIEW_EXTERNAL_LINK_CHANNEL, listener) + }, + onLoadFailure: (callback: (payload: DocPreviewFailure) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: DocPreviewFailure): void => + callback(payload) + ipcRenderer.on(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, listener) + return () => ipcRenderer.removeListener(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, listener) + } +} diff --git a/src/preload/api/e2e-bridge.ts b/src/preload/api/e2e-bridge.ts new file mode 100644 index 00000000000..2876b72a265 --- /dev/null +++ b/src/preload/api/e2e-bridge.ts @@ -0,0 +1,5 @@ +import { preloadE2EConfig } from '../e2e-config' + +export const e2eApi = { + getConfig: () => preloadE2EConfig +} diff --git a/src/preload/api/emulator-bridge.ts b/src/preload/api/emulator-bridge.ts new file mode 100644 index 00000000000..f13e99fc52a --- /dev/null +++ b/src/preload/api/emulator-bridge.ts @@ -0,0 +1,98 @@ +import { ipcRenderer } from 'electron' + +export const emulatorApi = { + startFrameStream: (args: { + streamUrl: string + streamKey?: string + }): Promise<{ + streamId: string + }> => ipcRenderer.invoke('emulator:frameStreamStart', args), + stopFrameStream: (args: { streamId: string }): Promise => + ipcRenderer.invoke('emulator:frameStreamStop', args), + onFrameStreamFrame: ( + callback: (data: { streamId: string; bytes: ArrayBuffer }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { streamId: string; bytes: ArrayBuffer } + ) => callback(data) + ipcRenderer.on('emulator:frameStreamFrame', listener) + return () => ipcRenderer.removeListener('emulator:frameStreamFrame', listener) + }, + onFrameStreamError: ( + callback: (data: { streamId: string; message: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { streamId: string; message: string } + ) => callback(data) + ipcRenderer.on('emulator:frameStreamError', listener) + return () => ipcRenderer.removeListener('emulator:frameStreamError', listener) + }, + startVideoStream: (args: { deviceId: string; streamId: string }): Promise<{ streamId: string }> => + ipcRenderer.invoke('emulator:videoStreamStart', args), + stopVideoStream: (args: { streamId: string }): Promise => + ipcRenderer.invoke('emulator:videoStreamStop', args), + onVideoStreamMeta: ( + callback: (data: { + streamId: string + deviceId: string + meta: { codecId: string; width: number; height: number } + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + streamId: string + deviceId: string + meta: { codecId: string; width: number; height: number } + } + ) => callback(data) + ipcRenderer.on('emulator:videoStreamMeta', listener) + return () => ipcRenderer.removeListener('emulator:videoStreamMeta', listener) + }, + onVideoStreamFrame: ( + callback: (data: { + streamId: string + deviceId: string + config: boolean + keyFrame: boolean + bytes: ArrayBuffer + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + streamId: string + deviceId: string + config: boolean + keyFrame: boolean + bytes: ArrayBuffer + } + ) => callback(data) + ipcRenderer.on('emulator:videoStreamFrame', listener) + return () => ipcRenderer.removeListener('emulator:videoStreamFrame', listener) + }, + onPaneFocus: (callback: (data: { worktreeId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) => + callback(data) + ipcRenderer.on('emulator:pane-focus', listener) + return () => ipcRenderer.removeListener('emulator:pane-focus', listener) + }, + onAutoAttach: ( + callback: (data: { + worktreeId: string + info: { deviceUdid: string; streamUrl: string; wsUrl: string; axUrl?: string } + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + worktreeId: string + info: { deviceUdid: string; streamUrl: string; wsUrl: string; axUrl?: string } + } + ) => callback(data) + ipcRenderer.on('ui:emulatorAutoAttach', listener) + return () => ipcRenderer.removeListener('ui:emulatorAutoAttach', listener) + } +} diff --git a/src/preload/api/ephemeral-vm-bridge.ts b/src/preload/api/ephemeral-vm-bridge.ts new file mode 100644 index 00000000000..9f55530d6b8 --- /dev/null +++ b/src/preload/api/ephemeral-vm-bridge.ts @@ -0,0 +1,25 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const ephemeralVmApi = { + listRecipes: (args) => ipcRenderer.invoke('ephemeralVm:listRecipes', args), + listRecipeCatalog: () => ipcRenderer.invoke('ephemeralVm:listRecipeCatalog'), + doctor: (args) => ipcRenderer.invoke('ephemeralVm:doctor', args), + provision: (args) => ipcRenderer.invoke('ephemeralVm:provision', args), + cancelProvision: (args) => ipcRenderer.invoke('ephemeralVm:cancelProvision', args), + onProvisionEvent: (callback) => { + const listener = ( + _event: Electron.IpcRendererEvent, + event: { provisionId: string; stream: 'stdout' | 'stderr'; chunk: string } + ): void => callback(event) + ipcRenderer.on('ephemeralVm:provisionEvent', listener) + return () => ipcRenderer.removeListener('ephemeralVm:provisionEvent', listener) + }, + listRuntimes: () => ipcRenderer.invoke('ephemeralVm:listRuntimes'), + attachWorkspace: (args) => ipcRenderer.invoke('ephemeralVm:attachWorkspace', args), + suspendWorkspace: (args) => ipcRenderer.invoke('ephemeralVm:suspendWorkspace', args), + resumeWorkspace: (args) => ipcRenderer.invoke('ephemeralVm:resumeWorkspace', args), + cleanup: (args) => ipcRenderer.invoke('ephemeralVm:cleanup', args), + stopCleanup: (args) => ipcRenderer.invoke('ephemeralVm:stopCleanup', args), + getCleanupCommand: (args) => ipcRenderer.invoke('ephemeralVm:getCleanupCommand', args) +} satisfies PreloadApi['ephemeralVm'] diff --git a/src/preload/api/export-bridge.ts b/src/preload/api/export-bridge.ts new file mode 100644 index 00000000000..637d4bea2ef --- /dev/null +++ b/src/preload/api/export-bridge.ts @@ -0,0 +1,10 @@ +import { ipcRenderer } from 'electron' + +export const exportApi = { + htmlToPdf: (args: { + html: string + title: string + }): Promise< + { success: true; filePath: string } | { success: false; cancelled?: boolean; error?: string } + > => ipcRenderer.invoke('export:html-to-pdf', args) +} diff --git a/src/preload/api/feedback-bridge.ts b/src/preload/api/feedback-bridge.ts new file mode 100644 index 00000000000..55b3b5fcaa7 --- /dev/null +++ b/src/preload/api/feedback-bridge.ts @@ -0,0 +1,13 @@ +import { ipcRenderer } from 'electron' + +export const feedbackApi = { + submit: (args: { + feedback: string + submitAnonymously?: boolean + githubLogin: string | null + githubEmail: string | null + images?: { contentType: string; data: Uint8Array }[] + }): Promise< + { ok: true; imagesDelivered?: boolean } | { ok: false; status: number | null; error: string } + > => ipcRenderer.invoke('feedback:submit', args) +} diff --git a/src/preload/api/folder-workspaces-bridge.ts b/src/preload/api/folder-workspaces-bridge.ts new file mode 100644 index 00000000000..d085719d8ab --- /dev/null +++ b/src/preload/api/folder-workspaces-bridge.ts @@ -0,0 +1,10 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const folderWorkspacesApi = { + list: () => ipcRenderer.invoke('folderWorkspaces:list'), + getPathStatus: (args) => ipcRenderer.invoke('folderWorkspaces:getPathStatus', args), + create: (args) => ipcRenderer.invoke('folderWorkspaces:create', args), + update: (args) => ipcRenderer.invoke('folderWorkspaces:update', args), + delete: (args) => ipcRenderer.invoke('folderWorkspaces:delete', args) +} satisfies PreloadApi['folderWorkspaces'] diff --git a/src/preload/api/fs-bridge.ts b/src/preload/api/fs-bridge.ts new file mode 100644 index 00000000000..538480ce2f5 --- /dev/null +++ b/src/preload/api/fs-bridge.ts @@ -0,0 +1,219 @@ +import { ipcRenderer } from 'electron' +import type { SshMutationExpectation } from '../../shared/ssh-types' +import type { SearchResult } from '../../shared/code-search-types' +import type { FsChangedPayload } from '../../shared/filesystem-entry-types' +import type { + LocalLogTailChangedPayload, + LocalLogTailReadArgs, + LocalLogTailReadResult, + LocalLogTailWatchArgs +} from '../../shared/local-log-tail-types' + +export const fsApi = { + readDir: (args: { + dirPath: string + connectionId?: string + }): Promise<{ name: string; isDirectory: boolean; isSymlink: boolean }[]> => + ipcRenderer.invoke('fs:readDir', args), + readFile: (args: { + filePath: string + connectionId?: string + includeLocalLogMetadata?: boolean + }): Promise<{ + content: string + isBinary: boolean + isImage?: boolean + mimeType?: string + fileIdentity?: string + }> => ipcRenderer.invoke('fs:readFile', args), + readLocalLogTail: (args: LocalLogTailReadArgs): Promise => + ipcRenderer.invoke('fs:readLocalLogTail', args), + startLocalLogTail: (args: LocalLogTailWatchArgs): Promise => + ipcRenderer.invoke('fs:startLocalLogTail', args), + stopLocalLogTail: (args: { subscriptionId: string }): Promise => + ipcRenderer.invoke('fs:stopLocalLogTail', args), + onLocalLogTailChanged: ( + callback: (payload: LocalLogTailChangedPayload) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: LocalLogTailChangedPayload + ): void => callback(payload) + ipcRenderer.on('fs:localLogTailChanged', listener) + return () => ipcRenderer.removeListener('fs:localLogTailChanged', listener) + }, + downloadFile: (args: { + filePath: string + connectionId: string + }): Promise<{ canceled: true } | { canceled: false; destinationPath: string }> => + ipcRenderer.invoke('fs:downloadFile', args), + downloadFolder: (args: { + dirPath: string + connectionId: string + }): Promise<{ canceled: true } | { canceled: false; destinationPath: string }> => + ipcRenderer.invoke('fs:downloadFolder', args), + saveDownloadedFile: (args: { + suggestedName: string + content: string + encoding: 'utf8' | 'base64' + }): Promise<{ canceled: true } | { canceled: false; destinationPath: string }> => + ipcRenderer.invoke('fs:saveDownloadedFile', args), + startDownloadedFile: (args: { + suggestedName: string + }): Promise< + { canceled: true } | { canceled: false; transferId: string; destinationPath: string } + > => ipcRenderer.invoke('fs:startDownloadedFile', args), + appendDownloadedFileChunk: (args: { + transferId: string + contentBase64: string + }): Promise<{ ok: true }> => ipcRenderer.invoke('fs:appendDownloadedFileChunk', args), + finishDownloadedFile: (args: { + transferId: string + }): Promise<{ canceled: false; destinationPath: string }> => + ipcRenderer.invoke('fs:finishDownloadedFile', args), + cancelDownloadedFile: (args: { transferId: string }): Promise<{ ok: true }> => + ipcRenderer.invoke('fs:cancelDownloadedFile', args), + listMarkdownDocuments: (args: { + rootPath: string + connectionId?: string + }): Promise<{ filePath: string; relativePath: string; basename: string; name: string }[]> => + ipcRenderer.invoke('fs:listMarkdownDocuments', args), + writeFile: ( + args: { + filePath: string + content: string + connectionId?: string + } & SshMutationExpectation + ): Promise => ipcRenderer.invoke('fs:writeFile', args), + createFile: ( + args: { filePath: string; connectionId?: string } & SshMutationExpectation + ): Promise => ipcRenderer.invoke('fs:createFile', args), + createDir: ( + args: { dirPath: string; connectionId?: string } & SshMutationExpectation + ): Promise => ipcRenderer.invoke('fs:createDir', args), + rename: ( + args: { oldPath: string; newPath: string; connectionId?: string } & SshMutationExpectation + ): Promise => ipcRenderer.invoke('fs:rename', args), + copy: ( + args: { + sourcePath: string + destinationPath: string + connectionId?: string + } & SshMutationExpectation + ): Promise => ipcRenderer.invoke('fs:copy', args), + deletePath: ( + args: { + targetPath: string + connectionId?: string + recursive?: boolean + } & SshMutationExpectation + ): Promise => ipcRenderer.invoke('fs:deletePath', args), + authorizeExternalPath: (args: { targetPath: string }): Promise => + ipcRenderer.invoke('fs:authorizeExternalPath', args), + stat: (args: { + filePath: string + connectionId?: string + }): Promise<{ size: number; isDirectory: boolean; mtime: number }> => + ipcRenderer.invoke('fs:stat', args), + pathExists: (args: { filePath: string; connectionId?: string }): Promise => + ipcRenderer.invoke('fs:pathExists', args), + listFiles: (args: { + rootPath: string + connectionId?: string + excludePaths?: string[] + requestToken?: string + maxResults?: number + searchQuery?: string + }): Promise => ipcRenderer.invoke('fs:listFiles', args), + cancelListFiles: (args: { requestToken: string }): Promise => + ipcRenderer.invoke('fs:cancelListFiles', args), + search: (args: { + query: string + rootPath: string + caseSensitive?: boolean + wholeWord?: boolean + useRegex?: boolean + includePattern?: string + excludePattern?: string + maxResults?: number + connectionId?: string + }): Promise => ipcRenderer.invoke('fs:search', args), + importExternalPaths: ( + args: { + sourcePaths: string[] + destDir: string + connectionId?: string + ensureDir?: boolean + } & SshMutationExpectation + ): Promise<{ + results: ( + | { + sourcePath: string + status: 'imported' + destPath: string + kind: 'file' | 'directory' + renamed: boolean + } + | { + sourcePath: string + status: 'skipped' + reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' + } + | { + sourcePath: string + status: 'failed' + reason: string + } + )[] + }> => ipcRenderer.invoke('fs:importExternalPaths', args), + stageExternalPathsForRuntimeUpload: (args: { + sourcePaths: string[] + }): Promise<{ + sources: ( + | { + sourcePath: string + status: 'staged' + name: string + kind: 'file' | 'directory' + entries: ( + | { relativePath: string; kind: 'directory' } + | { relativePath: string; kind: 'file'; contentBase64: string } + )[] + } + | { + sourcePath: string + status: 'skipped' + reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' + } + | { + sourcePath: string + status: 'failed' + reason: string + } + )[] + }> => ipcRenderer.invoke('fs:stageExternalPathsForRuntimeUpload', args), + resolveDroppedPathsForAgent: ( + args: { + paths: string[] + worktreePath: string + connectionId?: string + } & SshMutationExpectation + ): Promise<{ + resolvedPaths: string[] + skipped: { + sourcePath: string + reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' + }[] + failed: { sourcePath: string; reason: string }[] + }> => ipcRenderer.invoke('fs:resolveDroppedPathsForAgent', args), + watchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise => + ipcRenderer.invoke('fs:watchWorktree', args), + unwatchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise => + ipcRenderer.invoke('fs:unwatchWorktree', args), + onFsChanged: (callback: (payload: FsChangedPayload) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: FsChangedPayload) => + callback(payload) + ipcRenderer.on('fs:changed', listener) + return () => ipcRenderer.removeListener('fs:changed', listener) + } +} diff --git a/src/preload/api/gh-bridge-part-1.ts b/src/preload/api/gh-bridge-part-1.ts new file mode 100644 index 00000000000..11ed8c32f79 --- /dev/null +++ b/src/preload/api/gh-bridge-part-1.ts @@ -0,0 +1,193 @@ +import { ipcRenderer } from 'electron' +import type { GitHubReactionContent } from '../../shared/github/comment-types' +import type { + GitHubPRRefreshCandidate, + GitHubPRRefreshEvent, + GitHubPRRefreshReason +} from '../../shared/github/pull-request-refresh-types' +import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' +import type { GitHubWorkItem, ListWorkItemsResult } from '../../shared/github/work-item-types' +import type { GitHubCreateIssueResult } from '../../shared/issue-mutation-types' +import type { TaskSourceContext } from '../../shared/task-source-context' + +export const ghApiPart1 = { + viewer: (): Promise => ipcRenderer.invoke('gh:viewer'), + repoSlug: (args: { repoPath: string; repoId?: string }): Promise => + ipcRenderer.invoke('gh:repoSlug', args), + repoUpstream: (args: { repoPath: string; repoId?: string }): Promise => + ipcRenderer.invoke('gh:repoUpstream', args), + prForBranch: (args: { + repoPath: string + repoId?: string + branch: string + linkedPRNumber?: number | null + fallbackPRNumber?: number | null + acceptMergedFallbackPR?: boolean + currentHeadOid?: string | null + }): Promise => ipcRenderer.invoke('gh:prForBranch', args), + refreshPRNow: (args: { candidate: GitHubPRRefreshCandidate }): Promise => + ipcRenderer.invoke('gh:refreshPRNow', args), + enqueuePRRefresh: (args: { + candidate: GitHubPRRefreshCandidate + reason: GitHubPRRefreshReason + priority?: number + }): Promise => ipcRenderer.invoke('gh:enqueuePRRefresh', args), + reportVisiblePRRefreshCandidates: (args: { + candidates: GitHubPRRefreshCandidate[] + generation: number + }): Promise => ipcRenderer.invoke('gh:reportVisiblePRRefreshCandidates', args), + onPRRefreshEvent: (callback: (event: GitHubPRRefreshEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, event: GitHubPRRefreshEvent): void => + callback(event) + ipcRenderer.on('gh:prRefreshEvent', listener) + return () => ipcRenderer.removeListener('gh:prRefreshEvent', listener) + }, + issue: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + number: number + }): Promise => ipcRenderer.invoke('gh:issue', args), + workItem: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + number: number + type?: 'issue' | 'pr' + }): Promise => ipcRenderer.invoke('gh:workItem', args), + workItemByOwnerRepo: (args: { + repoPath: string + repoId?: string + owner: string + repo: string + host?: string + number: number + type: 'issue' | 'pr' + }): Promise => ipcRenderer.invoke('gh:workItemByOwnerRepo', args), + workItemDetails: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + number: number + type?: 'issue' | 'pr' + }): Promise => ipcRenderer.invoke('gh:workItemDetails', args), + notifyWorkItemMutated: (args: { + repoPath: string + repoId?: string + type: 'issue' | 'pr' + number: number + }): Promise => ipcRenderer.invoke('gh:notifyWorkItemMutated', args), + prFileContents: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + prNumber: number + prRepo?: GitHubOwnerRepo | null + path: string + oldPath?: string + status: string + headSha: string + baseSha: string + }): Promise => ipcRenderer.invoke('gh:prFileContents', args), + listIssues: (args: { repoPath: string; repoId?: string; limit?: number }): Promise => + ipcRenderer.invoke('gh:listIssues', args), + createIssue: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + title: string + body: string + labels?: string[] + assignees?: string[] + }): Promise => ipcRenderer.invoke('gh:createIssue', args), + countWorkItems: (args: { repoPath: string; repoId?: string; query?: string }): Promise => + ipcRenderer.invoke('gh:countWorkItems', args), + listWorkItems: (args: { + repoPath: string + repoId?: string + limit?: number + query?: string + page?: number + noCache?: boolean + }): Promise>> => + ipcRenderer.invoke('gh:listWorkItems', args), + prChecks: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + prNumber: number + headSha?: string + prRepo?: GitHubOwnerRepo | null + noCache?: boolean + }): Promise => ipcRenderer.invoke('gh:prChecks', args), + prCheckDetails: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + checkRunId?: number + workflowRunId?: number + checkName?: string + url?: string | null + prRepo?: GitHubOwnerRepo | null + }): Promise => ipcRenderer.invoke('gh:prCheckDetails', args), + rerunPRChecks: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + prNumber: number + headSha?: string + failedOnly?: boolean + prRepo?: GitHubOwnerRepo | null + }): Promise<{ ok: true; count: number } | { ok: false; error: string }> => + ipcRenderer.invoke('gh:rerunPRChecks', args), + prComments: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + prNumber: number + prRepo?: GitHubOwnerRepo | null + noCache?: boolean + }): Promise => ipcRenderer.invoke('gh:prComments', args), + setPRCommentReaction: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + reactionSubjectId: string + content: GitHubReactionContent + reacted: boolean + prRepo?: GitHubOwnerRepo | null + }): Promise => ipcRenderer.invoke('gh:setPRCommentReaction', args), + resolveReviewThread: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + threadId: string + resolve: boolean + prRepo?: GitHubOwnerRepo | null + }): Promise => ipcRenderer.invoke('gh:resolveReviewThread', args), + setPRFileViewed: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + prNumber: number + prRepo?: GitHubOwnerRepo | null + pullRequestId: string + path: string + viewed: boolean + }): Promise => ipcRenderer.invoke('gh:setPRFileViewed', args), + updatePRTitle: (args: { + repoPath: string + repoId?: string + prNumber: number + title: string + prRepo?: GitHubOwnerRepo | null + }): Promise => ipcRenderer.invoke('gh:updatePRTitle', args), + mergePR: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + prNumber: number + method?: 'merge' | 'squash' | 'rebase' + prRepo?: GitHubOwnerRepo | null + }): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gh:mergePR', args) +} diff --git a/src/preload/api/gh-bridge-part-2.ts b/src/preload/api/gh-bridge-part-2.ts new file mode 100644 index 00000000000..c7ebc51b57e --- /dev/null +++ b/src/preload/api/gh-bridge-part-2.ts @@ -0,0 +1,202 @@ +import { ipcRenderer } from 'electron' +import type { GitHubCommentResult } from '../../shared/github/comment-types' +import type { GitHubAssignableUser, GitHubOwnerRepo } from '../../shared/github/pull-request-types' +import type { GetRateLimitResult } from '../../shared/github/rate-limit-types' +import type { GhAuthDiagnostic } from '../../shared/github/auth-types' +import type { TaskSourceContext } from '../../shared/task-source-context' +import type { + GetProjectViewTableResult, + GitHubProjectCommentMutationResult, + GitHubProjectMutationResult, + ListAccessibleProjectsResult, + ListAssignableUsersBySlugResult, + ListIssueTypesBySlugResult, + ListLabelsBySlugResult, + ListProjectViewsResult, + ProjectWorkItemDetailsBySlugResult, + ResolveProjectRefResult +} from '../../shared/github/project-result-types' +import type { + AddIssueCommentBySlugArgs, + ClearProjectItemFieldArgs, + DeleteIssueCommentBySlugArgs, + GetProjectViewTableArgs, + ListAccessibleProjectsArgs, + ListAssignableUsersBySlugArgs, + ListIssueTypesBySlugArgs, + ListLabelsBySlugArgs, + ListProjectViewsArgs, + ProjectWorkItemDetailsBySlugArgs, + ResolveProjectRefArgs, + UpdateIssueBySlugArgs, + UpdateIssueCommentBySlugArgs, + UpdateIssueTypeBySlugArgs, + UpdatePullRequestBySlugArgs, + UpdateProjectItemFieldArgs +} from '../../shared/github/project-request-types' +import type { AppStarSource } from '../../shared/gh-star-source' + +export const ghApiPart2 = { + setPRAutoMerge: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + prNumber: number + enabled: boolean + method?: 'merge' | 'squash' | 'rebase' + prRepo?: GitHubOwnerRepo | null + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('gh:setPRAutoMerge', args), + updatePRState: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + prNumber: number + updates: { state: 'open' | 'closed' } + prRepo?: GitHubOwnerRepo | null + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('gh:updatePRState', args), + markPRReadyForReview: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + prNumber: number + prRepo?: GitHubOwnerRepo | null + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('gh:markPRReadyForReview', args), + requestPRReviewers: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + prNumber: number + reviewers: string[] + prRepo?: GitHubOwnerRepo | null + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('gh:requestPRReviewers', args), + removePRReviewers: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + prNumber: number + reviewers: string[] + prRepo?: GitHubOwnerRepo | null + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('gh:removePRReviewers', args), + updateIssue: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + number: number + updates: unknown + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('gh:updateIssue', args), + addIssueComment: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + number: number + body: string + type?: 'issue' | 'pr' + prRepo?: GitHubOwnerRepo | null + }): Promise => ipcRenderer.invoke('gh:addIssueComment', args), + addPRReviewCommentReply: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + prNumber: number + commentId: number + body: string + threadId?: string + path?: string + line?: number + prRepo?: GitHubOwnerRepo | null + }): Promise => ipcRenderer.invoke('gh:addPRReviewCommentReply', args), + addPRReviewComment: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + prNumber: number + prRepo?: GitHubOwnerRepo | null + commitId: string + path: string + line: number + startLine?: number + body: string + }): Promise => ipcRenderer.invoke('gh:addPRReviewComment', args), + listLabels: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + }): Promise => ipcRenderer.invoke('gh:listLabels', args), + listAssignableUsers: (args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + }): Promise => ipcRenderer.invoke('gh:listAssignableUsers', args), + onWorkItemMutated: ( + callback: (payload: { + repoPath: string + repoId?: string + type: 'issue' | 'pr' + number: number + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: { repoPath: string; repoId?: string; type: 'issue' | 'pr'; number: number } + ): void => callback(payload) + ipcRenderer.on('gh:workItemMutated', listener) + return () => ipcRenderer.removeListener('gh:workItemMutated', listener) + }, + checkOrcaStarred: (): Promise => ipcRenderer.invoke('gh:checkOrcaStarred'), + starOrca: (source: AppStarSource): Promise => ipcRenderer.invoke('gh:starOrca', source), + rateLimit: (args?: { force?: boolean }): Promise => + ipcRenderer.invoke('gh:rateLimit', args), + diagnoseAuth: (args?: { host?: string }): Promise => + ipcRenderer.invoke('gh:diagnoseAuth', args), + listAccessibleProjects: ( + args?: ListAccessibleProjectsArgs + ): Promise => ipcRenderer.invoke('gh:listAccessibleProjects', args), + resolveProjectRef: (args: ResolveProjectRefArgs): Promise => + ipcRenderer.invoke('gh:resolveProjectRef', args), + listProjectViews: (args: ListProjectViewsArgs): Promise => + ipcRenderer.invoke('gh:listProjectViews', args), + getProjectViewTable: (args: GetProjectViewTableArgs): Promise => + ipcRenderer.invoke('gh:getProjectViewTable', args), + projectWorkItemDetailsBySlug: ( + args: ProjectWorkItemDetailsBySlugArgs + ): Promise => + ipcRenderer.invoke('gh:projectWorkItemDetailsBySlug', args), + updateProjectItemField: ( + args: UpdateProjectItemFieldArgs + ): Promise => ipcRenderer.invoke('gh:updateProjectItemField', args), + clearProjectItemField: (args: ClearProjectItemFieldArgs): Promise => + ipcRenderer.invoke('gh:clearProjectItemField', args), + updateIssueBySlug: (args: UpdateIssueBySlugArgs): Promise => + ipcRenderer.invoke('gh:updateIssueBySlug', args), + updatePullRequestBySlug: ( + args: UpdatePullRequestBySlugArgs + ): Promise => ipcRenderer.invoke('gh:updatePullRequestBySlug', args), + addIssueCommentBySlug: ( + args: AddIssueCommentBySlugArgs + ): Promise => + ipcRenderer.invoke('gh:addIssueCommentBySlug', args), + updateIssueCommentBySlug: ( + args: UpdateIssueCommentBySlugArgs + ): Promise => + ipcRenderer.invoke('gh:updateIssueCommentBySlug', args), + deleteIssueCommentBySlug: ( + args: DeleteIssueCommentBySlugArgs + ): Promise => + ipcRenderer.invoke('gh:deleteIssueCommentBySlug', args), + listLabelsBySlug: (args: ListLabelsBySlugArgs): Promise => + ipcRenderer.invoke('gh:listLabelsBySlug', args), + listAssignableUsersBySlug: ( + args: ListAssignableUsersBySlugArgs + ): Promise => + ipcRenderer.invoke('gh:listAssignableUsersBySlug', args), + listIssueTypesBySlug: (args: ListIssueTypesBySlugArgs): Promise => + ipcRenderer.invoke('gh:listIssueTypesBySlug', args), + updateIssueTypeBySlug: (args: UpdateIssueTypeBySlugArgs): Promise => + ipcRenderer.invoke('gh:updateIssueTypeBySlug', args) +} diff --git a/src/preload/api/gh-bridge.ts b/src/preload/api/gh-bridge.ts new file mode 100644 index 00000000000..c4bef718721 --- /dev/null +++ b/src/preload/api/gh-bridge.ts @@ -0,0 +1,4 @@ +import { ghApiPart1 } from './gh-bridge-part-1' +import { ghApiPart2 } from './gh-bridge-part-2' + +export const ghApi = { ...ghApiPart1, ...ghApiPart2 } diff --git a/src/preload/api/git-bash-bridge.ts b/src/preload/api/git-bash-bridge.ts new file mode 100644 index 00000000000..bd62dfc614a --- /dev/null +++ b/src/preload/api/git-bash-bridge.ts @@ -0,0 +1,5 @@ +import { ipcRenderer } from 'electron' + +export const gitBashApi = { + isAvailable: (): Promise => ipcRenderer.invoke('gitBash:isAvailable') +} diff --git a/src/preload/api/git-bridge.ts b/src/preload/api/git-bridge.ts new file mode 100644 index 00000000000..89dfc8db5ae --- /dev/null +++ b/src/preload/api/git-bridge.ts @@ -0,0 +1,200 @@ +import { ipcRenderer } from 'electron' +import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../shared/git-fork-sync' +import type { GitStagingArea, GitUpstreamStatus } from '../../shared/git-status-types' +import type { GitPushTarget } from '../../shared/worktree/types' +import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' + +export const gitApi = { + status: (args: { + worktreePath: string + connectionId?: string + includeIgnored?: boolean + bypassEffectiveUpstreamNegativeCache?: boolean + reuseLineStats?: boolean + branchLineTotalMergeBase?: string + requestToken?: string + }): Promise => ipcRenderer.invoke('git:status', args), + cancelStatus: (args: { requestToken: string }): Promise => + ipcRenderer.invoke('git:cancelStatus', args), + setStatusUpstreamRefWatch: (args: { + worktreeId: string + worktreePath: string + executionHostId: string + connectionId?: string + branch?: string + upstreamName?: string + }): Promise => ipcRenderer.invoke('git:setStatusUpstreamRefWatch', args), + submoduleStatus: (args: { + worktreePath: string + submodulePath: string + connectionId?: string + area?: GitStagingArea + }): Promise => ipcRenderer.invoke('git:submoduleStatus', args), + checkIgnored: (args: { + worktreePath: string + paths: string[] + connectionId?: string + }): Promise => ipcRenderer.invoke('git:checkIgnored', args), + findHugeFoldersToIgnore: (args: { worktreePath: string }): Promise => + ipcRenderer.invoke('git:findHugeFoldersToIgnore', args), + appendGitignore: (args: { worktreePath: string; folderName: string }): Promise => + ipcRenderer.invoke('git:appendGitignore', args), + history: ( + args: { worktreePath: string; connectionId?: string } & GitHistoryOptions + ): Promise => ipcRenderer.invoke('git:history', args), + conflictOperation: (args: { worktreePath: string; connectionId?: string }): Promise => + ipcRenderer.invoke('git:conflictOperation', args), + abortMerge: (args: { worktreePath: string; connectionId?: string }): Promise => + ipcRenderer.invoke('git:abortMerge', args), + abortRebase: (args: { worktreePath: string; connectionId?: string }): Promise => + ipcRenderer.invoke('git:abortRebase', args), + diff: (args: { + worktreePath: string + filePath: string + staged: boolean + compareAgainstHead?: boolean + connectionId?: string + }): Promise => ipcRenderer.invoke('git:diff', args), + branchCompare: (args: { + worktreePath: string + baseRef: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:branchCompare', args), + commitCompare: (args: { + worktreePath: string + commitId: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:commitCompare', args), + upstreamStatus: (args: { + worktreePath: string + connectionId?: string + pushTarget?: GitPushTarget + }): Promise => ipcRenderer.invoke('git:upstreamStatus', args), + fetch: (args: { + worktreePath: string + connectionId?: string + pushTarget?: GitPushTarget + }): Promise => ipcRenderer.invoke('git:fetch', args), + syncFork: (args: { + worktreePath: string + connectionId?: string + expectedUpstream: GitForkSyncExpectedUpstream + }): Promise => ipcRenderer.invoke('git:syncFork', args), + push: (args: { + worktreePath: string + publish?: boolean + forceWithLease?: boolean + connectionId?: string + pushTarget?: unknown + }): Promise => ipcRenderer.invoke('git:push', args), + pull: (args: { + worktreePath: string + connectionId?: string + pushTarget?: GitPushTarget + }): Promise => ipcRenderer.invoke('git:pull', args), + fastForward: (args: { + worktreePath: string + connectionId?: string + pushTarget?: GitPushTarget + }): Promise => ipcRenderer.invoke('git:fastForward', args), + rebaseFromBase: (args: { + worktreePath: string + baseRef: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:rebaseFromBase', args), + branchDiff: (args: { + worktreePath: string + compare: { baseRef: string; baseOid: string; headOid: string; mergeBase: string } + filePath: string + oldPath?: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:branchDiff', args), + commitDiff: (args: { + worktreePath: string + commitOid: string + parentOid?: string | null + filePath: string + oldPath?: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:commitDiff', args), + commit: (args: { + worktreePath: string + message: string + connectionId?: string + }): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('git:commit', args), + generateCommitMessage: (args: { + worktreePath: string + worktreeId?: string + repoId?: string + connectionId?: string + sourceControlAiResolvedParams?: unknown + sourceControlAi?: unknown + agentCmdOverrides?: Record + }): Promise => ipcRenderer.invoke('git:generateCommitMessage', args), + discoverCommitMessageModels: (args: { + agentId: string + worktreePath?: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:discoverCommitMessageModels', args), + cancelGenerateCommitMessage: (args: { + worktreePath: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:cancelGenerateCommitMessage', args), + generatePullRequestFields: (args: { + worktreePath: string + worktreeId?: string + repoId?: string + base: string + title: string + body: string + draft: boolean + provider?: unknown + useTemplate?: boolean + connectionId?: string + sourceControlAiResolvedParams?: unknown + sourceControlAi?: unknown + agentCmdOverrides?: Record + }): Promise => ipcRenderer.invoke('git:generatePullRequestFields', args), + cancelGeneratePullRequestFields: (args: { + worktreePath: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:cancelGeneratePullRequestFields', args), + stage: (args: { worktreePath: string; filePath: string; connectionId?: string }): Promise => + ipcRenderer.invoke('git:stage', args), + bulkStage: (args: { + worktreePath: string + filePaths: string[] + connectionId?: string + }): Promise => ipcRenderer.invoke('git:bulkStage', args), + unstage: (args: { + worktreePath: string + filePath: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:unstage', args), + bulkUnstage: (args: { + worktreePath: string + filePaths: string[] + connectionId?: string + }): Promise => ipcRenderer.invoke('git:bulkUnstage', args), + discard: (args: { + worktreePath: string + filePath: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:discard', args), + bulkDiscard: (args: { + worktreePath: string + filePaths: string[] + connectionId?: string + }): Promise => ipcRenderer.invoke('git:bulkDiscard', args), + remoteFileUrl: (args: { + worktreePath: string + relativePath: string + line: number + connectionId?: string + }): Promise => ipcRenderer.invoke('git:remoteFileUrl', args), + remoteCommitUrl: (args: { + worktreePath: string + sha: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:remoteCommitUrl', args) +} diff --git a/src/preload/api/gl-bridge.ts b/src/preload/api/gl-bridge.ts new file mode 100644 index 00000000000..c48794a4976 --- /dev/null +++ b/src/preload/api/gl-bridge.ts @@ -0,0 +1,3 @@ +import { glApi } from '../gitlab' + +export const glApiBridge = glApi diff --git a/src/preload/api/grok-accounts-bridge.ts b/src/preload/api/grok-accounts-bridge.ts new file mode 100644 index 00000000000..b4719905ec7 --- /dev/null +++ b/src/preload/api/grok-accounts-bridge.ts @@ -0,0 +1,6 @@ +import { ipcRenderer } from 'electron' +import type { GrokAccountStatus } from '../../shared/rate-limit-types' + +export const grokAccountsApi = { + getStatus: (): Promise => ipcRenderer.invoke('grokAccounts:getStatus') +} diff --git a/src/preload/api/hooks-bridge.ts b/src/preload/api/hooks-bridge.ts new file mode 100644 index 00000000000..59a6fee71b6 --- /dev/null +++ b/src/preload/api/hooks-bridge.ts @@ -0,0 +1,44 @@ +import { ipcRenderer } from 'electron' +import type { WorktreeSetupLaunch } from '../../shared/worktree/launch-types' +import type { ExecutionHostId } from '../../shared/execution-host' + +export const hooksApi = { + check: (args: { + repoId: string + hostId?: ExecutionHostId + }): Promise<{ + status?: 'ok' | 'error' + hasHooks: boolean + hooks: unknown + mayNeedUpdate: boolean + }> => ipcRenderer.invoke('hooks:check', args), + + inspectSetupScriptImports: (args: { + repoId: string + hostId?: ExecutionHostId + }): Promise => ipcRenderer.invoke('hooks:inspectSetupScriptImports', args), + + createIssueCommandRunner: (args: { + repoId: string + worktreePath: string + command: string + }): Promise => ipcRenderer.invoke('hooks:createIssueCommandRunner', args), + + readIssueCommand: (args: { + repoId: string + hostId?: ExecutionHostId + }): Promise<{ + status?: 'ok' | 'error' + localContent: string | null + sharedContent: string | null + effectiveContent: string | null + localFilePath: string + source: 'local' | 'shared' | 'none' + }> => ipcRenderer.invoke('hooks:readIssueCommand', args), + + writeIssueCommand: (args: { + repoId: string + content: string + hostId?: ExecutionHostId + }): Promise => ipcRenderer.invoke('hooks:writeIssueCommand', args) +} diff --git a/src/preload/api/hosted-review-bridge.ts b/src/preload/api/hosted-review-bridge.ts new file mode 100644 index 00000000000..dc8323b5bf3 --- /dev/null +++ b/src/preload/api/hosted-review-bridge.ts @@ -0,0 +1,12 @@ +import { ipcRenderer } from 'electron' +import type { HostedReviewForBranchArgs } from '../../shared/hosted-review' + +export const hostedReviewApi = { + forBranch: (args: HostedReviewForBranchArgs): Promise => + ipcRenderer.invoke('hostedReview:forBranch', args), + getCreationEligibility: (args: unknown): Promise => + ipcRenderer.invoke('hostedReview:getCreationEligibility', args), + create: (args: unknown): Promise => ipcRenderer.invoke('hostedReview:create', args), + createStacked: (args: unknown): Promise => + ipcRenderer.invoke('hostedReview:createStacked', args) +} diff --git a/src/preload/api/jira-bridge.ts b/src/preload/api/jira-bridge.ts new file mode 100644 index 00000000000..47a27b77f68 --- /dev/null +++ b/src/preload/api/jira-bridge.ts @@ -0,0 +1,110 @@ +import { ipcRenderer } from 'electron' +import type { JiraProjectStatusOrder } from '../../shared/jira-types' + +export const jiraApi = { + connect: (args: { + siteUrl: string + email: string + apiToken: string + authType?: 'cloud' | 'server' + }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => + ipcRenderer.invoke('jira:connect', args), + + disconnect: (args?: { siteId?: string }): Promise => + ipcRenderer.invoke('jira:disconnect', args), + + selectSite: (args: { siteId: string }): Promise => + ipcRenderer.invoke('jira:selectSite', args), + + status: (): Promise => ipcRenderer.invoke('jira:status'), + + readStatus: (): Promise => ipcRenderer.invoke('jira:readStatus'), + + testConnection: (args?: { + siteId?: string + }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => + ipcRenderer.invoke('jira:testConnection', args), + + searchIssues: (args: { + jql: string + limit?: number + siteId?: string + requestId?: string + }): Promise => ipcRenderer.invoke('jira:searchIssues', args), + cancelSearchIssues: (args: { requestId: string }): Promise => + ipcRenderer.invoke('jira:cancelSearchIssues', args), + + listIssues: (args?: { + filter?: 'assigned' | 'reported' | 'all' | 'done' + limit?: number + siteId?: string + }): Promise => ipcRenderer.invoke('jira:listIssues', args), + + getIssue: (args: { key: string; siteId?: string }): Promise => + ipcRenderer.invoke('jira:getIssue', args), + + lookupIssueSummary: (args: { + key: string + siteId: string + requestId?: string + }): Promise => ipcRenderer.invoke('jira:lookupIssueSummary', args), + cancelIssueSummary: (args: { requestId: string }): Promise => + ipcRenderer.invoke('jira:cancelIssueSummary', args), + + createIssue: (args: { + siteId?: string + projectId: string + issueTypeId: string + title: string + description?: string + customFields?: Record + }): Promise<{ ok: true; id: string; key: string; url: string } | { ok: false; error: string }> => + ipcRenderer.invoke('jira:createIssue', args), + + updateIssue: (args: { + key: string + updates: unknown + siteId?: string + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('jira:updateIssue', args), + + addIssueComment: (args: { + key: string + body: string + siteId?: string + }): Promise<{ ok: true; id: string } | { ok: false; error: string }> => + ipcRenderer.invoke('jira:addIssueComment', args), + + issueComments: (args: { key: string; siteId?: string }): Promise => + ipcRenderer.invoke('jira:issueComments', args), + + listProjects: (args?: { siteId?: string }): Promise => + ipcRenderer.invoke('jira:listProjects', args), + + listIssueTypes: (args: { projectIdOrKey: string; siteId?: string }): Promise => + ipcRenderer.invoke('jira:listIssueTypes', args), + + listCreateFields: (args: { + projectIdOrKey: string + issueTypeId: string + siteId?: string + }): Promise => ipcRenderer.invoke('jira:listCreateFields', args), + + listPriorities: (args?: { siteId?: string }): Promise => + ipcRenderer.invoke('jira:listPriorities', args), + + listAssignableUsers: (args: { + key: string + query?: string + siteId?: string + }): Promise => ipcRenderer.invoke('jira:listAssignableUsers', args), + searchUsers: (args?: { query?: string; siteId?: string }): Promise => + ipcRenderer.invoke('jira:searchUsers', args), + + listTransitions: (args: { key: string; siteId?: string }): Promise => + ipcRenderer.invoke('jira:listTransitions', args), + getProjectStatusOrder: (args: { + projectKey: string + siteId?: string + }): Promise => ipcRenderer.invoke('jira:getProjectStatusOrder', args) +} diff --git a/src/preload/api/keybindings-bridge.ts b/src/preload/api/keybindings-bridge.ts new file mode 100644 index 00000000000..3111ddd6676 --- /dev/null +++ b/src/preload/api/keybindings-bridge.ts @@ -0,0 +1,20 @@ +import { ipcRenderer } from 'electron' +import type { KeybindingActionId, KeybindingFileSnapshot } from '../../shared/keybindings' + +export const keybindingsApi = { + get: (): Promise => ipcRenderer.invoke('keybindings:get'), + ensureFile: (): Promise => ipcRenderer.invoke('keybindings:ensureFile'), + setAction: (args: { + actionId: KeybindingActionId + bindings: string[] | null + }): Promise => ipcRenderer.invoke('keybindings:setAction', args), + reload: (): Promise => ipcRenderer.invoke('keybindings:reload'), + openFile: (): Promise => ipcRenderer.invoke('keybindings:openFile'), + revealFile: (): Promise => ipcRenderer.invoke('keybindings:revealFile'), + onChanged: (callback: (snapshot: KeybindingFileSnapshot) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, snapshot: KeybindingFileSnapshot): void => + callback(snapshot) + ipcRenderer.on('keybindings:changed', listener) + return () => ipcRenderer.removeListener('keybindings:changed', listener) + } +} diff --git a/src/preload/api/linear-bridge.ts b/src/preload/api/linear-bridge.ts new file mode 100644 index 00000000000..cd6ec0e9c15 --- /dev/null +++ b/src/preload/api/linear-bridge.ts @@ -0,0 +1,143 @@ +import { ipcRenderer } from 'electron' +import type { LinearProjectDetail } from '../../shared/linear/project-types' + +export const linearApi = { + connect: (args: { + apiKey: string + }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => + ipcRenderer.invoke('linear:connect', args), + + disconnect: (args?: { workspaceId?: string }): Promise => + ipcRenderer.invoke('linear:disconnect', args), + + selectWorkspace: (args: { workspaceId: string }): Promise => + ipcRenderer.invoke('linear:selectWorkspace', args), + + status: (): Promise => ipcRenderer.invoke('linear:status'), + + testConnection: (args?: { + workspaceId?: string + }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => + ipcRenderer.invoke('linear:testConnection', args), + + searchIssues: (args: { + query: string + limit?: number + workspaceId?: string + }): Promise => ipcRenderer.invoke('linear:searchIssues', args), + + listIssues: (args?: { + filter?: 'assigned' | 'created' | 'all' | 'completed' + limit?: number + workspaceId?: string + attributeFilter?: unknown + }): Promise => ipcRenderer.invoke('linear:listIssues', args), + + createIssue: (args: { + teamId: string + title: string + description?: string + workspaceId?: string + parentIssueId?: string + projectId?: string | null + stateId?: string + priority?: number + assigneeId?: string | null + labelIds?: string[] + }): Promise< + | { ok: true; id: string; identifier: string; title: string; url: string } + | { ok: false; error: string } + > => ipcRenderer.invoke('linear:createIssue', args), + + getIssue: (args: { id: string; workspaceId?: string }): Promise => + ipcRenderer.invoke('linear:getIssue', args), + + updateIssue: (args: { + id: string + updates: unknown + workspaceId?: string + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('linear:updateIssue', args), + + addIssueComment: (args: { + issueId: string + body: string + workspaceId?: string + }): Promise<{ ok: true; id: string } | { ok: false; error: string }> => + ipcRenderer.invoke('linear:addIssueComment', args), + + issueComments: (args: { issueId: string; workspaceId?: string }): Promise => + ipcRenderer.invoke('linear:issueComments', args), + + listTeams: (args?: { workspaceId?: string }): Promise => + ipcRenderer.invoke('linear:listTeams', args), + + listProjects: (args?: { + query?: string + limit?: number + workspaceId?: string + force?: boolean + }): Promise => ipcRenderer.invoke('linear:listProjects', args), + + createProject: (args: { + name: string + description?: string + content?: string + teamIds: string[] + workspaceId?: string + leadId?: string | null + memberIds?: string[] + labelIds?: string[] + priority?: number + startDate?: string + targetDate?: string + }): Promise<{ ok: true; project: LinearProjectDetail } | { ok: false; error: string }> => + ipcRenderer.invoke('linear:createProject', args), + + getProject: (args: { id: string; workspaceId: string; force?: boolean }): Promise => + ipcRenderer.invoke('linear:getProject', args), + + listProjectIssues: (args: { + projectId: string + limit?: number + workspaceId: string + force?: boolean + }): Promise => ipcRenderer.invoke('linear:listProjectIssues', args), + + listCustomViews: (args: { + model: string + limit?: number + workspaceId?: string + force?: boolean + }): Promise => ipcRenderer.invoke('linear:listCustomViews', args), + + getCustomView: (args: { + viewId: string + model: string + workspaceId: string + force?: boolean + }): Promise => ipcRenderer.invoke('linear:getCustomView', args), + + listCustomViewIssues: (args: { + viewId: string + limit?: number + workspaceId: string + force?: boolean + }): Promise => ipcRenderer.invoke('linear:listCustomViewIssues', args), + + listCustomViewProjects: (args: { + viewId: string + limit?: number + workspaceId: string + force?: boolean + }): Promise => ipcRenderer.invoke('linear:listCustomViewProjects', args), + + teamStates: (args: { teamId: string; workspaceId?: string }): Promise => + ipcRenderer.invoke('linear:teamStates', args), + + teamLabels: (args: { teamId: string; workspaceId?: string }): Promise => + ipcRenderer.invoke('linear:teamLabels', args), + + teamMembers: (args: { teamId: string; workspaceId?: string }): Promise => + ipcRenderer.invoke('linear:teamMembers', args) +} diff --git a/src/preload/api/localhost-worktree-labels-bridge.ts b/src/preload/api/localhost-worktree-labels-bridge.ts new file mode 100644 index 00000000000..a44dcffff69 --- /dev/null +++ b/src/preload/api/localhost-worktree-labels-bridge.ts @@ -0,0 +1,11 @@ +import { ipcRenderer } from 'electron' +import type { + LocalhostWorktreeLabelResult, + LocalhostWorktreeLabelRoute +} from '../../shared/localhost-worktree-labels' +import type { PreloadApi } from '../api-types' + +export const localhostWorktreeLabelsApi = { + register: (args: LocalhostWorktreeLabelRoute): Promise => + ipcRenderer.invoke('localhostWorktreeLabels:register', args) +} satisfies PreloadApi['localhostWorktreeLabels'] diff --git a/src/preload/api/macos-tcc-prompts-bridge.ts b/src/preload/api/macos-tcc-prompts-bridge.ts new file mode 100644 index 00000000000..0c6c6b184fc --- /dev/null +++ b/src/preload/api/macos-tcc-prompts-bridge.ts @@ -0,0 +1,17 @@ +import { ipcRenderer } from 'electron' + +export const macosTccPromptsApi = { + onThreshold: (callback: (payload: unknown) => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: unknown): void => + callback(payload) + ipcRenderer.on('macosTccPrompts:threshold', listener) + return () => ipcRenderer.removeListener('macosTccPrompts:threshold', listener) + }, + consumePending: (): Promise<{ claimId: number; promptCount: number } | null> => + ipcRenderer.invoke('macosTccPrompts:consumePending'), + acknowledgePending: (claimId: number): Promise => + ipcRenderer.invoke('macosTccPrompts:acknowledgePending', claimId), + releasePending: (claimId: number): Promise => + ipcRenderer.invoke('macosTccPrompts:releasePending', claimId), + dismiss: (): Promise => ipcRenderer.invoke('macosTccPrompts:dismiss') +} diff --git a/src/preload/api/memory-bridge.ts b/src/preload/api/memory-bridge.ts new file mode 100644 index 00000000000..15af55cb09c --- /dev/null +++ b/src/preload/api/memory-bridge.ts @@ -0,0 +1,6 @@ +import { ipcRenderer } from 'electron' +import type { MemorySnapshot } from '../../shared/process-stats-types' + +export const memoryApi = { + getSnapshot: (): Promise => ipcRenderer.invoke('memory:getSnapshot') +} diff --git a/src/preload/api/minimax-credentials-bridge.ts b/src/preload/api/minimax-credentials-bridge.ts new file mode 100644 index 00000000000..a758d9e2e5b --- /dev/null +++ b/src/preload/api/minimax-credentials-bridge.ts @@ -0,0 +1,10 @@ +import { ipcRenderer } from 'electron' + +export const minimaxCredentialsApi = { + getStatus: (): Promise<{ configured: boolean }> => + ipcRenderer.invoke('minimaxCredentials:getStatus'), + saveCookie: (cookie: string): Promise<{ configured: boolean }> => + ipcRenderer.invoke('minimaxCredentials:saveCookie', cookie), + clearCookie: (): Promise<{ configured: boolean }> => + ipcRenderer.invoke('minimaxCredentials:clearCookie') +} diff --git a/src/preload/api/mobile-bridge.ts b/src/preload/api/mobile-bridge.ts new file mode 100644 index 00000000000..836f27f6f37 --- /dev/null +++ b/src/preload/api/mobile-bridge.ts @@ -0,0 +1,95 @@ +import { ipcRenderer } from 'electron' +import type { MobileRelayStatus } from '../../shared/mobile-relay-status' +import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' +import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach' +import type { MobileRelayMintFailure } from '../../shared/mobile-relay-mint-failure' + +export const mobileApi = { + listNetworkInterfaces: (): Promise<{ + interfaces: { name: string; address: string; hasDefaultRoute?: boolean }[] + }> => ipcRenderer.invoke('mobile:listNetworkInterfaces'), + + getPairingQR: (args?: { + address?: string + connectionMode?: MobilePairingConnectionMode + rotate?: boolean + }): Promise< + | { + available: false + reason?: string + guidance?: string + relayFailure?: MobileRelayMintFailure + } + | { + available: true + qrDataUrl: string | null + /** Natural bitmap width and height in pixels. */ + qrSize: number | null + qrError?: 'encoding_failed' + pairingUrl: string + /** Null when no direct address was advertised — the QR pairs over Relay alone. */ + endpoint: string | null + deviceId: string + connectionMode: MobilePairingConnectionMode + } + > => ipcRenderer.invoke('mobile:getPairingQR', args), + + getWindowsFirewallStatus: (args?: { address?: string }) => + ipcRenderer.invoke('mobile:getWindowsFirewallStatus', args), + + repairWindowsFirewall: () => ipcRenderer.invoke('mobile:repairWindowsFirewall'), + + openWindowsNetworkSettings: () => ipcRenderer.invoke('mobile:openWindowsNetworkSettings'), + + getRuntimePairingUrl: (args?: { + address?: string + rotate?: boolean + // Why: the widen is one-way and host-wide, so main must gate it on the reach the user picked, not + // on how the typed address happens to look (a Custom loopback may front an SSH tunnel). + reach?: RuntimePairingReach + }): Promise< + | { available: false; reason?: 'network_exposure_failed'; guidance?: string } + | { + available: true + pairingUrl: string + webClientUrl: string | null + endpoint: string + deviceId: string + } + > => ipcRenderer.invoke('mobile:getRuntimePairingUrl', args), + + listDevices: (): Promise<{ + devices: { deviceId: string; name: string; pairedAt: number; lastSeenAt: number }[] + }> => ipcRenderer.invoke('mobile:listDevices'), + + revokeDevice: (args: { deviceId: string }): Promise<{ revoked: boolean }> => + ipcRenderer.invoke('mobile:revokeDevice', args), + + listRuntimeAccessGrants: () => ipcRenderer.invoke('mobile:listRuntimeAccessGrants'), + + revokeRuntimeAccess: (args: { deviceId: string }): Promise<{ revoked: boolean }> => + ipcRenderer.invoke('mobile:revokeRuntimeAccess', args), + + isWebSocketReady: (): Promise<{ ready: boolean; endpoint: string | null }> => + ipcRenderer.invoke('mobile:isWebSocketReady'), + + getRelayStatus: (): Promise<{ status: MobileRelayStatus }> => + ipcRenderer.invoke('mobile:getRelayStatus'), + + onRelayStatusChanged: (callback: (status: MobileRelayStatus) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, status: MobileRelayStatus) => + callback(status) + ipcRenderer.on('mobile:relayStatusChanged', listener) + return () => ipcRenderer.removeListener('mobile:relayStatusChanged', listener) + }, + + consumePendingUnpairedDeviceAuthFailure: (): Promise => + ipcRenderer.invoke('mobile:consumePendingUnpairedDeviceAuthFailure'), + + /** Fires (throttled, once per session) when an unpaired phone repeatedly fails direct-transport auth. */ + onUnpairedDeviceAuthFailure: (callback: () => void): (() => void) => { + const listener = () => callback() + ipcRenderer.on('mobile:unpairedDeviceAuthFailure', listener) + return () => ipcRenderer.removeListener('mobile:unpairedDeviceAuthFailure', listener) + } +} diff --git a/src/preload/api/native-chat-bridge.ts b/src/preload/api/native-chat-bridge.ts new file mode 100644 index 00000000000..16c2906349e --- /dev/null +++ b/src/preload/api/native-chat-bridge.ts @@ -0,0 +1,40 @@ +import { ipcRenderer } from 'electron' +import type { + NativeChatAppendedPayload, + NativeChatReadSessionResult, + NativeChatSubscriptionFrame +} from '../api-types' +import type { AgentType } from '../../shared/native-chat-types' + +export const nativeChatApi = { + readSession: ( + agent: AgentType, + sessionId: string, + limit?: number, + transcriptPath?: string + ): Promise => + ipcRenderer.invoke('nativeChat:readSession', { agent, sessionId, limit, transcriptPath }), + /** Start live tailing; onAppended fires with only newly-appended messages. Returns an unsubscribe fn that closes the watcher. */ + subscribe: ( + args: { + subscriptionId: string + agent: AgentType + sessionId: string + transcriptPath?: string + limit?: number + }, + onFrame: (frame: NativeChatSubscriptionFrame) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: NativeChatAppendedPayload) => { + if (payload.subscriptionId === args.subscriptionId) { + onFrame(payload.frame) + } + } + ipcRenderer.on('nativeChat:appended', listener) + ipcRenderer.send('nativeChat:subscribe', args) + return () => { + ipcRenderer.removeListener('nativeChat:appended', listener) + ipcRenderer.send('nativeChat:unsubscribe', { subscriptionId: args.subscriptionId }) + } + } +} diff --git a/src/preload/api/notebook-bridge.ts b/src/preload/api/notebook-bridge.ts new file mode 100644 index 00000000000..ee307b9f0e2 --- /dev/null +++ b/src/preload/api/notebook-bridge.ts @@ -0,0 +1,11 @@ +import { ipcRenderer } from 'electron' + +export const notebookApi = { + runPythonCell: (args: { + filePath: string + code: string + preamble?: string + connectionId?: string | null + }): Promise<{ stdout: string; stderr: string; exitCode: number | null; error?: string }> => + ipcRenderer.invoke('notebook:runPythonCell', args) +} diff --git a/src/preload/api/notifications-bridge.ts b/src/preload/api/notifications-bridge.ts new file mode 100644 index 00000000000..aa84fb1a8a6 --- /dev/null +++ b/src/preload/api/notifications-bridge.ts @@ -0,0 +1,120 @@ +import { ipcRenderer } from 'electron' +import type { + NotificationDeliveryProbeResult, + NotificationDismissResult, + NotificationDispatchResult, + NotificationPermissionStatusResult, + NotificationSoundDataResult, + NotificationSoundPathResult, + NotificationSoundResult +} from '../../shared/notification-settings-types' + +// Why: cache one shared Audio + blob URL per sound path so notifications do not re-read large files. +let cachedNotificationSound: { + path: string + blobUrl: string + audio: HTMLAudioElement +} | null = null +let isNotificationSoundPlaying = false +// Why: audio.play() can reject before ended/error fires; cleanup prevents leaked listeners. +let cleanupNotificationSoundPlayback: (() => void) | null = null + +function clearNotificationSoundPlaybackState(): void { + cleanupNotificationSoundPlayback?.() + cleanupNotificationSoundPlayback = null + isNotificationSoundPlaying = false +} + +function disposeCachedNotificationSound(): void { + if (cachedNotificationSound) { + clearNotificationSoundPlaybackState() + cachedNotificationSound.audio.pause() + cachedNotificationSound.audio.src = '' + URL.revokeObjectURL(cachedNotificationSound.blobUrl) + cachedNotificationSound = null + } +} + +export const notificationsApi = { + dispatch: (args: Record): Promise => + ipcRenderer.invoke('notifications:dispatch', args), + dismiss: (ids: string[]): Promise => + ipcRenderer.invoke('notifications:dismiss', ids), + openSystemSettings: (): Promise => ipcRenderer.invoke('notifications:openSystemSettings'), + getPermissionStatus: (): Promise => + ipcRenderer.invoke('notifications:getPermissionStatus'), + probeDelivery: (args?: { force?: boolean }): Promise => + ipcRenderer.invoke('notifications:probeDelivery', args), + playSound: async (options?: { + force?: boolean + volume?: number + }): Promise => { + try { + // Why: drop replays while still ringing; the test button passes force to always confirm. + if (!options?.force && isNotificationSoundPlaying) { + return { played: false, reason: 'deduped' } + } + + const resolved = (await ipcRenderer.invoke( + 'notifications:resolveSoundPath' + )) as NotificationSoundPathResult + if (!resolved.ok) { + if (cachedNotificationSound) { + disposeCachedNotificationSound() + } + return { played: false, reason: resolved.reason } + } + + let entry = cachedNotificationSound + if (!entry || entry.path !== resolved.path) { + const sound = (await ipcRenderer.invoke( + 'notifications:loadSound' + )) as NotificationSoundDataResult + if (!sound.ok) { + disposeCachedNotificationSound() + return { played: false, reason: sound.reason } + } + const arrayBuffer = new ArrayBuffer(sound.data.byteLength) + new Uint8Array(arrayBuffer).set(sound.data) + const blob = new Blob([arrayBuffer], { type: sound.mimeType }) + disposeCachedNotificationSound() + const blobUrl = URL.createObjectURL(blob) + entry = { path: sound.path, blobUrl, audio: new Audio(blobUrl) } + cachedNotificationSound = entry + } + + const audio = entry.audio + // Why: restart from zero on each play so bursts replay instead of stacking copies (GNOME canberra / VS Code signal service). + audio.currentTime = 0 + if (typeof options?.volume === 'number' && Number.isFinite(options.volume)) { + audio.volume = Math.min(1, Math.max(0, options.volume / 100)) + } + isNotificationSoundPlaying = true + cleanupNotificationSoundPlayback?.() + const release = (): void => { + cleanup() + if (cleanupNotificationSoundPlayback === cleanup) { + cleanupNotificationSoundPlayback = null + } + isNotificationSoundPlaying = false + } + const cleanup = (): void => { + audio.removeEventListener('ended', release) + audio.removeEventListener('error', release) + } + cleanupNotificationSoundPlayback = cleanup + audio.addEventListener('ended', release) + audio.addEventListener('error', release) + try { + await audio.play() + } catch { + release() + return { played: false, reason: 'playback-failed' } + } + return { played: true } + } catch { + clearNotificationSoundPlaybackState() + return { played: false, reason: 'playback-failed' } + } + } +} diff --git a/src/preload/api/onboarding-bridge.ts b/src/preload/api/onboarding-bridge.ts new file mode 100644 index 00000000000..1b4393268ac --- /dev/null +++ b/src/preload/api/onboarding-bridge.ts @@ -0,0 +1,11 @@ +import { ipcRenderer } from 'electron' +import type { OnboardingState } from '../../shared/onboarding-state-types' + +export const onboardingApi = { + get: (): Promise => ipcRenderer.invoke('onboarding:get'), + update: ( + updates: Partial> & { + checklist?: Partial + } + ): Promise => ipcRenderer.invoke('onboarding:update', updates) +} diff --git a/src/preload/api/open-code-usage-bridge.ts b/src/preload/api/open-code-usage-bridge.ts new file mode 100644 index 00000000000..5cc668e6e00 --- /dev/null +++ b/src/preload/api/open-code-usage-bridge.ts @@ -0,0 +1,4 @@ +import { ipcRenderer } from 'electron' +import { createUsageProviderApi } from '../usage-provider-api' + +export const openCodeUsageApi = createUsageProviderApi(ipcRenderer, 'openCodeUsage') diff --git a/src/preload/api/orca-profiles-bridge.ts b/src/preload/api/orca-profiles-bridge.ts new file mode 100644 index 00000000000..0b2897f8ab1 --- /dev/null +++ b/src/preload/api/orca-profiles-bridge.ts @@ -0,0 +1,21 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const orcaProfilesApi = { + list: () => ipcRenderer.invoke('orcaProfiles:list'), + authStatus: () => ipcRenderer.invoke('orcaProfiles:authStatus'), + createLocal: (args) => ipcRenderer.invoke('orcaProfiles:createLocal', args), + createCloudLinked: (args) => ipcRenderer.invoke('orcaProfiles:createCloudLinked', args), + switchProfile: (args) => ipcRenderer.invoke('orcaProfiles:switch', args), + transferProject: (args) => ipcRenderer.invoke('orcaProfiles:transferProject', args), + findProjectProfiles: (args) => ipcRenderer.invoke('orcaProfiles:findProjectProfiles', args), + connectCurrent: () => ipcRenderer.invoke('orcaProfiles:connectCurrent'), + refreshAuth: () => ipcRenderer.invoke('orcaProfiles:refreshAuth'), + signOutCurrent: () => ipcRenderer.invoke('orcaProfiles:signOutCurrent'), + selectOrg: (args) => ipcRenderer.invoke('orcaProfiles:selectOrg', args), + orgMembersList: (args) => ipcRenderer.invoke('orcaProfiles:orgMembersList', args), + orgMemberInvite: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberInvite', args), + orgInviteRevoke: (args) => ipcRenderer.invoke('orcaProfiles:orgInviteRevoke', args), + orgMemberChangeRole: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberChangeRole', args), + orgMemberRemove: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberRemove', args) +} satisfies PreloadApi['orcaProfiles'] diff --git a/src/preload/api/pet-bridge.ts b/src/preload/api/pet-bridge.ts new file mode 100644 index 00000000000..8a5d3309c5c --- /dev/null +++ b/src/preload/api/pet-bridge.ts @@ -0,0 +1,11 @@ +import { ipcRenderer } from 'electron' +import type { CustomPet } from '../../shared/pet-types' + +export const petApi = { + import: (): Promise => ipcRenderer.invoke('pet:import'), + importPetBundle: (): Promise => ipcRenderer.invoke('pet:importPetBundle'), + read: (id: string, fileName: string, kind?: 'image' | 'bundle'): Promise => + ipcRenderer.invoke('pet:read', id, fileName, kind), + delete: (id: string, fileName: string, kind?: 'image' | 'bundle'): Promise => + ipcRenderer.invoke('pet:delete', id, fileName, kind) +} diff --git a/src/preload/api/platform-bridge.ts b/src/preload/api/platform-bridge.ts new file mode 100644 index 00000000000..8e47a4386cf --- /dev/null +++ b/src/preload/api/platform-bridge.ts @@ -0,0 +1,17 @@ +import { getLinuxDisplayServer } from '../preload-runtime-support' +import type { PreloadApi } from '../api-types' + +export const platformApi = { + get: () => ({ + platform: process.platform, + // Why: sandboxed preload cannot require node:os; Electron exposes the OS + // version on process.getSystemVersion when available. + osRelease: + (process as NodeJS.Process & { getSystemVersion?: () => string }).getSystemVersion?.() ?? '', + arch: process.arch, + // Why: these identify the default shell without probing user config files. + // process.env is available in the sandboxed preload; node:os is not. + shell: process.env.SHELL?.trim() || process.env.ComSpec?.trim() || '', + displayServer: getLinuxDisplayServer() + }) +} satisfies PreloadApi['platform'] diff --git a/src/preload/api/plugins-bridge.ts b/src/preload/api/plugins-bridge.ts new file mode 100644 index 00000000000..5459ca3b696 --- /dev/null +++ b/src/preload/api/plugins-bridge.ts @@ -0,0 +1,63 @@ +import { ipcRenderer } from 'electron' +import type { + PluginPanelActionOutcome, + PluginPanelEntry +} from '../../shared/plugins/plugin-panel-bridge' +import type { PluginConsentRequest } from '../../shared/plugins/plugin-consent-request' +import type { PluginChangeEvent } from '../../shared/plugins/plugin-change-event' +import type { + PluginHostInstallResult, + PluginHostInstallSource, + PluginHostListEntry, + PluginHostLogLine +} from '../api-types' +import type { PreloadApi } from '../api-types' + +export const pluginsApi = { + list: (): Promise => ipcRenderer.invoke('plugins:list'), + listLanguagePacks: () => ipcRenderer.invoke('plugins:listLanguagePacks'), + consent: (args: PluginConsentRequest): Promise => + ipcRenderer.invoke('plugins:consent', args), + setEnabled: (args: { pluginKey: string; enabled: boolean }): Promise => + ipcRenderer.invoke('plugins:setEnabled', args), + readPanelEntry: (args: { + pluginKey: string + panelId: string + }): Promise => ipcRenderer.invoke('plugins:readPanelEntry', args), + invokeCommand: (args: { + pluginKey: string + commandId: string + args?: unknown + }): Promise => ipcRenderer.invoke('plugins:invokeCommand', args), + panelAction: (args: { + sessionToken: string + action: string + params?: unknown + }): Promise => ipcRenderer.invoke('plugins:panelAction', args), + install: (source: PluginHostInstallSource): Promise => + ipcRenderer.invoke('plugins:install', source), + listMarketplaces: () => ipcRenderer.invoke('plugins:listMarketplaces'), + addMarketplace: (source) => ipcRenderer.invoke('plugins:addMarketplace', source), + removeMarketplace: (args) => ipcRenderer.invoke('plugins:removeMarketplace', args), + refreshMarketplaces: (args = {}) => ipcRenderer.invoke('plugins:refreshMarketplaces', args), + listMarketplacePlugins: () => ipcRenderer.invoke('plugins:listMarketplacePlugins'), + previewMarketplacePlugin: (args) => ipcRenderer.invoke('plugins:previewMarketplacePlugin', args), + installMarketplacePlugin: (preview) => + ipcRenderer.invoke('plugins:installMarketplacePlugin', preview), + previewMarketplaceUpdate: (args) => ipcRenderer.invoke('plugins:previewMarketplaceUpdate', args), + rollbackMarketplacePlugin: (args) => + ipcRenderer.invoke('plugins:rollbackMarketplacePlugin', args), + remove: (args: { pluginKey: string }): Promise => + ipcRenderer.invoke('plugins:remove', args), + getLogs: (args: { pluginKey: string }): Promise => + ipcRenderer.invoke('plugins:getLogs', args), + refresh: (): Promise => ipcRenderer.invoke('plugins:refresh'), + onChanged: (callback): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, change: PluginChangeEvent): void => + callback(change) + ipcRenderer.on('plugins:changed', listener) + return () => { + ipcRenderer.removeListener('plugins:changed', listener) + } + } +} satisfies PreloadApi['plugins'] diff --git a/src/preload/api/preflight-bridge.ts b/src/preload/api/preflight-bridge.ts new file mode 100644 index 00000000000..c05721a2d3b --- /dev/null +++ b/src/preload/api/preflight-bridge.ts @@ -0,0 +1,43 @@ +import { ipcRenderer } from 'electron' +import type { PreflightRuntimeContext, RefreshAgentsResult } from '../api-types' + +export const preflightApi = { + check: (args?: { + force?: boolean + }): Promise<{ + git: { installed: boolean } + gh: { installed: boolean; authenticated: boolean } + glab?: { installed: boolean; authenticated: boolean } + bitbucket?: { configured: boolean; authenticated: boolean; account: string | null } + azureDevOps?: { + configured: boolean + authenticated: boolean + account: string | null + baseUrl: string | null + tokenConfigured: boolean + } + gitea?: { + configured: boolean + authenticated: boolean + account: string | null + baseUrl: string | null + tokenConfigured: boolean + } + linear: { connected: boolean } + }> => ipcRenderer.invoke('preflight:check', args), + detectAgents: (args?: PreflightRuntimeContext): Promise => + ipcRenderer.invoke('preflight:detectAgents', args), + refreshAgents: (args?: PreflightRuntimeContext): Promise => + ipcRenderer.invoke('preflight:refreshAgents', args), + detectRemoteAgents: (args: { connectionId: string }): Promise => + ipcRenderer.invoke('preflight:detectRemoteAgents', args), + detectRemoteWindowsTerminalCapabilities: (args: { + connectionId: string + }): Promise<{ + wslAvailable: boolean + wslDistros: string[] + pwshAvailable: boolean + gitBashAvailable: boolean + hostPlatform: NodeJS.Platform | null + }> => ipcRenderer.invoke('preflight:detectRemoteWindowsTerminalCapabilities', args) +} diff --git a/src/preload/api/project-groups-bridge.ts b/src/preload/api/project-groups-bridge.ts new file mode 100644 index 00000000000..c14d58b7d00 --- /dev/null +++ b/src/preload/api/project-groups-bridge.ts @@ -0,0 +1,22 @@ +import { ipcRenderer } from 'electron' +import type { NestedRepoScanResult } from '../../shared/project-group-types' +import type { PreloadApi } from '../api-types' + +export const projectGroupsApi = { + list: () => ipcRenderer.invoke('projectGroups:list'), + create: (args) => ipcRenderer.invoke('projectGroups:create', args), + update: (args) => ipcRenderer.invoke('projectGroups:update', args), + delete: (args) => ipcRenderer.invoke('projectGroups:delete', args), + moveProject: (args) => ipcRenderer.invoke('projectGroups:moveProject', args), + scanNested: (args) => ipcRenderer.invoke('projectGroups:scanNested', args), + cancelNestedScan: (args) => ipcRenderer.invoke('projectGroups:cancelNestedScan', args), + onNestedScanProgress: (callback) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { scanId: string; scan: NestedRepoScanResult } + ) => callback(data) + ipcRenderer.on('projectGroups:scanNestedProgress', listener) + return () => ipcRenderer.removeListener('projectGroups:scanNestedProgress', listener) + }, + importNested: (args) => ipcRenderer.invoke('projectGroups:importNested', args) +} satisfies PreloadApi['projectGroups'] diff --git a/src/preload/api/projects-bridge.ts b/src/preload/api/projects-bridge.ts new file mode 100644 index 00000000000..c8114fbdf4a --- /dev/null +++ b/src/preload/api/projects-bridge.ts @@ -0,0 +1,12 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const projectsApi = { + list: () => ipcRenderer.invoke('projects:list'), + update: (args) => ipcRenderer.invoke('projects:update', args), + listHostSetups: () => ipcRenderer.invoke('projectHostSetups:list'), + createHostSetup: (args) => ipcRenderer.invoke('projectHostSetups:create', args), + setupExistingFolder: (args) => ipcRenderer.invoke('projectHostSetups:setupExistingFolder', args), + updateHostSetup: (args) => ipcRenderer.invoke('projectHostSetups:update', args), + deleteHostSetup: (args) => ipcRenderer.invoke('projectHostSetups:delete', args) +} satisfies PreloadApi['projects'] diff --git a/src/preload/api/pty-bridge-part-1.ts b/src/preload/api/pty-bridge-part-1.ts new file mode 100644 index 00000000000..40da8dbfa52 --- /dev/null +++ b/src/preload/api/pty-bridge-part-1.ts @@ -0,0 +1,207 @@ +import { ipcRenderer } from 'electron' +import type { AgentSessionPtyWriteRefusal } from '../../shared/agent-session-pty-write-admission' +import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' +import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../../shared/agent-session-resume' +import type { TuiAgent } from '../../shared/tui-agent' +import type { PtyListedSession } from '../../shared/pty-listed-session' +import type { + PtyRendererDeliveryHealthReply, + PtyRendererDeliveryStateReport +} from '../../shared/pty-renderer-delivery-health' +import type { TerminalViewAttributes } from '../../shared/terminal-view-attributes' +import type { PtyMainDeliveryDiagnostics } from '../../shared/pty-delivery-diagnostics' +import type { AgentKind, LaunchSource, RequestKind } from '../../shared/telemetry-events' + +export const ptyApiPart1 = { + spawn: (opts: { + cols: number + rows: number + cwd?: string + cwdFallback?: 'worktree' + env?: Record + envToDelete?: string[] + command?: string + commandDelivery?: 'renderer' | 'provider' + launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata + launchToken?: string + launchAgent?: TuiAgent + startupCommandDelivery?: StartupCommandDelivery + connectionId?: string | null + worktreeId?: string + sessionId?: string + shellOverride?: string + projectRuntime?: ProjectExecutionRuntimeResolution + terminalColorQueryReplies?: { foreground?: string; background?: string } + // Why: marks the PTY hidden before its first byte so the delivery gate + model responder own spawn-time queries (terminal-query-authority.md §races). + initiallyHidden?: boolean + // Why: closes the SIGKILL race (INVESTIGATION.md) — main sync-flushes the (worktreeId, tabId, leafId → ptyId) binding before pty:spawn returns. + tabId?: string + leafId?: string + // Why: loose typing on purpose — renderer owns launch metadata, main owns whether the launch happened and validates (telemetry-plan.md §Agent launch semantics). + telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } + }): Promise<{ + id: string + /** Which lifetime of `id` this reply named; absent when the execution host predates the field. */ + incarnationId?: string + launchConfig?: SleepingAgentLaunchConfig + snapshot?: string + snapshotCols?: number + snapshotRows?: number + snapshotPrefixAnsi?: string + snapshotFrameAnsi?: string + snapshotFrameRestoreAnsi?: string + snapshotKittyKeyboardFlags?: number + snapshotTerminalOwner?: 'shell' + snapshotSeq?: number + isReattach?: boolean + isAlternateScreen?: boolean + replay?: string + sessionExpired?: boolean + coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } + startupCwdFallback?: { kind: 'worktree'; cwd: string } + agentResumeUnavailable?: true + }> => ipcRenderer.invoke('pty:spawn', opts), + write: (id: string, data: string): void => { + ipcRenderer.send('pty:write', { id, data }) + }, + writeAccepted: (id: string, data: string): Promise => + ipcRenderer.invoke('pty:writeAccepted', { id, data }), + onWriteUnavailable: ( + callback: (payload: { + id: string + /** Set only when a durable agent-session lease refused the write; absent otherwise. */ + agentSessionRefusal?: AgentSessionPtyWriteRefusal + }) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + payload: { id: string; agentSessionRefusal?: AgentSessionPtyWriteRefusal } + ): void => callback(payload) + ipcRenderer.on('pty:writeUnavailable', handler) + return () => ipcRenderer.removeListener('pty:writeUnavailable', handler) + }, + resize: (id: string, cols: number, rows: number): void => { + ipcRenderer.send('pty:resize', { id, cols, rows }) + }, + claimViewport: (id: string, cols: number, rows: number): void => { + ipcRenderer.send('pty:claimViewport', { id, cols, rows }) + }, + reportGeometry: (id: string, cols: number, rows: number): void => { + ipcRenderer.send('pty:reportGeometry', { id, cols, rows }) + }, + signal: (id: string, signal: string): void => { + ipcRenderer.send('pty:signal', { id, signal }) + }, + clearBuffer: (id: string): void => { + ipcRenderer.send('pty:clearBuffer', { id }) + }, + ackColdRestore: (id: string): void => { + ipcRenderer.send('pty:ackColdRestore', { id }) + }, + ackData: (id: string, charCount: number, processedChars?: number): void => { + ipcRenderer.send('pty:ackData', { + id, + charCount, + ...(typeof processedChars === 'number' ? { processedChars } : {}) + }) + }, + onDeliveryResyncRequest: (callback: (payload: { requestId: number }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: { requestId: number }) => + callback(payload) + ipcRenderer.on('pty:requestDeliveryResync', listener) + return () => ipcRenderer.removeListener('pty:requestDeliveryResync', listener) + }, + respondDeliveryResync: (payload: { + requestId: number + processedCharsByPty: Record + }): void => { + ipcRenderer.send('pty:deliveryResyncResponse', payload) + }, + reportRendererDeliveryState: ( + report: PtyRendererDeliveryStateReport + ): Promise => + ipcRenderer.invoke('pty:reportRendererDeliveryState', report), + getPtyDataListenerCount: (): number => ipcRenderer.listenerCount('pty:data'), + rendererDispatcherReady: (): void => { + ipcRenderer.send('pty:rendererDispatcherReady') + }, + setActiveRendererPty: (id: string, active: boolean): void => { + ipcRenderer.send('pty:setActiveRendererPty', { id, active }) + }, + setRendererPtyVisible: (id: string, visible: boolean): void => { + ipcRenderer.send('pty:setRendererPtyVisible', { id, visible }) + }, + setHiddenRendererPty: (id: string, hidden: boolean): void => { + ipcRenderer.send('pty:setHiddenRendererPty', { id, hidden }) + }, + setPtyDeliveryInterest: (id: string, interested: boolean): void => { + ipcRenderer.send('pty:setPtyDeliveryInterest', { id, interested }) + }, + publishTerminalViewAttributes: (attributes: TerminalViewAttributes): void => { + ipcRenderer.send('pty:terminalViewAttributes', attributes) + }, + kill: (id: string, opts?: { keepHistory?: boolean }): Promise => + ipcRenderer.invoke('pty:kill', { id, keepHistory: opts?.keepHistory ?? false }), + listSessions: (): Promise => ipcRenderer.invoke('pty:listSessions'), + getAuthoritativeBufferSnapshotCapabilities: ( + ids: string[] + ): Promise<{ id: string; authoritative: boolean | null }[]> => + ipcRenderer.invoke('pty:getAuthoritativeBufferSnapshotCapabilities', { ids }), + hasPty: (id: string): Promise => ipcRenderer.invoke('pty:hasPty', { id }), + getMainBufferSnapshot: ( + id: string, + opts?: { scrollbackRows?: number } + ): Promise<{ + data: string + frameRestoreAnsi?: string + cols: number + rows: number + cwd?: string | null + seq?: number + pendingDeliveryStartSeq?: number + source?: 'headless' | 'renderer' + alternateScreen?: boolean + scrollbackAnsi?: string + pendingEscapeTailAnsi?: string + kittyKeyboardFlags?: number + terminalOwner?: 'shell' + } | null> => ipcRenderer.invoke('pty:getMainBufferSnapshot', { id, opts }), + getRendererDeliveryDebugSnapshot: (): Promise<{ + pendingPtyCount: number + pendingChars: number + maxPendingCharsByPty: number + rendererInFlightPtyCount: number + rendererInFlightChars: number + maxRendererInFlightCharsByPty: number + activeRendererPtyCount: number + flushScheduled: boolean + peakPendingChars: number + peakMaxPendingCharsByPty: number + peakRendererInFlightChars: number + peakMaxRendererInFlightCharsByPty: number + ackGatedFlushSkipCount: number + hiddenDeliveryGatedPtyCount: number + hiddenDeliveryGatedVisiblePtyCount: number + hiddenDeliveryGatedActivePtyCount: number + deliveryInterestPtyCount: number + hiddenDeliveryDroppedChars: number + hiddenDeliveryDroppedChunks: number + pendingDroppedChars: number + diagnostics: PtyMainDeliveryDiagnostics + rendererLifecycleResetCount: number + lastLifecycleResetClearedChars: number + rendererPtyDispatcherReady: boolean + rendererDispatcherReadyForcedCount: number + }> => ipcRenderer.invoke('pty:getRendererDeliveryDebugSnapshot'), + resetRendererDeliveryDebug: (): Promise => + ipcRenderer.invoke('pty:resetRendererDeliveryDebug'), + hasChildProcesses: (id: string): Promise => + ipcRenderer.invoke('pty:hasChildProcesses', { id }), + getForegroundProcess: (id: string): Promise => + ipcRenderer.invoke('pty:getForegroundProcess', { id }) +} diff --git a/src/preload/api/pty-bridge-part-2.ts b/src/preload/api/pty-bridge-part-2.ts new file mode 100644 index 00000000000..1686a621f97 --- /dev/null +++ b/src/preload/api/pty-bridge-part-2.ts @@ -0,0 +1,141 @@ +import { ipcRenderer } from 'electron' +import type { PtyModelRestoreNeededEvent } from '../../shared/pty-model-restore-marker' +import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' + +export const ptyApiPart2 = { + inspectProcess: ( + id: string + ): Promise<{ + foregroundProcess: string | null + hasChildProcesses: boolean + unavailable?: true + }> => ipcRenderer.invoke('pty:inspectProcess', { id }), + confirmForegroundProcess: (id: string): Promise => + ipcRenderer.invoke('pty:confirmForegroundProcess', { id }), + getCwd: (id: string): Promise => ipcRenderer.invoke('pty:getCwd', { id }), + getSize: (id: string): Promise<{ cols: number; rows: number } | null> => + ipcRenderer.invoke('pty:getSize', { id }), + onData: ( + callback: (data: { + id: string + data: string + seq?: number + rawLength?: number + transformed?: boolean + background?: boolean + droppedOutput?: boolean + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + id: string + data: string + seq?: number + rawLength?: number + transformed?: boolean + background?: boolean + droppedOutput?: boolean + } + ) => callback(data) + ipcRenderer.on('pty:data', listener) + return () => ipcRenderer.removeListener('pty:data', listener) + }, + onReplay: (callback: (data: { id: string; data: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { id: string; data: string }) => + callback(data) + ipcRenderer.on('pty:replay', listener) + return () => ipcRenderer.removeListener('pty:replay', listener) + }, + onModelRestoreNeeded: (callback: (event: PtyModelRestoreNeededEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, event: PtyModelRestoreNeededEvent) => + callback(event) + ipcRenderer.on('pty:modelRestoreNeeded', listener) + return () => ipcRenderer.removeListener('pty:modelRestoreNeeded', listener) + }, + onSideEffect: (callback: (batch: TerminalSideEffectBatch) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, batch: TerminalSideEffectBatch) => + callback(batch) + ipcRenderer.on('pty:sideEffect', listener) + return () => ipcRenderer.removeListener('pty:sideEffect', listener) + }, + getSideEffectSnapshot: (id: string): Promise => + ipcRenderer.invoke('pty:sideEffectSnapshot', { id }), + onExit: ( + callback: (data: { + id: string + code: number + preserveRendererBinding?: boolean + /** Which lifetime of `id` died; absent when the execution host predates the field. */ + incarnationId?: string + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + id: string + code: number + preserveRendererBinding?: boolean + incarnationId?: string + } + ) => callback(data) + ipcRenderer.on('pty:exit', listener) + return () => ipcRenderer.removeListener('pty:exit', listener) + }, + onSpawned: (callback: (data: { id: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { id: string }) => callback(data) + ipcRenderer.on('pty:spawned', listener) + return () => ipcRenderer.removeListener('pty:spawned', listener) + }, + onSerializeBufferRequest: ( + callback: (data: { + requestId: string + ptyId: string + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + requestId: string + ptyId: string + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + } + ) => callback(data) + ipcRenderer.on('pty:serializeBuffer:request', listener) + return () => ipcRenderer.removeListener('pty:serializeBuffer:request', listener) + }, + onClearBufferRequest: (callback: (data: { ptyId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { ptyId: string }) => callback(data) + ipcRenderer.on('pty:clearBuffer:request', listener) + return () => ipcRenderer.removeListener('pty:clearBuffer:request', listener) + }, + sendSerializedBuffer: ( + requestId: string, + snapshot: { + data: string + cols: number + rows: number + seq?: number + lastTitle?: string + kittyKeyboardFlags?: number + } | null + ): void => { + ipcRenderer.send('pty:serializeBuffer:response', { requestId, snapshot }) + }, + declarePendingPaneSerializer: (paneKey: string): Promise => + ipcRenderer.invoke('pty:declarePendingPaneSerializer', { paneKey }), + settlePaneSerializer: (paneKey: string, gen: number): Promise => + ipcRenderer.invoke('pty:settlePaneSerializer', { paneKey, gen }), + clearPendingPaneSerializer: (paneKey: string, gen: number): Promise => + ipcRenderer.invoke('pty:clearPendingPaneSerializer', { paneKey, gen }), + reportRendererSerializerReady: (ptyId: string): Promise => + ipcRenderer.invoke('pty:reportRendererSerializerReady', { ptyId }), + management: { + listSessions: () => ipcRenderer.invoke('pty:management:listSessions'), + killAll: () => ipcRenderer.invoke('pty:management:killAll'), + killOne: (args: { sessionId: string }) => ipcRenderer.invoke('pty:management:killOne', args), + restart: () => ipcRenderer.invoke('pty:management:restart'), + macTccAttribution: () => ipcRenderer.invoke('pty:management:macTccAttribution') + } +} diff --git a/src/preload/api/pty-bridge.ts b/src/preload/api/pty-bridge.ts new file mode 100644 index 00000000000..314bd6f993f --- /dev/null +++ b/src/preload/api/pty-bridge.ts @@ -0,0 +1,4 @@ +import { ptyApiPart1 } from './pty-bridge-part-1' +import { ptyApiPart2 } from './pty-bridge-part-2' + +export const ptyApi = { ...ptyApiPart1, ...ptyApiPart2 } diff --git a/src/preload/api/pwsh-bridge.ts b/src/preload/api/pwsh-bridge.ts new file mode 100644 index 00000000000..bd202277ad1 --- /dev/null +++ b/src/preload/api/pwsh-bridge.ts @@ -0,0 +1,5 @@ +import { ipcRenderer } from 'electron' + +export const pwshApi = { + isAvailable: (): Promise => ipcRenderer.invoke('pwsh:isAvailable') +} diff --git a/src/preload/api/rate-limits-bridge.ts b/src/preload/api/rate-limits-bridge.ts new file mode 100644 index 00000000000..f403d79cdc6 --- /dev/null +++ b/src/preload/api/rate-limits-bridge.ts @@ -0,0 +1,30 @@ +import { ipcRenderer } from 'electron' +import type { + CodexRateLimitResetResult, + RateLimitRuntimeTarget, + RateLimitState +} from '../../shared/rate-limit-types' + +export const rateLimitsApi = { + get: (): Promise => ipcRenderer.invoke('rateLimits:get'), + refresh: (): Promise => ipcRenderer.invoke('rateLimits:refresh'), + refreshCodexForTarget: (target: RateLimitRuntimeTarget): Promise => + ipcRenderer.invoke('rateLimits:refreshCodexForTarget', target), + consumeCodexResetCredit: (): Promise => + ipcRenderer.invoke('rateLimits:consumeCodexResetCredit'), + refreshClaudeForTarget: (target: RateLimitRuntimeTarget): Promise => + ipcRenderer.invoke('rateLimits:refreshClaudeForTarget', target), + setPollingInterval: (ms: number): Promise => + ipcRenderer.invoke('rateLimits:setPollingInterval', ms), + fetchInactiveClaudeAccounts: (): Promise => + ipcRenderer.invoke('rateLimits:fetchInactiveClaudeAccounts'), + fetchInactiveCodexAccounts: (): Promise => + ipcRenderer.invoke('rateLimits:fetchInactiveCodexAccounts'), + refreshMiniMax: (): Promise => ipcRenderer.invoke('rateLimits:refreshMiniMax'), + refreshGrok: (): Promise => ipcRenderer.invoke('rateLimits:refreshGrok'), + onUpdate: (callback: (state: RateLimitState) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, state: RateLimitState) => callback(state) + ipcRenderer.on('rateLimits:update', listener) + return () => ipcRenderer.removeListener('rateLimits:update', listener) + } +} diff --git a/src/preload/api/remote-workspace-bridge.ts b/src/preload/api/remote-workspace-bridge.ts new file mode 100644 index 00000000000..2a7ca528d21 --- /dev/null +++ b/src/preload/api/remote-workspace-bridge.ts @@ -0,0 +1,19 @@ +import { ipcRenderer } from 'electron' +import type { RemoteWorkspaceChangedEvent } from '../../shared/remote-workspace-types' +import type { PreloadApi } from '../api-types' + +export const remoteWorkspaceApi = { + get: (args) => ipcRenderer.invoke('remoteWorkspace:get', args), + setForConnectedTargets: (args) => + ipcRenderer.invoke('remoteWorkspace:setForConnectedTargets', args), + listEnabledConnectedTargets: () => + ipcRenderer.invoke('remoteWorkspace:listEnabledConnectedTargets'), + listConnectedClients: (args) => ipcRenderer.invoke('remoteWorkspace:listConnectedClients', args), + clientId: () => ipcRenderer.invoke('remoteWorkspace:clientId'), + onChanged: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, data: RemoteWorkspaceChangedEvent) => + callback(data) + ipcRenderer.on('remoteWorkspace:changed', listener) + return () => ipcRenderer.removeListener('remoteWorkspace:changed', listener) + } +} satisfies PreloadApi['remoteWorkspace'] diff --git a/src/preload/api/repos-bridge.ts b/src/preload/api/repos-bridge.ts new file mode 100644 index 00000000000..36859897938 --- /dev/null +++ b/src/preload/api/repos-bridge.ts @@ -0,0 +1,87 @@ +import { ipcRenderer } from 'electron' +import type { + HostRepoCatalogSnapshot, + ListReposForExecutionHostArgs +} from '../../shared/host-repo-catalog-contract' +import type { BaseRefDefaultResult, BaseRefSearchResult } from '../../shared/repo-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { PreloadApi } from '../api-types' + +export const reposApi = { + list: () => ipcRenderer.invoke('repos:list'), + + listForExecutionHost: (args: ListReposForExecutionHostArgs): Promise => + ipcRenderer.invoke('repos:listForExecutionHost', args), + + add: (args) => ipcRenderer.invoke('repos:add', args), + + addRemote: (args) => ipcRenderer.invoke('repos:addRemote', args), + + create: (args) => ipcRenderer.invoke('repos:create', args), + + isGitAvailable: (): Promise => ipcRenderer.invoke('repos:isGitAvailable'), + + getDefaultCreateProjectParent: (): Promise => + ipcRenderer.invoke('repos:getDefaultCreateProjectParent'), + + remove: (args) => ipcRenderer.invoke('repos:remove', args), + + removeForHost: (args) => ipcRenderer.invoke('repos:removeForHost', args), + + reorder: (args) => ipcRenderer.invoke('repos:reorder', args), + + reorderForHost: (args) => ipcRenderer.invoke('repos:reorderForHost', args), + + update: (args) => ipcRenderer.invoke('repos:update', args), + + pickFolder: () => ipcRenderer.invoke('repos:pickFolder'), + + pickFolders: () => ipcRenderer.invoke('repos:pickFolders'), + + pickDirectory: () => ipcRenderer.invoke('repos:pickDirectory'), + + clone: (args) => ipcRenderer.invoke('repos:clone', args), + + cloneRemote: (args) => ipcRenderer.invoke('repos:cloneRemote', args), + + createRemote: (args) => ipcRenderer.invoke('repos:createRemote', args), + + cloneAbort: () => ipcRenderer.invoke('repos:cloneAbort'), + + onCloneProgress: (callback: (data: { phase: string; percent: number }) => void): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { phase: string; percent: number } + ) => callback(data) + ipcRenderer.on('repos:clone-progress', listener) + return () => ipcRenderer.removeListener('repos:clone-progress', listener) + }, + + getGitUsername: (args: { repoId: string }): Promise => + ipcRenderer.invoke('repos:getGitUsername', args), + + getBaseRefDefault: (args: { + repoId: string + hostId?: ExecutionHostId + }): Promise => ipcRenderer.invoke('repos:getBaseRefDefault', args), + + searchBaseRefs: (args: { + repoId: string + query: string + limit?: number + hostId?: ExecutionHostId + }): Promise => ipcRenderer.invoke('repos:searchBaseRefs', args), + + searchBaseRefDetails: (args: { + repoId: string + query: string + limit?: number + hostId?: ExecutionHostId + }): Promise => ipcRenderer.invoke('repos:searchBaseRefDetails', args), + + onChanged: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('repos:changed', listener) + return () => ipcRenderer.removeListener('repos:changed', listener) + } +} satisfies PreloadApi['repos'] diff --git a/src/preload/api/runtime-bridge.ts b/src/preload/api/runtime-bridge.ts new file mode 100644 index 00000000000..c58049bcbe3 --- /dev/null +++ b/src/preload/api/runtime-bridge.ts @@ -0,0 +1,144 @@ +import { ipcRenderer } from 'electron' +import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-browser-rows' +import type { + RuntimeBrowserDriverState, + RuntimeRendererSyncWindowGraph, + RuntimeStatus, + RuntimeSyncWindowGraphResult, + RuntimeTerminalDriverState +} from '../../shared/runtime-types' +import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import type { RuntimeEnvironmentSubscriptionHandle } from '../runtime-environment-subscriptions' + +export const runtimeApi = { + syncWindowGraph: (graph: RuntimeRendererSyncWindowGraph): Promise => + ipcRenderer.invoke('runtime:syncWindowGraph', graph), + getStatus: (): Promise => ipcRenderer.invoke('runtime:getStatus'), + call: (args: { method: string; params?: unknown }): Promise> => + ipcRenderer.invoke('runtime:call', args), + subscribe: async ( + args: { method: string; params?: unknown }, + callback: (response: RuntimeRpcResponse) => void + ): Promise => { + const subscriptionId = `desktop-${crypto.randomUUID()}` + const channel = `runtime:subscription:${subscriptionId}` + const listener = (_event: Electron.IpcRendererEvent, response: RuntimeRpcResponse) => + callback(response) + ipcRenderer.on(channel, listener) + try { + await ipcRenderer.invoke('runtime:subscribe', { subscriptionId, ...args }) + } catch (error) { + ipcRenderer.removeListener(channel, listener) + throw error + } + return { + unsubscribe: () => { + ipcRenderer.removeListener(channel, listener) + ipcRenderer.send('runtime:unsubscribe', { subscriptionId }) + }, + sendBinary: () => { + throw new Error('Local runtime subscriptions do not accept binary input') + } + } + }, + getTerminalFitOverrides: (): Promise< + { ptyId: string; mode: 'mobile-fit' | 'remote-desktop-fit'; cols: number; rows: number }[] + > => ipcRenderer.invoke('runtime:getTerminalFitOverrides'), + getTerminalDrivers: (): Promise< + { + ptyId: string + driver: RuntimeTerminalDriverState + }[] + > => ipcRenderer.invoke('runtime:getTerminalDrivers'), + getBrowserDrivers: (): Promise< + { + browserPageId: string + driver: RuntimeBrowserDriverState + }[] + > => ipcRenderer.invoke('runtime:getBrowserDrivers'), + getBrowserRemoteViewerPages: (): Promise => + ipcRenderer.invoke('runtime:getBrowserRemoteViewerPages'), + getClientHostedBrowserRows: (): Promise => + ipcRenderer.invoke('runtime:getClientHostedBrowserRows'), + restoreTerminalFit: (ptyId: string): Promise<{ restored: boolean }> => + ipcRenderer.invoke('runtime:restoreTerminalFit', { ptyId }), + reclaimBrowserForDesktop: (browserPageId: string): Promise<{ reclaimed: boolean }> => + ipcRenderer.invoke('runtime:reclaimBrowserForDesktop', { browserPageId }), + onTerminalFitOverrideChanged: ( + callback: (event: { + ptyId: string + mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' + cols: number + rows: number + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + ptyId: string + mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' + cols: number + rows: number + } + ) => callback(data) + ipcRenderer.on('runtime:terminalFitOverrideChanged', listener) + return () => ipcRenderer.removeListener('runtime:terminalFitOverrideChanged', listener) + }, + onTerminalDriverChanged: ( + callback: (event: { ptyId: string; driver: RuntimeTerminalDriverState }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + ptyId: string + driver: RuntimeTerminalDriverState + } + ) => callback(data) + ipcRenderer.on('runtime:terminalDriverChanged', listener) + return () => ipcRenderer.removeListener('runtime:terminalDriverChanged', listener) + }, + onNativeChatLaunchDraftResolved: ( + callback: (event: { tabId: string; text: string; createdAt: number }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { tabId: string; text: string; createdAt: number } + ) => callback(data) + ipcRenderer.on('runtime:nativeChatLaunchDraftResolved', listener) + return () => ipcRenderer.removeListener('runtime:nativeChatLaunchDraftResolved', listener) + }, + onBrowserDriverChanged: ( + callback: (event: { browserPageId: string; driver: RuntimeBrowserDriverState }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + browserPageId: string + driver: RuntimeBrowserDriverState + } + ) => callback(data) + ipcRenderer.on('runtime:browserDriverChanged', listener) + return () => ipcRenderer.removeListener('runtime:browserDriverChanged', listener) + }, + onBrowserRemoteViewersChanged: ( + callback: (event: { browserPageId: string; hasRemoteViewers: boolean }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + browserPageId: string + hasRemoteViewers: boolean + } + ) => callback(data) + ipcRenderer.on('runtime:browserRemoteViewersChanged', listener) + return () => ipcRenderer.removeListener('runtime:browserRemoteViewersChanged', listener) + }, + onClientHostedBrowserRowsChanged: ( + callback: (event: ClientHostedBrowserRowsEvent) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: ClientHostedBrowserRowsEvent) => + callback(data) + ipcRenderer.on('runtime:clientHostedBrowserRowsChanged', listener) + return () => ipcRenderer.removeListener('runtime:clientHostedBrowserRowsChanged', listener) + } +} diff --git a/src/preload/api/runtime-environments-bridge.ts b/src/preload/api/runtime-environments-bridge.ts new file mode 100644 index 00000000000..d018c0574c8 --- /dev/null +++ b/src/preload/api/runtime-environments-bridge.ts @@ -0,0 +1,93 @@ +import { ipcRenderer } from 'electron' +import type { VerifyAndAddRuntimeEnvironmentResult } from '../../shared/remote-pairing-verification' +import type { RuntimeStatus } from '../../shared/runtime-types' +import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import type { PublicKnownRuntimeEnvironment } from '../../shared/runtime-environments' +import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../shared/remote-runtime-shared-control-types' +import { RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL } from '../../shared/runtime-environment-diagnostics' +import { + subscribeRuntimeEnvironmentFromPreload, + type RuntimeEnvironmentSubscriptionHandle +} from '../runtime-environment-subscriptions' + +export const runtimeEnvironmentsApi = { + list: (): Promise => + ipcRenderer.invoke('runtimeEnvironments:list'), + addFromPairingCode: (args: { + name: string + pairingCode: string + }): Promise<{ environment: PublicKnownRuntimeEnvironment }> => + ipcRenderer.invoke('runtimeEnvironments:addFromPairingCode', args), + verifyAndAddFromPairingCode: (args: { + name: string + pairingCode: string + allowLoopback?: boolean + }): Promise => + ipcRenderer.invoke('runtimeEnvironments:verifyAndAddFromPairingCode', args), + resolve: (args: { selector: string }): Promise => + ipcRenderer.invoke('runtimeEnvironments:resolve', args), + remove: (args: { selector: string }): Promise<{ removed: PublicKnownRuntimeEnvironment }> => + ipcRenderer.invoke('runtimeEnvironments:remove', args), + disconnect: (args: { + selector: string + }): Promise<{ disconnected: PublicKnownRuntimeEnvironment }> => + ipcRenderer.invoke('runtimeEnvironments:disconnect', args), + connect: (args: { + selector: string + timeoutMs?: number + }): Promise> => + ipcRenderer.invoke('runtimeEnvironments:connect', args), + getStatus: (args: { + selector: string + timeoutMs?: number + observeOnly?: true + }): Promise> => + ipcRenderer.invoke('runtimeEnvironments:getStatus', args), + retryControlConnection: (args: { selector: string }): Promise => + ipcRenderer.invoke('runtimeEnvironments:retryControlConnection', args), + onSharedControlDiagnostics: ( + callback: (event: { + environmentId: string + transportGeneration: number + diagnostics: RemoteRuntimeSharedConnectionDiagnostics + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + environmentId: string + transportGeneration: number + diagnostics: RemoteRuntimeSharedConnectionDiagnostics + } + ): void => callback(data) + ipcRenderer.on(RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL, listener) + return () => ipcRenderer.removeListener(RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL, listener) + }, + prepareBrowserClientHostPlacement: (args) => + ipcRenderer.invoke('runtimeEnvironments:prepareBrowserClientHostPlacement', args), + retryConnectionsNow: (): Promise => + ipcRenderer.invoke('runtimeEnvironments:retryConnectionsNow'), + call: (args: { + selector: string + method: string + params?: unknown + timeoutMs?: number + expectedEnvironmentPairingRevision?: number + }): Promise> => ipcRenderer.invoke('runtimeEnvironments:call', args), + subscribe: async ( + args: { + selector: string + method: string + params?: unknown + timeoutMs?: number + expectedEnvironmentPairingRevision?: number + }, + callbacks: { + onResponse: (response: RuntimeRpcResponse) => void + onBinary?: (bytes: Uint8Array) => void + onError?: (error: { code: string; message: string }) => void + onClose?: () => void + } + ): Promise => + subscribeRuntimeEnvironmentFromPreload(ipcRenderer, args, callbacks) +} diff --git a/src/preload/api/session-bridge.ts b/src/preload/api/session-bridge.ts new file mode 100644 index 00000000000..189dd96c1d1 --- /dev/null +++ b/src/preload/api/session-bridge.ts @@ -0,0 +1,16 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const sessionApi = { + // hostId is optional; main defaults it to 'local' so existing omitting call sites keep the local session partition. + get: (hostId) => ipcRenderer.invoke('session:get', hostId), + set: (args, hostId) => ipcRenderer.invoke('session:set', args, hostId), + patch: (args, hostId) => ipcRenderer.invoke('session:patch', args, hostId), + flush: () => ipcRenderer.invoke('session:flush'), + readTerminalScrollback: (args) => + ipcRenderer.sendSync('session:read-terminal-scrollback-sync', args), + /** Synchronous session save for beforeunload — blocks until flushed to disk. */ + setSync: (args, hostId) => { + ipcRenderer.sendSync('session:set-sync', args, hostId) + } +} satisfies PreloadApi['session'] diff --git a/src/preload/api/settings-bridge.ts b/src/preload/api/settings-bridge.ts new file mode 100644 index 00000000000..d8aaa23b0b7 --- /dev/null +++ b/src/preload/api/settings-bridge.ts @@ -0,0 +1,39 @@ +import { ipcRenderer } from 'electron' +import type { GhosttyImportPreview } from '../../shared/global-settings-types' +import type { + WarpThemeImportPreview, + WarpThemeImportSource +} from '../../shared/terminal-custom-themes' + +export const settingsApi = { + get: (): Promise => ipcRenderer.invoke('settings:get'), + + // Why: blocking read for the few startup decisions (terminal side-effect authority) that can't wait for async hydration. Call sparingly. + getSync: (): unknown => ipcRenderer.sendSync('settings:get-sync'), + + set: (args: Record): Promise => + ipcRenderer.invoke('settings:set', args), + + setActiveRuntimeEnvironmentPreference: (args: { + environmentId: string | null + }): Promise => + ipcRenderer.invoke('settings:set-active-runtime-environment-preference', args), + + updatePRBotAuthorOverride: (args: { author: string; isBot: boolean }): Promise => + ipcRenderer.invoke('settings:update-pr-bot-author-override', args), + + listFonts: (): Promise => ipcRenderer.invoke('settings:listFonts'), + + previewGhosttyImport: (): Promise => + ipcRenderer.invoke('settings:previewGhosttyImport'), + + previewWarpThemeImport: (source: WarpThemeImportSource): Promise => + ipcRenderer.invoke('settings:previewWarpThemeImport', source), + + onChanged: (callback: (updates: Record) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, updates: Record): void => + callback(updates) + ipcRenderer.on('settings:changed', listener) + return () => ipcRenderer.removeListener('settings:changed', listener) + } +} diff --git a/src/preload/api/shell-bridge.ts b/src/preload/api/shell-bridge.ts new file mode 100644 index 00000000000..be34c7ff70e --- /dev/null +++ b/src/preload/api/shell-bridge.ts @@ -0,0 +1,41 @@ +import { ipcRenderer } from 'electron' +import type { + ShellOpenExternalEditorRequest, + ShellOpenExternalEditorResult, + ShellOpenLocalPathResult +} from '../../shared/shell-open-types' + +export const shellApi = { + openPath: (path: string): Promise => ipcRenderer.invoke('shell:openPath', path), + + openInFileManager: (path: string): Promise => + ipcRenderer.invoke('shell:openInFileManager', path), + + openInExternalEditor: ( + request: ShellOpenExternalEditorRequest + ): Promise => + ipcRenderer.invoke('shell:openInExternalEditor', request), + + openUrl: (url: string): Promise => ipcRenderer.invoke('shell:openUrl', url), + + openFilePath: (path: string): Promise => ipcRenderer.invoke('shell:openFilePath', path), + + openFileUri: (uri: string): Promise => ipcRenderer.invoke('shell:openFileUri', uri), + + pathExists: (path: string): Promise => ipcRenderer.invoke('shell:pathExists', path), + + pickAttachment: (): Promise => ipcRenderer.invoke('shell:pickAttachment'), + + pickImage: (): Promise => ipcRenderer.invoke('shell:pickImage'), + + pickRepoIconImage: (): Promise<{ dataUrl: string; fileName: string } | null> => + ipcRenderer.invoke('shell:pickRepoIconImage'), + + pickAudio: (): Promise => ipcRenderer.invoke('shell:pickAudio'), + + pickDirectory: (args: { defaultPath?: string }): Promise => + ipcRenderer.invoke('shell:pickDirectory', args), + + copyFile: (args: { srcPath: string; destPath: string }): Promise => + ipcRenderer.invoke('shell:copyFile', args) +} diff --git a/src/preload/api/skills-bridge.ts b/src/preload/api/skills-bridge.ts new file mode 100644 index 00000000000..eafaf2ce543 --- /dev/null +++ b/src/preload/api/skills-bridge.ts @@ -0,0 +1,129 @@ +import { ipcRenderer } from 'electron' +import type { + SkillDeletePlan, + SkillDeleteRequest, + SkillDeleteResult +} from '../../shared/skill-delete-contract' +import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../shared/skills' +import type { + SkillCloudOwnedShare, + SkillCloudOperation, + SkillCloudPackageDetails +} from '../../shared/skill-cloud-contract' +import type { + SkillBundleInstallPreviewInput, + SkillBundleInstallPreviewOperation, + SkillBundlePackageVersionInstallInput, + SkillBundleShareInstallInput, + SkillBundleShareInstallOperation, + SkillInstallPreviewInput, + SkillInstallPreviewOperation, + ManagedSkillInstallListOperation, + SkillPackageVersionInstallInput, + SkillRemoveInput, + SkillRemoveOperation, + SkillShareInstallInput, + SkillShareInstallOperation, + SkillInstallCancelInput, + SkillInstallProgress, + SkillSharePreview, + SkillShareProgress, + SkillSharePublishInput, + SkillSharePublishOperation, + SkillShareResolvedOperation +} from '../../shared/skill-sharing-contract' +import type { + SkillFreshnessInventory, + SkillUpdateRun, + SkillUpdateStartResult +} from '../../shared/skill-freshness' + +export const skillsApi = { + discover: (target?: SkillDiscoveryTarget): Promise => + ipcRenderer.invoke('skills:discover', target), + freshnessInventory: (): Promise => + ipcRenderer.invoke('skills:freshnessInventory'), + startUpdateRun: (names: string[]): Promise => + ipcRenderer.invoke('skills:startUpdateRun', names), + cancelUpdateRun: (): Promise => ipcRenderer.invoke('skills:cancelUpdateRun'), + acknowledgeUpdateRun: (): Promise => ipcRenderer.invoke('skills:acknowledgeUpdateRun'), + getUpdateRun: (): Promise => ipcRenderer.invoke('skills:getUpdateRun'), + prepareShare: (input: { + skillIds: string[] + bundleName: string + target?: SkillDiscoveryTarget + packageId?: string + }): Promise => ipcRenderer.invoke('skills:prepareShare', input), + publishShare: (input: SkillSharePublishInput): Promise => + ipcRenderer.invoke('skills:publishShare', input), + cancelShare: (preparationId: string): Promise => + ipcRenderer.invoke('skills:cancelShare', preparationId), + releaseShare: (preparationId: string): Promise => + ipcRenderer.invoke('skills:releaseShare', preparationId), + resolveShare: (shareId: string): Promise => + ipcRenderer.invoke('skills:resolveShare', shareId), + installShare: (input: SkillShareInstallInput): Promise => + ipcRenderer.invoke('skills:installShare', input), + installBundleShare: ( + input: SkillBundleShareInstallInput + ): Promise => + ipcRenderer.invoke('skills:installBundleShare', input), + installBundlePackageVersion: ( + input: SkillBundlePackageVersionInstallInput + ): Promise => + ipcRenderer.invoke('skills:installBundlePackageVersion', input), + installPackageVersion: ( + input: SkillPackageVersionInstallInput + ): Promise => + ipcRenderer.invoke('skills:installPackageVersion', input), + cancelInstall: (input: SkillInstallCancelInput): Promise<{ cancelled: boolean }> => + ipcRenderer.invoke('skills:cancelInstall', input), + previewInstall: (input: SkillInstallPreviewInput): Promise => + ipcRenderer.invoke('skills:previewInstall', input), + previewBundleInstall: ( + input: SkillBundleInstallPreviewInput + ): Promise => + ipcRenderer.invoke('skills:previewBundleInstall', input), + removeInstall: (input: SkillRemoveInput): Promise => + ipcRenderer.invoke('skills:removeInstall', input), + // Desktop always registers the delete IPC handlers in its own main process. + deleteSupported: (): Promise => Promise.resolve(true), + previewDelete: (request: SkillDeleteRequest): Promise => + ipcRenderer.invoke('skills:previewDelete', request), + delete: (request: SkillDeleteRequest): Promise => + ipcRenderer.invoke('skills:delete', request), + listManagedInstalls: (environmentId?: string): Promise => + ipcRenderer.invoke('skills:listManagedInstalls', environmentId), + getPackage: (packageId: string): Promise> => + ipcRenderer.invoke('skills:getPackage', packageId), + listOwnedShares: (): Promise> => + ipcRenderer.invoke('skills:listOwnedShares'), + revokeShare: (shareId: string): Promise> => + ipcRenderer.invoke('skills:revokeShare', shareId), + deletePackageVersion: (input: { + packageId: string + versionId: string + }): Promise> => + ipcRenderer.invoke('skills:deletePackageVersion', input), + deletePackage: (packageId: string): Promise> => + ipcRenderer.invoke('skills:deletePackage', packageId), + listWslDistros: (environmentId?: string): Promise => + ipcRenderer.invoke('skills:listWslDistros', environmentId), + onInstallProgress: (callback: (progress: SkillInstallProgress) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, progress: SkillInstallProgress): void => + callback(progress) + ipcRenderer.on('skills:installProgress', listener) + return () => ipcRenderer.removeListener('skills:installProgress', listener) + }, + onShareProgress: (callback: (progress: SkillShareProgress) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, progress: SkillShareProgress): void => + callback(progress) + ipcRenderer.on('skills:shareProgress', listener) + return () => ipcRenderer.removeListener('skills:shareProgress', listener) + }, + onUpdateRun: (callback: (run: SkillUpdateRun) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, run: SkillUpdateRun): void => callback(run) + ipcRenderer.on('skills:updateRun', listener) + return () => ipcRenderer.removeListener('skills:updateRun', listener) + } +} diff --git a/src/preload/api/sparse-presets-bridge.ts b/src/preload/api/sparse-presets-bridge.ts new file mode 100644 index 00000000000..91676740e6e --- /dev/null +++ b/src/preload/api/sparse-presets-bridge.ts @@ -0,0 +1,16 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const sparsePresetsApi = { + list: (args) => ipcRenderer.invoke('sparsePresets:list', args), + + save: (args) => ipcRenderer.invoke('sparsePresets:save', args), + + remove: (args) => ipcRenderer.invoke('sparsePresets:remove', args), + + onChanged: (callback: (data: { repoId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { repoId: string }) => callback(data) + ipcRenderer.on('sparsePresets:changed', listener) + return () => ipcRenderer.removeListener('sparsePresets:changed', listener) + } +} satisfies PreloadApi['sparsePresets'] diff --git a/src/preload/api/speech-bridge.ts b/src/preload/api/speech-bridge.ts new file mode 100644 index 00000000000..513b219f498 --- /dev/null +++ b/src/preload/api/speech-bridge.ts @@ -0,0 +1,81 @@ +import { ipcRenderer } from 'electron' +import type { + SpeechErrorEvent, + SpeechLifecycleEvent, + SpeechModelManifest, + SpeechModelState, + SpeechTranscriptEvent +} from '../../shared/speech-types' + +export const speechApi = { + getCatalog: (): Promise => ipcRenderer.invoke('speech:getCatalog'), + getModelStates: (): Promise => ipcRenderer.invoke('speech:getModelStates'), + getOpenAiApiKeyStatus: (): Promise<{ configured: boolean }> => + ipcRenderer.invoke('speech:getOpenAiApiKeyStatus'), + saveOpenAiApiKey: (apiKey: string): Promise<{ configured: boolean }> => + ipcRenderer.invoke('speech:saveOpenAiApiKey', apiKey), + clearOpenAiApiKey: (): Promise<{ configured: boolean }> => + ipcRenderer.invoke('speech:clearOpenAiApiKey'), + downloadModel: (modelId: string): Promise => + ipcRenderer.invoke('speech:downloadModel', modelId), + cancelDownload: (modelId: string): Promise => + ipcRenderer.invoke('speech:cancelDownload', modelId), + deleteModel: (modelId: string): Promise => + ipcRenderer.invoke('speech:deleteModel', modelId), + startDictation: ( + modelId: string, + hotwords: string[] | undefined, + sessionId: string + ): Promise => ipcRenderer.invoke('speech:startDictation', modelId, hotwords, sessionId), + feedAudio: (samples: Float32Array, sampleRate: number, sessionId = 'desktop'): Promise => + // Why: Float32Array is zeroed crossing the contextBridge/IPC boundary; wrap in a Buffer to preserve bytes. + ipcRenderer.invoke( + 'speech:feedAudio', + Buffer.from(samples.buffer, samples.byteOffset, samples.byteLength), + sampleRate, + sessionId + ), + stopDictation: (sessionId = 'desktop'): Promise => + ipcRenderer.invoke('speech:stopDictation', sessionId), + + onPartialTranscript: (callback: (data: SpeechTranscriptEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: SpeechTranscriptEvent): void => + callback(data) + ipcRenderer.on('speech:partial', listener) + return () => ipcRenderer.removeListener('speech:partial', listener) + }, + onFinalTranscript: (callback: (data: SpeechTranscriptEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: SpeechTranscriptEvent): void => + callback(data) + ipcRenderer.on('speech:final', listener) + return () => ipcRenderer.removeListener('speech:final', listener) + }, + onDownloadProgress: ( + callback: (data: { modelId: string; progress: number }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { modelId: string; progress: number } + ): void => callback(data) + ipcRenderer.on('speech:downloadProgress', listener) + return () => ipcRenderer.removeListener('speech:downloadProgress', listener) + }, + onReady: (callback: (data: SpeechLifecycleEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: SpeechLifecycleEvent): void => + callback(data) + ipcRenderer.on('speech:ready', listener) + return () => ipcRenderer.removeListener('speech:ready', listener) + }, + onStopped: (callback: (data: SpeechLifecycleEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: SpeechLifecycleEvent): void => + callback(data) + ipcRenderer.on('speech:stopped', listener) + return () => ipcRenderer.removeListener('speech:stopped', listener) + }, + onError: (callback: (data: SpeechErrorEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: SpeechErrorEvent): void => + callback(data) + ipcRenderer.on('speech:error', listener) + return () => ipcRenderer.removeListener('speech:error', listener) + } +} diff --git a/src/preload/api/ssh-bridge.ts b/src/preload/api/ssh-bridge.ts new file mode 100644 index 00000000000..2884e4ec8c1 --- /dev/null +++ b/src/preload/api/ssh-bridge.ts @@ -0,0 +1,183 @@ +import { ipcRenderer } from 'electron' +import type { + SshConnectionState, + SshConfigHostListArgs, + SshConfigHostListResult, + SshConfigHostResolution, + SshConfigImportResult, + SshTargetAddResult, + SshTargetCreateInput, + SshTarget, + SshTargetUpdateInput, + PortForwardEntry, + EnrichedDetectedPort +} from '../../shared/ssh-types' +import { + admitSshConnectionStateForAuthorityReconciliation, + admitSshDetectedPorts +} from '../../shared/ssh-retained-payload-admission' +import type { FilesystemPathFlavor } from '../../shared/filesystem-entry-types' + +export const sshApi = { + listTargets: (): Promise => ipcRenderer.invoke('ssh:listTargets'), + + listRemovedTargetLabels: (): Promise> => + ipcRenderer.invoke('ssh:listRemovedTargetLabels'), + + addTarget: (args: { target: SshTargetCreateInput }): Promise => + ipcRenderer.invoke('ssh:addTarget', args), + + updateTarget: (args: { id: string; updates: SshTargetUpdateInput }): Promise => + ipcRenderer.invoke('ssh:updateTarget', args), + + removeTarget: (args: { id: string }): Promise => + ipcRenderer.invoke('ssh:removeTarget', args), + + importConfig: (args?: { reAdopt?: boolean }): Promise => + ipcRenderer.invoke('ssh:importConfig', args), + + listConfigHosts: (args?: SshConfigHostListArgs): Promise => + ipcRenderer.invoke('ssh:listConfigHosts', args), + + resolveConfigHost: (args: { alias: string }): Promise => + ipcRenderer.invoke('ssh:resolveConfigHost', args), + + connect: async (args: { targetId: string }): Promise => { + const state: unknown = await ipcRenderer.invoke('ssh:connect', args) + return state ? admitSshConnectionStateForAuthorityReconciliation(state, args.targetId) : null + }, + + disconnect: (args: { targetId: string }): Promise => + ipcRenderer.invoke('ssh:disconnect', args), + + terminateSessions: (args: { targetId: string }): Promise => + ipcRenderer.invoke('ssh:terminateSessions', args), + + resetRelay: (args: { targetId: string }): Promise => + ipcRenderer.invoke('ssh:resetRelay', args), + + getState: async (args: { targetId: string }): Promise => { + const state: unknown = await ipcRenderer.invoke('ssh:getState', args) + return state ? admitSshConnectionStateForAuthorityReconciliation(state, args.targetId) : null + }, + + needsPassphrasePrompt: (args: { targetId: string }): Promise => + ipcRenderer.invoke('ssh:needsPassphrasePrompt', args), + + testConnection: async (args: { + targetId: string + }): Promise<{ success: boolean; error?: string; state?: SshConnectionState }> => { + const result: { success: boolean; error?: string; state?: unknown } = await ipcRenderer.invoke( + 'ssh:testConnection', + args + ) + const state = result.state + ? admitSshConnectionStateForAuthorityReconciliation(result.state, args.targetId) + : null + return { ...result, ...(state ? { state } : { state: undefined }) } + }, + + onStateChanged: ( + callback: (data: { targetId: string; state: SshConnectionState }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { targetId: string; state: unknown } + ): void => { + const state = admitSshConnectionStateForAuthorityReconciliation(data.state, data.targetId) + if (state) { + callback({ targetId: data.targetId, state }) + } + } + ipcRenderer.on('ssh:state-changed', listener) + return () => ipcRenderer.removeListener('ssh:state-changed', listener) + }, + + addPortForward: (args: { + targetId: string + localPort: number + remoteHost: string + remotePort: number + label?: string + }): Promise => ipcRenderer.invoke('ssh:addPortForward', args), + + updatePortForward: (args: { + id: string + targetId: string + localPort: number + remoteHost: string + remotePort: number + label?: string + }): Promise => ipcRenderer.invoke('ssh:updatePortForward', args), + + removePortForward: (args: { id: string }): Promise => + ipcRenderer.invoke('ssh:removePortForward', args), + + listPortForwards: (args?: { targetId?: string }): Promise => + ipcRenderer.invoke('ssh:listPortForwards', args), + + listDetectedPorts: async (args: { targetId: string }): Promise => + admitSshDetectedPorts(await ipcRenderer.invoke('ssh:listDetectedPorts', args)), + + onPortForwardsChanged: ( + callback: (data: { targetId: string; forwards: PortForwardEntry[] }) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + data: { targetId: string; forwards: PortForwardEntry[] } + ) => callback(data) + ipcRenderer.on('ssh:port-forwards-changed', handler) + return () => ipcRenderer.removeListener('ssh:port-forwards-changed', handler) + }, + + onDetectedPortsChanged: ( + callback: (data: { targetId: string; ports: EnrichedDetectedPort[] }) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + data: { targetId: string; ports: unknown } + ) => callback({ targetId: data.targetId, ports: admitSshDetectedPorts(data.ports) }) + ipcRenderer.on('ssh:detected-ports-changed', handler) + return () => ipcRenderer.removeListener('ssh:detected-ports-changed', handler) + }, + + browseDir: (args: { + targetId: string + dirPath: string + }): Promise<{ + entries: { name: string; isDirectory: boolean }[] + resolvedPath: string + pathFlavor: FilesystemPathFlavor + }> => ipcRenderer.invoke('ssh:browseDir', args), + + onCredentialRequest: ( + callback: (data: { + requestId: string + targetId: string + kind: 'passphrase' | 'password' | 'keyboard-interactive' + detail: string + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + requestId: string + targetId: string + kind: 'passphrase' | 'password' | 'keyboard-interactive' + detail: string + } + ) => callback(data) + ipcRenderer.on('ssh:credential-request', listener) + return () => ipcRenderer.removeListener('ssh:credential-request', listener) + }, + + onCredentialResolved: (callback: (data: { requestId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { requestId: string }) => + callback(data) + ipcRenderer.on('ssh:credential-resolved', listener) + return () => ipcRenderer.removeListener('ssh:credential-resolved', listener) + }, + + submitCredential: (args: { requestId: string; value: string | null }): Promise => + ipcRenderer.invoke('ssh:submitCredential', args) +} diff --git a/src/preload/api/star-nag-bridge.ts b/src/preload/api/star-nag-bridge.ts new file mode 100644 index 00000000000..b697739a52f --- /dev/null +++ b/src/preload/api/star-nag-bridge.ts @@ -0,0 +1,30 @@ +import { ipcRenderer } from 'electron' + +export const starNagApi = { + onShow: ( + callback: (payload?: { mode?: 'gh' | 'web'; surface?: 'card' | 'toast' }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload?: { mode?: 'gh' | 'web'; surface?: 'card' | 'toast' } + ): void => callback(payload) + ipcRenderer.on('star-nag:show', listener) + return () => ipcRenderer.removeListener('star-nag:show', listener) + }, + onHide: (callback: () => void): (() => void) => { + const listener = (): void => callback() + ipcRenderer.on('star-nag:hide', listener) + return () => ipcRenderer.removeListener('star-nag:hide', listener) + }, + dismiss: (): Promise => ipcRenderer.invoke('star-nag:dismiss'), + later: (): Promise => ipcRenderer.invoke('star-nag:later'), + complete: (): Promise => ipcRenderer.invoke('star-nag:complete'), + disable: (): Promise => ipcRenderer.invoke('star-nag:disable'), + openWeb: (): Promise => ipcRenderer.invoke('star-nag:openWeb'), + starOrca: (): Promise => ipcRenderer.invoke('star-nag:starOrca'), + forceShow: (): Promise => ipcRenderer.invoke('star-nag:forceShow'), + agentValueMoment: (): Promise<{ status: 'ready'; mode: 'gh' | 'web' } | { status: 'skipped' }> => + ipcRenderer.invoke('star-nag:agentValueMoment'), + showAgentValueMoment: (): Promise => ipcRenderer.invoke('star-nag:showAgentValueMoment'), + onboardingCompleted: (): Promise => ipcRenderer.invoke('star-nag:onboardingCompleted') +} diff --git a/src/preload/api/stats-bridge.ts b/src/preload/api/stats-bridge.ts new file mode 100644 index 00000000000..20bc823b86a --- /dev/null +++ b/src/preload/api/stats-bridge.ts @@ -0,0 +1,10 @@ +import { ipcRenderer } from 'electron' + +export const statsApi = { + getSummary: (): Promise<{ + totalAgentsSpawned: number + totalPRsCreated: number + totalAgentTimeMs: number + firstEventAt: number | null + }> => ipcRenderer.invoke('stats:summary') +} diff --git a/src/preload/api/terminal-preview-bridge.ts b/src/preload/api/terminal-preview-bridge.ts new file mode 100644 index 00000000000..c8bf5b38623 --- /dev/null +++ b/src/preload/api/terminal-preview-bridge.ts @@ -0,0 +1,33 @@ +import { ipcRenderer } from 'electron' +import type { + TerminalPreviewConnectResult, + TerminalPreviewDataPayload +} from '../../shared/terminal-preview' + +export const terminalPreviewApi = { + connect: ( + ptyId: string, + opts?: { scrollbackRows?: number } + ): Promise => + ipcRenderer.invoke('terminalPreview:connect', { ptyId, opts }), + input: (ptyId: string, data: string): Promise => + ipcRenderer.invoke('terminalPreview:input', { ptyId, data }), + fit: ( + ptyId: string, + cols: number, + rows: number + ): Promise<{ cols: number; rows: number } | null> => + ipcRenderer.invoke('terminalPreview:fit', { ptyId, cols, rows }), + ack: (ptyId: string, bytes: number): Promise => + ipcRenderer.invoke('terminalPreview:ack', { ptyId, bytes }), + unsubscribe: (ptyId: string): Promise => + ipcRenderer.invoke('terminalPreview:unsubscribe', { ptyId }), + onData: (callback: (payload: TerminalPreviewDataPayload) => void): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: TerminalPreviewDataPayload + ): void => callback(payload) + ipcRenderer.on('terminalPreview:data', listener) + return () => ipcRenderer.removeListener('terminalPreview:data', listener) + } +} diff --git a/src/preload/api/ui-bridge-part-1.ts b/src/preload/api/ui-bridge-part-1.ts new file mode 100644 index 00000000000..b402b44b248 --- /dev/null +++ b/src/preload/api/ui-bridge-part-1.ts @@ -0,0 +1,167 @@ +import { ipcRenderer } from 'electron' +import type { PersistedUIState } from '../../shared/persisted-ui-state-types' +import type { KeybindingActionId } from '../../shared/keybindings' + +export const uiApiPart1 = { + get: () => ipcRenderer.invoke('ui:get'), + set: (args) => ipcRenderer.invoke('ui:set', args), + setWithAck: (args) => ipcRenderer.invoke('ui:set', args), + recordFeatureInteraction: (id) => ipcRenderer.invoke('ui:recordFeatureInteraction', id), + onStateChanged: (callback: (ui: PersistedUIState) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, ui: PersistedUIState): void => callback(ui) + ipcRenderer.on('ui:stateChanged', listener) + return () => ipcRenderer.removeListener('ui:stateChanged', listener) + }, + onOpenSettings: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:openSettings', listener) + return () => ipcRenderer.removeListener('ui:openSettings', listener) + }, + consumePendingOpenSettings: (): Promise => + ipcRenderer.invoke('ui:consumePendingOpenSettings'), + onOpenSkillShare: (callback: (shareId: string) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, shareId: string): void => callback(shareId) + ipcRenderer.on('ui:openSkillShare', listener) + return () => ipcRenderer.removeListener('ui:openSkillShare', listener) + }, + consumePendingSkillShare: (): Promise => + ipcRenderer.invoke('ui:consumePendingSkillShare'), + onOpenSetupGuide: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:openSetupGuide', listener) + return () => ipcRenderer.removeListener('ui:openSetupGuide', listener) + }, + onOpenFeatureTour: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:openFeatureTour', listener) + return () => ipcRenderer.removeListener('ui:openFeatureTour', listener) + }, + onOpenCrashReport: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:openCrashReport', listener) + return () => ipcRenderer.removeListener('ui:openCrashReport', listener) + }, + onToggleLeftSidebar: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:toggleLeftSidebar', listener) + return () => ipcRenderer.removeListener('ui:toggleLeftSidebar', listener) + }, + onToggleRightSidebar: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:toggleRightSidebar', listener) + return () => ipcRenderer.removeListener('ui:toggleRightSidebar', listener) + }, + onToggleWorktreePalette: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:toggleWorktreePalette', listener) + return () => ipcRenderer.removeListener('ui:toggleWorktreePalette', listener) + }, + onToggleFloatingTerminal: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:toggleFloatingTerminal', listener) + return () => ipcRenderer.removeListener('ui:toggleFloatingTerminal', listener) + }, + onTerminalShortcutCaptured: ( + callback: (data: { actionId: KeybindingActionId }) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { actionId: KeybindingActionId }) => + callback(data) + ipcRenderer.on('ui:terminalShortcutCaptured', listener) + return () => ipcRenderer.removeListener('ui:terminalShortcutCaptured', listener) + }, + onOpenQuickOpen: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:openQuickOpen', listener) + return () => ipcRenderer.removeListener('ui:openQuickOpen', listener) + }, + onToggleQuickCommandsMenu: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:toggleQuickCommandsMenu', listener) + return () => ipcRenderer.removeListener('ui:toggleQuickCommandsMenu', listener) + }, + onOpenNewWorkspace: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:openNewWorkspace', listener) + return () => ipcRenderer.removeListener('ui:openNewWorkspace', listener) + }, + onDeleteCurrentWorkspace: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:deleteCurrentWorkspace', listener) + return () => ipcRenderer.removeListener('ui:deleteCurrentWorkspace', listener) + }, + onOpenWorkspaceBoard: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:openWorkspaceBoard', listener) + return () => ipcRenderer.removeListener('ui:openWorkspaceBoard', listener) + }, + onOpenTasks: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:openTasks', listener) + return () => ipcRenderer.removeListener('ui:openTasks', listener) + }, + onToggleAgentDashboard: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:toggleAgentDashboard', listener) + return () => ipcRenderer.removeListener('ui:toggleAgentDashboard', listener) + }, + onJumpToWorktreeIndex: (callback: (index: number) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, index: number) => callback(index) + ipcRenderer.on('ui:jumpToWorktreeIndex', listener) + return () => ipcRenderer.removeListener('ui:jumpToWorktreeIndex', listener) + }, + onJumpToTabIndex: (callback: (index: number) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, index: number) => callback(index) + ipcRenderer.on('ui:jumpToTabIndex', listener) + return () => ipcRenderer.removeListener('ui:jumpToTabIndex', listener) + }, + onWorktreeHistoryNavigate: (callback: (direction: 'back' | 'forward') => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, direction: 'back' | 'forward') => + callback(direction) + ipcRenderer.on('ui:worktreeHistoryNavigate', listener) + return () => ipcRenderer.removeListener('ui:worktreeHistoryNavigate', listener) + }, + onNewBrowserTab: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:newBrowserTab', listener) + return () => ipcRenderer.removeListener('ui:newBrowserTab', listener) + }, + onNewMarkdownTab: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:newMarkdownTab', listener) + return () => ipcRenderer.removeListener('ui:newMarkdownTab', listener) + }, + onNewSimulatorTab: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:newSimulatorTab', listener) + return () => ipcRenderer.removeListener('ui:newSimulatorTab', listener) + }, + onRequestTabCreate: ( + callback: (data: { + requestId: string + url: string + worktreeId?: string + browserPageId?: string + sessionProfileId?: string | null + sessionPartition?: string + activate?: boolean + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + requestId: string + url: string + worktreeId?: string + browserPageId?: string + sessionProfileId?: string | null + sessionPartition?: string + activate?: boolean + } + ) => callback(data) + ipcRenderer.on('browser:requestTabCreate', listener) + return () => ipcRenderer.removeListener('browser:requestTabCreate', listener) + }, + replyTabCreate: (reply: { requestId: string; browserPageId?: string; error?: string }): void => { + ipcRenderer.send('browser:tabCreateReply', reply) + } +} diff --git a/src/preload/api/ui-bridge-part-2.ts b/src/preload/api/ui-bridge-part-2.ts new file mode 100644 index 00000000000..fdf8956622e --- /dev/null +++ b/src/preload/api/ui-bridge-part-2.ts @@ -0,0 +1,203 @@ +import { ipcRenderer } from 'electron' +import { admitCloseActiveTabPayload } from '../close-active-tab-payload-admission' +import type { CloseActiveTabPayload } from '../api/ui-command-event-api' +import type { + WorktreeDefaultTabsLaunch, + WorktreeSetupLaunch +} from '../../shared/worktree/launch-types' +import { browserFindSubscriptions } from '../preload-runtime-support' + +export const uiApiPart2 = { + onRequestTabSetProfile: ( + callback: (data: { + requestId: string + browserPageId: string + profileId: string + sessionPartition?: string + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + requestId: string + browserPageId: string + profileId: string + sessionPartition?: string + } + ) => callback(data) + ipcRenderer.on('browser:requestTabSetProfile', listener) + return () => ipcRenderer.removeListener('browser:requestTabSetProfile', listener) + }, + replyTabSetProfile: (reply: { requestId: string; error?: string }): void => { + ipcRenderer.send('browser:tabSetProfileReply', reply) + }, + onRequestTabClose: ( + callback: (data: { requestId: string; tabId: string | null; worktreeId?: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { requestId: string; tabId: string | null; worktreeId?: string } + ) => callback(data) + ipcRenderer.on('browser:requestTabClose', listener) + return () => ipcRenderer.removeListener('browser:requestTabClose', listener) + }, + replyTabClose: (reply: { + requestId: string + error?: string + code?: 'browser_tab_not_found' + }): void => { + ipcRenderer.send('browser:tabCloseReply', reply) + }, + onNewTerminalTab: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:newTerminalTab', listener) + return () => ipcRenderer.removeListener('ui:newTerminalTab', listener) + }, + onFocusBrowserAddressBar: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:focusBrowserAddressBar', listener) + return () => ipcRenderer.removeListener('ui:focusBrowserAddressBar', listener) + }, + onFindInBrowserPage: browserFindSubscriptions.subscribe, + onReloadBrowserPage: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:reloadBrowserPage', listener) + return () => ipcRenderer.removeListener('ui:reloadBrowserPage', listener) + }, + onBrowserHistoryNavigate: (callback: (direction: 'back' | 'forward') => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, direction: 'back' | 'forward'): void => + callback(direction) + ipcRenderer.on('ui:browserHistoryNavigate', listener) + return () => ipcRenderer.removeListener('ui:browserHistoryNavigate', listener) + }, + onZoomBrowserPage: (callback: (direction: 'in' | 'out' | 'reset') => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, direction: 'in' | 'out' | 'reset') => + callback(direction) + ipcRenderer.on('ui:zoomBrowserPage', listener) + return () => ipcRenderer.removeListener('ui:zoomBrowserPage', listener) + }, + onScrollBrowserPage: ( + callback: (event: { browserPageId: string; deltaX: number; deltaY: number }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: { browserPageId: string; deltaX: number; deltaY: number } + ) => callback(payload) + ipcRenderer.on('ui:scrollBrowserPage', listener) + return () => ipcRenderer.removeListener('ui:scrollBrowserPage', listener) + }, + onHardReloadBrowserPage: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:hardReloadBrowserPage', listener) + return () => ipcRenderer.removeListener('ui:hardReloadBrowserPage', listener) + }, + onCloseActiveTab: (callback: (payload?: CloseActiveTabPayload) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload?: unknown): void => { + const admitted = admitCloseActiveTabPayload(payload) + if (admitted.kind === 'legacy') { + callback() + } else if (admitted.kind === 'source') { + callback(admitted.payload) + } + } + ipcRenderer.on('ui:closeActiveTab', listener) + return () => ipcRenderer.removeListener('ui:closeActiveTab', listener) + }, + onCloseFloatingItem: (callback: (payload: { sourceId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: { sourceId: string }) => + callback(payload) + ipcRenderer.on('ui:closeFloatingItem', listener) + return () => ipcRenderer.removeListener('ui:closeFloatingItem', listener) + }, + onSelectFloatingIndex: (callback: (payload: { index: number }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: { index: number }) => + callback(payload) + ipcRenderer.on('ui:selectFloatingIndex', listener) + return () => ipcRenderer.removeListener('ui:selectFloatingIndex', listener) + }, + onSwitchTab: (callback: (direction: 1 | -1) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, direction: 1 | -1) => callback(direction) + ipcRenderer.on('ui:switchTab', listener) + return () => ipcRenderer.removeListener('ui:switchTab', listener) + }, + onSwitchTabAcrossAllTypes: (callback: (direction: 1 | -1) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, direction: 1 | -1) => callback(direction) + ipcRenderer.on('ui:switchTabAcrossAllTypes', listener) + return () => ipcRenderer.removeListener('ui:switchTabAcrossAllTypes', listener) + }, + onSwitchRecentTab: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:switchRecentTab', listener) + return () => ipcRenderer.removeListener('ui:switchRecentTab', listener) + }, + onSwitchTerminalTab: (callback: (direction: 1 | -1) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, direction: 1 | -1) => callback(direction) + ipcRenderer.on('ui:switchTerminalTab', listener) + return () => ipcRenderer.removeListener('ui:switchTerminalTab', listener) + }, + onCtrlTabKeyDown: (callback: (data: { shiftKey: boolean }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { shiftKey: boolean }) => + callback(data) + ipcRenderer.on('ui:ctrlTabKeyDown', listener) + return () => ipcRenderer.removeListener('ui:ctrlTabKeyDown', listener) + }, + onCtrlTabKeyUp: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:ctrlTabKeyUp', listener) + return () => ipcRenderer.removeListener('ui:ctrlTabKeyUp', listener) + }, + onToggleStatusBar: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:toggleStatusBar', listener) + return () => ipcRenderer.removeListener('ui:toggleStatusBar', listener) + }, + onExportPdfRequested: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('export:requestPdf', listener) + return () => ipcRenderer.removeListener('export:requestPdf', listener) + }, + onAppMenuPaste: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:appMenuPaste', listener) + return () => ipcRenderer.removeListener('ui:appMenuPaste', listener) + }, + onAppMenuSelectionAction: (callback: (action: 'copy' | 'select-all') => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, action: 'copy' | 'select-all'): void => + callback(action) + ipcRenderer.on('ui:appMenuSelectionAction', listener) + return () => ipcRenderer.removeListener('ui:appMenuSelectionAction', listener) + }, + onEditableContextPaste: (callback: (data: { plainTextOnly: boolean }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { plainTextOnly: boolean }): void => + callback({ plainTextOnly: data?.plainTextOnly === true }) + ipcRenderer.on('ui:editableContextPaste', listener) + return () => ipcRenderer.removeListener('ui:editableContextPaste', listener) + }, + onDictationKeyDown: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:dictationKeyDown', listener) + return () => ipcRenderer.removeListener('ui:dictationKeyDown', listener) + }, + onActivateWorktree: ( + callback: (data: { + repoId: string + worktreeId: string + setup?: WorktreeSetupLaunch + startup?: { command: string; env?: Record } + defaultTabs?: WorktreeDefaultTabsLaunch + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + repoId: string + worktreeId: string + setup?: WorktreeSetupLaunch + startup?: { command: string; env?: Record } + defaultTabs?: WorktreeDefaultTabsLaunch + } + ) => callback(data) + ipcRenderer.on('ui:activateWorktree', listener) + return () => ipcRenderer.removeListener('ui:activateWorktree', listener) + } +} diff --git a/src/preload/api/ui-bridge-part-3.ts b/src/preload/api/ui-bridge-part-3.ts new file mode 100644 index 00000000000..44669b67838 --- /dev/null +++ b/src/preload/api/ui-bridge-part-3.ts @@ -0,0 +1,214 @@ +import { ipcRenderer } from 'electron' +import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry' +import type { TerminalTabCreateReply } from '../../shared/terminal-reveal-identity' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../../shared/agent-session-resume' +import type { TuiAgent } from '../../shared/tui-agent' +import type { + RuntimeMobileSessionTabMove, + RuntimeTerminalCreateRequestPayload, + RuntimeTerminalPresentation +} from '../../shared/runtime-types' + +export const uiApiPart3 = { + onCreateTerminal: ( + callback: (data: { + requestId?: string + worktreeId: string + command?: string + cwd?: string + env?: Record + launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata + launchToken?: string + launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' + title?: string + ptyId?: string + activate?: boolean + focus?: boolean + presentation?: RuntimeTerminalPresentation + surfaceOwner?: false + tabId?: string + leafId?: string + splitFromLeafId?: string + splitDirection?: 'horizontal' | 'vertical' + splitTelemetrySource?: TerminalPaneSplitSource + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + requestId?: string + worktreeId: string + command?: string + cwd?: string + env?: Record + launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata + launchToken?: string + launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' + title?: string + ptyId?: string + activate?: boolean + focus?: boolean + presentation?: RuntimeTerminalPresentation + surfaceOwner?: false + tabId?: string + leafId?: string + splitFromLeafId?: string + splitDirection?: 'horizontal' | 'vertical' + splitTelemetrySource?: TerminalPaneSplitSource + } + ) => callback(data) + ipcRenderer.on('ui:createTerminal', listener) + return () => ipcRenderer.removeListener('ui:createTerminal', listener) + }, + onRequestTerminalCreate: ( + callback: (data: RuntimeTerminalCreateRequestPayload) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: RuntimeTerminalCreateRequestPayload + ) => callback(data) + ipcRenderer.on('terminal:requestTabCreate', listener) + return () => ipcRenderer.removeListener('terminal:requestTabCreate', listener) + }, + onRequestTerminalTabMount: ( + callback: (data: { worktreeId: string; tabId?: string; ptyId?: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { worktreeId: string; tabId?: string; ptyId?: string } + ) => callback(data) + ipcRenderer.on('terminal:requestTabMount', listener) + return () => ipcRenderer.removeListener('terminal:requestTabMount', listener) + }, + replyTerminalCreate: (reply: TerminalTabCreateReply): void => { + ipcRenderer.send('terminal:tabCreateReply', reply) + }, + onSplitTerminal: ( + callback: (data: { + tabId: string + paneRuntimeId: number + direction: 'horizontal' | 'vertical' + command?: string + worktreeId?: string + sourceLeafId?: string + telemetrySource?: TerminalPaneSplitSource + newLeafId?: string + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + tabId: string + paneRuntimeId: number + direction: 'horizontal' | 'vertical' + command?: string + worktreeId?: string + sourceLeafId?: string + telemetrySource?: TerminalPaneSplitSource + newLeafId?: string + } + ) => callback(data) + ipcRenderer.on('ui:splitTerminal', listener) + return () => ipcRenderer.removeListener('ui:splitTerminal', listener) + }, + onRenameTerminal: ( + callback: (data: { tabId: string; title: string | null }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { tabId: string; title: string | null } + ) => callback(data) + ipcRenderer.on('ui:renameTerminal', listener) + return () => ipcRenderer.removeListener('ui:renameTerminal', listener) + }, + onFocusTerminal: ( + callback: (data: { + tabId: string + worktreeId: string + leafId?: string | null + ackPaneKeyOnSuccess?: string + flashFocusedPane?: boolean + scrollToBottomIfOutputSinceLastView?: boolean + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + tabId: string + worktreeId: string + leafId?: string | null + ackPaneKeyOnSuccess?: string + flashFocusedPane?: boolean + scrollToBottomIfOutputSinceLastView?: boolean + } + ) => callback(data) + ipcRenderer.on('ui:focusTerminal', listener) + return () => ipcRenderer.removeListener('ui:focusTerminal', listener) + }, + onFocusEditorTab: ( + callback: (data: { tabId: string; worktreeId: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { tabId: string; worktreeId: string } + ) => callback(data) + ipcRenderer.on('ui:focusEditorTab', listener) + return () => ipcRenderer.removeListener('ui:focusEditorTab', listener) + }, + onCloseSessionTab: ( + callback: (data: { tabId: string; worktreeId: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { tabId: string; worktreeId: string } + ) => callback(data) + ipcRenderer.on('ui:closeSessionTab', listener) + return () => ipcRenderer.removeListener('ui:closeSessionTab', listener) + }, + onSessionTabCloseRequest: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, request: Parameters[0]) => + callback(request) + ipcRenderer.on('ui:sessionTabCloseRequest', listener) + return () => ipcRenderer.removeListener('ui:sessionTabCloseRequest', listener) + }, + respondSessionTabClose: (response) => { + ipcRenderer.send('ui:sessionTabCloseResponse', response) + }, + onMoveSessionTab: ( + callback: (data: { worktreeId: string } & RuntimeMobileSessionTabMove) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { worktreeId: string } & RuntimeMobileSessionTabMove + ) => callback(data) + ipcRenderer.on('ui:moveSessionTab', listener) + return () => ipcRenderer.removeListener('ui:moveSessionTab', listener) + }, + onOpenFileFromMobile: ( + callback: (data: { + worktreeId: string + filePath: string + relativePath: string + runtimeEnvironmentId?: string + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + worktreeId: string + filePath: string + relativePath: string + runtimeEnvironmentId?: string + } + ) => callback(data) + ipcRenderer.on('ui:openFileFromMobile', listener) + return () => ipcRenderer.removeListener('ui:openFileFromMobile', listener) + } +} diff --git a/src/preload/api/ui-bridge-part-4.ts b/src/preload/api/ui-bridge-part-4.ts new file mode 100644 index 00000000000..2c5e2683931 --- /dev/null +++ b/src/preload/api/ui-bridge-part-4.ts @@ -0,0 +1,195 @@ +import { ipcRenderer, webFrame } from 'electron' +import type { + RuntimeMobileMarkdownRequest, + RuntimeMobileMarkdownResponse +} from '../../shared/mobile-markdown-document' +import { + richMarkdownContextMenuCommandChannel, + richMarkdownContextMenuTargetChannel, + type RichMarkdownContextMenuCommandPayload, + type RichMarkdownContextMenuTableTarget +} from '../../shared/rich-markdown-context-menu' +import type { NativeFileDropPayload } from '../../shared/native-file-drop' +import type { ReadClipboardTextOptions } from '../../shared/clipboard-text' +import { subscribeNativeFileDrop } from '../preload-runtime-support' + +export const uiApiPart4 = { + onOpenDiffFromMobile: ( + callback: (data: { + worktreeId: string + filePath: string + relativePath: string + staged: boolean + runtimeEnvironmentId?: string + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + worktreeId: string + filePath: string + relativePath: string + staged: boolean + runtimeEnvironmentId?: string + } + ) => callback(data) + ipcRenderer.on('ui:openDiffFromMobile', listener) + return () => ipcRenderer.removeListener('ui:openDiffFromMobile', listener) + }, + onMobileMarkdownRequest: ( + callback: (request: RuntimeMobileMarkdownRequest) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, request: RuntimeMobileMarkdownRequest) => + callback(request) + ipcRenderer.on('ui:mobileMarkdownRequest', listener) + return () => ipcRenderer.removeListener('ui:mobileMarkdownRequest', listener) + }, + respondMobileMarkdownRequest: (response: RuntimeMobileMarkdownResponse): void => { + ipcRenderer.send('ui:mobileMarkdownResponse', response) + }, + onCloseTerminal: ( + callback: (data: { tabId: string; paneRuntimeId?: number }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { tabId: string; paneRuntimeId?: number } + ) => callback(data) + ipcRenderer.on('ui:closeTerminal', listener) + return () => ipcRenderer.removeListener('ui:closeTerminal', listener) + }, + onTerminalTabCloseRequest: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, request: Parameters[0]) => + callback(request) + ipcRenderer.on('ui:terminalTabCloseRequest', listener) + return () => ipcRenderer.removeListener('ui:terminalTabCloseRequest', listener) + }, + respondTerminalTabClose: (response) => { + ipcRenderer.send('ui:terminalTabCloseResponse', response) + }, + onSleepWorktree: (callback: (data: { worktreeId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) => + callback(data) + ipcRenderer.on('ui:sleepWorktree', listener) + return () => ipcRenderer.removeListener('ui:sleepWorktree', listener) + }, + onResumeSleepingAgents: (callback: (data: { worktreeId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) => + callback(data) + ipcRenderer.on('ui:resumeSleepingAgents', listener) + return () => ipcRenderer.removeListener('ui:resumeSleepingAgents', listener) + }, + onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, direction: 'in' | 'out' | 'reset') => + callback(direction) + ipcRenderer.on('terminal:zoom', listener) + return () => ipcRenderer.removeListener('terminal:zoom', listener) + }, + readClipboardText: (options?: ReadClipboardTextOptions): Promise => + ipcRenderer.invoke('clipboard:readText', options), + readSelectionClipboardText: (options?: ReadClipboardTextOptions): Promise => + ipcRenderer.invoke('clipboard:readSelectionText', options), + saveClipboardImageAsTempFile: (args?: { + connectionId?: string | null + runtimeEnvironmentId?: string | null + }): Promise => ipcRenderer.invoke('clipboard:saveImageAsTempFile', args), + writeClipboardText: (text: string): Promise => + ipcRenderer.invoke('clipboard:writeText', text), + writeTerminalClipboardText: (text: string): Promise => + ipcRenderer.invoke('clipboard:writeTerminalText', text), + writeSelectionClipboardText: (text: string): Promise => + ipcRenderer.invoke('clipboard:writeSelectionText', text), + writeClipboardImage: (dataUrl: string): Promise => + ipcRenderer.invoke('clipboard:writeImage', dataUrl), + performNativePaste: (options?: { mode?: 'paste' | 'paste-and-match-style' }): void => { + ipcRenderer.send('ui:performNativePaste', { + mode: options?.mode === 'paste-and-match-style' ? 'paste-and-match-style' : 'paste' + }) + }, + performNativeSelectionAction: (action: 'copy' | 'select-all'): void => { + ipcRenderer.send('ui:performNativeSelectionAction', action) + }, + writeClipboardFile: ( + args: + | { + filePath: string + connectionId?: string | null + } + | string + ): Promise<{ ok: boolean; reason?: string }> => ipcRenderer.invoke('clipboard:writeFile', args), + onFileDrop: (callback: (data: NativeFileDropPayload) => void): (() => void) => + subscribeNativeFileDrop(callback), + getZoomLevel: (): number => webFrame.getZoomLevel(), + setZoomLevel: (level: number): void => webFrame.setZoomLevel(level), + syncTrafficLights: (zoomFactor: number): void => + ipcRenderer.send('ui:sync-traffic-lights', zoomFactor), + setMarkdownEditorFocused: (focused: boolean): void => { + ipcRenderer.send('ui:setMarkdownEditorFocused', focused) + }, + setRichMarkdownContextMenuTarget: (target: RichMarkdownContextMenuTableTarget | null): void => { + ipcRenderer.send(richMarkdownContextMenuTargetChannel, target) + }, + setTerminalInputFocused: (focused: boolean): void => { + ipcRenderer.send('ui:setTerminalInputFocused', focused) + }, + setFloatingFocus: (state: { panelFocused: boolean; terminalFocused: boolean }): void => { + ipcRenderer.send('ui:setFloatingFocus', state) + }, + setShortcutRecorderFocused: (focused: boolean): void => { + ipcRenderer.send('ui:setShortcutRecorderFocused', focused) + }, + onRichMarkdownContextCommand: ( + callback: (payload: RichMarkdownContextMenuCommandPayload) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: RichMarkdownContextMenuCommandPayload + ) => callback(payload) + ipcRenderer.on(richMarkdownContextMenuCommandChannel, listener) + return () => ipcRenderer.removeListener(richMarkdownContextMenuCommandChannel, listener) + }, + onFullscreenChanged: (callback: (isFullScreen: boolean) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, isFullScreen: boolean) => + callback(isFullScreen) + ipcRenderer.on('window:fullscreen-changed', listener) + return () => ipcRenderer.removeListener('window:fullscreen-changed', listener) + }, + onSystemResumed: (callback: () => void): (() => void) => { + const listener = () => callback() + ipcRenderer.on('system:resumed', listener) + return () => ipcRenderer.removeListener('system:resumed', listener) + }, + minimize: (): void => { + ipcRenderer.send('window:minimize') + }, + maximize: (): void => { + ipcRenderer.send('window:maximize') + }, + isMaximized: (): Promise => ipcRenderer.invoke('window:isMaximized'), + onMaximizeChanged: (callback: (isMaximized: boolean) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, isMaximized: boolean) => + callback(isMaximized) + ipcRenderer.on('window:maximize-changed', listener) + return () => ipcRenderer.removeListener('window:maximize-changed', listener) + }, + requestClose: (): void => { + ipcRenderer.send('window:request-close') + }, + popupMenu: (): void => { + ipcRenderer.send('menu:popup') + }, + onWindowCloseRequested: (callback: (data: { isQuitting: boolean }) => void): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { isQuitting: boolean; requestId?: number } + ): void => { + // Why: main cannot reach will-quit while a frozen renderer owns the window close handshake. + ipcRenderer.send('window:close-request-received', data?.requestId) + callback({ isQuitting: data?.isQuitting ?? false }) + } + ipcRenderer.on('window:close-requested', listener) + return () => ipcRenderer.removeListener('window:close-requested', listener) + }, + confirmWindowClose: (): void => { + ipcRenderer.send('window:confirm-close') + } +} diff --git a/src/preload/api/ui-bridge-part-5.ts b/src/preload/api/ui-bridge-part-5.ts new file mode 100644 index 00000000000..7c4c10eb1c6 --- /dev/null +++ b/src/preload/api/ui-bridge-part-5.ts @@ -0,0 +1,7 @@ +import { ipcRenderer } from 'electron' + +export const uiApiPart5 = { + notifyWindowRevealed: (): void => { + ipcRenderer.send('ui:window-revealed') + } +} diff --git a/src/preload/api/ui-bridge.ts b/src/preload/api/ui-bridge.ts new file mode 100644 index 00000000000..97e199215c6 --- /dev/null +++ b/src/preload/api/ui-bridge.ts @@ -0,0 +1,14 @@ +import type { PreloadApi } from '../api-types' +import { uiApiPart1 } from './ui-bridge-part-1' +import { uiApiPart2 } from './ui-bridge-part-2' +import { uiApiPart3 } from './ui-bridge-part-3' +import { uiApiPart4 } from './ui-bridge-part-4' +import { uiApiPart5 } from './ui-bridge-part-5' + +export const uiApi = { + ...uiApiPart1, + ...uiApiPart2, + ...uiApiPart3, + ...uiApiPart4, + ...uiApiPart5 +} satisfies PreloadApi['ui'] diff --git a/src/preload/api/updater-bridge.ts b/src/preload/api/updater-bridge.ts new file mode 100644 index 00000000000..2b40e42b8be --- /dev/null +++ b/src/preload/api/updater-bridge.ts @@ -0,0 +1,36 @@ +import { ipcRenderer } from 'electron' +import type { UpdateStatus } from '../../shared/update-status-types' +import { prepareAndInvokeUpdaterInstall } from '../renderer-restart-wiring' +import { awaitBeforeUnloadCheckpoint, updaterQuitAbortRelay } from '../preload-runtime-support' +import type { PreloadApi } from '../api-types' + +export const updaterApi = { + getStatus: () => ipcRenderer.invoke('updater:getStatus'), + getVersion: () => ipcRenderer.invoke('updater:getVersion'), + check: (options) => ipcRenderer.invoke('updater:check', options), + download: () => ipcRenderer.invoke('updater:download'), + dismissNudge: () => ipcRenderer.invoke('updater:dismissNudge'), + dismissAvailableUpdate: () => ipcRenderer.invoke('updater:dismissAvailableUpdate'), + getLinuxPackageInstallInstructions: () => + ipcRenderer.invoke('updater:getLinuxPackageInstallInstructions'), + showLinuxPackage: () => ipcRenderer.invoke('updater:showLinuxPackage'), + listBuilds: (channel) => ipcRenderer.invoke('updater:listBuilds', channel), + quitAndInstall: (): Promise => + prepareAndInvokeUpdaterInstall( + window, + updaterQuitAbortRelay, + () => ipcRenderer.invoke('updater:quitAndInstall'), + awaitBeforeUnloadCheckpoint + ), + + onStatus: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, status: UpdateStatus) => callback(status) + ipcRenderer.on('updater:status', listener) + return () => ipcRenderer.removeListener('updater:status', listener) + }, + onClearDismissal: (callback) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('updater:clearDismissal', listener) + return () => ipcRenderer.removeListener('updater:clearDismissal', listener) + } +} satisfies PreloadApi['updater'] diff --git a/src/preload/api/workspace-cleanup-bridge.ts b/src/preload/api/workspace-cleanup-bridge.ts new file mode 100644 index 00000000000..e9e4ae227cb --- /dev/null +++ b/src/preload/api/workspace-cleanup-bridge.ts @@ -0,0 +1,36 @@ +import { ipcRenderer } from 'electron' +import type { WorkspaceCleanupScanProgress } from '../../shared/workspace-cleanup' +import type { PreloadApi } from '../api-types' + +export const workspaceCleanupApi = { + scan: (args, onProgress) => { + if (!onProgress) { + return ipcRenderer.invoke('workspaceCleanup:scan', args) + } + const scanId = args?.scanId ?? crypto.randomUUID() + const listener = ( + _event: Electron.IpcRendererEvent, + progress: WorkspaceCleanupScanProgress + ): void => { + if (progress.scanId === scanId) { + onProgress(progress) + } + } + ipcRenderer.on('workspaceCleanup:scanProgress', listener) + return ipcRenderer + .invoke('workspaceCleanup:scan', { ...args, scanId }) + .finally(() => ipcRenderer.removeListener('workspaceCleanup:scanProgress', listener)) + }, + cancelScan: (scanId) => ipcRenderer.invoke('workspaceCleanup:cancelScan', scanId), + getCachedScan: () => ipcRenderer.invoke('workspaceCleanup:getCachedScan'), + dismiss: (args) => ipcRenderer.invoke('workspaceCleanup:dismiss', args), + clearDismissals: () => ipcRenderer.invoke('workspaceCleanup:clearDismissals'), + hasKillableLocalProcesses: (args) => + ipcRenderer.invoke('workspaceCleanup:hasKillableLocalProcesses', args), + beginRemovalSnapshotPruneBatch: (args) => + ipcRenderer.invoke('workspaceCleanup:beginRemovalSnapshotPruneBatch', args), + recordRemovalSnapshotPrune: (args) => + ipcRenderer.invoke('workspaceCleanup:recordRemovalSnapshotPrune', args), + finishRemovalSnapshotPruneBatch: (args) => + ipcRenderer.invoke('workspaceCleanup:finishRemovalSnapshotPruneBatch', args) +} satisfies PreloadApi['workspaceCleanup'] diff --git a/src/preload/api/workspace-ports-bridge.ts b/src/preload/api/workspace-ports-bridge.ts new file mode 100644 index 00000000000..6cb4c859ed3 --- /dev/null +++ b/src/preload/api/workspace-ports-bridge.ts @@ -0,0 +1,16 @@ +import { ipcRenderer } from 'electron' +import type { WorkspacePortAdvertisedUrlChangedEvent } from '../../shared/workspace-ports' +import type { PreloadApi } from '../api-types' + +export const workspacePortsApi = { + scan: (args) => ipcRenderer.invoke('workspacePorts:scan', args), + kill: (args) => ipcRenderer.invoke('workspacePorts:kill', args), + onAdvertisedUrlChanged: (callback) => { + const listener = ( + _event: Electron.IpcRendererEvent, + event: WorkspacePortAdvertisedUrlChangedEvent + ): void => callback(event) + ipcRenderer.on('workspacePorts:advertised-url-changed', listener) + return () => ipcRenderer.removeListener('workspacePorts:advertised-url-changed', listener) + } +} satisfies PreloadApi['workspacePorts'] diff --git a/src/preload/api/workspace-space-bridge.ts b/src/preload/api/workspace-space-bridge.ts new file mode 100644 index 00000000000..ec9bc74e12a --- /dev/null +++ b/src/preload/api/workspace-space-bridge.ts @@ -0,0 +1,17 @@ +import { ipcRenderer } from 'electron' +import type { WorkspaceSpaceScanProgress } from '../../shared/workspace-space-types' +import type { PreloadApi } from '../api-types' + +export const workspaceSpaceApi = { + analyze: () => ipcRenderer.invoke('workspaceSpace:analyze'), + getCachedAnalysis: () => ipcRenderer.invoke('workspaceSpace:getCachedAnalysis'), + cancel: () => ipcRenderer.invoke('workspaceSpace:cancel'), + onProgress: (callback) => { + const listener = ( + _event: Electron.IpcRendererEvent, + progress: WorkspaceSpaceScanProgress + ): void => callback(progress) + ipcRenderer.on('workspaceSpace:progress', listener) + return () => ipcRenderer.removeListener('workspaceSpace:progress', listener) + } +} satisfies PreloadApi['workspaceSpace'] diff --git a/src/preload/api/worktrees-bridge.ts b/src/preload/api/worktrees-bridge.ts new file mode 100644 index 00000000000..8fb8d5728ac --- /dev/null +++ b/src/preload/api/worktrees-bridge.ts @@ -0,0 +1,117 @@ +import { ipcRenderer } from 'electron' +import type { + HostLineageSnapshot, + ListDesktopLineageForHostArgs +} from '../../shared/host-lineage-contract' +import type { + WorktreeBaseStatusEvent, + WorktreeRemoteBranchConflictEvent +} from '../../shared/worktree/base-ref-drift-types' +import type { WorktreeHeadIdentity } from '../../shared/worktree/types' +import type { PreloadApi } from '../api-types' + +export const worktreesApi = { + list: (args) => ipcRenderer.invoke('worktrees:list', args), + listRetiredNames: (args) => ipcRenderer.invoke('worktrees:listRetiredNames', args), + + listDetected: (args) => ipcRenderer.invoke('worktrees:listDetected', args), + + listKnownForExecutionHost: (args) => + ipcRenderer.invoke('worktrees:listKnownForExecutionHost', args), + + forgetRemovedForExecutionHost: (args) => + ipcRenderer.invoke('worktrees:forgetRemovedForExecutionHost', args), + + cancelListDetected: (args) => ipcRenderer.invoke('worktrees:cancelListDetected', args), + + listAll: () => ipcRenderer.invoke('worktrees:listAll'), + + create: (args) => ipcRenderer.invoke('worktrees:create', args), + + adoptProvisionedRoot: (args) => ipcRenderer.invoke('worktrees:adoptProvisionedRoot', args), + + onCreateProgress: ( + callback: (data: { creationId?: string; phase: 'fetching' | 'creating' }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { creationId?: string; phase: 'fetching' | 'creating' } + ) => callback(data) + ipcRenderer.on('createWorktree:progress', listener) + return () => ipcRenderer.removeListener('createWorktree:progress', listener) + }, + + prefetchCreateBase: (args) => ipcRenderer.invoke('worktrees:prefetchCreateBase', args), + + resolvePrBase: (args) => ipcRenderer.invoke('worktrees:resolvePrBase', args), + + resolveMrBase: (args) => ipcRenderer.invoke('worktrees:resolveMrBase', args), + + remove: (args) => ipcRenderer.invoke('worktrees:remove', args), + + forgetLocal: (args) => ipcRenderer.invoke('worktrees:forgetLocal', args), + + forceDeletePreservedBranch: (args) => + ipcRenderer.invoke('worktrees:forceDeletePreservedBranch', args), + + updateMeta: (args) => ipcRenderer.invoke('worktrees:updateMeta', args), + + listLineage: () => ipcRenderer.invoke('worktrees:listLineage'), + + listLineageForHost: (args: ListDesktopLineageForHostArgs): Promise => + ipcRenderer.invoke('worktrees:listLineageForHost', args), + + updateLineage: (args) => ipcRenderer.invoke('worktrees:updateLineage', args), + + persistSortOrder: (args) => ipcRenderer.invoke('worktrees:persistSortOrder', args), + + getBranchRenameFailureOutput: (args) => + ipcRenderer.invoke('worktrees:getBranchRenameFailureOutput', args), + + onChanged: ( + callback: (data: { + repoId: string + renamed?: { oldWorktreeId: string; newWorktreeId: string } + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { repoId: string; renamed?: { oldWorktreeId: string; newWorktreeId: string } } + ) => callback(data) + ipcRenderer.on('worktrees:changed', listener) + return () => ipcRenderer.removeListener('worktrees:changed', listener) + }, + + onGitStatusMetadataChanged: (callback: (data: { repoId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { repoId: string }) => callback(data) + ipcRenderer.on('worktrees:gitStatusMetadataChanged', listener) + return () => ipcRenderer.removeListener('worktrees:gitStatusMetadataChanged', listener) + }, + + onHeadIdentitiesChanged: ( + callback: (data: { repoId: string; identities: WorktreeHeadIdentity[] }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { repoId: string; identities: WorktreeHeadIdentity[] } + ) => callback(data) + ipcRenderer.on('worktrees:headIdentitiesChanged', listener) + return () => ipcRenderer.removeListener('worktrees:headIdentitiesChanged', listener) + }, + + onBaseStatus: (callback: (data: WorktreeBaseStatusEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: WorktreeBaseStatusEvent) => + callback(data) + ipcRenderer.on('worktree:baseStatus', listener) + return () => ipcRenderer.removeListener('worktree:baseStatus', listener) + }, + + onRemoteBranchConflict: ( + callback: (data: WorktreeRemoteBranchConflictEvent) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: WorktreeRemoteBranchConflictEvent) => + callback(data) + ipcRenderer.on('worktree:remoteBranchConflict', listener) + return () => ipcRenderer.removeListener('worktree:remoteBranchConflict', listener) + } +} satisfies PreloadApi['worktrees'] diff --git a/src/preload/api/wsl-bridge.ts b/src/preload/api/wsl-bridge.ts new file mode 100644 index 00000000000..aeb32cdfa45 --- /dev/null +++ b/src/preload/api/wsl-bridge.ts @@ -0,0 +1,6 @@ +import { ipcRenderer } from 'electron' + +export const wslApi = { + isAvailable: (): Promise => ipcRenderer.invoke('wsl:isAvailable'), + listDistros: (): Promise => ipcRenderer.invoke('wsl:listDistros') +} diff --git a/src/preload/index.ts b/src/preload/index.ts index d1c2c358cc2..ad97a911fe5 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5351 +1,189 @@ -/* eslint-disable max-lines -- Why: preload is the audited renderer/Electron IPC contract; co-locating the surface eases security and type-drift review. */ -import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron' +import { contextBridge, ipcRenderer } from 'electron' import { electronAPI } from '@electron-toolkit/preload' -import { preloadE2EConfig } from './e2e-config' -import { glApi } from './gitlab' -import { admitCloseActiveTabPayload } from './close-active-tab-payload-admission' -import type { CloseActiveTabPayload } from './api/ui-command-event-api' -import type { - SkillDeletePlan, - SkillDeleteRequest, - SkillDeleteResult -} from '../shared/skill-delete-contract' +import type { PreloadApi } from './api-types' import { - DOC_PREVIEW_EXTERNAL_LINK_CHANNEL, - DOC_PREVIEW_LOAD_FAILURE_CHANNEL, - DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL, - DOC_PREVIEW_MINT_GRANT_CHANNEL, - DOC_PREVIEW_REVOKE_GRANT_CHANNEL, - type DocPreviewFailure -} from '../shared/doc-preview-scheme' -import type { DocPreviewGrantRequest } from './api/doc-preview-api' -import type { AppIdentity } from '../shared/app-identity' -import type { MacCapturedDigitRowChord } from '../shared/macos-symbolic-hotkeys' -import type { ComputerAwakeStatus } from '../shared/computer-awake-mode' -import type { - DashboardRevealAgentArgs, - DashboardSleepWorkspaceArgs, - DashboardSnapshot, - DashboardSpawnAgentArgs -} from '../shared/dashboard-snapshot' -import type { - TerminalPreviewConnectResult, - TerminalPreviewDataPayload -} from '../shared/terminal-preview' -import type { AgentSessionPtyWriteRefusal } from '../shared/agent-session-pty-write-admission' -import type { CliInstallStatus } from '../shared/cli-install-types' -import type { CodexConfigSyncStatus } from '../shared/codex-config-sync-types' -import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry' -import type { TerminalTabCreateReply } from '../shared/terminal-reveal-identity' -import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' -import type { StartupCommandDelivery } from '../shared/codex-startup-delivery' -import type { - AgentProviderSessionMetadata, - SleepingAgentLaunchConfig -} from '../shared/agent-session-resume' -import type { MobileRelayStatus } from '../shared/mobile-relay-status' -import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode' -import type { RuntimePairingReach } from '../shared/runtime-pairing-reach' -import type { MobileRelayMintFailure } from '../shared/mobile-relay-mint-failure' -import type { VerifyAndAddRuntimeEnvironmentResult } from '../shared/remote-pairing-verification' -import type { - SshMutationExpectation, - SshConnectionState, - SshConfigHostListArgs, - SshConfigHostListResult, - SshConfigHostResolution, - SshConfigImportResult, - SshTargetAddResult, - SshTargetCreateInput, - SshTarget, - SshTargetUpdateInput, - PortForwardEntry, - EnrichedDetectedPort -} from '../shared/ssh-types' -import { - admitSshConnectionStateForAuthorityReconciliation, - admitSshDetectedPorts -} from '../shared/ssh-retained-payload-admission' -import type { - HostRepoCatalogSnapshot, - ListReposForExecutionHostArgs -} from '../shared/host-repo-catalog-contract' -import type { - HostLineageSnapshot, - ListDesktopLineageForHostArgs -} from '../shared/host-lineage-contract' -import type { - PluginPanelActionOutcome, - PluginPanelEntry -} from '../shared/plugins/plugin-panel-bridge' -import type { PluginConsentRequest } from '../shared/plugins/plugin-consent-request' -import type { PluginChangeEvent } from '../shared/plugins/plugin-change-event' -import type { BrowserViewportOverride } from '../shared/browser-workspace-types' -import type { - BrowserWebAuthnAccountRequest, - BrowserWebAuthnAccountResponse -} from '../shared/browser-webauthn-account' -import type { SearchResult } from '../shared/code-search-types' -import type { - FilesystemPathFlavor, - FsChangedPayload, - MarkdownDocument -} from '../shared/filesystem-entry-types' -import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../shared/git-fork-sync' -import type { GitStagingArea, GitUpstreamStatus } from '../shared/git-status-types' -import type { GitHubCommentResult, GitHubReactionContent } from '../shared/github/comment-types' -import type { - GitHubPRRefreshCandidate, - GitHubPRRefreshEvent, - GitHubPRRefreshReason -} from '../shared/github/pull-request-refresh-types' -import type { GitHubAssignableUser, GitHubOwnerRepo } from '../shared/github/pull-request-types' -import type { GetRateLimitResult } from '../shared/github/rate-limit-types' -import type { GitHubWorkItem, ListWorkItemsResult } from '../shared/github/work-item-types' -import type { GhosttyImportPreview } from '../shared/global-settings-types' -import type { GitHubCreateIssueResult } from '../shared/issue-mutation-types' -import type { JiraProjectStatusOrder } from '../shared/jira-types' -import type { LinearProjectDetail } from '../shared/linear/project-types' -import type { - NotificationDeliveryProbeResult, - NotificationDismissResult, - NotificationDispatchResult, - NotificationPermissionStatusResult, - NotificationSoundDataResult, - NotificationSoundPathResult, - NotificationSoundResult -} from '../shared/notification-settings-types' -import type { OnboardingState } from '../shared/onboarding-state-types' -import type { PersistedUIState } from '../shared/persisted-ui-state-types' -import type { CustomPet } from '../shared/pet-types' -import type { MemorySnapshot } from '../shared/process-stats-types' -import type { NestedRepoScanResult } from '../shared/project-group-types' -import type { BaseRefDefaultResult, BaseRefSearchResult } from '../shared/repo-types' -import type { TuiAgent } from '../shared/tui-agent' -import type { FloatingTerminalCwdRequest } from '../shared/ui-chrome-types' -import type { UpdateStatus } from '../shared/update-status-types' -import type { - WorktreeBaseStatusEvent, - WorktreeRemoteBranchConflictEvent -} from '../shared/worktree/base-ref-drift-types' -import type { - WorktreeDefaultTabsLaunch, - WorktreeSetupLaunch -} from '../shared/worktree/launch-types' -import type { GitPushTarget, WorktreeHeadIdentity } from '../shared/worktree/types' -import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker' -import type { PtyListedSession } from '../shared/pty-listed-session' -import type { - PtyRendererDeliveryHealthReply, - PtyRendererDeliveryStateReport -} from '../shared/pty-renderer-delivery-health' -import type { TerminalViewAttributes } from '../shared/terminal-view-attributes' -import type { WriteTerminalRenderDesyncEvidenceArgs } from '../shared/terminal-render-desync-evidence' -import type { PtyMainDeliveryDiagnostics } from '../shared/pty-delivery-diagnostics' -import type { - WarpThemeImportPreview, - WarpThemeImportSource -} from '../shared/terminal-custom-themes' -import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history' -import type { - ShellOpenExternalEditorRequest, - ShellOpenExternalEditorResult, - ShellOpenLocalPathResult -} from '../shared/shell-open-types' -import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills' -import type { - SkillCloudOwnedShare, - SkillCloudOperation, - SkillCloudPackageDetails -} from '../shared/skill-cloud-contract' -import type { - SkillBundleInstallPreviewInput, - SkillBundleInstallPreviewOperation, - SkillBundlePackageVersionInstallInput, - SkillBundleShareInstallInput, - SkillBundleShareInstallOperation, - SkillInstallPreviewInput, - SkillInstallPreviewOperation, - ManagedSkillInstallListOperation, - SkillPackageVersionInstallInput, - SkillRemoveInput, - SkillRemoveOperation, - SkillShareInstallInput, - SkillShareInstallOperation, - SkillInstallCancelInput, - SkillInstallProgress, - SkillSharePreview, - SkillShareProgress, - SkillSharePublishInput, - SkillSharePublishOperation, - SkillShareResolvedOperation -} from '../shared/skill-sharing-contract' -import type { - SkillFreshnessInventory, - SkillUpdateRun, - SkillUpdateStartResult -} from '../shared/skill-freshness' -import type { ClientHostedBrowserRowsEvent } from '../shared/client-hosted-browser-rows' -import type { - RuntimeBrowserDriverState, - RuntimeMobileSessionTabMove, - RuntimeRendererSyncWindowGraph, - RuntimeStatus, - RuntimeSyncWindowGraphResult, - RuntimeTerminalCreateRequestPayload, - RuntimeTerminalDriverState, - RuntimeTerminalPresentation -} from '../shared/runtime-types' -import type { RuntimeRpcResponse } from '../shared/runtime-rpc-envelope' -import type { RemoteRuntimeSharedConnectionDiagnostics } from '../shared/remote-runtime-shared-control-types' -import { RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL } from '../shared/runtime-environment-diagnostics' -import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' -import type { RemoteWorkspaceChangedEvent } from '../shared/remote-workspace-types' -import type { - RuntimeMobileMarkdownRequest, - RuntimeMobileMarkdownResponse -} from '../shared/mobile-markdown-document' -import type { - CodexRateLimitResetResult, - GrokAccountStatus, - RateLimitRuntimeTarget, - RateLimitState -} from '../shared/rate-limit-types' -import type { WorkspaceSpaceScanProgress } from '../shared/workspace-space-types' -import type { WorkspaceCleanupScanProgress } from '../shared/workspace-cleanup' -import type { WorkspacePortAdvertisedUrlChangedEvent } from '../shared/workspace-ports' -import type { GhAuthDiagnostic } from '../shared/github/auth-types' -import type { TaskSourceContext } from '../shared/task-source-context' -import type { - GetProjectViewTableResult, - GitHubProjectCommentMutationResult, - GitHubProjectMutationResult, - ListAccessibleProjectsResult, - ListAssignableUsersBySlugResult, - ListIssueTypesBySlugResult, - ListLabelsBySlugResult, - ListProjectViewsResult, - ProjectWorkItemDetailsBySlugResult, - ResolveProjectRefResult -} from '../shared/github/project-result-types' -import type { - AddIssueCommentBySlugArgs, - ClearProjectItemFieldArgs, - DeleteIssueCommentBySlugArgs, - GetProjectViewTableArgs, - ListAccessibleProjectsArgs, - ListAssignableUsersBySlugArgs, - ListIssueTypesBySlugArgs, - ListLabelsBySlugArgs, - ListProjectViewsArgs, - ProjectWorkItemDetailsBySlugArgs, - ResolveProjectRefArgs, - UpdateIssueBySlugArgs, - UpdateIssueCommentBySlugArgs, - UpdateIssueTypeBySlugArgs, - UpdatePullRequestBySlugArgs, - UpdateProjectItemFieldArgs -} from '../shared/github/project-request-types' -import { - richMarkdownContextMenuCommandChannel, - richMarkdownContextMenuTargetChannel, - type RichMarkdownContextMenuCommandPayload, - type RichMarkdownContextMenuTableTarget -} from '../shared/rich-markdown-context-menu' -import type { - AgentStatusClearIpcPayload, - AgentStatusIpcPayload, - MigrationUnsupportedPtyEntry -} from '../shared/agent-status-types' -import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent' -import type { AgentQuestionAnsweredInferenceRequest } from '../shared/agent-question-answered-intent' -import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts' -import type { - SpeechErrorEvent, - SpeechLifecycleEvent, - SpeechModelManifest, - SpeechModelState, - SpeechTranscriptEvent -} from '../shared/speech-types' -import type { TelemetryConsentState } from '../shared/telemetry-consent-types' -import type { - PreflightRuntimeContext, - RefreshAgentsResult, - NativeChatAppendedPayload, - NativeChatReadSessionResult, - NativeChatSubscriptionFrame, - PluginHostInstallResult, - PluginHostInstallSource, - PluginHostListEntry, - PluginHostLogLine, - ExternalAutomationManagerResult, - PreloadApi -} from './api-types' -import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events' -import { - KEYBOARD_LAYOUT_CHANGED_CHANNEL, - type KeyboardLayoutChangeEvent -} from '../shared/keyboard-layout-events' -import { createBrowserFindSubscriptions } from './browser-find-subscriptions' -import { createBrowserClientPageRendererRequests } from './browser-client-page-renderer-requests' -import { readBrowserClientHostIdArgument } from '../shared/browser-client-host-id-argument' -import { createUsageProviderApi } from './usage-provider-api' -import type { AppStarSource } from '../shared/gh-star-source' -import type { ExecutionHostId } from '../shared/execution-host' -import type { - AutomationDispatchRequest, - AutomationDispatchResult, - ExternalAutomationRunsPage, - AutomationRun, - AutomationPrecheckResult -} from '../shared/automations-types' -import type { AutomationOwnerRef } from '../shared/automation-owner-ref' -import type { - ScopedExternalManagerActionRequest, - ScopedExternalManagerCreateRequest, - ScopedExternalManagerListRequest, - ScopedExternalManagerRunsRequest, - ScopedExternalManagerUpdateRequest -} from '../shared/external-automation-scope' -import type { AutomationsChangedPayload } from '../shared/runtime-client-events' -import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings' -import type { - AiVaultDeleteSessionArgs, - AiVaultDeleteSessionResult -} from '../shared/ai-vault-session-deletion' -import type { - AiVaultFirstUserPromptArgs, - AiVaultListArgs, - AiVaultSubagentListArgs -} from '../shared/ai-vault-types' -import type { AiVaultSessionTitlesArgs } from '../shared/ai-vault-session-title' -import type { AiVaultPrepareSessionResumeArgs } from '../shared/ai-vault-resume-preparation' -import type { AgentType } from '../shared/native-chat-types' -import { ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT } from '../shared/updater-renderer-events' -import { - ORCA_INTERNAL_FILE_DRAG_TYPE, - createNativeFileDropPayload, - createRejectedNativeFileDropPayload, - hasNativeFileDragTypes, - NATIVE_FILE_DROP_MAX_PATHS, - resolveNativeFileDropPath, - type NativeDropResolution, - type NativeFileDropPayload, - type NativeFileDropPathEntry -} from '../shared/native-file-drop' -import type { - LocalLogTailChangedPayload, - LocalLogTailReadArgs, - LocalLogTailReadResult, - LocalLogTailWatchArgs -} from '../shared/local-log-tail-types' -import { subscribeRuntimeEnvironmentFromPreload } from './runtime-environment-subscriptions' -import type { RuntimeEnvironmentSubscriptionHandle } from './runtime-environment-subscriptions' -import type { HostedReviewForBranchArgs } from '../shared/hosted-review' -import type { ReadClipboardTextOptions } from '../shared/clipboard-text' -import type { - LocalhostWorktreeLabelResult, - LocalhostWorktreeLabelRoute -} from '../shared/localhost-worktree-labels' -import type { - CrashReportBreadcrumbData, - CrashReportCopyDiagnosticsArgs, - CrashReportSubmitArgs, - CrashReportSubmitResult, - ReactErrorBoundaryReportArgs, - ReactErrorBoundaryReportResult -} from '../shared/crash-reporting' -import type { RendererHeapStatistics } from '../shared/renderer-heap-statistics' -import type { RendererProcessMemory } from '../shared/renderer-process-memory' -import { readRendererHeapStatistics } from './renderer-heap-statistics-reader' -import { readRendererProcessMemory } from './renderer-process-memory-reader' -import { createUpdaterQuitAbortRelay } from '../shared/renderer-restart-preparation' -import { - prepareAndInvokeAppRestart, - prepareAndInvokeUpdaterInstall, - registerRendererRestartIpcRelays -} from './renderer-restart-wiring' + installBrowserFindListener, + installNativeFileDropHandlers +} from './preload-runtime-support' +import { appApi } from './api/app-bridge' +import { orcaProfilesApi } from './api/orca-profiles-bridge' +import { platformApi } from './api/platform-bridge' +import { wslApi } from './api/wsl-bridge' +import { pwshApi } from './api/pwsh-bridge' +import { gitBashApi } from './api/git-bash-bridge' +import { pluginsApi } from './api/plugins-bridge' +import { reposApi } from './api/repos-bridge' +import { projectsApi } from './api/projects-bridge' +import { projectGroupsApi } from './api/project-groups-bridge' +import { folderWorkspacesApi } from './api/folder-workspaces-bridge' +import { sparsePresetsApi } from './api/sparse-presets-bridge' +import { worktreesApi } from './api/worktrees-bridge' +import { workspaceCleanupApi } from './api/workspace-cleanup-bridge' +import { workspaceSpaceApi } from './api/workspace-space-bridge' +import { workspacePortsApi } from './api/workspace-ports-bridge' +import { ptyApi } from './api/pty-bridge' +import { feedbackApi } from './api/feedback-bridge' +import { crashReportsApi } from './api/crash-reports-bridge' +import { exportApi } from './api/export-bridge' +import { ghApi } from './api/gh-bridge' +import { hostedReviewApi } from './api/hosted-review-bridge' +import { glApiBridge } from './api/gl-bridge' +import { bitbucketApi } from './api/bitbucket-bridge' +import { linearApi } from './api/linear-bridge' +import { jiraApi } from './api/jira-bridge' +import { starNagApi } from './api/star-nag-bridge' +import { diagnosticsApi } from './api/diagnostics-bridge' +import { settingsApi } from './api/settings-bridge' +import { agentAwakeApi } from './api/agent-awake-bridge' +import { localhostWorktreeLabelsApi } from './api/localhost-worktree-labels-bridge' +import { keybindingsApi } from './api/keybindings-bridge' +import { codexAccountsApi } from './api/codex-accounts-bridge' +import { claudeAccountsApi } from './api/claude-accounts-bridge' +import { cliApi } from './api/cli-bridge' +import { codexConfigSyncApi } from './api/codex-config-sync-bridge' +import { agentTrustApi } from './api/agent-trust-bridge' +import { preflightApi } from './api/preflight-bridge' +import { notificationsApi } from './api/notifications-bridge' +import { onboardingApi } from './api/onboarding-bridge' +import { dashboardApi } from './api/dashboard-bridge' +import { terminalPreviewApi } from './api/terminal-preview-bridge' +import { macosTccPromptsApi } from './api/macos-tcc-prompts-bridge' +import { developerPermissionsApi } from './api/developer-permissions-bridge' +import { computerUsePermissionsApi } from './api/computer-use-permissions-bridge' +import { shellApi } from './api/shell-bridge' +import { skillsApi } from './api/skills-bridge' +import { petApi } from './api/pet-bridge' +import { browserApi } from './api/browser-bridge' +import { emulatorApi } from './api/emulator-bridge' +import { hooksApi } from './api/hooks-bridge' +import { ephemeralVmApi } from './api/ephemeral-vm-bridge' +import { cacheApi } from './api/cache-bridge' +import { sessionApi } from './api/session-bridge' +import { remoteWorkspaceApi } from './api/remote-workspace-bridge' +import { updaterApi } from './api/updater-bridge' +import { docPreviewApi } from './api/doc-preview-bridge' +import { notebookApi } from './api/notebook-bridge' +import { fsApi } from './api/fs-bridge' +import { gitApi } from './api/git-bridge' +import { uiApi } from './api/ui-bridge' +import { statsApi } from './api/stats-bridge' +import { memoryApi } from './api/memory-bridge' +import { claudeUsageApi } from './api/claude-usage-bridge' +import { codexUsageApi } from './api/codex-usage-bridge' +import { openCodeUsageApi } from './api/open-code-usage-bridge' +import { aiVaultApi } from './api/ai-vault-bridge' +import { nativeChatApi } from './api/native-chat-bridge' +import { runtimeApi } from './api/runtime-bridge' +import { runtimeEnvironmentsApi } from './api/runtime-environments-bridge' +import { rateLimitsApi } from './api/rate-limits-bridge' +import { minimaxCredentialsApi } from './api/minimax-credentials-bridge' +import { grokAccountsApi } from './api/grok-accounts-bridge' +import { sshApi } from './api/ssh-bridge' +import { automationsApi } from './api/automations-bridge' +import { e2eApi } from './api/e2e-bridge' +import { mobileApi } from './api/mobile-bridge' +import { agentStatusApi } from './api/agent-status-bridge' +import { speechApi } from './api/speech-bridge' -// Why: the sync checkpoint only stages; this joins its durable write so a -// navigating path can abort instead of losing the staged session. -async function awaitBeforeUnloadCheckpoint(): Promise { - const result = (await ipcRenderer.invoke('app:await-before-unload-checkpoint')) as { - ok?: unknown - } - if (result?.ok !== true) { - throw new Error('Failed to persist renderer state before unload.') - } -} +installNativeFileDropHandlers() +installBrowserFindListener() -type NativeFileDropCallback = (data: NativeFileDropPayload) => void +// Custom APIs for renderer. Each domain bridge owns its IPC contract. +const telemetryTrackApi: PreloadApi['telemetryTrack'] = (name, props) => + ipcRenderer.invoke('telemetry:track', name, props) +const telemetrySetOptInApi: PreloadApi['telemetrySetOptIn'] = (optedIn) => + ipcRenderer.invoke('telemetry:setOptIn', optedIn) +const telemetryAcknowledgeBannerApi: PreloadApi['telemetryAcknowledgeBanner'] = () => + ipcRenderer.invoke('telemetry:acknowledgeBanner') +const telemetryGetConsentStateApi: PreloadApi['telemetryGetConsentState'] = () => + ipcRenderer.invoke('telemetry:getConsentState') -const nativeFileDropCallbacks: NativeFileDropCallback[] = [] -let nativeFileDropListenerRegistered = false -const updaterQuitAbortRelay = createUpdaterQuitAbortRelay( - window, - ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT -) - -registerRendererRestartIpcRelays(ipcRenderer, window, updaterQuitAbortRelay) - -function getLinuxDisplayServer(): 'wayland' | 'x11' | null { - if (process.platform !== 'linux') { - return null - } - if ( - process.env.WAYLAND_DISPLAY || - process.env.XDG_SESSION_TYPE?.toLowerCase() === 'wayland' || - process.env.ELECTRON_OZONE_PLATFORM_HINT?.toLowerCase() === 'wayland' - ) { - return 'wayland' - } - return process.env.DISPLAY ? 'x11' : null -} - -const onNativeFileDrop = (_event: Electron.IpcRendererEvent, data: NativeFileDropPayload): void => { - for (const callback of Array.from(nativeFileDropCallbacks)) { - callback(data) - } -} - -function subscribeNativeFileDrop(callback: NativeFileDropCallback): () => void { - nativeFileDropCallbacks.push(callback) - if (!nativeFileDropListenerRegistered) { - // Why: keep one real IPC listener and fan out locally — panes subscribe per split group, which would otherwise trip listener warnings. - ipcRenderer.on('terminal:file-drop', onNativeFileDrop) - nativeFileDropListenerRegistered = true - } - return () => { - const callbackIndex = nativeFileDropCallbacks.indexOf(callback) - if (callbackIndex !== -1) { - nativeFileDropCallbacks.splice(callbackIndex, 1) - } - if (nativeFileDropCallbacks.length === 0 && nativeFileDropListenerRegistered) { - ipcRenderer.removeListener('terminal:file-drop', onNativeFileDrop) - nativeFileDropListenerRegistered = false - } - } -} - -// Why: cache one shared Audio + blob URL per sound path so we don't re-read 10MB from disk and re-transfer over IPC on every notification. -let cachedNotificationSound: { - path: string - blobUrl: string - audio: HTMLAudioElement -} | null = null -let isNotificationSoundPlaying = false -// Why: audio.play() can reject before ended/error fires — cleanup hook prevents leaked listeners on the cached Audio. -let cleanupNotificationSoundPlayback: (() => void) | null = null - -function clearNotificationSoundPlaybackState(): void { - cleanupNotificationSoundPlayback?.() - cleanupNotificationSoundPlayback = null - isNotificationSoundPlaying = false -} - -function disposeCachedNotificationSound(): void { - if (cachedNotificationSound) { - clearNotificationSoundPlaybackState() - cachedNotificationSound.audio.pause() - cachedNotificationSound.audio.src = '' - URL.revokeObjectURL(cachedNotificationSound.blobUrl) - cachedNotificationSound = null - } -} - -/** - * Classify which UI surface the native OS drop landed on, and for file-explorer drops - * extract the destination directory from `data-native-file-drop-dir`. - * - * Why: preload consumes the native `drop` before React can read paths, so it must capture - * the destination dir now — otherwise the renderer can't tell "root" from "inside this folder". - */ -function resolveNativeFileDrop(event: DragEvent): NativeDropResolution | null { - const pathEntries: NativeFileDropPathEntry[] = [] - for (const entry of event.composedPath()) { - if (entry instanceof HTMLElement) { - pathEntries.push({ - nativeFileDropTarget: entry.dataset.nativeFileDropTarget, - nativeFileDropDir: entry.dataset.nativeFileDropDir, - terminalTabId: entry.dataset.terminalTabId, - terminalPaneLeafId: entry.dataset.terminalPaneLeafId ?? entry.dataset.leafId - }) - } - } - return resolveNativeFileDropPath(pathEntries) -} - -// File drag-and-drop lives in preload because webUtils (File→path) is only available in the preload/main world, not the renderer's isolated world. -document.addEventListener( - 'dragover', - (e) => { - // Let in-app drags through to React handlers (their own dropEffect); only override for native OS file drops. - if (e.dataTransfer && !hasNativeFileDragTypes(e.dataTransfer.types)) { - return - } - e.preventDefault() - if (e.dataTransfer) { - e.dataTransfer.dropEffect = 'copy' - } - }, - true -) - -document.addEventListener( - 'drop', - (e) => { - // Let in-app drags (e.g. file explorer → terminal) through to React handlers - if (e.dataTransfer?.types.includes(ORCA_INTERNAL_FILE_DRAG_TYPE)) { - return - } - - e.preventDefault() - e.stopPropagation() - const files = e.dataTransfer?.files - if (!files || files.length === 0) { - return - } - const resolution = resolveNativeFileDrop(e) - - // Why: reject oversized gestures by count before resolving every File object (path resolution is synchronous here). - if (files.length > NATIVE_FILE_DROP_MAX_PATHS) { - ipcRenderer.send( - 'terminal:file-dropped-from-preload', - createRejectedNativeFileDropPayload({ - byteLength: 0, - pathCount: files.length, - reason: 'too-many-paths', - status: 'rejected' - }) - ) - return - } - - const paths: string[] = [] - for (let i = 0; i < files.length; i++) { - // webUtils.getPathForFile is the Electron 28+ replacement for File.path - const filePath = webUtils.getPathForFile(files[i]) - if (filePath) { - paths.push(filePath) - } - } - - if (paths.length === 0) { - return - } - - // Why: explorer marker present but no destination dir resolved → reject entirely, no editor fallback (fail-closed, design §7.1). - if (resolution?.target === 'rejected') { - return - } - - const payload = createNativeFileDropPayload(resolution, paths) - if (!payload) { - return - } - // Why: emit exactly one native-drop event per gesture (the shared planner rejects oversized payloads without leaking path contents). - ipcRenderer.send('terminal:file-dropped-from-preload', payload) - }, - true -) - -const startupDiagnosticsEnabled = process.env.ORCA_STARTUP_DIAGNOSTICS === '1' -const browserFindSubscriptions = createBrowserFindSubscriptions() -const browserClientPageRendererRequests = createBrowserClientPageRendererRequests({ - ipc: ipcRenderer, - isTopFrame: () => window.top === window -}) - -ipcRenderer.on('ui:findInBrowserPage', (_event, source: unknown) => { - browserFindSubscriptions.dispatch(source) -}) - -// Custom APIs for renderer const api = { - app: { - getIdentity: (): Promise => ipcRenderer.invoke('app:getIdentity'), - getFeatureWallAssetBaseUrl: (): Promise => - ipcRenderer.invoke('app:getFeatureWallAssetBaseUrl'), - relaunch: (): Promise => - prepareAndInvokeAppRestart( - window, - () => ipcRenderer.invoke('app:relaunch'), - awaitBeforeUnloadCheckpoint - ), - restart: (): Promise => - prepareAndInvokeAppRestart( - window, - () => ipcRenderer.invoke('app:restart'), - awaitBeforeUnloadCheckpoint - ), - reload: (): Promise => - prepareAndInvokeAppRestart( - window, - () => ipcRenderer.invoke('app:reload'), - awaitBeforeUnloadCheckpoint - ), - stageBeforeUnloadSync: (args: Parameters[0]) => { - const result = ipcRenderer.sendSync('app:stage-before-unload-sync', args) as { - ok?: unknown - } - if (result?.ok !== true) { - throw new Error('Failed to stage renderer state before unload.') - } - }, - awaitFirstWindowStartupServices: (): Promise => - ipcRenderer.invoke('app:awaitFirstWindowStartupServices'), - prepareTerminalStartupRestoration: (): Promise => - ipcRenderer.invoke('app:prepareTerminalStartupRestoration'), - recoverLegacyWorkerTerminalsForRendererStartup: (): Promise => - ipcRenderer.invoke('app:recoverLegacyWorkerTerminalsForRendererStartup'), - startupDiagnostic: (event: string, details?: Record): Promise => - startupDiagnosticsEnabled - ? ipcRenderer.invoke('app:startupDiagnostic', event, details) - : Promise.resolve(), - // Why: macOS input mode (or layout ID) so keyboard workarounds can tell CJK/compose layouts from US QWERTY (issue #1205); null on non-Darwin or read failure. - getKeyboardInputSourceId: (): Promise => - ipcRenderer.invoke('app:getKeyboardInputSourceId'), - getMacCapturedDigitRowChords: (): Promise => - ipcRenderer.invoke('app:getMacCapturedDigitRowChords'), - getKeyboardLayoutSnapshot: () => ipcRenderer.invoke('app:getKeyboardLayoutSnapshot'), - onKeyboardLayoutChanged: ( - callback: (event: KeyboardLayoutChangeEvent) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - event: KeyboardLayoutChangeEvent - ): void => callback(event) - ipcRenderer.on(KEYBOARD_LAYOUT_CHANGED_CHANNEL, listener) - return () => ipcRenderer.removeListener(KEYBOARD_LAYOUT_CHANGED_CHANNEL, listener) - }, - setUnreadDockBadgeCount: (count: number): Promise => - ipcRenderer.invoke('app:setUnreadDockBadgeCount', count), - getFloatingTerminalCwd: (args?: FloatingTerminalCwdRequest): Promise => - ipcRenderer.invoke('app:getFloatingTerminalCwd', args), - getFloatingMarkdownDirectory: (): Promise => - ipcRenderer.invoke('app:getFloatingMarkdownDirectory'), - pickFloatingMarkdownDocument: (): Promise => - ipcRenderer.invoke('app:pickFloatingMarkdownDocument'), - pickFloatingWorkspaceDirectory: (): Promise => - ipcRenderer.invoke('app:pickFloatingWorkspaceDirectory'), - writeTerminalRenderDesyncEvidence: (args: WriteTerminalRenderDesyncEvidenceArgs) => - ipcRenderer.invoke('terminal:writeRenderDesyncEvidence', args) - }, - - orcaProfiles: { - list: () => ipcRenderer.invoke('orcaProfiles:list'), - authStatus: () => ipcRenderer.invoke('orcaProfiles:authStatus'), - createLocal: (args) => ipcRenderer.invoke('orcaProfiles:createLocal', args), - createCloudLinked: (args) => ipcRenderer.invoke('orcaProfiles:createCloudLinked', args), - switchProfile: (args) => ipcRenderer.invoke('orcaProfiles:switch', args), - transferProject: (args) => ipcRenderer.invoke('orcaProfiles:transferProject', args), - findProjectProfiles: (args) => ipcRenderer.invoke('orcaProfiles:findProjectProfiles', args), - connectCurrent: () => ipcRenderer.invoke('orcaProfiles:connectCurrent'), - refreshAuth: () => ipcRenderer.invoke('orcaProfiles:refreshAuth'), - signOutCurrent: () => ipcRenderer.invoke('orcaProfiles:signOutCurrent'), - selectOrg: (args) => ipcRenderer.invoke('orcaProfiles:selectOrg', args), - orgMembersList: (args) => ipcRenderer.invoke('orcaProfiles:orgMembersList', args), - orgMemberInvite: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberInvite', args), - orgInviteRevoke: (args) => ipcRenderer.invoke('orcaProfiles:orgInviteRevoke', args), - orgMemberChangeRole: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberChangeRole', args), - orgMemberRemove: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberRemove', args) - } satisfies PreloadApi['orcaProfiles'], - - platform: { - get: () => ({ - platform: process.platform, - // Why: sandboxed preload cannot require node:os; Electron exposes the OS - // version on process.getSystemVersion when available. - osRelease: - (process as NodeJS.Process & { getSystemVersion?: () => string }).getSystemVersion?.() ?? - '', - arch: process.arch, - // Why: these identify the default shell without probing user config files. - // process.env is available in the sandboxed preload; node:os is not. - shell: process.env.SHELL?.trim() || process.env.ComSpec?.trim() || '', - displayServer: getLinuxDisplayServer() - }) - } satisfies PreloadApi['platform'], - - wsl: { - isAvailable: (): Promise => ipcRenderer.invoke('wsl:isAvailable'), - listDistros: (): Promise => ipcRenderer.invoke('wsl:listDistros') - }, - - pwsh: { - isAvailable: (): Promise => ipcRenderer.invoke('pwsh:isAvailable') - }, - - gitBash: { - isAvailable: (): Promise => ipcRenderer.invoke('gitBash:isAvailable') - }, - - plugins: { - list: (): Promise => ipcRenderer.invoke('plugins:list'), - listLanguagePacks: () => ipcRenderer.invoke('plugins:listLanguagePacks'), - consent: (args: PluginConsentRequest): Promise => - ipcRenderer.invoke('plugins:consent', args), - setEnabled: (args: { pluginKey: string; enabled: boolean }): Promise => - ipcRenderer.invoke('plugins:setEnabled', args), - readPanelEntry: (args: { - pluginKey: string - panelId: string - }): Promise => ipcRenderer.invoke('plugins:readPanelEntry', args), - invokeCommand: (args: { - pluginKey: string - commandId: string - args?: unknown - }): Promise => ipcRenderer.invoke('plugins:invokeCommand', args), - panelAction: (args: { - sessionToken: string - action: string - params?: unknown - }): Promise => ipcRenderer.invoke('plugins:panelAction', args), - install: (source: PluginHostInstallSource): Promise => - ipcRenderer.invoke('plugins:install', source), - listMarketplaces: () => ipcRenderer.invoke('plugins:listMarketplaces'), - addMarketplace: (source) => ipcRenderer.invoke('plugins:addMarketplace', source), - removeMarketplace: (args) => ipcRenderer.invoke('plugins:removeMarketplace', args), - refreshMarketplaces: (args = {}) => ipcRenderer.invoke('plugins:refreshMarketplaces', args), - listMarketplacePlugins: () => ipcRenderer.invoke('plugins:listMarketplacePlugins'), - previewMarketplacePlugin: (args) => - ipcRenderer.invoke('plugins:previewMarketplacePlugin', args), - installMarketplacePlugin: (preview) => - ipcRenderer.invoke('plugins:installMarketplacePlugin', preview), - previewMarketplaceUpdate: (args) => - ipcRenderer.invoke('plugins:previewMarketplaceUpdate', args), - rollbackMarketplacePlugin: (args) => - ipcRenderer.invoke('plugins:rollbackMarketplacePlugin', args), - remove: (args: { pluginKey: string }): Promise => - ipcRenderer.invoke('plugins:remove', args), - getLogs: (args: { pluginKey: string }): Promise => - ipcRenderer.invoke('plugins:getLogs', args), - refresh: (): Promise => ipcRenderer.invoke('plugins:refresh'), - onChanged: (callback): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, change: PluginChangeEvent): void => - callback(change) - ipcRenderer.on('plugins:changed', listener) - return () => { - ipcRenderer.removeListener('plugins:changed', listener) - } - } - } satisfies PreloadApi['plugins'], - - repos: { - list: () => ipcRenderer.invoke('repos:list'), - - listForExecutionHost: (args: ListReposForExecutionHostArgs): Promise => - ipcRenderer.invoke('repos:listForExecutionHost', args), - - add: (args) => ipcRenderer.invoke('repos:add', args), - - addRemote: (args) => ipcRenderer.invoke('repos:addRemote', args), - - create: (args) => ipcRenderer.invoke('repos:create', args), - - isGitAvailable: (): Promise => ipcRenderer.invoke('repos:isGitAvailable'), - - getDefaultCreateProjectParent: (): Promise => - ipcRenderer.invoke('repos:getDefaultCreateProjectParent'), - - remove: (args) => ipcRenderer.invoke('repos:remove', args), - - removeForHost: (args) => ipcRenderer.invoke('repos:removeForHost', args), - - reorder: (args) => ipcRenderer.invoke('repos:reorder', args), - - reorderForHost: (args) => ipcRenderer.invoke('repos:reorderForHost', args), - - update: (args) => ipcRenderer.invoke('repos:update', args), - - pickFolder: () => ipcRenderer.invoke('repos:pickFolder'), - - pickFolders: () => ipcRenderer.invoke('repos:pickFolders'), - - pickDirectory: () => ipcRenderer.invoke('repos:pickDirectory'), - - clone: (args) => ipcRenderer.invoke('repos:clone', args), - - cloneRemote: (args) => ipcRenderer.invoke('repos:cloneRemote', args), - - createRemote: (args) => ipcRenderer.invoke('repos:createRemote', args), - - cloneAbort: () => ipcRenderer.invoke('repos:cloneAbort'), - - onCloneProgress: ( - callback: (data: { phase: string; percent: number }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { phase: string; percent: number } - ) => callback(data) - ipcRenderer.on('repos:clone-progress', listener) - return () => ipcRenderer.removeListener('repos:clone-progress', listener) - }, - - getGitUsername: (args: { repoId: string }): Promise => - ipcRenderer.invoke('repos:getGitUsername', args), - - getBaseRefDefault: (args: { - repoId: string - hostId?: ExecutionHostId - }): Promise => ipcRenderer.invoke('repos:getBaseRefDefault', args), - - searchBaseRefs: (args: { - repoId: string - query: string - limit?: number - hostId?: ExecutionHostId - }): Promise => ipcRenderer.invoke('repos:searchBaseRefs', args), - - searchBaseRefDetails: (args: { - repoId: string - query: string - limit?: number - hostId?: ExecutionHostId - }): Promise => ipcRenderer.invoke('repos:searchBaseRefDetails', args), - - onChanged: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('repos:changed', listener) - return () => ipcRenderer.removeListener('repos:changed', listener) - } - } satisfies PreloadApi['repos'], - - projects: { - list: () => ipcRenderer.invoke('projects:list'), - update: (args) => ipcRenderer.invoke('projects:update', args), - listHostSetups: () => ipcRenderer.invoke('projectHostSetups:list'), - createHostSetup: (args) => ipcRenderer.invoke('projectHostSetups:create', args), - setupExistingFolder: (args) => - ipcRenderer.invoke('projectHostSetups:setupExistingFolder', args), - updateHostSetup: (args) => ipcRenderer.invoke('projectHostSetups:update', args), - deleteHostSetup: (args) => ipcRenderer.invoke('projectHostSetups:delete', args) - } satisfies PreloadApi['projects'], - - projectGroups: { - list: () => ipcRenderer.invoke('projectGroups:list'), - create: (args) => ipcRenderer.invoke('projectGroups:create', args), - update: (args) => ipcRenderer.invoke('projectGroups:update', args), - delete: (args) => ipcRenderer.invoke('projectGroups:delete', args), - moveProject: (args) => ipcRenderer.invoke('projectGroups:moveProject', args), - scanNested: (args) => ipcRenderer.invoke('projectGroups:scanNested', args), - cancelNestedScan: (args) => ipcRenderer.invoke('projectGroups:cancelNestedScan', args), - onNestedScanProgress: (callback) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { scanId: string; scan: NestedRepoScanResult } - ) => callback(data) - ipcRenderer.on('projectGroups:scanNestedProgress', listener) - return () => ipcRenderer.removeListener('projectGroups:scanNestedProgress', listener) - }, - importNested: (args) => ipcRenderer.invoke('projectGroups:importNested', args) - } satisfies PreloadApi['projectGroups'], - - folderWorkspaces: { - list: () => ipcRenderer.invoke('folderWorkspaces:list'), - getPathStatus: (args) => ipcRenderer.invoke('folderWorkspaces:getPathStatus', args), - create: (args) => ipcRenderer.invoke('folderWorkspaces:create', args), - update: (args) => ipcRenderer.invoke('folderWorkspaces:update', args), - delete: (args) => ipcRenderer.invoke('folderWorkspaces:delete', args) - } satisfies PreloadApi['folderWorkspaces'], - - sparsePresets: { - list: (args) => ipcRenderer.invoke('sparsePresets:list', args), - - save: (args) => ipcRenderer.invoke('sparsePresets:save', args), - - remove: (args) => ipcRenderer.invoke('sparsePresets:remove', args), - - onChanged: (callback: (data: { repoId: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { repoId: string }) => - callback(data) - ipcRenderer.on('sparsePresets:changed', listener) - return () => ipcRenderer.removeListener('sparsePresets:changed', listener) - } - } satisfies PreloadApi['sparsePresets'], - - worktrees: { - list: (args) => ipcRenderer.invoke('worktrees:list', args), - listRetiredNames: (args) => ipcRenderer.invoke('worktrees:listRetiredNames', args), - - listDetected: (args) => ipcRenderer.invoke('worktrees:listDetected', args), - - listKnownForExecutionHost: (args) => - ipcRenderer.invoke('worktrees:listKnownForExecutionHost', args), - - forgetRemovedForExecutionHost: (args) => - ipcRenderer.invoke('worktrees:forgetRemovedForExecutionHost', args), - - cancelListDetected: (args) => ipcRenderer.invoke('worktrees:cancelListDetected', args), - - listAll: () => ipcRenderer.invoke('worktrees:listAll'), - - create: (args) => ipcRenderer.invoke('worktrees:create', args), - - adoptProvisionedRoot: (args) => ipcRenderer.invoke('worktrees:adoptProvisionedRoot', args), - - onCreateProgress: ( - callback: (data: { creationId?: string; phase: 'fetching' | 'creating' }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { creationId?: string; phase: 'fetching' | 'creating' } - ) => callback(data) - ipcRenderer.on('createWorktree:progress', listener) - return () => ipcRenderer.removeListener('createWorktree:progress', listener) - }, - - prefetchCreateBase: (args) => ipcRenderer.invoke('worktrees:prefetchCreateBase', args), - - resolvePrBase: (args) => ipcRenderer.invoke('worktrees:resolvePrBase', args), - - resolveMrBase: (args) => ipcRenderer.invoke('worktrees:resolveMrBase', args), - - remove: (args) => ipcRenderer.invoke('worktrees:remove', args), - - forgetLocal: (args) => ipcRenderer.invoke('worktrees:forgetLocal', args), - - forceDeletePreservedBranch: (args) => - ipcRenderer.invoke('worktrees:forceDeletePreservedBranch', args), - - updateMeta: (args) => ipcRenderer.invoke('worktrees:updateMeta', args), - - listLineage: () => ipcRenderer.invoke('worktrees:listLineage'), - - listLineageForHost: (args: ListDesktopLineageForHostArgs): Promise => - ipcRenderer.invoke('worktrees:listLineageForHost', args), - - updateLineage: (args) => ipcRenderer.invoke('worktrees:updateLineage', args), - - persistSortOrder: (args) => ipcRenderer.invoke('worktrees:persistSortOrder', args), - - getBranchRenameFailureOutput: (args) => - ipcRenderer.invoke('worktrees:getBranchRenameFailureOutput', args), - - onChanged: ( - callback: (data: { - repoId: string - renamed?: { oldWorktreeId: string; newWorktreeId: string } - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { repoId: string; renamed?: { oldWorktreeId: string; newWorktreeId: string } } - ) => callback(data) - ipcRenderer.on('worktrees:changed', listener) - return () => ipcRenderer.removeListener('worktrees:changed', listener) - }, - - onGitStatusMetadataChanged: (callback: (data: { repoId: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { repoId: string }) => - callback(data) - ipcRenderer.on('worktrees:gitStatusMetadataChanged', listener) - return () => ipcRenderer.removeListener('worktrees:gitStatusMetadataChanged', listener) - }, - - onHeadIdentitiesChanged: ( - callback: (data: { repoId: string; identities: WorktreeHeadIdentity[] }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { repoId: string; identities: WorktreeHeadIdentity[] } - ) => callback(data) - ipcRenderer.on('worktrees:headIdentitiesChanged', listener) - return () => ipcRenderer.removeListener('worktrees:headIdentitiesChanged', listener) - }, - - onBaseStatus: (callback: (data: WorktreeBaseStatusEvent) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: WorktreeBaseStatusEvent) => - callback(data) - ipcRenderer.on('worktree:baseStatus', listener) - return () => ipcRenderer.removeListener('worktree:baseStatus', listener) - }, - - onRemoteBranchConflict: ( - callback: (data: WorktreeRemoteBranchConflictEvent) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: WorktreeRemoteBranchConflictEvent - ) => callback(data) - ipcRenderer.on('worktree:remoteBranchConflict', listener) - return () => ipcRenderer.removeListener('worktree:remoteBranchConflict', listener) - } - } satisfies PreloadApi['worktrees'], - - workspaceCleanup: { - scan: (args, onProgress) => { - if (!onProgress) { - return ipcRenderer.invoke('workspaceCleanup:scan', args) - } - const scanId = args?.scanId ?? crypto.randomUUID() - const listener = ( - _event: Electron.IpcRendererEvent, - progress: WorkspaceCleanupScanProgress - ): void => { - if (progress.scanId === scanId) { - onProgress(progress) - } - } - ipcRenderer.on('workspaceCleanup:scanProgress', listener) - return ipcRenderer - .invoke('workspaceCleanup:scan', { ...args, scanId }) - .finally(() => ipcRenderer.removeListener('workspaceCleanup:scanProgress', listener)) - }, - cancelScan: (scanId) => ipcRenderer.invoke('workspaceCleanup:cancelScan', scanId), - getCachedScan: () => ipcRenderer.invoke('workspaceCleanup:getCachedScan'), - dismiss: (args) => ipcRenderer.invoke('workspaceCleanup:dismiss', args), - clearDismissals: () => ipcRenderer.invoke('workspaceCleanup:clearDismissals'), - hasKillableLocalProcesses: (args) => - ipcRenderer.invoke('workspaceCleanup:hasKillableLocalProcesses', args), - beginRemovalSnapshotPruneBatch: (args) => - ipcRenderer.invoke('workspaceCleanup:beginRemovalSnapshotPruneBatch', args), - recordRemovalSnapshotPrune: (args) => - ipcRenderer.invoke('workspaceCleanup:recordRemovalSnapshotPrune', args), - finishRemovalSnapshotPruneBatch: (args) => - ipcRenderer.invoke('workspaceCleanup:finishRemovalSnapshotPruneBatch', args) - } satisfies PreloadApi['workspaceCleanup'], - - workspaceSpace: { - analyze: () => ipcRenderer.invoke('workspaceSpace:analyze'), - getCachedAnalysis: () => ipcRenderer.invoke('workspaceSpace:getCachedAnalysis'), - cancel: () => ipcRenderer.invoke('workspaceSpace:cancel'), - onProgress: (callback) => { - const listener = ( - _event: Electron.IpcRendererEvent, - progress: WorkspaceSpaceScanProgress - ): void => callback(progress) - ipcRenderer.on('workspaceSpace:progress', listener) - return () => ipcRenderer.removeListener('workspaceSpace:progress', listener) - } - } satisfies PreloadApi['workspaceSpace'], - - workspacePorts: { - scan: (args) => ipcRenderer.invoke('workspacePorts:scan', args), - kill: (args) => ipcRenderer.invoke('workspacePorts:kill', args), - onAdvertisedUrlChanged: (callback) => { - const listener = ( - _event: Electron.IpcRendererEvent, - event: WorkspacePortAdvertisedUrlChangedEvent - ): void => callback(event) - ipcRenderer.on('workspacePorts:advertised-url-changed', listener) - return () => ipcRenderer.removeListener('workspacePorts:advertised-url-changed', listener) - } - } satisfies PreloadApi['workspacePorts'], - - pty: { - spawn: (opts: { - cols: number - rows: number - cwd?: string - cwdFallback?: 'worktree' - env?: Record - envToDelete?: string[] - command?: string - commandDelivery?: 'renderer' | 'provider' - launchConfig?: SleepingAgentLaunchConfig - resumeProviderSession?: AgentProviderSessionMetadata - launchToken?: string - launchAgent?: TuiAgent - startupCommandDelivery?: StartupCommandDelivery - connectionId?: string | null - worktreeId?: string - sessionId?: string - shellOverride?: string - projectRuntime?: ProjectExecutionRuntimeResolution - terminalColorQueryReplies?: { foreground?: string; background?: string } - // Why: marks the PTY hidden before its first byte so the delivery gate + model responder own spawn-time queries (terminal-query-authority.md §races). - initiallyHidden?: boolean - // Why: closes the SIGKILL race (INVESTIGATION.md) — main sync-flushes the (worktreeId, tabId, leafId → ptyId) binding before pty:spawn returns. - tabId?: string - leafId?: string - // Why: loose typing on purpose — renderer owns launch metadata, main owns whether the launch happened and validates (telemetry-plan.md §Agent launch semantics). - telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } - }): Promise<{ - id: string - /** Which lifetime of `id` this reply named; absent when the execution host predates the field. */ - incarnationId?: string - launchConfig?: SleepingAgentLaunchConfig - snapshot?: string - snapshotCols?: number - snapshotRows?: number - snapshotPrefixAnsi?: string - snapshotFrameAnsi?: string - snapshotFrameRestoreAnsi?: string - snapshotKittyKeyboardFlags?: number - snapshotTerminalOwner?: 'shell' - snapshotSeq?: number - isReattach?: boolean - isAlternateScreen?: boolean - replay?: string - sessionExpired?: boolean - coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } - startupCwdFallback?: { kind: 'worktree'; cwd: string } - agentResumeUnavailable?: true - }> => ipcRenderer.invoke('pty:spawn', opts), - - write: (id: string, data: string): void => { - ipcRenderer.send('pty:write', { id, data }) - }, - writeAccepted: (id: string, data: string): Promise => - ipcRenderer.invoke('pty:writeAccepted', { id, data }), - onWriteUnavailable: ( - callback: (payload: { - id: string - /** Set only when a durable agent-session lease refused the write; absent otherwise. */ - agentSessionRefusal?: AgentSessionPtyWriteRefusal - }) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - payload: { id: string; agentSessionRefusal?: AgentSessionPtyWriteRefusal } - ): void => callback(payload) - ipcRenderer.on('pty:writeUnavailable', handler) - return () => ipcRenderer.removeListener('pty:writeUnavailable', handler) - }, - - resize: (id: string, cols: number, rows: number): void => { - ipcRenderer.send('pty:resize', { id, cols, rows }) - }, - claimViewport: (id: string, cols: number, rows: number): void => { - ipcRenderer.send('pty:claimViewport', { id, cols, rows }) - }, - - /** Why: measurement-only sibling of resize — keeps the runtime's restore-target baseline fresh while a mobile-fit override blocks pty:resize. Never resizes the PTY. See docs/mobile-fit-hold.md. */ - reportGeometry: (id: string, cols: number, rows: number): void => { - ipcRenderer.send('pty:reportGeometry', { id, cols, rows }) - }, - - signal: (id: string, signal: string): void => { - ipcRenderer.send('pty:signal', { id, signal }) - }, - - /** Why: Cmd/Ctrl+K clears the renderer xterm, but the PTY host keeps its own screen state and would repaint the next prompt at the stale cursor row. */ - clearBuffer: (id: string): void => { - ipcRenderer.send('pty:clearBuffer', { id }) - }, - - ackColdRestore: (id: string): void => { - ipcRenderer.send('pty:ackColdRestore', { id }) - }, - /** charCount is the legacy per-chunk delta; processedChars is the cumulative per-pty total (self-heals under lost ACKs). */ - ackData: (id: string, charCount: number, processedChars?: number): void => { - ipcRenderer.send('pty:ackData', { - id, - charCount, - ...(typeof processedChars === 'number' ? { processedChars } : {}) - }) - }, - /** Main requests the renderer's cumulative processed totals when delivery looks stuck on lost ACKs. */ - onDeliveryResyncRequest: (callback: (payload: { requestId: number }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: { requestId: number }) => - callback(payload) - ipcRenderer.on('pty:requestDeliveryResync', listener) - return () => ipcRenderer.removeListener('pty:requestDeliveryResync', listener) - }, - respondDeliveryResync: (payload: { - requestId: number - processedCharsByPty: Record - }): void => { - ipcRenderer.send('pty:deliveryResyncResponse', payload) - }, - /** Renderer-initiated delivery health/heal lane — rides invoke because the field wedge (v1.4.121-rc.0) kills main→renderer push while invoke stays alive. */ - reportRendererDeliveryState: ( - report: PtyRendererDeliveryStateReport - ): Promise => - ipcRenderer.invoke('pty:reportRendererDeliveryState', report), - /** Live pty:data listener count — the watchdog's "listener detached" vs "channel dead" discriminator. */ - getPtyDataListenerCount: (): number => ipcRenderer.listenerCount('pty:data'), - rendererDispatcherReady: (): void => { - ipcRenderer.send('pty:rendererDispatcherReady') - }, - setActiveRendererPty: (id: string, active: boolean): void => { - ipcRenderer.send('pty:setActiveRendererPty', { id, active }) - }, - setRendererPtyVisible: (id: string, visible: boolean): void => { - ipcRenderer.send('pty:setRendererPtyVisible', { id, visible }) - }, - /** Hidden-delivery gate: hidden=true lets main DROP renderer byte delivery after model ingestion; reveal restores from the model snapshot. Fire-and-forget. */ - setHiddenRendererPty: (id: string, hidden: boolean): void => { - ipcRenderer.send('pty:setHiddenRendererPty', { id, hidden }) - }, - /** Delivery-interest signal: a renderer party needing raw bytes suppresses the hidden-delivery gate for that PTY while registered. */ - setPtyDeliveryInterest: (id: string, interested: boolean): void => { - ipcRenderer.send('pty:setPtyDeliveryInterest', { id, interested }) - }, - /** Push composed terminal appearance so main's model responder can answer OSC 4/10/11/12 and DSR ?996n for hidden-gated PTYs with renderer-true values. */ - publishTerminalViewAttributes: (attributes: TerminalViewAttributes): void => { - ipcRenderer.send('pty:terminalViewAttributes', attributes) - }, - - kill: (id: string, opts?: { keepHistory?: boolean }): Promise => - ipcRenderer.invoke('pty:kill', { id, keepHistory: opts?.keepHistory ?? false }), - - listSessions: (): Promise => ipcRenderer.invoke('pty:listSessions'), - getAuthoritativeBufferSnapshotCapabilities: ( - ids: string[] - ): Promise<{ id: string; authoritative: boolean | null }[]> => - ipcRenderer.invoke('pty:getAuthoritativeBufferSnapshotCapabilities', { ids }), - hasPty: (id: string): Promise => ipcRenderer.invoke('pty:hasPty', { id }), - - getMainBufferSnapshot: ( - id: string, - opts?: { scrollbackRows?: number } - ): Promise<{ - data: string - frameRestoreAnsi?: string - cols: number - rows: number - cwd?: string | null - seq?: number - pendingDeliveryStartSeq?: number - source?: 'headless' | 'renderer' - alternateScreen?: boolean - scrollbackAnsi?: string - pendingEscapeTailAnsi?: string - kittyKeyboardFlags?: number - terminalOwner?: 'shell' - } | null> => ipcRenderer.invoke('pty:getMainBufferSnapshot', { id, opts }), - - getRendererDeliveryDebugSnapshot: (): Promise<{ - pendingPtyCount: number - pendingChars: number - maxPendingCharsByPty: number - rendererInFlightPtyCount: number - rendererInFlightChars: number - maxRendererInFlightCharsByPty: number - activeRendererPtyCount: number - flushScheduled: boolean - peakPendingChars: number - peakMaxPendingCharsByPty: number - peakRendererInFlightChars: number - peakMaxRendererInFlightCharsByPty: number - ackGatedFlushSkipCount: number - hiddenDeliveryGatedPtyCount: number - hiddenDeliveryGatedVisiblePtyCount: number - hiddenDeliveryGatedActivePtyCount: number - deliveryInterestPtyCount: number - hiddenDeliveryDroppedChars: number - hiddenDeliveryDroppedChunks: number - pendingDroppedChars: number - diagnostics: PtyMainDeliveryDiagnostics - rendererLifecycleResetCount: number - lastLifecycleResetClearedChars: number - rendererPtyDispatcherReady: boolean - rendererDispatcherReadyForcedCount: number - }> => ipcRenderer.invoke('pty:getRendererDeliveryDebugSnapshot'), - - resetRendererDeliveryDebug: (): Promise => - ipcRenderer.invoke('pty:resetRendererDeliveryDebug'), - - /** True if the PTY's shell has child processes (a running command); false at an idle prompt. */ - hasChildProcesses: (id: string): Promise => - ipcRenderer.invoke('pty:hasChildProcesses', { id }), - - /** Return the PTY foreground process basename when available (e.g. "codex"). */ - getForegroundProcess: (id: string): Promise => - ipcRenderer.invoke('pty:getForegroundProcess', { id }), - inspectProcess: ( - id: string - ): Promise<{ - foregroundProcess: string | null - hasChildProcesses: boolean - unavailable?: true - }> => ipcRenderer.invoke('pty:inspectProcess', { id }), - confirmForegroundProcess: (id: string): Promise => - ipcRenderer.invoke('pty:confirmForegroundProcess', { id }), - - /** Resolve a PTY's live cwd via `/proc` (Linux) or `lsof` (macOS); `''` when unknown or unresolvable. */ - getCwd: (id: string): Promise => ipcRenderer.invoke('pty:getCwd', { id }), - - /** The PTY's last APPLIED size (real winsize), or null if unknown — lets the renderer detect drift after a dropped resize and re-assert. */ - getSize: (id: string): Promise<{ cols: number; rows: number } | null> => - ipcRenderer.invoke('pty:getSize', { id }), - - onData: ( - callback: (data: { - id: string - data: string - seq?: number - rawLength?: number - transformed?: boolean - background?: boolean - droppedOutput?: boolean - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - id: string - data: string - seq?: number - rawLength?: number - transformed?: boolean - background?: boolean - droppedOutput?: boolean - } - ) => callback(data) - ipcRenderer.on('pty:data', listener) - return () => ipcRenderer.removeListener('pty:data', listener) - }, - - onReplay: (callback: (data: { id: string; data: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { id: string; data: string }) => - callback(data) - ipcRenderer.on('pty:replay', listener) - return () => ipcRenderer.removeListener('pty:replay', listener) - }, - - /** Out-of-band signal that main dropped renderer-bound bytes (hidden-gate / pending cap); pane restores from the model snapshot. - * NOT on pty:data — an in-band marker is ambiguous with chunks fully stripped by OSC-9999 cleaning. */ - onModelRestoreNeeded: (callback: (event: PtyModelRestoreNeededEvent) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, event: PtyModelRestoreNeededEvent) => - callback(event) - ipcRenderer.on('pty:modelRestoreNeeded', listener) - return () => ipcRenderer.removeListener('pty:modelRestoreNeeded', listener) - }, - - /** Batched side-effect facts (title/bell/agent transitions) for local-main PTYs. - * Per-PTY in-order; deliberately NOT synced with pty:data (terminal-side-effect-authority.md). */ - onSideEffect: (callback: (batch: TerminalSideEffectBatch) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, batch: TerminalSideEffectBatch) => - callback(batch) - ipcRenderer.on('pty:sideEffect', listener) - return () => ipcRenderer.removeListener('pty:sideEffect', listener) - }, - - /** Title-only replay snapshot on (re)attach — attention facts (bells/completions) never replay. */ - getSideEffectSnapshot: (id: string): Promise => - ipcRenderer.invoke('pty:sideEffectSnapshot', { id }), - - onExit: ( - callback: (data: { - id: string - code: number - preserveRendererBinding?: boolean - /** Which lifetime of `id` died; absent when the execution host predates the field. */ - incarnationId?: string - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - id: string - code: number - preserveRendererBinding?: boolean - incarnationId?: string - } - ) => callback(data) - ipcRenderer.on('pty:exit', listener) - return () => ipcRenderer.removeListener('pty:exit', listener) - }, - - onSpawned: (callback: (data: { id: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { id: string }) => callback(data) - ipcRenderer.on('pty:spawned', listener) - return () => ipcRenderer.removeListener('pty:spawned', listener) - }, - - onSerializeBufferRequest: ( - callback: (data: { - requestId: string - ptyId: string - opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - requestId: string - ptyId: string - opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } - } - ) => callback(data) - ipcRenderer.on('pty:serializeBuffer:request', listener) - return () => ipcRenderer.removeListener('pty:serializeBuffer:request', listener) - }, - - onClearBufferRequest: (callback: (data: { ptyId: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { ptyId: string }) => - callback(data) - ipcRenderer.on('pty:clearBuffer:request', listener) - return () => ipcRenderer.removeListener('pty:clearBuffer:request', listener) - }, - - sendSerializedBuffer: ( - requestId: string, - snapshot: { - data: string - cols: number - rows: number - seq?: number - lastTitle?: string - kittyKeyboardFlags?: number - } | null - ): void => { - ipcRenderer.send('pty:serializeBuffer:response', { requestId, snapshot }) - }, - - // Claim serializer ownership before spawn; echo the generation token on settle/clear to prevent pane-key reuse races. - declarePendingPaneSerializer: (paneKey: string): Promise => - ipcRenderer.invoke('pty:declarePendingPaneSerializer', { paneKey }), - - settlePaneSerializer: (paneKey: string, gen: number): Promise => - ipcRenderer.invoke('pty:settlePaneSerializer', { paneKey, gen }), - - clearPendingPaneSerializer: (paneKey: string, gen: number): Promise => - ipcRenderer.invoke('pty:clearPendingPaneSerializer', { paneKey, gen }), - - reportRendererSerializerReady: (ptyId: string): Promise => - ipcRenderer.invoke('pty:reportRendererSerializerReady', { ptyId }), - - management: { - listSessions: () => ipcRenderer.invoke('pty:management:listSessions'), - killAll: () => ipcRenderer.invoke('pty:management:killAll'), - killOne: (args: { sessionId: string }) => ipcRenderer.invoke('pty:management:killOne', args), - restart: () => ipcRenderer.invoke('pty:management:restart'), - macTccAttribution: () => ipcRenderer.invoke('pty:management:macTccAttribution') - } - }, - - feedback: { - submit: (args: { - feedback: string - submitAnonymously?: boolean - githubLogin: string | null - githubEmail: string | null - images?: { contentType: string; data: Uint8Array }[] - }): Promise< - { ok: true; imagesDelivered?: boolean } | { ok: false; status: number | null; error: string } - > => ipcRenderer.invoke('feedback:submit', args) - }, - - crashReports: { - getLatestPending: () => ipcRenderer.invoke('crashReports:getLatestPending'), - getLatestReport: () => ipcRenderer.invoke('crashReports:getLatestReport'), - dismiss: (args: { reportId: string }) => ipcRenderer.invoke('crashReports:dismiss', args), - recordRendererError: ( - args: ReactErrorBoundaryReportArgs - ): Promise => - ipcRenderer.invoke('crashReports:recordRendererError', args), - recordBreadcrumb: (args: { name: string; data?: CrashReportBreadcrumbData }): void => - ipcRenderer.send('crashReports:recordBreadcrumb', args), - submit: (args: CrashReportSubmitArgs): Promise => - ipcRenderer.invoke('crashReports:submit', args), - copyLatestDiagnostics: (args?: CrashReportCopyDiagnosticsArgs) => - ipcRenderer.invoke('crashReports:copyLatestDiagnostics', args), - readHeapStatistics: (): RendererHeapStatistics | null => readRendererHeapStatistics(), - readProcessMemory: (): Promise => readRendererProcessMemory() - }, - - export: { - htmlToPdf: (args: { - html: string - title: string - }): Promise< - { success: true; filePath: string } | { success: false; cancelled?: boolean; error?: string } - > => ipcRenderer.invoke('export:html-to-pdf', args) - }, - - gh: { - viewer: (): Promise => ipcRenderer.invoke('gh:viewer'), - - repoSlug: (args: { repoPath: string; repoId?: string }): Promise => - ipcRenderer.invoke('gh:repoSlug', args), - - repoUpstream: (args: { repoPath: string; repoId?: string }): Promise => - ipcRenderer.invoke('gh:repoUpstream', args), - - prForBranch: (args: { - repoPath: string - repoId?: string - branch: string - linkedPRNumber?: number | null - fallbackPRNumber?: number | null - acceptMergedFallbackPR?: boolean - currentHeadOid?: string | null - }): Promise => ipcRenderer.invoke('gh:prForBranch', args), - - refreshPRNow: (args: { - candidate: GitHubPRRefreshCandidate - reason?: GitHubPRRefreshReason - }): Promise => ipcRenderer.invoke('gh:refreshPRNow', args), - - enqueuePRRefresh: (args: { - candidate: GitHubPRRefreshCandidate - reason: GitHubPRRefreshReason - priority?: number - }): Promise => ipcRenderer.invoke('gh:enqueuePRRefresh', args), - - reportVisiblePRRefreshCandidates: (args: { - candidates: GitHubPRRefreshCandidate[] - generation: number - }): Promise => ipcRenderer.invoke('gh:reportVisiblePRRefreshCandidates', args), - - onPRRefreshEvent: (callback: (event: GitHubPRRefreshEvent) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, event: GitHubPRRefreshEvent): void => - callback(event) - ipcRenderer.on('gh:prRefreshEvent', listener) - return () => ipcRenderer.removeListener('gh:prRefreshEvent', listener) - }, - - issue: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - number: number - }): Promise => ipcRenderer.invoke('gh:issue', args), - - workItem: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - number: number - type?: 'issue' | 'pr' - }): Promise => ipcRenderer.invoke('gh:workItem', args), - - workItemByOwnerRepo: (args: { - repoPath: string - repoId?: string - owner: string - repo: string - host?: string - number: number - type: 'issue' | 'pr' - }): Promise => ipcRenderer.invoke('gh:workItemByOwnerRepo', args), - - workItemDetails: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - number: number - type?: 'issue' | 'pr' - }): Promise => ipcRenderer.invoke('gh:workItemDetails', args), - - notifyWorkItemMutated: (args: { - repoPath: string - repoId?: string - type: 'issue' | 'pr' - number: number - }): Promise => ipcRenderer.invoke('gh:notifyWorkItemMutated', args), - - prFileContents: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - path: string - oldPath?: string - status: string - headSha: string - baseSha: string - }): Promise => ipcRenderer.invoke('gh:prFileContents', args), - - listIssues: (args: { repoPath: string; repoId?: string; limit?: number }): Promise => - ipcRenderer.invoke('gh:listIssues', args), - - createIssue: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - title: string - body: string - labels?: string[] - assignees?: string[] - }): Promise => ipcRenderer.invoke('gh:createIssue', args), - - countWorkItems: (args: { - repoPath: string - repoId?: string - query?: string - }): Promise => ipcRenderer.invoke('gh:countWorkItems', args), - - listWorkItems: (args: { - repoPath: string - repoId?: string - limit?: number - query?: string - page?: number - noCache?: boolean - }): Promise>> => - ipcRenderer.invoke('gh:listWorkItems', args), - - prChecks: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - headSha?: string - prRepo?: GitHubOwnerRepo | null - noCache?: boolean - }): Promise => ipcRenderer.invoke('gh:prChecks', args), - - prCheckDetails: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - checkRunId?: number - workflowRunId?: number - checkName?: string - url?: string | null - prRepo?: GitHubOwnerRepo | null - }): Promise => ipcRenderer.invoke('gh:prCheckDetails', args), - - rerunPRChecks: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - headSha?: string - failedOnly?: boolean - prRepo?: GitHubOwnerRepo | null - }): Promise<{ ok: true; count: number } | { ok: false; error: string }> => - ipcRenderer.invoke('gh:rerunPRChecks', args), - - prComments: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - noCache?: boolean - }): Promise => ipcRenderer.invoke('gh:prComments', args), - - setPRCommentReaction: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - reactionSubjectId: string - content: GitHubReactionContent - reacted: boolean - prRepo?: GitHubOwnerRepo | null - }): Promise => ipcRenderer.invoke('gh:setPRCommentReaction', args), - - resolveReviewThread: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - threadId: string - resolve: boolean - prRepo?: GitHubOwnerRepo | null - }): Promise => ipcRenderer.invoke('gh:resolveReviewThread', args), - - setPRFileViewed: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - pullRequestId: string - path: string - viewed: boolean - }): Promise => ipcRenderer.invoke('gh:setPRFileViewed', args), - - updatePRTitle: (args: { - repoPath: string - repoId?: string - prNumber: number - title: string - prRepo?: GitHubOwnerRepo | null - }): Promise => ipcRenderer.invoke('gh:updatePRTitle', args), - - mergePR: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - method?: 'merge' | 'squash' | 'rebase' - prRepo?: GitHubOwnerRepo | null - }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('gh:mergePR', args), - - setPRAutoMerge: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - enabled: boolean - method?: 'merge' | 'squash' | 'rebase' - prRepo?: GitHubOwnerRepo | null - }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('gh:setPRAutoMerge', args), - - updatePRState: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - updates: { state: 'open' | 'closed' } - prRepo?: GitHubOwnerRepo | null - }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('gh:updatePRState', args), - - markPRReadyForReview: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('gh:markPRReadyForReview', args), - - requestPRReviewers: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - reviewers: string[] - prRepo?: GitHubOwnerRepo | null - }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('gh:requestPRReviewers', args), - - removePRReviewers: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - reviewers: string[] - prRepo?: GitHubOwnerRepo | null - }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('gh:removePRReviewers', args), - - updateIssue: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - number: number - updates: unknown - }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('gh:updateIssue', args), - - addIssueComment: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - number: number - body: string - type?: 'issue' | 'pr' - prRepo?: GitHubOwnerRepo | null - }): Promise => ipcRenderer.invoke('gh:addIssueComment', args), - - addPRReviewCommentReply: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - commentId: number - body: string - threadId?: string - path?: string - line?: number - prRepo?: GitHubOwnerRepo | null - }): Promise => ipcRenderer.invoke('gh:addPRReviewCommentReply', args), - - addPRReviewComment: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - commitId: string - path: string - line: number - startLine?: number - body: string - }): Promise => ipcRenderer.invoke('gh:addPRReviewComment', args), - - listLabels: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - }): Promise => ipcRenderer.invoke('gh:listLabels', args), - - listAssignableUsers: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - }): Promise => ipcRenderer.invoke('gh:listAssignableUsers', args), - - // Why: renderer owns the work-item cache; main fires this for non-origin mutations only (origin callers updated optimistically). See src/main/ipc/github.ts. - onWorkItemMutated: ( - callback: (payload: { - repoPath: string - repoId?: string - type: 'issue' | 'pr' - number: number - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload: { repoPath: string; repoId?: string; type: 'issue' | 'pr'; number: number } - ): void => callback(payload) - ipcRenderer.on('gh:workItemMutated', listener) - return () => ipcRenderer.removeListener('gh:workItemMutated', listener) - }, - - checkOrcaStarred: (): Promise => ipcRenderer.invoke('gh:checkOrcaStarred'), - starOrca: (source: AppStarSource): Promise => - ipcRenderer.invoke('gh:starOrca', source), - - // Why: rate_limit is exempt from rate-limit accounting; `force` still busts the 30s in-process cache after an expensive op. - rateLimit: (args?: { force?: boolean }): Promise => - ipcRenderer.invoke('gh:rateLimit', args), - - diagnoseAuth: (args?: { host?: string }): Promise => - ipcRenderer.invoke('gh:diagnoseAuth', args), - - // ── ProjectV2 (GitHub Projects) ─────────────────────────────────── - listAccessibleProjects: ( - args?: ListAccessibleProjectsArgs - ): Promise => - ipcRenderer.invoke('gh:listAccessibleProjects', args), - resolveProjectRef: (args: ResolveProjectRefArgs): Promise => - ipcRenderer.invoke('gh:resolveProjectRef', args), - listProjectViews: (args: ListProjectViewsArgs): Promise => - ipcRenderer.invoke('gh:listProjectViews', args), - getProjectViewTable: (args: GetProjectViewTableArgs): Promise => - ipcRenderer.invoke('gh:getProjectViewTable', args), - projectWorkItemDetailsBySlug: ( - args: ProjectWorkItemDetailsBySlugArgs - ): Promise => - ipcRenderer.invoke('gh:projectWorkItemDetailsBySlug', args), - updateProjectItemField: ( - args: UpdateProjectItemFieldArgs - ): Promise => - ipcRenderer.invoke('gh:updateProjectItemField', args), - clearProjectItemField: ( - args: ClearProjectItemFieldArgs - ): Promise => ipcRenderer.invoke('gh:clearProjectItemField', args), - updateIssueBySlug: (args: UpdateIssueBySlugArgs): Promise => - ipcRenderer.invoke('gh:updateIssueBySlug', args), - updatePullRequestBySlug: ( - args: UpdatePullRequestBySlugArgs - ): Promise => - ipcRenderer.invoke('gh:updatePullRequestBySlug', args), - addIssueCommentBySlug: ( - args: AddIssueCommentBySlugArgs - ): Promise => - ipcRenderer.invoke('gh:addIssueCommentBySlug', args), - updateIssueCommentBySlug: ( - args: UpdateIssueCommentBySlugArgs - ): Promise => - ipcRenderer.invoke('gh:updateIssueCommentBySlug', args), - deleteIssueCommentBySlug: ( - args: DeleteIssueCommentBySlugArgs - ): Promise => - ipcRenderer.invoke('gh:deleteIssueCommentBySlug', args), - listLabelsBySlug: (args: ListLabelsBySlugArgs): Promise => - ipcRenderer.invoke('gh:listLabelsBySlug', args), - listAssignableUsersBySlug: ( - args: ListAssignableUsersBySlugArgs - ): Promise => - ipcRenderer.invoke('gh:listAssignableUsersBySlug', args), - listIssueTypesBySlug: (args: ListIssueTypesBySlugArgs): Promise => - ipcRenderer.invoke('gh:listIssueTypesBySlug', args), - updateIssueTypeBySlug: ( - args: UpdateIssueTypeBySlugArgs - ): Promise => ipcRenderer.invoke('gh:updateIssueTypeBySlug', args) - }, - - hostedReview: { - forBranch: (args: HostedReviewForBranchArgs): Promise => - ipcRenderer.invoke('hostedReview:forBranch', args), - getCreationEligibility: (args: unknown): Promise => - ipcRenderer.invoke('hostedReview:getCreationEligibility', args), - create: (args: unknown): Promise => ipcRenderer.invoke('hostedReview:create', args), - createStacked: (args: unknown): Promise => - ipcRenderer.invoke('hostedReview:createStacked', args) - }, - - // Why: GitLab bindings live in `./gitlab` so `gl.*` changes don't conflict on every upstream sync of this central file. - gl: glApi, - - bitbucket: { - connect: (args: { - authMode: 'token' | 'basic' - accessToken?: string | null - email?: string | null - apiToken?: string | null - baseUrl?: string | null - }): Promise<{ ok: true; account: string | null } | { ok: false; error: string }> => - ipcRenderer.invoke('bitbucket:connect', args), - - disconnect: (): Promise => ipcRenderer.invoke('bitbucket:disconnect'), - - status: (): Promise => ipcRenderer.invoke('bitbucket:status') - }, - - linear: { - connect: (args: { - apiKey: string - }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => - ipcRenderer.invoke('linear:connect', args), - - disconnect: (args?: { workspaceId?: string }): Promise => - ipcRenderer.invoke('linear:disconnect', args), - - selectWorkspace: (args: { workspaceId: string }): Promise => - ipcRenderer.invoke('linear:selectWorkspace', args), - - status: (): Promise => ipcRenderer.invoke('linear:status'), - - testConnection: (args?: { - workspaceId?: string - }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => - ipcRenderer.invoke('linear:testConnection', args), - - searchIssues: (args: { - query: string - limit?: number - workspaceId?: string - }): Promise => ipcRenderer.invoke('linear:searchIssues', args), - - listIssues: (args?: { - filter?: 'assigned' | 'created' | 'all' | 'completed' - limit?: number - workspaceId?: string - attributeFilter?: unknown - }): Promise => ipcRenderer.invoke('linear:listIssues', args), - - createIssue: (args: { - teamId: string - title: string - description?: string - workspaceId?: string - parentIssueId?: string - projectId?: string | null - stateId?: string - priority?: number - assigneeId?: string | null - labelIds?: string[] - }): Promise< - | { ok: true; id: string; identifier: string; title: string; url: string } - | { ok: false; error: string } - > => ipcRenderer.invoke('linear:createIssue', args), - - getIssue: (args: { id: string; workspaceId?: string }): Promise => - ipcRenderer.invoke('linear:getIssue', args), - - updateIssue: (args: { - id: string - updates: unknown - workspaceId?: string - }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('linear:updateIssue', args), - - addIssueComment: (args: { - issueId: string - body: string - workspaceId?: string - }): Promise<{ ok: true; id: string } | { ok: false; error: string }> => - ipcRenderer.invoke('linear:addIssueComment', args), - - issueComments: (args: { issueId: string; workspaceId?: string }): Promise => - ipcRenderer.invoke('linear:issueComments', args), - - listTeams: (args?: { workspaceId?: string }): Promise => - ipcRenderer.invoke('linear:listTeams', args), - - listProjects: (args?: { - query?: string - limit?: number - workspaceId?: string - force?: boolean - }): Promise => ipcRenderer.invoke('linear:listProjects', args), - - createProject: (args: { - name: string - description?: string - content?: string - teamIds: string[] - workspaceId?: string - leadId?: string | null - memberIds?: string[] - labelIds?: string[] - priority?: number - startDate?: string - targetDate?: string - }): Promise<{ ok: true; project: LinearProjectDetail } | { ok: false; error: string }> => - ipcRenderer.invoke('linear:createProject', args), - - getProject: (args: { id: string; workspaceId: string; force?: boolean }): Promise => - ipcRenderer.invoke('linear:getProject', args), - - listProjectIssues: (args: { - projectId: string - limit?: number - workspaceId: string - force?: boolean - }): Promise => ipcRenderer.invoke('linear:listProjectIssues', args), - - listCustomViews: (args: { - model: string - limit?: number - workspaceId?: string - force?: boolean - }): Promise => ipcRenderer.invoke('linear:listCustomViews', args), - - getCustomView: (args: { - viewId: string - model: string - workspaceId: string - force?: boolean - }): Promise => ipcRenderer.invoke('linear:getCustomView', args), - - listCustomViewIssues: (args: { - viewId: string - limit?: number - workspaceId: string - force?: boolean - }): Promise => ipcRenderer.invoke('linear:listCustomViewIssues', args), - - listCustomViewProjects: (args: { - viewId: string - limit?: number - workspaceId: string - force?: boolean - }): Promise => ipcRenderer.invoke('linear:listCustomViewProjects', args), - - teamStates: (args: { teamId: string; workspaceId?: string }): Promise => - ipcRenderer.invoke('linear:teamStates', args), - - teamLabels: (args: { teamId: string; workspaceId?: string }): Promise => - ipcRenderer.invoke('linear:teamLabels', args), - - teamMembers: (args: { teamId: string; workspaceId?: string }): Promise => - ipcRenderer.invoke('linear:teamMembers', args) - }, - - jira: { - connect: (args: { - siteUrl: string - email: string - apiToken: string - authType?: 'cloud' | 'server' - }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => - ipcRenderer.invoke('jira:connect', args), - - disconnect: (args?: { siteId?: string }): Promise => - ipcRenderer.invoke('jira:disconnect', args), - - selectSite: (args: { siteId: string }): Promise => - ipcRenderer.invoke('jira:selectSite', args), - - status: (): Promise => ipcRenderer.invoke('jira:status'), - - readStatus: (): Promise => ipcRenderer.invoke('jira:readStatus'), - - testConnection: (args?: { - siteId?: string - }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => - ipcRenderer.invoke('jira:testConnection', args), - - searchIssues: (args: { - jql: string - limit?: number - siteId?: string - requestId?: string - }): Promise => ipcRenderer.invoke('jira:searchIssues', args), - cancelSearchIssues: (args: { requestId: string }): Promise => - ipcRenderer.invoke('jira:cancelSearchIssues', args), - - listIssues: (args?: { - filter?: 'assigned' | 'reported' | 'all' | 'done' - limit?: number - siteId?: string - }): Promise => ipcRenderer.invoke('jira:listIssues', args), - - getIssue: (args: { key: string; siteId?: string }): Promise => - ipcRenderer.invoke('jira:getIssue', args), - - lookupIssueSummary: (args: { - key: string - siteId: string - requestId?: string - }): Promise => ipcRenderer.invoke('jira:lookupIssueSummary', args), - cancelIssueSummary: (args: { requestId: string }): Promise => - ipcRenderer.invoke('jira:cancelIssueSummary', args), - - createIssue: (args: { - siteId?: string - projectId: string - issueTypeId: string - title: string - description?: string - customFields?: Record - }): Promise< - { ok: true; id: string; key: string; url: string } | { ok: false; error: string } - > => ipcRenderer.invoke('jira:createIssue', args), - - updateIssue: (args: { - key: string - updates: unknown - siteId?: string - }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('jira:updateIssue', args), - - addIssueComment: (args: { - key: string - body: string - siteId?: string - }): Promise<{ ok: true; id: string } | { ok: false; error: string }> => - ipcRenderer.invoke('jira:addIssueComment', args), - - issueComments: (args: { key: string; siteId?: string }): Promise => - ipcRenderer.invoke('jira:issueComments', args), - - listProjects: (args?: { siteId?: string }): Promise => - ipcRenderer.invoke('jira:listProjects', args), - - listIssueTypes: (args: { projectIdOrKey: string; siteId?: string }): Promise => - ipcRenderer.invoke('jira:listIssueTypes', args), - - listCreateFields: (args: { - projectIdOrKey: string - issueTypeId: string - siteId?: string - }): Promise => ipcRenderer.invoke('jira:listCreateFields', args), - - listPriorities: (args?: { siteId?: string }): Promise => - ipcRenderer.invoke('jira:listPriorities', args), - - listAssignableUsers: (args: { - key: string - query?: string - siteId?: string - }): Promise => ipcRenderer.invoke('jira:listAssignableUsers', args), - searchUsers: (args?: { query?: string; siteId?: string }): Promise => - ipcRenderer.invoke('jira:searchUsers', args), - - listTransitions: (args: { key: string; siteId?: string }): Promise => - ipcRenderer.invoke('jira:listTransitions', args), - getProjectStatusOrder: (args: { - projectKey: string - siteId?: string - }): Promise => ipcRenderer.invoke('jira:getProjectStatusOrder', args) - }, - - starNag: { - onShow: ( - callback: (payload?: { mode?: 'gh' | 'web'; surface?: 'card' | 'toast' }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload?: { mode?: 'gh' | 'web'; surface?: 'card' | 'toast' } - ): void => callback(payload) - ipcRenderer.on('star-nag:show', listener) - return () => ipcRenderer.removeListener('star-nag:show', listener) - }, - onHide: (callback: () => void): (() => void) => { - const listener = (): void => callback() - ipcRenderer.on('star-nag:hide', listener) - return () => ipcRenderer.removeListener('star-nag:hide', listener) - }, - dismiss: (): Promise => ipcRenderer.invoke('star-nag:dismiss'), - later: (): Promise => ipcRenderer.invoke('star-nag:later'), - complete: (): Promise => ipcRenderer.invoke('star-nag:complete'), - disable: (): Promise => ipcRenderer.invoke('star-nag:disable'), - openWeb: (): Promise => ipcRenderer.invoke('star-nag:openWeb'), - starOrca: (): Promise => ipcRenderer.invoke('star-nag:starOrca'), - forceShow: (): Promise => ipcRenderer.invoke('star-nag:forceShow'), - agentValueMoment: (): Promise< - { status: 'ready'; mode: 'gh' | 'web' } | { status: 'skipped' } - > => ipcRenderer.invoke('star-nag:agentValueMoment'), - showAgentValueMoment: (): Promise => ipcRenderer.invoke('star-nag:showAgentValueMoment'), - onboardingCompleted: (): Promise => ipcRenderer.invoke('star-nag:onboardingCompleted') - }, - - // Why: main validates telemetry; renderer call sites use typed wrappers. - telemetryTrack: (name: string, props: Record): Promise => - ipcRenderer.invoke('telemetry:track', name, props), - telemetrySetOptIn: (optedIn: boolean): Promise => - ipcRenderer.invoke('telemetry:setOptIn', optedIn), - telemetryAcknowledgeBanner: (): Promise => - ipcRenderer.invoke('telemetry:acknowledgeBanner'), - telemetryGetConsentState: (): Promise => - ipcRenderer.invoke('telemetry:getConsentState'), - - // Why: bridges are deliberately loose — main type-narrows this untrusted renderer input (see telemetry-error-tracking.md). - diagnostics: { - getStatus: (): Promise => ipcRenderer.invoke('diagnostics:getStatus'), - collectBundle: (lookbackMinutes?: number): Promise => - ipcRenderer.invoke('diagnostics:collectBundle', lookbackMinutes), - openBundlePreview: (bundleSubmissionId: string): Promise => - ipcRenderer.invoke('diagnostics:openBundlePreview', bundleSubmissionId), - discardBundlePreview: (bundleSubmissionId: string): Promise => - ipcRenderer.invoke('diagnostics:discardBundlePreview', bundleSubmissionId), - uploadBundle: (bundleSubmissionId: string): Promise => - ipcRenderer.invoke('diagnostics:uploadBundle', bundleSubmissionId), - deleteBundle: (ticketId: string): Promise => - ipcRenderer.invoke('diagnostics:deleteBundle', ticketId) - }, - - settings: { - get: (): Promise => ipcRenderer.invoke('settings:get'), - - // Why: blocking read for the few startup decisions (terminal side-effect authority) that can't wait for async hydration. Call sparingly. - getSync: (): unknown => ipcRenderer.sendSync('settings:get-sync'), - - set: (args: Record): Promise => - ipcRenderer.invoke('settings:set', args), - - setActiveRuntimeEnvironmentPreference: (args: { - environmentId: string | null - }): Promise => - ipcRenderer.invoke('settings:set-active-runtime-environment-preference', args), - - updatePRBotAuthorOverride: (args: { author: string; isBot: boolean }): Promise => - ipcRenderer.invoke('settings:update-pr-bot-author-override', args), - - listFonts: (): Promise => ipcRenderer.invoke('settings:listFonts'), - - previewGhosttyImport: (): Promise => - ipcRenderer.invoke('settings:previewGhosttyImport'), - - previewWarpThemeImport: (source: WarpThemeImportSource): Promise => - ipcRenderer.invoke('settings:previewWarpThemeImport', source), - - onChanged: (callback: (updates: Record) => void): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - updates: Record - ): void => callback(updates) - ipcRenderer.on('settings:changed', listener) - return () => ipcRenderer.removeListener('settings:changed', listener) - } - }, - - agentAwake: { - getStatus: (): Promise => ipcRenderer.invoke('agentAwake:getStatus'), - onChanged: (callback: (status: ComputerAwakeStatus) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, status: ComputerAwakeStatus): void => - callback(status) - ipcRenderer.on('agentAwake:changed', listener) - return () => ipcRenderer.removeListener('agentAwake:changed', listener) - } - } satisfies PreloadApi['agentAwake'], - - localhostWorktreeLabels: { - register: (args: LocalhostWorktreeLabelRoute): Promise => - ipcRenderer.invoke('localhostWorktreeLabels:register', args) - } satisfies PreloadApi['localhostWorktreeLabels'], - - keybindings: { - get: (): Promise => ipcRenderer.invoke('keybindings:get'), - ensureFile: (): Promise => ipcRenderer.invoke('keybindings:ensureFile'), - setAction: (args: { - actionId: KeybindingActionId - bindings: string[] | null - }): Promise => ipcRenderer.invoke('keybindings:setAction', args), - reload: (): Promise => ipcRenderer.invoke('keybindings:reload'), - openFile: (): Promise => ipcRenderer.invoke('keybindings:openFile'), - revealFile: (): Promise => ipcRenderer.invoke('keybindings:revealFile'), - onChanged: (callback: (snapshot: KeybindingFileSnapshot) => void): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - snapshot: KeybindingFileSnapshot - ): void => callback(snapshot) - ipcRenderer.on('keybindings:changed', listener) - return () => ipcRenderer.removeListener('keybindings:changed', listener) - } - }, - - codexAccounts: { - list: (): Promise => ipcRenderer.invoke('codexAccounts:list'), - add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): Promise => - ipcRenderer.invoke('codexAccounts:add', args), - reauthenticate: (args: { - accountId: string - activateIfSelectionWasEmpty?: boolean - }): Promise => ipcRenderer.invoke('codexAccounts:reauthenticate', args), - remove: (args: { accountId: string }): Promise => - ipcRenderer.invoke('codexAccounts:remove', args), - select: (args: { - accountId: string | null - runtime?: 'host' | 'wsl' - wslDistro?: string | null - }): Promise => ipcRenderer.invoke('codexAccounts:select', args), - listStalePanes: (args: { - ptyIds: string[] - }): Promise< - { - ptyId: string - launchAccountId: string | null - activeAccountId: string | null - reason?: 'account-change' | 'home-route-change' - }[] - > => ipcRenderer.invoke('codexAccounts:listStalePanes', args), - listRecordedPaneLanes: (args: { ptyIds: string[] }): Promise> => - ipcRenderer.invoke('codexAccounts:listRecordedPaneLanes', args), - forgetStalePanes: (args: { ptyIds: string[] }): Promise => - ipcRenderer.invoke('codexAccounts:forgetStalePanes', args) - }, - - claudeAccounts: { - list: (): Promise => ipcRenderer.invoke('claudeAccounts:list'), - add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): Promise => - ipcRenderer.invoke('claudeAccounts:add', args), - cancelPendingLogin: (): Promise => - ipcRenderer.invoke('claudeAccounts:cancelPendingLogin'), - reauthenticate: (args: { accountId: string }): Promise => - ipcRenderer.invoke('claudeAccounts:reauthenticate', args), - remove: (args: { accountId: string }): Promise => - ipcRenderer.invoke('claudeAccounts:remove', args), - select: (args: { - accountId: string | null - runtime?: 'host' | 'wsl' - wslDistro?: string | null - }): Promise => ipcRenderer.invoke('claudeAccounts:select', args) - }, - - cli: { - getInstallStatus: (): Promise => ipcRenderer.invoke('cli:getInstallStatus'), - install: (): Promise => ipcRenderer.invoke('cli:install'), - remove: (): Promise => ipcRenderer.invoke('cli:remove'), - getWslInstallStatus: (args?: { distro?: string | null }): Promise => - ipcRenderer.invoke('cli:getWslInstallStatus', args), - installWsl: (args?: { distro?: string | null }): Promise => - ipcRenderer.invoke('cli:installWsl', args), - removeWsl: (args?: { distro?: string | null }): Promise => - ipcRenderer.invoke('cli:removeWsl', args) - }, - - codexConfigSync: { - status: (): Promise => ipcRenderer.invoke('codexConfigSync:status') - }, - agentTrust: { - markTrusted: (args: { - preset: 'cursor' | 'copilot' | 'codex' - workspacePath: string - connectionId?: string - }): Promise => ipcRenderer.invoke('agentTrust:markTrusted', args) - }, - - preflight: { - check: (args?: { - force?: boolean - }): Promise<{ - git: { installed: boolean } - gh: { installed: boolean; authenticated: boolean } - glab?: { installed: boolean; authenticated: boolean } - bitbucket?: { configured: boolean; authenticated: boolean; account: string | null } - azureDevOps?: { - configured: boolean - authenticated: boolean - account: string | null - baseUrl: string | null - tokenConfigured: boolean - } - gitea?: { - configured: boolean - authenticated: boolean - account: string | null - baseUrl: string | null - tokenConfigured: boolean - } - linear: { connected: boolean } - }> => ipcRenderer.invoke('preflight:check', args), - detectAgents: (args?: PreflightRuntimeContext): Promise => - ipcRenderer.invoke('preflight:detectAgents', args), - refreshAgents: (args?: PreflightRuntimeContext): Promise => - ipcRenderer.invoke('preflight:refreshAgents', args), - detectRemoteAgents: (args: { connectionId: string }): Promise => - ipcRenderer.invoke('preflight:detectRemoteAgents', args), - detectRemoteWindowsTerminalCapabilities: (args: { - connectionId: string - }): Promise<{ - wslAvailable: boolean - wslDistros: string[] - pwshAvailable: boolean - gitBashAvailable: boolean - hostPlatform: NodeJS.Platform | null - }> => ipcRenderer.invoke('preflight:detectRemoteWindowsTerminalCapabilities', args) - }, - - notifications: { - dispatch: (args: Record): Promise => - ipcRenderer.invoke('notifications:dispatch', args), - dismiss: (ids: string[]): Promise => - ipcRenderer.invoke('notifications:dismiss', ids), - openSystemSettings: (): Promise => ipcRenderer.invoke('notifications:openSystemSettings'), - getPermissionStatus: (): Promise => - ipcRenderer.invoke('notifications:getPermissionStatus'), - probeDelivery: (args?: { force?: boolean }): Promise => - ipcRenderer.invoke('notifications:probeDelivery', args), - playSound: async (options?: { - force?: boolean - volume?: number - }): Promise => { - try { - // Why: drop replays while still ringing; the test button passes force to always confirm. - if (!options?.force && isNotificationSoundPlaying) { - return { played: false, reason: 'deduped' } - } - - const resolved = (await ipcRenderer.invoke( - 'notifications:resolveSoundPath' - )) as NotificationSoundPathResult - if (!resolved.ok) { - if (cachedNotificationSound) { - disposeCachedNotificationSound() - } - return { played: false, reason: resolved.reason } - } - - let entry = cachedNotificationSound - if (!entry || entry.path !== resolved.path) { - const sound = (await ipcRenderer.invoke( - 'notifications:loadSound' - )) as NotificationSoundDataResult - if (!sound.ok) { - disposeCachedNotificationSound() - return { played: false, reason: sound.reason } - } - const arrayBuffer = new ArrayBuffer(sound.data.byteLength) - new Uint8Array(arrayBuffer).set(sound.data) - const blob = new Blob([arrayBuffer], { type: sound.mimeType }) - disposeCachedNotificationSound() - const blobUrl = URL.createObjectURL(blob) - entry = { path: sound.path, blobUrl, audio: new Audio(blobUrl) } - cachedNotificationSound = entry - } - - const audio = entry.audio - // Why: restart from zero on each play so bursts replay instead of stacking copies (GNOME canberra / VS Code signal service). - audio.currentTime = 0 - if (typeof options?.volume === 'number' && Number.isFinite(options.volume)) { - audio.volume = Math.min(1, Math.max(0, options.volume / 100)) - } - isNotificationSoundPlaying = true - cleanupNotificationSoundPlayback?.() - const release = (): void => { - cleanup() - if (cleanupNotificationSoundPlayback === cleanup) { - cleanupNotificationSoundPlayback = null - } - isNotificationSoundPlaying = false - } - const cleanup = (): void => { - audio.removeEventListener('ended', release) - audio.removeEventListener('error', release) - } - cleanupNotificationSoundPlayback = cleanup - audio.addEventListener('ended', release) - audio.addEventListener('error', release) - try { - await audio.play() - } catch { - release() - return { played: false, reason: 'playback-failed' } - } - return { played: true } - } catch { - clearNotificationSoundPlaybackState() - return { played: false, reason: 'playback-failed' } - } - } - }, - - onboarding: { - get: (): Promise => ipcRenderer.invoke('onboarding:get'), - update: ( - updates: Partial> & { - checklist?: Partial - } - ): Promise => ipcRenderer.invoke('onboarding:update', updates) - }, - - dashboard: { - // Open the pop-out dashboard window, or focus it if already open. - openPopout: (view?: 'board' | 'map'): Promise => - ipcRenderer.invoke('dashboardPopout:open', view), - - // ── Producer side (main window) ────────────────────────────────────── - publishSnapshot: (snapshot: DashboardSnapshot): Promise => - ipcRenderer.invoke('dashboard:publishSnapshot', snapshot), - getPopoutOpen: (): Promise => ipcRenderer.invoke('dashboard:getPopoutOpen'), - onPopoutOpenChanged: (callback: (open: boolean) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, open: boolean): void => callback(open) - ipcRenderer.on('dashboard:popoutOpenChanged', listener) - return () => ipcRenderer.removeListener('dashboard:popoutOpenChanged', listener) - }, - onSnapshotRequested: (callback: () => void): (() => void) => { - const listener = (): void => callback() - ipcRenderer.on('dashboard:snapshotRequested', listener) - return () => ipcRenderer.removeListener('dashboard:snapshotRequested', listener) - }, - onRevealAgent: (callback: (args: DashboardRevealAgentArgs) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, args: DashboardRevealAgentArgs): void => - callback(args) - ipcRenderer.on('ui:revealDashboardAgent', listener) - return () => ipcRenderer.removeListener('ui:revealDashboardAgent', listener) - }, - onAckAgent: (callback: (paneKey: string) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, paneKey: string): void => - callback(paneKey) - ipcRenderer.on('ui:ackDashboardAgent', listener) - return () => ipcRenderer.removeListener('ui:ackDashboardAgent', listener) - }, - onSpawnAgent: (callback: (args: DashboardSpawnAgentArgs) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, args: DashboardSpawnAgentArgs): void => - callback(args) - ipcRenderer.on('ui:spawnDashboardAgent', listener) - return () => ipcRenderer.removeListener('ui:spawnDashboardAgent', listener) - }, - onSleepWorkspace: (callback: (args: DashboardSleepWorkspaceArgs) => void): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - args: DashboardSleepWorkspaceArgs - ): void => callback(args) - ipcRenderer.on('ui:sleepDashboardWorkspace', listener) - return () => ipcRenderer.removeListener('ui:sleepDashboardWorkspace', listener) - }, - - // ── Consumer side (pop-out window) ─────────────────────────────────── - requestSnapshot: (): Promise => ipcRenderer.invoke('dashboard:requestSnapshot'), - onSnapshot: (callback: (snapshot: DashboardSnapshot) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, snapshot: DashboardSnapshot): void => - callback(snapshot) - ipcRenderer.on('dashboard:snapshot', listener) - return () => ipcRenderer.removeListener('dashboard:snapshot', listener) - }, - onViewRequested: (callback: (view: 'board' | 'map') => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, view: 'board' | 'map'): void => - callback(view) - ipcRenderer.on('dashboard:viewRequested', listener) - return () => ipcRenderer.removeListener('dashboard:viewRequested', listener) - }, - revealAgent: (args: DashboardRevealAgentArgs): Promise => - ipcRenderer.invoke('dashboardPopout:revealAgent', args), - ackAgent: (paneKey: string): Promise => - ipcRenderer.invoke('dashboardPopout:ackAgent', { paneKey }), - spawnAgent: (args: DashboardSpawnAgentArgs): Promise => - ipcRenderer.invoke('dashboardPopout:spawnAgent', args), - sleepWorkspace: (args: DashboardSleepWorkspaceArgs): Promise => - ipcRenderer.invoke('dashboardPopout:sleepWorkspace', args) - }, - - terminalPreview: { - connect: ( - ptyId: string, - opts?: { scrollbackRows?: number } - ): Promise => - ipcRenderer.invoke('terminalPreview:connect', { ptyId, opts }), - input: (ptyId: string, data: string): Promise => - ipcRenderer.invoke('terminalPreview:input', { ptyId, data }), - fit: ( - ptyId: string, - cols: number, - rows: number - ): Promise<{ cols: number; rows: number } | null> => - ipcRenderer.invoke('terminalPreview:fit', { ptyId, cols, rows }), - ack: (ptyId: string, bytes: number): Promise => - ipcRenderer.invoke('terminalPreview:ack', { ptyId, bytes }), - unsubscribe: (ptyId: string): Promise => - ipcRenderer.invoke('terminalPreview:unsubscribe', { ptyId }), - onData: (callback: (payload: TerminalPreviewDataPayload) => void): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload: TerminalPreviewDataPayload - ): void => callback(payload) - ipcRenderer.on('terminalPreview:data', listener) - return () => ipcRenderer.removeListener('terminalPreview:data', listener) - } - }, - - macosTccPrompts: { - onThreshold: (callback: (payload: unknown) => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: unknown): void => - callback(payload) - ipcRenderer.on('macosTccPrompts:threshold', listener) - return () => ipcRenderer.removeListener('macosTccPrompts:threshold', listener) - }, - consumePending: (): Promise<{ claimId: number; promptCount: number } | null> => - ipcRenderer.invoke('macosTccPrompts:consumePending'), - acknowledgePending: (claimId: number): Promise => - ipcRenderer.invoke('macosTccPrompts:acknowledgePending', claimId), - releasePending: (claimId: number): Promise => - ipcRenderer.invoke('macosTccPrompts:releasePending', claimId), - dismiss: (): Promise => ipcRenderer.invoke('macosTccPrompts:dismiss') - }, - - developerPermissions: { - getStatus: (): Promise => ipcRenderer.invoke('developerPermissions:getStatus'), - request: (args: { id: string }): Promise => - ipcRenderer.invoke('developerPermissions:request', args), - openSettings: (args: { id: string }): Promise => - ipcRenderer.invoke('developerPermissions:openSettings', args), - testLocalNetworkConnection: (args: { host: string; port: number }): Promise => - ipcRenderer.invoke('developerPermissions:testLocalNetworkConnection', args) - }, - - computerUsePermissions: { - getStatus: (): Promise => ipcRenderer.invoke('computerUsePermissions:getStatus'), - openSetup: (args?: { id?: string }): Promise => - ipcRenderer.invoke('computerUsePermissions:openSetup', args), - reset: (): Promise => ipcRenderer.invoke('computerUsePermissions:reset') - }, - - shell: { - openPath: (path: string): Promise => ipcRenderer.invoke('shell:openPath', path), - - openInFileManager: (path: string): Promise => - ipcRenderer.invoke('shell:openInFileManager', path), - - openInExternalEditor: ( - request: ShellOpenExternalEditorRequest - ): Promise => - ipcRenderer.invoke('shell:openInExternalEditor', request), - - openUrl: (url: string): Promise => ipcRenderer.invoke('shell:openUrl', url), - - openFilePath: (path: string): Promise => - ipcRenderer.invoke('shell:openFilePath', path), - - openFileUri: (uri: string): Promise => ipcRenderer.invoke('shell:openFileUri', uri), - - pathExists: (path: string): Promise => ipcRenderer.invoke('shell:pathExists', path), - - pickAttachment: (): Promise => ipcRenderer.invoke('shell:pickAttachment'), - - pickImage: (): Promise => ipcRenderer.invoke('shell:pickImage'), - - pickRepoIconImage: (): Promise<{ dataUrl: string; fileName: string } | null> => - ipcRenderer.invoke('shell:pickRepoIconImage'), - - pickAudio: (): Promise => ipcRenderer.invoke('shell:pickAudio'), - - pickDirectory: (args: { defaultPath?: string }): Promise => - ipcRenderer.invoke('shell:pickDirectory', args), - - copyFile: (args: { srcPath: string; destPath: string }): Promise => - ipcRenderer.invoke('shell:copyFile', args) - }, - - skills: { - discover: (target?: SkillDiscoveryTarget): Promise => - ipcRenderer.invoke('skills:discover', target), - freshnessInventory: (): Promise => - ipcRenderer.invoke('skills:freshnessInventory'), - startUpdateRun: (names: string[]): Promise => - ipcRenderer.invoke('skills:startUpdateRun', names), - cancelUpdateRun: (): Promise => ipcRenderer.invoke('skills:cancelUpdateRun'), - acknowledgeUpdateRun: (): Promise => ipcRenderer.invoke('skills:acknowledgeUpdateRun'), - getUpdateRun: (): Promise => ipcRenderer.invoke('skills:getUpdateRun'), - prepareShare: (input: { - skillIds: string[] - bundleName: string - target?: SkillDiscoveryTarget - packageId?: string - }): Promise => ipcRenderer.invoke('skills:prepareShare', input), - publishShare: (input: SkillSharePublishInput): Promise => - ipcRenderer.invoke('skills:publishShare', input), - cancelShare: (preparationId: string): Promise => - ipcRenderer.invoke('skills:cancelShare', preparationId), - releaseShare: (preparationId: string): Promise => - ipcRenderer.invoke('skills:releaseShare', preparationId), - resolveShare: (shareId: string): Promise => - ipcRenderer.invoke('skills:resolveShare', shareId), - installShare: (input: SkillShareInstallInput): Promise => - ipcRenderer.invoke('skills:installShare', input), - installBundleShare: ( - input: SkillBundleShareInstallInput - ): Promise => - ipcRenderer.invoke('skills:installBundleShare', input), - installBundlePackageVersion: ( - input: SkillBundlePackageVersionInstallInput - ): Promise => - ipcRenderer.invoke('skills:installBundlePackageVersion', input), - installPackageVersion: ( - input: SkillPackageVersionInstallInput - ): Promise => - ipcRenderer.invoke('skills:installPackageVersion', input), - cancelInstall: (input: SkillInstallCancelInput): Promise<{ cancelled: boolean }> => - ipcRenderer.invoke('skills:cancelInstall', input), - previewInstall: (input: SkillInstallPreviewInput): Promise => - ipcRenderer.invoke('skills:previewInstall', input), - previewBundleInstall: ( - input: SkillBundleInstallPreviewInput - ): Promise => - ipcRenderer.invoke('skills:previewBundleInstall', input), - removeInstall: (input: SkillRemoveInput): Promise => - ipcRenderer.invoke('skills:removeInstall', input), - // Desktop always registers the delete IPC handlers in its own main process. - deleteSupported: (): Promise => Promise.resolve(true), - previewDelete: (request: SkillDeleteRequest): Promise => - ipcRenderer.invoke('skills:previewDelete', request), - delete: (request: SkillDeleteRequest): Promise => - ipcRenderer.invoke('skills:delete', request), - listManagedInstalls: (environmentId?: string): Promise => - ipcRenderer.invoke('skills:listManagedInstalls', environmentId), - getPackage: (packageId: string): Promise> => - ipcRenderer.invoke('skills:getPackage', packageId), - listOwnedShares: (): Promise> => - ipcRenderer.invoke('skills:listOwnedShares'), - revokeShare: (shareId: string): Promise> => - ipcRenderer.invoke('skills:revokeShare', shareId), - deletePackageVersion: (input: { - packageId: string - versionId: string - }): Promise> => - ipcRenderer.invoke('skills:deletePackageVersion', input), - deletePackage: (packageId: string): Promise> => - ipcRenderer.invoke('skills:deletePackage', packageId), - listWslDistros: (environmentId?: string): Promise => - ipcRenderer.invoke('skills:listWslDistros', environmentId), - onInstallProgress: (callback: (progress: SkillInstallProgress) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, progress: SkillInstallProgress): void => - callback(progress) - ipcRenderer.on('skills:installProgress', listener) - return () => ipcRenderer.removeListener('skills:installProgress', listener) - }, - onShareProgress: (callback: (progress: SkillShareProgress) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, progress: SkillShareProgress): void => - callback(progress) - ipcRenderer.on('skills:shareProgress', listener) - return () => ipcRenderer.removeListener('skills:shareProgress', listener) - }, - onUpdateRun: (callback: (run: SkillUpdateRun) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, run: SkillUpdateRun): void => - callback(run) - ipcRenderer.on('skills:updateRun', listener) - return () => ipcRenderer.removeListener('skills:updateRun', listener) - } - }, - - pet: { - import: (): Promise => ipcRenderer.invoke('pet:import'), - importPetBundle: (): Promise => ipcRenderer.invoke('pet:importPetBundle'), - read: (id: string, fileName: string, kind?: 'image' | 'bundle'): Promise => - ipcRenderer.invoke('pet:read', id, fileName, kind), - delete: (id: string, fileName: string, kind?: 'image' | 'bundle'): Promise => - ipcRenderer.invoke('pet:delete', id, fileName, kind) - }, - - browser: { - onClientPageRendererRequest: browserClientPageRendererRequests.subscribe, - readClientHostId: (): string | null => readBrowserClientHostIdArgument(process.argv), - registerGuest: (args: { - browserPageId: string - workspaceId: string - worktreeId: string - sessionProfileId?: string | null - webContentsId: number - }): Promise => ipcRenderer.invoke('browser:registerGuest', args), - - isGuestRegistered: (args: { browserPageId: string; webContentsId: number }): Promise => - ipcRenderer.invoke('browser:isGuestRegistered', args), - - repairGuestRegistration: (args: { - browserPageId: string - workspaceId: string - worktreeId: string - sessionProfileId?: string | null - webContentsId: number - }): Promise => ipcRenderer.invoke('browser:repairGuestRegistration', args), - - unregisterGuest: (args: { browserPageId: string }): Promise => - ipcRenderer.invoke('browser:unregisterGuest', args), - - onWebAuthnAccountRequest: ( - callback: (request: BrowserWebAuthnAccountRequest) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - request: BrowserWebAuthnAccountRequest - ): void => callback(request) - ipcRenderer.on('browser:webauthn-account-requested', listener) - return () => ipcRenderer.removeListener('browser:webauthn-account-requested', listener) - }, - - onWebAuthnAccountRequestClosed: ( - callback: (event: { requestId: string }) => void - ): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { requestId: string }): void => - callback(data) - ipcRenderer.on('browser:webauthn-account-request-closed', listener) - return () => ipcRenderer.removeListener('browser:webauthn-account-request-closed', listener) - }, - - respondWebAuthnAccount: (response: BrowserWebAuthnAccountResponse): Promise => - ipcRenderer.invoke('browser:respondWebAuthnAccount', response), - - openDevTools: (args: { browserPageId: string }): Promise => - ipcRenderer.invoke('browser:openDevTools', args), - - setViewportOverride: (args: { - browserPageId: string - override: BrowserViewportOverride | null - }): Promise => ipcRenderer.invoke('browser:setViewportOverride', args), - - reportViewportScrollState: (args: { - browserPageId: string - state: { - scrollLeft: number - scrollTop: number - maxScrollLeft: number - maxScrollTop: number - } - }): void => ipcRenderer.send('browser:reportViewportScrollState', args), - - setAnnotationViewportBridge: (args): Promise => - ipcRenderer.invoke('browser:setAnnotationViewportBridge', args), - - publishClientPageMetadata: (args) => - ipcRenderer.invoke('browser:publishClientPageMetadata', args), - - onGuestLoadFailed: ( - callback: (args: { - browserPageId: string - loadError: { code: number; description: string; validatedUrl: string } - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - browserPageId: string - loadError: { code: number; description: string; validatedUrl: string } - } - ) => callback(data) - ipcRenderer.on('browser:guest-load-failed', listener) - return () => ipcRenderer.removeListener('browser:guest-load-failed', listener) - }, - - onCertificateFailureChanged: (callback): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: Parameters[0] - ): void => callback(data) - ipcRenderer.on('browser:certificate-failure-changed', listener) - return () => ipcRenderer.removeListener('browser:certificate-failure-changed', listener) - }, - - proceedCertificate: (args) => ipcRenderer.invoke('browser:proceedCertificate', args), - - onPermissionDenied: ( - callback: (event: { browserPageId: string; permission: string; origin: string }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { browserPageId: string; permission: string; origin: string } - ) => callback(data) - ipcRenderer.on('browser:permission-denied', listener) - return () => ipcRenderer.removeListener('browser:permission-denied', listener) - }, - - onPopup: ( - callback: (event: { - browserPageId: string - origin: string - action: 'opened-in-orca' | 'opened-external' | 'blocked' - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - browserPageId: string - origin: string - action: 'opened-in-orca' | 'opened-external' | 'blocked' - } - ) => callback(data) - ipcRenderer.on('browser:popup', listener) - return () => ipcRenderer.removeListener('browser:popup', listener) - }, - - onDownloadRequested: ( - callback: (event: { - browserPageId: string - downloadId: string - origin: string - filename: string - totalBytes: number | null - mimeType: string | null - savePath: string - status: 'downloading' - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - browserPageId: string - downloadId: string - origin: string - filename: string - totalBytes: number | null - mimeType: string | null - savePath: string - status: 'downloading' - } - ) => callback(data) - ipcRenderer.on('browser:download-requested', listener) - return () => ipcRenderer.removeListener('browser:download-requested', listener) - }, - - onDownloadProgress: ( - callback: (event: { - browserPageId?: string - downloadId: string - receivedBytes: number - totalBytes: number | null - state: 'progressing' | 'interrupted' | null - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - browserPageId?: string - downloadId: string - receivedBytes: number - totalBytes: number | null - state: 'progressing' | 'interrupted' | null - } - ) => callback(data) - ipcRenderer.on('browser:download-progress', listener) - return () => ipcRenderer.removeListener('browser:download-progress', listener) - }, - - onDownloadFinished: ( - callback: (event: { - browserPageId?: string - downloadId: string - status: 'completed' | 'canceled' | 'failed' - savePath: string | null - remoteDestination?: { workspaceRelativePath: string; hostLabel: string } - error: string | null - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - browserPageId?: string - downloadId: string - status: 'completed' | 'canceled' | 'failed' - savePath: string | null - remoteDestination?: { workspaceRelativePath: string; hostLabel: string } - error: string | null - } - ) => callback(data) - ipcRenderer.on('browser:download-finished', listener) - return () => ipcRenderer.removeListener('browser:download-finished', listener) - }, - - onContextMenuRequested: ( - callback: (event: { - browserPageId: string - x: number - y: number - screenX: number - screenY: number - pageUrl: string - linkUrl: string | null - selectionText: string - canGoBack: boolean - canGoForward: boolean - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - browserPageId: string - x: number - y: number - screenX: number - screenY: number - pageUrl: string - linkUrl: string | null - selectionText: string - canGoBack: boolean - canGoForward: boolean - } - ) => callback(data) - ipcRenderer.on('browser:context-menu-requested', listener) - return () => ipcRenderer.removeListener('browser:context-menu-requested', listener) - }, - - onContextMenuDismissed: ( - callback: (event: { browserPageId: string }) => void - ): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { browserPageId: string }) => - callback(data) - ipcRenderer.on('browser:context-menu-dismissed', listener) - return () => ipcRenderer.removeListener('browser:context-menu-dismissed', listener) - }, - - onNavigationUpdate: ( - callback: (event: { browserPageId: string; url: string; title: string }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { browserPageId: string; url: string; title: string } - ) => callback(data) - ipcRenderer.on('browser:navigation-update', listener) - return () => ipcRenderer.removeListener('browser:navigation-update', listener) - }, - - onActivateView: ( - callback: (data: { worktreeId?: string; browserPageId?: string }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { worktreeId?: string; browserPageId?: string } - ) => callback(data) - ipcRenderer.on('browser:activateView', listener) - return () => ipcRenderer.removeListener('browser:activateView', listener) - }, - - onPaneFocus: ( - callback: (data: { worktreeId: string | null; browserPageId: string }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { worktreeId: string | null; browserPageId: string } - ) => callback(data) - ipcRenderer.on('browser:pane-focus', listener) - return () => ipcRenderer.removeListener('browser:pane-focus', listener) - }, - - onOpenLinkInOrcaTab: ( - callback: (event: { browserPageId: string; url: string }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { browserPageId: string; url: string } - ) => callback(data) - ipcRenderer.on('browser:open-link-in-orca-tab', listener) - return () => ipcRenderer.removeListener('browser:open-link-in-orca-tab', listener) - }, - - cancelDownload: (args: { downloadId: string }): Promise => - ipcRenderer.invoke('browser:cancelDownload', args), - - setGrabMode: (args: { - browserPageId: string - enabled: boolean - }): Promise<{ ok: true } | { ok: false; reason: string }> => - ipcRenderer.invoke('browser:setGrabMode', args), - - awaitGrabSelection: (args: { browserPageId: string; opId: string }): Promise => - ipcRenderer.invoke('browser:awaitGrabSelection', args), - - cancelGrab: (args: { browserPageId: string }): Promise => - ipcRenderer.invoke('browser:cancelGrab', args), - - captureSelectionScreenshot: (args: { - browserPageId: string - rect: { x: number; y: number; width: number; height: number } - }): Promise<{ ok: true; screenshot: unknown } | { ok: false; reason: string }> => - ipcRenderer.invoke('browser:captureSelectionScreenshot', args), - - extractHoverPayload: (args: { - browserPageId: string - }): Promise<{ ok: true; payload: unknown } | { ok: false; reason: string }> => - ipcRenderer.invoke('browser:extractHoverPayload', args), - - onGrabModeToggle: (callback: (browserPageId: string) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, browserPageId: string) => - callback(browserPageId) - ipcRenderer.on('browser:grabModeToggle', listener) - return () => ipcRenderer.removeListener('browser:grabModeToggle', listener) - }, - - onGrabActionShortcut: ( - callback: (args: { browserPageId: string; key: 'c' | 's' }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { browserPageId: string; key: 'c' | 's' } - ) => callback(data) - ipcRenderer.on('browser:grabActionShortcut', listener) - return () => ipcRenderer.removeListener('browser:grabActionShortcut', listener) - }, - - sessionListProfiles: (): Promise => - ipcRenderer.invoke('browser:session:listProfiles'), - - prepareSshWorkspacePartition: (args: { - targetId: string - browserProfileId?: string - skipProbe?: boolean - }): Promise<{ partition: string }> => - ipcRenderer.invoke('browser:prepareSshWorkspacePartition', args), - - sessionCreateProfile: (args: { - scope: 'default' | 'isolated' | 'imported' - label: string - userAgentMode?: 'clean' | 'native' - }): Promise => ipcRenderer.invoke('browser:session:createProfile', args), - - sessionDeleteProfile: (args: { profileId: string }): Promise => - ipcRenderer.invoke('browser:session:deleteProfile', args), - - sessionImportCookies: (args: { - profileId: string - }): Promise< - { ok: true; profileId: string; summary: unknown } | { ok: false; reason: string } - > => ipcRenderer.invoke('browser:session:importCookies', args), - - sessionResolvePartition: (args: { profileId: string | null }): Promise => - ipcRenderer.invoke('browser:session:resolvePartition', args), - - sessionDetectBrowsers: (): Promise => - ipcRenderer.invoke('browser:session:detectBrowsers'), - - sessionDetectBrowsersForClientHost: (args: { - environmentId: string - }): Promise => - ipcRenderer.invoke('browser:session:detectBrowsersForClientHost', args), - - sessionImportFromBrowser: (args: { - profileId: string - browserFamily: string - }): Promise< - { ok: true; profileId: string; summary: unknown } | { ok: false; reason: string } - > => ipcRenderer.invoke('browser:session:importFromBrowser', args), - - sessionImportFromBrowserForClientHost: (args: { - environmentId: string - profileId: string - browserFamily: string - browserProfile?: string - }): Promise< - { ok: true; profileId: string; summary: unknown } | { ok: false; reason: string } | null - > => ipcRenderer.invoke('browser:session:importFromBrowserForClientHost', args), - - sessionClientRouteImportSources: (args: { - environmentId: string - }): Promise> => - ipcRenderer.invoke('browser:session:clientRouteImportSources', args), - - sessionClearDefaultCookies: (): Promise => - ipcRenderer.invoke('browser:session:clearDefaultCookies'), - - notifyActiveTabChanged: (args: { browserPageId: string }): Promise => - ipcRenderer.invoke('browser:activeTabChanged', args) - }, - - emulator: { - startFrameStream: (args: { - streamUrl: string - streamKey?: string - }): Promise<{ - streamId: string - }> => ipcRenderer.invoke('emulator:frameStreamStart', args), - stopFrameStream: (args: { streamId: string }): Promise => - ipcRenderer.invoke('emulator:frameStreamStop', args), - onFrameStreamFrame: ( - callback: (data: { streamId: string; bytes: ArrayBuffer }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { streamId: string; bytes: ArrayBuffer } - ) => callback(data) - ipcRenderer.on('emulator:frameStreamFrame', listener) - return () => ipcRenderer.removeListener('emulator:frameStreamFrame', listener) - }, - onFrameStreamError: ( - callback: (data: { streamId: string; message: string }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { streamId: string; message: string } - ) => callback(data) - ipcRenderer.on('emulator:frameStreamError', listener) - return () => ipcRenderer.removeListener('emulator:frameStreamError', listener) - }, - startVideoStream: (args: { - deviceId: string - streamId: string - }): Promise<{ streamId: string }> => ipcRenderer.invoke('emulator:videoStreamStart', args), - stopVideoStream: (args: { streamId: string }): Promise => - ipcRenderer.invoke('emulator:videoStreamStop', args), - onVideoStreamMeta: ( - callback: (data: { - streamId: string - deviceId: string - meta: { codecId: string; width: number; height: number } - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - streamId: string - deviceId: string - meta: { codecId: string; width: number; height: number } - } - ) => callback(data) - ipcRenderer.on('emulator:videoStreamMeta', listener) - return () => ipcRenderer.removeListener('emulator:videoStreamMeta', listener) - }, - onVideoStreamFrame: ( - callback: (data: { - streamId: string - deviceId: string - config: boolean - keyFrame: boolean - bytes: ArrayBuffer - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - streamId: string - deviceId: string - config: boolean - keyFrame: boolean - bytes: ArrayBuffer - } - ) => callback(data) - ipcRenderer.on('emulator:videoStreamFrame', listener) - return () => ipcRenderer.removeListener('emulator:videoStreamFrame', listener) - }, - onPaneFocus: (callback: (data: { worktreeId: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) => - callback(data) - ipcRenderer.on('emulator:pane-focus', listener) - return () => ipcRenderer.removeListener('emulator:pane-focus', listener) - }, - onAutoAttach: ( - callback: (data: { - worktreeId: string - info: { deviceUdid: string; streamUrl: string; wsUrl: string; axUrl?: string } - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - worktreeId: string - info: { deviceUdid: string; streamUrl: string; wsUrl: string; axUrl?: string } - } - ) => callback(data) - ipcRenderer.on('ui:emulatorAutoAttach', listener) - return () => ipcRenderer.removeListener('ui:emulatorAutoAttach', listener) - } - }, - - hooks: { - check: (args: { - repoId: string - hostId?: ExecutionHostId - }): Promise<{ - status?: 'ok' | 'error' - hasHooks: boolean - hooks: unknown - mayNeedUpdate: boolean - }> => ipcRenderer.invoke('hooks:check', args), - - inspectSetupScriptImports: (args: { - repoId: string - hostId?: ExecutionHostId - }): Promise => ipcRenderer.invoke('hooks:inspectSetupScriptImports', args), - - createIssueCommandRunner: (args: { - repoId: string - worktreePath: string - command: string - }): Promise => ipcRenderer.invoke('hooks:createIssueCommandRunner', args), - - readIssueCommand: (args: { - repoId: string - hostId?: ExecutionHostId - }): Promise<{ - status?: 'ok' | 'error' - localContent: string | null - sharedContent: string | null - effectiveContent: string | null - localFilePath: string - source: 'local' | 'shared' | 'none' - }> => ipcRenderer.invoke('hooks:readIssueCommand', args), - - writeIssueCommand: (args: { - repoId: string - content: string - hostId?: ExecutionHostId - }): Promise => ipcRenderer.invoke('hooks:writeIssueCommand', args) - }, - - ephemeralVm: { - listRecipes: (args) => ipcRenderer.invoke('ephemeralVm:listRecipes', args), - listRecipeCatalog: () => ipcRenderer.invoke('ephemeralVm:listRecipeCatalog'), - doctor: (args) => ipcRenderer.invoke('ephemeralVm:doctor', args), - provision: (args) => ipcRenderer.invoke('ephemeralVm:provision', args), - cancelProvision: (args) => ipcRenderer.invoke('ephemeralVm:cancelProvision', args), - onProvisionEvent: (callback) => { - const listener = ( - _event: Electron.IpcRendererEvent, - event: { provisionId: string; stream: 'stdout' | 'stderr'; chunk: string } - ): void => callback(event) - ipcRenderer.on('ephemeralVm:provisionEvent', listener) - return () => ipcRenderer.removeListener('ephemeralVm:provisionEvent', listener) - }, - listRuntimes: () => ipcRenderer.invoke('ephemeralVm:listRuntimes'), - attachWorkspace: (args) => ipcRenderer.invoke('ephemeralVm:attachWorkspace', args), - suspendWorkspace: (args) => ipcRenderer.invoke('ephemeralVm:suspendWorkspace', args), - resumeWorkspace: (args) => ipcRenderer.invoke('ephemeralVm:resumeWorkspace', args), - cleanup: (args) => ipcRenderer.invoke('ephemeralVm:cleanup', args), - stopCleanup: (args) => ipcRenderer.invoke('ephemeralVm:stopCleanup', args), - getCleanupCommand: (args) => ipcRenderer.invoke('ephemeralVm:getCleanupCommand', args) - } satisfies PreloadApi['ephemeralVm'], - - cache: { - getGitHub: () => ipcRenderer.invoke('cache:getGitHub'), - setGitHub: (args) => ipcRenderer.invoke('cache:setGitHub', args) - } satisfies PreloadApi['cache'], - - session: { - // hostId is optional; main defaults it to 'local' so existing omitting call sites keep the local session partition. - get: (hostId) => ipcRenderer.invoke('session:get', hostId), - set: (args, hostId) => ipcRenderer.invoke('session:set', args, hostId), - patch: (args, hostId) => ipcRenderer.invoke('session:patch', args, hostId), - flush: () => ipcRenderer.invoke('session:flush'), - readTerminalScrollback: (args) => - ipcRenderer.sendSync('session:read-terminal-scrollback-sync', args), - /** Synchronous session save for beforeunload — blocks until flushed to disk. */ - setSync: (args, hostId) => { - ipcRenderer.sendSync('session:set-sync', args, hostId) - } - } satisfies PreloadApi['session'], - - remoteWorkspace: { - get: (args) => ipcRenderer.invoke('remoteWorkspace:get', args), - setForConnectedTargets: (args) => - ipcRenderer.invoke('remoteWorkspace:setForConnectedTargets', args), - listEnabledConnectedTargets: () => - ipcRenderer.invoke('remoteWorkspace:listEnabledConnectedTargets'), - listConnectedClients: (args) => - ipcRenderer.invoke('remoteWorkspace:listConnectedClients', args), - clientId: () => ipcRenderer.invoke('remoteWorkspace:clientId'), - onChanged: (callback) => { - const listener = (_event: Electron.IpcRendererEvent, data: RemoteWorkspaceChangedEvent) => - callback(data) - ipcRenderer.on('remoteWorkspace:changed', listener) - return () => ipcRenderer.removeListener('remoteWorkspace:changed', listener) - } - } satisfies PreloadApi['remoteWorkspace'], - - updater: { - getStatus: () => ipcRenderer.invoke('updater:getStatus'), - getVersion: () => ipcRenderer.invoke('updater:getVersion'), - check: (options) => ipcRenderer.invoke('updater:check', options), - download: () => ipcRenderer.invoke('updater:download'), - dismissNudge: () => ipcRenderer.invoke('updater:dismissNudge'), - dismissAvailableUpdate: () => ipcRenderer.invoke('updater:dismissAvailableUpdate'), - getLinuxPackageInstallInstructions: () => - ipcRenderer.invoke('updater:getLinuxPackageInstallInstructions'), - showLinuxPackage: () => ipcRenderer.invoke('updater:showLinuxPackage'), - listBuilds: (channel) => ipcRenderer.invoke('updater:listBuilds', channel), - quitAndInstall: (): Promise => - prepareAndInvokeUpdaterInstall( - window, - updaterQuitAbortRelay, - () => ipcRenderer.invoke('updater:quitAndInstall'), - awaitBeforeUnloadCheckpoint - ), - - onStatus: (callback) => { - const listener = (_event: Electron.IpcRendererEvent, status: UpdateStatus) => callback(status) - ipcRenderer.on('updater:status', listener) - return () => ipcRenderer.removeListener('updater:status', listener) - }, - onClearDismissal: (callback) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('updater:clearDismissal', listener) - return () => ipcRenderer.removeListener('updater:clearDismissal', listener) - } - } satisfies PreloadApi['updater'], - - docPreview: { - mintGrant: (request: DocPreviewGrantRequest): Promise<{ grantId: string; url: string }> => - ipcRenderer.invoke(DOC_PREVIEW_MINT_GRANT_CHANNEL, request), - revokeGrant: (grantId: string): Promise => - ipcRenderer.invoke(DOC_PREVIEW_REVOKE_GRANT_CHANNEL, grantId), - authorizeDirectory: (grantId: string, relativePath: string): Promise => - ipcRenderer.invoke(DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL, grantId, relativePath), - onExternalLink: (callback: (payload: { url: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: { url: string }): void => - callback(payload) - ipcRenderer.on(DOC_PREVIEW_EXTERNAL_LINK_CHANNEL, listener) - return () => ipcRenderer.removeListener(DOC_PREVIEW_EXTERNAL_LINK_CHANNEL, listener) - }, - onLoadFailure: (callback: (payload: DocPreviewFailure) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: DocPreviewFailure): void => - callback(payload) - ipcRenderer.on(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, listener) - return () => ipcRenderer.removeListener(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, listener) - } - }, - - notebook: { - runPythonCell: (args: { - filePath: string - code: string - preamble?: string - connectionId?: string | null - }): Promise<{ stdout: string; stderr: string; exitCode: number | null; error?: string }> => - ipcRenderer.invoke('notebook:runPythonCell', args) - }, - - fs: { - readDir: (args: { - dirPath: string - connectionId?: string - }): Promise<{ name: string; isDirectory: boolean; isSymlink: boolean }[]> => - ipcRenderer.invoke('fs:readDir', args), - readFile: (args: { - filePath: string - connectionId?: string - includeLocalLogMetadata?: boolean - }): Promise<{ - content: string - isBinary: boolean - isImage?: boolean - mimeType?: string - fileIdentity?: string - }> => ipcRenderer.invoke('fs:readFile', args), - readLocalLogTail: (args: LocalLogTailReadArgs): Promise => - ipcRenderer.invoke('fs:readLocalLogTail', args), - startLocalLogTail: (args: LocalLogTailWatchArgs): Promise => - ipcRenderer.invoke('fs:startLocalLogTail', args), - stopLocalLogTail: (args: { subscriptionId: string }): Promise => - ipcRenderer.invoke('fs:stopLocalLogTail', args), - onLocalLogTailChanged: ( - callback: (payload: LocalLogTailChangedPayload) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload: LocalLogTailChangedPayload - ): void => callback(payload) - ipcRenderer.on('fs:localLogTailChanged', listener) - return () => ipcRenderer.removeListener('fs:localLogTailChanged', listener) - }, - downloadFile: (args: { - filePath: string - connectionId: string - }): Promise<{ canceled: true } | { canceled: false; destinationPath: string }> => - ipcRenderer.invoke('fs:downloadFile', args), - downloadFolder: (args: { - dirPath: string - connectionId: string - }): Promise<{ canceled: true } | { canceled: false; destinationPath: string }> => - ipcRenderer.invoke('fs:downloadFolder', args), - saveDownloadedFile: (args: { - suggestedName: string - content: string - encoding: 'utf8' | 'base64' - }): Promise<{ canceled: true } | { canceled: false; destinationPath: string }> => - ipcRenderer.invoke('fs:saveDownloadedFile', args), - startDownloadedFile: (args: { - suggestedName: string - }): Promise< - { canceled: true } | { canceled: false; transferId: string; destinationPath: string } - > => ipcRenderer.invoke('fs:startDownloadedFile', args), - appendDownloadedFileChunk: (args: { - transferId: string - contentBase64: string - }): Promise<{ ok: true }> => ipcRenderer.invoke('fs:appendDownloadedFileChunk', args), - finishDownloadedFile: (args: { - transferId: string - }): Promise<{ canceled: false; destinationPath: string }> => - ipcRenderer.invoke('fs:finishDownloadedFile', args), - cancelDownloadedFile: (args: { transferId: string }): Promise<{ ok: true }> => - ipcRenderer.invoke('fs:cancelDownloadedFile', args), - listMarkdownDocuments: (args: { - rootPath: string - connectionId?: string - }): Promise<{ filePath: string; relativePath: string; basename: string; name: string }[]> => - ipcRenderer.invoke('fs:listMarkdownDocuments', args), - writeFile: ( - args: { - filePath: string - content: string - connectionId?: string - } & SshMutationExpectation - ): Promise => ipcRenderer.invoke('fs:writeFile', args), - createFile: ( - args: { filePath: string; connectionId?: string } & SshMutationExpectation - ): Promise => ipcRenderer.invoke('fs:createFile', args), - createDir: ( - args: { dirPath: string; connectionId?: string } & SshMutationExpectation - ): Promise => ipcRenderer.invoke('fs:createDir', args), - rename: ( - args: { oldPath: string; newPath: string; connectionId?: string } & SshMutationExpectation - ): Promise => ipcRenderer.invoke('fs:rename', args), - copy: ( - args: { - sourcePath: string - destinationPath: string - connectionId?: string - } & SshMutationExpectation - ): Promise => ipcRenderer.invoke('fs:copy', args), - deletePath: ( - args: { - targetPath: string - connectionId?: string - recursive?: boolean - } & SshMutationExpectation - ): Promise => ipcRenderer.invoke('fs:deletePath', args), - authorizeExternalPath: (args: { targetPath: string }): Promise => - ipcRenderer.invoke('fs:authorizeExternalPath', args), - stat: (args: { - filePath: string - connectionId?: string - }): Promise<{ size: number; isDirectory: boolean; mtime: number }> => - ipcRenderer.invoke('fs:stat', args), - pathExists: (args: { filePath: string; connectionId?: string }): Promise => - ipcRenderer.invoke('fs:pathExists', args), - listFiles: (args: { - rootPath: string - connectionId?: string - excludePaths?: string[] - requestToken?: string - maxResults?: number - searchQuery?: string - }): Promise => ipcRenderer.invoke('fs:listFiles', args), - cancelListFiles: (args: { requestToken: string }): Promise => - ipcRenderer.invoke('fs:cancelListFiles', args), - search: (args: { - query: string - rootPath: string - caseSensitive?: boolean - wholeWord?: boolean - useRegex?: boolean - includePattern?: string - excludePattern?: string - maxResults?: number - connectionId?: string - }): Promise => ipcRenderer.invoke('fs:search', args), - importExternalPaths: ( - args: { - sourcePaths: string[] - destDir: string - connectionId?: string - ensureDir?: boolean - } & SshMutationExpectation - ): Promise<{ - results: ( - | { - sourcePath: string - status: 'imported' - destPath: string - kind: 'file' | 'directory' - renamed: boolean - } - | { - sourcePath: string - status: 'skipped' - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - } - | { - sourcePath: string - status: 'failed' - reason: string - } - )[] - }> => ipcRenderer.invoke('fs:importExternalPaths', args), - stageExternalPathsForRuntimeUpload: (args: { - sourcePaths: string[] - }): Promise<{ - sources: ( - | { - sourcePath: string - status: 'staged' - name: string - kind: 'file' | 'directory' - entries: ( - | { relativePath: string; kind: 'directory' } - | { relativePath: string; kind: 'file'; contentBase64: string } - )[] - } - | { - sourcePath: string - status: 'skipped' - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - } - | { - sourcePath: string - status: 'failed' - reason: string - } - )[] - }> => ipcRenderer.invoke('fs:stageExternalPathsForRuntimeUpload', args), - resolveDroppedPathsForAgent: ( - args: { - paths: string[] - worktreePath: string - connectionId?: string - } & SshMutationExpectation - ): Promise<{ - resolvedPaths: string[] - skipped: { - sourcePath: string - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - }[] - failed: { sourcePath: string; reason: string }[] - }> => ipcRenderer.invoke('fs:resolveDroppedPathsForAgent', args), - watchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise => - ipcRenderer.invoke('fs:watchWorktree', args), - unwatchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise => - ipcRenderer.invoke('fs:unwatchWorktree', args), - onFsChanged: (callback: (payload: FsChangedPayload) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: FsChangedPayload) => - callback(payload) - ipcRenderer.on('fs:changed', listener) - return () => ipcRenderer.removeListener('fs:changed', listener) - } - }, - - git: { - status: (args: { - worktreePath: string - connectionId?: string - admissionTier?: 'interactive' | 'status' | 'background' - includeIgnored?: boolean - includeLineStats?: boolean - bypassEffectiveUpstreamNegativeCache?: boolean - reuseLineStats?: boolean - branchLineTotalMergeBase?: string - requestToken?: string - }): Promise => ipcRenderer.invoke('git:status', args), - cancelStatus: (args: { requestToken: string }): Promise => - ipcRenderer.invoke('git:cancelStatus', args), - setStatusUpstreamRefWatch: (args: { - worktreeId: string - worktreePath: string - executionHostId: string - connectionId?: string - branch?: string - upstreamName?: string - }): Promise => ipcRenderer.invoke('git:setStatusUpstreamRefWatch', args), - submoduleStatus: (args: { - worktreePath: string - submodulePath: string - connectionId?: string - area?: GitStagingArea - }): Promise => ipcRenderer.invoke('git:submoduleStatus', args), - checkIgnored: (args: { - worktreePath: string - paths: string[] - connectionId?: string - }): Promise => ipcRenderer.invoke('git:checkIgnored', args), - findHugeFoldersToIgnore: (args: { worktreePath: string }): Promise => - ipcRenderer.invoke('git:findHugeFoldersToIgnore', args), - appendGitignore: (args: { worktreePath: string; folderName: string }): Promise => - ipcRenderer.invoke('git:appendGitignore', args), - history: ( - args: { worktreePath: string; connectionId?: string } & GitHistoryOptions - ): Promise => ipcRenderer.invoke('git:history', args), - conflictOperation: (args: { worktreePath: string; connectionId?: string }): Promise => - ipcRenderer.invoke('git:conflictOperation', args), - abortMerge: (args: { worktreePath: string; connectionId?: string }): Promise => - ipcRenderer.invoke('git:abortMerge', args), - abortRebase: (args: { worktreePath: string; connectionId?: string }): Promise => - ipcRenderer.invoke('git:abortRebase', args), - diff: (args: { - worktreePath: string - filePath: string - staged: boolean - compareAgainstHead?: boolean - connectionId?: string - }): Promise => ipcRenderer.invoke('git:diff', args), - branchCompare: (args: { - worktreePath: string - baseRef: string - connectionId?: string - admissionTier?: 'interactive' | 'status' | 'background' - }): Promise => ipcRenderer.invoke('git:branchCompare', args), - commitCompare: (args: { - worktreePath: string - commitId: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:commitCompare', args), - upstreamStatus: (args: { - worktreePath: string - connectionId?: string - pushTarget?: GitPushTarget - }): Promise => ipcRenderer.invoke('git:upstreamStatus', args), - fetch: (args: { - worktreePath: string - connectionId?: string - pushTarget?: GitPushTarget - }): Promise => ipcRenderer.invoke('git:fetch', args), - syncFork: (args: { - worktreePath: string - connectionId?: string - expectedUpstream: GitForkSyncExpectedUpstream - }): Promise => ipcRenderer.invoke('git:syncFork', args), - push: (args: { - worktreePath: string - publish?: boolean - forceWithLease?: boolean - connectionId?: string - pushTarget?: unknown - }): Promise => ipcRenderer.invoke('git:push', args), - pull: (args: { - worktreePath: string - connectionId?: string - pushTarget?: GitPushTarget - }): Promise => ipcRenderer.invoke('git:pull', args), - fastForward: (args: { - worktreePath: string - connectionId?: string - pushTarget?: GitPushTarget - }): Promise => ipcRenderer.invoke('git:fastForward', args), - rebaseFromBase: (args: { - worktreePath: string - baseRef: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:rebaseFromBase', args), - branchDiff: (args: { - worktreePath: string - compare: { baseRef: string; baseOid: string; headOid: string; mergeBase: string } - filePath: string - oldPath?: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:branchDiff', args), - commitDiff: (args: { - worktreePath: string - commitOid: string - parentOid?: string | null - filePath: string - oldPath?: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:commitDiff', args), - commit: (args: { - worktreePath: string - message: string - connectionId?: string - }): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('git:commit', args), - generateCommitMessage: (args: { - worktreePath: string - worktreeId?: string - repoId?: string - connectionId?: string - sourceControlAiResolvedParams?: unknown - sourceControlAi?: unknown - agentCmdOverrides?: Record - }): Promise => ipcRenderer.invoke('git:generateCommitMessage', args), - discoverCommitMessageModels: (args: { - agentId: string - worktreePath?: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:discoverCommitMessageModels', args), - cancelGenerateCommitMessage: (args: { - worktreePath: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:cancelGenerateCommitMessage', args), - generatePullRequestFields: (args: { - worktreePath: string - worktreeId?: string - repoId?: string - base: string - title: string - body: string - draft: boolean - provider?: unknown - useTemplate?: boolean - connectionId?: string - sourceControlAiResolvedParams?: unknown - sourceControlAi?: unknown - agentCmdOverrides?: Record - }): Promise => ipcRenderer.invoke('git:generatePullRequestFields', args), - cancelGeneratePullRequestFields: (args: { - worktreePath: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:cancelGeneratePullRequestFields', args), - stage: (args: { - worktreePath: string - filePath: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:stage', args), - bulkStage: (args: { - worktreePath: string - filePaths: string[] - connectionId?: string - }): Promise => ipcRenderer.invoke('git:bulkStage', args), - unstage: (args: { - worktreePath: string - filePath: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:unstage', args), - bulkUnstage: (args: { - worktreePath: string - filePaths: string[] - connectionId?: string - }): Promise => ipcRenderer.invoke('git:bulkUnstage', args), - discard: (args: { - worktreePath: string - filePath: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:discard', args), - bulkDiscard: (args: { - worktreePath: string - filePaths: string[] - connectionId?: string - }): Promise => ipcRenderer.invoke('git:bulkDiscard', args), - remoteFileUrl: (args: { - worktreePath: string - relativePath: string - line: number - connectionId?: string - }): Promise => ipcRenderer.invoke('git:remoteFileUrl', args), - remoteCommitUrl: (args: { - worktreePath: string - sha: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:remoteCommitUrl', args) - }, - - ui: { - get: () => ipcRenderer.invoke('ui:get'), - set: (args) => ipcRenderer.invoke('ui:set', args), - // Same channel: the local invoke already rejects when main fails to apply. - setWithAck: (args) => ipcRenderer.invoke('ui:set', args), - recordFeatureInteraction: (id) => ipcRenderer.invoke('ui:recordFeatureInteraction', id), - onStateChanged: (callback: (ui: PersistedUIState) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, ui: PersistedUIState): void => - callback(ui) - ipcRenderer.on('ui:stateChanged', listener) - return () => ipcRenderer.removeListener('ui:stateChanged', listener) - }, - onOpenSettings: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:openSettings', listener) - return () => ipcRenderer.removeListener('ui:openSettings', listener) - }, - consumePendingOpenSettings: (): Promise => - ipcRenderer.invoke('ui:consumePendingOpenSettings'), - onOpenSkillShare: (callback: (shareId: string) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, shareId: string): void => - callback(shareId) - ipcRenderer.on('ui:openSkillShare', listener) - return () => ipcRenderer.removeListener('ui:openSkillShare', listener) - }, - consumePendingSkillShare: (): Promise => - ipcRenderer.invoke('ui:consumePendingSkillShare'), - onOpenSetupGuide: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:openSetupGuide', listener) - return () => ipcRenderer.removeListener('ui:openSetupGuide', listener) - }, - onOpenFeatureTour: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:openFeatureTour', listener) - return () => ipcRenderer.removeListener('ui:openFeatureTour', listener) - }, - onOpenCrashReport: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:openCrashReport', listener) - return () => ipcRenderer.removeListener('ui:openCrashReport', listener) - }, - onToggleLeftSidebar: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:toggleLeftSidebar', listener) - return () => ipcRenderer.removeListener('ui:toggleLeftSidebar', listener) - }, - onToggleRightSidebar: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:toggleRightSidebar', listener) - return () => ipcRenderer.removeListener('ui:toggleRightSidebar', listener) - }, - onToggleWorktreePalette: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:toggleWorktreePalette', listener) - return () => ipcRenderer.removeListener('ui:toggleWorktreePalette', listener) - }, - onToggleFloatingTerminal: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:toggleFloatingTerminal', listener) - return () => ipcRenderer.removeListener('ui:toggleFloatingTerminal', listener) - }, - onTerminalShortcutCaptured: ( - callback: (data: { actionId: KeybindingActionId }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { actionId: KeybindingActionId } - ) => callback(data) - ipcRenderer.on('ui:terminalShortcutCaptured', listener) - return () => ipcRenderer.removeListener('ui:terminalShortcutCaptured', listener) - }, - onOpenQuickOpen: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:openQuickOpen', listener) - return () => ipcRenderer.removeListener('ui:openQuickOpen', listener) - }, - onToggleQuickCommandsMenu: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:toggleQuickCommandsMenu', listener) - return () => ipcRenderer.removeListener('ui:toggleQuickCommandsMenu', listener) - }, - onOpenNewWorkspace: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:openNewWorkspace', listener) - return () => ipcRenderer.removeListener('ui:openNewWorkspace', listener) - }, - onDeleteCurrentWorkspace: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:deleteCurrentWorkspace', listener) - return () => ipcRenderer.removeListener('ui:deleteCurrentWorkspace', listener) - }, - onOpenWorkspaceBoard: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:openWorkspaceBoard', listener) - return () => ipcRenderer.removeListener('ui:openWorkspaceBoard', listener) - }, - onOpenTasks: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:openTasks', listener) - return () => ipcRenderer.removeListener('ui:openTasks', listener) - }, - onToggleAgentDashboard: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:toggleAgentDashboard', listener) - return () => ipcRenderer.removeListener('ui:toggleAgentDashboard', listener) - }, - onJumpToWorktreeIndex: (callback: (index: number) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, index: number) => callback(index) - ipcRenderer.on('ui:jumpToWorktreeIndex', listener) - return () => ipcRenderer.removeListener('ui:jumpToWorktreeIndex', listener) - }, - onJumpToTabIndex: (callback: (index: number) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, index: number) => callback(index) - ipcRenderer.on('ui:jumpToTabIndex', listener) - return () => ipcRenderer.removeListener('ui:jumpToTabIndex', listener) - }, - onWorktreeHistoryNavigate: ( - callback: (direction: 'back' | 'forward') => void - ): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, direction: 'back' | 'forward') => - callback(direction) - ipcRenderer.on('ui:worktreeHistoryNavigate', listener) - return () => ipcRenderer.removeListener('ui:worktreeHistoryNavigate', listener) - }, - onNewBrowserTab: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:newBrowserTab', listener) - return () => ipcRenderer.removeListener('ui:newBrowserTab', listener) - }, - onNewMarkdownTab: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:newMarkdownTab', listener) - return () => ipcRenderer.removeListener('ui:newMarkdownTab', listener) - }, - onNewSimulatorTab: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:newSimulatorTab', listener) - return () => ipcRenderer.removeListener('ui:newSimulatorTab', listener) - }, - onRequestTabCreate: ( - callback: (data: { - requestId: string - url: string - worktreeId?: string - browserPageId?: string - sessionProfileId?: string | null - sessionPartition?: string - activate?: boolean - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - requestId: string - url: string - worktreeId?: string - browserPageId?: string - sessionProfileId?: string | null - sessionPartition?: string - activate?: boolean - } - ) => callback(data) - ipcRenderer.on('browser:requestTabCreate', listener) - return () => ipcRenderer.removeListener('browser:requestTabCreate', listener) - }, - replyTabCreate: (reply: { - requestId: string - browserPageId?: string - error?: string - }): void => { - ipcRenderer.send('browser:tabCreateReply', reply) - }, - onRequestTabSetProfile: ( - callback: (data: { - requestId: string - browserPageId: string - profileId: string - sessionPartition?: string - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - requestId: string - browserPageId: string - profileId: string - sessionPartition?: string - } - ) => callback(data) - ipcRenderer.on('browser:requestTabSetProfile', listener) - return () => ipcRenderer.removeListener('browser:requestTabSetProfile', listener) - }, - replyTabSetProfile: (reply: { requestId: string; error?: string }): void => { - ipcRenderer.send('browser:tabSetProfileReply', reply) - }, - onRequestTabClose: ( - callback: (data: { requestId: string; tabId: string | null; worktreeId?: string }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { requestId: string; tabId: string | null; worktreeId?: string } - ) => callback(data) - ipcRenderer.on('browser:requestTabClose', listener) - return () => ipcRenderer.removeListener('browser:requestTabClose', listener) - }, - replyTabClose: (reply: { - requestId: string - error?: string - code?: 'browser_tab_not_found' - }): void => { - ipcRenderer.send('browser:tabCloseReply', reply) - }, - onNewTerminalTab: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:newTerminalTab', listener) - return () => ipcRenderer.removeListener('ui:newTerminalTab', listener) - }, - onFocusBrowserAddressBar: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:focusBrowserAddressBar', listener) - return () => ipcRenderer.removeListener('ui:focusBrowserAddressBar', listener) - }, - onFindInBrowserPage: browserFindSubscriptions.subscribe, - onReloadBrowserPage: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:reloadBrowserPage', listener) - return () => ipcRenderer.removeListener('ui:reloadBrowserPage', listener) - }, - onBrowserHistoryNavigate: (callback: (direction: 'back' | 'forward') => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, direction: 'back' | 'forward'): void => - callback(direction) - ipcRenderer.on('ui:browserHistoryNavigate', listener) - return () => ipcRenderer.removeListener('ui:browserHistoryNavigate', listener) - }, - onZoomBrowserPage: (callback: (direction: 'in' | 'out' | 'reset') => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, direction: 'in' | 'out' | 'reset') => - callback(direction) - ipcRenderer.on('ui:zoomBrowserPage', listener) - return () => ipcRenderer.removeListener('ui:zoomBrowserPage', listener) - }, - onScrollBrowserPage: ( - callback: (event: { browserPageId: string; deltaX: number; deltaY: number }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload: { browserPageId: string; deltaX: number; deltaY: number } - ) => callback(payload) - ipcRenderer.on('ui:scrollBrowserPage', listener) - return () => ipcRenderer.removeListener('ui:scrollBrowserPage', listener) - }, - onHardReloadBrowserPage: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:hardReloadBrowserPage', listener) - return () => ipcRenderer.removeListener('ui:hardReloadBrowserPage', listener) - }, - onCloseActiveTab: (callback: (payload?: CloseActiveTabPayload) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload?: unknown): void => { - const admitted = admitCloseActiveTabPayload(payload) - if (admitted.kind === 'legacy') { - callback() - } else if (admitted.kind === 'source') { - callback(admitted.payload) - } - } - ipcRenderer.on('ui:closeActiveTab', listener) - return () => ipcRenderer.removeListener('ui:closeActiveTab', listener) - }, - onCloseFloatingItem: (callback: (payload: { sourceId: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: { sourceId: string }) => - callback(payload) - ipcRenderer.on('ui:closeFloatingItem', listener) - return () => ipcRenderer.removeListener('ui:closeFloatingItem', listener) - }, - onSelectFloatingIndex: (callback: (payload: { index: number }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: { index: number }) => - callback(payload) - ipcRenderer.on('ui:selectFloatingIndex', listener) - return () => ipcRenderer.removeListener('ui:selectFloatingIndex', listener) - }, - onSwitchTab: (callback: (direction: 1 | -1) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, direction: 1 | -1) => callback(direction) - ipcRenderer.on('ui:switchTab', listener) - return () => ipcRenderer.removeListener('ui:switchTab', listener) - }, - onSwitchTabAcrossAllTypes: (callback: (direction: 1 | -1) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, direction: 1 | -1) => callback(direction) - ipcRenderer.on('ui:switchTabAcrossAllTypes', listener) - return () => ipcRenderer.removeListener('ui:switchTabAcrossAllTypes', listener) - }, - onSwitchRecentTab: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:switchRecentTab', listener) - return () => ipcRenderer.removeListener('ui:switchRecentTab', listener) - }, - onSwitchTerminalTab: (callback: (direction: 1 | -1) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, direction: 1 | -1) => callback(direction) - ipcRenderer.on('ui:switchTerminalTab', listener) - return () => ipcRenderer.removeListener('ui:switchTerminalTab', listener) - }, - onCtrlTabKeyDown: (callback: (data: { shiftKey: boolean }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { shiftKey: boolean }) => - callback(data) - ipcRenderer.on('ui:ctrlTabKeyDown', listener) - return () => ipcRenderer.removeListener('ui:ctrlTabKeyDown', listener) - }, - onCtrlTabKeyUp: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:ctrlTabKeyUp', listener) - return () => ipcRenderer.removeListener('ui:ctrlTabKeyUp', listener) - }, - onToggleStatusBar: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:toggleStatusBar', listener) - return () => ipcRenderer.removeListener('ui:toggleStatusBar', listener) - }, - onExportPdfRequested: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('export:requestPdf', listener) - return () => ipcRenderer.removeListener('export:requestPdf', listener) - }, - onAppMenuPaste: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:appMenuPaste', listener) - return () => ipcRenderer.removeListener('ui:appMenuPaste', listener) - }, - onAppMenuSelectionAction: (callback: (action: 'copy' | 'select-all') => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, action: 'copy' | 'select-all'): void => - callback(action) - ipcRenderer.on('ui:appMenuSelectionAction', listener) - return () => ipcRenderer.removeListener('ui:appMenuSelectionAction', listener) - }, - onEditableContextPaste: ( - callback: (data: { plainTextOnly: boolean }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { plainTextOnly: boolean } - ): void => callback({ plainTextOnly: data?.plainTextOnly === true }) - ipcRenderer.on('ui:editableContextPaste', listener) - return () => ipcRenderer.removeListener('ui:editableContextPaste', listener) - }, - onDictationKeyDown: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:dictationKeyDown', listener) - return () => ipcRenderer.removeListener('ui:dictationKeyDown', listener) - }, - onActivateWorktree: ( - callback: (data: { - repoId: string - worktreeId: string - setup?: WorktreeSetupLaunch - startup?: { command: string; env?: Record } - defaultTabs?: WorktreeDefaultTabsLaunch - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - repoId: string - worktreeId: string - setup?: WorktreeSetupLaunch - startup?: { command: string; env?: Record } - defaultTabs?: WorktreeDefaultTabsLaunch - } - ) => callback(data) - ipcRenderer.on('ui:activateWorktree', listener) - return () => ipcRenderer.removeListener('ui:activateWorktree', listener) - }, - onCreateTerminal: ( - callback: (data: { - requestId?: string - worktreeId: string - command?: string - cwd?: string - env?: Record - launchConfig?: SleepingAgentLaunchConfig - resumeProviderSession?: AgentProviderSessionMetadata - launchToken?: string - launchAgent?: TuiAgent - viewMode?: 'terminal' | 'chat' - title?: string - ptyId?: string - activate?: boolean - focus?: boolean - presentation?: RuntimeTerminalPresentation - surfaceOwner?: false - tabId?: string - leafId?: string - splitFromLeafId?: string - splitDirection?: 'horizontal' | 'vertical' - splitTelemetrySource?: TerminalPaneSplitSource - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - requestId?: string - worktreeId: string - command?: string - cwd?: string - env?: Record - launchConfig?: SleepingAgentLaunchConfig - resumeProviderSession?: AgentProviderSessionMetadata - launchToken?: string - launchAgent?: TuiAgent - viewMode?: 'terminal' | 'chat' - title?: string - ptyId?: string - activate?: boolean - focus?: boolean - presentation?: RuntimeTerminalPresentation - surfaceOwner?: false - tabId?: string - leafId?: string - splitFromLeafId?: string - splitDirection?: 'horizontal' | 'vertical' - splitTelemetrySource?: TerminalPaneSplitSource - } - ) => callback(data) - ipcRenderer.on('ui:createTerminal', listener) - return () => ipcRenderer.removeListener('ui:createTerminal', listener) - }, - onRequestTerminalCreate: ( - callback: (data: RuntimeTerminalCreateRequestPayload) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: RuntimeTerminalCreateRequestPayload - ) => callback(data) - ipcRenderer.on('terminal:requestTabCreate', listener) - return () => ipcRenderer.removeListener('terminal:requestTabCreate', listener) - }, - onRequestTerminalTabMount: ( - callback: (data: { worktreeId: string; tabId?: string; ptyId?: string }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { worktreeId: string; tabId?: string; ptyId?: string } - ) => callback(data) - ipcRenderer.on('terminal:requestTabMount', listener) - return () => ipcRenderer.removeListener('terminal:requestTabMount', listener) - }, - replyTerminalCreate: (reply: TerminalTabCreateReply): void => { - ipcRenderer.send('terminal:tabCreateReply', reply) - }, - onSplitTerminal: ( - callback: (data: { - tabId: string - paneRuntimeId: number - direction: 'horizontal' | 'vertical' - command?: string - worktreeId?: string - sourceLeafId?: string - telemetrySource?: TerminalPaneSplitSource - newLeafId?: string - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - tabId: string - paneRuntimeId: number - direction: 'horizontal' | 'vertical' - command?: string - worktreeId?: string - sourceLeafId?: string - telemetrySource?: TerminalPaneSplitSource - newLeafId?: string - } - ) => callback(data) - ipcRenderer.on('ui:splitTerminal', listener) - return () => ipcRenderer.removeListener('ui:splitTerminal', listener) - }, - onRenameTerminal: ( - callback: (data: { tabId: string; title: string | null }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { tabId: string; title: string | null } - ) => callback(data) - ipcRenderer.on('ui:renameTerminal', listener) - return () => ipcRenderer.removeListener('ui:renameTerminal', listener) - }, - onFocusTerminal: ( - callback: (data: { - tabId: string - worktreeId: string - leafId?: string | null - ackPaneKeyOnSuccess?: string - flashFocusedPane?: boolean - scrollToBottomIfOutputSinceLastView?: boolean - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - tabId: string - worktreeId: string - leafId?: string | null - ackPaneKeyOnSuccess?: string - flashFocusedPane?: boolean - scrollToBottomIfOutputSinceLastView?: boolean - } - ) => callback(data) - ipcRenderer.on('ui:focusTerminal', listener) - return () => ipcRenderer.removeListener('ui:focusTerminal', listener) - }, - onFocusEditorTab: ( - callback: (data: { tabId: string; worktreeId: string }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { tabId: string; worktreeId: string } - ) => callback(data) - ipcRenderer.on('ui:focusEditorTab', listener) - return () => ipcRenderer.removeListener('ui:focusEditorTab', listener) - }, - onCloseSessionTab: ( - callback: (data: { tabId: string; worktreeId: string }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { tabId: string; worktreeId: string } - ) => callback(data) - ipcRenderer.on('ui:closeSessionTab', listener) - return () => ipcRenderer.removeListener('ui:closeSessionTab', listener) - }, - onSessionTabCloseRequest: (callback) => { - const listener = ( - _event: Electron.IpcRendererEvent, - request: Parameters[0] - ) => callback(request) - ipcRenderer.on('ui:sessionTabCloseRequest', listener) - return () => ipcRenderer.removeListener('ui:sessionTabCloseRequest', listener) - }, - respondSessionTabClose: (response) => { - ipcRenderer.send('ui:sessionTabCloseResponse', response) - }, - onMoveSessionTab: ( - callback: (data: { worktreeId: string } & RuntimeMobileSessionTabMove) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { worktreeId: string } & RuntimeMobileSessionTabMove - ) => callback(data) - ipcRenderer.on('ui:moveSessionTab', listener) - return () => ipcRenderer.removeListener('ui:moveSessionTab', listener) - }, - onOpenFileFromMobile: ( - callback: (data: { - worktreeId: string - filePath: string - relativePath: string - runtimeEnvironmentId?: string - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - worktreeId: string - filePath: string - relativePath: string - runtimeEnvironmentId?: string - } - ) => callback(data) - ipcRenderer.on('ui:openFileFromMobile', listener) - return () => ipcRenderer.removeListener('ui:openFileFromMobile', listener) - }, - onOpenDiffFromMobile: ( - callback: (data: { - worktreeId: string - filePath: string - relativePath: string - staged: boolean - runtimeEnvironmentId?: string - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - worktreeId: string - filePath: string - relativePath: string - staged: boolean - runtimeEnvironmentId?: string - } - ) => callback(data) - ipcRenderer.on('ui:openDiffFromMobile', listener) - return () => ipcRenderer.removeListener('ui:openDiffFromMobile', listener) - }, - onMobileMarkdownRequest: ( - callback: (request: RuntimeMobileMarkdownRequest) => void - ): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, request: RuntimeMobileMarkdownRequest) => - callback(request) - ipcRenderer.on('ui:mobileMarkdownRequest', listener) - return () => ipcRenderer.removeListener('ui:mobileMarkdownRequest', listener) - }, - respondMobileMarkdownRequest: (response: RuntimeMobileMarkdownResponse): void => { - ipcRenderer.send('ui:mobileMarkdownResponse', response) - }, - onCloseTerminal: ( - callback: (data: { tabId: string; paneRuntimeId?: number }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { tabId: string; paneRuntimeId?: number } - ) => callback(data) - ipcRenderer.on('ui:closeTerminal', listener) - return () => ipcRenderer.removeListener('ui:closeTerminal', listener) - }, - onTerminalTabCloseRequest: (callback) => { - const listener = ( - _event: Electron.IpcRendererEvent, - request: Parameters[0] - ) => callback(request) - ipcRenderer.on('ui:terminalTabCloseRequest', listener) - return () => ipcRenderer.removeListener('ui:terminalTabCloseRequest', listener) - }, - respondTerminalTabClose: (response) => { - ipcRenderer.send('ui:terminalTabCloseResponse', response) - }, - onSleepWorktree: (callback: (data: { worktreeId: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) => - callback(data) - ipcRenderer.on('ui:sleepWorktree', listener) - return () => ipcRenderer.removeListener('ui:sleepWorktree', listener) - }, - onResumeSleepingAgents: (callback: (data: { worktreeId: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) => - callback(data) - ipcRenderer.on('ui:resumeSleepingAgents', listener) - return () => ipcRenderer.removeListener('ui:resumeSleepingAgents', listener) - }, - onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, direction: 'in' | 'out' | 'reset') => - callback(direction) - ipcRenderer.on('terminal:zoom', listener) - return () => ipcRenderer.removeListener('terminal:zoom', listener) - }, - readClipboardText: (options?: ReadClipboardTextOptions): Promise => - ipcRenderer.invoke('clipboard:readText', options), - readSelectionClipboardText: (options?: ReadClipboardTextOptions): Promise => - ipcRenderer.invoke('clipboard:readSelectionText', options), - saveClipboardImageAsTempFile: (args?: { - connectionId?: string | null - runtimeEnvironmentId?: string | null - }): Promise => ipcRenderer.invoke('clipboard:saveImageAsTempFile', args), - writeClipboardText: (text: string): Promise => - ipcRenderer.invoke('clipboard:writeText', text), - writeTerminalClipboardText: (text: string): Promise => - ipcRenderer.invoke('clipboard:writeTerminalText', text), - writeSelectionClipboardText: (text: string): Promise => - ipcRenderer.invoke('clipboard:writeSelectionText', text), - writeClipboardImage: (dataUrl: string): Promise => - ipcRenderer.invoke('clipboard:writeImage', dataUrl), - performNativePaste: (options?: { mode?: 'paste' | 'paste-and-match-style' }): void => { - ipcRenderer.send('ui:performNativePaste', { - mode: options?.mode === 'paste-and-match-style' ? 'paste-and-match-style' : 'paste' - }) - }, - performNativeSelectionAction: (action: 'copy' | 'select-all'): void => { - ipcRenderer.send('ui:performNativeSelectionAction', action) - }, - writeClipboardFile: ( - args: - | { - filePath: string - connectionId?: string | null - } - | string - ): Promise<{ ok: boolean; reason?: string }> => ipcRenderer.invoke('clipboard:writeFile', args), - onFileDrop: (callback: (data: NativeFileDropPayload) => void): (() => void) => - subscribeNativeFileDrop(callback), - getZoomLevel: (): number => webFrame.getZoomLevel(), - setZoomLevel: (level: number): void => webFrame.setZoomLevel(level), - syncTrafficLights: (zoomFactor: number): void => - ipcRenderer.send('ui:sync-traffic-lights', zoomFactor), - // Why: one-way send so main's before-input-event can synchronously skip Cmd+B while the markdown editor is focused (TipTap bold). - setMarkdownEditorFocused: (focused: boolean): void => { - ipcRenderer.send('ui:setMarkdownEditorFocused', focused) - }, - setRichMarkdownContextMenuTarget: (target: RichMarkdownContextMenuTableTarget | null): void => { - ipcRenderer.send(richMarkdownContextMenuTargetChannel, target) - }, - setTerminalInputFocused: (focused: boolean): void => { - ipcRenderer.send('ui:setTerminalInputFocused', focused) - }, - // Why: one atomic payload so main's synchronous before-input-event never sees a torn terminal=true/panel=false state. - setFloatingFocus: (state: { panelFocused: boolean; terminalFocused: boolean }): void => { - ipcRenderer.send('ui:setFloatingFocus', state) - }, - setShortcutRecorderFocused: (focused: boolean): void => { - ipcRenderer.send('ui:setShortcutRecorderFocused', focused) - }, - onRichMarkdownContextCommand: ( - callback: (payload: RichMarkdownContextMenuCommandPayload) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload: RichMarkdownContextMenuCommandPayload - ) => callback(payload) - ipcRenderer.on(richMarkdownContextMenuCommandChannel, listener) - return () => ipcRenderer.removeListener(richMarkdownContextMenuCommandChannel, listener) - }, - onFullscreenChanged: (callback: (isFullScreen: boolean) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, isFullScreen: boolean) => - callback(isFullScreen) - ipcRenderer.on('window:fullscreen-changed', listener) - return () => ipcRenderer.removeListener('window:fullscreen-changed', listener) - }, - /** Fired when the OS resumes from sleep — a focus-preserving wake fires no renderer focus/visibility events. */ - onSystemResumed: (callback: () => void): (() => void) => { - const listener = () => callback() - ipcRenderer.on('system:resumed', listener) - return () => ipcRenderer.removeListener('system:resumed', listener) - }, - /** Desktop custom titlebar only: minimize via renderer-drawn window controls. */ - minimize: (): void => { - ipcRenderer.send('window:minimize') - }, - /** Desktop custom titlebar only: toggle maximize/restore via renderer-drawn controls. */ - maximize: (): void => { - ipcRenderer.send('window:maximize') - }, - /** Desktop custom titlebar only: read initial maximize state on mount — maximize-changed only fires on transitions. */ - isMaximized: (): Promise => ipcRenderer.invoke('window:isMaximized'), - /** Desktop custom titlebar only: subscribe to maximize-state changes so the maximize button shows the right icon. */ - onMaximizeChanged: (callback: (isMaximized: boolean) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, isMaximized: boolean) => - callback(isMaximized) - ipcRenderer.on('window:maximize-changed', listener) - return () => ipcRenderer.removeListener('window:maximize-changed', listener) - }, - /** Desktop custom titlebar only: request close via main so the BrowserWindow 'close' event - * (and its terminal-running guard) still fires — window.close() is unreliable in sandboxed renderers. */ - requestClose: (): void => { - ipcRenderer.send('window:request-close') - }, - /** Desktop custom titlebar only: pop up the app menu at the cursor — Alt-reveal replacement for the ··· button. */ - popupMenu: (): void => { - ipcRenderer.send('menu:popup') - }, - /** Fired by main when the user tries to close the window; renderer confirms running - * terminals then calls confirmWindowClose(). isQuitting (Cmd+Q / app.quit) skips that dialog. */ - onWindowCloseRequested: (callback: (data: { isQuitting: boolean }) => void): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { isQuitting: boolean; requestId?: number } - ): void => { - // Why: main cannot reach will-quit while a frozen renderer owns the window close handshake. - ipcRenderer.send('window:close-request-received', data?.requestId) - callback({ isQuitting: data?.isQuitting ?? false }) - } - ipcRenderer.on('window:close-requested', listener) - return () => ipcRenderer.removeListener('window:close-requested', listener) - }, - /** Tell the main process to proceed with the window close. */ - confirmWindowClose: (): void => { - ipcRenderer.send('window:confirm-close') - }, - /** Report a genuine hidden→visible reveal so main can recover a stale (throttled) layout/compositor surface. */ - notifyWindowRevealed: (): void => { - ipcRenderer.send('ui:window-revealed') - } - } satisfies PreloadApi['ui'], - - stats: { - getSummary: (): Promise<{ - totalAgentsSpawned: number - totalPRsCreated: number - totalAgentTimeMs: number - firstEventAt: number | null - }> => ipcRenderer.invoke('stats:summary') - }, - - memory: { - getSnapshot: (): Promise => ipcRenderer.invoke('memory:getSnapshot') - }, - - claudeUsage: createUsageProviderApi(ipcRenderer, 'claudeUsage'), - codexUsage: createUsageProviderApi(ipcRenderer, 'codexUsage'), - openCodeUsage: createUsageProviderApi(ipcRenderer, 'openCodeUsage'), - - aiVault: { - listSessions: (args?: AiVaultListArgs): Promise => - ipcRenderer.invoke('aiVault:listSessions', args), - resolveSessionTitles: (args: AiVaultSessionTitlesArgs): Promise => - ipcRenderer.invoke('aiVault:resolveSessionTitles', args), - cancelListSessions: (args: { requestToken: string }): Promise => - ipcRenderer.invoke('aiVault:cancelListSessions', args), - prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs): Promise => - ipcRenderer.invoke('aiVault:prepareSessionResume', args), - listSubagentSessions: (args: AiVaultSubagentListArgs): Promise => - ipcRenderer.invoke('aiVault:listSubagentSessions', args), - getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs): Promise => - ipcRenderer.invoke('aiVault:getFirstUserPrompt', args), - deleteSession: (args: AiVaultDeleteSessionArgs): Promise => - ipcRenderer.invoke('aiVault:deleteSession', args), - onWindowFocused: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('aiVault:windowFocused', listener) - return () => ipcRenderer.removeListener('aiVault:windowFocused', listener) - } - }, - - nativeChat: { - readSession: ( - agent: AgentType, - sessionId: string, - limit?: number, - transcriptPath?: string - ): Promise => - ipcRenderer.invoke('nativeChat:readSession', { agent, sessionId, limit, transcriptPath }), - /** Start live tailing; onAppended fires with only newly-appended messages. Returns an unsubscribe fn that closes the watcher. */ - subscribe: ( - args: { - subscriptionId: string - agent: AgentType - sessionId: string - transcriptPath?: string - limit?: number - }, - onFrame: (frame: NativeChatSubscriptionFrame) => void - ): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: NativeChatAppendedPayload) => { - if (payload.subscriptionId === args.subscriptionId) { - onFrame(payload.frame) - } - } - ipcRenderer.on('nativeChat:appended', listener) - ipcRenderer.send('nativeChat:subscribe', args) - return () => { - ipcRenderer.removeListener('nativeChat:appended', listener) - ipcRenderer.send('nativeChat:unsubscribe', { subscriptionId: args.subscriptionId }) - } - } - }, - - runtime: { - syncWindowGraph: ( - graph: RuntimeRendererSyncWindowGraph - ): Promise => - ipcRenderer.invoke('runtime:syncWindowGraph', graph), - getStatus: (): Promise => ipcRenderer.invoke('runtime:getStatus'), - call: (args: { method: string; params?: unknown }): Promise> => - ipcRenderer.invoke('runtime:call', args), - subscribe: async ( - args: { method: string; params?: unknown }, - callback: (response: RuntimeRpcResponse) => void - ): Promise => { - const subscriptionId = `desktop-${crypto.randomUUID()}` - const channel = `runtime:subscription:${subscriptionId}` - const listener = (_event: Electron.IpcRendererEvent, response: RuntimeRpcResponse) => - callback(response) - ipcRenderer.on(channel, listener) - try { - await ipcRenderer.invoke('runtime:subscribe', { subscriptionId, ...args }) - } catch (error) { - ipcRenderer.removeListener(channel, listener) - throw error - } - return { - unsubscribe: () => { - ipcRenderer.removeListener(channel, listener) - ipcRenderer.send('runtime:unsubscribe', { subscriptionId }) - }, - sendBinary: () => { - throw new Error('Local runtime subscriptions do not accept binary input') - } - } - }, - getTerminalFitOverrides: (): Promise< - { ptyId: string; mode: 'mobile-fit' | 'remote-desktop-fit'; cols: number; rows: number }[] - > => ipcRenderer.invoke('runtime:getTerminalFitOverrides'), - getTerminalDrivers: (): Promise< - { - ptyId: string - driver: RuntimeTerminalDriverState - }[] - > => ipcRenderer.invoke('runtime:getTerminalDrivers'), - getBrowserDrivers: (): Promise< - { - browserPageId: string - driver: RuntimeBrowserDriverState - }[] - > => ipcRenderer.invoke('runtime:getBrowserDrivers'), - getBrowserRemoteViewerPages: (): Promise => - ipcRenderer.invoke('runtime:getBrowserRemoteViewerPages'), - getClientHostedBrowserRows: (): Promise => - ipcRenderer.invoke('runtime:getClientHostedBrowserRows'), - restoreTerminalFit: (ptyId: string): Promise<{ restored: boolean }> => - ipcRenderer.invoke('runtime:restoreTerminalFit', { ptyId }), - reclaimBrowserForDesktop: (browserPageId: string): Promise<{ reclaimed: boolean }> => - ipcRenderer.invoke('runtime:reclaimBrowserForDesktop', { browserPageId }), - onTerminalFitOverrideChanged: ( - callback: (event: { - ptyId: string - mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' - cols: number - rows: number - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - ptyId: string - mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' - cols: number - rows: number - } - ) => callback(data) - ipcRenderer.on('runtime:terminalFitOverrideChanged', listener) - return () => ipcRenderer.removeListener('runtime:terminalFitOverrideChanged', listener) - }, - onTerminalDriverChanged: ( - callback: (event: { ptyId: string; driver: RuntimeTerminalDriverState }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - ptyId: string - driver: RuntimeTerminalDriverState - } - ) => callback(data) - ipcRenderer.on('runtime:terminalDriverChanged', listener) - return () => ipcRenderer.removeListener('runtime:terminalDriverChanged', listener) - }, - onNativeChatLaunchDraftResolved: ( - callback: (event: { tabId: string; text: string; createdAt: number }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { tabId: string; text: string; createdAt: number } - ) => callback(data) - ipcRenderer.on('runtime:nativeChatLaunchDraftResolved', listener) - return () => ipcRenderer.removeListener('runtime:nativeChatLaunchDraftResolved', listener) - }, - onBrowserDriverChanged: ( - callback: (event: { browserPageId: string; driver: RuntimeBrowserDriverState }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - browserPageId: string - driver: RuntimeBrowserDriverState - } - ) => callback(data) - ipcRenderer.on('runtime:browserDriverChanged', listener) - return () => ipcRenderer.removeListener('runtime:browserDriverChanged', listener) - }, - onBrowserRemoteViewersChanged: ( - callback: (event: { browserPageId: string; hasRemoteViewers: boolean }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - browserPageId: string - hasRemoteViewers: boolean - } - ) => callback(data) - ipcRenderer.on('runtime:browserRemoteViewersChanged', listener) - return () => ipcRenderer.removeListener('runtime:browserRemoteViewersChanged', listener) - }, - onClientHostedBrowserRowsChanged: ( - callback: (event: ClientHostedBrowserRowsEvent) => void - ): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: ClientHostedBrowserRowsEvent) => - callback(data) - ipcRenderer.on('runtime:clientHostedBrowserRowsChanged', listener) - return () => ipcRenderer.removeListener('runtime:clientHostedBrowserRowsChanged', listener) - } - }, - - runtimeEnvironments: { - list: (): Promise => - ipcRenderer.invoke('runtimeEnvironments:list'), - addFromPairingCode: (args: { - name: string - pairingCode: string - }): Promise<{ environment: PublicKnownRuntimeEnvironment }> => - ipcRenderer.invoke('runtimeEnvironments:addFromPairingCode', args), - verifyAndAddFromPairingCode: (args: { - name: string - pairingCode: string - allowLoopback?: boolean - }): Promise => - ipcRenderer.invoke('runtimeEnvironments:verifyAndAddFromPairingCode', args), - resolve: (args: { selector: string }): Promise => - ipcRenderer.invoke('runtimeEnvironments:resolve', args), - remove: (args: { selector: string }): Promise<{ removed: PublicKnownRuntimeEnvironment }> => - ipcRenderer.invoke('runtimeEnvironments:remove', args), - disconnect: (args: { - selector: string - }): Promise<{ disconnected: PublicKnownRuntimeEnvironment }> => - ipcRenderer.invoke('runtimeEnvironments:disconnect', args), - connect: (args: { - selector: string - timeoutMs?: number - }): Promise> => - ipcRenderer.invoke('runtimeEnvironments:connect', args), - getStatus: (args: { - selector: string - timeoutMs?: number - observeOnly?: true - }): Promise> => - ipcRenderer.invoke('runtimeEnvironments:getStatus', args), - retryControlConnection: (args: { selector: string }): Promise => - ipcRenderer.invoke('runtimeEnvironments:retryControlConnection', args), - onSharedControlDiagnostics: ( - callback: (event: { - environmentId: string - transportGeneration: number - diagnostics: RemoteRuntimeSharedConnectionDiagnostics - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - environmentId: string - transportGeneration: number - diagnostics: RemoteRuntimeSharedConnectionDiagnostics - } - ): void => callback(data) - ipcRenderer.on(RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL, listener) - return () => ipcRenderer.removeListener(RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL, listener) - }, - prepareBrowserClientHostPlacement: (args) => - ipcRenderer.invoke('runtimeEnvironments:prepareBrowserClientHostPlacement', args), - retryConnectionsNow: (): Promise => - ipcRenderer.invoke('runtimeEnvironments:retryConnectionsNow'), - call: (args: { - selector: string - method: string - params?: unknown - timeoutMs?: number - expectedEnvironmentPairingRevision?: number - }): Promise> => - ipcRenderer.invoke('runtimeEnvironments:call', args), - subscribe: async ( - args: { - selector: string - method: string - params?: unknown - timeoutMs?: number - expectedEnvironmentPairingRevision?: number - }, - callbacks: { - onResponse: (response: RuntimeRpcResponse) => void - onBinary?: (bytes: Uint8Array) => void - onError?: (error: { code: string; message: string }) => void - onClose?: () => void - } - ): Promise => - subscribeRuntimeEnvironmentFromPreload(ipcRenderer, args, callbacks) - }, - - rateLimits: { - get: (): Promise => ipcRenderer.invoke('rateLimits:get'), - refresh: (): Promise => ipcRenderer.invoke('rateLimits:refresh'), - refreshCodexForTarget: (target: RateLimitRuntimeTarget): Promise => - ipcRenderer.invoke('rateLimits:refreshCodexForTarget', target), - consumeCodexResetCredit: (): Promise => - ipcRenderer.invoke('rateLimits:consumeCodexResetCredit'), - refreshClaudeForTarget: (target: RateLimitRuntimeTarget): Promise => - ipcRenderer.invoke('rateLimits:refreshClaudeForTarget', target), - setPollingInterval: (ms: number): Promise => - ipcRenderer.invoke('rateLimits:setPollingInterval', ms), - fetchInactiveClaudeAccounts: (): Promise => - ipcRenderer.invoke('rateLimits:fetchInactiveClaudeAccounts'), - fetchInactiveCodexAccounts: (): Promise => - ipcRenderer.invoke('rateLimits:fetchInactiveCodexAccounts'), - refreshMiniMax: (): Promise => ipcRenderer.invoke('rateLimits:refreshMiniMax'), - refreshGrok: (): Promise => ipcRenderer.invoke('rateLimits:refreshGrok'), - onUpdate: (callback: (state: RateLimitState) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, state: RateLimitState) => callback(state) - ipcRenderer.on('rateLimits:update', listener) - return () => ipcRenderer.removeListener('rateLimits:update', listener) - } - }, - - minimaxCredentials: { - getStatus: (): Promise<{ configured: boolean }> => - ipcRenderer.invoke('minimaxCredentials:getStatus'), - saveCookie: (cookie: string): Promise<{ configured: boolean }> => - ipcRenderer.invoke('minimaxCredentials:saveCookie', cookie), - clearCookie: (): Promise<{ configured: boolean }> => - ipcRenderer.invoke('minimaxCredentials:clearCookie') - }, - - grokAccounts: { - getStatus: (): Promise => ipcRenderer.invoke('grokAccounts:getStatus') - }, - - ssh: { - listTargets: (): Promise => ipcRenderer.invoke('ssh:listTargets'), - - listRemovedTargetLabels: (): Promise> => - ipcRenderer.invoke('ssh:listRemovedTargetLabels'), - - addTarget: (args: { target: SshTargetCreateInput }): Promise => - ipcRenderer.invoke('ssh:addTarget', args), - - updateTarget: (args: { id: string; updates: SshTargetUpdateInput }): Promise => - ipcRenderer.invoke('ssh:updateTarget', args), - - removeTarget: (args: { id: string }): Promise => - ipcRenderer.invoke('ssh:removeTarget', args), - - importConfig: (args?: { reAdopt?: boolean }): Promise => - ipcRenderer.invoke('ssh:importConfig', args), - - listConfigHosts: (args?: SshConfigHostListArgs): Promise => - ipcRenderer.invoke('ssh:listConfigHosts', args), - - resolveConfigHost: (args: { alias: string }): Promise => - ipcRenderer.invoke('ssh:resolveConfigHost', args), - - connect: async (args: { targetId: string }): Promise => { - const state: unknown = await ipcRenderer.invoke('ssh:connect', args) - return state ? admitSshConnectionStateForAuthorityReconciliation(state, args.targetId) : null - }, - - disconnect: (args: { targetId: string }): Promise => - ipcRenderer.invoke('ssh:disconnect', args), - - terminateSessions: (args: { targetId: string }): Promise => - ipcRenderer.invoke('ssh:terminateSessions', args), - - resetRelay: (args: { targetId: string }): Promise => - ipcRenderer.invoke('ssh:resetRelay', args), - - getState: async (args: { targetId: string }): Promise => { - const state: unknown = await ipcRenderer.invoke('ssh:getState', args) - return state ? admitSshConnectionStateForAuthorityReconciliation(state, args.targetId) : null - }, - - needsPassphrasePrompt: (args: { targetId: string }): Promise => - ipcRenderer.invoke('ssh:needsPassphrasePrompt', args), - - testConnection: async (args: { - targetId: string - }): Promise<{ success: boolean; error?: string; state?: SshConnectionState }> => { - const result: { success: boolean; error?: string; state?: unknown } = - await ipcRenderer.invoke('ssh:testConnection', args) - const state = result.state - ? admitSshConnectionStateForAuthorityReconciliation(result.state, args.targetId) - : null - return { ...result, ...(state ? { state } : { state: undefined }) } - }, - - onStateChanged: ( - callback: (data: { targetId: string; state: SshConnectionState }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { targetId: string; state: unknown } - ): void => { - const state = admitSshConnectionStateForAuthorityReconciliation(data.state, data.targetId) - if (state) { - callback({ targetId: data.targetId, state }) - } - } - ipcRenderer.on('ssh:state-changed', listener) - return () => ipcRenderer.removeListener('ssh:state-changed', listener) - }, - - addPortForward: (args: { - targetId: string - localPort: number - remoteHost: string - remotePort: number - label?: string - }): Promise => ipcRenderer.invoke('ssh:addPortForward', args), - - updatePortForward: (args: { - id: string - targetId: string - localPort: number - remoteHost: string - remotePort: number - label?: string - }): Promise => ipcRenderer.invoke('ssh:updatePortForward', args), - - removePortForward: (args: { id: string }): Promise => - ipcRenderer.invoke('ssh:removePortForward', args), - - listPortForwards: (args?: { targetId?: string }): Promise => - ipcRenderer.invoke('ssh:listPortForwards', args), - - listDetectedPorts: async (args: { targetId: string }): Promise => - admitSshDetectedPorts(await ipcRenderer.invoke('ssh:listDetectedPorts', args)), - - onPortForwardsChanged: ( - callback: (data: { targetId: string; forwards: PortForwardEntry[] }) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - data: { targetId: string; forwards: PortForwardEntry[] } - ) => callback(data) - ipcRenderer.on('ssh:port-forwards-changed', handler) - return () => ipcRenderer.removeListener('ssh:port-forwards-changed', handler) - }, - - onDetectedPortsChanged: ( - callback: (data: { targetId: string; ports: EnrichedDetectedPort[] }) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - data: { targetId: string; ports: unknown } - ) => callback({ targetId: data.targetId, ports: admitSshDetectedPorts(data.ports) }) - ipcRenderer.on('ssh:detected-ports-changed', handler) - return () => ipcRenderer.removeListener('ssh:detected-ports-changed', handler) - }, - - browseDir: (args: { - targetId: string - dirPath: string - }): Promise<{ - entries: { name: string; isDirectory: boolean }[] - resolvedPath: string - pathFlavor: FilesystemPathFlavor - }> => ipcRenderer.invoke('ssh:browseDir', args), - - onCredentialRequest: ( - callback: (data: { - requestId: string - targetId: string - kind: 'passphrase' | 'password' | 'keyboard-interactive' - detail: string - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - requestId: string - targetId: string - kind: 'passphrase' | 'password' | 'keyboard-interactive' - detail: string - } - ) => callback(data) - ipcRenderer.on('ssh:credential-request', listener) - return () => ipcRenderer.removeListener('ssh:credential-request', listener) - }, - - onCredentialResolved: (callback: (data: { requestId: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { requestId: string }) => - callback(data) - ipcRenderer.on('ssh:credential-resolved', listener) - return () => ipcRenderer.removeListener('ssh:credential-resolved', listener) - }, - - submitCredential: (args: { requestId: string; value: string | null }): Promise => - ipcRenderer.invoke('ssh:submitCredential', args) - }, - - // Orca automation CRUD rides the local runtime RPC surface (`runtime:call`), - // so only external-manager and dispatch-loop plumbing stays on IPC. - automations: { - listExternalManagerForOwner: ( - request: ScopedExternalManagerListRequest - ): Promise => - ipcRenderer.invoke('automations:listExternalManagerForOwner', request), - listExternalRunsForOwner: ( - request: ScopedExternalManagerRunsRequest - ): Promise => - ipcRenderer.invoke('automations:listExternalRunsForOwner', request), - createExternalForOwner: (request: ScopedExternalManagerCreateRequest): Promise => - ipcRenderer.invoke('automations:createExternalForOwner', request), - updateExternalForOwner: (request: ScopedExternalManagerUpdateRequest): Promise => - ipcRenderer.invoke('automations:updateExternalForOwner', request), - runExternalActionForOwner: (request: ScopedExternalManagerActionRequest): Promise => - ipcRenderer.invoke('automations:runExternalActionForOwner', request), - retainExternalScopes: (request: { owners: readonly AutomationOwnerRef[] }): Promise => - ipcRenderer.invoke('automations:retainExternalScopes', request), - runPrecheck: (args: { - automationId: string - runId: string - }): Promise => - ipcRenderer.invoke('automations:runPrecheck', args), - markDispatchResult: (result: AutomationDispatchResult): Promise => - ipcRenderer.invoke('automations:markDispatchResult', result), - snapshotWorkspaceName: (args: { workspaceId: string; displayName: string }): Promise => - ipcRenderer.invoke('automations:snapshotWorkspaceName', args), - rendererReady: (): Promise => ipcRenderer.invoke('automations:rendererReady'), - onDispatchRequested: (callback: (request: AutomationDispatchRequest) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, request: AutomationDispatchRequest) => - callback(request) - ipcRenderer.on('automations:dispatchRequested', listener) - return () => ipcRenderer.removeListener('automations:dispatchRequested', listener) - }, - onChanged: (callback: (payload: AutomationsChangedPayload) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: AutomationsChangedPayload) => - callback(payload) - ipcRenderer.on('automations:changed', listener) - return () => ipcRenderer.removeListener('automations:changed', listener) - } - }, - - e2e: { - getConfig: () => preloadE2EConfig - }, - - mobile: { - listNetworkInterfaces: (): Promise<{ - interfaces: { name: string; address: string; hasDefaultRoute?: boolean }[] - }> => ipcRenderer.invoke('mobile:listNetworkInterfaces'), - - getPairingQR: (args?: { - address?: string - connectionMode?: MobilePairingConnectionMode - rotate?: boolean - }): Promise< - | { - available: false - reason?: string - guidance?: string - relayFailure?: MobileRelayMintFailure - } - | { - available: true - qrDataUrl: string | null - /** Natural bitmap width and height in pixels. */ - qrSize: number | null - qrError?: 'encoding_failed' - pairingUrl: string - /** Null when no direct address was advertised — the QR pairs over Relay alone. */ - endpoint: string | null - deviceId: string - connectionMode: MobilePairingConnectionMode - } - > => ipcRenderer.invoke('mobile:getPairingQR', args), - - getWindowsFirewallStatus: (args?: { address?: string }) => - ipcRenderer.invoke('mobile:getWindowsFirewallStatus', args), - - repairWindowsFirewall: () => ipcRenderer.invoke('mobile:repairWindowsFirewall'), - - openWindowsNetworkSettings: () => ipcRenderer.invoke('mobile:openWindowsNetworkSettings'), - - getRuntimePairingUrl: (args?: { - address?: string - rotate?: boolean - // Why: the widen is one-way and host-wide, so main must gate it on the reach the user picked, not - // on how the typed address happens to look (a Custom loopback may front an SSH tunnel). - reach?: RuntimePairingReach - }): Promise< - | { available: false; reason?: 'network_exposure_failed'; guidance?: string } - | { - available: true - pairingUrl: string - webClientUrl: string | null - endpoint: string - deviceId: string - } - > => ipcRenderer.invoke('mobile:getRuntimePairingUrl', args), - - listDevices: (): Promise<{ - devices: { deviceId: string; name: string; pairedAt: number; lastSeenAt: number }[] - }> => ipcRenderer.invoke('mobile:listDevices'), - - revokeDevice: (args: { deviceId: string }): Promise<{ revoked: boolean }> => - ipcRenderer.invoke('mobile:revokeDevice', args), - - listRuntimeAccessGrants: () => ipcRenderer.invoke('mobile:listRuntimeAccessGrants'), - - revokeRuntimeAccess: (args: { deviceId: string }): Promise<{ revoked: boolean }> => - ipcRenderer.invoke('mobile:revokeRuntimeAccess', args), - - isWebSocketReady: (): Promise<{ ready: boolean; endpoint: string | null }> => - ipcRenderer.invoke('mobile:isWebSocketReady'), - - getRelayStatus: (): Promise<{ status: MobileRelayStatus }> => - ipcRenderer.invoke('mobile:getRelayStatus'), - - onRelayStatusChanged: (callback: (status: MobileRelayStatus) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, status: MobileRelayStatus) => - callback(status) - ipcRenderer.on('mobile:relayStatusChanged', listener) - return () => ipcRenderer.removeListener('mobile:relayStatusChanged', listener) - }, - - consumePendingUnpairedDeviceAuthFailure: (): Promise => - ipcRenderer.invoke('mobile:consumePendingUnpairedDeviceAuthFailure'), - - /** Fires (throttled, once per session) when an unpaired phone repeatedly fails direct-transport auth. */ - onUnpairedDeviceAuthFailure: (callback: () => void): (() => void) => { - const listener = () => callback() - ipcRenderer.on('mobile:unpairedDeviceAuthFailure', listener) - return () => ipcRenderer.removeListener('mobile:unpairedDeviceAuthFailure', listener) - } - }, - - agentStatus: { - /** Listen for agent status updates forwarded from native hook receivers. */ - onSet: (callback: (data: AgentStatusIpcPayload) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: AgentStatusIpcPayload) => - callback(data) - ipcRenderer.on('agentStatus:set', listener) - return () => ipcRenderer.removeListener('agentStatus:set', listener) - }, - onClear: (callback: (data: AgentStatusClearIpcPayload) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: AgentStatusClearIpcPayload) => - callback(data) - ipcRenderer.on('agentStatus:clear', listener) - return () => ipcRenderer.removeListener('agentStatus:clear', listener) - }, - /** Pull cached hook statuses after renderer hydration, so startup replays aren't lost before tabs exist. */ - getSnapshot: (): Promise => - ipcRenderer.invoke('agentStatus:getSnapshot'), - inferInterrupt: (request: AgentInterruptInferenceRequest): Promise => - ipcRenderer.invoke('agentStatus:inferInterrupt', request), - inferQuestionAnswered: (request: AgentQuestionAnsweredInferenceRequest): Promise => - ipcRenderer.invoke('agentStatus:inferQuestionAnswered', request), - onMigrationUnsupported: ( - callback: (entry: MigrationUnsupportedPtyEntry) => void - ): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, entry: MigrationUnsupportedPtyEntry) => - callback(entry) - ipcRenderer.on('agentStatus:migrationUnsupported', listener) - return () => ipcRenderer.removeListener('agentStatus:migrationUnsupported', listener) - }, - onMigrationUnsupportedClear: (callback: (data: { ptyId: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { ptyId: string }) => - callback(data) - ipcRenderer.on('agentStatus:migrationUnsupportedClear', listener) - return () => ipcRenderer.removeListener('agentStatus:migrationUnsupportedClear', listener) - }, - onLegacyWorkerTerminalRecovery: ( - callback: (data: { - paneKey: string - resolution: 'adopted' | 'exited' | 'rolled_back' - ptyId?: string - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - paneKey: string - resolution: 'adopted' | 'exited' | 'rolled_back' - ptyId?: string - } - ) => callback(data) - ipcRenderer.on('agentStatus:legacyWorkerTerminalRecovery', listener) - return () => ipcRenderer.removeListener('agentStatus:legacyWorkerTerminalRecovery', listener) - }, - getMigrationUnsupportedSnapshot: (): Promise => - ipcRenderer.invoke('agentStatus:getMigrationUnsupportedSnapshot'), - /** Drop the cached hook status for a paneKey on both sides (memory + on-disk) so a relaunch can't resurrect a dismissed row. */ - drop: (paneKey: string): void => { - ipcRenderer.send('agentStatus:drop', paneKey) - }, - reconcileEndedProcess: (paneKey: string): void => { - ipcRenderer.send('agentStatus:reconcileEndedProcess', paneKey) - }, - /** Drop all cached hook statuses under one terminal tab prefix; fired on explicit tab close even without a local row. */ - dropByTabPrefix: (tabId: string): void => { - ipcRenderer.send('agentStatus:dropByTabPrefix', tabId) - }, - retirePaneAuthority: (paneKey: string): void => { - ipcRenderer.send('agentStatus:retirePaneAuthority', paneKey) - }, - restorePaneAuthority: (paneKey: string): void => { - ipcRenderer.send('agentStatus:restorePaneAuthority', paneKey) - }, - transferPaneAuthority: (args: { - fromPaneKey: string - toPaneKey: string - ptyId?: string - }): void => { - ipcRenderer.send('agentStatus:transferPaneAuthority', args) - } - }, - - speech: { - getCatalog: (): Promise => ipcRenderer.invoke('speech:getCatalog'), - getModelStates: (): Promise => ipcRenderer.invoke('speech:getModelStates'), - getOpenAiApiKeyStatus: (): Promise<{ configured: boolean }> => - ipcRenderer.invoke('speech:getOpenAiApiKeyStatus'), - saveOpenAiApiKey: (apiKey: string): Promise<{ configured: boolean }> => - ipcRenderer.invoke('speech:saveOpenAiApiKey', apiKey), - clearOpenAiApiKey: (): Promise<{ configured: boolean }> => - ipcRenderer.invoke('speech:clearOpenAiApiKey'), - downloadModel: (modelId: string): Promise => - ipcRenderer.invoke('speech:downloadModel', modelId), - cancelDownload: (modelId: string): Promise => - ipcRenderer.invoke('speech:cancelDownload', modelId), - deleteModel: (modelId: string): Promise => - ipcRenderer.invoke('speech:deleteModel', modelId), - startDictation: ( - modelId: string, - hotwords: string[] | undefined, - sessionId: string - ): Promise => ipcRenderer.invoke('speech:startDictation', modelId, hotwords, sessionId), - feedAudio: (samples: Float32Array, sampleRate: number, sessionId = 'desktop'): Promise => - // Why: Float32Array is zeroed crossing the contextBridge/IPC boundary; wrap in a Buffer to preserve bytes. - ipcRenderer.invoke( - 'speech:feedAudio', - Buffer.from(samples.buffer, samples.byteOffset, samples.byteLength), - sampleRate, - sessionId - ), - stopDictation: (sessionId = 'desktop'): Promise => - ipcRenderer.invoke('speech:stopDictation', sessionId), - - onPartialTranscript: (callback: (data: SpeechTranscriptEvent) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: SpeechTranscriptEvent): void => - callback(data) - ipcRenderer.on('speech:partial', listener) - return () => ipcRenderer.removeListener('speech:partial', listener) - }, - onFinalTranscript: (callback: (data: SpeechTranscriptEvent) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: SpeechTranscriptEvent): void => - callback(data) - ipcRenderer.on('speech:final', listener) - return () => ipcRenderer.removeListener('speech:final', listener) - }, - onDownloadProgress: ( - callback: (data: { modelId: string; progress: number }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { modelId: string; progress: number } - ): void => callback(data) - ipcRenderer.on('speech:downloadProgress', listener) - return () => ipcRenderer.removeListener('speech:downloadProgress', listener) - }, - onReady: (callback: (data: SpeechLifecycleEvent) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: SpeechLifecycleEvent): void => - callback(data) - ipcRenderer.on('speech:ready', listener) - return () => ipcRenderer.removeListener('speech:ready', listener) - }, - onStopped: (callback: (data: SpeechLifecycleEvent) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: SpeechLifecycleEvent): void => - callback(data) - ipcRenderer.on('speech:stopped', listener) - return () => ipcRenderer.removeListener('speech:stopped', listener) - }, - onError: (callback: (data: SpeechErrorEvent) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: SpeechErrorEvent): void => - callback(data) - ipcRenderer.on('speech:error', listener) - return () => ipcRenderer.removeListener('speech:error', listener) - } - } + app: appApi, + orcaProfiles: orcaProfilesApi, + platform: platformApi, + wsl: wslApi, + pwsh: pwshApi, + gitBash: gitBashApi, + plugins: pluginsApi, + repos: reposApi, + projects: projectsApi, + projectGroups: projectGroupsApi, + folderWorkspaces: folderWorkspacesApi, + sparsePresets: sparsePresetsApi, + worktrees: worktreesApi, + workspaceCleanup: workspaceCleanupApi, + workspaceSpace: workspaceSpaceApi, + workspacePorts: workspacePortsApi, + pty: ptyApi, + feedback: feedbackApi, + crashReports: crashReportsApi, + export: exportApi, + gh: ghApi, + hostedReview: hostedReviewApi, + gl: glApiBridge, + bitbucket: bitbucketApi, + linear: linearApi, + jira: jiraApi, + starNag: starNagApi, + telemetryTrack: telemetryTrackApi, + telemetrySetOptIn: telemetrySetOptInApi, + telemetryAcknowledgeBanner: telemetryAcknowledgeBannerApi, + telemetryGetConsentState: telemetryGetConsentStateApi, + diagnostics: diagnosticsApi, + settings: settingsApi, + agentAwake: agentAwakeApi, + localhostWorktreeLabels: localhostWorktreeLabelsApi, + keybindings: keybindingsApi, + codexAccounts: codexAccountsApi, + claudeAccounts: claudeAccountsApi, + cli: cliApi, + codexConfigSync: codexConfigSyncApi, + agentTrust: agentTrustApi, + preflight: preflightApi, + notifications: notificationsApi, + onboarding: onboardingApi, + dashboard: dashboardApi, + terminalPreview: terminalPreviewApi, + macosTccPrompts: macosTccPromptsApi, + developerPermissions: developerPermissionsApi, + computerUsePermissions: computerUsePermissionsApi, + shell: shellApi, + skills: skillsApi, + pet: petApi, + browser: browserApi, + emulator: emulatorApi, + hooks: hooksApi, + ephemeralVm: ephemeralVmApi, + cache: cacheApi, + session: sessionApi, + remoteWorkspace: remoteWorkspaceApi, + updater: updaterApi, + docPreview: docPreviewApi, + notebook: notebookApi, + fs: fsApi, + git: gitApi, + ui: uiApi, + stats: statsApi, + memory: memoryApi, + claudeUsage: claudeUsageApi, + codexUsage: codexUsageApi, + openCodeUsage: openCodeUsageApi, + aiVault: aiVaultApi, + nativeChat: nativeChatApi, + runtime: runtimeApi, + runtimeEnvironments: runtimeEnvironmentsApi, + rateLimits: rateLimitsApi, + minimaxCredentials: minimaxCredentialsApi, + grokAccounts: grokAccountsApi, + ssh: sshApi, + automations: automationsApi, + e2e: e2eApi, + mobile: mobileApi, + agentStatus: agentStatusApi, + speech: speechApi } -// Expose Electron APIs via contextBridge when context-isolated, otherwise attach to the DOM global. if (process.contextIsolated) { try { contextBridge.exposeInMainWorld('electron', electronAPI) diff --git a/src/preload/preload-runtime-support.ts b/src/preload/preload-runtime-support.ts new file mode 100644 index 00000000000..b4ea3260ac8 --- /dev/null +++ b/src/preload/preload-runtime-support.ts @@ -0,0 +1,165 @@ +import { ipcRenderer, webUtils } from 'electron' +import { createBrowserClientPageRendererRequests } from './browser-client-page-renderer-requests' +import { createBrowserFindSubscriptions } from './browser-find-subscriptions' +import { registerRendererRestartIpcRelays } from './renderer-restart-wiring' +import { createUpdaterQuitAbortRelay } from '../shared/renderer-restart-preparation' +import { ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT } from '../shared/updater-renderer-events' +import { + ORCA_INTERNAL_FILE_DRAG_TYPE, + createNativeFileDropPayload, + createRejectedNativeFileDropPayload, + hasNativeFileDragTypes, + NATIVE_FILE_DROP_MAX_PATHS, + resolveNativeFileDropPath, + type NativeDropResolution, + type NativeFileDropPayload, + type NativeFileDropPathEntry +} from '../shared/native-file-drop' + +/** Joins the synchronous unload checkpoint with its durable renderer write. */ +export async function awaitBeforeUnloadCheckpoint(): Promise { + const result = (await ipcRenderer.invoke('app:await-before-unload-checkpoint')) as { + ok?: unknown + } + if (result?.ok !== true) { + throw new Error('Failed to persist renderer state before unload.') + } +} + +export const startupDiagnosticsEnabled = process.env.ORCA_STARTUP_DIAGNOSTICS === '1' + +export function getLinuxDisplayServer(): 'wayland' | 'x11' | null { + if (process.platform !== 'linux') { + return null + } + if ( + process.env.WAYLAND_DISPLAY || + process.env.XDG_SESSION_TYPE?.toLowerCase() === 'wayland' || + process.env.ELECTRON_OZONE_PLATFORM_HINT?.toLowerCase() === 'wayland' + ) { + return 'wayland' + } + return process.env.DISPLAY ? 'x11' : null +} + +type NativeFileDropCallback = (data: NativeFileDropPayload) => void +const nativeFileDropCallbacks: NativeFileDropCallback[] = [] +let nativeFileDropListenerRegistered = false + +const onNativeFileDrop = (_event: Electron.IpcRendererEvent, data: NativeFileDropPayload): void => { + for (const callback of Array.from(nativeFileDropCallbacks)) { + callback(data) + } +} + +export function subscribeNativeFileDrop(callback: NativeFileDropCallback): () => void { + nativeFileDropCallbacks.push(callback) + if (!nativeFileDropListenerRegistered) { + ipcRenderer.on('terminal:file-drop', onNativeFileDrop) + nativeFileDropListenerRegistered = true + } + return () => { + const callbackIndex = nativeFileDropCallbacks.indexOf(callback) + if (callbackIndex !== -1) { + nativeFileDropCallbacks.splice(callbackIndex, 1) + } + if (nativeFileDropCallbacks.length === 0 && nativeFileDropListenerRegistered) { + ipcRenderer.removeListener('terminal:file-drop', onNativeFileDrop) + nativeFileDropListenerRegistered = false + } + } +} + +function resolveNativeFileDrop(event: DragEvent): NativeDropResolution | null { + const pathEntries: NativeFileDropPathEntry[] = [] + for (const entry of event.composedPath()) { + if (entry instanceof HTMLElement) { + pathEntries.push({ + nativeFileDropTarget: entry.dataset.nativeFileDropTarget, + nativeFileDropDir: entry.dataset.nativeFileDropDir, + terminalTabId: entry.dataset.terminalTabId, + terminalPaneLeafId: entry.dataset.terminalPaneLeafId ?? entry.dataset.leafId + }) + } + } + return resolveNativeFileDropPath(pathEntries) +} + +/** Installs the one preload-side listener that converts native File objects to paths. */ +export function installNativeFileDropHandlers(): void { + document.addEventListener( + 'dragover', + (event) => { + if (event.dataTransfer && !hasNativeFileDragTypes(event.dataTransfer.types)) { + return + } + event.preventDefault() + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'copy' + } + }, + true + ) + document.addEventListener( + 'drop', + (event) => { + if (event.dataTransfer?.types.includes(ORCA_INTERNAL_FILE_DRAG_TYPE)) { + return + } + event.preventDefault() + event.stopPropagation() + const files = event.dataTransfer?.files + if (!files || files.length === 0) { + return + } + const resolution = resolveNativeFileDrop(event) + if (files.length > NATIVE_FILE_DROP_MAX_PATHS) { + ipcRenderer.send( + 'terminal:file-dropped-from-preload', + createRejectedNativeFileDropPayload({ + byteLength: 0, + pathCount: files.length, + reason: 'too-many-paths', + status: 'rejected' + }) + ) + return + } + const paths: string[] = [] + for (let index = 0; index < files.length; index += 1) { + const filePath = webUtils.getPathForFile(files[index]) + if (filePath) { + paths.push(filePath) + } + } + if (paths.length === 0 || resolution?.target === 'rejected') { + return + } + const payload = createNativeFileDropPayload(resolution, paths) + if (payload) { + ipcRenderer.send('terminal:file-dropped-from-preload', payload) + } + }, + true + ) +} + +export const browserFindSubscriptions = createBrowserFindSubscriptions() +export const browserClientPageRendererRequests = createBrowserClientPageRendererRequests({ + ipc: ipcRenderer, + isTopFrame: () => window.top === window +}) + +/** Registers browser find forwarding once for this preload context. */ +export function installBrowserFindListener(): void { + ipcRenderer.on('ui:findInBrowserPage', (_event, source: unknown) => { + browserFindSubscriptions.dispatch(source) + }) +} + +export const updaterQuitAbortRelay = createUpdaterQuitAbortRelay( + window, + ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT +) + +registerRendererRestartIpcRelays(ipcRenderer, window, updaterQuitAbortRelay)