ReviewOS

also looking at this

craft-native/craft

iOS: the capability gate, and the two actions it unblocks — ratchet 48 → 46

#92
Merged glennmichael123 wants to merge feat/ios-capability-gate into main
9 files +1526 -62
10 days ago

Two commits. The first closes a gap the migration kept working around; the second spends it.

1. Read the app's capability config instead of ignoring it

Every action in CraftApp.swift's dispatcher is guarded by a config.enable* flag. Zig had none of them, so 32 actions it already served were served unconditionally — an app that set enableClipboard: false got clipboard anyway.

src/ios_config.zig reads the same bundled craft.config.json Swift decodes; ios_dispatch.route consults it before the module chain. Only actions Zig serves are gated — the rest keep whatever answer the shim gives them, because Zig deciding on the shim's behalf would answer from Zig's parse a question the owning arm is about to answer from its own.

60 of the spec's 65 gated cases hang

Only share, secureClear, getCurrentPosition, watchPosition and startLocationRecording reply CAPABILITY_DISABLED. The rest have no else: the guard fails, the case falls out of the switch, the callback is never settled, and the page's promise stays pending until its timeout. That is the ota* defect the conformance test already catches, at twelve times the scale. Those 60 are not a contract to port, so a disabled capability answers rather than hangs.

The decode is all-or-nothing, because Swift's is

CraftConfig's properties carry defaults, but a synthesized Decodable does not use them — it throws on a missing key, try? swallows it, and line 178 falls back to an all-false CraftConfig(). One absent key disables all 35 capabilities. Both runtimes read one file in one process through the hand-off table, so a per-key decode would be more useful and would disagree with Swift about the same bytes.

Seven behaviours verified against a real swiftc run rather than assumed:

inputSwiftwhy it matters
missing required keythrows → all falsethe rule above
1 for a Boolthrowscoercing would be the obvious thing to write
null / "true" for a Boolthrows
non-string in trustedOriginsthrows
duplicated keykeeps the first, no throwZig's default is error.DuplicateField, which would disable an app over a pasted line
unknown extra keyignored
devServerURL absent or nullfinethe one optional

Keeping the table honest

Five assertions compare the gate table against the spec: it must cover every gated action Zig serves, agree on which flag, carry no stale entry, spell only keys CraftConfig actually has, and require exactly the keys Swift's decoder requires. A misspelling like enableMlKit is not a one-flag bug — the key reads as missing and disables all 35 — so that has its own check.

Six mutations verified to fail the build, on exit codes (a first pass keyed on a grep pattern that also matched benign error.NoWebView log lines would have reported false passes):

PASS (caught, rc=1): gateFor missing an entry (clipboardRead)
PASS (caught, rc=1): gateFor pointing at the wrong feature
PASS (caught, rc=1): a typo in a json key (enableMLKit -> enableMlKit)
PASS (caught, rc=1): a stale gate entry for an action Zig does not serve
PASS (caught, rc=1): dropping a required non-capability key (darkMode)
PASS (caught, rc=1): parse dropping the duplicate-key option

Both were unserved for exactly the gap above; bridge_mobile_misc's header said so in as many words — "It stays with the shim until a config channel exists." The gate caught both new actions as ungated on the first build after I added them, which is what it is for.

  • takeScreenshot renders the key window's layer and answers a bare JSON string carrying a data: URL. UIGraphicsBeginImageContextWithOptions and friends stand in for UIGraphicsImageRenderer, whose imageWithActions: takes a block — and a global block cannot capture the layer it must render, so reaching it would mean module-level mutable state to avoid two C calls that produce the same pixels. The five entry points resolve via dlsym, the route bridge_mobile_imagepicker already takes, because the host test binaries link Cocoa and not UIKit. They resolve together, so a UIKit missing one refuses before beginning the image context — the only call here with a cleanup obligation.
  • registerDeepLinkHandler acknowledges and does nothing else, because there is nothing native to register: Swift's whole arm is resolveCallback(true) and the real registration is the page's own addEventListener. Porting it faithfully means porting a handshake.
  • getInitialURL stays with the shim — its value lives in DeepLinkManager.shared, a pure-Swift class with no @objc surface, fed by SwiftUI's .onOpenURL. The config channel does not change that.

Verification

zig build test green (153/153), zig fmt --check, build-ios and build-ios-simulator clean, pickier . 0 errors.

20 fixture assertions pass on a simulator, including three new ones:

ok: disabled capability refused with CAPABILITY_DISABLED (i=20 seen 1x)
ok: enabled capabilities still served (i=10, i=12, i=14 above)
ok: screenshot rendered the live window to a real PNG (i=22 seen 1x)
ok: deep-link handshake replied true behind its own gate (i=24 seen 1x)

Both arms of the gate matter: the Keychain, SQLite and notification round trips run through actions the same config enables, so it is not simply refusing everything. The screenshot assertion decodes the base64 and checks the PNG signature byte by byte plus a size floor — a fabricated data URL with the right prefix fails it, verified by mutation.

What this unblocks

33 of the remaining 46 are behind a config flag rather than behind any Objective-C difficulty. That was the actual wall, and it is gone.

9 changed files on the files tab, with 0 review threads.

Sign in to comment on this pull request.