improve profile system

This commit is contained in:
Boof2015
2026-03-31 15:48:34 -04:00
parent 3a8a6dae75
commit d76abe38a8
11 changed files with 574 additions and 93 deletions
+100 -43
View File
@@ -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 type { BrowserWindowConstructorOptions, MenuItemConstructorOptions, OpenDialogOptions, WebContents } from 'electron'
import { extname, join, resolve } from 'path' import { extname, join, resolve } from 'path'
import type { import type {
@@ -22,7 +22,9 @@ import type {
ThemeLibrarySnapshot, ThemeLibrarySnapshot,
} from '../types/theme' } from '../types/theme'
import { RESIZE_DIRECTIONS, type ResizeDirection } from '../types/windowResize' import { RESIZE_DIRECTIONS, type ResizeDirection } from '../types/windowResize'
import type { DialogOptions, DialogResult } from '../types/dialog'
import { normalizeProfile } from '../shared/profileState' import { normalizeProfile } from '../shared/profileState'
import { resolveNativeThemeSource } from '../shared/themeState'
import { calculateResizedWindowBounds } from '../shared/windowResize' import { calculateResizedWindowBounds } from '../shared/windowResize'
import { FileBackedProfileLibrary } from './profileLibrary' import { FileBackedProfileLibrary } from './profileLibrary'
import { AstraIntegrationService } from './services/astraIntegration' import { AstraIntegrationService } from './services/astraIntegration'
@@ -216,10 +218,22 @@ async function processPendingThemeOpenPaths(): Promise<void> {
if (!latestSnapshot || !mainWindow || mainWindow.isDestroyed()) return if (!latestSnapshot || !mainWindow || mainWindow.isDestroyed()) return
applyNativeThemeSnapshot(latestSnapshot)
focusMainWindow() focusMainWindow()
mainWindow.webContents.send('themes:external-activated', latestSnapshot) 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 { function scheduleMainWindowBoundsSave(window: BrowserWindow): void {
if (!isMainRendererWindow(window) || !mainRendererReady) return if (!isMainRendererWindow(window) || !mainRendererReady) return
@@ -620,6 +634,51 @@ function loadRendererTarget(window: BrowserWindow, query: Record<string, string>
void window.loadFile(join(__dirname, '../renderer/index.html'), { query }) 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 { function createMainWindow(): void {
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
...WINDOW_DEFAULTS, ...WINDOW_DEFAULTS,
@@ -1081,45 +1140,28 @@ function setupIPC(): void {
return getProfileLibrary().importProfileFromPath(path) return getProfileLibrary().importProfileFromPath(path)
}) })
ipcMain.handle('profiles:prompt-unsaved', async (event, profileName: string | null) => { ipcMain.handle('profiles:prompt-unsaved', async (_event, profileName: string | null) => {
const targetWindow = getWindowFromSender(event.sender) ?? mainWindow ?? undefined const result = await showCustomDialog({
const { response } = targetWindow type: 'confirm',
? await dialog.showMessageBox(targetWindow, { title: 'Unsaved Profile Changes',
type: 'warning', message: profileName
title: 'Unsaved Profile Changes', ? `Save changes to "${profileName}"?`
message: profileName : 'Save unsaved profile changes?',
? `Save changes to "${profileName}"?` detail: 'Your profile changes will be lost if you continue without saving.',
: 'Save unsaved profile changes?', buttons: ['Save', 'Discard', 'Cancel'],
detail: 'Your profile changes will be lost if you continue without saving.', defaultId: 0,
buttons: ['Save', 'Discard', 'Cancel'], cancelId: 2,
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'
}
if (result.buttonIndex === 0) return 'save'
if (result.buttonIndex === 1) return 'discard'
return 'cancel' return 'cancel'
}) })
ipcMain.handle('dialog:show', async (_event, options: DialogOptions) => {
return showCustomDialog(options)
})
ipcMain.handle('profiles:reveal-folder', async () => { ipcMain.handle('profiles:reveal-folder', async () => {
const folderPath = getProfileLibrary().getProfilesDirectory() const folderPath = getProfileLibrary().getProfilesDirectory()
const openResult = await shell.openPath(folderPath) const openResult = await shell.openPath(folderPath)
@@ -1133,23 +1175,33 @@ function setupIPC(): void {
}) })
ipcMain.handle('themes:get-snapshot', async () => { 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) => { 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) => { 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) => { 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 () => { ipcMain.handle('themes:reload', async () => {
return getThemeLibrary().reloadThemes() const snapshot = await getThemeLibrary().reloadThemes()
applyNativeThemeSnapshot(snapshot)
return snapshot
}) })
ipcMain.handle('themes:import-dialog', async () => { ipcMain.handle('themes:import-dialog', async () => {
@@ -1171,7 +1223,9 @@ function setupIPC(): void {
return null 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 () => { ipcMain.handle('themes:reveal-folder', async () => {
@@ -1183,7 +1237,9 @@ function setupIPC(): void {
}) })
ipcMain.handle('themes:migrate-legacy', async (_event, payload: LegacyThemeMigrationPayload) => { 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) => { ipcMain.on('profile-menu:open', (event, rawRequest: unknown) => {
@@ -1324,6 +1380,7 @@ if (!hasSingleInstanceLock) {
void getAstraIntegrationService().initialize() void getAstraIntegrationService().initialize()
setupIPC() setupIPC()
await getWindowStateStore().initialize() await getWindowStateStore().initialize()
await syncNativeThemeAppearance()
createMainWindow() createMainWindow()
queueProfileOpenPaths(extractProfilePathsFromArgv(process.argv)) queueProfileOpenPaths(extractProfilePathsFromArgv(process.argv))
queueThemeOpenPaths(extractThemePathsFromArgv(process.argv)) queueThemeOpenPaths(extractThemePathsFromArgv(process.argv))
+8
View File
@@ -26,6 +26,7 @@ import type {
LegacyThemeMigrationResult, LegacyThemeMigrationResult,
ThemeLibrarySnapshot, ThemeLibrarySnapshot,
} from '../types/theme' } from '../types/theme'
import type { DialogOptions, DialogResult } from '../types/dialog'
import type { ResizeDirection } from '../types/windowResize' import type { ResizeDirection } from '../types/windowResize'
import type { VisualizerDSP } from '../renderer/audio/native/visualizer-dsp' import type { VisualizerDSP } from '../renderer/audio/native/visualizer-dsp'
@@ -217,6 +218,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.on('scope-popout:session', handler) ipcRenderer.on('scope-popout:session', handler)
return () => ipcRenderer.removeListener('scope-popout:session', handler) return () => ipcRenderer.removeListener('scope-popout:session', handler)
}, },
showDialog: (options: DialogOptions) => ipcRenderer.invoke('dialog:show', options) as Promise<DialogResult>,
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 // Native DSP module — load if available, gracefully degrade if not
+9 -4
View File
@@ -77,10 +77,15 @@ export default function App(): JSX.Element {
}) })
}) })
const unsubscribeCloseRequested = window.electronAPI.onMainCloseRequested(() => { const unsubscribeCloseRequested = window.electronAPI.onMainCloseRequested(() => {
void (async () => { void window.electronAPI.getWindowBounds()
const shouldClose = await guardProfileTransition(async () => {}) .then((bounds) => {
window.electronAPI.respondToCloseRequest(shouldClose) if (bounds) {
})() updateMainWindowBounds(bounds)
}
})
.finally(() => {
window.electronAPI.respondToCloseRequest(true)
})
}) })
return () => { return () => {
+218
View File
@@ -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<DialogOptions | null>(null)
const [inputValue, setInputValue] = useState('')
const inputRef = useRef<HTMLInputElement>(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<HTMLDivElement>) => {
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 <div className="dialog-root" />
}
const primaryIndex = config.defaultId ?? 0
const cancelId = config.cancelId ?? config.buttons.length - 1
return (
<div className="dialog-root" onKeyDown={handleKeyDown} tabIndex={-1}>
<div className="dialog-window">
<div className="dialog-content">
<div className="dialog-title">{config.title}</div>
<div className="dialog-message">{config.message}</div>
{config.detail && (
<div className="dialog-detail">{config.detail}</div>
)}
{config.type === 'prompt' && (
<input
ref={inputRef}
type="text"
className="dialog-input"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.stopPropagation()
submit(primaryIndex)
}
}}
/>
)}
</div>
<div className="dialog-buttons">
{config.buttons.map((label, i) => (
<button
key={label}
type="button"
className={[
'dialog-btn',
i === primaryIndex ? 'dialog-btn--primary' : '',
i === cancelId && i !== primaryIndex ? 'dialog-btn--cancel' : '',
label.toLowerCase() === 'delete' || label.toLowerCase() === 'discard'
? 'dialog-btn--danger'
: '',
].filter(Boolean).join(' ')}
onClick={() => submit(i)}
autoFocus={i === primaryIndex && config.type !== 'prompt'}
>
{label}
</button>
))}
</div>
</div>
<style>{`
.dialog-root {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 8px;
background: transparent;
-webkit-app-region: no-drag;
}
.dialog-window {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: var(--panel-surface);
border: 1px solid var(--panel-outline);
box-shadow:
0 22px 56px rgba(0, 0, 0, 0.58),
inset 0 1px 0 rgba(255, 255, 255, 0.04);
border-radius: 12px;
overflow: hidden;
}
.dialog-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
padding: 20px 20px 16px;
}
.dialog-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.3;
}
.dialog-message {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.45;
}
.dialog-detail {
font-size: 11px;
color: var(--text-tertiary);
line-height: 1.45;
margin-top: 2px;
}
.dialog-input {
margin-top: 6px;
width: 100%;
background: var(--input-bg);
border: 1px solid var(--input-border);
border-radius: 5px;
padding: 6px 9px;
font-size: 12px;
color: var(--text-primary);
outline: none;
transition: border-color 0.12s;
}
.dialog-input:focus {
border-color: var(--input-border-focus);
background: var(--input-bg-focus);
}
.dialog-buttons {
display: flex;
flex-direction: row-reverse;
gap: 7px;
padding: 0 14px 14px;
}
.dialog-btn {
height: 28px;
padding: 0 14px;
border-radius: 5px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
color: var(--text-primary);
background: var(--control-bg);
border-color: var(--control-border);
transition: background 0.1s, border-color 0.1s;
}
.dialog-btn:hover {
background: var(--control-bg-hover);
}
.dialog-btn--primary {
background: rgba(var(--accent-rgb), 0.18);
border-color: rgba(var(--accent-rgb), 0.32);
color: var(--accent-hover);
}
.dialog-btn--primary:hover {
background: rgba(var(--accent-rgb), 0.26);
border-color: rgba(var(--accent-rgb), 0.48);
}
.dialog-btn--danger {
background: rgba(248, 113, 113, 0.12);
border-color: rgba(248, 113, 113, 0.28);
color: var(--danger);
}
.dialog-btn--danger:hover {
background: rgba(248, 113, 113, 0.2);
}
`}</style>
</div>
)
}
+35 -17
View File
@@ -113,13 +113,22 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
}, []) }, [])
const handleSaveNew = useCallback(async () => { const handleSaveNew = useCallback(async () => {
setIsProfileMenuOpen(false)
const count = Object.keys(useSettingsStore.getState().profiles).length 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 { try {
await saveProfile(`Profile ${count}`) await saveProfile(result.value.trim())
} catch (error) { } catch (error) {
window.alert(getErrorMessage(error, 'Could not save the profile.')) window.alert(getErrorMessage(error, 'Could not save the profile.'))
} finally {
setIsProfileMenuOpen(false)
} }
}, [saveProfile]) }, [saveProfile])
@@ -140,18 +149,22 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
return return
} }
const nextName = window.prompt('Rename profile', profile.name)?.trim() setIsProfileMenuOpen(false)
if (!nextName) { const result = await window.electronAPI.showDialog({
setIsProfileMenuOpen(false) type: 'prompt',
return 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 { try {
await renameProfile(id, nextName) await renameProfile(id, result.value.trim())
} catch (error) { } catch (error) {
window.alert(getErrorMessage(error, 'Could not rename the profile.')) window.alert(getErrorMessage(error, 'Could not rename the profile.'))
} finally {
setIsProfileMenuOpen(false)
} }
}, [renameProfile]) }, [renameProfile])
@@ -162,17 +175,22 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
return return
} }
if (!window.confirm(`Delete "${profile.name}"?`)) { setIsProfileMenuOpen(false)
setIsProfileMenuOpen(false) const result = await window.electronAPI.showDialog({
return 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 { try {
await deleteProfile(id) await deleteProfile(id)
} catch (error) { } catch (error) {
window.alert(getErrorMessage(error, 'Could not delete the profile.')) window.alert(getErrorMessage(error, 'Could not delete the profile.'))
} finally {
setIsProfileMenuOpen(false)
} }
}, [deleteProfile]) }, [deleteProfile])
+4
View File
@@ -28,6 +28,7 @@ import type {
LegacyThemeMigrationResult, LegacyThemeMigrationResult,
ThemeLibrarySnapshot, ThemeLibrarySnapshot,
} from '../types/theme' } from '../types/theme'
import type { DialogOptions, DialogResult } from '../types/dialog'
import type { ResizeDirection } from '../types/windowResize' import type { ResizeDirection } from '../types/windowResize'
declare global { declare global {
@@ -111,6 +112,9 @@ declare global {
onScopePopoutSnapshot: (callback: (snapshot: ScopePopoutSnapshot) => void) => () => void onScopePopoutSnapshot: (callback: (snapshot: ScopePopoutSnapshot) => void) => () => void
onScopePopoutAudio: (callback: (kind: ScopeKind, batch: ScopePopoutAudioBatch) => void) => () => void onScopePopoutAudio: (callback: (kind: ScopeKind, batch: ScopePopoutAudioBatch) => void) => () => void
onScopePopoutSession: (callback: (kind: ScopeKind, session: ScopePopoutSessionState) => void) => () => void onScopePopoutSession: (callback: (kind: ScopeKind, session: ScopePopoutSessionState) => void) => () => void
showDialog: (options: DialogOptions) => Promise<DialogResult>
onDialogConfig: (callback: (options: DialogOptions) => void) => () => void
sendDialogResult: (result: DialogResult) => void
} }
} }
} }
+10 -3
View File
@@ -2,6 +2,7 @@ import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import App from './App' import App from './App'
import ScopePopoutWindow from './popouts/ScopePopoutWindow' import ScopePopoutWindow from './popouts/ScopePopoutWindow'
import DialogApp from './components/DialogApp'
import './styles/globals.css' import './styles/globals.css'
import '@fontsource/inter/400.css' import '@fontsource/inter/400.css'
import '@fontsource/inter/500.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 params = new URLSearchParams(window.location.search)
const windowMode = params.get('mode')
const windowRole = params.get('window') const windowRole = params.get('window')
const scopeKind = params.get('scope') const scopeKind = params.get('scope')
const root = windowRole === 'scope-popout' let root: React.ReactElement
? isScopeKind(scopeKind) if (windowMode === 'dialog') {
root = <DialogApp />
} else if (windowRole === 'scope-popout') {
root = isScopeKind(scopeKind)
? <ScopePopoutWindow scopeKind={scopeKind} /> ? <ScopePopoutWindow scopeKind={scopeKind} />
: <div>Invalid scope popout</div> : <div>Invalid scope popout</div>
: <App /> } else {
root = <App />
}
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
+75 -17
View File
@@ -83,6 +83,10 @@ function canUseElectronAPI(): boolean {
return typeof window !== 'undefined' && typeof window.electronAPI !== 'undefined' 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 { function getErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error && error.message return error instanceof Error && error.message
? error.message ? error.message
@@ -90,9 +94,20 @@ function getErrorMessage(error: unknown, fallback: string): string {
} }
function loadFromStorage(): Partial<PersistedSettingsState> { function loadFromStorage(): Partial<PersistedSettingsState> {
if (!canUseBrowserStorage()) {
return {}
}
try { try {
const raw = localStorage.getItem(STORAGE_KEY) const raw = localStorage.getItem(STORAGE_KEY)
if (raw) return JSON.parse(raw) as Partial<PersistedSettingsState> if (!raw) {
return {}
}
const parsed = JSON.parse(raw) as unknown
if (typeof parsed === 'object' && parsed !== null) {
return parsed as Partial<PersistedSettingsState>
}
} catch { } catch {
// Ignore localStorage read failures. // Ignore localStorage read failures.
} }
@@ -100,6 +115,10 @@ function loadFromStorage(): Partial<PersistedSettingsState> {
} }
function saveToStorage(state: WorkingSettingsState): void { function saveToStorage(state: WorkingSettingsState): void {
if (!canUseBrowserStorage()) {
return
}
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ localStorage.setItem(STORAGE_KEY, JSON.stringify({
themeId: state.themeId, themeId: state.themeId,
@@ -116,9 +135,17 @@ function saveToStorage(state: WorkingSettingsState): void {
} }
function persistWorkingState(state: WorkingSettingsState): void { function persistWorkingState(state: WorkingSettingsState): void {
if (!canUseElectronAPI()) { saveToStorage(state)
saveToStorage(state) }
}
function hasPersistedWorkingState(state: Partial<PersistedSettingsState>): 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 { function loadLegacyProfileMigrationPayload(): LegacyProfileMigrationPayload | null {
@@ -178,6 +205,20 @@ function createWorkingStateFromProfile(profile: Profile): WorkingSettingsState {
} }
} }
function createWorkingStateFromPersistedState(state: Partial<PersistedSettingsState>): WorkingSettingsState {
return {
themeId: typeof state.themeId === 'string' && state.themeId.trim()
? state.themeId.trim()
: null,
scopeOrder: normalizeScopeOrder(state.scopeOrder),
hiddenScopes: new Set<ScopeKind>(normalizeHiddenScopes(state.hiddenScopes)),
widthWeights: normalizeWidthWeights(state.widthWeights),
scopeSettings: mergeScopeSettings(state.scopeSettings),
scopePopouts: normalizeScopePopouts(state.scopePopouts),
windowBounds: state.windowBounds,
}
}
function getActiveProfileName(state: Pick<SettingsState, 'activeProfileId' | 'profiles'>): string | null { function getActiveProfileName(state: Pick<SettingsState, 'activeProfileId' | 'profiles'>): string | null {
if (!state.activeProfileId) { if (!state.activeProfileId) {
return null return null
@@ -454,18 +495,8 @@ async function restoreSavedProfileBaseline(
syncCurrentMainWindowBounds(set) syncCurrentMainWindowBounds(set)
} }
const stored = canUseElectronAPI() ? {} : loadFromStorage() const stored = loadFromStorage()
const initialWorkingState: WorkingSettingsState = { const initialWorkingState = createWorkingStateFromPersistedState(stored)
themeId: typeof stored.themeId === 'string' && stored.themeId.trim()
? stored.themeId.trim()
: null,
scopeOrder: normalizeScopeOrder(stored.scopeOrder),
hiddenScopes: new Set<ScopeKind>(normalizeHiddenScopes(stored.hiddenScopes)),
widthWeights: normalizeWidthWeights(stored.widthWeights),
scopeSettings: mergeScopeSettings(stored.scopeSettings),
scopePopouts: normalizeScopePopouts(stored.scopePopouts),
windowBounds: stored.windowBounds,
}
export const useSettingsStore = create<SettingsState>((set, get) => ({ export const useSettingsStore = create<SettingsState>((set, get) => ({
...initialWorkingState, ...initialWorkingState,
@@ -496,7 +527,34 @@ export const useSettingsStore = create<SettingsState>((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) => { applyExternalProfileSnapshot: (snapshot: ProfileLibrarySnapshot) => {
+14
View File
@@ -322,6 +322,10 @@ function parseCssColor(value: string): RgbaColor | null {
return { r, g, b, a } 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 { function toCssColor(color: RgbaColor): string {
if (Math.abs(color.a - 1) < 0.001) { if (Math.abs(color.a - 1) < 0.001) {
return `rgb(${color.r}, ${color.g}, ${color.b})` 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 { export function resolveLegacyThemeToPresetId(payload: LegacyThemeMigrationPayload): string | null {
switch (payload.presetId) { switch (payload.presetId) {
case 'default': case 'default':
+15
View File
@@ -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
}
+86 -9
View File
@@ -18,7 +18,7 @@ import {
createDefaultProfile, createDefaultProfile,
} from '../src/shared/profileState' } from '../src/shared/profileState'
import { calculateResizedWindowBounds } from '../src/shared/windowResize' 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 { usePerformanceStore } from '../src/renderer/stores/performanceStore'
import { buildProfileDraft, profilesMatch } from '../src/renderer/stores/profileDraft' import { buildProfileDraft, profilesMatch } from '../src/renderer/stores/profileDraft'
import { import {
@@ -223,6 +223,8 @@ function createScopePopouts(poppedOutScopes: ScopeKind[] = []): ScopePopoutState
function installFakeLocalStorage(): { function installFakeLocalStorage(): {
getSetCount: () => number getSetCount: () => number
getItem: (key: string) => string | null
setItem: (key: string, value: string) => void
restore: () => void restore: () => void
} { } {
const storage = new Map<string, string>() const storage = new Map<string, string>()
@@ -254,6 +256,12 @@ function installFakeLocalStorage(): {
return { return {
getSetCount: () => setCount, getSetCount: () => setCount,
getItem(key: string): string | null {
return storage.get(key) ?? null
},
setItem(key: string, value: string): void {
storage.set(key, value)
},
restore(): void { restore(): void {
if (previousLocalStorage === undefined) { if (previousLocalStorage === undefined) {
delete globalWithStorage.localStorage delete globalWithStorage.localStorage
@@ -1097,6 +1105,21 @@ test('buildProfileDraft preserves unlinked themes instead of coercing the active
assert.equal(draft.themeId, null) 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', () => { test('toggleScope appends astra to the scope order when it is enabled from an opt-in profile', () => {
const previousSettingsState = useSettingsStore.getState() const previousSettingsState = useSettingsStore.getState()
const fakeWindow = installFakeElectronWindow() 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 previousSettingsState = useSettingsStore.getState()
const fakeStorage = installFakeLocalStorage() const fakeStorage = installFakeLocalStorage()
const fakeWindow = installFakeElectronWindow() 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 }) useSettingsStore.getState().updateMainWindowBounds({ x: 10, y: 20, width: 900, height: 180 })
assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false) 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 }) useSettingsStore.getState().updateMainWindowBounds({ x: 24, y: 20, width: 900, height: 180 })
assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, true) 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 }) useSettingsStore.getState().updateMainWindowBounds({ x: 10, y: 20, width: 900, height: 180 })
assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false) 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 { } finally {
useSettingsStore.setState(previousSettingsState) useSettingsStore.setState(previousSettingsState)
fakeWindow.restore() 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 previousSettingsState = useSettingsStore.getState()
const fakeStorage = installFakeLocalStorage() const fakeStorage = installFakeLocalStorage()
const fakeWindow = installFakeElectronWindow() 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 }) useSettingsStore.getState().updatePopoutBounds('spectrum', { x: 140, y: 60, width: 420, height: 240 })
assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false) 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 }) useSettingsStore.getState().updatePopoutBounds('spectrum', { x: 180, y: 60, width: 420, height: 240 })
assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, true) 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 }) useSettingsStore.getState().updatePopoutBounds('spectrum', { x: 140, y: 60, width: 420, height: 240 })
assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false) assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false)
assert.equal(fakeStorage.getSetCount(), 0) assert.equal(fakeStorage.getSetCount(), 3)
} finally { } finally {
useSettingsStore.setState(previousSettingsState) useSettingsStore.setState(previousSettingsState)
fakeWindow.restore() fakeWindow.restore()