make prsm file format

This commit is contained in:
Boof2015
2026-03-28 12:32:06 -04:00
parent 39e15575ba
commit 60e8b921c0
12 changed files with 1818 additions and 352 deletions
+216 -11
View File
@@ -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()
+526
View File
@@ -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.
}
}
}
+30
View File
@@ -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
View File
@@ -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
+107 -23
View File
@@ -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>
+18
View File
@@ -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
+235 -304
View File
@@ -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),
})
}
+331
View File
@@ -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,
}
}
+73
View File
@@ -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
}