diff --git a/src/ipc/config/handlers.ts b/src/ipc/config/handlers.ts index 6d485e6..1f0dcaf 100644 --- a/src/ipc/config/handlers.ts +++ b/src/ipc/config/handlers.ts @@ -1,5 +1,6 @@ import { AppConfig } from '../../types/config'; import { ConfigManager } from './manager'; +import { syncAutoStart } from '../../utils/autoStart'; export function loadConfig(): AppConfig { return ConfigManager.loadConfig(); @@ -10,4 +11,5 @@ export function saveConfig(config: AppConfig): void { // Logic to update Tray if language changes // For now just save ConfigManager.saveConfig(config); + syncAutoStart(config); } diff --git a/src/localization/i18n.ts b/src/localization/i18n.ts index dbc7f33..2b119d4 100644 --- a/src/localization/i18n.ts +++ b/src/localization/i18n.ts @@ -100,6 +100,14 @@ i18n auto_sync: 'Auto Sync Current Account', auto_sync_desc: 'Periodically sync active account information', }, + startup: { + title: 'Startup', + description: 'Control application launch behavior at system startup.', + auto_startup: 'Start with system', + auto_startup_desc: 'Launch at sign-in and keep the app in the system tray', + macos_hint: + 'macOS requires a signed app for Login Items to work. If auto-start fails, please sign the app or enable it manually in System Settings.', + }, proxy: { title: 'Upstream Proxy', description: 'Configure a proxy for outgoing requests to Google/Gemini APIs.', @@ -391,6 +399,14 @@ i18n auto_sync: '自动获取当前账号', auto_sync_desc: '定期自动同步当前活跃账号信息', }, + startup: { + title: '启动', + description: '控制系统启动时的应用行为。', + auto_startup: '随系统启动', + auto_startup_desc: '登录时自动启动并保持在托盘里', + macos_hint: + 'macOS 需要应用已签名才可生效。如自启动失败,请自行签名或在系统设置中手动启用。', + }, proxy: { title: '上游代理', description: '为发往 Google/Gemini API 的请求配置代理。', diff --git a/src/main.ts b/src/main.ts index 9cfb66b..2edea01 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,6 +18,8 @@ import { bootstrapNestServer, stopNestServer } from './server/main'; import { initTray, setTrayLanguage, destroyTray } from './ipc/tray/handler'; import { rpcHandler } from './ipc/handler'; import { ConfigManager } from './ipc/config/manager'; +import { AppConfig } from './types/config'; +import { isAutoStartLaunch, syncAutoStart } from './utils/autoStart'; const packetLogPath = path.join(app.getPath('userData'), 'orpc_packets.log'); @@ -63,6 +65,25 @@ const inDevelopment = process.env.NODE_ENV === 'development'; let globalMainWindow: BrowserWindow | null = null; // let tray: Tray | null = null; // Moved to tray/handler.ts let isQuitting = false; +let startupConfig: AppConfig | null = null; +let shouldStartHidden = false; + +const gotSingleInstanceLock = app.requestSingleInstanceLock(); + +if (!gotSingleInstanceLock) { + app.quit(); +} else { + app.on('second-instance', () => { + logger.info('Second instance detected, focusing existing window'); + if (app.isReady()) { + createWindow({ startHidden: false }); + return; + } + app.whenReady().then(() => { + createWindow({ startHidden: false }); + }); + }); +} process.on('exit', (code) => { logger.info(`Process exit event triggered with code: ${code}`); @@ -75,7 +96,22 @@ process.on('before-exit', (code) => { // let tray: Tray | null = null; // Moved to tray/handler.ts -function createWindow() { +function createWindow({ startHidden }: { startHidden: boolean }) { + if (globalMainWindow && !globalMainWindow.isDestroyed()) { + if (startHidden) { + globalMainWindow.hide(); + return; + } + if (globalMainWindow.isMinimized()) { + globalMainWindow.restore(); + } + if (!globalMainWindow.isVisible()) { + globalMainWindow.show(); + } + globalMainWindow.focus(); + return; + } + logger.info('createWindow: start'); const preload = path.join(__dirname, 'preload.js'); logger.info(`createWindow: preload path: ${preload}`); @@ -84,6 +120,7 @@ function createWindow() { const mainWindow = new BrowserWindow({ width: 1200, height: 800, + show: !startHidden, autoHideMenuBar: true, webPreferences: { devTools: inDevelopment, @@ -99,6 +136,10 @@ function createWindow() { }); globalMainWindow = mainWindow; logger.info('createWindow: BrowserWindow instance created'); + if (startHidden) { + mainWindow.hide(); + logger.info('createWindow: startHidden enabled, window hidden'); + } logger.info('createWindow: setting main window in ipcContext'); ipcContext.setMainWindow(mainWindow); @@ -126,6 +167,7 @@ function createWindow() { mainWindow.on('closed', () => { logger.info('Window closed event triggered'); + globalMainWindow = null; }); mainWindow.webContents.on('render-process-gone', (event, details) => { @@ -241,13 +283,23 @@ app logger.info('Step: Initialize Antigravity DB (WAL Mode)'); initDatabase(); }) + .then(() => { + logger.info('Step: Load Config'); + const config = ConfigManager.loadConfig(); + startupConfig = config; + syncAutoStart(config); + shouldStartHidden = isAutoStartLaunch() && config.auto_startup; + if (shouldStartHidden) { + logger.info('Startup: Auto-start detected, window will start hidden'); + } + }) .then(() => { logger.info('Step: setupORPC'); return setupORPC(); }) .then(async () => { logger.info('Step: createWindow'); - await createWindow(); + await createWindow({ startHidden: shouldStartHidden }); }) .then(() => { logger.info('Step: installExtensions (SKIPPED)'); @@ -264,7 +316,7 @@ app AuthServer.start(); // Gateway Server (NestJS) - auto-start if enabled - const config = ConfigManager.loadConfig(); + const config = startupConfig || ConfigManager.loadConfig(); if (config.proxy?.auto_start) { const port = config.proxy?.port || 8045; // Default to a valid ProxyConfig object if null, although loadConfig ensures defaults @@ -306,7 +358,7 @@ app.on('window-all-closed', () => { app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { - createWindow(); + createWindow({ startHidden: false }); } }); //osX only ends diff --git a/src/routes/settings.tsx b/src/routes/settings.tsx index ca42ee1..28d1ddb 100644 --- a/src/routes/settings.tsx +++ b/src/routes/settings.tsx @@ -47,6 +47,10 @@ function SettingsPage() { queryFn: getPlatform, }); + const isAutoStartSupported = + platform === 'win32' || platform === 'darwin' || platform === 'linux'; + const isMac = platform === 'darwin'; + const handleLanguageChange = (value: string) => { setAppLanguage(value, i18n); }; @@ -179,6 +183,49 @@ function SettingsPage() { + {isAutoStartSupported && ( + + + {t('settings.startup.title', 'Startup')} + + {t( + 'settings.startup.description', + 'Control application launch behavior at system startup.', + )} + + + +
+
+ +

+ {t( + 'settings.startup.auto_startup_desc', + 'Launch at sign-in and keep the app in the system tray', + )} +

+
+ { + if (config) { + await saveConfig({ ...config, auto_startup: checked }); + } + }} + /> +
+ {isMac && ( +

+ {t( + 'settings.startup.macos_hint', + 'macOS requires a signed app for Login Items to work. If auto-start fails, please sign the app or enable it manually in System Settings.', + )} +

+ )} +
+
+ )} + {t('settings.about.title')} diff --git a/src/types/config.ts b/src/types/config.ts index aa41f7b..e5158a1 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -22,6 +22,7 @@ export const AppConfigSchema = z.object({ refresh_interval: z.number(), // minutes auto_sync: z.boolean(), // 是否自动同步 sync_interval: z.number(), // minutes + auto_startup: z.boolean(), // 是否随系统启动 default_export_path: z.string().nullable().optional(), // 导出路径 proxy: ProxyConfigSchema, }); @@ -37,6 +38,7 @@ export const DEFAULT_APP_CONFIG: AppConfig = { refresh_interval: 15, auto_sync: false, sync_interval: 5, + auto_startup: false, default_export_path: null, proxy: { enabled: false, diff --git a/src/utils/autoStart.ts b/src/utils/autoStart.ts new file mode 100644 index 0000000..4b37fdb --- /dev/null +++ b/src/utils/autoStart.ts @@ -0,0 +1,126 @@ +import { app } from 'electron'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { logger } from './logger'; +import { AppConfig } from '../types/config'; + +export const AUTO_START_ARG = '--autostart'; +const LINUX_AUTOSTART_FILENAME = 'antigravity-manager.desktop'; + +function getLinuxAutoStartPath() { + const dir = path.join(os.homedir(), '.config', 'autostart'); + return path.join(dir, LINUX_AUTOSTART_FILENAME); +} + +export function isAutoStartLaunch(argv = process.argv) { + if (process.platform === 'linux') { + return argv.includes(AUTO_START_ARG); + } + + if (process.platform === 'win32' || process.platform === 'darwin') { + try { + const settings = app.getLoginItemSettings(); + return settings.wasOpenedAtLogin; + } catch (error) { + logger.error('AutoStart: Failed to read login item settings', error); + } + } + + return false; +} + +function syncWindowsOrMacAutoStart(enabled: boolean) { + try { + app.setLoginItemSettings({ + openAtLogin: enabled, + openAsHidden: enabled, + path: process.execPath, + args: enabled ? [AUTO_START_ARG] : [], + }); + const settings = app.getLoginItemSettings(); + if (settings.openAtLogin !== enabled) { + logger.warn( + `AutoStart: Login item mismatch (expected ${enabled}, actual ${settings.openAtLogin})`, + ); + } + logger.info(`AutoStart: Login item set to ${enabled}`); + } catch (error) { + logger.error('AutoStart: Failed to update login item settings', error); + } +} + +function cleanupLinuxAutoStartEntries(currentPath: string) { + const autoStartDir = path.dirname(currentPath); + if (!fs.existsSync(autoStartDir)) { + return; + } + + const legacyNames = [ + `${app.getName()}.desktop`, + `${app.getName().replace(/\s+/g, '')}.desktop`, + 'Antigravity Manager.desktop', + 'AntigravityManager.desktop', + ]; + + for (const name of legacyNames) { + const candidate = path.join(autoStartDir, name); + if (candidate === currentPath) { + continue; + } + if (fs.existsSync(candidate)) { + try { + fs.unlinkSync(candidate); + logger.info(`AutoStart: Removed legacy autostart entry ${candidate}`); + } catch (error) { + logger.warn('AutoStart: Failed to remove legacy autostart entry', error); + } + } + } +} + +function syncLinuxAutoStart(enabled: boolean) { + const autoStartPath = getLinuxAutoStartPath(); + + if (!enabled) { + if (fs.existsSync(autoStartPath)) { + fs.unlinkSync(autoStartPath); + logger.info('AutoStart: Removed Linux autostart entry'); + } + cleanupLinuxAutoStartEntries(autoStartPath); + return; + } + + const autoStartDir = path.dirname(autoStartPath); + if (!fs.existsSync(autoStartDir)) { + fs.mkdirSync(autoStartDir, { recursive: true }); + } + + const execPath = process.execPath.replace(/"/g, '\\"'); + const content = [ + '[Desktop Entry]', + 'Type=Application', + `Name=${app.getName()}`, + `Exec="${execPath}" ${AUTO_START_ARG}`, + 'X-GNOME-Autostart-enabled=true', + 'Hidden=false', + 'NoDisplay=false', + ].join('\n'); + + cleanupLinuxAutoStartEntries(autoStartPath); + fs.writeFileSync(autoStartPath, content, 'utf-8'); + logger.info('AutoStart: Created Linux autostart entry'); +} + +export function syncAutoStart(config: AppConfig) { + const enabled = Boolean(config.auto_startup); + + if (process.platform === 'win32' || process.platform === 'darwin') { + syncWindowsOrMacAutoStart(enabled); + return; + } + + if (process.platform === 'linux') { + syncLinuxAutoStart(enabled); + } +}