Skip to content

Nucleus 3.0 - #776

Draft
kdroidFilter wants to merge 796 commits into
mainfrom
nucleus-3.0
Draft

kdroidFilter wants to merge 796 commits into
mainfrom
nucleus-3.0

Conversation

@kdroidFilter

@kdroidFilter kdroidFilter commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Nucleus 3.0 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, jlink / App Store PKG / NSIS fixes, and SemVer pre-release versions on Windows and macOS. Native libraries can extract to an app-chosen cache. FsWatcher shares one native watcher per instance and delivers macOS renames as Moved. Windows NSIS installs update without closing the app, and updates can be tested without publishing a release, and a manifest file name can no longer escape the staging directory. A new Kotlin Multiplatform share module opens the system share sheet. Tabs can live in app-owned windows and cross between linked workspaces. Mouse side buttons reach Compose, and a macOS Control-click is a secondary click. FileKit dialogs attach to a Nucleus window. Kotlin 2.4.20 and Compose 1.12.1 are required.

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.
  • controlButtonsDirection (#733) puts a floating satellite's controls on the main window's side. An RTL app on an LTR system that passes ControlButtonsDirection.SystemNative to its main window had its satellites' traffic lights on the opposite side. Default Auto, unchanged.
  • 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).

Title bar slot (#731). TabWindows(titleBar = { strip -> … }) keeps an app's own title bar (a gradient, newFullscreenControls, ControlButtonsDirection.SystemNative). The default is the now-public DefaultTabTitleBar, the previous BasicTitleBar(FillCenter) { strip }. In nucleus-application the slot has a NucleusDecoratedWindowScope receiver; rememberNucleusScope() is extracted from bindNucleusContent so the title bar gets the Nucleus scope without running the window binding a third time.

TabWindows(
    workspace = workspace,
    strip = { MyStrip() },
    titleBar = { strip -> JewelTitleBar(gradientStartColor = accent) { strip() } },
)

App-owned tab windows (#732). TabWindows is now built from public parts, so an app can open its own window per group (state, title, key handling, close policy) and draw its own strip with the exact workspace behavior:

  • BindTabGroupWindow(workspace, group, window) attaches the window to its group and detaches it on dispose (closesOnQuit = false).
  • TabGroupWindowPlacement(group, state) applies the group's placementRevision (restore, tear-off) to an app-owned WindowState, never fighting a user drag.
  • ApplicationScope.TabDragGhostWindow(workspace, compositionLocalContext, content), plus NucleusApplicationScope.TabDragGhostWindow(workspace, nativeContextMenu, content).
  • TabStripDrag + TabStripScope.rememberTabStripDrag(reorderAnimation): carry along the strip, slide home, hand-over to another window.
  • Modifier.tabStripGrip(scope, drag, tab, index, cursor): slot, placement, noWindowDrag and the drag handle in one place. Modifier.tabStripCarry(drag, tab) is the carried offset.
  • TabGripCursor on tabStripGrip and TabStrip(gripCursor): Default keeps the open/closed hand, None keeps the strip's own pointer.

Linked workspaces (#734). An app with one TabWorkspace per virtual desktop lets a tab cross between them. TabWorkspace.linkedWorkspaces lists the other workspaces whose strips accept this one's drags (tried after its own, pointer drags only). onForeignDrop(tab, into, target) is called on release over a linked strip; the workspace leaves the tab in place, because only the app can declare it in the other workspace. With null (default), linked strips are no target. Where strips of two workspaces overlap, the drop follows the in-workspace rule (a strip under the pointer beats one the card only reaches, then the most recently focused window) on one process-wide focus clock.

Fixes: a tear-off after restore() never takes a restored group's id (the counter restarted with the process, so two windows could share an id, or pending tabs landed in the torn-off window) (#731). tabSlot ignores a tab that just left its group, which crashed the strip (found by a 400-operation random walk) (#732).

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.

Mouse buttons

Back and forward (#728). Side buttons reach Compose as PointerButton.Back / PointerButton.Forward on all three OSes. Windows and Linux used to send every extra button as the same code, macOS reported them all as Middle. TaoMouseButton gains BACK = 3 / FORWARD = 4 and OTHER moves from 3 to 5 (a const val: recompile code that reads it). Linux NativeView capture boxes map GDK buttons 8/9 instead of passing them as a primary click. TaoMouseButtonWireDriftTest guards the Rust / Kotlin codes. Native popup panels and the Windows NativeView blending overlay still drop side buttons.

Control-click on macOS (#737, fixes #736). A left press with Control held is the secondary button, in the window (TaoComposeSceneHost.onPointerButton) and in native popup panels (popup_panel.m). The choice is latched at the press, so the release names the same button even if Control is let go first. ContextMenuArea, text-field menus and isSecondaryPressed now see it.

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.

Classpath order in packaged launchers

#729. jpackage builds app.classpath from its --input directory sorted by name, so when two JARs define the same classes a packaged app loaded whichever sorted first, while ./gradlew run follows Gradle's order. Every Jewel app was affected: the IntelliJ icon libraries pull kotlinx-coroutines-core-jvm-1.10.2-intellij-2, which sorted before 1.11.0 and failed with NoSuchMethodError: Job.cancel$default. LauncherClasspathOrder rewrites the launcher .cfg classpath in Gradle's order (main JAR first) right after jpackage, before macOS signing and the Linux pathing-jar collapse. The sandboxed strip task records .classpath-order for it.

AWT fonts in macOS GraalVM bundles

#735. Any java.awt.Font use crashed in macOS native bundles (Problem reading font data / Fontconfig head is null); Jewel hits it at startup. sun.awt.fontconfig pointed at the binary fontconfig.bfc, which the JDK always parses as text properties, and resolveMacOsFontConfig looked in the wrong directory when execDir ended in /.. The plugin now ships the JDK's fontconfig.properties.src as Contents/Resources/fontconfig.properties, the path is normalized, and the Font.createFont(int, InputStream) substitution applies on macOS too.

Pre-release versions on Windows and macOS

#255. jpackage on Windows and macOS rejects SemVer pre-release / build metadata (2.3.5-beta.7 failed with invalid component [5-beta.7]), and every installer format is built from its app image. The plugin now strips the suffix wherever the platform needs a numeric version; no new DSL.

  • jpackage --app-version (Windows / macOS) gets the MAJOR.MINOR.PATCH core. Linux keeps the full version.
  • macOS CFBundleVersion falls back to the numeric version when packageBuildVersion is unset. GraalVM bundles do the same in Info.plist; the Windows .rc builds FILEVERSION / PRODUCTVERSION from the numeric version (2.3.5-beta.7 used to become 2,3,0,7) and keeps the full one in the strings.
  • electron-builder formats validate only the release part; electron-builder converts the suffix itself.
  • Every JVM and GraalVM caller resolves its version through packageVersionFor (format → OS → packageVersion → project.version → 1.0.0). The GraalVM Windows .rc used to skip project.version.
  • nucleus-app.properties uses the same lookup without the 1.0.0 default, so NucleusApp.version and the updater see the full pre-release even though jpackage.app-version is shortened.

Auto-update

Hot update on Windows

#720. On Windows, installAndRestart closed the app, ran the NSIS installer and relaunched it: ~13–15 s with nothing on screen. The app now stays visible for the whole update.

  • Versioned layout (every NSIS installer of a JVM app): App.exe + app\App.cfg stay at the install root, the runtime and the app move to versions\<version>\, so a new version installs next to the running one.
  • Install while running: the running launcher is renamed and copied back, and the installer runs with NUCLEUS_HOT_UPDATE=1, which makes the generated NSIS hooks neither kill the app nor delete its files.
  • Handoff: the old version releases the single-instance lock, launches the new one, and exits once the new version presents its first frame (UpdateHandoff.signalReady()). The new version deletes the retired one.
  • Failure handling: the classic update runs if the hot path cannot start; a failed installer leaves the app running; per-machine installs use the classic update.
  • Multi-instance (Chromium's model): installs are serialized by a cross-process lock; other instances see pendingRestartVersion and switch with restartToInstalledVersion(relaunchArguments).
  • Update scripts are written with a UTF-8 BOM (Windows PowerShell 5.1 read them as ANSI, so updates failed for accented profile paths, classic path included). consumeUpdateEvent / wasJustUpdated no longer report an update whose install failed.

New public API: UpdateHandoff, VersionedInstall, SingleInstanceManager.releaseForHandoff(), installAndRestart(file, relaunchArguments), pendingRestartVersion, restartToInstalledVersion(). GraalVM native images, MSI and portable builds keep the classic update.

Test updates without publishing a release

#725. Modeled on electron-updater's dev-app-update.yml and Squirrel / Velopack local sources.

I want to… Use What runs for real
build and review the update UI simulation: ./gradlew run -Pnucleus.updater.simulate=update nothing leaves the machine; install skipped
check + download against my next build feed redirect: -Pnucleus.updater.feedUrl=build/compose/binaries/main/nsis manifest, selection, download, SHA-512
update an installed copy to my next build feed redirect of the installed app + serveUpdateFeed everything, installer and restart included
  • UpdateSimulation (in code or nucleus.updater.simulate*) plays available / up-to-date / check-error / download-error / checksum-error, timed progress, differential and "just updated".
  • An installed app honours launch-time switches only with UpdaterConfig.allowLaunchOverrides. An unpackaged run always honours them and never installs.
  • serveUpdateFeed packages, then serves the manifests and artifacts with byte ranges on 127.0.0.1:8421.
  • New published module updater-testing: UpdateFeedServer with fault injection (FeedFault.Status / Delay / Throttle / Truncate / Corrupt / IgnoreRange).
  • Fixes: every packaging output now carries its latest*.yml (electron-builder wrote none without a publish provider); only artifacts of the packaged version are listed, and old manifests are deleted first (clients downloaded the previous installer or failed its checksum); GenericProvider accepts http://[::1].

Manifest file names

#740. The artifact's file name comes from the manifest's url field (remote input) and names the staged download, so ../victim.zip resolved outside the staging directory and the download + rename could overwrite any file the app can write. downloadUpdate() now throws ParseException for a name that is not a single path component (empty, ., .., / or \) before creating the staging directory or downloading anything. No legitimate manifest is affected.

Share sheet

#727. New share module (dev.nucleusframework:nucleus.share), the repo's first Kotlin Multiplatform library, inspired by robius-share. Rust only on desktop; Android, iOS and the web are pure Kotlin.

Platform Share UI
Android Android Sharesheet (ACTION_SEND / ACTION_SEND_MULTIPLE)
iOS UIActivityViewController
macOS NSSharingServicePicker (Rust, JNI)
Windows Share UI, DataTransferManager desktop interop (Rust, JNI)
Linux XDG portal: "Open With" for one file or URL, "save files" for a mixed payload, xdg-open fallback (Rust, JNI)
Web (js, wasmJs) Web Share API
val window = LocalNucleusWindow.current
scope.launch {
    window.share(anchor) {
        title = "Share"
        text("Cross-platform native share sheet")
        url("https://github.com/NucleusFramework/Nucleus")
        file("/path/to/report.pdf", mimeType = "application/pdf")
    }
}
  • ShareSheet.share is suspend and safe from any dispatcher. It reports neither the picked target nor a cancel. Errors are ShareException(ShareError), validated in common code first.
  • NucleusWindow.share (nucleus-application, compileOnly on nucleus.share) resolves the parent: HWND, NSWindow (picker pointing at shareAnchor(density) or the top centre), x11:<xid>, or a Wayland xdg_foreign export released once the portal dialog closes.
  • Android serves staged copies through its own NucleusShareProvider (${applicationId}.nucleus.share, no AndroidX). The web needs a secure context and a user gesture, and shares blob: / data: / same-origin URIs.
  • nucleus.native-module wires jvmProcessResources for KMP modules; the root build wires per-source-set detekt tasks into check.

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.

FileKit dialogs attached to a window (#722). NucleusWindow.withFileKitDialogSettings { settings -> … } fills FileKitDialogSettings.parent from the window: HWND on Windows, x11:<xid> on X11, an xdg_foreign export on Wayland (closed when the block returns or throws). macOS needs none: FileKit's panels are app-modal. filekit-dialogs is compileOnly. FileKit moves to 0.16.0.

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.
  • global-hotkey (#721) invoked HotKeyListener on the native thread that received the press. Its three bridges now post to NucleusUiThread and resolve the listener inside the posted block, so a press still queued when unregister() returns is dropped.

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.

Dependencies

#723. Kotlin 2.4.20, Compose 1.12.1, Jewel 0.41.0, Ktor 3.6.0, Coil 3.6.3, materialkolor 5.0.1, composenativetray 2.1.6, composewebview 1.0.3, BCV 0.18.2, GraalVM native build tools 1.1.14. Compose 1.12.1 types LocalSystemTheme as its own SystemTheme; ProvideNucleusSystemTheme provides that enum, so isSystemInDarkTheme() still reads Nucleus's reactive detector.

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).

A window created maximized restores to its floating size, macOS (#733). applyMacOsInitialMaximizedSize built the native window at the work area, which tao keeps as the frame to restore to, so leaving maximized gave a screen-sized window pushed down by the title bar (0,-32 1710x1073 → now 155,105 1400x900). The native window is built at the requested size; WindowState still holds the work area for the first frame.

Unplaceable outer positions are rejected (#738). TaoWindow.setOuterPosition requires a finite position within ±1,000,000 dp and throws IllegalArgumentException naming the values. On macOS such a value (a sentinel like Int.MIN_VALUE) reached setFrameTopLeftPoint:, AppKit raised, and with panic = "abort" the process died with only panic in a function that cannot unwind.

Drag ghost off every display (#759). The ghost's position (pointer − grab offset) could land past the ±1,000,000 dp guard of #738 when the pointer left every display, and setOuterPosition threw on the Tao main thread. DragGhostWindow clamps its pointer-driven position (clampOuterPosition()); app-requested positions keep the strict guard.

Wrap-content windows settle even when the scene re-measures (#759, #546). A re-measurement during the resize echo cancelled the effect before WindowSizePolicy.settled, so the scene kept wrapping (TitleBar 138 px in a 300 px scene) and the initial centring was lost. The wait, settled and the centring now run in a separate effect keyed on the once-recorded resolved size.

Headful suite runs to the end on every OS (#758, #759, #760). The #576 zoom present-lag case is skipped on Linux (the GTK host records no presented size); the #569 dialog-appearance and popup-placement cases no longer depend on the sampling clock, the wallpaper or one large display; the suite watchdog goes from 15 to 45 min (macOS never finished) and the job limit to 60 min.

One TaoGpuRenderContext per surface (#752). rememberTaoGpuRenderContext() promised an identity-stable instance but built one per call site, so filament-compose created one engine per 3D view instead of one per window. The context is memoized per texture host (weak keys and values); a rebuilt GPU context still yields a new instance. In Zayit: 21 → 6 Filament engines, new-tab freeze 210–250 ms → 75–125 ms.

XInitThreads before GTK opens its display, Linux (#744). On libX11 < 1.8 (Ubuntu 22.04) Xlib only locks connections opened after XInitThreads(), and rendering, input and GTK's loop share GDK's connection across threads: random [xcb] Unknown sequence number aborts. Tao now calls it once before gtk::init().

launcher-linux never loses an early GMainLoop quit (#744). g_main_loop_run() ignores a g_main_loop_quit() issued before it runs, so registerQueryHandler() followed by unregister() could hang in pthread_join forever. The quit is now an idle source on the loop's own context. #744 also makes the pre-merge checks green again since the move to ubuntu-22.04 (theme e2e probe bounded, D-Bus tests skipped without a responsive bus, GraalVM jobs on setup-nucleus's Xvfb, one flaky scroll test parked on CMP-10875).

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/share-demo / examples/share-web-demo The share sheet from a Nucleus window (text, link, file, mixed, invalid payloads), and from Kotlin/JS in a browser
examples/hot-update-demo Windows hot update, driven by scripts/windows-hot-update-e2e.ps1
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.
  23. Kotlin 2.4.20 and Compose Multiplatform 1.12.1 are required.
  24. Windows NSIS installs keep the runtime and app in versions\<version>\. Don't build paths from the install root. An app that shows no Nucleus window calls UpdateHandoff.signalReady() once it is up.
  25. TaoMouseButton.OTHER is now 5 (3 / 4 are BACK / FORWARD). Recompile code that reads raw Tao button codes.
  26. TaoWindow.setOuterPosition throws on NaN, infinite or out-of-range positions. Don't pass sentinels such as Int.MIN_VALUE.
  27. Global hotkey listeners run on the host UI thread, no longer on a native thread.
  28. With only project.version set, NucleusApp.version now reports it, ./gradlew run included, so update checks run there instead of returning NotAvailable. Pre-release suffixes are kept at runtime and stripped only where jpackage / CFBundleVersion need a numeric version.
  29. downloadUpdate() throws ParseException for a manifest artifact name that is not a single path component.

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
#720 Hot update for Windows NSIS installs
#721 Deliver global hotkey callbacks on the UI thread
#722 Parent FileKit dialogs to a Nucleus window
#723 Bump dependencies (Kotlin 2.4.20, Compose 1.12.1, Jewel 0.41.0)
#725 Test updates without publishing a release
#727 Kotlin Multiplatform native share sheet
#728 Forward back/forward mouse buttons to Compose
#729 Keep the Gradle classpath order in packaged launchers
#731 Restored group ids and a titleBar slot on TabWindows
#732 Export TabWindows' parts for app-owned tab windows, TabGripCursor
#733 macOS: satellites' control side, maximized windows restore to their floating size
#734 Linked workspaces take each other's tab drags
#735 Make AWT font creation work in macOS GraalVM bundles
#737 macOS: deliver Control-click as a secondary click (#736)
#738 Reject an unplaceable outer position instead of aborting in AppKit
#744 Make the pre-merge checks pass (XInitThreads, launcher-linux quit race)
#752 One TaoGpuRenderContext instance per surface
#255 Accept SemVer pre-release versions on Windows and macOS packaging
#740 Reject manifest file names that escape the staging directory
#758 Skip the #576 zoom present-lag case on Linux
#759 Unblock the Windows headful suite: drag ghost, #569 popup cases, #546 wrap-content settle
#760 Give the headful suite room to finish on macOS

@kdroidFilter kdroidFilter mentioned this pull request Oct 6, 2026
@janbarari

Copy link
Copy Markdown

Huge update 🚚 🎈 , Can't wait to try it. Thanks for the hard work 🙏🏼

@kdroidFilter

Copy link
Copy Markdown
Collaborator Author

@janbarari You can already give it a try today; I’m publishing pre-releases on MC. Unfortunately, I had to increase latency during resizing on Windows, and I’d like to know if you can notice it on your end.

@janbarari

Copy link
Copy Markdown

@janbarari You can already give it a try today; I’m publishing pre-releases on MC. Unfortunately, I had to increase latency during resizing on Windows, and I’d like to know if you can notice it on your end.

Used 3.0.0-dev-202610060441 in my project -> buttery smooth on Win 11 (26H2)

@janbarari

janbarari commented Oct 6, 2026 •

Copy link
Copy Markdown

Thanks again for the great work. Nucleus feels very confident to use on projects.

sproctor and others added 25 commits October 8, 2026 13:16
`#595 NSTextInputClient answers and empty corporate commit` timed out now
and then on hosted macOS runners at "empty setMarkedText unmarks" (1 in 15
and 1 in 40 iterations on a loop). Traced, every layer did its job: tao
cancelled the preedit, TaoImeSession received the empty preedit with the
session active and the field composing "xyz", and issued the edit.

The edit was dropped by BasicTextField(TextFieldValue) itself, which calls
onValueChange only for a result that differs from the value it was last
composed with. The case cancelled as soon as onValueChange reported
"helloxyz", before the field recomposed with it; cancelling then yields
"hello", equal to the stale value, so no onValueChange, and the next
recomposition restored the preedit.

Wait for the field to be composed with the second preedit before cancelling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`tab mouse flick from one strip to another merges the tab` failed once on
Windows (PR #810's run, its first failure in 52 Windows runs) with only
"timed out waiting for: the flicked tab merged into the first window": no
way to tell a flick released short of the home strip from one resolved
wrongly at the release.

Both waits now report the aim, the home strip's rect, where Beta is, every
group with its tabs and window bounds, the live drag and preview, the robot's
aim, and every pointer sample the drag took with what it previewed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test(tao): fix the macOS held-key repeat and #595 empty-setMarkedText headful cases
test(tao): report where a tab flick ended when the merge times out
fix(tao/linux): keep touchscreen crossing out of mouse input
fix(tao/linux): drain pending GTK events between loop steps
fix(tao/linux): preserve touchscreen sample timestamps
…-snapshots

Fix hot reload crashes from rebuilt included-build dependencies
On a Wayland session the Linux backend binds shortcuts through
org.freedesktop.portal.GlobalShortcuts, and every test that registers one
pops the desktop's "add keyboard shortcuts" dialog (labelled "kover-linux"
by NativeLinuxHotKeyBridgeTest) while ./gradlew preMerge runs locally.

portalRegistrationAllowed gates the three registering tests: false only on
Linux + Wayland (the same rule as detectBackend in
nucleus_global_hotkey_linux.c) with CI unset. X11, macOS and Windows keep
running them everywhere; CI=1 ./gradlew :global-hotkey:test runs them on a
Wayland desktop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The static analysis registers every native library of the classpath as a
resource, so libskiko was embedded in the image on top of the copy the
packaging puts in lib/ (bin/ on Windows), which Skiko loads first from
java.home. The JAR the image is compiled from now leaves out Skiko's
library, every architecture of it, wherever that copy is made (macOS
always, Linux/Windows unless headless).

Fixes #821
…native-image

Compile GraalVM images without the Skiko library shipped beside them
test(global-hotkey): register through the Wayland portal on CI only
A loop frame recorded before a resize could reach the screen after the
same-turn present at the new size, and the presentedInDispatch skip then
swallowed the next frame: static content stayed drawn at the old size.
…-stale-frame

fix(tao): repaint a macOS frame the window was resized under (#817)
nativeGetClientSize read GetClientRect before show(), while the hidden
window was not maximized yet, so the first frame of an initially
maximized system-framed window still rendered at the restored size
(800x600 composed, then 2560x1009 after the first WM_SIZE).

nativeGetMaximizedClientSize computes it instead: the work area of the
window's monitor minus the caption, the same formula as
estimateRestoredClientSize. A maximized frame overhangs the work area by
exactly its sizing border, so only the caption comes off.
Conflicts with #889 (#532), which moved the size request of every
non-resizable Linux window onto its content box (tao patch 0017,
util::set_content_size_request). That covers the system-decorated case
this branch handled on its own, so the branch's content_size_request
special case in window.rs and event_loop.rs is dropped in favour of it.

The system-decorations tao patch becomes 0018, regenerated against the
new base without the size-request hunks; README, CLAUDE.md and
docs/internals/system-decorations.md follow the renumbering.
… behind it

Measured on Windows 11 26100: IAsyncOperation.Cancel() closes the Hello dialog
2-7 s later at best and, in a run of attempts, not at all, the dialog staying up
even after the app exits. It lives in CredentialUIBroker, which UIPI shields
(WM_CLOSE, SendInput, UI Automation and TerminateProcess are refused), and
Windows shows one Hello request at a time, so later requests queued behind it.

A new Hello request now waits, up to 15 s after this process last cancelled one,
for no Credential Dialog Xaml Host window to be visible; a caller cancelled
meanwhile ends without showing anything. The caller still resumes at once on
cancel. KDoc, README and docs/internals/authentication.md say the dialog may
stay until the user dismisses it, as Chrome's reauth and other libraries do.
…ation

# Conflicts:
#	.github/workflows/build-natives.yaml
#	.github/workflows/publish-maven.yaml
#	CLAUDE.md
#	README.md
#	THIRD_PARTY_NOTICES.md
#	settings.gradle.kts
feat: Window / DialogWindow with the platform's own decorations (#841)
The unix-process subject carried our own pid, which a pid namespace makes
meaningless: in a Flatpak the JVM is pid 2, polkitd looked up the host's
pid 2 (kthreadd) and answered "process with PID 2 has been replaced",
while availability() reported available.

The subject is now system-bus-name with the unique name Hello returned:
polkitd resolves the owner through the bus daemon, which reads the pid
from the socket credentials in the host's namespace. Same process on the
host.

Verified on GNOME 50 / polkit 127 in a flatpak build sandbox (Platform
25.08): no system-bus access -> Unavailable, no hang;
--system-talk-name=org.freedesktop.PolicyKit1 or --socket=system-bus -> the
host agent prompts and the cancel dismisses it. Host smoke tests unchanged.
feat(authentication): Kotlin Multiplatform device-owner authentication
Windows backend over Windows.Services.Store, forked from tauri-plugin-iap
and decoupled from Tauri: products, purchase, consumables, subscriptions,
license change updates. macOS is not supported yet and Linux has no store;
both answer Unsupported from Kotlin, with no native library.
InAppPurchase.appLicense() reads StoreAppLicense (active, trial, time left,
trial unique id, SKU), purchaseApp() buys the app itself to convert a trial,
and appLicenseUpdates reports license state changes from
OfflineLicensesChanged. An install the Store did not license reads as an
active non-trial license without a SKU, so isFullLicense also requires one.
… Microsoft Store

system.purchases drives in-app-purchase on Windows: app license and trial,
full-version purchase, add-ons, consumables, purchases and the updates the
Store sends unasked. The Lab's AppX identity moves to its own reservation,
KDroidFilter.NucleusLab, which the probe is verified against.
javaaccessbridge.dll imports jawt.dll, which Windows resolves from the
executable's directory, but the plugin only copied it to bin/ for Skiko.
With Java Access Bridge enabled (screen readers, jabswitch -enable) AWT
failed at startup with UnsatisfiedLinkError: javaaccessbridge, unless a
JDK on PATH happened to provide a jawt.dll of its own. Seen on the Nucleus
Lab installed from the Microsoft Store.
Verified on the Nucleus Lab installed from the Microsoft Store (7-day
trial): TrialTimeRemaining reports the unlimited sentinel for a
time-limited trial, so trialTimeRemaining read null and the trial looked
unlimited. It is now derived from ExpirationDate whenever there is one.
The same install shows TrialUniqueId empty and IsTrialOwnedByThisUser
false: both only describe usage-limited trials, which the KDoc now says.
It also confirms a Store install carries a SkuStoreId (9MV8ZRKS4KT4/0011).
On the Nucleus Lab installed from the Microsoft Store, converting the
trial to a full license never produced an app license update. The
OfflineLicensesChanged subscription was made on the thread that first
collected an update flow, the UI thread, an STA (tao's OleInitialize),
where RoInitialize(MULTITHREADED) fails and the StoreContext stayed in
that apartment. It is now made from a dedicated MTA thread, and a Rust
test subscribes from an STA and checks the apartment.

Every license notification is also logged at FINE, and the Lab probe
lists those lines, so a real install shows whether notifications arrive.
StoreUpdates checks the Store for a newer version of the app
(GetAppAndOptionalStorePackageUpdatesAsync: version and mandatory flag),
installs it from inside the app with the Store's dialog or silently,
streaming StorePackageUpdateStatus progress as a Flow, and opens the
app's Store page. It shares the purchase bridge: progress travels as
updateProgress events routed to the request that asked, and cancelling
the collection cancels the download. New StoreErrorCode.UpdateBlocked for
an update the Store holds until charging or Wi-Fi.

The Lab gets a Store updates probe to verify it on the real listing.
On the Nucleus Lab installed from the Microsoft Store, the purchase
dialog offered an owned durable add-on, and the owned app itself (full
license, SKU 0010), for sale again, asking to pay a second time.
Microsoft documents that a non-expiring durable can only be purchased
once and recommends checking the license before offering a purchase; no
other library checks first. purchase() (non-consumables and
subscriptions) and purchaseApp() now read this device's licenses before
any window and answer Success(alreadyOwned = true). A device whose
licenses have not synced yet still gets the Store's dialog.
The first in-app update of the Nucleus Lab from the Microsoft Store
(1.0.4 -> 1.0.5) installed, closed the app and left it closed. Microsoft
documents the relaunch of a desktop app the Store updates as opt-in
through RegisterApplicationRestart. StoreUpdates.install now registers
the app right before installing, for updates only (no crash, hang or
reboot restarts), and unregisters when the install returns without
replacing the process. Windows skips it for an app that ran under 60 s.

The KDoc no longer claims Windows relaunches the app on its own.
…et size

packageGraalvmAppX never received the appx { } logos (only the JVM task did), and its
fallback, the Windows .ico, cannot be read by ImageIO: nothing was staged and the package
shipped electron-builder's sample logos, which the Microsoft Store rejects as generic.

- the GraalVM pipeline wires the four logos and the Linux PNG fallback, as the JVM one
- the fallback reads .ico files (their largest PNG or 32/24 bit bitmap entry)
- each logo is generated at scale-100 to 400 and the app icon at targetsize-16 to 256,
  plated and unplated, so electron-builder indexes them in resources.pri and Windows picks
  the one for the display instead of stretching a single 44 px image
fix(plugin): the GraalVM AppX gets its logos, at every scale and target size
The module covers more than purchases now (app license, trial, Store updates)
and only ever targets the Microsoft Store, so it is named after it:

- module microsoft-store, artifact nucleus.microsoft-store
- package dev.nucleusframework.store.microsoft, native lib nucleus_msstore
- one root object: MicrosoftStore.purchases (list/restore replace
  purchases/restorePurchases) and MicrosoftStore.updates
…ndows

feat(microsoft-store): Microsoft Store purchases, app license, trial and updates
Minimizing a window hides the windows it owns (satellites, dialogs) and
restoring it shows them again through WM_SHOWWINDOW/SW_PARENTOPENING,
without TaoWindow.show ever running. DWM has dropped the hidden window's
frame and the scene is clean, so nothing was presented: the satellite
stayed white until the pointer passed over it.

The deco WndProc arms on SW_PARENTOPENING and, once the window is really
mapped (WM_WINDOWPOSCHANGED with SWP_SHOWWINDOW), calls back into the
scene host, which forces its next present. Calling back from
WM_SHOWWINDOW itself is too early: the window is still hidden and DWM
drops that present too.
…hite

fix(tao): repaint satellites Win32 re-shows on their owner's restore
…nt on

A DirectManipulation glide closed with MOMENTUM_ENDED, an immediate PanEnd.
Compose flings at PanEnd with the velocity of the last steps, so fingers put
down to stop a fast glide (INERTIA -> RUNNING) threw the content another
half-glide. The glide now closes with a deferred ENDED: the PanEnd lands past
Compose's velocity horizon, and fingers that move again continue the pan.
fix(tao): stopping a touchpad glide no longer flings the content on
Flathub repackages the GraalVM native image instead of building from
source, the path a closed-source Nucleus app takes. Running the Lab from
a local Flatpak repository turned up three run-time bugs every such app
would hit:

- ExecutableRuntime reported the archive's marker (TAR): /.flatpak-info
  now wins over it, so the app reports FLATPAK and isSandboxed()
- the Wayland app_id was the executable's name: Tao sets the GTK program
  name to FLATPAK_ID before GTK starts
- GNOME minimize/maximize vanished: GSettings only sees the app's keyfile
  in a sandbox, so button-layout is read and watched through the Settings
  portal there

release-desktop now publishes the Linux tar.gz; the manifest, metainfo,
screenshots and the local repository loop live in
examples/lab/app/packaging/flathub and docs/internals/flathub.md.
feat(flathub): ship the Lab on Flathub from its released tar.gz
New `flatpak` module, the Flathub counterpart of MicrosoftStore.updates,
over org.freedesktop.portal.Flatpak (Rust JNI bridge on zbus):

- `status`: the portal's update monitor, as Available / Installed /
  UpToDate. The portal has no "check now" (it polls every 30 min), so
  this is a Flow, not a check()
- `install`: UpdateMonitor.Update with progress; the first one shows the
  portal's "Update <app>?" dialog over the parent window
- `restart`: hands the single-instance lock over and Spawns the latest
  commit, then exits
- `openStorePage`: appstream://<app id> through the OpenURI portal

Spawn is always given fds 0-2: without any, the portal hands the
environment to `flatpak run` on fd 0, which it rejects, and nothing
starts after the app already quit.

The Lab's system.flatpak-updates probe drives it, deep-link flags
included (?watch&install&restart). Verified end to end from a local
Flatpak repository: detection, the dialog, install, restart after an
in-app and after a background update.
feat(flatpak): in-app updates through the Flatpak portal

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants