commit 0d6b0b2f80c7d269af33d0b977b5f31809396205 Author: Dawnsorrow Date: Tue Jul 14 22:06:36 2026 -0500 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7d9852b --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ + +dist/ +build/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# Local secrets / runtime +config/agent.toml +deploy/docker/.env +**/local_state/ +**/.syncgames/ + +# Android +android/.gradle/ +android/local.properties +android/**/build/ +android/**/.idea/ +*.apk +*.aab +*.keystore + +# Agent build artifacts +agent/dist/ +agent/build/ +agent/.venv/ + +# Editor +.idea/ +.vscode/ +*.swp +.DS_Store +android/.android-sdk/ +android/.jdk/ +android/local.properties +dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..b159508 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# SyncGames + +Session-gated save sync for Steam (incl. Seamless Coop) and emulator titles across Linux PCs/laptops and Android. + +**Architecture:** Path 3 — MinIO SSOT on NAS, Python agent on Linux, light Kotlin UI on Android. +**Ingress:** Cloudflare → Tunnel → NGINX → MinIO (WAN HTTPS, no VPN required). + +## Safety model + +1. **Pull** SSOT into the game’s native save path only at session start. +2. Play against local WIP only. +3. **Push** WIP to SSOT only at session end (history slot + live promote). +4. One **lease** per game; hash gate blocks stale WIP overwrites. +5. Per-device history keeps the last N versions (default 5) for LOPE recovery. + +Never point continuous sync tools at live game save directories. + +## Download (multi-device) + +Repo: [git.hisora.dev/Dawnsorrow/SyncGames](https://git.hisora.dev/Dawnsorrow/SyncGames) + +Grab the latest **Release** assets (AppImage / APK) — do not commit binaries into git. + +```bash +# Linux +chmod +x SyncGames-*-x86_64.AppImage +./SyncGames-*-x86_64.AppImage +``` + +Use the **Setup** tab for endpoint/keys, **Games** to add/edit titles, **Session** for start/end/restore. + +## Quick start (Linux) + +### Build AppImage from source + +```bash +cd agent +./packaging/build-appimage.sh +# → agent/dist/SyncGames-*-x86_64.AppImage +``` + +### CLI / editable install + +```bash +cd agent +python -m venv .venv && source .venv/bin/activate +pip install -e ".[gui]" + +syncgames-gui # thin management UI +# or: syncgames gui + +cp ../config/agent.toml.example ~/.config/syncgames/agent.toml +syncgames doctor +``` + +## Docs + +| Doc | Purpose | +|-----|---------| +| [docs/architecture.md](docs/architecture.md) | System overview | +| [docs/protocol.md](docs/protocol.md) | Shared SSOT protocol (Python ↔ Android) | +| [docs/nas-minio-cloudflare.md](docs/nas-minio-cloudflare.md) | MinIO + NGINX + Cloudflare | +| [docs/add-game.md](docs/add-game.md) | Add / remove games | +| [docs/fallback-path1.md](docs/fallback-path1.md) | Syncthing fallback | +| [docs/planning/](docs/planning/) | Planning archive | + +## Layout + +``` +SyncGames/ + agent/ Python CLI + thin GUI (+ AppImage packaging) + android/ Kotlin/Compose light UI + config/ Device + game TOML + deploy/ + docker/ Docker Compose (MinIO SSOT) + nginx/ Example NGINX vhost + systemd/ User units for watchers + docs/ +``` + +## MinIO / NAS Docker + +See [deploy/docker/README.md](deploy/docker/README.md) for Compose (`MinIO` on `127.0.0.1:9000` behind your existing NGINX). diff --git a/agent/README.md b/agent/README.md new file mode 100644 index 0000000..268f991 --- /dev/null +++ b/agent/README.md @@ -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--x86_64.AppImage +``` + +Config always lives in `~/.config/syncgames/` (writable). The AppImage stays read-only. diff --git a/agent/packaging/appimage/syncgames.desktop b/agent/packaging/appimage/syncgames.desktop new file mode 100644 index 0000000..5d2b2db --- /dev/null +++ b/agent/packaging/appimage/syncgames.desktop @@ -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 diff --git a/agent/packaging/build-appimage.sh b/agent/packaging/build-appimage.sh new file mode 100755 index 0000000..4b83e3f --- /dev/null +++ b/agent/packaging/build-appimage.sh @@ -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" < + + io.github.syncgames + SyncGames + Session-gated game save sync setup and management + CC0-1.0 + MIT + +

Configure MinIO SSOT connection, manage game save paths, and run start/end session sync with lease and version guardrails.

+
+ syncgames.desktop + + + + +
+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}" diff --git a/agent/packaging/entrypoint.py b/agent/packaging/entrypoint.py new file mode 100644 index 0000000..c304e9d --- /dev/null +++ b/agent/packaging/entrypoint.py @@ -0,0 +1,6 @@ +"""PyInstaller / AppImage entry — launches SyncGames thin GUI.""" + +from syncgames.gui.app import main + +if __name__ == "__main__": + main() diff --git a/agent/packaging/pyinstaller.spec b/agent/packaging/pyinstaller.spec new file mode 100644 index 0000000..b599a4a --- /dev/null +++ b/agent/packaging/pyinstaller.spec @@ -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", +) diff --git a/agent/pyproject.toml b/agent/pyproject.toml new file mode 100644 index 0000000..aab89f1 --- /dev/null +++ b/agent/pyproject.toml @@ -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*"] diff --git a/agent/syncgames/__init__.py b/agent/syncgames/__init__.py new file mode 100644 index 0000000..7a4275b --- /dev/null +++ b/agent/syncgames/__init__.py @@ -0,0 +1,4 @@ +"""SyncGames Linux agent — session-gated save sync.""" + +__version__ = "0.1.1" +SCHEMA_VERSION = 1 diff --git a/agent/syncgames/__main__.py b/agent/syncgames/__main__.py new file mode 100644 index 0000000..be2c7a0 --- /dev/null +++ b/agent/syncgames/__main__.py @@ -0,0 +1,4 @@ +from syncgames.cli import main + +if __name__ == "__main__": + main() diff --git a/agent/syncgames/cli.py b/agent/syncgames/cli.py new file mode 100644 index 0000000..e389c47 --- /dev/null +++ b/agent/syncgames/cli.py @@ -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() diff --git a/agent/syncgames/config.py b/agent/syncgames/config.py new file mode 100644 index 0000000..3f30d5c --- /dev/null +++ b/agent/syncgames/config.py @@ -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", "syncgames-watch@.service"): + src = unit_source / name + if src.exists(): + shutil.copy2(src, unit_dir / name) + return unit_dir \ No newline at end of file diff --git a/agent/syncgames/errors.py b/agent/syncgames/errors.py new file mode 100644 index 0000000..56f0e62 --- /dev/null +++ b/agent/syncgames/errors.py @@ -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" diff --git a/agent/syncgames/gui/__init__.py b/agent/syncgames/gui/__init__.py new file mode 100644 index 0000000..38eab7b --- /dev/null +++ b/agent/syncgames/gui/__init__.py @@ -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() diff --git a/agent/syncgames/gui/app.py b/agent/syncgames/gui/app.py new file mode 100644 index 0000000..800d50b --- /dev/null +++ b/agent/syncgames/gui/app.py @@ -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() diff --git a/agent/syncgames/gui/main_window.py b/agent/syncgames/gui/main_window.py new file mode 100644 index 0000000..d14788a --- /dev/null +++ b/agent/syncgames/gui/main_window.py @@ -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 " + "syncgames-watch@elden-ring-seamless.service" + ) + 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@.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) diff --git a/agent/syncgames/hashutil.py b/agent/syncgames/hashutil.py new file mode 100644 index 0000000..8506e3c --- /dev/null +++ b/agent/syncgames/hashutil.py @@ -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 diff --git a/agent/syncgames/lease.py b/agent/syncgames/lease.py new file mode 100644 index 0000000..9947a32 --- /dev/null +++ b/agent/syncgames/lease.py @@ -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 diff --git a/agent/syncgames/session.py b/agent/syncgames/session.py new file mode 100644 index 0000000..01ecd80 --- /dev/null +++ b/agent/syncgames/session.py @@ -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 / for directories and 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// + 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" diff --git a/agent/syncgames/store.py b/agent/syncgames/store.py new file mode 100644 index 0000000..559ba48 --- /dev/null +++ b/agent/syncgames/store.py @@ -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, + ) diff --git a/agent/syncgames/watchers.py b/agent/syncgames/watchers.py new file mode 100644 index 0000000..073a4b5 --- /dev/null +++ b/agent/syncgames/watchers.py @@ -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) diff --git a/agent/tests/test_config_io.py b/agent/tests/test_config_io.py new file mode 100644 index 0000000..620b93b --- /dev/null +++ b/agent/tests/test_config_io.py @@ -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" diff --git a/agent/tests/test_session.py b/agent/tests/test_session.py new file mode 100644 index 0000000..7bbb322 --- /dev/null +++ b/agent/tests/test_session.py @@ -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) diff --git a/agent/tests/test_store.py b/agent/tests/test_store.py new file mode 100644 index 0000000..53b346d --- /dev/null +++ b/agent/tests/test_store.py @@ -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) diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..2b5196c --- /dev/null +++ b/android/README.md @@ -0,0 +1,32 @@ +# SyncGames Android (Kotlin + Compose) + +Light UI for Path 3 session ops against MinIO over Cloudflare HTTPS. + +## Features + +- Settings (endpoint, keys in EncryptedSharedPreferences, device id, games JSON) +- Game list → Start / End / Status / History / Restore +- Doctor probe (PUT/GET/DELETE) to validate NGINX/Cloudflare +- Implements the same protocol as the Python agent (`docs/protocol.md`) + +## Build + +Open `android/` in Android Studio, sync Gradle, run on a device/emulator. + +```bash +cd android +./gradlew :app:assembleDebug # after generating the Gradle wrapper in Android Studio once +``` + +Sideload `app/build/outputs/apk/debug/app-debug.apk`. + +## First-run config + +1. Settings → set `https://syncgames-s3.` +2. MinIO access/secret keys +3. `device_id` e.g. `phone-android` +4. Edit games JSON `nativePath` to the absolute Eden/Yuzu save folder on the device + +## Note on Storage Access Framework + +v1 accepts absolute paths in games JSON for devices where the save tree is readable. A SAF folder picker can be added without changing the protocol layer. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..8f53d8e --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,55 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "com.syncgames.app" + compileSdk = 35 + + defaultConfig { + applicationId = "com.syncgames.app" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "0.1.0" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + compose = true + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2024.10.01") + implementation(composeBom) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.activity:activity-compose:1.9.3") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") + implementation("androidx.navigation:navigation-compose:2.8.3") + implementation("androidx.security:security-crypto:1.1.0-alpha06") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") + implementation("com.amazonaws:aws-android-sdk-s3:2.77.1") + implementation("org.json:json:20240303") + debugImplementation("androidx.compose.ui:ui-tooling") +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..fb164d6 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1 @@ +# Add project specific ProGuard rules here. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1d87381 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/syncgames/app/MainActivity.kt b/android/app/src/main/java/com/syncgames/app/MainActivity.kt new file mode 100644 index 0000000..f103342 --- /dev/null +++ b/android/app/src/main/java/com/syncgames/app/MainActivity.kt @@ -0,0 +1,20 @@ +package com.syncgames.app + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.material3.MaterialTheme +import com.syncgames.app.ui.SyncGamesApp + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme { + SyncGamesApp() + } + } + } +} diff --git a/android/app/src/main/java/com/syncgames/app/PreviewStub.kt b/android/app/src/main/java/com/syncgames/app/PreviewStub.kt new file mode 100644 index 0000000..68f7056 --- /dev/null +++ b/android/app/src/main/java/com/syncgames/app/PreviewStub.kt @@ -0,0 +1,15 @@ +package com.syncgames.app + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.syncgames.app.ui.SyncGamesApp + +@Preview +@Composable +fun PreviewApp() { + MaterialTheme { + // Preview placeholder — full app needs Application context for ViewModel + androidx.compose.material3.Text("SyncGames") + } +} diff --git a/android/app/src/main/java/com/syncgames/app/data/SettingsRepository.kt b/android/app/src/main/java/com/syncgames/app/data/SettingsRepository.kt new file mode 100644 index 0000000..59d12e5 --- /dev/null +++ b/android/app/src/main/java/com/syncgames/app/data/SettingsRepository.kt @@ -0,0 +1,70 @@ +package com.syncgames.app.data + +import android.content.Context +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey + +data class AppSettings( + val endpointUrl: String = "", + val bucket: String = "syncgames", + val region: String = "us-east-1", + val accessKey: String = "", + val secretKey: String = "", + val deviceId: String = "phone-android", + val gamesJson: String = DEFAULT_GAMES, +) + +val DEFAULT_GAMES = """ +[ + {"id":"eden-saves","name":"Eden Emulator Saves","nativePath":""}, + {"id":"yuzu-saves","name":"Yuzu Emulator Saves","nativePath":""} +] +""".trimIndent() + +class SettingsRepository(context: Context) { + private val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + private val prefs = EncryptedSharedPreferences.create( + context, + "syncgames_secure", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + + fun load(): AppSettings = AppSettings( + endpointUrl = prefs.getString("endpoint_url", "") ?: "", + bucket = prefs.getString("bucket", "syncgames") ?: "syncgames", + region = prefs.getString("region", "us-east-1") ?: "us-east-1", + accessKey = prefs.getString("access_key", "") ?: "", + secretKey = prefs.getString("secret_key", "") ?: "", + deviceId = prefs.getString("device_id", "phone-android") ?: "phone-android", + gamesJson = prefs.getString("games_json", DEFAULT_GAMES) ?: DEFAULT_GAMES, + ) + + fun save(settings: AppSettings) { + prefs.edit() + .putString("endpoint_url", settings.endpointUrl) + .putString("bucket", settings.bucket) + .putString("region", settings.region) + .putString("access_key", settings.accessKey) + .putString("secret_key", settings.secretKey) + .putString("device_id", settings.deviceId) + .putString("games_json", settings.gamesJson) + .apply() + } + + fun setGamePath(gameId: String, path: String) { + val cur = load() + val arr = org.json.JSONArray(cur.gamesJson) + for (i in 0 until arr.length()) { + val o = arr.getJSONObject(i) + if (o.getString("id") == gameId) { + o.put("nativePath", path) + } + } + save(cur.copy(gamesJson = arr.toString(2))) + } +} diff --git a/android/app/src/main/java/com/syncgames/app/protocol/HashUtil.kt b/android/app/src/main/java/com/syncgames/app/protocol/HashUtil.kt new file mode 100644 index 0000000..7e0d20c --- /dev/null +++ b/android/app/src/main/java/com/syncgames/app/protocol/HashUtil.kt @@ -0,0 +1,34 @@ +package com.syncgames.app.protocol + +import java.io.File +import java.io.FileInputStream +import java.security.MessageDigest + +object HashUtil { + fun sha256File(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + FileInputStream(file).use { input -> + val buf = ByteArray(1024 * 1024) + while (true) { + val n = input.read(buf) + if (n <= 0) break + digest.update(buf, 0, n) + } + } + return "sha256:" + digest.digest().joinToString("") { "%02x".format(it) } + } + + fun sha256Bytes(data: ByteArray): String { + val digest = MessageDigest.getInstance("SHA-256") + digest.update(data) + return "sha256:" + digest.digest().joinToString("") { "%02x".format(it) } + } + + fun treeHash(fileChecksums: Map): String { + val lines = fileChecksums.keys.sorted().joinToString("") { rel -> + val digest = fileChecksums.getValue(rel).removePrefix("sha256:") + "$rel\u0000$digest\n" + } + return sha256Bytes(lines.toByteArray(Charsets.UTF_8)) + } +} diff --git a/android/app/src/main/java/com/syncgames/app/protocol/Meta.kt b/android/app/src/main/java/com/syncgames/app/protocol/Meta.kt new file mode 100644 index 0000000..942f6b9 --- /dev/null +++ b/android/app/src/main/java/com/syncgames/app/protocol/Meta.kt @@ -0,0 +1,96 @@ +package com.syncgames.app.protocol + +import org.json.JSONObject +import java.time.Instant +import java.util.UUID + +data class Lease( + val holder: String, + val expiresAt: String, + val sessionId: String, +) + +data class Meta( + val schema: Int = 1, + val gameId: String, + val liveHash: String?, + val fileChecksums: Map, + val lease: Lease?, + val versionsToKeep: Int, + val updatedAt: String?, + val updatedBy: String?, +) { + fun toJson(): String { + val o = JSONObject() + o.put("schema", schema) + o.put("game_id", gameId) + o.put("live_hash", liveHash) + val files = JSONObject() + fileChecksums.forEach { (k, v) -> files.put(k, v) } + o.put("file_checksums", files) + if (lease == null) { + o.put("lease", JSONObject.NULL) + } else { + o.put( + "lease", + JSONObject() + .put("holder", lease.holder) + .put("expires_at", lease.expiresAt) + .put("session_id", lease.sessionId), + ) + } + o.put("versions_to_keep", versionsToKeep) + o.put("updated_at", updatedAt) + o.put("updated_by", updatedBy) + return o.toString(2) + "\n" + } + + companion object { + fun empty(gameId: String, versions: Int = 5) = Meta( + gameId = gameId, + liveHash = null, + fileChecksums = emptyMap(), + lease = null, + versionsToKeep = versions, + updatedAt = Instant.now().toString(), + updatedBy = null, + ) + + fun fromJson(text: String): Meta { + val o = JSONObject(text) + val filesObj = o.optJSONObject("file_checksums") ?: JSONObject() + val files = mutableMapOf() + filesObj.keys().forEach { key -> files[key] = filesObj.getString(key) } + val leaseObj = o.optJSONObject("lease") + val lease = if (leaseObj == null || o.isNull("lease")) null else Lease( + holder = leaseObj.getString("holder"), + expiresAt = leaseObj.getString("expires_at"), + sessionId = leaseObj.getString("session_id"), + ) + return Meta( + schema = o.optInt("schema", 1), + gameId = o.getString("game_id"), + liveHash = if (o.isNull("live_hash")) null else o.optString("live_hash", null), + fileChecksums = files, + lease = lease, + versionsToKeep = o.optInt("versions_to_keep", 5), + updatedAt = if (o.isNull("updated_at")) null else o.optString("updated_at"), + updatedBy = if (o.isNull("updated_by")) null else o.optString("updated_by"), + ) + } + + fun newLease(holder: String, ttlHours: Long = 6): Lease { + val expires = Instant.now().plusSeconds(ttlHours * 3600) + return Lease(holder, expires.toString(), UUID.randomUUID().toString()) + } + } +} + +fun Lease?.isActive(): Boolean { + if (this == null) return false + return try { + Instant.parse(expiresAt).isAfter(Instant.now()) + } catch (_: Exception) { + false + } +} diff --git a/android/app/src/main/java/com/syncgames/app/protocol/MinioStore.kt b/android/app/src/main/java/com/syncgames/app/protocol/MinioStore.kt new file mode 100644 index 0000000..bf1b79f --- /dev/null +++ b/android/app/src/main/java/com/syncgames/app/protocol/MinioStore.kt @@ -0,0 +1,165 @@ +package com.syncgames.app.protocol + +import com.amazonaws.auth.BasicAWSCredentials +import com.amazonaws.services.s3.AmazonS3 +import com.amazonaws.services.s3.AmazonS3Client +import com.amazonaws.services.s3.S3ClientOptions +import com.amazonaws.services.s3.model.CopyObjectRequest +import com.amazonaws.services.s3.model.DeleteObjectRequest +import com.amazonaws.services.s3.model.ListObjectsV2Request +import com.amazonaws.services.s3.model.ObjectMetadata +import com.amazonaws.services.s3.model.PutObjectRequest +import java.io.ByteArrayInputStream +import java.io.File +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +class MinioStore( + endpointUrl: String, + private val bucket: String, + accessKey: String, + secretKey: String, + region: String = "us-east-1", +) { + private val s3: AmazonS3 = AmazonS3Client(BasicAWSCredentials(accessKey, secretKey)).apply { + setEndpoint(endpointUrl) + setS3ClientOptions( + S3ClientOptions.builder() + .setPathStyleAccess(true) + .build(), + ) + // Region is mainly for signing; path-style custom endpoints ignore AWS regional hosts. + setRegion(com.amazonaws.regions.Region.getRegion(com.amazonaws.regions.Regions.fromName(region))) + } + + fun getMeta(gameId: String): Meta? { + val key = "games/$gameId/meta.json" + if (!s3.doesObjectExist(bucket, key)) return null + return s3.getObjectAsString(bucket, key).let { Meta.fromJson(it) } + } + + fun putMeta(gameId: String, meta: Meta) { + val bytes = meta.toJson().toByteArray(Charsets.UTF_8) + val md = ObjectMetadata().apply { + contentLength = bytes.size.toLong() + contentType = "application/json" + } + s3.putObject(PutObjectRequest(bucket, "games/$gameId/meta.json", ByteArrayInputStream(bytes), md)) + } + + fun listLive(gameId: String): List { + val prefix = "games/$gameId/live/" + return listRels(prefix) + } + + private fun listRels(prefix: String): List { + val out = mutableListOf() + var token: String? = null + do { + val req = ListObjectsV2Request() + .withBucketName(bucket) + .withPrefix(prefix) + .withContinuationToken(token) + val res = s3.listObjectsV2(req) + res.objectSummaries.forEach { sum -> + if (!sum.key.endsWith("/")) { + out += sum.key.removePrefix(prefix) + } + } + token = if (res.isTruncated) res.nextContinuationToken else null + } while (token != null) + return out + } + + fun downloadLive(gameId: String, destDir: File): Map { + destDir.mkdirs() + val checksums = mutableMapOf() + val prefix = "games/$gameId/live/" + for (rel in listLive(gameId)) { + val target = File(destDir, rel) + target.parentFile?.mkdirs() + s3.getObject(bucket, prefix + rel).objectContent.use { input -> + target.outputStream().use { output -> input.copyTo(output) } + } + checksums[rel] = HashUtil.sha256File(target) + } + return checksums + } + + fun uploadTree(gameId: String, kind: String, extra: String, localDir: File): Map { + val base = when (kind) { + "live" -> "games/$gameId/live" + "history" -> "games/$gameId/history/$extra" + else -> error("bad kind") + } + val checksums = mutableMapOf() + localDir.walkTopDown().filter { it.isFile }.forEach { file -> + val rel = file.relativeTo(localDir).invariantSeparatorsPath + s3.putObject(bucket, "$base/$rel", file) + checksums[rel] = HashUtil.sha256File(file) + } + return checksums + } + + fun deleteLiveNotIn(gameId: String, keep: Set) { + for (rel in listLive(gameId)) { + if (rel !in keep) { + s3.deleteObject(DeleteObjectRequest(bucket, "games/$gameId/live/$rel")) + } + } + } + + fun listHistory(gameId: String): List { + val prefix = "games/$gameId/history/" + val set = linkedSetOf() + for (rel in listRels(prefix)) { + val parts = rel.split("/") + if (parts.size >= 2) set += "${parts[0]}/${parts[1]}" + } + return set.sortedDescending() + } + + fun copyHistoryToLive(gameId: String, deviceTs: String) { + for (rel in listLive(gameId)) { + s3.deleteObject(bucket, "games/$gameId/live/$rel") + } + val srcPrefix = "games/$gameId/history/$deviceTs/" + for (rel in listRels(srcPrefix)) { + val src = srcPrefix + rel + val dst = "games/$gameId/live/$rel" + s3.copyObject(CopyObjectRequest(bucket, src, bucket, dst)) + } + } + + fun pruneDeviceHistory(gameId: String, deviceId: String, keep: Int) { + val prefix = "games/$gameId/history/$deviceId/" + val stamps = listRels(prefix).map { it.substringBefore("/") }.toSet().sortedDescending() + for (stamp in stamps.drop(keep)) { + for (rel in listRels("$prefix$stamp/")) { + s3.deleteObject(bucket, "$prefix$stamp/$rel") + } + } + } + + fun probe(): String { + val ts = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'") + .withZone(ZoneOffset.UTC) + .format(Instant.now()) + val key = "games/_probe/ping-$ts.txt" + val body = "syncgames-probe\n".toByteArray() + val md = ObjectMetadata().apply { contentLength = body.size.toLong() } + s3.putObject(PutObjectRequest(bucket, key, ByteArrayInputStream(body), md)) + val got = s3.getObjectAsString(bucket, key).toByteArray() + s3.deleteObject(bucket, key) + require(got.contentEquals(body)) { "probe mismatch — check NGINX buffering" } + return HashUtil.sha256Bytes(body) + } + + companion object { + fun isoTs(): String = + DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'") + .withZone(ZoneOffset.UTC) + .format(Instant.now()) + } +} diff --git a/android/app/src/main/java/com/syncgames/app/protocol/SessionService.kt b/android/app/src/main/java/com/syncgames/app/protocol/SessionService.kt new file mode 100644 index 0000000..29b64c8 --- /dev/null +++ b/android/app/src/main/java/com/syncgames/app/protocol/SessionService.kt @@ -0,0 +1,158 @@ +package com.syncgames.app.protocol + +import android.content.Context +import org.json.JSONObject +import java.io.File +import java.time.Instant + +class SessionService( + private val context: Context, + private val store: MinioStore, + private val deviceId: String, + private val leaseTtlHours: Long = 6, +) { + private val sessionsDir: File + get() = File(context.filesDir, "sessions").also { it.mkdirs() } + private val wipRoot: File + get() = File(context.filesDir, "wip").also { it.mkdirs() } + + fun acquireLease(gameId: String, versions: Int = 5): Meta { + var meta = store.getMeta(gameId) ?: Meta.empty(gameId, versions) + val lease = meta.lease + if (lease.isActive() && lease?.holder != deviceId) { + error("lease_held: held by ${lease?.holder} until ${lease?.expiresAt}") + } + val newLease = Meta.newLease(deviceId, leaseTtlHours) + meta = meta.copy(lease = newLease, versionsToKeep = versions) + store.putMeta(gameId, meta) + val again = store.getMeta(gameId) + if (again?.lease?.holder != deviceId) error("lease_held: lost race") + return again + } + + fun start(gameId: String, nativeRoot: File, versions: Int = 5): Meta { + val meta = acquireLease(gameId, versions) + val staging = File(wipRoot, gameId).also { + if (it.exists()) it.deleteRecursively() + it.mkdirs() + } + val checksums = store.downloadLive(gameId, staging) + copyTree(staging, nativeRoot) + val parent = meta.liveHash ?: if (checksums.isNotEmpty()) HashUtil.treeHash(checksums) else null + writeSession( + gameId, + JSONObject() + .put("game_id", gameId) + .put("parent_live_hash", parent) + .put("session_id", meta.lease?.sessionId) + .put("started_at", Instant.now().toString()) + .put("device_id", deviceId) + .put("native_root", nativeRoot.absolutePath), + ) + return meta + } + + fun end(gameId: String, nativeRoot: File, forceHash: Boolean = false): Meta { + val sess = readSession(gameId) ?: error("config_error: no local session") + val parent = if (sess.isNull("parent_live_hash")) null else sess.optString("parent_live_hash") + val staging = File(wipRoot, gameId).also { + if (it.exists()) it.deleteRecursively() + it.mkdirs() + } + copyTree(nativeRoot, staging) + val checksums = checksumTree(staging) + val wipHash = if (checksums.isEmpty()) null else HashUtil.treeHash(checksums) + var meta = store.getMeta(gameId) ?: Meta.empty(gameId) + if (!forceHash && meta.liveHash != null && parent != null && meta.liveHash != parent) { + error("stale_wip: parent $parent != live ${meta.liveHash}") + } + val ts = MinioStore.isoTs() + store.uploadTree(gameId, "history", "$deviceId/$ts", staging) + store.uploadTree(gameId, "live", "", staging) + store.deleteLiveNotIn(gameId, checksums.keys) + meta = meta.copy( + liveHash = wipHash, + fileChecksums = checksums, + lease = null, + updatedAt = Instant.now().toString(), + updatedBy = deviceId, + ) + store.putMeta(gameId, meta) + store.pruneDeviceHistory(gameId, deviceId, meta.versionsToKeep) + File(sessionsDir, "$gameId.json").delete() + staging.deleteRecursively() + return meta + } + + fun history(gameId: String): List = store.listHistory(gameId) + + fun restore(gameId: String, deviceTs: String, nativeRoot: File, versions: Int = 5): Meta { + acquireLease(gameId, versions) + store.copyHistoryToLive(gameId, deviceTs) + val staging = File(wipRoot, gameId).also { + if (it.exists()) it.deleteRecursively() + it.mkdirs() + } + val checksums = store.downloadLive(gameId, staging) + val liveHash = if (checksums.isEmpty()) null else HashUtil.treeHash(checksums) + var meta = store.getMeta(gameId) ?: Meta.empty(gameId, versions) + meta = meta.copy( + liveHash = liveHash, + fileChecksums = checksums, + updatedAt = Instant.now().toString(), + updatedBy = deviceId, + ) + store.putMeta(gameId, meta) + copyTree(staging, nativeRoot) + writeSession( + gameId, + JSONObject() + .put("game_id", gameId) + .put("parent_live_hash", liveHash) + .put("session_id", meta.lease?.sessionId) + .put("started_at", Instant.now().toString()) + .put("device_id", deviceId) + .put("restored_from", deviceTs) + .put("native_root", nativeRoot.absolutePath), + ) + return meta + } + + fun status(gameId: String): String { + val meta = store.getMeta(gameId) + val sess = readSession(gameId) + return "live=${meta?.liveHash}\nlease=${meta?.lease}\nsession=$sess" + } + + private fun writeSession(gameId: String, obj: JSONObject) { + File(sessionsDir, "$gameId.json").writeText(obj.toString(2)) + } + + private fun readSession(gameId: String): JSONObject? { + val f = File(sessionsDir, "$gameId.json") + if (!f.exists()) return null + return JSONObject(f.readText()) + } + + private fun copyTree(from: File, to: File) { + if (!from.exists()) return + to.mkdirs() + from.walkTopDown().forEach { src -> + val rel = src.relativeTo(from) + val dst = File(to, rel.path) + if (src.isDirectory) dst.mkdirs() + else { + dst.parentFile?.mkdirs() + src.copyTo(dst, overwrite = true) + } + } + } + + private fun checksumTree(root: File): Map { + val out = linkedMapOf() + root.walkTopDown().filter { it.isFile }.forEach { f -> + out[f.relativeTo(root).invariantSeparatorsPath] = HashUtil.sha256File(f) + } + return out + } +} diff --git a/android/app/src/main/java/com/syncgames/app/ui/SyncGamesApp.kt b/android/app/src/main/java/com/syncgames/app/ui/SyncGamesApp.kt new file mode 100644 index 0000000..d5026cc --- /dev/null +++ b/android/app/src/main/java/com/syncgames/app/ui/SyncGamesApp.kt @@ -0,0 +1,275 @@ +package com.syncgames.app.ui + +import android.app.Application +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.syncgames.app.data.AppSettings +import com.syncgames.app.data.SettingsRepository +import com.syncgames.app.protocol.MinioStore +import com.syncgames.app.protocol.SessionService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONArray +import java.io.File + +data class GameUi(val id: String, val name: String, val nativePath: String) + +class SyncGamesViewModel(app: Application) : AndroidViewModel(app) { + private val repo = SettingsRepository(app) + private val _settings = MutableStateFlow(repo.load()) + val settings: StateFlow = _settings + private val _status = MutableStateFlow("") + val status: StateFlow = _status + private val _history = MutableStateFlow>(emptyList()) + val history: StateFlow> = _history + + fun games(): List { + val arr = JSONArray(_settings.value.gamesJson) + return buildList { + for (i in 0 until arr.length()) { + val o = arr.getJSONObject(i) + add( + GameUi( + id = o.getString("id"), + name = o.optString("name", o.getString("id")), + nativePath = o.optString("nativePath", ""), + ), + ) + } + } + } + + fun saveSettings(s: AppSettings) { + repo.save(s) + _settings.value = s + } + + private fun service(): SessionService { + val s = _settings.value + require(s.endpointUrl.isNotBlank()) { "Configure endpoint URL in Settings" } + val store = MinioStore(s.endpointUrl, s.bucket, s.accessKey, s.secretKey, s.region) + return SessionService(getApplication(), store, s.deviceId) + } + + fun doctor() = runOp("doctor") { + val s = _settings.value + val store = MinioStore(s.endpointUrl, s.bucket, s.accessKey, s.secretKey, s.region) + "probe_ok ${store.probe()}" + } + + fun start(game: GameUi) = runOp("start") { + require(game.nativePath.isNotBlank()) { "Set native save path for ${game.id}" } + val meta = service().start(game.id, File(game.nativePath)) + "Started ${game.id}; lease=${meta.lease}" + } + + fun end(game: GameUi) = runOp("end") { + require(game.nativePath.isNotBlank()) { "Set native save path for ${game.id}" } + val meta = service().end(game.id, File(game.nativePath)) + "Ended ${game.id}; live=${meta.liveHash}" + } + + fun loadHistory(gameId: String) = runOp("history") { + val items = service().history(gameId) + _history.value = items + "history ${items.size} entries" + } + + fun restore(game: GameUi, from: String) = runOp("restore") { + require(game.nativePath.isNotBlank()) { "Set native save path for ${game.id}" } + val meta = service().restore(game.id, from, File(game.nativePath)) + "Restored $from; live=${meta.liveHash}" + } + + fun refreshStatus(gameId: String) = runOp("status") { + service().status(gameId) + } + + private fun runOp(label: String, block: () -> String) { + viewModelScope.launch { + _status.value = "$label…" + _status.value = try { + withContext(Dispatchers.IO) { block() } + } catch (e: Exception) { + "error: ${e.message}" + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SyncGamesApp(vm: SyncGamesViewModel = viewModel()) { + val nav = rememberNavController() + val status by vm.status.collectAsState() + NavHost(navController = nav, startDestination = "games") { + composable("games") { + Scaffold(topBar = { + TopAppBar( + title = { Text("SyncGames") }, + actions = { + TextButton(onClick = { nav.navigate("settings") }) { Text("Settings") } + }, + ) + }) { pad -> + GamesScreen(pad, vm, status, onOpen = { nav.navigate("game/$it") }) + } + } + composable("settings") { + Scaffold(topBar = { + TopAppBar( + title = { Text("Settings") }, + navigationIcon = { + TextButton(onClick = { nav.popBackStack() }) { Text("Back") } + }, + ) + }) { pad -> + SettingsScreen(pad, vm) + } + } + composable("game/{id}") { entry -> + val id = entry.arguments?.getString("id") ?: return@composable + Scaffold(topBar = { + TopAppBar( + title = { Text(id) }, + navigationIcon = { + TextButton(onClick = { nav.popBackStack() }) { Text("Back") } + }, + ) + }) { pad -> + GameDetailScreen(pad, vm, id, status) + } + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun GamesScreen( + pad: PaddingValues, + vm: SyncGamesViewModel, + status: String, + onOpen: (String) -> Unit, +) { + Column(Modifier.padding(pad).padding(16.dp).fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(status) + LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(vm.games()) { g -> + Card(onClick = { onOpen(g.id) }, modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + Text(g.name) + Text(g.id) + Text(if (g.nativePath.isBlank()) "path: not set" else "path: ${g.nativePath}") + } + } + } + } + OutlinedButton(onClick = { vm.doctor() }) { Text("Doctor (probe MinIO)") } + } +} + +@Composable +private fun SettingsScreen(pad: PaddingValues, vm: SyncGamesViewModel) { + val cur by vm.settings.collectAsState() + var endpoint by remember(cur) { mutableStateOf(cur.endpointUrl) } + var bucket by remember(cur) { mutableStateOf(cur.bucket) } + var access by remember(cur) { mutableStateOf(cur.accessKey) } + var secret by remember(cur) { mutableStateOf(cur.secretKey) } + var device by remember(cur) { mutableStateOf(cur.deviceId) } + var gamesJson by remember(cur) { mutableStateOf(cur.gamesJson) } + Column( + Modifier.padding(pad).padding(16.dp).fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField(endpoint, { endpoint = it }, label = { Text("Endpoint URL") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(bucket, { bucket = it }, label = { Text("Bucket") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(access, { access = it }, label = { Text("Access key") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(secret, { secret = it }, label = { Text("Secret key") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(device, { device = it }, label = { Text("Device id") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(gamesJson, { gamesJson = it }, label = { Text("Games JSON") }, modifier = Modifier.fillMaxWidth(), minLines = 4) + Button(onClick = { + vm.saveSettings( + AppSettings( + endpointUrl = endpoint.trim(), + bucket = bucket.trim(), + accessKey = access.trim(), + secretKey = secret.trim(), + deviceId = device.trim(), + gamesJson = gamesJson, + ), + ) + }) { Text("Save") } + Text("Paste absolute native save folder paths into games JSON nativePath fields (SAF path picker can be added later).") + } +} + +@Composable +private fun GameDetailScreen(pad: PaddingValues, vm: SyncGamesViewModel, gameId: String, status: String) { + val game = vm.games().firstOrNull { it.id == gameId } ?: return + val history by vm.history.collectAsState() + var confirmRestore by remember { mutableStateOf(null) } + Column( + Modifier.padding(pad).padding(16.dp).fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(game.name) + Text("path: ${game.nativePath.ifBlank { "(set in Settings games JSON)" }}") + Text(status) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { vm.start(game) }) { Text("Start session") } + Button(onClick = { vm.end(game) }) { Text("End session") } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { vm.refreshStatus(game.id) }) { Text("Status") } + OutlinedButton(onClick = { vm.loadHistory(game.id) }) { Text("History") } + } + LazyColumn(verticalArrangement = Arrangement.spacedBy(4.dp)) { + items(history) { item -> + OutlinedButton(onClick = { confirmRestore = item }) { Text("Restore $item") } + } + } + confirmRestore?.let { from -> + Text("Type YES conceptually: confirm restore of $from") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { + vm.restore(game, from) + confirmRestore = null + }) { Text("Confirm restore") } + OutlinedButton(onClick = { confirmRestore = null }) { Text("Cancel") } + } + } + } +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..7fb8e9f --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + SyncGames + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..47ce4b3 --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +