feat: load + debounced persist via tauri-plugin-store
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
92
src/lib/persist.ts
Normal file
92
src/lib/persist.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { load, type Store } from "@tauri-apps/plugin-store";
|
||||||
|
import { STORE_FILE, STORE_KEY, type OverlayPersistedState } from "../types/overlay";
|
||||||
|
import { useOverlayStore } from "./store";
|
||||||
|
import { debounce } from "./debounce";
|
||||||
|
|
||||||
|
const DEFAULTS: OverlayPersistedState = {
|
||||||
|
url: "",
|
||||||
|
opacity: 1.0,
|
||||||
|
alwaysOnTop: true,
|
||||||
|
clickThrough: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let storePromise: Promise<Store> | null = null;
|
||||||
|
function getStore(): Promise<Store> {
|
||||||
|
if (!storePromise) {
|
||||||
|
storePromise = load(STORE_FILE, { defaults: {}, autoSave: false });
|
||||||
|
}
|
||||||
|
return storePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPersistedShape(v: unknown): v is OverlayPersistedState {
|
||||||
|
if (!v || typeof v !== "object") return false;
|
||||||
|
const o = v as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof o.url === "string" &&
|
||||||
|
typeof o.opacity === "number" &&
|
||||||
|
typeof o.alwaysOnTop === "boolean" &&
|
||||||
|
typeof o.clickThrough === "boolean"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load persisted state from disk and hydrate the Zustand store. Safe on first run. */
|
||||||
|
export async function hydrateFromDisk(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const s = await getStore();
|
||||||
|
const raw = await s.get<unknown>(STORE_KEY);
|
||||||
|
const value = isPersistedShape(raw) ? raw : DEFAULTS;
|
||||||
|
useOverlayStore.getState().hydrate(value);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Failed to load persisted state; using defaults", err);
|
||||||
|
useOverlayStore.getState().hydrate(DEFAULTS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We persist on a 300ms debounce. The mirror below tracks "latest known
|
||||||
|
// pending state" so a synchronous flush at beforeunload time has something
|
||||||
|
// to issue — even though the actual write is unavoidably async.
|
||||||
|
let latestPending: OverlayPersistedState | null = null;
|
||||||
|
|
||||||
|
async function writeNow(state: OverlayPersistedState): Promise<void> {
|
||||||
|
try {
|
||||||
|
const s = await getStore();
|
||||||
|
await s.set(STORE_KEY, state);
|
||||||
|
await s.save();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Failed to persist state", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeDebounced = debounce((state: OverlayPersistedState): void => {
|
||||||
|
latestPending = null;
|
||||||
|
void writeNow(state);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
/** Subscribe to store changes and persist the four user-pref fields. */
|
||||||
|
export function startPersistSubscription(): () => void {
|
||||||
|
return useOverlayStore.subscribe((s) => {
|
||||||
|
const snapshot: OverlayPersistedState = {
|
||||||
|
url: s.url,
|
||||||
|
opacity: s.opacity,
|
||||||
|
alwaysOnTop: s.alwaysOnTop,
|
||||||
|
clickThrough: s.clickThrough,
|
||||||
|
};
|
||||||
|
latestPending = snapshot;
|
||||||
|
writeDebounced(snapshot);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort synchronous flush for beforeunload. The actual disk write is
|
||||||
|
* unavoidably async — we fire-and-forget it. If the window is destroyed
|
||||||
|
* before the write lands, the prior debounce-tick's value (at most 300ms
|
||||||
|
* stale) will be loaded next session. That bounded staleness is the
|
||||||
|
* accepted trade-off for a Phase 1 utility app.
|
||||||
|
*/
|
||||||
|
export function flushPendingPersistSync(): void {
|
||||||
|
if (latestPending === null) return;
|
||||||
|
const snapshot = latestPending;
|
||||||
|
latestPending = null;
|
||||||
|
writeDebounced.cancel();
|
||||||
|
void writeNow(snapshot);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user