Skip to content

Auto Updater

Enable the updater feature on the quickgui crate. The updater is part of the Rust core, so Rust apps call quickgui::updater directly. Go and TypeScript load the same implementation as an extension, with the same feed, key, and install helper.

#Configuration

#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.pub

The 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: "rust",
  name: "My App",
  identifier: "com.example.my-app",
  version: "1.0.0",
  entry: ".",
  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: "rust",
  name: "My App",
  identifier: "com.example.my-app",
  version: "1.0.0",
  entry: ".",
  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: "rust",
  name: "My App",
  identifier: "com.example.my-app",
  version: "1.0.0",
  entry: ".",
  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.

Enable the crate feature. Keep the crate version already in your project:

[dependencies]
quickgui = { version = "...", features = ["updater"] }

#3. All updates options

OptionRequiredMeaning
publicKeyyesContents of quickgui-update.pub. Embedded in the app and used to verify every update.
targetyes"github" or "s3". Chooses which section is used.
github.repositorywith target = "github"owner/name of a public GitHub repository.
github.tagPrefixnoRelease tag is this plus the version. Default "v", so version 1.2.0 is released as v1.2.0.
s3.bucketwith target = "s3"Bucket name.
s3.publicUrlwith target = "s3"Public HTTPS address that serves the bucket. No query string or credentials.
s3.endpointnoS3 API endpoint for non-AWS providers. Omit for AWS.
s3.regionnoBucket region, or "auto" for R2.
s3.prefixnoFolder inside the bucket. It is also appended to publicUrl.
automaticChecksnoWhether new installs check at launch. Default true. A user's own choice is remembered and wins.
changelognoMarkdown changelog for all versions. The ## x.y.z section matching version becomes the release notes. Default CHANGELOG.md when that file exists.
ed25519SecretKeynoPath to the private key file for local builds. The QUICKGUI_UPDATER_PRIVATE_KEY environment variable overrides it.
manifestnotrue 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.

GitHubS3
Update feed, install.sh, latest-linux-<arch>.txthttps://github.com/<repository>/releases/latest/download/<file><publicUrl>/<prefix>/<file>
Installers and update archiveshttps://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 one Updater after the application is ready and read its events on the application thread:

use std::rc::Rc;
use quickgui::updater::{UpdateStatus, Updater};

fn start_updates(&mut self, cx: &mut quickgui::EventContext) {
    let task = cx.spawn(|task_cx: quickgui::AsyncViewContext<Self>| async move {
        let Ok((updater, mut events)) = Updater::start(quickgui::updater_options!()).await else {
            return Ok(());
        };
        let updater = Rc::new(updater);
        task_cx.update({
            let updater = updater.clone();
            move |this, _| this.updater = Some(updater)
        }).await?;
        while let Some(event) = events.next().await {
            task_cx.update(move |this, cx| {
                if event.quit_required {
                    cx.exit();
                }
                this.update_available = event.status == UpdateStatus::Available;
                this.update_version = event.version.clone();
                cx.invalidate();
            }).await?;
        }
        Ok::<(), quickgui::AsyncContextError>(())
    });
    self.update_events = task.ok();
}

quickgui::updater_options!() expands to the settings quickgui build embedded. With plain cargo run it is empty, and the updater reports Disabled, as every development build does.

Call updater.check().await from a Check for Updates action, updater.install().await from an update banner, and updater.set_automatic_checks(enabled).await from settings. Each resolves when the command is accepted or rejected; results arrive as events. updater.state() returns the latest event without native calls.

#Lifecycle

Keep the Updater for the process lifetime; it belongs to the application, not to a window. Dropping it cancels checks and downloads. quit_required means the verified installer is waiting for the app to exit: call cx.exit() after saving work. If quit is cancelled, the helper times out and leaves the current app in place. The core tells the helper when the new version has started, and the helper restores the previous version if it never does.

#Platforms

PlatformUpdate payloadInstallation
macOSZIP containing the signed .appSparkle, embedded only with the feature
WindowsQuickGUI NSIS .exe installerA helper waits for exit, runs the verified installer, then relaunches
LinuxType-2 .AppImage, or the install.sh tarballA helper waits for exit and replaces the user-owned AppImage or install directory, restoring it if the new build fails to start

Linux .deb packages, system binaries, and root-owned installs report Disabled. Use the platform package manager there.

quickgui build also writes <Name>-<version>-linux-<arch>.tar.gz, install.sh, and latest-linux-<arch>.txt. --upload publishes all three, and with GitHub the script has a stable URL: curl -fsSL https://github.com/<owner>/<repo>/releases/latest/download/install.sh | sh installs into ~/.local/<package>.app without root, adds the app to the applications menu, and that install keeps itself current. Set linux.tarball = false to skip it.

#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 --upload

For 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

TargetFiles
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=false

Publishing 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
      - uses: dtolnay/rust-toolchain@stable
      - 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, where x.y.z is the version in 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 appearsHow it is shown
Windows and LinuxYour app receives the Markdown unchanged as event.notes. Render it with the Markdown component, or show it as text.
macOSSparkle's update window renders it as Markdown.
GitHub release page (target = "github")It becomes the release description.

#S3 credentials

VariableMeaning
AWS_ACCESS_KEY_ID or S3_ACCESS_KEY_IDAccess key with permission to write objects in the bucket
AWS_SECRET_ACCESS_KEY or S3_SECRET_ACCESS_KEYIts secret
AWS_SESSION_TOKEN or S3_SESSION_TOKENOnly 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 | sh

Add | 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 version must be higher than the installed one, the GitHub release must be published (--upload leaves 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 set ed25519SecretKey.
  • 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.z heading that equals version exactly, 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. Check gh auth status, that the token can write to the repository (contents: write in Actions), and that the repository name is right.
  • S3 upload failed. Check the credentials, region, and endpoint. A 403 on download means the bucket does not allow public reads.

#Native artifacts

The updater is linked into the executable. quickgui build detects the feature and stages the install helper beside it (quickgui-updater-helper, or Sparkle.framework on macOS) from @quickgui/extension-updater at the same version. Current prebuilt packages cover macOS; on other hosts run bun packages/native/build.ts --extension updater in a source checkout first.