132 lines
4.2 KiB
Python
132 lines
4.2 KiB
Python
"""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
|