diff --git a/examples/macos-appex-demo/README.md b/examples/macos-appex-demo/README.md
new file mode 100644
index 000000000..64b69e319
--- /dev/null
+++ b/examples/macos-appex-demo/README.md
@@ -0,0 +1,86 @@
+# macOS Network Extension (`.appex`) demo
+
+Reproduces the scenario from [issue #394](https://github.com/NucleusFramework/Nucleus/issues/394):
+shipping a macOS **Network Extension** (`.appex`) inside a Nucleus JVM app, embedded under
+`Contents/PlugIns/`, **signed with its own entitlements** (distinct from the host app).
+
+Nucleus embeds and signs the extension for you via the `appExtensions {}` DSL:
+
+```kotlin
+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"))
+ }
+ }
+}
+```
+
+Under the hood the plugin copies the `.appex` into `Contents/PlugIns/`, embeds its provisioning
+profile (as `Contents/embedded.provisionprofile` inside the extension), signs the extension with
+its own entitlements, then seals the outer app **without `--deep`** — so the extension keeps its
+distinct signature. It does the same on the DMG/PKG re-seal path.
+
+> Nucleus does not build the `.appex` — that stays Xcode / Kotlin/Native territory. Here a small
+> `build.sh` compiles a minimal `NEFilterDataProvider` into a universal `.appex`.
+
+## Layout
+
+```
+packaging/
+ app.entitlements host-app entitlements (App Group + networkextension)
+ extension/
+ FilterDataProvider.m minimal NEFilterDataProvider (allows all traffic)
+ Info.plist NSExtension declaration (principal class, point id)
+ NetworkExtension.entitlements the EXTENSION's own entitlements
+ build.sh compiles the universal .appex
+src/main/kotlin/.../Main.kt Compose app; inspects its own Contents/PlugIns at runtime
+```
+
+## Run it
+
+```bash
+# Build the .app with the extension embedded & signed (ad-hoc, no certificate needed):
+./gradlew :examples:macos-appex-demo:createDistributable
+
+# Launch it — the window lists the embedded extension and shows that the .appex
+# carries its own signature/entitlements, separate from the app:
+open build/compose/binaries/main/app/NetworkExtensionDemo.app
+```
+
+Inspect manually:
+
+```bash
+APP=build/compose/binaries/main/app/NetworkExtensionDemo.app
+codesign --verify --deep --strict --verbose=2 "$APP"
+codesign -d --entitlements :- "$APP/Contents/PlugIns/NetworkFilter.appex"
+```
+
+## Real distribution (Developer ID / App Store)
+
+1. Request the Network Extension capability for your App ID, create App IDs + provisioning
+ profiles for both the app and the extension (they need the same App Group).
+2. Enable `signing { sign.set(true); identity.set("Developer ID Application: You (TEAMID)") }`.
+3. Add each extension's `provisioningProfile(...)` and the app's `provisioningProfile.set(...)`.
+
+Build the GraalVM native variant (the `.appex` is embedded & ad-hoc signed there too):
+
+```bash
+GRAALVM_HOME=/path/to/graalvm ./gradlew :examples:macos-appex-demo:packageGraalvmNative
+# → build/compose/tmp/main/graalvm/output/NetworkExtensionDemo.app/Contents/PlugIns/NetworkFilter.appex
+```
+
+### Caveats
+
+- **GraalVM native images are always ad-hoc signed**, so the embedded extension is ad-hoc too.
+ For a Developer-ID/notarized GraalVM DMG, configure `signing {}` (the GraalVM DMG re-seal goes
+ through the same electron-builder path as the JVM one).
+- Actually *installing/enabling* the extension uses the NetworkExtension management APIs
+ (`NEFilterManager` / `NETunnelProviderManager`), called from the JVM via a native bridge —
+ see https://nucleusframework.dev/en/docs/performance/native-code/. This example is about
+ signing/bundling/shipping the `.appex`.
+- Testing the extension at runtime without a paid account requires disabling SIP + AMFI on a
+ dev VM / victim machine (`csrutil disable` + `nvram boot-args="amfi_get_out_of_my_way=0x1"`).
diff --git a/examples/macos-appex-demo/build.gradle.kts b/examples/macos-appex-demo/build.gradle.kts
new file mode 100644
index 000000000..6b89da461
--- /dev/null
+++ b/examples/macos-appex-demo/build.gradle.kts
@@ -0,0 +1,83 @@
+import dev.nucleusframework.desktop.application.dsl.TargetFormat
+
+plugins {
+ alias(libs.plugins.kotlin)
+ alias(libs.plugins.kotlinComposePlugin)
+ id("dev.nucleusframework")
+}
+
+dependencies {
+ implementation(nucleus.desktop.currentOs)
+ implementation(libs.compose.material3)
+}
+
+val macAppName = "NetworkExtensionDemo"
+val isMac = System.getProperty("os.name").startsWith("Mac")
+val extensionDir = layout.projectDirectory.dir("packaging/extension")
+val appexOutputDir = layout.buildDirectory.dir("appex")
+
+// Compile the Network Extension .appex (Nucleus does not build .appex itself).
+// Nucleus signs it via the appExtensions {} DSL below.
+val buildAppex by tasks.registering(Exec::class) {
+ group = "distribution"
+ description = "Compile the Network Extension .appex."
+ onlyIf { isMac }
+ inputs.dir(extensionDir)
+ outputs.dir(appexOutputDir)
+ commandLine(
+ "bash",
+ extensionDir.file("build.sh").asFile.absolutePath,
+ appexOutputDir.get().asFile.absolutePath,
+ )
+}
+
+nucleus.application {
+ mainClass = "dev.nucleusframework.appexdemo.MainKt"
+
+ // The .appex is embedded & signed on the GraalVM native path too (ad-hoc).
+ graalvm {
+ isEnabled = true
+ imageName = "network-extension-demo"
+ }
+
+ nativeDistributions {
+ targetFormats(TargetFormat.Dmg)
+ appName = "Network Extension Demo"
+ packageName = macAppName
+ packageVersion = "1.0.0"
+
+ macOS {
+ bundleID = "dev.nucleusframework.appexdemo"
+ appCategory = "public.app-category.utilities"
+ entitlementsFile.set(layout.projectDirectory.file("packaging/app.entitlements"))
+
+ // First-class embedding: Nucleus copies the .appex into Contents/PlugIns,
+ // signs it with its OWN entitlements, then seals the app without --deep.
+ appExtensions {
+ extension("NetworkFilter") {
+ appex(appexOutputDir.get().file("NetworkFilter.appex").asFile)
+ entitlements(extensionDir.file("NetworkExtension.entitlements").asFile)
+ // provisioningProfile(file("packaging/NetworkFilter.provisionprofile")) // real distribution
+ }
+ }
+
+ // For a real, notarizable / App Store build, enable signing so the DMG re-seal
+ // keeps the nested extension signature:
+ // signing {
+ // sign.set(true)
+ // identity.set("Developer ID Application: You (TEAMID)")
+ // }
+ }
+ }
+}
+
+// The .appex must exist before the app image is assembled (JVM and GraalVM paths).
+val appImageTasks =
+ setOf(
+ "createDistributable",
+ "createReleaseDistributable",
+ "embedGraalvmAppExtensions",
+ "embedReleaseGraalvmAppExtensions",
+ )
+tasks.matching { it.name in appImageTasks }.configureEach { dependsOn(buildAppex) }
+
diff --git a/examples/macos-appex-demo/packaging/app.entitlements b/examples/macos-appex-demo/packaging/app.entitlements
new file mode 100644
index 000000000..41f084caa
--- /dev/null
+++ b/examples/macos-appex-demo/packaging/app.entitlements
@@ -0,0 +1,29 @@
+
+
+
+
+
+ com.apple.developer.networking.networkextension
+
+ content-filter-provider
+
+ com.apple.security.application-groups
+
+ group.dev.nucleusframework.appexdemo
+
+
+ com.apple.security.cs.allow-jit
+
+ com.apple.security.cs.allow-unsigned-executable-memory
+
+ com.apple.security.cs.disable-library-validation
+
+
+
diff --git a/examples/macos-appex-demo/packaging/extension/FilterDataProvider.m b/examples/macos-appex-demo/packaging/extension/FilterDataProvider.m
new file mode 100644
index 000000000..1b27e42c2
--- /dev/null
+++ b/examples/macos-appex-demo/packaging/extension/FilterDataProvider.m
@@ -0,0 +1,34 @@
+// Minimal macOS Network Extension provider used only to demonstrate packaging.
+//
+// This is a content-filter data provider (NEFilterDataProvider) that allows all
+// traffic. It is intentionally trivial: the point of this example is the *build,
+// sign, bundle and re-seal* pipeline around the .appex, not the filtering logic.
+//
+// The executable has no main() of its own — an app extension's entry point is
+// NSExtensionMain (provided by Foundation). build.sh links it via `-e _NSExtensionMain`.
+// The principal class is declared in Info.plist (NSExtensionPrincipalClass).
+
+#import
+#import
+
+@interface FilterDataProvider : NEFilterDataProvider
+@end
+
+@implementation FilterDataProvider
+
+- (void)startFilterWithCompletionHandler:(void (^)(NSError *_Nullable))completionHandler {
+ // No filtering rules — start successfully.
+ completionHandler(nil);
+}
+
+- (void)stopFilterWithReason:(NEProviderStopReason)reason
+ completionHandler:(void (^)(void))completionHandler {
+ completionHandler();
+}
+
+- (NEFilterNewFlowVerdict *)handleNewFlow:(NEFilterFlow *)flow {
+ // Allow every new flow.
+ return [NEFilterNewFlowVerdict allowVerdict];
+}
+
+@end
diff --git a/examples/macos-appex-demo/packaging/extension/Info.plist b/examples/macos-appex-demo/packaging/extension/Info.plist
new file mode 100644
index 000000000..f1fed4ff1
--- /dev/null
+++ b/examples/macos-appex-demo/packaging/extension/Info.plist
@@ -0,0 +1,31 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleDisplayName
+ Network Filter
+ CFBundleExecutable
+ NetworkFilter
+ CFBundleIdentifier
+ dev.nucleusframework.appexdemo.networkfilter
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ NetworkFilter
+ CFBundlePackageType
+ XPC!
+ CFBundleShortVersionString
+ 1.0.0
+ CFBundleVersion
+ 1
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.networkextension.filter-data
+ NSExtensionPrincipalClass
+ FilterDataProvider
+
+
+
diff --git a/examples/macos-appex-demo/packaging/extension/NetworkExtension.entitlements b/examples/macos-appex-demo/packaging/extension/NetworkExtension.entitlements
new file mode 100644
index 000000000..317e18315
--- /dev/null
+++ b/examples/macos-appex-demo/packaging/extension/NetworkExtension.entitlements
@@ -0,0 +1,20 @@
+
+
+
+
+
+ com.apple.developer.networking.networkextension
+
+ content-filter-provider
+
+
+ com.apple.security.application-groups
+
+ group.dev.nucleusframework.appexdemo
+
+
+
diff --git a/examples/macos-appex-demo/packaging/extension/build.sh b/examples/macos-appex-demo/packaging/extension/build.sh
new file mode 100755
index 000000000..482c0cca0
--- /dev/null
+++ b/examples/macos-appex-demo/packaging/extension/build.sh
@@ -0,0 +1,35 @@
+#!/usr/bin/env bash
+#
+# Compiles the Network Extension .appex bundle. Signing is handled by Nucleus:
+# the appExtensions {} DSL signs the extension with its own entitlements and seals
+# the app. This script only produces the (unsigned) .appex.
+#
+# Usage: build.sh → /NetworkFilter.appex
+set -euo pipefail
+
+OUT_DIR="${1:?usage: build.sh }"
+HERE="$(cd "$(dirname "$0")" && pwd)"
+
+APPEX="$OUT_DIR/NetworkFilter.appex"
+MACOS_DIR="$APPEX/Contents/MacOS"
+
+echo "==> Assembling $APPEX"
+rm -rf "$APPEX"
+mkdir -p "$MACOS_DIR"
+cp "$HERE/Info.plist" "$APPEX/Contents/Info.plist"
+
+# An app extension's executable entry point is NSExtensionMain (from Foundation),
+# so there is no main() in our source; we override the entry symbol with -e.
+echo "==> Compiling universal (arm64 + x86_64) executable"
+clang \
+ -arch arm64 -arch x86_64 \
+ -mmacosx-version-min=11.0 \
+ -fobjc-arc \
+ -fvisibility=hidden \
+ -framework Foundation \
+ -framework NetworkExtension \
+ -e _NSExtensionMain \
+ -o "$MACOS_DIR/NetworkFilter" \
+ "$HERE/FilterDataProvider.m"
+
+echo "==> Done: $APPEX"
diff --git a/examples/macos-appex-demo/src/main/kotlin/dev/nucleusframework/appexdemo/Main.kt b/examples/macos-appex-demo/src/main/kotlin/dev/nucleusframework/appexdemo/Main.kt
new file mode 100644
index 000000000..6bb7f30d0
--- /dev/null
+++ b/examples/macos-appex-demo/src/main/kotlin/dev/nucleusframework/appexdemo/Main.kt
@@ -0,0 +1,94 @@
+package dev.nucleusframework.appexdemo
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Window
+import androidx.compose.ui.window.application
+import java.io.File
+
+/**
+ * Demonstrates a Nucleus JVM app shipping a macOS Network Extension `.appex`
+ * embedded under `Contents/PlugIns/`.
+ *
+ * When launched from the packaged `.app`, this window locates its own bundle and
+ * lists the embedded extensions, proving that the `.appex` was bundled and that
+ * it carries its OWN code signature / entitlements (distinct from the app).
+ *
+ * Note: this only *inspects* the bundled extension. Actually installing/enabling
+ * a Network Extension requires the NetworkExtension management APIs
+ * (NEFilterManager / NETunnelProviderManager), reached from the JVM via a native
+ * bridge (Kotlin/Native + FFM or JNI) — out of scope for this packaging example.
+ * See https://nucleusframework.dev/en/docs/performance/native-code/
+ */
+fun main() =
+ application {
+ Window(onCloseRequest = ::exitApplication, title = "Network Extension Demo") {
+ MaterialTheme {
+ var report by remember { mutableStateOf(inspectBundledExtensions()) }
+ Column(
+ modifier = Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text("Bundled Network Extensions", style = MaterialTheme.typography.titleLarge)
+ Button(onClick = { report = inspectBundledExtensions() }) { Text("Refresh") }
+ Text(report, style = MaterialTheme.typography.bodyMedium)
+ }
+ }
+ }
+ }
+
+/** Walks up from the running executable to the `.app`, then lists the `.appex` bundles in `Contents/PlugIns`. */
+private fun inspectBundledExtensions(): String {
+ val pluginsDir = locatePlugInsDir()
+ ?: return "Not running from a packaged .app bundle.\n" +
+ "Package first, then launch the app from the built .app:\n" +
+ " ./gradlew :examples:macos-appex-demo:embedAppex\n" +
+ " open build/compose/binaries/main/app/NetworkExtensionDemo.app"
+
+ val appexes = pluginsDir.listFiles { f -> f.isDirectory && f.name.endsWith(".appex") }?.toList().orEmpty()
+ if (appexes.isEmpty()) return "No .appex found under ${pluginsDir.absolutePath}"
+
+ return buildString {
+ appendLine("PlugIns: ${pluginsDir.absolutePath}\n")
+ for (appex in appexes) {
+ appendLine("• ${appex.name}")
+ appendLine(codesignInfo(appex).prependIndent(" "))
+ appendLine()
+ }
+ }
+}
+
+private fun locatePlugInsDir(): File? {
+ // Inside a packaged app the launcher lives at .app/Contents/MacOS/.
+ val cmd = ProcessHandle.current().info().command().orElse(null) ?: return null
+ val macOsDir = File(cmd).parentFile ?: return null // .../Contents/MacOS
+ val contents = macOsDir.parentFile ?: return null // .../Contents
+ if (contents.name != "Contents") return null
+ return File(contents, "PlugIns").takeIf { it.isDirectory }
+}
+
+/** Reads the extension's real signature + entitlements via the codesign CLI. */
+private fun codesignInfo(appex: File): String =
+ try {
+ val proc = ProcessBuilder(
+ "/usr/bin/codesign", "-d", "--verbose=2", "--entitlements", ":-", appex.absolutePath,
+ ).redirectErrorStream(true).start()
+ val out = proc.inputStream.bufferedReader().readText()
+ proc.waitFor()
+ out.trim().ifEmpty { "(no signature information)" }
+ } catch (e: Exception) {
+ "codesign inspection failed: ${e.message}"
+ }
diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/MacAppExtensionSettings.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/MacAppExtensionSettings.kt
new file mode 100644
index 000000000..c172a493b
--- /dev/null
+++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/MacAppExtensionSettings.kt
@@ -0,0 +1,89 @@
+/*
+ * Copyright 2020-2022 JetBrains s.r.o. and respective authors and developers.
+ * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file.
+ */
+
+package dev.nucleusframework.desktop.application.dsl
+
+import org.gradle.api.Action
+import java.io.File
+import java.io.Serializable
+
+/**
+ * DSL block for embedding macOS app extensions (`.appex`) in the app bundle at
+ * `Contents/PlugIns/`.
+ *
+ * Nucleus copies each extension into the bundle and signs it with its OWN
+ * entitlements and provisioning profile, then seals the outer app without
+ * `--deep` so the extension keeps its distinct signature. This is what a macOS
+ * Network Extension needs (its own `com.apple.developer.networking.networkextension`
+ * entitlement, its own App Group, its own `embedded.provisionprofile`).
+ *
+ * Nucleus does not build the `.appex` — build it with Xcode or Kotlin/Native and
+ * point [MacAppExtension.appex] at the result.
+ *
+ * ```kotlin
+ * macOS {
+ * appExtensions {
+ * extension("NetworkFilter") {
+ * appex(file("build/NetworkExtension/NetworkFilter.appex"))
+ * entitlements(file("packaging/networkextension.entitlements"))
+ * provisioningProfile(file("packaging/NetworkFilter.provisionprofile"))
+ * }
+ * }
+ * }
+ * ```
+ */
+class MacAppExtensionSettings : Serializable {
+ internal val extensions: MutableList = mutableListOf()
+
+ /**
+ * Declares an app extension to embed.
+ *
+ * @param name identifier used for diagnostics only
+ */
+ fun extension(name: String, fn: Action) {
+ val extension = MacAppExtension(name)
+ fn.execute(extension)
+ extensions.add(extension)
+ }
+
+ companion object {
+ private const val serialVersionUID = 1L
+ }
+}
+
+/**
+ * A single macOS app extension (`.appex`) to embed under `Contents/PlugIns/`.
+ *
+ * The extension is signed with its own [entitlements] (and, when set,
+ * [provisioningProfile]), using the app's signing identity. The outer app is then
+ * re-sealed without `--deep` so the extension's signature is preserved.
+ */
+class MacAppExtension(
+ /** Identifier used for diagnostics only. */
+ val name: String,
+) : Serializable {
+ internal var appex: File? = null
+ internal var entitlements: File? = null
+ internal var provisioningProfile: File? = null
+
+ /** The prebuilt `.appex` bundle to embed. */
+ fun appex(bundle: File) {
+ appex = bundle
+ }
+
+ /** Entitlements plist applied to the extension (distinct from the app's). */
+ fun entitlements(file: File) {
+ entitlements = file
+ }
+
+ /** Provisioning profile embedded as `Contents/embedded.provisionprofile` inside the extension. */
+ fun provisioningProfile(file: File) {
+ provisioningProfile = file
+ }
+
+ companion object {
+ private const val serialVersionUID = 1L
+ }
+}
diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/PlatformSettings.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/PlatformSettings.kt
index 19129ca13..bc00b1e04 100644
--- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/PlatformSettings.kt
+++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/PlatformSettings.kt
@@ -132,6 +132,28 @@ abstract class JvmMacOSPlatformSettings : AbstractMacOSPlatformSettings() {
fn.execute(launchAgents)
}
+ /**
+ * Configures macOS app extensions (`.appex`) to embed under `Contents/PlugIns/`,
+ * each signed with its own entitlements and provisioning profile.
+ *
+ * ```kotlin
+ * macOS {
+ * appExtensions {
+ * extension("NetworkFilter") {
+ * appex(file("build/NetworkExtension/NetworkFilter.appex"))
+ * entitlements(file("packaging/networkextension.entitlements"))
+ * provisioningProfile(file("packaging/NetworkFilter.provisionprofile"))
+ * }
+ * }
+ * }
+ * ```
+ */
+ val appExtensions: MacAppExtensionSettings = MacAppExtensionSettings()
+
+ fun appExtensions(fn: Action) {
+ fn.execute(appExtensions)
+ }
+
internal val infoPlistSettings = InfoPlistSettings()
fun infoPlist(fn: Action) {
diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/configureGraalvmApplication.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/configureGraalvmApplication.kt
index ed525f336..e7559b3ff 100644
--- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/configureGraalvmApplication.kt
+++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/configureGraalvmApplication.kt
@@ -4,6 +4,7 @@ package dev.nucleusframework.desktop.application.internal
import dev.nucleusframework.desktop.application.dsl.FileAssociation
import dev.nucleusframework.desktop.application.dsl.GraalvmSettings
+import dev.nucleusframework.desktop.application.dsl.MacAppExtension
import dev.nucleusframework.desktop.application.dsl.NativeImageMarch
import dev.nucleusframework.desktop.application.dsl.PackagingBackend
import dev.nucleusframework.desktop.application.dsl.UrlProtocol
@@ -1591,6 +1592,29 @@ private fun JvmApplicationContext.configureMacOsGraalvmPackaging(
commandLine("codesign", "--force", "--deep", "--sign", "-", bundleDir.get().asFile.absolutePath)
}
+ // Embed and (ad-hoc) sign app extensions into Contents/PlugIns after the bundle is sealed,
+ // then re-seal the outer bundle without --deep so each extension keeps its own entitlements.
+ val macAppExtensions = app.nativeDistributions.macOS.appExtensions.extensions
+ val embedAppExtensions =
+ if (macAppExtensions.isNotEmpty()) {
+ tasks.register(
+ taskNameAction = "embed",
+ taskNameObject = "graalvmAppExtensions",
+ ) {
+ description = "Embed and sign macOS app extensions (.appex) into the .app bundle"
+ dependsOn(codesignBundle)
+ for (extension in macAppExtensions) {
+ extension.appex?.let { inputs.dir(it) }
+ extension.entitlements?.let { inputs.file(it) }
+ extension.provisioningProfile?.let { inputs.file(it) }
+ }
+ val bundleDir = appTmpDir.map { it.dir("graalvm/output/${appBundleName.get()}") }.get().asFile
+ commandLine("bash", "-c", buildGraalvmAppExtensionEmbedScript(bundleDir, macAppExtensions))
+ }
+ } else {
+ null
+ }
+
return tasks.register(
taskNameAction = "package",
taskNameObject = "graalvmNative",
@@ -1611,6 +1635,48 @@ private fun JvmApplicationContext.configureMacOsGraalvmPackaging(
copyIcon,
)
copyFileAssociationIcons?.let { dependsOn(it) }
+ embedAppExtensions?.let { dependsOn(it) }
+ }
+}
+
+/**
+ * Builds the bash script that embeds each `.appex` into the GraalVM `.app` bundle's
+ * `Contents/PlugIns/`, signs it (ad-hoc) with its own entitlements inside-out, and re-seals
+ * the outer bundle without `--deep`. GraalVM native images are always ad-hoc signed.
+ */
+private fun buildGraalvmAppExtensionEmbedScript(
+ bundleDir: File,
+ extensions: List,
+): String {
+ fun quote(file: File): String = "'" + file.absolutePath.replace("'", "'\\''") + "'"
+
+ val plugInsDir = File(bundleDir, "Contents/PlugIns")
+ return buildString {
+ appendLine("set -euo pipefail")
+ appendLine("mkdir -p ${quote(plugInsDir)}")
+ for (extension in extensions) {
+ val source =
+ extension.appex
+ ?: error("appExtension '${extension.name}': no .appex file configured (call appex(...))")
+ val dest = File(plugInsDir, source.name)
+ val frameworks = File(dest, "Contents/Frameworks")
+ val entitlementsArg = extension.entitlements?.let { " --entitlements ${quote(it)}" } ?: ""
+
+ appendLine("rm -rf ${quote(dest)}")
+ appendLine("cp -R ${quote(source)} ${quote(plugInsDir)}/")
+ extension.provisioningProfile?.let { profile ->
+ appendLine("cp ${quote(profile)} ${quote(File(dest, "Contents/embedded.provisionprofile"))}")
+ }
+ // Sign nested frameworks first (inside-out), then the extension bundle.
+ appendLine(
+ "if [ -d ${quote(frameworks)} ]; then find ${quote(frameworks)} -type f " +
+ "-exec codesign --force --options runtime$entitlementsArg --sign - {} +; fi",
+ )
+ appendLine("codesign --force --options runtime$entitlementsArg --sign - ${quote(dest)}")
+ }
+ // Re-seal the outer bundle (no --deep) so the nested extension signatures are preserved.
+ appendLine("codesign --force --options runtime --sign - ${quote(bundleDir)}")
+ appendLine("codesign --verify --deep --strict --verbose=2 ${quote(bundleDir)}")
}
}
@@ -2003,6 +2069,12 @@ private fun JvmApplicationContext.configureGraalvmElectronBuilderPackaging(
unpackDefaultResources.flatMap { it.resources.defaultEntitlements },
),
)
+ macAppExtensions.set(mac.appExtensions.extensions)
+ macAppExtensionFiles.from(
+ mac.appExtensions.extensions.flatMap {
+ listOfNotNull(it.appex, it.entitlements, it.provisioningProfile)
+ },
+ )
}
}
diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/configureJvmApplication.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/configureJvmApplication.kt
index 925769185..d59423700 100644
--- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/configureJvmApplication.kt
+++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/configureJvmApplication.kt
@@ -975,6 +975,12 @@ private fun JvmApplicationContext.configureElectronBuilderPackageTask(
packageTask.macRuntimeEntitlementsFile.set(
mac.runtimeEntitlementsFile.orElse(defaultRuntimeEntitlements),
)
+ packageTask.macAppExtensions.set(mac.appExtensions.extensions)
+ packageTask.macAppExtensionFiles.from(
+ mac.appExtensions.extensions.flatMap {
+ listOfNotNull(it.appex, it.entitlements, it.provisioningProfile)
+ },
+ )
}
}
@@ -1077,6 +1083,12 @@ internal fun JvmApplicationContext.configurePlatformSettings(
packageTask.urlProtocols.set(app.nativeDistributions.protocols)
packageTask.macLayeredIcons.set(mac.layeredIconDir)
packageTask.macLaunchAgents.set(mac.launchAgents.agents)
+ packageTask.macAppExtensions.set(mac.appExtensions.extensions)
+ packageTask.macAppExtensionFiles.from(
+ mac.appExtensions.extensions.flatMap {
+ listOfNotNull(it.appex, it.entitlements, it.provisioningProfile)
+ },
+ )
}
}
}
diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractElectronBuilderPackageTask.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractElectronBuilderPackageTask.kt
index a54f8ff8e..a9dcebcde 100644
--- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractElectronBuilderPackageTask.kt
+++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractElectronBuilderPackageTask.kt
@@ -7,6 +7,7 @@ package dev.nucleusframework.desktop.application.tasks
import dev.nucleusframework.desktop.application.dsl.CompressionLevel
import dev.nucleusframework.desktop.application.dsl.JvmApplicationDistributions
+import dev.nucleusframework.desktop.application.dsl.MacAppExtension
import dev.nucleusframework.desktop.application.dsl.MacOSSigningSettings
import dev.nucleusframework.desktop.application.dsl.ReleaseChannel
import dev.nucleusframework.desktop.application.dsl.TargetFormat
@@ -43,13 +44,16 @@ import net.coobird.thumbnailator.Thumbnails
import net.coobird.thumbnailator.filters.Canvas
import net.coobird.thumbnailator.geometry.Positions
import org.gradle.api.GradleException
+import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.logging.Logger
+import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.InputFile
+import org.gradle.api.tasks.InputFiles
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.Nested
import org.gradle.api.tasks.Optional
@@ -188,6 +192,16 @@ abstract class AbstractElectronBuilderPackageTask
@get:Optional
internal val nonValidatedMacBundleID: Property = objects.nullableProperty()
+ @get:Internal
+ internal val macAppExtensions: ListProperty =
+ objects.listProperty(MacAppExtension::class.java).convention(emptyList())
+
+ // Tracks the .appex payload + per-extension entitlements/profiles for up-to-date checks.
+ @get:InputFiles
+ @get:Optional
+ @get:PathSensitive(PathSensitivity.RELATIVE)
+ internal val macAppExtensionFiles: ConfigurableFileCollection = objects.fileCollection()
+
@get:Input
@get:Optional
val macAppStore: Property = objects.nullableProperty()
@@ -712,6 +726,15 @@ abstract class AbstractElectronBuilderPackageTask
spec.isIgnoreExitValue = false
}
+ // The blanket `--deep` above re-signs embedded extensions ad-hoc, dropping their
+ // own entitlements. When extensions are configured, re-sign them with their
+ // entitlements and re-seal the outer bundle (without --deep) to preserve them.
+ // NoCertificateSigner only signs on Apple Silicon; on Intel the --deep result stands.
+ if (signer != null && currentArch == Arch.Arm64 && macAppExtensions.get().isNotEmpty()) {
+ signAppExtensions(appDir, signer)
+ signer.sign(appDir, macEntitlementsFile.orNull?.asFile, forceEntitlements = true)
+ }
+
logger.info("Ad-hoc signature applied successfully")
}
@@ -759,10 +782,59 @@ abstract class AbstractElectronBuilderPackageTask
}
}
+ // Re-sign embedded app extensions (Contents/PlugIns) with their own entitlements
+ // before sealing the outer bundle. The jpackage task embedded them; the copy that
+ // electron-builder packages must carry a valid nested signature.
+ signAppExtensions(appDir, signer)
+
// Re-sign the entire app bundle
signer.sign(appDir, appEntitlements, forceEntitlements = true)
}
+ /**
+ * Re-signs each configured app extension found under `Contents/PlugIns/` with its own
+ * entitlements, inside-out. Mirrors the embedding done by the jpackage task; here the
+ * `.appex` already exists in the bundle copy and only needs a fresh signature.
+ */
+ private fun signAppExtensions(
+ appDir: File,
+ signer: MacSigner,
+ ) {
+ val extensions = macAppExtensions.get()
+ if (extensions.isEmpty()) return
+
+ val plugInsDir = appDir.resolve("Contents/PlugIns")
+ for (extension in extensions) {
+ val appexName = extension.appex?.name ?: continue
+ val appex = plugInsDir.resolve(appexName)
+ if (!appex.exists()) continue
+ signBundleInsideOut(appex, extension.entitlements, signer)
+ }
+ }
+
+ /**
+ * Signs a nested bundle (e.g. an `.appex`) inside-out: nested executables/dylibs in its
+ * `Contents/Frameworks` first, then the bundle itself with its [entitlements].
+ */
+ private fun signBundleInsideOut(
+ bundle: File,
+ entitlements: File?,
+ signer: MacSigner,
+ ) {
+ val frameworks = bundle.resolve("Contents/Frameworks")
+ if (frameworks.exists()) {
+ frameworks.walk().forEach { file ->
+ val path = file.toPath()
+ if (path.isRegularFile(LinkOption.NOFOLLOW_LINKS) &&
+ (path.isExecutable() || file.name.isDylibPath)
+ ) {
+ signer.sign(file, entitlements)
+ }
+ }
+ }
+ signer.sign(bundle, entitlements, forceEntitlements = true)
+ }
+
/**
* Re-signs the .app bundle for PKG builds (always App Store).
* Delegates to [resignApp] for the core signing, then augments entitlements
diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractJPackageTask.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractJPackageTask.kt
index ddf6e7d34..3d768b020 100644
--- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractJPackageTask.kt
+++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractJPackageTask.kt
@@ -7,6 +7,7 @@ package dev.nucleusframework.desktop.application.tasks
import dev.nucleusframework.desktop.application.dsl.FileAssociation
import dev.nucleusframework.desktop.application.dsl.LaunchAgentDefinition
+import dev.nucleusframework.desktop.application.dsl.MacAppExtension
import dev.nucleusframework.desktop.application.dsl.MacOSSigningSettings
import dev.nucleusframework.desktop.application.internal.LaunchAgentPlistGenerator
import dev.nucleusframework.desktop.application.dsl.TargetFormat
@@ -265,6 +266,16 @@ abstract class AbstractJPackageTask
internal val macLaunchAgents: ListProperty =
objects.listProperty(LaunchAgentDefinition::class.java).convention(emptyList())
+ @get:Internal
+ internal val macAppExtensions: ListProperty =
+ objects.listProperty(MacAppExtension::class.java).convention(emptyList())
+
+ // Tracks the .appex payload + per-extension entitlements/profiles for up-to-date checks.
+ @get:InputFiles
+ @get:Optional
+ @get:PathSensitive(PathSensitivity.RELATIVE)
+ internal val macAppExtensionFiles: ConfigurableFileCollection = objects.fileCollection()
+
@get:Input
@get:Optional
val macOsSdkVersion: Property = objects.nullableProperty()
@@ -671,6 +682,9 @@ abstract class AbstractJPackageTask
}
}
+ // Embed and sign app extensions (.appex) into Contents/PlugIns before sealing the app.
+ embedAndSignAppExtensions(appDir, macSigner)
+
macSigner.sign(runtimeDir, runtimeEntitlementsFile, forceEntitlements = true)
macSigner.sign(appDir, appEntitlementsFile, forceEntitlements = true)
@@ -684,6 +698,65 @@ abstract class AbstractJPackageTask
}
}
+ /**
+ * Copies each configured app extension into `Contents/PlugIns/`, embeds its own
+ * provisioning profile, and signs it inside-out with its own entitlements. The outer
+ * app is sealed afterwards (without `--deep`), which preserves these signatures.
+ */
+ private fun embedAndSignAppExtensions(
+ appDir: File,
+ macSigner: MacSigner,
+ ) {
+ val extensions = macAppExtensions.get()
+ if (extensions.isEmpty()) return
+
+ val plugInsDir = appDir.resolve("Contents/PlugIns")
+ for (extension in extensions) {
+ val source =
+ extension.appex
+ ?: error("appExtension '${extension.name}': no .appex file configured (call appex(...))")
+ check(source.exists()) {
+ "appExtension '${extension.name}': .appex not found at ${source.absolutePath}"
+ }
+ plugInsDir.mkdirs()
+ val dest = plugInsDir.resolve(source.name)
+ dest.deleteRecursively()
+ source.copyRecursively(dest, overwrite = true)
+
+ // Embed the extension's own provisioning profile.
+ extension.provisioningProfile?.copyTo(
+ target = dest.resolve("Contents/embedded.provisionprofile"),
+ overwrite = true,
+ )
+
+ // Sign the extension inside-out with its OWN entitlements.
+ signBundleInsideOut(dest, extension.entitlements, macSigner)
+ }
+ }
+
+ /**
+ * Signs a nested bundle (e.g. an `.appex`) inside-out: nested executables/dylibs in its
+ * `Contents/Frameworks` first, then the bundle itself with its [entitlements].
+ */
+ private fun signBundleInsideOut(
+ bundle: File,
+ entitlements: File?,
+ macSigner: MacSigner,
+ ) {
+ val frameworks = bundle.resolve("Contents/Frameworks")
+ if (frameworks.exists()) {
+ frameworks.walk().forEach { file ->
+ val path = file.toPath()
+ if (path.isRegularFile(LinkOption.NOFOLLOW_LINKS) &&
+ (path.isExecutable() || file.name.isDylibPath)
+ ) {
+ macSigner.sign(file, entitlements)
+ }
+ }
+ }
+ macSigner.sign(bundle, entitlements, forceEntitlements = true)
+ }
+
/**
* Moves native libraries from `Contents/app/resources/` to `Contents/Frameworks/`
* (Apple convention for sandboxed apps) and signs them.
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 5dfca6f73..06de0ad13 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -91,4 +91,5 @@ include(":examples:fs-watcher-smoke")
include(":examples:extra-launcher-demo")
include(":examples:benchmark-demo")
include(":examples:tao-native-test")
+include(":examples:macos-appex-demo")
includeBuild("plugin-build")