"""User-editable launcher settings with conservative defaults.""" from __future__ import annotations from dataclasses import asdict, dataclass import json import os from pathlib import Path from .paths import AppPaths @dataclass(frozen=True) class Settings: width: int = 1600 height: int = 900 output_width: int = 1600 output_height: int = 900 refresh_rate: int = 60 fullscreen: bool = False borderless: bool = True scaler: str = "fit" filter: str = "linear" runtime: str = "auto" proton_sync: str = "auto" proton_path: str | None = None game_source: str | None = None @property def source_override(self) -> Path | None: return Path(self.game_source).expanduser().resolve() if self.game_source else None def load_settings(paths: AppPaths) -> Settings: try: value = json.loads(paths.settings_file.read_text(encoding="utf-8")) except FileNotFoundError: return Settings() except (OSError, json.JSONDecodeError) as exc: raise ValueError(f"invalid settings file {paths.settings_file}: {exc}") from exc if not isinstance(value, dict): raise ValueError(f"settings file must contain an object: {paths.settings_file}") settings = Settings( width=_integer(value, "width", 1600), height=_integer(value, "height", 900), output_width=_integer(value, "output_width", 1600), output_height=_integer(value, "output_height", 900), refresh_rate=_integer(value, "refresh_rate", 60), fullscreen=_boolean(value, "fullscreen", False), borderless=_boolean(value, "borderless", True), scaler=_string(value, "scaler", "fit"), filter=_string(value, "filter", "linear"), runtime=_string(value, "runtime", "auto"), proton_sync=_string(value, "proton_sync", "auto"), proton_path=_optional_string(value, "proton_path"), game_source=_optional_string(value, "game_source"), ) _validate(settings) return settings def save_settings(paths: AppPaths, settings: Settings) -> None: _validate(settings) paths.config.mkdir(parents=True, exist_ok=True) temporary = paths.settings_file.with_name( f".{paths.settings_file.name}.{os.getpid()}.tmp" ) temporary.write_text( json.dumps(asdict(settings), indent=2, sort_keys=True) + "\n", encoding="utf-8", ) temporary.replace(paths.settings_file) def ensure_settings(paths: AppPaths) -> Settings: if paths.settings_file.exists(): return load_settings(paths) settings = Settings() save_settings(paths, settings) return settings def _validate(settings: Settings) -> None: for name, value in ( ("width", settings.width), ("height", settings.height), ("output_width", settings.output_width), ("output_height", settings.output_height), ): if value < 640 or value > 16384: raise ValueError(f"{name} must be between 640 and 16384") if settings.refresh_rate < 24 or settings.refresh_rate > 1000: raise ValueError("refresh_rate must be between 24 and 1000") if settings.scaler not in {"auto", "integer", "fit", "fill", "stretch"}: raise ValueError(f"unsupported Gamescope scaler: {settings.scaler}") if settings.filter not in {"linear", "nearest", "fsr", "nis", "pixel"}: raise ValueError(f"unsupported Gamescope filter: {settings.filter}") if settings.runtime not in {"auto", "proton7", "umu"}: raise ValueError(f"unsupported runtime selection: {settings.runtime}") if settings.proton_sync not in {"auto", "fsync", "esync"}: raise ValueError(f"unsupported Proton synchronization mode: {settings.proton_sync}") def _integer(value: dict[str, object], key: str, default: int) -> int: item = value.get(key, default) if isinstance(item, bool) or not isinstance(item, int): raise ValueError(f"{key} must be an integer") return item def _boolean(value: dict[str, object], key: str, default: bool) -> bool: item = value.get(key, default) if not isinstance(item, bool): raise ValueError(f"{key} must be a boolean") return item def _string(value: dict[str, object], key: str, default: str) -> str: item = value.get(key, default) if not isinstance(item, str) or not item: raise ValueError(f"{key} must be a non-empty string") return item def _optional_string(value: dict[str, object], key: str) -> str | None: item = value.get(key) if item is None: return None if not isinstance(item, str) or not item: raise ValueError(f"{key} must be null or a non-empty string") return item