From d76abe38a8866d8b9de0f16cca59219a9f83f5f3 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:48:34 -0400 Subject: [PATCH] improve profile system --- src/main/index.ts | 143 ++++++++++++----- src/preload/index.ts | 8 + src/renderer/App.tsx | 13 +- src/renderer/components/DialogApp.tsx | 218 ++++++++++++++++++++++++++ src/renderer/components/Toolbar.tsx | 52 ++++-- src/renderer/env.d.ts | 4 + src/renderer/main.tsx | 13 +- src/renderer/stores/settingsStore.ts | 92 +++++++++-- src/shared/themeState.ts | 14 ++ src/types/dialog.ts | 15 ++ test/renderer-helpers.test.ts | 95 +++++++++-- 11 files changed, 574 insertions(+), 93 deletions(-) create mode 100644 src/renderer/components/DialogApp.tsx create mode 100644 src/types/dialog.ts diff --git a/src/main/index.ts b/src/main/index.ts index fe84959..92e666d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, desktopCapturer, dialog, ipcMain, Menu, screen, session, shell } from 'electron' +import { app, BrowserWindow, desktopCapturer, 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 { @@ -22,7 +22,9 @@ import type { 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 { calculateResizedWindowBounds } from '../shared/windowResize' import { FileBackedProfileLibrary } from './profileLibrary' import { AstraIntegrationService } from './services/astraIntegration' @@ -216,10 +218,22 @@ async function processPendingThemeOpenPaths(): Promise { 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 { + applyNativeThemeSnapshot(await getThemeLibrary().getSnapshot()) +} + function scheduleMainWindowBoundsSave(window: BrowserWindow): void { if (!isMainRendererWindow(window) || !mainRendererReady) return @@ -620,6 +634,51 @@ function loadRendererTarget(window: BrowserWindow, query: Record void window.loadFile(join(__dirname, '../renderer/index.html'), { query }) } +async function showCustomDialog(options: DialogOptions): Promise { + 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, @@ -1081,45 +1140,28 @@ function setupIPC(): void { return getProfileLibrary().importProfileFromPath(path) }) - ipcMain.handle('profiles:prompt-unsaved', async (event, profileName: string | null) => { - const targetWindow = getWindowFromSender(event.sender) ?? mainWindow ?? undefined - const { response } = targetWindow - ? await dialog.showMessageBox(targetWindow, { - type: 'warning', - 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, - noLink: true, - }) - : await dialog.showMessageBox({ - type: 'warning', - 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, - noLink: true, - }) - - if (response === 0) { - return 'save' - } - - if (response === 1) { - return 'discard' - } + 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) @@ -1133,23 +1175,33 @@ function setupIPC(): void { }) ipcMain.handle('themes:get-snapshot', async () => { - return getThemeLibrary().getSnapshot() + const snapshot = await getThemeLibrary().getSnapshot() + applyNativeThemeSnapshot(snapshot) + return snapshot }) ipcMain.handle('themes:load', async (_event, id: string) => { - return getThemeLibrary().loadTheme(id) + const snapshot = await getThemeLibrary().loadTheme(id) + applyNativeThemeSnapshot(snapshot) + return snapshot }) ipcMain.handle('themes:rename', async (_event, id: string, name: string) => { - return getThemeLibrary().renameTheme(id, name) + const snapshot = await getThemeLibrary().renameTheme(id, name) + applyNativeThemeSnapshot(snapshot) + return snapshot }) ipcMain.handle('themes:delete', async (_event, id: string) => { - return getThemeLibrary().deleteTheme(id) + const snapshot = await getThemeLibrary().deleteTheme(id) + applyNativeThemeSnapshot(snapshot) + return snapshot }) ipcMain.handle('themes:reload', async () => { - return getThemeLibrary().reloadThemes() + const snapshot = await getThemeLibrary().reloadThemes() + applyNativeThemeSnapshot(snapshot) + return snapshot }) ipcMain.handle('themes:import-dialog', async () => { @@ -1171,7 +1223,9 @@ function setupIPC(): void { return null } - return getThemeLibrary().importThemeFromPath(result.filePaths[0]) + const snapshot = await getThemeLibrary().importThemeFromPath(result.filePaths[0]) + applyNativeThemeSnapshot(snapshot) + return snapshot }) ipcMain.handle('themes:reveal-folder', async () => { @@ -1183,7 +1237,9 @@ function setupIPC(): void { }) ipcMain.handle('themes:migrate-legacy', async (_event, payload: LegacyThemeMigrationPayload) => { - return getThemeLibrary().migrateLegacyTheme(payload) + const migration = await getThemeLibrary().migrateLegacyTheme(payload) + applyNativeThemeSnapshot(migration.snapshot) + return migration }) ipcMain.on('profile-menu:open', (event, rawRequest: unknown) => { @@ -1324,6 +1380,7 @@ if (!hasSingleInstanceLock) { void getAstraIntegrationService().initialize() setupIPC() await getWindowStateStore().initialize() + await syncNativeThemeAppearance() createMainWindow() queueProfileOpenPaths(extractProfilePathsFromArgv(process.argv)) queueThemeOpenPaths(extractThemePathsFromArgv(process.argv)) diff --git a/src/preload/index.ts b/src/preload/index.ts index a3d6620..b9e8ba3 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -26,6 +26,7 @@ import type { LegacyThemeMigrationResult, ThemeLibrarySnapshot, } from '../types/theme' +import type { DialogOptions, DialogResult } from '../types/dialog' import type { ResizeDirection } from '../types/windowResize' import type { VisualizerDSP } from '../renderer/audio/native/visualizer-dsp' @@ -217,6 +218,13 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on('scope-popout:session', handler) return () => ipcRenderer.removeListener('scope-popout:session', handler) }, + showDialog: (options: DialogOptions) => ipcRenderer.invoke('dialog:show', options) as Promise, + onDialogConfig: (callback: (options: DialogOptions) => void) => { + const handler = (_event: Electron.IpcRendererEvent, options: DialogOptions): void => callback(options) + ipcRenderer.on('dialog:config', handler) + return () => ipcRenderer.removeListener('dialog:config', handler) + }, + sendDialogResult: (result: DialogResult) => ipcRenderer.send('dialog:result', result), }) // Native DSP module — load if available, gracefully degrade if not diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index f0fdcd6..83a7d2b 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -77,10 +77,15 @@ export default function App(): JSX.Element { }) }) const unsubscribeCloseRequested = window.electronAPI.onMainCloseRequested(() => { - void (async () => { - const shouldClose = await guardProfileTransition(async () => {}) - window.electronAPI.respondToCloseRequest(shouldClose) - })() + void window.electronAPI.getWindowBounds() + .then((bounds) => { + if (bounds) { + updateMainWindowBounds(bounds) + } + }) + .finally(() => { + window.electronAPI.respondToCloseRequest(true) + }) }) return () => { diff --git a/src/renderer/components/DialogApp.tsx b/src/renderer/components/DialogApp.tsx new file mode 100644 index 0000000..f1e4d1f --- /dev/null +++ b/src/renderer/components/DialogApp.tsx @@ -0,0 +1,218 @@ +import { useState, useEffect, useRef, useCallback, type JSX, type KeyboardEvent } from 'react' +import type { DialogOptions, DialogResult } from '../../types/dialog' + +export default function DialogApp(): JSX.Element { + const [config, setConfig] = useState(null) + const [inputValue, setInputValue] = useState('') + const inputRef = useRef(null) + + useEffect(() => { + const unsubscribe = window.electronAPI.onDialogConfig((options) => { + setConfig(options) + setInputValue(options.defaultValue ?? '') + }) + return unsubscribe + }, []) + + useEffect(() => { + if (config?.type === 'prompt' && inputRef.current) { + inputRef.current.focus() + inputRef.current.select() + } + }, [config]) + + const submit = useCallback((buttonIndex: number) => { + if (!config) return + const result: DialogResult = { buttonIndex } + if (config.type === 'prompt') { + result.value = inputValue + } + window.electronAPI.sendDialogResult(result) + }, [config, inputValue]) + + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (!config) return + if (e.key === 'Enter') { + const defaultId = config.defaultId ?? 0 + submit(defaultId) + } else if (e.key === 'Escape') { + const cancelId = config.cancelId ?? config.buttons.length - 1 + submit(cancelId) + } + }, [config, submit]) + + if (!config) { + return
+ } + + const primaryIndex = config.defaultId ?? 0 + const cancelId = config.cancelId ?? config.buttons.length - 1 + + return ( +
+
+
+
{config.title}
+
{config.message}
+ {config.detail && ( +
{config.detail}
+ )} + {config.type === 'prompt' && ( + setInputValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.stopPropagation() + submit(primaryIndex) + } + }} + /> + )} +
+
+ {config.buttons.map((label, i) => ( + + ))} +
+
+ +
+ ) +} diff --git a/src/renderer/components/Toolbar.tsx b/src/renderer/components/Toolbar.tsx index 7a41f8b..d746e74 100644 --- a/src/renderer/components/Toolbar.tsx +++ b/src/renderer/components/Toolbar.tsx @@ -113,13 +113,22 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps): }, []) const handleSaveNew = useCallback(async () => { + setIsProfileMenuOpen(false) const count = Object.keys(useSettingsStore.getState().profiles).length + const result = await window.electronAPI.showDialog({ + type: 'prompt', + title: 'Save as New Profile', + message: 'Profile name', + buttons: ['Save', 'Cancel'], + defaultId: 0, + cancelId: 1, + defaultValue: `Profile ${count}`, + }) + if (result.buttonIndex !== 0 || !result.value?.trim()) return try { - await saveProfile(`Profile ${count}`) + await saveProfile(result.value.trim()) } catch (error) { window.alert(getErrorMessage(error, 'Could not save the profile.')) - } finally { - setIsProfileMenuOpen(false) } }, [saveProfile]) @@ -140,18 +149,22 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps): return } - const nextName = window.prompt('Rename profile', profile.name)?.trim() - if (!nextName) { - setIsProfileMenuOpen(false) - return - } + setIsProfileMenuOpen(false) + const result = await window.electronAPI.showDialog({ + type: 'prompt', + title: 'Rename Profile', + message: `New name for "${profile.name}"`, + buttons: ['Rename', 'Cancel'], + defaultId: 0, + cancelId: 1, + defaultValue: profile.name, + }) + if (result.buttonIndex !== 0 || !result.value?.trim()) return try { - await renameProfile(id, nextName) + await renameProfile(id, result.value.trim()) } catch (error) { window.alert(getErrorMessage(error, 'Could not rename the profile.')) - } finally { - setIsProfileMenuOpen(false) } }, [renameProfile]) @@ -162,17 +175,22 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps): return } - if (!window.confirm(`Delete "${profile.name}"?`)) { - setIsProfileMenuOpen(false) - return - } + setIsProfileMenuOpen(false) + const result = await window.electronAPI.showDialog({ + type: 'confirm', + title: 'Delete Profile', + message: `Delete "${profile.name}"?`, + detail: 'This cannot be undone.', + buttons: ['Delete', 'Cancel'], + defaultId: 1, + cancelId: 1, + }) + if (result.buttonIndex !== 0) return try { await deleteProfile(id) } catch (error) { window.alert(getErrorMessage(error, 'Could not delete the profile.')) - } finally { - setIsProfileMenuOpen(false) } }, [deleteProfile]) diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts index a1b9465..b83ab2f 100644 --- a/src/renderer/env.d.ts +++ b/src/renderer/env.d.ts @@ -28,6 +28,7 @@ import type { LegacyThemeMigrationResult, ThemeLibrarySnapshot, } from '../types/theme' +import type { DialogOptions, DialogResult } from '../types/dialog' import type { ResizeDirection } from '../types/windowResize' declare global { @@ -111,6 +112,9 @@ declare global { onScopePopoutSnapshot: (callback: (snapshot: ScopePopoutSnapshot) => void) => () => void onScopePopoutAudio: (callback: (kind: ScopeKind, batch: ScopePopoutAudioBatch) => void) => () => void onScopePopoutSession: (callback: (kind: ScopeKind, session: ScopePopoutSessionState) => void) => () => void + showDialog: (options: DialogOptions) => Promise + onDialogConfig: (callback: (options: DialogOptions) => void) => () => void + sendDialogResult: (result: DialogResult) => void } } } diff --git a/src/renderer/main.tsx b/src/renderer/main.tsx index c576328..1307379 100644 --- a/src/renderer/main.tsx +++ b/src/renderer/main.tsx @@ -2,6 +2,7 @@ import React from 'react' import ReactDOM from 'react-dom/client' import App from './App' import ScopePopoutWindow from './popouts/ScopePopoutWindow' +import DialogApp from './components/DialogApp' import './styles/globals.css' import '@fontsource/inter/400.css' import '@fontsource/inter/500.css' @@ -14,14 +15,20 @@ function isScopeKind(value: string | null): value is ScopeKind { } const params = new URLSearchParams(window.location.search) +const windowMode = params.get('mode') const windowRole = params.get('window') const scopeKind = params.get('scope') -const root = windowRole === 'scope-popout' - ? isScopeKind(scopeKind) +let root: React.ReactElement +if (windowMode === 'dialog') { + root = +} else if (windowRole === 'scope-popout') { + root = isScopeKind(scopeKind) ? :
Invalid scope popout
- : +} else { + root = +} ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/src/renderer/stores/settingsStore.ts b/src/renderer/stores/settingsStore.ts index 5932b08..8aed849 100644 --- a/src/renderer/stores/settingsStore.ts +++ b/src/renderer/stores/settingsStore.ts @@ -83,6 +83,10 @@ function canUseElectronAPI(): boolean { return typeof window !== 'undefined' && typeof window.electronAPI !== 'undefined' } +function canUseBrowserStorage(): boolean { + return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined' +} + function getErrorMessage(error: unknown, fallback: string): string { return error instanceof Error && error.message ? error.message @@ -90,9 +94,20 @@ function getErrorMessage(error: unknown, fallback: string): string { } function loadFromStorage(): Partial { + if (!canUseBrowserStorage()) { + return {} + } + try { const raw = localStorage.getItem(STORAGE_KEY) - if (raw) return JSON.parse(raw) as Partial + if (!raw) { + return {} + } + + const parsed = JSON.parse(raw) as unknown + if (typeof parsed === 'object' && parsed !== null) { + return parsed as Partial + } } catch { // Ignore localStorage read failures. } @@ -100,6 +115,10 @@ function loadFromStorage(): Partial { } function saveToStorage(state: WorkingSettingsState): void { + if (!canUseBrowserStorage()) { + return + } + try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ themeId: state.themeId, @@ -116,9 +135,17 @@ function saveToStorage(state: WorkingSettingsState): void { } function persistWorkingState(state: WorkingSettingsState): void { - if (!canUseElectronAPI()) { - saveToStorage(state) - } + saveToStorage(state) +} + +function hasPersistedWorkingState(state: Partial): boolean { + return 'themeId' in state + || 'scopeOrder' in state + || 'hiddenScopes' in state + || 'widthWeights' in state + || 'scopeSettings' in state + || 'scopePopouts' in state + || 'windowBounds' in state } function loadLegacyProfileMigrationPayload(): LegacyProfileMigrationPayload | null { @@ -178,6 +205,20 @@ function createWorkingStateFromProfile(profile: Profile): WorkingSettingsState { } } +function createWorkingStateFromPersistedState(state: Partial): WorkingSettingsState { + return { + themeId: typeof state.themeId === 'string' && state.themeId.trim() + ? state.themeId.trim() + : null, + scopeOrder: normalizeScopeOrder(state.scopeOrder), + hiddenScopes: new Set(normalizeHiddenScopes(state.hiddenScopes)), + widthWeights: normalizeWidthWeights(state.widthWeights), + scopeSettings: mergeScopeSettings(state.scopeSettings), + scopePopouts: normalizeScopePopouts(state.scopePopouts), + windowBounds: state.windowBounds, + } +} + function getActiveProfileName(state: Pick): string | null { if (!state.activeProfileId) { return null @@ -454,18 +495,8 @@ async function restoreSavedProfileBaseline( syncCurrentMainWindowBounds(set) } -const stored = canUseElectronAPI() ? {} : loadFromStorage() -const initialWorkingState: WorkingSettingsState = { - themeId: typeof stored.themeId === 'string' && stored.themeId.trim() - ? stored.themeId.trim() - : null, - scopeOrder: normalizeScopeOrder(stored.scopeOrder), - hiddenScopes: new Set(normalizeHiddenScopes(stored.hiddenScopes)), - widthWeights: normalizeWidthWeights(stored.widthWeights), - scopeSettings: mergeScopeSettings(stored.scopeSettings), - scopePopouts: normalizeScopePopouts(stored.scopePopouts), - windowBounds: stored.windowBounds, -} +const stored = loadFromStorage() +const initialWorkingState = createWorkingStateFromPersistedState(stored) export const useSettingsStore = create((set, get) => ({ ...initialWorkingState, @@ -496,7 +527,34 @@ export const useSettingsStore = create((set, get) => ({ } } - applyProfileSnapshot(set, snapshot, { loadActiveProfile: true }) + // If localStorage has a working state from a previous session, preserve it so the + // user picks up exactly where they left off (dirty or not). The saved profile becomes + // the baseline for dirty-state comparison but the working values are left as-is. + // On first launch (no stored state) we load the profile normally. + const storedWorkingState = loadFromStorage() + const hasStoredWorkingState = hasPersistedWorkingState(storedWorkingState) + applyProfileSnapshot(set, snapshot, { loadActiveProfile: !hasStoredWorkingState }) + if (hasStoredWorkingState) { + set((state) => commitWorkingState( + state, + createWorkingStateFromPersistedState(storedWorkingState), + state.savedProfileBaseline, + )) + + // Restore window position: use the working-state bounds (last known position, + // possibly dirty) or fall back to the saved profile's bounds if none exist. + const state = get() + const boundsToApply = state.windowBounds ?? state.savedProfileBaseline?.windowBounds ?? null + if (boundsToApply) { + window.electronAPI.setWindowBounds(boundsToApply) + } + syncCurrentMainWindowBounds(set) + const { themeId } = state + const { themes, loadTheme, activeThemeId } = useThemeStore.getState() + if (themeId && themeId !== activeThemeId && themes[themeId]) { + void loadTheme(themeId) + } + } }, applyExternalProfileSnapshot: (snapshot: ProfileLibrarySnapshot) => { diff --git a/src/shared/themeState.ts b/src/shared/themeState.ts index 2feb054..9ec0abf 100644 --- a/src/shared/themeState.ts +++ b/src/shared/themeState.ts @@ -322,6 +322,10 @@ function parseCssColor(value: string): RgbaColor | null { return { r, g, b, a } } +function getPerceivedBrightness(color: RgbaColor): number { + return ((color.r * 299) + (color.g * 587) + (color.b * 114)) / 1000 +} + function toCssColor(color: RgbaColor): string { if (Math.abs(color.a - 1) < 0.001) { return `rgb(${color.r}, ${color.g}, ${color.b})` @@ -1142,6 +1146,16 @@ export function resolveTheme(theme: PrismTheme): PrismResolvedTheme { } } +export function resolveNativeThemeSource(theme: PrismTheme | null | undefined): 'dark' | 'light' { + const resolved = resolveTheme(theme ?? createDefaultTheme()) + const parsed = parseCssColor(resolved.interface.menuBg) ?? parseThemeChannelColor(resolved.interface.menuBg) + if (!parsed) { + return 'dark' + } + + return getPerceivedBrightness(parsed) >= 156 ? 'light' : 'dark' +} + export function resolveLegacyThemeToPresetId(payload: LegacyThemeMigrationPayload): string | null { switch (payload.presetId) { case 'default': diff --git a/src/types/dialog.ts b/src/types/dialog.ts new file mode 100644 index 0000000..966229d --- /dev/null +++ b/src/types/dialog.ts @@ -0,0 +1,15 @@ +export interface DialogOptions { + type: 'confirm' | 'prompt' + title: string + message: string + detail?: string + buttons: string[] + defaultId?: number + cancelId?: number + defaultValue?: string +} + +export interface DialogResult { + buttonIndex: number + value?: string +} diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index 8965ebd..371c635 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -18,7 +18,7 @@ import { createDefaultProfile, } from '../src/shared/profileState' import { calculateResizedWindowBounds } from '../src/shared/windowResize' -import { createDefaultTheme, resolveTheme } from '../src/shared/themeState' +import { createDefaultTheme, resolveNativeThemeSource, resolveTheme } from '../src/shared/themeState' import { usePerformanceStore } from '../src/renderer/stores/performanceStore' import { buildProfileDraft, profilesMatch } from '../src/renderer/stores/profileDraft' import { @@ -223,6 +223,8 @@ function createScopePopouts(poppedOutScopes: ScopeKind[] = []): ScopePopoutState function installFakeLocalStorage(): { getSetCount: () => number + getItem: (key: string) => string | null + setItem: (key: string, value: string) => void restore: () => void } { const storage = new Map() @@ -254,6 +256,12 @@ function installFakeLocalStorage(): { return { getSetCount: () => setCount, + getItem(key: string): string | null { + return storage.get(key) ?? null + }, + setItem(key: string, value: string): void { + storage.set(key, value) + }, restore(): void { if (previousLocalStorage === undefined) { delete globalWithStorage.localStorage @@ -1097,6 +1105,21 @@ test('buildProfileDraft preserves unlinked themes instead of coercing the active assert.equal(draft.themeId, null) }) +test('resolveNativeThemeSource follows the active theme brightness for native UI', () => { + const darkTheme = createDefaultTheme() + const lightTheme = createDefaultTheme() + lightTheme.app.background = 'rgb(248, 250, 252)' + lightTheme.app.surface = 'rgba(255, 255, 255, 0.96)' + lightTheme.app.surfaceAlt = 'rgba(255, 255, 255, 0.92)' + lightTheme.app.text = 'rgb(15, 23, 42)' + lightTheme.app.textMuted = 'rgba(15, 23, 42, 0.48)' + lightTheme.controls.menuSurface = 'rgb(255, 255, 255)' + lightTheme.controls.menuBorder = 'rgba(15, 23, 42, 0.12)' + + assert.equal(resolveNativeThemeSource(darkTheme), 'dark') + assert.equal(resolveNativeThemeSource(lightTheme), 'light') +}) + test('toggleScope appends astra to the scope order when it is enabled from an opt-in profile', () => { const previousSettingsState = useSettingsStore.getState() const fakeWindow = installFakeElectronWindow() @@ -1118,7 +1141,7 @@ test('toggleScope appends astra to the scope order when it is enabled from an op } }) -test('main-window bounds updates stay in memory in Electron mode until save', () => { +test('main-window bounds updates persist working state in Electron mode', () => { const previousSettingsState = useSettingsStore.getState() const fakeStorage = installFakeLocalStorage() const fakeWindow = installFakeElectronWindow() @@ -1131,15 +1154,69 @@ test('main-window bounds updates stay in memory in Electron mode until save', () useSettingsStore.getState().updateMainWindowBounds({ x: 10, y: 20, width: 900, height: 180 }) assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false) - assert.equal(fakeStorage.getSetCount(), 0) + assert.equal(fakeStorage.getSetCount(), 1) useSettingsStore.getState().updateMainWindowBounds({ x: 24, y: 20, width: 900, height: 180 }) assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, true) - assert.equal(fakeStorage.getSetCount(), 0) + assert.equal(fakeStorage.getSetCount(), 2) useSettingsStore.getState().updateMainWindowBounds({ x: 10, y: 20, width: 900, height: 180 }) assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false) - assert.equal(fakeStorage.getSetCount(), 0) + assert.equal(fakeStorage.getSetCount(), 3) + } finally { + useSettingsStore.setState(previousSettingsState) + fakeWindow.restore() + fakeStorage.restore() + } +}) + +test('initializeProfiles restores persisted dirty window bounds while keeping the saved profile as baseline', async () => { + const previousSettingsState = useSettingsStore.getState() + const fakeStorage = installFakeLocalStorage() + const restoredBounds: WindowBounds[] = [] + const dirtyBounds = { x: 44, y: 55, width: 900, height: 180 } + const fakeWindow = installFakeElectronWindow({ + getProfileSnapshot: async () => { + const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) + profile.themeId = 'theme_default' + profile.windowBounds = { x: 10, y: 20, width: 900, height: 180 } + + return { + activeProfileId: DEFAULT_PROFILE_ID, + profiles: { + [DEFAULT_PROFILE_ID]: profile, + }, + } + }, + getWindowBounds: async () => dirtyBounds, + setWindowBounds: (bounds: WindowBounds) => { + restoredBounds.push(bounds) + }, + }) + + try { + const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) + profile.themeId = 'theme_default' + profile.windowBounds = { x: 10, y: 20, width: 900, height: 180 } + + fakeStorage.setItem('prism:settings', JSON.stringify({ + themeId: profile.themeId, + scopeOrder: profile.scopeOrder, + hiddenScopes: profile.hiddenScopes, + widthWeights: profile.widthWeights, + scopeSettings: profile.scopeSettings, + scopePopouts: profile.scopePopouts, + windowBounds: dirtyBounds, + })) + + await useSettingsStore.getState().initializeProfiles() + + const state = useSettingsStore.getState() + assert.equal(state.activeProfileId, DEFAULT_PROFILE_ID) + assert.deepEqual(state.windowBounds, dirtyBounds) + assert.deepEqual(state.savedProfileBaseline?.windowBounds, profile.windowBounds) + assert.equal(state.hasUnsavedProfileChanges, true) + assert.deepEqual(restoredBounds, [dirtyBounds]) } finally { useSettingsStore.setState(previousSettingsState) fakeWindow.restore() @@ -1332,7 +1409,7 @@ test('geometry sync window extends while load-time macOS bound updates continue' } }) -test('popout bounds updates stay in memory in Electron mode until save', () => { +test('popout bounds updates persist working state in Electron mode', () => { const previousSettingsState = useSettingsStore.getState() const fakeStorage = installFakeLocalStorage() const fakeWindow = installFakeElectronWindow() @@ -1348,15 +1425,15 @@ test('popout bounds updates stay in memory in Electron mode until save', () => { useSettingsStore.getState().updatePopoutBounds('spectrum', { x: 140, y: 60, width: 420, height: 240 }) assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false) - assert.equal(fakeStorage.getSetCount(), 0) + assert.equal(fakeStorage.getSetCount(), 1) useSettingsStore.getState().updatePopoutBounds('spectrum', { x: 180, y: 60, width: 420, height: 240 }) assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, true) - assert.equal(fakeStorage.getSetCount(), 0) + assert.equal(fakeStorage.getSetCount(), 2) useSettingsStore.getState().updatePopoutBounds('spectrum', { x: 140, y: 60, width: 420, height: 240 }) assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false) - assert.equal(fakeStorage.getSetCount(), 0) + assert.equal(fakeStorage.getSetCount(), 3) } finally { useSettingsStore.setState(previousSettingsState) fakeWindow.restore()