Files
SyncGames/agent/syncgames/gui/main_window.py
T
DawnsorrowandCursor 0d6b0b2f80 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]>
2026-07-14 22:06:36 -05:00

647 lines
23 KiB
Python

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)