diff --git a/README.ja-JP.md b/README.ja-JP.md index 7b0483c09..bea53e387 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -122,6 +122,9 @@ ClawX はドキュメント処理スキル(`pdf`、`xlsx`、`docx`、`pptx`) ### 🌙 アダプティブテーマ ライトモード、ダークモード、またはシステム同期テーマ。ClawXはあなたの好みに自動的に適応します。 +### 🚀 自動起動設定 +**設定 → 通用** から **システム起動時に自動起動** を有効化すると、ログイン後に ClawX が自動的に起動します。 + --- ## はじめに diff --git a/README.md b/README.md index af00f2c82..c2411bdf3 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,9 @@ Connect to multiple AI providers (OpenAI, Anthropic, and more) with credentials ### 🌙 Adaptive Theming Light mode, dark mode, or system-synchronized themes. ClawX adapts to your preferences automatically. +### 🚀 Startup Launch Control +In **Settings → General**, you can enable **Launch at system startup** so ClawX starts automatically after login. + --- ## Getting Started diff --git a/README.zh-CN.md b/README.zh-CN.md index d5325edcc..e9d9a9e54 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -123,6 +123,9 @@ ClawX 还会内置预装完整的文档处理技能(`pdf`、`xlsx`、`docx`、 ### 🌙 自适应主题 支持浅色模式、深色模式或跟随系统主题。ClawX 自动适应你的偏好设置。 +### 🚀 开机启动控制 +在 **设置 → 通用** 中,你可以开启 **开机自动启动**,让 ClawX 在系统登录后自动启动。 + --- ## 快速上手 diff --git a/electron/api/routes/settings.ts b/electron/api/routes/settings.ts index b6be41aa2..ddb552188 100644 --- a/electron/api/routes/settings.ts +++ b/electron/api/routes/settings.ts @@ -1,5 +1,6 @@ import type { IncomingMessage, ServerResponse } from 'http'; import { applyProxySettings } from '../../main/proxy'; +import { syncLaunchAtStartupSettingFromStore } from '../../main/launch-at-startup'; import { getAllSettings, getSetting, resetSettings, setSetting, type AppSettings } from '../../utils/store'; import type { HostApiContext } from '../context'; import { parseJsonBody, sendJson } from '../route-utils'; @@ -23,6 +24,10 @@ function patchTouchesProxy(patch: Partial): boolean { )); } +function patchTouchesLaunchAtStartup(patch: Partial): boolean { + return Object.prototype.hasOwnProperty.call(patch, 'launchAtStartup'); +} + export async function handleSettingsRoutes( req: IncomingMessage, res: ServerResponse, @@ -44,6 +49,9 @@ export async function handleSettingsRoutes( if (patchTouchesProxy(patch)) { await handleProxySettingsChange(ctx); } + if (patchTouchesLaunchAtStartup(patch)) { + await syncLaunchAtStartupSettingFromStore(); + } sendJson(res, 200, { success: true }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); @@ -76,6 +84,9 @@ export async function handleSettingsRoutes( ) { await handleProxySettingsChange(ctx); } + if (key === 'launchAtStartup') { + await syncLaunchAtStartupSettingFromStore(); + } sendJson(res, 200, { success: true }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); @@ -87,6 +98,7 @@ export async function handleSettingsRoutes( try { await resetSettings(); await handleProxySettingsChange(ctx); + await syncLaunchAtStartupSettingFromStore(); sendJson(res, 200, { success: true, settings: await getAllSettings() }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); diff --git a/electron/main/index.ts b/electron/main/index.ts index 8aa1352e1..194bbfc4c 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -20,6 +20,7 @@ import { ensureClawXContext, repairClawXOnlyBootstrapFiles } from '../utils/open import { autoInstallCliIfNeeded, generateCompletionCache, installCompletionToProfile } from '../utils/openclaw-cli'; import { isQuitting, setQuitting } from './app-state'; import { applyProxySettings } from './proxy'; +import { syncLaunchAtStartupSettingFromStore } from './launch-at-startup'; import { getSetting } from '../utils/store'; import { ensureBuiltinSkillsInstalled, ensurePreinstalledSkillsInstalled } from '../utils/skill-config'; import { startHostApiServer } from '../api/server'; @@ -162,6 +163,7 @@ async function initialize(): Promise { // Apply persisted proxy settings before creating windows or network requests. await applyProxySettings(); + await syncLaunchAtStartupSettingFromStore(); // Set application menu createMenu(); diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index de6a31a53..97fbce4a5 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -37,6 +37,7 @@ import { getProviderConfig } from '../utils/provider-registry'; import { deviceOAuthManager, OAuthProviderType } from '../utils/device-oauth'; import { browserOAuthManager, type BrowserOAuthProviderType } from '../utils/browser-oauth'; import { applyProxySettings } from './proxy'; +import { syncLaunchAtStartupSettingFromStore } from './launch-at-startup'; import { proxyAwareFetch } from '../utils/proxy-fetch'; import { getRecentTokenUsageHistory } from '../utils/token-usage'; import { getProviderService } from '../services/providers/provider-service'; @@ -224,6 +225,10 @@ function isProxyKey(key: keyof AppSettings): boolean { ); } +function isLaunchAtStartupKey(key: keyof AppSettings): boolean { + return key === 'launchAtStartup'; +} + function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { const providerService = getProviderService(); const handleProxySettingsChange = async () => { @@ -694,6 +699,9 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { if (isProxyKey(key)) { await handleProxySettingsChange(); } + if (isLaunchAtStartupKey(key)) { + await syncLaunchAtStartupSettingFromStore(); + } data = { success: true }; break; } @@ -706,6 +714,9 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { if (entries.some(([key]) => isProxyKey(key))) { await handleProxySettingsChange(); } + if (entries.some(([key]) => isLaunchAtStartupKey(key))) { + await syncLaunchAtStartupSettingFromStore(); + } data = { success: true }; break; } @@ -713,6 +724,7 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { await resetSettings(); const settings = await getAllSettings(); await handleProxySettingsChange(); + await syncLaunchAtStartupSettingFromStore(); data = { success: true, settings }; break; } @@ -2239,6 +2251,9 @@ function registerSettingsHandlers(gatewayManager: GatewayManager): void { ) { await handleProxySettingsChange(); } + if (key === 'launchAtStartup') { + await syncLaunchAtStartupSettingFromStore(); + } return { success: true }; }); @@ -2259,6 +2274,9 @@ function registerSettingsHandlers(gatewayManager: GatewayManager): void { )) { await handleProxySettingsChange(); } + if (entries.some(([key]) => key === 'launchAtStartup')) { + await syncLaunchAtStartupSettingFromStore(); + } return { success: true }; }); @@ -2267,6 +2285,7 @@ function registerSettingsHandlers(gatewayManager: GatewayManager): void { await resetSettings(); const settings = await getAllSettings(); await handleProxySettingsChange(); + await syncLaunchAtStartupSettingFromStore(); return { success: true, settings }; }); } diff --git a/electron/main/launch-at-startup.ts b/electron/main/launch-at-startup.ts new file mode 100644 index 000000000..bccc68599 --- /dev/null +++ b/electron/main/launch-at-startup.ts @@ -0,0 +1,85 @@ +import { app } from 'electron'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { logger } from '../utils/logger'; +import { getSetting } from '../utils/store'; + +const LINUX_AUTOSTART_FILE = join('.config', 'autostart', 'clawx.desktop'); + +function quoteDesktopArg(value: string): string { + if (!value) return '""'; + const escaped = value.replace(/(["\\`$])/g, '\\$1'); + if (/[\s"'\\`$]/.test(value)) { + return `"${escaped}"`; + } + return value; +} + +function getLinuxExecCommand(): string { + if (app.isPackaged) { + return quoteDesktopArg(process.execPath); + } + + const launchArgs = process.argv.slice(1).filter(Boolean); + const cmdParts = [process.execPath, ...launchArgs].map(quoteDesktopArg); + return cmdParts.join(' '); +} + +function getLinuxDesktopEntry(): string { + return [ + '[Desktop Entry]', + 'Type=Application', + 'Version=1.0', + 'Name=ClawX', + 'Comment=ClawX - AI Assistant', + `Exec=${getLinuxExecCommand()}`, + 'Terminal=false', + 'Categories=Utility;', + 'X-GNOME-Autostart-enabled=true', + '', + ].join('\n'); +} + +async function applyLinuxLaunchAtStartup(enabled: boolean): Promise { + const targetPath = join(app.getPath('home'), LINUX_AUTOSTART_FILE); + if (enabled) { + await mkdir(dirname(targetPath), { recursive: true }); + await writeFile(targetPath, getLinuxDesktopEntry(), 'utf8'); + logger.info(`Launch-at-startup enabled via desktop entry: ${targetPath}`); + return; + } + + await rm(targetPath, { force: true }); + logger.info(`Launch-at-startup disabled and desktop entry removed: ${targetPath}`); +} + +function applyWindowsOrMacLaunchAtStartup(enabled: boolean): void { + app.setLoginItemSettings({ + openAtLogin: enabled, + openAsHidden: false, + }); + logger.info(`Launch-at-startup ${enabled ? 'enabled' : 'disabled'} via login items`); +} + +export async function applyLaunchAtStartupSetting(enabled: boolean): Promise { + try { + if (process.platform === 'linux') { + await applyLinuxLaunchAtStartup(enabled); + return; + } + + if (process.platform === 'win32' || process.platform === 'darwin') { + applyWindowsOrMacLaunchAtStartup(enabled); + return; + } + + logger.warn(`Launch-at-startup unsupported on platform: ${process.platform}`); + } catch (error) { + logger.error(`Failed to apply launch-at-startup=${enabled}:`, error); + } +} + +export async function syncLaunchAtStartupSettingFromStore(): Promise { + const launchAtStartup = await getSetting('launchAtStartup'); + await applyLaunchAtStartupSetting(Boolean(launchAtStartup)); +} diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 7e2ca27e2..d376cd63b 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -2,13 +2,15 @@ "title": "Settings", "subtitle": "Configure your ClawX experience", "appearance": { - "title": "Appearance", + "title": "General", "description": "Customize the look and feel", "theme": "Theme", "light": "Light", "dark": "Dark", "system": "System", - "language": "Language" + "language": "Language", + "launchAtStartup": "Launch at system startup", + "launchAtStartupDesc": "Automatically launch ClawX when you log in" }, "aiProviders": { "title": "AI Providers", diff --git a/src/i18n/locales/ja/settings.json b/src/i18n/locales/ja/settings.json index 4ea25c863..e2a5cdeff 100644 --- a/src/i18n/locales/ja/settings.json +++ b/src/i18n/locales/ja/settings.json @@ -2,13 +2,15 @@ "title": "設定", "subtitle": "ClawX の体験をカスタマイズ", "appearance": { - "title": "外観", + "title": "通用", "description": "外観とスタイルをカスタマイズ", "theme": "テーマ", "light": "ライト", "dark": "ダーク", "system": "システム", - "language": "言語" + "language": "言語", + "launchAtStartup": "システム起動時に自動起動", + "launchAtStartupDesc": "ログイン時に ClawX を自動的に起動します" }, "aiProviders": { "title": "AI プロバイダー", diff --git a/src/i18n/locales/zh/settings.json b/src/i18n/locales/zh/settings.json index a66586df3..6648e5a89 100644 --- a/src/i18n/locales/zh/settings.json +++ b/src/i18n/locales/zh/settings.json @@ -2,13 +2,15 @@ "title": "设置", "subtitle": "配置您的 ClawX 体验", "appearance": { - "title": "外观", + "title": "通用", "description": "自定义外观和风格", "theme": "主题", "light": "浅色", "dark": "深色", "system": "跟随系统", - "language": "语言" + "language": "语言", + "launchAtStartup": "开机自动启动", + "launchAtStartupDesc": "登录系统后自动启动 ClawX" }, "aiProviders": { "title": "AI 模型提供商", diff --git a/src/pages/Settings/index.tsx b/src/pages/Settings/index.tsx index 27812be51..3dc01be56 100644 --- a/src/pages/Settings/index.tsx +++ b/src/pages/Settings/index.tsx @@ -53,6 +53,8 @@ export function Settings() { setTheme, language, setLanguage, + launchAtStartup, + setLaunchAtStartup, gatewayAutoStart, setGatewayAutoStart, proxyEnabled, @@ -435,6 +437,18 @@ export function Settings() { ))} +
+
+ +

+ {t('appearance.launchAtStartupDesc')} +

+
+ +
diff --git a/src/stores/settings.ts b/src/stores/settings.ts index b2b4a639d..3a186d61f 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -119,7 +119,13 @@ export const useSettingsStore = create()( }).catch(() => { }); }, setStartMinimized: (startMinimized) => set({ startMinimized }), - setLaunchAtStartup: (launchAtStartup) => set({ launchAtStartup }), + setLaunchAtStartup: (launchAtStartup) => { + set({ launchAtStartup }); + void hostApiFetch('/api/settings/launchAtStartup', { + method: 'PUT', + body: JSON.stringify({ value: launchAtStartup }), + }).catch(() => { }); + }, setTelemetryEnabled: (telemetryEnabled) => { set({ telemetryEnabled }); void hostApiFetch('/api/settings/telemetryEnabled', { diff --git a/tests/unit/launch-at-startup.test.ts b/tests/unit/launch-at-startup.test.ts new file mode 100644 index 000000000..432c7b424 --- /dev/null +++ b/tests/unit/launch-at-startup.test.ts @@ -0,0 +1,91 @@ +import { access, readFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const originalPlatform = process.platform; + +const { + testHome, + electronAppMock, + setLoginItemSettingsMock, +} = vi.hoisted(() => { + const suffix = Math.random().toString(36).slice(2); + const setLoginItemSettingsMock = vi.fn(); + const electronAppMock = { + isPackaged: true, + getPath: (name: string) => (name === 'home' ? `/tmp/clawx-launch-startup-${suffix}` : '/tmp'), + setLoginItemSettings: setLoginItemSettingsMock, + }; + + return { + testHome: `/tmp/clawx-launch-startup-${suffix}`, + electronAppMock, + setLoginItemSettingsMock, + }; +}); + +vi.mock('electron', () => ({ + app: electronAppMock, +})); + +function setPlatform(platform: string): void { + Object.defineProperty(process, 'platform', { value: platform, writable: true }); +} + +describe('launch-at-startup integration', () => { + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + electronAppMock.isPackaged = true; + await rm(testHome, { recursive: true, force: true }); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform, writable: true }); + }); + + it('uses login item settings on Windows', async () => { + setPlatform('win32'); + const { applyLaunchAtStartupSetting } = await import('@electron/main/launch-at-startup'); + + await applyLaunchAtStartupSetting(true); + expect(setLoginItemSettingsMock).toHaveBeenCalledWith({ + openAtLogin: true, + openAsHidden: false, + }); + }); + + it('uses login item settings on macOS', async () => { + setPlatform('darwin'); + const { applyLaunchAtStartupSetting } = await import('@electron/main/launch-at-startup'); + + await applyLaunchAtStartupSetting(false); + expect(setLoginItemSettingsMock).toHaveBeenCalledWith({ + openAtLogin: false, + openAsHidden: false, + }); + }); + + it('creates and removes Linux autostart desktop entry', async () => { + setPlatform('linux'); + const { applyLaunchAtStartupSetting } = await import('@electron/main/launch-at-startup'); + + const autostartPath = join(testHome, '.config', 'autostart', 'clawx.desktop'); + await applyLaunchAtStartupSetting(true); + + const content = await readFile(autostartPath, 'utf8'); + expect(content).toContain('[Desktop Entry]'); + expect(content).toContain('Name=ClawX'); + expect(content).toContain('Exec='); + + await applyLaunchAtStartupSetting(false); + await expect(access(autostartPath)).rejects.toThrow(); + }); + + it('does not throw on unsupported platforms', async () => { + setPlatform('freebsd'); + const { applyLaunchAtStartupSetting } = await import('@electron/main/launch-at-startup'); + + await expect(applyLaunchAtStartupSetting(true)).resolves.toBeUndefined(); + }); +}); diff --git a/tests/unit/stores.test.ts b/tests/unit/stores.test.ts index 42fe4bfae..d51dcb3bf 100644 --- a/tests/unit/stores.test.ts +++ b/tests/unit/stores.test.ts @@ -47,6 +47,30 @@ describe('Settings Store', () => { setDevModeUnlocked(true); expect(useSettingsStore.getState().devModeUnlocked).toBe(true); }); + + it('should persist launch-at-startup setting through host api', () => { + const invoke = vi.mocked(window.electron.ipcRenderer.invoke); + invoke.mockResolvedValueOnce({ + ok: true, + data: { + status: 200, + ok: true, + json: { success: true }, + }, + }); + + const { setLaunchAtStartup } = useSettingsStore.getState(); + setLaunchAtStartup(true); + + expect(useSettingsStore.getState().launchAtStartup).toBe(true); + expect(invoke).toHaveBeenCalledWith( + 'hostapi:fetch', + expect.objectContaining({ + path: '/api/settings/launchAtStartup', + method: 'PUT', + }), + ); + }); }); describe('Gateway Store', () => {