feat: add startup launch support (#1421)

Add launch-at-startup support across the Electron desktop app and the web CLI.

Electron now supports macOS launch-at-login through the native login item API. Login launches start OpenChamber in the background without opening a window, while Dock activation, deep links, and second-instance launches still open or focus the normal app window. The desktop Settings UI now exposes a localized launch-at-login toggle in Desktop Network Access.

The web CLI now includes `openchamber startup status|enable|disable`, backed by native user services:
- macOS: launchd LaunchAgent
- Linux: systemd --user service
- Windows: Task Scheduler

Startup services run `openchamber serve --foreground` so the OS service manager owns process lifetime and restarts. Foreground service updates now defer restarts to the service manager instead of spawning duplicate CLI restarts.

Startup services snapshot useful environment variables by default so provider tokens, PATH, SSH agent settings, and OpenCode configuration survive login/reboot starts. The snapshot avoids shell/session-only state, uses systemd-compatible env quoting on Linux, and avoids unused env artifacts on macOS.

Also adds localized docs for startup services and environment variables.
This commit is contained in:
Bohdan Triapitsyn
2026-05-26 01:36:11 +03:00
committed by GitHub
parent e97bf0d9dc
commit 2014303bc0
30 changed files with 1854 additions and 30 deletions
+83 -10
View File
@@ -20,6 +20,24 @@ const isDev = process.env.OPENCHAMBER_ELECTRON_DEV === '1' || !app.isPackaged;
const DEEP_LINK_PROTOCOL = 'openchamber';
const APP_USER_MODEL_ID = 'dev.openchamber.desktop';
const BACKGROUND_START_ARG = '--background';
const readLoginItemSettings = () => {
if (process.platform !== 'darwin') return null;
try {
return app.getLoginItemSettings();
} catch {
return null;
}
};
const shouldStartInBackground = (loginItemSettings = readLoginItemSettings()) => {
return (
process.argv.includes(BACKGROUND_START_ARG) ||
loginItemSettings?.wasOpenedAtLogin === true ||
loginItemSettings?.wasOpenedAsHidden === true
);
};
if (!app.requestSingleInstanceLock()) {
app.exit(0);
@@ -1378,6 +1396,21 @@ const activateMainWindow = async (url, localOrigin, bootOutcome) => {
return state.mainWindow;
};
const openMainWindow = async () => {
if (!state.localOrigin) {
const { initialUrl, localOrigin, bootOutcome } = await resolveInitialUrl();
return activateMainWindow(initialUrl, localOrigin, bootOutcome);
}
const config = readDesktopHostsConfig();
const localUiUrl = state.sidecarUrl || state.localOrigin;
const host = config.defaultHostId && config.defaultHostId !== LOCAL_HOST_ID
? config.hosts.find((entry) => entry.id === config.defaultHostId)
: null;
const targetUrl = host?.url && !state.unreachableHosts.has(host.url) ? host.url : localUiUrl;
return activateMainWindow(targetUrl, state.localOrigin, state.bootOutcome);
};
const createAdditionalWindow = async (url) => {
if (!state.localOrigin) {
return null;
@@ -1869,6 +1902,24 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
case 'desktop_get_app_version':
return APP_VERSION;
case 'desktop_get_launch_at_login': {
if (process.platform !== 'darwin') return { supported: false, enabled: false };
const settings = app.getLoginItemSettings();
return { supported: true, enabled: settings.openAtLogin === true };
}
case 'desktop_set_launch_at_login': {
if (process.platform !== 'darwin') return { supported: false, enabled: false };
const enabled = args.enabled === true;
app.setLoginItemSettings({
openAtLogin: enabled,
openAsHidden: enabled,
args: enabled ? [BACKGROUND_START_ARG] : [],
});
const settings = app.getLoginItemSettings();
return { supported: true, enabled: settings.openAtLogin === true };
}
case 'desktop_browser_capture_page': {
const wcId = Number.isFinite(args.webContentsId) ? Math.trunc(args.webContentsId) : null;
if (wcId === null || wcId < 0) throw new Error('webContentsId is required');
@@ -2636,12 +2687,19 @@ app.on('second-instance', (_event, argv) => {
? argv.filter((arg) => typeof arg === 'string' && arg.startsWith(`${DEEP_LINK_PROTOCOL}://`))
: [];
if (urls.length > 0) handleDeepLinks(urls);
focusForegroundWindow();
if (BrowserWindow.getAllWindows().length > 0) {
focusForegroundWindow();
} else {
void openMainWindow();
}
});
app.on('open-url', (event, url) => {
event.preventDefault();
handleDeepLinks([url]);
if (BrowserWindow.getAllWindows().length === 0) {
void openMainWindow();
}
});
app.on('activate', async () => {
@@ -2655,23 +2713,20 @@ app.on('activate', async () => {
return;
}
if (state.localOrigin) {
const config = readDesktopHostsConfig();
const localUiUrl = state.sidecarUrl || state.localOrigin;
const host = config.defaultHostId && config.defaultHostId !== LOCAL_HOST_ID
? config.hosts.find((entry) => entry.id === config.defaultHostId)
: null;
const targetUrl = host?.url && !state.unreachableHosts.has(host.url) ? host.url : localUiUrl;
await createAdditionalWindow(targetUrl);
}
await openMainWindow();
});
app.whenReady().then(async () => {
const loginItemSettings = readLoginItemSettings();
const isBackgroundStart = shouldStartInBackground(loginItemSettings);
log.info('[electron] app starting', {
version: APP_VERSION,
packaged: app.isPackaged,
platform: process.platform,
arch: process.arch,
argv: process.argv,
isBackgroundStart,
loginItemSettings,
});
nativeTheme.themeSource = readThemeSource();
setupAutoUpdater();
@@ -2680,6 +2735,24 @@ app.whenReady().then(async () => {
Menu.setApplicationMenu(buildMacMenu());
}
if (process.platform === 'darwin' && app.isPackaged) {
const openAtLogin = loginItemSettings?.openAtLogin === true;
app.setLoginItemSettings({
openAtLogin,
openAsHidden: openAtLogin,
args: openAtLogin ? [BACKGROUND_START_ARG] : [],
});
}
if (isBackgroundStart) {
const { localOrigin, bootOutcome } = await resolveInitialUrl();
state.localOrigin = localOrigin;
state.bootOutcome = bootOutcome ?? null;
state.initScript = buildInitScript(localOrigin, state.bootOutcome);
log.info('[electron] started in background without window');
return;
}
state.mainWindow = createBrowserWindow({
label: 'main',
restoreGeometry: true,