Improve local backup permissions and change tracking
All checks were successful
Deploy Sokko G / deploy (push) Successful in 19s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 19s
This commit is contained in:
parent
34e542be28
commit
ee823d2c37
13 changed files with 372 additions and 37 deletions
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue