/** * Settings persistence. * * Settings live at `/.sys/config.json` on the WebDAV server. The bundled * `config.json` is used as the initial value while the file is being * fetched, and as a fallback if the server is unreachable. * * The `useSettings()` hook returns the same `(config, setConfig)` shape * the page components already use, but every change is also persisted * to the WebDAV server after a short debounce. */ import { useCallback, useEffect, useRef, useState } from 'react'; import bundledConfig from '../imports/config.json'; import { createFolder, getFileContents, putFileContents, stat, } from './webdav'; /** The canonical paths on the WebDAV server. */ export const SETTINGS_PATH = '/.sys/config.json'; export const DEVICES_PATH = '/.sys/devices.json'; const SETTINGS_DIR = '/.sys'; const SD_SYS_DIR = '/sd/.sys'; /** * How long to wait after the last change before writing to disk. * * The debounce timer is reset on every change, so this is effectively * "the number of milliseconds the user must be idle before the change * is committed". Bump this up to give the user more time to make * multiple related edits; lower it to write changes more eagerly. */ export const SAVE_IDLE_MS = 3000; /** How long the "Saved" indicator lingers before fading out. */ const SAVED_INDICATOR_MS = 1500; export type SettingsConfig = Record; /** 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 */ } try { return await getFileContents(fallback).then(b => b.text()); } catch { /* both failed */ } return null; } /** Read both config files from the WebDAV server and merge them. Returns null on failure. */ export async function readSettings(): Promise { try { // Mirror firmware priority: /sd/.sys/ first, fall back to /.sys/ const [configText, devicesText] = await Promise.all([ getTextWithFallback('/sd' + SETTINGS_PATH, SETTINGS_PATH), getTextWithFallback('/sd' + DEVICES_PATH, DEVICES_PATH), ]); if (!configText) return null; const config = JSON.parse(configText); if (!config || typeof config !== 'object') return null; if (devicesText) { const devices = JSON.parse(devicesText); if (devices && typeof devices === 'object') { // One-level deep merge: devices.json keys are merged into matching // top-level objects in config (e.g. devices.iec.devices → config.iec.devices). for (const [k, v] of Object.entries(devices)) { if (config[k] && typeof config[k] === 'object' && !Array.isArray(config[k]) && v && typeof v === 'object' && !Array.isArray(v)) { config[k] = { ...config[k], ...(v as object) }; } else { config[k] = v; } } } } return config as SettingsConfig; } catch { return null; } } /** * Persist the settings object to the WebDAV server, creating the * containing `/.sys/` directory if needed. Pretty-prints with 2-space * indentation so the file is diff-friendly. */ export async function writeSettings(config: SettingsConfig): Promise { try { await createFolder(SETTINGS_DIR, true); } catch { /* exists */ } const { iec, ...mainConfig } = config; const { devices: iecDevices, ...iecBusConfig } = (iec ?? {}) as any; const configJson = JSON.stringify({ ...mainConfig, iec: iecBusConfig }, null, 2) + '\n'; const devicesJson = JSON.stringify({ iec: { devices: iecDevices } }, null, 2) + '\n'; await Promise.all([ putFileContents(SETTINGS_PATH, configJson), putFileContents(DEVICES_PATH, devicesJson), ]); // Mirror to /sd/.sys/ if the SD card is mounted. try { const sdStat = await stat('/sd'); if (sdStat) { try { await createFolder(SD_SYS_DIR, true); } catch { /* exists */ } await Promise.all([ putFileContents(SD_SYS_DIR + '/config.json', configJson), putFileContents(SD_SYS_DIR + '/devices.json', devicesJson), ]); } } catch { /* /sd unreachable — skip mirror */ } } export type SaveStatus = | 'idle' // nothing to do | 'loading' // initial fetch from server | 'unsaved' // one or more changes pending; waiting for the idle timer | 'saving' // a write is currently in flight | 'saved' // last write succeeded (fades back to 'idle' shortly) | 'error'; // last write failed; will retry on the next change export interface UseSettingsResult { config: SettingsConfig; setConfig: (next: SettingsConfig | ((prev: SettingsConfig) => SettingsConfig)) => void; /** Whether the initial load from the server has completed. */ loaded: boolean; /** Current save status (see {@link SaveStatus}). */ saveStatus: SaveStatus; /** Number of pending (uncommitted) changes since the last successful save. */ pendingCount: number; /** Force a re-fetch from the server and replace local state. */ reload: () => Promise; /** Flush any pending changes immediately, ignoring the idle timer. */ flushNow: () => Promise; } /** * React hook that loads settings from the WebDAV server on mount and * auto-saves them on every change (debounced). * * Behavior: * - State is initialised with the bundled `config.json` so the UI * renders immediately, even before the server is contacted. * - On mount, an async fetch replaces state with the server's copy. * - On every `setConfig`, a debounced write fires `SAVE_DEBOUNCE_MS` * after the most recent change. * - On `beforeunload`, any pending change is flushed synchronously * (best-effort: `sendBeacon` cannot be used with WebDAV, so the page * may be allowed to close only if the user agrees via the browser's * default "Leave site?" dialog). */ export function useSettings(): UseSettingsResult { const [config, setConfigState] = useState(bundledConfig as SettingsConfig); const [loaded, setLoaded] = useState(false); const [saveStatus, setSaveStatus] = useState('loading'); const [pendingCount, setPendingCount] = useState(0); // Refs so the debounced writer always sees the latest values without // needing to re-subscribe on every render. const configRef = useRef(config); const loadedRef = useRef(false); const timerRef = useRef(null); const dirtyRef = useRef(false); const pendingRef = useRef(0); const savedTimerRef = useRef(null); const load = useCallback(async () => { setSaveStatus('loading'); const fromServer = await readSettings(); if (fromServer) { configRef.current = fromServer; setConfigState(fromServer); } loadedRef.current = true; setLoaded(true); setSaveStatus('idle'); }, []); useEffect(() => { void load(); }, [load]); const flushNow = useCallback(async () => { if (timerRef.current !== null) { window.clearTimeout(timerRef.current); timerRef.current = null; } if (savedTimerRef.current !== null) { window.clearTimeout(savedTimerRef.current); savedTimerRef.current = null; } if (!dirtyRef.current) return; dirtyRef.current = false; const pending = pendingRef.current; pendingRef.current = 0; setPendingCount(0); setSaveStatus('saving'); try { await writeSettings(configRef.current); setSaveStatus('saved'); // Drop the "saved" badge after a short delay so it doesn't linger. savedTimerRef.current = window.setTimeout(() => { setSaveStatus((s) => (s === 'saved' ? 'idle' : s)); }, SAVED_INDICATOR_MS); } catch (e) { // Roll back: the change is still pending. dirtyRef.current = true; pendingRef.current = pending; setPendingCount(pending); setSaveStatus('error'); // Re-throw so the caller can handle it if they want. throw e; } }, []); // Flush on tab close (best effort; most browsers will block until the // fetch completes if it can). useEffect(() => { const onBeforeUnload = (e: BeforeUnloadEvent) => { if (!dirtyRef.current) return; // Modern browsers: `fetch` with `keepalive: true` continues even // after the page is being unloaded. We don't await it. try { const { iec, ...mainConfig } = configRef.current; const { devices: iecDevices, ...iecBusConfig } = (iec ?? {}) as any; void fetch(absoluteUrl(SETTINGS_PATH), { method: 'PUT', body: JSON.stringify({ ...mainConfig, iec: iecBusConfig }, null, 2) + '\n', headers: { 'Content-Type': 'application/json' }, keepalive: true, }); void fetch(absoluteUrl(DEVICES_PATH), { method: 'PUT', body: JSON.stringify({ iec: { devices: iecDevices } }, null, 2) + '\n', headers: { 'Content-Type': 'application/json' }, keepalive: true, }); } catch { /* ignore */ } e.preventDefault(); e.returnValue = ''; }; window.addEventListener('beforeunload', onBeforeUnload); return () => window.removeEventListener('beforeunload', onBeforeUnload); }, []); const setConfig = useCallback( (next: SettingsConfig | ((prev: SettingsConfig) => SettingsConfig)) => { const value = typeof next === 'function' ? (next as (p: SettingsConfig) => SettingsConfig)(configRef.current) : next; configRef.current = value; setConfigState(value); dirtyRef.current = true; pendingRef.current += 1; setPendingCount(pendingRef.current); setSaveStatus('unsaved'); if (timerRef.current !== null) { window.clearTimeout(timerRef.current); } // Reset the idle timer: save `SAVE_IDLE_MS` after the most recent // change. Every additional change resets the timer again. timerRef.current = window.setTimeout(() => { void flushNow(); }, SAVE_IDLE_MS); }, [flushNow], ); return { config, setConfig, loaded, saveStatus, pendingCount, reload: load, flushNow, }; } function absoluteUrl(path: string): string { return `${window.location.protocol}//${window.location.hostname}${ window.location.port ? ':' + window.location.port : '' }${path}`; }