A failed settings read (expired Authelia session -> synthetic 401, network blip, HTML login page) left the store on its defaults, and the next settings change PUT them over ui-state.json — wiping the whole prompt-shortcut config. The same path could also push a stale pre-July localStorage copy. - serverStorage: writes are held until the server value was actually read; failed reads are retried on focus/online/30s via retryHydration(store); the localStorage bridge only runs on a confirmed-empty server. - ui_state.py: daily ui-state.json.bak-YYYY-MM-DD snapshot, 14 kept. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
91 lines
3.2 KiB
Python
91 lines
3.2 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.
|
|
|
|
Before the first write of each day the current file is copied to
|
|
``ui-state.json.bak-YYYY-MM-DD`` (the last ``BACKUP_DAYS`` kept): a client that
|
|
ever persists its defaults over the real config — as happened once to a whole
|
|
prompt-shortcut set — then costs a restore, not the config.
|
|
"""
|
|
|
|
import datetime
|
|
import shutil
|
|
|
|
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}$")
|
|
|
|
BACKUP_DAYS = 14
|
|
|
|
|
|
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 _backup(self) -> None:
|
|
"""Daily snapshot of the file as it was before today's first write."""
|
|
bak = self.path.with_name(f"{self.path.name}.bak-{datetime.date.today():%Y-%m-%d}")
|
|
if bak.exists() or not self.path.exists():
|
|
return
|
|
try:
|
|
shutil.copy2(self.path, bak)
|
|
for old in sorted(self.path.parent.glob(f"{self.path.name}.bak-*"))[:-BACKUP_DAYS]:
|
|
old.unlink(missing_ok=True)
|
|
except OSError:
|
|
pass
|
|
|
|
def _save(self) -> None:
|
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
self._backup()
|
|
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)
|