From 3557b3ab2236684f0833bfcbed204e26ab5ebb47 Mon Sep 17 00:00:00 2001 From: Jaime Idolpx Date: Thu, 11 Jun 2026 17:27:04 -0400 Subject: [PATCH] feat(settings): implement config migration and deep merge for backward compatibility --- src/app/settings.ts | 121 +++++++++++++++++++++++++++++++++++++--- src/imports/config.json | 1 + 2 files changed, 114 insertions(+), 8 deletions(-) diff --git a/src/app/settings.ts b/src/app/settings.ts index 78ae2ae..96515a6 100644 --- a/src/app/settings.ts +++ b/src/app/settings.ts @@ -40,6 +40,83 @@ const SAVED_INDICATOR_MS = 1500; export type SettingsConfig = Record; +const BUNDLED_VERSION: string = (bundledConfig as any).version ?? '0'; + +// ─── Config migration ───────────────────────────────────────────────────────── + +/** Recursively fill missing keys/sub-objects from `defaults` into `target`. */ +function deepMergeDefaults(target: Record, defaults: Record): Record { + const result = { ...target }; + for (const [k, v] of Object.entries(defaults)) { + if (k === 'version') continue; + if (!(k in result)) { + result[k] = v; + } else if ( + v !== null && typeof v === 'object' && !Array.isArray(v) && + result[k] !== null && typeof result[k] === 'object' && !Array.isArray(result[k]) + ) { + result[k] = deepMergeDefaults(result[k] as Record, v as Record); + } + } + return result; +} + +/** + * Migrate a config loaded from an older format to the current structure. + * + * Handles: + * - Top-level renames: general→preferences, iec_config→settings + * - Sub-key renames inside settings: drive_rom→drive_roms + * - Moving peripherals into devices: hardware.userport, cassette, bluetooth, modem + * - Old devices.json shape: { iec: { devices:{} } } → devices.iec + * - Deep-merging any missing keys from the bundled defaults + */ +function migrateConfig(raw: Record): Record { + let c = { ...raw }; + + // Top-level key renames + if ('general' in c && !('preferences' in c)) { c.preferences = c.general; delete c.general; } + if ('iec_config' in c && !('settings' in c)) { c.settings = c.iec_config; delete c.iec_config; } + + // settings sub-key rename + if (c.settings && typeof c.settings === 'object') { + const s = { ...c.settings } as Record; + if ('drive_rom' in s && !('drive_roms' in s)) { s.drive_roms = s.drive_rom; delete s.drive_rom; } + c.settings = s; + } + + // Old devices.json merged as config.iec = { devices:{} } — move to config.devices.iec + if (c.iec && typeof c.iec === 'object') { + if (!c.devices) c.devices = {}; + const oldIec = c.iec as Record; + if ('devices' in oldIec && !c.devices.iec) c.devices.iec = oldIec.devices; + delete c.iec; + } + + // Move peripheral config into devices + if (!c.devices) c.devices = {}; + if (c.hardware && typeof c.hardware === 'object') { + const hw = c.hardware as Record; + if (hw.userport !== undefined && c.devices.userport === undefined) c.devices.userport = hw.userport; + if (hw.ps2 !== undefined && c.devices.ps2 === undefined) c.devices.ps2 = hw.ps2; + delete c.hardware; + } + if (c.cassette && !c.devices.cassette) { c.devices.cassette = c.cassette; delete c.cassette; } + if (c.bluetooth && !c.devices.bluetooth) { c.devices.bluetooth = c.bluetooth; delete c.bluetooth; } + if (c.modem && !c.devices.modem) { c.devices.modem = c.modem; delete c.modem; } + if ('boip' in c) delete c.boip; + + // Fill any missing keys/sub-objects from bundled defaults + c = deepMergeDefaults(c, bundledConfig as Record); + + // Stamp the current version + c.version = BUNDLED_VERSION; + + return c; +} + +// ─── I/O helpers ────────────────────────────────────────────────────────────── + /** Try primary path first, fall back to secondary. Returns null if both fail. */ async function getTextWithFallback(primary: string, fallback: string): Promise { try { return await getFileContents(primary).then(b => b.text()); } catch { /* try fallback */ } @@ -47,8 +124,14 @@ async function getTextWithFallback(primary: string, fallback: string): Promise { +export async function readSettings(): Promise { try { // Mirror firmware priority: /sd/.sys/ first, fall back to /.sys/ const [configText, devicesText] = await Promise.all([ @@ -56,10 +139,10 @@ export async function readSettings(): Promise { getTextWithFallback('/sd' + DEVICES_PATH, DEVICES_PATH), ]); if (!configText) return null; - const config = JSON.parse(configText); + let config = JSON.parse(configText) as Record; if (!config || typeof config !== 'object') return null; if (devicesText) { - const devices = JSON.parse(devicesText); + const devices = JSON.parse(devicesText) as Record; if (devices && typeof devices === 'object') { // One-level deep merge: devices.json keys are merged into matching // top-level objects in config (e.g. devices.devices.iec → config.devices.iec). @@ -73,7 +156,9 @@ export async function readSettings(): Promise { } } } - return config as SettingsConfig; + const needsMigration = !config.version || config.version !== BUNDLED_VERSION; + if (needsMigration) config = migrateConfig(config); + return { config: config as SettingsConfig, migrated: needsMigration }; } catch { return null; } @@ -164,10 +249,30 @@ export function useSettings(): UseSettingsResult { const load = useCallback(async () => { setSaveStatus('loading'); - const fromServer = await readSettings(); - if (fromServer) { - configRef.current = fromServer; - setConfigState(fromServer); + const result = await readSettings(); + if (result) { + configRef.current = result.config; + setConfigState(result.config); + if (result.migrated) { + // Persist the migrated config immediately so the device is updated. + setSaveStatus('saving'); + try { + await writeSettings(result.config); + setSaveStatus('saved'); + savedTimerRef.current = window.setTimeout(() => { + setSaveStatus((s) => (s === 'saved' ? 'idle' : s)); + }, SAVED_INDICATOR_MS); + } catch { + // If the write fails, mark dirty so the save-status badge appears. + dirtyRef.current = true; + pendingRef.current = 1; + setPendingCount(1); + setSaveStatus('unsaved'); + } + loadedRef.current = true; + setLoaded(true); + return; + } } loadedRef.current = true; setLoaded(true); diff --git a/src/imports/config.json b/src/imports/config.json index 2984e07..b7cca33 100644 --- a/src/imports/config.json +++ b/src/imports/config.json @@ -1,4 +1,5 @@ { + "version": "0.5.0", "preferences": { "appearance": "auto", "language": "en",