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
+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):
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
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
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
(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(