diff --git a/backend/api/routers/settings.py b/backend/api/routers/settings.py index 2d11dd1b..3290cb3d 100644 --- a/backend/api/routers/settings.py +++ b/backend/api/routers/settings.py @@ -13,6 +13,7 @@ The state endpoint duplicates `/system/hf-token/state` (which lives on from __future__ import annotations import logging +import os from dataclasses import asdict from typing import Optional @@ -194,3 +195,93 @@ def get_license_acceptance(engine_id: str) -> dict: logger.exception("get_license_accepted failed for %s", eid) raise HTTPException(status_code=500, detail="Failed to read license acceptance") return {"engine_id": eid, "accepted": bool(accepted)} + + +# ── Storage: configurable models directory (#64) ────────────────────────── +# Where HuggingFace / Torch download model weights. The user's choice is +# persisted durably to the per-user env file as OMNIVOICE_CACHE_DIR, which +# main.py maps to HF_HOME / HF_HUB_CACHE / TORCH_HOME at startup. That env file +# is the *single source of truth*: PUT writes it, GET reads it back — there is +# no second store to diverge from. Takes effect on the next backend restart +# (a storage-location change can't safely move an in-use cache mid-process). +_MODELS_DIR_ENV = "OMNIVOICE_CACHE_DIR" + + +def _default_models_dir() -> str: + """huggingface_hub's default cache root, honoring XDG_CACHE_HOME on Linux + (matches HF so GET reports the *true* default the backend would use).""" + base = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache") + return os.path.join(base, "huggingface") + + +def _effective_models_dir() -> str: + return ( + os.environ.get("HF_HUB_CACHE") + or os.environ.get("HUGGINGFACE_HUB_CACHE") + or os.environ.get("HF_HOME") + or _default_models_dir() + ) + + +class _ModelsDirBody(BaseModel): + path: str = Field(default="", description="Absolute directory; empty clears → default cache") + + +@router.get("/storage/models-dir") +def get_models_dir(): + """Current models directory: the persisted choice (from the durable env + file — the same value main.py reads at startup), what's effective in this + process, and the platform default.""" + from core import user_env + + configured = user_env.get_user_env(_MODELS_DIR_ENV) or None + return { + "configured": configured, + "effective": _effective_models_dir(), + "default": _default_models_dir(), + "restart_required": False, + } + + +@router.put("/storage/models-dir") +def set_models_dir(body: _ModelsDirBody): + """Set (or clear, with an empty path) the models download directory. + + Validates the directory is writable, then writes OMNIVOICE_CACHE_DIR to the + durable per-user env file so main.py applies it on the next launch. The env + file is the only persisted store, so GET can never diverge from what was + saved. Returns restart_required=True. + """ + from core import user_env + + raw = (body.path or "").strip() + if not raw: + user_env.unset_user_env(_MODELS_DIR_ENV) + return {"configured": None, "default": _default_models_dir(), "restart_required": True} + + # Reject control characters / NUL before touching the filesystem: an + # embedded NUL makes os.makedirs raise ValueError (→ 500). This is also + # the input-validation barrier for the path before it reaches any fs call + # (the dir is user-chosen by design — this is a loopback-gated, same-user + # local file picker, not a cross-privilege boundary). + if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in raw): + raise HTTPException(status_code=400, detail="Path contains invalid control characters") + + path = os.path.abspath(os.path.expanduser(raw)) + try: + os.makedirs(path, exist_ok=True) + probe = os.path.join(path, ".omnivoice_write_test") + with open(probe, "w", encoding="utf-8") as f: + f.write("ok") + except OSError as e: + raise HTTPException(status_code=400, detail=f"Directory is not writable: {e}") from e + finally: + # Best-effort cleanup; a failed remove (concurrent process, perm change) + # must not leave the request hanging or mask the real error. + try: + os.remove(os.path.join(path, ".omnivoice_write_test")) + except OSError: + pass + + user_env.set_user_env(_MODELS_DIR_ENV, path) + return {"configured": path, "effective": _effective_models_dir(), "restart_required": True} diff --git a/backend/core/user_env.py b/backend/core/user_env.py new file mode 100644 index 00000000..9e17e695 --- /dev/null +++ b/backend/core/user_env.py @@ -0,0 +1,84 @@ +"""Durable per-user environment file (`~/.config/omnivoice/env`). + +`main.py` loads this file at startup (via dotenv) before importing torch/HF, so +values written here take effect on the next backend launch. Used by the +configurable models directory (#64): the Settings endpoint upserts +``OMNIVOICE_CACHE_DIR`` here, which main.py then maps to +``HF_HOME`` / ``HF_HUB_CACHE`` / ``TORCH_HOME``. + +Format is dotenv-style ``KEY=value`` lines. Upsert preserves other keys (e.g. a +persisted ``HF_TOKEN``) and writes the file ``0600`` (it can hold secrets). +""" +from __future__ import annotations + +import os +from typing import Optional + +USER_ENV_PATH = os.path.expanduser("~/.config/omnivoice/env") + + +def _read_lines(path: str) -> list[str]: + try: + with open(path, "r", encoding="utf-8") as f: + return f.read().splitlines() + except FileNotFoundError: + return [] + # Any *other* OSError (permission, I/O error) propagates: collapsing it to + # an empty baseline would make a subsequent upsert silently drop existing + # keys (e.g. a persisted HF_TOKEN) when it rewrites the file. + + +def _opener_0600(path: str, flags: int) -> int: + # Create the file with 0600 from the start (no world-readable window before + # a follow-up chmod) — it can hold secrets like HF_TOKEN. The mode is + # masked by umask but only ever *more* restrictive; non-POSIX platforms + # ignore the mode bits. + return os.open(path, flags, 0o600) + + +def _write_lines(path: str, lines: list[str]) -> None: + parent = os.path.dirname(path) + if parent: # bare filename (e.g. an OMNIVOICE_ENV_FILE override) has no parent + os.makedirs(parent, exist_ok=True) + body = "\n".join(lines) + if body and not body.endswith("\n"): + body += "\n" + with open(path, "w", encoding="utf-8", opener=_opener_0600) as f: + f.write(body) + try: + os.chmod(path, 0o600) # tighten an existing file that predates the opener + except OSError: + pass # best-effort; some filesystems/Windows don't support chmod + + +def get_user_env(key: str, path: Optional[str] = None) -> Optional[str]: + path = path or os.environ.get("OMNIVOICE_ENV_FILE") or USER_ENV_PATH # resolved at call time so tests can monkeypatch + prefix = f"{key}=" + for line in _read_lines(path): + if line.startswith(prefix): + return line[len(prefix):] + return None + + +def set_user_env(key: str, value: str, path: Optional[str] = None) -> None: + """Upsert ``KEY=value``, preserving all other lines.""" + path = path or os.environ.get("OMNIVOICE_ENV_FILE") or USER_ENV_PATH + prefix = f"{key}=" + lines = _read_lines(path) + replaced = False + for i, line in enumerate(lines): + if line.startswith(prefix): + lines[i] = f"{key}={value}" + replaced = True + break + if not replaced: + lines.append(f"{key}={value}") + _write_lines(path, lines) + + +def unset_user_env(key: str, path: Optional[str] = None) -> None: + """Remove ``KEY=...`` if present, preserving all other lines.""" + path = path or os.environ.get("OMNIVOICE_ENV_FILE") or USER_ENV_PATH + prefix = f"{key}=" + lines = [ln for ln in _read_lines(path) if not ln.startswith(prefix)] + _write_lines(path, lines) diff --git a/frontend/src/components/settings/StoragePanel.css b/frontend/src/components/settings/StoragePanel.css new file mode 100644 index 00000000..21958c06 --- /dev/null +++ b/frontend/src/components/settings/StoragePanel.css @@ -0,0 +1,59 @@ +.storagepanel { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 14px; + margin-bottom: 12px; + border: 1px solid var(--border, #3c3836); + border-radius: 8px; + background: var(--panel-bg, rgba(255, 255, 255, 0.02)); +} +.storagepanel__title { + display: flex; + align-items: center; + gap: 6px; + margin: 0; + font-size: 13px; + font-weight: 600; +} +.storagepanel__help { + margin: 0; + font-size: 12px; + opacity: 0.8; +} +.storagepanel__row { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} +.storagepanel__input { + flex: 1 1 280px; + min-width: 0; + padding: 6px 8px; + font-size: 12px; + font-family: var(--mono, ui-monospace, monospace); + border: 1px solid var(--border, #504945); + border-radius: 6px; + background: var(--input-bg, rgba(0, 0, 0, 0.2)); + color: inherit; +} +.storagepanel__btn { + padding: 6px 12px; + font-size: 12px; + border: 1px solid var(--border, #504945); + border-radius: 6px; + background: var(--accent, #83a598); + color: #1d2021; + cursor: pointer; +} +.storagepanel__btn:disabled { opacity: 0.5; cursor: default; } +.storagepanel__btn--ghost { background: transparent; color: inherit; } +.storagepanel__meta { margin: 0; font-size: 11px; opacity: 0.7; } +.storagepanel__meta code { font-size: 11px; } +.storagepanel__restart { margin: 0; font-size: 12px; color: var(--warn, #fabd2f); } +.storagepanel__error { + font-size: 12px; + color: var(--danger, #fb4934); + padding: 4px 0; +} diff --git a/frontend/src/components/settings/StoragePanel.jsx b/frontend/src/components/settings/StoragePanel.jsx new file mode 100644 index 00000000..ee47ae2f --- /dev/null +++ b/frontend/src/components/settings/StoragePanel.jsx @@ -0,0 +1,128 @@ +/** + * Settings → Models tab → Models directory panel (#64). + * + * Lets the user choose where model weights download (the HuggingFace / Torch + * cache). The backend persists it to the durable per-user env file as + * OMNIVOICE_CACHE_DIR, which main.py maps to HF_HOME / HF_HUB_CACHE / TORCH_HOME + * on the next launch — so changes apply after a restart. + * + * Endpoints: + * GET /api/settings/storage/models-dir + * → {configured, effective, default, restart_required} + * PUT /api/settings/storage/models-dir body {path} (empty path clears) + */ +import React, { useCallback, useEffect, useState } from 'react'; +import { HardDrive } from 'lucide-react'; +import toast from 'react-hot-toast'; +import { apiJson, apiFetch } from '../../api/client'; +import './StoragePanel.css'; + +export default function StoragePanel() { + const [configured, setConfigured] = useState(''); + const [effective, setEffective] = useState(''); + const [def, setDef] = useState(''); + const [input, setInput] = useState(''); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [restart, setRestart] = useState(false); + + const refresh = useCallback(async () => { + setLoading(true); + setError(null); + try { + const d = await apiJson('/api/settings/storage/models-dir'); + setConfigured(d?.configured || ''); + setEffective(d?.effective || ''); + setDef(d?.default || ''); + setInput(d?.configured || ''); + } catch (e) { + setError(e?.message || 'Failed to load storage settings'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { refresh(); }, [refresh]); + + const save = async (path) => { + setSaving(true); + setError(null); + try { + const res = await apiFetch('/api/settings/storage/models-dir', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path }), + }); + if (!res.ok) { + const b = await res.json().catch(() => ({})); + throw new Error(b?.detail || `HTTP ${res.status}`); + } + const b = await res.json(); + setConfigured(b?.configured || ''); + setRestart(Boolean(b?.restart_required)); + toast.success(path ? 'Models directory saved — restart to apply' : 'Reverted to default — restart to apply'); + refresh(); + } catch (e) { + setError(e?.message || 'Failed to save models directory'); + } finally { + setSaving(false); + } + }; + + return ( +
+

+ Models directory +

+ + {error &&
{error}
} + +

+ Where model weights download (the HuggingFace / Torch cache). Point this + at a larger or faster drive — useful when your system drive is small. + Changes apply on the next restart. +

+ +
+ setInput(e.target.value)} + disabled={saving || loading} + spellCheck={false} + aria-labelledby="storagepanel-heading" + aria-describedby="storagepanel-help" + data-testid="models-dir-input" + /> + + +
+ +

+ Effective now: {effective || '…'} + {configured ? <> · configured: {configured} : <> · using default} +

+ + {restart && ( +

↻ Restart OmniVoice to use the new location.

+ )} +
+ ); +} diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 0b8732ea..7f53ebf4 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -25,6 +25,7 @@ import { useAppStore } from '../store'; import ApiKeysPanel from '../components/settings/ApiKeysPanel'; import PerformancePanel from '../components/settings/PerformancePanel'; import AppearancePanel from '../components/settings/AppearancePanel'; +import StoragePanel from '../components/settings/StoragePanel'; import EngineCompatibilityMatrix from '../components/EngineCompatibilityMatrix'; import './Settings.css'; @@ -1027,7 +1028,12 @@ export default function Settings() { className="settings-tabs-ui" /> - {activeTab === 'models' && } + {activeTab === 'models' && ( + <> + + + + )} {activeTab === 'engines' && } diff --git a/tests/test_models_dir_setting.py b/tests/test_models_dir_setting.py new file mode 100644 index 00000000..ff745b66 --- /dev/null +++ b/tests/test_models_dir_setting.py @@ -0,0 +1,84 @@ +"""#64 — the configurable models-dir settings endpoints (validate + persist + +write the durable env that main.py reads at startup). + +Single source of truth: the durable per-user env file (``OMNIVOICE_CACHE_DIR``). +``main.py`` reads it at launch; the GET endpoint reads it back. There is no +second store to diverge from. +""" +from __future__ import annotations + +import os + +import fastapi +import pytest + +from core import user_env +from api.routers import settings as s + + +@pytest.fixture +def env(tmp_path, monkeypatch): + # Resolve the durable env file via a process-global override so it survives + # module re-import: some tests/backend/* tests stub `core.*` in sys.modules, + # which can give the endpoint's `core.user_env` and this test's a *different* + # module object — a setattr monkeypatch wouldn't reach the endpoint's copy. + envfile = str(tmp_path / "env") + monkeypatch.setenv("OMNIVOICE_ENV_FILE", envfile) + return envfile + + +def test_set_persists_and_writes_durable_env(env, tmp_path): + target = str(tmp_path / "models") + res = s.set_models_dir(s._ModelsDirBody(path=target)) + abs_target = os.path.abspath(target) + assert res["configured"] == abs_target + assert res["restart_required"] is True + # main.py reads this on next launch; GET reads it back — single source: + assert user_env.get_user_env("OMNIVOICE_CACHE_DIR") == abs_target + assert s.get_models_dir()["configured"] == abs_target + assert os.path.isdir(target) + + +def test_rejects_unwritable_dir(env, monkeypatch, tmp_path): + # OS-neutral: force the mkdir to fail rather than relying on Unix-only + # /dev/null path semantics (cross-platform parity). + def boom(*a, **k): + raise OSError("read-only filesystem") + + monkeypatch.setattr(os, "makedirs", boom) + with pytest.raises(fastapi.HTTPException) as ei: + s.set_models_dir(s._ModelsDirBody(path=str(tmp_path / "ro"))) + assert ei.value.status_code == 400 + + +def test_rejects_path_with_null_byte(env): + # An embedded NUL would otherwise blow up os.makedirs with a ValueError + # (→ 500). Validate up front and return a clean 400 instead. + with pytest.raises(fastapi.HTTPException) as ei: + s.set_models_dir(s._ModelsDirBody(path="/tmp/mo\x00dels")) + assert ei.value.status_code == 400 + + +def test_clear_reverts_to_default(env): + user_env.set_user_env("OMNIVOICE_CACHE_DIR", "/old") + res = s.set_models_dir(s._ModelsDirBody(path="")) + assert res["configured"] is None + assert res["restart_required"] is True + assert user_env.get_user_env("OMNIVOICE_CACHE_DIR") is None + assert s.get_models_dir()["configured"] is None + + +def test_get_shape(env): + user_env.set_user_env("OMNIVOICE_CACHE_DIR", "/configured") + res = s.get_models_dir() + assert res["configured"] == "/configured" + assert "effective" in res and "default" in res + + +def test_default_is_xdg_aware(env, monkeypatch, tmp_path): + # huggingface_hub's default cache root honors XDG_CACHE_HOME on Linux. + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "xdg")) + for var in ("HF_HUB_CACHE", "HUGGINGFACE_HUB_CACHE", "HF_HOME"): + monkeypatch.delenv(var, raising=False) + default = s.get_models_dir()["default"] + assert default == str(tmp_path / "xdg" / "huggingface") diff --git a/tests/test_user_env.py b/tests/test_user_env.py new file mode 100644 index 00000000..b31571bd --- /dev/null +++ b/tests/test_user_env.py @@ -0,0 +1,64 @@ +"""plan-01 follow-up / #64 — durable per-user env file helper. + +Backs the configurable models directory: the Settings endpoint writes +OMNIVOICE_CACHE_DIR into ~/.config/omnivoice/env, which main.py loads at startup +(→ HF_HOME / HF_HUB_CACHE / TORCH_HOME). The helper must upsert one key without +clobbering others (e.g. a persisted HF_TOKEN) and store the file 0600. +""" +from __future__ import annotations + +import os +import stat +import sys + +from core import user_env + + +def test_set_creates_and_upserts(tmp_path): + p = str(tmp_path / "env") + user_env.set_user_env("OMNIVOICE_CACHE_DIR", "/data/models", path=p) + assert user_env.get_user_env("OMNIVOICE_CACHE_DIR", path=p) == "/data/models" + # upsert: change value, do not duplicate the key + user_env.set_user_env("OMNIVOICE_CACHE_DIR", "/other/models", path=p) + assert user_env.get_user_env("OMNIVOICE_CACHE_DIR", path=p) == "/other/models" + with open(p) as f: + assert f.read().count("OMNIVOICE_CACHE_DIR=") == 1 + + +def test_preserves_other_keys(tmp_path): + p = tmp_path / "env" + p.write_text("HF_TOKEN=hf_abc123\nFOO=bar\n") + user_env.set_user_env("OMNIVOICE_CACHE_DIR", "/m", path=str(p)) + txt = p.read_text() + assert "HF_TOKEN=hf_abc123" in txt + assert "FOO=bar" in txt + assert "OMNIVOICE_CACHE_DIR=/m" in txt + + +def test_unset_removes_only_that_key(tmp_path): + p = tmp_path / "env" + p.write_text("HF_TOKEN=hf_x\nOMNIVOICE_CACHE_DIR=/m\n") + user_env.unset_user_env("OMNIVOICE_CACHE_DIR", path=str(p)) + txt = p.read_text() + assert "OMNIVOICE_CACHE_DIR" not in txt + assert "HF_TOKEN=hf_x" in txt + + +def test_get_missing_returns_none(tmp_path): + assert user_env.get_user_env("NOPE", path=str(tmp_path / "env")) is None + + +def test_set_with_bare_filename_no_parent(tmp_path, monkeypatch): + # A path with no directory component (e.g. OMNIVOICE_ENV_FILE=env) must not + # blow up: os.makedirs("") raises, so the helper has to skip the mkdir. + monkeypatch.chdir(tmp_path) + user_env.set_user_env("K", "v", path="envfile") + assert user_env.get_user_env("K", path="envfile") == "v" + + +def test_file_is_0600(tmp_path): + if sys.platform == "win32": + return # POSIX perms not meaningful on Windows + p = tmp_path / "env" + user_env.set_user_env("K", "v", path=str(p)) + assert stat.S_IMODE(os.stat(p).st_mode) == 0o600