Files
prism/src/main/index.ts
T
2026-04-05 23:11:56 -04:00

1439 lines
44 KiB
TypeScript

import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, screen, session, shell } from 'electron'
import type { BrowserWindowConstructorOptions, MenuItemConstructorOptions, OpenDialogOptions, WebContents } from 'electron'
import { extname, join, resolve } from 'path'
import type {
AstraControlCommand,
AstraIntegrationConfig,
AstraIntegrationState,
} from '../types/astra'
import type {
ScopePopoutAudioBatch,
ScopePopoutSessionState,
ScopePopoutSnapshot,
ScopePopoutSyncStateMap,
WindowBounds,
} from '../types/popout'
import type { ProfileMenuRequest } from '../types/profileMenu'
import type { LegacyProfileMigrationPayload, Profile } from '../types/profile'
import { SCOPE_KINDS, type ScopeKind } from '../types/scope'
import type {
LegacyThemeMigrationPayload,
ThemeLibrarySnapshot,
} from '../types/theme'
import { RESIZE_DIRECTIONS, type ResizeDirection } from '../types/windowResize'
import type { DialogOptions, DialogResult } from '../types/dialog'
import { normalizeProfile } from '../shared/profileState'
import { resolveNativeThemeSource } from '../shared/themeState'
import {
clampDraggedMainWindowBounds,
raiseWindowAboveNormalPopouts,
resolveExpandedMainWindowBounds,
} from '../shared/windowGeometry'
import { calculateResizedWindowBounds } from '../shared/windowResize'
import { FileBackedProfileLibrary } from './profileLibrary'
import { AstraIntegrationService } from './services/astraIntegration'
import { FileBackedThemeLibrary } from './themeLibrary'
import { FileBackedWindowStateStore } from './windowStateStore'
let mainWindow: BrowserWindow | null = null
let moveInterval: ReturnType<typeof setInterval> | null = null
let moveStartCursor: { x: number; y: number } | null = null
let moveStartBounds: WindowBounds | null = null
let resizeInterval: ReturnType<typeof setInterval> | null = null
let resizeWindow: BrowserWindow | null = null
let resizeStartCursor: { x: number; y: number } | null = null
let resizeStartBounds: WindowBounds | null = null
let resizeEdge: ResizeDirection | null = null
let mainWindowBoundsTimer: ReturnType<typeof setTimeout> | null = null
let mainRendererReady = false
let allowMainWindowClose = false
let mainWindowClosePending = false
let suppressMainWindowSyncUntil = 0
let mainWindowLogicalBounds: WindowBounds | null = null
const scopePopoutWindows = new Map<ScopeKind, BrowserWindow>()
const scopePopoutCloseAllowed = new Set<ScopeKind>()
const popoutBoundsTimers = new Map<ScopeKind, ReturnType<typeof setTimeout>>()
const suppressNextPopoutBoundsEvents = new Set<ScopeKind>()
const windowSettingsHeights = new Map<number, number>()
const windowSettingsBottomAnchors = new Map<number, number>()
const pendingProfileOpenPaths: string[] = []
const pendingThemeOpenPaths: string[] = []
let profileLibrary: FileBackedProfileLibrary | null = null
let themeLibrary: FileBackedThemeLibrary | null = null
let astraIntegrationService: AstraIntegrationService | null = null
let windowStateStore: FileBackedWindowStateStore | null = null
const WINDOW_DEFAULTS = {
width: 900,
height: 180,
minWidth: 400,
minHeight: 100,
}
const POPOUT_DEFAULTS = {
width: 360,
height: 240,
minWidth: 220,
minHeight: 160,
}
const MAIN_WINDOW_SYNC_SUPPRESSION_MS = 180
const MAIN_WINDOW_VISIBLE_GRAB_MARGIN = 64
function getProfileLibrary(): FileBackedProfileLibrary {
if (!profileLibrary) {
profileLibrary = new FileBackedProfileLibrary(
join(app.getPath('documents'), 'Prism Profiles'),
join(app.getPath('userData'), 'profile-state.json'),
async () => getThemeLibrary().getActiveThemeId(),
)
}
return profileLibrary
}
function getThemeLibrary(): FileBackedThemeLibrary {
if (!themeLibrary) {
themeLibrary = new FileBackedThemeLibrary(
join(app.getPath('documents'), 'Prism Themes'),
join(app.getPath('userData'), 'theme-state.json'),
)
}
return themeLibrary
}
function getWindowStateStore(): FileBackedWindowStateStore {
if (!windowStateStore) {
windowStateStore = new FileBackedWindowStateStore(
join(app.getPath('userData'), 'window-state.json'),
)
}
return windowStateStore
}
function broadcastAstraState(state: AstraIntegrationState): void {
for (const window of BrowserWindow.getAllWindows()) {
if (window.isDestroyed() || window.webContents.isDestroyed()) continue
window.webContents.send('astra:state-changed', state)
}
}
function getAstraIntegrationService(): AstraIntegrationService {
if (!astraIntegrationService) {
astraIntegrationService = new AstraIntegrationService({
configPath: join(app.getPath('userData'), 'astra-integration.json'),
})
astraIntegrationService.subscribe((state) => {
broadcastAstraState(state)
})
}
return astraIntegrationService
}
function queueProfileOpenPath(filePath: string): void {
if (extname(filePath).toLowerCase() !== '.prsm') return
const resolvedPath = resolve(filePath)
if (!pendingProfileOpenPaths.includes(resolvedPath)) {
pendingProfileOpenPaths.push(resolvedPath)
}
}
function queueProfileOpenPaths(paths: string[]): void {
for (const filePath of paths) {
queueProfileOpenPath(filePath)
}
}
function queueThemeOpenPath(filePath: string): void {
if (extname(filePath).toLowerCase() !== '.iro') return
const resolvedPath = resolve(filePath)
if (!pendingThemeOpenPaths.includes(resolvedPath)) {
pendingThemeOpenPaths.push(resolvedPath)
}
}
function queueThemeOpenPaths(paths: string[]): void {
for (const filePath of paths) {
queueThemeOpenPath(filePath)
}
}
function extractProfilePathsFromArgv(argv: string[]): string[] {
return argv
.filter((value) => extname(value).toLowerCase() === '.prsm')
.map((value) => resolve(value))
}
function extractThemePathsFromArgv(argv: string[]): string[] {
return argv
.filter((value) => extname(value).toLowerCase() === '.iro')
.map((value) => resolve(value))
}
function focusMainWindow(): void {
if (!mainWindow) return
if (mainWindow.isMinimized()) {
mainWindow.restore()
}
mainWindow.show()
mainWindow.focus()
raiseMainWindowAboveNormalPopouts()
}
function getErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error && error.message
? error.message
: fallback
}
async function processPendingProfileOpenPaths(): Promise<void> {
if (pendingProfileOpenPaths.length === 0) return
if (!mainRendererReady || !mainWindow || mainWindow.isDestroyed()) return
const paths = [...pendingProfileOpenPaths]
pendingProfileOpenPaths.length = 0
focusMainWindow()
for (const filePath of paths) {
mainWindow.webContents.send('profiles:open-requested', filePath)
}
}
async function processPendingThemeOpenPaths(): Promise<void> {
if (pendingThemeOpenPaths.length === 0) return
const paths = [...pendingThemeOpenPaths]
pendingThemeOpenPaths.length = 0
let latestSnapshot: ThemeLibrarySnapshot | null = null
for (const filePath of paths) {
try {
latestSnapshot = await getThemeLibrary().importThemeFromPath(filePath)
} catch (error) {
dialog.showErrorBox(
'Could Not Open Theme',
getErrorMessage(error, `Prism could not open ${filePath}.`),
)
}
}
if (!latestSnapshot || !mainWindow || mainWindow.isDestroyed()) return
applyNativeThemeSnapshot(latestSnapshot)
focusMainWindow()
mainWindow.webContents.send('themes:external-activated', latestSnapshot)
}
function applyNativeThemeSnapshot(snapshot: ThemeLibrarySnapshot): void {
const activeTheme = snapshot.activeThemeId
? snapshot.themes[snapshot.activeThemeId] ?? null
: null
nativeTheme.themeSource = resolveNativeThemeSource(activeTheme)
}
async function syncNativeThemeAppearance(): Promise<void> {
applyNativeThemeSnapshot(await getThemeLibrary().getSnapshot())
}
function scheduleMainWindowBoundsSave(window: BrowserWindow): void {
if (!isMainRendererWindow(window) || !mainRendererReady) return
if (mainWindowBoundsTimer) {
clearTimeout(mainWindowBoundsTimer)
}
mainWindowBoundsTimer = setTimeout(() => {
mainWindowBoundsTimer = null
if (window.isDestroyed() || window.webContents.isDestroyed()) return
if (isMainWindowSyncSuppressed()) {
return
}
window.webContents.send('window:bounds-changed', toLogicalBounds(window))
}, 80)
}
function normalizeIncomingProfile(raw: unknown, fallbackName = 'Profile'): Profile {
return normalizeProfile(raw, fallbackName)
}
function isScopeKind(value: unknown): value is ScopeKind {
return typeof value === 'string' && SCOPE_KINDS.includes(value as ScopeKind)
}
function getWindowFromSender(sender: WebContents): BrowserWindow | null {
return BrowserWindow.fromWebContents(sender)
}
function isMainRendererWindow(window: BrowserWindow | null): boolean {
return window !== null && window === mainWindow
}
function getScopeKindForWindow(window: BrowserWindow | null): ScopeKind | null {
if (!window) return null
for (const [kind, popoutWindow] of scopePopoutWindows) {
if (popoutWindow === window) {
return kind
}
}
return null
}
function describeWindow(window: BrowserWindow): string {
if (isMainRendererWindow(window)) {
return 'main window'
}
const kind = getScopeKindForWindow(window)
return kind ? `${kind} popout` : `window ${window.id}`
}
function getBaseMinHeight(window: BrowserWindow): number {
return isMainRendererWindow(window) ? WINDOW_DEFAULTS.minHeight : POPOUT_DEFAULTS.minHeight
}
function normalizeMainWindowBounds(bounds: WindowBounds): WindowBounds {
return {
x: Math.round(bounds.x),
y: Math.round(bounds.y),
width: Math.max(WINDOW_DEFAULTS.minWidth, Math.round(bounds.width)),
height: Math.max(WINDOW_DEFAULTS.minHeight, Math.round(bounds.height)),
}
}
function getDisplayWorkAreas(): WindowBounds[] {
return screen.getAllDisplays().map((display) => ({
x: display.workArea.x,
y: display.workArea.y,
width: display.workArea.width,
height: display.workArea.height,
}))
}
function suppressMainWindowSync(durationMs = MAIN_WINDOW_SYNC_SUPPRESSION_MS): void {
suppressMainWindowSyncUntil = Math.max(suppressMainWindowSyncUntil, Date.now() + durationMs)
}
function isMainWindowSyncSuppressed(): boolean {
return suppressMainWindowSyncUntil > Date.now()
}
function getSettingsHeight(window: BrowserWindow | null): number {
if (!window) return 0
return windowSettingsHeights.get(window.id) ?? 0
}
function setSettingsHeightForWindow(window: BrowserWindow, height: number): void {
const nextHeight = Math.max(0, Math.round(height))
if (nextHeight === 0) {
windowSettingsHeights.delete(window.id)
windowSettingsBottomAnchors.delete(window.id)
return
}
windowSettingsHeights.set(window.id, nextHeight)
}
function toLogicalBounds(window: BrowserWindow, bounds = window.getBounds()): WindowBounds {
if (isMainRendererWindow(window) && mainWindowLogicalBounds) {
return { ...mainWindowLogicalBounds }
}
return {
...bounds,
height: Math.max(getBaseMinHeight(window), bounds.height - getSettingsHeight(window)),
}
}
function syncMainWindowLogicalBounds(window: BrowserWindow, bounds = window.getBounds()): void {
if (!isMainRendererWindow(window)) {
return
}
mainWindowLogicalBounds = normalizeMainWindowBounds({
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: Math.max(WINDOW_DEFAULTS.minHeight, bounds.height - getSettingsHeight(window)),
})
}
function applyMainWindowLogicalBounds(window: BrowserWindow, bounds: WindowBounds): void {
const logicalBounds = normalizeMainWindowBounds(bounds)
mainWindowLogicalBounds = logicalBounds
suppressMainWindowSync()
window.setBounds(resolveExpandedMainWindowBounds(logicalBounds, getSettingsHeight(window), getDisplayWorkAreas()))
}
function applyLogicalBounds(window: BrowserWindow, bounds: WindowBounds): void {
if (isMainRendererWindow(window)) {
applyMainWindowLogicalBounds(window, bounds)
return
}
window.setBounds({
...bounds,
height: bounds.height + getSettingsHeight(window),
})
}
function setWindowHeight(window: BrowserWindow, bounds: WindowBounds, height: number, y = bounds.y): void {
window.setBounds({
x: bounds.x,
y,
width: bounds.width,
height,
})
}
function applySettingsHeight(window: BrowserWindow, rawNextHeight: number): void {
const currentSettingsHeight = getSettingsHeight(window)
const nextSettingsHeight = Math.max(0, Math.round(rawNextHeight))
const baseMinHeight = getBaseMinHeight(window)
const [minW] = window.getMinimumSize()
window.setMinimumSize(minW, baseMinHeight + nextSettingsHeight)
if (currentSettingsHeight === nextSettingsHeight) {
return
}
if (isMainRendererWindow(window)) {
if (!mainWindowLogicalBounds) {
syncMainWindowLogicalBounds(window)
}
setSettingsHeightForWindow(window, nextSettingsHeight)
applyMainWindowLogicalBounds(window, toLogicalBounds(window))
if (currentSettingsHeight === 0 && nextSettingsHeight > 0) {
raiseMainWindowAboveNormalPopouts()
}
return
}
const delta = nextSettingsHeight - currentSettingsHeight
if (delta !== 0) {
const bounds = window.getBounds()
const newHeight = Math.max(baseMinHeight + nextSettingsHeight, bounds.height + delta)
const display = screen.getDisplayMatching(bounds)
const workArea = display.workArea
const workAreaBottom = workArea.y + workArea.height
const bottomEdge = bounds.y + newHeight
if (delta > 0 && bottomEdge > workAreaBottom) {
if (!windowSettingsBottomAnchors.has(window.id)) {
windowSettingsBottomAnchors.set(window.id, bounds.y + bounds.height)
}
const newY = Math.max(workArea.y, workAreaBottom - newHeight)
setWindowHeight(window, bounds, newHeight, newY)
} else if (delta < 0) {
const anchoredBottom = windowSettingsBottomAnchors.get(window.id)
if (anchoredBottom !== undefined) {
const targetY = Math.max(workArea.y, Math.min(anchoredBottom - newHeight, workAreaBottom - newHeight))
setWindowHeight(window, bounds, newHeight, targetY)
} else {
const baseHeight = newHeight - nextSettingsHeight
const naturalBottom = bounds.y + baseHeight
if (naturalBottom < workAreaBottom) {
const maxY = workAreaBottom - newHeight
if (bounds.y < maxY) {
setWindowHeight(window, bounds, newHeight)
} else {
setWindowHeight(window, bounds, newHeight, maxY)
}
} else {
setWindowHeight(window, bounds, newHeight)
}
}
} else {
setWindowHeight(window, bounds, newHeight)
}
}
setSettingsHeightForWindow(window, nextSettingsHeight)
}
function raiseMainWindowAboveNormalPopouts(): void {
raiseWindowAboveNormalPopouts(mainWindow, scopePopoutWindows.values())
}
function sendToRenderer(sender: WebContents, channel: string, ...args: unknown[]): void {
if (!sender.isDestroyed()) {
sender.send(channel, ...args)
}
}
function isResizeDirection(value: unknown): value is ResizeDirection {
return typeof value === 'string' && RESIZE_DIRECTIONS.includes(value as ResizeDirection)
}
function stopWindowMoveController(): void {
if (moveInterval) {
clearInterval(moveInterval)
moveInterval = null
}
moveStartCursor = null
moveStartBounds = null
}
function stopWindowResizeController(): void {
if (resizeInterval) {
clearInterval(resizeInterval)
resizeInterval = null
}
resizeWindow = null
resizeStartCursor = null
resizeStartBounds = null
resizeEdge = null
}
function getFramelessWindowChromeOptions(): Pick<
BrowserWindowConstructorOptions,
'frame' | 'transparent' | 'backgroundColor' | 'roundedCorners' | 'hasShadow' | 'thickFrame' | 'backgroundMaterial'
> {
return {
frame: false,
transparent: false,
backgroundColor: '#000000',
roundedCorners: false,
hasShadow: false,
...(process.platform === 'win32'
? {
thickFrame: false,
backgroundMaterial: 'none',
}
: {}),
}
}
function normalizeProfileMenuRequest(raw: unknown): ProfileMenuRequest | null {
if (typeof raw !== 'object' || raw === null) return null
const candidate = raw as Partial<ProfileMenuRequest>
if (
typeof candidate.x !== 'number'
|| typeof candidate.y !== 'number'
|| !Array.isArray(candidate.profiles)
) {
return null
}
const profiles = candidate.profiles
.filter((profile): profile is ProfileMenuRequest['profiles'][number] => {
return typeof profile?.id === 'string'
&& typeof profile?.name === 'string'
&& typeof profile?.isDefault === 'boolean'
})
.map((profile) => ({
id: profile.id,
name: profile.name,
isDefault: profile.isDefault,
}))
return {
x: Math.round(candidate.x),
y: Math.round(candidate.y),
profiles,
activeProfileId: typeof candidate.activeProfileId === 'string' ? candidate.activeProfileId : null,
}
}
function buildProfileMenuTemplate(
request: ProfileMenuRequest,
sender: WebContents,
): MenuItemConstructorOptions[] {
const activeProfile = request.activeProfileId
? request.profiles.find((profile) => profile.id === request.activeProfileId) ?? null
: null
const template: MenuItemConstructorOptions[] = [
{ label: 'Profiles', enabled: false },
...request.profiles.map((profile) => ({
type: 'checkbox' as const,
checked: profile.id === request.activeProfileId,
label: profile.name,
click: () => sendToRenderer(sender, 'profile-menu:load', profile.id),
})),
{ type: 'separator' },
{
label: 'Save as New Profile',
click: () => sendToRenderer(sender, 'profile-menu:save-new'),
},
]
if (activeProfile) {
template.push({
label: `Save to "${activeProfile.name}"`,
click: () => sendToRenderer(sender, 'profile-menu:save-overwrite'),
})
}
template.push(
{ type: 'separator' },
{
label: 'Import .prsm...',
click: () => sendToRenderer(sender, 'profile-menu:import'),
},
{
label: 'Show Profiles Folder',
click: () => sendToRenderer(sender, 'profile-menu:show-folder'),
},
)
if (activeProfile && !activeProfile.isDefault) {
template.push(
{ type: 'separator' },
{
label: `Rename "${activeProfile.name}"...`,
click: () => sendToRenderer(sender, 'profile-menu:rename-active', activeProfile.id),
},
{
label: `Delete "${activeProfile.name}"`,
click: () => sendToRenderer(sender, 'profile-menu:delete-active', activeProfile.id),
},
)
}
return template
}
function normalizeBounds(raw: unknown, fallback: WindowBounds): WindowBounds {
if (typeof raw !== 'object' || raw === null) return fallback
const candidate = raw as Partial<WindowBounds>
if (
typeof candidate.x !== 'number'
|| typeof candidate.y !== 'number'
|| typeof candidate.width !== 'number'
|| typeof candidate.height !== 'number'
) {
return fallback
}
return {
x: Math.round(candidate.x),
y: Math.round(candidate.y),
width: Math.max(POPOUT_DEFAULTS.minWidth, Math.round(candidate.width)),
height: Math.max(POPOUT_DEFAULTS.minHeight, Math.round(candidate.height)),
}
}
function emitAlwaysOnTopChanged(window: BrowserWindow, next: boolean): void {
if (window.isDestroyed() || window.webContents.isDestroyed()) {
return
}
window.webContents.send('window:always-on-top-changed', next)
}
async function persistAlwaysOnTopPreference(window: BrowserWindow, next: boolean): Promise<void> {
if (isMainRendererWindow(window)) {
await getWindowStateStore().setMainAlwaysOnTop(next)
return
}
const kind = getScopeKindForWindow(window)
if (kind) {
await getWindowStateStore().setPopoutAlwaysOnTop(kind, next)
}
}
function setWindowAlwaysOnTop(window: BrowserWindow, next: boolean): void {
if (window.isDestroyed()) {
return
}
window.setAlwaysOnTop(next)
emitAlwaysOnTopChanged(window, next)
void persistAlwaysOnTopPreference(window, next).catch((error) => {
console.warn(`Could not persist always-on-top state for ${describeWindow(window)}:`, error)
})
}
function loadRendererTarget(window: BrowserWindow, query: Record<string, string>): void {
if (process.env.ELECTRON_RENDERER_URL) {
const url = new URL(process.env.ELECTRON_RENDERER_URL)
for (const [key, value] of Object.entries(query)) {
url.searchParams.set(key, value)
}
void window.loadURL(url.toString())
return
}
void window.loadFile(join(__dirname, '../renderer/index.html'), { query })
}
async function showCustomDialog(options: DialogOptions): Promise<DialogResult> {
return new Promise((resolve) => {
const height = options.type === 'prompt' ? 200 : 160
const win = new BrowserWindow({
width: 380,
height,
frame: false,
transparent: true,
backgroundColor: '#00000000',
resizable: false,
alwaysOnTop: true,
hasShadow: false,
skipTaskbar: true,
show: false,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false,
},
})
win.center()
loadRendererTarget(win, { mode: 'dialog' })
const onResult = (_event: Electron.IpcMainEvent, result: DialogResult) => {
if (_event.sender !== win.webContents) return
resolve(result)
win.destroy()
}
ipcMain.on('dialog:result', onResult)
win.webContents.once('did-finish-load', () => {
win.webContents.send('dialog:config', options)
win.show()
})
win.once('closed', () => {
ipcMain.removeListener('dialog:result', onResult)
resolve({ buttonIndex: options.cancelId ?? options.buttons.length - 1 })
})
})
}
function createMainWindow(): void {
mainWindow = new BrowserWindow({
...WINDOW_DEFAULTS,
...getFramelessWindowChromeOptions(),
alwaysOnTop: getWindowStateStore().getMainAlwaysOnTop(),
autoHideMenuBar: true,
resizable: true,
maximizable: true,
fullscreenable: true,
title: 'Prism',
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false,
backgroundThrottling: false,
},
})
syncMainWindowLogicalBounds(mainWindow)
mainWindow.on('close', (event) => {
if (allowMainWindowClose || !mainRendererReady || mainWindow?.webContents.isDestroyed()) {
allowMainWindowClose = false
mainWindowClosePending = false
return
}
event.preventDefault()
if (mainWindowClosePending) {
return
}
mainWindowClosePending = true
mainWindow?.webContents.send('window:close-requested')
})
mainWindow.on('closed', () => {
if (resizeWindow === mainWindow) {
stopWindowResizeController()
}
stopWindowMoveController()
if (mainWindowBoundsTimer) {
clearTimeout(mainWindowBoundsTimer)
mainWindowBoundsTimer = null
}
if (mainWindow) {
windowSettingsHeights.delete(mainWindow.id)
windowSettingsBottomAnchors.delete(mainWindow.id)
}
mainWindowLogicalBounds = null
suppressMainWindowSyncUntil = 0
mainRendererReady = false
allowMainWindowClose = false
mainWindowClosePending = false
mainWindow = null
for (const kind of SCOPE_KINDS) {
destroyScopePopoutWindow(kind)
}
})
mainWindow.on('move', () => {
if (!mainWindow) return
if (!isMainWindowSyncSuppressed()) {
syncMainWindowLogicalBounds(mainWindow)
}
scheduleMainWindowBoundsSave(mainWindow)
})
mainWindow.on('resize', () => {
if (!mainWindow) return
if (!isMainWindowSyncSuppressed()) {
syncMainWindowLogicalBounds(mainWindow)
}
scheduleMainWindowBoundsSave(mainWindow)
})
mainWindow.on('focus', () => {
raiseMainWindowAboveNormalPopouts()
})
loadRendererTarget(mainWindow, { window: 'main' })
}
function emitPopoutBoundsChanged(kind: ScopeKind, window: BrowserWindow): void {
if (!mainWindow || mainWindow.isDestroyed() || window.isDestroyed() || !mainRendererReady) return
const existingTimer = popoutBoundsTimers.get(kind)
if (existingTimer) {
clearTimeout(existingTimer)
}
const timer = setTimeout(() => {
popoutBoundsTimers.delete(kind)
if (!mainWindow || mainWindow.isDestroyed() || window.isDestroyed()) return
if (suppressNextPopoutBoundsEvents.has(kind)) {
suppressNextPopoutBoundsEvents.delete(kind)
return
}
const bounds = toLogicalBounds(window)
mainWindow.webContents.send('scope-popout:bounds-changed', kind, bounds)
}, 80)
popoutBoundsTimers.set(kind, timer)
}
function destroyScopePopoutWindow(kind: ScopeKind): void {
const window = scopePopoutWindows.get(kind)
if (!window) return
const pendingTimer = popoutBoundsTimers.get(kind)
if (pendingTimer) {
clearTimeout(pendingTimer)
popoutBoundsTimers.delete(kind)
}
suppressNextPopoutBoundsEvents.delete(kind)
scopePopoutCloseAllowed.add(kind)
scopePopoutWindows.delete(kind)
if (!window.isDestroyed()) {
window.close()
return
}
scopePopoutCloseAllowed.delete(kind)
}
function createScopePopoutWindow(kind: ScopeKind, rawBounds?: WindowBounds): BrowserWindow | null {
if (!mainWindow) return null
const existing = scopePopoutWindows.get(kind)
if (existing && !existing.isDestroyed()) {
return existing
}
const mainBounds = mainWindow.getBounds()
const fallbackBounds: WindowBounds = {
x: mainBounds.x + 40,
y: mainBounds.y + 40,
width: POPOUT_DEFAULTS.width,
height: POPOUT_DEFAULTS.height,
}
const bounds = normalizeBounds(rawBounds, fallbackBounds)
suppressNextPopoutBoundsEvents.add(kind)
const options: BrowserWindowConstructorOptions = {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
minWidth: POPOUT_DEFAULTS.minWidth,
minHeight: POPOUT_DEFAULTS.minHeight,
...getFramelessWindowChromeOptions(),
resizable: true,
fullscreenable: false,
maximizable: false,
minimizable: true,
skipTaskbar: true,
autoHideMenuBar: true,
title: `Prism ${kind}`,
alwaysOnTop: getWindowStateStore().getPopoutAlwaysOnTop(kind),
show: false,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false,
backgroundThrottling: false,
},
}
const popoutWindow = new BrowserWindow(options)
setSettingsHeightForWindow(popoutWindow, 0)
scopePopoutWindows.set(kind, popoutWindow)
popoutWindow.once('ready-to-show', () => {
if (!popoutWindow.isDestroyed()) {
popoutWindow.show()
raiseMainWindowAboveNormalPopouts()
}
})
popoutWindow.on('close', (event) => {
if (scopePopoutCloseAllowed.has(kind) || !mainWindow || mainWindow.isDestroyed()) {
return
}
event.preventDefault()
mainWindow.webContents.send('scope-popout:close-requested', kind)
})
popoutWindow.on('closed', () => {
if (resizeWindow === popoutWindow) {
stopWindowResizeController()
}
windowSettingsHeights.delete(popoutWindow.id)
windowSettingsBottomAnchors.delete(popoutWindow.id)
scopePopoutWindows.delete(kind)
scopePopoutCloseAllowed.delete(kind)
const pendingTimer = popoutBoundsTimers.get(kind)
if (pendingTimer) {
clearTimeout(pendingTimer)
popoutBoundsTimers.delete(kind)
}
})
popoutWindow.on('move', () => emitPopoutBoundsChanged(kind, popoutWindow))
popoutWindow.on('resize', () => emitPopoutBoundsChanged(kind, popoutWindow))
loadRendererTarget(popoutWindow, { window: 'scope-popout', scope: kind })
return popoutWindow
}
function syncScopePopouts(nextState: ScopePopoutSyncStateMap): void {
for (const kind of SCOPE_KINDS) {
const desired = nextState[kind]
if (!desired?.shouldBeOpen) {
destroyScopePopoutWindow(kind)
continue
}
const popoutWindow = createScopePopoutWindow(kind, desired.bounds)
if (!popoutWindow || popoutWindow.isDestroyed()) continue
const currentBounds = popoutWindow.getBounds()
const nextBounds = normalizeBounds(desired.bounds, currentBounds)
const hasBoundsDelta =
currentBounds.x !== nextBounds.x
|| currentBounds.y !== nextBounds.y
|| currentBounds.width !== nextBounds.width
|| currentBounds.height !== nextBounds.height
if (hasBoundsDelta) {
suppressNextPopoutBoundsEvents.add(kind)
applyLogicalBounds(popoutWindow, nextBounds)
}
}
}
function setupPermissions(): void {
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
if (permission === 'media') {
callback(true)
} else {
callback(false)
}
})
}
function setupIPC(): void {
ipcMain.on('window:minimize', (event) => {
getWindowFromSender(event.sender)?.minimize()
})
ipcMain.on('window:start-move', (event) => {
const targetWindow = getWindowFromSender(event.sender)
if (!targetWindow) return
stopWindowResizeController()
stopWindowMoveController()
const cursor = screen.getCursorScreenPoint()
moveStartCursor = { x: cursor.x, y: cursor.y }
moveStartBounds = targetWindow.getBounds()
moveInterval = setInterval(() => {
if (!targetWindow || targetWindow.isDestroyed() || !moveStartCursor || !moveStartBounds) return
const current = screen.getCursorScreenPoint()
const dx = current.x - moveStartCursor.x
const dy = current.y - moveStartCursor.y
if (isMainRendererWindow(targetWindow)) {
const nextBounds = clampDraggedMainWindowBounds({
x: moveStartBounds.x + dx,
y: moveStartBounds.y + dy,
width: moveStartBounds.width,
height: moveStartBounds.height,
}, getDisplayWorkAreas(), MAIN_WINDOW_VISIBLE_GRAB_MARGIN)
targetWindow.setPosition(nextBounds.x, nextBounds.y)
return
}
targetWindow.setPosition(moveStartBounds.x + dx, moveStartBounds.y + dy)
}, 16)
})
ipcMain.on('window:stop-move', () => {
stopWindowMoveController()
})
ipcMain.on('window:start-resize', (event, rawEdge: unknown) => {
if (process.platform !== 'win32' || !isResizeDirection(rawEdge)) return
const targetWindow = getWindowFromSender(event.sender)
if (!targetWindow || targetWindow.isDestroyed()) return
stopWindowMoveController()
stopWindowResizeController()
resizeWindow = targetWindow
resizeEdge = rawEdge
resizeStartCursor = screen.getCursorScreenPoint()
resizeStartBounds = targetWindow.getBounds()
resizeInterval = setInterval(() => {
if (
!resizeWindow
|| resizeWindow.isDestroyed()
|| !resizeStartCursor
|| !resizeStartBounds
|| !resizeEdge
) {
stopWindowResizeController()
return
}
const currentCursor = screen.getCursorScreenPoint()
const [minWidth, minHeight] = resizeWindow.getMinimumSize()
const nextBounds = calculateResizedWindowBounds({
edge: resizeEdge,
startBounds: resizeStartBounds,
startCursor: resizeStartCursor,
cursor: currentCursor,
minWidth,
minHeight,
})
resizeWindow.setBounds(nextBounds)
}, 16)
})
ipcMain.on('window:stop-resize', () => {
stopWindowResizeController()
})
ipcMain.on('window:close', (event) => {
getWindowFromSender(event.sender)?.close()
})
ipcMain.on('window:close-response', (event, shouldClose: boolean) => {
const targetWindow = getWindowFromSender(event.sender)
if (!targetWindow || !isMainRendererWindow(targetWindow)) return
mainWindowClosePending = false
if (!shouldClose) {
return
}
allowMainWindowClose = true
targetWindow.close()
})
ipcMain.on('window:toggle-always-on-top', (event) => {
const targetWindow = getWindowFromSender(event.sender)
if (!targetWindow) return
setWindowAlwaysOnTop(targetWindow, !targetWindow.isAlwaysOnTop())
})
ipcMain.handle('window:is-always-on-top', (event) => {
return getWindowFromSender(event.sender)?.isAlwaysOnTop() ?? false
})
ipcMain.handle('astra:get-config', async () => {
return getAstraIntegrationService().getConfig()
})
ipcMain.handle('astra:save-config', async (_event, rawConfig: AstraIntegrationConfig) => {
return getAstraIntegrationService().saveConfig(rawConfig)
})
ipcMain.handle('astra:get-state', async () => {
return getAstraIntegrationService().getState()
})
ipcMain.handle('astra:set-active', async (event, active: boolean) => {
return getAstraIntegrationService().setConsumerActive(event.sender.id, Boolean(active))
})
ipcMain.handle('astra:send-control', async (_event, command: AstraControlCommand) => {
await getAstraIntegrationService().sendControl(command)
return getAstraIntegrationService().getState()
})
ipcMain.handle('profiles:get-snapshot', async () => {
return getProfileLibrary().getSnapshot()
})
ipcMain.handle('profiles:save-new', async (_event, name: string, rawProfile: unknown) => {
return getProfileLibrary().saveNewProfile(name, normalizeIncomingProfile(rawProfile, name))
})
ipcMain.handle('profiles:overwrite', async (_event, id: string, rawProfile: unknown) => {
return getProfileLibrary().overwriteProfile(id, normalizeIncomingProfile(rawProfile))
})
ipcMain.handle('profiles:load', async (_event, id: string) => {
return getProfileLibrary().loadProfile(id)
})
ipcMain.handle('profiles:delete', async (_event, id: string) => {
return getProfileLibrary().deleteProfile(id)
})
ipcMain.handle('profiles:rename', async (_event, id: string, name: string) => {
return getProfileLibrary().renameProfile(id, name)
})
ipcMain.handle('profiles:import-dialog', async () => {
const targetWindow = mainWindow ?? BrowserWindow.getFocusedWindow() ?? undefined
const dialogOptions: OpenDialogOptions = {
properties: ['openFile'],
filters: [
{
name: 'Prism Profiles',
extensions: ['prsm'],
},
],
}
const result = targetWindow
? await dialog.showOpenDialog(targetWindow, dialogOptions)
: await dialog.showOpenDialog(dialogOptions)
if (result.canceled || result.filePaths.length === 0) {
return null
}
return getProfileLibrary().importProfileFromPath(result.filePaths[0])
})
ipcMain.handle('profiles:import-path', async (_event, path: string) => {
return getProfileLibrary().importProfileFromPath(path)
})
ipcMain.handle('profiles:prompt-unsaved', async (_event, profileName: string | null) => {
const result = await showCustomDialog({
type: 'confirm',
title: 'Unsaved Profile Changes',
message: profileName
? `Save changes to "${profileName}"?`
: 'Save unsaved profile changes?',
detail: 'Your profile changes will be lost if you continue without saving.',
buttons: ['Save', 'Discard', 'Cancel'],
defaultId: 0,
cancelId: 2,
})
if (result.buttonIndex === 0) return 'save'
if (result.buttonIndex === 1) return 'discard'
return 'cancel'
})
ipcMain.handle('dialog:show', async (_event, options: DialogOptions) => {
return showCustomDialog(options)
})
ipcMain.handle('profiles:reveal-folder', async () => {
const folderPath = getProfileLibrary().getProfilesDirectory()
const openResult = await shell.openPath(folderPath)
if (openResult) {
throw new Error(openResult)
}
})
ipcMain.handle('profiles:migrate-legacy', async (_event, payload: LegacyProfileMigrationPayload) => {
return getProfileLibrary().migrateLegacyProfiles(payload)
})
ipcMain.handle('themes:get-snapshot', async () => {
const snapshot = await getThemeLibrary().getSnapshot()
applyNativeThemeSnapshot(snapshot)
return snapshot
})
ipcMain.handle('themes:load', async (_event, id: string) => {
const snapshot = await getThemeLibrary().loadTheme(id)
applyNativeThemeSnapshot(snapshot)
return snapshot
})
ipcMain.handle('themes:rename', async (_event, id: string, name: string) => {
const snapshot = await getThemeLibrary().renameTheme(id, name)
applyNativeThemeSnapshot(snapshot)
return snapshot
})
ipcMain.handle('themes:delete', async (_event, id: string) => {
const snapshot = await getThemeLibrary().deleteTheme(id)
applyNativeThemeSnapshot(snapshot)
return snapshot
})
ipcMain.handle('themes:reload', async () => {
const snapshot = await getThemeLibrary().reloadThemes()
applyNativeThemeSnapshot(snapshot)
return snapshot
})
ipcMain.handle('themes:import-dialog', async () => {
const targetWindow = mainWindow ?? BrowserWindow.getFocusedWindow() ?? undefined
const dialogOptions: OpenDialogOptions = {
properties: ['openFile'],
filters: [
{
name: 'Prism Themes',
extensions: ['iro'],
},
],
}
const result = targetWindow
? await dialog.showOpenDialog(targetWindow, dialogOptions)
: await dialog.showOpenDialog(dialogOptions)
if (result.canceled || result.filePaths.length === 0) {
return null
}
const snapshot = await getThemeLibrary().importThemeFromPath(result.filePaths[0])
applyNativeThemeSnapshot(snapshot)
return snapshot
})
ipcMain.handle('themes:reveal-folder', async () => {
const folderPath = getThemeLibrary().getThemesDirectory()
const openResult = await shell.openPath(folderPath)
if (openResult) {
throw new Error(openResult)
}
})
ipcMain.handle('themes:migrate-legacy', async (_event, payload: LegacyThemeMigrationPayload) => {
const migration = await getThemeLibrary().migrateLegacyTheme(payload)
applyNativeThemeSnapshot(migration.snapshot)
return migration
})
ipcMain.on('profile-menu:open', (event, rawRequest: unknown) => {
const request = normalizeProfileMenuRequest(rawRequest)
if (!request) return
const targetWindow = getWindowFromSender(event.sender)
const menu = Menu.buildFromTemplate(buildProfileMenuTemplate(request, event.sender))
menu.popup({
window: targetWindow ?? undefined,
x: request.x,
y: request.y,
callback: () => sendToRenderer(event.sender, 'profile-menu:closed'),
})
})
ipcMain.on('window:set-bounds', (event, bounds: WindowBounds) => {
const targetWindow = getWindowFromSender(event.sender)
if (!targetWindow) return
applyLogicalBounds(targetWindow, bounds)
})
ipcMain.handle('window:get-bounds', (event) => {
const targetWindow = getWindowFromSender(event.sender)
if (!targetWindow) return null
return toLogicalBounds(targetWindow)
})
ipcMain.on('window:reposition', (event, position: 'top' | 'bottom') => {
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,
})
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)
})
ipcMain.on('window:expand-settings', (event, panelHeight: number) => {
const targetWindow = getWindowFromSender(event.sender)
if (!targetWindow) return
applySettingsHeight(targetWindow, getSettingsHeight(targetWindow) + panelHeight)
})
ipcMain.on('window:collapse-settings', (event, panelHeight: number) => {
const targetWindow = getWindowFromSender(event.sender)
if (!targetWindow) return
applySettingsHeight(targetWindow, getSettingsHeight(targetWindow) - panelHeight)
})
ipcMain.on('window:set-settings-height', (event, panelHeight: number) => {
const targetWindow = getWindowFromSender(event.sender)
if (!targetWindow) return
applySettingsHeight(targetWindow, panelHeight)
})
ipcMain.on('renderer:ready', (event) => {
const targetWindow = getWindowFromSender(event.sender)
if (!isMainRendererWindow(targetWindow)) return
mainRendererReady = true
void processPendingProfileOpenPaths()
})
ipcMain.on('scope-popout:sync', (event, state: ScopePopoutSyncStateMap) => {
const targetWindow = getWindowFromSender(event.sender)
if (!isMainRendererWindow(targetWindow)) return
syncScopePopouts(state)
})
ipcMain.on('scope-popout:snapshot', (event, snapshot: ScopePopoutSnapshot) => {
const targetWindow = getWindowFromSender(event.sender)
if (!isMainRendererWindow(targetWindow) || !isScopeKind(snapshot?.kind)) return
const popoutWindow = scopePopoutWindows.get(snapshot.kind)
if (!popoutWindow || popoutWindow.isDestroyed()) return
popoutWindow.webContents.send('scope-popout:snapshot', snapshot)
})
ipcMain.on('scope-popout:audio', (event, kind: ScopeKind, batch: ScopePopoutAudioBatch) => {
const targetWindow = getWindowFromSender(event.sender)
if (!isMainRendererWindow(targetWindow) || !isScopeKind(kind)) return
const popoutWindow = scopePopoutWindows.get(kind)
if (!popoutWindow || popoutWindow.isDestroyed()) return
popoutWindow.webContents.send('scope-popout:audio', kind, batch)
})
ipcMain.on('scope-popout:session', (event, kind: ScopeKind, sessionState: ScopePopoutSessionState) => {
const targetWindow = getWindowFromSender(event.sender)
if (!isMainRendererWindow(targetWindow) || !isScopeKind(kind)) return
const popoutWindow = scopePopoutWindows.get(kind)
if (!popoutWindow || popoutWindow.isDestroyed()) return
popoutWindow.webContents.send('scope-popout:session', kind, sessionState)
})
ipcMain.on('scope-popout:ready', (event, kind: ScopeKind) => {
const targetWindow = getWindowFromSender(event.sender)
if (!targetWindow || isMainRendererWindow(targetWindow) || !isScopeKind(kind)) return
mainWindow?.webContents.send('scope-popout:ready', kind)
})
ipcMain.on('scope-popout:request-pop-in', (event, kind: ScopeKind) => {
const targetWindow = getWindowFromSender(event.sender)
if (!targetWindow || isMainRendererWindow(targetWindow) || !isScopeKind(kind)) return
mainWindow?.webContents.send('scope-popout:close-requested', kind)
})
ipcMain.on('scope-popout:settings-update', (event, kind: ScopeKind, partial: unknown) => {
const targetWindow = getWindowFromSender(event.sender)
if (!targetWindow || isMainRendererWindow(targetWindow) || !isScopeKind(kind)) return
mainWindow?.webContents.send('scope-popout:settings-update', kind, partial)
})
}
app.commandLine.appendSwitch('enable-features', 'Metal')
const hasSingleInstanceLock = app.requestSingleInstanceLock()
if (!hasSingleInstanceLock) {
app.quit()
} else {
app.whenReady().then(async () => {
setupPermissions()
void getAstraIntegrationService().initialize()
setupIPC()
await getWindowStateStore().initialize()
await syncNativeThemeAppearance()
createMainWindow()
queueProfileOpenPaths(extractProfilePathsFromArgv(process.argv))
queueThemeOpenPaths(extractThemePathsFromArgv(process.argv))
void processPendingProfileOpenPaths()
void processPendingThemeOpenPaths()
})
app.on('open-file', (event, filePath) => {
event.preventDefault()
queueProfileOpenPath(filePath)
queueThemeOpenPath(filePath)
if (app.isReady()) {
void processPendingProfileOpenPaths()
void processPendingThemeOpenPaths()
}
})
app.on('second-instance', (_event, argv) => {
queueProfileOpenPaths(extractProfilePathsFromArgv(argv))
queueThemeOpenPaths(extractThemePathsFromArgv(argv))
if (app.isReady()) {
focusMainWindow()
void processPendingProfileOpenPaths()
void processPendingThemeOpenPaths()
}
})
}
app.on('window-all-closed', () => {
void astraIntegrationService?.dispose()
app.quit()
})