diff --git a/main/src/core/importBoundary.test.ts b/main/src/core/importBoundary.test.ts index 4881387..cbf39df 100644 --- a/main/src/core/importBoundary.test.ts +++ b/main/src/core/importBoundary.test.ts @@ -1,6 +1,11 @@ import fs from 'fs'; import path from 'path'; import { describe, expect, it } from 'vitest'; +import { + DAEMON_OWNED_CHANNEL_PREFIXES, + DAEMON_OWNED_EXACT_CHANNELS, + ELECTRON_ADAPTER_ONLY_CHANNELS, +} from '../../../shared/types/daemon'; const MAIN_SRC_ROOT = path.resolve(process.cwd(), 'src'); @@ -46,11 +51,47 @@ describe('daemon/client import boundary', () => { }); it('keeps the daemon server free of Electron bootstrap imports', () => { - const source = readMainSrcFile('daemon/server.ts'); + const daemonBoundaryFiles = [ + 'daemon/server.ts', + 'ipc/daemon.ts', + ]; + + for (const relativePath of daemonBoundaryFiles) { + const source = readMainSrcFile(relativePath); + + expect(source, relativePath).not.toContain("from 'electron'"); + expect(source, relativePath).not.toContain('mainWindow'); + expect(source, relativePath).not.toMatch(/from ['"](?:\.\.\/)+(?:index)['"]/); + expect(source, relativePath).not.toMatch(/from ['"](?:\.\.\/)+(?:index)\.ts['"]/); + } + }); - expect(source).not.toContain("from 'electron'"); - expect(source).not.toContain('mainWindow'); - expect(source).not.toMatch(/from ['"](?:\.\.\/)+(?:index)['"]/); - expect(source).not.toMatch(/from ['"](?:\.\.\/)+(?:index)\.ts['"]/); + it('routes daemon-owned preload invokes through the runtime-safe bridge helper', () => { + const source = readMainSrcFile('preload.ts'); + + expect(source).toContain('function isDaemonOwnedChannel'); + expect(source).toContain('isDaemonOwnedChannel'); + expect(source).not.toContain("../../shared/types/daemon"); + expect(source).not.toContain("from './daemon/daemonChannels'"); + expect(source).toContain("ipcRenderer.invoke('daemon:invoke', channel, ...args)"); + expect(source).not.toMatch( + /ipcRenderer\.invoke\('(sessions:|projects:|folders:|prompts:|resource-monitor:|panels:|terminal:|logs:|git:(cancel-status-for-project|clone-repo|commit|execute-project|file-status|get-github-remote|restore|revert)|file:(copy|delete|duplicate|exists|getPath|list|move|read|read-binary|read-project|readAtRevision|rename|resolveAbsolutePath|search|write|write-binary|write-project))/, + ); + }); + + it('keeps preload channel ownership literals aligned with the shared daemon contract', () => { + const source = readMainSrcFile('preload.ts'); + + for (const prefix of DAEMON_OWNED_CHANNEL_PREFIXES) { + expect(source).toContain(`'${prefix}'`); + } + + for (const channel of DAEMON_OWNED_EXACT_CHANNELS) { + expect(source).toContain(`'${channel}'`); + } + + for (const channel of ELECTRON_ADAPTER_ONLY_CHANNELS) { + expect(source).toContain(`'${channel}'`); + } }); }); diff --git a/main/src/daemon/commandRegistry.ts b/main/src/daemon/commandRegistry.ts index 2de857f..b34062c 100644 --- a/main/src/daemon/commandRegistry.ts +++ b/main/src/daemon/commandRegistry.ts @@ -1,4 +1,4 @@ -import { isDaemonOwnedChannel } from '../../../shared/types/daemon'; +import { isDaemonOwnedChannel } from './daemonChannels'; export type PaneCommandHandler = ( ...args: TArgs diff --git a/main/src/daemon/daemonChannels.ts b/main/src/daemon/daemonChannels.ts new file mode 100644 index 0000000..572c221 --- /dev/null +++ b/main/src/daemon/daemonChannels.ts @@ -0,0 +1,6 @@ +export { + DAEMON_OWNED_CHANNEL_PREFIXES, + DAEMON_OWNED_EXACT_CHANNELS, + ELECTRON_ADAPTER_ONLY_CHANNELS, + isDaemonOwnedChannel, +} from '../../../shared/types/daemon'; diff --git a/main/src/daemon/socketFraming.test.ts b/main/src/daemon/socketFraming.test.ts index eb9a480..181e4e9 100644 --- a/main/src/daemon/socketFraming.test.ts +++ b/main/src/daemon/socketFraming.test.ts @@ -4,7 +4,6 @@ import { PaneDaemonFrameDecoder, } from './socketFraming'; import { - isDaemonOwnedChannel, isPaneDaemonEventFrame, isPaneDaemonFrame, isPaneDaemonRequestFrame, @@ -13,6 +12,7 @@ import { type PaneDaemonRequestFrame, type PaneDaemonResponseFrame, } from '../../../shared/types/daemon'; +import { isDaemonOwnedChannel } from './daemonChannels'; describe('Pane daemon framing', () => { it('encodes frames as newline-delimited JSON', () => { diff --git a/main/src/index.ts b/main/src/index.ts index f172056..9189da9 100644 --- a/main/src/index.ts +++ b/main/src/index.ts @@ -952,11 +952,6 @@ async function createWindow() { resourceMonitorService.handleVisibilityChange(true); }); - // Pull-path query so the renderer can get the authoritative focus state on - // mount without waiting for the next focus-change event. Default to true - // (focused) if mainWindow is somehow null at call time. - ipcMain.handle('window:is-focused', () => mainWindow?.isFocused() ?? true); - mainWindow.on('restore', () => { // Don't assume restore = focused. The OS will fire 'focus' if/when the user // actually focuses the window; that is what restarts git/resource work. @@ -1212,6 +1207,10 @@ app.whenReady().then(async () => { await initializeServices(); console.log('[Main] Services initialized, creating window...'); + // Register before any renderer loads. useNotifications pulls this on mount + // and will race a late registration inside createWindow/loadURL. + ipcMain.handle('window:is-focused', () => mainWindow?.isFocused() ?? true); + // Start the ptyHost supervisor before the window opens so the renderer's // preload listener for 'ptyHost-port' has a port to receive when the window // finishes loading. Gated on the `usePtyHost` setting: when off (default), diff --git a/main/src/ipc/daemon.ts b/main/src/ipc/daemon.ts index d9e7f7d..a28f7d3 100644 --- a/main/src/ipc/daemon.ts +++ b/main/src/ipc/daemon.ts @@ -1,4 +1,4 @@ -import { isDaemonOwnedChannel } from '../../../shared/types/daemon'; +import { isDaemonOwnedChannel } from '../daemon/daemonChannels'; import type { PaneCommandRegistry } from '../daemon/commandRegistry'; interface IpcMainHandleLike { diff --git a/main/src/preload.ts b/main/src/preload.ts index a7198ab..fcde800 100644 --- a/main/src/preload.ts +++ b/main/src/preload.ts @@ -3,7 +3,6 @@ import type { CreateSessionRequest, Session } from './types/session'; import type { AppConfig, UpdateConfigRequest } from './types/config'; import type { CreateProjectRequest, UpdateProjectRequest, Project } from '../../frontend/src/types/project'; import type { ToolPanel } from '../../shared/types/panels'; -import { isDaemonOwnedChannel } from '../../shared/types/daemon'; interface LogEntry { timestamp: string; @@ -88,6 +87,66 @@ interface UpdaterInfo { size?: number; } +const DAEMON_OWNED_CHANNEL_PREFIXES = [ + 'folders:', + 'logs:', + 'panels:', + 'projects:', + 'prompts:', + 'resource-monitor:', + 'sessions:', + 'terminal:', +] as const; + +const DAEMON_OWNED_EXACT_CHANNELS = [ + 'git:cancel-status-for-project', + 'git:clone-repo', + 'git:commit', + 'git:execute-project', + 'git:file-status', + 'git:get-github-remote', + 'git:restore', + 'git:revert', + 'file:copy', + 'file:delete', + 'file:duplicate', + 'file:exists', + 'file:getPath', + 'file:list', + 'file:move', + 'file:read', + 'file:read-binary', + 'file:read-project', + 'file:readAtRevision', + 'file:rename', + 'file:resolveAbsolutePath', + 'file:search', + 'file:write', + 'file:write-binary', + 'file:write-project', +] as const; + +const ELECTRON_ADAPTER_ONLY_CHANNELS = new Set([ + 'file:showInFolder', + 'sessions:open-ide', + 'sessions:set-active-session', + 'terminal:clipboard-paste-image', +]); + +// Sandboxed Electron preload scripts cannot reliably require local runtime +// modules, so the daemon-owned channel classifier stays inline here. +function isDaemonOwnedChannel(channel: string): boolean { + if (ELECTRON_ADAPTER_ONLY_CHANNELS.has(channel)) { + return false; + } + + if (DAEMON_OWNED_EXACT_CHANNELS.includes(channel as (typeof DAEMON_OWNED_EXACT_CHANNELS)[number])) { + return true; + } + + return DAEMON_OWNED_CHANNEL_PREFIXES.some((prefix) => channel.startsWith(prefix)); +} + // Increase max listeners for ipcRenderer to prevent warnings when many components listen to events ipcRenderer.setMaxListeners(50); diff --git a/shared/types/daemon.ts b/shared/types/daemon.ts index fb6e7cc..e91ca3a 100644 --- a/shared/types/daemon.ts +++ b/shared/types/daemon.ts @@ -46,7 +46,7 @@ interface PaneDaemonResponseFrameCandidate { error?: unknown; } -const DAEMON_OWNED_CHANNEL_PREFIXES = [ +export const DAEMON_OWNED_CHANNEL_PREFIXES = [ 'folders:', 'logs:', 'panels:', @@ -57,7 +57,7 @@ const DAEMON_OWNED_CHANNEL_PREFIXES = [ 'terminal:', ] as const; -const DAEMON_OWNED_EXACT_CHANNELS = [ +export const DAEMON_OWNED_EXACT_CHANNELS = [ 'git:cancel-status-for-project', 'git:clone-repo', 'git:commit', @@ -85,15 +85,17 @@ const DAEMON_OWNED_EXACT_CHANNELS = [ 'file:write-project', ] as const; -const ELECTRON_ADAPTER_ONLY_CHANNELS = new Set([ +export const ELECTRON_ADAPTER_ONLY_CHANNELS = [ 'file:showInFolder', 'sessions:open-ide', 'sessions:set-active-session', 'terminal:clipboard-paste-image', -]); +] as const; + +const ELECTRON_ADAPTER_ONLY_CHANNEL_SET = new Set(ELECTRON_ADAPTER_ONLY_CHANNELS); export function isDaemonOwnedChannel(channel: string): boolean { - if (ELECTRON_ADAPTER_ONLY_CHANNELS.has(channel)) { + if (ELECTRON_ADAPTER_ONLY_CHANNEL_SET.has(channel)) { return false; }