Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 998e79afe6 | |||
| dbab55d004 | |||
| 19e7942e8d | |||
| dc0f64c1ea | |||
| a22f2043d1 | |||
| cb6a417965 | |||
| fe41864a15 | |||
| 046ea270a4 | |||
| f6e4d99f09 | |||
| 781b7f9653 | |||
| b25cefe318 | |||
| ef3ba18168 | |||
| 465a163e27 | |||
| a33d579177 | |||
| 393a0ed36e | |||
| e8f9e4ee50 | |||
| 659c893b29 | |||
| 8d0da34e62 | |||
| 1c1fba0c0f | |||
| 86fecf6cee | |||
| 23d0f5908a | |||
| 919d0e6cdf | |||
| 5d7fee8262 | |||
| 5c32accf12 | |||
| d575734635 | |||
| ee63266095 | |||
| e7c161970c | |||
| ddd975e4ea | |||
| 474cb5e364 | |||
| e6ac6ac4fc | |||
| 0ecdc7d2e9 | |||
| e924d7fcac | |||
| 41c4cdc47a | |||
| 31d5b202bc | |||
| 2bc942db46 | |||
| 199b89e06d | |||
| 716eb27c53 | |||
| baef0d96d3 | |||
| f228e861d1 | |||
| 6ec836c491 | |||
| 9ce0370181 | |||
| a7665807f3 | |||
| fe92cff10a | |||
| 7d67158444 | |||
| c71a7b4638 | |||
| d7aeea1ea9 | |||
| 8b2a97ffe7 | |||
| 473cad71ef | |||
| 06b6ccd53c | |||
| ed84e35975 | |||
| 5fa8074f7c | |||
| 8d7946edbd | |||
| 011caa9183 | |||
| b7c493bab0 | |||
| aa7ed9aa52 | |||
| 833e79e44a | |||
| cafbf206e8 | |||
| 0126d20f30 | |||
| 2fd7c4c3dc | |||
| 4c95b04539 | |||
| c7e64b647c | |||
| a404ca166d | |||
| 7f6830a15b | |||
| 7a29661384 | |||
| a727b1db68 | |||
| 7e3a3157d0 | |||
| 283ab804c0 | |||
| 83a7aee721 | |||
| 30c1410c10 | |||
| bfcc5ff8df | |||
| 3a580c9a8d | |||
| 4269233703 | |||
| b230b140b8 | |||
| 04125b7d62 | |||
| 21f6057041 | |||
| 4f0b57f2c2 | |||
| b21849c1bf | |||
| ff6a1c2c32 | |||
| b01851d03c | |||
| 0f990c2007 | |||
| 4ceffdbbd5 | |||
| 030bf8df74 |
@@ -20,7 +20,7 @@ jobs:
|
||||
version: 0.16.0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
# TypeScript cores compile through the external core compiler at
|
||||
# build/test time; the compiler and the frontend's toolchain both
|
||||
# arrive with this one install (without it the ts-core suites skip
|
||||
@@ -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
|
||||
|
||||
@@ -42,7 +44,7 @@ jobs:
|
||||
version: 0.16.0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
# The exact-pinned external core compiler and the frontend's
|
||||
# toolchain, one install (packages/core/package.json is the one
|
||||
# place the pin lives).
|
||||
@@ -61,8 +63,20 @@ jobs:
|
||||
- uses: vercel-labs/setup-zig@v1
|
||||
with:
|
||||
version: 0.16.0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
# 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 manifest 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).
|
||||
@@ -89,8 +103,8 @@ jobs:
|
||||
with:
|
||||
version: 0.16.0
|
||||
# Percentile perf check: 5 cold launches asserting p90
|
||||
# first-frame latency under NATIVE_SDK_PERF_BUDGET_MS, then 5 steady-state
|
||||
# widget clicks asserting p90 input latency under NATIVE_SDK_PERF_INPUT_BUDGET_MS.
|
||||
# first-frame latency, 5 steady-state widget clicks asserting p90 input
|
||||
# latency, then a reset-scoped retained-animation cadence sample.
|
||||
# Its own job so a shared-runner slowdown is visible in isolation and
|
||||
# never blocks the correctness smokes.
|
||||
# Shared macos-14 runners are far noisier than a dev box (first CI run
|
||||
@@ -101,6 +115,13 @@ jobs:
|
||||
env:
|
||||
NATIVE_SDK_PERF_BUDGET_MS: "1500"
|
||||
NATIVE_SDK_PERF_INPUT_BUDGET_MS: "500"
|
||||
# The virtual display is commonly 25-35 Hz even though AppKit
|
||||
# reports the window visible. Keep physical/dev defaults at the
|
||||
# strict 60 Hz-class 45/20/34; this hosted job remains a
|
||||
# step-function regression sentinel rather than a display gate.
|
||||
NATIVE_SDK_PERF_ANIMATION_MIN_FRAMES: "30"
|
||||
NATIVE_SDK_PERF_ANIMATION_P90_MS: "50"
|
||||
NATIVE_SDK_PERF_ANIMATION_MAX_MS: "100"
|
||||
|
||||
linux-webkitgtk:
|
||||
name: Linux WebKitGTK
|
||||
@@ -163,7 +184,7 @@ jobs:
|
||||
version: 0.16.0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
# The TypeScript core suites compile through the external core
|
||||
# compiler at build/test time; without the install they skip
|
||||
# silently, so CI must provide it.
|
||||
@@ -203,7 +224,7 @@ jobs:
|
||||
version: 0.16.0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
# The TypeScript examples compile through the external core
|
||||
# compiler at build time; the compiler and the frontend toolchain
|
||||
# arrive with this install.
|
||||
@@ -337,7 +358,7 @@ jobs:
|
||||
version: 0.16.0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
# The scaffold default is the TypeScript core; its frontend and
|
||||
# compiler run at build time from this checkout's packages/core.
|
||||
- run: npm ci --prefix packages/core
|
||||
@@ -442,7 +463,7 @@ jobs:
|
||||
version: 0.16.0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
# The default scaffold is a TypeScript core: its build runs the
|
||||
# @native-sdk/core frontend and the external core compiler from
|
||||
# this checkout's own install.
|
||||
@@ -477,7 +498,7 @@ jobs:
|
||||
app=".zig-cache/scaffold-${frontend}"
|
||||
rm -rf "$app"
|
||||
./zig-out/bin/native init "$app" --frontend "$frontend" --full
|
||||
(cd "$app" && zig build test -Dplatform=null && ../../zig-out/bin/native validate app.zon)
|
||||
(cd "$app" && zig build test -Dplatform=null && ../../zig-out/bin/native validate app.json)
|
||||
# Every scaffold ships a CI workflow; parse it as real YAML.
|
||||
test -s "$app/.github/workflows/ci.yml"
|
||||
python3 -c 'import sys, yaml; yaml.safe_load(open(sys.argv[1]))' "$app/.github/workflows/ci.yml"
|
||||
|
||||
@@ -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,11 +2,20 @@
|
||||
|
||||
Guidance for agents (and humans) working on this repository.
|
||||
|
||||
## App authoring default
|
||||
|
||||
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.json`. `app.zon` remains a supported legacy/alternative manifest. 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`; 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
|
||||
|
||||
```bash
|
||||
zig build test # root engine + runtime suites
|
||||
zig build validate # sample app.zon manifest check
|
||||
zig build validate # framework's legacy app.zon manifest check
|
||||
zig build test-example-<name> # one example's suite (e.g. test-example-notes)
|
||||
scripts/gate.sh fast [ref] # affected-only local gate for your diff (default base: main)
|
||||
scripts/gate.sh full # everything CI-shaped that runs locally
|
||||
@@ -20,12 +29,13 @@ Pinned goldens (pixel signatures, schema fingerprints, command counts) are updat
|
||||
|
||||
## Changelog
|
||||
|
||||
Do not edit `CHANGELOG.md` directly. Each user-visible change ships a fragment in `changelog.d/` — see `changelog.d/README.md` for the format and voice. Internal-only polish needs no fragment.
|
||||
Do not edit `CHANGELOG.md` as part of regular feature or fix work. The release agent reviews the git history since the previous release and writes the complete changelog entry during release preparation; see [RELEASING.md](./RELEASING.md).
|
||||
|
||||
## Where things live
|
||||
|
||||
- `src/` — the engine and runtime; `src/primitives/canvas/` holds the widget, markup, and vector core.
|
||||
- `examples/` — the showcase apps, most zero-config (`app.zon` + `src/`).
|
||||
- `apps/schema/` — the standalone static Vercel project for `schema.native-sdk.dev`.
|
||||
- `examples/` — the showcase apps, many predating the JSON default (`app.zon` + `src/`).
|
||||
- `docs/` — the documentation site; `docs/AGENTS.md` has its MDX conventions.
|
||||
- `skills/` and `skill-data/` — the agent skills the CLI ships (`native skills list`).
|
||||
- `tools/` and `scripts/` — dev tooling and the local gate.
|
||||
|
||||
@@ -2,12 +2,250 @@
|
||||
|
||||
All notable changes to the Native SDK (formerly zero-native) will be documented in this file.
|
||||
|
||||
## 0.7.2
|
||||
## 0.9.5
|
||||
|
||||
<!-- release:start -->
|
||||
|
||||
### New Features
|
||||
|
||||
- **JSON manifests by default**: New TypeScript, Zig, web, full, and ejected apps now scaffold with `app.json`, backed by full parsing, discovery, build conversion, validation, vendoring, a published versioned schema, and seamless `app.zon` fallback for existing projects (#385).
|
||||
- **Registered-image source cropping**: Canvas image options and Native markup can now select atomic source rectangles from registered images for texture-atlas rendering, with schema, compiler, validation, documentation, and sampling-bleed coverage (#390).
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Reliable installed TypeScript toolchains**: Core and service builds now resolve ScriptC across nested, hoisted, and global sibling npm layouts, generate the complete SQLite SDK module family, and validate library imports against their actual directories (#389).
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Focused schema hosting**: `schema.native-sdk.dev` now serves only the versioned app schema and its current-version alias, redirecting every non-schema route to the main Native SDK site (#388).
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
|
||||
<!-- release:end -->
|
||||
|
||||
## 0.9.4
|
||||
|
||||
### New Features
|
||||
|
||||
- **Model-driven window restore policies**: TypeScript apps can now declare whether each model-driven window restores saved geometry or opens fresh, including center-on-primary placement, with matching defaults, validation, runtime forwarding, tests, and documentation (#381).
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Faster, more predictable iterative rebuilds**: Generated core and service ABI artifacts now change only when their contents do, markup and app code compile into independently cached objects, SDK module edits invalidate the right inputs, and rebuild diagnostics expose phase timing, memory use, and cache decisions across platforms (#382).
|
||||
- **Updated TypeScript compiler integration**: ScriptC advances to 0.0.33 with published compile-cache bootstrapping, explicit development and release library profiles, synchronized compiler-surface artifacts, and Node 24 throughout the TypeScript build and CI toolchain (#384).
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
|
||||
## 0.9.3
|
||||
|
||||
### 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
|
||||
|
||||
## 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).
|
||||
- **Live TypeScript theme packs**: zero-config TypeScript apps can export `themePack(model)` to switch the built-in theme pack from app state without losing live system scheme, accessibility, accent, or scale inputs (#308).
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Smooth macOS dialog blur**: Host backdrop blur now uses an optimized three-pass Gaussian approximation and correct dirty-region invalidation, eliminating flat or stale dialog backgrounds while preserving the established scrim treatment (#299).
|
||||
- **Byte-accurate PTY event keys**: TypeScript PTY event routes now expose echoed session keys as `Uint8Array`, matching the byte-text host, generated facade, and external-core contract (#307).
|
||||
- **Reliable keyboard widget navigation**: Interactive canvas lists, trees, menus, and anchored controls now retain logical focus across clipped rows, scroll keyboard targets into view, and paint active and focus-visible states consistently (#308).
|
||||
|
||||
### Improvements
|
||||
|
||||
- **TypeScript component gallery**: The GPU component showcase is now a TypeScript core and Native markup app with isolated interactive specimens, model-driven Default and Geist switching, clearer navigation, and dedicated smoke coverage (#308).
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
- @marcusschiesser
|
||||
- @NyxTools-M
|
||||
|
||||
## 0.8.3
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Packaged TypeScript app assets**: Runtime asset lookup now finds bundled macOS resources before the process working directory, so Native markup boot images such as the Kanban agent avatars render after launch (#297).
|
||||
- **Unclipped drag landing motion**: Dropped cards now stay in the lifted drag layer through their landing animation while neighboring reflow remains clipped within its swimlane (#297).
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Denser Kanban showcase**: The seeded board now includes twice as many Jira-style tickets and removes redundant issue glyphs from card metadata (#297).
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
|
||||
## 0.8.2
|
||||
|
||||
### New Features
|
||||
|
||||
- **Native drag and drop for TypeScript apps**: Native markup's new `on-drag` channel delivers live, release, and cancellation geometry to compiled cores while the renderer lifts the source under the pointer, preserves one globally keyed insertion slot, animates neighboring items, and supports Escape cancellation; TypeScript cores can also map native multi-file drops into ordinary deterministic messages through `dropMsg` (#285).
|
||||
- **Desktop notifications from model cores**: TypeScript apps can return fire-and-forget `Cmd.showNotification` effects and Zig apps can call `fx.showNotification`, with bounded validation and suppression during fake execution and session replay (#283).
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Explicit zero canvas padding**: Programmatic and compiled or interpreted Native markup views now preserve `padding="0"` instead of replacing it with the widget kind's default padding (#288).
|
||||
|
||||
### Improvements
|
||||
|
||||
- **TypeScript-first app authoring guidance**: Repository instructions, bundled skills, examples, package documentation, and the docs site now consistently lead with TypeScript cores and Native markup for new apps while keeping Zig as the explicit alternative and toolkit-extension tier (#284).
|
||||
- **Agent ticket Kanban showcase**: The TypeScript Kanban example now presents numbered OpenAI- and Claude-assigned tickets, uses an icon-only add action, keeps columns scrollable, and extends its end-to-end coverage for the updated drag geometry (#295).
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
- @johnlindquist
|
||||
- @Railly
|
||||
|
||||
## 0.8.1
|
||||
|
||||
### New Features
|
||||
|
||||
- **Safe presentational HTML in Markdown**: Markdown now lowers common GitHub-style inline and block HTML into native widgets, including links, details, aligned containers, and caller-resolved images, while scripts, styles, forms, embeds, event attributes, and unsupported or malformed markup remain inert literal text (#280).
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Reliable resolved Markdown images**: image discovery now follows renderable block starts, canonicalizes entity-encoded URLs consistently between loading and lookup, preserves aspect ratios within declared bounds, honors centered and end alignment, and ignores images inside comments, unsupported markup, code, and preformatted blocks (#281).
|
||||
- **Payload-free HTTP write requests**: `Effects.fetch` now sends an explicit zero-length body for POST, PUT, and PATCH requests without a payload, preventing debug-build crashes and emitting the required `Content-Length: 0` header (#277).
|
||||
|
||||
### Improvements
|
||||
|
||||
- **History-driven release notes**: release preparation now builds the complete changelog entry and contributor list from the commits since the previous release, replacing the per-change fragment workflow (#278).
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
- @Railly
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### New Features
|
||||
|
||||
- **Compiler-truth checks for TypeScript cores**: `native check` now ends with the pinned external core compiler's analyzer over the entry with the shipped SDK declarations mapped, so check and build share one compiler verdict. Type errors the frontend's own line would miss fail with the compiler's diagnostics verbatim; an analyzer that cannot reach a verdict defers to the build instead of wedging check.
|
||||
- **TypeScript cores compile through the external core compiler**: the frontend checks `src/core.ts` and emits its contract sidecar, the exact-pinned compiler builds a native archive, and the app links a generated mirror over it — no JS runtime in the binary, nothing to configure.
|
||||
- **The TS-to-Zig transpiled lane is removed** (a deliberate pre-1.0 break): `core_compiler = "transpiler"` in app.zon (and `-Dcore-compiler=transpiler`) is refused with a teaching, and `native check` runs the checker and contract only — no emitted Zig lands under `.native/check/`.
|
||||
- **The compiler is a package dependency**: it ships exact-pinned with the SDK's `packages/core` (repo checkouts install it with `npm ci` there; an npm-installed CLI carries it automatically).
|
||||
- **The core dev loop is restart-shaped**: markup hot reload and the instant `native dev --core` node loop are unchanged, and a core edit now pays a native compile measured in seconds on rebuild.
|
||||
- **TypeScript cores are desktop-only for now**: a mobile target with `src/core.ts` is taught before lane selection (the external toolchain does not target mobile yet); Zig and markup cores stay fully supported on mobile.
|
||||
- **Shipped type declarations**: `@native-sdk/core` now ships generated `sdk/*.d.ts` declaration files beside its TypeScript sources, so external tooling can resolve the SDK's types without compiling them.
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Leaner TypeScript toolchain installs**: the unused `@typescript/typescript6` compatibility wrapper is no longer a dependency of `@native-sdk/cli` or `@native-sdk/core`. The frontend already imports its compiler directly through the exactly pinned `@typescript/old` alias, while consumer trees carrying their own wrapper remain unaffected.
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
|
||||
## 0.7.2
|
||||
|
||||
### New Features
|
||||
|
||||
- **Geist-style code diffs**: `ui.code` and `<code>` can mark added and removed logical lines with theme-aware full-row washes, renderer-owned `+`/`-` markers, optional line numbers, and unchanged syntax-highlighted clipboard source.
|
||||
|
||||
### Improvements
|
||||
@@ -30,8 +268,6 @@ All notable changes to the Native SDK (formerly zero-native) will be documented
|
||||
- @ctate
|
||||
- @oshtz
|
||||
|
||||
<!-- release:end -->
|
||||
|
||||
## 0.7.1
|
||||
|
||||
### New Features
|
||||
|
||||
@@ -123,6 +123,6 @@ Branch from `main` (fork first if you don't have push access), keep the change f
|
||||
scripts/gate.sh fast # root suites + the example suites your diff touches
|
||||
```
|
||||
|
||||
If the change is user-visible, add a changelog fragment in `changelog.d/` (see [changelog.d/README.md](./changelog.d/README.md)) instead of editing `CHANGELOG.md`. Open the PR against `main` describing what changed and why; for larger changes, open an issue first so the design can be discussed.
|
||||
Do not edit `CHANGELOG.md` as part of a feature or fix PR; the release agent writes the complete entry from the release-range history. Open the PR against `main` describing what changed and why; for larger changes, open an issue first so the design can be discussed.
|
||||
|
||||
Commits must be cryptographically signed (`git commit -S`, or set `commit.gpgsign = true`) so they show as **Verified** — the `Signed-off-by` trailer from `git commit -s` is a DCO attestation, not a signature.
|
||||
Commits must be cryptographically signed (`git commit -S`, or set `commit.gpgsign = true`) so they show as **Verified** — the `Signed-off-by` trailer from `git commit -s` is a DCO attestation, not a signature.
|
||||
|
||||
@@ -93,17 +93,20 @@ Read the full guide at [native-sdk.dev/quick-start](https://native-sdk.dev/quick
|
||||
|
||||
## Examples
|
||||
|
||||
The apps pictured above live in [examples/](./examples), most as zero-config projects — `app.zon` plus `src/`, no build files — run straight from their directory with `native dev`.
|
||||
The apps pictured above live in [examples/](./examples), most as zero-config projects with a manifest plus `src/` and no build files, run straight from their directory with `native dev`. Many examples predate the current `app.json` default and retain `app.zon`; both formats have the same capabilities. Start with the TypeScript examples when learning the primary authoring path. The `-ts` suffix on `soundboard-ts` and `system-monitor-ts` is historical because those apps are ports kept beside older Zig originals. Chatbot is TypeScript-only and follows the unsuffixed naming used by new apps created with `native init`.
|
||||
|
||||
| Example | What it shows |
|
||||
| --- | --- |
|
||||
| [`chatbot`](./examples/chatbot) | TypeScript + Native markup end to end: modules, a text editor, streaming fetch effects, and replay-safe configuration. |
|
||||
| [`soundboard-ts`](./examples/soundboard-ts) | The full music-player showcase in TypeScript + Native markup: audio, search, assets, timers, and context menus. |
|
||||
| [`system-monitor-ts`](./examples/system-monitor-ts) | A live process monitor in TypeScript + Native markup: subprocess effects, tables, charts, and timers. |
|
||||
| [`calculator`](./examples/calculator) | A complete small app: markup keypad, keyboard input, chrome shortcuts, theming. |
|
||||
| [`notes`](./examples/notes) | Persistence through the effects channel: debounced writes, restore on boot, dialogs, search. |
|
||||
| [`soundboard`](./examples/soundboard) | Album grid with decoded cover art, context menus, timers, and a custom theme. |
|
||||
| [`deck`](./examples/deck) | The soundboard player rebuilt as a dense hardware chassis: two windows, same widgets, different tokens. |
|
||||
| [`feed`](./examples/feed) | A 100,000-row list, virtualized with runtime-owned scrolling. |
|
||||
|
||||
The full catalog in [examples/README.md](./examples/README.md) also covers guarded OS capabilities, GPU surfaces, WebView composition, web-frontend shells, and the iOS/Android embed hosts.
|
||||
The unsuffixed showcase apps above predate the TypeScript default and retain their Zig cores as first-class alternative implementations. The full catalog in [examples/README.md](./examples/README.md) also covers guarded OS capabilities, GPU surfaces, WebView composition, web-frontend shells, and the iOS/Android embed hosts.
|
||||
|
||||
## Platforms
|
||||
|
||||
|
||||
@@ -7,11 +7,16 @@ To prepare a release:
|
||||
1. Create a branch (e.g. `prepare-v1.2.0`)
|
||||
2. Bump the version in `packages/native-sdk/package.json`
|
||||
3. Run `npm --prefix packages/native-sdk run version:sync` to update all version references
|
||||
4. Run `scripts/changelog-merge.sh` to fold any pending `changelog.d/` fragments into the `## Unreleased` section
|
||||
5. Write the changelog entry in `CHANGELOG.md`, wrapped in `<!-- release:start -->` and `<!-- release:end -->` markers
|
||||
6. Populate the entry's `### Contributors` from commit authors and `Co-authored-by` trailers in the release range, using GitHub handles when available; this marked block is also the GitHub release body
|
||||
7. Remove the `<!-- release:start -->` and `<!-- release:end -->` markers from the previous release entry; only the latest release should have markers
|
||||
8. Open a PR and merge to `main`
|
||||
4. Review the git history since the previous release and write the complete changelog entry at the top of `CHANGELOG.md`, under a new `## <version>` heading wrapped in `<!-- release:start -->` and `<!-- release:end -->` markers
|
||||
5. Populate the entry's `### Contributors` from commit authors and `Co-authored-by` trailers in the release range, using GitHub handles when available; this marked block is also the GitHub release body
|
||||
6. Remove the `<!-- release:start -->` and `<!-- release:end -->` markers from the previous release entry; only the latest release should have markers
|
||||
7. Open a PR and merge to `main`
|
||||
|
||||
## Writing the changelog
|
||||
|
||||
Follow the existing format and voice. Group changes under descriptive headings such as `### New Features`, `### Bug Fixes`, and `### Improvements`. Give each bullet a bold lead-in followed by a concise description, and include PR numbers when available. Do not prefix entries with commit hashes.
|
||||
|
||||
The release entry should cover the complete git range since the previous release, including changes whose individual PRs did not touch `CHANGELOG.md`.
|
||||
|
||||
CI compares the version in `packages/native-sdk/package.json` to what's on npm. If it differs, it cross-builds the CLI for every platform, creates the GitHub release with the binaries, publishes the per-platform binary packages (`packages/native-sdk/npm/*`), and publishes `@native-sdk/cli` last — so the main package only lands once every binary package it pins is live. If npm already has the version but the GitHub release is missing assets, CI recreates the GitHub release from the marked changelog entry.
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Native SDK schemas
|
||||
|
||||
Static JSON Schemas published at `schema.native-sdk.dev`.
|
||||
|
||||
- `/app/v1.json` is the stable-major schema URL scaffolded into `app.json`.
|
||||
- `/app.json` is the short-lived current-version alias.
|
||||
|
||||
Only those schema URLs are served from this deployment. Every other path
|
||||
redirects (302) to `https://native-sdk.dev`, so the domain stays a single-
|
||||
purpose home for the manifest rather than hosting arbitrary content.
|
||||
|
||||
Create the Vercel project as `native-schema`, set its root directory to
|
||||
`apps/schema`, leave the framework preset as Other with no build command, and
|
||||
attach `schema.native-sdk.dev`. `vercel.json` sets the output directory to
|
||||
`public`. Add a new versioned file only for a breaking manifest contract;
|
||||
backward-compatible additions update the current major.
|
||||
|
||||
The original `https://native-sdk.dev/schemas/app.schema.json` URL remains a
|
||||
byte-identical compatibility copy owned by the docs deployment.
|
||||
@@ -0,0 +1,435 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://schema.native-sdk.dev/app/v1.json",
|
||||
"title": "Native SDK app manifest",
|
||||
"description": "Complete app.json manifest for a Native SDK application. app.zon remains supported as a legacy alternative.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "name", "version"],
|
||||
"properties": {
|
||||
"$schema": { "type": "string", "format": "uri-reference" },
|
||||
"id": { "type": "string", "minLength": 1, "maxLength": 128, "description": "Reverse-DNS application identifier." },
|
||||
"name": { "type": "string", "minLength": 1, "description": "Short machine-readable app name." },
|
||||
"display_name": { "type": "string", "minLength": 1, "description": "Human-readable app name." },
|
||||
"description": { "type": "string", "minLength": 1, "maxLength": 256 },
|
||||
"version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" },
|
||||
"icons": { "$ref": "#/$defs/stringArray" },
|
||||
"platforms": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": { "enum": ["macos", "linux", "windows", "ios", "android", "web"] }
|
||||
},
|
||||
"permissions": { "$ref": "#/$defs/stringArray" },
|
||||
"capabilities": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"enum": [
|
||||
"native_module", "webview", "js_bridge", "native_views", "gpu_surfaces",
|
||||
"menus", "shortcuts", "tray", "filesystem", "network", "notifications",
|
||||
"dialog", "clipboard", "credentials", "persist", "store", "sqlite",
|
||||
"open_url", "reveal_path", "recent_documents", "file_drops",
|
||||
"app_activation_events", "file_associations", "url_schemes"
|
||||
]
|
||||
}
|
||||
},
|
||||
"dock_visible": { "type": "boolean", "default": true },
|
||||
"persist": { "$ref": "#/$defs/persist" },
|
||||
"images": { "$ref": "#/$defs/images" },
|
||||
"service_packages": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/servicePackage" }
|
||||
},
|
||||
"service_carrier": { "enum": ["auto", "in_process", "child"], "default": "auto" },
|
||||
"service_pool_size": { "type": "integer", "minimum": 1, "maximum": 16 },
|
||||
"bridge": { "$ref": "#/$defs/bridge" },
|
||||
"web_engine": { "enum": ["system", "chromium"], "default": "system" },
|
||||
"webview_layer": { "enum": ["auto", "include", "exclude"], "default": "auto" },
|
||||
"core_compiler": { "const": "external", "default": "external" },
|
||||
"theme": { "enum": ["house", "geist"] },
|
||||
"theme_accent": { "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$" },
|
||||
"cef": { "$ref": "#/$defs/cef" },
|
||||
"frontend": { "$ref": "#/$defs/frontend" },
|
||||
"security": { "$ref": "#/$defs/security" },
|
||||
"assets": { "$ref": "#/$defs/assets" },
|
||||
"windows": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/window" }
|
||||
},
|
||||
"shell": { "$ref": "#/$defs/shell" },
|
||||
"commands": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/command" }
|
||||
},
|
||||
"menus": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/menu" }
|
||||
},
|
||||
"shortcuts": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/shortcut" }
|
||||
},
|
||||
"file_associations": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/fileAssociation" }
|
||||
},
|
||||
"url_schemes": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/urlScheme" }
|
||||
},
|
||||
"dmg": { "$ref": "#/$defs/dmg" }
|
||||
},
|
||||
"$defs": {
|
||||
"stringArray": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"position": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["x", "y"],
|
||||
"properties": {
|
||||
"x": { "type": "integer", "minimum": 0, "maximum": 65535 },
|
||||
"y": { "type": "integer", "minimum": 0, "maximum": 65535 }
|
||||
}
|
||||
},
|
||||
"persist": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["version", "restore"],
|
||||
"properties": {
|
||||
"version": { "type": "integer", "minimum": 1 },
|
||||
"debounce_ms": { "type": "integer", "minimum": 0, "maximum": 60000, "default": 500 },
|
||||
"restore": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["ok", "none", "err"],
|
||||
"properties": {
|
||||
"ok": { "type": "string", "minLength": 1 },
|
||||
"none": { "type": "string", "minLength": 1 },
|
||||
"err": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"images": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"max_image_pixel_bytes": { "type": "integer", "minimum": 1048576, "maximum": 8388608, "default": 1048576 }
|
||||
}
|
||||
},
|
||||
"servicePackage": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "version", "content_hash"],
|
||||
"properties": {
|
||||
"name": { "type": "string", "minLength": 1 },
|
||||
"version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" },
|
||||
"content_hash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }
|
||||
}
|
||||
},
|
||||
"bridge": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"commands": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name"],
|
||||
"properties": {
|
||||
"name": { "type": "string", "minLength": 1 },
|
||||
"permissions": { "$ref": "#/$defs/stringArray" },
|
||||
"origins": { "$ref": "#/$defs/stringArray" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cef": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"dir": { "type": "string", "default": "third_party/cef/macos" },
|
||||
"auto_install": { "type": "boolean", "default": false }
|
||||
}
|
||||
},
|
||||
"frontend": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"dist": { "type": "string", "default": "dist" },
|
||||
"entry": { "type": "string", "default": "index.html" },
|
||||
"spa_fallback": { "type": "boolean", "default": true },
|
||||
"dev": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["url"],
|
||||
"properties": {
|
||||
"url": { "type": "string", "format": "uri" },
|
||||
"command": { "$ref": "#/$defs/stringArray" },
|
||||
"ready_path": { "type": "string", "default": "/" },
|
||||
"timeout_ms": { "type": "integer", "minimum": 1, "maximum": 4294967295, "default": 30000 }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"navigation": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"allowed_origins": { "$ref": "#/$defs/stringArray" },
|
||||
"external_links": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"action": { "enum": ["deny", "open_system_browser"], "default": "deny" },
|
||||
"allowed_urls": { "$ref": "#/$defs/stringArray" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"assets": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"images": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "path"],
|
||||
"properties": {
|
||||
"id": { "type": "integer", "minimum": 1 },
|
||||
"path": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"windowBase": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": { "type": "string", "default": "main" },
|
||||
"title": { "type": "string" },
|
||||
"width": { "type": "number", "exclusiveMinimum": 0, "default": 720 },
|
||||
"height": { "type": "number", "exclusiveMinimum": 0, "default": 480 },
|
||||
"x": { "type": "number" },
|
||||
"y": { "type": "number" },
|
||||
"resizable": { "type": "boolean", "default": true },
|
||||
"restore_state": { "type": "boolean", "default": true },
|
||||
"titlebar": { "enum": ["standard", "hidden_inset", "hidden_inset_tall", "chromeless"], "default": "standard" },
|
||||
"transparent": { "type": "boolean", "default": false },
|
||||
"always_on_top": { "type": "boolean", "default": false },
|
||||
"click_through": { "type": "boolean", "default": false },
|
||||
"activate_on_show": { "type": "boolean", "default": true },
|
||||
"initially_hidden": { "type": "boolean", "default": false },
|
||||
"allows_fullscreen": { "type": "boolean", "default": true },
|
||||
"min_width": { "type": "number", "minimum": 0, "default": 0 },
|
||||
"min_height": { "type": "number", "minimum": 0, "default": 0 },
|
||||
"close_policy": { "enum": ["quit", "hide"], "default": "quit" }
|
||||
}
|
||||
},
|
||||
"window": {
|
||||
"allOf": [{ "$ref": "#/$defs/windowBase" }],
|
||||
"unevaluatedProperties": false
|
||||
},
|
||||
"shell": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"windows": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/shellWindow" }
|
||||
},
|
||||
"chrome": { "$ref": "#/$defs/shellChrome" }
|
||||
}
|
||||
},
|
||||
"shellWindow": {
|
||||
"allOf": [
|
||||
{ "$ref": "#/$defs/windowBase" },
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"restore_policy": { "enum": ["clamp_to_visible_screen", "center_on_primary"], "default": "clamp_to_visible_screen" },
|
||||
"views": { "type": "array", "items": { "$ref": "#/$defs/shellView" } }
|
||||
}
|
||||
}
|
||||
],
|
||||
"unevaluatedProperties": false
|
||||
},
|
||||
"shellChrome": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"tabs": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/shellTab" }
|
||||
},
|
||||
"primary_action": { "$ref": "#/$defs/shellTab" }
|
||||
}
|
||||
},
|
||||
"shellTab": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "label"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1 },
|
||||
"label": { "type": "string", "minLength": 1 },
|
||||
"icon": { "type": "string", "default": "" }
|
||||
}
|
||||
},
|
||||
"shellView": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["label", "kind"],
|
||||
"properties": {
|
||||
"label": { "type": "string", "minLength": 1 },
|
||||
"kind": {
|
||||
"enum": [
|
||||
"webview", "toolbar", "titlebar_accessory", "sidebar", "statusbar", "split", "stack",
|
||||
"button", "icon_button", "list_item", "checkbox", "toggle", "segmented_control",
|
||||
"text_field", "search_field", "label", "spacer", "gpu_surface", "progress_indicator"
|
||||
]
|
||||
},
|
||||
"parent": { "type": "string" },
|
||||
"edge": { "enum": ["top", "right", "bottom", "left"] },
|
||||
"axis": { "enum": ["row", "horizontal", "column", "vertical"] },
|
||||
"x": { "type": "number" },
|
||||
"y": { "type": "number" },
|
||||
"width": { "type": "number" },
|
||||
"height": { "type": "number" },
|
||||
"min_width": { "type": "number" },
|
||||
"min_height": { "type": "number" },
|
||||
"max_width": { "type": "number" },
|
||||
"max_height": { "type": "number" },
|
||||
"fill": { "type": "boolean", "default": false },
|
||||
"layer": { "type": "integer", "minimum": -2147483648, "maximum": 2147483647, "default": 0 },
|
||||
"visible": { "type": "boolean", "default": true },
|
||||
"enabled": { "type": "boolean", "default": true },
|
||||
"role": { "type": "string" },
|
||||
"accessibility_label": { "type": "string" },
|
||||
"url": { "type": "string" },
|
||||
"text": { "type": "string" },
|
||||
"command": { "type": "string" },
|
||||
"gpu_backend": { "enum": ["none", "metal", "software"] },
|
||||
"gpu_pixel_format": { "enum": ["none", "bgra8_unorm"] },
|
||||
"gpu_present_mode": { "enum": ["none", "timer"] },
|
||||
"gpu_alpha_mode": { "enum": ["none", "opaque", "premultiplied"] },
|
||||
"gpu_color_space": { "enum": ["none", "srgb", "display_p3"] },
|
||||
"gpu_vsync": { "type": "boolean" }
|
||||
}
|
||||
},
|
||||
"command": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1 },
|
||||
"title": { "type": "string", "default": "" },
|
||||
"enabled": { "type": "boolean", "default": true },
|
||||
"checked": { "type": "boolean", "default": false }
|
||||
}
|
||||
},
|
||||
"menu": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["title"],
|
||||
"properties": {
|
||||
"title": { "type": "string", "minLength": 1 },
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/menuItem" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"menuItem": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"label": { "type": "string", "default": "" },
|
||||
"command": { "type": "string", "default": "" },
|
||||
"key": { "type": "string", "default": "" },
|
||||
"modifiers": { "$ref": "#/$defs/modifiers" },
|
||||
"separator": { "type": "boolean", "default": false },
|
||||
"enabled": { "type": "boolean", "default": true },
|
||||
"checked": { "type": "boolean", "default": false }
|
||||
}
|
||||
},
|
||||
"modifiers": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": { "enum": ["primary", "command", "control", "option", "alt", "shift"] }
|
||||
},
|
||||
"shortcut": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "key"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1 },
|
||||
"key": { "type": "string", "minLength": 1 },
|
||||
"modifiers": { "$ref": "#/$defs/modifiers" }
|
||||
}
|
||||
},
|
||||
"fileAssociation": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name"],
|
||||
"properties": {
|
||||
"name": { "type": "string", "minLength": 1 },
|
||||
"role": { "$ref": "#/$defs/associationRole" },
|
||||
"extensions": { "$ref": "#/$defs/stringArray" },
|
||||
"mime_types": { "$ref": "#/$defs/stringArray" },
|
||||
"icon": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"urlScheme": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["scheme"],
|
||||
"properties": {
|
||||
"scheme": { "type": "string", "minLength": 1 },
|
||||
"role": { "$ref": "#/$defs/associationRole" }
|
||||
}
|
||||
},
|
||||
"associationRole": { "enum": ["viewer", "editor", "shell", "none"], "default": "viewer" },
|
||||
"dmg": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"volume_name": { "type": "string" },
|
||||
"background": { "type": "string" },
|
||||
"window_width": { "type": "integer", "minimum": 320, "maximum": 2000, "default": 660 },
|
||||
"window_height": { "type": "integer", "minimum": 240, "maximum": 1400, "default": 400 },
|
||||
"icon_size": { "type": "integer", "minimum": 32, "maximum": 256, "default": 128 },
|
||||
"app_position": { "$ref": "#/$defs/position" },
|
||||
"applications_position": { "$ref": "#/$defs/position" },
|
||||
"applications_link": { "type": "boolean", "default": true },
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/dmgItem" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"dmgItem": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["kind", "position"],
|
||||
"properties": {
|
||||
"kind": { "enum": ["app", "applications", "file", "link"] },
|
||||
"path": { "type": "string" },
|
||||
"name": { "type": "string" },
|
||||
"position": { "$ref": "#/$defs/position" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||
"outputDirectory": "public",
|
||||
"rewrites": [
|
||||
{ "source": "/app.json", "destination": "/app/v1.json" }
|
||||
],
|
||||
"redirects": [
|
||||
{
|
||||
"source": "/((?!app(?:\\.json|/v1\\.json)$).*)",
|
||||
"destination": "https://native-sdk.dev",
|
||||
"statusCode": 302
|
||||
}
|
||||
],
|
||||
"headers": [
|
||||
{
|
||||
"source": "/app/v1.json",
|
||||
"headers": [
|
||||
{ "key": "Content-Type", "value": "application/schema+json; charset=utf-8" },
|
||||
{ "key": "Cache-Control", "value": "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400" },
|
||||
{ "key": "Access-Control-Allow-Origin", "value": "*" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "/app.json",
|
||||
"headers": [
|
||||
{ "key": "Content-Type", "value": "application/schema+json; charset=utf-8" },
|
||||
{ "key": "Cache-Control", "value": "public, max-age=300, s-maxage=300, stale-while-revalidate=86400" },
|
||||
{ "key": "Access-Control-Allow-Origin", "value": "*" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -27,6 +27,7 @@
|
||||
"src",
|
||||
"templates",
|
||||
"tests",
|
||||
"third_party/sqlite",
|
||||
"tools",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -12,21 +12,16 @@
|
||||
// node's ancestor node_modules walk). Node's own type stripping is never
|
||||
// relied on: it refuses node_modules-resident .ts by design
|
||||
// (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), and outside node_modules
|
||||
// it only became DEFAULT in node 22.18 — so a checkout target on
|
||||
// 22.15-22.17 would die with ERR_UNKNOWN_FILE_EXTENSION if the hook let it
|
||||
// "fall through". Hooking everything makes the 22.15 floor true for both
|
||||
// layouts. Then the runner imports the requested module with argv
|
||||
// it only became DEFAULT in node 22.18. Hooking everything keeps both layouts
|
||||
// identical. scriptc 0.0.33 requires Node 24 for its published compile-cache
|
||||
// bootstrap, so this shared runner enforces the same floor before importing
|
||||
// any frontend module. Then it imports the requested module with argv
|
||||
// respliced so the target sees its usual shape (its own path at argv[1],
|
||||
// its arguments from argv[2]).
|
||||
//
|
||||
// On node builds without module.registerHooks (pre-22.15, or 23.0-23.4 —
|
||||
// the hook landed in 22.15 and 23.5, so ">=22.15" alone is not the
|
||||
// capability line) NO .ts target can run — node_modules-resident
|
||||
// stripping is refused by design and default stripping outside
|
||||
// node_modules only landed in 22.18, which is above this tier anyway —
|
||||
// so the runner fails fast with one teaching line (upgrade to Node.js
|
||||
// 22.15+, on the 23 line 23.5+) before importing it, instead of
|
||||
// surfacing node's raw extension/stripping error.
|
||||
// A Node 24 build without module.registerHooks is incomplete for this tier,
|
||||
// so the runner gives the same Node 24 teaching instead of surfacing a raw
|
||||
// extension/stripping error.
|
||||
|
||||
import module, { createRequire } from 'node:module';
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -39,19 +34,21 @@ if (!target) {
|
||||
process.exit(2);
|
||||
}
|
||||
const targetPath = resolve(target);
|
||||
const nodeMajor = Number(process.versions.node.split('.')[0]);
|
||||
if (!Number.isInteger(nodeMajor) || nodeMajor < 24) {
|
||||
console.error(`TypeScript apps need Node.js 24+; you're running ${process.version} - upgrade node and re-run.`);
|
||||
process.exit(1);
|
||||
}
|
||||
// Drop the runner from argv so the target module parses its own argv
|
||||
// exactly as when node runs it directly.
|
||||
process.argv.splice(1, 1);
|
||||
|
||||
if (typeof module.registerHooks !== 'function') {
|
||||
// No load hooks on this node (pre-22.15, or a 23.0-23.4 build). Every
|
||||
// .ts target needs the hook — node_modules-resident stripping is
|
||||
// refused by design, and native default stripping outside node_modules
|
||||
// is 22.18+ — so any .ts target would fail deep inside node with a raw
|
||||
// extension/stripping error. Teach the fix instead.
|
||||
// Every .ts target needs the hook. A supported Node build without it is
|
||||
// incomplete for this tier, so teach the supported installation instead.
|
||||
if (targetPath.endsWith('.ts')) {
|
||||
console.error(
|
||||
`TypeScript apps need Node.js 22.15+ (on the 23 line: 23.5+); you're running ${process.version} - upgrade node and re-run.`,
|
||||
`TypeScript apps need Node.js 24+ with module.registerHooks; you're running ${process.version} - upgrade or reinstall node and re-run.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# Changelog fragments
|
||||
|
||||
Agents and feature branches do not edit `CHANGELOG.md` directly — concurrent work would conflict on every merge. Instead, each change lands with a small fragment in this directory, and `scripts/changelog-merge.sh` folds all fragments into the `## Unreleased` section of `CHANGELOG.md` (typically during release prep, see RELEASING.md).
|
||||
|
||||
## Writing a fragment
|
||||
|
||||
Add `changelog.d/<slug>.md`, where `<slug>` names your change (e.g. `gpu-dashboard-smoke-budget.md`). The file holds a bullet or two for one changelog section:
|
||||
|
||||
- The first line starts with a section tag: `feature:`, `improvement:`, or `fix:`, followed by the first bullet's text.
|
||||
- Any further lines are additional bullets (start them with `- `; bare lines get `- ` prefixed for you).
|
||||
- One tag per fragment. A change that touches multiple sections ships multiple fragments.
|
||||
- Match the CHANGELOG voice: bold lead-in, then the story. One line per bullet — never hard-wrap.
|
||||
|
||||
Example (`changelog.d/faster-frobnication.md`):
|
||||
|
||||
```
|
||||
improvement: **Faster frobnication**: the frobnicator now memoizes per-frame, cutting rebuild time ~40% on the kanban example.
|
||||
- **Frobnication telemetry**: automation snapshots report `frob_cache_hits=`.
|
||||
```
|
||||
|
||||
Tags map to sections: `feature:` → `### New Features`, `improvement:` → `### Improvements`, `fix:` → `### Bug Fixes`.
|
||||
|
||||
## Merging
|
||||
|
||||
```sh
|
||||
scripts/changelog-merge.sh
|
||||
```
|
||||
|
||||
appends every fragment's bullets to the end of its section under `## Unreleased` (creating the section — or the whole `## Unreleased` block — when missing), then deletes the merged fragments. This `README.md` is never merged or deleted. The script refuses unknown tags loudly instead of guessing.
|
||||
@@ -1 +0,0 @@
|
||||
`native check` on a TypeScript core now ends with the compiler-truth pass: the pinned external core compiler's analyzer runs over the entry with the shipped SDK declarations mapped, so the check verdict and the build verdict come from one compiler. Type errors the frontend's own line would miss fail the check with the compiler's diagnostics verbatim; an analyzer that cannot reach a verdict defers to the build instead of wedging check.
|
||||
@@ -1 +0,0 @@
|
||||
The unused `@typescript/typescript6` compat wrapper is no longer a dependency of `@native-sdk/cli` or `packages/core`. The frontend's compiler was already imported directly through the exactly pinned `@typescript/old` alias; the wrapper shipped in every install without ever being loaded. Consumer trees that carry their own copy of the wrapper are unaffected — toolchain validation never probed it.
|
||||
@@ -1,6 +0,0 @@
|
||||
feature: **TypeScript cores compile through the external core compiler**: the frontend checks `src/core.ts` and emits its contract sidecar, the exact-pinned compiler builds a native archive, and the app links a generated mirror over it — no JS runtime in the binary, nothing to configure.
|
||||
- **The TS-to-Zig transpiled lane is removed** (a deliberate pre-1.0 break): `core_compiler = "transpiler"` in app.zon (and `-Dcore-compiler=transpiler`) is refused with a teaching, and `native check` runs the checker and contract only — no emitted Zig lands under `.native/check/`.
|
||||
- **The compiler is a package dependency**: it ships exact-pinned with the SDK's `packages/core` (repo checkouts install it with `npm ci` there; an npm-installed CLI carries it automatically).
|
||||
- **The core dev loop is restart-shaped**: markup hot reload and the instant `native dev --core` node loop are unchanged, and a core edit now pays a native compile measured in seconds on rebuild.
|
||||
- **TypeScript cores are desktop-only for now**: a mobile target with `src/core.ts` is taught before lane selection (the external toolchain does not target mobile yet); Zig and markup cores stay fully supported on mobile.
|
||||
- **Shipped type declarations**: `@native-sdk/core` now ships generated `sdk/*.d.ts` declaration files beside its TypeScript sources, so external tooling can resolve the SDK's types without compiling them.
|
||||
@@ -4,3 +4,4 @@ next-env.d.ts
|
||||
.next-gate/
|
||||
.next-agent/
|
||||
.next-check/
|
||||
.next-final/
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
},
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check": "pnpm typecheck && pnpm build && node scripts/check-doc-routes.mjs && node scripts/check-code-toggle.mjs && node scripts/check-wasm-preview.mjs"
|
||||
"check": "node scripts/check-app-schema.mjs && pnpm typecheck && pnpm build && node scripts/check-doc-routes.mjs && node scripts/check-code-toggle.mjs && node scripts/check-wasm-preview.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdx-js/loader": "^3",
|
||||
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 854 B After Width: | Height: | Size: 1010 B |
|
Before Width: | Height: | Size: 864 B After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.6 KiB |
@@ -0,0 +1,435 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://schema.native-sdk.dev/app/v1.json",
|
||||
"title": "Native SDK app manifest",
|
||||
"description": "Complete app.json manifest for a Native SDK application. app.zon remains supported as a legacy alternative.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "name", "version"],
|
||||
"properties": {
|
||||
"$schema": { "type": "string", "format": "uri-reference" },
|
||||
"id": { "type": "string", "minLength": 1, "maxLength": 128, "description": "Reverse-DNS application identifier." },
|
||||
"name": { "type": "string", "minLength": 1, "description": "Short machine-readable app name." },
|
||||
"display_name": { "type": "string", "minLength": 1, "description": "Human-readable app name." },
|
||||
"description": { "type": "string", "minLength": 1, "maxLength": 256 },
|
||||
"version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" },
|
||||
"icons": { "$ref": "#/$defs/stringArray" },
|
||||
"platforms": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": { "enum": ["macos", "linux", "windows", "ios", "android", "web"] }
|
||||
},
|
||||
"permissions": { "$ref": "#/$defs/stringArray" },
|
||||
"capabilities": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"enum": [
|
||||
"native_module", "webview", "js_bridge", "native_views", "gpu_surfaces",
|
||||
"menus", "shortcuts", "tray", "filesystem", "network", "notifications",
|
||||
"dialog", "clipboard", "credentials", "persist", "store", "sqlite",
|
||||
"open_url", "reveal_path", "recent_documents", "file_drops",
|
||||
"app_activation_events", "file_associations", "url_schemes"
|
||||
]
|
||||
}
|
||||
},
|
||||
"dock_visible": { "type": "boolean", "default": true },
|
||||
"persist": { "$ref": "#/$defs/persist" },
|
||||
"images": { "$ref": "#/$defs/images" },
|
||||
"service_packages": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/servicePackage" }
|
||||
},
|
||||
"service_carrier": { "enum": ["auto", "in_process", "child"], "default": "auto" },
|
||||
"service_pool_size": { "type": "integer", "minimum": 1, "maximum": 16 },
|
||||
"bridge": { "$ref": "#/$defs/bridge" },
|
||||
"web_engine": { "enum": ["system", "chromium"], "default": "system" },
|
||||
"webview_layer": { "enum": ["auto", "include", "exclude"], "default": "auto" },
|
||||
"core_compiler": { "const": "external", "default": "external" },
|
||||
"theme": { "enum": ["house", "geist"] },
|
||||
"theme_accent": { "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$" },
|
||||
"cef": { "$ref": "#/$defs/cef" },
|
||||
"frontend": { "$ref": "#/$defs/frontend" },
|
||||
"security": { "$ref": "#/$defs/security" },
|
||||
"assets": { "$ref": "#/$defs/assets" },
|
||||
"windows": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/window" }
|
||||
},
|
||||
"shell": { "$ref": "#/$defs/shell" },
|
||||
"commands": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/command" }
|
||||
},
|
||||
"menus": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/menu" }
|
||||
},
|
||||
"shortcuts": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/shortcut" }
|
||||
},
|
||||
"file_associations": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/fileAssociation" }
|
||||
},
|
||||
"url_schemes": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/urlScheme" }
|
||||
},
|
||||
"dmg": { "$ref": "#/$defs/dmg" }
|
||||
},
|
||||
"$defs": {
|
||||
"stringArray": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"position": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["x", "y"],
|
||||
"properties": {
|
||||
"x": { "type": "integer", "minimum": 0, "maximum": 65535 },
|
||||
"y": { "type": "integer", "minimum": 0, "maximum": 65535 }
|
||||
}
|
||||
},
|
||||
"persist": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["version", "restore"],
|
||||
"properties": {
|
||||
"version": { "type": "integer", "minimum": 1 },
|
||||
"debounce_ms": { "type": "integer", "minimum": 0, "maximum": 60000, "default": 500 },
|
||||
"restore": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["ok", "none", "err"],
|
||||
"properties": {
|
||||
"ok": { "type": "string", "minLength": 1 },
|
||||
"none": { "type": "string", "minLength": 1 },
|
||||
"err": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"images": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"max_image_pixel_bytes": { "type": "integer", "minimum": 1048576, "maximum": 8388608, "default": 1048576 }
|
||||
}
|
||||
},
|
||||
"servicePackage": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "version", "content_hash"],
|
||||
"properties": {
|
||||
"name": { "type": "string", "minLength": 1 },
|
||||
"version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" },
|
||||
"content_hash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }
|
||||
}
|
||||
},
|
||||
"bridge": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"commands": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name"],
|
||||
"properties": {
|
||||
"name": { "type": "string", "minLength": 1 },
|
||||
"permissions": { "$ref": "#/$defs/stringArray" },
|
||||
"origins": { "$ref": "#/$defs/stringArray" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cef": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"dir": { "type": "string", "default": "third_party/cef/macos" },
|
||||
"auto_install": { "type": "boolean", "default": false }
|
||||
}
|
||||
},
|
||||
"frontend": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"dist": { "type": "string", "default": "dist" },
|
||||
"entry": { "type": "string", "default": "index.html" },
|
||||
"spa_fallback": { "type": "boolean", "default": true },
|
||||
"dev": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["url"],
|
||||
"properties": {
|
||||
"url": { "type": "string", "format": "uri" },
|
||||
"command": { "$ref": "#/$defs/stringArray" },
|
||||
"ready_path": { "type": "string", "default": "/" },
|
||||
"timeout_ms": { "type": "integer", "minimum": 1, "maximum": 4294967295, "default": 30000 }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"navigation": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"allowed_origins": { "$ref": "#/$defs/stringArray" },
|
||||
"external_links": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"action": { "enum": ["deny", "open_system_browser"], "default": "deny" },
|
||||
"allowed_urls": { "$ref": "#/$defs/stringArray" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"assets": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"images": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "path"],
|
||||
"properties": {
|
||||
"id": { "type": "integer", "minimum": 1 },
|
||||
"path": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"windowBase": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": { "type": "string", "default": "main" },
|
||||
"title": { "type": "string" },
|
||||
"width": { "type": "number", "exclusiveMinimum": 0, "default": 720 },
|
||||
"height": { "type": "number", "exclusiveMinimum": 0, "default": 480 },
|
||||
"x": { "type": "number" },
|
||||
"y": { "type": "number" },
|
||||
"resizable": { "type": "boolean", "default": true },
|
||||
"restore_state": { "type": "boolean", "default": true },
|
||||
"titlebar": { "enum": ["standard", "hidden_inset", "hidden_inset_tall", "chromeless"], "default": "standard" },
|
||||
"transparent": { "type": "boolean", "default": false },
|
||||
"always_on_top": { "type": "boolean", "default": false },
|
||||
"click_through": { "type": "boolean", "default": false },
|
||||
"activate_on_show": { "type": "boolean", "default": true },
|
||||
"initially_hidden": { "type": "boolean", "default": false },
|
||||
"allows_fullscreen": { "type": "boolean", "default": true },
|
||||
"min_width": { "type": "number", "minimum": 0, "default": 0 },
|
||||
"min_height": { "type": "number", "minimum": 0, "default": 0 },
|
||||
"close_policy": { "enum": ["quit", "hide"], "default": "quit" }
|
||||
}
|
||||
},
|
||||
"window": {
|
||||
"allOf": [{ "$ref": "#/$defs/windowBase" }],
|
||||
"unevaluatedProperties": false
|
||||
},
|
||||
"shell": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"windows": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/shellWindow" }
|
||||
},
|
||||
"chrome": { "$ref": "#/$defs/shellChrome" }
|
||||
}
|
||||
},
|
||||
"shellWindow": {
|
||||
"allOf": [
|
||||
{ "$ref": "#/$defs/windowBase" },
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"restore_policy": { "enum": ["clamp_to_visible_screen", "center_on_primary"], "default": "clamp_to_visible_screen" },
|
||||
"views": { "type": "array", "items": { "$ref": "#/$defs/shellView" } }
|
||||
}
|
||||
}
|
||||
],
|
||||
"unevaluatedProperties": false
|
||||
},
|
||||
"shellChrome": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"tabs": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/shellTab" }
|
||||
},
|
||||
"primary_action": { "$ref": "#/$defs/shellTab" }
|
||||
}
|
||||
},
|
||||
"shellTab": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "label"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1 },
|
||||
"label": { "type": "string", "minLength": 1 },
|
||||
"icon": { "type": "string", "default": "" }
|
||||
}
|
||||
},
|
||||
"shellView": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["label", "kind"],
|
||||
"properties": {
|
||||
"label": { "type": "string", "minLength": 1 },
|
||||
"kind": {
|
||||
"enum": [
|
||||
"webview", "toolbar", "titlebar_accessory", "sidebar", "statusbar", "split", "stack",
|
||||
"button", "icon_button", "list_item", "checkbox", "toggle", "segmented_control",
|
||||
"text_field", "search_field", "label", "spacer", "gpu_surface", "progress_indicator"
|
||||
]
|
||||
},
|
||||
"parent": { "type": "string" },
|
||||
"edge": { "enum": ["top", "right", "bottom", "left"] },
|
||||
"axis": { "enum": ["row", "horizontal", "column", "vertical"] },
|
||||
"x": { "type": "number" },
|
||||
"y": { "type": "number" },
|
||||
"width": { "type": "number" },
|
||||
"height": { "type": "number" },
|
||||
"min_width": { "type": "number" },
|
||||
"min_height": { "type": "number" },
|
||||
"max_width": { "type": "number" },
|
||||
"max_height": { "type": "number" },
|
||||
"fill": { "type": "boolean", "default": false },
|
||||
"layer": { "type": "integer", "minimum": -2147483648, "maximum": 2147483647, "default": 0 },
|
||||
"visible": { "type": "boolean", "default": true },
|
||||
"enabled": { "type": "boolean", "default": true },
|
||||
"role": { "type": "string" },
|
||||
"accessibility_label": { "type": "string" },
|
||||
"url": { "type": "string" },
|
||||
"text": { "type": "string" },
|
||||
"command": { "type": "string" },
|
||||
"gpu_backend": { "enum": ["none", "metal", "software"] },
|
||||
"gpu_pixel_format": { "enum": ["none", "bgra8_unorm"] },
|
||||
"gpu_present_mode": { "enum": ["none", "timer"] },
|
||||
"gpu_alpha_mode": { "enum": ["none", "opaque", "premultiplied"] },
|
||||
"gpu_color_space": { "enum": ["none", "srgb", "display_p3"] },
|
||||
"gpu_vsync": { "type": "boolean" }
|
||||
}
|
||||
},
|
||||
"command": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1 },
|
||||
"title": { "type": "string", "default": "" },
|
||||
"enabled": { "type": "boolean", "default": true },
|
||||
"checked": { "type": "boolean", "default": false }
|
||||
}
|
||||
},
|
||||
"menu": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["title"],
|
||||
"properties": {
|
||||
"title": { "type": "string", "minLength": 1 },
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/menuItem" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"menuItem": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"label": { "type": "string", "default": "" },
|
||||
"command": { "type": "string", "default": "" },
|
||||
"key": { "type": "string", "default": "" },
|
||||
"modifiers": { "$ref": "#/$defs/modifiers" },
|
||||
"separator": { "type": "boolean", "default": false },
|
||||
"enabled": { "type": "boolean", "default": true },
|
||||
"checked": { "type": "boolean", "default": false }
|
||||
}
|
||||
},
|
||||
"modifiers": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": { "enum": ["primary", "command", "control", "option", "alt", "shift"] }
|
||||
},
|
||||
"shortcut": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "key"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1 },
|
||||
"key": { "type": "string", "minLength": 1 },
|
||||
"modifiers": { "$ref": "#/$defs/modifiers" }
|
||||
}
|
||||
},
|
||||
"fileAssociation": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name"],
|
||||
"properties": {
|
||||
"name": { "type": "string", "minLength": 1 },
|
||||
"role": { "$ref": "#/$defs/associationRole" },
|
||||
"extensions": { "$ref": "#/$defs/stringArray" },
|
||||
"mime_types": { "$ref": "#/$defs/stringArray" },
|
||||
"icon": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"urlScheme": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["scheme"],
|
||||
"properties": {
|
||||
"scheme": { "type": "string", "minLength": 1 },
|
||||
"role": { "$ref": "#/$defs/associationRole" }
|
||||
}
|
||||
},
|
||||
"associationRole": { "enum": ["viewer", "editor", "shell", "none"], "default": "viewer" },
|
||||
"dmg": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"volume_name": { "type": "string" },
|
||||
"background": { "type": "string" },
|
||||
"window_width": { "type": "integer", "minimum": 320, "maximum": 2000, "default": 660 },
|
||||
"window_height": { "type": "integer", "minimum": 240, "maximum": 1400, "default": 400 },
|
||||
"icon_size": { "type": "integer", "minimum": 32, "maximum": 256, "default": 128 },
|
||||
"app_position": { "$ref": "#/$defs/position" },
|
||||
"applications_position": { "$ref": "#/$defs/position" },
|
||||
"applications_link": { "type": "boolean", "default": true },
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/dmgItem" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"dmgItem": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["kind", "position"],
|
||||
"properties": {
|
||||
"kind": { "enum": ["app", "applications", "file", "link"] },
|
||||
"path": { "type": "string" },
|
||||
"name": { "type": "string" },
|
||||
"position": { "$ref": "#/$defs/position" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const docsRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const repoRoot = path.resolve(docsRoot, "..");
|
||||
const publishedPath = path.join(repoRoot, "apps", "schema", "public", "app", "v1.json");
|
||||
const legacyPath = path.join(docsRoot, "public", "schemas", "app.schema.json");
|
||||
const packagePath = path.join(repoRoot, "packages", "native-sdk", "schemas", "app.schema.json");
|
||||
const deploymentPath = path.join(repoRoot, "apps", "schema", "vercel.json");
|
||||
const publishedBytes = fs.readFileSync(publishedPath);
|
||||
const schema = JSON.parse(publishedBytes.toString("utf8"));
|
||||
const deployment = JSON.parse(fs.readFileSync(deploymentPath, "utf8"));
|
||||
|
||||
assert.equal(schema.$id, "https://schema.native-sdk.dev/app/v1.json");
|
||||
assert.equal(schema.$defs.persist.properties.debounce_ms.minimum, 0);
|
||||
assert.equal(schema.$defs.persist.properties.debounce_ms.maximum, 60_000);
|
||||
assert.equal(schema.$defs.frontend.properties.dev.properties.timeout_ms.minimum, 1);
|
||||
assert.equal(schema.$defs.frontend.properties.dev.properties.timeout_ms.maximum, 4_294_967_295);
|
||||
assert.equal(schema.$defs.dmg.properties.window_width.minimum, 320);
|
||||
assert.equal(schema.$defs.dmg.properties.window_width.maximum, 2_000);
|
||||
assert.equal(schema.$defs.dmg.properties.window_height.minimum, 240);
|
||||
assert.equal(schema.$defs.dmg.properties.window_height.maximum, 1_400);
|
||||
assert.equal(schema.$defs.dmg.properties.icon_size.minimum, 32);
|
||||
assert.equal(schema.$defs.dmg.properties.icon_size.maximum, 256);
|
||||
assert.deepEqual(fs.readFileSync(legacyPath), publishedBytes, "legacy docs schema differs from canonical v1");
|
||||
assert.deepEqual(fs.readFileSync(packagePath), publishedBytes, "published and npm-packaged app schemas differ");
|
||||
assert.equal(deployment.outputDirectory, "public");
|
||||
// /app.json aliases the canonical schema; the redirect's negative lookahead
|
||||
// keeps both schema URLs local while every other path returns an actual 302.
|
||||
assert.deepEqual(deployment.rewrites, [{ source: "/app.json", destination: "/app/v1.json" }]);
|
||||
assert.deepEqual(deployment.redirects, [
|
||||
{
|
||||
source: "/((?!app(?:\\.json|/v1\\.json)$).*)",
|
||||
destination: "https://native-sdk.dev",
|
||||
statusCode: 302,
|
||||
},
|
||||
]);
|
||||
const redirectPattern = new RegExp(`^${deployment.redirects[0].source}$`);
|
||||
assert.equal(redirectPattern.test("/app.json"), false);
|
||||
assert.equal(redirectPattern.test("/app/v1.json"), false);
|
||||
assert.equal(redirectPattern.test("/"), true);
|
||||
assert.equal(redirectPattern.test("/docs"), true);
|
||||
assert.equal(redirectPattern.test("/app/v2.json"), true);
|
||||
assert.ok(deployment.headers.some((entry) => entry.source === "/app/v1.json"));
|
||||
assert.ok(deployment.headers.some((entry) => entry.source === "/app.json"));
|
||||
|
||||
console.log("app schema check passed: canonical deployment, runtime bounds, and npm mirror agree");
|
||||
@@ -156,7 +156,7 @@ A Zig-root app keeps dev-time hot reload for its embedded fragments too: build w
|
||||
|
||||
## Side effects
|
||||
|
||||
`update` stays pure by routing anything asynchronous — subprocesses, HTTP, file persistence, timers, clipboard — through the effects channel, and results come back as ordinary messages. In a TypeScript core, effects are `Cmd` data returned from `update` and recurring timers are declared `Sub` data — see [TypeScript Cores: Effects](/docs/typescript#effects-are-cmd-data). In a Zig core, declare `.update_fx` instead of `.update` and spawn from message arms; boot-time work goes in `.init_fx`, which runs exactly once before the first paint. See [Native UI: Effects](/docs/native-ui#effects).
|
||||
`update` stays pure by routing anything that leaves the model — subprocesses, HTTP, file persistence, timers, clipboard, desktop notifications — through the effects channel, and routed results come back as ordinary messages. In a TypeScript core, effects are `Cmd` data returned from `update` and recurring timers are declared `Sub` data — see [TypeScript Cores: Effects](/docs/typescript#effects-are-cmd-data). In a Zig core, declare `.update_fx` instead of `.update` and spawn from message arms; boot-time work goes in `.init_fx`, which runs exactly once before the first paint. See [Native UI: Effects](/docs/native-ui#effects).
|
||||
|
||||
## Dropping down
|
||||
|
||||
|
||||
@@ -1,45 +1,52 @@
|
||||
# Config
|
||||
|
||||
The `app.zon` manifest declares app metadata, permissions, security rules, window layout, and packaging inputs. It is read by the CLI and tooling at build, package, and validation time.
|
||||
The `app.json` manifest declares app metadata, permissions, security rules, window layout, and packaging inputs. It is read by the CLI and tooling at build, package, and validation time. New projects use JSON so TypeScript developers get familiar syntax, completion, and inline validation through [`$schema`](https://schema.native-sdk.dev/app/v1.json). Existing `app.zon` manifests remain fully supported and expose the same fields—there is no reduced JSON feature set. The versioned URL stays compatible for the lifetime of the v1 manifest contract; `/app.json` is the current-version alias.
|
||||
|
||||
## Example: native-rendered app
|
||||
|
||||
The manifest `native init` generates — identity, one shell window with a GPU surface view, and the minimal permission set:
|
||||
|
||||
```zig:app.zon
|
||||
.{
|
||||
.id = "dev.native_sdk.my-app",
|
||||
.name = "my-app",
|
||||
.display_name = "My App",
|
||||
.description = "A counter that lives in one native window.",
|
||||
.version = "0.1.0",
|
||||
.icons = .{"assets/icon.png"},
|
||||
.platforms = .{"macos"},
|
||||
.permissions = .{ "view", "command" },
|
||||
.capabilities = .{ "native_views", "gpu_surfaces" },
|
||||
.shell = .{
|
||||
.windows = .{
|
||||
.{
|
||||
.label = "main",
|
||||
.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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
.security = .{
|
||||
.navigation = .{
|
||||
.allowed_origins = .{ "zero://app", "zero://inline" },
|
||||
.external_links = .{ .action = "deny" },
|
||||
},
|
||||
},
|
||||
.web_engine = "system",
|
||||
.cef = .{ .dir = "third_party/cef/macos", .auto_install = false },
|
||||
```json:app.json
|
||||
{
|
||||
"$schema": "https://schema.native-sdk.dev/app/v1.json",
|
||||
"id": "dev.native_sdk.my-app",
|
||||
"name": "my-app",
|
||||
"display_name": "My App",
|
||||
"description": "A counter that lives in one native window.",
|
||||
"version": "0.1.0",
|
||||
"icons": ["assets/icon.png"],
|
||||
"platforms": ["macos"],
|
||||
"permissions": ["view", "command"],
|
||||
"capabilities": ["native_views", "gpu_surfaces"],
|
||||
"shell": {
|
||||
"windows": [{
|
||||
"label": "main",
|
||||
"title": "My App",
|
||||
"width": 480,
|
||||
"height": 320,
|
||||
"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
|
||||
}]
|
||||
}]
|
||||
},
|
||||
"security": {
|
||||
"navigation": {
|
||||
"allowed_origins": ["zero://app", "zero://inline"],
|
||||
"external_links": { "action": "deny" }
|
||||
}
|
||||
},
|
||||
"web_engine": "system",
|
||||
"cef": { "dir": "third_party/cef/macos", "auto_install": false }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -148,13 +155,29 @@ A fuller manifest for an app that also [embeds web content](/docs/frontend) and
|
||||
<td><code>platforms</code></td>
|
||||
<td>Target platforms: <code>macos</code>, <code>linux</code>, <code>windows</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>dmg</code></td>
|
||||
<td>Optional macOS DMG presentation: volume name, PNG/JPEG/TIFF background (with automatic adjacent <code>@2x</code> discovery), usable Finder canvas and icon sizes, simple app/Applications positions, or an explicit positioned <code>items</code> list of the app, Applications alias, project files/directories, and absolute links. The zero-config defaults produce a complete drag-to-Applications layout.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>permissions</code></td>
|
||||
<td>Runtime permissions (see <a href="/docs/security">Security</a>)</td>
|
||||
<td>Runtime permissions (see <a href="/docs/security">Security</a>). Audio capture uses <code>microphone</code> and <code>system_audio</code>; macOS packaging emits the matching microphone, audio-capture, and screen-capture usage descriptions only when declared</td>
|
||||
</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 = .{ .max_image_pixel_bytes = 8_388_608 }</code>. The default is 1 MiB and accepted values are 1–8 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>
|
||||
@@ -269,9 +292,9 @@ 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`), `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 `titlebar` field is 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, and `activate_on_show = false` reveals it without activating the app or taking focus. 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. 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`, `shell.windows`, runtime `WindowCreateOptions`, and `UiApp.WindowDescriptor`.
|
||||
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.
|
||||
|
||||
Windows also take `close_policy` (`quit`, the default — the close affordance really closes, behavior unchanged for every existing app — or `hide`, the menu-bar-app shape: close hides the window and the app keeps running behind its status item). Like `titlebar`, close handling is host window state fixed at create, and the first shell window's declaration threads through the startup create. `hide` is supported on macOS and Windows — on Windows it additionally requires the `"tray"` capability (the tray icon is the only re-show affordance there; a declaration without it is refused at build time with a teaching). Linux has no status item to bring a hidden window back, so the declaration is refused at build time with a teaching. See <a href="/docs/windows#close-policy">Windows</a> and the <a href="/docs/tray#the-menu-bar-app-lifecycle">tray lifecycle recipe</a>. The same `close_policy` field is accepted on top-level `windows` entries.
|
||||
|
||||
@@ -297,11 +320,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 = .{
|
||||
@@ -320,7 +343,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 = .{
|
||||
@@ -412,6 +435,6 @@ The optional `frontend.dev` block configures the managed dev server for `native
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
native validate app.zon
|
||||
native doctor --manifest app.zon --strict
|
||||
native validate app.json
|
||||
native doctor --manifest app.json --strict
|
||||
```
|
||||
|
||||
@@ -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 <item-id></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 <item-id></code> or <code>tray-action <status-item-id> <item-id></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 <view-label></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()`:
|
||||
|
||||
@@ -49,7 +49,7 @@ Native controls can also bind a `command` when created with `runtime.createView(
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Platform support queries are available through `window.zero.platform.supports(...)` when `js_window_api` is `true`. The command accepts every feature name from `PlatformFeature`: `main_webview`, `child_webviews`, `native_views`, `native_control_commands`, `menus`, `tray`, `shortcuts`, `dialogs`, `clipboard_text`, `clipboard_rich_data`, `open_url`, `reveal_path`, `notifications`, `recent_documents`, `credentials`, `file_drops`, `app_activation_events`, `gpu_surfaces`, `gpu_surface_scroll_drivers`, `context_menus`, `view_surface_adoption`, `audio_playback`, `audio_streaming`, `audio_spectrum`, and `window_hide_on_close`. JavaScript callers can also use the camelCase aliases — `mainWebView`, `childWebViews`, `nativeViews`, `nativeControlCommands`, `clipboardText`, `clipboardRichData`, `openUrl`, `revealPath`, `recentDocuments`, `fileDrops`, `appActivationEvents`, `gpuSurfaces`, `gpuSurfaceScrollDrivers`, `contextMenus`, `viewSurfaceAdoption`, `audioPlayback`, `audioStreaming`, `audioSpectrum`, and `windowHideOnClose`. The helper accepts either a string or a selector object with `feature` or `name`; raw bridge payloads may use the same fields. Use an explicit `builtin_bridge` policy when you want per-command origin lists.
|
||||
Platform support queries are available through `window.zero.platform.supports(...)` when `js_window_api` is `true`. The command accepts every feature name from `PlatformFeature`: `main_webview`, `child_webviews`, `native_views`, `native_control_commands`, `menus`, `tray`, `shortcuts`, `dialogs`, `clipboard_text`, `clipboard_rich_data`, `open_url`, `reveal_path`, `notifications`, `recent_documents`, `credentials`, `file_drops`, `app_activation_events`, `gpu_surfaces`, `gpu_surface_scroll_drivers`, `context_menus`, `view_surface_adoption`, `audio_playback`, `audio_streaming`, `audio_spectrum`, `microphone_capture`, `system_audio_capture`, and `window_hide_on_close`. JavaScript callers can also use the camelCase aliases — `mainWebView`, `childWebViews`, `nativeViews`, `nativeControlCommands`, `clipboardText`, `clipboardRichData`, `openUrl`, `revealPath`, `recentDocuments`, `fileDrops`, `appActivationEvents`, `gpuSurfaces`, `gpuSurfaceScrollDrivers`, `contextMenus`, `viewSurfaceAdoption`, `audioPlayback`, `audioStreaming`, `audioSpectrum`, `microphoneCapture`, `systemAudioCapture`, and `windowHideOnClose`. The helper accepts either a string or a selector object with `feature` or `name`; raw bridge payloads may use the same fields. Use an explicit `builtin_bridge` policy when you want per-command origin lists.
|
||||
|
||||
## Window commands
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Capabilities
|
||||
|
||||
Native SDK capabilities are native OS services and app events exposed through `PlatformServices`, runtime methods, lifecycle events, and — for apps that [embed web content](/docs/frontend) — guarded bridge commands. Native code reaches them directly; web content does not receive capability access by default. In a [`UiApp`](/docs/app-model), clipboard access rides the effects channel (`fx.writeClipboard` / `fx.readClipboard`) so `update` never needs a runtime handle.
|
||||
Native SDK capabilities are native OS services and app events exposed through `PlatformServices`, runtime methods, lifecycle events, and — for apps that [embed web content](/docs/frontend) — guarded bridge commands. Native code reaches them directly; web content does not receive capability access by default. In a [model core](/docs/app-model), OS work rides the effects channel (`Cmd.*` in TypeScript and `fx.*` in Zig) so `update` never needs a runtime handle.
|
||||
|
||||
Web content itself is declare-to-use: an app ships the embedded web layer only when it declares web intent — `"webview"` in `.capabilities`, a `.frontend` block, a `.shell` webview view, or a web engine resolved to Chromium (`.web_engine = "chromium"` in app.zon, or the `-Dweb-engine`/`--web-engine` flags) — and an app that declares none of them builds native-only, where any attempt to create a webview fails with a teaching error instead of loading a layer the app never asked for. The [`webview_layer`](/docs/app-zon) manifest field overrides the inference in either direction. Native-only builds shed the platform web stack for real: the Windows executable carries no `WebView2Loader.dll` reference, and the Linux host neither links WebKitGTK nor requires `libwebkitgtk` on user machines.
|
||||
|
||||
@@ -10,7 +10,7 @@ Web content itself is declare-to-use: an app ships the embedded web layer only w
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Capability</th>
|
||||
<th>Zig API</th>
|
||||
<th>Native API</th>
|
||||
<th>JavaScript command</th>
|
||||
<th>Bridge permission</th>
|
||||
<th>Current native support</th>
|
||||
@@ -19,17 +19,38 @@ Web content itself is declare-to-use: an app ships the embedded web layer only w
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Open URL in system browser</td>
|
||||
<td><code>runtime.openExternalUrl(url)</code></td>
|
||||
<td><code>Cmd.openExternalUrl(url)</code> / <code>runtime.openExternalUrl(url)</code></td>
|
||||
<td><code>native-sdk.os.openUrl</code></td>
|
||||
<td><code>network</code></td>
|
||||
<td>macOS, Linux, and Windows system WebView; macOS Chromium</td>
|
||||
<td>macOS, Linux, and Windows model cores and system WebView; macOS Chromium</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>System notification</td>
|
||||
<td><code>runtime.showNotification(options)</code></td>
|
||||
<td><code>Cmd.showNotification(spec)</code> / <code>fx.showNotification(options)</code> / <code>runtime.showNotification(options)</code></td>
|
||||
<td><code>native-sdk.os.showNotification</code></td>
|
||||
<td><code>notifications</code></td>
|
||||
<td>macOS, Linux, and Windows system WebView; macOS Chromium</td>
|
||||
<td>macOS, Linux, and Windows model cores and system WebView; macOS Chromium</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Model persistence</td>
|
||||
<td><code>Cmd.persist()</code> / <code>fx.persist()</code></td>
|
||||
<td>None. Model-core effect only.</td>
|
||||
<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<Name></code> / <code>Cmd.qTx</code> / <code>Sub.q<Name></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>
|
||||
@@ -40,10 +61,10 @@ Web content itself is declare-to-use: an app ships the embedded web layer only w
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Reveal path in file manager</td>
|
||||
<td><code>runtime.revealPath(path)</code></td>
|
||||
<td><code>Cmd.revealPath(path)</code> / <code>runtime.revealPath(path)</code></td>
|
||||
<td><code>native-sdk.os.revealPath</code></td>
|
||||
<td><code>filesystem</code></td>
|
||||
<td>macOS, Linux, and Windows system WebView; macOS Chromium</td>
|
||||
<td>macOS, Linux, and Windows model cores and system WebView; macOS Chromium</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Recent documents</td>
|
||||
@@ -82,10 +103,17 @@ Web content itself is declare-to-use: an app ships the embedded web layer only w
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Credential store</td>
|
||||
<td><code>runtime.setCredential(options)</code> / <code>runtime.getCredential(key)</code> / <code>runtime.deleteCredential(key)</code></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 system WebView and macOS Chromium through Keychain; Linux system WebView through Secret Service/libsecret when available; Windows 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>
|
||||
<td><code>Cmd.formatLocalTime(timestampMs, style, route)</code> / <code>runtime.formatLocalTime(...)</code></td>
|
||||
<td>None. Effects/runtime API.</td>
|
||||
<td>None. No bridge surface.</td>
|
||||
<td>macOS, Linux, and Windows model cores; locale- and time-zone-aware, with results captured by session recording</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>App activation events</td>
|
||||
@@ -132,6 +160,8 @@ const support = {
|
||||
audioPlayback: await window.zero.platform.supports("audio_playback"),
|
||||
audioStreaming: await window.zero.platform.supports("audio_streaming"),
|
||||
audioSpectrum: await window.zero.platform.supports("audio_spectrum"),
|
||||
microphoneCapture: await window.zero.platform.supports("microphone_capture"),
|
||||
systemAudioCapture: await window.zero.platform.supports("system_audio_capture"),
|
||||
};
|
||||
```
|
||||
|
||||
@@ -143,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" } },
|
||||
@@ -163,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
|
||||
@@ -181,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");
|
||||
@@ -220,4 +256,88 @@ 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. 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"),
|
||||
})];
|
||||
```
|
||||
|
||||
### Zig
|
||||
|
||||
```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).
|
||||
|
||||
@@ -10,7 +10,7 @@ The `native` CLI provides project scaffolding, markup validation, automation, pa
|
||||
native init [path] [--template <ts-core|zig-core>] [--frontend <native|next|vite|react|svelte|vue>] [--full]
|
||||
```
|
||||
|
||||
Scaffold a new Native SDK project. The default `native` frontend scaffolds a native-rendered markup app with no web frontend and no build files — the CLI owns the build. The default core is TypeScript: `src/core.ts`, `src/app.native`, `app.zon`, and no language flag anywhere else — the build detects which core the tree carries. Omit `path` to scaffold into the current directory.
|
||||
Scaffold a new Native SDK project. The default `native` frontend scaffolds a native-rendered markup app with no web frontend and no build files — the CLI owns the build. The default core is TypeScript: `src/core.ts`, `src/app.native`, `app.json`, and no language flag anywhere else — the build detects which core the tree carries. Omit `path` to scaffold into the current directory.
|
||||
|
||||
<dl>
|
||||
<dt><code>--template</code></dt>
|
||||
@@ -26,7 +26,7 @@ Scaffold a new Native SDK project. The default `native` frontend scaffolds a nat
|
||||
```sh
|
||||
native dev [dir]
|
||||
native dev [dir] --core [--script msgs.ndjson] [--watch]
|
||||
native dev --binary <path> [--manifest app.zon] [--url <url>] [--command "<cmd>"] [--timeout-ms <n>]
|
||||
native dev --binary <path> [--manifest app.json] [--url <url>] [--command "<cmd>"] [--timeout-ms <n>]
|
||||
```
|
||||
|
||||
Build and run the app in the current (or given) app directory — a Debug build by default, printing a one-line completion and naming any failing step. The markup hot-reload watcher and the Debug-only teaching diagnostics are compiled in only in Debug; pass `-Doptimize=...` to override. Apps with a frontend dev config also get the managed dev server — see [Dev Server](/docs/cli/dev).
|
||||
@@ -41,13 +41,13 @@ Build and run the app in the current (or given) app directory — a Debug build
|
||||
<dt><code>--binary</code></dt>
|
||||
<dd>Path to a prebuilt app binary — the legacy prebuilt-shell form: the build step is skipped and only the frontend dev flow runs. Ordinary <code>native dev</code> builds the app itself.</dd>
|
||||
<dt><code>--manifest</code></dt>
|
||||
<dd>Path to <code>app.zon</code> (default: <code>app.zon</code>).</dd>
|
||||
<dd>Path to <code>app.json</code> or <code>app.zon</code> (default: auto-detected, JSON first).</dd>
|
||||
<dt><code>--url</code></dt>
|
||||
<dd>Override the dev server URL from <code>app.zon</code>.</dd>
|
||||
<dd>Override the dev server URL from the app manifest.</dd>
|
||||
<dt><code>--command</code></dt>
|
||||
<dd>Override the dev server command (space-separated).</dd>
|
||||
<dt><code>--timeout-ms</code></dt>
|
||||
<dd>Milliseconds to wait for the dev server (default from <code>app.zon</code>, or 30000).</dd>
|
||||
<dd>Milliseconds to wait for the dev server (default from the app manifest, or 30000).</dd>
|
||||
</dl>
|
||||
|
||||
### `native build`
|
||||
@@ -72,7 +72,7 @@ Run the app's test suite, printing the zig build summary (step/test tally) plus
|
||||
native check [dir] [--strict]
|
||||
```
|
||||
|
||||
Validate the whole tree without building the app. A TypeScript core (`src/core.ts`) runs the subset checker first — real tsc semantics plus the app-core rules, diagnostics verbatim — then every `src/**.native` markup file and `app.zon` are checked as before. With a fresh model contract (`zig-out/model-contract.zon`, refreshed by `native test`) it also checks bindings, iterables, and message tags against your `Model`/`Msg` — for a TypeScript core, against its model contract — and warns on model state no view uses. Without the artifact it degrades to structural checking and says so: "model contract: not yet built - bindings checked structurally only; run `native test` to enable typed checks". Markup accessibility findings are reported per file in full, and a failing `src/*.native` file that no Zig source embeds gets a leftover-file hint.
|
||||
Validate the whole tree without building the app. A TypeScript core (`src/core.ts`) runs the subset checker first — real tsc semantics plus the app-core rules, diagnostics verbatim — then every `src/**.native` markup file and the app manifest are checked as before. With a fresh model contract (`zig-out/model-contract.zon`, refreshed by `native test`) it also checks bindings, iterables, and message tags against your `Model`/`Msg` — for a TypeScript core, against its model contract — and warns on model state no view uses. Without the artifact it degrades to structural checking and says so: "model contract: not yet built - bindings checked structurally only; run `native test` to enable typed checks". Markup accessibility findings are reported per file in full, and a failing `src/*.native` file that no Zig source embeds gets a leftover-file hint.
|
||||
|
||||
<dl>
|
||||
<dt><code>--strict</code></dt>
|
||||
@@ -100,7 +100,7 @@ Write an owned copy of a library composite into `src/components/` (once, never o
|
||||
### `native doctor`
|
||||
|
||||
```sh
|
||||
native doctor [--strict] [--manifest app.zon] [--web-engine system|chromium] [--cef-dir path] [--cef-auto-install]
|
||||
native doctor [--strict] [--manifest app.json] [--web-engine system|chromium] [--cef-dir path] [--cef-auto-install]
|
||||
```
|
||||
|
||||
Check host environment, WebView, manifest, and CEF. See [native doctor](/docs/debugging/doctor) for what each check means.
|
||||
@@ -108,10 +108,10 @@ Check host environment, WebView, manifest, and CEF. See [native doctor](/docs/de
|
||||
### `native validate`
|
||||
|
||||
```sh
|
||||
native validate [app.zon]
|
||||
native validate [app.json|app.zon]
|
||||
```
|
||||
|
||||
Validate `app.zon` against the manifest schema.
|
||||
Validate `app.json` or `app.zon` against the same manifest contract.
|
||||
|
||||
### `native package`
|
||||
|
||||
@@ -119,13 +119,13 @@ Validate `app.zon` against the manifest schema.
|
||||
native package [--target <macos|linux|windows|ios|android>] [flags]
|
||||
```
|
||||
|
||||
Package the app for distribution. The manifest is picked up at `app.zon` and the binary at `zig-out/bin/<name>` automatically; the flags below override.
|
||||
Package the app for distribution. The manifest is picked up at `app.json` (falling back to `app.zon`) and the binary at `zig-out/bin/<name>` automatically; the flags below override.
|
||||
|
||||
<dl>
|
||||
<dt><code>--target</code></dt>
|
||||
<dd>Target platform (<code>macos</code>, <code>linux</code>, <code>windows</code>, <code>ios</code>, <code>android</code>).</dd>
|
||||
<dt><code>--manifest</code></dt>
|
||||
<dd>Path to <code>app.zon</code>.</dd>
|
||||
<dd>Path to <code>app.json</code> or <code>app.zon</code>.</dd>
|
||||
<dt><code>--output</code></dt>
|
||||
<dd>Output path for the package.</dd>
|
||||
<dt><code>--binary</code></dt>
|
||||
@@ -135,11 +135,11 @@ Package the app for distribution. The manifest is picked up at `app.zon` and the
|
||||
<dt><code>--optimize</code></dt>
|
||||
<dd>Optimization level.</dd>
|
||||
<dt><code>--web-engine</code></dt>
|
||||
<dd>Temporarily override <code>app.zon</code> with <code>system</code> or macOS-only <code>chromium</code>.</dd>
|
||||
<dd>Temporarily override the app manifest with <code>system</code> or macOS-only <code>chromium</code>.</dd>
|
||||
<dt><code>--web-layer</code></dt>
|
||||
<dd>Override <code>app.zon</code>'s <code>.webview_layer</code> with <code>auto</code>, <code>include</code>, or <code>exclude</code> — the same precedence as <code>-Dweb-layer</code> in the build graph. <code>zig build package</code> passes the graph's resolved decision here automatically so the package always matches the built executable.</dd>
|
||||
<dd>Override the app manifest's <code>webview_layer</code> with <code>auto</code>, <code>include</code>, or <code>exclude</code> — the same precedence as <code>-Dweb-layer</code> in the build graph. <code>zig build package</code> passes the graph's resolved decision here automatically so the package always matches the built executable.</dd>
|
||||
<dt><code>--cef-dir</code></dt>
|
||||
<dd>Temporarily override the CEF distribution path from <code>app.zon</code>.</dd>
|
||||
<dd>Temporarily override the CEF distribution path from the app manifest.</dd>
|
||||
<dt><code>--cef-auto-install</code></dt>
|
||||
<dd>Temporarily allow prepared CEF installation during Chromium packaging.</dd>
|
||||
<dt><code>--signing</code></dt>
|
||||
@@ -151,24 +151,25 @@ Package the app for distribution. The manifest is picked up at `app.zon` and the
|
||||
<dt><code>--team-id</code></dt>
|
||||
<dd>Apple Developer Team ID.</dd>
|
||||
<dt><code>--archive</code></dt>
|
||||
<dd>Create a distributable archive.</dd>
|
||||
<dd>Create a distributable archive. On macOS this is a styled DMG with the app, an Applications alias, a generated or custom background, and the Finder layout declared by the app manifest.</dd>
|
||||
</dl>
|
||||
|
||||
### 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`
|
||||
|
||||
```sh
|
||||
native bundle-assets [app.zon] [assets] [output]
|
||||
native bundle-assets [app.json|app.zon] [assets] [output]
|
||||
```
|
||||
|
||||
Copy frontend assets into the build output.
|
||||
@@ -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 <id></code></dt>
|
||||
<dd>Dispatch a shortcut command event.</dd>
|
||||
<dt><code>automate tray-action <item-id></code></dt>
|
||||
<dd>Select a status-item dropdown row.</dd>
|
||||
<dt><code>automate tray-action <item-id></code> / <code>automate tray-action <status-item-id> <item-id></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 <view-label></code></dt>
|
||||
<dd>Focus a native or WebView-backed view.</dd>
|
||||
<dt><code>automate focus-next</code> / <code>automate focus-previous</code></dt>
|
||||
|
||||
@@ -31,6 +31,13 @@ With a registered image the engine clips it to the avatar circle (`cover` fit);
|
||||
ui.avatar(.{ .image = image_id }, "ZN")
|
||||
```
|
||||
|
||||
An avatar can select one region from a registered texture atlas by declaring all four source coordinates in decoded-image pixels. The same crop is `ElementOptions.image_src` in a Zig view:
|
||||
|
||||
```html
|
||||
<avatar image="{atlas_image}" source-x="64" source-y="0"
|
||||
source-width="32" source-height="32" label="Profile picture">ZN</avatar>
|
||||
```
|
||||
|
||||
## Attributes
|
||||
|
||||
<AttrTable element="avatar" attrs={["text", "image", "label"]} />
|
||||
<AttrTable element="avatar" attrs={["text", "image", "source-x", "source-y", "source-width", "source-height", "label"]} />
|
||||
|
||||
@@ -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>
|
||||
```
|
||||
|
||||
|
||||
@@ -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",
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AttrTable } from "@/components/attr-table";
|
||||
|
||||
# Input Group
|
||||
|
||||
The composer shape: one bordered field wrapping a [textarea](/docs/components/textarea) plus an accessory row of controls inside the same border — attach on the bottom-left, send on the bottom-right. The group wears the focus ring whenever focus is on any control inside it, and the textarea's own chrome dissolves automatically, so the whole group reads as a single field. The textarea keeps its full behavior: `text` and `placeholder` bind from the model, `on-input` hears every edit, `on-submit` rides the primary chord, and `autofocus` lands the keyboard on mount.
|
||||
The composer shape: one bordered field wrapping a [textarea](/docs/components/textarea) plus an accessory row of controls inside the same border — attach on the bottom-left, send on the bottom-right. The group wears the focus ring whenever focus is on any control inside it, and the textarea's own chrome dissolves automatically, so the whole group reads as a single field. The textarea keeps its full behavior: `text` and `placeholder` bind from the model, `on-input` hears every edit, `on-submit` handles submission, `submit-on-enter="true"` opts a chat composer into plain-Enter submission, and `autofocus` lands the keyboard on mount.
|
||||
|
||||
<ComponentPreview name="input-group" alt="An input group rendered by the engine" />
|
||||
|
||||
@@ -13,7 +13,7 @@ The textarea comes first (document order is focus order), then the optional `inp
|
||||
|
||||
```html
|
||||
<input-group label="Message composer" height="120">
|
||||
<textarea placeholder="Type a message" text="{draft}" on-input="draft_edited" on-submit="send" />
|
||||
<textarea placeholder="Type a message" text="{draft}" submit-on-enter="true" on-input="draft_edited" on-submit="send" />
|
||||
<input-group-actions>
|
||||
<button icon="plus" variant="ghost" size="icon" on-press="attach" label="Attach"></button>
|
||||
<spacer grow="1" />
|
||||
@@ -33,6 +33,7 @@ ui.inputGroup(.{
|
||||
}, ui.el(.textarea, .{
|
||||
.placeholder = "Type a message",
|
||||
.text = model.draft,
|
||||
.submit_on_enter = true,
|
||||
.on_input = Ui.inputMsg(.draft_edited),
|
||||
.on_submit = .send,
|
||||
.semantics = .{ .label = "Message" },
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -3,14 +3,14 @@ import { AttrTable } from "@/components/attr-table";
|
||||
|
||||
# Markdown
|
||||
|
||||
Renders a markdown string (a GFM subset, pipe tables included) as native widgets through the same text pipeline as every other component — deterministic layout, selectable text. `source` is required and must be one `{binding}`; the element takes no children. Links dispatch `on-link` with the URL as payload (bare URLs autolink), `<details>` blocks toggle through `on-details` plus a model-owned `details-expanded` flag list, and `#123` references linkify through `issue-link-base`. Fenced blocks lower through the reusable [Code](/docs/components/code) component, so indentation and syntax behavior stay identical.
|
||||
Renders a markdown string (a GFM subset, including pipe tables and safe presentational HTML) as native widgets through the same text pipeline as every other component — deterministic layout, selectable text. `source` is required and must be one `{binding}`; the element takes no children. Applications can pass already-loaded image mappings through `images`, links dispatch `on-link` with the URL as payload (bare URLs autolink), `<details>` blocks toggle through `on-details` plus a model-owned `details-expanded` flag list, and `#123` references linkify through `issue-link-base`. Fenced blocks lower through the reusable [Code](/docs/components/code) component, so indentation and syntax behavior stay identical.
|
||||
|
||||
<ComponentPreview name="markdown" alt="A markdown document rendered by the engine" caption="headings, emphasis, inline code, lists, links, and a code block" />
|
||||
|
||||
## Markup
|
||||
|
||||
```html
|
||||
<markdown source="{release_notes}" on-link="open_link" issue-link-base="https://github.com/native-sdk/native/issues/"></markdown>
|
||||
<markdown source="{release_notes}" images="{markdown_images}" on-link="open_link" issue-link-base="https://github.com/native-sdk/native/issues/"></markdown>
|
||||
```
|
||||
|
||||
## Programmatic construction (Zig)
|
||||
@@ -21,11 +21,33 @@ The builder is the `canvas.markdown` module, parameterized over the app's Msg ty
|
||||
const Md = native_sdk.canvas.markdown.Markdown(Msg);
|
||||
|
||||
Md.view(ui, model.release_notes, .{
|
||||
.images = model.markdown_images,
|
||||
.on_link = Ui.linkMsg(.open_link),
|
||||
.issue_link_base = "https://github.com/native-sdk/native/issues/",
|
||||
})
|
||||
```
|
||||
|
||||
Image discovery writes canonical, entity-decoded URLs into caller-owned bounded storage. Consume each `value()` while that storage is alive, copy accepted sources into the model, and use the same bytes for both `fx.loadImage` and the eventual `ResolvedImage.source` mapping:
|
||||
|
||||
```zig
|
||||
var source_storage: [canvas.markdown.max_markdown_images]canvas.markdown.CollectedImageSource = undefined;
|
||||
const sources = canvas.markdown.collectImageSources(model.release_notes, &source_storage);
|
||||
for (sources) |*collected| {
|
||||
const source = collected.value();
|
||||
// Validate the scheme, copy source into the model, then issue fx.loadImage.
|
||||
}
|
||||
```
|
||||
|
||||
## HTML subset
|
||||
|
||||
README- and comment-style HTML is lowered onto native presentation rather than passed to a browser:
|
||||
|
||||
- Text: `<b>`, `<strong>`, `<i>`, `<em>`, `<var>`, `<s>`, `<strike>`, `<del>`, `<u>`, `<ins>`, `<code>`, `<kbd>`, `<samp>`, `<tt>`, `<mark>`, `<small>`, `<sub>`, and `<sup>`.
|
||||
- Content: `<a href>`, `<img src alt width height>`, `<br>`, `<wbr>`, `<q>`, HTML comments, core named entities, and numeric entities. A leading image in a paragraph, heading, list item, blockquote, or pipe-table cell becomes a native image when `images` contains a successful `canvas.markdown.ResolvedImage` mapping for its source; unresolved and mid-paragraph images fall back to alt text. The view never fetches remote media — discover bounded sources with `canvas.markdown.collectImageSources`, read each caller-owned `CollectedImageSource` through `value()`, load it through `fx.loadImage`, retain successful ids and dimensions in the model, and pass mappings with that canonical source back on the next rebuild.
|
||||
- Blocks and wrappers: `<h1>` through `<h6>`, `<p>`, `<blockquote>`, `<pre>`, `<hr>`, list items, and the common `<div align="center">` README pattern. Harmless list, table, and container wrappers are accepted as readable native content; use Markdown pipe tables for full native table layout. `<details>` and `<summary>` retain their controlled expansion behavior.
|
||||
|
||||
This is a sanitized native subset, not a DOM. CSS, scripts, forms, embeds, event attributes, and arbitrary HTML do not execute; unsupported or malformed tags remain literal text. Only `href`, image `src`/`alt`/dimensions, and block `align` affect presentation.
|
||||
|
||||
## Attributes
|
||||
|
||||
<AttrTable element="markdown" attrs={["source", "on-link", "on-details", "details-expanded", "issue-link-base"]} />
|
||||
<AttrTable element="markdown" attrs={["source", "images", "on-link", "on-details", "details-expanded", "issue-link-base"]} />
|
||||
|
||||
@@ -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",
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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."),
|
||||
|
||||
@@ -3,7 +3,9 @@ import { AttrTable } from "@/components/attr-table";
|
||||
|
||||
# Textarea
|
||||
|
||||
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. Enter (and Shift+Enter) inserts a newline instead of submitting; when a textarea carries `on-submit`, the submit rides the primary chord — Cmd+Enter on macOS, Ctrl+Enter elsewhere. Give it a definite `width` and `height` (or a `grow`) to size the editing box.
|
||||
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" />
|
||||
|
||||
@@ -35,6 +37,7 @@ ui.el(.textarea, .{
|
||||
"placeholder",
|
||||
"disabled",
|
||||
"autofocus",
|
||||
"submit-on-enter",
|
||||
"on-input",
|
||||
"on-submit",
|
||||
]}
|
||||
|
||||
@@ -22,7 +22,7 @@ The `native doctor` command checks your development environment for issues.
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Manifest</td>
|
||||
<td><code>app.zon</code> validation (only when <code>--manifest</code> is passed)</td>
|
||||
<td><code>app.json</code> or <code>app.zon</code> validation when a manifest is discovered or passed explicitly</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Log directory</td>
|
||||
@@ -30,7 +30,7 @@ The `native doctor` command checks your development environment for issues.
|
||||
</tr>
|
||||
<tr>
|
||||
<td>CEF</td>
|
||||
<td>CEF distribution presence when Chromium is selected by <code>app.zon</code> or <code>--web-engine chromium</code></td>
|
||||
<td>CEF distribution presence when Chromium is selected by the app manifest or <code>--web-engine chromium</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Signing tools</td>
|
||||
@@ -46,10 +46,10 @@ The `native doctor` command checks your development environment for issues.
|
||||
native doctor
|
||||
|
||||
# Strict mode (exits non-zero on any warning)
|
||||
native doctor --manifest app.zon --strict
|
||||
native doctor --manifest app.json --strict
|
||||
|
||||
# Check CEF setup
|
||||
native doctor --manifest app.zon
|
||||
native doctor --manifest app.json
|
||||
```
|
||||
|
||||
## Flags
|
||||
@@ -68,11 +68,11 @@ native doctor --manifest app.zon
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--manifest</code></td>
|
||||
<td>Path to <code>app.zon</code></td>
|
||||
<td>Path to <code>app.json</code> or <code>app.zon</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--web-engine</code></td>
|
||||
<td>Temporarily override the engine from <code>app.zon</code> with <code>system</code> or <code>chromium</code></td>
|
||||
<td>Temporarily override the engine from the app manifest with <code>system</code> or <code>chromium</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--cef-dir</code></td>
|
||||
|
||||
@@ -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.
|
||||
@@ -104,9 +104,21 @@ A loaded image occupies one of the registry's **16 slots** until you release it,
|
||||
|
||||
Unlike a load, unregister is **synchronous registry surgery, not an effect**: no result Msg follows (registration by `imageLoad` has a terminal because I/O and decode can fail; releasing a slot cannot), and aimed at an id with no registration it no-ops — `imageCancel`'s idle rule. It frees only the **current** registration: a load in flight under the id is untouched, and its terminal still registers the pixels, re-occupying the id. To evict an id whose load is still running, `Cmd.imageCancel(id)` first, then unregister.
|
||||
|
||||
When many small visuals ship together, one slot can hold a texture atlas instead: `<image>` and `<avatar>` accept `source-x`, `source-y`, `source-width`, and `source-height` together, in decoded-image pixel coordinates. Zig views set the equivalent `ElementOptions.image_src` rectangle. Cropped widgets use nearest sampling so adjacent regions cannot bleed into the tile. Every atlas region references the same registered ImageId, so it consumes one registry slot; use the dimensions reported by the load result because decode-to-fit may change the atlas geometry.
|
||||
|
||||
## 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 +126,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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -32,4 +32,4 @@ Native SDK is designed for a world where humans and AI agents build software tog
|
||||
|
||||
## Where to go next
|
||||
|
||||
Ready to build? The [Quick Start](/docs/quick-start) takes you from install to a running, tested app, the [CLI](/docs/cli) covers every verb the tooling provides, and [Config](/docs/app-zon) documents the `app.zon` manifest.
|
||||
Ready to build? The [Quick Start](/docs/quick-start) takes you from install to a running, tested app, the [CLI](/docs/cli) covers every verb the tooling provides, and [Config](/docs/app-zon) documents `app.json` and its `app.zon` compatibility format.
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 |
|
||||
@@ -92,7 +92,7 @@ A few widget kinds are deliberately **not** markup elements because their shape
|
||||
|
||||
Apps with their own iconography can parse any stroke-dialect/Feather/Tabler-dialect SVG at comptime (`canvas.svg_icon.parseComptime(@embedFile("icons/logo.svg"))`) and register it at boot with `canvas.icons.registerAppIcons(&table)`: the draw paths (icon leaves via `ui.appIcon`, `ElementOptions.icon` on buttons, toggle buttons, icon buttons, and list/menu items) resolve registered names exactly like built-ins. Markup reaches them through the `app:` NAMESPACE (`<icon name="app:waveform"/>`, `icon="app:waveform"`): bare names keep the closed built-in vocabulary (the compiled engine proves them at comptime, where a runtime registration cannot exist), while `app:` names are structurally accepted by both engines and verified by `native check` against the model contract - declare the table as `pub const app_icons` on the app root so the contract emit reflects the same names `main` registers. Bound icon names (`icon="{binding}"`) make the choice model data - a per-row status icon, a play/pause toggle - and any name that fails to resolve at draw time renders the missing-icon fallback (a slashed circle) with a Debug warning naming the value, never a silent gap.
|
||||
|
||||
Layout attributes: `gap`, `padding`, `grow`, `width`, `height`, `wrap`, `text-alignment`, `columns`, `main`, `cross`, `virtualized`, and the anchored-floating family on `dropdown-menu` and `tooltip`: `anchor` (`below`/`above` — floats the surface against its parent, flipping when the preferred side does not fit), `anchor-alignment` (`start`/`end`/`stretch`), `anchor-offset` (points, default 4), plus `tooltip-delay` on `tooltip` alone (the hover-intent show delay in milliseconds, default the 600ms token; `"0"` shows the instant the trigger is hovered; keyboard-focus reveals are always immediate — a teaching error without `anchor` beside it). An anchored tooltip's visibility is runtime-owned hover intent on its trigger, unlike the model-owned dropdown. `gap` belongs to flow containers: the stacking kinds (`stack`, `panel`, `card`, and the surface/modal elements) layer their children, so `gap` there is rejected with a teaching error — wrap the children in a `column` (or `row`) inside for flow (on `split` it sets the divider band thickness). `width` and `height` are definite sizes: the element is exactly that size, so intrinsic content neither shrinks nor silently overflows it (`resizable` treats `width` as its initial width), and debug builds log a `zero_canvas_layout` diagnostic whenever children's minimum sizes overflow their container. `min-width` is a floor without the definite max: the element may grow past it but never shrink below — on split panes it is what bounds the divider drag. `wrap` applies to `text` only: `wrap="true"` word-wraps the content at the width the element receives and reserves the wrapped height in columns; `wrap="false"` and unset are the honest single-line mode — the content measures and paints as one line, and content that does not fit follows `overflow`. `overflow` (`text` only) names the single-line policy for content that does not fit: `ellipsis` (the default) elides the tail behind a trailing … measured with the same metrics paint uses, the right choice for width-constrained list-row titles; `clip` hard-cuts at the frame for fixed-format content like a duration column, where "1…" would be worse than a partial glyph. There is deliberately no overflow-visible — painting past the frame is the bug class the layout audit exists to catch. `text-alignment` (`start`|`center`|`end`) aligns text content in text leaves, status bars, and surface titles. `columns` fixes a `grid`'s column count (grid-only — anywhere else it is a teaching error; omit it for the derived near-square grid). `tree-level` gives flat sibling rows with `role="treeitem"` a one-based logical depth so Left/Right can resolve parents and children; omit it for structurally nested rows. Appearance: `variant`, `size` (the control scale `default`|`sm`|`lg`|`icon` on every sized element; on `text` also the typography rungs `heading`|`display` — named typography token steps for section headings and hero stats, themable like every token, and text-only: on a control they are a teaching error naming text as their home; numeric sizes are refused by design — retheme the typography tokens to move the whole scale), `disabled`, `checked`, `selected`, `expanded` (tree rows: model-owned disclosure state; omit on leaves). Focus: `autofocus` (focusable controls only) moves keyboard focus to the element when it mounts or when the bound value turns on — edge-triggered, so holding it true never re-steals focus; it is the model-driven way to focus an editor on create or give a keyboard-first app its first focus. Semantics: `role` (`treeitem` also makes a row part of its tree's roving focus set), `label` (an explicit accessible name — it replaces the element's text; see [Accessibility](#accessibility)). Identity: `key` (sibling-scoped) and `global-key` (survives moving between containers — board cards, tab pages). Window chrome: `window-drag="true"` marks the element as a window-drag surface for hidden-titlebar windows (see [Hidden titlebar](#hidden-titlebar-drag-regions-and-chrome-insets)).
|
||||
Layout attributes: `gap`, `padding`, `grow`, `width`, `height`, `min-width`, `max-width`, `wrap`, `text-alignment`, `columns`, `main`, `cross`, `virtualized`, and the anchored-floating family on `dropdown-menu` and `tooltip`: `anchor` (`below`/`above` — floats the surface against its parent, flipping when the preferred side does not fit), `anchor-alignment` (`start`/`end`/`stretch`), `anchor-offset` (points, default 4), plus `tooltip-delay` on `tooltip` alone (the hover-intent show delay in milliseconds, default the 600ms token; `"0"` shows the instant the trigger is hovered; keyboard-focus reveals are always immediate — a teaching error without `anchor` beside it). An anchored tooltip's visibility is runtime-owned hover intent on its trigger, unlike the model-owned dropdown. `gap` belongs to flow containers: the stacking kinds (`stack`, `panel`, `card`, and the surface/modal elements) layer their children, so `gap` there is rejected with a teaching error — wrap the children in a `column` (or `row`) inside for flow (on `split` it sets the divider band thickness). `width` and `height` are definite sizes: the element is exactly that size, so intrinsic content neither shrinks nor silently overflows it (`resizable` treats `width` as its initial width), and debug builds log a `zero_canvas_layout` diagnostic whenever children's minimum sizes overflow their container. `min-width` is a floor without the definite max: the element may grow past it but never shrink below — on split panes it is what bounds the divider drag. `max-width` is the inverse ceiling without a definite minimum, so the element still shrinks with a narrow parent; a growing child inside a centered row is the responsive content-column pattern. `wrap` applies to `text` only: `wrap="true"` word-wraps the content at the width the element receives and reserves the wrapped height in columns; `wrap="false"` and unset are the honest single-line mode — the content measures and paints as one line, and content that does not fit follows `overflow`. `overflow` (`text` only) names the single-line policy for content that does not fit: `ellipsis` (the default) elides the tail behind a trailing … measured with the same metrics paint uses, the right choice for width-constrained list-row titles; `clip` hard-cuts at the frame for fixed-format content like a duration column, where "1…" would be worse than a partial glyph. There is deliberately no overflow-visible — painting past the frame is the bug class the layout audit exists to catch. `text-alignment` (`start`|`center`|`end`) aligns text content in text leaves, status bars, and surface titles. `columns` fixes a `grid`'s column count (grid-only — anywhere else it is a teaching error; omit it for the derived near-square grid). `tree-level` gives flat sibling rows with `role="treeitem"` a one-based logical depth so Left/Right can resolve parents and children; omit it for structurally nested rows. Appearance: `variant`, `size` (the control scale `default`|`sm`|`lg`|`icon` on every sized element; on `text` also the typography rungs `heading`|`display` — named typography token steps for section headings and hero stats, themable like every token, and text-only: on a control they are a teaching error naming text as their home; numeric sizes are refused by design — retheme the typography tokens to move the whole scale), `disabled`, `checked`, `selected`, `expanded` (tree rows: model-owned disclosure state; omit on leaves). Focus: `autofocus` (focusable controls only) moves keyboard focus to the element when it mounts or when the bound value turns on — edge-triggered, so holding it true never re-steals focus; it is the model-driven way to focus an editor on create or give a keyboard-first app its first focus. Semantics: `role` (`treeitem` also makes a row part of its tree's roving focus set), `label` (an explicit accessible name — it replaces the element's text; see [Accessibility](#accessibility)). Identity: `key` (sibling-scoped) and `global-key` (survives moving between containers — board cards, tab pages). Window chrome: `window-drag="true"` marks the element as a window-drag surface for hidden-titlebar windows (see [Hidden titlebar](#hidden-titlebar-drag-regions-and-chrome-insets)).
|
||||
|
||||
## Styling with design tokens
|
||||
|
||||
@@ -197,13 +197,13 @@ Type discipline is teaching errors over silent coercion: a string minus a number
|
||||
|
||||
The function library is closed — seventeen functions, and growing the set is a toolkit change: `fixed(x, digits)` (exact decimals), `thousands(n)` (`1,234,567`), `percent(fraction, digits?)` (`0.42` → `42%`), `date(ts)`/`time(ts)`/`datetime(ts)` (a model unix timestamp in seconds, formatted in UTC — formatting model time is pure; *reading the clock* is an effect, so `now()` is a teaching error pointing at the model/fx loop), `upper`/`lower`/`trim` (ASCII case mapping; other characters pass through), `min`/`max`/`abs`, `round`/`floor`/`ceil` (number → whole number), `plural(count, singular, plural)` (`{plural(n, 'item', 'items')}`), and `pad(x, width)` (zero-pads the integer value of x to `width` digits — `pad(7, 2)` → `07`, a negative sign precedes the zeros without counting toward the width, and numbers wider than `width` print in full; the mm:ss counter function: `{pad(minutes, 2)}:{pad(seconds, 2)}`).
|
||||
|
||||
Complexity is bounded and taught one past the bound: at most 256 bytes, 64 terms, and 16 nesting levels per expression — anything larger is a named model function by design.
|
||||
Complexity is bounded and taught one past the bound: at most 256 bytes, 64 terms, and 16 nesting levels per expression — anything larger is a named core helper by design.
|
||||
|
||||
The same line separates inline arithmetic from model functions. Inline expression arithmetic is sanctioned for one-off presentation-level derivation — `{percent(done / total)}` on the single readout that shows it is exactly what expressions are for. The moment a derivation is reused in a second binding, deserves a name, or carries meaning the model owns (a threshold, a rule, a policy), it belongs in a named model function: `{completionRate}` reads at the binding site, tests in Zig, and changes in one place.
|
||||
The same line separates inline arithmetic from core helpers. Inline expression arithmetic is sanctioned for one-off presentation-level derivation — `{percent(done / total)}` on the single readout that shows it is exactly what expressions are for. The moment a derivation is reused in a second binding, deserves a name, or carries meaning the model owns (a threshold, a rule, a policy), it belongs in a named helper: `{completionRate}` reads at the binding site, tests in the core's language, and changes in one place.
|
||||
|
||||
Bindings resolve against your model: struct fields, zero-argument public methods, and — for `for each` — slices, public array declarations, or functions taking `(*const Model)` or `(*const Model, std.mem.Allocator)` (the allocator variant is how filtered lists work). Enums resolve to their tag names. Expressions are allowed in text interpolation, attribute values, `if` tests, and template args at use sites; message tags and payloads, `for each` iterables, and import paths stay path-only.
|
||||
In the default TypeScript core, model fields bind by their authored names (`nextId` is `{nextId}`), and an exported helper declared in `src/core.ts` with exactly one `Model` parameter becomes a derived binding under its exported name. Scalar-returning helpers drive text/attributes; array-returning helpers drive `for each` and chart series; item record fields continue the path. See [TypeScript Cores](/docs/typescript) for the supported return shapes and module boundary.
|
||||
|
||||
Scalar bindings take the allocator form too: `{summary}` binds `pub fn summary(m: *const Model, arena: std.mem.Allocator) []const u8` directly, formatting a derived display string into the build arena — it works in text interpolation, attribute values, message payloads, and as function arguments (`{upper(summary)}`). The one exclusion is comparison operands (`==`, `<`, ...), which reject arena-computed values with a teaching error: compare the source fields, or bind a `bool`-returning method. For `<if test>`, write the predicate out (`test="{count > 0}"`) or bind one (`test="{hasItems}"`) instead of leaning on numeric truthiness.
|
||||
In a Zig core, bindings resolve struct fields and zero-argument public model methods. A `for each` iterable may also be a slice, public array declaration, `pub fn (*const Model) []const T`, or `pub fn (*const Model, std.mem.Allocator) []const T`; the allocator form derives filtered rows into the one-build arena. Scalar bindings take that form too: `{summary}` binds `pub fn summary(m: *const Model, arena: std.mem.Allocator) []const u8`. Arena-computed values work in text, attributes, message payloads, and function arguments, but not as comparison operands — compare source fields or bind a boolean helper. In either tier, write `<if test>` predicates explicitly (`test="{count > 0}"` or `test="{hasItems}"`) instead of leaning on numeric truthiness. Expressions are allowed in text interpolation, attribute values, `if` tests, and template args at use sites; message tags and payloads, `for each` iterables, and import paths stay path-only.
|
||||
|
||||
## Messages
|
||||
|
||||
@@ -238,6 +238,8 @@ pub fn draft(model: *const Model) []const u8 {
|
||||
|
||||
`on-resize` (on the `split` element; `Ui.valueMsg(.tag)` on `on_resize` in Zig views) names a variant whose payload is the new first-pane fraction (`f32`): after every divider drag, keyboard adjustment, or assistive increment/decrement the runtime delivers the fraction it already applied and clamped — store it in the model and echo it back through the split's `value`, and rebuilds never fight live resizing.
|
||||
|
||||
`on-drag` makes any element a draggable spatial object and names the closed record `{ sourceId, phase, x, y, viewWidth, viewHeight }`. Markup supplies numeric `sourceId` from a binding such as `on-drag="card_dragged:{card.id}"`; the runtime supplies `phase` (a number able to represent 0, 1, and 2) plus floating-point view-local `x`, `y`, `viewWidth`, and `viewHeight` (`f32`/`f64` in Zig, `number` in TypeScript). Geometry stays floating-point because pointer capture can carry negative coordinates outside the view. Phase 0 means motion, 1 release, and 2 cancellation. The renderer lifts the actual source appearance under the pointer at full opacity and leaves its in-flow space blank. Apps that need precise insertion can keep committed data unchanged during phase 0 while returning a derived view that moves the same `global-key` into the candidate position. Its hidden in-flow rendering is the one card-sized reserved slot: it begins at the source, moves to each candidate, and never duplicates. Keyed draggable neighbors ease between candidate poses; release carries the floating item from its pointer position into that slot and commits the exact order. A plain Escape during the drag dispatches phase 2, consumes that key, and carries the item back to its source slot; pointer cancellation uses the same path. Reduced-motion appearances snap these reflows. `examples/kanban` demonstrates within-column and cross-column reordering with this pattern.
|
||||
|
||||
`on-dismiss` (on the dismissible surfaces: `dialog`, `drawer`, `sheet`, `dropdown-menu`; `ElementOptions.on_dismiss` in Zig views) dispatches when Escape or a click outside dismisses the surface, so the model owns the close — clear the open flag in `update`. The engine hides the surface immediately as an optimistic echo; the next rebuild's source tree is truth. Escape works regardless of focus: it dismisses the nearest surface up the focused widget's chain, and when nothing relevant is focused — a menu opened from a plain-text trigger takes no focus — it falls back to the topmost mounted anchored surface. `on-hold` (any element; `ElementOptions.on_hold`) is press-and-hold: a pointer held ~350 ms dispatches the hold Msg and the release presses nothing, a quick click dispatches `on-press` as usual, and a right/ctrl-click with no context menu on its route dispatches the hold Msg immediately (a declared `<context-menu>` always wins the right-click) — the crumb-switcher shape (`on-press` selects, `on-hold` opens an anchored menu). Both legs are live-drivable through automation: `native automate widget-hold <view> <id>` runs the pointer+timer gesture, `widget-context-press <view> <id>` the secondary click.
|
||||
|
||||
`on-hover-enter` and `on-hover-leave` (any element; `ElementOptions.on_hover_enter` / `on_hover_leave`) are the pointer-hover pair — Elm's `onMouseEnter`/`onMouseLeave`: enter dispatches once when the pointer enters the element's hit region, leave once when it exits — discrete containment edges, never per-move, so hover previews, prefetch, and hover cards are ordinary Msgs. Binding either makes the element hover-hittable the way a bound press makes it pressable (so both are legal everywhere, and a pointer over plain text or icons inside counts as inside), but never pressable: clicks keep falling through, no accessibility action is announced, and no hover wash appears — the wash stays the visual channel of acting controls ([state washes](/docs/building-components#theming-your-component)), so a `quiet-hover` content tile that binds hover stays visually quiet while the model hears it. Nested bound elements track containment independently (moving onto a bound row inside a bound card never leaves the card); enters fire outermost-first and leaves innermost-first. Every enter is answered by exactly one eventual leave while the app runs: the leave Msg is captured while the element stands — kept fresh as rebuilds change the binding, retained from the last build that bound one — so it still arrives when the exit is the element unmounting. Exits follow the same resolution the hover wash already uses: moving off and the pointer leaving the window are direct edges; content scrolling or reflowing out from under a stationary pointer re-hit-tests the last pointer position (a scrolled list fires the same leave and enter a real move would); and a dismissal removing the surface under the pointer delivers that surface's leaves immediately, with whatever it reveals entered when the model's close rebuild re-hit-tests — pair dismissible surfaces with `on-dismiss`, as always. Overlays occlude hover exactly as they occlude clicks (the topmost surface under the pointer wins). Hover comes from mouse and trackpad pointers only: containment advances on hover-phase motion — a pointer floating without contact, which touch physically cannot produce — so touch input never synthesizes hover, and anything reachable only by hover must stay reachable another way.
|
||||
@@ -246,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
|
||||
|
||||
@@ -305,13 +307,28 @@ export function pinchMsg(pinch: PinchEvent): Msg | null {
|
||||
|
||||
macOS emits pinch today (trackpad `magnifyWithEvent:`); Windows precision-touchpad and GTK gesture sources are staged follow-ups — on those platforms the channel simply never fires. Everything flows from the journaled input events, so recorded sessions replay the identical zoom, and tests (or agents without a trackpad) drive the real event stream with `native automate widget-pinch <view-label> <scale> [x y]` — `<scale>` is the final multiplicative zoom for the gesture (one change event whose product lands exactly on it), anchor point defaulting to the view center.
|
||||
|
||||
## File drops
|
||||
|
||||
Native file drops reach a Zig `UiApp` through `Options.on_drop`, after any targeted canvas-widget drop handler. The event carries the source `window_id`, `view_label`, an optional view-local `point`, and every dropped path. TypeScript cores export the same channel as `dropMsg`; import its structural record from `@native-sdk/core/events`. Paths are `Uint8Array` byte text so non-ASCII filesystem names cross without a JS string conversion:
|
||||
|
||||
```ts
|
||||
import { type FileDropEvent } from "@native-sdk/core/events";
|
||||
|
||||
export function dropMsg(drop: FileDropEvent): Msg | null {
|
||||
if (drop.paths.length === 0) return null;
|
||||
return { kind: "file_opened", path: drop.paths[0] };
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
On macOS, every non-virtualized `scroll` region is driven by an invisible `NSScrollView`: momentum and the system overlay scrollbar are OS-computed while the engine renders the content. This needs no app code — scroll offsets stay on the widget (`sync`, snapshot offsets, and programmatic scrolls work unchanged), and the engine's drawn scrollbar stands down for natively driven regions. Other platforms keep the engine's wheel physics.
|
||||
|
||||
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}">
|
||||
@@ -420,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(.{
|
||||
@@ -432,13 +449,44 @@ 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. 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",
|
||||
}),
|
||||
```
|
||||
|
||||
</CodeToggle>
|
||||
|
||||
Failure and overflow are always visible: a spawn that cannot run delivers an exit Msg with reason `rejected`, a fetch that cannot run delivers a response Msg with outcome `rejected`, and a file effect that cannot run delivers a result Msg with outcome `rejected`; dropped or truncated lines carry counts and flags; `cancel` kills and reaps the process and always ends in exactly one `cancelled` exit Msg, with no further line Msgs after it. Tests use the fake executor (`effects.executor = .fake`) to assert on spawn, fetch, and file requests and feed synthetic lines, stderr (`feedStderr`, collect spawns), exits, responses, and file results back deterministically — set it before the first frame and `init_fx` boot spawns are recorded too. See `examples/effects-probe`.
|
||||
|
||||
For timestamps, the facade owns the clocks (Zig 0.16 puts `std.time` behind `std.Io`, which `update` never sees): `native_sdk.nowMs()` / `nowNanoseconds()` read the wall clock and `monotonicMs()` / `monotonicNanoseconds()` the duration clock. Time-dependent logic stores the `native_sdk.Clock` seam in the model (`.system` by default) so tests substitute a deterministic `native_sdk.TestClock` and advance it by hand.
|
||||
|
||||
## 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`. `restorePolicy: "center_on_primary"` centers a fresh descriptor with no `x`/`y` on macOS; Windows and Linux currently keep their native default placement. The default policy is `"clamp_to_visible_screen"`. `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 {
|
||||
@@ -467,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.
|
||||
|
||||
@@ -525,9 +573,27 @@ ui.image(.{ .image = model.chart_image, .width = 120, .height = 80, .semantics =
|
||||
<avatar image="{avatar_image}" label="Octocat">OC</avatar>
|
||||
```
|
||||
|
||||
One registered image can be a texture atlas. `ElementOptions.image_src` is an optional `geometry.RectF` in decoded-image pixel coordinates; Native markup spells the same rectangle with all four source attributes. The widget frame stays the destination, a source rectangle crossing the registered image bounds is clipped, and cropped widgets use nearest sampling so adjacent atlas regions cannot bleed into the tile:
|
||||
|
||||
```zig
|
||||
ui.image(.{
|
||||
.image = model.atlas_image,
|
||||
.image_src = geometry.RectF.init(64, 32, 24, 24),
|
||||
.width = 48,
|
||||
.height = 48,
|
||||
.semantics = .{ .label = "Warning badge" },
|
||||
})
|
||||
```
|
||||
|
||||
```html
|
||||
<image image="{atlas_image}" source-x="64" source-y="32"
|
||||
source-width="24" source-height="24" width="48" height="48"
|
||||
label="Warning badge" />
|
||||
```
|
||||
|
||||
`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
|
||||
|
||||
@@ -589,6 +655,7 @@ Markdown builds on the same model. `native_sdk.markdown` maps a GitHub-flavored
|
||||
```zig
|
||||
const Md = native_sdk.markdown.Markdown(Msg);
|
||||
Md.view(ui, issue.body, .{
|
||||
.images = model.markdown_images, // successful fx.loadImage mappings
|
||||
.on_link = Ui.linkMsg(.open_url),
|
||||
.on_details = Md.detailsMsg(.toggle_details),
|
||||
.details_expanded = &model.details_expanded, // caller-owned flags, elm-style
|
||||
@@ -605,10 +672,10 @@ GFM pipe tables map onto the real `table`/`table-row`/`table-cell` widgets: the
|
||||
In markup, the `<markdown>` element wires all of this declaratively:
|
||||
|
||||
```html
|
||||
<markdown source="{issue_body}" on-link="open_url" on-details="toggle_details" details-expanded="{details_expanded}" issue-link-base="ghissue://" />
|
||||
<markdown source="{issue_body}" images="{markdownImages}" on-link="open_url" on-details="toggle_details" details-expanded="{details_expanded}" issue-link-base="ghissue://" />
|
||||
```
|
||||
|
||||
`source` (required) is one `{binding}` producing the markdown text — a string field, zero-arg method, or arena-taking method. `on-link` and `on-details` are bare `Msg` tags (the runtime supplies their payloads: the URL as `[]const u8`, the details index as `usize`), `details-expanded` names a `[]const bool` iterable through the same sources `for each` accepts, and `issue-link-base` (a literal prefix or one `{binding}`) turns `#123` references into links to base ++ number. Everything but `source` is optional — without the details wiring, `<details>` blocks render collapsed and inert. Both the interpreter and the comptime compiler implement the element identically.
|
||||
`source` (required) is one `{binding}` producing the markdown text — a string field, zero-arg method, or arena-taking method. `images` optionally binds `[]const canvas.markdown.ResolvedImage`: give `canvas.markdown.collectImageSources` a caller-owned `[]canvas.markdown.CollectedImageSource`, consume each canonical source through `value()` while that storage is alive, load it through `fx.loadImage`, and retain the source, successful id, and dimensions in the model. Return those mappings from a model or arena-taking method; the view performs no I/O. `on-link` and `on-details` are bare `Msg` tags (the runtime supplies their payloads: the URL as `[]const u8`, the details index as `usize`), `details-expanded` names a `[]const bool` iterable through the same sources `for each` accepts, and `issue-link-base` (a literal prefix or one `{binding}`) turns `#123` references into links to base ++ number. Everything but `source` is optional — without the details wiring, `<details>` blocks render collapsed and inert. Both the interpreter and the comptime compiler implement the element identically.
|
||||
|
||||
## Pipeline components: stepper, timeline, and nav
|
||||
|
||||
@@ -698,10 +765,10 @@ Zig-built views get the same discipline at tree level: `canvas.expectA11yAuditSw
|
||||
|
||||
The model-contract artifact — `zig-out/model-contract.zon`, a reflection of your `Model`/`Msg` — is refreshed by `native test` in every app shape (apps that own their build can also run `zig build model-contract` directly). With that artifact fresh, `native check` verifies markup against your app's actual surface — no app compile in the loop:
|
||||
|
||||
- View → model: every binding path, `for each` iterable, `key` field, message tag, and payload type must exist on the model with the right shape, and expressions type-check with the real binding types — `{count > 'a'}` is an error naming `count` and its Zig type. Unknown names get a did-you-mean over your model's actual fields.
|
||||
- Model → view: model fields, query fns, and `Msg` tags that no view binds or dispatches are reported as **warnings** — state only `update`/fx logic touches is legitimate, so declare it: `pub const view_unbound = .{ "next_id" };` on `Model` or `Msg` opts names out. `--strict` promotes the warnings to failures.
|
||||
- View → model: every binding path, `for each` iterable, `key` field, message tag, and payload type must exist on the model with the right shape, and expressions type-check with the real binding types — `{count > 'a'}` is an error naming `count` and its core type. Unknown names get a did-you-mean over your model's actual fields.
|
||||
- Model → view: model fields, derived helpers/query functions, and `Msg` tags that no view binds or dispatches are reported as **warnings**. State only `update` or effects logic touches is legitimate, so opt it out in the core's vocabulary: TypeScript declares `export const viewUnbound = ["nextId", "tick"] as const` in `src/core.ts`; Zig declares `pub const view_unbound = .{ "next_id", "tick" };` on `Model` or `Msg`. Use the names exactly as that core authored them. `--strict` promotes the warnings to failures.
|
||||
- Template args are part of a template's interface: the kinds of use-site arguments flow into the template body (a string passed into a `width` arg fails at the use), slot content checks in the consumer's scope, and the check follows the whole `<import>` closure.
|
||||
|
||||
A missing or stale artifact (the artifact carries a hash of your Zig sources) degrades to structural checking with one loud line ("model contract: not yet built - bindings checked structurally only; run `native test` to enable typed checks") — never a false pass; the compiled engine still enforces the same contract at build time, and a conformance suite holds the two checkers to identical accept/reject sets. Model state consumed only by a Zig-built view needs `view_unbound` too — the markup checker cannot see Zig view reads.
|
||||
A missing or stale artifact (the artifact carries a hash of the app's core sources) degrades to structural checking with one loud line ("model contract: not yet built - bindings checked structurally only; run `native test` to enable typed checks") — never a false pass; the compiled engine still enforces the same contract at build time, and a conformance suite holds the two checkers to identical accept/reject sets. Model state consumed only by a Zig-built view needs `view_unbound` too — the markup checker cannot see Zig view reads.
|
||||
|
||||
For the component catalog and styling model, see [Built-in Components](/docs/built-in-components). For dropping down to the programmatic API, the `canvas.Ui(Msg)` builder produces exactly the same trees the markup compiles to.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Packaging
|
||||
|
||||
The Native SDK provides tooling to bundle your app into distributable packages for macOS, Linux, and Windows. A native-rendered app packages as a single binary plus icons, metadata, and whatever lives in your `assets/` directory — no browser runtime. On macOS the asset tree is mirrored into the bundle at its app-relative path (`Contents/Resources/assets/`), and the packaged app resolves relative asset paths (audio files, bundled fonts) against `Contents/Resources`, so a path like `assets/music/track.mp3` names the same file in a dev run and in the installed app. Keep large optional data out of `assets/` when you package, or it ships. The frontend-asset and CEF sections below apply only to apps that [embed web content](/docs/frontend); Chromium packaging is currently supported for macOS and includes the CEF runtime when `.web_engine = "chromium"` and the matching CEF layout is installed.
|
||||
The Native SDK provides tooling to bundle your app into distributable packages for macOS, Linux, and Windows. A native-rendered app packages as a single binary plus icons, metadata, and whatever lives in your `assets/` directory — no browser runtime. On macOS the asset tree is mirrored into the bundle at its app-relative path (`Contents/Resources/assets/`), and the packaged app resolves relative asset paths (audio files, bundled fonts) against `Contents/Resources`, so a path like `assets/music/track.mp3` names the same file in a dev run and in the installed app. Keep large optional data out of `assets/` when you package, or it ships. The frontend-asset and CEF sections below apply only to apps that [embed web content](/docs/frontend); Chromium packaging is currently supported for macOS and includes the CEF runtime when `"web_engine": "chromium"` and the matching CEF layout is installed.
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -11,7 +11,7 @@ native build
|
||||
native package --target macos
|
||||
```
|
||||
|
||||
`native package` picks up the manifest at `app.zon` and the binary at `zig-out/bin/<name>` automatically; pass `--manifest`, `--binary`, or the other flags below for more control. Zero-config apps package without ejecting: the two commands above are the complete path from source to distributable, and `native eject` exists only for apps that want to own their build files. Apps that do own their build ([ejected](/docs/cli) or scaffolded with `--full`) also get a `zig build package` step that wires the same thing into the build graph.
|
||||
`native package` picks up `app.json` (falling back to `app.zon`) and the binary at `zig-out/bin/<name>` automatically; pass `--manifest`, `--binary`, or the other flags below for more control. Zero-config apps package without ejecting: the two commands above are the complete path from source to distributable, and `native eject` exists only for apps that want to own their build files. Apps that do own their build ([ejected](/docs/cli) or scaffolded with `--full`) also get a `zig build package` step that wires the same thing into the build graph.
|
||||
|
||||
## Build options
|
||||
|
||||
@@ -36,7 +36,7 @@ The build system exposes options that control platform, web engine, and build fe
|
||||
<tr>
|
||||
<td><code>-Dweb-engine</code></td>
|
||||
<td><code>system</code>, <code>chromium</code></td>
|
||||
<td><code>app.zon</code></td>
|
||||
<td>App manifest</td>
|
||||
<td>Temporary WebView engine override; Chromium is currently macOS-only</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -72,20 +72,27 @@ The build system exposes options that control platform, web engine, and build fe
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## app.zon packaging fields
|
||||
## App manifest packaging fields
|
||||
|
||||
The manifest drives packaging metadata:
|
||||
|
||||
```zig
|
||||
.{
|
||||
.id = "com.example.myapp",
|
||||
.name = "myapp",
|
||||
.display_name = "My App",
|
||||
.version = "1.0.0",
|
||||
.icons = .{"assets/icon.png"},
|
||||
.platforms = .{ "macos", "linux" },
|
||||
.web_engine = "system",
|
||||
.cef = .{ .dir = "third_party/cef/macos", .auto_install = false },
|
||||
```json:app.json
|
||||
{
|
||||
"id": "com.example.myapp",
|
||||
"name": "myapp",
|
||||
"display_name": "My App",
|
||||
"version": "1.0.0",
|
||||
"icons": ["assets/icon.png"],
|
||||
"platforms": ["macos", "linux"],
|
||||
"web_engine": "system",
|
||||
"cef": { "dir": "third_party/cef/macos", "auto_install": false },
|
||||
"dmg": {
|
||||
"background": "assets/dmg-background.png",
|
||||
"window_width": 660,
|
||||
"window_height": 400,
|
||||
"app_position": { "x": 166, "y": 182 },
|
||||
"applications_position": { "x": 486, "y": 182 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -125,12 +132,16 @@ The manifest drives packaging metadata:
|
||||
<td><code>platforms</code></td>
|
||||
<td>Which platform packages to generate</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>dmg</code></td>
|
||||
<td>Optional macOS disk-image branding and Finder layout; defaults already produce a styled drag-to-Applications image</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## App icons
|
||||
|
||||
Drop one square image in your project — `assets/icon.png` (1:1, ideally 1024x1024) or `assets/icon.svg` — list it in `.icons`, and packaging generates what each platform needs: a complete `.icns` for macOS (with the platform's rounded-rectangle icon shape and margins applied automatically, so full-bleed artwork looks native in the Dock), a multi-size `.ico` for Windows, and hicolor PNG size sets for Linux, plus asset-catalog and launcher-mipmap images for the mobile host skeletons. Artwork that already has transparent corners is treated as pre-shaped and ships unmasked. For art-directed control, a prebuilt `.icns` (macOS) or `.ico` (Windows) in `.icons` always wins untouched. Everything is generated by the SDK's own rasterizer and encoders — no external tools. `native validate` checks the source up front (square, decodable, large enough) with the same messages packaging prints.
|
||||
Drop one square image in your project — `assets/icon.png` (1:1, ideally 1024x1024) or `assets/icon.svg` — list it in `icons`, and packaging generates what each platform needs: a complete `.icns` for macOS (with the platform's rounded-rectangle icon shape and margins applied automatically, so full-bleed artwork looks native in the Dock), a multi-size `.ico` for Windows, and hicolor PNG size sets for Linux, plus asset-catalog and launcher-mipmap images for the mobile host skeletons. Artwork that already has transparent corners is treated as pre-shaped and ships unmasked. For art-directed control, a prebuilt `.icns` (macOS) or `.ico` (Windows) in `icons` always wins untouched. Everything is generated by the SDK's own rasterizer and encoders — no external tools. `native validate` checks the source up front (square, decodable, large enough) with the same messages packaging prints.
|
||||
|
||||
## macOS
|
||||
|
||||
@@ -140,7 +151,7 @@ Drop one square image in your project — `assets/icon.png` (1:1, ideally 1024x1
|
||||
|
||||
- `Contents/MacOS/<binary>` -- the compiled executable
|
||||
- `Contents/Resources/AppIcon.icns` -- the app icon, generated from your icon source (or your prebuilt `.icns`, copied untouched under its own name)
|
||||
- `Contents/Info.plist` -- generated from `app.zon`
|
||||
- `Contents/Info.plist` -- generated from the app manifest
|
||||
- `Contents/Resources/assets/` -- the app's asset tree, mirrored at its app-relative path so runtime asset paths resolve inside the bundle
|
||||
- `Contents/Resources/dist/` -- frontend assets (if configured; replaces the `assets/` mirror)
|
||||
|
||||
@@ -148,6 +159,55 @@ macOS app bundles declare `LSMinimumSystemVersion` as `11.0`. File associations
|
||||
|
||||
See [Code Signing](/docs/packaging/signing) for signing, notarization, and DMG creation.
|
||||
|
||||
### DMG archives
|
||||
|
||||
Add <code>--archive</code> to turn the packaged app into the disk image users download:
|
||||
|
||||
```bash
|
||||
native build
|
||||
native package --target macos --archive
|
||||
```
|
||||
|
||||
Without any extra configuration, Native creates a 660×400 Finder window with a quiet generated background and arrow, packages matching 1× and 2× representations for crisp Retina rendering, positions the app and an `/Applications` alias on either side, hides the Finder chrome, and compresses the result as a `.dmg`. The package diagnostic prints both the `.app` and `.dmg` paths.
|
||||
|
||||
Customize the presentation in `app.json`:
|
||||
|
||||
```json:app.json
|
||||
"dmg": {
|
||||
"volume_name": "My App",
|
||||
"background": "assets/dmg-background.png",
|
||||
"window_width": 720,
|
||||
"window_height": 440,
|
||||
"icon_size": 144,
|
||||
"app_position": { "x": 180, "y": 210 },
|
||||
"applications_position": { "x": 540, "y": 210 },
|
||||
"applications_link": true
|
||||
}
|
||||
```
|
||||
|
||||
The window dimensions are the usable background canvas, excluding Finder's title bar. Positions are icon centers measured from the canvas's top-left corner. The background must be a project-relative PNG, JPEG, or TIFF at the configured window size; Finder displays it at its natural size. `native validate` and packaging reject malformed images or dimensions that do not match the configured canvas. For Retina artwork, put a double-sized sibling next to it using the `@2x` convention—for example, `dmg-background.png` at 720×440 and `dmg-background@2x.png` at 1440×880. Native discovers the pair, verifies that the sibling is exactly double-sized, and packages both representations. A prebuilt multi-resolution TIFF also works.
|
||||
|
||||
Omitting `background` keeps Native's generated Retina-aware gradient and draws the arrow between the configured app and Applications positions. Set `applications_link = false` only for a disk image that is not meant to use the conventional drag-to-install flow.
|
||||
|
||||
For complete control over which Finder items appear and where they sit, replace the fixed app/Applications pair with `items`:
|
||||
|
||||
```json:app.json
|
||||
"dmg": {
|
||||
"background": "assets/dmg-background.png",
|
||||
"window_width": 760,
|
||||
"window_height": 480,
|
||||
"icon_size": 112,
|
||||
"items": [
|
||||
{ "kind": "app", "position": { "x": 150, "y": 180 } },
|
||||
{ "kind": "applications", "position": { "x": 610, "y": 180 } },
|
||||
{ "kind": "file", "path": "README.pdf", "name": "Read Me.pdf", "position": { "x": 250, "y": 370 } },
|
||||
{ "kind": "link", "path": "/Library/QuickLook", "name": "QuickLook", "position": { "x": 510, "y": 370 } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
An explicit list must contain exactly one `app`; its optional `name` changes only the bundle name shown in the DMG. `applications` creates the `/Applications` alias. `file` copies a project-relative file or directory, with an optional display `name`, while `link` creates a named symbolic link to an absolute path. When `items` is present, it replaces `app_position`, `applications_position`, and `applications_link`. The generated background draws its arrow whenever the list includes both `app` and `applications`.
|
||||
|
||||
## Linux
|
||||
|
||||
### Package structure
|
||||
@@ -160,7 +220,7 @@ Linux packaging creates an install tree:
|
||||
- `share/mime/packages/<name>.xml` -- shared MIME metadata when file associations are configured
|
||||
|
||||
```bash
|
||||
native package --target linux --manifest app.zon --binary zig-out/bin/MyApp
|
||||
native package --target linux --manifest app.json --binary zig-out/bin/MyApp
|
||||
```
|
||||
|
||||
Configured file associations and URL schemes are added to the desktop file `MimeType` list. Extension-only associations get generated `application/x-...` MIME types with glob patterns in the shared MIME package.
|
||||
@@ -168,7 +228,7 @@ Configured file associations and URL schemes are added to the desktop file `Mime
|
||||
## Windows
|
||||
|
||||
```bash
|
||||
native package --target windows --manifest app.zon --binary zig-out/bin/MyApp.exe
|
||||
native package --target windows --manifest app.json --binary zig-out/bin/MyApp.exe
|
||||
```
|
||||
|
||||
Windows packaging is in early development. The packager copies the binary and assets into a distributable directory structure and writes a multi-size `app-icon.ico` generated from your icon source (or copies a prebuilt `.ico` from `.icons` untouched). When file associations or URL schemes are configured, the artifact also includes `install/register-file-types.ps1`, which registers the package-local executable under the current user's `HKCU\Software\Classes` registry keys.
|
||||
@@ -185,13 +245,13 @@ zig build bundle-assets
|
||||
|
||||
This copies the configured `dist` directory into the build output. Production packages serve these through `zero://app/`, so paths like `/assets/app.js` work without `file://` URLs.
|
||||
|
||||
### Configure in app.zon
|
||||
### Configure in app.json
|
||||
|
||||
```zig
|
||||
.frontend = .{
|
||||
.dist = "dist",
|
||||
.entry = "index.html",
|
||||
.spa_fallback = true,
|
||||
```json:app.json
|
||||
"frontend": {
|
||||
"dist": "dist",
|
||||
"entry": "index.html",
|
||||
"spa_fallback": true
|
||||
}
|
||||
```
|
||||
|
||||
@@ -236,7 +296,7 @@ zig build
|
||||
native package --target macos
|
||||
```
|
||||
|
||||
Set `.web_engine = "chromium"` and `.cef = .{ .dir = "third_party/cef/macos", .auto_install = false }` in `app.zon` for the normal Chromium package path. Use `-Dweb-engine`, `--web-engine`, `-Dcef-dir`, or `--cef-dir` only when you need a one-off override.
|
||||
Set `"web_engine": "chromium"` and `"cef": { "dir": "third_party/cef/macos", "auto_install": false }` in `app.json` for the normal Chromium package path. Use `-Dweb-engine`, `--web-engine`, `-Dcef-dir`, or `--cef-dir` only when you need a one-off override.
|
||||
|
||||
Verify the Chromium macOS package layout locally with:
|
||||
|
||||
@@ -261,8 +321,8 @@ This renders the full icon family (16 through 1024 px with @2x variants) through
|
||||
Check that your manifest and environment are ready for packaging:
|
||||
|
||||
```bash
|
||||
native doctor --manifest app.zon --strict
|
||||
native validate app.zon
|
||||
native doctor --manifest app.json --strict
|
||||
native validate app.json
|
||||
```
|
||||
|
||||
`doctor` checks the host environment, WebView availability, manifest validity, log paths, and optional CEF paths. Add `--strict` to fail on any warning. See [Debugging](/docs/debugging) for details on what `native doctor` checks.
|
||||
@@ -272,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>
|
||||
|
||||
@@ -83,11 +83,11 @@ The framework repository includes a `zig build notarize` helper for local releas
|
||||
zig build notarize
|
||||
```
|
||||
|
||||
Generated apps should use `native package --target macos --signing identity ...` unless they add their own `notarize` build step. This helper does not invoke `xcrun notarytool` directly. After the signed package is created, submit it for notarization manually:
|
||||
Generated apps should use `native package --target macos --signing identity ... --archive` unless they add their own `notarize` build step. This helper does not invoke `xcrun notarytool` directly. After the signed DMG is created, submit and staple it manually (use the archive path printed by the package command):
|
||||
|
||||
```bash
|
||||
xcrun notarytool submit zig-out/package/your-app.zip --apple-id "you@example.com" --team-id "TEAMID" --password "@keychain:AC_PASSWORD" --wait
|
||||
xcrun stapler staple zig-out/package/your-app.app
|
||||
xcrun notarytool submit zig-out/package/your-app-1.0.0-macos-ReleaseFast.dmg --apple-id "you@example.com" --team-id "TEAMID" --password "@keychain:AC_PASSWORD" --wait
|
||||
xcrun stapler staple zig-out/package/your-app-1.0.0-macos-ReleaseFast.dmg
|
||||
```
|
||||
|
||||
## Chromium apps
|
||||
@@ -97,8 +97,7 @@ Chromium packages include `Chromium Embedded Framework.framework` inside the `.a
|
||||
```bash
|
||||
native cef install --version <pinned-version>
|
||||
zig build
|
||||
native package --target macos --signing identity --identity "Developer ID Application: Your Name"
|
||||
hdiutil create -volname "Your App" -srcfolder zig-out/package/your-app.app -ov -format UDZO zig-out/package/your-app.dmg
|
||||
native package --target macos --signing identity --identity "Developer ID Application: Your Name" --archive
|
||||
```
|
||||
|
||||
Use `.web_engine = "chromium"` and `.cef = .{ .dir = "third_party/cef/macos", .auto_install = false }` in `app.zon` for the normal signing path. `-Dweb-engine`, `--web-engine`, `-Dcef-dir`, and `--cef-dir` remain available for temporary overrides.
|
||||
@@ -110,9 +109,11 @@ If Gatekeeper rejects the app, check that the CEF framework is present in `Conte
|
||||
Create a distributable disk image:
|
||||
|
||||
```bash
|
||||
zig build dmg
|
||||
native package --target macos --archive
|
||||
```
|
||||
|
||||
This creates the conventional drag-to-Applications presentation by default: a generated background and arrow, positioned app and Applications icons, hidden Finder chrome, and a compressed final image. Customize it through the [`dmg` fields in `app.zon`](/docs/packaging#dmg-archives).
|
||||
|
||||
## Entitlements
|
||||
|
||||
The project includes `assets/native-sdk.entitlements` as a starting point. Customize it for your app's needs (e.g. network access, file system access, camera).
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata";
|
||||
|
||||
export const metadata = pageMetadata("persistence");
|
||||
|
||||
export default function PersistenceLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
# Model Persistence
|
||||
|
||||
Model persistence stores the TypeScript core's committed `Model` without giving `update` filesystem access or making the app own a serialization loop. Declare the capability and its monotonic schema version in `app.zon`:
|
||||
|
||||
```zig:app.zon
|
||||
.capabilities = .{ "persist" },
|
||||
.persist = .{
|
||||
.version = 1,
|
||||
.debounce_ms = 500,
|
||||
.restore = .{
|
||||
.ok = "restored",
|
||||
.none = "fresh_boot",
|
||||
.err = "restore_failed",
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
The three route names refer to `Msg` arms. `ok` and `none` are void arms; `err` carries one `Uint8Array` field containing one closed reason: `corrupt`, `version_unknown`, `migrate_failed`, `io_failed`, or `rejected`.
|
||||
|
||||
```ts:src/core.ts
|
||||
import { Cmd } from "@native-sdk/core";
|
||||
|
||||
export interface Model {
|
||||
readonly draft: Uint8Array;
|
||||
readonly saves: number;
|
||||
}
|
||||
|
||||
export type Msg =
|
||||
| { readonly kind: "edited"; readonly draft: Uint8Array }
|
||||
| { readonly kind: "restored" }
|
||||
| { readonly kind: "fresh_boot" }
|
||||
| { readonly kind: "restore_failed"; readonly reason: Uint8Array };
|
||||
|
||||
export const viewUnbound = ["restored", "fresh_boot", "restore_failed"] as const;
|
||||
|
||||
export function initialModel(): Model {
|
||||
return { draft: new Uint8Array(0), saves: 0 };
|
||||
}
|
||||
|
||||
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
|
||||
switch (msg.kind) {
|
||||
case "edited":
|
||||
return [{ ...model, draft: msg.draft, saves: model.saves + 1 }, Cmd.persist()];
|
||||
case "restored":
|
||||
case "fresh_boot":
|
||||
case "restore_failed":
|
||||
return model;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`Cmd.persist()` snapshots the model from that committed update. The command carries no model bytes; the compiled core exposes its generated canonical encoder to the host. The host coalesces requests on a trailing edge, performs filesystem work off the update thread, keeps at most one write in flight, and force-flushes the pending tail during backgrounding and graceful shutdown. A failed write dispatches the configured `err` route, just like a failed boot restore. The default debounce is 500 ms; `debounce_ms` accepts 0–60,000.
|
||||
|
||||
Snapshots live in the platform app-data directory as `snapshot.nsd`. Installation is atomic: the engine writes and syncs a temporary file, renames it over the primary, and keeps one structurally valid prior generation as `snapshot.nsd.bak`. A corrupt or torn primary falls back to that backup. The generated body is little-endian and uses tagged, length-delimited Model fields; snapshot bodies are bounded at 16 MiB, independently of the raw file-effect limits.
|
||||
|
||||
## Boot and replay
|
||||
|
||||
Before the first frame, the engine restores the canonical model and dispatches exactly one configured route. `restored` observes the restored model; `fresh_boot` means neither generation exists. Restore and migration failures leave the initial model in place and dispatch `restore_failed` with the reason bytes.
|
||||
|
||||
The restore result crosses the ordinary effect journal boundary. Recording stores non-empty snapshot bytes in the session blob store; replay feeds those bytes back without reading or writing the live app-data directory. `Cmd.persist()` remains on the replay command stream for fingerprint parity, but its host binding is a no-op.
|
||||
|
||||
## 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 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`:
|
||||
|
||||
```typescript
|
||||
export function migrate(snapshot: Uint8Array, fromVersion: number): Model {
|
||||
// Decode the versioned legacy bytes and construct the current Model.
|
||||
// Throw a subset value when the legacy bytes cannot be migrated.
|
||||
return decodeLegacyModel(snapshot, fromVersion);
|
||||
}
|
||||
```
|
||||
|
||||
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; 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>
|
||||
@@ -128,9 +136,10 @@ macOS, Linux, and Windows run full desktop apps through their own platform hosts
|
||||
5. App menus and context menus are native on all three desktops: macOS presents `NSMenu`, Windows `TrackPopupMenu` (the tray menu's popup path), Linux `GtkPopoverMenu`. Hosts without a native context-menu presenter — the mobile toolkit hosts and embed hosts today — present the same declared context menu as an anchored canvas surface at the click point; authors declare one menu either way.
|
||||
6. Tray support is implemented on macOS (`NSStatusItem`) and Windows; Linux tray calls return `UnsupportedService` until a portable status-notifier implementation is selected.
|
||||
7. Web engines only apply to apps that embed web content; native-rendered apps carry none. The system WebView is the default engine on every desktop platform; bundled Chromium through CEF is available on macOS only, and Linux/Windows Chromium builds fail early instead of silently substituting an engine. The Windows system engine is WebView2: its Evergreen runtime ships with Windows 11 and current Windows 10 (older machines need the runtime installer; `native doctor` checks for it), and the build stages the vendored loader next to the executable automatically. See [Web Engines](/docs/web-engines). The mobile shell examples embed the platform WebView as the content workspace.
|
||||
8. `native package` targets all five platforms: macOS gets a `.app` bundle (plus `zig build dmg`), 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).
|
||||
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
|
||||
|
||||
@@ -298,6 +307,14 @@ macOS, Linux, and Windows run full desktop apps through their own platform hosts
|
||||
<td>Unsupported</td>
|
||||
<td>Supported on Windows 10 2004+ (process-scoped WASAPI loopback of this app only, probed live)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Microphone + system audio capture</td>
|
||||
<td>Supported (AVAudioEngine microphone; ScreenCaptureKit system mix on macOS 13+; user consent required)</td>
|
||||
<td>Unsupported</td>
|
||||
<td>Unsupported</td>
|
||||
<td>Unsupported</td>
|
||||
<td>Supported (default capture endpoint + WASAPI render loopback)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -328,7 +345,7 @@ JavaScript can query the same support model through the built-in bridge when `js
|
||||
const hasTray = await window.zero.platform.supports("tray");
|
||||
```
|
||||
|
||||
Feature names match the Zig `PlatformFeature` enum and the TypeScript `NativeSdkPlatformFeature` union. JavaScript callers may use either snake_case names such as `native_views` or camelCase aliases such as `nativeViews`. Unsupported operations still reject explicitly if called; support checks are intended for choosing UI affordances before making those calls.
|
||||
Feature names match the Zig `PlatformFeature` enum and the TypeScript `NativeSdkPlatformFeature` union. JavaScript callers may use either snake_case names such as `native_views`, `microphone_capture`, and `system_audio_capture` or camelCase aliases such as `nativeViews`, `microphoneCapture`, and `systemAudioCapture`. Unsupported operations still reject explicitly if called; support checks are intended for choosing UI affordances before making those calls.
|
||||
|
||||
## Native Surfaces
|
||||
|
||||
|
||||
@@ -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 24+ for the default TypeScript scaffold — the TypeScript frontend (the checker), scriptc compiler, and core dev loop run under it at build and dev time; scriptc 0.0.33 uses Node 24's compile cache to accelerate repeated invocations. 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
|
||||
|
||||
@@ -47,8 +47,8 @@ This scaffolds a native-rendered app — and nothing else. There are no build fi
|
||||
<td>The entire UI: elements, layout, bindings, and message dispatch</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>app.zon</code></td>
|
||||
<td>App manifest: identity, window and view declarations, permissions, security policy</td>
|
||||
<td><code>app.json</code></td>
|
||||
<td>App manifest: identity, window and view declarations, permissions, security policy. Its <code>$schema</code> enables editor completion and validation; existing <code>app.zon</code> manifests remain supported.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>assets/icon.png</code></td>
|
||||
@@ -271,13 +271,13 @@ Because the subset is executable TypeScript, the same file runs unmodified under
|
||||
native check
|
||||
```
|
||||
|
||||
`native check` validates the whole tree without building anything: `src/core.ts` runs the subset checker (typecheck plus the app-core rules, with teaching diagnostics that name the rule, the fix, and the reason), then every `.native` file under `src/` and `app.zon`:
|
||||
`native check` validates the whole tree without building anything: `src/core.ts` runs the subset checker (typecheck plus the app-core rules, with teaching diagnostics that name the rule, the fix, and the reason), then every `.native` file under `src/` and `app.json`:
|
||||
|
||||
```
|
||||
model contract: not yet built - bindings and app: icon names checked structurally only; run `native test` to enable typed checks
|
||||
src/app.native: ok
|
||||
info[manifest.valid]: app.zon is valid
|
||||
checked 1 markup file, app.zon and src/core.ts (subset checker clean)
|
||||
info[manifest.valid]: app.json is valid
|
||||
checked 1 markup file, app.json and src/core.ts (subset checker clean)
|
||||
```
|
||||
|
||||
The first line is honest about what a fresh tree can check: once a build has produced the model contract, the markup pass also verifies bindings, iterables, and message tags against the core's `Model`/`Msg`. Markup errors come back with `file:line:column` and a teaching message (`native markup lsp` provides the same diagnostics plus completion and hover in your editor). `native test` runs the app's test suite; the Zig template additionally scaffolds `src/tests.zig` — full-loop UI tests that click buttons through typed dispatch, headless, on any machine. See [Testing](/docs/testing) for the full tiers, including driving the live app from the outside with [automation](/docs/automation).
|
||||
@@ -294,7 +294,7 @@ This produces an optimized binary and tells you where it landed:
|
||||
built zig-out/bin/my-app (ReleaseFast)
|
||||
```
|
||||
|
||||
(The binary name comes from `app.zon`: `native init my_app` sets `.name = "my-app"`.) Where `native dev` runs a Debug build to arm hot reload, `native build` produces an optimized ReleaseFast binary. The TypeScript core compiles to native code inside it — no JS engine, no interpreter. From there, [Packaging](/docs/packaging) turns it into a distributable app bundle with `native package`.
|
||||
(The binary name comes from `app.json`: `native init my_app` sets `"name": "my-app"`.) Where `native dev` runs a Debug build to arm hot reload, `native build` produces an optimized ReleaseFast binary. The TypeScript core compiles to native code inside it — no JS engine, no interpreter. From there, [Packaging](/docs/packaging) turns it into a distributable app bundle with `native package`.
|
||||
|
||||
## Escape hatch: own the build
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 1–64 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.
|
||||
@@ -149,7 +149,7 @@ The runtime dispatches `LifecycleEvent` values through your `event_fn`:
|
||||
- **`frame`** -- a frame has been requested (for animations or state updates)
|
||||
- **`stop`** -- the app is shutting down
|
||||
|
||||
Native file drops dispatch `Event.files_dropped` to `event_fn`. Apps with WebView content also receive `app:activate`, `app:deactivate`, and `drop:files` on each trusted `window.zero` instance:
|
||||
Native file drops dispatch `Event.files_dropped` to `event_fn`; `UiApp` maps them through `Options.on_drop`, and TypeScript cores export `dropMsg(drop: FileDropEvent)` (see [Native UI: File drops](/docs/native-ui#file-drops)). Apps with WebView content also receive `app:activate`, `app:deactivate`, and `drop:files` on each trusted `window.zero` instance:
|
||||
|
||||
```ts
|
||||
window.zero.on("drop:files", (event) => {
|
||||
|
||||
@@ -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>
|
||||
@@ -61,6 +61,10 @@ Every app declares `permissions` and `capabilities` in `app.zon` — the runtime
|
||||
<td><code>microphone</code></td>
|
||||
<td>Microphone access</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>system_audio</code></td>
|
||||
<td>System-output audio capture</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>location</code></td>
|
||||
<td>Location services</td>
|
||||
@@ -195,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 = .{
|
||||
@@ -323,7 +327,7 @@ External links are denied by default. To open links in the system browser, opt i
|
||||
|
||||
Do not allow broad external patterns for pages that can be influenced by remote content.
|
||||
|
||||
The same policy gates `runtime.openExternalUrl(...)` and `window.zero.os.openUrl(...)`. A bridge grant for `native-sdk.os.openUrl` is not enough by itself; the URL must also match `external_links.allowed_urls`.
|
||||
The same policy gates `Cmd.openExternalUrl(...)`, `runtime.openExternalUrl(...)`, and `window.zero.os.openUrl(...)`. A bridge grant for `native-sdk.os.openUrl` is not enough by itself; the URL must also match `external_links.allowed_urls`.
|
||||
|
||||
## CSP guidance
|
||||
|
||||
|
||||
@@ -21,10 +21,14 @@ The CLI itself serves the full skill content. This is the layer the installed di
|
||||
```sh
|
||||
native skills list # list built-in skills
|
||||
native skills get core # print a skill
|
||||
native skills get native-ui # default app views and app loop
|
||||
native skills get ts-core # default TypeScript app core
|
||||
native skills get core --full # include its reference files
|
||||
native skills get --all [--full] # print every skill
|
||||
```
|
||||
|
||||
For a normal app created by `native init`, give the agent both `native-ui` and `ts-core`: TypeScript + Native markup is the primary authoring path. Load `core --full` when the task reaches shared runtime wiring, WebViews, packaging, or native capabilities; load `zig` for an existing Zig-core app or toolkit-extension work.
|
||||
|
||||
`skills get` prints the skill to stdout, so delivering one to an agent is a single redirect into wherever your agent loads skills from:
|
||||
|
||||
```sh
|
||||
@@ -38,11 +42,11 @@ native skills get native-ui > .claude/skills/native-ui/SKILL.md
|
||||
|
||||
<dl>
|
||||
<dt><code>core</code></dt>
|
||||
<dd>The shared foundation: the mental model, project structure, <code>app.zon</code>, App and Runtime patterns, frontend integration, web engines, the JavaScript bridge, permissions, windows, WebViews, dialogs, packaging, debugging, and testing. <code>--full</code> appends its five reference files (project anatomy, App/Runtime patterns, frontend assets, web engines/packaging/debugging, bridge/security/native capabilities).</dd>
|
||||
<dd>The shared foundation and task router: it establishes TypeScript + Native markup as the default, then covers project structure, <code>app.zon</code>, lower-level App and Runtime patterns, frontend integration, web engines, the JavaScript bridge, permissions, windows, WebViews, dialogs, packaging, debugging, and testing. <code>--full</code> appends its five reference files.</dd>
|
||||
<dt><code>native-ui</code></dt>
|
||||
<dd>Authoring native-rendered apps: <code>.native</code> markup views, bindings and message dispatch, <code>Model</code>/<code>Msg</code>/<code>update</code> on the <code>UiApp</code> loop, testing markup views, hot reload, and verifying the result through the automation harness.</dd>
|
||||
<dd>The view half of primary app authoring: <code>.native</code> markup views, bindings and message dispatch, the <code>Model</code>/<code>Msg</code>/<code>update</code> loop, testing markup views, hot reload, and verification through the automation harness.</dd>
|
||||
<dt><code>ts-core</code></dt>
|
||||
<dd>Authoring <a href="/docs/typescript">TypeScript app cores</a>: the app-core subset, every checker rule by ID with its idiomatic fix, text-is-bytes, the full <code>Cmd</code>/<code>Sub</code> effect vocabulary, and the node dev loop.</dd>
|
||||
<dd>The logic half of primary app authoring: <a href="/docs/typescript">TypeScript app cores</a>, the app-core subset, every checker rule by ID with its idiomatic fix, text-is-bytes, the full <code>Cmd</code>/<code>Sub</code> effect vocabulary, and the node dev loop.</dd>
|
||||
<dt><code>automation</code></dt>
|
||||
<dd>Driving and verifying a running app through <code>native automate</code>: snapshots, readiness waits, assertions, bridge round-trips, and smoke tests.</dd>
|
||||
<dt><code>zig</code></dt>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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^53−1) 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 1–64 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -28,6 +28,85 @@ const MyApp = native_sdk.UiApp(Model, Msg);
|
||||
.theme = app_runner.manifestThemePack(),
|
||||
```
|
||||
|
||||
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 themeState(model: Model): ThemeState {
|
||||
if (model.pinkAccent) {
|
||||
return {
|
||||
pack: model.theme,
|
||||
colorScheme: model.themePreference,
|
||||
accent: "#df2670",
|
||||
};
|
||||
}
|
||||
return { pack: model.theme, colorScheme: model.themePreference };
|
||||
}
|
||||
```
|
||||
|
||||
`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:
|
||||
|
||||
```zig
|
||||
@@ -97,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
|
||||
|
||||
|
||||
@@ -37,6 +37,26 @@ On macOS, `title` renders the tray as a menu-bar extra: a titled `NSStatusItem`
|
||||
<td><code>[]const TrayMenuItem</code></td>
|
||||
<td><code>&.{}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>presentation</code></td>
|
||||
<td><code>TrayPresentation</code></td>
|
||||
<td><code>.{}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>activation_command</code></td>
|
||||
<td><code>[]const u8</code></td>
|
||||
<td><code>""</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>alternate_activation_command</code></td>
|
||||
<td><code>[]const u8</code></td>
|
||||
<td><code>""</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>open_command</code></td>
|
||||
<td><code>[]const u8</code></td>
|
||||
<td><code>""</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -76,6 +96,41 @@ On macOS, `title` renders the tray as a menu-bar extra: a titled `NSStatusItem`
|
||||
<td><code>bool</code></td>
|
||||
<td><code>true</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>detail</code></td>
|
||||
<td><code>[]const u8</code></td>
|
||||
<td><code>""</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>role</code></td>
|
||||
<td><code>TrayItemRole</code></td>
|
||||
<td><code>.command</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>key</code></td>
|
||||
<td><code>[]const u8</code></td>
|
||||
<td><code>""</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>modifiers</code></td>
|
||||
<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>
|
||||
|
||||
@@ -83,15 +138,136 @@ 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`, 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. 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";
|
||||
import { type StatusItemState } from "@native-sdk/core/events";
|
||||
|
||||
export function statusItem(model: Model): StatusItemState {
|
||||
return {
|
||||
iconPath: asciiBytes("assets/menu-bar.svg"),
|
||||
tooltip: utf8Bytes("Player status"),
|
||||
activationCommand: asciiBytes("app.refresh"),
|
||||
alternateActivationCommand: asciiBytes("player.toggle"),
|
||||
openCommand: asciiBytes("app.refresh"),
|
||||
presentation: {
|
||||
title: model.playing ? utf8Bytes("MB PLAY") : utf8Bytes("MB"),
|
||||
width: model.playing ? 72 : 48,
|
||||
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 } },
|
||||
{ id: 0, label: asciiBytes(""), command: asciiBytes(""), separator: true, enabled: false, detail: asciiBytes(""), role: "command", key: asciiBytes(""), modifiers: { primary: false, command: false, control: false, option: false, shift: false } },
|
||||
{ id: 3, label: utf8Bytes("Settings…"), command: asciiBytes("app.settings"), separator: false, enabled: true, detail: asciiBytes(""), role: "command", key: asciiBytes(","), modifiers: { primary: false, command: true, control: false, option: false, shift: false } },
|
||||
],
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
`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.
|
||||
|
||||
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 1–32 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 menu’s 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(.{
|
||||
@@ -146,9 +322,9 @@ app.* = PreviewApp.init(allocator, .{}, .{
|
||||
|
||||
macOS (`NSStatusItem`) is the proven host; platforms without a status-bar service log a warning and continue. See `examples/canvas-preview` for the live composition.
|
||||
|
||||
## Model-driven title and menu
|
||||
## 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 title-only change retitles the live status button (no flicker, no menu rebuild), a menu change updates the dropdown. The static `status_item` still provides the icon and tooltip.
|
||||
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 {
|
||||
@@ -156,43 +332,63 @@ fn statusItem(model: *const Model, scratch: *App.StatusItemScratch) App.StatusIt
|
||||
scratch.items[0] = .{ .id = 1, .label = "Refresh", .command = "app.refresh" };
|
||||
scratch.items[1] = .{ .separator = true };
|
||||
scratch.items[2] = .{ .id = 10, .label = model.latest_title, .command = "issue.select.latest" };
|
||||
return .{ .title = title, .items = scratch.items[0..3] };
|
||||
return .{
|
||||
.presentation = .{ .title = title, .width = 62, .monospaced = true },
|
||||
.items = scratch.items[0..3],
|
||||
};
|
||||
}
|
||||
// options: .status_item_fn = statusItem,
|
||||
```
|
||||
|
||||
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()`. `examples/menu-bar` is the whole loop, tested end to end.
|
||||
`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.
|
||||
|
||||
|
||||
@@ -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,9 +43,15 @@ 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` performs a buffered exchange on the effect engine and routes the result back as an ordinary Msg carrying `{ status, body }`. The request is data, the response 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 client for an OpenAI-compatible chat endpoint:
|
||||
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:
|
||||
|
||||
<CodeToggle>
|
||||
|
||||
@@ -83,7 +94,7 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
|
||||
case "answered":
|
||||
// The status is the real HTTP status - a 404 is a delivered
|
||||
// response. Parse the body in pure TypeScript over bytes; the
|
||||
// ai-chat-ts example ships the complete JSON walk.
|
||||
// The Chatbot example ships the complete JSON walk.
|
||||
return { ...model, waiting: false, answer: msg.body };
|
||||
case "ask_failed":
|
||||
// The transport reason ("timed_out", "connect_failed", ...) -
|
||||
@@ -108,7 +119,25 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
|
||||
|
||||
</CodeToggle>
|
||||
|
||||
The flagship version of this pattern is [`examples/ai-chat-ts`](https://github.com/vercel-labs/native/tree/main/examples/ai-chat-ts): a chat client for an OpenAI-compatible endpoint — conversation history in the Model, request encoding and response parsing as plain subset TypeScript over bytes, endpoint and credentials through the env channel with the key riding a runtime-built `Authorization: Bearer` header (header names are compile-time; header values may be runtime bytes), honest sending/failed/unconfigured states — with an end-to-end suite that pins the exact request bytes and replays a recorded conversation with no network in the room and none of the launch variables set. One v1 boundary, stated plainly: responses are buffered, not token-streamed (the engine underneath already streams line-framed bodies on the Zig channel; the TS Cmd surface for it is roadmap).
|
||||
For token-by-token UI, request the endpoint's streaming mode and add a `line` route:
|
||||
|
||||
```ts
|
||||
Cmd.fetch(
|
||||
{
|
||||
url: endpoint,
|
||||
method: "POST",
|
||||
headers: { accept: "text/event-stream", authorization: bearerToken },
|
||||
body: requestBody,
|
||||
timeoutMs: 120000,
|
||||
maxLineBytes: 65536,
|
||||
},
|
||||
{ key: "chat", line: "chat_event", ok: "chat_done", err: "chat_failed" },
|
||||
)
|
||||
```
|
||||
|
||||
`chat_event` carries one `Uint8Array` field for each complete SSE/NDJSON line; parse its `data:` payload and append the delta to the assistant message in the Model. `chat_done` carries one number field with the terminal HTTP status. Cancellation and transport failures reach `chat_failed` as reason bytes, including `cancelled`, so a partially displayed answer never ends silently.
|
||||
|
||||
The flagship [`examples/chatbot`](https://github.com/vercel-labs/native/tree/main/examples/chatbot) uses that streaming shape against Vercel AI Gateway: the Gateway URL and `openai/gpt-5.6-luna` default are fixed, a dropdown inside the prompt group lists the Luna, Terra, and Sol variants in that order, `AI_GATEWAY_API_KEY` and an optional initial `NATIVE_SDK_CHAT_MODEL` override arrive through the env channel, and every `choices[0].delta.content` extends the visible pending assistant reply before `[DONE]` and the terminal status commit it to history. Its end-to-end suite pins the request, observes partial UI updates, and replays every stream line without network or launch variables.
|
||||
|
||||
## Full npm ecosystem UIs
|
||||
|
||||
@@ -149,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.
|
||||
- **`@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/ai-chat-ts/src/api.ts`, is the kind of module it exists to absorb.
|
||||
- **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.
|
||||
|
||||
@@ -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.json`. Existing `app.zon` manifests remain supported. 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:
|
||||
|
||||
@@ -12,7 +12,7 @@ The same `core.ts` is executable TypeScript: it typechecks with stock tsc and ru
|
||||
native dev --core # run the core under node's virtual host: dispatch Msgs as
|
||||
# JSON lines, watch the model + effect transcript
|
||||
native dev # build and run the real app (markup hot reload)
|
||||
native check # subset-check core.ts + validate markup + app.zon
|
||||
native check # subset-check core.ts + validate markup + app.json
|
||||
native build # ReleaseFast binary; native test runs the app's tests
|
||||
```
|
||||
|
||||
@@ -51,7 +51,7 @@ export function update(model: Model, msg: Msg): Model {
|
||||
A complete core in the idiom — readonly interfaces, a tagged Msg, spread updates, map/filter, bytes for text, derived exports:
|
||||
|
||||
```ts:src/core.ts
|
||||
import { asciiBytes } from "@native-sdk/core";
|
||||
import { utf8Bytes } from "@native-sdk/core";
|
||||
|
||||
export type Bytes = Uint8Array;
|
||||
export type Filter = "all" | "active" | "done";
|
||||
@@ -77,7 +77,7 @@ export type Msg =
|
||||
|
||||
export function initialModel(): Model {
|
||||
return {
|
||||
tasks: [{ id: 1, title: asciiBytes("Ship the core"), done: false }],
|
||||
tasks: [{ id: 1, title: utf8Bytes("Ship the core"), done: false }],
|
||||
nextId: 2,
|
||||
filter: "all",
|
||||
draft: new Uint8Array(0),
|
||||
@@ -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 (NS1001–NS1059) — 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 NS1001–NS1064 and are judged by the same pinned compiler's ordinary static tier instead; the class boundary rules NS1065–NS1067 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>
|
||||
|
||||
@@ -155,16 +160,17 @@ pub fn pick__f64(xs: []const f64, i: i64) f64 {
|
||||
|
||||
</CodeToggle>
|
||||
|
||||
One rule deserves calling out early: **text is bytes**. Dynamic, user-visible text lives in the Model as `Uint8Array` — `string` is for literals, string-literal-union tags, and `===` comparisons. Turn literals and templates into bytes with the `asciiBytes` intrinsic:
|
||||
One rule deserves calling out early: **text is bytes**. Dynamic, user-visible text lives in the Model as `Uint8Array` — `string` is for literals, string-literal-union tags, and `===` comparisons. Turn display literals and templates into UTF-8 with `utf8Bytes`; use `asciiBytes` only when ASCII is part of the value's contract, such as a command name, key, or protocol token:
|
||||
|
||||
```ts
|
||||
import { asciiBytes } from "@native-sdk/core";
|
||||
import { asciiBytes, utf8Bytes } from "@native-sdk/core";
|
||||
|
||||
const label = asciiBytes(`${done} of ${total} done`); // per-dispatch bytes
|
||||
const seed = asciiBytes("Stretch"); // rodata, free to commit
|
||||
const label = utf8Bytes(`${done} of ${total} done`); // per-dispatch UTF-8
|
||||
const seed = utf8Bytes("Café…"); // UTF-8 rodata
|
||||
const command = asciiBytes("app.refresh"); // guaranteed ASCII
|
||||
```
|
||||
|
||||
The compiler folds every `asciiBytes` call at compile time; under node the same import runs as a plain function with the same result. Observing a `string`'s code units (`.length`, `s[i]`) is a taught error because UTF-16 and UTF-8 would disagree, and `+` concatenation is taught away because runtime string building needs a JS string heap the binary does not carry.
|
||||
The compiler folds both byte intrinsics at compile time; under node the same imports run as plain functions with the same result. `asciiBytes` fails with NS1064 when a literal/template contains non-ASCII and throws `RangeError` if called directly with such text under node. `utf8Bytes` encodes Unicode exactly like `TextEncoder`, including U+FFFD for lone surrogates. Observing a `string`'s code units (`.length`, `s[i]`) is a taught error because UTF-16 and UTF-8 would disagree, and `+` concatenation is taught away because runtime string building needs a JS string heap the binary does not carry.
|
||||
|
||||
Bytes still read like text: the everyday string methods work directly on `Uint8Array` values, with **byte-honest semantics** — every length, offset, and index is a BYTE length/offset (never a character count: `é` measures 2), search is byte-wise, and case mapping is Unicode simple case mapping (code point to code point from the Unicode tables, locale-free, no special casing — `ß` stays `ß`; invalid UTF-8 passes through unchanged). The compiled core and node run the same methods from the same generated tables, so both produce identical bytes by construction.
|
||||
|
||||
@@ -278,14 +284,42 @@ The runtime interprets the command after the model commits and dispatches any re
|
||||
<td><code>Cmd.writeFile(path, bytes, { key?, ok, err })</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>{ exists, size, mtimeMs }</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 & Streaming</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.fetch(spec, { key?, ok, err })</code></td>
|
||||
<td>A buffered HTTP(S) exchange; <code>ok</code> carries <code>{ status, body }</code> (a 404 is still <code>ok</code> — a delivered response), <code>err</code> the transport reason</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.fetch(spec, { key?, line, ok, err })</code></td>
|
||||
<td>A line-streamed HTTP(S) exchange for SSE/NDJSON; each <code>line</code> carries bytes as it arrives, then <code>ok</code> carries the terminal HTTP status or <code>err</code> the transport reason</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.clipboardWrite(bytes)</code> / <code>Cmd.clipboardRead({ key?, ok, err })</code></td>
|
||||
<td>System clipboard: write is fire-and-forget, read routes the text bytes back</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.showNotification({ id?, title, subtitle?, body?, actionLabel?, actionCommand? })</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.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>
|
||||
<td>Format an epoch timestamp as localized <code>date</code>, <code>time</code>, or <code>datetime</code> text in the host's current time zone</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.spawn(argv, { key?, stdin?, line?, exit, err })</code></td>
|
||||
<td>Run a subprocess, streaming stdout line by line; <code>collect: true</code> buffers whole stdout into the <code>exit</code> arm instead</td>
|
||||
@@ -295,8 +329,16 @@ The runtime interprets the command after the model commits and dispatches any re
|
||||
<td>The audio player: one event stream (<code>loaded</code>, <code>position</code>, <code>completed</code>, <code>failed</code>, <code>spectrum</code>, ...) until <code>audioStop</code> closes it</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.showWindow(label)</code> / <code>Cmd.quitApp()</code></td>
|
||||
<td>The menu-bar lifecycle verbs: un-hide + activate the labeled window (the tray "Open" consequence, the counterpart to <code>close_policy = "hide"</code>), and the real graceful terminate</td>
|
||||
<td><code>Cmd.showWindow(label)</code> / <code>Cmd.hideWindow(label)</code> / <code>Cmd.quitApp()</code></td>
|
||||
<td>The menu-bar lifecycle verbs: show or retain-but-hide the labeled window, and gracefully terminate the app</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.setDockPresence(visible)</code></td>
|
||||
<td>Switch macOS between regular Dock/app-switcher presence and accessory/headless behavior; unsupported hosts ignore it</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.launchAtLoginStatus(route)</code> / <code>Cmd.setLaunchAtLogin(enabled, route)</code></td>
|
||||
<td>Query or change the installed app bundle's <code>SMAppService</code> registration; the ok bytes name <code>enabled</code>, <code>disabled</code>, <code>requires_approval</code>, or <code>not_found</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.imageLoad(id, source, { event })</code> + <code>imageCancel(id)</code>/<code>imageUnregister(id)</code></td>
|
||||
@@ -306,6 +348,22 @@ The runtime interprets the command after the model commits and dispatches any re
|
||||
<td><code>Cmd.channelOpen(key, { event })</code> / <code>channelClose(key)</code></td>
|
||||
<td>Open an external-source channel under an app-chosen numeric key: the native side holds the posting handle and feeds bytes from its own threads, and every post arrives through the one <code>event</code> arm with the back-pressure counters aboard. <code>channelClose</code> flushes staged posts, dispatches exactly one <code>closed</code> event with the final drop totals, and frees the key</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.audioCaptureStart(key, spec, { event })</code> / <code>audioCaptureStop(key)</code></td>
|
||||
<td>Capture <code>microphone</code> or <code>system</code> audio as bounded, timestamped, interleaved signed-16 LE PCM chunks. The stream reports <code>started</code>, <code>data</code>, <code>failed</code>, <code>stopped</code>, and <code>rejected</code>, with observable drop counters</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<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, { key?, ok, err })</code></td>
|
||||
<td>App-defined host commands by literal name: fire-and-forget, or routed with exactly one result Msg back</td>
|
||||
@@ -317,49 +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, no fields for `writeFile`'s ok, one number plus one `Uint8Array` field for `fetch`'s — and tsc checks the shapes for you. Keys carry ONE in-flight discipline: a keyed effect — `Cmd.request`, the 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. The one exception is a live `Cmd.spawn` key, which rejects the duplicate (`err` gets `rejected`) — a running subprocess is never killed implicitly; cancel it first, and that cancel is loud (`err` gets `cancelled`) because killing a process is an observable event. Every `err` arm receives a machine-readable reason, so failure is never silence. Persistence today is `Cmd.writeFile` + a boot-time `Cmd.readFile` — the pattern every real app uses.
|
||||
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.
|
||||
|
||||
The debounced-autosave shape, in full — edits re-arm a one-shot; one write lands after the pause:
|
||||
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.
|
||||
|
||||
```ts:src/core.ts
|
||||
import { Cmd, asciiBytes } from "@native-sdk/core";
|
||||
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.
|
||||
|
||||
export interface Model {
|
||||
readonly draft: Uint8Array;
|
||||
readonly dirty: boolean;
|
||||
}
|
||||
|
||||
export type Msg =
|
||||
| { readonly kind: "draft_edit"; readonly text: Uint8Array }
|
||||
| { readonly kind: "save_now"; readonly at: number }
|
||||
| { readonly kind: "saved" }
|
||||
| { readonly kind: "save_failed"; readonly reason: Uint8Array };
|
||||
|
||||
export const viewUnbound = ["save_now", "saved", "save_failed", "dirty"] as const;
|
||||
|
||||
export function initialModel(): Model {
|
||||
return { draft: new Uint8Array(0), dirty: false };
|
||||
}
|
||||
|
||||
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
|
||||
switch (msg.kind) {
|
||||
case "draft_edit":
|
||||
return [
|
||||
{ ...model, draft: msg.text, dirty: true },
|
||||
Cmd.delay("autosave", 800, "save_now"),
|
||||
];
|
||||
case "save_now":
|
||||
return [
|
||||
model,
|
||||
Cmd.writeFile(asciiBytes("draft.bin"), model.draft, { key: "save", ok: "saved", err: "save_failed" }),
|
||||
];
|
||||
case "saved":
|
||||
return { ...model, dirty: false };
|
||||
case "save_failed":
|
||||
return model;
|
||||
}
|
||||
}
|
||||
```
|
||||
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.
|
||||
|
||||
@@ -411,6 +433,59 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
|
||||
}
|
||||
```
|
||||
|
||||
Audio input uses the same bounded, wake-driven stream transport without requiring native posting code. `Cmd.audioCaptureStart(key, { source, sampleRate?, channels? }, { event })` captures the microphone or the desktop output mix. The supported canonical rates are 16, 24, and 48 kHz; channels are mono or stereo; the default is 48 kHz mono. Each `data` event carries at most 20 ms of interleaved signed 16-bit little-endian PCM in `pcm`, plus `timestampMs`, `frames`, the delivered format, and drop counters. Microphone and system capture can run concurrently, but only one stream per source is live; starting that source again stops the prior key. `Cmd.audioCaptureStop(key)` quiesces the native callback, drains accepted chunks, then emits one `stopped` terminal. A key remains occupied until that terminal is delivered, so wait for `stopped` before reusing it. Add `"microphone"` and/or `"system_audio"` to the `app.json` permissions array so packaged macOS apps receive the required usage descriptions and consent prompts.
|
||||
|
||||
```ts:src/core.ts
|
||||
import { Cmd, type AudioCaptureState, type AudioCaptureSource } from "@native-sdk/core";
|
||||
|
||||
export type Msg =
|
||||
| { readonly kind: "record" }
|
||||
| { readonly kind: "stop" }
|
||||
| { readonly kind: "audio_chunk"; readonly key: number; readonly state: AudioCaptureState; readonly source: AudioCaptureSource; readonly sampleRate: number; readonly channels: number; readonly timestampMs: number; readonly frames: number; readonly pcm: Uint8Array; readonly droppedPending: number; readonly droppedTotal: number };
|
||||
|
||||
// In update:
|
||||
// case "record": return [model, Cmd.audioCaptureStart(1, { source: "microphone", sampleRate: 48000, channels: 1 }, { event: "audio_chunk" })];
|
||||
// case "stop": return [model, Cmd.audioCaptureStop(1)];
|
||||
```
|
||||
|
||||
## 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 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";
|
||||
import { type StatusItemState } from "@native-sdk/core/events";
|
||||
|
||||
export function statusItem(model: Model): StatusItemState {
|
||||
return {
|
||||
iconPath: asciiBytes("assets/menu-bar.svg"),
|
||||
tooltip: utf8Bytes("Sync status"),
|
||||
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, 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 } },
|
||||
],
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
`restorePolicy` accepts `"clamp_to_visible_screen"` (the default) or `"center_on_primary"`. Model-declared windows do not restore persisted frames. On macOS, `"center_on_primary"` centers a fresh descriptor with no authored `x`/`y`; Windows and Linux currently keep their native default placement.
|
||||
|
||||
`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
|
||||
|
||||
Recurring effects are declared, not issued: export `subscriptions(model): Sub<Msg>` and return descriptors derived from the current model. After every commit the host reconciles the returned set against its active timers by key — a new key (or a changed interval) arms a timer, a missing key cancels it — so starting, stopping, and re-tuning timers is just returning different data:
|
||||
@@ -455,7 +530,7 @@ export function subscriptions(model: Model): Sub<Msg> {
|
||||
}
|
||||
```
|
||||
|
||||
Keep the Sub-vs-stream line straight: a Sub is declarative — derived from the model, started and stopped by reconciliation, never opened or closed by the app. The multi-result streams (`Cmd.spawn`'s lines, `Cmd.audioPlay`'s events, `Cmd.channelOpen`'s posts) are Cmd-initiated — imperative opens with a keyed lifecycle the app drives. If the effect should exist exactly while some model state holds, it wants a Sub; if the app decides when it starts and ends, it is a stream.
|
||||
Keep the Sub-vs-stream line straight: a Sub is declarative — derived from the model, started and stopped by reconciliation, never opened or closed by the app. The multi-result streams (`Cmd.fetch`'s response lines, `Cmd.spawn`'s stdout lines, `Cmd.audioPlay`'s events, `Cmd.channelOpen`'s posts, and audio capture chunks) are Cmd-initiated — imperative opens with a keyed lifecycle the app drives. If the effect should exist exactly while some model state holds, it wants a Sub; if the app decides when it starts and ends, it is a stream.
|
||||
|
||||
## Text input from markup
|
||||
|
||||
@@ -463,7 +538,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, 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 record types (`TextInputEvent` re-exported, `ScrollState`, `FrameEvent`, `KeyEvent`, `PinchPhase`/`PinchEvent`, `ColorScheme`, the chrome records, `AudioState`/`AudioEvent`) so no core re-types the vocabulary — 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>
|
||||
|
||||
@@ -490,26 +565,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 NS1065–NS1067 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 the app manifest. 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 (NS1001–NS1064)</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 NS1065–NS1067 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 1–64 (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><app>_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`.
|
||||