Skip to content

Nucleus 2.6 - #628

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

kdroidFilter wants to merge 207 commits into
mainfrom
nucleus-2.6

Conversation

@kdroidFilter

@kdroidFilter kdroidFilter commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Nucleus 2.6 vs current main. Tao becomes the only window backend. Two multi-window archetypes land on it: satellite palettes that dock, and Chrome-like tabs. Compose 1.12's window API v2 is supported through an AWT-free clone. Windows can drop their minimize and maximize buttons. nucleusApplication gains exitProcessOnExit. Packaging gains macOS .appex embedding, a startup optimization pack, lastJdk, and two jlink / App Store PKG fixes. Native libraries can extract to an app-chosen cache. FsWatcher shares one native watcher per instance and delivers macOS renames as Moved.

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

Breaking: Tao is the only backend

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

Gone from the public API:

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

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

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

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

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

Compose window API v2

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

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

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

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

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

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

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

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

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

Known limits:

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

Satellites, docking, Chrome-like tabs

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

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

Shared core (window/tao/workspace/)

Internal, not public:

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

Native Wayland

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

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

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

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

Satellite windows

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

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

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

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

Dock layout

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

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

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

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

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

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

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

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

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

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

dock() and the preview share one width (dockSeedExtent) and one weight (dockSeedWeight), so what lights up is what the release produces.

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

RTL is inferred from the slots. insertionIndex is direction-aware (a right-to-left strip used to resolve every drop mirrored).

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.

macOS trackpad

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

Packaging

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

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.

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 on java.library.path and extract nothing; 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()
}

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

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.

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

Demos

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

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

Migration

From 2.5 / current main:

  1. Depend on nucleus.decorated-window-tao. Drop backend = NucleusBackend.…. NucleusBackend and LocalNucleusBackend are gone.
  2. Replace window.unsafe.awtWindow / awtDialog and Compose Desktop's Window / Dialog / Tray with nucleusWindow, HostedWindow / HostedDialog, and an AWT-free tray.
  3. Window API v2: change the import from androidx.compose.ui.window.v2 to dev.nucleusframework.window.tao.v2. Compose's own types are not accepted.
  4. Satellites / dock / tabs: opt in with @OptIn(ExperimentalNucleusApi::class) or -opt-in=dev.nucleusframework.window.ExperimentalNucleusApi. The surface may change without a deprecation cycle.
  5. Branch Wayland-sensitive chrome on TaoWindow.canPlaceOnScreen (or SatelliteScope.isCompositorPlaced), not on the compositor name. Use floatingCaption for the strip the title bar leaves to the compositor's move.
  6. LCD/ClearType is on by default for opaque Windows windows. Opt out with -Dnucleus.text.lcd=false or -Pnucleus.text.lcd.patch=false.
  7. nucleusOptimization = true is opt-in. lastJdk downloads OpenJDK 27 (Liberica on Intel mac and Windows ARM) the first time a packaging or run task needs it.
  8. minimizable / maximizable default to true. Themed NucleusWindowHost implementations must take the new parameters. Dialogs and satellite palettes already pass the right values.
  9. exitProcessOnExit defaults to true. Pass false only if you need to continue in-process after the last window.
  10. Native cache: -Dnucleus.native.cacheDir= or NativeLibraryLoader.cacheDirectory before the first native load. Packaged plugin builds skip extraction.
  11. Custom PointerEventType.Scroll handlers on macOS must also handle Pan, or set -Dnucleus.tao.trackpadPanEvents=false.

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)

…tensions DSL

Add a macOS { appExtensions { } } DSL to embed prebuilt .appex bundles into
Contents/PlugIns, each signed with its own entitlements and (optional) provisioning
profile, then seal the outer app without --deep so the extension keeps its distinct
signature. Covers the JVM/jpackage path, the DMG/PKG re-seal (electron-builder), and
GraalVM native images. Adds the macos-appex-demo example.

No behavior change when appExtensions is unused (all new paths are guarded).
Tao is now the single window backend. Removes the three AWT-based modules
(`decorated-window-awt`, `-jbr`, `-jni`) with their native sources, API
dumps, detekt baselines and GraalVM metadata, plus the `jni-demo` sample.

BREAKING CHANGE: `NucleusBackend`, `LocalNucleusBackend`, the `backend =`
parameter of `nucleusApplication`, `NucleusApplicationScope.backend` and
`NucleusWindowUnsafe.awtWindow` / `awtDialog` are gone, as are the 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.

- nucleus-application: drops the AWT scope/window/dialog adapters and takes
  `api(project(":decorated-window-tao"))`, since the backend is no longer a
  consumer choice and a missing runtime module would only fail at launch
- material2: ports `MaterialDecoratedDialog` to a `NucleusApplicationScope`
  receiver — it existed only in AWT form, so M2 keeps parity with M3
- removing the AWT overloads also retires the `LowPriorityInOverloadResolution`
  / `INVISIBLE_REFERENCE` workarounds they needed
- taskbar-progress-tao: single Tao dispatch path
- core-runtime keeps `WindowBackend.Awt`: it still describes a plain Compose
  Desktop / Swing host embedding Nucleus libraries
- scheduler-demo moves to Tao (drops the `java.desktop/sun.*` add-opens);
  service-management-demo swaps `java.awt.EventQueue` for
  `rememberCoroutineScope().launch`, as SMAppService completion handlers must
  hop to the Tao main thread
- drops the now-unused `jbr-api` catalog entry, the jni/jbr native build steps
  and verify entries from CI, and rewrites the backend docs
- also adds `rect-stress-demo` / `widget-demo` to `apiValidation.ignoredProjects`
  alongside the other demos, fixing their pre-existing `apiCheck` failures
Compose hardcodes grayscale as the Windows font-smoothing default. Enable
ClearType end to end without runtime reflection:

- plugin: LcdTextDefaultTransform (artifact transform + ASM) patches
  FontRasterizationSettings.PlatformDefault in ui-text-desktop jars on
  non-test runtime classpaths — SubpixelAntiAlias on Windows, opt-outs
  -Dnucleus.text.lcd=false (runtime) / -Pnucleus.text.lcd.patch=false
  (build). Android/HotReload/KMP guards mirror the CleanNativeLibs
  transform; referenced ctor/enum fields are verified so Compose layout
  drift fails the build. Canary test patches both the plugin's Compose
  and the consumer version from the root version catalog.
- 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 or
  Acrylic backdrops, transparent windows) keep unknown geometry so Skia
  falls back to grayscale. renderGlFrame now requires windowTransparent.
- jewel-demo: use JewelDecoratedWindow instead of hand-rolled theming.
DecoratedWindow, DecoratedDialog, HostedWindow and HostedDialog take
androidx.compose.ui.window.v2 state. Requested geometry is applied
asynchronously; observed bounds/placement publish once the window is shown.
tao-demo uses the v2 rememberWindowState and requestPlacement path.
…text

feat(tao): LCD/ClearType text on Windows
WindowState.requestSize()/requestPosition() build the two-arg
WindowBoundsProvider, whose getBounds dereferences an AWT-backed
WindowGeometryProviderScope. Tao has none, so the request was dropped —
but the placement was already rewritten to Floating, knocking a maximized
window out of maximized for a request that never applied. Skip the whole
request when the provider cannot be evaluated, and log it at WARNING
(rememberWindowStateWithBounds hits the same path).

The observed bounds now fall back to the native window rectangle when the
v1 position never turns Absolute: a WM that emits no move event for a
PlatformDefault window left WindowState.isInitialized false and bounds /
size / position throwing forever.

Also: narrow the constantBoundsOrNull catch to NullPointerException so a
provider's own failure is not reported as "needs live metrics"; move
dialog size clamping out of composition into an effect; move
inspectableWindowBounds to dev.nucleusframework.window.tao so apiCheck
covers it and the split package with compose-ui is gone; document that
setMinimumSize/setMaximumSize clear per window, not per axis; register
ComposeWindowV2BridgeTest with the scene test battery drift guard.
Compose v2 documents WindowState.bounds as the whole window, insets
included, but the bridge published the v1 state's outer position paired
with its inner size — so bounds.size changed meaning once the WM emitted
its first move event, and requestBounds(state.bounds) or a
WindowState.Saver restore resized the window by the decoration insets.
Observed bounds now always come from the native outer rect, and the
request path converts back to the inner size the v1 state expects.

Hosts that never expose the TaoWindow (rememberSyncedWindowState) left
isInitialized false forever on a window manager that emits no initial
move, making every bounds / size / position read throw. They now publish
an approximate outer rect instead.

The initial v2 -> v1 conversion drains the request channels, so a window
that left and re-entered composition before ever being visible fell back
to the 800x600 platform default. Memoize the drained geometry per state.

constantBoundsOrNull treated any NullPointerException as "this provider
needs AWT window metrics", hiding real provider bugs behind a dropped
geometry request. Only the shapes that come from the null scope we pass
in count now.

requestSize / requestPosition stay inert: their providers live in a
synthetic lambda's captures, so honouring them would need reflection,
and building a WindowGeometryProviderScope would need a displayable AWT
window. Add requestInspectableBounds() as the working equivalent and
point the diagnostics at it.
Compose 1.12's `androidx.compose.ui.window.v2` is anchored to AWT: `Screen`
wraps a `java.awt.GraphicsDevice` and reads its insets through
`Toolkit.getDefaultToolkit()`, and `WindowGeometryProviderScope` takes a
`java.awt.Window` that must already be displayable. The Tao backend has
neither, so every provider that touches the scope was accepted, logged and
dropped, and `requestScreen` was drained into the void.

Mirror the package instead, member for member, as
`dev.nucleusframework.window.tao.v2`, backed by our own monitor enumeration
and `TaoWindow` rather than by AWT. Migrating is a single import change, and
deleting the package restores the upstream import unchanged if JetBrains
decouples its own types.

- `TaoMonitors` / `TaoMonitor`: multi-monitor enumeration via a new
  `nativeGetMonitors` on each platform bridge (`EnumDisplayMonitors` +
  `GetDpiForMonitor` on Windows, `NSScreen.screens` on macOS, GDK monitors on
  Linux), one tab-separated descriptor per monitor. Physical pixels, top-left
  origin, work area included — the conventions the existing primary-monitor
  calls already used. Never reports zero monitors.
- `v2`: `Screen`, `WindowScreenProvider(Scope)`, `WindowMetrics`,
  `WindowGeometryProviderScope`, `WindowBoundsProvider` /
  `WindowSizeProvider` / `WindowPositionProvider` with their companions,
  `WindowState`, `DialogState`, savers and `remember*` factories.
- `DecoratedWindow` / `DecoratedDialog` / `HostedWindow` / `HostedDialog` /
  `NucleusWindowHost` overloads for the cloned states. The host default
  bodies fall back to the v1 surface, so themed hosts keep working; the
  default host overrides them for the full path.
- Size and position stay split instead of folding into a `DpRect`
  (`CombinedBoundsProvider`): a rectangle cannot carry an unspecified
  position or a wrap-content axis without turning both into `NaN`.
- Wrap-content sizing routes through the window's own path rather than a
  one-shot content measurement, so `Unconstrained` / `PreferredWidth` /
  `PreferredHeight` keep re-measuring.

The Compose-typed overloads stay as they are — best effort with the warning
— and their KDoc now points at the clone.

Verified headfully on a real window (`taoHeadfulTest`, 5 new cases):
initial provider centring, `requestSize` / `requestPosition`, a scoped bounds
provider reading live window metrics, `requestScreen` landing on the target
monitor, and `screenId` tracking the hosting monitor.
Brings the 2.6 line up to date with the released one (#629 MSI installer
options, #630 NSIS menu category, #632 clean-frame present skip, #633
alwaysOnTop stickiness).

Conflict: `nucleus_tao_windows_deco.c` — 2.6's ClearType pixel-geometry probe
and main's #631 topmost helpers were appended at the same spot. Both kept.

Verified on Windows: rebuilt natives, `check` on decorated-window-tao and
nucleus-application, headful suite 27 run / 0 failed.
Observed geometry was only published from an effect keyed on the v1 state, so
a move or resize the window manager applies without the v1 state changing left
`WindowState.bounds` / `position` / `size` reporting a stale rectangle for the
rest of the window's life. The initial geometry apply is exactly that case: it
lands after the effect has already run.

Bump a counter from the window's own move / resize callbacks and key the
publishing effect on it too. Both binders get it — the Compose-typed one has
the same shape and the same gap.

Caught by the headful suite, which only reproduced it with the full case list:
the filtered run happened to settle in time.
Both conflicts are additive registries where 2.6 and this branch appended at
the same spot: the JVM-only test list (its LCD capture test vs our monitor /
bridge tests) and the headful suite registry (AlwaysOnTopHeadfulCases vs
WindowApiV2HeadfulCases). Both sides kept.

Verified on Windows: rebuilt natives, `check` on both modules, headful suite
32 run / 0 failed, twice.
… API

Written blind on a Windows box and caught by CI. Three mistakes, all verified
this time against the gdk 0.18.2 sources:

- `gtk::gdk::prelude::DisplayExt` does not exist — `Display`'s monitor
  accessors are inherent in gdk3-rs. This is the E0432 that failed the build.
- `Monitor::is_primary()` does exist, so the primary flag no longer has to be
  matched on geometry — which would not have compiled either, since
  `gdk::Rectangle` implements no `PartialEq`.
- `Rectangle` is a boxed inline type, so selecting between the geometry and
  the work area *by value* moved a rectangle still read afterwards. Read the
  four numbers out first and pick between tuples.

Also spell the tab/newline sanitiser as two `replace` calls: the char-array
`Pattern` impl is newer than the toolchain floor this crate builds with.
`gdk::Display::default()` is `assert_initialized_main_thread!()`, and a failed
Rust assertion crossing FFI aborts: the enumeration took the whole test JVM
down with SIGABRT on a headless CI box (exit 134). Guard the no-window path
with `gtk::is_initialized_main_thread()` and report "no monitors" instead, so
a tray-only app or a unit test gets the synthesized fallback rather than a
dead process.

The X11 work-area fallback behind that synthesized monitor is already
headless-safe (`XOpenDisplay(NULL)` returning NULL).
`LcdTextTest > Compose LCD text on an RGB surface has chromatic edges` has
been failing the macOS tao-tests job since #626 merged, which leaves every PR
targeting this branch red.

Skia can only fringe where the platform font host produces subpixel glyph
masks: DirectWrite and FreeType do, CoreText does not — macOS dropped
subpixel antialiasing in Mojave and renders grayscale whatever the surface's
PixelGeometry says. So `lcdScore == grayScore` there, which is this feature's
documented behaviour (`macOS and Linux stay grayscale` asserts the same thing
on the surface-props side) rather than a regression. Skip the pixel assertion
on macOS only; Windows and Linux keep it.
`tao-headful (ubuntu-latest)` timed out on the centring case while the four
other clone cases passed — so positioning works there; it is the *initial*
position that openbox overrides with its own placement policy. The v1 path
retries its Aligned centring for the same reason.

Split the assertion: the size and the strict centre where the platform honours
the request, and containment in the target work area on Linux. Prints the
observed rectangle so the CI log carries the numbers.
The native move / resize callbacks bumped a snapshot-state counter, and those
callbacks run on the event-loop thread from within the platform's resize
handling — which can be *inside* a Compose measure/layout pass. The
recomposition that write schedules then re-entered layout:
`IllegalArgumentException: performMeasureAndLayout called during measure
layout`, which took down the GraalVM headful battery on all three platforms.

Signal through a conflated channel instead. A send carries no snapshot
obligation, and the receiving coroutine resumes on the dispatcher once the
native frame has unwound, so publication happens outside the pass.
`setMenu` reaches `g_bus_get_sync(G_BUS_TYPE_SESSION, …)`, which takes no
timeout: on a runner with no session bus it blocks forever. The test task then
never finishes and hangs the whole `preMerge` job until the 30-minute cap kills
it — the failure mode pre-merge.yaml's own comment records ("`:launcher-linux:test`
has done exactly that three times"), and it just cost another PR two runs.

Skip when neither `DBUS_SESSION_BUS_ADDRESS` nor `$XDG_RUNTIME_DIR/bus` is
there: without a bus there is nothing to register against anyway.
…ow hooks

Rename DecoratedDialog's applyDialogOwnerRelationship to
applyWindowOwnerRelationship and add its inverse,
clearWindowOwnerRelationship, so a second secondary-window archetype can
reuse the Win32 / AppKit / GTK owner plumbing.

TaoWindow gains what a window that observes *another* window needs:
setOuterPositionPx (physical-pixel positioning, SetWindowPos on Windows so
a second-monitor DPI never leaks in), remove*Listener counterparts for the
multi-cast moved / resized / destroyed / fullscreen-prepare hooks, and an
onClosing hook fired at the start of requestClose() so owned windows can
sever their owner link before the OS would take them down with it.
Add SatelliteWindow, the floating tool-palette / inspector archetype on
Tao, with a Nucleus-level overload in nucleus-application:

- WindowPositioner / WindowAnchor / WindowConstraintAdjustment: pure
  placement geometry with a flip → slide → resize cascade, pinned by
  12 unit tests (registered in the scene battery and drift test).
- Anchored initial placement, parent-relative follow in physical pixels
  with echo filtering, offset re-capture when the user drags the
  satellite, suppression while the parent is fullscreen or maximized,
  and SatelliteWindowState.reanchor() to re-apply the rule.
- Reparenting keeps the satellite where it is on screen, including when
  the previous owner closes in the same frame: the owner link is severed
  before the old window is destroyed and the close decision is taken
  from composition, where the new owner is already known.
- Headful coverage: anchoring + follow, maximize suppression + restore,
  reanchor, and reparent-as-the-owner-closes. The harness gains a
  selectable satellite owner, a closable dialog and onCloseRequest
  routing for that last case.
- examples/satellite-demo: two document windows sharing one inspector.
…ble (#576)

The outer gate exists to catch chrome drift — TitleBar and frame disagreeing.
But the outer rectangle is a separate query from the resize event the scene
tracks: on a loaded Xvfb the X server's geometry lagged the scene by 3px over
two consecutive samples while `maxSceneVsInner` stayed at 0, and
`SUSTAINED_FRAMES = 2` promoted that into a failure. Only the outer query can
see such a lag; the scene has nothing to correct.

Fail on outer drift only when the scene also lost the inner size. The metric
line keeps reporting it either way.
Nine of the last nine `preMerge` jobs that hit the 30-minute cap — on main as
much as on feature branches, two of them running the full 6 hours before the
cap existed — were stuck in `:launcher-linux:test`. The culprit is
`g_bus_get_sync(G_BUS_TYPE_SESSION)` with `DBUS_SESSION_BUS_ADDRESS` unset:
GDBus then autolaunches, spawning `dbus-launch --autolaunch`, which waits on an
X display a headless process never provides. No timeout, so the JNI entry
point never returns, and with `nativeRegisterQueryHandler` the calling thread
also sits in a condvar wait for a worker thread that is itself stuck there.

Refuse to connect when the address is unset: a session bus that exists is
always advertised through that variable, so "unset" means "no bus", and the
bridge already treats a NULL connection as "launcher unavailable". This fixes
the headless-app case too — a service or CI process must not spawn dbus-launch.

Guard the second native test the same way (the quicklist one already was), and
drop the `$XDG_RUNTIME_DIR/bus` probe from its check: that fallback is libdbus
behaviour, not GDBus's.
`requestSize` and `WindowBoundsProvider(sizeProvider = …)` pair the size with
`WindowPositionProvider.Current`. Before the window exists that was resolved
against the placeholder rectangle the initial scope hands out, which pinned the
window to an absolute point. The v1 `rememberWindowState(size = …)` idiom this
replaces leaves placement to the window manager — keep that: an initial
size-only request now resolves to `WindowPosition.PlatformDefault`. Once the
window is up, `Current` reads the live outer rectangle as before.

Review follow-up on #634; the other points (AWT on the Tao thread, dummy peer,
dialog constraints, partial min size, host fallback, KDoc) were already
addressed by the clone.
The Linux headful job timed out once on the initial size converging and
passed the run before with the exact requested size; the diagnostic sat
after that wait, so the log had nothing. Print the outer rectangle once a
second during the wait.
Two X11 facts make it say nothing there. openbox applies its own placement
policy to a client's initial position (the window lands at 0,0 — the v1 path
retries Aligned centring for the same reason), so the centre is never
observable. And this is the only headful case whose window receives an
absolute position *before* `show()`: under Xvfb/openbox that window
intermittently stays at GTK's unallocated 1×1 for the whole 15 s budget —
the sizing trace shows it — while the very next window of the same run maps
normally. That is a pre-map race in the v1 create → move → show sequence,
independent of the clone, and not reproducible from a Windows box.

Size, position and screen requests after mapping stay covered on every
platform by the four sibling cases.
Compose's own `androidx.compose.ui.window.v2` types are no longer accepted by
`DecoratedWindow` / `DecoratedDialog` / `HostedWindow` / `HostedDialog` or
the `NucleusWindowHost` / `NucleusDialogHost` surfaces. On Tao that surface
could only ever be half-working — every scoped geometry provider (including
the ones `requestSize` / `requestPosition` build internally) and `requestScreen`
were accepted, logged and dropped, because Compose's scope needs a displayable
`java.awt.Window`. An API that silently ignores part of its contract is worse
than one that does not exist; the supported v2 surface is the clone,
`dev.nucleusframework.window.tao.v2`, where everything is applied and
migrating is one import.

Removed: `ComposeWindowV2Bridge`, the `ComposeWindowV2Access` friend-package
accessor, the Compose-typed `DecoratedWindow` / `DecoratedDialog` overloads,
`inspectableWindowBounds` / `requestInspectableBounds`,
`rememberSyncedWindowState` / `rememberSyncedDialogState`, the matching
nucleus-application overloads, adapters and host methods, and their tests.
The geometry helpers the clone shared with that bridge move into
`NucleusWindowV2Bridge`. `examples/tao-demo` and the host tests use the clone.
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).
…arded

A native popup layer hands its parent every mouse event that lands in the
margin it draws its shadow in. AppKit, though, keeps a whole button gesture on
the window that took its mouseDown — this panel — so the parent was being given
a press whose end it could never see. Two ways to lose it, both measured on a
tab strip's hover card: the region hit-test answers differently on the way up
(the press is what dismisses the card, which re-lays out the content under the
pointer), or the panel is ordered out between the two, and an ordered-out
window receives nothing at all. The release then reaches no one: not the panel,
not the owner's view, not the JVM.

What the owner is left with is a press that never ends. Compose holds the
gesture open, so the click it belonged to never happens — the tab under the
pointer is not selected — and the next press is read against the stale state
(`TaoComposeSceneHost` closes it out there, which is why a second click
appeared to work).

So the panel now tracks which buttons it forwarded: the rest of that gesture
follows its press to the parent whatever the region test says, and a panel
ordered out still holding one hands over the release AppKit will not deliver.
The two appearance comparisons failed intermittently on a real display, on
metrics that read one frame each.

`firstVisible` — and `slideIn`, measured from it — anchored on the first frame
where the colour probe caught the dialog. The dialog fades in over the scrim,
so that frame sits on the knife-edge of the detection threshold: it came out
bimodal on *both* layers, 0 ms on the runs that caught the faint start and
~60 ms on the runs that did not, and the comparison failed whenever the two
films happened to land in different modes. They now anchor half-way through
the fade, far from that edge and the same moment of the same animation either
way.

`hideMinHeightRatio` took the strict minimum over the fade-out. What it guards
against is a surface that *stays* collapsed for the whole fade; a single short
frame is a drawable caught mid-present, which an OS surface can show and a
scene drawing into the window canvas never can (measured: 1 px, and half the
dialog, in runs whose neighbouring frames were both full height). Read over
two consecutive frames it keeps the guard and drops the compositor.
…frame-ordering

fix(tao/linux): commit the resize frame with GTK's geometry, not after it (#444)
…mized

A maximized, tiled or fullscreen window has no CSD shadow ring, so the
content sub-surface's opaque region covers the toplevel edge to edge.
Mutter culls such a parent as obscured and stops answering its
`wl_surface.frame`; GDK freezes its frame clock on the unanswered
callback of the last commit GTK made in that state.

Two symptoms followed the #444 in-frame path:

1. The whole UI froze on maximize. The burst rendered only from GTK's
   `draw`, and its end was only evaluated inside the render pass — with
   GTK unable to paint, no frame was ever rendered again, and the
   coroutine continuations drained there never resumed. The burst's end
   (`endResizeBurstIfStale`) now runs from `onRedrawRequested` too, every
   `queue_draw` is watched (`askToplevelDraw`, 50 ms grace + a watchdog
   redraw through `DelayScheduler`) and an unanswered one drops the burst
   back to the event-loop path; the in-frame path is never armed while the
   window is maximized / tiled / fullscreen (`parentObscured()`).

2. Hover and drags were choppy while maximized although the app rendered
   at full rate. GDK3 holds a lone motion event until the frame clock's
   flush-events phase, which does not run while the clock is frozen, so
   motion was only delivered when another event arrived. GTK repaints
   the toplevel once after every maximize (`applyContentOffset`, to land
   the sub-surface at (0, 0)), and that commit went unanswered. The
   content's opaque region now always leaves its bottom row out, so the
   compositor keeps painting the toplevel and its callbacks keep coming.

Measured with WAYLAND_DEBUG over 9 maximize/restore toggles: every one of
the parent's 67 frame callbacks answered, max latency 25 ms (before: 2.5 s
or never), content at 90 fps throughout, no protocol gap over 22 ms.
A tag `v<semver>-dev-<id>` (convention `v2.6.0-dev-YYYYMMDDHHMM`, cut by the
`tag-dev` skill) now publishes the runtime modules to Maven Central and the
plugin to the Gradle Plugin Portal without running `preMerge` — the usual
Kotlin-ecosystem dev build, so a downstream app can consume 2.6 before it is
released. Natives are still built and verified; the JARs would be unusable
otherwise. Nothing else in the publish graph runs tests: `publishToMavenLocal`
pulls in `compileKotlin` → `jar`/`sourcesJar`/`javadoc` → `pom`/`publish` and
neither `test`, `apiCheck` nor `detekt`.

`release-tag-info` is the single place that classifies a tag. It also rejects
anything that is not `v<semver>`: every module derives its version with
`GITHUB_REF.removePrefix("refs/tags/v")`, so a bare `dev-2026…` tag would have
published a version literally named `refs/tags/dev-2026…` — permanently, on
Central.

Dev tags are excluded from `release-desktop` / `release-graalvm`: they publish
libraries, they should cut no GitHub release and burn no packaging matrix.

`validate-release-ref` now derives the branch a prerelease tag must live on from
the tag itself (`v2.6.0-rc.1` → `nucleus-2.6`). The pinned default was still
`nucleus-2.0`, a branch that no longer exists on origin, so the next rc would
have failed to fetch — the guard was protecting the previous release line.
kdroidFilter added a commit to NucleusFramework/EdgeTranslator that referenced this pull request Sep 20, 2026
Kotlin 2.4.20, Nucleus 2.6.0-dev, plus ktor, metro, sqlDelight, filekit,
materialKolor, activity-compose, compose-rules, stability-analyzer and
aboutLibraries.

Nucleus 2.6 (NucleusFramework/Nucleus#628) retires the AWT/JBR/JNI window
backends, so NucleusWindowUnsafe.awtWindow is gone and the AWT fallback in
fileKitDialog() no longer compiles. It ships in the same commit to keep the
tree buildable.

The XDG portal path is unchanged. Windows now parents the picker to
TaoWindow.nativeHandle (the HWND) via FileKitDialogParent.windows, which is
what that property's KDoc points at. macOS gets an unparented dialog:
nsWindowHandle is an NSWindow* and FileKit has no macos() factory yet. OS
detection goes through Nucleus' own Platform.Current.
kdroidFilter added a commit to NucleusFramework/EdgeTranslator that referenced this pull request Sep 20, 2026
* chore: bump dependencies and adapt to the Tao-only Nucleus 2.6

Kotlin 2.4.20, Nucleus 2.6.0-dev, plus ktor, metro, sqlDelight, filekit,
materialKolor, activity-compose, compose-rules, stability-analyzer and
aboutLibraries.

Nucleus 2.6 (NucleusFramework/Nucleus#628) retires the AWT/JBR/JNI window
backends, so NucleusWindowUnsafe.awtWindow is gone and the AWT fallback in
fileKitDialog() no longer compiles. It ships in the same commit to keep the
tree buildable.

The XDG portal path is unchanged. Windows now parents the picker to
TaoWindow.nativeHandle (the HWND) via FileKitDialogParent.windows, which is
what that property's KDoc points at. macOS gets an unparented dialog:
nsWindowHandle is an NSWindow* and FileKit has no macos() factory yet. OS
detection goes through Nucleus' own Platform.Current.

* fix: unpin LiteRT-LM and move to 0.17.1

0.14.0 was pinned because 0.15.0/0.16.x aborted the JVM on Windows CPU
generate (0xC0000409 in litertlm_jni.dll nativeGenerateContent /
sendMessage, LiteRT-LM#3230). 0.17.1 fixes it.

ThinkingConfig and maxOutputToken were commented out only because 0.14.0
predates them; both come back on JVM and Android.

LiteRtWindowsSmokeTest covers the three paths that used to abort — CPU,
GPU, GPU with speculative decoding. It needs a real .litertlm, so it skips
unless -Dlitertlm.test.model (or LITERTLM_TEST_MODEL) points at one. A
native abort takes the test JVM down, which Gradle reports as a crashed
worker — that is the signal we want.

* feat: enable multi-token prediction by default

MTP is roughly twice as fast on GPU and now holds up on Windows with
LiteRT-LM 0.17.1, so it becomes the default instead of an opt-in.

Snapshots written before the flag existed inherit the new default; an
explicit "off" is still preserved. The settings copy drops "experimental"
and opens with "on by default" across all 35 locales, with gender
agreement where the subject noun requires it.

* style: clear all ktlint and detekt violations

Mostly ktlintFormat output: import ordering, blank lines between
multiline when-branches, one argument per line, missing braces, a default
Modifier on ThemedScrollbar.

Two findings needed a real fix:

- TwoPane reused its first/second slots across the Column and Row
  branches, so crossing the 680.dp threshold discarded each pane's text,
  cursor and scroll state. Both slots are movableContentOf now.
- LocalMicLevels is a deliberate CompositionLocal: the level flow is
  provided once at the root and collected only in the two waveform
  composables, which keeps a 60 Hz stream out of every intermediate
  signature. Added to the ktlint and detekt allowlists that already exist
  for the other app-owned locals.

* feat: spellcheck the translation source field

Wraps the source BasicTextField in Nucleus' SpellcheckContextMenu: red
wavy underlines and suggestions in the context menu. The proofread screen
is left alone on purpose — it already corrects the text itself.

SpellcheckContextMenu is JVM-only, so it goes behind an expect/actual
Spellchecked(); Android is a passthrough since the IME already does this.

The language tag passed is the selected source language, not the UI
locale: SpellChecker.locale follows Locale.getDefault(), which the app
points at the interface language, so German input would otherwise be
checked against a French dictionary. Auto-detect has no dictionary to
pick and falls back to the process locale.

* chore: refresh the composable stability reference

shared.stability had not been regenerated since 189eda8, so stabilityCheck
was already failing on main with 23 drifts before this branch. The bumps
here and the new Spellchecked composable add a few more.

The drift is mechanical — composables added, removed or moved between
files, parameter counts shifting with the Compose and analyzer bumps — so
the reference is simply redumped.

* build: order stabilityCheck after the Kotlin compile tasks

stability-analyzer 0.14.0 gives every Kotlin compile task its own
build/stability/<task> directory, which the check and dump tasks read as a
whole. Gradle flags that as an undeclared dependency the moment a compile
task shares the graph, so `./gradlew stabilityCheck jvmTest` failed at
configuration time while either task alone was fine.

Ordering is all these tasks need — they only read what the compilation has
already written.
kdroidFilter and others added 3 commits September 22, 2026 07:52
Brings in #692 (macOS GraalVM Developer ID signing / notarization).

Conflict in AbstractElectronBuilderPackageTask.resignApp(): 2.6 re-signs the
embedded app extensions there, main re-signs the GraalVM dylibs under
Contents/MacOS/. Both kept — Contents/PlugIns and Contents/MacOS do not
overlap. The MacOS walk goes first so the app-extension block stays adjacent
to the final bundle seal, as its comment requires.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The edge double-click zoom (`-[NSWindow _zoomToScreenEdge:]`), the
Window-menu tiling and `zoom:` all end in AppKit's
`setFrame:display:animate:YES` — a blocking animator whose private
run-loop mode services no tao observer, so every step's `Resized` sat in
tao's queue until the animation had ended and the content snapped into
the final bounds: the #576 trailing, one path over.

`TaoWindow` now overrides `setFrame:display:animate:` and routes every
animated frame change to `util::animate_frame`, the stepper #678 wrote
for `set_maximized_async`, which now simply calls
`setFrame:display:YES animate:YES` (the resizable and non-resizable
branches merge). Guarded by `in_fullscreen_transition` / `fullscreen`
so the AppKit fullscreen transition (#327) is untouched.
`window_delegate::shared_state_of` reaches the `SharedState` from the
window class.

Headful gate `#576 AppKit frame animation (edge double-click zoom)
dispatches every step in time` drives the same AppKit path
programmatically (`setResizable(false)` + maximize) and asserts that
every `Resized` is dispatched while the native frame is at its size:
42 events up to 1880 px off before, 41 events at 0 px after.
…-576

fix(tao/macos): step AppKit's own frame animations too — edge double-click zoom (#576)

This branch has not been deployed

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