mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-18 03:33:40 +02:00
tray icon and open on login
This commit is contained in:
@@ -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 |
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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 = []
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Vendored
+19
@@ -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))
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [],
|
||||
}
|
||||
@@ -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],
|
||||
])
|
||||
})
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user