Skip to content

feat(packaging): embed & sign macOS app extensions (.appex) - #396

Draft
kdroidFilter wants to merge 1 commit into
mainfrom
feat/macos-app-extensions
Draft

feat(packaging): embed & sign macOS app extensions (.appex)#396
kdroidFilter wants to merge 1 commit into
mainfrom
feat/macos-app-extensions

Conversation

@kdroidFilter

Copy link
Copy Markdown
Collaborator

Summary

Adds first-class support for embedding & signing macOS app extensions (.appex) — e.g. a Network Extension — inside a packaged app, addressing #394.

New DSL:

macOS {
    entitlementsFile.set(file("packaging/app.entitlements"))     // host-app entitlements
    appExtensions {
        extension("NetworkFilter") {
            appex(file("build/appex/NetworkFilter.appex"))                          // prebuilt .appex
            entitlements(file("packaging/extension/NetworkExtension.entitlements"))  // its OWN
            // provisioningProfile(file("packaging/NetworkFilter.provisionprofile"))
        }
    }
}

The plugin copies each .appex into Contents/PlugIns/, embeds its own embedded.provisionprofile, signs it inside-out with its own entitlements, then seals the outer app without --deep so the nested signature is preserved.

What changed

  • DSL: MacAppExtensionSettings / MacAppExtension + macOS { appExtensions { } }.
  • JVM/jpackage (AbstractJPackageTask): embed + sign extensions before the final seal.
  • DMG/PKG (AbstractElectronBuilderPackageTask): re-sign extensions in resignApp and in the ad-hoc --deep fallback (arm64) so installers preserve their entitlements.
  • GraalVM native (configureGraalvmApplication): embed + ad-hoc sign extensions into the native .app, plus wire them into the GraalVM electron-builder task.
  • Example: examples/macos-appex-demo (minimal NEFilterDataProvider compiled to a universal .appex, Compose UI that inspects its own Contents/PlugIns).

Nucleus does not build the .appex (that stays Xcode / Kotlin/Native); it copies + signs + seals a prebuilt one.

No regressions by design

All new paths are guarded by isEmpty()/isNotEmpty() — behavior is byte-identical when appExtensions is unused.

Test plan

  • Plugin compiles; detekt + ktlint pass
  • JVM createDistributable.appex in Contents/PlugIns, codesign --verify --deep --strict OK, distinct entitlements (app allow-jit=1 / appex allow-jit=0, appex networkextension=1)
  • Self-signed named identity → app + appex carry the Authority (not adhoc)
  • GraalVM packageGraalvmNative (real native arm64 binary) → .appex embedded, --deep --strict OK
  • Regression: no-extension example (extra-launcher-demo) still packages, no PlugIns, codesign OK
  • Verified by @tomasjablonskis against a real Network Extension via publishDevToMavenLocal

Limitations

  • GraalVM native images are always ad-hoc signed (embedded extension is ad-hoc too); configure signing {} for a notarized GraalVM DMG.
  • Activating the extension at runtime uses the NetworkExtension management APIs (NEFilterManager / NETunnelProviderManager) via a native bridge — out of scope here; this is about signing/bundling/shipping the .appex.

…tensions DSL

Add a macOS { appExtensions { } } DSL to embed prebuilt .appex bundles into
Contents/PlugIns, each signed with its own entitlements and (optional) provisioning
profile, then seal the outer app without --deep so the extension keeps its distinct
signature. Covers the JVM/jpackage path, the DMG/PKG re-seal (electron-builder), and
GraalVM native images. Adds the macos-appex-demo example.

No behavior change when appExtensions is unused (all new paths are guarded).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant