tray icon and open on login

This commit is contained in:
Boof2015
2026-08-15 21:20:48 -04:00
parent 6debfb8b34
commit 5236a92763
23 changed files with 1940 additions and 60 deletions
+5
View File
@@ -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/"
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 606 B

+64
View File
@@ -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, `<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<g transform="translate(${symbolTranslateX} ${symbolTranslateY}) scale(${iconSymbolScale})">
<path d="${lowerPath}" fill="#000000" />
<path d="${upperPath}" fill="#000000" />
</g>
</svg>
`)
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 })
}
+38
View File
@@ -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)
+558 -53
View File
@@ -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<typeof setInterval> | null = null
@@ -62,9 +87,18 @@ let mainWindowBoundsTimer: ReturnType<typeof setTimeout> | 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<BrowserWindow>()
const scopePopoutWindows = new Map<ScopeKind, BrowserWindow>()
const scopePopoutCloseAllowed = new Set<ScopeKind>()
@@ -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> {
desktopIntegrationSnapshot = loginItemService
? await loginItemService.getSnapshot(desktopIntegrationPreferences)
: {
...desktopIntegrationPreferences,
openAtLogin: false,
loginItemStatus: 'unavailable',
loginItemError: null,
}
broadcastDesktopIntegrationSnapshot()
refreshTrayMenu()
return { ...desktopIntegrationSnapshot }
}
async function updateDesktopIntegrationPreferences(
patch: Partial<DesktopIntegrationPreferences>,
): Promise<DesktopIntegrationSnapshot> {
desktopIntegrationPreferences = normalizeDesktopIntegrationPreferences({
...desktopIntegrationPreferences,
...patch,
})
desktopIntegrationPreferences = await saveDesktopIntegrationPreferences(
getDesktopIntegrationPreferencesPath(),
desktopIntegrationPreferences,
)
return refreshDesktopIntegrationSnapshot()
}
async function updateOpenAtLogin(enabled: boolean): Promise<DesktopIntegrationSnapshot> {
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<typeof buildTrayMenuModel>): 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<DialogResult> {
nodeIntegration: false,
},
})
customDialogWindows.add(win)
win.center()
loadRendererTarget(win, { mode: 'dialog' })
@@ -1146,10 +1594,11 @@ async function showCustomDialog(options: DialogOptions): Promise<DialogResult> {
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()
})
@@ -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<DesktopIntegrationPreferences>
return {
closeToTray: candidate.closeToTray === true,
loginLaunchMode: candidate.loginLaunchMode === 'tray' ? 'tray' : 'show',
}
}
export async function loadDesktopIntegrationPreferences(
filePath: string,
): Promise<DesktopIntegrationPreferences> {
try {
return normalizeDesktopIntegrationPreferences(JSON.parse(await readFile(filePath, 'utf8')))
} catch {
return { ...DEFAULT_DESKTOP_INTEGRATION_PREFERENCES }
}
}
export async function saveDesktopIntegrationPreferences(
filePath: string,
preferences: DesktopIntegrationPreferences,
): Promise<DesktopIntegrationPreferences> {
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
}
+224
View File
@@ -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<DesktopIntegrationSnapshot> {
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<DesktopIntegrationSnapshot> {
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)
}
}
+19
View File
@@ -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))
}
+122
View File
@@ -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<TrayProfileOption>
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<TrayAudioSourceOption>
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<TrayRendererState>
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)
}
@@ -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 = []
}
}
+38
View File
@@ -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<AppBuildInfo>,
minimize: () => ipcRenderer.send('window:minimize'),
close: () => ipcRenderer.send('window:close'),
desktopIntegration: {
get: () => ipcRenderer.invoke('desktop-integration:get') as Promise<DesktopIntegrationSnapshot>,
setCloseToTray: (enabled: boolean) => ipcRenderer.invoke(
'desktop-integration:set-close-to-tray',
enabled,
) as Promise<DesktopIntegrationSnapshot>,
setOpenAtLogin: (enabled: boolean) => ipcRenderer.invoke(
'desktop-integration:set-open-at-login',
enabled,
) as Promise<DesktopIntegrationSnapshot>,
setLoginLaunchMode: (mode: LoginLaunchMode) => ipcRenderer.invoke(
'desktop-integration:set-login-launch-mode',
mode,
) as Promise<DesktopIntegrationSnapshot>,
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),
+12
View File
@@ -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<ReturnType<typeof setTimeout> | 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 {
</div>
<ScopePopoutBridge />
<TrayControlBridge ready={trayReady} />
<AppBanner />
+62 -3
View File
@@ -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 (
<div className="bottom-bar" ref={rootRef}>
@@ -489,9 +503,24 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
<section className="bottom-bar__section bottom-bar__section--window">
<div className="bottom-bar__section-header">
<div className="bottom-bar__section-title">Window</div>
{windowBackground.mode !== 'solid' ? (
<span className="bottom-bar__window-note">
Window snapping is disabled in this mode
{windowBackground.mode !== 'solid' || loginItemStatusMessage ? (
<span className="bottom-bar__window-metadata">
{windowBackground.mode !== 'solid' ? (
<span className="bottom-bar__window-note">
Window snapping is disabled in this mode
</span>
) : null}
{windowBackground.mode !== 'solid' && loginItemStatusMessage ? (
<span className="bottom-bar__metadata-separator" aria-hidden="true">·</span>
) : null}
{loginItemStatusMessage ? (
<span
className={`${desktopIntegrationError ? 'settings-error-text' : 'settings-info-text'} bottom-bar__desktop-status`.trim()}
role="status"
>
{loginItemStatusMessage}
</span>
) : null}
</span>
) : null}
</div>
@@ -536,6 +565,36 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
}}
title="How much of the desktop shows through"
/>
<span className="bottom-bar__inline-divider" aria-hidden="true" />
<button
type="button"
className={`settings-chip ${desktopIntegration.closeToTray ? 'is-active' : ''}`.trim()}
disabled={desktopIntegrationBusy}
onClick={() => void setCloseToTray(!desktopIntegration.closeToTray)}
title="Closing Prism hides every Prism window; use the tray menu to quit"
>
Close to tray
</button>
<button
type="button"
className={`settings-chip ${desktopIntegration.openAtLogin ? 'is-active' : ''}`.trim()}
disabled={desktopIntegrationBusy || desktopIntegration.loginItemStatus === 'unavailable'}
onClick={() => void setOpenAtLogin(!desktopIntegration.openAtLogin)}
>
Open at login
</button>
<ThemedSelect
value={desktopIntegration.loginLaunchMode}
disabled={desktopIntegrationBusy || !desktopIntegration.openAtLogin}
onChange={(event) => {
void setLoginLaunchMode(event.target.value === 'tray' ? 'tray' : 'show')
}}
className="bottom-bar__login-select"
title="What Prism should show when opened automatically at login"
>
<option value="show">Login: Show Prism</option>
<option value="tray">Login: Start in tray</option>
</ThemedSelect>
</div>
</div>
</section>
@@ -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<void> {
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
}
+19
View File
@@ -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<AppBuildInfo>
minimize: () => void
close: () => void
desktopIntegration: {
get: () => Promise<DesktopIntegrationSnapshot>
setCloseToTray: (enabled: boolean) => Promise<DesktopIntegrationSnapshot>
setOpenAtLogin: (enabled: boolean) => Promise<DesktopIntegrationSnapshot>
setLoginLaunchMode: (mode: LoginLaunchMode) => Promise<DesktopIntegrationSnapshot>
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
@@ -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<void>
applySnapshot: (snapshot: DesktopIntegrationSnapshot) => void
setCloseToTray: (enabled: boolean) => Promise<void>
setOpenAtLogin: (enabled: boolean) => Promise<void>
setLoginLaunchMode: (mode: LoginLaunchMode) => Promise<void>
}
function getErrorMessage(error: unknown): string {
return error instanceof Error && error.message
? error.message
: 'Could not update desktop integration settings.'
}
export const useDesktopIntegrationStore = create<DesktopIntegrationState>((set) => {
const applyResult = (snapshot: DesktopIntegrationSnapshot): void => {
set({
snapshot,
initialized: true,
busy: false,
error: snapshot.loginItemError,
})
}
const runMutation = async (
operation: () => Promise<DesktopIntegrationSnapshot>,
): Promise<void> => {
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))
},
}
})
+47 -3
View File
@@ -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;
+69
View File
@@ -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: [],
}
+343
View File
@@ -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<number>
} {
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<number>()
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],
])
})
+16 -1
View File
@@ -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(
/<section className="bottom-bar__section bottom-bar__section--window">([\s\S]*?)<div className="bottom-bar__divider" \/>/,
)?.[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)]