+1 (415) 480-3939

Shipping Flutter Desktop: macOS Notarization, Windows Signing, Linux Packaging, and Auto-Update

Desktop is the part of Flutter's multi-platform promise that clients discover late. A team ships iOS and Android, someone in ops asks for a Windows build of the same internal tool, and flutter build windows produces a folder that works on the developer's machine and nowhere else. The Dart code was never the problem. The problem is everything that happens after the build: signing, notarization, packaging, distribution, and updates — three platforms, three sets of rules, none of them shared with mobile.

This tutorial covers that gap. It assumes Flutter 3.4x with desktop enabled and an app that already runs in debug on the target OS.

What "done" means on each platform

Build outputMust be signedDistributionUpdate mechanism
macOS.app bundleYes — Developer ID + notarization for anything outside the App Store.dmg, .pkg, or App StoreSparkle, or the store
Windowsloose .exe + DLLsEffectively yes — SmartScreen blocks unsigned installers.msix, .exe installer (Inno/WiX), or the StoreMSIX App Installer, or a custom updater
Linuxloose ELF + lib/NoFlatpak, Snap, .deb/.rpm, AppImageThe package manager

The rule of thumb: budget more time for macOS and Windows release engineering than for the desktop UI work itself on the first release, and almost none on every release after, provided you automate it now.

Before you package: make the app behave like a desktop app

A mobile-shaped Flutter app that merely runs on desktop reads as a port. Three things do most of the work.

Window sizing and persistence. Users expect a window to reopen where they left it.

// lib/main.dart
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {
    await windowManager.ensureInitialized();
    final saved = await WindowStore.load();
    await windowManager.waitUntilReadyToShow(
      WindowOptions(
        size: saved?.size ?? const Size(1280, 800),
        minimumSize: const Size(720, 560),
        titleBarStyle: TitleBarStyle.normal,
        title: 'Acme Console',
      ),
      () async {
        if (saved?.offset != null) await windowManager.setPosition(saved!.offset!);
        await windowManager.show();
      },
    );
  }

  runApp(const AcmeApp());
}

Persist size and position on onWindowMoved / onWindowResized (debounced), and clamp the restored rectangle to the current display list — an external monitor that is no longer attached must not park your window off-screen.

Keyboard and menus. Desktop users navigate with the keyboard. Register real shortcuts through Shortcuts/Actions, and give macOS a proper menu bar with PlatformMenuBar so Cmd+, opens settings and Cmd+W closes the window. Make sure every interactive widget is reachable with Tab; FocusTraversalGroup around your main regions is usually enough.

File system reality. Mobile apps rarely touch arbitrary paths; desktop apps do constantly. Use file_selector for open/save dialogs, path_provider's getApplicationSupportDirectory() for app data (never write next to the executable — Program Files is read-only for standard users), and handle drag-and-drop with desktop_drop. On macOS, opening a user-chosen file outside the sandbox requires the right entitlement; see below.

macOS: entitlements, signing, notarization

The macOS build is the strictest of the three and the one that fails silently for end users: an unnotarized app downloaded from the web is simply refused by Gatekeeper with a message that tells your customer the app is damaged.

Entitlements

Flutter generates two entitlement files — macos/Runner/DebugProfile.entitlements and macos/Runner/Release.entitlements. Debug includes network client permission so hot reload works; release does not, which is why the classic bug is "all my API calls fail, but only in the release build."

<!-- macos/Runner/Release.entitlements -->
<dict>
  <key>com.apple.security.app-sandbox</key>            <true/>
  <key>com.apple.security.network.client</key>         <true/>
  <key>com.apple.security.files.user-selected.read-write</key> <true/>
  <!-- only if you genuinely need them: -->
  <!-- <key>com.apple.security.device.camera</key>     <true/> -->
  <!-- <key>com.apple.security.print</key>             <true/> -->
</dict>

Keep the sandbox on if you can — it is mandatory for the Mac App Store and a good answer in security reviews. If you distribute outside the store and need broad filesystem access, you can drop the sandbox, but then hardened runtime plus notarization is doing all the trust work.

Sign and notarize

Notarization now goes through notarytool (the old altool path is gone). Store credentials once in a keychain profile so CI and laptops use the same command:

xcrun notarytool store-credentials "acme-notary" \
  --apple-id "release@acme.example" \
  --team-id "ABCDE12345" \
  --password "$APP_SPECIFIC_PASSWORD"

Then the release pipeline:

flutter build macos --release

APP="build/macos/Build/Products/Release/Acme Console.app"

# 1. Sign every nested binary, deepest first, with hardened runtime.
codesign --force --deep --options runtime --timestamp \
  --entitlements macos/Runner/Release.entitlements \
  --sign "Developer ID Application: Acme Ltd (ABCDE12345)" "$APP"

# 2. Package. Notarization needs a container: dmg, pkg, or zip.
ditto -c -k --keepParent "$APP" build/AcmeConsole.zip

# 3. Submit and wait.
xcrun notarytool submit build/AcmeConsole.zip \
  --keychain-profile "acme-notary" --wait

# 4. Staple the ticket to the .app so it launches offline, then rebuild the dmg.
xcrun stapler staple "$APP"
create-dmg --volname "Acme Console" build/AcmeConsole.dmg "$APP"
codesign --force --sign "Developer ID Application: Acme Ltd (ABCDE12345)" build/AcmeConsole.dmg
xcrun stapler staple build/AcmeConsole.dmg

When notarization is rejected, do not guess — the log names the offending binary:

xcrun notarytool log <submission-id> --keychain-profile "acme-notary"

The two failures we see most often: a plugin ships a prebuilt .dylib or XCFramework that was never signed with hardened runtime (fix by signing nested frameworks before the outer bundle, or asking the plugin author to ship a signed binary), and a --deep signature that skipped a helper because it was placed outside Contents/Frameworks. Verify locally before you upload:

codesign --verify --deep --strict --verbose=2 "$APP"
spctl --assess --type execute --verbose "$APP"   # should print: accepted, source=Notarized Developer ID

Windows: signing without a USB token, then MSIX

Windows code signing changed shape: OV certificates now require hardware or an attested cloud key store, so the old "PFX file in a CI secret" workflow is largely dead. The practical options for a team are Azure Trusted Signing (cheapest, signs from CI with no hardware) or an EV certificate in a cloud HSM. Whichever you choose, the signing step is the same shape — sign the binaries, then sign the installer.

flutter build windows --release

# Sign the app binaries produced by the build.
$dir = "build\windows\x64\runner\Release"
signtool sign /v /fd SHA256 /tr http://timestamp.acs.microsoft.com /td SHA256 `
  /dlib "$env:TRUSTED_SIGNING_DLIB" /dmdf metadata.json `
  "$dir\acme_console.exe" "$dir\*.dll"

Always pass a timestamp server (/tr). Without it, every signature expires when the certificate does, and last year's installer stops working.

For packaging, msix is the least-effort route from a Flutter project:

# pubspec.yaml
dev_dependencies:
  msix: ^3.16.8

msix_config:
  display_name: Acme Console
  publisher_display_name: Acme Ltd
  publisher: CN=Acme Ltd, O=Acme Ltd, L=Leeds, C=GB   # must match the certificate subject exactly
  identity_name: com.acme.console
  msix_version: 1.4.2.0            # four parts; the last must be 0
  logo_path: windows\runner\resources\app_icon.png
  capabilities: internetClient
  install_certificate: false
dart run msix:create --release

A publisher that does not byte-match the certificate subject is the number one MSIX failure; copy it out of the certificate rather than typing it. Note also that MSIX runs your app in a lightweight container: writes to %LOCALAPPDATA% are redirected, and some plugins that expect a plain Win32 environment misbehave. Smoke-test the packaged build, not just the loose Release folder.

If you need a classic installer instead — common for enterprise deployment via SCCM/Intune — Inno Setup or WiX over the same Release folder works fine; just remember to sign the resulting .exe too.

Linux: package, don't ship a folder

flutter build linux --release gives you build/linux/x64/release/bundle, an executable plus lib/ and data/. Shipping that as a tarball works but is not something an IT department will deploy.

Flatpak is the most portable option and sidesteps the glibc-version problem that bites .deb builds made on a newer distro than the target:

# com.acme.Console.yml
app-id: com.acme.Console
runtime: org.freedesktop.Platform
runtime-version: '24.08'
sdk: org.freedesktop.Sdk
command: acme_console
finish-args:
  - --socket=wayland
  - --socket=fallback-x11
  - --share=ipc
  - --share=network
  - --device=dri
  - --filesystem=xdg-documents
modules:
  - name: acme-console
    buildsystem: simple
    build-commands:
      - cp -r bundle /app/console
      - ln -s /app/console/acme_console /app/bin/acme_console
      - install -Dm644 com.acme.Console.desktop /app/share/applications/com.acme.Console.desktop
      - install -Dm644 icon.png /app/share/icons/hicolor/256x256/apps/com.acme.Console.png
    sources:
      - type: dir
        path: build/linux/x64/release

Whatever format you pick, ship a .desktop entry and a hicolor icon — without them the app has no launcher entry and shows a generic icon in the dock. Build inside a container matching your oldest supported distro if you produce .deb or .rpm.

Auto-update: the feature desktop users assume exists

Mobile has stores; desktop mostly does not. Pick one mechanism per platform and make it boring.

  • macOS: Sparkle via auto_updater, serving a signed appcast XML over HTTPS. Sign the appcast with an EdDSA key, not just TLS.
  • Windows: if you ship MSIX, an .appinstaller file gives you OS-managed updates for free. Otherwise Squirrel-style updaters or a check-and-download-installer flow.
  • Linux: do nothing. The package manager owns updates, and users get annoyed when apps fight it.

Whatever the mechanism, add a server-side version gate so you can force-upgrade clients that are too old to talk to your API:

final info = await PackageInfo.fromPlatform();
final policy = await api.updatePolicy(
  platform: Platform.operatingSystem,
  version: info.version,
  build: info.buildNumber,
);

switch (policy) {
  case UpdatePolicy.blocked:   // API contract changed; the old client cannot work
    showBlockingUpdateDialog(policy.downloadUrl);
  case UpdatePolicy.available:
    showDismissibleUpdateBanner(policy.downloadUrl);
  case UpdatePolicy.current:
    break;
}

That endpoint is fifteen minutes of backend work and it is the difference between a supportable desktop fleet and guessing which build a bug report came from.

Crash reporting and logs

Desktop users cannot send you a store crash report. Wire the same handlers you use on mobile, and add a "reveal log file" menu item — support tickets get resolved dramatically faster when the user can attach a log without being talked through a hidden directory.

await SentryFlutter.init(
  (o) => o.dsn = dsn,
  appRunner: () => runApp(const AcmeApp()),
);

PlatformDispatcher.instance.onError = (error, stack) {
  Log.file.severe('uncaught', error, stack);   // rotating file in app support dir
  return false;
};

One CI matrix, three artifacts

Each platform must build on its own runner; there is no cross-compilation for Flutter desktop.

name: desktop-release
on:
  push:
    tags: ['v*']

jobs:
  build:
    strategy:
      fail-fast: false
      matrix:
        include:
          - { os: macos-latest,   target: macos }
          - { os: windows-latest, target: windows }
          - { os: ubuntu-latest,  target: linux }
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with: { channel: stable, cache: true }

      - if: matrix.target == 'linux'
        run: sudo apt-get update && sudo apt-get install -y ninja-build libgtk-3-dev

      - run: flutter pub get
      - run: flutter test
      - run: flutter build ${{ matrix.target }} --release

      - if: matrix.target == 'macos'
        run: ./tool/release_macos.sh          # sign, notarize, staple, dmg
        env:
          APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }}

      - if: matrix.target == 'windows'
        run: ./tool/release_windows.ps1       # signtool + msix:create

      - uses: actions/upload-artifact@v4
        with:
          name: acme-${{ matrix.target }}
          path: dist/

Keep the platform-specific work in scripts rather than inline YAML so a developer can reproduce a signing failure locally instead of pushing tags to debug.

A realistic first-release checklist

  1. Window size, minimum size, position persistence, and multi-monitor clamping.
  2. Keyboard shortcuts, focus traversal, and a macOS menu bar.
  3. Release entitlements audited — especially network.client.
  4. Signing identities provisioned: Developer ID on macOS, Trusted Signing or HSM cert on Windows.
  5. Installer artifacts for all three platforms, produced by CI from a tag.
  6. Update channel plus a server-side version gate.
  7. Crash reporting, rotating file logs, and a way for users to find them.
  8. A smoke test run on a clean machine that has never had Flutter installed. This catches missing Visual C++ runtimes, unsigned dylibs, and hardcoded developer paths better than any other single step.

Desktop delivery is not harder than mobile — it is differently bureaucratic, and it is front-loaded. Automate it once at the start of the engagement and the fifth release costs a tagged commit.


AviaryApps builds and ships Flutter applications across mobile, web, and desktop. If your team needs a Windows or macOS build of an existing Flutter app — or a release pipeline that produces signed artifacts without a manual afternoon — get in touch.