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.
New module cloud_sync/icons.py:
- CLOUD_SVG, STORAGE_SVG: card glyphs (Material Icons, Apache-2.0)
- WARNING_BADGE_SVG, PLUS_BADGE_SVG, SYNC_BADGE_SVG: header badges
(hand-rolled — circle + vector primitives, no glyph font dependency)
- svg_pixmap(svg, size): QSvgRenderer-backed rasteriser; dual-binding
helper falls back from PySide6 to PyQt6.QtSvg.
Wired into ui_qt.py:
- Conflict dialog: warning badge SVG (light disc + dark !), cloud
card SVG, storage card SVG. Drops the QSS circle hack — entire
badge is one pixmap now.
- Login dialog: plus badge SVG (Prism-green disc + dark +).
- Progress window: sync arrow badge SVG (Prism-green disc + circular
arrow + head).
QSS shed: no more bg/border-radius/font-size acrobatics for badges
since the SVG includes the disc. Card icons are crisp at any DPI.
52 tests still green; pyz grows ~5 KB for the SVG strings + the new
icons module.
state.py: per-instance sync state. <pack>/.cloud-sync/state.json
(mode 600) records last_pulled_snapshot_id + last_pulled_at +
host_tag. Versioned schema. clear() on remote-empty.
sync.pull decision tree (replaces the unconditional restore):
no token file
→ prompt_login_qt; on Skip return 0 (don't block launch)
no state + remote empty
→ no-op
no state + remote non-empty
→ restore (first-run on this machine)
state.id == remote.id
→ skip restore (up to date)
state.id != remote.id, no in-scope local edits since state.at
→ restore (fast-forward)
state.id != remote.id, in-scope local edits since state.at
→ prompt_conflict_qt
keep_local → don't restore; push will overwrite cloud
use_remote → restore + update state
cancel → exit 1
sync.push: --json output parsed for snapshot_id; state.json updated
to that id after a successful backup. Skips silently if no token.
_find_modified_in_scope: walks include roots, filters via
_matches_any (restic-style globs: dir/, **/dir/, **/*.glob).
Stops at 50 hits; we only need 'any' + a sample for the dialog.
_format_dt: hand-rolled (no GNU-vs-Windows strftime quirks) →
'Thursday, October 21, 2021 at 7:12 PM'.
Restic JSON parsing helpers: _parse_snapshots, _parse_restic_time
(handles nanosecond precision), _parse_backup_summary.
tests/test_state.py: 19 new tests covering state read/write, scope-
aware mtime walk, exclude glob matching, restic output parsers.
Total: 52 green.
Same skeleton: Prism-green badge ('↻') + uppercase title in the
header row, status text under it, indeterminate progress bar (dark
inset with Prism-green chunk), Cancel button bottom-right styled
as the shared 'secondary' button.
Bumped from 440x160 to 520x240 so the header reads at the same
weight as the other two dialogs. title.upper() applied in run_with
so callers can keep passing 'Cloud sync — pulling' without manual
uppercasing.
Three dialogs now share one visual language: Prism dark surface
(#313131), Prism-green accent (#96db59), monospace for code/data,
circled badge in the top-left of every header.
Same skeleton as the conflict dialog: header (Prism-green badge +
uppercase title), body copy, content, foot row.
Specifics:
- badge: '+' on Prism-green circle (vs the conflict's '!' on
light gray) — green reads as 'setup / new', not warning
- command callout: monospace, green text, dark inset card
- token input: monospace, dark inset, green focus border, green
selection highlight
- 'Skip cloud sync' = secondary (dark surface, green hover border)
- 'Save and continue' = primary (Prism-green fill, black text)
Same Enter-to-submit + format validation behavior as before;
on-screen error reuses Prism's BrightText red.
Same Steam-Cloud-Conflict layout (header glyph + uppercase title +
two card-buttons + Cancel) but recolored to Prism's palette:
background #313131 (Prism Window)
cards #2a2a2a (Prism AlternateBase)
card hover #383838 + #96db59 border (Prism Highlight green)
cancel button #303030 + #96db59 border on hover
Warning glyph keeps the light circle (#d6dde6) for high contrast
against the dark surface — gives the same 'pay attention' weight
without forcing a different theme just for one dialog.
Replaces the radio+continue layout with a Steam Cloud Conflict
lookalike: uppercase title with circled-! glyph, three-line body
copy, two large card-buttons (Cloud Save / Local Save) each showing
icon + label + 'Modified <date>', Cancel bottom-right. Card click
commits the choice and dismisses — no separate Continue button.
Background switched to Steam-blue (#1b2838) instead of Prism dark
so the dialog reads as a 'pay attention' moment rather than another
status pane.
Card glyphs are unicode placeholders (☁ / ▢) — easy SVG swap later
if we want crisp HDD/cloud icons.
API change:
prompt_conflict_qt(local_summary, remote_summary)
→ prompt_conflict_qt(local_modified, remote_modified, save_label)
Caller now passes pre-formatted timestamp strings + an optional
noun phrase ('Minecraft save').
Login dialog (prompt_login_qt):
Modal asking for a discord_id:password token. Echo masked. Validates
format before accepting. 'Skip cloud sync' returns None so caller can
bypass sync without blocking the launch.
Conflict dialog (prompt_conflict_qt):
Modal with three radio options — keep_local, use_remote, cancel.
Shown when local files were modified since the last pulled snapshot
AND the remote has a newer snapshot from another host (divergence
state.json detection happens in sync.pull; not wired yet).
Both reuse the Prism dark palette via _apply_prism_dark. Tk fallbacks
not implemented — Qt is the path most players will hit.
Next: state.json (last_pulled_snapshot_id), divergence detection in
sync.pull, integration with these dialogs.
cloud-sync now ships a real Qt UI alongside the tkinter fallback.
Architecture:
- HeadlessProgress: --no-gui path, plain stdout
- TkProgressWindow: stdlib fallback when Qt isn't installed
- QtProgressWindow: preferred path; supports both PySide6 and PyQt6
(interchangeable APIs for our subset)
The factory in ui.py picks Qt → tkinter → headless. Tk stays so the
zipapp still works on bare Python with no extras.
Threading: QApplication runs on the main thread (started by run_with
via QDialog.exec). The restic worker runs on a daemon threading.Thread.
Cross-thread UI updates go via a Signal on a bridge QObject so Qt
auto-marshals them onto the main thread via a queued connection.
Cancellation: WM close + Cancel button both set a flag. sync.pull/push
pass ui.is_cancelled as restic.run's cancel_check; the subprocess gets
killed and returns -1 → exit 1.
Theme: Fusion style + Prism's dark palette (RGB values copied as facts
from PrismLauncher's DarkTheme.cpp). Override with PRISM_THEME=off.
Pyz size went 20 KB → 36 KB (added ui.py + ui_qt.py).
33 tests still green.
Reasons stacked up:
- AV: unsigned JARs that auto-download binaries + upload files trigger
Windows Defender false-positives more often than Python scripts
invoked by code-signed python.exe.
- Qt UI option: PySide6 opens a path to a real Qt UI (matching Prism's
look) if needed later. JVM Qt bindings are abandoned.
- frazclient already needs Python; inlining as 'import cloud_sync' is
zero overhead vs the launcher always shelling out to java.
Implementation:
- cloud_sync package: cli.py (argparse), creds.py, scope.py,
restic.py (binary discovery + auto-download + sha256 verify),
sync.py (pull/push subprocess restic).
- pyproject.toml with hatchling backend; pip-installable.
- Makefile builds cloud-sync.pyz via python -m zipapp (~53 KB).
- 33 pytest tests, stdlib only on runtime.
- CI workflow runs pytest matrix (3.10/3.11/3.12) + builds pyz.
- DESIGN.md + README.md updated to reflect Python.
E2E verified against local restic-rest-server:
pull empty → push initial → rm -rf local → pull restores → modify+push
creates second snapshot → client forget --prune blocked by --append-only.
Throws away ~565 LOC of Kotlin (and 18 jar tests) committed earlier in
this same session. Net result is ~250 LOC Python + 33 tests = smaller
and more aligned with the rest of the stack.
- Server-side prune via per-repo operator master key (restic key add at provision time)
- Auto-download restic on first run (packwiz-installer-bootstrap pattern)
- Nightly prune at 04:00 UTC via in-process time.Ticker
- Per-caller provisioning tokens (CLOUD_PROVISIONING_TOKENS_<NAME>) for audit attribution; NOT shared
Earlier draft archived cloud-svc entirely. Better shape: keep it as a
control plane for the restic backend. Two listeners in one process:
- provisioning :9091 on automc-net (called by discord-bot)
- operator :9092 on 127.0.0.1 (called by automc-setup wizard)
Players still hit restic-rest-server (data plane) directly with their
per-user password. cloud-svc never sits in the player data path —
limits its public exposure to zero.
cloud-svc was a worse re-implementation of what restic-rest-server
already does (--private-repos + --append-only + native retention +
chunk-level dedup). Pivoting before either ships in production.
cloud-sync.jar becomes a restic CLI wrapper. ~200 LOC instead of
~2000+ in the custom-server path. Server-side prune via operator
master password (option 1 — multi-key per repo).
Open questions flagged at end of doc for confirmation.
'installer' implied one-way install (matching packwiz-installer); this
tool is bidirectional sync (pull on launch, push on exit). Aligns with
the existing automc <scope>-<role> pattern: cloud-svc ↔ cloud-sync,
matching mc-router, mc-wrapper, gate-waker.
Gitea repo renamed via API (id 2974 preserved); jar filename now
cloud-sync-<version>.jar. Build verified post-rename.
Compiles to a 3.4 MB fat jar via shadow plugin.
Entrypoint with CLI dispatch (pull/push subcommands) + FlatLaf init.
Real sync logic stubbed; lands in subsequent commits per task plan.
Build via containerized gradle (8.10.2 + jdk21) to sidestep the
Kotlin 2.1 + JDK 26 compiler crash. Documented in README.
CI workflow tags-only release artifact upload prepared (needs
RELEASE_TOKEN secret in repo settings before first tag).