mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-21 13:03:27 +02:00
improve profile system
This commit is contained in:
+100
-43
@@ -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<void> {
|
||||
|
||||
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
|
||||
|
||||
@@ -620,6 +634,51 @@ function loadRendererTarget(window: BrowserWindow, query: Record<string, string>
|
||||
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,
|
||||
@@ -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))
|
||||
|
||||
@@ -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<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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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])
|
||||
|
||||
|
||||
Vendored
+4
@@ -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<DialogResult>
|
||||
onDialogConfig: (callback: (options: DialogOptions) => void) => () => void
|
||||
sendDialogResult: (result: DialogResult) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-3
@@ -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 = <DialogApp />
|
||||
} else if (windowRole === 'scope-popout') {
|
||||
root = isScopeKind(scopeKind)
|
||||
? <ScopePopoutWindow scopeKind={scopeKind} />
|
||||
: <div>Invalid scope popout</div>
|
||||
: <App />
|
||||
} else {
|
||||
root = <App />
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -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<PersistedSettingsState> {
|
||||
if (!canUseBrowserStorage()) {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
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 {
|
||||
// Ignore localStorage read failures.
|
||||
}
|
||||
@@ -100,6 +115,10 @@ function loadFromStorage(): Partial<PersistedSettingsState> {
|
||||
}
|
||||
|
||||
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<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 {
|
||||
@@ -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 {
|
||||
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<ScopeKind>(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<SettingsState>((set, get) => ({
|
||||
...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) => {
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<string, string>()
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user