Skip to content

Nucleus 3.0 - #628

Closed
kdroidFilter wants to merge 419 commits into
mainfrom
nucleus-2.6
Closed

kdroidFilter wants to merge 419 commits into
mainfrom
nucleus-2.6

Conversation

@kdroidFilter

@kdroidFilter kdroidFilter commented Aug 31, 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

JEP 493 JDKs (Temurin 24+ and other builds without jmods/) refuse to
link an image that contains jdk.jlink. includeAllModules copied
`java --list-modules` verbatim, which includes it. A shipped app never
needs jlink, so filter it out before --add-modules. The default module
list never contained it, so default packaging is unchanged.
fix(jlink): drop jdk.jlink from includeAllModules runtime images (#673)
Every FsWatcher now owns a single native watcher shared by all of its
registrations: one inotify instance, one FSEvents stream, one
ReadDirectoryChangesW loop per watcher instead of per path. Events are
routed back to registrations by root in lib.rs; a root already watched is
not re-watched unless a recursive registration arrives over a
non-recursive one, and the backend is dropped when the last registration
goes. A per-watcher mutation lock serialises watch/unwatch/close and is
never taken by callbacks.

On macOS, FSEvents attaches an inode's accumulated flags to every event,
so a rename of a long-existing file reaches the debouncer as
Create+Rename+Modify on the gone path and gets folded into a bare
Created(new); a delete of a pre-existing file surfaced as Modified. An
FsEventsNormalizer now fronts the debounced backend (Create/Modify of an
absent path, Remove of a present path and Create of an already-tracked
path are history and dropped), the canonical root is handed to the
backend so file-id pairing matches FSEvents' spelling, and renames the
backend cannot pair are reported as Removed(old)+Created(new) instead of
being discarded. Raw delivery is left untouched.

Real-filesystem tests cover the four rename shapes, a non-canonical root,
the pre-existing delete, the Raw rename contract, and 24 registrations
sharing one native watcher (inotify instances via /proc on Linux, OS
threads via ps on macOS), with per-registration routing and unwatch of a
single path checked through the shared backend.

Fixes #570
Fixes #571
With one INotifyWatcher per FsWatcher, two spellings of the same directory
(its real path and a symlink to it) share a single inotify watch
descriptor, and notify keeps one path per descriptor — so the alias that
registered last decided the spelling every event was reported under and
the other registration matched nothing. Unwatching either alias would also
have removed the descriptor from under the other.

The backend is now handed the canonical root on Linux as on macOS: one
watch per real directory, shared by every alias, with the Kotlin side
projecting events back onto each registration's own spelling exactly as it
already does for FSEvents. Windows keeps the registered spelling because
canonicalize() yields \\?\ verbatim paths that Java's toRealPath() never
produces.

A symlink root registered with followSymlinks = false is thereby reported
on Linux the way the option documents and macOS always did (its target's
events are not remapped onto the link), so that real-fs test now runs on
Linux too.
…hared-watcher-renames

fix(fs-watcher): shared native watcher, macOS renames (#570, #571)
A satellite palette follows its parent at an offset and docks from its
screen geometry, neither of which means anything for a maximized window,
yet nothing stopped it: isDialog never reached the native window and
resizable = true gave it WS_MAXIMIZEBOX / the macOS zoom button.

Add a maximizable flag mirroring minimizable (#504): TaoWindow.isMaximizable
+ setMaximizable, a SetMaximizable event backed by tao's set_maximizable,
the parameter on every DecoratedWindow overload, NucleusWindowHost and
HostedWindow. The Compose chromes drop the maximize slot and the title-bar
double-click when it is off; Restore stays available so a window the WM
maximized anyway can still leave. SatelliteWindow and DecoratedDialog pass
maximizable = false; the satellite also un-maximizes itself on Linux, where
tao has no client-side hint.
feat(window): maximizable flag, off by default for satellite windows
Bring Linux install/remove scripts (#664) and Pacman upgrade hooks (#685).
Applications that keep config, cache and logs under a single directory can
now relocate the native library extraction path, which was hard-wired to
`%LOCALAPPDATA%\nucleus\native` / `~/.cache/nucleus/native`.

Two levers, tried in order, each falling through to the next when the
directory cannot be created or written to:

  1. `-Dnucleus.native.cacheDir=<dir>`, which also works for libraries
     loaded before any application code runs and bakes into the launcher
     `.cfg` through the existing `jvmArgs`;
  2. `NativeLibraryLoader.cacheDirectory`, for a path computed in `main()`;
  3. the platform default.

The root is resolved once, at the first extraction, so every library of a
run shares it; a later assignment is ignored with a warning. The
content-addressed layout of #304 is kept under the chosen root.

Also fixes a pre-existing defect in the default: a set-but-empty or
relative `XDG_CACHE_HOME` / `LOCALAPPDATA` made the root relative, putting
native libraries under the process working directory.
feat(core-runtime): configurable native library cache directory (#303)
Notification, launcher and media-control callbacks were posted with
SwingUtilities.invokeLater. Under the Tao backend the AWT EDT is not
Compose's UI thread, so those callbacks land on a thread that paints
nothing — the same mistake menu-macos fixed in #310, still present in
five modules.

WindowBackend documents itself as the escape hatch for exactly this
("avoid touching the AWT event dispatch thread when running on Tao") and
was read nowhere: its only use in the tree was the write in
nucleusApplication.

- add NucleusUiThread to core-runtime: a single marshalling point backed
  by an executor the backend registers, falling back to
  EventQueue.invokeLater when no Nucleus entry point ran. No new
  dependency, so the OS modules stay Compose-free and headless-capable.
- register it (and WindowBackend.Tao, which a bare TaoApplication.run app
  never recorded) from TaoApplication.run, so a plain taoApplication host
  is covered too, not just nucleusApplication.
- route the nine call sites in notification-linux, notification-windows,
  media-control, launcher-linux and launcher-macos through it.
- restate the seven public KDoc blocks that promised the Swing EDT.
PKG was hardwired to the Mac App Store: TargetFormat.isStoreFormat forced
the sandboxed pipeline, "3rd Party Mac Developer" certificates and a
post-build productsign, so a PKG for MDM deployment or manual install
outside the store was not buildable.

Whether a PKG is a store package is now a DSL choice, macOS { pkg {
appStore } }, defaulting to the previous behaviour. With appStore = false
the PKG takes the same non-sandboxed pipeline as the DMG, electron-builder
signs the installer itself from the bare NAME (TEAMID) identity, a DSL
keychain travels as CSC_KEYCHAIN, and notarizePkg notarizes the .pkg.
electron-builder silently emits an unsigned package when no matching
"Developer ID Installer" certificate is found, so the task verifies the
result with pkgutil --check-signature.

pkg { preInstall / postInstall } stage install scripts for pkgbuild
--scripts. The App Store rejects them (error 90254), so they require
appStore = false, checked at configuration time.

The staged preinstall/postinstall are shims. electron-builder sets
BundlePre/PostInstallScriptPath *and* passes --scripts, so PackageInfo
declares each script twice and the Installer runs it twice, confirmed on
a real install. The shim skips the per-bundle pass and execs the app's
script, staged under a name electron-builder's scan does not match.

Runtime: ExecutableRuntime.isSandboxed() reads APP_SANDBOX_CONTAINER_ID.
The scheduler gates on it instead of isPkg(), and a Developer ID PKG
becomes self-updatable while the sandboxed store build stays excluded.
The ignored-projects list was maintained by hand, so every new sample had
to be added to it. Twice it was not, and apiCheck failed with "Expected
file with API declarations ... does not exist" for macos-appex-demo and
reader-dock-demo.

Excluding every :examples: subproject removes the class of failure. The
predicate matches the one already used for detekt and explicitApi() lower
in the file. decorated-window-jewel stays listed explicitly: it is not a
sample, it is BCV's ASM being unable to read JVM 25 class files.
feat(pkg): Developer ID PKG with install scripts (#249)
ktlintCheck was failing on nucleus-2.6 independently of any branch:
chain-method-continuation on the two kover plugin applications in the root
build script, and an unused NucleusDecoratedWindowScope import in
satellite-demo. Applied ktlintFormat to those two targets only, so the
change is limited to them. detekt, ktlint and apiCheck are now green
across the whole build.
… UI thread

`NotificationCenter` dispatches its delegate callbacks on a worker pool of
its own ("NucleusNotificationCallback-N"), so the portable `notification { }`
callbacks were the only ones left off the host UI thread on macOS while the
Linux and Windows bridges already post to it.

Verified on macOS with a real notification: clicking a button, the body and
the close box now all reach Kotlin on the Tao main thread (the thread Compose
composes on), instead of `NucleusNotificationCallback-1`.

`onFailed` from the `add()` completion takes the same route, since that
completion comes off the same pool.
#444)

On Wayland `wl_egl_window_resize` only records a *pending* size: the buffer
behind the default framebuffer is reallocated inside the next
`eglSwapBuffers`. Skia's render target wraps that framebuffer (`fbId = 0`),
so building it from the size we just requested overstates it for one frame,
and under `SurfaceOrigin.BOTTOM_LEFT` the frame lands that many rows off the
top of the real drawable — the band of clear colour the issue sees flicker
on roughly a third of the frames of a drag.

Both earlier attempts predicted that size rather than reading it: first "the
buffer follows the request" (the flash), then "the buffer is one present
behind", which had to be confined to KWin because the prediction was wrong
elsewhere — it fixed Fedora Mutter and regressed Ubuntu GNOME.
`eglQuerySurface` is neither prediction but the answer, so there is no
desktop environment left in the decision: `useDrawableSizedPaint`,
`drawableWidthPx` / `drawableHeightPx` and `onDrawablePresented` are gone.
Where a driver answered with the requested size instead of the real one, this
would behave exactly as the code did before it.

Layout and the render target are also separated, which is what made the
earlier attempt a trade-off in the first place: the scene keeps the window's
size, so Compose never measures for a buffer that is a step behind, and only
the render target follows the drawable. A frame drawn while the buffer lags
is then anchored correctly and merely leaves that step uncovered until the
catch-up frame, instead of displacing everything by it.

Measured by a new headful case against a nested compositor: 16 of 64 frames
painted at a size the buffer did not have before, 0 after, the window
converging to its final size. The case fails when it measured nothing — it
requires the window to have actually changed size, and reports the render
passes dropped on a swap still in flight, because a window the compositor
treats as occluded never gets its frame callbacks, renders nothing at all,
and used to look exactly like a pass. `taoHeadfulTest` now forwards
WAYLAND_DISPLAY so the suite can be pointed at a nested compositor instead
of whichever session owns the screen.

Defects (1) and (3) of the issue are untouched: the buffer still does not
arrive in the same commit as the window geometry, which is a commit-ordering
problem between GTK's toplevel and our sub-surface rather than a render
target one.
fix: marshal native callbacks to the host UI thread, not the AWT EDT
Querying the drawable instead of predicting it is only authoritative if the
driver cannot act on the pending `wl_egl_window_resize` after the answer is
given. That held on Mesa, which defers the reallocation to `eglSwapBuffers`
(measured: 0 of ~180 frames changed size under the frame). It does not hold on
the NVIDIA proprietary driver, which reallocates when the back buffer is first
used for rendering — in the middle of our frame, after the render target was
built (measured on Ubuntu 26.04 / RTX 5060 Ti / 595.84: 12 of ~80 frames).

So pin the moment rather than predict it per driver: on the frames that pushed
a resize, bind the default framebuffer and clear it before querying, which is
the first use either driver is waiting for. The answer then describes the
buffer the whole frame lands in on both. The clear is not extra work — the
frame clears anyway — and it is confined to resize frames because it costs a
Skia GL state reset.
The render target is built from one `eglQuerySurface` taken before the frame,
which is only authoritative if the driver cannot act on the pending resize
afterwards. Drivers differ on exactly that, so the case reports the frames where
it happened anyway and prints what the buffer became, which is what tells a
stale basis from a correct one.

This is what identified the NVIDIA behaviour: 12 of ~80 frames per run there,
0 of ~180 on Mesa, every one of them `queried -> requested` — Skia painting at
the pre-configure size into a buffer that had already reached the new one.
Both said more than what was measured, which is the same mistake that produced
the desktop-environment branch this change removes.

- The paint-size KDoc claimed the queried drawable never changes under a frame,
  measured on Mesa and stated flatly. It changes on the NVIDIA proprietary
  driver; say which driver does what, and point at the call that pins it.
- The headful case claimed compositors ignore `setInnerSize` "(this one does)".
  Mutter 50.1 honours it — #576 drives 40 distinct sizes through it. The case
  still drives maximize/restore, but because a client resize is advisory and a
  session that drops it would leave the case measuring nothing, not because
  compositors reject it.
…e-sized-paint

fix(tao/linux): paint at the drawable's real size, not a predicted one (#444)
The badge a Caps Lock bound to keyboard-layout switching raises is a window
HIToolbox creates *inside our own process*, positioned from whatever
`firstRectForCharacterRange:` answers. TaoView answered a process-global caret
rect that nothing ever invalidated, so once a focused text field was destroyed
the badge kept appearing over the spot the field used to occupy. Before any
field had been focused it was worse: the overrides were only installed when the
first text-input session started, so tao's own implementation answered — the
content-rect corner with a *top-down* y handed back as a Cocoa bottom-up
coordinate — which parks the badge in the bottom-left corner of the screen of
an app that has never shown a text field.

Three changes, all measured against a logging `NSTextInputClient` probe driven
by `TISSelectInputSource` with the badge window tracked through
`CGWindowListCopyWindowInfo`:

The cached rect is now scoped to the view that pushed it, and every other
answer is `NSZeroRect`. That exact shape is the one AppKit reads as "no
insertion point": a zero *size* alone does not suppress the badge — `(0, 30,
0x0)`, which is what tao answers, still draws it — and `selectedRange =
NSNotFound` plus `invalidateCharacterCoordinates` change nothing at all. A
rect falling outside the key window is not drawn either, which is why the
corner case only shows on a window large enough to contain it.

The overrides are installed per window creation rather than on the first
session, so the answer is ours from the first frame. `TaoView` only exists once
a window has been built, and the swizzle is idempotent.

The input context is deactivated when the session ends. On its own that is not
enough — `interpretKeyEvents:` re-activates it on the next keystroke — but it
takes the badge down immediately for an app that is not being typed into. The
teardown carries the activation token it was handed, because focus moving
between fields starts the incoming session *before* the outgoing one is torn
down: without the guard, focusing a second field deactivates the context the
first teardown then finds live. Same ordering trap as the document cache.
`MacOsTextInputClientProbe.imeRect` reads what TaoView answers AppKit and
reports an all-zero rect as "no insertion point", which is the property both
cases assert.

`caretRectDiesWithTheFocusedField` walks one field lifecycle: the caret is
published, it follows focus to a second field (the ordering guard — without it
the run takes three times as long because the incoming session's context is
deactivated and has to be re-established), it is dropped when the fields are
destroyed, a keystroke does not republish it, and a field composed again gets
it back. `noCaretRectBeforeAnyField` covers the window that never shows one.

Both fail on the code before this branch.
…indicator

fix(tao/macos): keep the input-source indicator off a caret that is gone
AppKit fires `mouseExited:` for a cursor that never left the view. Measured on
26.5 with the pointer parked over a tab strip: enter → exit → enter → exit, all
at one screen point. Two sources found — a hover card's own popup panel rising
over the pointer, which is a *child window* of ours whose draw margin overlaps
the tab it hangs off, and the rebuilds of tao's legacy tracking rect.

That reaches Compose as `PointerEventType.Exit` and is taken at face value, so
hover state is dropped. Nothing corrects it: `CursorEntered` carries no
position and was never forwarded, and a pointer that *rests* sends nothing
more. Hover effects and tab hover cards stay dead until the user moves the
mouse — the symptom the standing HACK comment in `TaoComposeSceneHost`
describes as "hover doesn't render until the user clicks once". A hover card is
worse than dead: it dies on its own phantom exit, reopens, and exits again, a
loop no pointer can break.

So trust the geometry over the event. An exit is ignored when the cursor is
demonstrably still ours — inside the view's bounds, and the window on top at
that screen point is this window or one of its `childWindows` — and the
position is re-published instead; `CursorLeft` is kept for a cursor that really
is somewhere else. `mouseEntered:` publishes its position for the same reason.
`a popup outside the owner window is left alone while the screen has room`
needs the popup to land outside the window *and* inside the work area at once.
The default 800 dp window centred on the runner's 1024 px display leaves 112 px
for it, so the popup was clamped back in — on the case whose whole point is
that nothing clamps. A small window against the left edge has room anywhere we
run.

The two cases that need a window *above* the work area are asking for a state
macOS does not have. Measured on 26.5: `setFrameOrigin:` and `setFrame:display:`
both pull a frame whose top would go under the menu bar back down, titled and
borderless alike, and only an override of `constrainFrameRect:toScreen:`
escapes — not a trade Nucleus makes, since AppKit runs that constraint on
display changes too and a window it no longer keeps on screen is a window the
user cannot reach. A user cannot drag a window off the top there either. They
skip on macOS with that reason and stay covered on Windows and Linux, where the
drag is an everyday gesture.
`Robot.mouseMove` warps the cursor on macOS, and the events that follow a warp
carry the *pre-warp* location for a few hundred ms — measured as ten
consecutive moves all reporting one stale point. A press sent inside that
window is hit-tested where the pointer used to be, so a case that clicks
straight after moving clicks the wrong thing.

`robotPressAndDrag` now lands on its start point in two hops, the second a real
move from the cursor's new home, which is what flushes the true location
through. Same shape as the pattern `NativePopupMarginInputHeadfulCases` already
used for its pointer cases.
…r it (#444)

Two ordering defects made the content trail the window by one configure
on every commit of a Wayland resize — visible as the jump on a left/top
edge drag, where the origin moves a step ahead of the content.

1. Tao's GTK `draw` and `configure-event` handlers only post to its event
   channel: `RedrawRequested` / `Resized` reach the host after GDK's
   after-paint has already committed the toplevel with the new
   `set_window_geometry`. The frame for configure N could never ride
   commit N. The widget helper now connects a real `draw` handler on the
   GtkWindow (`nativeConnectToplevelDraw`); during a resize burst the host
   renders from it, taking the size from `gtk_window_get_size` (GTK's own
   configure-event lags the same way), with the content sub-surface in
   `set_sync` and the swap at interval 0, and waits for the swap before
   returning — so the buffer is cached compositor-side when GTK's commit
   applies it, atomically with the geometry. Sync is armed only from an
   idle swap thread and only with the interval-0 burst, or a commit with a
   frame callback attached would wait for the very GTK commit the draw
   has yet to return to. The swap interval is now applied by the swap
   thread before its present, so it is in force before the first synced
   commit.

2. Mesa's `wl_egl_window` resize callback adopts the new size only while
   no back buffer is acquired, and `eglMakeCurrent` acquires one: a
   resize pushed after it lands in the next frame's buffer, and this
   frame paints the previous size. `applyPendingNativeResize` now runs
   before `nativeMakeCurrent`; the Skia surface rebuild a scale change
   asks for is deferred to when the context is current.

Measured with a compositor-driven left-edge resize (KWin scripting,
30 steps of 8 px) and screen capture, counting frames where the
content's right edge moves — which it never does for a plain GTK3
window: before 64–82 of ~300 frames, after 0 on three runs. A
WAYLAND_DEBUG trace pairs every GTK geometry commit with a child buffer
of exactly that width (100/100).
kdroidFilter and others added 26 commits October 5, 2026 21:44
…awable

A partial frame has a fixed cost (~0.3 ms for a few pixels against
~0.7 ms for a full frame). Measured on Apple Silicon at 2560×1050 with
the real scene recording, full vs partial, GPU-synced medians: partial
breaks even around 80 % of the drawable and costs up to ~5 % more above,
while it still saves 6–26 % at 70 %. A repaint covering 80 % or more is
now done in full.
…nt it as the dirty rect (#755)

ANGLE's D3D11 window surface renders into an offscreen texture it copies
into the swap chain at present. With EGL_BUFFER_PRESERVED that texture
always holds the previous frame (buffer age 1), so a frame repaints only
its damage; eglPostSubBufferNV copies just that rectangle and hands it to
DXGI as the Present1 dirty rect. Damage drawn but not yet presented is
accumulated for the next present. A frame that changed nothing is not
presented, its successor held to the measured present period.
NUCLEUS_TAO_PARTIAL_REDRAW=0 disables it natively.
…hold (#755)

Measured on Windows (ANGLE / D3D11, 2560x1032, real scene recording,
partial and full frames interleaved, GPU-synced medians): a partial frame
costs ~0.4 ms for a few pixels against ~0.9 ms in full, breaks even at
80 % (1.01-1.05) and costs up to ~9 % more above, while it still saves
2-11 % at 70 % — the macOS figure. The constant and the check move to
PartialRedraw so both hosts share them.
…tialRedraw } (#755)

Off by default. The knob (also on under the nucleusOptimization master)
registers the Compose patch — without it Compose is not transformed at
all — adds -Dnucleus.tao.partialRedraw=true to jvmArgs, and bakes
optimization.partialRedraw=true into nucleus-app.properties for native
images, which have no launcher .cfg. The runtime reads the system
property first, then that key. -Pnucleus.tao.partialRedraw.patch is gone;
ProGuard also -dontwarns the generated hooks class, absent by default.
…updates" (#755)

The tint marked what each partial frame repainted, alternating two
colours, and left it in the preserved buffer: a full frame drew nothing,
so a change it painted read as missed damage, and old tints stayed on
screen until something repainted them.

It now does what HWUI's FrameInfoVisualizer does: what a frame changed
flashes in translucent red (0x7fff0000) every other drawn frame, the
whole frame when the damage is unknown, and frames repaint in full while
the tint is on (HWUI's unionDirty empties the dirty rectangle), so a
flash lasts one frame. Empty frames are still skipped, and the debug
stats still report what partial redraw would repaint. One helper,
PartialRedraw.drawDebugTint, for the three hosts.
…ters (#755)

nucleus.application {
    debug {
        partialRedraw { tint = true; stats = true; verify = false }
        recomposition { enabled = true; top = 10 }
    }
}

The block maps to the runtime switches (nucleus.tao.partialRedraw.tint /
.debug / .verify) on the run task only: never the launcher .cfg, a
runDistributable or a native image, so a build cannot ship with debug
overlays. -Pnucleus.debug=tint,stats,verify,recomposition adds switches
for one run without touching the build; unknown ones are warned about,
and so are partial redraw switches while partial redraw is off (Compose
is then not patched and every frame repaints in full).

recomposition installs a CompositionTracer (RecompositionCounter, with
the first scene) and logs the composables that ran the most every 3 s,
what Android Studio's Layout Inspector shows as recomposition counts.
…orever (#755)

A purged IOSurface stays Empty — and volatile — until someone makes it
non-volatile, and Core Animation never does: the KeepCurrent read reported
that buffer lost on every frame after a single purge. Read the state by
setting NonVolatile, so the purge is reported once.
Seeded window-level chaos (resizes, bounces, maximize, fullscreen,
minimize, clear colour, pauses around the idle reset, bursts, menus, a
background-thread writer) over partial-friendly churn, for the verify
oracle; partial.demo.settleDir adds a capture handshake that checks for
stale pixels at real frame timing.
…t one (#755)

The 4-40 ms window never accepted a frame above 250 Hz, so a 360 Hz panel
stayed paced at the 60 Hz default after every clean frame, and it took
two-refresh intervals (a slow frame, 30 fps content on 60 Hz) as a
period, drifting the idle hold towards 33 ms.
The Windows and Linux hosts queue the scene's coroutines on a dispatcher
drained only on the frame path, and a minimized window returns before
any drain: every effect of that window froze until the user restored it
(an app timer, an effect restoring the window itself), and the queue
grew with every snapshot write. Minimized, nothing is drawn and the
frame clock stays parked, but the queue is drained, at most once per
display period so a self-dispatching coroutine cannot spin the loop.
…cle a race from a miss (#755)

scene.draw applies the global snapshot itself, after the beforeDraw hook:
a write since the frame's apply (another thread, a layout callback)
invalidated a layer after the tracker measured the damage, so the frame
re-recorded it outside the repaint and showed it a frame late. Applying
it right before the last layout leaves only writes landing during the
walk to race it. The tracker reports the layers whose version moved
during the draw, and the verify oracle counts a difference confined to
them as raced= rather than as missed damage.
…rank (#755)

Every setContent moved its writer to the top of the clear-colour stack,
and a writer's SideEffect runs on every recomposition. The resolved
colour recomposes the chrome (its light/dark), so a TitleBar and a
WindowBackground of opposite darkness took the top in turns, one frame
each, forever: every frame a clear-colour change, repainted in full.
Only a change of colour now re-ranks a writer. The partial redraw debug
log names what changed in the frame key.
… a new size (#755)

ANGLE resizes its window surface only at the end of a present
(checkForOutOfDateSwapChain after swapRect), so the frame drawn right
after a resize landed in the old-size buffer — origin bottom-left,
everything past the old size lost — and was presented that way; the
verify oracle's control caught it on every maximize, fullscreen and
animated size step. nativeResize now posts one pixel of the current
buffer when the size changed, which runs that check. An empty rectangle
does not: EGL returns before reaching the swap chain.
…an (#755)

renderFrameBlocking records without the damage walk and pumps the
dispatcher before its replay: the pump can record and walk a newer loop
frame that reaches the render thread first. The older blocking picture
then landed in a drawable marked up to date, while the next damage was
measured from the newer frame — whatever changed in between stayed stale
in that drawable (a grid cell after a fullscreen toggle in the torture
scene, ~1 per 10-30 cycles). The blocking frame now leaves its drawable
unknown, so the next frame drawn into it repaints in full.
feat(tao): partial redraw — damage tracking + Linux EGL, macOS Metal, Windows ANGLE
glibc creates up to 8 x cores malloc arenas and each keeps what it once
held, which left SeforimApp ~900 MB above what it used. A new
nucleusOptimization { mallocArenas } knob (also on under the master)
caps them at 2 through mallopt(M_ARENA_MAX), first thing in
GraalVmInitializer.initialize(), for JVM and native-image apps alike.

- linux-hidpi: nativeSetMallocArenaMax (dlsym, glibc only) and
  capLinuxMallocArenas(); an environment MALLOC_ARENA_MAX or
  glibc.malloc.arena_max tunable always wins
- plugin: -Dnucleus.optimization.mallocArenaMax=2 in jvmArgs (an
  explicit value wins, 0 disables) and optimization.mallocArenaMax in
  nucleus-app.properties for native images
- rename HiDpiLinuxBridge to LinuxStartupBridge, since it now carries
  more than the HiDPI detection
fix(tao/macos): one cursor table, no throwing private NSCursor factory (#746)
The Linux and Windows hosts drained the scene's coroutine queue only from
the frame path, so every dispatch requested a frame: a main-confined
delay loop, a clock or a withContext(Dispatchers.Main) hop rendered (and
on Linux presented) a full frame although nothing changed on screen.

SceneMainDispatcher, shared by both hosts, still drains in the frame path
before the frame clock ticks, and otherwise drains between frames through
TaoMainDispatcher. Frames come from the scene's own invalidations only.
The minimized drain and Linux's swap-busy / idle-hold drains are gone.

partial-redraw-demo gains a main-tick scene reproducing the issue.
…-754

fix(tao): a main-dispatcher dispatch no longer renders a frame (#754)
`Version.fromString` did not accept a `+build` suffix, so a version such
as `1.2.3+42` failed the regex and fell back to `0.0.0`. An app built
with build metadata therefore saw every published release as newer and
was offered the same update on every check. For pre-releases the
metadata leaked into `meta` (`beta.1+sha`), which broke comparisons.

Match the `+...` suffix and discard it, as SemVer 2.0.0 §10 requires
build metadata to be ignored when determining precedence.
fix(updater): ignore SemVer build metadata when parsing versions
… hosts

The loopback exception to the https requirement accepted any host starting
with "127.", so a remote name such as 127.updates.example.com could serve
updates over plain http. The 127.0.0.0/8 exception now applies only to a
dotted-quad IPv4 literal, checked without resolving it.

The IPv6 case never matched: URI.getHost() returns "[::1]" with brackets,
so http://[::1] was rejected. "localhost" is now matched ignoring case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix(updater): limit GenericProvider's http exception to real loopback hosts
@kdroidFilter
kdroidFilter deleted the nucleus-2.6 branch October 6, 2026 04:40
@kdroidFilter kdroidFilter mentioned this pull request Oct 6, 2026
@kdroidFilter

Copy link
Copy Markdown
Collaborator Author

Superseded by #776: the head branch was renamed from nucleus-2.6 to nucleus-3.0, which closed this PR.

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