also looking at this
feat(ios): the Zig runtime, offered to a Swift-hosted app
#103
7 files
+868
-59
| @@ -262,6 +262,36 @@ export function renderWatchEntitlements(config: CraftConfig): string { | ||
| 262 | 262 | return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0">\n<dict>\n${appGroups}</dict>\n</plist>\n` |
| 263 | 263 | } |
| 264 | 264 | |
| 265 | /** | |
| 266 | * The build settings that link the Zig runtime into the app target. | |
| 267 | * | |
| 268 | * Two things are load-bearing here: | |
| 269 | * | |
| 270 | * `LIBRARY_SEARCH_PATHS` is written twice, once per SDK, so `-lcraft-ios` | |
| 271 | * resolves to the device archive for a device build and the simulator archive | |
| 272 | * for a simulator one without the flag itself changing. Xcode applies the | |
| 273 | * `[sdk=...]` condition; xcodegen passes these keys through verbatim. | |
| 274 | * | |
| 275 | * The four `-u` flags are the reason anything works at all. Nothing in the | |
| 276 | * Swift source *references* these symbols — `CraftZigRuntime` and | |
| 277 | * `CraftSwiftShim` both find them with `dlsym` at runtime — so a static | |
| 278 | * archive contributes no objects for them and the linker drops the entire | |
| 279 | * runtime as unreachable. `-u` names them as undefined so the objects are | |
| 280 | * pulled in and the symbols end up in the binary for `dlsym` to find. | |
| 281 | * | |
| 282 | * `-u` rather than `-force_load`: forcing the whole archive would pull in | |
| 283 | * every object whether reachable or not, and these four entry points already | |
| 284 | * reach everything the bridge actually uses. | |
| 285 | */ | |
| 286 | export function renderRuntimeSettings(): string { | |
| 287 | return [ | |
| 288 | ' LIBRARY_SEARCH_PATHS[sdk=iphoneos*]: "$(PROJECT_DIR)/Runtime/device"', | |
| 289 | ' LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]: "$(PROJECT_DIR)/Runtime/simulator"', | |
| 290 | ' OTHER_LDFLAGS: "-lcraft-ios -Wl,-u,_craft_ios_handle_action -Wl,-u,_craft_ios_set_webview ' | |
| 291 | + '-Wl,-u,_craft_ios_deliver_result -Wl,-u,_craft_ios_deliver_error"', | |
| 292 | ].join('\n') | |
| 293 | } | |
| 294 | ||
| 265 | 295 | export function renderPrivacyManifest(config: CraftConfig): string { |
| 266 | 296 | const privacy = config.privacy ?? {} |
| 267 | 297 | const collected = privacy.collectedDataTypes ?? [] |
| @@ -334,6 +364,81 @@ export function syncWebAssets(source: string, output: string): void { | ||
| 334 | 364 | /** |
| 335 | 365 | * Initialize a new iOS project |
| 336 | 366 | */ |
| 367 | /** | |
| 368 | * Where the Zig runtime archives live, or null when this build has none. | |
| 369 | * | |
| 370 | * `CRAFT_IOS_RUNTIME` points at a directory holding the archives `zig build | |
| 371 | * build-ios-all` produces. It is opt-in and there is deliberately no fallback | |
| 372 | * search: a generated project with no runtime is the shipping default today, | |
| 373 | * and it works — `CraftZigRuntime.offer` is a `dlsym` miss, every action | |
| 374 | * answers false, and the Swift switch serves the whole surface exactly as it | |
| 375 | * always has. Guessing at a path would turn "no runtime" into "some runtime, | |
| 376 | * from somewhere", which is the failure mode the pantry contract exists to | |
| 377 | * rule out. | |
| 378 | * | |
| 379 | * This is the same shape as `CRAFT_BIN`: an explicit override for the monorepo | |
| 380 | * dev loop, not a lookup path. Shipping the runtime to real apps means putting | |
| 381 | * these archives in the pantry package beside the `craft` binary, which is a | |
| 382 | * distribution decision this function does not make. | |
| 383 | */ | |
| 384 | export function resolveRuntimeDir(): string | null { | |
| 385 | const dir = process.env.CRAFT_IOS_RUNTIME | |
| 386 | if (!dir) return null | |
| 387 | if (!existsSync(dir)) { | |
| 388 | throw new Error(`CRAFT_IOS_RUNTIME points at ${dir}, which does not exist.`) | |
| 389 | } | |
| 390 | return dir | |
| 391 | } | |
| 392 | ||
| 393 | /** The archives a runtime directory must contain, by Xcode SDK. */ | |
| 394 | const RUNTIME_ARCHIVES = { | |
| 395 | device: ['libcraft-ios.a'], | |
| 396 | // Both simulator slices, merged into one archive below. A Mac on Apple | |
| 397 | // silicon runs arm64 simulators and an Intel one runs x86_64; shipping a | |
| 398 | // single-slice archive makes the project build on one machine and fail to | |
| 399 | // link on the other, which is the kind of break that only shows up on | |
| 400 | // someone else's laptop. | |
| 401 | simulator: ['libcraft-ios-simulator-arm64.a', 'libcraft-ios-simulator-x64.a'], | |
| 402 | } as const | |
| 403 | ||
| 404 | /** | |
| 405 | * Copy the Zig archives into the generated project as `Runtime/<sdk>/libcraft-ios.a`. | |
| 406 | * | |
| 407 | * One name for both SDKs, in two directories, because that is what lets a | |
| 408 | * single `-lcraft-ios` in OTHER_LDFLAGS work for device and simulator builds: | |
| 409 | * Xcode picks the directory by SDK and the flag never changes. | |
| 410 | * | |
| 411 | * Returns true when a runtime was installed. | |
| 412 | */ | |
| 413 | export async function installRuntime(output: string, runtimeDir: string): Promise<boolean> { | |
| 414 | const dest = join(output, 'Runtime') | |
| 415 | rmSync(dest, { recursive: true, force: true }) | |
| 416 | ||
| 417 | for (const [sdk, archives] of Object.entries(RUNTIME_ARCHIVES)) { | |
| 418 | const present = archives.filter(a => existsSync(join(runtimeDir, a))) | |
| 419 | if (present.length === 0) { | |
| 420 | throw new Error( | |
| 421 | `CRAFT_IOS_RUNTIME=${runtimeDir} has none of ${archives.join(', ')}. ` | |
| 422 | + `Run \`zig build build-ios-all\` in packages/zig and point at its zig-out/lib.`, | |
| 423 | ) | |
| 424 | } | |
| 425 | ||
| 426 | const sdkDir = join(dest, sdk) | |
| 427 | mkdirSync(sdkDir, { recursive: true }) | |
| 428 | const target = join(sdkDir, 'libcraft-ios.a') | |
| 429 | ||
| 430 | if (present.length === 1) { | |
| 431 | cpSync(join(runtimeDir, present[0]!), target) | |
| 432 | } | |
| 433 | else { | |
| 434 | // `lipo -create` rather than picking one slice: see RUNTIME_ARCHIVES. | |
| 435 | await $`lipo -create ${present.map(a => join(runtimeDir, a))} -output ${target}`.quiet() | |
| 436 | } | |
| 437 | } | |
| 438 | ||
| 439 | return true | |
| 440 | } | |
| 441 | ||
| 337 | 442 | export async function init(options: InitOptions): Promise<void> { |
| 338 | 443 | const { name, bundleId, teamId, output } = options |
| 339 | 444 | |
| @@ -426,7 +531,14 @@ export async function init(options: InitOptions): Promise<void> { | ||
| 426 | 531 | SKIP_INSTALL: YES`) |
| 427 | 532 | } |
| 428 | 533 | |
| 534 | // The Zig runtime, when this build has one. Installed before the project is | |
| 535 | // rendered because the settings below name the directories it creates. | |
| 536 | const runtimeDir = resolveRuntimeDir() | |
| 537 | const hasRuntime = runtimeDir ? await installRuntime(output, runtimeDir) : false | |
| 538 | if (hasRuntime) console.log(' Linked the Zig runtime from', runtimeDir) | |
| 539 | ||
| 429 | 540 | const projectYml = projectYmlTemplate |
| 541 | .replace(/\{\{CRAFT_RUNTIME_SETTINGS\}\}/g, hasRuntime ? renderRuntimeSettings() : '') | |
| 430 | 542 | .replace(/\{\{APP_NAME\}\}/g, name) |
| 431 | 543 | .replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId) |
| 432 | 544 | .replace(/\{\{BUNDLE_ID_PREFIX\}\}/g, bundleIdPrefix) |
| @@ -338,6 +338,12 @@ struct CraftWebView: UIViewRepresentable { | ||
| 338 | 338 | webView.navigationDelegate = context.coordinator |
| 339 | 339 | webView.isOpaque = false |
| 340 | 340 | |
| 341 | // Before any message can be offered. Zig evaluates every reply against | |
| 342 | // this webview, and without it an action would run, succeed, and reach | |
| 343 | // `error.NoWebView` on the way back — the page hearing nothing while | |
| 344 | // the work happened. | |
| 345 | CraftZigRuntime.attach(webView) | |
| 346 | ||
| 341 | 347 | // Register with DeepLinkManager |
| 342 | 348 | DeepLinkManager.shared.setWebView(webView) |
| 343 | 349 | |
| @@ -363,6 +369,13 @@ struct CraftWebView: UIViewRepresentable { | ||
| 363 | 369 | |
| 364 | 370 | func updateUIView(_ webView: WKWebView, context: Context) {} |
| 365 | 371 | |
| 372 | /// The teardown half of `CraftZigRuntime.attach`, called by SwiftUI when | |
| 373 | /// this representable goes away. Without it Zig keeps an unretained | |
| 374 | /// pointer to a deallocated webview and answers into freed memory. | |
| 375 | static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) { | |
| 376 | CraftZigRuntime.detach(uiView) | |
| 377 | } | |
| 378 | ||
| 366 | 379 | func makeCoordinator() -> Coordinator { |
| 367 | 380 | let coordinator = Coordinator(config: config) |
| 368 | 381 | // The Zig dispatcher finds CraftSwiftShim by class name; the shim finds |
| @@ -516,7 +529,24 @@ struct CraftWebView: UIViewRepresentable { | ||
| 516 | 529 | guard let body = message.body as? [String: Any], |
| 517 | 530 | let action = body["action"] as? String else { return } |
| 518 | 531 | |
| 519 | dispatch(action: action, body: body, callbackId: body["callbackId"] as? String) | |
| 532 | let callbackId = body["callbackId"] as? String | |
| 533 | ||
| 534 | // The Zig runtime first, when there is one. | |
| 535 | // | |
| 536 | // `offer` returns true when Zig has taken responsibility for the | |
| 537 | // call — served it, or refused it and settled the page's promise | |
| 538 | // on the way. Either way the answer is on its way and this method | |
| 539 | // must not produce a second one. False means no Zig module | |
| 540 | // recognised the action, which is every action still listed in the | |
| 541 | // switch below and nothing else. | |
| 542 | // | |
| 543 | // In an app with no Zig runtime linked, `offer` is a `dlsym` miss | |
| 544 | // that answers false for everything, and the switch serves the | |
| 545 | // whole surface exactly as it did before. That is why this is one | |
| 546 | // line and not a build flag. | |
| 547 | if CraftZigRuntime.offer(action: action, body: body, callbackId: callbackId) { return } | |
| 548 | ||
| 549 | dispatch(action: action, body: body, callbackId: callbackId) | |
| 520 | 550 | } |
| 521 | 551 | |
| 522 | 552 | /// The action switch, reachable from two directions: the |
| @@ -2421,6 +2451,27 @@ struct CraftWebView: UIViewRepresentable { | ||
| 2421 | 2451 | } |
| 2422 | 2452 | })(window.craft); |
| 2423 | 2453 | |
| 2454 | // The reply route for actions the Zig runtime serves. | |
| 2455 | // | |
| 2456 | // Zig owns one wire format and calls these two functions by name; | |
| 2457 | // this page owns `_callbacks`, keyed by the 'cb_<n>' ids `_invoke` | |
| 2458 | // hands out. The whole adaptation is turning the numeric id Zig | |
| 2459 | // carries back into that key. Nothing else is translated, because | |
| 2460 | // nothing else differs: `craftSpeechStart` and friends already | |
| 2461 | // arrive as plain CustomEvents from both sides. | |
| 2462 | // | |
| 2463 | // A null id means the page sent no callback — the tray-style | |
| 2464 | // fire-and-forget posts — so there is nothing to settle and | |
| 2465 | // dropping it is correct rather than lossy. | |
| 2466 | window.__craftBridgeResult = function (action, result, id) { | |
| 2467 | if (id === null || id === undefined) return; | |
| 2468 | window.craft._resolveCallback('cb_' + id, result); | |
| 2469 | }; | |
| 2470 | window.__craftBridgeError = function (ctx) { | |
| 2471 | if (!ctx || ctx.id === null || ctx.id === undefined) return; | |
| 2472 | window.craft._rejectCallback('cb_' + ctx.id, ctx.message, ctx.code); | |
| 2473 | }; | |
| 2474 | ||
| 2424 | 2475 | // Dispatch ready event |
| 2425 | 2476 | window.dispatchEvent(new CustomEvent('craftReady', {detail: window.craft})); |
| 2426 | 2477 | console.log('Craft iOS bridge initialized'); |
| @@ -5193,6 +5244,123 @@ extension CraftWebView.Coordinator: WCSessionDelegate { | ||
| 5193 | 5244 | |
| 5194 | 5245 | // MARK: - Zig hand-off shim |
| 5195 | 5246 | |
| 5247 | /// The seam the page's messages reach the Zig runtime through. | |
| 5248 | /// | |
| 5249 | /// The opposite direction from `CraftSwiftShim` below, and the two together | |
| 5250 | /// are the whole hand-off: this class offers each message to Zig, Zig serves | |
| 5251 | /// what it has migrated and declines the rest, and for anything Zig *did* | |
| 5252 | /// take that needs Swift work, `CraftSwiftShim` is how it comes back. | |
| 5253 | /// | |
| 5254 | /// Discovery is `dlsym`, exactly as the shim's is, and for the same reason: | |
| 5255 | /// neither side links the other. An app built without the Zig static library | |
| 5256 | /// finds nothing here, `offer` answers false for every action, and the | |
| 5257 | /// coordinator's switch serves the entire surface as it always has. There is | |
| 5258 | /// no build flag and no second code path — the absence of a symbol is the | |
| 5259 | /// off switch. | |
| 5260 | @objc(CraftZigRuntime) | |
| 5261 | final class CraftZigRuntime: NSObject { | |
| 5262 | private typealias SetWebViewFn = @convention(c) (UnsafeMutableRawPointer?) -> Void | |
| 5263 | private typealias ClearWebViewFn = @convention(c) (UnsafeMutableRawPointer?) -> Void | |
| 5264 | private typealias HandleActionFn = @convention(c) ( | |
| 5265 | UnsafePointer<CChar>, UInt, UnsafePointer<CChar>, UInt, Int64 | |
| 5266 | ) -> Bool | |
| 5267 | ||
| 5268 | private static let image: UnsafeMutableRawPointer? = dlopen(nil, RTLD_NOW) | |
| 5269 | ||
| 5270 | private static let setWebViewFn: SetWebViewFn? = { | |
| 5271 | guard let sym = dlsym(image, "craft_ios_set_webview") else { return nil } | |
| 5272 | return unsafeBitCast(sym, to: SetWebViewFn.self) | |
| 5273 | }() | |
| 5274 | ||
| 5275 | private static let clearWebViewFn: ClearWebViewFn? = { | |
| 5276 | guard let sym = dlsym(image, "craft_ios_clear_webview") else { return nil } | |
| 5277 | return unsafeBitCast(sym, to: ClearWebViewFn.self) | |
| 5278 | }() | |
| 5279 | ||
| 5280 | private static let handleActionFn: HandleActionFn? = { | |
| 5281 | guard let sym = dlsym(image, "craft_ios_handle_action") else { return nil } | |
| 5282 | return unsafeBitCast(sym, to: HandleActionFn.self) | |
| 5283 | }() | |
| 5284 | ||
| 5285 | /// Whether this build has a Zig runtime at all. Read by nothing here; kept | |
| 5286 | /// because "is Zig linked" is the first question to ask when an action | |
| 5287 | /// behaves like the Swift one after a migration was supposed to move it. | |
| 5288 | @objc static var isLinked: Bool { handleActionFn != nil } | |
| 5289 | ||
| 5290 | /// Give Zig the webview its replies are evaluated against. | |
| 5291 | /// | |
| 5292 | /// Unretained on purpose, on both sides: this is the app's own root view | |
| 5293 | /// and it outlives the runtime. A retain here would be a cycle with | |
| 5294 | /// nothing to break it. | |
| 5295 | static func attach(_ webView: WKWebView) { | |
| 5296 | setWebViewFn?(Unmanaged.passUnretained(webView).toOpaque()) | |
| 5297 | } | |
| 5298 | ||
| 5299 | /// Forget this webview, if Zig is still holding it. | |
| 5300 | /// | |
| 5301 | /// `attach` hands over an unretained pointer, so the moment SwiftUI | |
| 5302 | /// deallocates the view Zig is holding freed memory and every later reply | |
| 5303 | /// is an `objc_msgSend` into it. Nothing called this before, and nothing | |
| 5304 | /// had to while the app had exactly one webview for its whole life — but | |
| 5305 | /// a `UIViewRepresentable` is rebuilt whenever its identity changes, and | |
| 5306 | /// the second rebuild is where that assumption stops holding. | |
| 5307 | /// | |
| 5308 | /// The webview is passed rather than implied: Zig clears only if this is | |
| 5309 | /// still the pointer it has, so a rebuild that makes the replacement | |
| 5310 | /// before dismantling the original cannot blank the live one. | |
| 5311 | static func detach(_ webView: WKWebView) { | |
| 5312 | clearWebViewFn?(Unmanaged.passUnretained(webView).toOpaque()) | |
| 5313 | } | |
| 5314 | ||
| 5315 | /// Offer one page message to the Zig dispatcher. | |
| 5316 | /// | |
| 5317 | /// Returns true when Zig has taken responsibility — the caller must not | |
| 5318 | /// answer as well. False when no Zig module recognised the action, or when | |
| 5319 | /// there is no runtime to ask. | |
| 5320 | static func offer(action: String, body: [String: Any], callbackId: String?) -> Bool { | |
| 5321 | guard let handleActionFn else { return false } | |
| 5322 | ||
| 5323 | // `_invoke` flattens the payload into the message — | |
| 5324 | // `Object.assign({}, payload, {action, callbackId})` — so the payload | |
| 5325 | // Zig's handlers parse is the message minus the two envelope keys. | |
| 5326 | // This is the exact inverse of what `CraftSwiftShim.handleAction` does | |
| 5327 | // when a call travels the other way, and the two must stay inverses: | |
| 5328 | // leaving `action` in would put a key in the payload no handler | |
| 5329 | // expects, and dropping a real field would hand a handler defaults the | |
| 5330 | // page never asked for. | |
| 5331 | var payload = body | |
| 5332 | payload.removeValue(forKey: "action") | |
| 5333 | payload.removeValue(forKey: "callbackId") | |
| 5334 | ||
| 5335 | // A payload that will not serialise is not offered. Falling through to | |
| 5336 | // the Swift switch is the safe direction: it reads `body` directly and | |
| 5337 | // never needs the round trip through JSON that just failed. | |
| 5338 | guard JSONSerialization.isValidJSONObject(payload), | |
| 5339 | let data = try? JSONSerialization.data(withJSONObject: payload), | |
| 5340 | let json = String(data: data, encoding: .utf8) | |
| 5341 | else { return false } | |
| 5342 | ||
| 5343 | return action.withCString { a in | |
| 5344 | json.withCString { p in | |
| 5345 | handleActionFn(a, UInt(strlen(a)), p, UInt(strlen(p)), requestId(from: callbackId)) | |
| 5346 | } | |
| 5347 | } | |
| 5348 | } | |
| 5349 | ||
| 5350 | /// "cb_7" -> 7, and -1 for a message with no callback waiting on it. | |
| 5351 | /// | |
| 5352 | /// -1 rather than 0, matching what `craft_ios_deliver_result` already | |
| 5353 | /// treats as "no id": zero is a perfectly good callback number, and a | |
| 5354 | /// sentinel that collides with a real id would deliver one caller's answer | |
| 5355 | /// to another. | |
| 5356 | private static func requestId(from callbackId: String?) -> Int64 { | |
| 5357 | guard let callbackId, callbackId.hasPrefix("cb_"), | |
| 5358 | let n = Int64(callbackId.dropFirst(3)) | |
| 5359 | else { return -1 } | |
| 5360 | return n | |
| 5361 | } | |
| 5362 | } | |
| 5363 | ||
| 5196 | 5364 | /// The seam the Zig dispatcher hands unmigrated actions through. |
| 5197 | 5365 | /// |
| 5198 | 5366 | /// Discovery is symmetric and both directions are runtime-only. Zig finds this |
| @@ -52,7 +52,7 @@ pub const MobileBridge = struct { | ||
| 52 | 52 | } |
| 53 | 53 | |
| 54 | 54 | fn getDeviceInfo(self: *Self) !void { |
| 55 | var buf: [512]u8 = undefined; | |
| 55 | var buf: [4096]u8 = undefined; | |
| 56 | 56 | const json = try describeDevice(&buf); |
| 57 | 57 | bridge_error.sendResultToJS(self.allocator, A.get_device_info, json); |
| 58 | 58 | } |
| @@ -88,14 +88,142 @@ fn describeDevice(buf: []u8) ![]const u8 { | ||
| 88 | 88 | const system_name = try nsStringField(device, "systemName"); |
| 89 | 89 | const system_version = try nsStringField(device, "systemVersion"); |
| 90 | 90 | const model = try nsStringField(device, "model"); |
| 91 | // The one field a user can set, so the one that can carry a quote or a | |
| 92 | // backslash: "Glenn\"s iPhone" is a legal device name and an illegal JSON | |
| 93 | // string. Everything here goes through `escapeJsonString` for that reason, | |
| 94 | // not just this one — a locale identifier is tame today and the escaping | |
| 95 | // costs nothing. | |
| 96 | const device_name = try nsStringField(device, "name"); | |
| 97 | ||
| 98 | // `identifierForVendor` is nil before the device is unlocked once after | |
| 99 | // boot. The spec answers `""` there (`device.identifierForVendor?.uuidString | |
| 100 | // ?? ""`), so this does too: a missing id and an empty one are the same | |
| 101 | // thing to a page, and inventing a UUID would be worse than either. | |
| 102 | const vendor_id: []const u8 = blk: { | |
| 103 | const sel_ifv = objc.sel_registerName("identifierForVendor") orelse break :blk ""; | |
| 104 | const uuid = objc.msgSendId(device, sel_ifv); | |
| 105 | if (uuid == null) break :blk ""; | |
| 106 | break :blk nsStringField(uuid, "UUIDString") catch ""; | |
| 107 | }; | |
| 108 | ||
| 109 | // UIScreen, for the three screen fields. A nil mainScreen is not a case | |
| 110 | // that happens in an app with a window, but reading `bounds` off nil would | |
| 111 | // answer a zero rect rather than fail, and a page cannot tell a 0x0 screen | |
| 112 | // from a broken one. | |
| 113 | const UIScreen = objc.objc_getClass("UIScreen") orelse return error.ClassNotFound; | |
| 114 | const sel_main = objc.sel_registerName("mainScreen") orelse return error.SelectorNotFound; | |
| 115 | const screen = objc.msgSendId(UIScreen, sel_main); | |
| 116 | if (screen == null) return error.NoMainScreen; | |
| 117 | ||
| 118 | const bounds = try msgSendRect(screen, "bounds"); | |
| 119 | const scale = try msgSendCGFloat(screen, "scale"); | |
| 120 | ||
| 121 | // `batteryLevel` is -1 unless battery monitoring is enabled, and neither | |
| 122 | // the spec nor this enables it. Reporting the same -1 is the parity that | |
| 123 | // matters: a page that special-cases it keeps working, and turning | |
| 124 | // monitoring on here would be a behaviour change the spec never made. | |
| 125 | const battery_level = try msgSendFloat(device, "batteryLevel"); | |
| 126 | const battery_state = batteryStateName(try msgSendInteger(device, "batteryState")); | |
| 127 | ||
| 128 | const NSLocale = objc.objc_getClass("NSLocale") orelse return error.ClassNotFound; | |
| 129 | const sel_curloc = objc.sel_registerName("currentLocale") orelse return error.SelectorNotFound; | |
| 130 | const locale_obj = objc.msgSendId(NSLocale, sel_curloc); | |
| 131 | const locale = if (locale_obj != null) nsStringField(locale_obj, "localeIdentifier") catch "" else ""; | |
| 132 | ||
| 133 | const NSTimeZone = objc.objc_getClass("NSTimeZone") orelse return error.ClassNotFound; | |
| 134 | const sel_localtz = objc.sel_registerName("localTimeZone") orelse return error.SelectorNotFound; | |
| 135 | const tz_obj = objc.msgSendId(NSTimeZone, sel_localtz); | |
| 136 | const timezone = if (tz_obj != null) nsStringField(tz_obj, "name") catch "" else ""; | |
| 137 | ||
| 138 | var esc: [5][512]u8 = undefined; | |
| 91 | 139 | |
| 92 | 140 | return std.fmt.bufPrint( |
| 93 | 141 | buf, |
| 94 | "", | |
| 95 | .{ system_name, system_version, model, isSimulator() }, | |
| 142 | "", | |
| 158 | .{ | |
| 159 | try bridge_error.escapeJsonString(&esc[0], model), | |
| 160 | try bridge_error.escapeJsonString(&esc[1], device_name), | |
| 161 | system_name, | |
| 162 | system_version, | |
| 163 | vendor_id, | |
| 164 | isSimulator(), | |
| 165 | bounds.size.width, | |
| 166 | bounds.size.height, | |
| 167 | scale, | |
| 168 | battery_level, | |
| 169 | battery_state, | |
| 170 | try bridge_error.escapeJsonString(&esc[2], locale), | |
| 171 | try bridge_error.escapeJsonString(&esc[3], timezone), | |
| 172 | }, | |
| 96 | 173 | ); |
| 97 | 174 | } |
| 98 | 175 | |
| 176 | /// `UIDevice.BatteryState` as the spec spells it (`getBatteryState`, | |
| 177 | /// `CraftApp.swift:3204`). The raw values are UIKit's: unknown 0, unplugged 1, | |
| 178 | /// charging 2, full 3. Anything else is "unknown", matching the spec's | |
| 179 | /// `default` rather than inventing a fifth name for a value UIKit does not | |
| 180 | /// currently produce. | |
| 181 | fn batteryStateName(raw: isize) []const u8 { | |
| 182 | return switch (raw) { | |
| 183 | 1 => "unplugged", | |
| 184 | 2 => "charging", | |
| 185 | 3 => "full", | |
| 186 | else => "unknown", | |
| 187 | }; | |
| 188 | } | |
| 189 | ||
| 190 | /// A zero-argument selector returning `CGRect`. | |
| 191 | /// | |
| 192 | /// A plain `objc_msgSend` cast, deliberately — **not** | |
| 193 | /// `objc_runtime.msgSendStret`, which picks `objc_msgSend_stret` for anything | |
| 194 | /// over 16 bytes. `CGRect` is 32, and `objc_msgSend_stret` does not exist on | |
| 195 | /// arm64: the struct return travels in x8 through ordinary `objc_msgSend`. | |
| 196 | /// `bridge_mobile_motion.zig:124` records the same trap, and `macos.zig`'s | |
| 197 | /// `msgSendRect` is the shape this copies. | |
| 198 | fn msgSendRect(target: objc.id, comptime selector: [:0]const u8) !objc.CGRect { | |
| 199 | const sel = objc.sel_registerName(selector) orelse return error.SelectorNotFound; | |
| 200 | const func: *const fn (objc.id, objc.SEL) callconv(.c) objc.CGRect = @ptrCast(&objc.objc_msgSend); | |
| 201 | return func(target, sel); | |
| 202 | } | |
| 203 | ||
| 204 | /// A zero-argument selector returning `CGFloat` (`double` on 64-bit). | |
| 205 | fn msgSendCGFloat(target: objc.id, comptime selector: [:0]const u8) !objc.CGFloat { | |
| 206 | const sel = objc.sel_registerName(selector) orelse return error.SelectorNotFound; | |
| 207 | const func: *const fn (objc.id, objc.SEL) callconv(.c) objc.CGFloat = @ptrCast(&objc.objc_msgSend); | |
| 208 | return func(target, sel); | |
| 209 | } | |
| 210 | ||
| 211 | /// A zero-argument selector returning `float`. `batteryLevel` is a `float`, | |
| 212 | /// not a `CGFloat` — reading it as a double returns garbage, because the two | |
| 213 | /// come back in different halves of the same register. | |
| 214 | fn msgSendFloat(target: objc.id, comptime selector: [:0]const u8) !f32 { | |
| 215 | const sel = objc.sel_registerName(selector) orelse return error.SelectorNotFound; | |
| 216 | const func: *const fn (objc.id, objc.SEL) callconv(.c) f32 = @ptrCast(&objc.objc_msgSend); | |
| 217 | return func(target, sel); | |
| 218 | } | |
| 219 | ||
| 220 | /// A zero-argument selector returning `NSInteger`. | |
| 221 | fn msgSendInteger(target: objc.id, comptime selector: [:0]const u8) !isize { | |
| 222 | const sel = objc.sel_registerName(selector) orelse return error.SelectorNotFound; | |
| 223 | const func: *const fn (objc.id, objc.SEL) callconv(.c) isize = @ptrCast(&objc.objc_msgSend); | |
| 224 | return func(target, sel); | |
| 225 | } | |
| 226 | ||
| 99 | 227 | /// Send a zero-argument selector that returns an NSString, and borrow its UTF-8. |
| 100 | 228 | /// |
| 101 | 229 | /// The returned slice points into the NSString's own buffer, which is |
| @@ -670,9 +670,12 @@ pub const uikit = if (is_darwin) struct { | ||
| 670 | 670 | /// "which is already presenting" and silently does nothing, so the |
| 671 | 671 | /// completion handler never fires and the caller waits out its timeout. |
| 672 | 672 | pub fn topmostViewController() !objc.id { |
| 673 | // One `orelse`, not an `orelse` plus a null check. `getWebView` used to | |
| 674 | // answer a double optional, so the `orelse` unwrapped the outer level | |
| 675 | // and the second line was the guard that actually ran; now the slot is | |
| 676 | // a single `objc.id` and this catches both spellings of "no webview". | |
| 673 | 677 | const webview = ios_dispatch.getWebView() orelse |
| 674 | 678 | return bridge_error.BridgeError.WebViewHandleNotSet; |
| 675 | if (webview == null) return bridge_error.BridgeError.WebViewHandleNotSet; | |
| 676 | 679 | |
| 677 | 680 | const sel_window = objc.sel_registerName("window") orelse return error.SelectorNotFound; |
| 678 | 681 | const window = objc.msgSendId(webview, sel_window); |
| @@ -703,9 +706,12 @@ pub const uikit = if (is_darwin) struct { | ||
| 703 | 706 | /// pre-iOS-14 `+requestReview`, which is deprecated and may be gone, and it |
| 704 | 707 | /// is certainly not a reason to reply success having done nothing. |
| 705 | 708 | fn keyWindowScene() !objc.id { |
| 709 | // One `orelse`, not an `orelse` plus a null check. `getWebView` used to | |
| 710 | // answer a double optional, so the `orelse` unwrapped the outer level | |
| 711 | // and the second line was the guard that actually ran; now the slot is | |
| 712 | // a single `objc.id` and this catches both spellings of "no webview". | |
| 706 | 713 | const webview = ios_dispatch.getWebView() orelse |
| 707 | 714 | return bridge_error.BridgeError.WebViewHandleNotSet; |
| 708 | if (webview == null) return bridge_error.BridgeError.WebViewHandleNotSet; | |
| 709 | 715 | |
| 710 | 716 | const sel_window = objc.sel_registerName("window") orelse return error.SelectorNotFound; |
| 711 | 717 | const window = objc.msgSendId(webview, sel_window); |
| @@ -3,6 +3,7 @@ const builtin = ; | ||
| 3 | 3 | const objc_runtime = ; |
| 4 | 4 | const request_context = ; |
| 5 | 5 | const bridge_error = ; |
| 6 | const capabilities = ; | |
| 6 | 7 | pub const ios_async = ; |
| 7 | 8 | pub const ios_events = ; |
| 8 | 9 | pub const ios_delegate = ; |
| @@ -65,13 +66,24 @@ const objc = objc_runtime.objc; | ||
| 65 | 66 | /// One slot, because iOS has one webview. When multi-window arrives this |
| 66 | 67 | /// becomes a lookup keyed by the same request id the reply already carries — |
| 67 | 68 | /// the id is threaded through `request_context` for exactly that reason. |
| 68 | var global_webview: ?objc.id = null; | |
| 69 | /// `objc.id`, not `?objc.id`. The extra optional was a real defect, not | |
| 70 | /// redundancy: `objc.id` is already `?*anyopaque`, so a double optional made | |
| 71 | /// `setWebView(null)` store a *present* outer value wrapping a nil webview, | |
| 72 | /// and `global_webview orelse return error.NoWebView` below then unwrapped | |
| 73 | /// only the outer one and sailed past. Every reply after such a call went to | |
| 74 | /// `objc_msgSend(nil, …)`, which is a silent no-op — the action ran, it | |
| 75 | /// worked, and the page heard nothing, with no error anywhere to say so. | |
| 76 | /// | |
| 77 | /// One optional means "no webview" has one spelling and `orelse` catches it. | |
| 78 | /// (`macos.zig:4075` still holds the two-level version. It is unreachable | |
| 79 | /// there today because nothing passes nil, but it is the same shape.) | |
| 80 | var global_webview: objc.id = null; | |
| 69 | 81 | |
| 70 | 82 | pub fn setWebView(webview: objc.id) void { |
| 71 | 83 | global_webview = webview; |
| 72 | 84 | } |
| 73 | 85 | |
| 74 | pub fn getWebView() ?objc.id { | |
| 86 | pub fn getWebView() objc.id { | |
| 75 | 87 | return global_webview; |
| 76 | 88 | } |
| 77 | 89 | |
| @@ -184,62 +196,86 @@ fn payloadOf(root: std.json.ObjectMap) ![]const u8 { | ||
| 184 | 196 | /// the page's timeout fires, and thirty seconds of nothing is the worst |
| 185 | 197 | /// diagnostic a bridge can give. |
| 186 | 198 | fn route(allocator: std.mem.Allocator, msg_type: []const u8, action: []const u8, data: []const u8) !void { |
| 187 | if (std.mem.eql(u8, msg_type, "mobile")) { | |
| 188 | // The app's own configuration, before anything else. An action the | |
| 189 | // spec gates on a `config.enable*` flag is refused here when that flag | |
| 190 | // is off, rather than served — which is what every one of these did | |
| 191 | // until `ios_config.zig` existed, because Zig had no way to read the | |
| 192 | // flag. | |
| 193 | // | |
| 194 | // Only actions this file's modules serve are gated. `gateFor` returns | |
| 195 | // null for the rest, so an action still on the shim keeps whatever | |
| 196 | // answer the shim gives it: Zig refusing on Swift's behalf would mean | |
| 197 | // deciding, from Zig's parse of the config, a question the arm that | |
| 198 | // owns the action is about to decide from its own. | |
| 199 | if (ios_config.gateFor(action)) |feature| { | |
| 200 | if (!ios_config.isEnabled(feature)) { | |
| 201 | std.log.info( | |
| 202 | "ios: refusing {s}; {s} is not enabled in craft.config.json", | |
| 203 | .{ action, feature.jsonKey() }, | |
| 204 | ); | |
| 205 | bridge_error.sendErrorToJS(allocator, action, bridge_error.BridgeError.CapabilityDisabled); | |
| 206 | return bridge_error.BridgeError.CapabilityDisabled; | |
| 207 | } | |
| 208 | } | |
| 199 | if (!std.mem.eql(u8, msg_type, "mobile")) { | |
| 200 | bridge_error.sendErrorToJS(allocator, action, bridge_error.BridgeError.UnknownAction); | |
| 201 | return error.UnknownNamespace; | |
| 202 | } | |
| 209 | 203 | |
| 210 | // First module that recognises the action wins. UnknownAction means | |
| 211 | // "not mine, ask the next"; any other error means a handler ran and | |
| 212 | // failed, and its answer is final — retrying the same action against | |
| 213 | // another module (or the shim) would replace a specific failure with | |
| 214 | // whatever the next thing happens to say. | |
| 215 | // | |
| 216 | // `test/ios_conformance_test.zig` fails the build if one action is | |
| 217 | // declared by two modules, so first-match is deterministic rather than | |
| 218 | // dependent on the order of this tuple. That check was claimed here | |
| 219 | // before it existed; it exists now. | |
| 220 | inline for (mobile_bridges) |Bridge| { | |
| 221 | var bridge = Bridge.init(allocator); | |
| 222 | defer bridge.deinit(); | |
| 223 | ||
| 224 | if (bridge.handleMessage(action, data)) |_| { | |
| 225 | return; | |
| 226 | } else |err| switch (err) { | |
| 227 | bridge_error.BridgeError.UnknownAction => {}, | |
| 228 | else => { | |
| 229 | bridge_error.sendErrorToJS(allocator, action, asBridgeError(err)); | |
| 230 | return err; | |
| 231 | }, | |
| 232 | } | |
| 233 | } | |
| 204 | switch (try offerToModules(allocator, action, data)) { | |
| 205 | .answered => return, | |
| 206 | .not_ours => {}, | |
| 207 | } | |
| 234 | 208 | |
| 235 | if (try handOffToHost(action, data)) return; | |
| 209 | if (try handOffToHost(action, data)) return; | |
| 236 | 210 | |
| 237 | bridge_error.sendErrorToJS(allocator, action, bridge_error.BridgeError.UnknownAction); | |
| 238 | return error.UnknownAction; | |
| 211 | bridge_error.sendErrorToJS(allocator, action, bridge_error.BridgeError.UnknownAction); | |
| 212 | return error.UnknownAction; | |
| 213 | } | |
| 214 | ||
| 215 | /// Whether the Zig modules took responsibility for an action. | |
| 216 | /// | |
| 217 | /// The distinction the hand-off is built on, and the one a Swift-hosted app | |
| 218 | /// needs too: `answered` means the page will hear back from Zig and nobody | |
| 219 | /// else may reply, `not_ours` means nothing here recognised the action and | |
| 220 | /// whoever asked must serve it themselves. | |
| 221 | /// | |
| 222 | /// A refusal is `answered`. Every path that calls `sendErrorToJS` has settled | |
| 223 | /// the page's promise, so treating "refused" as "not mine" would let a second | |
| 224 | /// arm answer the same call — the double-reply this envelope exists to | |
| 225 | /// prevent. | |
| 226 | const Offer = enum { answered, not_ours }; | |
| 227 | ||
| 228 | /// The capability gate, then the module chain. Shared by the two entry points | |
| 229 | /// so they cannot drift: `route` for a message Zig received itself, and | |
| 230 | /// `craft_ios_handle_action` for one a Swift host received and passed over. | |
| 231 | fn offerToModules(allocator: std.mem.Allocator, action: []const u8, data: []const u8) !Offer { | |
| 232 | // The app's own configuration, before anything else. An action the spec | |
| 233 | // gates on a `config.enable*` flag is refused here when that flag is off, | |
| 234 | // rather than served — which is what every one of these did until | |
| 235 | // `ios_config.zig` existed, because Zig had no way to read the flag. | |
| 236 | // | |
| 237 | // Only actions this file's modules serve are gated. `gateFor` returns null | |
| 238 | // for the rest, so an action still on the shim keeps whatever answer the | |
| 239 | // shim gives it: Zig refusing on Swift's behalf would mean deciding, from | |
| 240 | // Zig's parse of the config, a question the arm that owns the action is | |
| 241 | // about to decide from its own. | |
| 242 | if (ios_config.gateFor(action)) |feature| { | |
| 243 | if (!ios_config.isEnabled(feature)) { | |
| 244 | std.log.info( | |
| 245 | "ios: refusing {s}; {s} is not enabled in craft.config.json", | |
| 246 | .{ action, feature.jsonKey() }, | |
| 247 | ); | |
| 248 | bridge_error.sendErrorToJS(allocator, action, bridge_error.BridgeError.CapabilityDisabled); | |
| 249 | return bridge_error.BridgeError.CapabilityDisabled; | |
| 250 | } | |
| 239 | 251 | } |
| 240 | 252 | |
| 241 | bridge_error.sendErrorToJS(allocator, action, bridge_error.BridgeError.UnknownAction); | |
| 242 | return error.UnknownNamespace; | |
| 253 | // First module that recognises the action wins. UnknownAction means | |
| 254 | // "not mine, ask the next"; any other error means a handler ran and | |
| 255 | // failed, and its answer is final — retrying the same action against | |
| 256 | // another module (or the shim) would replace a specific failure with | |
| 257 | // whatever the next thing happens to say. | |
| 258 | // | |
| 259 | // `test/ios_conformance_test.zig` fails the build if one action is | |
| 260 | // declared by two modules, so first-match is deterministic rather than | |
| 261 | // dependent on the order of this tuple. That check was claimed here | |
| 262 | // before it existed; it exists now. | |
| 263 | inline for (mobile_bridges) |Bridge| { | |
| 264 | var bridge = Bridge.init(allocator); | |
| 265 | defer bridge.deinit(); | |
| 266 | ||
| 267 | if (bridge.handleMessage(action, data)) |_| { | |
| 268 | return .answered; | |
| 269 | } else |err| switch (err) { | |
| 270 | bridge_error.BridgeError.UnknownAction => {}, | |
| 271 | else => { | |
| 272 | bridge_error.sendErrorToJS(allocator, action, asBridgeError(err)); | |
| 273 | return err; | |
| 274 | }, | |
| 275 | } | |
| 276 | } | |
| 277 | ||
| 278 | return .not_ours; | |
| 243 | 279 | } |
| 244 | 280 | |
| 245 | 281 | /// Every Zig module serving the `mobile` namespace, in dispatch order. |
| @@ -284,6 +320,83 @@ const mobile_bridges = .{ | ||
| 284 | 320 | bridge_mobile_speech.SpeechBridge, |
| 285 | 321 | }; |
| 286 | 322 | |
| 323 | /// The same modules again, as their capability manifests. | |
| 324 | /// | |
| 325 | /// A second spelling of `mobile_bridges` is not something to want, but the two | |
| 326 | /// cannot be one: `mobile_bridges` holds bridge *types* and `capability_actions` | |
| 327 | /// is declared at module scope, so there is no way to reach a module's manifest | |
| 328 | /// from its struct. The comptime length check below is what keeps them in step | |
| 329 | /// — a module added to one list and not the other fails the build. | |
| 330 | const mobile_manifests = [_][]const capabilities.ActionDecl{ | |
| 331 | &bridge_mobile.capability_actions, | |
| 332 | &bridge_mobile_clipboard.capability_actions, | |
| 333 | &bridge_mobile_haptics.capability_actions, | |
| 334 | &bridge_mobile_device.capability_actions, | |
| 335 | &bridge_mobile_system.capability_actions, | |
| 336 | &bridge_mobile_display.capability_actions, | |
| 337 | &bridge_mobile_storage.capability_actions, | |
| 338 | &bridge_mobile_misc.capability_actions, | |
| 339 | &bridge_mobile_shortcuts.capability_actions, | |
| 340 | &bridge_mobile_securestore.capability_actions, | |
| 341 | &bridge_mobile_biometric.capability_actions, | |
| 342 | &bridge_mobile_permissions.capability_actions, | |
| 343 | &bridge_mobile_db.capability_actions, | |
| 344 | &bridge_mobile_notifcancel.capability_actions, | |
| 345 | &bridge_mobile_notifications.capability_actions, | |
| 346 | &bridge_mobile_bgtasks.capability_actions, | |
| 347 | &bridge_mobile_watch.capability_actions, | |
| 348 | &bridge_mobile_location.capability_actions, | |
| 349 | &bridge_mobile_locrecording.capability_actions, | |
| 350 | &bridge_mobile_motion.capability_actions, | |
| 351 | &bridge_mobile_imagepicker.capability_actions, | |
| 352 | &bridge_mobile_filepicker.capability_actions, | |
| 353 | &bridge_mobile_contactpicker.capability_actions, | |
| 354 | &bridge_mobile_calendar.capability_actions, | |
| 355 | &bridge_mobile_contacts.capability_actions, | |
| 356 | &bridge_mobile_vision.capability_actions, | |
| 357 | &bridge_mobile_auth.capability_actions, | |
| 358 | &bridge_mobile_siri.capability_actions, | |
| 359 | &bridge_mobile_pdf.capability_actions, | |
| 360 | &bridge_mobile_bluetooth.capability_actions, | |
| 361 | &bridge_mobile_audiorec.capability_actions, | |
| 362 | &bridge_mobile_health.capability_actions, | |
| 363 | &bridge_mobile_speech.capability_actions, | |
| 364 | }; | |
| 365 | ||
| 366 | comptime { | |
| 367 | if (mobile_manifests.len != mobile_bridges.len) { | |
| 368 | @compileError("mobile_manifests and mobile_bridges must list the same modules"); | |
| 369 | } | |
| 370 | } | |
| 371 | ||
| 372 | /// Whether a host that offered this action has to serve it itself. | |
| 373 | /// | |
| 374 | /// `.status = .unavailable` in a module's manifest means the handler is | |
| 375 | /// reachable and refuses, deliberately, because Zig cannot do the thing | |
| 376 | /// correctly — `haptic` cannot see `config.enableHaptics`, and the rest say | |
| 377 | /// why in their own `reason`. | |
| 378 | /// | |
| 379 | /// In the Zig-hosted app that refusal is the honest end of the line: `route` | |
| 380 | /// tries `handOffToHost` next, and the shim is what actually serves it. The | |
| 381 | /// host-offered path has no such fallback — `craft_ios_handle_action` | |
| 382 | /// deliberately does not consult `handOffToHost`, because there the host *is* | |
| 383 | /// the caller. So claiming one of these would not be a refusal instead of an | |
| 384 | /// answer, it would be a refusal instead of the *host's working answer*: an app | |
| 385 | /// that has haptics today would stop buzzing the moment the runtime was linked, | |
| 386 | /// and the page would get a rejection where it used to get silence-and-a-buzz. | |
| 387 | /// | |
| 388 | /// `test/ios_conformance_test.zig` already treats falling through as better | |
| 389 | /// than `.unavailable`. This is that rule, applied to the one path that had no | |
| 390 | /// way to fall through. | |
| 391 | fn hostServesItself(action: []const u8) bool { | |
| 392 | for (mobile_manifests) |manifest| { | |
| 393 | for (manifest) |decl| { | |
| 394 | if (decl.status == .unavailable and std.mem.eql(u8, decl.name, action)) return true; | |
| 395 | } | |
| 396 | } | |
| 397 | return false; | |
| 398 | } | |
| 399 | ||
| 287 | 400 | /// Narrow an arbitrary handler error to one the page's error codes can express. |
| 288 | 401 | /// |
| 289 | 402 | /// A handler's error set is wider than `BridgeError` — it picks up allocation |
| @@ -340,6 +453,110 @@ fn handOffToHost(action: []const u8, data: []const u8) !bool { | ||
| 340 | 453 | return func(shim, sel, ns_action, ns_payload, request_id); |
| 341 | 454 | } |
| 342 | 455 | |
| 456 | /// Hand the Zig runtime the webview its replies are evaluated against. | |
| 457 | /// | |
| 458 | /// The one thing a Swift-hosted app must do before any message is offered | |
| 459 | /// below. In the Zig-hosted app `ios.zig` sets this itself while building the | |
| 460 | /// webview; when SwiftUI owns the `WKWebView`, Zig never sees it and every | |
| 461 | /// reply would reach `error.NoWebView` — an action that ran, worked, and told | |
| 462 | /// nobody. | |
| 463 | /// | |
| 464 | /// Not retained. The caller owns the webview and outlives the runtime: it is | |
| 465 | /// the app's own root view. Retaining it here would create a cycle with | |
| 466 | /// nothing to break it, since this pointer is never cleared except by a second | |
| 467 | /// call. | |
| 468 | export fn craft_ios_set_webview(webview: ?*anyopaque) callconv(.c) void { | |
| 469 | setWebView(webview); | |
| 470 | } | |
| 471 | ||
| 472 | /// Forget the webview, if it is still the one the caller installed. | |
| 473 | /// | |
| 474 | /// The other half of `craft_ios_set_webview`, and the reason it is needed: | |
| 475 | /// Zig holds the webview unretained, so once SwiftUI tears the view down the | |
| 476 | /// pointer is dangling and every later reply is an `objc_msgSend` into freed | |
| 477 | /// memory. `UIViewRepresentable` has a `dismantleUIView` hook for exactly this | |
| 478 | /// moment, and this is what it calls. | |
| 479 | /// | |
| 480 | /// Compare-and-clear, not an unconditional reset. SwiftUI is free to build the | |
| 481 | /// replacement view *before* dismantling the old one — during a state-driven | |
| 482 | /// rebuild it usually does — and a blind clear on dismantle would blank the | |
| 483 | /// pointer the new view had already installed, leaving a live app whose | |
| 484 | /// replies all reach `error.NoWebView`. Clearing only when the pointer still | |
| 485 | /// matches makes the two orders equivalent. | |
| 486 | export fn craft_ios_clear_webview(webview: ?*anyopaque) callconv(.c) void { | |
| 487 | if (global_webview == webview) global_webview = null; | |
| 488 | } | |
| 489 | ||
| 490 | /// Offer one already-parsed page message to the Zig dispatcher. | |
| 491 | /// | |
| 492 | /// The entry point for a **Swift-hosted** app, where `WKScriptMessageHandler` | |
| 493 | /// belongs to SwiftUI and Zig is a library rather than the thing that owns | |
| 494 | /// `UIApplicationMain`. The caller has already parsed the page's message, so | |
| 495 | /// this takes the three fields routing needs directly instead of re-parsing an | |
| 496 | /// envelope Zig never saw. | |
| 497 | /// | |
| 498 | /// Returns **true when Zig has taken responsibility** — the page will hear | |
| 499 | /// back from Zig and the caller must not answer. Returns **false only when no | |
| 500 | /// Zig module recognised the action**, which is the caller's signal to serve | |
| 501 | /// it themselves. | |
| 502 | /// | |
| 503 | /// A refusal returns true. `offerToModules` calls `sendErrorToJS` before it | |
| 504 | /// reports a failure, so the promise is already settled; answering again from | |
| 505 | /// the Swift side would resolve a callback that has been deleted at best, and | |
| 506 | /// deliver two contradictory answers to one call at worst. | |
| 507 | /// | |
| 508 | /// `handOffToHost` is deliberately **not** consulted here. In this direction | |
| 509 | /// the host is the caller, so handing back to `CraftSwiftShim` would take a | |
| 510 | /// round trip through the Objective-C runtime to reach the same `dispatch` | |
| 511 | /// method the caller is about to call anyway — and would do it under a | |
| 512 | /// synthetic `zig:` callback id, routing the reply back through Zig for no | |
| 513 | /// reason. `false` says the same thing in one word. | |
| 514 | /// | |
| 515 | /// `request_id` is negative for a page message with no callback waiting on it, | |
| 516 | /// matching the null the `{t,a,d,i}` envelope carries in that case. | |
| 517 | export fn craft_ios_handle_action( | |
| 518 | action_ptr: [*]const u8, | |
| 519 | action_len: usize, | |
| 520 | payload_ptr: [*]const u8, | |
| 521 | payload_len: usize, | |
| 522 | request_id: i64, | |
| 523 | ) callconv(.c) bool { | |
| 524 | const action = action_ptr[0..action_len]; | |
| 525 | const payload = payload_ptr[0..payload_len]; | |
| 526 | ||
| 527 | // Pushed for the whole call, exactly as `handleMessage` does: a reply | |
| 528 | // formatted after this frame is gone reads `request_context.current()` and | |
| 529 | // would stamp null, falling back to action-name correlation and handing | |
| 530 | // one caller's answer to another. | |
| 531 | request_context.push(if (request_id < 0) null else @intCast(request_id)); | |
| 532 | defer request_context.pop(); | |
| 533 | ||
| 534 | // The same line `handleMessage` logs, and for the same reason: on a device | |
| 535 | // this is the only window into the bridge. `t=mobile` is spelled out | |
| 536 | // rather than taken from an envelope, because a host-offered message has | |
| 537 | // no envelope — this path is only ever the mobile namespace. | |
| 538 | std.log.info("craft-bridge dispatch t=mobile a={s} i={?d}", .{ | |
| 539 | action, | |
| 540 | request_context.current(), | |
| 541 | }); | |
| 542 | ||
| 543 | // Before the gate and the module chain, because this is not a question | |
| 544 | // about what the config allows or what a handler would do — it is a | |
| 545 | // question about which arm owns the action at all, and the answer is the | |
| 546 | // caller's own. | |
| 547 | if (hostServesItself(action)) { | |
| 548 | std.log.info("ios: {s} is declared unavailable; leaving it to the host", .{action}); | |
| 549 | return false; | |
| 550 | } | |
| 551 | ||
| 552 | const offer = offerToModules(std.heap.c_allocator, action, payload) catch { | |
| 553 | // A module ran and failed, and answered the page on its way out. That | |
| 554 | // is a served action with an unhappy result, not an unserved one. | |
| 555 | return true; | |
| 556 | }; | |
| 557 | return offer == .answered; | |
| 558 | } | |
| 559 | ||
| 343 | 560 | /// Deliver a result the host shim produced. |
| 344 | 561 | /// |
| 345 | 562 | /// Exported for the shim to call. Everything after this point is the same path |
| @@ -582,3 +799,132 @@ test "a shim error with no request id omits the id field entirely" { | ||
| 582 | 799 | try buildShimError(testing.allocator, &out, "share", "m", "E", -1); |
| 583 | 800 | try testing.expect(std.mem.indexOf(u8, out.items, "\"id\"") == null); |
| 584 | 801 | } |
| 802 | ||
| 803 | test "a host offer that no module recognises comes back as the host's problem" { | |
| 804 | // The whole contract of `craft_ios_handle_action` in one assertion. A | |
| 805 | // Swift-hosted app calls this first and serves the action itself when the | |
| 806 | // answer is false; if an unrecognised action ever returned true, that | |
| 807 | // action would silently stop working in every generated app — the page's | |
| 808 | // promise would hang with nobody having agreed to answer it. | |
| 809 | try testing.expect(!craft_ios_handle_action("noSuchAction", "noSuchAction".len, "{}", 2, 7)); | |
| 810 | } | |
| 811 | ||
| 812 | test "a host offer for a served action is claimed, not handed back" { | |
| 813 | // `getDeviceInfo` is served by `bridge_mobile_device.zig` on every | |
| 814 | // platform this test runs on. The reply cannot arrive — there is no | |
| 815 | // webview in a test binary, so `sendResultToJS` reaches | |
| 816 | // `error.NoWebView` — but claiming is the half under test: true means Zig | |
| 817 | // has taken responsibility, and a caller that also answered would be the | |
| 818 | // double-reply this return value exists to prevent. | |
| 819 | // | |
| 820 | // Off Darwin the action still routes; it just fails inside the handler, | |
| 821 | // which is `answered` too. Either way the answer is true, and that is the | |
| 822 | // point: "claimed" is about ownership, never about success. | |
| 823 | try testing.expect(craft_ios_handle_action("getDeviceInfo", "getDeviceInfo".len, "{}", 2, 1)); | |
| 824 | } | |
| 825 | ||
| 826 | test "a host offer for a disabled capability is claimed and refused, not handed back" { | |
| 827 | // The trap this return value is shaped around. `clipboardRead` is served | |
| 828 | // by Zig and gated on `enableClipboard`; a test binary has no bundled | |
| 829 | // craft.config.json, so `isEnabled` is false and the gate refuses. | |
| 830 | // | |
| 831 | // The refusal already called `sendErrorToJS`, so the page's promise is | |
| 832 | // settled. Returning false here would invite the Swift arm to answer the | |
| 833 | // same call a second time — and Swift's own arm for a disabled capability | |
| 834 | // has no `else`, so the page would get one rejection and one silence. | |
| 835 | try testing.expect(craft_ios_handle_action("clipboardRead", "clipboardRead".len, "{}", 2, 3)); | |
| 836 | } | |
| 837 | ||
| 838 | test "a negative request id means no callback is waiting" { | |
| 839 | // Mirrors `craft_ios_deliver_result`'s reading of the same sentinel. The | |
| 840 | // id is pushed for the duration of the call and popped after, so a stack | |
| 841 | // that leaked a frame would be visible here as a non-empty context once | |
| 842 | // the call has returned. | |
| 843 | try testing.expect(request_context.current() == null); | |
| 844 | _ = craft_ios_handle_action("noSuchAction", "noSuchAction".len, "{}", 2, -1); | |
| 845 | try testing.expect(request_context.current() == null); | |
| 846 | } | |
| 847 | ||
| 848 | test "setting the webview from a host is what makes replies reachable" { | |
| 849 | // The one call a Swift-hosted app must make before offering anything. | |
| 850 | // Without it every reply lands on `error.NoWebView`: the action runs, it | |
| 851 | // works, and the page never hears. Restored afterwards so the ordering of | |
| 852 | // tests in this file cannot matter. | |
| 853 | const saved = getWebView(); | |
| 854 | defer setWebView(saved); | |
| 855 | ||
| 856 | setWebView(null); | |
| 857 | try testing.expectError(error.NoWebView, evalJS("1")); | |
| 858 | ||
| 859 | var fake: u8 = 0; | |
| 860 | craft_ios_set_webview(&fake); | |
| 861 | try testing.expect(getWebView() == @as(objc.id, @ptrCast(&fake))); | |
| 862 | ||
| 863 | // And clearing it is reachable again, which is the half that was broken: | |
| 864 | // a host that hands over nil must leave the runtime saying "no webview" | |
| 865 | // rather than quietly addressing every reply to it. | |
| 866 | craft_ios_set_webview(null); | |
| 867 | try testing.expectError(error.NoWebView, evalJS("1")); | |
| 868 | } | |
| 869 | ||
| 870 | test "an action declared unavailable is left to the host, not claimed" { | |
| 871 | // The regression this guards. Each of these has a working Swift arm and a | |
| 872 | // Zig handler that refuses on purpose; before the host-offered path knew | |
| 873 | // the difference, linking the runtime replaced the working answer with a | |
| 874 | // rejection. `false` here is what sends the call back to Swift's switch. | |
| 875 | const declared_unavailable = [_][]const u8{ | |
| 876 | "haptic", | |
| 877 | "getNetworkStatus", | |
| 878 | "lockOrientation", | |
| 879 | "unlockOrientation", | |
| 880 | }; | |
| 881 | for (declared_unavailable) |action| { | |
| 882 | try testing.expect(hostServesItself(action)); | |
| 883 | try testing.expect(!craft_ios_handle_action(action.ptr, action.len, "{}", 2, 11)); | |
| 884 | } | |
| 885 | } | |
| 886 | ||
| 887 | test "the fall-through is read from the manifests, not from a list kept beside them" { | |
| 888 | // `hostServesItself` must answer for exactly the actions the modules | |
| 889 | // declare `.unavailable` — no more, or a working Zig action is handed away; | |
| 890 | // no fewer, or the regression comes back for whichever one was missed. So | |
| 891 | // count the declarations rather than trusting the test above's literals. | |
| 892 | var declared: usize = 0; | |
| 893 | for (mobile_manifests) |manifest| { | |
| 894 | for (manifest) |decl| { | |
| 895 | if (decl.status == .unavailable) { | |
| 896 | declared += 1; | |
| 897 | try testing.expect(hostServesItself(decl.name)); | |
| 898 | } | |
| 899 | } | |
| 900 | } | |
| 901 | try testing.expectEqual(@as(usize, 4), declared); | |
| 902 | } | |
| 903 | ||
| 904 | test "a live action is still claimed, unavailable is not a blanket hand-back" { | |
| 905 | // The other half: `vibrate` sits in the same module as `haptic` and is | |
| 906 | // `.live`, so the seam must keep claiming it. A fall-through that widened | |
| 907 | // to whole modules would silently un-migrate everything beside a refusal. | |
| 908 | try testing.expect(!hostServesItself("vibrate")); | |
| 909 | try testing.expect(!hostServesItself("setKeepAwake")); | |
| 910 | try testing.expect(!hostServesItself("getDeviceInfo")); | |
| 911 | try testing.expect(!hostServesItself("noSuchAction")); | |
| 912 | } | |
| 913 | ||
| 914 | test "clearing the webview only forgets the one the caller named" { | |
| 915 | // The rebuild order that makes a blind clear wrong: SwiftUI builds the | |
| 916 | // replacement, then dismantles the original. If dismantling cleared | |
| 917 | // unconditionally, the live view installed a moment earlier would be | |
| 918 | // dropped and every reply after it would reach `error.NoWebView`. | |
| 919 | const first: *anyopaque = @ptrFromInt(0x1000); | |
| 920 | const second: *anyopaque = @ptrFromInt(0x2000); | |
| 921 | defer setWebView(null); | |
| 922 | ||
| 923 | craft_ios_set_webview(first); | |
| 924 | craft_ios_set_webview(second); // the replacement arrives first | |
| 925 | craft_ios_clear_webview(first); // then the original is dismantled | |
| 926 | try testing.expect(getWebView() == second); | |
| 927 | ||
| 928 | craft_ios_clear_webview(second); | |
| 929 | try testing.expect(getWebView() == null); | |
| 930 | } | |
| @@ -649,3 +649,51 @@ test "Zig requires exactly the keys the spec's decoder requires" { | ||
| 649 | 649 | try testing.expect(required >= 40); |
| 650 | 650 | try testing.expect(optional_seen >= 1); |
| 651 | 651 | } |
| 652 | ||
| 653 | test "getDeviceInfo answers every field the spec answers" { | |
| 654 | // The regression that made this test necessary. Zig's `getDeviceInfo` is | |
| 655 | // `.live`, so the Swift-hosted seam prefers it over the spec's arm — and | |
| 656 | // it answered four of the spec's fourteen fields. A page reading | |
| 657 | // `screenWidth` or `locale` got `undefined` from an action that reported | |
| 658 | // success, which is the exact failure `.unavailable` exists to prevent and | |
| 659 | // could not catch, because the action does work; it just works less. | |
| 660 | // | |
| 661 | // Keys are read out of the spec rather than listed here, so a field added | |
| 662 | // to `CraftApp.swift` fails this test until Zig answers it too. | |
| 663 | // Anchored to the function, not to `let info:` — the spec has more than | |
| 664 | // one dictionary spelled that way (`getMemoryUsage` has another), and the | |
| 665 | // first match is not this one. | |
| 666 | const fn_start = std.mem.indexOf(u8, swift_spec, "private func getDeviceInfo(callbackId:") orelse | |
| 667 | return error.SpecShapeChanged; | |
| 668 | const body = swift_spec[fn_start..]; | |
| 669 | const start = std.mem.indexOf(u8, body, "let info: [String: Any] = [") orelse | |
| 670 | return error.SpecShapeChanged; | |
| 671 | const after = body[start..]; | |
| 672 | const end = std.mem.indexOf(u8, after, "\n ]") orelse return error.SpecShapeChanged; | |
| 673 | const block = after[0..end]; | |
| 674 | ||
| 675 | const zig_src = @embedFile("src/bridge_mobile.zig"); | |
| 676 | ||
| 677 | var it = std.mem.splitScalar(u8, block, '\n'); | |
| 678 | var checked: usize = 0; | |
| 679 | while (it.next()) |line| { | |
| 680 | const q1 = std.mem.indexOfScalar(u8, line, '"') orelse continue; | |
| 681 | const rest = line[q1 + 1 ..]; | |
| 682 | const q2 = std.mem.indexOfScalar(u8, rest, '"') orelse continue; | |
| 683 | const key = rest[0..q2]; | |
| 684 | if (key.len == 0) continue; | |
| 685 | ||
| 686 | // Zig builds the reply as a format string, so the key appears in the | |
| 687 | // source as the escaped `\"key\":` it will emit. | |
| 688 | var needle_buf: [64]u8 = undefined; | |
| 689 | const needle = try std.fmt.bufPrint(&needle_buf, "\\\"{s}\\\":", .{key}); | |
| 690 | if (std.mem.indexOf(u8, zig_src, needle) == null) { | |
| 691 | std.debug.print("getDeviceInfo: the spec answers `{s}` and Zig does not\n", .{key}); | |
| 692 | return error.FieldMissingFromZig; | |
| 693 | } | |
| 694 | checked += 1; | |
| 695 | } | |
| 696 | ||
| 697 | // A shape change that silently matched nothing would otherwise pass. | |
| 698 | try testing.expectEqual(@as(usize, 14), checked); | |
| 699 | } | |