ReviewOS

also looking at this

craft-native/craft

iOS: the Bluetooth scan pair — ratchet 35 → 33

#97
Merged glennmichael123 wants to merge feat/ios-bluetooth into main
10 files +679 -3
packages/ios/fixtures/zig-slice/Info.plistmodified+2-0
Changes to packages/ios/fixtures/zig-slice/Info.plist
@@ -12,5 +12,7 @@
1212 <key>UILaunchScreen</key><dict/>
1313 <key>UISupportedInterfaceOrientations</key>
1414 <array><string>UIInterfaceOrientationPortrait</string></array>
15 <key>NSBluetoothAlwaysUsageDescription</key>
16 <string>The slice fixture asks for Bluetooth so startBluetoothScan reaches CBCentralManager rather than stopping at the plist guard.</string>
1517</dict>
1618</plist>
packages/ios/fixtures/zig-slice/build-and-run.shmodified+13-1
Changes to packages/ios/fixtures/zig-slice/build-and-run.sh
@@ -64,7 +64,7 @@ swiftc -target "$TRIPLE" -sdk "$SDK" \
6464 "$ROOT/packages/zig/zig-out/lib/$LIB" \
6565 -framework UIKit -framework WebKit -framework Foundation -framework Security \
6666 -framework Vision -framework LocalAuthentication -framework PDFKit \
67 -framework WatchConnectivity \
67 -framework WatchConnectivity -framework CoreBluetooth \
6868 -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __entitlements -Xlinker "$OUT/sim.entitlements" \
6969 -o "$APP/CraftSlice"
7070
@@ -320,4 +320,16 @@ C54="$(count 54)"
320320[ "$C54" -ge 1 ] || { echo "FAIL: openSettings did not reply with a boolean"; exit 1; }
321321echo "ok: openSettings replied a real boolean, not \"granted\"/\"denied\" (i=54 seen ${C54}x)"
322322
323C56="$(count 56)"
324# The refusal comes from centralManagerDidUpdateState, not from the handler:
325# startBluetoothScan returns having done nothing but create a manager, and the
326# radio answers later. So this passing proves the ticket survived the gap
327# between the call and the callback.
328[ "$C56" -ge 1 ] || { echo "FAIL: startBluetoothScan did not refuse from the state callback"; exit 1; }
329echo "ok: scan refused by the radio's own state, via a deferred ticket (i=56 seen ${C56}x)"
330
331C58="$(count 58)"
332[ "$C58" -ge 1 ] || { echo "FAIL: stopBluetoothScan did not resolve"; exit 1; }
333echo "ok: stopping with nothing running still answers true (i=58 seen ${C58}x)"
334
323335echo "PASS"
packages/ios/fixtures/zig-slice/craft.config.jsonmodified+1-1
Changes to packages/ios/fixtures/zig-slice/craft.config.json
@@ -27,7 +27,7 @@
2727 "enableVideoRecording": false,
2828 "enableMotionSensors": true,
2929 "enableLocalDatabase": true,
30 "enableBluetooth": false,
30 "enableBluetooth": true,
3131 "enableNFC": false,
3232 "enableHealthKit": false,
3333 "enableLiveActivities": false,
packages/ios/fixtures/zig-slice/page.mmodified+24-0
Changes to packages/ios/fixtures/zig-slice/page.m
@@ -51,6 +51,7 @@ const char craft_slice_page[] =
5151 "var sentAuth = false, sentSiriAdd = false, sentSiriDel = false;\n"
5252 "var sentPdfOpen = false, sentPdfClose = false;\n"
5353 "var sentDownload = false, sentWatch = false, sentSettings = false;\n"
54 "var sentBtStart = false, sentBtStop = false;\n"
5455 "window.__craftBridgeError = function (ctx) {\n"
5556 " // The message crossed Zig's escaping with a backslash, quote, and\n"
5657 " // newline intact; anything mangled and this equality fails, i=7 never\n"
@@ -77,6 +78,16 @@ const char craft_slice_page[] =
7778 " // NOT_FOUND, not PLATFORM_NOT_SUPPORTED: WatchConnectivity is linked,\n"
7879 " // so this is WCSession itself reporting no reachable watch rather than\n"
7980 " // a missing class. Before this phase the action was not served at all.\n"
81 " // PLATFORM_NOT_SUPPORTED, specifically: CoreBluetooth is linked and the\n"
82 " // usage description is present, so this is the radio's own state and\n"
83 " // not a missing class or a missing plist key. Swift answers all five\n"
84 " // unusable states with one string; this one names which.\n"
85 " if (!sentBtStart && ctx && ctx.error === true && ctx.id === 103\n"
86 " && ctx.code === 'PLATFORM_NOT_SUPPORTED') {\n"
87 " sentBtStart = true;\n"
88 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:56});\n"
89 " bridge.postMessage({t:'mobile', a:'stopBluetoothScan', i:104});\n"
90 " }\n"
8091 " if (!sentWatch && ctx && ctx.error === true && ctx.id === 101\n"
8192 " && ctx.code === 'NOT_FOUND') {\n"
8293 " sentWatch = true;\n"
@@ -173,6 +184,10 @@ const char craft_slice_page[] =
173184 " // was parsed and routed — and the donation ran without throwing.\n"
174185 " // A bare string path, landed inside the app container's Documents.\n"
175186 " // Nothing but a real download-and-move produces it.\n"
187 " if (!sentBtStop && action === 'stopBluetoothScan' && payload === true) {\n"
188 " sentBtStop = true;\n"
189 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:58});\n"
190 " }\n"
176191 " if (!sentSettings && action === 'openSettings' && typeof payload === 'boolean') {\n"
177192 " sentSettings = true;\n"
178193 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:54});\n"
@@ -314,6 +329,15 @@ const char craft_slice_page[] =
314329 " // only branch the Swift shim reaches there.\n"
315330 " bridge.postMessage({t:'mobile', a:'sendToWatch',\n"
316331 " d: JSON.stringify({message:{action:'ping'}}), i:101});\n"
332 " // A simulator has no Bluetooth radio, so CBCentralManager reports\n"
333 " // unsupported and the scan is refused from the state callback — not\n"
334 " // from the handler, which returns before the radio has said anything.\n"
335 " // Only the start here. Posting the stop alongside it would race: a\n"
336 " // stop while a start is still awaiting its first state callback\n"
337 " // answers that start CANCELLED, which is the module's deliberate fix\n"
338 " // for the promise Swift drops — and would mask the state refusal this\n"
339 " // asserts. The stop goes out once the start has been answered.\n"
340 " bridge.postMessage({t:'mobile', a:'startBluetoothScan', i:103});\n"
317341 " // A Tier-0 action migrated in this phase, served by Zig directly.\n"
318342 " bridge.postMessage({t:'mobile', a:'getAppState', i:88});\n"
319343 " // And one the shim answers by rejecting, to prove the error route.\n"
packages/zig/build.zigmodified+3-0
Changes to packages/zig/build.zig
@@ -413,6 +413,9 @@ pub fn build(b: *std.Build) void {
413413 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_pdf.zig", .{
414414 .root_source_file = b.path("src/bridge_mobile_pdf.zig"),
415415 });
416 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_bluetooth.zig", .{
417 .root_source_file = b.path("src/bridge_mobile_bluetooth.zig"),
418 });
416419 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile.zig", .{
417420 .root_source_file = b.path("src/bridge_mobile.zig"),
418421 });
packages/zig/src/bridge_mobile_bluetooth.zigadded+623-0
Changes to packages/zig/src/bridge_mobile_bluetooth.zig
@@ -0,0 +1,623 @@
1//! The `mobile` namespace's Bluetooth scan pair: `startBluetoothScan` and
2//! `stopBluetoothScan`.
3//!
4//! ## The reply does not come from the call
5//!
6//! `startBluetoothScan` creates a `CBCentralManager` and returns. Nothing has
7//! happened yet: Core Bluetooth powers the radio up asynchronously and then
8//! calls `centralManagerDidUpdateState:`, and *that* is where Swift both starts
9//! the scan and answers the page. So the action needs an `ios_async` ticket
10//! even though its own body cannot fail in an interesting way, and the
11//! delegate — not the handler — decides whether the page sees success.
12//!
13//! `queue: nil` in the Swift means the main queue, so every callback below
14//! arrives on the thread the dispatch already ran on. That is why this module
15//! has no mutex: there is exactly one thread involved, and adding a lock would
16//! suggest otherwise.
17//!
18//! ## What a refusal means, and why it is not one code
19//!
20//! Swift rejects every non-`poweredOn` state with the single string
21//! "Bluetooth not available". Five states reach that branch and they are not
22//! the same fact: `unsupported` is a device that has no radio (every
23//! simulator), `unauthorized` is a user who said no, `poweredOff` is a switch
24//! the user can flip. The first is permanent, the second is a settings trip,
25//! the third is a toggle — and a page that retries on all three is wrong twice.
26//! So the state is mapped to three codes rather than one, and the state's raw
27//! value is logged either way.
28//!
29//! ## Deduplication is a belt, not the mechanism
30//!
31//! Swift keeps every discovered `CBPeripheral` in an array and checks
32//! membership before emitting. That array grows for the life of a scan and is
33//! only cleared by `stopBluetoothScan`. It is also nearly redundant: the scan
34//! is started with `CBCentralManagerScanOptionAllowDuplicatesKey: false`, which
35//! is Core Bluetooth's own instruction to report each peripheral once. This
36//! module keeps a bounded set for the same purpose and says what happens when
37//! it fills — past `max_tracked`, a device may be re-announced. An unbounded
38//! set that a long scan grows without limit would be the worse trade.
39//!
40//! ## `stopBluetoothScan` answers a call the spec leaves hanging
41//!
42//! It is ungated, and its Swift arm resolves `true` from the dispatcher rather
43//! than from the function. If it runs while a `startBluetoothScan` is still
44//! waiting for its first state callback, Swift's `pendingCallbackId` is simply
45//! dropped — the start promise never settles. That is not carried across: the
46//! pending ticket is answered `CANCELLED`, which is what actually happened.
47
48const std = ;
49const builtin = ;
50const capabilities = ;
51const bridge_error = ;
52const objc_runtime = ;
53const ios_async = ;
54const ios_events = ;
55const ios_delegate = ;
56
57const objc = objc_runtime.objc;
58const BridgeError = bridge_error.BridgeError;
59const Id = ?*anyopaque;
60const is_darwin = builtin.target.os.tag.isDarwin();
61
62pub const A = struct {
63 pub const start_bluetooth_scan = "startBluetoothScan";
64 pub const stop_bluetooth_scan = "stopBluetoothScan";
65};
66
67pub const capability_actions = [_]capabilities.ActionDecl{
68 .{ .name = A.start_bluetooth_scan, .reply = .result },
69 .{ .name = A.stop_bluetooth_scan, .reply = .result },
70};
71
72/// `CBManagerState`. Only `poweredOn` starts a scan.
73const CBManagerState = enum(c_long) {
74 unknown = 0,
75 resetting = 1,
76 unsupported = 2,
77 unauthorized = 3,
78 powered_off = 4,
79 powered_on = 5,
80 _,
81
82 /// The code a page gets for a state that cannot scan.
83 ///
84 /// Swift says "Bluetooth not available" for all of them. These three are
85 /// different enough to branch on: only `unauthorized` is fixed by a trip to
86 /// Settings, only `powered_off` is fixed by a toggle, and `unsupported` is
87 /// never fixed at all.
88 fn refusal(self: CBManagerState) BridgeError {
89 return switch (self) {
90 .unsupported => BridgeError.PlatformNotSupported,
91 .unauthorized => BridgeError.PermissionDenied,
92 // `unknown` and `resetting` are transient — Core Bluetooth will
93 // call back again — but the page asked now and there is no scan
94 // now, so it is told so rather than left waiting for a second
95 // callback that may never come.
96 else => BridgeError.NotFound,
97 };
98 }
99};
100
101/// `stopBluetoothScan`'s reply: the bare JSON `true`, resolved by the Swift
102/// dispatcher rather than by the function it calls.
103const stop_reply = "true";
104
105/// `startBluetoothScan`'s reply once the radio reports `poweredOn`.
106const start_reply = "true";
107
108/// How many peripherals are remembered for deduplication.
109///
110/// Past this a device may be announced twice. That is the bounded failure;
111/// Swift's unbounded array has no bound to exceed and grows for the length of
112/// the scan instead. `allowDuplicates: false` means Core Bluetooth is already
113/// suppressing repeats, so this set is a belt and reaching its limit is not
114/// expected in a session a person is watching.
115const max_tracked = 256;
116
117/// A peripheral's `identifier.uuidString` is a canonical 36-character UUID.
118const uuid_len = 36;
119
120var tracked: [max_tracked][uuid_len]u8 = undefined;
121var tracked_count: usize = 0;
122
123/// Everything Core Bluetooth needs kept alive, and the call it will answer.
124///
125/// `CBCentralManager` holds its delegate weakly, so a released delegate is a
126/// message to freed memory on the first state change rather than a leak. The
127/// manager itself is retained because nothing else refers to it once
128/// `startBluetoothScan` returns.
129var manager: Id = null;
130var delegate: Id = null;
131var pending: ?ios_async.Ticket = null;
132
133pub const BluetoothBridge = struct {
134 allocator: std.mem.Allocator,
135
136 const Self = @This();
137
138 pub fn init(allocator: std.mem.Allocator) Self {
139 return .{ .allocator = allocator };
140 }
141
142 pub fn deinit(_: *Self) void {}
143
144 pub fn handleMessage(self: *Self, action: []const u8, _: []const u8) !void {
145 if (std.mem.eql(u8, action, A.start_bluetooth_scan)) {
146 try self.startScan();
147 } else if (std.mem.eql(u8, action, A.stop_bluetooth_scan)) {
148 try self.stopScan();
149 } else {
150 return BridgeError.UnknownAction;
151 }
152 }
153
154 /// Power up the radio and answer from the state callback.
155 ///
156 /// Neither action takes a payload — the injected JS posts both with no
157 /// second argument — so there is nothing to parse and nothing to refuse
158 /// before the ticket.
159 fn startScan(self: *Self) !void {
160 _ = self;
161 if (!is_darwin) return BridgeError.PlatformNotSupported;
162
163 const CBCentralManager = objc.objc_getClass("CBCentralManager") orelse {
164 std.log.warn(
165 "startBluetoothScan: CBCentralManager is not in this process; " ++
166 "the app does not link CoreBluetooth",
167 .{},
168 );
169 return BridgeError.PlatformNotSupported;
170 };
171
172 // A second start while one is in flight would strand the first
173 // ticket. Swift overwrites `pendingCallbackId` and does exactly that.
174 if (pending) |existing| {
175 std.log.warn(
176 "startBluetoothScan: a scan is already starting; answering the first call " ++
177 "rather than replacing it",
178 .{},
179 );
180 ios_async.deliverErrorCode(existing, BridgeError.Cancelled);
181 pending = null;
182 }
183
184 // Before anything touches Core Bluetooth. iOS 13+ terminates a process
185 // that creates a `CBCentralManager` without this key — not an
186 // exception to catch, a kill — so the plist is checked first and the
187 // refusal happens while refusing is still possible. Same hazard and
188 // same guard as `bridge_mobile_location`'s usage-description check and
189 // `bridge_mobile_imagepicker`'s camera one.
190 try requireBluetoothUsageDescription();
191
192 const instance = try delegateInstance();
193
194 const ticket = ios_async.acquire(A.start_bluetooth_scan) orelse {
195 std.log.warn(
196 "startBluetoothScan: no free reply slot; {d} native calls are already awaiting one",
197 .{ios_async.max_in_flight},
198 );
199 return BridgeError.NativeCallFailed;
200 };
201 errdefer ios_async.abandon(ticket);
202 pending = ticket;
203
204 const sel_alloc = objc.sel_registerName("alloc") orelse return BridgeError.NativeCallFailed;
205 const sel_init = objc.sel_registerName("initWithDelegate:queue:") orelse
206 return BridgeError.NativeCallFailed;
207 const allocated = objc.msgSendId(CBCentralManager, sel_alloc) orelse
208 return BridgeError.NativeCallFailed;
209
210 // `queue: nil` is the main queue, which is the thread this already runs
211 // on — so every callback lands here and the module needs no lock.
212 releaseObject(manager);
213 manager = objc.msgSendId2(allocated, sel_init, instance, @as(Id, null)) orelse {
214 pending = null;
215 return BridgeError.NativeCallFailed;
216 };
217 }
218
219 /// Stop scanning, forget what was found, and resolve.
220 ///
221 /// Resolves even when no scan is running, because Swift's arm is
222 /// `stopBluetoothScan(); resolveCallback(callbackId, result: true)` with
223 /// the optional-chained calls inside doing nothing on nil.
224 fn stopScan(self: *Self) !void {
225 if (!is_darwin) return BridgeError.PlatformNotSupported;
226
227 stopAndForget();
228 bridge_error.sendResultToJS(self.allocator, A.stop_bluetooth_scan, stop_reply);
229 }
230};
231
232/// The shared teardown, so `stopBluetoothScan` and a failed start agree.
233fn stopAndForget() void {
234 if (manager) |live| {
235 const sel_stop = objc.sel_registerName("stopScan");
236 if (sel_stop) |sel| objc.msgSend(live, sel);
237 releaseObject(live);
238 manager = null;
239 }
240 tracked_count = 0;
241
242 // A start still waiting for its first state callback is answered here
243 // rather than dropped. Swift drops it, and the page's promise never
244 // settles.
245 if (pending) |ticket| {
246 pending = null;
247 ios_async.deliverErrorCode(ticket, BridgeError.Cancelled);
248 }
249}
250
251/// The key iOS demands before a process may touch Core Bluetooth.
252const key_bluetooth_usage = "NSBluetoothAlwaysUsageDescription";
253
254/// Refuse unless this app declared why it wants Bluetooth.
255///
256/// The generator writes the key from `config.enableBluetooth`, so in a
257/// generated app its presence and the flag agree — and the flag has already
258/// been checked by `ios_dispatch` before this module runs. The check is still
259/// here because the consequence of being wrong is a terminated process rather
260/// than an error, and because a hand-edited plist can disagree with the config
261/// that the dispatcher read.
262fn requireBluetoothUsageDescription() !void {
263 if (!is_darwin) return BridgeError.PlatformNotSupported;
264
265 const NSBundle = objc.objc_getClass("NSBundle") orelse return BridgeError.NativeCallFailed;
266 const sel_main = objc.sel_registerName("mainBundle") orelse return BridgeError.NativeCallFailed;
267 const bundle = objc.msgSendId(NSBundle, sel_main) orelse return BridgeError.NativeCallFailed;
268
269 const NSString = objc.objc_getClass("NSString") orelse return BridgeError.NativeCallFailed;
270 const sel_string = objc.sel_registerName("stringWithUTF8String:") orelse
271 return BridgeError.NativeCallFailed;
272 const ns_key = objc.msgSendId1(NSString, sel_string, @as([*:0]const u8, key_bluetooth_usage)) orelse
273 return BridgeError.NativeCallFailed;
274
275 const sel_lookup = objc.sel_registerName("objectForInfoDictionaryKey:") orelse
276 return BridgeError.NativeCallFailed;
277 if (objc.msgSendId1(bundle, sel_lookup, ns_key) != null) return;
278
279 std.log.warn(
280 "startBluetoothScan refused: Info.plist has no {s}, and creating a CBCentralManager " ++
281 "without it terminates the process rather than failing",
282 .{key_bluetooth_usage},
283 );
284 return BridgeError.PermissionDenied;
285}
286
287const delegate_class_name = "CraftBluetoothDelegate";
288
289/// The delegate instance, built once and kept.
290fn delegateInstance() !Id {
291 if (delegate) |existing| return existing;
292
293 const class = ios_delegate.defineClass(delegate_class_name, "NSObject", &.{
294 .{
295 .selector = "centralManagerDidUpdateState:",
296 .imp = @ptrCast(&craftBluetoothDidUpdateState),
297 .types = ios_delegate.enc.void_one_object,
298 },
299 .{
300 .selector = "centralManager:didDiscoverPeripheral:advertisementData:RSSI:",
301 .imp = @ptrCast(&craftBluetoothDidDiscover),
302 .types = ios_delegate.enc.void_four_objects,
303 },
304 }) catch |err| {
305 std.log.warn("startBluetoothScan: could not build the delegate class: {}", .{err});
306 return BridgeError.NativeCallFailed;
307 };
308
309 // +1 and never released: `CBCentralManager` holds its delegate weakly, and
310 // one instance serves every scan for the life of the process.
311 delegate = ios_delegate.instantiate(class) catch |err| {
312 std.log.warn("startBluetoothScan: could not instantiate the delegate: {}", .{err});
313 return BridgeError.NativeCallFailed;
314 };
315 return delegate;
316}
317
318/// The radio reported a state. Start scanning, or refuse.
319export fn craftBluetoothDidUpdateState(_: objc.id, _: objc.SEL, central: objc.id) callconv(.c) void {
320 if (!is_darwin) return;
321
322 const state = readState(central);
323
324 // Core Bluetooth calls this on every state change for the life of the
325 // manager, not only the first. A later change with no call waiting is not
326 // an error — it is a user toggling the radio — so it is silent.
327 const ticket = pending orelse {
328 if (state == .powered_on) startScanning(central);
329 return;
330 };
331 pending = null;
332
333 if (state != .powered_on) {
334 std.log.warn(
335 "startBluetoothScan refused: CBManagerState is {d} ({s})",
336 .{ @backingInt(state), @tagName(state) },
337 );
338 ios_async.deliverErrorCode(ticket, state.refusal());
339 return;
340 }
341
342 startScanning(central);
343 ios_async.deliverJson(ticket, start_reply);
344}
345
346fn readState(central: objc.id) CBManagerState {
347 const sel = objc.sel_registerName("state") orelse return .unknown;
348 const StateFn = *const fn (objc.id, objc.SEL) callconv(.c) c_long;
349 const stateFn: StateFn = @ptrCast(&objc.objc_msgSend);
350 return @fromBackingInt(@intCast(stateFn(central, sel)));
351}
352
353/// `scanForPeripheralsWithServices:options:` — every service, no duplicates.
354fn startScanning(central: objc.id) void {
355 const sel = objc.sel_registerName("scanForPeripheralsWithServices:options:") orelse return;
356 const options = scanOptions();
357 const ScanFn = *const fn (objc.id, objc.SEL, Id, Id) callconv(.c) void;
358 const scan: ScanFn = @ptrCast(&objc.objc_msgSend);
359 scan(central, sel, null, options);
360}
361
362/// `@{CBCentralManagerScanOptionAllowDuplicatesKey: @NO}`.
363///
364/// Null when the key symbol is missing, which Core Bluetooth reads as "no
365/// options" — the same default. Rebuilding the key from its own spelling would
366/// silently produce an unrecognised option instead, and an unrecognised option
367/// means duplicates.
368fn scanOptions() Id {
369 const symbol = dlsym(RTLD_DEFAULT, "CBCentralManagerScanOptionAllowDuplicatesKey") orelse {
370 std.log.warn(
371 "startBluetoothScan: CBCentralManagerScanOptionAllowDuplicatesKey is not in this " ++
372 "process; scanning with default options, which may report duplicates",
373 .{},
374 );
375 return null;
376 };
377 const key_cell: *const Id = @ptrCast(@alignCast(symbol));
378 const key = key_cell.* orelse return null;
379
380 const NSNumber = objc.objc_getClass("NSNumber") orelse return null;
381 const sel_bool = objc.sel_registerName("numberWithBool:") orelse return null;
382 const NumberFn = *const fn (objc.Class, objc.SEL, bool) callconv(.c) Id;
383 const numberWith: NumberFn = @ptrCast(&objc.objc_msgSend);
384 const no = numberWith(NSNumber, sel_bool, false) orelse return null;
385
386 const NSDictionary = objc.objc_getClass("NSDictionary") orelse return null;
387 const sel_with = objc.sel_registerName("dictionaryWithObject:forKey:") orelse return null;
388 return objc.msgSendId2(NSDictionary, sel_with, no, key);
389}
390
391/// A peripheral was seen. Announce it once.
392export fn craftBluetoothDidDiscover(
393 _: objc.id,
394 _: objc.SEL,
395 _: objc.id,
396 peripheral: objc.id,
397 _: objc.id,
398 rssi: objc.id,
399) callconv(.c) void {
400 if (!is_darwin) return;
401
402 const identifier = readIdentifier(peripheral) orelse return;
403 if (alreadySeen(identifier)) return;
404 remember(identifier);
405
406 const allocator = std.heap.c_allocator;
407 const detail = shapeDevice(allocator, identifier, peripheral, rssi) catch |err| {
408 std.log.warn("bluetooth: could not shape a discovered device: {}", .{err});
409 return;
410 };
411 defer allocator.free(detail);
412
413 ios_events.emit(.bluetooth_device, detail);
414}
415
416/// `{"id":…,"name":…,"rssi":…}` — Swift's three keys.
417fn shapeDevice(
418 allocator: std.mem.Allocator,
419 identifier: []const u8,
420 peripheral: objc.id,
421 rssi: objc.id,
422) ![]u8 {
423 var out: std.ArrayListUnmanaged(u8) = .empty;
424 errdefer out.deinit(allocator);
425
426 try out.appendSlice(allocator, "{\"id\":");
427 try appendJsonString(allocator, &out, identifier);
428 try out.appendSlice(allocator, ",\"name\":");
429 // Swift's `peripheral.name ?? "Unknown"`. Most peripherals do not
430 // advertise a name, so this is the common case rather than the edge.
431 try appendJsonString(allocator, &out, readName(peripheral) orelse "Unknown");
432 try out.appendSlice(allocator, ",\"rssi\":");
433 try out.print(allocator, "{d}", .{readRssi(rssi)});
434 try out.append(allocator, '}');
435
436 return out.toOwnedSlice(allocator);
437}
438
439fn readIdentifier(peripheral: objc.id) ?[]const u8 {
440 const sel_identifier = objc.sel_registerName("identifier") orelse return null;
441 const uuid = objc.msgSendId(peripheral, sel_identifier) orelse return null;
442 const sel_string = objc.sel_registerName("UUIDString") orelse return null;
443 const ns_string = objc.msgSendId(uuid, sel_string) orelse return null;
444 const utf8 = objc.getNSStringUTF8(ns_string) orelse return null;
445 return std.mem.span(utf8);
446}
447
448fn readName(peripheral: objc.id) ?[]const u8 {
449 const sel = objc.sel_registerName("name") orelse return null;
450 const ns_name = objc.msgSendId(peripheral, sel) orelse return null;
451 const utf8 = objc.getNSStringUTF8(ns_name) orelse return null;
452 return std.mem.span(utf8);
453}
454
455/// `RSSI.intValue` — Swift reads the NSNumber as an `Int`.
456fn readRssi(rssi: objc.id) i64 {
457 const sel = objc.sel_registerName("intValue") orelse return 0;
458 const IntFn = *const fn (objc.id, objc.SEL) callconv(.c) c_int;
459 const intFn: IntFn = @ptrCast(&objc.objc_msgSend);
460 return intFn(rssi, sel);
461}
462
463fn alreadySeen(identifier: []const u8) bool {
464 if (identifier.len != uuid_len) return false;
465 var i: usize = 0;
466 while (i < tracked_count) : (i += 1) {
467 if (std.mem.eql(u8, &tracked[i], identifier)) return true;
468 }
469 return false;
470}
471
472fn remember(identifier: []const u8) void {
473 if (identifier.len != uuid_len) return;
474 if (tracked_count >= max_tracked) return;
475 @memcpy(&tracked[tracked_count], identifier);
476 tracked_count += 1;
477}
478
479fn appendJsonString(
480 allocator: std.mem.Allocator,
481 out: *std.ArrayListUnmanaged(u8),
482 s: []const u8,
483) !void {
484 try out.append(allocator, '"');
485 try bridge_error.appendJsonEscaped(allocator, out, s);
486 try out.append(allocator, '"');
487}
488
489fn releaseObject(object: Id) void {
490 const target = object orelse return;
491 const sel = objc.sel_registerName("release") orelse return;
492 objc.msgSend(target, sel);
493}
494
495extern "c" fn dlsym(handle: ?*anyopaque, symbol: [*:0]const u8) ?*anyopaque;
496
497/// `RTLD_DEFAULT` — search every image already loaded into the process.
498const RTLD_DEFAULT: ?*anyopaque = @ptrFromInt(@as(usize, @bitCast(@as(isize, -2))));
499
500const testing = std.testing;
501
502test "the action names match the Swift case labels exactly" {
503 try testing.expectEqualStrings("startBluetoothScan", A.start_bluetooth_scan);
504 try testing.expectEqualStrings("stopBluetoothScan", A.stop_bluetooth_scan);
505}
506
507test "both replies are the bare JSON true" {
508 // `stopBluetoothScan` is resolved by the dispatcher, not by the function
509 // it calls; `startBluetoothScan` is resolved from the state callback. Both
510 // are `.fragmentsAllowed` booleans rather than objects.
511 try testing.expectEqualStrings("true", start_reply);
512 try testing.expectEqualStrings("true", stop_reply);
513}
514
515test "each unusable radio state gets the code that fits it" {
516 // Swift says "Bluetooth not available" for all five. They are not the same
517 // fact: unsupported is permanent, unauthorized is a settings trip,
518 // poweredOff is a toggle — and a page that retries on all three is wrong
519 // twice.
520 try testing.expectEqual(BridgeError.PlatformNotSupported, CBManagerState.unsupported.refusal());
521 try testing.expectEqual(BridgeError.PermissionDenied, CBManagerState.unauthorized.refusal());
522 try testing.expectEqual(BridgeError.NotFound, CBManagerState.powered_off.refusal());
523 try testing.expectEqual(BridgeError.NotFound, CBManagerState.unknown.refusal());
524 try testing.expectEqual(BridgeError.NotFound, CBManagerState.resetting.refusal());
525}
526
527test "the state values are Core Bluetooth's, not a guess" {
528 // These are read from `-[CBCentralManager state]` and compared against
529 // `powered_on`. A wrong number here starts a scan on a radio that is off,
530 // or refuses one that is on, with no diagnostic either way.
531 try testing.expectEqual(@as(c_long, 0), @backingInt(CBManagerState.unknown));
532 try testing.expectEqual(@as(c_long, 2), @backingInt(CBManagerState.unsupported));
533 try testing.expectEqual(@as(c_long, 3), @backingInt(CBManagerState.unauthorized));
534 try testing.expectEqual(@as(c_long, 4), @backingInt(CBManagerState.powered_off));
535 try testing.expectEqual(@as(c_long, 5), @backingInt(CBManagerState.powered_on));
536}
537
538test "a device is announced once and then remembered" {
539 tracked_count = 0;
540 const a = "01234567-89AB-CDEF-0123-456789ABCDEF";
541 const b = "FEDCBA98-7654-3210-FEDC-BA9876543210";
542
543 try testing.expect(!alreadySeen(a));
544 remember(a);
545 try testing.expect(alreadySeen(a));
546 try testing.expect(!alreadySeen(b));
547 remember(b);
548 try testing.expect(alreadySeen(b));
549 try testing.expectEqual(@as(usize, 2), tracked_count);
550
551 // A malformed identifier is neither matched nor stored: a short string
552 // compared against a fixed-width slot would read past its end.
553 try testing.expect(!alreadySeen("too-short"));
554 remember("too-short");
555 try testing.expectEqual(@as(usize, 2), tracked_count);
556
557 tracked_count = 0;
558}
559
560test "the dedup set is bounded, and says so by re-announcing rather than growing" {
561 // Swift's array grows for the life of the scan. This one stops at
562 // max_tracked, past which a device may be announced twice — the bounded
563 // failure, chosen over an unbounded one.
564 tracked_count = max_tracked;
565 const fresh = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE";
566 remember(fresh);
567 try testing.expectEqual(@as(usize, max_tracked), tracked_count);
568 try testing.expect(!alreadySeen(fresh));
569 tracked_count = 0;
570}
571
572test "a discovered device is shaped with Swift's three keys" {
573 // The `name` fallback is the common case, not the edge: most peripherals
574 // advertise no name at all.
575 const json = try shapeDevice(testing.allocator, "01234567-89AB-CDEF-0123-456789ABCDEF", null, null);
576 defer testing.allocator.free(json);
577
578 try testing.expectEqualStrings(
579 "{\"id\":\"01234567-89AB-CDEF-0123-456789ABCDEF\",\"name\":\"Unknown\",\"rssi\":0}",
580 json,
581 );
582}
583
584test "every declared action dispatches to something" {
585 var bridge = BluetoothBridge.init(testing.allocator);
586 defer bridge.deinit();
587
588 for (capability_actions) |decl| {
589 bridge.handleMessage(decl.name, "{}") catch |err| {
590 try testing.expect(err != BridgeError.UnknownAction);
591 continue;
592 };
593 }
594 try testing.expectError(BridgeError.UnknownAction, bridge.handleMessage("getDeviceInfo", "{}"));
595}
596
597test "stopping with nothing running still answers, and strands no ticket" {
598 // Swift's arm is `stopBluetoothScan(); resolveCallback(true)` with the
599 // calls inside doing nothing on nil, so a stop with no scan is a success.
600 if (!is_darwin) return error.SkipZigTest;
601
602 manager = null;
603 pending = null;
604 stopAndForget();
605 try testing.expect(pending == null);
606 try testing.expectEqual(@as(usize, 0), tracked_count);
607}
608
609test "a process with no usage description is refused before Core Bluetooth is touched" {
610 // The refusal has to happen before `CBCentralManager` is created, because
611 // iOS kills the process rather than raising something catchable. The host
612 // test binary has no such key, so this exercises the real path.
613 if (!is_darwin) return error.SkipZigTest;
614
615 try testing.expectError(BridgeError.PermissionDenied, requireBluetoothUsageDescription());
616}
617
618test "the discovery selector uses the four-object encoding" {
619 // `RSSI:` is an NSNumber, not a primitive, so all four arguments are
620 // objects. A narrower encoding registers fine and reads the signal
621 // strength from the wrong register — a plausible number, silently wrong.
622 try testing.expectEqualStrings("v@:@@@@", ios_delegate.enc.void_four_objects);
623}
packages/zig/src/ios_config.zigmodified+1-0
Changes to packages/zig/src/ios_config.zig
@@ -429,6 +429,7 @@ pub fn gateFor(action: []const u8) ?Feature {
429429 .{ "saveFile", .file_download },
430430 .{ "scheduleBackgroundTask", .background_tasks },
431431 .{ "secureClear", .secure_storage },
432 .{ "startBluetoothScan", .bluetooth },
432433 .{ "secureGet", .secure_storage },
433434 .{ "secureRemove", .secure_storage },
434435 .{ "secureSet", .secure_storage },
packages/zig/src/ios_delegate.zigmodified+8-0
Changes to packages/zig/src/ios_delegate.zig
@@ -50,6 +50,13 @@ pub const enc = struct {
5050 /// did-finish-picking pair and `userContentController:didReceiveScriptMessage:`
5151 /// are both this shape.
5252 pub const void_two_objects: [:0]const u8 = "v@:@@";
53 /// `- (void)m:(id)a :(id)b :(id)c :(id)d` — four object arguments.
54 /// `centralManager:didDiscoverPeripheral:advertisementData:RSSI:` is this
55 /// shape, and it is the widest one any delegate here uses. `RSSI:` is an
56 /// `NSNumber *`, not a primitive, so it counts as an object like the rest —
57 /// getting that wrong would read the signal strength from the wrong
58 /// register and report a plausible number.
59 pub const void_four_objects: [:0]const u8 = "v@:@@@@";
5360 /// `- (BOOL)m:(id)a :(id)b` — `application:didFinishLaunchingWithOptions:`.
5461 ///
5562 /// `B` is C99 `_Bool`: on 64-bit Apple platforms `__OBJC_BOOL_IS_BOOL` is
@@ -189,5 +196,6 @@ test "the encodings name the shapes these delegates actually use" {
189196 // them is the only defence available, so the names are pinned.
190197 try testing.expectEqualStrings("v@:@", enc.void_one_object);
191198 try testing.expectEqualStrings("v@:@@", enc.void_two_objects);
199 try testing.expectEqualStrings("v@:@@@@", enc.void_four_objects);
192200 try testing.expectEqualStrings("B@:@@", enc.bool_two_objects);
193201}
packages/zig/src/ios_dispatch.zigmodified+2-0
Changes to packages/zig/src/ios_dispatch.zig
@@ -36,6 +36,7 @@ const bridge_mobile_vision = ;
3636const bridge_mobile_auth = ;
3737const bridge_mobile_siri = ;
3838const bridge_mobile_pdf = ;
39const bridge_mobile_bluetooth = ;
3940// `bridge_mobile_speech.zig` is deliberately NOT in the chain. Its
4041// capability_actions table is empty — the two speech actions were researched
4142// in full and left with the Swift shim — so a module here would iterate,
@@ -281,6 +282,7 @@ const mobile_bridges = .{
281282 bridge_mobile_auth.AuthBridge,
282283 bridge_mobile_siri.SiriBridge,
283284 bridge_mobile_pdf.PdfBridge,
285 bridge_mobile_bluetooth.BluetoothBridge,
284286};
285287
286288/// Narrow an arbitrary handler error to one the page's error codes can express.
packages/zig/test/ios_conformance_test.zigmodified+2-1
Changes to packages/zig/test/ios_conformance_test.zig
@@ -53,6 +53,7 @@ const zig_sources = [_][]const u8{
5353 @embedFile("src/bridge_mobile_auth.zig"),
5454 @embedFile("src/bridge_mobile_siri.zig"),
5555 @embedFile("src/bridge_mobile_pdf.zig"),
56 @embedFile("src/bridge_mobile_bluetooth.zig"),
5657 @embedFile("src/bridge_mobile_speech.zig"),
5758};
5859
@@ -97,7 +98,7 @@ const dispatch_end = "func webView(";
9798/// 48 after calendar and contacts. Speech was researched in full and left
9899/// with the shim: neither action replies, so unlike the earlier deferrals
99100/// there is no promise to strand and falling through costs the page nothing.
100const max_not_yet_migrated: usize = 35;
101const max_not_yet_migrated: usize = 33;
101102
102103fn dispatcherRegion() []const u8 {
103104 const begin = std.mem.indexOf(u8, swift_spec, dispatch_begin) orelse return "";