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>;
|
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. */
|
/** Try primary path first, fall back to secondary. Returns null if both fail. */
|
||||||
async function getTextWithFallback(primary: string, fallback: string): Promise<string | null> {
|
async function getTextWithFallback(primary: string, fallback: string): Promise<string | null> {
|
||||||
try { return await getFileContents(primary).then(b => b.text()); } catch { /* try fallback */ }
|
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;
|
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. */
|
/** 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 {
|
try {
|
||||||
// Mirror firmware priority: /sd/.sys/ first, fall back to /.sys/
|
// Mirror firmware priority: /sd/.sys/ first, fall back to /.sys/
|
||||||
const [configText, devicesText] = await Promise.all([
|
const [configText, devicesText] = await Promise.all([
|
||||||
|
|
@ -56,10 +139,10 @@ export async function readSettings(): Promise<SettingsConfig | null> {
|
||||||
getTextWithFallback('/sd' + DEVICES_PATH, DEVICES_PATH),
|
getTextWithFallback('/sd' + DEVICES_PATH, DEVICES_PATH),
|
||||||
]);
|
]);
|
||||||
if (!configText) return null;
|
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 (!config || typeof config !== 'object') return null;
|
||||||
if (devicesText) {
|
if (devicesText) {
|
||||||
const devices = JSON.parse(devicesText);
|
const devices = JSON.parse(devicesText) as Record<string, any>;
|
||||||
if (devices && typeof devices === 'object') {
|
if (devices && typeof devices === 'object') {
|
||||||
// One-level deep merge: devices.json keys are merged into matching
|
// One-level deep merge: devices.json keys are merged into matching
|
||||||
// top-level objects in config (e.g. devices.devices.iec → config.devices.iec).
|
// 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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -164,10 +249,30 @@ export function useSettings(): UseSettingsResult {
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setSaveStatus('loading');
|
setSaveStatus('loading');
|
||||||
const fromServer = await readSettings();
|
const result = await readSettings();
|
||||||
if (fromServer) {
|
if (result) {
|
||||||
configRef.current = fromServer;
|
configRef.current = result.config;
|
||||||
setConfigState(fromServer);
|
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;
|
loadedRef.current = true;
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
{
|
{
|
||||||
|
"version": "0.5.0",
|
||||||
"preferences": {
|
"preferences": {
|
||||||
"appearance": "auto",
|
"appearance": "auto",
|
||||||
"language": "en",
|
"language": "en",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user