From e7684a27aedaae23c8c9ed885e3aaa730817658e Mon Sep 17 00:00:00 2001 From: timsit Date: Thu, 22 Oct 2026 19:02:39 +0100 Subject: [PATCH] Desktop app updates itself in place, with optional development builds (1.14.0) "Update to vX" in the app's version window downloads this computer's file (size and SHA-256 checked), quits, and hands over: the Windows installer runs silently and reopens the app (/RELAUNCH=1 in meanap.iss); on a Mac a shell script swaps the .app in Applications and reopens it, putting the old one back if anything fails. The next start reports how it went. Fetched by the app rather than a browser, the file carries no download mark, so no Chrome or Gatekeeper warning. Falls back to the download page where it can't (Mac app not in Applications, folder not writable, files not built yet). "Also offer development builds" adds the per-merge pre-releases: dev-build.yml now calls desktop-app.yml to attach apps to each one, and removes the apps of all but the newest five dev builds. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X78uDckMcD2xCkAo9Hj7g8 --- .github/workflows/desktop-app.yml | 19 ++- .github/workflows/dev-build.yml | 49 +++++- docs/python/installation.md | 23 ++- packaging/windows/meanap.iss | 9 ++ python/DESKTOP_APP_PLAN.md | 7 +- python/test_desktop_app.py | 220 ++++++++++++++++++++++++- src/meanap/app_update.py | 259 ++++++++++++++++++++++++++++++ src/meanap/gui/app.py | 1 + src/meanap/gui/main_window.py | 25 ++- src/meanap/gui/versions_dialog.py | 215 +++++++++++++++++++++---- src/meanap/updates.py | 127 +++++++++++++-- version.txt | 2 +- 12 files changed, 884 insertions(+), 72 deletions(-) create mode 100644 src/meanap/app_update.py 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