diff --git a/src/main/AryxAppService.ts b/src/main/AryxAppService.ts index dfcddcb..6dc3e0a 100644 --- a/src/main/AryxAppService.ts +++ b/src/main/AryxAppService.ts @@ -514,6 +514,12 @@ export class AryxAppService extends EventEmitter { return this.persistAndBroadcast(workspace); } + async setMinimizeToTray(enabled: boolean): Promise { + const workspace = await this.loadWorkspace(); + workspace.settings.minimizeToTray = enabled; + return this.persistAndBroadcast(workspace); + } + async describeTerminal(): Promise { return this.ptyManager.getSnapshot(); } diff --git a/src/main/index.ts b/src/main/index.ts index 1d61e20..adb2562 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 { appService = new AryxAppService(); @@ -21,6 +23,29 @@ async function bootstrap(): Promise { 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 { 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(); }); diff --git a/src/main/ipc/registerIpcHandlers.ts b/src/main/ipc/registerIpcHandlers.ts index ef7ad9d..4f16972 100644 --- a/src/main/ipc/registerIpcHandlers.ts +++ b/src/main/ipc/registerIpcHandlers.ts @@ -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), ); diff --git a/src/main/services/systemTray.ts b/src/main/services/systemTray.ts new file mode 100644 index 0000000..e0db7c5 --- /dev/null +++ b/src/main/services/systemTray.ts @@ -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; +type NativeImageType = ReturnType; + +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(); +} diff --git a/src/preload/index.ts b/src/preload/index.ts index e05a27f..3d804f4 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -27,6 +27,7 @@ const api: ElectronApi = { setTheme: (theme) => ipcRenderer.invoke(ipcChannels.setTheme, theme), setTerminalHeight: (input) => ipcRenderer.invoke(ipcChannels.setTerminalHeight, input), setNotificationsEnabled: (enabled) => ipcRenderer.invoke(ipcChannels.setNotificationsEnabled, enabled), + setMinimizeToTray: (enabled) => ipcRenderer.invoke(ipcChannels.setMinimizeToTray, enabled), saveMcpServer: (input) => ipcRenderer.invoke(ipcChannels.saveMcpServer, input), deleteMcpServer: (serverId) => ipcRenderer.invoke(ipcChannels.deleteMcpServer, serverId), saveLspProfile: (input) => ipcRenderer.invoke(ipcChannels.saveLspProfile, input), @@ -95,6 +96,12 @@ const api: ElectronApi = { ipcRenderer.on(ipcChannels.sessionEvent, handler); return () => ipcRenderer.off(ipcChannels.sessionEvent, handler); }, + onTrayCreateScratchpad: (listener) => { + const handler = () => listener(); + + ipcRenderer.on(ipcChannels.trayCreateScratchpad, handler); + return () => ipcRenderer.off(ipcChannels.trayCreateScratchpad, handler); + }, }; contextBridge.exposeInMainWorld('aryxApi', api); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 604318d..88f3581 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -485,6 +485,13 @@ export default function App() { } }, [api, workspace]); + // Listen for tray "Quick Scratchpad" action + const scratchpadRef = useRef(handleCreateScratchpad); + scratchpadRef.current = handleCreateScratchpad; + useEffect(() => { + return api.onTrayCreateScratchpad(() => scratchpadRef.current()); + }, [api]); + const projectForSettings = useMemo( () => workspace?.projects.find((p) => p.id === projectSettingsId), [workspace?.projects, projectSettingsId], @@ -638,6 +645,8 @@ export default function App() { onSetTheme={(theme) => void api.setTheme(theme)} notificationsEnabled={workspace.settings.notificationsEnabled !== false} onSetNotificationsEnabled={(enabled) => void api.setNotificationsEnabled(enabled)} + minimizeToTray={workspace.settings.minimizeToTray === true} + onSetMinimizeToTray={(enabled) => void api.setMinimizeToTray(enabled)} onOpenAppDataFolder={() => void api.openAppDataFolder()} onResetLocalWorkspace={async () => { const fresh = await api.resetLocalWorkspace(); diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index 12bd8be..931ecba 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -42,6 +42,8 @@ interface SettingsPanelProps { onSetTheme: (theme: AppearanceTheme) => void; notificationsEnabled: boolean; onSetNotificationsEnabled: (enabled: boolean) => void; + minimizeToTray: boolean; + onSetMinimizeToTray: (enabled: boolean) => void; onOpenAppDataFolder: () => void; onResetLocalWorkspace: () => Promise; onResolveUserDiscoveredTooling?: (serverIds: string[], resolution: 'accept' | 'dismiss') => void; @@ -122,6 +124,8 @@ export function SettingsPanel({ onSetTheme, notificationsEnabled, onSetNotificationsEnabled, + minimizeToTray, + onSetMinimizeToTray, onOpenAppDataFolder, onResetLocalWorkspace, onResolveUserDiscoveredTooling, @@ -265,6 +269,8 @@ export function SettingsPanel({ onSetTheme={onSetTheme} notificationsEnabled={notificationsEnabled} onSetNotificationsEnabled={onSetNotificationsEnabled} + minimizeToTray={minimizeToTray} + onSetMinimizeToTray={onSetMinimizeToTray} /> )} {activeSection === 'connection' && ( @@ -327,12 +333,16 @@ function AppearanceSection({ onSetTheme, notificationsEnabled, onSetNotificationsEnabled, + minimizeToTray, + onSetMinimizeToTray, }: { theme: AppearanceTheme; onSetTheme: (theme: AppearanceTheme) => void; notificationsEnabled: boolean; onSetNotificationsEnabled: (enabled: boolean) => void; -}) { + minimizeToTray: boolean; + onSetMinimizeToTray: (enabled: boolean) => void; +}){ return (
@@ -397,6 +407,30 @@ function AppearanceSection({
+ + {/* System Tray */} +
+

System Tray

+

+ Control how Aryx behaves when you close the window +

+
+ +
); } diff --git a/src/shared/contracts/channels.ts b/src/shared/contracts/channels.ts index be9b957..e45e199 100644 --- a/src/shared/contracts/channels.ts +++ b/src/shared/contracts/channels.ts @@ -16,6 +16,7 @@ export const ipcChannels = { setTheme: 'settings:set-theme', setTerminalHeight: 'settings:set-terminal-height', setNotificationsEnabled: 'settings:set-notifications-enabled', + setMinimizeToTray: 'settings:set-minimize-to-tray', saveMcpServer: 'tooling:mcp:save', deleteMcpServer: 'tooling:mcp:delete', saveLspProfile: 'tooling:lsp:save', @@ -54,4 +55,5 @@ export const ipcChannels = { workspaceUpdated: 'workspace:updated', sessionEvent: 'sessions:event', getQuota: 'sidecar:get-quota', + trayCreateScratchpad: 'tray:create-scratchpad', } as const; diff --git a/src/shared/contracts/ipc.ts b/src/shared/contracts/ipc.ts index 4baee39..1669c5c 100644 --- a/src/shared/contracts/ipc.ts +++ b/src/shared/contracts/ipc.ts @@ -195,6 +195,7 @@ export interface ElectronApi { setTheme(theme: AppearanceTheme): Promise; setTerminalHeight(input: SetTerminalHeightInput): Promise; setNotificationsEnabled(enabled: boolean): Promise; + setMinimizeToTray(enabled: boolean): Promise; describeTerminal(): Promise; createTerminal(): Promise; restartTerminal(): Promise; @@ -208,6 +209,7 @@ export interface ElectronApi { onTerminalExit(listener: (info: TerminalExitInfo) => void): () => void; onWorkspaceUpdated(listener: (workspace: WorkspaceState) => void): () => void; onSessionEvent(listener: (event: SessionEventRecord) => void): () => void; + onTrayCreateScratchpad(listener: () => void): () => void; } export interface RendererSelectionState { diff --git a/src/shared/domain/tooling.ts b/src/shared/domain/tooling.ts index f9ead2f..a31fa92 100644 --- a/src/shared/domain/tooling.ts +++ b/src/shared/domain/tooling.ts @@ -65,6 +65,7 @@ export interface WorkspaceSettings { discoveredUserTooling: DiscoveredToolingState; terminalHeight?: number; notificationsEnabled?: boolean; + minimizeToTray?: boolean; } export interface SessionToolingSelection { @@ -206,6 +207,8 @@ export function normalizeWorkspaceSettings(settings?: Partial }, discoveredUserTooling: normalizeDiscoveredToolingState(settings?.discoveredUserTooling), ...(terminalHeight !== undefined ? { terminalHeight } : {}), + ...(settings?.notificationsEnabled !== undefined ? { notificationsEnabled: settings.notificationsEnabled } : {}), + ...(settings?.minimizeToTray !== undefined ? { minimizeToTray: settings.minimizeToTray } : {}), }; }