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

@ -1823,13 +1823,22 @@
"Google Chrome",
"Microsoft Edge"
],
"permissionTitle": "Fonctionnement de l’autorisation",
"permissionDescription": "Opera GX peut redemander l’accès après la fermeture du navigateur. Pour des raisons de sécurité, la popup système ne peut apparaître qu’après un clic de votre part.",
"permissionSteps": [
"Si l’accès est toujours accordé, la vérification et la sauvegarde éventuelle restent silencieuses.",
"L’application mémorise localement les modifications confirmées. Si l’accès a expiré, une alerte vous propose alors de les sauvegarder.",
"Cliquez sur « Sauvegarder » dans cette alerte : le navigateur redemande l’accès, puis l’application vérifie le dossier avant toute écriture."
],
"reminderMessage": "Des modifications ont été détectées dans vos toolboxes. Voulez-vous créer une sauvegarde locale ?",
"reminderAction": "Sauvegarder",
"folderLabel": "Dossier configuré :",
"enabled": "Activer la sauvegarde automatique",
"toggleHelp": "Une sauvegarde automatique est créée au maximum une fois par jour lorsque l’application est ouverte.",
"chooseFolder": "Choisir un dossier",
"changeFolder": "Changer de dossier",
"reauthorize": "Réautoriser l’accès",
"permissionRequired": "L’accès au dossier doit être réautorisé pour reprendre les sauvegardes. Utilisez « Réautoriser l’accès » ci-dessous.",
"reauthorize": "Autoriser et vérifier",
"permissionRequired": "L’accès au dossier doit être redonné pour effectuer les vérifications et reprendre les sauvegardes.",
"saveNow": "Sauvegarder maintenant",
"noFolder": "Aucun dossier",
"never": "Jamais",

View file

@ -85,6 +85,8 @@ export function AppOverlays({
id={notification.id}
message={notification.message}
durationMs={notification.durationMs}
actionLabel={notification.actionLabel}
onAction={notification.onAction}
onClose={dismissNotification}
/>
))}

View file

@ -24,15 +24,20 @@ export function ConfirmModal({ title, message, confirmLabel = "Confirmer", cance
);
}
export function NotificationToast({ id, message, durationMs = 120000, onClose }) {
export function NotificationToast({ id, message, durationMs = 120000, actionLabel = "", onAction, onClose }) {
useEffect(() => {
if (durationMs <= 0) return undefined;
const timeoutId = window.setTimeout(() => onClose(id), durationMs);
return () => window.clearTimeout(timeoutId);
}, [durationMs, id, onClose]);
return (
<div className="notification-toast nds-toast" role="status">
<div className={`notification-toast nds-toast${actionLabel && onAction ? " has-action" : ""}`} role="status">
<span>{message}</span>
{actionLabel && onAction && <button className="nds-button notification-toast-action" data-size="xs" data-variant="primary" type="button" onClick={() => {
onAction();
onClose(id);
}}>{actionLabel}</button>}
<button className="nds-icon-button" data-size="xs" type="button" onClick={() => onClose(id)} aria-label="Fermer l'alerte" title="Fermer">
<Icon name="close" />
</button>
@ -103,6 +108,11 @@ export function AutoBackupModal({ backup, content, onClose }) {
<strong>{content.compatibleLabel}</strong>
<ul>{content.browsers.map((browser) => <li key={browser}>{browser}</li>)}</ul>
</div>
<div className="auto-backup-permission-guide">
<strong>{content.permissionTitle}</strong>
<p>{content.permissionDescription}</p>
<ol>{content.permissionSteps.map((step) => <li key={step}>{step}</li>)}</ol>
</div>
{!backup.supported ? <p className="nds-inline-notice" data-variant="warning" role="status">{content.unsupported}</p> : (
<>
<div className="auto-backup-folder">
@ -121,7 +131,7 @@ export function AutoBackupModal({ backup, content, onClose }) {
{backup.config.directoryHandle && (backup.permission === "prompt" || backup.permission === "denied") && <p className="nds-inline-notice" data-variant="warning" role="status">{content.permissionRequired}</p>}
{backup.error && <p className="nds-inline-notice" data-variant="danger" role="alert">{backup.error}</p>}
<footer className="nds-modal-footer auto-backup-modal-footer">
{backup.config.directoryHandle && (backup.permission === "prompt" || backup.permission === "denied") && <button className="nds-button" data-size="sm" type="button" disabled={backup.busy} onClick={() => void backup.requestPermission()}>{content.reauthorize}</button>}
{backup.config.directoryHandle && (backup.permission === "prompt" || backup.permission === "denied") && <button className="nds-button" data-size="sm" data-variant="primary" type="button" disabled={backup.busy} onClick={() => void backup.requestPermission()}>{content.reauthorize}</button>}
<button className="nds-button" data-size="sm" data-variant="primary" type="button" disabled={backup.busy || !backup.config.directoryHandle || backup.permission !== "granted"} onClick={() => void backup.saveNow()}>{content.saveNow}</button>
</footer>
</>

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 }
);
}

View file

@ -1,6 +1,6 @@
// Rôle : point d'entrée React, assemble données, routes, shell et overlays.
/* eslint-disable react-refresh/only-export-components -- point d'entrée Vite sans export de composant. */
import { lazy, StrictMode, Suspense, useCallback, useEffect, useState } from "react";
import { lazy, StrictMode, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import "./styles/main.scss";
import { AppOverlays } from "./components/AppOverlays.jsx";
@ -24,6 +24,7 @@ const DEFAULT_NOTIFICATION_DURATION_MS = 120000;
const SHORT_NOTIFICATION_DURATION_MS = 5000;
function getNotificationDuration(message, options = {}) {
if (options.persistent) return 0;
if (Number.isFinite(options.durationMs)) return Math.max(1000, Number(options.durationMs));
const normalized = String(message || "").toLowerCase();
return /\bcopi[ée]|\bcoll[ée]|presse-papiers/.test(normalized) ? SHORT_NOTIFICATION_DURATION_MS : DEFAULT_NOTIFICATION_DURATION_MS;
@ -42,16 +43,26 @@ function App() {
const [pendingTutorialToolboxId, setPendingTutorialToolboxId] = useState("");
const [notifications, setNotifications] = useState([]);
const [storageError, setStorageError] = useState("");
const backupReminderIdRef = useRef("");
const handleStorageError = useCallback((message) => setStorageError(message), []);
const store = useIndexedToolboxes(handleStorageError);
const autoBackup = useAutoBackup(store);
const { backupNeeded, requestPermission: requestBackupPermission } = autoBackup;
const guideChecklistSession = useGuideChecklistSession(store);
const notify = useCallback((message, options = {}) => {
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
setNotifications((current) => [
{ id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, message, durationMs: getNotificationDuration(message, options) },
{
id,
message,
durationMs: getNotificationDuration(message, options),
actionLabel: options.actionLabel,
onAction: options.onAction
},
...current
].slice(0, 5));
return id;
}, []);
const dismissNotification = useCallback((notificationId) => {
@ -67,6 +78,21 @@ function App() {
return () => window.removeEventListener("sokkog:notify", handleNotification);
}, [notify]);
useEffect(() => {
if (!backupNeeded) {
if (backupReminderIdRef.current) dismissNotification(backupReminderIdRef.current);
backupReminderIdRef.current = "";
return;
}
if (backupReminderIdRef.current || !siteContent?.toolboxes?.autoBackup) return;
const content = siteContent.toolboxes.autoBackup;
backupReminderIdRef.current = notify(content.reminderMessage, {
persistent: true,
actionLabel: content.reminderAction,
onAction: () => void requestBackupPermission()
});
}, [backupNeeded, dismissNotification, notify, requestBackupPermission, siteContent]);
const t = (key, { capitalize = false } = {}) => {
const value = mhwilds.translations[key] || String(key || "").replace(/_/g, " ");
return capitalize ? value.charAt(0).toUpperCase() + value.slice(1) : value;

View file

@ -98,6 +98,35 @@
font-size: var(--font-size-sm);
}
.auto-backup-permission-guide {
margin-bottom: var(--space-5);
padding: var(--space-4);
border: 1px solid rgba(34, 211, 238, 0.2);
border-radius: var(--radius-md);
background: rgba(34, 211, 238, 0.06);
}
.auto-backup-permission-guide strong {
color: var(--color-text-primary);
}
.auto-backup-permission-guide p {
margin: var(--space-2) 0 var(--space-3);
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
line-height: 1.5;
}
.auto-backup-permission-guide ol {
display: grid;
gap: 6px;
margin: 0;
padding-left: 20px;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
line-height: 1.45;
}
.auto-backup-folder {
display: flex;
align-items: stretch;

View file

@ -17,6 +17,14 @@
min-width: 0;
}
.notification-toast.has-action {
grid-template-columns: minmax(0, 1fr) auto 34px;
}
.notification-toast .notification-toast-action {
white-space: nowrap;
}
.notification-toast button .ui-icon {
width: 16px;
height: 16px;