feat: implement launch at startup functionality in zn-ai

- Added a new setting for "launch at startup" in the GeneralSettingsPanel.
- Integrated the setting with the existing settings store and IPC mechanisms.
- Implemented platform-specific logic for enabling/disabling startup behavior in the main process.
- Created a new service for managing launch at startup settings, including Linux desktop entry creation.
- Added unit tests for the new functionality and ensured existing tests are updated accordingly.
- Updated i18n messages for the new setting in English, Chinese, and Japanese.
This commit is contained in:
duanshuwen
2026-04-20 23:29:10 +08:00
parent 35319e6a1d
commit 301f7d33ed
15 changed files with 924 additions and 3 deletions

View File

@@ -0,0 +1,108 @@
import React from 'react';
import { fireEvent, render, screen, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { setLocale } from '../src/i18n';
import GeneralSettingsPanel from '../src/pages/Setting/components/GeneralSettingsPanel';
import type { GatewaySettingState } from '../src/pages/Setting/useGatewaySettingState';
import type { SettingUpdateState } from '../src/pages/Setting/useSettingUpdateState';
function createGatewayState(): GatewaySettingState {
return {
status: 'connected',
loading: false,
showLogs: false,
gatewayAutoStart: true,
gateway: {
ok: true,
status: 'connected',
initialized: true,
mode: null,
port: 18789,
pid: null,
},
logContent: '',
statusLoading: false,
logLoading: false,
restarting: false,
autoStartUpdating: false,
error: null,
lastFetchedAt: null,
refreshStatus: async () => {},
restartGateway: async () => {},
toggleLogs: async () => {},
loadLogs: async () => {},
viewLogs: async () => {},
setGatewayAutoStart: async () => {},
};
}
function createUpdateState(): SettingUpdateState {
return {
status: 'idle',
currentVersion: '1.0.0',
updateInfo: null,
progress: null,
error: null,
autoCheckUpdate: true,
autoDownloadUpdate: false,
checkUpdate: async () => {},
downloadUpdate: async () => {},
installUpdate: async () => {},
setAutoCheckUpdate: () => {},
setAutoDownloadUpdate: () => {},
};
}
describe('GeneralSettingsPanel', () => {
beforeEach(() => {
setLocale('zh');
});
it('renders the launch-at-startup setting after language and before gateway', () => {
render(
<GeneralSettingsPanel
themeMode="system"
language="zh"
launchAtStartup={false}
onThemeChange={async () => {}}
onLanguageChange={async () => {}}
onLaunchAtStartupChange={async () => {}}
gatewayState={createGatewayState()}
updateState={createUpdateState()}
/>,
);
const languageLabel = screen.getByText('语言');
const launchLabel = screen.getByText('开机自动启动');
const gatewayLabel = screen.getByText('网关');
expect(languageLabel.compareDocumentPosition(launchLabel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(launchLabel.compareDocumentPosition(gatewayLabel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it('calls the launch-at-startup handler when the switch is toggled', () => {
const handleLaunchAtStartupChange = vi.fn();
render(
<GeneralSettingsPanel
themeMode="system"
language="zh"
launchAtStartup={false}
onThemeChange={async () => {}}
onLanguageChange={async () => {}}
onLaunchAtStartupChange={handleLaunchAtStartupChange}
gatewayState={createGatewayState()}
updateState={createUpdateState()}
/>,
);
const launchLabel = screen.getByText('开机自动启动');
const launchRow = launchLabel.parentElement?.parentElement;
expect(launchRow).toBeTruthy();
const launchSwitch = within(launchRow as HTMLElement).getByRole('switch');
fireEvent.click(launchSwitch);
expect(handleLaunchAtStartupChange).toHaveBeenCalledWith(true);
});
});

View File

@@ -0,0 +1,155 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const originalPlatform = process.platform;
const {
configManagerMock,
electronAppMock,
loggerMock,
mkdirMock,
rmMock,
setLoginItemSettingsMock,
testHome,
writeFileMock,
} = vi.hoisted(() => {
const suffix = Math.random().toString(36).slice(2);
const setLoginItemSettingsMock = vi.fn();
const mkdirMock = vi.fn();
const rmMock = vi.fn();
const writeFileMock = vi.fn();
const configManagerMock = {
get: vi.fn(),
};
const loggerMock = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const electronAppMock = {
isPackaged: true,
getName: () => 'zn-ai',
getPath: (name: string) => (name === 'home' ? `/tmp/zn-ai-launch-startup-${suffix}` : '/tmp'),
setLoginItemSettings: setLoginItemSettingsMock,
};
return {
configManagerMock,
electronAppMock,
loggerMock,
mkdirMock,
rmMock,
setLoginItemSettingsMock,
testHome: `/tmp/zn-ai-launch-startup-${suffix}`,
writeFileMock,
};
});
vi.mock('electron', () => ({
app: electronAppMock,
}));
vi.mock('@electron/service/config-service', () => ({
default: configManagerMock,
}));
vi.mock('@electron/service/logger', () => ({
logManager: loggerMock,
default: loggerMock,
}));
vi.mock('node:fs/promises', () => ({
mkdir: mkdirMock,
rm: rmMock,
writeFile: writeFileMock,
}));
vi.mock('node:path', () => ({
dirname: (path: string) => path.slice(0, path.lastIndexOf('/')) || '/',
join: (...parts: string[]) => parts.join('/').replace(/\/+/g, '/'),
}));
function setPlatform(platform: string): void {
Object.defineProperty(process, 'platform', { value: platform, writable: true });
}
describe('launch-at-startup service', () => {
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
configManagerMock.get.mockReturnValue(false);
electronAppMock.isPackaged = 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/service/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/service/launch-at-startup');
await applyLaunchAtStartupSetting(false);
expect(setLoginItemSettingsMock).toHaveBeenCalledWith({
openAtLogin: false,
openAsHidden: false,
});
});
it('creates and removes a Linux autostart desktop entry', async () => {
setPlatform('linux');
const { applyLaunchAtStartupSetting } = await import('@electron/service/launch-at-startup');
const autostartPath = `${testHome}/.config/autostart/zn-ai.desktop`;
await applyLaunchAtStartupSetting(true);
expect(mkdirMock).toHaveBeenCalledWith(`${testHome}/.config/autostart`, { recursive: true });
expect(writeFileMock).toHaveBeenCalledTimes(1);
expect(writeFileMock).toHaveBeenCalledWith(
autostartPath,
expect.stringContaining('[Desktop Entry]'),
'utf8',
);
expect(writeFileMock.mock.calls[0]?.[1]).toContain('Name=zn-ai');
expect(writeFileMock.mock.calls[0]?.[1]).toContain('Exec=');
await applyLaunchAtStartupSetting(false);
expect(rmMock).toHaveBeenCalledWith(autostartPath, { force: true });
});
it('syncs the persisted setting from config on startup', async () => {
setPlatform('win32');
configManagerMock.get.mockReturnValue(true);
const { syncLaunchAtStartupSettingFromConfig } = await import('@electron/service/launch-at-startup');
await syncLaunchAtStartupSettingFromConfig();
expect(configManagerMock.get).toHaveBeenCalledWith('launchAtStartup');
expect(setLoginItemSettingsMock).toHaveBeenCalledWith({
openAtLogin: true,
openAsHidden: false,
});
});
it('does not throw on unsupported platforms', async () => {
setPlatform('freebsd');
const { applyLaunchAtStartupSetting } = await import('@electron/service/launch-at-startup');
await expect(applyLaunchAtStartupSetting(true)).resolves.toBeUndefined();
});
});