From 5236a92763958190e134171017fbad81727d1cda Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:20:48 -0400 Subject: [PATCH] tray icon and open on login --- package.json | 5 + resources/tray/prism-tray.ico | Bin 0 -> 8024 bytes resources/tray/prism-tray.png | Bin 0 -> 1053 bytes resources/tray/prismTrayTemplate.png | Bin 0 -> 419 bytes resources/tray/prismTrayTemplate@2x.png | Bin 0 -> 606 bytes scripts/build/generate-prism-icons.mjs | 64 ++ scripts/run-desktop-integration-tests.mjs | 38 ++ src/main/index.ts | 611 ++++++++++++++++-- src/main/services/desktopIntegrationPrefs.ts | 64 ++ src/main/services/loginItem.ts | 224 +++++++ src/main/services/trayAssets.ts | 19 + src/main/services/trayMenu.ts | 122 ++++ src/main/services/trayRendererCommandQueue.ts | 20 + src/preload/index.ts | 38 ++ src/renderer/App.tsx | 12 + src/renderer/components/BottomBar.tsx | 65 +- src/renderer/components/TrayControlBridge.tsx | 136 ++++ src/renderer/env.d.ts | 19 + .../stores/desktopIntegrationStore.ts | 84 +++ src/renderer/styles/globals.css | 50 +- src/types/desktopIntegration.ts | 69 ++ test/desktop-integration.test.ts | 343 ++++++++++ test/renderer-helpers.test.ts | 17 +- 23 files changed, 1940 insertions(+), 60 deletions(-) create mode 100644 resources/tray/prism-tray.ico create mode 100644 resources/tray/prism-tray.png create mode 100644 resources/tray/prismTrayTemplate.png create mode 100644 resources/tray/prismTrayTemplate@2x.png create mode 100644 scripts/run-desktop-integration-tests.mjs create mode 100644 src/main/services/desktopIntegrationPrefs.ts create mode 100644 src/main/services/loginItem.ts create mode 100644 src/main/services/trayAssets.ts create mode 100644 src/main/services/trayMenu.ts create mode 100644 src/main/services/trayRendererCommandQueue.ts create mode 100644 src/renderer/components/TrayControlBridge.tsx create mode 100644 src/renderer/stores/desktopIntegrationStore.ts create mode 100644 src/types/desktopIntegration.ts create mode 100644 test/desktop-integration.test.ts diff --git a/package.json b/package.json index 01d341f..db96efc 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "test:themes": "node scripts/run-theme-library-tests.mjs", "test:secret-vault": "node scripts/run-secret-vault-tests.mjs", "test:window-state": "node scripts/run-window-state-tests.mjs", + "test:desktop-integration": "node scripts/run-desktop-integration-tests.mjs", "test:renderer-helpers": "node scripts/run-renderer-helper-tests.mjs", "test:spectrum-native": "node --test test/spectrum-native.test.mjs", "test:tui": "node scripts/build/build-tui.cjs --test", @@ -100,6 +101,10 @@ "from": "resources/icon.png", "to": "icon.png" }, + { + "from": "resources/tray/", + "to": "tray/" + }, { "from": "plugin/dist-installer/", "to": "plugins/" diff --git a/resources/tray/prism-tray.ico b/resources/tray/prism-tray.ico new file mode 100644 index 0000000000000000000000000000000000000000..9014eeb5dd2645323042116ffad138828735f785 GIT binary patch literal 8024 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 KfA0T#I`|)j1nOu2 literal 0 HcmV?d00001 diff --git a/resources/tray/prism-tray.png b/resources/tray/prism-tray.png new file mode 100644 index 0000000000000000000000000000000000000000..ef34c3a4295bd65d372fac1b438c52e8a3637db8 GIT binary patch literal 1053 zcmV+&1mgRNP)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR917@z|H1ONa40RR917ytkO0OB=utpET6heT`5QuH-fmw1+c;uDq@Wb zXeoV6J8h5WT$lN7 zMZSqhKCy9Or_fw?0mL2#_jo)|RaFJSU=aNN5{N`1kk99fB?_W|RrzeT`wcCJYFL zLSPsMdGCinX(=2(ehdcs`{2^$D^N-iS?LsTq{|Hd9Oi6_G)*f<5T|H^n>S^%S=h0C z8&p8L*jbbv9z+^IsuU@^zox66Us(GB!6=zHd&acG;OvJgSC9JL6 zfDax%k_4$#O3vwY8WTiBDU&vACsg-*qLm2>(I_qgQE_iWBfjnH!&oea&tG)mruxlT zRvyArr_W&L&0AQ%Wh+HfASs@pKm-c<{AF0bc`LfyUTi(shQlKxXjvB38}aP93o@EH z`vQT+DJo_NRfA+a20ot`dV0E`v7tfs2Bk`>Z7GZu3An*>mI>80wb0RV5!w&6f!FH= z>QsqI^#dw8d>J1HA*AYm=W;5tMrq)tD=Rm!qvH~O{`_Tunz+bZ^xob#PHHNo$o67^ zOj!{uzx@YVad7a11fH3hkuv3(Wji!9jP31*QFF7})cFeH@1+S?3F}Cj-Q6#x-dXdk zf9?YYo|u@xYu9g}5n3%Hprw_&5SQJfph zCQI0jCW~yk-7!}>8z*od!{oX=t}7}kNENe-;qZ4blL-RWZS&=4fzK>6b%c!y1Qe3vg~LXC;qb*% zmi3Ag^;rC(&!e{c2^ Xh*m9%iQcy^00000NkvXXu0mjfA~nLa literal 0 HcmV?d00001 diff --git a/resources/tray/prismTrayTemplate.png b/resources/tray/prismTrayTemplate.png new file mode 100644 index 0000000000000000000000000000000000000000..8960141ec40e79a82c23a3cd52a496e55159b7ae GIT binary patch literal 419 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|DpDgn(|mmy zw18|51{Ovs23C*~Ahu(ahO^xmH5iz|;!F$-?U@WLP&Ix)8U#Fm7^DY6GcRC-ss@TJ zV1la_Sip>6gS4^Vay<&9I14-?iy0WWg+Z8+Vb&Z8px_Zt7sn6_!Mhi2xehr9v^`wV zWIwmJEb|SE;}(yZ%rAIz))q7PFOWNU$Y>gnxO9qHB~;*u+lG)U$xk&CW&*|<_5UQe{80(4}KjRDC$L6shy>QVy2k}KUy)myDtY5h7 zJf|A$xh@~o$;pUXO@geCy8 CQFrnH literal 0 HcmV?d00001 diff --git a/resources/tray/prismTrayTemplate@2x.png b/resources/tray/prismTrayTemplate@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..39c749a636dddd6ee76ac2714d0aff681b40f912 GIT binary patch literal 606 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz#^NA%Cx&(BWL^R}6{!)PX}-P; zT0k}j0}G=R11m@g5Zf_I!`W_(8Vt-}aV7?a_DlvAs2V>Y4FVH@7^DY6GcRC-ss@TJ zV1lbwSip>6gS6Fg-vYUiv%n*=n1Mk|6@(c*gH%2MO{@2GaSVyzd^=6s!zEClExe|o zTf;8FbcT=Ak+jRY*>|!g=N>XUxWqSSmdk2i-ABrjET%V2GB0jYh&sZaC2;h(c>z1$ z^u^poY2DA;`#;Y({HOT&yPY{|!lKq6?pp_hFJ-KJ#CoCF|IGCVTv>}`WA+8z*)Xq3 zXGXSkdkNz%JH@aq>k?j0XWBAB_Z&~8=q0D5%hq!d5`9)Ox3pJd;UPI;A~Xfbi2KK`tmYZgttyC71K+Uta32n z_;h$rb9Dquu&MgZ-4aLNu--X$V*8PhOOHHPG-R*&?ECGkiQBW}0OR`^mm2GQu0LGd z^;7aA6H74r%x4c6Yy4MNZ$Fa0K(uMaJRRWz#;ixu_dC}+$+KMMn8|k_{lz=kqBN5k z*^@G6edLW<{3T`|vuZ}egIlV}w*s#BA9=RWc*p9=0qZ76z4~$T9vhFv^M5v1_ea^Q to)Dgpz2y0l4&@u|D{eI2XjG8>!8Rdb=F1=d3Nk?P?dU*8~6n literal 0 HcmV?d00001 diff --git a/scripts/build/generate-prism-icons.mjs b/scripts/build/generate-prism-icons.mjs index 07e34fe..74afd99 100644 --- a/scripts/build/generate-prism-icons.mjs +++ b/scripts/build/generate-prism-icons.mjs @@ -16,6 +16,7 @@ import { deflateSync, inflateSync } from 'node:zlib' const scriptDir = dirname(fileURLToPath(import.meta.url)) const repoRoot = resolve(scriptDir, '../..') const resourcesDir = join(repoRoot, 'resources') +const trayResourcesDir = join(resourcesDir, 'tray') const tempDir = mkdtempSync(join(tmpdir(), 'prism-icons-')) const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]) const iconBackground = { r: 5, g: 7, b: 10 } @@ -240,6 +241,28 @@ function restoreIconBackgroundAlpha(filePath) { writePngRgba(filePath, width, height, pixels) } +function convertTemplateToAlphaMask(filePath) { + const image = readPngRgba(filePath) + const { width, height, pixels } = image + + for (let offset = 0; offset < pixels.length; offset += 4) { + const originalAlpha = pixels[offset + 3] + const luminance = Math.round( + (pixels[offset] + pixels[offset + 1] + pixels[offset + 2]) / 3, + ) + + // Quick Look rasterizes SVG previews over white on some macOS versions. + // Template images need that white canvas removed so macOS can tint only + // the Prism mark for light/dark menu bars. + pixels[offset] = 0 + pixels[offset + 1] = 0 + pixels[offset + 2] = 0 + pixels[offset + 3] = Math.round(originalAlpha * ((255 - luminance) / 255)) + } + + writePngRgba(filePath, width, height, pixels) +} + function writeIcoFile(outputPath, images) { const headerLength = 6 const entryLength = 16 @@ -332,6 +355,47 @@ try { icoImages.push({ size, buffer: readFileSync(outputPath) }) } writeIcoFile(join(resourcesDir, 'icon.ico'), icoImages) + + mkdirSync(trayResourcesDir, { recursive: true }) + + const trayTemplateSvgPath = join(tempDir, 'prism-tray-template-source.svg') + writeFileSync(trayTemplateSvgPath, ` + + + + + +`) + execFileSync('qlmanage', ['-t', '-s', '1024', '-o', tempDir, trayTemplateSvgPath], { + stdio: 'ignore', + }) + + const trayTemplateMaster = `${trayTemplateSvgPath}.png` + if (!existsSync(trayTemplateMaster)) { + throw new Error('Quick Look did not create the tray template PNG.') + } + + const trayTemplatePath = join(trayResourcesDir, 'prismTrayTemplate.png') + const trayTemplate2xPath = join(trayResourcesDir, 'prismTrayTemplate@2x.png') + resizePng(trayTemplateMaster, trayTemplatePath, 16) + resizePng(trayTemplateMaster, trayTemplate2xPath, 32) + convertTemplateToAlphaMask(trayTemplatePath) + convertTemplateToAlphaMask(trayTemplate2xPath) + execFileSync('sips', ['-s', 'dpiWidth', '72', '-s', 'dpiHeight', '72', trayTemplatePath], { + stdio: 'ignore', + }) + execFileSync('sips', ['-s', 'dpiWidth', '144', '-s', 'dpiHeight', '144', trayTemplate2xPath], { + stdio: 'ignore', + }) + + resizePng(resourcePngPath, join(trayResourcesDir, 'prism-tray.png'), 24) + const trayIcoImages = [] + for (const size of [16, 20, 24, 32, 40, 48]) { + const outputPath = join(tempDir, `prism-tray-${size}.png`) + resizePng(resourcePngPath, outputPath, size) + trayIcoImages.push({ size, buffer: readFileSync(outputPath) }) + } + writeIcoFile(join(trayResourcesDir, 'prism-tray.ico'), trayIcoImages) } finally { rmSync(tempDir, { recursive: true, force: true }) } diff --git a/scripts/run-desktop-integration-tests.mjs b/scripts/run-desktop-integration-tests.mjs new file mode 100644 index 0000000..e446115 --- /dev/null +++ b/scripts/run-desktop-integration-tests.mjs @@ -0,0 +1,38 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawn } from 'node:child_process' +import { build } from 'esbuild' + +const rootDir = dirname(dirname(fileURLToPath(import.meta.url))) +const tempDir = await mkdtemp(join(tmpdir(), 'prism-desktop-integration-tests-')) +const bundledTestPath = join(tempDir, 'desktop-integration.test.mjs') +const entryPoint = join(rootDir, 'test', 'desktop-integration.test.ts') + +let exitCode = 1 + +try { + await build({ + entryPoints: [entryPoint], + outfile: bundledTestPath, + bundle: true, + platform: 'node', + format: 'esm', + target: 'node23', + sourcemap: 'inline', + }) + + exitCode = await new Promise((resolve) => { + const child = spawn(process.execPath, ['--test', bundledTestPath], { + stdio: 'inherit', + cwd: rootDir, + }) + child.on('exit', (code) => resolve(code ?? 1)) + child.on('error', () => resolve(1)) + }) +} finally { + await rm(tempDir, { recursive: true, force: true }) +} + +process.exit(exitCode) diff --git a/src/main/index.ts b/src/main/index.ts index 11f6c52..b658128 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, nativeTheme, safeStorage, screen, session, shell } from 'electron' +import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, nativeTheme, safeStorage, screen, session, shell, Tray } from 'electron' import type { BrowserWindowConstructorOptions, MenuItemConstructorOptions, OpenDialogOptions, WebContents } from 'electron' import { execFileSync } from 'child_process' import { existsSync, readFileSync } from 'fs' @@ -48,6 +48,31 @@ import { FileBackedWindowStateStore } from './windowStateStore' import type { WindowBackgroundSnapshot, WindowBackgroundState } from '../types/windowState' import type { NativeWindowsMediaAPI } from '../types/nativeWindowsMedia' import type { NativeWindowChromeAPI } from '../types/nativeWindowChrome' +import { + DEFAULT_DESKTOP_INTEGRATION_PREFERENCES, + DEFAULT_TRAY_RENDERER_STATE, + type DesktopIntegrationPreferences, + type DesktopIntegrationSnapshot, + type LoginLaunchMode, + type TrayRendererCommand, + type TrayRendererState, +} from '../types/desktopIntegration' +import { + loadDesktopIntegrationPreferences, + normalizeDesktopIntegrationPreferences, + resolveMainWindowCloseDisposition, + resolveStartHiddenAtLogin, + saveDesktopIntegrationPreferences, + type MainWindowCloseDisposition, +} from './services/desktopIntegrationPrefs' +import { LoginItemService } from './services/loginItem' +import { + buildTrayMenuModel, + createTrayMenuStateKey, + normalizeTrayRendererState, +} from './services/trayMenu' +import { TrayRendererCommandQueue } from './services/trayRendererCommandQueue' +import { resolveTrayAssetPath } from './services/trayAssets' let mainWindow: BrowserWindow | null = null let moveInterval: ReturnType | null = null @@ -62,9 +87,18 @@ let mainWindowBoundsTimer: ReturnType | null = null let mainRendererReady = false let allowMainWindowClose = false let mainWindowClosePending = false +let pendingMainWindowCloseDisposition: MainWindowCloseDisposition | null = null let suppressMainWindowSyncUntil = 0 let mainWindowLogicalBounds: WindowBounds | null = null let windowRecreationPending = false +let isAppQuitting = false +let appHiddenToTray = false +let appTray: Tray | null = null +let latestTrayMenuStateKey: string | null = null +let trayRendererReady = false +let latestTrayRendererState: TrayRendererState = { ...DEFAULT_TRAY_RENDERER_STATE } +const pendingTrayRendererCommands = new TrayRendererCommandQueue() +const customDialogWindows = new Set() const scopePopoutWindows = new Map() const scopePopoutCloseAllowed = new Set() @@ -83,6 +117,16 @@ let windowStateStore: FileBackedWindowStateStore | null = null let secretVault: SecretVault | null = null let nativeWindowsMediaApi: NativeWindowsMediaAPI | null | undefined let nativeWindowChromeApi: NativeWindowChromeAPI | null | undefined +let loginItemService: LoginItemService | null = null +let desktopIntegrationPreferences: DesktopIntegrationPreferences = { + ...DEFAULT_DESKTOP_INTEGRATION_PREFERENCES, +} +let desktopIntegrationSnapshot: DesktopIntegrationSnapshot = { + ...DEFAULT_DESKTOP_INTEGRATION_PREFERENCES, + openAtLogin: false, + loginItemStatus: 'unavailable', + loginItemError: null, +} const WINDOW_DEFAULTS = { width: 900, @@ -106,6 +150,7 @@ const NOW_PLAYING_CONFIG_DEFAULTS = { } const STATIC_APP_ICON_FILENAME = 'icon.png' +const DESKTOP_INTEGRATION_PREFS_FILENAME = 'desktop-integration.json' const MAIN_WINDOW_SYNC_SUPPRESSION_MS = 180 const MAIN_WINDOW_VISIBLE_GRAB_MARGIN = 64 const RESTORED_WINDOW_VISIBLE_MARGIN = 64 @@ -332,6 +377,414 @@ function applyStaticDockIcon(): void { app.dock?.setIcon(icon) } +function getDesktopIntegrationPreferencesPath(): string { + return join(app.getPath('userData'), DESKTOP_INTEGRATION_PREFS_FILENAME) +} + +function isTrayAvailable(): boolean { + return Boolean(appTray && !appTray.isDestroyed()) +} + +function isMainWindowPresented(): boolean { + return Boolean( + mainWindow + && !mainWindow.isDestroyed() + && mainWindow.isVisible() + && !mainWindow.isMinimized() + && !appHiddenToTray, + ) +} + +function broadcastDesktopIntegrationSnapshot(): void { + for (const window of BrowserWindow.getAllWindows()) { + if (window.isDestroyed() || window.webContents.isDestroyed()) continue + window.webContents.send('desktop-integration:changed', desktopIntegrationSnapshot) + } +} + +async function refreshDesktopIntegrationSnapshot(): Promise { + desktopIntegrationSnapshot = loginItemService + ? await loginItemService.getSnapshot(desktopIntegrationPreferences) + : { + ...desktopIntegrationPreferences, + openAtLogin: false, + loginItemStatus: 'unavailable', + loginItemError: null, + } + broadcastDesktopIntegrationSnapshot() + refreshTrayMenu() + return { ...desktopIntegrationSnapshot } +} + +async function updateDesktopIntegrationPreferences( + patch: Partial, +): Promise { + desktopIntegrationPreferences = normalizeDesktopIntegrationPreferences({ + ...desktopIntegrationPreferences, + ...patch, + }) + desktopIntegrationPreferences = await saveDesktopIntegrationPreferences( + getDesktopIntegrationPreferencesPath(), + desktopIntegrationPreferences, + ) + return refreshDesktopIntegrationSnapshot() +} + +async function updateOpenAtLogin(enabled: boolean): Promise { + desktopIntegrationSnapshot = loginItemService + ? await loginItemService.setOpenAtLogin(enabled, desktopIntegrationPreferences) + : await refreshDesktopIntegrationSnapshot() + broadcastDesktopIntegrationSnapshot() + refreshTrayMenu() + return { ...desktopIntegrationSnapshot } +} + +function hidePrismWindowsToTray(): void { + if (!isTrayAvailable()) return + appHiddenToTray = true + stopWindowMoveController() + stopWindowResizeController() + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed() && window.isVisible()) { + window.hide() + } + } + refreshTrayMenu() +} + +function showPrismWindows(): void { + appHiddenToTray = false + if (!mainWindow || mainWindow.isDestroyed()) { + if (!isAppQuitting && app.isReady()) { + createMainWindow() + } + return + } + + if (mainWindow.isMinimized()) { + mainWindow.restore() + } + mainWindow.show() + + for (const window of scopePopoutWindows.values()) { + if (!window.isDestroyed()) window.show() + } + if (nowPlayingConfigWindow && !nowPlayingConfigWindow.isDestroyed()) { + nowPlayingConfigWindow.show() + } + + const visibleDialogs = Array.from(customDialogWindows).filter((window) => !window.isDestroyed()) + for (const window of visibleDialogs) { + window.show() + } + + if (visibleDialogs.length > 0) { + visibleDialogs.at(-1)?.focus() + } else { + mainWindow.focus() + raiseMainWindowAboveNormalPopouts() + } + refreshTrayMenu() +} + +function togglePrismWindowsFromTray(): void { + if (isMainWindowPresented()) { + hidePrismWindowsToTray() + } else { + showPrismWindows() + } +} + +function executeMainWindowCloseDisposition(disposition: MainWindowCloseDisposition): void { + if (disposition === 'hide-to-tray') { + hidePrismWindowsToTray() + return + } + + if (!mainWindow || mainWindow.isDestroyed()) return + allowMainWindowClose = true + mainWindow.close() +} + +function requestMainWindowDisposition(disposition: MainWindowCloseDisposition): void { + if (!mainWindow || mainWindow.isDestroyed()) return + if (!mainRendererReady || mainWindow.webContents.isDestroyed()) { + executeMainWindowCloseDisposition(disposition) + return + } + if (mainWindowClosePending) return + + mainWindowClosePending = true + pendingMainWindowCloseDisposition = disposition + mainWindow.webContents.send('window:close-requested') +} + +function sendTrayRendererCommand(command: TrayRendererCommand, showMainWindow = false): void { + if (showMainWindow) { + showPrismWindows() + } + if ( + trayRendererReady + && mainWindow + && !mainWindow.isDestroyed() + && !mainWindow.webContents.isDestroyed() + ) { + mainWindow.webContents.send('tray-controls:command', command) + return + } + pendingTrayRendererCommands.enqueue(command) +} + +function flushPendingTrayRendererCommands(): void { + if ( + !trayRendererReady + || !mainWindow + || mainWindow.isDestroyed() + || mainWindow.webContents.isDestroyed() + ) return + + pendingTrayRendererCommands.flush((command) => { + mainWindow?.webContents.send('tray-controls:command', command) + }) +} + +function repositionWindowToEdge(targetWindow: BrowserWindow, position: 'top' | 'bottom'): void { + if (!supportsProgrammaticReposition() || targetWindow.isDestroyed()) return + + const display = screen.getDisplayMatching(targetWindow.getBounds()) + const workArea = display.workArea + if (isMainRendererWindow(targetWindow)) { + const logicalBounds = toLogicalBounds(targetWindow) + applyLogicalBounds(targetWindow, { + x: workArea.x, + y: position === 'top' ? workArea.y : workArea.y + workArea.height - logicalBounds.height, + width: workArea.width, + height: logicalBounds.height, + }) + flushRepositionedWindowBounds(targetWindow) + return + } + + const [, height] = targetWindow.getSize() + targetWindow.setPosition(workArea.x, position === 'top' ? workArea.y : workArea.y + workArea.height - height) + targetWindow.setSize(workArea.width, height) + flushRepositionedWindowBounds(targetWindow) +} + +function loginItemStatusLabel(snapshot: DesktopIntegrationSnapshot): string | null { + if (snapshot.loginItemStatus === 'requires-approval') { + return 'Approval required in System Settings' + } + if (snapshot.loginItemStatus === 'blocked') { + return 'Disabled in system startup settings' + } + if (snapshot.loginItemStatus === 'unavailable') { + return app.isPackaged ? 'Open at login is unavailable' : 'Open at login is unavailable in development' + } + if (snapshot.loginItemStatus === 'error') { + return snapshot.loginItemError ?? 'Could not read login settings' + } + return null +} + +function createNativeTrayMenu(model: ReturnType): Electron.Menu { + const profileItems: MenuItemConstructorOptions[] = model.rendererState.profiles.length > 0 + ? model.rendererState.profiles.map((profile) => ({ + label: profile.name, + type: 'radio', + checked: profile.id === model.rendererState.activeProfileId, + enabled: model.rendererReady, + click: () => sendTrayRendererCommand( + { type: 'load-profile', profileId: profile.id }, + model.rendererState.hasUnsavedProfileChanges, + ), + })) + : [{ label: 'Profiles unavailable', enabled: false }] + + const audioSourceItems: MenuItemConstructorOptions[] = [ + { label: 'Output Devices', enabled: false }, + ...model.rendererState.systemSources.map((source): MenuItemConstructorOptions => ({ + label: source.isDefault && !source.label.toLowerCase().includes('default') + ? `${source.label} (Default)` + : source.label, + type: 'radio', + checked: model.rendererState.captureMode === 'system' + && source.id === model.rendererState.selectedSystemSourceId, + enabled: model.rendererReady, + click: () => sendTrayRendererCommand({ type: 'select-system-source', sourceId: source.id }), + })), + { type: 'separator' }, + { label: 'Input Devices', enabled: false }, + ...model.rendererState.inputSources.map((source): MenuItemConstructorOptions => ({ + label: source.label, + type: 'radio', + checked: model.rendererState.captureMode === 'device' + && (source.id || null) === model.rendererState.selectedDeviceId, + enabled: model.rendererReady, + click: () => sendTrayRendererCommand({ + type: 'select-input-source', + deviceId: source.id || null, + }), + })), + ] + if (model.rendererState.systemSources.length === 0) { + audioSourceItems.splice(1, 0, { label: 'No outputs available', enabled: false }) + } + if (model.rendererState.inputSources.length === 0) { + audioSourceItems.push({ label: 'No inputs available', enabled: false }) + } + + const loginStatus = loginItemStatusLabel(model.desktopIntegration) + const windowItems: MenuItemConstructorOptions[] = [ + { + label: 'Always on Top', + type: 'checkbox', + checked: model.alwaysOnTop, + click: () => { + if (mainWindow && !mainWindow.isDestroyed()) { + setWindowAlwaysOnTop(mainWindow, !mainWindow.isAlwaysOnTop()) + refreshTrayMenu() + } + }, + }, + { + label: 'Position', + enabled: model.supportsReposition, + submenu: [ + { + label: 'Top', + click: () => { + if (mainWindow && !mainWindow.isDestroyed()) repositionWindowToEdge(mainWindow, 'top') + }, + }, + { + label: 'Bottom', + click: () => { + if (mainWindow && !mainWindow.isDestroyed()) repositionWindowToEdge(mainWindow, 'bottom') + }, + }, + ], + }, + { type: 'separator' }, + { + label: 'Close to Tray', + type: 'checkbox', + checked: model.desktopIntegration.closeToTray, + click: () => { + void updateDesktopIntegrationPreferences({ + closeToTray: !model.desktopIntegration.closeToTray, + }) + }, + }, + { + label: 'Open at Login', + type: 'checkbox', + checked: model.desktopIntegration.openAtLogin, + enabled: model.desktopIntegration.loginItemStatus !== 'unavailable', + click: () => { + void updateOpenAtLogin(!model.desktopIntegration.openAtLogin) + }, + }, + { + label: 'When Opened at Login', + enabled: model.desktopIntegration.openAtLogin, + submenu: [ + { + label: 'Show Prism', + type: 'radio', + checked: model.desktopIntegration.loginLaunchMode === 'show', + click: () => void updateDesktopIntegrationPreferences({ loginLaunchMode: 'show' }), + }, + { + label: 'Start in Tray', + type: 'radio', + checked: model.desktopIntegration.loginLaunchMode === 'tray', + click: () => void updateDesktopIntegrationPreferences({ loginLaunchMode: 'tray' }), + }, + ], + }, + ] + if (loginStatus) { + windowItems.push({ type: 'separator' }, { label: loginStatus, enabled: false }) + } + + return Menu.buildFromTemplate([ + { label: model.statusLabel, enabled: false }, + { label: model.mainWindowActionLabel, click: togglePrismWindowsFromTray }, + { type: 'separator' }, + { label: 'Profile', submenu: profileItems }, + { label: 'Audio Source', submenu: audioSourceItems }, + { + label: model.captureActionLabel, + enabled: model.captureActionEnabled, + click: () => sendTrayRendererCommand({ + type: 'set-capture-running', + running: model.captureActionLabel === 'Start Capture', + }), + }, + { label: 'Window', submenu: windowItems }, + { type: 'separator' }, + { + label: 'Settings…', + click: () => sendTrayRendererCommand({ type: 'open-settings' }, true), + }, + { + label: 'Quit Prism', + click: () => { + isAppQuitting = true + app.quit() + }, + }, + ]) +} + +function refreshTrayMenu(): void { + if (!isTrayAvailable()) return + const model = buildTrayMenuModel({ + mainWindowVisible: isMainWindowPresented(), + rendererReady: trayRendererReady, + rendererState: latestTrayRendererState, + desktopIntegration: desktopIntegrationSnapshot, + alwaysOnTop: Boolean(mainWindow && !mainWindow.isDestroyed() && mainWindow.isAlwaysOnTop()), + supportsReposition: supportsProgrammaticReposition(), + }) + const stateKey = createTrayMenuStateKey(model) + appTray!.setToolTip(model.tooltip) + if (stateKey !== latestTrayMenuStateKey) { + appTray!.setContextMenu(createNativeTrayMenu(model)) + latestTrayMenuStateKey = stateKey + } +} + +function createAppTray(): void { + if (isTrayAvailable()) { + refreshTrayMenu() + return + } + const image = nativeImage.createFromPath(resolveTrayAssetPath({ + platform: process.platform, + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + appPath: app.getAppPath(), + })) + if (image.isEmpty()) { + console.warn('Prism tray icon asset is unavailable.') + return + } + if (process.platform === 'darwin') image.setTemplateImage(true) + appTray = new Tray(image) + if (process.platform !== 'darwin') { + appTray.on('click', togglePrismWindowsFromTray) + } + refreshTrayMenu() +} + +function destroyAppTray(): void { + if (appTray && !appTray.isDestroyed()) appTray.destroy() + appTray = null + latestTrayMenuStateKey = null +} + function broadcastNowPlayingState(state: NowPlayingState): void { for (const window of BrowserWindow.getAllWindows()) { if (window.isDestroyed() || window.webContents.isDestroyed()) continue @@ -454,13 +907,7 @@ function extractProfilePathsFromArgv(argv: string[]): string[] { } function focusMainWindow(): void { - if (!mainWindow) return - if (mainWindow.isMinimized()) { - mainWindow.restore() - } - mainWindow.show() - mainWindow.focus() - raiseMainWindowAboveNormalPopouts() + showPrismWindows() } function normalizeExternalHttpUrl(raw: string): string | null { @@ -1132,6 +1579,7 @@ async function showCustomDialog(options: DialogOptions): Promise { nodeIntegration: false, }, }) + customDialogWindows.add(win) win.center() loadRendererTarget(win, { mode: 'dialog' }) @@ -1146,10 +1594,11 @@ async function showCustomDialog(options: DialogOptions): Promise { win.webContents.once('did-finish-load', () => { win.webContents.send('dialog:config', options) - win.show() + if (!appHiddenToTray) win.show() }) win.once('closed', () => { + customDialogWindows.delete(win) ipcMain.removeListener('dialog:result', onResult) resolve({ buttonIndex: options.cancelId ?? options.buttons.length - 1 }) }) @@ -1171,6 +1620,7 @@ function createMainWindow(restoreBounds?: WindowBounds): void { resizable: true, maximizable: true, fullscreenable: true, + show: !appHiddenToTray, title: 'Prism', ...getStaticWindowIconOptions(), webPreferences: { @@ -1189,19 +1639,20 @@ function createMainWindow(restoreBounds?: WindowBounds): void { syncMainWindowLogicalBounds(mainWindow) mainWindow.on('close', (event) => { - if (allowMainWindowClose || !mainRendererReady || mainWindow?.webContents.isDestroyed()) { + if (allowMainWindowClose || isAppQuitting || mainWindow?.webContents.isDestroyed()) { allowMainWindowClose = false mainWindowClosePending = false + pendingMainWindowCloseDisposition = null return } event.preventDefault() - if (mainWindowClosePending) { - return - } - - mainWindowClosePending = true - mainWindow?.webContents.send('window:close-requested') + requestMainWindowDisposition(resolveMainWindowCloseDisposition({ + closeToTray: desktopIntegrationPreferences.closeToTray, + isAppQuitting, + trayAvailable: isTrayAvailable(), + windowRecreationPending, + })) }) mainWindow.on('closed', () => { @@ -1220,8 +1671,12 @@ function createMainWindow(restoreBounds?: WindowBounds): void { mainWindowLogicalBounds = null suppressMainWindowSyncUntil = 0 mainRendererReady = false + trayRendererReady = false + latestTrayRendererState = { ...DEFAULT_TRAY_RENDERER_STATE } + pendingTrayRendererCommands.clear() allowMainWindowClose = false mainWindowClosePending = false + pendingMainWindowCloseDisposition = null mainWindow = null for (const kind of SCOPE_KINDS) { @@ -1230,6 +1685,7 @@ function createMainWindow(restoreBounds?: WindowBounds): void { if (nowPlayingConfigWindow && !nowPlayingConfigWindow.isDestroyed()) { nowPlayingConfigWindow.close() } + refreshTrayMenu() }) mainWindow.on('move', () => { @@ -1249,6 +1705,14 @@ function createMainWindow(restoreBounds?: WindowBounds): void { mainWindow.on('focus', () => { raiseMainWindowAboveNormalPopouts() }) + mainWindow.on('show', refreshTrayMenu) + mainWindow.on('hide', refreshTrayMenu) + mainWindow.on('minimize', refreshTrayMenu) + mainWindow.on('restore', refreshTrayMenu) + mainWindow.webContents.on('did-start-loading', () => { + trayRendererReady = false + refreshTrayMenu() + }) loadRendererTarget(mainWindow, { window: 'main', ...getWindowBackgroundQuery(background) }) } @@ -1405,7 +1869,7 @@ function createScopePopoutWindow(kind: ScopeKind, rawBounds?: WindowBounds): Bro scopePopoutWindows.set(kind, popoutWindow) popoutWindow.once('ready-to-show', () => { - if (!popoutWindow.isDestroyed()) { + if (!popoutWindow.isDestroyed() && !appHiddenToTray) { popoutWindow.show() raiseMainWindowAboveNormalPopouts() } @@ -1574,7 +2038,7 @@ function createNowPlayingConfigWindow(): BrowserWindow { const configWindow = nowPlayingConfigWindow configWindow.once('ready-to-show', () => { - if (!configWindow.isDestroyed()) { + if (!configWindow.isDestroyed() && !appHiddenToTray) { configWindow.show() configWindow.focus() } @@ -1719,13 +2183,13 @@ function setupIPC(): void { const targetWindow = getWindowFromSender(event.sender) if (!targetWindow || !isMainRendererWindow(targetWindow)) return + const disposition = pendingMainWindowCloseDisposition ?? 'close' mainWindowClosePending = false + pendingMainWindowCloseDisposition = null if (!shouldClose) { return } - - allowMainWindowClose = true - targetWindow.close() + executeMainWindowCloseDisposition(disposition) }) ipcMain.on('window:toggle-always-on-top', (event) => { @@ -2004,39 +2468,9 @@ function setupIPC(): void { }) ipcMain.on('window:reposition', (event, position: 'top' | 'bottom') => { - if (!supportsProgrammaticReposition()) { - return - } - const targetWindow = getWindowFromSender(event.sender) if (!targetWindow) return - - const display = screen.getDisplayMatching(targetWindow.getBounds()) - const workArea = display.workArea - - if (isMainRendererWindow(targetWindow)) { - const logicalBounds = toLogicalBounds(targetWindow) - applyLogicalBounds(targetWindow, { - x: workArea.x, - y: position === 'top' - ? workArea.y - : workArea.y + workArea.height - logicalBounds.height, - width: workArea.width, - height: logicalBounds.height, - }) - flushRepositionedWindowBounds(targetWindow) - return - } - - const [, height] = targetWindow.getSize() - - if (position === 'top') { - targetWindow.setPosition(workArea.x, workArea.y) - } else { - targetWindow.setPosition(workArea.x, workArea.y + workArea.height - height) - } - targetWindow.setSize(workArea.width, height) - flushRepositionedWindowBounds(targetWindow) + repositionWindowToEdge(targetWindow, position) }) ipcMain.on('window:expand-settings', (event, panelHeight: number) => { @@ -2060,6 +2494,46 @@ function setupIPC(): void { applySettingsHeight(targetWindow, panelHeight) }) + ipcMain.handle('desktop-integration:get', async () => { + return refreshDesktopIntegrationSnapshot() + }) + + ipcMain.handle('desktop-integration:set-close-to-tray', async (_event, enabled: unknown) => { + return updateDesktopIntegrationPreferences({ closeToTray: enabled === true }) + }) + + ipcMain.handle('desktop-integration:set-open-at-login', async (_event, enabled: unknown) => { + return updateOpenAtLogin(enabled === true) + }) + + ipcMain.handle('desktop-integration:set-login-launch-mode', async (_event, mode: unknown) => { + const loginLaunchMode: LoginLaunchMode = mode === 'tray' ? 'tray' : 'show' + return updateDesktopIntegrationPreferences({ loginLaunchMode }) + }) + + ipcMain.on('tray-controls:renderer-ready', (event) => { + const targetWindow = getWindowFromSender(event.sender) + if (!isMainRendererWindow(targetWindow)) return + trayRendererReady = true + flushPendingTrayRendererCommands() + refreshTrayMenu() + }) + + ipcMain.on('tray-controls:renderer-not-ready', (event) => { + const targetWindow = getWindowFromSender(event.sender) + if (!isMainRendererWindow(targetWindow)) return + trayRendererReady = false + latestTrayRendererState = { ...DEFAULT_TRAY_RENDERER_STATE } + refreshTrayMenu() + }) + + ipcMain.on('tray-controls:publish-state', (event, rawState: unknown) => { + const targetWindow = getWindowFromSender(event.sender) + if (!isMainRendererWindow(targetWindow)) return + latestTrayRendererState = normalizeTrayRendererState(rawState) + refreshTrayMenu() + }) + ipcMain.on('renderer:ready', (event) => { const targetWindow = getWindowFromSender(event.sender) if (!isMainRendererWindow(targetWindow)) return @@ -2127,15 +2601,36 @@ const hasSingleInstanceLock = app.requestSingleInstanceLock() if (!hasSingleInstanceLock) { app.quit() } else { + queueProfileOpenPaths(extractProfilePathsFromArgv(process.argv)) + app.whenReady().then(async () => { setupPermissions() void getNowPlayingManager().initialize() setupIPC() await getWindowStateStore().initialize() + desktopIntegrationPreferences = await loadDesktopIntegrationPreferences( + getDesktopIntegrationPreferencesPath(), + ) + loginItemService = new LoginItemService({ + platform: process.platform, + isPackaged: app.isPackaged, + executablePath: process.execPath, + appImagePath: process.env.APPIMAGE, + configHome: process.env.XDG_CONFIG_HOME, + homePath: app.getPath('home'), + getNativeSettings: (options) => app.getLoginItemSettings(options), + setNativeSettings: (settings) => app.setLoginItemSettings(settings), + }) + desktopIntegrationSnapshot = await loginItemService.getSnapshot(desktopIntegrationPreferences) await syncNativeThemeAppearance() applyStaticDockIcon() + createAppTray() + appHiddenToTray = isTrayAvailable() && resolveStartHiddenAtLogin({ + isLoginLaunch: loginItemService.wasOpenedAtLogin(process.argv), + loginLaunchMode: desktopIntegrationPreferences.loginLaunchMode, + hasPendingFileOpen: pendingProfileOpenPaths.length > 0, + }) createMainWindow() - queueProfileOpenPaths(extractProfilePathsFromArgv(process.argv)) void processPendingProfileOpenPaths() }) @@ -2154,6 +2649,10 @@ if (!hasSingleInstanceLock) { void processPendingProfileOpenPaths() } }) + + app.on('activate', () => { + if (app.isReady()) showPrismWindows() + }) } app.on('window-all-closed', () => { @@ -2164,3 +2663,9 @@ app.on('window-all-closed', () => { void nowPlayingManager?.dispose() app.quit() }) + +app.on('before-quit', () => { + isAppQuitting = true + destroyAppTray() + pendingTrayRendererCommands.clear() +}) diff --git a/src/main/services/desktopIntegrationPrefs.ts b/src/main/services/desktopIntegrationPrefs.ts new file mode 100644 index 0000000..1d41636 --- /dev/null +++ b/src/main/services/desktopIntegrationPrefs.ts @@ -0,0 +1,64 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' +import { + DEFAULT_DESKTOP_INTEGRATION_PREFERENCES, + type DesktopIntegrationPreferences, +} from '../../types/desktopIntegration' + +export function normalizeDesktopIntegrationPreferences(value: unknown): DesktopIntegrationPreferences { + if (!value || typeof value !== 'object') { + return { ...DEFAULT_DESKTOP_INTEGRATION_PREFERENCES } + } + + const candidate = value as Partial + return { + closeToTray: candidate.closeToTray === true, + loginLaunchMode: candidate.loginLaunchMode === 'tray' ? 'tray' : 'show', + } +} + +export async function loadDesktopIntegrationPreferences( + filePath: string, +): Promise { + try { + return normalizeDesktopIntegrationPreferences(JSON.parse(await readFile(filePath, 'utf8'))) + } catch { + return { ...DEFAULT_DESKTOP_INTEGRATION_PREFERENCES } + } +} + +export async function saveDesktopIntegrationPreferences( + filePath: string, + preferences: DesktopIntegrationPreferences, +): Promise { + const normalized = normalizeDesktopIntegrationPreferences(preferences) + await mkdir(dirname(filePath), { recursive: true }) + await writeFile(filePath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8') + return normalized +} + +export type MainWindowCloseDisposition = 'hide-to-tray' | 'close' + +export function resolveMainWindowCloseDisposition(options: { + closeToTray: boolean + isAppQuitting: boolean + trayAvailable: boolean + windowRecreationPending: boolean +}): MainWindowCloseDisposition { + return options.closeToTray + && !options.isAppQuitting + && !options.windowRecreationPending + && options.trayAvailable + ? 'hide-to-tray' + : 'close' +} + +export function resolveStartHiddenAtLogin(options: { + isLoginLaunch: boolean + loginLaunchMode: DesktopIntegrationPreferences['loginLaunchMode'] + hasPendingFileOpen: boolean +}): boolean { + return options.isLoginLaunch + && options.loginLaunchMode === 'tray' + && !options.hasPendingFileOpen +} diff --git a/src/main/services/loginItem.ts b/src/main/services/loginItem.ts new file mode 100644 index 0000000..f5dfce6 --- /dev/null +++ b/src/main/services/loginItem.ts @@ -0,0 +1,224 @@ +import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises' +import { dirname, isAbsolute, join } from 'node:path' +import type { + DesktopIntegrationPreferences, + DesktopIntegrationSnapshot, + LoginItemStatus, +} from '../../types/desktopIntegration' + +export const LOGIN_LAUNCH_ARG = '--prism-login-launch' +const LINUX_AUTOSTART_FILENAME = 'com.astra.prism.desktop' + +interface NativeLoginItemSettings { + openAtLogin?: boolean + wasOpenedAtLogin?: boolean + status?: 'not-registered' | 'enabled' | 'requires-approval' | 'not-found' + executableWillLaunchAtLogin?: boolean +} + +interface LoginItemServiceOptions { + platform: NodeJS.Platform + isPackaged: boolean + executablePath: string + appImagePath?: string + configHome?: string + homePath: string + getNativeSettings?: (options?: { path?: string; args?: string[] }) => NativeLoginItemSettings + setNativeSettings?: (settings: { + openAtLogin: boolean + path?: string + args?: string[] + type?: 'mainAppService' + }) => void +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error && error.message ? error.message : 'Login item operation failed.' +} + +export function resolveLinuxAutostartPath(configHome: string | undefined, homePath: string): string { + const resolvedConfigHome = configHome && isAbsolute(configHome) + ? configHome + : join(homePath, '.config') + return join(resolvedConfigHome, 'autostart', LINUX_AUTOSTART_FILENAME) +} + +export function resolveLinuxLaunchExecutable(appImagePath: string | undefined, executablePath: string): string { + return appImagePath && isAbsolute(appImagePath) ? appImagePath : executablePath +} + +export function quoteDesktopExecArgument(value: string): string { + const escaped = value.replace(/[\\"`$]/g, (character) => `\\${character}`) + return `"${escaped}"` +} + +export function buildLinuxAutostartEntry(executablePath: string): string { + const executable = quoteDesktopExecArgument(executablePath) + return [ + '[Desktop Entry]', + 'Type=Application', + 'Version=1.0', + 'Name=Prism', + 'Comment=Open Prism at login', + `TryExec=${executable}`, + `Exec=${executable} ${LOGIN_LAUNCH_ARG}`, + 'Terminal=false', + 'Hidden=false', + 'X-GNOME-Autostart-enabled=true', + '', + ].join('\n') +} + +export function resolveNativeLoginItemStatus( + platform: NodeJS.Platform, + settings: NativeLoginItemSettings, +): LoginItemStatus { + if (platform === 'darwin') { + if (settings.status === 'requires-approval') return 'requires-approval' + return settings.openAtLogin || settings.status === 'enabled' ? 'enabled' : 'disabled' + } + + if (platform === 'win32') { + if (!settings.openAtLogin) return 'disabled' + return settings.executableWillLaunchAtLogin === false ? 'blocked' : 'enabled' + } + + return 'unavailable' +} + +export function isLoginLaunch(platform: NodeJS.Platform, argv: string[], nativeSettings?: NativeLoginItemSettings): boolean { + return platform === 'darwin' + ? nativeSettings?.wasOpenedAtLogin === true + : argv.includes(LOGIN_LAUNCH_ARG) +} + +export class LoginItemService { + constructor(private readonly options: LoginItemServiceOptions) {} + + isSupported(): boolean { + return this.options.isPackaged + && (this.options.platform === 'darwin' + || this.options.platform === 'win32' + || this.options.platform === 'linux') + } + + wasOpenedAtLogin(argv: string[]): boolean { + if (!this.isSupported()) return false + try { + return isLoginLaunch( + this.options.platform, + argv, + this.options.platform === 'darwin' ? this.options.getNativeSettings?.() : undefined, + ) + } catch { + return false + } + } + + async getSnapshot( + preferences: DesktopIntegrationPreferences, + ): Promise { + if (!this.isSupported()) { + return { + ...preferences, + openAtLogin: false, + loginItemStatus: 'unavailable', + loginItemError: null, + } + } + + try { + if (this.options.platform === 'linux') { + const filePath = resolveLinuxAutostartPath(this.options.configHome, this.options.homePath) + let contents = '' + try { + contents = await readFile(filePath, 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + return { + ...preferences, + openAtLogin: false, + loginItemStatus: 'disabled', + loginItemError: null, + } + } + const enabled = !/^\s*Hidden\s*=\s*true\s*$/im.test(contents) + return { + ...preferences, + openAtLogin: enabled, + loginItemStatus: enabled ? 'enabled' : 'disabled', + loginItemError: null, + } + } + + const nativeOptions = this.options.platform === 'win32' + ? { path: this.options.executablePath, args: [LOGIN_LAUNCH_ARG] } + : undefined + const settings = this.options.getNativeSettings?.(nativeOptions) ?? {} + const loginItemStatus = resolveNativeLoginItemStatus(this.options.platform, settings) + return { + ...preferences, + openAtLogin: settings.openAtLogin === true + || loginItemStatus === 'enabled' + || loginItemStatus === 'requires-approval' + || loginItemStatus === 'blocked', + loginItemStatus, + loginItemError: null, + } + } catch (error) { + return { + ...preferences, + openAtLogin: false, + loginItemStatus: 'error', + loginItemError: getErrorMessage(error), + } + } + } + + async setOpenAtLogin( + enabled: boolean, + preferences: DesktopIntegrationPreferences, + ): Promise { + if (!this.isSupported()) { + return this.getSnapshot(preferences) + } + + try { + if (this.options.platform === 'linux') { + const filePath = resolveLinuxAutostartPath(this.options.configHome, this.options.homePath) + if (enabled) { + const executablePath = resolveLinuxLaunchExecutable( + this.options.appImagePath, + this.options.executablePath, + ) + await mkdir(dirname(filePath), { recursive: true }) + await writeFile(filePath, buildLinuxAutostartEntry(executablePath), 'utf8') + } else { + await unlink(filePath).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') throw error + }) + } + } else if (this.options.platform === 'win32') { + this.options.setNativeSettings?.({ + openAtLogin: enabled, + path: this.options.executablePath, + args: [LOGIN_LAUNCH_ARG], + }) + } else { + this.options.setNativeSettings?.({ + openAtLogin: enabled, + type: 'mainAppService', + }) + } + } catch (error) { + const current = await this.getSnapshot(preferences) + return { + ...current, + loginItemStatus: 'error', + loginItemError: getErrorMessage(error), + } + } + + return this.getSnapshot(preferences) + } +} diff --git a/src/main/services/trayAssets.ts b/src/main/services/trayAssets.ts new file mode 100644 index 0000000..f089d5e --- /dev/null +++ b/src/main/services/trayAssets.ts @@ -0,0 +1,19 @@ +import { join } from 'node:path' + +export function getTrayAssetFilename(platform: NodeJS.Platform): string { + if (platform === 'darwin') return 'prismTrayTemplate.png' + if (platform === 'win32') return 'prism-tray.ico' + return 'prism-tray.png' +} + +export function resolveTrayAssetPath(options: { + platform: NodeJS.Platform + isPackaged: boolean + resourcesPath: string + appPath: string +}): string { + const directory = options.isPackaged + ? join(options.resourcesPath, 'tray') + : join(options.appPath, 'resources', 'tray') + return join(directory, getTrayAssetFilename(options.platform)) +} diff --git a/src/main/services/trayMenu.ts b/src/main/services/trayMenu.ts new file mode 100644 index 0000000..fdf5c08 --- /dev/null +++ b/src/main/services/trayMenu.ts @@ -0,0 +1,122 @@ +import { + DEFAULT_TRAY_RENDERER_STATE, + type DesktopIntegrationSnapshot, + type TrayAudioSourceOption, + type TrayProfileOption, + type TrayRendererState, +} from '../../types/desktopIntegration' + +const MAX_TRAY_ITEMS = 64 +const MAX_LABEL_LENGTH = 96 + +export interface TrayMenuState { + mainWindowVisible: boolean + rendererReady: boolean + rendererState: TrayRendererState + desktopIntegration: DesktopIntegrationSnapshot + alwaysOnTop: boolean + supportsReposition: boolean +} + +export interface TrayMenuModel extends TrayMenuState { + statusLabel: string + tooltip: string + mainWindowActionLabel: 'Show Prism' | 'Hide Prism' + captureActionLabel: 'Start Capture' | 'Stop Capture' + captureActionEnabled: boolean +} + +function normalizeText(value: unknown, fallback: string): string { + if (typeof value !== 'string') return fallback + const trimmed = value.trim() + if (!trimmed) return fallback + return trimmed.slice(0, MAX_LABEL_LENGTH) +} + +function normalizeProfiles(value: unknown): TrayProfileOption[] { + if (!Array.isArray(value)) return [] + return value.slice(0, MAX_TRAY_ITEMS).flatMap((raw) => { + if (!raw || typeof raw !== 'object') return [] + const candidate = raw as Partial + if (typeof candidate.id !== 'string' || !candidate.id.trim()) return [] + return [{ + id: candidate.id, + name: normalizeText(candidate.name, 'Profile'), + }] + }) +} + +function normalizeSources(value: unknown): TrayAudioSourceOption[] { + if (!Array.isArray(value)) return [] + return value.slice(0, MAX_TRAY_ITEMS).flatMap((raw) => { + if (!raw || typeof raw !== 'object') return [] + const candidate = raw as Partial + if (typeof candidate.id !== 'string') return [] + return [{ + id: candidate.id, + label: normalizeText(candidate.label, 'Audio Source'), + isDefault: candidate.isDefault === true, + }] + }) +} + +export function normalizeTrayRendererState(value: unknown): TrayRendererState { + if (!value || typeof value !== 'object') { + return { ...DEFAULT_TRAY_RENDERER_STATE } + } + + const candidate = value as Partial + const captureStatus = candidate.captureStatus === 'connecting' + || candidate.captureStatus === 'capturing' + || candidate.captureStatus === 'error' + ? candidate.captureStatus + : 'idle' + + return { + profiles: normalizeProfiles(candidate.profiles), + activeProfileId: typeof candidate.activeProfileId === 'string' ? candidate.activeProfileId : null, + hasUnsavedProfileChanges: candidate.hasUnsavedProfileChanges === true, + captureStatus, + activeSourceLabel: typeof candidate.activeSourceLabel === 'string' + ? normalizeText(candidate.activeSourceLabel, 'Audio Source') + : null, + captureMode: candidate.captureMode === 'device' ? 'device' : 'system', + selectedSystemSourceId: typeof candidate.selectedSystemSourceId === 'string' + ? candidate.selectedSystemSourceId + : null, + selectedDeviceId: typeof candidate.selectedDeviceId === 'string' + ? candidate.selectedDeviceId + : null, + systemSources: normalizeSources(candidate.systemSources), + inputSources: normalizeSources(candidate.inputSources), + } +} + +export function buildTrayMenuModel(state: TrayMenuState): TrayMenuModel { + const sourceLabel = state.rendererState.activeSourceLabel + const statusText = state.rendererState.captureStatus === 'capturing' + ? sourceLabel ? `Capturing · ${sourceLabel}` : 'Capturing' + : state.rendererState.captureStatus === 'connecting' + ? 'Connecting to audio…' + : state.rendererState.captureStatus === 'error' + ? 'Audio capture error' + : 'Capture stopped' + + return { + ...state, + statusLabel: `Prism — ${statusText}`, + tooltip: sourceLabel && state.rendererState.captureStatus === 'capturing' + ? `Prism — ${sourceLabel}` + : 'Prism', + mainWindowActionLabel: state.mainWindowVisible ? 'Hide Prism' : 'Show Prism', + captureActionLabel: state.rendererState.captureStatus === 'capturing' + || state.rendererState.captureStatus === 'connecting' + ? 'Stop Capture' + : 'Start Capture', + captureActionEnabled: state.rendererReady, + } +} + +export function createTrayMenuStateKey(model: TrayMenuModel): string { + return JSON.stringify(model) +} diff --git a/src/main/services/trayRendererCommandQueue.ts b/src/main/services/trayRendererCommandQueue.ts new file mode 100644 index 0000000..f0c4380 --- /dev/null +++ b/src/main/services/trayRendererCommandQueue.ts @@ -0,0 +1,20 @@ +import type { TrayRendererCommand } from '../../types/desktopIntegration' + +export class TrayRendererCommandQueue { + private commands: TrayRendererCommand[] = [] + + enqueue(command: TrayRendererCommand): void { + this.commands.push(command) + } + + flush(send: (command: TrayRendererCommand) => void): void { + for (const command of this.commands) { + send(command) + } + this.commands = [] + } + + clear(): void { + this.commands = [] + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 363711e..457da30 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -33,6 +33,12 @@ import type { UpdateCheckResult } from '../types/updates' import type { WindowCapabilities } from '../types/windowCapabilities' import type { ResizeDirection } from '../types/windowResize' import type { WindowBackgroundSnapshot, WindowBackgroundState } from '../types/windowState' +import type { + DesktopIntegrationSnapshot, + LoginLaunchMode, + TrayRendererCommand, + TrayRendererState, +} from '../types/desktopIntegration' import type { VisualizerDSP } from '../renderer/audio/native/visualizer-dsp' import { resolveWindowCapabilities } from '../shared/windowCapabilities' import { getCaptureBackendSupport } from './captureSupport' @@ -52,6 +58,38 @@ contextBridge.exposeInMainWorld('electronAPI', { getAppBuildInfo: () => ipcRenderer.invoke('app:get-build-info') as Promise, minimize: () => ipcRenderer.send('window:minimize'), close: () => ipcRenderer.send('window:close'), + desktopIntegration: { + get: () => ipcRenderer.invoke('desktop-integration:get') as Promise, + setCloseToTray: (enabled: boolean) => ipcRenderer.invoke( + 'desktop-integration:set-close-to-tray', + enabled, + ) as Promise, + setOpenAtLogin: (enabled: boolean) => ipcRenderer.invoke( + 'desktop-integration:set-open-at-login', + enabled, + ) as Promise, + setLoginLaunchMode: (mode: LoginLaunchMode) => ipcRenderer.invoke( + 'desktop-integration:set-login-launch-mode', + mode, + ) as Promise, + onChanged: (callback: (snapshot: DesktopIntegrationSnapshot) => void) => { + const handler = (_event: Electron.IpcRendererEvent, snapshot: DesktopIntegrationSnapshot): void => { + callback(snapshot) + } + ipcRenderer.on('desktop-integration:changed', handler) + return () => ipcRenderer.removeListener('desktop-integration:changed', handler) + }, + }, + trayControls: { + markReady: () => ipcRenderer.send('tray-controls:renderer-ready'), + markNotReady: () => ipcRenderer.send('tray-controls:renderer-not-ready'), + publishState: (state: TrayRendererState) => ipcRenderer.send('tray-controls:publish-state', state), + onCommand: (callback: (command: TrayRendererCommand) => void) => { + const handler = (_event: Electron.IpcRendererEvent, command: TrayRendererCommand): void => callback(command) + ipcRenderer.on('tray-controls:command', handler) + return () => ipcRenderer.removeListener('tray-controls:command', handler) + }, + }, startWindowMove: () => ipcRenderer.send('window:start-move'), stopWindowMove: () => ipcRenderer.send('window:stop-move'), startWindowResize: (edge: ResizeDirection) => ipcRenderer.send('window:start-resize', edge), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index eecafd6..5d7b567 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -6,6 +6,7 @@ import BottomBar from './components/BottomBar' import ScopePopoutBridge from './components/ScopePopoutBridge' import AppBanner from './components/AppBanner' import WindowResizeOverlay from './components/WindowResizeOverlay' +import TrayControlBridge from './components/TrayControlBridge' import { resolveMainWindowSettingsHeight } from './mainWindowSettings' import { useSettingsStore } from './stores/settingsStore' import { startAudioDeviceWatcher, useAudioStore } from './stores/audioStore' @@ -14,12 +15,14 @@ import { useThemeStore } from './stores/themeStore' import { useUiStore } from './stores/uiStore' import { useWindowBackgroundStore } from './stores/windowBackgroundStore' import { useUpdateStore } from './stores/updateStore' +import { useDesktopIntegrationStore } from './stores/desktopIntegrationStore' import { getRendererWindowCapabilities } from './windowCapabilities' export default function App(): JSX.Element { const [toolbarVisible, setToolbarVisible] = useState(false) const [settingsPanelHeight, setSettingsPanelHeight] = useState(0) const [bottomBarHeight, setBottomBarHeight] = useState(0) + const [trayReady, setTrayReady] = useState(false) const hideTimeoutRef = useRef | null>(null) const settingsOpenRef = useRef(false) const externalProfileOpenQueueRef = useRef(Promise.resolve()) @@ -43,6 +46,8 @@ export default function App(): JSX.Element { const initializeWindowBackground = useWindowBackgroundStore((s) => s.initialize) const windowBackgroundMode = useWindowBackgroundStore((s) => s.effective.mode) const useNativeDragRegions = getRendererWindowCapabilities().useNativeDragRegions + const initializeDesktopIntegration = useDesktopIntegrationStore((s) => s.initialize) + const applyDesktopIntegrationSnapshot = useDesktopIntegrationStore((s) => s.applySnapshot) const isNowPlayingVisible = !hiddenScopes.has('nowPlaying') && (scopeOrder.includes('nowPlaying') || scopePopouts.nowPlaying?.poppedOut === true) @@ -75,6 +80,7 @@ export default function App(): JSX.Element { await initializeProfiles() await initializeNowPlaying() if (!isDisposed) { + setTrayReady(true) window.electronAPI.notifyRendererReady() } })() @@ -155,6 +161,11 @@ export default function App(): JSX.Element { updateMainWindowBounds, ]) + useEffect(() => { + void initializeDesktopIntegration() + return window.electronAPI.desktopIntegration.onChanged(applyDesktopIntegrationSnapshot) + }, [applyDesktopIntegrationSnapshot, initializeDesktopIntegration]) + useEffect(() => { void initializeNowPlaying() .then(() => setNowPlayingConsumerActive(isNowPlayingVisible)) @@ -301,6 +312,7 @@ export default function App(): JSX.Element { + diff --git a/src/renderer/components/BottomBar.tsx b/src/renderer/components/BottomBar.tsx index 9ed7043..0969f17 100644 --- a/src/renderer/components/BottomBar.tsx +++ b/src/renderer/components/BottomBar.tsx @@ -6,6 +6,7 @@ import { useSettingsStore } from '../stores/settingsStore' import { useThemeStore } from '../stores/themeStore' import { useUiStore } from '../stores/uiStore' import { useWindowBackgroundStore } from '../stores/windowBackgroundStore' +import { useDesktopIntegrationStore } from '../stores/desktopIntegrationStore' import { getRendererWindowCapabilities } from '../windowCapabilities' import { getHorizontalWheelScrollResult } from '../utils/horizontalWheelScroll' import type { ScopeKind } from '../../types/scope' @@ -184,6 +185,12 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): setInputGain, } = useAudioStore() const showBanner = useUiStore((s) => s.showBanner) + const desktopIntegration = useDesktopIntegrationStore((s) => s.snapshot) + const desktopIntegrationBusy = useDesktopIntegrationStore((s) => s.busy) + const desktopIntegrationError = useDesktopIntegrationStore((s) => s.error) + const setCloseToTray = useDesktopIntegrationStore((s) => s.setCloseToTray) + const setOpenAtLogin = useDesktopIntegrationStore((s) => s.setOpenAtLogin) + const setLoginLaunchMode = useDesktopIntegrationStore((s) => s.setLoginLaunchMode) useLayoutEffect(() => { if (!onHeightChange || !rootRef.current) return @@ -389,6 +396,13 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): const canUseDefaultSource = captureMode === 'system' ? selectedSystemSourceId !== defaultSystemSourceId : selectedDeviceId !== null + const loginItemStatusMessage = desktopIntegration.loginItemStatus === 'requires-approval' + ? 'Approval required in system login settings' + : desktopIntegration.loginItemStatus === 'blocked' + ? 'Disabled in system startup settings' + : desktopIntegration.loginItemStatus === 'unavailable' + ? 'Open at login is available in packaged builds' + : desktopIntegrationError return (
@@ -489,9 +503,24 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
Window
- {windowBackground.mode !== 'solid' ? ( - - Window snapping is disabled in this mode + {windowBackground.mode !== 'solid' || loginItemStatusMessage ? ( + + {windowBackground.mode !== 'solid' ? ( + + Window snapping is disabled in this mode + + ) : null} + {windowBackground.mode !== 'solid' && loginItemStatusMessage ? ( + + ) : null} + {loginItemStatusMessage ? ( + + {loginItemStatusMessage} + + ) : null} ) : null}
@@ -536,6 +565,36 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): }} title="How much of the desktop shows through" /> +
diff --git a/src/renderer/components/TrayControlBridge.tsx b/src/renderer/components/TrayControlBridge.tsx new file mode 100644 index 0000000..20d4540 --- /dev/null +++ b/src/renderer/components/TrayControlBridge.tsx @@ -0,0 +1,136 @@ +import { useEffect, type JSX } from 'react' +import type { TrayRendererCommand, TrayRendererState } from '../../types/desktopIntegration' +import { useAudioStore } from '../stores/audioStore' +import { useSettingsStore } from '../stores/settingsStore' +import { useUiStore } from '../stores/uiStore' + +interface TrayControlBridgeProps { + ready: boolean +} + +const DEFAULT_SYSTEM_SOURCE_ID = '__default_system_output__' + +function getErrorMessage(error: unknown, fallback: string): string { + return error instanceof Error && error.message ? error.message : fallback +} + +async function handleTrayCommand(command: TrayRendererCommand): Promise { + if (command.type === 'open-settings') { + useUiStore.getState().setSettingsOpen(true) + return + } + + if (command.type === 'load-profile') { + const settings = useSettingsStore.getState() + try { + await settings.guardProfileTransition(async () => { + await useSettingsStore.getState().loadProfile(command.profileId) + }) + } catch (error) { + useUiStore.getState().showBanner({ + tone: 'error', + message: getErrorMessage(error, 'Could not load the profile.'), + actions: [], + }) + } + return + } + + const audio = useAudioStore.getState() + if (command.type === 'select-system-source') { + await audio.selectSystemSource(command.sourceId) + await useAudioStore.getState().startCapture() + return + } + if (command.type === 'select-input-source') { + await audio.selectDevice(command.deviceId) + await useAudioStore.getState().startCapture() + return + } + if (command.type === 'set-capture-running') { + if (command.running) { + await audio.startCapture() + } else { + audio.stopCapture() + } + } +} + +export default function TrayControlBridge({ ready }: TrayControlBridgeProps): JSX.Element | null { + const profiles = useSettingsStore((state) => state.profiles) + const activeProfileId = useSettingsStore((state) => state.activeProfileId) + const hasUnsavedProfileChanges = useSettingsStore((state) => state.hasUnsavedProfileChanges) + const captureStatus = useAudioStore((state) => state.captureStatus) + const activeSourceLabel = useAudioStore((state) => state.activeSourceLabel) + const captureMode = useAudioStore((state) => state.captureMode) + const selectedSystemSourceId = useAudioStore((state) => state.selectedSystemSourceId) + const selectedDeviceId = useAudioStore((state) => state.selectedDeviceId) + const systemSources = useAudioStore((state) => state.systemSources) + const devices = useAudioStore((state) => state.devices) + + useEffect(() => { + const unsubscribe = window.electronAPI.trayControls.onCommand((command) => { + void handleTrayCommand(command).catch((error) => { + useUiStore.getState().showBanner({ + tone: 'error', + message: getErrorMessage(error, 'The tray action could not be completed.'), + actions: [], + }) + }) + }) + return unsubscribe + }, []) + + useEffect(() => { + if (!ready) { + window.electronAPI.trayControls.markNotReady() + return + } + window.electronAPI.trayControls.markReady() + return () => window.electronAPI.trayControls.markNotReady() + }, [ready]) + + useEffect(() => { + const visibleSystemSources = systemSources.length > 0 + ? systemSources + : [{ id: DEFAULT_SYSTEM_SOURCE_ID, label: 'Default Output', isDefault: true }] + const state: TrayRendererState = { + profiles: Object.entries(profiles).map(([id, profile]) => ({ id, name: profile.name })), + activeProfileId, + hasUnsavedProfileChanges, + captureStatus, + activeSourceLabel, + captureMode, + selectedSystemSourceId, + selectedDeviceId, + systemSources: visibleSystemSources.map((source) => ({ + id: source.id, + label: source.label, + isDefault: source.isDefault, + })), + inputSources: [ + { id: '', label: 'Default Input', isDefault: true }, + ...devices + .filter((device) => device.deviceId !== 'default') + .map((device) => ({ + id: device.deviceId, + label: device.label || `Input ${device.deviceId.slice(0, 8)}`, + })), + ], + } + window.electronAPI.trayControls.publishState(state) + }, [ + activeProfileId, + activeSourceLabel, + captureMode, + captureStatus, + devices, + hasUnsavedProfileChanges, + profiles, + selectedDeviceId, + selectedSystemSourceId, + systemSources, + ]) + + return null +} diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts index 4df7d6d..f0abf30 100644 --- a/src/renderer/env.d.ts +++ b/src/renderer/env.d.ts @@ -36,6 +36,12 @@ import type { UpdateCheckResult } from '../types/updates' import type { WindowCapabilities } from '../types/windowCapabilities' import type { ResizeDirection } from '../types/windowResize' import type { WindowBackgroundSnapshot, WindowBackgroundState } from '../types/windowState' +import type { + DesktopIntegrationSnapshot, + LoginLaunchMode, + TrayRendererCommand, + TrayRendererState, +} from '../types/desktopIntegration' declare global { interface Window { @@ -47,6 +53,19 @@ declare global { getAppBuildInfo: () => Promise minimize: () => void close: () => void + desktopIntegration: { + get: () => Promise + setCloseToTray: (enabled: boolean) => Promise + setOpenAtLogin: (enabled: boolean) => Promise + setLoginLaunchMode: (mode: LoginLaunchMode) => Promise + onChanged: (callback: (snapshot: DesktopIntegrationSnapshot) => void) => () => void + } + trayControls: { + markReady: () => void + markNotReady: () => void + publishState: (state: TrayRendererState) => void + onCommand: (callback: (command: TrayRendererCommand) => void) => () => void + } startWindowMove: () => void stopWindowMove: () => void startWindowResize: (edge: ResizeDirection) => void diff --git a/src/renderer/stores/desktopIntegrationStore.ts b/src/renderer/stores/desktopIntegrationStore.ts new file mode 100644 index 0000000..48dbbbd --- /dev/null +++ b/src/renderer/stores/desktopIntegrationStore.ts @@ -0,0 +1,84 @@ +import { create } from 'zustand' +import type { + DesktopIntegrationSnapshot, + LoginLaunchMode, +} from '../../types/desktopIntegration' + +const DEFAULT_SNAPSHOT: DesktopIntegrationSnapshot = { + closeToTray: false, + openAtLogin: false, + loginLaunchMode: 'show', + loginItemStatus: 'unavailable', + loginItemError: null, +} + +interface DesktopIntegrationState { + snapshot: DesktopIntegrationSnapshot + initialized: boolean + busy: boolean + error: string | null + initialize: () => Promise + applySnapshot: (snapshot: DesktopIntegrationSnapshot) => void + setCloseToTray: (enabled: boolean) => Promise + setOpenAtLogin: (enabled: boolean) => Promise + setLoginLaunchMode: (mode: LoginLaunchMode) => Promise +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error && error.message + ? error.message + : 'Could not update desktop integration settings.' +} + +export const useDesktopIntegrationStore = create((set) => { + const applyResult = (snapshot: DesktopIntegrationSnapshot): void => { + set({ + snapshot, + initialized: true, + busy: false, + error: snapshot.loginItemError, + }) + } + + const runMutation = async ( + operation: () => Promise, + ): Promise => { + set({ busy: true, error: null }) + try { + applyResult(await operation()) + } catch (error) { + set({ busy: false, error: getErrorMessage(error) }) + } + } + + return { + snapshot: DEFAULT_SNAPSHOT, + initialized: false, + busy: false, + error: null, + + initialize: async () => { + try { + applyResult(await window.electronAPI.desktopIntegration.get()) + } catch (error) { + set({ initialized: true, error: getErrorMessage(error) }) + } + }, + + applySnapshot: (snapshot) => { + applyResult(snapshot) + }, + + setCloseToTray: async (enabled) => { + await runMutation(() => window.electronAPI.desktopIntegration.setCloseToTray(enabled)) + }, + + setOpenAtLogin: async (enabled) => { + await runMutation(() => window.electronAPI.desktopIntegration.setOpenAtLogin(enabled)) + }, + + setLoginLaunchMode: async (mode) => { + await runMutation(() => window.electronAPI.desktopIntegration.setLoginLaunchMode(mode)) + }, + } +}) diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index 95c4e5d..2aed9fb 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -1908,7 +1908,7 @@ button.toolbar__version:hover { } .bottom-bar__section--theme { - min-width: 420px; + min-width: 480px; } .bottom-bar__section--astra, @@ -1917,7 +1917,7 @@ button.toolbar__version:hover { } .bottom-bar__section--window { - min-width: 380px; + min-width: 880px; } .bottom-bar__section--source { @@ -1977,10 +1977,55 @@ button.toolbar__version:hover { gap: 6px; } +.bottom-bar__inline--theme .settings-chip, +.bottom-bar__inline--window .settings-chip { + flex: 0 0 auto; + max-width: none; +} + .bottom-bar__inline--now-playing { gap: 6px; } +.bottom-bar__inline--window { + gap: 8px; +} + +.bottom-bar__inline-divider { + width: 1px; + height: 24px; + margin: 0 2px; + background: var(--divider); + flex: 0 0 1px; +} + +.bottom-bar__login-select { + width: 190px; + flex: 0 0 190px; +} + +.bottom-bar__desktop-status { + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bottom-bar__window-metadata { + min-width: 0; + margin-left: auto; + display: flex; + align-items: baseline; + justify-content: flex-end; + gap: 6px; + overflow: hidden; + white-space: nowrap; +} + +.bottom-bar__metadata-separator { + color: var(--text-muted); +} + .bottom-bar__theme-metadata { min-width: 0; margin-left: auto; @@ -2007,7 +2052,6 @@ button.toolbar__version:hover { .bottom-bar__window-note { min-width: 0; - margin-left: auto; color: var(--text-tertiary); font-size: 10px; line-height: 1.4; diff --git a/src/types/desktopIntegration.ts b/src/types/desktopIntegration.ts new file mode 100644 index 0000000..3eb7927 --- /dev/null +++ b/src/types/desktopIntegration.ts @@ -0,0 +1,69 @@ +export type LoginLaunchMode = 'show' | 'tray' + +export type LoginItemStatus = + | 'unavailable' + | 'disabled' + | 'enabled' + | 'requires-approval' + | 'blocked' + | 'error' + +export interface DesktopIntegrationPreferences { + closeToTray: boolean + loginLaunchMode: LoginLaunchMode +} + +export interface DesktopIntegrationSnapshot extends DesktopIntegrationPreferences { + openAtLogin: boolean + loginItemStatus: LoginItemStatus + loginItemError: string | null +} + +export interface TrayProfileOption { + id: string + name: string +} + +export interface TrayAudioSourceOption { + id: string + label: string + isDefault?: boolean +} + +export interface TrayRendererState { + profiles: TrayProfileOption[] + activeProfileId: string | null + hasUnsavedProfileChanges: boolean + captureStatus: 'idle' | 'connecting' | 'capturing' | 'error' + activeSourceLabel: string | null + captureMode: 'system' | 'device' + selectedSystemSourceId: string | null + selectedDeviceId: string | null + systemSources: TrayAudioSourceOption[] + inputSources: TrayAudioSourceOption[] +} + +export type TrayRendererCommand = + | { type: 'load-profile'; profileId: string } + | { type: 'select-system-source'; sourceId: string } + | { type: 'select-input-source'; deviceId: string | null } + | { type: 'set-capture-running'; running: boolean } + | { type: 'open-settings' } + +export const DEFAULT_DESKTOP_INTEGRATION_PREFERENCES: DesktopIntegrationPreferences = { + closeToTray: false, + loginLaunchMode: 'show', +} + +export const DEFAULT_TRAY_RENDERER_STATE: TrayRendererState = { + profiles: [], + activeProfileId: null, + hasUnsavedProfileChanges: false, + captureStatus: 'idle', + activeSourceLabel: null, + captureMode: 'system', + selectedSystemSourceId: null, + selectedDeviceId: null, + systemSources: [], + inputSources: [], +} diff --git a/test/desktop-integration.test.ts b/test/desktop-integration.test.ts new file mode 100644 index 0000000..210a4b8 --- /dev/null +++ b/test/desktop-integration.test.ts @@ -0,0 +1,343 @@ +import assert from 'node:assert/strict' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { inflateSync } from 'node:zlib' +import test from 'node:test' +import { + loadDesktopIntegrationPreferences, + normalizeDesktopIntegrationPreferences, + resolveMainWindowCloseDisposition, + resolveStartHiddenAtLogin, + saveDesktopIntegrationPreferences, +} from '../src/main/services/desktopIntegrationPrefs' +import { + LOGIN_LAUNCH_ARG, + LoginItemService, + buildLinuxAutostartEntry, + isLoginLaunch, + quoteDesktopExecArgument, + resolveLinuxAutostartPath, + resolveLinuxLaunchExecutable, + resolveNativeLoginItemStatus, +} from '../src/main/services/loginItem' +import { + buildTrayMenuModel, + normalizeTrayRendererState, +} from '../src/main/services/trayMenu' +import { TrayRendererCommandQueue } from '../src/main/services/trayRendererCommandQueue' +import { + getTrayAssetFilename, + resolveTrayAssetPath, +} from '../src/main/services/trayAssets' + +function inspectPng(buffer: Buffer): { + width: number + height: number + alphaValues: Set +} { + assert.deepEqual(Array.from(buffer.subarray(0, 8)), [137, 80, 78, 71, 13, 10, 26, 10]) + let offset = 8 + let width = 0 + let height = 0 + const idat: Buffer[] = [] + while (offset < buffer.length) { + const length = buffer.readUInt32BE(offset) + const type = buffer.toString('ascii', offset + 4, offset + 8) + const data = buffer.subarray(offset + 8, offset + 8 + length) + if (type === 'IHDR') { + width = data.readUInt32BE(0) + height = data.readUInt32BE(4) + assert.equal(data[8], 8) + assert.equal(data[9], 6) + } else if (type === 'IDAT') { + idat.push(data) + } + offset += length + 12 + } + + const bytesPerPixel = 4 + const stride = width * bytesPerPixel + const raw = inflateSync(Buffer.concat(idat)) + const pixels = Buffer.alloc(width * height * bytesPerPixel) + let rawOffset = 0 + for (let y = 0; y < height; y += 1) { + const filter = raw[rawOffset] + rawOffset += 1 + for (let x = 0; x < stride; x += 1) { + const source = raw[rawOffset + x] + const pixelOffset = (y * stride) + x + const left = x >= bytesPerPixel ? pixels[pixelOffset - bytesPerPixel] : 0 + const up = y > 0 ? pixels[pixelOffset - stride] : 0 + const upLeft = y > 0 && x >= bytesPerPixel + ? pixels[pixelOffset - stride - bytesPerPixel] + : 0 + let decoded = source + if (filter === 1) decoded += left + else if (filter === 2) decoded += up + else if (filter === 3) decoded += Math.floor((left + up) / 2) + else if (filter === 4) { + const predictor = left + up - upLeft + const leftDistance = Math.abs(predictor - left) + const upDistance = Math.abs(predictor - up) + const upLeftDistance = Math.abs(predictor - upLeft) + decoded += leftDistance <= upDistance && leftDistance <= upLeftDistance + ? left + : upDistance <= upLeftDistance ? up : upLeft + } else { + assert.equal(filter, 0) + } + pixels[pixelOffset] = decoded & 0xff + } + rawOffset += stride + } + + const alphaValues = new Set() + for (let index = 3; index < pixels.length; index += bytesPerPixel) { + alphaValues.add(pixels[index]) + } + return { width, height, alphaValues } +} + +test('desktop integration preferences normalize to safe defaults and persist', async () => { + assert.deepEqual(normalizeDesktopIntegrationPreferences(null), { + closeToTray: false, + loginLaunchMode: 'show', + }) + assert.deepEqual(normalizeDesktopIntegrationPreferences({ + closeToTray: true, + loginLaunchMode: 'tray', + }), { + closeToTray: true, + loginLaunchMode: 'tray', + }) + + const directory = await mkdtemp(join(tmpdir(), 'prism-desktop-prefs-')) + const filePath = join(directory, 'nested', 'desktop-integration.json') + try { + await saveDesktopIntegrationPreferences(filePath, { + closeToTray: true, + loginLaunchMode: 'tray', + }) + assert.deepEqual(await loadDesktopIntegrationPreferences(filePath), { + closeToTray: true, + loginLaunchMode: 'tray', + }) + } finally { + await rm(directory, { recursive: true, force: true }) + } +}) + +test('close-to-tray is used only when hiding cannot strand the app', () => { + assert.equal(resolveMainWindowCloseDisposition({ + closeToTray: true, + isAppQuitting: false, + trayAvailable: true, + windowRecreationPending: false, + }), 'hide-to-tray') + for (const override of [ + { closeToTray: false }, + { isAppQuitting: true }, + { trayAvailable: false }, + { windowRecreationPending: true }, + ]) { + assert.equal(resolveMainWindowCloseDisposition({ + closeToTray: true, + isAppQuitting: false, + trayAvailable: true, + windowRecreationPending: false, + ...override, + }), 'close') + } +}) + +test('hidden login launch requires the login origin, tray preference, and no file open', () => { + assert.equal(resolveStartHiddenAtLogin({ + isLoginLaunch: true, + loginLaunchMode: 'tray', + hasPendingFileOpen: false, + }), true) + assert.equal(resolveStartHiddenAtLogin({ + isLoginLaunch: false, + loginLaunchMode: 'tray', + hasPendingFileOpen: false, + }), false) + assert.equal(resolveStartHiddenAtLogin({ + isLoginLaunch: true, + loginLaunchMode: 'show', + hasPendingFileOpen: false, + }), false) + assert.equal(resolveStartHiddenAtLogin({ + isLoginLaunch: true, + loginLaunchMode: 'tray', + hasPendingFileOpen: true, + }), false) +}) + +test('Linux autostart helpers use XDG paths, AppImage paths, and desktop-entry quoting', () => { + assert.equal( + resolveLinuxAutostartPath('/tmp/prism config', '/home/test'), + '/tmp/prism config/autostart/com.astra.prism.desktop', + ) + assert.equal( + resolveLinuxAutostartPath('relative', '/home/test'), + '/home/test/.config/autostart/com.astra.prism.desktop', + ) + assert.equal(resolveLinuxLaunchExecutable('/apps/Prism.AppImage', '/tmp/.mount/prism'), '/apps/Prism.AppImage') + assert.equal(resolveLinuxLaunchExecutable('relative', '/usr/bin/prism'), '/usr/bin/prism') + assert.equal(quoteDesktopExecArgument('/apps/Prism $Nightly`"'), '"/apps/Prism \\$Nightly\\`\\\""') + + const entry = buildLinuxAutostartEntry('/apps/Prism Nightly.AppImage') + assert.match(entry, /^\[Desktop Entry\]$/m) + assert.match(entry, /^TryExec="\/apps\/Prism Nightly\.AppImage"$/m) + assert.match(entry, new RegExp(`^Exec="/apps/Prism Nightly\\.AppImage" ${LOGIN_LAUNCH_ARG}$`, 'm')) + assert.match(entry, /^Hidden=false$/m) +}) + +test('Linux login service writes, detects, and removes its user autostart entry', async () => { + const directory = await mkdtemp(join(tmpdir(), 'prism-linux-login-')) + const configHome = join(directory, 'config') + const preferences = { closeToTray: false, loginLaunchMode: 'tray' as const } + const service = new LoginItemService({ + platform: 'linux', + isPackaged: true, + executablePath: '/tmp/.mount_Prism/prism', + appImagePath: '/apps/Prism.AppImage', + configHome, + homePath: directory, + }) + try { + assert.equal((await service.getSnapshot(preferences)).openAtLogin, false) + const enabled = await service.setOpenAtLogin(true, preferences) + assert.equal(enabled.openAtLogin, true) + assert.equal(enabled.loginItemStatus, 'enabled') + const entryPath = resolveLinuxAutostartPath(configHome, directory) + assert.match(await readFile(entryPath, 'utf8'), /^Exec="\/apps\/Prism\.AppImage" --prism-login-launch$/m) + assert.equal((await service.setOpenAtLogin(false, preferences)).openAtLogin, false) + } finally { + await rm(directory, { recursive: true, force: true }) + } +}) + +test('login support reports native approval/blocking and stays unavailable in development', async () => { + assert.equal(resolveNativeLoginItemStatus('darwin', { + openAtLogin: true, + status: 'requires-approval', + }), 'requires-approval') + assert.equal(resolveNativeLoginItemStatus('win32', { + openAtLogin: true, + executableWillLaunchAtLogin: false, + }), 'blocked') + assert.equal(isLoginLaunch('darwin', [], { wasOpenedAtLogin: true }), true) + assert.equal(isLoginLaunch('win32', [LOGIN_LAUNCH_ARG]), true) + + const service = new LoginItemService({ + platform: 'darwin', + isPackaged: false, + executablePath: '/Applications/Prism.app', + homePath: '/tmp', + }) + assert.deepEqual(await service.getSnapshot({ closeToTray: false, loginLaunchMode: 'show' }), { + closeToTray: false, + loginLaunchMode: 'show', + openAtLogin: false, + loginItemStatus: 'unavailable', + loginItemError: null, + }) +}) + +test('tray state validation and menu model expose capture, visibility, and checked state', () => { + const rendererState = normalizeTrayRendererState({ + profiles: [{ id: 'default', name: 'Default' }], + activeProfileId: 'default', + hasUnsavedProfileChanges: true, + captureStatus: 'capturing', + activeSourceLabel: 'Studio Output', + captureMode: 'system', + selectedSystemSourceId: 'output-1', + selectedDeviceId: null, + systemSources: [{ id: 'output-1', label: 'Studio Output' }], + inputSources: [{ id: '', label: 'Default Input' }], + }) + const model = buildTrayMenuModel({ + mainWindowVisible: false, + rendererReady: true, + rendererState, + desktopIntegration: { + closeToTray: true, + loginLaunchMode: 'tray', + openAtLogin: true, + loginItemStatus: 'enabled', + loginItemError: null, + }, + alwaysOnTop: true, + supportsReposition: true, + }) + assert.equal(model.statusLabel, 'Prism — Capturing · Studio Output') + assert.equal(model.mainWindowActionLabel, 'Show Prism') + assert.equal(model.captureActionLabel, 'Stop Capture') + assert.equal(model.rendererState.hasUnsavedProfileChanges, true) + + assert.equal(normalizeTrayRendererState({ profiles: 'invalid', captureStatus: 'bad' }).captureStatus, 'idle') +}) + +test('tray renderer commands remain queued until the renderer is ready', () => { + const queue = new TrayRendererCommandQueue() + const received: string[] = [] + queue.enqueue({ type: 'open-settings' }) + queue.enqueue({ type: 'set-capture-running', running: false }) + queue.flush((command) => received.push(command.type)) + assert.deepEqual(received, ['open-settings', 'set-capture-running']) + queue.flush((command) => received.push(command.type)) + assert.deepEqual(received, ['open-settings', 'set-capture-running']) +}) + +test('tray assets resolve for development and packaged builds', () => { + assert.equal(getTrayAssetFilename('darwin'), 'prismTrayTemplate.png') + assert.equal(getTrayAssetFilename('win32'), 'prism-tray.ico') + assert.equal(getTrayAssetFilename('linux'), 'prism-tray.png') + assert.equal(resolveTrayAssetPath({ + platform: 'darwin', + isPackaged: true, + resourcesPath: '/Applications/Prism.app/Contents/Resources', + appPath: '/Applications/Prism.app/Contents/Resources/app.asar', + }), '/Applications/Prism.app/Contents/Resources/tray/prismTrayTemplate.png') + assert.equal(resolveTrayAssetPath({ + platform: 'linux', + isPackaged: false, + resourcesPath: '/unused', + appPath: '/workspace/prism', + }), '/workspace/prism/resources/tray/prism-tray.png') +}) + +test('generated tray assets have the expected sizes and transparent macOS mask', async () => { + const trayDirectory = join(process.cwd(), 'resources', 'tray') + const template1x = inspectPng(await readFile(join(trayDirectory, 'prismTrayTemplate.png'))) + const template2x = inspectPng(await readFile(join(trayDirectory, 'prismTrayTemplate@2x.png'))) + const linux = inspectPng(await readFile(join(trayDirectory, 'prism-tray.png'))) + assert.deepEqual([template1x.width, template1x.height], [16, 16]) + assert.deepEqual([template2x.width, template2x.height], [32, 32]) + assert.deepEqual([linux.width, linux.height], [24, 24]) + assert.equal(template1x.alphaValues.has(0), true) + assert.equal(template1x.alphaValues.has(255), true) + assert.equal(template2x.alphaValues.has(0), true) + assert.equal(template2x.alphaValues.has(255), true) + + const ico = await readFile(join(trayDirectory, 'prism-tray.ico')) + assert.equal(ico.readUInt16LE(0), 0) + assert.equal(ico.readUInt16LE(2), 1) + const imageCount = ico.readUInt16LE(4) + assert.equal(imageCount, 6) + const dimensions = Array.from({ length: imageCount }, (_, index) => { + const entryOffset = 6 + (index * 16) + return [ico[entryOffset], ico[entryOffset + 1]] + }) + assert.deepEqual(dimensions, [ + [16, 16], + [20, 20], + [24, 24], + [32, 32], + [40, 40], + [48, 48], + ]) +}) diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index 9799205..a8d72bf 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -3547,13 +3547,28 @@ test('BottomBar theme section renders compact credit metadata and opens valid li assert.match(componentSource, /bottom-bar__theme-separator/) assert.match(componentSource, /bottom-bar__section-header/) assert.match(componentSource, /bottom-bar__theme-credit--link/) - assert.match(stylesSource, /\.bottom-bar__section--theme \{[\s\S]*min-width: 420px;/) + assert.match(stylesSource, /\.bottom-bar__section--theme \{[\s\S]*min-width: 480px;/) + assert.match(stylesSource, /\.bottom-bar__inline--theme \.settings-chip,[\s\S]*flex: 0 0 auto;/) assert.match(stylesSource, /\.bottom-bar__section-header \{/) assert.match(stylesSource, /\.bottom-bar__theme-metadata \{/) assert.match(stylesSource, /\.bottom-bar__theme-description \{/) assert.match(stylesSource, /\.bottom-bar__theme-credit--link \{/) }) +test('BottomBar keeps Window controls on one row', async () => { + const componentSource = await readFile(join(process.cwd(), 'src', 'renderer', 'components', 'BottomBar.tsx'), 'utf8') + const stylesSource = await readFile(join(process.cwd(), 'src', 'renderer', 'styles', 'globals.css'), 'utf8') + + const windowSection = componentSource.match( + /
([\s\S]*?)
/, + )?.[1] + assert.ok(windowSection) + assert.equal(windowSection.match(/bottom-bar__inline--window/g)?.length, 1) + assert.doesNotMatch(windowSection, /bottom-bar__inline--desktop-integration/) + assert.match(stylesSource, /\.bottom-bar__section--window \{[\s\S]*min-width: 880px;/) + assert.match(stylesSource, /\.bottom-bar__inline--window \{[\s\S]*gap: 8px;/) +}) + test('BottomBar close button uses flat themed control backgrounds', async () => { const stylesSource = await readFile(join(process.cwd(), 'src', 'renderer', 'styles', 'globals.css'), 'utf8') const closeBlock = [...stylesSource.matchAll(/^\.settings-panel__close \{([\s\S]*?)\n\}/gm)]