rename: cloud sync -> instance sync; cloud -> Timemachine Network; drop Tk
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:
@@ -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 (`<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
|
||||
|
||||
```
|
||||
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 <pack-folder>/.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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+9
-9
@@ -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
|
||||
|
||||
+6
-5
@@ -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/<id>/ 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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+5
-4
@@ -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 ``<pack-folder>/.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
|
||||
``<pack-folder>/.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()
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+14
-14
@@ -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,
|
||||
)
|
||||
|
||||
+23
-167
@@ -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()
|
||||
|
||||
+19
-18
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user