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 0000000..9014eeb Binary files /dev/null and b/resources/tray/prism-tray.ico differ diff --git a/resources/tray/prism-tray.png b/resources/tray/prism-tray.png new file mode 100644 index 0000000..ef34c3a Binary files /dev/null and b/resources/tray/prism-tray.png differ diff --git a/resources/tray/prismTrayTemplate.png b/resources/tray/prismTrayTemplate.png new file mode 100644 index 0000000..8960141 Binary files /dev/null and b/resources/tray/prismTrayTemplate.png differ diff --git a/resources/tray/prismTrayTemplate@2x.png b/resources/tray/prismTrayTemplate@2x.png new file mode 100644 index 0000000..39c749a Binary files /dev/null and b/resources/tray/prismTrayTemplate@2x.png differ 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)]