| @@ -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 | + |
| 48 | +const std = ; |
| 49 | +const builtin = ; |
| 50 | +const capabilities = ; |
| 51 | +const bridge_error = ; |
| 52 | +const objc_runtime = ; |
| 53 | +const ios_async = ; |
| 54 | +const ios_events = ; |
| 55 | +const ios_delegate = ; |
| 56 | + |
| 57 | +const objc = objc_runtime.objc; |
| 58 | +const BridgeError = bridge_error.BridgeError; |
| 59 | +const Id = ?*anyopaque; |
| 60 | +const is_darwin = builtin.target.os.tag.isDarwin(); |
| 61 | + |
| 62 | +pub const A = struct { |
| 63 | + pub const start_bluetooth_scan = "startBluetoothScan"; |
| 64 | + pub const stop_bluetooth_scan = "stopBluetoothScan"; |
| 65 | +}; |
| 66 | + |
| 67 | +pub 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. |
| 73 | +const 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. |
| 103 | +const stop_reply = "true"; |
| 104 | + |
| 105 | +/// `startBluetoothScan`'s reply once the radio reports `poweredOn`. |
| 106 | +const 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. |
| 115 | +const max_tracked = 256; |
| 116 | + |
| 117 | +/// A peripheral's `identifier.uuidString` is a canonical 36-character UUID. |
| 118 | +const uuid_len = 36; |
| 119 | + |
| 120 | +var tracked: [max_tracked][uuid_len]u8 = undefined; |
| 121 | +var 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. |
| 129 | +var manager: Id = null; |
| 130 | +var delegate: Id = null; |
| 131 | +var pending: ?ios_async.Ticket = null; |
| 132 | + |
| 133 | +pub 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. |
| 233 | +fn 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. |
| 252 | +const 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. |
| 262 | +fn 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 | + |
| 287 | +const delegate_class_name = "CraftBluetoothDelegate"; |
| 288 | + |
| 289 | +/// The delegate instance, built once and kept. |
| 290 | +fn 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. |
| 319 | +export 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 | + |
| 346 | +fn 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. |
| 354 | +fn 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. |
| 368 | +fn 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. |
| 392 | +export 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. |
| 417 | +fn 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 | + |
| 439 | +fn 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 | + |
| 448 | +fn 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`. |
| 456 | +fn 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 | + |
| 463 | +fn 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 | + |
| 472 | +fn 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 | + |
| 479 | +fn 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 | + |
| 489 | +fn 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 | + |
| 495 | +extern "c" fn dlsym(handle: ?*anyopaque, symbol: [*:0]const u8) ?*anyopaque; |
| 496 | + |
| 497 | +/// `RTLD_DEFAULT` — search every image already loaded into the process. |
| 498 | +const RTLD_DEFAULT: ?*anyopaque = @ptrFromInt(@as(usize, @bitCast(@as(isize, -2)))); |
| 499 | + |
| 500 | +const testing = std.testing; |
| 501 | + |
| 502 | +test "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 | + |
| 507 | +test "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 | + |
| 515 | +test "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 | + |
| 527 | +test "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 | + |
| 538 | +test "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 | + |
| 560 | +test "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 | + |
| 572 | +test "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 | + |
| 584 | +test "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 | + |
| 597 | +test "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 | + |
| 609 | +test "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 | + |
| 618 | +test "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 | +} |