Initial SyncGames tree: agent, Android, deploy, docs.

Session-gated MinIO save sync with AppImage GUI, CLI edit/session flow, and Gitea release helper.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
2026-07-14 22:06:36 -05:00
co-authored by Cursor
commit 0d6b0b2f80
76 changed files with 5697 additions and 0 deletions
+273
View File
@@ -0,0 +1,273 @@
from __future__ import annotations
import os
import re
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from syncgames.errors import ConfigError
SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
def slugify(name: str) -> str:
s = name.lower().strip()
s = re.sub(r"[^a-z0-9]+", "-", s)
s = re.sub(r"-+", "-", s).strip("-")
if not s or not SLUG_RE.match(s):
raise ConfigError(f"Cannot slugify name into valid game-id: {name!r}")
return s
def expand_path(p: str) -> Path:
return Path(os.path.expanduser(os.path.expandvars(p))).resolve()
@dataclass
class AgentConfig:
device_id: str
endpoint_url: str
bucket: str = "syncgames"
region: str = "us-east-1"
path_style: bool = True
access_key: str = ""
secret_key: str = ""
store: str = "minio" # minio | filesystem
ssot_root: Path | None = None
lease_ttl_hours: int = 6
config_root: Path = field(default_factory=Path)
state_root: Path = field(default_factory=Path)
@property
def games_dir(self) -> Path:
return self.config_root / "games"
@dataclass
class GameConfig:
id: str
name: str
platform: str
paths: list[str]
versions_to_keep: int = 5
steam_appid: int | None = None
process_names: list[str] = field(default_factory=list)
def resolved_paths(self) -> list[Path]:
return [expand_path(p) for p in self.paths]
def default_config_root() -> Path:
env = os.environ.get("SYNCGAMES_CONFIG")
if env:
return Path(env).expanduser().resolve()
xdg = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))
return Path(xdg) / "syncgames"
def default_state_root() -> Path:
env = os.environ.get("SYNCGAMES_STATE")
if env:
return Path(env).expanduser().resolve()
xdg = os.environ.get("XDG_STATE_HOME", str(Path.home() / ".local" / "state"))
return Path(xdg) / "syncgames"
def repo_config_root() -> Path | None:
"""If running from the SyncGames checkout, prefer repo config/ when present."""
here = Path(__file__).resolve()
candidate = here.parents[2] / "config" # SyncGames/config
if (candidate / "agent.toml.example").exists() or (candidate / "agent.toml").exists():
return candidate
return None
def load_agent_config(config_root: Path | None = None) -> AgentConfig:
root = config_root or default_config_root()
agent_path = root / "agent.toml"
# Bootstrap: allow repo example copy location
if not agent_path.exists():
repo = repo_config_root()
if repo and (repo / "agent.toml").exists():
root = repo
agent_path = root / "agent.toml"
elif repo and (repo / "agent.toml.example").exists():
raise ConfigError(
f"No agent.toml found. Copy {repo / 'agent.toml.example'} to "
f"{default_config_root() / 'agent.toml'} or {repo / 'agent.toml'}"
)
else:
raise ConfigError(f"Missing agent config: {agent_path}")
with agent_path.open("rb") as f:
data = tomllib.load(f)
store = data.get("store", "minio")
ssot = data.get("ssot_root")
return AgentConfig(
device_id=str(data["device_id"]),
endpoint_url=str(data.get("endpoint_url", "")),
bucket=str(data.get("bucket", "syncgames")),
region=str(data.get("region", "us-east-1")),
path_style=bool(data.get("path_style", True)),
access_key=str(data.get("access_key", "")),
secret_key=str(data.get("secret_key", "")),
store=store,
ssot_root=expand_path(ssot) if ssot else None,
lease_ttl_hours=int(data.get("lease_ttl_hours", 6)),
config_root=root,
state_root=default_state_root(),
)
def load_game(config: AgentConfig, game_id: str) -> GameConfig:
path = config.games_dir / f"{game_id}.toml"
if not path.exists():
raise ConfigError(f"Unknown game: {game_id} (expected {path})")
with path.open("rb") as f:
data = tomllib.load(f)
paths = data.get("paths") or []
if isinstance(paths, str):
paths = [paths]
procs = data.get("process_names") or data.get("process_name") or []
if isinstance(procs, str):
procs = [procs]
return GameConfig(
id=str(data.get("id", game_id)),
name=str(data.get("name", game_id)),
platform=str(data.get("platform", "other")),
paths=[str(p) for p in paths],
versions_to_keep=int(data.get("versions_to_keep", 5)),
steam_appid=int(data["steam_appid"]) if data.get("steam_appid") else None,
process_names=[str(p) for p in procs],
)
def list_games(config: AgentConfig) -> list[GameConfig]:
games_dir = config.games_dir
if not games_dir.exists():
return []
out: list[GameConfig] = []
for path in sorted(games_dir.glob("*.toml")):
if path.name.endswith(".removed.toml"):
continue
out.append(load_game(config, path.stem))
return out
def write_game_toml(config: AgentConfig, game: GameConfig) -> Path:
config.games_dir.mkdir(parents=True, exist_ok=True)
path = config.games_dir / f"{game.id}.toml"
lines = [
f'id = "{game.id}"',
f'name = "{game.name}"',
f'platform = "{game.platform}"',
f"versions_to_keep = {game.versions_to_keep}",
"paths = [",
]
for p in game.paths:
lines.append(f' "{p}",')
lines.append("]")
if game.steam_appid is not None:
lines.append(f"steam_appid = {game.steam_appid}")
if game.process_names:
lines.append("process_names = [")
for n in game.process_names:
lines.append(f' "{n}",')
lines.append("]")
lines.append("")
path.write_text("\n".join(lines), encoding="utf-8")
return path
def _toml_str(value: str) -> str:
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
def save_agent_config(config: AgentConfig, *, path: Path | None = None) -> Path:
"""Write agent.toml (secrets included — file mode 0600)."""
root = config.config_root if config.config_root else default_config_root()
root.mkdir(parents=True, exist_ok=True)
out = path or (root / "agent.toml")
lines = [
f"device_id = {_toml_str(config.device_id)}",
f"store = {_toml_str(config.store)}",
f"endpoint_url = {_toml_str(config.endpoint_url)}",
f"bucket = {_toml_str(config.bucket)}",
f"region = {_toml_str(config.region)}",
f"path_style = {'true' if config.path_style else 'false'}",
f"access_key = {_toml_str(config.access_key)}",
f"secret_key = {_toml_str(config.secret_key)}",
f"lease_ttl_hours = {int(config.lease_ttl_hours)}",
]
if config.store == "filesystem" and config.ssot_root is not None:
lines.append(f"ssot_root = {_toml_str(str(config.ssot_root))}")
lines.append("")
out.write_text("\n".join(lines), encoding="utf-8")
try:
out.chmod(0o600)
except OSError:
pass
return out
def try_load_agent_config(config_root: Path | None = None) -> AgentConfig | None:
try:
return load_agent_config(config_root)
except ConfigError:
return None
def _bundled_root() -> Path | None:
"""PyInstaller _MEIPASS or package-adjacent bundled_* dirs."""
import sys
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
return Path(sys._MEIPASS)
here = Path(__file__).resolve().parents[1]
if (here / "bundled_config").exists() or (here / "bundled_systemd").exists():
return here
return None
def seed_example_games(config: AgentConfig, source_games_dir: Path | None = None) -> int:
"""Copy missing example game tomls from the SyncGames checkout into config."""
if source_games_dir is None:
repo = repo_config_root()
if repo and (repo / "games").exists():
source_games_dir = repo / "games"
else:
bundled = _bundled_root()
if bundled and (bundled / "bundled_config" / "games").exists():
source_games_dir = bundled / "bundled_config" / "games"
if source_games_dir is None or not source_games_dir.exists():
return 0
config.games_dir.mkdir(parents=True, exist_ok=True)
copied = 0
for src in sorted(source_games_dir.glob("*.toml")):
dest = config.games_dir / src.name
if not dest.exists():
dest.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
copied += 1
return copied
def install_systemd_units(unit_source: Path | None = None) -> Path:
"""Install user systemd units; returns the unit directory."""
import shutil
xdg = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))
unit_dir = Path(xdg) / "systemd" / "user"
unit_dir.mkdir(parents=True, exist_ok=True)
if unit_source is None:
candidate = Path(__file__).resolve().parents[2] / "systemd"
if not candidate.exists():
bundled = _bundled_root()
candidate = (bundled / "bundled_systemd") if bundled else candidate
unit_source = candidate
for name in ("syncgames-agent.service", "[email protected]"):
src = unit_source / name
if src.exists():
shutil.copy2(src, unit_dir / name)
return unit_dir