Skip to content

Shipping a Model Inside a Desktop App: Packaging, Signing, Updates

11 min read · updated August 4, 2026

The first real decision when putting a language model inside a desktop application is whether the weights live inside the code-signed bundle or beside it. Almost everything else — release time, update size, disk usage, notarisation pain — follows from that one choice, and the answer is almost always beside it.

The decision that shapes everything else

Put a 4 GB weight file inside the application bundle and it becomes part of the signed, notarised, distributed artefact. Every consequence is bad:

  • Every release, however small the code change, ships 4 GB to every user.
  • Signing and notarisation must process the whole thing, turning a two-minute step into a long one and your CI artefacts into something that strains every storage limit in the pipeline.
  • The installer, the download and the disk footprint are all dominated by an asset that changes far less often than the code.
  • You cannot ship a model update without shipping an app update, so a model fix waits for a release train.

Put it beside the bundle — in the platform’s application-support directory, downloaded on first run — and all four reverse. The cost is that you must now do yourself what the signature was doing for you: verify that the file is the one you intended. That is a hash check and a signature check, and it is perhaps forty lines of code.

The exception is a small model — under a couple of hundred megabytes — that is essential on first launch. Then bundling is simpler, the signing overhead is tolerable, and there is no first-run download to design. Do the arithmetic on your own release cadence: bundle size multiplied by releases per year multiplied by installed users is the number that decides.

Where the model actually goes

Each platform has one correct location for large application-managed data, and using it is what makes backup, cleanup and permissions behave.

PlatformDescription
macOS~/Library/Application Support/<your app>/models/. Inside the sandbox container if sandboxed. Mark it excluded from backup — a user does not want a 4 GB regenerable file in every backup snapshot.
Windows%LOCALAPPDATA%\<your app>\models\. Local rather than roaming: a roaming profile must not carry gigabytes across machines, and IT administrators will find you if it does.
Linux$XDG_DATA_HOME/<your app>/models/, defaulting to ~/.local/share. Respect the variable rather than hard-coding the default; packaged and containerised installs rely on it.

Store the model as a single file per version, in a directory named for the version, with a manifest alongside it. Never overwrite in place — the reason is the atomic swap section below.

models/
  manifest.json                  ← which version is active
  q4-2026-06-12/
    weights.gguf
    weights.gguf.sha256
    tokenizer.json
  q4-2026-08-01/
    weights.gguf
    weights.gguf.sha256
    tokenizer.json

Signing, notarisation and reputation

A desktop application that downloads and loads a large binary file is exactly the shape that operating-system defences are built to be suspicious of. Three separate mechanisms are in play and they are often confused:

  • Code signing proves the executable came from you and has not been modified. Required in practice on macOS and effectively required on Windows. It covers your binary — a data file you download afterwards is not covered, which is precisely why you must verify it yourself.
  • Notarisation (macOS) is an additional automated scan by Apple of your signed application, whose result is stapled to the artefact. It applies to what you ship, not to what you later download.
  • Reputation (Windows SmartScreen and equivalents) is not a check you pass once. A newly signed application is unknown and will warn users until enough installs accumulate. Changing signing certificates resets it. Plan the first release knowing that some users will see a warning, and do not solve it by asking them to disable protection.

Verify the downloaded model with a hash you shipped inside the signed application, and prefer a signature over a bare hash: a hash embedded in the binary is only as trustworthy as the binary, whereas a signature lets you publish new model versions without a new release while still proving they came from you. That is also the boundary where it is worth being clear-eyed about what you are protecting against — weights on hardware you do not control are readable by a determined user regardless.

Sandboxes and what they forbid

If your application is sandboxed — mandatory for the Mac App Store, and the default for Flatpak and Snap distribution on Linux — several habits from server-side inference stop working:

  • Arbitrary filesystem paths are gone. Everything lives in the container. A model path a user typed into a settings field will fail unless it arrived through a file picker that granted access.
  • Spawning helper processes is constrained. A design that shells out to a separately downloaded inference binary is unlikely to survive sandboxing, and on the Mac App Store downloading executable code is disallowed outright. Link the runtime into your application rather than fetching it.
  • Executable memory may be restricted. Runtimes that JIT-compile kernels need an entitlement, and on some distribution channels they will not get one. Check whether your inference library JITs before choosing it, not after the rejection.
  • Network access is an entitlement. Obvious, and still forgotten: the model downloader needs the outgoing-network entitlement declared even though your app “works offline”.

Downloading several gigabytes without losing it

A multi-gigabyte transfer over a real consumer connection will be interrupted. Design for that rather than retrying from zero.

  1. Check free space before starting, and require at least the model size plus the current model — you need both on disk simultaneously during the swap. Failing early with a clear message is far better than filling a user’s disk.
  2. Download to a temporary file with a .partial suffix, in the same directory as the destination so the eventual rename stays on one filesystem.
  3. Use HTTP range requests to resume. On restart, if a partial file exists, request from its current length with a Range header and append. Store the expected total length and the expected hash in the manifest so a resumed download can still be verified.
  4. Hash while writing, not afterwards. Feeding each chunk into the digest as it lands avoids a second full read of a multi-gigabyte file, which on a slow disk is minutes.
  5. Respect metered connections and battery state where the platform reports them, and always let the user postpone. An unannounced 4 GB download on a hotel connection is the kind of thing people write reviews about.

The atomic swap

The failure to avoid is a half-written model that the application tries to load on next launch. The sequence that prevents it:

  1. Download to weights.gguf.partial in the new version directory.
  2. Verify the hash against the manifest. On mismatch, delete and stop.
  3. Rename weights.gguf.partial to weights.gguf. A rename within one filesystem is atomic, so the file either exists complete or does not exist.
  4. Load the new model and run a smoke test — a fixed prompt with an expected-shaped response — before switching the manifest.
  5. Write the new active version to a temporary manifest and rename that over the real one. Same atomicity argument.
  6. Only then delete the previous version directory, and only after the application has run successfully once on the new one.

Keeping the previous version until the new one has proven itself is what turns a bad model release from an outage into an inconvenience. The rollback is then a one-line manifest change, which is the same principle as canary releases for model migrations applied to a filesystem instead of a routing layer.

Making the second update cheap

Full re-downloads for every model change are what make on-device models expensive to maintain. Three ways out, in increasing order of engineering cost:

  • Separate the base from the adaptation. If your changes are fine-tunes, ship a base model once and adapters afterwards. A low-rank adapter is typically tens of megabytes against a multi-gigabyte base — the reason adapters are small is structural, not a compression trick — so the update is two orders of magnitude cheaper.
  • Content-addressed chunking. Split the weight file into fixed chunks, hash each, and download only chunks whose hash changed. This works well when a new version shares most tensors with the old, and poorly when quantisation parameters changed, which perturbs every byte.
  • Binary diffs. Generate a patch between versions server-side and apply it on the client. Effective, and the operational cost is real: you must generate and store a patch for every from-version you support, and keep a full download path for anyone too far behind.

Whichever you choose, keep the full download available as a fallback and make the client fall back automatically when a patch fails to apply. A user stuck on an old model because a diff will not apply is a silent failure, and silent failures on desktop are the ones that persist for months.