feat(electron): add Windows startup and system tray support (#2112)

Add Windows launch-at-login with background startup support and extend the
native tray integration to Windows.

Add a Windows-only setting to minimize or close the main window to the
system tray, persist it through desktop settings, and expose it in Settings
search and all locale dictionaries.

Keep tray state synchronized with live sessions on both macOS and Windows,
while preserving the existing macOS behavior.
This commit is contained in:
achcyano
2026-07-10 12:40:48 +03:00
committed by GitHub
parent 51e6ae7e3f
commit 4296efb64d
20 changed files with 325 additions and 39 deletions
+88 -17
View File
@@ -29,10 +29,21 @@ const DEV_APP_USER_MODEL_ID = 'dev.openchamber.desktop.dev';
const APP_USER_MODEL_ID = app.isPackaged ? PACKAGED_APP_USER_MODEL_ID : DEV_APP_USER_MODEL_ID;
const BACKGROUND_START_ARG = '--background';
const getLoginItemOptions = () => {
if (process.platform === 'win32') {
return {
path: process.execPath,
args: [BACKGROUND_START_ARG],
name: APP_USER_MODEL_ID,
};
}
return {};
};
const readLoginItemSettings = () => {
if (process.platform !== 'darwin') return null;
if (process.platform !== 'darwin' && process.platform !== 'win32') return null;
try {
return app.getLoginItemSettings();
return app.getLoginItemSettings(getLoginItemOptions());
} catch {
return null;
}
@@ -225,6 +236,22 @@ const readDesktopKeepAwakeStatus = () => {
return { supported: true, enabled, active };
};
const readDesktopMinimizeToTrayStatus = () => {
const supported = process.platform === 'win32';
return {
supported,
enabled: supported && readSettingsRoot().desktopMinimizeToTrayEnabled === true,
};
};
const shouldHideMainWindowToTray = (browserWindow) => {
if (process.platform !== 'win32') return false;
if (!state.trayController) return false;
if (!browserWindow || browserWindow.isDestroyed()) return false;
if (browserWindow.__ocMiniChat === true) return false;
return readSettingsRoot().desktopMinimizeToTrayEnabled === true;
};
const quitRisk = {
hasActiveTunnel: false,
hasRunningScheduledTasks: false,
@@ -2278,7 +2305,20 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} }
browserWindow.on('move', () => {
debounceWindowStatePersist(browserWindow, false);
});
browserWindow.on('minimize', (event) => {
if (!shouldHideMainWindowToTray(browserWindow)) return;
debounceWindowStatePersist(browserWindow, true);
event.preventDefault();
browserWindow.hide();
});
browserWindow.on('close', (event) => {
if (!state.quitRequested && shouldHideMainWindowToTray(browserWindow)) {
debounceWindowStatePersist(browserWindow, true);
event.preventDefault();
browserWindow.hide();
return;
}
if (process.platform === 'darwin' && !state.quitRequested) {
const remainingVisible = BrowserWindow.getAllWindows().filter(
(window) => !window.isDestroyed() && window.isVisible(),
@@ -3389,23 +3429,39 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
return APP_VERSION;
case 'desktop_get_launch_at_login': {
if (process.platform !== 'darwin') return { supported: false, enabled: false };
const settings = app.getLoginItemSettings();
if (process.platform !== 'darwin' && process.platform !== 'win32') return { supported: false, enabled: false };
const settings = app.getLoginItemSettings(getLoginItemOptions());
return { supported: true, enabled: settings.openAtLogin === true };
}
case 'desktop_set_launch_at_login': {
if (process.platform !== 'darwin') return { supported: false, enabled: false };
if (process.platform !== 'darwin' && process.platform !== 'win32') return { supported: false, enabled: false };
const enabled = args.enabled === true;
app.setLoginItemSettings({
const settingsArgs = {
openAtLogin: enabled,
openAsHidden: enabled,
args: enabled ? [BACKGROUND_START_ARG] : [],
});
const settings = app.getLoginItemSettings();
...(process.platform === 'darwin' ? { openAsHidden: enabled } : {}),
...(process.platform === 'win32' ? getLoginItemOptions() : { args: enabled ? [BACKGROUND_START_ARG] : [] }),
...(process.platform === 'win32' ? { enabled } : {}),
};
app.setLoginItemSettings(settingsArgs);
const settings = app.getLoginItemSettings(getLoginItemOptions());
return { supported: true, enabled: settings.openAtLogin === true };
}
case 'desktop_get_minimize_to_tray': {
return readDesktopMinimizeToTrayStatus();
}
case 'desktop_set_minimize_to_tray': {
if (process.platform !== 'win32') return { supported: false, enabled: false };
const enabled = args.enabled === true;
await mutateSettingsRoot((root) => {
root.desktopMinimizeToTrayEnabled = enabled;
});
setupTray();
return readDesktopMinimizeToTrayStatus();
}
case 'desktop_get_keep_awake': {
return readDesktopKeepAwakeStatus();
}
@@ -4525,8 +4581,8 @@ ipcMain.handle('openchamber:file:grant-existing', async (event, filePath) => {
};
});
// --- macOS menu bar (status bar) ---------------------------------------------
// Tray lives only on macOS; the renderer streams a compact state snapshot via
// --- Native tray / menu bar ---------------------------------------------------
// Tray lives on macOS and Windows; the renderer streams a compact state snapshot via
// the `desktop_tray_update` IPC command (see the command switch). Tray clicks
// flow back through dispatchTrayAction → renderer (focus/respond) or native
// handlers (show window / quit).
@@ -4558,6 +4614,21 @@ const resolveTraySurface = () => {
const trayIconAssets = () => {
const dir = path.join(resourceRoot(), 'icons', 'tray');
const statusDir = path.join(dir, 'status');
if (process.platform === 'win32') {
const iconPath = getWindowIconPath() || path.join(resourceRoot(), 'icons', 'icon.ico');
return {
idleIconPath: iconPath,
unseenIconPath: iconPath,
breathIconPaths: [iconPath],
statusIconPaths: {
busy: path.join(statusDir, 'busy.png'),
retry: path.join(statusDir, 'retry.png'),
error: path.join(statusDir, 'error.png'),
unseen: path.join(statusDir, 'unseen.png'),
blank: path.join(statusDir, 'blank.png'),
},
};
}
return {
idleIconPath: path.join(dir, 'trayTemplate-idle.png'),
unseenIconPath: path.join(dir, 'trayTemplate-unseen.png'),
@@ -4576,7 +4647,7 @@ const trayIconAssets = () => {
};
const setupTray = () => {
if (process.platform !== 'darwin' || state.trayController) return;
if (!['darwin', 'win32'].includes(process.platform) || state.trayController) return;
const assets = trayIconAssets();
if (!fs.existsSync(assets.idleIconPath)) {
log.warn('[electron] tray icon missing, skipping tray setup', { iconPath: assets.idleIconPath });
@@ -4778,17 +4849,17 @@ app.whenReady().then(async () => {
if (process.platform === 'darwin') {
Menu.setApplicationMenu(buildMacMenu());
setupTray();
} else {
Menu.setApplicationMenu(buildAutoHiddenMenu());
}
setupTray();
if (process.platform === 'darwin' && app.isPackaged) {
if ((process.platform === 'darwin' || process.platform === 'win32') && app.isPackaged) {
const openAtLogin = loginItemSettings?.openAtLogin === true;
app.setLoginItemSettings({
openAtLogin,
openAsHidden: openAtLogin,
args: openAtLogin ? [BACKGROUND_START_ARG] : [],
...(process.platform === 'darwin' ? { openAsHidden: openAtLogin, args: openAtLogin ? [BACKGROUND_START_ARG] : [] } : {}),
...(process.platform === 'win32' ? { ...getLoginItemOptions(), enabled: openAtLogin } : {}),
});
}
+11 -3
View File
@@ -1,4 +1,4 @@
// macOS menu bar (status bar) controller.
// Native tray/menu bar controller.
//
// Surfaces a glanceable, always-visible view of OpenChamber's live state:
// 1. an aggregate activity indicator (idle / busy / error+retry) in the icon
@@ -17,6 +17,8 @@
import { Tray, Menu, nativeImage } from 'electron';
const isMac = process.platform === 'darwin';
const MAX_SESSIONS = 8;
const MAX_APPROVALS = 10;
@@ -87,7 +89,7 @@ const ANIM_INTERVAL_MS = 75;
const toTemplateImage = (p) => {
const image = nativeImage.createFromPath(p);
image.setTemplateImage(true);
if (isMac) image.setTemplateImage(true);
return image;
};
@@ -99,6 +101,7 @@ export const createTrayController = ({ idleIconPath, unseenIconPath, breathIconP
let lastTitle = null;
// macOS auto-picks the @2x file next to each path and tints the alpha.
// Windows uses the regular app icon and ignores template tinting.
const idleFrame = toTemplateImage(idleIconPath);
const unseenFrame = toTemplateImage(unseenIconPath);
const breathFrames = breathIconPaths.map(toTemplateImage);
@@ -122,6 +125,7 @@ export const createTrayController = ({ idleIconPath, unseenIconPath, breathIconP
const startAnim = () => {
if (animTimer || !tray || tray.isDestroyed?.()) return;
if (breathFrames.length < 2) return;
animIndex = 0;
animDir = 1;
animTimer = setInterval(() => {
@@ -139,7 +143,8 @@ export const createTrayController = ({ idleIconPath, unseenIconPath, breathIconP
iconState = nextState;
if (!tray || tray.isDestroyed?.()) return;
if (nextState === 'busy') {
startAnim();
if (breathFrames.length > 1) startAnim();
else tray.setImage(breathFrames[0] || idleFrame);
} else if (nextState === 'unseen') {
stopAnim();
tray.setImage(unseenFrame);
@@ -153,6 +158,9 @@ export const createTrayController = ({ idleIconPath, unseenIconPath, breathIconP
if (tray && !tray.isDestroyed?.()) return tray;
tray = new Tray(idleFrame);
tray.setIgnoreDoubleClickEvents(true);
if (!isMac) {
tray.on('click', () => onAction({ type: 'show-main-window' }));
}
return tray;
};