mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-12 05:10:51 +02:00
530 lines
18 KiB
TypeScript
530 lines
18 KiB
TypeScript
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 PrismProfileFile,
|
|
type ProfileLibrarySnapshot,
|
|
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,
|
|
private readonly resolveDefaultThemeId?: () => Promise<string | null>,
|
|
) {}
|
|
|
|
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 defaultThemeId = await this.resolveDefaultThemeId?.() ?? null
|
|
const defaultProfile = createDefaultProfile(DEFAULT_PROFILE_NAME)
|
|
defaultProfile.themeId = defaultThemeId
|
|
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<PrismProfileFile> {
|
|
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<PrismProfileFile>
|
|
if (candidate.format !== PROFILE_FILE_FORMAT) {
|
|
throw new Error(`Unsupported profile format in ${basename(filePath)}.`)
|
|
}
|
|
|
|
if (candidate.version !== 1 && 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.
|
|
}
|
|
}
|
|
}
|