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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ Deep dives (why a mechanism is built the way it is, measurements, E2E) live in [
- **Windows NSIS hot update** (every NSIS installer of a JVM app, per-user installs): the app never leaves the screen while it updates. PowerShell scripts are written with a UTF-8 BOM (`writePowerShellScript`). Layout, handoff, multi-instance model, E2E: [docs/internals/windows-nsis-hot-update.md](docs/internals/windows-nsis-hot-update.md)
- **Testing updates without publishing**: `nucleus.updater.feedUrl`, `nucleus.updater.simulate`, the `serveUpdateFeed` task and `updater-testing`'s `UpdateFeedServer`. Installed apps honour launch overrides only with `UpdaterConfig.allowLaunchOverrides`; an unpackaged run never installs: [docs/internals/updater-testing.md](docs/internals/updater-testing.md)
- **PKG has two channels** (`macOS { pkg { appStore } }`): App Store (sandboxed, never notarized) vs Developer ID (#249). Gate sandbox-sensitive features on `ExecutableRuntime.isSandboxed()`, never on `isPkg()`. Signing identity, install-script shims: [docs/internals/macos-pkg.md](docs/internals/macos-pkg.md)
- **GraalVM + Mac App Store**: a macOS App Store PKG of a GraalVM app is packaged from a second, sandboxed image (`nativeImageCompileSandboxed` → `createSandboxedGraalvmNativeDistributable`), built only when such a format is configured; the regular image is untouched. It runs without Substrate signal handling (`-R:-EnableSignalHandling`: the sandbox denies its named semaphore; `TerminationSignals` gives SIGINT/SIGTERM/SIGHUP their shutdown hooks back through libdispatch), ships third-party natives in `Contents/Frameworks`, and `GraalVmInitializer` points `user.home` at the container: [docs/internals/graalvm-app-sandbox.md](docs/internals/graalvm-app-sandbox.md)
- **Node.js is provisioned, not required**: the plugin downloads Node for electron-builder into `<gradle-user-home>/nucleus/nodejs`, so CI runs no `actions/setup-node`. Precedence, and the `universal-macos` mirror script: [docs/internals/nodejs-provisioning.md](docs/internals/nodejs-provisioning.md)
- **Flathub repackages the released `tar.gz`** (no source build, the closed-source path): inside a Flatpak the type comes from `/.flatpak-info` (not the archive's marker), Tao sets the Wayland `app_id` to `FLATPAK_ID`, and GNOME's `button-layout` is read through the Settings portal (GSettings only sees the app's keyfile there). Manifest, local repo loop, lint with Flathub's flags, screenshots: [docs/internals/flathub.md](docs/internals/flathub.md)
- **Windows signing covers the app image, not only the installer** (#748): `WindowsAppImageSigner` signs launchers and DLLs (loose and inside JARs) before electron-builder runs: [docs/internals/windows-signing.md](docs/internals/windows-signing.md)
Expand Down
99 changes: 99 additions & 0 deletions docs/internals/graalvm-app-sandbox.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# GraalVM native image under the macOS App Sandbox

A macOS App Store PKG of a GraalVM app (`graalvm { isEnabled = true }` + `TargetFormat.Pkg` with
`macOS { pkg { appStore = true } }`) is packaged from a **second, sandboxed image**, built beside
the regular one only when such a format is configured. The regular image, its command line and its
inputs are unchanged; DMG, ZIP and Developer ID PKG keep using it.

## Why a regular image cannot run sandboxed

Three things kill or cripple a regular native image under `com.apple.security.app-sandbox`:

1. **Signal handling.** At start-up Substrate's `SunMiscSupport` creates the named POSIX semaphore
`/cSunMiscSignal-<pid>`; the sandbox denies `ipc-posix-sem-create` and the image aborts before
`main`. The sandboxed image is compiled with `-R:-EnableSignalHandling` (a runtime option baked
as the default). Left there, SIGINT / SIGTERM / SIGHUP would take their default action and skip
the shutdown hooks, so `GraalVmInitializer` installs `TerminationSignals` for that bundle: the
`nucleus_locale` library receives the three signals through libdispatch (kqueue, no semaphore)
and calls back into Kotlin on a dispatch thread, which exits like HotSpot — shutdown hooks,
then status 128 + signal. `TerminationSignalsTest` checks it on a child JVM started with `-Xrs`
(HotSpot's handlers off, the same state). The handler is an empty `sigaction`, not `SIG_IGN`:
an ignored disposition survives `exec`, so every process the app starts would ignore SIGTERM
(`Process.destroy()` included); a handler is reset on `exec`, as HotSpot's are — the test
checks that too. What does not come back: `sun.misc.Signal.handle` for the app's own signals
(SIGUSR1, …), which needs Substrate's signal handling.
2. **`user.home`.** SubstrateVM reads it from the password database (`/Users/<name>`), where
HotSpot follows `HOME` — the container. Every `~/Library/…` path then lands outside the
container and is denied. `GraalVmInitializer` sets `user.home` from `HOME` when
`APP_SANDBOX_CONTAINER_ID` is present.
3. **Native code extracted at run time** (JNA's `jnidispatch`, zstd-kmp, compose-webview, …) is
quarantined inside the sandbox and refused by Gatekeeper behind a prompt. The JVM store pipeline
already solves this; the GraalVM one reuses it.

## Pipeline (`configureGraalvmApplication.kt`, `registerSandboxedMacGraalvmApp`)

| Task | Does |
|---|---|
| `stripGraalvmNativeLibsForSandboxing` | `AbstractStripNativeLibsFromJarsTask` on the stripped uber JAR: third-party native libs replaced by markers, their `System.load` call sites rewritten to `NucleusSandboxLoader`, redirect manifest written |
| `stageGraalvmSandboxedJars` | rewritten JAR as `app.jar` + `nucleus-sandbox-shim.jar` |
| `extractGraalvmNativeLibsForSandboxing` | the same libs, extracted at build time for the target arch |
| `nativeImageCompileSandboxed` | `-cp <shim> -jar app.jar -R:-EnableSignalHandling` into `graalvm/nativeCompileSandboxed` |
| `createSandboxedGraalvmNativeDistributable` | `AbstractAssembleSandboxedGraalvmMacAppTask`: `ditto` of the regular `.app`, sandboxed binary swapped in (build-version patch, rpath, `strip -x`), libs signed in `Contents/Frameworks`, manifest in `Contents/Resources`, provisioning profile embedded, ad hoc signature with the sandbox entitlements |
| `runSandboxedGraalvmNativeDistributable` | runs it |

`sandboxing.keepNativeLibsInJars` does not apply (warning): it matches dependency JAR names, and
the image is compiled from one uber JAR, so a substring would match nothing or keep every library
verbatim — all of them then extracted at run time, which the sandbox refuses.

The App Store PKG task packages that bundle and re-signs it with the real identity, like the JVM
store pipeline. Flatpak (the other sandboxed format) stays unsupported for GraalVM.

## Run time (`GraalVmInitializer.applyMacOsAppSandbox`)

The sandboxed bundle is recognized by `Contents/Resources/nucleus-sandbox-manifest.properties`
(a data file, kept out of `Contents/MacOS` / `Contents/Frameworks`, where Gatekeeper expects only
code). Then, without overriding any explicit setting: `Contents/Frameworks` joins
`java.library.path`, `nucleus.sandbox.manifest` points the shim at the manifest, and JNA is told
not to unpack (`jna.nounpack`, `jna.nosys`, `jna.boot.library.path`, `jna.library.path`) — the same
properties the sandboxed JVM launcher passes. Any other bundle, and any unsandboxed run, is left
untouched.

## Verified (Lab, macOS 27, arm64, GraalVM CE 25.4)

Launched through LaunchServices with the default sandbox entitlements: the Lab reports
`sandboxed`, `checks.json` lands in `~/Library/Containers/<bundle id>/Data/Library/Application
Support/NucleusLab` and survives a restart, and the four libraries extracted at build time load from
`Contents/Frameworks` — `libjnidispatch` (Jewel), `libzstd-kmp` (`system.zstd`: round trip
identical, the image resource is the marker), `libcompose_webview_macos` (`rendering.webview`),
`libMacTray` (`shell.tray`). No file access is denied (only `system-info vfs.disk-space`, the
`system.info` probe's own business). `packageGraalvmPkg` packages this bundle.

The copy taken from the PKG payload (re-signed with the hardened runtime, plus the
`.nucleus-executable-type` = `pkg` marker) behaves the same. The marker turns single instance on
(`enableSingleInstance = !isDev()`): the lock lands in the container's `tmp`, a second launch exits
and brings the first window forward. Its FSEvents watch logs one `file-read-data` denial per
ancestor of the container (`/Users`, `/Users/<name>`, …, `Containers/<bundle id>`) — harmless, the
watch works.

**Launch path** (`GraalVmInitializer.execDirOf`): on macOS the executable directory comes from
`ProcessHandle…command()`, the path the process was started with. It is canonicalized, so a CLI
symlink (`/usr/local/bin/app` → `…/Contents/MacOS/app`) finds the bundle's fonts and libraries —
before, every image launched through a symlink died with "Fontconfig head is null". A relative
path (`./app`) is resolved against `user.dir`, then `PWD`, keeping the one that names the file:
inside the sandbox `user.dir` is not the directory the process started in, and `PWD` goes stale
under a launcher that changes directory without updating it (Python's `subprocess` with `cwd=`).
Sandboxed, relative *and* with a stale `PWD` has no right answer left; LaunchServices never
starts an app that way. A sandboxed image launched through a symlink still dies, in
AppKit ("NSRemoteView encountered app without bundle"): a sandboxed app must run from its bundle,
as LaunchServices always does.

**Torture run** (sandboxed Lab): every probe opened (25 from the sidebar, 37 by `LAB_PROBE`) with
no crash or exception; the only denials are the sandbox's own business and identical on the JVM
(`AppleSMCClient` for temperatures, `locationd` without the location entitlement, `ps` being
setuid). 20 × SIGTERM, SIGINT + SIGTERM together, SIGTERM 0.3 s into start-up: same exit codes
as the regular image, never a hang. Five simultaneous launches leave one instance; a `kill -9`
leaves no stale lock; a bundle under a path with spaces and accents works.

**Memory**: the sandboxed variant doubles native-image work (two compiles, ~8.5 GiB each, run one
after the other). To iterate on packaging only, exclude both compiles:
`-x :app:nativeImageCompile -x :app:nativeImageCompileSandboxed`.
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import java.util.concurrent.ConcurrentHashMap
import java.util.logging.Logger
import kotlin.io.path.Path
import kotlin.io.path.appendText
import kotlin.io.path.createDirectories
import kotlin.io.path.deleteIfExists
import kotlin.io.path.exists
import kotlin.io.path.isDirectory
import kotlin.io.path.readLines
import kotlin.io.path.writeText

Expand All @@ -23,9 +25,25 @@ object LabPaths {
Platform.Windows -> Path(System.getenv("APPDATA") ?: home, "NucleusLab")
else -> Path(System.getenv("XDG_DATA_HOME") ?: "$home/.local/share", "nucleus-lab")
}
dir.createDirectories()
ensureDirectory(dir)
}

/**
* Creates [dir], first moving aside a plain file that sits where it should be (a botched copy, a
* restore gone wrong) — kept as `<name>.not-a-dir-<millis>` so nothing is lost — instead of
* failing every probe with `FileAlreadyExistsException`.
*/
internal fun ensureDirectory(dir: Path): Path {
if (dir.exists() && !dir.isDirectory()) {
val aside = dir.resolveSibling("${dir.fileName}.not-a-dir-${System.currentTimeMillis()}")
Files.move(dir, aside)
logger.warning("$dir was a file, not a directory: moved it to $aside")
}
return dir.createDirectories()
}

private val logger: Logger = Logger.getLogger(LabPaths::class.java.name)

/** A fresh scratch directory under [dataDir], for probes that need real files. */
fun scratch(name: String): Path = dataDir.resolve("scratch").resolve(name).createDirectories()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package dev.nucleusframework.lab.core.checks

import kotlinx.serialization.json.Json
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import java.util.logging.Level
import java.util.logging.Logger
import kotlin.io.path.exists
import kotlin.io.path.readText
import kotlin.io.path.writeText

/**
* `checks.json` on disk, shared by every Lab process of one user: two instances that run side by
* side (a dev run next to a packaged one, both sandboxed in the same container) each apply their
* change to the file as it is now, never to a copy loaded at start-up, so neither erases the
* other's results. Writes go through a temporary file and a move, so a reader never sees half a
* file.
*/
internal class CheckFile(
private val file: Path,
) {
/** The book on disk, or `null` when there is none or it cannot be read. */
fun read(): CheckBook? =
runCatching { if (file.exists()) json.decodeFromString(CheckBook.serializer(), file.readText()) else null }
.onFailure { logger.log(Level.WARNING, "Unreadable $file", it) }
.getOrNull()

/**
* Applies [change] to the book on disk ([fallback] when there is none) and writes the result.
* Returns the new book even when the write fails, which is logged.
*/
fun update(
fallback: CheckBook,
change: (CheckBook) -> CheckBook,
): CheckBook =
synchronized(this) {
val next = change(read() ?: fallback)
runCatching { write(next) }.onFailure { logger.log(Level.WARNING, "Could not write $file", it) }
next
}

private fun write(book: CheckBook) {
val temp = file.resolveSibling("${file.fileName}.${ProcessHandle.current().pid()}.tmp")
temp.writeText(json.encodeToString(CheckBook.serializer(), book))
try {
Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
} catch (_: AtomicMoveNotSupportedException) {
Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING)
} finally {
Files.deleteIfExists(temp)
}
}

private companion object {
val json = Json { prettyPrint = true }
val logger: Logger = Logger.getLogger(CheckFile::class.java.name)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,7 @@ import dev.zacsweers.metro.SingleIn
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.nio.file.Path
import java.util.logging.Level
import java.util.logging.Logger
import kotlin.io.path.exists
import kotlin.io.path.readText
import kotlin.io.path.writeText

@Serializable
enum class CheckStatus { Untested, Pass, Fail, Skipped }
Expand All @@ -44,8 +36,8 @@ data class CheckBook(
class CheckStore(
private val environment: EnvironmentProvider,
) {
private val file: Path = LabPaths.dataDir.resolve("checks.json")
private val state = MutableStateFlow(load())
private val file = CheckFile(LabPaths.dataDir.resolve("checks.json"))
private val state = MutableStateFlow(file.read() ?: CheckBook())
val book: StateFlow<CheckBook> = state.asStateFlow()

private val fingerprint: String get() = environment.snapshot.value.fingerprint
Expand All @@ -60,42 +52,27 @@ class CheckStore(
checkId: String,
status: CheckStatus,
note: String? = null,
) {
state.update { book ->
val env = book.results[fingerprint].orEmpty()
val probeResults = env[probe.value].orEmpty()
val previous = probeResults[checkId] ?: CheckResult()
val next =
previous.copy(
status = status,
note = note ?: previous.note,
epochMillis = System.currentTimeMillis(),
)
book.copy(results = book.results + (fingerprint to env + (probe.value to probeResults + (checkId to next))))
}
save()
) = change { book ->
val env = book.results[fingerprint].orEmpty()
val probeResults = env[probe.value].orEmpty()
val previous = probeResults[checkId] ?: CheckResult()
val next =
previous.copy(
status = status,
note = note ?: previous.note,
epochMillis = System.currentTimeMillis(),
)
book.copy(results = book.results + (fingerprint to env + (probe.value to probeResults + (checkId to next))))
}

fun reset(probe: ProbeId) {
state.update { book ->
fun reset(probe: ProbeId) =
change { book ->
val env = book.results[fingerprint].orEmpty()
book.copy(results = book.results + (fingerprint to env - probe.value))
}
save()
}

private fun load(): CheckBook =
runCatching { if (file.exists()) json.decodeFromString(CheckBook.serializer(), file.readText()) else null }
.onFailure { logger.log(Level.WARNING, "Unreadable $file, starting from scratch", it) }
.getOrNull() ?: CheckBook()

private fun save() {
runCatching { file.writeText(json.encodeToString(CheckBook.serializer(), state.value)) }
.onFailure { logger.log(Level.WARNING, "Could not write $file", it) }
}

private companion object {
val json = Json { prettyPrint = true }
val logger: Logger = Logger.getLogger(CheckStore::class.java.name)
/** Applies [transform] to the file as it is now, so another Lab process's results survive. */
private fun change(transform: (CheckBook) -> CheckBook) {
state.value = file.update(fallback = state.value, change = transform)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package dev.nucleusframework.lab.core

import java.nio.file.Files
import kotlin.io.path.isDirectory
import kotlin.io.path.listDirectoryEntries
import kotlin.io.path.readText
import kotlin.io.path.writeText
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class LabPathsTest {
@Test
fun `a file in the way of the data dir is moved aside, not fatal`() {
val parent = Files.createTempDirectory("lab-paths")
try {
val dir = parent.resolve("NucleusLab")
dir.writeText("not a directory")

val created = LabPaths.ensureDirectory(dir)

assertTrue(created.isDirectory())
val aside = parent.listDirectoryEntries("NucleusLab.not-a-dir-*").single()
assertEquals("not a directory", aside.readText())
// Idempotent once it is a directory.
assertEquals(dir, LabPaths.ensureDirectory(dir))
} finally {
parent.toFile().deleteRecursively()
}
}
}
Loading