Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions examples/macos-appex-demo/README.md
Original file line number Diff line number Diff line change
@@ -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"`).
83 changes: 83 additions & 0 deletions examples/macos-appex-demo/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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) }

29 changes: 29 additions & 0 deletions examples/macos-appex-demo/packaging/app.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!--
HOST APP entitlements. These are distinct from the extension's
(packaging/extension/NetworkExtension.entitlements).

The host app needs the networkextension entitlement to install/enable the
extension via the NetworkExtension management APIs, and the SAME App Group
as the extension to share a container / IPC.
-->
<key>com.apple.developer.networking.networkextension</key>
<array>
<string>content-filter-provider</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.dev.nucleusframework.appexdemo</string>
</array>
<!-- JVM apps need these; keep them when adding a hardened-runtime signature. -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
34 changes: 34 additions & 0 deletions examples/macos-appex-demo/packaging/extension/FilterDataProvider.m
Original file line number Diff line number Diff line change
@@ -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 <Foundation/Foundation.h>
#import <NetworkExtension/NetworkExtension.h>

@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
31 changes: 31 additions & 0 deletions examples/macos-appex-demo/packaging/extension/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Network Filter</string>
<key>CFBundleExecutable</key>
<string>NetworkFilter</string>
<key>CFBundleIdentifier</key>
<string>dev.nucleusframework.appexdemo.networkfilter</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>NetworkFilter</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.networkextension.filter-data</string>
<key>NSExtensionPrincipalClass</key>
<string>FilterDataProvider</string>
</dict>
</dict>
</plist>
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!--
Entitlements that belong to the EXTENSION, not the host app.
This is the whole point of the issue: the .appex needs its own, distinct
entitlements (and, for real distribution, its own provisioning profile).
-->
<key>com.apple.developer.networking.networkextension</key>
<array>
<string>content-filter-provider</string>
</array>
<!-- Shared App Group used to talk to the host app (IPC / shared container). -->
<key>com.apple.security.application-groups</key>
<array>
<string>group.dev.nucleusframework.appexdemo</string>
</array>
</dict>
</plist>
35 changes: 35 additions & 0 deletions examples/macos-appex-demo/packaging/extension/build.sh
Original file line number Diff line number Diff line change
@@ -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 <output-dir> → <output-dir>/NetworkFilter.appex
set -euo pipefail

OUT_DIR="${1:?usage: build.sh <output-dir>}"
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"
Loading
Loading