ReviewOS

also looking at this

craft-native/craft

iOS Phase 5: the data tier — in-process SQLite and notification cancels; ratchet 74 → 70

#87
Merged glennmichael123 wants to merge feat/ios-phase5-data into main
7 files +1858 -4
packages/ios/fixtures/zig-slice/build-and-run.shmodified+5-1
Changes to packages/ios/fixtures/zig-slice/build-and-run.sh
@@ -90,7 +90,7 @@ LAUNCH_PID=$!
9090# The round trip is fast, but a cold simulator is not. Poll rather than sleep a
9191# fixed amount, so a slow boot does not read as a failure.
9292for _ in $(seq 1 60); do
93 if grep -q 'i=7' "$LOG" 2>/dev/null && grep -q 'i=10' "$LOG" 2>/dev/null; then sleep 1; break; fi
93 if grep -q 'i=12' "$LOG" 2>/dev/null && grep -q 'i=10' "$LOG" 2>/dev/null; then sleep 1; break; fi
9494 sleep 1
9595done
9696kill "$LAUNCH_PID" 2>/dev/null || true
@@ -154,4 +154,8 @@ C10="$(count 10)"
154154[ "$C10" -ge 1 ] || { echo "FAIL: the Keychain round trip never completed byte-identical"; exit 1; }
155155echo "ok: secureSet/secureGet round-tripped a secret through the Keychain (i=10 seen ${C10}x)"
156156
157C12="$(count 12)"
158[ "$C12" -ge 1 ] || { echo "FAIL: the SQLite round trip never returned the bound value"; exit 1; }
159echo "ok: dbExecute/dbQuery round-tripped through in-process SQLite (i=12 seen ${C12}x)"
160
157161echo "PASS"
packages/ios/fixtures/zig-slice/page.mmodified+13-1
Changes to packages/ios/fixtures/zig-slice/page.m
@@ -30,7 +30,7 @@ const char craft_slice_page[] =
3030 "<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
3131 "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
3232 "</head><body><h1 id=\"s\">craft slice</h1><script>\n"
33 "var sentRoundTrip = false, sentHandOff = false, sentTierZero = false, sentSecure = false;\n"
33 "var sentRoundTrip = false, sentHandOff = false, sentTierZero = false, sentSecure = false, sentDb = false;\n"
3434 "var bridge = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.craft;\n"
3535 "\n"
3636 "// Read before anything else touches it: the user script is installed at\n"
@@ -77,6 +77,14 @@ const char craft_slice_page[] =
7777 " sentSecure = true;\n"
7878 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:10});\n"
7979 " }\n"
80 " if (action === 'dbExecute') {\n"
81 " bridge.postMessage({t:'mobile', a:'dbQuery', d: JSON.stringify({sql:'SELECT v FROM slice WHERE k=?', params:['k1']}), i:41});\n"
82 " }\n"
83 " if (!sentDb && action === 'dbQuery' && Array.isArray(payload)\n"
84 " && payload.length === 1 && payload[0].v === 'v\\'1') {\n"
85 " sentDb = true;\n"
86 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:12});\n"
87 " }\n"
8088 " if (!sentTierZero && (payload === 'active' || payload === 'inactive')) {\n"
8189 " sentTierZero = true;\n"
8290 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:8});\n"
@@ -96,6 +104,10 @@ const char craft_slice_page[] =
96104 " // reply, then confirm byte-identity. The secret carries a quote and a\n"
97105 " // backslash so the escaping is proven in both directions.\n"
98106 " bridge.postMessage({t:'mobile', a:'secureSet', d: JSON.stringify({key:'slice-key', value:SECRET}), i:30});\n"
107 " // SQLite in-process: create, insert (with a quote in the value, bound\n"
108 " // not formatted), then read it back through dbQuery.\n"
109 " bridge.postMessage({t:'mobile', a:'dbExecute', d: JSON.stringify({sql:'CREATE TABLE IF NOT EXISTS slice (k TEXT PRIMARY KEY, v TEXT)'}), i:40});\n"
110 " bridge.postMessage({t:'mobile', a:'dbExecute', d: JSON.stringify({sql:'INSERT OR REPLACE INTO slice (k,v) VALUES (?,?)', params:['k1',\"v'1\"]}), i:42});\n"
99111 " // A Tier-0 action migrated in this phase, served by Zig directly.\n"
100112 " bridge.postMessage({t:'mobile', a:'getAppState', i:88});\n"
101113 " // And one the shim answers by rejecting, to prove the error route.\n"
packages/zig/build.zigmodified+82-0
Changes to packages/zig/build.zig
@@ -342,6 +342,11 @@ pub fn build(b: *std.Build) void {
342342 // The storage module is Keychain: SecItemAdd and the kSec* constants
343343 // live in Security.framework.
344344 ios_surface_tests.root_module.linkFramework("Security", .{});
345 ios_surface_tests.root_module.addCSourceFile(.{
346 .file = b.path("vendor/sqlite/sqlite3.c"),
347 .flags = &.{ "-DSQLITE_THREADSAFE=1", "-DSQLITE_ENABLE_FTS5", "-DSQLITE_ENABLE_JSON1" },
348 });
349 ios_surface_tests.root_module.addIncludePath(b.path("vendor/sqlite"));
345350 }
346351
347352 // Tests are collected from the ROOT module only — a named-module import is
@@ -369,6 +374,11 @@ pub fn build(b: *std.Build) void {
369374 ios_module_tests.root_module.linkFramework("CoreGraphics", .{});
370375 ios_module_tests.root_module.linkFramework("CoreMIDI", .{});
371376 ios_module_tests.root_module.linkFramework("Security", .{});
377 ios_module_tests.root_module.addCSourceFile(.{
378 .file = b.path("vendor/sqlite/sqlite3.c"),
379 .flags = &.{ "-DSQLITE_THREADSAFE=1", "-DSQLITE_ENABLE_FTS5", "-DSQLITE_ENABLE_JSON1" },
380 });
381 ios_module_tests.root_module.addIncludePath(b.path("vendor/sqlite"));
372382 // Rooting at ios.zig compiles its exports, whose reply path reaches the
373383 // desktop bridge — including the Carbon hotkey machinery. Same linkage
374384 // macos_hotkey_tests already carries.
@@ -424,6 +434,12 @@ pub fn build(b: *std.Build) void {
424434 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_permissions.zig", .{
425435 .root_source_file = b.path("src/bridge_mobile_permissions.zig"),
426436 });
437 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_db.zig", .{
438 .root_source_file = b.path("src/bridge_mobile_db.zig"),
439 });
440 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_notifcancel.zig", .{
441 .root_source_file = b.path("src/bridge_mobile_notifcancel.zig"),
442 });
427443
428444 const menubar_tests = b.addTest(.{
429445 .root_module = b.createModule(.{
@@ -1759,6 +1775,21 @@ pub fn build(b: *std.Build) void {
17591775 // others) must travel inside the archive — no later link step provides
17601776 // them, and the failure is undefined symbols in whoever consumes the lib.
17611777 ios_device_lib.bundle_compiler_rt = true;
1778 // The db actions run on the vendored SQLite, same flags as every other
1779 // target — an iOS archive without it would push the whole amalgamation
1780 // onto the consuming app's build instead.
1781 if (iosSdkPath(b, "iphoneos")) |sdk_path| {
1782 ios_device_lib.root_module.addCSourceFile(.{
1783 .file = b.path("vendor/sqlite/sqlite3.c"),
1784 .flags = &.{ "-DSQLITE_THREADSAFE=1", "-DSQLITE_ENABLE_FTS5", "-DSQLITE_ENABLE_JSON1" },
1785 });
1786 ios_device_lib.root_module.addIncludePath(b.path("vendor/sqlite"));
1787 ios_device_lib.root_module.addSystemIncludePath(.{ .cwd_relative = b.fmt("{s}/usr/include", .{sdk_path}) });
1788 } else {
1789 // No xcrun: the archive ships without sqlite and the db actions
1790 // resolve as undefined symbols at app link — loud, not silent.
1791 }
1792 ios_device_lib.root_module.link_libc = true;
17621793 // Frameworks are linked at the app level in Xcode, not in the static library
17631794 const ios_device_install = b.addInstallArtifact(ios_device_lib, .{});
17641795 build_ios.dependOn(&ios_device_install.step);
@@ -1788,6 +1819,21 @@ pub fn build(b: *std.Build) void {
17881819 // others) must travel inside the archive — no later link step provides
17891820 // them, and the failure is undefined symbols in whoever consumes the lib.
17901821 ios_sim_arm64_lib.bundle_compiler_rt = true;
1822 // The db actions run on the vendored SQLite, same flags as every other
1823 // target — an iOS archive without it would push the whole amalgamation
1824 // onto the consuming app's build instead.
1825 if (iosSdkPath(b, "iphonesimulator")) |sdk_path| {
1826 ios_sim_arm64_lib.root_module.addCSourceFile(.{
1827 .file = b.path("vendor/sqlite/sqlite3.c"),
1828 .flags = &.{ "-DSQLITE_THREADSAFE=1", "-DSQLITE_ENABLE_FTS5", "-DSQLITE_ENABLE_JSON1" },
1829 });
1830 ios_sim_arm64_lib.root_module.addIncludePath(b.path("vendor/sqlite"));
1831 ios_sim_arm64_lib.root_module.addSystemIncludePath(.{ .cwd_relative = b.fmt("{s}/usr/include", .{sdk_path}) });
1832 } else {
1833 // No xcrun: the archive ships without sqlite and the db actions
1834 // resolve as undefined symbols at app link — loud, not silent.
1835 }
1836 ios_sim_arm64_lib.root_module.link_libc = true;
17911837 const ios_sim_arm64_install = b.addInstallArtifact(ios_sim_arm64_lib, .{});
17921838 build_ios_simulator.dependOn(&ios_sim_arm64_install.step);
17931839 build_ios_all.dependOn(&ios_sim_arm64_install.step);
@@ -1816,6 +1862,21 @@ pub fn build(b: *std.Build) void {
18161862 // others) must travel inside the archive — no later link step provides
18171863 // them, and the failure is undefined symbols in whoever consumes the lib.
18181864 ios_sim_x64_lib.bundle_compiler_rt = true;
1865 // The db actions run on the vendored SQLite, same flags as every other
1866 // target — an iOS archive without it would push the whole amalgamation
1867 // onto the consuming app's build instead.
1868 if (iosSdkPath(b, "iphonesimulator")) |sdk_path| {
1869 ios_sim_x64_lib.root_module.addCSourceFile(.{
1870 .file = b.path("vendor/sqlite/sqlite3.c"),
1871 .flags = &.{ "-DSQLITE_THREADSAFE=1", "-DSQLITE_ENABLE_FTS5", "-DSQLITE_ENABLE_JSON1" },
1872 });
1873 ios_sim_x64_lib.root_module.addIncludePath(b.path("vendor/sqlite"));
1874 ios_sim_x64_lib.root_module.addSystemIncludePath(.{ .cwd_relative = b.fmt("{s}/usr/include", .{sdk_path}) });
1875 } else {
1876 // No xcrun: the archive ships without sqlite and the db actions
1877 // resolve as undefined symbols at app link — loud, not silent.
1878 }
1879 ios_sim_x64_lib.root_module.link_libc = true;
18191880 const ios_sim_x64_install = b.addInstallArtifact(ios_sim_x64_lib, .{});
18201881 build_ios_simulator.dependOn(&ios_sim_x64_install.step);
18211882 build_ios_all.dependOn(&ios_sim_x64_install.step);
@@ -2048,6 +2109,27 @@ pub fn build(b: *std.Build) void {
20482109/// Link platform-specific system libraries for a build module.
20492110/// Centralizes the per-OS library linking that is shared across exe, craft_exe,
20502111/// lib_unit_tests, and cross-compilation targets.
2112/// The iOS SDK sysroot, resolved through xcrun at configure time.
2113///
2114/// Needed because the iOS archives compile the vendored sqlite3.c, and C
2115/// compilation for an Apple target wants the SDK's libc headers — Zig's
2116/// bundled headers cover Zig code, not a C translation unit's <stdio.h>.
2117/// Absent xcrun (a non-mac host), returns null and the C source is skipped;
2118/// the cross-compile of *Zig* code still works, and CI's mac runners have
2119/// xcrun.
2120fn iosSdkPath(b: *std.Build, sdk_name: []const u8) ?[]const u8 {
2121 var exit_code: u8 = 0;
2122 const out = b.runAllowFail(
2123 &.{ "xcrun", "--sdk", sdk_name, "--show-sdk-path" },
2124 &exit_code,
2125 .ignore,
2126 ) catch return null;
2127 if (exit_code != 0) return null;
2128 const trimmed = std.mem.trim(u8, out, " \n\r\t");
2129 if (trimmed.len == 0) return null;
2130 return trimmed;
2131}
2132
20512133fn linkPlatformLibraries(b: *std.Build, module: *std.Build.Module, target_os: std.Target.Os.Tag, sdk_path: ?[]const u8) void {
20522134 switch (target_os) {
20532135 .macos => {
packages/zig/src/bridge_mobile_db.zigadded+1207-0
Changes to packages/zig/src/bridge_mobile_db.zig
@@ -0,0 +1,1207 @@
1//! The local-database actions of the `mobile` namespace: `dbExecute`,
2//! `dbQuery`.
3//!
4//! The database is `<sandbox>/Documents/craft.db`, the exact file the Swift
5//! coordinator's `setupDatabase` opens — same directory lookup
6//! (`NSDocumentDirectory` in `NSUserDomainMask`), same filename, same plain
7//! `sqlite3_open`. It is deliberately **not** opened through `database.zig`'s
8//! `Database`, whose init applies pragmas Swift never sets (WAL,
9//! `foreign_keys=ON`, cache size, busy timeout); wrapping it would silently
10//! flip an existing app's `craft.db` into WAL mode mid-migration. Raw externs,
11//! no pragmas, opened lazily on the first db action and kept for the process
12//! lifetime, as Swift keeps its coordinator-lifetime handle. The externs are
13//! this file's own copies — `database.zig`'s are file-private by design, and
14//! `extern` declarations are free to repeat.
15//!
16//! The payload fields are `sql` (string, required) and `params` (array,
17//! optional) — the names the Swift dispatcher reads (`body["sql"]`,
18//! `body["params"]`) and the shim hand-off proves ride inside `d`. The Android
19//! surface posts the same pair (`CraftBridge.kt.template`'s `db.execute`
20//! stringifies `params || []`), so the field names are pinned on three sides.
21//!
22//! ## What is carried across exactly, because it is the observable contract
23//!
24//! - **`dbExecute` resolves `{"rowsAffected":<int>,"lastInsertId":<int>}`** —
25//! `sqlite3_changes` and `sqlite3_last_insert_rowid`, both fields always
26//! present.
27//! - **`dbQuery` resolves a bare JSON array of row objects keyed by column
28//! name** — Swift's `.fragmentsAllowed` makes the bare `[]`/`[{...}]` legal,
29//! and `test-bridges.html` stringifies the value straight into the page, so
30//! keys and value types are all visible contract. An empty result set is
31//! `[]`; a non-SELECT statement sent through `dbQuery` executes and
32//! resolves `[]`.
33//! - **A `BLOB` column's key is entirely absent from its row object** — not
34//! `null`. Swift's column switch has `default: break`, so a row that is all
35//! BLOBs serializes as `{}`. Anything else changes the shape.
36//! - **`NULL` columns are JSON `null`**, Swift's `NSNull`.
37//! - **A SQLite failure rejects with `sqlite3_errmsg`'s text as the
38//! message** — "no such table: x" is what the page displays (`'Error: ' +
39//! e.message`), so the raw message text is the contract and travels intact
40//! through `ErrorContext`. `dbExecute` of a SELECT that returns rows steps
41//! to `SQLITE_ROW`, not `SQLITE_DONE`, and therefore rejects — with
42//! whatever `sqlite3_errmsg` says at that point (typically "not an error").
43//! Bug-compatible on purpose: the two implementations must be
44//! interchangeable while the shim exists.
45//! - **A row value SQLite holds but JSON cannot carry (an infinite REAL,
46//! e.g. a stored `9e999`) rejects with "Native result could not be
47//! serialized"** — the exact message Swift's `JSONSerialization` catch
48//! produces when the Double reaches it.
49//!
50//! ## What is deliberately not carried across
51//!
52//! **Swift's silent hang.** The dispatcher arms are gate + `if let sql` with
53//! no `else`: a missing or non-string `sql`, or `enableLocalDatabase` at its
54//! default `false`, replies nothing at all. Malformed input errors here
55//! instead, and the config gate is left plumbable the way clipboard's is: a
56//! host calls `craft_ios_set_local_database_enabled(false)` and the actions
57//! reject `PermissionDenied` — never silence. It defaults to enabled, because
58//! default-off would answer every app with PERMISSION_DENIED until a host that
59//! does not exist yet is written (the clipboard module records the same
60//! trade).
61//!
62//! **The `Int32` funnels.** Swift binds integer params through `Int32(int)`,
63//! which *traps* — crashes the app — for |v| >= 2^31, and reads INTEGER
64//! columns through `sqlite3_column_int`, which silently wraps large values.
65//! Both go through the 64-bit calls here; a crash is not a contract, and a
66//! wrapped rowid is a corrupted answer.
67//!
68//! **Text truncation at NUL.** Swift's `sqlite3_bind_text(_, _, str, -1, nil)`
69//! measures to the first NUL *and* passes SQLITE_STATIC on a temporary — a
70//! latent use-after-free. Strings bind here with their byte length and
71//! SQLITE_TRANSIENT, exactly as the desktop `bindText` does, so an embedded
72//! NUL in a *param* survives the round trip. Reads use `column_bytes` rather
73//! than `String(cString:)` for the same reason. The `sql` string itself is
74//! different: SQLite stops parsing at a NUL, so a NUL there would silently
75//! execute a prefix of what the page sent — refused as `InvalidParameter`
76//! (rule: truncation that changes meaning is refused, not performed).
77//!
78//! **Silently dropped payload.** Swift binds NULL for a param it cannot
79//! downcast (an object or array), ignores a `params` value that is not an
80//! array, never checks a bind's return code (excess params vanish into
81//! `SQLITE_RANGE`), and `prepare_v2` executes only the first statement of a
82//! multi-statement string, discarding the rest. Every one of those is a page
83//! payload quietly not doing what the page said. Here: non-bindable params and
84//! a non-array `params` are `InvalidParameter`; bind failures reject with the
85//! SQLite message; SQL that continues past the first statement (more than
86//! whitespace/semicolons — a trailing `--` comment is refused too,
87//! conservatively) is `InvalidParameter`.
88//!
89//! **Partial rows on a mid-iteration step failure.** Swift's query loop is
90//! `while step == SQLITE_ROW` with the terminating code unexamined: an
91//! `SQLITE_BUSY` or corruption error just ends the loop and *resolves* the
92//! rows collected so far, indistinguishable from a complete result. That is
93//! fabricated success; a step that ends with anything but `SQLITE_DONE`
94//! rejects here, with the SQLite message.
95//!
96//! **The error `code` label.** Swift rejected with its catch-all
97//! `"CRAFT_ERROR"`; a Zig-served rejection carries `NATIVE_CALL_FAILED`
98//! (`ErrorContext` owns the code vocabulary). The message text — the part the
99//! page displays and the part that names the failing table — is preserved
100//! verbatim.
101//!
102//! **A failed open is closed.** Swift's `setupDatabase` ignores that
103//! `sqlite3_open` hands back a handle even on failure, and leaves it set — so
104//! later actions run against a half-open handle. Here a failed open is closed
105//! and the action rejects "Database not initialized", the message Swift's
106//! `guard let db` produces for exactly this condition. The open is retried on
107//! the next action rather than latched broken.
108//!
109//! ## Host-test safety, named because it is invisible
110//!
111//! On a macOS host, the lazy open would land in the *developer's*
112//! `~/Documents/craft.db`. Every test below therefore either fails validation
113//! before `ensureDatabase` can run, or drives `runExecute`/`runQuery` directly
114//! against its own `:memory:` handle. No test may hand `handleMessage` a
115//! payload with a valid `sql`.
116
117const std = ;
118const builtin = ;
119const capabilities = ;
120const bridge_error = ;
121const request_context = ;
122const memory = ;
123const objc_runtime = ;
124
125const objc = objc_runtime.objc;
126
127// SQLite, spelled exactly as `database.zig` spells it (those declarations are
128// file-private by design; `extern` declarations are free to repeat). The
129// vendored amalgamation is compiled into every artifact that links this
130// module on Darwin — `ios_surface_tests`, `ios_module_tests`, the device lib —
131// per `build.zig`.
132const sqlite3 = opaque {};
133const sqlite3_stmt = opaque {};
134
135const SQLITE_OK = 0;
136const SQLITE_ROW = 100;
137const SQLITE_DONE = 101;
138
139const SQLITE_INTEGER = 1;
140const SQLITE_FLOAT = 2;
141const SQLITE_TEXT = 3;
142const SQLITE_BLOB = 4;
143const SQLITE_NULL = 5;
144
145extern fn sqlite3_open(filename: [*:0]const u8, ppDb: *?*sqlite3) c_int;
146extern fn sqlite3_close(db: ?*sqlite3) c_int;
147extern fn sqlite3_errmsg(db: ?*sqlite3) ?[*:0]const u8;
148extern fn sqlite3_changes(db: ?*sqlite3) c_int;
149extern fn sqlite3_last_insert_rowid(db: ?*sqlite3) i64;
150extern fn sqlite3_prepare_v2(db: ?*sqlite3, zSql: [*]const u8, nByte: c_int, ppStmt: *?*sqlite3_stmt, pzTail: ?*[*]const u8) c_int;
151extern fn sqlite3_finalize(stmt: ?*sqlite3_stmt) c_int;
152extern fn sqlite3_step(stmt: ?*sqlite3_stmt) c_int;
153extern fn sqlite3_bind_null(stmt: ?*sqlite3_stmt, idx: c_int) c_int;
154extern fn sqlite3_bind_int64(stmt: ?*sqlite3_stmt, idx: c_int, value: i64) c_int;
155extern fn sqlite3_bind_double(stmt: ?*sqlite3_stmt, idx: c_int, value: f64) c_int;
156extern fn sqlite3_bind_text(stmt: ?*sqlite3_stmt, idx: c_int, text: [*]const u8, len: c_int, destructor: ?*const anyopaque) c_int;
157extern fn sqlite3_column_count(stmt: ?*sqlite3_stmt) c_int;
158extern fn sqlite3_column_name(stmt: ?*sqlite3_stmt, idx: c_int) ?[*:0]const u8;
159extern fn sqlite3_column_type(stmt: ?*sqlite3_stmt, idx: c_int) c_int;
160extern fn sqlite3_column_int64(stmt: ?*sqlite3_stmt, idx: c_int) i64;
161extern fn sqlite3_column_double(stmt: ?*sqlite3_stmt, idx: c_int) f64;
162extern fn sqlite3_column_text(stmt: ?*sqlite3_stmt, idx: c_int) ?[*]const u8;
163extern fn sqlite3_column_bytes(stmt: ?*sqlite3_stmt, idx: c_int) c_int;
164
165/// `((sqlite3_destructor_type)-1)`: copy the bound bytes rather than borrow
166/// them. An opaque pointer, never a typed fn pointer — the all-ones address
167/// cannot satisfy fn-pointer alignment, and the long comment in `database.zig`
168/// records the two Linux link breakages the typed spelling caused.
169const SQLITE_TRANSIENT: ?*const anyopaque = @ptrFromInt(std.math.maxInt(usize));
170
171/// The action names, spelled exactly as the Swift `case` labels spell them.
172/// `test/ios_conformance_test.zig` matches the two lists by string in both
173/// directions.
174pub const A = struct {
175 pub const db_execute = "dbExecute";
176 pub const db_query = "dbQuery";
177};
178
179/// Both `.result`: each Swift path resolves or rejects a callback, and both
180/// are awaited by the page. Both `.live`: plain SQLite C calls, no UI, no
181/// permission prompt, no completion handler — the amalgamation is compiled
182/// into the iOS artifacts, so nothing warrants `.unavailable`.
183pub const capability_actions = [_]capabilities.ActionDecl{
184 .{ .name = A.db_execute, .reply = .result },
185 .{ .name = A.db_query, .reply = .result },
186};
187
188/// The `enableLocalDatabase` gate, kept plumbable per the clipboard precedent
189/// (`craft_ios_set_clipboard_enabled`). Swift's gate at `false` replied
190/// *nothing*; off here means `PermissionDenied`, never silence. Main-thread
191/// only, like every gate in the chain: the WKScriptMessageHandler callback is
192/// the sole reader, a host at startup the sole writer.
193var local_database_enabled: bool = true;
194
195pub fn setEnabled(value: bool) void {
196 local_database_enabled = value;
197}
198
199export fn craft_ios_set_local_database_enabled(value: bool) callconv(.c) void {
200 local_database_enabled = value;
201}
202
203/// The one process-lifetime handle to `Documents/craft.db`, mirroring Swift's
204/// coordinator-lifetime `db`. Opened lazily on the first db action rather than
205/// at init, because `DbBridge` is constructed per-message by the dispatch loop
206/// and must stay free to construct. Never populated by tests — see the module
207/// comment on host-test safety.
208var global_db: ?*sqlite3 = null;
209
210/// Which handler an action selects, split from `handleMessage` so the
211/// table-versus-dispatch agreement is assertable on a host without invoking a
212/// handler whose success path would open the developer's own `craft.db`.
213const Route = enum { execute, query };
214
215fn routeFor(action: []const u8) ?Route {
216 if (std.mem.eql(u8, action, A.db_execute)) return .execute;
217 if (std.mem.eql(u8, action, A.db_query)) return .query;
218 return null;
219}
220
221pub const DbBridge = struct {
222 allocator: std.mem.Allocator,
223
224 const Self = @This();
225
226 pub fn init(allocator: std.mem.Allocator) Self {
227 return .{ .allocator = allocator };
228 }
229
230 pub fn deinit(_: *Self) void {}
231
232 pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void {
233 const route = routeFor(action) orelse return bridge_error.BridgeError.UnknownAction;
234 // Exhaustive, so a `Route` without a handler is a compile error.
235 return switch (route) {
236 .execute => self.serve(.execute, data),
237 .query => self.serve(.query, data),
238 };
239 }
240
241 /// One body for both actions — they share the gate, the payload shape, the
242 /// database handle, and the two reply routes; only the runner differs.
243 fn serve(self: *Self, route: Route, data: []const u8) !void {
244 const action: []const u8 = switch (route) {
245 .execute => A.db_execute,
246 .query => A.db_query,
247 };
248
249 if (!local_database_enabled) return bridge_error.BridgeError.PermissionDenied;
250
251 var parsed = try parsePayload(self.allocator, data);
252 defer parsed.deinit();
253 const request = try parseRequest(parsed.value);
254
255 // Validation strictly precedes the open: this ordering is what lets
256 // the host tests exercise every refusal above without ever creating
257 // `~/Documents/craft.db` on the machine running them.
258 // `if` comparisons rather than a `switch`: on Darwin the comptime
259 // platform gate is pruned, so `error.UnsupportedPlatform` is not in
260 // the inferred error set and a switch arm naming it would not compile.
261 const db = ensureDatabase(self.allocator) catch |err| {
262 if (err == error.OutOfMemory) return err;
263 if (err == error.UnsupportedPlatform) return err;
264 // Swift's `guard let db` message, verbatim — the observable
265 // reply for "there is no database to run this against".
266 rejectWithMessage(self.allocator, action, "Database not initialized");
267 return;
268 };
269
270 const outcome = switch (route) {
271 .execute => try runExecute(self.allocator, db, request),
272 .query => try runQuery(self.allocator, db, request),
273 };
274 switch (outcome) {
275 .ok => |fragment| {
276 defer self.allocator.free(fragment);
277 bridge_error.sendResultToJS(self.allocator, action, fragment);
278 },
279 .failed => |message| {
280 defer self.allocator.free(message);
281 rejectWithMessage(self.allocator, action, message);
282 },
283 }
284 }
285};
286
287/// Parse `d`, distinguishing a bad payload from a failed allocation — telling
288/// the page INVALID_JSON about its own good JSON sends whoever debugs it to
289/// the wrong side of the bridge.
290fn parsePayload(allocator: std.mem.Allocator, data: []const u8) !std.json.Parsed(std.json.Value) {
291 return std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch |err| switch (err) {
292 error.OutOfMemory => return err,
293 else => return bridge_error.BridgeError.InvalidJSON,
294 };
295}
296
297/// The payload after validation. `sql` and `params` are the names the injected
298/// surfaces post and the Swift dispatcher reads — pinned on both sides of the
299/// migration. `params` borrows the parsed JSON's array items; the caller keeps
300/// the parse alive until the run completes.
301const Request = struct {
302 sql: []const u8,
303 params: []const std.json.Value,
304};
305
306fn parseRequest(payload: std.json.Value) !Request {
307 const object = switch (payload) {
308 .object => |o| o,
309 else => return bridge_error.BridgeError.InvalidJSON,
310 };
311
312 const sql_field = object.get("sql") orelse return bridge_error.BridgeError.MissingData;
313 const sql = switch (sql_field) {
314 .string => |s| s,
315 // Swift's `as? String` fails here and replies nothing at all.
316 else => return bridge_error.BridgeError.InvalidParameter,
317 };
318 // the \\u0000 escape is legal JSON, so a page can reach this. SQLite stops
319 // parsing SQL at a NUL: an unchecked one would silently execute a prefix
320 // of what the page sent and report success for the whole.
321 if (std.mem.indexOfScalar(u8, sql, 0) != null) return bridge_error.BridgeError.InvalidParameter;
322 // `prepare_v2` takes the byte count as a c_int; a longer `sql` would trip
323 // the `@intCast` safety panic — the same crash-instead-of-answer the
324 // module doc rejects in Swift's `Int32(int)` funnel. (SQLite itself caps
325 // statements at SQLITE_LIMIT_LENGTH, far below this, so nothing honest is
326 // refused.)
327 if (sql.len > std.math.maxInt(c_int)) return bridge_error.BridgeError.InvalidParameter;
328
329 const params: []const std.json.Value = if (object.get("params")) |params_field| switch (params_field) {
330 .array => |a| a.items,
331 // `craft.db.execute(sql)` with params omitted serializes `null` on
332 // some surfaces; absent and null are both "no params", as in Swift.
333 .null => &[_]std.json.Value{},
334 // Swift's `as? [Any]` quietly yields nil here and executes without
335 // the params the page sent — a dropped payload, refused instead.
336 else => return bridge_error.BridgeError.InvalidParameter,
337 } else &[_]std.json.Value{};
338
339 for (params) |param| switch (param) {
340 // `bind_text` takes the byte length as a c_int — same `@intCast`
341 // panic as `sql` above for a param of 2^31 bytes or more; refused
342 // here so a page payload can never crash instead of erroring.
343 .string => |s| if (s.len > std.math.maxInt(c_int)) return bridge_error.BridgeError.InvalidParameter,
344 .integer, .float, .bool, .null => {},
345 // Swift binds NULL for an object or array param — the value the page
346 // sent, silently replaced with a different one. `.number_string`
347 // cannot occur under default parse options; refusing it beats
348 // guessing at its binding.
349 else => return bridge_error.BridgeError.InvalidParameter,
350 };
351
352 return .{ .sql = sql, .params = params };
353}
354
355/// What a runner hands back: the resolve fragment, or the message the page's
356/// rejection carries (SQLite's own text — the observable contract). Exactly
357/// one of the two, and the caller owns either slice. Validation failures are
358/// errors instead, and take the dispatcher's fixed-message route.
359const RunOutcome = union(enum) {
360 ok: []u8,
361 failed: []u8,
362};
363
364/// The `dbExecute` resolve fragment. Key order is fixed here where Swift's
365/// dictionary serialization left it arbitrary; both fields are the contract.
366fn executeFragment(allocator: std.mem.Allocator, rows_affected: c_int, last_insert_id: i64) ![]u8 {
367 return std.fmt.allocPrint(
368 allocator,
369 "",
370 .{ rows_affected, last_insert_id },
371 );
372}
373
374/// True when `sql` continues past the first prepared statement with anything
375/// but whitespace and semicolons. `prepare_v2` points `tail` one past the end
376/// of the first statement; Swift never looks, and silently discards whatever
377/// follows. Conservative on purpose: a trailing `-- comment` is refused too,
378/// and the page gets INVALID_PARAMETER instead of a silent partial execution.
379fn hasTrailingStatement(sql: []const u8, tail: [*]const u8) bool {
380 const consumed = @intFromPtr(tail) - @intFromPtr(sql.ptr);
381 if (consumed >= sql.len) return false;
382 const rest = std.mem.trim(u8, sql[consumed..], " \t\r\n;");
383 return rest.len != 0;
384}
385
386/// `sqlite3_errmsg`'s current text, copied immediately — the pointer is only
387/// valid until the next call on this connection, and `finalize` runs before
388/// the message reaches the page.
389fn copyErrMsg(allocator: std.mem.Allocator, db: *sqlite3) ![]u8 {
390 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
391
392 const msg = sqlite3_errmsg(db) orelse return allocator.dupe(u8, "unknown SQLite error");
393 return allocator.dupe(u8, std.mem.span(msg));
394}
395
396/// Bind one JSON param at 1-based `idx`, returning SQLite's own verdict.
397///
398/// The mapping Swift's downcast chain implies, minus its traps: strings carry
399/// their byte length with SQLITE_TRANSIENT (Swift's `-1, nil` truncated at NUL
400/// and borrowed a temporary), integers and bools go through `bind_int64`
401/// (Swift's `Int32(int)` traps at 2^31; its NSNumber `true` binds as INTEGER
402/// 1, matched here), floats through `bind_double`, null through `bind_null`.
403fn bindValue(stmt: ?*sqlite3_stmt, idx: c_int, value: std.json.Value) c_int {
404 return switch (value) {
405 .string => |s| if (s.len == 0)
406 // A zero-length slice's pointer may be dangling; SQLite still
407 // wants a readable address for the empty string.
408 sqlite3_bind_text(stmt, idx, "", 0, SQLITE_TRANSIENT)
409 else
410 sqlite3_bind_text(stmt, idx, s.ptr, @intCast(s.len), SQLITE_TRANSIENT),
411 .integer => |v| sqlite3_bind_int64(stmt, idx, v),
412 .float => |v| sqlite3_bind_double(stmt, idx, v),
413 .bool => |v| sqlite3_bind_int64(stmt, idx, @as(i64, @intFromBool(v))),
414 .null => sqlite3_bind_null(stmt, idx),
415 // parseRequest refused everything else before a statement existed.
416 else => unreachable,
417 };
418}
419
420/// Bind all params; a failure returns the message for the page's rejection.
421/// Swift never checks these return codes — excess params vanish into an
422/// ignored SQLITE_RANGE. Divergence, recorded: a param that did not bind is a
423/// payload that did not do what the page said.
424fn bindAll(
425 allocator: std.mem.Allocator,
426 db: *sqlite3,
427 stmt: ?*sqlite3_stmt,
428 params: []const std.json.Value,
429) !?[]u8 {
430 for (params, 0..) |param, i| {
431 const rc = bindValue(stmt, @intCast(i + 1), param);
432 if (rc != SQLITE_OK) return try copyErrMsg(allocator, db);
433 }
434 return null;
435}
436
437/// Prepare `sql` (exactly one statement of it), or say why not.
438///
439/// Shared by both runners: same prepare, same multi-statement refusal, same
440/// "nothing to execute" refusal for whitespace/comment-only SQL (where Swift
441/// stepped a nil statement into SQLITE_MISUSE for `dbExecute` and resolved
442/// `[]` for `dbQuery` — neither is an answer about SQL that contains no
443/// statement).
444const Prepared = union(enum) {
445 stmt: *sqlite3_stmt,
446 failed: []u8,
447};
448
449fn prepareOne(allocator: std.mem.Allocator, db: *sqlite3, sql: []const u8) !Prepared {
450 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
451
452 if (sql.len == 0) return bridge_error.BridgeError.InvalidParameter;
453
454 var stmt: ?*sqlite3_stmt = null;
455 var tail: [*]const u8 = sql.ptr;
456 const rc = sqlite3_prepare_v2(db, sql.ptr, @intCast(sql.len), &stmt, &tail);
457 if (rc != SQLITE_OK) {
458 _ = sqlite3_finalize(stmt);
459 return .{ .failed = try copyErrMsg(allocator, db) };
460 }
461 const prepared = stmt orelse return bridge_error.BridgeError.InvalidParameter;
462 if (hasTrailingStatement(sql, tail)) {
463 _ = sqlite3_finalize(prepared);
464 return bridge_error.BridgeError.InvalidParameter;
465 }
466 return .{ .stmt = prepared };
467}
468
469/// `dbExecute`: prepare, bind, single step to DONE, report changes + rowid.
470///
471/// Takes the handle explicitly so host tests run against their own `:memory:`
472/// database — nothing in here knows about `Documents/craft.db`.
473fn runExecute(allocator: std.mem.Allocator, db: *sqlite3, request: Request) !RunOutcome {
474 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
475
476 const stmt = switch (try prepareOne(allocator, db, request.sql)) {
477 .failed => |message| return .{ .failed = message },
478 .stmt => |s| s,
479 };
480 defer _ = sqlite3_finalize(stmt);
481
482 if (try bindAll(allocator, db, stmt, request.params)) |message| {
483 return .{ .failed = message };
484 }
485
486 // Anything but DONE rejects — including SQLITE_ROW from a SELECT sent
487 // through `dbExecute`, which Swift also rejects (with the "not an error"
488 // text `sqlite3_errmsg` reports at that point). Bug-compatible; see the
489 // module comment.
490 if (sqlite3_step(stmt) != SQLITE_DONE) {
491 return .{ .failed = try copyErrMsg(allocator, db) };
492 }
493
494 return .{ .ok = try executeFragment(allocator, sqlite3_changes(db), sqlite3_last_insert_rowid(db)) };
495}
496
497/// `dbQuery`: prepare, bind, step to completion, serialize each row as an
498/// object keyed by column name — the exact JSON the page consumes.
499fn runQuery(allocator: std.mem.Allocator, db: *sqlite3, request: Request) !RunOutcome {
500 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
501
502 const stmt = switch (try prepareOne(allocator, db, request.sql)) {
503 .failed => |message| return .{ .failed = message },
504 .stmt => |s| s,
505 };
506 defer _ = sqlite3_finalize(stmt);
507
508 if (try bindAll(allocator, db, stmt, request.params)) |message| {
509 return .{ .failed = message };
510 }
511
512 var out: std.ArrayListUnmanaged(u8) = .empty;
513 errdefer out.deinit(allocator);
514 try out.append(allocator, '[');
515
516 const column_count = sqlite3_column_count(stmt);
517 var first_row = true;
518 while (true) {
519 const rc = sqlite3_step(stmt);
520 if (rc == SQLITE_DONE) break;
521 if (rc != SQLITE_ROW) {
522 // Swift's loop condition swallows this code and resolves the rows
523 // collected so far as if they were all of them. Divergence,
524 // chosen: an interrupted result set is a failure, not a shorter
525 // success.
526 const message = try copyErrMsg(allocator, db);
527 out.deinit(allocator);
528 return .{ .failed = message };
529 }
530
531 if (!first_row) try out.append(allocator, ',');
532 first_row = false;
533 try out.append(allocator, '{');
534
535 var first_col = true;
536 var i: c_int = 0;
537 while (i < column_count) : (i += 1) {
538 const col_type = sqlite3_column_type(stmt, i);
539 // Swift's `default: break`: the key is entirely absent, not null.
540 if (col_type == SQLITE_BLOB) continue;
541
542 // Null only on OOM inside SQLite — never "no name".
543 const name = sqlite3_column_name(stmt, i) orelse return error.OutOfMemory;
544
545 if (!first_col) try out.append(allocator, ',');
546 first_col = false;
547 try out.append(allocator, '"');
548 // Page-controlled via `AS "alia\"s"` — escaped like every other
549 // page-controlled byte that rides a reply.
550 try bridge_error.appendJsonEscaped(allocator, &out, std.mem.span(name));
551 try out.appendSlice(allocator, "\":");
552
553 switch (col_type) {
554 SQLITE_INTEGER => try out.print(allocator, "{d}", .{sqlite3_column_int64(stmt, i)}),
555 SQLITE_FLOAT => {
556 const value = sqlite3_column_double(stmt, i);
557 if (!std.math.isFinite(value)) {
558 // SQLite can hold an infinity (`9e999`); JSON cannot.
559 // Swift's JSONSerialization throws and its catch
560 // rejects with exactly this message.
561 const message = try allocator.dupe(u8, "Native result could not be serialized");
562 out.deinit(allocator);
563 return .{ .failed = message };
564 }
565 try out.print(allocator, "{d}", .{value});
566 },
567 SQLITE_TEXT => {
568 // `column_text` is null only on OOM for a TEXT column.
569 // Length via `column_bytes` (valid right after
570 // `column_text`), not a NUL scan — Swift's
571 // `String(cString:)` truncated stored text at an embedded
572 // NUL; the desktop reader and this one carry it whole.
573 const text = sqlite3_column_text(stmt, i) orelse return error.OutOfMemory;
574 const len: usize = @intCast(sqlite3_column_bytes(stmt, i));
575 try out.append(allocator, '"');
576 try bridge_error.appendJsonEscaped(allocator, &out, text[0..len]);
577 try out.append(allocator, '"');
578 },
579 SQLITE_NULL => try out.appendSlice(allocator, "null"),
580 // The five storage classes are exhaustive and BLOB was handled
581 // above; SQLite returning a sixth is a broken runtime. The
582 // `errdefer` above frees `out` on this error return — a manual
583 // deinit here would run it twice.
584 else => return error.NativeCallFailed,
585 }
586 }
587 try out.append(allocator, '}');
588 }
589 try out.append(allocator, ']');
590
591 return .{ .ok = try out.toOwnedSlice(allocator) };
592}
593
594/// Reject the current call with a free-text message — the route that carries
595/// `sqlite3_errmsg`'s words to the page, where `sendErrorToJS` could only send
596/// a fixed per-code string. Same wire shape (`__craftBridgeError`), same
597/// escaping (`ErrorContext.toJSON`), stamped with the current request id so
598/// exactly the calling promise rejects.
599fn rejectWithMessage(allocator: std.mem.Allocator, action: []const u8, message: []const u8) void {
600 var ctx = bridge_error.ErrorContext.init(bridge_error.BridgeError.NativeCallFailed, action, message);
601 ctx.request_id = request_context.current();
602
603 const json = ctx.toJSON(allocator) catch {
604 // Out of memory mid-reply: the fixed-message route still beats
605 // silence — the page's promise must settle.
606 bridge_error.sendErrorToJS(allocator, action, bridge_error.BridgeError.NativeCallFailed);
607 return;
608 };
609 defer allocator.free(json);
610
611 const js = std.fmt.allocPrint(
612 allocator,
613 "if(window.__craftBridgeError)window.__craftBridgeError({s});",
614 .{json},
615 ) catch {
616 bridge_error.sendErrorToJS(allocator, action, bridge_error.BridgeError.NativeCallFailed);
617 return;
618 };
619 defer allocator.free(js);
620
621 const bridge = ;
622 bridge.evalJS(js) catch |err| {
623 std.log.warn("db bridge: could not deliver rejection for '{s}': {}", .{ action, err });
624 };
625}
626
627/// The process-wide handle, opened on first use at `Documents/craft.db` —
628/// Swift's `setupDatabase` path exactly, so a partially migrated app reads the
629/// data its Swift half wrote. A failed open is closed and reported (Swift left
630/// the broken handle installed — see the module comment) and retried on the
631/// next action rather than latched.
632fn ensureDatabase(allocator: std.mem.Allocator) !*sqlite3 {
633 if (global_db) |db| return db;
634 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
635
636 const docs = try documentsPath(allocator);
637 defer allocator.free(docs);
638
639 const joined = try std.fmt.allocPrint(allocator, "{s}/craft.db", .{docs});
640 defer allocator.free(joined);
641
642 const db = try openDatabaseFile(allocator, joined);
643 global_db = db;
644 return db;
645}
646
647/// `NSFileManager` → `URLsForDirectory:inDomains:` → `firstObject` → `path`,
648/// the chain Swift's `setupDatabase` runs, with every step guarded — a nil
649/// anywhere is a named error, never a message to nil read as a path.
650/// NSDocumentDirectory = 9, NSUserDomainMask = 1. (`mobile.zig`'s
651/// `getDocumentsDirectory` is a stub returning `""` — not used on purpose.)
652fn documentsPath(allocator: std.mem.Allocator) ![]u8 {
653 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
654
655 const NSFileManager = objc.objc_getClass("NSFileManager") orelse return error.ClassNotFound;
656 const sel_default = objc.sel_registerName("defaultManager") orelse return error.SelectorNotFound;
657 const manager = objc.msgSendId(NSFileManager, sel_default);
658 if (manager == null) return error.NativeCallFailed;
659
660 const sel_urls = objc.sel_registerName("URLsForDirectory:inDomains:") orelse return error.SelectorNotFound;
661 // NSUInteger arguments: explicit c_ulong, because the variadic msgSend
662 // cast takes argument types from what is passed.
663 const urls = objc.msgSendId2(manager, sel_urls, @as(c_ulong, 9), @as(c_ulong, 1));
664 if (urls == null) return error.NativeCallFailed;
665
666 const sel_first = objc.sel_registerName("firstObject") orelse return error.SelectorNotFound;
667 const url = objc.msgSendId(urls, sel_first);
668 if (url == null) return error.NotFound;
669
670 const sel_path = objc.sel_registerName("path") orelse return error.SelectorNotFound;
671 const path_obj = objc.msgSendId(url, sel_path);
672 if (path_obj == null) return error.NativeCallFailed;
673
674 const cstr = objc.getNSStringUTF8(path_obj) orelse return error.NativeCallFailed;
675 return allocator.dupe(u8, std.mem.span(cstr));
676}
677
678/// Plain `sqlite3_open` — RW|CREATE, no pragmas — matching Swift; see the
679/// module comment for why `database.zig`'s pragma-applying `Database` would be
680/// wrong here.
681fn openDatabaseFile(allocator: std.mem.Allocator, path: []const u8) !*sqlite3 {
682 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
683
684 const path_z = try memory.dupeZ(allocator, u8, path);
685 defer allocator.free(path_z);
686
687 var handle: ?*sqlite3 = null;
688 const rc = sqlite3_open(path_z.ptr, &handle);
689 if (rc != SQLITE_OK) {
690 // sqlite3_open hands back a handle even on failure, carrying the
691 // error detail; read it for the log, then close it.
692 if (handle) |h| {
693 if (sqlite3_errmsg(h)) |msg| {
694 std.log.warn("craft.db open failed: {s}", .{std.mem.span(msg)});
695 }
696 _ = sqlite3_close(h);
697 }
698 return error.NativeCallFailed;
699 }
700 return handle orelse error.NativeCallFailed;
701}
702
703// =============================================================================
704// Tests — host-only, two tiers. The routing/parsing/shaping tier is pure and
705// runs everywhere. The SQLite tier drives `runExecute`/`runQuery` against its
706// own `:memory:` handle — real statements, real bindings, exact reply bytes —
707// and is gated on Darwin because that is where `build.zig` compiles the
708// vendored amalgamation into the ios test artifacts (the code is portable C;
709// the gate mirrors the build wiring, not the code). No test reaches
710// `ensureDatabase`: on a macOS host that would create the developer's own
711// `~/Documents/craft.db`.
712// =============================================================================
713
714const testing = std.testing;
715
716test "the declared actions are the ones the handler serves" {
717 try testing.expectEqual(@as(usize, 2), capability_actions.len);
718 try testing.expectEqualStrings(A.db_execute, capability_actions[0].name);
719 try testing.expectEqualStrings(A.db_query, capability_actions[1].name);
720
721 for (capability_actions) |decl| {
722 try testing.expectEqual(capabilities.Reply.result, decl.reply);
723 try testing.expectEqual(capabilities.ActionStatus.live, decl.status);
724 }
725}
726
727test "the action names match the Swift case labels exactly" {
728 // The conformance ratchet compares these against the `case "…":` labels in
729 // `CraftApp.swift` in both directions; a prettier spelling would read as
730 // Zig serving an action the spec does not have.
731 try testing.expectEqualStrings("dbExecute", A.db_execute);
732 try testing.expectEqualStrings("dbQuery", A.db_query);
733}
734
735test "every declared action routes, every route is declared, none shared" {
736 var execute_declared = false;
737 var query_declared = false;
738 for (capability_actions) |decl| {
739 const route = routeFor(decl.name) orelse {
740 std.debug.print("declared action '{s}' does not route\n", .{decl.name});
741 return error.DeclaredActionDoesNotRoute;
742 };
743 switch (route) {
744 .execute => {
745 if (execute_declared) return error.TwoDeclarationsShareARoute;
746 execute_declared = true;
747 },
748 .query => {
749 if (query_declared) return error.TwoDeclarationsShareARoute;
750 query_declared = true;
751 },
752 }
753 }
754 // The other direction: a route with a handler and no declaration is an
755 // action the page can call and the manifest denies exists.
756 try testing.expect(execute_declared);
757 try testing.expect(query_declared);
758}
759
760test "an action the namespace does not serve is reported, not ignored" {
761 var bridge = DbBridge.init(testing.allocator);
762 defer bridge.deinit();
763
764 const strangers = [_][]const u8{
765 "noSuchAction",
766 // Near misses — casing is how a real typo arrives.
767 "dbexecute",
768 "DbQuery",
769 // The JS surface names, which are not action names.
770 "db",
771 "execute",
772 "query",
773 // A Swift neighbor this module must not answer for.
774 "startBluetoothScan",
775 };
776 for (strangers) |name| {
777 try testing.expectError(
778 bridge_error.BridgeError.UnknownAction,
779 bridge.handleMessage(name, "{}"),
780 );
781 }
782}
783
784test "both actions fail validation before any database can be opened" {
785 // `{}` has no `sql`, so both error out before `ensureDatabase` runs —
786 // which is what makes this safe on a developer's machine (the lazy open
787 // would land in ~/Documents/craft.db), and what rules out a routed name
788 // whose handler ignores its payload contract.
789 var bridge = DbBridge.init(testing.allocator);
790 defer bridge.deinit();
791
792 try testing.expectError(bridge_error.BridgeError.MissingData, bridge.handleMessage(A.db_execute, "{}"));
793 try testing.expectError(bridge_error.BridgeError.MissingData, bridge.handleMessage(A.db_query, "{}"));
794}
795
796test "a malformed payload is reported as bad JSON, not as a missing field" {
797 var bridge = DbBridge.init(testing.allocator);
798 defer bridge.deinit();
799
800 try testing.expectError(
801 bridge_error.BridgeError.InvalidJSON,
802 bridge.handleMessage(A.db_execute, "{not json"),
803 );
804}
805
806test "the gate off means PermissionDenied, never Swift's silence" {
807 setEnabled(false);
808 defer setEnabled(true);
809
810 var bridge = DbBridge.init(testing.allocator);
811 defer bridge.deinit();
812
813 try testing.expectError(
814 bridge_error.BridgeError.PermissionDenied,
815 bridge.handleMessage(A.db_query, "{}"),
816 );
817}
818
819fn parseRequestFrom(json: []const u8, out_parsed: *std.json.Parsed(std.json.Value)) !Request {
820 out_parsed.* = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
821 return parseRequest(out_parsed.value);
822}
823
824fn expectRequestError(json: []const u8, expected: anyerror) !void {
825 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
826 defer parsed.deinit();
827 try testing.expectError(expected, parseRequest(parsed.value));
828}
829
830test "the field names the page sends are the ones that are read" {
831 // `body["sql"]` / `body["params"]` in the Swift dispatcher; the same pair
832 // rides `d` through the shim hand-off. A rename on either side of the
833 // migration would make the two handlers read different payloads.
834 var parsed = try std.json.parseFromSlice(
835 std.json.Value,
836 testing.allocator,
837 "{\"sql\":\"SELECT ?\",\"params\":[\"a\",1,2.5,null,true]}",
838 .{},
839 );
840 defer parsed.deinit();
841
842 const request = try parseRequest(parsed.value);
843 try testing.expectEqualStrings("SELECT ?", request.sql);
844 try testing.expectEqual(@as(usize, 5), request.params.len);
845 try testing.expectEqualStrings("a", request.params[0].string);
846 try testing.expectEqual(@as(i64, 1), request.params[1].integer);
847 try testing.expectEqual(@as(f64, 2.5), request.params[2].float);
848 try testing.expect(request.params[3] == .null);
849 try testing.expect(request.params[4].bool);
850}
851
852test "absent and null params are no params; anything else is refused" {
853 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, "{\"sql\":\"SELECT 1\"}", .{});
854 defer parsed.deinit();
855 try testing.expectEqual(@as(usize, 0), (try parseRequest(parsed.value)).params.len);
856
857 var parsed_null = try std.json.parseFromSlice(std.json.Value, testing.allocator, "{\"sql\":\"SELECT 1\",\"params\":null}", .{});
858 defer parsed_null.deinit();
859 try testing.expectEqual(@as(usize, 0), (try parseRequest(parsed_null.value)).params.len);
860
861 // Swift's `as? [Any]` silently drops a non-array `params` and executes
862 // without the values the page sent.
863 try expectRequestError("{\"sql\":\"SELECT ?\",\"params\":\"nope\"}", bridge_error.BridgeError.InvalidParameter);
864 try expectRequestError("{\"sql\":\"SELECT ?\",\"params\":7}", bridge_error.BridgeError.InvalidParameter);
865}
866
867test "an absent or non-string sql is refused rather than hung on" {
868 try expectRequestError("{}", bridge_error.BridgeError.MissingData);
869 try expectRequestError("{\"params\":[]}", bridge_error.BridgeError.MissingData);
870 try expectRequestError("{\"sql\":7}", bridge_error.BridgeError.InvalidParameter);
871 try expectRequestError("{\"sql\":null}", bridge_error.BridgeError.InvalidParameter);
872}
873
874test "sql with an embedded NUL is refused, not silently truncated" {
875 // SQLite stops parsing at the NUL: "SELECT 1\x00; DROP ..." would execute
876 // the SELECT and report success for the whole string.
877 try expectRequestError("{\"sql\":\"SELECT 1\\u0000DROP TABLE t\"}", bridge_error.BridgeError.InvalidParameter);
878}
879
880test "a param SQLite has no honest binding for is refused, not bound as NULL" {
881 // Swift binds NULL here — the value the page sent, silently replaced.
882 try expectRequestError("{\"sql\":\"SELECT ?\",\"params\":[{}]}", bridge_error.BridgeError.InvalidParameter);
883 try expectRequestError("{\"sql\":\"SELECT ?\",\"params\":[[1]]}", bridge_error.BridgeError.InvalidParameter);
884}
885
886test "a payload that is not an object is bad JSON, not a missing field" {
887 try expectRequestError("[]", bridge_error.BridgeError.InvalidJSON);
888 try expectRequestError("\"sql\"", bridge_error.BridgeError.InvalidJSON);
889}
890
891test "the execute fragment is the two-field object, keys and order pinned" {
892 const fragment = try executeFragment(testing.allocator, 1, 42);
893 defer testing.allocator.free(fragment);
894 try testing.expectEqualStrings("{\"rowsAffected\":1,\"lastInsertId\":42}", fragment);
895
896 const none = try executeFragment(testing.allocator, 0, 0);
897 defer testing.allocator.free(none);
898 try testing.expectEqualStrings("{\"rowsAffected\":0,\"lastInsertId\":0}", none);
899}
900
901test "the multi-statement detector sees a second statement and not a tail of semicolons" {
902 const two: []const u8 = "SELECT 1; SELECT 2";
903 try testing.expect(hasTrailingStatement(two, two.ptr + 9));
904
905 const trailing: []const u8 = "SELECT 1; \n;";
906 try testing.expect(!hasTrailingStatement(trailing, trailing.ptr + 9));
907
908 const exact: []const u8 = "SELECT 1";
909 try testing.expect(!hasTrailingStatement(exact, exact.ptr + exact.len));
910}
911
912// -----------------------------------------------------------------------------
913// The SQLite tier: real statements against this test's own :memory: database.
914// -----------------------------------------------------------------------------
915
916fn openMemoryDb() !*sqlite3 {
917 var handle: ?*sqlite3 = null;
918 const rc = sqlite3_open(":memory:", &handle);
919 if (rc != SQLITE_OK) {
920 if (handle) |h| _ = sqlite3_close(h);
921 return error.OpenFailed;
922 }
923 return handle orelse error.OpenFailed;
924}
925
926const Runner = enum { execute, query };
927
928/// Drive a runner through the same parse the handler uses, from the same
929/// payload shape the page posts.
930fn runFromJson(db: *sqlite3, runner: Runner, payload: []const u8) !RunOutcome {
931 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, payload, .{});
932 defer parsed.deinit();
933 const request = try parseRequest(parsed.value);
934 return switch (runner) {
935 .execute => runExecute(testing.allocator, db, request),
936 .query => runQuery(testing.allocator, db, request),
937 };
938}
939
940fn freeOutcome(outcome: RunOutcome) void {
941 switch (outcome) {
942 .ok => |s| testing.allocator.free(s),
943 .failed => |s| testing.allocator.free(s),
944 }
945}
946
947fn expectOk(outcome: RunOutcome, expected: []const u8) !void {
948 switch (outcome) {
949 .ok => |fragment| try testing.expectEqualStrings(expected, fragment),
950 .failed => |message| {
951 std.debug.print("expected ok, got rejection: {s}\n", .{message});
952 return error.UnexpectedRejection;
953 },
954 }
955}
956
957test "create, insert with every JSON param type, and read the exact reply back" {
958 if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
959
960 const db = try openMemoryDb();
961 defer _ = sqlite3_close(db);
962
963 const created = try runFromJson(db, .execute, "{\"sql\":\"CREATE TABLE t (a, b, c, d, e)\"}");
964 defer freeOutcome(created);
965 try expectOk(created, "{\"rowsAffected\":0,\"lastInsertId\":0}");
966
967 // One param of each bindable JSON type. `true` binds as INTEGER 1 — the
968 // NSNumber funnel Swift's `as? Int` implies — and reads back as 1.
969 const inserted = try runFromJson(
970 db,
971 .execute,
972 "{\"sql\":\"INSERT INTO t VALUES (?, ?, ?, ?, ?)\",\"params\":[\"text\",42,3.5,null,true]}",
973 );
974 defer freeOutcome(inserted);
975 try expectOk(inserted, "{\"rowsAffected\":1,\"lastInsertId\":1}");
976
977 // The whole observable reply, byte for byte: column keys, a TEXT string,
978 // an INTEGER number, a REAL number, a JSON null, the bool as 1.
979 const rows = try runFromJson(db, .query, "{\"sql\":\"SELECT a, b, c, d, e FROM t\"}");
980 defer freeOutcome(rows);
981 try expectOk(rows, "[{\"a\":\"text\",\"b\":42,\"c\":3.5,\"d\":null,\"e\":1}]");
982}
983
984test "an empty result set is the bare fragment [], and rows accumulate with commas" {
985 if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
986
987 const db = try openMemoryDb();
988 defer _ = sqlite3_close(db);
989
990 freeOutcome(try runFromJson(db, .execute, "{\"sql\":\"CREATE TABLE t (n)\"}"));
991
992 const empty = try runFromJson(db, .query, "{\"sql\":\"SELECT n FROM t\"}");
993 defer freeOutcome(empty);
994 try expectOk(empty, "[]");
995
996 freeOutcome(try runFromJson(db, .execute, "{\"sql\":\"INSERT INTO t VALUES (1)\"}"));
997 freeOutcome(try runFromJson(db, .execute, "{\"sql\":\"INSERT INTO t VALUES (2)\"}"));
998
999 const rows = try runFromJson(db, .query, "{\"sql\":\"SELECT n FROM t ORDER BY n\"}");
1000 defer freeOutcome(rows);
1001 try expectOk(rows, "[{\"n\":1},{\"n\":2}]");
1002}
1003
1004test "a BLOB column's key is absent from the row, exactly as Swift's default: break" {
1005 if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
1006
1007 const db = try openMemoryDb();
1008 defer _ = sqlite3_close(db);
1009
1010 const mixed = try runFromJson(db, .query, "{\"sql\":\"SELECT 1 AS n, x'00ff' AS b\"}");
1011 defer freeOutcome(mixed);
1012 try expectOk(mixed, "[{\"n\":1}]");
1013
1014 // A row that is all BLOBs is `{}` — present, and empty.
1015 const all_blob = try runFromJson(db, .query, "{\"sql\":\"SELECT x'00' AS b\"}");
1016 defer freeOutcome(all_blob);
1017 try expectOk(all_blob, "[{}]");
1018}
1019
1020test "an INTEGER beyond 32 bits survives — Swift's column_int wrapped it" {
1021 if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
1022
1023 const db = try openMemoryDb();
1024 defer _ = sqlite3_close(db);
1025
1026 freeOutcome(try runFromJson(db, .execute, "{\"sql\":\"CREATE TABLE t (n)\"}"));
1027 freeOutcome(try runFromJson(
1028 db,
1029 .execute,
1030 "{\"sql\":\"INSERT INTO t VALUES (?)\",\"params\":[3000000000]}",
1031 ));
1032
1033 const rows = try runFromJson(db, .query, "{\"sql\":\"SELECT n FROM t\"}");
1034 defer freeOutcome(rows);
1035 try expectOk(rows, "[{\"n\":3000000000}]");
1036}
1037
1038test "a TEXT value with an embedded NUL rides the round trip whole" {
1039 if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
1040
1041 const db = try openMemoryDb();
1042 defer _ = sqlite3_close(db);
1043
1044 freeOutcome(try runFromJson(db, .execute, "{\"sql\":\"CREATE TABLE t (a)\"}"));
1045 // The NUL is the point: length+TRANSIENT on the way in, column_bytes on
1046 // the way out. Swift stored a truncated "a" and read back a truncated "a".
1047 freeOutcome(try runFromJson(
1048 db,
1049 .execute,
1050 "{\"sql\":\"INSERT INTO t VALUES (?)\",\"params\":[\"a\\u0000b\"]}",
1051 ));
1052
1053 const rows = try runFromJson(db, .query, "{\"sql\":\"SELECT a FROM t\"}");
1054 defer freeOutcome(rows);
1055 try expectOk(rows, "[{\"a\":\"a\\u0000b\"}]");
1056}
1057
1058test "a column name that needs escaping cannot break the reply" {
1059 if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
1060
1061 const db = try openMemoryDb();
1062 defer _ = sqlite3_close(db);
1063
1064 // The alias is page-controlled and lands as a JSON key.
1065 const rows = try runFromJson(db, .query, "{\"sql\":\"SELECT 1 AS \\\"we\\\"\\\"ird\\\"\"}");
1066 defer freeOutcome(rows);
1067 try expectOk(rows, "[{\"we\\\"ird\":1}]");
1068}
1069
1070test "bad SQL rejects with SQLite's own words, not a fixed code string" {
1071 if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
1072
1073 const db = try openMemoryDb();
1074 defer _ = sqlite3_close(db);
1075
1076 const outcome = try runFromJson(db, .query, "{\"sql\":\"SELECT * FROM no_such_table\"}");
1077 defer freeOutcome(outcome);
1078 switch (outcome) {
1079 .ok => return error.ExpectedRejection,
1080 .failed => |message| {
1081 // "no such table: no_such_table" is what the page displays; the
1082 // table name in the message is the diagnostic contract.
1083 try testing.expect(std.mem.indexOf(u8, message, "no such table") != null);
1084 try testing.expect(std.mem.indexOf(u8, message, "no_such_table") != null);
1085 },
1086 }
1087}
1088
1089test "dbExecute of a SELECT that returns rows rejects, as Swift's does" {
1090 if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
1091
1092 const db = try openMemoryDb();
1093 defer _ = sqlite3_close(db);
1094
1095 // step yields SQLITE_ROW, not SQLITE_DONE — bug-compatible rejection.
1096 const outcome = try runFromJson(db, .execute, "{\"sql\":\"SELECT 1\"}");
1097 defer freeOutcome(outcome);
1098 try testing.expect(outcome == .failed);
1099}
1100
1101test "a non-SELECT through dbQuery executes and resolves []" {
1102 if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
1103
1104 const db = try openMemoryDb();
1105 defer _ = sqlite3_close(db);
1106
1107 const created = try runFromJson(db, .query, "{\"sql\":\"CREATE TABLE made_by_query (n)\"}");
1108 defer freeOutcome(created);
1109 try expectOk(created, "[]");
1110
1111 // And it really executed — the table exists now.
1112 const proof = try runFromJson(db, .query, "{\"sql\":\"SELECT n FROM made_by_query\"}");
1113 defer freeOutcome(proof);
1114 try expectOk(proof, "[]");
1115}
1116
1117test "multi-statement SQL is refused, not silently truncated to its first statement" {
1118 if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
1119
1120 const db = try openMemoryDb();
1121 defer _ = sqlite3_close(db);
1122
1123 freeOutcome(try runFromJson(db, .execute, "{\"sql\":\"CREATE TABLE t (n)\"}"));
1124
1125 // Swift executes the SELECT and silently discards the DROP. Neither
1126 // running half the payload nor running all of it behind prepare_v2's back
1127 // is honest; the page gets a refusal it can see.
1128 try testing.expectError(
1129 bridge_error.BridgeError.InvalidParameter,
1130 runFromJson(db, .execute, "{\"sql\":\"SELECT 1; DROP TABLE t\"}"),
1131 );
1132 try testing.expectError(
1133 bridge_error.BridgeError.InvalidParameter,
1134 runFromJson(db, .query, "{\"sql\":\"SELECT 1; SELECT 2\"}"),
1135 );
1136
1137 // A trailing semicolon is one statement, not two.
1138 const one = try runFromJson(db, .execute, "{\"sql\":\"INSERT INTO t VALUES (1); \"}");
1139 defer freeOutcome(one);
1140 try expectOk(one, "{\"rowsAffected\":1,\"lastInsertId\":1}");
1141}
1142
1143test "SQL containing no statement is refused, not stepped into SQLITE_MISUSE" {
1144 if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
1145
1146 const db = try openMemoryDb();
1147 defer _ = sqlite3_close(db);
1148
1149 try testing.expectError(
1150 bridge_error.BridgeError.InvalidParameter,
1151 runFromJson(db, .execute, "{\"sql\":\"\"}"),
1152 );
1153 try testing.expectError(
1154 bridge_error.BridgeError.InvalidParameter,
1155 runFromJson(db, .execute, "{\"sql\":\" \"}"),
1156 );
1157 try testing.expectError(
1158 bridge_error.BridgeError.InvalidParameter,
1159 runFromJson(db, .query, "{\"sql\":\";\"}"),
1160 );
1161}
1162
1163test "a param that does not bind rejects — Swift ignored the SQLITE_RANGE" {
1164 if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
1165
1166 const db = try openMemoryDb();
1167 defer _ = sqlite3_close(db);
1168
1169 // Two params, one slot: the second bind fails and the failure is said,
1170 // where Swift silently discarded the value the page sent.
1171 const outcome = try runFromJson(db, .query, "{\"sql\":\"SELECT ?1 AS v\",\"params\":[1,2]}");
1172 defer freeOutcome(outcome);
1173 try testing.expect(outcome == .failed);
1174}
1175
1176test "a stored infinity rejects with Swift's serialization message, not broken JSON" {
1177 if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
1178
1179 const db = try openMemoryDb();
1180 defer _ = sqlite3_close(db);
1181
1182 freeOutcome(try runFromJson(db, .execute, "{\"sql\":\"CREATE TABLE t (x)\"}"));
1183 // 9e999 overflows to +Inf, which SQLite stores and JSON cannot spell.
1184 freeOutcome(try runFromJson(db, .execute, "{\"sql\":\"INSERT INTO t VALUES (9e999)\"}"));
1185
1186 const outcome = try runFromJson(db, .query, "{\"sql\":\"SELECT x FROM t\"}");
1187 defer freeOutcome(outcome);
1188 switch (outcome) {
1189 .ok => return error.ExpectedRejection,
1190 .failed => |message| try testing.expectEqualStrings("Native result could not be serialized", message),
1191 }
1192}
1193
1194test "an empty-string param binds as the empty string, not as NULL" {
1195 if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
1196
1197 const db = try openMemoryDb();
1198 defer _ = sqlite3_close(db);
1199
1200 freeOutcome(try runFromJson(db, .execute, "{\"sql\":\"CREATE TABLE t (a)\"}"));
1201 freeOutcome(try runFromJson(db, .execute, "{\"sql\":\"INSERT INTO t VALUES (?)\",\"params\":[\"\"]}"));
1202
1203 // "" and NULL are different answers and must stay different.
1204 const rows = try runFromJson(db, .query, "{\"sql\":\"SELECT a FROM t\"}");
1205 defer freeOutcome(rows);
1206 try expectOk(rows, "[{\"a\":\"\"}]");
1207}
packages/zig/src/bridge_mobile_notifcancel.zigadded+541-0
Changes to packages/zig/src/bridge_mobile_notifcancel.zig
@@ -0,0 +1,541 @@
1//! The notification-cancel actions of the `mobile` namespace:
2//! `cancelNotification`, `cancelAllNotifications`.
3//!
4//! One JS surface reaches these: the legacy `craft.notifications` object —
5//! `cancel(notificationId)` posts `{action:'cancelNotification',
6//! id: notificationId, callbackId: id}`, `cancelAll()` posts the action alone.
7//! The modern contract-install (`Object.assign` over the legacy object) keeps
8//! both methods unchanged. Neither promise has a timeout — they do not go
9//! through `_createCallback` — so a dropped message parks the page forever,
10//! which is why every path here ends in a reply or an error.
11//!
12//! ## What is carried across exactly, because it is the observable contract
13//!
14//! - **The identifier field is `id`** — not `identifier`, not
15//! `notificationId`. That is what the injected JS posts and what the Swift
16//! shim reads (`body["id"]`), pinned on both sides of the migration. The
17//! `callbackId` also present in the payload is correlation plumbing the
18//! Zig envelope replaces with `i`; it is ignored, not consumed.
19//! - **Replies are the bare fragment `true`**, Swift's
20//! `resolveCallback(callbackId, result: true)` through `.fragmentsAllowed`.
21//! No object wrapper. Consumers discard the value (`craft.d.ts` says
22//! `Promise<void>`); what they rely on is that the promise settles.
23//! - **Pending removal only.** Swift calls
24//! `removePendingNotificationRequests(withIdentifiers:)` /
25//! `removeAllPendingNotificationRequests()` and never touches delivered
26//! notifications — zero "Delivered" calls in `CraftApp.swift`. The desktop
27//! module (`bridge_notification.zig`) *does* also clear delivered ones;
28//! copying that here would make the Zig and Swift handlers observably
29//! different mid-migration, so it is deliberately not copied.
30//! - **Cancelling an unknown or never-scheduled id is a no-op that still
31//! resolves `true`** — the UN call ignores unmatched identifiers, and so
32//! does Swift. Idempotent, like the securestore removes next door.
33//! - **An empty-string `id` passes through.** Swift hands `""` to the UN
34//! center, which matches nothing, and resolves `true`. The desktop module
35//! errors `MissingData` on an empty id; bug-compatibility with the Swift
36//! shim wins here, because the two handlers must be interchangeable while
37//! the shim still exists.
38//!
39//! ## What is deliberately not carried across
40//!
41//! **Swift's silent hang.** The dispatcher arm is
42//! `if config.enableLocalNotifications, let notifId = body["id"] as? String`
43//! with no `else`: a missing `id`, a non-string `id`, or the gate at its
44//! default `false` replies nothing at all, and the untimed promise never
45//! settles. Malformed input errors here instead (`MissingData` /
46//! `InvalidParameter`). The `enableLocalNotifications` gate has no Zig mirror
47//! (it appears nowhere in `packages/zig/src`), so the actions are served
48//! unconditionally — the securestore module documents the same choice, and
49//! cancelling notifications grants a page strictly less than the wipe that
50//! precedent already serves ungated.
51//!
52//! **An `id` with an embedded NUL.** `\u0000` is a legal JSON escape and a
53//! page can send it; the route to the UN center is `stringWithUTF8String:`,
54//! which truncates at the NUL and would cancel a *different* identifier than
55//! the page named, then report `true` for the full one. Refused as
56//! `InvalidParameter`, exactly as `bridge_mobile_storage.requireNulFree` does.
57//!
58//! ## The bundle-identifier guard, first and non-negotiable
59//!
60//! `[UNUserNotificationCenter currentNotificationCenter]` raises
61//! `NSInternalInconsistencyException` in a process without a bundle
62//! identifier, and an uncaught Objective-C exception is an uncatchable
63//! SIGABRT. That is precisely the environment the bare `craft` fixture binary
64//! and the host test runner execute in, so both handlers ask
65//! `requireBundleIdentifier` before anything touches the center — the same
66//! condition `bridge_notification.zig`'s `hasBundleIdentifier` guards on the
67//! desktop. The refusal is an explicit error reply, never a fabricated `true`:
68//! a process that cannot reach the notification center has not cancelled
69//! anything.
70
71const std = ;
72const builtin = ;
73const capabilities = ;
74const bridge_error = ;
75const objc_runtime = ;
76
77const objc = objc_runtime.objc;
78
79/// The same type as `objc.id` — `?*anyopaque` — spelled locally, per the
80/// securestore precedent: `objc_runtime.objc` is an empty struct off Darwin
81/// and signatures are analysed even when a comptime guard prunes the body, so
82/// naming `objc.id` here would break the Linux build. A single optional
83/// pointer, never `?objc.id` — a double optional is illegal in `callconv(.c)`.
84const Id = ?*anyopaque;
85
86/// The action names, spelled exactly as the Swift `case` labels spell them.
87/// `test/ios_conformance_test.zig` matches the two lists by string in both
88/// directions.
89pub const A = struct {
90 pub const cancel_notification = "cancelNotification";
91 pub const cancel_all_notifications = "cancelAllNotifications";
92};
93
94/// Both `.result`: each Swift path resolves a callback, and both JS promises
95/// are the untimed legacy kind — `.none` here would strand a caller forever,
96/// not for thirty seconds.
97///
98/// Both `.live`. Cancelling needs no permission prompt, no completion
99/// handler, no entitlement — removal of the app's own pending requests is a
100/// plain synchronous call — so nothing warrants `.unavailable`.
101pub const capability_actions = [_]capabilities.ActionDecl{
102 .{ .name = A.cancel_notification, .reply = .result },
103 .{ .name = A.cancel_all_notifications, .reply = .result },
104};
105
106/// The one reply shape both actions produce: Swift's
107/// `resolveCallback(callbackId, result: true)` through `.fragmentsAllowed` is
108/// the bare fragment `true`. Static, so replying allocates nothing.
109const true_fragment = "true";
110
111/// Which handler an action selects, split out from `handleMessage` so the
112/// table-versus-dispatch agreement is assertable on a host. The split is
113/// load-bearing for `cancelAllNotifications`: it has no payload and therefore
114/// no validation step, so a test that *called* it to prove routing would — in
115/// a process that does have a bundle identifier — issue a real
116/// `removeAllPendingNotificationRequests` against whatever app runs the tests.
117const Route = enum { cancel_one, cancel_all };
118
119fn routeFor(action: []const u8) ?Route {
120 if (std.mem.eql(u8, action, A.cancel_notification)) return .cancel_one;
121 if (std.mem.eql(u8, action, A.cancel_all_notifications)) return .cancel_all;
122 return null;
123}
124
125pub const NotifCancelBridge = struct {
126 allocator: std.mem.Allocator,
127
128 const Self = @This();
129
130 pub fn init(allocator: std.mem.Allocator) Self {
131 return .{ .allocator = allocator };
132 }
133
134 pub fn deinit(_: *Self) void {}
135
136 pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void {
137 const route = routeFor(action) orelse return bridge_error.BridgeError.UnknownAction;
138 // Exhaustive, so a `Route` without a handler is a compile error.
139 return switch (route) {
140 .cancel_one => self.cancelNotification(data),
141 .cancel_all => self.cancelAllNotifications(),
142 };
143 }
144
145 /// Remove one pending notification request by identifier. Resolves the
146 /// bare `true` whether or not anything matched — Swift's exact semantics.
147 ///
148 /// Validation runs before any Objective-C: a payload the handler must
149 /// refuse is refused identically on every platform, and the host tests
150 /// can pin it without a notification center in sight.
151 fn cancelNotification(self: *Self, data: []const u8) !void {
152 var parsed = try parsePayload(self.allocator, data);
153 defer parsed.deinit();
154
155 const notif_id = try parseId(parsed.value);
156
157 try requireBundleIdentifier();
158 const center = try notificationCenter();
159 try removePending(self.allocator, center, notif_id);
160
161 bridge_error.sendResultToJS(self.allocator, A.cancel_notification, true_fragment);
162 }
163
164 /// Remove every pending notification request. The payload is ignored, as
165 /// Swift ignores the body: the injected JS posts the action alone, and
166 /// `ios_dispatch.payloadOf` hands this `"{}"` for an absent `d`. An
167 /// already-empty queue still resolves `true` — idempotent, as in Swift.
168 fn cancelAllNotifications(self: *Self) !void {
169 try requireBundleIdentifier();
170 const center = try notificationCenter();
171 try removeAllPending(center);
172
173 bridge_error.sendResultToJS(self.allocator, A.cancel_all_notifications, true_fragment);
174 }
175};
176
177/// Parse `d`, distinguishing a bad payload from a failed allocation — telling
178/// the page INVALID_JSON about its own good JSON sends whoever debugs it to
179/// the wrong side of the bridge.
180fn parsePayload(allocator: std.mem.Allocator, data: []const u8) !std.json.Parsed(std.json.Value) {
181 return std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch |err| switch (err) {
182 error.OutOfMemory => return err,
183 else => return bridge_error.BridgeError.InvalidJSON,
184 };
185}
186
187/// The `id` field, or the reason it cannot be used. Pure, so the host tests
188/// can pin every outcome Swift's `as? String` collapsed into a silent hang.
189///
190/// An empty string is *not* refused: Swift passes `""` through to a harmless
191/// UN no-op and resolves `true`, and bug-compatibility with the shim is the
192/// contract mid-migration (the desktop module's `MissingData`-on-empty is the
193/// documented divergence, not this).
194fn parseId(payload: std.json.Value) ![]const u8 {
195 const object = switch (payload) {
196 .object => |o| o,
197 else => return bridge_error.BridgeError.InvalidJSON,
198 };
199 const id_field = object.get("id") orelse return bridge_error.BridgeError.MissingData;
200 const notif_id = switch (id_field) {
201 .string => |s| s,
202 // Swift's `as? String` fails here and replies nothing at all. A
203 // coercion would be worse: it would cancel a stringified something
204 // the page never named and report success.
205 else => return bridge_error.BridgeError.InvalidParameter,
206 };
207 try requireNulFree(notif_id);
208 return notif_id;
209}
210
211/// Refuse an `id` carrying the one byte it cannot survive. `\u0000` is a
212/// legal JSON escape and `std.json` decodes it to the byte, so a page can
213/// reach this; `createNSString` (`stringWithUTF8String:`) would then truncate
214/// and the removal would address a *different* identifier than the caller
215/// gave, while the reply reported `true` for the full one.
216fn requireNulFree(s: []const u8) !void {
217 if (std.mem.indexOfScalar(u8, s, 0) != null) return bridge_error.BridgeError.InvalidParameter;
218}
219
220/// Refuse to go anywhere near `UNUserNotificationCenter` in a process without
221/// a bundle identifier — `currentNotificationCenter` raises
222/// `NSInternalInconsistencyException` there, and an uncaught ObjC exception
223/// is an uncatchable SIGABRT, not an error to map. This is the condition the
224/// bare `craft` fixture binary and the host test runner actually run in.
225fn requireBundleIdentifier() !void {
226 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
227
228 const NSBundle = objc.objc_getClass("NSBundle") orelse return error.ClassNotFound;
229 const sel_main = objc.sel_registerName("mainBundle") orelse return error.SelectorNotFound;
230 const bundle = objc.msgSendId(NSBundle, sel_main) orelse return error.NoBundleIdentifier;
231 const sel_ident = objc.sel_registerName("bundleIdentifier") orelse return error.SelectorNotFound;
232 if (objc.msgSendId(bundle, sel_ident) == null) return error.NoBundleIdentifier;
233}
234
235/// `[UNUserNotificationCenter currentNotificationCenter]`, guarded. A null
236/// class means UserNotifications.framework is not in the process — a link
237/// configuration problem, named rather than crashed on.
238fn notificationCenter() !Id {
239 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
240
241 const UNUserNotificationCenter = objc.objc_getClass("UNUserNotificationCenter") orelse return error.ClassNotFound;
242 const sel_current = objc.sel_registerName("currentNotificationCenter") orelse return error.SelectorNotFound;
243 const center = objc.msgSendId(UNUserNotificationCenter, sel_current) orelse return error.NativeCallFailed;
244 return center;
245}
246
247/// `removePendingNotificationRequests(withIdentifiers:)` with a one-element
248/// array, matching Swift's `[id]`. Synchronous, void, no completion handler.
249fn removePending(allocator: std.mem.Allocator, center: Id, notif_id: []const u8) !void {
250 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
251
252 // `createNSString` can fail (allocation, missing class) *and* can hand
253 // back nil — `stringWithUTF8String:` returns nil for bytes it rejects.
254 // `std.json` already validated UTF-8 and the NUL check ran, so a nil here
255 // should not fire, but unchecked it would be an uncatchable
256 // NSInvalidArgumentException inside `arrayWithObject:`.
257 const ns_id = try objc.createNSString(notif_id, allocator);
258 if (ns_id == null) return error.StringCreationFailed;
259
260 const NSArray = objc.objc_getClass("NSArray") orelse return error.ClassNotFound;
261 const sel_array = objc.sel_registerName("arrayWithObject:") orelse return error.SelectorNotFound;
262 const identifiers = objc.msgSendId1(NSArray, sel_array, ns_id) orelse return error.NativeCallFailed;
263
264 const sel_remove = objc.sel_registerName("removePendingNotificationRequestsWithIdentifiers:") orelse return error.SelectorNotFound;
265 objc.msgSendVoid1(center, sel_remove, identifiers);
266}
267
268/// `removeAllPendingNotificationRequests` — void, zero arguments.
269fn removeAllPending(center: Id) !void {
270 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
271
272 const sel_remove_all = objc.sel_registerName("removeAllPendingNotificationRequests") orelse return error.SelectorNotFound;
273 objc.msgSend(center, sel_remove_all);
274}
275
276// =============================================================================
277// Tests — host-only. Everything that decides what the page sees (routing,
278// field names, refusal reasons, the reply fragment) is pinned as pure logic,
279// and the one Objective-C path a host can reach — the bundle-identifier
280// refusal — is exercised for real, because the test runner is exactly the
281// bundle-less process the guard exists for. The UN removal calls themselves
282// are never made from here: in a process that *did* have a bundle identifier
283// they would remove that app's actual pending notifications.
284// =============================================================================
285
286const testing = std.testing;
287
288test "the declared actions are the ones the handler serves" {
289 try testing.expectEqual(@as(usize, 2), capability_actions.len);
290 try testing.expectEqualStrings(A.cancel_notification, capability_actions[0].name);
291 try testing.expectEqualStrings(A.cancel_all_notifications, capability_actions[1].name);
292
293 for (capability_actions) |decl| {
294 try testing.expectEqual(capabilities.Reply.result, decl.reply);
295 try testing.expectEqual(capabilities.ActionStatus.live, decl.status);
296 }
297}
298
299test "the action names match the Swift case labels exactly" {
300 // The conformance ratchet compares these against the `case "…":` labels in
301 // `CraftApp.swift` in both directions; a prettier spelling would read as
302 // Zig serving an action the spec does not have.
303 try testing.expectEqualStrings("cancelNotification", A.cancel_notification);
304 try testing.expectEqualStrings("cancelAllNotifications", A.cancel_all_notifications);
305}
306
307test "every declared action is one the dispatcher routes" {
308 for (capability_actions) |decl| {
309 if (routeFor(decl.name) == null) {
310 std.debug.print("declared action '{s}' does not route\n", .{decl.name});
311 return error.DeclaredActionDoesNotRoute;
312 }
313 }
314}
315
316test "every route the dispatcher has is a declared action" {
317 // The other direction: a route with a handler and no declaration is an
318 // action the page can call and the manifest denies exists. Each
319 // declaration must claim a *distinct* route — counting alone would let two
320 // rows share one route while another went undeclared.
321 var claimed = std.mem.zeroes([std.enums.values(Route).len]bool);
322 for (capability_actions) |decl| {
323 const route = routeFor(decl.name) orelse return error.DeclaredActionDoesNotRoute;
324 const slot = @backingInt(route);
325 if (claimed[slot]) {
326 std.debug.print("two declarations route to {s}\n", .{@tagName(route)});
327 return error.TwoDeclarationsShareARoute;
328 }
329 claimed[slot] = true;
330 }
331 for (claimed, 0..) |taken, slot| {
332 if (!taken) {
333 std.debug.print(
334 "route {s} has a handler but no capability_actions row\n",
335 .{@tagName(@as(Route, @fromBackingInt(@intCast(slot))))},
336 );
337 return error.RouteNotDeclared;
338 }
339 }
340}
341
342test "an action the namespace does not serve is reported, not ignored" {
343 var bridge = NotifCancelBridge.init(testing.allocator);
344 defer bridge.deinit();
345
346 try testing.expectError(
347 bridge_error.BridgeError.UnknownAction,
348 bridge.handleMessage("noSuchAction", "{}"),
349 );
350 // Near misses — casing is how a real typo arrives.
351 try testing.expectError(
352 bridge_error.BridgeError.UnknownAction,
353 bridge.handleMessage("cancelnotification", "{}"),
354 );
355 // The JS surface method names, which are not action names.
356 try testing.expectError(
357 bridge_error.BridgeError.UnknownAction,
358 bridge.handleMessage("cancel", "{}"),
359 );
360 try testing.expectError(
361 bridge_error.BridgeError.UnknownAction,
362 bridge.handleMessage("cancelAll", "{}"),
363 );
364 // The Swift *helper* name, which is not a dispatcher case.
365 try testing.expectError(
366 bridge_error.BridgeError.UnknownAction,
367 bridge.handleMessage("cancelLocalNotification", "{}"),
368 );
369 // The neighbouring notification actions this module must not claim: two
370 // modules answering one action would make `ios_dispatch`'s first-match
371 // routing order-dependent.
372 try testing.expectError(
373 bridge_error.BridgeError.UnknownAction,
374 bridge.handleMessage("scheduleNotification", "{}"),
375 );
376 try testing.expectError(
377 bridge_error.BridgeError.UnknownAction,
378 bridge.handleMessage("requestNotificationPermission", "{}"),
379 );
380}
381
382test "a malformed payload is reported as bad JSON, not as a missing field" {
383 var bridge = NotifCancelBridge.init(testing.allocator);
384 defer bridge.deinit();
385
386 try testing.expectError(
387 bridge_error.BridgeError.InvalidJSON,
388 bridge.handleMessage(A.cancel_notification, "{not json"),
389 );
390}
391
392fn expectIdError(json: []const u8, expected: anyerror) !void {
393 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
394 defer parsed.deinit();
395 try testing.expectError(expected, parseId(parsed.value));
396}
397
398test "the field name the page sends is the one that is read" {
399 // `{action:'cancelNotification', id: notificationId, callbackId: id}` in
400 // the injected JS; the shim reads `body["id"]`. A rename on either side of
401 // the migration would make the two handlers read different payloads.
402 var parsed = try std.json.parseFromSlice(
403 std.json.Value,
404 testing.allocator,
405 "{\"id\":\"notif-42\",\"callbackId\":\"cb_7\"}",
406 .{},
407 );
408 defer parsed.deinit();
409 try testing.expectEqualStrings("notif-42", try parseId(parsed.value));
410
411 // The plausible wrong names are *absences*, not aliases — a payload
412 // carrying only them must read as missing, or a handler bound to the
413 // wrong name would pass this suite.
414 try expectIdError("{\"identifier\":\"n\"}", bridge_error.BridgeError.MissingData);
415 try expectIdError("{\"notificationId\":\"n\"}", bridge_error.BridgeError.MissingData);
416}
417
418test "a missing id is refused rather than defaulted" {
419 // Swift's `if let notifId = body["id"] as? String` fails here and replies
420 // nothing at all — the legacy promise has no timeout, so that is a hang
421 // forever. An error is the deliberate divergence; a default would be
422 // worse still, cancelling an identifier the page never named.
423 var bridge = NotifCancelBridge.init(testing.allocator);
424 defer bridge.deinit();
425
426 try testing.expectError(
427 bridge_error.BridgeError.MissingData,
428 bridge.handleMessage(A.cancel_notification, "{}"),
429 );
430}
431
432test "a non-string id is refused, not coerced" {
433 try expectIdError("{\"id\":7}", bridge_error.BridgeError.InvalidParameter);
434 try expectIdError("{\"id\":null}", bridge_error.BridgeError.InvalidParameter);
435 try expectIdError("{\"id\":[\"n\"]}", bridge_error.BridgeError.InvalidParameter);
436}
437
438test "an id with an embedded NUL is refused, not truncated" {
439 // `stringWithUTF8String:` stops at the first NUL, so an unchecked
440 // "a\u0000b" would cancel the request named "a" while the reply reported
441 // `true` for the full identifier the page sent.
442 try expectIdError("{\"id\":\"a\\u0000b\"}", bridge_error.BridgeError.InvalidParameter);
443}
444
445test "an empty id is accepted, matching the Swift shim" {
446 // Swift hands "" to the UN center — a harmless no-op — and resolves
447 // `true`. The desktop module refuses an empty id; interchangeability with
448 // the shim is the contract here, so the parser passes it through and the
449 // module comment carries the divergence.
450 var parsed = try std.json.parseFromSlice(
451 std.json.Value,
452 testing.allocator,
453 "{\"id\":\"\"}",
454 .{},
455 );
456 defer parsed.deinit();
457 try testing.expectEqualStrings("", try parseId(parsed.value));
458}
459
460test "a payload that is not an object is bad JSON, not a missing field" {
461 try expectIdError("[]", bridge_error.BridgeError.InvalidJSON);
462 try expectIdError("\"notif-42\"", bridge_error.BridgeError.InvalidJSON);
463}
464
465test "the reply is the bare fragment Swift's .fragmentsAllowed produces" {
466 // `resolveCallback(callbackId, result: true)` serialises to the JSON
467 // fragment `true` — not `{"success":true}`, which would change what a
468 // caller inspecting the resolved value sees.
469 try testing.expectEqualStrings("true", true_fragment);
470}
471
472test "off Darwin the handlers refuse rather than fake success" {
473 if (builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
474
475 var bridge = NotifCancelBridge.init(testing.allocator);
476 defer bridge.deinit();
477
478 // A valid payload, so the refusal is the platform's and not the parser's.
479 try testing.expectError(
480 error.UnsupportedPlatform,
481 bridge.handleMessage(A.cancel_notification, "{\"id\":\"n1\"}"),
482 );
483 try testing.expectError(
484 error.UnsupportedPlatform,
485 bridge.handleMessage(A.cancel_all_notifications, "{}"),
486 );
487}
488
489test "without a bundle identifier the guard fires before the center is touched" {
490 if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
491
492 // The host test runner is a bare binary with no Info.plist — exactly the
493 // process in which `currentNotificationCenter` would SIGABRT. The guard
494 // must turn that into an error reply, and it must fire for both actions.
495 // If these expectations are ever *reached* and fail, the guard has been
496 // reordered behind the center call and this test will have crashed the
497 // runner instead — which is still a loud answer.
498 if (requireBundleIdentifier()) |_| {
499 // A runner that does carry a bundle identifier (tests hosted inside
500 // an app) would make the calls below real removals against that
501 // app's pending notifications; proving the guard needs the bundle-less
502 // environment, so skip rather than pretend.
503 return error.SkipZigTest;
504 } else |err| switch (err) {
505 error.NoBundleIdentifier => {
506 var bridge = NotifCancelBridge.init(testing.allocator);
507 defer bridge.deinit();
508
509 try testing.expectError(
510 error.NoBundleIdentifier,
511 bridge.handleMessage(A.cancel_notification, "{\"id\":\"conformance-probe\"}"),
512 );
513 try testing.expectError(
514 error.NoBundleIdentifier,
515 bridge.handleMessage(A.cancel_all_notifications, "{}"),
516 );
517 },
518 else => return err,
519 }
520}
521
522test "validation outruns Objective-C on every platform" {
523 // The same refusals with and without a runtime behind them: a payload the
524 // handler must refuse is refused identically on Linux and macOS, which is
525 // what makes the parser tests above binding for the device build too.
526 var bridge = NotifCancelBridge.init(testing.allocator);
527 defer bridge.deinit();
528
529 try testing.expectError(
530 bridge_error.BridgeError.MissingData,
531 bridge.handleMessage(A.cancel_notification, "{}"),
532 );
533 try testing.expectError(
534 bridge_error.BridgeError.InvalidParameter,
535 bridge.handleMessage(A.cancel_notification, "{\"id\":42}"),
536 );
537 try testing.expectError(
538 bridge_error.BridgeError.InvalidParameter,
539 bridge.handleMessage(A.cancel_notification, "{\"id\":\"a\\u0000b\"}"),
540 );
541}
packages/zig/src/ios_dispatch.zigmodified+4-0
Changes to packages/zig/src/ios_dispatch.zig
@@ -16,6 +16,8 @@ const bridge_mobile_shortcuts = ;
1616const bridge_mobile_securestore = ;
1717const bridge_mobile_biometric = ;
1818const bridge_mobile_permissions = ;
19const bridge_mobile_db = ;
20const bridge_mobile_notifcancel = ;
1921
2022const objc = objc_runtime.objc;
2123
@@ -213,6 +215,8 @@ const mobile_bridges = .{
213215 bridge_mobile_securestore.SecureStoreBridge,
214216 bridge_mobile_biometric.BiometricStoreBridge,
215217 bridge_mobile_permissions.PermissionsBridge,
218 bridge_mobile_db.DbBridge,
219 bridge_mobile_notifcancel.NotifCancelBridge,
216220};
217221
218222/// Narrow an arbitrary handler error to one the page's error codes can express.
packages/zig/test/ios_conformance_test.zigmodified+6-2
Changes to packages/zig/test/ios_conformance_test.zig
@@ -36,6 +36,8 @@ const zig_sources = [_][]const u8{
3636 @embedFile("src/bridge_mobile_securestore.zig"),
3737 @embedFile("src/bridge_mobile_biometric.zig"),
3838 @embedFile("src/bridge_mobile_permissions.zig"),
39 @embedFile("src/bridge_mobile_db.zig"),
40 @embedFile("src/bridge_mobile_notifcancel.zig"),
3941};
4042
4143/// The action list lives in the `switch action` block, and nowhere else.
@@ -61,8 +63,10 @@ const dispatch_end = "func webView(";
6163/// History: 105 after the vertical slice (getDeviceInfo); 86 after Tier 0
6264/// landed clipboard, haptics, device, system, display, and storage; 74 after
6365/// the secure tier landed flashlight, shortcuts, the Keychain secure store,
64/// biometric persistence, and permissions — including the first async reply.
65const max_not_yet_migrated: usize = 74;
66/// biometric persistence, and permissions — including the first async reply;
67/// 70 after the data tier landed SQLite (dbExecute/dbQuery) and the
68/// notification cancels.
69const max_not_yet_migrated: usize = 70;
6670
6771fn dispatcherRegion() []const u8 {
6872 const begin = std.mem.indexOf(u8, swift_spec, dispatch_begin) orelse return "";