Compare commits

...

49 Commits

Author SHA1 Message Date
Chris Tate 222b8a79ff fix(tray): harden rich row contracts 2026-08-17 22:59:53 -05:00
Chris Tate 42f5cc287b fix(tray): keep rich rows model-owned 2026-08-17 21:32:10 -05:00
Chris Tate 03a9693666 feat(tray): add typed rich rows
- Add typed metric, segmented-choice, and bounded chart rows across Zig and TypeScript.
- Render native macOS controls with command routing and accessible platform fallbacks.
- Support independently styled persistent menu-bar titles with configurable size, weight, and number style.
2026-08-17 20:23:41 -05:00
Chris Tate b25cefe318 chore(release): prepare v0.9.3 (#380)
- Synchronize CLI, core, platform package, and example versions to 0.9.3.
- Add release notes and contributors for all changes since v0.9.2.
2026-08-17 15:14:54 -05:00
Chris Tate ef3ba18168 Add platform-correct Command Backspace (#377)
* Add platform-correct Command Backspace

- Add semantic line-start deletion across Zig and TypeScript text mirrors.
- Preserve platform keymaps, controlled state, undo, and replay behavior.
- Cover all editable widgets and document the shortcut.

Co-authored-by: Mohak Bajaj <77928693+MohakBajaj@users.noreply.github.com>

* Fix Command Backspace edge cases

* Fix platform-specific Command Backspace test

---------

Co-authored-by: Mohak Bajaj <77928693+MohakBajaj@users.noreply.github.com>
2026-08-17 14:46:23 -05:00
Chris Tate 465a163e27 feat: add model-driven theme state (#378)
* feat: add model-driven theme state

- Add the TypeScript themeState helper across checker, generated ABI, and UiApp runtime.
- Preserve system accessibility behavior while supporting model pack, scheme, and accent precedence.
- Document the API and migrate gpu-components with runtime and replay coverage.

Co-authored-by: MohakBajaj <77928693+MohakBajaj@users.noreply.github.com>

* fix: constrain theme state optional fields

---------

Co-authored-by: MohakBajaj <77928693+MohakBajaj@users.noreply.github.com>
2026-08-17 08:51:57 -05:00
Chris Tate a33d579177 fix(macos): preserve file drop targets (#374)
* fix(macos): preserve file drop targets

- Route AppKit drops with labeled, view-local coordinates across canvas, WebView, and window fallback paths.
- Preserve drop metadata through Zig and cover widget routing, TypeScript contracts, docs, and examples.

Co-authored-by: Mohak Bajaj <77928693+MohakBajaj@users.noreply.github.com>

* fix(macos): preserve file drop coordinates

* fix: keep file drop targets sized

* fix: keep capability webview interactive

* fix: constrain capabilities window width

---------

Co-authored-by: Mohak Bajaj <77928693+MohakBajaj@users.noreply.github.com>
2026-08-16 23:11:12 -05:00
Chris Tate 393a0ed36e fix(app-runner): load manifest menus and commands (#376)
* fix(app-runner): load manifest menus and commands

- Resolve app.zon commands, menus, and shortcuts consistently across live and replay runners.
- Add zero-config TypeScript coverage, automation, and documentation.

Co-authored-by: MohakBajaj <77928693+MohakBajaj@users.noreply.github.com>

* test(app-runner): verify manifest menu registration

* fix(automation): escape menu snapshot catalogs

---------

Co-authored-by: MohakBajaj <77928693+MohakBajaj@users.noreply.github.com>
2026-08-16 23:04:07 -05:00
Chris Tate e8f9e4ee50 Fix large TypeScript Msg union compilation (#375)
- Derive comptime scan quotas from Msg shape and identifier bytes across generated shims, persistence, channels, and environment routing.
- Keep tag-skew diagnostics precise and teach future generated-code quota failures without blaming contracts.
- Add 160-arm compile/link and full TypeScript pipeline regression coverage.

Co-authored-by: Mohak Bajaj <77928693+MohakBajaj@users.noreply.github.com>
2026-08-16 21:46:23 -05:00
Chris Tate 659c893b29 Fix combobox Enter submit precedence (#373)
- Prefer bound combobox submit handlers over Enter trigger activation.
- Cover builder, markup, runtime, menu-selection, and command dispatch paths.
- Document the TypeScript-first on-submit composition and keyboard behavior.

Co-authored-by: Mohak Bajaj <77928693+MohakBajaj@users.noreply.github.com>
2026-08-16 21:46:14 -05:00
Chris Tate 8d0da34e62 chore(release): prepare v0.9.2 (#372)
- Synchronize CLI, core, platform package, and example versions to 0.9.2.
- Add release notes and contributors for all changes since v0.9.1.
2026-08-16 20:22:43 -05:00
Chris Tate 1c1fba0c0f fix(macos): place fresh windows correctly (#369)
* fix(macos): place fresh windows correctly

- Separate restored, explicit, and default window placement from persistence policy.
- Honor explicit origins and restore policy across platform seams and both macOS hosts.
- Add placement coverage, update docs, and remove obsolete example workarounds.

* fix(macos): honor window placement policies

* fix(macos): correct window placement policies

* fix(macos): preserve window placement contracts

* fix(runtime): restore secondary window placement
2026-08-16 19:06:29 -05:00
Chris Tate 86fecf6cee fix(macos): report content rects in window frame events (#370)
* fix(macos): report content rects in window frame events

- Emit content geometry consistently from the AppKit and CEF hosts.
- Preserve layout and restore round-trips without titlebar drift.

Co-authored-by: Sepehr Safari <25853688+sepehr-safari@users.noreply.github.com>

* fix(macos): restore CEF content geometry

---------

Co-authored-by: Sepehr Safari <25853688+sepehr-safari@users.noreply.github.com>
2026-08-16 17:18:48 -05:00
Chris Tate 23d0f5908a feat(images): decode photos to fit runtime budgets (#366)
* feat(images): decode photos to fit runtime budgets

- Decode encoded photos to fit the app's registered-pixel budget across platform codecs.
- Add a validated 1-8 MiB app.zon image budget and independent 8 MiB source bound.
- Cover deterministic pixels, 1080p loading, replay, ABI plumbing, and documentation.

Co-authored-by: Sepehr Safari <25853688+sepehr-safari@users.noreply.github.com>

* fix(images): honor raised budgets across hosts

* fix(images): cover generated and non-mac hosts

* fix(images): harden source and replay limits

* fix(images): fit Android panoramas before validation

---------

Co-authored-by: Sepehr Safari <25853688+sepehr-safari@users.noreply.github.com>
2026-08-16 16:26:36 -05:00
Chris Tate 919d0e6cdf fix(canvas): respect link span underline flag (#368)
- Render span underlines only when explicitly enabled.
- Preserve conventional underlines for Markdown-generated links.
- Cover linked spans with and without underline decoration.

Co-authored-by: Sepehr Safari <25853688+sepehr-safari@users.noreply.github.com>
2026-08-16 16:02:39 -05:00
Chris Tate 5d7fee8262 schema and rendering fixes (#367)
* canvas: allow checkbox and radio label content

* canvas: add interaction washes to actionable surfaces

* canvas: keep selection control geometry and fills consistent

* canvas: separate switch thumb and track colors

* canvas: preserve resolved colors through disabled washes

* canvas: skip zero-width badge and button strokes

* canvas: tint destructive alerts and align compact content

* docs: regenerate canvas control previews

* canvas: preserve text attributes on text-bearing controls

* canvas: fast-path idle layout container rendering

* canvas: keep authored row fills as the rest state

* bench: recalibrate full-view toggle budget

* canvas: complete schema and rendering fixes

- Enable checkbox and radio labels consistently across schema, markup engines, accessibility, docs, and previews.
- Unify actionable state, disabled-color, variant-accent, and zero-width stroke behavior across controls and surfaces.
- Correct compact control geometry and destructive alert styling/alignment with comprehensive golden and gate coverage.

* canvas: address schema review findings

* canvas: fix actionable state routing

* canvas: fix drag menus and disabled control tokens
2026-08-16 15:05:24 -05:00
Chris Tate 5c32accf12 fix(runtime): reveal autofocus targets and carets (#364)
* fix(runtime): reveal autofocus targets and carets

- Share scroll-then-verify focus reveal across keyboard, autofocus, and automation.
- Preserve selection while revealing collapsed end carets inside text editors.
- Cover scrolled focus flows and document the autofocus contract.

* fix(runtime): make focus reveals transactional

* fix(runtime): make widget focus reveal transactional
2026-08-16 08:02:28 -05:00
Chris Tate d575734635 Fix anchored surface correctness (#363)
* fix(canvas): dismiss anchored surfaces without focus

* fix(canvas): relayout anchors after scroll restore

* fix(canvas): place modal surfaces against root

* fix(canvas): address anchored surface review issues

* fix(canvas): unify window surface behavior
2026-08-15 22:26:04 -05:00
Chris Tate ee63266095 Implement logical canvas radio groups (#361)
* Implement logical canvas radio groups

- Scope nested radios as one roving-focus, single-selection group.
- Align radio keyboard, pointer, and handler dispatch behavior.
- Expose radiogroup accessibility semantics and document the contract.

* Fix radio group accessibility edge cases

* Fix radio group focus traversal edge cases

* Fix radio group keyboard and naming semantics

* fix: preserve radio selection semantics
2026-08-15 18:36:17 -05:00
Chris Tate e7c161970c Add configurable accessory startup (#358)
* Add configurable accessory startup

- Add manifest, runner, and macOS host support for flash-free Accessory startup.\n- Require a tray affordance and cover launch/runtime composition with tests.\n- Update the TypeScript menu-bar example and lifecycle documentation.

* Fix flash-free accessory startup

* Enforce tray requirement when packaging accessories
2026-08-15 00:52:34 -05:00
Chris Tate ddd975e4ea chore(ovation): checkpoint agent step (#357) 2026-08-14 18:03:46 -05:00
Chris Tate 474cb5e364 fix(core): pin scriptc tuple normalization fix (#356)
- Upgrade Native SDK compiler dependencies and generated references to scriptc 0.0.31.
- Add a real compiled-core ABI regression for bare-model and effect-tuple returns.
- Centralize test pin lookup and remove release numbers from comments and prose.

Co-authored-by: John Lindquist <36073+johnlindquist@users.noreply.github.com>
2026-08-14 17:27:26 -05:00
Chris Tate e6ac6ac4fc fix(linux): avoid GTK alert dialog crash (#354)
- Initialize GtkAlertDialog with a valid empty format string.
- Add regression coverage preventing a NULL constructor argument.

Co-authored-by: ElSebas41 <189925713+ElSebas41@users.noreply.github.com>
2026-08-14 09:53:19 -05:00
Chris Tate 0ecdc7d2e9 feat(core): expose model-declared windows to TypeScript (#351)
* feat(core): expose model-declared windows to TypeScript

- Add canonical TypeScript window descriptors with close-policy and close-command routing.
- Compile and hot-reload label-addressed secondary-window markup in generated launchers.
- Cover quit/hide behavior end to end and port the TypeScript system-monitor settings window.

* fix(core): harden TypeScript window declarations

* fix(core): validate TypeScript window views

* fix(core): validate returned window descriptors

* fix(core): harden TypeScript window contracts
2026-08-14 09:30:18 -05:00
Chris Tate e924d7fcac feat(examples): make the feed reader the end-to-end services showcase (#352)
* feat(examples): make the feed reader the end-to-end services showcase

* fix(examples): contain feed results in scroll pane

* fix(core): preserve service facade unbound metadata
2026-08-13 22:34:57 -05:00
Chris Tate 41c4cdc47a feat(files): add delete file effects (#350)
* feat(files): add delete file effects

- Expose Cmd.deleteFile as the primary TypeScript API with checked routing and documentation.
- Add real, fake, permission, journal, and replay support through the shared file-effect engine.
- Cover compiled cores across macOS, Linux, and Windows, including reliable Windows append behavior.

* fix(files): await Windows append completion

* fix(files): preserve final symlink on delete
2026-08-13 21:00:48 -05:00
Chris Tate 31d5b202bc feat(mobile): compile TypeScript cores and services for mobile targets (#346)
* feat(mobile): compile TypeScript cores and services for mobile targets

The external core and service compile drivers admit the pinned
compiler's three mobile triples — aarch64 iOS device and simulator
(macOS build host, iOS 15.0 floor) and aarch64 Android (any desktop
host, API 26 floor) — as library archives only, mapping the build
graph's Zig triples onto the compiler's own spellings and threading
the Android NDK location the way the graph already threads its zig.
The service executable lane refuses mobile targets with the
in-process pointer: no child process exists there, so the carrier
resolution turns "auto" into the in-process pool on iOS/Android and
teaches on an explicit "child", while desktop behavior is unchanged.

Mobile app builds stage a generated mobile entry beside the desktop
wiring: it satisfies the embed host's AppDef contract over the same
mirror, markup, and registry, drives the canonical mobile scene plus
the manifest's declared chrome, owns the service pool (markers and
stream relays live in the shim-installed app-data directory), and
delivers that directory through envMsgs. The compiled archives merge
into the embed static library the host tiers already link — flattened
to plain objects on Android, where Zig's ELF static-library emission
stores archive inputs as nested members the NDK link would skip.

A mobile execution lane (scripts/mobile-e2e.sh, NATIVE_SDK_MOBILE=1
in either gate tier) stages a battery over the service fixture's
compiled core and service archives, packages the fixture app for both
mobile targets and a services-free example for Android, then executes
the battery on a booted iPhone simulator and a headless arm64
emulator: typed pool results across update round trips, trap
isolation poisoning exactly one instance, and a journal replay that
reproduces the recorded model without initializing the archive. All
checks pass on both device classes. The TypeScript and services
chapters and the ts-core/ts-services skills state the mobile matrix;
persistence, boot images, and URL media caching remain unwired on
mobile.

* fix mobile TypeScript app wiring

* fix(mobile): validate TypeScript package outputs
2026-08-13 18:13:37 -05:00
Chris Tate 2bc942db46 feat: support close policies on secondary windows (#349)
- Expose UiApp.WindowDescriptor.close_policy and forward it through window creation.
- Preserve hide/reopen identity while retaining existing platform safety gates.
- Document and test quit, hide, and unsupported-host lifecycle behavior.
2026-08-13 17:37:27 -05:00
Chris Tate 199b89e06d feat: add actionable notifications (#347)
* feat: add actionable notifications

- Add stable replacement identifiers and paired notification actions across SDK APIs.
- Dispatch activations through the normal command path on macOS, Windows, and Linux.
- Advance the TypeScript wire generation and cover validation, test platforms, and docs.

* fix: secure notification action dispatch
2026-08-13 16:33:37 -05:00
Chris Tate 716eb27c53 feat(core): classify subset rules as guarantee or deferred (#345)
Every NS rule in the diagnostics catalogue carries class: "guarantee" |
"deferred". Guarantee rules protect a core invariant (determinism and
replay, fixed shapes, immutability of shared data, the one text
representation) and are permanent; deferred rules (NS1011, NS1019,
NS1040, NS1042, NS1044) wait on an easing decision and their
diagnostics say the capability is deliberately deferred, not
impossible.

Fix text names the concrete service alternative where one exists:
NS1002 (network via the generated client), NS1005 (clock reads),
NS1011 (Map/Set transforms), NS1040 (regex matching), NS1041 (JSON
parsing returning a typed record).

Docs and skills frame the core and services by role: the core is the
app's deterministic logic; services do the app's imperative work.
2026-08-13 14:15:06 -05:00
Chris Tate baef0d96d3 feat(storage): harden file effects (#339)
* feat(storage): harden file effects

- Add bounded streaming reads, atomic write sinks, stat, and append effects.
- Gate external paths with symlink-safe filesystem permission checks.
- Preserve deterministic record/replay through content-addressed stream blobs.

* fix(storage): harden file effect lifecycles

* fix(storage): harden stream replay and key ownership

* fix(storage): keep stream sink on rejected chunks

* fix(storage): address file stream review findings
2026-08-13 14:10:10 -05:00
Chris Tate f228e861d1 Upgrade scriptc to 0.0.29 (#344)
- Pin scriptc and its compiler/runtime lock data to 0.0.29.
- Refresh service contracts, compatibility fixtures, calibration, and documentation.
- Regenerate the service compile-surface reference; the 0.0.29 surface manifest carries no entry changes from 0.0.28 and adds a coverage statement for the mobile library targets (aarch64-apple-ios, aarch64-apple-ios-simulator, aarch64-linux-android).
2026-08-13 13:56:28 -05:00
Chris Tate 6ec836c491 feat: support multiple macOS status items (#343)
- Add keyed status-item lifecycle and independent live updates across the runtime and macOS hosts.
- Expose model-driven TypeScript and Zig collection APIs with status-item-aware events.
- Extend automation, journaling, documentation, and regression coverage.
2026-08-13 13:14:16 -05:00
Chris Tate 9ce0370181 chore(release): prepare v0.9.0 (#341)
- Add complete v0.9.0 release notes and contributor credits.
- Synchronize CLI, core, platform, tool, and example version pins.
- Discover every TypeScript example during version synchronization.
2026-08-13 02:00:28 -05:00
Chris Tate a7665807f3 feat(cores): compile TypeScript cores for cross desktop targets (#340)
The external core compile driver now enforces the same host/target
pairing matrix as the service compile lane: same-triple compiles keep
the native lane, Linux and Windows GNU targets cross-compile from any
macOS/Linux/Windows build host over the compiler's zig-cc lane, macOS
targets need a macOS build host, and every refused pairing teaches
before compiler work starts. The co-emitted contract sidecar is
target-independent: a macOS-native, x86_64-windows-gnu, and
x86_64-linux-musl compile of one staged tree emit byte-identical
documents with identical integer-class decisions, and the COFF and ELF
archives declare the same nsc_core_* symbol surface as the Mach-O one.

The SDK's fixture graph compiles corewire for the build host, so the
battery lanes configure under a cross -Dtarget, and a new
stage-cross-e2e step installs the host-fixture, markup, and in-process
service pool batteries under <prefix>/e2e for execution on the target
machine. scripts/cross-e2e.sh drives the lane end to end: it
cross-builds the batteries, the kanban example, and the service fixture
app (in-process carrier — the core and service archives linked into one
executable, with no defined-symbol overlap between the localized
service archive and the core's contract surface) for x86_64-windows-gnu
and x86_64-linux-musl, then executes the batteries on the Windows box
over ssh and in an amd64 Alpine container. gate.sh runs the lane in
either tier when NATIVE_SDK_CROSS=1 is set and skips it otherwise.

All three batteries pass on both targets: 29 host-fixture, 10 markup,
and 19 in-process pool tests on Windows (the three posix-spawn tests
skip there), and 32/10/19 in the musl container. The TypeScript chapter
and ts-core skill state the supported build matrix, including the Linux
glibc spelling and the unchanged desktop-only scope for mobile.
2026-08-13 01:28:42 -05:00
Chris Tate fe92cff10a feat(services): extend the in-process carrier opt-in to Windows and cross targets (#337)
* feat(services): extend the in-process carrier opt-in to Windows and cross targets

scriptc 0.0.28 localizes archive runtime symbols format-aware (ELF,
COFF, Mach-O) on every desktop host and for cross targets, so the
explicit `.service_carrier = "in_process"` opt-in no longer needs the
host-native macOS/Linux gate. Carrier resolution and the SDK fixture
lane's archive gate now admit the compiler's build matrix: Linux and
Windows targets from any macOS/Linux/Windows build host, and macOS
targets from a macOS host. Auto still resolves to the isolated child
carrier everywhere, and child stays selectable on every shape.

The service compile lane replaces its build-host-only refusal with the
same matrix. Same-triple compiles keep the native lane; admitted cross
pairings run the compiler's zig-cc lane (SCRIPTC_CC=zigcc,
SCRIPTC_TARGET=<triple>, and the build's own zig at the front of PATH
through the new --zig-exe argument); refused pairings keep a precise
teaching. The child executable's name follows the target OS instead of
the host's.

Cross Linux targets carry one encoded caveat: a bare `-gnu` spelling
lands on Zig's default glibc floor, which predates arc4random_buf — a
symbol the compiled service runtime references — so carrier resolution
teaches the `-gnu.2.36`+ (or `-musl`) spelling at configure time, and a
stated glibc version now rides the platform triple into the compile.
Windows targets link ws2_32, iphlpapi, and advapi32 beside the compiled
archives: the fixture modules get a shared helper, and the app lane's
Windows platform block adds advapi32 for the archive's CSPRNG.

Both carrier e2e suites pass natively on Windows (14 child-carrier
tests, 19 in-process pool tests: parallel keys, per-key FIFO,
cooperative cancellation and deadlines, trap isolation, streaming, and
replay), a macOS-built x86_64-windows-gnu service archive links and
initializes on Windows, and the linux-musl child executable and archive
cross-compile from macOS. The docs services chapter, packages page, and
ts-services skill state the widened opt-in and the cross-target scope.

* fix(services): align cross-target ScriptC archives

* fix(services): gate in-process archive architectures

* fix(build): keep ScriptC floor checks step-local

* fix(services): tighten cross-target toolchain gates
2026-08-12 23:51:19 -05:00
Railly Hugo 7d67158444 fix(docs): redirect /docs to the introduction page (#338)
/docs returned 404 while every page beneath it resolved and every legacy
URL form already redirected into /docs/*. docsSlugs() only yields nested
slugs, so the /docs segment itself had neither a route nor a generated
redirect, and /docs/ compounded it by 308ing into that dead end.

/docs is the parent of every documentation link on the site and the
likeliest hand-typed entry point, so open it on the Introduction. The
.md sibling keeps the Markdown surface whole for agents that reach for
it, matching the pairing legacyDocsRedirects applies to every slug.

Fixes #331

Co-authored-by: Cris <cristian.correa.cs@gmail.com>
2026-08-13 01:20:52 -03:00
Chris Tate c71a7b4638 Upgrade scriptc to 0.0.28 (#336)
* Upgrade scriptc to 0.0.28

- Pin scriptc and its compiler/runtime lock data to 0.0.28.
- Refresh service contracts, compatibility fixtures, calibration, and documentation.
- Regenerate the service compile-surface reference; the 0.0.28 surface manifest carries no entry changes from 0.0.27.

* docs: state the carrier default correctly on the packages page
2026-08-12 19:56:19 -05:00
Chris Tate d7aeea1ea9 Promote credentials to core effects (#335)
* Promote credentials to core effects

- Add app-scoped TypeScript and Zig credential effects with capability and permission gates plus platform-backed storage.

- Redact credential journal results, synthesize deterministic replay placeholders, and provide hermetic devhost and test stores.

- Update checker diagnostics, mobile plumbing, SDK surfaces, documentation, skills, and conformance coverage.

* fix(runtime): harden credential effect lifecycle

* fix: harden credential effect handling
2026-08-12 19:21:30 -05:00
Chris Tate 8b2a97ffe7 feat(services): add the in-process TypeScript service carrier (#334)
* feat(services): add the in-process TypeScript service carrier

Compile src/services into a thread-instanced, runtime-localized library
archive (scriptc 0.0.27 library mode) linked into the app binary, and run
it on a small worker-thread pool: one archive instance per pool thread,
same-key requests strictly FIFO, distinct keys in parallel across
instances. The carrier preserves the child-process seam exactly — the
same HostCallBinding, poll-based completion delivery, journaled results,
lazy start, and replay that never initializes the archive.

Cancellation and deadlines ride the same cooperative marker-file token
the child publishes, with the same grace; an operation that ignores its
token is abandoned (thread detached, timeout routed, pool refilled), and
a detected trap routes kind service_trap through the per-instance panic
sink while other instances keep answering. Streaming chunks relay live
through a per-request framed file the pool's supervisor thread tails, so
chunks keep preceding the typed terminal mid-operation.

In-process is the default carrier on host-native macOS/Linux builds;
app.zon .service_carrier (and -Dservice-carrier) select explicitly, with
.service_pool_size (-Dservice-pool-size) setting the pool width (default
min(4, cores)). Windows and cross builds keep the child carrier.

corewire grows the two in-process projections (library facade entry and
compiler profile), the service compile script grows an --out-archive
lane, and bench-service-host now measures both carriers. The new pool
e2e suite covers success/throw routing, duplicate and unkeyed keys,
live streaming with cancellation, queued deadlines, parallelism,
per-key FIFO, trap isolation, the registry/archive pairing fence, and
journal replay against the pool.

* test(services): absorb runner load in the pool parallelism proof

One retry of the parallel batch keeps the timing assertion meaningful
on saturated runners; a pool that serialized distinct keys fails both
attempts deterministically.

* fix(services): harden in-process carrier supervision

* fix(services): harden pool queue scheduling

Wake the supervisor when newly admitted work can move the next deadline earlier, and scan the full queue so large busy-key bursts cannot hide runnable independent work.

* fix(services): drain streams after grace race

Let completion-owned requests remain in the supervisor stream polling path when the grace poison CAS loses, and cover the boundary with a deterministic regression.
2026-08-12 16:25:50 -05:00
Chris Tate 473cad71ef feat(storage): ship checked relational SQLite (#326)
* feat(storage): ship checked relational SQLite

- Add capability-gated SQLite effects, migrations, transactions, live queries, and replay across desktop and mobile.
- Validate schemas and named SQL at build time, generating typed command and subscription APIs.
- Add the relational notes flagship, documentation, tests, skills, and SDK mirror support.

* fix relational SQLite correctness gaps

* fix(storage): harden relational SQLite checks

* fix relational runtime policy parity

* fix sqlite tooling on node 22

* fix(storage): harden relational sqlite boundaries

* fix(storage): address relational review findings

* fix(storage): address remaining relational review findings

* fix(storage): address relational review issues

* fix(sqlite): retire stale live queries before replacements
2026-08-12 14:26:00 -05:00
Chris Tate 06b6ccd53c Upgrade scriptc to 0.0.27 (#333)
- Pin scriptc and its compiler/runtime lock data to 0.0.27.
- Refresh service contracts, compatibility fixtures, calibration, and documentation.
- Regenerate the service compile-surface reference; the 0.0.27 surface manifest carries no entry changes from 0.0.26.
2026-08-12 12:41:59 -05:00
Chris Tate ed84e35975 tools: scriptc surface-manifest diff and service-capability gate (#328)
* feat(tooling): gate capability claims on the compiler surface manifest

- Add surface_manifest_diff.mjs: mechanical diff of two scriptc surface
  manifests keyed by stable entry ids — flips to static (with the
  staging-transform/caveat retirements each flip makes due), tier
  regressions, easings, added/removed entries, SC-code and semantic
  note changes; human-readable and --json output.
- Add gen_service_surface.mjs: generate the author-facing service
  compile-surface reference (skill-data/ts-services/references/
  service-surface.md) byte-derived from the pinned compiler's manifest,
  plus a --check mode that fails on a stale or hand-edited reference,
  a manifest/pin version mismatch, any hand-written SC-code claim in
  docs/skills/skill-data prose, or a compiler version literal that does
  not match the pin.
- Wire the check into scripts/gate.sh as the surface-claims step:
  fast tier when docs/, skills/, skill-data/, or packages/core/ change;
  always in full.
- Point skill-data/ts-services/SKILL.md at the generated reference and
  cover the tooling in packages/core/test/surface_tools.test.ts.

* fix(tooling): harden service surface checks

* fix(tooling): close service surface audit gaps

* fix(tooling): close manifest review gaps

* fix(tooling): harden surface claim auditing

* fix(tooling): keep surface audits layer-aware
2026-08-12 09:50:03 -05:00
Chris Tate 5fa8074f7c feat(bench): add service-host carrier benchmark (#329)
Measure the out-of-process TypeScript service carrier through its
production HostCallBinding against a bytes-echo service compiled by the
production service lane (frontend contract -> corewire host/registry ->
exact-pinned plain-scriptc executable). The echo operation returns its
request unchanged, so the numbers are the carrier's — lazy child spawn,
hello fence, framing, pipes, worker-thread queueing — not a workload's.

Scenarios: cold start (fresh host, first keyed request from admission to
polled completion, including the lazy spawn), warm round trips for 64 B
and 256 KiB payloads (p50/p90/p99), and queued throughput for N keyed
requests drained through the single worker thread.

Run: zig build bench-service-host -Doptimize=ReleaseFast
(requires node and npm ci in packages/core, like the services e2e lane).
2026-08-12 01:43:40 -05:00
Chris Tate 8d7946edbd docs: add the TypeScript Services chapter (#327)
* docs: add the TypeScript Services chapter

Give TypeScript services their own documentation page covering the
two-tier split, service authority and the sanitized environment,
operation authoring and kind-tagged errors, the generated typed client
with keys/timeouts/cancellation/streaming, exact vendored npm with the
calibrated coverage results, and the runtime behavior (lazy start,
supervision, replay without the service child, desktop scope).

Condense the inline services section on the TypeScript Cores page to a
summary that links the chapter, point the packages page at the new
route, wire navigation and page titles, and correct the keyed-effect
note: service requests reject duplicate live keys rather than
replacing them.

* docs: restyle the TypeScript Services chapter as reference documentation

Rewrite the chapter in a terse reference style: noun-phrase sections,
short declarative paragraphs, code examples and tables first. All
technical content is unchanged: the two tiers, service authority and
the environment allowlist, operation rules and boundary types,
kind-tagged errors, the generated client, keys/timeouts/cancellation,
streaming, exact vendored npm with the calibration results, runtime
behavior, the dev loop, and the NS1065-NS1067 teachings.

Replace vague failure-mode wording on the cores page with the precise
outcomes: dropped requests dispatch no message, stream cancellation
routes cancelled to err, and truncated streaming fetches end with
err: truncated.
2026-08-12 01:32:56 -05:00
Chris Tate 011caa9183 Upgrade scriptc to 0.0.26 (#325)
- Pin scriptc and its compiler/runtime lock data to 0.0.26.
- Refresh service contracts, compatibility fixtures, calibration, and documentation.
- Invalidate cached service frontends whenever the compiler manifest changes.
2026-08-11 22:38:24 -05:00
Chris Tate b7c493bab0 feat(ts-services): add typed boundary and ecosystem (#321)
* feat(ts-services): add typed boundary and ecosystem

- generate record-typed service clients/codecs and enforce hermetic 100% npm-static coverage

- run services in the devhost with native-format record/replay parity

- add typed streaming, cooperative cancellation, deadlines, fixtures, and guidance

* fix(ts-services): harden request admission

* fix(ts-services): align devhost runtime behavior

* fix(ts-services): enforce request deadlines
2026-08-11 21:25:48 -05:00
Chris Tate aa7ed9aa52 feat(storage): add SQLite-backed record store (#320)
* feat(storage): add SQLite-backed record store

- add capability-shed SQLite storage with deterministic effects, replay, and atomic record operations
- expose matching TypeScript and Zig APIs across desktop and mobile hosts
- add hermetic coverage, devhost support, documentation, and a worked example

Co-authored-by: carvalab <1446654+carvalab@users.noreply.github.com>

* fix(storage): restore pristine SQLite amalgamation

- restore the upstream byte removed during whitespace cleanup so the vendored source matches its documented checksum

Co-authored-by: carvalab <1446654+carvalab@users.noreply.github.com>

* fix(storage): harden store result delivery

* fix(storage): align devhost store semantics

* fix(storage): address review findings

---------

Co-authored-by: carvalab <1446654+carvalab@users.noreply.github.com>
2026-08-11 19:59:33 -05:00
Chris Tate 833e79e44a Implement the TypeScript service seam (#317)
* Implement the TypeScript service seam

- Classify src/services as ordinary static-tier TypeScript and generate a checked service contract.
- Compile, package, and supervise a pinned out-of-process service host behind Cmd.request.
- Cover service authority, failures, restart, timeout, replay, docs, skills, and showcase fixtures.

* fix service boundary validation and staging

* fix TypeScript service packaging and validation

* fix TypeScript service review findings
2026-08-11 14:23:22 -05:00
491 changed files with 335977 additions and 2931 deletions
+8
View File
@@ -29,6 +29,8 @@ jobs:
# per-run compiler cache dies with the VM and runs stay hermetic
# across commits by machine lifecycle.
- run: npm ci --prefix packages/core
- name: Service surface tooling and claims
run: node --test packages/core/test/surface_tools.test.ts
- run: zig build test
- run: zig build validate
@@ -67,8 +69,14 @@ jobs:
# gpu-components is a TypeScript-core app, so its smoke build needs
# the frontend compiler and exact-pinned TypeScript toolchain.
- run: npm ci --prefix packages/core
# The mobile aggregate runs on Linux for Android. Exercise the other
# store-capable cross-target here against the real iPhone simulator SDK.
- run: zig build test-example-mobile-canvas-lib-ios-store
- run: zig build test-webview-system-link
- run: zig build test-webview-smoke
# The zero-config TypeScript runner must load app.zon menus before
# automation can select their registered command ids.
- run: zig build test-menu-bar-smoke
# Signed-package seal pin: an ad-hoc signed package must pass
# codesign --verify --strict (macOS runners are the only tier with
# codesign; the step skips loudly anywhere else).
+1
View File
@@ -42,6 +42,7 @@ docs/tsconfig.tsbuildinfo
# Dev-tool dependency trees (installed per package, never committed)
packages/core/node_modules/
.pnpm-store/
# The CLI-materialized editor copy of @native-sdk/core inside the TS example
# apps (node_modules is editor surface, never source — the same entry the
+2 -2
View File
@@ -6,9 +6,9 @@ Guidance for agents (and humans) working on this repository.
Native SDK itself is implemented in Zig, but Native SDK **apps are authored in TypeScript + Native markup by default**. Do not infer the app-authoring language from this repository's implementation language or from older Zig-core examples.
- For a new app, use `native init <path>` and expect `src/core.ts`, `src/app.native`, and `app.zon`. Do not add Zig app code unless the user explicitly chooses `--template zig-core` or the feature requires a toolkit extension.
- For a new app, use `native init <path>` and expect `src/core.ts`, `src/app.native`, and `app.zon`. Ordinary compiled TypeScript work that needs filesystem, process, JSON, regex, classes, or other static-tier APIs belongs under optional `src/services/`, reached from the core with `Cmd.request`; do not import a service from the core. Do not add Zig app code unless the user explicitly chooses `--template zig-core` or the feature requires a toolkit extension.
- Before changing an existing app, inspect its tree. A `src/core.ts` app stays TypeScript; a `src/main.zig` app stays Zig unless the task is specifically a migration.
- For default app work, read `skill-data/native-ui/SKILL.md` and `skill-data/ts-core/SKILL.md`. `skill-data/core/SKILL.md` covers shared/runtime concerns; `skill-data/zig/SKILL.md` is for Zig-core apps and SDK implementation work.
- For default app work, read `skill-data/native-ui/SKILL.md` and `skill-data/ts-core/SKILL.md`; also read `skill-data/ts-services/SKILL.md` when the tree has `src/services/` or the task needs ordinary TypeScript beyond the core subset. `skill-data/core/SKILL.md` covers shared/runtime concerns; `skill-data/zig/SKILL.md` is for Zig-core apps and SDK implementation work.
- The `-ts` suffix on a few examples only distinguishes ports from older Zig originals. New TypeScript apps need no suffix because TypeScript is the default.
## Build, test, and gate
+103 -3
View File
@@ -2,12 +2,114 @@
All notable changes to the Native SDK (formerly zero-native) will be documented in this file.
## 0.8.4
## 0.9.3
<!-- release:start -->
### New Features
- **Model-driven TypeScript theme state**: Zero-config TypeScript apps can now derive the built-in pack, color scheme, and accent from committed model state while preserving manifest fallback, live system accessibility settings, deterministic replay, and the existing `themePack` helper (#378).
- **Platform-correct line deletion**: Command+Backspace on macOS now deletes to the start of a field or logical textarea line across every editable canvas control, with matching TypeScript text helpers, controlled-state behavior, undo, and replay (#377).
### Bug Fixes
- **Precise macOS file-drop routing**: AppKit drops now retain labeled canvas and WebView targets with top-left, view-local coordinates, while unlabeled window regions fall back to content coordinates (#374).
- **Manifest menus in generated runners**: Zero-config TypeScript and Zig-core apps now load `app.zon` commands, shortcuts, and menus consistently in live and replay runners, including ejected-runner fallbacks (#376).
- **Large TypeScript message unions compile reliably**: Generated shims now derive comptime scan quotas from message shape and identifier size, allowing wide unions to compile across persistence, channels, environment routing, and the full external-core pipeline (#375).
- **Correct combobox Enter precedence**: A bound `on-submit` now handles Enter before trigger activation, so query submission no longer opens the picker or dispatches the wrong command (#373).
### Contributors
- @ctate
- @MohakBajaj
<!-- release:end -->
## 0.9.2
### New Features
- **Flash-free accessory startup**: Apps can opt into accessory activation from `app.zon` to launch without a Dock icon or foreground flash, with tray-affordance validation, runtime composition, packaging support, and an updated menu-bar example (#358).
- **Logical canvas radio groups**: Nested radios now form accessible single-selection groups with roving focus and consistent keyboard, pointer, handler, and naming semantics (#361).
- **Budget-aware photo decoding**: Dynamic encoded images are downsampled across desktop and mobile codecs to fit a configurable registered-pixel budget, with independent source bounds, deterministic replay, and platform-level regression coverage (#366).
### Bug Fixes
- **Correct anchored surfaces**: Floating and modal surfaces now dismiss without requiring focus, relayout after scroll restoration, resolve against the correct root, and behave consistently across window contexts (#363).
- **Reliable autofocus and caret reveal**: Keyboard focus, autofocus, and automation now transactionally reveal offscreen targets while preserving collapsed end-caret selections in text editors (#364).
- **Explicit link decoration**: Linked text spans now honor their underline flag while Markdown-generated links retain conventional underlines (#368).
- **Stable macOS window geometry**: Fresh windows now distinguish restored, explicit, and default placement, while AppKit and CEF frame events consistently report content geometry without titlebar drift (#369, #370).
### Improvements
- **Consistent canvas controls and surfaces**: Checkbox and radio labels can contain markup consistently, while actionable states, disabled colors, variant accents, selection geometry, compact layouts, and zero-width strokes now render uniformly across the schema, runtime, accessibility tree, and documentation (#367).
### Contributors
- @ctate
- @sepehr-safari
## 0.9.1
### New Features
- **Multi-item macOS menu bars**: Apps can now manage independent, keyed status items with model-driven updates, events, automation, journaling, and regression coverage (#343).
- **Complete TypeScript file effects**: Secure, permission-gated effects now support bounded streaming reads, atomic writes, stat, append, and deletion while preserving deterministic record and replay behavior (#339, #350).
- **Actionable desktop notifications**: Notification replacement identifiers and actions dispatch through the ordinary command path on macOS, Windows, and Linux (#347).
- **Secondary-window lifecycle control**: Window descriptors can declare quit or hide-on-close behavior, preserve hidden-window identity when reopened, and expose the same model-driven window contract to TypeScript apps (#349, #351).
- **Mobile TypeScript cores and services**: TypeScript apps with services now compile into iOS and Android library archives, with mobile packaging and device-level runtime coverage (#346).
### Bug Fixes
- **Safe Linux alert dialogs**: GTK alert dialogs now initialize with a valid empty format string, avoiding a crash from a null constructor argument (#354).
- **Correct compiled-core tuple returns**: The SDK now pins the scriptc tuple-normalization fix and verifies bare-model and effect-tuple ABI returns with a compiled-core regression (#356).
### Improvements
- **Stronger TypeScript core guidance and diagnostics**: Subset rules now distinguish permanent guarantees from deliberately deferred capabilities and point authors to the appropriate service alternative (#345).
- **End-to-end services showcase**: The Feed Reader example now demonstrates the full TypeScript service workflow with typed feed parsing, shared data, fixtures, and replay coverage (#352).
- **Updated compiler integration**: scriptc advances through 0.0.31 with refreshed generated contracts, compatibility fixtures, and compiler-surface references (#344, #356).
### Contributors
- @ctate
- @ElSebas41
- @johnlindquist
## 0.9.0
### New Features
- **Ordinary TypeScript services behind a typed boundary**: Apps can place filesystem, process, JSON, regex, class, and exact-vendored npm work under `src/services/`; Native SDK generates the checked client and codecs, compiles a pinned static service executable, and carries keyed requests, typed streaming, cooperative cancellation, deadlines, supervision, and deterministic replay across the isolated boundary (#317, #321).
- **Optional in-process TypeScript services**: Services can use the same boundary through a linked, runtime-localized worker pool with per-key FIFO ordering, parallel independent keys, streaming, timeout and trap isolation, and replay that never starts the carrier; the explicit opt-in now follows the compiler's Windows, Linux, macOS, and cross-target matrix while the isolated child remains the automatic default (#334, #337).
- **Engine-owned model persistence**: TypeScript cores can persist committed models through capability-gated, atomically replaced snapshots with generated codecs, debounced writes, backup recovery, explicit restore and migration routes, rollback safety, and journal/replay support (#316).
- **SQLite record storage**: TypeScript and Zig apps can use a capability-gated record store for deterministic atomic CRUD effects backed by bundled SQLite across desktop and mobile hosts, with devhost parity and a complete Record Store example (#320).
- **Checked relational SQLite**: Append-only migrations, build-validated named SQL, transactions, generated typed commands and live-query subscriptions, replay, and the Relational Notes example make relational SQLite a first-class offline data layer across desktop and mobile (#326).
- **Model-driven menu-bar apps**: TypeScript apps can derive status-item labels, icons, tooltips, and rich menus from committed model state, while new macOS effects control hidden startup, fullscreen, Dock visibility, and launch-at-login behavior across both native hosts (#311, #314).
- **Platform services for TypeScript cores**: Typed effects now open external URLs, reveal filesystem paths, and format local time through validated macOS, Linux, and Windows backends (#315).
- **App-scoped credentials**: TypeScript and Zig cores can store, load, and delete credentials through capability- and permission-gated native providers, with redacted journals, deterministic replay placeholders, and hermetic devhost stores across desktop and mobile (#335).
- **Cross-compiled TypeScript cores**: The external core compiler now builds Linux and Windows GNU targets from macOS, Linux, or Windows and macOS targets from macOS, with target-independent contracts and cross-platform end-to-end batteries for Windows and Linux musl (#340).
### Bug Fixes
- **Working documentation root**: `/docs`, `/docs/`, and the matching Markdown route now resolve to the Introduction instead of ending at a 404 (#338).
### Improvements
- **Faster retained desktop frames**: Animation pumping and Windows wake scheduling now avoid stalled or redundant work, profiling uses monotonic frame-correlated telemetry, and physical macOS and Windows performance gates protect input latency and frame budgets (#313).
- **Measured, compiler-truth service tooling**: A dedicated TypeScript Services reference documents the two-tier model and failure semantics; production-carrier benchmarks measure cold start, latency, and throughput; generated compiler-surface references and manifest diffs keep capability claims mechanically honest; and scriptc advances through 0.0.28 with refreshed contracts and calibration (#325, #327, #328, #329, #333, #336).
### Contributors
- @ctate
- @carvalab
- @Railly
- @camilocbarrera
## 0.8.4
### New Features
- **Streaming fetch responses for TypeScript cores**: `Cmd.fetch` can now deliver line-framed HTTP responses through typed message arms with deterministic terminal errors, loud cancellation, duplicate-key rejection, and bounded line sizes; the rebuilt Chatbot example streams Vercel AI Gateway replies with live model selection and a Stop action (#300).
- **Desktop audio capture**: TypeScript cores can start bounded, timestamped microphone or system-output PCM streams on macOS and Windows with explicit lifecycle, permission, drop-count, and replay handling; the new Voice Memo example records, saves, and plays WAV files (#303).
- **Customizable macOS DMG packaging**: `native package` now creates polished drag-to-Applications disk images with generated or custom Retina backgrounds, configurable Finder geometry, positioned app and Applications entries, and staged files, directories, or links (#304).
@@ -29,8 +131,6 @@ All notable changes to the Native SDK (formerly zero-native) will be documented
- @marcusschiesser
- @NyxTools-M
<!-- release:end -->
## 0.8.3
### Bug Fixes
+824 -17
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -27,6 +27,7 @@
"src",
"templates",
"tests",
"third_party/sqlite",
"tools",
},
}
+1171 -78
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -4,3 +4,4 @@ next-env.d.ts
.next-gate/
.next-agent/
.next-check/
.next-final/
+7
View File
@@ -58,6 +58,13 @@ const nextConfig = {
return [
// The Philosophy page became the Introduction, the opening page of the docs.
{ source: "/philosophy", destination: "/docs/introduction", permanent: true },
// docsSlugs() only yields nested slugs, so the /docs segment itself has
// neither a route nor a generated redirect and 404s. It is the parent of
// every documentation link on the site and the likeliest hand-typed entry
// point, so open it on the Introduction instead. The .md sibling keeps the
// Markdown surface whole for agents that reach for it.
{ source: "/docs", destination: "/docs/introduction", permanent: true },
{ source: "/docs.md", destination: "/docs/introduction.md", permanent: true },
...legacyDocsRedirects,
];
},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 854 B

After

Width:  |  Height:  |  Size: 1010 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 864 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.
+13 -7
View File
@@ -24,8 +24,6 @@ The manifest `native init` generates — identity, one shell window with a GPU s
.title = "My App",
.width = 480,
.height = 320,
.restore_state = false,
.restore_policy = "center_on_primary",
.views = .{
.{ .label = "main-canvas", .kind = "gpu_surface", .fill = true, .role = "Counter canvas", .accessibility_label = "Counter", .gpu_backend = "metal", .gpu_pixel_format = "bgra8_unorm", .gpu_present_mode = "timer", .gpu_alpha_mode = "opaque", .gpu_color_space = "srgb", .gpu_vsync = true },
},
@@ -158,12 +156,20 @@ A fuller manifest for an app that also [embeds web content](/docs/frontend) and
</tr>
<tr>
<td><code>capabilities</code></td>
<td>Feature declarations (see <a href="/docs/security">Security</a>)</td>
<td>Feature declarations (see <a href="/docs/security">Security</a>). <code>"store"</code> links the engine-owned record store; <code>"sqlite"</code> links relational SQL effects. They share one capability-shed SQLite object but use separate databases; see <a href="/docs/record-store">Record Store</a> and <a href="/docs/sqlite">Relational SQLite</a></td>
</tr>
<tr>
<td><code>dock_visible</code></td>
<td>Initial macOS Dock and app-switcher presence. Defaults to <code>true</code>. Set <code>false</code> for an Accessory/menu-bar app; the policy is applied before startup windows are created, so no Dock tile flashes. Accessory apps must declare the <code>"tray"</code> capability. Runtime <code>Cmd.setDockPresence</code> can still promote or demote the app later.</td>
</tr>
<tr>
<td><code>persist</code></td>
<td>Engine-owned Model snapshot config: monotonic <code>version</code>, optional <code>debounce_ms</code>, and the <code>restore</code> Msg routes (<code>ok</code>/<code>none</code>/<code>err</code>). Requires <code>"persist"</code> in capabilities — see <a href="/docs/persistence">Model Persistence</a></td>
</tr>
<tr>
<td><code>images</code></td>
<td>Optional registered-image budget: <code>.images = .&#123; .max_image_pixel_bytes = 8_388_608 &#125;</code>. The default is 1 MiB and accepted values are 18 MiB. Encoded photos decode aspect-preservingly to fit; storage is lazy per used slot, but 16 fully used 8 MiB slots are a declared 128 MiB high-water. See <a href="/docs/dynamic-images">Dynamic Images</a>.</td>
</tr>
<tr>
<td><code>bridge</code></td>
<td>Bridge command policies (see <a href="/docs/bridge">Bridge</a>)</td>
@@ -277,7 +283,7 @@ For a scene-first app — a `UiApp` passing its Zig scene (`shell_scene`) to the
},
```
Each window takes a `label` plus optional `title`, `width`, `height`, `x`, `y`, `resizable`, `restore_state`, `restore_policy` (`clamp_to_visible_screen` or `center_on_primary`), `initially_hidden` (default false; create the native window ordered out until an explicit show/focus), `allows_fullscreen` (default true; false disables native fullscreen on macOS without disabling ordinary resizing), `min_width`/`min_height` (a content min-size floor the window itself enforces — macOS `contentMinSize`; the first shell window's declaration threads through the startup create like `titlebar`, negative values are a manifest error, 0 means no floor), and `titlebar` (`standard`, `hidden_inset`, `hidden_inset_tall` — the tall variant centers macOS's traffic lights in the 52pt unified band for toolbar-height headers — or `chromeless`, the fully-skinned opt-in that removes all OS chrome including the system buttons; only for apps that draw their own working window controls, see `examples/deck`). `titlebar = "hidden_inset"` hides the titlebar and extends content under it (macOS keeps the traffic lights) — the first shell window's declaration threads through the STARTUP window create, so the main window's chrome is right from the first frame; the app's own header then takes over dragging and inset padding through the `window-drag` attribute and the `on_chrome` hook (see <a href="/docs/native-ui">Native UI</a>). Platforms without the concept keep standard chrome. The same fields are accepted on top-level `windows` entries.
Each window takes a `label` plus optional `title`, `width`, `height`, `x`, `y`, `resizable`, `restore_state`, `restore_policy` (`clamp_to_visible_screen` or `center_on_primary`), `initially_hidden` (default false; create the native window ordered out until an explicit show/focus), `allows_fullscreen` (default true; false disables native fullscreen on macOS without disabling ordinary resizing), `min_width`/`min_height` (a content min-size floor the window itself enforces — macOS `contentMinSize`; the first shell window's declaration threads through the startup create like `titlebar`, negative values are a manifest error, 0 means no floor), and `titlebar` (`standard`, `hidden_inset`, `hidden_inset_tall` — the tall variant centers macOS's traffic lights in the 52pt unified band for toolbar-height headers — or `chromeless`, the fully-skinned opt-in that removes all OS chrome including the system buttons; only for apps that draw their own working window controls, see `examples/deck`). `restore_state` defaults to true and controls only whether the state store is consulted: a store hit restores the saved frame, an authored `x` or `y` is explicit placement, and a fresh window with neither is default placement. On macOS the default `clamp_to_visible_screen` policy keeps restored and explicit frames on their matching or nearest display, centers the main fresh window, and cascades fresh secondary windows; `center_on_primary` centers restored and fresh default frames on the primary display. `titlebar = "hidden_inset"` hides the titlebar and extends content under it (macOS keeps the traffic lights) — the first shell window's declaration threads through the STARTUP window create, so the main window's chrome is right from the first frame; the app's own header then takes over dragging and inset padding through the `window-drag` attribute and the `on_chrome` hook (see <a href="/docs/native-ui">Native UI</a>). Platforms without the concept keep standard chrome. The same fields are accepted on top-level `windows` entries. `dock_visible` is top-level app policy, not a window field: setting it false removes the Dock/app-switcher presence but does not hide a window; pair it with `initially_hidden = true` when the app should launch behind its status item.
Overlay presentation is also fixed at create time: `transparent` makes the top-level window alpha-capable, `always_on_top` selects its floating/topmost level, `click_through` passes pointer input to windows underneath, `activate_on_show = false` reveals it without activating the app or taking focus, and `allows_fullscreen = false` removes macOS fullscreen participation while keeping the window resizable. Canvas windows already use present-before-show, so these flags are applied while the window is hidden and its first alpha-correct frame becomes the first visible frame. `initially_hidden = true` is stronger: it suppresses that reveal until `Cmd.showWindow` or explicit focus. Pair `transparent = true` with a non-opaque `gpu_alpha_mode`; `UiApp.WindowDescriptor` makes that canvas-alpha choice and uses an alpha-zero clear automatically. See the <a href="/docs/windows#overlay-windows">overlay window recipe</a>. These fields are accepted on top-level `windows` and `shell.windows`. Runtime `WindowCreateOptions` exposes the same controls but spells the hidden mode `.show = .hidden`; `UiApp.WindowDescriptor` exposes the overlay controls but not the initially-hidden mode.
@@ -305,11 +311,11 @@ The optional `commands` list declares shared command metadata. The runtime still
An app can define up to 256 commands. Command ids can be up to 128 bytes and titles can be up to 128 bytes.
Generated runners load manifest commands into `RuntimeOptions.commands`. Native code can read the active catalog with `runtime.listCommands(...)`, and trusted WebView code can read it with `window.zero.commands.list()` when the built-in command bridge allows it. Use the catalog to keep menus, shortcuts, toolbar controls, tray items, and bridge callers aligned with the same command ids.
Generated zero-config TypeScript and Zig-core runners load manifest commands into `RuntimeOptions.commands`; ejected runners use the same fallback. Native code can read the active catalog with `runtime.listCommands(...)`, and trusted WebView code can read it with `window.zero.commands.list()` when the built-in command bridge allows it. Use the catalog to keep menus, shortcuts, toolbar controls, tray items, and bridge callers aligned with the same command ids.
## `shortcuts`
The optional `shortcuts` list defines app-level keyboard shortcuts. Generated runners load these automatically:
The optional `shortcuts` list defines app-level keyboard shortcuts. Generated zero-config TypeScript and Zig-core runners load these automatically, as do ejected runners:
```zig
.shortcuts = .{
@@ -328,7 +334,7 @@ Chromium builds are currently macOS-only; use the Linux system WebView backend w
## `menus`
The optional `menus` list defines native app menus. Generated runners load these automatically:
The optional `menus` list defines native app menus. Generated zero-config TypeScript and Zig-core runners load these automatically, as do ejected runners:
```zig
.menus = .{
+9 -3
View File
@@ -36,7 +36,7 @@ When the runtime publishes a snapshot, it writes these files to the automation d
<tbody>
<tr>
<td><code>snapshot.txt</code></td>
<td>Runtime state: source kind, window metadata, native/WebView metadata including role, accessibility label, text, and focus state, <code>ready=true/false</code>, and <code>markup_watch=armed|off</code> in the header — whether the markup hot-reload watch is armed (only in builds where the app wired <code>.markup</code> with a <code>watch_path</code> and <code>io</code>, or registered compiled fragments through <code>fragment_watch</code> — i.e. Debug dev builds)</td>
<td>Runtime state: source kind, window metadata, native/WebView metadata including role, accessibility label, text, and focus state, the configured command and app-menu catalogs, <code>ready=true/false</code>, and <code>markup_watch=armed|off</code> in the header — whether the markup hot-reload watch is armed (only in builds where the app wired <code>.markup</code> with a <code>watch_path</code> and <code>io</code>, or registered compiled fragments through <code>fragment_watch</code> — i.e. Debug dev builds)</td>
</tr>
<tr>
<td><code>accessibility.txt</code></td>
@@ -146,8 +146,8 @@ The runtime watches the command queue and processes these actions:
<td>Dispatch a shortcut command event for the main window</td>
</tr>
<tr>
<td><code>tray-action &lt;item-id&gt;</code></td>
<td>Select a status-item dropdown row (ids from the snapshot's <code>tray-item #id</code> lines)</td>
<td><code>tray-action &lt;item-id&gt;</code> or <code>tray-action &lt;status-item-id&gt; &lt;item-id&gt;</code></td>
<td>Select a status-item dropdown row. The one-id form targets primary status item <code>#1</code>; multiple-item snapshots print <code>tray #id</code> headers</td>
</tr>
<tr>
<td><code>focus &lt;view-label&gt;</code></td>
@@ -261,6 +261,12 @@ Edits refuse rather than guess: a widget authored in Zig, a file that changed on
`zig build test-writeback-smoke` (macOS) drives the whole loop against the kanban example: query provenance, flip the button label through the verb, assert the repaint, verify the byte-exact diff, and flip it back.
## Session recording and blob growth
`NATIVE_SDK_SESSION_RECORD=/path/session.journal` records platform events and effect results; `NATIVE_SDK_SESSION_REPLAY` replays that journal without touching the original network, process, database, or file. Large effect payloads—including every streamed file-read chunk—live content-addressed under the sibling `blobs/` directory, and the journal stores each hash and length. Keep the journal and `blobs/` together when copying a recording.
The blob store deduplicates identical chunks but is intentionally unbounded today: there is no automatic size quota or garbage collector. Long recordings and repeated large imports can therefore grow the directory substantially. Treat a recording as one disposable artifact, monitor its directory size in long automation runs, and delete the journal plus its sibling `blobs/` directory when it is no longer needed. A future GC must reason over every retained journal before removing an unreferenced content address; Tier 5 does not guess at that retention policy.
## Custom directory
Pass a custom path to `automation.Server.init()`:
@@ -246,7 +246,7 @@ Dialog commands are **always default-deny** and require an explicit `builtin_bri
<tr>
<td><code>native-sdk.os.showNotification</code></td>
<td><code>notifications</code></td>
<td>Show a native system notification with a title, optional subtitle, and optional body</td>
<td>Show or replace a native notification; optional paired <code>actionLabel</code> / <code>actionCommand</code> fields dispatch an app command</td>
</tr>
<tr>
<td><code>native-sdk.os.revealPath</code></td>
@@ -266,7 +266,7 @@ Dialog commands are **always default-deny** and require an explicit `builtin_bri
</tbody>
</table>
OS commands are **always default-deny** and require an explicit `builtin_bridge` policy. `native-sdk.os.openUrl` also checks `security.navigation.external_links`; the URL must match the external link allowlist before the platform service is called. macOS, Linux, and Windows system WebView hosts implement `openUrl`, `revealPath`, notifications, and recent-document commands; macOS Chromium also implements the current OS command set. Other platform hosts return `invalid_request` with the standard unsupported-service message until implemented.
OS commands are **always default-deny** and require an explicit `builtin_bridge` policy. `native-sdk.os.openUrl` also checks `security.navigation.external_links`; the URL must match the external link allowlist before the platform service is called. An actionable notification additionally requires an allowed `native-sdk.command.invoke` policy and the `command` permission for the same origin, because activating it enters the normal app-command path. macOS, Linux, and Windows system WebView hosts implement `openUrl`, `revealPath`, notifications, and recent-document commands; macOS Chromium also implements the current OS command set. Other platform hosts return `invalid_request` with the standard unsupported-service message until implemented.
## Credential Commands
@@ -466,9 +466,12 @@ const result = await window.zero.invoke("native-sdk.dialog.showMessage", {
await window.zero.os.openUrl("https://example.com/docs/start");
await window.zero.os.showNotification({
id: "build-status",
title: "Build finished",
subtitle: "native-sdk",
body: "All checks passed.",
actionLabel: "Open results",
actionCommand: "build.open-results",
});
await window.zero.os.revealPath("/Users/me/Downloads/report.pdf");
await window.zero.os.addRecentDocument("/Users/me/Downloads/report.pdf");
+81 -4
View File
@@ -38,6 +38,20 @@ Web content itself is declare-to-use: an app ships the embedded web layer only w
<td>None. Gated by the <code>persist</code> build capability.</td>
<td>Generated TypeScript app runners on every app-data platform; Zig-core hosts receive the same named <code>core.persist</code> effect seam</td>
</tr>
<tr>
<td>Record store</td>
<td><code>Cmd.store.set/get/delete/scan/setMany</code> / <code>fx.storeSet/storeGet/storeDelete/storeScan/storeSetMany</code></td>
<td>None. Model-core effect only.</td>
<td>None. Gated by the <code>store</code> build capability.</td>
<td>SQLite-backed engine store in the per-app data directory; replay remains offline and the core devhost uses a process-local map</td>
</tr>
<tr>
<td>Relational SQLite</td>
<td><code>Cmd.q&lt;Name&gt;</code> / <code>Cmd.qTx</code> / <code>Sub.q&lt;Name&gt;</code> / raw <code>Cmd.db.query/exec</code> / <code>fx.dbQuery/dbExec/dbSubscribe</code></td>
<td>None. Model-core effect only.</td>
<td>None. Gated by the <code>sqlite</code> build capability.</td>
<td>Checked migrations and named SQL over engine-owned <code>app.db</code>; real in-memory SQLite in check, devhost, and tests; journal-only replay</td>
</tr>
<tr>
<td>Native dialogs</td>
<td><code>runtime.showOpenDialog(...)</code> / <code>showSaveDialog(...)</code> / <code>showMessageDialog(...)</code></td>
@@ -89,10 +103,10 @@ Web content itself is declare-to-use: an app ships the embedded web layer only w
</tr>
<tr>
<td>Credential store</td>
<td><code>Cmd.credentialSet(...)</code> / <code>credentialGet(...)</code> / <code>credentialDelete(...)</code> / runtime equivalents</td>
<td><code>Cmd.credentials.set/get/delete</code> / <code>fx.credentialsSet/Get/Delete</code> / runtime equivalents</td>
<td><code>native-sdk.credentials.set</code> / <code>native-sdk.credentials.get</code> / <code>native-sdk.credentials.delete</code></td>
<td><code>credentials</code></td>
<td>macOS model cores, system WebView, and Chromium through Keychain; Linux model cores and system WebView through Secret Service/libsecret when available; Windows model cores and system WebView through Credential Manager</td>
<td><code>credentials</code>. Model cores require both the build capability and permission; WebView commands require the bridge permission.</td>
<td>macOS model cores, system WebView, and Chromium through Keychain; Linux model cores and system WebView through Secret Service/libsecret when available; Windows model cores and system WebView through Credential Manager; iOS toolkit apps through generic-password Keychain entries; Android toolkit apps through an AndroidKeyStore AES-GCM key and authenticated ciphertext in app-private preferences. Core dev and test hosts use hermetic memory.</td>
</tr>
<tr>
<td>Local date/time formatting</td>
@@ -159,6 +173,7 @@ OS bridge commands are default-deny. Add explicit `builtin_bridge` entries for e
.builtin_bridge = .{
.enabled = true,
.commands = &.{
.{ .name = "native-sdk.command.invoke", .permissions = .{ "command" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.platform.supports", .permissions = .{ "window" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.os.openUrl", .permissions = .{ "network" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.os.showNotification", .permissions = .{ "notifications" }, .origins = .{ "zero://app" } },
@@ -179,6 +194,8 @@ OS bridge commands are default-deny. Add explicit `builtin_bridge` entries for e
},
```
Because the JavaScript example below supplies a notification action, the app/runtime permission list must also include `command`. Display-only notifications need only the `native-sdk.os.showNotification` policy and `notifications` permission.
Opening external URLs is also gated by the external-link policy:
```zig
@@ -197,9 +214,12 @@ Opening external URLs is also gated by the external-link policy:
```javascript
await window.zero.os.openUrl("https://example.com/docs/start");
await window.zero.os.showNotification({
id: "build-status",
title: "Build finished",
subtitle: "native-sdk",
body: "All checks passed.",
actionLabel: "Open results",
actionCommand: "build.open-results",
});
await window.zero.os.revealPath("/Users/me/Downloads/report.pdf");
await window.zero.os.addRecentDocument("/Users/me/Downloads/report.pdf");
@@ -236,18 +256,23 @@ window.zero.on("app:activate", () => {
});
```
Use the `app.zon` app id as `service` when WebView code and a model core need to address the same entry. Core credential effects apply that namespace automatically; their key corresponds to the bridge `account` field.
## Model cores
Model-driven apps request notifications as effects. Delivery is fire-and-forget because OS focus modes and user settings remain authoritative after the host accepts the request; fake-executor tests and session replay do not display notifications.
Model-driven apps request notifications as effects. Delivery is fire-and-forget because OS focus modes and user settings remain authoritative after the host accepts the request; fake-executor tests and session replay do not display notifications. An optional nonempty `id` replaces the app's earlier notification with the same id. Supply `actionLabel` and `actionCommand` together to route activation through the same application command path as menus, shortcuts, and bridge command invocation. Action-command delivery is process-scoped: a notification from an earlier execution cannot dispatch its command into a later one. Linux notification shells may still D-Bus-activate an exited app when the notification or its action is clicked, but the stale action is rejected rather than entering the new runtime.
### TypeScript
```ts
case "build_finished":
return [model, Cmd.showNotification({
id: asciiBytes("build-status"),
title: asciiBytes("Build finished"),
subtitle: asciiBytes("native-sdk"),
body: asciiBytes("All checks passed."),
actionLabel: asciiBytes("Open results"),
actionCommand: asciiBytes("build.open-results"),
})];
```
@@ -255,12 +280,64 @@ case "build_finished":
```zig
.build_finished => fx.showNotification(.{
.id = "build-status",
.title = "Build finished",
.subtitle = "native-sdk",
.body = "All checks passed.",
.action_label = "Open results",
.action_command = "build.open-results",
}),
```
Apps using the lower-level runtime can call the same platform seam directly with `try runtime.showNotification(options)`.
### Credentials
Credentials are app-scoped effects, not Model data. Declare both gates; `native check` reports NS1071 for a missing capability and NS1072 for a missing permission when a TypeScript core uses `Cmd.credentials.*`. NS1073 reserves the underlying `core.credentials.*` request names for these typed factories:
```zig:app.zon
.capabilities = .{ "credentials" },
.permissions = .{ "credentials" },
```
The manifest app id is the OS keychain service namespace and is bounded at 128 bytes, so authored code supplies only a key. Keys are NUL-free UTF-8 through 256 bytes and secrets are bounded at 2,560 bytes, the largest binary value every first-party OS backend can store whole. A get miss routes the error arm with `miss`; the other closed outcomes are `denied`, `locked`, `io_failed`, `over_bound`, and `rejected`. Set and delete route empty bytes on success, and delete is idempotent.
```ts
case "save_token":
return [model, Cmd.credentials.set("api-token", msg.token, {
key: "save-token",
ok: "token_saved",
err: "credential_failed",
})];
case "load_token":
return [model, Cmd.credentials.get("api-token", {
key: "load-token",
ok: "token_loaded",
err: "credential_failed",
})];
case "token_loaded":
// Consume msg.token immediately to construct the next effect. Do not
// copy it into Model, where persistence and state fingerprints can see it.
return [model, Cmd.fetch({
url: asciiBytes("https://api.example.com/me"),
headers: { authorization: msg.token },
}, { ok: "profile_loaded", err: "profile_failed" })];
```
```zig
.save_token => |token| fx.credentialsSet(.{
.key = 41,
.credential_key = "api-token",
.secret = token,
.on_result = Effects.credentialsMsg(.credential_result),
}),
.load_token => fx.credentialsGet(.{
.key = 42,
.credential_key = "api-token",
.on_result = Effects.credentialsMsg(.credential_result),
}),
```
Credential results still cross the effect boundary, but recording never writes secret bytes to the journal or blob store. A successful recorded get keeps only its length, a per-session salt, and a placeholder digest that is deliberately independent of the secret (so the artifact is not a password-guessing oracle). Replay supplies deterministic placeholder bytes of the same length and never opens the live keychain. Consequently, replay is suitable for control-flow verification but cannot perform a new authenticated exchange with the original token. The core devhost also stores credentials only in process memory and prints `<redacted, N bytes>`.
See also: [Builtin Commands](/docs/bridge/builtin-commands) and [Security](/docs/security).
+5 -4
View File
@@ -157,13 +157,14 @@ Package the app for distribution. The manifest is picked up at `app.zon` and the
### Platform shortcuts
```sh
native package-windows [--output path] [--binary path]
native package-linux [--output path] [--binary path]
native package-windows [--output path] [--binary path] [--service-binary path]
native package-linux [--output path] [--binary path] [--service-binary path]
native package-ios [--output path] [--binary path]
native package-android [--output path] [--binary path]
```
Per-platform shortcuts for `native package --target <platform>`.
The desktop shortcuts use an explicit `--service-binary` when supplied; otherwise, service-bearing projects discover the normal `zig-out/bin/<app>_services[.exe]` build output just like the canonical command.
### `native bundle-assets`
@@ -237,8 +238,8 @@ Interact with the automation server of a running automation-enabled app. See [Au
<dd>Dispatch a trackpad pinch gesture at a gpu-surface view (<code>scale</code> is the final multiplicative zoom for the gesture; the anchor point defaults to the view center).</dd>
<dt><code>automate shortcut &lt;id&gt;</code></dt>
<dd>Dispatch a shortcut command event.</dd>
<dt><code>automate tray-action &lt;item-id&gt;</code></dt>
<dd>Select a status-item dropdown row.</dd>
<dt><code>automate tray-action &lt;item-id&gt;</code> / <code>automate tray-action &lt;status-item-id&gt; &lt;item-id&gt;</code></dt>
<dd>Select a status-item dropdown row; the one-id shorthand targets primary status item <code>#1</code>.</dd>
<dt><code>automate focus &lt;view-label&gt;</code></dt>
<dd>Focus a native or WebView-backed view.</dd>
<dt><code>automate focus-next</code> / <code>automate focus-previous</code></dt>
@@ -4,7 +4,7 @@ import { CodeToggle } from "@/components/code-toggle";
# Checkbox
A binary value control: the label rides the `text` attribute — checkbox is not a text-bearing element, so text content between the tags is rejected with a teaching error (`label="..."` alone names one for accessibility without a visible label). The model binds `checked`, and `on-toggle` dispatches its Msg — the engine never flips state on its own. For a single choice among options, use [radio](/docs/components/radio); for an on/off setting rendered as a sliding thumb, use [switch](/docs/components/switch).
A binary value control. Put its visible label between the tags (the `text` attribute is the equivalent binding-friendly form); `label="..."` alone supplies an accessible name without drawing a label. The model binds `checked`, and `on-toggle` dispatches its Msg — the engine never flips state on its own. For a single choice among options, use [radio](/docs/components/radio); for an on/off setting rendered as a sliding thumb, use [switch](/docs/components/switch).
<ComponentPreview name="checkbox" alt="Checkboxes rendered by the engine" caption="checked, unchecked, and disabled checkboxes" />
@@ -12,9 +12,9 @@ A binary value control: the label rides the `text` attribute — checkbox is not
```html
<column gap="12">
<checkbox checked="{accepted}" on-toggle="toggle_terms" text="Accept terms and conditions" />
<checkbox checked="{reports}" on-toggle="toggle_reports" text="Send usage reports" />
<checkbox checked="true" disabled="true" text="Managed by your organization" />
<checkbox checked="{accepted}" on-toggle="toggle_terms">Accept terms and conditions</checkbox>
<checkbox checked="{reports}" on-toggle="toggle_reports">Send usage reports</checkbox>
<checkbox checked="true" disabled="true">Managed by your organization</checkbox>
</column>
```
+19 -2
View File
@@ -5,6 +5,8 @@ import { AttrTable } from "@/components/attr-table";
`combobox` is a trigger-only primitive like [select](/docs/components/select), but the trigger is a text entry with a menu affordance: `on-input` names a Msg variant that receives every edit as a text-input event (`canvas.TextInputEvent` in a Zig core; the `TextInputEvent` union from `@native-sdk/core/text` in a TypeScript core), and the model filters the options as the user types. The options themselves are composed the same way as the select's — an anchored [dropdown-menu](/docs/components/dropdown-menu) of menu-items beside the trigger in a `stack`, rendered under an `if`, with `on-dismiss` clearing the model's open flag when Escape or a click outside closes the surface.
Enter submits when `on-submit` is bound; otherwise Enter opens the picker. Space and the open-arrow keys (Down/Up) always open it. Once focus moves into the open menu, Enter selects the focused `menu-item` as usual.
<ComponentPreview name="combobox" alt="A combobox rendered by the engine" caption="a combobox trigger with its search placeholder" />
## Markup
@@ -13,8 +15,8 @@ The model owns the query and the open flag; the `for` source is the model-filter
```html
<stack width="240">
<combobox placeholder="Search frameworks" text="{framework_query}" on-input="framework_edited" on-press="open_framework_menu" />
<if test="{framework_menu_open}">
<combobox placeholder="Search frameworks" text="{frameworkQuery}" on-input="framework_edited" on-submit="commit_framework_query" on-press="open_framework_menu" />
<if test="{frameworkMenuOpen}">
<dropdown-menu anchor="below" anchor-alignment="stretch" on-dismiss="close_framework_menu">
<for each="matchingFrameworks" key="id" as="f">
<menu-item on-press="pick_framework:{f.id}">{f.name}</menu-item>
@@ -24,6 +26,19 @@ The model owns the query and the open flag; the `for` source is the model-filter
</stack>
```
In the primary TypeScript core, the submit arm commits the current model-owned query. The open and submit messages remain separate, so Enter can commit without toggling the picker:
```ts
export type Msg =
| { readonly kind: "framework_edited"; readonly edit: TextInputEvent }
| { readonly kind: "open_framework_menu" }
| { readonly kind: "close_framework_menu" }
| { readonly kind: "commit_framework_query" };
case "commit_framework_query":
return { ...model, committedFrameworkQuery: model.frameworkQuery, frameworkMenuOpen: false };
```
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically. `on_input` takes a comptime message constructor: `Ui.inputMsg(.tag)` builds `Msg{ .tag = edit }` for each `canvas.TextInputEvent`.
@@ -34,6 +49,7 @@ ui.stack(.{ .width = 240 }, .{
.placeholder = "Search frameworks",
.text = model.framework_query,
.on_input = Ui.inputMsg(.framework_edited),
.on_submit = .commit_framework_query,
.on_press = .open_framework_menu,
}, .{}),
if (model.framework_menu_open) ui.el(.dropdown_menu, .{
@@ -56,6 +72,7 @@ ui.stack(.{ .width = 240 }, .{
"disabled",
"on-press",
"on-input",
"on-submit",
"on-dismiss",
]}
/>
+1 -1
View File
@@ -4,7 +4,7 @@ import { CodeToggle } from "@/components/code-toggle";
# Dialog
A modal dialog surface rendered in place: the title comes from the `text` attribute, and visibility is model-owned — wrap the dialog in an `if` on an open flag. `on-dismiss` dispatches when Escape or a click outside dismisses the surface, so `update` clears the flag; the engine hides the surface immediately as an optimistic echo, and the source tree wins on the next rebuild. The title is drawn by the surface chrome and children stack over the full content box, so lead the body column with a fixed-height spacer that clears the title line. For edge-anchored surfaces with the same contract, see [drawer](/docs/components/drawer) and [sheet](/docs/components/sheet).
A modal dialog surface centered against the root view, regardless of which container declares it. The title comes from the `text` attribute, and visibility is model-owned — wrap the dialog in an `if` on an open flag. `on-dismiss` dispatches when Escape or a click outside dismisses the surface, so `update` clears the flag; the engine hides the surface immediately as an optimistic echo, and the source tree wins on the next rebuild. The title is drawn by the surface chrome and children stack over the full content box, so lead the body column with a fixed-height spacer that clears the title line. For root-edge surfaces with the same contract, see [drawer](/docs/components/drawer) and [sheet](/docs/components/sheet).
<ComponentPreview name="dialog" alt="A modal dialog rendered by the engine" caption="title chrome, body content, and a trailing action row" />
+4 -4
View File
@@ -3,9 +3,9 @@ import { AttrTable } from "@/components/attr-table";
# Drawer
A side-anchored surface with the same contract as [dialog](/docs/components/dialog): rendered in place, title via the `text` attribute, visibility model-owned behind an `if`, and `on-dismiss` dispatched on Escape or click-outside so `update` clears the open flag. As with the dialog, the title chrome is engine-drawn and children stack over the full content box, so lead the body column with a fixed-height spacer. For the bottom-edge variant, see [sheet](/docs/components/sheet).
A bottom-edge surface with the same contract as [dialog](/docs/components/dialog): it spans the root view width and pins to the root bottom, regardless of which container declares it. The title comes from the `text` attribute, visibility is model-owned behind an `if`, and `on-dismiss` dispatches on Escape or click-outside so `update` clears the open flag. As with the dialog, the title chrome is engine-drawn and children stack over the full content box, so lead the body column with a fixed-height spacer. For the right-edge variant, see [sheet](/docs/components/sheet).
<ComponentPreview name="drawer" alt="A drawer surface rendered by the engine" caption="a side drawer with a title and stacked controls" />
<ComponentPreview name="drawer" alt="A drawer surface rendered by the engine" caption="a bottom drawer with a title and stacked controls" />
## Markup
@@ -13,7 +13,7 @@ A side-anchored surface with the same contract as [dialog](/docs/components/dial
<column padding="24">
<button variant="outline" icon="menu" on-press="open_filters">Filters</button>
<if test="{filters_open}">
<drawer text="Filters" width="260" padding="24" on-dismiss="close_filters">
<drawer text="Filters" height="260" padding="24" on-dismiss="close_filters">
<column gap="12">
<spacer height="34"></spacer>
<checkbox checked="{only_unread}" on-toggle="toggle_unread">Only unread</checkbox>
@@ -31,7 +31,7 @@ In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically
```zig
// Rendered only while model.filters_open is true; on_dismiss clears it in update.
ui.el(.drawer, .{ .text = "Filters", .width = 260, .padding = 24, .on_dismiss = .close_filters }, .{
ui.el(.drawer, .{ .text = "Filters", .height = 260, .padding = 24, .on_dismiss = .close_filters }, .{
ui.column(.{ .gap = 12 }, .{
ui.column(.{ .height = 34 }, .{}), // clears the engine-drawn title line
ui.checkbox(.{ .text = "Only unread", .checked = model.only_unread, .on_toggle = .toggle_unread }),
@@ -43,6 +43,10 @@ pub fn email(model: *const Model) []const u8 {
</CodeToggle>
## Editing keys
The built-in editor follows the platform keymap in `input`, `text-field`, `search-field`, and `combobox`: Backspace/Delete remove one caret unit, Option+Backspace/Delete on macOS (Ctrl+Backspace/Delete elsewhere) remove one word, and Command+Backspace on macOS (with or without Shift) deletes from the caret to the beginning of the field. A non-empty selection always wins and is deleted by itself. The semantic edit still arrives through `on-input`, so `applyTextInputEvent` and `TextBuffer` keep controlled fields synchronized and one undo restores the whole deletion.
## Search field
`search-field` renders the search affordance but binds exactly like an input; pair it with a model-filtered list. Whenever the field holds text it also shows a built-in clear affordance — a small x inside its trailing edge — and pressing it (or pressing Escape while focused) clears through the standard text-edit path, so the `on-input` handler receives the clear like any other edit and a model-owned buffer empties with it. No attribute enables or disables this; searchable fields simply carry it. For text entry that opens a menu of suggestions, see [combobox](/docs/components/combobox).
+14 -8
View File
@@ -4,17 +4,19 @@ import { CodeToggle } from "@/components/code-toggle";
# Radio
The single-choice value control, grouped by a `radio-group` row container. Like [checkbox](/docs/components/checkbox), the label rides the `text` attribute — radio is not a text-bearing element, so text content between the tags is rejected with a teaching error. One model field holds the group's selection: render it with `{a == b}` equalities on each radio's `checked`, and let each radio's `on-toggle` dispatch the Msg that sets the field — the engine never flips state on its own.
The single-choice value control, grouped by a `radio-group`. Give the group an accessible `label` that names the shared choice. Like [checkbox](/docs/components/checkbox), each radio takes its visible label as element content; the `text` attribute is the equivalent binding-friendly form. Descendant radios at any nesting depth form one logical group: one Tab stop, arrows wrap through the choices while Home/End move to the edges, focus and selection move together, and selecting one clears the rest. Bind the model's choice through `checked`; an actual selection transition dispatches `on-change` when bound, then falls back to `on-toggle` and `on-press` for compatibility. Activating the already-checked radio has no new `on-change` edge (a legacy fallback handler still receives the activation).
<ComponentPreview name="radio-group" alt="A radio group rendered by the engine" caption="a radio group with one selected and one disabled option" />
## Markup
```html
<radio-group gap="12">
<radio checked="{density == default}" on-toggle="set_default" text="Default" />
<radio checked="{density == comfortable}" on-toggle="set_comfortable" text="Comfortable" />
<radio checked="{density == compact}" disabled="true" text="Compact" />
<radio-group gap="12" label="Density">
<radio checked="{density == default}" on-change="set_default">Default</radio>
<row>
<radio checked="{density == comfortable}" on-change="set_comfortable">Comfortable</radio>
</row>
<radio checked="{density == compact}" disabled="true">Compact</radio>
</radio-group>
```
@@ -40,9 +42,11 @@ case "set_comfortable":
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically:
```zig
ui.el(.radio_group, .{ .gap = 12 }, .{
ui.el(.radio, .{ .text = "Default", .checked = model.density == .default, .on_toggle = .set_default }, .{}),
ui.el(.radio, .{ .text = "Comfortable", .checked = model.density == .comfortable, .on_toggle = .set_comfortable }, .{}),
ui.el(.radio_group, .{ .gap = 12, .semantics = .{ .label = "Density" } }, .{
ui.el(.radio, .{ .text = "Default", .checked = model.density == .default, .on_change = .set_default }, .{}),
ui.row(.{}, .{
ui.el(.radio, .{ .text = "Comfortable", .checked = model.density == .comfortable, .on_change = .set_comfortable }, .{}),
}),
ui.el(.radio, .{ .text = "Compact", .checked = model.density == .compact, .disabled = true }, .{}),
})
```
@@ -54,6 +58,8 @@ ui.el(.radio_group, .{ .gap = 12 }, .{
"text",
"checked",
"disabled",
"on-change",
"on-toggle",
"on-press",
]}
/>
+4 -4
View File
@@ -3,9 +3,9 @@ import { AttrTable } from "@/components/attr-table";
# Sheet
A bottom-anchored surface with the same contract as [dialog](/docs/components/dialog) and [drawer](/docs/components/drawer): rendered in place, title via the `text` attribute, visibility model-owned behind an `if`, and `on-dismiss` dispatched on Escape or click-outside so `update` clears the open flag. The sheet rises from the bottom edge; size it with `height`. The title chrome is engine-drawn and children stack over the full content box, so lead the body column with a fixed-height spacer.
A right-edge surface with the same contract as [dialog](/docs/components/dialog) and [drawer](/docs/components/drawer): it spans the root view height and pins to the root right, regardless of which container declares it. Visibility is model-owned behind an `if`, and `on-dismiss` dispatches on Escape or click-outside so `update` clears the open flag. Size the sheet with `width`. The title chrome is engine-drawn and children stack over the full content box, so lead the body column with a fixed-height spacer.
<ComponentPreview name="sheet" alt="A sheet surface rendered by the engine" caption="a bottom sheet with a title, body text, and an action row" />
<ComponentPreview name="sheet" alt="A sheet surface rendered by the engine" caption="a right-edge sheet with a title, body text, and an action row" />
## Markup
@@ -13,7 +13,7 @@ A bottom-anchored surface with the same contract as [dialog](/docs/components/di
<column padding="24">
<button variant="outline" icon="external-link" on-press="open_share">Share</button>
<if test="{share_open}">
<sheet text="Share" height="190" padding="24" on-dismiss="close_share">
<sheet text="Share" width="320" padding="24" on-dismiss="close_share">
<column gap="12">
<spacer height="34"></spacer>
<text wrap="true" foreground="text_muted">Anyone with the link can view this board.</text>
@@ -32,7 +32,7 @@ A bottom-anchored surface with the same contract as [dialog](/docs/components/di
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically:
```zig
ui.el(.sheet, .{ .text = "Share", .height = 190, .padding = 24, .on_dismiss = .close_share }, .{
ui.el(.sheet, .{ .text = "Share", .width = 320, .padding = 24, .on_dismiss = .close_share }, .{
ui.column(.{ .gap = 12 }, .{
ui.column(.{ .height = 34 }, .{}), // clears the engine-drawn title line
ui.text(.{ .wrap = true, .style_tokens = .{ .foreground = .text_muted } }, "Anyone with the link can view this board."),
@@ -5,6 +5,8 @@ import { AttrTable } from "@/components/attr-table";
Multi-line text entry. Like [input](/docs/components/input), `text` and `placeholder` bind from the model and `on-input` names a Msg variant that receives every edit as a text-input event — see [input](/docs/components/input) for the core-side contract in both languages. By default, Enter (and Shift+Enter) inserts a newline; when a textarea carries `on-submit`, submission rides Cmd+Enter on macOS or Ctrl+Enter elsewhere. Chat composers can set `submit-on-enter="true"`: plain Enter then submits, Shift+Enter still inserts a newline, and the primary chord still submits. Give it a definite `width` and `height` (or a `grow`) to size the editing box.
Textarea editing uses the same platform shortcuts as [input](/docs/components/input#editing-keys). On macOS, Command+Backspace (with or without Shift) deletes from the caret to the beginning of the current hard-newline-delimited line (or deletes the active selection); soft-wrapped visual lines use their logical line start in this first version.
<ComponentPreview name="textarea" alt="A textarea rendered by the engine" />
## Markup
+13 -3
View File
@@ -93,7 +93,7 @@ Prefer **omitting** `cachePath` for URL sources: when the app wiring configures
Exactly one event arm dispatches per load — a five-field record matched by field name: `id`, `state`, `width`, `height`, `status`. `id` echoes the requested ImageId, so two loads in flight at once share one arm and still tell their results apart (that is what the example's `cover: msg.id` reads); `status` is the HTTP status for url loads that performed an exchange, and **0 when none occurred** — local paths and cache hits — so a `loaded` with status 0 is honest signal that the pixels came without a network round trip, never a fabricated 200; `loaded` means the pixels are registered and drawable; everything else names what actually happened:
- **Source classes** — `not_found` (missing local file, no url), `io_failed` (a local read failure), `connect_failed` / `tls_failed` / `protocol_failed` / `timed_out` (the fetch taxonomy, on the fetch machinery's own timeout), `http_status` (a non-2xx answer, with the status carried through — an error page is not an image, so the body is discarded).
- **Decode and registry classes** — the same errors the direct registration API raises: `decode_failed`, `unsupported` (a host without a codec), `too_large`, `registry_full`, and `alloc_failed` (the host refused the memory the registration needed — resource exhaustion, not corrupt bytes: the same source may load once memory frees, so it is never reported as `decode_failed`).
- **Decode and registry classes** — `decode_failed`, `unsupported` (a host without a codec), `registry_full`, and `alloc_failed` (the host refused the memory the registration needed — resource exhaustion, not corrupt bytes: the same source may load once memory frees, so it is never reported as `decode_failed`). `too_large` normally means the encoded source exceeded 8 MiB; platform codecs fit decoded pixels to the app's declared target.
- **Discipline classes** — `rejected` (an invalid id, no source at all, or a duplicate live id: one load per id at a time, the spawn rule — a load in flight is never replaced implicitly) and `cancelled` (`Cmd.imageCancel(id)` ended the load).
`Cmd.imageCancel(id)` is the load's cancel — image loads are keyed by their numeric id, so the string-keyed `Cmd.cancel` never touches them. Cancel is **loud**, the spawn discipline: the one terminal still arrives, as the load's own event arm with state `cancelled`, and the id is free for a fresh load once it lands (a slow CDN fetch no longer pins its id against a retry). Aimed at an id with no live load it no-ops — whatever it targeted already delivered its terminal.
@@ -106,7 +106,17 @@ Unlike a load, unregister is **synchronous registry surgery, not an effect**: no
## Limits, honestly
Decode limits are the registered-image limits, fixed and loud: **16 slots** of **1 MiB decoded pixels** each (512×512 RGBA8 — avatar and cover-art scale, not photo scale), with `Cmd.imageUnregister` releasing a slot when the app is done with an image. The encoded source is bounded at **1.25 MiB** from every source alike, and over-bound sources fail whole with `too_large` — never a silently cropped decode. Loaded pixels live in the existing registered-image storage; there is no separate pool to size. The framework bundles no codecs: bytes decode through CGImageSource on macOS, gdk-pixbuf on GTK, WIC on Windows, and the mobile hosts' embed image service — a host without one answers `unsupported`, never silence.
The default is **16 slots** with a **1 MiB decoded-pixel target per slot**. That target is not a refusal: platform codecs decode photo-scale sources down, preserving aspect, until `width × height × 4` fits. A wide 1024×256 image already fits and stays that size; a 640×480 image registers at a smaller geometry, and the result's `width`/`height` report exactly what views draw. The encoded source has its own flat **8 MiB** bound; an over-bound source fails whole with `too_large`, never as truncated bytes.
Image-centric apps can raise the target in `app.zon`, up to the hard 8 MiB ceiling:
```zig
.images = .{ .max_image_pixel_bytes = 8_388_608 },
```
The value is frozen at startup. Pixel blocks and decode scratch allocate lazily, so declaring a raise costs nothing until an image is used; each used registry slot is a whole-budget block, however, so filling all 16 slots at 8 MiB is a declared **128 MiB** high-water. The source bound stays 8 MiB regardless of this setting.
This gives three clear tiers: decode-to-fit is the default for feeds, galleries, avatars, and covers; the manifest raise serves wallpaper and image-forward apps that want more display-scale detail; pixel-exact editing, source-resolution zoom, and gigapixel tiling belong in an app-owned `gpu_surface` or [media-surface producer](/docs/media-producers), not the registry. The framework still bundles no codecs: bytes decode through CGImageSource on Apple platforms, gdk-pixbuf on GTK, WIC on Windows, BitmapFactory on Android, and the embed image service — a host without one answers `unsupported`.
For textures **produced** by your own renderer at video rates — a decoder, a camera, mpv — this is the wrong tool: that is the [media surface](/docs/media-producers)'s dynamic texture channel. `imageLoad` is for images that exist as encoded bytes somewhere and should become long-lived registered pixels.
@@ -114,7 +124,7 @@ For textures **produced** by your own renderer at video rates — a decoder, a c
The loaded bytes **are** the effect result, and the session journal treats them that way. When a recorded session performs an image load, the encoded source bytes are written — at effect-result time — into a content-addressed blob store beside the journal (`blobs/<sha256[..16]>` in the session directory), and the journal record carries the hash and length. Loading the same bytes twice stores one blob: content addressing is deduplication — and the deduplicating probe verifies the existing blob's bytes before trusting its name, so a damaged blob is repaired in place on the next same-bytes recording rather than sealing a journal replay would refuse.
Replay reads the blob, re-runs the same decode and registration with the recorded bytes, and delivers the recorded result — **byte-identical and fully offline**: the original file, the network, and the cache are never consulted, and the fingerprint checkpoints verify the replayed session against the recording frame by frame. A journal whose `blobs/` directory is missing or damaged refuses loudly (the bytes are verified against their address) rather than replaying a different session. The blob record kind is journal format **v7** — older journals are refused at the preamble with the standard re-record teaching, the format's usual honest break.
Replay reads the blob, re-runs decode and registration with the recorded bytes, and delivers the recorded result — **byte-identical and fully offline**: the original file, the network, and the cache are never consulted, and the fingerprint checkpoints verify the replayed session against the recording frame by frame. If the current manifest lowers the image budget after recording, the recorded result dimensions still replay verbatim while best-effort presentation re-decodes to the current budget; any resulting screenshot difference is a verification mismatch, not false journal damage. A journal whose `blobs/` directory is missing or damaged refuses loudly (the bytes are verified against their address) rather than replaying a different session. The blob record kind is journal format **v7** — older journals are refused at the preamble with the standard re-record teaching, the format's usual honest break.
```sh
NATIVE_SDK_SESSION_RECORD=session/app.journal native run # records blobs/ beside the journal
+7
View File
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("files");
export default function FilesLayout({ children }: { children: React.ReactNode }) {
return children;
}
+70
View File
@@ -0,0 +1,70 @@
# Files and streaming
Raw file effects are the escape hatch for files the user owns: imports, exports, recordings, images, CSV, and other blobs. App state belongs in [Model Persistence](/docs/persistence), the [Record Store](/docs/record-store), or [Relational SQLite](/docs/sqlite); secrets belong in credentials.
Every operation is still an effect. `update` returns a `Cmd`, the host performs I/O after the model commits, and results return as later `Msg` values. Session recording journals those results; streamed chunks live in the content-addressed `blobs/` directory beside the journal so replay never opens the original file.
The blob directory is deduplicated but currently has no quota or automatic GC; see [Automation: Session recording and blob growth](/docs/automation#session-recording-and-blob-growth).
## Small whole-file operations
`Cmd.readFile` and `Cmd.writeFile` remain convenient for payloads up to 1 MiB. An over-bound write is rejected. An over-bound read returns `truncated` rather than passing cut bytes as a successful file. `Cmd.appendFile` appends one payload up to 1 MiB, `Cmd.statFile` reports `{ exists, size, mtimeMs }` without reading the content, and `Cmd.deleteFile` removes one file.
```ts
return [model, Cmd.statFile(path, {
ok: "file_stat",
err: "file_failed",
})];
return [model, Cmd.deleteFile(path, {
ok: "file_deleted",
err: "file_failed",
})];
```
Deletion is file-only and never recursive: it reports `not_found` when the path is absent and `io_failed` for a directory or another OS refusal. If the final path component is a symlink, deletion removes the link rather than its target. File outcomes are closed and machine-readable: `ok`, `not_found`, `io_failed`, `truncated`, `rejected`, `cancelled`, `sink_missing`, `out_of_order`, and `disk_full`.
## Streaming reads
`Cmd.readFileStream` delivers zero or more 256-KiB chunks, followed by exactly one `done` message carrying the total byte count, or one `err` message.
Read-stream keys follow ordinary file semantics: reissuing a live key silently replaces the old read, and `Cmd.cancel(key)` silently drops it. Stale chunks from the retired generation cannot reach the replacement route.
```ts
return [model, Cmd.readFileStream(path, {
key: "import",
chunk: "import_chunk",
done: "import_done",
err: "import_failed",
})];
```
Streaming has its own four-slot budget and no total-size ceiling. Each individual chunk remains bounded, so one large import cannot consume the sixteen general spawn/fetch/whole-file slots.
## Atomic streaming writes
Open a sink, send one acknowledged chunk at a time, then close it. Close syncs the temporary file and atomically replaces the destination; before a successful close, the old destination stays visible and teardown removes the temporary file.
```ts
Cmd.writeFileStream("export", path, { ok: "sink_open", err: "export_failed" });
Cmd.writeFileChunk("export", bytes, { ok: "chunk_written", err: "export_failed" });
Cmd.writeFileClose("export", { ok: "export_done", err: "export_failed" });
```
A duplicate live sink rejects. A chunk or close issued before the previous acknowledgment returns `out_of_order`; a chunk or close with no open sink returns `sink_missing`. Chunks may be at most 1 MiB.
Write sinks use the loud stream discipline: `Cmd.cancel(key)` ends the sink through `err: cancelled` and removes its temporary file. A half-written export is never silently replaced or installed.
## Filesystem permission
Raw paths inside this app's resolved `data`, `config`, `cache`, `state`, `logs`, and `temp` directories need no permission. Any path outside those roots requires:
```zig
.permissions = .{ "filesystem" },
```
The runtime is authoritative. Before checking, it resolves the target when it exists, or resolves the deepest existing parent and normalizes the missing suffix. Existing symlinks are followed for authorization, so a symlink inside an app directory that points outside is external and requires the permission. Deletion still unlinks the final symlink itself after that check. `..` cannot escape an allowed root.
`native check` also reports NS1074 for certainly-external literal paths, but dynamic paths are decided only by the runtime. File pickers therefore require the `filesystem` permission because the selected user file is normally outside app-owned directories.
Directory enumeration, file watching, chmod/permissions, and per-path grant prompts are not part of this API.
+1 -1
View File
@@ -14,7 +14,7 @@ The Native SDK can configure native app menus from `app.zon` or typed Zig data.
},
```
Generated runners load `app.zon` menus automatically. Pass `menus` to `runWithOptions` when an app needs to override the manifest at runtime:
Generated zero-config runners load `app.zon` menus automatically for both TypeScript cores and Zig cores. Ejected runners use the same fallback. Pass a non-null `menus` slice to `runWithOptions` when lower-level Zig wiring needs to override the manifest at runtime (an explicit empty slice disables manifest menus):
```zig
const view_items = [_]native_sdk.MenuItem{
+17 -9
View File
@@ -48,7 +48,7 @@ The runtime owns the rest: install, presentation, resize, typed event dispatch,
| `dropdown-menu` | menu surface | vertical; children are `menu-item`s. `anchor="below\|above"` floats it against its parent's frame: a late z-pass above the whole tree, window-clipped (never cropped by a scroll pane), auto-flipping at the window edges, zero flow space — pair with `on-dismiss` so Escape/click-outside close model-side |
| `accordion` | accordion | header via the `text` attribute; children show while `selected`, dispatch `on-toggle` |
| `alert`, `bubble` | surfaces | `alert` title via the `text` attribute; children stack inside. `bubble` hugs its message up to 80% of the thread (`ghost` exempt; explicit `width` wins) and takes one `<reactions>` child — the reaction pill straddling its bottom edge, one text run, dock via `text-alignment` (default `end`); `text=` on bubble itself is a teaching error (that channel belongs to the pill) |
| `dialog`, `drawer`, `sheet` | modal surfaces | rendered in place — title via `text`, wrap in `<if>` to show conditionally |
| `dialog`, `drawer`, `sheet` | modal surfaces | root-relative and unaffected by ancestor scroll or clipping: dialogs center in the viewport, drawers span its width and dock to the bottom, and sheets span its height and dock to the right — title via `text`, wrap in `<if>` to show conditionally |
| `resizable` | resizable panel | engine-managed drag handle; `width` sets the initial width |
| `split` | two-pane splitter | exactly two element children (nest splits for more panes); the engine synthesizes the draggable divider between them. `value` binds the model-owned first-pane fraction, `on-resize` names an f32 Msg variant dispatched with each applied fraction (echo it back through `value`), `min-width` on the panes bounds the drag, `gap` sets the divider band thickness; the focused divider takes Left/Right (Shift for bigger steps) and Home/End. `resize-duration` (milliseconds, split only) animates model-driven value moves — the runtime eases the rendered fraction to the new value one presented frame at a time instead of snapping, and reduced-motion appearances snap automatically; `resize-easing` (`linear`/`standard`/`emphasized`/`spring`) shapes the ramp and needs a nonzero duration beside it |
| `tree` | disclosure tree | vertical container whose descendant rows with `role="treeitem"` form one roving keyboard focus set: Up/Down walk visible rows (selection follows focus via `on-change` when bound, otherwise `on-press`), Left collapses or moves to the parent row, Right expands or moves to the first child row, Home/End jump to the edges, Enter/Space activate; nested rows derive hierarchy structurally, while flat `<for>` rows declare their one-based `tree-level`; expandable rows bind `expanded` and `on-toggle`, and the model owns both states |
@@ -248,7 +248,7 @@ pub fn draft(model: *const Model) []const u8 {
On a `list-item`, `on-submit` grows a second home beyond text entry: with a submit handler bound, plain Enter on a ring-focused row dispatches it as the row's PRIMARY action (open the record, play the track — the desktop list convention), while Space keeps the row's select activation (`on-press`). Rows without a submit handler resolve Enter exactly as before — both activation keys select. It is the keyboard mirror of `on-double-press`: bind both to the same Msg and pointer and keyboard users share one primary action (the soundboard's Zig track rows bind `on_press` select, `on_double_press` play, `on_submit` play). How Enter reaches the row at all — and why arrows may not — is [keyboard routing](#keyboard-routing-focus-registers-quiet-list-rows-and-the-app-level-fallback).
Presses follow one rule: a click lands on the nearest pressable widget under the pointer — plain text, icons, images, badges, and layout containers let it fall through to their closest pressable ancestor, and dragging still selects text. Any element with a bound `on-press`, `on-double-press`, or `on-toggle` is pressable (the handler makes it a hit target), so a pressable row is just `<panel on-press="open:{id}">` — or `<row on-press=...>` — with plain text children: no overlays, no duplicated handlers. Nested pressables resolve to the deepest one (a button inside a pressable row wins); editable text fields, scroll containers, and modal surfaces always claim their own presses. Value/text handlers (`on-change`, `on-submit`, `on-input`) still belong on controls only — the validator, both engines, and the LSP reject them on layout/decoration elements with a teaching error.
Presses follow one rule: a click lands on the nearest press-claiming widget under the pointer — plain text, icons, images, badges, and layout containers let it fall through to their closest interactive ancestor, and dragging still selects text. Any element with `on-press`, `on-double-press`, `on-toggle`, `on-hold`, or `on-drag` becomes a hit target and press claimer, so a row can bind `on-press` for clicks or `on-drag` for spatial dragging and child text still resolves to that row without overlays or duplicated handlers. Nested claimers resolve to the deepest one; editable text fields, scroll containers, and modal surfaces always claim their own presses. Value/text handlers (`on-change`, `on-submit`, `on-input`) still belong on controls only — the validator, both engines, and the LSP reject them on layout/decoration elements with a teaching error.
## Keyboard routing: focus registers, quiet list rows, and the app-level fallback
@@ -320,7 +320,7 @@ export function dropMsg(drop: FileDropEvent): Msg | null {
}
```
The platform event is journaled before either route, so record/replay delivers the identical source, point, and path bytes. A host that cannot resolve the target view leaves `viewLabel` empty and `point` null.
The platform event is journaled before either route, so record/replay delivers the identical source, point, and path bytes. The macOS system host reports labeled, view-local points for canvas and WebView drops; an unlabeled window region keeps an empty `viewLabel` and reports a window-content point. A host that cannot resolve either leaves `viewLabel` empty and `point` null.
## Native scrolling and context menus
@@ -328,7 +328,7 @@ On macOS, every non-virtualized `scroll` region is driven by an invisible `NSScr
Scroll regions pin at their content edges by default on every path — no rubber-band bounce; kinetic motion stops cleanly at the boundary. Bouncing is a per-region opt-in: `overscroll="rubber_band"` in markup or `ElementOptions.overscroll = .rubber_band` in Zig views (the native macOS scroller gets elastic edges, the engine physics overscroll under resistance and spring back). The `ScrollPhysics.overscroll` design token flips the app-wide default; per-region values override it, and `overscroll="none"` pins a region regardless of the token.
Right/ctrl-click presents a real OS context menu at the pointer: `NSMenu` on macOS, `TrackPopupMenu` on Windows, `GtkPopoverMenu` on Linux. Authors declare ONE menu and the platform decides presentation: hosts without a native menu presenter (the mobile toolkit hosts and embed hosts today) mount the same declared items as an anchored canvas surface at the click point automatically — never two authored menus, never a canvas imitation where the OS menu exists. In markup, the menu is a `<context-menu>` child of the pressable element it answers, holding `menu-item`s (`on-press` required, `disabled` optional) and `separator`s, with `if`/`else`/`for` to swap or repeat items:
Right/ctrl-click presents a real OS context menu at the pointer: `NSMenu` on macOS, `TrackPopupMenu` on Windows, `GtkPopoverMenu` on Linux. Authors declare ONE menu and the platform decides presentation: hosts without a native menu presenter (the mobile toolkit hosts and embed hosts today) mount the same declared items as an anchored canvas surface at the click point automatically — never two authored menus, never a canvas imitation where the OS menu exists. In markup, the menu is a `<context-menu>` child of the interactive element it answers — a hit-target kind or an element made interactive by `on-press`, `on-double-press`, `on-toggle`, `on-hold`, or `on-drag` — holding `menu-item`s (`on-press` required, `disabled` optional) and `separator`s, with `if`/`else`/`for` to swap or repeat items:
```html
<list-item on-press="open:{entry.id}" label="{entry.title}">
@@ -437,7 +437,7 @@ case "fetched":
Response bodies are binary-safe and bounded (256 KiB; longer arrives cut with `truncated = true`), the whole exchange honors a per-fetch timeout (default 30 s), and cancelling a fetch delivers exactly one `cancelled` Msg with nothing after it.
Files ride the same channel: `fx.writeFile` / `fx.readFile` persist app state — session snapshots, transcripts — without smuggling an `Io` handle from `main` into `update`. Bounded (1 KiB paths, 1 MiB files), key-based, one terminal Msg per operation with an explicit outcome (`ok`, `not_found`, `io_failed`, `truncated` — an over-bound read's own outcome, so a cut JSON snapshot cannot parse as whole — `rejected`, `cancelled`); writes create missing parent directories and replace the file whole:
Files ride the same channel without smuggling an `Io` handle from `main` into `update`. Whole-file reads and writes remain bounded at 1 MiB; `appendFile`, `statFile`, and `deleteFile` handle logs and file lifecycle, while the dedicated streaming family carries large imports and atomic exports. External paths require the `filesystem` permission; app-owned directories are exempt after symlink-safe normalization. See [Files & Streaming](/docs/files).
```zig
.save => fx.writeFile(.{
@@ -449,24 +449,30 @@ Files ride the same channel: `fx.writeFile` / `fx.readFile` persist app state
.saved => |result| model.noteSaved(result.outcome),
```
Desktop notifications use the same bounded platform-services facade but are intentionally fire-and-forget: once the host accepts a request, the OS can still suppress it through Focus / Do Not Disturb or the user's notification settings, so a success Msg would over-promise. Return `Cmd.showNotification` from a TypeScript core or call `fx.showNotification` from a Zig `update_fx` arm; invalid or over-bound fields and unavailable services fail closed. The fake executor and session replay never display one.
Desktop notifications use the same bounded platform-services facade but are intentionally fire-and-forget: once the host accepts a request, the OS can still suppress it through Focus / Do Not Disturb or the user's notification settings, so a success Msg would over-promise. A nonempty `id` replaces an earlier notification with that id. Paired `actionLabel` / `actionCommand` fields add an action whose activation returns through the ordinary application command path while the process is running. Return `Cmd.showNotification` from a TypeScript core or call `fx.showNotification` from a Zig `update_fx` arm; invalid or over-bound fields, unpaired action fields, invalid command names, and unavailable services fail closed. The fake executor and session replay never display one; the null test platform records and can activate one deterministically.
<CodeToggle>
```ts
case "build_finished":
return [model, Cmd.showNotification({
id: asciiBytes("build-status"),
title: asciiBytes("Build finished"),
subtitle: asciiBytes("native-sdk"),
body: asciiBytes("All checks passed."),
actionLabel: asciiBytes("Open results"),
actionCommand: asciiBytes("build.open-results"),
})];
```
```zig
.build_finished => fx.showNotification(.{
.id = "build-status",
.title = "Build finished",
.subtitle = "native-sdk",
.body = "All checks passed.",
.action_label = "Open results",
.action_command = "build.open-results",
}),
```
@@ -478,7 +484,9 @@ For timestamps, the facade owns the clocks (Zig 0.16 puts `std.time` behind `std
## Secondary windows
Windows are model-declared, exactly like the tray: `Options.windows_fn` returns the window descriptors that should exist right now (presence IS visibility — there is no `visible` flag because the platform window channel is create/focus/close with no hide), and `Options.window_view` builds each declared window's whole canvas tree, keyed by the descriptor's window label. After every dispatched Msg the runtime reconciles: windows the model started declaring are created (a source-less native window wearing one `gpu_surface` view with the descriptor's `canvas_label`, inheriting the main canvas's gpu options), windows it stopped declaring close, and every open window's view rebuilds from the same model — a theme picked in the settings window restyles the main window on the same dispatch.
In a default TypeScript app, export `windows(model): readonly WindowDescriptor[]` and put each possible window's markup at `src/windows/<label>.native`; the generated launcher compiles those files, maps descriptor labels to them, and includes shared components they import from nested paths under `src/windows/`. Construct descriptors with `windowDescriptor`. `closePolicy: "quit"` routes `onCloseCommand` through `commandMsg`; `"hide"` retains the window and dispatches no close command. `titlebar` includes `"chromeless"` for fully skinned windows. `examples/system-monitor-ts` is the complete default-path reference.
In Zig cores and custom wiring, windows are model-declared exactly like the tray: `Options.windows_fn` returns the window descriptors that should exist right now (presence IS liveness), and `Options.window_view` builds each declared window's whole canvas tree, keyed by the descriptor's window label. After every dispatched Msg the runtime reconciles: windows the model started declaring are created (a source-less native window wearing one `gpu_surface` view with the descriptor's `canvas_label`, inheriting the main canvas's gpu options), windows it stopped declaring close, and every open window's view rebuilds from the same model — a theme picked in the settings window restyles the main window on the same dispatch. There is no `visible` flag: transient visibility is host state changed through `hideWindow`/`showWindow` or a `.hide` close policy; stop declaring a window to really close it and release its retained views.
```zig
fn windows(model: *const Model, scratch: *App.WindowsScratch) []const App.WindowDescriptor {
@@ -507,7 +515,7 @@ fn windowView(ui: *App.Ui, model: *const Model, window_label: []const u8) App.Ui
// options: .windows_fn = windows, .window_view = windowView,
```
Input from any window dispatches Msgs through that window's own tree with its window identity, and a USER close (the titlebar close button) dispatches the descriptor's `on_close` Msg — the dismissal precedent applied to windows: the window is already gone as an optimistic echo, the model clears its open flag in `update` (or keeps declaring the window and the next rebuild brings it back — source wins). A close the model itself initiated (it stopped declaring the window) never echoes a Msg. At most `UiApp.max_ui_windows` (4) secondary windows may be declared; the excess warns and is ignored. `min_width`/`min_height` declare a content min-size floor the window itself enforces (macOS `contentMinSize`): the user's resize stops at the floor instead of the layout clamping and clipping panes below their declared minimums — the same fields exist on app.zon windows and `ShellWindow`, and the startup window applies its declaration at the host create like `titlebar`. 0 (the default) leaves the axis at the platform's own minimum.
Input from any window dispatches Msgs through that window's own tree with its window identity. `WindowDescriptor.close_policy` accepts `.quit` (the default) or `.hide`, with the same behavior and platform safeguards as manifest windows. A USER close under `.quit` dispatches the descriptor's `on_close` Msg — the dismissal precedent applied to windows: the window is already gone as an optimistic echo, the model clears its open flag in `update` (or keeps declaring the window and the next rebuild brings it back — source wins). Under `.hide`, the window stays alive with its views, `on_close` does not fire, and `showWindow(label)` brings it back. A close the model itself initiated (it stopped declaring the window) never echoes a Msg. At most `UiApp.max_ui_windows` (4) secondary windows may be declared; the excess warns and is ignored. `min_width`/`min_height` declare a content min-size floor the window itself enforces (macOS `contentMinSize`): the user's resize stops at the floor instead of the layout clamping and clipping panes below their declared minimums — the same fields exist on app.zon windows and `ShellWindow`, and the startup window applies its declaration at the host create like `titlebar`. 0 (the default) leaves the axis at the platform's own minimum.
Markup deliberately binds ONE window's content: there is no `window` element in the closed grammar, because windows are app-shell concerns, not view-tree concerns. A markup-authored secondary window is a `canvas.CompiledMarkupView` whose `build` the `window_view` fn calls for the matching label.
@@ -567,7 +575,7 @@ ui.image(.{ .image = model.chart_image, .width = 120, .height = 80, .semantics =
`fx.registerImage(id, width, height, rgba8)` registers already-decoded pixels (the runtime copies them; your buffer is free on return), `fx.registerImageBytes(id, bytes)` decodes through the platform codec first, and `fx.unregisterImage(id)` frees the slot. Re-registering an id replaces its pixels and every view repaints — GPU caches re-upload off the changed content fingerprint, no invalidation calls. For caches, mint fresh ids (effect-key style, monotonically increasing) and unregister the evictee — never re-key different content onto a live id. Outside `UiApp`, the same registry is `Runtime.registerCanvasImage`/`registerCanvasImageBytes`/`unregisterCanvasImage`.
Capacities are fixed and loud (`canvas_limits`): `max_registered_canvas_images` slots (16) of `max_registered_canvas_image_pixel_bytes` each (1 MiB — 512×512 RGBA8, avatar/icon scale), with `error.ImageRegistryFull`, `error.ImageTooLarge`, `error.ImageDecodeFailed`, and `error.UnsupportedService` (a platform without a codec) never silent. Registered images render everywhere the canvas does: live presentation (GPU packet and software paths), `renderCanvasScreenshot`, and automation screenshots. A draw referencing an id that is not (or no longer) registered skips — a pure view cannot fail presentation with a transient loading state. In tests, the null platform's `image_decode` flag enables a deterministic decoder for the strict PNG subset `canvas.png.writeRgba8` emits, so raw RGBA fixtures exercise the full decode→register→draw path without bundling a codec.
Capacities are fixed and loud (`canvas_limits`): 16 slots with a 1 MiB decoded-pixel target by default. Encoded photos decode aspect-preservingly to fit, while raw `fx.registerImage` pixels remain strict. Image-centric apps may raise the startup-frozen target through app.zon `.images.max_image_pixel_bytes`, up to 8 MiB; storage is lazy per used slot, but filling all 16 ceiling-sized slots is a declared 128 MiB high-water. Every encoded entry point, including direct `fx.registerImageBytes`, shares the flat 8 MiB source bound. `error.ImageRegistryFull`, `error.ImageTooLarge` (encoded source, raw pixels, or a codec-contract violation), `error.ImageDecodeFailed`, and `error.UnsupportedService` are never silent. Registered images render everywhere the canvas does: live presentation, screenshots, and automation. A missing id simply draws its fallback. In tests, the null platform's deterministic strict-PNG decoder also pins exact decode-to-fit dimensions and pixels.
## Rich text: inline spans and markdown
+4 -2
View File
@@ -332,12 +332,14 @@ native validate app.zon
In addition to `native package --target <platform>`, the CLI provides shortcut commands:
```bash
native package-windows [--output path] [--binary path]
native package-linux [--output path] [--binary path]
native package-windows [--output path] [--binary path] [--service-binary path]
native package-linux [--output path] [--binary path] [--service-binary path]
native package-ios [--output path] [--binary path]
native package-android [--output path] [--binary path]
```
The desktop shortcuts use an explicit `--service-binary` when supplied; otherwise, service-bearing projects discover the normal `zig-out/bin/<app>_services[.exe]` build output just like `native package`.
## Platform targets
<table>
+2 -2
View File
@@ -61,7 +61,7 @@ The restore result crosses the ordinary effect journal boundary. Recording store
## Schema versions and migration
Increase `.persist.version` whenever the `Model` shape changes, and never reuse a version. `native check` verifies that the configured `ok` and `none` routes name void `Msg` arms and `err` names a one-`Uint8Array`-field arm; `native dev --core` runs the same fence before starting its virtual host. Check also remembers the last accepted version/fingerprint pair under `.native/cache` and reports NS1065 when the shape changes without a bump or the version moves backward. The snapshot header carries that app version, the generated model-only shape fingerprint, and the compiler's snapshot-format version, so a cold checkout still fails closed at runtime: a same-version shape mismatch reports `corrupt`, and a snapshot from a future app version reports `version_unknown`. That older binary also refuses subsequent writes with `version_unknown`, preserving the newer snapshot across a rollback.
Increase `.persist.version` whenever the `Model` shape changes, and never reuse a version. `native check` verifies that the configured `ok` and `none` routes name void `Msg` arms and `err` names a one-`Uint8Array`-field arm; `native dev --core` runs the same fence before starting its virtual host. Check also remembers the last accepted version/fingerprint pair under `.native/cache` and reports NS1068 when the shape changes without a bump or the version moves backward. The snapshot header carries that app version, the generated model-only shape fingerprint, and the compiler's snapshot-format version, so a cold checkout still fails closed at runtime: a same-version shape mismatch reports `corrupt`, and a snapshot from a future app version reports `version_unknown`. That older binary also refuses subsequent writes with `version_unknown`, preserving the newer snapshot across a rollback.
To accept an older version, export the pure migration hook from `src/core.ts`:
@@ -75,4 +75,4 @@ export function migrate(snapshot: Uint8Array, fromVersion: number): Model {
The checker requires the exact `(Uint8Array, number) => Model` shape. A successful migration is encoded in the current format, installed as the new snapshot, restored before delivery, and journaled as an ordinary successful restore. A thrown value, missing hook, or invalid result reports `migrate_failed`.
Version 1 persists the whole model. Do not put tokens or passwords in it; credentials belong in an OS keychain-backed effect. Raw `Cmd.readFile` and `Cmd.writeFile` remain the escape hatch for user-visible files, exports, and blobs—not the default model store.
Version 1 persists the whole model. Do not put tokens or passwords in it; declare the `credentials` capability and permission, store them with [`Cmd.credentials.set`](/docs/capabilities), and consume get-result bytes without retaining them in Model. Credential recording is redacted and replay receives only a deterministic same-length placeholder. Raw `Cmd.readFile` and `Cmd.writeFile` remain the escape hatch for user-visible files, exports, and blobs—not the default model store.
@@ -78,6 +78,14 @@ macOS, Linux, and Windows run full desktop apps through their own platform hosts
<td><Tier tier="none" /></td>
<td><Tier tier="none" /></td>
</tr>
<tr>
<td>System notifications</td>
<td><Tier tier="full" note="same-id replacement and a native action button; actions dispatch while the app is running" fn={11} /></td>
<td><Tier tier="caveats" note="one in-flight legacy balloon, so every new notification replaces the current one; there is no custom button, and clicking the notification invokes its labeled action" fn={11} /></td>
<td><Tier tier="full" note="same-id replacement and a native action button; actions dispatch while the app is running" fn={11} /></td>
<td><Tier tier="none" /></td>
<td><Tier tier="none" /></td>
</tr>
<tr>
<td>Menu-bar app lifecycle (<code>close_policy = "hide"</code>)</td>
<td><Tier tier="full" note="close hides, the Dock reopen re-shows, tray Open/Quit drive showWindow/quitApp" /></td>
@@ -131,6 +139,7 @@ macOS, Linux, and Windows run full desktop apps through their own platform hosts
8. `native package` targets all five platforms: macOS gets a `.app` bundle plus a styled drag-to-Applications DMG with `--archive`, Linux an install tree, Windows a distributable directory with a per-user file-type registration script, iOS a complete generated Xcode project — toolkit host sources, Info.plist, asset catalog, shared scheme, and the device-slice embed library — that `xcodebuild archive` builds with zero edits (code signing stays a manual step, like notarization), and Android a complete generated host project whose debug APK assembles with zero edits, directly with the SDK's build tools (store signing keys stay a manual step). Every platform's icons generate from one square source image. See [Packaging](/docs/packaging).
9. macOS signing supports `adhoc` and `identity` modes with entitlements; notarization is submitted manually with the platform tools after packaging. No signing tooling exists yet for the other platforms. See [Code Signing](/docs/packaging/signing).
10. The automation server is a file-based protocol the runtime serves on every desktop platform: snapshots, assertions, synthetic input, screenshots, record/replay. Engine screenshots render through the deterministic CPU reference renderer on every platform, so they are byte-comparable across hosts. Mobile exposes accessibility snapshots and actions through the embed ABI; the iOS and Android toolkit hosts serve the same file-based protocol inside the app's data container when launched with automation enabled.
11. Desktop notification identifiers are app-local replacement keys. macOS and Linux expose the requested action as a native button. The current Windows host uses the in-box `Shell_NotifyIcon` balloon surface, which has no app-defined action button; it includes the label in the notification and treats a notification click as activation. All three deliver action commands only to the process that issued the notification. Linux notification shells may D-Bus-activate an exited app when a persisted notification is clicked, but its process-scoped action token is stale and cannot dispatch a command into the new runtime; macOS and Windows cold-start command activation is not wired.
## Desktop Hosts
+1 -1
View File
@@ -7,7 +7,7 @@ Native SDK is the complete toolkit for building beautiful native desktop applica
## Prerequisites
- macOS 11 or newer, Linux, or Windows
- Node.js 22.15+ (on the 23 line: 23.5+) for the default TypeScript scaffold — the TypeScript frontend (the checker) and the core dev loop run under it at build and dev time, and the external core compiler that builds the checked core to native code ships as an exact-pinned dependency of the CLI; the binary you ship carries no JS runtime. A Zig-core app (`--template zig-core`) does not need node.
- Node.js 22.15+ (on the 23 line: 23.5+) for the default TypeScript scaffold — the TypeScript frontend (the checker) and the core dev loop run under it at build and dev time, and the external core compiler that builds the checked core to native code ships as an exact-pinned dependency of the CLI; the binary you ship carries no JS runtime. A Zig-core app (`--template zig-core`) needs Node only when it declares relational SQLite, whose schema checker and migration generator run at build time.
## Get the CLI
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("record-store");
export default function RecordStoreLayout({ children }: { children: React.ReactNode }) {
return children;
}
+127
View File
@@ -0,0 +1,127 @@
# Record Store
The record store persists independent byte records without giving `update` a database handle or making the app own a file format. It is the right fit for caches, message history, and document-sized values that grow or change one record at a time. Declare the build capability in `app.zon`:
```zig:app.zon
.capabilities = .{ "store" },
```
`"store"` links the shared SQLite engine and opens one engine-owned `store.db` in the app-data directory. Apps name keys, never paths or SQL. The common desktop runner and the iOS and Android hosts install that data directory before the first app effect. Apps without either storage capability do not link SQLite, and `native check` warns when the `"store"` declaration and `Cmd.store.*` calls disagree. The relational tier's `"sqlite"` capability selects the same engine object, so an app declaring both still links SQLite once.
## A saved draft
Every operation is a command. The committed model changes first; the result returns later as an ordinary `Msg`.
```ts:src/core.ts
import { Cmd } from "@native-sdk/core";
export interface Model {
readonly draft: Uint8Array;
readonly loaded: boolean;
}
export type Msg =
| { readonly kind: "load" }
| { readonly kind: "loaded"; readonly result: Uint8Array }
| { readonly kind: "edited"; readonly draft: Uint8Array }
| { readonly kind: "saved" }
| { readonly kind: "store_failed"; readonly reason: Uint8Array };
export const viewUnbound = ["loaded", "saved", "store_failed"] as const;
export function initialModel(): Model {
return { draft: new Uint8Array(0), loaded: false };
}
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
switch (msg.kind) {
case "load":
return [model, Cmd.store.get("draft/current", {
key: "load-draft", ok: "loaded", err: "store_failed",
})];
case "loaded":
// A get result starts with 1 for a hit and 0 for a miss. The value
// follows the hit byte, so an empty value is distinct from absence.
return msg.result[0] === 1
? { draft: msg.result.subarray(1), loaded: true }
: { ...model, loaded: true };
case "edited":
return [{ ...model, draft: msg.draft }, Cmd.store.set(
"draft/current",
msg.draft,
{ key: "save-draft", ok: "saved", err: "store_failed" },
)];
case "saved":
case "store_failed":
return model;
}
}
```
Reissuing the same route `key` replaces the older in-flight operation, and `Cmd.cancel(key)` cancels it silently. Distinct commands issued in one commit are performed in command-stream order: a synchronous read waits for writes that precede it. A get in a later commit also observes an earlier successful set.
## Zig-core parity
Zig cores use the same runtime-owned database and result envelope. The common app runner installs the binding before the app's first effect; no Zig entry point resolves a path or opens SQLite.
```zig
const Msg = union(enum) {
store_result: native_sdk.EffectHostResult,
};
const Effects = native_sdk.Effects(Msg);
fn loadDraft(fx: *Effects) void {
fx.storeGet(.{
.key = 1,
.record_key = "draft/current",
.on_result = Effects.hostMsg(.store_result),
});
}
```
`Effects.storeSet`, `storeGet`, `storeDelete`, `storeScan`, and `storeSetMany` mirror the TypeScript operations. Their `EffectHostResult` carries `key`, `ok`, and `bytes`; get and scan use exactly the framing described below.
## Operations and bounds
<table>
<thead>
<tr>
<th>Command</th>
<th>Behavior</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Cmd.store.set(key, bytes, route)</code></td>
<td>Insert or replace one value. Keys are non-empty UTF-8 up to 512 bytes; values are at most 1 MiB.</td>
</tr>
<tr>
<td><code>Cmd.store.get(key, route)</code></td>
<td>Return <code>[1][value...]</code> for a hit or <code>[0]</code> for a miss through the ok arm.</td>
</tr>
<tr>
<td><code>Cmd.store.delete(key, route)</code></td>
<td>Delete one value. A missing key succeeds.</td>
</tr>
<tr>
<td><code>Cmd.store.scan(prefix, options, route)</code></td>
<td>Return a byte-lexicographic prefix page. <code>limit</code> defaults to 100 and is capped at 256; pass the returned next-key bytes as <code>after</code> (a known literal key may be passed as a string).</td>
</tr>
<tr>
<td><code>Cmd.store.setMany(entries, route)</code></td>
<td>Insert or replace 164 records atomically, with an 8 MiB encoded batch bound.</td>
</tr>
</tbody>
</table>
A scan page is little-endian framed bytes: `count u32`, then `count` repetitions of `key_length u32`, key bytes, `value_length u32`, value bytes, followed by `next_length u32` and the next-key bytes. An empty next key ends pagination. Pages stop at record boundaries; records are never truncated.
All error arms receive one closed reason as UTF-8 bytes: `io_failed`, `over_bound`, `bad_key`, `rejected`, or `busy`. Cache misses use the get ok arm because absence is an expected lookup result. `setMany` validates the entire batch before its transaction, so an invalid entry changes nothing.
## Replay and the virtual host
Store results use the ordinary effect journal. Session replay feeds the recorded result and never opens the live database. Zig full-loop tests opt into one hermetic SQLite database per harness with `TestHarness().createWithRecordStore(allocator, surface)`; it is bound before `harness.start(app)` and closed by `harness.destroy(allocator)`. `native dev --core` performs the same API against a process-local map that survives its simulated `{"restart": true}` command.
Use [Model Persistence](/docs/persistence) when the whole in-memory model is the unit you save. Use the record store when records grow independently. Use raw file effects only for user-visible files, exports, or blobs larger than the record bound; relational queries and secondary indexes belong in the SQL tier rather than this API.
The repository's [record-store example](https://github.com/vercel-labs/native/tree/main/examples/record-store) exercises all five commands from a TypeScript core and Native markup view.
+2 -2
View File
@@ -39,7 +39,7 @@ Every app declares `permissions` and `capabilities` in `app.zon` — the runtime
</tr>
<tr>
<td><code>filesystem</code></td>
<td>File system access from bridge commands</td>
<td>Raw file effects and bridge commands outside this app's resolved data/config/cache/state/logs/temp roots; symlinks are resolved before the runtime check</td>
</tr>
<tr>
<td><code>clipboard</code></td>
@@ -199,7 +199,7 @@ The Native SDK provides built-in commands for app command routing (`native-sdk.c
`js_window_api` exposes the JavaScript command, window, view, WebView, and platform support helpers, but it does not bypass security. Command routing (`native-sdk.command.invoke`) must come from an allowed origin and have the `command` permission when runtime permissions are configured. Generic native view commands (`native-sdk.view.create`, `list`, `update`, `setFrame`, `setVisible`, `focus`, `focusNext`, `focusPrevious`, `close`) require the `view` permission. Platform support queries, window commands (`native-sdk.window.list`, `create`, `focus`, `close`), and WebView commands (`native-sdk.webview.create`, `list`, `setFrame`, `navigate`, `setZoom`, `setLayer`, `close`) require the `window` permission. The legacy `window` grant is still accepted for command and view helpers for compatibility. View and WebView commands can only target the window that sent the bridge message. WebView URLs must also be allowed by `security.navigation.allowed_origins`, and child WebViews receive `window.zero` only when created with `bridge: true`.
For broader control, use an explicit `builtin_bridge` policy. When you choose this path, list every built-in command your app calls. Dialog commands (`native-sdk.dialog.openFile`, `saveFile`, `showMessage`), OS commands (`native-sdk.os.openUrl`, `showNotification`, `revealPath`, `addRecentDocument`, `clearRecentDocuments`), clipboard commands (`native-sdk.clipboard.readText`, `writeText`, `read`, `write`), and credential commands (`native-sdk.credentials.set`, `get`, `delete`) are **always default-deny** and require an explicit `builtin_bridge` policy with the command listed. `native-sdk.os.openUrl` also requires `security.navigation.external_links` to allow the target URL:
For broader control, use an explicit `builtin_bridge` policy. When you choose this path, list every built-in command your app calls. Dialog commands (`native-sdk.dialog.openFile`, `saveFile`, `showMessage`), OS commands (`native-sdk.os.openUrl`, `showNotification`, `revealPath`, `addRecentDocument`, `clearRecentDocuments`), clipboard commands (`native-sdk.clipboard.readText`, `writeText`, `read`, `write`), and credential commands (`native-sdk.credentials.set`, `get`, `delete`) are **always default-deny** and require an explicit `builtin_bridge` policy with the command listed. `native-sdk.os.openUrl` also requires `security.navigation.external_links` to allow the target URL. A `showNotification` request with `actionLabel` / `actionCommand` additionally requires `native-sdk.command.invoke` and its `command` permission for the same origin:
```zig
.builtin_bridge = .{
+7
View File
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("sqlite");
export default function SqliteLayout({ children }: { children: React.ReactNode }) {
return children;
}
+123
View File
@@ -0,0 +1,123 @@
# Relational SQLite
The relational tier gives a model core an engine-owned SQLite database while keeping `update` pure. Add the capability in `app.zon`:
```zig:app.zon
.capabilities = .{ "sqlite" },
```
The runner opens `app.db` in the app-data directory, enables WAL and foreign keys, applies pending migrations, and keeps the path private. Apps that declare neither `"sqlite"` nor `"store"` do not link SQLite. The record store uses a separate `store.db`.
## Make the schema append-only
Migration files under `src/schema/` are the source of truth. Names are contiguous and monotonic:
```text
src/schema/0001_init.sql
src/schema/0002_add_tags.sql
```
Create the next file with:
```sh
native db new-migration add-tags
```
Use `STRICT` for ordinary generated-query tables so SQLite's storage classes agree with generated TypeScript types:
```sql:src/schema/0001_init.sql
CREATE TABLE folder (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE
) STRICT;
CREATE TABLE note (
id INTEGER PRIMARY KEY,
folder_id INTEGER NOT NULL REFERENCES folder(id),
title TEXT NOT NULL
) STRICT;
```
`native check` applies the complete chain to a real in-memory SQLite database. It rejects gaps, edited published migrations, invalid SQLite, non-STRICT tables, and a schema change that is not represented by a new version. The accepted hashes are written to `src/schema/migrations.lock.json`; commit that file with the migrations so append-only validation has the same authority in every checkout and in CI. At launch the runtime compares the chain with `PRAGMA user_version` and applies all pending files in one transaction. A migration failure refuses the database open; a database newer than the binary is `version_unknown` and is never repaired or downgraded automatically.
`native db status` compares source and installed versions. `native db reset --yes` deletes the development `app.db`, WAL, and shared-memory files; the next launch reapplies the chain. Reset never accepts an arbitrary path.
## Declare checked queries
Put named statements in `src/queries.sql`:
```sql:src/queries.sql
-- name: notesInFolder :live
SELECT n.id, n.title
FROM note AS n
WHERE n.folder_id = :folder
ORDER BY n.id DESC;
-- name: moveNote :exec
UPDATE note SET folder_id = :to WHERE id = :id;
```
`native check` asks real SQLite to prepare every statement against the migrated schema. Missing tables or columns, invalid SQL, wrong read/write declarations, parameter mistakes, and invalid result shapes are reported at the `.sql` source with NS14xx diagnostics.
The accepted schema generates a flat API in `@native-sdk/core`:
- `Cmd.qNotesInFolder(params, route)` returns typed row pages.
- `Cmd.qMoveNote(params)` returns a typed transaction member.
- `Cmd.qTx([statement, ...], route)` commits all generated `:exec` members atomically.
- `Sub.qNotesInFolder(key, params, route)` exists because the query is marked `:live`.
- `NotesInFolderRow`, `NotesInFolderParams`, and `decodeNotesInFolderPage(bytes)` describe and decode its result.
The `q<Name>` spelling is intentionally flat: it stays inside the ahead-of-time core subset while retaining one-to-one names from `queries.sql`.
```ts:src/core.ts
import {
Cmd,
Sub,
decodeNotesInFolderPage,
utf8Bytes,
} from "@native-sdk/core";
export type Msg =
| { readonly kind: "move" }
| { readonly kind: "rows"; readonly page: Uint8Array }
| { readonly kind: "rows_done" }
| { readonly kind: "wrote" }
| { readonly kind: "db_failed"; readonly reason: Uint8Array };
// In update:
return [model, Cmd.qTx([
Cmd.qMoveNote({ id: 7, to: 2 }),
], { key: "move-note", ok: "wrote", err: "db_failed" })];
// In the rows arm:
const rows = decodeNotesInFolderPage(msg.page);
// In subscriptions(model):
return Sub.qNotesInFolder("folder-notes", { folder: model.folderId }, {
page: "rows",
done: "rows_done",
err: "db_failed",
});
```
SQLite `INTEGER` and `REAL` map to `number`; generated decoders reject an `INTEGER` outside JavaScript's exact ±(2^531) range. `TEXT` and `BLOB` map to `Uint8Array`, matching byte-honest Model storage. Generated parameters inferred from a TEXT column wrap bytes automatically. Use `dbText(bytes)` when a raw query—or a parameter whose storage class cannot be inferred, such as an FTS `MATCH` term—must bind bytes as SQLite TEXT rather than BLOB. Nullability comes from the schema.
## Live queries
A `:live` query runs when subscribed and runs again after a committed transaction touches one of its generated table dependencies. The runtime combines SQLite authorizer write targets with row-update notifications until commit, so `WITHOUT ROWID` tables and truncate-optimized deletes invalidate reliably; it coalesces invalidations once per command frame and never reruns an unrelated subscription. FTS5 shadow tables are included automatically. A `:live` declaration with no table dependency is rejected because it could never refresh.
Each delivery uses the same bounded page route as a one-shot query: zero or more `page` messages followed by `done`. Keep temporary pages in the Model and replace the visible result set on `done`. Changing a subscription's key, parameters, routes, SQL, or dependencies re-arms it; omitting the key cancels it. Dependencies are table-level in this release.
Every page and terminal crosses the session journal. Pages over 64 KiB spill into the journal's content-addressed blob store. Replay never opens SQLite: recorded one-shot and live results are fed back as ordinary Msg values, including repeated live deliveries.
## Raw escape hatch and bounds
`Cmd.db.query(sql, params, route)` and `Cmd.db.exec(statements, route)` remain available. A raw query is read-only and returns pages; one raw exec commits its entire 164 statement array as one transaction. `native check` warns when a raw query literal could instead be declared and checked.
Parameters accept `null`, finite `number`, literal `string`, `Uint8Array`, `dbText(bytes)`, and `boolean` (integer 0/1). A query accepts at most 64 parameters and 1 MiB of parameter bytes. A transaction accepts at most 8 MiB. SQL is capped at 64 KiB per statement. Results page at 256 rows or 256 KiB and never truncate a row; one result is capped at 8,192 rows or 8 MiB and rejects whole when it crosses either bound. Add `LIMIT` and keyset pagination for larger collections.
The binary page header is `column_count u32`, `row_count u32`, then length-prefixed UTF-8 column names. Row-major values use tags `0` NULL, `1` + signed little-endian i64, `2` + little-endian f64, `3` + length-prefixed TEXT, and `4` + length-prefixed BLOB.
The database boundary stays pathless. SQLite's authorizer denies `ATTACH`, `DETACH`, `VACUUM INTO`, and writes to engine-owned lifecycle PRAGMAs. Outcomes are closed: `constraint`, `busy`, `io_failed`, `corrupt`, `misuse`, `rejected`, and `cancelled`. Query keys replace; duplicate transaction keys reject loudly so a write is never silently lost.
Zig cores have first-class `Effects(Msg).dbQuery`, `dbExec`, `dbSubscribe`, and `dbUnsubscribe` operations over the same runtime. `native test` and `TestHarness().createWithRelationalStore` use real in-memory SQLite. See [`examples/relational-notes`](https://github.com/vercel-labs/native/tree/main/examples/relational-notes) for migrations, typed atomic writes, FTS5, page decoding, and two live queries together.
+6
View File
@@ -4,6 +4,12 @@ import { CodeToggle } from "@/components/code-toggle";
The model stores source-of-truth state only: the raw items, the current filter, the draft text. Everything the view shows that is computable from those — counts, sums, filtered lists, formatted strings — is derived at view time, never stored. This page collects the data-flow patterns that keep a Native SDK app honest; they hold in both core languages, and the samples show each where the expression differs.
## Pick one canonical store
For small in-memory apps, the Model owns the domain data and [model persistence](/docs/persistence) snapshots it. For relational apps, [SQLite](/docs/sqlite) owns the domain data; the Model holds view state plus the row pages most recently delivered by a query. Writes go out as one atomic `Cmd.db.exec`, and reads return as later Msg values—never as a database handle or a synchronous call inside `update`. Derived UI values still come from the Model's current rows, so the rule below does not change.
Secrets are the exception: tokens and passwords never belong in the Model, even temporarily. Store them with [`Cmd.credentials.set`](/docs/capabilities), retrieve them as an effect result, and consume the returned Msg bytes immediately to construct the next command (for example, an authenticated fetch) while returning a Model that does not retain them. That keeps secrets out of model snapshots, state fingerprints, and ordinary view data. Session recording redacts successful credential reads and replay supplies only a same-length placeholder.
## Derive, don't store
A cached derivable must be re-maintained in every `update` arm and goes stale the moment one is missed; a derived function cannot go stale.
+68 -5
View File
@@ -28,21 +28,84 @@ const MyApp = native_sdk.UiApp(Model, Msg);
.theme = app_runner.manifestThemePack(),
```
In a zero-config TypeScript app, export a single-model `themePack` helper when the pack itself belongs in live app state. The generated launcher recognizes this helper and wires the stock-token path automatically:
In a zero-config TypeScript app, export `themeState(model)` when pack, color scheme, or accent belongs in live app state. The generated launcher recognizes the helper and wires the stock-token path automatically:
```ts
import { type ThemeState } from "@native-sdk/core/events";
export type ThemePack = "house" | "geist";
export type ThemePreference = "system" | "light" | "dark";
export interface Model {
readonly theme: ThemePack;
readonly themePreference: ThemePreference;
readonly pinkAccent: boolean;
}
export function themePack(model: Model): ThemePack {
return model.theme;
export function themeState(model: Model): ThemeState {
if (model.pinkAccent) {
return {
pack: model.theme,
colorScheme: model.themePreference,
accent: "#df2670",
};
}
return { pack: model.theme, colorScheme: model.themePreference };
}
```
Change `model.theme` through ordinary messages (for example, from a pair of model-driven `toggle-button`s). The helper is evaluated on every rebuild. It changes only the built-in pack: system light/dark, high contrast, reduced motion, manifest `theme_accent`, and each surface's scale remain live runtime inputs. Without the helper, `app.zon` remains the static pack choice.
`ThemeState` has three optional fields:
```ts
export type ThemeState = {
readonly pack?: "house" | "geist";
readonly colorScheme?: "light" | "dark" | "system";
readonly accent?: string; // exactly #rrggbb
};
```
Change the model through ordinary messages (for example, model-driven `toggle-button`s). The helper is evaluated after every committed update. Omitted fields inherit the next lower layer; omitted `colorScheme` and `"system"` both follow the OS. A malformed accent is a loud runtime teaching error, never a silent fallback. High contrast and reduced motion remain live OS inputs; high contrast suppresses both manifest and model accent overrides so accessibility wins.
The stock-theme precedence is:
<table>
<thead>
<tr>
<th>Axis</th>
<th>Highest to lowest precedence</th>
</tr>
</thead>
<tbody>
<tr>
<td>Complete tokens</td>
<td><code>tokens_fn</code> → static <code>tokens</code> → stock theme composition below</td>
</tr>
<tr>
<td>Pack</td>
<td><code>themeState.pack</code> → <code>app.zon theme</code> → <code>house</code></td>
</tr>
<tr>
<td>Color scheme</td>
<td>forced <code>themeState.colorScheme</code> → OS appearance</td>
</tr>
<tr>
<td>Accent</td>
<td><code>themeState.accent</code> → <code>app.zon theme_accent</code> → selected pack</td>
</tr>
<tr>
<td>High contrast / reduced motion</td>
<td>OS appearance (high contrast suppresses accent overrides)</td>
</tr>
<tr>
<td>Surface scale / text measurement</td>
<td>Runtime-stamped last</td>
</tr>
</tbody>
</table>
`themeState` deliberately controls canvas design tokens in v1. Native title bars and WebViews still follow the platform's effective appearance; forcing dark canvas content does not call `NSApp.appearance` or impose a scheme on embedded web content.
The earlier `themePack(model): "house" | "geist"` helper remains supported unchanged for apps that only switch packs. It preserves live OS scheme, manifest accent, high contrast, reduced motion, and surface scale. Export `themePack` or `themeState`, never both; the checker and adapter report that conflict as a teaching error.
Apps that derive their own tokens select the pack directly — `ThemeOptions.pack` is just another theme axis, exactly as switchable at runtime as the scheme:
@@ -113,7 +176,7 @@ pub fn brandTokens(scheme: canvas.ColorScheme, contrast: canvas.ColorContrast) c
}
```
Hand it to your app via `tokens_fn` (model-owned, follows the system scheme through your model) or `tokens` (fixed). The runtime stamps `pixel_snap.scale` and text measurement after your function runs, so never cache those.
Hand it to your app via `tokens_fn` (model-owned, follows the system scheme through your model) or `tokens` (fixed). These complete-token paths take precedence over `themeState`; the runtime stamps `pixel_snap.scale` and text measurement after your function runs, so never cache those.
## What themes cannot do
+140 -26
View File
@@ -116,6 +116,21 @@ On macOS, `title` renders the tray as a menu-bar extra: a titled `NSStatusItem`
<td><code>ShortcutModifiers</code></td>
<td><code>.{}</code></td>
</tr>
<tr>
<td><code>segmented</code></td>
<td><code>?TraySegmentedRow</code></td>
<td><code>null</code></td>
</tr>
<tr>
<td><code>metric</code></td>
<td><code>?TrayMetricRow</code></td>
<td><code>null</code></td>
</tr>
<tr>
<td><code>chart</code></td>
<td><code>?TrayChartRow</code></td>
<td><code>null</code></td>
</tr>
</tbody>
</table>
@@ -123,17 +138,22 @@ On macOS, `title` renders the tray as a menu-bar extra: a titled `NSStatusItem`
Use the runtime methods from app code:
- `runtime.createStatusItem(id, options)` -- create a status item under a stable non-zero id
- `runtime.updateStatusItemShell(id, shell)` -- update icon, tooltip, visibility, and activation/open commands in place
- `runtime.updateStatusItemMenu(id, items)` -- update one menu without recreating its status item
- `runtime.updateStatusItemPresentation(id, presentation)` -- update one live title and visual presentation
- `runtime.removeStatusItem(id)` -- remove only the identified item
- `runtime.createTray(options)` -- create or replace the tray icon
- `runtime.updateTrayMenu(items)` -- update menu items without recreating the tray
- `runtime.updateTrayTitle(title)` -- update only the live tray title
- `runtime.updateTrayPresentation(presentation)` -- update the live title and visual presentation
- `runtime.removeTray()` -- remove the tray icon
The lower-level `PlatformServices.createTray`, `updateTrayMenu`, `updateTrayTitle`, `updateTrayPresentation`, and `removeTray` hooks are available for platform adapters. `Runtime` validates tray options before dispatch: non-separator menu items need a label, command-backed items need a unique non-zero `id`, and menus are capped at 32 items.
The singular `*Tray` methods are compatibility wrappers for reserved status-item id `1`. The lower-level `PlatformServices` surface exposes the same keyed methods for platform adapters. `Runtime` supports at most eight simultaneous status items and validates every menu independently: non-separator rows need a label, command-backed rows need a unique non-zero row `id`, and each menu is capped at 32 rows.
## TypeScript: model-derived status items
In a TypeScript app, export `statusItem(model)` from `src/core.ts`. The generated launcher installs it from the committed boot model and re-runs it after every model update. Presentation and menu are hashed independently, so changing the title, width, tone, icon opacity, or number style does not rebuild the dropdown or recreate the native status item.
In a TypeScript app, export `statusItem(model)` from `src/core.ts`. The generated launcher installs it from the committed boot model and re-runs it after every model update. Shell, presentation, and menu are hashed independently, so changing the icon, tooltip, click hooks, title, width, tone, icon opacity, typography, or rows patches only that channel and never recreates the native status item.
```ts:src/core.ts
import { asciiBytes, utf8Bytes } from "@native-sdk/core";
@@ -152,6 +172,8 @@ export function statusItem(model: Model): StatusItemState {
tone: model.failed ? "critical" : "normal",
iconOpacity: model.stale ? 0.5 : 1,
monospaced: true,
fontSize: 13,
fontWeight: "semibold",
},
items: [
{ id: 10, label: model.today, command: asciiBytes(""), separator: false, enabled: false, detail: model.quota, role: "hero", key: asciiBytes(""), modifiers: { primary: false, command: false, control: false, option: false, shift: false } },
@@ -162,15 +184,90 @@ export function statusItem(model: Model): StatusItemState {
}
```
`iconPath`, `tooltip`, `activationCommand`, `alternateActivationCommand`, and `openCommand` are captured when the item installs. A normal click emits `activationCommand` and opens the menu; an Option-click emits `alternateActivationCommand` without opening it. Every menu open emits `openCommand`, which is useful for an on-demand refresh while the background cadence stays slow. These lifecycle hooks dispatch with `source = .tray`; empty commands disable them.
`iconPath`, `tooltip`, `activationCommand`, `alternateActivationCommand`, and `openCommand` update live alongside presentation and rows. A normal click emits `activationCommand` and opens the menu; an Option-click emits `alternateActivationCommand` without opening it. Every menu open emits `openCommand`, which is useful for an on-demand refresh while the background cadence stays slow. These lifecycle hooks dispatch with `source = .tray`; empty commands disable them.
Rows use the exact `StatusItemMenuItem` record. `role` is `command`, `info`, `header`, `hero`, `agent`, or `context`; capable macOS hosts render the readout roles as native rich content while simpler hosts degrade them to text. `detail` carries secondary readout content, and `key` plus the five explicit `modifiers` fields declares a menu equivalent. Actionable rows need unique non-zero ids; separators conventionally use id 0 and empty byte fields. The menu may contain at most 32 rows. Map every row/click/open command to an ordinary message with `commandMsg(name): Msg | null`; no Zig `status_item_fn` glue is needed.
For multiple independent items, export `statusItems(model): readonly StatusItemDescriptor[]` instead. Each descriptor has the same shell, presentation, and row fields plus a stable non-zero `id` and live `visible` flag. Presence creates, absence removes, and changed fields patch only that identifier; menus update without replacing their `NSStatusItem`. Export either `statusItem` or `statusItems`, not both. This is the Vercel-shaped split: one spend indicator can appear or disappear while a separate control-menu item persists.
Persistent menu-bar composition uses that collection directly: return one descriptor for compact context text, another for a larger live metric, and another icon-only descriptor that owns the dropdown. Each descriptor independently chooses optional `fontSize` (omitted or `0` keeps the platform default), optional `fontWeight` (omitted means `regular`; otherwise `regular`, `medium`, `semibold`, or `bold`), `monospaced`, width, tone, and icon opacity. They remain separate native status items, so each can appear, disappear, or update without recreating its neighbors.
Rows use the exact `StatusItemMenuItem` record. `role` is `command`, `info`, `header`, `hero`, `agent`, `context`, `segmented`, or `chart`; capable macOS hosts render the readout roles as native rich content while simpler hosts degrade them to text. `detail` carries secondary readout content, and `key` plus the five explicit `modifiers` fields declares a menu equivalent. Actionable rows need unique non-zero ids; separators conventionally use id 0 and empty byte fields. The menu may contain at most 32 rows. Map every row/click/open command to an ordinary message with `commandMsg(name): Msg | null`; no Zig `status_item_fn` glue is needed.
### Typed rich rows
A segmented row carries its choices as data rather than encoding them into `label` or `detail`. Each option has its own stable `id`, label, command, selected state, and enabled state. macOS renders the row as `NSSegmentedControl`; selecting a segment emits its option id through the same tray-action → command → `commandMsg` route as an ordinary command row. Other hosts may expose the options as separate command items.
```ts
{
id: 0,
label: asciiBytes(""),
command: asciiBytes(""),
separator: false,
enabled: true,
detail: asciiBytes(""),
role: "segmented",
key: asciiBytes(""),
modifiers: { primary: false, command: false, control: false, option: false, shift: false },
segmented: {
options: [
{ id: 20, label: asciiBytes("Day"), command: asciiBytes("range.day"), selected: model.range === "day", enabled: true },
{ id: 21, label: asciiBytes("Week"), command: asciiBytes("range.week"), selected: model.range === "week", enabled: true },
],
},
}
```
A typed metric row keeps prominent primary and secondary text inside the dropdown, separate from the persistent menu-bar title:
```ts
{
id: 0,
label: asciiBytes(""),
command: asciiBytes(""),
separator: false,
enabled: false,
detail: asciiBytes(""),
role: "hero",
key: asciiBytes(""),
modifiers: { primary: false, command: false, control: false, option: false, shift: false },
metric: {
primaryText: asciiBytes("2,494 requests"),
secondaryText: utf8Bytes("Today · production"),
accessibilityLabel: asciiBytes("2,494 requests today in production"),
},
}
```
A chart row carries 132 finite values plus an explicit numeric domain, leading caption, trailing summary, and required accessibility label. Every value must fall inside `minValue...maxValue`. The macOS system host draws native AppKit bars in a custom `NSView`; hosts without custom tray rows retain the caption and summary as text.
```ts
{
id: 0,
label: asciiBytes(""),
command: asciiBytes(""),
separator: false,
enabled: false,
detail: asciiBytes(""),
role: "chart",
key: asciiBytes(""),
modifiers: { primary: false, command: false, control: false, option: false, shift: false },
chart: {
values: model.cpuHistory,
minValue: 0,
maxValue: 1,
leadingCaption: asciiBytes("CPU"),
trailingSummary: model.cpuSummary,
accessibilityLabel: model.cpuAccessibility,
},
}
```
A segmented row admits at most eight options and at most one selected option. Option ids share the enclosing menus command-id namespace, including other segmented rows. The 32-row menu budget is also the fallback budget: a segmented row consumes one fallback row per option, preventing silent truncation on plain-menu hosts.
Use `utf8Bytes` for titles, labels, tooltips, and details; it preserves characters such as `…`, `·`, and emoji as UTF-8. Use `asciiBytes` for guaranteed-ASCII command names, keys, paths, and empty byte fields. Passing non-ASCII literal/template text to `asciiBytes` is an NS1064 build error.
## Handling tray actions
When a user clicks a tray menu item, the runtime dispatches a `CommandEvent` with source `.tray` and the native `tray_item_id`. Prefer command-backed items when the item represents a known app action:
When a user clicks a tray menu item, the runtime dispatches a `CommandEvent` with source `.tray`, the native `status_item_id`, and that menu's `tray_item_id`. Row ids only need to be unique within their own menu. Prefer command-backed items when the item represents a known app action:
```zig
try runtime.createTray(.{
@@ -227,7 +324,7 @@ macOS (`NSStatusItem`) is the proven host; platforms without a status-bar servic
## Model-driven title and menu in Zig-core apps
For a live menu-bar extra — an open-count badge in the title, the latest items in the dropdown — add `UiApp.Options.status_item_fn`. It is consulted on install and after every rebuild, and the runtime re-applies only what actually changed: a presentation-only change patches the live status button (no flicker, no menu rebuild), while a menu change updates the dropdown. The static `status_item` can provide install-time icon, tooltip, activation, alternate-activation, and open commands.
For a live menu-bar extra — an open-count badge in the title, the latest items in the dropdown — add `UiApp.Options.status_item_fn`. It is consulted on install and after every rebuild, and the runtime re-applies only what actually changed: shell, presentation, and menu changes patch independently without flicker or native-item recreation. The static `status_item` provides defaults for icon, tooltip, activation, alternate-activation, and open commands; the callback may update those fields live too.
```zig
fn statusItem(model: *const Model, scratch: *App.StatusItemScratch) App.StatusItemState {
@@ -245,36 +342,53 @@ fn statusItem(model: *const Model, scratch: *App.StatusItemScratch) App.StatusIt
Selections dispatch each item's `command` through `on_command` with source `.tray`, the same shape as window menus. Platforms without a tray-title seam keep the menu updates and log the missing title support once.
For multiple Zig-core items, use `UiApp.Options.status_items_fn`, returning up to eight `App.StatusItemDescriptor` values from `App.StatusItemsScratch`. Descriptor presence creates/removes by `id`; `visible` hides without removing; shell, presentation, and menu hashes reconcile independently. It is mutually exclusive with the singular `status_item` / `status_item_fn` pair.
## The menu-bar app lifecycle
The tray-player pattern (a Spotify-shaped app that lives in the menu bar) is two declarations and two verbs:
The default TypeScript + Native markup recipe combines one app policy, two window policies, and two commands:
1. The window declares `close_policy = "hide"` in app.zon, so the red close button hides it instead of quitting — the app keeps running behind its status item, and on macOS clicking the Dock icon re-shows the hidden window on its own. On Windows this requires the `"tray"` capability (the status item is the only way back to a hidden window there — the build refuses `"hide"` without it, and a tray that fails to install downgrades the first hide-close to a real close with a loud log). The default (`"quit"`) keeps the classic behavior; see [Windows](/docs/windows#close-policy).
2. The tray rows map to the window verbs in `update`: "Open" returns `fx.showWindow(label)` (un-hide + activate; it also restores a minimized window) and "Quit" returns `fx.quitApp()` — the real graceful terminate, riding the same shutdown path a last-window close takes.
1. Declare the `"tray"` capability and set top-level `dock_visible = false`. macOS selects the Accessory activation policy before creating the startup window, so no Dock tile or cmd+Tab entry flashes. The tray requirement is validated because it becomes the app's route back to hidden windows.
2. Set `initially_hidden = true` on the startup window when the app should launch behind the status item. `dock_visible = false` alone removes desktop presence; it does not suppress the window.
3. Set `close_policy = "hide"`, so the red close button hides the window instead of quitting. The default `"quit"` keeps classic windowed-app behavior; see [Windows](/docs/windows#close-policy).
4. Map tray rows to `Cmd.showWindow("main")` and `Cmd.quitApp()`. Show unhides, orders front, and activates; Quit follows the real graceful shutdown path.
```zig
pub const status_items = [_]native_sdk.TrayMenuItem{
.{ .id = 1, .label = "Open Player", .command = "app.open" },
.{ .separator = true },
.{ .id = 2, .label = "Quit", .command = "app.quit" },
};
```zig:app.zon
.capabilities = .{ "native_views", "gpu_surfaces", "tray" },
.dock_visible = false,
.shell = .{
.windows = .{
.{
.label = "main",
.initially_hidden = true,
.close_policy = "hide",
// views...
},
},
},
```
pub fn command(name: []const u8) ?Msg {
if (std.mem.eql(u8, name, "app.open")) return .open_player;
if (std.mem.eql(u8, name, "app.quit")) return .quit;
return null;
```ts:src/core.ts
import { Cmd } from "@native-sdk/core";
export function commandMsg(name: string): Msg | null {
if (name === "app.open") return { kind: "open_player" };
if (name === "app.quit") return { kind: "quit" };
return null;
}
pub fn update(model: *Model, msg: Msg, fx: *Effects) void {
switch (msg) {
.open_player => fx.showWindow("main"),
.quit => fx.quitApp(),
// ...
}
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
switch (msg.kind) {
case "open_player":
return [model, Cmd.showWindow("main")];
case "quit":
return [model, Cmd.quitApp()];
// other arms...
}
}
```
In the TypeScript tier the same verbs are `Cmd.showWindow("main")` and `Cmd.quitApp()`, and the exported `statusItem(model)` helper above supplies the live title and rows. `examples/menu-bar` is the whole zero-Zig loop.
`Cmd.setDockPresence(true)` can later promote the same running Accessory app to Regular; passing `false` demotes it again. `examples/menu-bar` is the complete zero-Zig loop with `statusItem(model)` supplying the live title and rows.
Linux is the honest exception: the toolkit has no status item there yet, so nothing could bring a hidden window back — `close_policy = "hide"` is refused at build/create time with a teaching, and the platform-support matrix states it plainly.
+15 -3
View File
@@ -4,7 +4,7 @@ import { CodeToggle } from "@/components/code-toggle";
An app core is pure TypeScript: no npm packages run inside it, because no JS engine ships in the binary. That line is drawn on purpose, and it buys the properties the rest of the toolkit stands on — byte-identical record→replay, headless testing of the whole app, automation over real state, and native dispatch speed with zero allocation at runtime. The language inside the core is complete ([TypeScript Cores](/docs/typescript) covers exactly what that means); the ecosystem lives at the edges, and every edge has a first-class pattern.
The question behind "can I use npm?" is almost always one of these four:
The question behind "can I use npm?" is almost always one of these five:
<table>
<thead>
@@ -15,6 +15,11 @@ The question behind "can I use npm?" is almost always one of these four:
</tr>
</thead>
<tbody>
<tr>
<td>Filesystem, JSON/regex parsing, transforms, or imperative work in ordinary TypeScript</td>
<td>A compiled module under <code>src/services/</code></td>
<td>A native service-host process, reached through <code>@native-sdk/services</code> commands</td>
</tr>
<tr>
<td>An HTTP API — including AI/LLM endpoints</td>
<td><code>Cmd.fetch</code> with routed results</td>
@@ -38,6 +43,12 @@ The question behind "can I use npm?" is almost always one of these four:
</tbody>
</table>
## Compiled TypeScript services
Put ordinary static-tier TypeScript under `src/services/` when the work needs Node built-ins, regexes, JSON, `Map`/`Set`, `Date`, classes, or ambient process authority. Each directly exported, non-default named synchronous function becomes an operation named `<module-basename>.<export>`. Its request and result may be shared, contract-encodable records; the core calls the generated constructor from `@native-sdk/services`, so success and failure still arrive as Msgs and record/replay remains offline.
This is compiled native code with no JavaScript engine. `native vendor . package@X.Y.Z` resolves an exact package graph once, with lifecycle scripts disabled, into checked-in `src/services/vendor/` sources and hash facts in app.zon. Builds are offline: every byte is verified and scriptc receives only the explicit `--npm-static` package list—never automatic or dynamic fallback. `native check` preserves scriptc's coverage note and refuses anything below 100% static coverage. The checked-in five-package calibration passed three small source-shipping utilities and refused two (`nanoid` and `micromark`), so package support is intentionally selective. Services run in a lazily started child process by default, with an explicit in-process opt-in where the compiler can localize the target archive; [TypeScript Services](/docs/typescript/services) covers the exact platform/architecture matrix, typed calls, streaming, cancellation, authority, and crash recovery. [`examples/service-feed-reader`](https://github.com/vercel-labs/native/tree/main/examples/service-feed-reader) is the reference: `Cmd.fetch` downloads a feed, the service parses the bytes into typed records through the generated client, and the recorded loop replays without the service or the network.
## Calling APIs, AI endpoints included
Most packages people reach for first — API clients, AI SDKs — are HTTP wrappers. The HTTP is already in the toolkit: `Cmd.fetch` can perform a buffered exchange and route `{ status, body }`, or line-stream an SSE/NDJSON response through repeated Msgs. The request is data, every response event is a message, and a recorded session replays the whole conversation with zero network — which is not something an SDK dependency can offer. A complete buffered client for an OpenAI-compatible chat endpoint:
@@ -167,7 +178,8 @@ import { parseCsvRow } from "./csv.ts"; // vendored under src
import { containsIgnoreCase } from "@native-sdk/core/text"; // the SDK library channel
```
- **Vendor it under `src/`.** Subset-clean TypeScript compiles into the core like your own modules ([splitting a core into modules](/docs/typescript#splitting-a-core-into-modules)); the subset checker tells you immediately — by rule ID, with the rewrite — whether a vendored file fits. Code that leans on classes, exceptions, or regexes generally wants rewriting rather than vendoring, and the rewrite is usually smaller than the dependency.
- **Vendor subset-clean code under `src/` outside `src/services/`.** It compiles into the core like your own modules ([splitting a core into modules](/docs/typescript#splitting-a-core-into-modules)); the subset checker tells you immediately — by rule ID, with the rewrite — whether it fits.
- **Vendor ordinary static-tier code or exact npm packages under `src/services/`.** Classes, regexes, JSON, `Map`/`Set`, `Date`, Node built-ins, and imperative transforms stay in TypeScript and compile into the service host; run `native vendor . package@X.Y.Z` for a package, then reach its typed exported operations through `@native-sdk/services`.
- **`@native-sdk/core/*` is the curated library channel**: SDK modules written in the same subset, compiled into your core when imported and absent when not. Today that is `@native-sdk/core/text` — the byte-splice text engine (caret, selection, IME, case-insensitive search) — and `@native-sdk/core/events` — the canonical event record types markup and the wiring channels match. The channel grows with the toolkit; JSON encoding/parsing over bytes, today demonstrated in `examples/chatbot/src/api.ts`, is the kind of module it exists to absorb.
One thing deliberately does not exist: a package manager for cores. A core's import graph is exactly the files under `src/` plus the SDK modules — the whole program is readable, the build is hermetic, and nothing arrives at build time that you have not checked in.
One thing deliberately does not exist: a package manager for cores. A core's import graph is exactly its class under `src/` plus the SDK modules. Services stay hermetic: local and npm sources are checked in under `src/services/`, app.zon pins each npm name/version/tree hash, and build performs no install or network step. Nothing arrives at build time that you have not checked in.
+78 -19
View File
@@ -2,9 +2,9 @@ import { CodeToggle } from "@/components/code-toggle";
# TypeScript Cores
An app core is the logic tier of a Native SDK app: `Model` (the app state), `Msg` (a discriminated union of everything that can happen), `update(model, msg)` (the one pure transition function), and the pure helpers they call. By default you write it as one TypeScript module — `src/core.ts` — the `@native-sdk/core` frontend checks it, and the external core compiler builds it to native code at build time. No JS engine ships in the binary: the program either passes the subset checker and compiles to native, or you get a teaching error naming the rule, the fix, and the reason.
An app core is a Native SDK app's deterministic logic: `Model` (the app state), `Msg` (a discriminated union of everything that can happen), `update(model, msg)` (the one pure transition function), and the pure helpers they call. By default you write it as one TypeScript module — `src/core.ts` — the `@native-sdk/core` frontend checks it, and the external core compiler builds it to native code at build time. No JS engine ships in the binary: the program either passes the subset checker and compiles to native, or you get a teaching error naming the rule, the fix, and the reason.
This is the two-tier shape of the toolkit: Zig is how everything works — the engine, the runtime, every widget — and TypeScript plus [Native markup](/docs/native-ui) are how applications are authored. A whole app is three files and zero Zig: `src/core.ts`, `src/app.native`, and `app.zon`. Writing the core in Zig instead ([App Model](/docs/app-model)) is first-class by choice — same loop, same runtime — and extending the toolkit itself (custom widgets, host services, render passes) is always Zig.
This is the two-tier shape of the toolkit: Zig is how everything works — the engine, the runtime, every widget — and TypeScript plus [Native markup](/docs/native-ui) are how applications are authored. A whole app starts as three files and zero Zig: `src/core.ts`, `src/app.native`, and `app.zon`. When ordinary TypeScript work needs filesystem access, JSON, regexes, `Map`, `Date`, classes, or child processes, add modules under `src/services/`; they compile to native code too and answer the core through the same effect→Msg boundary as every other external action. Writing the core in Zig instead ([App Model](/docs/app-model)) is first-class by choice — same loop, same runtime — and extending the toolkit itself (custom widgets and render passes) is always Zig.
The same `core.ts` is executable TypeScript: it typechecks with stock tsc and runs unmodified under node, which is what makes the fastest dev loop possible:
@@ -129,7 +129,12 @@ Both regions have fixed, build-time capacities (1 MiB each by default): the fram
## The subset posture
App cores are written in a closed subset of TypeScript, and the subset means one precise thing: TypeScript minus the ecosystem minus the purity violations — never minus basic syntax. Every basic statement, operator, and declaration form compiles: plain interfaces, discriminated unions, `switch` (with `default` arms), every loop shape (`for`, `for...of`, `while`, `do...while`, labels with labeled `break`/`continue`), the full operator and assignment family (`**`, shifts, `+=` through `??=`), const record destructuring, namespace imports, spreads, the array methods (`.map`/`.filter`/`.find`/`.reduce`/`.toSorted`/...), `Math`, template literals — everything with exact JS semantics, pinned so node and native always agree (a machine-checked grammar matrix classifies every production of the language, so nothing is missing by accident). Classes and exceptions compile too: data classes (fields, a constructor, methods, `static` methods and `static readonly` consts, erased `private`/`protected` — `new Task(...)`, `this.count`, `Task.fromRow(...)`, mutation under the same local-ownership rule as arrays) compile to plain structs plus functions, and `throw`/`try`/`catch`/`finally` is deterministic control flow — a thrown kind-tagged subset value unwinds to the nearest catch (several distinct shapes may throw; the checker collects them into the core's thrown union, and `catch (e)` narrows it with plain kind tests, no `as` ceremony), `finally` runs on every path, and an uncaught throw is a defined panic exactly where node would crash. What isn't available is exactly two families: the ecosystem the binary cannot carry (npm packages, regexes, `JSON`, Promises, `eval` — no JS engine ships) and constructs that would break the core's guarantees (class inheritance, `async`/`await` — asynchrony is command data, `Map`/`Set`, module-level `let`, `Date.now()`/`Math.random()` inside `update`, runtime type tests, text as indexable strings — a core's text is bytes). Each has an idiomatic replacement the checker teaches by ID (NS1001NS1059) — kind-tagged error shapes narrowed in the catch, time and randomness arrive as message payloads, keyed data is an id-keyed array. Immutability is a rule about SHARED data, not a style: mutation is legal on locally-owned arrays — a scratch array your function creates (a literal or a `.slice()` copy) takes `push`/`pop`/`splice`/in-place `sort`, the `xs[xs.length] = v` append, and the rest with exact JS semantics until the value escapes; a `let` reassigned only from fresh copies stays owned, passing into a `readonly T[]` reader parameter borrows instead of escaping, and the checker teaches only at the real boundaries. Generics are ordinary TypeScript too: a module-level generic function, interface, or type alias monomorphizes per call site from tsc's own resolved type arguments — one native function per instantiation. These rules scope to app cores, the logic tier; they say nothing about the TypeScript you write anywhere else. Where the npm ecosystem fits — calling APIs (AI endpoints included), embedding npm-heavy web UIs, running node as a worker, vendoring utilities — has its own page: [Where Packages Go](/docs/typescript/packages).
App cores are written in a closed subset of TypeScript, and the subset means one precise thing: TypeScript minus the ecosystem minus the purity violations — never minus basic syntax. Every basic statement, operator, and declaration form compiles: plain interfaces, discriminated unions, `switch` (with `default` arms), every loop shape (`for`, `for...of`, `while`, `do...while`, labels with labeled `break`/`continue`), the full operator and assignment family (`**`, shifts, `+=` through `??=`), const record destructuring, namespace imports, spreads, the array methods (`.map`/`.filter`/`.find`/`.reduce`/`.toSorted`/...), `Math`, template literals — everything with exact JS semantics, pinned so node and native always agree (a machine-checked grammar matrix classifies every production of the language, so nothing is missing by accident). Classes and exceptions compile too: data classes (fields, a constructor, methods, `static` methods and `static readonly` consts, erased `private`/`protected` — `new Task(...)`, `this.count`, `Task.fromRow(...)`, mutation under the same local-ownership rule as arrays) compile to plain structs plus functions, and `throw`/`try`/`catch`/`finally` is deterministic control flow — a thrown kind-tagged subset value unwinds to the nearest catch (several distinct shapes may throw; the checker collects them into the core's thrown union, and `catch (e)` narrows it with plain kind tests, no `as` ceremony), `finally` runs on every path, and an uncaught throw is a defined panic exactly where node would crash. What isn't available is exactly two families: the ecosystem the core cannot carry (npm packages, regexes, `JSON`, Promises, `eval`) and constructs that would break the core's guarantees (class inheritance, `async`/`await` — asynchrony is command data, `Map`/`Set`, module-level `let`, `Date.now()`/`Math.random()` inside `update`, runtime type tests, text as indexable strings — a core's text is bytes). Each has an idiomatic replacement the checker teaches by ID — kind-tagged error shapes narrowed in the catch, time and randomness arrive as message payloads, keyed data is an id-keyed array, and ordinary static-tier work moves behind a `src/services/` request. Immutability is a rule about SHARED data, not a style: mutation is legal on locally-owned arrays — a scratch array your function creates (a literal or a `.slice()` copy) takes `push`/`pop`/`splice`/in-place `sort`, the `xs[xs.length] = v` append, and the rest with exact JS semantics until the value escapes; a `let` reassigned only from fresh copies stays owned, passing into a `readonly T[]` reader parameter borrows instead of escaping, and the checker teaches only at the real boundaries. Generics are ordinary TypeScript too: a module-level generic function, interface, or type alias monomorphizes per call site from tsc's own resolved type arguments — one native function per instantiation. These rules scope to the core class. Files under `src/services/` skip NS1001NS1064 and are judged by the same pinned compiler's ordinary static tier instead; the class boundary rules NS1065NS1067 keep the deterministic core and ambient-authority service separate. Where the ecosystem fits has its own page: [Where Packages Go](/docs/typescript/packages).
Every rule in the catalogue carries a class. A `guarantee` rule protects a core invariant — determinism and replay, fixed shapes, immutability of shared data, the one text representation — and is permanent. A `deferred` rule bans nothing those invariants require; the capability waits on a deliberate easing decision, and its diagnostic says so.
- `guarantee` — permanent: NS1001 (shared data is immutable), NS1002 (updates are synchronous), NS1005 (update is deterministic), NS1010 (module state lives in the Model), the byte-text rules (NS1004, NS1018, NS1024, NS1060), and every other rule not listed as deferred.
- `deferred` — awaiting an easing decision: NS1011 (`Map`/`Set`), NS1019 (fixed arity: parameter defaults, rest, `arguments`, call spreads), NS1040 (regular expressions), NS1042 (generators), NS1044 (`BigInt`/`Symbol`).
<CodeToggle>
@@ -279,6 +284,14 @@ The runtime interprets the command after the model commits and dispatches any re
<td><code>Cmd.writeFile(path, bytes, &#123; key?, ok, err &#125;)</code></td>
<td>Write a whole file (parents created, replaced whole); <code>ok</code> carries no payload — a successful write has nothing to report</td>
</tr>
<tr>
<td><code>Cmd.appendFile</code> / <code>Cmd.statFile</code> / <code>Cmd.deleteFile</code></td>
<td>Append one bounded payload, inspect <code>&#123; exists, size, mtimeMs &#125;</code>, or delete one file with explicit <code>not_found</code> handling</td>
</tr>
<tr>
<td><code>Cmd.readFileStream</code> / <code>Cmd.writeFileStream</code> + <code>writeFileChunk</code>/<code>writeFileClose</code></td>
<td>Read 256-KiB chunks without a total-size cliff, or build an atomic export one acknowledged chunk at a time — see <a href="/docs/files">Files &amp; Streaming</a></td>
</tr>
<tr>
<td><code>Cmd.fetch(spec, &#123; key?, ok, err &#125;)</code></td>
<td>A buffered HTTP(S) exchange; <code>ok</code> carries <code>&#123; status, body &#125;</code> (a 404 is still <code>ok</code> — a delivered response), <code>err</code> the transport reason</td>
@@ -292,16 +305,16 @@ The runtime interprets the command after the model commits and dispatches any re
<td>System clipboard: write is fire-and-forget, read routes the text bytes back</td>
</tr>
<tr>
<td><code>Cmd.showNotification(&#123; title, subtitle?, body? &#125;)</code></td>
<td>Show a desktop notification, fire-and-forget; text fields are bytes and the OS remains authoritative over final delivery</td>
<td><code>Cmd.showNotification(&#123; id?, title, subtitle?, body?, actionLabel?, actionCommand? &#125;)</code></td>
<td>Show or replace a desktop notification, fire-and-forget; paired action fields dispatch through the ordinary app-command path while the process is running</td>
</tr>
<tr>
<td><code>Cmd.openExternalUrl(url)</code> / <code>Cmd.revealPath(path)</code></td>
<td>Open an allowed HTTP(S) URL in the system browser or reveal a path in Finder/Files/Explorer; both are fire-and-forget and fail closed</td>
</tr>
<tr>
<td><code>Cmd.credentialSet(...)</code> / <code>credentialGet(...)</code> / <code>credentialDelete(...)</code></td>
<td>Routed access to Keychain, Secret Service, or Credential Manager; get returns the secret bytes and missing items route <code>not_found</code></td>
<td><code>Cmd.credentials.set(...)</code> / <code>Cmd.credentials.get(...)</code> / <code>Cmd.credentials.delete(...)</code></td>
<td>App-scoped access to the OS credential store; get returns secret bytes, missing items route <code>miss</code>, and the manifest must declare the <code>credentials</code> capability and permission</td>
</tr>
<tr>
<td><code>Cmd.formatLocalTime(timestampMs, style, route)</code></td>
@@ -343,6 +356,14 @@ The runtime interprets the command after the model commits and dispatches any re
<td><code>Cmd.persist()</code></td>
<td>Snapshot the just-committed Model through the engine-owned, capability-gated atomic store; restore arrives through the manifest's configured boot Msg route — see <a href="/docs/persistence">Model Persistence</a></td>
</tr>
<tr>
<td><code>Cmd.store.set/get/delete/scan/setMany</code></td>
<td>Persist independent byte records in the engine-owned, capability-gated record store; every result returns through the declared Msg route — see <a href="/docs/record-store">Record Store</a></td>
</tr>
<tr>
<td><code>Cmd.db.query(sql, params, route)</code> / <code>Cmd.db.exec(statements, route)</code></td>
<td>Run read-only relational queries as bounded row pages or commit a statement list atomically through the engine-owned SQLite database — see <a href="/docs/sqlite">Relational SQLite</a></td>
</tr>
<tr>
<td><code>Cmd.host(name, ...args)</code> / <code>Cmd.request(name, payload, &#123; key?, ok, err &#125;)</code></td>
<td>App-defined host commands by literal name: fire-and-forget, or routed with exactly one result Msg back</td>
@@ -354,11 +375,13 @@ The runtime interprets the command after the model commits and dispatches any re
</tbody>
</table>
Result arms are ordinary Msg arms with the shape the effect produces — one `Uint8Array` field for host results and errors, one number field for timer fires and a streaming fetch's terminal status, no fields for `writeFile`'s ok, one number plus one `Uint8Array` field for a buffered fetch's result — and tsc checks the shapes for you. Keys carry ONE in-flight discipline: a keyed effect — `Cmd.request`, buffered named engine ops, `Cmd.delay` — whose key is already in flight replaces the old one (the superseded result is dropped, no message; the debounce shape), and `Cmd.cancel` drops it silently. Live `Cmd.spawn` and streaming-fetch keys reject a duplicate (`err` gets `rejected`) so two sources can never splice into one stream; cancel either first, and that cancel is loud (`err` gets `cancelled`). A streaming fetch whose line is cut or dropped also ends loudly with `err: truncated`, never a misleading successful status. Every routed `err` arm receives a machine-readable reason, so failure is never silence.
Result arms are ordinary Msg arms with the shape the effect produces — one `Uint8Array` field for raw host results and errors, a generated service result record, one number field for timer fires and stream totals, no fields for write acknowledgments, and one number plus one `Uint8Array` field for a buffered fetch's result — and tsc checks the shapes for you. Buffered engine effects and streamed file reads replace a live same-key predecessor and cancel silently. Live `Cmd.spawn`, streaming-fetch, streaming-service, and streamed write-sink keys reject duplicates so two producers cannot splice; cancelling those is loud through `err: cancelled`. A streaming fetch whose line is cut or dropped also ends with `err: truncated`, never a misleading successful status. Every routed `err` arm receives a machine-readable reason.
Platform state stays on the same effect boundary. `Cmd.openExternalUrl(url)` enforces [`security.navigation.external_links`](/docs/security#external-links) before entering the browser; `Cmd.revealPath(path)` uses the desktop file manager. Credential operations take byte `service` and `account` identifiers plus the standard `{ key?, ok, err }` route: set/delete return empty bytes on `ok`, get returns the secret, and a missing item routes `not_found`. `Cmd.formatLocalTime(timestampMs, "date" | "time" | "datetime", route)` returns localized UTF-8 bytes using the current host locale and time zone. That formatting is deliberately a Cmd—not a pure helper—so session recording captures the observed text and replay never re-reads ambient locale or timezone state.
Durable in-memory state uses `Cmd.persist()`: declare the `persist` capability, configure the boot routes and schema version, then return the command beside the committed model. The engine owns canonical serialization, trailing-edge coalescing, atomic app-data placement, backup recovery, migration, and journal/replay. See [Model Persistence](/docs/persistence) for the complete setup. `Cmd.readFile` and `Cmd.writeFile` remain for user-visible files, exports, and blobs; when using them, request the framework-provided app-data directory through `envMsgs` instead of depending on process cwd.
Durable in-memory state uses `Cmd.persist()`: declare the `persist` capability, configure the boot routes and schema version, then return the command beside the committed model. The engine owns canonical serialization, trailing-edge coalescing, atomic app-data placement, backup recovery, migration, and journal/replay. See [Model Persistence](/docs/persistence) for the complete setup. Raw file commands remain for user-visible files, exports, and blobs; [Files & Streaming](/docs/files) covers their bounds, atomic sink protocol, replay, and `filesystem` permission gate.
Independent byte records use `Cmd.store`: declare the `store` capability, then route set/get/delete/scan/setMany results back to Msg arms. The engine owns the app-data path, SQLite schema, atomic batches, pagination, and replay boundary. See [Record Store](/docs/record-store).
External sources — sockets, file watchers, native worker threads — reach `update` through a channel. `Cmd.channelOpen(key, { event })` opens a long-lived stream under an app-chosen numeric key, and every event dispatches the one `event` arm as a five-field record; `state` must be a named string-literal-union alias carrying exactly the three members — a narrower union would silently drop states the host emits, so the build refuses it. Posting is not a TS verb: compiled cores are single-threaded by design, so the posting handle lives on the native side (`Effects.channelHandle(key)`), where embedders and platform-services extensions post bytes from their own threads. Back-pressure is honest — posts the native handle refused count into `droppedPending`/`droppedTotal` on the next delivered event, never silence — and a duplicate open on a live key dispatches `rejected`. `Cmd.channelClose(key)` ends the stream: staged posts flush, exactly one `closed` event carries the final totals, and the key frees.
@@ -427,7 +450,7 @@ export type Msg =
## Model-derived menu-bar status items
A `src/core.ts` app can own its complete native menu-bar item without custom Zig wiring. Export `statusItem(model): StatusItemState`; the generated launcher installs its icon, tooltip, click/open commands, presentation, and rows from the boot model, then re-derives the live presentation and menu after committed updates. It patches those two parts independently and never recreates the item just because model state changed.
A `src/core.ts` app can own its complete native menu-bar item without custom Zig wiring. Export `statusItem(model): StatusItemState`; the generated launcher installs its icon, tooltip, click/open commands, presentation, and rows from the boot model, then re-derives all of them after committed updates. It patches shell, presentation, and menu independently and never recreates the item just because model state changed.
```ts:src/core.ts
import { asciiBytes, utf8Bytes } from "@native-sdk/core";
@@ -440,7 +463,7 @@ export function statusItem(model: Model): StatusItemState {
activationCommand: asciiBytes("app.sync"),
alternateActivationCommand: asciiBytes(""),
openCommand: asciiBytes("app.sync"),
presentation: { title: model.syncing ? utf8Bytes("SYNC…") : utf8Bytes("READY"), width: 62, tone: model.failed ? "critical" : "normal", iconOpacity: model.stale ? 0.5 : 1, monospaced: true },
presentation: { title: model.syncing ? utf8Bytes("SYNC…") : utf8Bytes("READY"), width: 62, tone: model.failed ? "critical" : "normal", iconOpacity: model.stale ? 0.5 : 1, monospaced: true, fontSize: 13, fontWeight: "semibold" },
items: [
{ id: 1, label: utf8Bytes("Open"), command: asciiBytes("app.open"), separator: false, enabled: true, detail: asciiBytes(""), role: "command", key: asciiBytes(""), modifiers: { primary: false, command: false, control: false, option: false, shift: false } },
{ id: 2, label: utf8Bytes("Sync now…"), command: asciiBytes("app.sync"), separator: false, enabled: !model.syncing, detail: asciiBytes(""), role: "command", key: asciiBytes("r"), modifiers: { primary: true, command: false, control: false, option: false, shift: false } },
@@ -449,7 +472,17 @@ export function statusItem(model: Model): StatusItemState {
}
```
Import the canonical records and unions from `@native-sdk/core/events`. Presentation includes byte `title`, numeric `width`, `normal | warning | critical` tone, `iconOpacity` in 0…1, and `monospaced`. Rows include id/label/command/separator/enabled plus secondary `detail`, semantic `role`, key equivalent, and all five modifier booleans. Actionable ids are unique and non-zero, and there are at most 32 rows. `commandMsg(name): Msg | null` maps row selection, status-button activation, Option-activation, and menu-open refresh into the ordinary update loop. See [System Tray](/docs/tray) and the zero-Zig `examples/menu-bar` app for the full hide/Open/Quit lifecycle.
Import the canonical records and unions from `@native-sdk/core/events`. Presentation includes byte `title`, numeric `width`, tone, `iconOpacity`, `monospaced`, `fontSize`, and `fontWeight`; `statusItems` composes several independently styled persistent menu-bar items. Rows include id/label/command/separator/enabled plus secondary `detail`, semantic `role`, key equivalent, and all five modifier booleans. Actionable ids are unique and non-zero, and there are at most 32 rows. `commandMsg(name): Msg | null` maps row selection, status-button activation, Option-activation, and menu-open refresh into the ordinary update loop. See [System Tray](/docs/tray).
Export `statusItems(model): readonly StatusItemDescriptor[]` when the app needs several independent items. Each descriptor adds stable non-zero `id` identity and a live `visible` flag to the same shell/presentation/menu record. Adding/removing descriptors creates/removes only those ids; icon, title, tooltip, visibility, activation/open commands, and menu changes patch in place. Export either the singular or collection helper, not both. macOS supports up to eight simultaneous items; every item keeps its own 32-row menu.
## Model-declared secondary windows
Export `windows(model): readonly WindowDescriptor[]` to derive the live secondary-window set from model state. Construct entries with `windowDescriptor` from `@native-sdk/core`, import `WindowDescriptor` from `@native-sdk/core/events`, and put each window's markup at `src/windows/<label>.native`. Spell the constructor label as a literal `label: asciiBytes("<label>")`; `native check` and every build reject dynamic labels or a label without that matching root. Window roots can import shared components nested under `src/windows/`; the generated launcher embeds and hot-reloads the complete import closure. Adding/removing descriptors creates/closes only those windows; all open windows rebuild from the same committed model.
`closePolicy` accepts `"quit"` (the default) or `"hide"`. A `"quit"` user close routes `onCloseCommand` through `commandMsg`, where the app maps it to the Msg that clears its open flag. A `"hide"` close retains the same native window and view and dispatches no close command; `Cmd.showWindow(label)` reveals it. Model-declared secondary windows are desktop-only. See `examples/system-monitor-ts`.
`titlebar` accepts `"standard"`, `"hidden_inset"`, `"hidden_inset_tall"`, or `"chromeless"`. Transparent Windows windows require `"chromeless"`; because that removes the system buttons, fully skinned windows must draw working close/minimize controls.
## Subscriptions are Sub data
@@ -503,7 +536,7 @@ A markup text control (`<text-field text="{draft}" on-input="draft_edit" />`) ne
## Splitting a core into modules
A core that outgrows one file splits into modules under `src/`: relative imports spelled with their real filenames (`./parsers.ts` — the same file runs under node, whose loader resolves real files), `src/` as the hard boundary (`../` and npm packages are teaching errors), and no runtime cycles (`import type` back-edges are fine and idiomatic — a helper module typically type-imports `Model` from the entry). Export lists and value re-exports are ordinary module surface: `export { helper, doneCount as remaining }` binds names over existing declarations, and `export { parsePs } from "./parsers.ts"` forwards another module's export by name — what stays out is `export default`, `export =`, and `export * from` (the core's flat namespace resolves by name, so every export names what it binds). `core.ts` stays the entry module and the app's public face: `update`, `initialModel`, `subscriptions`, the wiring channels, `themePack` / `statusItem`, and the exported binding helpers live there (declared and exported under their own names — a rename or re-export cannot bind an entry point), and imported modules hold the machinery they call. The SDK also ships library modules in the same subset — `@native-sdk/core/text` is the byte-splice text engine (caret, selection, IME composition, ASCII case-insensitive compare), and `@native-sdk/core/events` is the canonical event and shell vocabulary (`TextInputEvent` re-exported, `ScrollState`, `FrameEvent`, `KeyEvent`, `PinchPhase`/`PinchEvent`, `ColorScheme`, the chrome records, `AudioState`/`AudioEvent`, and the status-item state/presentation/row/modifier records and unions) so no core re-types it — compiled into your core when imported and absent when not.
A core that outgrows one file splits into modules under `src/` except `src/services/`: relative imports spelled with their real filenames (`./parsers.ts` — the same file runs under node, whose loader resolves real files), `src/` as the hard boundary (`../` and npm packages are teaching errors), and no runtime cycles (`import type` back-edges are fine and idiomatic — a helper module typically type-imports `Model` from the entry). The core may not import service files, even type-only; shared subset-legal shapes live in an ordinary core-class module which a service may import. Export lists and value re-exports are ordinary module surface: `export { helper, doneCount as remaining }` binds names over existing declarations, and `export { parsePs } from "./parsers.ts"` forwards another module's export by name — what stays out is `export default`, `export =`, and `export * from` (the core's flat namespace resolves by name, so every export names what it binds). `core.ts` stays the entry module and the app's public face: `update`, `initialModel`, `subscriptions`, the wiring channels, `themeState` / `themePack` / `statusItem` / `statusItems` / `windows`, and the exported binding helpers live there (declared and exported under their own names — a rename or re-export cannot bind an entry point), and imported modules hold the machinery they call. The SDK also ships library modules in the same subset — `@native-sdk/core/text` is the byte-splice text engine (caret, selection, IME composition, ASCII case-insensitive compare), and `@native-sdk/core/events` is the canonical event and shell vocabulary (`TextInputEvent` re-exported, `ScrollState`, `FrameEvent`, `KeyEvent`, `PinchPhase`/`PinchEvent`, `ColorScheme`, `ThemeState`, the chrome records, `AudioState`/`AudioEvent`, status-item records, and `WindowDescriptor`) so no core re-types it — compiled into your core when imported and absent when not.
<CodeToggle>
@@ -530,26 +563,52 @@ pub fn parseSample(bytes: []const u8) ?Sample { ... }
</CodeToggle>
## TypeScript services
The core is the app's deterministic logic — `Model`, `Msg`, `update`; services do the app's imperative work. A service operation is a directly exported, non-default named synchronous function under `src/services/`, taking zero or one explicitly typed request and declaring a contract-encodable result. Crossing shapes live in an exported, subset-legal module outside `src/services/` so the core and service import one declaration. The operation name is `<module-basename>.<export>`; `native check` projects its complete type table into `services.contract.json`, checks both classes, and generates the typed core client:
```ts:src/core.ts
import { feedsParse } from "@native-sdk/services";
case "parse":
return [
model,
feedsParse({ source: model.source, caseSensitive: false }, {
key: "parse",
ok: "parsed", // one ParseResult field
err: "parse_failed", // one Uint8Array field
}),
];
```
The core never receives a synchronous handle. Its update returns a command, the typed result crosses back as the named Msg arm, and the runtime journals that result like every other effect. Replay parks the request and feeds the recorded result without starting the carrier. The service itself is ordinary static-tier TypeScript — Node built-ins, `fetch`, regexes, JSON, `Map`/`Set`, `Date`, classes — running with the app's privileges on a supervised, lazily started carrier: a sibling child process with a sanitized environment by default, or an explicitly selected in-process worker-thread pool. Writing operations, kind-tagged error throws, streaming and cancellation, exact vendored npm, and the boundary rules NS1065NS1067 have their own chapter: [TypeScript Services](/docs/typescript/services).
## The dev loop
`native dev --core` is the fastest loop for logic work: the core runs under node with a virtual host — dispatch Msgs as JSON lines (`{"kind":"add"}`, `{"$bytes":"…"}` for bytes payloads), advance a virtual clock (`{"advance":1000}`) to fire timers deterministically, and watch the committed model and effect transcript. Effects the virtual host does not perform (files, fetch, spawn) print as `cmd ...` lines — feed their results back yourself as ordinary Msg lines; that is the point, results are plain messages. Pair `--script msgs.ndjson` with `--watch` to replay a scenario on every edit. [Quick Start](/docs/quick-start#the-fastest-loop-the-core-under-node) shows a full transcript.
`native dev --core` is the fastest loop for logic work: the core runs under Node with a virtual host — dispatch Msgs as JSON lines (`{"kind":"add"}`, `{"$bytes":"…"}` for bytes payloads), advance a virtual clock (`{"advance":1000}`) to fire timers deterministically, and watch the committed model and effect transcript. Service requests run in an isolated Node worker through the same generated contract: vendored hashes are verified, request/results use the same codecs and error arms, cooperative cancellation/deadlines interrupt CPU-bound work, and stream chunks use the same channel-event shape. Pair `--script msgs.ndjson` with `--watch` to replay a scenario on every edit. The devhost also consumes `NATIVE_SDK_SESSION_RECORD`/`NATIVE_SDK_SESSION_REPLAY` (the environment set by `native automate record|replay`): it writes the native journal format, and replay starts no service worker. Service-only recordings cross between it and the packaged runtime; packaged recordings containing other effect families use `native automate replay`, and devhost rejects those records explicitly. `native dev` runs compiled services for real beside the native app. [Quick Start](/docs/quick-start#the-fastest-loop-the-core-under-node) shows a full transcript.
`native dev` keeps markup instant — `.native` edits hot-reload into the running window — but a `src/core.ts` edit rebuilds the core through the external core compiler and restarts the app: seconds per rebuild (roughly 3-6s warm), not sub-second. The core loop in the real window is restart-shaped; keep logic iteration under `native dev --core` and rebuild when you want to see it live.
`native check` runs the subset checker (real tsc semantics plus the app-core rules) over `src/core.ts` and its whole import graph (diagnostics carry each module's own path), then validates markup and `app.zon`. Every diagnostic names the rule, the idiomatic rewrite, and the reason — write to them up front and the loop stays fast.
`native check` runs the subset checker (real tsc semantics plus the app-core rules) over the core class, emits and validates the service contract, runs the pinned compiler's coverage verdict over each independent service root, then validates markup and `app.zon`. Every diagnostic names the rule, the idiomatic rewrite, and the reason — write to them up front and the loop stays fast.
## Build targets
Builds compile everything in the app — the core archive, any service executables or in-process archives, and the runner — for one stated target. The default is the build host; `-Dtarget` selects a cross desktop target following the pinned compiler's build matrix: Linux and Windows GNU targets build from any macOS, Linux, or Windows host, and macOS targets build on a macOS host (Apple linking needs the host toolchain's SDK). A Windows MSVC target builds natively on a matching Windows host; cross-Windows builds use the GNU ABI because Zig supplies that target's CRT and system libraries. An explicitly spelled Linux `-gnu` target also states its glibc version — `x86_64-linux-gnu.2.36` or later, or `x86_64-linux-musl` — because the compiled runtime needs glibc 2.36+ (a bare `-gnu` spelling lands on Zig's older default floor and is refused with the same teaching). The executable name and packaging follow the target OS.
Mobile targets compile the same core as a static archive merged into the mobile embed library, which `native dev|package --target ios|android` link into the toolkit hosts exactly as they do for Zig cores. The mobile matrix is aarch64 only: `aarch64-ios` and `aarch64-ios-simulator` build on a macOS host against the selected Apple SDK with an iOS 15.0 floor, and `aarch64-linux-android` builds on any desktop host against an installed NDK (`ANDROID_NDK_ROOT`, or the newest `ndk/<version>` under the SDK) with an API 26 floor. Services on mobile run only on the in-process pool — mobile apps cannot spawn a sibling process — so `service_carrier = "auto"` resolves to the pool there and an explicit `"child"` is refused with a teaching; desktop builds of the same app keep the child carrier under `auto`. The vendored npm lane is unchanged. Model persistence (`persist`), boot images, and URL media caching are not wired on mobile yet.
## Editor support
Editor support is stock tsc — no extension, no plugin. The scaffold ships `package.json` and `tsconfig.json` as the editor-and-versioning surface: the tsconfig mirrors the compiler options the checker itself builds its program with (strict, `moduleResolution: "bundler"`, `verbatimModuleSyntax`, `exactOptionalPropertyTypes`, …), so what your editor flags is what `native check` flags, and `@native-sdk/core` (plus subpaths like `@native-sdk/core/text`) resolves through `node_modules` like any package. Until `@native-sdk/core` is published to npm, the CLI materializes that `node_modules` copy itself — exactly the files the published package will contain — and `native check`/`dev`/`build` keep it fresh against the SDK (`native doctor` reports skew). After the publish, a plain `npm install` writes identical content and takes over. None of it is build truth: builds check and compile against the SDK the CLI ships with and never read `node_modules` — delete it and every `native` verb still works.
Editor support is stock tsc — no extension, no plugin. The scaffold ships `package.json` and `tsconfig.json` as the editor-and-versioning surface: the tsconfig mirrors the compiler options the checker itself builds its program with (strict, `moduleResolution: "bundler"`, `verbatimModuleSyntax`, `exactOptionalPropertyTypes`, …), so what your editor flags is what `native check` flags, and `@native-sdk/core` (plus subpaths like `@native-sdk/core/text`) resolves through `node_modules` like any package. Apps with services also receive an ignored `node_modules/@native-sdk/services` editor package whenever `native check` or `native dev --core` regenerates the typed client; authored `src/` stays clean. Until `@native-sdk/core` is published to npm, the CLI materializes that package copy itself — exactly the files the published artifact will contain — and `native check`/`dev`/`build` keep it fresh against the SDK (`native doctor` reports skew). After the publish, a plain `npm install` writes identical core content and takes over. None of it is build truth: builds check and compile against the SDK the CLI ships with and never read `node_modules` — delete it and every `native` verb still works.
## Outgrowing the subset
The compiled core is a native static archive, not generated source: `native check` checks and leaves nothing behind, and there is no emitted Zig to read or adopt. If an app outgrows the subset, the migration path is porting the core to Zig by hand — the [App Model](/docs/app-model) page covers the Zig wiring, and the wiring the build generates for a TypeScript app doubles as the port's blueprint: same loop, same runtime, so the port is a translation of `update` and friends, not a redesign.
The compiled core is a native static archive, not generated source: `native check` checks and leaves nothing behind, and there is no emitted Zig to read or adopt. If logic needs ambient APIs or ordinary static-tier TypeScript, keep deterministic state transitions in the core and move that work into `src/services/`. Port the core to Zig only when the logic tier itself needs capabilities outside both TypeScript classes; the [App Model](/docs/app-model) page covers that wiring.
## Where the subset ends
The core tier covers app logic. The toolkit-extension tier — custom widgets, rasterizer work, host services, platform integration — is Zig by design: that layer is the machinery itself, and [Building Components](/docs/building-components) is its guide. A TypeScript app that needs one custom widget does not switch tiers wholesale; the widget is Zig, the core stays TypeScript.
The core owns app state and decisions — the app's deterministic logic. Services own imperative application work in ordinary TypeScript: parsing, filesystem transforms, environment inspection, subprocesses, and other ambient operations whose results cross back as messages. The toolkit-extension tier — custom widgets, rasterizer work, new engine-owned effects, and platform integration — remains Zig by design: that layer is the machinery itself, and [Building Components](/docs/building-components) is its guide. Services are not a backdoor storage engine or general FFI surface.
## Reference
The complete authoring guide — every rule ID, every Cmd shape, every subset corner with its idiom — ships as the `ts-core` agent skill: `native skills get ts-core`. It is written for AI agents and precise enough for humans.
The complete core guide ships as `native skills get ts-core`; typed service contracts, vendored npm, streaming, authority, and transport limits ship as `native skills get ts-services`. Both are written for AI agents and precise enough for humans.
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("typescript/services");
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}
@@ -0,0 +1,383 @@
# TypeScript Services
Modules under `src/services/` are ordinary TypeScript compiled to native code on the compiler's full static tier: `fs`, `path`, `process`, `os`, `child_process`, `fetch`, regexes, `JSON`, `Map`/`Set`, `Date`, and classes, when the pinned compiler supports them. The same pinned compiler builds the deterministic core ([TypeScript Cores](/docs/typescript)) and the services; no JavaScript engine ships in either.
The core calls a service by returning a command from `update`. The typed result returns as an ordinary `Msg`:
```ts:src/core.ts
import { feedsParse } from "@native-sdk/services";
case "parse":
return [model, feedsParse({ source: model.source, caseSensitive: false }, {
key: "parse",
ok: "parsed", // the one Msg arm carrying ParseResult
err: "parse_failed", // a one-Uint8Array-field arm
})];
```
Services run on a supervised carrier — on desktop, a separate child process by default or an explicitly selected worker-thread pool compiled into the app binary; on iOS and Android, the in-process pool only (see [Runtime behavior](#runtime-behavior)).
## The two roles
The split is by role: the core is the app's deterministic logic — `Model`, `Msg`, `update` — and services do the app's imperative work. Record→replay, headless testing, and [automation](/docs/automation) depend on `update` being a pure function of its inputs. A service reads the real filesystem, clock, and network, so the checker refuses a core import of a service file (NS1065) and the core-to-service edge is always a command. Service results are journaled like every other effect result.
<table>
<thead>
<tr>
<th>Role</th>
<th>Owns</th>
<th>Language rules</th>
</tr>
</thead>
<tbody>
<tr>
<td>Core (<code>src/core.ts</code> + imports outside <code>src/services/</code>)</td>
<td>App state and decisions: <code>Model</code>, <code>Msg</code>, <code>update</code>, pure helpers</td>
<td>The deterministic subset (NS1001NS1064)</td>
</tr>
<tr>
<td>Service (<code>src/services/**/*.ts</code>)</td>
<td>Imperative work: parsing, filesystem transforms, environment inspection, subprocesses</td>
<td>Ordinary static-tier TypeScript; only the boundary rules NS1065NS1067 apply</td>
</tr>
</tbody>
</table>
Services are not a storage engine or a general FFI surface. Durable data uses the engine-owned [persistence](/docs/persistence) and [record store](/docs/record-store) effects; custom widgets, render passes, and new engine capabilities are Zig ([Building Components](/docs/building-components)).
## Service authority
A service runs with the app's privileges. Its working directory is the app data directory. It may use:
- **Filesystem** — Node built-ins over the real disk.
- **Environment** — `process` and the allowlisted variables below.
- **Network** — `fetch` and sockets, directly.
- **Ambient time and randomness** — `Date.now()`, `Math.random()`, and friends. Their results reach the core only as journaled message payloads.
The child process receives an explicit environment allowlist; everything else, including every `NATIVE_SDK_*` internal, is stripped.
<table>
<thead>
<tr>
<th>Group</th>
<th>Variables</th>
</tr>
</thead>
<tbody>
<tr>
<td>Path</td>
<td><code>PATH</code></td>
</tr>
<tr>
<td>Home / user / temp</td>
<td><code>HOME</code>, <code>USER</code>, <code>TMPDIR</code>, <code>TMP</code>, <code>TEMP</code></td>
</tr>
<tr>
<td>Locale / time zone</td>
<td><code>LANG</code>, <code>LC_ALL</code>, <code>LC_CTYPE</code>, <code>TZ</code></td>
</tr>
<tr>
<td>Certificates</td>
<td><code>SSL_CERT_FILE</code>, <code>SSL_CERT_DIR</code></td>
</tr>
<tr>
<td>Proxies</td>
<td><code>HTTP_PROXY</code>, <code>HTTPS_PROXY</code>, <code>NO_PROXY</code></td>
</tr>
<tr>
<td>Windows additions</td>
<td><code>USERPROFILE</code>, <code>USERNAME</code>, <code>SystemRoot</code>, <code>COMSPEC</code>, <code>PATHEXT</code>; all names match case-insensitively</td>
</tr>
</tbody>
</table>
Standard output carries the framed transport between app and service, so service diagnostics go to standard error.
## Writing a service
A service module is any `.ts` file under `src/services/`. Every directly exported, non-default named function is an operation:
- It is synchronous and has a body.
- It takes zero or one explicitly annotated request parameter.
- It declares a contract-encodable result type.
- Its name is `<module-basename>.<export>` — `export function parse` in `src/services/feeds.ts` is `feeds.parse`.
Boundary shapes live in a shared, subset-legal module outside `src/services/`, imported by the core and the service:
```ts:src/shared.ts
export type ParseRequest = {
readonly source: Uint8Array;
readonly caseSensitive: boolean;
};
export type ParseResult = {
readonly bytes: Uint8Array;
readonly matches: boolean;
};
```
```ts:src/services/feeds.ts
import * as fs from "node:fs";
import type { ParseRequest, ParseResult } from "../shared.ts";
export function parse(request: ParseRequest): ParseResult {
if (!fs.existsSync(".")) {
throw { kind: "data_directory_missing", message: "the app data directory is unavailable" };
}
const source = new TextDecoder().decode(request.source);
const matches = request.caseSensitive ? /feed/.test(source) : /feed/i.test(source);
return { bytes: new TextEncoder().encode(JSON.stringify({ matches })), matches };
}
```
`native check` projects the complete type table into a contract sidecar (`services.contract.json`), checks both classes, and generates the typed client the core imports. An operation shaped any other way — `async`, a default export, an unannotated request, a non-encodable result — is a teaching error (NS1067) naming the rewrite.
### Boundary types
<table>
<thead>
<tr>
<th>Crosses</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr>
<td>Booleans, numbers</td>
<td>Integer-class fields are proven and carried as integers</td>
</tr>
<tr>
<td><code>Uint8Array</code></td>
<td>The bytes form the core and services already share</td>
</tr>
<tr>
<td>Optionals, readonly slices</td>
<td><code>T | null</code> and <code>readonly T[]</code> of encodable elements</td>
</tr>
<tr>
<td>Named records, enums, kind-tagged unions</td>
<td>Declared in the shared module; both sides import the one declaration</td>
</tr>
<tr>
<td>Functions, behavior-bearing classes, Promises</td>
<td>Do not cross — the boundary is encoded data, not object references</td>
</tr>
</tbody>
</table>
Inside the service, classes, `Map`s, and the rest of the static tier are unrestricted; they just cannot be a request or result shape.
### Errors
An explicit throw crossing the operation boundary must be exactly an inline `{ kind: "...", message: "..." }` shape with a string-valued message, and it must escape the operation rather than be caught locally:
```ts
throw { kind: "parse", message: "bad feed" };
```
The encoded kind and message arrive on the core's error arm as UTF-8 JSON bytes. Do not throw `new Error(...)` from the exported surface. The build mechanically lowers the escaping tagged value into the form the pinned compiler carries across the boundary; your checked-in source — and its behavior under Node — does not change.
## Calling a service
`native check` derives the virtual module `@native-sdk/services` from the contract: one constructor per operation, named `<module><Export>` (`feeds.parse` → `feedsParse`), taking the typed request plus a route.
```ts:src/core.ts
import { feedsParse } from "@native-sdk/services";
import type { ParseRequest, ParseResult } from "./shared.ts";
export type Msg =
| { readonly kind: "parse"; readonly request: ParseRequest }
| { readonly kind: "parsed"; readonly result: ParseResult }
| { readonly kind: "parse_failed"; readonly error: Uint8Array };
case "parse":
return [model, feedsParse(msg.request, {
key: "feed-parse",
ok: "parsed",
err: "parse_failed",
})];
```
The route is typechecked: the constructor's type proves that `ok` names the one Msg arm carrying exactly the declared result record and that `err` names a one-bytes-field arm. A stale field or wrong route is a `native check` type error at the call site. The generated source lives only in build scratch space and the ignored editor package under `node_modules/@native-sdk/services`, never in authored `src/`.
Raw `Cmd.request("feeds.parse", bytes, { key?, ok, err })` remains the low-level byte seam beneath the client — same transport, same routing, request and result as raw bytes you encode yourself.
### Keys
Keys share the engine effect-key space. A second live request on the same key — buffered or streaming — is rejected (`err` receives `rejected`) rather than replacing the first, so two calls can never splice into one result. Cancel the first if you mean to supersede it.
### Timeouts
Every request carries a deadline: 30 seconds by default, or the operation's declared `@deadlineMs` (a JSDoc tag, 1 to 86400000 ms). Expiry routes JSON with `kind: "timeout"` to `err`.
### Cancellation
`Cmd.cancel(key)` on a buffered request drops it — no message is dispatched — and cooperatively interrupts the service child. Cancelling a stream routes `cancelled` to `err` (see [Streaming](#streaming)).
## Streaming
To return incremental results, declare a final typed `emit` capability. Each chunk arrives through a channel-event Msg arm; the function's return stays the one typed terminal result.
```ts:src/services/feeds.ts
import type { ServiceCancellation } from "@native-sdk/core";
import type { ParseChunk, ParseRequest, ParseResult } from "../shared.ts";
/**
* @deadlineMs 5000
* @streamBuffer 8
*/
export function parseLarge(
request: ParseRequest,
emit: (chunk: ParseChunk) => void,
cancellation: ServiceCancellation,
): ParseResult {
for (let index = 0; index < request.source.length; index += 4096) {
cancellation.throwIfCancelled();
emit({ bytes: request.source.slice(index, index + 4096), index });
}
return parse(request);
}
```
The generated route gains two fields beside `key`, `ok`, and `err`: `channelKey` (an app-chosen numeric channel key) and `event` (the channel-event Msg arm each chunk dispatches). The terminal result closes the channel after all accepted chunks. `@streamBuffer` caps in-flight chunks at 164 (default 8).
### Cooperative cancellation
An optional final `ServiceCancellation` parameter opts an operation into cooperative cancellation — legal only as the last parameter. Poll `cancelled()` or call `throwIfCancelled()` at bounded intervals.
- `Cmd.cancel(key)` on a stream flips the token, closes the channel, routes `cancelled` to `err`, and drops every later chunk.
- A deadline expiry flips the same token and routes `kind: "timeout"` to `err`.
- The child gets a short grace period to unwind and stays alive when it cooperates. An operation that ignores its token is hard-killed, and the next request starts a clean host.
## npm packages
Service modules may import local service files, shared core-class declarations, and exact vendored npm packages — never a bare install:
```bash
native vendor . escape-string-regexp@5.0.0
```
The command resolves the exact version once (lifecycle scripts disabled), copies the flattened package graph and license files into `src/services/vendor/`, and writes the exact name/version/tree-hash facts into `app.zon`. Check both in. Builds are hermetic: no npm, no network — every vendored byte is re-hashed, and the compiler receives only the explicit declared package allowlist. Importing a package that was never vendored is NS1066:
> Run `native vendor . package@X.Y.Z`, check in `src/services/vendor/` and the generated app.zon `service_packages` facts, then import that exact package name; or vendor a local source module and import it relatively.
npm support is selective. A vendored package compiles only if the pinned compiler reaches 100% static coverage of its bytes; anything less fails `native check` with the compiler's coverage note preserved verbatim and a remediation. The shipped compiler's calibration run over five deliberately small candidates passed three and refused two:
<table>
<thead>
<tr>
<th>Package</th>
<th>Verdict</th>
<th>Static coverage</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>escape-string-regexp@5.0.0</code></td>
<td>compiled</td>
<td>100%</td>
</tr>
<tr>
<td><code>comma-separated-tokens@2.0.3</code></td>
<td>compiled</td>
<td>100%</td>
</tr>
<tr>
<td><code>space-separated-tokens@2.0.2</code></td>
<td>compiled</td>
<td>100%</td>
</tr>
<tr>
<td><code>nanoid@3.3.15</code></td>
<td>refused</td>
<td>76%</td>
</tr>
<tr>
<td><code>micromark@4.0.2</code></td>
<td>refused</td>
<td>92%</td>
</tr>
</tbody>
</table>
Small, source-shipping, dependency-light utilities are the realistic fit. There is no `auto` mode or dynamic fallback; `native check` is the verdict for the exact bytes you vendored, and a refusal names the options: choose another exact package, port or vendor a suitable implementation, or wait for broader compiler support. Source you control — your own modules under `src/services/` — compiles on the same tier with no coverage question. For npm-heavy work that does not compile statically (an editor component, a charting stack), use a different edge: see [Where Packages Go](/docs/typescript/packages).
## Runtime behavior
Two carriers run the same operations behind the same routes, keys, deadlines, cancellation, streaming, and replay semantics. The build selects one:
<table>
<thead>
<tr>
<th>Carrier</th>
<th>Where services run</th>
<th>Selection</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>child</code></td>
<td>A second native executable — <code>&lt;app&gt;_services</code> — beside the app binary, packaged with it</td>
<td>Unset/<code>auto</code> default on desktop; unavailable on mobile</td>
</tr>
<tr>
<td><code>in_process</code></td>
<td>Compiled into the app binary; a small thread pool, one isolated module instance per thread</td>
<td>Explicit opt-in on native Linux, cross-Linux x86_64/aarch64, native Windows x86_64, cross-Windows x86_64 GNU, or macOS built on macOS; unset/<code>auto</code> default on iOS and Android</td>
</tr>
</tbody>
</table>
`.service_carrier = "in_process" | "child"` in app.zon (or `-Dservice-carrier`) states the choice. Unset/`"auto"` selects the child carrier on desktop and the in-process pool on iOS/Android, where a child process is unavailable. `.service_pool_size` (or `-Dservice-pool-size`, 1-16) sets the in-process pool width; the default is min(4, cores).
Shared guarantees:
- **Lazy start.** Nothing starts before the first real request — no child process, no pool thread. A session that never calls a service pays nothing.
- **Verified pairing.** The child's startup handshake checks the protocol version and a fingerprint of the generated operation/type/package registry; the in-process carrier checks the same fingerprint against the linked archive. A mismatch rejects before any operation dispatches.
- **Supervision.** Same-key requests run strictly FIFO. The in-process pool runs different keys in parallel across its instances; the child runs everything on one worker. A cancellation or deadline publishes the cooperative token and grants a short grace: an operation that returns inside it keeps its instance (or process) warm. Past the grace, the child is killed and respawns on the next request; the in-process carrier abandons the instance's thread, routes the failure, and adds a fresh instance to the pool. An abandoned dispatch keeps its key reserved until it physically stops, so a same-key replacement cannot overlap its side effects (and can itself expire while waiting). A detected trap poisons only the instance it fired in (`kind: "service_trap"`); other instances keep answering. Every failure produces a routed result: a dead transport `kind: "service_host"`, an expired deadline `kind: "timeout"`.
- **Replay.** Terminal results and stream events are journaled like every other effect. Replaying a recorded session parks each request and feeds the recorded result; neither carrier starts anything.
- **Scope.** Child executables are desktop-only and follow the pinned compiler's broad matrix: same-platform builds, Linux and Windows GNU targets cross-compiled from a macOS/Linux/Windows host, and macOS targets built on macOS. A Windows MSVC target builds natively on a matching Windows host; cross-Windows uses GNU because Zig supplies that target's CRT and system libraries. In-process archives use the compiler's narrower object-localization matrix: native Linux, cross-Linux x86_64/aarch64 (`aarch64-linux-android` included, API 26 floor, NDK required), native Windows x86_64, cross-Windows x86_64 GNU, or the Mach-O targets — macOS, `aarch64-ios`, and `aarch64-ios-simulator` (iOS 15.0 floor) — built on macOS. Mobile targets are archive-only: no sibling process exists there, so `service_carrier = "auto"` resolves to the in-process pool and an explicit `"child"` is refused with a teaching. A pairing outside the relevant matrix fails with a teaching, as does any explicitly spelled Linux `-gnu` target without a glibc version — even when it matches the build host, Zig's target uses its default floor. The service runtime needs glibc 2.36+ or musl, so explicit Linux targets are spelled `x86_64-linux-gnu.2.36` (or later) or `x86_64-linux-musl`. Operations are synchronous.
In-process specifics:
- Service code shares the app process: its ambient authority is the app's own (no environment allowlist, the app's working directory), and a hardware fault in service code — a stack overflow above all — is process-wide. The child carrier remains the fully isolated option.
- Each pool worker owns a separate instance of the service modules. Mutable module globals are worker-local, so different-key requests may observe different copies; keep shared durable state outside service-module globals.
- An abandoned instance's memory is reclaimed only at process exit; each trap or ignored token costs one leaked instance.
- `process.exit()` in service code exits the app.
## Development
`native dev --core` runs service operations in an isolated Node worker through the same generated contract: the same vendored-package hash verification, request/result codecs, error arms, cooperative cancellation and deadlines, and channel-event chunk shape. Pair `--script scenario.ndjson` with `--watch` for repeatable iteration.
The devhost honors session record/replay the same way the packaged runtime does — replay starts no service worker — and service-only recordings cross between the devhost and the packaged app. `native dev` runs the app with its build-selected carrier: the in-process pool linked into the binary, or the compiled service executable beside it.
## Boundary diagnostics
Three checker rules enforce the boundary. Each teaches the fix and the reason at the site.
<table>
<thead>
<tr>
<th>Rule</th>
<th>Teaching</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>NS1065</strong> — the core does not import services</td>
<td>A direct import would run ambient, non-deterministic service authority inside update and erase the command/result boundary that journaling and replay depend on. The core-to-service edge is always an effect.</td>
</tr>
<tr>
<td><strong>NS1066</strong> — service package imports are exact vendored facts</td>
<td>Service builds have no package-manager or network input: the compiler sees only manifest-declared, hash-verified checked-in sources through an explicit static-package allowlist.</td>
</tr>
<tr>
<td><strong>NS1067</strong> — service calls match the generated typed contract</td>
<td>The host codecs, runner registry, and typed client are projections of <code>services.contract.json</code>; every crossing data shape, stream declaration, deadline, and operation name must be stated there once.</td>
</tr>
</tbody>
</table>
## Reference
[`examples/service-feed-reader`](https://github.com/vercel-labs/native/tree/main/examples/service-feed-reader) is the complete loop as a small app: `Cmd.fetch` downloads a feed, the delivered bytes cross to `feeds.parse` through the generated typed client, the service's regex-and-`Map` parser returns shared `FeedResult` records the markup renders, and malformed input lands on the err arm as kind-tagged JSON. Its end-to-end suite (`tests/ts-services/feed_reader_e2e_tests.zig` in the SDK repo) records the whole loop against a loopback HTTP fixture and replays it byte-identically with the service executable absent. The machine-precise authoring guide ships as `native skills get ts-services`.
+10 -5
View File
@@ -8,7 +8,6 @@ const shell_windows = [_]native_sdk.ShellWindow{.{
.title = "My App",
.width = 480,
.height = 320,
.restore_state = false,
.views = &shell_views,
}};
const shell_scene: native_sdk.ShellConfig = .{ .windows = &shell_windows };
@@ -16,6 +15,8 @@ const shell_scene: native_sdk.ShellConfig = .{ .windows = &shell_windows };
The first scene window adopts the startup window; additional scene windows are created through the window service. Secondary windows can also be created imperatively from Zig — or, in apps that [embed web content](/docs/frontend), from trusted JavaScript.
Fresh windows use three-way placement. A successful state-store lookup restores the saved frame. Otherwise, an authored `x` or `y` places the window at that origin (constrained to the matching or nearest visible screen on macOS). With neither saved state nor an explicit origin, macOS centers the main window. Under the default `clamp_to_visible_screen` policy, restored frames stay on their matching or nearest display and fresh secondary windows cascade from the active window; `center_on_primary` centers restored and fresh default frames on the primary display. `restore_state = true` is only the persistence opt-in—it no longer makes a first launch look like a restored frame at `(0, 0)`.
## Overlay windows
Window creation accepts four overlay controls:
@@ -35,7 +36,6 @@ const overlay: native_sdk.ShellWindow = .{
.always_on_top = true,
.click_through = true,
.activate_on_show = false,
.restore_state = false,
.views = &.{.{
.label = "overlay-canvas",
.kind = .gpu_surface,
@@ -68,6 +68,7 @@ const info = try runtime.createWindow(.{
.label = "tools",
.title = "Tools",
.default_frame = native_sdk.geometry.RectF.init(80, 80, 420, 320),
.initial_placement = .explicit,
});
try runtime.focusWindow(info.id);
```
@@ -155,7 +156,7 @@ JavaScript creation also accepts `titlebar: "standard" | "hidden_inset" | "hidde
</tr>
<tr>
<td><code>WindowRestorePolicy</code></td>
<td>How restored frames are placed, such as clamping to the visible screen or centering on the primary display</td>
<td>How macOS places restored and fresh default frames: clamp to the matching/nearest visible screen or center on the primary display</td>
</tr>
<tr>
<td><code>WindowClosePolicy</code></td>
@@ -169,9 +170,13 @@ JavaScript creation also accepts `titlebar: "standard" | "hidden_inset" | "hidde
Each window declares what the user's close affordance (the red traffic light, cmd+W, the caption X) does, via `close_policy`:
- `"quit"` — the default and the classic behavior, unchanged for every existing app: the window really closes, and closing the last one follows the host's exit semantics.
- `"hide"` — the menu-bar/tray-app shape: the close affordance hides the window (it stays alive with its views; `WindowState.hidden` flips true on the frame channel, in the session journal, and in the `hidden` field of the window JSON the `window.zero.windows.*` bridge returns — `open` stays true, so `hidden` is what distinguishes a policy-hidden window from a visible unfocused one) and the app keeps running behind its status item. `fx.showWindow(label)` brings it back — a tray "Open" row's natural consequence — and on macOS the Dock-icon reopen re-shows it on its own. Runtime-initiated closes (`fx.closeWindow`, reconcile closes) still really close: the policy governs the USER's affordance, not the app's own decisions.
- `"hide"` — the menu-bar/tray-app shape: the close affordance hides the window (it stays alive with its views; `WindowState.hidden` flips true on the frame channel, in the session journal, and in the `hidden` field of the window JSON the `window.zero.windows.*` bridge returns — `open` stays true, so `hidden` is what distinguishes a policy-hidden window from a visible unfocused one) and the app keeps running behind its status item. `Cmd.showWindow(label)` brings it back — a tray "Open" row's natural consequence — and on macOS a visible Dock icon provides a second reopen path. Runtime-initiated closes (`Cmd.closeWindow`, reconcile closes) still really close: the policy governs the USER's affordance, not the app's own decisions.
`hide` is supported where the host can re-show a hidden window: macOS (`windowShouldClose` orders the window out; the Dock reopen path always exists) and Windows (`WM_CLOSE` hides via `SW_HIDE`; the tray icon is the ONLY re-show affordance, so on Windows `close_policy = "hide"` additionally requires the `"tray"` capability in app.zon — hiding removes the taskbar entry and Windows has no Dock-style reopen, so a declaration without the tray is refused at build time and at window create, exactly like Linux's refusal below; and if the declared tray fails to install at runtime, the first hide-close downgrades to a real close with a loud log rather than stranding an invisible process). Linux has no status item in this toolkit yet, so nothing could bring a hidden window back — the declaration is refused loudly at build time (a compile error for manifest windows) and at window create (`error.UnsupportedWindowClosePolicy`), never a silent no-op. The hidden state is session-transient: it never persists to the window-state store, so every launch starts shown.
`UiApp.WindowDescriptor.close_policy` exposes the same enum for model-declared secondary windows. Descriptor presence means the window remains alive: a `.hide` user close retains its slot and views and does not dispatch `on_close`; `showWindow(label)` reveals that same window. Stopping the declaration still performs a real reconcile close.
TypeScript cores expose the same contract as `windows(model): readonly WindowDescriptor[]`, with camel-case `closePolicy`. Each descriptor uses a literal `label: asciiBytes("<label>")` and the matching `src/windows/<label>.native`; check/build reject dynamic labels and missing roots. Those roots may import components nested under `src/windows/`. `titlebar` includes `"chromeless"`, including the transparent-Windows shape described above. Under `"quit"`, `onCloseCommand` routes through `commandMsg`; under `"hide"`, no close command fires because the window did not close.
`hide` is supported where the host can re-show a hidden window: macOS (`windowShouldClose` orders the window out) and Windows (`WM_CLOSE` hides via `SW_HIDE`; the tray icon is the ONLY re-show affordance, so on Windows `close_policy = "hide"` additionally requires the `"tray"` capability in app.zon — hiding removes the taskbar entry and Windows has no Dock-style reopen, so a declaration without the tray is refused at build time and at window create, exactly like Linux's refusal below; and if the declared tray fails to install at runtime, the first hide-close downgrades to a real close with a loud log rather than stranding an invisible process). On macOS the Dock reopen path exists only while `dock_visible = true`; an Accessory app has no Dock icon, so its status item must call `Cmd.showWindow(label)` to bring the window back. This is why `dock_visible = false` requires the `"tray"` capability. Linux has no status item in this toolkit yet, so nothing could bring a hidden window back — the declaration is refused loudly at build time (a compile error for manifest windows) and at window create (`error.UnsupportedWindowClosePolicy`), never a silent no-op. The hidden state is session-transient: it never persists to the window-state store, so every launch starts shown unless the manifest explicitly sets `initially_hidden = true`.
A policy-hidden window is occluded for pacing, exactly like a minimized one: its canvas frame completions drop to the ~1 Hz occluded heartbeat instead of the display grid (a menu-bar app parks hidden for days — full-rate frames there would be pure background CPU burn), and it does not count as a visible display for `.spectrum` emissions. `fx.showWindow` restores full cadence with the window.
+6 -6
View File
@@ -54,11 +54,11 @@
},
{
"name": "checkbox",
"doc": "Value control; bind checked, dispatch with on-toggle."
"doc": "Text-bearing value control; the visible label is text content (or text=), bind checked, dispatch with on-toggle."
},
{
"name": "radio",
"doc": "Value control; bind checked or selected, dispatch with on-toggle."
"doc": "Text-bearing single-choice value control; the visible label is text content (or text=), bind checked or selected. Selection dispatches on-change when bound, then on-toggle, then on-press for compatibility."
},
{
"name": "toggle",
@@ -118,7 +118,7 @@
},
{
"name": "radio-group",
"doc": "Row container grouping radio controls; children flow horizontally."
"doc": "Logical radiogroup: give it an accessible label; descendant radios at any nesting depth share one Tab stop and selection, arrows wrap, and Home/End move to the scope edges."
},
{
"name": "tabs",
@@ -158,15 +158,15 @@
},
{
"name": "dialog",
"doc": "Modal dialog surface rendered in place; title via text, wrap in an if to show conditionally."
"doc": "Root-relative modal centered in the viewport and unaffected by ancestor scroll or clipping; title via text, wrap in an if to show conditionally."
},
{
"name": "drawer",
"doc": "Drawer surface rendered in place; title via text, wrap in an if to show conditionally."
"doc": "Root-relative modal spanning the viewport width and docked to its bottom; title via text, wrap in an if to show conditionally."
},
{
"name": "sheet",
"doc": "Sheet surface rendered in place; title via text, wrap in an if to show conditionally."
"doc": "Root-relative modal spanning the viewport height and docked to its right edge; title via text, wrap in an if to show conditionally."
},
{
"name": "resizable",
+10 -1
View File
@@ -27,10 +27,10 @@ const unprefixedNavSections: NavSection[] = [
items: [
{ name: "App Model", href: "/app-model" },
{ name: "TypeScript Cores", href: "/typescript" },
{ name: "TypeScript Services", href: "/typescript/services" },
{ name: "Where Packages Go", href: "/typescript/packages" },
{ name: "Native UI", href: "/native-ui" },
{ name: "Dynamic Images", href: "/dynamic-images" },
{ name: "Model Persistence", href: "/persistence" },
{ name: "Terminal", href: "/terminal" },
{ name: "State & Data Flow", href: "/state" },
{ name: "Theming", href: "/theming" },
@@ -38,6 +38,15 @@ const unprefixedNavSections: NavSection[] = [
{ name: "Building Components", href: "/building-components" },
],
},
{
title: "Data",
items: [
{ name: "Model Persistence", href: "/persistence" },
{ name: "Record Store", href: "/record-store" },
{ name: "Relational SQLite", href: "/sqlite" },
{ name: "Files & Streaming", href: "/files" },
],
},
{
// One entry per built-in component page, generated from the shared
// components-pages inventory (previews regenerate via
+4
View File
@@ -9,10 +9,14 @@ export const PAGE_TITLES: Record<string, string> = {
"quick-start": "Quick Start",
"app-model": "App Model",
typescript: "TypeScript Cores",
"typescript/services": "TypeScript Services",
"typescript/packages": "Where Packages Go",
"native-ui": "Native UI",
"dynamic-images": "Dynamic Images",
persistence: "Model Persistence",
"record-store": "Record Store",
sqlite: "Relational SQLite",
files: "Files & Streaming",
terminal: "Terminal",
state: "State & Data Flow",
theming: "Theming",
+257 -21
View File
@@ -1,5 +1,5 @@
//! Decoder over the app-core Cmd/Sub wire format (rt.zig, cmd_format_version
//! 3), shared by the ts-track behavioral harnesses. The graders copy this
//! 7), shared by the ts-track behavioral harnesses. The graders copy this
//! file next to each case's harness so assertions read decoded ops — "a
//! fetch with key `feed` targeting this URL", "the delay re-armed" — instead
//! of hand-built byte strings, which keeps harnesses lenient about the parts
@@ -15,10 +15,27 @@ pub const Op = union(enum) {
now: struct { msg_tag: u8 },
host: Host,
host_bytes: struct { name: []const u8, payload: []const u8 },
request: struct { name: []const u8, key: []const u8, ok_tag: u8, err_tag: u8, payload: []const u8 },
request: struct { name: []const u8, key: []const u8, ok_tag: u8, err_tag: u8, typed_service: bool, payload: []const u8 },
service_stream_request: struct {
channel_key: f64,
event_tag: u8,
max_pending: u8,
name: []const u8,
key: []const u8,
ok_tag: u8,
err_tag: u8,
payload: []const u8,
},
cancel: struct { key: []const u8 },
read_file: struct { key: []const u8, ok_tag: u8, err_tag: u8, path: []const u8 },
write_file: struct { key: []const u8, ok_tag: u8, err_tag: u8, path: []const u8, bytes: []const u8 },
append_file: struct { key: []const u8, ok_tag: u8, err_tag: u8, path: []const u8, bytes: []const u8 },
stat_file: struct { key: []const u8, ok_tag: u8, err_tag: u8, path: []const u8 },
delete_file: struct { key: []const u8, ok_tag: u8, err_tag: u8, path: []const u8 },
read_file_stream: struct { key: []const u8, chunk_tag: u8, done_tag: u8, err_tag: u8, path: []const u8 },
write_file_stream: struct { key: []const u8, ok_tag: u8, err_tag: u8, path: []const u8 },
write_file_chunk: struct { key: []const u8, ok_tag: u8, err_tag: u8, bytes: []const u8 },
write_file_close: struct { key: []const u8, ok_tag: u8, err_tag: u8 },
fetch: Fetch,
fetch_stream: FetchStream,
clip_write: struct { bytes: []const u8 },
@@ -30,17 +47,22 @@ pub const Op = union(enum) {
window_show: struct { label: []const u8 },
window_hide: struct { label: []const u8 },
dock_presence: struct { visible: bool },
store_set: struct { key: []const u8, ok_tag: u8, err_tag: u8, scope: u32, store_key: []const u8, bytes: []const u8 },
store_get: struct { key: []const u8, ok_tag: u8, err_tag: u8, scope: u32, store_key: []const u8 },
store_delete: struct { key: []const u8, ok_tag: u8, err_tag: u8, scope: u32, store_key: []const u8 },
store_scan: struct { key: []const u8, ok_tag: u8, err_tag: u8, scope: u32, prefix: []const u8, limit: u32, after: []const u8 },
store_set_many: StoreSetMany,
quit_app,
image_load: struct { id: f64, event_tag: u8, path: []const u8, url: []const u8, cache_path: []const u8, expected_bytes: f64 },
image_cancel: struct { id: f64 },
image_unregister: struct { id: f64 },
channel_open: struct { key: f64, event_tag: u8 },
channel_open: struct { key: f64, event_tag: u8, max_pending: u8 },
channel_close: struct { key: f64 },
pty_spawn: PtySpawn,
pty_write: struct { key: []const u8, bytes: []const u8 },
pty_resize: struct { key: []const u8, cols: f64, rows: f64 },
pty_kill: struct { key: []const u8 },
show_notification: struct { title: []const u8, subtitle: []const u8, body: []const u8 },
show_notification: struct { id: []const u8, title: []const u8, subtitle: []const u8, body: []const u8, action_label: []const u8, action_command: []const u8 },
audio_capture_start: struct { key: f64, source: u8, sample_rate: u32, channels: u8, event_tag: u8 },
audio_capture_stop: struct { key: f64 },
@@ -137,6 +159,26 @@ pub const Op = union(enum) {
}
}
};
pub const StoreSetMany = struct {
key: []const u8,
ok_tag: u8,
err_tag: u8,
scope: u32,
count: u32,
/// Raw entries: `[key_len u32][key][value_len u32][value]`.
entry_bytes: []const u8,
pub fn entry(self: StoreSetMany, index: usize) struct { key: []const u8, bytes: []const u8 } {
var off: usize = 0;
var i: usize = 0;
while (true) : (i += 1) {
const key = longBytes(self.entry_bytes, &off);
const bytes = longBytes(self.entry_bytes, &off);
if (i == index) return .{ .key = key, .bytes = bytes };
}
}
};
};
pub const CmdIter = struct {
@@ -178,9 +220,10 @@ pub const CmdIter = struct {
const key = shortBytes(b, &off);
const ok = b[off];
const err = b[off + 1];
off += 2;
const typed_service = b[off + 2] != 0;
off += 3;
const payload = longBytes(b, &off);
break :blk .{ .request = .{ .name = name, .key = key, .ok_tag = ok, .err_tag = err, .payload = payload } };
break :blk .{ .request = .{ .name = name, .key = key, .ok_tag = ok, .err_tag = err, .typed_service = typed_service, .payload = payload } };
},
0x06 => blk: {
const key = shortBytes(b, &off);
@@ -304,16 +347,15 @@ pub const CmdIter = struct {
off += 8;
break :blk .{ .image_unregister = .{ .id = id } };
},
// channel_open [op][key f64 LE][event_tag u8] — the bytes
// channel_open [op][key f64 LE][event_tag u8][max_pending u8] — the bytes
// rt.zig's cmdChannelOpen builds (ts_core_host.zig, 0x15).
// No max_pending rides the wire: the host opens with the
// engine default.
0x15 => blk: {
const key: f64 = @bitCast(std.mem.readInt(u64, b[off..][0..8], .little));
off += 8;
const event_tag = b[off];
off += 1;
break :blk .{ .channel_open = .{ .key = key, .event_tag = event_tag } };
const max_pending = b[off + 1];
off += 2;
break :blk .{ .channel_open = .{ .key = key, .event_tag = event_tag, .max_pending = max_pending } };
},
// channel_close [op][key f64 LE] (ts_core_host.zig, 0x16).
0x16 => blk: {
@@ -371,7 +413,7 @@ pub const CmdIter = struct {
const title = longBytes(b, &off);
const subtitle = longBytes(b, &off);
const body = longBytes(b, &off);
break :blk .{ .show_notification = .{ .title = title, .subtitle = subtitle, .body = body } };
break :blk .{ .show_notification = .{ .id = "", .title = title, .subtitle = subtitle, .body = body, .action_label = "", .action_command = "" } };
},
// audio_capture_start [op 0x1E][key f64 LE][source u8]
// [sample_rate u32 LE][channels u8][event_tag u8].
@@ -438,6 +480,24 @@ pub const CmdIter = struct {
.body = body,
} };
},
// actionable_notification [op 0x31][id/title/subtitle/body/
// action_label/action_command as u32-length bytes].
0x31 => blk: {
const id = longBytes(b, &off);
const title = longBytes(b, &off);
const subtitle = longBytes(b, &off);
const body = longBytes(b, &off);
const action_label = longBytes(b, &off);
const action_command = longBytes(b, &off);
break :blk .{ .show_notification = .{
.id = id,
.title = title,
.subtitle = subtitle,
.body = body,
.action_label = action_label,
.action_command = action_command,
} };
},
// window_hide [op 0x21][label_len u8][label].
0x21 => blk: {
const label = shortBytes(b, &off);
@@ -449,6 +509,100 @@ pub const CmdIter = struct {
off += 1;
break :blk .{ .dock_presence = .{ .visible = visible } };
},
// Atomic typed streaming-service admission.
0x28 => blk: {
const channel_key: f64 = @bitCast(std.mem.readInt(u64, b[off..][0..8], .little));
off += 8;
const event_tag = b[off];
const max_pending = b[off + 1];
off += 2;
const name = shortBytes(b, &off);
const key = shortBytes(b, &off);
const ok_tag = b[off];
const err_tag = b[off + 1];
off += 2;
const payload = longBytes(b, &off);
break :blk .{ .service_stream_request = .{
.channel_key = channel_key,
.event_tag = event_tag,
.max_pending = max_pending,
.name = name,
.key = key,
.ok_tag = ok_tag,
.err_tag = err_tag,
.payload = payload,
} };
},
0x23 => blk: {
const head = routedHead(b, &off);
const scope = readU32(b, &off);
const store_key = longBytes(b, &off);
const bytes = longBytes(b, &off);
break :blk .{ .store_set = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .scope = scope, .store_key = store_key, .bytes = bytes } };
},
0x24 => blk: {
const head = routedHead(b, &off);
const scope = readU32(b, &off);
const store_key = longBytes(b, &off);
break :blk .{ .store_get = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .scope = scope, .store_key = store_key } };
},
0x25 => blk: {
const head = routedHead(b, &off);
const scope = readU32(b, &off);
const store_key = longBytes(b, &off);
break :blk .{ .store_delete = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .scope = scope, .store_key = store_key } };
},
0x26 => blk: {
const head = routedHead(b, &off);
const scope = readU32(b, &off);
const prefix = longBytes(b, &off);
const limit = readU32(b, &off);
const after = longBytes(b, &off);
break :blk .{ .store_scan = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .scope = scope, .prefix = prefix, .limit = limit, .after = after } };
},
0x27 => blk: {
const head = routedHead(b, &off);
const scope = readU32(b, &off);
const count = readU32(b, &off);
const entries_start = off;
for (0..count) |_| {
_ = longBytes(b, &off);
_ = longBytes(b, &off);
}
break :blk .{ .store_set_many = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .scope = scope, .count = count, .entry_bytes = b[entries_start..off] } };
},
0x2B => blk: {
const head = routedHead(b, &off);
break :blk .{ .append_file = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .path = longBytes(b, &off), .bytes = longBytes(b, &off) } };
},
0x2C => blk: {
const head = routedHead(b, &off);
break :blk .{ .stat_file = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .path = longBytes(b, &off) } };
},
0x2D => blk: {
const key = shortBytes(b, &off);
const chunk_tag = b[off];
const done_tag = b[off + 1];
const err_tag = b[off + 2];
off += 3;
break :blk .{ .read_file_stream = .{ .key = key, .chunk_tag = chunk_tag, .done_tag = done_tag, .err_tag = err_tag, .path = longBytes(b, &off) } };
},
0x2E => blk: {
const head = routedHead(b, &off);
break :blk .{ .write_file_stream = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .path = longBytes(b, &off) } };
},
0x2F => blk: {
const head = routedHead(b, &off);
break :blk .{ .write_file_chunk = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .bytes = longBytes(b, &off) } };
},
0x30 => blk: {
const head = routedHead(b, &off);
break :blk .{ .write_file_close = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err } };
},
0x32 => blk: {
const head = routedHead(b, &off);
break :blk .{ .delete_file = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .path = longBytes(b, &off) } };
},
else => std.debug.panic("cmdview: unknown op byte 0x{X:0>2} at offset {d}", .{ op, self.off }),
};
self.off = off;
@@ -510,6 +664,12 @@ fn longBytes(b: []const u8, off: *usize) []const u8 {
return out;
}
fn readU32(b: []const u8, off: *usize) u32 {
const value = std.mem.readInt(u32, b[off.*..][0..4], .little);
off.* += 4;
return value;
}
// ------------------------------------------------------------------ helpers
/// First decoded op of the given kind in a cmd buffer, or null.
@@ -584,6 +744,47 @@ test "window_hide and dock_presence decode, alone and inside a batch" {
try std.testing.expectEqual(@as(?Op, null), iter.next());
}
test "record store command records decode and advance exactly" {
const batch = [_]u8{
0x23, 1, 'r', 2, 3, 0, 0, 0, 0, 1, 0, 0, 0, 'k', 1, 0, 0, 0, 'v',
0x26, 0, 4, 5, 0, 0, 0, 0, 2, 0, 0, 0, 'p', '/', 7, 0, 0, 0, 1,
0, 0, 0, 'a', 0x27, 1, 'b', 6, 7, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0,
0, 0, 'x', 2, 0, 0, 0, 8, 9, 0x02, 10,
};
var iter = CmdIter.init(&batch);
const set = (iter.next() orelse return error.TestUnexpectedResult).store_set;
try std.testing.expectEqualStrings("r", set.key);
try std.testing.expectEqualStrings("k", set.store_key);
try std.testing.expectEqualSlices(u8, "v", set.bytes);
const scan = (iter.next() orelse return error.TestUnexpectedResult).store_scan;
try std.testing.expectEqualStrings("p/", scan.prefix);
try std.testing.expectEqual(@as(u32, 7), scan.limit);
try std.testing.expectEqualStrings("a", scan.after);
const many = (iter.next() orelse return error.TestUnexpectedResult).store_set_many;
try std.testing.expectEqual(@as(u32, 1), many.count);
try std.testing.expectEqualStrings("x", many.entry(0).key);
try std.testing.expectEqualSlices(u8, &.{ 8, 9 }, many.entry(0).bytes);
try std.testing.expectEqual(@as(u8, 10), (iter.next() orelse return error.TestUnexpectedResult).now.msg_tag);
try std.testing.expectEqual(@as(?Op, null), iter.next());
}
test "delete_file decodes and advances a batch exactly" {
const batch = [_]u8{
0x32, 4, 'f', 'i', 'l', 'e', 2, 3,
12, 0, 0, 0, 'o', 'b', 's', 'o',
'l', 'e', 't', 'e', '.', 'b', 'i', 'n',
0x02, 7,
};
var iter = CmdIter.init(&batch);
const deleted = (iter.next() orelse return error.TestUnexpectedResult).delete_file;
try std.testing.expectEqualStrings("file", deleted.key);
try std.testing.expectEqual(@as(u8, 2), deleted.ok_tag);
try std.testing.expectEqual(@as(u8, 3), deleted.err_tag);
try std.testing.expectEqualStrings("obsolete.bin", deleted.path);
try std.testing.expectEqual(@as(u8, 7), (iter.next() orelse return error.TestUnexpectedResult).now.msg_tag);
try std.testing.expectEqual(@as(?Op, null), iter.next());
}
test "the image records decode, alone and inside a batch" {
// image_load: [op 0x12][id f64 LE][event_tag][path][url][cache]
// [expected f64 LE] — the bytes rt.zig's cmdImageLoad pins (the same
@@ -623,15 +824,16 @@ test "the image records decode, alone and inside a batch" {
}
test "the channel records decode, alone and inside a batch" {
// channel_open: [op 0x15][key f64 LE][event_tag u8] — the bytes
// rt.zig's cmdChannelOpen pins (no max_pending on the wire).
var open_bytes: [10]u8 = undefined;
// channel_open: [op 0x15][key f64 LE][event_tag u8][max_pending u8].
var open_bytes: [11]u8 = undefined;
open_bytes[0] = 0x15;
open_bytes[1..9].* = @bitCast(@as(f64, 41));
open_bytes[9] = 5; // event_tag
open_bytes[10] = 7; // max_pending
const opened = findOp(&open_bytes, .channel_open) orelse return error.TestUnexpectedResult;
try std.testing.expectEqual(@as(f64, 41), opened.key);
try std.testing.expectEqual(@as(u8, 5), opened.event_tag);
try std.testing.expectEqual(@as(u8, 7), opened.max_pending);
// channel_close: [op 0x16][key f64 LE].
var close_bytes: [9]u8 = undefined;
@@ -641,13 +843,13 @@ test "the channel records decode, alone and inside a batch" {
try std.testing.expectEqual(@as(f64, 41), closed.key);
// A batch of open + close + a trailing now record: each record must
// advance the iterator exactly its own length (ten bytes, then
// advance the iterator exactly its own length (eleven bytes, then
// nine) for the tail to decode.
var batch: [21]u8 = undefined;
batch[0..10].* = open_bytes;
batch[10..19].* = close_bytes;
batch[19] = 0x02;
batch[20] = 7;
var batch: [22]u8 = undefined;
batch[0..11].* = open_bytes;
batch[11..20].* = close_bytes;
batch[20] = 0x02;
batch[21] = 7;
var iter = CmdIter.init(&batch);
const first = iter.next() orelse return error.TestUnexpectedResult;
try std.testing.expectEqual(@as(f64, 41), first.channel_open.key);
@@ -658,6 +860,40 @@ test "the channel records decode, alone and inside a batch" {
try std.testing.expectEqual(@as(?Op, null), iter.next());
}
test "typed request metadata and atomic streaming service records decode" {
const a = std.testing.allocator;
var bytes: std.ArrayList(u8) = .empty;
defer bytes.deinit(a);
try bytes.append(a, 0x05);
try bytes.append(a, 5);
try bytes.appendSlice(a, "parse");
try bytes.append(a, 3);
try bytes.appendSlice(a, "one");
try bytes.appendSlice(a, &.{ 4, 5, 1, 2, 0, 0, 0, 'o', 'k' });
const request = findOp(bytes.items, .request) orelse return error.TestUnexpectedResult;
try std.testing.expectEqualStrings("parse", request.name);
try std.testing.expect(request.typed_service);
try std.testing.expectEqualStrings("ok", request.payload);
bytes.clearRetainingCapacity();
try bytes.append(a, 0x28);
try bytes.appendSlice(a, &@as([8]u8, @bitCast(@as(f64, 79))));
try bytes.appendSlice(a, &.{ 6, 3 });
try bytes.append(a, 6);
try bytes.appendSlice(a, "stream");
try bytes.append(a, 3);
try bytes.appendSlice(a, "two");
try bytes.appendSlice(a, &.{ 7, 8, 3, 0, 0, 0, 1, 2, 3 });
const stream = findOp(bytes.items, .service_stream_request) orelse return error.TestUnexpectedResult;
try std.testing.expectEqual(@as(f64, 79), stream.channel_key);
try std.testing.expectEqual(@as(u8, 6), stream.event_tag);
try std.testing.expectEqual(@as(u8, 3), stream.max_pending);
try std.testing.expectEqualStrings("stream", stream.name);
try std.testing.expectEqualStrings("two", stream.key);
try std.testing.expectEqualSlices(u8, &.{ 1, 2, 3 }, stream.payload);
}
test "the audio capture records decode and advance a batch exactly" {
var start: [16]u8 = undefined;
start[0] = 0x1E;
+2
View File
@@ -17,6 +17,8 @@ TypeScript is the primary app-authoring language. A new `native init my_app` pro
| Example | Shows |
| --- | --- |
| `chatbot` | Multi-module TypeScript core, text editing, streaming `Cmd.fetch`, environment messages, and deterministic replay. |
| `service-feed-reader` | The complete services loop: `Cmd.fetch`, a parsing service reached through the generated `@native-sdk/services` client, shared record shapes, and recorded replay without the service. |
| `relational-notes` | Append-only SQLite migrations, build-time checked SQL, generated typed transactions and page decoders, FTS5, and live queries. |
| `gpu-components` | Isolated interactive Native UI specimens, disclosure trees, anchored menus, and controlled component state. |
| `soundboard-ts` | Full music player: audio effects, timers, search, assets, native context menus, and adaptive markup. |
| `system-monitor-ts` | Subprocess effects, timers, parsing, tables, charts, controlled scroll, and confirmation flows. |
@@ -24,6 +24,8 @@ enum {
NATIVE_SDK_WIDGET_ROLE_SWITCH = 17,
NATIVE_SDK_WIDGET_ROLE_SLIDER = 18,
NATIVE_SDK_WIDGET_ROLE_PROGRESSBAR = 19,
NATIVE_SDK_WIDGET_ROLE_RADIO = 20,
NATIVE_SDK_WIDGET_ROLE_RADIOGROUP = 21,
};
enum {
@@ -266,6 +268,7 @@ int native_sdk_app_text_input_state(void *app, native_sdk_text_input_state_t *ou
typedef double (*native_sdk_text_measure_fn)(void *context, uint64_t font_id, double size, const char *text, uintptr_t text_len);
int native_sdk_app_set_text_measure(void *app, native_sdk_text_measure_fn measure, void *context);
int native_sdk_app_set_automation_dir(void *app, const char *path, uintptr_t len);
int native_sdk_app_set_data_root(void *app, const char *path, uintptr_t len);
int native_sdk_app_render_pixel_size(void *app, float scale, native_sdk_canvas_pixels_t *out);
int native_sdk_app_render_pixels(void *app, float scale, uint8_t *pixels, uintptr_t pixels_len, native_sdk_canvas_pixels_t *out);
// Incremental sibling of native_sdk_app_render_pixels for a host that
@@ -248,7 +248,7 @@ class MainActivity : Activity(), SurfaceHolder.Callback {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
info.stateDescription = widgetStateDescription(node)
}
info.isCheckable = node.role == WIDGET_ROLE_CHECKBOX || node.role == WIDGET_ROLE_SWITCH
info.isCheckable = node.role == WIDGET_ROLE_CHECKBOX || node.role == WIDGET_ROLE_RADIO || node.role == WIDGET_ROLE_SWITCH
info.isChecked = info.isCheckable && widgetValueSelected(node)
info.isClickable = widgetSupportsAnyAction(node, WIDGET_ACTION_PRESS or WIDGET_ACTION_TOGGLE or WIDGET_ACTION_SELECT)
info.isEditable = node.role == WIDGET_ROLE_TEXTBOX && (node.flags and WIDGET_FLAG_READ_ONLY) == 0
@@ -347,6 +347,8 @@ class MainActivity : Activity(), SurfaceHolder.Callback {
WIDGET_ROLE_BUTTON, WIDGET_ROLE_MENUITEM -> "android.widget.Button"
WIDGET_ROLE_TEXTBOX -> "android.widget.EditText"
WIDGET_ROLE_CHECKBOX -> "android.widget.CheckBox"
WIDGET_ROLE_RADIO -> "android.widget.RadioButton"
WIDGET_ROLE_RADIOGROUP -> "android.widget.RadioGroup"
WIDGET_ROLE_SWITCH -> "android.widget.Switch"
WIDGET_ROLE_SLIDER -> "android.widget.SeekBar"
WIDGET_ROLE_PROGRESSBAR -> "android.widget.ProgressBar"
@@ -784,6 +786,8 @@ class MainActivity : Activity(), SurfaceHolder.Callback {
private const val WIDGET_ROLE_SWITCH = 17
private const val WIDGET_ROLE_SLIDER = 18
private const val WIDGET_ROLE_PROGRESSBAR = 19
private const val WIDGET_ROLE_RADIO = 20
private const val WIDGET_ROLE_RADIOGROUP = 21
private const val WIDGET_FLAG_FOCUSED = 1 shl 0
private const val WIDGET_FLAG_SELECTED = 1 shl 3
private const val WIDGET_FLAG_DISABLED = 1 shl 4
+1
View File
@@ -118,6 +118,7 @@ fn manifestWindow(comptime window: anytype, comptime index: usize) native_sdk.Wi
.resizable = windowBool(window, "resizable", true),
.restore_state = windowBool(window, "restore_state", true),
.restore_policy = windowRestorePolicy(window),
.initial_placement = if (@hasField(@TypeOf(window), "x") or @hasField(@TypeOf(window), "y")) .explicit else .default,
};
}
-1
View File
@@ -18,7 +18,6 @@
.width = 320,
.height = 490,
.resizable = false,
.restore_state = false,
.restore_policy = "center_on_primary",
.titlebar = "hidden_inset",
.views = .{
-2
View File
@@ -56,7 +56,6 @@ const shell_windows = [_]native_sdk.ShellWindow{.{
// the in-canvas drag band carries the window (see view.zig), and
// app.zon's startup window declares the same style.
.resizable = false,
.restore_state = false,
.titlebar = .hidden_inset,
.views = &shell_views,
}};
@@ -152,7 +151,6 @@ pub fn main(init: std.process.Init) !void {
.window_title = "Calculator",
.bundle_id = "dev.native_sdk.calculator",
.default_frame = geometry.RectF.init(0, 0, window_width, window_height),
.restore_state = false,
.js_window_api = false,
.shortcuts = &app_shortcuts,
.security = .{
-1
View File
@@ -12,7 +12,6 @@
.title = "Native SDK Canvas Preview",
.width = 960,
.height = 640,
.restore_state = false,
.restore_policy = "center_on_primary",
.views = .{
.{ .label = "preview-canvas", .kind = "gpu_surface", .fill = true, .role = "Canvas chrome", .accessibility_label = "Canvas Preview chrome", .gpu_backend = "metal", .gpu_pixel_format = "bgra8_unorm", .gpu_present_mode = "timer", .gpu_alpha_mode = "opaque", .gpu_color_space = "srgb", .gpu_vsync = true },
-2
View File
@@ -49,7 +49,6 @@ pub const shell_windows = [_]native_sdk.ShellWindow{.{
.title = "Native SDK Canvas Preview",
.width = window_width,
.height = window_height,
.restore_state = false,
.views = &shell_views,
}};
pub const shell_scene: native_sdk.ShellConfig = .{ .windows = &shell_windows };
@@ -196,7 +195,6 @@ pub fn main(init: std.process.Init) !void {
.window_title = "Native SDK Canvas Preview",
.bundle_id = "dev.native_sdk.canvas_preview",
.default_frame = geometry.RectF.init(0, 0, window_width, window_height),
.restore_state = false,
.js_window_api = false,
.security = .{
.navigation = .{ .allowed_origins = &.{ "zero://inline", "zero://app", "https://example.com", "https://native-sdk.dev" } },
+1 -1
View File
@@ -126,7 +126,7 @@ test "toolbar and status-item commands navigate and reload the webview" {
try testing.expectEqual(@as(usize, 1), harness.null_platform.trayCreateCount());
try testing.expectEqualStrings("NS", harness.null_platform.lastTrayTitle());
try testing.expectEqual(@as(usize, main.status_items.len), harness.null_platform.trayItems().len);
try harness.runtime.dispatchPlatformEvent(app_state.app(), .{ .tray_action = 3 });
try harness.runtime.dispatchPlatformEvent(app_state.app(), .{ .tray_action = .{ .item_id = 3 } });
try testing.expectEqual(@as(u32, 1), app_state.model.reload_count);
try testing.expectEqualStrings(main.docs_url, (try previewWebView(harness)).url);
try testing.expectEqual(navigations_after_install + 2, harness.null_platform.webview_navigate_count);
+3 -1
View File
@@ -8,7 +8,7 @@ This example shows guarded OS capabilities from trusted WebView code:
- Clipboard text read and write.
- Message dialogs.
- Credential set, get, and delete.
- File-drop events delivered to Zig and the WebView event bridge.
- File-drop events delivered to Zig and the WebView event bridge, plus a real canvas `drop_files` target.
- File association and custom URL scheme packaging metadata.
- App activation and deactivation events.
@@ -24,6 +24,8 @@ Run the headless test path:
zig build test -Dplatform=null
```
For the macOS host integration check, run the app with the system backend and drag a Finder file onto the right-hand **Drop files here** canvas. The status bar must report `Widget target 2 fired` and the dropped path. Dropping over the left WebView must still report the ordinary app-level drop without a widget target. The guest-VM harness cannot synthesize an AppKit drag session yet, so this is the documented manual receipt for the real host path.
Run all native-first example tests from the repository root:
```sh
+4 -1
View File
@@ -9,6 +9,7 @@
"webview",
"js_bridge",
"native_views",
"gpu_surfaces",
"open_url",
"reveal_path",
"recent_documents",
@@ -39,11 +40,13 @@
.title = "Native SDK Capabilities",
.width = 900,
.height = 620,
.min_width = 770,
.restore_policy = "center_on_primary",
.views = .{
.{ .label = "main", .kind = "webview", .url = "zero://inline", .fill = true },
.{ .label = "statusbar", .kind = "statusbar", .edge = "bottom", .height = 34, .role = "Status" },
.{ .label = "status-label", .kind = "label", .parent = "statusbar", .x = 14, .y = 8, .width = 640, .height = 18, .text = "Ready." },
.{ .label = "drop-canvas", .kind = "gpu_surface", .edge = "right", .width = 250, .min_width = 220, .role = "File drop canvas", .accessibility_label = "File drop target", .gpu_backend = "metal" },
.{ .label = "main", .kind = "webview", .url = "zero://inline", .fill = true, .min_width = 520 },
},
},
},
+125 -7
View File
@@ -10,7 +10,10 @@ const manifest_url_schemes = if (@hasField(@TypeOf(app_manifest), "url_schemes")
const window_width: f32 = 900;
const window_height: f32 = 620;
const window_min_width: f32 = 770;
const statusbar_height: f32 = 34;
const drop_canvas_label = "drop-canvas";
const drop_target_id: native_sdk.canvas.ObjectId = 2;
const html =
\\<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
@@ -54,24 +57,30 @@ const builtin_policies = [_]native_sdk.BridgeCommandPolicy{
.{ .name = "native-sdk.credentials.delete", .permissions = &credential_permission, .origins = &bridge_origins },
};
const shell_views = [_]native_sdk.ShellView{
.{ .label = "main", .kind = .webview, .url = "zero://inline", .fill = true },
.{ .label = "statusbar", .kind = .statusbar, .edge = .bottom, .height = statusbar_height, .layer = 20, .role = "Status" },
.{ .label = "status-label", .kind = .label, .parent = "statusbar", .x = 14, .y = 8, .width = 640, .height = 18, .layer = 21, .text = "Ready." },
.{ .label = drop_canvas_label, .kind = .gpu_surface, .edge = .right, .width = 250, .min_width = 220, .role = "File drop canvas", .accessibility_label = "File drop target", .gpu_backend = .metal },
.{ .label = "main", .kind = .webview, .url = "zero://inline", .fill = true, .min_width = 520 },
};
const shell_windows = [_]native_sdk.ShellWindow{.{
.label = "main",
.title = "Native SDK Capabilities",
.width = window_width,
.height = window_height,
.min_width = window_min_width,
.views = &shell_views,
}};
const shell_scene: native_sdk.ShellConfig = .{ .windows = &shell_windows };
const CapabilitiesApp = struct {
drop_count: u32 = 0,
widget_drop_count: u32 = 0,
activation_count: u32 = 0,
deactivation_count: u32 = 0,
last_drop_paths: []const []const u8 = &.{},
last_drop_target_id: native_sdk.canvas.ObjectId = 0,
pending_drop_target_id: ?native_sdk.canvas.ObjectId = null,
drop_target_installed: bool = false,
fn app(self: *@This()) native_sdk.App {
return .{
@@ -96,9 +105,34 @@ const CapabilitiesApp = struct {
self.last_drop_paths = drop.paths;
var status_buffer: [160]u8 = undefined;
const first_path = if (drop.paths.len > 0) drop.paths[0] else "";
const status = try std.fmt.bufPrint(&status_buffer, "Received file drop {d}: {d} file(s): {s}", .{ self.drop_count, drop.paths.len, first_path });
const drop_target_id_value = self.pending_drop_target_id;
self.pending_drop_target_id = null;
const status = if (drop_target_id_value) |target_id|
try std.fmt.bufPrint(&status_buffer, "Widget target {d} fired; app drop {d}: {d} file(s): {s}", .{ target_id, self.drop_count, drop.paths.len, first_path })
else
try std.fmt.bufPrint(&status_buffer, "Received file drop {d}: {d} file(s): {s}", .{ self.drop_count, drop.paths.len, first_path });
_ = try runtime.updateView(drop.window_id, "status-label", .{ .text = status });
},
.canvas_widget_file_drop => |drop| {
self.widget_drop_count += 1;
if (drop.target) |target| {
self.last_drop_target_id = target.id;
self.pending_drop_target_id = target.id;
} else {
self.pending_drop_target_id = null;
}
},
.gpu_surface_frame => |frame| {
if (!self.drop_target_installed and std.mem.eql(u8, frame.label, drop_canvas_label)) {
try installDropTarget(runtime, frame.window_id, frame.label, frame.size);
self.drop_target_installed = true;
}
},
.gpu_surface_resized => |resize| {
if (self.drop_target_installed and std.mem.eql(u8, resize.label, drop_canvas_label)) {
try installDropTarget(runtime, resize.window_id, resize.label, resize.frame.size());
}
},
.lifecycle => |lifecycle| switch (lifecycle) {
.activate => {
self.activation_count += 1;
@@ -110,11 +144,31 @@ const CapabilitiesApp = struct {
},
else => {},
},
.appearance_changed, .command, .shortcut, .timer, .effects_wake, .audio, .video, .gpu_surface_frame, .gpu_surface_resized, .gpu_surface_input, .canvas_widget_pointer, .canvas_widget_keyboard, .canvas_widget_scroll, .canvas_widget_file_drop, .canvas_widget_drag, .canvas_widget_context_menu, .canvas_widget_context_menu_shown, .canvas_widget_context_menu_dismissed, .canvas_widget_context_menu_request, .canvas_widget_dismiss, .canvas_widget_context_press, .canvas_widget_resize, .canvas_widget_change, .window_closed, .automation_provenance => {},
.appearance_changed, .command, .shortcut, .timer, .effects_wake, .audio, .video, .gpu_surface_input, .canvas_widget_pointer, .canvas_widget_keyboard, .canvas_widget_scroll, .canvas_widget_drag, .canvas_widget_context_menu, .canvas_widget_context_menu_shown, .canvas_widget_context_menu_dismissed, .canvas_widget_context_menu_request, .canvas_widget_dismiss, .canvas_widget_context_press, .canvas_widget_resize, .canvas_widget_change, .window_closed, .automation_provenance => {},
}
}
};
fn installDropTarget(runtime: *native_sdk.Runtime, window_id: native_sdk.WindowId, label: []const u8, size: native_sdk.geometry.SizeF) !void {
const canvas = native_sdk.canvas;
const margin: f32 = 24;
const target = canvas.Widget{
.id = drop_target_id,
.kind = .button,
.frame = native_sdk.geometry.RectF.init(margin, margin, @max(1, size.width - margin * 2), @max(1, size.height - margin * 2)),
.text = "Drop files here",
.semantics = .{ .label = "Drop files here", .actions = .{ .drop_files = true } },
};
var nodes: [2]canvas.WidgetLayoutNode = undefined;
const layout = try canvas.layoutWidgetTree(
.{ .id = 1, .kind = .panel, .children = &.{target} },
native_sdk.geometry.RectF.init(0, 0, size.width, size.height),
&nodes,
);
_ = try runtime.setCanvasWidgetLayout(window_id, label, layout);
_ = try runtime.emitCanvasWidgetDisplayList(window_id, label, .{});
}
pub fn main(init: std.process.Init) !void {
var app = CapabilitiesApp{};
try runner.runWithOptions(app.app(), .{
@@ -139,6 +193,7 @@ pub fn main(init: std.process.Init) !void {
test "capabilities bridge gates native services and dispatches file drops" {
const harness = try native_sdk.TestHarness().create(std.testing.allocator, .{ .size = native_sdk.geometry.SizeF.init(window_width, window_height) });
defer harness.destroy(std.testing.allocator);
harness.null_platform.gpu_surfaces = true;
harness.runtime.options.builtin_bridge = .{ .enabled = true, .commands = &builtin_policies };
harness.runtime.options.security = .{
.permissions = &app_permissions,
@@ -155,6 +210,30 @@ test "capabilities bridge gates native services and dispatches file drops" {
const app = app_state.app();
try harness.start(app);
var views_buffer: [8]native_sdk.ViewInfo = undefined;
const views = harness.runtime.listViews(1, &views_buffer);
const webview = viewByLabel(views, "main").?;
const drop_canvas = viewByLabel(views, drop_canvas_label).?;
const statusbar = viewByLabel(views, "statusbar").?;
try std.testing.expect(webview.parent == null);
try std.testing.expect(drop_canvas.parent == null);
try std.testing.expectEqual(native_sdk.geometry.RectF.init(0, 0, 650, window_height - statusbar_height), webview.frame);
try std.testing.expectEqual(native_sdk.geometry.RectF.init(650, 0, 250, window_height - statusbar_height), drop_canvas.frame);
try std.testing.expectEqual(native_sdk.geometry.RectF.init(0, window_height - statusbar_height, window_width, statusbar_height), statusbar.frame);
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{
.window_id = 1,
.label = drop_canvas_label,
.size = native_sdk.geometry.SizeF.init(250, window_height - statusbar_height),
.frame_index = 1,
.nonblank = true,
} });
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_resized = .{
.window_id = 1,
.label = drop_canvas_label,
.frame = native_sdk.geometry.RectF.init(0, 0, 350, window_height - statusbar_height),
} });
try dispatchBridge(harness, app, "{\"id\":\"notify\",\"command\":\"native-sdk.os.showNotification\",\"payload\":{\"title\":\"Capabilities\",\"subtitle\":\"native-sdk\",\"body\":\"Done\"}}");
try std.testing.expect(std.mem.indexOf(u8, harness.null_platform.lastBridgeResponse(), "\"ok\":true") != null);
try std.testing.expectEqual(@as(usize, 1), harness.null_platform.notificationCount());
@@ -194,15 +273,37 @@ test "capabilities bridge gates native services and dispatches file drops" {
try std.testing.expect(std.mem.indexOf(u8, harness.null_platform.lastBridgeResponse(), "\"result\":true") != null);
const dropped_paths = [_][]const u8{ "/tmp/one\nname.txt", "/tmp/two.txt" };
try harness.runtime.dispatchPlatformEvent(app, .{ .files_dropped = .{
.window_id = 1,
.paths = &dropped_paths,
} });
try harness.runtime.dispatchPlatformEvent(app, .{
.files_dropped = .{
.window_id = 1,
.view_label = drop_canvas_label,
// x=300 is outside the initial 250-point surface and proves the
// resize event rebuilt the retained widget hit-test geometry.
.point = native_sdk.geometry.PointF.init(300, 40),
.paths = &dropped_paths,
},
});
try std.testing.expectEqual(@as(u32, 1), app_state.widget_drop_count);
try std.testing.expectEqual(drop_target_id, app_state.last_drop_target_id);
try std.testing.expectEqual(@as(u32, 1), app_state.drop_count);
try std.testing.expectEqual(@as(usize, 2), app_state.last_drop_paths.len);
try std.testing.expectEqualStrings("/tmp/one\nname.txt", app_state.last_drop_paths[0]);
try std.testing.expectEqualStrings("/tmp/two.txt", app_state.last_drop_paths[1]);
try std.testing.expectEqualStrings("drop:files", harness.null_platform.lastWindowEventName());
try std.testing.expect(std.mem.startsWith(u8, nullViewText(harness, "status-label"), "Widget target 2 fired"));
try std.testing.expect(app_state.pending_drop_target_id == null);
const webview_paths = [_][]const u8{"/tmp/webview.txt"};
try harness.runtime.dispatchPlatformEvent(app, .{ .files_dropped = .{
.window_id = 1,
.view_label = "main",
.point = native_sdk.geometry.PointF.init(40, 40),
.paths = &webview_paths,
} });
try std.testing.expectEqual(@as(u32, 1), app_state.widget_drop_count);
try std.testing.expect(app_state.pending_drop_target_id == null);
try std.testing.expectEqual(@as(u32, 2), app_state.drop_count);
try std.testing.expect(std.mem.startsWith(u8, nullViewText(harness, "status-label"), "Received file drop 2"));
try harness.runtime.dispatchPlatformEvent(app, .app_activated);
try std.testing.expectEqual(@as(u32, 1), app_state.activation_count);
@@ -213,6 +314,9 @@ test "capabilities bridge gates native services and dispatches file drops" {
}
test "capabilities manifest declares package integration metadata" {
try std.testing.expectEqual(window_min_width, app_manifest.shell.windows[0].min_width);
try std.testing.expectEqual(window_min_width, shell_windows[0].min_width);
try std.testing.expectEqual(@as(usize, 1), manifest_file_associations.len);
try std.testing.expectEqualStrings("Native SDK Capability Document", manifest_file_associations[0].name);
try std.testing.expectEqualStrings("viewer", manifest_file_associations[0].role);
@@ -231,3 +335,17 @@ fn dispatchBridge(harness: *native_sdk.TestHarness(), app: native_sdk.App, bytes
.webview_label = "main",
} });
}
fn nullViewText(harness: *native_sdk.TestHarness(), label: []const u8) []const u8 {
for (harness.null_platform.views[0..harness.null_platform.view_count]) |view| {
if (std.mem.eql(u8, view.label, label)) return view.text;
}
return "";
}
fn viewByLabel(views: []const native_sdk.ViewInfo, label: []const u8) ?native_sdk.ViewInfo {
for (views) |view| {
if (std.mem.eql(u8, view.label, label)) return view;
}
return null;
}
+63 -1
View File
@@ -58,6 +58,11 @@ pub const RunOptions = struct {
if (windows.len > 0) {
info.main_window = windows[0];
info.windows = windows;
} else {
info.main_window.default_frame = manifestShellStartupFrame(info.main_window.default_frame);
info.main_window.restore_state = manifestShellStartupRestoreState(info.main_window.restore_state);
info.main_window.restore_policy = manifestShellStartupRestorePolicy(info.main_window.restore_policy);
info.main_window.initial_placement = manifestShellStartupInitialPlacement(info.main_window.initial_placement);
}
return info;
}
@@ -138,6 +143,7 @@ fn manifestWindow(comptime window: anytype, comptime index: usize) native_sdk.Wi
.resizable = windowBool(window, "resizable", true),
.restore_state = windowBool(window, "restore_state", true),
.restore_policy = windowRestorePolicy(window),
.initial_placement = if (@hasField(@TypeOf(window), "x") or @hasField(@TypeOf(window), "y")) .explicit else .default,
};
}
@@ -171,6 +177,57 @@ fn windowRestorePolicy(comptime window: anytype) native_sdk.WindowRestorePolicy
@compileError("unknown app.zon window restore_policy");
}
fn windowHasExplicitOrigin(comptime window: anytype) bool {
return @hasField(@TypeOf(window), "x") or @hasField(@TypeOf(window), "y");
}
fn manifestShellStartupFrame(fallback: native_sdk.geometry.RectF) native_sdk.geometry.RectF {
if (comptime !@hasField(@TypeOf(app_manifest), "shell")) return fallback;
const shell = app_manifest.shell;
if (comptime !@hasField(@TypeOf(shell), "windows")) return fallback;
if (comptime shell.windows.len == 0) return fallback;
const window = shell.windows[0];
return native_sdk.geometry.RectF.init(
windowFloatFallback(window, "x", fallback.x),
windowFloatFallback(window, "y", fallback.y),
windowFloatFallback(window, "width", fallback.width),
windowFloatFallback(window, "height", fallback.height),
);
}
fn windowFloatFallback(comptime window: anytype, comptime field: []const u8, fallback: f32) f32 {
if (comptime @hasField(@TypeOf(window), field)) return @field(window, field);
return fallback;
}
fn manifestShellStartupRestoreState(fallback: bool) bool {
if (comptime !@hasField(@TypeOf(app_manifest), "shell")) return fallback;
const shell = app_manifest.shell;
if (comptime !@hasField(@TypeOf(shell), "windows")) return fallback;
if (comptime shell.windows.len == 0) return fallback;
const window = shell.windows[0];
if (comptime @hasField(@TypeOf(window), "restore_state")) return window.restore_state;
return fallback;
}
fn manifestShellStartupRestorePolicy(fallback: native_sdk.WindowRestorePolicy) native_sdk.WindowRestorePolicy {
if (comptime !@hasField(@TypeOf(app_manifest), "shell")) return fallback;
const shell = app_manifest.shell;
if (comptime !@hasField(@TypeOf(shell), "windows")) return fallback;
if (comptime shell.windows.len == 0) return fallback;
const window = shell.windows[0];
if (comptime !@hasField(@TypeOf(window), "restore_policy")) return fallback;
return windowRestorePolicy(window);
}
fn manifestShellStartupInitialPlacement(fallback: native_sdk.WindowInitialPlacement) native_sdk.WindowInitialPlacement {
if (comptime !@hasField(@TypeOf(app_manifest), "shell")) return fallback;
const shell = app_manifest.shell;
if (comptime !@hasField(@TypeOf(shell), "windows")) return fallback;
if (comptime shell.windows.len == 0) return fallback;
return if (windowHasExplicitOrigin(shell.windows[0])) .explicit else fallback;
}
fn shortcutModifiers(comptime shortcut: anytype) native_sdk.ShortcutModifiers {
const values = if (@hasField(@TypeOf(shortcut), "modifiers")) shortcut.modifiers else .{};
var modifiers: native_sdk.ShortcutModifiers = .{};
@@ -408,12 +465,17 @@ fn prepareStateStore(io: std.Io, env_map: *std.process.Environ.Map, app_info: *n
if (!window.restore_state) continue;
if (store.loadWindow(window.label, &buffers.read) catch null) |saved| {
window.default_frame = saved.frame;
if (index == 0) app_info.main_window.default_frame = saved.frame;
window.initial_placement = .restored;
if (index == 0) {
app_info.main_window.default_frame = saved.frame;
app_info.main_window.initial_placement = .restored;
}
}
}
} else if (app_info.main_window.restore_state) {
if (store.loadWindow(app_info.main_window.label, &buffers.read) catch null) |saved| {
app_info.main_window.default_frame = saved.frame;
app_info.main_window.initial_placement = .restored;
}
}
return store;
-1
View File
@@ -12,7 +12,6 @@
.title = "Native SDK Channel Monitor",
.width = 560,
.height = 420,
.restore_state = false,
.restore_policy = "center_on_primary",
.views = .{
.{ .label = "monitor-canvas", .kind = "gpu_surface", .fill = true, .role = "Channel monitor canvas", .accessibility_label = "Channel monitor", .gpu_backend = "metal", .gpu_pixel_format = "bgra8_unorm", .gpu_present_mode = "timer", .gpu_alpha_mode = "opaque", .gpu_color_space = "srgb", .gpu_vsync = true },
-2
View File
@@ -38,7 +38,6 @@ const shell_windows = [_]native_sdk.ShellWindow{.{
.title = "Native SDK Channel Monitor",
.width = window_width,
.height = window_height,
.restore_state = false,
.views = &shell_views,
}};
const shell_scene: native_sdk.ShellConfig = .{ .windows = &shell_windows };
@@ -306,7 +305,6 @@ pub fn main(init: std.process.Init) !void {
.window_title = "Native SDK Channel Monitor",
.bundle_id = "dev.native_sdk.channel_monitor",
.default_frame = geometry.RectF.init(0, 0, window_width, window_height),
.restore_state = false,
.js_window_api = false,
.security = .{
.navigation = .{ .allowed_origins = &.{ "zero://inline", "zero://app" } },
-1
View File
@@ -21,7 +21,6 @@
.height = 640,
.min_width = 560,
.min_height = 420,
.restore_state = false,
.restore_policy = "center_on_primary",
.titlebar = "hidden_inset_tall",
.views = .{
+1 -2
View File
@@ -5,7 +5,7 @@
.description = "Choose a folder and edit its source files in a native two-pane window.",
.version = "0.1.0",
.platforms = .{"macos"},
.permissions = .{ "view", "command", "dialog" },
.permissions = .{ "view", "command", "dialog", "filesystem" },
.capabilities = .{ "native_views", "gpu_surfaces" },
.shortcuts = .{
.{ .id = "save-file", .key = "s", .modifiers = .{ "primary" } },
@@ -24,7 +24,6 @@
.height = 720,
.min_width = 760,
.min_height = 480,
.restore_state = false,
.restore_policy = "center_on_primary",
.titlebar = "hidden_inset_tall",
.views = .{
+1 -2
View File
@@ -52,6 +52,7 @@ const max_status_bytes: usize = 192;
const app_permissions = [_][]const u8{
native_sdk.security.permission_command,
native_sdk.security.permission_dialog,
native_sdk.security.permission_filesystem,
native_sdk.security.permission_view,
};
const shell_views = [_]native_sdk.ShellView{
@@ -64,7 +65,6 @@ const shell_windows = [_]native_sdk.ShellWindow{.{
.height = window_height,
.min_width = window_min_width,
.min_height = window_min_height,
.restore_state = false,
.titlebar = .hidden_inset_tall,
.views = &shell_views,
}};
@@ -2037,7 +2037,6 @@ pub fn main(init: std.process.Init) !void {
.window_title = "Native SDK Code Editor",
.bundle_id = "dev.native_sdk.code_editor",
.default_frame = geometry.RectF.init(0, 0, window_width, window_height),
.restore_state = false,
.js_window_api = false,
.shortcuts = &app_shortcuts,
.security = .{
+1 -1
View File
@@ -241,7 +241,7 @@ test "command app routes toolbar menu tray shortcut and bridge commands" {
.window_id = 1,
} });
try std.testing.expectEqual(@as(usize, 1), harness.null_platform.trayCreateCount());
try harness.runtime.dispatchPlatformEvent(app.app(), .{ .tray_action = 1 });
try harness.runtime.dispatchPlatformEvent(app.app(), .{ .tray_action = .{ .item_id = 1 } });
try harness.runtime.dispatchPlatformEvent(app.app(), .{ .shortcut = .{
.id = command_id,
.key = "s",

Some files were not shown because too many files have changed in this diff Show More