Files
rigdoctor/src/rigdoctor/config.py
T
jessey 4386838b69 feat(m9): graphical first-run setup wizard — 0.26.0
The full installer experience as a GUI wizard (gui/setup_wizard.py): environment
summary → pick dependency bundles (from the catalog, grouped) → install missing
apt packages → choose recording trigger → readiness summary.

- Shown on first launch (config setup_done) and via `rigdoctor-gui --setup`;
  re-runnable from Settings → Run setup wizard.
- install.sh launches it after a fresh install when a desktop session is present.
- catalog.by_bundle() groups components; config gains setup_done.
- Tests: by_bundle grouping + wizard construction smoke.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:13:51 +02:00

200 lines
6.5 KiB
Python

"""Paths and configuration defaults (XDG layout, see ARCHITECTURE §10)."""
from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
APP = "rigdoctor"
def _xdg(env: str, default: str) -> Path:
base = os.environ.get(env) or str(Path.home() / default)
return Path(base) / APP
CONFIG_DIR = _xdg("XDG_CONFIG_HOME", ".config")
DATA_DIR = _xdg("XDG_DATA_HOME", ".local/share")
STATE_DIR = _xdg("XDG_STATE_HOME", ".local/state")
LOG_DIR = DATA_DIR / "logs"
CONFIG_FILE = CONFIG_DIR / "config.toml"
# Crash-capture logger (M3)
LOG_FILE = LOG_DIR / "capture.jsonl"
# Guided diagnostic (M6/D12): a focused capture writes here, separate from the always-on
# crash log, so its report covers only that session's window.
DIAG_LOG = LOG_DIR / "diagnostic.jsonl"
# A crashed (unterminated, unacknowledged) diagnostic is preserved here when a new capture
# starts, so auto-capture (the Steam wrapper) relaunching the game doesn't wipe it first.
DIAG_CRASH = LOG_DIR / "diagnostic-crash.jsonl"
STATUS_FILE = STATE_DIR / "recorder.json"
PID_FILE = STATE_DIR / "recorder.pid"
SPAWN_LOG = STATE_DIR / "recorder.out"
# Gaming environment / game detection (M6) — cached Steam game scan (mutable state,
# not config: refreshed by the background scan on every launch).
GAMES_FILE = STATE_DIR / "games.json"
# Update access token (M13) — gates updates to Gitea account holders (D18).
# Stored in the OS keyring (Secret Service / GNOME Keyring) via `secret-tool` when
# available — encrypted at rest, unlocked with the login session — else a 0600 file.
TOKEN_FILE = CONFIG_DIR / "token"
_SECRET_ATTRS = ["application", "rigdoctor", "type", "update-token"]
def _secret_tool() -> str | None:
return shutil.which("secret-tool")
def keyring_available() -> bool:
"""True if an encrypted OS keyring (secret-tool) is usable."""
return _secret_tool() is not None
def _keyring_store(token: str) -> bool:
tool = _secret_tool()
if not tool:
return False
try:
proc = subprocess.run(
[tool, "store", "--label", "RigDoctor update token", *_SECRET_ATTRS],
input=token, text=True, capture_output=True, timeout=20,
)
return proc.returncode == 0
except (subprocess.SubprocessError, OSError):
return False
def _keyring_lookup() -> str | None:
tool = _secret_tool()
if not tool:
return None
try:
proc = subprocess.run(
[tool, "lookup", *_SECRET_ATTRS], text=True, capture_output=True, timeout=20
)
if proc.returncode == 0 and proc.stdout.strip():
return proc.stdout.strip()
except (subprocess.SubprocessError, OSError):
pass
return None
def _keyring_clear() -> None:
tool = _secret_tool()
if not tool:
return
try:
subprocess.run([tool, "clear", *_SECRET_ATTRS], capture_output=True, timeout=20)
except (subprocess.SubprocessError, OSError):
pass
def load_token() -> str | None:
"""Token from $RIGDOCTOR_TOKEN, then the OS keyring, then a 0600 file."""
env = os.environ.get("RIGDOCTOR_TOKEN")
if env and env.strip():
return env.strip()
from_keyring = _keyring_lookup()
if from_keyring:
return from_keyring
try:
token = TOKEN_FILE.read_text().strip()
return token or None
except OSError:
return None
def save_token(token: str) -> None:
"""Save to the OS keyring if possible (encrypted); else a 0600 file."""
token = token.strip()
if _keyring_store(token):
try: # don't leave a plaintext copy once it's in the keyring
TOKEN_FILE.unlink()
except OSError:
pass
return
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
TOKEN_FILE.write_text(token + "\n")
try:
TOKEN_FILE.chmod(0o600)
except OSError:
pass
def clear_token() -> None:
_keyring_clear()
try:
TOKEN_FILE.unlink()
except OSError:
pass
def token_backend() -> str:
"""Where the active token lives: 'env' | 'keyring' | 'file' | 'none'."""
env = os.environ.get("RIGDOCTOR_TOKEN")
if env and env.strip():
return "env"
if _keyring_lookup() is not None:
return "keyring"
if TOKEN_FILE.exists():
return "file"
return "none"
DEFAULTS: dict = {
"interval": 1.0, # sampling interval in seconds (default ≤1 Hz — NFR)
"log_max_bytes": 20_000_000, # rotate a log segment past this size
"log_backups": 10, # keep this many rotated segments (bounds disk use)
"update_check_minutes": 30, # re-check for updates this often while running (0 = off)
"elevate_on_launch": True, # GUI asks for the password once at launch (SMART/dmidecode)
"alerts_enabled": True, # desktop notifications on overheat / GPU-lost / new version
"gpu_temp_alert": 90.0, # °C — alert when GPU reaches this
"cpu_temp_alert": 95.0, # °C — alert when CPU reaches this
"relay_url": "wss://rigdoctor.jesseyvanofferen.com", # session-sharing relay (M12)
"steam_libraries": [], # Steam library paths to scan for games (M6); empty = none picked yet
"trigger_mode": "manual", # crash-logger trigger (D6): manual | always-on | game-launch
"setup_done": False, # first-run GUI setup wizard completed (M9)
}
def load_config() -> dict:
"""Return defaults merged with config.toml if present (best-effort)."""
cfg = dict(DEFAULTS)
try:
import tomllib
if CONFIG_FILE.exists():
with CONFIG_FILE.open("rb") as f:
cfg.update(tomllib.load(f))
except Exception:
pass
return cfg
def _toml_value(value) -> str:
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float)):
return repr(value)
if isinstance(value, (list, tuple)):
return "[" + ", ".join(_toml_value(v) for v in value) + "]"
return '"' + str(value).replace("\\", "\\\\").replace('"', '\\"') + '"'
def save_config(values: dict) -> None:
"""Write a flat config.toml (stdlib has no TOML writer)."""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
lines = ["# RigDoctor config — edit in the app (Notifications) or here."]
lines += [f"{key} = {_toml_value(value)}" for key, value in values.items()]
CONFIG_FILE.write_text("\n".join(lines) + "\n")
def update_config(**changes) -> dict:
"""Merge changes into the current effective config and persist them."""
cfg = load_config()
cfg.update(changes)
save_config(cfg)
return cfg