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")