4386838b69
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>
200 lines
6.5 KiB
Python
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
|