From b31fdd023af1912175b9e5dea06f3033d44e88d4 Mon Sep 17 00:00:00 2001 From: claude-timemachine Date: Fri, 5 Jun 2026 01:14:02 +0200 Subject: [PATCH] rename: cloud sync -> instance sync; cloud -> Timemachine Network; drop Tk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Product / UI / CLI / docs rebrand. Internal package, repo, and on-disk dir names stay 'cloud_sync' / 'cloud-sync' / '.cloud-sync/' to avoid breaking existing installs; a future commit can do the file-system rename when the cost is worth paying. User-facing changes: CLI prog name: cloud-sync -> instance-sync CLI description: cloud-svc URL -> Timemachine Network endpoint Dialog title: CLOUD SYNC -> INSTANCE SYNC Dialog title: CLOUD CONFLICT -> INSTANCE CONFLICT Dialog title: CONNECT CLOUD SAVE -> CONNECT TO THE NETWORK Card label: Cloud Save -> Remote Save Skip button: Skip cloud sync -> Skip instance sync Body copy: 'the cloud' -> 'the Timemachine Network' Window titles: Cloud sync — ... -> Instance sync — ... Log prefix: cloud-sync: -> instance-sync: Error prose: 'cloud-sync token' -> 'instance-sync token' Backend changes: restic --host tag: cloud-sync -> instance-sync State.host_tag dflt: cloud-sync -> instance-sync (Existing snapshots with the old tag still pull fine; we use 'latest'.) Drop tkinter fallback: ui.py now offers Qt OR Headless. tkinter is unnecessary given we already maintain Qt + headless; one less code path to keep styled, smaller pyz. make_progress() picks Qt first, falls through to HeadlessProgress on ImportError with a stderr hint to 'pip install PySide6'. README: rebrand title + prose; note repo/dir rename deferred; call out the PySide6 install step. Conflict/login dialogs are now Qt-only; without Qt, conflict aborts (defensive) and login tells the user to paste the token manually. 52 tests green; no test-file label changes needed since they only exercise internal APIs. --- README.md | 24 +++--- cloud_sync/__init__.py | 6 +- cloud_sync/cli.py | 18 ++-- cloud_sync/creds.py | 11 +-- cloud_sync/restic.py | 4 +- cloud_sync/scope.py | 9 +- cloud_sync/state.py | 4 +- cloud_sync/sync.py | 28 +++--- cloud_sync/ui.py | 190 +++++------------------------------------ cloud_sync/ui_qt.py | 37 ++++---- 10 files changed, 99 insertions(+), 232 deletions(-) diff --git a/README.md b/README.md index 7ed1245..94535b1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ -# cloud-sync +# instance-sync -Per-user Minecraft state sync via [restic](https://restic.net). Single Python zipapp drops into Prism / MMC / ATLauncher pre-launch and post-exit hooks alongside [packwiz-installer-bootstrap](https://github.com/packwiz/packwiz-installer-bootstrap). Part of the [automc](https://git.timemachine.center/Timemachine/automc) platform. +Per-user Minecraft instance sync over the **Timemachine Network**, backed by [restic](https://restic.net). Single Python zipapp drops into Prism / MMC / ATLauncher pre-launch and post-exit hooks alongside [packwiz-installer-bootstrap](https://github.com/packwiz/packwiz-installer-bootstrap). Part of the [automc](https://git.timemachine.center/Timemachine/automc) platform. + +> Repo + package + on-disk dir names are still `cloud-sync` / `cloud_sync` / `.cloud-sync/` for now — the rename was at the product / UI / CLI level. A future commit can pick up the file-system rename when it's worth breaking existing installs. See [`DESIGN.md`](DESIGN.md) for the full architecture (restic backend, two-port cloud-svc control plane, etc.). @@ -32,18 +34,20 @@ Post-exit: python /path/to/cloud-sync.pyz push --url=https://cloud.tm.center --pack-folder=$INST_MC_DIR ``` -Player needs Python 3.10+ on PATH. Token file (`/.cloud-sync/token`) gets the `discord_id:password` credentials from their `/register` Discord DM. +Player needs Python 3.10+ on PATH AND a Qt binding (`pip install PySide6`). The first pull on a fresh instance opens a "CONNECT TO THE NETWORK" dialog; the player pastes a token they got via `/cloud register` in Discord. The token lands at `/.cloud-sync/token`. + +If `PySide6` / `PyQt6` is missing the pyz falls back to headless mode (status to stdout). The conflict + login dialogs do not have a headless mode — without Qt the conflict path aborts the launch defensively, and the login path tells the user to paste the token manually. ## CLI ``` python cloud-sync.pyz {pull,push} \ - --url URL cloud-svc data plane URL (required) + --url URL Timemachine Network endpoint (required) --pack-folder PATH Minecraft instance directory (default: cwd) --token-file PATH override default /.cloud-sync/token --restic-binary PATH skip auto-discovery --no-download fail if no usable restic; don't fetch from upstream - -g, --no-gui headless mode + -g, --no-gui headless mode (no Qt windows) ``` ## Programmatic API (for frazclient) @@ -82,19 +86,19 @@ Auto-excluded from sync. Multiple MC instances = multiple `.cloud-sync/` dirs wi ## Why Python (not a JAR) 1. **Antivirus.** Unsigned JARs that auto-download binaries + upload files are textbook Windows Defender false-positive triggers. Python invoked by code-signed `python.exe` mostly sidesteps that. -2. **Future Qt UI.** PySide6 opens a path to a real Qt UI (matching Prism's look) if richer surfaces are wanted later. JVM Qt bindings are abandoned. +2. **Qt UI.** PySide6 gives us a real native window with the Prism-dark Steam-style layout. JVM Qt bindings are abandoned. 3. **frazclient already needs Python.** Inlining as an import is zero overhead; the same package serves Prism via the pyz. -Cost: players using Prism must have Python 3.10+ installed. Most Linux/Mac systems already do; Windows users install once from the Microsoft Store or python.org. +Cost: players using Prism must have Python 3.10+ AND Qt installed. Most Linux/Mac systems ship Python; Windows users install once from the Microsoft Store or python.org. Qt comes via `pip install PySide6`. ## Where the data lives | Component | Role | Repo | |---|---|---| -| `cloud-sync` (this) | Player-side. Subprocess restic for pull/push. | `Timemachine/cloud-sync` | +| `instance-sync` (this) | Player-side. Subprocesses restic for pull/push. Surfaces login + conflict + progress dialogs. | `Timemachine/cloud-sync` | | `cloud-svc` | Operator-side control plane (provisioning + admin). | `Timemachine/cloud-svc` | -| `restic-rest-server` (existing) | Data plane. Player's restic hits it directly with their password. | upstream | -| `discord-bot` | Calls cloud-svc on `/register` to provision a player's cloud account. | `Timemachine/discord-bot` | +| `restic-rest-server` (existing) | Timemachine Network data plane. Player's restic hits it directly with their password. | upstream | +| `discord-bot` | Calls cloud-svc on `/cloud register` to provision a player's Timemachine Network account. | `Timemachine/discord-bot` | ## License diff --git a/cloud_sync/__init__.py b/cloud_sync/__init__.py index 9df3c2b..165fff2 100644 --- a/cloud_sync/__init__.py +++ b/cloud_sync/__init__.py @@ -1,10 +1,14 @@ -"""cloud-sync — per-user state sync for Minecraft via restic. +"""instance-sync — per-user Minecraft instance sync via the Timemachine Network. Public API for in-process callers (e.g. frazclient): import cloud_sync cloud_sync.pull(url="https://cloud.tm.center", pack_folder=Path("/instance")) cloud_sync.push(url="https://cloud.tm.center", pack_folder=Path("/instance")) + +Note: the Python package name stays ``cloud_sync`` for now to keep +existing imports working; only the public feature name + UI/CLI prose +have been rebranded to "instance sync" / "Timemachine Network". """ from .cli import Args diff --git a/cloud_sync/cli.py b/cloud_sync/cli.py index ea91250..09d7714 100644 --- a/cloud_sync/cli.py +++ b/cloud_sync/cli.py @@ -27,17 +27,17 @@ class Args: def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( - prog="cloud-sync", - description="Per-user Minecraft state sync via restic.", + prog="instance-sync", + description="Per-user Minecraft instance sync via the Timemachine Network.", ) - p.add_argument("--version", action="version", version="cloud-sync 0.1.0") + p.add_argument("--version", action="version", version="instance-sync 0.1.0") sub = p.add_subparsers(dest="cmd", required=True) for name in ("pull", "push"): - sp = sub.add_parser(name, help=f"{name} player state") + sp = sub.add_parser(name, help=f"{name} instance state") sp.add_argument( "--url", required=True, - help="cloud-svc data plane URL (e.g. https://cloud.tm.center)", + help="Timemachine Network endpoint (e.g. https://cloud.tm.center)", ) sp.add_argument( "--pack-folder", default=".", type=Path, @@ -57,7 +57,7 @@ def build_parser() -> argparse.ArgumentParser: ) sp.add_argument( "-g", "--no-gui", action="store_true", - help="Headless mode (no Swing/Qt windows, restic stdout only)", + help="Headless mode (no Qt windows; status to stdout only)", ) return p @@ -93,13 +93,13 @@ def main(argv: list[str] | None = None) -> int: action = {"pull": sync.pull, "push": sync.push}[cmd] progress = ui.make_progress(headless=args.headless) - title = "Cloud sync — pulling" if cmd == "pull" else "Cloud sync — pushing" + title = "Instance sync — pulling" if cmd == "pull" else "Instance sync — pushing" try: return progress.run_with(lambda: action(args, progress), title) except KeyboardInterrupt: - print("cloud-sync: cancelled", file=sys.stderr) + print("instance-sync: cancelled", file=sys.stderr) return 1 except Exception as e: # noqa: BLE001 - print(f"cloud-sync {cmd}: {e}", file=sys.stderr) + print(f"instance-sync {cmd}: {e}", file=sys.stderr) return 2 diff --git a/cloud_sync/creds.py b/cloud_sync/creds.py index aaac857..347ac8e 100644 --- a/cloud_sync/creds.py +++ b/cloud_sync/creds.py @@ -3,8 +3,9 @@ Format: ``discord_id:password`` on a single line. Whitespace tolerated. The Discord ID is the URL path segment under cloud.tm.center// that restic-rest-server's --private-repos enforces against the basic-auth -username. The password is the bcrypt'd entry's plaintext AND the restic -repo encryption password (cloud-svc provisions one password covering both). +username. The password is the bcrypt'd entry's plaintext — it covers HTTP +basic auth only (restic repos use --insecure-no-password). The Timemachine +Network control plane provisions the credential at /register time. """ from __future__ import annotations @@ -19,14 +20,14 @@ class CredentialsError(Exception): def read_credentials(token_file: Path) -> tuple[str, str]: if not token_file.exists(): raise CredentialsError( - f"cloud-sync token not found at {token_file}. " + f"instance-sync token not found at {token_file}. " f"After /register in Discord you should have received credentials; " f"paste them into this file as 'discord_id:password' on one line." ) raw = token_file.read_text(encoding="utf-8").strip() if ":" not in raw: raise CredentialsError( - f"cloud-sync token at {token_file} malformed " + f"instance-sync token at {token_file} malformed " f"(expected 'discord_id:password' on one line)" ) discord_id, password = raw.split(":", 1) @@ -34,7 +35,7 @@ def read_credentials(token_file: Path) -> tuple[str, str]: password = password.strip() if not discord_id or not password: raise CredentialsError( - f"cloud-sync token at {token_file} malformed " + f"instance-sync token at {token_file} malformed " f"(empty discord_id or password)" ) return discord_id, password diff --git a/cloud_sync/restic.py b/cloud_sync/restic.py index 081c041..6dd3d5b 100644 --- a/cloud_sync/restic.py +++ b/cloud_sync/restic.py @@ -8,7 +8,7 @@ Discovery order: The version is pinned because repos written by one restic version can have features another version can't read. Cache the pinned binary per-instance -so deleting the instance dir wipes everything cloud-sync owns. +so deleting the instance dir wipes everything instance-sync owns. """ from __future__ import annotations @@ -203,7 +203,7 @@ def _download_restic_to(target: Path, plat: Platform) -> None: f"{RELEASE_TAG}/SHA256SUMS" ) print( - f"cloud-sync: downloading restic {RESTIC_VERSION} from {asset_url}", + f"instance-sync: downloading restic {RESTIC_VERSION} from {asset_url}", file=sys.stderr, ) with tempfile.NamedTemporaryFile(suffix=f".{ext}", delete=False) as tmp: diff --git a/cloud_sync/scope.py b/cloud_sync/scope.py index 9255deb..c404d14 100644 --- a/cloud_sync/scope.py +++ b/cloud_sync/scope.py @@ -1,8 +1,9 @@ """Per-distribution sync scope (include/exclude paths). -Each cloud-sync deployment ships its own ``scope.json`` that picks which -files participate in sync. Lives at ``/.cloud-sync/scope.json``. -Defaults are baked in so a fresh install with no scope.json works. +Each instance-sync deployment ships its own ``scope.json`` that picks +which files participate in sync. Lives at +``/.cloud-sync/scope.json``. Defaults are baked in so a +fresh install with no scope.json works. """ from __future__ import annotations @@ -48,7 +49,7 @@ def load(pack_folder: Path) -> Scope: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as e: print( - f"cloud-sync: scope.json invalid ({e}); using defaults", + f"instance-sync: scope.json invalid ({e}); using defaults", file=sys.stderr, ) return Scope() diff --git a/cloud_sync/state.py b/cloud_sync/state.py index e44b61b..234e084 100644 --- a/cloud_sync/state.py +++ b/cloud_sync/state.py @@ -24,7 +24,7 @@ SCHEMA_VERSION = 1 class State: last_pulled_snapshot_id: str last_pulled_at: datetime - host_tag: str = "cloud-sync" + host_tag: str = "instance-sync" def state_path(pack_folder: Path) -> Path: @@ -46,7 +46,7 @@ def read(pack_folder: Path) -> State | None: return State( last_pulled_snapshot_id=data["last_pulled_snapshot_id"], last_pulled_at=_parse_iso(data["last_pulled_at"]), - host_tag=data.get("host_tag", "cloud-sync"), + host_tag=data.get("host_tag", "instance-sync"), ) except (KeyError, ValueError): return None diff --git a/cloud_sync/sync.py b/cloud_sync/sync.py index 40e410c..0ea922b 100644 --- a/cloud_sync/sync.py +++ b/cloud_sync/sync.py @@ -44,7 +44,7 @@ def pull(args: Args, progress: Progress | None = None) -> int: if not args.token_file.exists(): if not _prompt_login_and_save(args, ui): ui.set_status("Cloud sync skipped") - print("cloud-sync: no token; skipping pull") + print("instance-sync: no token; skipping pull") return 0 ui.set_status("Reading credentials…") @@ -66,7 +66,7 @@ def pull(args: Args, progress: Progress | None = None) -> int: return 1 if code != 0: print( - f"cloud-sync: failed to list snapshots (restic exit {code})", + f"instance-sync: failed to list snapshots (restic exit {code})", file=sys.stderr, ) return 2 @@ -75,7 +75,7 @@ def pull(args: Args, progress: Progress | None = None) -> int: if not snapshots: statemod.clear(args.pack_folder) ui.set_status("No snapshots yet — nothing to pull") - print("cloud-sync: no snapshots yet for this user; nothing to pull") + print("instance-sync: no snapshots yet for this user; nothing to pull") return 0 remote = snapshots[0] @@ -90,7 +90,7 @@ def pull(args: Args, progress: Progress | None = None) -> int: decision = "use_remote" elif local_state.last_pulled_snapshot_id == remote_id: ui.set_status("Cloud is up to date") - print("cloud-sync: already at latest snapshot") + print("instance-sync: already at latest snapshot") return 0 else: modified = _find_modified_in_scope( @@ -104,7 +104,7 @@ def pull(args: Args, progress: Progress | None = None) -> int: # UI unavailable in headless mode → conservative: cancel ui.set_status("Conflict detected; no UI available") print( - "cloud-sync: conflict detected (remote moved + local edits) " + "instance-sync: conflict detected (remote moved + local edits) " "but headless mode can't prompt; aborting", file=sys.stderr, ) @@ -115,7 +115,7 @@ def pull(args: Args, progress: Progress | None = None) -> int: return 1 if decision == "keep_local": ui.set_status("Keeping local; push will overwrite cloud on exit") - print("cloud-sync: keeping local copy") + print("instance-sync: keeping local copy") return 0 # decision == "use_remote" @@ -135,7 +135,7 @@ def pull(args: Args, progress: Progress | None = None) -> int: if code == -1: return 1 if code != 0: - print(f"cloud-sync: restic restore failed (exit {code})", file=sys.stderr) + print(f"instance-sync: restic restore failed (exit {code})", file=sys.stderr) return 2 statemod.write( @@ -146,7 +146,7 @@ def pull(args: Args, progress: Progress | None = None) -> int: ), ) ui.set_status("Pull complete") - print("cloud-sync: pull ok") + print("instance-sync: pull ok") return 0 @@ -154,8 +154,8 @@ def push(args: Args, progress: Progress | None = None) -> int: ui = progress or HeadlessProgress() if not args.token_file.exists(): - ui.set_status("No cloud token; skipping push") - print("cloud-sync: no token; skipping push") + ui.set_status("No network token; skipping push") + print("instance-sync: no token; skipping push") return 0 ui.set_status("Reading credentials…") @@ -177,7 +177,7 @@ def push(args: Args, progress: Progress | None = None) -> int: "backup", "--files-from", str(files_from), "--exclude-file", str(exclude_from), - "--host", "cloud-sync", + "--host", "instance-sync", "--tag", "auto", "--json", ], @@ -188,7 +188,7 @@ def push(args: Args, progress: Progress | None = None) -> int: if code == -1: return 1 if code != 0: - print(f"cloud-sync: restic backup failed (exit {code})", file=sys.stderr) + print(f"instance-sync: restic backup failed (exit {code})", file=sys.stderr) return 2 new_id = _parse_backup_summary(out) @@ -201,7 +201,7 @@ def push(args: Args, progress: Progress | None = None) -> int: ), ) ui.set_status("Push complete") - print("cloud-sync: push ok") + print("instance-sync: push ok") return 0 @@ -311,7 +311,7 @@ def _prompt_login_and_save(args: Args, ui: Progress) -> bool: except ImportError: ui.set_status("No token and no UI; can't prompt") print( - "cloud-sync: no token at " + "instance-sync: no token at " f"{args.token_file} and no Qt UI available", file=sys.stderr, ) diff --git a/cloud_sync/ui.py b/cloud_sync/ui.py index be829e0..7bddbea 100644 --- a/cloud_sync/ui.py +++ b/cloud_sync/ui.py @@ -1,25 +1,22 @@ -"""Progress UI for cloud-sync operations. +"""Progress UI for instance-sync operations. Two implementations sharing the ``Progress`` protocol: - :class:`HeadlessProgress` — no window; prints to stdout/stderr. Used when - ``--no-gui`` is set or when tkinter import fails. - - :class:`TkProgressWindow` — tkinter modal window with status text + - indeterminate progress bar + Cancel button. Stdlib only. + ``--no-gui`` is set OR when Qt isn't available (the only graphical path + is Qt; there is no tkinter fallback). + - :class:`QtProgressWindow` (in :mod:`cloud_sync.ui_qt`) — Qt modal window + with the Prism-dark Steam-style layout. -The window runs in the main thread; the restic subprocess runs in a -worker thread. The window polls every 100 ms to check whether the worker -finished and whether the user clicked Cancel. - -Future option: PySide6 / PyQt6 for a real Qt window matching Prism's -look. Gated behind ``cloud-sync[qt]`` extra in ``pyproject.toml``; not -implemented yet. +The factory :func:`make_progress` picks Qt → Headless. Qt requires +PySide6 or PyQt6 to be importable. Install via +``pip install 'cloud-sync[qt]'`` or directly +``pip install PySide6``. """ from __future__ import annotations import sys -import threading from typing import Callable, Protocol @@ -31,16 +28,11 @@ class Progress(Protocol): def run_with(self, worker: Callable[[], int], title: str) -> int: ... -# --------------------------------------------------------------------------- -# Headless (text-only) -# --------------------------------------------------------------------------- - - class HeadlessProgress: """No-op progress. Status messages go to stdout, errors to stderr.""" def set_status(self, msg: str) -> None: - print(f"cloud-sync: {msg}", flush=True) + print(f"instance-sync: {msg}", flush=True) def is_cancelled(self) -> bool: return False @@ -50,147 +42,13 @@ class HeadlessProgress: return worker() -# --------------------------------------------------------------------------- -# Tk window -# --------------------------------------------------------------------------- - - -class TkProgressWindow: - """Modal tkinter window with status + indeterminate progress bar. - - Usage:: - - ui = TkProgressWindow() - rc = ui.run_with(lambda: do_the_work(ui), title="Cloud pull") - """ - - def __init__(self) -> None: - # Defer imports so headless fall-through doesn't blow up on - # tkinter-less Python builds. - import tkinter as tk - from tkinter import ttk - - self._tk = tk - self._ttk = ttk - - self._root = tk.Tk() - self._root.title("Cloud sync") - self._root.geometry("440x160") - self._root.resizable(False, False) - self._root.attributes("-topmost", True) - self._root.protocol("WM_DELETE_WINDOW", self._on_close) - - frame = ttk.Frame(self._root, padding=20) - frame.pack(fill="both", expand=True) - - self._title_var = tk.StringVar(value="Working…") - ttk.Label( - frame, - textvariable=self._title_var, - font=("TkDefaultFont", 11, "bold"), - ).pack(anchor="w") - - self._status_var = tk.StringVar(value="Starting…") - ttk.Label(frame, textvariable=self._status_var).pack(anchor="w", pady=(4, 8)) - - self._bar = ttk.Progressbar(frame, mode="indeterminate", length=400) - self._bar.pack(fill="x") - self._bar.start(15) - - button_row = ttk.Frame(frame) - button_row.pack(fill="x", pady=(12, 0)) - self._cancel_btn = ttk.Button( - button_row, text="Cancel", command=self._on_close - ) - self._cancel_btn.pack(side="right") - - self._cancelled = False - self._worker_rc: int | None = None - self._worker_exc: BaseException | None = None - self._center_on_screen() - - # -- public API ---------------------------------------------------- - - def set_status(self, msg: str) -> None: - try: - self._status_var.set(msg) - except self._tk.TclError: - # window was destroyed - pass - - def is_cancelled(self) -> bool: - return self._cancelled - - def run_with(self, worker: Callable[[], int], title: str) -> int: - self._title_var.set(title) - self._status_var.set("Starting…") - - def thread_target() -> None: - try: - self._worker_rc = worker() - except BaseException as e: # noqa: BLE001 - self._worker_exc = e - - t = threading.Thread(target=thread_target, daemon=True) - t.start() - self._root.after(100, self._poll, t) - self._root.mainloop() - - if self._worker_exc is not None: - raise self._worker_exc - if self._worker_rc is None: - # User cancelled before worker reported - return 1 - return self._worker_rc - - # -- internals ----------------------------------------------------- - - def _poll(self, thread: threading.Thread) -> None: - if not thread.is_alive(): - try: - self._bar.stop() - self._root.quit() - self._root.destroy() - except self._tk.TclError: - pass - return - self._root.after(100, self._poll, thread) - - def _on_close(self) -> None: - # Mark cancelled; worker checks via is_cancelled. Don't destroy - # window — polling loop will clean up once worker exits. - self._cancelled = True - try: - self._status_var.set("Cancelling…") - self._cancel_btn.configure(state="disabled") - except self._tk.TclError: - pass - - def _center_on_screen(self) -> None: - self._root.update_idletasks() - w = self._root.winfo_width() - h = self._root.winfo_height() - sw = self._root.winfo_screenwidth() - sh = self._root.winfo_screenheight() - x = max(0, (sw // 2) - (w // 2)) - y = max(0, (sh // 2) - (h // 2)) - self._root.geometry(f"+{x}+{y}") - - -# --------------------------------------------------------------------------- -# Factory -# --------------------------------------------------------------------------- - - def make_progress(headless: bool) -> Progress: - """Pick the best Progress impl for the runtime + flags. + """Return the best Progress impl for the runtime + flags. - Preference order: - 1. Qt (PySide6 or PyQt6) — modern look, matches Prism's aesthetic. - 2. tkinter — stdlib fallback; ships with most Python distributions. - 3. headless — print to stdout/stderr only. - - Override via ``--no-gui`` (forces headless). + Order: + 1. Qt window (PySide6 or PyQt6) — preferred when available. + 2. HeadlessProgress — fallback when ``--no-gui`` is set or Qt is + missing. Logs to stdout/stderr. """ if headless: return HeadlessProgress() @@ -198,17 +56,15 @@ def make_progress(headless: bool) -> Progress: from .ui_qt import QtProgressWindow return QtProgressWindow() except ImportError: - pass - except Exception as e: # noqa: BLE001 print( - f"cloud-sync: Qt init failed ({e}); falling back to tkinter", - file=sys.stderr, - ) - try: - return TkProgressWindow() - except Exception as e: # noqa: BLE001 - print( - f"cloud-sync: tkinter unavailable ({e}); falling back to headless", + "instance-sync: Qt (PySide6/PyQt6) not installed; " + "running headless. Install with: pip install PySide6", + file=sys.stderr, + ) + return HeadlessProgress() + except Exception as e: # noqa: BLE001 + print( + f"instance-sync: Qt init failed ({e}); falling back to headless", file=sys.stderr, ) return HeadlessProgress() diff --git a/cloud_sync/ui_qt.py b/cloud_sync/ui_qt.py index fa0cab8..9460450 100644 --- a/cloud_sync/ui_qt.py +++ b/cloud_sync/ui_qt.py @@ -1,4 +1,4 @@ -"""Qt progress UI for cloud-sync. +"""Qt UI for instance-sync. Supports both PySide6 (preferred — LGPL, official Qt binding) and PyQt6 (fallback — GPL/commercial). Same code runs on both because their @@ -6,7 +6,7 @@ QtWidgets / QtCore APIs are interchangeable for our subset. This module never imports Qt at top level. ``import_qt()`` raises ImportError if neither binding is available; the factory in ``ui.py`` -catches that and falls back to the tkinter window. +catches that and falls back to :class:`HeadlessProgress`. Threading model: ``QApplication`` runs on the main thread (started by ``run_with`` via ``QDialog.exec``); the restic worker runs on a daemon @@ -35,7 +35,7 @@ def import_qt() -> tuple[Any, Any, Any]: except ImportError as e: raise ImportError( "neither PySide6 nor PyQt6 is installed; " - "pip install 'cloud-sync[qt]' or pip install PySide6" + "pip install PySide6 (or pip install 'cloud-sync[qt]')" ) from e @@ -143,7 +143,7 @@ class QtProgressWindow: _apply_prism_dark(self._app) self._dialog = QtWidgets.QDialog() - self._dialog.setWindowTitle("Cloud sync") + self._dialog.setWindowTitle("Instance sync") self._dialog.setFixedSize(520, 240) self._dialog.setStyleSheet(_PROGRESS_QSS) self._dialog.setWindowFlag( @@ -161,7 +161,7 @@ class QtProgressWindow: badge.setPixmap(icons.svg_pixmap(icons.SYNC_BADGE_SVG, 32)) badge.setFixedSize(32, 32) header.addWidget(badge) - self._title_label = QtWidgets.QLabel("CLOUD SYNC") + self._title_label = QtWidgets.QLabel("INSTANCE SYNC") self._title_label.setObjectName("title") header.addWidget(self._title_label) header.addStretch(1) @@ -326,7 +326,7 @@ def prompt_login_qt() -> str | None: _apply_prism_dark(app) dialog = QtWidgets.QDialog() - dialog.setWindowTitle("Cloud sync — connect account") + dialog.setWindowTitle("Instance sync — connect account") dialog.setFixedSize(560, 360) dialog.setStyleSheet(_LOGIN_QSS) @@ -340,16 +340,16 @@ def prompt_login_qt() -> str | None: badge.setPixmap(icons.svg_pixmap(icons.PLUS_BADGE_SVG, 32)) badge.setFixedSize(32, 32) header.addWidget(badge) - title = QtWidgets.QLabel("CONNECT CLOUD SAVE") + title = QtWidgets.QLabel("CONNECT TO THE NETWORK") title.setObjectName("title") header.addWidget(title) header.addStretch(1) outer.addLayout(header) body = QtWidgets.QLabel( - "To enable cross-machine save sync, message the Discord bot to " - "register this account. The bot will DM you a one-line token — " - "paste it below." + "To sync this instance across machines, register on the Timemachine " + "Network. Message the Discord bot — it will DM you a one-line token. " + "Paste it below." ) body.setObjectName("body") body.setWordWrap(True) @@ -380,7 +380,7 @@ def prompt_login_qt() -> str | None: outer.addStretch(1) foot = QtWidgets.QHBoxLayout() - skip = QtWidgets.QPushButton("Skip cloud sync") + skip = QtWidgets.QPushButton("Skip instance sync") skip.setObjectName("secondary") foot.addWidget(skip) foot.addStretch(1) @@ -479,7 +479,7 @@ def prompt_conflict_qt( Args: local_modified: human-readable "Saturday, February 12 at 12:28 AM" - remote_modified: same, but for the cloud snapshot + remote_modified: same, but for the Timemachine Network snapshot save_label: noun phrase for body copy (e.g. "Minecraft save"). Returns one of: ``"keep_local"``, ``"use_remote"``, ``"cancel"``. @@ -527,7 +527,7 @@ def prompt_conflict_qt( self.clicked.emit() dialog = QtWidgets.QDialog() - dialog.setWindowTitle("Cloud sync — conflict") + dialog.setWindowTitle("Instance sync — conflict") dialog.setFixedSize(640, 460) dialog.setStyleSheet(_CONFLICT_QSS) @@ -541,16 +541,17 @@ def prompt_conflict_qt( warning.setPixmap(icons.svg_pixmap(icons.WARNING_BADGE_SVG, 32)) warning.setFixedSize(32, 32) header.addWidget(warning) - title = QtWidgets.QLabel("CLOUD CONFLICT") + title = QtWidgets.QLabel("INSTANCE CONFLICT") title.setObjectName("title") header.addWidget(title) header.addStretch(1) outer.addLayout(header) body = QtWidgets.QLabel( - f"Your local {save_label} conflicts with what is stored in the cloud. " - f"Whichever save data you choose to keep will be synced to this device " - f"and the cloud. The option you choose not to keep will be overwritten." + f"Your local {save_label} conflicts with what is stored on the " + f"Timemachine Network. Whichever save data you choose to keep will " + f"be synced to this device and the network. The option you choose " + f"not to keep will be overwritten." ) body.setObjectName("body") body.setWordWrap(True) @@ -560,7 +561,7 @@ def prompt_conflict_qt( cloud_card = _Card( icons.svg_pixmap(icons.CLOUD_SVG, 32), - "Cloud Save", + "Remote Save", f"Modified {remote_modified}", ) local_card = _Card(