Auto Updater
Install @quickgui/extension-updater to include automatic updates. Apps that omit it do not ship the updater.
#Configuration
bun add @quickgui/extension-updater#1. Create a signing key
Every update is signed with an Ed25519 key. Create the pair once and keep it for the life of the app: installs only accept updates signed by the key they were built with.
bunx @quickgui/cli keygen --out-dir "$HOME/.config/my-app/update-keys"
cp "$HOME/.config/my-app/update-keys/quickgui-update.pub" ./update-public-key.pubThe command writes quickgui-update.pub and quickgui-update.key. The public key is safe to commit; the config below reads it from update-public-key.pub. The .key file is the secret: store it in a password manager or your CI secret store, never in the repository. If you lose it, existing installs can never update again.
#2. Choose where releases are published
Set updates.target to "github" or "s3", and fill in the section with the same name. The target decides where --upload sends files and which URLs the app checks, so you never write a feed or download URL yourself. Both sections may stay in the config; only the one target names is used. The feed address is built into the app, so changing target later only affects versions built after the change. Keep publishing to the old target until users have moved over.
GitHub Releases needs only the repository. It must be public, because installed apps download updates without credentials.
import { readFileSync } from "node:fs";
import { defineConfig } from "@quickgui/cli";
export default defineConfig({
language: "typescript",
name: "My App",
identifier: "com.example.my-app",
version: "1.0.0",
entry: "app.tsx",
extensions: ["@quickgui/extension-updater"],
updates: {
target: "github", // publish to and update from GitHub Releases
publicKey: readFileSync(
new URL("./update-public-key.pub", import.meta.url),
"utf8",
).trim(),
automaticChecks: true, // default for new installs; users can change it
changelog: "CHANGELOG.md", // optional; this file is used by default when it exists
github: {
repository: "example/my-app", // owner/name of a PUBLIC repository
// tagPrefix: "v", // release tag = tagPrefix + version, e.g. v1.2.0
},
},
});Amazon S3 needs the bucket, its region, and the public HTTPS address that serves it (the bucket's own URL, or a CloudFront domain in front of it).
import { readFileSync } from "node:fs";
import { defineConfig } from "@quickgui/cli";
export default defineConfig({
language: "typescript",
name: "My App",
identifier: "com.example.my-app",
version: "1.0.0",
entry: "app.tsx",
extensions: ["@quickgui/extension-updater"],
updates: {
target: "s3", // publish to and update from the bucket below
publicKey: readFileSync(
new URL("./update-public-key.pub", import.meta.url),
"utf8",
).trim(),
automaticChecks: true, // default for new installs; users can change it
changelog: "CHANGELOG.md", // optional; this file is used by default when it exists
s3: {
bucket: "my-app-releases",
region: "us-east-1",
publicUrl: "https://my-app-releases.s3.us-east-1.amazonaws.com",
// prefix: "stable", // optional folder inside the bucket
},
},
});Cloudflare R2, MinIO, and other S3-compatible stores add endpoint, the address of the S3 API. publicUrl is still the address users download from, which for R2 is a custom domain or the bucket's r2.dev URL.
import { readFileSync } from "node:fs";
import { defineConfig } from "@quickgui/cli";
export default defineConfig({
language: "typescript",
name: "My App",
identifier: "com.example.my-app",
version: "1.0.0",
entry: "app.tsx",
extensions: ["@quickgui/extension-updater"],
updates: {
target: "s3", // publish to and update from the bucket below
publicKey: readFileSync(
new URL("./update-public-key.pub", import.meta.url),
"utf8",
).trim(),
automaticChecks: true, // default for new installs; users can change it
changelog: "CHANGELOG.md", // optional; this file is used by default when it exists
s3: {
bucket: "my-app-releases",
endpoint: "https://ACCOUNT_ID.r2.cloudflarestorage.com", // S3 API endpoint
region: "auto",
publicUrl: "https://downloads.example.com", // custom domain or https://pub-….r2.dev
// prefix: "stable",
},
},
});The bucket has to allow public reads of the uploaded files; QuickGUI uploads with your credentials but does not change bucket policy.
#3. All updates options
| Option | Required | Meaning |
|---|---|---|
publicKey | yes | Contents of quickgui-update.pub. Embedded in the app and used to verify every update. |
target | yes | "github" or "s3". Chooses which section is used. |
github.repository | with target = "github" | owner/name of a public GitHub repository. |
github.tagPrefix | no | Release tag is this plus the version. Default "v", so version 1.2.0 is released as v1.2.0. |
s3.bucket | with target = "s3" | Bucket name. |
s3.publicUrl | with target = "s3" | Public HTTPS address that serves the bucket. No query string or credentials. |
s3.endpoint | no | S3 API endpoint for non-AWS providers. Omit for AWS. |
s3.region | no | Bucket region, or "auto" for R2. |
s3.prefix | no | Folder inside the bucket. It is also appended to publicUrl. |
automaticChecks | no | Whether new installs check at launch. Default true. A user's own choice is remembered and wins. |
changelog | no | Markdown changelog for all versions. The ## x.y.z section matching version becomes the release notes. Default CHANGELOG.md when that file exists. |
ed25519SecretKey | no | Path to the private key file for local builds. The QUICKGUI_UPDATER_PRIVATE_KEY environment variable overrides it. |
manifest | no | true signs and writes the update feed on every production build, as --update-manifest does. |
#Where the URLs come from
The target fixes every address. <file> is a name such as appcast-darwin-arm64.xml, and <tag> is tagPrefix plus the version.
| GitHub | S3 | |
|---|---|---|
Update feed, install.sh, latest-linux-<arch>.txt | https://github.com/<repository>/releases/latest/download/<file> | <publicUrl>/<prefix>/<file> |
| Installers and update archives | https://github.com/<repository>/releases/download/<tag>/<file> | <publicUrl>/<prefix>/<file> |
On GitHub the feed always comes from the release marked Latest. Drafts and pre-releases are never latest, so they do not reach users until you publish them as a normal release. --upload always creates a draft.
The CLI embeds the version, identifier, feed URL, and public key in the app. Secret keys are never embedded.
#Application usage
Start after app.whenReady(), outside components, so every window shares one session:
import { app } from "@quickgui/native";
import Updater from "@quickgui/extension-updater";
await app.whenReady();
export const updater = new Updater({}, event => {
if (event.error) console.error(event.error);
if (event.quitRequired) {
void app.quit().catch(console.error);
}
});
await updater.ready;import { Button } from "@quickgui/solid";
import { updater } from "./updates";
export function CheckForUpdatesButton() {
return (
<Button style={{ p3: true, roundedLg: true }} onClick={() => void updater.check()}>
Check for Updates
</Button>
);
}#Lifecycle
| API | Use |
|---|---|
updater.check() | Manually check for an update. |
updater.install() | Install an available update. |
updater.setAutomaticChecks(enabled) | Change the automatic-check preference. |
updater.state | Read the last event. |
updater.close() | Stop a session the app no longer needs. |
Status is idle, checking, available, downloading, installing, or disabled. Closing a window must not close the shared updater.
Development builds report disabled by default. Use { allowDevelopment: true } only with a test feed and a disposable app bundle.
#Platforms
macOS uses Sparkle. Windows uses a signed NSIS installer. Linux uses an AppImage or the install.sh tarball that quickgui build writes (curl -fsSL https://example.com/install.sh | sh installs it into ~/.local without root). Linux package-manager installs report disabled. Current prebuilt packages cover macOS; other targets need a native build on that host.
#Publishing
#1. Release from your machine
Raise version in your config first: apps only install a version higher than their own. Add a ## <version> section to CHANGELOG.md for it. Then give the build the private key and run it with --upload:
export QUICKGUI_UPDATER_PRIVATE_KEY="$(cat ~/.config/my-app/update-keys/quickgui-update.key)"
bunx @quickgui/cli build --uploadFor GitHub, sign in once with gh auth login (or set GH_TOKEN). For S3, set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.
--upload builds the app, signs the update files, writes the feed, and publishes everything. It prints each public URL when it finishes; on GitHub those URLs work once you publish the draft release. Use --update-manifest instead to sign and write the feed without uploading, for example to inspect dist/ first. Run the command once per target; every target has its own feed, so a Mac never downloads a Linux build.
#2. What gets uploaded
| Target | Files |
|---|---|
| macOS | <Name>.dmg for new users, <Name>-<version>-<target>.zip for updates, appcast-<target>.xml |
| Windows | <Name>-<version>-<target>.exe (installer and update), appcast-<target>.xml |
| Linux | .AppImage, <Name>-<version>-<target>.tar.gz, .deb if configured, install.sh, latest-linux-<arch>.txt, appcast-<target>.xml |
Versioned files are uploaded first and the feed last, so the feed never points at a file that is not there yet. On GitHub the first target creates the release <tag> as a draft, with this version's changelog section as its description, and later targets add their files to it. Re-running replaces files of the same name.
A draft is invisible to users and the update feed does not move, so a half-uploaded release can never be offered. When every target has uploaded, review the draft and publish it, from the release page or with:
gh release edit v1.2.0 --repo example/my-app --draft=falsePublishing marks it as the latest release, and installed apps find the update on their next check. S3 has no draft stage: files are live as soon as they are uploaded.
#3. Release from GitHub Actions
Add the private key as a repository secret named QUICKGUI_UPDATER_PRIVATE_KEY (Settings → Secrets and variables → Actions), then push a tag that matches the version in your config:
# .github/workflows/release.yml
name: Release
on:
push:
tags: ["v*"]
permissions:
contents: write # lets GITHUB_TOKEN create the release and upload assets
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
target: darwin-arm64
- os: ubuntu-22.04
target: linux-x64
- os: windows-latest
target: windows-x64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install
- run: bunx @quickgui/cli build --target ${{ matrix.target }} --upload
env:
QUICKGUI_UPDATER_PRIVATE_KEY: ${{ secrets.QUICKGUI_UPDATER_PRIVATE_KEY }}
GH_TOKEN: ${{ github.token }}
# For updates.s3 instead of GH_TOKEN:
# AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
# AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
# updates.github only: the uploads above go into a draft release. Publish it once every
# target succeeded. Remove this job to review and publish drafts by hand.
publish:
needs: build
runs-on: ubuntu-latest
steps:
- run: gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false
env:
GH_TOKEN: ${{ github.token }}The three build jobs upload into the same draft release, and publish makes it live only after all of them succeeded, so users never get a release with a platform missing. The gh CLI is preinstalled on GitHub's runners. macOS builds that you distribute should also pass --sign and --notarize; Windows needs NSIS (makensis) on the runner, and Linux produces an AppImage only when appimagetool is installed (the tarball needs nothing).
#Release notes
Release notes come from one Markdown changelog that covers every version. When you publish version 1.2.0, the build takes the section under the ## 1.2.0 heading and ships it with the update.
updates.changelog names the file, relative to the project. Leave it out and CHANGELOG.md in the project directory is used when it exists.
# Changelog
## 1.2.0 - 2026-09-19
Faster startup and a fix for lost drafts.
- Search inside a project with **Ctrl+Shift+F**
- Windows remember their size and position
- Fix drafts being lost when the app quits during a save
## 1.1.0
- Add a tray icon
- Fix a crash when opening an empty project- A release heading is
## x.y.z, wherex.y.zis theversionin your config. A date may follow:## x.y.z - 2026-09-19. - The notes are everything below that heading up to the next
##heading. Text above the first release heading, such as the title or an introduction, is never published. - Use
###for groups such as "Added" and "Fixed" if you like; they stay inside the section. - A section may hold at most 16 KiB.
- Put the newest release at the top. Write for the people who use the app: what changed for them, not the commit history.
Publishing fails with CHANGELOG.md has no notes for this release. Add a `## 1.2.0` section when the heading is missing or its section is empty, so a release cannot go out with the wrong notes. A project with no changelog at all publishes without notes; the update is still offered with its version number. The build also writes the extracted section to dist/<target>/release-notes.md.
| Where it appears | How it is shown |
|---|---|
| Windows and Linux | Your app receives the Markdown unchanged as event.notes. Render it with the Markdown component, or show it as text. |
| macOS | Sparkle's update window renders it as Markdown. |
GitHub release page (target = "github") | It becomes the release description. |
#S3 credentials
| Variable | Meaning |
|---|---|
AWS_ACCESS_KEY_ID or S3_ACCESS_KEY_ID | Access key with permission to write objects in the bucket |
AWS_SECRET_ACCESS_KEY or S3_SECRET_ACCESS_KEY | Its secret |
AWS_SESSION_TOKEN or S3_SESSION_TOKEN | Only for temporary credentials |
For R2, create an API token with Object Read & Write on the bucket and use its access key ID and secret. If a CDN caches your bucket, give appcast-*.xml, latest-linux-<arch>.txt, and install.sh a short cache lifetime, because they change with every release.
#Linux install command
Linux builds also publish install.sh. It installs the tarball into ~/.local/<package>.app without root, adds the app to the applications menu, and that install keeps itself up to date. Give users one line:
# updates.github
curl -fsSL https://github.com/example/my-app/releases/latest/download/install.sh | sh
# updates.s3
curl -fsSL https://downloads.example.com/install.sh | shAdd | sh -s -- --uninstall to the same URL to remove it. Set linux.tarball = false to skip the tarball and script.
#Troubleshooting
- The app says it is up to date. The published
versionmust be higher than the installed one, the GitHub release must be published (--uploadleaves it as a draft) and not marked as a pre-release, and the feed for that target must exist. Open the feed URL from the table above in a browser to check. Set updates.publicKey and QUICKGUI_UPDATER_PRIVATE_KEY. The build has no private key. Export the variable or seted25519SecretKey.Update signing key does not match updates.publicKey. The private key is not the pair of the configured public key. Use the original key; apps in the field only trust that one.CHANGELOG.md has no notes for this release. Add a## x.y.zheading that equalsversionexactly, with at least one line under it.- The status is
disabled. Development builds never update, and neither do.deb, system-wide, or root-owned Linux installs. Test with a production build. GitHub upload failed. Checkgh auth status, that the token can write to the repository (contents: writein Actions), and that the repository name is right.S3 upload failed. Check the credentials,region, andendpoint. A 403 on download means the bucket does not allow public reads.
#Native artifacts
From this repository, build the updater once with bun packages/native/build.ts --extension updater. Packaged apps include it when the extension is listed in config.
