Compare commits

...
2 Commits
Author SHA1 Message Date
spoopyghosty0 b06cc9659b FramePort 0.3.4 2026-10-02 01:11:46 -04:00
spoopyghosty0 bc40325c2d Updater: start the Windows swap script in a hidden console and check it runs; Linux bundle built on Ubuntu 22.04
Found in the end-to-end test (0.3.1 -> 0.3.2): PowerShell started with DETACHED_PROCESS from the packaged app exits
without running the script, so the app quit and nothing was updated. apply() now waits until the script logs that it
runs and raises otherwise. The smoke test uses the same non-waiting launch as the app. The Linux bundle built on
24.04 needed GLib 2.80.
2026-10-02 01:01:37 -04:00
8 changed files with 78 additions and 12 deletions

No files matched your search

+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
archive: FramePort-windows-x64.zip
- os: macos-latest
archive: FramePort-macos-arm64.zip
- os: ubuntu-latest
- os: ubuntu-22.04 # oldest supported: the bundle needs the build host's GLib/glibc or newer
archive: FramePort-linux-x64.tar.gz
runs-on: ${{ matrix.os }}
steps:
+6
View File
@@ -367,6 +367,12 @@ Installed apps find the release themselves (self-update), so the notes are what
powershell.exe` with `PSModulePath` removed from the environment — started under PowerShell 7 (CI's default shell,
or a user's pwsh terminal) it couldn't run Get-AuthenticodeSignature (v0.3.0's Windows update smoke failed on it).
`v0.3.0` is a tag without a release (that failed build); the updater shipped first in v0.3.1.
- **Never start PowerShell with `DETACHED_PROCESS` from the packaged app**: it exits 0 without running anything (found
in the 0.3.1→0.3.2 end-to-end test: the app quit, nothing updated). `updates.spawn_hidden` = CREATE_NEW_CONSOLE +
hidden window; `apply()` waits until the script has logged that it runs and raises otherwise (the app then stays
open). Windows installs of 0.3.1/0.3.2 can't update themselves: they need one manual download.
- The Linux bundle is built on ubuntu-22.04: a 24.04-built Flutter bundle needs GLib 2.80 (`undefined symbol:
g_once_init_enter_pointer` on 22.04).
- **This project's GitHub identity is `spoopyghosty0`** (a dedicated account; the machine's default gh/git login is a
different, personal account that must never touch this repo). `gh` (`~/.local/bin/gh`) uses it through
`GH_CONFIG_DIR=~/.config/gh-spoopyghosty0` (set for Claude Code in the git-ignored `.claude/settings.local.json`);
+1 -1
View File
@@ -8,7 +8,7 @@ runtime, the OVRPort CLI and apksigner into its data folder (Settings → Tools
|---|---|---|
| Windows 10/11 (x64) | `FramePort-windows-x64.zip` | `FramePort.exe` |
| macOS (Apple Silicon) | `FramePort-macos-arm64.zip` | `FramePort.app` |
| Linux (x64, GTK 3) | `FramePort-linux-x64.tar.gz` | `FramePort/FramePort` |
| Linux (x64, GTK 3; Ubuntu 22.04 or newer) | `FramePort-linux-x64.tar.gz` | `FramePort/FramePort` |
| Command line only (Python 3.11+) | `frameport-<version>-py3-none-any.whl` | `frameport --help` |
The command-line version installs from the wheel's release link with `uv tool install <link>` (or pipx / pip).
+9 -2
View File
@@ -14,6 +14,7 @@ import os
import shutil
import sys
import tempfile
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
@@ -46,8 +47,14 @@ def main() -> int:
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()
# the same launch as the app (detached; apply() checks the script started), then wait for it to finish
script = updates.apply(new, installed, relaunch=False, pid=999999, platform=platform)
log_file = home / "data/logs/update.log"
for _ in range(240):
log = log_file.read_text() if log_file.exists() else ""
if "not relaunching" in log:
break
time.sleep(0.5)
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"
+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.3.2"
__version__ = "0.3.4"
+1 -2
View File
@@ -121,8 +121,7 @@ def update_cmd(check: bool = typer.Option(False, "--check", help="Only say wheth
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)
updates.spawn_hidden(["cmd", "/c", f"ping -n 3 127.0.0.1 >nul && {line}"])
typer.echo(f"Updating to FramePort {up.version} in the background: run `frameport --version` in a few seconds.")
return
for cmd in cmds:
+29 -5
View File
@@ -278,6 +278,20 @@ def _powershell() -> tuple[str, dict]:
return (str(exe) if exe.exists() else "powershell"), env
START_TIMEOUT = 15 # seconds for the swap script to log that it runs
def spawn_hidden(cmd: list[str], env: dict | None = None) -> subprocess.Popen:
"""Windows: start a console program in its own hidden console, independent of FramePort. DETACHED_PROCESS
doesn't work for PowerShell: started that way from the packaged app it exits 0 without running anything."""
si = subprocess.STARTUPINFO()
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
si.wShowWindow = 0 # SW_HIDE
return subprocess.Popen(cmd, creationflags=0x00000010 | 0x00000200, # CREATE_NEW_CONSOLE, NEW_PROCESS_GROUP
startupinfo=si, close_fds=True, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, env=env)
def _signer_thumbprint(exe: Path) -> str | None:
"""Windows: the Authenticode signer certificate's thumbprint ('' if unsigned, None if it can't be read)."""
ps, env = _powershell()
@@ -449,16 +463,26 @@ def apply(app: Path, target: Path | None = None, relaunch: bool = True, pid: int
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")
started = log.stat().st_size if log.exists() else 0
if platform == "win32":
ps, env = _powershell()
cmd = [ps, "-NoProfile", "-NonInteractive", "-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, env=env)
proc = spawn_hidden([ps, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden",
"-File", str(script)], env=env)
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 not wait: # make sure the script really runs before FramePort quits (it logs first thing)
deadline = time.time() + START_TIMEOUT
while time.time() < deadline:
if log.exists() and f"(pid {pid})" in log.read_text(errors="replace")[started:]:
break
if proc.poll() not in (None, 0):
break
time.sleep(0.2)
else:
raise UpdateError(f"the update script didn't start (see {log})")
if proc.poll() not in (None, 0):
raise UpdateError(f"the update script failed to start (exit {proc.returncode})")
if wait:
proc.wait()
return script
+30
View File
@@ -213,3 +213,33 @@ def test_cli_hint_once_a_day_without_waiting(monkeypatch):
monkeypatch.setenv("FRAMEPORT_NO_UPDATE_CHECK", "1")
library.set_setting("update.cli_hint", None)
assert "is available" not in runner.invoke(app, ["list"]).output
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX swap script")
def test_apply_detached_checks_the_script_started(monkeypatch, tmp_path):
import time
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")
updates.apply(app, installed, relaunch=False, pid=2 ** 22 + 2, platform="linux") # returns once the script runs
log = updates.user_data_dir() / "logs/update.log"
for _ in range(100):
if "not relaunching" in log.read_text():
break
time.sleep(0.1)
assert (installed / "data/version.txt").read_text() == "9.9.9"
def test_apply_reports_a_script_that_never_starts(monkeypatch, tmp_path):
installed = tmp_path / "FramePort"
installed.mkdir()
staged = tmp_path / "staged"
staged.mkdir()
monkeypatch.setattr(updates, "START_TIMEOUT", 0.5)
monkeypatch.setattr(updates.subprocess, "Popen", lambda *a, **k: type("P", (), {"poll": lambda self: None})())
with pytest.raises(updates.UpdateError):
updates.apply(staged, installed, relaunch=False, pid=1, platform="linux")