Skip to content

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

Merged
glennmichael123 merged 3 commits into
mainfrom
feat/ios-schedule-notification
Sep 3, 2026
Merged

feat(ios): scheduleNotification — the deferral that expired on its own, ratchet 26 → 25#110
glennmichael123 merged 3 commits into
mainfrom
feat/ios-schedule-notification

Conversation

@glennmichael123

Copy link
Copy Markdown
Contributor

bridge_mobile_notifications.zig left this action to the Swift shim and wrote down exactly why. The blocker was never a missing selector:

Both Swift failure paths are rejections, not results … ios_async can only resolvedeliverJson is the whole asynchronous reply surface. Resolving the string "denied" for a denied prompt would be textbook fabricated success … The fix is an ios_async.deliverError(ticket, code, message) sharing deliverJson's slot, dispatch_async_f hop, and request-id restoration.

commit date
that comment written 0a97def 2026-08-31
deliverErrorCode lands, sharing exactly those three things abfbd8f 2026-09-01

The comment described a limitation that stopped existing the next day, and the shim has been keeping an action Zig could serve. Worth re-reading the other deferrals for the same reason before assuming they still hold — that note is now in the ratchet's own history.

The chain

Authorization, then add: the spec's two stages in the spec's order. Content and request are built inside the authorization completion, where the spec builds them, so the autoreleased request never crosses a pool boundary and nothing needs retaining — which is what the original note expected to be the hard part. What survives is the identifier, owned heap memory parked on the slot and answered in stage two, because that is the value a page needs to cancel with later. Both stages take-and-clear, so a double-fired completion replies to nobody rather than twice.

A crash not reproduced

The spec passes delay / 1000 to UNTimeIntervalNotificationTrigger unguarded, and an interval of zero or less raises an uncatchable Objective-C exception — so {delay: 0} SIGABRTs the app today. A non-positive delay here produces a nil trigger, which is how UserNotifications spells "deliver now". bridge_notification.zig documents the same trap and answers it the same way.

Constants cited, not guessed

  • UNAuthorizationOptionAlert|Sound|Badge = 7 — UNUserNotificationCenter.h:23-25, independently the same value bridge_mobile_permissions.zig already resolved.
  • Calendar mask = 252 — NSCalendar.h:61-66 defines each unit as the matching kCFCalendarUnit*; CFCalendar.h:68-73 gives year 1<<2 through second 1<<7.

A sibling module once shipped a guessed HealthKit bit that named a different option and crashed the app, so both are pinned by a test that names its source.

Deliberate divergences

A missing notification object rejects. The spec's case has no else, so that call never reaches a callback and the page's promise hangs forever. Refusals carry typed codes rather than the spec's free text, matching every other migrated module.

Also in here

getPendingNotifications abandoned its slot and left the caller hanging when its reply would not shape — the same "ios_async can only resolve" reasoning, recorded in the same file. It rejects now.

Verification

Cold zig build test: 150/150 steps, 2486/2498 passed, 12 skipped, 0 failed. zig fmt --check clean. build-ios-all -Doptimize=ReleaseSafe produces all three archives.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

✅ Binary load time

rounds:    25 interleaved
base:      p50 160.2ms   p95 167.2ms   (152.4–167.6ms)
head:      p50 28.2ms   p95 34.9ms   (23.8–35.8ms)
delta:     -82.4%  (fails above +20.0%)

No binary load time regression.
What this measures

craft --help: process spawn, dynamic linking and argument parsing.
It never opens a window, so it cannot see a change in window or
webview startup — real startup is benchmarks/startup.bench.ts, which
needs a display.

Both binaries are measured interleaved on this runner and compared by
p50, rather than against a number recorded on another machine. On
byte-identical binaries that method reads within ~3.5%; the old one
swung 45%.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

✅ Binary Size Report

Metric Value
Current Size 14912KB (14.56MB)
Change 0KB (0%) unchanged
Size limits
  • Warning: 14.50MB
  • Maximum: 16.00MB

…n CI

Four findings from the seam review, all reproduced before being fixed.

**The suite depended on the developer's shell.** `init()` read
`CRAFT_IOS_RUNTIME` straight from `process.env`, with nothing to override it,
so `bun test src/index.test.ts` gave 12 passing in a clean shell and 8 passing
with 4 failing when the documented dev-loop variable happened to be exported.
`InitOptions.runtimeDir` is the escape hatch CLAUDE.md already describes for
the sibling variable — `AppConfig.craftPath` is to `CRAFT_BIN` what this is to
`CRAFT_IOS_RUNTIME` — and `null` means "no runtime, whatever the environment
says". Every `init()` in the suite now says so, and the result is identical
with the variable set and unset.

**And nothing ran that suite.** No CI job touched `packages/ios`, which is how
the above survived. `ios-builder` runs it with `CRAFT_IOS_RUNTIME` explicitly
empty — the job that would notice if the dependency came back — and
`publish-commit` gates on it.

**`installRuntime` destroyed a working install before validating the source.**
`rmSync(Runtime/)` ran first and the per-SDK "none of these archives" check
ran after, so pointing at an incomplete runtime directory deleted the archives
already in place on the way to throwing, leaving `project.yml` linking
`-lcraft-ios` against a directory that no longer existed. Every SDK is
resolved before anything is removed.

**A single simulator slice was copied silently.** `RUNTIME_ARCHIVES`' own
comment calls a single-slice archive the break that "only shows up on someone
else's laptop", and then the code shipped one without a word. It still copies
— a one-architecture dev loop is legitimate — but it says which slice is
missing and that the project will not link on the other architecture.

**Re-running init without a runtime left the archives behind.** The link
settings came out of `project.yml` and `Runtime/` stayed on disk, so the
directory and the project disagreed. Whatever a run decides, the tree now
agrees with it.

The two-slice `lipo` path is not unit-tested: it needs genuine Mach-O input
and `lipo` does not exist on the Linux runner this job uses. It is covered by
building a real generated app, which is how the fat archive was verified when
the seam landed.
…king a stale copy

`installRuntime` had exactly one caller: `init`. `build` never touched
`Runtime/`, and `run` just calls `build`. Since the archives are copied rather
than symlinked, the monorepo dev loop this whole option exists for did not
work: edit `packages/zig/src`, run `zig build build-ios-all`, then `craft ios
run`, and xcodebuild relinks the archive copied when the project was first
generated. The build succeeds, the app launches, and the change is absent —
the worst shape a stale artefact takes, because there is no error to chase.

`build` now refreshes a runtime the project already links, taking the same
`runtimeDir` override `init` does.

Refresh only: never installs, never removes. A project with no `Runtime/` was
generated without one and its `project.yml` carries no link settings, so
copying archives in would leave them unreferenced — that decision is `init`'s.
A project that does link one keeps the archives it has when no runtime
directory is configured, because a shell that forgot the variable should not
quietly turn the runtime off. Both are pinned by tests.
…n, ratchet 26 -> 25

`bridge_mobile_notifications.zig` left this action to the Swift shim and wrote
down exactly why. The blocker was not a missing selector:

    Both Swift failure paths are rejections, not results … `ios_async` can
    only *resolve* — `deliverJson` is the whole asynchronous reply surface.
    Resolving the string "denied" for a denied prompt would be textbook
    fabricated success … The fix is an `ios_async.deliverError(ticket, code,
    message)` sharing `deliverJson`'s slot, `dispatch_async_f` hop, and
    request-id restoration.

That was written on 2026-08-31 in 0a97def. `deliverErrorCode` landed on
2026-09-01 in abfbd8f, sharing exactly those three things. The comment has
been describing a limitation that no longer exists for days, and the shim has
been keeping an action Zig could serve.

**The chain.** Authorization, then add — the spec's two stages, in the spec's
order. The content and request are built *inside* the authorization
completion, where the spec builds them, so the autoreleased request never
crosses a pool boundary and nothing needs retaining. What survives is the
identifier, owned heap memory parked on the slot and answered in stage two,
because that is the value the page needs in order to cancel later. Both
stages take-and-clear, so a double-fired completion replies to nobody rather
than twice.

**A crash not reproduced.** The spec passes `delay / 1000` to
`UNTimeIntervalNotificationTrigger` unguarded, and an interval of zero or less
raises an uncatchable Objective-C exception — `{delay: 0}` SIGABRTs the app
today. A non-positive delay here produces a nil trigger, which is how
UserNotifications spells "deliver now"; `bridge_notification.zig` documents
the same trap and answers it the same way.

**Constants cited, not guessed.** `UNAuthorizationOptionAlert|Sound|Badge` is
7 (`UNUserNotificationCenter.h:23-25`), which independently matches the value
`bridge_mobile_permissions.zig` already resolved. The calendar mask is 252 —
`NSCalendar.h:61-66` defines each unit as the matching `kCFCalendarUnit*` and
`CFCalendar.h:68-73` gives year `1<<2` through second `1<<7`. A sibling module
once shipped a guessed HealthKit bit that named a different option and
crashed, so both are pinned by a test that names its source.

**Divergences, deliberate.** A missing `notification` object rejects; the
spec's `case` has no `else`, so that call never reaches a callback and the
page's promise hangs forever. Refusals carry typed codes rather than the
spec's free text, matching every other migrated module.

Also fixes a second stale workaround in the same file: `getPendingNotifications`
abandoned its slot and left the caller hanging when its reply would not shape,
for the same "ios_async can only resolve" reason. It rejects now.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant