mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-07 12:18:44 +02:00
feat: add system tray with quick actions and minimize-to-tray
System tray: - Always-visible tray icon with context menu - Quick Scratchpad action from tray (triggers session creation via IPC bridge to renderer) - Running session count in tray tooltip and menu - Click tray icon to show/focus window - Quit option in tray menu Minimize to tray: - New 'Minimize to tray on close' toggle in Settings > Appearance - When enabled, closing the window hides to tray instead of quitting (configurable per-user, default off) - macOS: hides dock icon when minimized to tray - Proper force-quit handling (Cmd+Q on macOS, tray Quit) Full IPC contract chain: - minimizeToTray setting in WorkspaceSettings - setMinimizeToTray channel + ElectronApi method - Preload bridge, service handler, IPC registration - Preserved through workspace normalization Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -514,6 +514,12 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async setMinimizeToTray(enabled: boolean): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
workspace.settings.minimizeToTray = enabled;
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async describeTerminal(): Promise<TerminalSnapshot | undefined> {
|
||||
return this.ptyManager.getSnapshot();
|
||||
}
|
||||
|
||||
+36
-3
@@ -5,11 +5,13 @@ import { registerIpcHandlers } from '@main/ipc/registerIpcHandlers';
|
||||
import { AryxAppService } from '@main/AryxAppService';
|
||||
import { createMainWindow } from '@main/windows/createMainWindow';
|
||||
import { applyTitleBarTheme } from '@main/windows/titleBarTheme';
|
||||
import { SystemTray, setupCloseToTray, showAndFocusWindow } from '@main/services/systemTray';
|
||||
|
||||
const { app, BrowserWindow } = electron;
|
||||
|
||||
let mainWindow: BrowserWindowType | undefined;
|
||||
let appService: AryxAppService | undefined;
|
||||
let systemTray: SystemTray | undefined;
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
appService = new AryxAppService();
|
||||
@@ -21,6 +23,29 @@ async function bootstrap(): Promise<void> {
|
||||
const workspace = await appService.loadWorkspace();
|
||||
applyTitleBarTheme(mainWindow, workspace.settings.theme);
|
||||
|
||||
// Set up system tray
|
||||
systemTray = new SystemTray({
|
||||
onShowWindow: showAndFocusWindow,
|
||||
onCreateScratchpad: () => {
|
||||
showAndFocusWindow();
|
||||
mainWindow?.webContents.send('tray:create-scratchpad');
|
||||
},
|
||||
onQuit: () => app.quit(),
|
||||
});
|
||||
systemTray.create();
|
||||
systemTray.updateRunningCount(workspace);
|
||||
|
||||
// Intercept close to hide to tray when the setting is enabled
|
||||
setupCloseToTray(mainWindow, () => {
|
||||
const currentWorkspace = appService?.getCachedWorkspace();
|
||||
return currentWorkspace?.settings.minimizeToTray === true;
|
||||
});
|
||||
|
||||
// Keep tray status in sync when workspace changes
|
||||
appService.on('workspace-updated', (updatedWorkspace) => {
|
||||
systemTray?.updateRunningCount(updatedWorkspace);
|
||||
});
|
||||
|
||||
if (!app.isPackaged) {
|
||||
mainWindow.webContents.openDevTools({ mode: 'detach' });
|
||||
}
|
||||
@@ -29,17 +54,25 @@ async function bootstrap(): Promise<void> {
|
||||
app.whenReady().then(bootstrap);
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
// When minimize-to-tray is enabled, don't quit on window close
|
||||
if (process.platform === 'darwin') return;
|
||||
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
const allHidden = windows.length > 0 && windows.every((w) => !w.isVisible());
|
||||
if (allHidden) return;
|
||||
|
||||
app.quit();
|
||||
});
|
||||
|
||||
app.on('activate', async () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
await bootstrap();
|
||||
} else {
|
||||
showAndFocusWindow();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', async () => {
|
||||
systemTray?.dispose();
|
||||
await appService?.dispose();
|
||||
});
|
||||
|
||||
@@ -91,6 +91,10 @@ export function registerIpcHandlers(window: BrowserWindow, service: AryxAppServi
|
||||
ipcChannels.setNotificationsEnabled,
|
||||
(_event, enabled: boolean) => service.setNotificationsEnabled(enabled),
|
||||
);
|
||||
ipcMain.handle(
|
||||
ipcChannels.setMinimizeToTray,
|
||||
(_event, enabled: boolean) => service.setMinimizeToTray(enabled),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.saveMcpServer, (_event, input: SaveMcpServerInput) =>
|
||||
service.saveMcpServer(input.server),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import electron from 'electron';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||
|
||||
const { app, Menu, Tray, nativeImage, BrowserWindow } = electron;
|
||||
type TrayType = InstanceType<typeof Tray>;
|
||||
type NativeImageType = ReturnType<typeof nativeImage.createFromPath>;
|
||||
|
||||
export interface SystemTrayOptions {
|
||||
onShowWindow: () => void;
|
||||
onCreateScratchpad: () => void;
|
||||
onQuit: () => void;
|
||||
}
|
||||
|
||||
function resolveTrayIcon(): NativeImageType {
|
||||
const basePath = app.getAppPath();
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
return nativeImage.createFromPath(join(basePath, 'assets', 'icons', 'windows', 'icon.ico'));
|
||||
}
|
||||
|
||||
// Use a smaller icon for tray on Linux/macOS — 32x32 for crispness
|
||||
const pngPath =
|
||||
process.platform === 'linux'
|
||||
? join(basePath, 'assets', 'icons', 'linux', 'icons', '32x32.png')
|
||||
: join(basePath, 'assets', 'icons', 'icon.png');
|
||||
|
||||
const image = nativeImage.createFromPath(pngPath);
|
||||
|
||||
// Resize to 16x16 for system tray standard size
|
||||
return image.resize({ width: 16, height: 16 });
|
||||
}
|
||||
|
||||
function buildContextMenu(options: SystemTrayOptions, runningCount: number): Electron.Menu {
|
||||
const statusLabel =
|
||||
runningCount > 0 ? `${runningCount} session${runningCount > 1 ? 's' : ''} running` : 'No active sessions';
|
||||
|
||||
return Menu.buildFromTemplate([
|
||||
{ label: 'Open Aryx', click: options.onShowWindow, type: 'normal' },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Quick Scratchpad', click: options.onCreateScratchpad, type: 'normal' },
|
||||
{ type: 'separator' },
|
||||
{ label: statusLabel, enabled: false, type: 'normal' },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Quit', click: options.onQuit, type: 'normal' },
|
||||
]);
|
||||
}
|
||||
|
||||
export class SystemTray {
|
||||
private tray: TrayType | null = null;
|
||||
private options: SystemTrayOptions;
|
||||
private runningCount = 0;
|
||||
|
||||
constructor(options: SystemTrayOptions) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
create(): void {
|
||||
if (this.tray) return;
|
||||
|
||||
const icon = resolveTrayIcon();
|
||||
this.tray = new Tray(icon);
|
||||
this.tray.setToolTip('Aryx');
|
||||
this.tray.setContextMenu(buildContextMenu(this.options, this.runningCount));
|
||||
|
||||
this.tray.on('click', () => {
|
||||
this.options.onShowWindow();
|
||||
});
|
||||
}
|
||||
|
||||
updateRunningCount(workspace: WorkspaceState): void {
|
||||
const count = workspace.sessions.filter((s) => !s.isArchived && s.status === 'running').length;
|
||||
if (count === this.runningCount) return;
|
||||
|
||||
this.runningCount = count;
|
||||
this.tray?.setContextMenu(buildContextMenu(this.options, count));
|
||||
|
||||
const tooltip = count > 0 ? `Aryx — ${count} running` : 'Aryx';
|
||||
this.tray?.setToolTip(tooltip);
|
||||
}
|
||||
|
||||
isMinimizeToTrayEnabled(workspace: WorkspaceState): boolean {
|
||||
return workspace.settings.minimizeToTray === true;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.tray?.destroy();
|
||||
this.tray = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept window close to hide to tray instead of quitting, when the setting is enabled.
|
||||
* Returns true if the close was intercepted (window hidden), false if it should proceed normally.
|
||||
*/
|
||||
export function setupCloseToTray(
|
||||
window: Electron.BrowserWindow,
|
||||
getMinimizeToTray: () => boolean,
|
||||
): void {
|
||||
let forceQuit = false;
|
||||
|
||||
// On macOS, Cmd+Q triggers before-quit before the close event
|
||||
app.on('before-quit', () => {
|
||||
forceQuit = true;
|
||||
});
|
||||
|
||||
window.on('close', (event) => {
|
||||
if (forceQuit) return;
|
||||
|
||||
if (getMinimizeToTray()) {
|
||||
event.preventDefault();
|
||||
window.hide();
|
||||
|
||||
// On macOS, also hide from the dock when minimized to tray
|
||||
if (process.platform === 'darwin') {
|
||||
app.dock?.hide();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show and focus the main window, restoring from tray if hidden.
|
||||
*/
|
||||
export function showAndFocusWindow(): void {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
const mainWindow = windows[0];
|
||||
if (!mainWindow) return;
|
||||
|
||||
// On macOS, show the dock icon again
|
||||
if (process.platform === 'darwin') {
|
||||
app.dock?.show();
|
||||
}
|
||||
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore();
|
||||
}
|
||||
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
Reference in New Issue
Block a user