Initial SyncGames tree: agent, Android, deploy, docs.
Session-gated MinIO save sync with AppImage GUI, CLI edit/session flow, and Gitea release helper. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
+41
@@ -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/
|
||||||
@@ -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).
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# SyncGames Linux agent
|
||||||
|
|
||||||
|
CLI + thin PySide6 management GUI. Package as AppImage for easy PC/laptop setup.
|
||||||
|
|
||||||
|
## Dev install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv && source .venv/bin/activate
|
||||||
|
pip install -e ".[gui]"
|
||||||
|
syncgames-gui
|
||||||
|
# or: syncgames gui
|
||||||
|
```
|
||||||
|
|
||||||
|
## AppImage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./packaging/build-appimage.sh
|
||||||
|
# → dist/SyncGames-<version>-x86_64.AppImage
|
||||||
|
```
|
||||||
|
|
||||||
|
Config always lives in `~/.config/syncgames/` (writable). The AppImage stays read-only.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=SyncGames
|
||||||
|
Comment=Session-gated game save sync (setup and management)
|
||||||
|
Exec=SyncGames
|
||||||
|
Icon=syncgames
|
||||||
|
Categories=Utility;
|
||||||
|
Terminal=false
|
||||||
Executable
+117
@@ -0,0 +1,117 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build SyncGames-x86_64.AppImage (thin GUI + agent).
|
||||||
|
# Usage: from SyncGames/agent/, ./packaging/build-appimage.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
VERSION="$(python3 -c "from syncgames import __version__; print(__version__)" 2>/dev/null || echo "0.1.0")"
|
||||||
|
# Prefer package version when installed in venv
|
||||||
|
if [[ -d "${ROOT}/.venv" ]]; then
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "${ROOT}/.venv/bin/activate"
|
||||||
|
VERSION="$(python3 -c "from syncgames import __version__; print(__version__)")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
OUT_NAME="SyncGames-${VERSION}-x86_64.AppImage"
|
||||||
|
APPDIR="${ROOT}/build/appimage/AppDir"
|
||||||
|
BUILD_BIN="${ROOT}/build/appimage"
|
||||||
|
APPIMAGETOOL_URL="${APPIMAGETOOL_URL:-https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage}"
|
||||||
|
APPIMAGETOOL="${APPIMAGETOOL:-${BUILD_BIN}/appimagetool-x86_64.AppImage}"
|
||||||
|
|
||||||
|
echo "==> Version: ${VERSION}"
|
||||||
|
echo "==> Output: ${OUT_NAME}"
|
||||||
|
|
||||||
|
if [[ ! -d "${ROOT}/.venv" ]]; then
|
||||||
|
echo "==> Creating .venv"
|
||||||
|
python3 -m venv "${ROOT}/.venv"
|
||||||
|
fi
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "${ROOT}/.venv/bin/activate"
|
||||||
|
python3 -m pip install -q -U pip
|
||||||
|
python3 -m pip install -q -e ".[build]"
|
||||||
|
|
||||||
|
echo "==> PyInstaller"
|
||||||
|
rm -rf "${ROOT}/build/pyinstaller" "${ROOT}/dist/SyncGames" || true
|
||||||
|
pyinstaller "${ROOT}/packaging/pyinstaller.spec"
|
||||||
|
|
||||||
|
if [[ ! -x "${ROOT}/dist/SyncGames/SyncGames" ]]; then
|
||||||
|
echo "PyInstaller did not produce dist/SyncGames/SyncGames" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Assemble AppDir"
|
||||||
|
rm -rf "$APPDIR"
|
||||||
|
mkdir -p "$APPDIR/usr/bin" \
|
||||||
|
"$APPDIR/usr/lib" \
|
||||||
|
"$APPDIR/usr/share/applications" \
|
||||||
|
"$APPDIR/usr/share/icons/hicolor/256x256/apps" \
|
||||||
|
"$APPDIR/usr/share/icons/hicolor/128x128/apps" \
|
||||||
|
"$APPDIR/usr/share/metainfo"
|
||||||
|
|
||||||
|
cp -a "${ROOT}/dist/SyncGames" "$APPDIR/usr/lib/syncgames"
|
||||||
|
ln -sf "../lib/syncgames/SyncGames" "$APPDIR/usr/bin/SyncGames"
|
||||||
|
|
||||||
|
python3 - <<'PY'
|
||||||
|
from PIL import Image
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
base = Path("build/appimage/AppDir/usr/share/icons/hicolor")
|
||||||
|
color = (24, 72, 64, 255) # deep teal — save/sync feel, not purple AI default
|
||||||
|
for size in (256, 128):
|
||||||
|
p = base / f"{size}x{size}" / "apps" / "syncgames.png"
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
Image.new("RGBA", (size, size), color).save(p)
|
||||||
|
PY
|
||||||
|
|
||||||
|
cp "$ROOT/packaging/appimage/syncgames.desktop" "$APPDIR/usr/share/applications/"
|
||||||
|
cp "$APPDIR/usr/share/applications/syncgames.desktop" "$APPDIR/"
|
||||||
|
|
||||||
|
cat > "$APPDIR/usr/share/metainfo/io.github.syncgames.appdata.xml" <<EOF
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<component type="desktop-application">
|
||||||
|
<id>io.github.syncgames</id>
|
||||||
|
<name>SyncGames</name>
|
||||||
|
<summary>Session-gated game save sync setup and management</summary>
|
||||||
|
<metadata_license>CC0-1.0</metadata_license>
|
||||||
|
<project_license>MIT</project_license>
|
||||||
|
<description>
|
||||||
|
<p>Configure MinIO SSOT connection, manage game save paths, and run start/end session sync with lease and version guardrails.</p>
|
||||||
|
</description>
|
||||||
|
<launchable type="desktop-id">syncgames.desktop</launchable>
|
||||||
|
<content_rating type="oars-1.1"/>
|
||||||
|
<releases>
|
||||||
|
<release version="${VERSION}"/>
|
||||||
|
</releases>
|
||||||
|
</component>
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$APPDIR/AppRun" <<'SH'
|
||||||
|
#!/bin/bash
|
||||||
|
HERE="$(dirname "$(readlink -f "$0")")"
|
||||||
|
export PATH="${HERE}/usr/bin:${PATH}"
|
||||||
|
LIBDIR="${HERE}/usr/lib/syncgames"
|
||||||
|
export LD_LIBRARY_PATH="${LIBDIR}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
||||||
|
if [[ -d "${LIBDIR}/PySide6" ]]; then
|
||||||
|
export QT_PLUGIN_PATH="${LIBDIR}/PySide6/Qt/plugins${QT_PLUGIN_PATH:+:$QT_PLUGIN_PATH}"
|
||||||
|
fi
|
||||||
|
exec "${HERE}/usr/bin/SyncGames" "$@"
|
||||||
|
SH
|
||||||
|
chmod +x "$APPDIR/AppRun"
|
||||||
|
|
||||||
|
ln -sf "usr/share/icons/hicolor/256x256/apps/syncgames.png" "$APPDIR/.DirIcon" || true
|
||||||
|
# appimagetool expects Icon= name at AppDir root
|
||||||
|
cp "$APPDIR/usr/share/icons/hicolor/256x256/apps/syncgames.png" "$APPDIR/syncgames.png"
|
||||||
|
|
||||||
|
echo "==> appimagetool"
|
||||||
|
mkdir -p "$BUILD_BIN"
|
||||||
|
if [[ ! -x "$APPIMAGETOOL" ]]; then
|
||||||
|
curl -fsSL -o "$APPIMAGETOOL" "$APPIMAGETOOL_URL"
|
||||||
|
chmod +x "$APPIMAGETOOL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "${ROOT}/dist/${OUT_NAME}" || true
|
||||||
|
mkdir -p "${ROOT}/dist"
|
||||||
|
ARCH=x86_64 "$APPIMAGETOOL" "$APPDIR" "${ROOT}/dist/${OUT_NAME}"
|
||||||
|
echo "==> Built ${ROOT}/dist/${OUT_NAME}"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""PyInstaller / AppImage entry — launches SyncGames thin GUI."""
|
||||||
|
|
||||||
|
from syncgames.gui.app import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -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*"]
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""SyncGames Linux agent — session-gated save sync."""
|
||||||
|
|
||||||
|
__version__ = "0.1.1"
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from syncgames.cli import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from syncgames.config import (
|
||||||
|
AgentConfig,
|
||||||
|
GameConfig,
|
||||||
|
default_config_root,
|
||||||
|
list_games,
|
||||||
|
load_agent_config,
|
||||||
|
load_game,
|
||||||
|
slugify,
|
||||||
|
write_game_toml,
|
||||||
|
)
|
||||||
|
from syncgames.errors import SyncGamesError
|
||||||
|
from syncgames.lease import break_lease
|
||||||
|
from syncgames.session import (
|
||||||
|
ensure_game_remote,
|
||||||
|
end_session,
|
||||||
|
restore_session,
|
||||||
|
start_session,
|
||||||
|
status_text,
|
||||||
|
)
|
||||||
|
from syncgames.store import build_store
|
||||||
|
from syncgames.watchers import watch_loop
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg(args: argparse.Namespace) -> AgentConfig:
|
||||||
|
root = Path(args.config_root).expanduser() if getattr(args, "config_root", None) else None
|
||||||
|
return load_agent_config(root)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_add(args: argparse.Namespace) -> int:
|
||||||
|
cfg = _cfg(args)
|
||||||
|
store = build_store(cfg)
|
||||||
|
gid = args.id or slugify(args.name)
|
||||||
|
paths = args.paths or []
|
||||||
|
if not paths:
|
||||||
|
raise SyncGamesError("At least one --paths is required", key="config_error")
|
||||||
|
procs = args.process_name or []
|
||||||
|
game = GameConfig(
|
||||||
|
id=gid,
|
||||||
|
name=args.name,
|
||||||
|
platform=args.platform,
|
||||||
|
paths=paths,
|
||||||
|
versions_to_keep=args.versions,
|
||||||
|
steam_appid=args.steam_appid,
|
||||||
|
process_names=procs,
|
||||||
|
)
|
||||||
|
path = write_game_toml(cfg, game)
|
||||||
|
ensure_game_remote(store, game)
|
||||||
|
print(f"Added game {gid} → {path}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_remove(args: argparse.Namespace) -> int:
|
||||||
|
cfg = _cfg(args)
|
||||||
|
store = build_store(cfg)
|
||||||
|
game = load_game(cfg, args.game_id)
|
||||||
|
if args.purge_remote:
|
||||||
|
confirm = input(f"Type {game.id} to permanently purge remote: ")
|
||||||
|
if confirm != game.id:
|
||||||
|
print("Aborted")
|
||||||
|
return 1
|
||||||
|
# still retire first for safety unless --purge-remote means delete
|
||||||
|
dest = store.retire_game(game.id)
|
||||||
|
print(f"Retired then leaving retired/ in place at {dest} (manual purge from console)")
|
||||||
|
else:
|
||||||
|
dest = store.retire_game(game.id)
|
||||||
|
print(f"Remote moved to {dest}")
|
||||||
|
src = cfg.games_dir / f"{game.id}.toml"
|
||||||
|
if src.exists():
|
||||||
|
src.rename(src.with_suffix(".toml.removed"))
|
||||||
|
print(f"Local config renamed for {game.id}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_edit(args: argparse.Namespace) -> int:
|
||||||
|
"""Update an existing game.toml; game id stays stable for MinIO prefixes."""
|
||||||
|
cfg = _cfg(args)
|
||||||
|
game = load_game(cfg, args.game_id)
|
||||||
|
if args.name:
|
||||||
|
game.name = args.name
|
||||||
|
if args.platform:
|
||||||
|
game.platform = args.platform
|
||||||
|
if args.paths:
|
||||||
|
game.paths = args.paths
|
||||||
|
if args.add_path:
|
||||||
|
for p in args.add_path:
|
||||||
|
if p not in game.paths:
|
||||||
|
game.paths.append(p)
|
||||||
|
if args.versions is not None:
|
||||||
|
game.versions_to_keep = args.versions
|
||||||
|
if args.steam_appid is not None:
|
||||||
|
game.steam_appid = args.steam_appid
|
||||||
|
if args.process_name is not None:
|
||||||
|
game.process_names = args.process_name
|
||||||
|
path = write_game_toml(cfg, game)
|
||||||
|
print(f"Updated {game.id} → {path}")
|
||||||
|
for p in game.paths:
|
||||||
|
print(f" path: {p}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_start(args: argparse.Namespace) -> int:
|
||||||
|
cfg = _cfg(args)
|
||||||
|
store = build_store(cfg)
|
||||||
|
game = load_game(cfg, args.game_id)
|
||||||
|
meta = start_session(cfg, store, game)
|
||||||
|
print(f"Started session {game.id}; lease={meta.lease}; live_hash={meta.live_hash}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_end(args: argparse.Namespace) -> int:
|
||||||
|
cfg = _cfg(args)
|
||||||
|
store = build_store(cfg)
|
||||||
|
game = load_game(cfg, args.game_id)
|
||||||
|
meta = end_session(
|
||||||
|
cfg, store, game, force=args.force, force_hash=args.force_restore
|
||||||
|
)
|
||||||
|
print(f"Ended session {game.id}; new live_hash={meta.live_hash}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_status(args: argparse.Namespace) -> int:
|
||||||
|
cfg = _cfg(args)
|
||||||
|
store = build_store(cfg)
|
||||||
|
game = load_game(cfg, args.game_id) if args.game_id else None
|
||||||
|
print(status_text(cfg, store, game))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_history(args: argparse.Namespace) -> int:
|
||||||
|
cfg = _cfg(args)
|
||||||
|
store = build_store(cfg)
|
||||||
|
game = load_game(cfg, args.game_id)
|
||||||
|
for item in store.list_history(game.id):
|
||||||
|
print(item)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_restore(args: argparse.Namespace) -> int:
|
||||||
|
cfg = _cfg(args)
|
||||||
|
store = build_store(cfg)
|
||||||
|
game = load_game(cfg, args.game_id)
|
||||||
|
confirm = input(
|
||||||
|
f"Restore {game.id} live from history {args.from_} ? Type YES: "
|
||||||
|
)
|
||||||
|
if confirm != "YES":
|
||||||
|
print("Aborted")
|
||||||
|
return 1
|
||||||
|
meta = restore_session(cfg, store, game, args.from_)
|
||||||
|
print(f"Restored {args.from_}; live_hash={meta.live_hash}. Lease held — end when done.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_doctor(args: argparse.Namespace) -> int:
|
||||||
|
cfg = _cfg(args)
|
||||||
|
store = build_store(cfg)
|
||||||
|
print(f"device_id={cfg.device_id}")
|
||||||
|
print(f"store={cfg.store}")
|
||||||
|
print(f"config_root={cfg.config_root}")
|
||||||
|
print(f"state_root={cfg.state_root}")
|
||||||
|
if args.break_lease:
|
||||||
|
game = load_game(cfg, args.break_lease)
|
||||||
|
break_lease(store, game.id)
|
||||||
|
print(f"Broke lease for {game.id}")
|
||||||
|
try:
|
||||||
|
digest = store.probe()
|
||||||
|
print(f"probe_ok hash={digest}")
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(f"probe_FAILED: {e}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
for g in list_games(cfg):
|
||||||
|
meta = store.get_meta(g.id)
|
||||||
|
print(f"game {g.id}: meta={'yes' if meta else 'missing'}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_export_ssot(args: argparse.Namespace) -> int:
|
||||||
|
cfg = _cfg(args)
|
||||||
|
store = build_store(cfg)
|
||||||
|
out = Path(args.out).expanduser().resolve()
|
||||||
|
store.export_ssot(out)
|
||||||
|
print(f"Exported SSOT to {out}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_watch(args: argparse.Namespace) -> int:
|
||||||
|
cfg = _cfg(args)
|
||||||
|
store = build_store(cfg)
|
||||||
|
game = load_game(cfg, args.game_id)
|
||||||
|
|
||||||
|
def on_start() -> None:
|
||||||
|
print(f"[watch] start {game.id}")
|
||||||
|
try:
|
||||||
|
start_session(cfg, store, game)
|
||||||
|
except SyncGamesError as e:
|
||||||
|
print(f"[watch] start skipped: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
def on_end() -> None:
|
||||||
|
print(f"[watch] end {game.id}")
|
||||||
|
try:
|
||||||
|
end_session(cfg, store, game)
|
||||||
|
except SyncGamesError as e:
|
||||||
|
print(f"[watch] end failed: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
print(f"Watching {game.id} (Ctrl+C to stop)")
|
||||||
|
watch_loop(game, on_start, on_end)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
p = argparse.ArgumentParser(prog="syncgames", description="SyncGames session agent")
|
||||||
|
p.add_argument(
|
||||||
|
"--config-root",
|
||||||
|
default=None,
|
||||||
|
help=f"Config directory (default {default_config_root()})",
|
||||||
|
)
|
||||||
|
sub = p.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
|
add = sub.add_parser("add", help="Declare a new game")
|
||||||
|
add.add_argument("--name", required=True)
|
||||||
|
add.add_argument("--id", default=None, help="Slug override")
|
||||||
|
add.add_argument("--platform", default="steam", choices=["steam", "eden", "yuzu", "other"])
|
||||||
|
add.add_argument("--paths", action="append", default=[])
|
||||||
|
add.add_argument("--versions", type=int, default=5)
|
||||||
|
add.add_argument("--steam-appid", type=int, default=None)
|
||||||
|
add.add_argument("--process-name", action="append", default=[])
|
||||||
|
add.set_defaults(func=cmd_add)
|
||||||
|
|
||||||
|
rem = sub.add_parser("remove", help="Retire a game")
|
||||||
|
rem.add_argument("game_id")
|
||||||
|
rem.add_argument("--purge-remote", action="store_true")
|
||||||
|
rem.set_defaults(func=cmd_remove)
|
||||||
|
|
||||||
|
ed = sub.add_parser("edit", help="Update an existing game config (id stays stable)")
|
||||||
|
ed.add_argument("game_id")
|
||||||
|
ed.add_argument("--name", default=None)
|
||||||
|
ed.add_argument("--platform", default=None, choices=["steam", "eden", "yuzu", "other"])
|
||||||
|
ed.add_argument("--paths", action="append", default=None, help="Replace all paths")
|
||||||
|
ed.add_argument("--add-path", action="append", default=None, help="Append a path")
|
||||||
|
ed.add_argument("--versions", type=int, default=None)
|
||||||
|
ed.add_argument("--steam-appid", type=int, default=None)
|
||||||
|
ed.add_argument("--process-name", action="append", default=None)
|
||||||
|
ed.set_defaults(func=cmd_edit)
|
||||||
|
|
||||||
|
st = sub.add_parser("start", help="Pull SSOT and acquire lease")
|
||||||
|
st.add_argument("game_id")
|
||||||
|
st.set_defaults(func=cmd_start)
|
||||||
|
|
||||||
|
en = sub.add_parser("end", help="Push WIP and release lease")
|
||||||
|
en.add_argument("game_id")
|
||||||
|
en.add_argument("--force", action="store_true", help="Allow end while process still seen")
|
||||||
|
en.add_argument(
|
||||||
|
"--force-restore",
|
||||||
|
action="store_true",
|
||||||
|
help="Bypass hash gate (dangerous)",
|
||||||
|
)
|
||||||
|
en.set_defaults(func=cmd_end)
|
||||||
|
|
||||||
|
su = sub.add_parser("status", help="Show leases and sessions")
|
||||||
|
su.add_argument("game_id", nargs="?")
|
||||||
|
su.set_defaults(func=cmd_status)
|
||||||
|
|
||||||
|
hi = sub.add_parser("history", help="List remote history prefixes")
|
||||||
|
hi.add_argument("game_id")
|
||||||
|
hi.set_defaults(func=cmd_history)
|
||||||
|
|
||||||
|
rs = sub.add_parser("restore", help="Promote history → live")
|
||||||
|
rs.add_argument("game_id")
|
||||||
|
rs.add_argument("--from", dest="from_", required=True, help="device/ts")
|
||||||
|
rs.set_defaults(func=cmd_restore)
|
||||||
|
|
||||||
|
doc = sub.add_parser("doctor", help="Connectivity + config probe")
|
||||||
|
doc.add_argument("--break-lease", metavar="GAME_ID", default=None)
|
||||||
|
doc.set_defaults(func=cmd_doctor)
|
||||||
|
|
||||||
|
ex = sub.add_parser("export-ssot", help="Export bucket/tree for Path 1 fallback")
|
||||||
|
ex.add_argument("--out", required=True)
|
||||||
|
ex.set_defaults(func=cmd_export_ssot)
|
||||||
|
|
||||||
|
wa = sub.add_parser("watch", help="Auto start/end around process lifetime")
|
||||||
|
wa.add_argument("game_id")
|
||||||
|
wa.set_defaults(func=cmd_watch)
|
||||||
|
|
||||||
|
gui = sub.add_parser("gui", help="Open thin setup / management GUI")
|
||||||
|
gui.set_defaults(func=cmd_gui)
|
||||||
|
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_gui(_args: argparse.Namespace) -> int:
|
||||||
|
from syncgames.gui.app import main as gui_main
|
||||||
|
|
||||||
|
gui_main()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> None:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
try:
|
||||||
|
code = args.func(args)
|
||||||
|
except SyncGamesError as e:
|
||||||
|
print(f"error[{e.key}]: {e}", file=sys.stderr)
|
||||||
|
raise SystemExit(2) from e
|
||||||
|
raise SystemExit(code)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tomllib
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from syncgames.errors import ConfigError
|
||||||
|
|
||||||
|
SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
||||||
|
|
||||||
|
|
||||||
|
def slugify(name: str) -> str:
|
||||||
|
s = name.lower().strip()
|
||||||
|
s = re.sub(r"[^a-z0-9]+", "-", s)
|
||||||
|
s = re.sub(r"-+", "-", s).strip("-")
|
||||||
|
if not s or not SLUG_RE.match(s):
|
||||||
|
raise ConfigError(f"Cannot slugify name into valid game-id: {name!r}")
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def expand_path(p: str) -> Path:
|
||||||
|
return Path(os.path.expanduser(os.path.expandvars(p))).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AgentConfig:
|
||||||
|
device_id: str
|
||||||
|
endpoint_url: str
|
||||||
|
bucket: str = "syncgames"
|
||||||
|
region: str = "us-east-1"
|
||||||
|
path_style: bool = True
|
||||||
|
access_key: str = ""
|
||||||
|
secret_key: str = ""
|
||||||
|
store: str = "minio" # minio | filesystem
|
||||||
|
ssot_root: Path | None = None
|
||||||
|
lease_ttl_hours: int = 6
|
||||||
|
config_root: Path = field(default_factory=Path)
|
||||||
|
state_root: Path = field(default_factory=Path)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def games_dir(self) -> Path:
|
||||||
|
return self.config_root / "games"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GameConfig:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
platform: str
|
||||||
|
paths: list[str]
|
||||||
|
versions_to_keep: int = 5
|
||||||
|
steam_appid: int | None = None
|
||||||
|
process_names: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
def resolved_paths(self) -> list[Path]:
|
||||||
|
return [expand_path(p) for p in self.paths]
|
||||||
|
|
||||||
|
|
||||||
|
def default_config_root() -> Path:
|
||||||
|
env = os.environ.get("SYNCGAMES_CONFIG")
|
||||||
|
if env:
|
||||||
|
return Path(env).expanduser().resolve()
|
||||||
|
xdg = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))
|
||||||
|
return Path(xdg) / "syncgames"
|
||||||
|
|
||||||
|
|
||||||
|
def default_state_root() -> Path:
|
||||||
|
env = os.environ.get("SYNCGAMES_STATE")
|
||||||
|
if env:
|
||||||
|
return Path(env).expanduser().resolve()
|
||||||
|
xdg = os.environ.get("XDG_STATE_HOME", str(Path.home() / ".local" / "state"))
|
||||||
|
return Path(xdg) / "syncgames"
|
||||||
|
|
||||||
|
|
||||||
|
def repo_config_root() -> Path | None:
|
||||||
|
"""If running from the SyncGames checkout, prefer repo config/ when present."""
|
||||||
|
here = Path(__file__).resolve()
|
||||||
|
candidate = here.parents[2] / "config" # SyncGames/config
|
||||||
|
if (candidate / "agent.toml.example").exists() or (candidate / "agent.toml").exists():
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_agent_config(config_root: Path | None = None) -> AgentConfig:
|
||||||
|
root = config_root or default_config_root()
|
||||||
|
agent_path = root / "agent.toml"
|
||||||
|
# Bootstrap: allow repo example copy location
|
||||||
|
if not agent_path.exists():
|
||||||
|
repo = repo_config_root()
|
||||||
|
if repo and (repo / "agent.toml").exists():
|
||||||
|
root = repo
|
||||||
|
agent_path = root / "agent.toml"
|
||||||
|
elif repo and (repo / "agent.toml.example").exists():
|
||||||
|
raise ConfigError(
|
||||||
|
f"No agent.toml found. Copy {repo / 'agent.toml.example'} to "
|
||||||
|
f"{default_config_root() / 'agent.toml'} or {repo / 'agent.toml'}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ConfigError(f"Missing agent config: {agent_path}")
|
||||||
|
|
||||||
|
with agent_path.open("rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
|
||||||
|
store = data.get("store", "minio")
|
||||||
|
ssot = data.get("ssot_root")
|
||||||
|
return AgentConfig(
|
||||||
|
device_id=str(data["device_id"]),
|
||||||
|
endpoint_url=str(data.get("endpoint_url", "")),
|
||||||
|
bucket=str(data.get("bucket", "syncgames")),
|
||||||
|
region=str(data.get("region", "us-east-1")),
|
||||||
|
path_style=bool(data.get("path_style", True)),
|
||||||
|
access_key=str(data.get("access_key", "")),
|
||||||
|
secret_key=str(data.get("secret_key", "")),
|
||||||
|
store=store,
|
||||||
|
ssot_root=expand_path(ssot) if ssot else None,
|
||||||
|
lease_ttl_hours=int(data.get("lease_ttl_hours", 6)),
|
||||||
|
config_root=root,
|
||||||
|
state_root=default_state_root(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_game(config: AgentConfig, game_id: str) -> GameConfig:
|
||||||
|
path = config.games_dir / f"{game_id}.toml"
|
||||||
|
if not path.exists():
|
||||||
|
raise ConfigError(f"Unknown game: {game_id} (expected {path})")
|
||||||
|
with path.open("rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
paths = data.get("paths") or []
|
||||||
|
if isinstance(paths, str):
|
||||||
|
paths = [paths]
|
||||||
|
procs = data.get("process_names") or data.get("process_name") or []
|
||||||
|
if isinstance(procs, str):
|
||||||
|
procs = [procs]
|
||||||
|
return GameConfig(
|
||||||
|
id=str(data.get("id", game_id)),
|
||||||
|
name=str(data.get("name", game_id)),
|
||||||
|
platform=str(data.get("platform", "other")),
|
||||||
|
paths=[str(p) for p in paths],
|
||||||
|
versions_to_keep=int(data.get("versions_to_keep", 5)),
|
||||||
|
steam_appid=int(data["steam_appid"]) if data.get("steam_appid") else None,
|
||||||
|
process_names=[str(p) for p in procs],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_games(config: AgentConfig) -> list[GameConfig]:
|
||||||
|
games_dir = config.games_dir
|
||||||
|
if not games_dir.exists():
|
||||||
|
return []
|
||||||
|
out: list[GameConfig] = []
|
||||||
|
for path in sorted(games_dir.glob("*.toml")):
|
||||||
|
if path.name.endswith(".removed.toml"):
|
||||||
|
continue
|
||||||
|
out.append(load_game(config, path.stem))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def write_game_toml(config: AgentConfig, game: GameConfig) -> Path:
|
||||||
|
config.games_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
path = config.games_dir / f"{game.id}.toml"
|
||||||
|
lines = [
|
||||||
|
f'id = "{game.id}"',
|
||||||
|
f'name = "{game.name}"',
|
||||||
|
f'platform = "{game.platform}"',
|
||||||
|
f"versions_to_keep = {game.versions_to_keep}",
|
||||||
|
"paths = [",
|
||||||
|
]
|
||||||
|
for p in game.paths:
|
||||||
|
lines.append(f' "{p}",')
|
||||||
|
lines.append("]")
|
||||||
|
if game.steam_appid is not None:
|
||||||
|
lines.append(f"steam_appid = {game.steam_appid}")
|
||||||
|
if game.process_names:
|
||||||
|
lines.append("process_names = [")
|
||||||
|
for n in game.process_names:
|
||||||
|
lines.append(f' "{n}",')
|
||||||
|
lines.append("]")
|
||||||
|
lines.append("")
|
||||||
|
path.write_text("\n".join(lines), encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _toml_str(value: str) -> str:
|
||||||
|
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||||
|
|
||||||
|
|
||||||
|
def save_agent_config(config: AgentConfig, *, path: Path | None = None) -> Path:
|
||||||
|
"""Write agent.toml (secrets included — file mode 0600)."""
|
||||||
|
root = config.config_root if config.config_root else default_config_root()
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
out = path or (root / "agent.toml")
|
||||||
|
lines = [
|
||||||
|
f"device_id = {_toml_str(config.device_id)}",
|
||||||
|
f"store = {_toml_str(config.store)}",
|
||||||
|
f"endpoint_url = {_toml_str(config.endpoint_url)}",
|
||||||
|
f"bucket = {_toml_str(config.bucket)}",
|
||||||
|
f"region = {_toml_str(config.region)}",
|
||||||
|
f"path_style = {'true' if config.path_style else 'false'}",
|
||||||
|
f"access_key = {_toml_str(config.access_key)}",
|
||||||
|
f"secret_key = {_toml_str(config.secret_key)}",
|
||||||
|
f"lease_ttl_hours = {int(config.lease_ttl_hours)}",
|
||||||
|
]
|
||||||
|
if config.store == "filesystem" and config.ssot_root is not None:
|
||||||
|
lines.append(f"ssot_root = {_toml_str(str(config.ssot_root))}")
|
||||||
|
lines.append("")
|
||||||
|
out.write_text("\n".join(lines), encoding="utf-8")
|
||||||
|
try:
|
||||||
|
out.chmod(0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def try_load_agent_config(config_root: Path | None = None) -> AgentConfig | None:
|
||||||
|
try:
|
||||||
|
return load_agent_config(config_root)
|
||||||
|
except ConfigError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _bundled_root() -> Path | None:
|
||||||
|
"""PyInstaller _MEIPASS or package-adjacent bundled_* dirs."""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
|
||||||
|
return Path(sys._MEIPASS)
|
||||||
|
here = Path(__file__).resolve().parents[1]
|
||||||
|
if (here / "bundled_config").exists() or (here / "bundled_systemd").exists():
|
||||||
|
return here
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def seed_example_games(config: AgentConfig, source_games_dir: Path | None = None) -> int:
|
||||||
|
"""Copy missing example game tomls from the SyncGames checkout into config."""
|
||||||
|
if source_games_dir is None:
|
||||||
|
repo = repo_config_root()
|
||||||
|
if repo and (repo / "games").exists():
|
||||||
|
source_games_dir = repo / "games"
|
||||||
|
else:
|
||||||
|
bundled = _bundled_root()
|
||||||
|
if bundled and (bundled / "bundled_config" / "games").exists():
|
||||||
|
source_games_dir = bundled / "bundled_config" / "games"
|
||||||
|
if source_games_dir is None or not source_games_dir.exists():
|
||||||
|
return 0
|
||||||
|
config.games_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
copied = 0
|
||||||
|
for src in sorted(source_games_dir.glob("*.toml")):
|
||||||
|
dest = config.games_dir / src.name
|
||||||
|
if not dest.exists():
|
||||||
|
dest.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
|
||||||
|
copied += 1
|
||||||
|
return copied
|
||||||
|
|
||||||
|
|
||||||
|
def install_systemd_units(unit_source: Path | None = None) -> Path:
|
||||||
|
"""Install user systemd units; returns the unit directory."""
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
xdg = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))
|
||||||
|
unit_dir = Path(xdg) / "systemd" / "user"
|
||||||
|
unit_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
if unit_source is None:
|
||||||
|
candidate = Path(__file__).resolve().parents[2] / "systemd"
|
||||||
|
if not candidate.exists():
|
||||||
|
bundled = _bundled_root()
|
||||||
|
candidate = (bundled / "bundled_systemd") if bundled else candidate
|
||||||
|
unit_source = candidate
|
||||||
|
for name in ("syncgames-agent.service", "[email protected]"):
|
||||||
|
src = unit_source / name
|
||||||
|
if src.exists():
|
||||||
|
shutil.copy2(src, unit_dir / name)
|
||||||
|
return unit_dir
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class SyncGamesError(Exception):
|
||||||
|
key = "error"
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, key: str | None = None) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
if key:
|
||||||
|
self.key = key
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(SyncGamesError):
|
||||||
|
key = "config_error"
|
||||||
|
|
||||||
|
|
||||||
|
class LeaseHeldError(SyncGamesError):
|
||||||
|
key = "lease_held"
|
||||||
|
|
||||||
|
|
||||||
|
class StaleWipError(SyncGamesError):
|
||||||
|
key = "stale_wip"
|
||||||
|
|
||||||
|
|
||||||
|
class StoreError(SyncGamesError):
|
||||||
|
key = "store_error"
|
||||||
|
|
||||||
|
|
||||||
|
class GameRunningError(SyncGamesError):
|
||||||
|
key = "game_running"
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"""Thin PySide6 setup / management GUI for SyncGames."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
__all__ = ["run_gui"]
|
||||||
|
|
||||||
|
|
||||||
|
def run_gui() -> None:
|
||||||
|
from syncgames.gui.app import main
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from syncgames import __version__
|
||||||
|
from syncgames.gui.main_window import MainWindow
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
app.setApplicationName("SyncGames")
|
||||||
|
app.setOrganizationName("SyncGames")
|
||||||
|
app.setApplicationVersion(__version__)
|
||||||
|
win = MainWindow()
|
||||||
|
win.show()
|
||||||
|
raise SystemExit(app.exec())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,646 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import traceback
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtCore import QObject, QThread, Signal
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QCheckBox,
|
||||||
|
QComboBox,
|
||||||
|
QFileDialog,
|
||||||
|
QFormLayout,
|
||||||
|
QGroupBox,
|
||||||
|
QHBoxLayout,
|
||||||
|
QInputDialog,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QListWidget,
|
||||||
|
QMainWindow,
|
||||||
|
QMessageBox,
|
||||||
|
QPlainTextEdit,
|
||||||
|
QPushButton,
|
||||||
|
QSpinBox,
|
||||||
|
QTabWidget,
|
||||||
|
QTextEdit,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from syncgames import __version__
|
||||||
|
from syncgames.config import (
|
||||||
|
AgentConfig,
|
||||||
|
GameConfig,
|
||||||
|
default_config_root,
|
||||||
|
install_systemd_units,
|
||||||
|
list_games,
|
||||||
|
save_agent_config,
|
||||||
|
seed_example_games,
|
||||||
|
slugify,
|
||||||
|
try_load_agent_config,
|
||||||
|
write_game_toml,
|
||||||
|
)
|
||||||
|
from syncgames.errors import SyncGamesError
|
||||||
|
from syncgames.session import (
|
||||||
|
end_session,
|
||||||
|
restore_session,
|
||||||
|
start_session,
|
||||||
|
status_text,
|
||||||
|
)
|
||||||
|
from syncgames.store import build_store
|
||||||
|
|
||||||
|
|
||||||
|
class Worker(QObject):
|
||||||
|
finished = Signal(str)
|
||||||
|
failed = Signal(str)
|
||||||
|
|
||||||
|
def __init__(self, fn) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._fn = fn
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
try:
|
||||||
|
result = self._fn()
|
||||||
|
self.finished.emit(result if isinstance(result, str) else str(result or "ok"))
|
||||||
|
except BaseException as e: # noqa: BLE001 — keep GUI alive on any probe/S3 failure
|
||||||
|
self.failed.emit(f"{type(e).__name__}: {e}\n\n{traceback.format_exc()}")
|
||||||
|
|
||||||
|
|
||||||
|
class MainWindow(QMainWindow):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.setWindowTitle(f"SyncGames {_version_label()}")
|
||||||
|
self.resize(920, 640)
|
||||||
|
self._config_root = default_config_root()
|
||||||
|
self._thread: QThread | None = None
|
||||||
|
self._worker: Worker | None = None
|
||||||
|
self._editing_game_id: str | None = None
|
||||||
|
|
||||||
|
self._log = QTextEdit(self)
|
||||||
|
self._log.setReadOnly(True)
|
||||||
|
self._log.setMinimumHeight(140)
|
||||||
|
|
||||||
|
tabs = QTabWidget(self)
|
||||||
|
tabs.addTab(self._build_setup(QWidget()), "Setup")
|
||||||
|
tabs.addTab(self._build_games(QWidget()), "Games")
|
||||||
|
tabs.addTab(self._build_session(QWidget()), "Session")
|
||||||
|
|
||||||
|
holder = QWidget(self)
|
||||||
|
lay = QVBoxLayout(holder)
|
||||||
|
lay.addWidget(tabs)
|
||||||
|
lay.addWidget(QLabel("Log"))
|
||||||
|
lay.addWidget(self._log)
|
||||||
|
self.setCentralWidget(holder)
|
||||||
|
|
||||||
|
self._load_config_into_form()
|
||||||
|
self._refresh_games()
|
||||||
|
|
||||||
|
def _build_setup(self, parent: QWidget) -> QWidget:
|
||||||
|
layout = QVBoxLayout(parent)
|
||||||
|
box = QGroupBox("MinIO / Cloudflare endpoint", parent)
|
||||||
|
form = QFormLayout(box)
|
||||||
|
|
||||||
|
self._device = QLineEdit(box)
|
||||||
|
self._endpoint = QLineEdit(box)
|
||||||
|
self._endpoint.setPlaceholderText("https://syncgames-s3.example.com")
|
||||||
|
self._bucket = QLineEdit(box)
|
||||||
|
self._bucket.setText("syncgames")
|
||||||
|
self._region = QLineEdit(box)
|
||||||
|
self._region.setText("us-east-1")
|
||||||
|
self._access = QLineEdit(box)
|
||||||
|
self._secret = QLineEdit(box)
|
||||||
|
self._secret.setEchoMode(QLineEdit.EchoMode.Password)
|
||||||
|
self._ttl = QSpinBox(box)
|
||||||
|
self._ttl.setRange(1, 72)
|
||||||
|
self._ttl.setValue(6)
|
||||||
|
self._path_style = QCheckBox("Path-style S3 addressing (required behind NGINX)", box)
|
||||||
|
self._path_style.setChecked(True)
|
||||||
|
self._store = QComboBox(box)
|
||||||
|
self._store.addItems(["minio", "filesystem"])
|
||||||
|
self._ssot = QLineEdit(box)
|
||||||
|
self._ssot.setPlaceholderText("Path 1 fallback ssot_root only")
|
||||||
|
|
||||||
|
form.addRow("Device id", self._device)
|
||||||
|
form.addRow("Endpoint URL", self._endpoint)
|
||||||
|
form.addRow("Bucket", self._bucket)
|
||||||
|
form.addRow("Region", self._region)
|
||||||
|
form.addRow("Access key", self._access)
|
||||||
|
form.addRow("Secret key", self._secret)
|
||||||
|
form.addRow("Lease TTL (hours)", self._ttl)
|
||||||
|
form.addRow("", self._path_style)
|
||||||
|
form.addRow("Store backend", self._store)
|
||||||
|
form.addRow("SSOT root (filesystem)", self._ssot)
|
||||||
|
|
||||||
|
layout.addWidget(box)
|
||||||
|
|
||||||
|
row = QHBoxLayout()
|
||||||
|
save_btn = QPushButton("Save config", parent)
|
||||||
|
save_btn.clicked.connect(self._on_save_config)
|
||||||
|
doctor_btn = QPushButton("Doctor (probe)", parent)
|
||||||
|
doctor_btn.clicked.connect(self._on_doctor)
|
||||||
|
seed_btn = QPushButton("Seed example games", parent)
|
||||||
|
seed_btn.clicked.connect(self._on_seed_games)
|
||||||
|
units_btn = QPushButton("Install systemd units", parent)
|
||||||
|
units_btn.clicked.connect(self._on_install_units)
|
||||||
|
row.addWidget(save_btn)
|
||||||
|
row.addWidget(doctor_btn)
|
||||||
|
row.addWidget(seed_btn)
|
||||||
|
row.addWidget(units_btn)
|
||||||
|
layout.addLayout(row)
|
||||||
|
|
||||||
|
hint = QLabel(
|
||||||
|
f"Config written to {_config_hint(self._config_root)}/agent.toml "
|
||||||
|
"(mode 0600). CLI remains available as syncgames.",
|
||||||
|
parent,
|
||||||
|
)
|
||||||
|
hint.setWordWrap(True)
|
||||||
|
layout.addWidget(hint)
|
||||||
|
layout.addStretch(1)
|
||||||
|
return parent
|
||||||
|
|
||||||
|
def _build_games(self, parent: QWidget) -> QWidget:
|
||||||
|
layout = QVBoxLayout(parent)
|
||||||
|
self._games_list = QListWidget(parent)
|
||||||
|
self._games_list.itemSelectionChanged.connect(self._on_game_selection_changed)
|
||||||
|
self._games_list.itemDoubleClicked.connect(lambda _item: self._load_selected_game())
|
||||||
|
layout.addWidget(self._games_list)
|
||||||
|
|
||||||
|
form_box = QGroupBox("Add / edit game", parent)
|
||||||
|
form = QFormLayout(form_box)
|
||||||
|
self._g_id = QLineEdit(form_box)
|
||||||
|
self._g_id.setPlaceholderText("auto from name when adding")
|
||||||
|
self._g_id.setReadOnly(True)
|
||||||
|
self._g_name = QLineEdit(form_box)
|
||||||
|
self._g_platform = QComboBox(form_box)
|
||||||
|
self._g_platform.addItems(["steam", "eden", "yuzu", "other"])
|
||||||
|
self._g_paths = QPlainTextEdit(form_box)
|
||||||
|
self._g_paths.setPlaceholderText("One save path per line")
|
||||||
|
self._g_paths.setFixedHeight(72)
|
||||||
|
self._g_versions = QSpinBox(form_box)
|
||||||
|
self._g_versions.setRange(3, 20)
|
||||||
|
self._g_versions.setValue(5)
|
||||||
|
self._g_appid = QLineEdit(form_box)
|
||||||
|
self._g_proc = QLineEdit(form_box)
|
||||||
|
self._g_proc.setPlaceholderText("eldenring.exe (comma-separated ok)")
|
||||||
|
|
||||||
|
browse = QPushButton("Add folder…", form_box)
|
||||||
|
browse.clicked.connect(self._browse_save_path)
|
||||||
|
path_row = QHBoxLayout()
|
||||||
|
path_row.addWidget(self._g_paths, 1)
|
||||||
|
path_row.addWidget(browse)
|
||||||
|
path_wrap = QWidget(form_box)
|
||||||
|
path_wrap.setLayout(path_row)
|
||||||
|
|
||||||
|
form.addRow("Game id", self._g_id)
|
||||||
|
form.addRow("Name", self._g_name)
|
||||||
|
form.addRow("Platform", self._g_platform)
|
||||||
|
form.addRow("Save path(s)", path_wrap)
|
||||||
|
form.addRow("Versions to keep", self._g_versions)
|
||||||
|
form.addRow("Steam AppID", self._g_appid)
|
||||||
|
form.addRow("Process name(s)", self._g_proc)
|
||||||
|
layout.addWidget(form_box)
|
||||||
|
|
||||||
|
row = QHBoxLayout()
|
||||||
|
load_btn = QPushButton("Load selected", parent)
|
||||||
|
load_btn.clicked.connect(self._load_selected_game)
|
||||||
|
add_btn = QPushButton("Add new", parent)
|
||||||
|
add_btn.clicked.connect(self._on_add_game)
|
||||||
|
save_btn = QPushButton("Save changes", parent)
|
||||||
|
save_btn.clicked.connect(self._on_save_game)
|
||||||
|
clear_btn = QPushButton("Clear form", parent)
|
||||||
|
clear_btn.clicked.connect(self._clear_game_form)
|
||||||
|
refresh_btn = QPushButton("Refresh", parent)
|
||||||
|
refresh_btn.clicked.connect(self._refresh_games)
|
||||||
|
remove_btn = QPushButton("Retire selected", parent)
|
||||||
|
remove_btn.clicked.connect(self._on_retire_game)
|
||||||
|
row.addWidget(load_btn)
|
||||||
|
row.addWidget(add_btn)
|
||||||
|
row.addWidget(save_btn)
|
||||||
|
row.addWidget(clear_btn)
|
||||||
|
row.addWidget(refresh_btn)
|
||||||
|
row.addWidget(remove_btn)
|
||||||
|
layout.addLayout(row)
|
||||||
|
return parent
|
||||||
|
|
||||||
|
def _build_session(self, parent: QWidget) -> QWidget:
|
||||||
|
layout = QVBoxLayout(parent)
|
||||||
|
row = QHBoxLayout()
|
||||||
|
self._session_game = QComboBox(parent)
|
||||||
|
row.addWidget(QLabel("Game"))
|
||||||
|
row.addWidget(self._session_game, 1)
|
||||||
|
layout.addLayout(row)
|
||||||
|
|
||||||
|
btns = QHBoxLayout()
|
||||||
|
for label, slot in (
|
||||||
|
("Start session", self._on_start),
|
||||||
|
("End session", self._on_end),
|
||||||
|
("Status", self._on_status),
|
||||||
|
("History", self._on_history),
|
||||||
|
("Restore…", self._on_restore),
|
||||||
|
):
|
||||||
|
b = QPushButton(label, parent)
|
||||||
|
b.clicked.connect(slot)
|
||||||
|
btns.addWidget(b)
|
||||||
|
layout.addLayout(btns)
|
||||||
|
|
||||||
|
warn = QLabel(
|
||||||
|
"Start pulls SSOT → native saves and takes the lease. "
|
||||||
|
"End pushes WIP → history + live and releases the lease. "
|
||||||
|
"Never use continuous sync on live game folders.",
|
||||||
|
parent,
|
||||||
|
)
|
||||||
|
warn.setWordWrap(True)
|
||||||
|
layout.addWidget(warn)
|
||||||
|
layout.addStretch(1)
|
||||||
|
return parent
|
||||||
|
|
||||||
|
def _append_log(self, text: str) -> None:
|
||||||
|
self._log.append(text.rstrip() + "\n")
|
||||||
|
|
||||||
|
def _on_bg_ok(self, text: str) -> None:
|
||||||
|
self._append_log(text)
|
||||||
|
if text.startswith("probe_ok"):
|
||||||
|
QMessageBox.information(self, "Doctor", text)
|
||||||
|
|
||||||
|
def _on_bg_err(self, err: str) -> None:
|
||||||
|
self._append_log(f"ERROR: {err}")
|
||||||
|
# Short popup; full traceback stays in the log panel
|
||||||
|
brief = err.split("\n\n", 1)[0]
|
||||||
|
QMessageBox.warning(
|
||||||
|
self,
|
||||||
|
"Operation failed",
|
||||||
|
brief
|
||||||
|
+ "\n\nTip: for LAN testing use endpoint http://192.168.1.5:9000 "
|
||||||
|
"(leave Cloudflare URL until NGINX returns 200, not 502).",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _run_bg(self, label: str, fn) -> None:
|
||||||
|
if self._thread is not None and self._thread.isRunning():
|
||||||
|
QMessageBox.information(self, "Busy", "Wait for the current operation to finish.")
|
||||||
|
return
|
||||||
|
self._append_log(f"→ {label}…")
|
||||||
|
thread = QThread(self)
|
||||||
|
worker = Worker(fn)
|
||||||
|
# Keep Python refs so Qt does not use-after-free the worker (AppImage crash).
|
||||||
|
self._thread = thread
|
||||||
|
self._worker = worker
|
||||||
|
worker.moveToThread(thread)
|
||||||
|
thread.started.connect(worker.run)
|
||||||
|
worker.finished.connect(self._on_bg_ok)
|
||||||
|
worker.failed.connect(self._on_bg_err)
|
||||||
|
worker.finished.connect(thread.quit)
|
||||||
|
worker.failed.connect(thread.quit)
|
||||||
|
|
||||||
|
def _clear() -> None:
|
||||||
|
self._worker = None
|
||||||
|
self._thread = None
|
||||||
|
|
||||||
|
thread.finished.connect(_clear)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
def _form_to_config(self) -> AgentConfig:
|
||||||
|
ssot = self._ssot.text().strip()
|
||||||
|
return AgentConfig(
|
||||||
|
device_id=self._device.text().strip() or "pc-desk",
|
||||||
|
endpoint_url=self._endpoint.text().strip(),
|
||||||
|
bucket=self._bucket.text().strip() or "syncgames",
|
||||||
|
region=self._region.text().strip() or "us-east-1",
|
||||||
|
path_style=self._path_style.isChecked(),
|
||||||
|
access_key=self._access.text().strip(),
|
||||||
|
secret_key=self._secret.text().strip(),
|
||||||
|
store=self._store.currentText(),
|
||||||
|
ssot_root=Path(ssot).expanduser() if ssot else None,
|
||||||
|
lease_ttl_hours=self._ttl.value(),
|
||||||
|
config_root=self._config_root,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_config_into_form(self) -> None:
|
||||||
|
cfg = try_load_agent_config(self._config_root)
|
||||||
|
if cfg is None:
|
||||||
|
self._device.setText("pc-desk")
|
||||||
|
self._append_log(f"No config yet — will create {self._config_root / 'agent.toml'}")
|
||||||
|
return
|
||||||
|
self._config_root = cfg.config_root
|
||||||
|
self._device.setText(cfg.device_id)
|
||||||
|
self._endpoint.setText(cfg.endpoint_url)
|
||||||
|
self._bucket.setText(cfg.bucket)
|
||||||
|
self._region.setText(cfg.region)
|
||||||
|
self._access.setText(cfg.access_key)
|
||||||
|
self._secret.setText(cfg.secret_key)
|
||||||
|
self._ttl.setValue(cfg.lease_ttl_hours)
|
||||||
|
self._path_style.setChecked(cfg.path_style)
|
||||||
|
idx = self._store.findText(cfg.store)
|
||||||
|
if idx >= 0:
|
||||||
|
self._store.setCurrentIndex(idx)
|
||||||
|
if cfg.ssot_root:
|
||||||
|
self._ssot.setText(str(cfg.ssot_root))
|
||||||
|
self._append_log(f"Loaded config from {cfg.config_root / 'agent.toml'}")
|
||||||
|
|
||||||
|
def _require_cfg(self) -> AgentConfig:
|
||||||
|
cfg = try_load_agent_config(self._config_root)
|
||||||
|
if cfg is None:
|
||||||
|
raise SyncGamesError("Save Setup config first", key="config_error")
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
def _on_save_config(self) -> None:
|
||||||
|
cfg = self._form_to_config()
|
||||||
|
path = save_agent_config(cfg)
|
||||||
|
self._append_log(f"Saved {path}")
|
||||||
|
QMessageBox.information(self, "Saved", f"Wrote {path}")
|
||||||
|
|
||||||
|
def _on_doctor(self) -> None:
|
||||||
|
def work() -> str:
|
||||||
|
cfg = self._form_to_config()
|
||||||
|
save_agent_config(cfg)
|
||||||
|
store = build_store(cfg)
|
||||||
|
digest = store.probe()
|
||||||
|
return f"probe_ok hash={digest}"
|
||||||
|
|
||||||
|
self._run_bg("doctor", work)
|
||||||
|
|
||||||
|
def _on_seed_games(self) -> None:
|
||||||
|
cfg = self._form_to_config()
|
||||||
|
self._config_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
save_agent_config(cfg)
|
||||||
|
n = seed_example_games(cfg)
|
||||||
|
self._refresh_games()
|
||||||
|
self._append_log(f"Seeded {n} example game config(s)")
|
||||||
|
|
||||||
|
def _on_install_units(self) -> None:
|
||||||
|
try:
|
||||||
|
unit_dir = install_systemd_units()
|
||||||
|
self._append_log(f"Installed systemd user units to {unit_dir}")
|
||||||
|
self._append_log(
|
||||||
|
"Enable a watcher: systemctl --user enable --now "
|
||||||
|
"[email protected]"
|
||||||
|
)
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
"Units installed",
|
||||||
|
f"Copied units to {unit_dir}\n\n"
|
||||||
|
"Then: systemctl --user daemon-reload\n"
|
||||||
|
"Enable: systemctl --user enable --now syncgames-watch@<game-id>.service",
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
QMessageBox.critical(self, "Failed", str(e))
|
||||||
|
|
||||||
|
def _browse_save_path(self) -> None:
|
||||||
|
path = QFileDialog.getExistingDirectory(self, "Select save folder")
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
existing = [ln.strip() for ln in self._g_paths.toPlainText().splitlines() if ln.strip()]
|
||||||
|
if path not in existing:
|
||||||
|
existing.append(path)
|
||||||
|
self._g_paths.setPlainText("\n".join(existing))
|
||||||
|
|
||||||
|
def _game_paths_from_form(self) -> list[str]:
|
||||||
|
return [ln.strip() for ln in self._g_paths.toPlainText().splitlines() if ln.strip()]
|
||||||
|
|
||||||
|
def _clear_game_form(self) -> None:
|
||||||
|
self._editing_game_id = None
|
||||||
|
self._g_id.clear()
|
||||||
|
self._g_id.setPlaceholderText("auto from name when adding")
|
||||||
|
self._g_name.clear()
|
||||||
|
self._g_platform.setCurrentIndex(0)
|
||||||
|
self._g_paths.clear()
|
||||||
|
self._g_versions.setValue(5)
|
||||||
|
self._g_appid.clear()
|
||||||
|
self._g_proc.clear()
|
||||||
|
|
||||||
|
def _on_game_selection_changed(self) -> None:
|
||||||
|
# Convenience: keep list selection in sync but don't auto-overwrite the form
|
||||||
|
# unless the user clicks Load / double-clicks.
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _list_selected_game_id(self) -> str | None:
|
||||||
|
item = self._games_list.currentItem()
|
||||||
|
if not item:
|
||||||
|
return None
|
||||||
|
return item.text().split(" — ", 1)[0].strip()
|
||||||
|
|
||||||
|
def _load_selected_game(self) -> None:
|
||||||
|
game_id = self._list_selected_game_id()
|
||||||
|
if not game_id:
|
||||||
|
QMessageBox.information(self, "Edit game", "Select a game in the list first.")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
cfg = self._require_cfg()
|
||||||
|
from syncgames.config import load_game
|
||||||
|
|
||||||
|
game = load_game(cfg, game_id)
|
||||||
|
except SyncGamesError as e:
|
||||||
|
QMessageBox.warning(self, "Edit game", str(e))
|
||||||
|
return
|
||||||
|
self._editing_game_id = game.id
|
||||||
|
self._g_id.setText(game.id)
|
||||||
|
self._g_name.setText(game.name)
|
||||||
|
idx = self._g_platform.findText(game.platform)
|
||||||
|
if idx >= 0:
|
||||||
|
self._g_platform.setCurrentIndex(idx)
|
||||||
|
self._g_paths.setPlainText("\n".join(game.paths))
|
||||||
|
self._g_versions.setValue(game.versions_to_keep)
|
||||||
|
self._g_appid.setText("" if game.steam_appid is None else str(game.steam_appid))
|
||||||
|
self._g_proc.setText(", ".join(game.process_names))
|
||||||
|
self._append_log(f"Loaded {game.id} into editor (Save changes to update)")
|
||||||
|
|
||||||
|
def _refresh_games(self) -> None:
|
||||||
|
self._games_list.clear()
|
||||||
|
self._session_game.clear()
|
||||||
|
cfg = try_load_agent_config(self._config_root)
|
||||||
|
if cfg is None:
|
||||||
|
return
|
||||||
|
for g in list_games(cfg):
|
||||||
|
self._games_list.addItem(f"{g.id} — {g.name}")
|
||||||
|
self._session_game.addItem(g.id, g.id)
|
||||||
|
|
||||||
|
def _on_add_game(self) -> None:
|
||||||
|
try:
|
||||||
|
cfg = self._require_cfg()
|
||||||
|
name = self._g_name.text().strip()
|
||||||
|
paths = self._game_paths_from_form()
|
||||||
|
if not name or not paths:
|
||||||
|
raise SyncGamesError("Name and at least one save path required", key="config_error")
|
||||||
|
game_id = slugify(name)
|
||||||
|
if (cfg.games_dir / f"{game_id}.toml").exists():
|
||||||
|
raise SyncGamesError(
|
||||||
|
f"Game id {game_id} already exists — Load selected + Save changes, or pick another name",
|
||||||
|
key="config_error",
|
||||||
|
)
|
||||||
|
appid = self._g_appid.text().strip()
|
||||||
|
procs = [p.strip() for p in self._g_proc.text().split(",") if p.strip()]
|
||||||
|
game = GameConfig(
|
||||||
|
id=game_id,
|
||||||
|
name=name,
|
||||||
|
platform=self._g_platform.currentText(),
|
||||||
|
paths=paths,
|
||||||
|
versions_to_keep=self._g_versions.value(),
|
||||||
|
steam_appid=int(appid) if appid else None,
|
||||||
|
process_names=procs,
|
||||||
|
)
|
||||||
|
write_game_toml(cfg, game)
|
||||||
|
store = build_store(cfg)
|
||||||
|
from syncgames.session import ensure_game_remote
|
||||||
|
|
||||||
|
ensure_game_remote(store, game)
|
||||||
|
self._editing_game_id = game.id
|
||||||
|
self._g_id.setText(game.id)
|
||||||
|
self._append_log(f"Added game {game.id}")
|
||||||
|
self._refresh_games()
|
||||||
|
except SyncGamesError as e:
|
||||||
|
QMessageBox.warning(self, "Add game", str(e))
|
||||||
|
|
||||||
|
def _on_save_game(self) -> None:
|
||||||
|
try:
|
||||||
|
cfg = self._require_cfg()
|
||||||
|
game_id = self._editing_game_id or self._g_id.text().strip() or self._list_selected_game_id()
|
||||||
|
if not game_id:
|
||||||
|
raise SyncGamesError(
|
||||||
|
"Load a game first (Load selected / double-click), then Save changes",
|
||||||
|
key="config_error",
|
||||||
|
)
|
||||||
|
name = self._g_name.text().strip()
|
||||||
|
paths = self._game_paths_from_form()
|
||||||
|
if not name or not paths:
|
||||||
|
raise SyncGamesError("Name and at least one save path required", key="config_error")
|
||||||
|
if not (cfg.games_dir / f"{game_id}.toml").exists():
|
||||||
|
raise SyncGamesError(f"Unknown game id {game_id}", key="config_error")
|
||||||
|
appid = self._g_appid.text().strip()
|
||||||
|
procs = [p.strip() for p in self._g_proc.text().split(",") if p.strip()]
|
||||||
|
game = GameConfig(
|
||||||
|
id=game_id, # keep stable so MinIO prefixes stay valid
|
||||||
|
name=name,
|
||||||
|
platform=self._g_platform.currentText(),
|
||||||
|
paths=paths,
|
||||||
|
versions_to_keep=self._g_versions.value(),
|
||||||
|
steam_appid=int(appid) if appid else None,
|
||||||
|
process_names=procs,
|
||||||
|
)
|
||||||
|
write_game_toml(cfg, game)
|
||||||
|
self._editing_game_id = game_id
|
||||||
|
self._g_id.setText(game_id)
|
||||||
|
self._append_log(f"Updated game {game_id}")
|
||||||
|
self._refresh_games()
|
||||||
|
QMessageBox.information(self, "Saved", f"Updated {game_id} (game id kept stable for MinIO)")
|
||||||
|
except SyncGamesError as e:
|
||||||
|
QMessageBox.warning(self, "Save changes", str(e))
|
||||||
|
except ValueError as e:
|
||||||
|
QMessageBox.warning(self, "Save changes", str(e))
|
||||||
|
|
||||||
|
def _on_retire_game(self) -> None:
|
||||||
|
game_id = self._list_selected_game_id()
|
||||||
|
if not game_id:
|
||||||
|
return
|
||||||
|
if QMessageBox.question(self, "Retire", f"Retire remote + local config for {game_id}?") != QMessageBox.StandardButton.Yes:
|
||||||
|
return
|
||||||
|
|
||||||
|
def work() -> str:
|
||||||
|
cfg = self._require_cfg()
|
||||||
|
from syncgames.config import load_game
|
||||||
|
|
||||||
|
game = load_game(cfg, game_id)
|
||||||
|
store = build_store(cfg)
|
||||||
|
dest = store.retire_game(game.id)
|
||||||
|
src = cfg.games_dir / f"{game.id}.toml"
|
||||||
|
if src.exists():
|
||||||
|
src.rename(src.with_suffix(".toml.removed"))
|
||||||
|
return f"Retired {game_id} → {dest}"
|
||||||
|
|
||||||
|
if self._editing_game_id == game_id:
|
||||||
|
self._clear_game_form()
|
||||||
|
self._run_bg("retire", work)
|
||||||
|
self._refresh_games()
|
||||||
|
|
||||||
|
def _selected_game_id(self) -> str:
|
||||||
|
return self._session_game.currentText().strip()
|
||||||
|
|
||||||
|
def _on_start(self) -> None:
|
||||||
|
gid = self._selected_game_id()
|
||||||
|
if not gid:
|
||||||
|
return
|
||||||
|
|
||||||
|
def work() -> str:
|
||||||
|
cfg = self._require_cfg()
|
||||||
|
from syncgames.config import load_game
|
||||||
|
|
||||||
|
game = load_game(cfg, gid)
|
||||||
|
meta = start_session(cfg, build_store(cfg), game)
|
||||||
|
return f"Started {gid}; lease={meta.lease}; live_hash={meta.live_hash}"
|
||||||
|
|
||||||
|
self._run_bg(f"start {gid}", work)
|
||||||
|
|
||||||
|
def _on_end(self) -> None:
|
||||||
|
gid = self._selected_game_id()
|
||||||
|
if not gid:
|
||||||
|
return
|
||||||
|
|
||||||
|
def work() -> str:
|
||||||
|
cfg = self._require_cfg()
|
||||||
|
from syncgames.config import load_game
|
||||||
|
|
||||||
|
game = load_game(cfg, gid)
|
||||||
|
meta = end_session(cfg, build_store(cfg), game)
|
||||||
|
return f"Ended {gid}; live_hash={meta.live_hash}"
|
||||||
|
|
||||||
|
self._run_bg(f"end {gid}", work)
|
||||||
|
|
||||||
|
def _on_status(self) -> None:
|
||||||
|
gid = self._selected_game_id()
|
||||||
|
|
||||||
|
def work() -> str:
|
||||||
|
cfg = self._require_cfg()
|
||||||
|
from syncgames.config import load_game
|
||||||
|
|
||||||
|
game = load_game(cfg, gid) if gid else None
|
||||||
|
return status_text(cfg, build_store(cfg), game)
|
||||||
|
|
||||||
|
self._run_bg("status", work)
|
||||||
|
|
||||||
|
def _on_history(self) -> None:
|
||||||
|
gid = self._selected_game_id()
|
||||||
|
if not gid:
|
||||||
|
return
|
||||||
|
|
||||||
|
def work() -> str:
|
||||||
|
cfg = self._require_cfg()
|
||||||
|
items = build_store(cfg).list_history(gid)
|
||||||
|
return "history:\n" + ("\n".join(items) if items else "(empty)")
|
||||||
|
|
||||||
|
self._run_bg(f"history {gid}", work)
|
||||||
|
|
||||||
|
def _on_restore(self) -> None:
|
||||||
|
gid = self._selected_game_id()
|
||||||
|
if not gid:
|
||||||
|
return
|
||||||
|
cfg = try_load_agent_config(self._config_root)
|
||||||
|
if cfg is None:
|
||||||
|
QMessageBox.warning(self, "Restore", "Save config first")
|
||||||
|
return
|
||||||
|
items = build_store(cfg).list_history(gid)
|
||||||
|
if not items:
|
||||||
|
QMessageBox.information(self, "Restore", "No history entries")
|
||||||
|
return
|
||||||
|
choice, ok = QInputDialog.getItem(self, "Restore", "History prefix", items, 0, False)
|
||||||
|
if not ok or not choice:
|
||||||
|
return
|
||||||
|
confirm, ok2 = QInputDialog.getText(
|
||||||
|
self, "Confirm", f'Type YES to restore live from "{choice}"'
|
||||||
|
)
|
||||||
|
if not ok2 or confirm != "YES":
|
||||||
|
self._append_log("Restore aborted")
|
||||||
|
return
|
||||||
|
|
||||||
|
def work() -> str:
|
||||||
|
from syncgames.config import load_game
|
||||||
|
|
||||||
|
game = load_game(cfg, gid)
|
||||||
|
meta = restore_session(cfg, build_store(cfg), game, choice)
|
||||||
|
return f"Restored {choice}; live_hash={meta.live_hash}"
|
||||||
|
|
||||||
|
self._run_bg(f"restore {choice}", work)
|
||||||
|
|
||||||
|
|
||||||
|
def _version_label() -> str:
|
||||||
|
return __version__
|
||||||
|
|
||||||
|
|
||||||
|
def _config_hint(root: Path) -> str:
|
||||||
|
return str(root)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_file(path: Path) -> str:
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with path.open("rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
return f"sha256:{h.hexdigest()}"
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_bytes(data: bytes) -> str:
|
||||||
|
return f"sha256:{hashlib.sha256(data).hexdigest()}"
|
||||||
|
|
||||||
|
|
||||||
|
def tree_hash(file_checksums: dict[str, str]) -> str:
|
||||||
|
"""Canonical tree hash per docs/protocol.md."""
|
||||||
|
lines: list[bytes] = []
|
||||||
|
for rel in sorted(file_checksums.keys()):
|
||||||
|
digest = file_checksums[rel]
|
||||||
|
if digest.startswith("sha256:"):
|
||||||
|
hexdigest = digest[len("sha256:") :]
|
||||||
|
else:
|
||||||
|
hexdigest = digest
|
||||||
|
lines.append(f"{rel}\0{hexdigest}\n".encode("utf-8"))
|
||||||
|
return sha256_bytes(b"".join(lines))
|
||||||
|
|
||||||
|
|
||||||
|
def checksum_dir(root: Path, files: list[Path] | None = None) -> dict[str, str]:
|
||||||
|
"""Hash files under root; keys are relative posix paths."""
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
if files is None:
|
||||||
|
paths = sorted(p for p in root.rglob("*") if p.is_file())
|
||||||
|
else:
|
||||||
|
paths = files
|
||||||
|
for p in paths:
|
||||||
|
rel = p.relative_to(root).as_posix()
|
||||||
|
out[rel] = sha256_file(p)
|
||||||
|
return out
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from syncgames.errors import LeaseHeldError
|
||||||
|
from syncgames.store import Meta, ObjectStore, parse_expires, utc_now
|
||||||
|
|
||||||
|
|
||||||
|
def lease_active(lease: dict[str, Any] | None) -> bool:
|
||||||
|
if not lease:
|
||||||
|
return False
|
||||||
|
exp = parse_expires(lease.get("expires_at"))
|
||||||
|
if exp is None:
|
||||||
|
return False
|
||||||
|
return exp > utc_now()
|
||||||
|
|
||||||
|
|
||||||
|
def acquire_lease(
|
||||||
|
store: ObjectStore,
|
||||||
|
game_id: str,
|
||||||
|
device_id: str,
|
||||||
|
*,
|
||||||
|
ttl_hours: int,
|
||||||
|
versions_to_keep: int,
|
||||||
|
break_other: bool = False,
|
||||||
|
) -> Meta:
|
||||||
|
meta = store.get_meta(game_id)
|
||||||
|
if meta is None:
|
||||||
|
from syncgames.store import empty_meta
|
||||||
|
|
||||||
|
meta = empty_meta(game_id, versions_to_keep)
|
||||||
|
|
||||||
|
lease = meta.lease
|
||||||
|
if lease_active(lease) and lease and lease.get("holder") != device_id:
|
||||||
|
if not break_other:
|
||||||
|
raise LeaseHeldError(
|
||||||
|
f"Lease held by {lease.get('holder')} until {lease.get('expires_at')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
expires = utc_now() + timedelta(hours=ttl_hours)
|
||||||
|
meta.raw["lease"] = {
|
||||||
|
"holder": device_id,
|
||||||
|
"expires_at": expires.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"session_id": session_id,
|
||||||
|
}
|
||||||
|
meta.raw["versions_to_keep"] = versions_to_keep
|
||||||
|
store.put_meta(game_id, meta)
|
||||||
|
# Re-read to reduce lost-update window (optimistic)
|
||||||
|
again = store.get_meta(game_id)
|
||||||
|
if again is None or not again.lease or again.lease.get("holder") != device_id:
|
||||||
|
raise LeaseHeldError("Lost lease race — another device acquired the lease")
|
||||||
|
return again
|
||||||
|
|
||||||
|
|
||||||
|
def release_lease(store: ObjectStore, game_id: str, device_id: str, meta: Meta | None = None) -> Meta:
|
||||||
|
meta = meta or store.get_meta(game_id)
|
||||||
|
if meta is None:
|
||||||
|
from syncgames.store import empty_meta
|
||||||
|
|
||||||
|
meta = empty_meta(game_id)
|
||||||
|
lease = meta.lease
|
||||||
|
if lease and lease.get("holder") not in (None, device_id):
|
||||||
|
if lease_active(lease):
|
||||||
|
raise LeaseHeldError(f"Cannot release lease owned by {lease.get('holder')}")
|
||||||
|
meta.raw["lease"] = None
|
||||||
|
store.put_meta(game_id, meta)
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
def break_lease(store: ObjectStore, game_id: str) -> Meta:
|
||||||
|
from syncgames.store import empty_meta
|
||||||
|
|
||||||
|
meta = store.get_meta(game_id) or empty_meta(game_id)
|
||||||
|
meta.raw["lease"] = None
|
||||||
|
store.put_meta(game_id, meta)
|
||||||
|
return meta
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from syncgames.config import AgentConfig, GameConfig
|
||||||
|
from syncgames.errors import GameRunningError, StaleWipError, SyncGamesError
|
||||||
|
from syncgames.hashutil import checksum_dir, tree_hash
|
||||||
|
from syncgames.lease import acquire_lease, release_lease
|
||||||
|
from syncgames.store import Meta, ObjectStore, empty_meta, iso_ts, utc_now
|
||||||
|
from syncgames.watchers import is_game_running
|
||||||
|
|
||||||
|
|
||||||
|
def session_path(cfg: AgentConfig, game_id: str) -> Path:
|
||||||
|
return cfg.state_root / "sessions" / f"{game_id}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def wip_dir(cfg: AgentConfig, game_id: str) -> Path:
|
||||||
|
return cfg.state_root / "wip" / game_id
|
||||||
|
|
||||||
|
|
||||||
|
def load_session(cfg: AgentConfig, game_id: str) -> dict | None:
|
||||||
|
p = session_path(cfg, game_id)
|
||||||
|
if not p.exists():
|
||||||
|
return None
|
||||||
|
return json.loads(p.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def save_session(cfg: AgentConfig, game_id: str, data: dict) -> None:
|
||||||
|
p = session_path(cfg, game_id)
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
p.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def clear_session(cfg: AgentConfig, game_id: str) -> None:
|
||||||
|
p = session_path(cfg, game_id)
|
||||||
|
if p.exists():
|
||||||
|
p.unlink()
|
||||||
|
w = wip_dir(cfg, game_id)
|
||||||
|
if w.exists():
|
||||||
|
shutil.rmtree(w)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_native_files(game: GameConfig) -> list[tuple[Path, str]]:
|
||||||
|
"""Return (absolute_path, archive_relpath) for all save files."""
|
||||||
|
out: list[tuple[Path, str]] = []
|
||||||
|
for base in game.resolved_paths():
|
||||||
|
if not base.exists():
|
||||||
|
continue
|
||||||
|
if base.is_file():
|
||||||
|
out.append((base, base.name))
|
||||||
|
continue
|
||||||
|
for path in sorted(p for p in base.rglob("*") if p.is_file()):
|
||||||
|
# Skip obvious temp / lock clutter
|
||||||
|
if path.suffix.lower() in {".tmp", ".lock", ".part"}:
|
||||||
|
continue
|
||||||
|
if path.name.startswith("."):
|
||||||
|
continue
|
||||||
|
rel = f"{base.name}/{path.relative_to(base).as_posix()}"
|
||||||
|
out.append((path, rel))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def stage_native_to_wip(cfg: AgentConfig, game: GameConfig) -> Path:
|
||||||
|
dest = wip_dir(cfg, game.id)
|
||||||
|
if dest.exists():
|
||||||
|
shutil.rmtree(dest)
|
||||||
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
for src, rel in collect_native_files(game):
|
||||||
|
target = dest / rel
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(src, target)
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
def install_staging_to_native(staging: Path, game: GameConfig) -> None:
|
||||||
|
"""Map staging layout back onto configured native paths."""
|
||||||
|
# Staging uses <basename>/<rel> for directories and <name> for single files
|
||||||
|
for base in game.resolved_paths():
|
||||||
|
if base.suffix and not base.exists() and not any(base.parent.glob(base.name)):
|
||||||
|
# treat as file target
|
||||||
|
candidate = staging / base.name
|
||||||
|
if candidate.is_file():
|
||||||
|
base.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(candidate, base)
|
||||||
|
continue
|
||||||
|
# Directory target: look for staging/<basename>/
|
||||||
|
staged = staging / base.name
|
||||||
|
if staged.is_dir():
|
||||||
|
base.mkdir(parents=True, exist_ok=True)
|
||||||
|
for path in staged.rglob("*"):
|
||||||
|
if path.is_file():
|
||||||
|
rel = path.relative_to(staged)
|
||||||
|
target = base / rel
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(path, target)
|
||||||
|
elif (staging / base.name).is_file() and base.parent.exists():
|
||||||
|
# file named same as last component under parent
|
||||||
|
shutil.copy2(staging / base.name, base)
|
||||||
|
|
||||||
|
|
||||||
|
def wait_until_idle(game: GameConfig, *, timeout_s: float = 30.0) -> None:
|
||||||
|
"""Poll until the game process exits or timeout."""
|
||||||
|
deadline = time.time() + timeout_s
|
||||||
|
while is_game_running(game):
|
||||||
|
if time.time() > deadline:
|
||||||
|
raise GameRunningError(
|
||||||
|
f"Game still running for {game.id} after {timeout_s:.0f}s — quit the game or use --force"
|
||||||
|
)
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def start_session(cfg: AgentConfig, store: ObjectStore, game: GameConfig) -> Meta:
|
||||||
|
meta = acquire_lease(
|
||||||
|
store,
|
||||||
|
game.id,
|
||||||
|
cfg.device_id,
|
||||||
|
ttl_hours=cfg.lease_ttl_hours,
|
||||||
|
versions_to_keep=game.versions_to_keep,
|
||||||
|
)
|
||||||
|
staging = wip_dir(cfg, game.id)
|
||||||
|
if staging.exists():
|
||||||
|
shutil.rmtree(staging)
|
||||||
|
staging.mkdir(parents=True, exist_ok=True)
|
||||||
|
checksums = store.download_live(game.id, staging)
|
||||||
|
install_staging_to_native(staging, game)
|
||||||
|
parent = meta.live_hash or tree_hash(checksums) if checksums else None
|
||||||
|
if checksums and not meta.live_hash:
|
||||||
|
parent = tree_hash(checksums)
|
||||||
|
save_session(
|
||||||
|
cfg,
|
||||||
|
game.id,
|
||||||
|
{
|
||||||
|
"game_id": game.id,
|
||||||
|
"parent_live_hash": parent,
|
||||||
|
"session_id": (meta.lease or {}).get("session_id"),
|
||||||
|
"started_at": utc_now().strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"device_id": cfg.device_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
def end_session(
|
||||||
|
cfg: AgentConfig,
|
||||||
|
store: ObjectStore,
|
||||||
|
game: GameConfig,
|
||||||
|
*,
|
||||||
|
force: bool = False,
|
||||||
|
force_hash: bool = False,
|
||||||
|
) -> Meta:
|
||||||
|
sess = load_session(cfg, game.id)
|
||||||
|
if not sess:
|
||||||
|
raise SyncGamesError(
|
||||||
|
f"No local session for {game.id}; run start first", key="config_error"
|
||||||
|
)
|
||||||
|
if not force:
|
||||||
|
try:
|
||||||
|
wait_until_idle(game)
|
||||||
|
except GameRunningError:
|
||||||
|
raise
|
||||||
|
if is_game_running(game) and not force:
|
||||||
|
raise GameRunningError(f"Refuse end while {game.id} process is running")
|
||||||
|
|
||||||
|
staging = stage_native_to_wip(cfg, game)
|
||||||
|
checksums = checksum_dir(staging)
|
||||||
|
wip_hash = tree_hash(checksums) if checksums else None
|
||||||
|
|
||||||
|
meta = store.get_meta(game.id) or empty_meta(game.id, game.versions_to_keep)
|
||||||
|
current_live = meta.live_hash
|
||||||
|
parent = sess.get("parent_live_hash")
|
||||||
|
if current_live and parent and current_live != parent and not force_hash:
|
||||||
|
raise StaleWipError(
|
||||||
|
f"Stale WIP: parent {parent} != live {current_live}. "
|
||||||
|
"Refuse push to avoid LOPE. Use --force-restore only if intentional."
|
||||||
|
)
|
||||||
|
|
||||||
|
ts = iso_ts()
|
||||||
|
hist_prefix = f"{cfg.device_id}/{ts}"
|
||||||
|
store.upload_prefix(game.id, "history", hist_prefix, staging)
|
||||||
|
store.upload_prefix(game.id, "live", "", staging)
|
||||||
|
store.delete_live_not_in(game.id, set(checksums.keys()))
|
||||||
|
|
||||||
|
meta.raw["live_hash"] = wip_hash
|
||||||
|
meta.raw["file_checksums"] = checksums
|
||||||
|
meta.raw["updated_at"] = utc_now().strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
meta.raw["updated_by"] = cfg.device_id
|
||||||
|
meta.raw["versions_to_keep"] = game.versions_to_keep
|
||||||
|
meta.raw["lease"] = None
|
||||||
|
store.put_meta(game.id, meta)
|
||||||
|
|
||||||
|
store.prune_device_history(game.id, cfg.device_id, game.versions_to_keep)
|
||||||
|
clear_session(cfg, game.id)
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
def restore_session(
|
||||||
|
cfg: AgentConfig,
|
||||||
|
store: ObjectStore,
|
||||||
|
game: GameConfig,
|
||||||
|
device_ts: str,
|
||||||
|
) -> Meta:
|
||||||
|
acquire_lease(
|
||||||
|
store,
|
||||||
|
game.id,
|
||||||
|
cfg.device_id,
|
||||||
|
ttl_hours=cfg.lease_ttl_hours,
|
||||||
|
versions_to_keep=game.versions_to_keep,
|
||||||
|
)
|
||||||
|
store.copy_history_to_live(game.id, device_ts)
|
||||||
|
staging = wip_dir(cfg, game.id)
|
||||||
|
if staging.exists():
|
||||||
|
shutil.rmtree(staging)
|
||||||
|
checksums = store.download_live(game.id, staging)
|
||||||
|
live_hash = tree_hash(checksums) if checksums else None
|
||||||
|
meta = store.get_meta(game.id) or empty_meta(game.id, game.versions_to_keep)
|
||||||
|
meta.raw["live_hash"] = live_hash
|
||||||
|
meta.raw["file_checksums"] = checksums
|
||||||
|
meta.raw["updated_at"] = utc_now().strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
meta.raw["updated_by"] = cfg.device_id
|
||||||
|
store.put_meta(game.id, meta)
|
||||||
|
# Leave lease held; user should start-equivalent install
|
||||||
|
install_staging_to_native(staging, game)
|
||||||
|
save_session(
|
||||||
|
cfg,
|
||||||
|
game.id,
|
||||||
|
{
|
||||||
|
"game_id": game.id,
|
||||||
|
"parent_live_hash": live_hash,
|
||||||
|
"session_id": (meta.lease or {}).get("session_id"),
|
||||||
|
"started_at": utc_now().strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"device_id": cfg.device_id,
|
||||||
|
"restored_from": device_ts,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_game_remote(store: ObjectStore, game: GameConfig) -> None:
|
||||||
|
meta = store.get_meta(game.id)
|
||||||
|
if meta is None:
|
||||||
|
m = empty_meta(game.id, game.versions_to_keep)
|
||||||
|
store.put_meta(game.id, m)
|
||||||
|
|
||||||
|
|
||||||
|
def status_text(cfg: AgentConfig, store: ObjectStore, game: GameConfig | None) -> str:
|
||||||
|
lines: list[str] = [f"device: {cfg.device_id}", f"store: {cfg.store}"]
|
||||||
|
games = [game] if game else __import__("syncgames.config", fromlist=["list_games"]).list_games(cfg)
|
||||||
|
for g in games:
|
||||||
|
meta = store.get_meta(g.id)
|
||||||
|
sess = load_session(cfg, g.id)
|
||||||
|
lease = meta.lease if meta else None
|
||||||
|
lines.append(f"\n[{g.id}] {g.name}")
|
||||||
|
lines.append(f" live_hash: {(meta.live_hash if meta else None)}")
|
||||||
|
lines.append(f" lease: {lease}")
|
||||||
|
lines.append(f" local_session: {sess}")
|
||||||
|
lines.append(f" running: {is_game_running(g)}")
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from syncgames.errors import StoreError
|
||||||
|
from syncgames.hashutil import sha256_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def iso_ts(dt: datetime | None = None) -> str:
|
||||||
|
d = dt or utc_now()
|
||||||
|
return d.strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_expires(s: str | None) -> datetime | None:
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
if s.endswith("Z"):
|
||||||
|
s = s[:-1] + "+00:00"
|
||||||
|
return datetime.fromisoformat(s)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Meta:
|
||||||
|
raw: dict[str, Any]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def live_hash(self) -> str | None:
|
||||||
|
return self.raw.get("live_hash")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def lease(self) -> dict[str, Any] | None:
|
||||||
|
return self.raw.get("lease")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def versions_to_keep(self) -> int:
|
||||||
|
return int(self.raw.get("versions_to_keep", 5))
|
||||||
|
|
||||||
|
def to_json(self) -> bytes:
|
||||||
|
return (json.dumps(self.raw, indent=2, sort_keys=True) + "\n").encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectStore(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
def get_meta(self, game_id: str) -> Meta | None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def put_meta(self, game_id: str, meta: Meta) -> None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def list_live(self, game_id: str) -> list[str]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def download_live(self, game_id: str, dest_dir: Path) -> dict[str, str]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def upload_prefix(
|
||||||
|
self, game_id: str, kind: str, prefix_extra: str, local_dir: Path
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""kind is 'live' or 'history'; prefix_extra for history is 'device/ts'."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def delete_live_not_in(self, game_id: str, keep_rels: set[str]) -> None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def list_history(self, game_id: str) -> list[str]:
|
||||||
|
"""Return list of 'device/ts' prefixes newest-first-ish."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def copy_history_to_live(self, game_id: str, device_ts: str) -> None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def prune_device_history(self, game_id: str, device_id: str, keep: int) -> int: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def retire_game(self, game_id: str) -> str: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def probe(self) -> str: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def export_ssot(self, out_dir: Path) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
def empty_meta(game_id: str, versions_to_keep: int = 5) -> Meta:
|
||||||
|
return Meta(
|
||||||
|
{
|
||||||
|
"schema": 1,
|
||||||
|
"game_id": game_id,
|
||||||
|
"live_hash": None,
|
||||||
|
"file_checksums": {},
|
||||||
|
"lease": None,
|
||||||
|
"versions_to_keep": versions_to_keep,
|
||||||
|
"updated_at": utc_now().strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"updated_by": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MinioStore(ObjectStore):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
endpoint_url: str,
|
||||||
|
bucket: str,
|
||||||
|
access_key: str,
|
||||||
|
secret_key: str,
|
||||||
|
region: str = "us-east-1",
|
||||||
|
path_style: bool = True,
|
||||||
|
) -> None:
|
||||||
|
import boto3
|
||||||
|
from botocore.client import Config
|
||||||
|
|
||||||
|
if not endpoint_url or not access_key or not secret_key:
|
||||||
|
raise StoreError(
|
||||||
|
"MinIO endpoint_url, access_key, and secret_key are required",
|
||||||
|
key="config_error",
|
||||||
|
)
|
||||||
|
self.bucket = bucket
|
||||||
|
cfg = Config(
|
||||||
|
signature_version="s3v4",
|
||||||
|
s3={"addressing_style": "path" if path_style else "auto"},
|
||||||
|
connect_timeout=10,
|
||||||
|
read_timeout=60,
|
||||||
|
retries={"max_attempts": 2, "mode": "standard"},
|
||||||
|
)
|
||||||
|
self.s3 = boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=endpoint_url.rstrip("/"),
|
||||||
|
aws_access_key_id=access_key,
|
||||||
|
aws_secret_access_key=secret_key,
|
||||||
|
region_name=region,
|
||||||
|
config=cfg,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _key(self, *parts: str) -> str:
|
||||||
|
return "/".join(p.strip("/") for p in parts if p)
|
||||||
|
|
||||||
|
def get_meta(self, game_id: str) -> Meta | None:
|
||||||
|
key = self._key("games", game_id, "meta.json")
|
||||||
|
try:
|
||||||
|
obj = self.s3.get_object(Bucket=self.bucket, Key=key)
|
||||||
|
data = json.loads(obj["Body"].read().decode("utf-8"))
|
||||||
|
return Meta(data)
|
||||||
|
except self.s3.exceptions.NoSuchKey:
|
||||||
|
return None
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
# boto3 often raises ClientError
|
||||||
|
code = getattr(e, "response", {}).get("Error", {}).get("Code", "")
|
||||||
|
if code in {"404", "NoSuchKey", "NotFound"}:
|
||||||
|
return None
|
||||||
|
raise StoreError(f"get_meta failed: {e}") from e
|
||||||
|
|
||||||
|
def put_meta(self, game_id: str, meta: Meta) -> None:
|
||||||
|
key = self._key("games", game_id, "meta.json")
|
||||||
|
try:
|
||||||
|
self.s3.put_object(
|
||||||
|
Bucket=self.bucket,
|
||||||
|
Key=key,
|
||||||
|
Body=meta.to_json(),
|
||||||
|
ContentType="application/json",
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
raise StoreError(f"put_meta failed: {e}") from e
|
||||||
|
|
||||||
|
def list_live(self, game_id: str) -> list[str]:
|
||||||
|
prefix = self._key("games", game_id, "live") + "/"
|
||||||
|
return self._list_rels(prefix)
|
||||||
|
|
||||||
|
def _list_rels(self, prefix: str) -> list[str]:
|
||||||
|
rels: list[str] = []
|
||||||
|
token = None
|
||||||
|
while True:
|
||||||
|
kw: dict[str, Any] = {"Bucket": self.bucket, "Prefix": prefix}
|
||||||
|
if token:
|
||||||
|
kw["ContinuationToken"] = token
|
||||||
|
resp = self.s3.list_objects_v2(**kw)
|
||||||
|
for item in resp.get("Contents") or []:
|
||||||
|
key = item["Key"]
|
||||||
|
if key.endswith("/"):
|
||||||
|
continue
|
||||||
|
rels.append(key[len(prefix) :])
|
||||||
|
if not resp.get("IsTruncated"):
|
||||||
|
break
|
||||||
|
token = resp.get("NextContinuationToken")
|
||||||
|
return rels
|
||||||
|
|
||||||
|
def download_live(self, game_id: str, dest_dir: Path) -> dict[str, str]:
|
||||||
|
from syncgames.hashutil import sha256_file
|
||||||
|
|
||||||
|
prefix = self._key("games", game_id, "live") + "/"
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
checksums: dict[str, str] = {}
|
||||||
|
for rel in self.list_live(game_id):
|
||||||
|
target = dest_dir / rel
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.s3.download_file(self.bucket, prefix + rel, str(target))
|
||||||
|
checksums[rel] = sha256_file(target)
|
||||||
|
return checksums
|
||||||
|
|
||||||
|
def upload_prefix(
|
||||||
|
self, game_id: str, kind: str, prefix_extra: str, local_dir: Path
|
||||||
|
) -> dict[str, str]:
|
||||||
|
from syncgames.hashutil import sha256_file
|
||||||
|
|
||||||
|
if kind == "live":
|
||||||
|
base = self._key("games", game_id, "live")
|
||||||
|
elif kind == "history":
|
||||||
|
base = self._key("games", game_id, "history", prefix_extra)
|
||||||
|
else:
|
||||||
|
raise StoreError(f"Unknown kind {kind}")
|
||||||
|
checksums: dict[str, str] = {}
|
||||||
|
for path in sorted(p for p in local_dir.rglob("*") if p.is_file()):
|
||||||
|
rel = path.relative_to(local_dir).as_posix()
|
||||||
|
key = f"{base}/{rel}"
|
||||||
|
self.s3.upload_file(str(path), self.bucket, key)
|
||||||
|
checksums[rel] = sha256_file(path)
|
||||||
|
return checksums
|
||||||
|
|
||||||
|
def delete_live_not_in(self, game_id: str, keep_rels: set[str]) -> None:
|
||||||
|
prefix = self._key("games", game_id, "live") + "/"
|
||||||
|
for rel in self.list_live(game_id):
|
||||||
|
if rel not in keep_rels:
|
||||||
|
self.s3.delete_object(Bucket=self.bucket, Key=prefix + rel)
|
||||||
|
|
||||||
|
def list_history(self, game_id: str) -> list[str]:
|
||||||
|
prefix = self._key("games", game_id, "history") + "/"
|
||||||
|
devices_ts: set[str] = set()
|
||||||
|
token = None
|
||||||
|
while True:
|
||||||
|
kw: dict[str, Any] = {"Bucket": self.bucket, "Prefix": prefix}
|
||||||
|
if token:
|
||||||
|
kw["ContinuationToken"] = token
|
||||||
|
resp = self.s3.list_objects_v2(**kw)
|
||||||
|
for item in resp.get("Contents") or []:
|
||||||
|
key = item["Key"][len(prefix) :]
|
||||||
|
parts = key.split("/")
|
||||||
|
if len(parts) >= 2:
|
||||||
|
devices_ts.add(f"{parts[0]}/{parts[1]}")
|
||||||
|
if not resp.get("IsTruncated"):
|
||||||
|
break
|
||||||
|
token = resp.get("NextContinuationToken")
|
||||||
|
return sorted(devices_ts, reverse=True)
|
||||||
|
|
||||||
|
def copy_history_to_live(self, game_id: str, device_ts: str) -> None:
|
||||||
|
src_prefix = self._key("games", game_id, "history", device_ts) + "/"
|
||||||
|
# Clear live then copy
|
||||||
|
for rel in self.list_live(game_id):
|
||||||
|
self.s3.delete_object(
|
||||||
|
Bucket=self.bucket, Key=self._key("games", game_id, "live", rel)
|
||||||
|
)
|
||||||
|
for rel in self._list_rels(src_prefix):
|
||||||
|
src = src_prefix + rel
|
||||||
|
dst = self._key("games", game_id, "live", rel)
|
||||||
|
self.s3.copy_object(
|
||||||
|
Bucket=self.bucket,
|
||||||
|
CopySource={"Bucket": self.bucket, "Key": src},
|
||||||
|
Key=dst,
|
||||||
|
)
|
||||||
|
|
||||||
|
def prune_device_history(self, game_id: str, device_id: str, keep: int) -> int:
|
||||||
|
prefix = self._key("games", game_id, "history", device_id) + "/"
|
||||||
|
stamps: set[str] = set()
|
||||||
|
for rel in self._list_rels(prefix):
|
||||||
|
stamps.add(rel.split("/", 1)[0])
|
||||||
|
ordered = sorted(stamps, reverse=True)
|
||||||
|
removed = 0
|
||||||
|
for stamp in ordered[keep:]:
|
||||||
|
for rel in self._list_rels(prefix + stamp + "/"):
|
||||||
|
self.s3.delete_object(Bucket=self.bucket, Key=prefix + stamp + "/" + rel)
|
||||||
|
removed += 1
|
||||||
|
return removed
|
||||||
|
|
||||||
|
def retire_game(self, game_id: str) -> str:
|
||||||
|
date = utc_now().strftime("%Y%m%d")
|
||||||
|
dest = f"retired/{game_id}-{date}"
|
||||||
|
src_prefix = self._key("games", game_id) + "/"
|
||||||
|
for rel in self._list_rels(src_prefix):
|
||||||
|
src = src_prefix + rel
|
||||||
|
self.s3.copy_object(
|
||||||
|
Bucket=self.bucket,
|
||||||
|
CopySource={"Bucket": self.bucket, "Key": src},
|
||||||
|
Key=f"{dest}/{rel}",
|
||||||
|
)
|
||||||
|
self.s3.delete_object(Bucket=self.bucket, Key=src)
|
||||||
|
# meta.json
|
||||||
|
meta_key = self._key("games", game_id, "meta.json")
|
||||||
|
try:
|
||||||
|
self.s3.copy_object(
|
||||||
|
Bucket=self.bucket,
|
||||||
|
CopySource={"Bucket": self.bucket, "Key": meta_key},
|
||||||
|
Key=f"{dest}/meta.json",
|
||||||
|
)
|
||||||
|
self.s3.delete_object(Bucket=self.bucket, Key=meta_key)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
return dest
|
||||||
|
|
||||||
|
def probe(self) -> str:
|
||||||
|
key = self._key("games", "_probe", f"ping-{iso_ts()}.txt")
|
||||||
|
body = b"syncgames-probe\n"
|
||||||
|
self.s3.put_object(Bucket=self.bucket, Key=key, Body=body)
|
||||||
|
got = self.s3.get_object(Bucket=self.bucket, Key=key)["Body"].read()
|
||||||
|
self.s3.delete_object(Bucket=self.bucket, Key=key)
|
||||||
|
if got != body:
|
||||||
|
raise StoreError("probe mismatch — possible NGINX buffering corruption")
|
||||||
|
return sha256_bytes(body)
|
||||||
|
|
||||||
|
def export_ssot(self, out_dir: Path) -> None:
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
token = None
|
||||||
|
while True:
|
||||||
|
kw: dict[str, Any] = {"Bucket": self.bucket, "Prefix": "games/"}
|
||||||
|
if token:
|
||||||
|
kw["ContinuationToken"] = token
|
||||||
|
resp = self.s3.list_objects_v2(**kw)
|
||||||
|
for item in resp.get("Contents") or []:
|
||||||
|
key = item["Key"]
|
||||||
|
target = out_dir / key
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.s3.download_file(self.bucket, key, str(target))
|
||||||
|
if not resp.get("IsTruncated"):
|
||||||
|
break
|
||||||
|
token = resp.get("NextContinuationToken")
|
||||||
|
|
||||||
|
|
||||||
|
class FilesystemStore(ObjectStore):
|
||||||
|
"""Path 1 fallback: SSOT as a directory tree."""
|
||||||
|
|
||||||
|
def __init__(self, root: Path) -> None:
|
||||||
|
if not root:
|
||||||
|
raise StoreError("ssot_root required for filesystem store", key="config_error")
|
||||||
|
self.root = root
|
||||||
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def _game(self, game_id: str) -> Path:
|
||||||
|
return self.root / "games" / game_id
|
||||||
|
|
||||||
|
def get_meta(self, game_id: str) -> Meta | None:
|
||||||
|
p = self._game(game_id) / "meta.json"
|
||||||
|
if not p.exists():
|
||||||
|
return None
|
||||||
|
return Meta(json.loads(p.read_text(encoding="utf-8")))
|
||||||
|
|
||||||
|
def put_meta(self, game_id: str, meta: Meta) -> None:
|
||||||
|
p = self._game(game_id) / "meta.json"
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
p.write_bytes(meta.to_json())
|
||||||
|
|
||||||
|
def list_live(self, game_id: str) -> list[str]:
|
||||||
|
live = self._game(game_id) / "live"
|
||||||
|
if not live.exists():
|
||||||
|
return []
|
||||||
|
return [p.relative_to(live).as_posix() for p in live.rglob("*") if p.is_file()]
|
||||||
|
|
||||||
|
def download_live(self, game_id: str, dest_dir: Path) -> dict[str, str]:
|
||||||
|
from syncgames.hashutil import sha256_file
|
||||||
|
|
||||||
|
live = self._game(game_id) / "live"
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
checksums: dict[str, str] = {}
|
||||||
|
if not live.exists():
|
||||||
|
return checksums
|
||||||
|
for path in live.rglob("*"):
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
rel = path.relative_to(live).as_posix()
|
||||||
|
target = dest_dir / rel
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(path, target)
|
||||||
|
checksums[rel] = sha256_file(target)
|
||||||
|
return checksums
|
||||||
|
|
||||||
|
def upload_prefix(
|
||||||
|
self, game_id: str, kind: str, prefix_extra: str, local_dir: Path
|
||||||
|
) -> dict[str, str]:
|
||||||
|
from syncgames.hashutil import sha256_file
|
||||||
|
|
||||||
|
if kind == "live":
|
||||||
|
dest = self._game(game_id) / "live"
|
||||||
|
else:
|
||||||
|
dest = self._game(game_id) / "history" / Path(prefix_extra)
|
||||||
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
checksums: dict[str, str] = {}
|
||||||
|
for path in local_dir.rglob("*"):
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
rel = path.relative_to(local_dir).as_posix()
|
||||||
|
target = dest / rel
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(path, target)
|
||||||
|
checksums[rel] = sha256_file(path)
|
||||||
|
return checksums
|
||||||
|
|
||||||
|
def delete_live_not_in(self, game_id: str, keep_rels: set[str]) -> None:
|
||||||
|
live = self._game(game_id) / "live"
|
||||||
|
if not live.exists():
|
||||||
|
return
|
||||||
|
for path in list(live.rglob("*")):
|
||||||
|
if path.is_file():
|
||||||
|
rel = path.relative_to(live).as_posix()
|
||||||
|
if rel not in keep_rels:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def list_history(self, game_id: str) -> list[str]:
|
||||||
|
hist = self._game(game_id) / "history"
|
||||||
|
if not hist.exists():
|
||||||
|
return []
|
||||||
|
out: list[str] = []
|
||||||
|
for device in hist.iterdir():
|
||||||
|
if not device.is_dir():
|
||||||
|
continue
|
||||||
|
for ts in device.iterdir():
|
||||||
|
if ts.is_dir():
|
||||||
|
out.append(f"{device.name}/{ts.name}")
|
||||||
|
return sorted(out, reverse=True)
|
||||||
|
|
||||||
|
def copy_history_to_live(self, game_id: str, device_ts: str) -> None:
|
||||||
|
src = self._game(game_id) / "history" / Path(device_ts)
|
||||||
|
live = self._game(game_id) / "live"
|
||||||
|
if live.exists():
|
||||||
|
shutil.rmtree(live)
|
||||||
|
shutil.copytree(src, live)
|
||||||
|
|
||||||
|
def prune_device_history(self, game_id: str, device_id: str, keep: int) -> int:
|
||||||
|
base = self._game(game_id) / "history" / device_id
|
||||||
|
if not base.exists():
|
||||||
|
return 0
|
||||||
|
stamps = sorted([p for p in base.iterdir() if p.is_dir()], key=lambda p: p.name, reverse=True)
|
||||||
|
removed = 0
|
||||||
|
for p in stamps[keep:]:
|
||||||
|
shutil.rmtree(p)
|
||||||
|
removed += 1
|
||||||
|
return removed
|
||||||
|
|
||||||
|
def retire_game(self, game_id: str) -> str:
|
||||||
|
date = utc_now().strftime("%Y%m%d")
|
||||||
|
dest = self.root / "retired" / f"{game_id}-{date}"
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
src = self._game(game_id)
|
||||||
|
if src.exists():
|
||||||
|
shutil.move(str(src), str(dest))
|
||||||
|
return str(dest.relative_to(self.root))
|
||||||
|
|
||||||
|
def probe(self) -> str:
|
||||||
|
p = self.root / "games" / "_probe" / "ping.txt"
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
body = b"syncgames-probe\n"
|
||||||
|
p.write_bytes(body)
|
||||||
|
got = p.read_bytes()
|
||||||
|
p.unlink()
|
||||||
|
if got != body:
|
||||||
|
raise StoreError("filesystem probe mismatch")
|
||||||
|
return sha256_bytes(body)
|
||||||
|
|
||||||
|
def export_ssot(self, out_dir: Path) -> None:
|
||||||
|
if out_dir.resolve() == self.root.resolve():
|
||||||
|
return
|
||||||
|
if out_dir.exists():
|
||||||
|
shutil.rmtree(out_dir)
|
||||||
|
shutil.copytree(self.root, out_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def build_store(agent) -> ObjectStore: # AgentConfig
|
||||||
|
if agent.store == "filesystem":
|
||||||
|
return FilesystemStore(agent.ssot_root) # type: ignore[arg-type]
|
||||||
|
return MinioStore(
|
||||||
|
endpoint_url=agent.endpoint_url,
|
||||||
|
bucket=agent.bucket,
|
||||||
|
access_key=agent.access_key,
|
||||||
|
secret_key=agent.secret_key,
|
||||||
|
region=agent.region,
|
||||||
|
path_style=agent.path_style,
|
||||||
|
)
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from syncgames.config import GameConfig
|
||||||
|
|
||||||
|
try:
|
||||||
|
import psutil
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
psutil = None # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(name: str) -> str:
|
||||||
|
return name.lower().removesuffix(".exe")
|
||||||
|
|
||||||
|
|
||||||
|
def is_game_running(game: GameConfig) -> bool:
|
||||||
|
if psutil is None:
|
||||||
|
return False
|
||||||
|
names = {_norm(n) for n in game.process_names}
|
||||||
|
# Steam AppID heuristic: look for steam apps running with appid in cmdline
|
||||||
|
appid = game.steam_appid
|
||||||
|
for proc in psutil.process_iter(["name", "cmdline"]):
|
||||||
|
try:
|
||||||
|
pname = _norm(proc.info["name"] or "")
|
||||||
|
if names and pname in names:
|
||||||
|
return True
|
||||||
|
if names and any(pname == _norm(n) for n in names):
|
||||||
|
return True
|
||||||
|
cmd = proc.info.get("cmdline") or []
|
||||||
|
joined = " ".join(cmd).lower()
|
||||||
|
if appid and (
|
||||||
|
f"appid={appid}" in joined
|
||||||
|
or f"steam_appid={appid}" in joined
|
||||||
|
or f"AppId={appid}" in joined
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
for n in game.process_names:
|
||||||
|
if n.lower() in joined:
|
||||||
|
return True
|
||||||
|
except (psutil.Error, TypeError):
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def watch_loop(
|
||||||
|
game: GameConfig,
|
||||||
|
on_start: Callable[[], None],
|
||||||
|
on_end: Callable[[], None],
|
||||||
|
*,
|
||||||
|
poll_s: float = 3.0,
|
||||||
|
) -> None:
|
||||||
|
"""Block forever: fire on_start when process appears, on_end when it exits."""
|
||||||
|
was_running = False
|
||||||
|
while True:
|
||||||
|
running = is_game_running(game)
|
||||||
|
if running and not was_running:
|
||||||
|
on_start()
|
||||||
|
elif not running and was_running:
|
||||||
|
# brief settle for flush
|
||||||
|
time.sleep(2.0)
|
||||||
|
on_end()
|
||||||
|
was_running = running
|
||||||
|
time.sleep(poll_s)
|
||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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.<your-domain>`
|
||||||
|
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.
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.SyncGames"
|
||||||
|
android:usesCleartextTraffic="false">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, String>): 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, String>,
|
||||||
|
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<String, String>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> {
|
||||||
|
val prefix = "games/$gameId/live/"
|
||||||
|
return listRels(prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun listRels(prefix: String): List<String> {
|
||||||
|
val out = mutableListOf<String>()
|
||||||
|
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<String, String> {
|
||||||
|
destDir.mkdirs()
|
||||||
|
val checksums = mutableMapOf<String, String>()
|
||||||
|
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<String, String> {
|
||||||
|
val base = when (kind) {
|
||||||
|
"live" -> "games/$gameId/live"
|
||||||
|
"history" -> "games/$gameId/history/$extra"
|
||||||
|
else -> error("bad kind")
|
||||||
|
}
|
||||||
|
val checksums = mutableMapOf<String, String>()
|
||||||
|
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<String>) {
|
||||||
|
for (rel in listLive(gameId)) {
|
||||||
|
if (rel !in keep) {
|
||||||
|
s3.deleteObject(DeleteObjectRequest(bucket, "games/$gameId/live/$rel"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun listHistory(gameId: String): List<String> {
|
||||||
|
val prefix = "games/$gameId/history/"
|
||||||
|
val set = linkedSetOf<String>()
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> = 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<String, String> {
|
||||||
|
val out = linkedMapOf<String, String>()
|
||||||
|
root.walkTopDown().filter { it.isFile }.forEach { f ->
|
||||||
|
out[f.relativeTo(root).invariantSeparatorsPath] = HashUtil.sha256File(f)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AppSettings> = _settings
|
||||||
|
private val _status = MutableStateFlow("")
|
||||||
|
val status: StateFlow<String> = _status
|
||||||
|
private val _history = MutableStateFlow<List<String>>(emptyList())
|
||||||
|
val history: StateFlow<List<String>> = _history
|
||||||
|
|
||||||
|
fun games(): List<GameUi> {
|
||||||
|
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<String?>(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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">SyncGames</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.SyncGames" parent="android:Theme.Material.Light.NoActionBar" />
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.7.2" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
+252
@@ -0,0 +1,252 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
|
||||||
|
' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
Vendored
+94
@@ -0,0 +1,94 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rootProject.name = "SyncGames"
|
||||||
|
include(":app")
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Copy to ~/.config/syncgames/agent.toml or SyncGames/config/agent.toml
|
||||||
|
|
||||||
|
device_id = "pc-desk"
|
||||||
|
store = "minio" # or "filesystem" for Path 1 fallback
|
||||||
|
|
||||||
|
# WAN HTTPS via Cloudflare → NGINX → MinIO (path-style)
|
||||||
|
endpoint_url = "https://syncgames-s3.example.com"
|
||||||
|
bucket = "syncgames"
|
||||||
|
region = "us-east-1"
|
||||||
|
path_style = true
|
||||||
|
access_key = "REPLACE_ME"
|
||||||
|
secret_key = "REPLACE_ME"
|
||||||
|
|
||||||
|
lease_ttl_hours = 6
|
||||||
|
|
||||||
|
# Path 1 fallback only:
|
||||||
|
# store = "filesystem"
|
||||||
|
# ssot_root = "/home/you/SyncGames-SSOT"
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Registry of known devices (documentation / optional tooling)
|
||||||
|
|
||||||
|
[[devices]]
|
||||||
|
id = "pc-desk"
|
||||||
|
os = "linux"
|
||||||
|
role = "desktop"
|
||||||
|
|
||||||
|
[[devices]]
|
||||||
|
id = "laptop"
|
||||||
|
os = "linux"
|
||||||
|
role = "laptop"
|
||||||
|
|
||||||
|
[[devices]]
|
||||||
|
id = "phone-android"
|
||||||
|
os = "android"
|
||||||
|
role = "phone"
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
id = "dark-souls-2-sotfs"
|
||||||
|
name = "Dark Souls II Scholar of the First Sin (Seamless Coop)"
|
||||||
|
platform = "steam"
|
||||||
|
versions_to_keep = 5
|
||||||
|
paths = [
|
||||||
|
"~/.steam/steam/steamapps/compatdata/335300/pfx/drive_c/users/steamuser/Application Data/DarkSoulsII",
|
||||||
|
]
|
||||||
|
steam_appid = 335300
|
||||||
|
process_names = [
|
||||||
|
"DarkSoulsII.exe",
|
||||||
|
]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
id = "dark-souls-3"
|
||||||
|
name = "Dark Souls III (Seamless Coop)"
|
||||||
|
platform = "steam"
|
||||||
|
versions_to_keep = 5
|
||||||
|
paths = [
|
||||||
|
"~/.steam/steam/steamapps/compatdata/374320/pfx/drive_c/users/steamuser/Application Data/DarkSoulsIII",
|
||||||
|
]
|
||||||
|
steam_appid = 374320
|
||||||
|
process_names = [
|
||||||
|
"DarkSoulsIII.exe",
|
||||||
|
]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
id = "dark-souls-remastered"
|
||||||
|
name = "Dark Souls Remastered (Seamless Coop)"
|
||||||
|
platform = "steam"
|
||||||
|
versions_to_keep = 5
|
||||||
|
# Adjust USERNAME / Steam library path on each device after install.
|
||||||
|
paths = [
|
||||||
|
"~/.steam/steam/steamapps/compatdata/570940/pfx/drive_c/users/steamuser/Documents/NBGI/DARK SOULS REMASTERED",
|
||||||
|
]
|
||||||
|
steam_appid = 570940
|
||||||
|
process_names = [
|
||||||
|
"DarkSoulsRemastered.exe",
|
||||||
|
]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
id = "eden-saves"
|
||||||
|
name = "Eden Emulator Saves"
|
||||||
|
platform = "eden"
|
||||||
|
versions_to_keep = 5
|
||||||
|
# Linux typical user dir — adjust; Android uses app SAF paths configured in the UI.
|
||||||
|
paths = [
|
||||||
|
"~/.local/share/eden/nand/user/save",
|
||||||
|
"~/.local/share/eden/sdmc",
|
||||||
|
]
|
||||||
|
process_names = [
|
||||||
|
"eden",
|
||||||
|
]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
id = "elden-ring-seamless"
|
||||||
|
name = "Elden Ring (Seamless Coop)"
|
||||||
|
platform = "steam"
|
||||||
|
versions_to_keep = 5
|
||||||
|
# Seamless Coop writes alongside vanilla saves under EldenRing/<SteamID>/
|
||||||
|
# Point at the SeamlessCoop subdirectory once present, or the parent character folder.
|
||||||
|
paths = [
|
||||||
|
"~/.steam/steam/steamapps/compatdata/1245620/pfx/drive_c/users/steamuser/AppData/Roaming/EldenRing",
|
||||||
|
]
|
||||||
|
steam_appid = 1245620
|
||||||
|
process_names = [
|
||||||
|
"eldenring.exe",
|
||||||
|
"start_protected_game.exe",
|
||||||
|
]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
id = "yuzu-saves"
|
||||||
|
name = "Yuzu Emulator Saves"
|
||||||
|
platform = "yuzu"
|
||||||
|
versions_to_keep = 5
|
||||||
|
paths = [
|
||||||
|
"~/.local/share/yuzu/nand/user/save",
|
||||||
|
"~/.local/share/yuzu/sdmc",
|
||||||
|
]
|
||||||
|
process_names = [
|
||||||
|
"yuzu",
|
||||||
|
]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# MinIO root (Console login uses THESE — exact match)
|
||||||
|
MINIO_ROOT_USER=syncgamesadmin
|
||||||
|
MINIO_ROOT_PASSWORD=change-me-root-password-32chars
|
||||||
|
|
||||||
|
# === Console WebUI (required for login from your PC) ===
|
||||||
|
# Replace 192.168.1.50 with your NAS LAN IP (same host you put in the browser).
|
||||||
|
# After up, logs MUST show this IP — NOT example.com and NOT min.hisora.dev.
|
||||||
|
MINIO_SERVER_URL=http://192.168.1.50:9000
|
||||||
|
MINIO_BROWSER_REDIRECT_URL=http://192.168.1.50:9001
|
||||||
|
|
||||||
|
# SyncGames agents use Cloudflare HTTPS in agent.toml (separate from above).
|
||||||
|
SYNCGAMES_BUCKET=syncgames
|
||||||
|
APP_ACCESS_KEY=syncgamesagent
|
||||||
|
APP_SECRET_KEY=change-me-agent-secret-32chars
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Docker Compose — SyncGames MinIO
|
||||||
|
|
||||||
|
Deploys the Path 3 SSOT store on your NAS. Keep using your existing NGINX + Cloudflare Tunnel in front.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd SyncGames/deploy/docker # or /volume1/docker/minio on Synology
|
||||||
|
cp .env.example .env
|
||||||
|
# edit .env — passwords, MINIO_SERVER_URL, APP_* keys
|
||||||
|
# IMPORTANT: save as UTF-8, Unix (LF) line endings, no BOM
|
||||||
|
|
||||||
|
docker compose up -d
|
||||||
|
docker compose --profile init run --rm createbuckets
|
||||||
|
```
|
||||||
|
|
||||||
|
### Synology / `.env` encoding errors (`\x00` in variable name)
|
||||||
|
|
||||||
|
Docker Compose only accepts **UTF-8** `.env` files. Editing in Windows Notepad, WordPad, or some Synology File Station flows saves **UTF-16**, which shows up as:
|
||||||
|
|
||||||
|
`unexpected character "\x00" in variable name`
|
||||||
|
|
||||||
|
**Fix on the NAS** (SSH):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /volume1/docker/minio
|
||||||
|
# inspect (lots of 00 = UTF-16)
|
||||||
|
od -An -tx1 .env | head
|
||||||
|
|
||||||
|
# recreate clean UTF-8 (overwrite after backing up your secrets)
|
||||||
|
mv .env .env.bak.utf16 2>/dev/null || true
|
||||||
|
cat > .env <<'EOF'
|
||||||
|
MINIO_ROOT_USER=syncgamesadmin
|
||||||
|
MINIO_ROOT_PASSWORD=change-me-root-password-32chars
|
||||||
|
MINIO_SERVER_URL=https://syncgames-s3.example.com
|
||||||
|
SYNCGAMES_BUCKET=syncgames
|
||||||
|
APP_ACCESS_KEY=syncgamesagent
|
||||||
|
APP_SECRET_KEY=change-me-agent-secret-32chars
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# or convert if the text is still readable:
|
||||||
|
# iconv -f UTF-16 -t UTF-8 .env.bak.utf16 | tr -d '\r' > .env
|
||||||
|
|
||||||
|
file .env # should say: UTF-8 text (or ASCII)
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Prefer editing `.env` over SSH (`nano`/`vi`) or an editor set to **UTF-8 / LF**. Avoid Notepad’s default Unicode save.
|
||||||
|
|
||||||
|
MinIO listens on **127.0.0.1:9000** (API) and **127.0.0.1:9001** (console).
|
||||||
|
|
||||||
|
## Wire to existing NGINX
|
||||||
|
|
||||||
|
Copy or include [`nginx-syncgames-s3.conf`](nginx-syncgames-s3.conf), set `server_name` to your Cloudflare hostname, reload NGINX.
|
||||||
|
|
||||||
|
Point Cloudflare Tunnel at that NGINX vhost (same pattern as your other services).
|
||||||
|
|
||||||
|
## Agent config
|
||||||
|
|
||||||
|
```toml
|
||||||
|
endpoint_url = "https://syncgames-s3.example.com"
|
||||||
|
bucket = "syncgames"
|
||||||
|
region = "us-east-1"
|
||||||
|
path_style = true
|
||||||
|
access_key = "<APP_ACCESS_KEY from .env>"
|
||||||
|
secret_key = "<APP_SECRET_KEY from .env>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run `syncgames doctor` (or Doctor in the AppImage GUI).
|
||||||
|
|
||||||
|
## Optional tunnel profile
|
||||||
|
|
||||||
|
Only if you do **not** already terminate tunnels on the NAS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# set CLOUDFLARE_TUNNEL_TOKEN in .env
|
||||||
|
docker compose --profile tunnel up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure the tunnel hostname to `http://127.0.0.1:80` (NGINX) ideally, or `http://127.0.0.1:9000` for direct MinIO (skips NGINX hardening — not preferred).
|
||||||
|
|
||||||
|
## Data
|
||||||
|
|
||||||
|
Volume: `syncgames_minio_data`. Console: http://127.0.0.1:9001 (or NAS LAN IP if you publish the port) with **root** user/password from `.env`.
|
||||||
|
|
||||||
|
### Console “unable to login due to network error”
|
||||||
|
|
||||||
|
MinIO Console runs in your **browser**. Login XHRs must reach the **API URL**, which must be a host your PC can open.
|
||||||
|
|
||||||
|
Your logs were advertising `API: https://syncgames-s3.example.com` / `https://min.hisora.dev` — the browser cannot complete Console login against those from `:9001`.
|
||||||
|
|
||||||
|
**Working LAN setup**
|
||||||
|
|
||||||
|
1. Copy updated `docker-compose.yml` to the NAS.
|
||||||
|
2. In `.env` set (use your real NAS IP):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MINIO_SERVER_URL=http://192.168.1.50:9000
|
||||||
|
MINIO_BROWSER_REDIRECT_URL=http://192.168.1.50:9001
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Recreate:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --force-recreate minio
|
||||||
|
docker compose logs minio | head -40
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Confirm logs show `API: http://192.168.1.50:9000` — **not** `example.com` or `min.hisora.dev`.
|
||||||
|
5. Open **exactly** `http://192.168.1.50:9001` (same IP).
|
||||||
|
6. Login as `MINIO_ROOT_USER` (exact spelling).
|
||||||
|
|
||||||
|
Cloudflare/`https://min.hisora.dev` is for SyncGames agents in `agent.toml` only — not for Console env vars.
|
||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Run this FROM YOUR PC (not inside the NAS container) while debugging Console login.
|
||||||
|
# Usage: ./check-console.sh 192.168.1.5
|
||||||
|
|
||||||
|
HOST="${1:-192.168.1.5}"
|
||||||
|
|
||||||
|
echo "==> API health http://${HOST}:9000/minio/health/live"
|
||||||
|
if curl -fsS --connect-timeout 3 "http://${HOST}:9000/minio/health/live"; then
|
||||||
|
echo
|
||||||
|
echo "OK — port 9000 reachable"
|
||||||
|
else
|
||||||
|
echo
|
||||||
|
echo "FAIL — browser Console cannot log in if API :9000 is blocked (Synology Firewall / port publish)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==> Console http://${HOST}:9001/"
|
||||||
|
if curl -fsS -o /dev/null --connect-timeout 3 "http://${HOST}:9001/"; then
|
||||||
|
echo "OK — port 9001 reachable"
|
||||||
|
else
|
||||||
|
echo "FAIL — cannot load WebUI"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Open EXACTLY: http://${HOST}:9001"
|
||||||
|
echo "Login as MINIO_ROOT_USER from .env (e.g. jorg) — not app access key."
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Synology / LAN WebUI fix — use host networking so Console+API bind on the NAS IP.
|
||||||
|
# Usage ON THE NAS:
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.host.yml up -d --force-recreate minio
|
||||||
|
#
|
||||||
|
# Then open exactly: http://192.168.1.5:9001
|
||||||
|
# (same IP as MINIO_SERVER_URL / MINIO_BROWSER_REDIRECT_URL in .env)
|
||||||
|
|
||||||
|
services:
|
||||||
|
minio:
|
||||||
|
network_mode: host
|
||||||
|
# host mode ignores "ports:" and "networks:" — bind on NAS interfaces directly
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
||||||
|
MINIO_SERVER_URL: ${MINIO_SERVER_URL}
|
||||||
|
MINIO_BROWSER_REDIRECT_URL: ${MINIO_BROWSER_REDIRECT_URL}
|
||||||
|
# keep command from base compose; host mode serves :9000 and :9001 on the NAS
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# SyncGames SSOT — MinIO
|
||||||
|
#
|
||||||
|
# Console login rule: the URL in the browser address bar and MINIO_*_URL
|
||||||
|
# must both be reachable FROM YOUR PC'S BROWSER (not only from Docker).
|
||||||
|
#
|
||||||
|
# cp .env.example .env # set passwords + NAS_LAN_IP
|
||||||
|
# docker compose up -d
|
||||||
|
# docker compose --profile init run --rm createbuckets
|
||||||
|
|
||||||
|
services:
|
||||||
|
minio:
|
||||||
|
image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z
|
||||||
|
container_name: syncgames-minio
|
||||||
|
restart: unless-stopped
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
||||||
|
# Must be URLs your BROWSER can open (NAS LAN IP or hostname).
|
||||||
|
# Do NOT set these to the Cloudflare public HTTPS hostname for Console use.
|
||||||
|
MINIO_SERVER_URL: ${MINIO_SERVER_URL}
|
||||||
|
MINIO_BROWSER_REDIRECT_URL: ${MINIO_BROWSER_REDIRECT_URL}
|
||||||
|
volumes:
|
||||||
|
- minio_data:/data
|
||||||
|
ports:
|
||||||
|
# 0.0.0.0 so PCs on LAN can reach API+Console (needed for WebUI login)
|
||||||
|
- "9000:9000"
|
||||||
|
- "9001:9001"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://127.0.0.1:9000/minio/health/live"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
networks:
|
||||||
|
- syncgames
|
||||||
|
|
||||||
|
createbuckets:
|
||||||
|
image: quay.io/minio/mc:RELEASE.2025-04-16T18-13-26Z
|
||||||
|
container_name: syncgames-mc-init
|
||||||
|
profiles: ["init"]
|
||||||
|
depends_on:
|
||||||
|
- minio
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
||||||
|
SYNCGAMES_BUCKET: ${SYNCGAMES_BUCKET:-syncgames}
|
||||||
|
APP_ACCESS_KEY: ${APP_ACCESS_KEY}
|
||||||
|
APP_SECRET_KEY: ${APP_SECRET_KEY}
|
||||||
|
entrypoint:
|
||||||
|
- /bin/sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
set -e
|
||||||
|
echo "Waiting for MinIO..."
|
||||||
|
i=0
|
||||||
|
until mc alias set local http://minio:9000 "$$MINIO_ROOT_USER" "$$MINIO_ROOT_PASSWORD" 2>/dev/null; do
|
||||||
|
i=$$((i+1))
|
||||||
|
if [ "$$i" -gt 30 ]; then echo "MinIO not ready"; exit 1; fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
mc mb --ignore-existing "local/$${SYNCGAMES_BUCKET}"
|
||||||
|
mc anonymous set none "local/$${SYNCGAMES_BUCKET}"
|
||||||
|
# App user for agents — fail loudly if key cannot be created
|
||||||
|
if ! mc admin user info local "$$APP_ACCESS_KEY" >/dev/null 2>&1; then
|
||||||
|
mc admin user add local "$$APP_ACCESS_KEY" "$$APP_SECRET_KEY"
|
||||||
|
else
|
||||||
|
mc admin user add local "$$APP_ACCESS_KEY" "$$APP_SECRET_KEY" 2>/dev/null || true
|
||||||
|
# update secret if user exists (MinIO: remove+readd or policy only)
|
||||||
|
echo "User $$APP_ACCESS_KEY already exists"
|
||||||
|
fi
|
||||||
|
mc admin policy attach local readwrite --user "$$APP_ACCESS_KEY"
|
||||||
|
mc admin user info local "$$APP_ACCESS_KEY"
|
||||||
|
echo "Bucket $${SYNCGAMES_BUCKET} ready"
|
||||||
|
mc ls local
|
||||||
|
|
||||||
|
cloudflared:
|
||||||
|
image: cloudflare/cloudflared:latest
|
||||||
|
container_name: syncgames-cloudflared
|
||||||
|
profiles: ["tunnel"]
|
||||||
|
restart: unless-stopped
|
||||||
|
command: tunnel --no-autoupdate run
|
||||||
|
environment:
|
||||||
|
TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN}
|
||||||
|
network_mode: host
|
||||||
|
|
||||||
|
networks:
|
||||||
|
syncgames:
|
||||||
|
name: syncgames
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
minio_data:
|
||||||
|
name: syncgames_minio_data
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# SyncGames MinIO — reverse proxy snippet for an EXISTING host NGINX.
|
||||||
|
# Include from your main nginx.conf or drop into conf.d/.
|
||||||
|
#
|
||||||
|
# Cloudflare Tunnel should target this host's NGINX (port 80 / 443), not MinIO directly.
|
||||||
|
# Docker Compose binds MinIO to 127.0.0.1:9000 on the NAS.
|
||||||
|
|
||||||
|
upstream syncgames_minio {
|
||||||
|
server 127.0.0.1:9000;
|
||||||
|
keepalive 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name syncgames-s3.example.com; # <-- your Cloudflare hostname
|
||||||
|
|
||||||
|
client_max_body_size 512m;
|
||||||
|
ignore_invalid_headers off;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://syncgames_minio;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
|
||||||
|
# Critical for SigV4 — do not rewrite Host to localhost
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
proxy_set_header Authorization $http_authorization;
|
||||||
|
proxy_set_header Content-Type $content_type;
|
||||||
|
proxy_set_header Content-Length $content_length;
|
||||||
|
proxy_set_header Expect $http_expect;
|
||||||
|
|
||||||
|
proxy_request_buffering off;
|
||||||
|
proxy_buffering off;
|
||||||
|
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# SyncGames S3 API — NGINX example
|
||||||
|
# Replace syncgames-s3.example.com and upstream as needed.
|
||||||
|
# Place behind Cloudflare Tunnel; do NOT put auth_basic on this vhost.
|
||||||
|
|
||||||
|
upstream syncgames_minio {
|
||||||
|
server 127.0.0.1:9000;
|
||||||
|
keepalive 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name syncgames-s3.example.com;
|
||||||
|
|
||||||
|
# Emulator dumps / large co-op packs
|
||||||
|
client_max_body_size 512m;
|
||||||
|
|
||||||
|
# S3 clients send unusual headers
|
||||||
|
ignore_invalid_headers off;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://syncgames_minio;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
|
||||||
|
# Critical for SigV4 — do not rewrite to localhost
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
proxy_set_header Authorization $http_authorization;
|
||||||
|
proxy_set_header Content-Type $content_type;
|
||||||
|
proxy_set_header Content-Length $content_length;
|
||||||
|
proxy_set_header Expect $http_expect;
|
||||||
|
|
||||||
|
# Avoid truncated PUTs
|
||||||
|
proxy_request_buffering off;
|
||||||
|
proxy_buffering off;
|
||||||
|
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Add / remove games
|
||||||
|
|
||||||
|
## Add
|
||||||
|
|
||||||
|
```bash
|
||||||
|
syncgames add \
|
||||||
|
--name "Elden Ring (Seamless)" \
|
||||||
|
--platform steam \
|
||||||
|
--paths "/absolute/or/~/path/to/save/dir_or_file" \
|
||||||
|
--versions 5 \
|
||||||
|
--steam-appid 1245620 \
|
||||||
|
--process-name eldenring.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
Effects:
|
||||||
|
|
||||||
|
1. Writes `config/games/<slug>.toml` (or `~/.config/syncgames/games/` depending on config root).
|
||||||
|
2. Ensures remote prefixes `games/<slug>/live/` and empty `meta.json` if absent (does not overwrite existing live).
|
||||||
|
3. Does **not** upload local saves until first `end` after a `start` (or explicit first push with force after careful review).
|
||||||
|
|
||||||
|
Multiple `--paths` allowed. Globs are expanded at session time.
|
||||||
|
|
||||||
|
### Platforms
|
||||||
|
|
||||||
|
| Value | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| `steam` | Steam / Proton; optional `steam_appid` for watchers |
|
||||||
|
| `eden` | Eden emulator saves |
|
||||||
|
| `yuzu` | Yuzu / Yuzu-fork saves |
|
||||||
|
| `other` | Manual paths only |
|
||||||
|
|
||||||
|
## Session loop
|
||||||
|
|
||||||
|
```bash
|
||||||
|
syncgames start <game-id>
|
||||||
|
# launch game, play, quit
|
||||||
|
syncgames end <game-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
With systemd watchers enabled, start/end can run automatically when the process appears/exits.
|
||||||
|
|
||||||
|
## Remove
|
||||||
|
|
||||||
|
```bash
|
||||||
|
syncgames remove <game-id>
|
||||||
|
# optional: syncgames remove <game-id> --purge-remote
|
||||||
|
```
|
||||||
|
|
||||||
|
Default:
|
||||||
|
|
||||||
|
1. Disable watchers for that game.
|
||||||
|
2. Move remote `games/<id>/` → `retired/<id>-<YYYYMMDD>/`.
|
||||||
|
3. Rename local TOML to `*.removed` or delete per flag.
|
||||||
|
4. Clear local session state.
|
||||||
|
|
||||||
|
`--purge-remote` permanently deletes remote prefixes (dangerous; requires typing game id again interactively when TTY).
|
||||||
|
|
||||||
|
## Templates
|
||||||
|
|
||||||
|
See [templates/game.toml](../templates/game.toml) and seeded examples under `config/games/`.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Sync gameplay saves across Linux desktop, Linux laptop, and Android phone/tablet without Steam Cloud (missing Seamless Coop) and without continuous bidirectional sync of live save files.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ ┌─────────────┐ ┌──────────────────┐
|
||||||
|
│ Linux PC │ │ Laptop │ │ Android app │
|
||||||
|
│ syncgames │ │ syncgames │ │ Compose UI │
|
||||||
|
│ Python CLI │ │ Python CLI │ │ S3 SDK │
|
||||||
|
└──────┬──────┘ └──────┬──────┘ └────────┬─────────┘
|
||||||
|
│ HTTPS WAN │ │
|
||||||
|
└────────────────┼──────────────────┘
|
||||||
|
▼
|
||||||
|
Cloudflare DNS + Tunnel
|
||||||
|
(+ optional Access)
|
||||||
|
▼
|
||||||
|
NGINX (NAS)
|
||||||
|
▼
|
||||||
|
MinIO bucket: syncgames
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
1. **Idle** — no lease; `live/` is SSOT.
|
||||||
|
2. **Start** — device acquires lease in `meta.json`, downloads `live/` into native save path(s), stores parent hash locally.
|
||||||
|
3. **Play** — only native files change; SSOT untouched.
|
||||||
|
4. **End** — snapshot native files → `history/<device>/<ts>/` → atomically update `live/` + `meta.json` → release lease → prune old history for that device.
|
||||||
|
|
||||||
|
## Trust boundaries
|
||||||
|
|
||||||
|
- MinIO credentials authenticate API calls.
|
||||||
|
- Optional Cloudflare Access gates the hostname before MinIO sees traffic.
|
||||||
|
- Lease + hash gate prevent two devices from inventing conflicting “live” states without an explicit restore.
|
||||||
|
|
||||||
|
## What is not synced
|
||||||
|
|
||||||
|
- Entire Proton prefixes
|
||||||
|
- Game installs / shaders
|
||||||
|
- Continuous file watchers writing straight into SSOT
|
||||||
|
|
||||||
|
## Local state (per device)
|
||||||
|
|
||||||
|
Under `~/.local/state/syncgames/` (Linux) or app private storage (Android):
|
||||||
|
|
||||||
|
- `sessions/<game-id>.json` — active lease parent hash, started_at
|
||||||
|
- `wip/<game-id>/` — optional staging copies during push
|
||||||
|
|
||||||
|
See [protocol.md](protocol.md) for exact wire format.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Path 1 fallback — Hardened Syncthing
|
||||||
|
|
||||||
|
Use this if Path 3 (MinIO over Cloudflare/NGINX) becomes unreliable or Android S3 access is blocked more often than Syncthing-Fork would be.
|
||||||
|
|
||||||
|
## Triggers
|
||||||
|
|
||||||
|
- WAN MinIO downtime / CF limits / NGINX upload bugs that block play sessions
|
||||||
|
- Android app cannot obtain durable storage access for emulator saves and Syncthing folder sync of the **SSOT tree** is acceptable
|
||||||
|
- You still refuse to sync live game directories directly
|
||||||
|
|
||||||
|
## What stays the same
|
||||||
|
|
||||||
|
Keep the Python session agent (`start`/`end`). It continues to copy:
|
||||||
|
|
||||||
|
`native save paths` ↔ local SSOT tree
|
||||||
|
|
||||||
|
Only the transport of the SSOT tree changes (Syncthing instead of MinIO).
|
||||||
|
|
||||||
|
## Migration MinIO → Syncthing SSOT folder
|
||||||
|
|
||||||
|
1. On a trusted machine with MinIO access:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
syncgames export-ssot --out ~/SyncGames-SSOT
|
||||||
|
```
|
||||||
|
|
||||||
|
(Downloads all `games/*/live`, `history`, `meta.json` into a filesystem mirror.)
|
||||||
|
|
||||||
|
2. Share `~/SyncGames-SSOT` with Syncthing across devices.
|
||||||
|
3. Enable **Simple File Versioning**, Keep Versions ≥ 5, on every device.
|
||||||
|
4. Point `agent.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
store = "filesystem"
|
||||||
|
ssot_root = "/home/you/SyncGames-SSOT"
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Stop relying on MinIO for that install (or keep MinIO as cold backup via periodic `export-ssot`).
|
||||||
|
|
||||||
|
## Hardening rules (do not violate)
|
||||||
|
|
||||||
|
- Syncthing folder = SSOT tree **only**
|
||||||
|
- Never add Proton `compatdata` / Android emulator internal dirs as Syncthing folders
|
||||||
|
- Lease/hash gates remain enforced by the agent against filesystem `meta.json`
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Point `store` back to `minio` and keep using the same game ids if the bucket is still intact.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Linux GUI + AppImage
|
||||||
|
|
||||||
|
The SyncGames AppImage launches a thin PySide6 manager for initial setup and day-to-day session ops.
|
||||||
|
|
||||||
|
## Tabs
|
||||||
|
|
||||||
|
| Tab | Actions |
|
||||||
|
|-----|---------|
|
||||||
|
| Setup | Endpoint, keys, device id, save `agent.toml`, Doctor probe, seed example games, install systemd units |
|
||||||
|
| Games | List / add / retire games (same TOML as CLI) |
|
||||||
|
| Session | Start / End / Status / History / Restore (YES confirm) |
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd agent
|
||||||
|
./packaging/build-appimage.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires network once to fetch `appimagetool` if missing.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Credentials and config are written to `~/.config/syncgames/agent.toml` (mode 0600), not into the AppImage.
|
||||||
|
- Watcher automation still uses `systemctl --user enable --now syncgames-watch@<game-id>.service` after units are installed.
|
||||||
|
- CLI remains available via `pip install -e .` (`syncgames …`) alongside the GUI.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# MinIO + NGINX + Cloudflare
|
||||||
|
|
||||||
|
WAN path for SyncGames SSOT. Laptop and phone use this HTTPS endpoint only (no LAN/VPN requirement).
|
||||||
|
|
||||||
|
## Topology
|
||||||
|
|
||||||
|
```
|
||||||
|
Clients → https://syncgames-s3.<your-domain>
|
||||||
|
→ Cloudflare DNS (+ optional Access)
|
||||||
|
→ cloudflared Tunnel
|
||||||
|
→ NGINX on NAS
|
||||||
|
→ MinIO S3 API :9000
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep MinIO Console (often `:9001`) on a separate vhost or LAN-only. SyncGames clients need the **S3 API** only.
|
||||||
|
|
||||||
|
## MinIO
|
||||||
|
|
||||||
|
Prefer Docker on the NAS: [deploy/docker/](../deploy/docker/) (`docker compose up -d`).
|
||||||
|
|
||||||
|
Manual checklist:
|
||||||
|
|
||||||
|
1. Bind API to `127.0.0.1:9000` (Compose does this).
|
||||||
|
2. Create bucket `syncgames` (private) — `docker compose --profile init run --rm createbuckets`.
|
||||||
|
3. Create an access key / secret for SyncGames devices (rotate periodically).
|
||||||
|
4. Set `MINIO_SERVER_URL=https://syncgames-s3.<your-domain>` so redirects/presigns use the public name if you use them (SyncGames prefers path-style without relying on redirects).
|
||||||
|
|
||||||
|
## NGINX (anti-grief checklist)
|
||||||
|
|
||||||
|
Example vhost: [deploy/nginx/syncgames-s3.conf](../deploy/nginx/syncgames-s3.conf)
|
||||||
|
|
||||||
|
Required behaviors:
|
||||||
|
|
||||||
|
| Setting | Why |
|
||||||
|
|---------|-----|
|
||||||
|
| Preserve `Host` as public hostname | SigV4 signing |
|
||||||
|
| `proxy_request_buffering off` | Avoid truncated large PUTs |
|
||||||
|
| `client_max_body_size 512m;` (or higher) | Emulator dumps |
|
||||||
|
| `proxy_http_version 1.1` | Keepalive / chunked |
|
||||||
|
| `ignore_invalid_headers off` | S3 headers with underscores |
|
||||||
|
| Forward `Authorization`, `X-Amz-*`, `Content-Length`, `X-Forwarded-Proto` | API correctness |
|
||||||
|
| **No** `auth_basic` | Breaks AWS SDKs — use Cloudflare Access instead |
|
||||||
|
|
||||||
|
## Cloudflare
|
||||||
|
|
||||||
|
1. Create Tunnel hostname `syncgames-s3.<domain>` → `http://127.0.0.1:80` (or whatever port NGINX listens on for that vhost).
|
||||||
|
2. Prefer Cloudflare Access (email OTP / your IdP) for defense-in-depth.
|
||||||
|
3. Orange-cloud proxy is fine for typical Souls/ER save sizes. If a huge upload fails (~100MB free-plan body limits can apply depending on product), check Cloudflare docs / plan or temporarily use a larger object split strategy.
|
||||||
|
|
||||||
|
## Client configuration
|
||||||
|
|
||||||
|
In `agent.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
endpoint_url = "https://syncgames-s3.example.com"
|
||||||
|
bucket = "syncgames"
|
||||||
|
region = "us-east-1"
|
||||||
|
path_style = true
|
||||||
|
access_key = "..."
|
||||||
|
secret_key = "..."
|
||||||
|
```
|
||||||
|
|
||||||
|
Android settings screen uses the same values.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
syncgames doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
Performs HEAD/list on the bucket and a tiny PUT/GET/DELETE probe object under `games/_probe/`. Failures usually mean Host/header/buffering misconfig on NGINX or Access blocking SDK traffic (use a Service Token or bypass path carefully for API clients if Access challenges browsers only — for programmatic S3, Access service tokens or skip Access on the API hostname and rely on MinIO keys + Cloudflare WAF IP restrictions).
|
||||||
|
|
||||||
|
### Cloudflare Access vs S3 SDKs
|
||||||
|
|
||||||
|
Browser Access login does **not** work for boto3/Android AWS SDK. Pick one:
|
||||||
|
|
||||||
|
1. **Access Service Auth** (service token headers) injected by agents — advanced, document if you enable it; or
|
||||||
|
2. **No Access on the S3 API hostname**, protect with strong MinIO keys + tunnel (not publicly documented); or
|
||||||
|
3. Separate hostname for human console only behind Access.
|
||||||
|
|
||||||
|
Recommended v1: tunnel + strong MinIO keys; optional IP allowlist / WAF; Access only on MinIO **Console** vhost if exposed.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# SyncGames planning archive
|
||||||
|
|
||||||
|
Frozen planning artifacts for posterity. Living operational docs live one level up in `docs/`.
|
||||||
|
|
||||||
|
| File | Contents |
|
||||||
|
|------|----------|
|
||||||
|
| [01-path-drafts.md](01-path-drafts.md) | Original three-path comparison (Syncthing / Gitea / MinIO) |
|
||||||
|
| [02-path3-implementation.md](02-path3-implementation.md) | Canonical Path 3 implementation plan (human-readable) |
|
||||||
|
| [syncgames_path_drafts_2b07b18f.plan.md](syncgames_path_drafts_2b07b18f.plan.md) | Cursor plan export (primary) |
|
||||||
|
| [syncgames_path_drafts_efddc87b.plan.md](syncgames_path_drafts_efddc87b.plan.md) | Earlier Cursor plan snapshot |
|
||||||
|
|
||||||
|
**Chosen path:** Path 3 (MinIO + session agents). Path 1 (hardened Syncthing) is the named fallback.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Path drafts — three architectures
|
||||||
|
|
||||||
|
Original comparison before Path 3 was selected.
|
||||||
|
|
||||||
|
## Non-negotiable session model
|
||||||
|
|
||||||
|
Every viable design shares the same state machine. Continuous bidirectional sync of **live** save directories is the antipattern that caused prior LOPEs.
|
||||||
|
|
||||||
|
- Pull only at session start; push only at session end
|
||||||
|
- One lease per game
|
||||||
|
- Hash gate rejects stale WIP unless explicit force-restore
|
||||||
|
- Per-device history (3–5 versions); single live SSOT slot
|
||||||
|
- Never delete live without first moving current into history
|
||||||
|
|
||||||
|
### Conceptual layout
|
||||||
|
|
||||||
|
```
|
||||||
|
games/<game-id>/
|
||||||
|
live/ # SSOT current slot
|
||||||
|
history/<device>/ # last N snapshots per device
|
||||||
|
meta.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Path 1 — Hardened Syncthing + Session Orchestrator
|
||||||
|
|
||||||
|
Syncthing syncs only the **SSOT tree**, never native game save dirs. A local agent copies native ↔ WIP ↔ SSOT around sessions.
|
||||||
|
|
||||||
|
**Pros:** Fast LAN; mature Android Syncthing client.
|
||||||
|
**Cons:** Discipline required; peer conflict resolution if lease fails.
|
||||||
|
**Status:** Named **fallback** if MinIO/WAN path becomes blocking.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Path 2 — Custom Agent + Gitea (Git LFS)
|
||||||
|
|
||||||
|
Gitea as SSOT; saves as LFS or release assets; agent pull/push around sessions.
|
||||||
|
|
||||||
|
**Pros:** Auditable history in web UI.
|
||||||
|
**Cons:** Git/LFS friction for binaries.
|
||||||
|
**Status:** Shelved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Path 3 — Custom Agent + MinIO (selected)
|
||||||
|
|
||||||
|
SSOT is versioned object storage on NAS MinIO. Agent implements pull-live / push-live / history / restore. Exposed via Cloudflare → NGINX → MinIO for WAN.
|
||||||
|
|
||||||
|
**Pros:** Cleanest binary SSOT model; no Git merge footguns; easy prune.
|
||||||
|
**Cons:** Own the agent end-to-end; need reachable HTTPS API.
|
||||||
|
**Status:** **Building this.**
|
||||||
|
|
||||||
|
### Comparison snapshot
|
||||||
|
|
||||||
|
| Tenet | Path 1 | Path 2 | Path 3 |
|
||||||
|
|-------|--------|--------|--------|
|
||||||
|
| Safety / direction | High if SSOT-only | High | Highest |
|
||||||
|
| Automation | Excellent Linux | Good | Excellent |
|
||||||
|
| Easy add/remove | CLI + Syncthing API | CLI + repo | CLI + key prefixes |
|
||||||
|
| LOPE recovery | history + .stversions | git/local history | object history |
|
||||||
|
| Android | Syncthing-Fork | Custom app | Custom Compose app |
|
||||||
|
| Past LOPE risk | Medium if live dirs synced | Low | Low |
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Path 3 implementation plan (canonical copy)
|
||||||
|
|
||||||
|
This is the human-readable archive of the Path 3 plan. The Cursor export snapshot lives beside this file.
|
||||||
|
|
||||||
|
## Locked decisions
|
||||||
|
|
||||||
|
| Decision | Choice |
|
||||||
|
|----------|--------|
|
||||||
|
| Primary | Path 3 — MinIO SSOT + session agents |
|
||||||
|
| Linux agent | Python CLI/daemon |
|
||||||
|
| Android | Kotlin + Jetpack Compose light UI |
|
||||||
|
| Remote access | WAN via Cloudflare (paid domain); no VPN |
|
||||||
|
| Ingress | Cloudflare → Tunnel → NGINX → MinIO |
|
||||||
|
| Fallback | Path 1 (Syncthing on SSOT tree only) |
|
||||||
|
| Planning | `docs/planning/` |
|
||||||
|
|
||||||
|
## Session ops
|
||||||
|
|
||||||
|
1. `start_session`: acquire lease → download `live/` → install to native paths → record WIP parent hash
|
||||||
|
2. `end_session`: ensure game idle → copy native → WIP → upload history → promote `live/` → update meta → release lease → prune
|
||||||
|
3. `restore`: copy chosen history prefix → `live/` (explicit)
|
||||||
|
4. `add` / `remove`: TOML + bucket prefixes / archive to `retired/`
|
||||||
|
|
||||||
|
## Object keys
|
||||||
|
|
||||||
|
```
|
||||||
|
games/<id>/live/<relative-save-path>
|
||||||
|
games/<id>/history/<device>/<iso-ts>/<relative-save-path>
|
||||||
|
games/<id>/meta.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
```
|
||||||
|
syncgames add|remove|start|end|status|history|restore|doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
## NGINX note
|
||||||
|
|
||||||
|
Treat MinIO as an S3 API vhost: preserve Host for SigV4, disable request buffering, large `client_max_body_size`, no `auth_basic` on S3 (use Cloudflare Access + MinIO keys). See `docs/nas-minio-cloudflare.md`.
|
||||||
|
|
||||||
|
## Out of scope (v1)
|
||||||
|
|
||||||
|
Path 2 Gitea, Windows-native agent, automatic binary merge, continuous sync of live game dirs.
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
---
|
||||||
|
name: SyncGames Path Drafts
|
||||||
|
overview: Build SyncGames on Path 3 (NAS MinIO + Python agent on Linux, light Android UI app). WAN access via Cloudflare Tunnel to paid domain. Path 1 is fallback. Planning markdown archived in-repo for posterity.
|
||||||
|
todos:
|
||||||
|
- id: scaffold-repo
|
||||||
|
content: Create SyncGames/ skeleton including docs/planning/ archive of all plan markdowns
|
||||||
|
status: in_progress
|
||||||
|
- id: archive-plans
|
||||||
|
content: Copy Cursor plan + path drafts into SyncGames/docs/planning/ for posterity
|
||||||
|
status: pending
|
||||||
|
- id: nas-minio-cloudflare
|
||||||
|
content: Document MinIO on NAS behind existing NGINX + Cloudflare Tunnel hostname, TLS, Access/auth, S3 path-style, nginx anti-grief settings
|
||||||
|
status: pending
|
||||||
|
- id: agent-core
|
||||||
|
content: Implement Python CLI/daemon - lease, pull-live, push-live, list-history, restore, add/remove game
|
||||||
|
status: pending
|
||||||
|
- id: safety-gates
|
||||||
|
content: Hash gate, atomic promote, version prune (N=3-5), refuse mid-write / mid-lease operations
|
||||||
|
status: pending
|
||||||
|
- id: linux-automation
|
||||||
|
content: systemd user units + process/Steam AppID watchers for auto start/end session (WAN endpoint same as laptop)
|
||||||
|
status: pending
|
||||||
|
- id: android-ui
|
||||||
|
content: Light Kotlin/Compose Android app - Start/End/History/Restore/Status talking MinIO over Cloudflare HTTPS
|
||||||
|
status: pending
|
||||||
|
- id: protocol-spec
|
||||||
|
content: Written protocol doc so Android and Python stay in lockstep on meta.json, leases, keys
|
||||||
|
status: pending
|
||||||
|
- id: path1-fallback-notes
|
||||||
|
content: Document Path 1 fallback trigger criteria and migration steps from MinIO SSOT tree
|
||||||
|
status: pending
|
||||||
|
- id: first-games
|
||||||
|
content: Seed configs for DS1/2/3, Elden Ring Seamless, Eden/Yuzu platform profiles
|
||||||
|
status: pending
|
||||||
|
isProject: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# SyncGames — Path 3 Implementation Plan
|
||||||
|
|
||||||
|
## Locked decisions
|
||||||
|
|
||||||
|
| Decision | Choice |
|
||||||
|
|----------|--------|
|
||||||
|
| Primary architecture | **Path 3** — MinIO SSOT on NAS + session agents |
|
||||||
|
| Linux agent | **Python** CLI/daemon |
|
||||||
|
| Android client | **Light UI app** (Kotlin + Jetpack Compose) — not Termux as primary |
|
||||||
|
| Remote access | **WAN via Cloudflare** (paid domain) — not LAN-only, not VPN |
|
||||||
|
| Fallback | **Path 1** — Hardened Syncthing on SSOT tree only |
|
||||||
|
| Planning artifacts | **Kept in-repo** under `SyncGames/docs/planning/` |
|
||||||
|
|
||||||
|
Path 2 (Gitea) remains shelved unless explicitly reopened.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Planning docs for posterity
|
||||||
|
|
||||||
|
On scaffold, archive all planning markdown into the repo so it survives outside Cursor’s plan UI:
|
||||||
|
|
||||||
|
```
|
||||||
|
SyncGames/docs/planning/
|
||||||
|
00-README.md # index of planning docs
|
||||||
|
01-path-drafts.md # original 3-path comparison
|
||||||
|
02-path3-implementation.md # this implementation plan (canonical copy)
|
||||||
|
syncgames_path_drafts_2b07b18f.plan.md # Cursor plan export snapshot
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the archive whenever the plan materializes major revisions. Operational docs (`architecture.md`, `nas-minio.md`, etc.) stay separate under `docs/` as living guides.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session model (unchanged)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> Idle
|
||||||
|
Idle --> Pulling: start_session
|
||||||
|
Pulling --> Ready: SSOT_copied_to_WIP
|
||||||
|
Ready --> Playing: game_running
|
||||||
|
Playing --> Pushing: end_session
|
||||||
|
Pushing --> Idle: SSOT_updated_and_versioned
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Pull** only at session start; **push** only at session end
|
||||||
|
- One **lease** per game; second device blocked until release/expiry
|
||||||
|
- **Hash gate**: push rejected if WIP parent ≠ current live unless force-restore
|
||||||
|
- Per-device **history** keeps last N snapshots (default 5); live is a single slot
|
||||||
|
- Never sync the game’s native save directory continuously
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Path 3 architecture (WAN)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph clients [Clients_on_WAN]
|
||||||
|
PC[Linux_PC_Python]
|
||||||
|
Laptop[Laptop_Python]
|
||||||
|
Phone[Android_Compose_app]
|
||||||
|
end
|
||||||
|
subgraph cf [Cloudflare]
|
||||||
|
DNS[Paid_domain_DNS]
|
||||||
|
Tunnel[Cloudflare_Tunnel]
|
||||||
|
Access[Optional_CF_Access]
|
||||||
|
end
|
||||||
|
subgraph nas [NAS]
|
||||||
|
Nginx[NGINX_reverse_proxy]
|
||||||
|
MinIO[MinIO_S3_API]
|
||||||
|
end
|
||||||
|
PC --> DNS
|
||||||
|
Laptop --> DNS
|
||||||
|
Phone --> DNS
|
||||||
|
DNS --> Access
|
||||||
|
Access --> Tunnel
|
||||||
|
Tunnel --> Nginx
|
||||||
|
Nginx --> MinIO
|
||||||
|
```
|
||||||
|
|
||||||
|
All devices (desktop, laptop, phone) use the **same public HTTPS endpoint**, e.g. `https://syncgames-s3.example.com`. No LAN assumption, no VPN requirement.
|
||||||
|
|
||||||
|
**Ingress chain (matches your NAS pattern):** Cloudflare → Tunnel → **existing NGINX** → MinIO. NGINX stays the single reverse-proxy front door for all services; we do not bypass it.
|
||||||
|
|
||||||
|
### NGINX + MinIO — will it cause grief?
|
||||||
|
|
||||||
|
**Verdict:** No, if we treat MinIO as an S3 API vhost (not a generic web app) and apply the known proxy checklist. Most LOPE-adjacent failures from reverse proxies are **truncated uploads** or **broken SigV4 signatures** — both avoidable.
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| SigV4 `Host` mismatch | Public hostname must be what clients sign; NGINX must forward that Host (or MinIO must be configured for the same domain). Do not rewrite Host to `127.0.0.1`. |
|
||||||
|
| Truncated / buffered PUTs | `proxy_request_buffering off`; `client_max_body_size` large enough (e.g. 512m+); `proxy_http_version 1.1` |
|
||||||
|
| Expect: 100-continue quirks | `proxy_set_header Expect $http_expect;` or strip carefully per MinIO docs — test with a real multipart upload |
|
||||||
|
| Underscore / odd S3 headers | `ignore_invalid_headers off` on the MinIO server block |
|
||||||
|
| Chunked encoding / redirect loops | Prefer path-style addressing to one API hostname; avoid redirecting API → console |
|
||||||
|
| Console vs API confusion | Expose **S3 API only** on `syncgames-s3.<domain>`; keep MinIO Console on a separate optional vhost or LAN-only |
|
||||||
|
| Cloudflare body limits | Same as before — game saves are usually fine; document if huge dumps fail |
|
||||||
|
| Double TLS / WebSockets | Console needs Upgrade headers; **API path used by SyncGames does not need WebSockets** |
|
||||||
|
|
||||||
|
**Recommended NGINX shape (to ship in `docs/nas-minio-cloudflare.md`):** dedicated `server_name syncgames-s3.<domain>`; `location /` → `http://127.0.0.1:9000` (MinIO API); forwarding `Authorization`, `X-Amz-*`, `Content-Type`, `Content-Length`, `X-Forwarded-Proto https`; no `proxy_buffering` on uploads; no auth_basic in front of S3 (breaks SDK) — use Cloudflare Access and/or MinIO keys instead.
|
||||||
|
|
||||||
|
Agents point at `https://syncgames-s3.<domain>` with path-style S3. `syncgames doctor` will include a PUT/GET round-trip probe to catch NGINX misconfig early.
|
||||||
|
|
||||||
|
### Cloudflare exposure (chosen approach)
|
||||||
|
|
||||||
|
1. Run **MinIO** on NAS private bind (e.g. localhost:9000)
|
||||||
|
2. Add **NGINX** vhost for the SyncGames S3 hostname (same pattern as your other services)
|
||||||
|
3. Point **cloudflared** at NGINX (or at the existing tunnel ingress that already hits NGINX), not directly at MinIO, so all services stay consistent
|
||||||
|
4. Map `syncgames-s3.<domain>` on the paid domain
|
||||||
|
5. Prefer **Cloudflare Access** in front (defense-in-depth); MinIO access key / secret still required
|
||||||
|
6. Document path-style addressing for boto3 + Android AWS SDK
|
||||||
|
7. Note CF upload size limits; fallback guidance if an emulator dump exceeds them
|
||||||
|
|
||||||
|
Laptop and phone never need to be on home LAN.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Object key layout
|
||||||
|
|
||||||
|
```
|
||||||
|
games/<game-id>/live/<relative-save-path>
|
||||||
|
games/<game-id>/history/<device-id>/<iso-ts>/<relative-save-path>
|
||||||
|
games/<game-id>/meta.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`meta.json` fields (minimum): schema version, live checksums, parent hash, lease holder device id, lease expires_at, versions_to_keep, updated_at.
|
||||||
|
|
||||||
|
### Ops
|
||||||
|
|
||||||
|
1. **start_session**: acquire lease → download `live/` → install to native save path(s) → record WIP parent hash locally
|
||||||
|
2. **end_session**: wait until game not writing → copy native → WIP → upload history prefix → promote to `live/` → update `meta.json` → release lease → prune old history beyond N
|
||||||
|
3. **restore**: user picks history object prefix → copies to `live/` (explicit only)
|
||||||
|
4. **add / remove game**: declarative TOML + bucket prefix init / archive-to-`retired/`
|
||||||
|
|
||||||
|
### Shared protocol spec
|
||||||
|
|
||||||
|
`docs/protocol.md` is the source of truth for key layout, lease rules, and hash gates so **Python and Android stay compatible** without sharing a runtime.
|
||||||
|
|
||||||
|
### Agent CLI surface (Linux)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
syncgames add --name "..." --platform steam --paths "..." --versions 5
|
||||||
|
syncgames remove <game-id>
|
||||||
|
syncgames start <game-id>
|
||||||
|
syncgames end <game-id>
|
||||||
|
syncgames status [<game-id>]
|
||||||
|
syncgames history <game-id>
|
||||||
|
syncgames restore <game-id> --from <device>/<ts>
|
||||||
|
syncgames doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repo skeleton
|
||||||
|
|
||||||
|
```
|
||||||
|
SyncGames/
|
||||||
|
README.md
|
||||||
|
docs/
|
||||||
|
planning/ # posterity: path drafts + plan snapshots
|
||||||
|
architecture.md
|
||||||
|
protocol.md
|
||||||
|
add-game.md
|
||||||
|
nas-minio-cloudflare.md
|
||||||
|
fallback-path1.md
|
||||||
|
config/
|
||||||
|
devices.toml
|
||||||
|
agent.toml # Cloudflare HTTPS endpoint, bucket, device-id
|
||||||
|
games/*.toml
|
||||||
|
templates/game.toml
|
||||||
|
agent/ # Python package (Linux PC + laptop)
|
||||||
|
android/ # Kotlin/Compose light UI app
|
||||||
|
systemd/
|
||||||
|
syncgames-agent.service
|
||||||
|
[email protected]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Android light UI — yes, in scope for v1
|
||||||
|
|
||||||
|
**Answer:** Yes. Implement Android as a **light dedicated UI app**, not Termux-first.
|
||||||
|
|
||||||
|
**Stack:** Kotlin + Jetpack Compose + AWS S3 / MinIO-compatible SDK over HTTPS to the Cloudflare hostname.
|
||||||
|
|
||||||
|
**Screens (minimal):**
|
||||||
|
|
||||||
|
- Game list + lease/status badge
|
||||||
|
- **Start session** (pull + lease + write into configured save dirs)
|
||||||
|
- **End session** (push + history + release)
|
||||||
|
- **History** + **Restore** (explicit confirm)
|
||||||
|
- Settings: endpoint URL, keys (prefer Android Keystore), device id, game path pickers via Storage Access Framework
|
||||||
|
|
||||||
|
**Deployability:** sideload APK or simple GitHub/Gitea release; configure once with Cloudflare URL + credentials; play flow is button → play → button.
|
||||||
|
|
||||||
|
**Why not Termux as primary:** harder to hand to “just use it” and weaker UX for safeties (confirm restore, show lease holder). Termux may remain a documented escape hatch only.
|
||||||
|
|
||||||
|
Linux still owns `add`/`remove` game and systemd automation; Android focuses on session buttons + restore for emulator titles on phone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Linux automation
|
||||||
|
|
||||||
|
- systemd user service runs the Python agent
|
||||||
|
- Per-game watcher: Steam AppID and/or process name from game TOML
|
||||||
|
- `start` / `end` on process lifecycle
|
||||||
|
- Endpoint in `agent.toml` is always the Cloudflare WAN URL (same as laptop/phone) for consistent behavior away from home
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Path 1 fallback triggers
|
||||||
|
|
||||||
|
- Cloudflare/MinIO WAN path too unreliable or blocked by CF limits
|
||||||
|
- Android app file-access friction exceeds Syncthing-Fork convenience **and** you accept SSOT-dir-only Syncthing rules
|
||||||
|
|
||||||
|
**Migration:** export MinIO `live/` + `history/` to a folder tree; Syncthing that tree only; keep Python session agent for native ↔ SSOT copies.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Seed game configs (after agents work)
|
||||||
|
|
||||||
|
- Dark Souls 1 / 2 / 3 — Steam / Proton + Seamless Coop paths
|
||||||
|
- Elden Ring — Seamless Coop paths
|
||||||
|
- Eden / Yuzu — Android + Linux emulator save profiles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of scope for v1
|
||||||
|
|
||||||
|
- Path 2 Gitea
|
||||||
|
- Windows-native agent
|
||||||
|
- Automatic binary merge
|
||||||
|
- Continuous sync of live game directories
|
||||||
|
- VPN / LAN-only client modes as a requirement (WAN Cloudflare is the path)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Paths 1–2 for posterity
|
||||||
|
|
||||||
|
Full Path 1 and Path 2 writeups are archived under `docs/planning/01-path-drafts.md` when the repo is scaffolded — not only in chat history.
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
---
|
||||||
|
name: SyncGames Path Drafts
|
||||||
|
overview: Greenfield SyncGames in a new `SyncGames/` directory. Below are three deployable architectures that all enforce the same session model (pull SSOT → play WIP → push SSOT) while differing in transport/storage. Pick one path before implementation.
|
||||||
|
todos: []
|
||||||
|
isProject: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# SyncGames — Three Architecture Paths
|
||||||
|
|
||||||
|
## Non-negotiable session model (all paths)
|
||||||
|
|
||||||
|
Every viable design shares the same state machine. Continuous bidirectional sync of **live** save directories is what caused your prior LOPE; none of these paths do that.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> Idle
|
||||||
|
Idle --> Pulling: start_session
|
||||||
|
Pulling --> Ready: SSOT_copied_to_WIP
|
||||||
|
Ready --> Playing: game_running
|
||||||
|
Playing --> Pushing: end_session
|
||||||
|
Pushing --> Idle: SSOT_updated_and_versioned
|
||||||
|
|
||||||
|
note right of Pulling
|
||||||
|
Refuse start if another device
|
||||||
|
holds lease or SSOT is dirty
|
||||||
|
end note
|
||||||
|
note right of Pushing
|
||||||
|
Snapshot WIP into device history
|
||||||
|
then promote to live SSOT slot
|
||||||
|
end note
|
||||||
|
```
|
||||||
|
|
||||||
|
**Shared layout (conceptual):**
|
||||||
|
|
||||||
|
```
|
||||||
|
SyncGames/
|
||||||
|
games/<game-id>/
|
||||||
|
live/ # SSOT current slot (one winner)
|
||||||
|
history/
|
||||||
|
<device-id>/ # last N snapshots per device (default 5)
|
||||||
|
meta.json # hash, mtime, device, session lease, schema
|
||||||
|
config/
|
||||||
|
games/*.toml # declarative game defs
|
||||||
|
devices.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
**Shared guardrails:**
|
||||||
|
|
||||||
|
| Rule | Behavior |
|
||||||
|
|------|----------|
|
||||||
|
| Direction | Pull only at session start; push only at session end |
|
||||||
|
| Lease | One active session per game; second device blocked until lease expires/release |
|
||||||
|
| Hash gate | Reject push if local WIP hash != expected parent (stale WIP) unless explicit `--force-restore` |
|
||||||
|
| Version retention | Keep N snapshots per device (configurable 3–5); prune oldest after successful new snapshot |
|
||||||
|
| Never delete live | Promote via atomic rename/copy; old live moves into history first |
|
||||||
|
| Game offline | Automation watches process exit / Steam AppID, never mid-write |
|
||||||
|
|
||||||
|
**Add-game workflow (identical UX across paths):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
syncgames add \
|
||||||
|
--name "Elden Ring (Seamless)" \
|
||||||
|
--platform steam \
|
||||||
|
--paths "~/.steam/.../SeamlessCoop/*.co2" \
|
||||||
|
--versions 5
|
||||||
|
```
|
||||||
|
|
||||||
|
**Remove-game:** archive `games/<id>/` to `retired/<id>-<date>/`, stop watchers, revoke folder/remote access.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Path 1 — Hardened Syncthing + Session Orchestrator
|
||||||
|
|
||||||
|
**Idea:** Syncthing only syncs the **SSOT tree** (`live/` + `history/` + `meta`), never the game’s native save directory. A local agent copies between native save path ↔ WIP ↔ SSOT around sessions.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
NativeSave[Game_native_save_path]
|
||||||
|
WIP[Local_WIP_staging]
|
||||||
|
SSOT[SSOT_folder_Syncthing]
|
||||||
|
OtherDev[Other_devices]
|
||||||
|
|
||||||
|
NativeSave -->|"end_session copy"| WIP
|
||||||
|
WIP -->|"snapshot + promote"| SSOT
|
||||||
|
SSOT <-->|"Syncthing send-receive on SSOT only"| OtherDev
|
||||||
|
SSOT -->|"start_session copy"| WIP
|
||||||
|
WIP -->|"start_session install"| NativeSave
|
||||||
|
```
|
||||||
|
|
||||||
|
**Syncthing config hardening:**
|
||||||
|
|
||||||
|
- Folder type: normal send-receive **only** on `SyncGames/games/` (or per-game subfolders)
|
||||||
|
- Enable **Simple File Versioning** (Keep Versions ≥ 5) as a second safety net behind custom history
|
||||||
|
- `.stignore` excludes `*.tmp`, lock files, WIP staging outside the synced tree
|
||||||
|
- Do **not** sync Proton/prefix save dirs directly
|
||||||
|
- Optional always-on home server / NAS as Receive-favoring mirror with staggered versioning
|
||||||
|
|
||||||
|
**Automation:** Linux systemd user units + process watchers (Steam AppID / executable). Android: Syncthing-Fork for SSOT folder + Termux/foreground service or a thin companion app for “Start session / End session” when full auto is harder.
|
||||||
|
|
||||||
|
| Pros | Cons |
|
||||||
|
|------|------|
|
||||||
|
| Fast LAN sync you already know | Must never point Syncthing at live saves (discipline + tooling) |
|
||||||
|
| Mature Android client for files | Peer conflict resolution still exists if lease protocol fails |
|
||||||
|
| Low custom infra | Orchestrator is still custom software |
|
||||||
|
|
||||||
|
**Best if:** You want maximum automation across Linux + Android with minimal self-hosted backend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Path 2 — Custom Agent + Gitea (Git LFS / raw releases)
|
||||||
|
|
||||||
|
**Idea:** Your Gitea instance is SSOT. Each game is a small repo (or one monorepo with per-game dirs). Saves live as LFS objects or release assets. Agent does pull/checkout → WIP, and commit+push / release upload on end.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Agent[syncgames_agent]
|
||||||
|
WIP[Local_WIP]
|
||||||
|
Gitea[Gitea_repo_LFS]
|
||||||
|
Hist[Device_history_local]
|
||||||
|
|
||||||
|
Agent -->|"git pull / download live tag"| WIP
|
||||||
|
WIP -->|"play"| Game[Game]
|
||||||
|
Game --> WIP
|
||||||
|
WIP -->|"snapshot"| Hist
|
||||||
|
Agent -->|"commit live + push"| Gitea
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hardening vs naïve git:**
|
||||||
|
|
||||||
|
- Treat `live/` as a single tracked blob set; never merge binary conflicts — lease + parent-hash check abort the push
|
||||||
|
- Store history as `history/<device>/<timestamp>/` commits **or** local-only tarballs with only `live/` pushed (cleaner; history survives offline)
|
||||||
|
- Prefer **Git LFS** for binary saves; optional signed tags `live/<game-id>` as the authoritative pointer
|
||||||
|
- Manual “Sync Now” buttons remain available; automation calls the same API
|
||||||
|
|
||||||
|
**Android:** Kotlin/Compose or Flutter thin client using Gitea HTTP API + LFS (avoid full git on phone if painful).
|
||||||
|
|
||||||
|
| Pros | Cons |
|
||||||
|
|------|------|
|
||||||
|
| Explicit, auditable history in Gitea UI | Git/LFS friction for large/frequently-changing binaries |
|
||||||
|
| Works over WAN without Syncthing mesh | Needs always-reachable Gitea |
|
||||||
|
| Matches “button press = state change” mental model | Automated push still needs the same lease logic you fear forgetting |
|
||||||
|
|
||||||
|
**Best if:** You already run Gitea and want human-readable restore via web UI more than raw speed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Path 3 — Custom Agent + Object Store SSOT (MinIO / S3 / rclone remote)
|
||||||
|
|
||||||
|
**Idea:** Skip git entirely. SSOT is versioned object storage you control (self-hosted MinIO on your LAN/homelab, or any S3-compatible bucket). Agent implements `pull-live`, `push-live`, `list-history`, `restore` against object keys.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph devices [Devices]
|
||||||
|
PC[Linux_PC_agent]
|
||||||
|
Laptop[Laptop_agent]
|
||||||
|
Phone[Android_agent]
|
||||||
|
end
|
||||||
|
subgraph ssot [SSOT_MinIO]
|
||||||
|
LiveKey["games/er/live/*"]
|
||||||
|
HistKeys["games/er/history/device/ts/*"]
|
||||||
|
Meta["games/er/meta.json + lease"]
|
||||||
|
end
|
||||||
|
PC --> ssot
|
||||||
|
Laptop --> ssot
|
||||||
|
Phone --> ssot
|
||||||
|
```
|
||||||
|
|
||||||
|
**Object layout:**
|
||||||
|
|
||||||
|
```
|
||||||
|
games/<id>/live/<relative-save-path>
|
||||||
|
games/<id>/history/<device>/<iso-ts>/<relative-save-path>
|
||||||
|
games/<id>/meta.json # checksums, parent, lease holder, expires_at
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ops model:**
|
||||||
|
|
||||||
|
- Push: upload WIP → new history prefix → checksum → atomic update of `live/` + `meta.json` (lease required)
|
||||||
|
- Pull: verify lease available → download `live/` → install into native path → mark WIP parent hash
|
||||||
|
- Restore: copy chosen history prefix → `live/` (explicit user action)
|
||||||
|
- Transport: MinIO SDK, or `rclone` wrapped so you can swap backends later
|
||||||
|
|
||||||
|
**Automation same as Path 1** (session hooks); storage is just S3 semantics instead of Syncthing/Git.
|
||||||
|
|
||||||
|
| Pros | Cons |
|
||||||
|
|------|------|
|
||||||
|
| Cleanest model for binary SSOT + versioned keys | Needs MinIO (or cloud S3) reachable from phone |
|
||||||
|
| No git binary-conflict footguns | You own the agent end-to-end |
|
||||||
|
| Easy prune (delete old prefixes) | Slightly more DIY than Syncthing for LAN presence |
|
||||||
|
|
||||||
|
**Best if:** You want the strongest conceptual match to “one live slot + per-device history” without Syncthing’s merge semantics or Git’s binary awkwardness.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comparison against your tenets
|
||||||
|
|
||||||
|
| Tenet | Path 1 Syncthing+Agent | Path 2 Gitea+Agent | Path 3 MinIO+Agent |
|
||||||
|
|------|------------------------|--------------------|--------------------|
|
||||||
|
| Safety / controlled direction | High if SSOT-only synced | High (hash+lease) | Highest (explicit keys+lease) |
|
||||||
|
| Automation | Excellent on Linux; good on Android | Good; WAN-friendly | Excellent; WAN-friendly |
|
||||||
|
| Easy add/remove game | CLI generates folder + Syncthing API | CLI creates repo/paths | CLI creates key prefixes |
|
||||||
|
| LOPE recovery | device history + `.stversions` | git history / local history | object history prefixes |
|
||||||
|
| Android friction | Lowest (Syncthing exists) | Medium (custom app) | Medium (custom app / rclone) |
|
||||||
|
| Your past LOPE risk | Medium — only if someone resyncs live dirs | Low | Low |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended default (if you want a pick)
|
||||||
|
|
||||||
|
**Path 3 (MinIO + session agent)** as the core SSOT design — it maps 1:1 to live slot + per-device history, avoids Syncthing merge and Git LFS pain, and still supports full automation via process hooks.
|
||||||
|
|
||||||
|
**Optional hybrid later:** Path 1’s Syncthing can mirror a MinIO bucket backup, or Path 3’s `history/` can be additionally restic-backed for offsite retention. That is additive hardening, not required for v1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Proposed SyncGames repo skeleton (whichever path)
|
||||||
|
|
||||||
|
```
|
||||||
|
SyncGames/
|
||||||
|
README.md
|
||||||
|
docs/architecture.md
|
||||||
|
docs/add-game.md
|
||||||
|
config/games/ # declarative TOML per title
|
||||||
|
config/devices.toml
|
||||||
|
agent/ # Python or Go CLI + daemon
|
||||||
|
templates/game.toml
|
||||||
|
systemd/ # user units for watchers
|
||||||
|
```
|
||||||
|
|
||||||
|
Initial titles in config templates: Dark Souls 1/2/3, Elden Ring (Seamless Coop paths), Eden/Yuzu NAND/save dirs as separate platform profiles.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision needed before implementation
|
||||||
|
|
||||||
|
Reply with which path to build first (**1**, **2**, or **3**). Also confirm:
|
||||||
|
|
||||||
|
1. Is a always-on homelab box available for MinIO/Gitea/Syncthing hub?
|
||||||
|
2. Primary phone OS for sync — Android only?
|
||||||
|
3. Prefer agent language: **Python** (fast to ship) or **Go** (single static binary)?
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# SyncGames protocol (Python ↔ Android)
|
||||||
|
|
||||||
|
Schema version: **1**
|
||||||
|
|
||||||
|
Both clients MUST implement these rules identically. Diverging behavior is a LOPE risk.
|
||||||
|
|
||||||
|
## Object key layout
|
||||||
|
|
||||||
|
Bucket: configured (default `syncgames`).
|
||||||
|
|
||||||
|
| Key | Purpose |
|
||||||
|
|-----|---------|
|
||||||
|
| `games/<game-id>/meta.json` | Lease, checksums, retention |
|
||||||
|
| `games/<game-id>/live/<rel>` | Current SSOT save file(s) |
|
||||||
|
| `games/<game-id>/history/<device-id>/<iso-ts>/<rel>` | Immutable snapshot |
|
||||||
|
| `retired/<game-id>-<date>/…` | Soft-deleted games |
|
||||||
|
|
||||||
|
- `<game-id>`: lowercase slug, `[a-z0-9-]+`
|
||||||
|
- `<device-id>`: stable per install (e.g. `pc-desk`, `phone-pixel`)
|
||||||
|
- `<iso-ts>`: UTC `YYYYMMDDTHHMMSSZ`
|
||||||
|
- `<rel>`: relative path using `/`, no `..` segments
|
||||||
|
|
||||||
|
## meta.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema": 1,
|
||||||
|
"game_id": "elden-ring-seamless",
|
||||||
|
"live_hash": "sha256:…",
|
||||||
|
"file_checksums": {
|
||||||
|
"ER0000.co2": "sha256:…"
|
||||||
|
},
|
||||||
|
"lease": {
|
||||||
|
"holder": "pc-desk",
|
||||||
|
"expires_at": "2026-07-14T03:00:00Z",
|
||||||
|
"session_id": "uuid"
|
||||||
|
},
|
||||||
|
"versions_to_keep": 5,
|
||||||
|
"updated_at": "2026-07-13T22:00:00Z",
|
||||||
|
"updated_by": "pc-desk"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `live_hash`: SHA-256 of the sorted concatenation of `path\\0hexdigest\\n` for every live object (canonical tree hash).
|
||||||
|
- `lease` may be `null` when idle.
|
||||||
|
- Clients MUST treat unknown JSON fields as forward-compatible (ignore).
|
||||||
|
|
||||||
|
## Lease rules
|
||||||
|
|
||||||
|
1. **Acquire** before mutating native installs from SSOT (start session).
|
||||||
|
2. Acquire succeeds if `lease` is null/expired OR `holder` equals this device.
|
||||||
|
3. Default TTL: **6 hours** (refreshable by same holder via start again).
|
||||||
|
4. Another device MUST refuse start while lease is valid for a different holder.
|
||||||
|
5. **Release** on successful end session (set `lease` to null). Crash: wait for expiry or operator clears via `doctor --break-lease` (destructive admin).
|
||||||
|
|
||||||
|
## start_session
|
||||||
|
|
||||||
|
1. Load `meta.json` (if missing, treat as empty live + null lease).
|
||||||
|
2. Acquire lease (conditional overwrite: read-modify-write; if lost race, abort).
|
||||||
|
3. Download all `games/<id>/live/*` objects.
|
||||||
|
4. Install into configured native path(s) (create parents; overwrite existing).
|
||||||
|
5. Persist local session state: `{ parent_live_hash, session_id, started_at }`.
|
||||||
|
|
||||||
|
## end_session
|
||||||
|
|
||||||
|
1. Require local session state for game.
|
||||||
|
2. Refuse if configured process/AppID still running (Linux); Android warns if user confirms force.
|
||||||
|
3. Compute WIP tree hash from native files.
|
||||||
|
4. **Hash gate:** if `parent_live_hash != meta.live_hash` and not `force`, **abort** (stale WIP).
|
||||||
|
5. Upload snapshot to `history/<device>/<ts>/…`.
|
||||||
|
6. Upload/replace all `live/…` objects to match WIP (delete remote live keys no longer present).
|
||||||
|
7. Update `meta.json`: new hashes, `lease=null`, `updated_by=device`.
|
||||||
|
8. Prune: list `history/<device>/`; keep newest `versions_to_keep`; delete older prefixes.
|
||||||
|
9. Clear local session state.
|
||||||
|
|
||||||
|
## restore
|
||||||
|
|
||||||
|
1. User selects `device/ts` history prefix explicitly.
|
||||||
|
2. Acquire lease (or require idle + force).
|
||||||
|
3. Copy history objects → `live/`.
|
||||||
|
4. Update `meta.json` hashes; leave lease held by restoring device until they end or release.
|
||||||
|
5. Never auto-restore.
|
||||||
|
|
||||||
|
## Checksum algorithm
|
||||||
|
|
||||||
|
- Per file: SHA-256 of raw bytes, encoded `sha256:<hex>`.
|
||||||
|
- Tree hash: sort relative paths lexicographically (UTF-8), for each path append `path + "\\0" + hex_digest + "\\n"`, then SHA-256 that byte string, encoded `sha256:<hex>`.
|
||||||
|
|
||||||
|
## S3 addressing
|
||||||
|
|
||||||
|
- Path-style: `https://syncgames-s3.example.com/syncgames/games/...`
|
||||||
|
- Region can be `us-east-1` dummy; path-style + custom endpoint required.
|
||||||
|
- TLS required on WAN.
|
||||||
|
|
||||||
|
## Error codes (CLI / UI mapping)
|
||||||
|
|
||||||
|
| Condition | Message key |
|
||||||
|
|-----------|-------------|
|
||||||
|
| Lease held by other | `lease_held` |
|
||||||
|
| Hash gate fail | `stale_wip` |
|
||||||
|
| Network / S3 | `store_error` |
|
||||||
|
| Game still running | `game_running` |
|
||||||
|
| Missing config | `config_error` |
|
||||||
Executable
+101
@@ -0,0 +1,101 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Create a Gitea release and upload AppImage/APK assets.
|
||||||
|
# Requires: GITEA_TOKEN, curl, python3
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
OWNER="${GITEA_OWNER:-Dawnsorrow}"
|
||||||
|
REPO="${GITEA_REPO_OVERRIDE:-SyncGames}"
|
||||||
|
BASE="${GITEA_BASE_URL:-https://git.hisora.dev}"
|
||||||
|
BASE="${BASE%/}"
|
||||||
|
VERSION="$(python3 -c "import tomllib; print(tomllib.load(open('${ROOT}/agent/pyproject.toml','rb'))['project']['version'])")"
|
||||||
|
TAG="v${VERSION}"
|
||||||
|
TITLE="SyncGames ${TAG}"
|
||||||
|
|
||||||
|
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||||||
|
echo "Set GITEA_TOKEN (Gitea personal access token with repo write)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
APPIMAGE="${1:-${ROOT}/dist/SyncGames-${VERSION}-x86_64.AppImage}"
|
||||||
|
APK="${2:-${ROOT}/dist/SyncGames-${VERSION}-debug.apk}"
|
||||||
|
|
||||||
|
if [[ ! -f "$APPIMAGE" ]]; then
|
||||||
|
echo "Missing AppImage: $APPIMAGE" >&2
|
||||||
|
echo "Build first: (cd agent && ./packaging/build-appimage.sh) && cp agent/dist/*.AppImage dist/" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
API="${BASE}/api/v1/repos/${OWNER}/${REPO}"
|
||||||
|
|
||||||
|
NOTE=$(cat <<EOF
|
||||||
|
## SyncGames ${TAG}
|
||||||
|
|
||||||
|
Linux AppImage for session-gated save sync.
|
||||||
|
|
||||||
|
### Install
|
||||||
|
\`\`\`bash
|
||||||
|
chmod +x SyncGames-${VERSION}-x86_64.AppImage
|
||||||
|
./SyncGames-${VERSION}-x86_64.AppImage
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Configure MinIO endpoint under **Setup**, edit game paths under **Games**, then **Start** / **End** sessions.
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
CREATE_BODY=$(TAG="$TAG" TITLE="$TITLE" NOTE="$NOTE" python3 - <<'PY'
|
||||||
|
import json, os
|
||||||
|
print(json.dumps({
|
||||||
|
"tag_name": os.environ["TAG"],
|
||||||
|
"target_commitish": "main",
|
||||||
|
"name": os.environ["TITLE"],
|
||||||
|
"body": os.environ["NOTE"],
|
||||||
|
"draft": False,
|
||||||
|
"prerelease": False,
|
||||||
|
}))
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "Creating release ${TAG} on ${OWNER}/${REPO} ..."
|
||||||
|
HTTP=$(curl -sS -o /tmp/sg-release.json -w "%{http_code}" \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-X POST "${API}/releases" \
|
||||||
|
-d "$CREATE_BODY")
|
||||||
|
|
||||||
|
if [[ "$HTTP" == "409" ]] || [[ "$HTTP" == "400" ]]; then
|
||||||
|
echo "Release may already exist (HTTP $HTTP); fetching..."
|
||||||
|
curl -sS \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"${API}/releases/tags/${TAG}" -o /tmp/sg-release.json
|
||||||
|
elif [[ "$HTTP" != "200" && "$HTTP" != "201" ]]; then
|
||||||
|
echo "Failed to create release: HTTP $HTTP" >&2
|
||||||
|
cat /tmp/sg-release.json >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
RELEASE_ID=$(python3 -c 'import json; print(json.load(open("/tmp/sg-release.json"))["id"])')
|
||||||
|
echo "Release id=${RELEASE_ID}"
|
||||||
|
|
||||||
|
upload() {
|
||||||
|
local file="$1"
|
||||||
|
local name
|
||||||
|
name="$(basename "$file")"
|
||||||
|
echo "Uploading ${name} ..."
|
||||||
|
curl -sS -f \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @"${file}" \
|
||||||
|
"${API}/releases/${RELEASE_ID}/assets?name=${name}" \
|
||||||
|
-o /tmp/sg-asset.json
|
||||||
|
echo " → $(python3 -c 'import json; d=json.load(open("/tmp/sg-asset.json")); print(d.get("browser_download_url") or d.get("name"))')"
|
||||||
|
}
|
||||||
|
|
||||||
|
upload "$APPIMAGE"
|
||||||
|
if [[ -f "$APK" ]]; then
|
||||||
|
upload "$APK"
|
||||||
|
else
|
||||||
|
echo "Skipping APK (not found at $APK)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Done: ${BASE}/${OWNER}/${REPO}/releases/tag/${TAG}"
|
||||||
Executable
+11
@@ -0,0 +1,11 @@
|
|||||||
|
# Install SyncGames systemd user units
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
||||||
|
mkdir -p "$UNIT_DIR"
|
||||||
|
cp "$ROOT/systemd/syncgames-agent.service" "$UNIT_DIR/"
|
||||||
|
cp "$ROOT/systemd/[email protected]" "$UNIT_DIR/"
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
echo "Installed units to $UNIT_DIR"
|
||||||
|
echo "Enable a game watcher: systemctl --user enable --now [email protected]"
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=SyncGames agent environment (documentation unit)
|
||||||
|
Documentation=file://%h/Documents/CursorProjects/Linux app issues/SyncGames/README.md
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
# Ensure config exists; real work is per-game [email protected]
|
||||||
|
ExecStart=/bin/true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=SyncGames watcher for %i
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
# Install agent into PATH or set Environment= PATH=
|
||||||
|
# Example: Environment=SYNCGAMES_CONFIG=%h/.config/syncgames
|
||||||
|
ExecStart=%h/.local/bin/syncgames watch %i
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
id = "example-game"
|
||||||
|
name = "Example Game"
|
||||||
|
platform = "steam" # steam | eden | yuzu | other
|
||||||
|
versions_to_keep = 5
|
||||||
|
paths = [
|
||||||
|
"~/path/to/save/dir",
|
||||||
|
]
|
||||||
|
steam_appid = 0
|
||||||
|
process_names = [
|
||||||
|
"game.exe",
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user