feat(ios): scheduleNotification — the deferral that expired on its own, ratchet 26 → 25 - #110
Merged
Merged
Conversation
✅ Binary load timeWhat this measures
Both binaries are measured interleaved on this runner and compared by |
✅ Binary Size Report
Size limits
|
…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.
chrisbbreuer
force-pushed
the
feat/ios-schedule-notification
branch
from
September 3, 2026 18:30
0a65abb to
f663748
Compare
This was referenced Sep 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
bridge_mobile_notifications.zigleft this action to the Swift shim and wrote down exactly why. The blocker was never a missing selector:0a97defdeliverErrorCodelands, sharing exactly those three thingsabfbd8fThe 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 / 1000toUNTimeIntervalNotificationTriggerunguarded, 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.zigdocuments the same trap and answers it the same way.Constants cited, not guessed
UNAuthorizationOptionAlert|Sound|Badge= 7 —UNUserNotificationCenter.h:23-25, independently the same valuebridge_mobile_permissions.zigalready resolved.NSCalendar.h:61-66defines each unit as the matchingkCFCalendarUnit*;CFCalendar.h:68-73gives year1<<2through second1<<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
notificationobject rejects. The spec'scasehas noelse, 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
getPendingNotificationsabandoned 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 --checkclean.build-ios-all -Doptimize=ReleaseSafeproduces all three archives.