feat(settings): implement config migration and deep merge for backward compatibility

This commit is contained in:
Jaime Idolpx 2026-06-11 17:27:04 -04:00
parent faa2e41be4
commit 3557b3ab22
2 changed files with 114 additions and 8 deletions

View File

@ -40,6 +40,83 @@ const SAVED_INDICATOR_MS = 1500;
export type SettingsConfig = Record<string, any>;
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<string, any>, defaults: Record<string, any>): Record<string, any> {
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<string, any>, v as Record<string, any>);
}
}
return result;
}
/**
* Migrate a config loaded from an older format to the current structure.
*
* Handles:
* - Top-level renames: generalpreferences, iec_configsettings
* - Sub-key renames inside settings: drive_romdrive_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<string, any>): Record<string, any> {
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<string, any>;
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<string, any>;
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<string, any>;
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<string, any>);
// 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<string | null> {
try { return await getFileContents(primary).then(b => b.text()); } catch { /* try fallback */ }
@ -47,8 +124,14 @@ async function getTextWithFallback(primary: string, fallback: string): Promise<s
return null;
}
export interface ReadSettingsResult {
config: SettingsConfig;
/** True when the loaded config was an older version and has been migrated. */
migrated: boolean;
}
/** Read both config files from the WebDAV server and merge them. Returns null on failure. */
export async function readSettings(): Promise<SettingsConfig | null> {
export async function readSettings(): Promise<ReadSettingsResult | null> {
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<SettingsConfig | null> {
getTextWithFallback('/sd' + DEVICES_PATH, DEVICES_PATH),
]);
if (!configText) return null;
const config = JSON.parse(configText);
let config = JSON.parse(configText) as Record<string, any>;
if (!config || typeof config !== 'object') return null;
if (devicesText) {
const devices = JSON.parse(devicesText);
const devices = JSON.parse(devicesText) as Record<string, any>;
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<SettingsConfig | null> {
}
}
}
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);

View File

@ -1,4 +1,5 @@
{
"version": "0.5.0",
"preferences": {
"appearance": "auto",
"language": "en",