# Nucleus > Nucleus is the all-in-one toolkit for shipping JVM desktop applications. It combines a Gradle plugin, runtime libraries, and GitHub Actions to handle performance (AOT), distribution (16 packaging formats), native look & feel (decorated windows, dark mode, system colors), and GraalVM Native Image support. Compatible with any JVM application, optimized for Compose Desktop. - Docs: https://nucleus.kdroidfilter.com - GitHub: https://github.com/kdroidFilter/Nucleus - Gradle Plugin Portal: https://plugins.gradle.org/plugin/io.github.kdroidfilter.nucleus - Maven Central: https://central.sonatype.com/search?q=io.github.kdroidfilter.nucleus - License: MIT **Nucleus is the native desktop platform for the JVM.** Combined with Compose Multiplatform, it forms the most complete, most performant, and most deeply integrated desktop application stack ever built — on any language, any runtime, any platform. Every technology eventually finds its mature form. Java evolved into **Kotlin**. JavaScript evolved into **TypeScript**. Desktop development is going through the same shift: Electron was the pioneer — it proved that cross-platform desktop apps could work. **Nucleus + Compose** is what comes next. Not an alternative. An evolution. Electron gave developers reach but asked them to accept a browser as a runtime, a DOM as a UI layer, and hundreds of megabytes as a baseline. Nucleus builds on the **JVM** and on **Compose Multiplatform** to deliver desktop applications that are natively integrated, natively fast, and natively lightweight. ## Why Nucleus ### Native on every OS Your app doesn't emulate native — it *is* native. Window decorations, notifications, taskbar badges, dock menus, dark mode, accent colors, global hotkeys, [system tray](runtime/system-tray/index.md) — everything behaves exactly as users expect on their OS. Not a web view wearing a disguise. A real desktop citizen, on every platform, on every screen. And Nucleus doesn't just expose native APIs — it **makes them simpler than the originals**. Windows Toast Notifications, macOS UserNotifications, Linux D-Bus StatusNotifierItem, Win32 ITaskbarList3, Unity LauncherEntry — each of these is a complex, platform-specific API with its own conventions, threading model, and pitfalls. Nucleus wraps every single one behind a clean, intuitive Kotlin API that feels the same everywhere. The result is paradoxical: a **cross-platform framework that makes native APIs easier to use than native development itself**. Writing a notification, managing a system tray, or showing taskbar progress takes a few lines of Kotlin — not pages of platform documentation. No compromise on capability. No lowest-common-denominator abstraction. Every platform feature, exposed in full, but through an API that any Kotlin developer can pick up in minutes. ### Performance that rivals C++ — with the simplicity of Kotlin The HotSpot JVM is the most advanced JIT compiler ever built. It optimizes your hot paths at runtime with decades of engineering behind it — delivering performance approaching C++ and Rust levels, but with the expressiveness of Kotlin. And unlike Electron's single-threaded event loop, the JVM gives you **true parallelism**: real threads, real cores, coroutines for structured concurrency, virtual threads for massive I/O. For maximum lightness, [GraalVM native image](graalvm/index.md) compiles your entire app into a standalone binary — **~0.5s cold start**, **100–150 MB RAM**, tiny bundle, no JRE needed. Compare that to **500 MB–1.5 GB** for a typical Electron app. ### The most advanced desktop UI stack Compose Multiplatform is not "React Native for desktop". It is a **compiled, type-safe, GPU-accelerated UI toolkit** with hardware-accelerated Skia rendering, a reactive state model, and shared code across Android, iOS, desktop, and web. No frontend/backend split. No REST API between your UI and your logic. No serialization layer. Your UI calls your business logic directly, in the same language, in the same process. Separate your concerns with **modules**, not with network boundaries. And on top of Compose sits **[Jewel](https://github.com/JetBrains/intellij-community/tree/master/platform/jewel#readme)** — the most advanced desktop UI framework in the world. Not a web framework adapted for desktop. A desktop framework, period. Jewel carries behind it the entire experience of JetBrains and its IDEs — IntelliJ IDEA, Android Studio, WebStorm — applications used daily by millions of developers, built with desktop in mind from day one. Nucleus integrates deeply with both Jewel and Material 2/3, plus native window controls and OS-level hooks. ## What Nucleus provides ### Ship everywhere - **16 packaging formats** — DMG, PKG, NSIS, MSI, AppX, Portable, DEB, RPM, AppImage, Snap, Flatpak, ZIP, TAR, 7Z - **Store-ready** — Mac App Store, Microsoft Store, Snapcraft, Flathub — one build, all stores - **Code signing & notarization** — Windows and macOS, built into the build pipeline - **Auto-update** — Your app checks for updates, downloads them, verifies integrity, and installs — all built-in - **Deep links & file associations** — Protocol handlers and file type registration on all platforms ### Go deeper when you need to Nucleus meets you where you are: - **Just ship an app?** — One Gradle DSL, one command. Done. - **Need OS integration?** — 30+ runtime modules with intuitive Kotlin APIs: notifications, launchers, dark mode, system colors, taskbar progress, energy management, and more. - **Need a platform API no library covers?** — [Native Access](native-access/index.md) lets you write Kotlin/Native and call it from the JVM. No C, no boilerplate, no build scripts. - **Need maximum lightness?** — [GraalVM native image](graalvm/index.md) compiles your app into a standalone binary. Nucleus resolves all reflection metadata transparently. - **CI/CD ready** — Reusable GitHub Actions, multi-platform matrix builds, universal macOS binaries, MSIX bundles. ## Quick start ```kotlin plugins { id("io.github.kdroidfilter.nucleus") version "" } nucleus.application { mainClass = "com.example.MainKt" nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Nsis, TargetFormat.Deb) packageName = "MyApp" packageVersion = "1.0.0" } } ``` ```bash ./gradlew run # Run locally ./gradlew packageDistributionForCurrentOS # Build installer for current OS ``` ## Try the demo A pre-built demo is available on the [GitHub Releases page](https://github.com/kdroidFilter/Nucleus/releases). ### macOS ```bash curl -fsSL https://nucleus.kdroidfilter.com/install.sh | bash ``` Detects your architecture (Apple Silicon or Intel), downloads, installs to `/Applications`, and launches. ### Linux ```bash curl -fsSL https://nucleus.kdroidfilter.com/install-linux.sh | bash ``` Detects your architecture and package manager, downloads and installs the appropriate `.deb` or `.rpm`. ### Windows Download the installer from the [releases page](https://github.com/kdroidFilter/Nucleus/releases). What you'll see: - **Instant startup** — Near-instant cold boot powered by JDK 25+ AOT cache - **Decorated Window** — Custom title bar with native window controls, Material 3 themed - **Dark Mode Detection** — Toggle your OS theme and watch the app switch in real time - **Auto-Update** — Checks for updates on launch, downloads with progress tracking, installs & restarts in one click **Test auto-update:** Download an **older release**, install it, and launch. It will detect the newer version and offer to update — automatically. The demo source code is in the [`example/`](https://github.com/kdroidFilter/Nucleus/tree/main/example) directory. ## Requirements | Requirement | Version | Note | |-------------|---------|------| | JDK | 17+ (25+ for AOT cache) | JBR 25 recommended | | Gradle | 8.0+ | | | Kotlin | 2.0+ | | ## License MIT — See [LICENSE](https://github.com/kdroidFilter/Nucleus/blob/main/LICENSE). --- # Getting Started ## Prerequisites Nucleus 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-nucleus` composite action installs Node.js automatically. See [CI/CD](ci-cd.md) for details. ## Installation Add the Nucleus plugin to your `build.gradle.kts`: ```kotlin plugins { id("io.github.kdroidfilter.nucleus") version "" } ``` The plugin is available on the [Gradle Plugin Portal](https://plugins.gradle.org/plugin/io.github.kdroidfilter.nucleus). No additional repository configuration is needed. ### Runtime Libraries (Optional) Runtime libraries are published on Maven Central: ```kotlin dependencies { implementation(compose.desktop.currentOs) // Executable type detection + single instance + deep links implementation("io.github.kdroidfilter:nucleus.core-runtime:") // AOT cache runtime detection (includes core-runtime) implementation("io.github.kdroidfilter:nucleus.aot-runtime:") // Auto-update library (includes core-runtime) implementation("io.github.kdroidfilter:nucleus.updater-runtime:") // Native taskbar/dock progress bar implementation("io.github.kdroidfilter:nucleus.taskbar-progress:") // Custom decorated window with native title bar implementation("io.github.kdroidfilter:nucleus.decorated-window:") } ``` ## Minimal Configuration ```kotlin nucleus.application { mainClass = "com.example.MainKt" nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Nsis, TargetFormat.Deb) packageName = "MyApp" packageVersion = "1.0.0" } } ``` ## Gradle Tasks ### Development | Task | Description | |------|-------------| | `run` | Run the application from the IDE/terminal | | `runDistributable` | Run the packaged application image | #### Compose Hot Reload Nucleus is fully compatible with [Compose Hot Reload](https://kotlinlang.org/docs/multiplatform/compose-hot-reload.html). Since Nucleus 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 `nucleus.application`, 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 a specific format (e.g., `packageDmg`, `packageNsis`, `packageDeb`) | | `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 a DMG on macOS ./gradlew packageDmg # Build NSIS installer on Windows ./gradlew packageNsis # Build DEB package on Linux ./gradlew packageDeb # Build all formats for current OS ./gradlew packageDistributionForCurrentOS # Release build (with ProGuard) ./gradlew packageReleaseDistributionForCurrentOS ``` ## Output Location Build artifacts are generated in: ``` build/compose/binaries/main// build/compose/binaries/main-release// # Release builds ``` Override with: ```kotlin nativeDistributions { 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 nativeDistributions { modules("java.sql", "java.net.http", "jdk.accessibility") } ``` Or include everything (larger binary): ```kotlin nativeDistributions { includeAllModules = true } ``` ## Application Icons Provide platform-specific icon files: ```kotlin nativeDistributions { 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 nativeDistributions { 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 Nucleus configuration lives inside the `nucleus.application { }` block in your `build.gradle.kts`. ## Overview ```kotlin nucleus.application { mainClass = "com.example.MainKt" jvmArgs += listOf("-Xmx512m") buildTypes { release { proguard { isEnabled = true optimize = true obfuscate.set(false) configurationFiles.from(project.file("proguard-rules.pro")) } } } nativeDistributions { // Target formats targetFormats(TargetFormat.Dmg, TargetFormat.Nsis, TargetFormat.Deb) // 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") // Nucleus 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 macOS { /* ... */ } windows { /* ... */ } linux { /* ... */ } } } ``` ## Target Formats All available formats: | Format | Platform | Task Name | Notes | |--------|----------|-----------|-------| | `TargetFormat.Dmg` | macOS | `packageDmg` | | | `TargetFormat.Pkg` | macOS | `packagePkg` | | | `TargetFormat.Nsis` | Windows | `packageNsis` | NSIS installer (`.exe`) | | `TargetFormat.Exe` | Windows | `packageExe` | Alias for `Nsis` — same output | | `TargetFormat.NsisWeb` | Windows | `packageNsisWeb` | NSIS web installer | | `TargetFormat.Msi` | Windows | `packageMsi` | | | `TargetFormat.Portable` | Windows | `packagePortable` | | | `TargetFormat.AppX` | Windows | `packageAppX` | MSIX format | | `TargetFormat.Deb` | Linux | `packageDeb` | | | `TargetFormat.Rpm` | Linux | `packageRpm` | | | `TargetFormat.AppImage` | Linux | `packageAppImage` | | | `TargetFormat.Snap` | Linux | `packageSnap` | | | `TargetFormat.Flatpak` | Linux | `packageFlatpak` | | | `TargetFormat.Zip` | All | `packageZip` | | | `TargetFormat.Tar` | All | `packageTar` | | | `TargetFormat.SevenZ` | All | `packageSevenZ` | | Target all formats at once: ```kotlin targetFormats(*TargetFormat.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/compose/binaries` | Output directory for built packages | ### Version Formats Different platforms have different version requirements: | Platform | Format | Example | |----------|--------|---------| | macOS | `MAJOR.MINOR.PATCH` | `1.2.3` | | Windows | `MAJOR.MINOR.BUILD` | `1.2.3` | | Linux DEB | `[EPOCH:]UPSTREAM[-REVISION]` | `1.2.3` | | Linux RPM | No dashes | `1.2.3` | ## Nucleus-Specific Features ### Native Library Cleanup Strips `.dll`, `.so`, `.dylib` files for non-target platforms from dependency JARs, reducing package size significantly. ```kotlin nativeDistributions { cleanupNativeLibs = true } ``` ### AOT Cache (JDK 25+) Generates an ahead-of-time compilation cache for faster startup: ```kotlin nativeDistributions { enableAotCache = true } ``` Requires JDK 25+ and that the application self-terminates during the training run. See [AOT Cache](runtime/aot-cache.md) for the application-side helper. ### Splash Screen Displays a splash screen during application startup: ```kotlin nativeDistributions { splashImage = "splash.png" // Relative to appResources } ``` ### Artifact Naming Customize output filenames with template variables: ```kotlin nativeDistributions { 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 nativeDistributions { 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 nativeDistributions { 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 nativeDistributions { protocol("MyApp", "myapp") // Registers myapp:// protocol } ``` - **macOS**: `CFBundleURLTypes` in `Info.plist` - **Windows**: Registry entries via NSIS/MSI - **Linux**: `MimeType` in `.desktop` file See [Deep Links](runtime/deep-links.md) for handling incoming deep links. ### File Associations Register file type associations: ```kotlin nativeDistributions { 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 packageReleaseDmg ./gradlew packageReleaseNsis ./gradlew packageReleaseDistributionForCurrentOS ``` ## Full DSL Tree ``` nucleus.application { mainClass jvmArgs buildTypes { release { proguard { isEnabled, version, optimize, obfuscate, joinOutputJars, configurationFiles } } } nativeDistributions { targetFormats(...) 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 { iconFile, bundleID, dockName, appCategory, layeredIconDir, signing , notarization , dmg , infoPlist } windows { iconFile, upgradeUuid, signing , nsis , appx } linux { 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 Nucleus 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 targetFormats(TargetFormat.Dmg, TargetFormat.Pkg) ``` ## General macOS Settings ```kotlin nativeDistributions { 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 | Nucleus 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 Nucleus **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 nucleus { nativeDistributions { 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 | | `packageDmg` / `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 cached at `~/Library/Caches/nucleus/patched-jvm/` and invalidated when the source JDK changes. ### 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 Nucleus 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("@keychain:AC_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 | | `packageVersion` | `String?` | `null` | Override version | | `packageBuildVersion` | `String?` | `null` | CFBundleVersion | | `dmgPackageVersion` | `String?` | `null` | DMG-specific version | | `dmgPackageBuildVersion` | `String?` | `null` | DMG-specific build version | | `pkgPackageVersion` | `String?` | `null` | PKG-specific version | | `pkgPackageBuildVersion` | `String?` | `null` | PKG-specific build version | | `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 Nucleus 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 targetFormats( TargetFormat.Nsis, TargetFormat.Msi, TargetFormat.AppX, TargetFormat.Portable, ) ``` ## General Windows Settings ```kotlin nativeDistributions { 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 Trusted 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 nativeDistributions { // 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 Nucleus 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 targetFormats( TargetFormat.Deb, TargetFormat.Rpm, TargetFormat.AppImage, TargetFormat.Snap, TargetFormat.Flatpak, ) ``` ## General Linux Settings ```kotlin nativeDistributions { 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") // Version override for DEB debPackageVersion = "1.0.0" } ``` ## RPM Package ```kotlin linux { // Dependencies injected into the RPM spec rpmRequires = listOf("gtk3", "libX11", "alsa-lib") // Version override for RPM (no dashes allowed) rpmPackageVersion = "1.0.0" // 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-nucleus` GitHub Action** (recommended): ```yaml - uses: kdroidFilter/Nucleus/.github/actions/setup-nucleus@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 nativeDistributions { 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 | | `debPackageVersion` | `String?` | `null` | DEB version override | | `rpmPackageVersion` | `String?` | `null` | RPM version override | | `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 Nucleus 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 determines which formats require sandboxing via `TargetFormat.isStoreFormat`: ```kotlin targetFormats( // Direct distribution — non-sandboxed pipeline TargetFormat.Dmg, TargetFormat.Nsis, TargetFormat.Deb, // Store distribution — sandboxed pipeline TargetFormat.Pkg, TargetFormat.AppX, TargetFormat.Flatpak, ) ``` 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, Nucleus 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 Nucleus uses: ./.github/actions/setup-nucleus 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-nucleus` 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. Nucleus 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 Trusted Signing For cloud-based signing with [Azure Trusted Signing](https://learn.microsoft.com/en-us/azure/trusted-signing/): ```kotlin windows { signing { enabled = true 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+: ```kotlin macOS { notarization { appleID.set("dev@example.com") password.set("@keychain:AC_PASSWORD") teamID.set("TEAMID") } } ``` > **Tip:** Use `xcrun notarytool store-credentials` to save credentials in the keychain: > ```bash > xcrun notarytool store-credentials "AC_PASSWORD" \ > --apple-id "dev@example.com" \ > --team-id "TEAMID" \ > --password "app-specific-password" > ``` ### 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. Nucleus 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 Nucleus 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**: The plugin generates update metadata files (`latest-*.yml`) alongside your installers 2. **Runtime**: The `nucleus.updater-runtime` library checks for updates, downloads, and installs them ## How It Works **Try it yourself:** Download an **older version** of the Nucleus 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 Nucleus 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 `TargetFormat.Zip` in your `targetFormats` configuration, otherwise macOS auto-update will not work: ```kotlin nativeDistributions { targetFormats( TargetFormat.Dmg, // Initial install TargetFormat.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 The plugin already generates a `latest-*.yml` file alongside the installers when you run `packageDistributionForCurrentOS`. 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 Nucleus 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 in `nativeDistributions` **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 nativeDistributions { 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:nucleus.updater-runtime:1.0.0") } ``` ### Quick Start ```kotlin import io.github.kdroidfilter.nucleus.updater.NucleusUpdater import io.github.kdroidfilter.nucleus.updater.UpdateResult import io.github.kdroidfilter.nucleus.updater.provider.GitHubProvider val updater = NucleusUpdater { 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 NucleusUpdater { // 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 io.github.kdroidfilter.nucleus.updater.provider.GitHubProvider provider = GitHubProvider( owner = "myorg", repo = "myapp", token = "ghp_..." // Optional, for private repos ) ``` #### Generic HTTP Server ```kotlin import io.github.kdroidfilter.nucleus.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 #### NucleusUpdater | 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 { NucleusUpdater { 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 built with [`NativeHttpClient`](runtime/native-http.md) — it is pre-configured with the OS trust store via `NativeTrustManager`: **1. Add the dependency** ```kotlin dependencies { implementation("io.github.kdroidfilter:nucleus.updater-runtime:") implementation("io.github.kdroidfilter:nucleus.native-http:") } ``` **2. Inject the client in the updater config** ```kotlin import io.github.kdroidfilter.nucleus.nativehttp.NativeHttpClient import io.github.kdroidfilter.nucleus.updater.NucleusUpdater import io.github.kdroidfilter.nucleus.updater.provider.GitHubProvider val updater = NucleusUpdater { 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 io.github.kdroidfilter.nucleus.nativehttp.NativeHttpClient.withNativeSsl import java.net.http.HttpClient import java.time.Duration val updater = NucleusUpdater { 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 = NucleusUpdater { 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: NucleusUpdater) { 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 `NucleusApp.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 Nucleus can publish your installers and update metadata to **GitHub Releases**, **Amazon S3**, or a **generic HTTP server**. ## Configuration ```kotlin nativeDistributions { publish { // Publish mode publishMode = PublishMode.Auto // Never, Auto, Always github { enabled = true owner = "myorg" repo = "myapp" token = System.getenv("GITHUB_TOKEN") channel = ReleaseChannel.Latest releaseType = ReleaseType.Release } // Or S3 s3 { enabled = true bucket = "my-updates-bucket" region = "us-east-1" path = "releases" acl = "public-read" } // Or generic HTTP server generic { enabled = true 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 Nucleus 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 NucleusUpdater { 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, Nucleus lets you declare the certificates once in your build script — they are imported automatically during packaging. ## Configuration ```kotlin nativeDistributions { 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, Nucleus 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 ↓ packageDmg / packageDeb / packageNsis / … ``` ## Notes - The original JLink runtime image is **never modified**. The patched copy lives in `build/compose/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. --- # AOT Cache JVM desktop applications can suffer from noticeable cold-start latency — the JVM has to load thousands of classes, verify bytecode, and JIT-compile hot paths before the UI feels responsive. **This was the primary motivation behind the creation of Nucleus.** [Project Leyden](https://openjdk.org/projects/leyden/) is an OpenJDK initiative originally designed to improve startup time for server-side workloads like microservices. Starting with JDK 25, it introduced **single-step AOT cache generation**: the JVM records a training run and produces a cache that dramatically accelerates subsequent launches. Nucleus brings this technology to **desktop applications** — the AOT cache is generated automatically during the build and bundled with the distributed installer, giving your Compose Desktop app near-instant cold boot with zero effort from the end user. ## Build Configuration **JDK 25+ strictly required:** AOT cache generation requires **JDK 25 or later**. If an older JDK is detected, the build **will fail**. Make sure your toolchain and CI environments use JDK 25+. Enable AOT cache in your `build.gradle.kts`: ```kotlin nucleus.application { nativeDistributions { enableAotCache = true } } ``` That's all you need on the build side. When enabled, the plugin will: 1. Launch your application in **training mode** after `createDistributable` 2. Record class loading and JIT compilation into an `app.aot` cache file (`-XX:AOTCacheOutput`) 3. Inject `-XX:AOTCache=app.aot` into the application launcher configuration 4. Bundle the cache with the final installer (DMG, NSIS, DEB, etc.) ### Platform-specific caches The AOT cache is **platform- and JDK-specific**. A cache generated on macOS ARM64 with JBR 25.0.2 will not work on Linux x64 or with a different JDK version. This means: - The cache **must be generated separately on each target platform** - The JDK used at build time **must be exactly the same** as the one bundled in the final distribution - **Let the plugin handle everything** — it uses the bundled JDK from `createDistributable` to generate the cache, ensuring a perfect match **Use CI for cross-platform builds:** The simplest and safest approach is to build on CI with a matrix strategy (one job per OS). The `setup-nucleus` action configures the same JBR version on every platform, ensuring consistent cache generation. See [CI/CD](../ci-cd.md) for a complete workflow example. ## Runtime Library To detect AOT mode at runtime (e.g. to self-terminate during training or skip heavy initialization), add the runtime library: ```kotlin dependencies { implementation("io.github.kdroidfilter:nucleus.aot-runtime:") // Transitive: nucleus.core-runtime is pulled in via `api` } ``` ```kotlin import io.github.kdroidfilter.nucleus.aot.runtime.AotRuntime import io.github.kdroidfilter.nucleus.aot.runtime.AotRuntimeMode ``` ## Modes | Method | Returns `true` when... | |--------|------------------------| | `AotRuntime.isTraining()` | App is running during AOT cache generation | | `AotRuntime.isRuntime()` | App is running with an AOT cache loaded | | `AotRuntime.mode()` | Returns `AotRuntimeMode.TRAINING`, `AotRuntimeMode.RUNTIME`, or `AotRuntimeMode.OFF` | ## Training Mode During AOT training, the plugin launches your application so the JVM can record which classes are loaded and which methods are compiled. Your application **must self-terminate** during this phase — otherwise the build will hang until the safety timeout (300 seconds) kills the process. ### Basic approach The simplest strategy is to run the app for a fixed duration (30–45 seconds is usually enough) and exit: ```kotlin private const val AOT_TRAINING_DURATION_MS = 45_000L fun main() { if (AotRuntime.isTraining()) { Thread({ Thread.sleep(AOT_TRAINING_DURATION_MS) System.exit(0) }, "aot-timer").apply { isDaemon = false start() } } application { Window(onCloseRequest = ::exitApplication, title = "MyApp") { App() } } } ``` ### Optimized approach For maximum startup improvement, actively exercise your application's hot paths during training. The more classes the JVM loads during the training run, the more it can pre-compile in the cache: ```kotlin fun main() { if (AotRuntime.isTraining()) { Thread({ Thread.sleep(AOT_TRAINING_DURATION_MS) System.exit(0) }, "aot-timer").apply { isDaemon = false start() } } // Eagerly load classes that the user will hit on first launch if (AotRuntime.isTraining()) { preloadNavigationScreens() preloadFontsAndImages() initializeDatabase() } application { Window(onCloseRequest = ::exitApplication, title = "MyApp") { App() } } } ``` The more representative the training run is of a real user session, the better the cold-start performance will be. ## How It Works The plugin sets the `nucleus.aot.mode` system property: - `training` — set during the AOT cache generation step - `runtime` — set when an AOT cache is loaded - absent — no AOT (`AotRuntime.mode()` returns `AotRuntimeMode.OFF`) ## Requirements - The training run must exit with code `0` - The plugin enforces a safety timeout of 300 seconds — if the app hasn't exited by then, the process is force-killed - On headless Linux, the plugin uses Xvfb automatically ## Further Reading - [Project Leyden](https://openjdk.org/projects/leyden/) — the OpenJDK initiative behind AOT cache technology - [CI/CD](../ci-cd.md) — cross-platform build workflows with `setup-nucleus` ## ExecutableRuntime Re-export The `aot-runtime` module re-exports `ExecutableRuntime` and `ExecutableType` via type aliases, so you can import from either package: ```kotlin // Both work: import io.github.kdroidfilter.nucleus.core.runtime.ExecutableRuntime import io.github.kdroidfilter.nucleus.aot.runtime.ExecutableRuntime ``` --- # 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, [AOT Cache (Leyden)](../runtime/aot-cache.md) 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**. Nucleus 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 — Nucleus handles native image transparently. ## Next steps - [Configuration](configuration.md) — Gradle DSL and build arguments - [Automatic Metadata](automatic-metadata.md) — How Nucleus 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 nucleus.application { 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`:** Nucleus 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 Nucleus 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, Nucleus combines five complementary metadata sources that are resolved and merged automatically at build time. ## Level 1 — Per-library conditional metadata The Nucleus 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 Nucleus 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 Nucleus'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 Nucleus 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 Nucleus 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`, Nucleus 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:nucleus.graalvm-runtime:") } ``` Then call `GraalVmInitializer.initialize()` as the **first line** of your `main()` function, before any AWT or Compose usage: ```kotlin import io.github.kdroidfilter.nucleus.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 via [`linux-hidpi`](../runtime/linux-hidpi.md) (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.*`) | | `nucleus/native/.*` | All Nucleus 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 [`decorated-window-jni`](../runtime/decorated-window.md) module was specifically designed to work with GraalVM Native Image (no JBR dependency). Use it instead of `decorated-window-jbr` 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 | | `packageGraalvmDeb` | Package the native image as a `.deb` installer (Linux) | | `packageGraalvmDmg` | Package the native image as a `.dmg` installer (macOS) | | `packageGraalvmNsis` | 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 packageGraalvmDeb # Linux ./gradlew packageGraalvmDmg # macOS ./gradlew packageGraalvmNsis # Windows # NOTE: The `homepage` property is required in nativeDistributions 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/compose/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/compose/binaries//graalvm-/ ``` ## CI/CD Native image compilation must happen **on each target platform**. Use `setup-nucleus` 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 Nucleus (GraalVM) uses: kdroidFilter/Nucleus/.github/actions/setup-nucleus@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:packageGraalvmDeb \ -PnativeMarch=compatibility --no-daemon elif [ "$RUNNER_OS" = "macOS" ]; then ./gradlew :myapp:packageGraalvmDmg \ -PnativeMarch=compatibility --no-daemon elif [ "$RUNNER_OS" = "Windows" ]; then ./gradlew :myapp:packageGraalvmNsis \ -PnativeMarch=compatibility --no-daemon fi - uses: actions/upload-artifact@v4 with: name: graalvm-${{ runner.os }} path: myapp/build/compose/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 Nucleus 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 Nucleus 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) - [Nucleus example app](https://github.com/kdroidFilter/Nucleus/tree/main/example) — minimal Compose Desktop + native-image setup - [Nucleus 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. Nucleus 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:** Nucleus'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 Nucleus. 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 + Nucleus, 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("io.github.kdroidfilter.nucleus") } kotlin { jvmToolchain(25) jvm() sourceSets { val jvmMain by getting { dependencies { implementation(compose.desktop.currentOs) implementation(project(":native")) } } } } nucleus.application { 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 [Nucleus 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 Nucleus provides reusable composite actions and ready-to-use GitHub Actions workflows for building, packaging, and publishing desktop applications across all platforms. **Use Nucleus actions in your own project:** All composite actions can be referenced directly from the Nucleus repository — no need to copy them into your project: ```yaml - uses: kdroidFilter/Nucleus/.github/actions/setup-nucleus@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-nucleus` Action The `setup-nucleus` composite action (`.github/actions/setup-nucleus`) 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-nucleus@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-nucleus@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 Nucleus uses: kdroidFilter/Nucleus/.github/actions/setup-nucleus@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/compose/binaries/**/*.dmg build/compose/binaries/**/*.pkg build/compose/binaries/**/*.exe build/compose/binaries/**/*.msi build/compose/binaries/**/*.appx build/compose/binaries/**/*.deb build/compose/binaries/**/*.rpm build/compose/binaries/**/*.AppImage build/compose/binaries/**/*.snap build/compose/binaries/**/*.flatpak build/compose/binaries/**/*.zip build/compose/binaries/**/*.tar build/compose/binaries/**/*.7z build/compose/binaries/**/*.blockmap build/compose/binaries/**/signing-metadata.json build/compose/binaries/**/packaging-metadata.json !build/compose/binaries/**/app/** !build/compose/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-nucleus@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" nucleus.application { nativeDistributions { packageVersion = releaseVersion } } ``` ## Universal macOS Binaries Merge arm64 and x64 builds into a universal (fat) binary using `lipo`, then optionally sign and notarize. Nucleus 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`. Nucleus 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. Nucleus 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 Nucleus provides reusable composite actions that you can reference directly in your workflows using `kdroidFilter/Nucleus/.github/actions/@main`: | Action | Usage | Description | |--------|-------|-------------| | `setup-nucleus` | `kdroidFilter/Nucleus/.github/actions/setup-nucleus@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-nucleus` with `graalvm: 'true'` and the `packageGraalvm` tasks: ```yaml name: Release GraalVM Native Image on: push: tags: ['v*'] permissions: contents: write jobs: build-natives: uses: ./.github/workflows/build-natives.yaml build: 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 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 # Download pre-built JNI native libraries # (darkmode-detector, native-ssl, decorated-window-jni, etc.) - name: Setup Nucleus (GraalVM) uses: kdroidFilter/Nucleus/.github/actions/setup-nucleus@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:packageGraalvmDeb \ -PnativeMarch=compatibility --no-daemon elif [ "$RUNNER_OS" = "macOS" ]; then ./gradlew :myapp:packageGraalvmDmg \ -PnativeMarch=compatibility --no-daemon elif [ "$RUNNER_OS" = "Windows" ]; then ./gradlew :myapp:packageGraalvmNsis \ -PnativeMarch=compatibility --no-daemon fi - uses: actions/upload-artifact@v4 with: name: graalvm-${{ runner.os }}-${{ matrix.arch }} path: myapp/build/compose/binaries/**/graalvm-*/** if-no-files-found: error ``` ### GraalVM Packaging Tasks | Task | Format | Platform | |------|--------|----------| | `packageGraalvmDeb` | `.deb` | Linux | | `packageGraalvmDmg` | `.dmg` | macOS | | `packageGraalvmNsis` | `.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-nucleus` 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-nucleus` 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 Nucleus repository - **workflow_dispatch**: Add it as a trigger to allow re-running a release manually --- # Runtime APIs Nucleus provides runtime libraries for use in your application code. All are published on Maven Central. ## Libraries | Library | Artifact | Description | |---------|----------|-------------| | Core Runtime | `io.github.kdroidfilter:nucleus.core-runtime` | Executable type detection, single instance, deep links, app metadata (`NucleusApp`) | | AOT Runtime | `io.github.kdroidfilter:nucleus.aot-runtime` | AOT cache detection (includes core-runtime via `api`) | | Updater Runtime | `io.github.kdroidfilter:nucleus.updater-runtime` | Auto-update library with update level detection and post-update events (includes core-runtime) | | Taskbar Progress | `io.github.kdroidfilter:nucleus.taskbar-progress` | Native taskbar/dock progress bar and attention requests (Windows, macOS, Linux) | | Notification (macOS) | `io.github.kdroidfilter:nucleus.notification-macos` | macOS UserNotifications API — local notifications, actions, badges via JNI | | Notification (Windows) | `io.github.kdroidfilter:nucleus.notification-windows` | Windows Toast Notifications API — rich toasts, buttons, progress bars via JNI (WinRT) | | Launcher (Windows) | `io.github.kdroidfilter:nucleus.launcher-windows` | Windows Launcher API — badge notifications, jump lists, overlay icons, and thumbnail toolbar (ITaskbarList3) via JNI | | Notification (Linux) | `io.github.kdroidfilter:nucleus.notification-linux` | Freedesktop Desktop Notifications API via JNI (D-Bus) | | Launcher (Linux) | `io.github.kdroidfilter:nucleus.launcher-linux` | Unity Launcher API — badge, progress, urgency, quicklist via JNI (D-Bus) | | Launcher (macOS) | `io.github.kdroidfilter:nucleus.launcher-macos` | macOS dock context menu — custom items, submenus, click callbacks via JNI | | Menu (macOS) | `io.github.kdroidfilter:nucleus.menu-macos` | Complete NSMenu mapping — application menu bar, items, badges, delegates, SF Symbols via JNI | | SF Symbols | `io.github.kdroidfilter:nucleus.sf-symbols` | Type-safe Apple SF Symbols constants (6 195 symbols, 21 categories) | | Freedesktop Icons | `io.github.kdroidfilter:nucleus.freedesktop-icons` | Type-safe freedesktop Icon Naming Specification constants | | Decorated Window | `io.github.kdroidfilter:nucleus.decorated-window` | Custom window decorations with native title bar | | Decorated Window — Jewel | `io.github.kdroidfilter:nucleus.decorated-window-jewel` | Jewel (IntelliJ theme) color mapping for decorated windows | | Decorated Window — Material 2 | `io.github.kdroidfilter:nucleus.decorated-window-material2` | Material 2 color mapping for decorated windows | | Decorated Window — Material 3 | `io.github.kdroidfilter:nucleus.decorated-window-material3` | Material 3 color mapping for decorated windows | | Dark Mode Detector | `io.github.kdroidfilter:nucleus.darkmode-detector` | Reactive OS dark mode detection via JNI | | System Color | `io.github.kdroidfilter:nucleus.system-color` | Reactive system accent color and high contrast detection via JNI | | Energy Manager | `io.github.kdroidfilter:nucleus.energy-manager` | Process-level and thread-level energy efficiency mode and screen-awake (caffeine) API for Windows, macOS, and Linux | | Native SSL | `io.github.kdroidfilter:nucleus.native-ssl` | OS trust store integration — merges native certs with JVM defaults | | Native HTTP | `io.github.kdroidfilter:nucleus.native-http` | `java.net.http.HttpClient` pre-configured with native OS trust | | Native HTTP — OkHttp | `io.github.kdroidfilter:nucleus.native-http-okhttp` | OkHttp client pre-configured with native OS trust | | Native HTTP — Ktor | `io.github.kdroidfilter:nucleus.native-http-ktor` | Ktor `HttpClient` extension for native OS trust (all engines) | | Linux HiDPI | `io.github.kdroidfilter:nucleus.linux-hidpi` | Native HiDPI scale factor detection on Linux | | GraalVM Runtime | `io.github.kdroidfilter:nucleus.graalvm-runtime` | GraalVM native-image bootstrap + font substitutions (includes linux-hidpi) | ```kotlin dependencies { // Pick what you need: implementation("io.github.kdroidfilter:nucleus.core-runtime:") implementation("io.github.kdroidfilter:nucleus.aot-runtime:") implementation("io.github.kdroidfilter:nucleus.updater-runtime:") implementation("io.github.kdroidfilter:nucleus.taskbar-progress:") implementation("io.github.kdroidfilter:nucleus.notification-macos:") implementation("io.github.kdroidfilter:nucleus.notification-windows:") implementation("io.github.kdroidfilter:nucleus.launcher-windows:") implementation("io.github.kdroidfilter:nucleus.notification-linux:") implementation("io.github.kdroidfilter:nucleus.launcher-linux:") implementation("io.github.kdroidfilter:nucleus.launcher-macos:") implementation("io.github.kdroidfilter:nucleus.menu-macos:") implementation("io.github.kdroidfilter:nucleus.sf-symbols:") implementation("io.github.kdroidfilter:nucleus.freedesktop-icons:") implementation("io.github.kdroidfilter:nucleus.decorated-window:") implementation("io.github.kdroidfilter:nucleus.decorated-window-jewel:") implementation("io.github.kdroidfilter:nucleus.decorated-window-material2:") implementation("io.github.kdroidfilter:nucleus.decorated-window-material3:") implementation("io.github.kdroidfilter:nucleus.darkmode-detector:") implementation("io.github.kdroidfilter:nucleus.system-color:") implementation("io.github.kdroidfilter:nucleus.energy-manager:") implementation("io.github.kdroidfilter:nucleus.native-ssl:") implementation("io.github.kdroidfilter:nucleus.native-http:") implementation("io.github.kdroidfilter:nucleus.native-http-okhttp:") implementation("io.github.kdroidfilter:nucleus.native-http-ktor:") implementation("io.github.kdroidfilter:nucleus.linux-hidpi:") implementation("io.github.kdroidfilter:nucleus.graalvm-runtime:") } ``` ## ProGuard When ProGuard is enabled in a release build, the Nucleus Gradle plugin **automatically includes** the required rules for all Nucleus runtime libraries (`default-compose-desktop-rules.pro`). No manual configuration is needed. Libraries that use JNI (`decorated-window`, `darkmode-detector`, `system-color`, `energy-manager`, `native-ssl`, `notification-macos`, `notification-windows`, `notification-linux`, `launcher-windows`, `launcher-linux`) require `-keep` rules for their native bridge classes — these are handled by the plugin automatically. ### Overriding the ProGuard configuration > **Warning:** `configurationFiles.from(...)` **replaces** the plugin's auto-injected rules entirely — it does not append to them. If you supply your own configuration file, the Nucleus JNI keep rules will no longer be applied automatically and you must copy them into your file manually. ```kotlin nucleus.application { buildTypes { release { proguard { isEnabled = true // ⚠ This replaces the auto-injected rules — see below for required manual rules. configurationFiles.from(project.file("proguard-rules.pro")) } } } } ``` When using a custom `configurationFiles`, add the following rules to your file to preserve all Nucleus JNI bridges: ```proguard # Nucleus decorated-window JNI (macOS) -keep class io.github.kdroidfilter.nucleus.window.utils.macos.NativeMacBridge { native ; } -keep class io.github.kdroidfilter.nucleus.window.** { *; } # Nucleus darkmode-detector JNI (macOS) -keep class io.github.kdroidfilter.nucleus.darkmodedetector.mac.NativeDarkModeBridge { native ; static void onThemeChanged(boolean); } # Nucleus darkmode-detector JNI (Linux) -keep class io.github.kdroidfilter.nucleus.darkmodedetector.linux.NativeLinuxBridge { native ; static void onThemeChanged(boolean); } # Nucleus darkmode-detector JNI (Windows) -keep class io.github.kdroidfilter.nucleus.darkmodedetector.windows.NativeWindowsBridge { native ; } -keep class io.github.kdroidfilter.nucleus.darkmodedetector.** { *; } # Nucleus native-ssl JNI (macOS) -keep class io.github.kdroidfilter.nucleus.nativessl.mac.NativeSslBridge { native ; } # Nucleus native-ssl JNI (Windows) -keep class io.github.kdroidfilter.nucleus.nativessl.windows.WindowsSslBridge { native ; } ``` Omitting these rules will cause `UnsatisfiedLinkError` or `ClassNotFoundException` at runtime in release builds. --- # App Metadata (`NucleusApp`) Access application metadata injected by the Nucleus Gradle plugin at runtime. ## Installation `NucleusApp` is part of `core-runtime`: ```kotlin dependencies { implementation("io.github.kdroidfilter:nucleus.core-runtime:") } ``` ## Usage ```kotlin import io.github.kdroidfilter.nucleus.core.runtime.NucleusApp // Application identifier (matches packageName in the Nucleus DSL) val appId: String = NucleusApp.appId // Optional metadata (null if not configured in the DSL) val version: String? = NucleusApp.version val vendor: String? = NucleusApp.vendor val description: String? = NucleusApp.description // Check if the plugin injected metadata if (NucleusApp.isConfigured) { println("Running as packaged app: $appId v$version") } ``` ## Properties | Property | Type | Description | |----------|------|-------------| | `appId` | `String` | Application identifier. Falls back to main class name or `"NucleusApp"` if not injected. | | `version` | `String?` | Application version from `packageVersion` in the DSL. `null` if not configured. | | `vendor` | `String?` | Application vendor from `vendor` in the DSL. `null` if not configured. | | `description` | `String?` | Application description from `description` in the DSL. `null` if not configured. | | `isConfigured` | `Boolean` | `true` if the Nucleus plugin injected metadata (via system property or classpath resource). | All properties are `@JvmStatic` and lazily initialized. ## How It Works The Nucleus Gradle plugin injects metadata through two mechanisms: ### 1. System properties (during `run`) When running via `./gradlew run`, the plugin adds JVM arguments: ``` -Dnucleus.app.id=MyApp ``` ### 2. Classpath resource (in packaged builds) At build time, the plugin generates a `nucleus/nucleus-app.properties` file that is included in the application's classpath: ```properties app.id=MyApp app.version=1.2.3 app.vendor=My Company app.description=My awesome desktop app ``` ### Resolution order For each property, `NucleusApp` checks (first non-null wins): 1. System property (`nucleus.app.id`, `nucleus.app.version`, etc.) 2. Classpath resource (`nucleus/nucleus-app.properties`) 3. Legacy fallback (for `appId` only): main class from `sun.java.command`, then `"NucleusApp"` ## Use Cases ### About dialog ```kotlin @Composable fun AboutDialog() { Column { Text("${NucleusApp.appId}") NucleusApp.version?.let { Text("Version $it") } NucleusApp.vendor?.let { Text("By $it") } } } ``` ### Conditional logic based on packaging ```kotlin if (NucleusApp.isConfigured) { // Running as packaged app — enable auto-update, telemetry, etc. initAutoUpdater() } else { // Running in dev mode (./gradlew run) enableDevTools() } ``` ### Used by other Nucleus modules `NucleusApp.appId` is consumed automatically by: - **`SingleInstanceManager`** — uses `appId` as the default lock file identifier (via `AppIdProvider`) - **`taskbar-progress`** — uses `appId` to resolve the Linux `.desktop` file for D-Bus progress - **`updater-runtime`** — uses `appId` to determine the update marker storage directory - **GraalVM `graalvm-runtime`** — uses `appId` to set the correct `WM_CLASS` on Linux native image --- # Executable Type Detection Detect at runtime which installer format was used to package your application. ## Installation ```kotlin dependencies { implementation("io.github.kdroidfilter:nucleus.core-runtime:") } ``` ```kotlin import io.github.kdroidfilter.nucleus.core.runtime.ExecutableRuntime import io.github.kdroidfilter.nucleus.core.runtime.ExecutableType ``` ## Usage ```kotlin // Get the executable type val type: ExecutableType = ExecutableRuntime.type() // Convenience checks if (ExecutableRuntime.isDev()) { // Running via ./gradlew run (not packaged) } if (ExecutableRuntime.isDmg()) { // Installed from DMG } if (ExecutableRuntime.isNsis()) { // Installed from NSIS installer } if (ExecutableRuntime.isDeb()) { // Installed from DEB package } ``` ## Available Types | Enum Value | Convenience Method | Platform | |------------|-------------------|----------| | `ExecutableType.DEV` | `isDev()` | All (not packaged) | | `ExecutableType.DMG` | `isDmg()` | macOS | | `ExecutableType.PKG` | `isPkg()` | macOS | | `ExecutableType.EXE` | `isExe()` | Windows | | `ExecutableType.MSI` | `isMsi()` | Windows | | `ExecutableType.NSIS` | `isNsis()` | Windows | | `ExecutableType.NSIS_WEB` | `isNsisWeb()` | Windows | | `ExecutableType.PORTABLE` | `isPortable()` | Windows | | `ExecutableType.APPX` | `isAppX()` | Windows | | `ExecutableType.DEB` | `isDeb()` | Linux | | `ExecutableType.RPM` | `isRpm()` | Linux | | `ExecutableType.SNAP` | `isSnap()` | Linux | | `ExecutableType.FLATPAK` | `isFlatpak()` | Linux | | `ExecutableType.APPIMAGE` | `isAppImage()` | Linux | | `ExecutableType.ZIP` | `isZip()` | All | | `ExecutableType.TAR` | `isTar()` | All | | `ExecutableType.SEVEN_Z` | `isSevenZ()` | All | ## How It Works The plugin injects a `nucleus.executable.type` system property into the launcher `.cfg` file at packaging time. `ExecutableRuntime.type()` reads this property. When running via `./gradlew run`, the value is `dev`. You can also read a custom property name: ```kotlin val type = ExecutableRuntime.type("my.custom.property") ``` ## Use Cases ```kotlin // Show different update UI based on installer when (ExecutableRuntime.type()) { ExecutableType.SNAP -> showSnapStoreUpdate() ExecutableType.FLATPAK -> showFlatpakUpdate() ExecutableType.APPX -> showMicrosoftStoreUpdate() else -> showNucleusAutoUpdate() } ``` --- # Single Instance Enforce that only one instance of your application runs at a time. ## Installation ```kotlin dependencies { implementation("io.github.kdroidfilter:nucleus.core-runtime:") } ``` ```kotlin import io.github.kdroidfilter.nucleus.core.runtime.SingleInstanceManager ``` `SingleInstanceManager` is a Kotlin `object` (singleton). It uses file-based locking to ensure single-instance behavior across platforms. ## Usage ```kotlin fun main() { application { var restoreRequested by remember { mutableStateOf(false) } val isSingle = remember { SingleInstanceManager.isSingleInstance( onRestoreFileCreated = { // Called on a NEW instance when the restore request file is created // `this` is the Path to the restore request file // You can write deep link data here for the primary instance to read }, onRestoreRequest = { // Called on the PRIMARY instance when another instance tries to start // `this` is the Path to the restore request file restoreRequested = true }, ) } if (!isSingle) { // Another instance is already running — this process will exit exitApplication() return@application } // Launch the UI — we are the primary instance Window(onCloseRequest = ::exitApplication) { // Bring window to front when another instance tries to start LaunchedEffect(restoreRequested) { if (restoreRequested) { window.toFront() window.requestFocus() restoreRequested = false } } App() } } } ``` ## Configuration Configure before the first call to `isSingleInstance()`: ```kotlin SingleInstanceManager.configuration = SingleInstanceManager.Configuration( lockFilesDir = Paths.get(System.getProperty("java.io.tmpdir")), // Default lockIdentifier = "com.example.myapp", // Defaults to auto-detected app ID ) ``` ### Configuration Properties | Property | Type | Default | Description | |----------|------|---------|-------------| | `lockFilesDir` | `Path` | System temp dir | Directory for lock files | | `lockIdentifier` | `String` | Auto-detected | Unique application identifier | | `lockFileName` | `String` | `"$lockIdentifier.lock"` | Lock file name (derived) | | `restoreRequestFileName` | `String` | `"$lockIdentifier.restore_request"` | Restore request file name (derived) | | `lockFilePath` | `Path` | Derived | Full path to lock file (derived) | | `restoreRequestFilePath` | `Path` | Derived | Full path to restore request file (derived) | ## How It Works 1. Creates a lock file in the configured directory 2. Uses `java.nio.channels.FileLock` for atomic locking 3. If the lock is already held, sends a restore request via the filesystem 4. The primary instance watches for restore request files and invokes the callback 5. Cross-platform: works on macOS, Windows, and Linux --- # Deep Links Handle custom URL protocol links (`myapp://action?param=value`). ## Installation ```kotlin dependencies { implementation("io.github.kdroidfilter:nucleus.core-runtime:") } ``` ```kotlin import io.github.kdroidfilter.nucleus.core.runtime.DeepLinkHandler ``` `DeepLinkHandler` is a Kotlin `object` (singleton). ## Setup 1. Register the protocol in the DSL: ```kotlin nativeDistributions { protocol("MyApp", "myapp") } ``` 2. Handle incoming links in your app: ```kotlin fun main(args: Array) { DeepLinkHandler.register(args) { uri -> println("Received deep link: $uri") // Handle: myapp://open?file=document.txt } // The current URI is also available as a property val currentUri = DeepLinkHandler.uri // Launch the UI... } ``` ## API Reference | Member | Type / Signature | Description | |--------|-----------------|-------------| | `uri` | `URI?` (read-only, volatile) | The most recent deep link URI | | `register(args, onDeepLink)` | `fun register(args: Array, onDeepLink: (URI) -> Unit)` | Register a deep link handler with CLI args | | `writeUriTo(path)` | `fun writeUriTo(path: Path)` | Write the current URI to a file (for IPC) | | `readUriFrom(path)` | `fun readUriFrom(path: Path)` | Read a URI from a file (for IPC) | ## Integration with Single Instance Deep links work with `SingleInstanceManager` to forward URLs to the primary instance: ```kotlin fun main(args: Array) { DeepLinkHandler.register(args) { uri -> handleDeepLink(uri) } application { var restoreRequested by remember { mutableStateOf(false) } val isSingle = remember { SingleInstanceManager.isSingleInstance( onRestoreFileCreated = { // New instance: write our deep link URI for the primary to read // `this` is the Path to the restore request file DeepLinkHandler.writeUriTo(this) }, onRestoreRequest = { // Primary instance: read the URI from the new instance // `this` is the Path to the restore request file DeepLinkHandler.readUriFrom(this) restoreRequested = true }, ) } if (!isSingle) { exitApplication() return@application } // Handle the initial deep link if launched with one DeepLinkHandler.uri?.let { handleDeepLink(it) } Window(onCloseRequest = ::exitApplication) { LaunchedEffect(restoreRequested) { if (restoreRequested) { window.toFront() restoreRequested = false } } App() } } } ``` ## Platform Behavior | Platform | Mechanism | |----------|-----------| | macOS | Apple Events (`setOpenURIHandler`) — works even when app is already running | | Windows | CLI argument via registry handler — new process forwards to primary instance | | Linux | CLI argument via `.desktop` MimeType — new process forwards to primary instance | --- # Decorated Window Compose for Desktop does not natively expose a way to draw custom content in the title bar while keeping native window controls and behavior (drag, resize, double-click maximize). On macOS, the underlying Swing layer does offer `JRootPane` client properties (such as `apple.awt.fullWindowContent` and `apple.awt.transparentTitleBar`) that let you extend Compose content into the title bar area while keeping native traffic lights — but this is a Swing-level mechanism, not a Compose API, and it does not give you a composable layout model for the title bar. On Windows and Linux there is no equivalent — your only option is a fully undecorated window where you reimplement everything from scratch. The decorated window modules bridge this gap. They are **completely design-system agnostic** — no dependency on Jewel, no dependency on Material 3. You wire in whatever color tokens your app uses (Material 3, Jewel, your own design system, or a plain `Color` literal). Optional convenience modules exist for automatic color wiring: [`decorated-window-jewel`](decorated-window-jewel.md) reads `JewelTheme`, [`decorated-window-material2`](decorated-window-material2.md) reads `MaterialTheme.colors`, and [`decorated-window-material3`](decorated-window-material3.md) reads `MaterialTheme.colorScheme` — but they are separate artifacts and are not required by the base modules. The implementation was originally inspired by [Jewel](https://github.com/JetBrains/intellij-community/tree/master/platform/jewel)'s decorated window. Key divergences from Jewel's own implementation: - **No JNA** — all native calls use JNI only, removing the JNA dependency entirely - **No Jewel dependency** — the base modules have zero runtime dependency on Jewel - **`DecoratedDialog`** — custom title bar for dialog windows, which Jewel does not provide - **Reworked Linux rendering** — the entire Linux experience has been rebuilt from the ground up to look as native as possible, even though everything is drawn with Compose: platform-accurate GNOME Adwaita and KDE Breeze window controls, proper window shape clipping, border styling, and full behavior emulation (drag, double-click maximize, focus-aware button states) ## Module Structure The decorated window functionality is split into three modules: | Module | Artifact | Description | |--------|----------|-------------| | `decorated-window-core` | `nucleus.decorated-window-core` | Shared types, layout, styling, resources. No platform-specific code. | | `decorated-window-jbr` | `nucleus.decorated-window-jbr` | JBR-based implementation. Uses JetBrains Runtime's `CustomTitleBar` API on macOS and Windows. | | `decorated-window-jni` | `nucleus.decorated-window-jni` | JBR-free implementation. Uses JNI native libraries on all platforms, with pure-Compose fallbacks when native libs are unavailable. | Both `decorated-window-jbr` and `decorated-window-jni` expose **the same public API**. Choose the one that fits your runtime: ### `decorated-window-jbr` — JetBrains Runtime implementation Uses JetBrains' official `CustomTitleBar` API. This is the more **battle-tested** option, backed by the same code that powers IntelliJ IDEA and other JetBrains products. Requires JBR. However, there is a known issue on **Windows**: - The window **cannot open in maximized state** directly — you need to use a `LaunchedEffect` with a short delay after the window appears, then set `WindowPlacement.Maximized` This is an upstream JBR bug, not a Nucleus bug. The module throws an `IllegalStateException` at startup if JBR is not detected. **TIP:** When running via `./gradlew run`, Gradle uses the JDK configured in your toolchain. Make sure it is a JBR distribution if using this module. **macOS: `--add-opens` required for IDE run configurations:** On macOS, this module uses reflection on `sun.awt.AWTAccessor` to obtain the native NSWindow pointer. The Nucleus plugin injects the required `--add-opens` flags automatically for `./gradlew run` and packaged applications, but if you launch your app **directly from the IDE** (e.g. clicking the Run button on `main()`), you must add these JVM arguments to your run configuration manually: ``` --add-opens=java.desktop/sun.awt=ALL-UNNAMED --add-opens=java.desktop/sun.lwawt=ALL-UNNAMED --add-opens=java.desktop/sun.lwawt.macosx=ALL-UNNAMED ``` Without these flags, you will see an `IllegalAccessException` on `sun.awt.AWTAccessor` and title bar color updates will not work. ### `decorated-window-jni` — Nucleus native implementation Entirely implemented by Nucleus using JNI native libraries on all platforms. None of the JBR bugs mentioned above are present — window maximization and drag work reliably. This module does not depend on JBR, making it compatible with **any JVM** (OpenJDK, GraalVM Native Image, etc.). It was specifically designed for use cases where JBR is not available, such as GraalVM native-image builds. On Linux, pair it with [`linux-hidpi`](linux-hidpi.md) for correct HiDPI support. **macOS: Liquid Glass and Xcode 26 appearance:** Nucleus automatically patches the application launcher's `LC_BUILD_VERSION` to macOS SDK 26.0 via `vtool`, enabling Liquid Glass window decorations (larger traffic lights, rounded corners). This works with **any JDK** — a JDK compiled with Xcode 26 is no longer required. See [macOS 26 Window Appearance](../targets/macos.md#macos-26-window-appearance-liquid-glass) for details and configuration options. **Windows: DPI-aware minimum and maximum window size:** On non-JBR JVMs (OpenJDK, GraalVM), `Window.minimumSize` and `Window.maximumSize` are stored in logical pixels but Windows expects physical pixels in `WM_GETMINMAXINFO`. This causes the enforced min/max size to be too small on HiDPI displays (e.g. a 640×480 minimum becomes 427×320 at 150% scaling). JBR fixes this internally with `ScaleUpX`/`ScaleUpY`. The JNI module replicates this fix: it intercepts `WM_GETMINMAXINFO` after AWT and applies `MulDiv(value, dpi, 96)` scaling. Just set `window.minimumSize` or `window.maximumSize` as usual — the DPI correction is automatic. This fix is **not present** in `decorated-window-jbr` (JBR handles it natively). **Windows: no white background flash during resize:** On Windows, Skiko's rendering pipeline clears the DirectX canvas to white before each frame. When the window is resized larger, the newly exposed pixels remain white for one frame — producing a visible white flash. The JNI module eliminates this by adjusting Skiko's clear color to transparent for dark themes (rendered as opaque black on the DirectX surface), so the flash is invisible against a dark background. It also synchronizes the DWM caption and border colors (`DWMWA_CAPTION_COLOR`, `DWMWA_BORDER_COLOR`, `DWMWA_USE_IMMERSIVE_DARK_MODE`) with the title bar color for consistent Windows 11 window chrome styling. This fix is **not present** in `decorated-window-jbr`. **macOS: resize behavior:** On macOS, resizing a window triggers a modal tracking loop on the main thread. Skiko's Metal layer (`CAMetalLayer`) presents frames asynchronously, which means macOS may briefly stretch stale frame content to fill the new window size during rapid resize. Both the JNI and JBR modules rely on this default asynchronous presentation. An earlier approach using `CAMetalLayer.presentsWithTransaction` during live resize was removed because synchronous presentation blocked the main thread on every frame, causing significant resize lag — especially on heavy Compose layouts. **Less battle-tested:** While the JNI module has no known bugs, it has not been as widely tested as the JBR implementation. Use it with appropriate caution in production, and report any issues you encounter. ## Installation Choose one implementation: ```kotlin dependencies { // Option 1: JBR-based (requires JetBrains Runtime) implementation("io.github.kdroidfilter:nucleus.decorated-window-jbr:") // Option 2: JNI-based (works on any JVM) implementation("io.github.kdroidfilter:nucleus.decorated-window-jni:") } ``` **Optionally**, if you use a supported design system and want automatic color wiring, add the companion module matching your theme. This is **not required** for the base decorated window to work. ```kotlin dependencies { // Optional — pick one depending on your design system implementation("io.github.kdroidfilter:nucleus.decorated-window-jewel:") // Jewel (IntelliJ) implementation("io.github.kdroidfilter:nucleus.decorated-window-material2:") // Material 2 implementation("io.github.kdroidfilter:nucleus.decorated-window-material3:") // Material 3 } ``` **NOTE:** See [`decorated-window-jewel`](decorated-window-jewel.md), [`decorated-window-material2`](decorated-window-material2.md), or [`decorated-window-material3`](decorated-window-material3.md) for details on the design system wrappers. If you use a custom theme, skip these modules and map your colors manually as shown in the [Styling](#styling) section below. ## Quick Start ```kotlin fun main() = application { NucleusDecoratedWindowTheme(isDark = true) { DecoratedWindow( onCloseRequest = ::exitApplication, title = "My App", ) { TitleBar { state -> Text( title, modifier = Modifier.align(Alignment.CenterHorizontally), color = LocalContentColor.current, ) } // Your app content MyContent() } } } ``` ## Screenshots ### macOS ![macOS Decorated Window](../assets/MacDecoratedWindow.png) ### Windows ![Windows Decorated Window](../assets/WindowsDecoratedWindow.png) ### Linux (GNOME) ![GNOME Decorated Window](../assets/GnomeDecoratedWindow.png) ### Linux (KDE) ![KDE Decorated Window](../assets/KdeDecoratedWindow.png) ## Platform Comparison The following tables compare a standard Compose `Window()`, the JBR module (`decorated-window-jbr`), and the JNI module (`decorated-window-jni`) across all three platforms. ### macOS | Feature | Compose `Window()` | `decorated-window-jbr` | `decorated-window-jni` | |---|---|---|---| | Custom title bar content | No | Yes (JBR `CustomTitleBar`) | Yes (JNI native bridge) | | Window controls | Native traffic lights | Native traffic lights | Native traffic lights | | Title bar drag | Native | JBR hit-test | `nativeStartWindowDrag()` via JNI | | Double-click maximize | Native | Native (via JBR `CustomTitleBar`) | Native via JNI | | Window snapping / tiling | Native | Native | Native (swizzled `_adjustWindowToScreen`) | | Resize flash / freeze | Image freezes during resize | No freeze (JBR handles it) | Async Metal presentation (same as JBR) | | 26pt corner radius | No | No | Yes (`macOSLargeCornerRadius()`) | | Fullscreen controls | No custom title bar | macOS native (`apple.awt.newFullScreenControls`) | Sliding overlay (`newFullscreenControls()`) | | RTL support | No custom title bar | No (requires [custom JBR](../targets/macos.md#jvm-based-applications)) | Yes (live hot-swap, traffic lights move to right) | | JDK requirement | Any | JBR only | Any (requires Xcode 26-compiled JDK for native features) | | Fallback (no native lib) | N/A | N/A | AWT client properties (no custom positioning) | ### Windows | Feature | Compose `Window()` | `decorated-window-jbr` | `decorated-window-jni` | |---|---|---|---| | Custom title bar content | No | Yes (JBR `CustomTitleBar`) | Yes (JNI DLL, WndProc subclass) | | Window controls | Native | Native min/max/close | Compose-drawn (SVG icons, Windows style) | | Title bar drag | Native | JBR `forceHitTest` + `clientRegion` | Native DLL or Compose fallback | | Double-click maximize | Native | Native (via JBR `CustomTitleBar`) | Compose detection | | Window snapping / tiling | Native | Native | Native (via `WM_NCLBUTTONDOWN` + `HTCAPTION`) | | Resize white flash | White flash on dark themes | White flash on dark themes | **Fixed** — `WM_ERASEBKGND` fill + `SWP_NOCOPYBITS` + DWM color sync | | Open in maximized state | Works | Broken (requires `LaunchedEffect` workaround) | Works | | Drag reliability | Native | Reliable (`clientRegion` hit-test) | Reliable | | True fullscreen | Broken (doesn't cover taskbar) | Broken (doesn't cover taskbar) | **Fixed** — native Win32 fullscreen (`newFullscreenControls()`) | | Fullscreen sliding title bar | No | No | Yes (`newFullscreenControls()`) | | DWM dark mode sync | No | No | Yes (`DWMWA_USE_IMMERSIVE_DARK_MODE`, caption/border color) | | DPI-aware min/max size | Broken on non-JBR | JBR handles it | **Fixed** — `WM_GETMINMAXINFO` DPI scaling | | RTL support | No custom title bar | Yes (no hot-swap, restart required) | Yes (live hot-swap) | | JDK requirement | Any | JBR only | Any | | Fallback (no native lib) | N/A | N/A | Compose `windowDragHandler()` (no WndProc subclass) | ### Linux | Feature | Compose `Window()` | `decorated-window-jbr` | `decorated-window-jni` | |---|---|---|---| | Custom title bar content | No | Yes (fully undecorated) | Yes (fully undecorated) | | Window controls | WM-provided | Compose `WindowControlArea` (SVG) | Compose `WindowControlArea` (SVG) | | Desktop environment styling | WM-provided | GNOME Adwaita / KDE Breeze icons | GNOME Adwaita / KDE Breeze icons | | System button layout | WM-provided | Reactive (GSettings observer) | Reactive (GSettings observer) | | Window shape | WM-provided | Rounded corners (GNOME 12dp, KDE 5dp top only) | Rounded corners (GNOME 12dp, KDE 5dp top only) | | Title bar drag | WM-provided | `JBR.getWindowMove()` | `_NET_WM_MOVERESIZE` via JNI or Compose fallback | | Double-click maximize | WM-provided | Compose detection | Compose detection | | True fullscreen | WM-provided | Compose `WindowPlacement.Fullscreen` | Native `_NET_WM_STATE_FULLSCREEN` via JNI | | Fullscreen sliding title bar | No | No | Yes (`newFullscreenControls()`) | | RTL support | No custom title bar | Yes (hot-swap) | Yes (hot-swap) | | JDK requirement | Any | JBR only | Any | | Fallback (no native lib) | N/A | N/A | Compose `windowDragHandler()` | ### Summary | Capability | Compose `Window()` | JBR | JNI | |---|:---:|:---:|:---:| | Custom title bar | | ✅ | ✅ | | Works on any JDK | ✅ | | ✅ | | GraalVM native-image | ✅ | | ✅ | | No resize artifacts (macOS) | | ✅ | | | No resize artifacts (Windows) | | | ✅ | | True fullscreen (Windows) | | | ✅ | | Native fullscreen (Linux) | | | ✅ | | DWM dark mode sync (Windows) | | | ✅ | | 26pt corner radius (macOS) | | | ✅ | | Fullscreen sliding title bar (all platforms) | | | ✅ | | macOS native fullscreen controls | | ✅ | ✅ | | RTL live hot-swap (all platforms) | | | ✅ | | Dialog centering on parent | | ✅ | ✅ | | Battle-tested | ✅ | ✅ | | ## Components ### `NucleusDecoratedWindowTheme` Provides styling for all decorated window components via `CompositionLocal`. Must wrap `DecoratedWindow` / `DecoratedDialog`. ```kotlin NucleusDecoratedWindowTheme( isDark: Boolean = true, windowStyle: DecoratedWindowStyle = DecoratedWindowDefaults.dark/lightWindowStyle(), titleBarStyle: TitleBarStyle = DecoratedWindowDefaults.dark/lightTitleBarStyle(), ) { // DecoratedWindow / DecoratedDialog go here } ``` The `isDark` flag selects the built-in dark or light presets. Pass your own `windowStyle` / `titleBarStyle` to override any or all values. ### `DecoratedWindow` Drop-in replacement for Compose `Window()`. Manages window state (active, fullscreen, minimized, maximized) and platform-specific decorations. ```kotlin DecoratedWindow( onCloseRequest = ::exitApplication, state = rememberWindowState(), title = "My App", icon = null, resizable = true, ) { TitleBar { state -> /* title bar content */ } // window content } ``` The `content` lambda receives a `DecoratedWindowScope` which exposes: - `window: ComposeWindow` — the underlying AWT window - `state: DecoratedWindowState` — current window state (`.isActive`, `.isFullscreen`, `.isMinimized`, `.isMaximized`) ### `DecoratedDialog` Same concept for dialog windows. Uses `DialogWindow` internally. Dialogs only show a close button on Linux (no minimize/maximize). ```kotlin DecoratedDialog( onCloseRequest = { showDialog = false }, title = "Settings", resizable = false, ) { DialogTitleBar { state -> Text(title, modifier = Modifier.align(Alignment.CenterHorizontally)) } // dialog content } ``` **Automatic centering on parent window (`decorated-window-jni` only):** When `DecoratedDialog` is composed inside a `DecoratedWindow`, it is automatically positioned **centered on its parent window** — no extra code needed. This is handled by hooking into the AWT `windowOpened` event, which fires exactly when the native dialog window is first shown. At that point, Compose Desktop has already applied any `DialogState` position, so the centering override reliably lands at the right time. If there is no parent window in the composition tree (for example, a dialog opened from a non-windowed context), the dialog falls back to being **centered on the screen** (`setLocationRelativeTo(null)`). This behavior is **not present** in `decorated-window-jbr`. ### `TitleBar` / `DialogTitleBar` Platform-dispatched title bar composable. Provides a `TitleBarScope` with: - `title: String` — the window title passed to `DecoratedWindow` - `icon: Painter?` — the window icon - `Modifier.align(alignment: Alignment.Horizontal)` — positions content within the title bar ```kotlin TitleBar { state -> // Left-aligned icon Icon( painter = myIcon, contentDescription = null, modifier = Modifier.align(Alignment.Start), ) // Centered title Text( title, modifier = Modifier.align(Alignment.CenterHorizontally), color = LocalContentColor.current, ) // Right-aligned action IconButton( onClick = { /* ... */ }, modifier = Modifier.align(Alignment.End), ) { Icon(Icons.Default.Settings, contentDescription = "Settings") } } ``` Centered content is automatically shifted to avoid overlapping with start/end content. ### `controlButtonsDirection` — Independent Button Placement By default, the window control buttons (close, minimize, maximize) follow the same layout direction as the title bar content. This means that in an RTL locale, both the content and the buttons mirror together. The `controlButtonsDirection` parameter lets you decouple the two: you can have RTL content in the title bar while keeping the control buttons on their conventional side, or vice versa. ```kotlin TitleBar( controlButtonsDirection = ControlButtonsDirection.Ltr, ) { state -> // Content follows LocalLayoutDirection (e.g. RTL for Arabic), // but control buttons always stay on the right side (LTR trailing). Text(title, modifier = Modifier.align(Alignment.CenterHorizontally)) } ``` | Value | Behavior | |-------|----------| | `Auto` | Follows Compose `LocalLayoutDirection` — the previous default behavior. | | `System` | Follows the JVM platform locale (`java.util.Locale`). | | `Ltr` | Always places buttons as in a left-to-right layout (trailing = right side). | | `Rtl` | Always places buttons as in a right-to-left layout (trailing = left side). | The default is `Auto`, which preserves backward compatibility. This parameter is available on `TitleBar` (both JBR and JNI modules) and on `MaterialTitleBar`. On macOS, this controls the native traffic-light button position (via JBR `controls.rtl` or JNI `nativeSetRTL`). On Windows and Linux, it controls the placement of the Compose-rendered control buttons. ### `Modifier.clientRegion()` — Interactive Title Bar Regions When you place interactive elements (buttons, dropdowns, etc.) inside a `TitleBar`, they need to receive mouse events instead of triggering window dragging. The `clientRegion` modifier registers a composable as an interactive area within the title bar, so the platform's hit-test system knows to treat it as a clickable region rather than a drag surface. This is particularly important with `decorated-window-jbr`, where the old pointer-event-based approach could occasionally miss drag events on Windows. The new `clientRegion` modifier uses AWT-level mouse listeners with precise coordinate-based hit testing, which is more reliable. ```kotlin TitleBar { state -> // This dropdown is marked as a client region — clicks go to // the dropdown, not to the window drag handler. Dropdown( modifier = Modifier.align(Alignment.Start).clientRegion("main_menu"), menuContent = { /* ... */ }, ) { Text("File") } Text(title, modifier = Modifier.align(Alignment.CenterHorizontally)) // This icon button is also a client region. IconButton( onClick = { /* ... */ }, modifier = Modifier.align(Alignment.End).clientRegion("settings"), ) { Icon(Icons.Default.Settings, contentDescription = "Settings") } } ``` The `key` parameter must be unique within the same window's title bar. When the composable is removed from the composition, its region is automatically unregistered. **`decorated-window-jbr` only:** The `clientRegion` modifier is provided by `decorated-window-jbr`. The `decorated-window-jni` module handles hit testing differently (via native platform APIs) and does not need this modifier — interactive elements in the title bar work automatically. ## Styling ### Mapping Your Own Theme The key idea: `NucleusDecoratedWindowTheme` accepts two style objects. You build them from whatever color system you use: ```kotlin // Example: map a custom theme to decorated window styles val myWindowStyle = DecoratedWindowStyle( colors = DecoratedWindowColors( border = MyTheme.colors.border, borderInactive = MyTheme.colors.border.copy(alpha = 0.5f), ), metrics = DecoratedWindowMetrics(borderWidth = 1.dp), ) val myTitleBarStyle = TitleBarStyle( colors = TitleBarColors( background = MyTheme.colors.surface, inactiveBackground = MyTheme.colors.surfaceDim, content = MyTheme.colors.onSurface, border = MyTheme.colors.outline, ), metrics = TitleBarMetrics(height = 40.dp), ) NucleusDecoratedWindowTheme( isDark = MyTheme.isDark, windowStyle = myWindowStyle, titleBarStyle = myTitleBarStyle, ) { DecoratedWindow(...) } ``` #### Custom Window Control Icon Colors On **Windows** and **Linux** (with `decorated-window-jni` only), you can override the color of the minimize, maximize, and close button icons. This is useful when using a dark title bar background where the default icon colors don't provide enough contrast. The close button hover/pressed state is never tinted — it keeps the native white-on-red appearance. ```kotlin val myTitleBarStyle = TitleBarStyle( colors = TitleBarColors( background = Color(0xFF1E1E2E), inactiveBackground = Color(0xFF2E2E3E), content = Color.White, border = Color.Transparent, // Force white icons, green on hover controlButtonIconColor = Color.White, controlButtonIconHoverColor = Color.Green, ), metrics = TitleBarMetrics(), ) ``` With `MaterialDecoratedWindow` (Material 3), pass the style via `titleBarStyle`: ```kotlin MaterialDecoratedWindow( onCloseRequest = ::exitApplication, titleBarStyle = myTitleBarStyle, ) { MaterialTitleBar { state -> /* ... */ } // ... } ``` **NOTE:** On macOS, the window controls are native AppKit traffic lights and cannot be tinted. This feature has no effect with `decorated-window-jbr`. ### `DecoratedWindowStyle` Controls the window border (visible only on Linux): | Property | Description | |----------|-------------| | `colors.border` | Border color when window is active | | `colors.borderInactive` | Border color when window is inactive | | `metrics.borderWidth` | Border width (default 1.dp) | ### `TitleBarStyle` Controls the title bar appearance: | Property | Description | |----------|-------------| | `colors.background` | Title bar background when active | | `colors.inactiveBackground` | Title bar background when inactive | | `colors.content` | Default content color (exposed via `LocalContentColor`) | | `colors.border` | Bottom border of the title bar | | `colors.fullscreenControlButtonsBackground` | Background for macOS fullscreen traffic lights | | `colors.iconButtonHoveredBackground` | Background for icon buttons on hover | | `colors.iconButtonPressedBackground` | Background for icon buttons on press | | `colors.controlButtonIconColor` | Tint color for window control button icons (min/max/close). `Color.Unspecified` = platform default. Only applies on Windows and Linux with `decorated-window-jni`. | | `colors.controlButtonIconHoverColor` | Tint color for window control button icons on hover. `Color.Unspecified` = uses `controlButtonIconColor`. Only applies on Windows and Linux with `decorated-window-jni`. | | `metrics.height` | Title bar height (default 40.dp) | | `metrics.gradientStartX` / `gradientEndX` | Gradient range (see below) | | `icons` | Custom `Painter` for close/minimize/maximize/restore buttons (null = platform default) | ## Gradient The `TitleBar` composable accepts a `gradientStartColor` parameter. When set, the title bar background becomes a horizontal gradient that transitions from the `background` color through `gradientStartColor` and back to `background`: ``` [background] → [gradientStartColor] → [background] ``` The gradient range is controlled by `TitleBarMetrics.gradientStartX` and `gradientEndX`. ```kotlin TitleBar( gradientStartColor = Color(0xFF6200EE), ) { state -> Text(title, modifier = Modifier.align(Alignment.CenterHorizontally)) } ``` When `gradientStartColor` is `Color.Unspecified` (the default), the background is a solid color. ## Custom Background The `TitleBar` composable accepts a `backgroundContent` parameter: a composable rendered inside the title bar `Box`, between the base background fill and the user content. This lets you draw arbitrary shapes, gradients, or images that need full layout access — things that cannot be expressed as a plain `Color` or a simple horizontal gradient. ```kotlin TitleBar( backgroundContent = { // Drawn on top of the background fill, behind all title bar content. // The Box is sized to the full title bar area. Canvas(modifier = Modifier.fillMaxSize()) { drawRect(color = Color(0xFF6200EE), size = Size(size.width / 2, size.height)) } }, ) { state -> Text(title, modifier = Modifier.align(Alignment.CenterHorizontally)) } ``` A typical use case is a **diagonal color band** on the leading edge, covering the native window controls area: ```kotlin TitleBar( backgroundContent = { val brandColor = Color(0xFFD32F2F) Canvas(modifier = Modifier.fillMaxSize()) { val slantWidth = size.height * 4f drawPath( path = Path().apply { moveTo(0f, 0f) lineTo(slantWidth, 0f) lineTo(slantWidth - size.height, size.height) lineTo(0f, size.height) close() }, color = brandColor, ) } }, ) { _ -> /* content */ } ``` This draws a solid trapezoid from the left edge with a 45° diagonal cut. Adjust `size.height * N` to control the width. **Interaction layer ordering:** On platforms that use a native `Spacer` for drag handling (Windows fallback, Linux), `backgroundContent` is composed **before** the drag `Spacer`, so pointer events pass through to the drag handler correctly. ## Fullscreen Title Bar ### `newFullscreenControls()` — Sliding Overlay Title Bar The `newFullscreenControls()` modifier enables a **native-style sliding title bar** in fullscreen mode. When the window enters fullscreen, the title bar is hidden and rendered as a **floating overlay** that slides down when the user moves the pointer near the top edge of the screen, and slides back up when the pointer moves away. The behavior matches each platform's native fullscreen conventions: **Safari-like** on macOS, **Edge-like** on Windows, and **Firefox-like** on Linux. This works on **all three platforms** (macOS, Windows, Linux). **Windows fullscreen fix:** Compose for Desktop does not handle fullscreen correctly on Windows — the window does not cover the taskbar and does not behave like a true fullscreen window. With `newFullscreenControls()` and `decorated-window-jni`, fullscreen is implemented via native Win32 APIs, producing a true fullscreen window that covers the taskbar, exactly like Edge or other native Windows applications. ```kotlin TitleBar(modifier = Modifier.newFullscreenControls()) { state -> // ... } ``` #### Behavior - In windowed mode, the title bar behaves normally - When the window enters fullscreen: - The title bar is removed from the window layout and repositioned as a **top-edge overlay** - Moving the pointer to the top edge of the screen triggers a **200ms slide-down animation** - Moving the pointer away triggers a **200ms slide-up animation** (hidden) - The title bar content, window controls, and drag behavior are preserved in the overlay #### Platform details | Platform | Fullscreen trigger | Overlay behavior | |----------|--------------------|------------------| | **macOS** | Native macOS fullscreen (green traffic light) | Safari-like: synced with the system menu bar; traffic light buttons animate in/out together with the title bar. Uses a native `NSEvent` monitor for menu bar visibility detection. | | **Windows** | Native Win32 fullscreen | Edge-like: title bar overlay with Compose-drawn window controls (minimize, maximize, close). Supports both native JNI drag and Compose fallback. | | **Linux** | Native WM fullscreen | Firefox-like: title bar overlay with GNOME Adwaita or KDE Breeze window controls. Uses `_NET_WM_MOVERESIZE` or Compose fallback for drag. | With `decorated-window-jbr`, this modifier sets the `apple.awt.newFullScreenControls` system property on macOS and uses `fullscreenControlButtonsBackground` from your `TitleBarStyle`. The sliding overlay behavior is only available with `decorated-window-jni`. With `decorated-window-jni`, the full sliding overlay is available on all platforms. ### `macOSLargeCornerRadius()` — Large Corner Radius On macOS, use the `macOSLargeCornerRadius()` modifier on `TitleBar` to enable the 26pt window corner radius — the same radius used by Apple apps with a toolbar (Finder, Safari, etc.). Without this modifier, the window uses the standard ~10pt radius. ```kotlin TitleBar( modifier = Modifier .newFullscreenControls() .macOSLargeCornerRadius() ) { state -> // ... } ``` When enabled, an invisible `NSToolbar` is attached to the window, which triggers AppKit's larger corner radius. The traffic light buttons are automatically repositioned to match Apple's native inset (+6pt horizontally and vertically), consistent with Finder and Safari. The toolbar is transparently managed around fullscreen transitions — removed before entering fullscreen to avoid visual glitches, and reinstalled after the animation completes. **Requires a JDK compiled with Xcode 26:** This modifier relies on the JNI native library to install the `NSToolbar`. If the native library cannot be loaded (i.e. the JDK was not compiled with Xcode 26 or later), `macOSLargeCornerRadius()` has **no effect**: the window will keep the standard ~10pt corner radius, and the traffic light buttons will remain at their default (smaller) position. This modifier only has an effect with `decorated-window-jni` on macOS. It is safe to call on other platforms (no-op). ## ProGuard Both modules use JNI on macOS. When ProGuard is enabled, the native bridge classes must be preserved. The Nucleus Gradle plugin includes these rules automatically, but if you need to add them manually: ```proguard # Nucleus decorated-window-jbr JNI -keep class io.github.kdroidfilter.nucleus.window.utils.macos.NativeMacBridge { native ; } # Nucleus decorated-window-jni JNI (all platforms) -keep class io.github.kdroidfilter.nucleus.window.utils.macos.JniMacTitleBarBridge { native ; } -keep class io.github.kdroidfilter.nucleus.window.utils.windows.JniWindowsDecorationBridge { native ; } -keep class io.github.kdroidfilter.nucleus.window.utils.linux.JniLinuxWindowBridge { native ; } -keep class io.github.kdroidfilter.nucleus.window.** { *; } ``` ## RTL (Right-to-Left) Layout Support ### Windows Both modules support RTL layout on Windows, but they differ in how they handle runtime direction changes: - **`decorated-window-jbr`**: Supports RTL layout, but **does not support hot-swapping** between RTL and LTR at runtime. If your application needs to switch layout direction, the user must **restart the application** for the change to take effect. - **`decorated-window-jni`**: Supports RTL layout with **live hot-swapping** — the title bar and window controls update immediately when the layout direction changes at runtime, with no restart required. ### macOS The standard JetBrains Runtime **does not support RTL** for the title bar on macOS — the traffic lights and title bar layout always remain in LTR mode. Two options are available for RTL support on macOS: - **`decorated-window-jni`**: Fully supports RTL layout with **live hot-swapping**, no custom JDK required. - **`decorated-window-jbr`** with the [custom JBR fork](../targets/macos.md#jvm-based-applications) (`v25.0.2b329.66-rtl`): Supports RTL layout with **live hot-swapping** as well. This fork includes a native RTL fix for macOS window decorations. ### Linux RTL layout is handled entirely by Compose since the window is fully undecorated on Linux. Both modules support RTL with live hot-swapping. ### Decoupling Content and Button Directions In many RTL applications, the title bar content should follow the RTL direction (text flows right-to-left) while the window control buttons stay on their platform-conventional side. Use the [`controlButtonsDirection`](#controlbuttonsdirection--independent-button-placement) parameter to achieve this: ```kotlin // RTL app where buttons stay on the right (Windows/Linux convention) CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { DecoratedWindow(onCloseRequest = ::exitApplication, title = "تطبيقي") { TitleBar(controlButtonsDirection = ControlButtonsDirection.Ltr) { state -> Text(title, modifier = Modifier.align(Alignment.CenterHorizontally)) } // app content } } ``` ## Linux Desktop Environment Detection On Linux, the module detects the current desktop environment and loads the appropriate icon set: - **GNOME** — Adwaita-style icons, rounded top corners (12dp radius) - **KDE** — Breeze-style icons, rounded top corners (5dp radius) - **Other** — Falls back to GNOME style Detection uses `XDG_CURRENT_DESKTOP` and `DESKTOP_SESSION` environment variables. ### System Button Layout On GNOME, the module reads the `org.gnome.desktop.wm.preferences` → `button-layout` GSettings key to determine which titlebar buttons to display and on which side. This is done via `libgio` (`dlopen`, no hard compile-time dependency). The layout updates **reactively** — if the user changes the button configuration in GNOME Tweaks (or via `gsettings set`), the title bar updates in real time without restarting the application. Supported configurations include: - `:minimize,maximize,close` — right side, all buttons (GNOME with tweaks) - `close,minimize,maximize:` — left side, Ubuntu style - `appmenu:close` — right side, close only (GNOME default) On **KDE** and other desktop environments, the module falls back to the default layout (close, maximize, minimize on the right). If you need the layout value directly (e.g. for custom rendering), use the `rememberLinuxButtonLayout()` composable: ```kotlin val layout = rememberLinuxButtonLayout() // layout.buttons — ordered list of visible buttons // layout.controlsOnRight — true if buttons are on the right side // layout.hasClose / layout.hasMinimize / layout.hasMaximize ``` --- # Decorated Window — Jewel The `decorated-window-jewel` module provides Jewel (IntelliJ theme) wrappers around the [Decorated Window](decorated-window.md) components. It reads colors from `JewelTheme` and automatically builds the matching `DecoratedWindowStyle` and `TitleBarStyle` — no manual color mapping needed. ## Installation ```kotlin dependencies { implementation("io.github.kdroidfilter:nucleus.decorated-window-jewel:") // Transitive: nucleus.decorated-window is pulled in via `api` } ``` ## Quick Start ```kotlin fun main() = application { val isDark = isSystemInDarkMode() // from nucleus.darkmode-detector val theme = if (isDark) JewelTheme.darkThemeDefinition() else JewelTheme.lightThemeDefinition() IntUiTheme(theme = theme, styling = ComponentStyling.default()) { JewelDecoratedWindow( onCloseRequest = ::exitApplication, title = "My App", ) { JewelTitleBar { state -> Text(title) } // Your app content } } } ``` That is all you need. The title bar, borders, and window control hover states automatically match your Jewel theme. ## Components ### `JewelDecoratedWindow` Drop-in replacement for `DecoratedWindow`. Same parameters, but wraps the window in a `NucleusDecoratedWindowTheme` derived from the current `JewelTheme`. ```kotlin JewelDecoratedWindow( onCloseRequest = ::exitApplication, state = rememberWindowState(), title = "My App", ) { JewelTitleBar { state -> /* ... */ } // content } ``` The `isDark` flag for `NucleusDecoratedWindowTheme` is read directly from `JewelTheme.isDark`. ### `JewelDecoratedDialog` Same concept for dialogs: ```kotlin JewelDecoratedDialog( onCloseRequest = { showDialog = false }, title = "Settings", ) { JewelDialogTitleBar { state -> Text(title) } // dialog content } ``` ### `JewelTitleBar` / `JewelDialogTitleBar` Jewel-styled title bars. They read `JewelTheme.globalColors` and `JewelTheme.contentColor` and build a `TitleBarStyle` from them. They accept the same `gradientStartColor` and `backgroundContent` parameters as the base `TitleBar`. ```kotlin JewelTitleBar( modifier = Modifier .newFullscreenControls() .macOSLargeCornerRadius(), gradientStartColor = Color.Unspecified, backgroundContent = {}, ) { state -> // TitleBarScope content } ``` See [Decorated Window — Fullscreen Title Bar](decorated-window.md#fullscreen-title-bar) for details on the sliding overlay behavior and the large corner radius modifier. ## Color Mapping The module maps Jewel theme tokens to decorated window styling as follows: | Decorated Window token | Jewel source | |------------------------|--------------------| | Title bar background | `globalColors.panelBackground` | | Title bar inactive background | `globalColors.panelBackground` | | Title bar content color | `contentColor` | | Title bar border | `globalColors.borders.normal` | | Window border | `globalColors.borders.normal` | | Window border (inactive) | `globalColors.borders.normal` at 50% alpha | | Fullscreen controls background | `globalColors.panelBackground` | This means switching between light and dark Jewel themes will update the title bar, borders, and window controls automatically. ## Different Title Bar Theme A common pattern with Jewel is having a dark title bar with a light content area. Wrap `JewelTitleBar` in a different `IntUiTheme` to achieve this: ```kotlin IntUiTheme(theme = contentTheme, styling = ComponentStyling.default()) { JewelDecoratedWindow( onCloseRequest = ::exitApplication, title = "My App", ) { // Title bar uses a different theme IntUiTheme(theme = titleBarTheme, styling = ComponentStyling.default()) { JewelTitleBar { state -> /* ... */ } } // Content uses the outer theme } } ``` `JewelTitleBar` reads colors from the nearest `JewelTheme` provider, so wrapping it in a different `IntUiTheme` changes its colors independently from the window. ## Using with Dark Mode Detector Combine with [`nucleus.darkmode-detector`](darkmode-detector.md) for automatic theme switching: ```kotlin fun main() = application { val isDark = isSystemInDarkMode() val theme = if (isDark) JewelTheme.darkThemeDefinition() else JewelTheme.lightThemeDefinition() IntUiTheme(theme = theme, styling = ComponentStyling.default()) { JewelDecoratedWindow( onCloseRequest = ::exitApplication, title = "My App", ) { JewelTitleBar { /* ... */ } Text("Theme follows OS setting") } } } ``` When the user toggles dark mode in their OS settings, `isSystemInDarkMode()` recomposes, the theme changes, and the decorated window updates to match — including the title bar, borders, and window control hover states. --- # Decorated Window — Material 2 The `decorated-window-material2` module provides Material 2 wrappers around the [Decorated Window](decorated-window.md) components. It reads colors from `MaterialTheme.colors` and automatically builds the matching `DecoratedWindowStyle` and `TitleBarStyle` — no manual color mapping needed. ## Installation ```kotlin dependencies { implementation("io.github.kdroidfilter:nucleus.decorated-window-material2:") // Transitive: nucleus.decorated-window is pulled in via `api` } ``` ## Quick Start ```kotlin fun main() = application { val isDark = isSystemInDarkMode() // from nucleus.darkmode-detector val colors = if (isDark) darkColors() else lightColors() MaterialTheme(colors = colors) { MaterialDecoratedWindow( onCloseRequest = ::exitApplication, title = "My App", ) { MaterialTitleBar { state -> Text( title, modifier = Modifier.align(Alignment.CenterHorizontally), color = MaterialTheme.colors.onSurface, ) } Surface(modifier = Modifier.fillMaxSize()) { // Your app content } } } } ``` That is all you need. The title bar, borders, and window control hover states automatically match your Material color scheme. ## Components ### `MaterialDecoratedWindow` Drop-in replacement for `DecoratedWindow`. Same parameters, but wraps the window in a `NucleusDecoratedWindowTheme` derived from the current `MaterialTheme.colors`. ```kotlin MaterialDecoratedWindow( onCloseRequest = ::exitApplication, state = rememberWindowState(), title = "My App", ) { MaterialTitleBar { state -> /* ... */ } // content } ``` The `isDark` flag for `NucleusDecoratedWindowTheme` is inferred from `Colors.isLight`. ### `MaterialDecoratedDialog` Same concept for dialogs: ```kotlin MaterialDecoratedDialog( onCloseRequest = { showDialog = false }, title = "Settings", ) { MaterialDialogTitleBar { state -> Text(title, modifier = Modifier.align(Alignment.CenterHorizontally)) } Surface(modifier = Modifier.fillMaxSize()) { // dialog content } } ``` ### `MaterialTitleBar` / `MaterialDialogTitleBar` Material-styled title bars. They read `MaterialTheme.colors` and build a `TitleBarStyle` from it. They accept the same `gradientStartColor` and `backgroundContent` parameters as the base `TitleBar`. ```kotlin MaterialTitleBar( modifier = Modifier .newFullscreenControls() // sliding overlay title bar in fullscreen (all platforms) .macOSLargeCornerRadius(), // 26pt corner radius on macOS (Finder/Safari style) gradientStartColor = Color.Unspecified, // optional horizontal gradient backgroundContent = {}, // optional custom background layer ) { state -> // TitleBarScope content } ``` See [Decorated Window — Fullscreen Title Bar](decorated-window.md#fullscreen-title-bar) for details on the sliding overlay behavior and the large corner radius modifier. ## Color Mapping The module maps Material 2 tokens to decorated window styling as follows: | Decorated Window token | Material 2 source | |------------------------|--------------------| | Title bar background | `surface` | | Title bar inactive background | `surface` | | Title bar content color | `onSurface` | | Title bar border | `onSurface` at 12% alpha | | Window border | `onSurface` at 12% alpha | | Window border (inactive) | `onSurface` at 6% alpha | | Fullscreen controls background | `surface` | This means switching from a `lightColors()` to a `darkColors()` (or a custom color palette) will update the title bar, borders, and window controls automatically. ## Using with Dark Mode Detector Combine with [`nucleus.darkmode-detector`](darkmode-detector.md) for automatic theme switching: ```kotlin fun main() = application { val isDark = isSystemInDarkMode() val colors = if (isDark) darkColors() else lightColors() MaterialTheme(colors = colors) { MaterialDecoratedWindow( onCloseRequest = ::exitApplication, title = "My App", ) { MaterialTitleBar { /* ... */ } Surface(modifier = Modifier.fillMaxSize()) { Text("Theme follows OS setting") } } } } ``` When the user toggles dark mode in their OS settings, `isSystemInDarkMode()` recomposes, the colors change, and the decorated window updates to match — including the title bar, borders, and window control hover states. ## Migrating to Material 3 If you later migrate to Material 3, switch to the [`decorated-window-material3`](decorated-window-material3.md) module instead. The API is identical — only the imports and color source change (`MaterialTheme.colorScheme` instead of `MaterialTheme.colors`). --- # Decorated Window — Material 3 The `decorated-window-material3` module provides Material 3 wrappers around the [Decorated Window](decorated-window.md) components. It reads colors from `MaterialTheme.colorScheme` and automatically builds the matching `DecoratedWindowStyle` and `TitleBarStyle` — no manual color mapping needed. ## Installation ```kotlin dependencies { implementation("io.github.kdroidfilter:nucleus.decorated-window-material3:") // Transitive: nucleus.decorated-window is pulled in via `api` } ``` ## Quick Start ```kotlin fun main() = application { val isDark = isSystemInDarkMode() // from nucleus.darkmode-detector val colorScheme = if (isDark) darkColorScheme() else lightColorScheme() MaterialTheme(colorScheme = colorScheme) { MaterialDecoratedWindow( onCloseRequest = ::exitApplication, title = "My App", ) { MaterialTitleBar { state -> Text( title, modifier = Modifier.align(Alignment.CenterHorizontally), color = MaterialTheme.colorScheme.onSurface, ) } Surface(modifier = Modifier.fillMaxSize()) { // Your app content } } } } ``` That is all you need. The title bar, borders, and window control hover states automatically match your Material color scheme. ## Components ### `MaterialDecoratedWindow` Drop-in replacement for `DecoratedWindow`. Same parameters, but wraps the window in a `NucleusDecoratedWindowTheme` derived from the current `MaterialTheme.colorScheme`. ```kotlin MaterialDecoratedWindow( onCloseRequest = ::exitApplication, state = rememberWindowState(), title = "My App", ) { MaterialTitleBar { state -> /* ... */ } // content } ``` The `isDark` flag for `NucleusDecoratedWindowTheme` is inferred automatically from the luminance of `colorScheme.background`. ### `MaterialDecoratedDialog` Same concept for dialogs: ```kotlin MaterialDecoratedDialog( onCloseRequest = { showDialog = false }, title = "Settings", ) { MaterialDialogTitleBar { state -> Text(title, modifier = Modifier.align(Alignment.CenterHorizontally)) } Surface(modifier = Modifier.fillMaxSize()) { // dialog content } } ``` ### `MaterialTitleBar` / `MaterialDialogTitleBar` Material-styled title bars. They read `MaterialTheme.colorScheme` and build a `TitleBarStyle` from it. They accept the same `gradientStartColor`, `backgroundContent`, and `controlButtonsDirection` parameters as the base `TitleBar`. ```kotlin MaterialTitleBar( modifier = Modifier .newFullscreenControls() // sliding overlay title bar in fullscreen (all platforms) .macOSLargeCornerRadius(), // 26pt corner radius on macOS (Finder/Safari style) gradientStartColor = Color.Unspecified, // optional horizontal gradient controlButtonsDirection = ControlButtonsDirection.Auto, // button placement (Auto/System/Ltr/Rtl) backgroundContent = {}, // optional custom background layer ) { state -> // TitleBarScope content } ``` See [controlButtonsDirection](decorated-window.md#controlbuttonsdirection--independent-button-placement) for details on decoupling button placement from content direction. See [Decorated Window — Fullscreen Title Bar](decorated-window.md#fullscreen-title-bar) for details on the sliding overlay behavior and the large corner radius modifier. #### Custom background with `backgroundContent` `backgroundContent` is a composable drawn behind the title bar content, on top of the base `background` fill. Use it to render shapes, gradients, or images that require full layout control. The lambda receives a `Box` scope sized to the full title bar area. A common use case is a diagonal color band on the leading edge — for example a branded accent that covers the native window controls area: ```kotlin MaterialTitleBar( backgroundContent = { val brandColor = Color(0xFFD32F2F) Canvas(modifier = Modifier.fillMaxSize()) { val slantWidth = size.height * 4f drawPath( path = Path().apply { moveTo(0f, 0f) lineTo(slantWidth, 0f) lineTo(slantWidth - size.height, size.height) lineTo(0f, size.height) close() }, color = brandColor, ) } }, ) { _ -> // content } ``` This draws a solid red trapezoid from the left edge with a 45° diagonal cut. Adjust `size.height * N` to control the width. ## Color Mapping The module maps Material 3 tokens to decorated window styling as follows: | Decorated Window token | Material 3 source | |------------------------|--------------------| | Title bar background | `surface` | | Title bar inactive background | `surface` | | Title bar content color | `onSurface` | | Title bar border | `outlineVariant` | | Window border | `outlineVariant` | | Window border (inactive) | `outlineVariant` at 50% alpha | | Button hover background | `onSurface` at 8% alpha | | Button press background | `onSurface` at 12% alpha | | Close button hover | `error` | | Close button press | `error` at 70% alpha | | Fullscreen controls background | `surface` | This means switching from a `lightColorScheme()` to a `darkColorScheme()` (or a dynamic/custom scheme) will update the title bar, borders, and window controls automatically. ## Using with Dark Mode Detector Combine with [`nucleus.darkmode-detector`](darkmode-detector.md) for automatic theme switching: ```kotlin fun main() = application { val isDark = isSystemInDarkMode() val colorScheme = if (isDark) darkColorScheme() else lightColorScheme() MaterialTheme(colorScheme = colorScheme) { MaterialDecoratedWindow( onCloseRequest = ::exitApplication, title = "My App", ) { MaterialTitleBar { /* ... */ } Surface(modifier = Modifier.fillMaxSize()) { Text("Theme follows OS setting") } } } } ``` When the user toggles dark mode in their OS settings, `isSystemInDarkMode()` recomposes, the `colorScheme` changes, and the decorated window updates to match — including the title bar, borders, and window control hover states. --- # System Tray **Separate repository:** System Tray is maintained in its own repository with its own release cycle: [**kdroidFilter/ComposeNativeTray**](https://github.com/kdroidFilter/ComposeNativeTray). The artifact is `io.github.kdroidfilter:composenativetray`. **ComposeNativeTray** is not a library. It is a **complete framework for building system tray applications** with Compose Desktop — the most expressive and the most powerful way to own the system tray on macOS, Windows, and Linux. Traditionally, building a tray application meant juggling platform-specific icon formats, managing menu state by hand, and hoping the result didn't look broken on half your users' machines. ComposeNativeTray erases all of that. Your tray icon is **drawn by Compose** — the same toolkit that renders your UI. Any `@Composable` becomes a tray icon: an `ImageVector`, a `Painter`, a resource, or a fully custom composable with gradients, badges, and animations. One icon definition. Every platform. Every DPI. Pixel-perfect. And the menu isn't a static list of strings you rebuild on every change. It's a **reactive Compose DSL**. Change a `mutableStateOf` and the menu updates itself — labels, checkmarks, visibility, icons, nested submenus — all of it, instantly, without touching a single callback or rebuilding anything. The tray is part of your Compose state tree, just like the rest of your UI. But ComposeNativeTray goes further. **TrayApp** turns a tray icon into a full menu bar application — a Compose window that pops up right next to the tray icon, like the best macOS menu bar apps. Click the icon, a window appears with smooth animations. Click outside, it disappears. The entire window is a Compose canvas: dashboards, controls, media players, quick actions — anything you can build with Compose, anchored to the tray. Native menus rendered by the OS. Dark mode detection and automatic icon adaptation. Tray position detection for precise window placement. Left-click actions, right-click menus, checkable items, nested submenus with icons. Full [GraalVM native image](../../graalvm/index.md) compatibility for instant-launch tray apps with minimal memory footprint. Everything a system tray framework should be — and nothing you have to wire up yourself. ## In action ### macOS ![macOS](assets/mac.png) ### Windows ![Windows](assets/windows.png) ### Linux (GNOME) ![GNOME](assets/gnome.png) ### Linux (KDE) ![KDE](assets/kde.png) ## Quick example ```kotlin var isDarkMode by remember { mutableStateOf(false) } Tray( icon = if (isDarkMode) Icons.Default.DarkMode else Icons.Default.LightMode, tooltip = "My App", primaryAction = { showWindow() }, ) { Item(label = "Show Window") { showWindow() } Divider() CheckableItem(label = "Dark Mode", checked = isDarkMode) { isDarkMode = !isDarkMode } SubMenu(label = "Options") { Item(label = "Settings") { openSettings() } Item(label = "About") { openAbout() } } Divider() Item(label = "Quit") { exitProcess(0) } } ``` Change `isDarkMode` and the icon switches, the checkmark toggles, the label updates — all in one recomposition. No manual menu rebuild. No platform-specific code. No icon export pipeline. ## Installation ```kotlin dependencies { implementation("io.github.kdroidfilter:composenativetray:") } ``` ## Next steps - [Tray API](tray-api.md) — `Tray()` composable, icon types, primary actions, platform-specific icons - [Menu DSL](menu-dsl.md) — Items, checkable items, submenus, dividers, reactive menus - [TrayApp](tray-app.md) — Popup Compose window anchored to the tray icon - [Utilities](utilities.md) — Tray position detection, dark mode detection --- # Tray API The `Tray()` composable is the main entry point. It creates a native system tray icon with an optional context menu. ## Basic usage ```kotlin Tray( icon = Icons.Default.Favorite, tooltip = "My App", ) { Item(label = "Show") { showWindow() } Item(label = "Quit") { exitProcess(0) } } ``` ## Icon types `Tray()` accepts multiple icon types — use whichever fits your project: ### ImageVector ```kotlin Tray( icon = Icons.Default.Notifications, tint = Color.White, // optional tint tooltip = "My App", ) { /* menu */ } ``` ### Painter ```kotlin Tray( icon = painterResource("icon.png"), tooltip = "My App", ) { /* menu */ } ``` ### DrawableResource (Compose Multiplatform) ```kotlin Tray( icon = Res.drawable.app_icon, tooltip = "My App", ) { /* menu */ } ``` ### Custom Composable Render any `@Composable` as the tray icon — animated icons, dynamic badges, anything Compose can draw: ```kotlin Tray( iconContent = { Box(Modifier.size(24.dp).background(Color.Red, CircleShape)) { Text("3", color = Color.White, modifier = Modifier.align(Alignment.Center)) } }, tooltip = "3 notifications", ) { /* menu */ } ``` ### Platform-specific icons Windows uses `.ico` format natively while macOS and Linux work best with vector icons. Use platform-specific overloads to provide the optimal format for each: ```kotlin Tray( windowsIcon = painterResource("icon.ico"), // ICO for Windows macLinuxIcon = Icons.Default.Notifications, // Vector for macOS/Linux tint = Color.White, tooltip = "My App", ) { /* menu */ } ``` ## Icon render properties Control how Compose icons are rasterized for the tray: ```kotlin Tray( icon = Icons.Default.Favorite, iconRenderProperties = IconRenderProperties( sceneWidth = 192, sceneHeight = 192, sceneDensity = Density(2f), targetWidth = 44, targetHeight = 44, ), tooltip = "My App", ) { /* menu */ } ``` Preconfigured presets are available: | Preset | Size | Use case | |--------|------|----------| | `IconRenderProperties.forCurrentOperatingSystem()` | 32x32 Win, 44x44 Mac, 24x24 Linux | Tray icon (default) | | `IconRenderProperties.forMenuItem()` | 16x16 all platforms | Menu item icons | | `IconRenderProperties.withoutScalingAndAliasing()` | No forced scaling | When you handle sizing yourself | ## Primary action The `primaryAction` callback fires on left-click (Windows/macOS) or single-click (Linux, desktop-dependent): ```kotlin Tray( icon = Icons.Default.Favorite, tooltip = "My App", primaryAction = { showWindow() }, ) { // Right-click menu Item(label = "Quit") { exitProcess(0) } } ``` Without a `primaryAction`, left-click opens the context menu on all platforms. ## Reactive icons The icon updates automatically when state changes — no manual refresh: ```kotlin var unreadCount by remember { mutableStateOf(0) } Tray( icon = if (unreadCount > 0) Icons.Default.MarkEmailUnread else Icons.Default.Email, tooltip = "$unreadCount unread messages", ) { Item(label = "Mark all read") { unreadCount = 0 } } ``` --- # Menu DSL The tray menu is built with a Kotlin DSL inside the `Tray()` trailing lambda. Menus are fully reactive — change state, and items update automatically. ## Items ```kotlin Tray(icon = Icons.Default.Favorite, tooltip = "App") { Item(label = "Open Settings") { openSettings() } Item(label = "Disabled", isEnabled = false) { } } ``` ### Items with icons Every item type supports icons — `ImageVector`, `Painter`, `DrawableResource`, or a custom `@Composable`: ```kotlin Item(label = "Settings", icon = Icons.Default.Settings) { openSettings() } Item(label = "Export", icon = painterResource("export.png")) { export() } Item(label = "Help", icon = Res.drawable.help) { openHelp() } ``` ## Checkable items Native checkmark appearance on each platform: ```kotlin var notifications by remember { mutableStateOf(true) } var darkMode by remember { mutableStateOf(false) } Tray(icon = Icons.Default.Favorite, tooltip = "App") { CheckableItem(label = "Notifications", checked = notifications) { notifications = it } CheckableItem(label = "Dark Mode", icon = Icons.Default.DarkMode, checked = darkMode) { darkMode = it } } ``` ## Submenus Nested submenus with optional icons, unlimited depth: ```kotlin Tray(icon = Icons.Default.Favorite, tooltip = "App") { SubMenu(label = "Tools", icon = Icons.Default.Build) { Item(label = "Terminal") { openTerminal() } Item(label = "File Manager") { openFiles() } SubMenu(label = "More") { Item(label = "Calculator") { openCalc() } } } } ``` ## Dividers Visual separators between groups of items: ```kotlin Tray(icon = Icons.Default.Favorite, tooltip = "App") { Item(label = "Show Window") { show() } Divider() Item(label = "Settings") { settings() } Item(label = "About") { about() } Divider() Item(label = "Quit") { exitProcess(0) } } ``` ## Reactive menus The entire menu tree participates in Compose recomposition. Conditionally show items, update labels, toggle states — the menu rebuilds natively: ```kotlin var isConnected by remember { mutableStateOf(false) } var showAdvanced by remember { mutableStateOf(false) } Tray(icon = Icons.Default.Wifi, tooltip = "Network") { Item(label = if (isConnected) "Disconnect" else "Connect") { isConnected = !isConnected } CheckableItem(label = "Advanced Options", checked = showAdvanced) { showAdvanced = it } if (showAdvanced) { Divider() SubMenu(label = "Advanced") { Item(label = "DNS Settings") { } Item(label = "Proxy") { } } } Divider() Item(label = "Quit") { exitProcess(0) } } ``` --- # TrayApp **Experimental:** `TrayApp` is in alpha. The API may change. Use `@OptIn(ExperimentalTrayAppApi::class)`. `TrayApp` creates a **popup Compose window anchored to the tray icon** — similar to macOS menu bar apps like Bartender or iStatMenus. Click the tray icon and a window appears right next to it, with smooth enter/exit animations. ## Basic usage ```kotlin @OptIn(ExperimentalTrayAppApi::class) fun main() = application { TrayApp( icon = Icons.Default.Dashboard, tooltip = "Quick Dashboard", windowSize = DpSize(300.dp, 400.dp), ) { // This is a full Compose window Column(Modifier.fillMaxSize().padding(16.dp)) { Text("Dashboard", style = MaterialTheme.typography.h6) Spacer(Modifier.height(8.dp)) Text("CPU: 42%") Text("RAM: 8.2 GB") } } } ``` ## State management Control visibility and window size programmatically: ```kotlin @OptIn(ExperimentalTrayAppApi::class) fun main() = application { val state = rememberTrayAppState( initialWindowSize = DpSize(350.dp, 500.dp), initiallyVisible = false, initialDismissMode = TrayWindowDismissMode.AUTO, ) TrayApp( icon = Icons.Default.Dashboard, tooltip = "Dashboard", state = state, ) { Column { Text("Dashboard") Button(onClick = { state.hide() }) { Text("Close") } Button(onClick = { state.setWindowSize(500.dp, 600.dp) }) { Text("Resize") } } } } ``` ### TrayAppState API | Method / Property | Description | |-------------------|-------------| | `isVisible: StateFlow` | Current visibility state | | `show()` | Show the popup window | | `hide()` | Hide the popup window | | `toggle()` | Toggle visibility | | `setWindowSize(size)` | Resize the popup | | `setDismissMode(mode)` | `AUTO` (click outside closes) or `MANUAL` | | `onVisibilityChanged(callback)` | Listen for visibility changes | ## With a context menu `TrayApp` can have both a popup window (left-click) and a context menu (right-click): ```kotlin @OptIn(ExperimentalTrayAppApi::class) fun main() = application { TrayApp( icon = Icons.Default.Dashboard, tooltip = "Dashboard", menu = { Item(label = "Settings") { openSettings() } Divider() Item(label = "Quit") { exitProcess(0) } }, ) { Text("Popup content here") } } ``` ## Window options | Parameter | Default | Description | |-----------|---------|-------------| | `windowSize` | `DpSize(300.dp, 200.dp)` | Initial popup size | | `visibleOnStart` | `false` | Show popup immediately on launch | | `enterTransition` | Platform default | Animation when popup appears | | `exitTransition` | Platform default | Animation when popup disappears | | `transparent` | `true` | Transparent window background | | `undecorated` | `true` | No title bar or window chrome | | `resizable` | `false` | Allow user resizing | | `horizontalOffset` | `0` | Horizontal offset from tray icon position | | `verticalOffset` | Platform default | Vertical offset from tray icon position | --- # Utilities ComposeNativeTray includes several utilities that are essential for tray-based applications. ## Tray Position Detection Position a window next to the tray icon — essential for `TrayApp`-style popup windows: ```kotlin // Get the optimal window position anchored to the tray icon val position = getTrayWindowPosition(width = 300, height = 400) // Or detect which corner/edge the tray is on val trayPosition: TrayPosition? = getTrayPosition() // Returns: TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, or BOTTOM_RIGHT ``` Works on all platforms — uses native APIs to detect the tray icon click position and compute the best window placement. ## Dark Mode Detection Detect whether the menu bar / system tray area is in dark mode — useful for tinting icons: ```kotlin @Composable fun MyTray() { val isDark = isMenuBarInDarkMode() Tray( icon = Icons.Default.Notifications, tint = if (isDark) Color.White else Color.Black, tooltip = "My App", ) { /* menu */ } } ``` Platform behavior: | Platform | Detection method | |----------|-----------------| | macOS | Menu bar color (adapts to wallpaper on macOS Ventura+) | | Windows | System theme setting | | Linux (GNOME/XFCE/Cinnamon/MATE) | Always reports dark (panel is dark) | | Linux (KDE) | System theme setting | The value updates reactively — if the user changes their theme, the tray icon adapts instantly. --- # Notification (Common) Cross-platform notification abstraction that unifies Linux, Windows, and macOS notification APIs behind a single Kotlin DSL. Send notifications with title, message, images, action buttons, and lifecycle callbacks — the module routes to the right platform backend at runtime. **Simple subset by design:** This module exposes the **intersection** of what all three platforms support: title, message, large image, small icon, up to 5 action buttons, and lifecycle callbacks. For platform-specific features (progress bars, input fields, scheduling, categories), use the dedicated [Linux](notification-linux.md), [Windows](notification-windows.md), or [macOS](notification-macos.md) modules directly. ## Installation ```kotlin dependencies { implementation("io.github.kdroidfilter:nucleus.notification-common:") } ``` This single dependency pulls in all three platform modules (Linux, Windows, macOS) and `core-runtime` transitively. The module detects the current OS at runtime and delegates to the appropriate backend — non-native libraries are simply unused on other platforms. ## Quick Start ```kotlin import io.github.kdroidfilter.nucleus.notification.common.* // Build a notification val n = notification( title = "Download Complete", message = "report.pdf has been saved", ) { button("Open") { openFile() } button("Show in Folder") { showInFolder() } } // Send it n.send() ``` ## Full Example ```kotlin import io.github.kdroidfilter.nucleus.notification.common.* val myNotification = notification( title = "New Message from Alice", message = "Hey! Have you seen the latest build?", largeImage = Res.getUri("drawable/alice_avatar.png"), smallIcon = Res.getUri("drawable/app_icon.png"), onActivated = { openConversation("alice") }, onDismissed = { reason -> println("Dismissed: $reason") }, onFailed = { println("Failed to show notification") }, ) { button("Reply") { showReplyDialog("alice") } button("Archive") { archiveConversation("alice") } } // Check availability before sending if (NotificationManager.isAvailable()) { when (val result = myNotification.send()) { is NotificationResult.Success -> { // Store the handle to dismiss later val handle = result.handle // ... handle.dismiss() } is NotificationResult.Failure -> { println("Could not send: ${result.reason}") } } } ``` ## API Reference ### `notification()` — DSL Entry Point Top-level function that builds a `Notification` instance. ```kotlin fun notification( title: String, message: String = "", largeImage: String? = null, smallIcon: String? = null, onActivated: (() -> Unit)? = null, onDismissed: ((DismissReason) -> Unit)? = null, onFailed: (() -> Unit)? = null, buttons: (NotificationButtonBuilder.() -> Unit)? = null, ): Notification ``` | Parameter | Type | Default | Description | |---|---|---|---| | `title` | `String` | *(required)* | Notification title. | | `message` | `String` | `""` | Body text. | | `largeImage` | `String?` | `null` | URI to a large image (hero image on Windows, image hint on Linux, attachment on macOS). | | `smallIcon` | `String?` | `null` | URI to a small icon (app logo on Windows, app icon on Linux, ignored on macOS). | | `onActivated` | `(() -> Unit)?` | `null` | Called when the user clicks the notification body. | | `onDismissed` | `((DismissReason) -> Unit)?` | `null` | Called when the notification is dismissed. | | `onFailed` | `(() -> Unit)?` | `null` | Called if the notification fails to display. | | `buttons` | DSL block | `null` | Builder block to add up to 5 action buttons. | ### `NotificationButtonBuilder` Available inside the `notification { }` trailing lambda. | Method | Description | |---|---| | `button(title: String, onClick: () -> Unit)` | Add an action button. Maximum 5 buttons (Windows limit). | --- ### `Notification` Immutable notification object returned by the `notification()` function. The same instance can be sent multiple times — each call creates a new system notification. | Method | Returns | Description | |---|---|---| | `send()` | `NotificationResult` | Sends the notification to the OS. | --- ### `NotificationResult` Sealed class returned by `send()`. | Subclass | Properties | Description | |---|---|---| | `Success` | `handle: NotificationHandle` | Notification sent successfully. | | `Failure` | `reason: String` | Notification could not be sent. | --- ### `NotificationHandle` Opaque handle to a sent notification. | Method | Description | |---|---| | `dismiss()` | Programmatically close the notification if still visible. | --- ### `NotificationManager` Singleton facade for platform detection and notification dispatch. | Method | Returns | Description | |---|---|---| | `isAvailable()` | `Boolean` | `true` if the current platform's notification module is on the classpath and functional. | | `initialize()` | `Unit` | Eagerly initialize the notification subsystem (Windows only — called lazily on first `send()` otherwise). | | `send(notification)` | `NotificationResult` | Send a notification. Prefer using `notification.send()` directly. | --- ### `DismissReason` Unified enum for why a notification was dismissed. | Value | Description | Linux | Windows | macOS | |---|---|---|---|---| | `USER_DISMISSED` | User explicitly dismissed | `DISMISSED` | `USER_CANCELED` | Custom dismiss action | | `TIMED_OUT` | Auto-expired after timeout | `EXPIRED` | `TIMED_OUT` | — | | `APPLICATION` | Closed programmatically | `CLOSED` | `APPLICATION_HIDDEN` | — | | `UNKNOWN` | Could not be determined | `UNDEFINED` | — | — | --- ## Platform Mapping How each parameter maps to platform-specific APIs: | Common | Linux | Windows | macOS | |---|---|---|---| | `title` | `summary` | First `AdaptiveText` (bold) | `content.title` | | `message` | `body` | Second `AdaptiveText` | `content.body` | | `largeImage` | `hints.imagePath` | Hero image (top banner) | `attachments[0]` | | `smallIcon` | `appIcon` | App logo override (left of text) | Ignored (uses bundle icon) | | `buttons` | `actions` list | `ToastButton` list | Auto-generated `NotificationCategory` | | `onActivated` | `onActionInvoked` with `"default"` key | `onActivated` with empty arguments | `didReceive` with `DEFAULT_ACTION` | | `onDismissed` | `onClosed` signal | `onDismissed` event | Requires `CUSTOM_DISMISS_ACTION` | | `onFailed` | `notify()` returns 0 | `onFailed` event | `add()` callback error | ## Platform Details ### Windows - **Installed app required**: Notifications require a Start Menu shortcut (`.lnk`) with the AUMID property. This is created by the installer (e.g. `./gradlew packageDistributionForCurrentOS`). When running via `./gradlew run`, notifications work only if the app has been installed before (the shortcut already exists). A warning is logged otherwise. - **Initialization**: `WindowsNotificationCenter.initialize()` is called automatically on the first `send()`. Call `NotificationManager.initialize()` explicitly for early setup. - **Tag/Group**: Each notification gets a unique tag (`n1`, `n2`, ...) under the `"ncm"` group. - **Images**: `largeImage` maps to a hero image at the top of the toast. `smallIcon` maps to the app logo override (displayed left of the text). Both accept `file:///` URIs and HTTP URLs. - **Buttons**: Up to 5, rendered as standard toast action buttons. ### macOS - **App bundle required**: Notifications only work inside a packaged `.app` bundle (e.g. via `./gradlew runDistributable`). `isAvailable()` returns `false` when running via `./gradlew run`. - **Authorization**: The user must have granted notification permissions. The common module does **not** auto-request authorization — use `NotificationCenter.requestAuthorization()` from the macOS module before sending. - **Buttons**: Require pre-registered `NotificationCategory` objects. The common module handles this automatically — it generates and caches categories per unique button configuration. - **Dismiss callback**: macOS does not natively fire dismiss events. The common module enables `CUSTOM_DISMISS_ACTION` on generated categories so `onDismissed` fires when the user explicitly dismisses. - **Small icon**: Ignored — macOS always uses the app icon from the bundle. - **Large image**: Mapped to a notification attachment (displayed as a thumbnail). ### Linux - **No initialization needed**: The D-Bus connection is established automatically. - **Images**: `largeImage` maps to the `imagePath` hint (icon name or `file://` URI). `smallIcon` maps to `appIcon`. See the [Linux notification docs](notification-linux.md#icons) for icon priority. - **Default action**: A `"default"` action is automatically added when `onActivated` is set, so clicking the notification body triggers the callback. - **All callbacks on Swing EDT**: Safe to update Compose state directly from callbacks. ## Compose Desktop Integration ```kotlin @Composable fun NotificationDemo() { var lastResult by remember { mutableStateOf(null) } Button(onClick = { val n = notification( title = "Build Finished", message = "nucleus-1.3.0 compiled in 42s", onActivated = { println("Notification clicked") }, ) { button("View Logs") { openLogs() } } lastResult = n.send() }) { Text("Send Notification") } lastResult?.let { result -> when (result) { is NotificationResult.Success -> Text("Sent!") is NotificationResult.Failure -> Text("Failed: ${result.reason}") } } } ``` **Getting the best experience across platforms:** Always provide both `largeImage` and `smallIcon` for the richest display. On platforms that don't support one (e.g. `smallIcon` on macOS), it is silently ignored. ## Architecture The module uses a dispatcher pattern inspired by `taskbar-progress`: ``` NotificationManager (singleton) └─ DispatcherFactory (selects by os.name) ├─ LinuxDispatcher → LinuxNotificationCenter ├─ WindowsDispatcher → WindowsNotificationCenter └─ MacOsDispatcher → NotificationCenter (macOS) ``` Each dispatcher: 1. Checks for the platform module on the classpath via `Class.forName` (no `NoClassDefFoundError` if absent) 2. Registers **one global listener** on the platform's notification center 3. Routes callbacks to per-notification lambdas via a `ConcurrentHashMap` registry 4. Cleans up callback entries on dismiss/failure events ## ProGuard No additional ProGuard rules are needed for `notification-common` itself. Ensure the platform module rules are applied — see [Linux](notification-linux.md#proguard), [Windows](notification-windows.md#proguard), [macOS](notification-macos.md#proguard). ## GraalVM No additional GraalVM metadata is needed for `notification-common`. The platform modules ship their own `reachability-metadata.json`. See [Linux](notification-linux.md#graalvm), [Windows](notification-windows.md#graalvm), [macOS](notification-macos.md#graalvm). --- # Notification (macOS) Complete Kotlin mapping of Apple's [UserNotifications](https://developer.apple.com/documentation/usernotifications) framework via JNI. Schedule local notifications with action buttons, text input, sounds, badges, and interruption levels. **Requires a packaged app:** macOS notifications require a bundle identifier. Use `./gradlew runDistributable` or `./gradlew runGraalvmNative` — notifications are disabled when running via `./gradlew run`. ## Installation ```kotlin dependencies { implementation("io.github.kdroidfilter:nucleus.notification-macos:") } ``` Depends on `core-runtime` (compile-only) for `NativeLibraryLoader` and `ExecutableRuntime`. ## Quick Start ```kotlin import io.github.kdroidfilter.nucleus.notification.* // 1. Request authorization NotificationCenter.requestAuthorization( setOf(AuthorizationOption.ALERT, AuthorizationOption.SOUND, AuthorizationOption.BADGE) ) { granted, error -> if (granted) { // 2. Send a notification NotificationCenter.add( NotificationRequest( identifier = "greeting", content = NotificationContent( title = "Hello", body = "Welcome to Nucleus!", sound = NotificationSound.Default, ), trigger = NotificationTrigger.TimeInterval(interval = 5.0), ) ) } } ``` **Async callbacks run on background threads:** Completion callbacks (`requestAuthorization`, `add`, `getNotificationSettings`, etc.) are invoked on macOS's internal dispatch thread. Use `SwingUtilities.invokeLater` if you need to update Compose state from these callbacks. Delegate methods (`willPresent`, `didReceive`, `openSettings`) are automatically dispatched to the EDT by the library. ## API Reference ### `NotificationCenter` | Property / Method | Description | |---|---| | `isAvailable: Boolean` | `true` if native lib loaded, macOS, and not in dev mode | #### Authorization | Method | Description | |---|---| | `requestAuthorization(options, callback)` | Request permission to post notifications. Callback: `(granted: Boolean, error: String?) -> Unit` | | `getNotificationSettings(callback)` | Retrieve current notification settings. Callback: `(NotificationSettings) -> Unit` | #### Notification Requests | Method | Description | |---|---| | `add(request, callback?)` | Schedule or immediately deliver a notification. Callback: `(error: String?) -> Unit` | | `removePendingNotifications(identifiers)` | Remove pending (not yet delivered) notifications by ID | | `removeAllPendingNotifications()` | Remove all pending notifications | | `getPendingNotifications(callback)` | Retrieve pending requests. Callback: `(List) -> Unit` | | `removeDeliveredNotifications(identifiers)` | Remove delivered notifications from Notification Center by ID | | `removeAllDeliveredNotifications()` | Remove all delivered notifications | | `getDeliveredNotifications(callback)` | Retrieve delivered notifications. Callback: `(List) -> Unit` | #### Categories & Actions | Method | Description | |---|---| | `setNotificationCategories(categories)` | Register notification categories with action buttons | | `getNotificationCategories(callback)` | Retrieve registered categories. Callback: `(List) -> Unit` | #### Badge | Method | Description | |---|---| | `setBadgeCount(count, callback?)` | Set the dock badge count. Uses `UNUserNotificationCenter.setBadgeCount` on macOS 13+, `NSDockTile` fallback on older. Callback: `(error: String?) -> Unit` | | `getBadgeCount(callback)` | Get current badge count. Callback: `(Int) -> Unit` | #### Delegate | Method | Description | |---|---| | `setDelegate(delegate?)` | Set a `NotificationCenterDelegate` for foreground presentation and action callbacks. Pass `null` to remove. Without a delegate, notifications show banner + sound + list by default. | --- ### `NotificationCenterDelegate` Implement this interface to control foreground notification display and handle user interactions. **Thread safety:** All delegate methods are automatically dispatched to the AWT Event Dispatch Thread by the library. You can safely update Compose state (`mutableStateOf`, `SnapshotStateList`, etc.) directly in your delegate implementation without manual thread marshalling. `willPresent` runs via `invokeAndWait` (synchronous, must return quickly). `didReceive` and `openSettings` run via `invokeLater` (asynchronous). ```kotlin NotificationCenter.setDelegate(object : NotificationCenterDelegate { override fun willPresent(notification: DeliveredNotification): Set { // Return which presentation to use when app is in foreground return setOf(PresentationOption.BANNER, PresentationOption.SOUND) } override fun didReceive(response: NotificationResponse) { when (response.actionIdentifier) { "reply" -> println("User replied: ${response.userText}") "archive" -> println("Archived") NotificationAction.DEFAULT_ACTION_IDENTIFIER -> println("Tapped notification") NotificationAction.DISMISS_ACTION_IDENTIFIER -> println("Dismissed") } } override fun openSettings(notification: DeliveredNotification?) { // Optional: user tapped notification settings button } }) ``` | Method | Returns | Description | |---|---|---| | `willPresent(notification)` | `Set` | Called when notification arrives while app is foreground. Return empty set to suppress. | | `didReceive(response)` | `Unit` | Called when user taps the notification or an action button. | | `openSettings(notification?)` | `Unit` | Called when user taps the settings button. Optional, default no-op. | --- ### Data Types #### `NotificationContent` | Property | Type | Default | Description | |---|---|---|---| | `title` | `String` | `""` | Notification title | | `subtitle` | `String` | `""` | Notification subtitle | | `body` | `String` | `""` | Notification body text | | `badge` | `Int?` | `null` | Badge count (`null` = don't change) | | `sound` | `NotificationSound?` | `null` | Sound to play | | `userInfo` | `Map` | `emptyMap()` | Custom key-value data | | `attachments` | `List` | `emptyList()` | Media attachments (file paths) | | `threadIdentifier` | `String` | `""` | Thread ID for grouping | | `categoryIdentifier` | `String` | `""` | Category ID (links to registered actions) | | `targetContentIdentifier` | `String` | `""` | Content identifier (macOS 11+) | | `interruptionLevel` | `InterruptionLevel` | `ACTIVE` | Interruption level (macOS 12+) | | `relevanceScore` | `Double` | `0.0` | Relevance score for sorting (macOS 12+) | #### `NotificationRequest` | Property | Type | Description | |---|---|---| | `identifier` | `String` | Unique request ID (reusing an ID replaces the previous notification) | | `content` | `NotificationContent` | Notification content | | `trigger` | `NotificationTrigger?` | Delivery trigger (`null` is not supported — use `TimeInterval(1.0)` for near-immediate) | #### `NotificationTrigger` ```kotlin // Fire after 30 seconds NotificationTrigger.TimeInterval(interval = 30.0) // Fire every day at 9:00 NotificationTrigger.Calendar( dateComponents = DateComponents(hour = 9, minute = 0), repeats = true, ) ``` | Subclass | Properties | Description | |---|---|---| | `TimeInterval` | `interval: Double`, `repeats: Boolean` | Fire after N seconds. Repeating requires `interval >= 60`. | | `Calendar` | `dateComponents: DateComponents`, `repeats: Boolean` | Fire when date/time components match. | #### `DateComponents` All fields are optional (`null` = wildcard). | Property | Type | Description | |---|---|---| | `year` | `Int?` | Year | | `month` | `Int?` | Month (1–12) | | `day` | `Int?` | Day of month (1–31) | | `hour` | `Int?` | Hour (0–23) | | `minute` | `Int?` | Minute (0–59) | | `second` | `Int?` | Second (0–59) | | `weekday` | `Int?` | Day of week (1=Sunday, 7=Saturday) | #### `NotificationSound` | Variant | Description | |---|---| | `NotificationSound.Default` | Default system sound | | `NotificationSound.Named(name)` | Custom sound from app bundle | | `NotificationSound.DefaultCritical` | Default critical alert sound (requires entitlement) | | `NotificationSound.DefaultCriticalWithVolume(volume)` | Critical sound with custom volume (0.0–1.0) | | `NotificationSound.CriticalNamed(name, volume)` | Named critical sound with volume | #### `NotificationAttachment` | Property | Type | Description | |---|---|---| | `identifier` | `String` | Unique attachment ID | | `url` | `String` | Absolute file path to the media (image, audio, video) | #### `NotificationAction` ```kotlin // Simple button NotificationAction( identifier = "archive", title = "Archive", options = setOf(ActionOption.DESTRUCTIVE), ) // Text input button TextInputNotificationAction( identifier = "reply", title = "Reply", options = setOf(ActionOption.FOREGROUND), textInputButtonTitle = "Send", textInputPlaceholder = "Type your reply...", ) ``` | Property | Type | Description | |---|---|---| | `identifier` | `String` | Unique action ID (received in `didReceive`) | | `title` | `String` | Button label | | `options` | `Set` | Action behavior flags | `TextInputNotificationAction` adds: | Property | Type | Description | |---|---|---| | `textInputButtonTitle` | `String` | Submit button label | | `textInputPlaceholder` | `String` | Input placeholder text | Constants: `NotificationAction.DEFAULT_ACTION_IDENTIFIER`, `NotificationAction.DISMISS_ACTION_IDENTIFIER`. #### `NotificationCategory` ```kotlin NotificationCategory( identifier = "message", actions = listOf(replyAction, archiveAction), intentIdentifiers = emptyList(), options = setOf(CategoryOption.CUSTOM_DISMISS_ACTION), ) ``` | Property | Type | Description | |---|---|---| | `identifier` | `String` | Category ID (referenced by `NotificationContent.categoryIdentifier`) | | `actions` | `List` | Action buttons (max 10, first 2 shown in compact view) | | `intentIdentifiers` | `List` | SiriKit intent identifiers | | `options` | `Set` | Category behavior flags | #### `NotificationSettings` Read-only snapshot of the app's notification permissions. | Property | Type | Description | |---|---|---| | `authorizationStatus` | `AuthorizationStatus` | Current authorization state | | `soundSetting` | `NotificationSetting` | Sound permission | | `badgeSetting` | `NotificationSetting` | Badge permission | | `alertSetting` | `NotificationSetting` | Alert permission | | `notificationCenterSetting` | `NotificationSetting` | Notification Center display | | `lockScreenSetting` | `NotificationSetting` | Lock screen display | | `alertStyle` | `AlertStyle` | Alert presentation style | | `showPreviewsSetting` | `ShowPreviewsSetting` | Preview display setting | | `criticalAlertSetting` | `NotificationSetting` | Critical alert permission | | `providesAppNotificationSettings` | `Boolean` | Whether app provides custom settings UI | | `timeSensitiveSetting` | `NotificationSetting` | Time-sensitive permission (macOS 12+) | | `directMessagesSetting` | `NotificationSetting` | Direct messages permission (macOS 12+) | | `scheduledDeliverySetting` | `NotificationSetting` | Scheduled delivery setting (macOS 15+) | #### `NotificationResponse` Received in `NotificationCenterDelegate.didReceive`. | Property | Type | Description | |---|---|---| | `actionIdentifier` | `String` | Which action button was tapped | | `notification` | `DeliveredNotification` | The original notification | | `userText` | `String?` | Text entered by user (non-null for `TextInputNotificationAction`) | #### `DeliveredNotification` | Property | Type | Description | |---|---|---| | `identifier` | `String` | Notification request ID | | `title` | `String` | Title | | `subtitle` | `String` | Subtitle | | `body` | `String` | Body | | `date` | `Long` | Delivery timestamp (epoch millis) | | `categoryIdentifier` | `String` | Category ID | | `threadIdentifier` | `String` | Thread ID | --- ### Enums #### `AuthorizationStatus` | Value | Description | |---|---| | `NOT_DETERMINED` | User hasn't been asked yet | | `DENIED` | User denied | | `AUTHORIZED` | User granted | | `PROVISIONAL` | Provisional (quiet) authorization | | `EPHEMERAL` | Ephemeral authorization | #### `AuthorizationOption` (bitmask) | Value | Description | |---|---| | `BADGE` | Badge count | | `SOUND` | Notification sounds | | `ALERT` | Alerts (banners/notifications) | | `CRITICAL_ALERT` | Critical alerts — **requires** `com.apple.developer.usernotifications.critical-alerts` entitlement. Including this option without the entitlement causes the entire authorization request to fail silently. | | `PROVIDES_APP_NOTIFICATION_SETTINGS` | App provides custom settings | | `PROVISIONAL` | Provisional authorization (no prompt) | | `TIME_SENSITIVE` | Time-sensitive notifications (macOS 12+) — may require entitlement on some macOS versions | #### `PresentationOption` (bitmask) Returned by `willPresent` to control foreground display. | Value | Description | |---|---| | `BADGE` | Update badge | | `SOUND` | Play sound | | `LIST` | Show in Notification Center list (macOS 11+) | | `BANNER` | Show banner (macOS 11+) | #### `InterruptionLevel` | Value | Description | |---|---| | `PASSIVE` | No sound, no wake | | `ACTIVE` | Default behavior | | `TIME_SENSITIVE` | Breaks through Focus modes | | `CRITICAL` | Always shows, even in DND (requires Apple entitlement) | #### `ActionOption` (bitmask) | Value | Description | |---|---| | `AUTHENTICATION_REQUIRED` | Device must be unlocked | | `DESTRUCTIVE` | Red styling | | `FOREGROUND` | Launches app to foreground | #### `CategoryOption` (bitmask) | Value | Description | |---|---| | `CUSTOM_DISMISS_ACTION` | Send dismiss action to delegate | | `ALLOW_IN_CAR_PLAY` | Allow in CarPlay | | `HIDDEN_PREVIEWS_SHOW_TITLE` | Show title when previews hidden | | `HIDDEN_PREVIEWS_SHOW_SUBTITLE` | Show subtitle when previews hidden | | `ALLOW_ANNOUNCEMENT` | Allow Siri announcement | #### `NotificationSetting` | Value | Description | |---|---| | `NOT_SUPPORTED` | Not available on this device | | `DISABLED` | Disabled by user | | `ENABLED` | Enabled | #### `AlertStyle` | Value | Description | |---|---| | `NONE` | No alerts | | `BANNER` | Auto-dismissing banners | | `ALERT` | Persistent alerts (require interaction) | #### `ShowPreviewsSetting` | Value | Description | |---|---| | `ALWAYS` | Always show previews | | `WHEN_AUTHENTICATED` | Only when unlocked | | `NEVER` | Never show previews | --- ## Full Example: Messaging Actions ```kotlin // Register categories with action buttons val replyAction = TextInputNotificationAction( identifier = "reply", title = "Reply", options = setOf(ActionOption.FOREGROUND), textInputButtonTitle = "Send", textInputPlaceholder = "Type your reply...", ) val markReadAction = NotificationAction(identifier = "mark-read", title = "Mark as Read") val deleteAction = NotificationAction( identifier = "delete", title = "Delete", options = setOf(ActionOption.DESTRUCTIVE), ) NotificationCenter.setNotificationCategories(setOf( NotificationCategory( identifier = "message", actions = listOf(replyAction, markReadAction, deleteAction), options = setOf(CategoryOption.CUSTOM_DISMISS_ACTION), ) )) // Set delegate to handle action callbacks NotificationCenter.setDelegate(object : NotificationCenterDelegate { override fun willPresent(notification: DeliveredNotification) = setOf(PresentationOption.BANNER, PresentationOption.SOUND, PresentationOption.LIST) override fun didReceive(response: NotificationResponse) { when (response.actionIdentifier) { "reply" -> sendMessage(response.userText ?: "") "mark-read" -> markAsRead(response.notification.identifier) "delete" -> deleteMessage(response.notification.identifier) NotificationAction.DEFAULT_ACTION_IDENTIFIER -> openConversation() } } }) // Send a notification with actions NotificationCenter.add( NotificationRequest( identifier = "msg-42", content = NotificationContent( title = "Alice", subtitle = "Project Nucleus", body = "Hey! Have you seen the latest build?", sound = NotificationSound.Default, categoryIdentifier = "message", threadIdentifier = "conversation-alice", ), trigger = NotificationTrigger.TimeInterval(interval = 1.0), ) ) { error -> if (error != null) println("Failed: $error") } ``` ## Critical Alerts Critical alerts bypass Do Not Disturb and Focus modes. They require: 1. An Apple-issued entitlement (`com.apple.developer.usernotifications.critical-alerts`) — [request from Apple](https://developer.apple.com/contact/request/notifications-critical-alerts-entitlement/) 2. The entitlement declared in your `entitlements.plist` 3. `AuthorizationOption.CRITICAL_ALERT` in `requestAuthorization` **Do NOT include `CRITICAL_ALERT` without the entitlement:** If you pass `AuthorizationOption.CRITICAL_ALERT` in `requestAuthorization` without the Apple-issued entitlement, **the entire authorization request fails** — macOS returns `granted = false` with error `"Notifications are not allowed for this application"` and the permission dialog is never shown. The same applies to `TIME_SENSITIVE` on some macOS versions. Only use `ALERT`, `SOUND`, and `BADGE` unless you have obtained the corresponding entitlement from Apple: ```kotlin // Safe default — works without special entitlements NotificationCenter.requestAuthorization( setOf(AuthorizationOption.ALERT, AuthorizationOption.SOUND, AuthorizationOption.BADGE) ) { granted, error -> ... } ``` ```kotlin // Only use this if you have the com.apple.developer.usernotifications.critical-alerts entitlement NotificationCenter.requestAuthorization( setOf(AuthorizationOption.ALERT, AuthorizationOption.SOUND, AuthorizationOption.CRITICAL_ALERT) ) { granted, _ -> if (granted) { NotificationCenter.add( NotificationRequest( identifier = "critical-1", content = NotificationContent( title = "System Alert", body = "Immediate attention required", sound = NotificationSound.DefaultCritical, interruptionLevel = InterruptionLevel.CRITICAL, ), trigger = NotificationTrigger.TimeInterval(interval = 1.0), ) ) } } ``` ## Native Library Ships pre-built macOS dylibs (arm64 + x86_64). No Windows or Linux native — `isAvailable` returns `false` on other platforms and all methods are no-op. - `libnucleus_notification.dylib` — linked against `UserNotifications.framework` and `Cocoa.framework` - Minimum deployment target: macOS 10.14 - `UNNotificationPresentationOptionBanner`/`List` require macOS 11+, falls back to `Alert` on 10.x - `InterruptionLevel` requires macOS 12+ - `setBadgeCount` uses `UNUserNotificationCenter` API on macOS 13+, `NSDockTile` fallback on older ## ProGuard ```proguard -keep class io.github.kdroidfilter.nucleus.notification.macos.NativeMacNotificationBridge { native ; static ** on*(...); } ``` ## GraalVM Reachability metadata is included in the JAR at `META-INF/native-image/io.github.kdroidfilter/nucleus.notification-macos/reachability-metadata.json`. No additional configuration needed. --- # Notification (Windows) Complete Kotlin mapping of the [Windows Toast Notifications API](https://learn.microsoft.com/en-us/windows/apps/develop/notifications/app-notifications/) via JNI. Show rich toast notifications with text, images, buttons, text input, selection boxes, progress bars, headers, and audio on Windows 10/11. **WinRT via WRL:** Uses Microsoft WRL (Windows Runtime Library) for COM event handling — no JNA, no reflection. Supports both MSIX-packaged and unpackaged apps. ## Installation ```kotlin dependencies { implementation("io.github.kdroidfilter:nucleus.notification-windows:") } ``` Depends on `core-runtime` (compile-only) for `NativeLibraryLoader`, `ExecutableRuntime`, and `NucleusApp`. ## Quick Start ```kotlin import io.github.kdroidfilter.nucleus.notification.windows.* // 1. Initialize (auto-detects APPX vs unpackaged) WindowsNotificationCenter.initialize() // 2. Show a simple toast WindowsNotificationCenter.showSimple( title = "Hello!", body = "Welcome to Nucleus.", tag = "greeting", ) ``` **AUMID handling:** - **APPX/MSIX**: the AUMID is resolved automatically from the package identity. - **Unpackaged apps (EXE, MSI, dev)**: the library derives the AUMID from `NucleusApp.appId` and registers it on the process via `SetCurrentProcessExplicitAppUserModelID`. - You can also pass an explicit AUMID: `initialize(aumid = "MyCompany.MyApp")`. **Installed app required:** Notifications on unpackaged apps require a Start Menu shortcut (`.lnk`) with the AUMID property set. Without it, toasts may not appear or persist in Action Center. This shortcut is created by the installer (e.g. `./gradlew packageDistributionForCurrentOS`). When running via `./gradlew run`, notifications will work **only if the app has been installed before** (even a different version), since the shortcut already exists. This is similar to macOS, where notifications require a packaged `.app` bundle. ## API Reference ### `WindowsNotificationCenter` Main entry point. All methods are thread-safe. | Property / Method | Description | |---|---| | `isAvailable: Boolean` | `true` if native library loaded (Windows only) | #### Initialization | Method | Returns | Description | |---|---|---| | `initialize(aumid?)` | `Boolean` | Initialize the notification subsystem. Pass `null` (default) to auto-resolve the AUMID. | | `uninitialize()` | `Unit` | Clean up native resources. Call on app shutdown. | #### Showing Notifications | Method | Description | |---|---| | `show(content, tag, group, ...)` | Show a toast from a `ToastContent` model. Supports `initialData` for data-bound progress bars. | | `showSimple(title, body, body2, tag, group)` | Show a simple text-only toast (up to 3 lines). | | `showFromXml(xml, tag, group, ...)` | Show a toast from raw XML string. | #### Updating & Removing | Method | Description | |---|---| | `update(tag, group, data)` | Update data-bound fields (progress bars) without replacing the toast. | | `remove(tag, group)` | Remove a specific toast from Action Center. | | `removeGroup(group)` | Remove all toasts in a group. | | `clearAll()` | Remove all toasts for this app. | #### History & Listeners | Method | Description | |---|---| | `getHistory(callback)` | Get active notifications. Callback: `(List, String?) -> Unit` | | `addListener(listener)` | Register a `ToastNotificationListener` for lifecycle events. | | `removeListener(listener)` | Unregister a listener. | --- ### `ToastNotificationListener` Implement this interface to receive toast lifecycle events. All callbacks are dispatched on the Swing EDT. | Method | Description | |---|---| | `onActivated(tag, group, arguments, userInputs)` | User clicked the toast body or a button. `userInputs` contains text box / selection values. | | `onDismissed(tag, group, reason)` | Toast was dismissed (`USER_CANCELED`, `APPLICATION_HIDDEN`, `TIMED_OUT`). | | `onFailed(tag, group, errorCode)` | Toast failed to display (HRESULT error code). | --- ## Toast Content Model The `ToastContent` data class maps the full [toast XML schema](https://learn.microsoft.com/en-us/windows/apps/develop/notifications/app-notifications/toast-schema): ### Data Types #### Visual Elements | Type | Description | |---|---| | `AdaptiveText` | Text element. Properties: `text`, `hintStyle`, `hintWrap`, `hintMaxLines`, `hintMinLines`, `hintAlign`, `language` | | `AdaptiveImage` | Inline image. Properties: `source`, `hintCrop`, `hintRemoveMargin`, `hintAlign`, `alternateText`, `addImageQuery` | | `ToastGenericAppLogo` | App logo override (left of text). Properties: `source`, `hintCrop`, `alternateText` | | `ToastGenericHeroImage` | Large hero image at top of toast. Properties: `source`, `alternateText` | | `ToastGenericAttributionText` | Attribution text at bottom. Properties: `text`, `language` | #### Adaptive Layout | Type | Description | |---|---| | `AdaptiveGroup` | Multi-column layout container. Contains `List`. | | `AdaptiveSubgroup` | Column within a group. Properties: `children`, `hintWeight`, `hintTextStacking` | #### Progress Bar | Type | Description | |---|---| | `AdaptiveProgressBar` | Progress bar with data binding support. Properties: `title`, `value` (0.0-1.0), `valueBind` (binding key), `valueStringOverride`, `status` | Use `valueBind` and `{key}` syntax in string fields for live updates via `WindowsNotificationCenter.update()`. #### Inputs | Type | Description | |---|---| | `ToastTextBox` | Text input. Properties: `id`, `title`, `placeholderContent`, `defaultInput` | | `ToastSelectionBox` | Dropdown. Properties: `id`, `title`, `defaultSelectionBoxItemId`, `items: List` | | `ToastSelectionBoxItem` | Selection item. Properties: `id`, `content` | #### Buttons | Type | Description | |---|---| | `ToastButton` | Action button. Properties: `content`, `arguments`, `activationType`, `imageUri`, `inputId`, `afterActivationBehavior`, `tooltipText` | | `ToastButtonSnooze` | System snooze button. Properties: `customContent`, `selectionBoxId` | | `ToastButtonDismiss` | System dismiss button. Properties: `customContent` | | `ToastContextMenuItem` | Right-click context menu item. Properties: `content`, `arguments`, `activationType` | #### Audio | Type | Description | |---|---| | `ToastAudio` | Sound configuration. Properties: `source` (use `ToastAudioSource`), `customSource`, `loop`, `silent` | #### Header | Type | Description | |---|---| | `ToastHeader` | Groups notifications in Action Center. Properties: `id`, `title`, `arguments`, `activationType` | #### Data Binding | Type | Description | |---|---| | `ToastNotificationData` | Data for updating bound fields. Properties: `sequenceNumber`, `values: Map` | ### Enums | Enum | Values | |---|---| | `ActivationType` | `FOREGROUND`, `BACKGROUND`, `PROTOCOL` | | `AfterActivationBehavior` | `DEFAULT`, `PENDING_UPDATE` | | `ToastScenario` | `DEFAULT`, `REMINDER`, `ALARM`, `INCOMING_CALL` | | `DismissalReason` | `USER_CANCELED`, `APPLICATION_HIDDEN`, `TIMED_OUT` | | `AdaptiveTextStyle` | 17 styles: `CAPTION`, `BODY`, `BASE`, `SUBTITLE`, `TITLE`, `SUBHEADER`, `HEADER` + subtle/numeral variants | | `AdaptiveTextAlign` | `DEFAULT`, `AUTO`, `LEFT`, `CENTER`, `RIGHT` | | `AdaptiveImageCrop` | `DEFAULT`, `NONE`, `CIRCLE` | | `AdaptiveImageAlign` | `DEFAULT`, `STRETCH`, `LEFT`, `CENTER`, `RIGHT` | | `ImagePlacement` | `INLINE`, `APP_LOGO_OVERRIDE`, `HERO` | | `AdaptiveSubgroupTextStacking` | `DEFAULT`, `TOP`, `CENTER`, `BOTTOM` | | `ToastAudioSource` | 25 sounds: `DEFAULT`, `IM`, `MAIL`, `REMINDER`, `SMS`, `ALARM_DEFAULT`-`ALARM10`, `CALL_DEFAULT`-`CALL10` | --- ## Kotlin DSL A type-safe DSL builder is provided for convenient toast construction: ```kotlin val content = toast { launch = "action=viewMessage&id=123" scenario = ToastScenario.REMINDER visual { text("Meeting Reminder") text("Team standup in 5 minutes") heroImage("https://example.com/hero.png") appLogo("https://example.com/logo.png", crop = AdaptiveImageCrop.CIRCLE) attribution("via Calendar") progressBar( status = "{progressStatus}", title = "{progressTitle}", valueBind = "progressValue", ) group { subgroup(weight = 1) { text("Column 1", style = AdaptiveTextStyle.CAPTION) } subgroup(weight = 2) { text("Column 2", style = AdaptiveTextStyle.BODY) } } } actions { textBox("replyBox", title = "Reply", placeholder = "Type a message...") selectionBox("snoozeTime", title = "Snooze for") { item("5", "5 minutes") item("15", "15 minutes") item("60", "1 hour") } button("Reply", arguments = "action=reply", inputId = "replyBox") button("Dismiss", arguments = "action=dismiss", activationType = ActivationType.BACKGROUND) contextMenuItem("Open settings", arguments = "action=settings") } audio(ToastAudioSource.REMINDER) header(id = "meetings", title = "Meetings", arguments = "action=openMeetings") } WindowsNotificationCenter.show(content, tag = "meeting-1") ``` ## Progress Bar with Data Binding To update a progress bar live without replacing the toast: ```kotlin // 1. Show toast with bindable progress bar val content = toast { visual { text("Downloading file.zip") progressBar( title = "{title}", valueBind = "progressValue", valueStringOverride = "{progressOverride}", status = "{status}", ) } } WindowsNotificationCenter.show( content, tag = "download-1", initialData = ToastNotificationData( sequenceNumber = 0, values = mapOf( "title" to "file.zip", "progressValue" to "0", "progressOverride" to "0%", "status" to "Starting...", ), ), ) // 2. Update the progress WindowsNotificationCenter.update( tag = "download-1", data = ToastNotificationData( sequenceNumber = 1, values = mapOf( "title" to "file.zip", "progressValue" to "0.75", "progressOverride" to "75%", "status" to "Downloading...", ), ), ) ``` **Data binding requirements:** - The toast **must** have a `tag` (required for `update` to find the notification). - Use `{key}` syntax in string fields and `valueBind` for the progress value. - The initial toast **must** include `initialData` with all binding keys set. - Each `update` call must increment `sequenceNumber` to avoid race conditions. ## Handling User Input When a toast has input elements (text boxes, selection boxes), the user's input is delivered in the `onActivated` callback: ```kotlin WindowsNotificationCenter.addListener(object : ToastNotificationListener { override fun onActivated( tag: String, group: String, arguments: String, userInputs: Map, ) { // arguments = "action=reply" // userInputs = {"replyBox" to "Hello!"} println("Reply text: ${userInputs["replyBox"]}") } override fun onDismissed(tag: String, group: String, reason: DismissalReason) {} override fun onFailed(tag: String, group: String, errorCode: Int) {} }) ``` ## Constraints | Constraint | Limit | |---|---| | Top-level text elements | 3 max | | Input elements per toast | 5 max | | Buttons per toast | 5 max (shared with context menu items) | | Toast XML payload | 5 KB max | | Image size | 3 MB (1 MB on metered connections) | | Notifications per app in Action Center | 20 max | | Tag / Group length | 16 characters max | ## Native Library Ships pre-built Windows DLLs (x64 + ARM64). No macOS or Linux native — `isAvailable` returns `false` on other platforms and all methods are no-op. - `nucleus_notification_windows.dll` — linked against `ole32`, `runtimeobject`, `shell32`, `user32`, `advapi32` - Requires Windows 10 (build 10240+) - Headers and progress bars require Creators Update (build 15063+) - Uses WRL `Callback<>` for COM event handlers ## ProGuard ```proguard -keep class io.github.kdroidfilter.nucleus.notification.windows.NativeWindowsNotificationBridge { native ; static ** on*(...); } ``` ## GraalVM Reachability metadata is included in the JAR at `META-INF/native-image/io.github.kdroidfilter/nucleus.notification-windows/reachability-metadata.json`. No additional configuration needed. --- # Notification (Linux) Complete Kotlin mapping of the [freedesktop Desktop Notifications Specification](https://specifications.freedesktop.org/notification/latest-single/) via JNI. Send notifications with action buttons, urgency levels, icons, sounds, and inline images on any Linux desktop. **Pure D-Bus via GIO:** Uses GLib/GIO (`libgio-2.0`) for D-Bus communication — no JNA, no reflection, no Java D-Bus libraries. ## Installation ```kotlin dependencies { implementation("io.github.kdroidfilter:nucleus.notification-linux:") } ``` Depends on `core-runtime` (compile-only) for `NativeLibraryLoader` and `freedesktop-icons` (transitive) for typesafe icon names. ## Quick Start ```kotlin import io.github.kdroidfilter.nucleus.notification.linux.* // Send a simple notification with typesafe icon and sound val id = LinuxNotificationCenter.notify( Notification( appName = "My App", summary = "Hello!", body = "Welcome to Nucleus.", appIcon = FreedesktopIcon.Status.DIALOG_INFORMATION, hints = NotificationHints( imagePath = FreedesktopIcon.Status.DIALOG_INFORMATION, soundName = NotificationSound.Notification.DIALOG_INFORMATION, ), ) ) // Close it programmatically LinuxNotificationCenter.closeNotification(id) ``` **Use `imagePath` for reliable icon display:** On GNOME Shell, the `appIcon` parameter may be ignored when the application has a visible window. Use the `imagePath` hint with a [freedesktop icon name](#icons) for consistent icon display across all desktops. ## API Reference ### `LinuxNotificationCenter` Main entry point. All methods are thread-safe. | Property / Method | Description | |---|---| | `isAvailable: Boolean` | `true` if native library loaded (Linux only) | #### Sending Notifications | Method | Returns | Description | |---|---|---| | `notify(notification)` | `Int` | Send a notification. Returns the server-assigned ID (> 0), or 0 on failure. | | `closeNotification(id)` | `Unit` | Forcefully close a notification by ID. | #### Server Queries | Method | Returns | Description | |---|---|---| | `getCapabilities()` | `List` | Query supported features (see [Capabilities](#capabilities)). | | `getServerInformation()` | `ServerInformation?` | Query the notification daemon identity. | #### Signal Listeners | Method | Description | |---|---| | `addListener(listener)` | Register a `LinuxNotificationListener`. Signal monitoring starts automatically on first listener. | | `removeListener(listener)` | Unregister a listener. Monitoring stops when the last listener is removed. | --- ### `LinuxNotificationListener` Implement this interface to receive asynchronous signals from the notification server. **Thread safety:** All callbacks are dispatched to the Swing EDT via `SwingUtilities.invokeLater`. You can safely update Compose state directly. ```kotlin LinuxNotificationCenter.addListener(object : LinuxNotificationListener { override fun onClosed(notificationId: Int, reason: CloseReason) { println("Notification #$notificationId closed: ${reason.name}") } override fun onActionInvoked(notificationId: Int, actionKey: String) { println("Action '$actionKey' on #$notificationId") } override fun onActivationToken(notificationId: Int, token: String) { // Wayland/X11 activation token for window focus } }) ``` | Method | Description | |---|---| | `onClosed(notificationId, reason)` | Notification was closed (expired, dismissed, or via API). | | `onActionInvoked(notificationId, actionKey)` | User clicked an action button. | | `onActivationToken(notificationId, token)` | Server provides an activation token (Wayland/X11). | --- ### Data Types #### `Notification` | Property | Type | Default | Description | |---|---|---|---| | `appName` | `String` | `""` | Application name (informational). | | `replacesId` | `Int` | `0` | If non-zero, atomically replaces the notification with this ID. | | `appIcon` | `FreedesktopIcon?` | `null` | Typesafe icon (see [Icons](#icons)). | | `summary` | `String` | *(required)* | Single-line notification title. | | `body` | `String` | `""` | Multi-line body text. Supports [limited markup](#body-markup). | | `actions` | `List` | `emptyList()` | Interactive action buttons. | | `hints` | `NotificationHints` | `NotificationHints()` | Display hints (urgency, image, sound, etc.). | | `expireTimeout` | `Int` | `-1` | Auto-close timeout: `-1` = server default, `0` = never, positive = milliseconds. | #### `NotificationAction` | Property | Type | Description | |---|---|---| | `key` | `String` | Unique action identifier. Use `NotificationAction.DEFAULT_KEY` (`"default"`) for the body click action. | | `label` | `String` | Human-readable button label. | ```kotlin val actions = listOf( NotificationAction(NotificationAction.DEFAULT_KEY, "Open"), NotificationAction("reply", "Reply"), NotificationAction("archive", "Archive"), ) ``` #### `NotificationHints` All properties are optional. `null` means the hint is not sent. | Property | Type | Description | |---|---|---| | `urgency` | `Urgency?` | Urgency level (see [Urgency](#urgency)). | | `category` | `String?` | Notification category (see [Categories](#categories)). | | `desktopEntry` | `String?` | Desktop file name without `.desktop` suffix. Helps the daemon identify the app. | | `imageData` | `ImageData?` | Raw pixel data (see [ImageData](#imagedata)). | | `imagePath` | `FreedesktopIcon?` | Typesafe icon (see [Icons](#icons)). Takes priority over `appIcon`. | | `actionIcons` | `Boolean?` | If `true`, action keys are interpreted as icon names. | | `soundFile` | `String?` | Absolute path to a sound file (see [Sounds](#sounds)). | | `soundName` | `NotificationSound?` | Typesafe sound (see [Sounds](#sounds)). | | `suppressSound` | `Boolean?` | If `true`, suppress any notification sound. | | `resident` | `Boolean?` | If `true`, notification stays after action is invoked. | | `transient` | `Boolean?` | If `true`, bypass notification log/history. | | `x` | `Int?` | Screen X position hint (requires `y`). | | `y` | `Int?` | Screen Y position hint (requires `x`). | #### `ImageData` Embed raw pixel data directly in the notification. Corresponds to the `image-data` hint with D-Bus signature `(iiibiiay)`. | Property | Type | Default | Description | |---|---|---|---| | `width` | `Int` | — | Image width in pixels. | | `height` | `Int` | — | Image height in pixels. | | `rowstride` | `Int` | — | Bytes per row (usually `width * channels`). | | `hasAlpha` | `Boolean` | — | Whether the image has an alpha channel. | | `bitsPerSample` | `Int` | `8` | Bits per color channel (must be 8). | | `channels` | `Int` | `3` or `4` | Number of channels (3 = RGB, 4 = RGBA). | | `data` | `ByteArray` | — | Raw pixel bytes in RGB(A) order. | ```kotlin // Create a 2x2 red square val imageData = ImageData( width = 2, height = 2, rowstride = 6, hasAlpha = false, data = byteArrayOf( 0xFF.toByte(), 0, 0, 0xFF.toByte(), 0, 0, // row 1 0xFF.toByte(), 0, 0, 0xFF.toByte(), 0, 0, // row 2 ), ) ``` #### `ServerInformation` | Property | Type | Description | |---|---|---| | `name` | `String` | Server name (e.g. `"gnome-shell"`, `"dunst"`, `"mako"`). | | `vendor` | `String` | Vendor (e.g. `"GNOME"`, `"dunst"`). | | `version` | `String` | Server version. | | `specVersion` | `String` | Specification version implemented (e.g. `"1.2"`). | --- ### Enums #### `Urgency` | Value | Int | Description | |---|---|---| | `LOW` | `0` | Low priority — may be displayed less prominently. | | `NORMAL` | `1` | Default urgency level. | | `CRITICAL` | `2` | Critical — should not auto-expire. | #### `CloseReason` Received in `LinuxNotificationListener.onClosed`. | Value | Int | Description | |---|---|---| | `EXPIRED` | `1` | The notification timed out. | | `DISMISSED` | `2` | The user dismissed the notification. | | `CLOSED` | `3` | Closed by `closeNotification()`. | | `UNDEFINED` | `4` | Reserved / undefined reason. | --- ## Icons Icons are typesafe via the `FreedesktopIcon` sealed interface from the shared [`freedesktop-icons`](freedesktop-icons.md) module. All 338 standard names from the [freedesktop Icon Naming Specification](https://specifications.freedesktop.org/icon-naming/latest/) are available as enum constants. ```kotlin import io.github.kdroidfilter.nucleus.freedesktop.icons.FreedesktopIcon FreedesktopIcon.Status.DIALOG_INFORMATION FreedesktopIcon.Device.PRINTER FreedesktopIcon.Custom("my-app-icon") ``` See [Freedesktop Icons](freedesktop-icons.md) for the full list of icon contexts and usage examples. ### Image Priority When multiple image sources are set, the notification daemon picks one using this priority order (per spec): 1. `imageData` hint (raw pixels — always works) 2. `imagePath` hint (icon name or file path) 3. `appIcon` parameter **GNOME Shell and `appIcon`:** GNOME Shell identifies running applications by their window/PID and may ignore `appIcon` in favor of the application's window icon. Use `imagePath` hint for reliable icon display on GNOME. Other daemons (dunst, mako, swaync) fully respect `appIcon`. --- ## Sounds Sounds are typesafe via the `NotificationSound` sealed interface. All 146 standard names from the [freedesktop Sound Naming Specification](https://specifications.freedesktop.org/sound-naming-spec/latest/) are available as enum constants, grouped by category. ### `NotificationSound` ```kotlin // Standard sound from the spec (typesafe) hints = NotificationHints(soundName = NotificationSound.Notification.MESSAGE_NEW_INSTANT) hints = NotificationHints(soundName = NotificationSound.Alert.DIALOG_ERROR) // Custom sound name hints = NotificationHints(soundName = NotificationSound.Custom("x-myapp-ding")) ``` ### Sound Categories | Enum | Count | Examples | |---|---|---| | `NotificationSound.Notification` | 40 | `MESSAGE_NEW_INSTANT`, `MESSAGE_NEW_EMAIL`, `COMPLETE_DOWNLOAD`, `DIALOG_INFORMATION`, `DIALOG_WARNING`, `DEVICE_ADDED`, `ALARM_CLOCK_ELAPSED` | | `NotificationSound.Alert` | 7 | `DIALOG_ERROR`, `BATTERY_LOW`, `NETWORK_CONNECTIVITY_ERROR`, `SOFTWARE_UPDATE_URGENT` | | `NotificationSound.Action` | 29 | `BELL_TERMINAL`, `TRASH_EMPTY`, `CAMERA_SHUTTER`, `SCREEN_CAPTURE`, `MESSAGE_SENT_INSTANT` | | `NotificationSound.InputFeedback` | 44 | `WINDOW_CLOSE`, `BUTTON_PRESSED`, `DIALOG_OK`, `DRAG_START` | | `NotificationSound.Game` | 5 | `GAME_OVER_WINNER`, `GAME_OVER_LOSER`, `GAME_CARD_SHUFFLE` | Full specification: [freedesktop Sound Naming Specification](https://specifications.freedesktop.org/sound-naming-spec/latest/) ### `soundFile` — Custom Sound File For sounds outside the theme, use an absolute file path: ```kotlin hints = NotificationHints(soundFile = "/usr/share/sounds/freedesktop/stereo/bell.oga") ``` ### `suppressSound` Suppress all sounds for a notification: ```kotlin hints = NotificationHints(suppressSound = true) ``` --- ## Body Markup The `body` field supports a subset of HTML when the server has the `body-markup` capability: | Tag | Example | Description | |---|---|---| | `` | `bold` | Bold text | | `` | `italic` | Italic text | | `` | `underline` | Underlined text | | `` | `link` | Hyperlink | | `` | `` | Inline image | ```kotlin Notification( summary = "Build Complete", body = "nucleus-1.3.0 built in 42s. " + "View on GitHub", ) ``` --- ## Categories The `category` hint uses a dot-separated format. Standard categories defined by the spec: | Category | Description | |---|---| | `device` | Generic device-related | | `device.added` | Device added | | `device.removed` | Device removed | | `device.error` | Device error | | `email` | Generic email | | `email.arrived` | New email | | `email.bounced` | Bounced email | | `im` | Generic instant message | | `im.received` | Message received | | `im.error` | IM error | | `network` | Generic network | | `network.connected` | Connected | | `network.disconnected` | Disconnected | | `network.error` | Network error | | `presence` | Generic presence | | `presence.online` | User came online | | `presence.offline` | User went offline | | `transfer` | Generic file transfer | | `transfer.complete` | Transfer complete | | `transfer.error` | Transfer error | Vendor extensions use the `x-vendor.*` prefix (e.g. `x-myapp.build-complete`). --- ## Capabilities Query the server's supported features with `getCapabilities()`. Common capabilities: | Capability | Description | |---|---| | `actions` | Server supports action buttons | | `body` | Server supports body text | | `body-markup` | Body supports HTML markup | | `body-hyperlinks` | Body supports `` links | | `body-images` | Body supports `` | | `icon-static` | Server supports static icons | | `persistence` | Server supports persistent notifications | | `sound` | Server supports sounds | ```kotlin val caps = LinuxNotificationCenter.getCapabilities() if ("actions" in caps) { // Safe to use action buttons } ``` --- ## Full Example: Messaging with Actions ```kotlin // Listen for user interactions LinuxNotificationCenter.addListener(object : LinuxNotificationListener { override fun onActionInvoked(notificationId: Int, actionKey: String) { when (actionKey) { NotificationAction.DEFAULT_KEY -> openConversation() "reply" -> showReplyDialog() "archive" -> archiveMessage() } } override fun onClosed(notificationId: Int, reason: CloseReason) { println("Notification #$notificationId: ${reason.name}") } }) // Send notification with actions val id = LinuxNotificationCenter.notify( Notification( appName = "My Messenger", summary = "Alice", body = "Project Nucleus\nHey! Have you seen the latest build?", appIcon = FreedesktopIcon.Status.MAIL_UNREAD, actions = listOf( NotificationAction(NotificationAction.DEFAULT_KEY, "Open"), NotificationAction("reply", "Reply"), NotificationAction("archive", "Archive"), ), hints = NotificationHints( urgency = Urgency.NORMAL, category = "im.received", imagePath = FreedesktopIcon.Status.MAIL_UNREAD, soundName = NotificationSound.Notification.MESSAGE_NEW_INSTANT, desktopEntry = "my-messenger", ), ) ) // Replace with an updated notification LinuxNotificationCenter.notify( Notification( replacesId = id, appName = "My Messenger", summary = "Alice (2 messages)", body = "Hey! Have you seen the latest build?\nAlso, lunch?", appIcon = FreedesktopIcon.Status.MAIL_UNREAD, hints = NotificationHints( urgency = Urgency.NORMAL, category = "im.received", imagePath = FreedesktopIcon.Status.MAIL_UNREAD, ), ) ) ``` ## Notification Replacement Use `replacesId` to atomically update an existing notification. The server replaces the old notification in-place without a new popup (useful for progress updates, message count changes, etc.): ```kotlin // Initial notification val id = LinuxNotificationCenter.notify( Notification(summary = "Downloading...", body = "0%") ) // Update in-place LinuxNotificationCenter.notify( Notification(replacesId = id, summary = "Downloading...", body = "50%") ) // Final update LinuxNotificationCenter.notify( Notification(replacesId = id, summary = "Download complete!", body = "100%") ) ``` --- ## Native Library Ships pre-built Linux shared libraries (x86_64 + aarch64). No macOS or Windows native — `isAvailable` returns `false` on other platforms. - `libnucleus_notification_linux.so` — linked against `libgio-2.0` (GLib/GIO) - Build requirement: `libgio-2.0-dev` (Debian/Ubuntu) or `glib2-devel` (Fedora) - Signal listening runs in a dedicated thread with its own `GMainLoop` ## ProGuard ```proguard -keep class io.github.kdroidfilter.nucleus.notification.linux.NativeLinuxNotificationBridge { native ; static ** on*(...); } ``` ## GraalVM JNI reflection metadata must include the bridge class: ```json [ { "type": "io.github.kdroidfilter.nucleus.notification.linux.NativeLinuxNotificationBridge", "methods": [ { "name": "onNotificationClosed", "parameterTypes": ["int", "int"] }, { "name": "onActionInvoked", "parameterTypes": ["int", "java.lang.String"] }, { "name": "onActivationToken", "parameterTypes": ["int", "java.lang.String"] } ] } ] ``` --- # Launcher (macOS) macOS dock context menu integration via JNI. Add custom items, submenus, separators, and click callbacks to your application's dock icon right-click menu. **Method swizzling:** Intercepts `applicationDockMenu:` on the existing `NSApplicationDelegate` via method swizzling. Works with any JVM — no JetBrains Runtime required. ## Installation ```kotlin dependencies { implementation("io.github.kdroidfilter:nucleus.launcher-macos:") } ``` Depends on `core-runtime` (compile-only) for `NativeLibraryLoader`. ## Quick Start ```kotlin import io.github.kdroidfilter.nucleus.launcher.macos.* // Listen for clicks MacOsDockMenu.listener = DockMenuListener { itemId -> println("Clicked item: $itemId") } // Set the dock menu MacOsDockMenu.setDockMenu(listOf( DockMenuItem(id = 1, title = "New Window"), DockMenuItem(id = 2, title = "Open File"), DockMenuItem.separator(id = 3), DockMenuItem( id = 4, title = "Recent Files", children = listOf( DockMenuItem(id = 41, title = "project.kt"), DockMenuItem(id = 42, title = "build.gradle.kts"), ), ), DockMenuItem.separator(id = 5), DockMenuItem(id = 6, title = "Preferences"), DockMenuItem(id = 7, title = "Disabled Item", enabled = false), )) // Remove the dock menu MacOsDockMenu.clearDockMenu() ``` **Thread safety:** Click callbacks are dispatched to the Swing EDT via `SwingUtilities.invokeLater`. You can safely update Compose state directly in your listener. --- ## API Reference ### `MacOsDockMenu` Singleton entry point for dock menu management. All methods are no-op when `isAvailable` is `false`. | Property / Method | Description | |---|---| | `isAvailable: Boolean` | `true` if the native library is loaded (macOS only). | | `listener: DockMenuListener?` | Callback invoked when a menu item is clicked. | | `setDockMenu(items)` | Set the dock context menu. Installs the method swizzle on first call. | | `clearDockMenu()` | Remove the dock context menu. | --- ### `DockMenuListener` Functional interface for menu item click events: ```kotlin MacOsDockMenu.listener = DockMenuListener { itemId -> when (itemId) { 1 -> openNewWindow() 2 -> openFile() } } ``` | Method | Description | |---|---| | `onItemClicked(itemId: Int)` | Called when the user clicks a dock menu item. Invoked on the Swing EDT. | --- ### `DockMenuItem` Data class representing a single item in the dock context menu. | Property | Type | Default | Description | |---|---|---|---| | `id` | `Int` | *(required)* | Unique numeric identifier (must be > 0). | | `title` | `String` | `""` | Display text. | | `enabled` | `Boolean` | `true` | Whether the item is clickable. | | `children` | `List` | `emptyList()` | Sub-menu items. Non-empty creates a submenu. | **macOS Dock menu limitations:** macOS Dock menus do not support images on menu items — the Dock process strips them. Only text, separators, submenus, and enabled/disabled state are rendered. #### Factory Methods ```kotlin // Separator DockMenuItem.separator(id = 3) ``` --- ## Full Example ```kotlin import io.github.kdroidfilter.nucleus.launcher.macos.* // Set up a dock menu with submenus and callbacks MacOsDockMenu.listener = DockMenuListener { itemId -> when (itemId) { 1 -> createNewWindow() 2 -> openFilePicker() 41 -> openRecentFile("project.kt") 42 -> openRecentFile("build.gradle.kts") 43 -> openRecentFile("README.md") 6 -> showPreferences() } } MacOsDockMenu.setDockMenu(listOf( DockMenuItem(id = 1, title = "New Window"), DockMenuItem(id = 2, title = "Open File"), DockMenuItem.separator(id = 3), DockMenuItem( id = 4, title = "Recent Files", children = listOf( DockMenuItem(id = 41, title = "project.kt"), DockMenuItem(id = 42, title = "build.gradle.kts"), DockMenuItem(id = 43, title = "README.md"), ), ), DockMenuItem.separator(id = 5), DockMenuItem(id = 6, title = "Preferences"), )) // Clean up on shutdown MacOsDockMenu.clearDockMenu() ``` --- ## Relation to `taskbar-progress` `taskbar-progress` provides cross-platform progress bar and attention request APIs. On macOS, it uses `NSDockTile` internally. Use `launcher-macos` when you need macOS-specific dock context menu functionality. The two modules are complementary — `taskbar-progress` handles the dock icon badge/progress, while `launcher-macos` handles the right-click menu. --- ## Native Library Ships pre-built macOS dylibs (arm64 + x86_64). `isAvailable` returns `false` on other platforms and all methods are no-op. - `libnucleus_launcher_macos.dylib` — linked against `Cocoa.framework` - Minimum deployment target: macOS 10.14 - Uses Objective-C method swizzling to intercept `applicationDockMenu:` on the existing `NSApplicationDelegate` - Menu construction and swizzle installation run on the main thread via `dispatch_sync` ## ProGuard ```proguard -keep class io.github.kdroidfilter.nucleus.launcher.macos.NativeMacOsDockMenuBridge { native ; static ** on*(...); } ``` ## GraalVM JNI reflection metadata must include the bridge class: ```json [ { "type": "io.github.kdroidfilter.nucleus.launcher.macos.NativeMacOsDockMenuBridge", "methods": [ { "name": "onMenuItemClicked", "parameterTypes": ["int"] } ] } ] ``` --- # Launcher (Windows) Windows Launcher API via JNI — badge notifications, jump lists, overlay icons, and thumbnail toolbar buttons for the app's taskbar button. ## Dependency ```kotlin dependencies { implementation("io.github.kdroidfilter:nucleus.launcher-windows:") } ``` ## Overview This module provides four Windows launcher APIs: - **Badge Notifications** — numeric counts (1–99+) or status glyph icons on the taskbar button and Start tile (APPX/MSIX only) - **Jump Lists** — custom categories and pinned tasks in the taskbar right-click menu (all packaging types) - **Overlay Icons** — small 16×16 status icons on the taskbar button (all packaging types) - **Thumbnail Toolbar** — up to 7 clickable buttons in the taskbar thumbnail preview (all packaging types) ### Packaging Compatibility | Feature | APPX/MSIX | NSIS / MSI / Distributable | |---------|-----------|----------------------------| | Badge Notifications | Yes | No (WinRT limitation) | | Jump Lists | Yes | Yes | | Overlay Icons | Yes | Yes | | Thumbnail Toolbar | Yes | Yes | --- ## Badge Notifications ### Quick Start ```kotlin import io.github.kdroidfilter.nucleus.launcher.windows.BadgeGlyph import io.github.kdroidfilter.nucleus.launcher.windows.WindowsBadgeManager // Initialize once at app startup if (WindowsBadgeManager.isAvailable) { WindowsBadgeManager.initialize() } // Set a numeric badge (e.g., unread message count) WindowsBadgeManager.setCount(5) // Set a glyph badge WindowsBadgeManager.setGlyph(BadgeGlyph.NEW_MESSAGE) // Clear the badge WindowsBadgeManager.clear() // Clean up on shutdown WindowsBadgeManager.uninitialize() ``` ### `WindowsBadgeManager` Thread-safe singleton providing the full badge API. Requires APPX/MSIX packaging. | Method | Description | |--------|-------------| | `isAvailable: Boolean` | Whether the native library is loaded on this platform | | `initialize(aumid: String? = null): Boolean` | Initialize the badge subsystem | | `uninitialize()` | Release native resources | | `setCount(count: Int): Boolean` | Set a numeric badge (0 clears, 1–99 shown as number, 100+ as "99+") | | `setGlyph(glyph: BadgeGlyph): Boolean` | Set a glyph badge icon | | `clear(): Boolean` | Remove the badge entirely | ### `BadgeGlyph` Predefined status glyph icons: | Glyph | Value | Description | |-------|-------|-------------| | `NONE` | `none` | Clears the badge | | `ACTIVITY` | `activity` | Activity indicator | | `ALARM` | `alarm` | Alarm status | | `ALERT` | `alert` | Alert / requires attention | | `ATTENTION` | `attention` | Attention required | | `AVAILABLE` | `available` | Presence: available | | `AWAY` | `away` | Presence: away | | `BUSY` | `busy` | Presence: busy | | `ERROR` | `error` | Error / failure | | `NEW_MESSAGE` | `newMessage` | New message received | | `PAUSED` | `paused` | Paused state | | `PLAYING` | `playing` | Playing / active | | `UNAVAILABLE` | `unavailable` | Presence: unavailable | --- ## Overlay Icons Small 16×16 status icons displayed over the app's taskbar button. Works with all packaging types. ### Quick Start ```kotlin import io.github.kdroidfilter.nucleus.launcher.windows.StockIcon import io.github.kdroidfilter.nucleus.launcher.windows.TaskbarIconSource import io.github.kdroidfilter.nucleus.launcher.windows.WindowsOverlayIcon // Set a stock icon overlay WindowsOverlayIcon.setIcon( window, TaskbarIconSource.FromStock(StockIcon.WARNING), description = "Warning", ) // Set an overlay from an .ico file WindowsOverlayIcon.setIcon( window, TaskbarIconSource.FromFile("C:\\path\\to\\icon.ico"), description = "Custom status", ) // Clear the overlay WindowsOverlayIcon.clearIcon(window) ``` ### `WindowsOverlayIcon` Thread-safe singleton. Requires a `java.awt.Window` reference. | Method | Description | |--------|-------------| | `isAvailable: Boolean` | Whether the native library is loaded | | `setIcon(window, icon, description): Boolean` | Set the overlay icon | | `clearIcon(window): Boolean` | Remove the overlay icon | | `lastError: String?` | Last error message, or null | --- ## Thumbnail Toolbar Up to 7 clickable buttons displayed in the taskbar thumbnail preview (visible when hovering the taskbar button). ### Quick Start ```kotlin import io.github.kdroidfilter.nucleus.launcher.windows.StockIcon import io.github.kdroidfilter.nucleus.launcher.windows.TaskbarIconSource import io.github.kdroidfilter.nucleus.launcher.windows.ThumbnailToolbarButton import io.github.kdroidfilter.nucleus.launcher.windows.WindowsThumbnailToolbar // Register buttons (once per window) WindowsThumbnailToolbar.setButtons( window, listOf( ThumbnailToolbarButton( id = 0, tooltip = "Previous", icon = TaskbarIconSource.FromStock(StockIcon.MEDIA_REWIND), ), ThumbnailToolbarButton( id = 1, tooltip = "Play/Pause", icon = TaskbarIconSource.FromStock(StockIcon.MEDIA_PLAY), ), ThumbnailToolbarButton( id = 2, tooltip = "Next", icon = TaskbarIconSource.FromStock(StockIcon.MEDIA_FORWARD), ), ), ) { buttonId -> println("Button clicked: $buttonId") } // Update button state (e.g., disable a button) WindowsThumbnailToolbar.updateButtons( window, listOf( ThumbnailToolbarButton(id = 1, tooltip = "Paused", enabled = false), ), ) // Unregister (hides buttons and removes callbacks) WindowsThumbnailToolbar.unregister(window) ``` ### `WindowsThumbnailToolbar` Thread-safe singleton. | Method | Description | |--------|-------------| | `isAvailable: Boolean` | Whether the native library is loaded | | `setButtons(window, buttons, onClick): Boolean` | Register toolbar buttons (can be called again after `unregister`) | | `updateButtons(window, buttons): Boolean` | Update the state of registered buttons | | `unregister(window): Boolean` | Hide buttons and remove click callbacks | | `lastError: String?` | Last error message, or null | ### `ThumbnailToolbarButton` | Property | Type | Default | Description | |----------|------|---------|-------------| | `id` | `Int` | — | Unique button ID (0–6) | | `tooltip` | `String` | `""` | Tooltip text | | `icon` | `TaskbarIconSource?` | `null` | Button icon | | `enabled` | `Boolean` | `true` | Whether the button is clickable | | `hidden` | `Boolean` | `false` | Whether the button is hidden | | `noBackground` | `Boolean` | `false` | Draw icon without button border | | `dismissOnClick` | `Boolean` | `false` | Close thumbnail preview on click | | `nonInteractive` | `Boolean` | `false` | Display-only (no click events) | ### Lifecycle Notes - Windows only allows adding buttons **once** per window. Calling `setButtons` again after `unregister` internally uses `ThumbBarUpdateButtons` to re-show them. - `unregister` **hides** buttons (Windows does not allow removing them). The buttons can be re-shown by calling `setButtons` again. - Click events are delivered on the AWT Event Dispatch Thread. --- ## Jump Lists Jump lists appear when the user right-clicks the app's taskbar button. They support custom categories, shell-managed categories (Recent/Frequent), and pinned user tasks. ### Quick Start ```kotlin import io.github.kdroidfilter.nucleus.launcher.windows.JumpListCategory import io.github.kdroidfilter.nucleus.launcher.windows.JumpListItem import io.github.kdroidfilter.nucleus.launcher.windows.StockIcon import io.github.kdroidfilter.nucleus.launcher.windows.TaskbarIconSource import io.github.kdroidfilter.nucleus.launcher.windows.WindowsJumpListManager // Set a jump list with categories and tasks WindowsJumpListManager.setJumpList( categories = listOf( JumpListCategory( name = "Recent Projects", items = listOf( JumpListItem( title = "Project A", arguments = "myapp://open?project=a", icon = TaskbarIconSource.FromStock(StockIcon.FOLDER), ), JumpListItem( title = "Project B", arguments = "myapp://open?project=b", icon = TaskbarIconSource.FromStock(StockIcon.FOLDER), ), ), ), ), tasks = listOf( JumpListItem( title = "New Window", arguments = "myapp://new-window", icon = TaskbarIconSource.FromStock(StockIcon.DESKTOP_PC), ), JumpListItem.SEPARATOR, JumpListItem( title = "Settings", arguments = "myapp://settings", icon = TaskbarIconSource.FromStock(StockIcon.SETTINGS), ), ), ) // Clear the jump list WindowsJumpListManager.clearJumpList() ``` ### `WindowsJumpListManager` Thread-safe singleton. Works for both APPX/MSIX and unpackaged apps. | Method | Description | |--------|-------------| | `isAvailable: Boolean` | Whether the native library is loaded | | `setProcessAppId(aumid: String? = null): Boolean` | Set the AUMID (call before any window is created, unpackaged only) | | `setJumpList(categories, tasks, knownCategories): Boolean` | Set the entire jump list atomically | | `clearJumpList(): Boolean` | Remove all jump list entries | | `lastError: String?` | Last error message, or null | ### `JumpListItem` A single clickable item in a jump list. | Property | Type | Default | Description | |----------|------|---------|-------------| | `title` | `String` | `""` | Display text | | `arguments` | `String` | `""` | Command-line args passed when clicked | | `description` | `String` | `""` | Tooltip text | | `icon` | `TaskbarIconSource?` | `null` | Item icon (null = app icon) | | `isSeparator` | `Boolean` | `false` | Whether this is a separator (tasks only) | Use `JumpListItem.SEPARATOR` for visual separators in the tasks section. ### `JumpListCategory` A named group of items. | Property | Type | Description | |----------|------|-------------| | `name` | `String` | Category display name (must be unique) | | `items` | `List` | Items in this category | ### `KnownCategory` Shell-managed categories populated automatically by Windows. | Value | Description | |-------|-------------| | `FREQUENT` | Frequently used destinations | | `RECENT` | Recently used destinations | ### How Click Handling Works Unlike macOS (which uses in-process `NSMenu` delegates) or Linux (which uses D-Bus callbacks), Windows jump lists have **no in-process callback mechanism**. When a user clicks a jump list item, Windows **launches a new process** of your application with the item's `arguments` appended to the command line. To handle this in a running application, you need two pieces from `core-runtime`: 1. **`SingleInstanceManager`** — detects that a second instance was launched and forwards data to the primary instance via a file-based IPC mechanism. 2. **`DeepLinkHandler`** — parses URI-style arguments from the command line and provides a callback when a deep link is received. #### Setup in `main()` ```kotlin fun main(args: Array) { // 1. Set the AUMID before any window is created (unpackaged apps only) WindowsJumpListManager.setProcessAppId() // 2. Register the deep link handler — parses any URI from CLI args DeepLinkHandler.register(args) { uri -> println("Received deep link: $uri") // Handle the URI (navigate, open file, etc.) } application { // 3. Enforce single instance with argument forwarding val isFirstInstance = remember { SingleInstanceManager.isSingleInstance( onRestoreFileCreated = { // Secondary instance: write the received URI to the IPC file DeepLinkHandler.writeUriTo(this) }, onRestoreRequest = { // Primary instance: read the URI from the IPC file DeepLinkHandler.readUriFrom(this) // Bring window to front isWindowVisible = true }, ) } // 4. If this is a secondary instance, exit immediately if (!isFirstInstance) { exitApplication() return@application } // ... create your window } } ``` #### Complete Flow ``` User clicks jump list item ("Open Dashboard", arguments = "myapp://dashboard") | v Windows launches: myapp.exe myapp://dashboard | v main(args = ["myapp://dashboard"]) starts | +-> DeepLinkHandler.register(args) parses "myapp://dashboard" as a URI | v SingleInstanceManager.isSingleInstance() tries to acquire lock | +- Lock FAILS (primary instance holds it) | +-> onRestoreFileCreated: writes URI to .restore_request file | +-> Secondary instance exits | v Primary instance's WatchService detects .restore_request file | +-> onRestoreRequest: DeepLinkHandler.readUriFrom(path) | +-> onDeepLink callback fires with URI("myapp://dashboard") | +-> Window brought to front, URI handled ``` > **Note:** Jump list items require a real application executable. They do **not** work > in Gradle `run` dev mode (where the process is `java.exe`). Test with `runDistributable` > or a packaged build (APPX, NSIS, MSI). --- ## Icons All APIs that accept icons use the unified `TaskbarIconSource` sealed class. ### `TaskbarIconSource` | Variant | Description | |---------|-------------| | `FromStock(stockIcon: StockIcon)` | Windows Shell stock icon (available on all Vista+ systems, no files needed) | | `FromFile(path: String)` | Load from an `.ico` file on disk | | `FromResource(dllPath: String, index: Int)` | Extract from a resource DLL (e.g., `shell32.dll`) | ### `StockIcon` Type-safe enum mapping all 94 Windows Shell Stock Icons (`SHSTOCKICONID`). Commonly used values: | Icon | Description | |------|-------------| | `StockIcon.WARNING` | Yellow warning triangle | | `StockIcon.ERROR` | Red error circle | | `StockIcon.INFO` | Blue information circle | | `StockIcon.SHIELD` | UAC shield | | `StockIcon.HELP` | Blue question mark | | `StockIcon.LOCK` | Padlock | | `StockIcon.KEY` | Key | | `StockIcon.FIND` | Magnifying glass | | `StockIcon.SETTINGS` | Gear/settings | | `StockIcon.WORLD` | Globe | | `StockIcon.USERS` | Multiple users | | `StockIcon.FOLDER` | Closed folder | | `StockIcon.DESKTOP_PC` | Desktop computer | | `StockIcon.MEDIA_PLAY` | Media play button | | `StockIcon.MEDIA_REWIND` | Media rewind | | `StockIcon.MEDIA_FORWARD` | Media fast-forward | See the `StockIcon` enum for the full list of 94 icons covering documents, folders, drives, network, media, devices, and status categories. --- ## AUMID Resolution The AUMID (Application User Model ID) identifies your app to Windows: | Packaging | Badge | Jump List | Overlay / Toolbar | |-----------|-------|-----------|-------------------| | **APPX/MSIX** | Automatic (package identity) | Automatic (package identity) | No AUMID needed | | **Unpackaged** | Not supported | Auto-derived from `NucleusApp.appId` | No AUMID needed | ## Platform Notes - Badge notifications require APPX/MSIX packaging (WinRT limitation). - Jump lists, overlay icons, and thumbnail toolbar work for all packaging types. - All operations are synchronous. - `isAvailable` returns `false` on non-Windows platforms. - Thumbnail toolbar buttons persist for the lifetime of the window. `unregister` hides them; `setButtons` re-shows them. ## ProGuard When ProGuard is enabled, the Nucleus Gradle plugin automatically includes the required keep rules. No manual configuration is needed. ## GraalVM Native Image The module ships with `reachability-metadata.json` for GraalVM native-image compatibility. No additional configuration is required. --- # Launcher (Linux) Complete Kotlin mapping of the [Unity Launcher API](https://wiki.ubuntu.com/Unity/LauncherAPI) and [com.canonical.dbusmenu](https://github.com/AyatanaIndicators/libdbusmenu) interface via JNI. Control your launcher icon's badge count, progress bar, urgency, update status, and dynamic right-click quicklist menus on Linux. **Pure D-Bus via GIO:** Uses GLib/GIO (`libgio-2.0`) for D-Bus communication — no JNA, no reflection, no Java D-Bus libraries. ## Installation ```kotlin dependencies { implementation("io.github.kdroidfilter:nucleus.launcher-linux:") } ``` Depends on `core-runtime` (compile-only) for `NativeLibraryLoader` and `freedesktop-icons` (transitive) for typesafe icon names. ## Quick Start ### Launcher Entry (Badge, Progress, Urgency) ```kotlin import io.github.kdroidfilter.nucleus.launcher.linux.* val appUri = LinuxLauncherEntry.appUri("myapp.desktop") // Badge count LinuxLauncherEntry.setCount(appUri, 42) LinuxLauncherEntry.clearCount(appUri) // Progress bar LinuxLauncherEntry.setProgress(appUri, 0.65) LinuxLauncherEntry.clearProgress(appUri) // Urgency (flash the icon) LinuxLauncherEntry.setUrgent(appUri, true) // Bulk update LinuxLauncherEntry.update(appUri, LauncherProperties( count = 5, countVisible = true, progress = 0.8, progressVisible = true, urgent = false, )) ``` ### Dynamic Quicklist (Right-Click Menu) ```kotlin import io.github.kdroidfilter.nucleus.freedesktop.icons.FreedesktopIcon import io.github.kdroidfilter.nucleus.launcher.linux.* val quicklist = LinuxQuicklist("/com/example/MyApp/Menu") // Handle click events quicklist.listener = LinuxQuicklist.Listener { id -> when (id) { 1 -> openNewWindow() 2 -> openFile() 8 -> exitApp() } } // Set the menu quicklist.setMenu(listOf( DbusmenuItem(id = 1, label = "New Window", icon = FreedesktopIcon.Action.WINDOW_NEW), DbusmenuItem(id = 2, label = "Open File", icon = FreedesktopIcon.Action.DOCUMENT_OPEN), DbusmenuItem.separator(id = 3), DbusmenuItem(id = 4, label = "Recent", icon = FreedesktopIcon.Action.DOCUMENT_OPEN_RECENT, children = listOf( DbusmenuItem(id = 41, label = "project.kt"), DbusmenuItem(id = 42, label = "build.gradle.kts"), ), ), DbusmenuItem.separator(id = 5), DbusmenuItem(id = 8, label = "Quit", icon = FreedesktopIcon.Action.APPLICATION_EXIT, disposition = DbusmenuItem.Disposition.ALERT), )) // Attach quicklist to the launcher entry LinuxLauncherEntry.update(appUri, LauncherProperties(quicklist = quicklist.objectPath)) // On shutdown quicklist.dispose() ``` --- ## API Reference ### `LinuxLauncherEntry` Singleton entry point for the `com.canonical.Unity.LauncherEntry` D-Bus interface. All methods are thread-safe. | Property / Method | Returns | Description | |---|---|---| | `isAvailable` | `Boolean` | `true` if the native library is loaded (Linux only). | | `appUri(desktopFileId)` | `String` | Builds an `application://` URI from a `.desktop` file ID. | | `update(appUri, properties)` | `Boolean` | Emits an `Update` signal with the given properties. | | `registerQueryHandler(appUri)` | `Boolean` | Registers a D-Bus object to handle `Query` method calls (for debugging). | | `unregister()` | `Unit` | Unregisters the `Query` handler and releases D-Bus resources. | #### Convenience Methods | Method | Description | |---|---| | `setCount(appUri, count, visible = true)` | Set the badge count. | | `clearCount(appUri)` | Clear the badge count. | | `setProgress(appUri, progress, visible = true)` | Set the progress bar (0.0–1.0). | | `clearProgress(appUri)` | Clear the progress bar. | | `setUrgent(appUri, urgent)` | Set or clear the urgency flag. | | `setUpdating(appUri, updating)` | Set or clear the updating flag. | --- ### `LauncherProperties` Data class representing the properties sent in an `Update` signal. All properties are nullable — only non-null values are included in the D-Bus signal. | Property | Type | Description | |---|---|---| | `count` | `Long?` | Badge count displayed on the launcher icon. | | `countVisible` | `Boolean?` | Whether the count badge is visible. | | `progress` | `Double?` | Progress bar value (0.0–1.0). | | `progressVisible` | `Boolean?` | Whether the progress bar is visible. | | `urgent` | `Boolean?` | Whether the entry requests user attention. | | `quicklist` | `String?` | D-Bus object path to a `com.canonical.dbusmenu` server, or empty string to unset. | | `updating` | `Boolean?` | Whether the application is being updated. | --- ### `LinuxQuicklist` Dynamic quicklist server implementing `com.canonical.dbusmenu` over D-Bus. Desktop environments query this object to show a right-click context menu on the launcher icon. ```kotlin val quicklist = LinuxQuicklist("/com/example/MyApp/Menu") ``` | Property / Method | Description | |---|---| | `objectPath: String` | The D-Bus object path for this menu server. | | `listener: Listener?` | Callback invoked when a menu item is clicked. | | `setMenu(items)` | Set the full menu layout. Replaces any previous layout. Returns `true` on success. | | `dispose()` | Unregister the D-Bus object and release native resources. | **Thread safety:** Click callbacks are dispatched to the Swing EDT via `SwingUtilities.invokeLater`. You can safely update Compose state directly. #### `LinuxQuicklist.Listener` Functional interface for menu item click events: ```kotlin quicklist.listener = LinuxQuicklist.Listener { itemId -> println("Clicked item: $itemId") } ``` --- ### `DbusmenuItem` Data class representing a single menu item in a quicklist. | Property | Type | Default | Description | |---|---|---|---| | `id` | `Int` | *(required)* | Unique numeric identifier (must be > 0, 0 is reserved for root). | | `label` | `String` | `""` | Display text. Supports mnemonics with `_` prefix (e.g. `"_Open"`). | | `icon` | `FreedesktopIcon?` | `null` | Typesafe icon from the [freedesktop Icon Naming Spec](freedesktop-icons.md). | | `enabled` | `Boolean` | `true` | Whether the item is clickable. | | `visible` | `Boolean` | `true` | Whether the item is shown. | | `type` | `ItemType` | `STANDARD` | `STANDARD` or `SEPARATOR`. | | `toggleType` | `ToggleType` | `NONE` | `NONE`, `CHECKBOX`, or `RADIO`. | | `toggleState` | `Int` | `-1` | `-1` = indeterminate, `0` = unchecked, `1` = checked. | | `shortcut` | `List` | `emptyList()` | Keyboard shortcut descriptors (e.g. `listOf("Control", "S")`). | | `disposition` | `Disposition` | `NORMAL` | Visual disposition: `NORMAL`, `INFORMATIONAL`, `WARNING`, or `ALERT`. | | `children` | `List` | `emptyList()` | Sub-menu items. Non-empty creates a submenu. | #### Factory Methods ```kotlin // Separator DbusmenuItem.separator(id = 3) ``` #### Checkbox / Radio Toggle ```kotlin // Checkbox item DbusmenuItem( id = 6, label = "Dark Mode", toggleType = DbusmenuItem.ToggleType.CHECKBOX, toggleState = if (darkMode) 1 else 0, ) ``` **Updating toggle state:** The dbusmenu protocol is stateless from the server side — the DE always queries `GetLayout` for the current state. To update a toggle, call `quicklist.setMenu(...)` again with the updated `toggleState`. The DE picks up the change on the next `GetLayout` query. --- ## Desktop Environment Support The `com.canonical.Unity.LauncherEntry` D-Bus interface is supported by: | Desktop / Dock | Badge | Progress | Urgent | Quicklist | |---|---|---|---|---| | GNOME (Ubuntu Dock / Dash to Dock) | Yes | Yes | Yes | Yes | | KDE Plasma | Yes | Yes | Yes | Yes | | Plank | Yes | Yes | Yes | Yes | | budgie-panel | Yes | Yes | Yes | — | | XFCE (with docklike-plugin) | — | — | — | — | **Quicklist click events:** GNOME Shell sends click events via `EventGroup` (batch), while KDE and Plank use `Event` (single). Both are handled transparently. --- ## Relation to `taskbar-progress` `taskbar-progress` provides a cross-platform abstraction (Windows, macOS, Linux) for progress bars and attention requests. On Linux, it delegates to `launcher-linux` internally. Use `launcher-linux` directly when you need Linux-specific features like badge counts, quicklist menus, or the updating flag. Use `taskbar-progress` for cross-platform progress bar needs. --- ## Native Library Ships pre-built Linux shared libraries (x86_64 + aarch64). `isAvailable` returns `false` on other platforms. - `libnucleus_launcher_linux.so` — linked against `libgio-2.0` (GLib/GIO) - Build requirement: `libglib2.0-dev` (Debian/Ubuntu) or `glib2-devel` (Fedora) - Each quicklist runs its own D-Bus server in a dedicated thread with its own `GMainLoop` ## ProGuard ```proguard -keep class io.github.kdroidfilter.nucleus.launcher.linux.NativeLinuxLauncherBridge { native ; static ** on*(...); } ``` ## GraalVM JNI reflection metadata must include the bridge class: ```json [ { "type": "io.github.kdroidfilter.nucleus.launcher.linux.NativeLinuxLauncherBridge", "methods": [ { "name": "onMenuItemEvent", "parameterTypes": ["java.lang.String", "int"] } ] } ] ``` --- # Dark Mode Detector Compose for Desktop ships `isSystemInDarkTheme()` in its Foundation library, but that function only reads the theme **once** — it is not reactive. If the user toggles dark mode in the OS settings, the value will not update and the UI will stay stale until the next restart. The `darkmode-detector` module solves this by providing a **reactive** `isSystemInDarkMode()` composable that uses native JNI bridges (no JNA) on each platform. It registers an OS-level listener that triggers recomposition the instant the system theme changes, giving your app real-time light/dark switching with no polling and no restart required. ## Installation ```kotlin dependencies { implementation("io.github.kdroidfilter:nucleus.darkmode-detector:") } ``` ## Usage ```kotlin @Composable fun App() { val isDark = isSystemInDarkMode() val colorScheme = if (isDark) darkColorScheme() else lightColorScheme() MaterialTheme(colorScheme = colorScheme) { // UI automatically recomposes when the OS theme changes } } ``` `isSystemInDarkMode()` is a `@Composable` function that: 1. Reads the current system dark mode preference 2. Registers a native listener that fires when the user changes their OS theme 3. Triggers recomposition when the theme changes 4. Cleans up the listener when the composable leaves the composition ## Platform Detection Methods | Platform | Method | Reactive | |----------|--------|----------| | **macOS** | `NSDistributedNotificationCenter` observer on `AppleInterfaceThemeChangedNotification`, reads `AppleInterfaceStyle` from `NSUserDefaults` | Yes — native callback via JNI | | **Windows** | Reads `HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize\AppsUseLightTheme` registry key. Value `0` = dark, `1` = light | Yes — `RegNotifyChangeKeyValue` on background thread | | **Linux** | XDG Desktop Portal `org.freedesktop.portal.Settings` D-Bus interface. `color-scheme = 1` means prefer-dark | Yes — listens for `SettingChanged` D-Bus signals | All three platforms use **JNI native libraries** (Objective-C on macOS, C on Windows/Linux) bundled inside the JAR. The library is extracted and loaded at runtime automatically. ## Native Libraries The module ships pre-built native binaries for: - macOS: `libnucleus_darkmode.dylib` (arm64 + x64) - Windows: `nucleus_windows_theme.dll` (x64 + ARM64) - Linux: `libnucleus_linux_theme.so` (x64 + aarch64) No external dependencies are needed at runtime. ## ProGuard The `darkmode-detector` module uses JNI native libraries on all platforms. When ProGuard is enabled, the native bridge classes must be preserved so that JNI callbacks from native code work correctly. The Nucleus Gradle plugin includes these rules automatically, but if you need to add them manually: ```proguard # macOS — NativeDarkModeBridge is looked up by name from native code -keep class io.github.kdroidfilter.nucleus.darkmodedetector.mac.NativeDarkModeBridge { native ; static void onThemeChanged(boolean); } # Linux — NativeLinuxBridge is looked up by name from native code -keep class io.github.kdroidfilter.nucleus.darkmodedetector.linux.NativeLinuxBridge { native ; static void onThemeChanged(boolean); } # Windows -keep class io.github.kdroidfilter.nucleus.darkmodedetector.windows.NativeWindowsBridge { native ; } -keep class io.github.kdroidfilter.nucleus.darkmodedetector.** { *; } ``` ## Compose Preview In Compose preview mode (`LocalInspectionMode`), the function falls back to the standard `isSystemInDarkTheme()` from Compose Foundation, which reads the JVM look-and-feel setting. ## Logging Debug and error messages are logged under the tags `MacOSThemeDetector`, `WindowsThemeDetector`, and `LinuxPortalThemeDetector`. Logging is off by default. To enable it, set the global flag from `core-runtime`: ```kotlin import io.github.kdroidfilter.nucleus.core.runtime.tools.allowNucleusRuntimeLogging allowNucleusRuntimeLogging = true ``` --- # System Color The `system-color` module provides **reactive** detection of the OS accent color and high contrast mode. It uses native JNI bridges on each platform to register OS-level listeners that trigger recomposition the instant a setting changes — no polling, no restart required. ## Installation ```kotlin dependencies { implementation("io.github.kdroidfilter:nucleus.system-color:") } ``` ## Usage ### Accent Color ```kotlin @Composable fun App() { val accentColor = systemAccentColor() MaterialTheme( colorScheme = if (accentColor != null) { // Build a dynamic color scheme from the system accent darkColorScheme(primary = accentColor) } else { // null = unsupported platform or macOS multicolor mode darkColorScheme() } ) { // UI automatically recomposes when the accent color changes } } ``` **macOS Multicolor Mode:** On macOS, when the user selects **Multicolor** in System Settings → Appearance → Accent color, each app is expected to use its own default color. In this case, `systemAccentColor()` returns `null` so your app can fall back to its own brand color or a default palette. ### Check Support Use `isSystemAccentColorSupported()` to check platform support before entering a composable context — useful for feature gating or conditional UI: ```kotlin fun main() = application { if (isSystemAccentColorSupported()) { // Platform supports accent color — safe to use systemAccentColor() } } ``` ### High Contrast ```kotlin @Composable fun App() { val highContrast = isSystemInHighContrast() if (highContrast) { // Use high contrast colors / larger borders } } ``` ## Material You on Desktop Android's [Material You](https://m3.material.io/styles/color/dynamic/choosing-a-source) dynamic theming derives an entire color scheme from a single seed color — the system accent color. On desktop, this is not supported out of the box, but you can reproduce the same effect by combining `systemAccentColor()` with the [material-kolor](https://github.com/jordond/MaterialKolor) library. ```kotlin dependencies { implementation("io.github.kdroidfilter:nucleus.system-color:") implementation("com.materialkolor:material-kolor:4.1.1") } ``` ```kotlin @Composable fun App() { val accentColor = systemAccentColor() val seedColor = accentColor ?: Color(0xFF6750A4) // Material default purple DynamicMaterialTheme( seedColor = seedColor, isDark = isSystemInDarkTheme(), animate = true, style = PaletteStyle.TonalSpot, ) { // Your app content — the entire color scheme adapts // to the OS accent color in real time } } ``` When the user changes their accent color in system settings, `systemAccentColor()` triggers a recomposition and `DynamicMaterialTheme` smoothly animates the entire palette to the new color. The Nucleus example app uses this exact approach — you can test it by running: ```bash ./gradlew :example:run ``` ## API Reference | Function | Returns | Description | |----------|---------|-------------| | `systemAccentColor()` | `Color?` | Composable. Returns the current system accent color, or `null` if unsupported or if the OS is in multicolor mode (macOS). Recomposes on change. | | `isSystemInHighContrast()` | `Boolean` | Composable. Returns `true` if the OS is in high contrast / increased contrast mode. Recomposes on change. | | `isSystemAccentColorSupported()` | `Boolean` | Non-composable. Returns whether the current platform supports accent color detection. | ## Platform Detection Methods | Platform | Accent Color | High Contrast | Reactive | |----------|-------------|---------------|----------| | **macOS** | `NSColor.controlAccentColor` (macOS 10.14+). Returns `null` in **multicolor mode** (detected via `AppleAccentColor` user default). | `accessibilityDisplayShouldIncreaseContrast` | Yes — `NSSystemColorsDidChangeNotification` / `NSWorkspaceAccessibilityDisplayOptionsDidChangeNotification` | | **Windows** | `HKCU\SOFTWARE\Microsoft\Windows\DWM\AccentColor` registry key (AABBGGRR) | `SystemParametersInfoW(SPI_GETHIGHCONTRAST)` | Yes — `RegNotifyChangeKeyValue` on background thread | | **Linux** | XDG Desktop Portal `org.freedesktop.appearance` / `accent-color` — RGB tuple (0.0–1.0) | XDG Desktop Portal `org.freedesktop.appearance` / `contrast` — uint32 (1 = high) | Yes — D-Bus `SettingChanged` signal listener | All three platforms use **JNI native libraries** bundled inside the JAR. The library is extracted and loaded at runtime automatically. ## Native Libraries The module ships pre-built native binaries for: - macOS: `libnucleus_systemcolor.dylib` (arm64 + x64) - Windows: `nucleus_systemcolor.dll` (x64 + ARM64) - Linux: `libnucleus_systemcolor.so` (x64 + aarch64) On Linux, `libdbus-1` must be present at runtime (installed by default on all major desktop distributions). ## Linux Desktop Environment Support The Linux implementation uses the [XDG Desktop Portal](https://flatpak.github.io/xdg-desktop-portal/) D-Bus interface, which provides a desktop-agnostic abstraction: - **GNOME 47+** — Full accent color and contrast support - **KDE Plasma 6+** — Accent color support - **elementary OS** — Accent color support - Other DEs — Works if the desktop portal implements `org.freedesktop.appearance` If the portal is not available or the setting is not configured, `systemAccentColor()` returns `null` and `isSystemInHighContrast()` returns `false`. ## ProGuard When ProGuard is enabled, the native bridge classes must be preserved. The Nucleus Gradle plugin includes these rules automatically, but if you need to add them manually: ```proguard # macOS -keep class io.github.kdroidfilter.nucleus.systemcolor.mac.NativeMacSystemColorBridge { native ; static void onAccentColorChanged(float, float, float); static void onAccentColorCleared(); static void onContrastChanged(boolean); } # Windows -keep class io.github.kdroidfilter.nucleus.systemcolor.windows.NativeWindowsSystemColorBridge { native ; static void onAccentColorChanged(int, int, int); static void onHighContrastChanged(boolean); } # Linux -keep class io.github.kdroidfilter.nucleus.systemcolor.linux.NativeLinuxSystemColorBridge { native ; static void onAccentColorChanged(float, float, float); static void onHighContrastChanged(boolean); } -keep class io.github.kdroidfilter.nucleus.systemcolor.** { *; } ``` ## Logging Debug messages are logged under the tags `MacSystemColorDetector`, `WindowsSystemColor`, and `LinuxSystemColorDetector`. Logging is off by default. To enable it: ```kotlin import io.github.kdroidfilter.nucleus.core.runtime.tools.allowNucleusRuntimeLogging allowNucleusRuntimeLogging = true ``` --- # System Info The `system-info` module provides cross-platform system information gathering for JVM desktop applications. It exposes a unified API covering CPU, memory, disks, network, temperature sensors, GPU, processes, users, and hardware identifiers — all via JNI native bridges (no JNA) on each platform. ## Installation ```kotlin dependencies { implementation("io.github.kdroidfilter:nucleus.system-info:") } ``` ## Quick Start ```kotlin import io.github.kdroidfilter.nucleus.systeminfo.SystemInfo fun main() { val os = SystemInfo.osInfo() println("OS: ${os?.longOsVersion} (${os?.cpuArch})") val mem = SystemInfo.memoryInfo() println("Memory: ${mem?.usedMemory?.div(1024 * 1024)}MB / ${mem?.totalMemory?.div(1024 * 1024)}MB") val cpu = SystemInfo.cpuInfo() println("CPU: ${cpu?.cpus?.firstOrNull()?.brand}, ${cpu?.cpus?.size} threads") SystemInfo.gpus().forEach { gpu -> println("GPU: ${gpu.name} — ${gpu.dedicatedVideoMemory / 1024 / 1024}MB VRAM") println(" Temp=${gpu.temperature?.let { "${it.toInt()}C" } ?: "N/A"}, Usage=${gpu.gpuUsage?.let { "${it.toInt()}%" } ?: "N/A"}") } } ``` ## Platform Support | Subsystem | Windows | macOS | Linux | |-----------|---------|-------|-------| | OS info (name, version, hostname, uptime) | DXGI / WMI | sysctl / NSProcessInfo | `/proc`, `/etc/os-release` | | Memory (total, free, available, swap) | `GlobalMemoryStatusEx` | sysctl `hw.memsize` | `/proc/meminfo` | | CPU (per-core usage, frequency, brand) | `GetSystemTimes` / registry | `host_processor_info` | `/proc/stat`, `/proc/cpuinfo`, `/sys/devices/system/cpu/*/cpufreq` | | Disks (mount, space, filesystem, type) | `GetDiskFreeSpaceEx` | `statvfs` | `/proc/mounts`, `statvfs`, `/sys/block/*/queue/rotational` | | Temperature sensors | WMI thermal zones | IOKit `AppleSMC` | `/sys/class/hwmon`, `/sys/class/thermal` | | GPU (name, VRAM, usage, temp, clocks, fan, power) | DXGI + NVML | IOKit Metal | DRM sysfs + NVML (dlopen) | | Network interfaces (bytes, packets, errors, MAC, MTU) | `GetIfTable2` / `GetAdaptersAddresses` | `getifaddrs` + sysctl | `/sys/class/net/*/statistics` | | Processes (PID, name, memory, CPU, status, cmdline) | `NtQuerySystemInformation` | `proc_listallpids` / `proc_pidinfo` | `/proc/[pid]/stat`, `/proc/[pid]/status` | | Users | `NetUserEnum` | `getpwent` | `/etc/passwd` | | Motherboard | WMI `Win32_BaseBoard` | IOKit `IOPlatformExpertDevice` | `/sys/devices/virtual/dmi/id/board_*` | | Product | WMI `Win32_ComputerSystemProduct` | IOKit | `/sys/devices/virtual/dmi/id/product_*` | ## API Reference All methods are on the `SystemInfo` singleton object. ### Availability | Function | Returns | Description | |----------|---------|-------------| | `isAvailable()` | `Boolean` | `true` if the native library loaded successfully on this platform. | ### OS Info ```kotlin val os: OsInfo? = SystemInfo.osInfo() ``` | Field | Type | Description | |-------|------|-------------| | `name` | `String?` | OS name (e.g. "Ubuntu", "Windows 11") | | `kernelVersion` | `String?` | Kernel version string | | `osVersion` | `String?` | Short OS version (e.g. "24.04") | | `longOsVersion` | `String?` | Full OS version string | | `distributionId` | `String?` | Linux distribution ID (e.g. "ubuntu") | | `hostName` | `String?` | Machine hostname | | `cpuArch` | `String?` | CPU architecture (e.g. "x86_64", "aarch64") | | `uptime` | `Long` | System uptime in seconds | | `bootTime` | `Long` | Boot time as Unix epoch (seconds) | ### Memory Info ```kotlin val mem: MemoryInfo? = SystemInfo.memoryInfo() ``` | Field | Type | Description | |-------|------|-------------| | `totalMemory` | `Long` | Total physical RAM (bytes) | | `freeMemory` | `Long` | Free physical RAM (bytes) | | `availableMemory` | `Long` | Available memory including caches (bytes) | | `usedMemory` | `Long` | Used memory = total - available (bytes) | | `totalSwap` | `Long` | Total swap space (bytes) | | `freeSwap` | `Long` | Free swap space (bytes) | | `usedSwap` | `Long` | Used swap = total - free (bytes) | ### CPU Info ```kotlin val cpu: CpuGlobalInfo? = SystemInfo.cpuInfo() ``` | Field | Type | Description | |-------|------|-------------| | `globalCpuUsage` | `Float` | Aggregate CPU usage across all cores (0–100%) | | `physicalCoreCount` | `Int?` | Number of physical cores (`null` if unknown) | | `cpus` | `List` | Per-logical-CPU info | Each `CpuInfo`: | Field | Type | Description | |-------|------|-------------| | `name` | `String` | Logical CPU name (e.g. "cpu0") | | `vendorId` | `String` | Vendor ID (e.g. "GenuineIntel") | | `brand` | `String` | Brand string (e.g. "Intel Core i9-14900K") | | `frequency` | `Long` | Current frequency in MHz | | `cpuUsage` | `Float` | Per-core usage (0–100%) | ### Disks ```kotlin val disks: List = SystemInfo.disks() ``` | Field | Type | Description | |-------|------|-------------| | `name` | `String` | Device name | | `fileSystem` | `String` | Filesystem type (e.g. "ext4", "NTFS") | | `mountPoint` | `String` | Mount point path | | `totalSpace` | `Long` | Total space (bytes) | | `availableSpace` | `Long` | Available space (bytes) | | `kind` | `String` | Disk type: "SSD", "HDD", or "Unknown" | | `isRemovable` | `Boolean` | Whether the disk is removable (USB) | | `isReadOnly` | `Boolean` | Whether the disk is mounted read-only | ### Temperature Sensors (Components) ```kotlin val sensors: List = SystemInfo.components() ``` | Field | Type | Description | |-------|------|-------------| | `label` | `String` | Sensor label (e.g. "coretemp Package id 0") | | `temperature` | `Float?` | Current temperature in Celsius | | `max` | `Float?` | Historical max temperature | | `critical` | `Float?` | Critical temperature threshold | ### GPU ```kotlin val gpus: List = SystemInfo.gpus() ``` | Field | Type | Description | |-------|------|-------------| | `name` | `String` | GPU device name | | `vendorId` | `Long` | PCI vendor ID (e.g. `0x10DE` for NVIDIA) | | `deviceId` | `Long` | PCI device ID | | `dedicatedVideoMemory` | `Long` | Dedicated VRAM (bytes) | | `dedicatedSystemMemory` | `Long` | Dedicated system memory (bytes) | | `sharedSystemMemory` | `Long` | Shared system memory / GTT (bytes) | | `driverVersion` | `String?` | Driver version string | | `temperature` | `Float?` | GPU temperature (Celsius) | | `gpuUsage` | `Float?` | GPU utilization (0–100%) | | `memoryUsed` | `Long?` | VRAM currently used (bytes) | | `coreClockMhz` | `Int?` | Current core clock (MHz) | | `memoryClockMhz` | `Int?` | Current memory clock (MHz) | | `fanSpeedPercent` | `Float?` | Fan speed (0–100%) | | `powerDrawWatts` | `Float?` | Current power draw (watts) | #### GPU Backend Details | Platform | Backend | Static Info | Live Metrics | |----------|---------|-------------|--------------| | **Windows** | DXGI enumeration + NVML (dlopen) | Name, VRAM, vendor/device IDs via DXGI | Temperature, usage, clocks, fan, power via NVML | | **macOS** | IOKit + Metal | Name, VRAM, vendor/device IDs via IOKit | Temperature via SMC (when available) | | **Linux — NVIDIA** | DRM enumeration + NVML (`libnvidia-ml.so.1`, dlopen at runtime) | Name, VRAM, driver version via NVML | Temperature, usage, VRAM used, clocks, fan, power via NVML | | **Linux — AMD** | DRM enumeration + amdgpu sysfs | Name, VRAM (`mem_info_vram_total`), GTT (`mem_info_gtt_total`) | Temperature (`hwmon/temp1_input`), usage (`gpu_busy_percent`), VRAM used (`mem_info_vram_used`), clocks (`pp_dpm_sclk/mclk`), fan (`pwm1`), power (`power1_average`) | | **Linux — Intel** | DRM enumeration + i915/xe sysfs | Name, local memory (`lmem_total_bytes` for discrete) | Temperature (`hwmon/temp1_input`), clock (`gt_cur_freq_mhz`), local memory used (`lmem_used_bytes`) | **NOTE:** On Linux, NVIDIA metrics require the NVIDIA driver to be installed (provides `libnvidia-ml.so.1`). AMD and Intel metrics use pure sysfs reads with no external dependencies. All `Float?`/`Int?`/`Long?` GPU fields return `null` when the metric is not available on the current hardware or driver. ### Network Interfaces ```kotlin val nets: List = SystemInfo.networks() ``` | Field | Type | Description | |-------|------|-------------| | `name` | `String` | Interface name (e.g. "eth0", "wlan0") | | `receivedBytes` | `Long` | Total bytes received | | `transmittedBytes` | `Long` | Total bytes transmitted | | `receivedPackets` | `Long` | Total packets received | | `transmittedPackets` | `Long` | Total packets transmitted | | `errorsOnReceived` | `Long` | Receive errors | | `errorsOnTransmitted` | `Long` | Transmit errors | | `macAddress` | `String` | MAC address | | `mtu` | `Long` | Maximum transmission unit | ### Processes ```kotlin val procs: List = SystemInfo.processes() val proc: ProcessInfo? = SystemInfo.process(pid = 1234L) ``` | Field | Type | Description | |-------|------|-------------| | `pid` | `Long` | Process ID | | `name` | `String` | Process name | | `exe` | `String?` | Executable path | | `memory` | `Long` | Resident memory (bytes) | | `virtualMemory` | `Long` | Virtual memory (bytes) | | `cpuUsage` | `Float` | CPU usage (0–100%) | | `status` | `String` | Status: "Run", "Sleep", "Zombie", "Stop", etc. | | `startTime` | `Long` | Start time (Unix epoch seconds) | | `runTime` | `Long` | Run time (seconds) | | `parentPid` | `Long?` | Parent PID (`null` for init/system) | | `cmd` | `List` | Command line arguments | | `cwd` | `String?` | Current working directory | | `root` | `String?` | Root directory | ### Users ```kotlin val users: List = SystemInfo.users() ``` | Field | Type | Description | |-------|------|-------------| | `name` | `String` | Username | | `id` | `String` | User ID / SID | | `groupId` | `String` | Primary group ID | | `groups` | `List` | Group names | ### Hardware Info ```kotlin val mb: MotherboardInfo? = SystemInfo.motherboard() val prod: ProductInfo? = SystemInfo.product() ``` `MotherboardInfo`: | Field | Type | Description | |-------|------|-------------| | `name` | `String?` | Board name | | `vendorName` | `String?` | Board vendor | | `version` | `String?` | Board version | | `serialNumber` | `String?` | Board serial number | | `assetTag` | `String?` | Asset tag | `ProductInfo`: | Field | Type | Description | |-------|------|-------------| | `name` | `String?` | Product name | | `family` | `String?` | Product family | | `serialNumber` | `String?` | Serial number | | `sku` | `String?` | SKU | | `uuid` | `String?` | System UUID | | `version` | `String?` | Product version | | `vendorName` | `String?` | System vendor | ## How It Works ### Linux Each subsystem reads directly from the kernel's virtual filesystems: | Subsystem | Source | |-----------|--------| | OS | `/etc/os-release`, `uname()` | | Memory | `/proc/meminfo` | | CPU usage | `/proc/stat` (delta-based, per-core) | | CPU frequency | `/sys/devices/system/cpu/cpu*/cpufreq/scaling_cur_freq`, fallback to `/proc/cpuinfo` | | Disks | `/proc/mounts` + `statvfs()`, type from `/sys/block/*/queue/rotational` | | Temperature | `/sys/class/hwmon/hwmon*/temp*_input` (millidegrees C), fallback to `/sys/class/thermal/thermal_zone*` | | GPU | DRM sysfs (`/sys/class/drm/card*/device/`) + NVML dlopen for NVIDIA | | Network | `/sys/class/net/*/statistics/{rx_bytes,tx_bytes,...}` | | Processes | `/proc/[pid]/stat`, `/proc/[pid]/status`, `/proc/[pid]/cmdline`, `/proc/[pid]/exe` | | Users | `getpwent()` | | Motherboard | `/sys/devices/virtual/dmi/id/board_*` | | Product | `/sys/devices/virtual/dmi/id/product_*` | ### Windows Uses Win32 APIs (DXGI, WMI, `GetSystemTimes`, `NtQuerySystemInformation`, etc.) via JNI. GPU metrics use NVML when an NVIDIA driver is present. ### macOS Uses sysctl, IOKit, `host_processor_info`, `proc_listallpids`, and Metal APIs via JNI (Objective-C). ## Native Libraries The module ships pre-built native binaries for: - **Windows**: `nucleus_system_info.dll` (x64 + ARM64) - **macOS**: `libnucleus_system_info.dylib` (x64 + arm64) - **Linux**: `libnucleus_system_info.so` (x64 + aarch64) On Linux, the library links only against `libdl` and `libm`. NVML (`libnvidia-ml.so.1`) is loaded at runtime via `dlopen()` — the module works without NVIDIA drivers, GPU metrics for NVIDIA cards simply return `null`. ## ProGuard When ProGuard is enabled, preserve the native bridge classes: ```proguard -keep class io.github.kdroidfilter.nucleus.systeminfo.** { *; } ``` --- # Energy Manager The `energy-manager` module provides two capabilities for Compose Desktop applications: 1. **Energy efficiency mode** — signals the OS to run your process (or a specific thread) at reduced power, ideal when minimized or unfocused. 2. **Screen-awake (caffeine)** — prevents the display and system from entering sleep, useful for presentations, media playback, or long-running tasks. ### Platform support | Feature | Windows | macOS | Linux | |---------|---------|-------|-------| | Full efficiency mode | EcoQoS + `IDLE_PRIORITY_CLASS` | `PRIO_DARWIN_BG` + QoS TIER_5 | nice +19, ioprio IDLE, timerslack 100ms | | Light efficiency mode | EcoQoS only | `task_policy_set(TIER_5)` | nice +10 | | Thread efficiency mode | EcoQoS + `THREAD_PRIORITY_IDLE` | `QOS_CLASS_BACKGROUND` | nice +19, ioprio IDLE, timerslack 100ms | | Screen-awake | `SetThreadExecutionState` | `IOPMAssertion` | DBus (GNOME / logind) or X11 `XScreenSaverSuspend` | ## Installation ```kotlin dependencies { implementation("io.github.kdroidfilter:nucleus.energy-manager:") } ``` ## Full vs Light Efficiency Mode The module provides two levels of process-level efficiency: | | Light | Full | |---|---|---| | **Use case** | Window lost focus — app still functional in background | Window minimized — no UI to render, deep power saving | | **CPU** | Deprioritized via QoS hints | Lowest priority (idle class) | | **I/O** | Normal | Throttled | | **Network** | Normal | Throttled (macOS) | | **Reversibility** | Instant | Instant | **Recommendation**: use **light mode** when the window loses focus and **full mode** when the window is minimized. This gives the best balance between responsiveness and power savings — the app remains functional in the background (network requests, file I/O) while still signaling the OS that it can be deprioritized. ## Usage ### Recommended: light mode on focus loss, full mode on minimize ```kotlin import io.github.kdroidfilter.nucleus.energymanager.EnergyManager import java.awt.event.WindowEvent import java.awt.event.WindowFocusListener @Composable fun App(state: WindowState) { DecoratedWindow(state = state, onCloseRequest = ::exitApplication) { var isWindowFocused by remember { mutableStateOf(window.isFocused) } DisposableEffect(window) { val listener = object : WindowFocusListener { override fun windowGainedFocus(e: WindowEvent?) { isWindowFocused = true } override fun windowLostFocus(e: WindowEvent?) { isWindowFocused = false } } window.addWindowFocusListener(listener) onDispose { window.removeWindowFocusListener(listener) } } LaunchedEffect(state.isMinimized, isWindowFocused) { when { state.isMinimized -> { EnergyManager.disableLightEfficiencyMode() EnergyManager.enableEfficiencyMode() } !isWindowFocused -> { EnergyManager.disableEfficiencyMode() EnergyManager.enableLightEfficiencyMode() } else -> { EnergyManager.disableEfficiencyMode() EnergyManager.disableLightEfficiencyMode() } } } // Your app content } } ``` ### Using efficiency mode in coroutines The `withEfficiencyMode` and `withLightEfficiencyMode` suspend helpers make it easy to run background work at reduced power inside coroutines. #### `withEfficiencyMode` — dedicated thread `withEfficiencyMode` creates a **dedicated single thread** with thread-level efficiency applied (`QOS_CLASS_BACKGROUND` on macOS, `THREAD_PRIORITY_IDLE` on Windows). The thread is disposed when the block completes. This is ideal for CPU-bound background tasks that should not interfere with the UI: ```kotlin // Inside a coroutine scope val result = EnergyManager.withEfficiencyMode { // Runs on a dedicated low-priority thread // Other coroutines on the default dispatcher are not affected computeHeavyReport() } // Back on the original dispatcher with full priority updateUI(result) ``` Since the efficiency is applied at the **thread level**, it does not affect other threads or coroutines in your application. The dedicated thread is automatically shut down when the block finishes. #### `withLightEfficiencyMode` — process-level, no thread pinning `withLightEfficiencyMode` applies **process-level** light QoS for the duration of the block. Unlike `withEfficiencyMode`, it does not create a new thread — it runs on the current dispatcher. This is useful for sections of code where the entire process can be deprioritized without throttling I/O: ```kotlin EnergyManager.withLightEfficiencyMode { // Process-level QoS is reduced (CPU deprioritized, I/O normal) syncDataFromServer() // network is not throttled writeToDatabase() // I/O is not throttled } // Process-level QoS restored to default ``` #### Choosing between the two | | `withEfficiencyMode` | `withLightEfficiencyMode` | |---|---|---| | **Scope** | Thread-level (dedicated thread) | Process-level | | **I/O throttled** | No (thread QoS doesn't throttle I/O) | No | | **CPU impact** | Only the dedicated thread | Entire process | | **Best for** | CPU-bound background work alongside a responsive UI | Batch operations where the whole app can be deprioritized | ### Using efficiency mode with raw threads If you manage threads manually, you can use the thread-level API directly: ```kotlin val thread = Thread { EnergyManager.enableThreadEfficiencyMode() try { performBackgroundWork() } finally { EnergyManager.disableThreadEfficiencyMode() } } thread.start() ``` The thread-level mode only affects the calling thread. On macOS this sets `QOS_CLASS_BACKGROUND` via `pthread_set_qos_class_self_np`, which confines the thread to E-cores without throttling I/O or network. ### Keeping the screen awake ```kotlin // Prevent display sleep (e.g. during a presentation) EnergyManager.keepScreenAwake() // Allow sleep again EnergyManager.releaseScreenAwake() // Check current state val active = EnergyManager.isScreenAwakeActive() ``` ## API Reference | Function | Returns | Description | |----------|---------|-------------| | `isAvailable()` | `Boolean` | `true` if the platform supports efficiency mode. | | `enableEfficiencyMode()` | `Result` | Activates full process-level energy efficiency (CPU + I/O throttling). | | `disableEfficiencyMode()` | `Result` | Restores default OS scheduling after full mode. | | `enableLightEfficiencyMode()` | `Result` | Activates light process-level efficiency (CPU only, no I/O throttling). | | `disableLightEfficiencyMode()` | `Result` | Restores default QoS tiers after light mode. | | `enableThreadEfficiencyMode()` | `Result` | Activates efficiency mode for the calling thread only. | | `disableThreadEfficiencyMode()` | `Result` | Restores default scheduling for the calling thread. | | `withEfficiencyMode { }` | `T` | Runs a suspend block on a dedicated efficient thread. | | `withLightEfficiencyMode { }` | `T` | Runs a suspend block with process-level light QoS. | | `keepScreenAwake()` | `Result` | Prevents display and system sleep. | | `releaseScreenAwake()` | `Result` | Releases the screen-awake inhibition. | | `isScreenAwakeActive()` | `Boolean` | `true` if screen-awake is currently active. | The `Result` data class: | Field | Type | Description | |-------|------|-------------| | `success` | `Boolean` | `true` if the native call succeeded. | | `errorCode` | `Int` | OS error code on failure, `0` on success. | | `message` | `String` | Human-readable error description on failure. | ## How It Works ### Full process efficiency mode #### Windows 11+ (full EcoQoS) 1. **`SetProcessInformation(ProcessPowerThrottling)`** — enables EcoQoS: reduced CPU frequency, E-core routing on hybrid processors. Triggers the **green leaf icon** in Task Manager (22H2+). 2. **`SetPriorityClass(IDLE_PRIORITY_CLASS)`** — lowers process base priority to 4. On Windows 10 1709+, the same calls succeed but EcoQoS only applies on battery ("LowQoS"). #### macOS 1. **`setpriority(PRIO_DARWIN_BG)`** — CPU low priority, I/O throttling, network throttling, E-core confinement on Apple Silicon. 2. **`task_policy_set(TASK_BASE_QOS_POLICY)`** with `LATENCY_QOS_TIER_5` / `THROUGHPUT_QOS_TIER_5` — reinforces via Mach task QoS (timer coalescing, throughput hints). #### Linux 1. **`setpriority(PRIO_PROCESS, 0, 19)`** — maximum nice value for lowest CPU priority. 2. **`prctl(PR_SET_TIMERSLACK, 100ms)`** — timer coalescing to reduce wakeups. 3. **`ioprio_set(IOPRIO_CLASS_IDLE)`** — I/O scheduling class idle. All three are reversible without root on any mainstream distribution. ### Light process efficiency mode #### macOS **`task_policy_set(TASK_BASE_QOS_POLICY)`** with `LATENCY_QOS_TIER_5` / `THROUGHPUT_QOS_TIER_5` — deprioritizes CPU scheduling without enabling `PRIO_DARWIN_BG`. This means: - CPU is deprioritized (timer coalescing, lower throughput QoS) - I/O is **not** throttled - Network is **not** throttled - No E-core confinement Disabled by resetting tiers to `UNSPECIFIED`. #### Windows EcoQoS only (no `IDLE_PRIORITY_CLASS`) — the green leaf in Task Manager with normal process priority. Disabled by clearing the `StateMask`. #### Linux **`setpriority(PRIO_PROCESS, 0, 10)`** — moderate CPU deprioritization (nice +10) without ioprio IDLE or timer slack. This means: - CPU is moderately deprioritized - I/O is **not** throttled (no ioprio change) - Timer coalescing is **not** applied (no timerslack change) Disabled by resetting nice to 0. ### Thread efficiency mode | Platform | Mechanism | |----------|-----------| | Windows 11+ | `SetThreadInformation(ThreadPowerThrottling)` EcoQoS + `THREAD_PRIORITY_IDLE` | | Windows 10 | `THREAD_PRIORITY_IDLE` only (no per-thread EcoQoS) | | macOS | `pthread_set_qos_class_self_np(QOS_CLASS_BACKGROUND)` | | Linux | Same as process-level (nice, ioprio, timerslack are per-thread on Linux) | ### Screen-awake (caffeine) #### Windows `SetThreadExecutionState(ES_CONTINUOUS | ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED)` — immediate, no setup cost. #### macOS `IOPMAssertionCreateWithName(kIOPMAssertPreventUserIdleDisplaySleep)` via IOKit — prevents both display and system idle sleep. Released via `IOPMAssertionRelease`. #### Linux A composite backend tries three strategies in order: 1. **GNOME SessionManager** — DBus `Inhibit()` on the session bus with `INHIBIT_IDLE | INHIBIT_SUSPEND` flags. Released via `Uninhibit()` with the returned cookie. 2. **systemd-logind** — DBus `Inhibit("idle")` on the system bus. Stays active as long as the returned file descriptor is kept open. 3. **X11 XScreenSaverSuspend** — suspends the X11 screen saver via `libXss`. All libraries (`libdbus-1`, `libX11`, `libXss`) are loaded at runtime via `dlopen()` — the module works even when some are not installed. Private DBus connections are used to avoid interference with the JVM's internal AT-SPI accessibility bus. ## Native Libraries The module ships pre-built native binaries for: - **Windows**: `nucleus_energy_manager.dll` (x64 + ARM64) — resolved dynamically via `GetProcAddress` - **macOS**: `libnucleus_energy_manager.dylib` (x64 + arm64) — linked against IOKit/CoreFoundation - **Linux**: `libnucleus_energy_manager.so` (x64 + aarch64) — loads `libdbus-1`, `libX11`, `libXss` via `dlopen()` ## ProGuard When ProGuard is enabled, preserve the native bridge classes: ```proguard -keep class io.github.kdroidfilter.nucleus.energymanager.** { *; } ``` --- # Linux HiDPI Standard OpenJDK does not detect the native display scale factor on Linux. On HiDPI screens this results in a tiny, blurry UI rendered at 1x resolution. The `linux-hidpi` module provides native scale factor detection using JNI, mirroring the detection logic built into JetBrains Runtime (`systemScale.c`). It queries multiple system sources and returns the correct scale so you can apply it via `sun.java2d.uiScale` before AWT initialises. This module was originally designed for running Compose Desktop applications compiled with **GraalVM Native Image** (alpha), where JBR is not available and scale detection must be handled manually. **JBR recommended for JVM-based applications:** If your application runs on a standard JVM (not a native image), prefer using **JetBrains Runtime (JBR)** which handles HiDPI detection natively and provides stable, battle-tested support across Linux desktop environments. This module is only necessary when JBR is not an option — typically with GraalVM Native Image or other non-JBR runtimes. **Already handled by `GraalVmInitializer`:** If you use the [`graalvm-runtime`](../graalvm/runtime-bootstrap.md) module, `GraalVmInitializer.initialize()` already calls `getLinuxNativeScaleFactor()` and applies the scale factor automatically. You do **not** need to add `linux-hidpi` as a separate dependency or call the function manually — it is included transitively. ## Installation ```kotlin dependencies { implementation("io.github.kdroidfilter:nucleus.linux-hidpi:") } ``` ## Usage Call `getLinuxNativeScaleFactor()` **before** `application {}` (i.e. before AWT initialises): ```kotlin import io.github.kdroidfilter.nucleus.hidpi.getLinuxNativeScaleFactor fun main() { if (System.getProperty("sun.java2d.uiScale") == null) { val scale = getLinuxNativeScaleFactor() if (scale > 0.0) { System.setProperty("sun.java2d.uiScale", scale.toString()) } } application { // Your Compose Desktop app } } ``` The function is a no-op on non-Linux platforms and returns `0.0`. ## Detection Sources The scale factor is resolved from the first available source, in priority order: | Priority | Source | Description | |----------|--------|-------------| | 1 | `J2D_UISCALE` | Explicit JVM override (environment variable) | | 2 | GSettings | GNOME `org.gnome.desktop.interface` → `scaling-factor` (via libgio) | | 3 | `GDK_SCALE` | GTK / GNOME session variable | | 4 | `GDK_DPI_SCALE` | GTK fractional DPI multiplier | | 5 | `Xft.dpi` | X Resource Manager (KDE, legacy GNOME, …) | If the JNI library cannot be loaded (e.g. on a minimal container), the function falls back to reading `J2D_UISCALE`, `GDK_SCALE`, and `GDK_DPI_SCALE` environment variables from pure Java. ## Native Libraries The module ships pre-built native binaries for: - Linux x64: `libnucleus_linux_hidpi_jni.so` - Linux aarch64: `libnucleus_linux_hidpi_jni.so` The native code uses `dlopen` to load optional dependencies (libgio for GSettings, libX11 for Xft.dpi) at runtime, so there are no hard link-time dependencies beyond libc. ## ProGuard When ProGuard is enabled, preserve the JNI bridge class: ```proguard -keep class io.github.kdroidfilter.nucleus.hidpi.HiDpiLinuxBridge { native ; } ``` --- # Taskbar Progress Native taskbar/dock progress bar and attention requests on Windows, macOS, and Linux. ## Installation ```kotlin dependencies { implementation("io.github.kdroidfilter:nucleus.taskbar-progress:") } ``` `taskbar-progress` depends on `core-runtime` (compile-only) for `Platform` detection and `NativeLibraryLoader`. ## Quick Start ```kotlin import io.github.kdroidfilter.nucleus.taskbarprogress.TaskbarProgress import java.awt.Window // Show a progress bar at 75% TaskbarProgress.showProgress(window, 0.75) // Show an indeterminate (pulsing) progress bar TaskbarProgress.showIndeterminate(window) // Show an error state TaskbarProgress.showError(window) // Hide the progress bar TaskbarProgress.hideProgress(window) // Request user attention (flash taskbar / bounce dock icon) TaskbarProgress.requestAttention(window) ``` ## API Reference ### `TaskbarProgress` All methods accept a `java.awt.Window` parameter and return `Boolean` (`true` if the operation succeeded on the current platform). | Method | Description | |--------|-------------| | `isAvailable(): Boolean` | Check if taskbar progress is supported on the current platform | | `setProgress(window, value: Double): Boolean` | Set progress value (0.0–1.0, clamped) | | `setState(window, state: State): Boolean` | Set progress state | | `showProgress(window, value: Double): Boolean` | Set state to `NORMAL` + set progress value | | `showError(window, value: Double = 1.0): Boolean` | Set state to `ERROR` + set progress value | | `showIndeterminate(window): Boolean` | Set state to `INDETERMINATE` | | `showPaused(window, value: Double = 1.0): Boolean` | Set state to `PAUSED` + set progress value | | `hideProgress(window): Boolean` | Set state to `NO_PROGRESS` | | `requestAttention(window, type: AttentionType = INFORMATIONAL): Boolean` | Request user attention | | `stopAttention(window): Boolean` | Cancel attention request | ### `TaskbarProgress.State` | Value | Windows | macOS | Linux | |-------|---------|-------|-------| | `NO_PROGRESS` | No overlay | Remove indicator | `progress-visible: false` | | `INDETERMINATE` | Pulsing green | Pulsing bar | `progress-visible: true` (DE-dependent) | | `NORMAL` | Green bar | Blue bar | Blue/accent bar | | `ERROR` | Red bar | Red bar | `urgent: true` | | `PAUSED` | Yellow bar | Yellow bar | (mapped to progress) | ### `TaskbarProgress.AttentionType` | Value | Windows | macOS | |-------|---------|-------| | `INFORMATIONAL` | Flash taskbar button 4 times | Bounce dock icon once | | `CRITICAL` | Flash until window receives focus | Bounce dock icon continuously | ### `linuxDesktopFilename` Optional override for the Linux `.desktop` filename used by the D-Bus protocol: ```kotlin TaskbarProgress.linuxDesktopFilename = "com.example.myapp.desktop" ``` By default, the module auto-detects the `.desktop` file using (in order): `NucleusApp.appId`, `GIO_LAUNCHED_DESKTOP_FILE` env var, `BAMF_DESKTOP_FILE_HINT` env var, `/proc/self/exe` name, or XDG application directory scan. ## Compose Desktop Integration ```kotlin @Composable fun DownloadScreen(window: Window) { var progress by remember { mutableStateOf(0.0) } var isDownloading by remember { mutableStateOf(false) } DisposableEffect(Unit) { onDispose { TaskbarProgress.hideProgress(window) } } LaunchedEffect(isDownloading) { if (isDownloading) { TaskbarProgress.showIndeterminate(window) // Simulate download for (i in 0..100) { delay(50) progress = i / 100.0 TaskbarProgress.showProgress(window, progress) } TaskbarProgress.hideProgress(window) isDownloading = false } } Button(onClick = { isDownloading = true }) { Text("Download") } } ``` **Getting the AWT Window in Compose:** Use `LocalWindow.current` (from `androidx.compose.ui.awt`) or capture the window reference from your `Window` / `DecoratedWindow` scope. ## Platform Details ### Windows Uses the COM `ITaskbarList3` interface for progress and state, and `FlashWindowEx` for attention requests. COM is lazily initialized on first use. Requires the HWND from `java.awt.Window`. ### macOS Uses `NSDockTile` with a custom `NSProgressIndicator` subclass rendered at the bottom of the dock icon. All AppKit calls are dispatched to the main thread via `dispatch_sync`. Attention uses `NSApplication.requestUserAttention`. **macOS dock progress is app-wide:** The macOS dock shows a single progress indicator per application. The `window` parameter is accepted for API consistency but the progress applies to the app dock tile, not a specific window. ### Linux Uses the D-Bus `com.canonical.Unity.LauncherEntry` protocol via GLib/GIO. Supported by GNOME (Ubuntu Dock, Dash to Dock), KDE Plasma, and other freedesktop-compliant desktop environments. Requires a `.desktop` file to identify the application. The module auto-detects it, but you can override with `TaskbarProgress.linuxDesktopFilename` if detection fails. --- # Global Hotkey System-wide keyboard shortcuts that fire even when the application does not have focus — for media players, screenshot tools, accessibility shortcuts, and more. ## Installation ```kotlin dependencies { implementation("io.github.kdroidfilter:nucleus.global-hotkey:") } ``` ## Quick Start ```kotlin import io.github.kdroidfilter.nucleus.globalhotkey.GlobalHotKeyManager import io.github.kdroidfilter.nucleus.globalhotkey.HotKeyModifier import java.awt.event.KeyEvent GlobalHotKeyManager.initialize() val handle = GlobalHotKeyManager.register( keyCode = KeyEvent.VK_F12, modifiers = HotKeyModifier.CONTROL + HotKeyModifier.SHIFT, ) { _, _ -> println("Hotkey pressed!") } // Later: GlobalHotKeyManager.unregister(handle) GlobalHotKeyManager.shutdown() ``` ## Usage ### Lifecycle Call `initialize()` once at startup (e.g., in `DisposableEffect`) and `shutdown()` on disposal. Both are safe to call multiple times. ```kotlin DisposableEffect(Unit) { GlobalHotKeyManager.initialize() onDispose { GlobalHotKeyManager.shutdown() } } ``` ### Registering a Hotkey `register()` returns a `Long` handle (≥ 0 on success, -1 on failure). Keep the handle to unregister later. ```kotlin val handle = GlobalHotKeyManager.register( keyCode = KeyEvent.VK_K, modifiers = HotKeyModifier.CONTROL + HotKeyModifier.SHIFT, ) { keyCode, modifiers -> // Called on a background thread — dispatch to UI if needed println("Pressed: keyCode=$keyCode modifiers=$modifiers") } if (handle < 0) { println("Failed: ${GlobalHotKeyManager.lastError}") } ``` ### Combining Modifiers Use the `+` operator to build a modifier bitmask: ```kotlin // Single modifier HotKeyModifier.CONTROL // Two modifiers HotKeyModifier.CONTROL + HotKeyModifier.SHIFT // Three modifiers HotKeyModifier.CONTROL + HotKeyModifier.ALT + HotKeyModifier.SHIFT // No modifier (bare key) 0 ``` **Avoid Ctrl+Alt+Fn on Linux:** Combinations involving `Ctrl+Alt+Fn` (e.g., `Ctrl+Alt+F1`) trigger virtual terminal switching at the kernel level and **cannot** be captured by an application. Use `Ctrl+Shift` instead. ### Media Keys Register media keys (Play/Pause, Stop, Next, Previous) without specifying a modifier: ```kotlin val handle = GlobalHotKeyManager.register(MediaKey.PLAY_PAUSE) { _, _ -> println("Play/Pause pressed") } ``` **Media keys are not supported on macOS:** Carbon's `RegisterEventHotKey` does not expose media key codes. Use `Ctrl+Shift+` as an alternative on macOS. ### Unregistering ```kotlin GlobalHotKeyManager.unregister(handle) ``` On the portal (Wayland) backend, unregistering triggers a full rebind of remaining shortcuts to keep the portal session in sync. ### Error Handling Check `isAvailable` before calling `initialize()`, and inspect `lastError` on any failure: ```kotlin if (!GlobalHotKeyManager.isAvailable) { println("Global hotkeys not supported on this platform") return } if (!GlobalHotKeyManager.initialize()) { println("Init failed: ${GlobalHotKeyManager.lastError}") return } ``` ## Compose Desktop Integration On Wayland, `register()` and `unregister()` block until the portal responds (CreateSession + BindShortcuts D-Bus round-trips). Call them on `Dispatchers.IO` to avoid freezing the UI thread: ```kotlin val scope = rememberCoroutineScope() Button(onClick = { scope.launch(Dispatchers.IO) { val handle = GlobalHotKeyManager.register(KeyEvent.VK_K, HotKeyModifier.CONTROL + HotKeyModifier.SHIFT ) { _, _ -> /* ... */ } withContext(Dispatchers.Main) { if (handle >= 0) { /* add to registered list */ } else println("Failed: ${GlobalHotKeyManager.lastError}") } } }) { Text("Register") } ``` **Dependency for `Dispatchers.Main`:** Compose Desktop requires `kotlinx-coroutines-swing` on the classpath to provide the main dispatcher: ```kotlin implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:") ``` ## API Reference ### `GlobalHotKeyManager` Thread-safe singleton. | Member | Description | |--------|-------------| | `isAvailable: Boolean` | Whether the native library is loaded and functional on this platform | | `lastError: String?` | Last error from a native operation, or `null` if the last operation succeeded | | `initialize(): Boolean` | Initialize the subsystem. Returns `true` on success | | `register(keyCode: Int, modifiers: Int, listener: HotKeyListener): Long` | Register a hotkey. Returns a handle ≥ 0 on success, -1 on failure | | `register(mediaKey: MediaKey, listener: HotKeyListener): Long` | Register a media key. Returns a handle ≥ 0 on success, -1 on failure | | `unregister(handle: Long): Boolean` | Unregister a previously registered hotkey | | `shutdown()` | Unregister all hotkeys and stop the native event loop | ### `HotKeyModifier` | Value | Key | |-------|-----| | `ALT` | Alt (Option on macOS) | | `CONTROL` | Control | | `SHIFT` | Shift | | `META` | Windows key / Command (macOS) | ### `MediaKey` | Value | Key | |-------|-----| | `PLAY_PAUSE` | Play / Pause toggle | | `STOP` | Stop playback | | `NEXT_TRACK` | Next track | | `PREV_TRACK` | Previous track | ### `HotKeyListener` ```kotlin fun interface HotKeyListener { fun onHotKey(keyCode: Int, modifiers: Int) } ``` The callback is invoked on a platform-specific background thread. Dispatch to the UI thread as needed. ## Platform Details ### Windows Uses Win32 `RegisterHotKey` / `UnregisterHotKey` on a dedicated message loop thread. `MOD_NOREPEAT` is set by default to suppress key-repeat events. ### macOS Uses Carbon `RegisterEventHotKey` / `UnregisterEventHotKey`. The event handler runs on the main run loop thread via `InstallApplicationEventHandler`. ### Linux — X11 Uses `XGrabKey` / `XUngrabKey` on the root window. The implementation registers the hotkey with all 16 combinations of lock modifiers (CapsLock, NumLock, ScrollLock) so that hotkeys fire regardless of lock key state. ### Linux — Wayland Uses the `org.freedesktop.portal.GlobalShortcuts` XDG Desktop Portal via GIO/GDBus. **Requirements:** - The application must have a valid `.desktop` file with a reverse-DNS name (e.g., `io.github.kdroidfilter.MyApp.desktop`) - The application must be launched from that `.desktop` file (or have `GIO_LAUNCHED_DESKTOP_FILE` set) - GNOME validates the `app_id` against `g_application_id_is_valid` — a plain name like `MyApp` will be rejected **Step 1 — Set a reverse-DNS `packageName` in your Nucleus build config:** `packageName` is used as the `.desktop` filename on Linux. It **must** follow reverse-DNS notation and pass `g_application_id_is_valid`. A plain name like `MyApp` or `my-app` is rejected by GNOME. ```kotlin nucleus.application { nativeDistributions { appName = "MyApp" // human-readable display name packageName = "io.github.kdroidfilter.MyApp" // becomes io.github.kdroidfilter.MyApp.desktop } } ``` **`appName` is required:** Without `appName`, the application title shown in GNOME Shell and window decorations will fall back to the full `packageName` (`io.github.kdroidfilter.MyApp`). Always set both: `packageName` for the reverse-DNS identity, `appName` for the display name. **Step 2 — Launch from the `.desktop` file:** The portal uses `GIO_LAUNCHED_DESKTOP_FILE` (set automatically by the desktop environment when the app is started from its launcher entry) to identify the calling application. If you launch the app directly from a terminal, this variable is not set and GNOME will reject the session with response code 2. For development, you can set it manually: ```bash GIO_LAUNCHED_DESKTOP_FILE=/usr/share/applications/io.github.kdroidfilter.MyApp.desktop \ GIO_LAUNCHED_DESKTOP_FILE_PID=$$ \ ./my-app ``` Or install the app once (via DEB/AppImage/Flatpak) so the `.desktop` file is registered, then launch from the application menu. **How the portal session works:** The portal backend uses a dedicated GLib thread permanently attached to the JVM. Because GNOME only allows one `BindShortcuts` call per portal session, the implementation automatically closes and recreates the session on every `register()` / `unregister()` call, then rebinds the full shortcut list. On Wayland, `register()` blocks the calling thread until GNOME responds — use `Dispatchers.IO` as shown above. ## Platform Support Matrix | Feature | Windows | macOS | Linux X11 | Linux Wayland | |---------|---------|-------|-----------|---------------| | Regular hotkeys | ✅ | ✅ | ✅ | ✅ | | Media keys | ✅ | ❌ | ✅ | ✅ | | No-modifier (bare key) | ✅ | ✅ | ✅ | ✅ | | Key-repeat suppression | ✅ (MOD_NOREPEAT) | ✅ | ✅ | portal-dependent | ## ProGuard If you use ProGuard/R8, keep the JNI bridge classes: ```proguard -keep class io.github.kdroidfilter.nucleus.globalhotkey.windows.NativeWindowsHotKeyBridge { *; } -keep class io.github.kdroidfilter.nucleus.globalhotkey.macos.NativeMacOsHotKeyBridge { *; } -keep class io.github.kdroidfilter.nucleus.globalhotkey.linux.NativeLinuxHotKeyBridge { *; } ``` --- # Menu (macOS) Declarative, Compose-reactive macOS application menu bar via JNI. Build a fully native menu bar with SF Symbols, keyboard shortcuts, badges, submenus, checkboxes, radio buttons, and more — all driven by Compose state. ## Installation ```kotlin dependencies { implementation("io.github.kdroidfilter:nucleus.menu-macos:") implementation("io.github.kdroidfilter:nucleus.sf-symbols:") // optional — type-safe SF Symbol constants } ``` Requires Compose Desktop on the classpath. Depends on `core-runtime` (compile-only) for `NativeLibraryLoader`. The `sf-symbols` module is optional but recommended for type-safe icon references. ## Quick Start ```kotlin import io.github.kdroidfilter.nucleus.menu.macos.* import io.github.kdroidfilter.nucleus.sfsymbols.* @Composable fun App() { NativeMenuBar { Menu("File") { Item("New", shortcut = NativeKeyShortcut("n"), icon = NsMenuItemImage.SystemSymbol(SFSymbolObjectsAndTools.DOCUMENT_BADGE_PLUS)) { println("New") } Item("Open...", shortcut = NativeKeyShortcut("o"), icon = NsMenuItemImage.SystemSymbol(SFSymbolObjectsAndTools.FOLDER)) { println("Open") } Separator() Item("Quit", shortcut = NativeKeyShortcut("q"), icon = NsMenuItemImage.SystemSymbol(SFSymbolPower.POWER)) { exitProcess(0) } } } // ... your window content } ``` The menu bar is installed when `NativeMenuBar` enters the composition and the original is restored when it leaves. --- ## NativeMenuBar ```kotlin @Composable fun NativeMenuBar(content: @Composable NativeMenuBarScope.() -> Unit) ``` Top-level composable that replaces the application menu bar. **Reactive** — any Compose state read inside `content` triggers a rebuild of the native menu bar. The **first** `Menu` call produces the application menu (the bold entry whose title is the app name). **App name in the menu bar:** The Nucleus Gradle plugin automatically sets the app name from `appName` (fallback: `packageName`) in the DSL — both for `./gradlew run` (`-Dapple.awt.application.name`) and for packaged `.app` distributions (`CFBundleName` in Info.plist). --- ## Menu ```kotlin // Top-level menu (in NativeMenuBarScope) @Composable fun Menu(text: String, enabled: Boolean = true, mnemonic: Char? = null, content: @Composable NativeMenuScope.() -> Unit) // Nested submenu (in NativeMenuScope) @Composable fun Menu(text: String, enabled: Boolean = true, mnemonic: Char? = null, icon: NsMenuItemImage? = null, content: @Composable NativeMenuScope.() -> Unit) ``` Can be nested to arbitrary depth: ```kotlin Menu("File") { Menu("Open Recent", icon = NsMenuItemImage.SystemSymbol("clock.arrow.circlepath")) { Item("project.kt") { } Item("build.gradle.kts") { } Separator() Item("Clear Menu") { } } } ``` --- ## Item ```kotlin fun Item( text: String, enabled: Boolean = true, shortcut: NativeKeyShortcut? = null, icon: NsMenuItemImage? = null, state: NsMenuItemState = NsMenuItemState.OFF, tag: Int = 0, badge: NsMenuItemBadge? = null, subtitle: String? = null, toolTip: String? = null, indentationLevel: Int = 0, isAlternate: Boolean = false, isHidden: Boolean = false, onStateImage: NsMenuItemImage? = null, offStateImage: NsMenuItemImage? = null, mixedStateImage: NsMenuItemImage? = null, onClick: () -> Unit = {}, ) ``` Full-featured menu item. `onClick` is a trailing lambda: ```kotlin Item("Save", shortcut = NativeKeyShortcut("s"), icon = NsMenuItemImage.SystemSymbol("square.and.arrow.down")) { println("Saved!") } ``` **Thread safety:** `onClick` callbacks are dispatched on the Swing EDT via `SwingUtilities.invokeLater`. ### Keyboard Shortcuts ```kotlin Item("Save", shortcut = NativeKeyShortcut("s")) { } // ⌘S Item("Save As...", shortcut = NativeKeyShortcut("s", shift = true)) { } // ⇧⌘S Item("Find...", shortcut = NativeKeyShortcut("f", option = true)) { } // ⌥⌘F Item("Full Screen", shortcut = NativeKeyShortcut("f", control = true)) { } // ⌃⌘F ``` `NativeKeyShortcut` parameters: | Parameter | Modifier | Default | |---|---|---| | `command` | ⌘ Command | `true` | | `shift` | ⇧ Shift | `false` | | `option` | ⌥ Option | `false` | | `control` | ⌃ Control | `false` | | `function` | Fn | `false` | For special keys, use `NativeKey` constants: ```kotlin Item("Exit", shortcut = NativeKeyShortcut(NativeKey.Escape, command = false)) { } Item("Help", shortcut = NativeKeyShortcut("?")) { } ``` Available constants: `NativeKey.Escape`, `Return`, `Tab`, `Delete`, `Backspace`, `Up`, `Down`, `Left`, `Right`, `F1`–`F12`, `Home`, `End`, `PageUp`, `PageDown`. ### Images (SF Symbols) With the `sf-symbols` module (type-safe): ```kotlin import io.github.kdroidfilter.nucleus.sfsymbols.* Item("Cut", icon = NsMenuItemImage.SystemSymbol(SFSymbolObjectsAndTools.SCISSORS)) { } Item("Inbox", icon = NsMenuItemImage.SystemSymbol(SFSymbolGeneral.TRAY_FILL)) { } Item("Undo", icon = NsMenuItemImage.SystemSymbol(SFSymbolArrows.ARROW_UTURN_BACKWARD)) { } ``` With raw strings (no extra dependency): ```kotlin Item("Cut", icon = NsMenuItemImage.SystemSymbol("scissors")) { } ``` Other image sources: ```kotlin Item("Info", icon = NsMenuItemImage.Named("NSActionTemplate")) { } // AppKit named image Item("Icon", icon = NsMenuItemImage.File("/path/to/icon.png")) { } // file path ``` State-specific images: ```kotlin Item("Sync", state = NsMenuItemState.ON, onStateImage = NsMenuItemImage.SystemSymbol(SFSymbolStatus.CHECKMARK_CIRCLE_FILL), offStateImage = NsMenuItemImage.SystemSymbol(SFSymbolShapes.CIRCLE), mixedStateImage = NsMenuItemImage.SystemSymbol(SFSymbolStatus.MINUS_CIRCLE), ) { } ``` ### Badges (macOS 14+) ```kotlin Item("Inbox", badge = NsMenuItemBadge.Count(42)) { } Item("Updates", badge = NsMenuItemBadge.updates(3)) { } Item("Alerts", badge = NsMenuItemBadge.alerts(1)) { } Item("New", badge = NsMenuItemBadge.newItems(7)) { } Item("Build", badge = NsMenuItemBadge.Text("PASS")) { } ``` Predefined types (`alerts`, `updates`, `newItems`) are automatically localized by macOS. Custom string badges must be localized by the caller. ### Subtitle (macOS 14.4+) ```kotlin Item("Main Title", subtitle = "Secondary description text") { } ``` ### Other Properties ```kotlin Item("Disabled", enabled = false) { } Item("Hidden (shortcut still works)", isHidden = true, shortcut = NativeKeyShortcut("h", shift = true, option = true)) { } Item("Indented", indentationLevel = 2) { } Item("With Tooltip", toolTip = "Hover to see this") { } Item("Tagged", tag = 42) { } ``` --- ## Alternate Items Alternate items appear when the user holds Option. They must immediately follow the base item and share the same key equivalent with a different modifier: ```kotlin Item("Paste", shortcut = NativeKeyShortcut("v")) { } Item("Paste and Match Style", shortcut = NativeKeyShortcut("v", option = true, shift = true), isAlternate = true, ) { } ``` --- ## CheckboxItem Toggles between checked (✓) and unchecked: ```kotlin var showToolbar by remember { mutableStateOf(true) } CheckboxItem("Show Toolbar", checked = showToolbar, icon = NsMenuItemImage.SystemSymbol("sidebar.left"), onCheckedChange = { showToolbar = it }, ) ``` Supports all the same optional parameters as `Item` (shortcut, badge, subtitle, etc.) except `state`, `isAlternate`, `isHidden`, and state images. --- ## RadioButtonItem Mutually exclusive selection — manage state externally: ```kotlin var theme by remember { mutableStateOf("System") } RadioButtonItem("System", selected = theme == "System", onClick = { theme = "System" }) RadioButtonItem("Light", selected = theme == "Light", onClick = { theme = "Light" }) RadioButtonItem("Dark", selected = theme == "Dark", onClick = { theme = "Dark" }) ``` --- ## Separator ```kotlin Separator() ``` --- ## SectionHeader (macOS 14+) Non-clickable section label. Falls back to a disabled item on older macOS versions. ```kotlin SectionHeader("View Options") CheckboxItem("Show Toolbar", ...) CheckboxItem("Show Status Bar", ...) ``` --- ## Conditional Menus Because the content is `@Composable`, you can use standard Compose control flow: ```kotlin var advanced by remember { mutableStateOf(false) } Menu("Advanced") { CheckboxItem("Enable Advanced", checked = advanced, onCheckedChange = { advanced = it }) if (advanced) { Separator() Menu("Settings") { Item("Setting 1") { } Item("Setting 2") { } } } } ``` The menu bar rebuilds automatically when `advanced` changes — the "Settings" submenu appears or disappears. --- ## Well-Known Menus When the menu bar is installed, submenus with specific titles are automatically registered with macOS: | Submenu title | Effect | |---|---| | `"Services"` | macOS populates with system services. | | `"Window"` | macOS adds the window list and "Bring All to Front". | | `"Help"` | macOS adds the search-in-menus field. | Detection is by **exact title**. To opt out, use a different title: ```kotlin Menu("Services") { /* macOS fills this */ } // ✓ auto-registered Menu("App Services") { Item("Custom") { } } // ✗ fully yours ``` --- ## Full Example ```kotlin import io.github.kdroidfilter.nucleus.sfsymbols.* @Composable fun App() { var showToolbar by remember { mutableStateOf(true) } var theme by remember { mutableStateOf("System") } var inboxCount by remember { mutableStateOf(42) } NativeMenuBar { Menu("MyApp") { Item("About MyApp", icon = NsMenuItemImage.SystemSymbol(SFSymbolStatus.INFO_CIRCLE)) { } Separator() Item("Settings...", shortcut = NativeKeyShortcut(","), icon = NsMenuItemImage.SystemSymbol(SFSymbolGeneral.GEARSHAPE)) { } Separator() Menu("Services") { } Separator() Item("Quit", shortcut = NativeKeyShortcut("q"), icon = NsMenuItemImage.SystemSymbol(SFSymbolPower.POWER)) { exitProcess(0) } } Menu("File") { Item("New", shortcut = NativeKeyShortcut("n"), icon = NsMenuItemImage.SystemSymbol(SFSymbolObjectsAndTools.DOCUMENT_BADGE_PLUS)) { } Item("Open...", shortcut = NativeKeyShortcut("o"), icon = NsMenuItemImage.SystemSymbol(SFSymbolObjectsAndTools.FOLDER)) { } Separator() Item("Save", shortcut = NativeKeyShortcut("s"), icon = NsMenuItemImage.SystemSymbol(SFSymbolShapes.SQUARE_AND_ARROW_DOWN)) { } } Menu("View") { CheckboxItem("Show Toolbar", checked = showToolbar, icon = NsMenuItemImage.SystemSymbol(SFSymbolGeneral.SIDEBAR_LEFT), onCheckedChange = { showToolbar = it }) Separator() SectionHeader("Theme") RadioButtonItem("System", selected = theme == "System", onClick = { theme = "System" }) RadioButtonItem("Light", selected = theme == "Light", onClick = { theme = "Light" }) RadioButtonItem("Dark", selected = theme == "Dark", onClick = { theme = "Dark" }) } Menu("Badges") { Item("Inbox", badge = NsMenuItemBadge.Count(inboxCount), icon = NsMenuItemImage.SystemSymbol(SFSymbolGeneral.TRAY_FILL)) { } Item("Updates", badge = NsMenuItemBadge.updates(3), icon = NsMenuItemImage.SystemSymbol(SFSymbolArrows.ARROW_DOWN_CIRCLE)) { } } Menu("Window") { } Menu("Help") { Item("MyApp Help", shortcut = NativeKeyShortcut("?"), icon = NsMenuItemImage.SystemSymbol(SFSymbolStatus.QUESTIONMARK_CIRCLE)) { } } } // Window content... } ``` --- ## API Reference ### Composable | Function | Scope | Description | |---|---|---| | `NativeMenuBar { }` | — | Installs a native menu bar. Restores original on disposal. | ### NativeMenuBarScope | Function | Description | |---|---| | `Menu(text, enabled, mnemonic, content)` | Top-level menu. | ### NativeMenuScope | Function | Description | |---|---| | `Item(text, ..., onClick)` | Regular item with all NSMenuItem properties. Trailing lambda. | | `CheckboxItem(text, checked, onCheckedChange, ...)` | Toggle item. | | `RadioButtonItem(text, selected, onClick, ...)` | Radio selection item. | | `Separator()` | Separator line. | | `SectionHeader(title)` | Section header (macOS 14+). | | `Menu(text, enabled, icon, content)` | Nested submenu. | ### NativeKeyShortcut ```kotlin data class NativeKeyShortcut( val key: String, val command: Boolean = true, val shift: Boolean = false, val option: Boolean = false, val control: Boolean = false, val function: Boolean = false, ) ``` ### NativeKey Special key constants: `Escape`, `Return`, `Tab`, `Delete`, `Backspace`, `Up`, `Down`, `Left`, `Right`, `F1`–`F12`, `Home`, `End`, `PageUp`, `PageDown`. ### NsMenuItemImage | Variant | Description | |---|---| | `NsMenuItemImage.SystemSymbol(symbol)` | Type-safe SF Symbol from the `sf-symbols` module (macOS 11+). | | `NsMenuItemImage.SystemSymbol(name)` | SF Symbol by raw string name (macOS 11+). | | `NsMenuItemImage.Named(name)` | AppKit named image. | | `NsMenuItemImage.File(path)` | Image from file path. | The `sf-symbols` module provides **6 195** type-safe constants across 21 categories: `SFSymbolArrows`, `SFSymbolMedia`, `SFSymbolObjectsAndTools`, `SFSymbolStatus`, `SFSymbolGeneral`, `SFSymbolDevices`, `SFSymbolShapes`, etc. ### NsMenuItemBadge | Factory | Description | |---|---| | `NsMenuItemBadge.Count(n)` | Numeric badge. | | `NsMenuItemBadge.Text(s)` | Custom string. | | `NsMenuItemBadge.alerts(n)` | System-localized alerts. | | `NsMenuItemBadge.updates(n)` | System-localized updates. | | `NsMenuItemBadge.newItems(n)` | System-localized new items. | ### NsMenuItemState | Value | Description | |---|---| | `OFF` | No mark. | | `ON` | Checkmark (✓). | | `MIXED` | Dash (—). | --- ## Native Library Ships pre-built macOS dylibs (arm64 + x86_64). `NativeMenuBar` is a no-op on non-macOS platforms. - `libnucleus_menu_macos.dylib` — linked against `Cocoa.framework` - Minimum deployment target: macOS 10.13 - Newer APIs (badges, section headers, subtitles) degrade gracefully on older systems - All mutations dispatched on the main thread - Action callbacks routed back to Kotlin via JNI, then to Swing EDT ## ProGuard ```proguard -keep class io.github.kdroidfilter.nucleus.menu.macos.NativeNsMenuBridge { native ; static ** on*(...); } ``` ## GraalVM ```json [ { "type": "io.github.kdroidfilter.nucleus.menu.macos.NativeNsMenuBridge", "methods": [ { "name": "onMenuItemAction", "parameterTypes": ["long"] }, { "name": "onMenuWillOpen", "parameterTypes": ["long"] }, { "name": "onMenuDidClose", "parameterTypes": ["long"] }, { "name": "onMenuNeedsUpdate", "parameterTypes": ["long"] }, { "name": "onMenuWillHighlightItem", "parameterTypes": ["long", "long"] }, { "name": "onNumberOfItemsInMenu", "parameterTypes": ["long"] } ] } ] ``` --- # Freedesktop Icons Type-safe Kotlin constants for the [freedesktop Icon Naming Specification](https://specifications.freedesktop.org/icon-naming/latest/). Shared dependency for `notification-linux` and `launcher-linux`. ## Installation ```kotlin dependencies { implementation("io.github.kdroidfilter:nucleus.freedesktop-icons:") } ``` **Transitive dependency:** If you already depend on `notification-linux` or `launcher-linux`, `freedesktop-icons` is included transitively via `api()` — no separate dependency needed. ## Quick Start ```kotlin import io.github.kdroidfilter.nucleus.freedesktop.icons.FreedesktopIcon // Standard icon from the spec (typesafe) val icon = FreedesktopIcon.Status.DIALOG_INFORMATION val action = FreedesktopIcon.Action.DOCUMENT_OPEN val device = FreedesktopIcon.Device.PRINTER // Custom icon name, file path, or URI val custom = FreedesktopIcon.Custom("my-app-icon") val filePath = FreedesktopIcon.Custom("/home/user/icon.png") val fileUri = FreedesktopIcon.Custom("file:///home/user/icon.png") // Country flag (ISO 3166-1 alpha-2) val flag = FreedesktopIcon.flag("fr") // "flag-fr" ``` ## API Reference ### `FreedesktopIcon` Sealed interface. All subtypes expose a `value: String` property containing the icon name sent over D-Bus. | Type | Description | |---|---| | `FreedesktopIcon.Custom(value)` | Inline value class for arbitrary icon names, file paths, or `file://` URIs | | `FreedesktopIcon.flag(countryCode)` | Returns a `Custom("flag-")` icon | ### Icon Contexts All 338 standard names from the [freedesktop Icon Naming Specification](https://specifications.freedesktop.org/icon-naming/latest/) are available as enum constants, grouped by context. | Enum | Count | Examples | |---|---|---| | `FreedesktopIcon.Status` | 57 | `DIALOG_INFORMATION`, `DIALOG_WARNING`, `DIALOG_ERROR`, `MAIL_UNREAD`, `BATTERY_LOW`, `NETWORK_ERROR`, `SOFTWARE_UPDATE_AVAILABLE`, `WEATHER_CLEAR` | | `FreedesktopIcon.Action` | 94 | `DOCUMENT_SAVE`, `EDIT_COPY`, `MAIL_SEND`, `MEDIA_PLAYBACK_START`, `SYSTEM_SHUTDOWN`, `ZOOM_IN`, `WINDOW_NEW`, `APPLICATION_EXIT` | | `FreedesktopIcon.Device` | 27 | `PRINTER`, `PHONE`, `CAMERA_PHOTO`, `COMPUTER`, `DRIVE_HARDDISK`, `NETWORK_WIRELESS` | | `FreedesktopIcon.Emblem` | 13 | `DOWNLOADS`, `FAVORITE`, `IMPORTANT`, `SHARED`, `SYNCHRONIZED` | | `FreedesktopIcon.Emote` | 21 | `FACE_SMILE`, `FACE_SAD`, `FACE_COOL`, `FACE_ANGRY`, `FACE_WINK` | | `FreedesktopIcon.Application` | 20 | `UTILITIES_TERMINAL`, `SYSTEM_FILE_MANAGER`, `ACCESSORIES_TEXT_EDITOR` | | `FreedesktopIcon.Category` | 19 | `APPLICATIONS_GAMES`, `APPLICATIONS_INTERNET`, `PREFERENCES_SYSTEM` | | `FreedesktopIcon.MimeType` | 15 | `TEXT_HTML`, `IMAGE_X_GENERIC`, `VIDEO_X_GENERIC`, `APPLICATION_X_EXECUTABLE` | | `FreedesktopIcon.Place` | 9 | `FOLDER`, `USER_HOME`, `USER_TRASH`, `NETWORK_SERVER` | | `FreedesktopIcon.Animation` | 1 | `PROCESS_WORKING` | Icon names are **cross-desktop** — they are resolved from the active icon theme (Adwaita, Breeze, Papirus, Yaru, etc.) on GNOME, KDE, XFCE, and any freedesktop-compliant environment. **Browse all available icons:** Run `gtk4-icon-browser` or `gtk3-icon-browser` to visually browse all icons in your current theme. ## Usage in Notifications ```kotlin import io.github.kdroidfilter.nucleus.freedesktop.icons.FreedesktopIcon import io.github.kdroidfilter.nucleus.notification.linux.* LinuxNotificationCenter.notify( Notification( summary = "Download complete", appIcon = FreedesktopIcon.Emblem.DOWNLOADS, hints = NotificationHints( imagePath = FreedesktopIcon.Emblem.DOWNLOADS, ), ) ) ``` ## Usage in Launcher Quicklists ```kotlin import io.github.kdroidfilter.nucleus.freedesktop.icons.FreedesktopIcon import io.github.kdroidfilter.nucleus.launcher.linux.DbusmenuItem val items = listOf( DbusmenuItem(id = 1, label = "New Window", icon = FreedesktopIcon.Action.WINDOW_NEW), DbusmenuItem(id = 2, label = "Open File", icon = FreedesktopIcon.Action.DOCUMENT_OPEN), DbusmenuItem.separator(id = 3), DbusmenuItem(id = 4, label = "Quit", icon = FreedesktopIcon.Action.APPLICATION_EXIT), ) ``` --- # Native SSL JVM applications shipped with a bundled JRE use only the certificates baked into that JRE. Certificates added by the user, an enterprise IT policy, or a corporate proxy — such as a custom root CA or an inspection proxy — are invisible to the JVM, causing `SSLHandshakeException` failures on machines where those certificates are required. The `native-ssl` module solves this by reading trusted certificates directly from the OS trust store on all three platforms and merging them with the JVM's default trust anchors, producing a combined `X509TrustManager` that accepts both. ## Installation ```kotlin dependencies { implementation("io.github.kdroidfilter:nucleus.native-ssl:") } ``` ## Usage The entire API surface is the `NativeTrustManager` singleton. All properties are lazy and thread-safe. ```kotlin import io.github.kdroidfilter.nucleus.nativessl.NativeTrustManager // Ready-to-use X509TrustManager (JVM defaults + OS native certs) val trustManager: X509TrustManager = NativeTrustManager.trustManager // TLS SSLContext initialised with the combined trust manager val sslContext: SSLContext = NativeTrustManager.sslContext // SSLSocketFactory derived from sslContext val sslSocketFactory: SSLSocketFactory = NativeTrustManager.sslSocketFactory ``` Pass these directly to your HTTP client of choice. If you use OkHttp, Ktor, or `java.net.http.HttpClient`, the purpose-built integration modules below handle the wiring for you. ## Platform Details ### macOS Uses the Security framework via a JNI bridge (`libnucleus_ssl.dylib`, bundled in the JAR for `arm64` and `x86_64`). Two passes are performed: 1. **System anchor certificates** — `SecTrustCopyAnchorCertificates()` returns all Apple-shipped root CAs baked into macOS. 2. **User and admin domain** — `SecTrustSettingsCopyCertificates()` enumerates certificates with explicit trust settings. Each certificate is evaluated through `isTrustedRoot()`, which mirrors the logic from [JetBrains jvm-native-trusted-roots](https://github.com/JetBrains/jvm-native-trusted-roots): - Checks user trust settings domain first, then admin. - No trust settings found → live evaluation via `SecTrustEvaluateWithError`. - Empty trust settings array → always trusted (per Apple docs). - `kSecTrustSettingsResult` must be `TrustRoot`, and the certificate must be self-signed (DN equality **and** cryptographic signature verified against its own public key). - `kSecTrustSettingsPolicy`, if present, must be `kSecPolicyAppleSSL`. - Constraints with unknown keys are rejected to avoid silently misinterpreting stricter rules. ### Windows Uses Crypt32 via a JNI bridge (`nucleus_ssl.dll`, bundled for `x64` and `ARM64`). Scans five store locations across two store types: | Store type | What it includes | |------------|-----------------| | `ROOT` | Trusted root CAs — all certificates included unconditionally | | `CA` | Intermediate CAs — validated via `CertGetCertificateChain` + `CertVerifyCertificateChainPolicy(CERT_CHAIN_POLICY_BASE)` with cache-only revocation | Store locations scanned: `CURRENT_USER`, `LOCAL_MACHINE`, `CURRENT_USER_GROUP_POLICY`, `LOCAL_MACHINE_GROUP_POLICY`, `LOCAL_MACHINE_ENTERPRISE`. This covers certificates deployed via Group Policy and Active Directory that `SunMSCAPI` does not reach. Deduplication is performed via SHA-1 thumbprint (`CERT_HASH_PROP_ID`), the standard Windows certificate identity. If the JNI library fails to load, the module falls back to `SunMSCAPI` `KeyStore` (`Windows-ROOT`, `Windows-CA`, `Windows-MY`). ### Linux Pure-JVM implementation — no native library required. Reads PEM bundle files and per-certificate directories using the same discovery paths as Go's `crypto/x509`: | Path | Distribution | |------|-------------| | `/etc/ssl/certs/ca-certificates.crt` | Debian, Ubuntu, Gentoo | | `/etc/pki/tls/certs/ca-bundle.crt` | Fedora, RHEL 6 | | `/etc/ssl/ca-bundle.pem` | openSUSE | | `/etc/pki/tls/cacert.pem` | OpenELEC | | `/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem` | CentOS, RHEL 7 | | `/etc/ssl/cert.pem` | Alpine Linux | | `/etc/ssl/certs/` *(directory)* | SLES10/SLES11 | | `/etc/pki/tls/certs/` *(directory)* | Fedora, RHEL | | `/system/etc/security/cacerts/` *(directory)* | Android | Certificates are deduplicated by DER content across all sources. ## ProGuard The `native-ssl` module uses JNI native libraries on macOS and Windows. When ProGuard is enabled, the bridge classes must be preserved. The Nucleus Gradle plugin includes these rules automatically; if you need them manually: ```proguard -keep class io.github.kdroidfilter.nucleus.nativessl.mac.NativeSslBridge { native ; } -keep class io.github.kdroidfilter.nucleus.nativessl.windows.WindowsSslBridge { native ; } ``` ## Logging Debug messages are emitted under the tags `NativeCertificateProvider`, `NativeSslBridge`, `WindowsCertificateProvider`, `LinuxCertificateProvider`, etc. Logging is off by default. To enable it, set the global flag from `core-runtime`: ```kotlin import io.github.kdroidfilter.nucleus.core.runtime.tools.allowNucleusRuntimeLogging allowNucleusRuntimeLogging = true ``` --- # Native HTTP The native HTTP modules provide ready-to-use HTTP clients pre-configured with [`NativeTrustManager`](native-ssl.md) from the `native-ssl` module. They handle the SSL wiring so you can make HTTPS requests to hosts that use enterprise, corporate, or user-installed certificates without any manual trust store configuration. Three integration modules are available — pick the one that matches your HTTP client: | Module | Artifact | Client | |--------|----------|--------| | `native-http` | `io.github.kdroidfilter:nucleus.native-http` | `java.net.http.HttpClient` (JDK 11+) | | `native-http-okhttp` | `io.github.kdroidfilter:nucleus.native-http-okhttp` | OkHttp 4 | | `native-http-ktor` | `io.github.kdroidfilter:nucleus.native-http-ktor` | Ktor Client (engine-agnostic) | All three pull in `native-ssl` transitively — no need to declare it separately. --- ## `native-http` — java.net.http.HttpClient ### Installation ```kotlin dependencies { implementation("io.github.kdroidfilter:nucleus.native-http:") } ``` ### Usage ```kotlin import io.github.kdroidfilter.nucleus.nativehttp.NativeHttpClient // Option 1 — pre-built client val client = NativeHttpClient.create() // Option 2 — builder extension val client = HttpClient.newBuilder() .withNativeSsl() .connectTimeout(Duration.ofSeconds(30)) .build() ``` `NativeHttpClient.create()` returns a `java.net.http.HttpClient` configured with `NativeTrustManager.sslContext` and `followRedirects(NORMAL)`. The `withNativeSsl()` extension lets you compose it into an existing builder chain. > **Note:** `create()` enables redirect following by default. If you use `withNativeSsl()` directly, remember to call `.followRedirects(HttpClient.Redirect.NORMAL)` yourself — without it, HTTP 302 responses (common with GitHub Releases, CDNs, etc.) will be treated as errors instead of being followed automatically. --- ## `native-http-okhttp` — OkHttp ### Installation ```kotlin dependencies { implementation("io.github.kdroidfilter:nucleus.native-http-okhttp:") } ``` OkHttp 4.x is pulled in transitively. ### Usage ```kotlin import io.github.kdroidfilter.nucleus.nativehttp.okhttp.NativeOkHttpClient // Option 1 — pre-built client val client = NativeOkHttpClient.create() // Option 2 — builder extension val client = OkHttpClient.Builder() .withNativeSsl() .callTimeout(30, TimeUnit.SECONDS) .build() ``` `NativeOkHttpClient.create()` configures `sslSocketFactory` and `trustManager` on the `OkHttpClient.Builder` using `NativeTrustManager`. --- ## `native-http-ktor` — Ktor Client ### Installation ```kotlin dependencies { implementation("io.github.kdroidfilter:nucleus.native-http-ktor:") // Add exactly one Ktor engine: implementation("io.ktor:ktor-client-cio:") // CIO (coroutine-based) // or: ktor-client-java, ktor-client-okhttp, ktor-client-apache5 } ``` `ktor-client-core` is pulled in transitively. The module supports **CIO, Java, OkHttp, and Apache5** engines — add whichever engine you use and `installNativeSsl()` configures it automatically at runtime. ### Usage ```kotlin import io.github.kdroidfilter.nucleus.nativehttp.ktor.installNativeSsl import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO val client = HttpClient(CIO) { installNativeSsl() } ``` `installNativeSsl()` is an `HttpClientConfig` extension. It probes at runtime for the active engine and applies the correct SSL configuration: | Engine | Configuration applied | |--------|-----------------------| | CIO | `https { trustManager = NativeTrustManager.trustManager }` | | Java | `config { sslContext(NativeTrustManager.sslContext) }` | | OkHttp | `config { sslSocketFactory(..., NativeTrustManager.trustManager) }` | | Apache5 | `sslContext = NativeTrustManager.sslContext` | Engine JARs are `compileOnly` in `native-http-ktor` — only the one you declare at runtime is required. --- # Migration from org.jetbrains.compose Nucleus is a drop-in extension of the official JetBrains Compose Desktop plugin. All existing configuration is preserved — Nucleus 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("io.github.kdroidfilter.nucleus") version "1.0.0" } ``` > The official `org.jetbrains.compose` plugin remains — Nucleus extends it, not replaces it. ## Step 2: Update Imports Replace the JetBrains Compose DSL imports with the Nucleus equivalents: ```diff -import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import io.github.kdroidfilter.nucleus.desktop.application.dsl.TargetFormat ``` This applies to all DSL types used in your `build.gradle.kts` (e.g. `TargetFormat`, `CompressionLevel`, `SigningAlgorithm`, etc.). ## Step 3: Use the Nucleus DSL Replace the `compose.desktop.application` block with `nucleus.application` for packaging and distribution: ```diff -compose.desktop.application { +nucleus.application { mainClass = "com.example.MainKt" nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) packageName = "MyApp" packageVersion = "1.0.0" macOS { bundleID = "com.example.myapp" iconFile.set(project.file("icons/app.icns")) } windows { iconFile.set(project.file("icons/app.ico")) } linux { 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 `nucleus.application`. If you use [Compose Hot Reload](https://kotlinlang.org/docs/multiplatform/compose-hot-reload.html), either keep a minimal Compose block alongside Nucleus: ```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 Nucleus Features (Optional) Enable the features you need. All are opt-in: ```kotlin nucleus.application { mainClass = "com.example.MainKt" nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Nsis, TargetFormat.Deb) packageName = "MyApp" packageVersion = "1.0.0" // --- New Nucleus 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", ) // New Linux targets targetFormats( TargetFormat.Dmg, TargetFormat.Nsis, TargetFormat.Deb, TargetFormat.AppImage, TargetFormat.Snap, TargetFormat.Flatpak, // NEW ) // Publishing publish { github { enabled = true owner = "myorg" repo = "myapp" } } // NSIS customization windows { nsis { oneClick = false allowToChangeInstallationDirectory = true createDesktopShortcut = true } } } } ``` ## Step 5: Add Runtime Libraries (Optional) ```kotlin dependencies { implementation(compose.desktop.currentOs) // Executable type detection implementation("io.github.kdroidfilter:nucleus.core-runtime:1.0.0") // AOT cache runtime (if using enableAotCache) implementation("io.github.kdroidfilter:nucleus.aot-runtime:1.0.0") // Auto-update (if using publish) implementation("io.github.kdroidfilter:nucleus.updater-runtime:1.0.0") // Native taskbar/dock progress bar implementation("io.github.kdroidfilter:nucleus.taskbar-progress:1.0.0") } ``` ## What Changes | Feature | Before (compose) | After (nucleus) | |---------|-------------------|-----------------| | DSL entry point | `compose.desktop.application` | `nucleus.application` | | DSL imports | `org.jetbrains.compose.desktop.application.dsl.*` | `io.github.kdroidfilter.nucleus.desktop.application.dsl.*` | | 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 Trusted 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), Nucleus 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 `nativeDistributions` block: ```kotlin nativeDistributions { homepage = "https://myapp.example.com" } ``` This also applies to GraalVM native image packaging (`packageGraalvmDeb`). ## What Stays the Same Everything from the official plugin works unchanged: - `mainClass`, `jvmArgs` - `nativeDistributions` block (metadata, icons, resources) - `buildTypes` / ProGuard configuration - `modules()` / `includeAllModules` - All existing Gradle tasks (`run`, `packageDmg`, `packageDeb`, etc.) - `compose.desktop.currentOs` dependency - Source set configuration - [Compose Hot Reload](https://kotlinlang.org/docs/multiplatform/compose-hot-reload.html) — works as usual since Nucleus 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-nucleus-dsl))