import { readFile, realpath, stat } from 'node:fs/promises' import { delimiter, extname, isAbsolute, join } from 'node:path' import { z } from 'zod' import { applicationSettingsSchema, applicationSettingsUpdateSchema, applicationNavigationSchema, builtInApplicationIds, defaultApplicationNavigation, defaultSupervisionTimeoutSeconds, defaultSupervisorModelConcurrency, type ApplicationSettings } from '../shared/application-settings-contracts' import { defaultLocalToolEnvironmentSettings, localToolExecutablePathSchema, type LocalToolEnvironmentSettings } from '../shared/local-tool-environment-contracts' import { inspectLocalToolExecutable } from './local-tool-environment/local-tool-environment' import { releaseVersionSchema } from '../shared/release-notes-contracts' import type { SettingsWarning } from '../shared/settings-warning-contracts' import { assertSupportedSettingsVersion, isolateCorruptSettingsFile, isMissingFileError, UnsupportedSettingsVersionError, writeJsonFileAtomically } from './settings-file-utils' export { applicationSettingsSchema, applicationSettingsUpdateSchema } from '../shared/application-settings-contracts' export type { ApplicationSettings } from '../shared/application-settings-contracts' const CURRENT_SETTINGS_VERSION = 12 const legacyStoredApplicationSettingsSchema = z .object({ version: z.union([z.literal(1), z.literal(2)]), checkUpdatesOnStartup: z.boolean() }) .strict() const versionTwoStoredApplicationSettingsSchema = z .object({ version: z.literal(2), checkUpdatesOnStartup: z.boolean(), magicNotesEnabled: z.boolean() }) .strict() const versionThreeStoredApplicationSettingsSchema = z .object({ version: z.literal(3), checkUpdatesOnStartup: z.boolean(), magicNotesEnabled: z.boolean(), magicNoteCommentMode: applicationSettingsSchema.shape.magicNoteCommentMode }) .strict() const versionFourStoredApplicationSettingsSchema = applicationSettingsSchema .omit({ updateSource: true, modelDownloadSource: true, localToolEnvironment: true, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: true, magicNotesShowIncompleteTodoCount: true }) .extend({ version: z.literal(4) }) .strict() const versionFiveStoredApplicationSettingsSchema = applicationSettingsSchema .omit({ updateSource: true, modelDownloadSource: true, localToolEnvironment: true, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: true, magicNotesShowIncompleteTodoCount: true }) .extend({ version: z.literal(5), lastSeenReleaseNotesVersion: releaseVersionSchema.nullable() }) .strict() const versionSixStoredApplicationSettingsSchema = applicationSettingsSchema .omit({ modelDownloadSource: true, localToolEnvironment: true, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: true, magicNotesShowIncompleteTodoCount: true }) .extend({ version: z.literal(6), lastSeenReleaseNotesVersion: releaseVersionSchema.nullable() }) .strict() const versionSevenStoredApplicationSettingsSchema = applicationSettingsSchema .omit({ localToolEnvironment: true, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: true, magicNotesShowIncompleteTodoCount: true }) .extend({ version: z.literal(7), lastSeenReleaseNotesVersion: releaseVersionSchema.nullable() }) .strict() const versionEightStoredApplicationSettingsSchema = applicationSettingsSchema .omit({ localToolEnvironment: true, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: true }) .extend({ version: z.literal(8), lastSeenReleaseNotesVersion: releaseVersionSchema.nullable() }) .strict() const versionNineStoredApplicationSettingsSchema = applicationSettingsSchema .omit({ localToolEnvironment: true, conversationHtmlRenderingEnabled: true }) .extend({ version: z.literal(9), lastSeenReleaseNotesVersion: releaseVersionSchema.nullable() }) .strict() const versionTenStoredApplicationSettingsSchema = applicationSettingsSchema .omit({ conversationHtmlRenderingEnabled: true }) .extend({ version: z.literal(10), lastSeenReleaseNotesVersion: releaseVersionSchema.nullable() }) .strict() const storedApplicationSettingsSchema = applicationSettingsSchema .extend({ // Stored partial orders are upgraded here; public settings writes remain strict. applicationNavigation: applicationNavigationSchema.extend({ order: z.array(z.enum(builtInApplicationIds)) .refine(ids => new Set(ids).size === ids.length, 'Duplicate application') .default([]) .transform(ids => [ ...(['knowledge', 'heartbeat'] as const).filter(id => !ids.includes(id)), ...ids, ...defaultApplicationNavigation.order.filter(id => id !== 'knowledge' && id !== 'heartbeat' && !ids.includes(id)) ]), pinned: applicationNavigationSchema.shape.pinned.partial() .default({}) .transform(pinned => ({ ...defaultApplicationNavigation.pinned, ...pinned })) }).default(defaultApplicationNavigation), version: z.literal(CURRENT_SETTINGS_VERSION), lastSeenReleaseNotesVersion: releaseVersionSchema.nullable() }) .strict() const versionElevenStoredApplicationSettingsSchema = storedApplicationSettingsSchema.extend({ version: z.literal(11) }) type StoredApplicationSettings = z.infer export type LegacyLocalToolEnvironmentPaths = { nodeExecutablePath?: string pythonExecutablePath?: string } export type LegacyLocalToolEnvironmentResolver = () => Promise async function resolveExecutable( kind: 'node' | 'python', binaryNames: readonly string[] ): Promise { const extensions = process.platform === 'win32' ? (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD') .split(';') .filter((extension) => /^\.[A-Za-z0-9]+$/u.test(extension)) : [''] const directories = ( process.env.PATH ?? process.env.Path ?? process.env.path ?? '' ) .split(delimiter) .map((directory) => directory.trim()) .filter(isAbsolute) for (const directory of directories) { for (const binaryName of binaryNames) { const names = process.platform === 'win32' && !extname(binaryName) ? extensions.map((extension) => `${binaryName}${extension}`) : [binaryName] for (const name of names) { try { const executablePath = await realpath(join(directory, name)) const metadata = await stat(executablePath) if ( metadata.isFile() && localToolExecutablePathSchema.safeParse(executablePath).success ) { const candidate = await inspectLocalToolExecutable( kind, executablePath, { baseEnvironment: process.env } ) if (candidate) { return candidate.executablePath } } } catch { // Continue through PATH candidates. } } } } return undefined } export const resolveLegacyLocalToolEnvironmentPaths: LegacyLocalToolEnvironmentResolver = async () => { const [nodeExecutablePath, pythonExecutablePath] = await Promise.all([ resolveExecutable('node', ['node']), resolveExecutable( 'python', process.platform === 'win32' ? ['python', 'python3'] : ['python3', 'python'] ) ]) return { ...(nodeExecutablePath ? { nodeExecutablePath } : {}), ...(pythonExecutablePath ? { pythonExecutablePath } : {}) } } export const defaultApplicationSettings: ApplicationSettings = { checkUpdatesOnStartup: true, desktopNotificationsEnabled: true, transparentFrostedEffectEnabled: true, updateSource: 'github', modelDownloadSource: 'modelscope', localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false, applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false, magicNotesEnabled: true, heartbeatEnabled: false, heartbeatReportTimeoutSeconds: defaultSupervisionTimeoutSeconds, supervisorOrganizeTimeoutSeconds: defaultSupervisionTimeoutSeconds, supervisorModelConcurrency: defaultSupervisorModelConcurrency, supervisorModelProfileId: null, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate', magicNoteCommentFormat: 'combined', magicNoteCanvasPageCount: 1 } export class ApplicationSettingsStore { private settings?: StoredApplicationSettings private settingsLoad?: Promise private warnings: SettingsWarning[] = [] private updateQueue: Promise = Promise.resolve() private readonly listeners = new Set<(settings: ApplicationSettings) => void>() constructor( private readonly filePath: string, private readonly legacyLocalToolEnvironmentResolver: LegacyLocalToolEnvironmentResolver = resolveLegacyLocalToolEnvironmentPaths ) {} onChanged(listener: (settings: ApplicationSettings) => void): () => void { this.listeners.add(listener) return () => { this.listeners.delete(listener) } } private async migrateLegacy( settings: Omit< StoredApplicationSettings, | 'conversationHtmlRenderingEnabled' | 'desktopNotificationsEnabled' | 'transparentFrostedEffectEnabled' | 'localToolEnvironment' | 'version' | 'applicationNavigation' | 'localInferenceEnabled' | 'deviceSharingEnabled' | 'heartbeatEnabled' | 'magicNoteCanvasPageCount' > & { conversationHtmlRenderingEnabled?: boolean desktopNotificationsEnabled?: boolean magicNoteCanvasPageCount?: number version: number } ): Promise { const resolved = await this.legacyLocalToolEnvironmentResolver() const selection = ( executablePath: string | undefined ): LocalToolEnvironmentSettings['node'] => executablePath && localToolExecutablePathSchema.safeParse(executablePath).success ? { source: 'custom', executablePath } : { source: 'managed' } const next: StoredApplicationSettings = { ...defaultApplicationSettings, ...settings, conversationHtmlRenderingEnabled: settings.conversationHtmlRenderingEnabled ?? true, version: CURRENT_SETTINGS_VERSION, localToolEnvironment: { node: selection(resolved.nodeExecutablePath), python: selection(resolved.pythonExecutablePath), artifactDownloadSource: 'native' } } await this.persist(next) return next } private async isolateCorruptFile(): Promise { await isolateCorruptSettingsFile( this.filePath, 'Application settings are corrupt and could not be isolated' ) } private async loadStored(): Promise { if (this.settings) { return this.settings } if (!this.settingsLoad) { this.settingsLoad = this.readStored().finally(() => { this.settingsLoad = undefined }) } return this.settingsLoad } private async readStored(): Promise { try { const contents = await readFile(this.filePath, 'utf8') let parsed: unknown try { parsed = JSON.parse(contents) as unknown } catch { await this.isolateCorruptFile() this.warnings = [{ code: 'application-settings-recovered' }] this.settings = { version: CURRENT_SETTINGS_VERSION, lastSeenReleaseNotesVersion: null, ...defaultApplicationSettings } return this.settings } assertSupportedSettingsVersion( parsed, CURRENT_SETTINGS_VERSION, (version) => `当前 GoodBuddy 不支持应用设置版本 ${version},请升级应用后重试` ) const result = storedApplicationSettingsSchema.safeParse(parsed) if (!result.success) { const versionElevenResult = versionElevenStoredApplicationSettingsSchema.safeParse(parsed) if (versionElevenResult.success) { const next: StoredApplicationSettings = { ...versionElevenResult.data, version: CURRENT_SETTINGS_VERSION } await this.persist(next) return next } const versionTenResult = versionTenStoredApplicationSettingsSchema.safeParse(parsed) if (versionTenResult.success) { const next: StoredApplicationSettings = { ...versionTenResult.data, version: CURRENT_SETTINGS_VERSION, conversationHtmlRenderingEnabled: true } await this.persist(next) return next } const versionNineResult = versionNineStoredApplicationSettingsSchema.safeParse(parsed) if (versionNineResult.success) { return this.migrateLegacy(versionNineResult.data) } const versionEightResult = versionEightStoredApplicationSettingsSchema.safeParse(parsed) if (versionEightResult.success) { return this.migrateLegacy({ ...versionEightResult.data, version: CURRENT_SETTINGS_VERSION, remoteProjectsEnabled: false }) } const versionSevenResult = versionSevenStoredApplicationSettingsSchema.safeParse(parsed) if (versionSevenResult.success) { return this.migrateLegacy({ ...versionSevenResult.data, version: CURRENT_SETTINGS_VERSION, remoteProjectsEnabled: false, magicNotesShowIncompleteTodoCount: true }) } const versionSixResult = versionSixStoredApplicationSettingsSchema.safeParse(parsed) if (versionSixResult.success) { return this.migrateLegacy({ ...versionSixResult.data, version: CURRENT_SETTINGS_VERSION, modelDownloadSource: 'modelscope', remoteProjectsEnabled: false, magicNotesShowIncompleteTodoCount: true }) } const versionFiveResult = versionFiveStoredApplicationSettingsSchema.safeParse(parsed) if (versionFiveResult.success) { return this.migrateLegacy({ ...versionFiveResult.data, version: CURRENT_SETTINGS_VERSION, updateSource: 'github', modelDownloadSource: 'modelscope', remoteProjectsEnabled: false, magicNotesShowIncompleteTodoCount: true }) } const versionFourResult = versionFourStoredApplicationSettingsSchema.safeParse(parsed) if (versionFourResult.success) { return this.migrateLegacy({ ...versionFourResult.data, version: CURRENT_SETTINGS_VERSION, updateSource: 'github', modelDownloadSource: 'modelscope', remoteProjectsEnabled: false, magicNotesShowIncompleteTodoCount: true, lastSeenReleaseNotesVersion: null }) } const versionThreeResult = versionThreeStoredApplicationSettingsSchema.safeParse(parsed) if (versionThreeResult.success) { return this.migrateLegacy({ ...versionThreeResult.data, version: CURRENT_SETTINGS_VERSION, updateSource: 'github', modelDownloadSource: 'modelscope', remoteProjectsEnabled: false, magicNotesShowIncompleteTodoCount: true, magicNoteCommentFormat: 'combined', lastSeenReleaseNotesVersion: null }) } const versionTwoResult = versionTwoStoredApplicationSettingsSchema.safeParse(parsed) if (versionTwoResult.success) { return this.migrateLegacy({ ...versionTwoResult.data, version: CURRENT_SETTINGS_VERSION, updateSource: 'github', modelDownloadSource: 'modelscope', remoteProjectsEnabled: false, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate', magicNoteCommentFormat: 'combined', lastSeenReleaseNotesVersion: null }) } const legacyResult = legacyStoredApplicationSettingsSchema.safeParse(parsed) if (legacyResult.success) { return this.migrateLegacy({ version: CURRENT_SETTINGS_VERSION, checkUpdatesOnStartup: legacyResult.data.checkUpdatesOnStartup, updateSource: 'github', modelDownloadSource: 'modelscope', remoteProjectsEnabled: false, magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate', magicNoteCommentFormat: 'combined', lastSeenReleaseNotesVersion: null }) } await this.isolateCorruptFile() this.warnings = [{ code: 'application-settings-recovered' }] this.settings = { version: CURRENT_SETTINGS_VERSION, lastSeenReleaseNotesVersion: null, ...defaultApplicationSettings } return this.settings } this.settings = result.data } catch (error) { if (error instanceof UnsupportedSettingsVersionError) { throw error } if (!isMissingFileError(error)) { throw new Error('Application settings could not be read', { cause: error }) } this.settings = { version: CURRENT_SETTINGS_VERSION, lastSeenReleaseNotesVersion: null, ...defaultApplicationSettings } } return this.settings } async get(): Promise { const stored = await this.loadStored() return { checkUpdatesOnStartup: stored.checkUpdatesOnStartup, desktopNotificationsEnabled: stored.desktopNotificationsEnabled, transparentFrostedEffectEnabled: stored.transparentFrostedEffectEnabled, updateSource: stored.updateSource, modelDownloadSource: stored.modelDownloadSource, localToolEnvironment: stored.localToolEnvironment, conversationHtmlRenderingEnabled: stored.conversationHtmlRenderingEnabled, remoteProjectsEnabled: stored.remoteProjectsEnabled, applicationNavigation: stored.applicationNavigation, localInferenceEnabled: stored.localInferenceEnabled, deviceSharingEnabled: stored.deviceSharingEnabled, magicNotesEnabled: stored.magicNotesEnabled, heartbeatEnabled: stored.heartbeatEnabled ?? false, heartbeatReportTimeoutSeconds: stored.heartbeatReportTimeoutSeconds ?? defaultSupervisionTimeoutSeconds, supervisorOrganizeTimeoutSeconds: stored.supervisorOrganizeTimeoutSeconds ?? defaultSupervisionTimeoutSeconds, supervisorModelConcurrency: stored.supervisorModelConcurrency ?? defaultSupervisorModelConcurrency, supervisorModelProfileId: stored.supervisorModelProfileId ?? null, supervisionReview: stored.supervisionReview, magicNotesShowIncompleteTodoCount: stored.magicNotesShowIncompleteTodoCount, magicNoteCommentMode: stored.magicNoteCommentMode, magicNoteCommentFormat: stored.magicNoteCommentFormat, magicNoteCanvasPageCount: stored.magicNoteCanvasPageCount, ...(this.warnings.length > 0 ? { warnings: [...this.warnings] } : {}) } } async getLastSeenReleaseNotesVersion(): Promise { return (await this.loadStored()).lastSeenReleaseNotesVersion } private async persist(next: StoredApplicationSettings): Promise { await writeJsonFileAtomically(this.filePath, next) this.settings = next } update(input: unknown): Promise { const operation = this.updateQueue.then(async () => { const updates = applicationSettingsUpdateSchema.parse(input) const current = await this.loadStored() const next: StoredApplicationSettings = { ...current, ...updates, version: CURRENT_SETTINGS_VERSION } await this.persist(next) this.warnings = [] const settings: ApplicationSettings = { checkUpdatesOnStartup: next.checkUpdatesOnStartup, desktopNotificationsEnabled: next.desktopNotificationsEnabled, transparentFrostedEffectEnabled: next.transparentFrostedEffectEnabled, updateSource: next.updateSource, modelDownloadSource: next.modelDownloadSource, localToolEnvironment: next.localToolEnvironment, conversationHtmlRenderingEnabled: next.conversationHtmlRenderingEnabled, remoteProjectsEnabled: next.remoteProjectsEnabled, applicationNavigation: next.applicationNavigation, localInferenceEnabled: next.localInferenceEnabled, deviceSharingEnabled: next.deviceSharingEnabled, magicNotesEnabled: next.magicNotesEnabled, heartbeatEnabled: next.heartbeatEnabled ?? false, heartbeatReportTimeoutSeconds: next.heartbeatReportTimeoutSeconds ?? defaultSupervisionTimeoutSeconds, supervisorOrganizeTimeoutSeconds: next.supervisorOrganizeTimeoutSeconds ?? defaultSupervisionTimeoutSeconds, supervisorModelConcurrency: next.supervisorModelConcurrency ?? defaultSupervisorModelConcurrency, supervisorModelProfileId: next.supervisorModelProfileId ?? null, supervisionReview: next.supervisionReview, magicNotesShowIncompleteTodoCount: next.magicNotesShowIncompleteTodoCount, magicNoteCommentMode: next.magicNoteCommentMode, magicNoteCommentFormat: next.magicNoteCommentFormat, magicNoteCanvasPageCount: next.magicNoteCanvasPageCount } for (const listener of this.listeners) listener(settings) return settings }) this.updateQueue = operation.then( () => undefined, () => undefined ) return operation } setLastSeenReleaseNotesVersion(version: unknown): Promise { const operation = this.updateQueue.then(async () => { const parsedVersion = releaseVersionSchema.parse(version) const current = await this.loadStored() if (current.lastSeenReleaseNotesVersion === parsedVersion) { return } await this.persist({ ...current, version: CURRENT_SETTINGS_VERSION, lastSeenReleaseNotesVersion: parsedVersion }) }) this.updateQueue = operation.then( () => undefined, () => undefined ) return operation } }