From ba68dc76c82e898a07b3bfc38dbad28731dfabb8 Mon Sep 17 00:00:00 2001 From: spoopyghosty0 <336754034+spoopyghosty0@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:59:56 -0400 Subject: [PATCH] Self-update for the app and the CLI FramePort finds new GitHub releases by itself and updates in one click: sidebar card + Library bar, a dialog with the release notes, "Update and restart" (download, SHA256SUMS check, Windows signer check, swap script, relaunch), optional automatic installs at the next start. CLI: --version, `frameport update [--check] [--yes]` and a once-a-day hint. One version source (_version.py); releases also ship the CLI wheel and "What's new" from the annotated tag; CI runs an update smoke test with each OS's archive. --- .github/workflows/build.yml | 16 +- CLAUDE.md | 29 +- README.md | 4 +- docs/ARCHITECTURE.md | 21 ++ docs/INSTALL.md | 25 ++ pyproject.toml | 5 +- scripts/package.py | 16 ++ scripts/ui_smoke.py | 10 + scripts/update_smoke.py | 63 ++++ src/frameport/__init__.py | 2 +- src/frameport/_version.py | 2 + src/frameport/cli.py | 86 +++++- src/frameport/core/applog.py | 7 +- src/frameport/core/cache.py | 4 +- src/frameport/diag/bundle.py | 7 +- src/frameport/ui/app.py | 12 + src/frameport/ui/help.py | 4 + src/frameport/ui/updater.py | 234 +++++++++++++++ src/frameport/ui/views/library.py | 13 +- src/frameport/ui/views/settings.py | 55 +++- src/frameport/updates.py | 445 +++++++++++++++++++++++++++++ tests/test_updates.py | 215 ++++++++++++++ 22 files changed, 1246 insertions(+), 29 deletions(-) create mode 100644 scripts/update_smoke.py create mode 100644 src/frameport/_version.py create mode 100644 src/frameport/ui/updater.py create mode 100644 src/frameport/updates.py create mode 100644 tests/test_updates.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8bf7664..4c76e66 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -91,6 +91,9 @@ jobs: - name: Package (Linux) if: runner.os == 'Linux' run: tar czf ${{ matrix.archive }} -C dist --transform 's,^linux,FramePort,' linux + # self-update with this archive: unpack, verify the layout (+ the signer on Windows), run the real swap script + - name: Update smoke test + run: uv run python scripts/update_smoke.py ${{ matrix.archive }} - name: Attest build provenance if: github.event_name != 'pull_request' uses: actions/attest-build-provenance@v4 @@ -109,10 +112,13 @@ jobs: contents: write steps: - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v10.2.0 - uses: actions/download-artifact@v8 with: path: out merge-multiple: true + - name: Wheel (CLI install via uv tool / pipx / pip, and its self-update) + run: uv build --wheel -o out && ls out - name: Checksums run: cd out && sha256sum * > SHA256SUMS.txt && cat SHA256SUMS.txt - name: Publish release @@ -121,7 +127,15 @@ jobs: run: | tag="${GITHUB_REF_NAME}" notes=$(mktemp) + # "What's new" = the annotated tag's message (FramePort's update dialog shows it); checkout may leave the + # tag lightweight, so fetch the tag object itself + git fetch --force origin "refs/tags/${tag}:refs/tags/${tag}" + whatsnew=$(git tag -l --format='%(contents:body)' "$tag") + [ -n "$whatsnew" ] || whatsnew=$(git tag -l --format='%(contents:subject)' "$tag") { echo "FramePort ${tag}: Windows (x64), macOS (Apple Silicon) and Linux (x64)."; echo; - sed -n '/^## First launch/,$p' docs/INSTALL.md; } > "$notes" + if [ -n "$whatsnew" ] && [ "$(git cat-file -t "$tag")" = tag ]; then + echo "## What's new"; echo; echo "$whatsnew"; echo; fi + echo "Already have FramePort? It offers this update itself (Library → **Update now**, or \`frameport update\`)." + echo; sed -n '/^## First launch/,$p' docs/INSTALL.md; } > "$notes" gh release delete "$tag" --yes 2>/dev/null || true gh release create "$tag" out/* packaging/FramePort-selfsigned.cer --title "FramePort ${tag}" --notes-file "$notes" diff --git a/CLAUDE.md b/CLAUDE.md index 82d9cc2..b98562c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,6 +111,25 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive), chars). "Share working config" → `working-config.yml` issue → maintainer label `catalog-accepted` → `catalog-from-issue.yml` workflow (`scripts/catalog_from_issue.py` validates) opens a catalog PR. App log: `core/applog.py` (`/logs/app.log`, finished GUI jobs in `/logs/jobs/`). + - Self-update (docs/ARCHITECTURE.md "Self-update", docs/INSTALL.md "Updating"): `_version.py` = the only version + (`frameport.__version__`; pyproject reads it via hatch `dynamic`; app log, diagnostics, User-Agent, Settings use + it — never `importlib.metadata`, bundles have no dist-info). `updates.py` (no Flet): `check()` = GitHub + releases/latest via `cached_json("app-release.json")`, 6 h, skips drafts/prereleases and the user's skipped version + (settings `update.last_check`/`skipped`/`auto_check`/`auto_install`/`cli_hint`; env `FRAMEPORT_NO_UPDATE_CHECK`); + `install_kind()` = `bundle` (psutil exe path → FramePort.exe's folder / FramePort.app / FramePort/FramePort), + `source` (`.git` checkout: `git pull --ff-only` + `uv sync`, refused on a dirty tree), `wheel` (`uv tool`/pipx/pip + reinstall of the release's `.whl`); `prepare()` → `/updates//` (SHA256SUMS.txt required + checked, + layout checked, Windows: the new FramePort.exe must have the running exe's Authenticode signer when that one is + signed, `ready.json`); `apply()` writes + starts detached `apply.ps1`/`apply.sh` (wait for pid → Windows: copy over, + replaced files kept in `updates//previous` because the zip has no top folder; macOS/Linux: mv to `.old` + swap, + rollback, `xattr -dr` quarantine → relaunch; `/logs/update.log`). `ui/updater.py`: background check (10 s, + then 6 h), sidebar card (hidden control, toggled), Library bar (`library_bar`), notes dialog (`ft.Markdown`), job + `app-update` that restarts only when no other job is active, `apply_pending_at_start()` in `main()` for + "Install updates automatically"; Settings → Updates. CLI: `--version`, `frameport update [--check (exit 10)] + [--yes]`, once-a-day stderr hint read from the cache only (`refresh_cache()` in a daemon thread writes no + settings, to avoid read-modify-write races). `scripts/update_smoke.py ` runs the real extract + swap script + (no relaunch) — CI runs it on all three OS with the archive it just built. + Verified: Linux + Windows (native, signer check) smoke with the v0.2.0 bundles; macOS only via the CI smoke. - `agent/frameport_agent.py` — runs **on the Frame** (python3 stdlib only), JSON over SSH. Owns the install layout, launch.sh template, Steam shortcuts (binary VDF), launch tests. Bump `AGENT_VERSION` when changing it. - `bootstrap/bootstrap.sh` — one-time Frame setup served by the pairing server (sshd, app key, avahi service, Lepton). @@ -138,7 +157,7 @@ Repo is on an NTFS drive (`core.fileMode=false`); line endings are LF (`.gitattr - Device checks: `frameport test ` / `frameport parity-device --results --baseline [--test-only]`; the pre-FramePort baseline is `PATCHED/_known-good-2026-09-28/_frame-state/baseline-launch.txt`. - GUI smoke test: `uv pip install flet-web playwright && playwright install chromium`, then - `FRAMEPORT_HOME= python scripts/ui_smoke.py --out [--game ] [--frame steamos@]` and look + `FRAMEPORT_HOME= python scripts/ui_smoke.py --out [--game ] [--frame steamos@] [--update]` (`--update` = fake release: banner, dialog, Settings → Updates) and look at the PNGs. Flet 1.0 notes: `ft.run` must own the main thread; background work via `page.run_thread`; FilePicker is awaited (`await ft.FilePicker().get_directory_path()`); dialogs via `page.show_dialog/pop_dialog`; running from source needs `flet-desktop` (declared) and web mode needs `flet-web`. @@ -306,7 +325,13 @@ crash): use PC versions. ## Releases, CI, GitHub Public repo `github.com/spoopyghosty0/frameport` (branch `main`). Push a `v*` tag → CI (`.github/workflows/build.yml`) tests, builds Windows x64 / macOS arm64 / Linux x64 bundles, signs, attests and publishes a GitHub Release -(`FramePort-*.zip/.tar.gz`, `SHA256SUMS.txt`, `FramePort-selfsigned.cer`, notes from `docs/INSTALL.md`). v0.1.0 exists. +(`FramePort-*.zip/.tar.gz`, the CLI wheel `frameport--py3-none-any.whl`, `SHA256SUMS.txt`, +`FramePort-selfsigned.cer`; notes = "What's new" from the annotated tag message + `docs/INSTALL.md` from "First launch"). +Installed apps find the release themselves (self-update), so the notes are what users see in the update dialog. +- **Release checklist:** bump `src/frameport/_version.py` (the only version; `scripts/package.py` fails a tag build + whose tag ≠ `v<_version>`), commit, `git tag -a vX.Y.Z -m "FramePort X.Y.Z" -m ""`, + push the commit and the tag. Never publish a release without its `SHA256SUMS.txt` (the updater refuses it) and keep + the asset names (`updates.ASSETS`) — renaming them breaks updating for every installed copy. - Signing is **free/self-signed by the owner's choice** (no paid certs, no SignPath): Windows binaries are signed with a self-signed "FramePort (self-signed)" code-signing cert (RSA 3072, valid to 2031, SHA-256 `4E:12:98:91:62:C0:E4:50:FB:65:1D:34:BB:73:00:09:7B:78:BE:88:5C:A7:6C:42:23:46:9B:92:A1:59:A7:6E`); secrets diff --git a/README.md b/README.md index 731b63a..74846f7 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,9 @@ apksigner). Nothing is installed system-wide, and no Android SDK/NDK is needed. ## Install - **App bundles**: download from [Releases](https://github.com/spoopyghosty0/frameport/releases) (Windows x64, macOS Apple Silicon, Linux x64); see `docs/INSTALL.md` for the first-launch warning (self-signed / ad-hoc signed) and - how to verify a download. Built by CI (`.github/workflows/build.yml`, `flet build`). + how to verify a download. Built by CI (`.github/workflows/build.yml`, `flet build`). FramePort **updates itself**: + when a new release is out, click **Update now** in the Library (or run `frameport update`). +- **Command line only**: `uv tool install ` (or pipx / pip), then `frameport --help`. - **From source**: `uv sync && uv run frameport-gui` (CLI: `uv run frameport --help`). ## Status of tested games diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 49457a6..04effbe 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -58,6 +58,27 @@ apart. "Build" = `pipeline.prepare_rift` (checks + Revive). Revive is a portable - **Native binaries** (`artifacts/`): edit `native/…`, run `native/build.sh`, commit the new artifacts + SHA256SUMS, run `frameport parity` to see which games change. +## Self-update (`updates.py`, `ui/updater.py`, `cli.py update`) +``` +check() GitHub releases/latest (cached 6 h, drafts/prereleases skipped, "skipped" version remembered) + │ → Update(version, notes = release body incl. "What's new" from the annotated tag, asset for this OS, sums, wheel) + ▼ +install_kind() bundle (running exe inside FramePort.exe's folder / FramePort.app / FramePort/FramePort) + │ source (git checkout) → git pull --ff-only + uv sync wheel (uv tool / pipx / pip) → reinstall wheel + ▼ bundle +prepare() /updates//: download → SHA256SUMS.txt check → extract to staged/ (ditto on macOS) → layout check + │ → Windows: same Authenticode signer as the running exe → ready.json + ▼ +apply() writes /updates/apply.{ps1,sh}, starts it detached; FramePort quits. The script waits for the pid, + Windows: backs up the files it replaces (updates//previous) and copies over the folder (the zip has + no folder of its own); macOS/Linux: mv target → .old, staged → target, rollback on failure, clears the + quarantine; then relaunches. Log: /logs/update.log. +``` +GUI: background check 10 s after start + every 6 h → sidebar card + Library bar → dialog with notes → job +`app-update` (waits for other jobs) → restart. "Install updates automatically": prepare in the background, apply in +`main()` before the window opens. CLI: once-a-day hint from the cached result (a daemon thread refreshes it), `frameport +update [--check] [--yes]`. CI runs `scripts/update_smoke.py` on every OS with the archive it just built. + ## Dynamic data (fetched live, cached, bundled fallback) overport release + patch list + patch titles (GitHub), Temurin JRE (Adoptium API), apksigner (Google repository index), store artwork/titles (overport image API), catalog (optional remote), Lepton location/appid (Frame appmanifests), diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 114fc87..2152a99 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -9,6 +9,11 @@ missing** once: FramePort downloads its own Java runtime, the overport CLI and a | Windows 10/11 (x64) | `FramePort-windows-x64.zip` | `FramePort.exe` | | macOS (Apple Silicon) | `FramePort-macos-arm64.zip` | `FramePort.app` | | Linux (x64) | `FramePort-linux-x64.tar.gz` | `FramePort/FramePort` (needs GTK 3) | +| Command line only (any OS with Python 3.11+) | `frameport--py3-none-any.whl` | `frameport --help` | + +The command-line version installs with [uv](https://docs.astral.sh/uv/) (or pipx / pip) straight from a release, e.g. +`uv tool install https://github.com/spoopyghosty0/frameport/releases/download/v0.3.0/frameport-0.3.0-py3-none-any.whl` +(copy the wheel's link from the release page). `frameport --version` shows the installed version. ## First launch @@ -25,6 +30,26 @@ not a paid one, so your OS warns the first time: `xattr -dr com.apple.quarantine FramePort.app`. - **Linux:** `tar xzf FramePort-linux-x64.tar.gz && ./FramePort/FramePort`. +## Updating +FramePort checks GitHub for a new release when it starts and every 6 hours (only the release information, nothing about +you is sent). When there is one, a **Update available** card appears in the sidebar and a bar at the top of the Library: +click **Update now** (or the card → **Update and restart**). FramePort downloads the new version, checks it against the +release's `SHA256SUMS.txt` (on Windows also that it carries the same signature as the running copy), closes, replaces +its program folder / `FramePort.app` and opens again as the new version. Your games, settings, signing keys and Frame +connection live in FramePort's data folder and are kept. If installs or uploads are running, the update waits until +they finish. **Later** hides that version (Settings → Updates → **Check for updates** still finds it). + +Settings → **Updates**: switch off the automatic check, or turn on **Install updates automatically** (downloads new +versions in the background and installs them the next time FramePort starts — no clicks at all). If FramePort's folder +isn't writable (e.g. under Program Files), Update now opens the release page instead; download and extract it as +above. The update's log is `logs/update.log` in FramePort's data folder; the replaced files of a Windows update are kept +in `updates//previous` until the next update. + +Command line: commands mention a new version at most once a day (on stderr). `frameport update` shows what's new and +updates (a bundle the same way as the app; a `uv tool`/pipx/pip install by reinstalling the release's wheel; a source +checkout with `git pull` + `uv sync`). `frameport update --check` only checks (exit code 10 = an update exists), +`--yes` skips the question. `FRAMEPORT_NO_UPDATE_CHECK=1` turns all checks off. + ## Oculus Rift (PC VR) games Scan a folder of Rift games (one folder per game; the game may sit a few levels down, next to installers and archives) the same way as Quest dumps, or use **Add games → Add one game folder…**. FramePort finds the program that starts diff --git a/pyproject.toml b/pyproject.toml index fe67af4..b3e13e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "frameport" -version = "0.2.0" +dynamic = ["version"] description = "Port Meta Quest standalone games to the Valve Steam Frame (Lepton): suggested patches, automated validation, one-click install." readme = "README.md" license = { text = "GPL-3.0-only" } @@ -31,6 +31,9 @@ frameport-gui = "frameport.ui.app:main" requires = ["hatchling"] build-backend = "hatchling.build" +[tool.hatch.version] +path = "src/frameport/_version.py" # the single source of the version + [tool.hatch.build.targets.wheel] packages = ["src/frameport"] diff --git a/scripts/package.py b/scripts/package.py index 11bd070..811eb0e 100644 --- a/scripts/package.py +++ b/scripts/package.py @@ -11,7 +11,9 @@ downloads and manages them on first run (Tools page), so bundles stay small and from __future__ import annotations import argparse +import os import platform +import re import shutil import subprocess import sys @@ -22,6 +24,18 @@ DATA = ROOT / "src/frameport/_data" TARGET = {"Windows": "windows", "Darwin": "macos", "Linux": "linux"}[platform.system()] +def version() -> str: + text = (ROOT / "src/frameport/_version.py").read_text(encoding="utf-8") + return re.search(r'__version__\s*=\s*"([^"]+)"', text).group(1) + + +def check_tag() -> None: + """A release build (tag vX.Y.Z) must carry that version, or the app's update check would compare wrongly.""" + ref = os.environ.get("GITHUB_REF_NAME", "") + if os.environ.get("GITHUB_REF_TYPE") == "tag" and ref.startswith("v") and ref[1:] != version(): + raise SystemExit(f"tag {ref} doesn't match src/frameport/_version.py ({version()}): bump it first") + + def stage_data(): shutil.rmtree(DATA, ignore_errors=True) for name in ("catalog", "artifacts", "agent", "bootstrap"): @@ -34,6 +48,8 @@ def main() -> int: ap.add_argument("--pyinstaller", action="store_true") ap.add_argument("--keep-data", action="store_true", help="leave src/frameport/_data after building") args = ap.parse_args() + check_tag() + print(f"FramePort {version()}") stage_data() try: if args.pyinstaller: diff --git a/scripts/ui_smoke.py b/scripts/ui_smoke.py index eb885f3..c3a55b9 100644 --- a/scripts/ui_smoke.py +++ b/scripts/ui_smoke.py @@ -44,6 +44,7 @@ def main() -> int: ap.add_argument("--no-test", action="store_true", help="with --frame: skip the launch-test job") ap.add_argument("--scale", type=float, default=1.0, help="UI scale to render at (e.g. 1.5)") ap.add_argument("--viewport", default="1280x820", help="browser size, e.g. 2560x1440") + ap.add_argument("--update", action="store_true", help="pretend a new FramePort release exists (update UI)") args = ap.parse_args() from frameport.ui import theme @@ -60,6 +61,15 @@ def main() -> int: # last: the right-click menu stays open over whatever comes next steps.append(("library-menu", lambda a: (a.page.pop_dialog(), a.navigate(0), time.sleep(3), a.library_view.open_menu(game)))) + if args.update: + from frameport import updates + + fake = updates.Update(version="9.9.9", tag="v9.9.9", page="https://example.invalid", asset=None, asset_url=None, + sums_url=None, wheel_url=None, + notes="## What's new\n- Self-update test release\n- **Bold** and `code` in notes") + steps += [("update-banner", lambda a: (a.updater._set(fake), a.navigate(0))), + ("update-dialog", lambda a: a.updater.show_dialog()), + ("update-settings", lambda a: (a.page.pop_dialog(), a.navigate(2)))] if args.frame: from frameport.frame.connection import parse_target diff --git a/scripts/update_smoke.py b/scripts/update_smoke.py new file mode 100644 index 0000000..6ee5a6e --- /dev/null +++ b/scripts/update_smoke.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""CI smoke test of FramePort's self-update on this OS, with the archive this job just built: + + python scripts/update_smoke.py FramePort-. + +It unpacks the archive twice (an "installed" copy, plus an older-looking copy of it with a stale marker file), then +runs the real update path: frameport.updates._extract (ditto on macOS) + the real swap script (PowerShell on Windows, +/bin/sh elsewhere), without relaunching. It checks the installed copy now has the new files, and on Windows that the +signer check sees the same certificate on both copies. Exits non-zero on any failure. +""" +from __future__ import annotations + +import os +import shutil +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + + +def main() -> int: + archive = Path(sys.argv[1]).resolve() + home = Path(tempfile.mkdtemp(prefix="fp-update-smoke-")) + os.environ["FRAMEPORT_HOME"] = str(home / "data") + from frameport import updates + + platform = sys.platform + new = updates._extract(archive, home / "staged", platform) + installed_parent = home / "Programs" + installed_parent.mkdir() + installed = installed_parent / new.name + old = updates._extract(archive, home / "old", platform) + shutil.move(str(old), str(installed)) + stale = installed / ("Contents/stale.txt" if platform == "darwin" else "stale.txt") + stale.write_text("from the old version") + marker = new / ("Contents/new-version.txt" if platform == "darwin" else "new-version.txt") + marker.write_text("new") + sub = next(p for p in sorted(marker.parent.iterdir()) if p.is_dir() and not p.is_symlink()) + nested = sub / "new-version.txt" # files in existing subfolders must be replaced too (Windows merges folders) + nested.write_text("new") + if platform == "win32" and os.environ.get("HAS_CERT") == "true": + a, b = updates._signer_thumbprint(installed / "FramePort.exe"), updates._signer_thumbprint(new / "FramePort.exe") + print(f"signer thumbprints: installed {a} new {b}") + assert a and a == b, "signature check failed" + script = updates.apply(new, installed, relaunch=False, pid=999999, platform=platform, wait=True) + log = (home / "data/logs/update.log").read_text() + print(script.read_text()[:400], "...\n--- update.log ---\n" + log) + installed_marker = installed / marker.relative_to(new) + assert installed_marker.read_text() == "new", "the new files weren't installed" + assert (installed / nested.relative_to(new)).read_text() == "new", "a subfolder wasn't updated" + assert "installed" in log and "FAILED" not in log, "the update script reported a failure" + exe = {"win32": installed / "FramePort.exe", "darwin": installed / "Contents/MacOS"}.get(platform, + installed / "FramePort") + assert exe.exists(), f"{exe} missing after the update" + if platform != "win32": # Windows copies over the old files (the zip has no folder of its own); others swap + assert not stale.exists(), "the old version's files are still there" + print("update smoke test passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/frameport/__init__.py b/src/frameport/__init__.py index febab76..c35f40b 100644 --- a/src/frameport/__init__.py +++ b/src/frameport/__init__.py @@ -1,6 +1,6 @@ """FramePort: port Meta Quest standalone games to the Valve Steam Frame.""" -__version__ = "0.1.0" +from ._version import __version__ # noqa: F401 (single source of the version) # Where the project lives (Settings → About, problem reports). Change it here if the repo moves. REPO_URL = "https://github.com/spoopyghosty0/frameport" diff --git a/src/frameport/_version.py b/src/frameport/_version.py new file mode 100644 index 0000000..6482fc7 --- /dev/null +++ b/src/frameport/_version.py @@ -0,0 +1,2 @@ +# The FramePort version: the single source (pyproject reads it via hatch; CI checks a release tag matches it). +__version__ = "0.3.0" diff --git a/src/frameport/cli.py b/src/frameport/cli.py index 912e735..659993c 100644 --- a/src/frameport/cli.py +++ b/src/frameport/cli.py @@ -42,11 +42,95 @@ app.add_typer(pc_app, name="pc") app.add_typer(diag_app, name="diag") +def _show_version(value: bool): + if value: + from . import __version__ + + typer.echo(f"FramePort {__version__}") + raise typer.Exit() + + @app.callback() -def _setup(): +def _setup(ctx: typer.Context, + version: bool = typer.Option(False, "--version", is_eager=True, callback=_show_version, + help="Show the FramePort version and exit.")): from .core import applog applog.setup("cli") + if ctx.invoked_subcommand != "update": + _update_hint() + + +def _update_hint() -> None: + """At most once a day: a one-line note when a newer FramePort release exists. Never waits for the network: it + reads what the last check cached and refreshes a stale cache in the background for next time.""" + import threading + import time + + from . import updates + + if updates.checks_disabled(): + return + age = updates.cache_age() + if age is None or age > updates.CHECK_EVERY: + threading.Thread(target=updates.refresh_cache, daemon=True).start() + up = updates.cached_update() + today = time.strftime("%Y-%m-%d") + if up and library.setting("update.cli_hint") != today: + library.set_setting("update.cli_hint", today) + typer.echo(f"FramePort {up.version} is available (you have {_version()}): run \"frameport update\"", err=True) + + +def _version() -> str: + from . import __version__ + + return __version__ + + +@app.command("update") +def update_cmd(check: bool = typer.Option(False, "--check", help="Only say whether a newer version exists."), + yes: bool = typer.Option(False, "--yes", "-y", help="Don't ask before updating.")): + """Update FramePort to the latest release (the app, a `uv tool`/pip install, or a source checkout).""" + import subprocess + + from . import updates + + up = updates.check(force=True) + if not up: + typer.echo(f"FramePort {_version()} is the latest version.") + return + typer.echo(f"FramePort {up.version} is available (you have {_version()}). {up.page}") + notes = [line for line in up.notes.strip().splitlines()][:25] + if notes: + typer.echo("\n" + "\n".join(" " + line for line in notes) + "\n") + if check: + raise typer.Exit(10) # scripts: 10 = an update is available + kind = updates.install_kind() + if kind == "source" and updates.source_is_dirty(): + typer.echo("This source checkout has uncommitted changes: commit or stash them first.", err=True) + raise typer.Exit(1) + if not yes and not typer.confirm(f"Update now ({kind})?", default=True): + raise typer.Exit() + if kind == "bundle": + rep = printing_reporter() + app_dir = updates.prepare(up, rep) + updates.apply(app_dir, relaunch=False) + typer.echo(f"FramePort {up.version} installs as soon as this command exits.") + return + cmds = updates.upgrade_commands(up, kind) + if kind == "wheel" and sys.platform == "win32": + # the running frameport.exe can't be replaced while it runs: finish the upgrade right after this exits + line = " && ".join(subprocess.list2cmdline(c) for c in cmds) + subprocess.Popen(f'cmd /c "timeout /t 2 /nobreak >nul && {line}"', creationflags=0x00000008 | 0x00000200, + close_fds=True) + typer.echo(f"Updating to FramePort {up.version} in the background: run `frameport --version` in a few seconds.") + return + for cmd in cmds: + typer.echo("$ " + " ".join(cmd)) + if subprocess.call(cmd): + typer.echo("Update failed.", err=True) + raise typer.Exit(1) + typer.echo(f"Updated to FramePort {up.version}.") def _target(frame: Optional[str], password: Optional[str] = None, to: str = "frame"): diff --git a/src/frameport/core/applog.py b/src/frameport/core/applog.py index a69a946..ea409ff 100644 --- a/src/frameport/core/applog.py +++ b/src/frameport/core/applog.py @@ -53,12 +53,9 @@ def setup(component: str = "app") -> None: def _version() -> str: - try: - from importlib.metadata import version + from .. import __version__ - return version("frameport") - except Exception: # noqa: BLE001 - return "dev" + return __version__ def save_job_log(kind: str, package: str | None, state: str, text: str) -> Path | None: diff --git a/src/frameport/core/cache.py b/src/frameport/core/cache.py index cf4ebd5..29503b8 100644 --- a/src/frameport/core/cache.py +++ b/src/frameport/core/cache.py @@ -13,7 +13,9 @@ import requests from .paths import user_data_dir -USER_AGENT = "FramePort/0.1 (+https://github.com/ovrport/app compatible)" +from .. import REPO_URL, __version__ + +USER_AGENT = f"FramePort/{__version__} (+{REPO_URL})" _session = requests.Session() _session.headers["User-Agent"] = USER_AGENT diff --git a/src/frameport/diag/bundle.py b/src/frameport/diag/bundle.py index d5415a9..e802966 100644 --- a/src/frameport/diag/bundle.py +++ b/src/frameport/diag/bundle.py @@ -67,12 +67,9 @@ def _read_tail(path: Path, limit: int = MAX_LOG) -> str: def _version() -> str: - try: - from importlib.metadata import version + from .. import __version__ - return version("frameport") - except Exception: # noqa: BLE001 - return "dev" + return __version__ class _Writer: diff --git a/src/frameport/ui/app.py b/src/frameport/ui/app.py index c2751d6..acbcd5e 100644 --- a/src/frameport/ui/app.py +++ b/src/frameport/ui/app.py @@ -47,6 +47,9 @@ class FramePortApp: self.exe_queue: list[str] = [] # games whose executable the user should confirm (after a scan) self._failures: list[Job] = [] # failed installs/tests, shown together when the queue is done self.jobs = JobManager(self._on_job) + from .updater import Updater + + self.updater = Updater(self) # new FramePort releases (sidebar card, Library bar, one-click update) page.title = "FramePort" T.apply(page) @@ -71,6 +74,7 @@ class FramePortApp: padding=ft.Padding(T.S2, T.S2, 0, T.S5)), self.nav_col, ft.Container(expand=True), + self.updater.card, self.activity_card, self.conn_card, ], spacing=T.S2), width=T.px(236), bgcolor=T.SIDEBAR, padding=T.S4, @@ -82,6 +86,7 @@ class FramePortApp: self.go("welcome" if needed() else "library") threading.Thread(target=self._startup, daemon=True).start() threading.Thread(target=self._poll, daemon=True).start() + self.updater.start() # ================================================================== shell def top_bar(self, heading: str, subtitle: str = "", actions: list[ft.Control] | None = None) -> ft.Control: @@ -278,6 +283,9 @@ class FramePortApp: self.activity.refresh() if job and job.state in ("done", "failed", "cancelled") and job.finished and not getattr(job, "_handled", 0): job._handled = 1 + if job.kind == "app-update" and job.state != "done": + self.updater.restart = None # the update didn't get ready: don't quit + self.updater.on_jobs_changed() # an update waiting for the queue to empty installs now if job.kind in ("install", "test", "uninstall", "tool-frame") and self.target: self.refresh_frame(quiet=True) # re-renders when the Frame's list changed (e.g. after an uninstall) if job.kind == "install" and job.package and job.package in self._records(): @@ -1391,6 +1399,10 @@ def main(argv=None): applog.setup("gui") from ..core import library + from .updater import apply_pending_at_start + + if apply_pending_at_start(): # "Install updates automatically": the new version starts instead of this one + return T.set_scale(T.scale_from_setting(library.setting("ui.scale", "auto"))) # before any view is built applog.log.info("ui scale %.2f", T.SCALE) ft.run(lambda page: FramePortApp(page), assets_dir=assets_dir()) diff --git a/src/frameport/ui/help.py b/src/frameport/ui/help.py index 0069431..9895b9e 100644 --- a/src/frameport/ui/help.py +++ b/src/frameport/ui/help.py @@ -77,6 +77,10 @@ HELP: dict[str, str] = { "restart of the Frame.", "ui_scale": "Size of text and layout. Automatic follows Windows' display scaling halfway (150 % in Windows → 125 %) " "when FramePort runs under WSL, where the window doesn't get it from Windows itself.", + "app_updates": "FramePort checks GitHub for a new release every few hours and shows it in the sidebar and the " + "Library. \"Update now\" downloads it, checks its checksum (and on Windows its signature), closes " + "FramePort and opens the new version; your games, settings and Frame connection are kept. With " + "automatic install, the download happens in the background and the new version starts next time.", "update_all": "Updates every game marked \"Update ready\" (a newer build, or patch settings changed since it was " "installed), one after another.", "rescan": "Scans the folders you added games from again and adds games that appeared since. Games already in the " diff --git a/src/frameport/ui/updater.py b/src/frameport/ui/updater.py new file mode 100644 index 0000000..17054b0 --- /dev/null +++ b/src/frameport/ui/updater.py @@ -0,0 +1,234 @@ +"""The GUI side of self-update: finds new releases in the background, shows them (sidebar card, Library bar, a dialog +with the release notes) and updates in one click. frameport.updates does the work.""" +from __future__ import annotations + +import subprocess +import sys +import threading +import time +from typing import TYPE_CHECKING + +import flet as ft + +from .. import __version__, updates +from ..core import applog, library +from . import components as C +from . import theme as T + +if TYPE_CHECKING: + from .app import FramePortApp + +FIRST_CHECK_DELAY = 10 # seconds after start: don't compete with the start-up work + + +class Updater: + def __init__(self, app: "FramePortApp"): + self.app = app + self.found: updates.Update | None = None + self.restart = None # callable: what to do once the job queue is idle (install + quit) + self.preparing = False + self._version = C.meta("", color=T.ON_ACCENT) + self.card = ft.Container( + ft.Row([ft.Icon(ft.Icons.SYSTEM_UPDATE_ROUNDED, size=T.px(18), color=T.ON_ACCENT), + ft.Column([C.body("Update available", T.ON_ACCENT, weight=ft.FontWeight.W_600), self._version], + spacing=0, expand=True)], spacing=T.S2), + padding=T.S3, border_radius=T.RADIUS_SM, bgcolor=T.ACCENT, ink=True, visible=False, + tooltip="A new FramePort version is ready to install", on_click=lambda e: self.show_dialog()) + + # ---------------------------------------------------------------- checking + def start(self) -> None: + threading.Thread(target=self._loop, daemon=True).start() + + def _loop(self) -> None: + time.sleep(FIRST_CHECK_DELAY) + while True: + if not updates.checks_disabled(): + try: + self._set(updates.check()) + except Exception: # noqa: BLE001 + applog.log.exception("update check failed") + time.sleep(updates.CHECK_EVERY) + + def check_now(self) -> None: + """Settings → "Check for updates": always asks GitHub, also finds a version the user chose to skip.""" + def work(): + up = updates.check(force=True) + self._set(up) + if up: + self.app.page.run_thread(self.show_dialog) + else: + self.app.toast(f"FramePort {__version__} is the latest version") + self.app.run_bg(work) + + def _set(self, up: updates.Update | None) -> None: + self.found = up + self.card.visible = bool(up) + self._version.value = f"FramePort {up.version} · click to install" if up else "" + C.update(self.card) + self.app.refresh_view() + if up and library.setting("update.auto_install", False) and updates.install_kind() == "bundle": + self._prepare_quietly(up) + + def _prepare_quietly(self, up: updates.Update) -> None: + """"Install updates automatically": download + verify now, install on the next start (main()).""" + if self.preparing or (updates.ready_update() or ("",))[0] == up.version: + return + self.preparing = True + try: + updates.prepare(up) + self.app.toast(f"FramePort {up.version} is downloaded and installs the next time FramePort starts", + action="Restart now", on_action=lambda e: self.install()) + except Exception as exc: # noqa: BLE001 + applog.log.warning("automatic update download failed: %s", exc) + finally: + self.preparing = False + + # ---------------------------------------------------------------- the dialog + def show_dialog(self) -> None: + up = self.found + if not up: + return + page = self.app.page + kind = updates.install_kind() + how = {"bundle": "FramePort downloads it, checks it, closes and opens again as the new version. Your games, " + "settings and Frame connection stay as they are.", + "source": "Updates this source checkout (git pull + uv sync), then restarts FramePort.", + "wheel": "Reinstalls FramePort from the release, then restarts it."}[kind] + notes = up.notes.strip() or "No release notes." + + def later(e): + updates.skip(up.version) + self._set(None) + page.pop_dialog() + + def go(e): + page.pop_dialog() + self.install() + page.show_dialog(ft.AlertDialog( + title=ft.Text(f"FramePort {up.version} is available", color=T.TEXT, weight=ft.FontWeight.W_600), bgcolor=T.SURFACE_2, + content=ft.Container(ft.Column([ + C.body(f"You have {__version__}. {how}", T.TEXT_2), + ft.Container(ft.Markdown(notes, selectable=True, extension_set=ft.MarkdownExtensionSet.GITHUB_WEB, + md_style_sheet=_notes_style(), + on_tap_link=lambda e: page.launch_url(e.data)), + padding=T.S3, border_radius=T.RADIUS_SM, bgcolor=T.SURFACE, border=ft.Border.all(1, T.BORDER)), + ], spacing=T.S3, scroll=ft.ScrollMode.AUTO, tight=True, horizontal_alignment=ft.CrossAxisAlignment.STRETCH), + width=T.px(560), height=T.px(min(420, 130 + 26 * len(notes.splitlines())))), + actions=[C.ghost("Skip this version", on_click=later), + C.ghost("Release page", ft.Icons.OPEN_IN_NEW_ROUNDED, lambda e: page.launch_url(up.page)), + C.primary("Update and restart", ft.Icons.SYSTEM_UPDATE_ROUNDED, go)])) + + # ---------------------------------------------------------------- installing + def install(self) -> None: + up = self.found or updates.ready_update_info() + if not up: + return + kind = updates.install_kind() + page = self.app.page + if kind == "bundle": + target = updates.bundle_root() + if not target or not updates.can_replace(target): + self.app.toast(f"FramePort can't replace itself in {target or 'this folder'} (no permission): opening " + "the release page to download it", error=True) + page.launch_url(up.page) + return + if kind == "source" and updates.source_is_dirty(): + self.app.toast("This source checkout has uncommitted changes: commit or stash them, then update", error=True) + return + if page.web: + self.app.toast("Updating works in the desktop app") + return + + def run(job): + rep = job.reporter + if kind == "bundle": + ready = updates.ready_update() + app = ready[1] if ready and ready[0] == up.version else updates.prepare(up, rep) + self.restart = lambda: self._restart_bundle(app) + else: + for cmd in updates.upgrade_commands(up, kind): + rep.stage(" ".join(cmd[:3])) + out = subprocess.run(cmd, capture_output=True, text=True) + for line in (out.stdout + out.stderr).splitlines()[-20:]: + rep.log(line) + if out.returncode: + raise RuntimeError(f"{cmd[0]} failed ({out.returncode})") + self.restart = self._restart_process + rep.stage("Restarting") + return f"FramePort {up.version} is ready: restarting" + self.app.submit(f"Update FramePort to {up.version}", run, None, kind="app-update", open_panel=True) + if self.app.jobs.current() and self.app.jobs.current().kind != "app-update": + self.app.toast("FramePort updates and restarts after the current job") + + def on_jobs_changed(self) -> None: + """Called when a job finishes: once nothing else runs, install and restart.""" + if self.restart and not any(j.active for j in self.app.jobs.jobs): + restart, self.restart = self.restart, None + self.app.page.run_thread(restart) + + def _restart_bundle(self, app) -> None: + try: + updates.apply(app) + except Exception as exc: # noqa: BLE001 + self.app.toast(f"Update failed: {exc}", error=True) + return + self._quit() + + def _restart_process(self) -> None: + """Source/wheel installs: start a fresh FramePort, then close this one.""" + subprocess.Popen([sys.executable, "-c", "from frameport.ui.app import main; main()"], close_fds=True, + start_new_session=sys.platform != "win32", + creationflags=0x00000008 if sys.platform == "win32" else 0) + self._quit() + + def _quit(self) -> None: + async def close(): + try: + await self.app.page.window.close() + except Exception: # noqa: BLE001 + pass + self.app.page.run_task(close) + + +def _notes_style() -> ft.MarkdownStyleSheet: + """Release notes in the dark theme (Markdown's defaults are dark text).""" + def text(color=T.TEXT, size=14, weight=None, **kw): + return ft.TextStyle(color=color, size=T.px(size), weight=weight, **kw) + return ft.MarkdownStyleSheet( + p_text_style=text(T.TEXT_2), list_bullet_text_style=text(T.TEXT_2), + strong_text_style=text(weight=ft.FontWeight.W_600), em_text_style=text(T.TEXT_2, italic=True), + a_text_style=text(T.ACCENT), code_text_style=text(T.TEXT, 13, font_family="monospace"), + h1_text_style=text(size=18, weight=ft.FontWeight.W_600), h2_text_style=text(size=16, weight=ft.FontWeight.W_600), + h3_text_style=text(size=15, weight=ft.FontWeight.W_600), blockquote_text_style=text(T.TEXT_2)) + + +def library_bar(app: "FramePortApp") -> ft.Control | None: + """The Library's "update available" bar (None when there's nothing to show).""" + up = app.updater.found if getattr(app, "updater", None) else None + if not up: + return None + return C.callout(ft.Row([ + C.body(f"FramePort {up.version} is available (you have {__version__}).", T.TEXT, expand=True), + C.primary("Update now", ft.Icons.SYSTEM_UPDATE_ROUNDED, lambda e: app.updater.install()), + C.ghost("What's new", on_click=lambda e: app.updater.show_dialog()), + C.ghost("Later", on_click=lambda e: (updates.skip(up.version), app.updater._set(None))), + ], spacing=T.S3), "info", ft.Icons.SYSTEM_UPDATE_ROUNDED) + + +def apply_pending_at_start() -> bool: + """main(), before the window opens: with "Install updates automatically", a downloaded and verified newer version + is installed now (FramePort quits and the update script starts the new version). True = quit now.""" + try: + updates.cleanup_old() + if not library.setting("update.auto_install", False) or updates.install_kind() != "bundle": + return False + ready = updates.ready_update() + target = updates.bundle_root() + if not ready or not target or not updates.can_replace(target): + return False + applog.log.info("installing the downloaded update %s at start", ready[0]) + updates.apply(ready[1], target) + return True + except Exception: # noqa: BLE001 + applog.log.exception("installing the downloaded update failed; starting the current version") + return False diff --git a/src/frameport/ui/views/library.py b/src/frameport/ui/views/library.py index 6a24f35..5de0e60 100644 --- a/src/frameport/ui/views/library.py +++ b/src/frameport/ui/views/library.py @@ -145,6 +145,7 @@ class LibraryView: self.select_mode = False self.sel_bar = ft.Container(visible=False) self.resume_bar = ft.Container(visible=False) + self.update_bar = ft.Container(visible=False) # "FramePort x.y is available" (ui/updater.py) self.games: list[dict] = [] self._lock = threading.Lock() self._search_timer: threading.Timer | None = None @@ -187,7 +188,7 @@ class LibraryView: ft.Row([ft.Column([ft.Text("Library", size=T.T_TITLE, weight=ft.FontWeight.W_700, color=T.TEXT, no_wrap=True), self.subtitle], spacing=T.px(2), expand=True), self.search, self.update_all_btn, self.rescan_btn, self.select_btn, add], vertical_alignment=ft.CrossAxisAlignment.CENTER, spacing=T.S3), - self.resume_bar, self.hint, self.filters, self.body, self.sel_bar, + self.update_bar, self.resume_bar, self.hint, self.filters, self.body, self.sel_bar, ], expand=True, spacing=T.S4) # skeleton cards until the first batch arrives n = min(len(library.games()), 15) @@ -198,6 +199,7 @@ class LibraryView: def mount(self) -> ft.Control: self._update_hint() self.update_resume_bar() + self.update_update_bar() self._update_sel_bar() return self.root @@ -213,6 +215,7 @@ class LibraryView: pc = set(local_installs()) tw = twins(games) self._update_header(games, pc) + self.update_update_bar() if not games: self.body.content = C.empty_state( ft.Icons.LIBRARY_ADD_ROUNDED, "Add your games", @@ -297,6 +300,14 @@ class LibraryView: border=ft.Border.all(1, T.ACCENT)) C.update(self.sel_bar, self.select_btn) + def update_update_bar(self) -> None: + from ..updater import library_bar + + bar = library_bar(self.app) + self.update_bar.visible = bar is not None + self.update_bar.content = bar + C.update(self.update_bar) + def update_resume_bar(self) -> None: """Installs that didn't finish (cancelled, failed, or the app closed): resume them from here.""" app = self.app diff --git a/src/frameport/ui/views/settings.py b/src/frameport/ui/views/settings.py index 7a3e8f8..6dffcea 100644 --- a/src/frameport/ui/views/settings.py +++ b/src/frameport/ui/views/settings.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING import flet as ft -from ... import REPO_URL +from ... import REPO_URL, __version__ from ...core.paths import user_data_dir from ...recommend import catalog from .. import components as C @@ -18,6 +18,9 @@ TOOL_TITLES = {"java": "Java runtime", "overport": "overport", "apksigner": "apk TOOL_WHY = {"java": "Runs overport and apksigner", "overport": "Converts Quest games to OpenXR", "apksigner": "Signs rebuilt games", "revive": "Runs Oculus Rift games on OpenXR"} +def switch_label() -> ft.TextStyle: + return ft.TextStyle(color=T.TEXT, size=T.px(14)) # Material's default is dark text + class SettingsView: def __init__(self, app: "FramePortApp"): @@ -75,7 +78,43 @@ class SettingsView: library.set_setting("install.launch_test", bool(e.control.value)) return ft.Switch(label="Launch test after installing on the Frame (starts the game once without the headset " "and checks its log)", value=bool(library.setting("install.launch_test", True)), - on_change=changed) + label_text_style=switch_label(), on_change=changed) + + def updates_card(self) -> ft.Control: + """Settings → Updates: FramePort's own updates (ui/updater.py, frameport/updates.py).""" + import time + + from ... import updates + from ...core import library + + app = self.app + last = library.setting("update.last_check") + when = time.strftime("%Y-%m-%d %H:%M", time.localtime(last)) if last else "never" + found = app.updater.found + status = (C.callout(ft.Row([C.body(f"FramePort {found.version} is available.", T.TEXT, expand=True), + C.primary("Update now", ft.Icons.SYSTEM_UPDATE_ROUNDED, + lambda e: app.updater.install())], spacing=T.S3), "info") + if found else C.meta(f"You have the latest version as of the last check ({when}).")) + + def auto_check(e): + library.set_setting("update.auto_check", bool(e.control.value)) + + def auto_install(e): + library.set_setting("update.auto_install", bool(e.control.value)) + kind = {"bundle": "the downloaded app", "source": "a source checkout (git pull + uv sync)", + "wheel": "an installed Python package (reinstalled from the release)"}[updates.install_kind()] + return ft.Column([ + ft.Row([C.kv("Installed", f"FramePort {__version__} · {kind}"), + ft.Container(expand=True), + C.secondary("Check for updates", ft.Icons.REFRESH_ROUNDED, lambda e: app.updater.check_now())], + vertical_alignment=ft.CrossAxisAlignment.CENTER), + status, + ft.Switch(label="Check for new versions automatically", value=bool(library.setting("update.auto_check", True)), + label_text_style=switch_label(), on_change=auto_check), + ft.Switch(label="Install updates automatically (downloads in the background, installs when FramePort " + "next starts)", value=bool(library.setting("update.auto_install", False)), + label_text_style=switch_label(), on_change=auto_install), + ], spacing=T.S3) def appearance(self) -> ft.Control: from ...core import library @@ -120,18 +159,14 @@ class SettingsView: self.pc.controls = [ft.Row([ft.ProgressRing(width=T.px(16), height=T.px(16), stroke_width=T.px(2), color=T.ACCENT), C.meta("Checking this PC…")], spacing=T.S2)] app.run_bg(self.fill_pc) - try: - from importlib.metadata import version - - ver = version("frameport") - except Exception: # noqa: BLE001 - ver = "dev" + from ... import __version__ as ver data = str(user_data_dir()) return ft.Column([ - app.top_bar("Settings", "Tools, this PC and where FramePort keeps its data"), + app.top_bar("Settings", "Updates, tools, this PC and where FramePort keeps its data"), + C.section("Updates", C.card(self.updates_card(), padding=T.S4), help="app_updates"), C.section("Tools", C.card(self.tools, padding=ft.Padding(T.S4, T.S2, T.S4, T.S2)), subtitle="FramePort manages its own copies; nothing is installed system-wide", - action=ft.Row([C.ghost("Check for updates", ft.Icons.UPDATE_ROUNDED, + action=ft.Row([C.ghost("Update tools", ft.Icons.UPDATE_ROUNDED, lambda e: app.update_tools(update=True)), C.secondary("Install missing", ft.Icons.DOWNLOAD_ROUNDED, lambda e: app.update_tools())], spacing=T.S2)), diff --git a/src/frameport/updates.py b/src/frameport/updates.py new file mode 100644 index 0000000..7f9660e --- /dev/null +++ b/src/frameport/updates.py @@ -0,0 +1,445 @@ +"""Self-update from GitHub releases (no Flet here: the GUI, the CLI and tests share it). + +How FramePort is installed decides how it updates (`install_kind()`): + bundle the app from a release archive (Windows FramePort.exe folder, macOS FramePort.app, Linux FramePort/ folder): + `prepare()` downloads this platform's archive, checks it against the release's SHA256SUMS.txt (and on Windows + that FramePort.exe has the same code-signing certificate as the running one), unpacks it into + /updates//; `apply()` starts a small script that waits for FramePort to exit, puts the new + version in place (Windows: copies the new files over the old ones, since the zip has no top folder of its + own; macOS/Linux: swaps the .app / folder, keeping the old one until the new one is in place), clears macOS + quarantine and starts the new version. Its log: /logs/update.log. + wheel installed with `uv tool install` / pipx / pip from the release's wheel: reinstall from the new wheel. + source a git checkout: `git pull --ff-only` + `uv sync`. +User data lives outside the program folder (core/paths.user_data_dir), so updates never touch libraries or settings. +""" +from __future__ import annotations + +import hashlib +import json +import os +import re +import shutil +import subprocess +import sys +import tarfile +import time +import zipfile +from dataclasses import asdict, dataclass +from pathlib import Path + +from . import REPO_URL, __version__ +from .core import cache, library +from .core.events import Reporter +from .core.paths import user_data_dir + +REPO = REPO_URL.removeprefix("https://github.com/").strip("/") +LATEST_API = f"https://api.github.com/repos/{REPO}/releases/latest" +CHECK_EVERY = 6 * 3600 # seconds between automatic checks +ASSETS = {"win32": "FramePort-windows-x64.zip", "darwin": "FramePort-macos-arm64.zip", + "linux": "FramePort-linux-x64.tar.gz"} +SUMS = "SHA256SUMS.txt" + + +class UpdateError(RuntimeError): + pass + + +@dataclass +class Update: + version: str # "0.3.0" + tag: str # "v0.3.0" + notes: str # release notes (markdown) + page: str # release page URL + asset: str | None # this platform's archive name, if the release has one + asset_url: str | None + sums_url: str | None + wheel_url: str | None + published: str = "" + + +# ------------------------------------------------------------------------------------------------- versions +def parse_version(text: str) -> tuple: + """(major, minor, patch, final) from "v1.2.3" / "1.2.3-rc1"; pre-releases sort before the final release.""" + m = re.match(r"v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(.*)$", (text or "").strip()) + if not m: + return (0, 0, 0, 0) + return (int(m.group(1)), int(m.group(2) or 0), int(m.group(3) or 0), 0 if m.group(4).strip() else 1) + + +def is_newer(candidate: str, current: str = __version__) -> bool: + return parse_version(candidate) > parse_version(current) + + +def platform_asset() -> str | None: + key = "linux" if sys.platform.startswith("linux") else sys.platform + return ASSETS.get(key) + + +def update_from_release(release: dict, asset_name: str | None = None) -> Update | None: + """The Update a GitHub "latest release" JSON describes (None for drafts/pre-releases or unusable data).""" + if not isinstance(release, dict) or release.get("draft") or release.get("prerelease") or not release.get("tag_name"): + return None + assets = {a.get("name"): a.get("browser_download_url") for a in release.get("assets") or [] if isinstance(a, dict)} + asset_name = asset_name if asset_name is not None else platform_asset() + wheel = next((u for n, u in assets.items() if n and n.endswith(".whl")), None) + tag = release["tag_name"] + return Update(version=tag.lstrip("v"), tag=tag, notes=release.get("body") or "", page=release.get("html_url") or "", + asset=asset_name if asset_name in assets else None, asset_url=assets.get(asset_name), + sums_url=assets.get(SUMS), wheel_url=wheel, published=release.get("published_at") or "") + + +# ------------------------------------------------------------------------------------------------- checking +def checks_disabled() -> bool: + """Automatic checks off (setting, or FRAMEPORT_NO_UPDATE_CHECK=1). "Check now" / `frameport update` still work.""" + return bool(os.environ.get("FRAMEPORT_NO_UPDATE_CHECK")) or not library.setting("update.auto_check", True) + + +def check(force: bool = False) -> Update | None: + """The newer release, or None (up to date, skipped by the user unless forced, or offline: never raises).""" + try: + data = cache.cached_json("app-release.json", LATEST_API, max_age=0 if force else CHECK_EVERY) + except Exception: # noqa: BLE001 + data = None + library.set_setting("update.last_check", time.time()) + up = update_from_release(data) if data else None + if not up or not is_newer(up.version): + return None + if not force and library.setting("update.skipped") == up.version: + return None + return up + + +def refresh_cache() -> None: + """Fetch the latest release into the cache only (no settings written: safe in a CLI background thread).""" + try: + cache.cached_json("app-release.json", LATEST_API, max_age=0) + except Exception: # noqa: BLE001 + pass + + +def cached_update() -> Update | None: + """What the last check found, without touching the network (for the CLI's one-line hint).""" + path = cache.cache_dir() / "app-release.json" + try: + up = update_from_release(json.loads(path.read_text(encoding="utf-8"))) + except (OSError, ValueError): + return None + if not up or not is_newer(up.version) or library.setting("update.skipped") == up.version: + return None + return up + + +def cache_age() -> float | None: + path = cache.cache_dir() / "app-release.json" + return time.time() - path.stat().st_mtime if path.exists() else None + + +def skip(version: str) -> None: + library.set_setting("update.skipped", version) + + +# ------------------------------------------------------------------------------------------------- how installed +def running_executable() -> Path | None: + try: + import psutil + + exe = psutil.Process().exe() + if exe: + return Path(exe) + except Exception: # noqa: BLE001 + pass + return Path(sys.executable) if sys.executable else None + + +def bundle_root(exe: Path | None = None, platform: str | None = None) -> Path | None: + """The installed app (Windows: the folder with FramePort.exe; macOS: FramePort.app; Linux: the folder with the + FramePort executable) when running from a release bundle, else None.""" + exe = exe or running_executable() + platform = platform or sys.platform + if not exe: + return None + if platform == "darwin": + for parent in [exe, *exe.parents]: + if parent.suffix == ".app" and parent.name.lower().startswith("frameport"): + return parent + return None + if platform == "win32": + return exe.parent if exe.name.lower() == "frameport.exe" else None + return exe.parent if exe.name == "FramePort" else None + + +def source_root() -> Path | None: + root = Path(__file__).resolve().parents[2] + return root if (root / ".git").exists() and (root / "pyproject.toml").exists() else None + + +def install_kind() -> str: + if bundle_root(): + return "bundle" + if source_root(): + return "source" + return "wheel" + + +def wheel_installer() -> str: + prefix = sys.prefix.replace("\\", "/").lower() + if "/uv/tools/" in prefix: + return "uv" + if "/pipx/venvs/" in prefix: + return "pipx" + return "pip" + + +def upgrade_commands(up: Update, kind: str | None = None) -> list[list[str]]: + """Commands that update a wheel or source install (bundles use prepare()/apply()).""" + kind = kind or install_kind() + if kind == "source": + root = str(source_root()) + return [["git", "-C", root, "pull", "--ff-only"], ["uv", "sync", "--project", root]] + if kind == "wheel": + if not up.wheel_url: + raise UpdateError(f"release {up.tag} has no wheel to install from") + tool = wheel_installer() + if tool == "uv": + return [["uv", "tool", "install", "--force", up.wheel_url]] + if tool == "pipx": + return [["pipx", "install", "--force", up.wheel_url]] + return [[sys.executable, "-m", "pip", "install", "--upgrade", up.wheel_url]] + raise UpdateError("bundles update with prepare()/apply()") + + +def source_is_dirty() -> bool: + root = source_root() + if not root: + return False + out = subprocess.run(["git", "-C", str(root), "status", "--porcelain", "--untracked-files=no"], + capture_output=True, text=True) + return bool(out.stdout.strip()) + + +# ------------------------------------------------------------------------------------------------- bundles +def updates_dir() -> Path: + path = user_data_dir() / "updates" + path.mkdir(parents=True, exist_ok=True) + return path + + +def parse_sums(text: str) -> dict[str, str]: + out = {} + for line in (text or "").splitlines(): + m = re.match(r"([0-9a-fA-F]{64})\s+\*?(.+?)\s*$", line) + if m: + out[m.group(2)] = m.group(1).lower() + return out + + +def _sha256(path: Path) -> str: + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest() + + +def _extract(archive: Path, dest: Path, platform: str) -> Path: + """Unpack and return the app inside (FramePort.app / the folder with FramePort[.exe]).""" + shutil.rmtree(dest, ignore_errors=True) + dest.mkdir(parents=True) + if platform == "darwin": + # ditto keeps the app bundle's symlinks and permissions (Python's zipfile doesn't) + subprocess.run(["ditto", "-x", "-k", str(archive), str(dest)], check=True) + app = next((p for p in dest.iterdir() if p.suffix == ".app"), None) + if not app or not (app / "Contents/MacOS").is_dir(): + raise UpdateError("the downloaded archive doesn't contain FramePort.app") + return app + if archive.name.endswith(".zip"): + with zipfile.ZipFile(archive) as z: + z.extractall(dest) + if not (dest / "FramePort.exe").exists(): + raise UpdateError("the downloaded archive doesn't contain FramePort.exe") + return dest + with tarfile.open(archive) as t: + t.extractall(dest, filter="tar") if hasattr(tarfile, "data_filter") else t.extractall(dest) + folder = dest / "FramePort" + if not (folder / "FramePort").is_file(): + raise UpdateError("the downloaded archive doesn't contain FramePort/FramePort") + return folder + + +def _signer_thumbprint(exe: Path) -> str | None: + """Windows: the Authenticode signer certificate's thumbprint ('' if unsigned, None if it can't be read).""" + try: + out = subprocess.run(["powershell", "-NoProfile", "-Command", + f"(Get-AuthenticodeSignature -LiteralPath '{exe}').SignerCertificate.Thumbprint"], + capture_output=True, text=True, timeout=60) + return out.stdout.strip() if out.returncode == 0 else None + except (OSError, subprocess.SubprocessError): + return None + + +def prepare(up: Update, reporter: Reporter | None = None, platform: str | None = None, + current: Path | None = None) -> Path: + """Download, verify and unpack the new version. Returns the unpacked app, ready for apply().""" + reporter = reporter or Reporter() + platform = platform or sys.platform + if not up.asset_url or not up.asset: + raise UpdateError(f"release {up.tag} has no download for this system; get it from {up.page}") + if not up.sums_url: + raise UpdateError(f"release {up.tag} has no {SUMS}; refusing an unverified update") + work = updates_dir() / up.version + work.mkdir(parents=True, exist_ok=True) + reporter.stage("Check the release") + expected = parse_sums(cache.http_get(up.sums_url).text).get(up.asset) + if not expected: + raise UpdateError(f"{SUMS} of {up.tag} doesn't list {up.asset}") + archive = work / up.asset + if archive.exists() and _sha256(archive) == expected: + reporter.log("already downloaded") + else: + reporter.stage(f"Download FramePort {up.version}") + cache.download(up.asset_url, archive, progress=lambda f: reporter.progress(f, f"{f:.0%}"), + expected_sha256=expected) + reporter.check("Checksum matches the release", True, expected[:16] + "…") + reporter.stage("Unpack") + app = _extract(archive, work / "staged", platform) + if platform == "win32": + current = current or bundle_root() + mine = _signer_thumbprint(current / "FramePort.exe") if current else None + if mine: # the running build is signed: the new one must carry the same certificate + theirs = _signer_thumbprint(app / "FramePort.exe") + if theirs != mine: + raise UpdateError("the new FramePort.exe isn't signed with FramePort's certificate") + reporter.check("Signed with FramePort's certificate", True, mine[:16] + "…") + (work / "ready.json").write_text(json.dumps({"version": up.version, "app": str(app), "update": asdict(up)})) + reporter.log(f"FramePort {up.version} is ready to install") + return app + + +def ready_update() -> tuple[str, Path] | None: + """A prepared (downloaded + verified) newer version waiting to be installed: (version, app).""" + best = None + for marker in updates_dir().glob("*/ready.json"): + try: + info = json.loads(marker.read_text()) + app = Path(info["app"]) + except (OSError, ValueError, KeyError): + continue + if app.exists() and is_newer(info["version"]) and (best is None or is_newer(info["version"], best[0])): + best = (info["version"], app) + return best + + +def ready_update_info() -> Update | None: + """The release a prepared update came from (for installing it without asking GitHub again).""" + ready = ready_update() + if not ready: + return None + try: + return Update(**json.loads((updates_dir() / ready[0] / "ready.json").read_text())["update"]) + except (OSError, ValueError, KeyError, TypeError): + return None + + +def cleanup_old() -> None: + """Remove downloads of versions that are no longer newer than the running one.""" + for d in updates_dir().iterdir(): + if d.is_dir() and not is_newer(d.name): + shutil.rmtree(d, ignore_errors=True) + + +def can_replace(target: Path) -> bool: + return target.exists() and os.access(target, os.W_OK) and os.access(target.parent, os.W_OK) + + +def _ps_quote(path: Path | str) -> str: + return "'" + str(path).replace("'", "''") + "'" + + +def _sh_quote(path: Path | str) -> str: + return "'" + str(path).replace("'", "'\\''") + "'" + + +def swap_script(app: Path, target: Path, pid: int, platform: str, relaunch: bool, log: Path) -> str: + """The script that installs `app` over `target` once process `pid` has exited (text; see apply()).""" + if platform == "win32": + start = f"Start-Process -FilePath {_ps_quote(target / 'FramePort.exe')} -WorkingDirectory {_ps_quote(target)}" \ + if relaunch else "Write-Log 'not relaunching'" + return f"""$ErrorActionPreference = 'Stop' +$log = {_ps_quote(log)} +function Write-Log($m) {{ Add-Content -LiteralPath $log -Value ("$(Get-Date -Format s) $m") }} +Write-Log 'update: waiting for FramePort (pid {pid}) to exit' +for ($i = 0; $i -lt 240 -and (Get-Process -Id {pid} -ErrorAction SilentlyContinue); $i++) {{ Start-Sleep -Milliseconds 500 }} +Start-Sleep -Milliseconds 500 +$src = {_ps_quote(app)}; $dst = {_ps_quote(target)} +$backup = Join-Path (Split-Path -Parent $src) 'previous' +try {{ + if (Test-Path -LiteralPath $backup) {{ Remove-Item -LiteralPath $backup -Recurse -Force }} + New-Item -ItemType Directory -Path $backup | Out-Null + # keep the files that will be replaced, so a failed copy can be undone + Get-ChildItem -LiteralPath $src -Recurse -File | ForEach-Object {{ + $rel = $_.FullName.Substring($src.Length).TrimStart('\\') + $old = Join-Path $dst $rel + if (Test-Path -LiteralPath $old) {{ + $keep = Join-Path $backup $rel + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $keep) | Out-Null + Copy-Item -LiteralPath $old -Destination $keep -Force + }} + }} + Copy-Item -Path (Join-Path $src '*') -Destination $dst -Recurse -Force + Write-Log 'update: installed' +}} catch {{ + Write-Log "update FAILED: $_ (restoring the previous files)" + Copy-Item -Path (Join-Path $backup '*') -Destination $dst -Recurse -Force -ErrorAction SilentlyContinue +}} +{start} +""" + if platform == "darwin": + start = f"open {_sh_quote(target)}" if relaunch else "echo 'not relaunching' >>\"$log\"" + extra = f"xattr -dr com.apple.quarantine {_sh_quote(target)} 2>/dev/null || true" + else: + start = (f"nohup {_sh_quote(target / 'FramePort')} >/dev/null 2>&1 &" if relaunch + else "echo 'not relaunching' >>\"$log\"") + extra = ":" + return f"""#!/bin/sh +log={_sh_quote(log)} +echo "$(date) update: waiting for FramePort (pid {pid}) to exit" >>"$log" +i=0; while kill -0 {pid} 2>/dev/null && [ $i -lt 240 ]; do sleep 0.5; i=$((i+1)); done +src={_sh_quote(app)}; dst={_sh_quote(target)}; old="$dst.old" +rm -rf "$old" +if mv "$dst" "$old" && mv "$src" "$dst"; then + {extra} + rm -rf "$old" + echo "$(date) update: installed" >>"$log" +else + echo "$(date) update FAILED: restoring the previous version" >>"$log" + [ -e "$dst" ] || mv "$old" "$dst" +fi +{start} +""" + + +def apply(app: Path, target: Path | None = None, relaunch: bool = True, pid: int | None = None, + platform: str | None = None, wait: bool = False) -> Path: + """Start the swap script (detached unless wait=True) and return its path; the caller then quits FramePort.""" + platform = platform or sys.platform + target = target or bundle_root() + if not target: + raise UpdateError("FramePort isn't running from a release bundle") + if not can_replace(target): + raise UpdateError(f"no permission to replace {target}") + pid = os.getpid() if pid is None else pid + log = user_data_dir() / "logs" / "update.log" + log.parent.mkdir(parents=True, exist_ok=True) + script = updates_dir() / ("apply.ps1" if platform == "win32" else "apply.sh") + script.write_text(swap_script(app, target, pid, platform, relaunch, log), encoding="utf-8") + if platform == "win32": + cmd = ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-File", str(script)] + flags = 0 if wait else (0x00000008 | 0x00000200 | 0x08000000) # DETACHED, NEW_PROCESS_GROUP, NO_WINDOW + proc = subprocess.Popen(cmd, creationflags=flags, close_fds=True, stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + else: + proc = subprocess.Popen(["/bin/sh", str(script)], start_new_session=not wait, close_fds=True, + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + if wait: + proc.wait() + return script diff --git a/tests/test_updates.py b/tests/test_updates.py new file mode 100644 index 0000000..cb130b0 --- /dev/null +++ b/tests/test_updates.py @@ -0,0 +1,215 @@ +"""Self-update (frameport.updates): version logic, release parsing, checks, staging and the swap script.""" +import hashlib +import io +import json +import os +import sys +import tarfile +import zipfile +from pathlib import Path + +import pytest + +from frameport import __version__, updates +from frameport.core import cache, library + + +def release(tag="v9.9.9", assets=("FramePort-windows-x64.zip", "FramePort-macos-arm64.zip", "FramePort-linux-x64.tar.gz", + "SHA256SUMS.txt", "frameport-9.9.9-py3-none-any.whl"), **kw): + return {"tag_name": tag, "body": "What's new: things", "html_url": f"https://example.invalid/releases/{tag}", + "draft": False, "prerelease": False, "published_at": "2026-10-02T00:00:00Z", + "assets": [{"name": n, "browser_download_url": f"https://example.invalid/dl/{n}"} for n in assets], **kw} + + +def test_versions(): + assert updates.parse_version("v0.10.1") > updates.parse_version("0.9.9") + assert updates.parse_version("1.0.0") > updates.parse_version("1.0.0-rc1") + assert updates.is_newer("0.2.1", "0.2.0") and not updates.is_newer("0.2.0", "0.2.0") + assert not updates.is_newer("garbage", "0.1.0") + + +def test_release_parsing_picks_this_platform_and_skips_drafts(): + up = updates.update_from_release(release(), "FramePort-linux-x64.tar.gz") + assert up.version == "9.9.9" and up.asset_url.endswith("linux-x64.tar.gz") and up.sums_url.endswith("SHA256SUMS.txt") + assert up.wheel_url.endswith(".whl") and "What's new" in up.notes + assert updates.update_from_release(release(draft=True)) is None + assert updates.update_from_release(release(prerelease=True)) is None + assert updates.update_from_release(release(assets=("SHA256SUMS.txt",)), "FramePort-linux-x64.tar.gz").asset is None + + +def test_check_skip_and_cached_hint(monkeypatch): + calls = [] + monkeypatch.setattr(cache, "http_get", lambda url, **kw: calls.append(url) or type( + "R", (), {"text": json.dumps(release()), "raise_for_status": lambda self: None})()) + up = updates.check(force=True) + assert up and up.version == "9.9.9" and calls == [updates.LATEST_API] + assert library.setting("update.last_check") + assert updates.cached_update().version == "9.9.9" # read from the cache, no network + updates.skip("9.9.9") + assert updates.check() is None and updates.cached_update() is None # "Later" hides this version + assert updates.check(force=True).version == "9.9.9" # "Check now" still finds it + monkeypatch.setattr(cache, "http_get", lambda url, **kw: (_ for _ in ()).throw(OSError("offline"))) + (cache.cache_dir() / "app-release.json").unlink() + assert updates.check(force=True) is None # offline: no update, no error + + +def test_older_release_is_no_update(monkeypatch): + monkeypatch.setattr(cache, "cached_json", lambda *a, **k: release(tag="v0.0.1")) + assert updates.check(force=True) is None + + +def test_bundle_layouts(): + assert updates.bundle_root(Path("C:/Games/FramePort/FramePort.exe"), "win32") == Path("C:/Games/FramePort") + assert updates.bundle_root(Path("/Applications/FramePort.app/Contents/MacOS/FramePort"), "darwin") == \ + Path("/Applications/FramePort.app") + assert updates.bundle_root(Path("/home/u/FramePort/FramePort"), "linux") == Path("/home/u/FramePort") + assert updates.bundle_root(Path("/usr/bin/python3"), "linux") is None + assert updates.bundle_root(Path("/usr/bin/python3.12"), "darwin") is None + + +def test_install_kind_from_source_checkout(): + # the test suite runs from the repo: a git checkout + assert updates.install_kind() == "source" + cmds = updates.upgrade_commands(updates.update_from_release(release(), None), "source") + assert cmds[0][:2] == ["git", "-C"] and cmds[0][-2:] == ["pull", "--ff-only"] and cmds[1][:2] == ["uv", "sync"] + + +def test_wheel_commands(monkeypatch): + up = updates.update_from_release(release(), None) + monkeypatch.setattr(sys, "prefix", "/home/u/.local/share/uv/tools/frameport") + assert updates.upgrade_commands(up, "wheel") == [["uv", "tool", "install", "--force", up.wheel_url]] + monkeypatch.setattr(sys, "prefix", "/home/u/venv") + assert updates.upgrade_commands(up, "wheel")[0][1:4] == ["-m", "pip", "install"] + + +def test_sums_parsing(): + sums = updates.parse_sums("a" * 64 + " FramePort-linux-x64.tar.gz\n" + "B" * 64 + " *FramePort-windows-x64.zip\njunk\n") + assert sums == {"FramePort-linux-x64.tar.gz": "a" * 64, "FramePort-windows-x64.zip": "b" * 64} + + +def _fake_downloads(monkeypatch, files: dict[str, bytes], sums: dict[str, str] | None = None): + sums = sums or {name: hashlib.sha256(data).hexdigest() for name, data in files.items()} + text = "".join(f"{h} {n}\n" for n, h in sums.items()) + monkeypatch.setattr(cache, "http_get", lambda url, **kw: type("R", (), {"text": text})()) + + def download(url, dest, progress=None, expected_sha256=None, expected_sha1=None): + data = files[url.rsplit("/", 1)[1]] + if expected_sha256 and hashlib.sha256(data).hexdigest() != expected_sha256: + raise ValueError("checksum mismatch") + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(data) + return dest + monkeypatch.setattr(cache, "download", download) + + +def _linux_archive(version: str) -> bytes: + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as t: + for name, data, mode in (("FramePort/FramePort", b"#!/bin/sh\necho " + version.encode() + b"\n", 0o755), + ("FramePort/data/version.txt", version.encode(), 0o644)): + info = tarfile.TarInfo(name) + info.size, info.mode = len(data), mode + t.addfile(info, io.BytesIO(data)) + return buf.getvalue() + + +def test_prepare_verifies_and_unpacks(monkeypatch): + up = updates.update_from_release(release(), "FramePort-linux-x64.tar.gz") + _fake_downloads(monkeypatch, {"FramePort-linux-x64.tar.gz": _linux_archive("9.9.9")}) + app = updates.prepare(up, platform="linux") + assert (app / "FramePort").is_file() and (app / "data/version.txt").read_text() == "9.9.9" + assert os.access(app / "FramePort", os.X_OK) or sys.platform == "win32" + assert updates.ready_update() == ("9.9.9", app) + + +def test_prepare_rejects_a_bad_checksum(monkeypatch): + up = updates.update_from_release(release(), "FramePort-linux-x64.tar.gz") + _fake_downloads(monkeypatch, {"FramePort-linux-x64.tar.gz": _linux_archive("9.9.9")}, + sums={"FramePort-linux-x64.tar.gz": "0" * 64}) + with pytest.raises(ValueError): + updates.prepare(up, platform="linux") + assert updates.ready_update() is None + + +def test_prepare_rejects_wrong_layout(monkeypatch): + up = updates.update_from_release(release(), "FramePort-windows-x64.zip") + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as z: + z.writestr("NotFramePort.exe", b"x") + _fake_downloads(monkeypatch, {"FramePort-windows-x64.zip": buf.getvalue()}) + with pytest.raises(updates.UpdateError): + updates.prepare(up, platform="win32", current=Path("/nonexistent")) + + +def test_release_without_sums_is_refused(): + up = updates.update_from_release(release(assets=("FramePort-linux-x64.tar.gz",)), "FramePort-linux-x64.tar.gz") + with pytest.raises(updates.UpdateError): + updates.prepare(up, platform="linux") + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX swap script") +def test_swap_script_replaces_the_installed_folder(monkeypatch, tmp_path): + up = updates.update_from_release(release(), "FramePort-linux-x64.tar.gz") + _fake_downloads(monkeypatch, {"FramePort-linux-x64.tar.gz": _linux_archive("9.9.9")}) + app = updates.prepare(up, platform="linux") + installed = tmp_path / "apps/FramePort" + installed.mkdir(parents=True) + (installed / "FramePort").write_text("old") + (installed / "stale.txt").write_text("old file") + script = updates.apply(app, installed, relaunch=False, pid=2 ** 22 + 1, platform="linux", wait=True) + assert script.exists() + assert (installed / "data/version.txt").read_text() == "9.9.9" and not (installed / "stale.txt").exists() + assert not (tmp_path / "apps/FramePort.old").exists() + log = (updates.user_data_dir() / "logs/update.log").read_text() + assert "update: installed" in log and "not relaunching" in log + + +def test_windows_script_backs_up_and_relaunches(): + text = updates.swap_script(Path("C:/data/updates/9.9.9/staged"), Path("C:/Games/FramePort"), 1234, "win32", True, + Path("C:/data/logs/update.log")) + assert "Get-Process -Id 1234" in text and "Copy-Item -Path (Join-Path $src '*') -Destination $dst" in text + assert "previous" in text and "Start-Process -FilePath 'C:" in text and "FramePort.exe" in text + + +def test_macos_script_clears_quarantine_and_opens(): + text = updates.swap_script(Path("/d/staged/FramePort.app"), Path("/Applications/FramePort.app"), 7, "darwin", + True, Path("/d/update.log")) + assert "xattr -dr com.apple.quarantine '/Applications/FramePort.app'" in text + assert "open '/Applications/FramePort.app'" in text + + +def test_version_is_single_sourced(): + text = (Path(__file__).resolve().parents[1] / "src/frameport/_version.py").read_text() + assert f'"{__version__}"' in text + assert 'dynamic = ["version"]' in (Path(__file__).resolve().parents[1] / "pyproject.toml").read_text() + + +def test_cli_version_and_update_check(monkeypatch): + from typer.testing import CliRunner + + from frameport.cli import app + + runner = CliRunner() + assert runner.invoke(app, ["--version"]).output.strip() == f"FramePort {__version__}" + monkeypatch.setattr(cache, "cached_json", lambda *a, **k: release()) + res = runner.invoke(app, ["update", "--check"]) + assert res.exit_code == 10 and "FramePort 9.9.9 is available" in res.output and "What's new" in res.output + monkeypatch.setattr(cache, "cached_json", lambda *a, **k: release(tag="v0.0.1")) + res = runner.invoke(app, ["update", "--check"]) + assert res.exit_code == 0 and "is the latest version" in res.output + + +def test_cli_hint_once_a_day_without_waiting(monkeypatch): + from typer.testing import CliRunner + + from frameport.cli import app + + (cache.cache_dir() / "app-release.json").write_text(json.dumps(release())) + monkeypatch.setattr(updates, "refresh_cache", lambda: None) # the cache is fresh anyway: no network + runner = CliRunner() + first = runner.invoke(app, ["list"]) + assert "FramePort 9.9.9 is available" in first.output + assert "FramePort 9.9.9 is available" not in runner.invoke(app, ["list"]).output # once a day + monkeypatch.setenv("FRAMEPORT_NO_UPDATE_CHECK", "1") + library.set_setting("update.cli_hint", None) + assert "is available" not in runner.invoke(app, ["list"]).output