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:
@@ -0,0 +1,4 @@
|
||||
"""SyncGames Linux agent — session-gated save sync."""
|
||||
|
||||
__version__ = "0.1.1"
|
||||
SCHEMA_VERSION = 1
|
||||
@@ -0,0 +1,4 @@
|
||||
from syncgames.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,314 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from syncgames.config import (
|
||||
AgentConfig,
|
||||
GameConfig,
|
||||
default_config_root,
|
||||
list_games,
|
||||
load_agent_config,
|
||||
load_game,
|
||||
slugify,
|
||||
write_game_toml,
|
||||
)
|
||||
from syncgames.errors import SyncGamesError
|
||||
from syncgames.lease import break_lease
|
||||
from syncgames.session import (
|
||||
ensure_game_remote,
|
||||
end_session,
|
||||
restore_session,
|
||||
start_session,
|
||||
status_text,
|
||||
)
|
||||
from syncgames.store import build_store
|
||||
from syncgames.watchers import watch_loop
|
||||
|
||||
|
||||
def _cfg(args: argparse.Namespace) -> AgentConfig:
|
||||
root = Path(args.config_root).expanduser() if getattr(args, "config_root", None) else None
|
||||
return load_agent_config(root)
|
||||
|
||||
|
||||
def cmd_add(args: argparse.Namespace) -> int:
|
||||
cfg = _cfg(args)
|
||||
store = build_store(cfg)
|
||||
gid = args.id or slugify(args.name)
|
||||
paths = args.paths or []
|
||||
if not paths:
|
||||
raise SyncGamesError("At least one --paths is required", key="config_error")
|
||||
procs = args.process_name or []
|
||||
game = GameConfig(
|
||||
id=gid,
|
||||
name=args.name,
|
||||
platform=args.platform,
|
||||
paths=paths,
|
||||
versions_to_keep=args.versions,
|
||||
steam_appid=args.steam_appid,
|
||||
process_names=procs,
|
||||
)
|
||||
path = write_game_toml(cfg, game)
|
||||
ensure_game_remote(store, game)
|
||||
print(f"Added game {gid} → {path}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_remove(args: argparse.Namespace) -> int:
|
||||
cfg = _cfg(args)
|
||||
store = build_store(cfg)
|
||||
game = load_game(cfg, args.game_id)
|
||||
if args.purge_remote:
|
||||
confirm = input(f"Type {game.id} to permanently purge remote: ")
|
||||
if confirm != game.id:
|
||||
print("Aborted")
|
||||
return 1
|
||||
# still retire first for safety unless --purge-remote means delete
|
||||
dest = store.retire_game(game.id)
|
||||
print(f"Retired then leaving retired/ in place at {dest} (manual purge from console)")
|
||||
else:
|
||||
dest = store.retire_game(game.id)
|
||||
print(f"Remote moved to {dest}")
|
||||
src = cfg.games_dir / f"{game.id}.toml"
|
||||
if src.exists():
|
||||
src.rename(src.with_suffix(".toml.removed"))
|
||||
print(f"Local config renamed for {game.id}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_edit(args: argparse.Namespace) -> int:
|
||||
"""Update an existing game.toml; game id stays stable for MinIO prefixes."""
|
||||
cfg = _cfg(args)
|
||||
game = load_game(cfg, args.game_id)
|
||||
if args.name:
|
||||
game.name = args.name
|
||||
if args.platform:
|
||||
game.platform = args.platform
|
||||
if args.paths:
|
||||
game.paths = args.paths
|
||||
if args.add_path:
|
||||
for p in args.add_path:
|
||||
if p not in game.paths:
|
||||
game.paths.append(p)
|
||||
if args.versions is not None:
|
||||
game.versions_to_keep = args.versions
|
||||
if args.steam_appid is not None:
|
||||
game.steam_appid = args.steam_appid
|
||||
if args.process_name is not None:
|
||||
game.process_names = args.process_name
|
||||
path = write_game_toml(cfg, game)
|
||||
print(f"Updated {game.id} → {path}")
|
||||
for p in game.paths:
|
||||
print(f" path: {p}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_start(args: argparse.Namespace) -> int:
|
||||
cfg = _cfg(args)
|
||||
store = build_store(cfg)
|
||||
game = load_game(cfg, args.game_id)
|
||||
meta = start_session(cfg, store, game)
|
||||
print(f"Started session {game.id}; lease={meta.lease}; live_hash={meta.live_hash}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_end(args: argparse.Namespace) -> int:
|
||||
cfg = _cfg(args)
|
||||
store = build_store(cfg)
|
||||
game = load_game(cfg, args.game_id)
|
||||
meta = end_session(
|
||||
cfg, store, game, force=args.force, force_hash=args.force_restore
|
||||
)
|
||||
print(f"Ended session {game.id}; new live_hash={meta.live_hash}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_status(args: argparse.Namespace) -> int:
|
||||
cfg = _cfg(args)
|
||||
store = build_store(cfg)
|
||||
game = load_game(cfg, args.game_id) if args.game_id else None
|
||||
print(status_text(cfg, store, game))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_history(args: argparse.Namespace) -> int:
|
||||
cfg = _cfg(args)
|
||||
store = build_store(cfg)
|
||||
game = load_game(cfg, args.game_id)
|
||||
for item in store.list_history(game.id):
|
||||
print(item)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_restore(args: argparse.Namespace) -> int:
|
||||
cfg = _cfg(args)
|
||||
store = build_store(cfg)
|
||||
game = load_game(cfg, args.game_id)
|
||||
confirm = input(
|
||||
f"Restore {game.id} live from history {args.from_} ? Type YES: "
|
||||
)
|
||||
if confirm != "YES":
|
||||
print("Aborted")
|
||||
return 1
|
||||
meta = restore_session(cfg, store, game, args.from_)
|
||||
print(f"Restored {args.from_}; live_hash={meta.live_hash}. Lease held — end when done.")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_doctor(args: argparse.Namespace) -> int:
|
||||
cfg = _cfg(args)
|
||||
store = build_store(cfg)
|
||||
print(f"device_id={cfg.device_id}")
|
||||
print(f"store={cfg.store}")
|
||||
print(f"config_root={cfg.config_root}")
|
||||
print(f"state_root={cfg.state_root}")
|
||||
if args.break_lease:
|
||||
game = load_game(cfg, args.break_lease)
|
||||
break_lease(store, game.id)
|
||||
print(f"Broke lease for {game.id}")
|
||||
try:
|
||||
digest = store.probe()
|
||||
print(f"probe_ok hash={digest}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"probe_FAILED: {e}", file=sys.stderr)
|
||||
return 1
|
||||
for g in list_games(cfg):
|
||||
meta = store.get_meta(g.id)
|
||||
print(f"game {g.id}: meta={'yes' if meta else 'missing'}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_export_ssot(args: argparse.Namespace) -> int:
|
||||
cfg = _cfg(args)
|
||||
store = build_store(cfg)
|
||||
out = Path(args.out).expanduser().resolve()
|
||||
store.export_ssot(out)
|
||||
print(f"Exported SSOT to {out}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_watch(args: argparse.Namespace) -> int:
|
||||
cfg = _cfg(args)
|
||||
store = build_store(cfg)
|
||||
game = load_game(cfg, args.game_id)
|
||||
|
||||
def on_start() -> None:
|
||||
print(f"[watch] start {game.id}")
|
||||
try:
|
||||
start_session(cfg, store, game)
|
||||
except SyncGamesError as e:
|
||||
print(f"[watch] start skipped: {e}", file=sys.stderr)
|
||||
|
||||
def on_end() -> None:
|
||||
print(f"[watch] end {game.id}")
|
||||
try:
|
||||
end_session(cfg, store, game)
|
||||
except SyncGamesError as e:
|
||||
print(f"[watch] end failed: {e}", file=sys.stderr)
|
||||
|
||||
print(f"Watching {game.id} (Ctrl+C to stop)")
|
||||
watch_loop(game, on_start, on_end)
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(prog="syncgames", description="SyncGames session agent")
|
||||
p.add_argument(
|
||||
"--config-root",
|
||||
default=None,
|
||||
help=f"Config directory (default {default_config_root()})",
|
||||
)
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
add = sub.add_parser("add", help="Declare a new game")
|
||||
add.add_argument("--name", required=True)
|
||||
add.add_argument("--id", default=None, help="Slug override")
|
||||
add.add_argument("--platform", default="steam", choices=["steam", "eden", "yuzu", "other"])
|
||||
add.add_argument("--paths", action="append", default=[])
|
||||
add.add_argument("--versions", type=int, default=5)
|
||||
add.add_argument("--steam-appid", type=int, default=None)
|
||||
add.add_argument("--process-name", action="append", default=[])
|
||||
add.set_defaults(func=cmd_add)
|
||||
|
||||
rem = sub.add_parser("remove", help="Retire a game")
|
||||
rem.add_argument("game_id")
|
||||
rem.add_argument("--purge-remote", action="store_true")
|
||||
rem.set_defaults(func=cmd_remove)
|
||||
|
||||
ed = sub.add_parser("edit", help="Update an existing game config (id stays stable)")
|
||||
ed.add_argument("game_id")
|
||||
ed.add_argument("--name", default=None)
|
||||
ed.add_argument("--platform", default=None, choices=["steam", "eden", "yuzu", "other"])
|
||||
ed.add_argument("--paths", action="append", default=None, help="Replace all paths")
|
||||
ed.add_argument("--add-path", action="append", default=None, help="Append a path")
|
||||
ed.add_argument("--versions", type=int, default=None)
|
||||
ed.add_argument("--steam-appid", type=int, default=None)
|
||||
ed.add_argument("--process-name", action="append", default=None)
|
||||
ed.set_defaults(func=cmd_edit)
|
||||
|
||||
st = sub.add_parser("start", help="Pull SSOT and acquire lease")
|
||||
st.add_argument("game_id")
|
||||
st.set_defaults(func=cmd_start)
|
||||
|
||||
en = sub.add_parser("end", help="Push WIP and release lease")
|
||||
en.add_argument("game_id")
|
||||
en.add_argument("--force", action="store_true", help="Allow end while process still seen")
|
||||
en.add_argument(
|
||||
"--force-restore",
|
||||
action="store_true",
|
||||
help="Bypass hash gate (dangerous)",
|
||||
)
|
||||
en.set_defaults(func=cmd_end)
|
||||
|
||||
su = sub.add_parser("status", help="Show leases and sessions")
|
||||
su.add_argument("game_id", nargs="?")
|
||||
su.set_defaults(func=cmd_status)
|
||||
|
||||
hi = sub.add_parser("history", help="List remote history prefixes")
|
||||
hi.add_argument("game_id")
|
||||
hi.set_defaults(func=cmd_history)
|
||||
|
||||
rs = sub.add_parser("restore", help="Promote history → live")
|
||||
rs.add_argument("game_id")
|
||||
rs.add_argument("--from", dest="from_", required=True, help="device/ts")
|
||||
rs.set_defaults(func=cmd_restore)
|
||||
|
||||
doc = sub.add_parser("doctor", help="Connectivity + config probe")
|
||||
doc.add_argument("--break-lease", metavar="GAME_ID", default=None)
|
||||
doc.set_defaults(func=cmd_doctor)
|
||||
|
||||
ex = sub.add_parser("export-ssot", help="Export bucket/tree for Path 1 fallback")
|
||||
ex.add_argument("--out", required=True)
|
||||
ex.set_defaults(func=cmd_export_ssot)
|
||||
|
||||
wa = sub.add_parser("watch", help="Auto start/end around process lifetime")
|
||||
wa.add_argument("game_id")
|
||||
wa.set_defaults(func=cmd_watch)
|
||||
|
||||
gui = sub.add_parser("gui", help="Open thin setup / management GUI")
|
||||
gui.set_defaults(func=cmd_gui)
|
||||
|
||||
return p
|
||||
|
||||
|
||||
def cmd_gui(_args: argparse.Namespace) -> int:
|
||||
from syncgames.gui.app import main as gui_main
|
||||
|
||||
gui_main()
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
code = args.func(args)
|
||||
except SyncGamesError as e:
|
||||
print(f"error[{e.key}]: {e}", file=sys.stderr)
|
||||
raise SystemExit(2) from e
|
||||
raise SystemExit(code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class SyncGamesError(Exception):
|
||||
key = "error"
|
||||
|
||||
def __init__(self, message: str, *, key: str | None = None) -> None:
|
||||
super().__init__(message)
|
||||
if key:
|
||||
self.key = key
|
||||
|
||||
|
||||
class ConfigError(SyncGamesError):
|
||||
key = "config_error"
|
||||
|
||||
|
||||
class LeaseHeldError(SyncGamesError):
|
||||
key = "lease_held"
|
||||
|
||||
|
||||
class StaleWipError(SyncGamesError):
|
||||
key = "stale_wip"
|
||||
|
||||
|
||||
class StoreError(SyncGamesError):
|
||||
key = "store_error"
|
||||
|
||||
|
||||
class GameRunningError(SyncGamesError):
|
||||
key = "game_running"
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Thin PySide6 setup / management GUI for SyncGames."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = ["run_gui"]
|
||||
|
||||
|
||||
def run_gui() -> None:
|
||||
from syncgames.gui.app import main
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from syncgames import __version__
|
||||
from syncgames.gui.main_window import MainWindow
|
||||
|
||||
|
||||
def main() -> None:
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("SyncGames")
|
||||
app.setOrganizationName("SyncGames")
|
||||
app.setApplicationVersion(__version__)
|
||||
win = MainWindow()
|
||||
win.show()
|
||||
raise SystemExit(app.exec())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,646 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QObject, QThread, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QFileDialog,
|
||||
QFormLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QInputDialog,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QPlainTextEdit,
|
||||
QPushButton,
|
||||
QSpinBox,
|
||||
QTabWidget,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from syncgames import __version__
|
||||
from syncgames.config import (
|
||||
AgentConfig,
|
||||
GameConfig,
|
||||
default_config_root,
|
||||
install_systemd_units,
|
||||
list_games,
|
||||
save_agent_config,
|
||||
seed_example_games,
|
||||
slugify,
|
||||
try_load_agent_config,
|
||||
write_game_toml,
|
||||
)
|
||||
from syncgames.errors import SyncGamesError
|
||||
from syncgames.session import (
|
||||
end_session,
|
||||
restore_session,
|
||||
start_session,
|
||||
status_text,
|
||||
)
|
||||
from syncgames.store import build_store
|
||||
|
||||
|
||||
class Worker(QObject):
|
||||
finished = Signal(str)
|
||||
failed = Signal(str)
|
||||
|
||||
def __init__(self, fn) -> None:
|
||||
super().__init__()
|
||||
self._fn = fn
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
result = self._fn()
|
||||
self.finished.emit(result if isinstance(result, str) else str(result or "ok"))
|
||||
except BaseException as e: # noqa: BLE001 — keep GUI alive on any probe/S3 failure
|
||||
self.failed.emit(f"{type(e).__name__}: {e}\n\n{traceback.format_exc()}")
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.setWindowTitle(f"SyncGames {_version_label()}")
|
||||
self.resize(920, 640)
|
||||
self._config_root = default_config_root()
|
||||
self._thread: QThread | None = None
|
||||
self._worker: Worker | None = None
|
||||
self._editing_game_id: str | None = None
|
||||
|
||||
self._log = QTextEdit(self)
|
||||
self._log.setReadOnly(True)
|
||||
self._log.setMinimumHeight(140)
|
||||
|
||||
tabs = QTabWidget(self)
|
||||
tabs.addTab(self._build_setup(QWidget()), "Setup")
|
||||
tabs.addTab(self._build_games(QWidget()), "Games")
|
||||
tabs.addTab(self._build_session(QWidget()), "Session")
|
||||
|
||||
holder = QWidget(self)
|
||||
lay = QVBoxLayout(holder)
|
||||
lay.addWidget(tabs)
|
||||
lay.addWidget(QLabel("Log"))
|
||||
lay.addWidget(self._log)
|
||||
self.setCentralWidget(holder)
|
||||
|
||||
self._load_config_into_form()
|
||||
self._refresh_games()
|
||||
|
||||
def _build_setup(self, parent: QWidget) -> QWidget:
|
||||
layout = QVBoxLayout(parent)
|
||||
box = QGroupBox("MinIO / Cloudflare endpoint", parent)
|
||||
form = QFormLayout(box)
|
||||
|
||||
self._device = QLineEdit(box)
|
||||
self._endpoint = QLineEdit(box)
|
||||
self._endpoint.setPlaceholderText("https://syncgames-s3.example.com")
|
||||
self._bucket = QLineEdit(box)
|
||||
self._bucket.setText("syncgames")
|
||||
self._region = QLineEdit(box)
|
||||
self._region.setText("us-east-1")
|
||||
self._access = QLineEdit(box)
|
||||
self._secret = QLineEdit(box)
|
||||
self._secret.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
self._ttl = QSpinBox(box)
|
||||
self._ttl.setRange(1, 72)
|
||||
self._ttl.setValue(6)
|
||||
self._path_style = QCheckBox("Path-style S3 addressing (required behind NGINX)", box)
|
||||
self._path_style.setChecked(True)
|
||||
self._store = QComboBox(box)
|
||||
self._store.addItems(["minio", "filesystem"])
|
||||
self._ssot = QLineEdit(box)
|
||||
self._ssot.setPlaceholderText("Path 1 fallback ssot_root only")
|
||||
|
||||
form.addRow("Device id", self._device)
|
||||
form.addRow("Endpoint URL", self._endpoint)
|
||||
form.addRow("Bucket", self._bucket)
|
||||
form.addRow("Region", self._region)
|
||||
form.addRow("Access key", self._access)
|
||||
form.addRow("Secret key", self._secret)
|
||||
form.addRow("Lease TTL (hours)", self._ttl)
|
||||
form.addRow("", self._path_style)
|
||||
form.addRow("Store backend", self._store)
|
||||
form.addRow("SSOT root (filesystem)", self._ssot)
|
||||
|
||||
layout.addWidget(box)
|
||||
|
||||
row = QHBoxLayout()
|
||||
save_btn = QPushButton("Save config", parent)
|
||||
save_btn.clicked.connect(self._on_save_config)
|
||||
doctor_btn = QPushButton("Doctor (probe)", parent)
|
||||
doctor_btn.clicked.connect(self._on_doctor)
|
||||
seed_btn = QPushButton("Seed example games", parent)
|
||||
seed_btn.clicked.connect(self._on_seed_games)
|
||||
units_btn = QPushButton("Install systemd units", parent)
|
||||
units_btn.clicked.connect(self._on_install_units)
|
||||
row.addWidget(save_btn)
|
||||
row.addWidget(doctor_btn)
|
||||
row.addWidget(seed_btn)
|
||||
row.addWidget(units_btn)
|
||||
layout.addLayout(row)
|
||||
|
||||
hint = QLabel(
|
||||
f"Config written to {_config_hint(self._config_root)}/agent.toml "
|
||||
"(mode 0600). CLI remains available as syncgames.",
|
||||
parent,
|
||||
)
|
||||
hint.setWordWrap(True)
|
||||
layout.addWidget(hint)
|
||||
layout.addStretch(1)
|
||||
return parent
|
||||
|
||||
def _build_games(self, parent: QWidget) -> QWidget:
|
||||
layout = QVBoxLayout(parent)
|
||||
self._games_list = QListWidget(parent)
|
||||
self._games_list.itemSelectionChanged.connect(self._on_game_selection_changed)
|
||||
self._games_list.itemDoubleClicked.connect(lambda _item: self._load_selected_game())
|
||||
layout.addWidget(self._games_list)
|
||||
|
||||
form_box = QGroupBox("Add / edit game", parent)
|
||||
form = QFormLayout(form_box)
|
||||
self._g_id = QLineEdit(form_box)
|
||||
self._g_id.setPlaceholderText("auto from name when adding")
|
||||
self._g_id.setReadOnly(True)
|
||||
self._g_name = QLineEdit(form_box)
|
||||
self._g_platform = QComboBox(form_box)
|
||||
self._g_platform.addItems(["steam", "eden", "yuzu", "other"])
|
||||
self._g_paths = QPlainTextEdit(form_box)
|
||||
self._g_paths.setPlaceholderText("One save path per line")
|
||||
self._g_paths.setFixedHeight(72)
|
||||
self._g_versions = QSpinBox(form_box)
|
||||
self._g_versions.setRange(3, 20)
|
||||
self._g_versions.setValue(5)
|
||||
self._g_appid = QLineEdit(form_box)
|
||||
self._g_proc = QLineEdit(form_box)
|
||||
self._g_proc.setPlaceholderText("eldenring.exe (comma-separated ok)")
|
||||
|
||||
browse = QPushButton("Add folder…", form_box)
|
||||
browse.clicked.connect(self._browse_save_path)
|
||||
path_row = QHBoxLayout()
|
||||
path_row.addWidget(self._g_paths, 1)
|
||||
path_row.addWidget(browse)
|
||||
path_wrap = QWidget(form_box)
|
||||
path_wrap.setLayout(path_row)
|
||||
|
||||
form.addRow("Game id", self._g_id)
|
||||
form.addRow("Name", self._g_name)
|
||||
form.addRow("Platform", self._g_platform)
|
||||
form.addRow("Save path(s)", path_wrap)
|
||||
form.addRow("Versions to keep", self._g_versions)
|
||||
form.addRow("Steam AppID", self._g_appid)
|
||||
form.addRow("Process name(s)", self._g_proc)
|
||||
layout.addWidget(form_box)
|
||||
|
||||
row = QHBoxLayout()
|
||||
load_btn = QPushButton("Load selected", parent)
|
||||
load_btn.clicked.connect(self._load_selected_game)
|
||||
add_btn = QPushButton("Add new", parent)
|
||||
add_btn.clicked.connect(self._on_add_game)
|
||||
save_btn = QPushButton("Save changes", parent)
|
||||
save_btn.clicked.connect(self._on_save_game)
|
||||
clear_btn = QPushButton("Clear form", parent)
|
||||
clear_btn.clicked.connect(self._clear_game_form)
|
||||
refresh_btn = QPushButton("Refresh", parent)
|
||||
refresh_btn.clicked.connect(self._refresh_games)
|
||||
remove_btn = QPushButton("Retire selected", parent)
|
||||
remove_btn.clicked.connect(self._on_retire_game)
|
||||
row.addWidget(load_btn)
|
||||
row.addWidget(add_btn)
|
||||
row.addWidget(save_btn)
|
||||
row.addWidget(clear_btn)
|
||||
row.addWidget(refresh_btn)
|
||||
row.addWidget(remove_btn)
|
||||
layout.addLayout(row)
|
||||
return parent
|
||||
|
||||
def _build_session(self, parent: QWidget) -> QWidget:
|
||||
layout = QVBoxLayout(parent)
|
||||
row = QHBoxLayout()
|
||||
self._session_game = QComboBox(parent)
|
||||
row.addWidget(QLabel("Game"))
|
||||
row.addWidget(self._session_game, 1)
|
||||
layout.addLayout(row)
|
||||
|
||||
btns = QHBoxLayout()
|
||||
for label, slot in (
|
||||
("Start session", self._on_start),
|
||||
("End session", self._on_end),
|
||||
("Status", self._on_status),
|
||||
("History", self._on_history),
|
||||
("Restore…", self._on_restore),
|
||||
):
|
||||
b = QPushButton(label, parent)
|
||||
b.clicked.connect(slot)
|
||||
btns.addWidget(b)
|
||||
layout.addLayout(btns)
|
||||
|
||||
warn = QLabel(
|
||||
"Start pulls SSOT → native saves and takes the lease. "
|
||||
"End pushes WIP → history + live and releases the lease. "
|
||||
"Never use continuous sync on live game folders.",
|
||||
parent,
|
||||
)
|
||||
warn.setWordWrap(True)
|
||||
layout.addWidget(warn)
|
||||
layout.addStretch(1)
|
||||
return parent
|
||||
|
||||
def _append_log(self, text: str) -> None:
|
||||
self._log.append(text.rstrip() + "\n")
|
||||
|
||||
def _on_bg_ok(self, text: str) -> None:
|
||||
self._append_log(text)
|
||||
if text.startswith("probe_ok"):
|
||||
QMessageBox.information(self, "Doctor", text)
|
||||
|
||||
def _on_bg_err(self, err: str) -> None:
|
||||
self._append_log(f"ERROR: {err}")
|
||||
# Short popup; full traceback stays in the log panel
|
||||
brief = err.split("\n\n", 1)[0]
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Operation failed",
|
||||
brief
|
||||
+ "\n\nTip: for LAN testing use endpoint http://192.168.1.5:9000 "
|
||||
"(leave Cloudflare URL until NGINX returns 200, not 502).",
|
||||
)
|
||||
|
||||
def _run_bg(self, label: str, fn) -> None:
|
||||
if self._thread is not None and self._thread.isRunning():
|
||||
QMessageBox.information(self, "Busy", "Wait for the current operation to finish.")
|
||||
return
|
||||
self._append_log(f"→ {label}…")
|
||||
thread = QThread(self)
|
||||
worker = Worker(fn)
|
||||
# Keep Python refs so Qt does not use-after-free the worker (AppImage crash).
|
||||
self._thread = thread
|
||||
self._worker = worker
|
||||
worker.moveToThread(thread)
|
||||
thread.started.connect(worker.run)
|
||||
worker.finished.connect(self._on_bg_ok)
|
||||
worker.failed.connect(self._on_bg_err)
|
||||
worker.finished.connect(thread.quit)
|
||||
worker.failed.connect(thread.quit)
|
||||
|
||||
def _clear() -> None:
|
||||
self._worker = None
|
||||
self._thread = None
|
||||
|
||||
thread.finished.connect(_clear)
|
||||
thread.start()
|
||||
|
||||
def _form_to_config(self) -> AgentConfig:
|
||||
ssot = self._ssot.text().strip()
|
||||
return AgentConfig(
|
||||
device_id=self._device.text().strip() or "pc-desk",
|
||||
endpoint_url=self._endpoint.text().strip(),
|
||||
bucket=self._bucket.text().strip() or "syncgames",
|
||||
region=self._region.text().strip() or "us-east-1",
|
||||
path_style=self._path_style.isChecked(),
|
||||
access_key=self._access.text().strip(),
|
||||
secret_key=self._secret.text().strip(),
|
||||
store=self._store.currentText(),
|
||||
ssot_root=Path(ssot).expanduser() if ssot else None,
|
||||
lease_ttl_hours=self._ttl.value(),
|
||||
config_root=self._config_root,
|
||||
)
|
||||
|
||||
def _load_config_into_form(self) -> None:
|
||||
cfg = try_load_agent_config(self._config_root)
|
||||
if cfg is None:
|
||||
self._device.setText("pc-desk")
|
||||
self._append_log(f"No config yet — will create {self._config_root / 'agent.toml'}")
|
||||
return
|
||||
self._config_root = cfg.config_root
|
||||
self._device.setText(cfg.device_id)
|
||||
self._endpoint.setText(cfg.endpoint_url)
|
||||
self._bucket.setText(cfg.bucket)
|
||||
self._region.setText(cfg.region)
|
||||
self._access.setText(cfg.access_key)
|
||||
self._secret.setText(cfg.secret_key)
|
||||
self._ttl.setValue(cfg.lease_ttl_hours)
|
||||
self._path_style.setChecked(cfg.path_style)
|
||||
idx = self._store.findText(cfg.store)
|
||||
if idx >= 0:
|
||||
self._store.setCurrentIndex(idx)
|
||||
if cfg.ssot_root:
|
||||
self._ssot.setText(str(cfg.ssot_root))
|
||||
self._append_log(f"Loaded config from {cfg.config_root / 'agent.toml'}")
|
||||
|
||||
def _require_cfg(self) -> AgentConfig:
|
||||
cfg = try_load_agent_config(self._config_root)
|
||||
if cfg is None:
|
||||
raise SyncGamesError("Save Setup config first", key="config_error")
|
||||
return cfg
|
||||
|
||||
def _on_save_config(self) -> None:
|
||||
cfg = self._form_to_config()
|
||||
path = save_agent_config(cfg)
|
||||
self._append_log(f"Saved {path}")
|
||||
QMessageBox.information(self, "Saved", f"Wrote {path}")
|
||||
|
||||
def _on_doctor(self) -> None:
|
||||
def work() -> str:
|
||||
cfg = self._form_to_config()
|
||||
save_agent_config(cfg)
|
||||
store = build_store(cfg)
|
||||
digest = store.probe()
|
||||
return f"probe_ok hash={digest}"
|
||||
|
||||
self._run_bg("doctor", work)
|
||||
|
||||
def _on_seed_games(self) -> None:
|
||||
cfg = self._form_to_config()
|
||||
self._config_root.mkdir(parents=True, exist_ok=True)
|
||||
save_agent_config(cfg)
|
||||
n = seed_example_games(cfg)
|
||||
self._refresh_games()
|
||||
self._append_log(f"Seeded {n} example game config(s)")
|
||||
|
||||
def _on_install_units(self) -> None:
|
||||
try:
|
||||
unit_dir = install_systemd_units()
|
||||
self._append_log(f"Installed systemd user units to {unit_dir}")
|
||||
self._append_log(
|
||||
"Enable a watcher: systemctl --user enable --now "
|
||||
"[email protected]"
|
||||
)
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Units installed",
|
||||
f"Copied units to {unit_dir}\n\n"
|
||||
"Then: systemctl --user daemon-reload\n"
|
||||
"Enable: systemctl --user enable --now syncgames-watch@<game-id>.service",
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
QMessageBox.critical(self, "Failed", str(e))
|
||||
|
||||
def _browse_save_path(self) -> None:
|
||||
path = QFileDialog.getExistingDirectory(self, "Select save folder")
|
||||
if not path:
|
||||
return
|
||||
existing = [ln.strip() for ln in self._g_paths.toPlainText().splitlines() if ln.strip()]
|
||||
if path not in existing:
|
||||
existing.append(path)
|
||||
self._g_paths.setPlainText("\n".join(existing))
|
||||
|
||||
def _game_paths_from_form(self) -> list[str]:
|
||||
return [ln.strip() for ln in self._g_paths.toPlainText().splitlines() if ln.strip()]
|
||||
|
||||
def _clear_game_form(self) -> None:
|
||||
self._editing_game_id = None
|
||||
self._g_id.clear()
|
||||
self._g_id.setPlaceholderText("auto from name when adding")
|
||||
self._g_name.clear()
|
||||
self._g_platform.setCurrentIndex(0)
|
||||
self._g_paths.clear()
|
||||
self._g_versions.setValue(5)
|
||||
self._g_appid.clear()
|
||||
self._g_proc.clear()
|
||||
|
||||
def _on_game_selection_changed(self) -> None:
|
||||
# Convenience: keep list selection in sync but don't auto-overwrite the form
|
||||
# unless the user clicks Load / double-clicks.
|
||||
pass
|
||||
|
||||
def _list_selected_game_id(self) -> str | None:
|
||||
item = self._games_list.currentItem()
|
||||
if not item:
|
||||
return None
|
||||
return item.text().split(" — ", 1)[0].strip()
|
||||
|
||||
def _load_selected_game(self) -> None:
|
||||
game_id = self._list_selected_game_id()
|
||||
if not game_id:
|
||||
QMessageBox.information(self, "Edit game", "Select a game in the list first.")
|
||||
return
|
||||
try:
|
||||
cfg = self._require_cfg()
|
||||
from syncgames.config import load_game
|
||||
|
||||
game = load_game(cfg, game_id)
|
||||
except SyncGamesError as e:
|
||||
QMessageBox.warning(self, "Edit game", str(e))
|
||||
return
|
||||
self._editing_game_id = game.id
|
||||
self._g_id.setText(game.id)
|
||||
self._g_name.setText(game.name)
|
||||
idx = self._g_platform.findText(game.platform)
|
||||
if idx >= 0:
|
||||
self._g_platform.setCurrentIndex(idx)
|
||||
self._g_paths.setPlainText("\n".join(game.paths))
|
||||
self._g_versions.setValue(game.versions_to_keep)
|
||||
self._g_appid.setText("" if game.steam_appid is None else str(game.steam_appid))
|
||||
self._g_proc.setText(", ".join(game.process_names))
|
||||
self._append_log(f"Loaded {game.id} into editor (Save changes to update)")
|
||||
|
||||
def _refresh_games(self) -> None:
|
||||
self._games_list.clear()
|
||||
self._session_game.clear()
|
||||
cfg = try_load_agent_config(self._config_root)
|
||||
if cfg is None:
|
||||
return
|
||||
for g in list_games(cfg):
|
||||
self._games_list.addItem(f"{g.id} — {g.name}")
|
||||
self._session_game.addItem(g.id, g.id)
|
||||
|
||||
def _on_add_game(self) -> None:
|
||||
try:
|
||||
cfg = self._require_cfg()
|
||||
name = self._g_name.text().strip()
|
||||
paths = self._game_paths_from_form()
|
||||
if not name or not paths:
|
||||
raise SyncGamesError("Name and at least one save path required", key="config_error")
|
||||
game_id = slugify(name)
|
||||
if (cfg.games_dir / f"{game_id}.toml").exists():
|
||||
raise SyncGamesError(
|
||||
f"Game id {game_id} already exists — Load selected + Save changes, or pick another name",
|
||||
key="config_error",
|
||||
)
|
||||
appid = self._g_appid.text().strip()
|
||||
procs = [p.strip() for p in self._g_proc.text().split(",") if p.strip()]
|
||||
game = GameConfig(
|
||||
id=game_id,
|
||||
name=name,
|
||||
platform=self._g_platform.currentText(),
|
||||
paths=paths,
|
||||
versions_to_keep=self._g_versions.value(),
|
||||
steam_appid=int(appid) if appid else None,
|
||||
process_names=procs,
|
||||
)
|
||||
write_game_toml(cfg, game)
|
||||
store = build_store(cfg)
|
||||
from syncgames.session import ensure_game_remote
|
||||
|
||||
ensure_game_remote(store, game)
|
||||
self._editing_game_id = game.id
|
||||
self._g_id.setText(game.id)
|
||||
self._append_log(f"Added game {game.id}")
|
||||
self._refresh_games()
|
||||
except SyncGamesError as e:
|
||||
QMessageBox.warning(self, "Add game", str(e))
|
||||
|
||||
def _on_save_game(self) -> None:
|
||||
try:
|
||||
cfg = self._require_cfg()
|
||||
game_id = self._editing_game_id or self._g_id.text().strip() or self._list_selected_game_id()
|
||||
if not game_id:
|
||||
raise SyncGamesError(
|
||||
"Load a game first (Load selected / double-click), then Save changes",
|
||||
key="config_error",
|
||||
)
|
||||
name = self._g_name.text().strip()
|
||||
paths = self._game_paths_from_form()
|
||||
if not name or not paths:
|
||||
raise SyncGamesError("Name and at least one save path required", key="config_error")
|
||||
if not (cfg.games_dir / f"{game_id}.toml").exists():
|
||||
raise SyncGamesError(f"Unknown game id {game_id}", key="config_error")
|
||||
appid = self._g_appid.text().strip()
|
||||
procs = [p.strip() for p in self._g_proc.text().split(",") if p.strip()]
|
||||
game = GameConfig(
|
||||
id=game_id, # keep stable so MinIO prefixes stay valid
|
||||
name=name,
|
||||
platform=self._g_platform.currentText(),
|
||||
paths=paths,
|
||||
versions_to_keep=self._g_versions.value(),
|
||||
steam_appid=int(appid) if appid else None,
|
||||
process_names=procs,
|
||||
)
|
||||
write_game_toml(cfg, game)
|
||||
self._editing_game_id = game_id
|
||||
self._g_id.setText(game_id)
|
||||
self._append_log(f"Updated game {game_id}")
|
||||
self._refresh_games()
|
||||
QMessageBox.information(self, "Saved", f"Updated {game_id} (game id kept stable for MinIO)")
|
||||
except SyncGamesError as e:
|
||||
QMessageBox.warning(self, "Save changes", str(e))
|
||||
except ValueError as e:
|
||||
QMessageBox.warning(self, "Save changes", str(e))
|
||||
|
||||
def _on_retire_game(self) -> None:
|
||||
game_id = self._list_selected_game_id()
|
||||
if not game_id:
|
||||
return
|
||||
if QMessageBox.question(self, "Retire", f"Retire remote + local config for {game_id}?") != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
def work() -> str:
|
||||
cfg = self._require_cfg()
|
||||
from syncgames.config import load_game
|
||||
|
||||
game = load_game(cfg, game_id)
|
||||
store = build_store(cfg)
|
||||
dest = store.retire_game(game.id)
|
||||
src = cfg.games_dir / f"{game.id}.toml"
|
||||
if src.exists():
|
||||
src.rename(src.with_suffix(".toml.removed"))
|
||||
return f"Retired {game_id} → {dest}"
|
||||
|
||||
if self._editing_game_id == game_id:
|
||||
self._clear_game_form()
|
||||
self._run_bg("retire", work)
|
||||
self._refresh_games()
|
||||
|
||||
def _selected_game_id(self) -> str:
|
||||
return self._session_game.currentText().strip()
|
||||
|
||||
def _on_start(self) -> None:
|
||||
gid = self._selected_game_id()
|
||||
if not gid:
|
||||
return
|
||||
|
||||
def work() -> str:
|
||||
cfg = self._require_cfg()
|
||||
from syncgames.config import load_game
|
||||
|
||||
game = load_game(cfg, gid)
|
||||
meta = start_session(cfg, build_store(cfg), game)
|
||||
return f"Started {gid}; lease={meta.lease}; live_hash={meta.live_hash}"
|
||||
|
||||
self._run_bg(f"start {gid}", work)
|
||||
|
||||
def _on_end(self) -> None:
|
||||
gid = self._selected_game_id()
|
||||
if not gid:
|
||||
return
|
||||
|
||||
def work() -> str:
|
||||
cfg = self._require_cfg()
|
||||
from syncgames.config import load_game
|
||||
|
||||
game = load_game(cfg, gid)
|
||||
meta = end_session(cfg, build_store(cfg), game)
|
||||
return f"Ended {gid}; live_hash={meta.live_hash}"
|
||||
|
||||
self._run_bg(f"end {gid}", work)
|
||||
|
||||
def _on_status(self) -> None:
|
||||
gid = self._selected_game_id()
|
||||
|
||||
def work() -> str:
|
||||
cfg = self._require_cfg()
|
||||
from syncgames.config import load_game
|
||||
|
||||
game = load_game(cfg, gid) if gid else None
|
||||
return status_text(cfg, build_store(cfg), game)
|
||||
|
||||
self._run_bg("status", work)
|
||||
|
||||
def _on_history(self) -> None:
|
||||
gid = self._selected_game_id()
|
||||
if not gid:
|
||||
return
|
||||
|
||||
def work() -> str:
|
||||
cfg = self._require_cfg()
|
||||
items = build_store(cfg).list_history(gid)
|
||||
return "history:\n" + ("\n".join(items) if items else "(empty)")
|
||||
|
||||
self._run_bg(f"history {gid}", work)
|
||||
|
||||
def _on_restore(self) -> None:
|
||||
gid = self._selected_game_id()
|
||||
if not gid:
|
||||
return
|
||||
cfg = try_load_agent_config(self._config_root)
|
||||
if cfg is None:
|
||||
QMessageBox.warning(self, "Restore", "Save config first")
|
||||
return
|
||||
items = build_store(cfg).list_history(gid)
|
||||
if not items:
|
||||
QMessageBox.information(self, "Restore", "No history entries")
|
||||
return
|
||||
choice, ok = QInputDialog.getItem(self, "Restore", "History prefix", items, 0, False)
|
||||
if not ok or not choice:
|
||||
return
|
||||
confirm, ok2 = QInputDialog.getText(
|
||||
self, "Confirm", f'Type YES to restore live from "{choice}"'
|
||||
)
|
||||
if not ok2 or confirm != "YES":
|
||||
self._append_log("Restore aborted")
|
||||
return
|
||||
|
||||
def work() -> str:
|
||||
from syncgames.config import load_game
|
||||
|
||||
game = load_game(cfg, gid)
|
||||
meta = restore_session(cfg, build_store(cfg), game, choice)
|
||||
return f"Restored {choice}; live_hash={meta.live_hash}"
|
||||
|
||||
self._run_bg(f"restore {choice}", work)
|
||||
|
||||
|
||||
def _version_label() -> str:
|
||||
return __version__
|
||||
|
||||
|
||||
def _config_hint(root: Path) -> str:
|
||||
return str(root)
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||
h.update(chunk)
|
||||
return f"sha256:{h.hexdigest()}"
|
||||
|
||||
|
||||
def sha256_bytes(data: bytes) -> str:
|
||||
return f"sha256:{hashlib.sha256(data).hexdigest()}"
|
||||
|
||||
|
||||
def tree_hash(file_checksums: dict[str, str]) -> str:
|
||||
"""Canonical tree hash per docs/protocol.md."""
|
||||
lines: list[bytes] = []
|
||||
for rel in sorted(file_checksums.keys()):
|
||||
digest = file_checksums[rel]
|
||||
if digest.startswith("sha256:"):
|
||||
hexdigest = digest[len("sha256:") :]
|
||||
else:
|
||||
hexdigest = digest
|
||||
lines.append(f"{rel}\0{hexdigest}\n".encode("utf-8"))
|
||||
return sha256_bytes(b"".join(lines))
|
||||
|
||||
|
||||
def checksum_dir(root: Path, files: list[Path] | None = None) -> dict[str, str]:
|
||||
"""Hash files under root; keys are relative posix paths."""
|
||||
out: dict[str, str] = {}
|
||||
if files is None:
|
||||
paths = sorted(p for p in root.rglob("*") if p.is_file())
|
||||
else:
|
||||
paths = files
|
||||
for p in paths:
|
||||
rel = p.relative_to(root).as_posix()
|
||||
out[rel] = sha256_file(p)
|
||||
return out
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from syncgames.errors import LeaseHeldError
|
||||
from syncgames.store import Meta, ObjectStore, parse_expires, utc_now
|
||||
|
||||
|
||||
def lease_active(lease: dict[str, Any] | None) -> bool:
|
||||
if not lease:
|
||||
return False
|
||||
exp = parse_expires(lease.get("expires_at"))
|
||||
if exp is None:
|
||||
return False
|
||||
return exp > utc_now()
|
||||
|
||||
|
||||
def acquire_lease(
|
||||
store: ObjectStore,
|
||||
game_id: str,
|
||||
device_id: str,
|
||||
*,
|
||||
ttl_hours: int,
|
||||
versions_to_keep: int,
|
||||
break_other: bool = False,
|
||||
) -> Meta:
|
||||
meta = store.get_meta(game_id)
|
||||
if meta is None:
|
||||
from syncgames.store import empty_meta
|
||||
|
||||
meta = empty_meta(game_id, versions_to_keep)
|
||||
|
||||
lease = meta.lease
|
||||
if lease_active(lease) and lease and lease.get("holder") != device_id:
|
||||
if not break_other:
|
||||
raise LeaseHeldError(
|
||||
f"Lease held by {lease.get('holder')} until {lease.get('expires_at')}"
|
||||
)
|
||||
|
||||
session_id = str(uuid.uuid4())
|
||||
expires = utc_now() + timedelta(hours=ttl_hours)
|
||||
meta.raw["lease"] = {
|
||||
"holder": device_id,
|
||||
"expires_at": expires.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"session_id": session_id,
|
||||
}
|
||||
meta.raw["versions_to_keep"] = versions_to_keep
|
||||
store.put_meta(game_id, meta)
|
||||
# Re-read to reduce lost-update window (optimistic)
|
||||
again = store.get_meta(game_id)
|
||||
if again is None or not again.lease or again.lease.get("holder") != device_id:
|
||||
raise LeaseHeldError("Lost lease race — another device acquired the lease")
|
||||
return again
|
||||
|
||||
|
||||
def release_lease(store: ObjectStore, game_id: str, device_id: str, meta: Meta | None = None) -> Meta:
|
||||
meta = meta or store.get_meta(game_id)
|
||||
if meta is None:
|
||||
from syncgames.store import empty_meta
|
||||
|
||||
meta = empty_meta(game_id)
|
||||
lease = meta.lease
|
||||
if lease and lease.get("holder") not in (None, device_id):
|
||||
if lease_active(lease):
|
||||
raise LeaseHeldError(f"Cannot release lease owned by {lease.get('holder')}")
|
||||
meta.raw["lease"] = None
|
||||
store.put_meta(game_id, meta)
|
||||
return meta
|
||||
|
||||
|
||||
def break_lease(store: ObjectStore, game_id: str) -> Meta:
|
||||
from syncgames.store import empty_meta
|
||||
|
||||
meta = store.get_meta(game_id) or empty_meta(game_id)
|
||||
meta.raw["lease"] = None
|
||||
store.put_meta(game_id, meta)
|
||||
return meta
|
||||
@@ -0,0 +1,260 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from syncgames.config import AgentConfig, GameConfig
|
||||
from syncgames.errors import GameRunningError, StaleWipError, SyncGamesError
|
||||
from syncgames.hashutil import checksum_dir, tree_hash
|
||||
from syncgames.lease import acquire_lease, release_lease
|
||||
from syncgames.store import Meta, ObjectStore, empty_meta, iso_ts, utc_now
|
||||
from syncgames.watchers import is_game_running
|
||||
|
||||
|
||||
def session_path(cfg: AgentConfig, game_id: str) -> Path:
|
||||
return cfg.state_root / "sessions" / f"{game_id}.json"
|
||||
|
||||
|
||||
def wip_dir(cfg: AgentConfig, game_id: str) -> Path:
|
||||
return cfg.state_root / "wip" / game_id
|
||||
|
||||
|
||||
def load_session(cfg: AgentConfig, game_id: str) -> dict | None:
|
||||
p = session_path(cfg, game_id)
|
||||
if not p.exists():
|
||||
return None
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def save_session(cfg: AgentConfig, game_id: str, data: dict) -> None:
|
||||
p = session_path(cfg, game_id)
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def clear_session(cfg: AgentConfig, game_id: str) -> None:
|
||||
p = session_path(cfg, game_id)
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
w = wip_dir(cfg, game_id)
|
||||
if w.exists():
|
||||
shutil.rmtree(w)
|
||||
|
||||
|
||||
def collect_native_files(game: GameConfig) -> list[tuple[Path, str]]:
|
||||
"""Return (absolute_path, archive_relpath) for all save files."""
|
||||
out: list[tuple[Path, str]] = []
|
||||
for base in game.resolved_paths():
|
||||
if not base.exists():
|
||||
continue
|
||||
if base.is_file():
|
||||
out.append((base, base.name))
|
||||
continue
|
||||
for path in sorted(p for p in base.rglob("*") if p.is_file()):
|
||||
# Skip obvious temp / lock clutter
|
||||
if path.suffix.lower() in {".tmp", ".lock", ".part"}:
|
||||
continue
|
||||
if path.name.startswith("."):
|
||||
continue
|
||||
rel = f"{base.name}/{path.relative_to(base).as_posix()}"
|
||||
out.append((path, rel))
|
||||
return out
|
||||
|
||||
|
||||
def stage_native_to_wip(cfg: AgentConfig, game: GameConfig) -> Path:
|
||||
dest = wip_dir(cfg, game.id)
|
||||
if dest.exists():
|
||||
shutil.rmtree(dest)
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
for src, rel in collect_native_files(game):
|
||||
target = dest / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, target)
|
||||
return dest
|
||||
|
||||
|
||||
def install_staging_to_native(staging: Path, game: GameConfig) -> None:
|
||||
"""Map staging layout back onto configured native paths."""
|
||||
# Staging uses <basename>/<rel> for directories and <name> for single files
|
||||
for base in game.resolved_paths():
|
||||
if base.suffix and not base.exists() and not any(base.parent.glob(base.name)):
|
||||
# treat as file target
|
||||
candidate = staging / base.name
|
||||
if candidate.is_file():
|
||||
base.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(candidate, base)
|
||||
continue
|
||||
# Directory target: look for staging/<basename>/
|
||||
staged = staging / base.name
|
||||
if staged.is_dir():
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
for path in staged.rglob("*"):
|
||||
if path.is_file():
|
||||
rel = path.relative_to(staged)
|
||||
target = base / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(path, target)
|
||||
elif (staging / base.name).is_file() and base.parent.exists():
|
||||
# file named same as last component under parent
|
||||
shutil.copy2(staging / base.name, base)
|
||||
|
||||
|
||||
def wait_until_idle(game: GameConfig, *, timeout_s: float = 30.0) -> None:
|
||||
"""Poll until the game process exits or timeout."""
|
||||
deadline = time.time() + timeout_s
|
||||
while is_game_running(game):
|
||||
if time.time() > deadline:
|
||||
raise GameRunningError(
|
||||
f"Game still running for {game.id} after {timeout_s:.0f}s — quit the game or use --force"
|
||||
)
|
||||
time.sleep(1.0)
|
||||
|
||||
|
||||
def start_session(cfg: AgentConfig, store: ObjectStore, game: GameConfig) -> Meta:
|
||||
meta = acquire_lease(
|
||||
store,
|
||||
game.id,
|
||||
cfg.device_id,
|
||||
ttl_hours=cfg.lease_ttl_hours,
|
||||
versions_to_keep=game.versions_to_keep,
|
||||
)
|
||||
staging = wip_dir(cfg, game.id)
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
staging.mkdir(parents=True, exist_ok=True)
|
||||
checksums = store.download_live(game.id, staging)
|
||||
install_staging_to_native(staging, game)
|
||||
parent = meta.live_hash or tree_hash(checksums) if checksums else None
|
||||
if checksums and not meta.live_hash:
|
||||
parent = tree_hash(checksums)
|
||||
save_session(
|
||||
cfg,
|
||||
game.id,
|
||||
{
|
||||
"game_id": game.id,
|
||||
"parent_live_hash": parent,
|
||||
"session_id": (meta.lease or {}).get("session_id"),
|
||||
"started_at": utc_now().strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"device_id": cfg.device_id,
|
||||
},
|
||||
)
|
||||
return meta
|
||||
|
||||
|
||||
def end_session(
|
||||
cfg: AgentConfig,
|
||||
store: ObjectStore,
|
||||
game: GameConfig,
|
||||
*,
|
||||
force: bool = False,
|
||||
force_hash: bool = False,
|
||||
) -> Meta:
|
||||
sess = load_session(cfg, game.id)
|
||||
if not sess:
|
||||
raise SyncGamesError(
|
||||
f"No local session for {game.id}; run start first", key="config_error"
|
||||
)
|
||||
if not force:
|
||||
try:
|
||||
wait_until_idle(game)
|
||||
except GameRunningError:
|
||||
raise
|
||||
if is_game_running(game) and not force:
|
||||
raise GameRunningError(f"Refuse end while {game.id} process is running")
|
||||
|
||||
staging = stage_native_to_wip(cfg, game)
|
||||
checksums = checksum_dir(staging)
|
||||
wip_hash = tree_hash(checksums) if checksums else None
|
||||
|
||||
meta = store.get_meta(game.id) or empty_meta(game.id, game.versions_to_keep)
|
||||
current_live = meta.live_hash
|
||||
parent = sess.get("parent_live_hash")
|
||||
if current_live and parent and current_live != parent and not force_hash:
|
||||
raise StaleWipError(
|
||||
f"Stale WIP: parent {parent} != live {current_live}. "
|
||||
"Refuse push to avoid LOPE. Use --force-restore only if intentional."
|
||||
)
|
||||
|
||||
ts = iso_ts()
|
||||
hist_prefix = f"{cfg.device_id}/{ts}"
|
||||
store.upload_prefix(game.id, "history", hist_prefix, staging)
|
||||
store.upload_prefix(game.id, "live", "", staging)
|
||||
store.delete_live_not_in(game.id, set(checksums.keys()))
|
||||
|
||||
meta.raw["live_hash"] = wip_hash
|
||||
meta.raw["file_checksums"] = checksums
|
||||
meta.raw["updated_at"] = utc_now().strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
meta.raw["updated_by"] = cfg.device_id
|
||||
meta.raw["versions_to_keep"] = game.versions_to_keep
|
||||
meta.raw["lease"] = None
|
||||
store.put_meta(game.id, meta)
|
||||
|
||||
store.prune_device_history(game.id, cfg.device_id, game.versions_to_keep)
|
||||
clear_session(cfg, game.id)
|
||||
return meta
|
||||
|
||||
|
||||
def restore_session(
|
||||
cfg: AgentConfig,
|
||||
store: ObjectStore,
|
||||
game: GameConfig,
|
||||
device_ts: str,
|
||||
) -> Meta:
|
||||
acquire_lease(
|
||||
store,
|
||||
game.id,
|
||||
cfg.device_id,
|
||||
ttl_hours=cfg.lease_ttl_hours,
|
||||
versions_to_keep=game.versions_to_keep,
|
||||
)
|
||||
store.copy_history_to_live(game.id, device_ts)
|
||||
staging = wip_dir(cfg, game.id)
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
checksums = store.download_live(game.id, staging)
|
||||
live_hash = tree_hash(checksums) if checksums else None
|
||||
meta = store.get_meta(game.id) or empty_meta(game.id, game.versions_to_keep)
|
||||
meta.raw["live_hash"] = live_hash
|
||||
meta.raw["file_checksums"] = checksums
|
||||
meta.raw["updated_at"] = utc_now().strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
meta.raw["updated_by"] = cfg.device_id
|
||||
store.put_meta(game.id, meta)
|
||||
# Leave lease held; user should start-equivalent install
|
||||
install_staging_to_native(staging, game)
|
||||
save_session(
|
||||
cfg,
|
||||
game.id,
|
||||
{
|
||||
"game_id": game.id,
|
||||
"parent_live_hash": live_hash,
|
||||
"session_id": (meta.lease or {}).get("session_id"),
|
||||
"started_at": utc_now().strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"device_id": cfg.device_id,
|
||||
"restored_from": device_ts,
|
||||
},
|
||||
)
|
||||
return meta
|
||||
|
||||
|
||||
def ensure_game_remote(store: ObjectStore, game: GameConfig) -> None:
|
||||
meta = store.get_meta(game.id)
|
||||
if meta is None:
|
||||
m = empty_meta(game.id, game.versions_to_keep)
|
||||
store.put_meta(game.id, m)
|
||||
|
||||
|
||||
def status_text(cfg: AgentConfig, store: ObjectStore, game: GameConfig | None) -> str:
|
||||
lines: list[str] = [f"device: {cfg.device_id}", f"store: {cfg.store}"]
|
||||
games = [game] if game else __import__("syncgames.config", fromlist=["list_games"]).list_games(cfg)
|
||||
for g in games:
|
||||
meta = store.get_meta(g.id)
|
||||
sess = load_session(cfg, g.id)
|
||||
lease = meta.lease if meta else None
|
||||
lines.append(f"\n[{g.id}] {g.name}")
|
||||
lines.append(f" live_hash: {(meta.live_hash if meta else None)}")
|
||||
lines.append(f" lease: {lease}")
|
||||
lines.append(f" local_session: {sess}")
|
||||
lines.append(f" running: {is_game_running(g)}")
|
||||
return "\n".join(lines) + "\n"
|
||||
@@ -0,0 +1,485 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from syncgames.errors import StoreError
|
||||
from syncgames.hashutil import sha256_bytes
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def iso_ts(dt: datetime | None = None) -> str:
|
||||
d = dt or utc_now()
|
||||
return d.strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
|
||||
def parse_expires(s: str | None) -> datetime | None:
|
||||
if not s:
|
||||
return None
|
||||
if s.endswith("Z"):
|
||||
s = s[:-1] + "+00:00"
|
||||
return datetime.fromisoformat(s)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Meta:
|
||||
raw: dict[str, Any]
|
||||
|
||||
@property
|
||||
def live_hash(self) -> str | None:
|
||||
return self.raw.get("live_hash")
|
||||
|
||||
@property
|
||||
def lease(self) -> dict[str, Any] | None:
|
||||
return self.raw.get("lease")
|
||||
|
||||
@property
|
||||
def versions_to_keep(self) -> int:
|
||||
return int(self.raw.get("versions_to_keep", 5))
|
||||
|
||||
def to_json(self) -> bytes:
|
||||
return (json.dumps(self.raw, indent=2, sort_keys=True) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
class ObjectStore(ABC):
|
||||
@abstractmethod
|
||||
def get_meta(self, game_id: str) -> Meta | None: ...
|
||||
|
||||
@abstractmethod
|
||||
def put_meta(self, game_id: str, meta: Meta) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
def list_live(self, game_id: str) -> list[str]: ...
|
||||
|
||||
@abstractmethod
|
||||
def download_live(self, game_id: str, dest_dir: Path) -> dict[str, str]: ...
|
||||
|
||||
@abstractmethod
|
||||
def upload_prefix(
|
||||
self, game_id: str, kind: str, prefix_extra: str, local_dir: Path
|
||||
) -> dict[str, str]:
|
||||
"""kind is 'live' or 'history'; prefix_extra for history is 'device/ts'."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def delete_live_not_in(self, game_id: str, keep_rels: set[str]) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
def list_history(self, game_id: str) -> list[str]:
|
||||
"""Return list of 'device/ts' prefixes newest-first-ish."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def copy_history_to_live(self, game_id: str, device_ts: str) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
def prune_device_history(self, game_id: str, device_id: str, keep: int) -> int: ...
|
||||
|
||||
@abstractmethod
|
||||
def retire_game(self, game_id: str) -> str: ...
|
||||
|
||||
@abstractmethod
|
||||
def probe(self) -> str: ...
|
||||
|
||||
@abstractmethod
|
||||
def export_ssot(self, out_dir: Path) -> None: ...
|
||||
|
||||
|
||||
def empty_meta(game_id: str, versions_to_keep: int = 5) -> Meta:
|
||||
return Meta(
|
||||
{
|
||||
"schema": 1,
|
||||
"game_id": game_id,
|
||||
"live_hash": None,
|
||||
"file_checksums": {},
|
||||
"lease": None,
|
||||
"versions_to_keep": versions_to_keep,
|
||||
"updated_at": utc_now().strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"updated_by": None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class MinioStore(ObjectStore):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
endpoint_url: str,
|
||||
bucket: str,
|
||||
access_key: str,
|
||||
secret_key: str,
|
||||
region: str = "us-east-1",
|
||||
path_style: bool = True,
|
||||
) -> None:
|
||||
import boto3
|
||||
from botocore.client import Config
|
||||
|
||||
if not endpoint_url or not access_key or not secret_key:
|
||||
raise StoreError(
|
||||
"MinIO endpoint_url, access_key, and secret_key are required",
|
||||
key="config_error",
|
||||
)
|
||||
self.bucket = bucket
|
||||
cfg = Config(
|
||||
signature_version="s3v4",
|
||||
s3={"addressing_style": "path" if path_style else "auto"},
|
||||
connect_timeout=10,
|
||||
read_timeout=60,
|
||||
retries={"max_attempts": 2, "mode": "standard"},
|
||||
)
|
||||
self.s3 = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=endpoint_url.rstrip("/"),
|
||||
aws_access_key_id=access_key,
|
||||
aws_secret_access_key=secret_key,
|
||||
region_name=region,
|
||||
config=cfg,
|
||||
)
|
||||
|
||||
def _key(self, *parts: str) -> str:
|
||||
return "/".join(p.strip("/") for p in parts if p)
|
||||
|
||||
def get_meta(self, game_id: str) -> Meta | None:
|
||||
key = self._key("games", game_id, "meta.json")
|
||||
try:
|
||||
obj = self.s3.get_object(Bucket=self.bucket, Key=key)
|
||||
data = json.loads(obj["Body"].read().decode("utf-8"))
|
||||
return Meta(data)
|
||||
except self.s3.exceptions.NoSuchKey:
|
||||
return None
|
||||
except Exception as e: # noqa: BLE001
|
||||
# boto3 often raises ClientError
|
||||
code = getattr(e, "response", {}).get("Error", {}).get("Code", "")
|
||||
if code in {"404", "NoSuchKey", "NotFound"}:
|
||||
return None
|
||||
raise StoreError(f"get_meta failed: {e}") from e
|
||||
|
||||
def put_meta(self, game_id: str, meta: Meta) -> None:
|
||||
key = self._key("games", game_id, "meta.json")
|
||||
try:
|
||||
self.s3.put_object(
|
||||
Bucket=self.bucket,
|
||||
Key=key,
|
||||
Body=meta.to_json(),
|
||||
ContentType="application/json",
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise StoreError(f"put_meta failed: {e}") from e
|
||||
|
||||
def list_live(self, game_id: str) -> list[str]:
|
||||
prefix = self._key("games", game_id, "live") + "/"
|
||||
return self._list_rels(prefix)
|
||||
|
||||
def _list_rels(self, prefix: str) -> list[str]:
|
||||
rels: list[str] = []
|
||||
token = None
|
||||
while True:
|
||||
kw: dict[str, Any] = {"Bucket": self.bucket, "Prefix": prefix}
|
||||
if token:
|
||||
kw["ContinuationToken"] = token
|
||||
resp = self.s3.list_objects_v2(**kw)
|
||||
for item in resp.get("Contents") or []:
|
||||
key = item["Key"]
|
||||
if key.endswith("/"):
|
||||
continue
|
||||
rels.append(key[len(prefix) :])
|
||||
if not resp.get("IsTruncated"):
|
||||
break
|
||||
token = resp.get("NextContinuationToken")
|
||||
return rels
|
||||
|
||||
def download_live(self, game_id: str, dest_dir: Path) -> dict[str, str]:
|
||||
from syncgames.hashutil import sha256_file
|
||||
|
||||
prefix = self._key("games", game_id, "live") + "/"
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
checksums: dict[str, str] = {}
|
||||
for rel in self.list_live(game_id):
|
||||
target = dest_dir / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.s3.download_file(self.bucket, prefix + rel, str(target))
|
||||
checksums[rel] = sha256_file(target)
|
||||
return checksums
|
||||
|
||||
def upload_prefix(
|
||||
self, game_id: str, kind: str, prefix_extra: str, local_dir: Path
|
||||
) -> dict[str, str]:
|
||||
from syncgames.hashutil import sha256_file
|
||||
|
||||
if kind == "live":
|
||||
base = self._key("games", game_id, "live")
|
||||
elif kind == "history":
|
||||
base = self._key("games", game_id, "history", prefix_extra)
|
||||
else:
|
||||
raise StoreError(f"Unknown kind {kind}")
|
||||
checksums: dict[str, str] = {}
|
||||
for path in sorted(p for p in local_dir.rglob("*") if p.is_file()):
|
||||
rel = path.relative_to(local_dir).as_posix()
|
||||
key = f"{base}/{rel}"
|
||||
self.s3.upload_file(str(path), self.bucket, key)
|
||||
checksums[rel] = sha256_file(path)
|
||||
return checksums
|
||||
|
||||
def delete_live_not_in(self, game_id: str, keep_rels: set[str]) -> None:
|
||||
prefix = self._key("games", game_id, "live") + "/"
|
||||
for rel in self.list_live(game_id):
|
||||
if rel not in keep_rels:
|
||||
self.s3.delete_object(Bucket=self.bucket, Key=prefix + rel)
|
||||
|
||||
def list_history(self, game_id: str) -> list[str]:
|
||||
prefix = self._key("games", game_id, "history") + "/"
|
||||
devices_ts: set[str] = set()
|
||||
token = None
|
||||
while True:
|
||||
kw: dict[str, Any] = {"Bucket": self.bucket, "Prefix": prefix}
|
||||
if token:
|
||||
kw["ContinuationToken"] = token
|
||||
resp = self.s3.list_objects_v2(**kw)
|
||||
for item in resp.get("Contents") or []:
|
||||
key = item["Key"][len(prefix) :]
|
||||
parts = key.split("/")
|
||||
if len(parts) >= 2:
|
||||
devices_ts.add(f"{parts[0]}/{parts[1]}")
|
||||
if not resp.get("IsTruncated"):
|
||||
break
|
||||
token = resp.get("NextContinuationToken")
|
||||
return sorted(devices_ts, reverse=True)
|
||||
|
||||
def copy_history_to_live(self, game_id: str, device_ts: str) -> None:
|
||||
src_prefix = self._key("games", game_id, "history", device_ts) + "/"
|
||||
# Clear live then copy
|
||||
for rel in self.list_live(game_id):
|
||||
self.s3.delete_object(
|
||||
Bucket=self.bucket, Key=self._key("games", game_id, "live", rel)
|
||||
)
|
||||
for rel in self._list_rels(src_prefix):
|
||||
src = src_prefix + rel
|
||||
dst = self._key("games", game_id, "live", rel)
|
||||
self.s3.copy_object(
|
||||
Bucket=self.bucket,
|
||||
CopySource={"Bucket": self.bucket, "Key": src},
|
||||
Key=dst,
|
||||
)
|
||||
|
||||
def prune_device_history(self, game_id: str, device_id: str, keep: int) -> int:
|
||||
prefix = self._key("games", game_id, "history", device_id) + "/"
|
||||
stamps: set[str] = set()
|
||||
for rel in self._list_rels(prefix):
|
||||
stamps.add(rel.split("/", 1)[0])
|
||||
ordered = sorted(stamps, reverse=True)
|
||||
removed = 0
|
||||
for stamp in ordered[keep:]:
|
||||
for rel in self._list_rels(prefix + stamp + "/"):
|
||||
self.s3.delete_object(Bucket=self.bucket, Key=prefix + stamp + "/" + rel)
|
||||
removed += 1
|
||||
return removed
|
||||
|
||||
def retire_game(self, game_id: str) -> str:
|
||||
date = utc_now().strftime("%Y%m%d")
|
||||
dest = f"retired/{game_id}-{date}"
|
||||
src_prefix = self._key("games", game_id) + "/"
|
||||
for rel in self._list_rels(src_prefix):
|
||||
src = src_prefix + rel
|
||||
self.s3.copy_object(
|
||||
Bucket=self.bucket,
|
||||
CopySource={"Bucket": self.bucket, "Key": src},
|
||||
Key=f"{dest}/{rel}",
|
||||
)
|
||||
self.s3.delete_object(Bucket=self.bucket, Key=src)
|
||||
# meta.json
|
||||
meta_key = self._key("games", game_id, "meta.json")
|
||||
try:
|
||||
self.s3.copy_object(
|
||||
Bucket=self.bucket,
|
||||
CopySource={"Bucket": self.bucket, "Key": meta_key},
|
||||
Key=f"{dest}/meta.json",
|
||||
)
|
||||
self.s3.delete_object(Bucket=self.bucket, Key=meta_key)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return dest
|
||||
|
||||
def probe(self) -> str:
|
||||
key = self._key("games", "_probe", f"ping-{iso_ts()}.txt")
|
||||
body = b"syncgames-probe\n"
|
||||
self.s3.put_object(Bucket=self.bucket, Key=key, Body=body)
|
||||
got = self.s3.get_object(Bucket=self.bucket, Key=key)["Body"].read()
|
||||
self.s3.delete_object(Bucket=self.bucket, Key=key)
|
||||
if got != body:
|
||||
raise StoreError("probe mismatch — possible NGINX buffering corruption")
|
||||
return sha256_bytes(body)
|
||||
|
||||
def export_ssot(self, out_dir: Path) -> None:
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
token = None
|
||||
while True:
|
||||
kw: dict[str, Any] = {"Bucket": self.bucket, "Prefix": "games/"}
|
||||
if token:
|
||||
kw["ContinuationToken"] = token
|
||||
resp = self.s3.list_objects_v2(**kw)
|
||||
for item in resp.get("Contents") or []:
|
||||
key = item["Key"]
|
||||
target = out_dir / key
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.s3.download_file(self.bucket, key, str(target))
|
||||
if not resp.get("IsTruncated"):
|
||||
break
|
||||
token = resp.get("NextContinuationToken")
|
||||
|
||||
|
||||
class FilesystemStore(ObjectStore):
|
||||
"""Path 1 fallback: SSOT as a directory tree."""
|
||||
|
||||
def __init__(self, root: Path) -> None:
|
||||
if not root:
|
||||
raise StoreError("ssot_root required for filesystem store", key="config_error")
|
||||
self.root = root
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _game(self, game_id: str) -> Path:
|
||||
return self.root / "games" / game_id
|
||||
|
||||
def get_meta(self, game_id: str) -> Meta | None:
|
||||
p = self._game(game_id) / "meta.json"
|
||||
if not p.exists():
|
||||
return None
|
||||
return Meta(json.loads(p.read_text(encoding="utf-8")))
|
||||
|
||||
def put_meta(self, game_id: str, meta: Meta) -> None:
|
||||
p = self._game(game_id) / "meta.json"
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_bytes(meta.to_json())
|
||||
|
||||
def list_live(self, game_id: str) -> list[str]:
|
||||
live = self._game(game_id) / "live"
|
||||
if not live.exists():
|
||||
return []
|
||||
return [p.relative_to(live).as_posix() for p in live.rglob("*") if p.is_file()]
|
||||
|
||||
def download_live(self, game_id: str, dest_dir: Path) -> dict[str, str]:
|
||||
from syncgames.hashutil import sha256_file
|
||||
|
||||
live = self._game(game_id) / "live"
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
checksums: dict[str, str] = {}
|
||||
if not live.exists():
|
||||
return checksums
|
||||
for path in live.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
rel = path.relative_to(live).as_posix()
|
||||
target = dest_dir / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(path, target)
|
||||
checksums[rel] = sha256_file(target)
|
||||
return checksums
|
||||
|
||||
def upload_prefix(
|
||||
self, game_id: str, kind: str, prefix_extra: str, local_dir: Path
|
||||
) -> dict[str, str]:
|
||||
from syncgames.hashutil import sha256_file
|
||||
|
||||
if kind == "live":
|
||||
dest = self._game(game_id) / "live"
|
||||
else:
|
||||
dest = self._game(game_id) / "history" / Path(prefix_extra)
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
checksums: dict[str, str] = {}
|
||||
for path in local_dir.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
rel = path.relative_to(local_dir).as_posix()
|
||||
target = dest / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(path, target)
|
||||
checksums[rel] = sha256_file(path)
|
||||
return checksums
|
||||
|
||||
def delete_live_not_in(self, game_id: str, keep_rels: set[str]) -> None:
|
||||
live = self._game(game_id) / "live"
|
||||
if not live.exists():
|
||||
return
|
||||
for path in list(live.rglob("*")):
|
||||
if path.is_file():
|
||||
rel = path.relative_to(live).as_posix()
|
||||
if rel not in keep_rels:
|
||||
path.unlink()
|
||||
|
||||
def list_history(self, game_id: str) -> list[str]:
|
||||
hist = self._game(game_id) / "history"
|
||||
if not hist.exists():
|
||||
return []
|
||||
out: list[str] = []
|
||||
for device in hist.iterdir():
|
||||
if not device.is_dir():
|
||||
continue
|
||||
for ts in device.iterdir():
|
||||
if ts.is_dir():
|
||||
out.append(f"{device.name}/{ts.name}")
|
||||
return sorted(out, reverse=True)
|
||||
|
||||
def copy_history_to_live(self, game_id: str, device_ts: str) -> None:
|
||||
src = self._game(game_id) / "history" / Path(device_ts)
|
||||
live = self._game(game_id) / "live"
|
||||
if live.exists():
|
||||
shutil.rmtree(live)
|
||||
shutil.copytree(src, live)
|
||||
|
||||
def prune_device_history(self, game_id: str, device_id: str, keep: int) -> int:
|
||||
base = self._game(game_id) / "history" / device_id
|
||||
if not base.exists():
|
||||
return 0
|
||||
stamps = sorted([p for p in base.iterdir() if p.is_dir()], key=lambda p: p.name, reverse=True)
|
||||
removed = 0
|
||||
for p in stamps[keep:]:
|
||||
shutil.rmtree(p)
|
||||
removed += 1
|
||||
return removed
|
||||
|
||||
def retire_game(self, game_id: str) -> str:
|
||||
date = utc_now().strftime("%Y%m%d")
|
||||
dest = self.root / "retired" / f"{game_id}-{date}"
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
src = self._game(game_id)
|
||||
if src.exists():
|
||||
shutil.move(str(src), str(dest))
|
||||
return str(dest.relative_to(self.root))
|
||||
|
||||
def probe(self) -> str:
|
||||
p = self.root / "games" / "_probe" / "ping.txt"
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
body = b"syncgames-probe\n"
|
||||
p.write_bytes(body)
|
||||
got = p.read_bytes()
|
||||
p.unlink()
|
||||
if got != body:
|
||||
raise StoreError("filesystem probe mismatch")
|
||||
return sha256_bytes(body)
|
||||
|
||||
def export_ssot(self, out_dir: Path) -> None:
|
||||
if out_dir.resolve() == self.root.resolve():
|
||||
return
|
||||
if out_dir.exists():
|
||||
shutil.rmtree(out_dir)
|
||||
shutil.copytree(self.root, out_dir)
|
||||
|
||||
|
||||
def build_store(agent) -> ObjectStore: # AgentConfig
|
||||
if agent.store == "filesystem":
|
||||
return FilesystemStore(agent.ssot_root) # type: ignore[arg-type]
|
||||
return MinioStore(
|
||||
endpoint_url=agent.endpoint_url,
|
||||
bucket=agent.bucket,
|
||||
access_key=agent.access_key,
|
||||
secret_key=agent.secret_key,
|
||||
region=agent.region,
|
||||
path_style=agent.path_style,
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
from syncgames.config import GameConfig
|
||||
|
||||
try:
|
||||
import psutil
|
||||
except ImportError: # pragma: no cover
|
||||
psutil = None # type: ignore
|
||||
|
||||
|
||||
def _norm(name: str) -> str:
|
||||
return name.lower().removesuffix(".exe")
|
||||
|
||||
|
||||
def is_game_running(game: GameConfig) -> bool:
|
||||
if psutil is None:
|
||||
return False
|
||||
names = {_norm(n) for n in game.process_names}
|
||||
# Steam AppID heuristic: look for steam apps running with appid in cmdline
|
||||
appid = game.steam_appid
|
||||
for proc in psutil.process_iter(["name", "cmdline"]):
|
||||
try:
|
||||
pname = _norm(proc.info["name"] or "")
|
||||
if names and pname in names:
|
||||
return True
|
||||
if names and any(pname == _norm(n) for n in names):
|
||||
return True
|
||||
cmd = proc.info.get("cmdline") or []
|
||||
joined = " ".join(cmd).lower()
|
||||
if appid and (
|
||||
f"appid={appid}" in joined
|
||||
or f"steam_appid={appid}" in joined
|
||||
or f"AppId={appid}" in joined
|
||||
):
|
||||
return True
|
||||
for n in game.process_names:
|
||||
if n.lower() in joined:
|
||||
return True
|
||||
except (psutil.Error, TypeError):
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def watch_loop(
|
||||
game: GameConfig,
|
||||
on_start: Callable[[], None],
|
||||
on_end: Callable[[], None],
|
||||
*,
|
||||
poll_s: float = 3.0,
|
||||
) -> None:
|
||||
"""Block forever: fire on_start when process appears, on_end when it exits."""
|
||||
was_running = False
|
||||
while True:
|
||||
running = is_game_running(game)
|
||||
if running and not was_running:
|
||||
on_start()
|
||||
elif not running and was_running:
|
||||
# brief settle for flush
|
||||
time.sleep(2.0)
|
||||
on_end()
|
||||
was_running = running
|
||||
time.sleep(poll_s)
|
||||
Reference in New Issue
Block a user