| @@ -135,27 +135,130 @@ fn payloadOf(root: std.json.ObjectMap) ![]const u8 { |
| 135 | 135 | }; |
| 136 | 136 | } |
| 137 | 137 | |
| 138 | | -/// Namespace routing. |
| 138 | +/// Namespace routing, with a hand-off for what Zig does not serve yet. |
| 139 | 139 | /// |
| 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. |
| 144 | 150 | fn route(allocator: std.mem.Allocator, msg_type: []const u8, action: []const u8, data: []const u8) !void { |
| 145 | 151 | if (std.mem.eql(u8, msg_type, "mobile")) { |
| 146 | 152 | var bridge = bridge_mobile.MobileBridge.init(allocator); |
| 147 | 153 | 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; |
| 153 | 173 | } |
| 154 | 174 | |
| 155 | 175 | bridge_error.sendErrorToJS(allocator, action, bridge_error.BridgeError.UnknownAction); |
| 156 | 176 | return error.UnknownNamespace; |
| 157 | 177 | } |
| 158 | 178 | |
| 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". |
| 187 | +fn 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. |
| 213 | +fn 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. |
| 244 | +export 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 | + |
| 159 | 262 | const testing = std.testing; |
| 160 | 263 | |
| 161 | 264 | test "an envelope without a type is rejected" { |
| @@ -220,3 +323,56 @@ test "evalJS without a webview reports it rather than crashing" { |
| 220 | 323 | |
| 221 | 324 | try testing.expectError(error.NoWebView, evalJS("void 0")); |
| 222 | 325 | } |
| 326 | + |
| 327 | +test "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 | + |
| 336 | +test "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 | + |
| 359 | +test "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 | +} |