Skip to content

Nucleus 2.6 - #628

Draft
kdroidFilter wants to merge 307 commits into
mainfrom
nucleus-2.6
Draft

kdroidFilter wants to merge 307 commits into
mainfrom
nucleus-2.6

Conversation

@kdroidFilter

@kdroidFilter kdroidFilter commented Aug 31, 2026 •

Copy link
Copy Markdown
Collaborator

Nucleus 2.6 vs current main. Tao becomes the only window backend. Two multi-window archetypes land on it: satellite palettes that dock, and Chrome-like tabs. Compose 1.12's window API v2 is supported through an AWT-free clone. Windows can drop their minimize and maximize buttons. nucleusApplication gains exitProcessOnExit. Cmd+Q and Dock → Quit go through each window's onCloseRequest. Native OS callbacks land on the host UI thread, not the AWT EDT. A stalled Windows event loop is reported instead of freezing silently. Recognized trackpad pinch reaches Compose as Scale events. nucleusApplication initializes FileKit and skips single instance in dev runs. Packaging provisions its own Node.js, ships Nucleus JNI libraries next to the app instead of extracting them, takes ANGLE from Maven (D3D11 only), and gains macOS .appex embedding, a Developer ID PKG with install scripts, a startup optimization pack, lastJdk, JRE fonts stripped from the runtime image, G1 in native images on Windows / macOS, and jlink / App Store PKG / NSIS fixes. Native libraries can extract to an app-chosen cache. FsWatcher shares one native watcher per instance and delivers macOS renames as Moved.

The satellite / dock / tab family is @ExperimentalNucleusApi and may still move.

Breaking: Tao is the only backend

#593 deletes decorated-window-awt, decorated-window-jbr, decorated-window-jni, and examples/jni-demo (~10k lines). nucleus-application now api-depends on decorated-window-tao: the backend is no longer a consumer choice, and a missing runtime would otherwise only fail at launch.

Gone from the public API:

  • NucleusBackend, LocalNucleusBackend, nucleusApplication(backend = …), NucleusApplicationScope.backend
  • NucleusWindowUnsafe.awtWindow / awtDialog
  • the receiver-less AWT overloads of MaterialDecoratedWindow / MaterialDecoratedDialog (M2 + M3) and JewelDecoratedWindow / JewelDecoratedDialog — only the NucleusApplicationScope receivers remain

Compose Desktop's AWT Window, Dialog and Tray are unsupported. Use DecoratedWindow, HostedWindow / HostedDialog, and an AWT-free tray.

core-runtime still has WindowBackend.Awt: it describes a plain Compose Desktop / Swing host that embeds Nucleus libraries. The plugin's jbr-api ProGuard keep and AOT unsealing stay, because an app can still ship jbr-api itself.

material2's dialog existed only in AWT form; it is now a NucleusApplicationScope receiver, so M2 keeps parity with M3. Dropping the AWT overloads also retires the LowPriorityInOverloadResolution / INVISIBLE_REFERENCE workarounds they needed.

NucleusWindowHost.Window is a public fun interface. minimizable / maximizable are new parameters on it (and on every DecoratedWindow / HostedWindow overload). Themed hosts that implement the interface have to take them; 2.6 already breaks that ABI.

Compose window API v2

#634. Compose 1.12's androidx.compose.ui.window.v2 is wired to AWT: Screen wraps a GraphicsDevice, WindowGeometryProviderScope takes a displayable java.awt.Window. Neither exists on Tao, and reflection is off the table in native-image. Accepting those types would mean every scoped geometry provider and requestScreen silently dropped. That is worse than no surface, so Compose's v2 types are not accepted anywhere.

The supported surface is dev.nucleusframework.window.tao.v2, a member-for-member clone (Screen, WindowState, DialogState, the three providers, savers, remember* factories) backed by TaoMonitors + TaoWindow. Migrating from the Compose package is one import; deleting the clone restores the upstream import if JetBrains ever decouples its types.

import dev.nucleusframework.window.tao.v2.WindowState
import dev.nucleusframework.window.tao.v2.rememberWindowState

val state = rememberWindowState()
DecoratedWindow(state = state, onCloseRequest = ::exitApplication) { … }

state.requestSize(DpSize(900.dp, 600.dp))
state.requestScreen(otherMonitor)

DecoratedWindow / DecoratedDialog / HostedWindow / HostedDialog gain overloads for the cloned states (no default state, so v1 call sites stay unambiguous). Every request is applied, requestScreen included. Observed bounds / screenId / placement / isMinimized republish from the native window on move and resize.

TaoMonitors enumerates displays without AWT (EnumDisplayMonitors / NSScreen.screens / GDK): physical px, top-left origin, work area, never empty. Linux enumeration is checked against the real gdk3 API and never aborts the process.

Size and position stay split (CombinedBoundsProvider). A DpRect cannot carry "let the WM place it" or a wrap-content axis without turning both into NaN. Wrap-content providers (Unconstrained, PreferredWidth / Height) re-measure continuously through the window's own wrap-content path.

Wrap-content placement (#674, #675, fixes #546). A window or dialog with Dp.Unspecified on an axis used to render at the right size and then sit wherever the creation fallback (800×600, or 0 dp) had been aligned. The initial Aligned is now remembered, skipped while wrap-content is unsettled, and re-applied after setInnerSize once the resize echo lands. A dialog with a parent recentres on that parent (macOS included); a parentless dialog centres on the screen, as AWT's setLocationRelativeTo(null). Once the measured size has landed, the scene column flips from wrapContentWidth(unbounded = true) to fillMaxSize(), so a TitleBar spans the window instead of measuring 0 px wide.

Known limits:

  • measureWindowContent before the window exists has no scene; it falls back to the current content size clamped to the constraints, then becomes a real ComposeScene.measureContent pass once mapped.
  • X11 window managers apply their own policy to a client's initial position (openbox lands at 0,0).
  • A maximized window is restored before requested bounds apply. On macOS the bridge never toggles zoom: itself (that re-zoomed the window); setting the frame un-zooms, then bounds are applied and confirmed. A placement-toggle storm followed by a bounds request now converges instead of leaving the window zoomed (#647).
  • Wrap-content is measured once, like Compose Desktop. Later content-size changes do not resize the window.

Satellites, docking, Chrome-like tabs

#635 then #663. Two archetypes on one shared core, declared from nucleusApplication so an app never has to reach for decorated-window-tao types.

Both families are @ExperimentalNucleusApi (dev.nucleusframework.window, lives in decorated-window-core). Opt-in level is ERROR. Library modules and the demos opt in module-wide; a new public satellite / dock / tab declaration must carry the marker.

Shared core (window/tao/workspace/)

Internal, not public:

Piece Job
WindowGroup membership, focus recency, pinning
RelocatedContentHost + RelocatingSaveableStateRegistry rememberSaveable state that follows content between windows
HostGeometry drop targets in physical screen px
CrossWindowDrag one live drag, screen-space handle
DragGhostWindow the preview that follows the pointer
ScreenPlacement TaoWindow.canPlaceOnScreen — the native-Wayland gate
TransferDrag the Wayland path of every cross-window gesture

Native Wayland

xdg-shell gives a client neither its windows' screen position nor a way to place them. GDK reports every toplevel at (0, 0) and ignores moves. Anything that treats outerBoundsPx()'s origin as a screen coordinate must check TaoWindow.canPlaceOnScreen (branch on that, not on isNativeWaylandSurface). The size half stays valid.

Where the app cannot place its windows, the gesture rides the platform drag-and-drop session — the only pointer grab that crosses windows with coordinates. The source starts a session carrying an in-process token (TaoPrivateTransfer, SAME_APP only). The window under the pointer resolves the drop in its own coordinates and records it on the session; the source acts on that record when the session ends. Roles are inverted versus the pointer path, because the source is told nothing about where the pointer is. The drag icon is a reduced snapshot of the dragged palette or panel (TaoWindow.contentSnapshot). DragGhostWindow(popupFor = source) is the preview that follows the pointer out of a compositor-placed window (wl_subsurface, parent-relative positions).

Chromium falls back to the same design on compositors without xdg-toplevel-drag, which GTK3 cannot reach. NUCLEUS_TAO_LINUX_RENDERER=x11 restores the window-following gesture.

SatelliteScope.isCompositorPlaced is the same answer for the window the chrome is composed in. SatelliteWorkspace.dragKind (Window / Transfer) says how a drag in flight is carried, which is what tells preview code whether dragGhost will ever be published.

Satellite windows

A satellite is a real native window that belongs to another one: it follows its parent, stays above it, hides with it, and can be reparented. SatelliteWindow + SatelliteWindowState cover the palette-attached-to-a-document case (WindowPositioner resolves the anchor).

SatelliteWorkspace is the workspace a tool app has: one palette serves whichever document is in front, and can be pulled into the document itself.

nucleusApplication(args) {
    val workspace = rememberSatelliteWorkspace()
    DecoratedWindow(onCloseRequest = ::exitApplication) {
        JoinSatelliteWorkspace(workspace)
        WindowScaffold(titleBar = { TitleBar { Text("Document") } }) { padding ->
            DockLayout(workspace, Modifier.padding(padding)) { Document() }
        }
    }
    Satellite(workspace, id = "tools", title = "Tools") { ToolsPanel() }
    Satellite(
        workspace,
        id = "colors",
        title = "Colors",
        initialPlacement = SatellitePlacement.Docked(DockSide.Right),
    ) { ColorPanel() }
}
  • The owner of the floating satellites follows keyboard focus between members, or is pinned with pinTo. When it closes, the next member takes over and the satellites move on without shifting on screen.
  • SatellitePlacement.Floating / Docked(side) chooses between an owned window and a panel inside the owner's DockLayout.
  • Modifier.satelliteDragHandle drags a satellite between the two. A panel dragged out is previewed by a borderless click-through ghost window.
  • snapshot() / restore() hand the whole layout to the app to persist.
  • rememberSaveable state inside a satellite survives the move between hosts.
  • A satellite outlives the window it is anchored to. Closed and reopened, it comes back where the user left it.
  • Closing a focused satellite hands focus back to its parent, not whatever Win32 would have picked next (which could belong to another application).
  • An Absolute position is applied before the window is shown, so a satellite no longer flashes at the WM's default spot.
  • Palettes are maximizable = false. A maximized satellite breaks anchoring and the drag-to-dock hit-test. On Linux, tao's set_maximizable is a no-op, so Super+Up is undone immediately.

On Wayland, a floating satellite's header carries the drag while a caption strip beside the window controls keeps the compositor's move — the split Chrome's tab strip and GIMP's dock tabs both land on. SatelliteCaptionStripWidth + the floatingCaption slot of Satellite are composed only where isCompositorPlaced, so an app never has to guess a width or accidentally claim the only area that can move the palette.

Dock layout

DockLayout is the dock. Sides nest in sideOrder (outermost first, default DefaultDockSideOrder = top, bottom, left, right — not DockSide.entries, whose declaration order is left, right, top, bottom). A side is either split (panels share its length by Docked.weight and its thickness by dockExtent(side)) or layered (layeredSides: each panel a full-length layer of its own Docked.extent, the shape of a nested split-pane tree without the tree).

SatellitePlacement.Docked carries its own extent and weight; both ride in SatelliteLayoutSnapshot and are driven by SatelliteWorkspace.setDockedExtent / setDockedWeight. Extents are fitted proportionally when the window is too small; the stored values come back with the room.

The splitter and panel slots hand the drawing to the caller. DockSplitterScope.dockSplitterHandle() carries the gesture, so a 1 dp divider with a wider overflowing grip works. DefaultSatelliteHeader no longer imposes a height or a background on a docked panel.

Sides are physical. The layout forces LTR internally, then restores the caller's direction for content, panels and slots, so DockSide.Left is the left of the screen in an RTL app and the splitters drag the right way.

Every panel and the content are movableContentOf: no layout change (extent, weight, order, side, restore, side order, direction) rebuilds a subtree, so a docked pane keeps its scroll position and its remembers. Inputs live in DockLayoutState as snapshot state because the bands are separate composables that strong skipping would otherwise skip.

Drop feedback is the target. DockZoneHints draws rectangles and publishes them to HostGeometry.zoneBoundsInWindowPx. dockTargetAt resolves a drop against those, not against the window's edges. On a layered side the strip is inset behind the existing layers; the window's own edge behind them is nothing. A zone is entered when the dragged satellite's edge is within one zone thickness of the zone's outer edge and overlaps it across the other axis (edge alignment, not overlap, or a full-height panel could never be torn out). The pointer inside a zone is a second trigger and the tie-break.

dockSides (Satellite(dockSides = …), default all four, empty = floating-only) is fixed at declaration and enforced everywhere: dock() and restore() refuse another side, hints neither draw nor publish it, drag sessions filter on it, and the default header hides its Dock action for a floating-only palette.

floatable = false is a fixed panel: undock() refuses it, a restore() that floats it is ignored, the docked drag publishes no tear-out ghost, a release off every zone leaves it in place, the default header drops its Float action, and the declaration requires a docked initialPlacement.

reorderable = false pins the rank: dock(order) is ignored (it takes the declared rank back), insertInStack pushes any other panel past the last pinned one, drop slots keep the forbidden ranks as empty so a slot's index is still its rank, and satelliteDragHandle is inert when a drag could not end anywhere.

Ranks. Docked.order is kept contiguous from 0 per (host, side). dock(order) inserts at that index; null is the rank the entry last held on that side (remembered in SatelliteEntry.dockMemory), else the end. A side with panels publishes DockDropZone.slots — one rect per rank, cut at the neighbours' centres, the dragged panel excluded — so DockTarget.order is the rank under the pointer. A pointer over a stack beats a strip across its corner.

minExtent / maxExtent (Satellite(...), default MinDockExtent..∞, #695) bound a panel's docked thickness: on its own layer, on a shared split side (thickest minimum, thinnest maximum, minimum wins), on the splitters, on dock() / restore(), and in the drop preview. The floating window is not constrained. restore() applies placements before extents.

dock() and the preview share one width (dockSeedExtent) and one weight (dockSeedWeight), so what lights up is what the release produces — a split side's landing rect is the stack at the thickness the side takes once the panel joins it.

Chrome-like tabs

Tabs are declared once with Tab. TabWindows composes one DecoratedWindow per group. Windows follow the tabs: a tear-off adds one, the last tab out closes one.

nucleusApplication(args) {
    val workspace = rememberTabWorkspace()
    TabWindows(workspace, onLastWindowClosed = ::exitApplication)
    for (document in documents) {
        Tab(workspace, id = document.id, title = document.name) { Editor(document) }
    }
}

TabWindows has two app slots, composed at one call site for every window, so a tab change neither rebuilds them nor moves the body's relocation keys:

  • windowWrapper wraps the whole window including its strip (per-window locals, background).
  • windowBodyWrapper wraps only what is under the strip — where window-level chrome goes (a DockLayout, activity bars).

Two drag paths. Where the app places its windows, the gesture is screenDragHandle → TabWorkspace.beginDrag (ghost window, screen hit-test, tear-off). A strip the card has reached counts as entered; the pointer's own strip still wins. Where it cannot (native Wayland), the grip is tabStripLocalDragHandle: a local reorder driven by the pointer's travel in window px, and the moment the pointer leaves the strip the gesture is handed to the platform DnD session (transferDragHandle). That handover is what gives every other window the pointer in its own coordinates. The tab slot carries noWindowDrag(): the title bar's move is a compositor grab that swallows the gesture.

TabStrip motion is a port of sh.calvin.reorderable's ReorderableRow state machine. Items are keyed on the tab id. A tab dragged along its own strip publishes no ghost; the strip draws it at the pointer's travel since the grab. A neighbour slides one tab-width aside (spring StiffnessMediumLow) when the carried tab's edge crosses its centre. On release the session sets pendingReorder instead of reordering — the strip's TabStripMotion.settle slides the tab into the target slot, then reorder() + rest() in the same frame, so nothing jumps. Offsets are draw-time graphicsLayer translations, so tabSlot geometry is always the settled layout. Tabs open/close by width (AnimatedVisibility, 200 ms). A hover card shows the tab under the pointer; a click a drag no longer swallows.

Direction. reorderTarget and insertionIndex are direction-aware (a right-to-left strip used to resolve every drop mirrored). Both take the direction the strip publishes on HostGeometry.layoutDirection and infer it from the slot order only without one: a single tab cannot tell, and a right-to-left single-tab strip resolved left to right flipped the index on every sample — two cards sliding about (#695). The drag ghost is laid out in the source strip's / dock's direction.

Customizable previews (#695):

  • TabEntry.thumbnail is app-assignable, so a restored layout can put its saved picture back (assign before the tab is shown; the recorder's capture replaces it once it is).
  • TabPreview(tab, modifier) is the one composable for a tab's thumbnail; the stock hover card is built on it.
  • TabWindows(dragGhost) draws the card under the pointer, TabStrip(dropGhostCard) the card in the landing slot. Both default to the now-public TabGhostCard(tab, modifier). TabDropGhost.tab carries the entry.
  • TabStrip(tabLeading, tabTrailing) are per-tab slots; the 6 dp gap is charged only when a slot draws something, so the stock chip is unchanged.
  • A tab is clipped to its slot only while it opens or closes, so the "+" no longer slides under a tab that appears at full width and a carried card is never cut.
  • WorkspaceDragKind (Window / Transfer) replaces SatelliteDragKind and is shared by SatelliteWorkspace.dragKind and TabWorkspace.dragKind. It is observable and reports Transfer only once the platform session exists.
  • TabWindows and DragGhostWindow are pinned to @ComposableOpenTarget(-1): the inferred target flipped with incremental compilation (Target-inferred nucleusApplication scope lets a callee's non-UI composable target flood the window scope #636).

One drop preview everywhere

The card that follows the pointer (SatelliteGhostCard / TabGhostCard on DragPreviewSurface) is also drawn on the space the release fills. The dock draws it at DockLayoutState.dropRectPx (empty side: the edge strip; layered: the layer at that rank; split: the share the re-divided weights give it). The tab strip opens a slot of the dragged tab's width (TabStripScope.dropGhost). The sides merely on offer are the same surface at hint intensity. No insertion bars, no drop-indicator lines. A custom strip draws dropGhost itself, as jewel-tabs-demo does with a placeholder TabData.Editor.

Pointer icons

Compose still only defines Default / Text / Hand / Crosshair in common code, and AWT-backed PointerIcon(Cursor(…)) is unusable on Tao. TaoPointerIcons adds Grab / Grabbing, Move, NotAllowed, Wait, Progress, Help and the two axis resizes as plain PointerIcon instances carrying a native cursor code.

Modifier.pointerHoverIcon(TaoPointerIcons.Grab)

Window chrome

#680 (fixes #504) then #684. DecoratedWindow / HostedWindow / NucleusWindowHost.Window gain minimizable and maximizable, both default true, next to resizable. resizable = false already removed the maximize slot; this is the other half of the caption, so a login screen can be close-only:

DecoratedWindow(resizable = false, minimizable = false) { … }

Both flags are snapshot-backed on TaoWindow and re-applied at runtime through the same LaunchedEffect as resizable. Restore stays available on a non-maximizable window, so one the WM maximized anyway can still leave.

minimizable = false maximizable = false
macOS yellow traffic light greyed; NSWindowStyleMaskMiniaturizable cleared (Cmd+M and the Window menu follow) zoom button disabled; Window > Zoom inert
Windows caption button gone; WS_MINIMIZEBOX dropped (taskbar click, Win+↓, system menu) caption button and title-bar double-click gone; WS_MAXIMIZEBOX dropped
Linux caption button gone. tao's set_minimizable is a no-op, so Super+H / Alt+Space still iconify caption button and double-click gone. tao's set_maximizable is a no-op; a satellite undoes Super+Up itself

DecoratedDialog passes both false. SatelliteWindow and workspace palettes pass maximizable = false.

LCD / ClearType on Windows

#626. Compose Desktop hardcodes grayscale as the Windows font-smoothing default (CMP-5359 / compose-multiplatform#875). ClearType is enabled end to end with no runtime reflection, under HotSpot, ProGuard, and GraalVM native-image.

  • Plugin. LcdTextDefaultTransform (artifact transform + ASM) patches FontRasterizationSettings.PlatformDefault in ui-text-desktop jars on non-test runtime classpaths — SubpixelAntiAlias on Windows. Compose layout drift fails the build: the patcher verifies the getter, the constructor descriptor, and the enum fields it references.
  • Tao. lcdSurfaceProps attaches the OS-queried pixel geometry (cached, RGB/BGR, grayscale on any unknown) to opaque Windows window surfaces only. Per-pixel-alpha surfaces (popups, NativeView overlay, Mica / Acrylic, transparent windows) keep unknown geometry so Skia falls back to grayscale.

Opt-outs: -Dnucleus.text.lcd=false (runtime) / -Pnucleus.text.lcd.patch=false (build, skips the transform).

Popup layers against the screen

#651, fixes #569. nativePopupLayers already exists on main. What 2.6 changes is that those layers behave like the OS surfaces they are.

  • Layers re-provide LocalWindowInfo so Popup.skiko.kt sees the work-area-sized box the design always intended, instead of the owner window.
  • popupScreenClampOffset clamps the native frame into the work area of the display it lands on, re-clamped on every push, so an open popup survives an owner drag across monitors. Dialogs must not follow the display: layers report the window size for dialogs and the work area for popups, discriminated on scrimColor.
  • A 32 dp draw margin past boundsInWindow so shadows and the slide-in are not clipped. A closing dialog keeps its surface where it was while it fades out (Dialog.skiko.kt reports a zero-size boundsInWindow at the window centre during the fade).
  • nativePopupLayers is now on JewelDecoratedWindow, which had no such parameter.

Native context menu flyout (opt-in per popup). NativePopupLayers { } is new: it provides, for its subtree only, the window scene's own LocalComposeSceneContext with createLayer routed to the native popup layer factory. A friend-package Java accessor (TaoComposeSceneContextAccess) reaches that internal local without reflection. The Windows and Linux flyouts wrap in it; macOS stays on NSMenu.

On Linux the menu maps as an xdg_popup instead of a wl_subsurface, so the compositor flips and slides it. One popup per parent takes that path; dialogs keep the subsurface. A second right click now moves the menu instead of only closing it. The flyout draws real CSS box-shadow layers with each OS's own declarations (libadwaita, Breeze, WinUI MenuFlyout at Translation.Z = 32), because Modifier.shadow multiplied the OS alphas by Compose's elevation factors and left a ~1 % darkening nobody could see. The Fluent menu is laid out as WinUI lays out a MenuFlyout.

Trackpad

Scroll and pan (macOS)

#661, port of #656 (#652–#654). Scroll deltas are AWT-shaped: preciseWheelRotation, no display scale, no extra X flip on a horizontal swipe.

Trackpad gestures reach Compose as PanStart / PanMove / PanEnd (panOffset = AWT delta × 10 dp). Wheel notches stay Scroll. Modifier.scrollable handles both. Custom handlers that only listen for PointerEventType.Scroll must also handle Pan, or the app can set -Dnucleus.tao.trackpadPanEvents=false to get AWT-style Scroll for everything.

Everything scroll-related enters the scene through TaoSceneScrollRouter (window host and macOS NSPanel popups). Native views get the whole pan, begin and end included. The phase wire (Rust SCROLL_GESTURE_*, popup_panel.m, TaoScrollGesturePhase) is guarded by TaoScrollWireDriftTest.

Pinch as Compose Scale events

#662, fixes #660. Platform-recognized pinch (AppKit magnify, GDK pinch, Windows / Linux Ctrl+wheel) used to be replayed as two synthetic PointerType.Touch contacts 120 px either side of the cursor: Compose re-recognized an already-recognized gesture (span / slop delay, ~13 % of pinch lost before any zoom), and a contact could land in neighbouring chrome (the MapLibre edge interruptions). It is now ScaleStart / ScaleChange / ScaleEnd at the cursor, scaleGestureFactor being the per-event ratio. Modifier.transformable and MapLibre consume it; a +1 % pinch zooms immediately.

  • detectTransformGestures does not see Scale events (Compose 1.12). Apps pinching through it need Modifier.transformable or a PointerEventType.Scale* listener.
  • Rotation still synthesizes two Touch contacts — Compose has no rotation event. The two models cannot overlap, so on macOS the gesture that begins first owns it. GDK reports pinch and rotation as one gesture, so on Linux a pinch opens as Scale and the rotation takes over once it has turned 10° with the zoom within ±10 %.
  • Linux rotation no longer runs backwards; the rotation contacts carry TaoTrackpadRotationContacts ids, which TitleBar ignores (a rotation over the bar started a compositor move); an interrupted rotation cancels pointer input before releasing the contacts, so it is no longer a tap.

Packaging

Node.js is provisioned, not required

#709 then #711. Every format except RawAppImage goes through electron-builder, which needed a node / npm on PATH. The plugin now downloads Node.js from nodejs.org (verified against the release's SHASUMS256.txt) into <gradle-user-home>/nucleus/nodejs, like the GraalVM and packaging JDK toolchains; the three share ToolchainDownloads, and parallel format tasks in one daemon no longer race on the install lock (#711).

nativeDistributions {
    nodejs {
        version = "22" // default; a major line, "lts", or a pinned release
    }
}

Precedence: compose.electronBuilder.nodePath, then NUCLEUS_NODE_HOME, then the provisioned install, then PATH (also the fallback when the download fails). A floating line is sticky once downloaded. electron-builder moves to 26.16.1. CI drops actions/setup-node everywhere; universal-macos provisions electron-builder the same way through provision-electron-builder.sh instead of npx --yes.

Nucleus JNI libraries ship next to the app

#712. Packaged apps carried every Nucleus native library inside its module JAR and extracted them on the first launch after an install or update. Only Nucleus libraries move — each nucleus.native-module module ships a META-INF/nucleus/native-libraries/nucleus.<module> manifest, and every other nucleus/native/ entry (an app's own library, a third party's) stays in its JAR.

  • jpackage: the target platform's libraries move into $APPDIR next to Skiko, other platforms' copies are dropped, and the launcher gets -Dnucleus.native.libraryPath=$APPDIR, which NativeLibraryLoader tries first. Only done when core-runtime's bundled-native-libraries marker is on the classpath (an older loader would not look there). The sandboxed pipeline keeps its layout.
  • GraalVM: the image is compiled from a copy of the uber JAR without those libraries, and they sit next to the executable (Contents/MacOS on macOS, stripped / patched / signed with the other dylibs).

Measured on Windows (tao-demo, 9 DLLs): ~40–90 ms of extraction on the first launch after an update, ~10 ms per warm launch.

ANGLE from Maven, D3D11 only

#707. The Windows ANGLE DLLs were extracted at build time from a pinned Electron release: 292 MB of download for 8.5 MB of DLL, carrying Vulkan, WGL, D3D9, SwiftShader and GLES1 backends Tao cannot reach. They now come from dev.nucleusframework:nucleus.angle-natives (8037.1, the Chromium branch), built from unmodified upstream sources of the branch stable Chrome ships with everything but D3D11 disabled. libGLESv2.dll x64 is 30 % smaller, arm64 is new, and gdi32.dll leaves the import table. A fresh worktree no longer lacks the DLLs. Dependabot is configured for Gradle and GitHub Actions, with ANGLE in a group of its own.

macOS app extensions (.appex)

#396, addresses #394. First-class embedding and signing of prebuilt .appex bundles (e.g. a Network Extension) into Contents/PlugIns/. Nucleus does not build the .appex (that stays Xcode / Kotlin/Native); it copies, signs, and seals.

macOS {
    entitlementsFile.set(file("packaging/app.entitlements"))
    appExtensions {
        extension("NetworkFilter") {
            appex(file("build/appex/NetworkFilter.appex"))
            entitlements(file("packaging/extension/NetworkExtension.entitlements"))
            // provisioningProfile(file("packaging/NetworkFilter.provisionprofile"))
        }
    }
}

Each extension is signed inside-out with its own entitlements and (optional) provisioning profile. The outer app is then sealed without --deep, so the nested signature is preserved. Wired on the JVM/jpackage path, the DMG/PKG re-seal (electron-builder), and GraalVM native images. Unused, the new paths are no-ops.

Follow-up from TestFlight validation: the jpackage path now copies with cp -R so the nested executable keeps +x (copyRecursively dropped the POSIX mode and launchd could not spawn the provider, errno 111). The plugin warns when an extension is embedded into an app whose entitlements still grant allow-unsigned-executable-memory / disable-library-validation (both in the default entitlements); a host carrying them alongside a network extension has been reported not to launch. com.apple.security.cs.allow-jit is enough for the JVM.

Caveats:

  • The extension only exists inside a signed .app. run cannot exercise it; use runDistributable.
  • Loading it at runtime needs an Apple-issued profile that grants com.apple.developer.networking.networkextension. Ad-hoc builds only prove bundling and signing.
  • GraalVM native images are always ad-hoc signed (the embedded extension is ad-hoc too); configure signing {} for a notarized GraalVM DMG.
  • Activating the extension (NEFilterManager / NETunnelProviderManager) is out of scope here.

Demo: examples/macos-appex-demo (minimal NEFilterDataProvider compiled to a universal .appex).

nucleusOptimization and lastJdk

#639. One-switch startup pack, with per-knob overrides. Does not enable AOT (enableAotCache is separate). Does not change the Gradle compile JDK.

nucleus.application {
    nucleusOptimization = true
    nucleusOptimization { idleGc = false }
}
Knob Default when the pack is on What it does
serialGc on Serial GC when garbageCollector is unset. An explicit collector always wins.
compactHeap on -Xms32m and -XX:MaxRAMPercentage=25, unless already in jvmArgs.
singleJar on Flatten runtime JARs (or joinOutputJars when ProGuard is on) so the jpackage image contains a single JAR.
idleGc on Request a GC 3 s after the last window loses focus, or immediately when a window is minimized. Wired through bindNucleusContent / bindNucleusDialogContent, so decorated windows, dialogs, tabs and satellites all get it. Also honored in GraalVM native images, through nucleus-app.properties (#715).
lastJdk on Package and run with the current OpenJDK feature release, auto-downloaded and cached under <gradle-user-home>/nucleus/jdk like the GraalVM toolchain. An explicit javaHome always wins.

lastJdk is pinned to OpenJDK 27 GA (build 35, https://jdk.java.net/27/). Intel macs and Windows ARM fall back to BellSoft Liberica JDK 27 (Oracle dropped those ports). Liberica Lite is not used: Lite has no jmods, so jlink cannot produce a runtime image (#669).

Developer ID PKG with install scripts

#688, closes #249. PKG was hardwired to the Mac App Store, so a PKG for Jamf / MDM or manual install was not buildable. macOS { pkg { appStore } } now picks the channel (default true, existing builds unchanged):

macOS {
    pkg {
        appStore = false
        preInstall.set(file("packaging/macos/preinstall"))
        postInstall.set(file("packaging/macos/postinstall"))
    }
}
  • appStore = false takes the DMG's non-sandboxed pipeline. electron-builder signs the installer with the matching Developer ID Installer certificate, handed the bare NAME (TEAMID) from signing {} (it prepends the certificate type and rejects a prefixed qualifier), a DSL keychain travels as CSC_KEYCHAIN, and notarizePkg notarizes the .pkg. A missing certificate would produce a silently unsigned package, so the task checks the result with pkgutil --check-signature.
  • preInstall / postInstall are staged for pkgbuild --scripts (shebang required). The App Store rejects install scripts (error 90254), so they require appStore = false, enforced at configuration time.
  • Scripts run once: electron-builder declares each script twice in PackageInfo, and Installer ran it twice on a real install. The staged preinstall / postinstall are shims that skip the per-bundle pass and exec the app's copy.
  • Runtime follows the sandbox, not the format: ExecutableRuntime.isSandboxed() (APP_SANDBOX_CONTAINER_ID). The scheduler gates on it instead of isPkg(), and a Developer ID PKG is self-updatable while the store build stays excluded. Build-side, the channel is macOS { pkg { appStore } } itself: nothing public replaces isStoreFormat, a build script reads appStore.

macOS.appStore and TargetFormat.isStoreFormat are deprecated at ERROR level rather than re-aliased: the former was ignored and defaulted to false, so an alias would have silently flipped existing builds to the other channel.

JRE fonts stripped from the runtime image

#694, port of JetBrains/compose-multiplatform#5706. createRuntimeImage passes --exclude-files=glob:/java.desktop/lib/fonts/**. Compose draws with its own fonts; on JetBrains Runtime 21 the image drops from 87 MB to 78 MB. Temurin and Liberica ship no lib/fonts, so nothing changes there. run and GraalVM images are unaffected. An app that renders text through AWT / Swing opts out:

nativeDistributions { stripJreFonts = false }

GraalVM 25i4

#698. The default innovation toolchain moves from 25i3 (GraalVM 25.3.4.1) to 25i4 (GraalVM 25.4.4.1.1, JDK 25.0.4.1.1). Roll back with graalvm { toolchain { version = "25i3" } }.

G1 in native images on Windows and macOS

#708. NativeImageGarbageCollector.G1 was gated to Linux. Oracle GraalVM ships the G1 libraries for Windows and macOS since 25.4, so the gate is now a version floor read from GRAALVM_VERSION in the toolchain's release file. 25.3 advertises --gc=G1 but dies at link time (g1gc-cr.lib); an unreadable version off Linux falls back to Serial. G1 stays Oracle-only.

App Store PKG is not notarized

#681, fixes #650. electron-builder was notarizing the macOS App Store PKG with the same credentials as the DMG. App Store packages must not be notarized; the plugin now emits notarize: false for that job. DMG / ZIP are unchanged.

jdk.jlink dropped from includeAllModules

#682, fixes #673. On a JEP 493 JDK (Temurin 24+ and other builds without jmods/), jlink refuses to emit an image containing jdk.jlink. nativeDistributions.includeAllModules = true copied java --list-modules verbatim, which includes it. AbstractJLinkTask now filters jdk.jlink out before --add-modules. A shipped app never needs jlink. The default module list never contained it, so default packaging is unchanged.

NSIS

#718. deleteAppDataOnUninstall now removes %APPDATA%\<appId> in both pipelines (a custom GraalVM imageName left it behind). package.json is regenerated on every packaging, so a packageName change reaches the installer name. Repackaging on Windows no longer fails with Cannot delete … after 5 attempts: the read-only flag of jpackage's launcher is cleared before deleting .app-image.

Native library cache

#686, closes #303. The extraction path was hard-wired (%LOCALAPPDATA%\nucleus\native, ~/Library/Caches/nucleus/native, $XDG_CACHE_HOME/nucleus/native). An app that keeps config, cache, logs and natives under one directory can now relocate it. Candidates are tried in order; a directory that cannot be created or written to falls through:

  1. -Dnucleus.native.cacheDir=<dir> (NativeLibraryLoader.CACHE_DIR_PROPERTY). Works for libraries loaded before any application code runs, and bakes into the launcher .cfg through existing jvmArgs. The JVM does not expand ${user.home}; a path computed at run time goes through cacheDirectory instead. Do not point this at the install directory or inside a macOS .app (read-only for a standard user; writing inside the bundle breaks the signature).
  2. NativeLibraryLoader.cacheDirectory, set from main() before the first native library loads.
  3. The platform default, unchanged.

The root is resolved once, at the first extraction. A later assignment is ignored with a warning naming the root in use. A configured directory is validated with a real write probe (create + delete a temp file), not Files.isWritable. The content-addressed layout of #304 (<root>/<platform>/<fingerprint>/<library>) is kept under the chosen root.

Packaged applications built by the Gradle plugin ship their natives next to the app and extract nothing (#712); this setting matters for fat JARs, IDE runs and distributions that bypass the plugin.

A set-but-empty or relative XDG_CACHE_HOME / LOCALAPPDATA used to make the cache root relative to the working directory. Those values are now ignored, as the XDG spec requires.

Filesystem watcher

#683, fixes #570 and #571. No public API change.

One native watcher per FsWatcher (#571). Every registration on that instance shares it: one inotify instance / FSEvents stream / ReadDirectoryChangesW loop, not one per path. Events are routed back by root in lib.rs. A root already watched is not re-watched (except non-recursive → recursive, done as unwatch + watch). The backend is created on the first watch() and dropped after the last unwatch().

The backend is handed the canonical root on macOS and Linux (one watch per real directory; inotify keys watches by inode and notify keeps one spelling per descriptor, so aliases must share it — Kotlin projects events back onto each registration's spelling). On Windows the registered spelling is used (canonicalize() yields \\?\ paths there).

macOS renames (#570). FSEvents reports an inode's accumulated flags: a rename of an old file arrives as Create+Rename+Modify on the gone path, which notify-debouncer-full used to fold into a bare Created(new); a delete of a pre-existing file surfaced as Modified. FsEventsNormalizer now fronts the debounced backend and drops what cannot be true of the path right now. Renames the backend cannot pair are Removed(old) + Created(new), never dropped. Raw delivery never emits Moved.

Application lifecycle

#676, fixes #667. nucleusApplication (and taoApplication) take exitProcessOnExit: Boolean = true, matching Compose Desktop's application(exitProcessOnExit).

After the last window closes, the JVM is terminated (exitProcess(0) on a normal quit, exitProcess(1) after a fatal error). That is still required because Compose/Skiko initialisation touches AWT, whose non-daemon EDT would otherwise keep the process alive.

exitProcessOnExit = false returns normally so the caller can continue in-process. A fatal error is then rethrown after the same SEVERE log.

fun main() {
    nucleusApplication(exitProcessOnExit = false) {
        DecoratedWindow(onCloseRequest = ::exitApplication) { /* … */ }
    }
    // continues here after exitApplication()
}

On Linux the closed window no longer stays mapped and frozen after nucleusApplication returns: the GDK and secondary X11 displays are flushed once the loop ends (#714).

System quit is cancellable (#696). Cmd+Q and Dock → Quit on macOS used to terminate immediately, bypassing the windows' close handlers. They now deliver onCloseRequest to every open Tao window, so the same save / confirm / cancel handling works on all three OSes. With no window open, the event loop exits. No new public API.

FileKit is initialized for you (#718). When FileKit is on the runtime classpath and the app has not initialized it, nucleusApplication calls FileKit.init(NucleusApp.appId), so dev runs, packaged JVM apps and native images share one data directory. FileKit is compileOnly; without it the call is a caught LinkageError, no reflection. Opt out with initializeFileKit = false.

Single instance is off in dev runs (#717). enableSingleInstance defaults to !ExecutableRuntime.isDev(), so a second debug instance, or one started while a packaged copy runs, is no longer forwarded and exited. Packaged builds are unchanged.

No system-theme polling (#716). Compose 1.12 polls the OS theme every second on Dispatchers.IO (ComposeUiFlags.pollSystemTheme). Nucleus already provides LocalSystemTheme from its reactive detector, so nucleusApplication clears the flag.

Native callbacks on the UI thread

#687. notification-linux, notification-windows, media-control, launcher-linux and launcher-macos posted their native callbacks with SwingUtilities.invokeLater. Under Tao the AWT EDT is not Compose's UI thread, and calling it initialises AWT in a process that may want none (graalvm { headless = true }).

  • NucleusUiThread (new, core-runtime) is the one marshalling point: an executor the backend registers, falling back to EventQueue.invokeLater when no Nucleus entry point ran. post always queues, never runs inline. The OS modules stay Compose-free; javax.swing is gone from all five.
  • Registered from TaoApplication.run, so a bare taoApplication host is covered too. The same place records WindowBackend.Tao, which a bare TaoApplication.run app used to report as Awt.
  • The KDoc that promised "the Swing EDT" now says the host's UI thread.

Verified on Linux against real D-Bus (notification close, MPRIS play, dbusmenu event): callbacks run on the Tao main thread. Without a Nucleus entry point they land on the same thread SwingUtilities.invokeLater would use, GUI or headless.

Event-loop watchdog

#710, closes #643. A deadlocked event loop produces no exception — to the JVM the thread is a healthy RUNNABLE in native code — so #640 froze the app in complete silence. TaoEventLoopWatchdog polls IsHungAppWindow every 2 s and, once a window has been hung past a grace period on top of Windows' own ~5 s, logs SEVERE with a full thread dump (event-loop thread first). The probe is a pure OS query: it sends nothing to the loop, unlike a WM_NULL probe, whose inline sent message is exactly the re-entrancy that deadlocked #640.

The shape is Electron's: the framework logs and raises callbacks, the "wait or quit" prompt is the app's to build.

nucleusApplication(args) {
    onUnresponsive { showWaitOrQuit() }
    onResponsive { hideWaitOrQuit() }
    expectUnresponsive { importHugeProjectSynchronously() } // not reported
}
  • Both callbacks run on their own nucleus-tao-watchdog-events thread — not the stuck UI thread, and not the sampling thread, so a blocking listener never delays detection.
  • Off under a debug agent. A poll that overslept by >10 s is read as a system suspend.
  • -Dnucleus.tao.watchdog=false disables it (=true forces it on under a debugger), -Dnucleus.tao.watchdogGraceMs=<ms> retunes it, -Dnucleus.tao.watchdogDialog=true also pops the native dialog.
  • Windows only: macOS has no public "not responding" query, and X11's _NET_WM_PING perturbs the loop it observes.

Tao follow-ups that are not on main

Resize present, macOS and Windows (#678, fixes #576). Animated WindowState.size and title-bar double-click zoom trembled and trailed the window edge. Each resize, Compose presented one to two frames behind the new bounds and the compositor stretched the stale drawable over them.

  • macOS: onResized presents a frame at the new size inside the resize dispatch (what prepareFullscreenFrame already did for Decorated window title bar content freeze when enter full screen mode #327), without pumping the dispatcher there. Vendored tao's set_maximized_async no longer uses the NSWindow animator: 16 ms steps eased over animationResizeTime:, a new request cancels the chain in flight. nativeResize anchors the stale drawable top-left instead of stretching. Idle zoom: 20 / 20 steps presented before the next arrived (was 2 / 20); median resize → present ~2 ms.
  • Windows: the same-turn present now uses swap interval 0 so flip-model DXGI replaces the queued frame instead of lining up behind it. The resize frame does not advance the frame clock (that would run the next animation step from inside the resize and starve the paced loop). Animated-height same-turn swap ~1.3 ms (was 5–8 ms, one step in two).

Edge double-click zoom, macOS (#693, follow-up to #678). AppKit's own animated frame changes — edge double-click (_zoomToScreenEdge:), Window-menu tiling, zoom: — run a private run-loop mode tao's observers never see, so the content replayed the whole animation in one burst at the end. The vendored TaoWindow overrides setFrame:display:animate: and routes every animated frame change through the #678 stepper, now the single engine (fullscreen transitions untouched). Resized events dispatched with the native frame elsewhere: 42 → 0.

Linux / Wayland resize (#689 then #691, closes #444).

  • Band of clear colour (fix(tao/linux): paint at the drawable's real size, not a predicted one (#444) #689). wl_egl_window_resize only records a pending size, so painting at the requested size put ~1 frame in 3 that many rows off. The paint size now comes from eglQuerySurface, not a prediction — and no desktop-environment check is left in that decision. On NVIDIA the driver reallocates mid-frame, so frames that pushed a resize first bind and clear the default framebuffer, then query (12 → 0 mid-frame reallocations). The scene keeps the window's size; only the render target follows the buffer, so layout no longer lags. Wrong-size frames on a maximize/restore storm: 16/64 → 0.
  • Content jumping on a left / top-edge drag (fix(tao/linux): commit the resize frame with GTK's geometry, not after it (#444) #691). Tao's GTK handlers only post to its event channel, so the Compose buffer rode the commit after GTK's geometry. During a resize burst the host now renders from a real draw hook on the GtkWindow (nativeConnectToplevelDraw, size from gtk_window_get_size) with the content sub-surface in set_sync and swap interval 0, and waits for the swap before returning. Mesa adopts a wl_egl_window resize only while no back buffer is acquired, so the resize is pushed before eglMakeCurrent. Frames with the right edge moving on a scripted left-edge drag: 82 → 0 (a plain GTK3 window: 0). In a native image the draw hook never connected (method looked up on an unregistered anonymous class) and was retried every frame; it now resolves on ToplevelDrawCallback and is tried once per window (#704).
  • The in-frame path is watched: a queue_draw unanswered for 50 ms falls back to the event-loop render path, it is never armed while maximized / tiled / fullscreen, and the content's opaque region leaves its bottom row out so Mutter never culls the toplevel (which froze GDK's frame clock and with it pointer motion).

Input-source indicator, macOS (#690). The badge a Caps Lock bound to layout switching raises is positioned from firstRectForCharacterRange:. TaoView answered a process-global caret rect nothing invalidated, so the badge kept appearing where a destroyed field used to be — or, before any field was focused, in the bottom-left corner of the screen. The rect is now scoped to the view that pushed it (NSZeroRect otherwise, the only shape AppKit reads as "no insertion point"), the NSTextInputClient overrides are installed per window creation, and the input context is deactivated when the session ends (guarded by its activation token).

Windows re-entrancy deadlocks (#646, ports #640 / #644). Three non-reentrant locks were held across a call that pumps or synchronously sends messages, so a nested window-procedure dispatch parked the event-loop thread in WaitOnAddress for good — the window went (Not Responding). Triggers: moving a window to another virtual desktop on Windows 11, Alt+Tab during startup, RDP disconnect, Explorer restarting (TaskbarCreated), end of an IME composition. tao 0.36.0 has the fixes; we vendor 0.35.0 and cannot bump (tao#1231 removed Windows subclassing, which our PATCH(nucleus) set lives in), so they are hand-ported. Shipped on 2.5 as v2.5.13.

GPU resource cache on macOS and Linux (#641). Windows already purges on main. The Metal host and the Linux GL host now run the same policy. Shared scene/GpuResourceCache.kt: the limit-toggle is the only primitive skiko gives us, and what actually reclaims is the purge (writing Skia's default 256 MiB budget was a no-op). macOS purges every 250 ms while sizes stream. Linux arms the purge in onResized and performs it in the render pass, the one point where this host's private EGL context is current. No settle purge and no System.gc() nudge on either: macOS and GTK have no drag-end signal, so a timer would fire after every size change.

NativeView / TextureView embed bugs (#659). The widgets already exist on main. New headful monkeys (click storms, resize storms, 150-action random walks) found and fixed:

  • NativeView disposed the platform view before detaching it (SIGSEGV); nativeViewHost() returned a new object per call.
  • Linux: a right click forwarded to an embed lost its release to the widget's context-menu grab; printable keys never reached a focused GTK embed; GTK's map-time default focus landed on the embed (two carets); the embed trailed its slot through a resize and, on Wayland, peeled off the Compose hole by a frame; nativeSetContentOffset committed GTK's toplevel out of band and aborted in cairo.
  • Windows: a press forwarded to an embedded child HWND was replayed to the owner; the child captured the mouse; keys kept going to an embed clicked into earlier; a child's modal loop froze the window because Windows derives MainEventsCleared from a WM_PAINT a modal loop never generates.
  • macOS: NSTextField.mouseDown: runs trackMouse:untilMouseUp: on the Tao thread, so a synthetic press never returned; nucleusIOSurfaceTextureSource now retains the IOSurface.
  • Native popup layer windows leaked when an owner closed during a Dialog's disappearance animation.

Window content stays on the UI applier (#649, port of #648, fixes #636). Window and dialog openers (including Satellite, SatelliteWindow, Tab) are @ComposableOpenTarget(-1) with @UiComposable content lambdas, so a non-UI composable called in the nucleusApplication scope — MapLibre Compose's rememberMapState was the reporter — cannot reclassify nested window content. COMPOSE_APPLIER_CALL_MISMATCH is escalated to an error in the two modules' test compilations; a ComposableTargetIsolationFixture compiles the reported shape.

JNI exceptions are logged before they are cleared (#677, fixes #486). Every JNI upcall path used to ExceptionCheck + ExceptionClear: a Kotlin listener that threw vanished, no JUL line, no stderr. Shared helper nucleus_jni_clear_exception in native-common/nucleus_jni.h now reports through JniExceptionReporter (JUL WARNING with the throwable — visible even when allowNucleusRuntimeLogging is off) and only then clears. All 34 native bridges go through it. NativeJniExceptionHygieneTest fails the build if a new ExceptionClear / ExceptionDescribe appears outside the helper.

launcher-linux never autolaunches the session bus. Without DBUS_SESSION_BUS_ADDRESS, GDBus spawned dbus-launch, which waited for an X display that never came. Nine of nine preMerge jobs that hit the 30-min cap were stuck in :launcher-linux:test. get_connection now treats "no address" as "no bus"; both native tests skip without one.

GDK's (0, 0, 1, 1) frame-extents placeholder is not published as a window's frame (tao patch 0007). TaoMonitor.isPrimary no longer throws under GDK's Wayland backend, which names no primary at all.

Windows redraws are served at the end of the batch, not inside it.

No internal onGloballyPositioned (#705, closes #560) in decorated-window-tao and nucleus-application. Geometry sites (NativeView, windowGlassRegion, caption hit zones, tabSlot, DockLayout, publishHostGeometry) use an internal Modifier.onPositionChanged that fires only on real moves while keeping the modifier's own coordinates; the drag sessions use onPlaced. Plain onLayoutRectChanged was not a drop-in: it reports the node's outer rect, which differs once a padding / offset precedes the modifier.

Headful screenshots are captured off the Tao thread (#658).

GraalVM native-test (#657): the test image is given the classes it was compiled against (Material 3 cases were dying with NoClassDefFoundError). The live-theme e2e is skipped on runners without an XDG portal (#703).

Demos

Example What it shows
examples/satellite-demo Floating palettes following the focused document, docking, drag-to-dock, layout snapshots
examples/tabs-demo Tear-off, merge, reorder, rememberSaveable state following a tab between windows
examples/jewel-tabs-demo The same tab workspace wearing Jewel's TabStrip / TabData.Editor chrome
examples/tab-satellites-demo Both archetypes composed: one SatelliteWorkspace per tab window, palettes drawing that window's selected tab
examples/reader-dock-demo The target layout of SeforimApp: seforim are tabs, every pane is a satellite. Layered right side with per-pane widths, sideOrder putting the right side outside the bottom one, 1 dp + 5 dp-grip splitters, hover headers, Classic / Islands styles, RTL. Book tree and contents are floatable = false + reorderable = false + dockSides = setOf(Right) — furniture, and no pane can be dropped in front of them. One dock per tab window hung on TabWindows(windowBodyWrapper), so the strip stays the top of the window and a tab change touches no panel.
examples/macos-appex-demo A macOS Network Extension .appex embedded and signed through macOS { appExtensions { } }

scheduler-demo and service-management-demo moved off AWT (java.desktop/sun.* add-opens gone; SMAppService completion handlers hop to the Tao main thread).

Migration

From 2.5 / current main:

  1. Depend on nucleus.decorated-window-tao. Drop backend = NucleusBackend.…. NucleusBackend and LocalNucleusBackend are gone.
  2. Replace window.unsafe.awtWindow / awtDialog and Compose Desktop's Window / Dialog / Tray with nucleusWindow, HostedWindow / HostedDialog, and an AWT-free tray.
  3. Window API v2: change the import from androidx.compose.ui.window.v2 to dev.nucleusframework.window.tao.v2. Compose's own types are not accepted.
  4. Satellites / dock / tabs: opt in with @OptIn(ExperimentalNucleusApi::class) or -opt-in=dev.nucleusframework.window.ExperimentalNucleusApi. The surface may change without a deprecation cycle.
  5. Branch Wayland-sensitive chrome on TaoWindow.canPlaceOnScreen (or SatelliteScope.isCompositorPlaced), not on the compositor name. Use floatingCaption for the strip the title bar leaves to the compositor's move.
  6. LCD/ClearType is on by default for opaque Windows windows. Opt out with -Dnucleus.text.lcd=false or -Pnucleus.text.lcd.patch=false.
  7. nucleusOptimization = true is opt-in. lastJdk downloads OpenJDK 27 (Liberica on Intel mac and Windows ARM) the first time a packaging or run task needs it.
  8. minimizable / maximizable default to true. Themed NucleusWindowHost implementations must take the new parameters. Dialogs and satellite palettes already pass the right values.
  9. exitProcessOnExit defaults to true. Pass false only if you need to continue in-process after the last window.
  10. Native cache: -Dnucleus.native.cacheDir= or NativeLibraryLoader.cacheDirectory before the first native load. Packaged plugin builds skip extraction.
  11. Custom PointerEventType.Scroll handlers on macOS must also handle Pan, or set -Dnucleus.tao.trackpadPanEvents=false.
  12. Experimental renames: SatelliteDragKind → WorkspaceDragKind, TabDropGhost.title → TabDropGhost.tab.
  13. PKG: macOS.appStore and TargetFormat.isStoreFormat no longer compile. Use macOS { pkg { appStore = … } } (a build script that branched on isStoreFormat reads appStore instead); gate sandbox-sensitive code on ExecutableRuntime.isSandboxed(), not isPkg().
  14. JRE fonts are stripped from jlink images. An app drawing text through AWT / Swing sets nativeDistributions { stripJreFonts = false }.
  15. Cmd+Q / Dock → Quit now call every window's onCloseRequest. A handler that never calls exitApplication (or closes its window) now vetoes the quit.
  16. Callbacks from notifications, media controls and the launchers run on the host UI thread (Tao's main thread), no longer on the AWT EDT.
  17. Pinch arrives as PointerEventType.Scale*, no longer as two synthetic touches. Code pinching through detectTransformGestures moves to Modifier.transformable or a Scale listener.
  18. Packaging no longer needs Node.js on the machine; drop setup-node from CI and cache ~/.gradle/nucleus/nodejs. Disable with nodejs { autoDownload = false }.
  19. Nucleus JNI libraries are no longer inside the packaged JARs. Code that read them as resources in a packaged app must load them through NativeLibraryLoader.
  20. enableSingleInstance is off in dev runs. Pass true to test single-instance behavior from run.
  21. FileKit is initialized with NucleusApp.appId. An app that called FileKit.init before nucleusApplication keeps its own configuration; one that calls it inside content replaces the automatic one. Opt out with initializeFileKit = false.
  22. Windows: a hung event loop now logs SEVERE. Hook onUnresponsive / onResponsive for UI, wrap known long synchronous work in expectUnresponsive { }, or disable with -Dnucleus.tao.watchdog=false.

Merged PRs

PRs that landed on nucleus-2.6 and are not already on main:

PR Title
#593 Retire the AWT/JBR/JNI window backends, leaving only Tao
#396 Embed & sign macOS app extensions (.appex)
#626 LCD/ClearType text on Windows
#634 Compose window API v2 through an AWT-free clone
#635 Satellite windows, a docking workspace, and Chrome-like tabs
#639 nucleusOptimization startup pack
#641 GPU resource cache on the macOS and Linux hosts (Windows already had it)
#646 Port the Windows re-entrancy deadlock fixes (#640, #644)
#647 Converge a v2 bounds request after a placement toggle storm
#649 Keep window content on the UI applier (#636)
#651 Place native popup layers against the screen (#569)
#657 Give the GraalVM test image the classes it was compiled against
#659 NativeView/TextureView headful monkeys and the embed bugs they caught
#661 Match AWT trackpad scrolling on macOS and surface Pan events (#656)
#663 Layered dock sides, per-panel sizes, app-owned dock chrome, ranks, dockSides / floatable / reorderable, in-hand tab reorder, one drop preview
#669 lastJdk Liberica fallback for Windows ARM
#674 Centre wrap-content windows and dialogs once measured (#546)
#675 Fill the scene once a wrap-content window is measured (#546)
#676 exitProcessOnExit on nucleusApplication (#667)
#677 Report pending JNI exceptions before clearing them (#486)
#678 Present on resize so content no longer trembles (#576)
#680 minimizable flag to drop the minimize button (#504)
#681 Stop electron-builder notarizing the App Store PKG (#650)
#682 Drop jdk.jlink from includeAllModules runtime images (#673)
#683 Shared native watcher, macOS renames (#570, #571)
#684 maximizable flag, off by default for satellite windows
#686 Configurable native library cache directory (#303)
#687 Marshal native callbacks to the host UI thread, not the AWT EDT
#688 Developer ID PKG with install scripts (#249)
#689 Linux: paint at the drawable's real size, not a predicted one (#444)
#690 macOS: keep the input-source indicator off a caret that is gone
#691 Linux: commit the resize frame with GTK's geometry, not after it (#444)
#693 macOS: step AppKit's own frame animations — edge double-click zoom (#576)
#694 Strip JRE fonts from the runtime image by default
#695 Customizable tab previews, pane extent limits, RTL drop fixes
#696 Cancellable system quit handling
#698 GraalVM innovation channel → 25i4
#700 Align DockLandingRectTest with the #695 split-side preview
#702 CI: run every Linux job on ubuntu-22.04
#662 Dispatch recognized trackpad pinch as Compose Scale events (#660)
#703 Skip the live theme e2e without an XDG portal
#704 Linux: resolve the toplevel draw callback on its interface (#444)
#705 Migrate internal onGloballyPositioned off the per-placement path (#560)
#707 Consume ANGLE as a Maven dependency, built for D3D11 only
#708 Allow --gc=G1 on Windows and macOS from GraalVM 25.4
#709 Provision Node.js in the plugin, electron-builder 26.16.1
#710 Report a stalled event loop instead of freezing silently (#643)
#711 Serialize toolchain provisioning across parallel tasks
#712 Ship Nucleus JNI libraries next to the app instead of inside its JARs
#714 Flush display connections after the Linux event loop returns
#715 Enable idle GC in GraalVM native images
#716 Disable Compose system theme polling
#717 Disable single instance by default in dev runs
#718 Auto-initialize FileKit and fix NSIS repackaging

xdg-shell gives a client neither its windows' screen position nor a way
to place them, so the screen-space drag the satellite and tab workspaces
are built on cannot work there: every window reports itself at (0, 0)
and every move is ignored, silently. The gestures now ride the platform
drag-and-drop session instead — the only pointer grab that crosses
windows with coordinates. The source starts a session carrying an
in-process token (SAME_APP only, so no foreign target ever sees it), the
window under the pointer resolves the drop in its own coordinates and
records it on the session, and the source acts on that record when the
session ends. Chromium falls back to the same design on compositors
without xdg-toplevel-drag, which GTK3 cannot reach: GDK3 exposes no
accessor for a window's xdg_toplevel and creates the drag's
wl_data_source inside gtk_drag_begin.

The drag icon is a reduced snapshot of the dragged palette or panel,
rendered through TaoWindow.contentSnapshot. On a floating satellite the
header strip carries the gesture while a caption strip beside the window
controls keeps the compositor's move — the split Chrome's tab strip and
GIMP's dock tabs both land on.

Two leaks found on the way:

- nativeLinuxHandles asked tao for the Xlib display handle, whose
  raw_display_handle_rwh_06 opens a fresh X connection per call and
  never closes it; polling the surface kind exhausted the server's
  client limit mid-suite. The kind is cached per window now, and the
  Xlib branch never asks for that handle.
- gtk_drag_begin's pointer grab is released by GTK handlers that run
  after ours, so returning as soon as drag-end fired left every window
  of the application deaf to the pointer for good. The session drains
  GTK's queue and ungrabs the seat before returning.

Covered by 26 headful cases on a real Wayland session — contract,
lifecycle, concurrency, bursts, churn, edge cases — plus unit tests for
the dock-zone math, the private payload and the transfer wiring, whose
completion callback is the only thing that ends a session.
There the title bar carries two gestures: the header strip drags the
palette into a dock over the platform DnD session, and the caption strip
beside the window controls starts the compositor's window move. Nothing
told them apart, so a press aimed at one landed on the other. The strip
is now a rounded, tinted chip inset in the bar, brighter on hover — the
same distinction Chrome's tab strip and GIMP's dock tabs draw, and for
the same reason. Everywhere else the whole bar drags and the header
stays flush, so the chip appears only where the split exists.
AppContext keeps its adapters in a Vec that adapter_index searches with
binary_search_by, but push_adapter appended to the end — a search is only
defined on a sorted slice, and adapters are created on the toolkit thread
yet registered from the AT-SPI worker, so a registration landing out of
order made every later lookup unreliable. Two call sites then unwrapped
that lookup, and the crate is built with panic = "abort", so the miss
took the whole JVM down:

  panicked at accesskit_atspi_common/src/adapter.rs:572:
  called `Result::unwrap()` on an `Err` value: 1
  ... finished with non-zero exit value 134

Reported on the tab-satellites demo, where several windows are created
at once, on both Wayland and X11.

push_adapter now inserts at the position the search looked for, so the
invariant holds, and the two unwraps skip the root announcement when the
adapter is not in the context instead of panicking — which is what
node.rs already does with the same miss (Error::Defunct). At worst one
AT-SPI "window created" event is missing; the application stays alive.

Not reproduced locally (nine runs under a client walking the tree), so
this comes from reading the code rather than from a failing case. The
patched library still exposes the demo's full AT-SPI tree.

The vendored AccessKit had no patch list; vendor/accesskit-patches/
starts one, following the tao-patches convention and the PATCH(nucleus)
markers at each site.
An Absolute position waited for `awaitMappedOnX11` before it was applied,
and the visibility effect shows the window meanwhile — so every positioned
window was mapped wherever the WM felt like and only then moved. On a
satellite that reads as the palette flashing at the screen's default spot
for a few hundred milliseconds before snapping beside its document.

The move is now issued before the show, which GTK and Win32 both carry
into the initial placement, and re-applied once the frame is real — that
still defeats the WM's own map-time placement and repairs an early move
that was lost, which is what the wait was there for.

Two anchoring bugs surfaced behind it:

  - a satellite declared inside its parent's content composes in the frame
    the parent window is created, so its anchor was resolved against a
    frame of size zero and latched at `parentLeft + gap` instead of
    `parentRight + gap`. It now waits for the parent to have a real frame,
    and `anchoredOriginPx` returns null for a frame with no size so a
    caller retries rather than latching;
  - a satellite stepping back in after its parent left a maximized or
    fullscreen frame was aligned against geometry the platform had not
    finished changing, and every move it made while hidden was skipped.
    The re-align now happens again on the first parent geometry that
    lands afterwards.

`satellite anchors to the parent's right edge and follows it` has been
failing on a real GNOME session for weeks and was recorded as an
environment quirk; it passes now.
`SatelliteWorkspace.close` documents that a satellite's placement is kept
until it is opened again, and it was — as the *rule* it was declared with.
A palette the user had dragged somewhere came back at its anchor instead,
and so did every palette after a `visible` sweep took them all down.

Where a satellite is now becomes its placement the moment its window
leaves composition, which is the last frame the live offset is known. The
dock path already did this through `currentFloating`; this is the same
capture on the way out of composition.
`SatelliteWorkspace.members` handed out the live observable list, so
`members == listOf(window)` was false even when that is exactly what it
held — Compose's list compares by identity — and the list could change
shape under a caller iterating it while a window opened. It returns a
snapshot now, like `TabWindowGroup.ids`, which documents the same trap.

`TaoMonitor.isPrimary` was false on every monitor under GDK's Wayland
backend, which names no primary at all, so `all().first { it.isPrimary }`
threw. Exactly one monitor now carries the flag: the one the platform
named, else the first — the fallback `TaoMonitors.primary` already
applied, moved to where the flag is produced.
The entry event carries a position, but only `onMoved` makes the scene
root resolve the target under it — so the target a file drag entered on
was not entered until the next motion event, and a platform that delivers
enter then drop with nothing in between found no target and refused
perfectly good files.

The three hosts also each resolved the scene's drop target from their own
expression. They now go through one lambda the window publishes, so an
in-process driver reaches `TaoSceneDnD` along the path the OS takes rather
than a parallel one that could drift from it — which is what makes the
inbound half of drag-and-drop testable at all.
The suite goes from 148 cases to 250. What is new, by theme:

  - **file drops** (14) — inbound drags driven through the same funnel the
    platform callbacks use, so the synthetic transferable, the Compose
    drag-and-drop tree and the app's own target all run: paths read back
    through `awtTransferable`, drops clear of every target, a target that
    refuses the drag, an empty payload, unreachable paths, 300 samples,
    20 drops back to back, a drop crossing a live tab drag, and one aimed
    at a window closing under it;
  - **the two archetypes composed** (18) — one satellite workspace per tab
    window, as in `examples/tab-satellites-demo`: a tab change redraws the
    palette without recreating it, a torn-off tab arrives with palettes of
    its own, a docked panel survives a tab change and outlives the tab it
    was drawing, and a tab drag and a palette drag run at once;
  - **the pointer** (16) — clicks, bursts of forty, sixty alternating,
    sub-pixel drift, a wobble under the touch slop, real drag / tear-off /
    merge gestures, the close button, the buttons that must do nothing,
    and a pointer that leaves the window mid-drag;
  - **placement** (4) — where a window is the first time it is *seen*,
    sampled every 8 ms and failed on dwell rather than presence: a frame
    of WM placement is nobody's problem, half a second is;
  - **window extremes** (18) — transparent, one pixel across, 120 sizes in
    a burst, an animation running through it, an embedded native view's
    rect, a texture view with no source and one signalled 500 times;
  - **load** (8) — four animating windows with palettes: no window
    starved, anchoring converging while the owner never stops moving;
  - **monitors and scale** (14) — the logical/physical round trips that
    are only wrong on a HiDPI desktop, plus display hops;
  - **races** (10) — background threads, coroutines on one group, a
    restore between a tear-off and its window, everything closed at once.

Two mechanisms make this possible where `java.awt.Robot` cannot inject at
all (a Wayland compositor refuses the portal session): pointer events are
posted through `TaoWindow.dispatch`, the same entry the native loop uses,
and inbound drags through the window's drop-target lambda. Everything
above the JNI boundary is the real pipeline — the sub-pixel deadband, the
resize-edge band, hit-testing, the touch slop, the drag handles.

Cases that need something this host cannot provide report it: a second
display, or a native handle no test module can fabricate on Linux.
- Increase global watchdog timeout to 15 minutes for large test matrix
- Normalize file drop paths using File.absolutePath on Windows
- Fix asynchronous recomposition and geometry races in tab and satellite tests
- Guard satellite window realign when unmaximizing parent
- Focus target windows before AWT Robot injection under Xvfb/Openbox and Windows
- Disable isAutoWaitForIdle in HeadfulRobot to prevent AWT event queue blocking
A cross-window gesture on native Wayland could freeze the whole
application: the pointer stayed stuck on the grab cursor and no window
received a click again.

`wl_data_device.start_drag` is validated against a still-pressed button
(Mutter's `meta_wayland_pointer_get_grab_info(require_pressed = TRUE)`).
With the button already up the request is silently ignored — no protocol
error, no grab, and so no `cancelled` / `dnd_finished` on the source.
GTK therefore never emits `drag-end`, and `start_outbound`'s cooperative
`while !done { main_iteration_do(true) }` spun for the rest of the
process's life. Tao's event loop never ran again, which is the freeze;
the pump kept painting, which is why the app still looked alive.

The gesture gets there whenever the client falls behind the compositor:
Compose crosses the touch slop on a queued motion that tao dispatches
after the real release was already delivered. Captured with
WAYLAND_DEBUG on tab-satellites-demo — the three working drags issue
`start_drag` with a press serial, the hanging one issues it 10 ms after
`wl_pointer.button(..., 0)`. A maximized window with satellites is the
easy way to see it, being the slowest to render.

Two guards, both in the Linux outbound path:

  - `buttons_held` reads the seat's button mask off GDK's own device
    state, and a session whose button is already up is refused before
    `gtk_drag_begin` — Compose sees a transfer that did not start and
    the gesture is a clean no-op.
  - the wait loop is now bounded past the release: once no button is
    held, a session that has not ended within a second is cancelled via
    `gtk_drag_cancel`, which runs the ordinary teardown. Never bounded
    during the drag, so holding a legitimate drag is unaffected.
Every other workspace case drives a gesture the way a user performs it —
begin, move, release, assert — which is how the intended behaviour is
pinned down, and also why those cases only ever visit states someone
thought of.

This one draws 200 actions from a seeded `Random` over one
`SatelliteWorkspace`: open / close a palette, dock / undock it, open and
destroy host windows, begin / move / end / cancel a drag, maximize,
resize, inject a scale change, flip the visibility sweep. Each action is
one call plus a 25 ms settle — it deliberately does not wait for a
steady state, because the states worth finding are the ones a gesture is
interrupted in. A quarter of the drag samples are what a coalesced flick
or a display unplug actually hands over: a point on no screen, or NaN.

What it asserts is not "the right thing happened" — for a random
sequence there is no such expectation — but that nothing is left
orphaned and nothing wedges:

  - the workspace never names a window it does not have: no member is a
    destroyed window, no satellite is docked into a non-member, no owner
    or pin points outside the membership;
  - no drag feedback outlives its drag, and no satellite keeps composing
    in two hosts;
  - native windows do not accumulate, and come back down to exactly the
    quiesced set at the end;
  - the closing phase asks for a plain state (visible, nothing docked,
    one window) and it has to converge — a workspace that survived the
    storm but can no longer be brought back is broken, it just fails
    later, in the app.

Deadlocks need a watcher that is not on the loop. The driver runs *on*
`Dispatchers.Main`, so if the Tao loop and the dispatcher ever wait on
each other the driver stops too and cannot fail its own case: the suite
would just hit its deadline with nothing said. `MainLoopWatchdog` posts
heartbeats from its own thread and dumps every stack the moment one goes
unanswered for 8 s, which is the whole diagnosis; a late one is reported
as the worst stall and fails the case. Each action also carries a short
5 s budget, so a wedged action names itself.

A native panic cannot be asserted at all — a Rust `panic!` across JNI
aborts the process and no Kotlin frame survives it. Reaching the end of
the case is the assertion, and the seed is what makes an abort
reproducible.

Failures carry the seed, the workspace state and the last 40 actions;
`-Dnucleus.tao.headful.monkeySeed=<seed>` replays the action sequence.
It does not replay the run — the state each action lands on depends on
what the loop got done in the milliseconds before it — so the journal,
not the seed, is what identifies a sequence to promote into a case of
its own. A green run prints what it reached and fails if it reached
nothing, so the case cannot quietly stop testing anything the day a
guard starts refusing early.
Two palettes docked on the same side, one of them taken out: the one
that stays comes back with the content of the one that left. Its own
body — its `remember`s, its scroll position, its saveable registry — is
the one that got destroyed.

`DockSideStack` composed its panels in a `forEachIndexed` with no key,
so Compose identified them by their position on the side. Going from
`[first, second]` to `[second]` is not read as "first left" but as "one
slot fewer": slot 0 is kept and slot 1 disposed, so `first`'s subtree is
recycled for `second` and `second`'s own subtree is the one thrown away.

Found by the seeded monkey (`-Dnucleus.tao.headful.monkeySeed=424242`),
which reached it as a palette still composing a panel in a window it had
already been undocked from, while the palette actually docked there
composed nothing at all. The instrumented trace names the two halves:

    +tools   docked@1#17          tools docked, panel index 0
    +outline docked@1#18          outline docked, panel index 1
    +tools   floating@11#19       tools undocked, floating window up
    -outline docked@1#18          outline's panel is the one disposed

`key(entry.id)` around each panel, in both orientations. `Satellite` and
`Tab` already key their own declarations; this is the level below them
that was missing one.

`panelsOnOneSideKeepTheirOwnSubtree` pins it deterministically rather
than leaving it to the monkey's luck: two palettes docked right, each
body publishing the id it was composed for plus a `remember` marker,
the first undocked, and the survivor has to answer with its own id and
its own marker. It fails without the key (`live bodies {0=second,
2=first}`) and passes with it.
`gdk_window_get_frame_extents` answers with its `(0, 0, 1, 1)` placeholder
until the window is mapped and, under a reparenting WM, framed. Tao reads
the extents only from its `configure-event` handler, so a configure that
lands inside that window pins the placeholder in `outer_position` /
`outer_size` until the *next* configure — which on a software-rendered X
server under a lightweight WM (Xvfb + openbox, i.e. the CI Linux leg) is
seconds away or never comes at all.

Every consumer then reads a 1x1 window at the screen origin, which is
where most of the Linux headful failures came from:

  - a tab torn into its own window measures "1.0 dp wide";
  - a satellite anchored against a 1px-tall child centres its *top* edge
    on the parent instead of its middle, and the follow logic preserves
    that offset for good;
  - `clientOriginPx` turns the placeholder into a negative screen
    coordinate, so every AWT Robot gesture aimed through it lands
    nowhere — eight cases timing out on "the drag started".

Fall back to the window's own frame origin plus its client size. Not to
`event.position()`: for a window a reparenting WM has framed, the
configure event carries coordinates relative to that frame, so using it
publishes every window at (0, 0) — which broke the window-v2 clone cases
on a real GNOME session.
`applyWindowOwnerRelationship` set `gtk_window_set_destroy_with_parent`
on every owned window. That is the JDialog behaviour a dialog wants and
the opposite of what a satellite wants: a satellite survives its owner,
because the workspace hands it to another member when that owner closes.

GTK took the toplevel down behind tao's back, leaving a live `TaoWindow`
with no GtkWindow: a satellite that reported no geometry, could never be
shown again, and — since `gtk_widget_show` re-realizes a disposed
`GtkApplicationWindow` — crashed the process inside
`gtk_application_window_real_realize`, dereferencing the menu sections
dispose had already cleared. A SIGSEGV in `g_menu_model_get_n_items`
with no GTK warning first; reproducible under Xvfb + openbox with the
satellite monkey.

`SatelliteWindow` now passes `destroyWithOwner = false`, and the show
path refuses a toplevel GTK has destroyed on its own — the same window
can still be reached through a `SetVisible` queued before the destroy.
Two ways the initial placement latched a wrong offset, both of them
permanent because the follow logic then preserves whatever it captured.

A frame of 1x1 is GTK's placeholder for "not laid out yet", not a size,
so `> 0` was the wrong test: anchoring against a 1px-tall satellite
centres its top edge on the parent rather than its middle. `reanchor`,
`captureOffset` and `anchoredOriginPx` all go through `hasRealFrame` now.

And one successful re-anchor is not enough. A window manager can report
a real frame at the origin and apply the requested position several
frames later — openbox under Xvfb takes tens of milliseconds, a loaded
desktop longer — so the satellite was anchored to a parent that was
never there and then trailed it by exactly the distance the parent moved
after the map. Keep re-anchoring until the parent's frame has held still
for three polls, which is the only signal a WM gives that placement is
done, and stop as soon as a reparent swaps the anchoring: the satellite
must stay where it is on screen when it changes owner.

Stepping back in after a maximized or fullscreen stint has the same
shape: re-showing the window races the single move the step-back path
issues, and when the move loses the satellite stays where it was before
it stepped aside. Re-assert the offset until it holds.
`clientOriginPx` put the whole outer/inner height difference above the
content. Win32's `GetWindowRect` includes the invisible resize border
below the content as well as beside it, so a pointer aimed through that
model lands one border too low — enough to miss the bottom of a tab, and
enough to shift every cross-window drop target. The bottom border is now
assumed to match the side ones, which is exact for a plain resize frame
and for a symmetric CSD shadow, and a no-op for a frame that adds
nothing horizontally.

`tearOff` had the mirror problem: it takes an outer frame and applied it
as a window *content* size, so the new window came out one chrome too
big. On Win32 that compounds — a tab dragged out, merged back and
dragged out again gained the resize border every round. Size it from the
source window's own frame-to-content difference instead.
Three ways the suite measured something that was not there yet.

Every "mapped with a real size" gate accepted GTK's 1x1 placeholder, so
a case proceeded against it and asserted on the placeholder: a torn-off
window "1.0 dp wide", a robot gesture aimed at a negative screen
coordinate. They all go through one `hasRealFramePx` now, which is `> 1`
for the reason the helper documents.

`composedIn` recorded one window per tab. A tab that leaves a multi-tab
window is composed in *both* windows until the window it left renders
again — Compose coalesces frames, so the two hosts genuinely overlap
while a frame is pending — and the arriving host overwrote the departing
one, whose disposal then erased the entry for a body that was still
composed. It is a list of hosts, published for the body's lifetime
rather than on every recomposition: a body that outlives its selection
for a frame never recomposes again.

`tab workspace never drops into a minimized window` ends with three
windows covering the same strip, so which of them answers a point on it
is decided by focus recency, not geometry. Wait for the platform to
grant the focus the case asks for instead of assuming it was granted.

The `#582` clipboard cases now skip when the window backend is forced
onto XWayland inside a Wayland session: `wl-copy` owns the Wayland
selection, so the two are on different clipboards and the case can only
time out. CI has no `wl-copy` at all, which is why only the X11 leg run
from a developer machine ever saw it.
… the aim

Two follow-ups to the placement settle.

It re-anchored on every poll until the parent's frame held still, which
kept overriding a placement someone else owns: a satellite reopened
where the user had dragged it came back at its declared anchor instead
(macOS and Windows), and a palette whose owner window moved while the
loop was still alive was re-anchored rather than left to follow. Only a
parent frame it has not been anchored against yet is worth another move,
and once the satellite has been placed the loop closes within ~190 ms —
long enough for a window manager's map-time placement, short enough to
be gone before the app moves anything.

The robot-driven headful cases now say where they aimed and where the
pointer actually went. Five of them still time out on the CI Linux
runner and pass under the same Xvfb + openbox locally; "the drag
started" never held is the same message whether the point was computed
against a frame the platform had not published yet or the press never
reached the window, and only the runner can tell us which.
The aim report already showed the pointer going exactly where the case
asked on the CI Linux runner, so the coordinates are right and the press
is not reaching the tab. The remaining two explanations are a client
origin that maps the right screen point to the wrong point inside the
window, and another window of the workspace sitting over it — so report
the frame, the content size, the client origin those imply, the slot,
and every group whose window covers the aim.
…ith it

A case that fails between its press and its release leaves the mouse
button down at the X server, and a `mousePress` on an already-pressed
button is a no-op. Every later robot case then computes the right point,
moves the pointer to it — the aim report proved that much — and receives
nothing at all: no MOUSE_DOWN reaches the window. Five red cases on the
CI Linux runner, four of them collateral, with nothing in the log to say
which one was the real failure. The suite lets go of every button after
every case now.

The one real failure was `workspace satellite dragged by its title bar`:
it grabbed 3 dp below the outer frame, which on a window whose frame adds
nothing above its content — Tao on X11, Win32 — is inside the 5 px
`ResizeFrameDecoration` band, so the press started a resize instead of
the workspace drag. It only ever passed where the frame has a title bar
of its own. The grab clears the band now; 3 px from the top edge of a
palette is a resize grip, and rightly so.

Under Xvfb + openbox, with the AWT Robot actually working (WAYLAND_DISPLAY
must be unset for the JDK to use XTEST rather than the RemoteDesktop
portal), the tab, satellite and workspace chunks are green: 155 cases.
Two cases were reading geometry the platform had answered but not yet
filled in.

A dock layout that has been placed once but is still a fraction of its
final size answers a point near its right edge with the *top* zone — the
nearest edge to its own centre is the top one when it is barely taller
than the inset the case aims with. That is the macOS "the superseded
drag stole the live preview: side=Top". Cases that aim at a named zone
now wait for a layout whose edges are far enough apart to be told apart.

`satellite placement` re-read the satellite's frame after sampling its
trajectory, so a frame the platform had taken away again by then read as
the screen origin and failed the case's own premise. The settled position
comes from the sampling now, and samples with no real frame are not
sampled at all.
`tab mouse click anywhere in a tab selects it` is the one robot case
still red on Windows, and which spot it fails at moves with the client
origin model — bottom edge before the frame's bottom border was
accounted for, top edge after. Guessing a Win32 frame from a rect is how
that happens; the case now prints the frame, the content size, the origin
they imply and the slot, so the next run answers it with numbers.
The frame-extents placeholder has a size *and* a position, and only the
size can be checked: `hasRealFrame` says nothing about (0, 0) being where
the window is or where GDK had nothing better to say. Two ways that got
in:

`outer_position` was falling back to a substitute whenever the extents
were the placeholder, and every candidate is wrong in its own way —
`event.position()` is frame-relative under a reparenting WM, `root_origin`
is implemented through `frame_extents` and answers the placeholder too,
and `gdk_window_get_origin` names the client rather than the frame, so
anchoring one window against another then mixes two rectangles. The size
still comes from the configure event, which is what fixes the 1x1 frame;
the position is simply kept as it was.

And a `Moved` carrying a position that is not the one the follow logic
asked for was taken for the user dragging the window, so the offset it
had just computed was replaced by one derived from an intermediate
frame — latched, and preserved from then on. While a move is in flight
those are ignored, bounded by the same echo budget as before so a move
the platform never confirms cannot make the satellite deaf to a real drag.
The geometry report answered the last red robot case on Windows:
`aimed (87, 31)` against `outer=[26, 26, 576, 389] content=560x381
client=(34, 26)`, i.e. 5 px below the top of the content — inside the
5 logical px `ResizeFrameDecoration` claims on a resizable window. The
press started a resize, not a selection.

A strip sits flush with the top of its window and the band is right to
be there: a press two pixels below a window's top edge is a resize grip
in every browser too. "Hard against the top edge" of a tab has to mean
the first pixel of it the tab actually owns, so the spot moves from an
eighth of the slot's height to a fifth.

It only surfaced once the client origin stopped putting a frame's bottom
border above its content — before that the whole case aimed one border
too low, which is what made its *bottom* edge the one that missed.
`CRobot.mouseEvent` segfaults the JVM on macOS when it is asked to
release a button that was never pressed — the whole headful run died on
its first case, before any robot case had run. The release after every
case is now armed by the press, so a suite where nothing touches the
robot never calls it.
Three defects the multi-window headful suite reproduces on Windows, all
of them visible in a real app long before a test notices:

Win32 only synthesises WM_PAINT once the message queue is otherwise
empty, so a window animating flat out — each frame posting the next
redraw as a queued user event — starves the paints of every other window
in the app. They stop being scheduled for good: 1358 frames for the
animating one against 3 for its neighbours, frozen until the animation
ends. On Windows a redraw request is now answered as the queued event it
already is, which puts every window on the same priority; OS-driven
repaints still arrive as RedrawRequested, and the JVM-side latch keeps
one request per frame.

A satellite created in the very frame its owner is being taken down
never hears that owner's closing announcement, so Win32 and GTK destroy
it along with the owner. The composable is still declared and its
remembered window is dead: the palette stays open, floating and
invisible for the rest of the session. It now rebuilds against whoever
owns it at that point.

A parent window is reported at the platform's cascade position until its
own WindowState is applied, so a satellite anchored to that frame is
placed beside a window that was never there — and the stale placement
its own WindowState carries lands *after* the settle loop's correction.
Satellites now wait for two identical parent frames before composing,
and the settle loop re-asserts the anchored offset until it holds.

Test side: awaitUntil takes an optional detail lambda read at timeout,
and the load and monkey cases use it to name the window that starved or
the palette that never came back.
Answering a redraw request where it arrives re-enters rendering from
inside the event batch, so `MainEventsCleared` — the tick that drains
`TaoMainDispatcher` — is never reached and everything scheduled on the
main dispatcher stops advancing. Collect the requests instead and serve
them once per batch, right after that drain: every window still paints
at the same priority, in request order, and a frame sees the work that
produced it.
The extremes probe emits its frame ticker as a sibling of the content in
the window's scene column, and a `fillMaxSize` there takes the whole
height with it: the content every geometry assertion is about was laid
out at zero, so the embed's rect and the composable's were compared
stale against stale — eight of the eighteen cases were measuring
nothing, and the embed storm failed outright on the one comparison the
collapse could not satisfy. The ticker is now the smallest node that can
draw, and `awaitSettledAt` asserts the content actually filled the scene
rather than trusting the scene's own size.
A drop is answered by the topmost dock layout under the pointer, and the
case's dialog is a dock host of its own whose default placement centres
it over the parent window. On a display small enough for the two to
overlap it sits astride the very zone the drags aim at and previews
*its* top edge — the case is about two drags racing, not about which
window is under them. It now parks the dialog off the parent's right
edge and waits until it is clear of both drop points before starting.
kdroidFilter and others added 30 commits September 24, 2026 23:08
A native image has no launcher .cfg, so the -Dnucleus.optimization.idleGc
flag never reached it and idle GC stayed off. The plugin now also bakes the
knob into nucleus/nucleus-app.properties; the runtime reads the system
property first and falls back to that resource.
fix(optimization): enable idle GC in GraalVM native images
…me-polling

fix(application): disable Compose system theme polling
…-default

fix(application): disable single instance by default in dev runs
nucleusApplication calls FileKit.init(NucleusApp.appId) when FileKit is on
the runtime classpath and the app has not initialized it itself. On Windows
this makes FileKit.filesDir the %APPDATA% directory the NSIS uninstaller
removes with deleteAppDataOnUninstall.

FileKit is compileOnly: an app without it hits a caught LinkageError.
Adds unit coverage and a process E2E (fileKitE2E).
- ensureProjectPackageMetadata no longer keeps an existing package.json, so
  a changed packageName reaches the npm name (installer name, NSIS app data
  dir) instead of staying stale.
- Clear the read-only flag before deleting .app-image: jpackage's launcher
  is read-only, the copy keeps it, and Windows refused to delete it, so
  every repackaging failed with "Cannot delete ... after 5 attempts".
deleteAppDataOnUninstall only removed the names electron-builder derives
from win.executableName and package.json. The GraalVM pipeline passes the
image name there, so with a custom graalvm.imageName the %APPDATA%\<appId>
directory (NucleusApp.appId, used for FileKit) survived the uninstall.

The generated NSIS include now also removes %APPDATA%\<appId> from
customUnInstall, under electron-builder's own condition (--delete-app-data,
or deleteAppDataOnUninstall outside an update). The protocol registration
shares that include, since NSIS allows one customUnInstall macro. Names
that are not plain file names are refused, so the RMDir can never target
%APPDATA% itself.
nucleusApplication(initializeFileKit = false) leaves FileKit untouched.
Defaults to true. Covered by a new optOut scenario in fileKitE2E.
Auto-initialize FileKit and fix NSIS repackaging
The app no longer disappears while it updates on Windows. NSIS installs of
JVM apps are laid out as App.exe + app\App.cfg at the root and
versions\<version>\{app,runtime}, so installAndRestart installs the new
version next to the running one, launches it, and exits once the new
version's first window is on screen.

- plugin: versioned app image layout and NSIS hooks that neither kill the
  running app nor delete its files when NUCLEUS_HOT_UPDATE=1
- updater: install while running, handoff on the new version's first frame,
  cleanup of retired versions, classic fallback when the hot path cannot start
- multi-instance: cross-process install lock, pendingRestartVersion /
  restartToInstalledVersion, explicit relaunchArguments
- fix: PowerShell update scripts are written with a BOM, so updates work for
  accented profile paths (classic path included)
- fix: the post-update event is only reported when its target version runs
- E2E: examples/hot-update-demo + scripts/windows-hot-update-e2e.ps1
HotKeyListener ran on the native thread that received the press (Win32
message loop, X11/portal thread, AppKit), unlike every other native-callback
module, which marshals through NucleusUiThread (#310). The listener is now
resolved inside the posted block, so a press still queued when unregister()
returns is dropped.
…read

fix(global-hotkey): deliver hotkey callbacks on the UI thread
NucleusWindow.withFileKitDialogSettings { } fills FileKitDialogSettings.parent
from the window's platform identity: HWND on Windows, x11:<xid> on X11, an
xdg_foreign export held for the dialog's duration on Wayland. macOS stays
unparented (FileKit 0.15 only accepts an AWT parent there).
…ot-update

# Conflicts:
#	plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractElectronBuilderPackageTask.kt
feat(application): parent FileKit dialogs to a Nucleus window
feat(updater): hot update for Windows NSIS installs
Compose 1.12.1 restores LocalSystemTheme as a deprecated public local typed
as Compose's own SystemTheme, so ProvideNucleusSystemTheme provides that
enum instead of Skiko's.
The generator writes the before-install/before-remove paths in double-quoted
YAML strings, which escape backslashes; the tests looked for the raw path and
failed on Windows hosts.
Feed redirect (nucleus.updater.feedUrl / NUCLEUS_UPDATER_FEED_URL) to a local
directory (LocalFileProvider), https or loopback http; UpdateSimulation for the
update UI; installed apps honour both only with allowLaunchOverrides, and an
unpackaged run never installs. The plugin forwards -Pnucleus.updater.* to run /
runDistributable, adds serveUpdateFeed, and makes every packaging output a
complete feed (manifest written without a publish provider, only this version's
artifacts, stale manifests deleted). New updater-testing module with
UpdateFeedServer, the fault-injecting loopback host the torture tests run on.
jpackage has no classpath option: it lists every file of --input, sorted by
name, after the main jar. When two JARs define the same classes, the packaged
app therefore loaded whichever sorted first, while ./gradlew run follows the
runtime-classpath order. Jewel apps hit it: the IntelliJ icon libraries pull
kotlinx-coroutines-core-jvm-1.10.2-intellij-2, which sorted before 1.11.0 and
failed at run time with NoSuchMethodError (Job.cancel$default).

The launcher .cfg classpath is now rewritten in classpath order right after
jpackage, before macOS signing and the Linux pathing-jar collapse. The
sandboxed strip task, whose output directory loses the order, records it in
.classpath-order for the package task.
jewel-demo, scheduler-demo and system-info-demo compile to class file 69 but
were packaged with the Gradle JVM's runtime, so the distributable died with
UnsupportedClassVersionError. Resolve a 25 toolchain as jewel-tabs-demo does.
feat(tao): forward back/forward mouse buttons to Compose
…order

fix: keep the Gradle classpath order in packaged launchers
feat(updater): test updates without publishing a release

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment