Move the two Zustand `persist` stores off browser localStorage and onto a
server-side file (`/data/ui-state.json`) so the config follows the user across
browsers and devices instead of being trapped per-browser.
- backend: `ui_state.py` — a dumb key→string JSON-file store (key = persist
store name, value = its opaque serialized blob), exposed via
`GET/PUT/DELETE /api/ui-state/{key}` (atomic temp-file rewrite, key validated).
- frontend: `lib/serverStorage.ts` — a `StateStorage` backend that reads/writes
those endpoints; failures are swallowed so a network blip can't white-screen
the UI. Includes a one-time self-cleaning migration that seeds the server file
from any leftover localStorage value then clears it.
- point `useSettings` (ai-agent-settings) and the main store (ai-agent-store —
notifKnown/notifNew/notifSeeded + drafts/sidebar) at serverStorage.
- hydration is now async: gate `syncNotifs` on `useStore.persist.hasHydrated()`
(new `lib/useHydrated`) so the notification baseline isn't seeded from empty
defaults before the server value lands.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
68 lines
2.4 KiB
Python
68 lines
2.4 KiB
Python
"""
|
|
Server-side persistence for the PWA's small client state — the Settings panel
|
|
config (theme, currency, subscription, prompt shortcuts) and the notification
|
|
feed's seen/new bookkeeping — that used to live in the browser's ``localStorage``.
|
|
|
|
Moving it into a file (``/data/ui-state.json``) makes the config follow the user
|
|
across browsers and devices instead of being trapped per-browser, and survives a
|
|
localStorage clear. The store is a dumb key→string map: each key is a Zustand
|
|
``persist`` store name (``ai-agent-settings`` / ``ai-agent-store``) and the value
|
|
is that store's already-serialized JSON blob — the backend never inspects it, it
|
|
just holds the opaque string the frontend hands it.
|
|
|
|
Single JSON file, whole-map rewrite on every set (atomic via a temp file + rename),
|
|
mirroring ``meta.py``. The map is tiny, so this is cheap.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import re
|
|
import threading
|
|
|
|
# Keys are Zustand persist store names — restrict to a safe, fixed shape so a
|
|
# bogus key can never escape the single JSON blob or collide with anything else.
|
|
_KEY_RE = re.compile(r"^[A-Za-z0-9_.-]{1,128}$")
|
|
|
|
|
|
def valid_key(key: str) -> bool:
|
|
return bool(_KEY_RE.match(key))
|
|
|
|
|
|
class UiStateStore:
|
|
def __init__(self, path: str):
|
|
self.path = pathlib.Path(path)
|
|
self._lock = threading.Lock()
|
|
self._data: dict[str, str] = {}
|
|
self._load()
|
|
|
|
def _load(self) -> None:
|
|
try:
|
|
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
# Keep only string values; drop anything unexpected on the floor.
|
|
self._data = {k: v for k, v in data.items() if isinstance(v, str)}
|
|
except (OSError, ValueError):
|
|
self._data = {}
|
|
|
|
def get(self, key: str) -> str | None:
|
|
with self._lock:
|
|
return self._data.get(key)
|
|
|
|
def set(self, key: str, value: str) -> None:
|
|
with self._lock:
|
|
self._data[key] = value
|
|
self._save()
|
|
|
|
def delete(self, key: str) -> None:
|
|
with self._lock:
|
|
if key in self._data:
|
|
del self._data[key]
|
|
self._save()
|
|
|
|
def _save(self) -> None:
|
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = self.path.with_suffix(".json.tmp")
|
|
tmp.write_text(json.dumps(self._data, indent=2, sort_keys=True),
|
|
encoding="utf-8")
|
|
os.replace(tmp, self.path)
|