import type { OpenDialogOptions, OpenDialogReturnValue } from 'electron'; import { app, App, BrowserWindow, dialog, globalShortcut, ipcMain, Menu, MenuItem, net, Notification, powerMonitor, powerSaveBlocker, screen, session, shell, Tray, } from 'electron'; import { pathToFileURL, format as formatUrl, URLSearchParams } from 'node:url'; import { Buffer } from 'node:buffer'; import fs from 'node:fs/promises'; import fsSync from 'node:fs'; import started from 'electron-squirrel-startup'; import path from 'node:path'; import os from 'node:os'; import { execFileSync, spawn, execFile } from 'child_process'; import 'dotenv/config'; import { checkBackendStatus } from './backendStatus'; import { startGooseServe } from './gooseServe'; import { getLoginShellPath } from './loginShellPath'; import { GooseServeLeaseRegistry, type GooseServeLease } from './gooseServeLeaseRegistry'; import { acpWebSocketUrlFromHttpBase, normalizeAcpHttpBaseUrl } from './acp/url'; import { expandTilde, sanitizeGoosePathRoot } from './utils/pathUtils'; import log from './utils/logger'; import { ensureWinShims } from './utils/winShims'; import { addRecentDir, loadRecentDirs } from './utils/recentDirs'; import { formatAppName, errorMessage, formatErrorForLogging } from './utils/conversionUtils'; import { isRetiredGooseChatApp } from './utils/retiredApps'; import type { Settings, SettingKey } from './utils/settings'; import { defaultSettings, getKeyboardShortcuts } from './utils/settings'; import * as crypto from 'crypto'; import * as yaml from 'yaml'; import windowStateKeeper from 'electron-window-state'; import { getUpdateAvailable, registerUpdateIpcHandlers, setAutoDownloadDisabled, setTrayRef, setupAutoUpdater, updateTrayMenu, } from './utils/autoUpdater'; import { UPDATES_ENABLED } from './updates'; import './utils/recipeHash'; import type { GooseApp } from './types/apps'; import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer'; import { BLOCKED_PROTOCOLS, WEB_PROTOCOLS } from './utils/urlSecurity'; import { buildCSP } from './utils/csp'; import { resolveWorkingDir } from './utils/workingDir'; function shouldSetupUpdater(): boolean { // Setup updater if either the flag is enabled OR dev updates are enabled return UPDATES_ENABLED || process.env.ENABLE_DEV_UPDATES === 'true'; } // ======================================================================= // Native menu localization // ----------------------------------------------------------------------- // Electron's main process can't use react-intl (which runs in the renderer), // so the native menu bar is translated here with a small hand-maintained // dictionary. Only Simplified Chinese is filled in right now; other locales // fall through to the original English labels. Keep the keys in sync with // the raw label strings used below. // ======================================================================= const MENU_TRANSLATIONS_ZH_CN: Record = { // Top-level File: '文件', Edit: '编辑', View: '视图', Window: '窗口', Help: '帮助', // Context menu 'Add to dictionary': '添加到词典', Cut: '剪切', Copy: '复制', Paste: '粘贴', // Goose-added items 'New Window': '新建窗口', Settings: '设置', 'Find…': '查找…', 'Find Next': '查找下一个', 'Find Previous': '查找上一个', 'Use Selection for Find': '用所选内容查找', Find: '查找', 'New Chat': '新建聊天', 'New Chat Window': '新建聊天窗口', 'Open Directory...': '打开目录…', 'Recent Directories': '最近的目录', 'Focus Goose Window': '聚焦 Goose 窗口', 'Quick Launcher': '快速启动器', 'Always on Top': '窗口置顶', 'Toggle Navigation': '切换导航', 'About Goose': '关于 Goose', // Electron's default role-based labels we want to translate as well. // (The menu role itself still provides the correct behaviour; only the // display string is overridden.) Undo: '撤销', Redo: '重做', 'Select All': '全选', Delete: '删除', Speech: '语音', Reload: '重新加载', 'Force Reload': '强制重新加载', 'Toggle Developer Tools': '切换开发者工具', 'Actual Size': '实际大小', 'Reset Zoom': '重置缩放', 'Zoom In': '放大', 'Zoom Out': '缩小', 'Toggle Full Screen': '切换全屏', 'Toggle Fullscreen': '切换全屏', Minimize: '最小化', Close: '关闭', 'Close Window': '关闭窗口', Quit: '退出', Exit: '退出', 'Bring All to Front': '全部置于最前', 'Emoji & Symbols': '表情符号', 'Start Dictation…': '开始听写…', 'Hide Goose': '隐藏 Goose', 'Hide Others': '隐藏其他', 'Show All': '全部显示', Services: '服务', }; function detectMenuLocale(): string { return getConfiguredGooseLocale() ?? 'en'; } function menuT(label: string): string { // Normalize underscores to hyphens so POSIX-style tags like "zh_CN" work. const lower = detectMenuLocale().replace(/_/g, '-').toLowerCase(); const isTraditional = /^zh-(hant|tw|hk|mo)\b/.test(lower); const isSimplifiedChinese = !isTraditional && (lower === 'zh' || lower.startsWith('zh-')); if (isSimplifiedChinese) { return MENU_TRANSLATIONS_ZH_CN[label] ?? label; } return label; } /** * Recursively translate `label` on every item in the given menu, including nested submenus. * Electron's default application menu comes with English labels that are not otherwise * configurable, so we post-process them here before calling `Menu.setApplicationMenu`. */ function translateMenuLabels(items: MenuItem[]): void { for (const item of items) { if (item.label) { const translated = menuT(item.label); if (translated !== item.label) { // MenuItem.label is a writable property on the main-process side, even though // the TS type sometimes claims otherwise. Cast through unknown for safety. (item as unknown as { label: string }).label = translated; } } if (item.submenu && item.submenu.items) { translateMenuLabels(item.submenu.items); } } } // Settings management const SETTINGS_FILE = path.join(app.getPath('userData'), 'settings.json'); const STARTUP_LOGS_DIR = path.join(app.getPath('userData'), 'logs', 'startup'); const validLanguageSettings = new Set([ 'system', 'en', 'es', 'fr', 'de', 'it', 'pt', 'id', 'ms', 'vi', 'hi', 'ja', 'ko', 'ru', 'tr', 'zh-CN', 'zh-TW', ]); function isValidLanguageSetting(value: unknown): value is Settings['language'] { return typeof value === 'string' && validLanguageSettings.has(value as Settings['language']); } function getSettings(): Settings { if (fsSync.existsSync(SETTINGS_FILE)) { let stored: Partial; try { const data = fsSync.readFileSync(SETTINGS_FILE, 'utf8'); stored = JSON.parse(data) as Partial; } catch (err) { console.error('Failed to read settings.json, using defaults:', err); return defaultSettings; } return { ...defaultSettings, ...stored, externalGoosed: { ...defaultSettings.externalGoosed, ...(stored.externalGoosed ?? {}), }, keyboardShortcuts: { ...defaultSettings.keyboardShortcuts, ...(stored.keyboardShortcuts ?? {}), }, }; } return defaultSettings; } function updateSettings(modifier: (settings: Settings) => void): void { const settings = getSettings(); modifier(settings); fsSync.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2)); } function getConfiguredGooseLocale(): string | undefined { const language = getSettings().language; if (isValidLanguageSetting(language) && language !== 'system') { return language; } if (process.env.GOOSE_LOCALE) { return process.env.GOOSE_LOCALE; } try { return app.isReady() ? app.getSystemLocale() || undefined : undefined; } catch { return undefined; } } function listGitWorktreeDirs(dir: string): Promise { return new Promise((resolve) => { if (!dir?.trim()) { resolve([]); return; } execFile( 'git', [ '-c', 'safe.bareRepository=explicit', '-c', 'core.fsmonitor=false', '-C', dir, 'worktree', 'list', '--porcelain', ], { timeout: 3000 }, (error, stdout) => { if (error) { resolve([]); return; } const dirs = stdout .split('\n') .filter((line) => line.startsWith('worktree ')) .map((line) => line.slice('worktree '.length).trim()) .filter(Boolean) .filter((worktreeDir, index, allDirs) => allDirs.indexOf(worktreeDir) === index); resolve(dirs); } ); }); } async function configureProxy() { const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy; const httpProxy = process.env.HTTP_PROXY || process.env.http_proxy; const noProxy = process.env.NO_PROXY || process.env.no_proxy || ''; const proxyUrl = httpsProxy || httpProxy; if (proxyUrl) { console.log('[Main] Configuring proxy'); await session.defaultSession.setProxy({ proxyRules: proxyUrl, proxyBypassRules: noProxy, }); console.log('[Main] Proxy configured successfully'); } } if (started) app.quit(); // Certificate trust for active backend leases. Renderer requests and // main-process net.fetch both pin to the exact cert fingerprint. Each backend // lease owns a trust record so old windows keep working after settings change. interface BackendCertificateTrust { hostname: string; fingerprint: string | null; } interface BackendCertificateTrustRegistration { trust: BackendCertificateTrust; release: () => void; } const trustedBackendCertificates = new Set(); function normalizeHostname(hostname: string): string { return hostname.toLowerCase(); } function normalizeFingerprint(fp: string): string { if (fp.startsWith('sha256/')) { const b64 = fp.slice('sha256/'.length); const buf = Buffer.from(b64, 'base64'); return Array.from(buf) .map((b) => b.toString(16).padStart(2, '0')) .join(':') .toUpperCase(); } return fp.toUpperCase(); } function trustBackendCertificate( hostname: string, fingerprint: string | null ): BackendCertificateTrustRegistration { const trust: BackendCertificateTrust = { hostname: normalizeHostname(hostname), fingerprint: fingerprint ? normalizeFingerprint(fingerprint) : null, }; trustedBackendCertificates.add(trust); return { trust, release: () => { trustedBackendCertificates.delete(trust); }, }; } function getBackendCertificateTrusts(hostname: string): BackendCertificateTrust[] { const normalizedHostname = normalizeHostname(hostname); return [...trustedBackendCertificates].filter((trust) => trust.hostname === normalizedHostname); } function verifyBackendCertificate(hostname: string, fingerprint: string): boolean { const normalizedFingerprint = normalizeFingerprint(fingerprint); const trusts = getBackendCertificateTrusts(hostname); if (trusts.length === 0) { return false; } if (trusts.some((trust) => trust.fingerprint === normalizedFingerprint)) { return true; } const tofuTrust = trusts.find((trust) => trust.fingerprint === null); if (tofuTrust) { // TOFU: pin the certificate from the first successful handshake. tofuTrust.fingerprint = normalizedFingerprint; return true; } return false; } function isTrustedHost(hostname: string): boolean { return getBackendCertificateTrusts(hostname).length > 0; } // Renderer requests: pin to the exact cert once known. app.on('certificate-error', (event, _webContents, url, _error, certificate, callback) => { const parsed = new URL(url); if (!isTrustedHost(parsed.hostname)) { callback(false); return; } event.preventDefault(); callback(verifyBackendCertificate(parsed.hostname, certificate.fingerprint)); }); app.whenReady().then(() => { appConfig.GOOSE_LOCALE = getConfiguredGooseLocale(); }); // Main-process net.fetch: pin to the exact cert once known. app.whenReady().then(() => { session.defaultSession.setCertificateVerifyProc((request, callback) => { if (!isTrustedHost(request.hostname)) { callback(-3); return; } const match = verifyBackendCertificate(request.hostname, request.certificate.fingerprint); callback(match ? 0 : -2); }); }); if (process.env.ENABLE_PLAYWRIGHT) { const debugPort = process.env.PLAYWRIGHT_DEBUG_PORT || '9222'; console.log(`[Main] Enabling Playwright remote debugging on port ${debugPort}`); app.commandLine.appendSwitch('remote-debugging-port', debugPort); } // In development mode, force registration as the default protocol client // In production, register normally if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { // Development mode - force registration console.log('[Main] Development mode: Forcing protocol registration for goose://'); app.setAsDefaultProtocolClient('goose'); if (process.platform === 'darwin') { try { // Reset the default handler to ensure dev version takes precedence spawn('open', ['-a', process.execPath, '--args', '--reset-protocol-handler', 'goose'], { detached: true, stdio: 'ignore', }); } catch { console.warn('[Main] Could not reset protocol handler'); } } } else { // Production mode - normal registration app.setAsDefaultProtocolClient('goose'); } // Apply single instance lock on Windows and Linux where it's needed for deep links // macOS uses the 'open-url' event instead let gotTheLock = true; let openUrlHandledLaunch = false; if (process.platform !== 'darwin') { gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { app.quit(); } else { app.on('second-instance', (_event, commandLine) => { const protocolUrl = commandLine.find((arg) => arg.startsWith('goose://')); if (protocolUrl) { const parsedUrl = new URL(protocolUrl); // If it's a bot/recipe URL, handle it directly by creating a new window if (parsedUrl.hostname === 'bot' || parsedUrl.hostname === 'recipe') { app.whenReady().then(async () => { const recentDirs = loadRecentDirs(); const openDir = recentDirs.length > 0 ? recentDirs[0] : null; const deeplinkData = parseRecipeDeeplink(protocolUrl); const scheduledJobId = parsedUrl.searchParams.get('scheduledJob'); await createChat(app, { dir: openDir || undefined, recipeDeeplink: deeplinkData?.config, scheduledJobId: scheduledJobId || undefined, recipeParameters: deeplinkData?.parameters, }); }); return; // Skip the rest of the handler } // Handle new-session URL by creating a fresh chat window if (parsedUrl.hostname === 'new-session') { app.whenReady().then(async () => { const recentDirs = loadRecentDirs(); const openDir = recentDirs.length > 0 ? recentDirs[0] : null; const prompt = parsedUrl.searchParams.get('prompt') || undefined; await createChat(app, { dir: openDir || undefined, initialMessage: prompt, initialMessageNoAutoSubmit: prompt !== undefined, }); }); return; } if (parsedUrl.hostname === 'resume') { app.whenReady().then(async () => { const recentDirs = loadRecentDirs(); const openDir = recentDirs.length > 0 ? recentDirs[0] : null; await createResumeChatWindow(parsedUrl, openDir || undefined); }); return; } // For non-bot URLs, continue with normal handling handleProtocolUrl(protocolUrl, parsedUrl); } // Only focus existing regular windows for non-bot/recipe URLs const regularWindows = getRegularWindows(); if (regularWindows.length > 0) { const mainWindow = regularWindows[0]; if (mainWindow.isMinimized()) { mainWindow.restore(); } mainWindow.focus(); } else if (!protocolUrl) { app.whenReady().then(async () => { const recentDirs = loadRecentDirs(); const openDir = recentDirs.length > 0 ? recentDirs[0] : null; await createChat(app, { dir: openDir || undefined }); }); } }); } // Handle protocol URLs on Windows and Linux startup const protocolUrl = process.argv.find((arg) => arg.startsWith('goose://')); if (protocolUrl) { app.whenReady().then(async () => { let parsedUrl: URL; try { parsedUrl = new URL(protocolUrl); } catch (error) { log.warn('[Main] Ignoring invalid startup protocol URL:', errorMessage(error)); return; } openUrlHandledLaunch = true; try { await handleProtocolUrl(protocolUrl, parsedUrl); } catch (error) { log.error('[Main] Failed to handle startup protocol URL:', errorMessage(error)); if (BrowserWindow.getAllWindows().length === 0) { const { dirPath } = parseArgs(); await createNewWindow(app, dirPath); } } }); } } const pendingDeepLinks = new Map(); function queuePendingDeepLink(windowId: number, url: string): void { if (pendingDeepLinks.get(windowId) === url) { return; } pendingDeepLinks.set(windowId, url); } const reactReadyWindows = new Set(); const DEEPLINK_BURST_DEDUP_MS = 2000; const recentSessionDeepLinkSends = new Map(); function pruneExpiredSessionDeepLinkSends(now: number): void { for (const [url, sentAt] of recentSessionDeepLinkSends) { if (now - sentAt >= DEEPLINK_BURST_DEDUP_MS) { recentSessionDeepLinkSends.delete(url); } } } function isBurstDuplicateSessionDeepLink(url: string): boolean { const now = Date.now(); pruneExpiredSessionDeepLinkSends(now); const sentAt = recentSessionDeepLinkSends.get(url); return sentAt !== undefined && now - sentAt < DEEPLINK_BURST_DEDUP_MS; } function recordSessionDeepLinkSend(url: string): void { const now = Date.now(); recentSessionDeepLinkSends.set(url, now); pruneExpiredSessionDeepLinkSends(now); } function sendOpenSharedSession(window: BrowserWindow, url: string): void { if (isBurstDuplicateSessionDeepLink(url)) { log.info('[Main] Ignoring burst duplicate session deep link'); return; } recordSessionDeepLinkSend(url); window.webContents.send('open-shared-session', url); } function deliverExtensionOrSessionDeepLink( url: string, parsedUrl: URL, targetWindow: BrowserWindow ): void { if (!reactReadyWindows.has(targetWindow.id) || targetWindow.webContents.isLoadingMainFrame()) { queuePendingDeepLink(targetWindow.id, url); return; } if (parsedUrl.hostname === 'extension') { targetWindow.webContents.send('add-extension', url); } else if (parsedUrl.hostname === 'sessions') { sendOpenSharedSession(targetWindow, url); } } function getResumeSessionId(parsedUrl: URL): string | null { try { const sessionId = decodeURIComponent(parsedUrl.pathname.replace(/^\/+/, '')).trim(); return sessionId || null; } catch { return null; } } async function createResumeChatWindow(parsedUrl: URL, dir?: string): Promise { const resumeSessionId = getResumeSessionId(parsedUrl); if (!resumeSessionId) { log.warn('[Main] Ignoring goose://resume URL without a session id'); return false; } await createChat(app, { dir, resumeSessionId }); return true; } async function handleProtocolUrl(url: string, parsedUrl: URL) { if (!url) return; const recentDirs = loadRecentDirs(); const openDir = recentDirs.length > 0 ? recentDirs[0] : null; if (parsedUrl.hostname === 'new-session') { const prompt = parsedUrl.searchParams.get('prompt') || undefined; await createChat(app, { dir: openDir || undefined, initialMessage: prompt, initialMessageNoAutoSubmit: prompt !== undefined, }); return; } else if (parsedUrl.hostname === 'resume') { await createResumeChatWindow(parsedUrl, openDir || undefined); return; } else if (parsedUrl.hostname === 'bot' || parsedUrl.hostname === 'recipe') { const existingWindows = BrowserWindow.getAllWindows(); const targetWindow = existingWindows.length > 0 ? existingWindows[0] : await createChat(app, { dir: openDir || undefined }); if (!targetWindow) return; await processProtocolUrl(url, parsedUrl, targetWindow); } else { const regularWindows = getRegularWindows(); let targetWindow: BrowserWindow | undefined; if (regularWindows.length > 0) { targetWindow = regularWindows[0]; if (targetWindow.isMinimized()) { targetWindow.restore(); } targetWindow.focus(); } else { targetWindow = await createChat(app, { dir: openDir || undefined }); } if (!targetWindow) return; if (targetWindow.webContents.isLoadingMainFrame()) { queuePendingDeepLink(targetWindow.id, url); } else { await processProtocolUrl(url, parsedUrl, targetWindow); } } } async function processProtocolUrl(url: string, parsedUrl: URL, window: BrowserWindow) { const recentDirs = loadRecentDirs(); const openDir = recentDirs.length > 0 ? recentDirs[0] : null; if (parsedUrl.hostname === 'extension') { window.webContents.send('add-extension', url); } else if (parsedUrl.hostname === 'sessions') { sendOpenSharedSession(window, url); } else if (parsedUrl.hostname === 'bot' || parsedUrl.hostname === 'recipe') { const deeplinkData = parseRecipeDeeplink(url); const scheduledJobId = parsedUrl.searchParams.get('scheduledJob'); await createChat(app, { dir: openDir || undefined, recipeDeeplink: deeplinkData?.config, scheduledJobId: scheduledJobId || undefined, recipeParameters: deeplinkData?.parameters, }); } } let windowDeeplinkURL: string | null = null; app.on('open-url', async (_event, url) => { if (process.platform !== 'win32') { const parsedUrl = new URL(url); log.info( '[Main] Received open-url event:', url.includes('key=') ? url.replace(/key=[^&]+/, 'key=REDACTED') : url ); await app.whenReady(); const recentDirs = loadRecentDirs(); const openDir = recentDirs.length > 0 ? recentDirs[0] : null; // Handle new-session URL by creating a fresh chat window if (parsedUrl.hostname === 'new-session') { log.info('[Main] Detected new-session URL, creating new chat window'); openUrlHandledLaunch = true; const prompt = parsedUrl.searchParams.get('prompt') || undefined; await createChat(app, { dir: openDir || undefined, initialMessage: prompt, initialMessageNoAutoSubmit: prompt !== undefined, }); return; } if (parsedUrl.hostname === 'resume') { log.info('[Main] Detected resume URL, creating session resume window'); openUrlHandledLaunch = await createResumeChatWindow(parsedUrl, openDir || undefined); return; } // Handle bot/recipe URLs by directly creating a new window if (parsedUrl.hostname === 'bot' || parsedUrl.hostname === 'recipe') { log.info('[Main] Detected bot/recipe URL, creating new chat window'); openUrlHandledLaunch = true; const deeplinkData = parseRecipeDeeplink(url); if (deeplinkData) { windowDeeplinkURL = url; } const scheduledJobId = parsedUrl.searchParams.get('scheduledJob'); await createChat(app, { dir: openDir || undefined, recipeDeeplink: deeplinkData?.config, scheduledJobId: scheduledJobId || undefined, recipeParameters: deeplinkData?.parameters, }); windowDeeplinkURL = null; return; } // For extension/session URLs, send to an existing regular window or open one const regularWindows = getRegularWindows(); if (regularWindows.length > 0) { const targetWindow = regularWindows[0]; if (targetWindow.isMinimized()) targetWindow.restore(); targetWindow.focus(); if (parsedUrl.hostname === 'extension' || parsedUrl.hostname === 'sessions') { deliverExtensionOrSessionDeepLink(url, parsedUrl, targetWindow); } } else { openUrlHandledLaunch = true; const newWindow = await createChat(app, { dir: openDir || undefined }); if (!newWindow) return; queuePendingDeepLink(newWindow.id, url); } } }); // Handle macOS drag-and-drop onto dock icon app.on('will-finish-launching', () => { if (process.platform === 'darwin') { app.setAboutPanelOptions({ applicationName: 'Goose', applicationVersion: app.getVersion(), }); } }); // Handle drag-and-drop onto dock icon app.on('open-file', async (event, filePath) => { event.preventDefault(); await handleFileOpen(filePath); }); // Handle multiple files/folders (macOS only) if (process.platform === 'darwin') { // Use type assertion for non-standard Electron event // eslint-disable-next-line @typescript-eslint/no-explicit-any app.on('open-files' as any, async (event: any, filePaths: string[]) => { event.preventDefault(); for (const filePath of filePaths) { await handleFileOpen(filePath); } }); } async function handleFileOpen(filePath: string) { try { if (!filePath || typeof filePath !== 'string') { return; } const stats = fsSync.lstatSync(filePath); let targetDir = filePath; // If it's a file, use its parent directory if (stats.isFile()) { targetDir = path.dirname(filePath); } // Add to recent directories addRecentDir(targetDir); // Create new window for the directory const newWindow = await createChat(app, { dir: targetDir }); // Focus the new window if (newWindow) { newWindow.show(); newWindow.focus(); newWindow.moveTop(); } } catch (error) { console.error('Failed to handle file open:', error); // Show user-friendly error notification new Notification({ title: 'Goose', body: `Could not open directory: ${path.basename(filePath)}`, }).show(); } } declare var MAIN_WINDOW_VITE_DEV_SERVER_URL: string; declare var MAIN_WINDOW_VITE_NAME: string; function getAppUrl(): URL { return MAIN_WINDOW_VITE_DEV_SERVER_URL ? new URL(MAIN_WINDOW_VITE_DEV_SERVER_URL) : pathToFileURL(path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`)); } // Parse command line arguments const parseArgs = () => { let dirPath = null; // Remove first two elements in dev mode (electron and script path) const args = !dirPath && app.isPackaged ? process.argv : process.argv.slice(2); for (let i = 0; i < args.length; i++) { if (args[i] === '--dir' && i + 1 < args.length) { dirPath = args[i + 1]; break; } } if (!dirPath && process.stdin.isTTY) { try { const cwd = process.cwd(); if (path.parse(cwd).root !== cwd) { dirPath = cwd; } } catch { // cwd unavailable; fall through to recentDirs } } return { dirPath }; }; interface BundledConfig { defaultProvider?: string; defaultModel?: string; predefinedModels?: string; version?: string; } const getBundledConfig = (): BundledConfig => { //{env-macro-start}// //needed when goose is bundled for a specific provider //{env-macro-end}// return { defaultProvider: process.env.GOOSE_DEFAULT_PROVIDER, defaultModel: process.env.GOOSE_DEFAULT_MODEL, predefinedModels: process.env.GOOSE_PREDEFINED_MODELS, version: process.env.GOOSE_VERSION, }; }; const { defaultProvider, defaultModel, predefinedModels, version } = getBundledConfig(); const GENERATED_SECRET = crypto.randomBytes(32).toString('hex'); interface ExternalBackend { source: 'env' | 'settings'; url: string; secret: string; certFingerprint?: string; workingDir?: string; } const getExternalBackendUrlFromEnv = (): string | null => { if (!process.env.GOOSE_EXTERNAL_BACKEND) { return null; } const configuredUrl = process.env.GOOSE_EXTERNAL_BACKEND_URL?.trim(); if (configuredUrl) { return configuredUrl; } return `http://127.0.0.1:${process.env.GOOSE_PORT || '3000'}`; }; const getExternalBackendFromEnv = (): ExternalBackend | null => { const url = getExternalBackendUrlFromEnv(); if (!url) { return null; } const secret = process.env.GOOSE_SERVER__SECRET_KEY; if (!secret) { throw new Error( 'GOOSE_SERVER__SECRET_KEY must be set when using GOOSE_EXTERNAL_BACKEND. ' + 'Set it to the same value on both the server and the desktop client.' ); } return { source: 'env', url, secret, }; }; const getServerSecret = (settings: Settings): string => { if (settings.externalGoosed?.enabled && settings.externalGoosed.secret) { return settings.externalGoosed.secret; } return GENERATED_SECRET; }; const getActiveExternalBackend = (settings: Settings): ExternalBackend | null => { const envBackend = getExternalBackendFromEnv(); if (envBackend) { return { ...envBackend, workingDir: settings.externalGoosed?.workingDir, }; } if (settings.externalGoosed?.enabled && settings.externalGoosed.url) { return { source: 'settings', url: settings.externalGoosed.url, secret: getServerSecret(settings), certFingerprint: settings.externalGoosed.certFingerprint, workingDir: settings.externalGoosed.workingDir, }; } return null; }; const getExternalBackendForCsp = (settings: Settings) => { const envUrl = getExternalBackendUrlFromEnv(); if (!envUrl) { return settings.externalGoosed; } return { ...settings.externalGoosed, enabled: true, url: envUrl, }; }; let appConfig = { GOOSE_DEFAULT_PROVIDER: defaultProvider, GOOSE_DEFAULT_MODEL: defaultModel, GOOSE_PREDEFINED_MODELS: predefinedModels, GOOSE_PATH_ROOT: sanitizeGoosePathRoot(process.env), GOOSE_WORKING_DIR: '', // Start with the env-var override; the OS region locale is filled in after app.ready // (see updateLocaleFromSystem below) since getSystemLocale() cannot be called earlier. GOOSE_LOCALE: process.env.GOOSE_LOCALE || undefined, // If GOOSE_ALLOWLIST_WARNING env var is not set, defaults to false (strict blocking mode) GOOSE_ALLOWLIST_WARNING: process.env.GOOSE_ALLOWLIST_WARNING === 'true', GOOSE_DISABLE_NOSTR_SHARING: process.env.GOOSE_DISABLE_NOSTR_SHARING === 'true', }; const windowMap = new Map(); const appWindows = new Map(); function getRegularWindows(): BrowserWindow[] { return [...windowMap.values()].filter((w) => !w.isDestroyed()); } const gooseServeLeases = new GooseServeLeaseRegistry(log); const windowPowerSaveBlockers = new Map(); // windowId -> blockerId // Track pending initial messages per window const pendingInitialMessages = new Map(); // windowId -> initialMessage const pendingInitialMessageNoAutoSubmit = new Set(); // windowIds whose initialMessage should NOT auto-submit interface CreateChatOptions { initialMessage?: string; initialMessageNoAutoSubmit?: boolean; dir?: string; resumeSessionId?: string; viewType?: string; recipeDeeplink?: string; recipeId?: string; scheduledJobId?: string; recipeParameters?: Record; } const createChat = async ( app: App, options: CreateChatOptions = {} ): Promise => { const { initialMessage, initialMessageNoAutoSubmit, dir, resumeSessionId, viewType, recipeDeeplink, recipeId, scheduledJobId, recipeParameters, } = options; const settings = getSettings(); let externalBackend: ExternalBackend | null; try { externalBackend = getActiveExternalBackend(settings); } catch (error) { dialog.showMessageBoxSync({ type: 'error', title: 'External Backend Misconfigured', message: 'The external backend environment is invalid.', detail: errorMessage(error), buttons: ['Quit'], }); app.quit(); return; } if (externalBackend?.certFingerprint) { const url = externalBackend.url; const usesHttps = (() => { try { return new URL(url).protocol === 'https:'; } catch { return false; } })(); if (!usesHttps) { const response = dialog.showMessageBoxSync({ type: 'error', title: 'External Backend Misconfigured', message: 'Certificate fingerprint requires an HTTPS external backend URL.', detail: 'Use an https:// URL or remove the configured certificate fingerprint.', buttons: ['Disable External Backend & Retry', 'Quit'], defaultId: 0, cancelId: 1, }); if (response === 0) { updateSettings((s) => { if (s.externalGoosed) { s.externalGoosed.enabled = false; } }); return createChat(app, options); } app.quit(); return; } } const serverSecret = externalBackend ? externalBackend.secret : GENERATED_SECRET; let workingDir = resolveWorkingDir(externalBackend?.workingDir, dir, os.homedir()); let gooseServeLease: GooseServeLease | null = null; if (externalBackend) { let externalCertificateTrust: BackendCertificateTrustRegistration | null = null; try { const externalBaseUrl = normalizeAcpHttpBaseUrl(externalBackend.url); const externalBase = new URL(externalBaseUrl); if (externalBase.protocol === 'https:') { externalCertificateTrust = trustBackendCertificate( externalBase.hostname, externalBackend.certFingerprint ?? null ); } const externalBackendReady = await checkBackendStatus({ baseUrl: externalBaseUrl, serverSecret, fetch: net.fetch as unknown as typeof globalThis.fetch, }); if (!externalBackendReady) { externalCertificateTrust?.release(); const canDisableExternalBackend = externalBackend.source === 'settings'; const response = dialog.showMessageBoxSync({ type: 'error', title: 'External Backend Unreachable', message: `Could not connect to external backend at ${externalBaseUrl}`, detail: 'The external backend must be running and the configured secret must match GOOSE_SERVER__SECRET_KEY on the server.', buttons: canDisableExternalBackend ? ['Disable External Backend & Retry', 'Quit'] : ['Quit'], defaultId: 0, cancelId: canDisableExternalBackend ? 1 : 0, }); if (canDisableExternalBackend && response === 0) { updateSettings((s) => { if (s.externalGoosed) { s.externalGoosed.enabled = false; } }); return createChat(app, options); } app.quit(); return; } const leaseCertificateTrust = externalCertificateTrust; externalCertificateTrust = null; gooseServeLease = gooseServeLeases.createExternal( acpWebSocketUrlFromHttpBase(externalBaseUrl, serverSecret), serverSecret, leaseCertificateTrust ? async () => leaseCertificateTrust.release() : undefined ); } catch (error) { externalCertificateTrust?.release(); log.error('External ACP backend is misconfigured', error); const canDisableExternalBackend = externalBackend.source === 'settings'; const response = dialog.showMessageBoxSync({ type: 'error', title: 'External Backend Misconfigured', message: 'The external backend URL is invalid.', detail: errorMessage(error), buttons: canDisableExternalBackend ? ['Disable External Backend & Retry', 'Quit'] : ['Quit'], defaultId: 0, cancelId: canDisableExternalBackend ? 1 : 0, }); if (canDisableExternalBackend && response === 0) { updateSettings((s) => { if (s.externalGoosed) { s.externalGoosed.enabled = false; } }); return createChat(app, options); } app.quit(); return; } } else { const localCertificateTrust = trustBackendCertificate('127.0.0.1', null); const loginShellPath = await getLoginShellPath(log); let gooseServeResult: Awaited>; try { gooseServeResult = await startGooseServe({ serverSecret, dir: workingDir, tls: true, env: { GOOSE_PATH_ROOT: appConfig.GOOSE_PATH_ROOT as string | undefined, }, loginShellPath, isPackaged: app.isPackaged, resourcesPath: app.isPackaged ? process.resourcesPath : undefined, logger: log, diagnosticsDir: STARTUP_LOGS_DIR, readinessFetch: net.fetch as unknown as typeof globalThis.fetch, }); if (!gooseServeResult.certFingerprint) { await gooseServeResult.cleanup(); throw new Error( 'goose serve started with TLS but did not return a certificate fingerprint' ); } const localCertFingerprint = normalizeFingerprint(gooseServeResult.certFingerprint); if ( localCertificateTrust.trust.fingerprint && localCertificateTrust.trust.fingerprint !== localCertFingerprint ) { await gooseServeResult.cleanup(); throw new Error('goose serve TLS certificate fingerprint did not match readiness probe'); } localCertificateTrust.trust.fingerprint = localCertFingerprint; } catch (error) { localCertificateTrust.release(); log.error('goose serve failed to start', error); dialog.showMessageBoxSync({ type: 'error', title: 'Goose Failed to Start', message: 'The backend server failed to start.', detail: [ 'Backend: goose serve', 'Readiness check: HTTPS GET /status', `Startup error:\n${errorMessage(error)}`, ].join('\n\n'), buttons: ['OK'], }); app.quit(); return; } workingDir = gooseServeResult.workingDir; const cleanupGooseServe = gooseServeResult.cleanup; gooseServeResult.cleanup = async () => { try { await cleanupGooseServe(); } finally { localCertificateTrust.release(); } }; gooseServeLease = gooseServeLeases.create(gooseServeResult, serverSecret); } const cleanupUnregisteredGooseServeLease = async () => { if (!gooseServeLease) { return; } const lease = gooseServeLease; gooseServeLease = null; await gooseServeLeases.cleanupLease(lease); }; let mainWindowState: ReturnType; let mainWindow: BrowserWindow; try { mainWindowState = windowStateKeeper({ defaultWidth: 940, defaultHeight: 800, }); mainWindow = new BrowserWindow({ show: false, titleBarStyle: process.platform === 'darwin' ? 'hidden' : 'default', trafficLightPosition: process.platform === 'darwin' ? { x: 20, y: 16 } : undefined, vibrancy: process.platform === 'darwin' ? 'window' : undefined, frame: process.platform !== 'darwin', // windowStateKeeper persists the outer window bounds (getBounds), so the // window must be restored by outer bounds too. With useContentSize the saved // outer height is reapplied as the content height, growing the window by the // frame height on every launch on framed platforms (#9363). x: mainWindowState.x, y: mainWindowState.y, width: mainWindowState.width, height: mainWindowState.height, minWidth: 480, minHeight: 400, resizable: true, icon: path.join(__dirname, '../images/icon.icns'), webPreferences: { spellcheck: settings.spellcheckEnabled ?? true, preload: path.join(__dirname, 'preload.js'), webSecurity: true, nodeIntegration: false, contextIsolation: true, additionalArguments: [ JSON.stringify({ ...appConfig, GOOSE_LOCALE: getConfiguredGooseLocale(), GOOSE_WORKING_DIR: workingDir, REQUEST_DIR: dir, GOOSE_VERSION: version, recipeDeeplink: recipeDeeplink, recipeId: recipeId, recipeParameters: recipeParameters, scheduledJobId: scheduledJobId, SECURITY_ML_MODEL_MAPPING: process.env.SECURITY_ML_MODEL_MAPPING, SECURITY_PROMPT_ENABLED_OVERRIDE: process.env.SECURITY_PROMPT_ENABLED_OVERRIDE, SECURITY_COMMAND_CLASSIFIER_ENABLED_OVERRIDE: process.env.SECURITY_COMMAND_CLASSIFIER_ENABLED_OVERRIDE, }), ], partition: 'persist:goose', }, }); } catch (error) { await cleanupUnregisteredGooseServeLease(); throw error; } if (gooseServeLease) { const lease = gooseServeLease; mainWindow.once('closed', () => { void gooseServeLeases.releaseWindow(mainWindow.id); }); gooseServeLeases.attachWindow(mainWindow.id, lease); gooseServeLease = null; } if (!app.isPackaged) { installExtension(REACT_DEVELOPER_TOOLS, { loadExtensionOptions: { allowFileAccess: true }, session: mainWindow.webContents.session, }) .then(() => log.info('added react dev tools')) .catch((err) => log.info('failed to install react dev tools:', err)); } // Let windowStateKeeper manage the window mainWindowState.manage(mainWindow); mainWindow.webContents.session.setSpellCheckerLanguages(['en-US', 'en-GB']); mainWindow.webContents.on('context-menu', (_event, params) => { const menu = new Menu(); const hasSpellingSuggestions = params.dictionarySuggestions.length > 0 || params.misspelledWord; if (hasSpellingSuggestions) { for (const suggestion of params.dictionarySuggestions) { menu.append( new MenuItem({ label: suggestion, click: () => mainWindow.webContents.replaceMisspelling(suggestion), }) ); } if (params.misspelledWord) { menu.append( new MenuItem({ label: menuT('Add to dictionary'), click: () => mainWindow.webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord), }) ); } if (params.selectionText) { menu.append(new MenuItem({ type: 'separator' })); } } if (params.selectionText) { menu.append( new MenuItem({ label: menuT('Cut'), accelerator: 'CmdOrCtrl+X', role: 'cut', }) ); menu.append( new MenuItem({ label: menuT('Copy'), accelerator: 'CmdOrCtrl+C', role: 'copy', }) ); } // Only show paste in editable fields (text inputs) if (params.isEditable) { menu.append( new MenuItem({ label: menuT('Paste'), accelerator: 'CmdOrCtrl+V', role: 'paste', }) ); } if (menu.items.length > 0) { menu.popup(); } }); // Handle new window creation for links (fallback for any links not handled by onClick) mainWindow.webContents.setWindowOpenHandler(({ url }) => { try { const protocol = new URL(url).protocol; if (BLOCKED_PROTOCOLS.includes(protocol)) { return { action: 'deny' }; } } catch { return { action: 'deny' }; } shell.openExternal(url); return { action: 'deny' }; }); // Handle new-window events (alternative approach for external links) // Use type assertion for non-standard Electron event // eslint-disable-next-line @typescript-eslint/no-explicit-any mainWindow.webContents.on('new-window' as any, function (event: any, url: string) { event.preventDefault(); try { const protocol = new URL(url).protocol; if (BLOCKED_PROTOCOLS.includes(protocol)) { return; } } catch { return; } shell.openExternal(url); }); const windowId = mainWindow.id; const url = getAppUrl(); let appPath = '/'; const routeMap: Record = { chat: '/', pair: '/pair', settings: '/settings', sessions: '/sessions', schedules: '/schedules', recipes: '/recipes', skills: '/skills', permission: '/permission', ConfigureProviders: '/configure-providers', }; if (viewType) { appPath = routeMap[viewType] || '/'; } if ( appPath === '/' && (recipeDeeplink !== undefined || recipeId !== undefined || initialMessage) ) { appPath = '/pair'; } let searchParams = new URLSearchParams(); if (resumeSessionId) { searchParams.set('resumeSessionId', resumeSessionId); if (appPath === '/') { appPath = '/pair'; } } // Goose's react app uses HashRouter, so the path + search params follow a #/ url.hash = `${appPath}?${searchParams.toString()}`; let formattedUrl = formatUrl(url); log.info('Opening URL: ', formattedUrl); mainWindow.once('ready-to-show', () => { if (!mainWindow.isDestroyed()) { mainWindow.show(); } }); mainWindow.loadURL(formattedUrl); // If we have an initial message, store it to send after React is ready if (initialMessage) { pendingInitialMessages.set(mainWindow.id, initialMessage); if (initialMessageNoAutoSubmit) { pendingInitialMessageNoAutoSubmit.add(mainWindow.id); } } // Set up local keyboard shortcuts that only work when the window is focused mainWindow.webContents.on('before-input-event', (event, input) => { if (input.key === 'r' && input.meta) { mainWindow.reload(); event.preventDefault(); } if (input.key === 'i' && input.alt && input.meta) { mainWindow.webContents.openDevTools(); event.preventDefault(); } }); mainWindow.on('app-command', (e, cmd) => { if (cmd === 'browser-backward') { mainWindow.webContents.send('mouse-back-button-clicked'); e.preventDefault(); } }); const broadcastFullScreenState = () => { if (!mainWindow.isDestroyed()) { mainWindow.webContents.send('fullscreen-change', mainWindow.isFullScreen()); } }; mainWindow.on('enter-full-screen', broadcastFullScreenState); mainWindow.on('leave-full-screen', broadcastFullScreenState); // Handle mouse back button (button 3) // Use type assertion for non-standard Electron event // eslint-disable-next-line @typescript-eslint/no-explicit-any mainWindow.webContents.on('mouse-up' as any, function (_event: any, mouseButton: number) { // MouseButton 3 is the back button. if (mouseButton === 3) { mainWindow.webContents.send('mouse-back-button-clicked'); } }); windowMap.set(windowId, mainWindow); // Handle window closure mainWindow.on('closed', () => { windowMap.delete(windowId); pendingInitialMessages.delete(windowId); pendingDeepLinks.delete(windowId); reactReadyWindows.delete(windowId); if (windowPowerSaveBlockers.has(windowId)) { const blockerId = windowPowerSaveBlockers.get(windowId)!; try { powerSaveBlocker.stop(blockerId); console.log( `[Main] Stopped power save blocker ${blockerId} for closing window ${windowId}` ); } catch (error) { console.error( `[Main] Failed to stop power save blocker ${blockerId} for window ${windowId}:`, error ); } windowPowerSaveBlockers.delete(windowId); } }); return mainWindow; }; let activeLauncherWindow: BrowserWindow | null = null; const createLauncher = () => { if (activeLauncherWindow && !activeLauncherWindow.isDestroyed()) { activeLauncherWindow.focus(); return activeLauncherWindow; } const launcherWindow = new BrowserWindow({ width: 600, height: 80, frame: false, transparent: process.platform === 'darwin', backgroundColor: process.platform === 'darwin' ? '#00000000' : '#ffffff', webPreferences: { preload: path.join(__dirname, 'preload.js'), nodeIntegration: false, contextIsolation: true, additionalArguments: [ JSON.stringify({ ...appConfig, GOOSE_LOCALE: getConfiguredGooseLocale(), }), ], partition: 'persist:goose', }, skipTaskbar: true, alwaysOnTop: true, resizable: false, movable: true, minimizable: false, maximizable: false, fullscreenable: false, hasShadow: true, vibrancy: process.platform === 'darwin' ? 'window' : undefined, }); // Center on screen const primaryDisplay = screen.getPrimaryDisplay(); const { width, height } = primaryDisplay.workAreaSize; const windowBounds = launcherWindow.getBounds(); launcherWindow.setPosition( Math.round(width / 2 - windowBounds.width / 2), Math.round(height / 3 - windowBounds.height / 2) ); // Load launcher window content const url = getAppUrl(); url.hash = '/launcher'; launcherWindow.loadURL(formatUrl(url)); activeLauncherWindow = launcherWindow; launcherWindow.on('closed', () => { reactReadyWindows.delete(launcherWindow.id); activeLauncherWindow = null; }); // Destroy window when it loses focus launcherWindow.on('blur', () => { launcherWindow.destroy(); }); // Also destroy on escape key launcherWindow.webContents.on('before-input-event', (event, input) => { if (input.key === 'Escape') { launcherWindow.destroy(); event.preventDefault(); } }); return launcherWindow; }; // Track tray instance let tray: Tray | null = null; const destroyTray = () => { if (tray) { tray.destroy(); tray = null; } }; const disableTray = () => { updateSettings((s) => { s.showMenuBarIcon = false; }); }; const createTray = () => { destroyTray(); const possiblePaths = [ path.join(process.resourcesPath, 'images', 'iconTemplate.png'), path.join(process.cwd(), 'src', 'images', 'iconTemplate.png'), path.join(__dirname, '..', 'images', 'iconTemplate.png'), path.join(__dirname, 'images', 'iconTemplate.png'), path.join(process.cwd(), 'images', 'iconTemplate.png'), ]; const iconPath = possiblePaths.find((p) => fsSync.existsSync(p)); if (!iconPath) { console.warn('[Main] Tray icon not found. App will continue without system tray.'); disableTray(); return; } try { tray = new Tray(iconPath); setTrayRef(tray); updateTrayMenu(getUpdateAvailable()); if (process.platform === 'win32') { tray.on('click', showWindow); } } catch (error) { console.error('[Main] Tray creation failed. App will continue without system tray.', error); disableTray(); tray = null; } }; const showWindow = async () => { const windows = BrowserWindow.getAllWindows(); if (windows.length === 0) { log.info('No windows are open, creating a new one...'); const recentDirs = loadRecentDirs(); const openDir = recentDirs.length > 0 ? recentDirs[0] : null; await createChat(app, { dir: openDir || undefined }); return; } const initialOffsetX = 30; const initialOffsetY = 30; // Iterate over all windows windows.forEach((win, index) => { const currentBounds = win.getBounds(); const newX = currentBounds.x + initialOffsetX * index; const newY = currentBounds.y + initialOffsetY * index; win.setBounds({ x: newX, y: newY, width: currentBounds.width, height: currentBounds.height, }); if (!win.isVisible()) { win.show(); } win.focus(); }); }; const buildRecentFilesMenu = () => { const recentDirs = loadRecentDirs(); return recentDirs.map((dir) => ({ label: dir, click: async () => { await createChat(app, { dir }); }, })); }; const openDirectoryDialog = async (): Promise => { // Get the current working directory from the focused window let defaultPath: string | undefined; const currentWindow = BrowserWindow.getFocusedWindow(); if (currentWindow) { try { const currentWorkingDir = await currentWindow.webContents.executeJavaScript( `window.appConfig ? window.appConfig.get('GOOSE_WORKING_DIR') : null` ); if (currentWorkingDir && typeof currentWorkingDir === 'string') { // Verify the directory exists before using it as default try { const stats = fsSync.lstatSync(currentWorkingDir); if (stats.isDirectory()) { defaultPath = currentWorkingDir; } } catch (error) { if (error && typeof error === 'object' && 'code' in error) { const fsError = error as { code?: string; message?: string }; if ( fsError.code === 'ENOENT' || fsError.code === 'EACCES' || fsError.code === 'EPERM' ) { console.warn( `Current working directory not accessible (${fsError.code}): ${currentWorkingDir}, falling back to home directory` ); defaultPath = os.homedir(); } else { console.warn( `Unexpected filesystem error (${fsError.code}) for directory ${currentWorkingDir}:`, fsError.message ); defaultPath = os.homedir(); } } else { console.warn(`Unexpected error checking directory ${currentWorkingDir}:`, error); defaultPath = os.homedir(); } } } } catch (error) { console.warn('Failed to get current working directory from window:', error); } } if (!defaultPath) { defaultPath = os.homedir(); } const result = (await dialog.showOpenDialog({ properties: ['openFile', 'openDirectory', 'createDirectory'], defaultPath: defaultPath, })) as unknown as OpenDialogReturnValue; if (!result.canceled && result.filePaths.length > 0) { const selectedPath = result.filePaths[0]; // If a file was selected, use its parent directory let dirToAdd = selectedPath; try { const stats = fsSync.lstatSync(selectedPath); // Reject symlinks for security if (stats.isSymbolicLink()) { console.warn(`Selected path is a symlink, using parent directory for security`); dirToAdd = path.dirname(selectedPath); } else if (stats.isFile()) { dirToAdd = path.dirname(selectedPath); } } catch { console.warn(`Could not stat selected path, using parent directory`); dirToAdd = path.dirname(selectedPath); // Fallback to parent directory } addRecentDir(dirToAdd); let deeplinkData: RecipeDeeplinkData | undefined = undefined; if (windowDeeplinkURL) { deeplinkData = parseRecipeDeeplink(windowDeeplinkURL); } await createChat(app, { dir: dirToAdd, recipeDeeplink: deeplinkData?.config, recipeParameters: deeplinkData?.parameters, }); } return result; }; interface RecipeDeeplinkData { config: string; parameters?: Record; } function parseRecipeDeeplink(url: string): RecipeDeeplinkData | undefined { const parsedUrl = new URL(url); let recipeDeeplink = parsedUrl.searchParams.get('config'); if (recipeDeeplink && !url.includes(recipeDeeplink)) { // URLSearchParams decodes + as space, which can break encoded configs // Parse raw query to preserve "+" characters in values like config const search = parsedUrl.search || ''; const configMatch = search.match(/(?:[?&])config=([^&]*)/); let recipeDeeplinkTmp = configMatch ? configMatch[1] : null; if (recipeDeeplinkTmp) { try { recipeDeeplink = decodeURIComponent(recipeDeeplinkTmp); } catch (error) { console.error('[Main] parseRecipeDeeplink - Failed to decode:', errorMessage(error)); return undefined; } } } if (!recipeDeeplink) { return undefined; } // Extract all query parameters except 'config' and 'scheduledJob' as recipe parameters // Use raw query string parsing to preserve '+' characters (consistent with config handling) const parameters: Record = {}; const search = parsedUrl.search || ''; const paramMatches = search.matchAll(/[?&]([^=&]+)=([^&]*)/g); for (const match of paramMatches) { const key = match[1]; const rawValue = match[2]; if (key !== 'config' && key !== 'scheduledJob') { try { parameters[key] = decodeURIComponent(rawValue); } catch { // If decoding fails, use raw value parameters[key] = rawValue; } } } return { config: recipeDeeplink, parameters: Object.keys(parameters).length > 0 ? parameters : undefined, }; } // Global error handler const handleFatalError = (error: Error) => { const windows = BrowserWindow.getAllWindows(); windows.forEach((win) => { win.webContents.send('fatal-error', error.message || 'An unexpected error occurred'); }); }; process.on('uncaughtException', (error) => { console.error('Uncaught Exception:', formatErrorForLogging(error)); handleFatalError(error); }); process.on('unhandledRejection', (error) => { console.error('Unhandled Rejection:', formatErrorForLogging(error)); handleFatalError(error instanceof Error ? error : new Error(String(error))); }); ipcMain.on('react-ready', (event) => { log.info('React ready event received'); // Get the window that sent the react-ready event const window = BrowserWindow.fromWebContents(event.sender); const windowId = window?.id; if (windowId !== undefined) { reactReadyWindows.add(windowId); } // Send any pending initial message for this window if (windowId && pendingInitialMessages.has(windowId)) { const initialMessage = pendingInitialMessages.get(windowId)!; const noAutoSubmit = pendingInitialMessageNoAutoSubmit.has(windowId); log.info('Sending pending initial message to window:', initialMessage); window.webContents.send('set-initial-message', initialMessage, { noAutoSubmit }); pendingInitialMessages.delete(windowId); pendingInitialMessageNoAutoSubmit.delete(windowId); } if (windowId && pendingDeepLinks.has(windowId) && window) { const deepLinkUrl = pendingDeepLinks.get(windowId)!; pendingDeepLinks.delete(windowId); log.info('Processing pending deep link for window:', windowId); try { const parsedUrl = new URL(deepLinkUrl); if (parsedUrl.hostname === 'extension') { window.webContents.send('add-extension', deepLinkUrl); } else if (parsedUrl.hostname === 'sessions') { sendOpenSharedSession(window, deepLinkUrl); } } catch (error) { log.error('Error processing pending deep link:', error); } } }); ipcMain.handle('open-external', async (_event, url: string) => { const parsedUrl = new URL(url); if (BLOCKED_PROTOCOLS.includes(parsedUrl.protocol)) { console.warn(`[Main] Blocked dangerous protocol: ${parsedUrl.protocol}`); return; } await shell.openExternal(url); }); ipcMain.handle('directory-chooser', async () => { return dialog.showOpenDialog({ properties: ['openDirectory', 'createDirectory'], defaultPath: os.homedir(), }); }); ipcMain.handle('add-recent-dir', (_event, dir: string) => { if (dir) { addRecentDir(dir); } }); ipcMain.handle('list-recent-dirs', () => { return loadRecentDirs(); }); ipcMain.handle('list-git-worktree-dirs', async (_event, dir: string) => { return await listGitWorktreeDirs(dir); }); ipcMain.handle('get-setting', (_event, key: SettingKey) => { const settings = getSettings(); return settings[key]; }); // Valid setting keys for runtime validation const validSettingKeys: Set = new Set([ 'showMenuBarIcon', 'showDockIcon', 'enableWakelock', 'enableNotifications', 'spellcheckEnabled', 'externalGoosed', 'globalShortcut', 'keyboardShortcuts', 'theme', 'useSystemTheme', 'language', 'responseStyle', 'showPricing', 'seenAnnouncementIds', 'disableAutoDownload', ]); ipcMain.handle('set-setting', (_event, key: SettingKey, value: unknown) => { // Validate key at runtime to prevent prototype pollution if (!validSettingKeys.has(key)) { console.error(`Invalid setting key rejected: ${key}`); return; } if (key === 'language' && !isValidLanguageSetting(value)) { console.error(`Invalid language setting rejected: ${String(value)}`); return; } const settings = getSettings(); // eslint-disable-next-line @typescript-eslint/no-explicit-any (settings as any)[key] = value; fsSync.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2)); if (key === 'language') { appConfig.GOOSE_LOCALE = getConfiguredGooseLocale(); } // Re-register shortcuts if keyboard shortcuts changed if (key === 'keyboardShortcuts') { registerGlobalShortcuts(); } if (key === 'disableAutoDownload') { setAutoDownloadDisabled(value as boolean); } }); ipcMain.handle('get-secret-key', (event) => { const windowId = BrowserWindow.fromWebContents(event.sender)?.id; if (!windowId) { return null; } return gooseServeLeases.getSecretKey(windowId) ?? null; }); ipcMain.handle('get-acp-url', async (event) => { const windowId = BrowserWindow.fromWebContents(event.sender)?.id; if (!windowId) { return null; } return gooseServeLeases.getAcpUrl(windowId) ?? null; }); // Handle menu bar icon visibility ipcMain.handle('set-menu-bar-icon', async (_event, show: boolean) => { updateSettings((s) => { s.showMenuBarIcon = show; }); if (show) { createTray(); } else { destroyTray(); } return true; }); ipcMain.handle('get-menu-bar-icon-state', () => { try { const settings = getSettings(); return settings.showMenuBarIcon ?? true; } catch (error) { console.error('Error getting menu bar icon state:', error); return true; } }); // Handle dock icon visibility (macOS only) ipcMain.handle('set-dock-icon', async (_event, show: boolean) => { if (process.platform !== 'darwin') return false; const settings = getSettings(); updateSettings((s) => { s.showDockIcon = show; }); if (show) { app.dock?.show(); } else { // Only hide the dock if we have a menu bar icon to maintain accessibility if (settings.showMenuBarIcon) { app.dock?.hide(); setTimeout(() => { focusWindow(); }, 50); } } return true; }); ipcMain.handle('get-dock-icon-state', () => { try { if (process.platform !== 'darwin') return true; const settings = getSettings(); return settings.showDockIcon ?? true; } catch (error) { console.error('Error getting dock icon state:', error); return true; } }); // Handle opening system notifications preferences ipcMain.handle('open-notifications-settings', async () => { try { if (process.platform === 'darwin') { spawn('open', ['x-apple.systempreferences:com.apple.preference.notifications']); return true; } else if (process.platform === 'win32') { // Windows: Open notification settings in Settings app spawn('ms-settings:notifications', { shell: true }); return true; } else if (process.platform === 'linux') { // Linux: Try different desktop environments function canSpawn(cmd: string): boolean { try { execFileSync('which', [cmd], { stdio: 'ignore' }); return true; } catch { return false; } } // GNOME if (canSpawn('gnome-control-center')) { spawn('gnome-control-center', ['notifications']); return true; } // KDE Plasma if (canSpawn('systemsettings5')) { spawn('systemsettings5', ['kcm_notifications']); return true; } // XFCE if (canSpawn('xfce4-settings-manager')) { spawn('xfce4-settings-manager', ['--socket-id=notifications']); return true; } console.warn('Could not find a suitable settings application for Linux'); return false; } else { console.warn( `Opening notification settings is not supported on platform: ${process.platform}` ); return false; } } catch (error) { console.error('Error opening notification settings:', error); return false; } }); // Handle wakelock setting ipcMain.handle('set-wakelock', async (_event, enable: boolean) => { updateSettings((s) => { s.enableWakelock = enable; }); // Stop all existing power save blockers when disabling the setting if (!enable) { for (const [windowId, blockerId] of windowPowerSaveBlockers.entries()) { try { powerSaveBlocker.stop(blockerId); console.log( `[Main] Stopped power save blocker ${blockerId} for window ${windowId} due to wakelock setting disabled` ); } catch (error) { console.error( `[Main] Failed to stop power save blocker ${blockerId} for window ${windowId}:`, error ); } } windowPowerSaveBlockers.clear(); } return true; }); ipcMain.handle('get-wakelock-state', () => { try { const settings = getSettings(); return settings.enableWakelock ?? false; } catch (error) { console.error('Error getting wakelock state:', error); return false; } }); ipcMain.handle('set-spellcheck', async (_event, enable: boolean) => { updateSettings((s) => { s.spellcheckEnabled = enable; }); return true; }); ipcMain.handle('get-spellcheck-state', () => { try { const settings = getSettings(); return settings.spellcheckEnabled ?? true; } catch (error) { console.error('Error getting spellcheck state:', error); return true; } }); ipcMain.handle('is-any-window-focused', () => { return BrowserWindow.getFocusedWindow() !== null; }); ipcMain.handle('get-is-fullscreen', (event) => { const win = BrowserWindow.fromWebContents(event.sender); return win?.isFullScreen() ?? false; }); // Add file/directory selection handler ipcMain.handle('select-file-or-directory', async (_event, defaultPath?: string) => { const dialogOptions: OpenDialogOptions = { properties: process.platform === 'darwin' ? ['openFile', 'openDirectory'] : ['openFile'], }; // Set default path if provided if (defaultPath) { // Expand tilde to home directory const expandedPath = expandTilde(defaultPath); // Check if the path exists try { const stats = await fs.stat(expandedPath); if (stats.isDirectory()) { dialogOptions.defaultPath = expandedPath; } else { dialogOptions.defaultPath = path.dirname(expandedPath); } // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (error) { // If path doesn't exist, fall back to home directory and log error console.error(`Default path does not exist: ${expandedPath}, falling back to home directory`); dialogOptions.defaultPath = os.homedir(); } } const result = (await dialog.showOpenDialog(dialogOptions)) as unknown as OpenDialogReturnValue; if (!result.canceled && result.filePaths.length > 0) { return result.filePaths[0]; } return null; }); // Native picker tailored for session imports: shows hidden files (so users can // reach `~/.claude/projects/...` or `~/.pi/agent/sessions/...`), filters for // .json/.jsonl, and returns the file's contents inline so the renderer doesn't // need a separate read step. ipcMain.handle('select-import-session-file', async () => { const result = (await dialog.showOpenDialog({ title: 'Import session', defaultPath: os.homedir(), properties: ['openFile', 'showHiddenFiles'], filters: [ { name: 'Session files', extensions: ['json', 'jsonl'] }, { name: 'All files', extensions: ['*'] }, ], })) as unknown as OpenDialogReturnValue; if (result.canceled || result.filePaths.length === 0) { return null; } const filePath = result.filePaths[0]; try { const contents = await fs.readFile(filePath, 'utf8'); return { filePath, contents }; } catch (err) { return { filePath, contents: '', error: errorMessage(err) }; } }); ipcMain.handle('check-ollama', async () => { try { return new Promise((resolve) => { // Run `ps` and filter for "ollama" const ps = spawn('ps', ['aux']); const grep = spawn('grep', ['-iw', '[o]llama']); let output = ''; let errorOutput = ''; // Pipe ps output to grep ps.stdout.pipe(grep.stdin); grep.stdout.on('data', (data) => { output += data.toString(); }); grep.stderr.on('data', (data) => { errorOutput += data.toString(); }); grep.on('close', (code) => { if (code !== null && code !== 0 && code !== 1) { // grep returns 1 when no matches found console.error('Error executing grep command:', errorOutput); return resolve(false); } const trimmedOutput = output.trim(); const isRunning = trimmedOutput.length > 0; resolve(isRunning); }); ps.on('error', (error) => { console.error('Error executing ps command:', error); resolve(false); }); grep.on('error', (error) => { console.error('Error executing grep command:', error); resolve(false); }); // Close ps stdin when done ps.stdout.on('end', () => { grep.stdin.end(); }); }); } catch (err) { console.error('Error checking for Ollama:', err); return false; } }); ipcMain.handle('read-file', async (_event, filePath) => { try { const expandedPath = expandTilde(filePath); if (process.platform === 'win32') { const buffer = await fs.readFile(expandedPath); return { file: buffer.toString('utf8'), filePath: expandedPath, error: null, found: true }; } // Non-Windows: keep previous behavior via cat for parity return await new Promise((resolve) => { const cat = spawn('cat', [expandedPath]); let output = ''; let errorOutput = ''; cat.stdout.on('data', (data) => { output += data.toString(); }); cat.stderr.on('data', (data) => { errorOutput += data.toString(); }); cat.on('close', (code) => { if (code !== 0) { resolve({ file: '', filePath: expandedPath, error: errorOutput || null, found: false }); return; } resolve({ file: output, filePath: expandedPath, error: null, found: true }); }); cat.on('error', (error) => { console.error('Error reading file:', error); resolve({ file: '', filePath: expandedPath, error, found: false }); }); }); } catch (error) { console.error('Error reading file:', error); return { file: '', filePath: expandTilde(filePath), error, found: false }; } }); ipcMain.handle('write-file', async (_event, filePath, content) => { try { // Expand tilde to home directory const expandedPath = expandTilde(filePath); await fs.writeFile(expandedPath, content, { encoding: 'utf8' }); return true; } catch (error) { console.error('Error writing to file:', error); return false; } }); // Enhanced file operations ipcMain.handle('ensure-directory', async (_event, dirPath) => { try { // Expand tilde to home directory const expandedPath = expandTilde(dirPath); await fs.mkdir(expandedPath, { recursive: true }); return true; } catch (error) { console.error('Error creating directory:', error); return false; } }); ipcMain.handle('list-files', async (_event, dirPath, extension) => { try { // Expand tilde to home directory const expandedPath = expandTilde(dirPath); const files = await fs.readdir(expandedPath); if (extension) { return files.filter((file) => file.endsWith(extension)); } return files; } catch (error) { console.error('Error listing files:', error); return []; } }); ipcMain.handle('show-message-box', async (_event, options) => { return dialog.showMessageBox(options); }); ipcMain.handle('show-save-dialog', async (_event, options) => { return dialog.showSaveDialog(options); }); ipcMain.handle('get-allowed-extensions', async () => { return await getAllowList(); }); const createNewWindow = async (app: App, dir?: string | null) => { const recentDirs = loadRecentDirs(); const openDir = dir || (recentDirs.length > 0 ? recentDirs[0] : undefined); return await createChat(app, { dir: openDir }); }; const focusWindow = () => { const windows = BrowserWindow.getAllWindows(); if (windows.length > 0) { windows.forEach((win) => { win.show(); }); windows[windows.length - 1].webContents.send('focus-input'); } else { createNewWindow(app); } }; const registerGlobalShortcuts = () => { globalShortcut.unregisterAll(); const settings = getSettings(); const shortcuts = getKeyboardShortcuts(settings); if (shortcuts.focusWindow) { try { globalShortcut.register(shortcuts.focusWindow, () => { focusWindow(); }); } catch (e) { console.error('Error registering focus window hotkey:', e); } } if (shortcuts.quickLauncher) { try { globalShortcut.register(shortcuts.quickLauncher, () => { createLauncher(); }); } catch (e) { console.error('Error registering launcher hotkey:', e); } } }; async function appMain() { powerMonitor.on('resume', () => { for (const window of BrowserWindow.getAllWindows()) { if (!window.isDestroyed()) { window.webContents.send('system-resume'); } } }); await configureProxy(); // Ensure Windows shims are available before any MCP processes are spawned await ensureWinShims(); registerUpdateIpcHandlers(); // Handle microphone permission requests session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => { console.log('Permission requested:', permission); // Allow microphone and media access if (permission === 'media') { callback(true); } else { // Default behavior for other permissions callback(true); } }); // Add CSP headers to all sessions, recomputed on every response so external // backend settings take effect without restarting the app. session.defaultSession.webRequest.onHeadersReceived((details, callback) => { const currentSettings = getSettings(); callback({ responseHeaders: { ...details.responseHeaders, 'Content-Security-Policy': buildCSP(getExternalBackendForCsp(currentSettings)), }, }); }); // Migrate old settings format if needed (one-time migration) const settings = getSettings(); if (!settings.keyboardShortcuts && settings.globalShortcut !== undefined) { updateSettings((s) => { s.keyboardShortcuts = getKeyboardShortcuts(s); delete s.globalShortcut; }); } // Register global shortcuts based on settings registerGlobalShortcuts(); session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => { details.requestHeaders['Origin'] = 'http://localhost:5173'; callback({ cancel: false, requestHeaders: details.requestHeaders }); }); if (settings.showMenuBarIcon) { createTray(); } if (process.platform === 'darwin' && !settings.showDockIcon && settings.showMenuBarIcon) { app.dock?.hide(); } const { dirPath } = parseArgs(); if (!openUrlHandledLaunch) { await createNewWindow(app, dirPath); } else { log.info('[Main] Skipping window creation in appMain - open-url already handled launch'); } // Setup auto-updater AFTER window is created and displayed (with delay to avoid blocking) setTimeout(() => { if (shouldSetupUpdater()) { log.info('Setting up auto-updater after window creation...'); try { const settings = getSettings(); if (settings.disableAutoDownload) { setAutoDownloadDisabled(true); } setupAutoUpdater(); } catch (error) { log.error('Error setting up auto-updater:', error); } } }, 2000); if (process.platform === 'darwin') { const dockMenu = Menu.buildFromTemplate([ { label: menuT('New Window'), click: () => { createNewWindow(app); }, }, ]); app.dock?.setMenu(dockMenu); } const menu = Menu.getApplicationMenu(); const shortcuts = getKeyboardShortcuts(settings); const appMenu = menu?.items.find((item) => item.label === 'Goose'); if (appMenu?.submenu) { appMenu.submenu.insert(1, new MenuItem({ type: 'separator' })); if (shortcuts.settings) { appMenu.submenu.insert( 1, new MenuItem({ label: menuT('Settings'), accelerator: shortcuts.settings, click() { const focusedWindow = BrowserWindow.getFocusedWindow(); if (focusedWindow) focusedWindow.webContents.send('set-view', 'settings'); }, }) ); } appMenu.submenu.insert(1, new MenuItem({ type: 'separator' })); } const editMenu = menu?.items.find((item) => item.label === 'Edit'); if (editMenu?.submenu) { const selectAllIndex = editMenu.submenu.items.findIndex((item) => item.label === 'Select All'); const findSubmenu = Menu.buildFromTemplate([ { label: menuT('Find…'), accelerator: shortcuts.find || undefined, click() { const focusedWindow = BrowserWindow.getFocusedWindow(); if (focusedWindow) focusedWindow.webContents.send('find-command'); }, }, { label: menuT('Find Next'), accelerator: shortcuts.findNext || undefined, click() { const focusedWindow = BrowserWindow.getFocusedWindow(); if (focusedWindow) focusedWindow.webContents.send('find-next'); }, }, { label: menuT('Find Previous'), accelerator: shortcuts.findPrevious || undefined, click() { const focusedWindow = BrowserWindow.getFocusedWindow(); if (focusedWindow) focusedWindow.webContents.send('find-previous'); }, }, { label: menuT('Use Selection for Find'), accelerator: process.platform === 'darwin' ? 'Command+E' : undefined, click() { const focusedWindow = BrowserWindow.getFocusedWindow(); if (focusedWindow) focusedWindow.webContents.send('use-selection-find'); }, visible: process.platform === 'darwin', // Only show on Mac }, ]); editMenu.submenu.insert( selectAllIndex + 1, new MenuItem({ label: menuT('Find'), submenu: findSubmenu, }) ); } const fileMenu = menu?.items.find((item) => item.label === 'File'); if (fileMenu?.submenu) { // Use a counter to track the actual insertion index let menuIndex = 0; if (shortcuts.newChat) { fileMenu.submenu.insert( menuIndex++, new MenuItem({ label: menuT('New Chat'), accelerator: shortcuts.newChat, click() { const focusedWindow = BrowserWindow.getFocusedWindow(); if (focusedWindow) focusedWindow.webContents.send('set-view', ''); }, }) ); } if (shortcuts.newChatWindow) { fileMenu.submenu.insert( menuIndex++, new MenuItem({ label: menuT('New Chat Window'), accelerator: shortcuts.newChatWindow, click() { ipcMain.emit('create-chat-window'); }, }) ); } if (shortcuts.openDirectory) { fileMenu.submenu.insert( menuIndex++, new MenuItem({ label: menuT('Open Directory...'), accelerator: shortcuts.openDirectory, click: () => openDirectoryDialog(), }) ); } const recentFilesSubmenu = buildRecentFilesMenu(); if (recentFilesSubmenu.length > 0) { fileMenu.submenu.insert( menuIndex++, new MenuItem({ label: menuT('Recent Directories'), submenu: recentFilesSubmenu, }) ); } fileMenu.submenu.insert(menuIndex++, new MenuItem({ type: 'separator' })); if (shortcuts.focusWindow) { fileMenu.submenu.append( new MenuItem({ label: menuT('Focus Goose Window'), accelerator: shortcuts.focusWindow, click() { focusWindow(); }, }) ); } if (shortcuts.quickLauncher) { fileMenu.submenu.append( new MenuItem({ label: menuT('Quick Launcher'), accelerator: shortcuts.quickLauncher, click() { createLauncher(); }, }) ); } } if (menu) { let windowMenu = menu.items.find((item) => item.label === 'Window'); if (!windowMenu) { windowMenu = new MenuItem({ label: menuT('Window'), submenu: Menu.buildFromTemplate([]), }); const helpMenuIndex = menu.items.findIndex((item) => item.label === 'Help'); if (helpMenuIndex >= 0) { menu.items.splice(helpMenuIndex, 0, windowMenu); } else { menu.items.push(windowMenu); } } if (windowMenu.submenu) { if (shortcuts.alwaysOnTop) { windowMenu.submenu.append( new MenuItem({ label: menuT('Always on Top'), type: 'checkbox', accelerator: shortcuts.alwaysOnTop, click(menuItem) { const focusedWindow = BrowserWindow.getFocusedWindow(); if (focusedWindow) { const isAlwaysOnTop = menuItem.checked; if (process.platform === 'darwin') { focusedWindow.setAlwaysOnTop(isAlwaysOnTop, 'floating'); } else { focusedWindow.setAlwaysOnTop(isAlwaysOnTop); } console.log( `[Main] Set always-on-top to ${isAlwaysOnTop} for window ${focusedWindow.id}` ); } }, }) ); } } const viewMenu = menu.items.find((item) => item.label === 'View'); if (viewMenu?.submenu && shortcuts.toggleNavigation) { viewMenu.submenu.append(new MenuItem({ type: 'separator' })); viewMenu.submenu.append( new MenuItem({ label: menuT('Toggle Navigation'), accelerator: shortcuts.toggleNavigation, click() { const focusedWindow = BrowserWindow.getFocusedWindow(); if (focusedWindow) { focusedWindow.webContents.send('toggle-navigation'); } }, }) ); } } // on macOS, the topbar is hidden if (menu && process.platform !== 'darwin') { let helpMenu = menu.items.find((item) => item.label === 'Help'); // If Help menu doesn't exist, create it and add it to the menu if (!helpMenu) { helpMenu = new MenuItem({ label: menuT('Help'), submenu: Menu.buildFromTemplate([]), // Start with an empty submenu }); // Find a reasonable place to insert the Help menu, usually near the end const insertIndex = menu.items.length > 0 ? menu.items.length - 1 : 0; menu.items.splice(insertIndex, 0, helpMenu); } // Ensure the Help menu has a submenu before appending if (helpMenu.submenu) { // Add a separator before the About item if the submenu is not empty if (helpMenu.submenu.items.length > 0) { helpMenu.submenu.append(new MenuItem({ type: 'separator' })); } // Create the About Goose menu item with a submenu const aboutGooseMenuItem = new MenuItem({ label: menuT('About Goose'), submenu: Menu.buildFromTemplate([]), // Start with an empty submenu for About }); // Add the Version menu item (display only) to the About Goose submenu if (aboutGooseMenuItem.submenu) { aboutGooseMenuItem.submenu.append( new MenuItem({ label: `Version ${version || app.getVersion()}`, enabled: false, }) ); } helpMenu.submenu.append(aboutGooseMenuItem); } } if (menu) { // Translate labels (including Electron's default top-level entries // File/Edit/View/Window/Help and submenu items populated by roles) before // installing the menu. Called last so the lookups above that match on the // English labels still succeed. translateMenuLabels(menu.items); Menu.setApplicationMenu(menu); } app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createNewWindow(app); } }); ipcMain.on('create-chat-window', (event, options = {}) => { const { query, dir, resumeSessionId, viewType, recipeId } = options; let resolvedDir = dir; if (!resolvedDir?.trim()) { const recentDirs = loadRecentDirs(); resolvedDir = recentDirs.length > 0 ? recentDirs[0] : undefined; } const isFromLauncher = query && !resumeSessionId && !viewType && !recipeId; if (isFromLauncher) { const senderWindow = BrowserWindow.fromWebContents(event.sender); const launcherWindowId = senderWindow?.id; const allWindows = BrowserWindow.getAllWindows(); const existingWindows = allWindows.filter( (win) => !win.isDestroyed() && win.id !== launcherWindowId ); if (existingWindows.length > 0) { const targetWindow = existingWindows[0]; targetWindow.show(); targetWindow.focus(); targetWindow.webContents.send('set-initial-message', query); return; } } createChat(app, { initialMessage: query, dir: resolvedDir, resumeSessionId, viewType, recipeId, }); }); ipcMain.on('close-window', (event) => { const window = BrowserWindow.fromWebContents(event.sender); if (window && !window.isDestroyed()) { window.close(); } }); ipcMain.on('notify', (event, data) => { try { // Validate notification data if (!data || typeof data !== 'object') { console.error('Invalid notification data'); return; } // Validate title and body if (typeof data.title !== 'string' || typeof data.body !== 'string') { console.error('Invalid notification title or body'); return; } // Limit the length of title and body const MAX_LENGTH = 1000; if (data.title.length > MAX_LENGTH || data.body.length > MAX_LENGTH) { console.error('Notification title or body too long'); return; } // Remove any HTML tags for security const sanitizeText = (text: string) => text.replace(/<[^>]*>/g, ''); const notification = new Notification({ title: sanitizeText(data.title), body: sanitizeText(data.body), }); // Add click handler to focus the window notification.on('click', () => { const window = BrowserWindow.fromWebContents(event.sender); if (window) { if (window.isMinimized()) { window.restore(); } window.show(); window.focus(); } }); notification.show(); } catch (error) { console.error('Error showing notification:', error); } }); ipcMain.on('logInfo', (_event, info) => { try { // Validate log info if (info === undefined || info === null) { console.error('Invalid log info: undefined or null'); return; } // Convert to string if not already const logMessage = String(info); // Limit log message length const MAX_LENGTH = 10000; // 10KB limit if (logMessage.length > MAX_LENGTH) { console.error('Log message too long'); return; } // Log the sanitized message log.info('from renderer:', logMessage); } catch (error) { console.error('Error logging info:', error); } }); ipcMain.on('broadcast-theme-change', (event, themeData) => { const senderWindow = BrowserWindow.fromWebContents(event.sender); const allWindows = BrowserWindow.getAllWindows(); allWindows.forEach((window) => { if (window.id !== senderWindow?.id) { window.webContents.send('theme-changed', themeData); } }); }); ipcMain.on('reload-app', (event) => { // Get the window that sent the event const window = BrowserWindow.fromWebContents(event.sender); if (window) { window.reload(); } }); ipcMain.on('open-in-chrome', (_event, url) => { try { // Validate URL const parsedUrl = new URL(url); // Only allow http and https protocols for browser URLs if (!WEB_PROTOCOLS.includes(parsedUrl.protocol)) { console.error('Invalid URL protocol. Only HTTP and HTTPS are allowed.'); return; } // On macOS, use the 'open' command with Chrome if (process.platform === 'darwin') { spawn('open', ['-a', 'Google Chrome', url]); } else if (process.platform === 'win32') { // On Windows, start is built-in command of cmd.exe spawn('cmd.exe', ['/c', 'start', '', 'chrome', url]); } else { // On Linux, use xdg-open with chrome spawn('xdg-open', [url]); } } catch (error) { console.error('Error opening URL in browser:', error); } }); // Handle app restart ipcMain.on('restart-app', () => { app.relaunch(); app.exit(0); }); // Handler for getting app version ipcMain.on('get-app-version', (event) => { event.returnValue = app.getVersion(); }); ipcMain.on('get-app-locale', (event) => { event.returnValue = getConfiguredGooseLocale(); }); ipcMain.handle('open-directory-in-explorer', async (_event, path: string) => { try { return !!(await shell.openPath(path)); } catch (error) { console.error('Error opening directory in explorer:', error); return false; } }); ipcMain.handle('launch-app', async (event, gooseApp: GooseApp) => { try { if (isRetiredGooseChatApp(gooseApp)) { throw new Error('This built-in Chat app is no longer supported.'); } const launchingWindow = BrowserWindow.fromWebContents(event.sender); if (!launchingWindow) { throw new Error('Could not find launching window'); } const launchingWindowId = launchingWindow.id; const launchingGooseServeLease = gooseServeLeases.get(launchingWindowId); if (!launchingGooseServeLease) { throw new Error('No backend lease found for launching window'); } const launchingWorkingDir = await launchingWindow.webContents .executeJavaScript(`window.appConfig ? window.appConfig.get('GOOSE_WORKING_DIR') : null`) .catch((error) => { console.warn('Failed to get working directory from launching window:', error); return undefined; }); const workingDir = resolveWorkingDir( typeof launchingWorkingDir === 'string' ? launchingWorkingDir : undefined, undefined, app.getPath('home') ); const appWindow = new BrowserWindow({ title: formatAppName(gooseApp.name), width: gooseApp.width ?? 800, height: gooseApp.height ?? 600, resizable: gooseApp.resizable ?? true, useContentSize: true, webPreferences: { preload: path.join(__dirname, 'preload.js'), nodeIntegration: false, contextIsolation: true, webSecurity: true, additionalArguments: [ JSON.stringify({ ...appConfig, GOOSE_LOCALE: getConfiguredGooseLocale(), GOOSE_WORKING_DIR: workingDir, GOOSE_VERSION: version, }), ], partition: 'persist:goose', }, }); gooseServeLeases.attachWindow(appWindow.id, launchingGooseServeLease); appWindows.set(gooseApp.name, appWindow); appWindow.on('closed', () => { void gooseServeLeases.releaseWindow(appWindow.id); appWindows.delete(gooseApp.name); }); const extensionName = gooseApp.mcpServers?.[0] ?? ''; const url = getAppUrl(); const searchParams = new URLSearchParams(); searchParams.set('resourceUri', gooseApp.uri); searchParams.set('extensionName', extensionName); searchParams.set('appName', gooseApp.name); searchParams.set('workingDir', workingDir); url.hash = `/standalone-app?${searchParams.toString()}`; await appWindow.loadURL(formatUrl(url)); appWindow.show(); } catch (error) { console.error('Failed to launch app:', error); throw error; } }); ipcMain.handle('refresh-app', async (_event, gooseApp: GooseApp) => { try { const appWindow = appWindows.get(gooseApp.name); if (!appWindow || appWindow.isDestroyed()) { console.log(`App window for '${gooseApp.name}' not found or destroyed, skipping refresh`); return; } // Bring to front first if (appWindow.isMinimized()) { appWindow.restore(); } appWindow.show(); appWindow.focus(); // Then reload await appWindow.webContents.reload(); } catch (error) { console.error('Failed to refresh app:', error); throw error; } }); ipcMain.handle('close-app', async (_event, appName: string) => { try { const appWindow = appWindows.get(appName); if (!appWindow || appWindow.isDestroyed()) { console.log(`App window for '${appName}' not found or destroyed, skipping close`); return; } appWindow.close(); } catch (error) { console.error('Failed to close app:', error); throw error; } }); } app.whenReady().then(async () => { try { await appMain(); } catch (error) { dialog.showErrorBox('Goose Error', `Failed to create main window: ${error}`); app.quit(); } }); async function getAllowList(): Promise { if (!process.env.GOOSE_ALLOWLIST) { return []; } const response = await fetch(process.env.GOOSE_ALLOWLIST); if (!response.ok) { throw new Error( `Failed to fetch allowed extensions: ${response.status} ${response.statusText}` ); } // Parse the YAML content const yamlContent = await response.text(); const parsedYaml = yaml.parse(yamlContent); // Extract the commands from the extensions array if (parsedYaml && parsedYaml.extensions && Array.isArray(parsedYaml.extensions)) { const commands = parsedYaml.extensions.map( (ext: { id: string; command: string }) => ext.command ); console.log(`Fetched ${commands.length} allowed extension commands`); return commands; } else { console.error('Invalid YAML structure:', parsedYaml); return []; } } app.on('will-quit', async () => { const gooseServeLeaseCount = gooseServeLeases.activeLeaseCount(); if (gooseServeLeaseCount > 0) { log.info(`App quitting, cleaning up ${gooseServeLeaseCount} backend lease(s)`); await gooseServeLeases.cleanupAll(); } for (const [windowId, blockerId] of windowPowerSaveBlockers.entries()) { try { powerSaveBlocker.stop(blockerId); console.log( `[Main] Stopped power save blocker ${blockerId} for window ${windowId} during app quit` ); } catch (error) { console.error( `[Main] Failed to stop power save blocker ${blockerId} for window ${windowId}:`, error ); } } windowPowerSaveBlockers.clear(); globalShortcut.unregisterAll(); }); app.on('window-all-closed', () => { // Only quit if we're not on macOS or don't have a tray icon if (process.platform !== 'darwin' || !tray) { app.quit(); } });