Compare commits

..
3 Commits
13 changed files with 233 additions and 11 deletions

No files matched your search

+52
View File
@@ -5,6 +5,15 @@ on:
tags: ["v*"]
pull_request:
workflow_dispatch:
inputs:
dev:
description: "Publish the bundles as the dev build (testers: Settings → Updates → Install the latest dev build)"
type: boolean
default: true
notes:
description: "What testers should try (shown before they install it)"
type: string
default: ""
jobs:
test:
@@ -50,6 +59,9 @@ jobs:
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y ninja-build libgtk-3-dev
- run: uv sync --extra dev
- name: Dev build version (next patch + .dev<run number>; not committed)
if: github.event_name == 'workflow_dispatch' && inputs.dev
run: uv run --no-project python scripts/dev_version.py ${{ github.run_number }}
- id: flet
run: uv run python scripts/package.py
continue-on-error: ${{ runner.os == 'macOS' || runner.arch == 'ARM64' }}
@@ -148,3 +160,43 @@ jobs:
echo; cat packaging/release-footer.md; } > "$notes" # short: the update dialog shows these 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"
dev-release:
# "Run workflow" with dev ticked: replaces the rolling `dev` pre-release (never shown by the automatic update check;
# testers install it from Settings → Updates). Same assets + SHA256SUMS as a release.
if: github.event_name == 'workflow_dispatch' && inputs.dev
needs: bundle
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: astral-sh/setup-uv@v10.2.0
- id: version
run: echo "version=$(uv run --no-project python scripts/dev_version.py ${{ github.run_number }})" >> "$GITHUB_OUTPUT"
- uses: actions/download-artifact@v8
with:
path: out
merge-multiple: true
- name: Wheel
run: uv build --wheel -o out && ls out
- name: Checksums
run: cd out && sha256sum * > SHA256SUMS.txt && cat SHA256SUMS.txt
- name: Publish the dev build
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.version.outputs.version }}
NOTES: ${{ inputs.notes }}
run: |
notes=$(mktemp)
last=$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null || true)
{ echo "Dev build **${VERSION}** from commit ${GITHUB_SHA::7} ($(date -u +%Y-%m-%d)). For testing fixes before"
echo "the next release; you get that release as a normal update."; echo
if [ -n "$NOTES" ]; then echo "## Please test"; echo; echo "$NOTES"; echo; fi
if [ -n "$last" ]; then echo "## Changes since ${last}"; echo
git log --no-merges --format='- %s' "${last}..HEAD" | cut -c1-160 | head -40; fi; } > "$notes"
gh release delete dev --yes --cleanup-tag 2>/dev/null || true
gh release create dev out/* packaging/FramePort-selfsigned.cer --prerelease --target "$GITHUB_SHA" \
--title "FramePort dev build ${VERSION}" --notes-file "$notes"
+5
View File
@@ -531,6 +531,11 @@ tests, builds Windows x64 / macOS arm64 / Linux x64 bundles, signs, attests and
`FramePort-selfsigned.cer`; notes = "What's new" from the annotated tag message + the short `packaging/release-footer.md`; owner: keep release
notes short — a few "What's new" bullets, nothing long after them).
Installed apps find the release themselves (self-update), so the notes are what users see in the update dialog.
- **Dev builds (for testers, no release):** Actions → build → Run workflow (main), "dev" ticked, optional "notes"
(what to test). The bundles get version `<next patch>.dev<run number>` (`scripts/dev_version.py`, not committed) and
replace the rolling `dev` pre-release (`dev-release` job; same assets + SHA256SUMS). Automatic update checks ignore
pre-releases; testers use Settings → Updates → "Install the latest dev build…" (`updates.check_dev`,
`Updater.install_dev`). `parse_version` sorts 0.9.0 < 0.9.1.devN < 0.9.1, so testers get the next release normally.
- **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
+13 -2
View File
@@ -35,7 +35,7 @@ import sys
import time
import zlib
AGENT_VERSION = 47
AGENT_VERSION = 48
HOME = os.path.expanduser("~")
STEAM = os.path.join(HOME, ".local/share/Steam")
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
@@ -1163,9 +1163,14 @@ def cmd_shortcut_status(args):
def deployment(pkg):
path = os.path.join(ANCHORS, pkg, "deployment.json")
try:
return json.load(open(path))
dep = json.load(open(path))
except (OSError, ValueError):
return None
if not isinstance(dep, dict):
return None
dep.setdefault("base", os.path.dirname(path))
dep.setdefault("title", pkg)
return dep
def cmd_list_installed(args):
@@ -1175,6 +1180,12 @@ def cmd_list_installed(args):
dep = json.load(open(dep_path))
except (OSError, ValueError):
continue
# incomplete records (an interrupted install, another tool's files in this folder) are skipped or completed
# instead of failing every connection with a KeyError (GitHub #40)
if not isinstance(dep, dict) or not dep.get("package") or not dep.get("appid"):
continue
dep.setdefault("base", os.path.dirname(dep_path))
dep.setdefault("title", dep["package"])
dep.setdefault("kind", "quest")
dep["anchor"] = os.path.dirname(dep_path) # its artwork/ feeds the Steam grid (shortcuts)
if dep["kind"] == "linux":
+4
View File
@@ -110,6 +110,10 @@ Settings → **Updates**: turn the check off, or turn on **Install updates autom
installs at the next start). If FramePort's folder isn't writable, **Update now** opens the release page instead. The
update log is `logs/update.log` in the data folder.
**Dev builds:** when you're asked to test a fix before it's released, use Settings → Updates → **Install the latest
dev build…**. It shows what to test, then installs like an update (same checks). Dev builds are less tested; the next
release is offered to you as a normal update.
Command line: `frameport update` (`--check` only checks, exit code 10 = update available; `--yes` doesn't ask).
`FRAMEPORT_NO_UPDATE_CHECK=1` turns all checks off.
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""Stamp a dev build's version into src/frameport/_version.py (CI only; never committed): the next patch release plus
.dev<build number>, e.g. 0.9.0 -> 0.9.1.dev57. It sorts above the current release and below the next one, so
testers on a dev build get the next release offered as a normal update.
python scripts/dev_version.py <build number> # prints the stamped version
"""
import re
import sys
from pathlib import Path
VERSION_FILE = Path(__file__).resolve().parents[1] / "src/frameport/_version.py"
def dev_version(current: str, build: int) -> str:
base = re.match(r"(\d+)\.(\d+)\.(\d+)", current)
if not base:
raise ValueError(f"unexpected version {current!r}")
major, minor, patch = (int(x) for x in base.groups())
return f"{major}.{minor}.{patch + 1}.dev{int(build)}"
def main() -> None:
text = VERSION_FILE.read_text(encoding="utf-8")
current = re.search(r'__version__\s*=\s*"([^"]+)"', text).group(1)
version = dev_version(current, int(sys.argv[1]))
VERSION_FILE.write_text(text.replace(f'"{current}"', f'"{version}"', 1), encoding="utf-8")
print(version)
if __name__ == "__main__":
main()
+1 -1
View File
@@ -1,2 +1,2 @@
# The FramePort version: the single source (pyproject reads it via hatch; CI checks a release tag matches it).
__version__ = "0.9.0"
__version__ = "0.9.1"
+9
View File
@@ -136,6 +136,7 @@
"Artwork: {value}": "",
"As the game sends it": "",
"Asked Steam to install Lepton": "",
"Asked to test a fix? Dev builds come before the next release.": "",
"Assassin's Creed Nexus (build 207706 only): turns off application space warp and runs at 72 Hz.": "",
"Assassin's Creed Nexus (build 207706 only): turns off application space warp and runs at 90 Hz.": "",
"Assassin's Creed Nexus: smoother picture at 72 Hz (turns off Meta's frame-rate trick).": "",
@@ -151,6 +152,7 @@
"Best guess": "",
"Blit quads flagged XrCompositionLayerImageLayoutFB VERTICAL_FLIP upside down (Vulkan). Fixes UI panels and text that show upside down (e.g. Assassin's Creed Nexus).": "",
"Browse and manage files on the Frame (Files tab): upload from this PC, download, rename, delete. Videos, Downloads and Documents are shared by every Quest game (/sdcard/Movies, /sdcard/Download, /sdcard/Documents); each installed game also has its own storage (its /sdcard). Apps find files by browsing folders. Video players that list only their own folder (e.g. 4XVR's Internal Storage = 4XPlayer): upload into that folder in the game's storage.": "",
"Build page": "",
"Can't reach your Frame. Make sure it's on, awake and on the same network, then try again.": "",
"Can't run": "",
"Cancel": "",
@@ -248,6 +250,7 @@
],
"Deletes the rollback copies kept from each game's previous install and leftover uploads. The games and saves stay.": "",
"Details": "",
"Dev builds let you test fixes before they're released. They're less tested than releases and may have bugs. You get the next release as a normal update (you have {version}).": "",
"Developer Mode (on the Frame: Settings → System → Developer) lets FramePort find the Frame on your network and connect to it over SSH. The first-time setup command turns it on for you.": "",
"Didn't finish ({value}): {error}": "",
"Disable application space warp if used": "",
@@ -433,6 +436,8 @@
"In your Steam library · launch settings changed — update it": "",
"Install": "",
"Install anyway": "",
"Install dev build": "",
"Install dev build {version}?": "",
"Install it anyway to try it, e.g. with different patches.": "",
"Install Lepton on the Frame": "",
"Install missing": "",
@@ -442,6 +447,7 @@
"Install Proton on the Frame?": "",
"Install SteamVR from Steam to play PC VR games": "",
"Install the alternate build (": "",
"Install the latest dev build…": "",
"Install updates automatically (downloads in the background, installs when FramePort next starts)": "",
"Install {len} on this PC": "",
"Install {n} on Frame": "",
@@ -567,12 +573,14 @@
"No activity": "",
"no answer from the Frame": "",
"No artwork could be downloaded from that {source} result; the current artwork stays. Try another one.": "",
"No dev build is published right now": "",
"No folders to rescan yet: add games with Scan a folder": "",
"No Frames found. New Frame? Use first-time setup (step 1). Otherwise make sure Developer Mode is on (Settings → System → Developer) and the Frame is on the same network.": "",
"No games found in that folder": "",
"No limit": "",
"No matching files": "",
"No new games in {name}": "",
"No notes.": "",
"No release notes.": "",
"No screenshots yet": "",
"No Unreal crash reporter": "",
@@ -1104,6 +1112,7 @@
"Yes": "",
"Yes: uses the Frame's OpenXR runtime": "",
"Yesterday": "",
"You already have the latest dev build ({version})": "",
"You have the latest version as of the last check ({when}).": "",
"You have {version}. {how}": "",
"Your copy is used as it is (only the Frame's copy gets launch patches)": "",
+2
View File
@@ -1746,6 +1746,8 @@ class FramePortApp:
self.frame_info = None
# reachable, but it doesn't let FramePort in: the Frame page points at the first-time setup
self._not_paired = isinstance(exc, FrameNotPaired)
if not isinstance(exc, (FrameNotPaired, OSError)): # unexpected: keep the traceback for reports
applog.log.exception("connect to %s failed", target.label)
if not quiet:
self.toast(tr("Couldn't connect: {exc}").format(exc=explain(exc)), error=True)
if self.route[0] in ("frame", "library", "game", "welcome"):
+42
View File
@@ -124,6 +124,48 @@ class Updater:
C.ghost(tr("Release page"), ft.Icons.OPEN_IN_NEW_ROUNDED, lambda e: page.launch_url(up.page)),
C.primary(tr("Update now"), ft.Icons.SYSTEM_UPDATE_ROUNDED, go)]))
# ---------------------------------------------------------------- dev builds
def install_dev(self) -> None:
"""Settings → "Install the latest dev build": the rolling `dev` pre-release the maintainer publishes for
testing a fix before a release (CI "Run workflow", dev build)."""
def work():
up = updates.check_dev()
if not up:
self.app.toast(tr("No dev build is published right now"), error=True)
return
if up.version == __version__:
self.app.toast(tr("You already have the latest dev build ({version})").format(version=up.version))
return
self.app.page.run_thread(lambda: self._confirm_dev(up))
self.app.run_bg(work)
def _confirm_dev(self, up: updates.Update) -> None:
page = self.app.page
notes = up.notes.strip() or tr("No notes.")
def go(e):
page.pop_dialog()
self.found = up
self.install()
page.show_dialog(ft.AlertDialog(
title=ft.Text(tr("Install dev build {version}?").format(version=up.version), color=T.TEXT,
weight=ft.FontWeight.W_600),
bgcolor=T.SURFACE_2,
content=ft.Container(ft.Column([
C.callout(C.body(tr("Dev builds let you test fixes before they're released. They're less tested "
"than releases and may have bugs. You get the next release as a normal update "
"(you have {version}).").format(version=__version__), T.TEXT), "warn"),
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(440, 190 + 26 * len(notes.splitlines())))),
actions=[C.ghost(tr("Cancel"), on_click=lambda e: page.pop_dialog()),
C.ghost(tr("Build page"), ft.Icons.OPEN_IN_NEW_ROUNDED, lambda e: page.launch_url(up.page)),
C.primary(tr("Install dev build"), ft.Icons.SCIENCE_OUTLINED, go)]))
# ---------------------------------------------------------------- installing
def install(self) -> None:
up = self.found or updates.ready_update_info()
+4
View File
@@ -133,6 +133,10 @@ class SettingsView:
C.switch(tr("Install updates automatically (downloads in the background, installs when FramePort "
"next starts)"), value=bool(library.setting("update.auto_install", False)),
on_change=auto_install),
ft.Row([C.meta(tr("Asked to test a fix? Dev builds come before the next release."), expand=True),
C.ghost(tr("Install the latest dev build…"), ft.Icons.SCIENCE_OUTLINED,
lambda e: app.updater.install_dev())],
vertical_alignment=ft.CrossAxisAlignment.CENTER),
], spacing=T.S3)
def appearance(self) -> ft.Control:
+31 -8
View File
@@ -38,6 +38,7 @@ _log = logging.getLogger("frameport.updates")
REPO = REPO_URL.removeprefix("https://github.com/").strip("/")
LATEST_API = f"https://api.github.com/repos/{REPO}/releases/latest"
DEV_API = f"https://api.github.com/repos/{REPO}/releases/tags/dev" # the rolling pre-release CI publishes on demand
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", "linux-arm64": "FramePort-linux-arm64.tar.gz"} # never rename
@@ -63,11 +64,15 @@ class Update:
# ------------------------------------------------------------------------------------------------- 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."""
"""(major, minor, patch, final, dev) from "v1.2.3" / "1.2.3-rc1" / "1.2.4.dev57": pre-releases and dev builds sort
before the final release, dev builds among themselves by build number."""
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)
return (0, 0, 0, 0, 0)
rest = m.group(4).strip()
dev = re.match(r"[.-]?dev(\d+)", rest)
return (int(m.group(1)), int(m.group(2) or 0), int(m.group(3) or 0), 0 if rest else 1,
int(dev.group(1)) if dev else 0)
def is_newer(candidate: str, current: str = __version__) -> bool:
@@ -81,16 +86,24 @@ def platform_asset() -> str | None:
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")
def update_from_release(release: dict, asset_name: str | None = None, dev: bool = False) -> Update | None:
"""The Update a GitHub "latest release" JSON describes (None for drafts/pre-releases or unusable data). dev=True:
the rolling `dev` pre-release, whose version comes from its wheel (frameport-0.9.1.dev57-py3-none-any.whl)."""
if (not isinstance(release, dict) or release.get("draft") or (release.get("prerelease") and not dev)
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)
wheel_name = next((n for n in assets if n and n.endswith(".whl")), None)
wheel = assets.get(wheel_name) if wheel_name else None
tag = release["tag_name"]
return Update(version=tag.lstrip("v"), tag=tag, notes=release.get("body") or "", page=release.get("html_url") or "",
version = tag.lstrip("v")
if dev:
m = re.match(r"frameport-([^-]+)-", wheel_name or "")
if not m:
return None
version = m.group(1)
return Update(version=version, 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 "")
@@ -116,6 +129,16 @@ def check(force: bool = False) -> Update | None:
return up
def check_dev() -> Update | None:
"""The latest dev build (Settings → "Install the latest dev build"), or None when none is published or GitHub
can't be reached. Returned even when it isn't newer: the caller says so."""
try:
data = cache.cached_json("app-dev-release.json", DEV_API, max_age=0)
except Exception: # noqa: BLE001
return None
return update_from_release(data, dev=True) if data else None
def refresh_cache() -> None:
"""Fetch the latest release into the cache only (no settings written: safe in a CLI background thread)."""
try:
+14
View File
@@ -1220,3 +1220,17 @@ def test_power_schedules_systemctl_in_a_user_timer(monkeypatch, tmp_path):
assert a.cmd_power({"action": "sleep", "force": True})["action"] == "sleep"
with pytest.raises(a.AgentError):
a.cmd_power({"action": "format"})
def test_incomplete_deployment_records_dont_break_listing(monkeypatch, tmp_path):
"""GitHub #40: a deployment.json without "base" made every connection fail with KeyError: 'base'."""
a = load_agent(monkeypatch, tmp_path)
good = tmp_path / "Applications/quest-frame/com.x.ok"
good.mkdir(parents=True)
(good / "deployment.json").write_text(json.dumps({"package": "com.x.ok", "appid": 5})) # no base / title
junk = tmp_path / "Applications/quest-frame/other"
junk.mkdir(parents=True)
(junk / "deployment.json").write_text(json.dumps({"name": "something else"}))
games = a.cmd_list_installed({})["games"]
assert [g["package"] for g in games] == ["com.x.ok"] and games[0]["base"] == str(good)
assert a.deployment("com.x.ok")["base"] == str(good)
+24
View File
@@ -254,3 +254,27 @@ def test_platform_asset_picks_the_arm64_linux_bundle(monkeypatch):
assert updates.platform_asset() == "FramePort-linux-arm64.tar.gz"
monkeypatch.setattr(updates.platform, "machine", lambda: "x86_64")
assert updates.platform_asset() == "FramePort-linux-x64.tar.gz"
def test_dev_build_versions_sort_between_releases():
import importlib.util
from pathlib import Path
spec = importlib.util.spec_from_file_location("dev_version", Path(__file__).parents[1] / "scripts/dev_version.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
assert mod.dev_version("0.9.0", 57) == "0.9.1.dev57"
pv = updates.parse_version
assert pv("0.9.0") < pv("0.9.1.dev57") < pv("0.9.1.dev58") < pv("0.9.1")
assert updates.is_newer("0.9.1", "0.9.1.dev58") and not updates.is_newer("0.9.0", "0.9.1.dev58")
def test_dev_release_is_only_read_on_request():
release = {"tag_name": "dev", "prerelease": True, "html_url": "https://example/dev", "body": "try X",
"assets": [{"name": "frameport-0.9.1.dev57-py3-none-any.whl", "browser_download_url": "w"},
{"name": "FramePort-windows-x64.zip", "browser_download_url": "z"},
{"name": "SHA256SUMS.txt", "browser_download_url": "s"}]}
assert updates.update_from_release(release, "FramePort-windows-x64.zip") is None # automatic checks skip it
up = updates.update_from_release(release, "FramePort-windows-x64.zip", dev=True)
assert up.version == "0.9.1.dev57" and up.asset_url == "z" and up.sums_url == "s" and up.notes == "try X"
assert updates.update_from_release({**release, "assets": []}, dev=True) is None # no wheel: no version