Compare commits

...

2 Commits

2 changed files with 78 additions and 40 deletions

View File

@ -413,7 +413,9 @@ export default function DeviceDetailOverlay({
)} )}
</div> </div>
{deviceData.cache !== undefined && ( {(deviceData.cache !== undefined ||
(deviceData.base_url ?? '').includes('://') ||
(deviceData.url ?? '').includes('://')) && (
<div> <div>
<label className="text-sm text-neutral-500 block mb-2">Cache</label> <label className="text-sm text-neutral-500 block mb-2">Cache</label>
<div className="flex gap-2"> <div className="flex gap-2">

View File

@ -164,34 +164,62 @@ export async function readSettings(): Promise<ReadSettingsResult | null> {
} }
} }
export interface SavedJson {
configJson: string;
devicesJson: string;
/** True if at least one file was actually written. */
wrote: boolean;
}
/** Serialize config into the two canonical JSON strings without writing anything. */
export function serializeConfig(config: SettingsConfig): SavedJson {
const { devices, ...mainConfig } = config;
return {
configJson: JSON.stringify(mainConfig, null, 2) + '\n',
devicesJson: JSON.stringify({ devices }, null, 2) + '\n',
wrote: false,
};
}
/** /**
* Persist the settings object to the WebDAV server, creating the * Persist the settings object to the WebDAV server, creating the
* containing `/.sys/` directory if needed. Pretty-prints with 2-space * containing `/.sys/` directory if needed. Pretty-prints with 2-space
* indentation so the file is diff-friendly. * indentation so the file is diff-friendly.
*
* Pass `lastSaved` to skip writing files whose content hasn't changed.
* Returns the canonical JSON strings that were written (or would have been).
*/ */
export async function writeSettings(config: SettingsConfig): Promise<void> { export async function writeSettings(
config: SettingsConfig,
lastSaved?: SavedJson | null,
): Promise<SavedJson> {
const next = serializeConfig(config);
const writeConfig = !lastSaved || next.configJson !== lastSaved.configJson;
const writeDevices = !lastSaved || next.devicesJson !== lastSaved.devicesJson;
if (!writeConfig && !writeDevices) return { ...next, wrote: false };
try { await createFolder(SETTINGS_DIR, true); } catch { /* exists */ } try { await createFolder(SETTINGS_DIR, true); } catch { /* exists */ }
const { devices, ...mainConfig } = config; const writes: Promise<void>[] = [];
const configJson = JSON.stringify(mainConfig, null, 2) + '\n'; if (writeConfig) writes.push(putFileContents(SETTINGS_PATH, next.configJson));
const devicesJson = JSON.stringify({ devices }, null, 2) + '\n'; if (writeDevices) writes.push(putFileContents(DEVICES_PATH, next.devicesJson));
await Promise.all(writes);
await Promise.all([
putFileContents(SETTINGS_PATH, configJson),
putFileContents(DEVICES_PATH, devicesJson),
]);
// Mirror to /sd/.sys/ if the SD card is mounted. // Mirror to /sd/.sys/ if the SD card is mounted.
try { try {
const sdStat = await stat('/sd'); const sdStat = await stat('/sd');
if (sdStat) { if (sdStat) {
try { await createFolder(SD_SYS_DIR, true); } catch { /* exists */ } try { await createFolder(SD_SYS_DIR, true); } catch { /* exists */ }
await Promise.all([ const sdWrites: Promise<void>[] = [];
putFileContents(SD_SYS_DIR + '/config.json', configJson), if (writeConfig) sdWrites.push(putFileContents(SD_SYS_DIR + '/config.json', next.configJson));
putFileContents(SD_SYS_DIR + '/devices.json', devicesJson), if (writeDevices) sdWrites.push(putFileContents(SD_SYS_DIR + '/devices.json', next.devicesJson));
]); await Promise.all(sdWrites);
} }
} catch { /* /sd unreachable — skip mirror */ } } catch { /* /sd unreachable — skip mirror */ }
return { ...next, wrote: true };
} }
export type SaveStatus = export type SaveStatus =
@ -246,6 +274,7 @@ export function useSettings(): UseSettingsResult {
const dirtyRef = useRef<boolean>(false); const dirtyRef = useRef<boolean>(false);
const pendingRef = useRef<number>(0); const pendingRef = useRef<number>(0);
const savedTimerRef = useRef<number | null>(null); const savedTimerRef = useRef<number | null>(null);
const lastSavedRef = useRef<SavedJson | null>(null);
const load = useCallback(async () => { const load = useCallback(async () => {
setSaveStatus('loading'); setSaveStatus('loading');
@ -257,7 +286,7 @@ export function useSettings(): UseSettingsResult {
// Persist the migrated config immediately so the device is updated. // Persist the migrated config immediately so the device is updated.
setSaveStatus('saving'); setSaveStatus('saving');
try { try {
await writeSettings(result.config); lastSavedRef.current = await writeSettings(result.config);
setSaveStatus('saved'); setSaveStatus('saved');
savedTimerRef.current = window.setTimeout(() => { savedTimerRef.current = window.setTimeout(() => {
setSaveStatus((s) => (s === 'saved' ? 'idle' : s)); setSaveStatus((s) => (s === 'saved' ? 'idle' : s));
@ -273,6 +302,8 @@ export function useSettings(): UseSettingsResult {
setLoaded(true); setLoaded(true);
return; return;
} }
// Record what the server holds so flushNow can skip unchanged files.
lastSavedRef.current = serializeConfig(result.config);
} }
loadedRef.current = true; loadedRef.current = true;
setLoaded(true); setLoaded(true);
@ -299,12 +330,16 @@ export function useSettings(): UseSettingsResult {
setPendingCount(0); setPendingCount(0);
setSaveStatus('saving'); setSaveStatus('saving');
try { try {
await writeSettings(configRef.current); const result = await writeSettings(configRef.current, lastSavedRef.current);
lastSavedRef.current = result;
if (result.wrote) {
setSaveStatus('saved'); setSaveStatus('saved');
// Drop the "saved" badge after a short delay so it doesn't linger.
savedTimerRef.current = window.setTimeout(() => { savedTimerRef.current = window.setTimeout(() => {
setSaveStatus((s) => (s === 'saved' ? 'idle' : s)); setSaveStatus((s) => (s === 'saved' ? 'idle' : s));
}, SAVED_INDICATOR_MS); }, SAVED_INDICATOR_MS);
} else {
setSaveStatus('idle');
}
} catch (e) { } catch (e) {
// Roll back: the change is still pending. // Roll back: the change is still pending.
dirtyRef.current = true; dirtyRef.current = true;
@ -324,19 +359,20 @@ export function useSettings(): UseSettingsResult {
// Modern browsers: `fetch` with `keepalive: true` continues even // Modern browsers: `fetch` with `keepalive: true` continues even
// after the page is being unloaded. We don't await it. // after the page is being unloaded. We don't await it.
try { try {
const { devices, ...mainConfig } = configRef.current; const next = serializeConfig(configRef.current);
const last = lastSavedRef.current;
if (!last || next.configJson !== last.configJson) {
void fetch(absoluteUrl(SETTINGS_PATH), { void fetch(absoluteUrl(SETTINGS_PATH), {
method: 'PUT', method: 'PUT', body: next.configJson,
body: JSON.stringify(mainConfig, null, 2) + '\n', headers: { 'Content-Type': 'application/json' }, keepalive: true,
headers: { 'Content-Type': 'application/json' },
keepalive: true,
}); });
}
if (!last || next.devicesJson !== last.devicesJson) {
void fetch(absoluteUrl(DEVICES_PATH), { void fetch(absoluteUrl(DEVICES_PATH), {
method: 'PUT', method: 'PUT', body: next.devicesJson,
body: JSON.stringify({ devices }, null, 2) + '\n', headers: { 'Content-Type': 'application/json' }, keepalive: true,
headers: { 'Content-Type': 'application/json' },
keepalive: true,
}); });
}
} catch { } catch {
/* ignore */ /* ignore */
} }