A retained-mode AppKit backend for Node.js: Core Animation (CALayer) layer trees, CoreText layout and drawing, IOSurface presentation, NSMenu, native control bezels and the privacy (TCC) authorizations. It is the macOS half of react-x11. Instead of immediate-mode draw calls, you build a persistent tree of layers, mutate their properties, and let the macOS WindowServer composite on the GPU — with implicit animations and correct retina handling for free.
Built as a drawing-backend experiment for react-x11-style reconcilers: React elements map
1:1 to layers, and commitUpdate becomes layer.set(props).
npm install # builds the native addon (macOS only, needs Xcode CLT)
npm run demo # hover/click the cards; press "q" to quit- CALayer tree — frame/bounds/position, backgroundColor, cornerRadius, borderWidth,
shadows, opacity, zPosition,
masksToBoundsclipping - Implicit animations — hover/click a card: plain
layer.set({...})property changes animate at 0.25s automatically - CATransaction — the orange ball tweens over 1.1s with easeInEaseOut just by grouping
a
positionchange in a transaction - Explicit CABasicAnimation — the spinner runs two infinite animations
(
transform.rotation.z+strokeEnd) entirely in the render server; they stay smooth even if the JS thread stalls - CATextLayer — retina-crisp text composited by the WindowServer
- CoreText — glyphs measured (
CTLine) and rasterized (CTFramesetter→CGImage) then set aslayer.contents, i.e. the glyph-atlas path - CAGradientLayer + layer.mask — the footer is a gradient masked by a text layer
- CAShapeLayer — CGPath commands, stroke/fill, dash patterns, animatable
strokeEnd - Hit testing — native
-[CALayer hitTest:]mapped back to JS wrapper objects - Events — mouse/keyboard from the NSApp event pump delivered to a JS callback
- Native controls — push buttons (incl. accent-filled default), checkboxes, radios, popup buttons, sliders, and switches rendered by AppKit itself and composited as layer contents; fully interactive (pressed states, toggles, slider drag) and re-renderable in dark/light appearance (the "Light / Dark" button flips all of them live)
Node's main thread is the process main thread on macOS, so the addon owns
NSApplication directly. In pump mode, the default, nobody calls [NSApp run]; instead JS
drives an event pump (nextEventMatchingMask: with distantPast) off a setInterval.
Core Animation animations execute in the render server, so their smoothness is
independent of the pump cadence — the JS timer only affects input latency. Threaded mode
(below) turns this around: the main thread stays in [NSApp run] and JS moves to a
Worker.
The window uses a layer-hosting NSView (we own the whole CALayer tree) with
isFlipped = YES, which makes AppKit give the hosted layer a top-left origin
(geometryFlipped) — coordinates match what a UI toolkit expects. Two flip gotchas are
handled in native code: hitTest: still takes bottom-up points, and
renderInContext: ignores geometryFlipped entirely (snapshots therefore capture the
window's real composited pixels via CGWindowListCreateImage, which needs no
screen-recording permission for the process's own windows).
Pump mode has two costs. Input waits for the next tick, up to 8 ms. And every modal loop
AppKit runs — live resize, menu tracking, a drag, runModal — runs inside pump2(),
so timers, sockets and even microtasks wait for the gesture to end (a drag once held a
25 ms interval for 31 s, sidorares/react-x11#484). Threaded mode (#49)
swaps the roles: the process main thread parks in a real [NSApp run] for the life of the
app, and the renderer's JS runs on a worker_threads Worker, which no AppKit loop can
reach. Pump mode stays the default; calling runMain() is the switch.
// main.js — the process main thread
const { Worker } = require('node:worker_threads');
const { native } = require('@windowkit/appkit');
native.initApp();
new Worker('./renderer.js');
const code = native.runMain(); // returns when the run ends
process.exit(code ?? 0); // only this thread may end the process
// renderer.js — the worker
const { native } = require('@windowkit/appkit');
native.connect((batch) => {
for (const ev of batch) {
if (ev.type === 'signal') process.emit(ev.signal); // SIGINT never reaches a Worker
else route(ev);
}
});
// ...and when the app is done:
native.requestExit(0);| verb | |
|---|---|
runMain() |
The process main thread only. Returns the code requestExit gave; null when the connected environment ended without asking (its own process.exit, which in a Worker ends only the worker, or an uncaught error); 128 + n for a signal with nobody connected to hear it. |
connect(onEvents) |
From the renderer's thread, one environment at a time. onEvents(batch) gets an array of the same event objects pump mode's callback gets one by one. |
requestExit(code) |
Any thread. Ends the run; runMain returns code. |
threaded() |
runMain is running. |
windowState(windowNumber) |
getWindowFrame's shape for a createWindow2 window, from the published copy (below); null for no such window. Any thread, either mode. |
activationPolicy() |
The published activation policy. |
pingUI(tag), postModalLoop('menu' | 'modal', ms) |
Test hooks: a command answered by ui-pong { tag, mode, drained } from the UI thread, and a pop-up menu's tracking or an NSAlert's runModal, ended by a timer and bracketed by modal-loop-begin / modal-loop-end. |
Events out. Every producer builds a plain record (never a JS object off its thread).
With the channel open, records are appended to one queue, and the first append after a
delivery makes one threadsafe call. The worker takes the whole queue as one batch, so a
worker busy for 200 ms gets what it missed together. Consecutive mousemove in one window
folds to the latest position before it crosses. Whatever is emitted before connect —
the launch's URL, input that arrived while the worker started — is the first batch. A
delivery runs in a callback scope, so a microtask queued inside onEvents runs right
after it. An exception thrown from onEvents, or from any callback the bridge answers
through, is that environment's uncaught exception: process.on('uncaughtException') sees
it, and with no handler the worker ends, and with it the run. The channel holds the
worker's loop open while any window or status item
exists, and lets it go when none does, so an app with nothing on screen can end.
Commands in. The command queue is a version-0 CFRunLoopSource on the main run loop, in
kCFRunLoopCommonModes, so it is drained during menu tracking (NSEventTrackingRunLoopMode),
runModal (NSModalPanelRunLoopMode) and live resize too. Each drain applies its
batch inside one CATransaction with implicit actions off. A command that starts a modal
loop runs from a run-loop callout of its own once its drain is done, so the rest of the
batch never waits behind the gesture.
The verbs from a worker (#51). The verbs are the same in both modes.
Each runs inline on the main thread, as it always has, and is queued from any other
thread. From a worker, what changes is the shape of what a verb returns, because JS never
waits on the UI thread (test/threaded-verbs.js covers each row):
| from a worker | verbs |
|---|---|
| unchanged, on the calling thread | surfaces and video surfaces, every ctx* (ctxDrawSymbol included), layouts and fonts, symbolSize, pasteboardTypeForMIME, pasteboardTypeInfo, contentTypeFor, colorSpace |
| a command, answering nothing | initApp, setActivationPolicy, setAppName, activateApp, showWindow, hideWindow, setWindowFrame, setWindowTitle, setWindowMinMax, setWindowIgnoresMouseEvents, invalidateWindowShadow, destroyWindow2, setCursor, setMainMenu, setDockMenu, setDockBadge, cancelUserAttention, setStatusItem, setStatusItemMenu, removeStatusItem, registerDropTypes, setDropResponse, pasteboardWriteText, pasteboardWrite, pasteboardClear, cancelPanel, cancelPopUpMenu; the test posts postMouseEvent, postKeyEvent, postAppleEvent, postAccessibilityDisplayChange |
| a handle at the call | createWindow2, followed by window-created { handle, windowNumber }; every event about the window, input included, carries handle, so ev.handle === win |
windowRootLayer, allocated with the window |
|
createStatusItem: its clicks carry the handle, and it is held until removeStatusItem |
|
requestUserAttention: a bridge id |
|
openPanel / savePanel: the answer through the callback, as before |
|
popUpMenu: the menu opens from a callout of its own, and answers through the callback |
|
| the published copy | getWindowFrame, windowIsVisible, windowNumber (all null until the window is made), listScreens, accessibilityDisplayOptions, activationPolicy, appInfo, pasteboardChangeCount |
| a callback, the last argument | pasteboardReadText, snapshotWindow, snapshotStatusItem, windowNumberAtPoint, mainMenuInfo, dockMenuInfo, statusItemInfo, activateMenuItem, activateDockMenuItem, activateStatusItemMenuItem, popUpMenuInfo, activatePopUpMenuItem, clickStatusItem, dragItems, dragItemData, dragItemString, postDragEvent. On the main thread each still answers synchronously when no callback is given. |
| events | beginDrag: drag-session-began, or drag-session-ended with nothing dropped. Its provide is a TypeError; give every value up front. |
A few verbs behave differently from a worker:
setDropResponsecannot answer thedraggingEntered:that is running as its event crosses. It sets the standing answer for what follows. To make up for it, a drop carriesitems: [{ types, strings }], its text and URL representations read in.- An app-modal panel can be cancelled with
cancelPanel, because the queue drains insiderunModal. sampleScreenColorand a location permission request make their AppKit calls on the UI thread.pump2and the first-generation API (createWindow,pump,setEventCallback,closeWindow,windowScale,windowContentSize,hitTest,drawControl,appearanceIsDark) throw off the main thread.
Frames from a worker (#52). Pixels stay on the renderer's thread:
drawing into surfaces, CoreText and ctxGetImageData all work there. Layer changes go to
the UI thread, a frame at a time (test/threaded-frames.js):
- One batch per frame. Between
txBeginandtxCommiton a worker, the layer verbs record into that thread's frame batch. These arecreateLayerand its kinds,set*Props,addSublayer,removeFromSuperlayer,addAnimation,removeAnimationandremoveAllAnimations,setContentsImage,setLayerContentsIOSurfaceandsurfaceToLayer. The outermosttxCommitposts the batch as one command. The UI thread applies it in the order it was recorded, in one commit, withtxBegin's options (disableActions,duration,timing) as pump mode would have them. A layer verb outside anytxBeginis a command of its own, with actions on, as in pump mode's implicit transaction. - Layers are handles answered at the call. The
CALayeris made on the UI thread when the frame applies, so no layer is touched by two threads.addAnimationstill answers its duration at the call, andpresentationValuetakes a callback. - Buffers change hands by event.
surfaceToLayertakes the bitmap at the call, so later drawing does not reach the frame already posted.setLayerContentsIOSurfaceflips when the frame applies. The renderer must not draw into the buffer the flip replaced until eithersurface-released { id }names it, sent once the frame has been committed, orsurfaceIsInUse(surface)answers false.
The live-resize handshake (#53). In pump mode a resize stays in step for
free: window-resize runs the renderer inside windowDidResize: itself, so the new frame
commits with the moved edge. From a worker the frame comes back later, and without a
handshake the edge moves first while the content catches up a frame or more behind.
setResizeHandshake(win, { waitMs }) turns the handshake on (0, the default, is off):
- When the window's size changes, whether by a live resize or
setWindowFrame, the UI thread sendswindow-resize. - It then waits, never longer than
waitMs, for a frame batch committed with that size:txCommit({ width, height }), the renderer echoing the event's size. - It applies that batch inline, so the frame lands in the same transaction as the new window size.
- Each wait is reported as
resize-handshake { width, height, live, waited, met }. - A live resize is bracketed by
window-live-resize { phase: 'begin' | 'end' }, in both modes (#63). AppKit calls nothing when the pointer stops or lifts, so the end is how a renderer knows to run the measured layout it deferred during the drag. The published window state (getWindowFrame,windowState) carriesliveResizetoo.
JS never waits on the UI thread, so the worst case is the deadline. A frame that misses it
shows the last frame at the new size, with the root layer's backgroundColor in the newly
exposed edge. The deadline is a strict dispatch timer (DISPATCH_TIMER_STRICT, zero
leeway), not a plain timed wait. Under a lower-QoS task policy, as on a CI runner or under
taskpolicy -c utility, the kernel coalesces timers: a 15 ms timed wait woke only when the
worker's own 60 ms timer fired.
Measured by test/threaded-resize.js on an M1 Pro, with 3 ms of layout per frame and a
50 ms budget:
| met | waited, p50 | |
|---|---|---|
20 setWindowFrame steps |
20 of 20 | 3.07 ms |
| a live resize (AppKit's own tracking, driven by posted mouse events) | 28 of 28 | 3.11 ms |
| a frame 60 ms late against a 15 ms budget | no | stopped at the deadline, ~16 ms |
Control bezels (#54). The NSCell or NSControl behind a bezel is made on the UI thread. Called from a worker, they used to draw correct pixels and then crash the process at exit.
measureControl(params, cb)answerscb({ width, height }).drawControlIntoSurface(surface, params, cb)draws straight into the worker's surface, with nothing copied, andcb()says it is done; until then the renderer leaves the surface alone.
On the main thread without a callback both answer in the call, as before. Off it, a call without a callback is a TypeError.
Published state. The UI thread keeps a copy, under a lock, of what a renderer reads
back synchronously: each window's content rect, visibility, occlusion, key state and
scale; the screen list; the accessibility display options; the activation policy; and the
pasteboard's change count, polled every 250 ms since another app's write announces
nothing. While runMain runs, listScreens, accessibilityDisplayOptions and
pasteboardChangeCount answer from it when called off the main thread. On the main
thread they still ask AppKit live, as in pump mode.
A copy that changes with no event after it is a renderer left waiting, so showWindow
sends window-shown { handle } once the window is on screen and published. AppKit's own
notifications usually say as much, since the window moves as it is ordered in, but a
window taller than its screen is constrained, resized and moved before it is visible, and
nothing after that says it is shown. A worker that holds the frames of a window it cannot
see waited on that window for good.
Exit and signals. Before the run stops, menu tracking is cancelled and an app-modal
loop stopped, since [NSApp stop:] only ends the innermost loop. A drag session or a live
resize cannot be ended from code, so the exit waits for the button to come up. While the run lasts,
SIGINT, SIGTERM and SIGHUP are ignored as signals and read through a
DISPATCH_SOURCE_TYPE_SIGNAL, each arriving as signal { signal: 'SIGINT' }.
applicationShouldTerminate: answers Cancel and sends app-quit-request, as in pump mode.
A worker's console.log and process.stdout are forwarded through the main thread's event
loop, which is parked, so write with fs.writeSync(1, …) on the worker. process.exit
there ends only the worker; ending the app is requestExit.
Measured by test/threaded.js on an M1 Pro, macOS 15.2, Node 26:
| command + event round trip | 0.06 ms p50, 0.10 ms p95 |
| a microtask queued inside a delivery | ran 0.01 ms later, p50 |
| commands sent during a pop-up menu's tracking | 65 of 65 applied inside NSEventTrackingRunLoopMode; the worker's 5 ms timer's worst gap 8.7 ms |
commands sent during an NSAlert's runModal |
80 of 80 applied inside NSModalPanelRunLoopMode; worst gap 7.1 ms |
const ca = require('@windowkit/appkit');
const { app, Window, Layer, TextLayer, GradientLayer, ShapeLayer,
transaction, withoutAnimations } = ca;
const win = new Window({ width: 800, height: 560, title: 'hi' });
const card = new Layer();
card.set({
frame: [32, 108, 228, 128], // top-left origin, points (not pixels)
backgroundColor: [0.98, 0.42, 0.36, 1],
cornerRadius: 14,
shadowOpacity: 0.5, shadowRadius: 12, shadowOffset: [0, 6],
});
win.root.add(card);
// implicit animation: just set the property
card.set({ backgroundColor: [0.36, 0.65, 0.98, 1] });
// batched, with custom duration/curve
transaction(() => card.set({ position: [400, 300] }),
{ duration: 1.1, timing: 'easeInEaseOut' });
// no animation (e.g. initial tree construction, reconciler commits)
withoutAnimations(() => card.set({ opacity: 0.5 }));
// explicit animation on any animatable keyPath — from/to, keyframes ('values') or a
// spring ('spring'); a curve by name or by control points; see "Animations"
card.animate('transform.rotation.z',
{ from: 0, to: Math.PI * 2, duration: 1, repeat: Infinity, timing: 'linear' });
card.animate('transform.translation.y',
{ values: [0, -6, 6, -6, 0], duration: 0.3, timing: [0.33, 1, 0.68, 1], id: 'shake' });
// a transform as a whole matrix, CSS's matrix(a, b, c, d, e, f) or matrix3d(…) —
// y grows down, as in CSS; see "Transforms as matrices"
card.set({ transform: { matrix: [0.87, 0.5, -0.5, 0.87, 0, 0] } });
// text, two ways
const label = new TextLayer();
label.set({ frame: [0, 14, 228, 22], contentsScale: win.scale })
.text({ string: 'hello', fontName: 'HelveticaNeue', fontSize: 15,
color: [1, 1, 1, 1], align: 'center' });
card.add(label);
const glyphs = ca.text.render({ text: 'CoreText', fontName: 'Menlo',
fontSize: 13, color: [1, 1, 1, 1], scale: win.scale });
new Layer().set({ frame: [10, 10, glyphs.width, glyphs.height] }).setImage(glyphs);
ca.text.measure({ text: 'CoreText', fontName: 'Menlo', fontSize: 13 });
// -> { width, ascent, descent, leading }
// masks, gradients, shapes
const g = new GradientLayer();
g.gradient({ colors: [[1, 0, 0, 1], [0, 0, 1, 1]], startPoint: [0, 0.5], endPoint: [1, 0.5] });
g.set({ mask: someTextLayer });
const shape = new ShapeLayer();
shape.shape({ path: [['move', 0, 0], ['line', 50, 80], ['arc', 25, 25, 20, 0, Math.PI, false]],
strokeColor: [1, 1, 1, 1], lineWidth: 4, lineCap: 'round', fillColor: null });
// input + hit testing
app.onEvent((ev) => { // mousedown/up/move/drag, wheel, keydown/up
const layer = win.hitTest(ev.x, ev.y); // deepest Layer wrapper or null
});
app.run({ onTick: () => { if (!win.visible) process.exit(0); } });
win.snapshot('/tmp/out.png'); // real composited pixels of the windowCore Animation runs an animation in the render server: the pump's cadence and a busy JS
thread do not touch it. The verbs here are shaped for a renderer that keeps its own model
of what is animating and hands over only the pixels — the layer's model value set under
disableActions, an explicit animation carrying the presentation from where it was to
where the model now is (react-x11's
animation design,
#29).
// the model goes straight to its target, with no implicit animation…
withoutAnimations(() => card.set({ opacity: 1 }));
// …and an explicit one carries the pixels there
card.animate('opacity', { from: 0, to: 1, duration: 0.2, timing: [0.33, 1, 0.68, 1], id: 'fade' });
// native.addAnimation(layer, keyPath, opts, key) is the same call, and returns the
// duration in seconds — a spring's settling timeThree kinds, told apart by which option is present:
| options | animation | |
|---|---|---|
{ from, to, duration } |
CABasicAnimation |
duration defaults to 0.25s |
{ values, keyTimes?, timings?, calculationMode? } |
CAKeyframeAnimation |
keyTimes one per value in 0..1, never decreasing (evenly spaced when omitted); timings one curve per segment; calculationMode linear (default), discrete, paced, cubic, cubicPaced |
{ spring: { mass, stiffness, damping, initialVelocity } | true, from, to } |
CASpringAnimation |
CA's defaults for what is omitted (true is all of them); the duration is the settling time unless a duration cuts it short |
A value — from, to, an entry of values — is a number, a point [x, y], a colour
[r, g, b, a] in sRGB like every colour in this API, or a transform { matrix } /
{ matrix3d } (below).
Options every kind takes:
| option | |
|---|---|
timing |
a curve name — linear, easeIn, easeOut, easeInEaseOut, default, or their CSS spellings ease-in, ease-out, ease-in-out, ease — or cubic-bezier control points [x1, y1, x2, y2]. An unknown name is a TypeError |
repeat |
a count, or Infinity |
autoreverse |
turn around at the end of each pass |
additive |
the values are deltas over the model value, and several in flight on one key path sum |
cumulative |
each repetition starts where the last ended |
delay |
seconds before it starts; the layer shows from while it waits (fillMode: backwards). A negative delay is CSS's: the animation starts that far in and ends that much sooner, a begin time in the past rather than a timeOffset, which would wrap a one-shot animation round to its start |
speed, timeOffset |
CAMediaTiming's; speed: 0 with a timeOffset is an animation paused at that time |
hold |
keep the final value on screen after the end (removedOnCompletion: NO, fillMode: forwards) — the model value stays what it was |
id |
report the end as a backend event (below) |
Control points rather than names are what a renderer with its own interpolator needs: the
same four numbers evaluate to the same curve on its side and in the render server, where a
name means whatever each side thinks it means — react-x11's ease-out is
cubic-bezier(0.33, 1, 0.68, 1), and CA's named easeOut is (0, 0, 0.58, 1), a fifth of the
range apart at t = 0.35. transaction(fn, { duration, timing }) takes the same forms.
Retargeting without reading back. Set the model to the new target and add an additive
animation from: old − new, to: 0: the pixels carry on from wherever the previous animation
had got to, because the previous one is still running and the two sum. That covers every
numeric key path (opacity, position, transform.*, cornerRadius, …). A colour cannot
be additive; for that there is presentationValue.
native.presentationValue(layer, keyPath) (layer.presentationValue(keyPath)) — the
value the render server is showing for that key path right now, animations applied: a
number, a point or size as [x, y], a rect as [x, y, w, h], a colour as [r, g, b, a], a
transform as its sixteen components; null before the layer's first commit. A colour comes
back in sRGB, the space it went in, so it can be handed straight back as a from —
but CA interpolates in the display's space, so the components' midpoint there is not this
space's midpoint.
native.colorSpace() — 'sRGB': the space every colour crossing this bridge is in —
a layer's backgroundColor, borderColor and shadowColor, a shape's fill and stroke,
a gradient's stops, an animation's from/to, a presentation value, a text span's ink
and the surfaces createSurface makes. One space, so a colour set on a layer and the
same colour rastered into a surface composite to the same pixels. An IOSurface says it
itself: those createSurfaceIOSurface makes and surfaceFromIOSurfaceID draws into name
sRGB as their colour space, and setLayerContentsIOSurface names one that names none — a
GL target — so a surface presented on a layer is matched to the display as a layer colour
is. Core Animation shows an IOSurface that names no space as the display's own numbers:
until these named theirs, a window presented that way went out on a wide-gamut panel as
the panel's own red, (255, 0, 0) in its space, beside a layer's sRGB red, (234, 51, 35),
and on a monitor with a profile of its own even a grey differed. (Before 0.6 the layer
half was Generic RGB, which the compositor converts on its way to the display: #dbe7f4
on a layer showed as (228, 236, 245) beside a surface's (219, 231, 244), and a colour
animation landed on a model value that did not match its own to. A caller that draws
both ways — react-x11's layer promotion — feature-detects the verb.) speed: 0, timeOffset: t plus a read is how a curve is sampled without
waiting for it, which is how test/animation.js checks every curve above.
Transforms as matrices. setLayerProps' transform takes Core Animation's
components — { translateX, translateY, rotate, scale, scaleX, scaleY } — or a whole
matrix, in CSS's two spellings: { matrix: [a, b, c, d, e, f] } is matrix(), a point
(x, y) going to (a·x + c·y + e, b·x + d·y + f) with the translation in points, and
{ matrix3d: [m11, m12, …, m44] } is matrix3d(), whose sixteen numbers are
CATransform3D's in the order presentationValue reads one back. The same objects are
animation values, so a key path of transform can run keyframes a caller computed — a
CSS transform list sampled where CSS interpolates it, which CATransform3D's own
interpolation would not reproduce for a turn of a whole circle or a mixed list. The
window's root layer is geometry-flipped, so y grows down here as it does in CSS: a
positive angle turns clockwise and a positive translation moves down, with no sign to
change on the way in. A matrix of the wrong length or with a number that is not finite
is a TypeError, and nothing is applied.
Contents gravity. setLayerProps' contentsGravity is how a layer's contents sit in
bounds that are not their size: resize, Core Animation's default, stretches them;
resizeAspect and resizeAspectFill scale them keeping their shape; center, top,
bottom, left, right, topLeft, topRight, bottomLeft and bottomRight leave
them at their size, anchored there. A window or a pane whose bounds grow before a frame
of the new size is drawn shows its last frame by this: stretched by default — a page's
left column scaled a little, then drawn back at its size — or anchored, a strip of what
is under it showing where the new frame will be. The names are as they look, top being
the window's top. Core Animation's own are in the layer's space, where top is the
greatest y, which under a window's geometry-flipped root is the bottom of the screen, so
the bridge turns them over where the layer's space is flipped; presentationValue(layer, 'contentsGravity') answers Core Animation's name, bottomLeft for a topLeft here.
Any other name, or a value that is not one, is a TypeError, and nothing is applied.
Contents rect and center. setLayerProps' contentsRect is the part of a layer's
contents it shows, and contentsCenter the part that stretches when they are scaled to
bounds that are not their size — what is outside it keeps its size, at its edge of the
bounds. Both are rects in the unit square of the contents, [x, y, width, height], named
as they look: y runs down from the window's top, where Core Animation's runs up from the
layer's least y, so the bridge turns them over where the layer's space is not flipped,
as it does gravities. null is the whole square, the default for both. Between them a
frame whose bounds outgrew it can be shown at its size with its last column and row
carried over the rest — the way a page continues past its edge, where a gravity either
stretches the frame or leaves a strip of whatever is under it: contentsRect crops an
axis the bounds shrank on, and contentsCenter names the last pixel. Name its middle, a
sliver of it: a centre a whole pixel wide stretches what the filter makes of it and the
pixel before it, a blend of the two, and Core Animation ignores a centre of no width.
Four finite numbers or null, or the call is a TypeError naming the key, and nothing is
applied.
native.transformForms() — ['translate', 'rotate', 'scale', 'matrix', 'matrix3d']:
the forms a transform takes on its way in. A caller that hands over matrices
feature-detects matrix here; before 0.19 an object holding one was read as no transform
at all, and refused as an animation value.
The end of an animation. With an id, the animation's delegate forwards
animationDidStop:finished: through setBackendEventCallback as
{ type: 'animation-end', id, key, keyPath, finished }finished is false for an animation that was removed (removeAnimation,
removeAllAnimations) or whose layer left the tree before it ran out. It arrives inside a
pump2(), like a window delegate's events. Without an id no delegate is set and nothing is
reported.
There is no WindowServer API for control drawing — AppKit draws controls in-process via
the NSCell architecture, and cells happily draw offscreen (the technique WebKit's
RenderThemeMac and Firefox's nsNativeThemeCocoa use for native form controls).
ca.controls.render() rasterizes a cell at retina scale under a chosen NSAppearance
and returns a CGImage for layer.contents:
const img = ca.controls.render({
kind: 'push', // 'push' | 'flexiblePush' | 'checkbox' | 'radio' | 'popup' | 'slider' | 'switch'
title: 'Click me',
pressed: false, // drive this from your own mouse events
state: 1, // on/off for checkbox/radio/switch
isDefault: true, // push: accent-filled default button
value: 0.5, // slider position
controlSize: 'regular', // 'mini' | 'small' | 'regular' | 'large'
appearance: 'dark', // 'system' | 'dark' | 'light'
scale: win.scale,
}); // -> { image, width, height, scale } (natural cellSize if
// width/height omitted)
new Layer().set({ frame: [x, y, img.width, img.height] }).setImage(img);Two render paths inside drawControl:
- Cell path (
NSButtonCell,NSPopUpButtonCell):drawWithFrame:inView:into a bitmapNSGraphicsContext, wrapped inperformAsCurrentDrawingAppearance:so dark mode and the user's accent color apply. - Offscreen-view path (
NSSlider,NSSwitch): modernNSSliderCellno longer draws offscreen (it defers to the view's layer machinery), andNSSwitchhas no cell at all, so these render a real unparentedNSControlviadisplayRectIgnoringOpacity:inContext:.
A push bezel is one height: drawn into a taller frame, AppKit draws its own 22pt bezel
centred in it. flexiblePush is NSBezelStyleFlexiblePush, the same button stretched to
its frame — the bezel AppKit gives a title that wraps, and what WebKit and Gecko draw a
push button taller than a push button with. At the push's height the two are the same
pixels, except that the default button's accent fill is flat in the flexible bezel.
The demo re-renders a control's image on each state change; a real renderer would cache
per (kind, size, state, appearance) and nine-slice-stretch bezels with
layer.contentsCenter. Menus/popovers are deliberately not painted — their
vibrancy materials need private API to reproduce; expose real NSMenu instead.
native.postMouseEvent(win, 'down'|'up'|'move'|'drag', x, y) synthesizes events through
the real pump — used by the demo's self-test (CAL_CLICKS="x,y;x,y" npm run demo).
A surface (native.createSurface(wPx, hPx, scale), or createSurfaceIOSurface for the
zero-copy presentation kind) is a CGBitmapContext with a top-left origin and the canvas
drawing verbs over it — ctxFillRect, ctxDrawSurface, ctxDrawGlyphs and the rest.
Two of those verbs are what a 2d context needs to composite one surface into another
without paying for a CGImage:
-
native.ctxSetBlendMode(surface, mode)— canvas'sglobalCompositeOperation, in CoreGraphics' spelling. Every canvas name maps to an exactCGBlendMode:source-over(the default) throughxorandlighter, and the separable and non-separable blend modes below them,multiply…luminosity;clear,plus-lighterandplus-darkerare CoreGraphics' own and go through too. A name off that list leaves the mode in force alone and answersfalse— canvas's rule for an unknown value, so a caller can keep its own property in step — and a known one answerstrue. The mode is graphics state, soctxSave/ctxRestorebracket it.copyis the one that matters for compositing: it makes a paint a replacement rather than a blend, alpha included, which is what an offscreen surface presented into a window wants and whatPictOp.Srcmeans on the X11 side. -
native.blitSurface(src, sx, sy, w, h, dst, dx, dy, clip?)— a rowmemcpyof a rect of one surface into another, at surfaces of any two sizes, returning the destination rect it actually wrote as[x, y, w, h]ornullwhen the intersection came out empty.copySurfaceRegionis the same-size special case a swapchain wants; this is the general one, for a caller compositing an offscreen surface into a window at a translate — a terminal's grid, an element's retained scene. It is exactly what acopy-modectxDrawSurfaceat 1:1 under a translate-only transform produces, byte for byte, without building aCGImageof the whole source: 2000x1620 into a window-sized surface is 1.2ms that way and 0.42ms this way on an M1 Pro.Coordinates are device pixels, top-left origin — the convention
createSurface's CTM gives user space, and the onecopySurfaceRegion's rects already use. Neither the destination's CTM nor its clip is visible to amemcpy, soclip, when given, is[x, y, w, h]in the destination's pixels and the caller passes the clip it is drawing under; a damage region of several rects is several calls. The rect copied is the destination rect intersected with that clip and with both surfaces' bounds, the source origin moving with it. An IOSurface-backed surface at either end wants the usualsurfaceLock/surfaceUnlockbracketing, and two handles onto one bitmap — a shared IOSurface looked up at both ends, or a surface onto itself — are refused, because overlappingmemcpyrows have no defined result.
const grid = native.createSurface(2000, 1620, 2); // the offscreen scene
const win = native.createSurface(2200, 1800, 2); // what the window presents
// the composite, clipped to the paint pass's damage rect
native.blitSurface(grid, 0, 0, 2000, 1620, win, 40, 30, [100, 100, 1200, 900]);
// -> [100, 100, 1200, 900] — the clip, which the blit covered whole
// the same pixels the slow way, for anything that is not a 1:1 translate
native.ctxSetBlendMode(win, 'copy');
native.ctxDrawSurface(win, grid, 0, 0, 2000, 1620, 40, 30, 2000, 1620);
native.ctxSetBlendMode(win, 'source-over');native.ctxDrawSurfaceFaded(dst, src, sx, sy, sw, sh, dx, dy, dw, dh, alpha)—ctxDrawSurfaceunder an alpha below 1, which the caller passes again because it is the alpha it set withctxSetGlobalAlphaand CoreGraphics keeps no getter for it. CoreGraphics draws an image under an alpha below 1 at some fifteen times what the same draw costs at 1, and composites a transparency layer the same way, so a group faded on a surface of its own — CSSopacity, a fade — cost more than drawing again what was on it. This scales the source rect's premultiplied pixels by the alpha into a bitmap of their own and draws that at 1: the same colours within a unit a channel, and a 556x300 surface in 0.25ms where the alpha path takes 1.24ms on an M1 Pro. The destination keeps the alpha it was set to. A source rect off the pixel grid or past the source, an IOSurface-backed source, a surface drawn onto itself and an alpha of 1 or 0 are drawn asctxDrawSurfacedraws them, under the alpha already set — so a caller can call it for every draw under an alpha, and feature-detect the verb to know whether a faded surface is cheap here.
native.ctxSetGlobalAlpha(win, 0.6);
native.ctxDrawSurfaceFaded(win, card, 0, 0, 556, 300, 100, 100, 556, 300, 0.6);native.ctxSetImageSmoothing(surface, quality)— how an image or a surface drawn scaled or turned is resampled:'none'(the nearest pixel),'low','medium'or'high', the 2D canvas'simageSmoothingEnabledandimageSmoothingQuality. It is part of the graphics state, soctxSaveandctxRestorescope it, and every context starts at'medium'. At'medium'CoreGraphics resamples the whole source image for a draw through a matrix, whatever the clip, so a surface drawn a tile at a time — a box in perspective, each tile under its own clip and the matrix of the plane over it — costs each tile all of the surface: 784 tiles of a 1400x1120 surface take some 200ms on an M1 Pro, and 27ms at'low', which is bilinear, what a layer in perspective is drawn with. A draw at 1:1 on whole pixels is the same at every quality.
native.ctxSave(win);
native.ctxSetImageSmoothing(win, 'low');
// …the tiles…
native.ctxRestore(win);A frame a decoder hands over as bytes — ffmpeg over a pipe, a WASM decoder, an addon — goes on the screen the way VideoToolbox's own output does: as a YCbCr IOSurface a plain layer shows, converted and scaled by the render server. The CPU's share is the copy in.
native.createVideoSurface(widthPx, heightPx, options?)→{ handle, iosurfaceId }.formatis'NV12'(the default: two planes,420v, or420fwithrange: 'full') or'BGRA'.colorSpace—'bt709'(the default),'bt601'or'bt2020'(SDR) — andrange—'video'(the default) or'full'— say what an NV12 surface's numbers mean, and go on it as the attachments CoreVideo gives a decoded frame: the YCbCr matrix, the primaries and the transfer function, which Core Animation reads. Shown throughsetLayerContentsIOSurface(layer, iosurfaceId), which keeps a colour a surface names (or derives from CoreVideo's tags: see below). A new surface is black.native.writeVideoSurface(target, format, planes, options?)— one frame,planesan array of Buffers or typed arrays:'NV12'(Y, then CbCr pairs),'I420'(Y, Cb, Cr) or'BGRA', each planeoptions.strides[i]bytes a row (packed by default) and checked to hold every row; a 4:2:0 chroma plane isceil(width / 2)byceil(height / 2). An NV12 surface takes NV12, and I420 with its chroma interleaved on the way in; a BGRA surface takes BGRA, and YCbCr converted.targetmay also be a 2D surface (createSurface,createSurfaceIOSurface), for a renderer that draws the frame in its own paint order — something is drawn over the video — rather than lifting it onto a layer: the frame lands at the surface's top-left,options.width/heightof it (the surface's size by default), converted withoptions.colorSpaceandrange. A frame's fourth byte is ignored: a frame is opaque.native.videoSurfaceIsInUse(handle)— whether anything still reads the surface; a renderer keeping a ring writes only into one this answersfalsefor, and in threaded modesurface-released { id }names the surface a frame took off a layer.native.releaseVideoSurface(handle)— free it now; idempotent; a layer showing it keeps it until it lets go. Every other verb on a released handle throws.native.videoFormats()→{ surfaces: ['NV12', 'BGRA'], frames: ['NV12', 'I420', 'BGRA'] }, for a renderer to ask rather than assume.
Two things the verbs exist to get right, both measured on screen (test/video-surface.js):
- Three planes draw nothing. A layer shows
420v,420fand2vuysurfaces and showsy420/f420as transparent, so an I420 frame is interleaved into NV12, in the copy the write makes anyway — 0.27ms at 1080p on an M1 Pro, against 0.07ms for NV12 as it is. - The same colours on a layer, in a bitmap and under
AVPlayerLayer. An NV12 surface is named the colour space CoreVideo makes of its tags, andsetLayerContentsIOSurfacenames one the same way for a tagged surface it is handed by id. Shown with the tags alone, Core Animation linearises a frame tagged BT.709 throughout with the exact 709 curve, whereAVPlayerLayer— the platform's player — VideoToolbox's pixel transfer and Core Image all use Apple's 1.961 gamma: a video-range grey of Y′=50 was sRGB 55 on such a layer and 44 everywhere else. Named, a frame on a layer, the same frame converted into a bitmap by VideoToolbox (about 1ms at 1080p) and the player showing it agree to within a level or two. What cannot match is gamut: a layer keeps a 2020 colour outside sRGB on a wide-gamut panel, and an sRGB bitmap clips it.
A file or URL played by AVFoundation — the platform's decoder, audio, seeking and HLS —
for a renderer's <video src>, with the picture on a layer the renderer places among its
own, and the frame showing drawable into a surface when something is drawn over it.
native.createPlayer(url, { autoPlay, loop, muted, volume, rate })→{ id, layer }.urlis a path, afile://URL or anhttp(s)one.layeris theAVPlayerLayerthat shows it, a handle every layer verb takes (addSublayer,setLayerProps,removeFromSuperlayer); itsvideoGravityfills the layer, so the renderer fits the picture itself. Nothing plays untilautoPlayorpaused: false.native.playerSet(id, { paused, rate, volume, muted, loop })— what it names changes, the rest stays;native.playerSeek(id, seconds)— exact, not to the nearest keyframe.native.playerCopyFrame(id, surface)→{ width, height, time, written }, ornullwhen no frame is newer than the last one copied: the frame showing now, converted into a 2D surface of the frame's size in the colours the layer shows it in (the same conversion aswriteVideoSurface). A surface of another size is told the size and left alone (written: false). It readsAVPlayerItemVideoOutputon the calling thread, as a display link would, so a worker's renderer copies without a hop.native.releasePlayer(id)— stopped, and everything it holds let go; its events stop.
Its events go through setBackendEventCallback, by id: player-metadata { width, height, duration } once the item is ready, and again if either moves (duration is
Infinity for a live stream); player-state { playing, rate } when it starts or stops;
player-time { currentTime } four times a second while it plays and once after a seek;
player-ended at the end of an item that does not loop; player-error { message }.
test/player.js plays test/fixtures/clip.mp4 — 1.5s of one colour, 160x90 H.264
tagged BT.709 — on a layer and copied into a surface beside it, and holds the two to
the same colour on screen: 211, 127, 67 and 211, 127, 68 on an M1 Pro.
A text verb's size is the caller's pixels, and a renderer that draws in device pixels hands
over device pixels: a 13px label on a 2x display is 26. CoreText reads some of a face's data
by point size, though — the optical size and tracking San Francisco is set at, the tracking
Apple Color Emoji's trak table adds below 29pt — so a font made at 26 is San Francisco as
it is set at 26pt, 8% narrower than AppKit sets the 13pt label, and a 19px emoji is 1em wide
where it is 23pt at 19pt. Faces with no such data (Helvetica, Menlo) measure the same either
way.
scale— the display's backing scale, as amatchFontoption and a third argument tocgFontWithSize(cg, size, scale)andfontByPostScriptName(name, size, scale). The font is made atsize / scalepoints under a matrix that scales it byscale, so CoreText reads the face at its point size, and every verb answers in the caller's pixels as before: metrics, advances,fontShapeText's runs, a layout's lines, runs, carets and hit testing, anddrawLayout,ctxDrawGlyphs,drawLayoutGradientandlayoutCoveragedraw it there.fontMetrics(font).sizeis the size asked for. A copy —fontWithSize, variations, features, afontFallbackForface, a face a layout substitutes — keeps the scale. Absent or 1, nothing changes.
Two answers are the scaled face's own rather than the point-size face's times the scale. An
emoji's advance: CoreText rounds a bitmap glyph's to a whole pixel of the font it is set in,
a whole point at 1x and half of one at 2x, so a 19px emoji is 45 pixels at 2x where the
point-size face's 23 points are 46. And the system face's postScriptName is
.SFNS-Regular, CoreText's name for it, where NSFont calls the unscaled one by its alias,
.AppleSystemUIFont.
// 13px at 2x: San Francisco as AppKit sets 13pt, measured in device pixels
const label = native.matchFont({ families: ['system-ui'], size: 26, scale: 2 });The text verbs a renderer lays paragraphs out and shapes glyph runs with —
matchFont, createLayout, fontShapeText — take the two adjustments a designed
interface reaches for past the face and the size:
native.fontApplyFeatures(font, features)— the font with OpenType features set by tag, the sibling offontApplyVariations. An array turns each tag on (['tnum']); an object sets each tag to its value,true/falseor a number for a feature that selects among alternates ({ tnum: true, liga: false, salt: 2 }). The settings replace any the font carries, and with nothing to set the font itself comes back, so it can be applied unconditionally.tnumis the one a live readout wants: the system face's digits are proportional, and a number that changes while a slider moves reflows with every value.letterSpacing, in points: acreateLayoutspan option, and a third-argument option tofontShapeText—{ letterSpacing }— so a paragraph and a run shaped alone agree. It is added after every character, the last on a line included, which is whatkCTKernAttributeNamedoes and what CSS'sletter-spacinglong did; negative values tighten. It says nothing about ligatures: a caller that wants CSS's rule turns the optional ones off throughfontApplyFeatureson a spaced span.
const face = native.matchFont({ families: ['system-ui'], size: 30 });
const small = native.matchFont({ families: ['system-ui'], size: 11, weight: 600 });
const readout = native.createLayout({
spans: [{ text: '487 Hz', font: native.fontApplyFeatures(face, ['tnum']) }],
});
const label = native.createLayout({
spans: [{ text: 'NOISE TYPE', font: small, letterSpacing: 1.26 }],
});createLayout's lineHeight is a multiplier over each line's natural height — its
ascent, descent and the face's line gap — and a line's box is that tall. Whatever the box
has beyond the glyphs' ascent and descent is split evenly above and below them: each line's
baseline is its y, plus half that leading, plus its ascent. That is CSS's
half-leading, and it is ntk's, the engine react-x11 draws text with on X11 and Wayland, so
one tree sets its text in the same place on both. Below 1 the same split takes room from
both sides and the glyphs overflow a short box evenly; 0 is a box of no height, as CSS's
line-height: 0 is. An absent multiplier is 1.
createLayout's justify sets lines to fill maxWidth: what a line leaves of the width is
shared equally among its word separators, each space and no-break space before the white
space it ends on that much wider — the white space it ends on hangs and takes none. That is
CSS Text 3's text-justify: auto for the scripts that space their words, and it is how ntk
justifies on X11 and Wayland. It is bits:
1— every line that goes on to another, CSS'stext-align: justify;2— the paragraph's last line and each a forced break ends (a line feed, a carriage return or a paragraph separator; a line separator, U+2028, is not one);3— every line.
A line with no separator, or no width to spare, keeps the place align gives it, and a line
an ellipsis ends is not justified. The lines are the ones the typesetter breaks at the
width; justifying moves no break, and a kept typesetter (keep, below) laid out again at
another width is broken and spaced again without shaping anything. A justified line is
maxWidth wide, its runs, carets (layoutCaret) and hit tests (layoutIndexAt) are where
the wider separators put them, and drawLayout, drawLayoutGradient and layoutCoverage
draw its glyphs there.
It is not CTLineCreateJustifiedLine. Once a line's spaces have taken some share of their
own advance, CoreText's justification spaces the letters too, where a browser widens
nothing but the separators; and it gives the space a line ends on no advance at all.
native.layoutCoverage(layout, pad?)—{ width, height, data }: how much of each pixel acreateLayoutlayout's glyphs cover, one byte a pixel, without drawing the layout anywhere. It is for text drawn where a surface is not, such as a GL surface's label atlas or a signed distance field made from a string. The raster is the layout's box in whole pixels (itswidthandheightrounded up) withpadpixels round it, and a fractional pad rounds up.datais aUint8Arrayofwidth × heightbytes, row-major, the top row first, and the layout's origin, thex, ythatdrawLayoutis handed, is at(pad, pad).nullfor anything that is not a layout, and for an empty layout with no pad.
It is the outlines' own coverage, not the ink the screen gets. Every glyph's outline goes
where the typesetter put it, unrounded, and the layout is filled once with the non-zero rule,
so glyphs that overlap cover a pixel once. The fill is signed-area accumulation, the
rasterizer ntk (react-x11's X11 text engine) uses, so the two give the same bytes for the
same outlines at the same positions. Nothing is smoothed, nothing is held on a glyph cache's
subpixel grid, and a span's colour plays no part: a translucent span covers what an opaque
one does. A glyph with no outline but ink of its own, Apple Color Emoji's bitmaps, comes out
as its silhouette. The same layout drawn by drawLayout onto a transparent surface is
30–45% heavier at text sizes, which is font smoothing, and its glyphs sit on half pixels.
It runs on the calling thread in threaded mode too, and answers the same bytes there.
const font = native.matchFont({ families: ['system-ui'], size: 26 });
const label = native.createLayout({ spans: [{ text: 'Hamburg', font }] });
const { width, height, data } = native.layoutCoverage(label.handle, 4);
// data[y * width + x] is 0-255, and the text's origin is at (4, 4)Most of a createLayout is the text becoming glyphs: the attributed string built from the
spans, and the typesetter CoreText shapes it with — two thirds of the call over a long
document. A layout of the same paragraph at another width breaks the same glyphs into other
lines, which is what a window resize does to every paragraph of a document, and what a
paragraph's max-content and wrapped measurements are.
keep: true— the result carries the paragraph's typesetter astypesetter.typesetter— handed to a latercreateLayoutin place ofspans, it lays the same text out at that call'smaxWidth,align,lineHeight,maxLinesandellipsis, and the layout is the one the spans would have made, line for line and pixel for pixel.rtlis the typesetter's and ignored beside one.native.releaseTypesetter(typesetter)— frees it now, where a cache lets one go. Idempotent; a released one handed tocreateLayoutthrows. The finalizer is the safety net, and the memory is reported to V8, at about 25 bytes a UTF-16 unit.packed: true— the geometry as twoFloat64Arrays in place oflines:lineData, ten numbers a line (x,y,width,height,baseline,ascent,descent,start,end, and how many runs it has), andrunData, five a run in line order (x,width,start,end, and1when it runs right to left). An object a line and a run is a call into V8 a field, and for short paragraphs building them was a sixth of the call.
A 50-character paragraph on an M1 Pro: 34 µs a layout from its spans, 16 µs from its kept typesetter, 8.6 µs packed.
const first = native.createLayout({ spans, maxWidth: 600, keep: true });
const narrower = native.createLayout({ typesetter: first.typesetter, maxWidth: 420, packed: true });
// narrower.lineData, narrower.runData
native.releaseTypesetter(first.typesetter);The system's icons by name — what createStatusItem's image and a menu item's iconName
already take — drawn into a surface, for a renderer's own content:
native.ctxDrawSymbol(surface, name, x, y, width, height, options?)— the symbol fitted into the rect, centred and keeping its proportions, in the current fill colour. A symbol is a template, its shape in whatever colour it is drawn with, so it is drawn the wayctxDrawGlyphsdraws a run: through the surface's CTM and clip, at its global alpha and blend mode, over what is already there. Answersfalse, drawing nothing, for a name the catalogue does not know — a name from another platform's icon theme simply misses.native.symbolSize(name, options?)—{ width, height }in points, the padding the symbol is designed with included, ornullfor an unknown name: the box a symbol sits in beside text of the same point size.
| option | |
|---|---|
pointSize |
the size it is designed for, default 13 — the size of the text beside it |
weight |
100-900, default 400: the nearest of AppKit's weights, as matchFont maps it |
scale |
'small', 'medium' (default) or 'large', relative to the point size |
variableValue |
0-1, how much of a variable symbol shows — macOS 13 and later, ignored before |
Both run on the calling thread in threaded mode too, and draw the same pixels there.
const s = native.createSurface(96, 96, 2);
native.ctxSetFillColor(s, 0.2, 0.4, 0.8, 1);
const { width, height } = native.symbolSize('speaker.wave.3.fill', { pointSize: 20 });
native.ctxDrawSymbol(s, 'speaker.wave.3.fill', 8, 8, width * 2, height * 2, {
pointSize: 20,
weight: 600,
variableValue: 0.66,
});The OS talks to the application as a whole through Apple Events: a URL for a scheme
the bundle registers (kInternetEventClass/kAEGetURL), a document handed over by the
Finder (kCoreEventClass/kAEOpenDocuments), a second launch of a running app
(kAEReopenApplication), and Quit from the Dock, the app menu or a logout
(kAEQuitApplication). native.initApp() installs an NSApplicationDelegate that
forwards them to the backend event callback and decides nothing itself:
| event | payload | from |
|---|---|---|
app-open-urls |
{ urls: [string] } |
application:openURLs: — scheme URLs as sent, documents as file:// URLs |
app-reopen |
{ hasVisibleWindows } |
applicationShouldHandleReopen:, answered NO — what a second launch means is the renderer's call |
app-quit-request |
{} |
applicationShouldTerminate:, answered Cancel while a callback is installed — the renderer quits (process.exit) or vetoes. With no callback the OS default stands and the process ends |
native.initApp(); // first: the delegate has to precede finishLaunching
native.setBackendEventCallback((ev) => {
if (ev.type === 'app-open-urls') route(ev.urls);
if (ev.type === 'app-reopen' && !ev.hasVisibleWindows) showMainWindow();
if (ev.type === 'app-quit-request') process.exit(0);
});
setInterval(() => native.pump2(), 16);Launch Services delivers the launching Apple Event inside finishLaunching — that is,
inside initApp(), before any callback exists. Whatever arrives with nobody listening
is held and replayed, in order, on the first pump2() that has a callback, ahead of
that tick's input. Registering the scheme itself is an install step, not runtime code:
CFBundleURLTypes (and CFBundleDocumentTypes for files) in the bundle's
Info.plist.
native.postAppleEvent('open-url', url), ('open-documents', [paths]), ('reopen')
and ('quit') build the corresponding Apple Event and dispatch it through
NSAppleEventManager as if it had just arrived — how npm test exercises the
delegate without a bundle.
Open and save dialogs are real NSOpenPanel / NSSavePanels owned by this process's
NSApplication — not a separate osascript — so they can run as a sheet on the window
that asked, every filter the OS type database knows gets through, and a cancel is a
cancel rather than a failed subprocess.
const { native } = require('@windowkit/appkit');
// With a window handle the panel is a sheet on it: the pump keeps running and the
// callback fires on a later tick. Without one it is app-modal: the call blocks in
// AppKit's modal loop until the panel is dismissed, and the callback runs before
// the call returns.
native.openPanel({
window: win._h, // omit for app-modal
directory: false, // true: choose folders instead of files
multiple: true,
message: 'Pick some images',
prompt: 'Import', // the confirm button's label
directoryURL: process.env.HOME,
allowedContentTypes: ['public.png', native.contentTypeFor({ mime: 'image/jpeg' })],
}, (paths) => { /* ['/Users/…/a.png', …], or null on cancel */ });
native.savePanel({
window: win._h,
nameFieldStringValue: 'Untitled.txt',
allowedContentTypes: [native.contentTypeFor({ extension: 'txt' })],
}, (path) => { /* '/Users/…/Untitled.txt', or null on cancel */ });Both take title, message, prompt, directoryURL (a path or file: URL),
allowedContentTypes and canCreateDirectories; the open panel adds directory and
multiple, the save panel nameFieldStringValue. Both return a panel handle:
native.cancelPanel(handle) dismisses a sheet that is still up (its callback then gets
null) and reports whether there was one, and destroyWindow2 answers any sheet still
attached to the window the same way, so no callback is left waiting.
Filters are UTType identifiers, the shape NSSavePanel.allowedContentTypes wants;
mapping extensions and MIME types onto them is the renderer's policy, and
native.contentTypeFor({ extension }) / ({ mime }) does the lookup in the OS's own
database ('png' → 'public.png', 'application/json' → 'public.json'; an
extension nobody has declared still gets a dynamic type that matches exactly that
extension). An absent or empty list means any file, and so does a list the OS
recognises nothing of.
The backend surface's windows (native.createWindow2) take drops and begin
drags through the hosting view — NSDraggingDestination and
NSDraggingSource — with every phase reported through the backend event
callback, the window's number attached, like every other window event.
Mechanism only: the renderer keeps the policy (what to accept, what a drop
means). Type names cross the boundary as pasteboard types — UTIs such as
public.utf8-plain-text, public.file-url, public.png — and mapping a MIME
vocabulary onto them is the renderer's job; pasteboardTypeForMIME(mime) and
pasteboardTypeInfo(uti) read the OS's own table for it (a MIME type no
declared type claims gets a dyn.* identifier that every process computes
alike).
const { native } = require('@windowkit/appkit');
const win = native.createWindow2({ width: 640, height: 480, title: 'drop here' });
// destination: register, then answer AppKit's questions from inside the callback
native.registerDropTypes(win, ['public.file-url', 'public.utf8-plain-text']);
native.setBackendEventCallback((ev) => {
switch (ev.type) {
case 'drag-enter': // { windowNumber, x, y, gx, gy, types, itemCount, sourceMask,
case 'drag-over': // operations, local, sourceWindowNumber?, sequence }
native.setDropResponse(win, { accept: ev.types.includes('public.file-url'),
operation: 'copy' });
break;
case 'drag-exit': // may carry no position: a drag cancelled mid-air
break;
case 'drag-perform': // the drop — read the payload now
for (let i = 0; i < ev.itemCount; i++)
console.log(native.dragItemString(i, 'public.file-url'));
break;
case 'drag-session-began': // source side: x/y in global top-left coordinates
case 'drag-session-moved':
case 'drag-session-ended': // + { operation: 'copy' | 'move' | ... | 'none', dropped }
break;
}
});
// source: from a press, once the renderer's own threshold says it is a drag
native.beginDrag(win, {
x: press.x, y: press.y, // content coords
items: [{ 'public.utf8-plain-text': 'hello', 'public.png': null }], // one item; null = lazy
provide: (type, index) => renderPng(), // asked when a consumer reads the promise
operations: ['copy', 'move'],
surface: previewSurface, imageX: node.x, imageY: node.y, // or image: text.render(...)
});setDropResponseanswers the question being asked. The callback runs synchronously insidedraggingEntered:/draggingUpdated:, so a response set duringdrag-enterordrag-overis what AppKit gets back for that event. It stays in force for thedrag-overevents that follow until changed, and resets to a refusal when a new drag enters.{ accept: false }duringdrag-performwithdraws a drop after a look at the payload.operationabsent picks copy, move, link, generic, private, delete — the first the source allows.typesis the pasteboard's union, promised translations included: apublic.pngalso appears aspublic.tiffand the legacyApple PNG pasteboard type, a file URL asNSFilenamesPboardType.dragItems()lists the declared types per item;dragItemData(index, type)returns aBufferanddragItemString(index, type)a string,nullwhen the item has no such representation. A Finder drag of three files is three items of onepublic.file-urleach. Read duringdrag-perform: the payload is the source's promise, and a source may withdraw it once its session has ended.beginDragreturns at once. The session is begun from the real mouse-down when the pointer is still down in the window (the event the renderer's threshold logic is reacting to), and runs on AppKit's own tracking from there: the pointer'smousemove/mouseupstop arriving, anddrag-session-endedis the release.itemsis one entry per dragging item, each a map of type → string | bytes |null, wherenullis a promise answered byprovide(type, index)when a consumer reads it — so a representation nobody asks for is never built.operationsis the source's mask (operationsOutsidefor other applications when it differs),ignoreModifiersstops Option/Command turning it into copy/link, andslideBack(default on) animates a refused drop home. The image is asurfacehandle or a CGImageimage(thetext.render/controls.renderresult works whole), placed atimageX/imageY— centred on the press by default — at its own size unlessimageWidth/imageHeightsay otherwise. A drop on one of our own windows arrives through the destination events of that window withlocal: trueand the source'ssourceWindowNumber.- A preview window of your own is the window under the pointer. A
renderer that draws its drag preview as a live window of its own — a
borderless popup following the pointer, instead of the session's image —
has put a window between the pointer and every destination, and the window
server finds that one first. Registering it for no dragged types is not a
way past it: the drag then simply has no destination, and the window
beneath never hears of it; transparent pixels pass no hit either.
createWindow2({ …, ignoresMouseEvents: true }), orsetWindowIgnoresMouseEvents(win, flag)on a live window, makes it one the pointer passes through, so a click or a drag reaches whatever is beneath;getWindowFramereports the flag besidevisibleandkey.windowNumberAtPoint(x, y, belowWindowNumber?)is the window server's own answer to which window a mouse-down at a global top-left point would reach, any application's, or 0 — the question the flag changes — and handing an answer back asbelowWindowNumberlooks beneath it, which is how a test finds its own windows under another application's. postDragEvent(win, phase, { x, y, items, operations, local })drives the destination methods with a dragging info of the bridge's own over a private pasteboard — whatpostMouseEventis to clicks.'enter'and'over'answer the operation the view returned ('none'when it refused),'drop'runs prepare + perform + conclude and answers whether the drop was taken. The CI smoke test and a renderer's headless tests drive drops this way.
The dropper button on a colour picker asks for one pixel of the screen, which is
precisely the thing an application cannot draw for itself. NSColorSampler (10.15+)
is macOS's answer: the system shows its own loupe out of process, the user
magnifies and clicks, and the app is told the one colour they picked. Nothing here
reads the screen, so this needs no Screen Recording grant — and the user gets the
magnifier every other Mac colour picker shows them.
const { screenColor, native, app } = require('@windowkit/appkit');
app.run(); // the answer arrives on the main thread
const color = await screenColor.sample();
// { r, g, b } — sRGB, 0–1 floats — or null if the user dismissed the sampler
if (color) paint(`#${[color.r, color.g, color.b].map((c) => Math.round(c * 255).toString(16).padStart(2, '0')).join('')}`);
// the same thing, unwrapped: cb(err, color)
native.sampleScreenColor((err, color) => { /* color, or null on a cancel */ });- A cancel is an ordinary outcome, not an error. Escape (or a dismissal any other
way) answers
null, the same shape every rung of react-x11's eyedropper ladder answers a cancel with; anErroris reserved for a colour that could not be read at all. - sRGB, 0–1 floats, the colour space every colour crosses this bridge in and the
shape of the
org.freedesktop.portal.Screenshot.PickColortriple this stands in for. The sampler reads the pixel in the display's own space — wide-gamut on most Macs now — and ColorSync gamut-maps on the way, so a Display P3 red arrives as{ r: 1, g: 0, b: 0 }rather than as components outside the range. - One at a time. A second
sample()while a loupe is up joins that session rather than stacking a second one — AppKit's own rule, its header says a show "begins or attaches to an existing color sampling session" — and both callers get the same answer, each once. - Nothing dismisses it from code. AppKit offers no such call, so the session ends
when the user picks a colour or presses Escape; there is no
cancelPanelcounterpart here. Until then the pending sample holds the event loop open like pending I/O, and the app has to be pumping (app.run()) to hear the answer — it is delivered on the main thread, like the location grant.
macOS decides per process whether an app may use the camera, microphone, screen, accessibility, input monitoring or location, read the user's calendars and reminders, or send Apple Events to another app. The bridge is mechanism only: read the status, raise the system prompt where a framework offers one, and deep-link to the Settings pane where it does not. Policy — when to ask, what to do with a refusal — stays in the renderer.
const { permissions, native } = require('@windowkit/appkit');
permissions.status('camera'); // 'authorized' | 'denied' | 'restricted' | 'notDetermined'
await permissions.request('microphone'); // raises the system prompt; resolves to granted (boolean)
permissions.status('automation', { target: 'com.apple.finder' }); // Apple Events, per target app
permissions.status('calendars'); // the four words, or 'writeOnly' — macOS 14's save-only grant
await permissions.request('calendars', { access: 'write-only' }); // the narrower prompt; granted when write-only or full access is held
permissions.openSettings('screen-recording'); // System Settings › Privacy & Security › Screen Recording
// the natives underneath, callback-shaped
native.authorizationStatus(kind, opts?); // never prompts
native.requestAuthorization(kind, opts?, (granted, status) => {}); // once, asynchronously
native.openPrivacySettings(kind?); // no kind: the Privacy pane itself| kind | status | request | notes |
|---|---|---|---|
camera, microphone |
AVCaptureDevice authorizationStatusForMediaType: |
requestAccessForMediaType: |
all four statuses; the prompt is in-process and the answer arrives when the user clicks |
screen-recording |
CGPreflightScreenCaptureAccess |
CGRequestScreenCaptureAccess |
a bool, so never notDetermined; the "prompt" is the system's go-to-Settings dialog (shown once) and the request resolves at once. A new grant needs a process restart |
accessibility |
AXIsProcessTrusted |
AXIsProcessTrustedWithOptions + prompt |
a bool, so never notDetermined; go-to-Settings dialog, resolves at once, the grant applies live |
input-monitoring |
IOHIDCheckAccess (listen) |
IOHIDRequestAccess |
granted / denied / unknown → notDetermined; the request posts the prompt and resolves at once, notDetermined while it is still up |
automation |
AEDeterminePermissionToAutomateTarget |
the same, asking | needs { target: bundleId } of a running app, otherwise throws — TCC only answers for a running target. Asking blocks until answered, so it runs off the main thread |
location |
CLLocationManager.authorizationStatus |
requestWhenInUseAuthorization |
the answer comes through the delegate on the main run loop, i.e. while app.run() is pumping |
calendars, reminders |
EKEventStore authorizationStatusForEntityType: |
requestFullAccessToEventsWithCompletion:, requestWriteOnlyAccessToEventsWithCompletion:, requestFullAccessToRemindersWithCompletion: (14+; requestAccessToEntityType:completion: before) |
the fifth word: on macOS 14+ the status can be writeOnly, the partial grant that lets an app save items it cannot read. It crosses as it is — a writer treats it as granted, a reader as denied, and that is the renderer's call. { access: 'write-only' } asks calendars for just that grant (reminders have no such grant: a TypeError); granted in the answer is whether the level asked for is held afterwards, so a write-only request is granted by write-only or full access. One EKEventStore serves the process, created by the first request — creating a store never prompts, only the request does — and it is the store the calendar-reading verbs share |
- A request answers once, asynchronously — never inside the call — and holds the event loop open until then, like pending I/O.
- Attribution. A bare
nodeprocess is attributed to its responsible process (Terminal, an IDE) or tonodeitself, and prompts with no usage-description strings. A bundled app must carry the keys (NSCameraUsageDescription,NSMicrophoneUsageDescription,NSLocationUsageDescription,NSAppleEventsUsageDescription; for EventKit on macOS 14+NSCalendarsFullAccessUsageDescription,NSCalendarsWriteOnlyAccessUsageDescriptionandNSRemindersFullAccessUsageDescription, and before 14NSCalendarsUsageDescription/NSRemindersUsageDescription); without them the request never prompts, or TCC ends the process. A sandboxed build also needs the App Sandbox's one EventKit entitlement,com.apple.security.personal-information.calendars. - A refusal is an answer; a prompt nobody saw is not. After a calendars
or reminders request the status is what the request reads back:
deniedwhen the user refused, and stillnotDeterminedwhen nothing was shown (a bundle without the usage string, a process TCC cannot attribute) — measured on macOS 15.2, a bundle without the keys gets its answer within a few milliseconds,grantedfalse and the status unchanged. The bridge does not tell those apart; the renderer re-reads and decides. - Folders (Desktop, Documents, Downloads) need nothing native: reading the
directory is the prompt and
EPERMis the denial.openSettingsalso takes'files-and-folders'and'full-disk-access'for their panes. restrictedis MDM or parental controls: the user cannot grant it.
Every account the user added in System Settings › Internet Accounts — iCloud,
Google, Exchange, CalDAV, a subscribed feed — is served by one framework,
EKEventStore: the macOS counterpart of Evolution Data Server plus GNOME
Online Accounts, where the desktop did the OAuth and the app never sees a
credential. Reading and writing go through the same store and the same
grant. Mechanism only — which calendars to show, how to draw an all-day
span, when to re-query and what to put in an event stay in the renderer.
const { calendars, permissions, native } = require('@windowkit/appkit');
await permissions.request('calendars'); // the TCC grant — "Privacy authorizations" above
const list = await calendars.list();
// [{ id, title, color: [r, g, b, a] | null, type: 'local' | 'calDAV' | 'exchange' |
// 'subscription' | 'birthday', source: { id, title, type }, immutable,
// allowsModifications, subscribed }]
const day = 24 * 60 * 60 * 1000;
const events = await calendars.eventsBetween({ start: Date.now(), end: Date.now() + 7 * day });
// the occurrences in the range, recurrences already expanded, sorted by start
const oneCalendar = await calendars.eventsBetween({
start: new Date('2026-09-01'), end: new Date('2026-10-01'), calendars: [list[0].id],
});
native.setBackendEventCallback((ev) => {
// 'calendar-store-changed' {} something in the store changed: query again
});
// writing: the same store, the full grant or macOS 14's write-only one
const home = await calendars.defaultCalendar(); // where a save with no calendar goes, or null
const id = await calendars.saveEvent({
title: 'Dentist', start: new Date('2026-10-05T09:00'), end: new Date('2026-10-05T10:00'),
location: '12 High St', alarms: [{ offset: -15 * 60 }],
recurrence: { frequency: 'weekly', daysOfWeek: [{ day: 2 }], count: 6 }, // six Mondays
});
const [, second] = await calendars.eventsBetween({ start, end, calendars: [home.id] });
await calendars.saveEvent({ id, occurrenceDate: second.occurrenceDate, start: later, end: later + hour }, { span: 'this' });
await calendars.saveEvent({ id, occurrenceDate: second.occurrenceDate, title: 'Orthodontist' }, { span: 'future' });
await calendars.removeEvent(id, { span: 'future' }); // the whole series, from its first occurrence
// a batch: nothing reaches the database until commit(); reset() forgets it instead
await calendars.saveEvent(a, { commit: false });
await calendars.saveEvent(b, { commit: false });
await calendars.commit();
// the natives underneath, callback-shaped
native.calendars(cb); // cb(err, [calendar])
native.eventsBetween({ start, end, calendars? }, cb); // epoch ms; cb(err, [event])
native.defaultCalendar(cb); // cb(err, calendar | null)
native.saveEvent(props, opts?, cb); // cb(err, id)
native.removeEvent(id, opts?, cb); // opts: { span, commit, occurrenceDate }; cb(err)
native.commitCalendarStore(cb); // cb(err)
native.resetCalendarStore(cb); // cb(null)
native.postCalendarStoreChanged(); // test-only: the notification EventKit posts| event field | what |
|---|---|
id, itemId, externalId |
eventIdentifier, calendarItemIdentifier, calendarItemExternalIdentifier — null where the store has none (a local calendar's items carry no external id). Every occurrence of a series shares the series' id; a detached occurrence gets its own, the series' with /RID=<slot> appended |
calendar |
the id of the calendar it is in |
title, location, notes, url |
strings, or null where the item has none — never '' for absent |
start, end |
epoch ms, as the store reports them |
allDay, timeZone |
isAllDay; the event's time-zone identifier ('Europe/London') or null for a floating one |
status |
'none', 'confirmed', 'tentative', 'cancelled' |
availability |
'notSupported', 'busy', 'free', 'tentative', 'unavailable' |
recurring, detached |
hasRecurrenceRules; whether this occurrence was edited away from its series |
occurrenceDate |
where the occurrence sits in the series (epoch ms). For a detached occurrence that was moved, macOS 15.2 reports the start it was moved to (the original slot survives as the RID= in its externalId), not the documented original date — pass it back as reported |
organizer, attendees |
present only when the event has them: { name, url, status, role, type, isCurrentUser } — url is the mailto: the account gave, status 'unknown' … 'inProcess', role 'required'/'optional'/'chair'/'nonParticipant', type 'person'/'room'/'resource'/'group' |
recurrence |
present only on a recurring event: its rule in the shape saveEvent takes (below), so it can be read, changed and written back. The framework allows several rules on an item; the first is carried, which is the only one Calendar or any account writes |
alarms |
present only when the event has alarms: [{ offset: seconds from the start, negative before it } | { at: epoch ms }] |
saveEvent(props, opts?) — every field but the ones that identify the event
is optional. On an existing event a field left out stays as it is and null
clears it; on a new one start and end are required.
| prop | what |
|---|---|
id, occurrenceDate |
neither: a new event. id names an existing one (eventWithIdentifier:); with occurrenceDate — as eventsBetween reported it for that occurrence — one occurrence of a recurring event, found through the same predicate the reads use; without it the first occurrence, which with span: 'future' is the whole series |
calendar |
a calendar id. A new event without one goes to defaultCalendar(); on an existing event it is a move, which an invitation refuses (EKErrorInvitesCannotBeMoved) |
title, location, notes, url, timeZone |
strings, or null to clear. url must be absolute (https://…), timeZone an identifier ('Europe/London'), null for a floating event |
start, end |
epoch ms, or a Date through the wrapper. Inverted ones are the framework's to refuse (EKErrorDatesInverted) |
allDay |
in the store's own convention, the one eventsBetween reports: start at local midnight, end at the last second of the last day. An exclusive end — the next midnight — makes a two-day event (measured: it reads back as 48 hours) |
availability |
'busy', 'free', 'tentative', 'unavailable' |
recurrence |
EKRecurrenceRule's own vocabulary, what its initialiser takes: { frequency: 'daily' | 'weekly' | 'monthly' | 'yearly', interval?, until? | count?, daysOfWeek?: [{ day: 1..7 (Sunday = 1), week? }], daysOfMonth?, monthsOfYear?, weeksOfYear?, daysOfYear?, setPositions? } — until epoch ms, count occurrences, neither for never; the arrays as iCalendar's BYDAY, BYMONTHDAY, BYMONTH, BYWEEKNO, BYYEARDAY, BYSETPOS, negatives counting from the end; week on a day only in a monthly (±1..5) or yearly (±1..53) rule. null removes the rule. Not an RRULE string: a consumer holding iCalendar text parses it on its own side |
alarms |
[{ offset: seconds from the start, negative before it }] or [{ at: epoch ms }]; null or [] removes them |
opts.span |
'this' (the default) or 'future': how far a change to — or the removal of — a recurring event reaches, EKSpanThisEvent / EKSpanFutureEvents. 'this' on one occurrence detaches it (it then carries its own id); 'future' from a middle occurrence splits the series there, and what follows answers under a new id — though the account keeps it with the original object, so removing 'future' from an earlier occurrence takes the split-off part and any detached occurrence with it |
opts.commit |
true by default. false leaves the change pending for commit() — commitCalendarStore — and reset() forgets what is pending; the id is answered either way |
- Reading needs the full grant. Both verbs answer an error naming the
status —
notDetermined,denied,restricted, or macOS 14'swriteOnly, which may save what it cannot read — rather than an empty list, so "no events" and "not allowed to look" stay distinguishable. Neither verb prompts: ask for'calendars'first. They share the oneEKEventStorethe grant created, andrefreshSourcesIfNecessaryruns before a listing so a calendar just added in Settings is there (the refresh itself is asynchronous; what it pulls in arrives as a change event). - Four years.
predicateForEventsWithStartDate:endDate:calendars:is limited to a four-year span, so a longer range is aTypeErrorat the bridge rather than a silently truncated answer — chunk it. Anendbefore thestartis one too, and so is acalendarsfilter naming nothing: an empty list would reach the predicate as "every calendar", the opposite of what it says. An id that names no calendar is an error through the callback, not a widened query. - All-day events cross as the store reports them:
allDaytrue,startat local midnight andendat the last second of the last day (23:59:59), not an exclusive end. Normalising is the renderer's job; the bridge is mechanism, andtest/calendars.jspins the convention so a renderer's normalisation has something to be checked against. - Off the JS thread.
eventsMatchingPredicate:is synchronous and can take a while over many calendars, so both verbs run on a background queue and answer through a thread-safe function — never inside the call — holding the loop open like pending I/O until they do. What the framework hands back is copied into plain objects on that queue, so nothing EventKit-owned crosses to JS. Colours are converted to sRGB like every other colour on this bridge. - The change event. EventKit posts one notification for any change, and
its documented contract is "re-fetch":
'calendar-store-changed'carries nothing, and the answer to it is to query again. The observer is installed with the process's store — i.e. from the first EventKit call, before a listener could exist — and a change that arrives beforesetBackendEventCallbackis held and replayed at the start of the nextpump2(), coalesced into one. The framework coalesces too, and a duplicate is harmless to a renderer whose answer is to re-query. - Writing needs either grant. A save, a removal, a commit and the
default calendar answer under the full grant or macOS 14's
writeOnlyone, and an error naming the status otherwise. Under write-only the store cannot read back what it saved —eventWithIdentifier:answers nil — so changing or removing an event by id is refused as the EKError it is (EKErrorEventStoreNotAuthorized) rather than reported as success; what a write-only app can do is add. Measured on 15.2:defaultCalendar()answers a stand-in the framework makes for the grant — idVIRTUAL_APP_CALENDAR_UUID, title "Calendar", a source called "Account" — and a save into it, or with no calendar named, lands in the user's real default calendar;list()andeventsBetween()answer the error namingwriteOnly. - What the framework refuses crosses as its error, never as a bare
boolean. The rejection carries
code(theEKErrorCodenumber),domain('EKErrorDomain') andreason, the code's name from the framework's own header —EKErrorCalendarReadOnly,EKErrorNoCalendar,EKErrorDatesInverted,EKErrorInvitesCannotBeMoved… — so a consumer can switch on the word; the message carries the framework's own text. An id that names no event, a calendar id that names none, anoccurrenceDatethat is not one of the series: errors through the callback with the bridge's own message. What the framework raises — a rule it cannot build, an object from another store — is caught and crosses the same way rather than ending the process. A save that finds nothing changed is not a failure. - Writes are serial. Every write runs on one serial queue at the reads'
QoS — off the JS thread, answered through a thread-safe function, never
inside the call — and one after another in the order asked, which is what
makes a batch (
commit: false…commit()) mean something. A commit, the consumer's own included, is followed by'calendar-store-changed', so a change made here and one made elsewhere look the same to the reader, which is correct. - No system sheet. There is no
EKEventEditViewControlleron macOS, so there is nothing to defer to; this is API only, and the renderer draws the editor. - Not here: reminders (
EKReminderhas its own predicate and its own grant), calendars themselves (saveCalendar:), and attendees, which an account manages through its invitations.
NSStatusItem is the tray. An item shows an image or a title (or both) in the
system status bar with a tooltip, and either owns a menu or reports clicks. The
menu takes the same item spec setMainMenu does, so the tray, the app menu bar
and — through react-x11's dbusmenu adapter — a Linux panel share one authoring
model; activations arrive as the same menu-activate events, tagged menu: 'status'.
const { native } = require('@windowkit/appkit');
native.setBackendEventCallback((ev) => {
if (ev.type === 'status-item-click') { // only without a menu
// ev.statusItem === item; ev.kind: 'left' | 'right' | 'middle'
// ev.x/y/width/height: the item's screen rect, top-left global — the
// anchor for a popup of your own; plus shift/control/option/command
}
if (ev.type === 'menu-activate') { /* ev.id from the spec below */ }
});
const item = native.createStatusItem({
image: 'bell.badge', // SF Symbol name — or a surface handle, or PNG bytes
title: '3', // beside the image, or alone
tooltip: 'Notifications',
length: 'variable', // 'variable' | 'square' | points
});
native.setStatusItemMenu(item, [ // setMainMenu's item vocabulary
{ id: 1, title: 'Open', iconName: 'macwindow' },
{ separator: true },
{ id: 2, title: 'Quit', key: 'q' },
]);
native.setStatusItem(item, { title: '', visible: false }); // in-place patch
native.setStatusItemMenu(item, null); // back to click events
native.removeStatusItem(item);Images are template by default (imageTemplate: false keeps their colours),
so a bitmap icon follows the bar's light/dark the way a symbol does; a surface's
bitmap is taken at the surface's scale, and imageSize: [w, h] overrides the
size in points. With a menu set, a left or right click tracks the menu and no
click event fires; a middle click is reported either way. The item stays in
the bar until removeStatusItem, whatever happens to the handle, and it is
visible at creation unless told otherwise — AppKit's own memory of a hidden
item (user defaults, by creation order) does not carry over.
For tests: statusItemInfo(item) returns what the bar shows (title, tooltip,
visibility, image, length, the menu in mainMenuInfo's shape, the screen
rect), activateStatusItemMenuItem(item, [i, j, ...]) fires a menu item by
index path, clickStatusItem(item, kind) posts a real press-and-release into
the item's window (pump afterwards; declined for left/right while a menu is
set, since that click would open it), and snapshotStatusItem(item, file)
writes the composited item to a PNG.
The menu that drops from a control in a window — a <select>'s list, a
pop-up button's — the way NSPopUpButton's does: the current item placed
over the control and checked, the menu at least the control's width, in the
font asked for. It is run through an NSPopUpButtonCell that is never drawn,
performClickWithFrame:inView:, which is what a browser does for a
<select> on macOS, so the placement, the tracking (type-select, the scroll
arrows of a long menu, VoiceOver) and the look are AppKit's own. The items
are setMainMenu's vocabulary.
const menu = native.popUpMenu(win, {
items: [ // setMainMenu's item vocabulary
{ id: 1, title: 'Default' },
{ id: 2, title: 'Retro' },
{ separator: true },
{ id: 3, title: 'Chaos', enabled: false },
],
frame: [40, 60, 220, 24], // the control, in the window's content, top-left, points
selected: 2, // placed over the control, checked; absent: none is
fontSize: 13, // the menu's font; the menu font at its own size by default
fontFamily: 'Avenir Next', // a family the system has, else the menu font at fontSize
appearance: 'dark', // 'light' | 'dark'; absent: the window's
rtl: false,
}, (id) => {
// the id chosen, or null for a menu dismissed (Escape, a click outside, cancelPopUpMenu)
});
native.cancelPopUpMenu(menu); // ends the tracking: the callback answers nullTracking is a modal loop. In pump mode it runs inside the call, the thread
being AppKit's for the length of the gesture, and the callback has run when
the call returns. From a worker the handle is answered at the call, the menu
opens from a callout of its own, the answer arrives through the callback, and
cancelPopUpMenu can end it while it is open.
For tests: popUpMenuInfo(menu) returns an open pop-up as mainMenuInfo's
shape plus fontFamily, fontSize, appearance and rtl, and null once
it has answered; activatePopUpMenuItem(menu, index) chooses an item the
way tracking would and ends the tracking. In pump mode, keys posted with
postKeyEvent before the call are read by the tracking as a person's would
be (test/popup-menu.js).
The handful of things an app shows outside its own windows — the Dock tile and the name
the Dock, the ⌘-Tab switcher and the menu bar print for it — as flat natives on
ca.native. Mechanism only: counts, reasons and timing stay in the renderer.
const { native } = require('@windowkit/appkit');
// Activation policy. Decide it before the first window so an agent app never
// flashes a Dock tile: 'regular' (Dock tile, menu bar, ⌘-Tab entry),
// 'accessory' (none of those, windows still work — LSUIElement), 'prohibited'.
native.initApp({ activationPolicy: 'accessory' });
native.setActivationPolicy('regular'); // live switch afterwards -> bool
native.setDockBadge('3'); // NSDockTile.badgeLabel; null clears
const id = native.requestUserAttention('critical'); // Dock bounce until activated;
// 'informational' bounces once
native.cancelUserAttention(id); // ignored while the app is active anyway
// Dock menu (right-click the tile): one menu's worth of the item vocabulary
// setMainMenu takes. Activations arrive on the backend event callback as
// { type: 'menu-activate', id, menu: 'dock' } — menu-bar items say menu: 'main',
// status-item menus menu: 'status'.
native.setDockMenu([{ id: 7, title: 'New Window' }, { separator: true },
{ id: 8, title: 'Recent', items: [{ id: 9, title: '…' }] }]);
native.setDockMenu(null);
// The display name. Best-effort: an unbundled process (node, bun) is renamed in
// LaunchServices' record of it, which is what the Dock and the switcher read; a
// bundle's Info.plist wins when it declares CFBundleName/CFBundleDisplayName (-> false).
native.setAppName('My App');
native.appInfo(); // -> { activationPolicy, name, dockBadge, active }Under a launcher (#64), the app's own code may not have run when the app
launches. A threaded-mode launcher calls initApp() and runMain() on the main thread
before its worker has imported the app's entry, so the app's initApp({ activationPolicy })
from the worker comes after finishLaunching, by which time an agent app has already shown
its Dock tile. Two ways to set the policy before launch:
APPKIT_ACTIVATION_POLICY=regular|accessory|prohibitedis read as the app launches, whichever call launches it. An unknown name is reported on stderr, and the app launches regular.runMain({ activationPolicy })is the policy to launch with whenrunMainis what launches the app. If the app is already up, it is a live switch, assetActivationPolicyis.
A policy the code gives before launch (initApp, setActivationPolicy, runMain) wins
over the variable. A bundled app says the same with LSUIElement in its Info.plist.
dockMenuInfo() and activateDockMenuItem([i, j, …]) mirror mainMenuInfo() and
activateMenuItem() for tests; both go through the delegate method the Dock itself calls.
Banners, the Notification Center list and action buttons through the
UserNotifications framework — the macOS counterpart of freedesktop's
org.freedesktop.Notifications, and the API that replaced the deprecated
NSUserNotification. Mechanism only: what to say, when to ask, and what to do
with a refusal stay in the renderer.
const { notifications, native } = require('@windowkit/appkit');
const s = await notifications.settings();
// { available: false, bundleIdentifier: null, reason } — a bare `node`: fall to another rung
// { available: true, bundleIdentifier, authorizationStatus, — 'notDetermined' | 'denied' | 'authorized' | 'provisional'
// alert, sound, badge, notificationCenter, lockScreen, — 'notSupported' | 'disabled' | 'enabled'
// criticalAlert, alertStyle, showPreviews, timeSensitive }
await notifications.requestAuthorization(['alert', 'sound', 'badge']); // the system prompt, once per app -> granted
notifications.setCategories([
{ id: 'download', actions: [{ id: 'open', title: 'Open', foreground: true },
{ id: 'trash', title: 'Delete', destructive: true }] },
]);
const id = await notifications.post({ title: 'Export finished', body: 'report.pdf — 2.4 MB',
sound: 'default', categoryId: 'download',
userInfo: { path: '/tmp/report.pdf' } });
await notifications.update(id, { title: 'Export finished', body: 'opened' }); // same identifier: replaced in place
notifications.remove(id); // out of Notification Center
native.setBackendEventCallback((ev) => {
// 'notification-action' { identifier, actionId, categoryId, userInfo } actionId 'default' = a click on the banner
// 'notification-dismissed' { identifier, reason: 'dismissed', categoryId, userInfo }
});
// the natives underneath, callback-shaped
native.notificationSettings(cb); // never throws; cb(settings) once, asynchronously
native.requestNotificationAuthorization(options, cb); // cb(granted, error)
native.setNotificationCategories(categories); // replaces the set
native.postNotification(props, cb?) -> identifier; // cb(error | null) once the system has taken it
native.updateNotification(identifier, props, cb?); // = post with that identifier
native.removeNotification(identifier | [identifiers]); // delivered and pending
native.deliveredNotifications(cb); native.notificationCategories(cb); // readback
native.postNotificationResponse({ identifier, actionId?, dismissed?, ... }); // test-only: a response as the
// delegate would queue it (no bundle needed)- The bundle-identity constraint. The system attributes every banner to an
app bundle — a
CFBundleIdentifierLaunch Services can see — andUNUserNotificationCenterraises (bundleProxyForCurrentProcess is nil) in a process that is none, which is what a barenodeis. The centre is probed once, atrequiretime, behind that check; the outcome issettings().available, with the reason when false. Every other call throws anErrorin that state — never a silent drop — so checkavailableand fall to another rung (osascript display notification, say). To run as a bundle, put the executable inName.app/Contents/MacOS/with anInfo.plistthat names it and carriesCFBundleIdentifier, and sign it (codesign --force --deep --sign - Name.appis enough locally). - Authorization is the system's prompt, shown once per bundle id; until it
is granted a post is refused with
UNErrorCodeNotificationsNotAllowed(error.code === 1,error.domain === 'UNErrorDomain'), and the system keeps no categories for the app (notificationCategoriesreads back empty). The bridge keeps the last set and hands it over again when a request is granted. - The delegate is installed at
requiretime, beforeinitApp()finishes launching, so a click that launched the app is delivered too. Responses are marshalled onto node's loop and emitted through the backend callback; anything that arrives beforesetBackendEventCallbackis held and replayed at the start of the nextpump2(), ahead of that tick's input. While the app is frontmost,willPresentstill shows the banner (list and sound too). - Dismissals. The system reports an explicit dismissal only for a category
carrying
UNNotificationCategoryOptionCustomDismissAction; every category set here carries it (customDismissAction: falseopts out), and a notification with nocategoryIdis filed under a bridge-owned category that has it. A banner that times out into Notification Center, or is removed byremoveNotification, is not reported by the system and produces no event.'default'is theactionIdof a click on the banner itself, so it is not a name for an action of your own. userInfois opaque:JSON.stringify'd on the way in and parsed back on the way out, so whatever JSON can carry round-trips exactly.
System Settings › Accessibility › Display, as NSWorkspace reports it — the switch a renderer
reads before it starts anything that moves on its own (#31):
native.accessibilityDisplayOptions();
// -> { reduceMotion, reduceTransparency, increaseContrast, differentiateWithoutColor, invertColors }
accessibility.displayOptions(); // the same, on the wrapperA change arrives as a backend event with the same five fields:
{ type: 'accessibility-display-changed', reduceMotion, reduceTransparency, increaseContrast,
differentiateWithoutColor, invertColors }It comes from NSWorkspaceAccessibilityDisplayOptionsDidChangeNotification, delivered on the
main thread inside a pump2() like every other event. Nothing is held for a listener that is
not there yet — a setting is a fact rather than a message — so install the callback, then
query: the query is the state, the events are what changes it from then on.
Mechanism only: what to do with reduceMotion is the renderer's (react-x11: looping
animations never start, a transition that ends still runs).
native.postAccessibilityDisplayChange() posts the same notification through the same centre,
so the observer path can be exercised without touching the user's settings — test-only, like
postAppleEvent; test/accessibility-display.js is the check.
The shape of a host config on top of this:
| Reconciler op | @windowkit/appkit |
|---|---|
createInstance |
new Layer() / new TextLayer() / ... per element type |
appendChild |
parent.add(child) |
removeChild |
child.remove() |
commitUpdate |
layer.set(diffedProps) |
| commit batch | wrap in withoutAnimations() (or a transaction() to get animated updates for free) |
getPublicInstance |
the Layer wrapper (hit-testing gives it back for events) |
Because the tree is retained and properties are mutable, the reconciler diff maps directly onto layer mutations — no repaint pass, no damage rects; the WindowServer recomposites only what changed.
- The pump-on-a-timer model means live window resizing/dragging runs AppKit's internal modal loops; input during those is choppy (Core Animation itself is unaffected). Threaded mode (above) is the way out.
- No
NSWindowDelegatewiring yet — window resize is observable only by pollingwin.size; sublayers don't autolayout (by design — the reconciler owns layout). - One shared event callback for all windows; per-window routing would need the window handle in the event payload.
- Layer handles are released on GC via External finalizers; native side keeps its own retains through the layer tree, so lifetime is safe but not tuned.
x64/arm64follows whatever node arch you build with; no prebuilds.