Session-gated MinIO save sync with AppImage GUI, CLI edit/session flow, and Gitea release helper. Co-authored-by: Cursor <[email protected]>
647 lines
23 KiB
Python
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)
|