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
+21
View File
@@ -0,0 +1,21 @@
# SyncGames Linux agent
CLI + thin PySide6 management GUI. Package as AppImage for easy PC/laptop setup.
## Dev install
```bash
python -m venv .venv && source .venv/bin/activate
pip install -e ".[gui]"
syncgames-gui
# or: syncgames gui
```
## AppImage
```bash
./packaging/build-appimage.sh
# → dist/SyncGames-<version>-x86_64.AppImage
```
Config always lives in `~/.config/syncgames/` (writable). The AppImage stays read-only.
@@ -0,0 +1,8 @@
[Desktop Entry]
Type=Application
Name=SyncGames
Comment=Session-gated game save sync (setup and management)
Exec=SyncGames
Icon=syncgames
Categories=Utility;
Terminal=false
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env bash
# Build SyncGames-x86_64.AppImage (thin GUI + agent).
# Usage: from SyncGames/agent/, ./packaging/build-appimage.sh
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
VERSION="$(python3 -c "from syncgames import __version__; print(__version__)" 2>/dev/null || echo "0.1.0")"
# Prefer package version when installed in venv
if [[ -d "${ROOT}/.venv" ]]; then
# shellcheck source=/dev/null
source "${ROOT}/.venv/bin/activate"
VERSION="$(python3 -c "from syncgames import __version__; print(__version__)")"
fi
OUT_NAME="SyncGames-${VERSION}-x86_64.AppImage"
APPDIR="${ROOT}/build/appimage/AppDir"
BUILD_BIN="${ROOT}/build/appimage"
APPIMAGETOOL_URL="${APPIMAGETOOL_URL:-https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage}"
APPIMAGETOOL="${APPIMAGETOOL:-${BUILD_BIN}/appimagetool-x86_64.AppImage}"
echo "==> Version: ${VERSION}"
echo "==> Output: ${OUT_NAME}"
if [[ ! -d "${ROOT}/.venv" ]]; then
echo "==> Creating .venv"
python3 -m venv "${ROOT}/.venv"
fi
# shellcheck source=/dev/null
source "${ROOT}/.venv/bin/activate"
python3 -m pip install -q -U pip
python3 -m pip install -q -e ".[build]"
echo "==> PyInstaller"
rm -rf "${ROOT}/build/pyinstaller" "${ROOT}/dist/SyncGames" || true
pyinstaller "${ROOT}/packaging/pyinstaller.spec"
if [[ ! -x "${ROOT}/dist/SyncGames/SyncGames" ]]; then
echo "PyInstaller did not produce dist/SyncGames/SyncGames" >&2
exit 1
fi
echo "==> Assemble AppDir"
rm -rf "$APPDIR"
mkdir -p "$APPDIR/usr/bin" \
"$APPDIR/usr/lib" \
"$APPDIR/usr/share/applications" \
"$APPDIR/usr/share/icons/hicolor/256x256/apps" \
"$APPDIR/usr/share/icons/hicolor/128x128/apps" \
"$APPDIR/usr/share/metainfo"
cp -a "${ROOT}/dist/SyncGames" "$APPDIR/usr/lib/syncgames"
ln -sf "../lib/syncgames/SyncGames" "$APPDIR/usr/bin/SyncGames"
python3 - <<'PY'
from PIL import Image
from pathlib import Path
base = Path("build/appimage/AppDir/usr/share/icons/hicolor")
color = (24, 72, 64, 255) # deep teal — save/sync feel, not purple AI default
for size in (256, 128):
p = base / f"{size}x{size}" / "apps" / "syncgames.png"
p.parent.mkdir(parents=True, exist_ok=True)
Image.new("RGBA", (size, size), color).save(p)
PY
cp "$ROOT/packaging/appimage/syncgames.desktop" "$APPDIR/usr/share/applications/"
cp "$APPDIR/usr/share/applications/syncgames.desktop" "$APPDIR/"
cat > "$APPDIR/usr/share/metainfo/io.github.syncgames.appdata.xml" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>io.github.syncgames</id>
<name>SyncGames</name>
<summary>Session-gated game save sync setup and management</summary>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license>
<description>
<p>Configure MinIO SSOT connection, manage game save paths, and run start/end session sync with lease and version guardrails.</p>
</description>
<launchable type="desktop-id">syncgames.desktop</launchable>
<content_rating type="oars-1.1"/>
<releases>
<release version="${VERSION}"/>
</releases>
</component>
EOF
cat > "$APPDIR/AppRun" <<'SH'
#!/bin/bash
HERE="$(dirname "$(readlink -f "$0")")"
export PATH="${HERE}/usr/bin:${PATH}"
LIBDIR="${HERE}/usr/lib/syncgames"
export LD_LIBRARY_PATH="${LIBDIR}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
if [[ -d "${LIBDIR}/PySide6" ]]; then
export QT_PLUGIN_PATH="${LIBDIR}/PySide6/Qt/plugins${QT_PLUGIN_PATH:+:$QT_PLUGIN_PATH}"
fi
exec "${HERE}/usr/bin/SyncGames" "$@"
SH
chmod +x "$APPDIR/AppRun"
ln -sf "usr/share/icons/hicolor/256x256/apps/syncgames.png" "$APPDIR/.DirIcon" || true
# appimagetool expects Icon= name at AppDir root
cp "$APPDIR/usr/share/icons/hicolor/256x256/apps/syncgames.png" "$APPDIR/syncgames.png"
echo "==> appimagetool"
mkdir -p "$BUILD_BIN"
if [[ ! -x "$APPIMAGETOOL" ]]; then
curl -fsSL -o "$APPIMAGETOOL" "$APPIMAGETOOL_URL"
chmod +x "$APPIMAGETOOL"
fi
rm -f "${ROOT}/dist/${OUT_NAME}" || true
mkdir -p "${ROOT}/dist"
ARCH=x86_64 "$APPIMAGETOOL" "$APPDIR" "${ROOT}/dist/${OUT_NAME}"
echo "==> Built ${ROOT}/dist/${OUT_NAME}"
+6
View File
@@ -0,0 +1,6 @@
"""PyInstaller / AppImage entry — launches SyncGames thin GUI."""
from syncgames.gui.app import main
if __name__ == "__main__":
main()
+68
View File
@@ -0,0 +1,68 @@
# -*- mode: python ; coding: utf-8 -*-
# From SyncGames/agent: pyinstaller packaging/pyinstaller.spec
import os
from pathlib import Path
from PyInstaller.utils.hooks import collect_submodules
project_root = Path(os.path.dirname(os.path.abspath(SPEC))).parent
repo_root = project_root.parent
block_cipher = None
hidden = collect_submodules("syncgames") + ["boto3", "botocore", "psutil"]
datas = []
games_src = repo_root / "config" / "games"
if games_src.is_dir():
for toml in sorted(games_src.glob("*.toml")):
datas.append((str(toml), "bundled_config/games"))
systemd_src = repo_root / "systemd"
if systemd_src.is_dir():
for unit in ("syncgames-agent.service", "syncgames-watch@.service", "install-user-units.sh"):
p = systemd_src / unit
if p.exists():
datas.append((str(p), "bundled_systemd"))
a = Analysis(
[str(project_root / "packaging" / "entrypoint.py")],
pathex=[str(project_root)],
binaries=[],
datas=datas,
hiddenimports=hidden,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name="SyncGames",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name="SyncGames",
)
+28
View File
@@ -0,0 +1,28 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "syncgames"
version = "0.1.1"
description = "Session-gated game save sync against MinIO SSOT"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"boto3>=1.34",
"tomli>=2.0; python_version < '3.11'",
"psutil>=5.9",
]
[project.optional-dependencies]
gui = ["PySide6>=6.6"]
dev = ["pytest>=8.0", "PySide6>=6.6"]
build = ["PySide6>=6.6", "pyinstaller>=6.0", "pillow>=10.0"]
[project.scripts]
syncgames = "syncgames.cli:main"
syncgames-gui = "syncgames.gui.app:main"
[tool.setuptools.packages.find]
where = ["."]
include = ["syncgames*"]
+4
View File
@@ -0,0 +1,4 @@
"""SyncGames Linux agent — session-gated save sync."""
__version__ = "0.1.1"
SCHEMA_VERSION = 1
+4
View File
@@ -0,0 +1,4 @@
from syncgames.cli import main
if __name__ == "__main__":
main()
+314
View File
@@ -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()
+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
+30
View File
@@ -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"
+11
View File
@@ -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()
+22
View File
@@ -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()
+646
View File
@@ -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)
+42
View File
@@ -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
+79
View File
@@ -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
+260
View File
@@ -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"
+485
View File
@@ -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,
)
+65
View File
@@ -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)
+24
View File
@@ -0,0 +1,24 @@
from __future__ import annotations
from pathlib import Path
from syncgames.config import AgentConfig, save_agent_config, try_load_agent_config
def test_save_and_reload_agent(tmp_path: Path, monkeypatch):
monkeypatch.setenv("SYNCGAMES_CONFIG", str(tmp_path))
cfg = AgentConfig(
device_id="laptop",
endpoint_url="https://syncgames-s3.example.com",
bucket="syncgames",
access_key="ak",
secret_key="sk",
config_root=tmp_path,
)
path = save_agent_config(cfg)
assert path.exists()
loaded = try_load_agent_config(tmp_path)
assert loaded is not None
assert loaded.device_id == "laptop"
assert loaded.endpoint_url.endswith("example.com")
assert loaded.secret_key == "sk"
+71
View File
@@ -0,0 +1,71 @@
from __future__ import annotations
from pathlib import Path
import pytest
from syncgames.config import AgentConfig, GameConfig
from syncgames.errors import StaleWipError
from syncgames.session import end_session, start_session
from syncgames.store import FilesystemStore
@pytest.fixture
def env(tmp_path: Path):
cfg_root = tmp_path / "cfg"
games = cfg_root / "games"
games.mkdir(parents=True)
native = tmp_path / "native" / "saves"
native.mkdir(parents=True)
(native / "slot1.sav").write_bytes(b"v1")
cfg = AgentConfig(
device_id="pc-test",
endpoint_url="",
store="filesystem",
ssot_root=tmp_path / "ssot",
config_root=cfg_root,
state_root=tmp_path / "state",
)
game = GameConfig(
id="demo",
name="Demo",
platform="other",
paths=[str(native)],
versions_to_keep=3,
)
# seed remote empty then first end will push after start with empty live
store = FilesystemStore(cfg.ssot_root)
return cfg, store, game, native
def test_start_end_roundtrip(env):
cfg, store, game, native = env
start_session(cfg, store, game)
(native / "slot1.sav").write_bytes(b"v2-progress")
meta = end_session(cfg, store, game)
assert meta.live_hash
assert store.list_live("demo")
def test_hash_gate_blocks_stale(env):
cfg, store, game, native = env
start_session(cfg, store, game)
(native / "slot1.sav").write_bytes(b"A")
end_session(cfg, store, game)
# Simulate stale session with old parent after remote advanced elsewhere
start_session(cfg, store, game)
# Mutate remote live out from under us
lives = list((Path(cfg.ssot_root) / "games" / "demo" / "live").rglob("slot1.sav"))
assert lives
lives[0].write_bytes(b"REMOTE-NEWER")
# Update meta live_hash to mismatch parent
meta = store.get_meta("demo")
assert meta
meta.raw["live_hash"] = "sha256:deadbeef"
store.put_meta("demo", meta)
(native / "slot1.sav").write_bytes(b"local-stale-wip")
with pytest.raises(StaleWipError):
end_session(cfg, store, game)
+56
View File
@@ -0,0 +1,56 @@
from __future__ import annotations
import json
from pathlib import Path
from syncgames.hashutil import tree_hash
from syncgames.store import FilesystemStore, empty_meta
def test_tree_hash_stable():
a = {"b.bin": "sha256:aa", "a.bin": "sha256:bb"}
b = {"a.bin": "sha256:bb", "b.bin": "sha256:aa"}
assert tree_hash(a) == tree_hash(b)
def test_filesystem_roundtrip(tmp_path: Path):
store = FilesystemStore(tmp_path / "ssot")
game = "test-game"
meta = empty_meta(game, 3)
store.put_meta(game, meta)
assert store.get_meta(game) is not None
local = tmp_path / "wip"
local.mkdir()
(local / "save.dat").write_bytes(b"hello-save")
checksums = store.upload_prefix(game, "live", "", local)
assert "save.dat" in checksums
dest = tmp_path / "down"
got = store.download_live(game, dest)
assert got["save.dat"].startswith("sha256:")
assert (dest / "save.dat").read_bytes() == b"hello-save"
store.upload_prefix(game, "history", "phone/20260101T000000Z", local)
hist = store.list_history(game)
assert any(h.startswith("phone/") for h in hist)
probe = store.probe()
assert probe.startswith("sha256:")
def test_meta_json_roundtrip(tmp_path: Path):
store = FilesystemStore(tmp_path)
m = empty_meta("g1")
m.raw["lease"] = {
"holder": "pc",
"expires_at": "2099-01-01T00:00:00Z",
"session_id": "x",
}
store.put_meta("g1", m)
again = store.get_meta("g1")
assert again is not None
assert again.lease["holder"] == "pc"
# ensure valid json file
text = (tmp_path / "games" / "g1" / "meta.json").read_text()
json.loads(text)