diff --git a/package.json b/package.json index ebd0431..895e895 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "preview": "electron-vite preview", "typecheck": "tsc --noEmit", "test:audio-router": "node scripts/run-audio-router-tests.mjs", + "test:profiles": "node scripts/run-profile-library-tests.mjs", "test:renderer-helpers": "node scripts/run-renderer-helper-tests.mjs", "build:native": "cd native && node-gyp rebuild", "rebuild:native": "node -e \"const e=require('electron/package.json').version;const a=process.arch;const{execSync}=require('child_process');execSync('node-gyp rebuild --target='+e+' --arch='+a+' --dist-url=https://electronjs.org/headers',{stdio:'inherit',cwd:'native'})\"", @@ -57,6 +58,14 @@ "directories": { "output": "dist" }, + "fileAssociations": [ + { + "ext": "prsm", + "name": "Prism Profile", + "description": "Prism shareable profile", + "role": "Editor" + } + ], "files": [ "out/**/*", "native/build/Release/*.node" diff --git a/scripts/run-profile-library-tests.mjs b/scripts/run-profile-library-tests.mjs new file mode 100644 index 0000000..859629d --- /dev/null +++ b/scripts/run-profile-library-tests.mjs @@ -0,0 +1,44 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawn } from 'node:child_process' +import { build } from 'esbuild' + +const rootDir = dirname(dirname(fileURLToPath(import.meta.url))) +const tempDir = await mkdtemp(join(tmpdir(), 'prism-profile-library-tests-')) +const bundledTestPath = join(tempDir, 'profile-library.test.mjs') +const entryPoint = join(rootDir, 'test', 'profile-library.test.ts') + +let exitCode = 1 + +try { + await build({ + entryPoints: [entryPoint], + outfile: bundledTestPath, + bundle: true, + platform: 'node', + format: 'esm', + target: 'node23', + sourcemap: 'inline', + }) + + exitCode = await new Promise((resolve) => { + const child = spawn(process.execPath, ['--test', bundledTestPath], { + stdio: 'inherit', + cwd: rootDir, + }) + + child.on('exit', (code) => { + resolve(code ?? 1) + }) + + child.on('error', () => { + resolve(1) + }) + }) +} finally { + await rm(tempDir, { recursive: true, force: true }) +} + +process.exit(exitCode) diff --git a/src/main/index.ts b/src/main/index.ts index dec453e..5b140ca 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 | null = null let moveStartCursor: { x: number; y: number } | null = null let moveStartPosition: number[] | null = null +let mainWindowBoundsTimer: ReturnType | null = null const scopePopoutWindows = new Map() const scopePopoutCloseAllowed = new Set() const popoutBoundsTimers = new Map>() const windowSettingsHeights = new Map() const windowSettingsBottomAnchors = new Map() +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 { + 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() diff --git a/src/main/profileLibrary.ts b/src/main/profileLibrary.ts new file mode 100644 index 0000000..70324a2 --- /dev/null +++ b/src/main/profileLibrary.ts @@ -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 { + const { entries, localState } = await this.loadLibrary() + return this.buildSnapshot(entries, localState) + } + + async saveNewProfile(name: string, profile: Profile): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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) + + 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 { + 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() + + 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 { + 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 + 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 { + try { + const raw = await readFile(this.localStatePath, 'utf8') + return normalizeProfileLocalState(JSON.parse(raw) as unknown) + } catch { + return createEmptyProfileLocalState() + } + } + + private async writeLocalState(state: PrismProfileLocalStateV1): Promise { + 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 { + 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 { + 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 { + 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) + + 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 { + try { + await access(targetPath) + return true + } catch { + return false + } + } + + private async unlinkIfExists(targetPath: string): Promise { + try { + await unlink(targetPath) + } catch { + // Ignore missing files. + } + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index d009a29..851cce2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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, + saveNewProfile: (name: string, profile: Profile) => ipcRenderer.invoke('profiles:save-new', name, profile) as Promise, + overwriteProfile: (id: string, profile: Profile) => ipcRenderer.invoke('profiles:overwrite', id, profile) as Promise, + loadProfile: (id: string) => ipcRenderer.invoke('profiles:load', id) as Promise, + deleteProfile: (id: string) => ipcRenderer.invoke('profiles:delete', id) as Promise, + renameProfile: (id: string, name: string) => ipcRenderer.invoke('profiles:rename', id, name) as Promise, + importProfileDialog: () => ipcRenderer.invoke('profiles:import-dialog') as Promise, + revealProfilesFolder: () => ipcRenderer.invoke('profiles:reveal-folder') as Promise, + migrateLegacyProfiles: (payload: LegacyProfileMigrationPayload) => ipcRenderer.invoke('profiles:migrate-legacy', payload) as Promise, 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) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 4c57c99..3f4257c 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -16,22 +16,10 @@ export default function App(): JSX.Element { const [settingsPanelHeight, setSettingsPanelHeight] = useState(0) const [bottomBarHeight, setBottomBarHeight] = useState(0) const hideTimeoutRef = useRef | 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 diff --git a/src/renderer/components/Toolbar.tsx b/src/renderer/components/Toolbar.tsx index 45a5e40..843774a 100644 --- a/src/renderer/components/Toolbar.tsx +++ b/src/renderer/components/Toolbar.tsx @@ -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" > - {activeProfile?.name ?? 'Presets'} + {activeProfile?.name ?? 'Profiles'} diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts index 8eab83a..71b6c9a 100644 --- a/src/renderer/env.d.ts +++ b/src/renderer/env.d.ts @@ -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 getDesktopSources: () => Promise<{ id: string; name: string }[]> getCaptureBackendSupport: () => Promise + getProfileSnapshot: () => Promise + saveNewProfile: (name: string, profile: Profile) => Promise + overwriteProfile: (id: string, profile: Profile) => Promise + loadProfile: (id: string) => Promise + deleteProfile: (id: string) => Promise + renameProfile: (id: string, name: string) => Promise + importProfileDialog: () => Promise + revealProfilesFolder: () => Promise + migrateLegacyProfiles: (payload: LegacyProfileMigrationPayload) => Promise 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 diff --git a/src/renderer/stores/settingsStore.ts b/src/renderer/stores/settingsStore.ts index d724490..1146a72 100644 --- a/src/renderer/stores/settingsStore.ts +++ b/src/renderer/stores/settingsStore.ts @@ -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 scopeSettings: ScopeSettings scopePopouts: ScopePopoutStateMap - windowBounds?: WindowBounds } -function loadProfiles(): Record { - try { - const raw = localStorage.getItem(PROFILES_STORAGE_KEY) - if (raw) return JSON.parse(raw) - } catch { /* ignore */ } - return {} -} - -function saveProfiles(profiles: Record): 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 widthWeights: Record scopeSettings: ScopeSettings scopePopouts: ScopePopoutStateMap +} - // Derived +interface SettingsState extends WorkingSettingsState { visibleScopes: () => ScopeKind[] - - // Profiles profiles: Record activeProfileId: string | null - - // Actions + initializeProfiles: () => Promise + 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 + saveProfileAs: (name: string) => Promise + updateActiveProfile: () => Promise + loadProfile: (id: string) => Promise + deleteProfile: (id: string) => Promise + renameProfile: (id: string, name: string) => Promise + importProfileFromDialog: () => Promise + showProfilesFolder: () => Promise } -function loadFromStorage(): Partial<{ - scopeOrder: ScopeKind[] - hiddenScopes: ScopeKind[] - widthWeights: Record - scopeSettings: ScopeSettings - scopePopouts: ScopePopoutStateMap -}> { +function canUseElectronAPI(): boolean { + return typeof window !== 'undefined' && typeof window.electronAPI !== 'undefined' +} + +function loadFromStorage(): Partial { try { const raw = localStorage.getItem(STORAGE_KEY) - if (raw) return JSON.parse(raw) + if (raw) return JSON.parse(raw) as Partial } 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() - 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 + : {} + + 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 - : {} +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(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) => 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 - 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>> - : {} +async function buildProfileFromState(state: SettingsState, name: string): Promise { + 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 = { - 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): Record { - 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((set, get) => ({ +const initialWorkingState: WorkingSettingsState = { scopeOrder: normalizeScopeOrder(stored.scopeOrder), - hiddenScopes: new Set( - normalizeHiddenScopes(stored.hiddenScopes) - ), - widthWeights: stored.widthWeights ?? { ...defaultWeights }, + hiddenScopes: new Set(normalizeHiddenScopes(stored.hiddenScopes)), + widthWeights: normalizeWidthWeights(stored.widthWeights), scopeSettings: mergeScopeSettings(stored.scopeSettings), scopePopouts: normalizeScopePopouts(stored.scopePopouts), - profiles: initialProfiles, - activeProfileId: initialActiveProfileId, +} + +export const useSettingsStore = create((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((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((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: (kind: K, settings: Partial) => { 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(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(normalizeHiddenScopes(profile.hiddenScopes)), - widthWeights: profile.widthWeights ?? { ...defaultWeights }, - scopeSettings: mergeScopeSettings(profile.scopeSettings), - scopePopouts: normalizeScopePopouts(profile.scopePopouts), - }) -} diff --git a/src/shared/profileState.ts b/src/shared/profileState.ts new file mode 100644 index 0000000..2334019 --- /dev/null +++ b/src/shared/profileState.ts @@ -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 = { + 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 + 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() + 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 { + const parsed = typeof raw === 'object' && raw !== null + ? raw as Partial> + : {} + + 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) +} + +export function mergeScopeSettings(raw: unknown): ScopeSettings { + const parsed = typeof raw === 'object' && raw !== null + ? raw as Partial + : {} + + 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>> + : {} + + 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 + : {} + + 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> + : {} + + 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 + : {} + + 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 + : {} + + 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>) + : 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 + : {} + + 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) + : {} + + 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>) + + 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, + } +} diff --git a/src/types/profile.ts b/src/types/profile.ts new file mode 100644 index 0000000..9f59a9a --- /dev/null +++ b/src/types/profile.ts @@ -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 + scopeSettings: ScopeSettings + scopePopouts: ScopePopoutStateMap + windowBounds?: WindowBounds +} + +export interface PrismProfileFileScopePopoutState { + poppedOut: boolean +} + +export type PrismProfileFileScopePopoutMap = Record + +export interface PrismProfileFileV1 { + format: typeof PROFILE_FILE_FORMAT + version: typeof PROFILE_FILE_VERSION + id: string + name: string + scopeOrder: ScopeKind[] + hiddenScopes: ScopeKind[] + widthWeights: Record + scopeSettings: ScopeSettings + scopePopouts: PrismProfileFileScopePopoutMap +} + +export interface ProfileLocalMetadata { + windowBounds?: WindowBounds + scopePopoutBounds?: Partial> +} + +export interface PrismProfileLocalStateV1 { + format: typeof PROFILE_LOCAL_STATE_FORMAT + version: typeof PROFILE_LOCAL_STATE_VERSION + migrationVersion: number + activeProfileId: string | null + profiles: Record +} + +export interface ProfileSummary { + id: string + name: string + isDefault: boolean +} + +export interface ProfileLibrarySnapshot { + profiles: Record + activeProfileId: string | null +} + +export interface LegacyProfileMigrationPayload { + profiles: Record + activeProfileId: string | null +} + +export interface LegacyProfileMigrationResult { + didMigrate: boolean + snapshot: ProfileLibrarySnapshot +} diff --git a/test/profile-library.test.ts b/test/profile-library.test.ts new file mode 100644 index 0000000..fd30422 --- /dev/null +++ b/test/profile-library.test.ts @@ -0,0 +1,217 @@ +import assert from 'node:assert/strict' +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { FileBackedProfileLibrary } from '../src/main/profileLibrary' +import { + createDefaultProfile, + extractLocalProfileMetadata, + profileFileToProfile, + profileToFileData, +} from '../src/shared/profileState' +import { + DEFAULT_PROFILE_ID, + DEFAULT_PROFILE_NAME, + PROFILE_FILE_FORMAT, + PROFILE_FILE_VERSION, + type Profile, +} from '../src/types/profile' + +async function createHarness(): Promise<{ + cleanup: () => Promise + library: FileBackedProfileLibrary + localStatePath: string + profilesDir: string + rootDir: string +}> { + const rootDir = await mkdtemp(join(tmpdir(), 'prism-profile-library-')) + const profilesDir = join(rootDir, 'Documents', 'Prism Profiles') + const localStatePath = join(rootDir, 'userData', 'profile-state.json') + + return { + cleanup: () => rm(rootDir, { recursive: true, force: true }), + library: new FileBackedProfileLibrary(profilesDir, localStatePath), + localStatePath, + profilesDir, + rootDir, + } +} + +function createProfile(name: string): Profile { + const profile = createDefaultProfile(name) + profile.scopePopouts.spectrum = { + poppedOut: true, + windowBounds: { x: 120, y: 40, width: 420, height: 240 }, + } + profile.windowBounds = { x: 10, y: 20, width: 840, height: 180 } + profile.scopeSettings.spectrogram.colorScheme = 'mono' + return profile +} + +test('profile file serialization excludes geometry and round-trips with local metadata', () => { + const profile = createProfile('Shared') + const file = profileToFileData('profile_shared', profile) + + assert.equal(file.format, PROFILE_FILE_FORMAT) + assert.equal(file.version, PROFILE_FILE_VERSION) + assert.equal(JSON.stringify(file).includes('windowBounds'), false) + assert.deepEqual(file.scopePopouts.spectrum, { poppedOut: true }) + + const restored = profileFileToProfile(file, extractLocalProfileMetadata(profile)) + assert.deepEqual(restored.windowBounds, profile.windowBounds) + assert.deepEqual(restored.scopePopouts.spectrum.windowBounds, profile.scopePopouts.spectrum.windowBounds) + assert.equal(restored.scopeSettings.spectrogram.colorScheme, 'mono') +}) + +test('library saves, renames, deletes, and resolves filename collisions', async () => { + const harness = await createHarness() + + try { + const firstSnapshot = await harness.library.saveNewProfile('Mix Bus', createProfile('First')) + const firstId = Object.keys(firstSnapshot.profiles).find((id) => id !== DEFAULT_PROFILE_ID) + assert.ok(firstId) + assert.equal(firstSnapshot.activeProfileId, firstId) + + const secondSnapshot = await harness.library.saveNewProfile('Mix Bus', createProfile('Second')) + const userProfileIds = Object.keys(secondSnapshot.profiles).filter((id) => id !== DEFAULT_PROFILE_ID) + const secondId = userProfileIds.find((id) => id !== firstId) + assert.ok(secondId) + + let fileNames = (await readdir(harness.profilesDir)).sort() + assert.deepEqual(fileNames, ['Default.prsm', 'Mix Bus (2).prsm', 'Mix Bus.prsm']) + + const renamedSnapshot = await harness.library.renameProfile(secondId, 'Vocals') + assert.equal(renamedSnapshot.profiles[secondId].name, 'Vocals') + + fileNames = (await readdir(harness.profilesDir)).sort() + assert.deepEqual(fileNames, ['Default.prsm', 'Mix Bus.prsm', 'Vocals.prsm']) + + await assert.rejects(() => harness.library.deleteProfile(DEFAULT_PROFILE_ID)) + + const deletedSnapshot = await harness.library.deleteProfile(secondId) + assert.equal(deletedSnapshot.profiles[secondId], undefined) + fileNames = (await readdir(harness.profilesDir)).sort() + assert.deepEqual(fileNames, ['Default.prsm', 'Mix Bus.prsm']) + } finally { + await harness.cleanup() + } +}) + +test('importing the same embedded id replaces the managed profile instead of duplicating it', async () => { + const harness = await createHarness() + + try { + const externalPath = join(harness.rootDir, 'shared.prsm') + const initialImport = profileToFileData('profile_shared', createDefaultProfile('Shared')) + await writeFile(externalPath, `${JSON.stringify(initialImport, null, 2)}\n`, 'utf8') + + const firstSnapshot = await harness.library.importProfileFromPath(externalPath) + assert.equal(firstSnapshot.activeProfileId, 'profile_shared') + assert.equal(Object.keys(firstSnapshot.profiles).length, 2) + + const updatedImport = { + ...initialImport, + name: 'Shared Updated', + scopeSettings: { + ...initialImport.scopeSettings, + spectrogram: { + ...initialImport.scopeSettings.spectrogram, + colorScheme: 'mono' as const, + }, + }, + } + const updatedExternalPath = join(harness.rootDir, 'shared-updated.prsm') + await writeFile(updatedExternalPath, `${JSON.stringify(updatedImport, null, 2)}\n`, 'utf8') + + const secondSnapshot = await harness.library.importProfileFromPath(updatedExternalPath) + assert.equal(secondSnapshot.activeProfileId, 'profile_shared') + assert.equal(Object.keys(secondSnapshot.profiles).length, 2) + assert.equal(secondSnapshot.profiles.profile_shared.name, 'Shared Updated') + assert.equal(secondSnapshot.profiles.profile_shared.scopeSettings.spectrogram.colorScheme, 'mono') + + const fileNames = (await readdir(harness.profilesDir)).sort() + assert.deepEqual(fileNames, ['Default.prsm', 'Shared Updated.prsm']) + } finally { + await harness.cleanup() + } +}) + +test('partial files normalize, unsupported versions fail, and import does not change active profile on failure', async () => { + const harness = await createHarness() + + try { + const initialSnapshot = await harness.library.saveNewProfile('Current', createProfile('Current')) + const activeBeforeFailure = initialSnapshot.activeProfileId + + const partialPath = join(harness.rootDir, 'partial.prsm') + await writeFile(partialPath, `${JSON.stringify({ + format: PROFILE_FILE_FORMAT, + version: PROFILE_FILE_VERSION, + id: 'profile_partial', + name: 'Partial', + scopeOrder: ['spectrogram'], + scopePopouts: { spectrogram: { poppedOut: true } }, + }, null, 2)}\n`, 'utf8') + + const partialSnapshot = await harness.library.importProfileFromPath(partialPath) + assert.equal(partialSnapshot.activeProfileId, 'profile_partial') + assert.equal(partialSnapshot.profiles.profile_partial.scopeSettings.spectrogram.colorScheme, 'heat') + assert.equal(partialSnapshot.profiles.profile_partial.scopePopouts.spectrogram.poppedOut, true) + assert.equal(partialSnapshot.profiles.profile_partial.widthWeights.spectrum, 1) + + const badVersionPath = join(harness.rootDir, 'unsupported.prsm') + await writeFile(badVersionPath, `${JSON.stringify({ + format: PROFILE_FILE_FORMAT, + version: 99, + id: 'profile_bad', + name: 'Unsupported', + }, null, 2)}\n`, 'utf8') + + await assert.rejects(() => harness.library.importProfileFromPath(badVersionPath)) + + const snapshotAfterFailure = await harness.library.getSnapshot() + assert.equal(snapshotAfterFailure.activeProfileId, 'profile_partial') + assert.notEqual(snapshotAfterFailure.activeProfileId, activeBeforeFailure) + assert.equal(snapshotAfterFailure.profiles.profile_bad, undefined) + } finally { + await harness.cleanup() + } +}) + +test('legacy migration writes managed files, preserves active profile, and stores local-only geometry', async () => { + const harness = await createHarness() + + try { + const legacyProfile = createProfile('Legacy Custom') + const migration = await harness.library.migrateLegacyProfiles({ + activeProfileId: 'profile_custom', + profiles: { + [DEFAULT_PROFILE_ID]: createDefaultProfile(DEFAULT_PROFILE_NAME), + profile_custom: legacyProfile, + }, + }) + + assert.equal(migration.didMigrate, true) + assert.equal(migration.snapshot.activeProfileId, 'profile_custom') + assert.equal(migration.snapshot.profiles.profile_custom.name, 'Legacy Custom') + assert.deepEqual(migration.snapshot.profiles.profile_custom.windowBounds, legacyProfile.windowBounds) + + const localState = JSON.parse(await readFile(harness.localStatePath, 'utf8')) as { + activeProfileId: string | null + migrationVersion: number + profiles: Record + } + assert.equal(localState.activeProfileId, 'profile_custom') + assert.equal(localState.migrationVersion, 1) + assert.deepEqual(localState.profiles.profile_custom.windowBounds, legacyProfile.windowBounds) + + const secondMigration = await harness.library.migrateLegacyProfiles({ + activeProfileId: null, + profiles: {}, + }) + assert.equal(secondMigration.didMigrate, false) + } finally { + await harness.cleanup() + } +})