Phase 3 UAPI Draft
This page explains the first Phase 3 contract in simple terms.
Phase 2 proved that a portable app can ask Krate for files, network, time, locale, and terminal input or output. Phase 3 adds the first desktop app shape. The app still does not call macOS, Windows, or Linux directly. It asks Krate for a window, input events, drawing, and audio. The host adapter translates those requests.
flowchart LR
APP["Portable app component"] --> GUI["krate:app gui world"]
GUI --> UDISP["Runtime UI dispatcher"]
UDISP --> ADAPTER["Shared UiAdapter trait"]
ADAPTER --> UI["UI<br/>window, tree, events"]
GUI --> GFX["Graphics<br/>2D canvas, GPU surface"]
GUI --> AUD["Audio<br/>playback, capture"]
UI --> HOST["Host adapter"]
GFX --> HOST
AUD --> HOST
HOST --> OS["Native desktop OS"]
classDef built fill:#d9fbe3,stroke:#16833a,color:#102a17,stroke-width:2px;
classDef draft fill:#fff3bf,stroke:#b7791f,color:#2d2100,stroke-width:2px;
classDef pending fill:#eeeeee,stroke:#999999,color:#777777,stroke-width:1px;
class APP,GUI,UDISP,ADAPTER draft;
class UI,GFX,AUD draft;
class HOST,OS pending;
What Exists Now
The first draft lives under wit/krate/phase3.
It defines:
krate:app@0.2.0with aguiworldkrate:ui@0.1.0for windows, widget trees, events, dialogs, clipboard, and menuskrate:gfx@0.1.0for simple 2D drawing and an early 3D surface shapekrate:audio@0.1.0for playback and capture stream shape
The checker is:
scripts/check-phase3-uapi.sh
That checker proves the WIT parses, the expected packages are present, the
gui world imports the intended interfaces, the world exports run, public
names follow the repo style, and the new error types include
permission-denied.
The CLI also recognizes this manifest world now:
[app]
id = "com.example.notes"
name = "Notes"
version = "0.1.0"
entry = "notes.wasm"
world = "krate:app/gui@0.2.0"
krate manifest check accepts that world and labels it as a Phase 3 GUI
draft. krate run does not launch it yet. It exits early with a clear message
that the GUI runtime is not implemented. This is intentional. We want the
manifest and tooling path to be real before we add windows.
The first Phase 3 capability names are also wired into the manifest and policy layer:
| Capability | Meaning today |
|---|---|
ui.window:create | A GUI app may ask for a window. This is a default grant. |
ui.dialog:* | File dialogs are allowed by default for the draft GUI shape. |
ui.clipboard:read and ui.clipboard:write | Clipboard access is sensitive and must be granted explicitly. |
ui.dropzone:<mime-type> | Drag and drop can be scoped by MIME type. |
gfx.gpu:basic | Basic GPU-backed drawing is a default grant. |
gfx.gpu:compute | Compute access is explicit. |
audio.playback | Playback is defined, but not default yet. |
audio.capture | Microphone capture is explicit. |
This does not grant real desktop access yet. It means Phase 3 manifests can name the same permissions the future runtime will enforce.
The repo also has a small shared UI model now: adapter-common::ui. It has a
UiAdapter trait and an in-memory DraftUiAdapter. The draft adapter can
create window records, validate titles and sizes, track show or close state,
and collect window events. This gives the runtime one stable shape to call
while native adapters are still being built. It is not AppKit, Win32, GTK, or a
real event loop yet.
The runtime now has a first UI dispatcher scaffold too: runtime::phase3_ui.
It checks the same capability policy before it calls the shared UI adapter.
Window create, show, resize, redraw, and close all pass through that boundary.
Clipboard read and write are shaped through the adapter too, but the draft
implementation still returns unsupported after the permission check. That lets
us test the security path before native clipboard integration exists.
The macOS, Linux, and Windows adapter crates also expose Phase 3 window and UI adapter entry points now. Each one currently uses the same headless draft backend and has a blank-window smoke test. The adapter info says that clearly. It also records the native backend each host is aiming at: AppKit on macOS, and winit on Linux and Windows. That means the host crates are wired into the UI contract, but they still do not open real OS windows.
The runtime can now discover the current host UI adapter too. Phase3UiRuntime
owns the session guard and the selected adapter, then hands out a dispatcher
that checks permissions before calling that adapter. It also reports adapter
capability info such as host family, backend name, and whether native windows
or a native event loop are enabled. Today those values still say headless draft.
The Rust side now has a named WindowAdapter boundary too. Window lifecycle,
redraw, event polling, close requests, resize, focus, theme, and scale changes
sit in that lower layer. UiAdapter builds on it with widget trees, input, and
clipboard. This keeps the first native window backend smaller than the full
widget bridge.
That window boundary now has the first native handle handoff. In plain terms,
Krate has a stable WindowId, while the host has a real object such as an
AppKit NSWindow, a winit window, or a Win32 window. The new
NativeWindowHandle token lets a host adapter attach that native object to the
Krate id, look it up later, and detach it. macOS has the first AppKit handoff
method. The default backend is still headless draft, so this does not open a
real window yet.
macOS now has the first opt-in native window prototype behind that handoff. The
AppKitWindowBackend can create an owned AppKit NSWindow on the main thread,
attach its raw handle to the Krate window id, and show it through the shared
window path. The normal adapter still reports headless draft because native
event capture, drawing, and app-facing loop wiring are not finished yet.
The same prototype now has a small event bridge. It can queue close requests,
resize events, focus changes, and display-scale changes through the shared
WindowAdapter path. It can also read a native snapshot from AppKit: content
size, focus, visibility, and backing scale. That snapshot is not a full event
loop yet. It is the checked handoff point that real AppKit delegates can call
next.
The prototype also has AppKitWindowSession now. This is the first event-loop
state object for macOS. It owns the native window, remembers the last snapshot,
and refreshes only changed state into the shared event queue. That keeps the
next delegate work small: callbacks can update the session instead of reaching
into loose helper methods.
The macOS adapter now exports AppKitWindowNativeEvent and
AppKitWindowEventState too. These are not Objective-C delegates yet. They are
the Rust shape those delegates will call: close request, resize, focus, display
scale, or a full snapshot. This lets us test the event behavior before adding
the unsafe AppKit callback object.
Redraw requests are part of that path now. In plain terms, when the future AppKit view needs to paint again, it can ask Krate for a redraw through the same shared window event queue. That keeps drawing requests beside resize, focus, scale, and close events instead of making a separate one-off path.
There is also a small Rust delegate bridge now. It uses AppKit-style callback names such as resize, become key, resign key, backing-scale change, display needed, and should close. The bridge translates those callbacks into the tested native event state. That means the real Objective-C delegate can stay small: call into Rust and let Rust handle the event rules.
The first real AppKit delegate object is now in place. It implements
NSWindowDelegate, stays retained by the AppKit window session, records native
close, resize, focus, and backing-scale callbacks into a FIFO queue, then lets
the Rust session drain those callbacks through the tested bridge. This is still
an opt-in prototype path, not the default runtime event loop.
There is also a small AppKit event-loop driver now. It does one non-blocking tick: refresh the native snapshot, drain delegate callbacks, and optionally queue a redraw request. That is enough to prove the native path can feed the same event stream as the headless draft adapter. It is not yet the default runtime path.
The runtime can select that prototype path explicitly now. The default
Phase3UiRuntime::with_host_adapter path remains headless. A caller that is
ready for native macOS rules can ask for Phase3HostUiMode::NativePrototype,
which selects the AppKit prototype adapter and reports native window plus
native event-loop support.
The event-loop step has a shared runtime boundary now too. UiAdapter exposes
one non-blocking pump method, and Phase3UiDispatcher checks the same UI grant
before it calls that method. Headless adapters return no native tick. The
AppKit prototype maps its native step report into a small common
UiEventLoopTick report. This keeps the next Linux and Windows window work on
the same shape instead of making macOS a one-off path.
Linux and Windows now have the first Winit callback collector bridge. The prototype adapters can record Winit-shaped callbacks in FIFO order, count what is waiting, and let the normal event-loop pump drain those callbacks through the shared UI event queue. This still does not create a real Linux or Windows window. It gives the coming real Winit event handlers a small tested place to send resize, focus, scale, redraw, and close callbacks.
There is also a local smoke command for the full selectable AppKit runtime
path. On macOS, a developer can run it to ask the runtime for
Phase3HostUiMode::NativePrototype, create and show the AppKit window, pump
one shared event-loop tick, inspect the tick report, and close the window. It
runs as a command rather than a unit test because AppKit needs the main process
thread. Normal CI should not open native desktop windows.
AppKit now has draw-surface state too. It records the Krate window id, logical
size, display scale, clear color, redraw count, and frame number. A redraw
request from that surface goes through the same delegate bridge as a future
native AppKit view. This still does not paint pixels. It is the small state
object the next NSView painter will use.
The first AppKit draw view surface is in place now. It attaches an owned
NSView to the opt-in prototype window, sets the window background from the
surface clear color, marks the view as needing display, and records the first
frame snapshot. This is a local native smoke path. The normal runtime still
uses the headless draft adapter until native event-loop wiring is ready.
ADR-0013 and RFC-0003 now define how widgets lower once a native backend exists. A widget should become a native control when the host has a semantic match. If it does not, Krate uses a drawn fallback with the same layout, input, accessibility, and permission rules.
The shared Rust model has started too. adapter-common::ui now has stable
widget IDs, widget kinds, labels, role hints, small style hints, and parent-link
validation. That is the local model the next layout and native-lowering code
can use before the WIT bindings are wired end to end.
The runtime can now move draft widget trees through the adapter boundary. The dispatcher checks the current GUI grant, then calls the selected adapter to set a root widget, upsert child nodes, remove nodes, move focus, and inspect draft widget state. This is still headless, but it proves the runtime path that later native widgets will use.
The first layout path is wired too. crates/layout maps that shared widget
tree into Taffy and returns a LayoutSnapshot: one logical rectangle per stable
WidgetId. The Phase 3 dispatcher can request that snapshot for a draft window
after the same UI capability check. This does not draw anything yet. It gives
native widgets, drawn fallback, hit testing, and accessibility one geometry
answer to share.
There is also a prepared layout path now. The runtime can prepare the window's widget tree once, then recompute layout for repeated viewport changes without rebuilding the engine tree each time. That is the shape the future event loop should use between widget mutations.
Layout has its first input-facing helper too. The layout crate can hit-test a point against the computed rectangles and return the deepest widget under that point. The runtime now has a draft pointer route that uses that helper. It takes a window, viewport, logical x and y, button state, and modifiers, then queues a pointer event with the widget ID if a hit was found. This is still not a native mouse or touch event loop, but the routing shape is now in code.
Keyboard and text input have a draft route too. The runtime can take a key name, button state, and modifiers, look up the window's focused widget, and queue a portable key event. It can also queue committed text input for that same focused widget. This is not IME support yet. It is the route that native keyboard and IME commit events will use.
Theme and scale changes have draft routes too. A native host can later tell the runtime that the system moved between light and dark mode, or that a window changed scale because it moved between displays. Scale values are checked before they enter the queue, so a bad host value cannot silently reach app code.
The event queue now has a single-event poll path as well as a batch drain path.
That matters because the planned UI API exposes events.poll(). Native host
event loops can feed the queue first, then app code can consume events one by
one in stable FIFO order.
Basic host window events have draft routes now too. A future native backend can queue "close requested", "window resized", and "window focus changed" without closing the app window by itself. The app still decides how to respond.
What It Does Not Mean Yet
This is not a finished desktop UI layer.
There is an opt-in AppKit window prototype on macOS, but the default runtime is still headless. The AppKit view path is available only through the native prototype and ignored local smoke tests. The shared event-loop pump gives that prototype a real runtime handoff point, but it does not mean the API is frozen. It is the first contract and adapter shape that lets us build the runtime and host work in the right direction.
Why Start Here
For Krate, the contract is the platform boundary. If the contract is vague, each host adapter will drift. Starting with WIT gives us one shared language for the app, runtime, SDKs, and host adapters.
The next proof should stay small and visible:
- Record prepared and cold layout benchmark numbers on the target hosts.
- Create the first real Linux and Windows
winitwindow and feed its actual callbacks into the Winit collector. - Connect real host input events to the draft pointer, key, and text routes.
- Add a small notes app skeleton that uses the same path.
- Keep capability checks at the dispatcher boundary as native code is added.