from __future__ import annotations import traceback from pathlib import Path from PySide6.QtCore import QObject, QThread, Signal from PySide6.QtWidgets import ( QCheckBox, QComboBox, QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, QInputDialog, QLabel, QLineEdit, QListWidget, QMainWindow, QMessageBox, QPlainTextEdit, QPushButton, QSpinBox, QTabWidget, QTextEdit, QVBoxLayout, QWidget, ) from syncgames import __version__ from syncgames.config import ( AgentConfig, GameConfig, default_config_root, install_systemd_units, list_games, save_agent_config, seed_example_games, slugify, try_load_agent_config, write_game_toml, ) from syncgames.errors import SyncGamesError from syncgames.session import ( end_session, restore_session, start_session, status_text, ) from syncgames.store import build_store class Worker(QObject): finished = Signal(str) failed = Signal(str) def __init__(self, fn) -> None: super().__init__() self._fn = fn def run(self) -> None: try: result = self._fn() self.finished.emit(result if isinstance(result, str) else str(result or "ok")) except BaseException as e: # noqa: BLE001 — keep GUI alive on any probe/S3 failure self.failed.emit(f"{type(e).__name__}: {e}\n\n{traceback.format_exc()}") class MainWindow(QMainWindow): def __init__(self) -> None: super().__init__() self.setWindowTitle(f"SyncGames {_version_label()}") self.resize(920, 640) self._config_root = default_config_root() self._thread: QThread | None = None self._worker: Worker | None = None self._editing_game_id: str | None = None self._log = QTextEdit(self) self._log.setReadOnly(True) self._log.setMinimumHeight(140) tabs = QTabWidget(self) tabs.addTab(self._build_setup(QWidget()), "Setup") tabs.addTab(self._build_games(QWidget()), "Games") tabs.addTab(self._build_session(QWidget()), "Session") holder = QWidget(self) lay = QVBoxLayout(holder) lay.addWidget(tabs) lay.addWidget(QLabel("Log")) lay.addWidget(self._log) self.setCentralWidget(holder) self._load_config_into_form() self._refresh_games() def _build_setup(self, parent: QWidget) -> QWidget: layout = QVBoxLayout(parent) box = QGroupBox("MinIO / Cloudflare endpoint", parent) form = QFormLayout(box) self._device = QLineEdit(box) self._endpoint = QLineEdit(box) self._endpoint.setPlaceholderText("https://syncgames-s3.example.com") self._bucket = QLineEdit(box) self._bucket.setText("syncgames") self._region = QLineEdit(box) self._region.setText("us-east-1") self._access = QLineEdit(box) self._secret = QLineEdit(box) self._secret.setEchoMode(QLineEdit.EchoMode.Password) self._ttl = QSpinBox(box) self._ttl.setRange(1, 72) self._ttl.setValue(6) self._path_style = QCheckBox("Path-style S3 addressing (required behind NGINX)", box) self._path_style.setChecked(True) self._store = QComboBox(box) self._store.addItems(["minio", "filesystem"]) self._ssot = QLineEdit(box) self._ssot.setPlaceholderText("Path 1 fallback ssot_root only") form.addRow("Device id", self._device) form.addRow("Endpoint URL", self._endpoint) form.addRow("Bucket", self._bucket) form.addRow("Region", self._region) form.addRow("Access key", self._access) form.addRow("Secret key", self._secret) form.addRow("Lease TTL (hours)", self._ttl) form.addRow("", self._path_style) form.addRow("Store backend", self._store) form.addRow("SSOT root (filesystem)", self._ssot) layout.addWidget(box) row = QHBoxLayout() save_btn = QPushButton("Save config", parent) save_btn.clicked.connect(self._on_save_config) doctor_btn = QPushButton("Doctor (probe)", parent) doctor_btn.clicked.connect(self._on_doctor) seed_btn = QPushButton("Seed example games", parent) seed_btn.clicked.connect(self._on_seed_games) units_btn = QPushButton("Install systemd units", parent) units_btn.clicked.connect(self._on_install_units) row.addWidget(save_btn) row.addWidget(doctor_btn) row.addWidget(seed_btn) row.addWidget(units_btn) layout.addLayout(row) hint = QLabel( f"Config written to {_config_hint(self._config_root)}/agent.toml " "(mode 0600). CLI remains available as syncgames.", parent, ) hint.setWordWrap(True) layout.addWidget(hint) layout.addStretch(1) return parent def _build_games(self, parent: QWidget) -> QWidget: layout = QVBoxLayout(parent) self._games_list = QListWidget(parent) self._games_list.itemSelectionChanged.connect(self._on_game_selection_changed) self._games_list.itemDoubleClicked.connect(lambda _item: self._load_selected_game()) layout.addWidget(self._games_list) form_box = QGroupBox("Add / edit game", parent) form = QFormLayout(form_box) self._g_id = QLineEdit(form_box) self._g_id.setPlaceholderText("auto from name when adding") self._g_id.setReadOnly(True) self._g_name = QLineEdit(form_box) self._g_platform = QComboBox(form_box) self._g_platform.addItems(["steam", "eden", "yuzu", "other"]) self._g_paths = QPlainTextEdit(form_box) self._g_paths.setPlaceholderText("One save path per line") self._g_paths.setFixedHeight(72) self._g_versions = QSpinBox(form_box) self._g_versions.setRange(3, 20) self._g_versions.setValue(5) self._g_appid = QLineEdit(form_box) self._g_proc = QLineEdit(form_box) self._g_proc.setPlaceholderText("eldenring.exe (comma-separated ok)") browse = QPushButton("Add folder…", form_box) browse.clicked.connect(self._browse_save_path) path_row = QHBoxLayout() path_row.addWidget(self._g_paths, 1) path_row.addWidget(browse) path_wrap = QWidget(form_box) path_wrap.setLayout(path_row) form.addRow("Game id", self._g_id) form.addRow("Name", self._g_name) form.addRow("Platform", self._g_platform) form.addRow("Save path(s)", path_wrap) form.addRow("Versions to keep", self._g_versions) form.addRow("Steam AppID", self._g_appid) form.addRow("Process name(s)", self._g_proc) layout.addWidget(form_box) row = QHBoxLayout() load_btn = QPushButton("Load selected", parent) load_btn.clicked.connect(self._load_selected_game) add_btn = QPushButton("Add new", parent) add_btn.clicked.connect(self._on_add_game) save_btn = QPushButton("Save changes", parent) save_btn.clicked.connect(self._on_save_game) clear_btn = QPushButton("Clear form", parent) clear_btn.clicked.connect(self._clear_game_form) refresh_btn = QPushButton("Refresh", parent) refresh_btn.clicked.connect(self._refresh_games) remove_btn = QPushButton("Retire selected", parent) remove_btn.clicked.connect(self._on_retire_game) row.addWidget(load_btn) row.addWidget(add_btn) row.addWidget(save_btn) row.addWidget(clear_btn) row.addWidget(refresh_btn) row.addWidget(remove_btn) layout.addLayout(row) return parent def _build_session(self, parent: QWidget) -> QWidget: layout = QVBoxLayout(parent) row = QHBoxLayout() self._session_game = QComboBox(parent) row.addWidget(QLabel("Game")) row.addWidget(self._session_game, 1) layout.addLayout(row) btns = QHBoxLayout() for label, slot in ( ("Start session", self._on_start), ("End session", self._on_end), ("Status", self._on_status), ("History", self._on_history), ("Restore…", self._on_restore), ): b = QPushButton(label, parent) b.clicked.connect(slot) btns.addWidget(b) layout.addLayout(btns) warn = QLabel( "Start pulls SSOT → native saves and takes the lease. " "End pushes WIP → history + live and releases the lease. " "Never use continuous sync on live game folders.", parent, ) warn.setWordWrap(True) layout.addWidget(warn) layout.addStretch(1) return parent def _append_log(self, text: str) -> None: self._log.append(text.rstrip() + "\n") def _on_bg_ok(self, text: str) -> None: self._append_log(text) if text.startswith("probe_ok"): QMessageBox.information(self, "Doctor", text) def _on_bg_err(self, err: str) -> None: self._append_log(f"ERROR: {err}") # Short popup; full traceback stays in the log panel brief = err.split("\n\n", 1)[0] QMessageBox.warning( self, "Operation failed", brief + "\n\nTip: for LAN testing use endpoint http://192.168.1.5:9000 " "(leave Cloudflare URL until NGINX returns 200, not 502).", ) def _run_bg(self, label: str, fn) -> None: if self._thread is not None and self._thread.isRunning(): QMessageBox.information(self, "Busy", "Wait for the current operation to finish.") return self._append_log(f"→ {label}…") thread = QThread(self) worker = Worker(fn) # Keep Python refs so Qt does not use-after-free the worker (AppImage crash). self._thread = thread self._worker = worker worker.moveToThread(thread) thread.started.connect(worker.run) worker.finished.connect(self._on_bg_ok) worker.failed.connect(self._on_bg_err) worker.finished.connect(thread.quit) worker.failed.connect(thread.quit) def _clear() -> None: self._worker = None self._thread = None thread.finished.connect(_clear) thread.start() def _form_to_config(self) -> AgentConfig: ssot = self._ssot.text().strip() return AgentConfig( device_id=self._device.text().strip() or "pc-desk", endpoint_url=self._endpoint.text().strip(), bucket=self._bucket.text().strip() or "syncgames", region=self._region.text().strip() or "us-east-1", path_style=self._path_style.isChecked(), access_key=self._access.text().strip(), secret_key=self._secret.text().strip(), store=self._store.currentText(), ssot_root=Path(ssot).expanduser() if ssot else None, lease_ttl_hours=self._ttl.value(), config_root=self._config_root, ) def _load_config_into_form(self) -> None: cfg = try_load_agent_config(self._config_root) if cfg is None: self._device.setText("pc-desk") self._append_log(f"No config yet — will create {self._config_root / 'agent.toml'}") return self._config_root = cfg.config_root self._device.setText(cfg.device_id) self._endpoint.setText(cfg.endpoint_url) self._bucket.setText(cfg.bucket) self._region.setText(cfg.region) self._access.setText(cfg.access_key) self._secret.setText(cfg.secret_key) self._ttl.setValue(cfg.lease_ttl_hours) self._path_style.setChecked(cfg.path_style) idx = self._store.findText(cfg.store) if idx >= 0: self._store.setCurrentIndex(idx) if cfg.ssot_root: self._ssot.setText(str(cfg.ssot_root)) self._append_log(f"Loaded config from {cfg.config_root / 'agent.toml'}") def _require_cfg(self) -> AgentConfig: cfg = try_load_agent_config(self._config_root) if cfg is None: raise SyncGamesError("Save Setup config first", key="config_error") return cfg def _on_save_config(self) -> None: cfg = self._form_to_config() path = save_agent_config(cfg) self._append_log(f"Saved {path}") QMessageBox.information(self, "Saved", f"Wrote {path}") def _on_doctor(self) -> None: def work() -> str: cfg = self._form_to_config() save_agent_config(cfg) store = build_store(cfg) digest = store.probe() return f"probe_ok hash={digest}" self._run_bg("doctor", work) def _on_seed_games(self) -> None: cfg = self._form_to_config() self._config_root.mkdir(parents=True, exist_ok=True) save_agent_config(cfg) n = seed_example_games(cfg) self._refresh_games() self._append_log(f"Seeded {n} example game config(s)") def _on_install_units(self) -> None: try: unit_dir = install_systemd_units() self._append_log(f"Installed systemd user units to {unit_dir}") self._append_log( "Enable a watcher: systemctl --user enable --now " "syncgames-watch@elden-ring-seamless.service" ) QMessageBox.information( self, "Units installed", f"Copied units to {unit_dir}\n\n" "Then: systemctl --user daemon-reload\n" "Enable: systemctl --user enable --now syncgames-watch@.service", ) except Exception as e: # noqa: BLE001 QMessageBox.critical(self, "Failed", str(e)) def _browse_save_path(self) -> None: path = QFileDialog.getExistingDirectory(self, "Select save folder") if not path: return existing = [ln.strip() for ln in self._g_paths.toPlainText().splitlines() if ln.strip()] if path not in existing: existing.append(path) self._g_paths.setPlainText("\n".join(existing)) def _game_paths_from_form(self) -> list[str]: return [ln.strip() for ln in self._g_paths.toPlainText().splitlines() if ln.strip()] def _clear_game_form(self) -> None: self._editing_game_id = None self._g_id.clear() self._g_id.setPlaceholderText("auto from name when adding") self._g_name.clear() self._g_platform.setCurrentIndex(0) self._g_paths.clear() self._g_versions.setValue(5) self._g_appid.clear() self._g_proc.clear() def _on_game_selection_changed(self) -> None: # Convenience: keep list selection in sync but don't auto-overwrite the form # unless the user clicks Load / double-clicks. pass def _list_selected_game_id(self) -> str | None: item = self._games_list.currentItem() if not item: return None return item.text().split(" — ", 1)[0].strip() def _load_selected_game(self) -> None: game_id = self._list_selected_game_id() if not game_id: QMessageBox.information(self, "Edit game", "Select a game in the list first.") return try: cfg = self._require_cfg() from syncgames.config import load_game game = load_game(cfg, game_id) except SyncGamesError as e: QMessageBox.warning(self, "Edit game", str(e)) return self._editing_game_id = game.id self._g_id.setText(game.id) self._g_name.setText(game.name) idx = self._g_platform.findText(game.platform) if idx >= 0: self._g_platform.setCurrentIndex(idx) self._g_paths.setPlainText("\n".join(game.paths)) self._g_versions.setValue(game.versions_to_keep) self._g_appid.setText("" if game.steam_appid is None else str(game.steam_appid)) self._g_proc.setText(", ".join(game.process_names)) self._append_log(f"Loaded {game.id} into editor (Save changes to update)") def _refresh_games(self) -> None: self._games_list.clear() self._session_game.clear() cfg = try_load_agent_config(self._config_root) if cfg is None: return for g in list_games(cfg): self._games_list.addItem(f"{g.id} — {g.name}") self._session_game.addItem(g.id, g.id) def _on_add_game(self) -> None: try: cfg = self._require_cfg() name = self._g_name.text().strip() paths = self._game_paths_from_form() if not name or not paths: raise SyncGamesError("Name and at least one save path required", key="config_error") game_id = slugify(name) if (cfg.games_dir / f"{game_id}.toml").exists(): raise SyncGamesError( f"Game id {game_id} already exists — Load selected + Save changes, or pick another name", key="config_error", ) appid = self._g_appid.text().strip() procs = [p.strip() for p in self._g_proc.text().split(",") if p.strip()] game = GameConfig( id=game_id, name=name, platform=self._g_platform.currentText(), paths=paths, versions_to_keep=self._g_versions.value(), steam_appid=int(appid) if appid else None, process_names=procs, ) write_game_toml(cfg, game) store = build_store(cfg) from syncgames.session import ensure_game_remote ensure_game_remote(store, game) self._editing_game_id = game.id self._g_id.setText(game.id) self._append_log(f"Added game {game.id}") self._refresh_games() except SyncGamesError as e: QMessageBox.warning(self, "Add game", str(e)) def _on_save_game(self) -> None: try: cfg = self._require_cfg() game_id = self._editing_game_id or self._g_id.text().strip() or self._list_selected_game_id() if not game_id: raise SyncGamesError( "Load a game first (Load selected / double-click), then Save changes", key="config_error", ) name = self._g_name.text().strip() paths = self._game_paths_from_form() if not name or not paths: raise SyncGamesError("Name and at least one save path required", key="config_error") if not (cfg.games_dir / f"{game_id}.toml").exists(): raise SyncGamesError(f"Unknown game id {game_id}", key="config_error") appid = self._g_appid.text().strip() procs = [p.strip() for p in self._g_proc.text().split(",") if p.strip()] game = GameConfig( id=game_id, # keep stable so MinIO prefixes stay valid name=name, platform=self._g_platform.currentText(), paths=paths, versions_to_keep=self._g_versions.value(), steam_appid=int(appid) if appid else None, process_names=procs, ) write_game_toml(cfg, game) self._editing_game_id = game_id self._g_id.setText(game_id) self._append_log(f"Updated game {game_id}") self._refresh_games() QMessageBox.information(self, "Saved", f"Updated {game_id} (game id kept stable for MinIO)") except SyncGamesError as e: QMessageBox.warning(self, "Save changes", str(e)) except ValueError as e: QMessageBox.warning(self, "Save changes", str(e)) def _on_retire_game(self) -> None: game_id = self._list_selected_game_id() if not game_id: return if QMessageBox.question(self, "Retire", f"Retire remote + local config for {game_id}?") != QMessageBox.StandardButton.Yes: return def work() -> str: cfg = self._require_cfg() from syncgames.config import load_game game = load_game(cfg, game_id) store = build_store(cfg) dest = store.retire_game(game.id) src = cfg.games_dir / f"{game.id}.toml" if src.exists(): src.rename(src.with_suffix(".toml.removed")) return f"Retired {game_id} → {dest}" if self._editing_game_id == game_id: self._clear_game_form() self._run_bg("retire", work) self._refresh_games() def _selected_game_id(self) -> str: return self._session_game.currentText().strip() def _on_start(self) -> None: gid = self._selected_game_id() if not gid: return def work() -> str: cfg = self._require_cfg() from syncgames.config import load_game game = load_game(cfg, gid) meta = start_session(cfg, build_store(cfg), game) return f"Started {gid}; lease={meta.lease}; live_hash={meta.live_hash}" self._run_bg(f"start {gid}", work) def _on_end(self) -> None: gid = self._selected_game_id() if not gid: return def work() -> str: cfg = self._require_cfg() from syncgames.config import load_game game = load_game(cfg, gid) meta = end_session(cfg, build_store(cfg), game) return f"Ended {gid}; live_hash={meta.live_hash}" self._run_bg(f"end {gid}", work) def _on_status(self) -> None: gid = self._selected_game_id() def work() -> str: cfg = self._require_cfg() from syncgames.config import load_game game = load_game(cfg, gid) if gid else None return status_text(cfg, build_store(cfg), game) self._run_bg("status", work) def _on_history(self) -> None: gid = self._selected_game_id() if not gid: return def work() -> str: cfg = self._require_cfg() items = build_store(cfg).list_history(gid) return "history:\n" + ("\n".join(items) if items else "(empty)") self._run_bg(f"history {gid}", work) def _on_restore(self) -> None: gid = self._selected_game_id() if not gid: return cfg = try_load_agent_config(self._config_root) if cfg is None: QMessageBox.warning(self, "Restore", "Save config first") return items = build_store(cfg).list_history(gid) if not items: QMessageBox.information(self, "Restore", "No history entries") return choice, ok = QInputDialog.getItem(self, "Restore", "History prefix", items, 0, False) if not ok or not choice: return confirm, ok2 = QInputDialog.getText( self, "Confirm", f'Type YES to restore live from "{choice}"' ) if not ok2 or confirm != "YES": self._append_log("Restore aborted") return def work() -> str: from syncgames.config import load_game game = load_game(cfg, gid) meta = restore_session(cfg, build_store(cfg), game, choice) return f"Restored {choice}; live_hash={meta.live_hash}" self._run_bg(f"restore {choice}", work) def _version_label() -> str: return __version__ def _config_hint(root: Path) -> str: return str(root)