mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-06 08:00:35 +02:00
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:
1 parent
22205d17a4
commit
ba68dc76c8
22 files changed
+1246
-29
No files matched your search
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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"
|
||||
@@ -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
@@ -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"):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user