mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-06 10:00:36 +02:00
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.
216 lines
11 KiB
Python
216 lines
11 KiB
Python
"""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
|