Skip to content

Spike: can an ad-hoc-signed Nuitka bundle replace itself on macOS 15+? #77

Closed

Description

@smb

Why this is a spike and not a task

#74 proposes in-app updates and names Velopack as the first candidate. Velopack's
own macOS documentation says code signing and notarization are required before
shipping releases, and its installer page says the .pkg must be signed. This
project ships unsigned by decision.

That decision either survives an updater or it does not, and nobody knows which.
The answer is worth an afternoon of measurement and nothing else should be built
until it exists — including the choice between Velopack and Sparkle, which
depends on it.

Deliverable: a written answer in the issue. No code lands from this work.

What is already established

Measured on the built bundle and both published release archives:

codesign -dv          Signature=adhoc, TeamIdentifier=not set
codesign --verify     fails (a subcomponent is not signed at all)
spctl --assess        rejected, "no usable signature"

Three things argue that a self-replacing update might work anyway, and they are
what makes this worth measuring rather than assuming:

  1. A self-download sets no quarantine. Only quarantine-aware applications —
    browsers, mail clients — attach com.apple.quarantine. A bundle the
    application fetches itself should launch with no Gatekeeper assessment at all.
    That makes the first install the barrier, not the update.
  2. Apple Silicon accepts an ad-hoc signature. Every arm64 executable must
    carry at least one or the kernel refuses it, and Nuitka's output has one. A
    whole replacement bundle should therefore stay runnable — which is also why
    patching files inside an installed bundle is not an option, as it would
    invalidate the signature of something already on disk.
  3. Windows has no equivalent gate; unsigned installs work and the user sees
    SmartScreen, exactly as they do for today's ZIP.

Questions to answer, each with evidence

  • Does a running MatteLoop bundle successfully replace itself with a newer
    ad-hoc-signed bundle and relaunch, on macOS 15 and on a current macOS?
  • Does Velopack's macOS flow function at all with nothing signed, or does
    installer refuse an unsigned .pkg outright?
  • App Translocation: an app launched from ~/Downloads without being moved
    in Finder runs from a read-only randomised mount. Velopack claims it can update
    a moved bundle; a translocated one is not moved. What happens?
  • Does replacing the bundle require elevation when it lives in /Applications,
    and what does the user see?
  • Can velopack be frozen by Nuitka, and can App().run() — documented as
    needing to be the first thing in the main process — coexist with an entrypoint
    that already has one? packaging/entrypoint.py intercepts the resource-tracker
    payload and app.py:main calls multiprocessing.freeze_support() before
    argparse. Every spawned child re-enters the same binary, and
    SetAutoApplyOnStartup defaults to on, so a segmentation child spawning
    mid-render could apply a pending update. Is that reachable, and can it be
    disabled from the Python SDK?

What the answer decides

  • If a self-replacing unsigned update works: the updater track stays
    possible, and the next question is what package authentication means without
    signatures.
  • If it does not: an updater costs an Apple Developer ID at USD 99/year plus
    a Windows signing identity, and the cheaper alternatives in feat: add automatic app updates with delta downloads and full-package fallback #74 — a version
    check that opens the releases page, or package-manager publication — become
    the answer rather than a fallback.

Note that this needs a real macOS host. docs/building.md records that the
project has never launched the application on macOS 15 at all, which is its own
finding.

Refs #74

Activity

  1. added
    questionFurther information is requested
    on Sep 5, 2026
  2. smb commented on Sep 6, 2026

    @smb
    CollaboratorAuthor

    Measured answer

    Host: macOS 26.6.2, arm64. No macOS 15 host was available, so every result below
    is from a current macOS only.

    Headline: an update does not re-prompt. The barrier is the first install and
    only the first install, which is what the spike needed to know.

    A blocker that had to be cleared first

    The published MatteLoop-v0.2.1-macos-15-arm64.zip cannot be launched at all:

    open MatteLoop.app
    → Launch failed. NSPOSIXErrorDomain Code=111, Launchd job spawn failed
    

    Every entry in that archive is -rw-r--r--, including Contents/MacOS/matteloop.
    That is the defect reported in #57, and PR #70 landed after the v0.2.1 tag, so
    the archive currently offered for download is inert. Exactly one file in the
    bundle needs the executable bit; restoring it made the build run
    (MatteLoop 0.2.1).

    Does a bundle replace itself and relaunch?

    Yes, in both variants, measured against a real installation in /Applications
    that had been approved by the user once:

    Step Result
    rm -rf the bundle under the running process process survives
    move a different bundle into its place ok
    relaunch through open (LaunchServices) runs, no dialog

    The replacement was demonstrably a different build: the main executable changed
    size and digest. It launched with no Gatekeeper interaction of any kind.

    Why. Gatekeeper assesses only bundles carrying com.apple.quarantine. The
    approved installation carries one with the assessment bit set — that is where
    the user's one-time consent is recorded. A bundle written by the application
    itself carries no such attribute at all, because only quarantine-aware
    downloaders set one. No attribute, no assessment. The consent is not inherited;
    the question is simply never asked. The ad-hoc signature changing, and
    codesign --verify continuing to fail on an unsigned subcomponent, are both
    irrelevant at that point because nothing inspects them.

    First install

    Confirmed as expected, and worse than the issue assumed. With a genuine
    quarantine attribute the launch is refused, and the dialog offers only Done
    and Move to Bin — there is no "Open Anyway" and no Control-click path.
    spctl --assess reports rejected, source=no usable signature throughout.

    App Translocation

    One run with an already-assessed quarantine flag did launch, and it ran
    translocated from a read-only AppTranslocation mount. In that state the
    application sees itself at a path it cannot write, so self-replacement is
    impossible there. This did not reproduce on demand, so it stands as an
    observation rather than a measurement — but it is enough to make the
    consequence concrete: an application left in the download folder cannot update
    itself. The install instruction must require moving it to /Applications.
    Relevant to #76.

    Elevation

    /Applications is drwxrwxr-x root:admin. An admin user replaces a bundle
    there with no elevation and no password prompt.

    Does Velopack function unsigned?

    It does. vpk 1.2.0 packaged the real bundle with warnings only, never a
    refusal:

    WRN Package will not be signed or notarized.
    WRN No Installer signing identity provided. The '.pkg' will not be signed.
    INF Installer created successfully
    

    It produced the full set — .nupkg, portable .zip, Setup.pkg and the
    release feed. Two details worth recording:

    • The .app it emits runs, is ad-hoc signed, and has a correct executable bit.
    • The .pkg has Status: no signature and spctl --assess --type install
      rejects it. The installer route is therefore the weaker of the two; in-app
      updates should replace the bundle, not run a package.

    Can the Python SDK be used, and can auto-apply be disabled?

    velopack 1.2.0 is on PyPI as an abi3 wheel with a macOS arm64 build — a single
    compiled extension module, which Nuitka handles as an ordinary binary
    extension.

    • App().run() returns normally when the process is not a Velopack
      installation; it logs NotInstalled and execution continues. It therefore
      coexists with an entrypoint that already calls multiprocessing.freeze_support()
      before argparse.
    • set_auto_apply_on_startup(False) is exposed on the builder, so the concern
      about a segmentation child applying a pending update mid-render is
      controllable from Python.

    Still unanswered

    • macOS 15, for want of a host.
    • An end-to-end update against a real release feed.
    • App Translocation as a repeatable case rather than a single observation.

    What this decides

    The unsigned track survives. Signing is not what makes updates work — updates
    work without it. Signing buys a smoother first install, which is a separate
    decision with a separate value, and no longer blocks the updater work in #74.

  3. smb commented on Sep 6, 2026

    @smb
    CollaboratorAuthor

    Conclusion: the updater track is open

    Working assumption for this answer: releases stay unsigned for the foreseeable
    future, and the user approves the application once at first install through
    System Settings. Under that assumption the spike's blocking question is
    answered — we can start.

    Full updates: yes, measured

    Replacing the whole bundle and relaunching works, with no dialog and no
    elevation, including replacing it underneath the running process. That is the
    entire mechanism: download, verify a checksum, unpack beside the installed
    bundle, swap, relaunch.

    One correction to the assumption

    The install location is not interchangeable. An application left where it
    was downloaded runs translocated from a read-only mount, where it cannot write
    its own bundle and therefore cannot update itself at all.

    What ends translocation is the user moving the application in Finder;
    /Applications is simply the natural destination. The install instructions have
    to require that move, or the updater is inert for anyone who skips it. Refs #76.

    Delta updates: sound in principle, payoff unmeasured

    The concern recorded earlier — that patching would invalidate the signature —
    applies only to patching a bundle already on disk. A delta does not mutate, it
    reconstructs: base plus patch produces a byte-identical result, so the new
    executable carries exactly the ad-hoc signature its full build produced. Nothing
    needs re-signing, provided the update is assembled beside the installed bundle
    and swapped in whole, which is what the full-update path already does.

    What is genuinely unknown is whether deltas are worth anything here. The main
    executable is roughly 79 MiB of a ~380 MiB installed tree, and Nuitka output
    moves around between builds. A delta between two real releases could be a few
    MiB or most of the binary. That measurement is an hour of work and it decides
    the question below.

    Velopack, or a few lines of our own

    Without Velopack With Velopack
    Full update trivial, measured works unsigned, measured
    Delta would have to be built included
    Toolchain none vpk needs .NET in CI
    macOS installer not used unsigned .pkg is rejected by the install assessment, so unusable

    Velopack earns its place only if the delta measurement comes out well. If it
    does not, it buys a .NET dependency for nothing, and the self-written swap is
    the better answer.

    What staying unsigned actually costs

    Not installability — authenticity. Without signatures the integrity of an update
    rests entirely on HTTPS plus a published checksum, and an updater is by
    definition a mechanism that downloads and executes. Whatever is chosen, the
    package itself should carry its own signature — minisign with the public key
    compiled into the binary is cheap — so the trust rests on a key rather than on
    the transport.

    Suggested order

    1. Measure the delta size between two real builds. That decides Velopack.
    2. Package authentication, independent of which updater is used.
    3. The update mechanism itself.
  4. self-assigned this
    on Sep 9, 2026
  5. smb commented on Sep 9, 2026

    @smb
    CollaboratorAuthor

    Second measurement pass: translocation is now measured, not observed

    Re-run on macOS 26.6.2 arm64 against the two published archives, to close the
    three gaps the earlier answer left open. No macOS 15 host is available, so that
    one stays open.

    App Translocation is reproducible, and it changes the design

    The earlier answer recorded translocation as a single observation. It now
    reproduces on demand for a quarantined copy launched through LaunchServices,
    and the consequence is measurable rather than inferred:

    • Replacing the bundle at its physical path in the download folder while the
      translocated process runs: succeeds.
    • Renaming the bundle at the translocated path the running process reports:
      fails with Read-only file system.

    So the earlier conclusion — that an app left in the download folder cannot
    update itself — is half right. What fails is a design that replaces "whatever
    path the running process reports". An updater that resolves and keeps the
    physical install path can replace the bundle even from a translocated process.
    That is a requirement on the updater, not only an instruction to the user.

    Also confirmed: a quarantined copy under ~/Applications translocates too.
    Living in an Applications folder is not the cure; the Finder move that clears
    the quarantine attribute is.

    Self-replacement, re-confirmed

    Wait-for-exit, rename aside, rename into place, relaunch — works at a
    user-writable physical path, both by direct executable path and through
    open -a, with no dialog and no elevation. ~/Applications is
    drwxr-xr-x for the owner; /Applications is drwxrwxr-x root:admin, so an
    admin needs no elevation there either (permissions read, not written to).

    Quarantined first launch

    Unchanged and confirmed: spctl --assess reports rejected, no usable signature; a direct execution of a quarantined copy does not return.

    One packaging defect worth repeating

    The published MatteLoop-v0.2.1-macos-15-arm64.zip still cannot be launched as
    unpacked — every entry is -rw-r--r--, including the main executable. That is
    #57/#70, fixed after the v0.2.1 tag; 0.3.0 is correct. It only matters here
    because any A → B qualification using 0.2.1 as the base must normalize that bit
    first, or it measures the wrong failure.

    Still unanswered

    • macOS 15, for want of a host.
    • An end-to-end update against a real Velopack feed. This pass exercised a
      shell helper and kill -TERM, not the application's own close path and not
      Velopack's apply helper — and it packed only the main executable, not the
      full bundle. Neither substitutes for the qualification run.
    • A genuine browser-originated download; the quarantine attribute was set by
      hand, which measures Gatekeeper's response but not the download agent.
  6. smb commented on Sep 11, 2026

    @smb
    CollaboratorAuthor

    Answered, and the answer has been load-bearing ever since. Closing.

    The spike's deliverable was explicitly "a written answer in the issue. No code lands from this work." That answer is in the comments above, in two measurement passes, and the question it blocked — whether an unsigned, ad-hoc-signed Nuitka bundle can replace itself — came back yes.

    What it established, in short:

    • An update does not re-prompt. The barrier is the first install and only the first install. A self-downloaded bundle attaches no quarantine, so Gatekeeper never assesses it.
    • Replacing the whole bundle and relaunching works, with no dialog and no elevation, including underneath the running process.
    • The install location is not interchangeable, and App Translocation is reproducible rather than anecdotal: a quarantined copy launched through LaunchServices runs from a read-only image and cannot update itself. That measurement is why the release notes tell people to move the application out of Downloads, and why the updater declines rather than failing halfway when it detects that situation.

    All of this shipped: in-app updates are in 0.4.0 and 0.4.1 and work.

    One gap stays open and is worth recording rather than quietly dropping. No macOS 15 host was ever available, so every measurement here was taken on macOS 26. The project supports macOS 15+. Two releases have now shipped and updated without a report from that version, which is evidence but not a measurement. If a macOS 15 machine ever becomes available, re-running the swap on it is an afternoon well spent — as its own issue, not by reopening this one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

questionFurther information is requested

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions