Self-updating apps
pyzed.updater lets an app built with pyzed build update itself from the GitHub Releases of its repository: it
finds the newest release, downloads the build for this system, checks it, and swaps the app folder for the new one
when the app restarts. Pyzed Design updates itself with the same module.
sequenceDiagram
participant App as Your app (v1.0.0)
participant GH as GitHub Releases
App->>GH: latest("acme/shop")
GH-->>App: v1.1.0 + shop-windows-x86_64.zip (and its SHA-256)
App->>GH: download(asset)
Note over App: checks the SHA-256, unpacks the zip
App->>App: replace_on_exit(new folder), then quit
Note over App: a small script waits, swaps the folders and starts v1.1.0
What a release must contain
Section titled “What a release must contain”-
A version tag such as
v1.1.0(pre-releases:v1.2.0-rc.1,v1.2.0-beta.2…). -
One zip per system, named
<app>-<system>-<arch>.zip:System Example Windows shop-windows-x86_64.zipmacOS shop-macos-arm64.zip,shop-macos-x86_64.zipLinux shop-linux-x86_64.zip,shop-linux-aarch64.zipupdater.asset_name("shop")returns the name for the machine it runs on. -
Inside each zip, exactly one entry: the app folder as
pyzed buildmade it (Shop/), orShop.appon macOS (zip it withditto -c -k --keepParentto keep the bundle’s signature).
The CI page has a workflow that builds and uploads these zips.
Check, download, restart
Section titled “Check, download, restart”The functions block while they talk to the network, so run them off the UI thread, for example with
asyncio.to_thread in a coroutine (see Async). A complete app:
import asyncioimport os
import pyzed as pzfrom pyzed import ui, updater
__version__ = "1.0.0" # keep it in sync with pyproject.tomlREPO = "acme/shop" # the GitHub repository with the releasesAPP = "shop" # the <app> part of the zip names
class Shop(pz.App): title = "Shop"
def __init__(self): self.release = None # a newer release, when there is one self.ready = None # the downloaded new version, ready to swap in self.status = ""
def on_start(self): if updater.is_frozen(): # only a packaged app can replace itself return self.check()
async def check(self): try: release = await asyncio.to_thread(updater.latest, REPO) except updater.UpdateError as err: return ("status", f"Could not check for updates: {err}") if updater.is_newer(release.version, __version__) and release.asset_for(updater.asset_name(APP)): return ("found", release) return ("status", "You have the latest version.")
async def download(self): asset = self.release.asset_for(updater.asset_name(APP)) self.status = f"Downloading {self.release.version}…" try: archive = await asyncio.to_thread(updater.download, asset) bundle = await asyncio.to_thread(updater.extract, archive) except updater.UpdateError as err: return ("status", f"The update failed: {err}") return ("ready", bundle)
def restart(self): updater.replace_on_exit(self.ready) # swaps the folders once this process ends... os._exit(0) # ...so end it now; the new version starts by itself
def update(self, message): kind, value = message if kind == "found": self.release, self.status = value, f"Version {value.version} is available." elif kind == "ready": self.ready, self.status = value, "Restart to finish the update." else: self.status = value
def view(self): actions = [] if self.ready: actions.append(ui.button("Restart to update", self.restart, variant="solid")) elif self.release: actions.append(ui.button("Download and install", self.download, variant="solid")) return pz.column([ui.heading("Shop"), ui.muted(f"Version {__version__}"), ui.label(self.status), *actions], spacing=10, padding=24)The functions
Section titled “The functions”| Function | What it does |
|---|---|
latest(repo, prerelease=False) |
The newest Release of "owner/name". With prerelease=True, pre-releases count too (a “preview channel”). |
release(repo, version) |
The release of one version ("1.1.0" or "v1.1.0"). |
is_newer(candidate, current) |
Compares versions; a final release is newer than its pre-releases (1.2.0 > 1.2.0-rc.1). |
asset_name(app) |
"<app>-<system>-<arch>.zip" for this machine. |
Release.asset_for(name) |
The release file with that name, or None if the release has no build for this system. |
download(asset, folder=None, progress=None) |
Downloads to folder (a temporary one by default), checks the SHA-256 and returns the path. progress receives the fraction done, from 0 to 1. A file already in folder with the right SHA-256 is reused. |
extract(archive) |
Unpacks the zip and returns the app inside it. |
replace_on_exit(bundle, relaunch=True, args=(), keep=()) |
Starts a small script that waits for the app to exit, swaps the old folder for bundle (restoring the old one if that fails) and starts the new version. keep lists files to carry over, such as an installer’s uninstaller ("unins*"). Stop the app’s child processes first: on Windows a folder still in use cannot move (the script retries for about 30 seconds, then starts the old version again). |
is_frozen() |
Whether this is a packaged app. |
install_dir() |
The folder (or .app) of the running app, None when running from Python. |
can_update_in_place() |
Whether the app is packaged and its folder is writable, so replace_on_exit can work. |
A Release has version, tag, notes (the release notes, Markdown: show them with
ui.markdown), url, published, prerelease and assets. Errors are UpdateError
with a readable message (“could not reach GitHub”, “rate limit”…): network problems never crash the app.
Things to know
Section titled “Things to know”- Where the app lives matters. The app can only replace a folder it can write to. Check
updater.can_update_in_place()before offering the update; if it isFalse(for example an app inC:\Program Files), point people to the download page instead. Per-user installs (%LOCALAPPDATA%\Programson Windows,~/.local/shareon Linux,/Applicationsfor admin users on macOS) work. - Folder builds only. A
--onefilebuild cannot update itself this way. - Signed apps stay signed on macOS as long as the zip was made with
dittofrom a signed (and notarized).app. - GitHub’s API is rate limited to 60 requests per hour per IP address without a token: check once a day, not on every start.