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.).
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user