feat(settings): implement config migration and deep merge for backward compatibility
This commit is contained in:
parent
faa2e41be4
commit
3557b3ab22
|
|
@ -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: 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<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);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"version": "0.5.0",
|
||||
"preferences": {
|
||||
"appearance": "auto",
|
||||
"language": "en",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user