Make app.json the default manifest (#385)

* Make app.json the default manifest

- Add full-featured app.json parsing, discovery, build conversion, and app.zon fallback.
- Scaffold JSON manifests by default across TypeScript, Zig, web, full, and ejected projects.
- Publish the JSON Schema and update validation, vendoring, docs, skills, and npm payloads.

* Fix app.json manifest regressions

* Fix app.json conversion and schema bounds

* Publish versioned app schema

* Fix app.json vendoring and schema bounds
This commit is contained in:
Chris Tate
2026-08-18 07:41:43 -05:00
committed by GitHub
parent cb6a417965
commit a22f2043d1
43 changed files with 2324 additions and 393 deletions
+2 -2
View File
@@ -74,7 +74,7 @@ jobs:
- run: zig build test-example-mobile-canvas-lib-ios-store
- run: zig build test-webview-system-link
- run: zig build test-webview-smoke
# The zero-config TypeScript runner must load app.zon menus before
# 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
@@ -498,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"
+4 -3
View File
@@ -6,7 +6,7 @@ Guidance for agents (and humans) working on this repository.
Native SDK itself is implemented in Zig, but Native SDK **apps are authored in TypeScript + Native markup by default**. Do not infer the app-authoring language from this repository's implementation language or from older Zig-core examples.
- For a new app, use `native init <path>` and expect `src/core.ts`, `src/app.native`, and `app.zon`. 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.
- 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.
@@ -15,7 +15,7 @@ Native SDK itself is implemented in Zig, but Native SDK **apps are authored in T
```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
@@ -34,7 +34,8 @@ Do not edit `CHANGELOG.md` as part of regular feature or fix work. The release a
## 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.
+1 -1
View File
@@ -93,7 +93,7 @@ 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`. 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`.
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 |
| --- | --- |
+15
View File
@@ -0,0 +1,15 @@
# 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.
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.
+435
View File
@@ -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" }
}
}
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"outputDirectory": "public",
"rewrites": [
{ "source": "/app.json", "destination": "/app/v1.json" }
],
"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": "*" }
]
}
]
}
+48 -11
View File
@@ -5,6 +5,7 @@
const std = @import("std");
const builtin = @import("builtin");
const json_to_zon = @import("../src/tooling/json_to_zon.zig");
/// Canonicalize a generated file by its CONTENT before another build step
/// consumes it. `std.Build.Step.Run` normally places outputs under a cache
@@ -121,6 +122,9 @@ const WebLayerOption = web_layer_contract.WebViewLayer;
pub const AppOptions = struct {
name: []const u8,
/// Explicit manifest path. Null auto-detects app.json first, then app.zon,
/// so older owned build.zig files can adopt JSON without a build edit.
manifest: ?[]const u8 = null,
/// App entry point; defaults to src/main.zig (relative to `app_root`).
main: []const u8 = "src/main.zig",
/// Root of the app source tree, relative to the build root. "." for a
@@ -163,6 +167,34 @@ fn appFileExists(b: *std.Build, app_root: []const u8, sub_path: []const u8) bool
return true;
}
fn appManifestName(b: *std.Build, app_root: []const u8, requested: ?[]const u8) []const u8 {
if (requested) |name| return name;
if (appFileExists(b, app_root, "app.json")) return "app.json";
return "app.zon";
}
fn appManifestPath(b: *std.Build, app_root: []const u8, manifest_name: []const u8) []const u8 {
return appPath(b, app_root, manifest_name);
}
/// Produce the Zig module consumed by the existing comptime manifest wiring.
/// ZON manifests are already modules; JSON manifests are converted losslessly
/// into a generated module, keeping one runtime feature path for both formats.
fn appManifestModule(b: *std.Build, app_root: []const u8, manifest_name: []const u8) *std.Build.Module {
const path = appManifestPath(b, app_root, manifest_name);
if (!json_to_zon.isJsonPath(path)) {
return b.createModule(.{ .root_source_file = b.path(path) });
}
const source = b.build_root.handle.readFileAlloc(b.graph.io, path, b.allocator, .limited(1024 * 1024)) catch
@panic("cannot read app.json");
const zon = json_to_zon.convertAlloc(b.allocator, source) catch |err| switch (err) {
error.NullNotAllowed => @panic("app.json cannot contain null values; omit optional fields instead"),
else => @panic("cannot convert app.json into the build-time manifest module; run `native check` for a precise diagnostic"),
};
const generated = b.addWriteFiles().add("app_manifest.zon", zon);
return b.createModule(.{ .root_source_file = generated });
}
const TsWindowView = struct {
label: []const u8,
source_path: []const u8,
@@ -1627,7 +1659,8 @@ pub fn addAppArtifacts(b: *std.Build, dep: *std.Build.Dependency, app_options: A
" src/main.zig,\nor keep src/main.zig and delete src/core.ts. (Other Zig files under" ++
" src/ are fine either way.)\n");
}
const app_config = appManifestBuildConfig(b, app_options.app_root);
const manifest_name = appManifestName(b, app_options.app_root, app_options.manifest);
const app_config = appManifestBuildConfig(b, app_options.app_root, manifest_name);
// The core-compiler setting names the one lane there is; the flag
// overrides app.zon's `.core_compiler` and both exist so a stated
// choice stays stateable (and so the removed lane's spelling teaches
@@ -1708,7 +1741,7 @@ pub fn addAppArtifacts(b: *std.Build, dep: *std.Build.Dependency, app_options: A
.archive = stage.archive,
.service_archive = stage.service_archive,
.markup_c = stage.markup_c,
.manifest_mod = b.createModule(.{ .root_source_file = b.path(appPath(b, app_options.app_root, "app.zon")) }),
.manifest_mod = appManifestModule(b, app_options.app_root, manifest_name),
} else null,
});
}
@@ -1758,7 +1791,7 @@ pub fn addAppArtifacts(b: *std.Build, dep: *std.Build.Dependency, app_options: A
options.addOption(bool, "web_layer", web_layer);
const options_mod = options.createModule();
const app_mod = appModule(b, dep, target, app_optimize, app_options, options_mod, ts_stage, relational_migrations, app_config);
const app_mod = appModule(b, dep, target, app_optimize, app_options, manifest_name, options_mod, ts_stage, relational_migrations, app_config);
// TypeScript app code and platform hosts are expensive Zig/Clang semantic
// work but do not depend on primary markup bytes. Compile them once into
// an object, then make the executable a link-only artifact over that
@@ -1839,7 +1872,7 @@ pub fn addAppArtifacts(b: *std.Build, dep: *std.Build.Dependency, app_options: A
// the production app module feeds the cached app-code object and must not
// absorb the separately linked markup data object.
const test_app_mod = if (ts_stage != null or app_optimize != optimize)
appModule(b, dep, target, optimize, app_options, options_mod, ts_stage, relational_migrations, app_config)
appModule(b, dep, target, optimize, app_options, manifest_name, options_mod, ts_stage, relational_migrations, app_config)
else
app_mod;
if (ts_stage) |stage| test_app_mod.addObject(markupDataObject(b, target, optimize, stage.markup_c));
@@ -1938,7 +1971,7 @@ pub fn addAppArtifacts(b: *std.Build, dep: *std.Build.Dependency, app_options: A
// WebView2 loader) from the framework root; the cached artifact's
// own location cannot derive it, so hand it over explicitly.
package_run.setEnvironmentVariable("NATIVE_SDK_PATH", dep.builder.pathFromRoot("."));
package_run.addArgs(&.{ "package", "--target", package_target_name, "--manifest", "app.zon", "--output" });
package_run.addArgs(&.{ "package", "--target", package_target_name, "--manifest", manifest_name, "--output" });
package_run.addArg(if (host_os == .macos)
b.fmt("zig-out/package/{s}.app", .{app_options.name})
else
@@ -2015,14 +2048,14 @@ fn exampleOptimizeMode(b: *std.Build, requested: ?std.builtin.OptimizeMode, defa
};
}
fn appModule(b: *std.Build, dep: *std.Build.Dependency, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, app_options: AppOptions, options_mod: *std.Build.Module, ts_stage: ?TsCoreStage, relational_migrations: std.Build.LazyPath, app_config: AppManifestBuildConfig) *std.Build.Module {
fn appModule(b: *std.Build, dep: *std.Build.Dependency, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, app_options: AppOptions, manifest_name: []const u8, options_mod: *std.Build.Module, ts_stage: ?TsCoreStage, relational_migrations: std.Build.LazyPath, app_config: AppManifestBuildConfig) *std.Build.Module {
const native_sdk_mod = nativeSdkModuleWithTerminal(b, dep, target, optimize, app_options.terminal_sessions);
const runner_mod = b.createModule(.{
.root_source_file = dep.path("src/app_runner/root.zig"),
.target = target,
.optimize = optimize,
});
const manifest_mod = b.createModule(.{ .root_source_file = b.path(appPath(b, app_options.app_root, "app.zon")) });
const manifest_mod = appManifestModule(b, app_options.app_root, manifest_name);
runner_mod.addImport("native_sdk", native_sdk_mod);
runner_mod.addImport("build_options", options_mod);
runner_mod.addImport("app_manifest_zon", manifest_mod);
@@ -2805,15 +2838,19 @@ fn appPath(b: *std.Build, app_root: []const u8, sub_path: []const u8) []const u8
return b.pathJoin(&.{ app_root, sub_path });
}
fn appManifestBuildConfig(b: *std.Build, app_root: []const u8) AppManifestBuildConfig {
fn appManifestBuildConfig(b: *std.Build, app_root: []const u8, manifest_name: []const u8) AppManifestBuildConfig {
// The fallback for a manifest this lenient parse cannot read keeps
// the web layer (see AppManifestBuildConfig): a shape mismatch here
// is not proof the app declares no web use.
const fallback: AppManifestBuildConfig = .{ .web_declaration = .unreadable_manifest };
const source = b.build_root.handle.readFileAlloc(b.graph.io, appPath(b, app_root, "app.zon"), b.allocator, .limited(1024 * 1024)) catch return fallback;
const source_z = b.allocator.dupeZ(u8, source) catch return fallback;
const source = b.build_root.handle.readFileAlloc(b.graph.io, appPath(b, app_root, manifest_name), b.allocator, .limited(1024 * 1024)) catch return fallback;
@setEvalBranchQuota(2000);
const raw = std.zon.parse.fromSliceAlloc(InferenceManifest, b.allocator, source_z, null, .{ .ignore_unknown_fields = true }) catch return fallback;
const raw = if (std.ascii.eqlIgnoreCase(std.fs.path.extension(manifest_name), ".json"))
std.json.parseFromSliceLeaky(InferenceManifest, b.allocator, source, .{ .ignore_unknown_fields = true }) catch return fallback
else zon: {
const source_z = b.allocator.dupeZ(u8, source) catch return fallback;
break :zon std.zon.parse.fromSliceAlloc(InferenceManifest, b.allocator, source_z, null, .{ .ignore_unknown_fields = true }) catch return fallback;
};
// `.core_compiler` names the one lane there is; validated here so
// the removed transpiled lane's spelling teaches at configure time.
if (!std.mem.eql(u8, raw.core_compiler, "external")) {
+1 -1
View File
@@ -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",
+435
View File
@@ -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" }
}
}
}
}
+34
View File
@@ -0,0 +1,34 @@
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");
assert.deepEqual(deployment.rewrites, [{ source: "/app.json", destination: "/app/v1.json" }]);
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");
+44 -35
View File
@@ -1,43 +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,
.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 }
}
```
@@ -426,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
```
+16 -16
View File
@@ -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,7 +151,7 @@ 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. On macOS this is a styled DMG with the app, an Applications alias, a generated or custom background, and the Finder layout declared by <code>app.zon</code>.</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
@@ -169,7 +169,7 @@ The desktop shortcuts use an explicit `--service-binary` when supplied; otherwis
### `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.
+6 -6
View File
@@ -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>
+1 -1
View File
@@ -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.
+59 -59
View File
@@ -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,27 +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 },
.dmg = .{
.background = "assets/dmg-background.png",
.window_width = 660,
.window_height = 400,
.app_position = .{ .x = 166, .y = 182 },
.applications_position = .{ .x = 486, .y = 182 },
},
```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 }
}
}
```
@@ -141,7 +141,7 @@ The manifest drives packaging metadata:
## 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
@@ -151,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)
@@ -170,19 +170,19 @@ 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.zon`:
Customize the presentation in `app.json`:
```zig
.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,
},
```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.
@@ -191,19 +191,19 @@ Omitting `background` keeps Native's generated Retina-aware gradient and draws t
For complete control over which Finder items appear and where they sit, replace the fixed app/Applications pair with `items`:
```zig
.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 } },
},
},
```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`.
@@ -220,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.
@@ -228,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.
@@ -245,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
}
```
@@ -296,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:
@@ -321,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.
+6 -6
View File
@@ -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
+4 -4
View File
@@ -4,7 +4,7 @@ import { CodeToggle } from "@/components/code-toggle";
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 starts as three files and zero Zig: `src/core.ts`, `src/app.native`, and `app.zon`. When ordinary TypeScript work needs filesystem access, JSON, regexes, `Map`, `Date`, classes, or child processes, add modules under `src/services/`; they compile to native code too and answer the core through the same effect→Msg boundary as every other external action. Writing the core in Zig instead ([App Model](/docs/app-model)) is first-class by choice — same loop, same runtime — and extending the toolkit itself (custom widgets and render passes) is always Zig.
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
```
@@ -433,7 +433,7 @@ 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 `app.zon` permissions so packaged macOS apps receive the required usage descriptions and consent prompts.
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";
@@ -591,7 +591,7 @@ The core never receives a synchronous handle. Its update returns a command, the
`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 the core class, emits and validates the service contract, runs the pinned compiler's coverage verdict over each independent service root, then validates markup and `app.zon`. Every diagnostic names the rule, the idiomatic rewrite, and the reason — write to them up front and the loop stays fast.
`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
@@ -80,7 +80,106 @@ function servicePackagesField(source) {
throw new Error("app.zon service_packages field is not balanced");
}
export function readServicePackages(source) {
function parseJsonManifest(source) {
const manifest = JSON.parse(source);
if (manifest === null || Array.isArray(manifest) || typeof manifest !== "object") throw new Error("app.json must contain one object");
return manifest;
}
function skipJsonWhitespace(source, start) {
let at = start;
while (/\s/.test(source[at] ?? "")) at++;
return at;
}
function scanJsonString(source, start) {
let escaped = false;
for (let at = start + 1; at < source.length; at++) {
const char = source[at];
if (escaped) escaped = false;
else if (char === "\\") escaped = true;
else if (char === '"') return at + 1;
}
throw new Error("app.json contains an unterminated string");
}
function scanJsonValue(source, start) {
const first = source[start];
if (first === '"') return scanJsonString(source, start);
if (first === "{" || first === "[") {
const close = first === "{" ? "}" : "]";
let depth = 1;
let string = false;
let escaped = false;
for (let at = start + 1; at < source.length; at++) {
const char = source[at];
if (string) {
if (escaped) escaped = false;
else if (char === "\\") escaped = true;
else if (char === '"') string = false;
continue;
}
if (char === '"') string = true;
else if (char === first) depth++;
else if (char === close && --depth === 0) return at + 1;
}
throw new Error("app.json contains an unterminated value");
}
let at = start;
while (at < source.length && !/[\s,}\]]/.test(source[at])) at++;
return at;
}
function jsonObjectField(source, fieldName) {
let at = skipJsonWhitespace(source, 0);
if (source[at] !== "{") throw new Error("app.json must contain one object");
at = skipJsonWhitespace(source, at + 1);
let memberCount = 0;
let match = null;
while (source[at] !== "}") {
if (source[at] !== '"') throw new Error("app.json contains an invalid object key");
const keyStart = at;
const keyEnd = scanJsonString(source, keyStart);
const key = JSON.parse(source.slice(keyStart, keyEnd));
at = skipJsonWhitespace(source, keyEnd);
if (source[at] !== ":") throw new Error("app.json contains an invalid object field");
const valueStart = skipJsonWhitespace(source, at + 1);
const valueEnd = scanJsonValue(source, valueStart);
memberCount++;
if (key === fieldName) {
if (match !== null) throw new Error(`app.json contains duplicate ${fieldName} fields`);
match = { valueStart, valueEnd };
}
at = skipJsonWhitespace(source, valueEnd);
if (source[at] === ",") at = skipJsonWhitespace(source, at + 1);
else if (source[at] !== "}") throw new Error("app.json contains an invalid object separator");
}
return { match, close: at, memberCount };
}
function jsonTopLevelIndent(source, close) {
const firstKey = source.indexOf('"', source.indexOf("{") + 1);
const key = firstKey >= 0 && firstKey < close ? firstKey : close;
const lineStart = source.lastIndexOf("\n", key - 1) + 1;
const indent = source.slice(lineStart, key);
return /^[ \t]+$/.test(indent) ? indent : " ";
}
function renderJsonValue(value, indent) {
return JSON.stringify(value, null, 2).replaceAll("\n", `\n${indent}`);
}
export function readServicePackages(source, format = "zon") {
if (format === "json") {
const entries = parseJsonManifest(source).service_packages ?? [];
if (!Array.isArray(entries)) throw new Error("app.json service_packages must be an array");
return entries.map((entry) => {
if (entry === null || typeof entry !== "object" || typeof entry.name !== "string" || typeof entry.version !== "string" || typeof entry.content_hash !== "string") {
throw new Error("app.json service_packages contains an invalid package fact");
}
return { name: entry.name, version: entry.version, content_hash: entry.content_hash };
});
}
const field = servicePackagesField(source);
if (!field) return [];
const body = source.slice(field.open + 1, field.close);
@@ -104,13 +203,24 @@ function packageNameFromSpec(spec) {
return spec.slice(0, spec.lastIndexOf("@"));
}
export function mergePackageSpecs(source, requested) {
const specs = new Map(readServicePackages(source).map((entry) => [entry.name, `${entry.name}@${entry.version}`]));
export function mergePackageSpecs(source, requested, format = "zon") {
const specs = new Map(readServicePackages(source, format).map((entry) => [entry.name, `${entry.name}@${entry.version}`]));
for (const spec of requested) specs.set(packageNameFromSpec(spec), spec);
return [...specs].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([, spec]) => spec);
}
export function replaceServicePackages(source, entries) {
export function replaceServicePackages(source, entries, format = "zon") {
if (format === "json") {
parseJsonManifest(source);
const field = jsonObjectField(source, "service_packages");
const indent = jsonTopLevelIndent(source, field.close);
const rendered = renderJsonValue(entries, indent);
if (field.match) {
return source.slice(0, field.match.valueStart) + rendered + source.slice(field.match.valueEnd);
}
const comma = field.memberCount === 0 ? "" : ",";
return `${source.slice(0, field.close).trimEnd()}${comma}\n${indent}"service_packages": ${rendered}\n${source.slice(field.close)}`;
}
const rendered = [
" .service_packages = .{",
...entries.map((entry) => ` .{ .name = ${JSON.stringify(entry.name)}, .version = ${JSON.stringify(entry.version)}, .content_hash = ${JSON.stringify(entry.content_hash)} },`),
@@ -139,10 +249,13 @@ function main(argv) {
return 2;
}
const manifestPath = path.join(appRoot, "app.zon");
const jsonPath = path.join(appRoot, "app.json");
const zonPath = path.join(appRoot, "app.zon");
const manifestPath = fs.existsSync(jsonPath) ? jsonPath : zonPath;
if (!fs.existsSync(manifestPath)) throw new Error(`${manifestPath} does not exist`);
const format = manifestPath.endsWith(".json") ? "json" : "zon";
const current = fs.readFileSync(manifestPath, "utf8");
const installSpecs = mergePackageSpecs(current, requested);
const installSpecs = mergePackageSpecs(current, requested, format);
const work = fs.mkdtempSync(path.join(os.tmpdir(), "native-service-vendor-"));
const servicesRoot = path.join(appRoot, "src", "services");
const stageRoot = path.join(appRoot, ".native", `.service-vendor-stage-${process.pid}-${Date.now()}`);
@@ -169,8 +282,8 @@ function main(argv) {
entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
const vendorRoot = path.join(servicesRoot, "vendor");
replaceVendorTree(vendorRoot, stageRoot);
fs.writeFileSync(manifestPath, replaceServicePackages(current, entries));
console.log(`vendored ${entries.length} package${entries.length === 1 ? "" : "s"} into src/services/vendor and updated app.zon`);
fs.writeFileSync(manifestPath, replaceServicePackages(current, entries, format));
console.log(`vendored ${entries.length} package${entries.length === 1 ? "" : "s"} into src/services/vendor and updated ${path.basename(manifestPath)}`);
for (const entry of entries) console.log(` ${entry.name}@${entry.version} ${entry.content_hash}`);
return 0;
} finally {
+28
View File
@@ -591,6 +591,34 @@ test("incremental vendoring preserves prior package facts and lets explicit upda
}
});
test("app.json vendoring preserves schema metadata and package facts", () => {
const source = `{
"$schema": "https://schema.native-sdk.dev/app/v1.json",
"id": "dev.example.fixture",
"name": "fixture",
"version": "1.0.0",
"assets": { "images": [{ "id": 9007199254740993, "path": "assets/cover.png" }] },
"frontend": { "dev": { "url": "http://127.0.0.1:5173/", "timeout_ms": 1e3 } },
"service_packages": [{ "name": "alpha", "version": "1.2.3", "content_hash": "${"a".repeat(64)}" }]
}\n`;
assert.deepEqual(mergePackageSpecs(source, ["beta@2.0.0"], "json"), ["alpha@1.2.3", "beta@2.0.0"]);
const replaced = replaceServicePackages(source, [{ name: "beta", version: "2.0.0", content_hash: "b".repeat(64) }], "json");
const parsed = JSON.parse(replaced);
assert.equal(parsed.$schema, "https://schema.native-sdk.dev/app/v1.json");
assert.deepEqual(readServicePackages(replaced, "json"), [{ name: "beta", version: "2.0.0", content_hash: "b".repeat(64) }]);
assert.match(replaced, /"id": 9007199254740993/);
assert.match(replaced, /"timeout_ms": 1e3/);
const inserted = replaceServicePackages(`{
"id": "dev.example.fixture",
"name": "fixture",
"version": "1.0.0",
"assets": { "images": [{ "id": 9007199254740993, "path": "assets/cover.png" }] }
}\n`, [{ name: "beta", version: "2.0.0", content_hash: "b".repeat(64) }], "json");
assert.match(inserted, /"id": 9007199254740993/);
assert.deepEqual(readServicePackages(inserted, "json"), [{ name: "beta", version: "2.0.0", content_hash: "b".repeat(64) }]);
});
test("service staging lowers tagged throws across the service-host graph", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "native-service-stage-"));
try {
+3 -3
View File
@@ -20,7 +20,7 @@ cd my_app
native dev
```
A native window opens with a working counter. The primary scaffold is three files of truth and no build config: `src/core.ts` (`Model`, `Msg`, `update`), `src/app.native` (the UI), and `app.zon` (the manifest). `native dev|build|test` own the generated build; `src/app.native` hot-reloads while the app runs, keeping your state; `native dev --core` runs the TypeScript logic loop under node; and `native check` validates the core and every view in milliseconds without building. Prefer a Zig core? Use `native init my_app --template zig-core`.
A native window opens with a working counter. The primary scaffold is three files of truth and no build config: `src/core.ts` (`Model`, `Msg`, `update`), `src/app.native` (the UI), and `app.json` (the manifest, with JSON Schema completion). Existing `app.zon` manifests remain supported. `native dev|build|test` own the generated build; `src/app.native` hot-reloads while the app runs, keeping your state; `native dev --core` runs the TypeScript logic loop under node; and `native check` validates the core and every view in milliseconds without building. Prefer a Zig core? Use `native init my_app --template zig-core`.
When part of your product is the web, WebView surfaces coexist with the native canvas; web-frontend scaffolds (`--frontend next`, `--frontend vite`, and more) install their generated frontend dependencies automatically on first run.
@@ -34,11 +34,11 @@ Read the full guide at [native-sdk.dev/quick-start](https://native-sdk.dev/quick
| `native dev [dir]` | Build and run the app (markup hot reload; managed frontend dev server when configured) |
| `native build [dir]` | Build a ReleaseFast binary into `zig-out/bin/` |
| `native test [dir]` | Run the app's test suite |
| `native check [dir]` | Validate `src/**.native` markup and `app.zon` against the model contract |
| `native check [dir]` | Validate `src/**.native` markup and `app.json`/`app.zon` against the model contract |
| `native markup check\|lsp` | Check individual markup files, or serve diagnostics, completion, and hover to your editor |
| `native eject [dir]` | Write an owned build.zig/build.zig.zon into the app |
| `native doctor` | Check host environment, WebView, manifest, and CEF |
| `native validate` | Validate `app.zon` against the manifest schema |
| `native validate` | Validate `app.json` (or `app.zon`) against the manifest schema |
| `native package` | Package the app for distribution |
| `native bundle-assets` | Copy frontend assets into the build output |
| `native automate` | Drive a running app: snapshots, widgets, assertions, screenshots, record/replay |
+1
View File
@@ -14,6 +14,7 @@
"bin/native.js",
"assets",
"skill-data",
"schemas",
"skills",
"src",
"build",
+435
View File
@@ -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" }
}
}
}
}
@@ -18,6 +18,7 @@ const mirrors = [
{ source: 'LICENSE', target: 'LICENSE' },
{ source: 'skills', target: 'skills' },
{ source: 'skill-data', target: 'skill-data' },
{ source: 'apps/schema/public/app/v1.json', target: 'schemas/app.schema.json' },
{ source: 'third_party/webview2', target: 'third_party/webview2' },
{ source: 'third_party/sqlite', target: 'third_party/sqlite' },
// corewire: the mirror/facade/profile generator every TypeScript-core
+11 -1
View File
@@ -27,7 +27,7 @@
// in the same transaction and resolved from packages/core by node's
// ancestor walk. test/ stays out: repo-dev surface, never build inputs.
import { cpSync, copyFileSync, rmSync } from 'fs';
import { cpSync, copyFileSync, mkdirSync, rmSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
@@ -43,6 +43,16 @@ for (const dir of ['src', 'build', 'assets', 'skills', 'skill-data']) {
console.log(`✓ Copied ${dir}/ to ${target}`);
}
{
const source = join(repoRoot, 'apps', 'schema', 'public', 'app', 'v1.json');
const targetDir = join(projectRoot, 'schemas');
const target = join(targetDir, 'app.schema.json');
rmSync(targetDir, { recursive: true, force: true });
mkdirSync(targetDir, { recursive: true });
cpSync(source, target, { recursive: true });
console.log(`✓ Copied apps/schema/public/app/v1.json to ${target}`);
}
{
rmSync(join(projectRoot, 'third_party'), { recursive: true, force: true });
for (const dir of ['webview2', 'sqlite']) {
+2 -1
View File
@@ -30,7 +30,7 @@
# test-examples-mobile; hello/
# webview/browser run their
# in-dir `zig build test`)
# docs/** -> docs `pnpm check`
# docs/**, apps/schema/** -> docs `pnpm check`
# docs/**, skills/**, skill-data/**,
# packages/core/** -> service-surface tooling tests:
# manifest diff categorization,
@@ -162,6 +162,7 @@ while IFS= read -r file; do
[ -n "$file" ] || continue
case "$file" in
docs/*) docs_changed=true ;;
apps/schema/*) docs_changed=true; meta_changed=true ;;
packages/core/*) meta_changed=true ;;
src/platform/macos/*) framework_changed=true; macos_platform_changed=true ;;
src/*|build.zig|build.zig.zon|build/*|tools/*|tests/*|assets/*) framework_changed=true ;;
+53 -15
View File
@@ -1,6 +1,6 @@
---
name: core
description: Core Native SDK guide for AI agents. Read this before explaining the Native SDK or changing a Native SDK app. Establishes TypeScript + Native markup as the default app-authoring path, routes default app work to the native-ui and ts-core skills, and covers the shared foundation: project structure, app.zon, lower-level App and Runtime patterns, frontend integration, web engines, JavaScript bridge commands, permissions, windows, WebViews, dialogs, packaging, debugging, and testing. Use when the user asks what the Native SDK is, how to build or modify an app, how to package or debug it, or how to add native capabilities.
description: Core Native SDK guide for AI agents. Read this before explaining the Native SDK or changing a Native SDK app. Establishes TypeScript + Native markup as the default app-authoring path, routes default app work to the native-ui and ts-core skills, and covers the shared foundation: project structure, app.json and legacy app.zon, lower-level App and Runtime patterns, frontend integration, web engines, JavaScript bridge commands, permissions, windows, WebViews, dialogs, packaging, debugging, and testing. Use when the user asks what the Native SDK is, how to build or modify an app, how to package or debug it, or how to add native capabilities.
---
# Build Native SDK apps
@@ -13,13 +13,13 @@ Agents should assume they do not know the Native SDK from general model knowledg
## Mental model
- The default app has three files of truth: `src/core.ts` (`Model`, `Msg`, `update`), `src/app.native` (UI), and `app.zon` (manifest).
- The default app has three files of truth: `src/core.ts` (`Model`, `Msg`, `update`), `src/app.native` (UI), and `app.json` (manifest).
- Native markup binds model values and dispatches typed messages; only `update` changes state.
- The TypeScript core compiles to native code. Node is a build/check/dev tool, not an app runtime.
- `App` is the lower-level product/runtime interface used by generated wiring, Zig-core apps, WebView shells, and extensions.
- `Runtime` owns the event loop, windows, bridge dispatch, security checks, automation, tracing, platform services, and window state.
- `WebViewSource` tells the runtime what to load: inline HTML, a URL, or packaged assets from a local app origin.
- `app.zon` is the app manifest: identity, icons, windows, frontend assets, web engine, permissions, bridge policy, security policy, and packaging inputs.
- `app.json` is the default app manifest: identity, icons, windows, frontend assets, web engine, permissions, bridge policy, security policy, and packaging inputs. `app.zon` remains supported.
- `src/runner.zig` and `src/main.zig` appear only when an app explicitly owns lower-level Zig wiring (for example a `zig-core` app or a WebView shell). `--full` makes a TypeScript app own `build.zig` but still does not give it Zig app logic. Do not add Zig sources to a default TypeScript app merely because SDK examples contain them.
- `frontend/` is normal web code. It talks to native Zig through `window.zero.invoke()` or builtin helpers when those are enabled.
@@ -48,13 +48,13 @@ cd my_app
native dev
```
This creates `src/core.ts`, `src/app.native`, and `app.zon`; a native window opens with `native dev`. Use `native init my_app --template zig-core` only when the user chooses Zig. Frontend choices (`--frontend next|vite|react|svelte|vue`) are the separate WebView migration/integration path.
This creates `src/core.ts`, `src/app.native`, and `app.json`; a native window opens with `native dev`. Use `native init my_app --template zig-core` only when the user chooses Zig. Frontend choices (`--frontend next|vite|react|svelte|vue`) are the separate WebView migration/integration path.
## Workflow for existing apps
Before editing an existing Native SDK app:
1. Read `app.zon` and inspect `src/` before assuming a core language. Read `src/core.ts` + `src/app.native` for the default path; read `src/main.zig`, `src/runner.zig`, and `build.zig` only when they exist.
1. Read `app.json` or `app.zon` and inspect `src/` before assuming a core language. Read `src/core.ts` + `src/app.native` for the default path; read `src/main.zig`, `src/runner.zig`, and `build.zig` only when they exist.
2. Identify whether the app is TypeScript + Native markup (default), an explicitly chosen Zig core, or a WebView frontend, then identify the layer the change belongs to.
3. Follow the generated code and examples in the repository instead of inventing a new app layout.
4. Prefer exact security policy changes over broad allowances.
@@ -62,7 +62,7 @@ Before editing an existing Native SDK app:
Common file ownership:
- `app.zon`: app identity, version, icons, windows, permissions, capabilities, bridge command policy, allowed origins, frontend dist/dev config, web engine, CEF config.
- `app.json` / `app.zon`: app identity, version, icons, windows, permissions, capabilities, bridge command policy, allowed origins, frontend dist/dev config, web engine, CEF config.
- `src/core.ts`: default app state and behavior — `Model`, `Msg`, `update`, pure helpers, `Cmd`, and `Sub`.
- `src/app.native`: default app UI — elements, layout, bindings, and message dispatch.
- `src/main.zig`: explicitly chosen Zig-core logic or lower-level `App` behavior, source selection, lifecycle callbacks, and custom bridge handlers.
@@ -106,9 +106,47 @@ fn source(context: *anyopaque) anyerror!native_sdk.WebViewSource {
`sourceFromEnv` reads `NATIVE_SDK_FRONTEND_URL`; otherwise it serves the configured asset directory. Use it for most framework apps.
## app.zon essentials
## app.json essentials
Keep `app.zon` as the source of truth for app-level behavior:
Keep `app.json` as the source of truth for app-level behavior. Existing `app.zon` projects use the same fields in ZON syntax.
```json
{
"$schema": "https://schema.native-sdk.dev/app/v1.json",
"id": "com.example.my-app",
"name": "my-app",
"display_name": "My App",
"description": "One line about the app, shown in the About panel.",
"version": "0.1.0",
"icons": ["assets/icon.png"],
"platforms": ["macos", "linux"],
"permissions": [],
"capabilities": ["webview"],
"frontend": {
"dist": "frontend/dist",
"entry": "index.html",
"spa_fallback": true,
"dev": {
"url": "http://127.0.0.1:5173/",
"command": ["npm", "--prefix", "frontend", "run", "dev", "--", "--host", "127.0.0.1"],
"ready_path": "/",
"timeout_ms": 30000
}
},
"security": {
"navigation": {
"allowed_origins": ["zero://app", "http://127.0.0.1:5173"],
"external_links": { "action": "deny" }
}
},
"web_engine": "system",
"windows": [
{ "label": "main", "title": "My App", "width": 960, "height": 640, "restore_state": true }
]
}
```
Legacy ZON equivalent:
```zig
.{
@@ -151,13 +189,13 @@ Use exact local origins for dev servers. Add `zero://inline` only for inline HTM
### Add a new framework app
Use `native init <path> --frontend <next|vite|react|svelte|vue>`. Then inspect the generated `app.zon`, `src/main.zig`, and `build.zig` before customizing. For framework behavior, keep frontend work in `frontend/` and use `sourceFromEnv` so development and packaged builds share one app shell.
Use `native init <path> --frontend <next|vite|react|svelte|vue>`. Then inspect the generated `app.json`, `src/main.zig`, and `build.zig` before customizing. For framework behavior, keep frontend work in `frontend/` and use `sourceFromEnv` so development and packaged builds share one app shell.
### Add a native bridge command
1. Add state and a handler in `src/main.zig`.
2. Register the handler in `bridge()`.
3. Allow the command in `app.zon` and in the runtime bridge policy if the runner reads manifest policy into runtime.
3. Allow the command in the app manifest and in the runtime bridge policy if the runner reads manifest policy into runtime.
4. Call it from JavaScript with `window.zero.invoke("namespace.command", payload)`.
5. Return valid JSON from Zig. Use `native_sdk.bridge.writeJsonStringValue()` for user-controlled strings.
@@ -182,11 +220,11 @@ native package --target macos --archive
On macOS, `--archive` adds a zero-config drag-to-Applications DMG with a generated 1×/2× Retina background. The optional `app.zon` `.dmg` block controls its volume name, PNG/JPEG/TIFF background, usable Finder canvas/icon geometry, and Applications link. An adjacent `name@2x.png`/`.jpg` is discovered automatically. Use `.dmg.items` only for a fully art-directed list: exactly one `app`, plus optional `applications`, project-relative `file`, and absolute `link` entries, each with its own icon-center position.
Apps that own their build (ejected or scaffolded `--full`) wire the same step into the build graph: keep package metadata in `app.zon`, build the frontend assets, build the native binary, then package:
Apps that own their build (ejected or scaffolded `--full`) wire the same step into the build graph: keep package metadata in the app manifest, build the frontend assets, build the native binary, then package:
```bash
zig build package
native doctor --manifest app.zon --strict
native doctor --manifest app.json --strict
```
Use signing and CEF options only when the product requires them.
@@ -211,7 +249,7 @@ zig build dev
Or run the CLI directly after building the binary:
```bash
native dev --manifest app.zon --binary zig-out/bin/MyApp
native dev --manifest app.json --binary zig-out/bin/MyApp
```
Vite usually uses `http://127.0.0.1:5173/`; Next.js usually uses `http://127.0.0.1:3000/`. The app WebView loads the dev URL directly, so framework HMR remains owned by Vite, Next.js, or the selected dev server.
@@ -247,8 +285,8 @@ zig build run
zig build dev
zig build test
zig build test-tooling
native validate app.zon
native doctor --manifest app.zon --strict
native validate app.json
native doctor --manifest app.json --strict
zig build package
```
@@ -4,13 +4,13 @@ Use this when creating, orienting in, or restructuring a Native SDK app.
## Generated project files
A default zero-config app ships no build files at all. Its authored files are `app.zon`, `src/core.ts`, and `src/app.native` (+ `assets/`). `native check` validates the TypeScript core, markup, and manifest without generating a build graph; `native dev|test|build` compile the app to native code and synthesize the build graph into `.native/build/` (gitignored). `build.zig`/`build.zig.zon` appear only in apps that own their build (`native eject`, the `--full` scaffold, or an expanded example). A Zig core is an explicit alternative created with `--template zig-core`, not something to infer from the SDK's implementation or older examples.
A default zero-config app ships no build files at all. Its authored files are `app.json`, `src/core.ts`, and `src/app.native` (+ `assets/`). Existing `app.zon` manifests remain fully supported. `native check` validates the TypeScript core, markup, and manifest without generating a build graph; `native dev|test|build` compile the app to native code and synthesize the build graph into `.native/build/` (gitignored). `build.zig`/`build.zig.zon` appear only in apps that own their build (`native eject`, the `--full` scaffold, or an expanded example). A Zig core is an explicit alternative created with `--template zig-core`, not something to infer from the SDK's implementation or older examples.
Files by path:
- `build.zig`: Zig build graph. Expanded scaffolds expose platform selection, trace mode, debug overlay, automation, JS bridge, web engine overrides, frontend install/build/dev steps, tests, and package steps.
- `build.zig.zon`: Zig package manifest and dependency declaration.
- `app.zon`: app manifest read by CLI/build/package/doctor tooling.
- `app.json`: default app manifest read by CLI/build/package/doctor tooling. `app.zon` is the supported alternative.
- `src/core.ts`: the default app core — `Model`, `Msg`, `update`, pure helpers, effects (`Cmd`), and subscriptions (`Sub`).
- `src/app.native`: the default app view — elements, layout, bindings, and typed message dispatch.
- `src/main.zig`: present for an explicitly selected Zig core or lower-level WebView/runtime app; app state, `app()` method, source resolver, optional bridge dispatcher, lifecycle callbacks.
@@ -18,9 +18,9 @@ Files by path:
- `assets/`: icons and other package resources.
- `frontend/`: framework app when using Next, Vite, React, Svelte, or Vue.
## app.zon responsibilities
## App manifest responsibilities
Keep product-level metadata and policies in `app.zon`:
Keep product-level metadata and policies in `app.json` (or `app.zon` in an existing project):
```zig
.{
@@ -115,11 +115,11 @@ zig build test-package-cef-layout -Dplatform=macos
## Layering rule
- If changing app identity, packaging inputs, permissions, origins, windows, frontend dist/dev paths, or web engine, update `app.zon`.
- If changing app identity, packaging inputs, permissions, origins, windows, frontend dist/dev paths, or web engine, update the app's manifest.
- If changing default app state, messages, transitions, effects, subscriptions, or derived bindings, update `src/core.ts` and follow the `ts-core` skill.
- If changing the default app UI, update `src/app.native` and follow the `native-ui` skill.
- If changing runtime services, platform setup, automation, logging, security wiring, or builtin bridge policy, update `src/runner.zig`.
- If an existing lower-level/Zig app changes native lifecycle callbacks, bridge handlers, source selection, or Zig-core behavior, update its `src/main.zig`.
- If changing UI, routes, CSS, or web calls, update `frontend/`.
Do not add `src/main.zig`, `src/runner.zig`, or an owned build merely to implement ordinary behavior in a default TypeScript app. Do not put package metadata in app state or bypass `app.zon` policy for convenience.
Do not add `src/main.zig`, `src/runner.zig`, or an owned build merely to implement ordinary behavior in a default TypeScript app. Do not put package metadata in app state or bypass manifest policy for convenience.
+3 -3
View File
@@ -1,13 +1,13 @@
---
name: ts-core
description: Authoring guide for the primary Native SDK app-logic path: TypeScript app cores - Model, Msg, update, and the pure functions they call, written in the closed app-core subset, checked by the @native-sdk/core frontend, and compiled ahead-of-time to native code by the external core compiler. Use for new apps unless the user explicitly chose Zig, when writing or modifying a src/core.ts app core, fixing subset checker errors (NS1001-NS1069), or deciding how to express state, messages, text (bytes and the byte-text string methods), text input, continuous controls (sliders, scroll), effects (Cmd), subscriptions (Sub), the host-event wiring channels (frameMsg, keyMsg, pinchMsg, dropMsg, appearanceMsg, chromeMsg, envMsgs, app.zon assets), derived values, the view_unbound lint opt-out, local mutation of owned arrays, or how to split a core into modules under src/ (relative imports, namespace imports, @native-sdk/core/text, @native-sdk/core/events). Use ts-services alongside this guide for src/services work.
description: Authoring guide for the primary Native SDK app-logic path: TypeScript app cores - Model, Msg, update, and the pure functions they call, written in the closed app-core subset, checked by the @native-sdk/core frontend, and compiled ahead-of-time to native code by the external core compiler. Use for new apps unless the user explicitly chose Zig, when writing or modifying a src/core.ts app core, fixing subset checker errors (NS1001-NS1069), or deciding how to express state, messages, text (bytes and the byte-text string methods), text input, continuous controls (sliders, scroll), effects (Cmd), subscriptions (Sub), the host-event wiring channels (frameMsg, keyMsg, pinchMsg, dropMsg, appearanceMsg, chromeMsg, envMsgs, app manifest assets), derived values, the view_unbound lint opt-out, local mutation of owned arrays, or how to split a core into modules under src/ (relative imports, namespace imports, @native-sdk/core/text, @native-sdk/core/events). Use ts-services alongside this guide for src/services work.
---
# Author app cores in the TypeScript subset
TypeScript is the primary app-authoring language. 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. You write it as a TypeScript module rooted at `src/core.ts` - splitting into more modules under `src/` when it grows (see "Splitting a core into modules") - and the build checks the whole import graph with the `@native-sdk/core` frontend and compiles it to native code with the external core compiler. 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. The same file is executable TypeScript: it typechecks with stock tsc and runs unmodified under node, so you can poke behavior with plain node scripts before the native build.
A whole TS app starts as three files of truth and zero Zig: `src/core.ts` (this guide; plus core-class modules it imports under `src/`), `src/app.native` (the markup view over the core's model), and `app.zon` (windows, identity, permissions). Optional ordinary-TypeScript service modules live under `src/services/` and are never imported by the core; load `native skills get ts-services` for that boundary. `native init` scaffolds the three-file base; the build detects `src/core.ts` in the tree (never a flag or config — a tree with both `src/core.ts` and `src/main.zig` is a teaching error) and generates the wiring outside the app. The loop:
A whole TS app starts as three files of truth and zero Zig: `src/core.ts` (this guide; plus core-class modules it imports under `src/`), `src/app.native` (the markup view over the core's model), and `app.json` (windows, identity, permissions). Existing `app.zon` manifests remain supported. Optional ordinary-TypeScript service modules live under `src/services/` and are never imported by the core; load `native skills get ts-services` for that boundary. `native init` scaffolds the three-file base; the build detects `src/core.ts` in the tree (never a flag or config — a tree with both `src/core.ts` and `src/main.zig` is a teaching error) and generates the wiring outside the app. The loop:
```sh
native dev --core # the fastest loop: run the core under node's virtual host —
@@ -15,7 +15,7 @@ native dev --core # the fastest loop: run the core under node's virtual host
# for bytes payloads, {"advance":1000} to run virtual timers),
# watch the model + effect transcript. Logic only, no renderer.
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/app.zon
native build # ReleaseFast binary; native test runs the app's tests
```
+2 -2
View File
@@ -1,6 +1,6 @@
---
name: native-sdk
description: Discovery skill for the Native SDK, the complete toolkit for building native desktop applications. Apps are authored in TypeScript + declarative Native markup (.native) by default and compiled to native code with no JS runtime in the binary; Zig cores are an explicit alternative, and WebViews are the optional web-content path. Use when the user asks what the Native SDK is, how to build a Native SDK app, author native UI, scaffold an app, configure app.zon, add bridge commands, embed web content, package an app, test a running app, or automate a Native SDK app.
description: Discovery skill for the Native SDK, the complete toolkit for building native desktop applications. Apps are authored in TypeScript + declarative Native markup (.native) by default and compiled to native code with no JS runtime in the binary; Zig cores are an explicit alternative, and WebViews are the optional web-content path. Use when the user asks what the Native SDK is, how to build a Native SDK app, author native UI, scaffold an app, configure app.json or legacy app.zon, add bridge commands, embed web content, package an app, test a running app, or automate a Native SDK app.
allowed-tools: Bash(native:*), Bash(npx @native-sdk/cli:*)
hidden: true
---
@@ -31,4 +31,4 @@ cd my_app
native dev
```
`native init my_app` generates the primary three-file app: `app.zon`, `src/app.native` (the markup view), and `src/core.ts` (`Model`, `Msg`, `update`). Inspect the tree before editing an existing app and preserve the core language it already uses. `src/main.zig` means the app explicitly uses the Zig-core template; web-frontend shells additionally carry `frontend/` and usually owned build/runtime wiring.
`native init my_app` generates the primary three-file app: `app.json`, `src/app.native` (the markup view), and `src/core.ts` (`Model`, `Msg`, `update`). Existing `app.zon` manifests remain supported. Inspect the tree before editing an existing app and preserve the core language it already uses. `src/main.zig` means the app explicitly uses the Zig-core template; web-frontend shells additionally carry `frontend/` and usually owned build/runtime wiring.
+5 -4
View File
@@ -423,6 +423,7 @@ pub fn buildEmbedLib(allocator: std.mem.Allocator, io: std.Io, app_name: []const
build_file = try buildgraph.ensureGeneratedBuild(allocator, io, ".", .{
.app_name = app_name,
.framework_root = framework_root,
.manifest_name = manifest_tool.defaultPath(io) orelse "app.json",
});
try argv.appendSlice(allocator, &.{ "--build-file", build_file.? });
}
@@ -655,15 +656,15 @@ pub const DevOptions = struct {
/// directory through an Io the desktop runner supplies, and the embed
/// host supplies neither — edit + rerun is the loop today.
pub fn runDev(allocator: std.mem.Allocator, io: std.Io, options: DevOptions) !void {
if (!buildgraph.fileExists(io, "app.zon")) {
const manifest_path = manifest_tool.defaultPath(io) orelse {
std.debug.print(
\\no app.zon here — `native dev --target android` runs inside an app directory
\\no app.json or app.zon here — `native dev --target android` runs inside an app directory
\\(or pass one: `native dev path/to/app --target android`). Start one with `native init`.
\\
, .{});
return error.MissingManifest;
}
const metadata = try manifest_tool.readMetadata(allocator, io, "app.zon");
};
const metadata = try manifest_tool.readMetadata(allocator, io, manifest_path);
const application_id = try applicationIdAlloc(allocator, metadata.id);
defer allocator.free(application_id);
+24 -8
View File
@@ -1,4 +1,4 @@
//! Zero-config build graph: when an app directory carries only app.zon +
//! Zero-config build graph: when an app directory carries only app.json/app.zon +
//! src/ (+ assets), the CLI synthesizes a build.zig/build.zig.zon pair into
//! `<app>/.native/build/` and drives it with `zig build --build-file`. The
//! generated build.zig is the same ~5-line `addApp` call every ejected app
@@ -127,6 +127,7 @@ pub const GenerateOptions = struct {
app_name: []const u8,
/// Absolute path to the framework checkout (resolveFrameworkRoot).
framework_root: []const u8,
manifest_name: []const u8 = "app.zon",
};
/// Synthesize (or refresh) `<app>/.native/build/{build.zig,build.zig.zon}`.
@@ -181,7 +182,7 @@ pub fn ensureGeneratedBuild(allocator: std.mem.Allocator, io: std.Io, app_dir: [
},
};
const build_zig = try renderBuildZig(allocator, options.app_name, .generated);
const build_zig = try renderBuildZigWithManifest(allocator, options.app_name, .generated, options.manifest_name);
defer allocator.free(build_zig);
const build_zon = try renderBuildZon(allocator, options.app_name, zon_dependency_path, .generated);
defer allocator.free(build_zon);
@@ -197,6 +198,7 @@ pub fn ensureGeneratedBuild(allocator: std.mem.Allocator, io: std.Io, app_dir: [
pub const EjectOptions = struct {
app_name: []const u8,
framework_root: []const u8,
manifest_name: []const u8 = "app.zon",
};
/// Write an owned build.zig/build.zig.zon pair into the app directory.
@@ -239,9 +241,9 @@ pub fn eject(allocator: std.mem.Allocator, io: std.Io, app_dir: []const u8, opti
},
};
const build_zig = try renderBuildZig(allocator, options.app_name, .ejected);
const build_zig = try renderBuildZigWithManifest(allocator, options.app_name, .ejected, options.manifest_name);
defer allocator.free(build_zig);
const build_zon = try renderBuildZon(allocator, options.app_name, zon_dependency_path, .ejected);
const build_zon = try renderBuildZonWithManifest(allocator, options.app_name, zon_dependency_path, .ejected, options.manifest_name);
defer allocator.free(build_zon);
try dir.writeFile(io, .{ .sub_path = "build.zig", .data = build_zig });
@@ -257,12 +259,16 @@ const Shape = enum {
};
pub fn renderBuildZig(allocator: std.mem.Allocator, app_name: []const u8, shape: Shape) ![]u8 {
return renderBuildZigWithManifest(allocator, app_name, shape, "app.zon");
}
fn renderBuildZigWithManifest(allocator: std.mem.Allocator, app_name: []const u8, shape: Shape, manifest_name: []const u8) ![]u8 {
var out: std.ArrayList(u8) = .empty;
errdefer out.deinit(allocator);
switch (shape) {
.generated => try out.appendSlice(allocator,
\\//! Generated by the `native` CLI — do not edit. This file is
\\//! re-synthesized from app.zon on every `native dev|build|test`
\\//! re-synthesized from the app manifest on every `native dev|build|test`
\\//! and any change here is overwritten. Run `native eject` to
\\//! write a build.zig you own into the app directory instead.
\\
@@ -291,6 +297,8 @@ pub fn renderBuildZig(allocator: std.mem.Allocator, app_name: []const u8, shape:
);
try out.appendSlice(allocator, " ");
try appendZigString(&out, allocator, app_name);
try out.appendSlice(allocator, ", .manifest = ");
try appendZigString(&out, allocator, manifest_name);
switch (shape) {
.generated => try out.appendSlice(allocator, ", .app_root = \"../..\""),
.ejected => {},
@@ -304,6 +312,10 @@ pub fn renderBuildZig(allocator: std.mem.Allocator, app_name: []const u8, shape:
}
pub fn renderBuildZon(allocator: std.mem.Allocator, app_name: []const u8, dependency_path: []const u8, shape: Shape) ![]u8 {
return renderBuildZonWithManifest(allocator, app_name, dependency_path, shape, "app.zon");
}
fn renderBuildZonWithManifest(allocator: std.mem.Allocator, app_name: []const u8, dependency_path: []const u8, shape: Shape, manifest_name: []const u8) ![]u8 {
const module_name = try templates.normalizeModuleName(allocator, app_name);
defer allocator.free(module_name);
@@ -325,7 +337,11 @@ pub fn renderBuildZon(allocator: std.mem.Allocator, app_name: []const u8, depend
try out.appendSlice(allocator, " } },\n");
switch (shape) {
.generated => try out.appendSlice(allocator, " .paths = .{ \"build.zig\", \"build.zig.zon\" },\n"),
.ejected => try out.appendSlice(allocator, " .paths = .{ \"build.zig\", \"build.zig.zon\", \"src\", \"assets\", \"app.zon\" },\n"),
.ejected => {
try out.appendSlice(allocator, " .paths = .{ \"build.zig\", \"build.zig.zon\", \"src\", \"assets\", ");
try appendZigString(&out, allocator, manifest_name);
try out.appendSlice(allocator, " },\n");
},
}
try out.appendSlice(allocator, "}\n");
return out.toOwnedSlice(allocator);
@@ -354,14 +370,14 @@ test "generated build.zig points addApp two directories up" {
const text = try renderBuildZig(std.testing.allocator, "my-app", .generated);
defer std.testing.allocator.free(text);
try std.testing.expect(std.mem.indexOf(u8, text, "Generated by the `native` CLI") != null);
try std.testing.expect(std.mem.indexOf(u8, text, ".name = \"my-app\", .app_root = \"../..\"") != null);
try std.testing.expect(std.mem.indexOf(u8, text, ".name = \"my-app\", .manifest = \"app.zon\", .app_root = \"../..\"") != null);
}
test "ejected build.zig is the plain addApp call with an ownership header" {
const text = try renderBuildZig(std.testing.allocator, "my-app", .ejected);
defer std.testing.allocator.free(text);
try std.testing.expect(std.mem.indexOf(u8, text, "native eject") != null);
try std.testing.expect(std.mem.indexOf(u8, text, ".{ .name = \"my-app\" }") != null);
try std.testing.expect(std.mem.indexOf(u8, text, ".{ .name = \"my-app\", .manifest = \"app.zon\" }") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "app_root") == null);
}
+1 -1
View File
@@ -126,7 +126,7 @@ fn migrationSourceVersion(io: std.Io) !u32 {
}
fn appDbPath(allocator: std.mem.Allocator, io: std.Io, env: *std.process.Environ.Map, buffer: []u8) ![]const u8 {
const metadata = try manifest_tool.readMetadata(allocator, io, "app.zon");
const metadata = try manifest_tool.readMetadata(allocator, io, manifest_tool.defaultPath(io) orelse return error.FileNotFound);
var dir_buffer: [std.Io.Dir.max_path_bytes]u8 = undefined;
const data_dir = try app_dirs.resolveOne(
.{ .name = metadata.id },
+1 -1
View File
@@ -165,7 +165,7 @@ pub fn reportForCurrentHostWithProbe(
} else if (target.os != .macos) {
try buffers.add("webview-system", .unsupported, "system WebView backend is not wired for this host yet", .{});
}
var manifest_engine = web_engine.readManifestConfig(allocator, io, options.manifest_path orelse "app.zon") catch web_engine.ManifestConfig{};
var manifest_engine = web_engine.readManifestConfig(allocator, io, options.manifest_path orelse manifest_tool.defaultPath(io) orelse "app.json") catch web_engine.ManifestConfig{};
defer manifest_engine.deinit(allocator);
const resolved_engine = web_engine.resolve(manifest_engine, .{
.web_engine = options.web_engine_override,
+5 -4
View File
@@ -216,6 +216,7 @@ pub fn buildEmbedLib(allocator: std.mem.Allocator, io: std.Io, app_name: []const
build_file = try buildgraph.ensureGeneratedBuild(allocator, io, ".", .{
.app_name = app_name,
.framework_root = framework_root,
.manifest_name = manifest_tool.defaultPath(io) orelse "app.json",
});
try argv.appendSlice(allocator, &.{ "--build-file", build_file.? });
}
@@ -262,15 +263,15 @@ pub const DevOptions = struct {
/// directory through an Io the desktop runner supplies, and the embed
/// host supplies neither — edit + rerun is the loop today.
pub fn runDev(allocator: std.mem.Allocator, io: std.Io, options: DevOptions) !void {
if (!buildgraph.fileExists(io, "app.zon")) {
const manifest_path = manifest_tool.defaultPath(io) orelse {
std.debug.print(
\\no app.zon here — `native dev --target ios` runs inside an app directory
\\no app.json or app.zon here — `native dev --target ios` runs inside an app directory
\\(or pass one: `native dev path/to/app --target ios`). Start one with `native init`.
\\
, .{});
return error.MissingManifest;
}
const metadata = try manifest_tool.readMetadata(allocator, io, "app.zon");
};
const metadata = try manifest_tool.readMetadata(allocator, io, manifest_path);
const bundle_id = try bundleIdAlloc(allocator, metadata.id);
defer allocator.free(bundle_id);
+136
View File
@@ -0,0 +1,136 @@
//! Lossless JSON-to-ZON syntax adapter for app manifests. JSON is the
//! authoring format; generated/ejected Zig build graphs feed this equivalent
//! module to the existing comptime runner so both syntaxes have one runtime
//! feature implementation.
const std = @import("std");
pub fn convertAlloc(allocator: std.mem.Allocator, source: []const u8) ![]u8 {
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
// Keep numeric tokens as authored. Parsing through f64 first can round a
// valid u64 manifest value before the generated ZON module sees it.
const root = try std.json.parseFromSliceLeaky(std.json.Value, arena.allocator(), source, .{ .parse_numbers = false });
if (root != .object) return error.ExpectedObject;
try validateValue(root);
var out = std.Io.Writer.Allocating.init(allocator);
errdefer out.deinit();
try writeValue(&out.writer, root, 0);
try out.writer.writeByte('\n');
return out.toOwnedSlice();
}
pub fn validateSource(allocator: std.mem.Allocator, source: []const u8) !void {
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const root = try std.json.parseFromSliceLeaky(std.json.Value, arena.allocator(), source, .{ .parse_numbers = false });
if (root != .object) return error.ExpectedObject;
try validateValue(root);
}
pub fn isJsonPath(path: []const u8) bool {
return std.ascii.eqlIgnoreCase(std.fs.path.extension(path), ".json");
}
fn validateValue(value: std.json.Value) !void {
switch (value) {
.null => return error.NullNotAllowed,
.array => |array| for (array.items) |item| try validateValue(item),
.object => |object| {
var iterator = object.iterator();
while (iterator.next()) |entry| try validateValue(entry.value_ptr.*);
},
else => {},
}
}
fn writeValue(writer: *std.Io.Writer, value: std.json.Value, depth: usize) !void {
switch (value) {
.null => return error.NullNotAllowed,
.bool => |boolean| try writer.writeAll(if (boolean) "true" else "false"),
.integer => |integer| try writer.print("{d}", .{integer}),
.float => |float| try writer.print("{d}", .{float}),
.number_string => |number| try writer.writeAll(number),
.string => |string| try writer.print("\"{f}\"", .{std.zig.fmtString(string)}),
.array => |array| {
if (array.items.len == 0) return writer.writeAll(".{}");
try writer.writeAll(".{\n");
for (array.items) |item| {
try indent(writer, depth + 1);
try writeValue(writer, item, depth + 1);
try writer.writeAll(",\n");
}
try indent(writer, depth);
try writer.writeByte('}');
},
.object => |object| {
if (object.count() == 0) return writer.writeAll(".{}");
try writer.writeAll(".{\n");
var iterator = object.iterator();
while (iterator.next()) |entry| {
// $schema is editor metadata, not an app runtime field.
if (depth == 0 and std.mem.eql(u8, entry.key_ptr.*, "$schema")) continue;
try indent(writer, depth + 1);
try writer.print(".{f} = ", .{std.zig.fmtId(entry.key_ptr.*)});
try writeValue(writer, entry.value_ptr.*, depth + 1);
try writer.writeAll(",\n");
}
try indent(writer, depth);
try writer.writeByte('}');
},
}
}
fn indent(writer: *std.Io.Writer, depth: usize) !void {
try writer.splatByteAll(' ', depth * 4);
}
test "converts a JSON manifest to a Zig manifest module" {
const converted = try convertAlloc(std.testing.allocator,
\\{
\\ "$schema": "https://schema.native-sdk.dev/app/v1.json",
\\ "id": "dev.example.app",
\\ "name": "example",
\\ "version": "1.0.0",
\\ "dock_visible": true,
\\ "windows": [{ "label": "main", "width": 480.5 }]
\\}
);
defer std.testing.allocator.free(converted);
try std.testing.expect(std.mem.indexOf(u8, converted, "$schema") == null);
try std.testing.expect(std.mem.indexOf(u8, converted, ".id = \"dev.example.app\"") != null);
try std.testing.expect(std.mem.indexOf(u8, converted, ".windows = .{") != null);
}
test "JSON manifests reject explicit null and recognize extension case-insensitively" {
try std.testing.expectError(error.NullNotAllowed, convertAlloc(std.testing.allocator,
\\{ "id": "dev.example.app", "name": "example", "version": "1.0.0", "theme": null }
));
try std.testing.expect(isJsonPath("app.json"));
try std.testing.expect(isJsonPath("config/APP.JSON"));
try std.testing.expect(!isJsonPath("app.zon"));
}
test "JSON-to-ZON conversion preserves numeric tokens exactly" {
const converted = try convertAlloc(std.testing.allocator,
\\{
\\ "assets": { "images": [{ "id": 9007199254740993.0, "path": "assets/cover.png" }] },
\\ "frontend": { "dev": { "url": "http://127.0.0.1:5173/", "timeout_ms": 1e3 } }
\\}
);
defer std.testing.allocator.free(converted);
try std.testing.expect(std.mem.indexOf(u8, converted, ".id = 9007199254740993.0") != null);
try std.testing.expect(std.mem.indexOf(u8, converted, ".timeout_ms = 1e3") != null);
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const source_z = try arena.allocator().dupeZ(u8, converted);
const Parsed = struct {
assets: struct { images: []const struct { id: u64, path: []const u8 } },
frontend: struct { dev: struct { url: []const u8, timeout_ms: u32 } },
};
const parsed = try std.zon.parse.fromSliceAlloc(Parsed, arena.allocator(), source_z, null, .{});
try std.testing.expectEqual(@as(u64, 9_007_199_254_740_993), parsed.assets.images[0].id);
try std.testing.expectEqual(@as(u32, 1000), parsed.frontend.dev.timeout_ms);
}
+120 -33
View File
@@ -2,6 +2,7 @@ const std = @import("std");
const app_icon_tool = @import("app_icon");
const app_manifest = @import("app_manifest");
const diagnostics = @import("diagnostics");
const json_to_zon = @import("json_to_zon.zig");
const raw_manifest = @import("raw_manifest.zig");
const web_engine_tool = @import("web_engine.zig");
@@ -464,104 +465,126 @@ const RawUrlScheme = raw_manifest.RawUrlScheme;
const RawDmg = raw_manifest.RawDmg;
const RawDmgItem = raw_manifest.RawDmgItem;
pub const json_name = "app.json";
pub const zon_name = "app.zon";
/// Resolve the conventional manifest in the current app directory. JSON is
/// the authoring default; app.zon remains a fully supported fallback. When a
/// project temporarily contains both (for example while migrating), app.json
/// is authoritative so every CLI boundary makes the same choice.
pub fn defaultPath(io: std.Io) ?[]const u8 {
if (pathExists(io, json_name)) return json_name;
if (pathExists(io, zon_name)) return zon_name;
return null;
}
fn pathExists(io: std.Io, path: []const u8) bool {
const stat = std.Io.Dir.cwd().statFile(io, path, .{}) catch return false;
return stat.kind == .file;
}
pub fn isJsonPath(path: []const u8) bool {
return json_to_zon.isJsonPath(path);
}
pub fn validateFile(allocator: std.mem.Allocator, io: std.Io, path: []const u8) !ValidationResult {
const source = try readFile(allocator, io, path);
defer allocator.free(source);
const metadata = parseText(allocator, source) catch return .{
const metadata = parseTextForPath(allocator, path, source) catch |err| return .{
.ok = false,
.message = zonParseFailureMessage(allocator, source) orelse "app.zon metadata could not be parsed",
.message = parseFailureMessage(allocator, path, source, err),
};
defer metadata.deinit(allocator);
if (metadata.description) |description| {
app_manifest.validateDescription(description) catch return .{
.ok = false,
.message = "app.zon description is invalid - it must be one non-empty line of at most 256 bytes with no control characters (it becomes the About panel credits line)",
.message = "app manifest description is invalid - it must be one non-empty line of at most 256 bytes with no control characters (it becomes the About panel credits line)",
};
}
if (metadata.theme) |theme_name| {
if (!isKnownThemePack(theme_name)) return .{
.ok = false,
.message = "app.zon theme is invalid - expected one of: house, geist",
.message = "app manifest theme is invalid - expected one of: house, geist",
};
}
if (metadata.theme_accent) |accent| {
if (!isHexColor(accent)) return .{
.ok = false,
.message = "app.zon theme_accent is invalid - expected a #rrggbb hex color (e.g. \"#df2670\")",
.message = "app manifest theme_accent is invalid - expected a #rrggbb hex color (e.g. \"#df2670\")",
};
}
validateIconPaths(metadata.icons) catch return .{ .ok = false, .message = "app.zon icons are invalid" };
validateIconPaths(metadata.icons) catch return .{ .ok = false, .message = "app manifest icons are invalid" };
if (try checkIconSources(allocator, io, std.fs.path.dirname(path) orelse ".", metadata.icons)) |icon_message| {
return .{ .ok = false, .message = icon_message };
}
const permissions = parsePermissions(allocator, metadata.permissions) catch return .{ .ok = false, .message = "app.zon permissions are invalid" };
const permissions = parsePermissions(allocator, metadata.permissions) catch return .{ .ok = false, .message = "app manifest permissions are invalid" };
defer allocator.free(permissions);
const capabilities = parseCapabilities(allocator, metadata.capabilities) catch return .{ .ok = false, .message = "app.zon capabilities are invalid" };
const capabilities = parseCapabilities(allocator, metadata.capabilities) catch return .{ .ok = false, .message = "app manifest capabilities are invalid" };
defer allocator.free(capabilities);
const persist = convertPersist(metadata.persist);
app_manifest.validateImages(.{ .max_image_pixel_bytes = metadata.images.max_image_pixel_bytes }) catch return .{
.ok = false,
.message = "app.zon images.max_image_pixel_bytes must be between 1048576 (the default) and 8388608 bytes; allocation is lazy once per used slot, up to 16 slots",
.message = "app manifest images.max_image_pixel_bytes must be between 1048576 (the default) and 8388608 bytes; allocation is lazy once per used slot, up to 16 slots",
};
if (!validServicePackages(metadata.service_packages)) return .{
.ok = false,
.message = "app.zon service_packages must use safe npm names, exact X.Y.Z versions, unique names, and lowercase SHA-256 content hashes",
.message = "app manifest service_packages must use safe npm names, exact X.Y.Z versions, unique names, and lowercase SHA-256 content hashes",
};
if (!validServiceCarrier(metadata.service_carrier)) return .{
.ok = false,
.message = "app.zon service_carrier must be \"auto\", \"in_process\", or \"child\"",
.message = "app manifest service_carrier must be \"auto\", \"in_process\", or \"child\"",
};
if (metadata.service_pool_size) |pool_size| {
if (pool_size < 1 or pool_size > 16) return .{
.ok = false,
.message = "app.zon service_pool_size must be between 1 and 16",
.message = "app manifest service_pool_size must be between 1 and 16",
};
}
const bridge_commands = parseBridgeCommands(allocator, metadata.bridge_commands) catch return .{ .ok = false, .message = "app.zon bridge commands are invalid" };
const bridge_commands = parseBridgeCommands(allocator, metadata.bridge_commands) catch return .{ .ok = false, .message = "app manifest bridge commands are invalid" };
defer {
for (bridge_commands) |command| allocator.free(command.permissions);
allocator.free(bridge_commands);
}
const frontend = if (metadata.frontend) |frontend_value| convertFrontend(frontend_value) else null;
const security = convertSecurity(metadata.security) catch return .{ .ok = false, .message = "app.zon security policy is invalid" };
const windows = convertWindows(allocator, metadata.windows) catch return .{ .ok = false, .message = "app.zon windows are invalid" };
const security = convertSecurity(metadata.security) catch return .{ .ok = false, .message = "app manifest security policy is invalid" };
const windows = convertWindows(allocator, metadata.windows) catch return .{ .ok = false, .message = "app manifest windows are invalid" };
defer allocator.free(windows);
const shell = parseShell(allocator, metadata.shell) catch return .{ .ok = false, .message = "app.zon shell is invalid" };
const shell = parseShell(allocator, metadata.shell) catch return .{ .ok = false, .message = "app manifest shell is invalid" };
defer deinitParsedShell(allocator, shell);
const commands = parseCommands(allocator, metadata.commands) catch return .{ .ok = false, .message = "app.zon commands are invalid" };
const commands = parseCommands(allocator, metadata.commands) catch return .{ .ok = false, .message = "app manifest commands are invalid" };
defer allocator.free(commands);
const menus = parseMenus(allocator, metadata.menus) catch return .{ .ok = false, .message = "app.zon menus are invalid" };
const menus = parseMenus(allocator, metadata.menus) catch return .{ .ok = false, .message = "app manifest menus are invalid" };
defer deinitParsedMenus(allocator, menus);
const shortcuts = parseShortcuts(allocator, metadata.shortcuts) catch return .{ .ok = false, .message = "app.zon shortcuts are invalid" };
const shortcuts = parseShortcuts(allocator, metadata.shortcuts) catch return .{ .ok = false, .message = "app manifest shortcuts are invalid" };
defer allocator.free(shortcuts);
const file_associations = parseFileAssociations(allocator, metadata.file_associations) catch return .{ .ok = false, .message = "app.zon file associations are invalid" };
const file_associations = parseFileAssociations(allocator, metadata.file_associations) catch return .{ .ok = false, .message = "app manifest file associations are invalid" };
defer allocator.free(file_associations);
const url_schemes = parseUrlSchemes(allocator, metadata.url_schemes) catch return .{ .ok = false, .message = "app.zon URL schemes are invalid" };
const url_schemes = parseUrlSchemes(allocator, metadata.url_schemes) catch return .{ .ok = false, .message = "app manifest URL schemes are invalid" };
defer allocator.free(url_schemes);
// General manifest validation owns only values the app explicitly
// declared. Archive-time fallbacks (notably display_name as the volume
// name) are validated when a macOS archive is actually requested, so an
// otherwise valid display name does not make every `native check` fail.
validateDmgSettings(metadata.dmg) catch return .{ .ok = false, .message = "app.zon dmg settings are invalid - check the volume/bundle names, window/icon geometry, and positions; an explicit items list needs exactly one app and unique safe destination names" };
validateDmgSettings(metadata.dmg) catch return .{ .ok = false, .message = "app manifest dmg settings are invalid - check the volume/bundle names, window/icon geometry, and positions; an explicit items list needs exactly one app and unique safe destination names" };
if (try checkDmgSources(allocator, io, std.fs.path.dirname(path) orelse ".", metadata.dmg)) |dmg_message| {
return .{ .ok = false, .message = dmg_message };
}
const manifest_web_engine = parseWebEngine(metadata.web_engine) catch return .{ .ok = false, .message = "app.zon web engine is invalid" };
const manifest_webview_layer = parseWebViewLayer(metadata.webview_layer) catch return .{ .ok = false, .message = "app.zon webview_layer is invalid - expected \"auto\", \"include\", or \"exclude\"" };
const manifest_web_engine = parseWebEngine(metadata.web_engine) catch return .{ .ok = false, .message = "app manifest web engine is invalid" };
const manifest_webview_layer = parseWebViewLayer(metadata.webview_layer) catch return .{ .ok = false, .message = "app manifest webview_layer is invalid - expected \"auto\", \"include\", or \"exclude\"" };
if (!std.mem.eql(u8, metadata.core_compiler, "external")) {
if (std.mem.eql(u8, metadata.core_compiler, "transpiler")) {
return .{ .ok = false, .message = "app.zon core_compiler = \"transpiler\" names the removed TS-to-Zig transpiled lane (v0.7.0 removed it) - TypeScript cores compile through the external core compiler now; delete the setting (or spell it \"external\")" };
return .{ .ok = false, .message = "app manifest core_compiler = \"transpiler\" names the removed TS-to-Zig transpiled lane (v0.7.0 removed it) - TypeScript cores compile through the external core compiler now; delete the setting (or spell it \"external\")" };
}
return .{ .ok = false, .message = "app.zon core_compiler is invalid - expected \"external\" (the default and only lane)" };
return .{ .ok = false, .message = "app manifest core_compiler is invalid - expected \"external\" (the default and only lane)" };
}
const platform_settings = parsePlatformSettings(allocator, metadata.platforms) catch return .{ .ok = false, .message = "app.zon platforms are invalid" };
const platform_settings = parsePlatformSettings(allocator, metadata.platforms) catch return .{ .ok = false, .message = "app manifest platforms are invalid" };
defer allocator.free(platform_settings);
const manifest: app_manifest.Manifest = .{
.identity = .{ .id = metadata.id, .name = metadata.name, .display_name = metadata.display_name, .description = metadata.description },
.version = parseVersion(metadata.version) catch return .{ .ok = false, .message = "app.zon version is invalid" },
.version = parseVersion(metadata.version) catch return .{ .ok = false, .message = "app manifest version is invalid" },
.permissions = permissions,
.capabilities = capabilities,
.dock_visible = metadata.dock_visible,
@@ -585,12 +608,12 @@ pub fn validateFile(allocator: std.mem.Allocator, io: std.Io, path: []const u8)
app_manifest.validateManifest(manifest) catch |err| return .{
.ok = false,
.message = switch (err) {
error.MissingTrayCapability => "app.zon dock_visible = false requires the \"tray\" capability: an accessory app has no Dock/app-switcher route back to hidden windows - add \"tray\" to .capabilities and install a status item, or keep dock_visible = true (the default)",
error.MissingTrayCapability => "app manifest dock_visible = false requires the \"tray\" capability: an accessory app has no Dock/app-switcher route back to hidden windows - add \"tray\" to capabilities and install a status item, or keep dock_visible = true (the default)",
error.WebViewLayerConflict => web_layer_conflict_message,
else => "manifest fields failed semantic validation",
},
};
return .{ .ok = true, .message = "app.zon is valid" };
return .{ .ok = true, .message = if (isJsonPath(path)) "app.json is valid" else "app.zon is valid" };
}
/// Re-parse a failed manifest with std.zon diagnostics enabled so the
@@ -616,12 +639,21 @@ fn zonParseFailureMessage(allocator: std.mem.Allocator, source: []const u8) ?[]c
}
}
fn parseFailureMessage(allocator: std.mem.Allocator, path: []const u8, source: []const u8, err: anyerror) []const u8 {
if (!isJsonPath(path)) return zonParseFailureMessage(allocator, source) orelse "app.zon metadata could not be parsed";
if (err == error.NullNotAllowed) return "app.json cannot contain null values - omit optional fields instead";
return std.fmt.allocPrint(allocator, "{s} could not be parsed as a Native SDK manifest ({s})", .{ std.fs.path.basename(path), @errorName(err) }) catch "app.json metadata could not be parsed";
}
pub fn readMetadata(allocator: std.mem.Allocator, io: std.Io, path: []const u8) !Metadata {
const source = try readFile(allocator, io, path);
defer allocator.free(source);
return parseText(allocator, source);
return parseTextForPath(allocator, path, source);
}
/// Parse legacy ZON text. Kept as the focused test/helper API; file-backed
/// callers use parseTextForPath so app.json and app.zon share one conversion
/// and semantic-validation pipeline.
pub fn parseText(allocator: std.mem.Allocator, source: []const u8) !Metadata {
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
@@ -629,6 +661,22 @@ pub fn parseText(allocator: std.mem.Allocator, source: []const u8) !Metadata {
const source_z = try scratch.dupeZ(u8, source);
@setEvalBranchQuota(4000);
const raw = try std.zon.parse.fromSliceAlloc(RawManifest, scratch, source_z, null, .{});
return metadataFromRaw(allocator, raw);
}
pub fn parseJsonText(allocator: std.mem.Allocator, source: []const u8) !Metadata {
try json_to_zon.validateSource(allocator, source);
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const raw = try std.json.parseFromSliceLeaky(RawManifest, arena.allocator(), source, .{ .ignore_unknown_fields = false });
return metadataFromRaw(allocator, raw);
}
pub fn parseTextForPath(allocator: std.mem.Allocator, path: []const u8, source: []const u8) !Metadata {
return if (isJsonPath(path)) parseJsonText(allocator, source) else parseText(allocator, source);
}
fn metadataFromRaw(allocator: std.mem.Allocator, raw: RawManifest) !Metadata {
return .{
.id = try allocator.dupe(u8, raw.id),
.name = try allocator.dupe(u8, raw.name),
@@ -1685,7 +1733,7 @@ fn parseWebViewLayer(value: []const u8) !app_manifest.WebViewLayer {
/// The teaching message every boundary prints for the same
/// contradiction: a manifest that excludes the web layer while declaring
/// web content.
pub const web_layer_conflict_message = "app.zon sets .webview_layer = \"exclude\" but the app declares web content (a .frontend block, the \"webview\" capability, a .shell webview view, or the Chromium web engine - from .web_engine or --web-engine) - remove the web declarations or drop the exclude";
pub const web_layer_conflict_message = "the app manifest sets webview_layer = \"exclude\" but the app declares web content (a frontend block, the \"webview\" capability, a shell webview view, or the Chromium web engine - from web_engine or --web-engine) - remove the web declarations or drop the exclude";
/// The same contradiction arriving through the CLI flag instead of the
/// manifest field: `--web-layer exclude` against an app that declares
@@ -2390,6 +2438,45 @@ fn parseVersionNumber(value: []const u8) !u32 {
return std.fmt.parseUnsigned(u32, value, 10);
}
test "JSON manifest parser accepts schema metadata and rejects unknown fields" {
const metadata = try parseJsonText(std.testing.allocator,
\\{
\\ "$schema": "https://schema.native-sdk.dev/app/v1.json",
\\ "id": "com.example.json",
\\ "name": "json-app",
\\ "version": "1.2.3",
\\ "capabilities": ["native_views"],
\\ "windows": [{ "label": "main", "width": 640, "height": 480 }]
\\}
);
defer metadata.deinit(std.testing.allocator);
try std.testing.expectEqualStrings("com.example.json", metadata.id);
try std.testing.expectEqualStrings("native_views", metadata.capabilities[0]);
try std.testing.expectEqual(@as(f32, 640), metadata.windows[0].width);
try std.testing.expectError(error.UnknownField, parseJsonText(std.testing.allocator,
\\{ "id": "com.example.json", "name": "json-app", "version": "1.2.3", "typo": true }
));
try std.testing.expectError(error.NullNotAllowed, parseJsonText(std.testing.allocator,
\\{ "id": "com.example.json", "name": "json-app", "version": "1.2.3", "theme": null }
));
}
test "JSON file validation rejects null with a teaching diagnostic regardless of extension case" {
var cwd = std.Io.Dir.cwd();
const root = ".zig-cache/test-validate-json-null";
try cwd.deleteTree(std.testing.io, root);
defer cwd.deleteTree(std.testing.io, root) catch {};
try cwd.createDirPath(std.testing.io, root);
try cwd.writeFile(std.testing.io, .{ .sub_path = root ++ "/APP.JSON", .data =
\\{ "id": "com.example.json", "name": "json-app", "version": "1.2.3", "theme": null }
});
const result = try validateFile(std.testing.allocator, std.testing.io, root ++ "/APP.JSON");
try std.testing.expect(!result.ok);
try std.testing.expectEqualStrings("app.json cannot contain null values - omit optional fields instead", result.message);
}
test "manifest metadata parser reads identity version and lists" {
const metadata = try parseText(std.testing.allocator,
\\.{
@@ -3296,7 +3383,7 @@ test "validate rejects a web-declaring manifest that excludes the web layer" {
const result = try validateFile(gpa, std.testing.io, root ++ "/app.zon");
try std.testing.expect(!result.ok);
try std.testing.expect(std.mem.indexOf(u8, result.message, ".webview_layer = \"exclude\"") != null);
try std.testing.expect(std.mem.indexOf(u8, result.message, "webview_layer = \"exclude\"") != null);
try std.testing.expect(std.mem.indexOf(u8, result.message, "declares web content") != null);
// A native-only manifest with the same exclude is valid.
+4
View File
@@ -1,6 +1,10 @@
const web_engine = @import("web_engine.zig");
pub const RawManifest = struct {
/// Editor-only JSON Schema association. The manifest tooling ignores the
/// value after parsing; app.json scaffolds point it at the published SDK
/// schema so editors can complete and validate the full manifest surface.
@"$schema": ?[]const u8 = null,
id: []const u8,
name: []const u8,
display_name: ?[]const u8 = null,
+1
View File
@@ -1,6 +1,7 @@
pub const templates = @import("templates.zig");
pub const manifest = @import("manifest.zig");
pub const raw_manifest = @import("raw_manifest.zig");
pub const json_to_zon = @import("json_to_zon.zig");
pub const assets = @import("assets.zig");
pub const codesign = @import("codesign.zig");
pub const doctor = @import("doctor.zig");
+165 -116
View File
@@ -54,7 +54,7 @@ pub const Frontend = enum {
};
/// Scaffold shape for the native frontend. `slim` is the zero-config
/// default: app.zon + src/ + assets + README only — the `native` CLI owns
/// default: app.json + src/ + assets + README only — the `native` CLI owns
/// the build graph (`native dev|build|test`) and `native eject` writes an
/// owned build.zig later. `full` keeps the pre-zero-config shape
/// (build.zig, build.zig.zon, .vscode, CI workflow) for users who want to
@@ -132,8 +132,8 @@ pub fn writeDefaultApp(allocator: std.mem.Allocator, io: std.Io, destination: []
defer allocator.free(build_zon);
const main_zig = try mainZig(allocator, names, options.frontend);
defer allocator.free(main_zig);
const app_zon = try appZon(allocator, names, options.frontend);
defer allocator.free(app_zon);
const app_json = try appJson(allocator, names, options.frontend);
defer allocator.free(app_json);
const readme_md = try readme(allocator, names, framework_path, options.frontend);
defer allocator.free(readme_md);
const ci_yaml = try frontendCiYaml(allocator, names);
@@ -144,7 +144,7 @@ pub fn writeDefaultApp(allocator: std.mem.Allocator, io: std.Io, destination: []
try writeFile(app_dir, io, "build.zig.zon", build_zon);
try writeFile(app_dir, io, "src/main.zig", main_zig);
try writeFile(app_dir, io, "src/runner.zig", runnerZig());
try writeFile(app_dir, io, "app.zon", app_zon);
try writeFile(app_dir, io, "app.json", app_json);
try writeFile(app_dir, io, "assets/icon.png", default_icon_png);
try writeFile(app_dir, io, ".github/workflows/ci.yml", ci_yaml);
try writeFile(app_dir, io, "README.md", readme_md);
@@ -159,15 +159,15 @@ fn writeNativeAppSlim(allocator: std.mem.Allocator, io: std.Io, app_dir: std.Io.
defer allocator.free(main_zig);
const tests_zig = try nativeTestsZig(allocator, names);
defer allocator.free(tests_zig);
const app_zon = try nativeAppZon(allocator, names);
defer allocator.free(app_zon);
const app_json = try nativeAppJson(allocator, names);
defer allocator.free(app_json);
const readme_md = try slimNativeReadme(allocator, names);
defer allocator.free(readme_md);
try writeFile(app_dir, io, "src/main.zig", main_zig);
try writeFile(app_dir, io, "src/app.native", nativeAppMarkup());
try writeFile(app_dir, io, "src/tests.zig", tests_zig);
try writeFile(app_dir, io, "app.zon", app_zon);
try writeFile(app_dir, io, "app.json", app_json);
try writeFile(app_dir, io, "assets/icon.png", default_icon_png);
try writeFile(app_dir, io, ".gitignore", slimGitignore());
try writeFile(app_dir, io, "README.md", readme_md);
@@ -183,7 +183,7 @@ fn slimGitignore() []const u8 {
}
/// The TypeScript-core zero-config scaffold - the `native init` default:
/// core.ts (logic), app.native (view), app.zon (manifest). ZERO Zig in the
/// core.ts (logic), app.native (view), app.json (manifest). ZERO Zig in the
/// tree; the build graph detects src/core.ts, compiles it through the
/// external core compiler, and stages the generated wiring outside the app
/// on every build.
@@ -195,14 +195,14 @@ fn slimGitignore() []const u8 {
/// detection above keys on src/core.ts alone, and every `native` verb
/// works with node_modules deleted.
fn writeTsAppSlim(allocator: std.mem.Allocator, io: std.Io, app_dir: std.Io.Dir, names: TemplateNames, destination: []const u8, sdk_source: []const u8) !void {
const app_zon = try nativeAppZon(allocator, names);
defer allocator.free(app_zon);
const app_json = try nativeAppJson(allocator, names);
defer allocator.free(app_json);
const readme_md = try tsSlimReadme(allocator, names);
defer allocator.free(readme_md);
try writeFile(app_dir, io, "src/core.ts", tsCoreStarter());
try writeFile(app_dir, io, "src/app.native", tsAppMarkup());
try writeFile(app_dir, io, "app.zon", app_zon);
try writeFile(app_dir, io, "app.json", app_json);
try writeFile(app_dir, io, "assets/icon.png", default_icon_png);
try writeFile(app_dir, io, ".gitignore", tsGitignore());
try writeFile(app_dir, io, "README.md", readme_md);
@@ -218,8 +218,8 @@ fn writeTsApp(allocator: std.mem.Allocator, io: std.Io, app_dir: std.Io.Dir, nam
defer allocator.free(build_zig);
const build_zon = try nativeBuildZon(allocator, names, framework_path);
defer allocator.free(build_zon);
const app_zon = try nativeAppZon(allocator, names);
defer allocator.free(app_zon);
const app_json = try nativeAppJson(allocator, names);
defer allocator.free(app_json);
const readme_md = try tsSlimReadme(allocator, names);
defer allocator.free(readme_md);
const ci_yaml = try nativeCiYaml(allocator, names, framework_path, .ts);
@@ -231,7 +231,7 @@ fn writeTsApp(allocator: std.mem.Allocator, io: std.Io, app_dir: std.Io.Dir, nam
try writeFile(app_dir, io, "build.zig.zon", build_zon);
try writeFile(app_dir, io, "src/core.ts", tsCoreStarter());
try writeFile(app_dir, io, "src/app.native", tsAppMarkup());
try writeFile(app_dir, io, "app.zon", app_zon);
try writeFile(app_dir, io, "app.json", app_json);
try writeFile(app_dir, io, "assets/icon.png", default_icon_png);
try writeFile(app_dir, io, ".gitignore", tsGitignore());
try writeFile(app_dir, io, "README.md", readme_md);
@@ -472,13 +472,13 @@ fn tsSlimReadme(allocator: std.mem.Allocator, names: TemplateNames) ![]const u8
\\ # dispatch messages as JSON lines, watch the model
\\ # and effect transcript (not a renderer)
\\native dev # build and run the real app (markup hot reload)
\\native check # verify core.ts (subset checker) + markup + app.zon
\\native check # verify core.ts (subset checker) + markup + app.json
\\native build # ReleaseFast binary in zig-out/bin/
\\native test # the app's test suite
\\```
\\
\\Edit `src/core.ts` for behavior, `src/app.native` for the view, and
\\`app.zon` for windows/identity/permissions. Markup binds the model's
\\`app.json` for windows/identity/permissions. Markup binds the model's
\\field names exactly as core.ts wrote them (`tickCount` -> `{tickCount}`),
\\and exported single-model helpers bind as derived values (`{total}`).
\\
@@ -530,7 +530,7 @@ fn slimNativeReadme(allocator: std.mem.Allocator, names: TemplateNames) ![]const
\\native dev # build and run the app with hot reload
\\native test # run the app's test suite
\\native build # produce a ReleaseFast binary in zig-out/bin/
\\native check # validate src/*.native markup and app.zon
\\native check # validate src/*.native markup and app.json
\\```
\\
\\## Hot reload
@@ -561,8 +561,8 @@ fn writeNativeApp(allocator: std.mem.Allocator, io: std.Io, app_dir: std.Io.Dir,
defer allocator.free(main_zig);
const tests_zig = try nativeTestsZig(allocator, names);
defer allocator.free(tests_zig);
const app_zon = try nativeAppZon(allocator, names);
defer allocator.free(app_zon);
const app_json = try nativeAppJson(allocator, names);
defer allocator.free(app_json);
const readme_md = try nativeReadme(allocator, names, framework_path);
defer allocator.free(readme_md);
const ci_yaml = try nativeCiYaml(allocator, names, framework_path, .zig);
@@ -573,7 +573,7 @@ fn writeNativeApp(allocator: std.mem.Allocator, io: std.Io, app_dir: std.Io.Dir,
try writeFile(app_dir, io, "src/main.zig", main_zig);
try writeFile(app_dir, io, "src/app.native", nativeAppMarkup());
try writeFile(app_dir, io, "src/tests.zig", tests_zig);
try writeFile(app_dir, io, "app.zon", app_zon);
try writeFile(app_dir, io, "app.json", app_json);
try writeFile(app_dir, io, "assets/icon.png", default_icon_png);
try writeFile(app_dir, io, ".vscode/settings.json", nativeVscodeSettings());
try writeFile(app_dir, io, ".github/workflows/ci.yml", ci_yaml);
@@ -594,7 +594,7 @@ fn nativeBuildZig(allocator: std.mem.Allocator, names: TemplateNames) ![]const u
try out.appendSlice(allocator, " ");
try appendZigString(&out, allocator, names.package_name);
try out.appendSlice(allocator,
\\ });
\\, .manifest = "app.json" });
\\}
\\
);
@@ -626,7 +626,7 @@ fn nativeBuildZon(allocator: std.mem.Allocator, names: TemplateNames, framework_
try appendZigString(&out, allocator, framework_path);
try out.appendSlice(allocator,
\\ } },
\\ .paths = .{ "build.zig", "build.zig.zon", "src", "assets", "app.zon", "README.md" },
\\ .paths = .{ "build.zig", "build.zig.zon", "src", "assets", "app.json", "README.md" },
\\}
\\
);
@@ -1023,61 +1023,62 @@ fn nativeTestsZig(allocator: std.mem.Allocator, names: TemplateNames) ![]const u
return out.toOwnedSlice(allocator);
}
fn nativeAppZon(allocator: std.mem.Allocator, names: TemplateNames) ![]const u8 {
fn nativeAppJson(allocator: std.mem.Allocator, names: TemplateNames) ![]const u8 {
var out: std.ArrayList(u8) = .empty;
errdefer out.deinit(allocator);
try out.appendSlice(allocator,
\\.{
\\ .id =
\\{
\\ "$schema": "https://schema.native-sdk.dev/app/v1.json",
\\ "id":
);
try out.appendSlice(allocator, " ");
try appendZigString(&out, allocator, names.app_id);
try appendJsonString(&out, allocator, names.app_id);
try out.appendSlice(allocator,
\\,
\\ .name =
\\ "name":
);
try out.appendSlice(allocator, " ");
try appendZigString(&out, allocator, names.package_name);
try appendJsonString(&out, allocator, names.package_name);
try out.appendSlice(allocator,
\\,
\\ .display_name =
\\ "display_name":
);
try out.appendSlice(allocator, " ");
try appendZigString(&out, allocator, names.display_name);
try appendJsonString(&out, allocator, names.display_name);
try out.appendSlice(allocator,
\\,
\\ .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 =
\\ "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":
);
try out.appendSlice(allocator, " ");
try appendZigString(&out, allocator, names.display_name);
try appendJsonString(&out, allocator, names.display_name);
try out.appendSlice(allocator,
\\,
\\ .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 },
\\ },
\\ },
\\ },
\\ "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" },
\\ },
\\ "security": {
\\ "navigation": {
\\ "allowed_origins": ["zero://app", "zero://inline"],
\\ "external_links": { "action": "deny" }
\\ }
\\ },
\\ .web_engine = "system",
\\ .cef = .{ .dir = "third_party/cef/macos", .auto_install = false },
\\ "web_engine": "system",
\\ "cef": { "dir": "third_party/cef/macos", "auto_install": false }
\\}
\\
);
@@ -1463,7 +1464,7 @@ fn buildZig(allocator: std.mem.Allocator, names: TemplateNames, framework_path:
\\ const runner_mod = localModule(b, target, optimize, "src/runner.zig");
\\ runner_mod.addImport("native_sdk", native_sdk_mod);
\\ runner_mod.addImport("build_options", options_mod);
\\ runner_mod.addImport("app_manifest_zon", b.createModule(.{ .root_source_file = b.path("app.zon") }));
\\ runner_mod.addImport("app_manifest_zon", appManifestModule(b));
\\ const migrations_mod = b.createModule(.{ .root_source_file = relational_migrations_source, .target = target, .optimize = optimize });
\\ migrations_mod.addImport("native_sdk", native_sdk_mod);
\\ runner_mod.addImport("relational_migrations", migrations_mod);
@@ -1512,7 +1513,7 @@ fn buildZig(allocator: std.mem.Allocator, names: TemplateNames, framework_path:
\\ const run_step = b.step("run", "Run the app");
\\ run_step.dependOn(&run.step);
\\
\\ const dev = b.addSystemCommand(&.{ "native", "dev", "--manifest", "app.zon", "--binary" });
\\ const dev = b.addSystemCommand(&.{ "native", "dev", "--manifest", "app.json", "--binary" });
\\ dev.addFileArg(exe.getEmittedBin());
\\ addWebView2RuntimeRunFiles(b, target, dev, web_engine, web_layer, native_sdk_path);
\\ dev.step.dependOn(&exe.step);
@@ -1530,7 +1531,7 @@ fn buildZig(allocator: std.mem.Allocator, names: TemplateNames, framework_path:
\\ const package_runner_mod = localModule(b, target, package_optimize, "src/runner.zig");
\\ package_runner_mod.addImport("native_sdk", package_sdk_mod);
\\ package_runner_mod.addImport("build_options", options_mod);
\\ package_runner_mod.addImport("app_manifest_zon", b.createModule(.{ .root_source_file = b.path("app.zon") }));
\\ package_runner_mod.addImport("app_manifest_zon", appManifestModule(b));
\\ const package_migrations_mod = b.createModule(.{ .root_source_file = relational_migrations_source, .target = target, .optimize = package_optimize });
\\ package_migrations_mod.addImport("native_sdk", package_sdk_mod);
\\ package_runner_mod.addImport("relational_migrations", package_migrations_mod);
@@ -1561,7 +1562,7 @@ fn buildZig(allocator: std.mem.Allocator, names: TemplateNames, framework_path:
\\ "--target",
\\ @tagName(package_target),
\\ "--manifest",
\\ "app.zon",
\\ "app.json",
\\ "--assets",
);
try appendZigString(&out, allocator, frontend.distDir());
@@ -2089,10 +2090,10 @@ fn buildZig(allocator: std.mem.Allocator, names: TemplateNames, framework_path:
\\ // The fallback for a manifest this lenient parse cannot read
\\ // keeps the web layer (see AppManifestBuildConfig): a shape
\\ // mismatch here is not proof the app declares no web use.
\\ const fallback: AppManifestBuildConfig = .{ .web_declaration = "an app.zon this build graph could not parse" };
\\ const source: [:0]const u8 = @embedFile("app.zon");
\\ const fallback: AppManifestBuildConfig = .{ .web_declaration = "an app.json this build graph could not parse" };
\\ const source = @embedFile("app.json");
\\ @setEvalBranchQuota(4000);
\\ const raw = std.zon.parse.fromSliceAlloc(InferenceManifest, b.allocator, source, null, .{ .ignore_unknown_fields = true }) catch return fallback;
\\ const raw = std.json.parseFromSliceLeaky(InferenceManifest, b.allocator, source, .{ .ignore_unknown_fields = true }) catch return fallback;
\\ var config: AppManifestBuildConfig = .{
\\ .web_engine = parseWebEngine(raw.web_engine) orelse .system,
\\ .cef_dir = raw.cef.dir,
@@ -2119,6 +2120,49 @@ fn buildZig(allocator: std.mem.Allocator, names: TemplateNames, framework_path:
\\ return config;
\\}
\\
\\fn appManifestModule(b: *std.Build) *std.Build.Module {
\\ const root = std.json.parseFromSliceLeaky(std.json.Value, b.allocator, @embedFile("app.json"), .{ .parse_numbers = false }) catch
\\ @panic("cannot parse app.json; run `native check` for a precise diagnostic");
\\ if (root != .object) @panic("app.json must contain one object");
\\ var out = std.Io.Writer.Allocating.init(b.allocator);
\\ writeManifestValue(&out.writer, root, 0) catch |err| switch (err) {
\\ error.NullNotAllowed => @panic("app.json cannot contain null values; omit optional fields instead"),
\\ else => @panic("out of memory converting app.json"),
\\ };
\\ const generated = b.addWriteFiles().add("app_manifest.zon", out.written());
\\ return b.createModule(.{ .root_source_file = generated });
\\}
\\
\\fn writeManifestValue(writer: *std.Io.Writer, value: std.json.Value, depth: usize) !void {
\\ switch (value) {
\\ .null => return error.NullNotAllowed,
\\ .bool => |v| try writer.writeAll(if (v) "true" else "false"),
\\ .integer => |v| try writer.print("{d}", .{v}),
\\ .float => |v| try writer.print("{d}", .{v}),
\\ .number_string => |v| try writer.writeAll(v),
\\ .string => |v| try writer.print("\"{f}\"", .{std.zig.fmtString(v)}),
\\ .array => |array| {
\\ try writer.writeAll(".{");
\\ for (array.items) |item| {
\\ try writeManifestValue(writer, item, depth + 1);
\\ try writer.writeByte(',');
\\ }
\\ try writer.writeByte('}');
\\ },
\\ .object => |object| {
\\ try writer.writeAll(".{");
\\ var iterator = object.iterator();
\\ while (iterator.next()) |entry| {
\\ if (depth == 0 and std.mem.eql(u8, entry.key_ptr.*, "$schema")) continue;
\\ try writer.print(".{f}=", .{std.zig.fmtId(entry.key_ptr.*)});
\\ try writeManifestValue(writer, entry.value_ptr.*, depth + 1);
\\ try writer.writeByte(',');
\\ }
\\ try writer.writeByte('}');
\\ },
\\ }
\\}
\\
\\fn hasManifestPermission(permissions: []const []const u8, name: []const u8) bool {
\\ for (permissions) |permission| {
\\ if (std.mem.eql(u8, permission, name)) return true;
@@ -2199,7 +2243,7 @@ fn buildZon(allocator: std.mem.Allocator, names: TemplateNames) ![]const u8 {
\\ .version = "0.1.0",
\\ .minimum_zig_version = "0.16.0",
\\ .dependencies = .{},
\\ .paths = .{ "build.zig", "build.zig.zon", "src", "assets", "frontend", "app.zon", "README.md" },
\\ .paths = .{ "build.zig", "build.zig.zon", "src", "assets", "frontend", "app.json", "README.md" },
\\}
\\
);
@@ -3203,46 +3247,47 @@ fn runnerZig() []const u8 {
;
}
fn appZon(allocator: std.mem.Allocator, names: TemplateNames, frontend: Frontend) ![]const u8 {
fn appJson(allocator: std.mem.Allocator, names: TemplateNames, frontend: Frontend) ![]const u8 {
var out: std.ArrayList(u8) = .empty;
errdefer out.deinit(allocator);
try out.appendSlice(allocator,
\\.{
\\ .id =
\\{
\\ "$schema": "https://schema.native-sdk.dev/app/v1.json",
\\ "id":
);
try appendZigString(&out, allocator, names.app_id);
try appendJsonString(&out, allocator, names.app_id);
try out.appendSlice(allocator,
\\,
\\ .name =
\\ "name":
);
try appendZigString(&out, allocator, names.package_name);
try appendJsonString(&out, allocator, names.package_name);
try out.appendSlice(allocator,
\\,
\\ .display_name =
\\ "display_name":
);
try appendZigString(&out, allocator, names.display_name);
try appendJsonString(&out, allocator, names.display_name);
try out.appendSlice(allocator,
\\,
\\ .version = "0.1.0",
\\ .icons = .{ "assets/icon.png" },
\\ .platforms = .{ "macos", "linux" },
\\ .permissions = .{},
\\ .capabilities = .{ "webview" },
\\ .frontend = .{
\\ .dist =
\\ "version": "0.1.0",
\\ "icons": ["assets/icon.png"],
\\ "platforms": ["macos", "linux"],
\\ "permissions": [],
\\ "capabilities": ["webview"],
\\ "frontend": {
\\ "dist":
);
try appendZigString(&out, allocator, frontend.distDir());
try appendJsonString(&out, allocator, frontend.distDir());
try out.appendSlice(allocator,
\\,
\\ .entry = "index.html",
\\ .spa_fallback = true,
\\ .dev = .{
\\ .url =
\\ "entry": "index.html",
\\ "spa_fallback": true,
\\ "dev": {
\\ "url":
);
try appendZigString(&out, allocator, frontend.devUrl());
try appendJsonString(&out, allocator, frontend.devUrl());
try out.appendSlice(allocator,
\\,
\\ .command = .{ "npm", "--prefix", "frontend", "run", "dev"
\\ "command": ["npm", "--prefix", "frontend", "run", "dev"
);
if (frontend != .next) {
try out.appendSlice(allocator,
@@ -3250,33 +3295,33 @@ fn appZon(allocator: std.mem.Allocator, names: TemplateNames, frontend: Frontend
);
}
try out.appendSlice(allocator,
\\ },
\\ .ready_path = "/",
\\ .timeout_ms = 30000,
\\ },
\\],
\\ "ready_path": "/",
\\ "timeout_ms": 30000
\\ }
\\ },
\\ .security = .{
\\ .navigation = .{
\\ .allowed_origins = .{ "zero://app", "zero://inline",
\\ "security": {
\\ "navigation": {
\\ "allowed_origins": ["zero://app", "zero://inline",
);
try out.appendSlice(allocator, " ");
const dev_origin = try std.fmt.allocPrint(allocator, "http://127.0.0.1:{s}", .{frontend.devPort()});
defer allocator.free(dev_origin);
try appendZigString(&out, allocator, dev_origin);
try appendJsonString(&out, allocator, dev_origin);
try out.appendSlice(allocator,
\\ },
\\ .external_links = .{ .action = "deny" },
\\ },
\\],
\\ "external_links": { "action": "deny" }
\\ }
\\ },
\\ .web_engine = "system",
\\ .cef = .{ .dir = "third_party/cef/macos", .auto_install = false },
\\ .windows = .{
\\ .{ .label = "main", .title =
\\ "web_engine": "system",
\\ "cef": { "dir": "third_party/cef/macos", "auto_install": false },
\\ "windows": [
\\ { "label": "main", "title":
);
try appendZigString(&out, allocator, names.display_name);
try appendJsonString(&out, allocator, names.display_name);
try out.appendSlice(allocator,
\\, .width = 720, .height = 480, .restore_state = true },
\\ },
\\, "width": 720, "height": 480, "restore_state": true }
\\ ]
\\}
\\
);
@@ -4004,10 +4049,10 @@ fn readme(allocator: std.mem.Allocator, names: TemplateNames, framework_path: []
\\zig build run
\\zig build test
\\zig build package
\\native doctor --manifest app.zon
\\native doctor --manifest app.json
\\```
\\
\\`zig build dev` starts the frontend dev server from `app.zon`, waits for it, and launches the native shell with `NATIVE_SDK_FRONTEND_URL`.
\\`zig build dev` starts the frontend dev server from `app.json`, waits for it, and launches the native shell with `NATIVE_SDK_FRONTEND_URL`.
\\
\\Frontend:
\\
@@ -4089,7 +4134,7 @@ test "writeDefaultApp emits Vite project files" {
const destination = ".zig-cache/test-vite-init-template";
try writeDefaultApp(std.testing.allocator, std.testing.io, destination, .{ .app_name = "My App", .framework_path = ".", .frontend = .vite });
const app_zon_text = try readTestFile(std.testing.allocator, std.testing.io, destination, "app.zon");
const app_zon_text = try readTestFile(std.testing.allocator, std.testing.io, destination, "app.json");
defer std.testing.allocator.free(app_zon_text);
const build_zig_text = try readTestFile(std.testing.allocator, std.testing.io, destination, "build.zig");
defer std.testing.allocator.free(build_zig_text);
@@ -4102,10 +4147,11 @@ test "writeDefaultApp emits Vite project files" {
const main_js_text = try readTestFile(std.testing.allocator, std.testing.io, destination, "frontend/src/main.js");
defer std.testing.allocator.free(main_js_text);
try std.testing.expect(std.mem.indexOf(u8, app_zon_text, ".frontend") != null);
try std.testing.expect(std.mem.indexOf(u8, app_zon_text, "\"frontend\"") != null);
try std.testing.expect(std.mem.indexOf(u8, app_zon_text, "frontend/dist") != null);
try std.testing.expect(std.mem.indexOf(u8, app_zon_text, "npm") != null);
try std.testing.expect(std.mem.indexOf(u8, app_zon_text, ".windows") != null);
try std.testing.expect(std.mem.indexOf(u8, app_zon_text, "\"windows\"") != null);
try std.testing.expect(std.mem.indexOf(u8, app_zon_text, "\"$schema\": \"https://schema.native-sdk.dev/app/v1.json\"") != null);
try std.testing.expect(std.mem.indexOf(u8, build_zig_text, "frontend-install") != null);
try std.testing.expect(std.mem.indexOf(u8, build_zig_text, "\"npm\", \"install\", \"--prefix\", \"frontend\"") != null);
try std.testing.expect(std.mem.indexOf(u8, build_zig_text, "frontend-build") != null);
@@ -4148,7 +4194,10 @@ test "writeDefaultApp emits Vite project files" {
// the packaged artifact structurally agrees with the compiled exe.
try std.testing.expect(std.mem.indexOf(u8, build_zig_text, "package.addArgs(&.{ \"--web-layer\", if (web_layer) \"include\" else \"exclude\" })") != null);
try std.testing.expect(std.mem.indexOf(u8, build_zig_text, "options.addOption(bool, \"web_layer\", web_layer)") != null);
try std.testing.expect(std.mem.indexOf(u8, build_zig_text, "std.zon.parse.fromSliceAlloc(InferenceManifest") != null);
try std.testing.expect(std.mem.indexOf(u8, build_zig_text, "std.json.parseFromSliceLeaky(InferenceManifest") != null);
try std.testing.expect(std.mem.indexOf(u8, build_zig_text, "fn appManifestModule") != null);
try std.testing.expect(std.mem.indexOf(u8, build_zig_text, ".{ .parse_numbers = false }") != null);
try std.testing.expect(std.mem.indexOf(u8, build_zig_text, ".null => return error.NullNotAllowed") != null);
try std.testing.expect(std.mem.indexOf(u8, build_zig_text, "the web layer is excluded ({s}) but the app declares web use ({s})") != null);
try std.testing.expect(std.mem.indexOf(u8, build_zig_text, ".system => if (web_layer) {") != null);
try std.testing.expect(std.mem.indexOf(u8, build_zig_text, "\"-DNATIVE_SDK_ALLOW_WEBVIEW2_STUB\"") != null);
@@ -4292,7 +4341,7 @@ test "writeDefaultApp emits frontend-specific Next paths" {
const destination = ".zig-cache/test-next-init-template";
try writeDefaultApp(std.testing.allocator, std.testing.io, destination, .{ .app_name = "Next App", .framework_path = ".", .frontend = .next });
const app_zon_text = try readTestFile(std.testing.allocator, std.testing.io, destination, "app.zon");
const app_zon_text = try readTestFile(std.testing.allocator, std.testing.io, destination, "app.json");
defer std.testing.allocator.free(app_zon_text);
const build_zig_text = try readTestFile(std.testing.allocator, std.testing.io, destination, "build.zig");
defer std.testing.allocator.free(build_zig_text);
@@ -4317,7 +4366,7 @@ test "writeDefaultApp emits the TS-core scaffold by default: three files of trut
defer std.testing.allocator.free(core_text);
const markup_text = try readTestFile(std.testing.allocator, std.testing.io, destination, "src/app.native");
defer std.testing.allocator.free(markup_text);
const ts_app_zon = try readTestFile(std.testing.allocator, std.testing.io, destination, "app.zon");
const ts_app_zon = try readTestFile(std.testing.allocator, std.testing.io, destination, "app.json");
defer std.testing.allocator.free(ts_app_zon);
const ts_readme = try readTestFile(std.testing.allocator, std.testing.io, destination, "README.md");
defer std.testing.allocator.free(ts_readme);
@@ -4410,7 +4459,7 @@ test "writeDefaultApp --template zig-core emits the slim Zig scaffold at ts-core
const destination = ".zig-cache/test-native-slim-template";
try writeDefaultApp(std.testing.allocator, std.testing.io, destination, .{ .app_name = "My App", .framework_path = ".", .frontend = .native, .core = .zig });
const app_zon_text = try readTestFile(std.testing.allocator, std.testing.io, destination, "app.zon");
const app_zon_text = try readTestFile(std.testing.allocator, std.testing.io, destination, "app.json");
defer std.testing.allocator.free(app_zon_text);
const main_zig_text = try readTestFile(std.testing.allocator, std.testing.io, destination, "src/main.zig");
defer std.testing.allocator.free(main_zig_text);
@@ -4467,7 +4516,7 @@ test "writeDefaultApp emits native project files" {
const destination = ".zig-cache/test-native-init-template";
try writeDefaultApp(std.testing.allocator, std.testing.io, destination, .{ .app_name = "My App", .framework_path = ".", .frontend = .native, .shape = .full, .core = .zig });
const app_zon_text = try readTestFile(std.testing.allocator, std.testing.io, destination, "app.zon");
const app_zon_text = try readTestFile(std.testing.allocator, std.testing.io, destination, "app.json");
defer std.testing.allocator.free(app_zon_text);
const build_zig_text = try readTestFile(std.testing.allocator, std.testing.io, destination, "build.zig");
defer std.testing.allocator.free(build_zig_text);
@@ -4493,8 +4542,8 @@ test "writeDefaultApp emits native project files" {
try std.testing.expect(std.mem.indexOf(u8, app_zon_text, "gpu_surface") != null);
try std.testing.expect(std.mem.indexOf(u8, app_zon_text, "\"native_views\", \"gpu_surfaces\"") != null);
try std.testing.expect(std.mem.indexOf(u8, app_zon_text, "dev.native_sdk.my-app") != null);
try std.testing.expect(std.mem.indexOf(u8, app_zon_text, ".frontend") == null);
try std.testing.expect(std.mem.indexOf(u8, build_zig_text, "native_sdk.addApp(b, b.dependency(\"native_sdk\", .{}), .{ .name = \"my-app\" })") != null);
try std.testing.expect(std.mem.indexOf(u8, app_zon_text, "\"frontend\"") == null);
try std.testing.expect(std.mem.indexOf(u8, build_zig_text, "native_sdk.addApp(b, b.dependency(\"native_sdk\", .{}), .{ .name = \"my-app\", .manifest = \"app.json\" })") != null);
try std.testing.expect(std.mem.indexOf(u8, build_zon_text, ".native_sdk = .{ .path = ") != null);
try std.testing.expect(std.mem.indexOf(u8, build_zon_text, ".name = .my_app") != null);
try std.testing.expect(std.mem.indexOf(u8, main_zig_text, "native_sdk.UiApp(Model, Msg)") != null);
+7 -6
View File
@@ -1,7 +1,7 @@
//! The canonical app verbs: `native dev|build|test` work in any app
//! directory. If the app owns a build.zig (ejected, and every example),
//! the verbs drive it through plain `zig build` — zero behavior change.
//! Otherwise (app.zon + src/ only) the CLI synthesizes the build graph
//! Otherwise (app.json/app.zon + src/ only) the CLI synthesizes the build graph
//! into `<app>/.native/build/` (see buildgraph.zig) and drives that.
//!
//! Callers are expected to have chdir'd into the app directory: every
@@ -43,15 +43,15 @@ pub const Options = struct {
};
pub fn run(allocator: std.mem.Allocator, io: std.Io, verb: Verb, options: Options) !void {
if (!buildgraph.fileExists(io, "app.zon")) {
const manifest_path = manifest_tool.defaultPath(io) orelse {
std.debug.print(
\\no app.zon here — `native {s}` runs inside an app directory
\\no app.json or app.zon here — `native {s}` runs inside an app directory
\\(or pass one: `native {s} path/to/app`). Start one with `native init`.
\\
, .{ @tagName(verb), @tagName(verb) });
return error.MissingManifest;
}
const metadata = try manifest_tool.readMetadata(allocator, io, "app.zon");
};
const metadata = try manifest_tool.readMetadata(allocator, io, manifest_path);
const rebuild_snapshot = if (options.explain_rebuild)
try explainRebuild(allocator, io)
else
@@ -112,6 +112,7 @@ pub fn run(allocator: std.mem.Allocator, io: std.Io, verb: Verb, options: Option
build_file = try buildgraph.ensureGeneratedBuild(allocator, io, ".", .{
.app_name = metadata.name,
.framework_root = framework_root,
.manifest_name = manifest_path,
});
// Keep artifacts where users expect them: the generated build root
// is .native/build/, so without a prefix the binary would hide in
@@ -335,7 +336,7 @@ fn explainRebuild(allocator: std.mem.Allocator, io: std.Io) ![]u8 {
if (old_hash != null and std.mem.eql(u8, old_hash.?, &input.hash)) continue;
changed += 1;
std.debug.print("native rebuild: {s} {s} -> {s}\n", .{ input.path, old_hash orelse "<new>", &input.hash });
if (std.mem.eql(u8, input.path, "app.zon")) {
if (std.mem.eql(u8, input.path, "app.json") or std.mem.eql(u8, input.path, "app.zon")) {
std.debug.print(" invalidates manifest-derived configuration -> affected contracts, app-code/platform link, and final artifact\n", .{});
} else if (std.mem.eql(u8, input.path, "build.zig") or std.mem.eql(u8, input.path, "build.zig.zon")) {
std.debug.print(" invalidates the owned build graph -> graph-selected compilation and final artifact\n", .{});
+17 -1
View File
@@ -1,4 +1,5 @@
const std = @import("std");
const json_to_zon = @import("json_to_zon.zig");
const raw_manifest = @import("raw_manifest.zig");
pub const default_engine: Engine = .system;
@@ -72,7 +73,10 @@ pub fn resolve(manifest: ManifestConfig, overrides: Overrides) Error!Resolved {
pub fn readManifestConfig(allocator: std.mem.Allocator, io: std.Io, path: []const u8) !ManifestConfig {
const source = try std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(1024 * 1024));
defer allocator.free(source);
return parseManifestConfig(allocator, source);
return if (json_to_zon.isJsonPath(path))
parseJsonManifestConfig(allocator, source)
else
parseManifestConfig(allocator, source);
}
pub fn parseManifestConfig(allocator: std.mem.Allocator, source: []const u8) !ManifestConfig {
@@ -82,6 +86,18 @@ pub fn parseManifestConfig(allocator: std.mem.Allocator, source: []const u8) !Ma
const source_z = try scratch.dupeZ(u8, source);
@setEvalBranchQuota(4000);
const raw = try std.zon.parse.fromSliceAlloc(raw_manifest.RawManifest, scratch, source_z, null, .{});
return duplicateManifestConfig(allocator, raw);
}
pub fn parseJsonManifestConfig(allocator: std.mem.Allocator, source: []const u8) !ManifestConfig {
try json_to_zon.validateSource(allocator, source);
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const raw = try std.json.parseFromSliceLeaky(raw_manifest.RawManifest, arena.allocator(), source, .{ .ignore_unknown_fields = false });
return duplicateManifestConfig(allocator, raw);
}
fn duplicateManifestConfig(allocator: std.mem.Allocator, raw: raw_manifest.RawManifest) !ManifestConfig {
return .{
.web_engine = try allocator.dupe(u8, raw.web_engine),
.cef = .{
+34 -33
View File
@@ -166,11 +166,11 @@ pub fn main(init: std.process.Init) !void {
} else if (std.mem.eql(u8, command, "markup")) {
try markup_cli.run(allocator, init.io, args[2..]);
} else if (std.mem.eql(u8, command, "validate")) {
checkVerbFlags("validate", args[2..], .{ .usage = "validate [app.zon]" });
const path = if (args.len >= 3) args[2] else "app.zon";
checkVerbFlags("validate", args[2..], .{ .usage = "validate [app.json|app.zon]" });
const path = if (args.len >= 3) args[2] else tooling.manifest.defaultPath(init.io) orelse "app.json";
const result = tooling.manifest.validateFile(allocator, init.io, path) catch |err| switch (err) {
error.FileNotFound => {
std.debug.print("error: {s} not found - run this from your app's root (the folder containing app.zon), or pass a path: native validate <path/to/app.zon>\n", .{path});
std.debug.print("error: {s} not found - run this from your app's root (the folder containing app.json or app.zon), or pass a manifest path\n", .{path});
std.process.exit(1);
},
else => return err,
@@ -180,8 +180,8 @@ pub fn main(init: std.process.Init) !void {
// returned error would bury it under the CLI's own return trace.
if (!result.ok) std.process.exit(1);
} else if (std.mem.eql(u8, command, "bundle-assets")) {
checkVerbFlags("bundle-assets", args[2..], .{ .usage = "bundle-assets [app.zon] [assets] [output]" });
const manifest_path = if (args.len >= 3) args[2] else "app.zon";
checkVerbFlags("bundle-assets", args[2..], .{ .usage = "bundle-assets [app.json|app.zon] [assets] [output]" });
const manifest_path = if (args.len >= 3) args[2] else tooling.manifest.defaultPath(init.io) orelse "app.json";
const metadata = try tooling.manifest.readMetadata(allocator, init.io, manifest_path);
const assets_dir = if (args.len >= 4) args[3] else if (metadata.frontend) |frontend| frontend.dist else "assets";
const output_dir = if (args.len >= 5) args[4] else "zig-out/assets";
@@ -193,10 +193,10 @@ pub fn main(init: std.process.Init) !void {
.value_flags = &.{ "--manifest", "--target", "--output", "--binary", "--service-binary", "--assets", "--web-engine", "--web-layer", "--cef-dir", "--signing", "--identity", "--entitlements", "--team-id", "--optimize" },
.bool_flags = &.{ "--cef-auto-install", "--archive" },
});
const manifest_path = try flagValue(args, "--manifest") orelse "app.zon";
const manifest_path = try flagValue(args, "--manifest") orelse tooling.manifest.defaultPath(init.io) orelse "app.json";
const metadata = tooling.manifest.readMetadata(allocator, init.io, manifest_path) catch |err| switch (err) {
error.FileNotFound => {
std.debug.print("error: {s} not found - run this from your app's root (the folder containing app.zon), or pass --manifest <path/to/app.zon>\n", .{manifest_path});
std.debug.print("error: {s} not found - run this from your app's root (the folder containing app.json or app.zon), or pass --manifest <path>\n", .{manifest_path});
std.process.exit(1);
},
else => return err,
@@ -271,7 +271,7 @@ pub fn main(init: std.process.Init) !void {
tooling.package.printDiagnostic(stats);
} else if (std.mem.eql(u8, command, "dev")) {
checkVerbFlags("dev", args[2..], .{
.usage = "dev [dir] [--yes] [--target ios|android] [--device name] [--url url] [--command \"npm run dev\"] [--timeout-ms n] [-D... zig build flags]\n native dev [dir] --core [--script msgs.ndjson] [--watch]\n native dev [--manifest app.zon] --binary path [--url url] [--command \"npm run dev\"] [--timeout-ms n]",
.usage = "dev [dir] [--yes] [--target ios|android] [--device name] [--url url] [--command \"npm run dev\"] [--timeout-ms n] [-D... zig build flags]\n native dev [dir] --core [--script msgs.ndjson] [--watch]\n native dev [--manifest app.json] --binary path [--url url] [--command \"npm run dev\"] [--timeout-ms n]",
.value_flags = &.{ "--url", "--command", "--timeout-ms", "--binary", "--manifest", "--target", "--device", "--script" },
.bool_flags = &.{ "--yes", "--core", "--watch" },
.forwards_build_flags = true,
@@ -291,7 +291,7 @@ pub fn main(init: std.process.Init) !void {
if (tooling.ts_core.detect(init.io) == .ts) {
tooling.ts_core.selfHealEditorPackage(allocator, init.io, framework_root);
}
const dev_metadata = try tooling.manifest.readMetadata(allocator, init.io, "app.zon");
const dev_metadata = try tooling.manifest.readMetadata(allocator, init.io, tooling.manifest.defaultPath(init.io) orelse "app.json");
const dev_service_packages = try allocator.alloc(tooling.ts_core.ServicePackage, dev_metadata.service_packages.len);
defer allocator.free(dev_service_packages);
for (dev_metadata.service_packages, 0..) |package_entry, index| dev_service_packages[index] = .{
@@ -365,7 +365,7 @@ pub fn main(init: std.process.Init) !void {
// Legacy shape (`--binary` provided): the caller already built
// the shell — e.g. the expanded template's `zig build dev` step —
// so only run the frontend-server + shell flow. Unchanged.
const manifest_path = try flagValue(args, "--manifest") orelse "app.zon";
const manifest_path = try flagValue(args, "--manifest") orelse tooling.manifest.defaultPath(init.io) orelse "app.json";
const metadata = try tooling.manifest.readMetadata(allocator, init.io, manifest_path);
const command_override = if (try flagValue(args, "--command")) |value| try splitCommand(allocator, value) else null;
try tooling.dev.run(allocator, init.io, .{
@@ -397,7 +397,7 @@ pub fn main(init: std.process.Init) !void {
try packageShortcut(allocator, init.io, init.environ_map, args, .linux, "zig-out/package/linux");
} else if (std.mem.eql(u8, command, "package-ios")) {
checkPackageShortcutFlags(command, args[2..]);
const metadata = try tooling.manifest.readMetadata(allocator, init.io, try flagValue(args, "--manifest") orelse "app.zon");
const metadata = try tooling.manifest.readMetadata(allocator, init.io, try flagValue(args, "--manifest") orelse tooling.manifest.defaultPath(init.io) orelse "app.json");
const web_engine = try tooling.web_engine.resolve(.{ .web_engine = metadata.web_engine, .cef = metadata.cef }, .{});
const binary_path = try flagValue(args, "--binary") orelse try iosPackageLibrary(allocator, init.io, init.environ_map, metadata.name, "ReleaseFast");
const stats = try tooling.package.createPackage(allocator, init.io, .{
@@ -415,7 +415,7 @@ pub fn main(init: std.process.Init) !void {
tooling.package.printDiagnostic(stats);
} else if (std.mem.eql(u8, command, "package-android")) {
checkPackageShortcutFlags(command, args[2..]);
const metadata = try tooling.manifest.readMetadata(allocator, init.io, try flagValue(args, "--manifest") orelse "app.zon");
const metadata = try tooling.manifest.readMetadata(allocator, init.io, try flagValue(args, "--manifest") orelse tooling.manifest.defaultPath(init.io) orelse "app.json");
const web_engine = try tooling.web_engine.resolve(.{ .web_engine = metadata.web_engine, .cef = metadata.cef }, .{});
const binary_path = try flagValue(args, "--binary") orelse try androidPackageLibrary(allocator, init.io, init.environ_map, metadata.name, "ReleaseFast");
const stats = try tooling.package.createPackage(allocator, init.io, .{
@@ -458,17 +458,17 @@ fn usage() void {
\\ build [dir] [--yes] [--explain-rebuild] [-D... zig build flags]
\\ build ReleaseFast; explain changed inputs and dirty steps
\\ test [dir] [--yes] [-D... zig build flags] run the app's test suite
\\ check [dir] [--strict] validate the core (src/core.ts through the subset checker), src/*.native markup, and app.zon
\\ check [dir] [--strict] validate the core (src/core.ts through the subset checker), src/*.native markup, and app.json/app.zon
\\ db new-migration <name> | status | reset --yes manage the relational schema and development database
\\ vendor [dir] <package@exact-version> [...] check npm sources into src/services/vendor and pin their hashes in app.zon
\\ vendor [dir] <package@exact-version> [...] check npm sources into src/services/vendor and pin their hashes in the app manifest
\\ eject [dir] write an owned build.zig/build.zig.zon into the app
\\ eject component <name> [dir] write an owned copy of a library composite into src/components/
\\ cef install|path|doctor [--dir path] [--version version] [--source prepared|official] [--force]
\\ doctor [--strict] [--manifest app.zon] [--web-engine system|chromium] [--cef-dir path] [--cef-auto-install]
\\ validate [app.zon]
\\ bundle-assets [app.zon] [assets] [output]
\\ doctor [--strict] [--manifest app.json] [--web-engine system|chromium] [--cef-dir path] [--cef-auto-install]
\\ validate [app.json|app.zon]
\\ bundle-assets [app.json|app.zon] [assets] [output]
\\ package [--target macos|windows|linux|ios|android] [--output path] [--binary path] [--service-binary path] [--assets path] [--web-engine system|chromium] [--web-layer auto|include|exclude] [--cef-dir path] [--cef-auto-install] [--signing none|adhoc|identity] [--identity name] [--entitlements path] [--team-id id] [--archive]
\\ dev [--manifest app.zon] --binary path [--url http://127.0.0.1:5173/] [--command "npm run dev"] [--timeout-ms 30000]
\\ dev [--manifest app.json] --binary path [--url http://127.0.0.1:5173/] [--command "npm run dev"] [--timeout-ms 30000]
\\ package-windows [--output path] [--binary path] [--service-binary path]
\\ package-linux [--output path] [--binary path] [--service-binary path]
\\ package-ios [--output path] [--binary path]
@@ -502,7 +502,7 @@ const VerbSpec = struct {
fn checkPackageShortcutFlags(verb: []const u8, args: []const []const u8) void {
var usage_buffer: [192]u8 = undefined;
const usage_text = std.fmt.bufPrint(&usage_buffer, "{s} [--output path] [--binary path] [--service-binary path] [--manifest app.zon] [--assets path]", .{verb}) catch verb;
const usage_text = std.fmt.bufPrint(&usage_buffer, "{s} [--output path] [--binary path] [--service-binary path] [--manifest app.json] [--assets path]", .{verb}) catch verb;
checkVerbFlags(verb, args, .{
.usage = usage_text,
.value_flags = &.{ "--manifest", "--output", "--binary", "--service-binary", "--assets" },
@@ -628,7 +628,7 @@ fn enterAppDir(io: std.Io, dir: []const u8) !void {
};
}
/// `native check`: validate every markup file under src/ plus app.zon — the
/// `native check`: validate every markup file under src/ plus the app manifest.
/// no-build confidence pass (markup vocabulary + manifest schema). With a
/// fresh model-contract artifact in zig-out (refreshed by `native test`),
/// the markup pass also verifies
@@ -636,15 +636,15 @@ fn enterAppDir(io: std.Io, dir: []const u8) !void {
/// app's actual Model/Msg, and reports unused model state as warnings
/// (--strict promotes warnings to failures).
fn runCheck(allocator: std.mem.Allocator, io: std.Io, env_map: *std.process.Environ.Map, strict: bool) !void {
if (!tooling.buildgraph.fileExists(io, "app.zon")) {
std.debug.print("no app.zon here — `native check` runs inside an app directory (or pass one: `native check path/to/app`)\n", .{});
const manifest_path = tooling.manifest.defaultPath(io) orelse {
std.debug.print("no app.json or app.zon here — `native check` runs inside an app directory (or pass one: `native check path/to/app`)\n", .{});
return error.MissingManifest;
}
};
const manifest_result = try tooling.manifest.validateFile(allocator, io, "app.zon");
const manifest_result = try tooling.manifest.validateFile(allocator, io, manifest_path);
tooling.manifest.printDiagnostic(manifest_result);
if (!manifest_result.ok) return error.InvalidManifest;
const metadata = try tooling.manifest.readMetadata(allocator, io, "app.zon");
const metadata = try tooling.manifest.readMetadata(allocator, io, manifest_path);
// The core tier first: a TypeScript core runs the @native-sdk/core checker
// (subset rules + tsc), whose NS diagnostics surface verbatim - the
@@ -732,7 +732,7 @@ fn runCheck(allocator: std.mem.Allocator, io: std.Io, env_map: *std.process.Envi
const checked_markup = markup_files.items.len;
const contract_note: []const u8 = if (outcome.contract_checked) " against the model contract" else "";
const core_note: []const u8 = if (core_tree == .ts) " and src/core.ts (subset checker clean)" else "";
std.debug.print("checked {d} markup file{s}{s}, app.zon{s}\n", .{ checked_markup, if (checked_markup == 1) "" else "s", contract_note, core_note });
std.debug.print("checked {d} markup file{s}{s}, {s}{s}\n", .{ checked_markup, if (checked_markup == 1) "" else "s", contract_note, manifest_path, core_note });
if (strict and outcome.warnings > 0) {
std.debug.print("{d} warning{s} promoted to errors (--strict)\n", .{ outcome.warnings, if (outcome.warnings == 1) "" else "s" });
return error.MarkupCheckFailed;
@@ -754,11 +754,11 @@ fn collectMarkupFiles(allocator: std.mem.Allocator, io: std.Io, root_path: []con
/// `native eject`: transfer build ownership to the app exactly once.
fn runEject(allocator: std.mem.Allocator, io: std.Io, env_map: *std.process.Environ.Map) !void {
if (!tooling.buildgraph.fileExists(io, "app.zon")) {
std.debug.print("no app.zon here — `native eject` runs inside an app directory (or pass one: `native eject path/to/app`)\n", .{});
const manifest_path = tooling.manifest.defaultPath(io) orelse {
std.debug.print("no app.json or app.zon here — `native eject` runs inside an app directory (or pass one: `native eject path/to/app`)\n", .{});
return error.MissingManifest;
}
const metadata = try tooling.manifest.readMetadata(allocator, io, "app.zon");
};
const metadata = try tooling.manifest.readMetadata(allocator, io, manifest_path);
const framework_root = try tooling.buildgraph.resolveFrameworkRoot(allocator, io, env_map) orelse {
std.debug.print("cannot locate the Native SDK framework; set NATIVE_SDK_PATH to your framework checkout\n", .{});
return error.MissingFramework;
@@ -768,6 +768,7 @@ fn runEject(allocator: std.mem.Allocator, io: std.Io, env_map: *std.process.Envi
tooling.buildgraph.eject(allocator, io, ".", .{
.app_name = metadata.name,
.framework_root = framework_root,
.manifest_name = manifest_path,
}) catch |err| switch (err) {
error.AlreadyEjected => {
std.debug.print("build.zig or build.zig.zon already exists — eject writes the owned build exactly once and never overwrites it\n", .{});
@@ -789,8 +790,8 @@ fn runEject(allocator: std.mem.Allocator, io: std.Io, env_map: *std.process.Envi
/// and the did-you-mean live in tooling (`eject_components.zig`); this
/// wrapper owns the CLI's teaching messages.
fn runEjectComponent(io: std.Io, name: []const u8) !void {
if (!tooling.buildgraph.fileExists(io, "app.zon")) {
std.debug.print("no app.zon here — `native eject component` runs inside an app directory (or pass one: `native eject component {s} path/to/app`)\n", .{name});
if (tooling.manifest.defaultPath(io) == null) {
std.debug.print("no app.json or app.zon here — `native eject component` runs inside an app directory (or pass one: `native eject component {s} path/to/app`)\n", .{name});
return error.MissingManifest;
}
const component = tooling.eject_components.find(name) orelse {
@@ -995,7 +996,7 @@ fn splitCommand(allocator: std.mem.Allocator, value: []const u8) ![]const []cons
}
fn packageShortcut(allocator: std.mem.Allocator, io: std.Io, env_map: *std.process.Environ.Map, args: []const []const u8, target: tooling.package.PackageTarget, default_output: []const u8) !void {
const manifest_path = try flagValue(args, "--manifest") orelse "app.zon";
const manifest_path = try flagValue(args, "--manifest") orelse tooling.manifest.defaultPath(io) orelse "app.json";
const project_dir = std.fs.path.dirname(manifest_path) orelse ".";
const metadata = try tooling.manifest.readMetadata(allocator, io, manifest_path);
const web_engine = try tooling.web_engine.resolve(.{ .web_engine = metadata.web_engine, .cef = metadata.cef }, .{});
+2 -2
View File
@@ -138,7 +138,7 @@ pub fn checkFiles(allocator: std.mem.Allocator, io: std.Io, files: []const []con
/// app tree, so "nothing embeds this" is a false signal that once cost a
/// user their view.
fn printOrphanHint(arena: std.mem.Allocator, io: std.Io, file_path: []const u8, cache: *?[]const []const u8) void {
if (!fileExists(io, "app.zon")) return;
if (!fileExists(io, "app.json") and !fileExists(io, "app.zon")) return;
const ts_track = fileExists(io, "src/core.ts");
const basenames: []const []const u8 = if (ts_track) &.{} else cache.* orelse blk: {
const collected = collectEmbeddedBasenames(arena, io) catch return;
@@ -231,7 +231,7 @@ const ContractState = union(enum) {
/// app.zon and prove it fresh (the artifact carries a hash over the app's
/// Zig sources; any drift degrades to structural checking).
fn discoverContract(arena: std.mem.Allocator, io: std.Io) ContractState {
if (!fileExists(io, "app.zon")) return .no_app;
if (!fileExists(io, "app.json") and !fileExists(io, "app.zon")) return .no_app;
const source = readFile(arena, io, ui_markup.contract.default_artifact_path) catch return .missing;
const parsed = ui_markup.contract.parseArtifact(arena, source) catch return .unreadable;
if (parsed.format != ui_markup.contract.format_version) return .unreadable;