ReviewOS

also looking at this

craft-native/craft

feat(ios): scheduleNotification — the deferral that expired on its own, ratchet 26 → 25

#110
Merged glennmichael123 wants to merge feat/ios-schedule-notification into main
6 files +922 -37
.github/workflows/ci.ymlmodified+29-1
Changes to .github/workflows/ci.yml
@@ -109,6 +109,34 @@ jobs:
109109 working-directory: packages/typescript
110110 run: bun run build
111111
112 # `packages/ios` had a test suite that nothing ran. That is how its
113 # generator kept a defect nobody saw: `init()` read `CRAFT_IOS_RUNTIME`
114 # straight from the environment, so the suite passed 12/12 in a clean shell
115 # and 8/12 in a developer's, and CI never had an opinion either way.
116 ios-builder:
117 runs-on: ubuntu-latest
118
119 steps:
120 - uses: actions/checkout@v6
121
122 - name: Setup Pantry (provides Bun, Zig, etc.)
123 uses: pantry-pm/pantry/packages/action@235036fa0f48bae99b2293df5a3dc35c809b1777 # pinned: last SHA whose bundled typescript resolves on linux-x64
124
125 - name: Install dependencies
126 run: bun install
127
128 # Unset deliberately. The suite must not depend on it, and this is the
129 # job that would notice if it started to again.
130 - name: Test iOS builder
131 working-directory: packages/ios
132 run: bun test
133 env:
134 CRAFT_IOS_RUNTIME: ''
135
136 - name: Build iOS builder
137 working-directory: packages/ios
138 run: bun run build
139
112140 create-craft:
113141 runs-on: ubuntu-latest
114142
@@ -219,7 +247,7 @@ jobs:
219247 run: bun run verify
220248
221249 publish-commit:
222 needs: [zig-core, typescript-sdk, create-craft, ts-maps, lint, integration]
250 needs: [zig-core, typescript-sdk, ios-builder, create-craft, ts-maps, lint, integration]
223251 runs-on: ubuntu-latest
224252
225253 steps:
packages/ios/src/index.test.tsmodified+144-1
Changes to packages/ios/src/index.test.ts
@@ -5,6 +5,9 @@ import { describe, expect, it } from 'bun:test'
55import {
66 build,
77 init,
8 installRuntime,
9 renderRuntimeSettings,
10 resolveRuntimeDir,
811 orderSimulators,
912 renderBackgroundModes,
1013 renderEntitlements,
@@ -83,6 +86,7 @@ describe('Craft iOS builder', () => {
8386 it('generates a production project whose bundled index lives under dist', async () => {
8487 const output = mkdtempSync(join(tmpdir(), 'craft-ios-project-'))
8588 await init({
89 runtimeDir: null,
8690 name: 'WildLoop',
8791 bundleId: 'org.wildloop.app',
8892 output,
@@ -127,7 +131,7 @@ describe('Craft iOS builder', () => {
127131 const output = join(root, 'ios')
128132 Bun.spawnSync(['mkdir', '-p', web])
129133 writeFileSync(join(web, 'index.html'), '<main>Available offline</main>')
130 await init({ name: 'WildLoop', bundleId: 'org.wildloop.app', output })
134 await init({ runtimeDir: null, name: 'WildLoop', bundleId: 'org.wildloop.app', output })
131135 await build({ htmlPath: web, devServer: 'https://wildloop.org', output, generateProject: false })
132136
133137 const swift = readFileSync(join(output, 'Sources', 'WildLoopApp.swift'), 'utf8')
@@ -138,6 +142,7 @@ describe('Craft iOS builder', () => {
138142 it('generates a native Live Activity extension when enabled', async () => {
139143 const output = mkdtempSync(join(tmpdir(), 'craft-ios-live-activity-'))
140144 await init({
145 runtimeDir: null,
141146 name: 'WildLoop',
142147 bundleId: 'org.wildloop.app',
143148 output,
@@ -161,6 +166,7 @@ describe('Craft iOS builder', () => {
161166 it('generates an embedded watchOS companion when enabled', async () => {
162167 const output = mkdtempSync(join(tmpdir(), 'craft-ios-watch-'))
163168 await init({
169 runtimeDir: null,
164170 name: 'WildLoop',
165171 bundleId: 'org.wildloop.app',
166172 output,
@@ -243,3 +249,140 @@ describe('choosing a simulator', () => {
243249 expect(devices[0]?.name).toBe('iPad Air')
244250 })
245251})
252
253describe('Zig runtime installation', () => {
254 // A runtime directory holding *one* simulator slice, so `installRuntime`
255 // takes the `cpSync` branch. The two-slice branch shells out to `lipo`,
256 // which needs genuine Mach-O input and does not exist off macOS — it is
257 // covered by building a real generated app, not from here. What these do
258 // cover is everything around it: resolution, ordering, and the warning.
259 function fakeRuntime(archives = ['libcraft-ios.a', 'libcraft-ios-simulator-arm64.a']): string {
260 const dir = mkdtempSync(join(tmpdir(), 'craft-rt-'))
261 for (const a of archives) writeFileSync(join(dir, a), 'stand-in for an archive')
262 return dir
263 }
264
265 it('resolves an explicit directory, and null means no runtime whatever the environment says', () => {
266 const dir = fakeRuntime()
267 const saved = process.env.CRAFT_IOS_RUNTIME
268 process.env.CRAFT_IOS_RUNTIME = dir
269 try {
270 // The gap this closes: the suite used to inherit the developer's shell,
271 // and went from 12 passing to 8 passing and 4 failing when this variable
272 // happened to be set.
273 expect(resolveRuntimeDir(null)).toBeNull()
274 expect(resolveRuntimeDir(dir)).toBe(dir)
275 expect(resolveRuntimeDir()).toBe(dir)
276 }
277 finally {
278 if (saved === undefined) delete process.env.CRAFT_IOS_RUNTIME
279 else process.env.CRAFT_IOS_RUNTIME = saved
280 }
281 })
282
283 it('names the source in the error, so a bad path says which knob set it', () => {
284 expect(() => resolveRuntimeDir('/no/such/runtime')).toThrow(/runtimeDir is/)
285 })
286
287 it('writes one archive name per SDK, which is what a single -lcraft-ios needs', async () => {
288 const output = mkdtempSync(join(tmpdir(), 'craft-out-'))
289 expect(await installRuntime(output, fakeRuntime())).toBe(true)
290 expect(existsSync(join(output, 'Runtime', 'device', 'libcraft-ios.a'))).toBe(true)
291 expect(existsSync(join(output, 'Runtime', 'simulator', 'libcraft-ios.a'))).toBe(true)
292 })
293
294 it('warns when only one simulator slice is present, rather than shipping it silently', async () => {
295 const warnings: string[] = []
296 const saved = console.warn
297 console.warn = (...args: unknown[]) => void warnings.push(args.join(' '))
298 try {
299 await installRuntime(mkdtempSync(join(tmpdir(), 'craft-out-')), fakeRuntime())
300 }
301 finally {
302 console.warn = saved
303 }
304 // RUNTIME_ARCHIVES' own comment calls this the failure that "only shows up
305 // on someone else's laptop", so it must not be silent.
306 expect(warnings.join('\n')).toContain('libcraft-ios-simulator-x64.a')
307 })
308
309 it('leaves a working install alone when the source directory is incomplete', async () => {
310 // The ordering bug: validation ran after the wipe, so a bad runtime dir
311 // destroyed the archives already in place and left project.yml linking
312 // against a Runtime/ that no longer existed.
313 const output = mkdtempSync(join(tmpdir(), 'craft-out-'))
314 await installRuntime(output, fakeRuntime())
315 const installed = join(output, 'Runtime', 'device', 'libcraft-ios.a')
316 expect(existsSync(installed)).toBe(true)
317
318 const empty = mkdtempSync(join(tmpdir(), 'craft-rt-empty-'))
319 await expect(installRuntime(output, empty)).rejects.toThrow(/has none of/)
320 expect(existsSync(installed)).toBe(true)
321 })
322
323 it('renders both SDK search paths and forces the four entry points', () => {
324 const settings = renderRuntimeSettings()
325 expect(settings).toContain('LIBRARY_SEARCH_PATHS[sdk=iphoneos*]')
326 expect(settings).toContain('LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]')
327 // Without -u the linker drops the whole archive as unreachable, because
328 // nothing in the Swift references these symbols — both seams use dlsym.
329 for (const sym of ['handle_action', 'set_webview', 'deliver_result', 'deliver_error']) {
330 expect(settings).toContain(`-Wl,-u,_craft_ios_${sym}`)
331 }
332 // Six-space indent: this is spliced into project.yml under `settings:`.
333 for (const line of settings.split('\n')) expect(line.startsWith(' ')).toBe(true)
334 })
335
336 it('generates a project whose settings match whether a runtime was installed', async () => {
337 const output = mkdtempSync(join(tmpdir(), 'craft-app-'))
338 await init({ runtimeDir: fakeRuntime(), name: 'HasRuntime', output })
339 expect(readFileSync(join(output, 'project.yml'), 'utf8')).toContain('-lcraft-ios')
340
341 // And re-running without one leaves no orphaned archives claiming otherwise.
342 await init({ runtimeDir: null, name: 'HasRuntime', output })
343 expect(readFileSync(join(output, 'project.yml'), 'utf8')).not.toContain('-lcraft-ios')
344 expect(existsSync(join(output, 'Runtime'))).toBe(false)
345 })
346})
347
348describe('runtime staleness', () => {
349 function runtimeWith(marker: string): string {
350 const dir = mkdtempSync(join(tmpdir(), 'craft-rt-'))
351 for (const a of ['libcraft-ios.a', 'libcraft-ios-simulator-arm64.a']) {
352 writeFileSync(join(dir, a), marker)
353 }
354 return dir
355 }
356
357 it('build picks up a rebuilt runtime instead of relinking the one init copied', async () => {
358 const output = mkdtempSync(join(tmpdir(), 'craft-app-'))
359 await init({ runtimeDir: runtimeWith('first build'), name: 'Stale', output })
360 const installed = join(output, 'Runtime', 'device', 'libcraft-ios.a')
361 expect(readFileSync(installed, 'utf8')).toBe('first build')
362
363 // The dev loop: edit Zig, rebuild the archives, run the app again. Before
364 // this, xcodebuild relinked the copy from init and the change was absent
365 // with no error anywhere.
366 await build({ output, runtimeDir: runtimeWith('second build'), generateProject: false })
367 expect(readFileSync(installed, 'utf8')).toBe('second build')
368 })
369
370 it('build leaves a runtimeless project alone rather than installing one behind init', async () => {
371 const output = mkdtempSync(join(tmpdir(), 'craft-app-'))
372 await init({ runtimeDir: null, name: 'NoRuntime', output })
373 expect(existsSync(join(output, 'Runtime'))).toBe(false)
374
375 // A runtime is available, but this project does not link one: its
376 // project.yml has no link settings, so archives here would be dead weight.
377 await build({ output, runtimeDir: runtimeWith('ignored'), generateProject: false })
378 expect(existsSync(join(output, 'Runtime'))).toBe(false)
379 })
380
381 it('build keeps the installed runtime when no runtime directory is configured', async () => {
382 const output = mkdtempSync(join(tmpdir(), 'craft-app-'))
383 await init({ runtimeDir: runtimeWith('from init'), name: 'Keep', output })
384 // A shell that forgot the variable must not quietly turn the runtime off.
385 await build({ output, runtimeDir: null, generateProject: false })
386 expect(readFileSync(join(output, 'Runtime', 'device', 'libcraft-ios.a'), 'utf8')).toBe('from init')
387 })
388})
packages/ios/src/index.tsmodified+99-10
Changes to packages/ios/src/index.ts
@@ -91,6 +91,20 @@ export interface InitOptions {
9191 teamId?: string
9292 output: string
9393 config?: Partial<CraftConfig>
94 /**
95 * Where the Zig runtime archives live, overriding `CRAFT_IOS_RUNTIME`.
96 *
97 * `null` means "no runtime, whatever the environment says" — the shape
98 * `AppConfig.craftPath` has for `CRAFT_BIN`, and the reason it exists here
99 * is the same: a caller that does not opt in should not be steered by an
100 * ambient variable. Without it this package's own tests inherited whatever
101 * the developer's shell exported, and went from 12 passing to 8 passing and
102 * 4 failing when `CRAFT_IOS_RUNTIME` happened to be set.
103 *
104 * Omitted (`undefined`) keeps the environment lookup, which is what the
105 * monorepo dev loop uses.
106 */
107 runtimeDir?: string | null
94108}
95109
96110export interface BuildOptions {
@@ -98,6 +112,12 @@ export interface BuildOptions {
98112 devServer?: string
99113 output: string
100114 generateProject?: boolean
115 /**
116 * Where to re-read the Zig runtime archives from, overriding
117 * `CRAFT_IOS_RUNTIME`. Same meaning as `InitOptions.runtimeDir`; `null`
118 * leaves whatever `init` installed exactly as it is.
119 */
120 runtimeDir?: string | null
101121}
102122
103123export interface OpenOptions {
@@ -380,12 +400,18 @@ export function syncWebAssets(source: string, output: string): void {
380400 * dev loop, not a lookup path. Shipping the runtime to real apps means putting
381401 * these archives in the pantry package beside the `craft` binary, which is a
382402 * distribution decision this function does not make.
403 *
404 * `override` is what `InitOptions.runtimeDir` passes: a path to use instead of
405 * the variable, or `null` to declare there is no runtime regardless of what the
406 * environment says. `undefined` falls through to `CRAFT_IOS_RUNTIME`.
383407 */
384export function resolveRuntimeDir(): string | null {
385 const dir = process.env.CRAFT_IOS_RUNTIME
408export function resolveRuntimeDir(override?: string | null): string | null {
409 if (override === null) return null
410 const dir = override ?? process.env.CRAFT_IOS_RUNTIME
386411 if (!dir) return null
387412 if (!existsSync(dir)) {
388 throw new Error(`CRAFT_IOS_RUNTIME points at ${dir}, which does not exist.`)
413 const source = override === undefined ? 'CRAFT_IOS_RUNTIME points at' : 'runtimeDir is'
414 throw new Error(`${source} ${dir}, which does not exist.`)
389415 }
390416 return dir
391417}
@@ -411,23 +437,44 @@ const RUNTIME_ARCHIVES = {
411437 * Returns true when a runtime was installed.
412438 */
413439export async function installRuntime(output: string, runtimeDir: string): Promise<boolean> {
414 const dest = join(output, 'Runtime')
415 rmSync(dest, { recursive: true, force: true })
416
417 for (const [sdk, archives] of Object.entries(RUNTIME_ARCHIVES)) {
440 // Every SDK resolved before anything is written, and long before the
441 // `rmSync` below. The check used to run per-SDK *after* the wipe, so a
442 // runtime directory missing an archive destroyed a working install on its
443 // way to throwing, and left `project.yml` linking `-lcraft-ios` against a
444 // `Runtime/` that no longer existed.
445 const resolved = Object.entries(RUNTIME_ARCHIVES).map(([sdk, archives]) => {
418446 const present = archives.filter(a => existsSync(join(runtimeDir, a)))
419447 if (present.length === 0) {
420448 throw new Error(
421 `CRAFT_IOS_RUNTIME=${runtimeDir} has none of ${archives.join(', ')}. `
449 `${runtimeDir} has none of ${archives.join(', ')}. `
422450 + `Run \`zig build build-ios-all\` in packages/zig and point at its zig-out/lib.`,
423451 )
424452 }
453 return { sdk, archives, present }
454 })
455
456 const dest = join(output, 'Runtime')
457 rmSync(dest, { recursive: true, force: true })
425458
459 for (const { sdk, archives, present } of resolved) {
426460 const sdkDir = join(dest, sdk)
427461 mkdirSync(sdkDir, { recursive: true })
428462 const target = join(sdkDir, 'libcraft-ios.a')
429463
430464 if (present.length === 1) {
465 // Said out loud, because the comment on RUNTIME_ARCHIVES describes this
466 // exact outcome as the thing to avoid: a single-slice simulator archive
467 // links on the machine that built it and fails on the other kind, which
468 // is a break that only ever shows up on someone else's laptop. Copying
469 // is still better than refusing — a one-arch dev loop is legitimate —
470 // but it should not happen silently.
471 const missing = archives.filter(a => !present.includes(a))
472 if (missing.length > 0) {
473 console.warn(
474 ` ⚠ ${sdk}: only ${present[0]} was found; ${missing.join(', ')} is missing. `
475 + `The generated project will not link on the other architecture.`,
476 )
477 }
431478 cpSync(join(runtimeDir, present[0]!), target)
432479 }
433480 else {
@@ -439,6 +486,37 @@ export async function installRuntime(output: string, runtimeDir: string): Promis
439486 return true
440487}
441488
489/**
490 * Re-copy the Zig runtime into a project that already links one.
491 *
492 * The archives are *copied* by `init`, not symlinked, so without this a
493 * rebuilt runtime never reached the app: edit `packages/zig/src`, run `zig
494 * build build-ios-all`, then `craft ios run`, and xcodebuild happily relinks
495 * the archive that was copied when the project was first generated. The build
496 * succeeds, the app launches, and the change is simply absent — the worst
497 * shape a stale artefact can take, because there is no error to chase.
498 *
499 * Only refreshes; never installs and never removes. A project with no
500 * `Runtime/` was generated without a runtime and its `project.yml` carries no
501 * link settings, so copying archives in would leave them unreferenced — that
502 * decision belongs to `init`. And a project that *does* link one keeps working
503 * from the archives it already has when no runtime directory is configured,
504 * because a shell that forgot the variable should not quietly turn the runtime
505 * off.
506 */
507async function refreshRuntime(output: string, override?: string | null): Promise<void> {
508 if (!existsSync(join(output, 'Runtime'))) return
509
510 const dir = resolveRuntimeDir(override)
511 if (!dir) {
512 console.log(' Keeping the Zig runtime installed at init (no runtime directory configured)')
513 return
514 }
515
516 await installRuntime(output, dir)
517 console.log(' Refreshed the Zig runtime from', dir)
518}
519
442520export async function init(options: InitOptions): Promise<void> {
443521 const { name, bundleId, teamId, output } = options
444522
@@ -533,9 +611,18 @@ export async function init(options: InitOptions): Promise<void> {
533611
534612 // The Zig runtime, when this build has one. Installed before the project is
535613 // rendered because the settings below name the directories it creates.
536 const runtimeDir = resolveRuntimeDir()
614 const runtimeDir = resolveRuntimeDir(options.runtimeDir)
537615 const hasRuntime = runtimeDir ? await installRuntime(output, runtimeDir) : false
538 if (hasRuntime) console.log(' Linked the Zig runtime from', runtimeDir)
616 if (hasRuntime) {
617 console.log(' Linked the Zig runtime from', runtimeDir)
618 }
619 else {
620 // Re-running init without a runtime used to strip the link settings from
621 // project.yml and leave the archives behind, so the directory said one
622 // thing and the project said another. Whatever this run decides, the tree
623 // agrees with it.
624 rmSync(join(output, 'Runtime'), { recursive: true, force: true })
625 }
539626
540627 const projectYml = projectYmlTemplate
541628 .replace(/\{\{CRAFT_RUNTIME_SETTINGS\}\}/g, hasRuntime ? renderRuntimeSettings() : '')
@@ -656,6 +743,8 @@ export async function build(options: BuildOptions): Promise<void> {
656743 console.log(` Synced: ${htmlPath} → dist/`)
657744 }
658745
746 await refreshRuntime(output, options.runtimeDir)
747
659748 if (!generateProject) return
660749
661750 // Generate Xcode project using xcodegen
packages/zig/src/bridge_mobile_notifications.zigmodified+641-24
Changes to packages/zig/src/bridge_mobile_notifications.zig
@@ -118,6 +118,7 @@ const bridge_error = ;
118118const objc_runtime = ;
119119const ios_async = ;
120120const compat_mutex = ;
121const memory = ;
121122
122123const objc = objc_runtime.objc;
123124const is_darwin = builtin.target.os.tag.isDarwin();
@@ -147,6 +148,7 @@ const Id = ?*anyopaque;
147148/// scheduleNotification is left to the shim rather than claimed and refused.
148149pub const A = struct {
149150 pub const get_pending_notifications = "getPendingNotifications";
151 pub const schedule_notification = "scheduleNotification";
150152};
151153
152154/// `.result`: Swift resolves the callback with the array, and the JS promise is
@@ -158,15 +160,20 @@ pub const A = struct {
158160/// `.unavailable`.
159161pub const capability_actions = [_]capabilities.ActionDecl{
160162 .{ .name = A.get_pending_notifications, .reply = .result },
163 // `.result`: the spec resolves the identifier string, and the page's
164 // promise is the untimed legacy kind. `.live`, because the reason this was
165 // absent is gone — see the module comment.
166 .{ .name = A.schedule_notification, .reply = .result },
161167};
162168
163169/// Which handler an action selects, split out from `handleMessage` so the
164170/// table-versus-dispatch agreement is assertable on a host without issuing the
165171/// Objective-C call the handler exists to make.
166const Route = enum { get_pending };
172const Route = enum { get_pending, schedule };
167173
168174fn routeFor(action: []const u8) ?Route {
169175 if (std.mem.eql(u8, action, A.get_pending_notifications)) return .get_pending;
176 if (std.mem.eql(u8, action, A.schedule_notification)) return .schedule;
170177 return null;
171178}
172179
@@ -186,6 +193,7 @@ pub const NotificationsBridge = struct {
186193 // Exhaustive, so a `Route` without a handler is a compile error.
187194 return switch (route) {
188195 .get_pending => self.getPendingNotifications(data),
196 .schedule => self.scheduleNotification(data),
189197 };
190198 }
191199
@@ -227,6 +235,41 @@ pub const NotificationsBridge = struct {
227235 const func: Fn = @ptrCast(&objc.objc_msgSend);
228236 func(center, sel_get, arrayBlock(ticket));
229237 }
238
239 /// Schedule one local notification, and answer with its identifier.
240 ///
241 /// Two stages, exactly as the spec has them: ask for authorization, and
242 /// only on a grant build the request and add it. The content and the
243 /// request are built *inside* the authorization completion, which is where
244 /// the spec builds them — so the autoreleased request never has to survive
245 /// a completion boundary, and nothing needs retaining. What does survive is
246 /// the identifier, which the second stage answers with; it is owned heap
247 /// memory parked on the slot.
248 ///
249 /// Every fallible step runs before `ios_async.acquire`, per this file's
250 /// existing rule: no error path may sit between leasing a slot and handing
251 /// a block to the framework, because a failure there would have to release
252 /// the slot by hand and a missed release narrows the pool permanently.
253 fn scheduleNotification(self: *Self, data: []const u8) !void {
254 if (!is_darwin) return error.UnsupportedPlatform;
255
256 try requireBundleIdentifier();
257 const center = try notificationCenter();
258 const sels = try ScheduleSels.resolve();
259 const sel_request = objc.sel_registerName("requestAuthorizationWithOptions:completionHandler:") orelse
260 return error.SelectorNotFound;
261
262 var schedule = try parseSchedule(self.allocator, data);
263 errdefer schedule.deinit(self.allocator);
264
265 const ticket = ios_async.acquire(A.schedule_notification) orelse return poolFull();
266
267 publishScheduleCall(ticket, sels, schedule);
268
269 const Fn = *const fn (Id, Id, c_ulong, *anyopaque) callconv(.c) void;
270 const func: Fn = @ptrCast(&objc.objc_msgSend);
271 func(center, sel_request, un_options_alert_sound_badge, boolErrorBlock(ticket));
272 }
230273};
231274
232275/// The answer for a full block pool, copied from `bridge_mobile_permissions`:
@@ -539,12 +582,10 @@ fn arrayBlock(ticket: ios_async.Ticket) *anyopaque {
539582/// answers under the request id captured back at dispatch.
540583///
541584/// The one path that cannot answer is a reply that will not shape: out of
542/// memory, a nil array, or a NUL-truncated string. `ios_async` can resolve and
543/// nothing else, so the honest options are a fabricated result or silence, and
544/// silence is the lesser wrong — the slot is released, the failure is logged
545/// with its cause, and the caller's untimed promise is left hanging rather than
546/// resolved with a lie. This is the second reason the module comment asks for
547/// `ios_async.deliverError`.
585/// memory, a nil array, or a NUL-truncated string. That is answered with
586/// `ios_async.deliverError`, which reports NATIVE_CALL_FAILED through this same
587/// slot and hop — so the caller learns the call failed instead of waiting out a
588/// promise nothing will settle.
548589fn pendingCompletionFired(index: u5, requests: Id) void {
549590 if (!is_darwin) return;
550591
@@ -558,11 +599,19 @@ fn pendingCompletionFired(index: u5, requests: Id) void {
558599
559600 const allocator = std.heap.c_allocator;
560601 const json = shapePendingReply(allocator, requests, call.sels) catch |err| {
602 // Rejects rather than going silent. This abandoned the slot and left
603 // the caller's untimed promise hanging, on the reasoning that
604 // `ios_async` could resolve and nothing else, so the choice was
605 // between a fabricated result and silence. That stopped being true one
606 // day after it was written: `deliverError` reports NATIVE_CALL_FAILED
607 // through the same slot, the same `dispatch_async_f` hop to the main
608 // queue, and the same restored request id. Silence was the lesser
609 // wrong only while it was the only alternative to a lie.
561610 std.log.err(
562 "getPendingNotifications could not shape its reply ({}); slot released, caller unanswered",
611 "getPendingNotifications could not shape its reply ({}); rejecting",
563612 .{err},
564613 );
565 ios_async.abandon(call.ticket);
614 ios_async.deliverError(call.ticket);
566615 return;
567616 };
568617 defer allocator.free(json);
@@ -570,6 +619,492 @@ fn pendingCompletionFired(index: u5, requests: Id) void {
570619 ios_async.deliverJson(call.ticket, json);
571620}
572621
622// =============================================================================
623// scheduleNotification: parsing, selectors, and the two-stage completion chain.
624// =============================================================================
625
626/// `UNAuthorizationOptionAlert | Sound | Badge` = `(1<<2)|(1<<1)|(1<<0)`.
627/// `UNUserNotificationCenter.h:23-25`. Same value
628/// `bridge_mobile_permissions.zig` resolved independently, which is the
629/// cross-check that it is not a guess.
630const un_options_alert_sound_badge: c_ulong = 7;
631
632/// `NSCalendarUnitYear|Month|Day|Hour|Minute|Second`, the set the spec asks
633/// `Calendar.current.dateComponents` for. `NSCalendar.h:61-66` defines each as
634/// the matching `kCFCalendarUnit*`, and `CFCalendar.h:68-73` gives the bits:
635/// year `1<<2` through second `1<<7`, so the union is 252.
636const calendar_units_ymdhms: c_ulong = (1 << 2) | (1 << 3) | (1 << 4) | (1 << 5) | (1 << 6) | (1 << 7);
637
638/// When the notification fires.
639///
640/// `.now` is the spec's nil trigger — UserNotifications reads that as "deliver
641/// immediately". It is also where a non-positive `delay` lands, and that is a
642/// deliberate divergence: the spec passes `delay / 1000` to
643/// `UNTimeIntervalNotificationTrigger` unguarded, and an interval of zero or
644/// less raises an uncatchable Objective-C exception, so `{delay: 0}` SIGABRTs
645/// the app today. `bridge_notification.zig` documents the same trap and answers
646/// it the same way. Crashing the process is not a behaviour worth reproducing.
647const Trigger = union(enum) {
648 now,
649 /// Seconds, always > 0.
650 interval: f64,
651 /// Seconds since the epoch.
652 calendar: f64,
653};
654
655/// The parsed `notification` object, owning its strings.
656const Schedule = struct {
657 /// `null` means the page sent none and the spec's `UUID().uuidString`
658 /// applies. Generated in the Objective-C path so this stays pure.
659 id: ?[]u8,
660 title: []u8,
661 body: []u8,
662 subtitle: ?[]u8,
663 badge: ?i64,
664 trigger: Trigger,
665
666 fn deinit(self: *Schedule, allocator: std.mem.Allocator) void {
667 if (self.id) |v| allocator.free(v);
668 allocator.free(self.title);
669 allocator.free(self.body);
670 if (self.subtitle) |v| allocator.free(v);
671 self.* = undefined;
672 }
673};
674
675/// Read the page's `notification` object.
676///
677/// Follows the spec's coercions rather than tightening them: a non-string
678/// `title` or `body` becomes `""` because `as? String ?? ""` does, and a
679/// non-numeric `badge` is dropped because `as? Int` yields nil. The one place
680/// this refuses where the spec does not is a missing `notification` object —
681/// the spec's `case` has no `else`, so that call never reaches a
682/// `resolveCallback` and the page's promise hangs forever. A rejection is the
683/// only honest answer available.
684fn parseSchedule(allocator: std.mem.Allocator, data: []const u8) !Schedule {
685 var parsed = std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch
686 return bridge_error.BridgeError.InvalidJSON;
687 defer parsed.deinit();
688
689 const root = switch (parsed.value) {
690 .object => |o| o,
691 else => return bridge_error.BridgeError.InvalidJSON,
692 };
693 const notification = switch (root.get("notification") orelse
694 return bridge_error.BridgeError.MissingData) {
695 .object => |o| o,
696 else => return bridge_error.BridgeError.MissingData,
697 };
698
699 // `as? String ?? ""`, field by field.
700 const title = try allocator.dupe(u8, stringOr(notification.get("title"), ""));
701 errdefer allocator.free(title);
702 const body = try allocator.dupe(u8, stringOr(notification.get("body"), ""));
703 errdefer allocator.free(body);
704
705 const subtitle: ?[]u8 = if (notification.get("subtitle")) |v| switch (v) {
706 .string => |str| try allocator.dupe(u8, str),
707 else => null,
708 } else null;
709 errdefer if (subtitle) |v| allocator.free(v);
710
711 const id: ?[]u8 = if (notification.get("id")) |v| switch (v) {
712 .string => |str| try allocator.dupe(u8, str),
713 else => null,
714 } else null;
715 errdefer if (id) |v| allocator.free(v);
716
717 return .{
718 .id = id,
719 .title = title,
720 .body = body,
721 .subtitle = subtitle,
722 .badge = numberOr(notification.get("badge")),
723 .trigger = triggerFrom(notification),
724 };
725}
726
727fn stringOr(value: ?std.json.Value, fallback: []const u8) []const u8 {
728 const v = value orelse return fallback;
729 return switch (v) {
730 .string => |s| s,
731 else => fallback,
732 };
733}
734
735/// `as? Int`: a JSON integer, or a float that is exactly one. Anything else is
736/// nil, and the badge is left unset.
737fn numberOr(value: ?std.json.Value) ?i64 {
738 const v = value orelse return null;
739 return switch (v) {
740 .integer => |i| i,
741 .float => |f| if (f == @trunc(f) and f >= -9.2e18 and f <= 9.2e18) @intFromFloat(f) else null,
742 else => null,
743 };
744}
745
746/// `timestamp` wins over `delay`, as it does in the spec's if/else-if.
747fn triggerFrom(notification: std.json.ObjectMap) Trigger {
748 if (millisFrom(notification.get("timestamp"))) |ms| return .{ .calendar = ms / 1000.0 };
749 if (millisFrom(notification.get("delay"))) |ms| {
750 const seconds = ms / 1000.0;
751 // See `Trigger`: the spec crashes here, this delivers now.
752 return if (seconds > 0) .{ .interval = seconds } else .now;
753 }
754 return .now;
755}
756
757fn millisFrom(value: ?std.json.Value) ?f64 {
758 const v = value orelse return null;
759 return switch (v) {
760 .float => |f| f,
761 .integer => |i| @floatFromInt(i),
762 else => null,
763 };
764}
765
766/// Selectors for the schedule path, all resolved before a slot is leased.
767const ScheduleSels = struct {
768 ns_string: Id,
769 number_with_ll: Id,
770 content_alloc_init: Id,
771 set_title: Id,
772 set_body: Id,
773 set_subtitle: Id,
774 set_badge: Id,
775 set_sound: Id,
776 default_sound: Id,
777 trigger_interval: Id,
778 trigger_calendar: Id,
779 current_calendar: Id,
780 components_from: Id,
781 date_with_since1970: Id,
782 request_with: Id,
783 add_request: Id,
784 uuid_string: Id,
785
786 fn resolve() !ScheduleSels {
787 if (!is_darwin) return error.UnsupportedPlatform;
788 return .{
789 .ns_string = try selector("stringWithUTF8String:"),
790 .number_with_ll = try selector("numberWithLongLong:"),
791 .content_alloc_init = try selector("init"),
792 .set_title = try selector("setTitle:"),
793 .set_body = try selector("setBody:"),
794 .set_subtitle = try selector("setSubtitle:"),
795 .set_badge = try selector("setBadge:"),
796 .set_sound = try selector("setSound:"),
797 .default_sound = try selector("defaultSound"),
798 .trigger_interval = try selector("triggerWithTimeInterval:repeats:"),
799 .trigger_calendar = try selector("triggerWithDateMatchingComponents:repeats:"),
800 .current_calendar = try selector("currentCalendar"),
801 .components_from = try selector("components:fromDate:"),
802 .date_with_since1970 = try selector("dateWithTimeIntervalSince1970:"),
803 .request_with = try selector("requestWithIdentifier:content:trigger:"),
804 .add_request = try selector("addNotificationRequest:withCompletionHandler:"),
805 .uuid_string = try selector("UUIDString"),
806 };
807 }
808};
809
810const ScheduleCall = struct {
811 ticket: ios_async.Ticket,
812 sels: ScheduleSels,
813 schedule: Schedule,
814};
815
816var schedule_calls: [ios_async.max_in_flight]?ScheduleCall = @splat(null);
817
818fn publishScheduleCall(ticket: ios_async.Ticket, sels: ScheduleSels, schedule: Schedule) void {
819 pending_mutex.lock();
820 defer pending_mutex.unlock();
821 schedule_calls[ticket.index] = .{ .ticket = ticket, .sels = sels, .schedule = schedule };
822}
823
824fn takeScheduleCall(index: u5) ?ScheduleCall {
825 pending_mutex.lock();
826 defer pending_mutex.unlock();
827 const call = schedule_calls[index];
828 schedule_calls[index] = null;
829 return call;
830}
831
832/// `void (^)(BOOL, NSError *)` — the authorization completion.
833const BoolErrorBlock = extern struct {
834 isa: ?*anyopaque,
835 flags: c_int,
836 reserved: c_int = 0,
837 invoke: *const anyopaque,
838 descriptor: *const BlockDescriptor,
839};
840
841/// `void (^)(NSError *)` — `addNotificationRequest:`'s completion.
842const ErrorBlock = extern struct {
843 isa: ?*anyopaque,
844 flags: c_int,
845 reserved: c_int = 0,
846 invoke: *const anyopaque,
847 descriptor: *const BlockDescriptor,
848};
849
850const bool_error_block_descriptor = BlockDescriptor{ .size = @sizeOf(BoolErrorBlock) };
851const error_block_descriptor = BlockDescriptor{ .size = @sizeOf(ErrorBlock) };
852
853fn makeBoolErrorInvoke(comptime index: u5) *const anyopaque {
854 const S = struct {
855 fn invoke(_: *const BoolErrorBlock, granted: bool, _: Id) callconv(.c) void {
856 authorizationFired(index, granted);
857 }
858 };
859 return @ptrCast(&S.invoke);
860}
861
862fn makeBoolErrorBlocks() [ios_async.max_in_flight]BoolErrorBlock {
863 var out: [ios_async.max_in_flight]BoolErrorBlock = undefined;
864 for (&out, 0..) |*b, i| {
865 b.* = .{
866 .isa = &_NSConcreteGlobalBlock,
867 .flags = BLOCK_IS_GLOBAL,
868 .invoke = makeBoolErrorInvoke(@intCast(i)),
869 .descriptor = &bool_error_block_descriptor,
870 };
871 }
872 return out;
873}
874
875fn makeErrorInvoke(comptime index: u5) *const anyopaque {
876 const S = struct {
877 fn invoke(_: *const ErrorBlock, err: Id) callconv(.c) void {
878 addFired(index, err);
879 }
880 };
881 return @ptrCast(&S.invoke);
882}
883
884fn makeErrorBlocks() [ios_async.max_in_flight]ErrorBlock {
885 var out: [ios_async.max_in_flight]ErrorBlock = undefined;
886 for (&out, 0..) |*b, i| {
887 b.* = .{
888 .isa = &_NSConcreteGlobalBlock,
889 .flags = BLOCK_IS_GLOBAL,
890 .invoke = makeErrorInvoke(@intCast(i)),
891 .descriptor = &error_block_descriptor,
892 };
893 }
894 return out;
895}
896
897var bool_error_blocks: [ios_async.max_in_flight]BoolErrorBlock =
898 if (is_darwin) makeBoolErrorBlocks() else undefined;
899var error_blocks: [ios_async.max_in_flight]ErrorBlock =
900 if (is_darwin) makeErrorBlocks() else undefined;
901
902fn boolErrorBlock(ticket: ios_async.Ticket) *anyopaque {
903 return @ptrCast(&bool_error_blocks[ticket.index]);
904}
905
906fn errorBlock(ticket: ios_async.Ticket) *anyopaque {
907 return @ptrCast(&error_blocks[ticket.index]);
908}
909
910/// Stage one, on whatever queue UserNotifications chose.
911///
912/// A refusal answers `PERMISSION_DENIED`, where the spec rejects with the
913/// string "Permission denied" — the typed code every other migrated module
914/// uses, carrying the same meaning in a form a page can branch on.
915fn authorizationFired(index: u5, granted: bool) void {
916 if (!is_darwin) return;
917
918 var call = takeScheduleCall(index) orelse {
919 std.log.warn(
920 "scheduleNotification authorization fired for slot {d} with no call recorded; ignored",
921 .{index},
922 );
923 return;
924 };
925 const allocator = std.heap.c_allocator;
926
927 if (!granted) {
928 call.schedule.deinit(allocator);
929 ios_async.deliverErrorCode(call.ticket, bridge_error.BridgeError.PermissionDenied);
930 return;
931 }
932
933 const request = buildRequest(&call.schedule, call.sels) catch |err| {
934 std.log.err("scheduleNotification could not build its request ({}); rejecting", .{err});
935 call.schedule.deinit(allocator);
936 ios_async.deliverError(call.ticket);
937 return;
938 };
939
940 const center = notificationCenter() catch {
941 call.schedule.deinit(allocator);
942 ios_async.deliverError(call.ticket);
943 return;
944 };
945
946 // Republished before the add, for the reason the first publish exists: the
947 // completion can fire before `msgSend` returns.
948 publishScheduleCall(call.ticket, call.sels, call.schedule);
949
950 const Fn = *const fn (Id, Id, Id, *anyopaque) callconv(.c) void;
951 const func: Fn = @ptrCast(&objc.objc_msgSend);
952 func(center, call.sels.add_request, request, errorBlock(call.ticket));
953}
954
955/// Stage two. Answers the identifier the request was filed under, which is what
956/// the page needs to cancel it later.
957fn addFired(index: u5, err: Id) void {
958 if (!is_darwin) return;
959
960 var call = takeScheduleCall(index) orelse {
961 std.log.warn(
962 "scheduleNotification add fired for slot {d} with no call recorded; ignored",
963 .{index},
964 );
965 return;
966 };
967 const allocator = std.heap.c_allocator;
968 defer call.schedule.deinit(allocator);
969
970 if (err != null) {
971 std.log.warn("scheduleNotification: addNotificationRequest reported an error", .{});
972 ios_async.deliverErrorCode(call.ticket, bridge_error.BridgeError.NativeCallFailed);
973 return;
974 }
975
976 const id = call.schedule.id orelse {
977 // `buildRequest` fills this in from `NSUUID` before the add, so a null
978 // here means the request was filed under an identifier nothing kept.
979 // Answering anyway would hand the page a value it cannot cancel with.
980 std.log.err("scheduleNotification: the request was added with no identifier recorded", .{});
981 ios_async.deliverError(call.ticket);
982 return;
983 };
984
985 // A bare JSON string, which is what the spec resolves. Escaped, because a
986 // page-supplied identifier can contain a quote.
987 var out: std.ArrayListUnmanaged(u8) = .empty;
988 defer out.deinit(allocator);
989 out.append(allocator, '"') catch return ios_async.deliverError(call.ticket);
990 bridge_error.appendJsonEscaped(allocator, &out, id) catch
991 return ios_async.deliverError(call.ticket);
992 out.append(allocator, '"') catch return ios_async.deliverError(call.ticket);
993
994 ios_async.deliverJson(call.ticket, out.items);
995}
996
997/// Build `UNNotificationRequest` from the parsed schedule.
998///
999/// Runs inside the authorization completion, where the spec builds it, so the
1000/// autoreleased result is handed straight to `addNotificationRequest:` without
1001/// crossing a pool boundary.
1002///
1003/// Fills `schedule.id` when the page sent none, using `NSUUID` exactly as the
1004/// spec's `UUID().uuidString` does — so the identifier answered to the page is
1005/// the identifier the request was actually filed under.
1006fn buildRequest(schedule: *Schedule, sels: ScheduleSels) !Id {
1007 const allocator = std.heap.c_allocator;
1008
1009 const UNMutableNotificationContent = objc.objc_getClass("UNMutableNotificationContent") orelse
1010 return error.ClassNotFound;
1011 const sel_alloc = objc.sel_registerName("alloc") orelse return error.SelectorNotFound;
1012 const content = objc.msgSendId(objc.msgSendId(UNMutableNotificationContent, sel_alloc), sels.content_alloc_init);
1013 if (content == null) return error.NoContent;
1014
1015 try setStringField(content, sels.set_title, schedule.title, sels);
1016 try setStringField(content, sels.set_body, schedule.body, sels);
1017 if (schedule.subtitle) |sub| try setStringField(content, sels.set_subtitle, sub, sels);
1018
1019 if (schedule.badge) |badge| {
1020 const NSNumber = objc.objc_getClass("NSNumber") orelse return error.ClassNotFound;
1021 const NumFn = *const fn (Id, Id, c_longlong) callconv(.c) Id;
1022 const num: NumFn = @ptrCast(&objc.objc_msgSend);
1023 const boxed = num(NSNumber, sels.number_with_ll, @intCast(badge));
1024 objc.msgSendVoid1(content, sels.set_badge, boxed);
1025 }
1026
1027 // `content.sound = .default`, unconditional in the spec.
1028 const UNNotificationSound = objc.objc_getClass("UNNotificationSound") orelse
1029 return error.ClassNotFound;
1030 objc.msgSendVoid1(content, sels.set_sound, objc.msgSendId(UNNotificationSound, sels.default_sound));
1031
1032 const trigger = try buildTrigger(schedule.trigger, sels);
1033
1034 if (schedule.id == null) schedule.id = try uuidString(allocator, sels);
1035 const ns_id = try nsString(schedule.id.?, sels);
1036
1037 const UNNotificationRequest = objc.objc_getClass("UNNotificationRequest") orelse
1038 return error.ClassNotFound;
1039 const ReqFn = *const fn (Id, Id, Id, Id, Id) callconv(.c) Id;
1040 const req: ReqFn = @ptrCast(&objc.objc_msgSend);
1041 const request = req(UNNotificationRequest, sels.request_with, ns_id, content, trigger);
1042 if (request == null) return error.NoRequest;
1043 return request;
1044}
1045
1046/// A nil trigger is legal and means "deliver now" — see `Trigger`.
1047fn buildTrigger(trigger: Trigger, sels: ScheduleSels) !Id {
1048 switch (trigger) {
1049 .now => return null,
1050 .interval => |seconds| {
1051 const UNTimeIntervalNotificationTrigger =
1052 objc.objc_getClass("UNTimeIntervalNotificationTrigger") orelse return error.ClassNotFound;
1053 const Fn = *const fn (Id, Id, f64, bool) callconv(.c) Id;
1054 const func: Fn = @ptrCast(&objc.objc_msgSend);
1055 return func(UNTimeIntervalNotificationTrigger, sels.trigger_interval, seconds, false);
1056 },
1057 .calendar => |epoch_seconds| {
1058 const NSDate = objc.objc_getClass("NSDate") orelse return error.ClassNotFound;
1059 const DateFn = *const fn (Id, Id, f64) callconv(.c) Id;
1060 const date_fn: DateFn = @ptrCast(&objc.objc_msgSend);
1061 const date = date_fn(NSDate, sels.date_with_since1970, epoch_seconds);
1062
1063 const NSCalendar = objc.objc_getClass("NSCalendar") orelse return error.ClassNotFound;
1064 const calendar = objc.msgSendId(NSCalendar, sels.current_calendar);
1065 const CompFn = *const fn (Id, Id, c_ulong, Id) callconv(.c) Id;
1066 const comp_fn: CompFn = @ptrCast(&objc.objc_msgSend);
1067 const components = comp_fn(calendar, sels.components_from, calendar_units_ymdhms, date);
1068
1069 const UNCalendarNotificationTrigger =
1070 objc.objc_getClass("UNCalendarNotificationTrigger") orelse return error.ClassNotFound;
1071 const TrigFn = *const fn (Id, Id, Id, bool) callconv(.c) Id;
1072 const trig_fn: TrigFn = @ptrCast(&objc.objc_msgSend);
1073 return trig_fn(UNCalendarNotificationTrigger, sels.trigger_calendar, components, false);
1074 },
1075 }
1076}
1077
1078fn nsString(text: []const u8, sels: ScheduleSels) !Id {
1079 const allocator = std.heap.c_allocator;
1080 // `memory.dupeZ`, not `allocator.dupeZ`: 0.17 dropped the method in some
1081 // dev builds, which is the churn that helper exists to absorb.
1082 const c_text = try memory.dupeZ(allocator, u8, text);
1083 defer allocator.free(c_text);
1084
1085 const NSString = objc.objc_getClass("NSString") orelse return error.ClassNotFound;
1086 const Fn = *const fn (Id, Id, [*:0]const u8) callconv(.c) Id;
1087 const func: Fn = @ptrCast(&objc.objc_msgSend);
1088 const ns = func(NSString, sels.ns_string, c_text.ptr);
1089 if (ns == null) return error.NoString;
1090 return ns;
1091}
1092
1093fn setStringField(content: Id, sel: Id, text: []const u8, sels: ScheduleSels) !void {
1094 objc.msgSendVoid1(content, sel, try nsString(text, sels));
1095}
1096
1097fn uuidString(allocator: std.mem.Allocator, sels: ScheduleSels) ![]u8 {
1098 const NSUUID = objc.objc_getClass("NSUUID") orelse return error.ClassNotFound;
1099 const sel_uuid = objc.sel_registerName("UUID") orelse return error.SelectorNotFound;
1100 const uuid = objc.msgSendId(NSUUID, sel_uuid);
1101 if (uuid == null) return error.NoUUID;
1102 const ns = objc.msgSendId(uuid, sels.uuid_string);
1103 if (ns == null) return error.NoUUID;
1104 const utf8 = objc.getNSStringUTF8(ns) orelse return error.NoUUID;
1105 return allocator.dupe(u8, std.mem.span(utf8));
1106}
1107
5731108// =============================================================================
5741109// Tests — host-only.
5751110//
@@ -585,8 +1120,11 @@ fn pendingCompletionFired(index: u5, requests: Id) void {
5851120const testing = std.testing;
5861121
5871122test "the declared actions are the ones the handler serves" {
588 try testing.expectEqual(@as(usize, 1), capability_actions.len);
1123 try testing.expectEqual(@as(usize, 2), capability_actions.len);
5891124 try testing.expectEqualStrings(A.get_pending_notifications, capability_actions[0].name);
1125 try testing.expectEqualStrings(A.schedule_notification, capability_actions[1].name);
1126 try testing.expectEqual(capabilities.Reply.result, capability_actions[1].reply);
1127 try testing.expectEqual(capabilities.ActionStatus.live, capability_actions[1].status);
5901128
5911129 // A `.result` whose handler never replies parks the caller on an untimed
5921130 // promise; a `.none` that is awaited resolves immediately and means
@@ -680,27 +1218,106 @@ test "an action the namespace does not serve is reported, not ignored" {
6801218 );
6811219}
6821220
683test "scheduleNotification is left to the Swift shim, not claimed and refused" {
684 // This is the deliberate omission, asserted rather than left implicit.
685 // `UnknownAction` is what makes `ios_dispatch` fall through to the host
686 // shim, which schedules notifications correctly today. Declaring the action
687 // `.unavailable` instead would take it away from the shim in order to
688 // reject it, which is strictly worse for the page — see the module comment
689 // for what `ios_async` still owes before this can be served honestly.
1221test "scheduleNotification is claimed now, and routes" {
1222 // This asserted the opposite until the blocker went away. The module
1223 // comment said `ios_async` could only resolve, so an action whose two
1224 // failure paths are both rejections could not be served honestly and was
1225 // left to the shim. `deliverErrorCode` landed the day after that was
1226 // written; both refusals have a home now, and the action is served.
6901227 var bridge = NotificationsBridge.init(testing.allocator);
6911228 defer bridge.deinit();
6921229
1230 try testing.expect(routeFor(A.schedule_notification) == .schedule);
1231
1232 var declared = false;
1233 for (capability_actions) |decl| {
1234 if (std.mem.eql(u8, decl.name, A.schedule_notification)) declared = true;
1235 }
1236 try testing.expect(declared);
1237
1238 // On the host there is no bundle identifier, so it refuses before touching
1239 // the notification center — which is the guard, not a missing migration.
6931240 try testing.expectError(
694 bridge_error.BridgeError.UnknownAction,
695 bridge.handleMessage("scheduleNotification", "{\"notification\":{\"title\":\"t\"}}"),
1241 error.NoBundleIdentifier,
1242 bridge.handleMessage(A.schedule_notification, "{\"notification\":{\"title\":\"t\"}}"),
6961243 );
697 try testing.expect(routeFor("scheduleNotification") == null);
1244}
6981245
699 // And it must not be declared, or `capabilities` would tell an app craft
700 // serves it.
701 for (capability_actions) |decl| {
702 try testing.expect(!std.mem.eql(u8, decl.name, "scheduleNotification"));
1246test "the payload is read the way the spec reads it" {
1247 const allocator = testing.allocator;
1248
1249 // `as? String ?? ""` for title and body; a non-string is not an error.
1250 var plain = try parseSchedule(allocator, "{\"notification\":{\"title\":7,\"body\":\"b\"}}");
1251 defer plain.deinit(allocator);
1252 try testing.expectEqualStrings("", plain.title);
1253 try testing.expectEqualStrings("b", plain.body);
1254 try testing.expect(plain.subtitle == null);
1255 try testing.expect(plain.badge == null);
1256 try testing.expect(plain.id == null);
1257 try testing.expect(plain.trigger == .now);
1258
1259 // `timestamp` wins over `delay`, as the spec's if/else-if does.
1260 var both = try parseSchedule(allocator, "{\"notification\":{\"timestamp\":2000,\"delay\":9000}}");
1261 defer both.deinit(allocator);
1262 try testing.expectEqual(@as(f64, 2.0), both.trigger.calendar);
1263
1264 var delayed = try parseSchedule(allocator, "{\"notification\":{\"delay\":1500}}");
1265 defer delayed.deinit(allocator);
1266 try testing.expectEqual(@as(f64, 1.5), delayed.trigger.interval);
1267
1268 // A missing `notification` hangs the page in the spec — its `case` has no
1269 // `else` and never reaches a callback. Rejecting is the only honest answer.
1270 try testing.expectError(
1271 bridge_error.BridgeError.MissingData,
1272 parseSchedule(allocator, "{}"),
1273 );
1274}
1275
1276test "a non-positive delay delivers now instead of raising" {
1277 // The spec passes `delay / 1000` to `UNTimeIntervalNotificationTrigger`
1278 // unguarded, and an interval of zero or less raises an uncatchable ObjC
1279 // exception — so `{delay: 0}` SIGABRTs the app today. A nil trigger is how
1280 // UserNotifications spells "now"; `bridge_notification.zig` answers the
1281 // same trap the same way.
1282 const allocator = testing.allocator;
1283 for ([_][]const u8{
1284 "{\"notification\":{\"delay\":0}}",
1285 "{\"notification\":{\"delay\":-1}}",
1286 "{\"notification\":{\"delay\":-0.5}}",
1287 }) |payload| {
1288 var s2 = try parseSchedule(allocator, payload);
1289 defer s2.deinit(allocator);
1290 try testing.expect(s2.trigger == .now);
1291 }
1292}
1293
1294test "the calendar unit mask is the header's, not a guess" {
1295 // `NSCalendar.h:61-66` defines each unit as the matching `kCFCalendarUnit*`,
1296 // and `CFCalendar.h:68-73` gives year `1<<2` through second `1<<7`. A
1297 // sibling module once shipped a guessed HealthKit bit that named a
1298 // different option and crashed, so this cites its source.
1299 try testing.expectEqual(@as(c_ulong, 252), calendar_units_ymdhms);
1300 try testing.expect(calendar_units_ymdhms & (1 << 1) == 0); // era, not asked for
1301 try testing.expectEqual(@as(c_ulong, 7), un_options_alert_sound_badge);
1302}
1303
1304test "each schedule slot has its own block, and a call is taken once" {
1305 if (!is_darwin) return error.SkipZigTest;
1306 for (&bool_error_blocks) |*b| {
1307 try testing.expectEqual(&_NSConcreteGlobalBlock, b.isa);
1308 try testing.expectEqual(BLOCK_IS_GLOBAL, b.flags);
7031309 }
1310 try testing.expect(bool_error_blocks[0].invoke != bool_error_blocks[1].invoke);
1311 try testing.expect(error_blocks[0].invoke != error_blocks[1].invoke);
1312
1313 // Take-and-clear is what makes a double-fired completion a no-op rather
1314 // than a second reply.
1315 const allocator = testing.allocator;
1316 var schedule = try parseSchedule(allocator, "{\"notification\":{\"title\":\"t\"}}");
1317 defer schedule.deinit(allocator);
1318 publishScheduleCall(.{ .index = 2, .generation = 9 }, undefined, schedule);
1319 try testing.expect(takeScheduleCall(2) != null);
1320 try testing.expect(takeScheduleCall(2) == null);
7041321}
7051322
7061323test "the payload is ignored, not parsed" {
packages/zig/src/ios_config.zigmodified+1-0
Changes to packages/zig/src/ios_config.zig
@@ -418,6 +418,7 @@ pub fn gateFor(action: []const u8) ?Feature {
418418 .{ "getCurrentPosition", .geolocation },
419419 .{ "getHealthData", .health_kit },
420420 .{ "getPendingNotifications", .local_notifications },
421 .{ "scheduleNotification", .local_notifications },
421422 .{ "haptic", .haptics },
422423 .{ "lockOrientation", .orientation_lock },
423424 .{ "openCamera", .camera },
packages/zig/test/ios_conformance_test.zigmodified+8-1
Changes to packages/zig/test/ios_conformance_test.zig
@@ -104,7 +104,14 @@ const dispatch_end = "func webView(";
104104/// preconditions and four blockers; the load-bearing one was the realtime
105105/// audio tap, which is the first callback in this migration that runs on a
106106/// thread where a lock is a bug rather than a slowdown.
107const max_not_yet_migrated: usize = 26;
107/// 25 with scheduleNotification. Its deferral was the first to expire on its
108/// own: the blocker recorded in `bridge_mobile_notifications.zig` was that
109/// `ios_async` could only resolve, so an action whose two failure paths are
110/// both rejections could not be served without fabricating success —
111/// and `deliverErrorCode` landed the day after that was written. Worth
112/// re-reading the other deferrals for the same reason before assuming they
113/// still hold.
114const max_not_yet_migrated: usize = 25;
108115
109116fn dispatcherRegion() []const u8 {
110117 const begin = std.mem.indexOf(u8, swift_spec, dispatch_begin) orelse return "";