mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-16 08:10:40 +02:00
make prsm file format
This commit is contained in:
+216
-11
@@ -1,6 +1,6 @@
|
||||
import { app, BrowserWindow, desktopCapturer, ipcMain, Menu, screen, session } from 'electron'
|
||||
import type { BrowserWindowConstructorOptions, MenuItemConstructorOptions, WebContents } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { app, BrowserWindow, desktopCapturer, dialog, ipcMain, Menu, screen, session, shell } from 'electron'
|
||||
import type { BrowserWindowConstructorOptions, MenuItemConstructorOptions, OpenDialogOptions, WebContents } from 'electron'
|
||||
import { extname, join, resolve } from 'path'
|
||||
import type { CaptureBackendSupport, CaptureBackendSupportEntry } from '../types/capture'
|
||||
import type {
|
||||
ScopePopoutAudioBatch,
|
||||
@@ -10,18 +10,25 @@ import type {
|
||||
WindowBounds,
|
||||
} from '../types/popout'
|
||||
import type { ProfileMenuRequest } from '../types/profileMenu'
|
||||
import type { LegacyProfileMigrationPayload, Profile, ProfileLibrarySnapshot } from '../types/profile'
|
||||
import { SCOPE_KINDS, type ScopeKind } from '../types/scope'
|
||||
import { normalizeProfile } from '../shared/profileState'
|
||||
import { FileBackedProfileLibrary } from './profileLibrary'
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let moveInterval: ReturnType<typeof setInterval> | null = null
|
||||
let moveStartCursor: { x: number; y: number } | null = null
|
||||
let moveStartPosition: number[] | null = null
|
||||
let mainWindowBoundsTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const scopePopoutWindows = new Map<ScopeKind, BrowserWindow>()
|
||||
const scopePopoutCloseAllowed = new Set<ScopeKind>()
|
||||
const popoutBoundsTimers = new Map<ScopeKind, ReturnType<typeof setTimeout>>()
|
||||
const windowSettingsHeights = new Map<number, number>()
|
||||
const windowSettingsBottomAnchors = new Map<number, number>()
|
||||
const pendingProfileOpenPaths: string[] = []
|
||||
|
||||
let profileLibrary: FileBackedProfileLibrary | null = null
|
||||
|
||||
const WINDOW_DEFAULTS = {
|
||||
width: 900,
|
||||
@@ -37,6 +44,96 @@ const POPOUT_DEFAULTS = {
|
||||
minHeight: 160,
|
||||
}
|
||||
|
||||
function getProfileLibrary(): FileBackedProfileLibrary {
|
||||
if (!profileLibrary) {
|
||||
profileLibrary = new FileBackedProfileLibrary(
|
||||
join(app.getPath('documents'), 'Prism Profiles'),
|
||||
join(app.getPath('userData'), 'profile-state.json'),
|
||||
)
|
||||
}
|
||||
|
||||
return profileLibrary
|
||||
}
|
||||
|
||||
function queueProfileOpenPath(filePath: string): void {
|
||||
if (extname(filePath).toLowerCase() !== '.prsm') return
|
||||
|
||||
const resolvedPath = resolve(filePath)
|
||||
if (!pendingProfileOpenPaths.includes(resolvedPath)) {
|
||||
pendingProfileOpenPaths.push(resolvedPath)
|
||||
}
|
||||
}
|
||||
|
||||
function queueProfileOpenPaths(paths: string[]): void {
|
||||
for (const filePath of paths) {
|
||||
queueProfileOpenPath(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
function extractProfilePathsFromArgv(argv: string[]): string[] {
|
||||
return argv
|
||||
.filter((value) => extname(value).toLowerCase() === '.prsm')
|
||||
.map((value) => resolve(value))
|
||||
}
|
||||
|
||||
function focusMainWindow(): void {
|
||||
if (!mainWindow) return
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore()
|
||||
}
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error && error.message
|
||||
? error.message
|
||||
: fallback
|
||||
}
|
||||
|
||||
async function processPendingProfileOpenPaths(): Promise<void> {
|
||||
if (pendingProfileOpenPaths.length === 0) return
|
||||
|
||||
const paths = [...pendingProfileOpenPaths]
|
||||
pendingProfileOpenPaths.length = 0
|
||||
|
||||
let latestSnapshot: ProfileLibrarySnapshot | null = null
|
||||
|
||||
for (const filePath of paths) {
|
||||
try {
|
||||
latestSnapshot = await getProfileLibrary().importProfileFromPath(filePath)
|
||||
} catch (error) {
|
||||
dialog.showErrorBox(
|
||||
'Could Not Open Profile',
|
||||
getErrorMessage(error, `Prism could not open ${filePath}.`),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!latestSnapshot || !mainWindow || mainWindow.isDestroyed()) return
|
||||
|
||||
focusMainWindow()
|
||||
mainWindow.webContents.send('profiles:external-activated', latestSnapshot)
|
||||
}
|
||||
|
||||
function scheduleMainWindowBoundsSave(window: BrowserWindow): void {
|
||||
if (!isMainRendererWindow(window)) return
|
||||
|
||||
if (mainWindowBoundsTimer) {
|
||||
clearTimeout(mainWindowBoundsTimer)
|
||||
}
|
||||
|
||||
mainWindowBoundsTimer = setTimeout(() => {
|
||||
mainWindowBoundsTimer = null
|
||||
if (window.isDestroyed()) return
|
||||
void getProfileLibrary().updateActiveProfileWindowBounds(toLogicalBounds(window))
|
||||
}, 80)
|
||||
}
|
||||
|
||||
function normalizeIncomingProfile(raw: unknown, fallbackName = 'Profile'): Profile {
|
||||
return normalizeProfile(raw, fallbackName)
|
||||
}
|
||||
|
||||
function isScopeKind(value: unknown): value is ScopeKind {
|
||||
return typeof value === 'string' && SCOPE_KINDS.includes(value as ScopeKind)
|
||||
}
|
||||
@@ -189,7 +286,7 @@ function buildProfileMenuTemplate(
|
||||
: null
|
||||
|
||||
const template: MenuItemConstructorOptions[] = [
|
||||
{ label: 'Presets', enabled: false },
|
||||
{ label: 'Profiles', enabled: false },
|
||||
...request.profiles.map((profile) => ({
|
||||
type: 'checkbox' as const,
|
||||
checked: profile.id === request.activeProfileId,
|
||||
@@ -198,7 +295,7 @@ function buildProfileMenuTemplate(
|
||||
})),
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Save as New Preset',
|
||||
label: 'Save as New Profile',
|
||||
click: () => sendToRenderer(sender, 'profile-menu:save-new'),
|
||||
},
|
||||
]
|
||||
@@ -210,6 +307,18 @@ function buildProfileMenuTemplate(
|
||||
})
|
||||
}
|
||||
|
||||
template.push(
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Import .prsm...',
|
||||
click: () => sendToRenderer(sender, 'profile-menu:import'),
|
||||
},
|
||||
{
|
||||
label: 'Show Profiles Folder',
|
||||
click: () => sendToRenderer(sender, 'profile-menu:show-folder'),
|
||||
},
|
||||
)
|
||||
|
||||
if (activeProfile && !activeProfile.isDefault) {
|
||||
template.push(
|
||||
{ type: 'separator' },
|
||||
@@ -285,6 +394,10 @@ function createMainWindow(): void {
|
||||
})
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
if (mainWindowBoundsTimer) {
|
||||
clearTimeout(mainWindowBoundsTimer)
|
||||
mainWindowBoundsTimer = null
|
||||
}
|
||||
if (mainWindow) {
|
||||
windowSettingsHeights.delete(mainWindow.id)
|
||||
windowSettingsBottomAnchors.delete(mainWindow.id)
|
||||
@@ -296,6 +409,15 @@ function createMainWindow(): void {
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.on('move', () => {
|
||||
if (!mainWindow) return
|
||||
scheduleMainWindowBoundsSave(mainWindow)
|
||||
})
|
||||
mainWindow.on('resize', () => {
|
||||
if (!mainWindow) return
|
||||
scheduleMainWindowBoundsSave(mainWindow)
|
||||
})
|
||||
|
||||
loadRendererTarget(mainWindow, { window: 'main' })
|
||||
}
|
||||
|
||||
@@ -319,6 +441,7 @@ function emitPopoutBoundsChanged(kind: ScopeKind, window: BrowserWindow): void {
|
||||
|
||||
if (!mainWindow || mainWindow.isDestroyed() || window.isDestroyed()) return
|
||||
const bounds = toLogicalBounds(window)
|
||||
void getProfileLibrary().updateActiveProfilePopoutBounds(kind, bounds)
|
||||
mainWindow.webContents.send('scope-popout:bounds-changed', kind, bounds)
|
||||
}, 80)
|
||||
|
||||
@@ -571,6 +694,64 @@ function setupIPC(): void {
|
||||
return getCaptureBackendSupport()
|
||||
})
|
||||
|
||||
ipcMain.handle('profiles:get-snapshot', async () => {
|
||||
return getProfileLibrary().getSnapshot()
|
||||
})
|
||||
|
||||
ipcMain.handle('profiles:save-new', async (_event, name: string, rawProfile: unknown) => {
|
||||
return getProfileLibrary().saveNewProfile(name, normalizeIncomingProfile(rawProfile, name))
|
||||
})
|
||||
|
||||
ipcMain.handle('profiles:overwrite', async (_event, id: string, rawProfile: unknown) => {
|
||||
return getProfileLibrary().overwriteProfile(id, normalizeIncomingProfile(rawProfile))
|
||||
})
|
||||
|
||||
ipcMain.handle('profiles:load', async (_event, id: string) => {
|
||||
return getProfileLibrary().loadProfile(id)
|
||||
})
|
||||
|
||||
ipcMain.handle('profiles:delete', async (_event, id: string) => {
|
||||
return getProfileLibrary().deleteProfile(id)
|
||||
})
|
||||
|
||||
ipcMain.handle('profiles:rename', async (_event, id: string, name: string) => {
|
||||
return getProfileLibrary().renameProfile(id, name)
|
||||
})
|
||||
|
||||
ipcMain.handle('profiles:import-dialog', async () => {
|
||||
const targetWindow = mainWindow ?? BrowserWindow.getFocusedWindow() ?? undefined
|
||||
const dialogOptions: OpenDialogOptions = {
|
||||
properties: ['openFile'],
|
||||
filters: [
|
||||
{
|
||||
name: 'Prism Profiles',
|
||||
extensions: ['prsm'],
|
||||
},
|
||||
],
|
||||
}
|
||||
const result = targetWindow
|
||||
? await dialog.showOpenDialog(targetWindow, dialogOptions)
|
||||
: await dialog.showOpenDialog(dialogOptions)
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return getProfileLibrary().importProfileFromPath(result.filePaths[0])
|
||||
})
|
||||
|
||||
ipcMain.handle('profiles:reveal-folder', async () => {
|
||||
const folderPath = getProfileLibrary().getProfilesDirectory()
|
||||
const openResult = await shell.openPath(folderPath)
|
||||
if (openResult) {
|
||||
throw new Error(openResult)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('profiles:migrate-legacy', async (_event, payload: LegacyProfileMigrationPayload) => {
|
||||
return getProfileLibrary().migrateLegacyProfiles(payload)
|
||||
})
|
||||
|
||||
ipcMain.on('profile-menu:open', (event, rawRequest: unknown) => {
|
||||
const request = normalizeProfileMenuRequest(rawRequest)
|
||||
if (!request) return
|
||||
@@ -722,12 +903,36 @@ function setupShortcuts(): void {
|
||||
})
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
setupPermissions()
|
||||
setupIPC()
|
||||
createMainWindow()
|
||||
setupShortcuts()
|
||||
})
|
||||
const hasSingleInstanceLock = app.requestSingleInstanceLock()
|
||||
|
||||
if (!hasSingleInstanceLock) {
|
||||
app.quit()
|
||||
} else {
|
||||
app.whenReady().then(() => {
|
||||
setupPermissions()
|
||||
setupIPC()
|
||||
createMainWindow()
|
||||
setupShortcuts()
|
||||
queueProfileOpenPaths(extractProfilePathsFromArgv(process.argv))
|
||||
void processPendingProfileOpenPaths()
|
||||
})
|
||||
|
||||
app.on('open-file', (event, filePath) => {
|
||||
event.preventDefault()
|
||||
queueProfileOpenPath(filePath)
|
||||
if (app.isReady()) {
|
||||
void processPendingProfileOpenPaths()
|
||||
}
|
||||
})
|
||||
|
||||
app.on('second-instance', (_event, argv) => {
|
||||
queueProfileOpenPaths(extractProfilePathsFromArgv(argv))
|
||||
if (app.isReady()) {
|
||||
focusMainWindow()
|
||||
void processPendingProfileOpenPaths()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
app.quit()
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { access, mkdir, readFile, readdir, unlink, writeFile } from 'node:fs/promises'
|
||||
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import {
|
||||
DEFAULT_PROFILE_ID,
|
||||
DEFAULT_PROFILE_NAME,
|
||||
LEGACY_PROFILE_MIGRATION_VERSION,
|
||||
PROFILE_FILE_FORMAT,
|
||||
PROFILE_FILE_VERSION,
|
||||
type LegacyProfileMigrationPayload,
|
||||
type Profile,
|
||||
type ProfileLibrarySnapshot,
|
||||
type PrismProfileFileV1,
|
||||
type PrismProfileLocalStateV1,
|
||||
} from '../types/profile'
|
||||
import type { ScopeKind } from '../types/scope'
|
||||
import type { WindowBounds } from '../types/popout'
|
||||
import {
|
||||
createDefaultProfile,
|
||||
createEmptyProfileLocalState,
|
||||
extractLocalProfileMetadata,
|
||||
normalizeProfile,
|
||||
normalizeProfileFile,
|
||||
normalizeProfileLocalState,
|
||||
normalizeProfileName,
|
||||
normalizeWindowBounds,
|
||||
profileFileToProfile,
|
||||
profileToFileData,
|
||||
} from '../shared/profileState'
|
||||
|
||||
const PROFILE_EXTENSION = '.prsm'
|
||||
|
||||
interface ManagedProfileEntry {
|
||||
id: string
|
||||
path: string
|
||||
profile: Profile
|
||||
}
|
||||
|
||||
export interface LegacyMigrationResult {
|
||||
didMigrate: boolean
|
||||
snapshot: ProfileLibrarySnapshot
|
||||
}
|
||||
|
||||
export class FileBackedProfileLibrary {
|
||||
constructor(
|
||||
private readonly profilesDir: string,
|
||||
private readonly localStatePath: string,
|
||||
) {}
|
||||
|
||||
getProfilesDirectory(): string {
|
||||
return this.profilesDir
|
||||
}
|
||||
|
||||
async getSnapshot(): Promise<ProfileLibrarySnapshot> {
|
||||
const { entries, localState } = await this.loadLibrary()
|
||||
return this.buildSnapshot(entries, localState)
|
||||
}
|
||||
|
||||
async saveNewProfile(name: string, profile: Profile): Promise<ProfileLibrarySnapshot> {
|
||||
const { entries, localState } = await this.loadLibrary()
|
||||
const id = this.generateProfileId(entries)
|
||||
const normalized = normalizeProfile({ ...profile, name }, normalizeProfileName(name, 'Profile'))
|
||||
await this.writeManagedProfile(entries, id, normalized)
|
||||
localState.profiles[id] = extractLocalProfileMetadata(normalized)
|
||||
localState.activeProfileId = id
|
||||
await this.writeLocalState(localState)
|
||||
return this.getSnapshot()
|
||||
}
|
||||
|
||||
async overwriteProfile(id: string, profile: Profile): Promise<ProfileLibrarySnapshot> {
|
||||
const { entries, localState } = await this.loadLibrary()
|
||||
const entry = this.findEntry(entries, id)
|
||||
const normalized = normalizeProfile({ ...profile, name: entry.profile.name }, entry.profile.name)
|
||||
await this.writeManagedProfile(entries, id, normalized, entry.path)
|
||||
localState.profiles[id] = extractLocalProfileMetadata(normalized)
|
||||
await this.writeLocalState(localState)
|
||||
return this.getSnapshot()
|
||||
}
|
||||
|
||||
async loadProfile(id: string): Promise<ProfileLibrarySnapshot> {
|
||||
const { entries, localState } = await this.loadLibrary()
|
||||
this.findEntry(entries, id)
|
||||
localState.activeProfileId = id
|
||||
await this.writeLocalState(localState)
|
||||
return this.buildSnapshot(entries, localState)
|
||||
}
|
||||
|
||||
async deleteProfile(id: string): Promise<ProfileLibrarySnapshot> {
|
||||
if (id === DEFAULT_PROFILE_ID) {
|
||||
throw new Error('The default profile cannot be deleted.')
|
||||
}
|
||||
|
||||
const { entries, localState } = await this.loadLibrary()
|
||||
const entry = this.findEntry(entries, id)
|
||||
await unlink(entry.path)
|
||||
delete localState.profiles[id]
|
||||
if (localState.activeProfileId === id) {
|
||||
localState.activeProfileId = null
|
||||
}
|
||||
await this.writeLocalState(localState)
|
||||
return this.getSnapshot()
|
||||
}
|
||||
|
||||
async renameProfile(id: string, name: string): Promise<ProfileLibrarySnapshot> {
|
||||
if (id === DEFAULT_PROFILE_ID) {
|
||||
throw new Error('The default profile cannot be renamed.')
|
||||
}
|
||||
|
||||
const { entries, localState } = await this.loadLibrary()
|
||||
const entry = this.findEntry(entries, id)
|
||||
const normalized = normalizeProfile({ ...entry.profile, name }, normalizeProfileName(name, entry.profile.name))
|
||||
await this.writeManagedProfile(entries, id, normalized, entry.path)
|
||||
await this.writeLocalState(localState)
|
||||
return this.getSnapshot()
|
||||
}
|
||||
|
||||
async importProfileFromPath(sourcePath: string): Promise<ProfileLibrarySnapshot> {
|
||||
const { entries, localState } = await this.loadLibrary()
|
||||
const resolvedSourcePath = resolve(sourcePath)
|
||||
const file = await this.readProfileFile(resolvedSourcePath)
|
||||
const normalizedProfile = profileFileToProfile(file)
|
||||
const existingEntry = entries.find((entry) => entry.id === file.id) ?? null
|
||||
const insideManagedDirectory = this.isPathInsideDirectory(resolvedSourcePath, this.profilesDir)
|
||||
const currentPath = existingEntry?.path ?? (insideManagedDirectory ? resolvedSourcePath : undefined)
|
||||
const targetPath = await this.writeManagedProfile(entries, file.id, normalizedProfile, currentPath)
|
||||
|
||||
if (insideManagedDirectory && resolvedSourcePath !== targetPath) {
|
||||
await this.unlinkIfExists(resolvedSourcePath)
|
||||
}
|
||||
|
||||
if (!localState.profiles[file.id]) {
|
||||
localState.profiles[file.id] = {}
|
||||
}
|
||||
localState.activeProfileId = file.id
|
||||
await this.writeLocalState(localState)
|
||||
return this.getSnapshot()
|
||||
}
|
||||
|
||||
async migrateLegacyProfiles(payload: LegacyProfileMigrationPayload): Promise<LegacyMigrationResult> {
|
||||
const { entries, localState } = await this.loadLibrary()
|
||||
if (localState.migrationVersion >= LEGACY_PROFILE_MIGRATION_VERSION) {
|
||||
return {
|
||||
didMigrate: false,
|
||||
snapshot: this.buildSnapshot(entries, localState),
|
||||
}
|
||||
}
|
||||
|
||||
let didMigrate = false
|
||||
const hasManagedUserProfiles = entries.some((entry) => entry.id !== DEFAULT_PROFILE_ID)
|
||||
const normalizedPayload = this.normalizeLegacyPayload(payload)
|
||||
const legacyEntries = Object.entries(normalizedPayload.profiles)
|
||||
|
||||
if (!hasManagedUserProfiles && legacyEntries.length > 0) {
|
||||
const nextEntries = [...entries]
|
||||
for (const [id, profile] of legacyEntries) {
|
||||
const existingEntry = nextEntries.find((entry) => entry.id === id)
|
||||
const normalizedProfile = normalizeProfile(profile, profile.name)
|
||||
const writtenPath = await this.writeManagedProfile(
|
||||
nextEntries,
|
||||
id,
|
||||
normalizedProfile,
|
||||
existingEntry?.path,
|
||||
)
|
||||
|
||||
const nextEntryIndex = nextEntries.findIndex((entry) => entry.id === id)
|
||||
const nextEntry: ManagedProfileEntry = {
|
||||
id,
|
||||
path: writtenPath,
|
||||
profile: normalizedProfile,
|
||||
}
|
||||
|
||||
if (nextEntryIndex === -1) {
|
||||
nextEntries.push(nextEntry)
|
||||
} else {
|
||||
nextEntries[nextEntryIndex] = nextEntry
|
||||
}
|
||||
|
||||
localState.profiles[id] = extractLocalProfileMetadata(normalizedProfile)
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedPayload.activeProfileId
|
||||
&& nextEntries.some((entry) => entry.id === normalizedPayload.activeProfileId)
|
||||
) {
|
||||
localState.activeProfileId = normalizedPayload.activeProfileId
|
||||
}
|
||||
|
||||
didMigrate = true
|
||||
}
|
||||
|
||||
localState.migrationVersion = LEGACY_PROFILE_MIGRATION_VERSION
|
||||
await this.writeLocalState(localState)
|
||||
|
||||
return {
|
||||
didMigrate,
|
||||
snapshot: await this.getSnapshot(),
|
||||
}
|
||||
}
|
||||
|
||||
async updateActiveProfileWindowBounds(bounds: WindowBounds): Promise<void> {
|
||||
const { entries, localState } = await this.loadLibrary()
|
||||
const activeProfileId = localState.activeProfileId
|
||||
if (!activeProfileId || !entries.some((entry) => entry.id === activeProfileId)) return
|
||||
|
||||
const normalizedBounds = normalizeWindowBounds(bounds)
|
||||
if (!normalizedBounds) return
|
||||
|
||||
const metadata = localState.profiles[activeProfileId] ?? {}
|
||||
localState.profiles[activeProfileId] = {
|
||||
...metadata,
|
||||
windowBounds: normalizedBounds,
|
||||
}
|
||||
await this.writeLocalState(localState)
|
||||
}
|
||||
|
||||
async updateActiveProfilePopoutBounds(kind: ScopeKind, bounds?: WindowBounds): Promise<void> {
|
||||
const { entries, localState } = await this.loadLibrary()
|
||||
const activeProfileId = localState.activeProfileId
|
||||
if (!activeProfileId || !entries.some((entry) => entry.id === activeProfileId)) return
|
||||
|
||||
const metadata = localState.profiles[activeProfileId] ?? {}
|
||||
const nextBounds = { ...(metadata.scopePopoutBounds ?? {}) }
|
||||
const normalizedBounds = normalizeWindowBounds(bounds)
|
||||
|
||||
if (normalizedBounds) {
|
||||
nextBounds[kind] = normalizedBounds
|
||||
} else {
|
||||
delete nextBounds[kind]
|
||||
}
|
||||
|
||||
localState.profiles[activeProfileId] = {
|
||||
...metadata,
|
||||
scopePopoutBounds: Object.keys(nextBounds).length > 0 ? nextBounds : undefined,
|
||||
}
|
||||
await this.writeLocalState(localState)
|
||||
}
|
||||
|
||||
private normalizeLegacyPayload(payload: LegacyProfileMigrationPayload): LegacyProfileMigrationPayload {
|
||||
if (typeof payload !== 'object' || payload === null) {
|
||||
return { profiles: {}, activeProfileId: null }
|
||||
}
|
||||
|
||||
const rawProfiles = typeof payload.profiles === 'object' && payload.profiles !== null
|
||||
? payload.profiles
|
||||
: {}
|
||||
|
||||
const profiles = Object.entries(rawProfiles).reduce((acc, [id, profile]) => {
|
||||
if (!id.trim()) return acc
|
||||
acc[id] = normalizeProfile(
|
||||
profile,
|
||||
id === DEFAULT_PROFILE_ID ? DEFAULT_PROFILE_NAME : 'Profile',
|
||||
)
|
||||
return acc
|
||||
}, {} as Record<string, Profile>)
|
||||
|
||||
return {
|
||||
profiles,
|
||||
activeProfileId: typeof payload.activeProfileId === 'string'
|
||||
? payload.activeProfileId
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
private async loadLibrary(): Promise<{
|
||||
entries: ManagedProfileEntry[]
|
||||
localState: PrismProfileLocalStateV1
|
||||
}> {
|
||||
await mkdir(this.profilesDir, { recursive: true })
|
||||
let localState = await this.readLocalState()
|
||||
let entries = await this.readManagedEntries(localState)
|
||||
|
||||
if (!entries.some((entry) => entry.id === DEFAULT_PROFILE_ID)) {
|
||||
const defaultProfile = createDefaultProfile(DEFAULT_PROFILE_NAME)
|
||||
const defaultPath = await this.writeManagedProfile(entries, DEFAULT_PROFILE_ID, defaultProfile)
|
||||
entries = await this.readManagedEntries({
|
||||
...localState,
|
||||
profiles: {
|
||||
...localState.profiles,
|
||||
[DEFAULT_PROFILE_ID]: extractLocalProfileMetadata(defaultProfile),
|
||||
},
|
||||
})
|
||||
localState.profiles[DEFAULT_PROFILE_ID] = extractLocalProfileMetadata(defaultProfile)
|
||||
if (!entries.some((entry) => entry.path === defaultPath)) {
|
||||
entries.push({
|
||||
id: DEFAULT_PROFILE_ID,
|
||||
path: defaultPath,
|
||||
profile: defaultProfile,
|
||||
})
|
||||
}
|
||||
await this.writeLocalState(localState)
|
||||
}
|
||||
|
||||
if (localState.activeProfileId && !entries.some((entry) => entry.id === localState.activeProfileId)) {
|
||||
localState = { ...localState, activeProfileId: null }
|
||||
await this.writeLocalState(localState)
|
||||
}
|
||||
|
||||
return {
|
||||
entries: this.sortEntries(entries),
|
||||
localState,
|
||||
}
|
||||
}
|
||||
|
||||
private async readManagedEntries(localState: PrismProfileLocalStateV1): Promise<ManagedProfileEntry[]> {
|
||||
const dirEntries = await readdir(this.profilesDir, { withFileTypes: true })
|
||||
const profilePaths = dirEntries
|
||||
.filter((entry) => entry.isFile() && extname(entry.name).toLowerCase() === PROFILE_EXTENSION)
|
||||
.map((entry) => resolve(join(this.profilesDir, entry.name)))
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
|
||||
const entries: ManagedProfileEntry[] = []
|
||||
const seenIds = new Set<string>()
|
||||
|
||||
for (const filePath of profilePaths) {
|
||||
try {
|
||||
const file = await this.readProfileFile(filePath)
|
||||
if (seenIds.has(file.id)) {
|
||||
console.warn(`Skipping duplicate profile id "${file.id}" in ${basename(filePath)}.`)
|
||||
continue
|
||||
}
|
||||
|
||||
seenIds.add(file.id)
|
||||
entries.push({
|
||||
id: file.id,
|
||||
path: filePath,
|
||||
profile: profileFileToProfile(file, localState.profiles[file.id]),
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn(`Skipping invalid profile file at ${filePath}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
private async readProfileFile(filePath: string): Promise<PrismProfileFileV1> {
|
||||
let parsed: unknown
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(await readFile(filePath, 'utf8')) as unknown
|
||||
} catch (error) {
|
||||
throw new Error(`Could not parse ${basename(filePath)} as JSON.`, { cause: error })
|
||||
}
|
||||
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
throw new Error(`Profile file ${basename(filePath)} must contain an object.`)
|
||||
}
|
||||
|
||||
const candidate = parsed as Partial<PrismProfileFileV1>
|
||||
if (candidate.format !== PROFILE_FILE_FORMAT) {
|
||||
throw new Error(`Unsupported profile format in ${basename(filePath)}.`)
|
||||
}
|
||||
|
||||
if (candidate.version !== PROFILE_FILE_VERSION) {
|
||||
throw new Error(`Unsupported profile version in ${basename(filePath)}.`)
|
||||
}
|
||||
|
||||
return normalizeProfileFile(
|
||||
parsed,
|
||||
this.buildFallbackProfileId(filePath),
|
||||
basename(filePath, PROFILE_EXTENSION),
|
||||
)
|
||||
}
|
||||
|
||||
private async readLocalState(): Promise<PrismProfileLocalStateV1> {
|
||||
try {
|
||||
const raw = await readFile(this.localStatePath, 'utf8')
|
||||
return normalizeProfileLocalState(JSON.parse(raw) as unknown)
|
||||
} catch {
|
||||
return createEmptyProfileLocalState()
|
||||
}
|
||||
}
|
||||
|
||||
private async writeLocalState(state: PrismProfileLocalStateV1): Promise<void> {
|
||||
await mkdir(dirname(this.localStatePath), { recursive: true })
|
||||
await this.writeJsonFile(this.localStatePath, state)
|
||||
}
|
||||
|
||||
private async writeManagedProfile(
|
||||
entries: ManagedProfileEntry[],
|
||||
id: string,
|
||||
profile: Profile,
|
||||
currentPath?: string,
|
||||
): Promise<string> {
|
||||
const normalizedProfile = normalizeProfile(profile, profile.name)
|
||||
const nextPath = await this.getManagedProfilePath(entries, id, normalizedProfile.name, currentPath)
|
||||
const existingPath = currentPath ? resolve(currentPath) : null
|
||||
|
||||
await this.writeJsonFile(nextPath, profileToFileData(id, normalizedProfile))
|
||||
|
||||
if (existingPath && existingPath !== nextPath) {
|
||||
await this.unlinkIfExists(existingPath)
|
||||
}
|
||||
|
||||
return nextPath
|
||||
}
|
||||
|
||||
private async writeJsonFile(filePath: string, value: unknown): Promise<void> {
|
||||
await mkdir(dirname(filePath), { recursive: true })
|
||||
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
private async getManagedProfilePath(
|
||||
entries: ManagedProfileEntry[],
|
||||
id: string,
|
||||
name: string,
|
||||
currentPath?: string,
|
||||
): Promise<string> {
|
||||
if (id === DEFAULT_PROFILE_ID) {
|
||||
const defaultPath = resolve(join(this.profilesDir, `${DEFAULT_PROFILE_NAME}${PROFILE_EXTENSION}`))
|
||||
if (!currentPath || resolve(currentPath) === defaultPath) {
|
||||
return defaultPath
|
||||
}
|
||||
const occupiedByOtherEntry = entries.some((entry) => entry.id !== id && entry.path === defaultPath)
|
||||
return occupiedByOtherEntry ? resolve(currentPath) : defaultPath
|
||||
}
|
||||
|
||||
const preferredCurrentPath = currentPath ? resolve(currentPath) : null
|
||||
const occupiedPaths = new Set(
|
||||
entries
|
||||
.filter((entry) => entry.id !== id)
|
||||
.map((entry) => entry.path),
|
||||
)
|
||||
const baseStem = this.sanitizeFileStem(name)
|
||||
|
||||
let attempt = 0
|
||||
while (true) {
|
||||
const suffix = attempt === 0 ? '' : ` (${attempt + 1})`
|
||||
const candidatePath = resolve(join(this.profilesDir, `${baseStem}${suffix}${PROFILE_EXTENSION}`))
|
||||
if (preferredCurrentPath === candidatePath) {
|
||||
return candidatePath
|
||||
}
|
||||
if (occupiedPaths.has(candidatePath)) {
|
||||
attempt += 1
|
||||
continue
|
||||
}
|
||||
if (await this.pathExists(candidatePath)) {
|
||||
attempt += 1
|
||||
continue
|
||||
}
|
||||
return candidatePath
|
||||
}
|
||||
}
|
||||
|
||||
private generateProfileId(entries: ManagedProfileEntry[]): string {
|
||||
const existingIds = new Set(entries.map((entry) => entry.id))
|
||||
let nextId = `profile_${randomUUID().replace(/-/g, '')}`
|
||||
while (existingIds.has(nextId)) {
|
||||
nextId = `profile_${randomUUID().replace(/-/g, '')}`
|
||||
}
|
||||
return nextId
|
||||
}
|
||||
|
||||
private findEntry(entries: ManagedProfileEntry[], id: string): ManagedProfileEntry {
|
||||
const entry = entries.find((candidate) => candidate.id === id)
|
||||
if (!entry) {
|
||||
throw new Error(`Profile "${id}" was not found.`)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
private buildSnapshot(
|
||||
entries: ManagedProfileEntry[],
|
||||
localState: PrismProfileLocalStateV1,
|
||||
): ProfileLibrarySnapshot {
|
||||
const profiles = this.sortEntries(entries).reduce((acc, entry) => {
|
||||
acc[entry.id] = entry.profile
|
||||
return acc
|
||||
}, {} as Record<string, Profile>)
|
||||
|
||||
return {
|
||||
profiles,
|
||||
activeProfileId: localState.activeProfileId && profiles[localState.activeProfileId]
|
||||
? localState.activeProfileId
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
private sortEntries(entries: ManagedProfileEntry[]): ManagedProfileEntry[] {
|
||||
return [...entries].sort((left, right) => {
|
||||
if (left.id === DEFAULT_PROFILE_ID) return -1
|
||||
if (right.id === DEFAULT_PROFILE_ID) return 1
|
||||
return left.profile.name.localeCompare(right.profile.name)
|
||||
})
|
||||
}
|
||||
|
||||
private isPathInsideDirectory(candidatePath: string, directoryPath: string): boolean {
|
||||
const relativePath = relative(resolve(directoryPath), resolve(candidatePath))
|
||||
return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))
|
||||
}
|
||||
|
||||
private sanitizeFileStem(name: string): string {
|
||||
const sanitized = normalizeProfileName(name, 'Profile')
|
||||
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
return sanitized || 'Profile'
|
||||
}
|
||||
|
||||
private buildFallbackProfileId(filePath: string): string {
|
||||
const stem = basename(filePath, PROFILE_EXTENSION)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
|
||||
return stem ? `profile_${stem}` : `profile_${randomUUID().replace(/-/g, '')}`
|
||||
}
|
||||
|
||||
private async pathExists(targetPath: string): Promise<boolean> {
|
||||
try {
|
||||
await access(targetPath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async unlinkIfExists(targetPath: string): Promise<void> {
|
||||
try {
|
||||
await unlink(targetPath)
|
||||
} catch {
|
||||
// Ignore missing files.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,12 @@ import type {
|
||||
WindowBounds,
|
||||
} from '../types/popout'
|
||||
import type { ProfileMenuRequest } from '../types/profileMenu'
|
||||
import type {
|
||||
LegacyProfileMigrationPayload,
|
||||
LegacyProfileMigrationResult,
|
||||
Profile,
|
||||
ProfileLibrarySnapshot,
|
||||
} from '../types/profile'
|
||||
import type { ScopeKind } from '../types/scope'
|
||||
import type { VisualizerDSP } from '../renderer/audio/native/visualizer-dsp'
|
||||
|
||||
@@ -34,6 +40,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
nativeBackend: resolveNativeCaptureSupport(support.nativeBackend),
|
||||
} satisfies CaptureBackendSupport
|
||||
},
|
||||
getProfileSnapshot: () => ipcRenderer.invoke('profiles:get-snapshot') as Promise<ProfileLibrarySnapshot>,
|
||||
saveNewProfile: (name: string, profile: Profile) => ipcRenderer.invoke('profiles:save-new', name, profile) as Promise<ProfileLibrarySnapshot>,
|
||||
overwriteProfile: (id: string, profile: Profile) => ipcRenderer.invoke('profiles:overwrite', id, profile) as Promise<ProfileLibrarySnapshot>,
|
||||
loadProfile: (id: string) => ipcRenderer.invoke('profiles:load', id) as Promise<ProfileLibrarySnapshot>,
|
||||
deleteProfile: (id: string) => ipcRenderer.invoke('profiles:delete', id) as Promise<ProfileLibrarySnapshot>,
|
||||
renameProfile: (id: string, name: string) => ipcRenderer.invoke('profiles:rename', id, name) as Promise<ProfileLibrarySnapshot>,
|
||||
importProfileDialog: () => ipcRenderer.invoke('profiles:import-dialog') as Promise<ProfileLibrarySnapshot | null>,
|
||||
revealProfilesFolder: () => ipcRenderer.invoke('profiles:reveal-folder') as Promise<void>,
|
||||
migrateLegacyProfiles: (payload: LegacyProfileMigrationPayload) => ipcRenderer.invoke('profiles:migrate-legacy', payload) as Promise<LegacyProfileMigrationResult>,
|
||||
expandSettings: (panelHeight: number) => ipcRenderer.send('window:expand-settings', panelHeight),
|
||||
collapseSettings: (panelHeight: number) => ipcRenderer.send('window:collapse-settings', panelHeight),
|
||||
setSettingsHeight: (panelHeight: number) => ipcRenderer.send('window:set-settings-height', panelHeight),
|
||||
@@ -95,6 +110,21 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.on('profile-menu:delete-active', handler)
|
||||
return () => ipcRenderer.removeListener('profile-menu:delete-active', handler)
|
||||
},
|
||||
onProfileMenuImport: (callback: () => void) => {
|
||||
const handler = (): void => callback()
|
||||
ipcRenderer.on('profile-menu:import', handler)
|
||||
return () => ipcRenderer.removeListener('profile-menu:import', handler)
|
||||
},
|
||||
onProfileMenuShowFolder: (callback: () => void) => {
|
||||
const handler = (): void => callback()
|
||||
ipcRenderer.on('profile-menu:show-folder', handler)
|
||||
return () => ipcRenderer.removeListener('profile-menu:show-folder', handler)
|
||||
},
|
||||
onExternalProfileActivated: (callback: (snapshot: ProfileLibrarySnapshot) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, snapshot: ProfileLibrarySnapshot): void => callback(snapshot)
|
||||
ipcRenderer.on('profiles:external-activated', handler)
|
||||
return () => ipcRenderer.removeListener('profiles:external-activated', handler)
|
||||
},
|
||||
onScopePopoutReady: (callback: (kind: ScopeKind) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, kind: ScopeKind): void => callback(kind)
|
||||
ipcRenderer.on('scope-popout:ready', handler)
|
||||
|
||||
+12
-14
@@ -16,22 +16,10 @@ export default function App(): JSX.Element {
|
||||
const [settingsPanelHeight, setSettingsPanelHeight] = useState(0)
|
||||
const [bottomBarHeight, setBottomBarHeight] = useState(0)
|
||||
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const startupBoundsAppliedRef = useRef(false)
|
||||
|
||||
const toggleScope = useSettingsStore((s) => s.toggleScope)
|
||||
const profiles = useSettingsStore((s) => s.profiles)
|
||||
const activeProfileId = useSettingsStore((s) => s.activeProfileId)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (startupBoundsAppliedRef.current) return
|
||||
|
||||
const profile = activeProfileId ? profiles[activeProfileId] : null
|
||||
startupBoundsAppliedRef.current = true
|
||||
|
||||
if (profile?.windowBounds) {
|
||||
window.electronAPI.setWindowBounds(profile.windowBounds)
|
||||
}
|
||||
}, [activeProfileId, profiles])
|
||||
const initializeProfiles = useSettingsStore((s) => s.initializeProfiles)
|
||||
const applyExternalProfileSnapshot = useSettingsStore((s) => s.applyExternalProfileSnapshot)
|
||||
|
||||
// Auto-capture on launch
|
||||
useEffect(() => {
|
||||
@@ -41,6 +29,16 @@ export default function App(): JSX.Element {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void initializeProfiles()
|
||||
|
||||
const unsubscribe = window.electronAPI.onExternalProfileActivated((snapshot) => {
|
||||
applyExternalProfileSnapshot(snapshot)
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [applyExternalProfileSnapshot, initializeProfiles])
|
||||
|
||||
const measuredSettingsHeight = settingsPanelHeight > 0 && bottomBarHeight > 0
|
||||
? settingsPanelHeight + bottomBarHeight
|
||||
: DEFAULT_SETTINGS_HEIGHT
|
||||
|
||||
@@ -70,6 +70,12 @@ interface ToolbarProps {
|
||||
|
||||
const DEFAULT_PROFILE_ID = 'profile_default'
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error && error.message
|
||||
? error.message
|
||||
: fallback
|
||||
}
|
||||
|
||||
export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps): JSX.Element {
|
||||
const profiles = useSettingsStore((s) => s.profiles)
|
||||
const activeProfileId = useSettingsStore((s) => s.activeProfileId)
|
||||
@@ -78,6 +84,8 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
|
||||
const deleteProfile = useSettingsStore((s) => s.deleteProfile)
|
||||
const renameProfile = useSettingsStore((s) => s.renameProfile)
|
||||
const updateActiveProfile = useSettingsStore((s) => s.updateActiveProfile)
|
||||
const importProfileFromDialog = useSettingsStore((s) => s.importProfileFromDialog)
|
||||
const showProfilesFolder = useSettingsStore((s) => s.showProfilesFolder)
|
||||
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(true)
|
||||
const [showReposition, setShowReposition] = useState(false)
|
||||
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false)
|
||||
@@ -89,32 +97,50 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
|
||||
return unsubscribe
|
||||
}, [])
|
||||
|
||||
const handleSaveNew = useCallback(() => {
|
||||
const handleSaveNew = useCallback(async () => {
|
||||
const count = Object.keys(useSettingsStore.getState().profiles).length
|
||||
saveProfile(`Profile ${count}`)
|
||||
setIsProfileMenuOpen(false)
|
||||
try {
|
||||
await saveProfile(`Profile ${count}`)
|
||||
} catch (error) {
|
||||
window.alert(getErrorMessage(error, 'Could not save the profile.'))
|
||||
} finally {
|
||||
setIsProfileMenuOpen(false)
|
||||
}
|
||||
}, [saveProfile])
|
||||
|
||||
const handleSaveOverwrite = useCallback(() => {
|
||||
updateActiveProfile()
|
||||
setIsProfileMenuOpen(false)
|
||||
const handleSaveOverwrite = useCallback(async () => {
|
||||
try {
|
||||
await updateActiveProfile()
|
||||
} catch (error) {
|
||||
window.alert(getErrorMessage(error, 'Could not update the active profile.'))
|
||||
} finally {
|
||||
setIsProfileMenuOpen(false)
|
||||
}
|
||||
}, [updateActiveProfile])
|
||||
|
||||
const handleRenameActive = useCallback((id: string) => {
|
||||
const handleRenameActive = useCallback(async (id: string) => {
|
||||
const profile = useSettingsStore.getState().profiles[id]
|
||||
if (!profile || id === DEFAULT_PROFILE_ID) {
|
||||
setIsProfileMenuOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
const nextName = window.prompt('Rename preset', profile.name)?.trim()
|
||||
if (nextName) {
|
||||
renameProfile(id, nextName)
|
||||
const nextName = window.prompt('Rename profile', profile.name)?.trim()
|
||||
if (!nextName) {
|
||||
setIsProfileMenuOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await renameProfile(id, nextName)
|
||||
} catch (error) {
|
||||
window.alert(getErrorMessage(error, 'Could not rename the profile.'))
|
||||
} finally {
|
||||
setIsProfileMenuOpen(false)
|
||||
}
|
||||
setIsProfileMenuOpen(false)
|
||||
}, [renameProfile])
|
||||
|
||||
const handleDeleteActive = useCallback((id: string) => {
|
||||
const handleDeleteActive = useCallback(async (id: string) => {
|
||||
const profile = useSettingsStore.getState().profiles[id]
|
||||
if (!profile || id === DEFAULT_PROFILE_ID) {
|
||||
setIsProfileMenuOpen(false)
|
||||
@@ -126,22 +152,70 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
|
||||
return
|
||||
}
|
||||
|
||||
deleteProfile(id)
|
||||
setIsProfileMenuOpen(false)
|
||||
try {
|
||||
await deleteProfile(id)
|
||||
} catch (error) {
|
||||
window.alert(getErrorMessage(error, 'Could not delete the profile.'))
|
||||
} finally {
|
||||
setIsProfileMenuOpen(false)
|
||||
}
|
||||
}, [deleteProfile])
|
||||
|
||||
const handleLoadProfile = useCallback(async (id: string) => {
|
||||
try {
|
||||
await loadProfile(id)
|
||||
} catch (error) {
|
||||
window.alert(getErrorMessage(error, 'Could not load the profile.'))
|
||||
} finally {
|
||||
setIsProfileMenuOpen(false)
|
||||
}
|
||||
}, [loadProfile])
|
||||
|
||||
const handleImportProfile = useCallback(async () => {
|
||||
try {
|
||||
await importProfileFromDialog()
|
||||
} catch (error) {
|
||||
window.alert(getErrorMessage(error, 'Could not import the profile file.'))
|
||||
} finally {
|
||||
setIsProfileMenuOpen(false)
|
||||
}
|
||||
}, [importProfileFromDialog])
|
||||
|
||||
const handleShowProfilesFolder = useCallback(async () => {
|
||||
try {
|
||||
await showProfilesFolder()
|
||||
} catch (error) {
|
||||
window.alert(getErrorMessage(error, 'Could not open the profiles folder.'))
|
||||
} finally {
|
||||
setIsProfileMenuOpen(false)
|
||||
}
|
||||
}, [showProfilesFolder])
|
||||
|
||||
useEffect(() => {
|
||||
const offClosed = window.electronAPI.onProfileMenuClosed(() => {
|
||||
setIsProfileMenuOpen(false)
|
||||
})
|
||||
const offLoad = window.electronAPI.onProfileMenuLoad((id) => {
|
||||
loadProfile(id)
|
||||
setIsProfileMenuOpen(false)
|
||||
void handleLoadProfile(id)
|
||||
})
|
||||
const offSaveNew = window.electronAPI.onProfileMenuSaveNew(() => {
|
||||
void handleSaveNew()
|
||||
})
|
||||
const offSaveOverwrite = window.electronAPI.onProfileMenuSaveOverwrite(() => {
|
||||
void handleSaveOverwrite()
|
||||
})
|
||||
const offRename = window.electronAPI.onProfileMenuRenameActive((id) => {
|
||||
void handleRenameActive(id)
|
||||
})
|
||||
const offDelete = window.electronAPI.onProfileMenuDeleteActive((id) => {
|
||||
void handleDeleteActive(id)
|
||||
})
|
||||
const offImport = window.electronAPI.onProfileMenuImport(() => {
|
||||
void handleImportProfile()
|
||||
})
|
||||
const offShowFolder = window.electronAPI.onProfileMenuShowFolder(() => {
|
||||
void handleShowProfilesFolder()
|
||||
})
|
||||
const offSaveNew = window.electronAPI.onProfileMenuSaveNew(handleSaveNew)
|
||||
const offSaveOverwrite = window.electronAPI.onProfileMenuSaveOverwrite(handleSaveOverwrite)
|
||||
const offRename = window.electronAPI.onProfileMenuRenameActive(handleRenameActive)
|
||||
const offDelete = window.electronAPI.onProfileMenuDeleteActive(handleDeleteActive)
|
||||
|
||||
return () => {
|
||||
offClosed()
|
||||
@@ -150,8 +224,18 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
|
||||
offSaveOverwrite()
|
||||
offRename()
|
||||
offDelete()
|
||||
offImport()
|
||||
offShowFolder()
|
||||
}
|
||||
}, [handleDeleteActive, handleRenameActive, handleSaveNew, handleSaveOverwrite, loadProfile])
|
||||
}, [
|
||||
handleDeleteActive,
|
||||
handleImportProfile,
|
||||
handleLoadProfile,
|
||||
handleRenameActive,
|
||||
handleSaveNew,
|
||||
handleSaveOverwrite,
|
||||
handleShowProfilesFolder,
|
||||
])
|
||||
|
||||
const handlePin = useCallback(() => {
|
||||
window.electronAPI.toggleAlwaysOnTop()
|
||||
@@ -225,10 +309,10 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
|
||||
type="button"
|
||||
className={`toolbar__profile-button ${isProfileMenuOpen ? 'is-active' : ''}`.trim()}
|
||||
onClick={handleOpenProfileMenu}
|
||||
title="Presets"
|
||||
title="Profiles"
|
||||
>
|
||||
<span className="toolbar__profile-name">
|
||||
{activeProfile?.name ?? 'Presets'}
|
||||
{activeProfile?.name ?? 'Profiles'}
|
||||
</span>
|
||||
<ChevronIcon />
|
||||
</button>
|
||||
|
||||
Vendored
+18
@@ -11,6 +11,12 @@ import type {
|
||||
WindowBounds,
|
||||
} from '../types/popout'
|
||||
import type { ProfileMenuRequest } from '../types/profileMenu'
|
||||
import type {
|
||||
LegacyProfileMigrationPayload,
|
||||
LegacyProfileMigrationResult,
|
||||
Profile,
|
||||
ProfileLibrarySnapshot,
|
||||
} from '../types/profile'
|
||||
import type { ScopeKind } from '../types/scope'
|
||||
|
||||
declare global {
|
||||
@@ -30,6 +36,15 @@ declare global {
|
||||
isAlwaysOnTop: () => Promise<boolean>
|
||||
getDesktopSources: () => Promise<{ id: string; name: string }[]>
|
||||
getCaptureBackendSupport: () => Promise<CaptureBackendSupport>
|
||||
getProfileSnapshot: () => Promise<ProfileLibrarySnapshot>
|
||||
saveNewProfile: (name: string, profile: Profile) => Promise<ProfileLibrarySnapshot>
|
||||
overwriteProfile: (id: string, profile: Profile) => Promise<ProfileLibrarySnapshot>
|
||||
loadProfile: (id: string) => Promise<ProfileLibrarySnapshot>
|
||||
deleteProfile: (id: string) => Promise<ProfileLibrarySnapshot>
|
||||
renameProfile: (id: string, name: string) => Promise<ProfileLibrarySnapshot>
|
||||
importProfileDialog: () => Promise<ProfileLibrarySnapshot | null>
|
||||
revealProfilesFolder: () => Promise<void>
|
||||
migrateLegacyProfiles: (payload: LegacyProfileMigrationPayload) => Promise<LegacyProfileMigrationResult>
|
||||
expandSettings: (panelHeight: number) => void
|
||||
collapseSettings: (panelHeight: number) => void
|
||||
setSettingsHeight: (panelHeight: number) => void
|
||||
@@ -51,6 +66,9 @@ declare global {
|
||||
onProfileMenuSaveOverwrite: (callback: () => void) => () => void
|
||||
onProfileMenuRenameActive: (callback: (id: string) => void) => () => void
|
||||
onProfileMenuDeleteActive: (callback: (id: string) => void) => () => void
|
||||
onProfileMenuImport: (callback: () => void) => () => void
|
||||
onProfileMenuShowFolder: (callback: () => void) => () => void
|
||||
onExternalProfileActivated: (callback: (snapshot: ProfileLibrarySnapshot) => void) => () => void
|
||||
onScopePopoutReady: (callback: (kind: ScopeKind) => void) => () => void
|
||||
onScopePopoutCloseRequested: (callback: (kind: ScopeKind) => void) => () => void
|
||||
onScopePopoutBoundsChanged: (callback: (kind: ScopeKind, bounds: WindowBounds) => void) => () => void
|
||||
|
||||
@@ -1,72 +1,53 @@
|
||||
import { create } from 'zustand'
|
||||
import { SCOPE_KINDS, type ScopeKind } from '../../types/scope'
|
||||
import type { ScopePopoutStateMap, WindowBounds } from '../../types/popout'
|
||||
import { DEFAULT_SCOPE_SETTINGS, type ScopeSettings } from '../../types/settings'
|
||||
import {
|
||||
DEFAULT_PROFILE_ID,
|
||||
DEFAULT_PROFILE_NAME,
|
||||
type LegacyProfileMigrationPayload,
|
||||
type Profile,
|
||||
type ProfileLibrarySnapshot,
|
||||
} from '../../types/profile'
|
||||
import type { ScopeKind } from '../../types/scope'
|
||||
import type { ScopeSettings } from '../../types/settings'
|
||||
import {
|
||||
cloneScopeSettings,
|
||||
createDefaultProfile,
|
||||
mergeScopeSettings,
|
||||
normalizeHiddenScopes,
|
||||
normalizeProfile,
|
||||
normalizeScopeOrder,
|
||||
normalizeScopePopouts,
|
||||
normalizeWidthWeights,
|
||||
} from '../../shared/profileState'
|
||||
|
||||
export type { ScopeSettings } from '../../types/settings'
|
||||
|
||||
const DEFAULT_VISIBLE: ScopeKind[] = ['spectrum', 'oscilloscope', 'vectorscope', 'vumeter']
|
||||
|
||||
const STORAGE_KEY = 'prism:settings'
|
||||
const PROFILES_STORAGE_KEY = 'prism:profiles'
|
||||
const ACTIVE_PROFILE_KEY = 'prism:activeProfile'
|
||||
const DEFAULT_PROFILE_ID = 'profile_default'
|
||||
|
||||
export interface Profile {
|
||||
name: string
|
||||
interface PersistedSettingsState {
|
||||
scopeOrder: ScopeKind[]
|
||||
hiddenScopes: ScopeKind[]
|
||||
widthWeights: Record<ScopeKind, number>
|
||||
scopeSettings: ScopeSettings
|
||||
scopePopouts: ScopePopoutStateMap
|
||||
windowBounds?: WindowBounds
|
||||
}
|
||||
|
||||
function loadProfiles(): Record<string, Profile> {
|
||||
try {
|
||||
const raw = localStorage.getItem(PROFILES_STORAGE_KEY)
|
||||
if (raw) return JSON.parse(raw)
|
||||
} catch { /* ignore */ }
|
||||
return {}
|
||||
}
|
||||
|
||||
function saveProfiles(profiles: Record<string, Profile>): void {
|
||||
try {
|
||||
localStorage.setItem(PROFILES_STORAGE_KEY, JSON.stringify(profiles))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function loadActiveProfileId(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(ACTIVE_PROFILE_KEY)
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
function saveActiveProfileId(id: string | null): void {
|
||||
try {
|
||||
if (id) {
|
||||
localStorage.setItem(ACTIVE_PROFILE_KEY, id)
|
||||
} else {
|
||||
localStorage.removeItem(ACTIVE_PROFILE_KEY)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
interface SettingsState {
|
||||
interface WorkingSettingsState {
|
||||
scopeOrder: ScopeKind[]
|
||||
hiddenScopes: Set<ScopeKind>
|
||||
widthWeights: Record<ScopeKind, number>
|
||||
scopeSettings: ScopeSettings
|
||||
scopePopouts: ScopePopoutStateMap
|
||||
}
|
||||
|
||||
// Derived
|
||||
interface SettingsState extends WorkingSettingsState {
|
||||
visibleScopes: () => ScopeKind[]
|
||||
|
||||
// Profiles
|
||||
profiles: Record<string, Profile>
|
||||
activeProfileId: string | null
|
||||
|
||||
// Actions
|
||||
initializeProfiles: () => Promise<void>
|
||||
applyExternalProfileSnapshot: (snapshot: ProfileLibrarySnapshot) => void
|
||||
toggleScope: (kind: ScopeKind) => void
|
||||
moveDockedScope: (kind: ScopeKind, direction: 'left' | 'right') => void
|
||||
setScopeWidthWeight: (kind: ScopeKind, weight: number) => void
|
||||
@@ -74,29 +55,29 @@ interface SettingsState {
|
||||
popOutScope: (kind: ScopeKind, bounds?: WindowBounds) => void
|
||||
popInScope: (kind: ScopeKind) => void
|
||||
updatePopoutBounds: (kind: ScopeKind, bounds: WindowBounds) => void
|
||||
saveProfile: (name: string) => string
|
||||
saveProfileAs: (name: string) => string
|
||||
updateActiveProfile: () => void
|
||||
loadProfile: (id: string) => void
|
||||
deleteProfile: (id: string) => void
|
||||
renameProfile: (id: string, name: string) => void
|
||||
saveProfile: (name: string) => Promise<string | null>
|
||||
saveProfileAs: (name: string) => Promise<string | null>
|
||||
updateActiveProfile: () => Promise<void>
|
||||
loadProfile: (id: string) => Promise<void>
|
||||
deleteProfile: (id: string) => Promise<void>
|
||||
renameProfile: (id: string, name: string) => Promise<void>
|
||||
importProfileFromDialog: () => Promise<void>
|
||||
showProfilesFolder: () => Promise<void>
|
||||
}
|
||||
|
||||
function loadFromStorage(): Partial<{
|
||||
scopeOrder: ScopeKind[]
|
||||
hiddenScopes: ScopeKind[]
|
||||
widthWeights: Record<ScopeKind, number>
|
||||
scopeSettings: ScopeSettings
|
||||
scopePopouts: ScopePopoutStateMap
|
||||
}> {
|
||||
function canUseElectronAPI(): boolean {
|
||||
return typeof window !== 'undefined' && typeof window.electronAPI !== 'undefined'
|
||||
}
|
||||
|
||||
function loadFromStorage(): Partial<PersistedSettingsState> {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) return JSON.parse(raw)
|
||||
if (raw) return JSON.parse(raw) as Partial<PersistedSettingsState>
|
||||
} catch { /* ignore */ }
|
||||
return {}
|
||||
}
|
||||
|
||||
function saveToStorage(state: SettingsState): void {
|
||||
function saveToStorage(state: WorkingSettingsState): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
scopeOrder: state.scopeOrder,
|
||||
@@ -108,95 +89,96 @@ function saveToStorage(state: SettingsState): void {
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function isScopeKind(value: unknown): value is ScopeKind {
|
||||
return typeof value === 'string' && SCOPE_KINDS.includes(value as ScopeKind)
|
||||
}
|
||||
|
||||
function normalizeScopeOrder(raw: unknown): ScopeKind[] {
|
||||
if (!Array.isArray(raw)) return [...SCOPE_KINDS]
|
||||
const valid = raw.filter(isScopeKind)
|
||||
const seen = new Set<ScopeKind>()
|
||||
const normalized: ScopeKind[] = []
|
||||
|
||||
for (const kind of valid) {
|
||||
if (seen.has(kind)) continue
|
||||
seen.add(kind)
|
||||
normalized.push(kind)
|
||||
}
|
||||
|
||||
for (const kind of SCOPE_KINDS) {
|
||||
if (!seen.has(kind)) {
|
||||
normalized.push(kind)
|
||||
function loadLegacyProfileMigrationPayload(): LegacyProfileMigrationPayload | null {
|
||||
try {
|
||||
const rawProfiles = localStorage.getItem(PROFILES_STORAGE_KEY)
|
||||
const rawActiveProfileId = localStorage.getItem(ACTIVE_PROFILE_KEY)
|
||||
if (!rawProfiles && !rawActiveProfileId) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return normalized
|
||||
const parsedProfiles = rawProfiles
|
||||
? JSON.parse(rawProfiles) as Record<string, Profile>
|
||||
: {}
|
||||
|
||||
return {
|
||||
profiles: parsedProfiles,
|
||||
activeProfileId: rawActiveProfileId,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHiddenScopes(raw: unknown): ScopeKind[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
return SCOPE_KINDS.filter((kind) => !DEFAULT_VISIBLE.includes(kind))
|
||||
}
|
||||
return raw.filter(isScopeKind)
|
||||
function clearLegacyProfileStorage(): void {
|
||||
try {
|
||||
localStorage.removeItem(PROFILES_STORAGE_KEY)
|
||||
localStorage.removeItem(ACTIVE_PROFILE_KEY)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function mergeScopeSettings(raw: unknown): ScopeSettings {
|
||||
const parsed = typeof raw === 'object' && raw !== null
|
||||
? raw as Partial<ScopeSettings>
|
||||
: {}
|
||||
function createWorkingStateFromProfile(profile: Profile): WorkingSettingsState {
|
||||
const normalizedProfile = normalizeProfile(profile, profile.name)
|
||||
|
||||
return {
|
||||
spectrum: { ...DEFAULT_SCOPE_SETTINGS.spectrum, ...(parsed.spectrum ?? {}) },
|
||||
oscilloscope: { ...DEFAULT_SCOPE_SETTINGS.oscilloscope, ...(parsed.oscilloscope ?? {}) },
|
||||
vectorscope: { ...DEFAULT_SCOPE_SETTINGS.vectorscope, ...(parsed.vectorscope ?? {}) },
|
||||
spectrogram: { ...DEFAULT_SCOPE_SETTINGS.spectrogram, ...(parsed.spectrogram ?? {}) },
|
||||
vumeter: { ...DEFAULT_SCOPE_SETTINGS.vumeter, ...(parsed.vumeter ?? {}) },
|
||||
lufsmeter: { ...DEFAULT_SCOPE_SETTINGS.lufsmeter, ...(parsed.lufsmeter ?? {}) },
|
||||
waveform: { ...DEFAULT_SCOPE_SETTINGS.waveform, ...(parsed.waveform ?? {}) },
|
||||
scopeOrder: normalizeScopeOrder(normalizedProfile.scopeOrder),
|
||||
hiddenScopes: new Set<ScopeKind>(normalizeHiddenScopes(normalizedProfile.hiddenScopes)),
|
||||
widthWeights: normalizeWidthWeights(normalizedProfile.widthWeights),
|
||||
scopeSettings: mergeScopeSettings(normalizedProfile.scopeSettings),
|
||||
scopePopouts: normalizeScopePopouts(normalizedProfile.scopePopouts),
|
||||
}
|
||||
}
|
||||
|
||||
function createDefaultScopePopouts(): ScopePopoutStateMap {
|
||||
return SCOPE_KINDS.reduce((acc, kind) => {
|
||||
acc[kind] = { poppedOut: false }
|
||||
return acc
|
||||
}, {} as ScopePopoutStateMap)
|
||||
}
|
||||
function applyProfileSnapshot(
|
||||
set: (partial: Partial<SettingsState>) => void,
|
||||
snapshot: ProfileLibrarySnapshot,
|
||||
options: { loadActiveProfile: boolean },
|
||||
): void {
|
||||
const activeProfile = snapshot.activeProfileId
|
||||
? snapshot.profiles[snapshot.activeProfileId] ?? null
|
||||
: null
|
||||
|
||||
function normalizeWindowBounds(raw: unknown): WindowBounds | undefined {
|
||||
if (typeof raw !== 'object' || raw === null) return undefined
|
||||
const candidate = raw as Partial<WindowBounds>
|
||||
if (
|
||||
typeof candidate.x !== 'number'
|
||||
|| typeof candidate.y !== 'number'
|
||||
|| typeof candidate.width !== 'number'
|
||||
|| typeof candidate.height !== 'number'
|
||||
) {
|
||||
return undefined
|
||||
if (!options.loadActiveProfile || !activeProfile) {
|
||||
set({
|
||||
profiles: snapshot.profiles,
|
||||
activeProfileId: snapshot.activeProfileId,
|
||||
})
|
||||
return
|
||||
}
|
||||
return {
|
||||
x: Math.round(candidate.x),
|
||||
y: Math.round(candidate.y),
|
||||
width: Math.max(120, Math.round(candidate.width)),
|
||||
height: Math.max(80, Math.round(candidate.height)),
|
||||
|
||||
const nextState = createWorkingStateFromProfile(activeProfile)
|
||||
saveToStorage(nextState)
|
||||
set({
|
||||
...nextState,
|
||||
profiles: snapshot.profiles,
|
||||
activeProfileId: snapshot.activeProfileId,
|
||||
})
|
||||
|
||||
if (activeProfile.windowBounds && canUseElectronAPI()) {
|
||||
window.electronAPI.setWindowBounds(activeProfile.windowBounds)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeScopePopouts(raw: unknown): ScopePopoutStateMap {
|
||||
const defaults = createDefaultScopePopouts()
|
||||
const parsed = typeof raw === 'object' && raw !== null
|
||||
? raw as Partial<Record<ScopeKind, Partial<ScopePopoutStateMap[ScopeKind]>>>
|
||||
: {}
|
||||
async function buildProfileFromState(state: SettingsState, name: string): Promise<Profile> {
|
||||
const profile = normalizeProfile({
|
||||
name,
|
||||
scopeOrder: [...state.scopeOrder],
|
||||
hiddenScopes: Array.from(state.hiddenScopes),
|
||||
widthWeights: { ...state.widthWeights },
|
||||
scopeSettings: cloneScopeSettings(state.scopeSettings),
|
||||
scopePopouts: normalizeScopePopouts(state.scopePopouts),
|
||||
}, name)
|
||||
|
||||
for (const kind of SCOPE_KINDS) {
|
||||
const value = parsed[kind]
|
||||
defaults[kind] = {
|
||||
poppedOut: Boolean(value?.poppedOut),
|
||||
windowBounds: normalizeWindowBounds(value?.windowBounds),
|
||||
}
|
||||
if (!canUseElectronAPI()) {
|
||||
return profile
|
||||
}
|
||||
|
||||
return defaults
|
||||
const bounds = await window.electronAPI.getWindowBounds()
|
||||
if (bounds) {
|
||||
profile.windowBounds = bounds
|
||||
}
|
||||
|
||||
return profile
|
||||
}
|
||||
|
||||
function isDockedScope(
|
||||
@@ -242,50 +224,46 @@ export function moveDockedScopeOrder(
|
||||
return didChange ? mergedOrder : scopeOrder
|
||||
}
|
||||
|
||||
function cloneScopeSettings(settings: ScopeSettings): ScopeSettings {
|
||||
return JSON.parse(JSON.stringify(settings)) as ScopeSettings
|
||||
}
|
||||
|
||||
const stored = loadFromStorage()
|
||||
|
||||
const defaultWeights: Record<ScopeKind, number> = {
|
||||
spectrum: 1, oscilloscope: 1, vectorscope: 1, spectrogram: 1,
|
||||
vumeter: 0.5, lufsmeter: 0.5, waveform: 1,
|
||||
}
|
||||
|
||||
// Ensure a default profile always exists
|
||||
function ensureDefaultProfile(profiles: Record<string, Profile>): Record<string, Profile> {
|
||||
if (profiles[DEFAULT_PROFILE_ID]) return profiles
|
||||
const defaultProfile: Profile = {
|
||||
name: 'Default',
|
||||
scopeOrder: [...SCOPE_KINDS],
|
||||
hiddenScopes: SCOPE_KINDS.filter((kind) => !DEFAULT_VISIBLE.includes(kind)),
|
||||
widthWeights: { ...defaultWeights },
|
||||
scopeSettings: cloneScopeSettings(DEFAULT_SCOPE_SETTINGS),
|
||||
scopePopouts: createDefaultScopePopouts(),
|
||||
}
|
||||
const updated = { [DEFAULT_PROFILE_ID]: defaultProfile, ...profiles }
|
||||
saveProfiles(updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
const initialProfiles = ensureDefaultProfile(loadProfiles())
|
||||
const initialActiveProfileId = loadActiveProfileId()
|
||||
|
||||
export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
const initialWorkingState: WorkingSettingsState = {
|
||||
scopeOrder: normalizeScopeOrder(stored.scopeOrder),
|
||||
hiddenScopes: new Set<ScopeKind>(
|
||||
normalizeHiddenScopes(stored.hiddenScopes)
|
||||
),
|
||||
widthWeights: stored.widthWeights ?? { ...defaultWeights },
|
||||
hiddenScopes: new Set<ScopeKind>(normalizeHiddenScopes(stored.hiddenScopes)),
|
||||
widthWeights: normalizeWidthWeights(stored.widthWeights),
|
||||
scopeSettings: mergeScopeSettings(stored.scopeSettings),
|
||||
scopePopouts: normalizeScopePopouts(stored.scopePopouts),
|
||||
profiles: initialProfiles,
|
||||
activeProfileId: initialActiveProfileId,
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
...initialWorkingState,
|
||||
profiles: {
|
||||
[DEFAULT_PROFILE_ID]: createDefaultProfile(DEFAULT_PROFILE_NAME),
|
||||
},
|
||||
activeProfileId: null,
|
||||
|
||||
visibleScopes: () => {
|
||||
const { scopeOrder, hiddenScopes } = get()
|
||||
return scopeOrder.filter((k) => !hiddenScopes.has(k))
|
||||
return scopeOrder.filter((kind) => !hiddenScopes.has(kind))
|
||||
},
|
||||
|
||||
initializeProfiles: async () => {
|
||||
if (!canUseElectronAPI()) return
|
||||
|
||||
const legacyPayload = loadLegacyProfileMigrationPayload()
|
||||
let snapshot = await window.electronAPI.getProfileSnapshot()
|
||||
|
||||
if (legacyPayload) {
|
||||
const migrationResult = await window.electronAPI.migrateLegacyProfiles(legacyPayload)
|
||||
if (migrationResult.didMigrate) {
|
||||
clearLegacyProfileStorage()
|
||||
snapshot = migrationResult.snapshot
|
||||
}
|
||||
}
|
||||
|
||||
applyProfileSnapshot(set, snapshot, { loadActiveProfile: true })
|
||||
},
|
||||
|
||||
applyExternalProfileSnapshot: (snapshot: ProfileLibrarySnapshot) => {
|
||||
applyProfileSnapshot(set, snapshot, { loadActiveProfile: true })
|
||||
},
|
||||
|
||||
toggleScope: (kind: ScopeKind) => {
|
||||
@@ -294,14 +272,14 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
if (next.has(kind)) {
|
||||
next.delete(kind)
|
||||
} else {
|
||||
// Don't allow hiding all scopes
|
||||
const visibleCount = state.scopeOrder.filter((k) => !next.has(k)).length
|
||||
const visibleCount = state.scopeOrder.filter((scope) => !next.has(scope)).length
|
||||
if (visibleCount <= 1) return state
|
||||
next.add(kind)
|
||||
}
|
||||
const newState = { ...state, hiddenScopes: next }
|
||||
saveToStorage(newState as SettingsState)
|
||||
return newState
|
||||
|
||||
const nextState = { ...state, hiddenScopes: next }
|
||||
saveToStorage(nextState)
|
||||
return nextState
|
||||
})
|
||||
},
|
||||
|
||||
@@ -315,193 +293,146 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
direction,
|
||||
)
|
||||
if (nextOrder === state.scopeOrder) return state
|
||||
const newState = { ...state, scopeOrder: nextOrder }
|
||||
saveToStorage(newState as SettingsState)
|
||||
return newState
|
||||
|
||||
const nextState = { ...state, scopeOrder: nextOrder }
|
||||
saveToStorage(nextState)
|
||||
return nextState
|
||||
})
|
||||
},
|
||||
|
||||
setScopeWidthWeight: (kind: ScopeKind, weight: number) => {
|
||||
set((state) => {
|
||||
const newState = { ...state, widthWeights: { ...state.widthWeights, [kind]: Math.max(0.1, weight) } }
|
||||
saveToStorage(newState as SettingsState)
|
||||
return newState
|
||||
const nextState = {
|
||||
...state,
|
||||
widthWeights: { ...state.widthWeights, [kind]: Math.max(0.1, weight) },
|
||||
}
|
||||
saveToStorage(nextState)
|
||||
return nextState
|
||||
})
|
||||
},
|
||||
|
||||
updateScopeSettings: <K extends ScopeKind>(kind: K, settings: Partial<ScopeSettings[K]>) => {
|
||||
set((state) => {
|
||||
const newState = {
|
||||
const nextState = {
|
||||
...state,
|
||||
scopeSettings: {
|
||||
...state.scopeSettings,
|
||||
[kind]: { ...state.scopeSettings[kind], ...settings },
|
||||
},
|
||||
}
|
||||
saveToStorage(newState as SettingsState)
|
||||
return newState
|
||||
saveToStorage(nextState)
|
||||
return nextState
|
||||
})
|
||||
},
|
||||
|
||||
popOutScope: (kind: ScopeKind, bounds?: WindowBounds) => {
|
||||
set((state) => {
|
||||
const nextPopout = {
|
||||
...state.scopePopouts,
|
||||
[kind]: {
|
||||
poppedOut: true,
|
||||
windowBounds: bounds ?? state.scopePopouts[kind]?.windowBounds,
|
||||
const nextState = {
|
||||
...state,
|
||||
scopePopouts: {
|
||||
...state.scopePopouts,
|
||||
[kind]: {
|
||||
poppedOut: true,
|
||||
windowBounds: bounds ?? state.scopePopouts[kind]?.windowBounds,
|
||||
},
|
||||
},
|
||||
}
|
||||
const newState = { ...state, scopePopouts: nextPopout }
|
||||
saveToStorage(newState as SettingsState)
|
||||
return newState
|
||||
saveToStorage(nextState)
|
||||
return nextState
|
||||
})
|
||||
},
|
||||
|
||||
popInScope: (kind: ScopeKind) => {
|
||||
set((state) => {
|
||||
const nextPopout = {
|
||||
...state.scopePopouts,
|
||||
[kind]: {
|
||||
...state.scopePopouts[kind],
|
||||
poppedOut: false,
|
||||
const nextState = {
|
||||
...state,
|
||||
scopePopouts: {
|
||||
...state.scopePopouts,
|
||||
[kind]: {
|
||||
...state.scopePopouts[kind],
|
||||
poppedOut: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
const newState = { ...state, scopePopouts: nextPopout }
|
||||
saveToStorage(newState as SettingsState)
|
||||
return newState
|
||||
saveToStorage(nextState)
|
||||
return nextState
|
||||
})
|
||||
},
|
||||
|
||||
updatePopoutBounds: (kind: ScopeKind, bounds: WindowBounds) => {
|
||||
set((state) => {
|
||||
const nextPopout = {
|
||||
...state.scopePopouts,
|
||||
[kind]: {
|
||||
...state.scopePopouts[kind],
|
||||
windowBounds: normalizeWindowBounds(bounds),
|
||||
const nextState = {
|
||||
...state,
|
||||
scopePopouts: {
|
||||
...state.scopePopouts,
|
||||
[kind]: {
|
||||
...state.scopePopouts[kind],
|
||||
windowBounds: bounds,
|
||||
},
|
||||
},
|
||||
}
|
||||
const newState = { ...state, scopePopouts: nextPopout }
|
||||
saveToStorage(newState as SettingsState)
|
||||
return newState
|
||||
saveToStorage(nextState)
|
||||
return nextState
|
||||
})
|
||||
},
|
||||
|
||||
saveProfile: (name: string) => {
|
||||
const state = get()
|
||||
const id = `profile_${Date.now()}`
|
||||
const profile: Profile = {
|
||||
name,
|
||||
scopeOrder: [...state.scopeOrder],
|
||||
hiddenScopes: Array.from(state.hiddenScopes),
|
||||
widthWeights: { ...state.widthWeights },
|
||||
scopeSettings: cloneScopeSettings(state.scopeSettings),
|
||||
scopePopouts: normalizeScopePopouts(state.scopePopouts),
|
||||
}
|
||||
// Capture window bounds asynchronously
|
||||
window.electronAPI.getWindowBounds().then((bounds) => {
|
||||
if (bounds) {
|
||||
const profiles = get().profiles
|
||||
const updated = { ...profiles, [id]: { ...profiles[id], windowBounds: bounds } }
|
||||
saveProfiles(updated)
|
||||
set({ profiles: updated })
|
||||
}
|
||||
})
|
||||
const profiles = { ...state.profiles, [id]: profile }
|
||||
saveProfiles(profiles)
|
||||
saveActiveProfileId(id)
|
||||
set({ profiles, activeProfileId: id })
|
||||
return id
|
||||
saveProfile: async (name: string) => {
|
||||
if (!canUseElectronAPI()) return null
|
||||
|
||||
const snapshot = await window.electronAPI.saveNewProfile(name, await buildProfileFromState(get(), name))
|
||||
applyProfileSnapshot(set, snapshot, { loadActiveProfile: false })
|
||||
return snapshot.activeProfileId
|
||||
},
|
||||
|
||||
saveProfileAs: (name: string) => {
|
||||
// Same as saveProfile but always creates a new entry
|
||||
saveProfileAs: async (name: string) => {
|
||||
return get().saveProfile(name)
|
||||
},
|
||||
|
||||
updateActiveProfile: () => {
|
||||
updateActiveProfile: async () => {
|
||||
if (!canUseElectronAPI()) return
|
||||
|
||||
const state = get()
|
||||
const id = state.activeProfileId
|
||||
if (!id || !state.profiles[id]) return
|
||||
const updated: Profile = {
|
||||
...state.profiles[id],
|
||||
scopeOrder: [...state.scopeOrder],
|
||||
hiddenScopes: Array.from(state.hiddenScopes),
|
||||
widthWeights: { ...state.widthWeights },
|
||||
scopeSettings: cloneScopeSettings(state.scopeSettings),
|
||||
scopePopouts: normalizeScopePopouts(state.scopePopouts),
|
||||
}
|
||||
// Capture window bounds asynchronously
|
||||
window.electronAPI.getWindowBounds().then((bounds) => {
|
||||
if (bounds) {
|
||||
const profiles = get().profiles
|
||||
const withBounds = { ...profiles, [id]: { ...profiles[id], windowBounds: bounds } }
|
||||
saveProfiles(withBounds)
|
||||
set({ profiles: withBounds })
|
||||
}
|
||||
})
|
||||
const profiles = { ...state.profiles, [id]: updated }
|
||||
saveProfiles(profiles)
|
||||
set({ profiles })
|
||||
|
||||
const snapshot = await window.electronAPI.overwriteProfile(
|
||||
id,
|
||||
await buildProfileFromState(state, state.profiles[id].name),
|
||||
)
|
||||
applyProfileSnapshot(set, snapshot, { loadActiveProfile: false })
|
||||
},
|
||||
|
||||
loadProfile: (id: string) => {
|
||||
const state = get()
|
||||
const profile = state.profiles[id]
|
||||
if (!profile) return
|
||||
const newState = {
|
||||
...state,
|
||||
scopeOrder: normalizeScopeOrder(profile.scopeOrder),
|
||||
hiddenScopes: new Set<ScopeKind>(normalizeHiddenScopes(profile.hiddenScopes)),
|
||||
widthWeights: profile.widthWeights ?? { ...defaultWeights },
|
||||
scopeSettings: mergeScopeSettings(profile.scopeSettings),
|
||||
scopePopouts: normalizeScopePopouts(profile.scopePopouts),
|
||||
activeProfileId: id,
|
||||
}
|
||||
saveToStorage(newState as SettingsState)
|
||||
saveActiveProfileId(id)
|
||||
set(newState)
|
||||
loadProfile: async (id: string) => {
|
||||
if (!canUseElectronAPI()) return
|
||||
|
||||
if (profile.windowBounds) {
|
||||
window.electronAPI.setWindowBounds(profile.windowBounds)
|
||||
}
|
||||
const snapshot = await window.electronAPI.loadProfile(id)
|
||||
applyProfileSnapshot(set, snapshot, { loadActiveProfile: true })
|
||||
},
|
||||
|
||||
deleteProfile: (id: string) => {
|
||||
// Prevent deleting the default profile
|
||||
if (id === DEFAULT_PROFILE_ID) return
|
||||
const state = get()
|
||||
const profiles = { ...state.profiles }
|
||||
delete profiles[id]
|
||||
saveProfiles(profiles)
|
||||
const nextActiveId = state.activeProfileId === id ? null : state.activeProfileId
|
||||
saveActiveProfileId(nextActiveId)
|
||||
set({
|
||||
profiles,
|
||||
activeProfileId: nextActiveId,
|
||||
})
|
||||
deleteProfile: async (id: string) => {
|
||||
if (id === DEFAULT_PROFILE_ID || !canUseElectronAPI()) return
|
||||
|
||||
const snapshot = await window.electronAPI.deleteProfile(id)
|
||||
applyProfileSnapshot(set, snapshot, { loadActiveProfile: false })
|
||||
},
|
||||
|
||||
renameProfile: (id: string, name: string) => {
|
||||
if (id === DEFAULT_PROFILE_ID) return
|
||||
const state = get()
|
||||
const profile = state.profiles[id]
|
||||
if (!profile) return
|
||||
const profiles = { ...state.profiles, [id]: { ...profile, name } }
|
||||
saveProfiles(profiles)
|
||||
set({ profiles })
|
||||
renameProfile: async (id: string, name: string) => {
|
||||
if (id === DEFAULT_PROFILE_ID || !canUseElectronAPI()) return
|
||||
|
||||
const snapshot = await window.electronAPI.renameProfile(id, name)
|
||||
applyProfileSnapshot(set, snapshot, { loadActiveProfile: false })
|
||||
},
|
||||
|
||||
importProfileFromDialog: async () => {
|
||||
if (!canUseElectronAPI()) return
|
||||
|
||||
const snapshot = await window.electronAPI.importProfileDialog()
|
||||
if (!snapshot) return
|
||||
applyProfileSnapshot(set, snapshot, { loadActiveProfile: true })
|
||||
},
|
||||
|
||||
showProfilesFolder: async () => {
|
||||
if (!canUseElectronAPI()) return
|
||||
await window.electronAPI.revealProfilesFolder()
|
||||
},
|
||||
}))
|
||||
|
||||
// On startup, restore last active profile's settings (but not window bounds — those are handled by Electron)
|
||||
if (initialActiveProfileId && initialProfiles[initialActiveProfileId]) {
|
||||
const profile = initialProfiles[initialActiveProfileId]
|
||||
useSettingsStore.setState({
|
||||
scopeOrder: normalizeScopeOrder(profile.scopeOrder),
|
||||
hiddenScopes: new Set<ScopeKind>(normalizeHiddenScopes(profile.hiddenScopes)),
|
||||
widthWeights: profile.widthWeights ?? { ...defaultWeights },
|
||||
scopeSettings: mergeScopeSettings(profile.scopeSettings),
|
||||
scopePopouts: normalizeScopePopouts(profile.scopePopouts),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import type { ScopePopoutStateMap, WindowBounds } from '../types/popout'
|
||||
import {
|
||||
DEFAULT_PROFILE_NAME,
|
||||
PROFILE_FILE_FORMAT,
|
||||
PROFILE_FILE_VERSION,
|
||||
PROFILE_LOCAL_STATE_FORMAT,
|
||||
PROFILE_LOCAL_STATE_VERSION,
|
||||
type Profile,
|
||||
type ProfileLocalMetadata,
|
||||
type PrismProfileFileScopePopoutMap,
|
||||
type PrismProfileFileV1,
|
||||
type PrismProfileLocalStateV1,
|
||||
} from '../types/profile'
|
||||
import { SCOPE_KINDS, type ScopeKind } from '../types/scope'
|
||||
import { DEFAULT_SCOPE_SETTINGS, type ScopeSettings } from '../types/settings'
|
||||
|
||||
export const DEFAULT_VISIBLE: ScopeKind[] = ['spectrum', 'oscilloscope', 'vectorscope', 'vumeter']
|
||||
|
||||
export const DEFAULT_SCOPE_WIDTH_WEIGHTS: Record<ScopeKind, number> = {
|
||||
spectrum: 1,
|
||||
oscilloscope: 1,
|
||||
vectorscope: 1,
|
||||
spectrogram: 1,
|
||||
vumeter: 0.5,
|
||||
lufsmeter: 0.5,
|
||||
waveform: 1,
|
||||
}
|
||||
|
||||
export function isScopeKind(value: unknown): value is ScopeKind {
|
||||
return typeof value === 'string' && SCOPE_KINDS.includes(value as ScopeKind)
|
||||
}
|
||||
|
||||
export function cloneScopeSettings(settings: ScopeSettings): ScopeSettings {
|
||||
return JSON.parse(JSON.stringify(settings)) as ScopeSettings
|
||||
}
|
||||
|
||||
export function createDefaultScopePopouts(): ScopePopoutStateMap {
|
||||
return SCOPE_KINDS.reduce((acc, kind) => {
|
||||
acc[kind] = { poppedOut: false }
|
||||
return acc
|
||||
}, {} as ScopePopoutStateMap)
|
||||
}
|
||||
|
||||
export function normalizeWindowBounds(
|
||||
raw: unknown,
|
||||
minWidth = 120,
|
||||
minHeight = 80,
|
||||
): WindowBounds | undefined {
|
||||
if (typeof raw !== 'object' || raw === null) return undefined
|
||||
|
||||
const candidate = raw as Partial<WindowBounds>
|
||||
if (
|
||||
typeof candidate.x !== 'number'
|
||||
|| typeof candidate.y !== 'number'
|
||||
|| typeof candidate.width !== 'number'
|
||||
|| typeof candidate.height !== 'number'
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
x: Math.round(candidate.x),
|
||||
y: Math.round(candidate.y),
|
||||
width: Math.max(minWidth, Math.round(candidate.width)),
|
||||
height: Math.max(minHeight, Math.round(candidate.height)),
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeScopeOrder(raw: unknown): ScopeKind[] {
|
||||
if (!Array.isArray(raw)) return [...SCOPE_KINDS]
|
||||
|
||||
const valid = raw.filter(isScopeKind)
|
||||
const seen = new Set<ScopeKind>()
|
||||
const normalized: ScopeKind[] = []
|
||||
|
||||
for (const kind of valid) {
|
||||
if (seen.has(kind)) continue
|
||||
seen.add(kind)
|
||||
normalized.push(kind)
|
||||
}
|
||||
|
||||
for (const kind of SCOPE_KINDS) {
|
||||
if (!seen.has(kind)) {
|
||||
normalized.push(kind)
|
||||
}
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function normalizeHiddenScopes(raw: unknown): ScopeKind[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
return SCOPE_KINDS.filter((kind) => !DEFAULT_VISIBLE.includes(kind))
|
||||
}
|
||||
|
||||
return raw.filter(isScopeKind)
|
||||
}
|
||||
|
||||
export function normalizeWidthWeights(raw: unknown): Record<ScopeKind, number> {
|
||||
const parsed = typeof raw === 'object' && raw !== null
|
||||
? raw as Partial<Record<ScopeKind, unknown>>
|
||||
: {}
|
||||
|
||||
return SCOPE_KINDS.reduce((acc, kind) => {
|
||||
const value = parsed[kind]
|
||||
acc[kind] = typeof value === 'number' && Number.isFinite(value)
|
||||
? Math.max(0.1, value)
|
||||
: DEFAULT_SCOPE_WIDTH_WEIGHTS[kind]
|
||||
return acc
|
||||
}, {} as Record<ScopeKind, number>)
|
||||
}
|
||||
|
||||
export function mergeScopeSettings(raw: unknown): ScopeSettings {
|
||||
const parsed = typeof raw === 'object' && raw !== null
|
||||
? raw as Partial<ScopeSettings>
|
||||
: {}
|
||||
|
||||
return {
|
||||
spectrum: { ...DEFAULT_SCOPE_SETTINGS.spectrum, ...(parsed.spectrum ?? {}) },
|
||||
oscilloscope: { ...DEFAULT_SCOPE_SETTINGS.oscilloscope, ...(parsed.oscilloscope ?? {}) },
|
||||
vectorscope: { ...DEFAULT_SCOPE_SETTINGS.vectorscope, ...(parsed.vectorscope ?? {}) },
|
||||
spectrogram: { ...DEFAULT_SCOPE_SETTINGS.spectrogram, ...(parsed.spectrogram ?? {}) },
|
||||
vumeter: { ...DEFAULT_SCOPE_SETTINGS.vumeter, ...(parsed.vumeter ?? {}) },
|
||||
lufsmeter: { ...DEFAULT_SCOPE_SETTINGS.lufsmeter, ...(parsed.lufsmeter ?? {}) },
|
||||
waveform: { ...DEFAULT_SCOPE_SETTINGS.waveform, ...(parsed.waveform ?? {}) },
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeScopePopouts(raw: unknown): ScopePopoutStateMap {
|
||||
const defaults = createDefaultScopePopouts()
|
||||
const parsed = typeof raw === 'object' && raw !== null
|
||||
? raw as Partial<Record<ScopeKind, Partial<ScopePopoutStateMap[ScopeKind]>>>
|
||||
: {}
|
||||
|
||||
for (const kind of SCOPE_KINDS) {
|
||||
const value = parsed[kind]
|
||||
defaults[kind] = {
|
||||
poppedOut: Boolean(value?.poppedOut),
|
||||
windowBounds: normalizeWindowBounds(value?.windowBounds),
|
||||
}
|
||||
}
|
||||
|
||||
return defaults
|
||||
}
|
||||
|
||||
export function normalizeProfileName(value: unknown, fallback = DEFAULT_PROFILE_NAME): string {
|
||||
if (typeof value !== 'string') return fallback
|
||||
const trimmed = value.trim()
|
||||
return trimmed || fallback
|
||||
}
|
||||
|
||||
export function createDefaultProfile(name = DEFAULT_PROFILE_NAME): Profile {
|
||||
return {
|
||||
name,
|
||||
scopeOrder: [...SCOPE_KINDS],
|
||||
hiddenScopes: SCOPE_KINDS.filter((kind) => !DEFAULT_VISIBLE.includes(kind)),
|
||||
widthWeights: { ...DEFAULT_SCOPE_WIDTH_WEIGHTS },
|
||||
scopeSettings: cloneScopeSettings(DEFAULT_SCOPE_SETTINGS),
|
||||
scopePopouts: createDefaultScopePopouts(),
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeProfile(raw: unknown, fallbackName = DEFAULT_PROFILE_NAME): Profile {
|
||||
const parsed = typeof raw === 'object' && raw !== null
|
||||
? raw as Partial<Profile>
|
||||
: {}
|
||||
|
||||
return {
|
||||
name: normalizeProfileName(parsed.name, fallbackName),
|
||||
scopeOrder: normalizeScopeOrder(parsed.scopeOrder),
|
||||
hiddenScopes: normalizeHiddenScopes(parsed.hiddenScopes),
|
||||
widthWeights: normalizeWidthWeights(parsed.widthWeights),
|
||||
scopeSettings: mergeScopeSettings(parsed.scopeSettings),
|
||||
scopePopouts: normalizeScopePopouts(parsed.scopePopouts),
|
||||
windowBounds: normalizeWindowBounds(parsed.windowBounds),
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeProfileFileScopePopouts(raw: unknown): PrismProfileFileScopePopoutMap {
|
||||
const parsed = typeof raw === 'object' && raw !== null
|
||||
? raw as Partial<Record<ScopeKind, { poppedOut?: unknown }>>
|
||||
: {}
|
||||
|
||||
return SCOPE_KINDS.reduce((acc, kind) => {
|
||||
acc[kind] = { poppedOut: Boolean(parsed[kind]?.poppedOut) }
|
||||
return acc
|
||||
}, {} as PrismProfileFileScopePopoutMap)
|
||||
}
|
||||
|
||||
export function normalizeProfileFile(
|
||||
raw: unknown,
|
||||
fallbackId: string,
|
||||
fallbackName = DEFAULT_PROFILE_NAME,
|
||||
): PrismProfileFileV1 {
|
||||
const parsed = typeof raw === 'object' && raw !== null
|
||||
? raw as Partial<PrismProfileFileV1>
|
||||
: {}
|
||||
|
||||
const id = typeof parsed.id === 'string' && parsed.id.trim()
|
||||
? parsed.id.trim()
|
||||
: fallbackId
|
||||
|
||||
const name = normalizeProfileName(parsed.name, fallbackName)
|
||||
|
||||
return {
|
||||
format: PROFILE_FILE_FORMAT,
|
||||
version: PROFILE_FILE_VERSION,
|
||||
id,
|
||||
name,
|
||||
scopeOrder: normalizeScopeOrder(parsed.scopeOrder),
|
||||
hiddenScopes: normalizeHiddenScopes(parsed.hiddenScopes),
|
||||
widthWeights: normalizeWidthWeights(parsed.widthWeights),
|
||||
scopeSettings: mergeScopeSettings(parsed.scopeSettings),
|
||||
scopePopouts: normalizeProfileFileScopePopouts(parsed.scopePopouts),
|
||||
}
|
||||
}
|
||||
|
||||
export function profileToFileData(id: string, profile: Profile): PrismProfileFileV1 {
|
||||
const normalized = normalizeProfile(profile, profile.name)
|
||||
|
||||
return {
|
||||
format: PROFILE_FILE_FORMAT,
|
||||
version: PROFILE_FILE_VERSION,
|
||||
id,
|
||||
name: normalized.name,
|
||||
scopeOrder: [...normalized.scopeOrder],
|
||||
hiddenScopes: [...normalized.hiddenScopes],
|
||||
widthWeights: { ...normalized.widthWeights },
|
||||
scopeSettings: cloneScopeSettings(normalized.scopeSettings),
|
||||
scopePopouts: SCOPE_KINDS.reduce((acc, kind) => {
|
||||
acc[kind] = { poppedOut: normalized.scopePopouts[kind]?.poppedOut === true }
|
||||
return acc
|
||||
}, {} as PrismProfileFileScopePopoutMap),
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeProfileLocalMetadata(raw: unknown): ProfileLocalMetadata {
|
||||
const parsed = typeof raw === 'object' && raw !== null
|
||||
? raw as Partial<ProfileLocalMetadata>
|
||||
: {}
|
||||
|
||||
const scopePopoutBounds = typeof parsed.scopePopoutBounds === 'object' && parsed.scopePopoutBounds !== null
|
||||
? SCOPE_KINDS.reduce((acc, kind) => {
|
||||
const bounds = normalizeWindowBounds(parsed.scopePopoutBounds?.[kind])
|
||||
if (bounds) {
|
||||
acc[kind] = bounds
|
||||
}
|
||||
return acc
|
||||
}, {} as Partial<Record<ScopeKind, WindowBounds>>)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
windowBounds: normalizeWindowBounds(parsed.windowBounds),
|
||||
scopePopoutBounds: scopePopoutBounds && Object.keys(scopePopoutBounds).length > 0
|
||||
? scopePopoutBounds
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function createEmptyProfileLocalState(): PrismProfileLocalStateV1 {
|
||||
return {
|
||||
format: PROFILE_LOCAL_STATE_FORMAT,
|
||||
version: PROFILE_LOCAL_STATE_VERSION,
|
||||
migrationVersion: 0,
|
||||
activeProfileId: null,
|
||||
profiles: {},
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeProfileLocalState(raw: unknown): PrismProfileLocalStateV1 {
|
||||
const parsed = typeof raw === 'object' && raw !== null
|
||||
? raw as Partial<PrismProfileLocalStateV1>
|
||||
: {}
|
||||
|
||||
const profiles = typeof parsed.profiles === 'object' && parsed.profiles !== null
|
||||
? Object.entries(parsed.profiles).reduce((acc, [id, metadata]) => {
|
||||
if (!id.trim()) return acc
|
||||
acc[id] = normalizeProfileLocalMetadata(metadata)
|
||||
return acc
|
||||
}, {} as Record<string, ProfileLocalMetadata>)
|
||||
: {}
|
||||
|
||||
return {
|
||||
format: PROFILE_LOCAL_STATE_FORMAT,
|
||||
version: PROFILE_LOCAL_STATE_VERSION,
|
||||
migrationVersion: typeof parsed.migrationVersion === 'number' && Number.isFinite(parsed.migrationVersion)
|
||||
? Math.max(0, Math.trunc(parsed.migrationVersion))
|
||||
: 0,
|
||||
activeProfileId: typeof parsed.activeProfileId === 'string' ? parsed.activeProfileId : null,
|
||||
profiles,
|
||||
}
|
||||
}
|
||||
|
||||
export function extractLocalProfileMetadata(profile: Profile): ProfileLocalMetadata {
|
||||
const normalized = normalizeProfile(profile, profile.name)
|
||||
const scopePopoutBounds = SCOPE_KINDS.reduce((acc, kind) => {
|
||||
const bounds = normalized.scopePopouts[kind]?.windowBounds
|
||||
if (bounds) {
|
||||
acc[kind] = bounds
|
||||
}
|
||||
return acc
|
||||
}, {} as Partial<Record<ScopeKind, WindowBounds>>)
|
||||
|
||||
return {
|
||||
windowBounds: normalized.windowBounds,
|
||||
scopePopoutBounds: Object.keys(scopePopoutBounds).length > 0 ? scopePopoutBounds : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function profileFileToProfile(
|
||||
file: PrismProfileFileV1,
|
||||
localMetadata?: ProfileLocalMetadata,
|
||||
): Profile {
|
||||
const metadata = normalizeProfileLocalMetadata(localMetadata)
|
||||
|
||||
return {
|
||||
name: normalizeProfileName(file.name, DEFAULT_PROFILE_NAME),
|
||||
scopeOrder: normalizeScopeOrder(file.scopeOrder),
|
||||
hiddenScopes: normalizeHiddenScopes(file.hiddenScopes),
|
||||
widthWeights: normalizeWidthWeights(file.widthWeights),
|
||||
scopeSettings: mergeScopeSettings(file.scopeSettings),
|
||||
scopePopouts: SCOPE_KINDS.reduce((acc, kind) => {
|
||||
acc[kind] = {
|
||||
poppedOut: Boolean(file.scopePopouts[kind]?.poppedOut),
|
||||
windowBounds: metadata.scopePopoutBounds?.[kind],
|
||||
}
|
||||
return acc
|
||||
}, {} as ScopePopoutStateMap),
|
||||
windowBounds: metadata.windowBounds,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { ScopePopoutStateMap, WindowBounds } from './popout'
|
||||
import type { ScopeKind } from './scope'
|
||||
import type { ScopeSettings } from './settings'
|
||||
|
||||
export const PROFILE_FILE_FORMAT = 'prism-profile'
|
||||
export const PROFILE_FILE_VERSION = 1
|
||||
export const PROFILE_LOCAL_STATE_FORMAT = 'prism-profile-local'
|
||||
export const PROFILE_LOCAL_STATE_VERSION = 1
|
||||
export const LEGACY_PROFILE_MIGRATION_VERSION = 1
|
||||
export const DEFAULT_PROFILE_ID = 'profile_default'
|
||||
export const DEFAULT_PROFILE_NAME = 'Default'
|
||||
|
||||
export interface Profile {
|
||||
name: string
|
||||
scopeOrder: ScopeKind[]
|
||||
hiddenScopes: ScopeKind[]
|
||||
widthWeights: Record<ScopeKind, number>
|
||||
scopeSettings: ScopeSettings
|
||||
scopePopouts: ScopePopoutStateMap
|
||||
windowBounds?: WindowBounds
|
||||
}
|
||||
|
||||
export interface PrismProfileFileScopePopoutState {
|
||||
poppedOut: boolean
|
||||
}
|
||||
|
||||
export type PrismProfileFileScopePopoutMap = Record<ScopeKind, PrismProfileFileScopePopoutState>
|
||||
|
||||
export interface PrismProfileFileV1 {
|
||||
format: typeof PROFILE_FILE_FORMAT
|
||||
version: typeof PROFILE_FILE_VERSION
|
||||
id: string
|
||||
name: string
|
||||
scopeOrder: ScopeKind[]
|
||||
hiddenScopes: ScopeKind[]
|
||||
widthWeights: Record<ScopeKind, number>
|
||||
scopeSettings: ScopeSettings
|
||||
scopePopouts: PrismProfileFileScopePopoutMap
|
||||
}
|
||||
|
||||
export interface ProfileLocalMetadata {
|
||||
windowBounds?: WindowBounds
|
||||
scopePopoutBounds?: Partial<Record<ScopeKind, WindowBounds>>
|
||||
}
|
||||
|
||||
export interface PrismProfileLocalStateV1 {
|
||||
format: typeof PROFILE_LOCAL_STATE_FORMAT
|
||||
version: typeof PROFILE_LOCAL_STATE_VERSION
|
||||
migrationVersion: number
|
||||
activeProfileId: string | null
|
||||
profiles: Record<string, ProfileLocalMetadata>
|
||||
}
|
||||
|
||||
export interface ProfileSummary {
|
||||
id: string
|
||||
name: string
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
export interface ProfileLibrarySnapshot {
|
||||
profiles: Record<string, Profile>
|
||||
activeProfileId: string | null
|
||||
}
|
||||
|
||||
export interface LegacyProfileMigrationPayload {
|
||||
profiles: Record<string, Profile>
|
||||
activeProfileId: string | null
|
||||
}
|
||||
|
||||
export interface LegacyProfileMigrationResult {
|
||||
didMigrate: boolean
|
||||
snapshot: ProfileLibrarySnapshot
|
||||
}
|
||||
Reference in New Issue
Block a user