diff --git a/.github/workflows/desktop-app.yml b/.github/workflows/desktop-app.yml index 3c03f2f7..42a6a495 100644 --- a/.github/workflows/desktop-app.yml +++ b/.github/workflows/desktop-app.yml @@ -4,12 +4,16 @@ # When it runs: # - an official release is published (v1.13.0, …) → build both, smoke-test # them, attach the .dmg and the Setup.exe to that release; +# - a development build is tagged (dev-build.yml calls this with its tag) → +# the same, attached to that pre-release, for app users who opted into +# development builds (the app's update check only offers a build once its +# files are attached); # - by hand (Actions → Desktop app → Run workflow) → build both, keep them as # downloadable workflow artifacts; # - a pull request that touches the packaging → build and smoke-test, so a # broken build is found before the release that needs it. -# Not on every merge: development builds are hundreds of MB each, and the -# dev-build workflow tags with GITHUB_TOKEN, which starts no other workflow. +# The dev-build workflow is a caller rather than a trigger because it tags with +# GITHUB_TOKEN, whose releases start no other workflow. name: Desktop app @@ -17,6 +21,12 @@ on: release: types: [published] workflow_dispatch: + workflow_call: + inputs: + tag: + description: The development build's tag, to attach the apps to + required: true + type: string pull_request: paths: - "packaging/**" @@ -156,7 +166,8 @@ jobs: retention-days: 14 - name: Attach to the release - if: github.event_name == 'release' + if: github.event_name == 'release' || inputs.tag != '' env: GH_TOKEN: ${{ github.token }} - run: gh release upload "${{ github.event.release.tag_name }}" "$ASSET" --clobber + TAG: ${{ inputs.tag || github.event.release.tag_name }} + run: gh release upload "$TAG" "$ASSET" --clobber diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml index 7c53b9cf..39c414ee 100644 --- a/.github/workflows/dev-build.yml +++ b/.github/workflows/dev-build.yml @@ -24,15 +24,18 @@ on: permissions: contents: write -concurrency: - # One at a time, in order: two merges in quick succession must not race for - # the same number. - group: dev-build - cancel-in-progress: false - jobs: tag: runs-on: ubuntu-latest + concurrency: + # One at a time, in order: two merges in quick succession must not race + # for the same number. (The tagging only: the app builds below take ~20 + # minutes and need not hold up the next merge's tag.) + group: dev-build + cancel-in-progress: false + outputs: + tag: ${{ steps.name.outputs.tag }} + skip: ${{ steps.name.outputs.skip }} steps: - uses: actions/checkout@v4 with: @@ -80,3 +83,37 @@ jobs: --generate-notes \ --notes-start-tag "$PREV" \ --notes "Automatic development build of main since $BASE. Not an official release: it has not been through release testing, and its changes are listed below as pull requests rather than written up. Official releases are the ones marked Latest." + + # The desktop apps, for app users who opted into development builds. + apps: + needs: tag + if: needs.tag.outputs.skip == 'false' + uses: ./.github/workflows/desktop-app.yml + with: + tag: ${{ needs.tag.outputs.tag }} + + prune-apps: + # Each build's apps are ~500 MB, one set per merge. Only the newest are + # ever offered to update to, so older development builds lose theirs (the + # tag, notes and source stay). Official releases are never touched. + needs: apps + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} + KEEP: 5 + steps: + - name: Remove the apps of older development builds + run: | + set -euo pipefail + gh release list -R "$GITHUB_REPOSITORY" --limit 100 \ + --json tagName,isPrerelease,createdAt \ + --jq '[.[] | select(.isPrerelease and (.tagName | test("-dev\\.[0-9]+$")))] + | sort_by(.createdAt) | reverse | .[env.KEEP | tonumber:] | .[].tagName' | + while read -r tag; do + gh release view "$tag" -R "$GITHUB_REPOSITORY" --json assets \ + --jq '.assets[].name | select(startswith("MEA-NAP-"))' | + while read -r name; do + echo "$tag: removing $name" + gh release delete-asset "$tag" "$name" -R "$GITHUB_REPOSITORY" --yes + done + done diff --git a/docs/python/installation.md b/docs/python/installation.md index 198345c3..f5c17fa9 100644 --- a/docs/python/installation.md +++ b/docs/python/installation.md @@ -58,8 +58,9 @@ what makes it safe to click through. need administrator rights. Tick **Create a desktop shortcut** if you want one. 4. Start MEA-NAP from the Start menu (or the desktop shortcut). -To update, download the newer installer and run it: it replaces the old -version and keeps your settings. To uninstall: **Settings → Apps → Installed +To update, use **Update** in MEA-NAP itself (see [Updates](#good-to-know) +below), or download the newer installer and run it: either way it replaces +the old version and keeps your settings. To uninstall: **Settings → Apps → Installed apps → MEA-NAP → Uninstall**. ### Installing on a Mac @@ -79,8 +80,10 @@ apps → MEA-NAP → Uninstall**. again. 4. From then on MEA-NAP opens normally. -To update, download the newer `.dmg` and drag MEA-NAP into Applications -again, replacing the old one. To uninstall, drag MEA-NAP from Applications to +To update, use **Update** in MEA-NAP itself (see [Updates](#good-to-know) +below; this needs MEA-NAP to be in Applications), or download the newer +`.dmg` and drag MEA-NAP into Applications again, replacing the old one. To +uninstall, drag MEA-NAP from Applications to the Bin. If macOS instead says MEA-NAP **"is damaged and can't be opened"**, the @@ -100,8 +103,16 @@ time. On Windows, antivirus software may also scan the app the first time it starts, so the window can take a minute to appear. **Updates.** The version number in the toolbar, beside the **Mode** -selector, shows **⬆ update** when a newer release is out. Click it, then -**Download**. +selector, shows **⬆ update** when a newer version is out. Click it, then +**Update to …**: MEA-NAP downloads the new version, closes, installs it and +opens again, about a minute in all, with no browser and none of the warnings +above. Your settings and results are kept. (Version 1.13.0 doesn't have this +button yet: update from it once by downloading, as above.) + +Tick **Also offer development builds** in the same window to get every fix as +soon as it is made (usually within half an hour), rather than waiting for the +next official release. These haven't been through release testing; untick +the box to go back to official releases from the next one on. **What the app doesn't include.** Running MATLAB or switching between MEA-NAP versions from inside the GUI, CAT-NAP's cell tracking across days diff --git a/packaging/windows/meanap.iss b/packaging/windows/meanap.iss index d701710f..1c24c1b9 100644 --- a/packaging/windows/meanap.iss +++ b/packaging/windows/meanap.iss @@ -60,3 +60,12 @@ Name: "{autodesktop}\MEA-NAP"; Filename: "{app}\MEA-NAP.exe"; Tasks: desktopicon [Run] Filename: "{app}\MEA-NAP.exe"; Description: "{cm:LaunchProgram,MEA-NAP}"; Flags: nowait postinstall skipifsilent +; The app updating itself runs this installer with /SILENT /RELAUNCH=1 +; (meanap.app_update), and the tick box above is skipped when silent. +Filename: "{app}\MEA-NAP.exe"; Flags: nowait runasoriginaluser; Check: RelaunchAfterUpdate + +[Code] +function RelaunchAfterUpdate: Boolean; +begin + Result := WizardSilent and (ExpandConstant('{param:relaunch|0}') = '1'); +end; diff --git a/python/DESKTOP_APP_PLAN.md b/python/DESKTOP_APP_PLAN.md index 83e1281c..4233ecf0 100644 --- a/python/DESKTOP_APP_PLAN.md +++ b/python/DESKTOP_APP_PLAN.md @@ -15,7 +15,8 @@ it, and gets the MEA-NAP GUI. Today's route — git clone, `uv sync`, | Left out | CAT-NAP **cell tracking** (needs `roicat` → PyTorch, not a declared dependency); **Kilosort**; **launching MATLAB / old MATLAB-only releases**; switching between versions in place. | | Distribution | `.dmg` (macOS) and a setup `.exe` (Windows, Inno Setup), built by GitHub Actions on each **official** release tag (and on demand) and attached to the GitHub release. Not on every dev build: hundreds of MB per merge. | | Download links | Asset names carry **no version** (`MEA-NAP-Windows-x64-Setup.exe`, `MEA-NAP-macOS-AppleSilicon.dmg`, `MEA-NAP-macOS-Intel.dmg`) so the install page can link to `releases/latest/download/`, one click, always the newest. README → the docs site's install page (MyST doesn't render on GitHub). | -| Updates | The app checks GitHub's releases (public repo, no git, no login) and offers a link to download the newer one. | +| Updates | The app checks GitHub's releases (public repo, no git, no login) and **updates itself in place** (1.14.0, `meanap.app_update`): downloads this computer's file (size + SHA-256 checked), quits, and the Windows installer (`/SILENT /RELAUNCH=1`) or a macOS shell script swaps it in and reopens it. Fetched by the app, not a browser, so no Chrome / Gatekeeper warnings. Falls back to the download page when it can't (Mac app not in Applications / translocated / folder not writable; files not built yet). The next start reports success or failure (`pending-update.json`). | +| Update channels | Official releases by default; a tick box **"Also offer development builds"** adds the per-merge pre-releases. `dev-build.yml` calls `desktop-app.yml` (`workflow_call`) to attach apps to each one (~20 min after the merge; a build is only offered once its file is there), and prunes the apps of all but the newest 5 dev builds. | ## What has to change in the code @@ -88,6 +89,10 @@ the package and, on Windows, no console. Each of these breaks something today: - Source-only extensions (isosplit6; OASIS on Intel) are compiled in CI with `MACOSX_DEPLOYMENT_TARGET=12.0`, or they'd only load on the runner's macOS. - No macOS/Windows machine locally: real-device testing depends on the user. +- **Self-update untested on real machines.** The macOS script is tested on + Linux with stand-ins for hdiutil/ditto/open; the Windows silent install not + at all. First real test: install 1.14.0 by hand, tick development builds, + update to the next dev build. Users on 1.13.0 must download once by hand. ## Status diff --git a/python/test_desktop_app.py b/python/test_desktop_app.py index 4e9c07e8..d67004b9 100644 --- a/python/test_desktop_app.py +++ b/python/test_desktop_app.py @@ -9,6 +9,7 @@ from __future__ import annotations +import io import json import os import subprocess @@ -191,10 +192,10 @@ def test_app_version_dialog_offers_the_download(monkeypatch): app = QApplication.instance() or QApplication([]) # noqa: F841 dlg = AppVersionDialog() dlg.set_status(updates.check_app("v1.12.0", fetch_json=_latest("v1.13.0"))) - assert dlg._download_button.text() == "Download v1.13.0" + assert dlg._update_button.text() == "Download v1.13.0" assert "v1.13.0" in dlg._status_label.text() dlg.set_status(updates.check_app("v1.12.0", fetch_json=_latest("v1.12.0"))) - assert dlg._download_button.text() == "Open download page" + assert dlg._update_button.text() == "Open download page" assert status_summary(dlg._status) == "This is the latest official release." @@ -222,3 +223,218 @@ def test_time_startup_reports_every_stage(): for name in STARTUP_IMPORTS: assert f"import {name}" in result.stdout assert "build and show the main window" in result.stdout and "total" in result.stdout + + +# ── updating the app in place ──────────────────────────────────────────────── + +from meanap import app_update # noqa: E402 + +_EXE = "MEA-NAP-Windows-x64-Setup.exe" + + +def _release(tag, *names, digest=None): + return {"tag_name": tag, "html_url": f"https://example/{tag}", "assets": [ + {"name": n, "browser_download_url": f"https://example/{tag}/{n}", "size": 10, + "digest": digest} for n in names]} + + +def _github(latest, listed=()): + def fetch(url): + return list(listed) if "per_page" in url else latest + return fetch + + +def test_check_app_finds_this_computers_file(): + s = updates.check_app("v1.12.0", asset_name=_EXE, fetch_json=_github( + _release("v1.13.0", _EXE, "MEA-NAP-macOS-Intel.dmg", digest="sha256:ab12"))) + assert s.asset == updates.AppAsset(_EXE, f"https://example/v1.13.0/{_EXE}", 10, "ab12") + + +def test_check_app_offers_a_release_before_its_files_are_built(): + s = updates.check_app("v1.12.0", asset_name=_EXE, fetch_json=_github(_release("v1.13.0"))) + assert s.newer_version == "v1.13.0" and s.asset is None + + +def test_check_app_development_builds_only_when_asked_and_built(): + github = _github(_release("v1.13.0", _EXE), [ + _release("v1.13.0-dev.3"), # newest, but its apps are still building + _release("v1.13.0-dev.2", _EXE), + _release("v1.13.0", _EXE)]) + assert updates.check_app("v1.13.0", asset_name=_EXE, fetch_json=github).newer_version is None + s = updates.check_app("v1.13.0", include_dev=True, asset_name=_EXE, fetch_json=github) + assert s.newer_version == "v1.13.0-dev.2" and s.asset.url.endswith("dev.2/" + _EXE) + # An official release overtakes the development builds before it. + s = updates.check_app("v1.13.0-dev.2", include_dev=True, asset_name=_EXE, + fetch_json=_github(_release("v1.14.0", _EXE), [_release("v1.13.0-dev.2", _EXE)])) + assert s.newer_version == "v1.14.0" + + +def test_app_asset_name_per_computer(monkeypatch): + monkeypatch.setattr(updates, "_under_rosetta", lambda: False) + assert updates.app_asset_name("win32") == _EXE + assert updates.app_asset_name("darwin", "arm64") == "MEA-NAP-macOS-AppleSilicon.dmg" + assert updates.app_asset_name("darwin", "x86_64") == "MEA-NAP-macOS-Intel.dmg" + assert updates.app_asset_name("linux", "x86_64") is None + monkeypatch.setattr(updates, "_under_rosetta", lambda: True) # Intel app on an M1 + assert updates.app_asset_name("darwin", "x86_64") == "MEA-NAP-macOS-AppleSilicon.dmg" + + +def test_app_running_version_names_the_development_build(monkeypatch): + from meanap import version + + monkeypatch.setattr(version, "build_info", lambda: {"describe": "v1.13.0-dev.4"}) + assert updates.app_running_version() == "v1.13.0-dev.4" + monkeypatch.setattr(version, "build_info", lambda: {"describe": "v1.13.0-dev.4-2-gabc-dirty"}) + assert updates.app_running_version() == f"v{version.meanap_version()}" + + +@pytest.fixture +def cache(monkeypatch, tmp_path): + monkeypatch.setattr(frozen, "cache_dir", lambda: tmp_path / "cache") + return tmp_path / "cache" / "updates" + + +class _Response(io.BytesIO): + headers: dict = {} + + +def _asset(data: bytes, **kw): + import hashlib + + return updates.AppAsset(_EXE, "https://example/x", **{ + "size": len(data), "sha256": hashlib.sha256(data).hexdigest(), **kw}) + + +def test_download_checks_size_and_checksum(cache): + data = os.urandom(3 * app_update._CHUNK + 5) + seen = [] + path = app_update.download(_asset(data), progress=lambda d, t: seen.append((d, t)), + opener=lambda _url: _Response(data)) + assert path == cache / _EXE and path.read_bytes() == data + assert seen[-1] == (len(data), len(data)) and len(seen) == 4 + with pytest.raises(app_update.UpdateFailed, match="checksum"): + app_update.download(_asset(data, sha256="00" * 32), opener=lambda _url: _Response(data)) + with pytest.raises(app_update.UpdateFailed, match="incomplete"): + app_update.download(_asset(data, size=len(data) + 1, sha256=None), + opener=lambda _url: _Response(data)) + with pytest.raises(app_update.UpdateCancelled): + app_update.download(_asset(data), cancelled=lambda: True, + opener=lambda _url: _Response(data)) + assert list(cache.iterdir()) == [] # nothing half-done or unverified is left + + +def test_why_not_self_update_on_a_mac(as_frozen, tmp_path): + def why(exe): + return app_update.why_not_self_update(platform="darwin", executable=exe) + + apps = tmp_path / "Applications" + (apps / "MEA-NAP.app" / "Contents" / "MacOS").mkdir(parents=True) + exe = str(apps / "MEA-NAP.app" / "Contents" / "MacOS" / "MEA-NAP") + assert why(exe) is None + assert "Applications" in why("/private/var/folders/x/AppTranslocation/y/d/MEA-NAP.app/" + "Contents/MacOS/MEA-NAP") + assert "Applications" in why("/Volumes/MEA-NAP/MEA-NAP.app/Contents/MacOS/MEA-NAP") + apps.chmod(0o555) + try: + assert "cannot change" in why(exe) + finally: + apps.chmod(0o755) + assert app_update.why_not_self_update(platform="win32") is None + + +def test_install_on_windows_runs_the_installer_silently(cache): + calls = [] + cache.mkdir(parents=True) + app_update.install(cache / _EXE, version="v1.14.0", running="v1.13.0", platform="win32", + popen=lambda argv, **kw: calls.append(argv)) + assert calls[0][0] == str(cache / _EXE) + assert {"/SILENT", "/RELAUNCH=1", "/CLOSEAPPLICATIONS"} <= set(calls[0]) + assert json.loads((cache / "pending-update.json").read_text())["to"] == "v1.14.0" + + +def _run_mac_script(cache, tmp_path, ditto='cp -R "$1" "$2"'): + """The real script, with hdiutil/ditto/open stood in for by small fakes. + + Returns the app folder and the list of apps the script opened. + """ + fakes = tmp_path / "bin" + fakes.mkdir() + log = tmp_path / "opened" + log.touch() + for name, body in { + # attach … -mountpoint : here the "dmg" is a folder to copy. + "hdiutil": 'if [ "$1" = attach ]; then eval mnt=\\${$(($#-1))}; eval dmg=\\${$#};' + ' cp -R "$dmg/." "$mnt/"; fi', + "ditto": ditto, + "open": f'echo "$1" >> "{log}"', + "xattr": "true", + }.items(): + (fakes / name).write_text(f"#!/bin/sh\n{body}\n") + (fakes / name).chmod(0o755) + dmg = tmp_path / "dmg" + (dmg / "MEA-NAP.app").mkdir(parents=True) + (dmg / "MEA-NAP.app" / "version").write_text("new") + app = tmp_path / "Applications" / "MEA-NAP.app" + (app / "Contents" / "MacOS").mkdir(parents=True) + (app / "version").write_text("old") + + calls = [] + app_update.install(dmg, version="v1.14.0", running="v1.13.0", platform="darwin", + executable=str(app / "Contents" / "MacOS" / "MEA-NAP"), + popen=lambda argv, **kw: calls.append(argv)) + argv = calls[0] + argv[2] = "999999" # the app has already quit + env = {**os.environ, "PATH": f"{fakes}{os.pathsep}{os.environ['PATH']}"} + subprocess.run(argv, env=env, timeout=30, check=False) # a failure is checked by the caller + return app, log.read_text().split() + + +@pytest.mark.skipif(sys.platform == "win32", reason="runs the macOS shell script") +def test_mac_install_script_swaps_the_app(cache, tmp_path): + app, opened = _run_mac_script(cache, tmp_path) + assert (app / "version").read_text() == "new" + assert sorted(p.name for p in app.parent.iterdir()) == ["MEA-NAP.app"] + assert opened == [str(app)] + assert not (cache / "update-failed.txt").exists() + + +@pytest.mark.skipif(sys.platform == "win32", reason="runs the macOS shell script") +def test_mac_install_script_keeps_the_old_app_when_it_fails(cache, tmp_path): + app, opened = _run_mac_script(cache, tmp_path, ditto="exit 1") + assert (app / "version").read_text() == "old" + assert sorted(p.name for p in app.parent.iterdir()) == ["MEA-NAP.app"] + assert opened == [str(app)] + assert "copy the new" in (cache / "update-failed.txt").read_text() + assert not app_update.take_outcome("v1.13.0").ok + + +def test_take_outcome_once_after_an_update(cache): + cache.mkdir(parents=True) + pending = cache / "pending-update.json" + assert app_update.take_outcome("v1.14.0") is None + pending.write_text(json.dumps({"to": "v1.14.0", "from": "v1.13.0"})) + assert app_update.take_outcome("v1.14.0").ok + assert app_update.take_outcome("v1.14.0") is None # said once + pending.write_text(json.dumps({"to": "v1.14.0", "from": "v1.13.0"})) + (cache / "update-failed.txt").write_text("Could not move the old MEA-NAP aside.") + out = app_update.take_outcome("v1.13.0") + assert not out.ok and "move the old" in out.message and "v1.13.0" in out.message + + +def test_app_version_dialog_updates_in_place_when_it_can(monkeypatch): + monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen") + from PyQt6.QtWidgets import QApplication + + from meanap.gui.versions_dialog import AppVersionDialog + + app = QApplication.instance() or QApplication([]) # noqa: F841 + dlg = AppVersionDialog() + built = updates.check_app("v1.13.0", asset_name=_EXE, + fetch_json=_github(_release("v1.14.0", _EXE))) + monkeypatch.setattr(app_update, "why_not_self_update", lambda: None) + dlg.set_status(built) + assert dlg._update_button.text() == "Update to v1.14.0" + monkeypatch.setattr(app_update, "why_not_self_update", lambda: "Move it to Applications.") + dlg.set_status(built) + assert dlg._update_button.text() == "Download v1.14.0" + assert dlg._blocked_label.text() == "Move it to Applications." diff --git a/src/meanap/app_update.py b/src/meanap/app_update.py new file mode 100644 index 00000000..3be6a955 --- /dev/null +++ b/src/meanap/app_update.py @@ -0,0 +1,259 @@ +"""The desktop app updating itself: download the new build, install it, reopen. + +:func:`meanap.updates.check_app` says which build is newer and which file +holds it for this computer; this module does the rest, without a browser: + +1. :func:`download` fetches that file into the app's cache folder and checks + its size and, where GitHub gives one, its SHA-256. +2. :func:`install` hands it to something that outlives the app — on Windows the + installer itself, run silently; on macOS a small shell script — and the app + then quits. Each waits for the app to be gone, puts the new build where the + old one was, and opens it. +3. On the next start, :func:`take_outcome` says whether that worked. + +Downloading it ourselves rather than in a browser also spares the user the +browser's "dangerous download" warning and, on a Mac, the "Open Anyway" step: +both are triggered by the mark a browser puts on what it downloads, and a file +fetched by the app carries none. + +Settings and results live outside the app's folder, so they survive. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import subprocess +import sys +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path + +from meanap import frozen, updates + +__all__ = ["UpdateCancelled", "UpdateFailed", "why_not_self_update", "app_bundle", + "download", "install", "take_outcome", "Outcome"] + +_CHUNK = 1 << 20 + +#: Written just before handing over to the installer; read (and removed) by the +#: next start, which compares the version it is with the one it was meant to be. +_PENDING = "pending-update.json" +#: Written by the macOS script when it had to put the old app back. +_FAILED = "update-failed.txt" + + +class UpdateCancelled(Exception): + """The user stopped the download.""" + + +class UpdateFailed(Exception): + """The download or the hand-over went wrong; the message says how.""" + + +def updates_dir() -> Path: + return frozen.cache_dir() / "updates" + + +def app_bundle(executable: str | None = None) -> Path | None: + """The ``MEA-NAP.app`` folder the running executable sits in (macOS).""" + path = Path(executable or sys.executable).resolve() + for parent in path.parents: + if parent.suffix == ".app": + return parent + return None + + +def why_not_self_update(*, platform: str | None = None, + executable: str | None = None) -> str | None: + """Why this copy cannot replace itself, or ``None`` when it can.""" + platform = platform or sys.platform + if not frozen.is_frozen(): + return "Only the desktop app updates itself." + if platform == "win32": + return None # the installer knows where it installed the app + if platform != "darwin": + return "Updating in place is built for Windows and macOS only." + bundle = app_bundle(executable) + if bundle is None: + return "Could not tell where MEA-NAP is installed." + if "/AppTranslocation/" in str(bundle) or str(bundle).startswith("/Volumes/"): + # macOS runs a downloaded app from a hidden read-only copy until it has + # been moved; and an app opened inside the .dmg is on the .dmg. + return ("MEA-NAP is running from where it was downloaded, not from " + "your Applications folder. Drag it into Applications, open it " + "from there, and it can update itself.") + if not (os.access(bundle.parent, os.W_OK) and os.access(bundle, os.W_OK)): + return (f"This account cannot change {bundle.parent}. Download the " + "new version and install it by hand, or ask an administrator.") + return None + + +def download(asset: updates.AppAsset, *, + progress: Callable[[int, int | None], None] | None = None, + cancelled: Callable[[], bool] | None = None, + opener: Callable | None = None) -> Path: + """Fetch *asset* into the updates folder and return the file. + + *progress* is called with (bytes so far, total or ``None``); *cancelled* + is polled between chunks. A file that does not match the size or digest + GitHub gave is deleted, never installed. *opener* is for tests: a callable + from URL to a readable response. + """ + import urllib.request + + folder = updates_dir() + folder.mkdir(parents=True, exist_ok=True) + for old in folder.iterdir(): # earlier downloads, finished or not + if old.is_file() and old.name not in (_PENDING, _FAILED): + old.unlink(missing_ok=True) + target = folder / asset.name + part = target.with_name(target.name + ".part") + + if opener is None: + def opener(url): + request = urllib.request.Request(url, headers={"User-Agent": "MEA-NAP"}) + return urllib.request.urlopen(request, timeout=updates.NETWORK_TIMEOUT_S, + context=updates._ssl_context()) + digest = hashlib.sha256() + done = 0 + try: + with opener(asset.url) as response, open(part, "wb") as out: + total = asset.size or _content_length(response) + while chunk := response.read(_CHUNK): + if cancelled is not None and cancelled(): + raise UpdateCancelled + out.write(chunk) + digest.update(chunk) + done += len(chunk) + if progress is not None: + progress(done, total) + except UpdateCancelled: + part.unlink(missing_ok=True) + raise + except OSError as exc: + part.unlink(missing_ok=True) + raise UpdateFailed(f"The download stopped: {exc}") from exc + if asset.size is not None and done != asset.size: + part.unlink(missing_ok=True) + raise UpdateFailed(f"The download was incomplete ({done:,} of {asset.size:,} bytes).") + if asset.sha256 is not None and digest.hexdigest() != asset.sha256.lower(): + part.unlink(missing_ok=True) + raise UpdateFailed("The downloaded file is not the one GitHub lists " + "(its checksum differs). Nothing was installed.") + part.replace(target) + return target + + +def _content_length(response) -> int | None: + try: + return int(response.headers.get("Content-Length")) + except (AttributeError, TypeError, ValueError): + return None + + +def install(installer: Path, *, version: str, running: str, + platform: str | None = None, executable: str | None = None, + popen: Callable = subprocess.Popen) -> None: + """Start replacing this app with *installer*; the caller must then quit. + + Returns once the hand-over is running. *version* is the tag being + installed, recorded so the next start can tell whether it took. + """ + platform = platform or sys.platform + folder = updates_dir() + folder.mkdir(parents=True, exist_ok=True) + (folder / _FAILED).unlink(missing_ok=True) + (folder / _PENDING).write_text(json.dumps({"to": version, "from": running})) + detached = {"stdin": subprocess.DEVNULL, "stdout": subprocess.DEVNULL, + "stderr": subprocess.DEVNULL, "close_fds": True} + try: + if platform == "win32": + # /RELAUNCH=1 is read by meanap.iss, whose usual "Launch MEA-NAP" + # tick box is skipped when silent. The installer waits for, and if + # need be closes, this app (CloseApplications) before replacing it. + flags = (getattr(subprocess, "DETACHED_PROCESS", 0) + | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)) + popen([str(installer), "/SILENT", "/SUPPRESSMSGBOXES", "/NORESTART", + "/CLOSEAPPLICATIONS", "/RELAUNCH=1", f"/LOG={folder / 'install.log'}"], + creationflags=flags, **detached) + elif platform == "darwin": + bundle = app_bundle(executable) + if bundle is None: + raise UpdateFailed("Could not tell where MEA-NAP is installed.") + script = folder / "install.sh" + script.write_text(_mac_script()) + log = open(folder / "install.log", "w") # noqa: SIM115 - the child owns it + popen(["/bin/sh", str(script), str(os.getpid()), str(installer), + str(bundle), str(folder / _FAILED)], + start_new_session=True, **{**detached, "stdout": log, "stderr": log}) + else: + raise UpdateFailed("Updating in place is built for Windows and macOS only.") + except OSError as exc: + (folder / _PENDING).unlink(missing_ok=True) + raise UpdateFailed(f"Could not start the installer: {exc}") from exc + except UpdateFailed: + (folder / _PENDING).unlink(missing_ok=True) + raise + + +def _mac_script() -> str: + """Wait for the app to quit, swap in the new one from the .dmg, open it. + + The new app is copied beside the old before anything is moved, so the + swap itself is two renames in one folder; if either fails the old app is + put back and opened, with the reason left for :func:`take_outcome`. + """ + return """\ +#!/bin/sh +pid=$1 dmg=$2 app=$3 failed=$4 +fail() { echo "$1" > "$failed"; [ -d "$app" ] && open "$app"; exit 1; } +while kill -0 "$pid" 2>/dev/null; do sleep 0.5; done +mnt=$(mktemp -d) || fail "Could not make a temporary folder." +hdiutil attach -nobrowse -readonly -noautoopen -mountpoint "$mnt" "$dmg" \\ + || fail "Could not open the downloaded disk image." +new="$app.new.$$" old="$app.old.$$" +ditto "$mnt/MEA-NAP.app" "$new"; copied=$? +hdiutil detach "$mnt" -quiet || hdiutil detach "$mnt" -force -quiet +[ $copied -eq 0 ] || { rm -rf "$new"; fail "Could not copy the new MEA-NAP out of the disk image."; } +# Fetched by the app, not a browser, it should carry no quarantine mark; make sure. +xattr -dr com.apple.quarantine "$new" 2>/dev/null +mv "$app" "$old" || { rm -rf "$new"; fail "Could not move the old MEA-NAP aside."; } +mv "$new" "$app" || { mv "$old" "$app"; fail "Could not put the new MEA-NAP in place."; } +rm -rf "$old" "$dmg" +open "$app" +""" + + +@dataclass(frozen=True) +class Outcome: + ok: bool + message: str + + +def take_outcome(running: str | None = None) -> Outcome | None: + """How the last update went, once, on the first start after it; else ``None``.""" + folder = updates_dir() + pending = folder / _PENDING + if not pending.exists(): + return None + try: + meant = json.loads(pending.read_text()).get("to") + except (OSError, ValueError, AttributeError): + meant = None + failed = folder / _FAILED + reason = failed.read_text().strip() if failed.exists() else "" + pending.unlink(missing_ok=True) + failed.unlink(missing_ok=True) + running = running or updates.app_running_version() + if meant and running == meant and not reason: + return Outcome(True, f"MEA-NAP was updated to {meant}.") + log = folder / "install.log" + return Outcome(False, ( + f"The update to {meant or 'the new version'} did not finish" + + (f": {reason}" if reason else ".") + + f" This is still {running}. You can download the new version from " + f"{updates.RELEASES_PAGE} and install it by hand." + + (f"\n\nDetails: {log}" if log.exists() else ""))) + diff --git a/src/meanap/gui/app.py b/src/meanap/gui/app.py index cf5e0ae2..9926384c 100644 --- a/src/meanap/gui/app.py +++ b/src/meanap/gui/app.py @@ -123,6 +123,7 @@ def main() -> None: splash.finish(window) print(f"MEA-NAP: window ready {time.perf_counter() - started:.1f} s after start", file=sys.stderr) + window.report_app_update() if check_on_start(): window.start_update_check() sys.exit(app.exec()) diff --git a/src/meanap/gui/main_window.py b/src/meanap/gui/main_window.py index 0ebf2d6c..f9b2b83b 100644 --- a/src/meanap/gui/main_window.py +++ b/src/meanap/gui/main_window.py @@ -48,7 +48,7 @@ from meanap.gui.tutorial import TutorialOverlay, TutorialStep, tabbar_target from meanap.frozen import is_frozen from meanap.gui.versions_dialog import ( - AppVersionDialog, Background, VersionsDialog, status_summary, + AppVersionDialog, Background, VersionsDialog, dev_builds_wanted, status_summary, ) from meanap.gui.combo import install_combo_popup_fit from meanap.gui.wheel import install_wheel_guard @@ -545,11 +545,29 @@ def start_update_check(self) -> None: """ from meanap import updates - job = Background(updates.check_app if is_frozen() else updates.check, self) + if is_frozen(): + include_dev = dev_builds_wanted() + job = Background(lambda: updates.check_app(include_dev=include_dev), self) + else: + job = Background(updates.check, self) job.done.connect(self._on_update_status) job.start() self._update_job = job + def report_app_update(self) -> None: + """Say how the update the app installed before this start went.""" + if not is_frozen(): + return + from meanap import app_update + + outcome = app_update.take_outcome() + if outcome is None: + return + if outcome.ok: + self.statusBar().showMessage(outcome.message, 15000) + else: + QMessageBox.warning(self, "MEA-NAP update", outcome.message) + def _on_update_status(self, status) -> None: self._update_status = status self._refresh_version_label() @@ -558,7 +576,8 @@ def _on_update_status(self, status) -> None: def _on_show_versions(self) -> None: if self._versions_dialog is None and is_frozen(): - self._versions_dialog = AppVersionDialog(self) + self._versions_dialog = AppVersionDialog( + self, is_busy=self._busy, on_quit=self.close) self._versions_dialog.status_changed.connect(self._on_update_status) if self._update_status is not None: self._versions_dialog.set_status(self._update_status) diff --git a/src/meanap/gui/versions_dialog.py b/src/meanap/gui/versions_dialog.py index d9be3ec0..db85e0cf 100644 --- a/src/meanap/gui/versions_dialog.py +++ b/src/meanap/gui/versions_dialog.py @@ -26,18 +26,21 @@ from PyQt6.QtCore import QObject, QSettings, Qt, QUrl, pyqtSignal from PyQt6.QtGui import QDesktopServices from PyQt6.QtWidgets import ( - QCheckBox, QDialog, QGroupBox, QHBoxLayout, QLabel, QListWidget, - QListWidgetItem, QMessageBox, QPushButton, QVBoxLayout, QWidget, + QApplication, QCheckBox, QDialog, QGroupBox, QHBoxLayout, QLabel, QListWidget, + QListWidgetItem, QMessageBox, QProgressBar, QPushButton, QVBoxLayout, QWidget, ) -from meanap import updates +from meanap import app_update, updates from meanap.version import meanap_version __all__ = ["VersionsDialog", "Background", "check_on_start", "set_check_on_start", + "dev_builds_wanted", "set_dev_builds_wanted", "default_version", "set_default_version"] _SETTINGS_KEY = "updates/check_on_start" +_DEV_BUILDS_KEY = "updates/app_dev_builds" + _DEFAULT_KEY = "versions/default" #: How many development builds the list shows. There is one per merge, so the @@ -59,6 +62,15 @@ def set_check_on_start(on: bool) -> None: QSettings("SAND Lab", "MEA-NAP").setValue(_SETTINGS_KEY, bool(on)) +def dev_builds_wanted() -> bool: + """Whether the desktop app also offers development builds (off by default).""" + return QSettings("SAND Lab", "MEA-NAP").value(_DEV_BUILDS_KEY, False, type=bool) + + +def set_dev_builds_wanted(on: bool) -> None: + QSettings("SAND Lab", "MEA-NAP").setValue(_DEV_BUILDS_KEY, bool(on)) + + def default_version() -> str | None: """The version ``meanap-gui`` opens: ``"main"``, a release tag, or ``None``. @@ -116,11 +128,16 @@ def status_summary(status: updates.Status | updates.AppStatus | None) -> str: if status is None: return "Checking GitHub for updates…" if isinstance(status, updates.AppStatus): - if status.newer_version: - return (f"A newer release, {status.newer_version}, is available " - "to download.") + newer = status.newer_version + if newer: + kind = "development build" if updates.is_dev_tag(newer) else "release" + return f"A newer {kind}, {newer}, is available." if status.latest_release: - return "This is the latest official release." + if updates.is_dev_tag(status.running) and not status.include_dev: + return ("This development build is newer than the latest " + f"official release, {status.latest_release}.") + return ("This is the latest build." if status.include_dev + else "This is the latest official release.") return status.error or "" co = status.checkout if co is None: @@ -572,24 +589,33 @@ class AppVersionDialog(QDialog): The app is not a clone, so the git half of :class:`VersionsDialog` — updating ``main``, opening another release beside this one — has nothing - to act on. What is left: what is running, whether GitHub has a newer - official release, and the page to download it from. + to act on. What is left: what is running, whether GitHub has something + newer, and updating to it in place (:mod:`meanap.app_update`) — or, where + the app cannot replace itself, the page to download it from. """ status_changed = pyqtSignal(object) + #: (bytes so far, total or 0), from the download thread. + _progress = pyqtSignal(int, int) - def __init__(self, parent: QWidget | None = None) -> None: + def __init__(self, parent: QWidget | None = None, *, + is_busy: Callable[[], bool] = lambda: False, + on_quit: Callable[[], None] | None = None) -> None: super().__init__(parent) from meanap.version import build_label self.setWindowTitle("MEA-NAP version") - self.setMinimumWidth(460) + self.setMinimumWidth(480) self._status: updates.AppStatus | None = None self._jobs: list[Background] = [] + self._is_busy = is_busy + self._on_quit = on_quit + self._cancel = threading.Event() + self._downloading = False layout = QVBoxLayout(self) build = build_label() - running = QLabel(f"MEA-NAP {meanap_version()} — desktop app" + running = QLabel(f"MEA-NAP {updates.app_running_version()} — desktop app" + (f"
build {build}" if build else "")) running.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) @@ -597,50 +623,101 @@ def __init__(self, parent: QWidget | None = None) -> None: self._status_label = QLabel() self._status_label.setWordWrap(True) layout.addWidget(self._status_label) + # Why "Update" became "Download", when the app can't replace itself. + self._blocked_label = QLabel() + self._blocked_label.setWordWrap(True) + self._blocked_label.setStyleSheet("color: palette(mid);") + layout.addWidget(self._blocked_label) row = QHBoxLayout() self._check_button = QPushButton("Check again") self._check_button.setAutoDefault(False) self._check_button.clicked.connect(self.refresh) - self._download_button = QPushButton("Open download page") - self._download_button.setDefault(True) - self._download_button.setToolTip( - "Opens the release on GitHub in your browser. Download the file for " - "your computer and install it over this one; your settings are kept.") - self._download_button.clicked.connect(self._on_download) + self._update_button = QPushButton("Open download page") + self._update_button.setDefault(True) + self._update_button.clicked.connect(self._on_update) row.addWidget(self._check_button) - row.addWidget(self._download_button) + row.addWidget(self._update_button) row.addStretch(1) layout.addLayout(row) - note = QLabel( - "Development builds and older releases run from a git clone of " - "MEA-NAP rather than the app — see the installation guide.") - note.setWordWrap(True) - note.setStyleSheet("color: palette(mid);") - layout.addWidget(note) + progress_row = QHBoxLayout() + progress_row.setContentsMargins(0, 0, 0, 0) + self._progress_bar = QProgressBar() + self._cancel_button = QPushButton("Cancel") + self._cancel_button.setAutoDefault(False) + self._cancel_button.clicked.connect(self._cancel.set) + progress_row.addWidget(self._progress_bar, 1) + progress_row.addWidget(self._cancel_button) + self._progress_box = QWidget() + self._progress_box.setLayout(progress_row) + self._progress_box.hide() + layout.addWidget(self._progress_box) + self._progress.connect(self._on_progress) self._auto_check = QCheckBox("Check for updates when MEA-NAP starts") self._auto_check.setChecked(check_on_start()) self._auto_check.toggled.connect(set_check_on_start) layout.addWidget(self._auto_check) + self._dev_check = QCheckBox("Also offer development builds") + self._dev_check.setToolTip( + "A development build is made of every change to MEA-NAP, usually " + "within half an hour of it: fixes reach you sooner, but they have " + "not been through release testing. Untick to wait for official " + "releases again (you stay on this build until the next one).") + self._dev_check.setChecked(dev_builds_wanted()) + self._dev_check.toggled.connect(self._on_dev_toggled) + layout.addWidget(self._dev_check) + + note = QLabel( + "Older releases run from a git clone of MEA-NAP rather than the " + "app — see the installation guide.") + note.setWordWrap(True) + note.setStyleSheet("color: palette(mid);") + layout.addWidget(note) self._render() def set_status(self, status: updates.AppStatus | None) -> None: self._status = status self._render() + def _can_install(self) -> bool: + return (self._status is not None and self._status.update_available + and self._status.asset is not None + and app_update.why_not_self_update() is None) + def _render(self) -> None: self._status_label.setText(status_summary(self._status)) - self._download_button.setText( - f"Download {self._status.newer_version}" - if self._status is not None and self._status.newer_version - else "Open download page") + newer = self._status.newer_version if self._status is not None else None + if newer and self._can_install(): + self._update_button.setText(f"Update to {newer}") + self._update_button.setToolTip( + "Downloads it, closes MEA-NAP, installs it and opens it again. " + "Your settings and results are kept.") + self._blocked_label.hide() + else: + self._update_button.setText(f"Download {newer}" if newer else "Open download page") + self._update_button.setToolTip( + "Opens the release on GitHub in your browser. Download the file " + "for your computer and install it over this one; your settings " + "are kept.") + reason = None + if newer and self._status.asset is None: + reason = ("The app for this computer is not attached to that " + "release yet; it is built within about half an hour " + "of the release.") + elif newer: + reason = app_update.why_not_self_update() + self._blocked_label.setText(reason or "") + self._blocked_label.setVisible(bool(reason)) + self._update_button.setEnabled(not self._downloading) + self._check_button.setEnabled(not self._downloading) def refresh(self) -> None: self._check_button.setEnabled(False) self._status_label.setText("Checking GitHub for updates…") - job = Background(updates.check_app, self) + include_dev = self._dev_check.isChecked() + job = Background(lambda: updates.check_app(include_dev=include_dev), self) job.done.connect(self._on_checked) job.failed.connect(lambda _msg: self._check_button.setEnabled(True)) self._jobs.append(job) @@ -651,6 +728,78 @@ def _on_checked(self, status: updates.AppStatus) -> None: self.set_status(status) self.status_changed.emit(status) - def _on_download(self) -> None: - url = self._status.url if self._status is not None else updates.RELEASES_PAGE - QDesktopServices.openUrl(QUrl(url)) + def _on_dev_toggled(self, on: bool) -> None: + set_dev_builds_wanted(on) + self.refresh() + + def _on_update(self) -> None: + if not self._can_install(): + url = self._status.url if self._status is not None else updates.RELEASES_PAGE + QDesktopServices.openUrl(QUrl(url)) + return + if self._is_busy(): + QMessageBox.information( + self, "MEA-NAP update", + "A run is in progress. Update once it has finished — " + "installing closes MEA-NAP.") + return + asset, version = self._status.asset, self._status.newer_version + self._downloading = True + self._cancel.clear() + self._progress_bar.setRange(0, 0) + self._progress_box.show() + self._render() + self._status_label.setText(f"Downloading {version}…") + report = self._progress.emit + job = Background(lambda: app_update.download( + asset, progress=lambda done, total: report(done, total or 0), + cancelled=self._cancel.is_set), self) + job.done.connect(lambda path: self._on_downloaded(path, version)) + job.failed.connect(self._on_download_failed) + self._jobs.append(job) + job.start() + + def _on_progress(self, done: int, total: int) -> None: + if total: + self._progress_bar.setRange(0, 1000) + self._progress_bar.setValue(int(1000 * done / total)) + self._progress_bar.setFormat(f"{done / 1e6:.0f} of {total / 1e6:.0f} MB") + else: + self._progress_bar.setFormat(f"{done / 1e6:.0f} MB") + + def _finish_download(self) -> None: + self._downloading = False + self._progress_box.hide() + self._render() + + def _on_download_failed(self, message: str) -> None: + self._finish_download() + if self._cancel.is_set(): + return + QMessageBox.warning(self, "MEA-NAP update", message or "The download failed.") + + def _on_downloaded(self, path, version: str) -> None: + self._finish_download() + if self._is_busy(): + QMessageBox.information( + self, "MEA-NAP update", + f"{version} is downloaded, but a run has started since. Update " + "again once it has finished.") + return + answer = QMessageBox.question( + self, "MEA-NAP update", + f"MEA-NAP will close, install {version} and open again. This takes " + "about a minute.\n\nInstall now?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, + QMessageBox.StandardButton.Yes) + if answer != QMessageBox.StandardButton.Yes: + return + try: + app_update.install(Path(path), version=version, + running=updates.app_running_version()) + except app_update.UpdateFailed as exc: + QMessageBox.warning(self, "MEA-NAP update", str(exc)) + return + if self._on_quit is not None: + self._on_quit() + QApplication.quit() diff --git a/src/meanap/updates.py b/src/meanap/updates.py index 8a8c137b..6dc49ee7 100644 --- a/src/meanap/updates.py +++ b/src/meanap/updates.py @@ -451,22 +451,77 @@ def _explain_merge_failure(err: str, repo: Path) -> str: # # The packaged app (see packaging/) is not a clone: no git, no history, and its # folder may be read-only. So there is nothing to fast-forward and no release -# to check out beside it. What it can still do is ask GitHub whether a newer -# official release exists and send the user to download it — over HTTPS from -# the public API, needing no git and no login. +# to check out beside it. What it can do is ask GitHub whether a newer build +# exists and which file holds it for this computer — over HTTPS from the public +# API, needing no git and no login — and then replace itself with that file +# (:mod:`meanap.app_update`). +# +# Two channels: official releases for everyone, and, for those who tick the +# box, the development build CI makes of every merge to main as well. RELEASES_PAGE = f"https://github.com/{UPSTREAM}/releases" _LATEST_RELEASE_API = f"https://api.github.com/repos/{UPSTREAM}/releases/latest" +#: The newest releases of either kind. One page is plenty: only the newest +#: development build matters, and the newest official one is asked for apart. +_RELEASES_API = f"https://api.github.com/repos/{UPSTREAM}/releases?per_page=30" + +#: The app's file on each release, per computer. No version in the names: the +#: install page links to releases/latest/download/ (desktop-app.yml). +APP_ASSETS = { + "windows": "MEA-NAP-Windows-x64-Setup.exe", + "mac-arm64": "MEA-NAP-macOS-AppleSilicon.dmg", + "mac-x86_64": "MEA-NAP-macOS-Intel.dmg", +} + + +def app_asset_name(platform: str | None = None, machine: str | None = None) -> str | None: + """The release file that installs the app on this computer, or ``None``. + + An Intel build running on Apple silicon (through Rosetta) reports an Intel + machine; it is given the Apple-silicon file, which runs natively. + """ + import platform as _platform + + platform = platform or sys.platform + if platform == "win32": + return APP_ASSETS["windows"] + if platform != "darwin": + return None + machine = machine or _platform.machine() + if machine == "arm64" or _under_rosetta(): + return APP_ASSETS["mac-arm64"] + return APP_ASSETS["mac-x86_64"] + + +def _under_rosetta() -> bool: + try: + out = subprocess.run(["sysctl", "-n", "sysctl.proc_translated"], + check=False, capture_output=True, text=True, timeout=5).stdout + except (OSError, subprocess.SubprocessError): + return False + return out.strip() == "1" + + +@dataclass(frozen=True) +class AppAsset: + """One downloadable file of a release.""" + + name: str + url: str + size: int | None = None + sha256: str | None = None # GitHub's digest, when it gives one @dataclass class AppStatus: """The outcome of one :func:`check_app`.""" - running: str # "v1.12.0" + running: str # "v1.12.0", or "v1.13.0-dev.4" for a development build latest_release: str | None = None - url: str = RELEASES_PAGE # where to download it + url: str = RELEASES_PAGE # the release's page, to download it by hand error: str | None = None + asset: AppAsset | None = None # the file to update from; None if not built for this computer + include_dev: bool = False # whether development builds were looked at @property def newer_version(self) -> str | None: @@ -480,30 +535,70 @@ def update_available(self) -> bool: return self.newer_version is not None -def check_app(running: str | None = None, *, fetch_json=None) -> AppStatus: - """Ask GitHub for the newest official release (never a development build). +def app_running_version() -> str: + """The running app's tag: its development build's if it is one.""" + from meanap.version import build_info, meanap_version + + describe = build_info().get("describe") + if is_dev_tag(describe): + return describe + return f"v{meanap_version()}" + - *fetch_json* is for tests: a callable from URL to the decoded JSON. +def check_app(running: str | None = None, *, include_dev: bool = False, + asset_name: str | None = None, fetch_json=None) -> AppStatus: + """Ask GitHub for the newest official release — or, with *include_dev*, + the newest development build if that is newer still. + + A development build is only offered once the app has been built for it + (CI attaches the files some 20 minutes after the merge); an official + release is offered at once, with the download page as the way to get it + until its files are there. *asset_name* defaults to this computer's + (:func:`app_asset_name`); *fetch_json* is for tests: a callable from URL to + the decoded JSON. """ if running is None: - from meanap.version import meanap_version - - running = f"v{meanap_version()}" - status = AppStatus(running=running) + running = app_running_version() + if asset_name is None: + asset_name = app_asset_name() + fetch = fetch_json or _fetch_json + status = AppStatus(running=running, include_dev=include_dev) try: - data = (fetch_json or _fetch_json)(_LATEST_RELEASE_API) + candidates = [fetch(_LATEST_RELEASE_API)] + if include_dev: + listed = fetch(_RELEASES_API) + candidates += [r for r in (listed if isinstance(listed, list) else []) + if isinstance(r, dict) and is_dev_tag(r.get("tag_name")) + and not r.get("draft") and _find_asset(r, asset_name)] except Exception as exc: # noqa: BLE001 - offline is normal, never fatal status.error = f"Could not reach GitHub to check for updates ({exc})." return status - tag = data.get("tag_name") if isinstance(data, dict) else None + official = candidates[0] + tag = official.get("tag_name") if isinstance(official, dict) else None if not is_version_tag(tag) or is_dev_tag(tag): status.error = "GitHub did not say which release is the latest." return status - status.latest_release = tag - status.url = data.get("html_url") or RELEASES_PAGE + newest = max(candidates, key=lambda r: _tag_key(r.get("tag_name"))) + status.latest_release = newest["tag_name"] + status.url = newest.get("html_url") or RELEASES_PAGE + status.asset = _find_asset(newest, asset_name) return status +def _find_asset(release: dict, name: str | None) -> AppAsset | None: + if not name: + return None + for asset in release.get("assets") or []: + if isinstance(asset, dict) and asset.get("name") == name: + url = asset.get("browser_download_url") + if not url: + return None + digest = asset.get("digest") or "" + return AppAsset(name=name, url=url, size=asset.get("size"), + sha256=digest[7:] if digest.startswith("sha256:") else None) + return None + + def _fetch_json(url: str): import json import urllib.request diff --git a/version.txt b/version.txt index feaae22b..850e7424 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.13.0 +1.14.0