rename: cloud sync -> instance sync; cloud -> Timemachine Network; drop Tk
CI / test (3.10) (push) Successful in 7s
CI / test (3.11) (push) Successful in 7s
CI / test (3.12) (push) Successful in 7s
CI / build-pyz (push) Successful in 4s
CI / release (push) Has been skipped

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.
This commit is contained in:
2026-06-05 01:14:02 +02:00
parent f1cb9f4b86
commit b31fdd023a
10 changed files with 99 additions and 232 deletions
+14 -10
View File
@@ -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.). 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 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 (`<INST_MC_DIR>/.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 `<INST_MC_DIR>/.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 ## CLI
``` ```
python cloud-sync.pyz {pull,push} \ 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) --pack-folder PATH Minecraft instance directory (default: cwd)
--token-file PATH override default <pack-folder>/.cloud-sync/token --token-file PATH override default <pack-folder>/.cloud-sync/token
--restic-binary PATH skip auto-discovery --restic-binary PATH skip auto-discovery
--no-download fail if no usable restic; don't fetch from upstream --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) ## 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) ## 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. 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. 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 ## Where the data lives
| Component | Role | Repo | | 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` | | `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 | | `restic-rest-server` (existing) | Timemachine Network 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` | | `discord-bot` | Calls cloud-svc on `/cloud register` to provision a player's Timemachine Network account. | `Timemachine/discord-bot` |
## License ## License
+5 -1
View File
@@ -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): Public API for in-process callers (e.g. frazclient):
import cloud_sync import cloud_sync
cloud_sync.pull(url="https://cloud.tm.center", pack_folder=Path("/instance")) cloud_sync.pull(url="https://cloud.tm.center", pack_folder=Path("/instance"))
cloud_sync.push(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 from .cli import Args
+9 -9
View File
@@ -27,17 +27,17 @@ class Args:
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser( p = argparse.ArgumentParser(
prog="cloud-sync", prog="instance-sync",
description="Per-user Minecraft state sync via restic.", 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) sub = p.add_subparsers(dest="cmd", required=True)
for name in ("pull", "push"): 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( sp.add_argument(
"--url", required=True, "--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( sp.add_argument(
"--pack-folder", default=".", type=Path, "--pack-folder", default=".", type=Path,
@@ -57,7 +57,7 @@ def build_parser() -> argparse.ArgumentParser:
) )
sp.add_argument( sp.add_argument(
"-g", "--no-gui", action="store_true", "-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 return p
@@ -93,13 +93,13 @@ def main(argv: list[str] | None = None) -> int:
action = {"pull": sync.pull, "push": sync.push}[cmd] action = {"pull": sync.pull, "push": sync.push}[cmd]
progress = ui.make_progress(headless=args.headless) 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: try:
return progress.run_with(lambda: action(args, progress), title) return progress.run_with(lambda: action(args, progress), title)
except KeyboardInterrupt: except KeyboardInterrupt:
print("cloud-sync: cancelled", file=sys.stderr) print("instance-sync: cancelled", file=sys.stderr)
return 1 return 1
except Exception as e: # noqa: BLE001 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 return 2
+6 -5
View File
@@ -3,8 +3,9 @@
Format: ``discord_id:password`` on a single line. Whitespace tolerated. Format: ``discord_id:password`` on a single line. Whitespace tolerated.
The Discord ID is the URL path segment under cloud.tm.center/<id>/ that The Discord ID is the URL path segment under cloud.tm.center/<id>/ that
restic-rest-server's --private-repos enforces against the basic-auth restic-rest-server's --private-repos enforces against the basic-auth
username. The password is the bcrypt'd entry's plaintext AND the restic username. The password is the bcrypt'd entry's plaintext — it covers HTTP
repo encryption password (cloud-svc provisions one password covering both). basic auth only (restic repos use --insecure-no-password). The Timemachine
Network control plane provisions the credential at /register time.
""" """
from __future__ import annotations from __future__ import annotations
@@ -19,14 +20,14 @@ class CredentialsError(Exception):
def read_credentials(token_file: Path) -> tuple[str, str]: def read_credentials(token_file: Path) -> tuple[str, str]:
if not token_file.exists(): if not token_file.exists():
raise CredentialsError( 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"After /register in Discord you should have received credentials; "
f"paste them into this file as 'discord_id:password' on one line." f"paste them into this file as 'discord_id:password' on one line."
) )
raw = token_file.read_text(encoding="utf-8").strip() raw = token_file.read_text(encoding="utf-8").strip()
if ":" not in raw: if ":" not in raw:
raise CredentialsError( 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)" f"(expected 'discord_id:password' on one line)"
) )
discord_id, password = raw.split(":", 1) discord_id, password = raw.split(":", 1)
@@ -34,7 +35,7 @@ def read_credentials(token_file: Path) -> tuple[str, str]:
password = password.strip() password = password.strip()
if not discord_id or not password: if not discord_id or not password:
raise CredentialsError( raise CredentialsError(
f"cloud-sync token at {token_file} malformed " f"instance-sync token at {token_file} malformed "
f"(empty discord_id or password)" f"(empty discord_id or password)"
) )
return discord_id, password return discord_id, password
+2 -2
View File
@@ -8,7 +8,7 @@ Discovery order:
The version is pinned because repos written by one restic version can have 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 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 from __future__ import annotations
@@ -203,7 +203,7 @@ def _download_restic_to(target: Path, plat: Platform) -> None:
f"{RELEASE_TAG}/SHA256SUMS" f"{RELEASE_TAG}/SHA256SUMS"
) )
print( print(
f"cloud-sync: downloading restic {RESTIC_VERSION} from {asset_url}", f"instance-sync: downloading restic {RESTIC_VERSION} from {asset_url}",
file=sys.stderr, file=sys.stderr,
) )
with tempfile.NamedTemporaryFile(suffix=f".{ext}", delete=False) as tmp: with tempfile.NamedTemporaryFile(suffix=f".{ext}", delete=False) as tmp:
+5 -4
View File
@@ -1,8 +1,9 @@
"""Per-distribution sync scope (include/exclude paths). """Per-distribution sync scope (include/exclude paths).
Each cloud-sync deployment ships its own ``scope.json`` that picks which Each instance-sync deployment ships its own ``scope.json`` that picks
files participate in sync. Lives at ``<pack-folder>/.cloud-sync/scope.json``. which files participate in sync. Lives at
Defaults are baked in so a fresh install with no scope.json works. ``<pack-folder>/.cloud-sync/scope.json``. Defaults are baked in so a
fresh install with no scope.json works.
""" """
from __future__ import annotations from __future__ import annotations
@@ -48,7 +49,7 @@ def load(pack_folder: Path) -> Scope:
data = json.loads(path.read_text(encoding="utf-8")) data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as e: except (OSError, json.JSONDecodeError) as e:
print( print(
f"cloud-sync: scope.json invalid ({e}); using defaults", f"instance-sync: scope.json invalid ({e}); using defaults",
file=sys.stderr, file=sys.stderr,
) )
return Scope() return Scope()
+2 -2
View File
@@ -24,7 +24,7 @@ SCHEMA_VERSION = 1
class State: class State:
last_pulled_snapshot_id: str last_pulled_snapshot_id: str
last_pulled_at: datetime last_pulled_at: datetime
host_tag: str = "cloud-sync" host_tag: str = "instance-sync"
def state_path(pack_folder: Path) -> Path: def state_path(pack_folder: Path) -> Path:
@@ -46,7 +46,7 @@ def read(pack_folder: Path) -> State | None:
return State( return State(
last_pulled_snapshot_id=data["last_pulled_snapshot_id"], last_pulled_snapshot_id=data["last_pulled_snapshot_id"],
last_pulled_at=_parse_iso(data["last_pulled_at"]), 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): except (KeyError, ValueError):
return None return None
+14 -14
View File
@@ -44,7 +44,7 @@ def pull(args: Args, progress: Progress | None = None) -> int:
if not args.token_file.exists(): if not args.token_file.exists():
if not _prompt_login_and_save(args, ui): if not _prompt_login_and_save(args, ui):
ui.set_status("Cloud sync skipped") ui.set_status("Cloud sync skipped")
print("cloud-sync: no token; skipping pull") print("instance-sync: no token; skipping pull")
return 0 return 0
ui.set_status("Reading credentials…") ui.set_status("Reading credentials…")
@@ -66,7 +66,7 @@ def pull(args: Args, progress: Progress | None = None) -> int:
return 1 return 1
if code != 0: if code != 0:
print( print(
f"cloud-sync: failed to list snapshots (restic exit {code})", f"instance-sync: failed to list snapshots (restic exit {code})",
file=sys.stderr, file=sys.stderr,
) )
return 2 return 2
@@ -75,7 +75,7 @@ def pull(args: Args, progress: Progress | None = None) -> int:
if not snapshots: if not snapshots:
statemod.clear(args.pack_folder) statemod.clear(args.pack_folder)
ui.set_status("No snapshots yet — nothing to pull") 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 return 0
remote = snapshots[0] remote = snapshots[0]
@@ -90,7 +90,7 @@ def pull(args: Args, progress: Progress | None = None) -> int:
decision = "use_remote" decision = "use_remote"
elif local_state.last_pulled_snapshot_id == remote_id: elif local_state.last_pulled_snapshot_id == remote_id:
ui.set_status("Cloud is up to date") ui.set_status("Cloud is up to date")
print("cloud-sync: already at latest snapshot") print("instance-sync: already at latest snapshot")
return 0 return 0
else: else:
modified = _find_modified_in_scope( 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 unavailable in headless mode → conservative: cancel
ui.set_status("Conflict detected; no UI available") ui.set_status("Conflict detected; no UI available")
print( print(
"cloud-sync: conflict detected (remote moved + local edits) " "instance-sync: conflict detected (remote moved + local edits) "
"but headless mode can't prompt; aborting", "but headless mode can't prompt; aborting",
file=sys.stderr, file=sys.stderr,
) )
@@ -115,7 +115,7 @@ def pull(args: Args, progress: Progress | None = None) -> int:
return 1 return 1
if decision == "keep_local": if decision == "keep_local":
ui.set_status("Keeping local; push will overwrite cloud on exit") 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 return 0
# decision == "use_remote" # decision == "use_remote"
@@ -135,7 +135,7 @@ def pull(args: Args, progress: Progress | None = None) -> int:
if code == -1: if code == -1:
return 1 return 1
if code != 0: 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 return 2
statemod.write( statemod.write(
@@ -146,7 +146,7 @@ def pull(args: Args, progress: Progress | None = None) -> int:
), ),
) )
ui.set_status("Pull complete") ui.set_status("Pull complete")
print("cloud-sync: pull ok") print("instance-sync: pull ok")
return 0 return 0
@@ -154,8 +154,8 @@ def push(args: Args, progress: Progress | None = None) -> int:
ui = progress or HeadlessProgress() ui = progress or HeadlessProgress()
if not args.token_file.exists(): if not args.token_file.exists():
ui.set_status("No cloud token; skipping push") ui.set_status("No network token; skipping push")
print("cloud-sync: no token; skipping push") print("instance-sync: no token; skipping push")
return 0 return 0
ui.set_status("Reading credentials…") ui.set_status("Reading credentials…")
@@ -177,7 +177,7 @@ def push(args: Args, progress: Progress | None = None) -> int:
"backup", "backup",
"--files-from", str(files_from), "--files-from", str(files_from),
"--exclude-file", str(exclude_from), "--exclude-file", str(exclude_from),
"--host", "cloud-sync", "--host", "instance-sync",
"--tag", "auto", "--tag", "auto",
"--json", "--json",
], ],
@@ -188,7 +188,7 @@ def push(args: Args, progress: Progress | None = None) -> int:
if code == -1: if code == -1:
return 1 return 1
if code != 0: 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 return 2
new_id = _parse_backup_summary(out) new_id = _parse_backup_summary(out)
@@ -201,7 +201,7 @@ def push(args: Args, progress: Progress | None = None) -> int:
), ),
) )
ui.set_status("Push complete") ui.set_status("Push complete")
print("cloud-sync: push ok") print("instance-sync: push ok")
return 0 return 0
@@ -311,7 +311,7 @@ def _prompt_login_and_save(args: Args, ui: Progress) -> bool:
except ImportError: except ImportError:
ui.set_status("No token and no UI; can't prompt") ui.set_status("No token and no UI; can't prompt")
print( print(
"cloud-sync: no token at " "instance-sync: no token at "
f"{args.token_file} and no Qt UI available", f"{args.token_file} and no Qt UI available",
file=sys.stderr, file=sys.stderr,
) )
+23 -167
View File
@@ -1,25 +1,22 @@
"""Progress UI for cloud-sync operations. """Progress UI for instance-sync operations.
Two implementations sharing the ``Progress`` protocol: Two implementations sharing the ``Progress`` protocol:
- :class:`HeadlessProgress` — no window; prints to stdout/stderr. Used when - :class:`HeadlessProgress` — no window; prints to stdout/stderr. Used when
``--no-gui`` is set or when tkinter import fails. ``--no-gui`` is set OR when Qt isn't available (the only graphical path
- :class:`TkProgressWindow` — tkinter modal window with status text + is Qt; there is no tkinter fallback).
indeterminate progress bar + Cancel button. Stdlib only. - :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 The factory :func:`make_progress` picks Qt → Headless. Qt requires
worker thread. The window polls every 100 ms to check whether the worker PySide6 or PyQt6 to be importable. Install via
finished and whether the user clicked Cancel. ``pip install 'cloud-sync[qt]'`` or directly
``pip install PySide6``.
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.
""" """
from __future__ import annotations from __future__ import annotations
import sys import sys
import threading
from typing import Callable, Protocol from typing import Callable, Protocol
@@ -31,16 +28,11 @@ class Progress(Protocol):
def run_with(self, worker: Callable[[], int], title: str) -> int: ... def run_with(self, worker: Callable[[], int], title: str) -> int: ...
# ---------------------------------------------------------------------------
# Headless (text-only)
# ---------------------------------------------------------------------------
class HeadlessProgress: class HeadlessProgress:
"""No-op progress. Status messages go to stdout, errors to stderr.""" """No-op progress. Status messages go to stdout, errors to stderr."""
def set_status(self, msg: str) -> None: 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: def is_cancelled(self) -> bool:
return False return False
@@ -50,147 +42,13 @@ class HeadlessProgress:
return worker() 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: 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: Order:
1. Qt (PySide6 or PyQt6) — modern look, matches Prism's aesthetic. 1. Qt window (PySide6 or PyQt6) — preferred when available.
2. tkinter — stdlib fallback; ships with most Python distributions. 2. HeadlessProgress — fallback when ``--no-gui`` is set or Qt is
3. headless — print to stdout/stderr only. missing. Logs to stdout/stderr.
Override via ``--no-gui`` (forces headless).
""" """
if headless: if headless:
return HeadlessProgress() return HeadlessProgress()
@@ -198,17 +56,15 @@ def make_progress(headless: bool) -> Progress:
from .ui_qt import QtProgressWindow from .ui_qt import QtProgressWindow
return QtProgressWindow() return QtProgressWindow()
except ImportError: except ImportError:
pass
except Exception as e: # noqa: BLE001
print( print(
f"cloud-sync: Qt init failed ({e}); falling back to tkinter", "instance-sync: Qt (PySide6/PyQt6) not installed; "
file=sys.stderr, "running headless. Install with: pip install PySide6",
) file=sys.stderr,
try: )
return TkProgressWindow() return HeadlessProgress()
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
print( print(
f"cloud-sync: tkinter unavailable ({e}); falling back to headless", f"instance-sync: Qt init failed ({e}); falling back to headless",
file=sys.stderr, file=sys.stderr,
) )
return HeadlessProgress() return HeadlessProgress()
+19 -18
View File
@@ -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 Supports both PySide6 (preferred — LGPL, official Qt binding) and PyQt6
(fallback — GPL/commercial). Same code runs on both because their (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 This module never imports Qt at top level. ``import_qt()`` raises
ImportError if neither binding is available; the factory in ``ui.py`` 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 Threading model: ``QApplication`` runs on the main thread (started by
``run_with`` via ``QDialog.exec``); the restic worker runs on a daemon ``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: except ImportError as e:
raise ImportError( raise ImportError(
"neither PySide6 nor PyQt6 is installed; " "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 ) from e
@@ -143,7 +143,7 @@ class QtProgressWindow:
_apply_prism_dark(self._app) _apply_prism_dark(self._app)
self._dialog = QtWidgets.QDialog() self._dialog = QtWidgets.QDialog()
self._dialog.setWindowTitle("Cloud sync") self._dialog.setWindowTitle("Instance sync")
self._dialog.setFixedSize(520, 240) self._dialog.setFixedSize(520, 240)
self._dialog.setStyleSheet(_PROGRESS_QSS) self._dialog.setStyleSheet(_PROGRESS_QSS)
self._dialog.setWindowFlag( self._dialog.setWindowFlag(
@@ -161,7 +161,7 @@ class QtProgressWindow:
badge.setPixmap(icons.svg_pixmap(icons.SYNC_BADGE_SVG, 32)) badge.setPixmap(icons.svg_pixmap(icons.SYNC_BADGE_SVG, 32))
badge.setFixedSize(32, 32) badge.setFixedSize(32, 32)
header.addWidget(badge) header.addWidget(badge)
self._title_label = QtWidgets.QLabel("CLOUD SYNC") self._title_label = QtWidgets.QLabel("INSTANCE SYNC")
self._title_label.setObjectName("title") self._title_label.setObjectName("title")
header.addWidget(self._title_label) header.addWidget(self._title_label)
header.addStretch(1) header.addStretch(1)
@@ -326,7 +326,7 @@ def prompt_login_qt() -> str | None:
_apply_prism_dark(app) _apply_prism_dark(app)
dialog = QtWidgets.QDialog() dialog = QtWidgets.QDialog()
dialog.setWindowTitle("Cloud sync — connect account") dialog.setWindowTitle("Instance sync — connect account")
dialog.setFixedSize(560, 360) dialog.setFixedSize(560, 360)
dialog.setStyleSheet(_LOGIN_QSS) 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.setPixmap(icons.svg_pixmap(icons.PLUS_BADGE_SVG, 32))
badge.setFixedSize(32, 32) badge.setFixedSize(32, 32)
header.addWidget(badge) header.addWidget(badge)
title = QtWidgets.QLabel("CONNECT CLOUD SAVE") title = QtWidgets.QLabel("CONNECT TO THE NETWORK")
title.setObjectName("title") title.setObjectName("title")
header.addWidget(title) header.addWidget(title)
header.addStretch(1) header.addStretch(1)
outer.addLayout(header) outer.addLayout(header)
body = QtWidgets.QLabel( body = QtWidgets.QLabel(
"To enable cross-machine save sync, message the Discord bot to " "To sync this instance across machines, register on the Timemachine "
"register this account. The bot will DM you a one-line token " "Network. Message the Discord bot — it will DM you a one-line token. "
"paste it below." "Paste it below."
) )
body.setObjectName("body") body.setObjectName("body")
body.setWordWrap(True) body.setWordWrap(True)
@@ -380,7 +380,7 @@ def prompt_login_qt() -> str | None:
outer.addStretch(1) outer.addStretch(1)
foot = QtWidgets.QHBoxLayout() foot = QtWidgets.QHBoxLayout()
skip = QtWidgets.QPushButton("Skip cloud sync") skip = QtWidgets.QPushButton("Skip instance sync")
skip.setObjectName("secondary") skip.setObjectName("secondary")
foot.addWidget(skip) foot.addWidget(skip)
foot.addStretch(1) foot.addStretch(1)
@@ -479,7 +479,7 @@ def prompt_conflict_qt(
Args: Args:
local_modified: human-readable "Saturday, February 12 at 12:28 AM" 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"). save_label: noun phrase for body copy (e.g. "Minecraft save").
Returns one of: ``"keep_local"``, ``"use_remote"``, ``"cancel"``. Returns one of: ``"keep_local"``, ``"use_remote"``, ``"cancel"``.
@@ -527,7 +527,7 @@ def prompt_conflict_qt(
self.clicked.emit() self.clicked.emit()
dialog = QtWidgets.QDialog() dialog = QtWidgets.QDialog()
dialog.setWindowTitle("Cloud sync — conflict") dialog.setWindowTitle("Instance sync — conflict")
dialog.setFixedSize(640, 460) dialog.setFixedSize(640, 460)
dialog.setStyleSheet(_CONFLICT_QSS) dialog.setStyleSheet(_CONFLICT_QSS)
@@ -541,16 +541,17 @@ def prompt_conflict_qt(
warning.setPixmap(icons.svg_pixmap(icons.WARNING_BADGE_SVG, 32)) warning.setPixmap(icons.svg_pixmap(icons.WARNING_BADGE_SVG, 32))
warning.setFixedSize(32, 32) warning.setFixedSize(32, 32)
header.addWidget(warning) header.addWidget(warning)
title = QtWidgets.QLabel("CLOUD CONFLICT") title = QtWidgets.QLabel("INSTANCE CONFLICT")
title.setObjectName("title") title.setObjectName("title")
header.addWidget(title) header.addWidget(title)
header.addStretch(1) header.addStretch(1)
outer.addLayout(header) outer.addLayout(header)
body = QtWidgets.QLabel( body = QtWidgets.QLabel(
f"Your local {save_label} conflicts with what is stored in the cloud. " f"Your local {save_label} conflicts with what is stored on the "
f"Whichever save data you choose to keep will be synced to this device " f"Timemachine Network. Whichever save data you choose to keep will "
f"and the cloud. The option you choose not to keep will be overwritten." f"be synced to this device and the network. The option you choose "
f"not to keep will be overwritten."
) )
body.setObjectName("body") body.setObjectName("body")
body.setWordWrap(True) body.setWordWrap(True)
@@ -560,7 +561,7 @@ def prompt_conflict_qt(
cloud_card = _Card( cloud_card = _Card(
icons.svg_pixmap(icons.CLOUD_SVG, 32), icons.svg_pixmap(icons.CLOUD_SVG, 32),
"Cloud Save", "Remote Save",
f"Modified {remote_modified}", f"Modified {remote_modified}",
) )
local_card = _Card( local_card = _Card(