* fix: harden sync overwrite recovery * refactor: separate backup retention settings * refactor: align backup retention controls * refactor: simplify backup retention card * fix: address PR #720 deep-review findings - Close the cross-window restore race by holding a time-bounded barrier in localStorage during every destructive apply; useAutoSync skips pushes while it's set, preventing a pre-restore snapshot from clobbering just-restored cloud data. - Round-trip startup three-way merges so merged-in local additions actually reach the cloud instead of living only on the device that ran the merge until the next edit. - Upgrade sync signatures from a 64-char ciphertext prefix to full SHA-256 (v3), closing the tail-mutation replay weakness. - Harden the vault-backup IPC: payload size cap, enum-validated reason, sanitized version strings, strict maxCount, concurrent-call mutex, monotonic createdAt to avoid same-ms ordering ties. - Extract the anchor-change decision into a pure module with unit tests covering no-anchor, resource-id drift, and signature mismatch paths. - Capture the protective backup from the pre-apply closure snapshot so it reflects what's being replaced rather than what was imported. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR #720 follow-up review findings Make protective backup abort-on-failure (was best-effort console.error), preserve nested syncedAt in fingerprint, use UTF-8 byte length for size guard, throw on conflict-inspect failure so stale uploads can't leak through, treat unreadable remote as changed, canonical-JSON signature meta, and hold the version stamp on transient backup failures so the retry path still fires. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address second-pass review findings on PR #720 - Hold version-change stamp when payload is non-meaningful (covers the startup vault-rehydrate race where a transient empty snapshot would permanently skip the upgrade backup). - readBackupRecord stat-checks before readFile so an oversized file in the backup dir cannot OOM the renderer on enumeration. - Reject maxBackups input outside 1..100 instead of silently clamping (matches the i18n error copy and the main-process sanitizer bound). - Wrap USE_LOCAL conflict-resolution push in withRestoreBarrier so a concurrent auto-sync in another window cannot interleave. - sha256Hex throws SyncSignatureUnavailableError on missing WebCrypto subtle; createSyncedFileSignature returns null, forcing the unreadable-remote → three-way-merge path instead of a weak length-only pseudo-signature. - Document that array order in normalizePayloadForHash is an invariant enforced by producers, not the hash function. - Drop three-way-merge completion logs from console.log to console.info. - Comment the implicit restore → store-listener refresh chain so future refactors don't silently break the UI reload path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address third-pass review findings on PR #720 Resolves I-3 through I-8 and related cleanup items identified in the deep review. Highlights: - replace setTimeout(0) post-merge round-trip with a direct syncAllProviders call using the already-computed merged payload, removing the React-commit race - resolve the empty-vault confirmation promise on unmount so a mid-dialog window teardown doesn't leak the resolver - retry the version-change backup as hosts/keys hydrate, instead of latching on the first (possibly empty) snapshot - heartbeat-refresh the cross-window restore barrier so long applies cannot expose a post-60s window to concurrent auto-sync - add a diagnostic warning when connected providers hold divergent bases (multi-account configurations) - surface a user-visible "Sync paused" toast when startup inspect fails, replacing the previous silent gate-open - tie-break backup list sort by id when createdAt collides - extract applyProtectedSyncPayload so the main and settings windows cannot drift on restore-barrier / protective-backup handling Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address deep-review findings on PR #720 Deep re-review surfaced six Important issues that survived the prior four review rounds. All are hardened here: - I1: fsync the protective backup file AND its directory before the rename completes, so a system crash between backup creation and the restore it guards cannot leave a torn/zero-length safety net. - I3: persist an apply-in-progress sentinel across the non-atomic localStorage writes in applySyncPayload. A crash mid-apply now surfaces on the next startup (toast + refuse auto-push) instead of silently pushing the half-applied state over an intact cloud copy. - I2: only open the auto-sync gate (remoteCheckDoneRef) when the startup inspect validated cleanly. Add a bounded exponential-backoff retry so a transient inspect failure self-heals instead of wedging auto-sync until restart. - I5: save the sync base BEFORE advancing the per-provider anchor inside uploadToProvider. A renderer crash between the two writes now degrades to "stale anchor forces re-inspect on next run," which re-merges against the fresh base — eliminating the silent base-drift window where a 3rd-device race could misclassify entries. - I6: main process broadcasts a vaultBackups:changed IPC event on every mutation; useLocalVaultBackups subscribes so protective backups created from the main window show up in the Settings backup list without manual refresh. - I4: update PR description + code comment to match the actual (safer) design: auto-sync gate opens on vault init, with hasMeaningfulSyncData + restore barrier preventing empty-push; the version-change backup is best-effort and retries as data hydrates. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: serialize startup checkRemoteVersion and stabilize its deps Re-review flagged that checkRemoteVersion's useCallback depended on `config` — a fresh object literal from App.tsx on every render — so the retry effect restarted with attempt=0 on every vault edit and could spawn overlapping in-flight inspect+apply runs. Two concurrent commitRemoteInspection + onApplyPayload calls could race on the apply-in-progress sentinel around interleaved writes. Route `buildPayload`, `config.onApplyPayload`, and `config.startupReady` through refs so checkRemoteVersion's identity no longer churns with unrelated App state. Add an in-flight guard that returns early when a previous invocation is still awaiting the network, closing the same-window re-entry gap that withRestoreBarrier intentionally doesn't cover. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: release in-flight lock on no-connected-provider early return Third-pass review caught that `checkRemoteInFlightRef` was acquired before the `!connectedProvider` check, so that early return leaked the lock and every subsequent retry-timer tick silently no-op'd. Move the acquisition past the early return so the only path that takes the lock reaches the finally-release. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
96 lines
2.9 KiB
TypeScript
96 lines
2.9 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
|
import {
|
|
type LocalVaultBackupPreview,
|
|
getLocalVaultBackupCapabilities,
|
|
getLocalVaultBackupMaxCount,
|
|
listLocalVaultBackups,
|
|
openLocalVaultBackupDir,
|
|
readLocalVaultBackup,
|
|
setLocalVaultBackupMaxCount,
|
|
trimLocalVaultBackups,
|
|
} from '../localVaultBackups';
|
|
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
|
|
|
export function useLocalVaultBackups() {
|
|
const [backups, setBackups] = useState<LocalVaultBackupPreview[]>([]);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [maxBackups, setMaxBackupsState] = useState(() => getLocalVaultBackupMaxCount());
|
|
// `null` while we're still asking the main process. The UI should treat
|
|
// `null` as "unknown, don't render restore controls yet" so we never expose
|
|
// a destructive action that might later be disabled.
|
|
const [encryptionAvailable, setEncryptionAvailable] = useState<boolean | null>(null);
|
|
|
|
const refreshBackups = useCallback(async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const next = await listLocalVaultBackups();
|
|
setBackups(next);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
void (async () => {
|
|
try {
|
|
const caps = await getLocalVaultBackupCapabilities();
|
|
if (!cancelled) {
|
|
setEncryptionAvailable(caps.encryptionAvailable);
|
|
}
|
|
} catch {
|
|
if (!cancelled) {
|
|
setEncryptionAvailable(false);
|
|
}
|
|
}
|
|
})();
|
|
void refreshBackups();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [refreshBackups]);
|
|
|
|
// Cross-window live refresh: the main process broadcasts when any
|
|
// renderer's createBackup or trimBackups actually mutated the on-disk
|
|
// set. Without this subscription, a protective backup written by the
|
|
// main window wouldn't show up in the Settings window's list until
|
|
// the user manually navigated away and back, silently under-reporting
|
|
// the most recent recovery points.
|
|
useEffect(() => {
|
|
const bridge = netcattyBridge.get();
|
|
const subscribe = bridge?.onVaultBackupsChanged;
|
|
if (typeof subscribe !== 'function') return undefined;
|
|
const unsubscribe = subscribe(() => {
|
|
void refreshBackups();
|
|
});
|
|
return () => {
|
|
try { unsubscribe?.(); } catch { /* ignore */ }
|
|
};
|
|
}, [refreshBackups]);
|
|
|
|
const updateMaxBackups = useCallback(async (value: number) => {
|
|
const sanitized = setLocalVaultBackupMaxCount(value);
|
|
setMaxBackupsState(sanitized);
|
|
await trimLocalVaultBackups(sanitized);
|
|
await refreshBackups();
|
|
return sanitized;
|
|
}, [refreshBackups]);
|
|
|
|
const openBackupDirectory = useCallback(async () => {
|
|
await openLocalVaultBackupDir();
|
|
}, []);
|
|
|
|
return {
|
|
backups,
|
|
isLoading,
|
|
maxBackups,
|
|
encryptionAvailable,
|
|
refreshBackups,
|
|
readBackup: readLocalVaultBackup,
|
|
setMaxBackups: updateMaxBackups,
|
|
openBackupDirectory,
|
|
};
|
|
}
|
|
|
|
export default useLocalVaultBackups;
|