# Potassium Packager > Potassium is a Gradle plugin for packaging and distributing Compose / JVM desktop applications on macOS, Windows, and Linux. It extends the official JetBrains Compose Desktop plugin and adds many installer formats (deb/rpm/AppImage/snap/flatpak, msi/exe/appx, dmg/pkg), code signing & notarization, electron-builder-based auto-update, and GraalVM Native Image builds. Plugin id: com.seanproctor.potassium. - Docs: https://github.com/sproctor/potassium-packager - GitHub: https://github.com/sproctor/potassium-packager - Maven Central: https://central.sonatype.com/artifact/com.seanproctor/potassium-packager - License: MIT **Potassium is a Gradle plugin for packaging and distributing Compose / JVM desktop applications** on macOS, Windows, and Linux. It is a drop-in extension of the official JetBrains Compose Desktop plugin — keep your existing `compose.desktop` configuration and opt into the capabilities you need. Potassium picks up where jpackage stops: more installer formats, real code signing and notarization, built-in auto-update, and GraalVM Native Image builds, all from a single Gradle DSL. ## What it does ### Ship everywhere - **Many installer formats** — Linux `deb` / `rpm` / `AppImage` / `snap` / `flatpak`, Windows `msi` / `exe` (NSIS) / `appx` / portable, macOS `dmg` / `pkg`, plus archives (`zip`, `tar`, `7z`) - **Store-ready** — Mac App Store, Microsoft Store, Snapcraft, Flathub - **Code signing & notarization** — Windows (PFX / Azure) and macOS, built into the build pipeline - **Auto-update** — electron-builder-compatible update metadata (`latest-*.yml`) generated alongside your installers, with SHA-512 verification - **Deep links & file associations** — protocol handlers and file type registration on all platforms ### Go further - **GraalVM Native Image** — compile your app into a standalone native binary. Potassium resolves the reachability metadata transparently, so most apps build with zero manual reflection config. - **AOT cache** — enable the JDK 25+ ahead-of-time cache for faster startup with a single flag. - **ProGuard release builds** — optimization and obfuscation for production. - **Trusted CA certificates** — import custom root CAs into the bundled JVM's `cacerts` at build time. - **CI/CD ready** — reusable GitHub Actions for multi-platform matrix builds, universal macOS binaries, and MSIX bundles. ## Quick start ```kotlin // settings.gradle.kts pluginManagement { repositories { gradlePluginPortal() mavenCentral() } } ``` ```kotlin // build.gradle.kts plugins { kotlin("jvm") version "..." id("org.jetbrains.kotlin.plugin.compose") version "..." id("org.jetbrains.compose") version "..." id("com.seanproctor.potassium") version "0.1.0" } potassium { mainClass = "com.example.MainKt" packageName = "MyApp" packageVersion = "1.0.0" macOS { targetFormats(MacOSTargetFormat.Dmg) } windows { targetFormats(WindowsTargetFormat.Nsis) } linux { targetFormats(LinuxTargetFormat.Deb) } } ``` ```bash ./gradlew run # Run locally ./gradlew packageDistributionForCurrentOS # Build installers for the current OS ``` **Kotlin DSL imports:** The Kotlin DSL types live under `com.seanproctor.potassium.*` (for example `import com.seanproctor.potassium.dsl.MacOSTargetFormat`). ## Coordinates - **Plugin id:** `com.seanproctor.potassium` - **Latest version:** `0.1.0` - **Published to:** Maven Central - **Repository:** [github.com/sproctor/potassium-packager](https://github.com/sproctor/potassium-packager) ## Requirements | Requirement | Version | Note | |-------------|---------|------| | JDK | 17+ (25+ for AOT cache) | | | Gradle | 8.0+ | | | Kotlin | 2.0+ | | | Node.js | 18+ | Required by electron-builder for installer formats | ## Next steps - [Getting Started](getting-started.md) — install the plugin and build your first installer - [Configuration](configuration.md) — full DSL reference - [Migration from org.jetbrains.compose](migration.md) — switch an existing Compose Desktop project ## License MIT — See [LICENSE](https://github.com/sproctor/potassium-packager/blob/main/LICENSE). --- # Getting Started ## Prerequisites Potassium uses [electron-builder](https://www.electron.build/) under the hood to produce platform-specific installers (DMG, NSIS, DEB, RPM, AppImage, etc.). This requires **Node.js 18+** and **npm** installed on your build machine. ```bash # Verify your installation node --version # v18.0.0 or later npm --version ``` **CI/CD:** The `setup-potassium` composite action installs Node.js automatically. See [CI/CD](ci-cd.md) for details. ## Installation The plugin is published to Maven Central. Add Maven Central to your plugin repositories, then apply the plugin in your build script: ```kotlin // settings.gradle.kts pluginManagement { repositories { gradlePluginPortal() mavenCentral() } } ``` ```kotlin // build.gradle.kts plugins { kotlin("jvm") version "..." id("org.jetbrains.kotlin.plugin.compose") version "..." id("org.jetbrains.compose") version "..." id("com.seanproctor.potassium") version "0.1.0" } ``` **Kotlin DSL imports:** The Kotlin DSL types live under `com.seanproctor.potassium.*` (for example `import com.seanproctor.potassium.dsl.MacOSTargetFormat`). ## Minimal Configuration ```kotlin potassium { mainClass = "com.example.MainKt" packageName = "MyApp" packageVersion = "1.0.0" macOS { targetFormats(MacOSTargetFormat.Dmg) } windows { targetFormats(WindowsTargetFormat.Nsis) } linux { targetFormats(LinuxTargetFormat.Deb) } } ``` ## Gradle Tasks ### Development | Task | Description | |------|-------------| | `run` | Run the application from the IDE/terminal | | `runDistributable` | Run the packaged application image | #### Compose Hot Reload Potassium is fully compatible with [Compose Hot Reload](https://kotlinlang.org/docs/multiplatform/compose-hot-reload.html). Since Potassium extends the Compose plugin (not replaces it), Hot Reload works out of the box. The `hotRun` task reads `mainClass` from the `compose.desktop.application` block. If you only set it in `potassium`, add a minimal Compose block: ```kotlin compose.desktop.application { mainClass = "com.example.MainKt" } ``` Or pass it via the command line: ```bash ./gradlew hotRun -PmainClass=com.example.MainKt ``` ### Packaging | Task | Description | |------|-------------| | `packageDistributionForCurrentOS` | Build all configured formats for the current OS | | `package` | Build all the current OS's non-store formats in one invocation (`packageMacOS` / `packageWindows` / `packageLinux`) | | `packagePkg` / `packageAppX` / `packageFlatpak` | Build a store format (each built separately) | | `packageReleaseDistributionForCurrentOS` | Same as above with ProGuard release build | | `createDistributable` | Create the application image without an installer | | `createReleaseDistributable` | Same with ProGuard | ### Utility | Task | Description | |------|-------------| | `suggestModules` | Suggest JDK modules required by your dependencies | | `packageUberJarForCurrentOS` | Create a single fat JAR with all dependencies | ### Running a Specific Task ```bash # Build all configured macOS formats (e.g. DMG + ZIP) in one invocation ./gradlew packageMacOS # Build all configured Windows formats (e.g. NSIS + MSI) in one invocation ./gradlew packageWindows # Build all configured Linux formats (e.g. DEB + RPM + AppImage) in one invocation ./gradlew packageLinux # Build all formats for current OS (incl. store formats) ./gradlew packageDistributionForCurrentOS # Release build (with ProGuard) ./gradlew packageReleaseDistributionForCurrentOS ``` ## Output Location Build artifacts are generated in: ``` build/potassium/binaries/main// build/potassium/binaries/main-release// # Release builds ``` Override with: ```kotlin potassium { outputBaseDir.set(project.layout.buildDirectory.dir("custom-output")) } ``` ## JDK Modules The plugin does not automatically detect required JDK modules. Use `suggestModules` to identify them: ```bash ./gradlew suggestModules ``` Then declare them in the DSL: ```kotlin potassium { modules("java.sql", "java.net.http", "jdk.accessibility") } ``` Or include everything (larger binary): ```kotlin potassium { includeAllModules = true } ``` ## Application Icons Provide platform-specific icon files: ```kotlin potassium { macOS { iconFile.set(project.file("icons/app.icns")) } windows { iconFile.set(project.file("icons/app.ico")) } linux { iconFile.set(project.file("icons/app.png")) } } ``` | Platform | Format | Recommended Size | |----------|--------|------------------| | macOS | `.icns` | 1024x1024 | | Windows | `.ico` | 256x256 | | Linux | `.png` | 512x512 | ## Application Resources Include extra files in the installation directory via `appResourcesRootDir`: ```kotlin potassium { appResourcesRootDir.set(project.layout.projectDirectory.dir("resources")) } ``` Resource directory structure: ``` resources/ common/ # Included on all platforms macos/ # macOS only macos-arm64/ # macOS Apple Silicon only macos-x64/ # macOS Intel only windows/ # Windows only linux/ # Linux only ``` Access at runtime: ```kotlin val resourcesDir = File(System.getProperty("compose.application.resources.dir")) ``` ## Next Steps - [Configuration](configuration.md) — Full DSL reference - [macOS](targets/macos.md) / [Windows](targets/windows.md) / [Linux](targets/linux.md) — Platform-specific options - [CI/CD](ci-cd.md) — Automate builds with GitHub Actions --- # Configuration All Potassium configuration lives inside the `potassium { }` block in your `build.gradle.kts`. ## Overview ```kotlin potassium { mainClass = "com.example.MainKt" jvmArgs += listOf("-Xmx512m") buildTypes { release { proguard { isEnabled = true optimize = true obfuscate.set(false) configurationFiles.from(project.file("proguard-rules.pro")) } } } // Package metadata appName = "My App" // Display name (installer, .desktop, Start Menu) packageName = "MyApp" // Technical name (executable, package file) packageVersion = "1.0.0" description = "My awesome desktop app" vendor = "My Company" copyright = "Copyright 2025 My Company" homepage = "https://myapp.example.com" licenseFile.set(project.file("LICENSE")) // JDK modules modules("java.sql", "java.net.http") // Potassium features cleanupNativeLibs = true enableAotCache = true splashImage = "splash.png" compressionLevel = CompressionLevel.Maximum artifactName = "${name}-${version}-${os}-${arch}.${ext}" // Deep links & file associations protocol("MyApp", "myapp") fileAssociation( mimeType = "application/x-myapp", extension = "myapp", description = "MyApp Document", ) // Publishing publish { /* ... */ } // Platform-specific — target formats are grouped per OS here macOS { targetFormats(MacOSTargetFormat.Dmg) /* ... */ } windows { targetFormats(WindowsTargetFormat.Nsis) /* ... */ } linux { targetFormats(LinuxTargetFormat.Deb) /* ... */ } } ``` ## Target Formats Formats are grouped per OS and declared inside the matching platform block. All of a platform's non-store formats build in a **single** electron-builder invocation (`packageMacOS` / `packageWindows` / `packageLinux`); store formats (PKG, AppX, Flatpak) build separately. ```kotlin macOS { targetFormats(MacOSTargetFormat.Dmg, MacOSTargetFormat.Pkg) } windows { targetFormats(WindowsTargetFormat.Nsis, WindowsTargetFormat.Msi) } linux { targetFormats(LinuxTargetFormat.Deb, LinuxTargetFormat.Rpm, LinuxTargetFormat.AppImage) } ``` | OS | Format | Built by | Notes | |----|--------|----------|-------| | macOS | `MacOSTargetFormat.Dmg` | `packageMacOS` | | | macOS | `MacOSTargetFormat.Pkg` | `packagePkg` | App Store — built separately | | Windows | `WindowsTargetFormat.Nsis` | `packageWindows` | NSIS installer (`.exe`) | | Windows | `WindowsTargetFormat.Exe` | `packageWindows` | Alias for `Nsis` — same output | | Windows | `WindowsTargetFormat.NsisWeb` | `packageWindows` | NSIS web installer | | Windows | `WindowsTargetFormat.Msi` | `packageWindows` | | | Windows | `WindowsTargetFormat.Portable` | `packageWindows` | | | Windows | `WindowsTargetFormat.AppX` | `packageAppX` | MSIX — built separately | | Linux | `LinuxTargetFormat.Deb` | `packageLinux` | | | Linux | `LinuxTargetFormat.Rpm` | `packageLinux` | | | Linux | `LinuxTargetFormat.AppImage` | `packageLinux` | | | Linux | `LinuxTargetFormat.Snap` | `packageLinux` | | | Linux | `LinuxTargetFormat.Flatpak` | `packageFlatpak` | Built separately | | all | `Zip` / `Tar` / `SevenZ` | `package` | Present in each OS enum (e.g. `MacOSTargetFormat.Zip`) | **One invocation per platform:** Configuring several formats for an OS (e.g. `Deb`, `Rpm`, `AppImage`) makes `packageLinux` build them all in one electron-builder run, rather than one run per format. Target all of an OS's formats at once: ```kotlin linux { targetFormats(*LinuxTargetFormat.entries.toTypedArray()) } ``` ## Package Metadata | Property | Type | Default | Description | |----------|------|---------|-------------| | `appName` | `String?` | `null` | Human-readable display name (installer title, `.desktop` Name, Start Menu). Falls back to `packageName` if not set. | | `packageName` | `String` | Gradle project name | Technical package/executable name. On Linux this should be lowercase (e.g. `zayit`). | | `packageVersion` | `String` | Gradle project version | Application version | | `description` | `String?` | `null` | Short application description | | `vendor` | `String?` | `null` | Publisher / company name | | `copyright` | `String?` | `null` | Copyright notice | | `homepage` | `String?` | `null` | Application homepage URL. **Required** for Linux DEB packaging (electron-builder enforces this). | | `licenseFile` | `RegularFileProperty` | — | Path to the license file | | `appResourcesRootDir` | `DirectoryProperty` | — | Root directory for bundled resources | | `outputBaseDir` | `DirectoryProperty` | `build/potassium/binaries` | Output directory for built packages | ### Version Set a single `packageVersion` (SemVer — it may include a pre-release suffix such as `1.2.3-beta.1`). There are no per-OS or per-format version overrides: each platform has different version rules, so Potassium and electron-builder format the one version per target automatically. | Target | Handling | `1.2.3-beta.1` → | |--------|----------|------------------| | Windows (NSIS/MSI) | pre-release suffix stripped to numeric `MAJOR.MINOR.PATCH` | `1.2.3` | | macOS (DMG/PKG) | pre-release suffix stripped for the app version | `1.2.3` | | Linux (DEB/RPM) | `-` replaced with `~` (sorts before the final release) | `1.2.3~beta.1` | | AppImage | full version preserved | `1.2.3-beta.1` | The pre-release identifier (`beta`, `alpha`, …) also selects the auto-update channel — see [Auto-Update](auto-update.md). ## Potassium-Specific Features ### Native Library Cleanup Strips `.dll`, `.so`, `.dylib` files for non-target platforms from dependency JARs, reducing package size significantly. ```kotlin potassium { cleanupNativeLibs = true } ``` ### AOT Cache (JDK 25+) Generates an ahead-of-time compilation cache for faster startup: ```kotlin potassium { enableAotCache = true } ``` Requires JDK 25+ and that the application self-terminates during the training run. ### Splash Screen Displays a splash screen during application startup: ```kotlin potassium { splashImage = "splash.png" // Relative to appResources } ``` ### Artifact Naming Customize output filenames with template variables: ```kotlin potassium { artifactName = "${name}-${version}-${os}-${arch}.${ext}" } ``` | Variable | Description | Example | |----------|-------------|---------| | `${name}` | Package name | `MyApp` | | `${version}` | Package version | `1.0.0` | | `${os}` | Operating system | `macos`, `windows`, `linux` | | `${arch}` | Architecture | `amd64`, `arm64` | | `${ext}` | File extension | `dmg`, `exe`, `deb` | ### Compression Level Controls compression for electron-builder formats: ```kotlin potassium { compressionLevel = CompressionLevel.Maximum } ``` | Level | Description | |-------|-------------| | `CompressionLevel.Store` | No compression | | `CompressionLevel.Normal` | Balanced (default) | | `CompressionLevel.Maximum` | Best compression (recommended for most formats) | **AppImage and Maximum Compression:** Using `CompressionLevel.Maximum` with AppImage causes extremely slow startup times (60s+) due to squashfs/FUSE decompression overhead. For AppImage targets, use `Normal` or `Store` instead. Other formats (DMG, NSIS, DEB, RPM, etc.) are not affected. See [electron-builder#7483](https://github.com/electron-userland/electron-builder/issues/7483) for details. ### Trusted CA Certificates Import custom CA certificates into the bundled JVM's `cacerts` keystore at build time. Useful for corporate proxies, VPN gateways, or filtering services that use a private root CA. ```kotlin potassium { trustedCertificates.from(files( "certs/company-proxy-ca.pem", )) } ``` Both PEM and DER formats are accepted. See [Trusted CA Certificates](trusted-certificates.md) for full details. ### Deep Links (Protocol Handler) Register a custom URL protocol across all platforms: ```kotlin potassium { protocol("MyApp", "myapp") // Registers myapp:// protocol } ``` - **macOS**: `CFBundleURLTypes` in `Info.plist` - **Windows**: Registry entries via NSIS/MSI - **Linux**: `MimeType` in `.desktop` file This registers the protocol at the OS level; handling the incoming deep link at runtime is up to your application. ### File Associations Register file type associations: ```kotlin potassium { fileAssociation( mimeType = "application/x-myapp", extension = "myapp", description = "MyApp Document", ) } ``` The cross-platform `fileAssociation()` method also accepts per-platform icon files: ```kotlin fileAssociation( mimeType = "application/x-myapp", extension = "myapp", description = "MyApp Document", linuxIconFile = project.file("icons/file.png"), windowsIconFile = project.file("icons/file.ico"), macOSIconFile = project.file("icons/file.icns"), ) ``` Multiple associations are supported by calling `fileAssociation()` multiple times. ## ProGuard (Release Builds) ```kotlin buildTypes { release { proguard { version = "7.8.1" isEnabled = true optimize = true obfuscate.set(false) // Disabled by default joinOutputJars.set(true) configurationFiles.from(project.file("proguard-rules.pro")) } } } ``` Release build tasks are suffixed with `Release`: ```bash ./gradlew packageReleaseMacOS # or packageReleaseWindows / packageReleaseLinux ./gradlew packageReleaseDistributionForCurrentOS ``` ## Full DSL Tree ``` potassium { mainClass jvmArgs buildTypes { release { proguard { isEnabled, version, optimize, obfuscate, joinOutputJars, configurationFiles } } } appName, packageName, packageVersion, description, vendor, copyright, homepage licenseFile, appResourcesRootDir, outputBaseDir modules(...), includeAllModules cleanupNativeLibs, enableAotCache, splashImage compressionLevel, artifactName protocol(name, vararg schemes) fileAssociation(mimeType, extension, description, linuxIconFile?, windowsIconFile?, macOSIconFile?) publish { github , s3 , generic } macOS { targetFormats(...), iconFile, bundleID, dockName, appCategory, layeredIconDir, signing , notarization , dmg , infoPlist } windows { targetFormats(...), iconFile, upgradeUuid, signing , nsis , appx } linux { targetFormats(...), iconFile, debMaintainer, debDepends, rpmRequires, appImage , snap , flatpak } } ``` ## Next Steps - Platform-specific configuration: [macOS](targets/macos.md) · [Windows](targets/windows.md) · [Linux](targets/linux.md) - [Code Signing](code-signing.md) — Sign and notarize your app - [Publishing](publishing.md) — Distribute via GitHub Releases, S3, or generic HTTP server --- # macOS Targets Potassium supports two macOS installer formats and universal (fat) binaries. ## Formats | Format | Extension | Auto-Update | Sandboxed | |--------|-----------|-------------|-----------| | DMG | `.dmg` | Yes | No | | PKG | `.pkg` | Yes | Yes (App Sandbox) | ```kotlin macOS { targetFormats(MacOSTargetFormat.Dmg, MacOSTargetFormat.Pkg) } ``` ## General macOS Settings ```kotlin potassium { macOS { // Bundle identifier (reverse DNS notation) bundleID = "com.example.myapp" // Dock display name dockName = "MyApp" // App Store category appCategory = "public.app-category.utilities" // Minimum macOS version minimumSystemVersion = "12.0" // Traditional icon iconFile.set(project.file("icons/app.icns")) // Layered icon for macOS 26+ (dynamic tilt/depth effects) layeredIconDir.set(project.file("icons/MyApp.icon")) // Entitlements entitlementsFile.set(project.file("entitlements.plist")) runtimeEntitlementsFile.set(project.file("runtime-entitlements.plist")) // Custom Info.plist entries (raw XML appended to Info.plist) infoPlist { extraKeysRawXml = """ NSMicrophoneUsageDescription This app requires microphone access. """.trimIndent() } } } ``` ## DMG Customization ### Window Appearance Control the DMG window title, icon sizes, position, and dimensions: ```kotlin macOS { dmg { title = "${productName} ${version}" iconSize = 128 iconTextSize = 12 window { x = 400 y = 100 width = 540 height = 380 } } } ``` ### Background Set a background image or a solid color for the DMG window: ```kotlin dmg { background.set(project.file("packaging/dmg-background.png")) // or use a solid color instead: // backgroundColor = "#FFFFFF" } ``` ### Format and Badge Icon Choose a DMG format and optionally overlay a badge icon on the volume icon: ```kotlin dmg { format = DmgFormat.UDZO // UDRW, UDRO, UDCO, UDZO, UDBZ, ULFO badgeIcon.set(project.file("icons/badge.icns")) } ``` ### Content Positioning Use `content()` to place icons at specific coordinates inside the DMG window. The typical pattern is one entry for the app and one entry for an Applications symlink so the user can drag-and-drop to install: ```kotlin dmg { content(x = 130, y = 220, type = DmgContentType.File, name = "MyApp.app") content(x = 410, y = 220, type = DmgContentType.Link, path = "/Applications") } ``` Each `content()` call adds an entry with an `(x, y)` position and a `DmgContentType`: | Type | Description | |------|-------------| | `DmgContentType.File` | An existing file in the DMG (e.g. the `.app` bundle). Set `name` to match the file. | | `DmgContentType.Link` | A symlink. Set `path` to the link target (usually `/Applications`). | | `DmgContentType.Dir` | A directory inside the DMG. | **Mapping from `create-dmg`:** If you are migrating from a `create-dmg` shell script, the `content()` DSL maps directly to the `--icon` and `--app-drop-link` flags: | `create-dmg` flag | Potassium equivalent | |---|---| | `--icon "MyApp.app" 130 220` | `content(x = 130, y = 220, type = DmgContentType.File, name = "MyApp.app")` | | `--app-drop-link 410 220` | `content(x = 410, y = 220, type = DmgContentType.Link, path = "/Applications")` | ## Layered Icons (macOS 26+) macOS 26 introduced [layered icons](https://developer.apple.com/design/human-interface-guidelines/app-icons#macOS) that support dynamic tilt and depth effects on the Dock and Spotlight. ```kotlin macOS { // Traditional icon (fallback for older macOS) iconFile.set(project.file("icons/app.icns")) // Layered icon for macOS 26+ layeredIconDir.set(project.file("icons/MyApp.icon")) } ``` ### Creating a `.icon` directory A `.icon` directory contains an `icon.json` manifest and image assets: ``` MyApp.icon/ icon.json Assets/ FrontImage.png BackImage.png ``` Create one using **Xcode 26+** or **Apple Icon Composer**: 1. Open Xcode, create or open an Asset Catalog 2. Add a new App Icon asset 3. Configure layers (front, back) 4. Export the `.icon` directory **Requirements:** - Xcode Command Line Tools with `actool` 26.0+ - Only effective on macOS build hosts - If `actool` is missing, a warning is logged and the build continues without layered icons ## macOS 26 Window Appearance (Liquid Glass) macOS 26 introduces a refreshed window chrome with **Liquid Glass**: larger traffic light buttons and more rounded window corners. These visual changes are applied automatically by AppKit — but only if the application binary's `LC_BUILD_VERSION` Mach-O header declares macOS SDK 26.0. ### Automatic SDK patching Potassium **automatically patches** the app launcher's `LC_BUILD_VERSION` via `vtool` so that AppKit enables Liquid Glass. This works with **any JDK** — a JDK compiled with Xcode 26 is no longer required. The patching is controlled by the `macOsSdkVersion` DSL property (defaults to `"26.0"`): ```kotlin potassium { macOS { macOsSdkVersion = "26.0" // default — enables Liquid Glass // macOsSdkVersion = null // disable SDK version patching } } ``` **What gets patched:** | Task | How | |------|-----| | `createDistributable` / `createSandboxedDistributable` | Launcher patched in the app bundle before signing | | `packageMacOS` / `packagePkg` | Derived from the patched distributable | | `runDistributable` | Runs the patched app bundle | | `run` | Uses a cached patched copy of the JVM binary | **Requirements:** - **Xcode Command Line Tools** must be installed (`vtool` and `codesign` must be available at `/usr/bin/`). If missing, a warning is logged and patching is skipped. - Only effective on macOS; ignored on other platforms. **How it works:** `vtool` modifies the `LC_BUILD_VERSION` load command in the Mach-O binary, setting the SDK version to 26.0. This is the same header that the linker writes when you compile with `-sdk_version 26.0`. The modification only affects metadata — no code is changed. For distributable builds, the launcher is patched before signing, so the code signature covers the patched binary. For the `run` task, a patched copy of the JVM is produced by the `potassiumPatchMacJvm` task into `build/potassium/patched-jvm/` and reused across runs. JavaExec forks the patched binary directly, so IntelliJ's debugger attaches normally (breakpoints, stop button) with Liquid Glass active. ### GraalVM Native Image For applications compiled with GraalVM Native Image, the native binary is linked directly by the system toolchain. Select **Xcode 26** before building: ```yaml - name: Select Xcode 26 if: runner.os == 'macOS' run: sudo xcode-select -s /Applications/Xcode_26.0.app/Contents/Developer - name: Build GraalVM native image run: ./gradlew :myapp:packageGraalvmNative --no-daemon ``` No custom JDK is needed at runtime since the output is a standalone native binary. Xcode 26 at **build time** is sufficient. The `macOsSdkVersion` patching does not apply to GraalVM native images — they get the SDK version from the linker directly. ## Universal Binaries Potassium supports creating universal (fat) macOS binaries that run natively on both Apple Silicon and Intel. This requires building on both architectures and merging with `lipo`. See [CI/CD](../ci-cd.md#universal-macos-binaries) for the GitHub Actions workflow. ## App Sandbox (PKG) PKG targets automatically use the sandboxed build pipeline. The plugin extracts native libraries from JARs, signs them individually, and injects JVM arguments so all native code loads from signed, pre-extracted locations. Default sandbox entitlements grant network access and user-selected file access. Override them for additional capabilities: ```kotlin macOS { entitlementsFile.set(project.file("packaging/sandbox-entitlements.plist")) runtimeEntitlementsFile.set(project.file("packaging/sandbox-runtime-entitlements.plist")) } ``` For Mac App Store builds (PKG), add provisioning profiles: ```kotlin macOS { provisioningProfile.set(project.file("packaging/MyApp.provisionprofile")) runtimeProvisioningProfile.set(project.file("packaging/MyApp_Runtime.provisionprofile")) } ``` **NOTE:** PKG is always treated as an App Store format. Sandbox entitlements, "3rd Party Mac Developer" certificates, and `productsign` signing are applied automatically — no `appStore` flag needed. See [Sandboxing](../sandboxing.md#macos-app-sandbox) for full details. ## Signing & Notarization See [Code Signing](../code-signing.md#macos) for full details. ```kotlin macOS { signing { sign.set(true) identity.set("Developer ID Application: My Company (TEAMID)") keychain.set("/path/to/keychain.keychain-db") } notarization { appleID.set("dev@example.com") password.set(System.getenv("MAC_NOTARIZATION_PASSWORD")) teamID.set("TEAMID") } } ``` ## Installation Path The `installationPath` property controls where the application is installed on disk. It defaults to `/Applications`. - **PKG installers** — passed as the `installLocation` to electron-builder and to `productbuild` for App Store builds. When the user chooses the local system domain during installation, the app is placed in `installationPath` (e.g. `/Applications`). When a home directory installation is chosen, the app is placed in `$HOME/Applications` instead. - **DMG** — used as the symlink target in the native DMG builder, so the drag-and-drop arrow points to the correct directory. ```kotlin macOS { // Default — installs into /Applications installationPath = "/Applications" // Custom — installs into /Applications/MyCompany installationPath = "/Applications/MyCompany" } ``` **NOTE:** This property is macOS-only. Windows and Linux installers do not use it. ## Full macOS DSL Reference ### `macOS { }` | Property | Type | Default | Description | |----------|------|---------|-------------| | `iconFile` | `RegularFileProperty` | — | `.icns` icon file | | `bundleID` | `String?` | `null` | macOS bundle identifier | | `dockName` | `String?` | `null` | Name displayed in the Dock | | `setDockNameSameAsPackageName` | `Boolean` | `true` | Use `packageName` as dock name | | `appCategory` | `String?` | `null` | App Store / Finder category | | `appStore` | `Boolean` | `false` | **Deprecated** — PKG is always built for the App Store. This property is ignored. | | `minimumSystemVersion` | `String?` | `null` | Minimum macOS version | | `layeredIconDir` | `DirectoryProperty` | — | `.icon` directory for macOS 26+ | | `packageName` | `String?` | `null` | Override package name | | `entitlementsFile` | `RegularFileProperty` | — | Entitlements plist | | `runtimeEntitlementsFile` | `RegularFileProperty` | — | Runtime entitlements plist | | `provisioningProfile` | `RegularFileProperty` | — | Provisioning profile | | `runtimeProvisioningProfile` | `RegularFileProperty` | — | Runtime provisioning profile | | `installationPath` | `String?` | `/Applications` | The install location used by PKG installers and as the DMG symlink target (see [below](#installation-path)) | ### `macOS { signing { } }` | Property | Type | Default | Description | |----------|------|---------|-------------| | `sign` | `Property` | `false` | Enable code signing | | `identity` | `Property` | — | Signing identity | | `keychain` | `Property` | — | Keychain path | | `prefix` | `Property` | — | Signing prefix | ### `macOS { notarization { } }` | Property | Type | Default | Description | |----------|------|---------|-------------| | `appleID` | `Property` | — | Apple ID email | | `password` | `Property` | — | App-specific password | | `teamID` | `Property` | — | Developer Team ID | ### `macOS { dmg { } }` | Property | Type | Default | Description | |----------|------|---------|-------------| | `title` | `String?` | `null` | DMG window title | | `iconSize` | `Int?` | `null` | Icon size in DMG window | | `iconTextSize` | `Int?` | `null` | Icon text size | | `format` | `DmgFormat?` | `null` | DMG format enum (`UDZO`, `UDBZ`, etc.) | | `size` | `String?` | `null` | DMG size | | `shrink` | `Boolean?` | `null` | Shrink DMG | | `sign` | `Boolean` | `false` | Sign the DMG | | `background` | `RegularFileProperty` | — | Background image | | `backgroundColor` | `String?` | `null` | Background color (hex) | | `icon` | `RegularFileProperty` | — | DMG volume icon | | `badgeIcon` | `RegularFileProperty` | — | Badge overlay icon | #### `DmgFormat` Enum `UDRW` (read/write), `UDRO` (read-only), `UDCO` (ADC compressed), `UDZO` (zlib compressed), `UDBZ` (bzip2), `ULFO` (lzfse) #### `dmg { window { } }` | Property | Type | Default | Description | |----------|------|---------|-------------| | `x` | `Int?` | `null` | Window x position on screen | | `y` | `Int?` | `null` | Window y position on screen | | `width` | `Int?` | `null` | Window width | | `height` | `Int?` | `null` | Window height | #### `dmg { content() }` Adds an icon entry to the DMG window layout. Call multiple times to position several items. ```kotlin fun content( x: Int, y: Int, type: DmgContentType? = null, name: String? = null, path: String? = null, ) ``` | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `x` | `Int` | Yes | Horizontal position inside the DMG window | | `y` | `Int` | Yes | Vertical position inside the DMG window | | `type` | `DmgContentType?` | No | Kind of content entry (`File`, `Link`, or `Dir`) | | `name` | `String?` | No | File name to match (used with `File` / `Dir`) | | `path` | `String?` | No | Target path (used with `Link`, e.g. `/Applications`) | #### `DmgContentType` Enum | Value | Serialized ID | Description | |-------|---------------|-------------| | `Link` | `link` | A symlink to a target path | | `File` | `file` | An existing file in the DMG | | `Dir` | `dir` | A directory in the DMG | --- # Windows Targets Potassium supports five Windows installer formats and a portable mode. ## Formats | Format | Extension | Auto-Update | Sandboxed | |--------|-----------|-------------|-----------| | NSIS | `.exe` | Yes | No | | NSIS Web | `.exe` | Yes | No | | MSI | `.msi` | Yes | No | | AppX | `.appx` | No (Store) | No | | Portable | `.exe` | No | No | ```kotlin windows { targetFormats( WindowsTargetFormat.Nsis, WindowsTargetFormat.Msi, WindowsTargetFormat.AppX, WindowsTargetFormat.Portable, ) } ``` ## General Windows Settings ```kotlin potassium { windows { iconFile.set(project.file("icons/app.ico")) // Upgrade UUID — used by MSI for updates // Auto-generated if null, but should be fixed for production upgradeUuid = "d24e3b8d-3e9b-4cc7-a5d8-5e2d1f0c9f1b" // Console mode (shows terminal window) console = false // Per-user install (no admin required) perUserInstall = true // Start menu group menuGroup = "My Company" // Installation directory name dirChooser = true } } ``` ## NSIS Installer NSIS produces a traditional Windows installer (`.exe`) with full customization. ```kotlin windows { nsis { // Installer behavior oneClick = false // One-click install (no UI) allowElevation = true // Request admin rights perMachine = true // Install for all users allowToChangeInstallationDirectory = true // Let user pick directory // Shortcuts createDesktopShortcut = true createStartMenuShortcut = true // Post-install runAfterFinish = true deleteAppDataOnUninstall = false // Multi-language multiLanguageInstaller = true installerLanguages = listOf("en_US", "fr_FR", "de_DE", "es_ES", "ja_JP", "zh_CN") // Custom icons installerIcon.set(project.file("packaging/installer.ico")) uninstallerIcon.set(project.file("packaging/uninstaller.ico")) // Custom NSIS header/sidebar images installerHeader.set(project.file("packaging/header.bmp")) installerSidebar.set(project.file("packaging/sidebar.bmp")) // Custom NSIS script includeScript.set(project.file("packaging/custom.nsi")) // or full custom script: // script.set(project.file("packaging/installer.nsi")) // License agreement license.set(project.file("LICENSE")) } } ``` ### NSIS DSL Reference | Property | Type | Default | Description | |----------|------|---------|-------------| | `oneClick` | `Boolean` | `true` | Silent one-click install | | `allowElevation` | `Boolean` | `false` | Request admin privileges | | `perMachine` | `Boolean` | `false` | Install for all users | | `allowToChangeInstallationDirectory` | `Boolean` | `false` | Show directory chooser | | `createDesktopShortcut` | `Boolean` | `true` | Create desktop shortcut | | `createStartMenuShortcut` | `Boolean` | `true` | Create Start Menu shortcut | | `runAfterFinish` | `Boolean` | `true` | Launch app after install | | `deleteAppDataOnUninstall` | `Boolean` | `false` | Remove app data on uninstall | | `multiLanguageInstaller` | `Boolean` | `false` | Multi-language installer UI | | `installerLanguages` | `List` | `[]` | NSIS language identifiers | | `installerIcon` | `RegularFileProperty` | — | Installer `.ico` | | `uninstallerIcon` | `RegularFileProperty` | — | Uninstaller `.ico` | | `installerHeader` | `RegularFileProperty` | — | Header bitmap | | `installerSidebar` | `RegularFileProperty` | — | Sidebar bitmap | | `license` | `RegularFileProperty` | — | License file shown during install | | `includeScript` | `RegularFileProperty` | — | Extra NSIS script to include | | `script` | `RegularFileProperty` | — | Full custom NSIS script | ### Custom Default Installation Directory By default, the NSIS installer installs to: - **Per-user** (`perMachine = false`): `%LOCALAPPDATA%\Programs\{AppName}` - **Per-machine** (`perMachine = true`): `%PROGRAMFILES%\{AppName}` To let users choose the installation directory, set `allowToChangeInstallationDirectory = true` (requires `oneClick = false`): ```kotlin nsis { oneClick = false allowElevation = true allowToChangeInstallationDirectory = true } ``` To also override the **default path** shown in the directory chooser, create a custom NSIS include script using the `preInit` macro. **1. Create** `packaging/nsis/installer.nsh`: ```nsis !macro preInit SetRegView 64 WriteRegExpandStr HKLM "${INSTALL_REGISTRY_KEY}" InstallLocation "C:\MyCompany\MyApp" WriteRegExpandStr HKCU "${INSTALL_REGISTRY_KEY}" InstallLocation "C:\MyCompany\MyApp" SetRegView 32 WriteRegExpandStr HKLM "${INSTALL_REGISTRY_KEY}" InstallLocation "C:\MyCompany\MyApp" WriteRegExpandStr HKCU "${INSTALL_REGISTRY_KEY}" InstallLocation "C:\MyCompany\MyApp" !macroend ``` **2. Reference it** in the DSL: ```kotlin nsis { oneClick = false allowElevation = true allowToChangeInstallationDirectory = true includeScript.set(project.file("packaging/nsis/installer.nsh")) } ``` **Registry hive matters:** - If `perMachine = true`, the installer reads from `HKLM`. Write to **both** `HKLM` and `HKCU` for safety. - If `perMachine = false`, only `HKCU` is checked. Writing to `HKLM` alone will have no effect. - Always write to both 32-bit and 64-bit registry views (`SetRegView 64` / `SetRegView 32`). **References:** - [electron-builder NSIS configuration](https://www.electron.build/nsis.html) - [Change default installation directory value (electron-builder#2855)](https://github.com/electron-userland/electron-builder/issues/2855) - [Change $INSTDIR to a custom path (electron-builder#1961)](https://github.com/electron-userland/electron-builder/issues/1961) ## AppX (Windows Store / MSIX) AppX packages use the MSIX format for the Microsoft Store and sideloading. Desktop Bridge apps run with full trust (`runFullTrust`), so they are **not sandboxed**. They use the [store build pipeline](../sandboxing.md#windows-appx) automatically. **Developer Mode required for local builds:** Building AppX/MSIX packages locally requires **Windows Developer Mode** to be enabled. Without it, the build will fail. Go to **Settings → System → For developers** and enable **Developer Mode**. This is not needed in CI (GitHub-hosted runners have it enabled by default). ```kotlin windows { appx { applicationId = "MyApp" publisherDisplayName = "My Company" displayName = "My App" publisher = "CN=D541E802-6D30-446A-864E-2E8ABD2DAA5E" identityName = "MyCompany.MyApp" // Languages languages = listOf("en-US", "fr-FR") // Visual backgroundColor = "#001F3F" showNameOnTiles = true // Tile logos (PNG) storeLogo.set(project.file("packaging/appx/StoreLogo.png")) square44x44Logo.set(project.file("packaging/appx/Square44x44Logo.png")) square150x150Logo.set(project.file("packaging/appx/Square150x150Logo.png")) wide310x150Logo.set(project.file("packaging/appx/Wide310x150Logo.png")) // Store build options addAutoLaunchExtension = false setBuildNumber = true } } ``` ### AppX Logo Requirements | Asset | Size | Description | |-------|------|-------------| | `StoreLogo.png` | 50x50 | Store listing icon | | `Square44x44Logo.png` | 44x44 | Taskbar icon | | `Square150x150Logo.png` | 150x150 | Start Menu tile | | `Wide310x150Logo.png` | 310x150 | Wide Start Menu tile | ### MSIX Bundle (Multi-Architecture) Create an `.msixbundle` containing both amd64 and arm64 `.appx` files. See [CI/CD](../ci-cd.md#windows-msix-bundle) for the GitHub Actions workflow. ## Code Signing See [Code Signing](../code-signing.md#windows) for full details on PFX certificates and Azure Artifact Signing. ```kotlin windows { signing { enabled = true certificateFile.set(file("certs/certificate.pfx")) certificatePassword = providers.environmentVariable("WIN_CSC_KEY_PASSWORD").orNull algorithm = SigningAlgorithm.Sha256 timestampServer = "http://timestamp.digicert.com" } } ``` ## File Associations ```kotlin potassium { // Cross-platform file association fileAssociation( mimeType = "application/x-myapp", extension = "myapp", description = "MyApp Document", ) // Windows-specific with icon windows { fileAssociation( mimeType = "application/x-myapp", extension = "myapp", description = "MyApp Document", iconFile = project.file("icons/file.ico"), ) } } ``` File associations are propagated to NSIS, NSIS Web, MSI, and AppX formats. --- # Linux Targets Potassium supports five Linux package formats. ## Formats | Format | Extension | Auto-Update | Sandboxed | |--------|-----------|-------------|-----------| | DEB | `.deb` | Yes | No | | RPM | `.rpm` | Yes | No | | AppImage | `.AppImage` | Yes | No | | Snap | `.snap` | No (Store) | No | | Flatpak | `.flatpak` | No | Yes | ```kotlin linux { targetFormats( LinuxTargetFormat.Deb, LinuxTargetFormat.Rpm, LinuxTargetFormat.AppImage, LinuxTargetFormat.Snap, LinuxTargetFormat.Flatpak, ) } ``` ## General Linux Settings ```kotlin potassium { linux { iconFile.set(project.file("icons/app.png")) // .desktop file settings shortcut = true packageName = "myapp" appRelease = "1" appCategory = "Utility" menuGroup = "Development" // StartupWMClass (helps window managers match the window) // Auto-derived from mainClass if null startupWMClass = "com-example-MyApp" } } ``` ## DEB Package ```kotlin linux { // Maintainer (required for DEB) debMaintainer = "Your Name " // Dependencies injected into the .deb control file debDepends = listOf("libfuse2", "libgtk-3-0", "libasound2") } ``` ## RPM Package ```kotlin linux { // Dependencies injected into the RPM spec rpmRequires = listOf("gtk3", "libX11", "alsa-lib") // RPM license tag rpmLicenseType = "MIT" } ``` ## AppImage [AppImage](https://appimage.org/) produces a single portable executable that runs on most Linux distributions without installation. ```kotlin linux { appImage { // XDG category category = AppImageCategory.Utility // Desktop entry fields genericName = "My Application" synopsis = "A short description of the app" // Extra .desktop entries (key=value pairs) desktopEntries = mapOf( "Keywords" to "editor;text;", ) } } ``` ### AppImage Categories `AudioVideo`, `Development`, `Education`, `Game`, `Graphics`, `Network`, `Office`, `Science`, `Settings`, `System`, `Utility` ### Compression AppImage startup time is heavily affected by the compression level. Using `CompressionLevel.Maximum` causes squashfs/FUSE decompression at every launch, resulting in startup times of **60 seconds or more**. | Compression Level | Startup Impact | Recommended | |---|---|---| | `Store` | Fastest startup, largest file | Testing | | `Normal` (default) | Good balance | **Production** | | `Maximum` | 60s+ startup, ~20% smaller | Not recommended | See [electron-builder#7483](https://github.com/electron-userland/electron-builder/issues/7483) for details. ### Build Requirements AppImage requires `fuse` (or `libfuse2`) on the build host. On Ubuntu: ```bash sudo apt-get install -y libfuse2 ``` ## Snap [Snap](https://snapcraft.io/) packages for the Snap Store. ```kotlin linux { snap { // Confinement level confinement = SnapConfinement.Strict // Strict, Classic, Devmode // Release grade grade = SnapGrade.Stable // Stable, Devel // Snap summary summary = "My awesome desktop app" // Base snap (Ubuntu core) base = "core22" // Plugs (permissions) plugs = listOf( SnapPlug.Desktop, SnapPlug.DesktopLegacy, SnapPlug.Home, SnapPlug.X11, SnapPlug.Wayland, SnapPlug.Network, SnapPlug.NetworkBind, SnapPlug.AudioPlayback, ) // Auto-start on login autoStart = false // Compression compression = SnapCompression.Xz // Xz, Lzo } } ``` **NOTE:** The default `plugs` list includes: `Desktop`, `DesktopLegacy`, `Home`, `X11`, `Wayland`, `Unity7`, `BrowserSupport`, `Network`, `Gsettings`, `AudioPlayback`, `Opengl`. ### Snap Plugs | Plug | `id` | Description | |------|------|-------------| | `SnapPlug.Desktop` | `desktop` | Desktop integration | | `SnapPlug.DesktopLegacy` | `desktop-legacy` | Legacy desktop integration | | `SnapPlug.Home` | `home` | Access to home directory | | `SnapPlug.X11` | `x11` | X11 display access | | `SnapPlug.Wayland` | `wayland` | Wayland display access | | `SnapPlug.Unity7` | `unity7` | Unity7 desktop integration | | `SnapPlug.BrowserSupport` | `browser-support` | Browser support | | `SnapPlug.Network` | `network` | Network access | | `SnapPlug.NetworkBind` | `network-bind` | Listen on network ports | | `SnapPlug.Gsettings` | `gsettings` | GSettings access | | `SnapPlug.AudioPlayback` | `audio-playback` | Audio playback | | `SnapPlug.AudioRecord` | `audio-record` | Audio recording | | `SnapPlug.Opengl` | `opengl` | OpenGL/GPU access | | `SnapPlug.RemovableMedia` | `removable-media` | Removable media access | | `SnapPlug.Cups` | `cups` | Printing | ### Build Requirements Snap requires `snapd` and `snapcraft` on the build host: ```bash sudo apt-get install -y snapd sudo snap install snapcraft --classic ``` ## Flatpak [Flatpak](https://flatpak.org/) packages with sandboxed runtime. Flatpak targets use the [sandboxed build pipeline](../sandboxing.md#flatpak-sandbox) automatically. ```kotlin linux { flatpak { // Freedesktop runtime runtime = "org.freedesktop.Platform" // Default runtimeVersion = "23.08" // Default sdk = "org.freedesktop.Sdk" // Default // Application branch branch = "master" // Default // Sandbox permissions (defaults: --share=ipc, --socket=x11, --socket=wayland, --socket=pulseaudio, --device=dri) finishArgs = listOf( "--share=ipc", "--socket=x11", "--socket=wayland", "--socket=pulseaudio", "--device=dri", "--filesystem=home", ) // License file license.set(project.file("LICENSE")) } } ``` ### Build Requirements Flatpak requires `flatpak` and `flatpak-builder` with the target runtime and SDK installed. If these tools are missing, the packaging task will skip gracefully with a clear message. **Using the `setup-potassium` GitHub Action** (recommended): ```yaml - uses: kdroidFilter/Nucleus/.github/actions/setup-potassium@main with: flatpak: 'true' ``` **Manual setup:** ```bash sudo apt-get install -y flatpak flatpak-builder flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo flatpak install -y flathub org.freedesktop.Platform//23.08 flatpak install -y flathub org.freedesktop.Sdk//23.08 ``` ## Deep Links & File Associations on Linux Protocol handlers and file associations declared at the top level are automatically injected into `.desktop` files as `MimeType` entries: ```kotlin potassium { protocol("MyApp", "myapp") fileAssociation(mimeType = "application/x-myapp", extension = "myapp", description = "MyApp Doc") } ``` This produces a `.desktop` file with: ```ini MimeType=x-scheme-handler/myapp;application/x-myapp; ``` No manual `desktopEntries` override is needed for MimeType. ## Full Linux DSL Reference ### `linux { }` | Property | Type | Default | Description | |----------|------|---------|-------------| | `iconFile` | `RegularFileProperty` | — | `.png` icon file | | `shortcut` | `Boolean` | `false` | Create `.desktop` file | | `packageName` | `String?` | `null` | Override package name | | `packageVersion` | `String?` | `null` | Override package version | | `startupWMClass` | `String?` | `null` | `StartupWMClass` in `.desktop` | | `appRelease` | `String?` | `null` | Application release number | | `appCategory` | `String?` | `null` | Application category | | `debMaintainer` | `String?` | `null` | DEB maintainer email | | `menuGroup` | `String?` | `null` | Menu group | | `rpmLicenseType` | `String?` | `null` | RPM license tag | | `debDepends` | `List` | `[]` | Extra DEB dependencies | | `rpmRequires` | `List` | `[]` | Extra RPM dependencies | ### `linux { appImage { } }` | Property | Type | Default | Description | |----------|------|---------|-------------| | `category` | `AppImageCategory?` | `null` | XDG application category | | `genericName` | `String?` | `null` | Generic application name | | `synopsis` | `String?` | `null` | Short description | | `desktopEntries` | `Map` | `{}` | Extra `.desktop` key-value pairs | ### `linux { snap { } }` | Property | Type | Default | Description | |----------|------|---------|-------------| | `confinement` | `SnapConfinement` | `Strict` | Confinement level | | `grade` | `SnapGrade` | `Stable` | Release grade | | `summary` | `String?` | `null` | Snap summary | | `base` | `String?` | `null` | Base snap (e.g., `core22`) | | `plugs` | `List` | 11 defaults | Snap plugs (permissions) | | `autoStart` | `Boolean` | `false` | Auto-start on login | | `compression` | `SnapCompression?` | `null` | Compression method | ### `linux { flatpak { } }` | Property | Type | Default | Description | |----------|------|---------|-------------| | `runtime` | `String` | `"org.freedesktop.Platform"` | Flatpak runtime | | `runtimeVersion` | `String` | `"23.08"` | Runtime version | | `sdk` | `String` | `"org.freedesktop.Sdk"` | SDK | | `branch` | `String` | `"master"` | Application branch | | `finishArgs` | `List` | 5 defaults | Sandbox permissions | | `license` | `RegularFileProperty` | — | License file | --- # Sandboxing Potassium automatically manages a **store build pipeline** for store formats. When your target formats include PKG, AppX, or Flatpak, the plugin splits the build into two parallel pipelines: one for direct-distribution formats (DMG, NSIS, DEB...) and one for store formats that require special handling (sandboxing on macOS/Linux, native library extraction for all). ## Store Formats | Format | OS | Sandbox Type | |--------|----|--------------| | PKG | macOS | [App Sandbox](https://developer.apple.com/documentation/security/app-sandbox) | | AppX | Windows | [MSIX packaging](https://learn.microsoft.com/en-us/windows/msix/overview) (full trust — not sandboxed) | | Flatpak | Linux | [Flatpak sandbox](https://docs.flatpak.org/en/latest/sandbox-permissions.html) | The plugin automatically classifies the store formats — `Pkg` (macOS), `AppX` (Windows), and `Flatpak` (Linux) — as requiring the sandboxed pipeline: ```kotlin // Each platform mixes direct (non-sandboxed) and store (sandboxed) formats freely. macOS { targetFormats(MacOSTargetFormat.Dmg, MacOSTargetFormat.Pkg) } // Pkg → sandboxed windows { targetFormats(WindowsTargetFormat.Nsis, WindowsTargetFormat.AppX) } // AppX → sandboxed linux { targetFormats(LinuxTargetFormat.Deb, LinuxTargetFormat.Flatpak) } // Flatpak → sandboxed ``` Both pipelines run in the same `./gradlew packageDistributionForCurrentOS` invocation. No extra configuration is needed. ## What the Sandboxed Pipeline Does When at least one store format is configured, Potassium registers additional Gradle tasks that handle the constraints imposed by OS-level sandboxing: ### 1. Extract Native Libraries from JARs Sandboxed apps (especially macOS App Sandbox) cannot load unsigned native code extracted to temp directories at runtime. The plugin scans all dependency JARs for `.dylib`, `.jnilib`, `.so`, and `.dll` files and extracts them. On macOS, these are placed in the app's `Contents/Frameworks/` directory (Apple convention); on other platforms they go into the app's `resources/` directory. **Task:** `extractNativeLibsForSandboxing` ### 2. Strip Native Libraries from JARs After extraction, the plugin rewrites JARs without the native library entries to avoid duplication in the final package. **Task:** `stripNativeLibsFromJars` ### 3. Prepare Sandboxed App Resources A separate `Sync` task merges the user's `appResources` with the extracted native libraries into a single resources directory for the sandboxed app-image. **Task:** `prepareSandboxedAppResources` ### 4. Inject Sandboxing JVM Arguments The sandboxed app-image is configured with JVM arguments that redirect native library loading to the pre-extracted location. On macOS, the path points to `Contents/Frameworks/` (`$APPDIR/../Frameworks`); on other platforms it points to `$APPDIR/resources`: **macOS:** ``` -Djava.library.path=$APPDIR/../Frameworks -Djna.nounpack=true -Djna.nosys=true -Djna.boot.library.path=$APPDIR/../Frameworks -Djna.library.path=$APPDIR/../Frameworks ``` **Windows / Linux:** ``` -Djava.library.path=$APPDIR/resources -Djna.nounpack=true -Djna.nosys=true -Djna.boot.library.path=$APPDIR/resources -Djna.library.path=$APPDIR/resources ``` This ensures JNA/JNI libraries are loaded from signed, pre-extracted locations instead of being dynamically extracted to temp at runtime. ### 5. Sign Native Libraries (macOS) On macOS, all `.dylib` files in the sandboxed app's `Contents/Frameworks/` directory are individually code-signed so they pass Gatekeeper checks. ### 6. Handle Skiko and icudtl.dat The Skiko library path is adjusted to point to `Contents/Frameworks/` (macOS) or `resources/` (other platforms) instead of the app root. The companion `icudtl.dat` file is placed alongside the Skiko native library. ## macOS App Sandbox ### Entitlements The plugin ships default entitlements for both sandboxed and non-sandboxed builds: **Non-sandboxed** (DMG, ZIP — direct distribution): ```xml com.apple.security.cs.allow-jit com.apple.security.cs.allow-unsigned-executable-memory com.apple.security.cs.disable-library-validation ``` **Sandboxed app** (PKG — App Store): ```xml com.apple.security.app-sandbox com.apple.security.cs.allow-jit com.apple.security.cs.allow-unsigned-executable-memory com.apple.security.cs.disable-library-validation com.apple.security.network.client com.apple.security.files.user-selected.read-write ``` **Sandboxed runtime** (JVM runtime binaries): ```xml com.apple.security.app-sandbox com.apple.security.cs.allow-jit com.apple.security.cs.allow-unsigned-executable-memory com.apple.security.cs.disable-library-validation ``` The runtime entitlements are more restrictive (no network, no file access) since only the app code should declare capabilities. ### Custom Entitlements Override the defaults for additional capabilities (camera, microphone, etc.): ```kotlin macOS { entitlementsFile.set(project.file("packaging/entitlements.plist")) runtimeEntitlementsFile.set(project.file("packaging/runtime-entitlements.plist")) } ``` ### Provisioning Profiles (Mac App Store) Mac App Store builds require provisioning profiles: ```kotlin macOS { provisioningProfile.set(project.file("packaging/MyApp.provisionprofile")) runtimeProvisioningProfile.set(project.file("packaging/MyApp_Runtime.provisionprofile")) } ``` **NOTE:** The `appStore` property is deprecated. PKG is always treated as an App Store format — sandbox entitlements and "3rd Party Mac Developer" certificates are applied automatically. ### AOT Cache and Sandboxing When generating an AOT cache for a sandboxed build, the plugin temporarily strips the code signature from `jspawnhelper` during the training phase. macOS kills sandboxed forked processes, so the signature must be removed for AOT training and re-applied afterward with the runtime entitlements. This is handled automatically — no configuration needed. ## Windows AppX AppX packages use MSIX packaging for the Microsoft Store. Desktop Bridge apps run with full trust (`runFullTrust`) and are **not sandboxed** — they have the same system access as regular desktop apps. Capabilities are declared via the AppX manifest settings: ```kotlin windows { appx { publisher = "CN=D541E802-6D30-446A-864E-2E8ABD2DAA5E" identityName = "MyCompany.MyApp" publisherDisplayName = "My Company" applicationId = "MyApp" } } ``` See [Windows Targets](targets/windows.md#appx-windows-store-msix) for all AppX settings. ## Flatpak Sandbox Flatpak apps are sandboxed by default. Use `finishArgs` to grant permissions: ```kotlin linux { flatpak { finishArgs = listOf( "--share=ipc", "--socket=x11", "--socket=wayland", "--socket=pulseaudio", "--device=dri", "--filesystem=home", // access home directory "--share=network", // network access ) } } ``` See [Linux Targets](targets/linux.md#flatpak) for all Flatpak settings. ## CI Integration The sandboxed pipeline runs transparently in CI. A single `./gradlew packageReleaseDistributionForCurrentOS` builds both sandboxed and non-sandboxed formats: ```yaml - name: Setup Potassium uses: ./.github/actions/setup-potassium with: jbr-version: '25.0.2b329.66' packaging-tools: 'true' flatpak: 'true' # Flatpak sandbox support snap: 'true' setup-gradle: 'true' setup-node: 'true' - name: Build packages run: ./gradlew packageReleaseDistributionForCurrentOS --stacktrace --no-daemon ``` The `setup-potassium` action installs all dependencies needed for sandboxed builds: - **Linux:** Flatpak SDK/runtime, Snapcraft - **macOS:** JBR 25 (for entitlements signing via `codesign`) - **Windows:** No extra setup needed (AppX uses the built-in Windows SDK) Sandboxed outputs go to `-sandboxed/` subdirectories and are uploaded alongside non-sandboxed artifacts. The post-build jobs (universal macOS binary, MSIX bundle, publish) handle both transparently. ## Gradle Tasks | Task | Description | |------|-------------| | `extractNativeLibsForSandboxing` | Extract `.dylib`/`.so`/`.dll` from dependency JARs | | `stripNativeLibsFromJars` | Rewrite JARs without native libraries | | `prepareSandboxedAppResources` | Merge app resources + extracted native libs | | `createSandboxedDistributable` | Build app-image with sandbox JVM args | | `generateSandboxedAotCache` | AOT cache for sandboxed distributable | | `package` | Final packaging using sandboxed distributable | These tasks are only registered when at least one store format is configured. --- # Code Signing Code signing ensures your application is trusted by the operating system and not flagged as malware. Potassium supports signing for Windows and macOS. ## Windows ### PFX Certificate Sign Windows installers (NSIS, MSI, AppX) with a `.pfx` / `.p12` certificate: ```kotlin windows { signing { enabled = true certificateFile.set(file("certs/certificate.pfx")) certificatePassword = "your-password" algorithm = SigningAlgorithm.Sha256 timestampServer = "http://timestamp.digicert.com" } } ``` ### Signing DSL Reference | Property | Type | Default | Description | |----------|------|---------|-------------| | `enabled` | `Boolean` | `false` | Enable code signing | | `certificateFile` | `RegularFileProperty` | — | Path to `.pfx` / `.p12` certificate | | `certificatePassword` | `String?` | `null` | Certificate password | | `certificateSha1` | `String?` | `null` | SHA-1 thumbprint (for store-installed certs) | | `certificateSubjectName` | `String?` | `null` | Subject name of the certificate | | `algorithm` | `SigningAlgorithm` | `Sha256` | Signing algorithm | | `timestampServer` | `String?` | `null` | Timestamp server URL | ### Signing Algorithms | Algorithm | Description | |-----------|-------------| | `SigningAlgorithm.Sha1` | Legacy, for older Windows | | `SigningAlgorithm.Sha256` | Recommended | | `SigningAlgorithm.Sha512` | Strongest | ### Common Timestamp Servers | Provider | URL | |----------|-----| | DigiCert | `http://timestamp.digicert.com` | | Sectigo | `http://timestamp.sectigo.com` | | GlobalSign | `http://timestamp.globalsign.com` | ### Azure Artifact Signing For cloud-based signing with [Azure Artifact Signing](https://learn.microsoft.com/en-us/azure/artifact-signing/): ```kotlin windows { signing { enabled = true publisherName = "Your Publisher Name" azureTenantId = "your-tenant-id" azureEndpoint = "https://your-region.codesigning.azure.net" azureCertificateProfileName = "your-profile" azureCodeSigningAccountName = "your-account" } } ``` ### CI/CD: Secrets Management Never commit certificates or passwords to source control. Use environment variables or CI secrets: ```kotlin windows { signing { enabled = true certificateFile.set(file(System.getenv("WIN_CSC_LINK") ?: "certs/certificate.pfx")) certificatePassword = System.getenv("WIN_CSC_KEY_PASSWORD") algorithm = SigningAlgorithm.Sha256 timestampServer = "http://timestamp.digicert.com" } } ``` In GitHub Actions: ```yaml env: WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }} WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }} ``` > **Tip:** Base64-encode your `.pfx` file for CI: > ```bash > base64 -i certificate.pfx -o certificate.b64 > ``` > Store the content as a GitHub secret, then decode at build time: > ```yaml > - name: Decode certificate > run: echo "${{ secrets.WIN_CSC_LINK }}" | base64 -d > certificate.pfx > ``` ## macOS ### Prerequisites macOS signing requires an [Apple Developer ID certificate](https://developer.apple.com/developer-id/): 1. Enroll in the [Apple Developer Program](https://developer.apple.com/programs/) 2. Create a "Developer ID Application" certificate in Xcode or the Apple Developer portal 3. The certificate must be in your local Keychain (or a CI keychain) ### Signing Configuration ```kotlin macOS { signing { sign.set(true) identity.set("Developer ID Application: My Company (TEAMID)") // keychain.set("/path/to/keychain.keychain-db") // Optional } } ``` ### Notarization Apple notarization is required for distributing outside the Mac App Store on macOS 10.15+. Three authentication modes are supported (mutually exclusive): #### Mode 1 — Apple ID + app-specific password ```kotlin macOS { notarization { appleID.set("dev@example.com") password.set(System.getenv("MAC_NOTARIZATION_PASSWORD")) teamID.set("TEAMID") } } ``` Equivalent Gradle properties: | Gradle property | Description | |-----------------|-------------| | `compose.desktop.mac.notarization.appleID` | Apple ID email | | `compose.desktop.mac.notarization.password` | App-specific password | | `compose.desktop.mac.notarization.teamID` | Apple Team ID | #### Mode 2 — `notarytool` keychain profile Store credentials once with `xcrun notarytool store-credentials`, then reference the profile by name: ```bash xcrun notarytool store-credentials "AC_PASSWORD" \ --apple-id "dev@example.com" \ --team-id "TEAMID" \ --password "app-specific-password" ``` ```kotlin macOS { notarization { keychainProfile.set("AC_PASSWORD") // Optional — defaults to the user's login keychain: // keychainPath.set("/Users/me/Library/Keychains/login.keychain-db") } } ``` Equivalent Gradle properties: | Gradle property | Description | |-----------------|-------------| | `compose.desktop.mac.notarization.keychainProfile` | Profile name created via `store-credentials` | | `compose.desktop.mac.notarization.keychainPath` | Optional path to the keychain holding the profile | #### Mode 3 — App Store Connect API key Generate a key in [App Store Connect → Users and Access → Integrations → Team Keys](https://appstoreconnect.apple.com/access/integrations/api), download the `.p8` file once, then reference it: ```kotlin macOS { notarization { apiKey.set("/path/to/AuthKey_ABC123.p8") apiKeyId.set("ABC123") // 10-char Key ID apiIssuer.set("12345678-90ab-cdef-1234-567890abcdef") // Issuer UUID } } ``` Equivalent Gradle properties: | Gradle property | Description | |-----------------|-------------| | `compose.desktop.mac.notarization.apiKey` | Path to the `.p8` API key file | | `compose.desktop.mac.notarization.apiKeyId` | Key ID (the 10-character identifier) | | `compose.desktop.mac.notarization.apiIssuer` | Issuer UUID for the team | This mode is recommended for CI/CD: API keys can be revoked independently of the Apple ID, support role-based scoping, and are not affected by 2FA. > Configuring more than one mode in the same build is rejected at validation time. Pick one. ### CI/CD: macOS Signing For GitHub Actions, import the certificate into a temporary keychain: ```yaml - name: Import certificate env: MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }} KEYCHAIN_PWD: ${{ secrets.KEYCHAIN_PWD }} run: | echo "$MACOS_CERTIFICATE" | base64 -d > certificate.p12 security create-keychain -p "$KEYCHAIN_PWD" build.keychain security default-keychain -s build.keychain security unlock-keychain -p "$KEYCHAIN_PWD" build.keychain security import certificate.p12 -k build.keychain -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PWD" build.keychain ``` ### CI/CD: macOS Signing for Universal Binaries When building universal (fat) macOS binaries, `lipo` invalidates all code signatures. Signing must happen **after** the universal merge, in the `universal-macos` CI job. Potassium provides a `setup-macos-signing` composite action (`.github/actions/setup-macos-signing`) that creates a temporary keychain and imports certificates: ```yaml - name: Setup macOS signing id: signing uses: ./.github/actions/setup-macos-signing with: certificate-base64: ${{ secrets.MAC_CERTIFICATES_P12 }} certificate-password: ${{ secrets.MAC_CERTIFICATES_PASSWORD }} ``` #### Required Secrets | Secret | Description | |--------|-------------| | `MAC_CERTIFICATES_P12` | Base64-encoded `.p12` containing all signing certificates | | `MAC_CERTIFICATES_PASSWORD` | Password for the `.p12` file | | `MAC_DEVELOPER_ID_APPLICATION` | Developer ID Application identity (e.g. `"Developer ID Application: Company (TEAMID)"`) | | `MAC_DEVELOPER_ID_INSTALLER` | Developer ID Installer identity (unused — PKG is always App Store) | | `MAC_APP_STORE_APPLICATION` | App Store application identity (e.g. `"3rd Party Mac Developer Application: Company (TEAMID)"`) | | `MAC_APP_STORE_INSTALLER` | App Store installer identity (e.g. `"3rd Party Mac Developer Installer: Company (TEAMID)"`) | | `MAC_PROVISIONING_PROFILE` | Base64-encoded `embedded.provisionprofile` for sandboxed app | | `MAC_RUNTIME_PROVISIONING_PROFILE` | Base64-encoded runtime provisioning profile for sandboxed app | | `MAC_NOTARIZATION_APPLE_ID` | Apple ID for notarization | | `MAC_NOTARIZATION_PASSWORD` | App-specific password for notarization | | `MAC_NOTARIZATION_TEAM_ID` | Apple Team ID for notarization | #### Granular Signing (Inside-Out) The `build-macos-universal` action signs `.app` bundles using a strict inside-out order to satisfy Apple's code signing requirements: 1. `.dylib` files (with runtime entitlements) 2. `.jnilib` files (with runtime entitlements) 3. Main executables in `Contents/MacOS/` (with app entitlements) 4. Runtime executables in `Contents/runtime/Contents/Home/bin/` (with runtime entitlements) 5. `.framework` bundles 6. Runtime bundle (`Contents/runtime`) 7. The `.app` bundle itself (with app entitlements) All signing uses `--options runtime --timestamp` for hardened runtime and timestamping. #### Distribution Flows **DMG + ZIP (Direct Distribution)**: - Non-sandboxed `.app` signed with **Developer ID Application** identity - Notarized via `xcrun notarytool` after packaging - DMG is stapled directly; ZIP is extracted, `.app` is stapled, then re-zipped **PKG (App Store)**: - Sandboxed `.app` signed with **3rd Party Mac Developer Application** identity - Provisioning profiles embedded in `Contents/embedded.provisionprofile` - PKG built via `productbuild --component` and signed with **3rd Party Mac Developer Installer** identity - No notarization needed (Apple reviews via Transporter) #### Backward Compatibility All signing is conditional. Without the `MAC_*` secrets configured, the workflow falls back to ad-hoc signing and electron-builder PKG generation (identical to the unsigned behavior). ### Entitlements For apps using certain capabilities (network, file access, JIT), provide entitlements: ```kotlin macOS { entitlementsFile.set(project.file("entitlements.plist")) runtimeEntitlementsFile.set(project.file("runtime-entitlements.plist")) } ``` Minimal `entitlements.plist` for a JVM app: ```xml com.apple.security.cs.allow-jit com.apple.security.cs.allow-unsigned-executable-memory com.apple.security.cs.allow-dyld-environment-variables ``` --- # Auto Update Potassium provides a complete auto-update solution compatible with the [electron-builder update format](https://www.electron.build/auto-update). The system has two parts: 1. **Build-time**: electron-builder writes a single update metadata file (`latest-.yml`) alongside your installers, and uploads it together with the installers to whichever provider you configure (GitHub, S3, or generic) 2. **Runtime**: The `potassium.updater-runtime` library checks for updates, downloads, and installs them ## How It Works **Try it yourself:** Download an **older version** of the Potassium demo app from the [GitHub Releases page](https://github.com/kdroidFilter/Nucleus/releases), install it, and launch it. The app will automatically detect that a newer version is available, download the update with a progress bar, and offer an "Install & Restart" button. This is the exact same flow your users will experience. ## Updatable Formats | Platform | Updatable Formats | Not updatable (Store-managed) | |----------|-------------------|-------------------------------| | macOS | DMG, ZIP | PKG | | Windows | EXE/NSIS, NSIS Web, MSI | AppX/MSIX | | Linux | DEB, RPM, AppImage | Snap, Flatpak | PKG (macOS), AppX/MSIX (Windows), Snap, and Flatpak are not supported by the auto-updater because Potassium assumes these formats are distributed through their respective app stores (Mac App Store, Microsoft Store, Snapcraft, Flathub), which handle updates natively. **macOS: ZIP is required alongside DMG:** On macOS, the auto-updater uses the **ZIP** format to perform the update (extract and replace the `.app` bundle silently). The DMG is used for initial installation only. You **must** include `MacOSTargetFormat.Zip` in your macOS `targetFormats` configuration, otherwise macOS auto-update will not work: ```kotlin macOS { targetFormats( MacOSTargetFormat.Dmg, // Initial install MacOSTargetFormat.Zip, // Required for auto-update on macOS // ... other formats ) } ``` Both the DMG and ZIP artifacts must be uploaded to the same release (GitHub, S3, or HTTP server). The generated `latest-mac.yml` will reference both files. ## Update Metadata (YML Files) The auto-updater relies on three YAML files that list all available installers with their SHA-512 checksums. These files **must be generated after building on all platforms**, because each platform produces its own artifacts. ### How CI generates them In the release workflow, each platform builds its installers in parallel and uploads them as separate artifacts (`release-assets-macOS-arm64`, `release-assets-Linux-amd64`, etc.). A final `release` job then: 1. Downloads all platform artifacts into a single directory 2. Runs the `generate-update-yml` action, which scans every installer file, computes SHA-512 checksums, and produces `latest-mac.yml`, `latest.yml` (Windows), and `latest-linux.yml` 3. Uploads everything (installers + YML files) to the release See the [example release workflow](https://github.com/kdroidFilter/Nucleus/blob/main/.github/workflows/release-desktop.yaml) for the full setup. ### Building locally Each platform is packaged in a single electron-builder invocation, so electron-builder writes one `latest-.yml` (covering all of that platform's installers) alongside them when you run `packageDistributionForCurrentOS`, and — for github, s3, and generic alike — uploads it as part of publishing. If you build on a single machine, the YML is ready to use for that platform. However, for a real multi-platform release, you need to build on each platform (macOS, Windows, Linux) and then **merge** the per-platform YML files into final ones that reference all architectures. The CI does this automatically with the `generate-update-yml` action. To do it locally: 1. Build on each platform and collect all installers + YML files into a single directory 2. For each platform YML (e.g. `latest-mac.yml`), merge the `files:` entries from all architectures into one file For example, if you built on macOS ARM64 and macOS x64 separately, combine both `files:` entries into a single `latest-mac.yml`: ```yaml version: 1.2.3 files: - url: MyApp-1.2.3-macos-arm64.dmg sha512: size: 102400000 - url: MyApp-1.2.3-macos-arm64.zip sha512: size: 98000000 - url: MyApp-1.2.3-macos-x64.dmg sha512: size: 98765432 - url: MyApp-1.2.3-macos-x64.zip sha512: size: 95000000 path: MyApp-1.2.3-macos-arm64.dmg sha512: releaseDate: '2026-03-01T12:00:00.000Z' ``` **TIP:** In practice, always use CI for multi-platform releases. The [release workflow](https://github.com/kdroidFilter/Nucleus/blob/main/.github/workflows/release-desktop.yaml) handles all of this automatically: build in parallel, merge YML files, and publish to GitHub Releases in a single pipeline. ### YML file examples Three YAML files are generated per release: ### `latest-mac.yml` ```yaml version: 1.2.3 files: - url: MyApp-1.2.3-macos-arm64.dmg sha512: VkJl1gDqcBHYbYhMb0HRI... size: 102400000 - url: MyApp-1.2.3-macos-amd64.dmg sha512: qJ8a5gFDCwv0R2rW6lM3k... size: 98765432 releaseDate: '2025-06-15T10:30:00.000Z' ``` ### `latest.yml` (Windows) ```yaml version: 1.2.3 files: - url: MyApp-1.2.3-windows-amd64.exe sha512: abc123... size: 85000000 releaseDate: '2025-06-15T10:30:00.000Z' ``` ### `latest-linux.yml` ```yaml version: 1.2.3 files: - url: MyApp-1.2.3-linux-amd64.deb sha512: def456... size: 68000000 - url: MyApp-1.2.3-linux-arm64.deb sha512: ghi789... size: 65000000 releaseDate: '2025-06-15T10:30:00.000Z' ``` ## Release Channels Potassium supports three release channels. Different YML files are generated for each: | Channel | YML Files | Tag Pattern | |---------|-----------|-------------| | `latest` | `latest-*.yml` | `v1.0.0` | | `beta` | `beta-*.yml` | `v1.0.0-beta.1` | | `alpha` | `alpha-*.yml` | `v1.0.0-alpha.1` | The channel is auto-detected from the version tag in CI. ## Publishing Artifacts ### The `publish {}` block in `build.gradle.kts` The `publish {}` block **only generates configuration** for electron-builder — it does **not** upload anything by itself. It tells the generated `electron-builder.yml` where the update files will be hosted, so the updater knows where to look: ```kotlin potassium { publish { github { enabled = true owner = "myorg" repo = "myapp" channel = ReleaseChannel.Latest releaseType = ReleaseType.Release } } } ``` You are responsible for uploading the installers and YML files to your chosen hosting. There are three options: ### Option 1: GitHub Releases (recommended) The simplest approach. Use the [ready-made release CI workflow](https://github.com/kdroidFilter/Nucleus/blob/main/.github/workflows/release-desktop.yaml) which handles everything automatically: 1. Builds on all platforms in parallel 2. Generates the `latest-*.yml` files from all platform artifacts 3. Uploads everything to a GitHub Release Push a tag (`v1.0.0`) and the workflow takes care of the rest. See [CI/CD](ci-cd.md) for setup details and [Publishing](publishing.md) for the full DSL reference. ### Option 2: Amazon S3 Configure the S3 provider and upload artifacts from your CI pipeline: ```kotlin publish { s3 { enabled = true bucket = "my-updates-bucket" region = "us-east-1" path = "releases" acl = "public-read" } } ``` ### Option 3: Generic HTTP server Host your files on any HTTP server. Upload the installers and YML files to the same base URL: ```kotlin publish { generic { enabled = true url = "https://updates.example.com/releases/" } } ``` The updater will fetch `https://updates.example.com/releases/latest-mac.yml` (and equivalent for other platforms) to check for updates, then download the installer from the same base URL. See [Publishing](publishing.md) for the full configuration reference. ## Runtime Library ### Installation ```kotlin dependencies { implementation("io.github.kdroidfilter:potassium.updater-runtime:1.0.0") } ``` ### Quick Start ```kotlin import com.seanproctor.potassium.updater.PotassiumUpdater import com.seanproctor.potassium.updater.UpdateResult import com.seanproctor.potassium.updater.provider.GitHubProvider val updater = PotassiumUpdater { provider = GitHubProvider(owner = "myorg", repo = "myapp") } when (val result = updater.checkForUpdates()) { is UpdateResult.Available -> { println("Update available: ${result.info.version}") updater.downloadUpdate(result.info).collect { progress -> println("${progress.percent.toInt()}%") if (progress.file != null) { updater.installAndRestart(progress.file!!) } } } is UpdateResult.NotAvailable -> println("Up to date") is UpdateResult.Error -> println("Error: ${result.exception.message}") } ``` ### Configuration ```kotlin PotassiumUpdater { // Current app version (auto-detected from jpackage.app-version system property) currentVersion = "1.0.0" // Update source (required) provider = GitHubProvider(owner = "myorg", repo = "myapp") // Release channel: "latest", "beta", or "alpha" channel = "latest" // Allow installing older versions allowDowngrade = false // Allow pre-release versions (auto-enabled if currentVersion contains "-") allowPrerelease = false // Force a specific installer format (auto-detected if null) executableType = null } ``` ### Providers #### GitHub Releases ```kotlin import com.seanproctor.potassium.updater.provider.GitHubProvider provider = GitHubProvider( owner = "myorg", repo = "myapp", token = "ghp_..." // Optional, for private repos ) ``` #### Generic HTTP Server ```kotlin import com.seanproctor.potassium.updater.provider.GenericProvider provider = GenericProvider( baseUrl = "https://updates.example.com" ) ``` Host your YML files and installers at: ``` https://updates.example.com/latest-mac.yml https://updates.example.com/latest.yml https://updates.example.com/latest-linux.yml https://updates.example.com/MyApp-1.2.3-macos-arm64.dmg ``` ### API Reference #### PotassiumUpdater | Method | Description | |--------|-------------| | `isUpdateSupported(): Boolean` | Check if the current executable type supports auto-update | | `suspend checkForUpdates(): UpdateResult` | Check for a newer version | | `downloadUpdate(info: UpdateInfo): Flow` | Download the installer with progress | | `installAndRestart(installerFile: File)` | Launch the installer, exit the current process, and relaunch after install | | `installAndQuit(installerFile: File)` | Launch the installer and exit without relaunching — the update is applied on next manual start | | `consumeUpdateEvent(): UpdateEvent?` | Returns the post-update event if the app was just updated, then clears it. Returns `null` if no update occurred. | | `wasJustUpdated(): Boolean` | Non-consuming check — returns `true` if the app was launched after an update. Call `consumeUpdateEvent()` to clear. | #### DownloadProgress ```kotlin data class DownloadProgress( val bytesDownloaded: Long, val totalBytes: Long, val percent: Double, // 0.0 .. 100.0 val file: File? = null, // Non-null on the final emission ) ``` #### UpdateResult ```kotlin sealed class UpdateResult { data class Available(val info: UpdateInfo, val level: UpdateLevel) data object NotAvailable data class Error(val exception: UpdateException) } ``` #### UpdateLevel ```kotlin enum class UpdateLevel { MAJOR, // e.g. 1.x.x → 2.x.x MINOR, // e.g. 1.2.x → 1.3.x PATCH, // e.g. 1.2.3 → 1.2.4 PRE_RELEASE, // e.g. 1.2.3-beta.1 → 1.2.3-beta.2 } ``` The `level` is computed automatically by comparing the current version with the available version using semantic versioning. #### UpdateEvent ```kotlin data class UpdateEvent( val previousVersion: String, val newVersion: String, val updateLevel: UpdateLevel, ) ``` ### Compose Desktop Integration ```kotlin @Composable fun UpdateBanner() { val updater = remember { PotassiumUpdater { provider = GitHubProvider(owner = "myorg", repo = "myapp") } } var status by remember { mutableStateOf("Checking for updates...") } var progress by remember { mutableStateOf(-1.0) } var downloadedFile by remember { mutableStateOf(null) } LaunchedEffect(Unit) { when (val result = updater.checkForUpdates()) { is UpdateResult.Available -> { status = "Downloading v${result.info.version}..." updater.downloadUpdate(result.info).collect { progress = it.percent if (it.file != null) { downloadedFile = it.file status = "Ready to install v${result.info.version}" } } } is UpdateResult.NotAvailable -> status = "Up to date" is UpdateResult.Error -> status = "Error: ${result.exception.message}" } } Column { Text(status) if (progress in 0.0..99.9) { LinearProgressIndicator(progress = (progress / 100.0).toFloat()) } downloadedFile?.let { file -> Button(onClick = { updater.installAndRestart(file) }) { Text("Install & Restart") } } } } ``` ### Installer Behavior The `installAndRestart()` method launches the platform-specific installer, exits the current process, and relaunches the app after installation: | Platform | Format | Command | |----------|--------|---------| | Linux | DEB | `sudo dpkg -i ` | | Linux | RPM | `sudo rpm -U ` | | macOS | DMG/PKG | `open ` | | Windows | EXE/NSIS | ` /S` (silent) | | Windows | MSI | `msiexec /i /passive` | ### Silent Update with `installAndQuit()` The `installAndQuit()` method works like `installAndRestart()` but does **not** relaunch the application after installation. The update is applied silently in the background and takes effect the next time the user opens the app. This is useful for applying updates transparently (e.g. when the user closes the app). ```kotlin // Example: apply update silently on app close updater.downloadUpdate(result.info).collect { progress -> if (progress.file != null) { updater.installAndQuit(progress.file!!) } } ``` #### Platform considerations | Platform | Format | Silent? | Notes | |----------|--------|---------|-------| | macOS | DMG | Yes | Installed via `open`, no elevation needed | | macOS | ZIP | Yes | Extracted silently, no elevation needed | | Windows | NSIS/EXE | Depends | Silent if installed in **user mode**; requires UAC elevation if installed system-wide | | Windows | MSI | Depends | Silent if installed in **user mode**; requires UAC elevation if installed system-wide | | Linux | AppImage | Yes | Replaces the file in place, no elevation needed | | Linux | DEB | No | Always requires elevation (`pkexec`) | | Linux | RPM | No | Always requires elevation (`pkexec`) | ### Using a Native HTTP Client By default, the updater uses a plain `java.net.http.HttpClient` backed by the JDK trust store. On machines with **enterprise proxies**, **corporate CAs**, or **user-installed certificates**, HTTPS requests may fail with `SSLHandshakeException`. To fix this, pass a client pre-configured with the OS trust store (for example via `NativeTrustManager`): **1. Add the dependency** ```kotlin dependencies { implementation("io.github.kdroidfilter:potassium.updater-runtime:") implementation("io.github.kdroidfilter:potassium.native-http:") } ``` **2. Inject the client in the updater config** ```kotlin import com.seanproctor.potassium.nativehttp.NativeHttpClient import com.seanproctor.potassium.updater.PotassiumUpdater import com.seanproctor.potassium.updater.provider.GitHubProvider val updater = PotassiumUpdater { provider = GitHubProvider(owner = "myorg", repo = "myapp") httpClient = NativeHttpClient.create() } ``` The injected client is used for **both** the metadata check and the file download. You can also compose additional options via the builder extension: ```kotlin import com.seanproctor.potassium.nativehttp.NativeHttpClient.withNativeSsl import java.net.http.HttpClient import java.time.Duration val updater = PotassiumUpdater { provider = GitHubProvider(owner = "myorg", repo = "myapp") httpClient = HttpClient.newBuilder() .withNativeSsl() .connectTimeout(Duration.ofSeconds(30)) .followRedirects(HttpClient.Redirect.NORMAL) .build() } ``` ### Update Level When `checkForUpdates()` returns `UpdateResult.Available`, the `level` field tells you how significant the update is: ```kotlin when (val result = updater.checkForUpdates()) { is UpdateResult.Available -> { when (result.level) { UpdateLevel.MAJOR -> showMajorUpdateDialog(result.info) UpdateLevel.MINOR -> showMinorUpdateBanner(result.info) UpdateLevel.PATCH -> silentlyDownloadAndInstall(result.info) UpdateLevel.PRE_RELEASE -> showPreReleaseBanner(result.info) } } // ... } ``` This allows you to adapt the UI — for example, force a confirmation dialog for major updates while silently applying patches. ### Post-Update Detection After an update is installed (via `installAndRestart()` or `installAndQuit()`), the updater persists a marker file. On the next launch, you can detect that the app was just updated: ```kotlin val updater = PotassiumUpdater { provider = GitHubProvider(owner = "myorg", repo = "myapp") } // Quick non-consuming check if (updater.wasJustUpdated()) { println("App was just updated!") } // Consume the event (returns null on subsequent calls) val event = updater.consumeUpdateEvent() if (event != null) { println("Updated from ${event.previousVersion} to ${event.newVersion}") println("This was a ${event.updateLevel} update") showWhatsNewDialog(event) } ``` #### Compose Integration ```kotlin @Composable fun PostUpdateBanner(updater: PotassiumUpdater) { var updateEvent by remember { mutableStateOf(updater.consumeUpdateEvent()) } updateEvent?.let { event -> Card(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Row( modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { Text("Updated to v${event.newVersion}") Text( "${event.updateLevel} update from v${event.previousVersion}", style = MaterialTheme.typography.bodySmall, ) } TextButton(onClick = { updateEvent = null }) { Text("Dismiss") } } } } } ``` The marker file is stored in the platform-specific app data directory (resolved from `PotassiumApp.appId`): - **Linux**: `$XDG_DATA_HOME//` or `~/.local/share//` - **macOS**: `~/Library/Application Support//` - **Windows**: `%APPDATA%//` ### Security - All downloads are verified with **SHA-512** checksums (base64-encoded) - If verification fails, the downloaded file is deleted and an error is returned - GitHub token is transmitted via `Authorization` header (not URL params) for private repos --- # Publishing Potassium can publish your installers and update metadata to **GitHub Releases**, **Amazon S3**, or a **generic HTTP server**. **One provider at a time:** Exactly one publish provider may be enabled. Enabling more than one of `github`, `s3`, or `generic` fails the build (electron-builder writes a single shared update manifest per build). ## Configuration ```kotlin potassium { publish { // Publish mode publishMode = PublishMode.Auto // Never, Auto, Always // Enable exactly one of the following providers. github { enabled = true owner = "myorg" repo = "myapp" token = System.getenv("GITHUB_TOKEN") channel = ReleaseChannel.Latest releaseType = ReleaseType.Release } // Or S3 s3 { enabled = false bucket = "my-updates-bucket" region = "us-east-1" path = "releases" acl = "public-read" } // Or generic HTTP server generic { enabled = false url = "https://updates.example.com/releases/" channel = ReleaseChannel.Latest useMultipleRangeRequest = true } } } ``` ## GitHub Releases The most common publishing target. Installers and YML metadata files are uploaded as release assets. ```kotlin publish { github { enabled = true owner = "myorg" // GitHub org or user repo = "myapp" // Repository name token = System.getenv("GITHUB_TOKEN") // Authentication token channel = ReleaseChannel.Latest // Latest, Beta, Alpha releaseType = ReleaseType.Release // Release, Draft, Prerelease } } ``` ### Release Structure A GitHub Release created by Potassium contains: ``` v1.0.0 (Release) ├── MyApp-1.0.0-macos-arm64.dmg ├── MyApp-1.0.0-macos-amd64.dmg ├── MyApp-1.0.0-macos-universal.dmg ├── MyApp-1.0.0-windows-amd64.exe ├── MyApp-1.0.0-windows-arm64.exe ├── MyApp-1.0.0-windows.msixbundle ├── MyApp-1.0.0-linux-amd64.deb ├── MyApp-1.0.0-linux-arm64.deb ├── MyApp-1.0.0-linux-amd64.rpm ├── MyApp-1.0.0-linux-amd64.AppImage ├── latest-mac.yml ← Auto-update metadata ├── latest.yml ← Auto-update metadata (Windows) └── latest-linux.yml ← Auto-update metadata ``` ### GitHub Token Use a `GITHUB_TOKEN` with `contents: write` permission: ```yaml # GitHub Actions — automatic token permissions: contents: write env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` ## Amazon S3 Publish to an S3 bucket for self-hosted update distribution: ```kotlin publish { s3 { enabled = true bucket = "my-updates-bucket" region = "us-east-1" path = "releases/myapp" // Prefix path in the bucket acl = "public-read" // ACL for uploaded files } } ``` ### S3 Bucket Structure ``` s3://my-updates-bucket/releases/myapp/ ├── MyApp-1.0.0-macos-arm64.dmg ├── MyApp-1.0.0-windows-amd64.exe ├── MyApp-1.0.0-linux-amd64.deb ├── latest-mac.yml ├── latest.yml └── latest-linux.yml ``` ### S3 Authentication Set AWS credentials via environment variables: ```bash export AWS_ACCESS_KEY_ID=AKIA... export AWS_SECRET_ACCESS_KEY=... export AWS_REGION=us-east-1 ``` ## Generic HTTP Server For self-hosted update distribution without cloud dependencies. The generic provider generates the `latest-*.yml` metadata files and configures the auto-updater to fetch from a base URL. You are responsible for uploading the output to your server. ```kotlin publish { generic { enabled = true url = "https://updates.example.com/releases/" channel = ReleaseChannel.Latest // Latest, Beta, Alpha useMultipleRangeRequest = true // Differential downloads } } ``` ### Server Structure Upload the installer and YML files to your server: ``` https://updates.example.com/releases/ ├── MyApp-1.0.0-macos-arm64.dmg ├── MyApp-1.0.0-windows-amd64.exe ├── MyApp-1.0.0-linux-amd64.deb ├── latest-mac.yml ├── latest.yml └── latest-linux.yml ``` Any static file server (Nginx, Caddy, Apache, S3 with public access, Cloudflare R2, etc.) works — the auto-updater simply fetches `/latest-.yml` and downloads the installer from the same base URL. ## Release Channels Channels allow you to distribute pre-release versions to testers: | Channel | YML Prefix | Version Pattern | Audience | |---------|------------|-----------------|----------| | `ReleaseChannel.Latest` | `latest-` | `1.0.0` | All users | | `ReleaseChannel.Beta` | `beta-` | `1.0.0-beta.1` | Beta testers | | `ReleaseChannel.Alpha` | `alpha-` | `1.0.0-alpha.1` | Internal testers | Users on the `beta` channel receive both `latest` and `beta` updates. Users on the `alpha` channel receive all updates. Configure the channel in the updater runtime: ```kotlin PotassiumUpdater { provider = GitHubProvider(owner = "myorg", repo = "myapp") channel = "beta" // Subscribe to beta updates } ``` ## Release Types | Type | Description | |------|-------------| | `ReleaseType.Release` | Visible on the releases page | | `ReleaseType.Draft` | Hidden until manually published | | `ReleaseType.Prerelease` | Marked as pre-release | ## Publish Modes | Mode | Description | |------|-------------| | `PublishMode.Never` | Do not publish (build only) | | `PublishMode.Auto` | Publish if on CI, skip locally | | `PublishMode.Always` | Always publish | ## DSL Reference ### `publish { }` | Property | Type | Default | Description | |----------|------|---------|-------------| | `publishMode` | `PublishMode` | `Never` | When to publish | ### `publish { github { } }` | Property | Type | Default | Description | |----------|------|---------|-------------| | `enabled` | `Boolean` | `false` | Enable GitHub publishing | | `owner` | `String` | — | GitHub owner/org | | `repo` | `String` | — | Repository name | | `token` | `String?` | `null` | GitHub token | | `channel` | `ReleaseChannel` | `Latest` | Release channel | | `releaseType` | `ReleaseType` | `Release` | Release type | ### `publish { s3 { } }` | Property | Type | Default | Description | |----------|------|---------|-------------| | `enabled` | `Boolean` | `false` | Enable S3 publishing | | `bucket` | `String` | — | S3 bucket name | | `region` | `String` | — | AWS region | | `path` | `String?` | `null` | Key prefix | | `acl` | `String?` | `null` | S3 ACL | ### `publish { generic { } }` | Property | Type | Default | Description | |----------|------|---------|-------------| | `enabled` | `Boolean` | `false` | Enable generic HTTP publishing | | `url` | `String` | — | Base URL where update files are hosted | | `channel` | `ReleaseChannel` | `Latest` | Release channel | | `useMultipleRangeRequest` | `Boolean` | `true` | Use multiple range requests for differential downloads | --- # Trusted CA Certificates Import custom CA certificates into the bundled JVM's `cacerts` keystore at build time. ## Use Case Corporate proxies, VPN gateways, or ISP-level filtering services often use a private root CA that is not trusted by the default JDK trust store. Without their certificate, any HTTPS connection your app makes will throw an `SSLHandshakeException`. Instead of asking users to patch their JVM manually, Potassium lets you declare the certificates once in your build script — they are imported automatically during packaging. ## Configuration ```kotlin potassium { trustedCertificates.from(files( "certs/company-proxy-ca.pem", "certs/company-proxy-ca-2.pem" )) } ``` Both PEM (`-----BEGIN CERTIFICATE-----`) and DER (binary) formats are accepted. ## How It Works 1. After the JLink runtime image is created, Potassium copies it to a separate `runtime-patched/` directory. 2. For each certificate file, it runs: ``` keytool -import -trustcacerts -alias - \ -keystore /lib/security/cacerts \ -storepass changeit -noprompt -file ``` 3. `createDistributable` and `createSandboxedDistributable` both use the patched runtime, so every packaging format (DMG, NSIS, DEB, PKG, AppX…) embeds the trusted certificate. ## Alias Generation Each certificate is imported under a unique alias derived from its filename and a short SHA-256 fingerprint of its content: ``` corp-root-ca.crt → corp-root-ca-3a1f8b2c proxy/ca.crt → ca-d341ce29 vpn/ca.crt → ca-8473a9f4 ``` This guarantees no collision even when multiple certificate files share the same name (e.g. two different `ca.crt` from different directories). ## Idempotency If a certificate with the same alias is already present in `cacerts`, the import is silently skipped. Rebuilding the project without changing the certificate files or the JLink runtime is instant (the `patchCaCertificates` Gradle task is up-to-date). ## Gradle Task | Task | Description | |------|-------------| | `patchCaCertificates` | Copies the runtime image and imports all configured certificates | The task is only registered when `trustedCertificates` is non-empty. It runs automatically as part of `createDistributable`; you do not need to invoke it manually. ``` createRuntimeImage ↓ patchCaCertificates ← copies runtime, runs keytool for each cert ↓ createDistributable createSandboxedDistributable ↓ packageMacOS / packageLinux / packageWindows (or packageDistributionForCurrentOS) ``` ## Notes - The original JLink runtime image is **never modified**. The patched copy lives in `build/potassium/tmp//runtime-patched/`. - The `keytool` binary used is the one from the JDK configured via `javaHome` (or the Gradle daemon's JVM if not set). - This feature patches the **bundled JVM** only. The host machine's JVM is not affected. --- # GraalVM Native Image **Alpha:** GraalVM Native Image support is **in alpha**. Most Compose Desktop apps work out of the box thanks to centralized reachability metadata, but edge cases (uncommon libraries, custom reflection) may still require additional configuration. ## Why Native Image? For most Compose Desktop applications, the JDK 25+ AOT cache (Leyden) is the recommended way to improve startup. It's simple to set up and provides a major boost. But there are cases where even Leyden isn't enough: - **Background services / system tray apps** — a lightweight app that mostly sits idle in the background will consume **300–400 MB of RAM** on a JVM, versus **100–150 MB** as a native image. For an app that's always running, this matters. - **Instant-launch expectations** — Leyden brings cold boot down to ~1.5 s, but a native image starts in ~0.5 s. For utilities, launchers, or CLI-like tools where every millisecond counts, native image is the way to go. - **Bundle size** — no bundled JRE means a much smaller distributable. GraalVM Native Image compiles your entire application **ahead of time** into a standalone native binary that feels truly native to the OS. ## Trade-offs Native image is not a free lunch. In addition to significantly more complex configuration (reflection, see below), there is a real **CPU throughput penalty**: the JVM's JIT compiler optimizes hot loops and polymorphic calls at runtime far better than AOT compilation can. For CPU-intensive workloads (heavy computation, real-time rendering, large data processing), a JVM with Leyden AOT cache will outperform a native image in sustained throughput. | | JVM + Leyden | Native Image | |---|---|---| | Cold boot | ~1.5 s | ~0.5 s | | RAM (idle) | 300–400 MB | 100–150 MB | | CPU throughput | Excellent (JIT) | Lower (no JIT) | | Bundle size | Larger (includes JRE) | Smaller | | Configuration | Simple (`enableAotCache = true`) | Simplified (centralized metadata) | | Stability | Stable | Alpha | **Choose native image when** startup speed and memory footprint are critical and CPU throughput is secondary. **Choose Leyden when** you want the best balance of performance, simplicity, and stability. ## Requirements ### BellSoft Liberica NIK 25 (Full) GraalVM Native Image compilation **requires [BellSoft Liberica NIK 25](https://bell-sw.com/liberica-native-image-kit/)** (full distribution, not lite). This is the only supported distribution — standard GraalVM CE does not include the AWT/Swing support needed for desktop GUI applications. **Will not work with other distributions:** Using Oracle GraalVM, GraalVM CE, or Liberica NIK Lite will fail. Desktop GUI applications require the **full** Liberica NIK distribution which includes AWT and Swing native-image support. ### Platform toolchains | Platform | Required | |----------|----------| | **macOS** | Xcode Command Line Tools (Xcode 26 for macOS 26 appearance) | | **Windows** | MSVC (Visual Studio Build Tools) — `ilammy/msvc-dev-cmd` in CI | | **Linux** | GCC, `patchelf`, `xvfb` (for headless compilation) | ## When to avoid native image Some libraries and use cases make native image compilation **extremely difficult or impractical**. Potassium can handle most standard Compose Desktop dependencies automatically, but the following categories will likely require extensive manual configuration — or may not work at all: **Libraries that are very hard to support:** - **Heavy JNA users** — Libraries that rely extensively on JNA (Java Native Access) for dynamic function calls. JNA's runtime proxy generation is fundamentally at odds with native-image's closed-world assumption. Examples: some system tray libraries, platform bridge libraries. - **Full-text search engines** — Apache Lucene, Elasticsearch client, and similar libraries use heavy reflection, dynamic class loading, custom classloaders, and `MethodHandle`-based access patterns that are nearly impossible to capture statically. - **Dynamic scripting engines** — Embedding Groovy, JRuby, Nashorn, or other scripting runtimes that rely on runtime code generation. - **Annotation-processing frameworks at runtime** — Libraries like Spring that scan classpath annotations and create proxies at runtime. (Compile-time DI frameworks like Koin or manual DI are fine.) - **OSGi or custom classloaders** — Any library that loads classes through non-standard classloaders will bypass native-image's static analysis entirely. - **Byte-code generation at runtime** — Libraries using ByteBuddy, cglib, or ASM to generate classes at runtime (e.g., mocking frameworks, some ORM lazy-loading proxies). If your application depends on libraries in these categories, **prefer AOT Cache (Leyden)** instead — it provides significant startup improvement with zero configuration overhead and full compatibility. For everything else — ktor, kotlinx.serialization, Coil, SQLite, Jewel, Compose Multiplatform resources, SLF4J, and most idiomatic Kotlin libraries — Potassium handles native image transparently. ## Next steps - [Configuration](configuration.md) — Gradle DSL and build arguments - [Automatic Metadata](automatic-metadata.md) — How Potassium resolves reflection metadata transparently - [Runtime Bootstrap](runtime-bootstrap.md) — `graalvm-runtime` module, initializer, font fixes, resource inclusion - [Tasks & CI/CD](tasks-ci.md) — Gradle tasks, output locations, CI workflows, debugging --- # Configuration ## Gradle DSL ```kotlin potassium { mainClass = "com.example.MainKt" graalvm { isEnabled = true imageName = "my-app" // Gradle Java Toolchain: auto-downloads Liberica NIK 25 // if it's not already installed on the machine. // In CI, the JDK is set up by graalvm/setup-graalvm@v1 instead. javaLanguageVersion = 25 jvmVendor = JvmVendorSpec.BELLSOFT buildArgs.addAll( "-H:+AddAllCharsets", "-Djava.awt.headless=false", "-Os", "-H:-IncludeMethodData", ) // Optional: customize Oracle Reachability Metadata Repository metadataRepository { enabled = true // default version = "0.10.6" // default excludedModules.add("com.example:my-lib") } // Optional: point to your own app-specific metadata nativeImageConfigBaseDir.set( layout.projectDirectory.dir("src/main/graalvm-config"), ) } } ``` **About `nativeImageConfigBaseDir`:** Potassium ships all generic and platform-specific reflection metadata automatically. The `nativeImageConfigBaseDir` is only needed if you have app-specific entries that the automatic metadata doesn't cover — which is rare. ## DSL Reference | Property | Type | Default | Description | |----------|------|---------|-------------| | `isEnabled` | `Boolean` | `false` | Enable GraalVM native compilation | | `javaLanguageVersion` | `Int` | `25` | Gradle toolchain language version — triggers auto-download of the matching JDK if not installed locally | | `jvmVendor` | `JvmVendorSpec` | — | Gradle toolchain vendor filter — set to `BELLSOFT` to auto-provision Liberica NIK | | `imageName` | `String` | project name | Output executable name | | `march` | `String` | `"native"` | CPU architecture target (`native` for current CPU, `compatibility` for broad compatibility) | | `buildArgs` | `ListProperty` | empty | Extra arguments passed to `native-image` | | `nativeImageConfigBaseDir` | `DirectoryProperty` | — | Directory containing app-specific `reachability-metadata.json` (optional — generic/platform metadata is built-in) | | `metadataRepository` | `MetadataRepositorySettings` | enabled | Oracle GraalVM Reachability Metadata Repository settings (see below) | ### `metadataRepository` DSL Reference | Property | Type | Default | Description | |----------|------|---------|-------------| | `enabled` | `Boolean` | `true` | Whether to auto-resolve metadata from the Oracle repository for classpath dependencies | | `version` | `String` | `"0.10.6"` | Version of the metadata repository artifact | | `excludedModules` | `SetProperty` | empty | Module coordinates (`group:artifact`) to exclude from repository resolution | | `moduleToConfigVersion` | `MapProperty` | empty | Override the metadata version for specific modules (key: `group:artifact`, value: version directory) | ## Recommended build arguments | Argument | Purpose | |----------|---------| | `-H:+AddAllCharsets` | Include all character sets (required for text I/O) | | `-Djava.awt.headless=false` | Enable GUI support (mandatory for desktop apps) | | `-Os` | Optimize for binary size | | `-H:-IncludeMethodData` | Reduce binary size by excluding method metadata | ## No Release Build Type Unlike standard JVM builds, GraalVM native-image builds **do not have a release variant**. There is no `packageReleaseGraalvmNative` or `runReleaseGraalvmNative` task. This is intentional: - **ProGuard is redundant** — GraalVM native-image already performs closed-world dead code elimination at compile time. Running ProGuard beforehand provides no additional size benefit. - **ProGuard is harmful** — ProGuard can rename or remove classes that are referenced in `reachability-metadata.json`, causing runtime crashes. Maintaining both ProGuard keep rules and reflection metadata is error-prone. All GraalVM tasks use the default (non-ProGuard) build type. Use `-Os` in `buildArgs` for size optimization. --- # Automatic Metadata Resolution The goal of Potassium is to make GraalVM native-image compilation **as transparent as possible**. In most cases, you should be able to run `packageGraalvmNative` and get a working binary without writing a single line of reflection configuration. To achieve this, Potassium combines five complementary metadata sources that are resolved and merged automatically at build time. ## Level 1 — Per-library conditional metadata The Potassium Gradle plugin ships **28 per-library metadata files** covering Compose UI, Skiko, ktor, kotlinx.serialization, SQLite, Coil, JNA, FileKit, and many others. Each file declares a `matchPackages` condition — the metadata is only included if the corresponding library is actually present on your runtime classpath. This means libraries like ktor or SQLite JDBC **just work** in native image without any manual configuration. ## Level 2 — Oracle Reachability Metadata Repository Potassium automatically downloads the [Oracle GraalVM Reachability Metadata Repository](https://github.com/oracle/graalvm-reachability-metadata) and resolves metadata for all dependencies on your runtime classpath. This covers libraries that are not yet covered by Potassium's own L1 metadata — SLF4J, Logback, and many others. The resolved metadata directories are passed to `native-image` via `-H:ConfigurationFileDirectories=`. This is enabled by default. To customize: ```kotlin graalvm { metadataRepository { enabled = true // disable with false version = "0.10.6" // override repository version excludedModules.add("group:artifact") // skip specific dependencies moduleToConfigVersion.put( // pin a specific metadata version "io.ktor:ktor-client-core", "3.0.0", ) } } ``` ## Level 3 — Platform-specific metadata The Potassium Gradle plugin ships pre-built platform-specific metadata for macOS, Windows, and Linux. These cover platform-specific AWT implementations (`sun.awt.windows.*`, `sun.lwawt.macosx.*`, `sun.awt.X11.*`), Java2D pipelines, font managers, and security providers. The plugin writes the correct platform metadata to the build directory at compile time — **no per-platform configuration needed in your build script**. ## Level 4 — Static bytecode analysis Potassium includes a **static bytecode analyzer** that scans all compiled classes on your runtime classpath at build time and automatically detects reflection, JNI, and resource requirements — without running the application. The analyzer detects: - **Native methods** and their parameter/return types (JNI metadata) - **`Class.forName()`** and **`MethodHandles.Lookup.findClass()`** calls (reflection metadata) - **`getResource()` / `getResourceAsStream()`** calls (resource metadata) - **JNI callback parameters** — classes passed to native code that call back into Java - **JNI superclass chains** — parent classes needed for field access from native code - **`@Serializable` classes** — automatically emits `Companion.serializer()` reflection entries This analysis runs transparently as part of the build (the `analyzeGraalvmStaticMetadata` task) and its output is passed to `native-image` alongside the other metadata levels. ## Level 5 — Generic cross-platform metadata The `graalvm-runtime` module ships a `reachability-metadata.json` inside its JAR that covers all cross-platform reflection entries: Compose Desktop, AWT/Swing, Skiko, security providers, font managers, and more (~300+ types). This metadata is **automatically picked up** by native-image from the classpath — no configuration needed. ## How it all fits together When you run `packageGraalvmNative`, Potassium automatically resolves all five metadata levels and passes them to `native-image`: All of this happens transparently — no manual steps required. The result is that **most applications compile and run as native images without any manual reflection configuration**. ## The tracing agent — a final safety net Even with five levels of automatic metadata, there can be edge cases that static analysis cannot catch: reflection driven by runtime values, dynamically loaded classes, or unusual library patterns. The tracing agent (`runWithNativeAgent`) remains available as a **final verification step**: ```bash ./gradlew runWithNativeAgent ``` During the tracing run, navigate through every screen and feature of your application. The agent records all reflection, JNI, resource, and proxy accesses and **merges** the results into your existing configuration. Entries already covered by the five metadata levels are **automatically deduplicated** — the agent output stays minimal. In many cases, the agent will find nothing new — the automatic metadata already covers everything. But running it once before release is a good safety net, especially for applications with complex library dependencies. ## Cleaning up manual metadata If you accumulated manual entries in your `reachability-metadata.json` that are now covered by the automatic metadata levels, you can clean them up: ```bash ./gradlew cleanupGraalvmMetadata ``` This task compares your manual entries against the combined baseline (L1 + L2 + L3 + L4) and removes any that are already covered. It reports what was removed and what remains, so you can verify the cleanup is safe. --- # Runtime Bootstrap ## `graalvm-runtime` module The `graalvm-runtime` module provides everything needed to bootstrap a Compose Desktop application in a GraalVM native image. Add it to your dependencies: ```kotlin dependencies { implementation("io.github.kdroidfilter:potassium.graalvm-runtime:") } ``` Then call `GraalVmInitializer.initialize()` as the **first line** of your `main()` function, before any AWT or Compose usage: ```kotlin import com.seanproctor.potassium.graalvm.GraalVmInitializer fun main() { GraalVmInitializer.initialize() application { Window(onCloseRequest = ::exitApplication, title = "MyApp") { App() } } } ``` The initializer handles all of the following automatically: | Concern | What it does | |---------|--------------| | **Metal L&F** | Sets `swing.defaultlaf` to avoid unsupported platform modules | | **`java.home`** | Points to the executable directory so Skiko finds jawt | | **`java.library.path`** | Sets `execDir` + `execDir/bin` so fontmanager/freetype/awt are discoverable | | **Charset init** | Forces early `Charset.defaultCharset()` to prevent `InternalError: platform encoding not initialized` | | **Fontmanager preload** | Calls `System.loadLibrary("fontmanager")` early to avoid crashes in `Font.createFont()` | | **Linux HiDPI** | Detects and applies the native scale factor on Linux (works in both JVM and native image) | The native-image-specific steps only run when `org.graalvm.nativeimage.imagecode` is set. The Linux HiDPI detection runs unconditionally (it's a no-op on non-Linux platforms). You can also check `GraalVmInitializer.isNativeImage` at any point to branch on native-image vs JVM execution. ## Font substitutions The module ships GraalVM `@TargetClass` substitutions (Java source files) that fix font-related crashes in native image on Windows and Linux: - **`FontCreateFontSubstitution`** — Buffers `Font.createFont(int, InputStream)` to a temp file on Windows, working around streams that lack mark/reset support in native image. - **`Win32FontManagerSubstitution`** — Replaces `Win32FontManager.getFontPath()` with a pure-Java implementation, fixing `InternalError: platform encoding not initialized`. - **`FcFontManagerSubstitution`** — Fixes `FcFontManager.getFontPath()` on Linux native image. These substitutions are automatically picked up by the native-image compiler — no configuration needed. ## Automatic Resource Inclusion One of the most common pitfalls with GraalVM native-image is **missing resources at runtime**. Icons, fonts, and service descriptors must be explicitly registered — otherwise `Class.getResource()` returns `null` and your UI renders blank icons. The `graalvm-runtime` module solves this automatically. It ships a `native-image.properties` file that registers broad resource patterns at compile time: | Pattern | What it covers | |---------|----------------| | `.*\.(svg\|ttf\|otf)` | All SVG icons and font files on the classpath — Jewel, IntelliJ Platform icons, Compose resources, your own icons | | `composeResources/.*` | All Compose Multiplatform resources (images, strings, fonts loaded via `Res.*`) | | `potassium/native/.*` | All Potassium JNI native libraries (`.dll`, `.dylib`, `.so`) | | `META-INF/services/.*` | All `ServiceLoader` descriptors (ktor, coil, SLF4J, etc.) | This means: - **All SVG icons work out of the box** — Jewel's `PathIconKey`, `AllIconsKeys`, dark/light variants, `@2x` retina variants — everything is included automatically. - **All fonts are embedded** — Inter, JetBrains Mono, or any custom `.ttf`/`.otf` in your dependencies. - **All Compose Multiplatform resources are included** — images, strings, and other resources loaded via the `Res` API. - **Service loaders resolve correctly** — ktor engines, coil fetchers, SLF4J providers, etc. **Binary size trade-off:** The glob pattern `.*\.(svg|ttf|otf)` includes **all** SVGs and fonts from **all** JARs on the classpath. If you depend on the IntelliJ Platform icons library, this may add several megabytes of icons you don't actually use. For most applications, the convenience far outweighs the size increase. If binary size is critical, you can override with more targeted patterns in your own `resource-config.json`. ## Decorated Window The JNI-based decorated-window backend was specifically designed to work with GraalVM Native Image (no JBR dependency). Use it instead of the JBR-based backend for native-image builds. --- # Tasks & CI/CD ## Gradle Tasks | Task | Description | |------|-------------| | `runWithNativeAgent` | Run the app with the GraalVM tracing agent to collect reflection metadata | | `analyzeGraalvmStaticMetadata` | Scan compiled bytecode for reflection/JNI/resource patterns (runs automatically) | | `filterGraalvmLibraryMetadata` | Filter per-library metadata based on actual classpath (runs automatically) | | `resolveGraalvmReachabilityMetadata` | Resolve Oracle Reachability Metadata Repository for classpath dependencies (runs automatically) | | `generateGraalvmPlatformMetadata` | Generate platform-specific metadata for the current OS (runs automatically) | | `cleanupGraalvmMetadata` | Remove manual entries already covered by automatic metadata | | `packageGraalvmNative` | Compile and package the application as a native binary | | `runGraalvmNative` | Build and run the native image directly | | `packageGraalvmLinux` | Package the native image as a `.deb` installer (Linux) | | `packageGraalvmMacOS` | Package the native image as a `.dmg` installer (macOS) | | `packageGraalvmWindows` | Package the native image as an NSIS `.exe` installer (Windows) | The tasks marked "runs automatically" are dependencies of `packageGraalvmNative` — you don't need to invoke them manually. They are listed here for reference and debugging. ```bash # Build the raw native image (triggers all automatic metadata tasks) ./gradlew packageGraalvmNative # Build and run the native image ./gradlew runGraalvmNative # Build platform-specific installers (requires Node.js for electron-builder) ./gradlew packageGraalvmLinux # Linux ./gradlew packageGraalvmMacOS # macOS ./gradlew packageGraalvmWindows # Windows # NOTE: The `homepage` property is required for DEB packaging. # electron-builder will fail without it. See Configuration > Package Metadata. # Optional: collect agent metadata as a final check ./gradlew runWithNativeAgent # Optional: clean up redundant manual entries ./gradlew cleanupGraalvmMetadata ``` Use `-PnativeMarch=compatibility` for binaries that should run on older CPUs: ```bash ./gradlew packageGraalvmNative -PnativeMarch=compatibility ``` ## Output location The raw native binary and its companion shared libraries are generated in: ``` /build/potassium/tmp//graalvm/output/ ``` | Platform | Output | |----------|--------| | **macOS** | `output/MyApp.app/` (full `.app` bundle with `Info.plist`, icons, signed dylibs) | | **Windows** | `output/my-app.exe` + companion DLLs (`awt.dll`, `fontmanager.dll`, etc.) | | **Linux** | `output/my-app` + companion `.so` files (`libawt.so`, `libfontmanager.so`, etc.) | The `packageGraalvm` tasks produce installers in: ``` /build/potassium/binaries//graalvm-/ ``` ## CI/CD Native image compilation must happen **on each target platform**. Use `setup-potassium` with `graalvm: 'true'`: ```yaml name: Build GraalVM Native Image on: push: tags: ["v*"] jobs: build-natives: uses: ./.github/workflows/build-natives.yaml graalvm: needs: build-natives name: GraalVM - ${{ matrix.name }} runs-on: ${{ matrix.os }} timeout-minutes: 60 strategy: fail-fast: false matrix: include: - name: Linux x64 os: ubuntu-latest - name: macOS ARM64 os: macos-latest - name: Windows x64 os: windows-latest steps: - uses: actions/checkout@v4 # Download pre-built JNI native libraries here... - name: Setup Potassium (GraalVM) uses: kdroidFilter/Nucleus/.github/actions/setup-potassium@main with: graalvm: 'true' setup-gradle: 'true' setup-node: 'true' # Required for packageGraalvm tasks - name: Build GraalVM native packages shell: bash run: | if [ "$RUNNER_OS" = "Linux" ]; then ./gradlew :myapp:packageGraalvmLinux \ -PnativeMarch=compatibility --no-daemon elif [ "$RUNNER_OS" = "macOS" ]; then ./gradlew :myapp:packageGraalvmMacOS \ -PnativeMarch=compatibility --no-daemon elif [ "$RUNNER_OS" = "Windows" ]; then ./gradlew :myapp:packageGraalvmWindows \ -PnativeMarch=compatibility --no-daemon fi - uses: actions/upload-artifact@v4 with: name: graalvm-${{ runner.os }} path: myapp/build/potassium/binaries/**/graalvm-*/** ``` See [CI/CD](../ci-cd.md#graalvm-native-image-release) for the full release workflow with publishing to GitHub Releases. ## Debugging ### Missing reflection at runtime Run your native binary from the terminal. Reflection failures produce clear error messages like `Class not found` or `No such field`. If you encounter a crash: 1. Run `./gradlew runWithNativeAgent`, navigate through the failing code path, and let the agent capture the missing entry 2. Agent output is automatically deduplicated — only truly new entries are added 3. Rebuild with `./gradlew packageGraalvmNative` ### Cleaning up accumulated metadata Over time, manual `reachability-metadata.json` entries may become redundant as Potassium adds coverage for more libraries. Run the cleanup task periodically: ```bash ./gradlew cleanupGraalvmMetadata ``` The task reports exactly which entries were removed and which remain, so you can verify the cleanup is safe before committing. ## Best Practices ### Test on all platforms early Don't wait until the end to test native-image on all three platforms. Each platform has its own set of reflection requirements and quirks. Test early and often. ### Run the agent once before release Even though the automatic metadata covers the vast majority of cases, running `runWithNativeAgent` once before a release is a good habit. In most cases it will find nothing new, but it costs little and provides confidence. ### Use the Jewel sample as reference The [`jewel-sample`](https://github.com/kdroidFilter/Nucleus/tree/main/jewel-sample) in the Potassium repository demonstrates a more complex native-image setup with the Jewel UI library. It is an excellent reference for advanced use cases. ## Further Reading - [GraalVM Native Image documentation](https://www.graalvm.org/latest/reference-manual/native-image/) - [BellSoft Liberica NIK](https://bell-sw.com/liberica-native-image-kit/) - [Oracle GraalVM Reachability Metadata Repository](https://github.com/oracle/graalvm-reachability-metadata) - [Potassium example app](https://github.com/kdroidFilter/Nucleus/tree/main/example) — minimal Compose Desktop + native-image setup - [Potassium Jewel sample](https://github.com/kdroidFilter/Nucleus/tree/main/jewel-sample) — advanced setup with reflection-heavy dependencies --- # Nucleus Native Access Every now and then, no runtime library covers your exact native API need. Potassium handles the common cases with JNI — but when you need something specific (a platform API, a custom algorithm, a C library), the usual path involves writing JNI glue in C, building a `.so`/`.dylib`/`.dll`, bundling it, and wiring it up from Kotlin. That's a lot of friction for what should be a simple call. **Nucleus Native Access** removes that friction. Write your native logic in **Kotlin/Native**, and the plugin generates the FFM bridge automatically. No C, no build scripts, no manual JNI plumbing — just Kotlin on both sides. **FFM, not JNI:** Potassium's built-in runtime libraries (decorated windows, dark mode, notifications…) use **JNI** for broad compatibility. Nucleus Native Access uses the **Foreign Function & Memory (FFM) API** (JEP 454, stable since JDK 22). Both are valid approaches, but FFM lets you write the native side in pure Kotlin rather than C. ## How It Works The plugin: 1. Analyzes sources via **Kotlin PSI** 2. Generates `@CName` bridge functions (native side) 3. Generates FFM `MethodHandle` proxies (JVM side) 4. Compiles to `.so` / `.dylib` / `.dll` 5. Bundles into JAR under `kne/native/{os}-{arch}/` The generated JVM proxies have **the exact same API** as your native classes — same names, same types, same method signatures. No wrapper types, no casting, no boilerplate. ## Setup **Separate versioning:** Nucleus Native Access is versioned independently from Potassium. Check the latest version on the [NucleusNativeAccess repository](https://github.com/kdroidFilter/NucleusNativeAccess). Add the plugin to your Kotlin Multiplatform module: ```kotlin // build.gradle.kts plugins { kotlin("multiplatform") id("io.github.kdroidfilter.nucleusnativeaccess") version "" // see github.com/kdroidFilter/NucleusNativeAccess } kotlin { jvmToolchain(25) // FFM requires JDK 22+; JDK 25 recommended macosArm64() // or macosX64(), linuxX64(), mingwX64() jvm() } kotlinNativeExport { nativeLibName = "mylib" nativePackage = "com.example.mylib" } ``` That's the entire configuration. The plugin handles compilation, bundling, and loading automatically. **JDK requirement:** FFM is stable from **JDK 22+**. JDK 25 is recommended. When running tests or the app, the JVM arg `--enable-native-access=ALL-UNNAMED` is required — the plugin adds it automatically for tests. ### Using with Compose Desktop The Compose compiler plugin doesn't support arbitrary Kotlin/Native targets (e.g. `linuxX64`, `mingwX64`) used for FFM bridges. **Put your native code in a separate Gradle module** without the Compose compiler plugin: ``` my-app/ ├── native/ ← Kotlin/Native + nucleusnativeaccess (no Compose) │ └── build.gradle.kts ├── app/ ← Compose Desktop + Potassium, depends on :native │ └── build.gradle.kts └── settings.gradle.kts ``` **`:native/build.gradle.kts`**: ```kotlin plugins { kotlin("multiplatform") id("io.github.kdroidfilter.nucleusnativeaccess") version "" } kotlin { jvmToolchain(25) linuxX64() // or macosArm64(), mingwX64() jvm() } kotlinNativeExport { nativeLibName = "mylib" nativePackage = "com.example.mylib" } ``` **`:app/build.gradle.kts`**: ```kotlin plugins { kotlin("multiplatform") id("org.jetbrains.compose") id("org.jetbrains.kotlin.plugin.compose") id("com.seanproctor.potassium") version "0.1.0" } kotlin { jvmToolchain(25) jvm() sourceSets { val jvmMain by getting { dependencies { implementation(compose.desktop.currentOs) implementation(project(":native")) } } } } potassium { mainClass = "com.example.MainKt" jvmArgs += listOf("--enable-native-access=ALL-UNNAMED") } ``` ## GraalVM Native Image Nucleus Native Access includes full GraalVM metadata generation: - `reflect-config.json` for all generated proxy classes - `resource-config.json` for bundled native libraries - `reachability-metadata.json` for FFM descriptors No manual configuration needed — the generated metadata is picked up automatically by the [Potassium GraalVM plugin](../graalvm/index.md). ## Repository Nucleus Native Access is maintained in a separate repository with its own release cycle: [**kdroidFilter/NucleusNativeAccess**](https://github.com/kdroidFilter/NucleusNativeAccess) — plugin source, examples, full documentation, and latest releases. The plugin ID is `io.github.kdroidfilter.nucleusnativeaccess`. ## Next steps - [Supported Types](types.md) — Full type mapping reference, declarations, and current limitations - [Usage & Patterns](usage.md) — Real-world examples, coroutines, flows, object lifecycle --- # Supported Types ## Type Mapping | Type | As param | As return | As property | Notes | |------|----------|-----------|-------------|-------| | `Int`, `Long`, `Double`, `Float`, `Boolean`, `Byte`, `Short` | ✅ | ✅ | ✅ | Direct pass-through | | `String` | ✅ | ✅ | ✅ | UTF-8 output-buffer pattern | | `ByteArray` | ✅ | ✅ | — | Pointer + size; suspend, callbacks, DC fields, collections | | `enum class` | ✅ | ✅ | ✅ | Ordinal mapping | | `data class` | ✅ | ✅ | ✅ | Fields: primitives, String, ByteArray, Enum, Object, nested DC, List, Set, Map | | `Object` (class instances) | ✅ | ✅ | ✅ | Opaque `StableRef` handle, lifecycle tracked | | Nested classes | ✅ | ✅ | ✅ | Exported as `Outer_Inner`, up to 3+ nesting levels | | `T?` (nullable) | ✅ | ✅ | ✅ | Sentinel-based null encoding | | `List`, `Set` | ✅ | ✅ | ✅ | All element types incl. DataClass, ByteArray, nested collections | | `Map` | ✅ | ✅ | ✅ | Parallel key + value arrays | | `List>` | ✅ | ✅ | — | Nested collections via StableRef handles | | `(T) -> R` (lambda) | ✅ | ✅ | — | FFM upcall/downcall stubs; nullable `((T) -> R)?` supported | | `suspend fun` | — | ✅ | — | All return types: primitives, String, ByteArray, DataClass, List, Set, Map | | `Flow` | — | ✅ | — | All element types: primitives, String, ByteArray, DataClass, List, Set, Map | ## Declarations | Feature | Notes | |---------|-------| | Classes | `StableRef` lifecycle, `AutoCloseable` on JVM | | Open / abstract classes | `open class Shape` → JVM `open class Shape`, hierarchy mirrored | | Inheritance | `class Circle : Shape` → JVM `class Circle : Shape(handle)`, multi-level (3+) | | Interfaces | `interface Measurable` → JVM `interface Measurable`, multi-interface impl | | Sealed classes | `sealed class Result` → JVM `sealed class`, subclass ordinal bridges | | Extension functions | `fun Shape.displayName()` → real Kotlin extension on JVM proxy | | Constructor `val`/`var` params | Exposed as properties with getters (and setters for `var`) | | Companion objects | Static methods and properties on JVM proxy | | Top-level functions | Grouped into a singleton `object` on JVM | ## Not yet supported | Feature | Notes | |---------|-------| | Generics (`class Box`) | Use concrete types or collections | | Interface / sealed class as return type | Methods must return the concrete type | | Operator overloading, infix functions | Use named methods | | `ByteArray` in collections / data class fields / callback params | Use `List` or Base64 String | | Subclassing from JVM | Subclass on native side instead | | CInterop types in public API (`CPointer`, etc.) | Wrap behind a clean Kotlin API | --- # Usage & Patterns ## Example: Take a Screenshot on macOS Here's a real-world example: capturing the screen using macOS's CoreGraphics API. This is a platform API with no JVM equivalent — the kind of thing that would normally require JNI C glue. **Native side** (`src/nativeMain/kotlin/com/example/screen/SystemDesktop.kt`): ```kotlin // suspend — runs off the main thread, returns PNG bytes actual suspend fun captureScreen(): ByteArray = memScoped { if (!CGPreflightScreenCaptureAccess()) { CGRequestScreenCaptureAccess() return@memScoped ByteArray(0) } val rect = alloc().apply { origin.x = CGRectInfinite.origin.x origin.y = CGRectInfinite.origin.y size.width = CGRectInfinite.size.width size.height = CGRectInfinite.size.height } val cgImage = CGWindowListCreateImage( rect.readValue(), kCGWindowListOptionOnScreenOnly, kCGNullWindowID, kCGWindowImageDefault, ) ?: return@memScoped ByteArray(0) // Encode as PNG — NSBitmapImageRep handles all the pixel format details val bitmapRep = NSBitmapImageRep(cGImage = cgImage) CGImageRelease(cgImage) val pngData = bitmapRep.representationUsingType( NSBitmapImageFileTypePNG, properties = emptyMap(), ) ?: return@memScoped ByteArray(0) ByteArray(pngData.length.toInt()) { i -> (pngData.bytes!!.reinterpret() + i)!!.pointed.value } } ``` **JVM + Compose side** — the plugin generates the proxy, you just use it: ```kotlin import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* import androidx.compose.material.Button import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import com.example.screen.SystemDesktop import kotlinx.coroutines.launch import org.jetbrains.skia.Image as SkiaImage @Composable fun ScreenshotViewer() { val desktop = remember { SystemDesktop() } var bitmap by remember { mutableStateOf(null) } var capturing by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { Button( onClick = { capturing = true scope.launch { val bytes = desktop.captureScreen() // suspend — UI never blocks if (bytes.isNotEmpty()) { bitmap = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap() } capturing = false } }, enabled = !capturing, ) { Text(if (capturing) "Capturing…" else "Capture Screen") } bitmap?.let { Image( bitmap = it, contentDescription = "Screenshot", contentScale = ContentScale.FillWidth, modifier = Modifier.fillMaxWidth(), ) } } } ``` **No C. No JNI headers. No build scripts. No `System.loadLibrary` call.** The `.dylib` is compiled by the plugin, bundled in the JAR, and extracted automatically at runtime. The `suspend` on the native side maps transparently to a coroutine on the JVM — the UI stays responsive while CoreGraphics does the work. **Full working example:** The [systeminfo example](https://github.com/kdroidFilter/NucleusNativeAccess/tree/main/examples/systeminfo) in the NucleusNativeAccess repo implements this pattern for all three platforms (CoreGraphics on macOS, XDG ScreenCast + PipeWire on Linux, GDI on Windows), plus native notifications, a system tray menu, and real-time memory updates via `Flow`. The same pattern works for any other platform API: ### macOS ```kotlin // Access NSWorkspace, IOKit, CoreBluetooth, AVFoundation, Metal… import platform.AppKit.* import platform.IOKit.* ``` ### Windows ```kotlin // Access Win32, WinRT, DirectX, COM interfaces… import platform.windows.* ``` ### Linux ```kotlin // Access POSIX, D-Bus, GTK, libnotify… import platform.posix.* import platform.linux.* ``` ## Using Top-Level Functions You don't have to wrap everything in a class. Top-level functions are grouped into a singleton `object` named after `nativeLibName` (first letter uppercased): ```kotlin // build.gradle.kts kotlinNativeExport { nativeLibName = "utils" // → object Utils { … } nativePackage = "com.example.utils" } ``` ```kotlin // nativeMain — top-level function package com.example.utils fun currentProcessId(): Int = platform.posix.getpid() ``` ```kotlin // jvmMain — generated object import com.example.utils.Utils val pid = Utils.currentProcessId() ``` ## Object Lifecycle Generated proxy classes implement `AutoCloseable`. Native memory is freed on `close()`, or automatically when garbage collected (via Java `Cleaner`): ```kotlin // Preferred — explicit, deterministic ScreenCapture().use { capture -> val bytes = capture.captureScreen() // ... } // Also valid — Cleaner will release when GC runs val capture = ScreenCapture() val bytes = capture.captureScreen() ``` ## Coroutines and Flows Suspend functions and `Flow` are transparent — no callbacks, no `CompletableFuture`, just coroutines on both sides: ```kotlin // nativeMain suspend fun fetchData(query: String): String { delay(100) return "result: $query" } fun eventStream(max: Int): Flow = flow { for (i in 1..max) { delay(10); emit(i) } } ``` ```kotlin // jvmMain — identical API val result = MyLib.fetchData("hello") // suspends, doesn't block MyLib.eventStream(100) .take(5) // cancels the native Flow automatically at 5 elements .collect { println(it) } ``` Cancellation is bidirectional: cancelling the JVM `Job` cancels the native coroutine, and vice versa. --- # CI/CD Potassium provides reusable composite actions and ready-to-use GitHub Actions workflows for building, packaging, and publishing desktop applications across all platforms. **Use Potassium actions in your own project:** All composite actions can be referenced directly from the Potassium repository — no need to copy them into your project: ```yaml - uses: kdroidFilter/Nucleus/.github/actions/setup-potassium@main ``` Replace `@main` with a specific tag (e.g. `@v1.0.0`) to pin a stable version. ## Overview A typical release pipeline has four stages: ## `setup-potassium` Action The `setup-potassium` composite action (`.github/actions/setup-potassium`) sets up the complete build environment: JetBrains Runtime 25, packaging tools, Gradle, and Node.js — all cross-platform. ### Usage ```yaml - uses: kdroidFilter/Nucleus/.github/actions/setup-potassium@main with: jbr-version: '25.0.2b329.66' packaging-tools: 'true' flatpak: 'true' snap: 'true' setup-gradle: 'true' setup-node: 'true' ``` ### Inputs | Input | Default | Description | |-------|---------|-------------| | `jbr-version` | `25.0.2b329.66` | JBR version (e.g. `25.0.2b329.66`) | | `jbr-variant` | `jbrsdk` | JBR variant (`jbrsdk`, `jbrsdk_jcef`, etc.) | | `jbr-download-url` | — | Override complete JBR download URL (bypasses version/variant) | | `graalvm` | `false` | Use GraalVM (Liberica NIK) instead of JBR | | `graalvm-java-version` | `25` | GraalVM Java version (when `graalvm` is `true`) | | `packaging-tools` | `true` | Install xvfb, rpm, fakeroot, patchelf, libx11-dev, libdbus-1-dev (Linux only) | | `flatpak` | `false` | Install Flatpak + Freedesktop SDK 24.08 (Linux only) | | `snap` | `false` | Install Snapd + Snapcraft (Linux only) | | `setup-gradle` | `true` | Setup Gradle via `gradle/actions/setup-gradle@v4` | | `setup-node` | `true` | Setup Node.js (needed for electron-builder) | | `node-version` | `20` | Node.js version when `setup-node` is `true` | ### Outputs | Output | Description | |--------|-------------| | `java-home` | Path to the JBR installation | ### GraalVM Mode When `graalvm: 'true'` is set, the action installs **BellSoft Liberica NIK** instead of JBR, plus platform-specific toolchains: ```yaml - uses: kdroidFilter/Nucleus/.github/actions/setup-potassium@main with: graalvm: 'true' setup-gradle: 'true' setup-node: 'true' ``` This automatically: - Installs **Liberica NIK 25** via `graalvm/setup-graalvm@v1` - Selects **Xcode 26** on macOS - Sets up **MSVC** on Windows via `ilammy/msvc-dev-cmd@v1` - Skips JBR installation entirely ### What It Does The action automatically: - Downloads and installs **JBR 25** (or **Liberica NIK 25** in GraalVM mode) for the current platform and architecture - Sets `JAVA_HOME` and adds the JDK to `PATH` - Installs Linux packaging tools (`xvfb`, `rpm`, `fakeroot`, `patchelf`, `libx11-dev`, `libdbus-1-dev`) and starts Xvfb with `DISPLAY=:99` - Installs Flatpak + Freedesktop SDK 24.08 (if enabled) - Installs Snapd + Snapcraft (if enabled) - Sets up Gradle caching via `gradle/actions/setup-gradle@v4` - Sets up Node.js (if enabled) ## Release Build Build native packages for all platforms on tag push. ### Build Matrix ```yaml # .github/workflows/release.yaml name: Release Desktop App (All Platforms) on: push: tags: ['v*'] workflow_dispatch: permissions: contents: write concurrency: group: release-${{ github.ref }} cancel-in-progress: false jobs: build: name: Build (${{ matrix.os }} / ${{ matrix.arch }}) runs-on: ${{ matrix.os }} timeout-minutes: 120 strategy: fail-fast: false matrix: include: # Linux - os: ubuntu-latest arch: amd64 - os: ubuntu-24.04-arm arch: arm64 # Windows - os: windows-latest arch: amd64 - os: windows-11-arm arch: arm64 # macOS - os: macos-latest arch: arm64 - os: macos-15-intel arch: amd64 env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_VERSION: ${{ github.ref_name }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Normalize version for manual runs if: github.event_name == 'workflow_dispatch' shell: bash run: | set -euo pipefail tag="$(git describe --tags --abbrev=0)" echo "RELEASE_VERSION=$tag" >> "$GITHUB_ENV" - name: Setup Potassium uses: kdroidFilter/Nucleus/.github/actions/setup-potassium@main with: jbr-version: '25.0.2b329.66' packaging-tools: 'true' flatpak: 'true' snap: 'true' setup-gradle: 'true' setup-node: 'true' - name: Build packages shell: bash run: ./gradlew packageReleaseDistributionForCurrentOS --stacktrace --no-daemon - uses: actions/upload-artifact@v4 with: name: release-assets-${{ runner.os }}-${{ matrix.arch }} path: | build/potassium/binaries/**/*.dmg build/potassium/binaries/**/*.pkg build/potassium/binaries/**/*.exe build/potassium/binaries/**/*.msi build/potassium/binaries/**/*.appx build/potassium/binaries/**/*.deb build/potassium/binaries/**/*.rpm build/potassium/binaries/**/*.AppImage build/potassium/binaries/**/*.snap build/potassium/binaries/**/*.flatpak build/potassium/binaries/**/*.zip build/potassium/binaries/**/*.tar build/potassium/binaries/**/*.7z build/potassium/binaries/**/*.blockmap build/potassium/binaries/**/signing-metadata.json build/potassium/binaries/**/packaging-metadata.json !build/potassium/binaries/**/app/** !build/potassium/binaries/**/runtime/** if-no-files-found: error ``` ### Custom JBR URL (per-matrix entry) You can override the JBR download URL for specific matrix entries. This is useful for custom JBR builds (e.g. with RTL patches): ```yaml matrix: include: - os: macos-latest arch: arm64 jbr-download-url: 'https://example.com/jbr-25-macos-aarch64-custom.tar.gz' - os: macos-15-intel arch: amd64 jbr-download-url: 'https://example.com/jbr-25-macos-x64-custom.tar.gz' steps: - uses: kdroidFilter/Nucleus/.github/actions/setup-potassium@main with: jbr-version: '25.0.2b329.66' jbr-download-url: ${{ matrix.jbr-download-url || '' }} ``` ### Version from Tag The `RELEASE_VERSION` environment variable is set from the Git tag. In your `build.gradle.kts`: ```kotlin val releaseVersion = System.getenv("RELEASE_VERSION") ?.removePrefix("v") ?.takeIf { it.isNotBlank() } ?: "1.0.0" potassium { packageVersion = releaseVersion } ``` ## Universal macOS Binaries Merge arm64 and x64 builds into a universal (fat) binary using `lipo`, then optionally sign and notarize. Potassium includes reusable composite actions (`setup-macos-signing` and `build-macos-universal`): ```yaml universal-macos: name: Universal macOS Binary needs: [build] if: needs.build.result == 'success' runs-on: macos-latest timeout-minutes: 45 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '20' # Setup signing (conditional — skipped if secrets not configured) - name: Setup macOS signing id: signing if: ${{ secrets.MAC_CERTIFICATES_P12 != '' }} uses: kdroidFilter/Nucleus/.github/actions/setup-macos-signing@main with: certificate-base64: ${{ secrets.MAC_CERTIFICATES_P12 }} certificate-password: ${{ secrets.MAC_CERTIFICATES_PASSWORD }} # Decode provisioning profiles for App Store PKG - name: Decode provisioning profiles if: ${{ secrets.MAC_PROVISIONING_PROFILE != '' }} shell: bash run: | echo "${{ secrets.MAC_PROVISIONING_PROFILE }}" | base64 -d > "$RUNNER_TEMP/embedded.provisionprofile" echo "PROVISIONING=$RUNNER_TEMP/embedded.provisionprofile" >> "$GITHUB_ENV" if [[ -n "${{ secrets.MAC_RUNTIME_PROVISIONING_PROFILE }}" ]]; then echo "${{ secrets.MAC_RUNTIME_PROVISIONING_PROFILE }}" | base64 -d > "$RUNNER_TEMP/runtime-embedded.provisionprofile" echo "RUNTIME_PROVISIONING=$RUNNER_TEMP/runtime-embedded.provisionprofile" >> "$GITHUB_ENV" fi - uses: actions/download-artifact@v4 with: name: release-assets-macOS-arm64 path: artifacts/release-assets-macOS-arm64 - uses: actions/download-artifact@v4 with: name: release-assets-macOS-amd64 path: artifacts/release-assets-macOS-amd64 - name: Build universal binary uses: kdroidFilter/Nucleus/.github/actions/build-macos-universal@main with: arm64-path: artifacts/release-assets-macOS-arm64 x64-path: artifacts/release-assets-macOS-amd64 output-path: artifacts/release-assets-macOS-universal signing-identity: ${{ secrets.MAC_DEVELOPER_ID_APPLICATION }} app-store-identity: ${{ secrets.MAC_APP_STORE_APPLICATION }} installer-identity: ${{ secrets.MAC_APP_STORE_INSTALLER }} keychain-path: ${{ steps.signing.outputs.keychain-path }} entitlements-file: example/packaging/macos/entitlements.plist runtime-entitlements-file: example/packaging/macos/runtime-entitlements.plist provisioning-profile: ${{ env.PROVISIONING }} runtime-provisioning-profile: ${{ env.RUNTIME_PROVISIONING }} # Notarize DMG and ZIP (conditional) - name: Notarize DMG if: ${{ secrets.MAC_NOTARIZATION_APPLE_ID != '' }} run: | DMG="$(find artifacts/release-assets-macOS-universal -name '*.dmg' -type f | head -1)" xcrun notarytool submit "$DMG" \ --apple-id "${{ secrets.MAC_NOTARIZATION_APPLE_ID }}" \ --password "${{ secrets.MAC_NOTARIZATION_PASSWORD }}" \ --team-id "${{ secrets.MAC_NOTARIZATION_TEAM_ID }}" --wait xcrun stapler staple "$DMG" - name: Cleanup keychain if: always() && steps.signing.outputs.keychain-path != '' run: security delete-keychain "${{ steps.signing.outputs.keychain-path }}" || true - uses: actions/upload-artifact@v4 with: name: release-assets-macOS-universal path: artifacts/release-assets-macOS-universal if-no-files-found: error ``` ### `build-macos-universal` Inputs | Input | Required | Description | |-------|----------|-------------| | `arm64-path` | Yes | Directory with arm64 artifacts | | `x64-path` | Yes | Directory with x64 artifacts | | `output-path` | No | Output directory (default: `universal-output`) | | `signing-identity` | No | Developer ID Application identity for DMG/ZIP signing | | `app-store-identity` | No | 3rd Party Mac Developer Application identity for App Store PKG | | `installer-identity` | No | 3rd Party Mac Developer Installer identity for PKG signing | | `keychain-path` | No | Path to keychain from `setup-macos-signing` | | `entitlements-file` | No | Path to `entitlements.plist` | | `runtime-entitlements-file` | No | Path to `runtime-entitlements.plist` | | `provisioning-profile` | No | Path to `embedded.provisionprofile` for sandboxed app | | `runtime-provisioning-profile` | No | Path to runtime provisioning profile | ## Windows MSIX Bundle Combine amd64 and arm64 `.appx` files into a single `.msixbundle`. Potassium includes a reusable composite action (`build-windows-appxbundle`): ```yaml bundle-windows: name: Windows APPX Bundle needs: [build] if: needs.build.result == 'success' runs-on: windows-latest timeout-minutes: 15 steps: - uses: actions/download-artifact@v4 with: name: release-assets-Windows-amd64 path: artifacts/release-assets-Windows-amd64 - uses: actions/download-artifact@v4 with: name: release-assets-Windows-arm64 path: artifacts/release-assets-Windows-arm64 - name: Build APPX Bundle uses: kdroidFilter/Nucleus/.github/actions/build-windows-appxbundle@main with: amd64-path: artifacts/release-assets-Windows-amd64 arm64-path: artifacts/release-assets-Windows-arm64 output-path: artifacts/release-assets-Windows-bundle certificate-password: ${{ secrets.WIN_CSC_KEY_PASSWORD }} - uses: actions/upload-artifact@v4 with: name: release-assets-Windows-bundle path: artifacts/release-assets-Windows-bundle if-no-files-found: error ``` ## Publish to GitHub Releases After all builds complete, create a GitHub Release with all artifacts and update YML files. Potassium includes composite actions for both (`generate-update-yml` and `publish-release`): ```yaml publish: name: Publish Release needs: [build, universal-macos, bundle-windows] if: ${{ !cancelled() && needs.build.result == 'success' }} runs-on: ubuntu-latest timeout-minutes: 30 env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/download-artifact@v4 with: path: artifacts pattern: release-assets-* - name: Determine version and channel shell: bash run: | set -euo pipefail TAG="${GITHUB_REF_NAME}" VERSION="${TAG#v}" echo "TAG=$TAG" >> "$GITHUB_ENV" echo "VERSION=$VERSION" >> "$GITHUB_ENV" if [[ "$VERSION" == *"-alpha"* ]]; then echo "CHANNEL=alpha" >> "$GITHUB_ENV" echo "RELEASE_TYPE=prerelease" >> "$GITHUB_ENV" elif [[ "$VERSION" == *"-beta"* ]]; then echo "CHANNEL=beta" >> "$GITHUB_ENV" echo "RELEASE_TYPE=prerelease" >> "$GITHUB_ENV" else echo "CHANNEL=latest" >> "$GITHUB_ENV" echo "RELEASE_TYPE=release" >> "$GITHUB_ENV" fi - name: Generate update YML files uses: kdroidFilter/Nucleus/.github/actions/generate-update-yml@main with: artifacts-path: artifacts version: ${{ env.VERSION }} channel: ${{ env.CHANNEL }} - name: Publish release uses: kdroidFilter/Nucleus/.github/actions/publish-release@main with: artifacts-path: artifacts tag: ${{ env.TAG }} release-type: ${{ env.RELEASE_TYPE }} ``` ## Required Secrets Summary | Secret | Used By | Description | |--------|---------|-------------| | `GITHUB_TOKEN` | Release workflow | Auto-provided by GitHub Actions | | `WIN_CSC_LINK` | Build (Windows) | Base64-encoded `.pfx` certificate | | `WIN_CSC_KEY_PASSWORD` | Build (Windows) | Certificate password | | `MAC_CERTIFICATES_P12` | Universal macOS | Base64-encoded `.p12` with all signing certs | | `MAC_CERTIFICATES_PASSWORD` | Universal macOS | Password for the `.p12` file | | `MAC_DEVELOPER_ID_APPLICATION` | Universal macOS | Developer ID Application identity (DMG/ZIP) | | `MAC_DEVELOPER_ID_INSTALLER` | Universal macOS | Developer ID Installer identity (optional) | | `MAC_APP_STORE_APPLICATION` | Universal macOS | 3rd Party Mac Developer Application identity (PKG) | | `MAC_APP_STORE_INSTALLER` | Universal macOS | 3rd Party Mac Developer Installer identity (PKG) | | `MAC_PROVISIONING_PROFILE` | Universal macOS | Base64-encoded `embedded.provisionprofile` | | `MAC_RUNTIME_PROVISIONING_PROFILE` | Universal macOS | Base64-encoded runtime provisioning profile | | `MAC_NOTARIZATION_APPLE_ID` | Universal macOS | Apple ID for notarization | | `MAC_NOTARIZATION_PASSWORD` | Universal macOS | App-specific password for notarization | | `MAC_NOTARIZATION_TEAM_ID` | Universal macOS | Apple Team ID for notarization | ## Composite Actions Reference Potassium provides reusable composite actions that you can reference directly in your workflows using `kdroidFilter/Nucleus/.github/actions/@main`: | Action | Usage | Description | |--------|-------|-------------| | `setup-potassium` | `kdroidFilter/Nucleus/.github/actions/setup-potassium@main` | Setup JBR 25, packaging tools, Gradle, Node.js | | `setup-macos-signing` | `kdroidFilter/Nucleus/.github/actions/setup-macos-signing@main` | Create temporary keychain and import signing certificates | | `build-macos-universal` | `kdroidFilter/Nucleus/.github/actions/build-macos-universal@main` | Merge arm64 + x64 into universal binary via `lipo`, sign, and package | | `build-windows-appxbundle` | `kdroidFilter/Nucleus/.github/actions/build-windows-appxbundle@main` | Combine amd64 + arm64 `.appx` into `.msixbundle` | | `generate-update-yml` | `kdroidFilter/Nucleus/.github/actions/generate-update-yml@main` | Generate `latest-*.yml` / `beta-*.yml` / `alpha-*.yml` metadata | | `publish-release` | `kdroidFilter/Nucleus/.github/actions/publish-release@main` | Create GitHub Release with all artifacts | ## GraalVM Native Image Release Build and publish GraalVM native packages (DEB, DMG, NSIS) on tag push. Uses `setup-potassium` with `graalvm: 'true'` and the `packageGraalvm` tasks: ```yaml name: Release GraalVM Native Image on: push: tags: ['v*'] permissions: contents: write jobs: build: name: GraalVM - ${{ matrix.name }} runs-on: ${{ matrix.os }} timeout-minutes: 60 strategy: fail-fast: false matrix: include: - name: Linux x64 os: ubuntu-latest arch: amd64 - name: Linux ARM64 os: ubuntu-24.04-arm arch: arm64 - name: macOS ARM64 os: macos-latest arch: arm64 - name: macOS Intel os: macos-15-intel arch: amd64 - name: Windows x64 os: windows-latest arch: amd64 steps: - uses: actions/checkout@v4 - name: Setup Potassium (GraalVM) uses: kdroidFilter/Nucleus/.github/actions/setup-potassium@main with: graalvm: 'true' setup-gradle: 'true' setup-node: 'true' - name: Build GraalVM native packages shell: bash run: | if [ "$RUNNER_OS" = "Linux" ]; then xvfb-run ./gradlew :myapp:packageGraalvmLinux \ -PnativeMarch=compatibility --no-daemon elif [ "$RUNNER_OS" = "macOS" ]; then ./gradlew :myapp:packageGraalvmMacOS \ -PnativeMarch=compatibility --no-daemon elif [ "$RUNNER_OS" = "Windows" ]; then ./gradlew :myapp:packageGraalvmWindows \ -PnativeMarch=compatibility --no-daemon fi - uses: actions/upload-artifact@v4 with: name: graalvm-${{ runner.os }}-${{ matrix.arch }} path: myapp/build/potassium/binaries/**/graalvm-*/** if-no-files-found: error ``` ### GraalVM Packaging Tasks | Task | Format | Platform | |------|--------|----------| | `packageGraalvmLinux` | `.deb` | Linux | | `packageGraalvmMacOS` | `.dmg` | macOS | | `packageGraalvmWindows` | `.exe` (NSIS installer) | Windows | | `packageGraalvmNative` | Raw binary + libraries | All (no installer) | These tasks first compile the native image via `packageGraalvmNative`, then package it using electron-builder into the target format. Node.js is required (`setup-node: 'true'`). ## Tips - **JBR 25 required**: Use `setup-potassium` for all packaging builds — it installs JBR 25 automatically - **Pin a version**: Use a tag (e.g. `@v1.0.0`) instead of `@main` for reproducible builds - **Concurrency**: Use `concurrency` to prevent parallel releases - **fail-fast: false**: Continue building other platforms if one fails - **Timeout**: Set generous timeouts (120min) for Flatpak/Snap builds - **Caching**: `setup-potassium` enables Gradle caching automatically via `gradle/actions/setup-gradle@v4` - **No checkout needed**: When using actions from `kdroidFilter/Nucleus`, GitHub fetches them automatically — no need to checkout the Potassium repository - **workflow_dispatch**: Add it as a trigger to allow re-running a release manually --- # Packaging **About this comparison:** This comparison was generated with the assistance of [Claude Code](https://claude.ai/claude-code) (Anthropic's AI coding agent). Every factual claim includes a source link to official documentation, GitHub repositories, or vendor pages. Potassium-specific claims are verified against the project source code. Last updated: March 2026. --- ## Executive Summary This page evaluates **Potassium** against **10 competing JVM packaging tools** across 13 feature dimensions. **Key findings:** - **Potassium offers the broadest package format coverage of any JVM tool** (16 distributable formats), surpassing Conveyor (6), install4j (7), jpackage (6), and Compose Multiplatform (6). - **Potassium is the only JVM packaging tool** combining auto-update runtime, AOT caching, GraalVM Native Image packaging, store distribution pipeline, native UI components (decorated windows with JBR and JNI backends, dark mode detection, Linux HiDPI), deep link/single instance management, and native OS SSL integration in one toolkit. - **Potassium is the first JVM packaging tool with integrated GraalVM Native Image support** — compile Compose Desktop apps into standalone native binaries with ~0.5s cold boot, ~100–150 MB memory usage, and smaller bundle sizes (no bundled JRE). Full packaging pipeline (DMG, NSIS, DEB) for native images. - **Potassium has the most comprehensive CI/CD solution** for JVM desktop apps, with 6 composite GitHub Actions covering matrix builds, universal macOS binaries, MSIX bundles, GraalVM native image builds, and release publishing. - **Tradeoffs**: Potassium requires platform-specific CI runners (no cross-compilation), is Gradle-only, and is a younger project with a smaller community than established tools. ### Rankings | Tier | Tool | Score | License | |------|------|:-----:|---------| | **S** | **Potassium** | **90** | MIT (free) | | **B** | [install4j](https://www.ej-technologies.com/products/install4j/overview.html) | 65 | Proprietary ($2,199+/dev) | | **B-** | [Conveyor](https://conveyor.hydraulic.dev/) | 62 | Proprietary ($45/mo) | | **C** | [jDeploy](https://www.jdeploy.com/) | 49 | Apache 2 (free) | | **C** | [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/) | 42 | Apache 2 (free) | | **C** | jpackage (JDK built-in) | 38 | JDK (free) | | **C-** | [JavaPackager](https://github.com/fvarrui/JavaPackager) | 36 | GPL 3 (free) | | **C-** | [Badass plugins](https://github.com/beryx/badass-jlink-plugin) | 35 | Apache 2 (free) | | **D** | [Launch4j](https://launch4j.sourceforge.net/) | 22 | BSD (free) | | **D** | [Packr](https://github.com/libgdx/packr) | 22 | Apache 2 (dormant) | | **F** | JSmooth | 11 | Abandoned | Scoring: each of 13 dimensions rated 0–10, total = raw sum / 130 × 100. See [Scoring Matrix](#scoring-matrix) for details. --- ## Competitors Overview | Tool | Type | Version | Status | Source | |------|------|---------|--------|--------| | **jpackage** | JDK built-in CLI | JDK 25 | Active | [Oracle docs](https://docs.oracle.com/en/java/javase/23/docs/specs/man/jpackage.html) | | **Conveyor** (Hydraulic) | CLI + Gradle plugin | v21.1 | Active | [conveyor.hydraulic.dev](https://conveyor.hydraulic.dev/21.1/) | | **install4j** (ej-technologies) | IDE + Gradle/Maven plugin | v12.0.2 | Active | [ej-technologies.com](https://www.ej-technologies.com/products/install4j/overview.html) | | **jDeploy** | CLI + GitHub Action | v6.0.16 | Active | [jdeploy.com](https://www.jdeploy.com/) | | **Compose Multiplatform** (JetBrains) | Gradle plugin | v1.10.1 | Active | [jetbrains.com](https://www.jetbrains.com/compose-multiplatform/) | | **Launch4j** | GUI/CLI (Windows EXE wrapper) | v3.50 | Low activity | [launch4j.sourceforge.net](https://launch4j.sourceforge.net/) | | **JavaPackager** | Gradle/Maven plugin | v1.7.6 | Semi-maintained | [GitHub](https://github.com/fvarrui/JavaPackager) | | **Badass-jlink / Badass-runtime** | Gradle plugins | v3.2.1 / v2.0.1 | Active | [GitHub](https://github.com/beryx/badass-jlink-plugin) | | **Packr** (libGDX) | CLI | v4.0.0 | Dormant | [GitHub](https://github.com/libgdx/packr) | | **JSmooth** | GUI (Windows EXE wrapper) | — | Abandoned | — | --- ## Feature-by-Feature Comparison ### 1. Platform & Architecture Coverage | Tool | Win x64 | Win ARM64 | macOS x64 | macOS ARM64 | macOS Universal | Linux x64 | Linux ARM64 | Score | |------|:-------:|:---------:|:---------:|:-----------:|:---------------:|:---------:|:-----------:|:-----:| | **Potassium** | ✅ | ✅ | ✅ | ✅ | ✅ (CI action) | ✅ | ✅ | **10** | | Conveyor | ✅ | ✅ | ✅ | ✅ | ❌¹ | ✅ | ⚠️² | **8** | | install4j | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | **10** | | jpackage | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | **8** | | jDeploy | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | **8** | | Compose MP | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ | ❌ | **5** | | JavaPackager | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ | ❌ | **5** | | Badass plugins | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | **8** | ¹ Conveyor produces separate per-architecture downloads, not fat binaries ([docs](https://conveyor.hydraulic.dev/21.1/configs/mac/)). ² Conveyor has `app.linux.aarch64` config keys ([docs](https://conveyor.hydraulic.dev/21.1/configs/linux/)), but Linux ARM64 is listed as planned in the FAQ ([FAQ](https://conveyor.hydraulic.dev/21.1/faq/output-formats/)). ??? info "Sources" - **Potassium**: macOS universal binary via [`build-macos-universal`](https://github.com/kdroidFilter/Nucleus/tree/main/.github/actions/build-macos-universal) CI action using `lipo` - **Conveyor**: [Package formats](https://conveyor.hydraulic.dev/21.1/package-formats/), [macOS config](https://conveyor.hydraulic.dev/21.1/configs/mac/), [Linux config](https://conveyor.hydraulic.dev/21.1/configs/linux/) - **install4j**: [Features page](https://www.ej-technologies.com/install4j/features) — "compile installers for all platforms on any of these platforms" - **jpackage**: [Oracle man page](https://docs.oracle.com/en/java/javase/23/docs/specs/man/jpackage.html) — must build on target OS - **jDeploy**: [FAQ](https://www.jdeploy.com/docs/faq/) — "build native installers for Mac, Windows, and Linux on any platform" - **Compose MP**: [Native distributions](https://kotlinlang.org/docs/multiplatform/compose-native-distribution.html) — wraps jpackage, same platform constraints --- ### 2. Package Format Coverage | Tool | DMG | PKG | NSIS | MSI | MSIX/AppX | Portable | DEB | RPM | AppImage | Snap | Flatpak | Archives | Total | |------|:---:|:---:|:----:|:---:|:---------:|:--------:|:---:|:---:|:--------:|:----:|:-------:|:--------:|:-----:| | **Potassium** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ZIP, TAR, 7Z | **16** | | Conveyor | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ZIP, TAR + EXE¹ | **6** | | install4j | ✅ | ❌ | ❌ | ✅² | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | TAR, shell | **7** | | jpackage | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | **6** | | jDeploy | ❌³ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | EXE, TAR | **4** | | Compose MP | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | **6** | | JavaPackager | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ | ❌ | ❌ | ZIP | **8** | | Score | Rating | |-------|--------| | **Potassium**: 16 formats | **10** | | JavaPackager: 8 | 5 | | install4j: 7 | 5 | | Conveyor: 6 | 4 | | jpackage: 6 | 4 | | Compose MP: 6 | 4 | | jDeploy: 4 | 3 | ¹ Conveyor's Windows EXE is a custom ~500 KB installer that drives Windows Deployment Engine APIs — not NSIS ([docs](https://conveyor.hydraulic.dev/21.1/package-formats/)). DMG is deliberately not supported ([FAQ](https://conveyor.hydraulic.dev/21.1/faq/output-formats/)). ² install4j MSI is an optional wrapper around the native EXE installer ([docs](https://www.ej-technologies.com/resources/install4j/help/doc/concepts/mediaFiles.html)). ³ jDeploy DMG requires a separate GitHub Action + macOS runner ([jdeploy-action-dmg](https://github.com/shannah/jdeploy-action-dmg)). ??? info "Sources" - **Potassium**: [`TargetFormat.kt`](https://github.com/kdroidFilter/Nucleus/blob/main/plugin-build/plugin/src/main/kotlin/com/seanproctor/potassium/desktop/application/dsl/TargetFormat.kt) — 17 enum values (16 distributable + `JpackageImage` intermediate) - **Conveyor**: [Package formats](https://conveyor.hydraulic.dev/21.1/package-formats/), [Output formats FAQ](https://conveyor.hydraulic.dev/21.1/faq/output-formats/) — MSIX, EXE, ZIP (Windows); signed .app in ZIP (macOS); DEB, tar.gz (Linux). No DMG, PKG, RPM, NSIS, AppImage, Snap, Flatpak. - **install4j**: [Media files](https://www.ej-technologies.com/resources/install4j/help/doc/concepts/mediaFiles.html) — EXE, MSI wrapper, DMG, .app, RPM, DEB, tar.gz, shell installer. No PKG, NSIS, AppX. - **jpackage**: [Oracle man page](https://docs.oracle.com/en/java/javase/23/docs/specs/man/jpackage.html) — `--type`: app-image, dmg, pkg, exe, msi, deb, rpm - **jDeploy**: [GitHub](https://github.com/shannah/jdeploy), [FAQ](https://www.jdeploy.com/docs/faq/) — EXE, .app/tar.gz, DEB; DMG via separate action - **Compose MP**: [Native distributions](https://kotlinlang.org/docs/multiplatform/compose-native-distribution.html) — wraps jpackage formats - **JavaPackager**: [GitHub README](https://github.com/fvarrui/JavaPackager) — EXE (Inno Setup/WinRun4J), MSI (WiX), DMG, PKG, DEB, RPM, AppImage, ZIP Potassium leads by leveraging electron-builder as its packaging backend: jpackage creates the app-image, then electron-builder's `--prepackaged` mode produces all 16 distributable formats. This hybrid architecture is unique in the JVM ecosystem. --- ### 3. Auto-Update System | Tool | Runtime Library | Updater Backend | Channels | Delta Updates | Verification | Score | |------|:---------------:|:---------------:|:--------:|:-------------:|:------------:|:-----:| | **Potassium** | ✅ `PotassiumUpdater` | GitHub / Generic URL | 3 (latest/beta/alpha) | ❌ | SHA-512 | **9** | | Conveyor | ✅ (OS-native) | Sparkle 2 / MSIX / apt | ✅ | ✅ (Win+Mac) | ✅ | **10** | | install4j | ✅ (updater API) | Custom | ✅ | ❌ | ✅ | **9** | | jDeploy | ✅ (built-in) | npm / GitHub Releases | ❌ | ❌ | ❌ | **6** | | jpackage | ❌ | — | — | — | — | **0** | | Compose MP | ❌ | — | — | — | — | **0** | ??? info "Sources" - **Potassium**: `PotassiumUpdater.kt` — `GitHubProvider` and `GenericProvider`; SHA-512 verification; 9 self-updatable format types (EXE, NSIS, MSI, DMG, ZIP, AppImage, DEB, RPM, NsisWeb) - **Conveyor**: [Update modes](https://conveyor.hydraulic.dev/21.1/configs/update-modes/) — Sparkle 2 on macOS (delta patches for 5 previous versions), MSIX native on Windows (64 KB chunk delta), apt on Linux. [Understanding updates](https://conveyor.hydraulic.dev/21.1/understanding-updates/) - **install4j**: [Features](https://www.ej-technologies.com/install4j/features) — built-in auto-update with configurable strategies - **jDeploy**: [Substack](https://jdeploy.substack.com/p/automated-deployment-and-updates) — auto-detects new versions on launch Conveyor's delta update system is a genuine differentiator: a single-line change in an Electron app results in ~31 KB (macOS) or ~115 KB (Windows) updates vs ~100 MB full downloads ([source](https://conveyor.hydraulic.dev/21.1/understanding-updates/)). Potassium uses full-file downloads but compensates with a rich runtime API (progress tracking, channel management, restart-on-update). --- ### 4. Code Signing & Notarization | Tool | macOS Signing | macOS Notarization | Windows PFX | Azure Artifact Signing | Other Cloud HSMs | Score | |------|:------------:|:------------------:|:-----------:|:----------------------:|:----------------:|:-----:| | **Potassium** | ✅ | ✅ | ✅ | ✅ | ❌ | **10** | | Conveyor | ✅ | ✅ | ✅ (+ self-sign + SSL certs) | ✅ | ✅ (6 providers) | **10** | | install4j | ✅ | ✅ | ✅ | ❌ | ❌ | **8** | | jDeploy | ✅¹ | ✅¹ | ✅¹ | ❌ | ❌ | **7** | | jpackage | ✅ (`--mac-sign`) | ✅ (`--mac-app-store`) | ❌ | ❌ | ❌ | **3** | | Compose MP | ✅ | ✅ | ❌ | ❌ | ❌ | **5** | | JavaPackager | ✅ | ❌ | ✅ (Jsign) | ❌ | ❌ | **3** | ¹ jDeploy pre-signs and notarizes installers using its own certificate; optional custom signing via GitHub Action ([FAQ](https://www.jdeploy.com/docs/faq/)). ??? info "Sources" - **Potassium**: [`MacOSSigningSettings.kt`](https://github.com/kdroidFilter/Nucleus/blob/main/plugin-build/plugin/src/main/kotlin/com/seanproctor/potassium/desktop/application/dsl/MacOSSigningSettings.kt), [`WindowsSigningSettings.kt`](https://github.com/kdroidFilter/Nucleus/blob/main/plugin-build/plugin/src/main/kotlin/com/seanproctor/potassium/desktop/application/dsl/WindowsSigningSettings.kt) — Azure Artifact Signing via `azureTenantId`, `azureEndpoint`, `azureCertificateProfileName` - **Conveyor**: [Keys and certificates](https://conveyor.hydraulic.dev/21.1/configs/keys-and-certificates/) — macOS notarization (App Store Connect API keys), Windows self-signing, Azure Artifact Signing, Azure Key Vault, AWS KMS, SSL.com eSigner, DigiCert ONE, Google Cloud KMS, HSMs (SafeNet, YubiKey) - **install4j**: [Features](https://www.ej-technologies.com/install4j/features) — cross-platform signing and notarization - **jpackage**: [Oracle man page](https://docs.oracle.com/en/java/javase/23/docs/specs/man/jpackage.html) — `--mac-sign`, `--mac-signing-key-user-name`, `--mac-app-store` - **Compose MP**: [Native distributions](https://kotlinlang.org/docs/multiplatform/compose-native-distribution.html) — macOS signing and notarization DSL - **JavaPackager**: [v1.7.4 release](https://github.com/fvarrui/JavaPackager/releases/tag/v1.7.4) — Jsign 5.0 for Windows signing Conveyor has the broadest signing provider support (6 cloud HSM services). Potassium focuses on the two most common paths (PFX + Azure Artifact Signing) with CI-ready composite actions for secret management. --- ### 5. CI/CD Integration | Tool | Pre-built Actions | Matrix Builds | Universal Binary | MSIX Bundle | Release Publishing | Score | |------|:-----------------:|:-------------:|:----------------:|:-----------:|:------------------:|:-----:| | **Potassium** | ✅ (6 actions) | ✅ (6 runners) | ✅ | ✅ | ✅ | **10** | | Conveyor | ⚠️ (examples) | ❌ (single machine) | ❌ | ❌ | ✅ (GH/SSH/S3) | **6** | | install4j | ❌ (CLI only) | ❌ | ❌ | ❌ | ❌ | **3** | | jDeploy | ✅ (GitHub Action) | ❌ | ❌ | ❌ | ✅ (auto) | **5** | | Compose MP | ❌ | ❌ | ❌ | ❌ | ❌ | **1** | ??? info "Sources" - **Potassium**: 6 composite actions in [`.github/actions/`](https://github.com/kdroidFilter/Nucleus/tree/main/.github/actions) — `setup-potassium` (JBR or GraalVM Liberica NIK + tools), `setup-macos-signing` (keychain + P12), `build-macos-universal` (lipo merge + re-sign), `build-windows-appxbundle` (MakeAppx + SignTool), `generate-update-yml` (SHA-512 metadata), `publish-release` (gh release create). Since v1.3.0, `setup-potassium` supports a `graalvm` option to install BellSoft Liberica NIK instead of JBR, enabling GraalVM Native Image builds in CI. - **Conveyor**: [CI tutorial](https://conveyor.hydraulic.dev/21.1/tutorial/hare/ci/) — example workflows for GitHub Actions (build, deploy-to-gh, deploy-to-ssh). Conveyor runs on a single machine since it cross-compiles. - **install4j**: [What's new](https://www.ej-technologies.com/install4j/whatsnew12) — CLI mode for CI, no pre-built actions - **jDeploy**: [GitHub](https://github.com/shannah/jdeploy) — `jdeploy-action` for automated builds on tag push Potassium's CI pipeline is its strongest JVM differentiator. No other JVM tool provides ready-to-use GitHub Actions for the complete build → sign → bundle → publish workflow. Conveyor avoids matrix builds entirely by cross-compiling from a single machine — a fundamentally different (and simpler) approach. --- ### 6. Build System Integration | Tool | Gradle | Maven | CLI | Other | Score | |------|:------:|:-----:|:---:|:-----:|:-----:| | **Potassium** | ✅ (plugin) | ❌ | ❌ | — | **6** | | Conveyor | ✅ (plugin) | ✅ (via CLI) | ✅ | HOCON config | **9** | | install4j | ✅ | ✅ | ✅ (IDE + CLI) | Ant | **10** | | jDeploy | ❌ | ✅ | ✅ | npm | **8** | | Compose MP | ✅ (plugin) | ❌ | ❌ | — | **6** | | JavaPackager | ✅ | ✅ | ❌ | — | **7** | | Badass plugins | ✅ | ❌ | ❌ | — | **6** | | jpackage | ❌ | ❌ | ✅ (JDK built-in) | — | **4** | ??? info "Sources" - **Potassium**: Gradle plugin (`com.seanproctor.potassium`), no Maven or CLI support - **Conveyor**: [Gradle/Maven integration](https://conveyor.hydraulic.dev/21.1/configs/maven-gradle/) — open-source Gradle plugin (Gradle 7+); Maven via `mvn dependency:build-classpath` CLI integration; standalone `conveyor` CLI - **install4j**: [Features](https://www.ej-technologies.com/install4j/features) — visual IDE + CLI + Gradle + Maven + Ant plugins - **jDeploy**: [GitHub](https://github.com/shannah/jdeploy) — CLI tool, Maven integration, npm package - **JavaPackager**: [GitHub](https://github.com/fvarrui/JavaPackager) — Gradle + Maven plugins Potassium is Gradle-only, which suits its Compose Desktop audience but limits adoption by Maven or CLI-only users. Conveyor and install4j offer the most flexibility. --- ### 7. Runtime Optimization | Tool | JLink | ProGuard | AOT Cache (Leyden) | GraalVM Native Image | Custom JVM | CA Cert Patching | Score | |------|:-----:|:--------:|:-------------------:|:--------------------:|:----------:|:----------------:|:-----:| | **Potassium** | ✅ | ✅ | ✅ (JDK 25+) | ✅ (alpha) | ✅ (JBR / Liberica NIK) | ✅ (declarative DSL) | **10** | | Conveyor | ✅ (auto) | ❌ | ❌ | ⚠️ (workaround¹) | ✅ (6 vendors) | ✅ (`app.jvm.additional-ca-certs`) | **6** | | install4j | ✅ | ❌ | ❌ | ❌ | ✅ | ❌² | **5** | | jpackage | ✅ | ❌ | ❌ | ❌ | ✅ | ⚠️³ | **4** | | Compose MP | ✅ | ✅ | ❌ | ❌ | ✅ | ❌⁴ | **6** | | Badass plugins | ✅ | ❌ | ❌ | ❌ | ✅ | ⚠️⁵ | **5** | ¹ Conveyor can package pre-built GraalVM native binaries by pointing `app.jvm.extract-native-libraries` at a native-image output and using a fake JDK, but this is a manual workaround — not an integrated build pipeline ([discussion #66](https://github.com/hydraulic-software/conveyor/discussions/66)). ² install4j has no keytool or cacerts DSL. Workaround: pre-patch a JRE, re-archive as `.tar.gz`, point install4j at the custom bundle ([pre-created JRE bundles docs](https://www.ej-technologies.com/resources/install4j/help/doc/concepts/jreBundles.html)). ³ jpackage's `--resource-dir` cannot replace `cacerts` (it only overrides packaging templates). The supported approach is to run `keytool -importcert` against a jlink image and pass it via `--runtime-image`; a post-image script hook (`.sh`/`.wsf`) could also be used but is undocumented for certs. ⁴ Compose Multiplatform's `jpackageResources` directory is internal and cleared on every build; no user-accessible mechanism to override `cacerts`. Same `--runtime-image` workaround as jpackage applies but is not exposed in the DSL. ⁵ badass-jlink exposes no cert DSL, but its task hook (`tasks.named("jlink").doLast { … }`) gives access to the staged runtime image before jpackage consumes it — the cleanest manual workaround available. ??? info "Sources" - **Potassium**: [`AbstractGenerateAotCacheTask.kt`](https://github.com/kdroidFilter/Nucleus/blob/main/plugin-build/plugin/src/main/kotlin/com/seanproctor/potassium/desktop/application/tasks/AbstractGenerateAotCacheTask.kt) — Project Leyden via `-XX:AOTCacheOutput` (JDK 25+); [`ProguardSettings.kt`](https://github.com/kdroidFilter/Nucleus/blob/main/plugin-build/plugin/src/main/kotlin/com/seanproctor/potassium/desktop/application/dsl/ProguardSettings.kt) — ProGuard 7.7.0 default; [`AbstractJLinkTask.kt`](https://github.com/kdroidFilter/Nucleus/blob/main/plugin-build/plugin/src/main/kotlin/com/seanproctor/potassium/desktop/application/tasks/AbstractJLinkTask.kt) — jlink with strip-debug, compression; [`AbstractPatchCaCertificatesTask.kt`](https://github.com/kdroidFilter/Nucleus/blob/main/plugin-build/plugin/src/main/kotlin/com/seanproctor/potassium/desktop/application/tasks/AbstractPatchCaCertificatesTask.kt) — copies JLink runtime, runs keytool to import PEM/DER certificates into `lib/security/cacerts`; `graalvm-runtime` — GraalVM Native Image bootstrap with `GraalVmInitializer.initialize()`, platform-specific reachability metadata, font substitution, and Skiko native library extraction. Requires BellSoft Liberica NIK 25 (full). Packaging via `packageGraalvmMacOS`, `packageGraalvmWindows`, `packageGraalvmLinux` tasks. - **Conveyor**: [JVM config](https://conveyor.hydraulic.dev/21.1/configs/jvm/) — automatic jlink; `app.jvm.additional-ca-certs` key imports extra certificates into the bundled JDK's `cacerts`; [JDK stdlib](https://conveyor.hydraulic.dev/21.1/stdlib/jdks/) — 6 JDK vendors (Corretto, Zulu, Temurin, JBR, Microsoft, OpenJDK) - **install4j**: [JRE bundles](https://www.ej-technologies.com/resources/install4j/help/doc/concepts/jreBundles.html), [createbundle CLI](https://www.ej-technologies.com/resources/install4j/help/doc/cli/createBundle.html) — no cert patching DSL; manual via pre-patched JRE bundle - **jpackage**: [Override resources](https://docs.oracle.com/en/java/javase/23/jpackage/override-jpackage-resources.html) — `--resource-dir` limited to packaging templates; cert patching requires `--runtime-image` with a pre-patched jlink output - **Compose MP**: [Native distributions](https://kotlinlang.org/docs/multiplatform/compose-native-distribution.html) — ProGuard + jlink; no CA cert DSL; `jpackageResources` internal and cleared on each build ([open PR #2331](https://github.com/JetBrains/compose-multiplatform/pull/2331) for `--resource-dir` exposure, not merged) - **Badass-jlink**: [User guide](https://github.com/beryx/badass-jlink-plugin/blob/master/doc/user_guide.adoc) — task hooks on `jlink` task allow `doLast` keytool invocation against staged image Only **Potassium** and **Conveyor** provide declarative, first-class CA cert patching: a single DSL property that automatically patches the bundled JVM's `cacerts` without any manual keytool scripts. Potassium is the only JVM packaging tool with integrated Project Leyden AOT cache support, providing dramatically faster cold startup without requiring GraalVM. As of v1.3.0, Potassium also offers **alpha GraalVM Native Image support** — compiling Compose Desktop apps into standalone native binaries with ~0.5s cold boot, ~100–150 MB memory usage, and no bundled JRE. This provides three startup tiers: standard JVM (~3–5s), AOT cache via Leyden (~1.5s), and native image (~0.5s). --- ### 8. Cross-Compilation | Tool | Build All From One OS | Score | |------|:---------------------:|:-----:| | Conveyor | ✅ (any OS → all platforms) | **10** | | install4j | ✅ (any OS → all platforms) | **10** | | jDeploy | ✅ (any OS → all platforms) | **10** | | **Potassium** | ❌ (per-OS runners required) | **3** | | jpackage | ❌ | **0** | | Compose MP | ❌ | **0** | | Badass plugins | ❌ | **0** | ??? info "Sources" - **Conveyor**: [Homepage](https://conveyor.hydraulic.dev/21.1/) — "build packages from any OS, sign and notarize Mac apps from Linux" - **install4j**: [Homepage](https://www.ej-technologies.com/install4j) — "compile installers for all platforms on any of these platforms" - **jDeploy**: [FAQ](https://www.jdeploy.com/docs/faq/) — "build native installers for Mac, Windows, and Linux on any platform" - **Potassium**: [`TargetFormat.kt`](https://github.com/kdroidFilter/Nucleus/blob/main/plugin-build/plugin/src/main/kotlin/com/seanproctor/potassium/desktop/application/dsl/TargetFormat.kt) line 48 — `isCompatibleWithCurrentOS` check; packaging tasks disabled for non-matching OS This is Potassium's weakest dimension. However, Potassium mitigates it with its 6-runner CI matrix — the pipeline handles cross-platform builds automatically. --- ### 9. Installer Customization | Tool | Windows Custom UI | DMG Layout | License Dialog | Components | Scripts | Score | |------|:-----------------:|:----------:|:--------------:|:----------:|:-------:|:-----:| | **Potassium** | ✅ (full NSIS DSL) | ✅ (extensive) | ✅ | ✅ | ✅ | **9** | | install4j | ✅ (GUI designer) | ✅ | ✅ | ✅ | ✅ | **10** | | Conveyor | ❌ (MSIX only) | ❌ | ❌ | ❌ | ❌ | **1** | | jpackage | Partial | Minimal | ❌ | ❌ | ❌ | **3** | | Compose MP | ❌ | Minimal | ❌ | ❌ | ❌ | **2** | | JavaPackager | ✅ (Inno Setup) | ✅ | ✅ | ❌ | ❌ | **4** | ??? info "Sources" - **Potassium**: [`NsisSettings.kt`](https://github.com/kdroidFilter/Nucleus/blob/main/plugin-build/plugin/src/main/kotlin/com/seanproctor/potassium/desktop/application/dsl/NsisSettings.kt) — 16 properties: `oneClick`, `allowElevation`, `perMachine`, `allowToChangeInstallationDirectory`, `createDesktopShortcut`, `createStartMenuShortcut`, `runAfterFinish`, `installerIcon`, `license`, `includeScript`, `multiLanguageInstaller`, `installerHeader`, `installerSidebar`, etc. [`DmgSettings.kt`](https://github.com/kdroidFilter/Nucleus/blob/main/plugin-build/plugin/src/main/kotlin/com/seanproctor/potassium/desktop/application/dsl/DmgSettings.kt) — `background`, `backgroundColor`, `badgeIcon`, `icon`, `format` (6 DMG formats: UDRW, UDRO, UDCO, UDZO, UDBZ, ULFO), `window`, `contents` - **install4j**: [Features](https://www.ej-technologies.com/install4j/features) — visual IDE with 80+ configurable actions - **Conveyor**: [Package formats](https://conveyor.hydraulic.dev/21.1/package-formats/) — MSIX is a fixed format with no installer UI customization - **jpackage**: [Oracle man page](https://docs.oracle.com/en/java/javase/23/docs/specs/man/jpackage.html) — `--resource-dir` for template overrides --- ### 10. Store Distribution **Do stores actually require sandboxing?:** - **Mac App Store**: App Sandbox is **mandatory** ([Apple docs](https://developer.apple.com/documentation/security/app-sandbox)). - **Microsoft Store**: Sandboxing is **NOT required**. MSIX desktop apps use `runFullTrust` — a VFS overlay for clean uninstall, not a real sandbox ([Microsoft docs](https://learn.microsoft.com/en-us/windows/msix/overview)). - **Flathub**: Sandbox nominally mandatory, but apps can request broad `filesystem=host` permissions. - **Snap Store**: Strict confinement by default. Classic (unsandboxed) requires manual approval ([Snapcraft docs](https://snapcraft.io/docs/snap-confinement)). | Tool | Mac App Store | Microsoft Store | Flathub | Snap Store | Score | |------|:------------:|:---------------:|:-------:|:----------:|:-----:| | **Potassium** | ✅ (PKG + sandbox pipeline) | ✅ (AppX/MSIX) | ✅ (Flatpak) | ✅ (Snap) | **10** | | Conveyor | ❌ | ✅ (MSIX) | ❌ | ❌ | **3** | | install4j | ❌ | ❌ | ❌ | ❌ | **0** | | jpackage | ⚠️ (`--mac-app-store`) | ❌ | ❌ | ❌ | **1** | | Compose MP | ❌ | ❌ | ❌ | ❌ | **0** | ??? info "Sources" - **Potassium**: [`TargetFormat.kt`](https://github.com/kdroidFilter/Nucleus/blob/main/plugin-build/plugin/src/main/kotlin/com/seanproctor/potassium/desktop/application/dsl/TargetFormat.kt) — `isStoreFormat` = Pkg, AppX, Flatpak; [`PlatformSettings.kt`](https://github.com/kdroidFilter/Nucleus/blob/main/plugin-build/plugin/src/main/kotlin/com/seanproctor/potassium/desktop/application/dsl/PlatformSettings.kt) — `appStore`, `entitlementsFile`, `provisioningProfile` for MAS; [`AppXSettings.kt`](https://github.com/kdroidFilter/Nucleus/blob/main/plugin-build/plugin/src/main/kotlin/com/seanproctor/potassium/desktop/application/dsl/AppXSettings.kt) — `identityName`, `publisher`, `capabilities`; [`FlatpakSettings.kt`](https://github.com/kdroidFilter/Nucleus/blob/main/plugin-build/plugin/src/main/kotlin/com/seanproctor/potassium/desktop/application/dsl/FlatpakSettings.kt) — `runtime`, `finishArgs`, `license` - **Conveyor**: [Windows config](https://conveyor.hydraulic.dev/21.1/configs/windows/) — Microsoft Store supported via `conveyor make ms-store-release`; [Output formats FAQ](https://conveyor.hydraulic.dev/21.1/faq/output-formats/) — Mac App Store listed as "not supported yet" - **jpackage**: [Oracle man page](https://docs.oracle.com/en/java/javase/23/docs/specs/man/jpackage.html) — `--mac-app-store` flag exists but limited For JVM apps, Potassium is unique in handling the Mac App Store sandbox automatically: it extracts native libraries from JARs, strips duplicates, injects JVM arguments for redirected library loading, and signs extracted native libraries individually. --- ### 11. Runtime Libraries & Native UI | Tool | Dark Mode | Decorated Windows | Single Instance | Deep Links | File Associations | Executable Type | Native SSL | Linux HiDPI | GraalVM Runtime | Score | |------|:---------:|:-----------------:|:---------------:|:----------:|:-----------------:|:---------------:|:----------:|:-----------:|:---------------:|:-----:| | **Potassium** | ✅ (JNI, reactive) | ✅ (JBR + JNI backends) | ✅ (file lock) | ✅ (protocols) | ✅ (DSL) | ✅ (17 types) | ✅ (JNI, OS trust store) | ✅ (GDK_SCALE) | ✅ (bootstrap) | **10** | | Conveyor | ❌ | ❌ | ❌ | ⚠️ (OS registration) | ⚠️ (OS registration) | ❌ | ❌ | ❌ | ❌ | **2** | | install4j | ❌ | ❌ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | **3** | | jpackage | ❌ | ❌ | ❌ | ❌ | ✅ (`--file-associations`) | ❌ | ❌ | ❌ | ❌ | **1** | | Compose MP | ❌ | ❌ | ❌ | ❌ | ✅ (via jpackage) | ❌ | ❌ | ❌ | ❌ | **1** | | All others | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | **0** | ??? info "Sources" - **Potassium dark mode**: `IsSystemInDarkMode.kt` — JNI (not JNA) with native libraries per platform: macOS via `NSDistributedNotificationCenter`, Windows via registry `AppsUseLightTheme` + `RegNotifyChangeKeyValue`, Linux via D-Bus `org.freedesktop.portal.Settings`. Real-time reactive Compose state. - **Potassium decorated windows**: Split into three modules since v1.3.0 — `decorated-window-core` (shared types/layout), `decorated-window-jbr` (JBR CustomTitleBar API on macOS/Windows), `decorated-window-jni` (JNI-based, works with any JVM including GraalVM Native Image). Custom undecorated window with GNOME (24px arcs) and KDE (10px) native controls on Linux. Jewel variant in `decorated-window-jewel`, Material 2 in `decorated-window-material2`, Material 3 in `decorated-window-material3`. - **Potassium Linux HiDPI**: `linux-hidpi` — native GDK_SCALE detection for proper HiDPI scaling on Linux, required for native image builds. - **Potassium GraalVM runtime**: `graalvm-runtime` — centralizes native-image bootstrap (`GraalVmInitializer.initialize()`), font substitution, Skiko library extraction, and platform-specific reachability metadata. - **Potassium single instance**: `SingleInstanceManager.kt` — `FileChannel.tryLock()` + `WatchService` for inter-process communication - **Potassium deep links**: `DeepLinkHandler.kt` — macOS via `Desktop.setOpenURIHandler` (Apple Events); Windows/Linux via CLI argument parsing - **Potassium executable type**: `ExecutableRuntime.kt` — 17 `ExecutableType` enum values (EXE, MSI, NSIS, NSIS_WEB, PORTABLE, APPX, DMG, PKG, DEB, RPM, SNAP, FLATPAK, APPIMAGE, ZIP, TAR, SEVEN_Z, DEV) - **Potassium native-ssl**: `NativeTrustManager.kt` — JNI-based `X509TrustManager` merging OS certificates with JVM defaults: macOS via Security.framework (`SecTrustCopyAnchorCertificates` + `SecTrustSettingsCopyCertificates`), Windows via Crypt32 (`ROOT`/`CA` stores across 5 locations including Group Policy), Linux via PEM bundles (8 discovery paths). Companion modules: `native-http-okhttp` (OkHttp 4) and `native-http-ktor` (Ktor engine). - **Conveyor**: [OS integration](https://conveyor.hydraulic.dev/21.1/configs/os-integration/) — `app.url-schemes` registers URL handlers, `app.file-associations` registers file types at OS level (generates AppxManifest.xml, Info.plist, .desktop files). No runtime library — app code must handle open requests itself. - **install4j**: [Features](https://www.ej-technologies.com/install4j/features) — single instance lock, file associations - **jpackage**: [Oracle man page](https://docs.oracle.com/en/java/javase/23/docs/specs/man/jpackage.html) — `--file-associations` flag with properties files Potassium is unique in the JVM space by bundling runtime libraries that address common desktop app needs. No other JVM packaging tool provides reactive dark mode detection, decorated windows, or deep link handling as a library. Since v1.3.0, the decorated window system offers two backends: JBR (for JetBrains Runtime) and JNI (for any JVM, including GraalVM Native Image), making it the only JVM window decoration solution that works across all JVM distributions. The `linux-hidpi` module and `graalvm-runtime` bootstrap module are also unique to Potassium. File associations are more widely supported (jpackage, install4j, Conveyor all register at OS level), but only Potassium combines registration with a runtime deep link handler. The `native-ssl` module is unique in the JVM packaging space: it replaces the JSSE default trust manager at runtime with one that reads directly from the OS certificate store, so corporate-issued CAs, enterprise Group Policy certificates, and filtering proxy roots are trusted automatically — without any JVM cacerts manipulation. The companion `native-http-okhttp` and `native-http-ktor` modules provide ready-to-use HTTP clients pre-configured with this trust manager. --- ### 12. Documentation & Developer Experience | Tool | Getting Started | DSL / Config Reference | CI/CD Guide | Migration Guide | API Docs | Examples | Score | |------|:---------------:|:----------------------:|:-----------:|:---------------:|:--------:|:--------:|:-----:| | **Potassium** | ✅ | ✅ (comprehensive) | ✅ (detailed) | ✅ (from Compose MP) | ✅ | ✅ (demo app) | **9** | | Conveyor | ✅ | ✅ (120+ settings) | ✅ | ✅ | ✅ | ✅ | **9** | | install4j | ✅ | ✅ (extensive) | ✅ | ❌ | ✅ | ✅ | **9** | | jpackage | ✅ (JEP + man page) | ❌ | ❌ | ❌ | ❌ | Minimal | **3** | | Compose MP | ✅ | Partial | ❌ | ❌ | ❌ | ✅ | **5** | ??? info "Sources" - **Potassium**: [docs site](https://kdroidfilter.github.io/Potassium/) — Getting Started, Configuration, per-platform guides, CI/CD, Runtime APIs, Migration, demo app in `example/` - **Conveyor**: [docs site](https://conveyor.hydraulic.dev/21.1/) — tutorials, 120+ config keys, CI guide, comparisons page, multiple sample projects - **install4j**: [help center](https://www.ej-technologies.com/resources/install4j/help/doc/) — extensive searchable docs - **jpackage**: [Oracle man page](https://docs.oracle.com/en/java/javase/23/docs/specs/man/jpackage.html) — CLI reference only - **Compose MP**: [Kotlin docs](https://kotlinlang.org/docs/multiplatform/compose-native-distribution.html) — basic packaging guide --- ### 13. Community, Maturity & Pricing | Tool | Age | Community | Release Cadence | Price | Comm. Score | Price Score | |------|:---:|:---------:|:---------------:|:-----:|:-----------:|:-----------:| | **Potassium** | 2025 | Small (growing) | Active | MIT (free) | **4** | **10** | | Conveyor | ~2022 | Small-Medium | Active | Free (OSS) / $45/mo | **6** | **6** | | install4j | ~2001 | Large (enterprise) | Active (Dec 2025) | $2,199+/dev | **10** | **3** | | jpackage | JDK 14+ | N/A (JDK built-in) | JDK releases | Free | **9** | **10** | | jDeploy | ~2020 | Small (growing) | Active (Feb 2026) | Apache 2 (free) | **5** | **10** | | Compose MP | ~2021 | Large (JetBrains) | Active (Feb 2026) | Apache 2 (free) | **9** | **10** | | JavaPackager | ~2019 | Small | Semi-maintained | GPL 3 (free) | **4** | **10** | | Badass plugins | ~2018 | Medium | Active (Jan 2026) | Apache 2 (free) | **5** | **10** | ??? info "Sources" - **Conveyor pricing**: [hydraulic.dev/pricing](https://hydraulic.dev/pricing.html) — Free for OSI-licensed projects (badge required); Standard $45/month (3 apps); Source tier on request - **install4j pricing**: [ej-technologies store](https://www.ej-technologies.com/store/install4j/new) — Multi-Platform $2,199 perpetual, Windows-only $769; floating license $6,599 - **install4j version**: [Changelog](https://www.ej-technologies.com/install4j/changelog) — v12.0.2, December 19, 2025 - **jDeploy**: [GitHub](https://github.com/shannah/jdeploy) — v6.0.16, February 16, 2026; Apache-2.0 - **Compose MP**: [Releases](https://github.com/JetBrains/compose-multiplatform/releases) — v1.10.1, February 2026 - **JavaPackager**: [Releases](https://github.com/fvarrui/JavaPackager/releases) — v1.7.6, June 30, 2024; maintainer seeks contributors - **Badass-jlink**: [Releases](https://github.com/beryx/badass-jlink-plugin/releases) — v3.2.1, January 29, 2026 - **Launch4j**: [Changelog](https://launch4j.sourceforge.net/changelog.html) — v3.50, November 13, 2022 - **Packr**: [Releases](https://github.com/libgdx/packr/releases) — v4.0.0, March 29, 2022 (dormant) --- ## Scoring Matrix Each dimension rated 0–10. Total = sum / 130 × 100 (rounded). | Tool | Fmt | Upd | Sign | CI | Plat | Store | Opt | Inst | RT | Docs | Comm | Price | Build | **Total** | |------|:---:|:---:|:----:|:--:|:----:|:-----:|:---:|:----:|:--:|:----:|:----:|:-----:|:-----:|:---------:| | **Potassium** | 10 | 9 | 10 | 10 | 10 | 10 | 10 | 9 | 10 | 9 | 4 | 10 | 6 | **90** | | **install4j** | 5 | 9 | 8 | 3 | 10 | 0 | 5 | 10 | 3 | 9 | 10 | 3 | 10 | **65**¹ | | **Conveyor** | 4 | 10 | 10 | 6 | 8 | 3 | 6 | 1 | 2 | 9 | 6 | 6 | 9 | **62**² | | **jDeploy** | 3 | 6 | 7 | 5 | 8 | 0 | 4 | 2 | 0 | 6 | 5 | 10 | 8 | **49** | | **Compose MP** | 4 | 0 | 5 | 1 | 5 | 0 | 6 | 2 | 1 | 5 | 9 | 10 | 6 | **42** | | **jpackage** | 4 | 0 | 3 | 0 | 8 | 1 | 4 | 3 | 1 | 3 | 9 | 10 | 4 | **38** | | **JavaPackager** | 5 | 0 | 3 | 1 | 5 | 0 | 4 | 4 | 0 | 4 | 4 | 10 | 7 | **36** | | **Badass** | 4 | 0 | 0 | 1 | 8 | 0 | 5 | 2 | 0 | 5 | 5 | 10 | 6 | **35** | | **Packr** | 1 | 0 | 0 | 0 | 5 | 0 | 3 | 0 | 0 | 3 | 2 | 10 | 4 | **22** | | **Launch4j** | 1 | 0 | 2 | 1 | 2 | 0 | 1 | 1 | 0 | 3 | 3 | 10 | 5 | **22** | | **JSmooth** | 1 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 1 | 0 | 10 | 1 | **11** | **Footnotes:** ¹ install4j's visual IDE, 20+ years of enterprise maturity, and cross-compilation (10/10) are significant advantages not fully captured by individual dimensions. ² Conveyor's cross-compilation (build all platforms from one machine, 10/10) is its biggest advantage. Its auto-update with delta updates (10/10) and broad signing provider support (10/10) are best-in-class. --- ## Detailed Tool Profiles ### jpackage (JDK 17+) Built into the JDK since Java 14 (GA in 16). Creates platform-specific installers (DMG, PKG, MSI, EXE, DEB, RPM) from a modular or non-modular Java application. Requires the target OS for building ([Oracle docs](https://docs.oracle.com/en/java/javase/23/docs/specs/man/jpackage.html)). macOS code signing and notarization via `--mac-sign`, `--mac-signing-key-user-name`, and `--mac-app-store` flags. Windows signing not included. Moderate customization via `--resource-dir`. WiX v4/v5 support added in JDK 24 ([JDK 24 release notes](https://www.oracle.com/java/technologies/javase/24-relnote-issues.html)). JDK 25 changed default jlink behavior: service bindings no longer included by default ([JDK 25 release notes](https://www.oracle.com/java/technologies/javase/25-relnote-issues.html)). No auto-update. The baseline that all JVM packaging tools build upon. ### Conveyor (Hydraulic) — v21.1 A modern CLI tool that uniquely supports **cross-compilation** — build for Windows, macOS, and Linux all from a single machine ([docs](https://conveyor.hydraulic.dev/21.1/)). Configured via HOCON (Human-Optimized Config Object Notation) with 120+ settings. **Formats**: MSIX + custom EXE installer (~500 KB, drives Windows Deployment Engine APIs) + ZIP on Windows; signed+notarized .app bundles in per-architecture ZIPs on macOS; DEB + tar.gz on Linux ([package formats](https://conveyor.hydraulic.dev/21.1/package-formats/)). No DMG (deliberately avoided — [FAQ](https://conveyor.hydraulic.dev/21.1/faq/output-formats/)), no PKG, no RPM (planned), no NSIS, no AppImage, no Snap, no Flatpak (planned). **Updates**: Sparkle 2 on macOS with delta patches (configurable, default 5 versions), MSIX native 64 KB-chunk delta on Windows, apt repositories on Linux ([update modes](https://conveyor.hydraulic.dev/21.1/configs/update-modes/)). **Signing**: Self-signing for free distribution, purchased Authenticode/SSL certificates (.p12/.pfx), macOS notarization via App Store Connect API keys, plus 6 cloud signing providers: Azure Artifact Signing, Azure Key Vault, AWS KMS, SSL.com eSigner, DigiCert ONE, Google Cloud KMS, and HSM support (SafeNet, YubiKey) ([keys and certificates](https://conveyor.hydraulic.dev/21.1/configs/keys-and-certificates/)). **OS integration**: Registers URL schemes (`app.url-schemes`) and file associations (`app.file-associations`) at OS level, but does **not** provide a runtime library — apps must implement receiving logic themselves ([OS integration](https://conveyor.hydraulic.dev/21.1/configs/os-integration/)). **JVM CA certificates**: `app.jvm.additional-ca-certs` imports extra PEM/DER certificates into the bundled JDK's `cacerts` at package time — useful for corporate proxies and private root CAs ([JVM config](https://conveyor.hydraulic.dev/21.1/configs/jvm/)). **Build systems**: Gradle plugin (Gradle 7+), Maven integration via CLI (`mvn dependency:build-classpath`), standalone CLI ([Gradle/Maven docs](https://conveyor.hydraulic.dev/21.1/configs/maven-gradle/)). **Store distribution**: Microsoft Store via `conveyor make ms-store-release`; Mac App Store not yet supported ([Windows config](https://conveyor.hydraulic.dev/21.1/configs/windows/)). **Platforms**: Windows x64+ARM64, macOS x64+ARM64 (separate per-architecture downloads, no universal/fat binary), Linux x64. Linux ARM64 has config infrastructure (`app.linux.aarch64`) but is listed as planned ([Linux config](https://conveyor.hydraulic.dev/21.1/configs/linux/), [output formats FAQ](https://conveyor.hydraulic.dev/21.1/faq/output-formats/)). **Pricing**: Free for OSI-licensed open source (badge required); $45/month Standard (3 apps); Source tier on request ([pricing](https://hydraulic.dev/pricing.html)). ### install4j (ej-technologies) — v12.0.2 The most mature commercial JVM installer tool (20+ years). Visual IDE for designing installer wizards with 80+ configurable actions ([features](https://www.ej-technologies.com/install4j/features)). Full cross-compilation from any OS ([homepage](https://www.ej-technologies.com/install4j)). Current version 12.0.2 released December 19, 2025 ([changelog](https://www.ej-technologies.com/install4j/changelog)). **Formats**: Native EXE installer (32-bit, 64-bit, ARM64), optional MSI wrapper, DMG archive, .app folder, shell-based GUI installer, RPM, DEB, gzipped tar.gz ([media files docs](https://www.ej-technologies.com/resources/install4j/help/doc/concepts/mediaFiles.html)). No PKG, NSIS, AppX/MSIX, AppImage, Snap, Flatpak. **v12 highlights**: Reworked installation directory handling, widget styles for UI customization, macOS binary launchers (start JVM in-process) ([what's new](https://www.ej-technologies.com/install4j/whatsnew12)). **Pricing**: Multi-Platform $2,199/dev perpetual (+ $879/yr support); Windows-only $769/dev; floating license $6,599 ([store](https://www.ej-technologies.com/store/install4j/new)). ### jDeploy — v6.0.16 Free, open-source tool (Apache-2.0) focused on simplicity ([GitHub](https://github.com/shannah/jdeploy)). Cross-compiles all platforms from one machine without any third-party tools other than OpenJDK ([FAQ](https://www.jdeploy.com/docs/faq/)). v6.0.16 released February 16, 2026. **Formats**: EXE on Windows, .app in tar.gz on macOS (DMG via separate [jdeploy-action-dmg](https://github.com/shannah/jdeploy-action-dmg) + macOS runner), DEB on Linux. The installer is pre-signed and notarized by jDeploy itself, so developers don't need their own Apple certificate ([FAQ](https://www.jdeploy.com/docs/faq/)). Auto-update is built-in via npm/GitHub Releases infrastructure ([blog](https://jdeploy.substack.com/p/automated-deployment-and-updates)). **v6.0 features**: Multi-modal deployments — GUI apps, CLI commands, background services, system tray helpers, and MCP servers for AI tool integration ([homepage](https://www.jdeploy.com/)). ### Compose Multiplatform (JetBrains) — v1.10.1 JetBrains' official Gradle plugin for Compose Desktop apps. v1.10.1 released February 2026 ([releases](https://github.com/JetBrains/compose-multiplatform/releases)). Wraps jpackage for packaging: supports DMG, PKG, MSI, EXE, DEB, RPM ([native distributions](https://kotlinlang.org/docs/multiplatform/compose-native-distribution.html)). macOS code signing and notarization via DSL. ProGuard integration. No auto-update, no cross-compilation, no NSIS/AppImage/Snap/Flatpak/AppX, minimal installer customization, no runtime libraries. **Potassium was designed as its successor/superset.** v1.10.0 highlights: Unified `@Preview` annotation, Navigation 3 on non-Android targets, stable Compose Hot Reload ([JetBrains blog](https://blog.jetbrains.com/kotlin/2026/01/compose-multiplatform-1-10-0/)). ### JavaPackager — v1.7.6 Gradle + Maven plugin supporting EXE (Inno Setup/WinRun4J), MSI (WiX), DMG, PKG, DEB, RPM, AppImage, and archives ([GitHub](https://github.com/fvarrui/JavaPackager)). Windows code signing via Jsign 5.0 (added in v1.7.4, [release notes](https://github.com/fvarrui/JavaPackager/releases/tag/v1.7.4)). JRE bundling with module selection. Last release: June 30, 2024. **Maintainer actively seeking contributors** ([GitHub](https://github.com/fvarrui/JavaPackager)). ### Badass-jlink / Badass-runtime Plugins Popular Gradle plugins wrapping jlink and jpackage with ergonomic APIs. badass-jlink v3.2.1 (January 2026) for modular apps; badass-runtime v2.0.1 (November 2025) for non-modular apps ([GitHub jlink](https://github.com/beryx/badass-jlink-plugin), [GitHub runtime](https://github.com/beryx/badass-runtime-plugin)). All jpackage formats supported. Inherits all jpackage limitations (no cross-compilation, no auto-update, no signing integration). Maintainer seeks co-maintainers ([GitHub](https://github.com/beryx/badass-jlink-plugin)). ### Launch4j — v3.50 Creates Windows EXE wrappers (~62 KB) for JAR files. Splash screen, JRE detection, heap configuration. Can be built cross-platform. Last release: November 13, 2022 ([changelog](https://launch4j.sourceforge.net/changelog.html)). An [omegat-org fork](https://github.com/omegat-org/launch4j) has more recent updates (2025) with ARM64 support. BSD license. ### Packr (libGDX) — v4.0.0 Bundles JRE with application into a directory structure. Game-focused (libGDX/LWJGL). Does NOT produce installers — only raw app bundles/executables. Dormant: last release March 29, 2022 ([GitHub releases](https://github.com/libgdx/packr/releases)). Apache 2.0 license. --- ## Strengths & Weaknesses ### Potassium's Strengths 1. **Unmatched format coverage** — 16 formats, more than any other JVM tool 2. **Only JVM tool with integrated runtime libraries** — dark mode, decorated windows (JBR + JNI backends), single instance, deep links, executable type detection, Linux HiDPI scaling 3. **Best CI pipeline for JVM apps** — 6 composite GitHub Actions, 6-runner matrix, universal macOS + MSIX bundle, GraalVM native image builds 4. **First JVM tool with AOT cache** — Project Leyden (JDK 25+), no GraalVM required 5. **First JVM tool with integrated GraalVM Native Image support** — compile Compose Desktop apps to standalone native binaries (~0.5s cold boot, ~100–150 MB RAM, no bundled JRE). Three startup tiers: standard JVM → AOT cache (Leyden) → native image 6. **Broadest store distribution** — 4 stores (MAS, MS Store, Flathub, Snap Store), unique JVM sandbox pipeline 7. **Full signing matrix** — macOS + notarization, Windows PFX + Azure Artifact Signing 8. **Free and open source** (MIT) 9. **Native SSL runtime** — unique JNI module using the OS trust store (macOS Security.framework, Windows Crypt32, Linux PEM bundles); pre-wired OkHttp and Ktor adapters; no cacerts manipulation needed at runtime 10. **Build-time CA cert patching** — import custom PEM/DER certificates into the bundled JVM's `cacerts` at packaging time (Conveyor also offers this via `app.jvm.additional-ca-certs`) 11. **Decorated windows on any JVM** — JNI backend (v1.3.0+) removes JBR dependency, enabling native window decorations with GraalVM, standard OpenJDK, and any other JVM distribution ### Potassium's Weaknesses 1. **No cross-compilation** — requires per-OS CI runners (mitigated by CI actions) 2. **Gradle-only** — no Maven or CLI support 3. **Young project** — smaller community, less battle-testing 4. **Depends on electron-builder** — Electron ecosystem dependency used as backend 5. **GraalVM Native Image is in alpha** — requires BellSoft Liberica NIK 25, limited to DMG/NSIS/DEB packaging, and some Compose features may need additional reachability metadata --- ## When to Choose Something Else | Use Case | Recommended Tool | Why | Source | |----------|-----------------|-----|--------| | **Build from one machine** | Conveyor or install4j | Full cross-compilation | [Conveyor](https://conveyor.hydraulic.dev/21.1/), [install4j](https://www.ej-technologies.com/install4j) | | **Custom installer UI** | install4j | Visual IDE, 80+ actions | [Features](https://www.ej-technologies.com/install4j/features) | | **Simplest setup** | jDeploy | Zero-config CLI, cross-compiles | [jDeploy](https://www.jdeploy.com/) | | **Enterprise deployment** | install4j | 20+ years maturity | [ej-technologies](https://www.ej-technologies.com/install4j) | | **Maven-only project** | install4j or JavaPackager | Potassium is Gradle-only | — | | **Delta updates critical** | Conveyor | MSIX + Sparkle delta | [Updates](https://conveyor.hydraulic.dev/21.1/understanding-updates/) | | **Maximum signing providers** | Conveyor | 6 cloud HSM services | [Keys](https://conveyor.hydraulic.dev/21.1/configs/keys-and-certificates/) | --- ## Methodology 1. **Codebase analysis** — Potassium plugin source, runtime libraries, CI workflows, and composite actions analyzed by [Claude Code](https://claude.ai/claude-code) with direct source file references 2. **Web research** — AI agents visited official documentation pages for all 11 tools, collecting version numbers, feature lists, pricing, and platform support 3. **Source verification** — Every claim includes a link to the official source (documentation page, GitHub repository, or vendor site) 4. **Scoring** — Each tool rated 0–10 across 13 dimensions; total = raw sum / 130 × 100 **Disclaimer:** While every effort was made to verify accuracy with source links, tool capabilities evolve. Always check the linked documentation for the latest information. Conveyor data verified against v21.1 docs (February 2026). Potassium data verified against v1.3.x source code (March 2026). --- # Migration from org.jetbrains.compose Potassium is a drop-in extension of the official JetBrains Compose Desktop plugin. All existing configuration is preserved — Potassium only adds new capabilities. ## Step 1: Add the Plugin ```diff plugins { id("org.jetbrains.kotlin.jvm") version "2.3.10" id("org.jetbrains.kotlin.plugin.compose") version "2.3.10" id("org.jetbrains.compose") version "1.10.1" + id("com.seanproctor.potassium") version "0.1.0" } ``` > The official `org.jetbrains.compose` plugin remains — Potassium extends it, not replaces it. The plugin is published to Maven Central, so make sure `mavenCentral()` is in your `pluginManagement.repositories`: ```kotlin // settings.gradle.kts pluginManagement { repositories { gradlePluginPortal() mavenCentral() } } ``` ## Step 2: Update Imports Replace the JetBrains Compose DSL imports with the Potassium equivalents: ```diff -import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import com.seanproctor.potassium.dsl.MacOSTargetFormat +import com.seanproctor.potassium.dsl.WindowsTargetFormat +import com.seanproctor.potassium.dsl.LinuxTargetFormat ``` This applies to all DSL types used in your `build.gradle.kts` (e.g. `MacOSTargetFormat`, `CompressionLevel`, `SigningAlgorithm`, etc.). Potassium splits the single Compose `TargetFormat` into per-OS enums declared inside each platform block — see [Step 3](#step-3-use-the-potassium-dsl). ## Step 3: Use the Potassium DSL Replace the `compose.desktop.application` block with `potassium` for packaging and distribution: ```diff -compose.desktop.application { +potassium { mainClass = "com.example.MainKt" - targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) packageName = "MyApp" packageVersion = "1.0.0" macOS { + targetFormats(MacOSTargetFormat.Dmg) bundleID = "com.example.myapp" iconFile.set(project.file("icons/app.icns")) } windows { + targetFormats(WindowsTargetFormat.Msi) iconFile.set(project.file("icons/app.ico")) } linux { + targetFormats(LinuxTargetFormat.Deb) iconFile.set(project.file("icons/app.png")) } } ``` **Using Compose Hot Reload?:** Some Compose plugin tasks (like `hotRun`) read `mainClass` from the original `compose.desktop.application` block, not from `potassium`. If you use [Compose Hot Reload](https://kotlinlang.org/docs/multiplatform/compose-hot-reload.html), either keep a minimal Compose block alongside Potassium: ```kotlin compose.desktop.application { mainClass = "com.example.MainKt" } ``` Or pass the property explicitly when running: ```bash ./gradlew hotRun -PmainClass=com.example.MainKt ``` ## Step 4: Add Potassium Features (Optional) Enable the features you need. All are opt-in: ```kotlin potassium { mainClass = "com.example.MainKt" packageName = "MyApp" packageVersion = "1.0.0" // --- New Potassium features --- cleanupNativeLibs = true enableAotCache = true splashImage = "splash.png" compressionLevel = CompressionLevel.Maximum artifactName = "${name}-${version}-${os}-${arch}.${ext}" // Deep links protocol("MyApp", "myapp") // File associations fileAssociation( mimeType = "application/x-myapp", extension = "myapp", description = "MyApp Document", ) // Target formats — grouped per OS (with new Potassium targets) macOS { targetFormats(MacOSTargetFormat.Dmg) } windows { targetFormats(WindowsTargetFormat.Nsis) } linux { targetFormats( LinuxTargetFormat.Deb, LinuxTargetFormat.AppImage, LinuxTargetFormat.Snap, LinuxTargetFormat.Flatpak, // NEW ) } // Publishing publish { github { enabled = true owner = "myorg" repo = "myapp" } } // NSIS customization windows { nsis { oneClick = false allowToChangeInstallationDirectory = true createDesktopShortcut = true } } } ``` ## What Changes | Feature | Before (compose) | After (potassium) | |---------|-------------------|---------------------------------------------------------------------------------------------------------------------------------------| | DSL entry point | `compose.desktop.application` | `potassium` | | DSL imports | `org.jetbrains.compose.desktop.application.dsl.*` | `com.seanproctor.potassium.dsl.*` | | Compose dependencies | `compose.desktop.currentOs`, `compose("…")` | Unchanged — keep using the official `org.jetbrains.compose` DSL. Potassium no longer re-exposes a `potassium.*` Compose-dependency accessor. | | Target formats | DMG, PKG, MSI, EXE, DEB, RPM | + NSIS, AppX, Portable, AppImage, Snap, Flatpak, archives | | Native lib cleanup | Manual | `cleanupNativeLibs = true` | | AOT cache | Not available | `enableAotCache = true` | | Splash screen | Manual | `splashImage = "splash.png"` | | Deep links | Manual (macOS only via Info.plist) | Cross-platform `protocol("name", "scheme")` | | File associations | Limited | Cross-platform `fileAssociation()` | | NSIS config | Not available | Full `nsis { }` DSL | | AppX config | Not available | Full `appx { }` DSL | | Snap config | Not available | Full `snap { }` DSL | | Flatpak config | Not available | Full `flatpak { }` DSL | | Store pipeline | Not available | Automatic dual pipeline for store formats (PKG, AppX, Flatpak) with sandboxing for PKG and Flatpak | | Auto-update | Not available | Built-in with YML metadata | | Code signing | macOS only | + Windows PFX / Azure Artifact Signing | | DMG appearance | Not customizable (jpackage defaults) | Full `dmg { }` DSL: background, icon size, window layout, content positioning, format ([details](targets/macos.md#dmg-customization)) | | Artifact naming | Fixed | Template with `artifactName` | ## Important Differences from Compose Desktop ### `homepage` is Required for Linux DEB Unlike Compose Desktop (which uses jpackage), Potassium uses electron-builder for packaging. Electron-builder **requires** the `homepage` property when building DEB packages. Without it, the build will fail with: ``` Please specify project homepage, see https://electron.build/configuration ``` Make sure to set it in your `potassium` block: ```kotlin potassium { homepage = "https://myapp.example.com" } ``` This also applies to GraalVM native image packaging (`packageGraalvmLinux`). ## What Stays the Same Everything from the official plugin works unchanged: - `mainClass`, `jvmArgs` - Package metadata, icons, resources (now set directly in the `potassium` block) - `buildTypes` / ProGuard configuration - `modules()` / `includeAllModules` - The standard Gradle tasks (`run`, `packageDistributionForCurrentOS`, the per-platform `packageMacOS` / `packageWindows` / `packageLinux`, etc.) - `compose.desktop.currentOs` dependency - Source set configuration - [Compose Hot Reload](https://kotlinlang.org/docs/multiplatform/compose-hot-reload.html) — works as usual since Potassium extends the Compose plugin. Note: `hotRun` reads `mainClass` from `compose.desktop.application`, so set it there too or pass `-PmainClass=...` (see [Step 3](#step-3-use-the-potassium-dsl)) --- # Roadmap Modules planned for upcoming releases. Contributions welcome. | Module | Description | macOS | Windows | Linux | |--------|-------------|-------|---------|-------| | `secure-storage` | Hardware-backed secret storage for tokens, passwords, keys | Keychain | Credential Manager / DPAPI | Secret Service (`libsecret`) | | `biometric-auth` | Prompt for fingerprint / face authentication | `LocalAuthentication` (Touch ID / Face ID) | Windows Hello | `fprintd` via D-Bus / polkit | | `share-sheet` | OS share sheet (URL, file, text) | `NSSharingService` | Windows `DataTransferManager` | xdg-desktop-portal `Share` | | `power-events` | Sleep / wake / lock / unlock / screen-off / battery state events | `NSWorkspace` notifications | `WM_POWERBROADCAST` / `WTSRegisterSessionNotification` | `org.freedesktop.login1` D-Bus signals | | `fs-watcher` | Native filesystem watcher (replaces slow `WatchService`) | `FSEvents` | `ReadDirectoryChangesW` | `inotify` | | `clipboard` | Rich clipboard — image, files, HTML, RTF — plus change watcher | `NSPasteboard` | `OleGetClipboard` / Clipboard History API | `wl-clipboard` / X11 selections | | `screen-capture` | Native screenshot / screen recording | `CGDisplayCreateImage` / ScreenCaptureKit | Windows Graphics Capture / DXGI | xdg-desktop-portal `Screenshot` |