fe26ed309c
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.
262 lines
8.0 KiB
Python
262 lines
8.0 KiB
Python
"""restic binary discovery + auto-download + invocation.
|
|
|
|
Discovery order:
|
|
1. ``--restic-binary <path>`` (explicit override)
|
|
2. ``<pack-folder>/.cloud-sync/restic-<RESTIC_VERSION>`` (pinned cached copy)
|
|
3. ``$PATH`` (only if version matches RESTIC_VERSION)
|
|
4. download from GitHub releases (unless ``--no-download``)
|
|
|
|
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.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import bz2
|
|
import hashlib
|
|
import os
|
|
import platform
|
|
import re
|
|
import shutil
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import urllib.request
|
|
import zipfile
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Callable
|
|
|
|
from .cli import Args
|
|
|
|
|
|
RESTIC_VERSION = "0.18.0"
|
|
RELEASE_TAG = f"v{RESTIC_VERSION}"
|
|
|
|
_HTTP_TIMEOUT = 30
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Platform:
|
|
os_tag: str # linux, darwin, windows
|
|
arch_tag: str # amd64, arm64
|
|
is_windows: bool
|
|
|
|
|
|
def _detect_platform() -> Platform:
|
|
name = platform.system().lower()
|
|
arch_raw = platform.machine().lower()
|
|
if name.startswith("linux"):
|
|
os_tag = "linux"
|
|
elif name.startswith("darwin"):
|
|
os_tag = "darwin"
|
|
elif name.startswith("windows"):
|
|
os_tag = "windows"
|
|
else:
|
|
raise RuntimeError(f"unsupported platform: {name}")
|
|
|
|
if arch_raw in ("amd64", "x86_64"):
|
|
arch_tag = "amd64"
|
|
elif arch_raw in ("aarch64", "arm64"):
|
|
arch_tag = "arm64"
|
|
else:
|
|
raise RuntimeError(f"unsupported architecture: {arch_raw}")
|
|
return Platform(os_tag, arch_tag, os_tag == "windows")
|
|
|
|
|
|
def _binary_filename(plat: Platform) -> str:
|
|
suffix = ".exe" if plat.is_windows else ""
|
|
return f"restic-{RESTIC_VERSION}{suffix}"
|
|
|
|
|
|
def resolve_binary(args: Args) -> Path:
|
|
"""Return a usable restic binary path, downloading if needed."""
|
|
if args.restic_binary is not None:
|
|
if not args.restic_binary.exists():
|
|
raise FileNotFoundError(
|
|
f"--restic-binary path does not exist: {args.restic_binary}"
|
|
)
|
|
return args.restic_binary
|
|
|
|
plat = _detect_platform()
|
|
cache_dir = args.pack_folder / ".cloud-sync"
|
|
cached = cache_dir / _binary_filename(plat)
|
|
if cached.exists() and os.access(cached, os.X_OK):
|
|
return cached
|
|
|
|
# Try $PATH only when version matches exactly
|
|
system = _find_system_binary_matching_version()
|
|
if system is not None:
|
|
return system
|
|
|
|
if not args.allow_download:
|
|
raise RuntimeError(
|
|
f"no usable restic binary at {cached} or on $PATH, "
|
|
f"and --no-download disabled auto-fetch"
|
|
)
|
|
|
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
_download_restic_to(cached, plat)
|
|
return cached
|
|
|
|
|
|
def run(
|
|
binary: Path,
|
|
args: list[str],
|
|
env: dict[str, str] | None = None,
|
|
cwd: Path | None = None,
|
|
timeout: int = 900,
|
|
cancel_check: Callable[[], bool] | None = None,
|
|
) -> tuple[int, str]:
|
|
"""Run restic. Inherits stderr to caller's terminal for live progress.
|
|
Returns (returncode, captured_stdout).
|
|
|
|
When cancel_check is supplied, polls every 100 ms; if it returns True,
|
|
kills restic and returns ``(-1, "")``.
|
|
"""
|
|
merged_env = dict(os.environ)
|
|
if env:
|
|
merged_env.update(env)
|
|
|
|
if cancel_check is None:
|
|
p_run = subprocess.run( # noqa: S603
|
|
[str(binary), *args],
|
|
cwd=str(cwd) if cwd else None,
|
|
env=merged_env,
|
|
stdout=subprocess.PIPE,
|
|
stderr=sys.stderr,
|
|
text=True,
|
|
timeout=timeout,
|
|
check=False,
|
|
)
|
|
return p_run.returncode, p_run.stdout
|
|
|
|
# Cancel-capable path: spawn + poll
|
|
p = subprocess.Popen( # noqa: S603
|
|
[str(binary), *args],
|
|
cwd=str(cwd) if cwd else None,
|
|
env=merged_env,
|
|
stdout=subprocess.PIPE,
|
|
stderr=sys.stderr,
|
|
text=True,
|
|
)
|
|
deadline = time.monotonic() + timeout
|
|
while p.poll() is None:
|
|
if cancel_check():
|
|
p.kill()
|
|
try:
|
|
p.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
return -1, ""
|
|
if time.monotonic() > deadline:
|
|
p.kill()
|
|
try:
|
|
p.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
raise subprocess.TimeoutExpired([str(binary), *args], timeout)
|
|
time.sleep(0.1)
|
|
out = p.stdout.read() if p.stdout else ""
|
|
return p.returncode, out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# discovery + download helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _find_system_binary_matching_version() -> Path | None:
|
|
name = "restic.exe" if _detect_platform().is_windows else "restic"
|
|
found = shutil.which(name)
|
|
if not found:
|
|
return None
|
|
path = Path(found)
|
|
return path if _binary_version(path) == RESTIC_VERSION else None
|
|
|
|
|
|
def _binary_version(path: Path) -> str | None:
|
|
try:
|
|
out = subprocess.run( # noqa: S603
|
|
[str(path), "version"],
|
|
capture_output=True, text=True, timeout=5, check=False,
|
|
)
|
|
except (subprocess.SubprocessError, OSError):
|
|
return None
|
|
text = out.stdout + out.stderr
|
|
m = re.search(r"restic\s+(\d+\.\d+\.\d+)", text)
|
|
return m.group(1) if m else None
|
|
|
|
|
|
def _download_restic_to(target: Path, plat: Platform) -> None:
|
|
ext = "zip" if plat.is_windows else "bz2"
|
|
asset = f"restic_{RESTIC_VERSION}_{plat.os_tag}_{plat.arch_tag}.{ext}"
|
|
asset_url = (
|
|
f"https://github.com/restic/restic/releases/download/"
|
|
f"{RELEASE_TAG}/{asset}"
|
|
)
|
|
sums_url = (
|
|
f"https://github.com/restic/restic/releases/download/"
|
|
f"{RELEASE_TAG}/SHA256SUMS"
|
|
)
|
|
print(
|
|
f"cloud-sync: downloading restic {RESTIC_VERSION} from {asset_url}",
|
|
file=sys.stderr,
|
|
)
|
|
with tempfile.NamedTemporaryFile(suffix=f".{ext}", delete=False) as tmp:
|
|
tmp_path = Path(tmp.name)
|
|
try:
|
|
with urllib.request.urlopen(asset_url, timeout=_HTTP_TIMEOUT) as r:
|
|
tmp_path.write_bytes(r.read())
|
|
expected = _expected_sha(sums_url, asset)
|
|
actual = _sha256_file(tmp_path)
|
|
if expected.lower() != actual.lower():
|
|
raise RuntimeError(
|
|
f"restic download sha mismatch: expected {expected}, got {actual}"
|
|
)
|
|
_decompress_into(tmp_path, target, ext)
|
|
if not plat.is_windows:
|
|
target.chmod(target.stat().st_mode | stat.S_IXUSR | stat.S_IRUSR | stat.S_IWUSR)
|
|
finally:
|
|
tmp_path.unlink(missing_ok=True)
|
|
|
|
|
|
def _expected_sha(sums_url: str, asset: str) -> str:
|
|
with urllib.request.urlopen(sums_url, timeout=_HTTP_TIMEOUT) as r:
|
|
body = r.read().decode("utf-8")
|
|
for line in body.splitlines():
|
|
line = line.strip()
|
|
if line.endswith(asset):
|
|
return line.split()[0]
|
|
raise RuntimeError(f"restic SHA256SUMS missing entry for {asset}")
|
|
|
|
|
|
def _sha256_file(path: Path) -> str:
|
|
h = hashlib.sha256()
|
|
with path.open("rb") as f:
|
|
for chunk in iter(lambda: f.read(65536), b""):
|
|
h.update(chunk)
|
|
return h.hexdigest()
|
|
|
|
|
|
def _decompress_into(archive: Path, target: Path, ext: str) -> None:
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
if ext == "bz2":
|
|
with bz2.open(archive, "rb") as src, target.open("wb") as dst:
|
|
shutil.copyfileobj(src, dst)
|
|
elif ext == "zip":
|
|
with zipfile.ZipFile(archive) as zf:
|
|
inner = next(
|
|
(n for n in zf.namelist() if n.endswith("restic.exe")),
|
|
None,
|
|
)
|
|
if inner is None:
|
|
raise RuntimeError("restic.exe not found in downloaded zip")
|
|
with zf.open(inner) as src, target.open("wb") as dst:
|
|
shutil.copyfileobj(src, dst)
|
|
else:
|
|
raise RuntimeError(f"unsupported archive ext: {ext}")
|