desktop_updater

Flutter desktop updater plugin for macOS, Windows, and Linux.

The 3.1 release uses the signed schema-v3 update flow: one small signed update index, one signed release descriptor, and one verified artifact:

app-archive.json -> release.json -> app.zip / installer artifact

No public folder listing is required. Clients fetch exact URLs and verify the artifact length and SHA-256 before installation.

flutter_desktop_updater

Quick Start

Upgrading from 2.x or 3.0? Read the 2.x to 3.0 migration guide and the 3.0 to 3.1 release-key guide before changing the dependency. The 3.1 release removes direct release-key options; removed paths are not compatibility aliases.

Add the package:

dependencies:
  desktop_updater: ^3.2.2

Add desktop_updater.yaml at your app repository root, next to pubspec.yaml:

updates:
  baseUrl: https://updates.example.com

Generate the feed-bound signing profile before constructing the controller:

dart run desktop_updater:release keygen

keygen prints the generated map and writes the same public metadata to desktop_updater.keys.json:

Public key map:
{
  "release-0123456789abcdef01234567": "base64-raw-ed25519-public-key"
}

Copy the complete printed map into trustedReleasePublicKeys; do not type a new key ID or generate a second key. The example values above are placeholders. desktop_updater.keys.json contains no private key and is safe to review and commit. The private seed remains in protected local storage.

Back up the private signing material immediately after keygen:

dart run desktop_updater:release keys export \
  --output release-key.dukey \
  --passphrase-env DESKTOP_UPDATER_KEY_BUNDLE_PASSPHRASE

Set DESKTOP_UPDATER_KEY_BUNDLE_PASSPHRASE to a passphrase of at least 12 characters before running the command.

Keep release-key.dukey outside the repository and store its passphrase separately in a password manager or CI secret store. CI and other computers restore it with release keys import. See the release key management guide for backup/import, existing 3.0 key adoption, and two-phase rotation. Existing 3.0 feeds must use that one-time adoption flow instead of keygen.

Every 3.1 controller also requires the expected package identity and an app-owned UpdateRecoveryStore. The package ID must exactly match the packageId written into the current platform's release.json. The Flutter publisher resolves its default as follows:

Platform Default package ID source
macOS PRODUCT_BUNDLE_IDENTIFIER in macos/Runner/Configs/AppInfo.xcconfig
Windows name in pubspec.yaml
Linux APPLICATION_ID in linux/CMakeLists.txt; otherwise publishing requires --package-id

If you publish with --package-id, use that exact override in the controller. Platform identifiers may differ, so select the value at runtime when needed.

recoveryStore is required in 3.1. It is not an update-state value; it is a durable storage adapter for a pending install marker. Before native install handoff, the controller writes the marker and reads it back exactly. On the next launch it uses the marker to verify whether the update completed. Copy or adapt the repository's file-backed JsonFileUpdateRecoveryStore and place its file in your app's persistent support directory. The complete controller setup below uses path_provider to resolve that directory:

flutter pub add path_provider
import "dart:io";

import "package:desktop_updater/desktop_updater.dart";
import "package:path_provider/path_provider.dart";

import "json_file_update_recovery_store.dart";

// Replace this entire map with the exact "Public key map" printed by keygen.
const trustedReleasePublicKeys = <String, String>{
  "release-0123456789abcdef01234567":
      "base64-raw-ed25519-public-key",
};

String expectedPackageIdForCurrentPlatform() {
  if (Platform.isMacOS) return "com.example.app";
  if (Platform.isWindows) return "example_app";
  if (Platform.isLinux) return "com.example.app";
  throw UnsupportedError("desktop_updater requires a desktop platform.");
}

Future<JsonFileUpdateRecoveryStore> createUpdateRecoveryStore() async {
  final appSupportDirectory = await getApplicationSupportDirectory();
  final separator = Platform.pathSeparator;
  return JsonFileUpdateRecoveryStore(
    File(
      "${appSupportDirectory.path}${separator}desktop_updater"
      "${separator}pending-install-stable.json",
    ),
  );
}

Future<DesktopUpdaterController> createDesktopUpdaterController() async {
  final recoveryStore = await createUpdateRecoveryStore();

  return DesktopUpdaterController(
    appArchiveUrl: Uri.parse(
      "https://updates.example.com/app-archive.json",
    ),
    expectedPackageId: expectedPackageIdForCurrentPlatform(),
    trustedReleasePublicKeys: trustedReleasePublicKeys,
    recoveryStore: recoveryStore,
  );
}

Private update hosts can add runtime authentication headers for update metadata, artifacts, and hosted release notes with requestHeadersProvider; see Runtime request headers.

For macOS, complete the macOS setup before building or publishing the app. Adding the Flutter dependency does not automatically bundle the install helper.

Before the first production publish, verify the platform toolchain:

dart run desktop_updater:release doctor --platform macos

Then publish the platform after the generated public key is pinned in the app:

dart run desktop_updater:release publish --platform macos

Add --initialize-feed only when the hosted archive has been independently proven absent; otherwise provide the verified existing history.

With only updates.baseUrl, publish creates an upload-ready package under dist/desktop_updater and prints the manual upload and validate instructions. With an upload provider configured, it uploads versioned files first, validates them, uploads app-archive.json last, then validates hosted update selection.

macOS Setup

The macOS updater requires a signed DesktopUpdaterInstallHelper inside your app bundle. Both SwiftPM and CocoaPods hosts need the Runner build phase below; installing the Flutter plugin alone does not add it. Start with flutter pub get and open macos/Runner.xcworkspace in Xcode.

1. Set the app version and build number

Use Flutter's version: <version>+<build-number> convention in your app's pubspec.yaml, for example:

version: 1.2.3+45

Keep these entries in macos/Runner/Info.plist:

<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>

The built app must have CFBundleShortVersionString = 1.2.3 and CFBundleVersion = 45. CFBundleVersion must be a nonnegative integer build number, not 1.2.3 or 1.2.3+45: native install authorization parses it as an integer. Increase the build number for each release, including when the version name changes; update selection compares build numbers first when both releases provide them. If you bundle the app yourself, preserve this mapping in the final Contents/Info.plist and keep it consistent with the published version and buildNumber metadata. Flutter's --build-name and --build-number options override the corresponding pubspec values.

2. Prepare the helper policy and signing

Create an app-owned macos/Runner/DesktopUpdaterHelperPolicy.json and its canonical SHA-256 using the macOS helper policy recipe. It binds your bundle ID, helper service ID, Apple signing requirements, allowed install locations and strategies, and the release public keys pinned in the controller. Replace the example app's identifiers, Team ID, and release keys with your own values.

Configure Runner's signing identity in Xcode. The embed script uses EXPANDED_CODE_SIGN_IDENTITY (falling back to CODE_SIGN_IDENTITY) to sign the helper before Xcode signs the outer app. Production builds need Developer ID signing, hardened runtime, and notarization. For ad hoc local builds, use the separate development-policy settings described in the policy recipe.

3. Add the Runner build phase

In Runner target > Build Settings, add a user-defined setting named DESKTOP_UPDATER_PACKAGE_ROOT pointing to the resolved desktop_updater package root (the directory containing its pubspec.yaml and macos/install_helper). For CocoaPods, use $(PROJECT_DIR)/Flutter/ephemeral/.symlinks/plugins/desktop_updater after pod installation. For SwiftPM, use the resolved plugin source root from the desktop_updater entry's rootUri in your app's .dart_tool/package_config.json (resolve a relative URI against that file), or the matching local package checkout. Set the filesystem path, not the file: URI, and update it when the resolved package location changes. The nested macos/desktop_updater Swift package directory is not the package root.

In Runner target > Build Phases, add a New Run Script Phase named Embed Desktop Updater Install Helper. Place it after Flutter's script that runs macos_assemble.sh embed, before outer app signing. Use /bin/sh, uncheck Based on dependency analysis so it runs on every build, and set User Script Sandboxing (ENABLE_USER_SCRIPT_SANDBOXING) to No for the Runner configurations that use this phase, as in the example project. Paste:

set -eu

: "${DESKTOP_UPDATER_PACKAGE_ROOT:?Set DESKTOP_UPDATER_PACKAGE_ROOT in Runner build settings}"
export DESKTOP_UPDATER_HELPER_INFO_TEMPLATE="${DESKTOP_UPDATER_PACKAGE_ROOT}/macos/install_helper/Configuration/Helper-Info.plist"
export DESKTOP_UPDATER_SEALED_POLICY_PATH="${PROJECT_DIR}/Runner/DesktopUpdaterHelperPolicy.json"
export DESKTOP_UPDATER_SEALED_POLICY_SHA256="REPLACE_WITH_GENERATED_SHA256"

"${DESKTOP_UPDATER_PACKAGE_ROOT}/macos/install_helper/embed_install_helper.sh"

Replace REPLACE_WITH_GENERATED_SHA256 with the digest from step 2 and update it whenever you regenerate the policy. Xcode supplies the build paths and signing identity; run this script as a build phase. It builds the helper, embeds its sealed policy, writes the host's DesktopUpdaterInstall* metadata and launch daemon plist, and verifies the helper layout. Missing metadata or a digest or signing mismatch fails the build.

The example Runner project shows the complete phase, including optional ad hoc policy settings. Its ../../macos/install_helper path is specific to this repository's layout; use your resolved package root in a consuming app.

4. Build and verify the app bundle

Build on macOS, then substitute your actual app name below:

set -eu
flutter build macos --release

app="build/macos/Build/Products/Release/My App.app"
test -x "$app/Contents/Helpers/DesktopUpdaterInstallHelper"
helper_plist=$(/usr/libexec/PlistBuddy -c \
  'Print :DesktopUpdaterInstallHelperLaunchDaemonPlistName' "$app/Contents/Info.plist")
test -f "$app/Contents/Library/LaunchDaemons/$helper_plist"
/usr/bin/codesign --verify --strict --verbose=2 \
  "$app/Contents/Helpers/DesktopUpdaterInstallHelper"
/usr/bin/codesign --verify --deep --strict --verbose=2 "$app"
/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$app/Contents/Info.plist"
/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$app/Contents/Info.plist"

The checks should succeed and the last two commands should print 1.2.3 and 45 for the example version. They verify bundle layout, signatures, and version fields; notarization and an actual update still require their own validation. Writable app replacements use the embedded one-shot helper. Protected installs and PKG updates use the SMAppService daemon on macOS 13+ and may require the user to approve it in System Settings; see the native helper integration guide.

Native Helper SDKs And Runtime Preview

The pub.dev Flutter package remains the full update runtime. This repository also ships Flutter-free install/relaunch helper SDKs and an opt-in native runtime preview for native host apps:

  • DesktopUpdaterKit through SwiftPM on macOS, including the Swift preview client;
  • desktop_updater::native helper targets on Windows and Linux, plus the opt-in source-built desktop_updater::runtime target on Linux;
  • DesktopUpdater.Native as the Windows .NET preview wrapper and NuGet package boundary for both native DLLs;
  • a compiled desktop-updater CLI candidate matrix for macOS, Windows, and Linux.

Helper-only consumers still provide their own discovery, verification, and staging. The preview adds the stateful checkForUpdate, downloadVerifyAndStage, prepareInstall, and commitAfterExit flow while reusing the same helpers and trust rules. Hosts use queryTransaction and recoverPendingInstall after a restart. Its current merge gates require signed app-archive authority, owned stage provenance, explicit install target proof, mount and reparse rejection, a one-shot handoff, Windows Unicode paths and relative redirects, and Release NuGet packages with third-party notices. Target-host evidence is commit-bound: the audited baseline normal jobs passed, while each 3.1 release candidate must rerun the named macOS, Windows, Linux, and Windows VM repetition gates for its exact commit. For 3.1.2, signed/notarized macOS app, DMG, standard PKG, privileged helper, and forced-recovery target-host lanes are verified locally. Windows junction/reparse, Linux mount/bind transaction mutation, installed Linux broker, and signed Windows Inno lanes remain separately gated. The cross-platform native runtime preview therefore remains candidate-only; the macOS production updater path has separate verified evidence.

See Native helper SDKs and standalone CLI for package integration and Native Runtime Preview API for compiling examples, current evidence, typed outcomes, and trust boundaries.

Additional Release Files

Use additionalFiles when PDFs, language packs, manuals, or other app-owned files must ship with the desktop update but are not produced by Flutter:

additionalFiles:
  - source: release-assets/manuals/*
    destination: docs/manuals
    platforms: [windows, linux]
  - source: release-assets/manuals/*
    destination: Contents/Resources/Manuals
    platforms: [macos]

release publish copies these files after flutter build and before macOS notarization, app-owned prePackage signing hooks, and zip packaging. That keeps the packaged artifact consistent with platform signing and trust gates.

Linux Zip Permissions

Linux is preview and direct-ZIP only for this release; it is candidate-only, not production-ready. AppImage, deb/APT, rpm/DNF, Flatpak/Flathub, and Snap store delivery are explicitly outside this release scope and remain future work.

Linux update zips must keep Unix file mode metadata for executable files in the bundle. release publish --platform linux creates artifacts with those modes, and the updater restores them while staging the verified zip before the native helper replaces the installed bundle. If you build Linux update zips with custom tooling, make sure the app runner remains executable in the archive.

EL10

Think of your update host as a shelf on the internet:

  1. The app reads app-archive.json.
  2. The archive says which release.json is newest for this platform/channel.
  3. release.json points to one zip and records its size and hash.
  4. The app downloads the zip only after the metadata says it is a valid update.
  5. The app verifies the zip before staging or installing it.

Publish does the reverse: create the zip, create release.json, update app-archive.json, upload the versioned files first, then expose the new archive last.

Ready-Made UI

Use the stock inline card:

DesktopUpdateWidget(
  controller: controller,
  child: const YourHomePage(),
)

Other built-in surfaces:

  • DesktopUpdateDirectCard
  • DesktopUpdateSliver
  • UpdateDialogListener

See Ready-made UI widgets for screenshots, placement guidance, and when to choose each surface.

For custom UI, switch on controller.state.

Localization And i18n

Ready-made updater UI can load bundled starter translations, app-owned JSON assets, direct string overrides, or an app-owned resolver such as AppLocalizations or _(). RTL locales such as Arabic and Hebrew can set or infer TextDirection.rtl.

Use DesktopUpdateLocalizationLoader.fromBundledLocale("tr_TR") to force a specific bundled language, or fromPlatformLocale() to follow the system locale. Support-policy dates default to YYYY-MM-DD HH:mm UTC; pass DesktopUpdateLocalization(formatDateTime: ...) when the app needs its own date format.

See Localization and i18n for the recommended setup, JSON schema, runtime language switching, RTL behavior, and Arabic, Hebrew, Japanese, Korean, and Cyrillic screenshots.

Update Policy Modes

Update policy lives in app-archive.json, so apps can change release pressure without rebuilding the old client:

  • Optional updates are soft prompts with Download, optional skip persistence, and restart deferral.
  • Mandatory updates keep prompting until installed, hide skip actions, and keep a Save first path so users can protect unsaved work before restart.
  • supportPolicy adds a minimum supported version and enforcement deadline so old clients can warn first, then fail closed after the deadline.
  • freshInstall marks releases that should send users to a fresh download instead of the in-app updater.

See Update policy modes for JSON examples, CLI flags, and the built-in card, sliver, and dialog behavior for each state.

Release Notes

Use releaseNotesLoader when notes should depend on the selected descriptor, platform, channel, locale, account, or environment. These examples reuse the generated key map and recovery-store factory from Quick Start:

final recoveryStore = await createUpdateRecoveryStore();
final controller = DesktopUpdaterController(
  appArchiveUrl: Uri.parse("https://updates.example.com/app-archive.json"),
  expectedPackageId: expectedPackageIdForCurrentPlatform(),
  trustedReleasePublicKeys: trustedReleasePublicKeys,
  recoveryStore: recoveryStore,
  releaseNotesLoader: (descriptor) {
    return myNotesApi.fetch(
      version: descriptor.version,
      platform: descriptor.platform,
      channel: descriptor.channel,
    );
  },
);

For a simple hosted file, pass releaseNotesUrl instead:

final recoveryStore = await createUpdateRecoveryStore();
final controller = DesktopUpdaterController(
  appArchiveUrl: Uri.parse("https://updates.example.com/app-archive.json"),
  expectedPackageId: expectedPackageIdForCurrentPlatform(),
  trustedReleasePublicKeys: trustedReleasePublicKeys,
  recoveryStore: recoveryStore,
  releaseNotesUrl: Uri.parse("https://updates.example.com/release-notes.json"),
);

When releaseNotesUrl points at a private host, requestHeadersProvider is used for the release notes request too. See Runtime request headers for sharing one auth token across update files and release notes, or routing different headers by request URL.

The simple contributor-friendly JSON shape uses a data array:

{
  "data": [
    { "type": "feat",  "message": "Add dark mode support" },
    { "type": "fix",   "message": "Fix crash on startup" },
    { "type": "other", "message": "General stability improvements" }
  ]
}

The richer package-owned shape supports sections, summaries, and item titles:

{
  "schemaVersion": 1,
  "format": "desktop_updater.release_notes.v1",
  "summary": "Quality improvements.",
  "sections": [
    {
      "type": "features",
      "title": "New features",
      "items": [
        { "body": "Add dark mode support" }
      ]
    }
  ]
}

The ready-made card shows a release notes icon when the active update can load notes. Custom UI can call controller.loadReleaseNotes() and render controller.releaseNotesState; the controller keeps caching, retry state, and descriptor context aligned.

Localise the bottom sheet and override section labels via DesktopUpdateLocalization:

localization: const DesktopUpdateLocalization(
  releaseNotesTitleText: "What's new",
  releaseNotesButtonTooltipText: "Release notes",
  releaseNotesTypeLabels: {
    "feat": "New features",
    "fix":  "Bug fixes",
    "other": "Other changes",
  },
  releaseNotesErrorText: "Could not load release notes.",
  releaseNotesRetryText: "Retry",
  releaseNotesEmptyText: "No release notes available for this version.",
),

Error Tooltip

When an update fails the error icon shows a tooltip. Supply an onUpdateFailedTooltip callback to return a custom string, or set updateFailedTooltipText for one static fallback:

localization: DesktopUpdateLocalization(
  updateFailedTooltipText: "Update failed. Please try again.",
  onUpdateFailedTooltip: (error) {
    if (error is SocketException) return "No internet connection.";
    if (error is TimeoutException) return "Connection timed out.";
    return null; // falls back to updateFailedTooltipText
  },
),

Diagnostics And Recovery

The 3.2.0 release retains the explicit app-owned diagnostics and recovery wiring introduced in 3.0. The Dart controller remains quiet unless the app provides its own sink; it performs no package-owned upload or telemetry. The privileged macOS helper additionally writes security-scoped lifecycle evidence to its fixed root-owned JSONL log so backup, move, cleanup, rollback, recovery, and recovery-marker handling can be audited after an administrator-approved installation.

Use in-memory problem reports for normal support, add an app-owned diagnostics sink for durable Dart lifecycle logs, and add an app-owned UpdateRecoveryStore when support needs post-relaunch evidence. Native helper APIs do not accept a caller-selected diagnostics path; standalone helpers use fixed platform logs rather than app-owned diagnostics storage.

Details live in Diagnostics and recovery, Ready-made UI widgets, and Publishing desktop updates.

Production Trust

desktop_updater handles update mechanics. Your app still owns platform trust:

Pin the Ed25519 release keys embedded in your app for production metadata authenticity. The same key map verifies both app-archive.json and the selected release.json before policy selection or artifact download:

final recoveryStore = await createUpdateRecoveryStore();
final controller = DesktopUpdaterController(
  appArchiveUrl: Uri.parse("https://updates.example.com/app-archive.json"),
  expectedPackageId: expectedPackageIdForCurrentPlatform(),
  trustedReleasePublicKeys: trustedReleasePublicKeys,
  recoveryStore: recoveryStore,
);

trustedReleasePublicKeys is required for every 3.1 controller and low-level update client. Each release must authenticate against one of the pinned Ed25519 keys before policy selection or artifact download. Native install handoff also requires a signed release.json whose key is sealed into the native helper policy; an unsigned descriptor fails before native handoff and leaves no pending recovery marker. Low-level callers should keep one DesktopUpdater().createZipFirstUpdateSession(...) and use that session for both checking and downloading/staging.

  • macOS production updates should be Developer ID signed, hardened-runtime enabled, notarized, stapled, and Gatekeeper accepted before packaging.
  • Windows production updates should use Authenticode when publisher trust is required.
  • Windows can publish direct zip artifacts or Inno Setup installer artifacts.
  • Microsoft Store/MSIX is a planned separate Windows distribution channel. Store packaging, manifest, Partner Center, update-ownership, and CI ideas are welcome through an issue or a focused pull request. Keep Store-managed updates separate from the direct ZIP/Inno updater; see Windows and Linux production release options.
  • Linux direct zip distribution should add descriptor signing or another publisher-authenticity policy when production trust matters.

For macOS DMG first installs, DMG update artifacts, PKG installer artifacts, and the local Apple-trust smoke harness, see macOS DMG and PKG installer updates.

Documentation

Maintainers and agentic contributors should start with AGENTS.md, then use Harness engineering and the execution plan index for repo-local workflow, validation, and plan status.

Advanced Commands

Most apps should start with release publish. Use low-level commands only when your pipeline needs to own each step:

dart run desktop_updater --help
dart run desktop_updater release publish --help
dart run desktop_updater package --help
dart run desktop_updater verify --help
dart run desktop_updater app-archive --help

# Legacy package entrypoints remain supported.
dart run desktop_updater:package --help
dart run desktop_updater:app_archive --help
dart run desktop_updater:verify --help

Support

If desktop_updater saves you maintenance time, you can support ongoing work through GitHub Sponsors.

Sponsorship helps fund cross-platform testing, release tooling, and maintenance for Windows, macOS, and Linux update flows.

Become a sponsor