ReviewOS

also looking at this

craft-native/craft

iOS: hand unserved actions to the host shim, over one reply path

#84
Merged glennmichael123 wants to merge feat/ios-handoff into main
4 files +282 -24
packages/ios/fixtures/zig-slice/build-and-run.shmodified+31-5
Changes to packages/ios/fixtures/zig-slice/build-and-run.sh
@@ -23,10 +23,26 @@ SDK="$(xcrun --sdk iphonesimulator --show-sdk-path)"
2323APP="$OUT/CraftSlice.app"
2424rm -rf "$OUT"; mkdir -p "$APP"
2525
26echo "==> compiling the fixture"
27# The ObjC translation units first, then swiftc drives the link. swiftc is the
28# linker rather than clang because Swift auto-links compatibility shims
29# (swiftCompatibility56 and friends) that live in the toolchain, not the SDK —
30# clang cannot find them and the link fails on undefined symbols.
31clang -isysroot "$SDK" -target "$TRIPLE" -fobjc-arc -O1 -c \
32 "$FIXTURE/main.m" -o "$OUT/main.o"
33clang -isysroot "$SDK" -target "$TRIPLE" -fobjc-arc -O1 -c \
34 "$FIXTURE/page.m" -o "$OUT/page.o"
35
2636echo "==> linking CraftSlice"
27clang -isysroot "$SDK" -target "$TRIPLE" \
28 -fobjc-arc -O1 \
29 "$FIXTURE/main.m" "$FIXTURE/page.m" \
37# The shim class has to survive into the binary for
38# `objc_getClass("CraftSwiftShim")` to find it at runtime. Compiling it into
39# the executable directly (rather than through an archive) is what guarantees
40# that: a linker is free to drop an archive member nothing references
41# statically, and a class reached only by name is exactly that.
42swiftc -target "$TRIPLE" -sdk "$SDK" \
43 -parse-as-library -O \
44 "$FIXTURE/shim.swift" \
45 "$OUT/main.o" "$OUT/page.o" \
3046 "$ROOT/packages/zig/zig-out/lib/$LIB" \
3147 -framework UIKit -framework WebKit -framework Foundation \
3248 -o "$APP/CraftSlice"
@@ -50,7 +66,7 @@ LAUNCH_PID=$!
5066# The round trip is fast, but a cold simulator is not. Poll rather than sleep a
5167# fixed amount, so a slow boot does not read as a failure.
5268for _ in $(seq 1 60); do
53 if grep -q 'i=2' "$LOG" 2>/dev/null && grep -q 'i=3' "$LOG" 2>/dev/null; then sleep 1; break; fi
69 if grep -q 'i=5' "$LOG" 2>/dev/null && grep -q 'i=3' "$LOG" 2>/dev/null; then sleep 1; break; fi
5470 sleep 1
5571done
5672kill "$LAUNCH_PID" 2>/dev/null || true
@@ -66,7 +82,7 @@ echo "==> assertions"
6682PLAIN="$OUT/console.plain"
6783sed -e $'s/\x1b\[[0-9;]*m//g' -e 's/\r$//' "$LOG" > "$PLAIN"
6884
69count() { grep -c "craft-bridge dispatch t=mobile a=getDeviceInfo i=$1" "$PLAIN" || true; }
85count() { grep -cE "craft-bridge dispatch t=mobile a=[A-Za-z]+ i=$1\$" "$PLAIN" || true; }
7086
7187C1="$(count 1)"; C2="$(count 2)"; C3="$(count 3)"
7288
@@ -85,4 +101,14 @@ echo "ok: no feedback loop"
85101[ "$C3" -ge 1 ] || { echo "FAIL: the user script had not run when the page executed"; exit 1; }
86102echo "ok: user script ran at document start"
87103
104C4="$(count 4)"; C5="$(count 5)"
105
106[ "$C4" -ge 1 ] || { echo "FAIL: the host-only action never reached the dispatcher"; exit 1; }
107echo "ok: unserved action reached the dispatcher (i=4 seen ${C4}x)"
108
109# servedBy is a string only the shim writes, and it came back through Zig's own
110# reply path. Neither side could have produced this alone.
111[ "$C5" -ge 1 ] || { echo "FAIL: the host shim never answered, or its answer never reached the page"; exit 1; }
112echo "ok: hand-off to host shim closed (i=5 seen ${C5}x)"
113
88114echo "PASS"
packages/ios/fixtures/zig-slice/page.mmodified+28-9
Changes to packages/ios/fixtures/zig-slice/page.m
@@ -2,22 +2,27 @@
22//
33// Signals are carried by *request id*, because ids are already logged by the
44// dispatcher and need no extra native code to observe. Each is sent at most
5// once — an earlier version of this page re-sent its confirmation from inside
6// the reply handler that the confirmation's own reply then re-entered, which
7// span forever and made the assertion pass on its first iteration regardless.
5// once — an earlier version re-sent its confirmation from inside the reply
6// handler that the confirmation's own reply then re-entered, which span
7// forever and made the assertion pass on its first iteration regardless.
88//
9// i=1 the page called native
10// i=2 native's reply came back carrying a real UIKit systemName
9// i=1 the page called an action Zig serves
10// i=2 that reply came back carrying a real UIKit systemName
1111// i=3 the user script had already run when the page's own script executed
12// i=4 the page called an action Zig does *not* serve
13// i=5 that reply came back, having been answered by the host shim
1214//
13// i=2 is the one that matters. It cannot be produced by a stub, a browser
14// fallback, or native talking to itself: it exists only if the page received
15// and understood an answer that came from UIKit.
15// i=2 and i=5 are the load-bearing ones, and they prove different things.
16// i=2 cannot be produced by a stub: only a real UIKit process reports
17// systemName "iOS". i=5 cannot be produced by Zig alone: `servedBy` is a
18// string only the shim writes, delivered back through Zig's own reply path.
19// Together they show both arms of the dispatcher reaching the same page over
20// one protocol.
1621const char craft_slice_page[] =
1722 "<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
1823 "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
1924 "</head><body><h1 id=\"s\">craft slice</h1><script>\n"
20 "var sentRoundTrip = false;\n"
25 "var sentRoundTrip = false, sentHandOff = false;\n"
2126 "var bridge = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.craft;\n"
2227 "\n"
2328 "// Read before anything else touches it: the user script is installed at\n"
@@ -31,14 +36,28 @@ const char craft_slice_page[] =
3136 "window.__craftBridgeResult = function (action, payload, id) {\n"
3237 " window.__craftSliceAck = JSON.stringify({action: action, id: id, payload: payload});\n"
3338 " document.getElementById('s').textContent = window.__craftSliceAck;\n"
39 "\n"
3440 " if (!sentRoundTrip && payload && payload.systemName === 'iOS') {\n"
3541 " sentRoundTrip = true;\n"
3642 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:2});\n"
3743 " }\n"
44 "\n"
45 " // `language` is written by the Swift shim and by nothing else, so this\n"
46 " // distinguishes a real hand-off from Zig answering by some other route.\n"
47 " // Zig routed the call out, Swift answered through\n"
48 " // craft_ios_deliver_result, and Zig delivered it here over the same\n"
49 " // protocol it uses for its own actions.\n"
50 " if (!sentHandOff && payload && payload.servedBy === 'host-shim'\n"
51 " && payload.language === 'swift') {\n"
52 " sentHandOff = true;\n"
53 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:5});\n"
54 " }\n"
3855 "};\n"
3956 "\n"
4057 "if (bridge) {\n"
4158 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:1});\n"
59 " // An action Zig does not serve, so it must reach the host shim.\n"
60 " bridge.postMessage({t:'mobile', a:'hostOnlyPing', i:4});\n"
4261 "} else {\n"
4362 " document.getElementById('s').textContent = 'NO BRIDGE';\n"
4463 "}\n"
packages/ios/fixtures/zig-slice/shim.swiftadded+57-0
Changes to packages/ios/fixtures/zig-slice/shim.swift
@@ -0,0 +1,57 @@
1// The host shim, in Swift, presenting the contract `CraftApp.swift` will.
2//
3// This replaces an Objective-C stand-in. The stand-in proved the runtime
4// mechanism — `objc_getClass` plus a message send — but not that *Swift*
5// presents that mechanism the way Zig expects: that `@objc(CraftSwiftShim)`
6// really does register under that name, that a `static func` becomes a class
7// method reachable by selector, that `String` parameters bridge from NSString,
8// and that `Bool` comes back as the ObjC BOOL Zig reads.
9//
10// Those are exactly the assumptions the real wiring will rest on, so they are
11// worth testing against the real compiler rather than assumed from a language
12// that shares an ABI with it.
13import Foundation
14
15// Zig owns the reply path. The shim decides *what* the answer is and hands it
16// back; the wire format, the request id and the escaping stay on the other
17// side. `@_silgen_name` reaches the exported symbol without needing a bridging
18// header, which keeps the harness to one swiftc invocation.
19@_silgen_name("craft_ios_deliver_result")
20func craft_ios_deliver_result(
21 _ action: UnsafePointer<CChar>, _ actionLen: UInt,
22 _ json: UnsafePointer<CChar>, _ jsonLen: UInt,
23 _ requestId: Int64
24)
25
26@objc(CraftSwiftShim)
27public class CraftSwiftShim: NSObject {
28 // Selector: handleAction:payload:requestId: — which is what
29 // `ios_dispatch.handOffToHost` sends. A mismatch here is not a compile
30 // error on either side, so the simulator assertion is what catches it.
31 @objc public static func handleAction(
32 _ action: String,
33 payload: String,
34 requestId: Int64
35 ) -> Bool {
36 // Exactly one action, to prove the seam. A real shim dispatches the
37 // ~105 the Swift template still owns.
38 guard action == "hostOnlyPing" else {
39 // Not ours. Zig answers UnknownAction, which is the same path a
40 // fully-migrated app with no shim at all takes.
41 return false
42 }
43
44 let json = "{\"servedBy\":\"host-shim\",\"language\":\"swift\",\"payloadBytes\":\(payload.utf8.count)}"
45
46 action.withCString { a in
47 json.withCString { j in
48 craft_ios_deliver_result(
49 a, UInt(strlen(a)),
50 j, UInt(strlen(j)),
51 requestId
52 )
53 }
54 }
55 return true
56 }
57}
packages/zig/src/ios_dispatch.zigmodified+166-10
Changes to packages/zig/src/ios_dispatch.zig
@@ -135,27 +135,130 @@ fn payloadOf(root: std.json.ObjectMap) ![]const u8 {
135135 };
136136}
137137
138/// Namespace routing.
138/// Namespace routing, with a hand-off for what Zig does not serve yet.
139139///
140/// One arm today. Every action not served here answers with an error rather
141/// than silence — a page awaiting a reply that never comes is
142/// indistinguishable from a slow one until its timeout fires, and thirty
143/// seconds of nothing is the worst diagnostic a bridge can give.
140/// This is the piece that makes the migration incremental rather than a
141/// cutover. An action Zig has not taken over is passed to the host shim, which
142/// answers it exactly as it does today; each later phase moves actions from the
143/// shim arm to the Zig arm and the page sees no difference. Without it, every
144/// phase before the last would ship an app missing most of its API.
145///
146/// Whatever happens, the page gets an answer. Silence is the one outcome ruled
147/// out: a reply that never arrives is indistinguishable from a slow one until
148/// the page's timeout fires, and thirty seconds of nothing is the worst
149/// diagnostic a bridge can give.
144150fn route(allocator: std.mem.Allocator, msg_type: []const u8, action: []const u8, data: []const u8) !void {
145151 if (std.mem.eql(u8, msg_type, "mobile")) {
146152 var bridge = bridge_mobile.MobileBridge.init(allocator);
147153 defer bridge.deinit();
148 bridge.handleMessage(action, data) catch |err| {
149 bridge_error.sendErrorToJS(allocator, action, bridge_error.BridgeError.UnknownAction);
150 return err;
151 };
152 return;
154
155 if (bridge.handleMessage(action, data)) |_| {
156 return;
157 } else |err| switch (err) {
158 // The only error worth handing on. Anything else means a handler
159 // ran and failed, and it has already decided what the real answer
160 // is — asking the shim to try the same action again would replace
161 // a specific failure with whatever it happens to say.
162 bridge_error.BridgeError.UnknownAction => {},
163 else => {
164 bridge_error.sendErrorToJS(allocator, action, asBridgeError(err));
165 return err;
166 },
167 }
168
169 if (try handOffToHost(action, data)) return;
170
171 bridge_error.sendErrorToJS(allocator, action, bridge_error.BridgeError.UnknownAction);
172 return error.UnknownAction;
153173 }
154174
155175 bridge_error.sendErrorToJS(allocator, action, bridge_error.BridgeError.UnknownAction);
156176 return error.UnknownNamespace;
157177}
158178
179/// Narrow an arbitrary handler error to one the page's error codes can express.
180///
181/// A handler's error set is wider than `BridgeError` — it picks up allocation
182/// failures and whatever else its implementation can raise. Those still have to
183/// reach the page as *something*: the alternative is a handler that fails and
184/// says nothing, which leaves the caller waiting on a promise that will not
185/// settle. `NativeCallFailed` is the honest catch-all for "it broke in a way
186/// the protocol has no word for".
187fn asBridgeError(err: anyerror) bridge_error.BridgeError {
188 return switch (err) {
189 error.AllocationFailed, error.OutOfMemory => bridge_error.BridgeError.AllocationFailed,
190 error.InvalidJSON => bridge_error.BridgeError.InvalidJSON,
191 error.InvalidParameter => bridge_error.BridgeError.InvalidParameter,
192 error.MissingData => bridge_error.BridgeError.MissingData,
193 error.NotFound => bridge_error.BridgeError.NotFound,
194 error.PermissionDenied => bridge_error.BridgeError.PermissionDenied,
195 error.PlatformNotSupported, error.UnsupportedPlatform => bridge_error.BridgeError.PlatformNotSupported,
196 error.Timeout => bridge_error.BridgeError.Timeout,
197 error.UnknownAction => bridge_error.BridgeError.UnknownAction,
198 else => bridge_error.BridgeError.NativeCallFailed,
199 };
200}
201
202/// Hand an action to the host shim. Returns false when there is no shim.
203///
204/// Found by name at runtime rather than linked, so a Zig-only app — the
205/// fixture, or any app that has migrated everything — carries no dependency on
206/// the shim existing. `objc_getClass` returning null is the normal answer
207/// there, not an error.
208///
209/// The shim does not reply. It produces a payload and hands it back through
210/// `craft_ios_deliver_result`, so the wire format, the request id, and the
211/// escaping all stay in one place. Two components replying to the same page by
212/// two different routes is how this codebase ended up with five envelopes.
213fn handOffToHost(action: []const u8, data: []const u8) !bool {
214 if (!builtin.target.os.tag.isDarwin()) return false;
215
216 const shim = objc.objc_getClass("CraftSwiftShim") orelse return false;
217
218 const sel = objc.sel_registerName("handleAction:payload:requestId:") orelse
219 return error.SelectorNotFound;
220
221 const allocator = std.heap.c_allocator;
222 const ns_action = try objc.createNSString(action, allocator);
223 const ns_payload = try objc.createNSString(data, allocator);
224
225 // The id the reply must carry. Read here, while the dispatch frame is still
226 // on the stack, because the shim may answer asynchronously and
227 // `request_context.current()` will be empty by then.
228 const request_id: i64 = if (request_context.current()) |id| @intCast(id) else -1;
229
230 const Fn = *const fn (objc.id, objc.SEL, objc.id, objc.id, i64) callconv(.c) bool;
231 const func: Fn = @ptrCast(&objc.objc_msgSend);
232 return func(shim, sel, ns_action, ns_payload, request_id);
233}
234
235/// Deliver a result the host shim produced.
236///
237/// Exported for the shim to call. Everything after this point is the same path
238/// a Zig-served action takes — same formatting, same escaping, same evalJS —
239/// which is the point: the shim decides *what* the answer is, never *how* it
240/// gets there.
241///
242/// `request_id` is negative for a message the page sent without one, matching
243/// the null the envelope carries in that case.
244export fn craft_ios_deliver_result(
245 action_ptr: [*]const u8,
246 action_len: usize,
247 json_ptr: [*]const u8,
248 json_len: usize,
249 request_id: i64,
250) callconv(.c) void {
251 const action = action_ptr[0..action_len];
252 const json = json_ptr[0..json_len];
253
254 // Restore the id the shim was handed, so the reply names the call that is
255 // actually waiting on it rather than falling back to action-name matching.
256 request_context.push(if (request_id < 0) null else @intCast(request_id));
257 defer request_context.pop();
258
259 bridge_error.sendResultToJS(std.heap.c_allocator, action, json);
260}
261
159262const testing = std.testing;
160263
161264test "an envelope without a type is rejected" {
@@ -220,3 +323,56 @@ test "evalJS without a webview reports it rather than crashing" {
220323
221324 try testing.expectError(error.NoWebView, evalJS("void 0"));
222325}
326
327test "with no host shim present, the hand-off declines rather than failing" {
328 // The host build has no `CraftSwiftShim` class, which is the same situation
329 // a fully-migrated app is in. Declining has to be the normal answer, not an
330 // error: an app that has taken every action into Zig should carry no
331 // dependency on a shim existing.
332 if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
333 try testing.expect(!try handOffToHost("anything", "{}"));
334}
335
336test "a handler error reaches the page as something the protocol can say" {
337 // A handler's error set is wider than BridgeError. Whatever it raises still
338 // has to arrive as an error code, because the alternative is a handler that
339 // fails silently and leaves the caller on a promise that never settles.
340 try testing.expectEqual(
341 bridge_error.BridgeError.AllocationFailed,
342 asBridgeError(error.OutOfMemory),
343 );
344 try testing.expectEqual(
345 bridge_error.BridgeError.UnknownAction,
346 asBridgeError(error.UnknownAction),
347 );
348 try testing.expectEqual(
349 bridge_error.BridgeError.PlatformNotSupported,
350 asBridgeError(error.UnsupportedPlatform),
351 );
352 // The catch-all. An error the protocol has no word for still gets one.
353 try testing.expectEqual(
354 bridge_error.BridgeError.NativeCallFailed,
355 asBridgeError(error.SomethingNobodyAnticipated),
356 );
357}
358
359test "a request id survives the trip out to the host and back" {
360 // The shim may answer asynchronously, by which point the dispatch frame is
361 // gone and `request_context.current()` is empty. The id is therefore read
362 // at hand-off time and passed explicitly, then restored on delivery. Losing
363 // it would drop the reply back to action-name matching, which hands one
364 // caller's answer to another whenever two calls of the same action are in
365 // flight.
366 request_context.push(7);
367 const captured: i64 = if (request_context.current()) |id| @intCast(id) else -1;
368 request_context.pop();
369
370 try testing.expectEqual(@as(i64, 7), captured);
371 try testing.expectEqual(@as(?u64, null), request_context.current());
372
373 // And the negative sentinel round-trips as "no id", matching the null the
374 // envelope carries when the page sent none.
375 request_context.push(if (captured < 0) null else @intCast(captured));
376 defer request_context.pop();
377 try testing.expectEqual(@as(?u64, 7), request_context.current());
378}