Skip to content

Commit 807ea39

Browse files
committed
feat(gui): persist theme and language preferences via backend
pywebview's http_server binds a random port each launch, changing the page origin and making per-origin localStorage unreliable for cross-session persistence. Store presentational preferences (theme, locale) in a dedicated gui-preferences.json next to config.json, authoritative across restarts. - Add plexmuxy_gui/preferences.py with load/save helpers: default fallback, value validation (theme/locale allowlists), partial merge, and atomic write - Expose get_preferences/save_preferences on PlexMuxyApi and register them in EXPOSED_API_METHODS - Sync preferences from backend on startup and persist on theme/locale change in app.js (best-effort; localStorage kept as an optional cache) - Add tests covering defaults, round-trip, partial merge, and invalid values
1 parent 85a289b commit 807ea39

5 files changed

Lines changed: 174 additions & 1 deletion

File tree

plexmuxy_gui/api.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
from plexmuxy.update_check import check_for_updates
5959

6060
from .notifications import NativeNotifier
61+
from .preferences import load_preferences, save_preferences
6162

6263
DEPENDENCY_RESOLVERS: dict[str, Callable[[str], DependencyResolution]] = {
6364
"mkvmerge": resolve_mkvmerge,
@@ -307,6 +308,12 @@ def run() -> dict[str, Any]:
307308

308309
return self.guarded(run)
309310

311+
def get_preferences(self) -> dict[str, Any]:
312+
return self.guarded(lambda: self.ok(load_preferences()))
313+
314+
def save_preferences(self, payload: dict[str, Any]) -> dict[str, Any]:
315+
return self.guarded(lambda: self.ok(save_preferences(payload if isinstance(payload, dict) else {})))
316+
310317
def init_config(self, force: bool = False) -> dict[str, Any]:
311318
def run() -> dict[str, Any]:
312319
config_path = resolve_config_path()

plexmuxy_gui/app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
"get_app_info",
6262
"get_job_report",
6363
"get_job_status",
64+
"get_preferences",
6465
"list_jobs",
6566
"load_job",
6667
"load_config",
@@ -80,6 +81,7 @@
8081
"retry_job",
8182
"retry_plex_refresh",
8283
"save_environment_settings",
84+
"save_preferences",
8385
"save_settings",
8486
"start_job",
8587
"test_notification",

plexmuxy_gui/preferences.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""Persistent GUI-only preferences (appearance theme and interface language).
2+
3+
These preferences intentionally live outside ``config.json`` because they are
4+
purely presentational choices made in the desktop shell, not muxing behaviour.
5+
6+
The GUI previously relied on ``localStorage`` for these values, but pywebview's
7+
built-in HTTP server binds to a fresh random port on every launch. Because
8+
``localStorage`` is isolated per-origin (scheme + host + port), a new port means
9+
a new origin, so the stored theme/language could not be read back and the UI
10+
always fell back to "System". Persisting through the Python backend keeps the
11+
choice stable across restarts regardless of the serving port.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import json
17+
import logging
18+
import os
19+
import tempfile
20+
from pathlib import Path
21+
from typing import Any
22+
23+
from plexmuxy.config import resolve_config_path
24+
25+
PREFERENCES_FILENAME = "gui-preferences.json"
26+
27+
VALID_THEMES = ("system", "light", "dark")
28+
VALID_LOCALES = ("system", "en", "zh-CN", "zh-TW", "ru")
29+
30+
DEFAULT_PREFERENCES: dict[str, str] = {"theme": "system", "locale": "system"}
31+
32+
33+
def preferences_path() -> Path:
34+
"""Return the GUI preferences file, stored alongside the active config."""
35+
return resolve_config_path().parent / PREFERENCES_FILENAME
36+
37+
38+
def load_preferences() -> dict[str, str]:
39+
"""Load persisted GUI preferences, falling back to safe defaults."""
40+
path = preferences_path()
41+
if not path.exists():
42+
return dict(DEFAULT_PREFERENCES)
43+
try:
44+
raw = json.loads(path.read_text(encoding="utf-8"))
45+
except (OSError, json.JSONDecodeError):
46+
logging.debug("Could not read GUI preferences at %s", path, exc_info=True)
47+
return dict(DEFAULT_PREFERENCES)
48+
if not isinstance(raw, dict):
49+
return dict(DEFAULT_PREFERENCES)
50+
theme = raw.get("theme")
51+
locale = raw.get("locale")
52+
return {
53+
"theme": theme if theme in VALID_THEMES else DEFAULT_PREFERENCES["theme"],
54+
"locale": locale if locale in VALID_LOCALES else DEFAULT_PREFERENCES["locale"],
55+
}
56+
57+
58+
def save_preferences(payload: dict[str, Any]) -> dict[str, str]:
59+
"""Merge and atomically persist GUI preferences, returning the stored values."""
60+
if not isinstance(payload, dict):
61+
raise ValueError("Preferences payload must be an object")
62+
63+
current = load_preferences()
64+
if "theme" in payload:
65+
theme = payload["theme"]
66+
if theme not in VALID_THEMES:
67+
raise ValueError(f"theme must be one of: {', '.join(VALID_THEMES)}")
68+
current["theme"] = theme
69+
if "locale" in payload:
70+
locale = payload["locale"]
71+
if locale not in VALID_LOCALES:
72+
raise ValueError(f"locale must be one of: {', '.join(VALID_LOCALES)}")
73+
current["locale"] = locale
74+
75+
target = preferences_path()
76+
target.parent.mkdir(parents=True, exist_ok=True)
77+
fd, temp_name = tempfile.mkstemp(prefix=f".{target.name}.", suffix=".tmp", dir=target.parent)
78+
try:
79+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as output:
80+
json.dump(current, output, indent=2, ensure_ascii=False)
81+
output.write("\n")
82+
output.flush()
83+
os.fsync(output.fileno())
84+
os.replace(temp_name, target)
85+
except Exception:
86+
try:
87+
os.unlink(temp_name)
88+
except OSError:
89+
pass
90+
raise
91+
return current

plexmuxy_gui/static/app.js

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ window.addEventListener("pywebviewready", async () => {
2424
bindEvents();
2525
initializeCustomSelects();
2626
initializeTheme();
27+
await syncPreferencesFromBackend();
2728
initializeNavigation();
2829
await initialize();
2930
});
@@ -130,11 +131,40 @@ async function initializeLocaleControls() {
130131
}
131132

132133
async function chooseLocale(event) {
133-
await window.PlexMuxyI18n?.setLocale(event.currentTarget.dataset.localeMode);
134+
const mode = event.currentTarget.dataset.localeMode;
135+
await window.PlexMuxyI18n?.setLocale(mode);
136+
persistPreference({ locale: mode });
134137
const menu = $("language-menu");
135138
if (menu) menu.open = false;
136139
}
137140

141+
// Persist appearance/language choices through the Python backend so they survive
142+
// restarts. pywebview's http_server binds a random port each launch, which
143+
// changes the page origin and makes localStorage (isolated per-origin)
144+
// unreliable for cross-session persistence; the backend file is authoritative.
145+
function persistPreference(payload) {
146+
if (!window.pywebview?.api?.save_preferences) return;
147+
callApi("save_preferences", payload).catch(() => { /* Preference persistence is best-effort. */ });
148+
}
149+
150+
async function syncPreferencesFromBackend() {
151+
if (!window.pywebview?.api?.get_preferences) return;
152+
let preferences;
153+
try { preferences = await callApi("get_preferences"); }
154+
catch (_) { return; }
155+
if (!preferences) return;
156+
157+
if (["system", "light", "dark"].includes(preferences.theme) && preferences.theme !== state.themeMode) {
158+
applyTheme(preferences.theme, false);
159+
}
160+
try { localStorage.setItem(THEME_STORAGE_KEY, state.themeMode); } catch (_) { /* Cache is optional. */ }
161+
162+
const currentLocaleMode = window.PlexMuxyI18n?.getMode();
163+
if (window.PlexMuxyI18n && ["system", "en", "zh-CN", "zh-TW", "ru"].includes(preferences.locale) && preferences.locale !== currentLocaleMode) {
164+
await window.PlexMuxyI18n.setLocale(preferences.locale);
165+
}
166+
}
167+
138168
function syncLocaleControls() {
139169
if (!window.PlexMuxyI18n) return;
140170
const mode = window.PlexMuxyI18n.getMode();
@@ -353,6 +383,7 @@ function applyTheme(mode, userInitiated) {
353383
document.documentElement.classList.add("theme-transition");
354384
window.setTimeout(() => document.documentElement.classList.remove("theme-transition"), 240);
355385
try { localStorage.setItem(THEME_STORAGE_KEY, mode); } catch (_) { /* Keep the in-memory choice. */ }
386+
persistPreference({ theme: mode });
356387
}
357388
document.documentElement.dataset.themeMode = mode;
358389
document.documentElement.dataset.theme = resolved;

tests/test_gui_api.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,48 @@ def test_save_settings_persists_font_subset_mode(monkeypatch, tmp_path):
264264
assert response["data"]["font"]["mode"] == "subset"
265265

266266

267+
def test_get_preferences_returns_defaults_when_missing(monkeypatch, tmp_path):
268+
monkeypatch.setattr("plexmuxy_gui.preferences.resolve_config_path", lambda path=None: tmp_path / "config.json")
269+
api = PlexMuxyApi()
270+
271+
response = api.get_preferences()
272+
273+
assert response["ok"] is True
274+
assert response["data"] == {"theme": "system", "locale": "system"}
275+
276+
277+
def test_save_preferences_persists_and_round_trips(monkeypatch, tmp_path):
278+
monkeypatch.setattr("plexmuxy_gui.preferences.resolve_config_path", lambda path=None: tmp_path / "config.json")
279+
api = PlexMuxyApi()
280+
281+
saved = api.save_preferences({"theme": "dark", "locale": "zh-CN"})
282+
283+
assert saved["ok"] is True
284+
assert saved["data"] == {"theme": "dark", "locale": "zh-CN"}
285+
assert (tmp_path / "gui-preferences.json").exists()
286+
assert api.get_preferences()["data"] == {"theme": "dark", "locale": "zh-CN"}
287+
288+
289+
def test_save_preferences_merges_partial_updates(monkeypatch, tmp_path):
290+
monkeypatch.setattr("plexmuxy_gui.preferences.resolve_config_path", lambda path=None: tmp_path / "config.json")
291+
api = PlexMuxyApi()
292+
293+
api.save_preferences({"theme": "light", "locale": "ru"})
294+
merged = api.save_preferences({"theme": "dark"})
295+
296+
assert merged["data"] == {"theme": "dark", "locale": "ru"}
297+
298+
299+
def test_save_preferences_rejects_invalid_values(monkeypatch, tmp_path):
300+
monkeypatch.setattr("plexmuxy_gui.preferences.resolve_config_path", lambda path=None: tmp_path / "config.json")
301+
api = PlexMuxyApi()
302+
303+
response = api.save_preferences({"theme": "neon"})
304+
305+
assert response["ok"] is False
306+
assert not (tmp_path / "gui-preferences.json").exists()
307+
308+
267309
def test_choose_dependency_uses_open_picker_and_validates_allowlist(tmp_path, monkeypatch):
268310
executable = tmp_path / "ffmpeg.exe"
269311
executable.write_bytes(b"stub")

0 commit comments

Comments
 (0)