Improve local backup permissions and change tracking
All checks were successful
Deploy Sokko G / deploy (push) Successful in 19s

This commit is contained in:
Shinuwa 2026-09-25 10:45:12 +02:00
parent 34e542be28
commit ee823d2c37
13 changed files with 372 additions and 37 deletions

View file

@ -1,6 +1,7 @@
// Rôle : gère les sauvegardes JSON locales via la File System Access API.
export const AUTO_BACKUP_SETTING = "autoBackup";
export const AUTO_BACKUP_DATA_CHANGED_EVENT = "sokkog:auto-backup-data-changed";
export const AUTO_BACKUP_FILE_PATTERN = /^sokkog-global-\d{4}-\d{2}-\d{2}\.json$/;
const AUTO_BACKUP_LOCK = "sokkog-auto-backup";
@ -23,7 +24,11 @@ export function normalizeAutoBackupConfig(value) {
return {
enabled: value?.enabled === true,
directoryHandle: value?.directoryHandle || null,
lastBackup: typeof value?.lastBackup === "string" ? value.lastBackup : ""
lastBackup: typeof value?.lastBackup === "string" ? value.lastBackup : "",
lastBackupSignature: typeof value?.lastBackupSignature === "string" ? value.lastBackupSignature : "",
hasUnsavedChanges: typeof value?.hasUnsavedChanges === "boolean"
? value.hasUnsavedChanges
: Boolean(value?.enabled && value?.directoryHandle)
};
}
@ -82,6 +87,29 @@ function comparableBackupContents(contents) {
}
}
export async function getBackupContentSignature(contents, cryptoScope = globalThis.crypto) {
const comparable = comparableBackupContents(contents);
if (comparable === null) return "";
try {
if (cryptoScope?.subtle) {
const digest = await cryptoScope.subtle.digest("SHA-256", new TextEncoder().encode(comparable));
return `sha256-${[...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join("")}`;
}
} catch {
// Le hash de secours reste déterministe lorsque Web Crypto est indisponible.
}
let hash = 2166136261;
for (let index = 0; index < comparable.length; index += 1) {
hash ^= comparable.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return `fnv1a-${(hash >>> 0).toString(16).padStart(8, "0")}-${comparable.length}`;
}
export function hasBackupDataChanged({ lastBackupSignature = "", currentSignature = "" }) {
return Boolean(currentSignature) && currentSignature !== lastBackupSignature;
}
export function backupContentsEqual(left, right) {
const comparableLeft = comparableBackupContents(left);
const comparableRight = comparableBackupContents(right);
@ -113,7 +141,15 @@ export async function saveBackup({ directoryHandle, generateJson, date = new Dat
return withBackupLock(async () => {
try {
const existing = await listBackupFiles(directoryHandle);
if (!force && existing.includes(filename)) return { ok: true, created: false, filename };
if (!force && existing.includes(filename)) {
let contentSignature = "";
try {
contentSignature = await getBackupContentSignature(await readBackupFile(directoryHandle, filename));
} catch {
// L'existence réelle du fichier suffit à respecter la limite quotidienne.
}
return { ok: true, created: false, filename, contentSignature };
}
const contents = await generateJson();
if (!force && existing.length) {
const latestFilename = sortBackupFiles(existing)[0];

View file

@ -4,8 +4,11 @@ import { createGlobalExportPayload } from "./toolboxStorage.js";
import { getSetting, setSetting } from "../../../utils/indexedDbStorage.js";
import {
AUTO_BACKUP_SETTING,
AUTO_BACKUP_DATA_CHANGED_EVENT,
getBackupContentSignature,
getBackupFileName,
getDirectoryPermission,
getLocalDateKey,
isFileSystemAccessSupported,
normalizeAutoBackupConfig,
saveBackup
@ -22,25 +25,47 @@ export function useAutoBackup(store) {
const [busy, setBusy] = useState(false);
const [permission, setPermission] = useState("unknown");
const [error, setError] = useState("");
const [backupNeeded, setBackupNeeded] = useState(false);
const configRef = useRef(config);
const configWriteRef = useRef(Promise.resolve());
const mutationVersionRef = useRef(0);
const storeRef = useRef(store);
const runRef = useRef(false);
const startedRef = useRef(false);
const persist = useCallback(async (next) => {
const normalized = normalizeAutoBackupConfig(next);
useEffect(() => {
storeRef.current = store;
}, [store]);
const persist = useCallback(async (update) => {
const current = configRef.current;
const value = typeof update === "function" ? update(current) : { ...current, ...update };
const normalized = normalizeAutoBackupConfig(value);
configRef.current = normalized;
setConfig(normalized);
const write = configWriteRef.current.then(() => setSetting(AUTO_BACKUP_SETTING, normalized));
configWriteRef.current = write.catch(() => {});
try {
await setSetting(AUTO_BACKUP_SETTING, normalized);
await write;
} catch (persistError) {
setError(persistError?.message || "Impossible d'enregistrer les préférences de sauvegarde.");
}
return normalized;
}, []);
const generateJson = useCallback(async () => {
const snapshot = await storeRef.current.getExportSnapshot(undefined, { onError: () => {} });
if (!snapshot) throw new Error("Impossible de préparer les données à sauvegarder.");
return serializeGlobalExport(snapshot);
}, []);
useEffect(() => {
let cancelled = false;
getSetting(AUTO_BACKUP_SETTING, normalizeAutoBackupConfig()).then((value) => {
if (!cancelled) {
setConfig(normalizeAutoBackupConfig(value));
const normalized = normalizeAutoBackupConfig(value);
configRef.current = normalized;
setConfig(normalized);
setLoaded(true);
}
}).catch((loadError) => {
@ -52,25 +77,53 @@ export function useAutoBackup(store) {
return () => { cancelled = true; };
}, []);
const run = useCallback(async ({ force = false, directoryHandle = config.directoryHandle, configOverride = config } = {}) => {
useEffect(() => {
if (!loaded) return undefined;
function markBackupDirty() {
mutationVersionRef.current += 1;
if (configRef.current.hasUnsavedChanges) return;
void persist((current) => ({ ...current, hasUnsavedChanges: true }));
}
window.addEventListener(AUTO_BACKUP_DATA_CHANGED_EVENT, markBackupDirty);
return () => window.removeEventListener(AUTO_BACKUP_DATA_CHANGED_EVENT, markBackupDirty);
}, [loaded, persist]);
const run = useCallback(async ({ force = false, directoryHandle = config.directoryHandle } = {}) => {
if (!supported || !directoryHandle || runRef.current) return { ok: false, reason: "unavailable" };
runRef.current = true;
setBusy(true);
try {
let generatedContents = "";
let exportedMutationVersion = null;
const result = await saveBackup({
directoryHandle,
force,
generateJson: async () => {
const snapshot = await store.getExportSnapshot(undefined, { onError: () => {} });
if (!snapshot) throw new Error("Impossible de préparer les données à sauvegarder.");
return serializeGlobalExport(snapshot);
generatedContents = await generateJson();
exportedMutationVersion = mutationVersionRef.current;
return generatedContents;
}
});
setPermission(result.permission || await getDirectoryPermission(directoryHandle));
if (result.ok && !result.cleanupError) setError("");
if (result.ok && result.created) {
const next = { ...configOverride, directoryHandle, lastBackup: new Date().toISOString() };
await persist(next);
if (result.ok) {
setBackupNeeded(false);
const contentSignature = result.contentSignature || (generatedContents
? await getBackupContentSignature(generatedContents)
: "");
const shouldPersist = result.created || result.unchanged || Boolean(contentSignature);
const backedUpCurrentData = (result.created || result.unchanged)
&& exportedMutationVersion !== null
&& exportedMutationVersion === mutationVersionRef.current;
if (shouldPersist) {
await persist((current) => ({
...current,
directoryHandle,
lastBackup: result.created ? new Date().toISOString() : current.lastBackup,
lastBackupSignature: contentSignature || current.lastBackupSignature,
hasUnsavedChanges: backedUpCurrentData ? false : current.hasUnsavedChanges
}));
}
}
if (!result.ok) setError(result.reason === "permission" ? "" : result.error?.message || "La sauvegarde locale a échoué.");
if (result.ok && result.cleanupError) setError(result.cleanupError);
@ -82,7 +135,7 @@ export function useAutoBackup(store) {
runRef.current = false;
setBusy(false);
}
}, [config, persist, store, supported]);
}, [config, generateJson, persist, supported]);
useEffect(() => {
if (!loaded || !supported || !store.ready || !config.enabled || !config.directoryHandle || startedRef.current) return;
@ -95,25 +148,42 @@ export function useAutoBackup(store) {
void getDirectoryPermission(config.directoryHandle).then(setPermission);
}, [config.directoryHandle, loaded]);
useEffect(() => {
if (!loaded || !store.ready || !supported || !config.enabled || !config.directoryHandle
|| (permission !== "prompt" && permission !== "denied")) return undefined;
let cancelled = false;
void (async () => {
await Promise.resolve();
if (config.lastBackup && getLocalDateKey(new Date(config.lastBackup)) === getLocalDateKey()) {
if (!cancelled) setBackupNeeded(false);
return;
}
if (!cancelled) setBackupNeeded(config.hasUnsavedChanges);
})();
return () => { cancelled = true; };
}, [config.directoryHandle, config.enabled, config.hasUnsavedChanges, config.lastBackup, loaded, permission, store.ready, supported]);
const chooseDirectory = useCallback(async () => {
if (!supported) return false;
try {
const directoryHandle = await window.showDirectoryPicker({ mode: "readwrite" });
const next = await persist({ ...config, directoryHandle });
const next = await persist({ directoryHandle });
setPermission("granted");
if (next.enabled) void run({ directoryHandle, configOverride: next });
setBackupNeeded(false);
if (next.enabled) void run({ directoryHandle });
return true;
} catch (chooseError) {
if (chooseError?.name !== "AbortError") setError(chooseError?.message || "Impossible de choisir ce dossier.");
return false;
}
}, [config, persist, run, supported]);
}, [persist, run, supported]);
const requestPermission = useCallback(async () => {
if (!config.directoryHandle?.requestPermission) return false;
try {
const result = await config.directoryHandle.requestPermission({ mode: "readwrite" });
setPermission(result);
if (result === "granted") setBackupNeeded(false);
if (result === "granted" && config.enabled) void run();
return result === "granted";
} catch (permissionError) {
@ -123,16 +193,18 @@ export function useAutoBackup(store) {
}, [config.directoryHandle, config.enabled, run]);
const setEnabled = useCallback(async (enabled) => {
const next = await persist({ ...config, enabled });
const next = await persist({ enabled });
if (!enabled) setBackupNeeded(false);
if (enabled && next.directoryHandle) {
const currentPermission = await getDirectoryPermission(next.directoryHandle);
setPermission(currentPermission);
if (currentPermission === "granted") void run({ configOverride: next });
if (currentPermission === "granted") void run({ directoryHandle: next.directoryHandle });
}
}, [config, persist, run]);
}, [persist, run]);
return {
supported,
backupNeeded,
config,
busy,
permission,

View file

@ -25,6 +25,7 @@ import { applyModuleDataValue, removeModuleDataKeys } from "./optimisticModuleDa
import { createPersistenceQueue } from "./persistenceQueue.js";
import { createQuotaRefresh } from "./quotaRefresh.js";
import { DEFERRED_MODULE_DATA, getDeferredModuleKeys, mergeLoadedModuleData, splitModuleData } from "./deferredModuleData.js";
import { AUTO_BACKUP_DATA_CHANGED_EVENT } from "./autoBackup.js";
function normalizeToolboxes(toolboxes) {
return compactToolboxesForStorage(toolboxes).map(normalizeToolbox).filter(Boolean);
@ -102,10 +103,16 @@ export function useIndexedToolboxes(onError) {
setLoadAttempt((attempt) => attempt + 1);
}
function enqueue(apply, persist) {
function enqueue(apply, persist, { marksBackupDirty = true } = {}) {
if (!writableRef.current) return Promise.resolve(false);
const result = queue.enqueue(apply, persist, reportStorageError);
void result.then((success) => { if (success) refreshQuota(); });
void result.then((success) => {
if (!success) return;
refreshQuota();
if (marksBackupDirty && typeof window !== "undefined") {
window.dispatchEvent(new window.Event(AUTO_BACKUP_DATA_CHANGED_EVENT));
}
});
return result;
}
@ -209,7 +216,8 @@ export function useIndexedToolboxes(onError) {
const toastPosition = position === "left" ? "left" : "right";
enqueue(
(current) => ({ ...current, toastPosition }),
() => dbSetSetting("toastPosition", toastPosition)
() => dbSetSetting("toastPosition", toastPosition),
{ marksBackupDirty: false }
);
}