"""Where the code lives versus where the code may write. In a checkout these are the same directory, which is why everything resolved against the repo root until now. Installed, they are not: the code sits under `Program Files`, which is read-only for a normal user and for a service running as LocalSystem, while the database, logs, camera list and downloaded models all have to be written somewhere that survives an upgrade. Three roots, resolved in one place so nothing else has to know it is frozen: - `install_root()` — the code and the bundled default config. Read-only. - `state_root()` — everything we write. `%PROGRAMDATA%\\Behavision` when frozen on Windows. - `config_path()` — the YAML actually loaded. Models live under `state_root()`, not next to the code: they are ~200 MB and are downloaded on first run rather than bundled (a 300 MB installer that has to be re-signed for a model change is a bad trade), so they must land somewhere writable. `BEHAVISION_DATA_DIR` and `BEHAVISION_CONFIG` override everything, which is what makes the installed layout testable from a checkout and lets one machine run two instances. """ from __future__ import annotations import os import sys from pathlib import Path APP_NAME = "Behavision" def is_frozen() -> bool: """True inside a PyInstaller bundle.""" return bool(getattr(sys, "frozen", False)) def install_root() -> Path: """Directory holding the code and bundled data files. Frozen, that is the folder containing the .exe — PyInstaller's one-folder layout — not `_MEIPASS`, which for onefile is a temp dir that vanishes. """ if is_frozen(): return Path(sys.executable).resolve().parent return Path(__file__).resolve().parent.parent def _os_family() -> str: """Which install layout applies. A seam, not decoration: a test cannot monkeypatch `os.name` to exercise the Windows layout on another host, because `pathlib` dispatches on it and every `Path()` in the process starts raising. """ if os.name == "nt": return "windows" if sys.platform == "darwin": return "macos" return "linux" def state_root() -> Path: """Directory we may write to. Created by the caller, not here.""" override = os.environ.get("BEHAVISION_DATA_DIR", "").strip() if override: return Path(override).expanduser().resolve() if not is_frozen(): # A checkout keeps everything together; that is the whole convenience # of developing from one. return install_root() family = _os_family() if family == "windows": base = os.environ.get("PROGRAMDATA") or r"C:\ProgramData" return Path(base) / APP_NAME if family == "macos": return Path.home() / "Library" / "Application Support" / APP_NAME return Path( os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share" ) / APP_NAME.lower() def config_path() -> Path: """The YAML to load. An installed system must be configurable without editing anything under `Program Files`, so a copy in the state root wins over the bundled one. `ensure_config()` puts it there on first run. """ override = os.environ.get("BEHAVISION_CONFIG", "").strip() if override: return Path(override).expanduser().resolve() local = state_root() / "config" / "default.yaml" if local.is_file(): return local return install_root() / "config" / "default.yaml" def env_file() -> "Path | None": """`.env`, preferring the writable copy. Returns None when there is none — it is optional, and an installed system keeps its secrets in the config and the camera store instead.""" for candidate in (state_root() / ".env", install_root() / ".env"): if candidate.is_file(): return candidate return None def ensure_config() -> Path: """Seed an editable config in the state root on first run, and return the path that will be loaded. Copied, never symlinked, and never overwritten: an upgrade must not silently revert an operator's thresholds. """ override = os.environ.get("BEHAVISION_CONFIG", "").strip() if override: return Path(override).expanduser().resolve() local = state_root() / "config" / "default.yaml" if local.is_file(): return local bundled = install_root() / "config" / "default.yaml" if not bundled.is_file(): # Nothing to seed. Return the bundled path so the caller's "no such # file" names the place the file was supposed to be. return bundled if local.parent.exists() and local.resolve() == bundled.resolve(): # A checkout: install root and state root are the same directory, so # the "copy" would be a file onto itself. return bundled local.parent.mkdir(parents=True, exist_ok=True) local.write_text(bundled.read_text(encoding="utf-8"), encoding="utf-8") return local def describe() -> dict: """For /api/health and the tray - "where is my database" must be answerable without reading the source.""" return { "frozen": is_frozen(), "install_root": str(install_root()), "state_root": str(state_root()), "config": str(config_path()), }