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.
This commit is contained in:
spoopyghosty0 committed 2026-10-01 22:59:56 -04:00
1 parent 22205d17a4
commit ba68dc76c8
22 files changed
+1246 -29

No files matched your search

+15 -1
View File
@@ -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"
+27 -2
View File
@@ -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` (`<data>/logs/app.log`, finished GUI jobs in `<data>/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()` → `<data>/updates/<ver>/` (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/<ver>/previous` because the zip has no top folder; macOS/Linux: mv to `.old` + swap,
rollback, `xattr -dr` quarantine → relaunch; `<data>/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 <archive>` 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 <pkg>` / `frameport parity-device --results <parity.json> --baseline <launch.txt>
[--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=<test dir> python scripts/ui_smoke.py --out <dir> [--game <pkg>] [--frame steamos@<host>]` and look
`FRAMEPORT_HOME=<test dir> python scripts/ui_smoke.py --out <dir> [--game <pkg>] [--frame steamos@<host>] [--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-<ver>-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 "<What's new, Markdown bullets>"`,
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
+3 -1
View File
@@ -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 <the release's frameport-*.whl link>` (or pipx / pip), then `frameport --help`.
- **From source**: `uv sync && uv run frameport-gui` (CLI: `uv run frameport --help`).
## Status of tested games
+21
View File
@@ -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() <data>/updates/<ver>/: download → SHA256SUMS.txt check → extract to staged/ (ditto on macOS) → layout check
│ → Windows: same Authenticode signer as the running exe → ready.json
▼
apply() writes <data>/updates/apply.{ps1,sh}, starts it detached; FramePort quits. The script waits for the pid,
Windows: backs up the files it replaces (updates/<ver>/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: <data>/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),
+25
View File
@@ -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-<version>-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/<version>/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
+4 -1
View File
@@ -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"]
+16
View File
@@ -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:
+10
View File
@@ -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
+63
View File
@@ -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-<os>.<zip|tar.gz>
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())
+1 -1
View File
@@ -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"
+2
View File
@@ -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"
+85 -1
View File
@@ -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"):
+2 -5
View File
@@ -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:
+3 -1
View File
@@ -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
+2 -5
View File
@@ -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:
+12
View File
@@ -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())
+4
View File
@@ -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 "
+234
View File
@@ -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
+12 -1
View File
@@ -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
+45 -10
View File
@@ -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)),
+445
View File
@@ -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
<data>/updates/<version>/; `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: <data>/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
+215
View File
@@ -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