diff --git a/docs/issues/agent-exec-utility-process-crash/plan.md b/docs/issues/agent-exec-utility-process-crash/plan.md new file mode 100644 index 000000000..380755b6d --- /dev/null +++ b/docs/issues/agent-exec-utility-process-crash/plan.md @@ -0,0 +1,39 @@ +# Plan + +## Diagnosis + +The source entrypoint gates `appMain` behind `runBackgroundExecUtilityHostIfRequested()`, but the +main build inlines dynamic imports. The built `out/main/index.js` still contains `appMain` top-level +startup code after the utility-host branch, so a utility child process can continue into the normal +app lifecycle and exit before responding to exec RPCs. + +The utility host also listens to `process.parentPort` as if the callback receives the raw payload. +Electron sends a message event object whose `data` field contains the payload. + +The formal `v1.0.5-beta.4` and `v1.0.5-beta.5` macOS arm64 artifacts both contain the same fragile +main-bundle utility entrypoint. A direct `utilityProcess.fork()` probe against those artifacts exits +with code 1 while loading `@electron-toolkit/utils`, because utility processes do not expose +main-process-only Electron exports such as `BrowserWindow`. + +## Design + +- Move `appMain` side effects into an exported `startApp()` function. +- Add a dedicated `backgroundExecUtilityHost` main build entrypoint that only loads the exec runtime. +- Keep `src/main/index.ts` as the normal app bootstrap and call `startApp()` there. +- Resolve the utility host entrypoint to `out/main/backgroundExecUtilityHost.js` in development, + unpacked, and packaged app layouts. +- Normalize parent-port messages in the utility host so both raw test payloads and Electron + `MessageEvent` payloads work. +- Keep the utility host event loop alive while it waits for parent-port RPC messages. +- Remove utility-host imports of `@electron-toolkit/utils`, because it statically imports + main-process-only Electron exports such as `BrowserWindow`. +- Keep shell environment and session path helpers free of `electron.app` imports so the utility + process can load the exec runtime. +- Add focused unit coverage for the host message normalization. + +## Validation + +- Run focused Vitest coverage for background exec runtime tests. +- Rebuild the main bundle and probe `utilityProcess.fork(out/main/backgroundExecUtilityHost.js)` + with `list` and `start` RPCs. +- Run project formatting, i18n check, and lint after implementation. diff --git a/docs/issues/agent-exec-utility-process-crash/spec.md b/docs/issues/agent-exec-utility-process-crash/spec.md new file mode 100644 index 000000000..7d0807d78 --- /dev/null +++ b/docs/issues/agent-exec-utility-process-crash/spec.md @@ -0,0 +1,34 @@ +# Agent Exec Utility Process Crash + +## Summary + +`agent-filesystem.exec` must run simple shell commands after the background exec manager moved +behind an Electron `utilityProcess` host. In `v1.0.5-beta.5`, every exec request can fail before +the shell command starts because the utility host exits instead of serving RPC requests. + +## User Story + +As an agent user, I can ask the built-in filesystem tool to run `true`, `echo hello`, `ls`, or a +long-running foreground command and get either normal output or a yielded background session. + +## Acceptance Criteria + +- The utility host starts without running the main app bootstrap. +- The utility host entrypoint has no static dependency on Electron main-process-only exports such as + `app` or `BrowserWindow`. +- The host accepts Electron `process.parentPort` message events and handles the contained RPC + payload. +- A foreground exec command that completes quickly returns the command result instead of a utility + process exit error. +- Existing crashed-session behavior remains intact when the utility process exits unexpectedly. + +## Non-Goals + +- Changing shell selection, command permissions, or session cleanup semantics. +- Replacing the `utilityProcess` architecture. +- Changing renderer tool-call UI. + +## Constraints + +- Keep the fix inside the main-process agent runtime/bootstrap path. +- Preserve packaged and development entrypoint resolution. diff --git a/docs/issues/agent-exec-utility-process-crash/tasks.md b/docs/issues/agent-exec-utility-process-crash/tasks.md new file mode 100644 index 000000000..5a5e0e00f --- /dev/null +++ b/docs/issues/agent-exec-utility-process-crash/tasks.md @@ -0,0 +1,15 @@ +# Tasks + +- [x] Refactor `appMain` startup into an explicit function. +- [x] Add a dedicated utility-host main entrypoint. +- [x] Update `index.ts` to be the normal app bootstrap only. +- [x] Keep the app bootstrap graph out of the utility host. +- [x] Resolve the utility host entrypoint from development and packaged main builds. +- [x] Normalize utility-host parent-port message events. +- [x] Keep the utility host alive while waiting for RPC messages. +- [x] Remove the utility-host logger dependency on `@electron-toolkit/utils`. +- [x] Remove the utility-host shell-env dependency on `electron.app`. +- [x] Remove the utility-host session-path dependency on `electron.app`. +- [x] Add tests for raw payload and MessageEvent payload handling. +- [x] Run focused runtime tests and build/probe validation. +- [x] Run `pnpm run format`, `pnpm run i18n`, and `pnpm run lint`. diff --git a/docs/issues/vite-mixed-import-warnings/plan.md b/docs/issues/vite-mixed-import-warnings/plan.md new file mode 100644 index 000000000..063f0dc83 --- /dev/null +++ b/docs/issues/vite-mixed-import-warnings/plan.md @@ -0,0 +1,22 @@ +# Vite Mixed Import Warnings Plan + +## Approach + +- Replace ineffective dynamic imports with ordinary static imports when the target is already part + of the eager main-process graph. +- For file MIME detection, move the detection helpers into a small dependency-free module so + `BaseFileAdapter` can import them statically without creating a direct adapter registry cycle. +- Keep `mime.ts` as the adapter registry facade by re-exporting the MIME detection helpers. + +## Validation + +- Search for remaining dynamic imports of the warned modules. +- Run formatting, i18n validation, lint, and a main build check to confirm the warnings are gone. + +## Risks + +- Static presenter imports are cyclic with `presenter/index.ts`; this project already uses the + exported singleton through static imports in several presenter modules. The changed call sites + only read the binding inside methods after initialization. +- Extracting MIME detection must preserve the existing exported symbols from `mime.ts` so current + callers do not need behavioral changes. diff --git a/docs/issues/vite-mixed-import-warnings/spec.md b/docs/issues/vite-mixed-import-warnings/spec.md new file mode 100644 index 000000000..12470f949 --- /dev/null +++ b/docs/issues/vite-mixed-import-warnings/spec.md @@ -0,0 +1,31 @@ +# Vite Mixed Import Warnings + +## Problem + +Electron Vite build output reports Rollup warnings when a module is dynamically imported while +also being statically imported elsewhere. The affected modules are: + +- `src/main/presenter/filePresenter/mime.ts` +- `src/main/presenter/agentSessionPresenter/legacyImportService.ts` +- `src/main/presenter/index.ts` + +Because the modules are already in the static graph, the dynamic imports cannot create separate +chunks and the build remains noisy. + +## Acceptance Criteria + +- The listed mixed static/dynamic import warnings no longer appear in the main-process build. +- Runtime behavior for file MIME detection, legacy chat import, data reset, shutdown interception, + and sync import broadcast remains unchanged. +- The fix stays scoped to import structure and avoids broad presenter refactors. + +## Non-Goals + +- Reworking presenter singleton ownership. +- Changing backup, reset, shutdown, or legacy import behavior. +- Optimizing chunk boundaries beyond removing ineffective dynamic imports. + +## Constraints + +- Keep the existing presenter and service APIs stable. +- Avoid introducing unresolved circular runtime reads during module initialization. diff --git a/docs/issues/vite-mixed-import-warnings/tasks.md b/docs/issues/vite-mixed-import-warnings/tasks.md new file mode 100644 index 000000000..e3e6e6070 --- /dev/null +++ b/docs/issues/vite-mixed-import-warnings/tasks.md @@ -0,0 +1,7 @@ +# Vite Mixed Import Warnings Tasks + +- [x] Add SDD notes for the warning cleanup. +- [x] Extract MIME detection helpers away from the adapter registry. +- [x] Replace ineffective dynamic imports for legacy import and presenter singleton access. +- [x] Verify no warned dynamic imports remain. +- [x] Run required quality checks. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index fadd81f7b..a42175ed5 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -24,10 +24,15 @@ export default defineConfig({ exclude: ['mermaid'] }, rollupOptions: { + input: { + index: resolve('src/main/index.ts'), + backgroundExecUtilityHost: resolve('src/main/backgroundExecUtilityHostEntry.ts') + }, external: ['sharp', '@duckdb/node-api'], output: { - inlineDynamicImports: true, - manualChunks: undefined, // Disable automatic chunk splitting + entryFileNames: '[name].js', + chunkFileNames: 'chunks/[name]-[hash].js', + manualChunks: undefined } } } diff --git a/src/main/appMain.ts b/src/main/appMain.ts index 1db239f86..d4041cb04 100644 --- a/src/main/appMain.ts +++ b/src/main/appMain.ts @@ -14,172 +14,182 @@ import { } from './lib/startupDeepLink' import { isInsecureTlsAllowed } from './lib/insecureTls' -registerWorkspacePreviewSchemes() - -// Handle unhandled exceptions to prevent app crash or error dialogs -process.on('uncaughtException', (error) => { - log.error('Uncaught Exception:', error) - - const msg = error.message || 'Unknown error' - const isNetworkError = [ - 'net::ERR', - 'ECONNRESET', - 'ETIMEDOUT', - 'ENOTFOUND', - 'Network Error', - 'fetch failed' - ].some((k) => msg.includes(k)) - - if (isNetworkError) { - // Send error to renderer to show a toast notification - // This is "elegant" and non-blocking - eventBus.sendToRenderer(NOTIFICATION_EVENTS.SHOW_ERROR, SendTarget.ALL_WINDOWS, { - id: Date.now().toString(), - title: 'Network Error', - message: msg, - type: 'error' - }) - } -}) - -process.on('unhandledRejection', (reason) => { - log.error('Unhandled Rejection:', reason) -}) - -// Set application command line arguments -app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required') // Allow video autoplay -app.commandLine.appendSwitch('webrtc-max-cpu-consumption-percentage', '100') // Set WebRTC max CPU usage -app.commandLine.appendSwitch('js-flags', '--max-old-space-size=4096') // Set V8 heap memory size -if (isInsecureTlsAllowed()) { - // This disables certificate validation app-wide, so keep it limited to local debugging. - app.commandLine.appendSwitch('ignore-certificate-errors') -} - -// Set platform-specific command line arguments -if (process.platform == 'win32') { - // Windows platform specific parameters (currently commented out) - // app.commandLine.appendSwitch('in-process-gpu') - // app.commandLine.appendSwitch('wm-window-animations-disabled') -} -if (process.platform === 'darwin') { - // macOS platform specific parameters - app.commandLine.appendSwitch('disable-features', 'DesktopCaptureMacV2,IOSurfaceCapturer') -} +let appStarted = false -const gotSingleInstanceLock = app.requestSingleInstanceLock() -if (!gotSingleInstanceLock) { - console.log('Another DeepChat instance is already running. Exiting current process.') - app.quit() -} +export function startApp(): void { + if (appStarted) { + return + } + appStarted = true + + registerWorkspacePreviewSchemes() + + // Handle unhandled exceptions to prevent app crash or error dialogs + process.on('uncaughtException', (error) => { + log.error('Uncaught Exception:', error) + + const msg = error.message || 'Unknown error' + const isNetworkError = [ + 'net::ERR', + 'ECONNRESET', + 'ETIMEDOUT', + 'ENOTFOUND', + 'Network Error', + 'fetch failed' + ].some((k) => msg.includes(k)) + + if (isNetworkError) { + // Send error to renderer to show a toast notification + // This is "elegant" and non-blocking + eventBus.sendToRenderer(NOTIFICATION_EVENTS.SHOW_ERROR, SendTarget.ALL_WINDOWS, { + id: Date.now().toString(), + title: 'Network Error', + message: msg, + type: 'error' + }) + } + }) -// Initialize presenter after ready -let presenter: Presenter | undefined - -console.log('Main process starting, checking for deeplink...') -console.log('Full command line arguments:', process.argv) -const startupDeepLink = findStartupDeepLink(process.argv, process.env) -if (startupDeepLink) { - console.log('Found startup deeplink during initialization:', startupDeepLink) - storeStartupDeepLink(startupDeepLink) -} else { - console.log('No startup deeplink detected during initialization') -} + process.on('unhandledRejection', (reason) => { + log.error('Unhandled Rejection:', reason) + }) -const focusExistingAppWindow = () => { - const targetWindow = presenter?.windowPresenter.getAllWindows()[0] - if (!targetWindow || targetWindow.isDestroyed()) { - return + // Set application command line arguments + app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required') // Allow video autoplay + app.commandLine.appendSwitch('webrtc-max-cpu-consumption-percentage', '100') // Set WebRTC max CPU usage + app.commandLine.appendSwitch('js-flags', '--max-old-space-size=4096') // Set V8 heap memory size + if (isInsecureTlsAllowed()) { + // This disables certificate validation app-wide, so keep it limited to local debugging. + app.commandLine.appendSwitch('ignore-certificate-errors') } - if (targetWindow.isMinimized()) { - targetWindow.restore() + // Set platform-specific command line arguments + if (process.platform == 'win32') { + // Windows platform specific parameters (currently commented out) + // app.commandLine.appendSwitch('in-process-gpu') + // app.commandLine.appendSwitch('wm-window-animations-disabled') + } + if (process.platform === 'darwin') { + // macOS platform specific parameters + app.commandLine.appendSwitch('disable-features', 'DesktopCaptureMacV2,IOSurfaceCapturer') } - targetWindow.show() - targetWindow.focus() -} -const routeIncomingDeeplink = (url: string, source: string) => { - if (!isDeepLinkUrl(url)) { + const gotSingleInstanceLock = app.requestSingleInstanceLock() + if (!gotSingleInstanceLock) { + console.log('Another DeepChat instance is already running. Exiting current process.') + app.quit() return } - console.log(`${source}:`, url) - const normalizedUrl = storeStartupDeepLink(url) - if (!normalizedUrl) { - return + // Initialize presenter after ready + let presenter: Presenter | undefined + + console.log('Main process starting, checking for deeplink...') + console.log('Startup arguments received', { argc: process.argv.length }) + const startupDeepLink = findStartupDeepLink(process.argv, process.env) + if (startupDeepLink) { + console.log('Found startup deeplink during initialization') + storeStartupDeepLink(startupDeepLink) + } else { + console.log('No startup deeplink detected during initialization') } - if (presenter && app.isReady()) { - void presenter.deeplinkPresenter.handleDeepLink(normalizedUrl) + const focusExistingAppWindow = () => { + const targetWindow = presenter?.windowPresenter.getAllWindows()[0] + if (!targetWindow || targetWindow.isDestroyed()) { + return + } + + if (targetWindow.isMinimized()) { + targetWindow.restore() + } + targetWindow.show() + targetWindow.focus() } -} -// Listen for open-url events that might occur during startup -// This must be set before app.whenReady() because open-url events can fire before that -app.on('open-url', (event, url) => { - event.preventDefault() - routeIncomingDeeplink(url, 'Received open-url event') -}) - -// Also listen for second-instance events (Windows/Linux) -if (gotSingleInstanceLock) { - app.on('second-instance', (_event, commandLine) => { - console.log('Received second-instance event with command line:', commandLine) - focusExistingAppWindow() - - const deepLinkUrl = findDeepLinkArg(commandLine) - if (deepLinkUrl) { - routeIncomingDeeplink(deepLinkUrl, 'Received second-instance deeplink') + const routeIncomingDeeplink = (url: string, source: string) => { + if (!isDeepLinkUrl(url)) { + return + } + + console.log(source) + const normalizedUrl = storeStartupDeepLink(url) + if (!normalizedUrl) { + return + } + + if (presenter && app.isReady()) { + void presenter.deeplinkPresenter.handleDeepLink(normalizedUrl) } + } + + // Listen for open-url events that might occur during startup + // This must be set before app.whenReady() because open-url events can fire before that + app.on('open-url', (event, url) => { + event.preventDefault() + routeIncomingDeeplink(url, 'Received open-url event') }) -} -// Initialize lifecycle manager and register core hooks -const lifecycleManager = new LifecycleManager() -registerCoreHooks(lifecycleManager) + // Also listen for second-instance events (Windows/Linux) + if (gotSingleInstanceLock) { + app.on('second-instance', (_event, commandLine) => { + console.log('Received second-instance event', { argc: commandLine.length }) + focusExistingAppWindow() + + const deepLinkUrl = findDeepLinkArg(commandLine) + if (deepLinkUrl) { + routeIncomingDeeplink(deepLinkUrl, 'Received second-instance deeplink') + } + }) + } -function clearPresenterPermissionCaches(activePresenter?: Presenter): void { - if (!activePresenter) return + // Initialize lifecycle manager and register core hooks + const lifecycleManager = new LifecycleManager() + registerCoreHooks(lifecycleManager) - activePresenter.commandPermissionService.clearAll() - activePresenter.filePermissionService.clearAll() - activePresenter.settingsPermissionService.clearAll() -} + function clearPresenterPermissionCaches(activePresenter?: Presenter): void { + if (!activePresenter) return -// Start the lifecycle management system instead of using app.whenReady() -app.whenReady().then(async () => { - // Set app user model id for windows - electronApp.setAppUserModelId('com.wefonk.deepchat') - try { - console.log('main: Application lifecycle startup') - await lifecycleManager.start() - presenter = getInstance(lifecycleManager) - console.log('main: Application lifecycle startup completed successfully') - } catch (error) { - console.error('main: Application lifecycle startup failed:', error) - dialog.showErrorBox( - 'Application startup failed', - error instanceof Error ? error.message : String(error) - ) - app.quit() // Serious error, exit the program + activePresenter.commandPermissionService.clearAll() + activePresenter.filePermissionService.clearAll() + activePresenter.settingsPermissionService.clearAll() } -}) -app.on('before-quit', () => { - clearPresenterPermissionCaches(presenter) -}) + // Start the lifecycle management system instead of using app.whenReady() + app.whenReady().then(async () => { + // Set app user model id for windows + electronApp.setAppUserModelId('com.wefonk.deepchat') + try { + console.log('main: Application lifecycle startup') + await lifecycleManager.start() + presenter = getInstance(lifecycleManager) + console.log('main: Application lifecycle startup completed successfully') + } catch (error) { + console.error('main: Application lifecycle startup failed:', error) + dialog.showErrorBox( + 'Application startup failed', + error instanceof Error ? error.message : String(error) + ) + app.quit() // Serious error, exit the program + } + }) + + app.on('before-quit', () => { + clearPresenterPermissionCaches(presenter) + }) -// Handle window-all-closed event -app.on('window-all-closed', () => { - clearPresenterPermissionCaches(presenter) - if (!presenter) return + // Handle window-all-closed event + app.on('window-all-closed', () => { + clearPresenterPermissionCaches(presenter) + if (!presenter) return - // Check if there are any non-floating-button windows - const mainWindows = presenter.windowPresenter.getAllWindows() + // Check if there are any non-floating-button windows + const mainWindows = presenter.windowPresenter.getAllWindows() - if (mainWindows.length === 0) { - // When only floating button windows exist, quit app on non-macOS platforms - console.log('main: All main windows closed, requesting shutdown') - app.quit() // Keep this event to avoid unexpected situations - } -}) + if (mainWindows.length === 0) { + // When only floating button windows exist, quit app on non-macOS platforms + console.log('main: All main windows closed, requesting shutdown') + app.quit() // Keep this event to avoid unexpected situations + } + }) +} diff --git a/src/main/backgroundExecUtilityHostEntry.ts b/src/main/backgroundExecUtilityHostEntry.ts new file mode 100644 index 000000000..14284c0b9 --- /dev/null +++ b/src/main/backgroundExecUtilityHostEntry.ts @@ -0,0 +1,5 @@ +import { runBackgroundExecUtilityHostIfRequested } from './lib/agentRuntime/backgroundExecUtilityHost' + +if (!runBackgroundExecUtilityHostIfRequested()) { + throw new Error('Background exec utility host entrypoint started outside a utility process.') +} diff --git a/src/main/index.ts b/src/main/index.ts index e45139758..d493ff2cf 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,5 +1,3 @@ -import { runBackgroundExecUtilityHostIfRequested } from './lib/agentRuntime/backgroundExecUtilityHost' +import { startApp } from './appMain' -if (!runBackgroundExecUtilityHostIfRequested()) { - void import('./appMain') -} +startApp() diff --git a/src/main/lib/agentRuntime/backgroundExecLogger.ts b/src/main/lib/agentRuntime/backgroundExecLogger.ts new file mode 100644 index 000000000..82a91bb43 --- /dev/null +++ b/src/main/lib/agentRuntime/backgroundExecLogger.ts @@ -0,0 +1,7 @@ +const backgroundExecLogger = { + error: (...params: unknown[]) => console.error(...params), + warn: (...params: unknown[]) => console.warn(...params), + info: (...params: unknown[]) => console.info(...params) +} + +export default backgroundExecLogger diff --git a/src/main/lib/agentRuntime/backgroundExecSessionManager.ts b/src/main/lib/agentRuntime/backgroundExecSessionManager.ts index 2967f0d3d..5b71886bb 100644 --- a/src/main/lib/agentRuntime/backgroundExecSessionManager.ts +++ b/src/main/lib/agentRuntime/backgroundExecSessionManager.ts @@ -4,7 +4,7 @@ import path from 'path' import { fileURLToPath } from 'url' import type { UtilityProcess } from 'electron' import { nanoid } from 'nanoid' -import logger from '@shared/logger' +import logger from './backgroundExecLogger' import { getUserShell } from './shellEnvHelper' import { createUtf8OutputDecoderPair, @@ -1171,8 +1171,8 @@ class BackgroundExecUtilityProxy { } private async startHost(): Promise { - const { utilityProcess } = await import('electron') - const modulePath = this.resolveUtilityHostEntryPoint() + const { app, utilityProcess } = await import('electron') + const modulePath = this.resolveUtilityHostEntryPoint(app.getAppPath()) const host = utilityProcess.fork(modulePath, ['--deepchat-exec-utility-host'], { serviceName: 'DeepChat Exec Utility', stdio: 'ignore', @@ -1216,12 +1216,20 @@ class BackgroundExecUtilityProxy { }) } - private resolveUtilityHostEntryPoint(): string { + private resolveUtilityHostEntryPoint(appPath?: string): string { const modulePath = fileURLToPath(import.meta.url) - if (path.basename(modulePath) === 'index.js') { - return modulePath - } - return fileURLToPath(new URL('../../index.js', import.meta.url)) + const candidates = [ + ...(appPath + ? [ + path.join(appPath, 'out/main/backgroundExecUtilityHost.js'), + path.join(appPath, 'backgroundExecUtilityHost.js') + ] + : []), + path.resolve(path.dirname(modulePath), 'backgroundExecUtilityHost.js'), + path.resolve(path.dirname(modulePath), '../backgroundExecUtilityHost.js'), + path.resolve(process.cwd(), 'out/main/backgroundExecUtilityHost.js') + ] + return candidates.find((candidate) => fs.existsSync(candidate)) ?? candidates[0] } private handleHostMessage(message: unknown): void { diff --git a/src/main/lib/agentRuntime/backgroundExecUtilityHost.ts b/src/main/lib/agentRuntime/backgroundExecUtilityHost.ts index d689f40a0..6c434bb2a 100644 --- a/src/main/lib/agentRuntime/backgroundExecUtilityHost.ts +++ b/src/main/lib/agentRuntime/backgroundExecUtilityHost.ts @@ -9,6 +9,11 @@ const EXEC_UTILITY_HOST_ARG = '--deepchat-exec-utility-host' type ParentPort = { postMessage(message: unknown): void on(event: 'message', listener: (message: unknown) => void): void + start?(): void +} + +type ParentPortMessageEvent = { + data?: unknown } function getParentPort(): ParentPort | null { @@ -40,6 +45,26 @@ function sendResponse(parentPort: ParentPort, response: BackgroundExecRpcRespons parentPort.postMessage(response) } +function isBackgroundExecRpcRequest(message: unknown): message is BackgroundExecRpcRequest { + return ( + Boolean(message) && + typeof message === 'object' && + (message as BackgroundExecRpcRequest).type === 'background-exec:request' + ) +} + +export function getParentPortMessagePayload(message: unknown): unknown { + if (isBackgroundExecRpcRequest(message)) { + return message + } + + if (message && typeof message === 'object' && 'data' in message) { + return (message as ParentPortMessageEvent).data + } + + return message +} + async function handleRequest( manager: BackgroundExecSessionManager, parentPort: ParentPort, @@ -80,19 +105,19 @@ export function runBackgroundExecUtilityHostIfRequested(): boolean { } const manager = new BackgroundExecSessionManager() + const keepAliveIntervalId = setInterval(() => {}, 2 ** 31 - 1) + parentPort.start?.() parentPort.on('message', (message) => { - if (!message || typeof message !== 'object') { - return - } - const request = message as BackgroundExecRpcRequest - if (request.type !== 'background-exec:request') { + const request = getParentPortMessagePayload(message) + if (!isBackgroundExecRpcRequest(request)) { return } void handleRequest(manager, parentPort, request) }) process.once('beforeExit', () => { + clearInterval(keepAliveIntervalId) void manager.shutdown() }) diff --git a/src/main/lib/agentRuntime/sessionPaths.ts b/src/main/lib/agentRuntime/sessionPaths.ts index fcafdda53..11049bc05 100644 --- a/src/main/lib/agentRuntime/sessionPaths.ts +++ b/src/main/lib/agentRuntime/sessionPaths.ts @@ -1,12 +1,12 @@ import { createHash } from 'crypto' -import { app } from 'electron' +import os from 'os' import path from 'path' const INVALID_WINDOWS_SEGMENT_CHARS = new Set(['<', '>', ':', '"', '/', '\\', '|', '?', '*']) const TRAILING_WINDOWS_SEGMENT_CHARS = /[. ]+$/g export function getSessionsRoot(): string { - return path.resolve(app.getPath('home'), '.deepchat', 'sessions') + return path.resolve(os.homedir(), '.deepchat', 'sessions') } export function resolveSessionDir(conversationId: string): string | null { diff --git a/src/main/lib/agentRuntime/shellEnvHelper.ts b/src/main/lib/agentRuntime/shellEnvHelper.ts index 5b0c2a829..1ece4681b 100644 --- a/src/main/lib/agentRuntime/shellEnvHelper.ts +++ b/src/main/lib/agentRuntime/shellEnvHelper.ts @@ -1,10 +1,6 @@ import { spawn } from 'child_process' -import { app } from 'electron' import fs from 'fs' import * as path from 'path' -import { RuntimeHelper } from '../runtimeHelper' - -const runtimeHelper = RuntimeHelper.getInstance() const PATH_ENV_KEYS = ['PATH', 'Path', 'path'] as const const NODE_ENV_KEYS = [ @@ -76,12 +72,30 @@ function pickRelevantEnvironment( } function getDefaultPathEntries(): string[] { - try { - return runtimeHelper.getDefaultPaths(app.getPath('home')) - } catch { - const homeDir = process.env.HOME || process.env.USERPROFILE || '' - return homeDir ? runtimeHelper.getDefaultPaths(homeDir) : [] + const homeDir = + process.platform === 'win32' + ? process.env.USERPROFILE || process.env.HOME || '' + : process.env.HOME || process.env.USERPROFILE || '' + + if (process.platform === 'darwin') { + return [ + '/bin', + '/usr/bin', + '/usr/local/bin', + '/usr/local/sbin', + '/opt/homebrew/bin', + '/opt/homebrew/sbin', + '/usr/local/opt/node/bin', + '/opt/local/bin', + ...(homeDir ? [`${homeDir}/.cargo/bin`] : []) + ] } + + if (process.platform === 'linux') { + return ['/bin', '/usr/bin', '/usr/local/bin', ...(homeDir ? [`${homeDir}/.cargo/bin`] : [])] + } + + return homeDir ? [`${homeDir}\\.cargo\\bin`, `${homeDir}\\.local\\bin`] : [] } export function getPathEntriesFromEnv( diff --git a/src/main/presenter/agentRuntimePresenter/messageStore.ts b/src/main/presenter/agentRuntimePresenter/messageStore.ts index 215efc08c..1a9d7c7b3 100644 --- a/src/main/presenter/agentRuntimePresenter/messageStore.ts +++ b/src/main/presenter/agentRuntimePresenter/messageStore.ts @@ -1,5 +1,5 @@ import { nanoid } from 'nanoid' -import { SQLitePresenter } from '../sqlitePresenter' +import type { SQLitePresenter } from '../sqlitePresenter' import type { ChatMessagePageResult, ChatMessageRecord, diff --git a/src/main/presenter/devicePresenter/index.ts b/src/main/presenter/devicePresenter/index.ts index d63caec1f..3c0116b08 100644 --- a/src/main/presenter/devicePresenter/index.ts +++ b/src/main/presenter/devicePresenter/index.ts @@ -11,6 +11,7 @@ import { is } from '@electron-toolkit/utils' import { eventBus, SendTarget } from '../../eventbus' import { NOTIFICATION_EVENTS } from '../../events' import { svgSanitizer } from '../../lib/svgSanitizer' +import { presenter } from '../index' const execAsync = promisify(exec) function toMimeType(value: unknown): string { @@ -325,7 +326,6 @@ export class DevicePresenter implements IDevicePresenter { async resetDataByType(resetType: 'chat' | 'knowledge' | 'config' | 'all'): Promise { try { const userDataPath = app.getPath('userData') - const { presenter } = await import('../index') const removeDirectory = (dirPath: string): void => { if (fs.existsSync(dirPath)) { diff --git a/src/main/presenter/filePresenter/BaseFileAdapter.ts b/src/main/presenter/filePresenter/BaseFileAdapter.ts index 543325cef..87e771b6d 100644 --- a/src/main/presenter/filePresenter/BaseFileAdapter.ts +++ b/src/main/presenter/filePresenter/BaseFileAdapter.ts @@ -2,6 +2,7 @@ import * as fs from 'fs' import * as crypto from 'crypto' import { FileMetaData } from '@shared/presenter' import path from 'path' +import { detectMimeType } from './mimeDetection' export abstract class BaseFileAdapter { filePath: string @@ -36,7 +37,6 @@ export abstract class BaseFileAdapter { } protected async preprocessFile(): Promise { - const { detectMimeType } = await import('./mime') this.mimeType = await detectMimeType(this.filePath) } diff --git a/src/main/presenter/filePresenter/mime.ts b/src/main/presenter/filePresenter/mime.ts index 030044cf6..a99815bb7 100644 --- a/src/main/presenter/filePresenter/mime.ts +++ b/src/main/presenter/filePresenter/mime.ts @@ -11,11 +11,8 @@ import { AudioFileAdapter } from './AudioFileAdapter' import { OpenDocumentFileAdapter } from './OpenDocumentFileAdapter' import { RtfFileAdapter } from './RtfFileAdapter' import { UnsupportFileAdapter } from './UnsupportFileAdapter' -import fs from 'fs/promises' -import path from 'path' -import { lookup } from 'es-mime-types' -const TYPESCRIPT_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts']) +export { detectMimeType, isLikelyTextFile } from './mimeDetection' export const getMimeTypeAdapterMap = (): Map => { const map = new Map() @@ -142,75 +139,3 @@ export const getMimeTypeAdapterMap = (): Map => return map } - -export const detectMimeType = async (filePath: string): Promise => { - try { - const mimeType = lookup(filePath) - const ext = path.extname(filePath).toLowerCase() - - if (mimeType === 'video/mp2t' && TYPESCRIPT_EXTENSIONS.has(ext)) { - return (await isLikelyTextFile(filePath)) ? 'application/typescript' : mimeType - } - - if (mimeType) { - return mimeType - } - - const isText = await isLikelyTextFile(filePath) - return isText ? 'text/plain' : 'application/octet-stream' - } catch { - try { - const isText = await isLikelyTextFile(filePath) - return isText ? 'text/plain' : 'application/octet-stream' - } catch (textCheckError) { - console.error(`Error during text check for ${filePath}:`, textCheckError) - return 'application/octet-stream' // Final fallback on error - } - } -} - -// Helper function to check if a file is likely text-based -export const isLikelyTextFile = async (filePath: string, bytesToRead = 1024): Promise => { - let fileHandle: fs.FileHandle | undefined - try { - fileHandle = await fs.open(filePath, 'r') - const buffer = Buffer.alloc(bytesToRead) - const { bytesRead } = await fileHandle.read(buffer, 0, bytesToRead, 0) - await fileHandle.close() // Close the file handle promptly - - if (bytesRead === 0) { - return false - } - - const content = buffer.slice(0, bytesRead) - - const hasNullByte = content.includes(0) - if (hasNullByte) { - return false - } - - let nonTextChars = 0 - for (let i = 0; i < content.length; i++) { - const byte = content[i] - if ( - !((byte >= 32 && byte <= 126) || byte === 9 || byte === 10 || byte === 13 || byte >= 128) - ) { - nonTextChars++ - } - } - - const nonTextRatio = bytesRead > 0 ? nonTextChars / bytesRead : 0 - - if (nonTextRatio > 0.1) { - return false - } - - return true - } catch (error) { - console.error(`[isLikelyTextFile] Failed to read file ${path.basename(filePath)}:`, error) - if (fileHandle) { - await fileHandle.close() // Ensure closure even on error - } - return false // Default to not-text on error - } -} diff --git a/src/main/presenter/filePresenter/mimeDetection.ts b/src/main/presenter/filePresenter/mimeDetection.ts new file mode 100644 index 000000000..3ac999402 --- /dev/null +++ b/src/main/presenter/filePresenter/mimeDetection.ts @@ -0,0 +1,76 @@ +import fs from 'fs/promises' +import path from 'path' +import { lookup } from 'es-mime-types' + +const TYPESCRIPT_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts']) + +export const detectMimeType = async (filePath: string): Promise => { + try { + const mimeType = lookup(filePath) + const ext = path.extname(filePath).toLowerCase() + + if (mimeType === 'video/mp2t' && TYPESCRIPT_EXTENSIONS.has(ext)) { + return (await isLikelyTextFile(filePath)) ? 'application/typescript' : mimeType + } + + if (mimeType) { + return mimeType + } + + const isText = await isLikelyTextFile(filePath) + return isText ? 'text/plain' : 'application/octet-stream' + } catch { + try { + const isText = await isLikelyTextFile(filePath) + return isText ? 'text/plain' : 'application/octet-stream' + } catch (textCheckError) { + console.error(`Error during text check for ${filePath}:`, textCheckError) + return 'application/octet-stream' + } + } +} + +export const isLikelyTextFile = async (filePath: string, bytesToRead = 1024): Promise => { + let fileHandle: fs.FileHandle | undefined + try { + fileHandle = await fs.open(filePath, 'r') + const buffer = Buffer.alloc(bytesToRead) + const { bytesRead } = await fileHandle.read(buffer, 0, bytesToRead, 0) + await fileHandle.close() + + if (bytesRead === 0) { + return false + } + + const content = buffer.slice(0, bytesRead) + + const hasNullByte = content.includes(0) + if (hasNullByte) { + return false + } + + let nonTextChars = 0 + for (let i = 0; i < content.length; i++) { + const byte = content[i] + if ( + !((byte >= 32 && byte <= 126) || byte === 9 || byte === 10 || byte === 13 || byte >= 128) + ) { + nonTextChars++ + } + } + + const nonTextRatio = bytesRead > 0 ? nonTextChars / bytesRead : 0 + + if (nonTextRatio > 0.1) { + return false + } + + return true + } catch (error) { + console.error(`[isLikelyTextFile] Failed to read file ${path.basename(filePath)}:`, error) + if (fileHandle) { + await fileHandle.close() + } + return false + } +} diff --git a/src/main/presenter/lifecyclePresenter/index.ts b/src/main/presenter/lifecyclePresenter/index.ts index ac5fa0ede..c985c61fc 100644 --- a/src/main/presenter/lifecyclePresenter/index.ts +++ b/src/main/presenter/lifecyclePresenter/index.ts @@ -25,6 +25,7 @@ import { BaseLifecycleEvent } from './types' import { is } from '@electron-toolkit/utils' +import { presenter } from '@/presenter' export { registerCoreHooks } from './coreHooks' @@ -431,7 +432,6 @@ export class LifecycleManager implements ILifecycleManager { this.state.isShuttingDown = true try { - const { presenter } = await import('@/presenter') if (presenter?.windowPresenter) { console.log('LifecycleManager: Setting application quitting flag via presenter') presenter.windowPresenter.setApplicationQuitting(true) @@ -453,7 +453,6 @@ export class LifecycleManager implements ILifecycleManager { } else { this.state.isShuttingDown = false try { - const { presenter } = await import('@/presenter') if (presenter?.windowPresenter) { presenter.windowPresenter.setApplicationQuitting(false) } diff --git a/src/main/presenter/sqlitePresenter/index.ts b/src/main/presenter/sqlitePresenter/index.ts index cdfa4a24f..d45a77767 100644 --- a/src/main/presenter/sqlitePresenter/index.ts +++ b/src/main/presenter/sqlitePresenter/index.ts @@ -40,6 +40,7 @@ import { SettingsActivityTable } from './tables/settingsActivity' import { DatabaseRepairService, SchemaInspector } from './schemaRepair' import type { SettingsActivityInput, SettingsActivityRecord } from '@shared/contracts/routes' import { configureSQLiteConnection } from './connectionConfig' +import { LegacyChatImportService } from '../agentSessionPresenter/legacyImportService' const DESTRUCTIVE_DATABASE_ERROR_PATTERNS = [ /database disk image is malformed/i, @@ -583,7 +584,6 @@ export class SQLitePresenter implements ISQLitePresenter { importedMessages: number importedSearchResults: number }> { - const { LegacyChatImportService } = await import('../agentSessionPresenter/legacyImportService') const service = new LegacyChatImportService(this) return await service.importFromSourceDb(sourceDbPath, mode) } diff --git a/src/main/presenter/syncPresenter/index.ts b/src/main/presenter/syncPresenter/index.ts index a0194f26d..81abcdf02 100644 --- a/src/main/presenter/syncPresenter/index.ts +++ b/src/main/presenter/syncPresenter/index.ts @@ -20,6 +20,7 @@ import { SyncConfigImportService, type SyncBackupManifest } from './configImportService' +import { presenter } from '../index' interface PromptStore { prompts: Array<{ id?: string; [key: string]: unknown }> @@ -840,7 +841,6 @@ export class SyncPresenter implements ISyncPresenter { private async broadcastThreadListUpdateAfterImport(): Promise { try { - const { presenter } = await import('../index') await presenter?.broadcastConversationThreadListUpdate?.() } catch (error) { console.warn('Failed to broadcast thread list update after import:', error) diff --git a/test/main/lib/agentRuntime/backgroundExecSessionManager.test.ts b/test/main/lib/agentRuntime/backgroundExecSessionManager.test.ts index 3f3c7b20b..4ea97bfd4 100644 --- a/test/main/lib/agentRuntime/backgroundExecSessionManager.test.ts +++ b/test/main/lib/agentRuntime/backgroundExecSessionManager.test.ts @@ -14,6 +14,7 @@ vi.mock('child_process', () => ({ vi.mock('electron', () => ({ app: { + getAppPath: vi.fn(() => '/mock/app'), getPath: vi.fn((name: string) => (name === 'userData' ? '/mock/userData' : '/mock/home')) }, utilityProcess: { @@ -444,7 +445,7 @@ describe('backgroundExecSessionManager utility proxy', () => { resetProxyState() }) - it('forks the main bootstrap entrypoint for the utility host', async () => { + it('forks the dedicated entrypoint for the utility host', async () => { const host = new MockUtilityProcess() mockUtilityProcessFork.mockReturnValue(host) @@ -456,7 +457,9 @@ describe('backgroundExecSessionManager utility proxy', () => { await expect(startPromise).resolves.toBe(host) expect(mockUtilityProcessFork).toHaveBeenCalledWith( - expect.stringMatching(/[\\/]src[\\/]main[\\/]index\.js$/), + expect.stringMatching( + /[\\/]mock[\\/]app[\\/]out[\\/]main[\\/]backgroundExecUtilityHost\.js$/ + ), ['--deepchat-exec-utility-host'], expect.objectContaining({ serviceName: 'DeepChat Exec Utility', diff --git a/test/main/lib/agentRuntime/backgroundExecUtilityHost.test.ts b/test/main/lib/agentRuntime/backgroundExecUtilityHost.test.ts new file mode 100644 index 000000000..e29d9a1d3 --- /dev/null +++ b/test/main/lib/agentRuntime/backgroundExecUtilityHost.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { getParentPortMessagePayload } from '@/lib/agentRuntime/backgroundExecUtilityHost' +import type { BackgroundExecRpcRequest } from '@/lib/agentRuntime/backgroundExecSessionManager' + +describe('backgroundExecUtilityHost', () => { + const request: BackgroundExecRpcRequest = { + type: 'background-exec:request', + id: 'rpc-1', + method: 'list', + args: ['conversation-1'] + } + + it('keeps raw RPC payloads for unit-test and mock callers', () => { + expect(getParentPortMessagePayload(request)).toBe(request) + }) + + it('unwraps Electron parentPort MessageEvent payloads', () => { + expect(getParentPortMessagePayload({ data: request })).toBe(request) + }) +}) diff --git a/test/main/lib/agentRuntime/sessionPaths.test.ts b/test/main/lib/agentRuntime/sessionPaths.test.ts index be6bf5e3a..55dd08ac5 100644 --- a/test/main/lib/agentRuntime/sessionPaths.test.ts +++ b/test/main/lib/agentRuntime/sessionPaths.test.ts @@ -1,6 +1,6 @@ import { createHash } from 'crypto' +import os from 'os' import path from 'path' -import { app } from 'electron' import { afterEach, describe, expect, it, vi } from 'vitest' import { resolveToolOffloadPath } from '@/lib/agentRuntime/sessionPaths' @@ -12,7 +12,7 @@ describe('sessionPaths offload path sanitization', () => { }) it('sanitizes colon-based tool call ids into a normal .offload file name', () => { - vi.spyOn(app, 'getPath').mockReturnValue(homeDir) + vi.spyOn(os, 'homedir').mockReturnValue(homeDir) const toolCallId = 'function.cdp_send:11' const fingerprint = createHash('sha1').update(toolCallId).digest('hex').slice(0, 8) @@ -32,7 +32,7 @@ describe('sessionPaths offload path sanitization', () => { }) it('sanitizes other windows-invalid characters and trailing dots or spaces', () => { - vi.spyOn(app, 'getPath').mockReturnValue(homeDir) + vi.spyOn(os, 'homedir').mockReturnValue(homeDir) const filePath = resolveToolOffloadPath('session-a', 'bad<>:"/\\\\|?*\u0001name. ') const fileName = path.basename(filePath!) @@ -43,7 +43,7 @@ describe('sessionPaths offload path sanitization', () => { }) it('adds a fingerprint so colliding sanitized tool ids still map to different files', () => { - vi.spyOn(app, 'getPath').mockReturnValue(homeDir) + vi.spyOn(os, 'homedir').mockReturnValue(homeDir) const colonFilePath = resolveToolOffloadPath('session-a', 'tool:1') const slashFilePath = resolveToolOffloadPath('session-a', 'tool/1') diff --git a/test/main/lib/agentRuntime/shellEnvHelper.test.ts b/test/main/lib/agentRuntime/shellEnvHelper.test.ts index 63c7572b5..8d5f49169 100644 --- a/test/main/lib/agentRuntime/shellEnvHelper.test.ts +++ b/test/main/lib/agentRuntime/shellEnvHelper.test.ts @@ -251,6 +251,7 @@ describe('shellEnvHelper', () => { value: 'win32' }) delete process.env.SHELL + process.env.USERPROFILE = 'C:\\Users\\tester' process.env.Path = 'C:\\Tools;C:\\Windows\\System32' process.env.PATH = 'C:\\Tools;C:\\Other'