| @@ -0,0 +1,692 @@ |
| 1 | +//! The `mobile` namespace's audio recording pair: `startAudioRecording` and |
| 2 | +//! `stopAudioRecording`. |
| 3 | +//! |
| 4 | +//! ## They move together |
| 5 | +//! |
| 6 | +//! `stopAudioRecording` reads two fields `startAudioRecording` wrote — the |
| 7 | +//! recorder and the URL it is writing to — so a split migration leaves the |
| 8 | +//! stop unable to find what the start made. Both are behind |
| 9 | +//! `config.enableAudioRecording`, so neither can arrive first by accident, but |
| 10 | +//! the pairing is stated because the file-picker family and the PDF pair have |
| 11 | +//! already shown how easy the asymmetric version looks. |
| 12 | +//! |
| 13 | +//! ## Three asynchronous steps, one ticket |
| 14 | +//! |
| 15 | +//! `requestRecordPermission:` answers on an arbitrary queue; Swift then hops to |
| 16 | +//! the main queue before touching `AVAudioSession`, because activating a |
| 17 | +//! session and starting a recorder from a background queue is not supported. |
| 18 | +//! Both hops happen here too, and the page's request id rides through them on |
| 19 | +//! one `ios_async` ticket rather than being re-derived. |
| 20 | +//! |
| 21 | +//! The permission block cannot be `ios_async.boolBlock`, for the reason |
| 22 | +//! `bridge_mobile_auth.zig` hit first: that block's delivery path answers the |
| 23 | +//! strings `"granted"` and `"denied"`, and this action resolves the bare JSON |
| 24 | +//! `true` or rejects. `"denied"` is truthy, so a page's |
| 25 | +//! `if (await craft.startAudioRecording())` would begin a recording that never |
| 26 | +//! started. |
| 27 | +//! |
| 28 | +//! ## A usage description is checked before the microphone is asked for |
| 29 | +//! |
| 30 | +//! `NSMicrophoneUsageDescription`. Requesting record permission without it |
| 31 | +//! terminates the process — a kill, not a catchable exception — so the plist is |
| 32 | +//! read first, the same guard `bridge_mobile_bluetooth.zig` and |
| 33 | +//! `bridge_mobile_location.zig` apply to their frameworks. |
| 34 | +//! |
| 35 | +//! ## Where this refuses and Swift returns megabytes |
| 36 | +//! |
| 37 | +//! `stopAudioRecording` resolves the whole recording as a `data:` URL, so a |
| 38 | +//! ten-minute take is several megabytes of base64 interpolated into the source |
| 39 | +//! `evaluateJavaScript:` parses. Swift has no ceiling. This module refuses |
| 40 | +//! above `max_recording_bytes` rather than build a string that large, the same |
| 41 | +//! trade `bridge_mobile_locrecording.zig` makes for a track file — and for the |
| 42 | +//! same reason: a reply that cannot be delivered is worse than an error that |
| 43 | +//! names why. |
| 44 | + |
| 45 | +const std = ; |
| 46 | +const builtin = ; |
| 47 | +const capabilities = ; |
| 48 | +const bridge_error = ; |
| 49 | +const objc_runtime = ; |
| 50 | +const ios_async = ; |
| 51 | +const io_context = ; |
| 52 | + |
| 53 | +const objc = objc_runtime.objc; |
| 54 | +const BridgeError = bridge_error.BridgeError; |
| 55 | +const Id = ?*anyopaque; |
| 56 | +const is_darwin = builtin.target.os.tag.isDarwin(); |
| 57 | + |
| 58 | +pub const A = struct { |
| 59 | + pub const start_audio_recording = "startAudioRecording"; |
| 60 | + pub const stop_audio_recording = "stopAudioRecording"; |
| 61 | +}; |
| 62 | + |
| 63 | +pub const capability_actions = [_]capabilities.ActionDecl{ |
| 64 | + .{ .name = A.start_audio_recording, .reply = .result }, |
| 65 | + .{ .name = A.stop_audio_recording, .reply = .result }, |
| 66 | +}; |
| 67 | + |
| 68 | +/// `kAudioFormatMPEG4AAC` — the four-character code `'aac '`. |
| 69 | +/// |
| 70 | +/// Spelled as its numeric value because that is what the key takes. Getting it |
| 71 | +/// wrong produces a recorder that fails to initialise, which is at least loud. |
| 72 | +const audio_format_mpeg4_aac: c_int = 0x61616320; |
| 73 | + |
| 74 | +/// `AVAudioQuality.high`. |
| 75 | +const audio_quality_high: c_int = 0x60; |
| 76 | + |
| 77 | +/// `AVAudioSessionCategoryOptions` — none, matching Swift's two-argument |
| 78 | +/// `setCategory(_:mode:)`. |
| 79 | +const no_category_options: c_ulong = 0; |
| 80 | + |
| 81 | +/// The prefix Swift concatenates ahead of the base64. |
| 82 | +const m4a_data_url_prefix = "data:audio/m4a;base64,"; |
| 83 | + |
| 84 | +/// `startAudioRecording`'s reply once the recorder is running. |
| 85 | +const start_reply = "true"; |
| 86 | + |
| 87 | +/// A recording larger than this is refused rather than base64'd into a |
| 88 | +/// JavaScript string literal. |
| 89 | +/// |
| 90 | +/// 32 MiB of AAC is roughly forty minutes at the settings below, and its |
| 91 | +/// base64 is 43 MiB of source for `evaluateJavaScript:` to parse. Swift has no |
| 92 | +/// ceiling and will try; the failure mode there is a webview that stalls or |
| 93 | +/// dies rather than an error a page can read. |
| 94 | +const max_recording_bytes: u64 = 32 << 20; |
| 95 | + |
| 96 | +/// The recorder, the file it is writing, and the call that started it. |
| 97 | +/// |
| 98 | +/// All main-thread state: every write below happens on the main queue, either |
| 99 | +/// from the dispatch or from the hop the permission block makes. |
| 100 | +var recorder: Id = null; |
| 101 | +var recording_path: ?[]u8 = null; |
| 102 | +var pending: ?ios_async.Ticket = null; |
| 103 | + |
| 104 | +pub const AudioRecordingBridge = struct { |
| 105 | + allocator: std.mem.Allocator, |
| 106 | + |
| 107 | + const Self = @This(); |
| 108 | + |
| 109 | + pub fn init(allocator: std.mem.Allocator) Self { |
| 110 | + return .{ .allocator = allocator }; |
| 111 | + } |
| 112 | + |
| 113 | + pub fn deinit(_: *Self) void {} |
| 114 | + |
| 115 | + pub fn handleMessage(self: *Self, action: []const u8, _: []const u8) !void { |
| 116 | + if (std.mem.eql(u8, action, A.start_audio_recording)) { |
| 117 | + try self.startRecording(); |
| 118 | + } else if (std.mem.eql(u8, action, A.stop_audio_recording)) { |
| 119 | + try self.stopRecording(); |
| 120 | + } else { |
| 121 | + return BridgeError.UnknownAction; |
| 122 | + } |
| 123 | + } |
| 124 | + |
| 125 | + /// Ask for the microphone; the reply comes from the permission block. |
| 126 | + fn startRecording(self: *Self) !void { |
| 127 | + _ = self; |
| 128 | + if (!is_darwin) return BridgeError.PlatformNotSupported; |
| 129 | + |
| 130 | + try requireMicrophoneUsageDescription(); |
| 131 | + |
| 132 | + const session = try sharedAudioSession(); |
| 133 | + |
| 134 | + // A second start while one is in flight would strand the first ticket, |
| 135 | + // which is what Swift's single `pendingCallbackId` does. |
| 136 | + if (pending) |existing| { |
| 137 | + std.log.warn( |
| 138 | + "startAudioRecording: a start is already in flight; answering the first call " ++ |
| 139 | + "rather than replacing it", |
| 140 | + .{}, |
| 141 | + ); |
| 142 | + ios_async.deliverErrorCode(existing, BridgeError.Cancelled); |
| 143 | + pending = null; |
| 144 | + } |
| 145 | + |
| 146 | + const ticket = ios_async.acquire(A.start_audio_recording) orelse { |
| 147 | + std.log.warn( |
| 148 | + "startAudioRecording: no free reply slot; {d} native calls are already awaiting one", |
| 149 | + .{ios_async.max_in_flight}, |
| 150 | + ); |
| 151 | + return BridgeError.NativeCallFailed; |
| 152 | + }; |
| 153 | + errdefer ios_async.abandon(ticket); |
| 154 | + pending = ticket; |
| 155 | + |
| 156 | + const sel = objc.sel_registerName("requestRecordPermission:") orelse |
| 157 | + return BridgeError.NativeCallFailed; |
| 158 | + const RequestFn = *const fn (Id, objc.SEL, *anyopaque) callconv(.c) void; |
| 159 | + const request: RequestFn = @ptrCast(&objc.objc_msgSend); |
| 160 | + request(session, sel, @ptrCast(&permission_blocks[ticket.index])); |
| 161 | + } |
| 162 | + |
| 163 | + /// Stop, read the file back, and resolve it as a `data:` URL. |
| 164 | + /// |
| 165 | + /// Synchronous throughout: `-[AVAudioRecorder stop]` finishes writing |
| 166 | + /// before it returns, which is what makes Swift's immediate read of the |
| 167 | + /// file legal. |
| 168 | + fn stopRecording(self: *Self) !void { |
| 169 | + if (!is_darwin) return BridgeError.PlatformNotSupported; |
| 170 | + |
| 171 | + if (recorder) |live| { |
| 172 | + const sel_stop = objc.sel_registerName("stop"); |
| 173 | + if (sel_stop) |sel| objc.msgSend(live, sel); |
| 174 | + release(live); |
| 175 | + recorder = null; |
| 176 | + } |
| 177 | + |
| 178 | + const path = recording_path orelse { |
| 179 | + // Swift's else branch: "No recording found". |
| 180 | + std.log.warn("stopAudioRecording: nothing was recording", .{}); |
| 181 | + return BridgeError.NotFound; |
| 182 | + }; |
| 183 | + defer { |
| 184 | + self.allocator.free(path); |
| 185 | + recording_path = null; |
| 186 | + } |
| 187 | + |
| 188 | + const bytes = readRecording(self.allocator, path) catch |err| switch (err) { |
| 189 | + // Swift's fallback: the file exists but could not be read, so the |
| 190 | + // path is resolved instead of its contents. |
| 191 | + error.Unreadable => return self.replyWithPath(path), |
| 192 | + error.NotFound => { |
| 193 | + std.log.warn("stopAudioRecording: the recorder wrote no file", .{}); |
| 194 | + return BridgeError.NotFound; |
| 195 | + }, |
| 196 | + else => return err, |
| 197 | + }; |
| 198 | + defer self.allocator.free(bytes); |
| 199 | + |
| 200 | + try self.replyWithDataUrl(bytes); |
| 201 | + } |
| 202 | + |
| 203 | + /// `"data:audio/m4a;base64,…"` as a bare JSON string. |
| 204 | + fn replyWithDataUrl(self: *Self, bytes: []const u8) !void { |
| 205 | + const encoder = std.base64.standard.Encoder; |
| 206 | + const encoded_len = encoder.calcSize(bytes.len); |
| 207 | + |
| 208 | + var out = try self.allocator.alloc(u8, m4a_data_url_prefix.len + encoded_len + 2); |
| 209 | + defer self.allocator.free(out); |
| 210 | + |
| 211 | + out[0] = '"'; |
| 212 | + @memcpy(out[1 .. 1 + m4a_data_url_prefix.len], m4a_data_url_prefix); |
| 213 | + _ = encoder.encode(out[1 + m4a_data_url_prefix.len ..][0..encoded_len], bytes); |
| 214 | + out[out.len - 1] = '"'; |
| 215 | + |
| 216 | + // Base64 and the prefix contain nothing JSON escapes, and the quoting |
| 217 | + // is done here rather than through the shared escaper because the |
| 218 | + // buffer is already exactly sized — but the alphabet is checked by a |
| 219 | + // test rather than asserted in prose. |
| 220 | + bridge_error.sendResultToJS(self.allocator, A.stop_audio_recording, out); |
| 221 | + } |
| 222 | + |
| 223 | + fn replyWithPath(self: *Self, path: []const u8) !void { |
| 224 | + var out: std.ArrayListUnmanaged(u8) = .empty; |
| 225 | + defer out.deinit(self.allocator); |
| 226 | + try out.append(self.allocator, '"'); |
| 227 | + try bridge_error.appendJsonEscaped(self.allocator, &out, path); |
| 228 | + try out.append(self.allocator, '"'); |
| 229 | + bridge_error.sendResultToJS(self.allocator, A.stop_audio_recording, out.items); |
| 230 | + } |
| 231 | +}; |
| 232 | + |
| 233 | +/// The recording's bytes, or which way it was unavailable. |
| 234 | +fn readRecording(allocator: std.mem.Allocator, path: []const u8) ![]u8 { |
| 235 | + const io = io_context.get(); |
| 236 | + |
| 237 | + const file = std.Io.Dir.cwd().openFile(io, path, .{}) catch |err| switch (err) { |
| 238 | + error.FileNotFound => return error.NotFound, |
| 239 | + else => { |
| 240 | + std.log.warn("stopAudioRecording: could not open '{s}': {}", .{ path, err }); |
| 241 | + return error.Unreadable; |
| 242 | + }, |
| 243 | + }; |
| 244 | + defer file.close(io); |
| 245 | + |
| 246 | + const info = file.stat(io) catch return error.Unreadable; |
| 247 | + if (info.size == 0) return error.NotFound; |
| 248 | + if (info.size > max_recording_bytes) { |
| 249 | + std.log.warn( |
| 250 | + "stopAudioRecording: the recording is {d} bytes, over the {d}-byte ceiling; " ++ |
| 251 | + "refusing rather than building a base64 string that large", |
| 252 | + .{ info.size, max_recording_bytes }, |
| 253 | + ); |
| 254 | + return error.Unreadable; |
| 255 | + } |
| 256 | + |
| 257 | + const buf = try allocator.alloc(u8, @intCast(info.size)); |
| 258 | + errdefer allocator.free(buf); |
| 259 | + |
| 260 | + var read: usize = 0; |
| 261 | + while (read < buf.len) { |
| 262 | + const n = file.readStreaming(io, &.{buf[read..]}) catch |err| switch (err) { |
| 263 | + error.EndOfStream => break, |
| 264 | + else => return error.Unreadable, |
| 265 | + }; |
| 266 | + if (n == 0) break; |
| 267 | + read += n; |
| 268 | + } |
| 269 | + if (read != buf.len) return error.Unreadable; |
| 270 | + return buf; |
| 271 | +} |
| 272 | + |
| 273 | +/// `[AVAudioSession sharedInstance]`. |
| 274 | +fn sharedAudioSession() !Id { |
| 275 | + const AVAudioSession = objc.objc_getClass("AVAudioSession") orelse { |
| 276 | + std.log.warn( |
| 277 | + "startAudioRecording: AVAudioSession is not in this process; " ++ |
| 278 | + "the app does not link AVFoundation", |
| 279 | + .{}, |
| 280 | + ); |
| 281 | + return BridgeError.PlatformNotSupported; |
| 282 | + }; |
| 283 | + const sel = objc.sel_registerName("sharedInstance") orelse return BridgeError.NativeCallFailed; |
| 284 | + return objc.msgSendId(AVAudioSession, sel) orelse BridgeError.NativeCallFailed; |
| 285 | +} |
| 286 | + |
| 287 | +/// The key iOS demands before a process may ask for the microphone. |
| 288 | +const key_microphone_usage = "NSMicrophoneUsageDescription"; |
| 289 | + |
| 290 | +fn requireMicrophoneUsageDescription() !void { |
| 291 | + if (!is_darwin) return BridgeError.PlatformNotSupported; |
| 292 | + |
| 293 | + const NSBundle = objc.objc_getClass("NSBundle") orelse return BridgeError.NativeCallFailed; |
| 294 | + const sel_main = objc.sel_registerName("mainBundle") orelse return BridgeError.NativeCallFailed; |
| 295 | + const bundle = objc.msgSendId(NSBundle, sel_main) orelse return BridgeError.NativeCallFailed; |
| 296 | + |
| 297 | + const NSString = objc.objc_getClass("NSString") orelse return BridgeError.NativeCallFailed; |
| 298 | + const sel_string = objc.sel_registerName("stringWithUTF8String:") orelse |
| 299 | + return BridgeError.NativeCallFailed; |
| 300 | + const ns_key = objc.msgSendId1(NSString, sel_string, @as([*:0]const u8, key_microphone_usage)) orelse |
| 301 | + return BridgeError.NativeCallFailed; |
| 302 | + |
| 303 | + const sel_lookup = objc.sel_registerName("objectForInfoDictionaryKey:") orelse |
| 304 | + return BridgeError.NativeCallFailed; |
| 305 | + if (objc.msgSendId1(bundle, sel_lookup, ns_key) != null) return; |
| 306 | + |
| 307 | + std.log.warn( |
| 308 | + "startAudioRecording refused: Info.plist has no {s}, and asking for record " ++ |
| 309 | + "permission without it terminates the process rather than failing", |
| 310 | + .{key_microphone_usage}, |
| 311 | + ); |
| 312 | + return BridgeError.PermissionDenied; |
| 313 | +} |
| 314 | + |
| 315 | +// --------------------------------------------------------------------------- |
| 316 | +// The permission block, and the main-queue hop it makes |
| 317 | +// --------------------------------------------------------------------------- |
| 318 | + |
| 319 | +const BlockDescriptor = extern struct { |
| 320 | + reserved: c_ulong = 0, |
| 321 | + size: c_ulong, |
| 322 | +}; |
| 323 | + |
| 324 | +/// `void (^)(BOOL granted)`. |
| 325 | +const PermissionBlock = extern struct { |
| 326 | + isa: ?*anyopaque, |
| 327 | + flags: c_int, |
| 328 | + reserved: c_int = 0, |
| 329 | + invoke: *const anyopaque, |
| 330 | + descriptor: *const BlockDescriptor, |
| 331 | +}; |
| 332 | + |
| 333 | +/// 1 << 28 — a global block is never copied. |
| 334 | +const BLOCK_IS_GLOBAL: c_int = 1 << 28; |
| 335 | + |
| 336 | +const permission_block_descriptor = BlockDescriptor{ .size = @sizeOf(PermissionBlock) }; |
| 337 | + |
| 338 | +extern var _NSConcreteGlobalBlock: anyopaque; |
| 339 | + |
| 340 | +fn makePermissionInvoke(comptime index: u5) *const anyopaque { |
| 341 | + const S = struct { |
| 342 | + fn invoke(_: *const PermissionBlock, granted: bool) callconv(.c) void { |
| 343 | + permissionAnswered(index, granted); |
| 344 | + } |
| 345 | + }; |
| 346 | + return @ptrCast(&S.invoke); |
| 347 | +} |
| 348 | + |
| 349 | +fn makePermissionBlocks() [ios_async.max_in_flight]PermissionBlock { |
| 350 | + var out: [ios_async.max_in_flight]PermissionBlock = undefined; |
| 351 | + for (&out, 0..) |*b, i| { |
| 352 | + b.* = .{ |
| 353 | + .isa = &_NSConcreteGlobalBlock, |
| 354 | + .flags = BLOCK_IS_GLOBAL, |
| 355 | + .invoke = makePermissionInvoke(@intCast(i)), |
| 356 | + .descriptor = &permission_block_descriptor, |
| 357 | + }; |
| 358 | + } |
| 359 | + return out; |
| 360 | +} |
| 361 | + |
| 362 | +var permission_blocks: [ios_async.max_in_flight]PermissionBlock = |
| 363 | + if (is_darwin) makePermissionBlocks() else undefined; |
| 364 | + |
| 365 | +/// Which slot the pending main-queue hop belongs to, since `dispatch_async_f` |
| 366 | +/// carries one pointer and an index fits in it. |
| 367 | +var hop_granted: bool = false; |
| 368 | + |
| 369 | +/// Runs on whatever queue AVAudioSession chose. |
| 370 | +/// |
| 371 | +/// Swift hops to the main queue here before touching the session, because |
| 372 | +/// activating a session and starting a recorder off the main thread is not |
| 373 | +/// supported. The hop carries the slot index in the context pointer, exactly |
| 374 | +/// as `ios_events` does. |
| 375 | +fn permissionAnswered(index: u5, granted: bool) void { |
| 376 | + if (!is_darwin) return; |
| 377 | + |
| 378 | + hop_granted = granted; |
| 379 | + dispatch_async_f(&_dispatch_main_q, @ptrFromInt(@as(usize, index) + 1), beginOnMain); |
| 380 | +} |
| 381 | + |
| 382 | +/// The main-queue half: configure the session, build the recorder, start it. |
| 383 | +fn beginOnMain(context: ?*anyopaque) callconv(.c) void { |
| 384 | + const index: usize = @intFromPtr(context orelse return) - 1; |
| 385 | + if (index >= ios_async.max_in_flight) return; |
| 386 | + |
| 387 | + const ticket = pending orelse return; |
| 388 | + if (ticket.index != index) return; |
| 389 | + pending = null; |
| 390 | + |
| 391 | + if (!hop_granted) { |
| 392 | + std.log.warn("startAudioRecording: microphone permission was denied", .{}); |
| 393 | + ios_async.deliverErrorCode(ticket, BridgeError.PermissionDenied); |
| 394 | + return; |
| 395 | + } |
| 396 | + |
| 397 | + beginRecording() catch |err| { |
| 398 | + std.log.warn("startAudioRecording: could not start the recorder: {}", .{err}); |
| 399 | + ios_async.deliverErrorCode(ticket, BridgeError.NativeCallFailed); |
| 400 | + return; |
| 401 | + }; |
| 402 | + |
| 403 | + ios_async.deliverJson(ticket, start_reply); |
| 404 | +} |
| 405 | + |
| 406 | +fn beginRecording() !void { |
| 407 | + const allocator = std.heap.c_allocator; |
| 408 | + |
| 409 | + const path = try recordingFilePath(allocator); |
| 410 | + errdefer allocator.free(path); |
| 411 | + |
| 412 | + const session = try sharedAudioSession(); |
| 413 | + try configureSession(session); |
| 414 | + |
| 415 | + const settings = try recorderSettings(allocator); |
| 416 | + const ns_path = try objc.createNSString(path, allocator); |
| 417 | + |
| 418 | + const NSURL = objc.objc_getClass("NSURL") orelse return error.ClassNotFound; |
| 419 | + const sel_file_url = objc.sel_registerName("fileURLWithPath:") orelse return error.SelectorNotFound; |
| 420 | + const url = objc.msgSendId1(NSURL, sel_file_url, ns_path) orelse return error.NativeCallFailed; |
| 421 | + |
| 422 | + const AVAudioRecorder = objc.objc_getClass("AVAudioRecorder") orelse return error.ClassNotFound; |
| 423 | + const sel_alloc = objc.sel_registerName("alloc") orelse return error.SelectorNotFound; |
| 424 | + const sel_init = objc.sel_registerName("initWithURL:settings:error:") orelse |
| 425 | + return error.SelectorNotFound; |
| 426 | + const allocated = objc.msgSendId(AVAudioRecorder, sel_alloc) orelse return error.NativeCallFailed; |
| 427 | + |
| 428 | + const InitFn = *const fn (Id, objc.SEL, Id, Id, ?*Id) callconv(.c) Id; |
| 429 | + const initFn: InitFn = @ptrCast(&objc.objc_msgSend); |
| 430 | + var init_error: Id = null; |
| 431 | + const built = initFn(allocated, sel_init, url, settings, &init_error) orelse { |
| 432 | + logNSError("startAudioRecording", init_error); |
| 433 | + return error.NativeCallFailed; |
| 434 | + }; |
| 435 | + |
| 436 | + const sel_record = objc.sel_registerName("record") orelse return error.SelectorNotFound; |
| 437 | + const RecordFn = *const fn (Id, objc.SEL) callconv(.c) bool; |
| 438 | + const recordFn: RecordFn = @ptrCast(&objc.objc_msgSend); |
| 439 | + if (!recordFn(built, sel_record)) { |
| 440 | + release(built); |
| 441 | + return error.NativeCallFailed; |
| 442 | + } |
| 443 | + |
| 444 | + // Replace only once the recorder is actually running, so a failed start |
| 445 | + // leaves the previous state untouched rather than half-cleared. |
| 446 | + release(recorder); |
| 447 | + recorder = built; |
| 448 | + if (recording_path) |old| allocator.free(old); |
| 449 | + recording_path = path; |
| 450 | +} |
| 451 | + |
| 452 | +/// `setCategory:mode:options:error:` then `setActive:error:`. |
| 453 | +fn configureSession(session: Id) !void { |
| 454 | + const category = try audioSessionConstant("AVAudioSessionCategoryPlayAndRecord"); |
| 455 | + const mode = try audioSessionConstant("AVAudioSessionModeDefault"); |
| 456 | + |
| 457 | + const sel_category = objc.sel_registerName("setCategory:mode:options:error:") orelse |
| 458 | + return error.SelectorNotFound; |
| 459 | + const CategoryFn = *const fn (Id, objc.SEL, Id, Id, c_ulong, ?*Id) callconv(.c) bool; |
| 460 | + const setCategory: CategoryFn = @ptrCast(&objc.objc_msgSend); |
| 461 | + var category_error: Id = null; |
| 462 | + if (!setCategory(session, sel_category, category, mode, no_category_options, &category_error)) { |
| 463 | + logNSError("startAudioRecording", category_error); |
| 464 | + return error.NativeCallFailed; |
| 465 | + } |
| 466 | + |
| 467 | + const sel_active = objc.sel_registerName("setActive:error:") orelse return error.SelectorNotFound; |
| 468 | + const ActiveFn = *const fn (Id, objc.SEL, bool, ?*Id) callconv(.c) bool; |
| 469 | + const setActive: ActiveFn = @ptrCast(&objc.objc_msgSend); |
| 470 | + var active_error: Id = null; |
| 471 | + if (!setActive(session, sel_active, true, &active_error)) { |
| 472 | + logNSError("startAudioRecording", active_error); |
| 473 | + return error.NativeCallFailed; |
| 474 | + } |
| 475 | +} |
| 476 | + |
| 477 | +/// `@{AVFormatIDKey: @(kAudioFormatMPEG4AAC), AVSampleRateKey: @44100.0, |
| 478 | +/// AVNumberOfChannelsKey: @2, AVEncoderAudioQualityKey: @(high)}`. |
| 479 | +fn recorderSettings(allocator: std.mem.Allocator) !Id { |
| 480 | + const NSMutableDictionary = objc.objc_getClass("NSMutableDictionary") orelse |
| 481 | + return error.ClassNotFound; |
| 482 | + const settings = try objc.allocInit(NSMutableDictionary); |
| 483 | + |
| 484 | + try setNumber(settings, "AVFormatIDKey", .{ .int = audio_format_mpeg4_aac }); |
| 485 | + try setNumber(settings, "AVSampleRateKey", .{ .double = 44100.0 }); |
| 486 | + try setNumber(settings, "AVNumberOfChannelsKey", .{ .int = 2 }); |
| 487 | + try setNumber(settings, "AVEncoderAudioQualityKey", .{ .int = audio_quality_high }); |
| 488 | + |
| 489 | + _ = allocator; |
| 490 | + return settings; |
| 491 | +} |
| 492 | + |
| 493 | +const Number = union(enum) { int: c_int, double: f64 }; |
| 494 | + |
| 495 | +fn setNumber(dictionary: Id, comptime key_symbol: [*:0]const u8, value: Number) !void { |
| 496 | + const key = try audioSessionConstant(key_symbol); |
| 497 | + |
| 498 | + const NSNumber = objc.objc_getClass("NSNumber") orelse return error.ClassNotFound; |
| 499 | + const number = switch (value) { |
| 500 | + .int => |i| blk: { |
| 501 | + const sel = objc.sel_registerName("numberWithInt:") orelse return error.SelectorNotFound; |
| 502 | + const Fn = *const fn (objc.Class, objc.SEL, c_int) callconv(.c) Id; |
| 503 | + const f: Fn = @ptrCast(&objc.objc_msgSend); |
| 504 | + break :blk f(NSNumber, sel, i); |
| 505 | + }, |
| 506 | + .double => |d| blk: { |
| 507 | + const sel = objc.sel_registerName("numberWithDouble:") orelse return error.SelectorNotFound; |
| 508 | + const Fn = *const fn (objc.Class, objc.SEL, f64) callconv(.c) Id; |
| 509 | + const f: Fn = @ptrCast(&objc.objc_msgSend); |
| 510 | + break :blk f(NSNumber, sel, d); |
| 511 | + }, |
| 512 | + } orelse return error.NativeCallFailed; |
| 513 | + |
| 514 | + const sel_set = objc.sel_registerName("setObject:forKey:") orelse return error.SelectorNotFound; |
| 515 | + objc.msgSendVoid2(dictionary, sel_set, number, key); |
| 516 | +} |
| 517 | + |
| 518 | +/// An `extern NSString * const` from AVFoundation, read through its symbol. |
| 519 | +/// |
| 520 | +/// The recorder-settings keys and the session category/mode are all constants |
| 521 | +/// whose *values* are not API. Rebuilding one from its own spelling would |
| 522 | +/// produce a dictionary key AVFoundation ignores — a recorder built with |
| 523 | +/// silent defaults rather than the settings the spec names. |
| 524 | +fn audioSessionConstant(comptime symbol: [*:0]const u8) !Id { |
| 525 | + const found = dlsym(RTLD_DEFAULT, symbol) orelse { |
| 526 | + std.log.warn("startAudioRecording: {s} is not in this process", .{symbol}); |
| 527 | + return error.NotFound; |
| 528 | + }; |
| 529 | + const cell: *const Id = @ptrCast(@alignCast(found)); |
| 530 | + return cell.* orelse error.NotFound; |
| 531 | +} |
| 532 | + |
| 533 | +/// `Documents/recording_<timeIntervalSince1970>.m4a`. |
| 534 | +fn recordingFilePath(allocator: std.mem.Allocator) ![]u8 { |
| 535 | + const NSFileManager = objc.objc_getClass("NSFileManager") orelse return error.ClassNotFound; |
| 536 | + const sel_default = objc.sel_registerName("defaultManager") orelse return error.SelectorNotFound; |
| 537 | + const manager = objc.msgSendId(NSFileManager, sel_default) orelse return error.NativeCallFailed; |
| 538 | + |
| 539 | + const sel_urls = objc.sel_registerName("URLsForDirectory:inDomains:") orelse |
| 540 | + return error.SelectorNotFound; |
| 541 | + // NSDocumentDirectory = 9, NSUserDomainMask = 1. |
| 542 | + const urls = objc.msgSendId2(manager, sel_urls, @as(c_ulong, 9), @as(c_ulong, 1)) orelse |
| 543 | + return error.NativeCallFailed; |
| 544 | + |
| 545 | + const sel_first = objc.sel_registerName("firstObject") orelse return error.SelectorNotFound; |
| 546 | + // Swift subscripts with `[0]`, which raises on an empty result. |
| 547 | + const url = objc.msgSendId(urls, sel_first) orelse return error.NotFound; |
| 548 | + |
| 549 | + const sel_path = objc.sel_registerName("path") orelse return error.SelectorNotFound; |
| 550 | + const ns_path = objc.msgSendId(url, sel_path) orelse return error.NativeCallFailed; |
| 551 | + const utf8 = objc.getNSStringUTF8(ns_path) orelse return error.NativeCallFailed; |
| 552 | + |
| 553 | + return std.fmt.allocPrint( |
| 554 | + allocator, |
| 555 | + "{s}/recording_{d}.m4a", |
| 556 | + .{ std.mem.span(utf8), currentTimeInterval() }, |
| 557 | + ); |
| 558 | +} |
| 559 | + |
| 560 | +fn currentTimeInterval() f64 { |
| 561 | + const NSDate = objc.objc_getClass("NSDate") orelse return 0; |
| 562 | + const sel_date = objc.sel_registerName("date") orelse return 0; |
| 563 | + const now = objc.msgSendId(NSDate, sel_date) orelse return 0; |
| 564 | + const sel_interval = objc.sel_registerName("timeIntervalSince1970") orelse return 0; |
| 565 | + const IntervalFn = *const fn (Id, objc.SEL) callconv(.c) f64; |
| 566 | + const intervalFn: IntervalFn = @ptrCast(&objc.objc_msgSend); |
| 567 | + return intervalFn(now, sel_interval); |
| 568 | +} |
| 569 | + |
| 570 | +fn logNSError(action: []const u8, err: Id) void { |
| 571 | + const ns_error = err orelse return; |
| 572 | + const sel = objc.sel_registerName("localizedDescription") orelse return; |
| 573 | + const ns_description = objc.msgSendId(ns_error, sel) orelse return; |
| 574 | + const utf8 = objc.getNSStringUTF8(ns_description) orelse return; |
| 575 | + std.log.warn("{s}: {s}", .{ action, std.mem.span(utf8) }); |
| 576 | +} |
| 577 | + |
| 578 | +fn release(object: Id) void { |
| 579 | + const target = object orelse return; |
| 580 | + const sel = objc.sel_registerName("release") orelse return; |
| 581 | + objc.msgSend(target, sel); |
| 582 | +} |
| 583 | + |
| 584 | +const dispatch_function_t = *const fn (?*anyopaque) callconv(.c) void; |
| 585 | +extern "c" fn dispatch_async_f(queue: *anyopaque, context: ?*anyopaque, work: dispatch_function_t) void; |
| 586 | +extern var _dispatch_main_q: anyopaque; |
| 587 | + |
| 588 | +extern "c" fn dlsym(handle: ?*anyopaque, symbol: [*:0]const u8) ?*anyopaque; |
| 589 | + |
| 590 | +/// `RTLD_DEFAULT` — search every image already loaded into the process. |
| 591 | +const RTLD_DEFAULT: ?*anyopaque = @ptrFromInt(@as(usize, @bitCast(@as(isize, -2)))); |
| 592 | + |
| 593 | +const testing = std.testing; |
| 594 | + |
| 595 | +test "the action names match the Swift case labels exactly" { |
| 596 | + try testing.expectEqualStrings("startAudioRecording", A.start_audio_recording); |
| 597 | + try testing.expectEqualStrings("stopAudioRecording", A.stop_audio_recording); |
| 598 | +} |
| 599 | + |
| 600 | +test "the recorder settings are the values Swift names" { |
| 601 | + // `kAudioFormatMPEG4AAC` is the four-character code 'aac '. A wrong value |
| 602 | + // here is not a silent misconfiguration — `initWithURL:settings:error:` |
| 603 | + // refuses it — but the number is unreadable at a glance, so it is pinned |
| 604 | + // against its own spelling. |
| 605 | + try testing.expectEqual(@as(c_int, 0x61616320), audio_format_mpeg4_aac); |
| 606 | + try testing.expectEqual( |
| 607 | + @as(u32, @bitCast([4]u8{ 'a', 'a', 'c', ' ' })), |
| 608 | + @byteSwap(@as(u32, @intCast(audio_format_mpeg4_aac))), |
| 609 | + ); |
| 610 | + try testing.expectEqual(@as(c_int, 0x60), audio_quality_high); |
| 611 | +} |
| 612 | + |
| 613 | +test "the reply prefix is the one Swift concatenates" { |
| 614 | + // A page assigning the reply to an <audio> src depends on both the media |
| 615 | + // type and the base64 marker. |
| 616 | + try testing.expectEqualStrings("data:audio/m4a;base64,", m4a_data_url_prefix); |
| 617 | + try testing.expectEqualStrings("true", start_reply); |
| 618 | +} |
| 619 | + |
| 620 | +test "base64 cannot carry a character JSON would have to escape" { |
| 621 | + // `replyWithDataUrl` quotes the payload without running the shared escaper, |
| 622 | + // because the buffer is sized exactly. That is only safe while the |
| 623 | + // alphabet stays free of `"` and `\`, so the alphabet is checked rather |
| 624 | + // than asserted in prose. |
| 625 | + const alphabet = std.base64.standard_alphabet_chars; |
| 626 | + for (alphabet) |c| { |
| 627 | + try testing.expect(c != '"'); |
| 628 | + try testing.expect(c != '\\'); |
| 629 | + try testing.expect(c >= 0x20); |
| 630 | + } |
| 631 | + try testing.expect(std.base64.standard.Encoder.pad_char.? == '='); |
| 632 | +} |
| 633 | + |
| 634 | +test "a recording larger than the ceiling is refused, not truncated" { |
| 635 | + // Swift has no ceiling and will base64 a ten-minute take into the source |
| 636 | + // `evaluateJavaScript:` parses. The failure there is a webview that stalls |
| 637 | + // rather than an error a page can read. |
| 638 | + try testing.expectEqual(@as(u64, 32 << 20), max_recording_bytes); |
| 639 | +} |
| 640 | + |
| 641 | +test "a missing recording is NotFound rather than an empty reply" { |
| 642 | + // Swift's else branch rejects "No recording found". Resolving an empty |
| 643 | + // data URL would tell the page it recorded silence. |
| 644 | + try testing.expectError( |
| 645 | + error.NotFound, |
| 646 | + readRecording(testing.allocator, "craft-audiorec-does-not-exist.m4a"), |
| 647 | + ); |
| 648 | +} |
| 649 | + |
| 650 | +test "a process with no usage description is refused before the microphone is asked for" { |
| 651 | + // Requesting record permission without NSMicrophoneUsageDescription |
| 652 | + // terminates the process, so the refusal has to happen first. The host |
| 653 | + // test binary has no such key, which is what this exercises. |
| 654 | + if (!is_darwin) return error.SkipZigTest; |
| 655 | + |
| 656 | + try testing.expectError(BridgeError.PermissionDenied, requireMicrophoneUsageDescription()); |
| 657 | +} |
| 658 | + |
| 659 | +test "every declared action dispatches to something" { |
| 660 | + var bridge = AudioRecordingBridge.init(testing.allocator); |
| 661 | + defer bridge.deinit(); |
| 662 | + |
| 663 | + for (capability_actions) |decl| { |
| 664 | + bridge.handleMessage(decl.name, "{}") catch |err| { |
| 665 | + try testing.expect(err != BridgeError.UnknownAction); |
| 666 | + continue; |
| 667 | + }; |
| 668 | + } |
| 669 | + try testing.expectError(BridgeError.UnknownAction, bridge.handleMessage("getDeviceInfo", "{}")); |
| 670 | +} |
| 671 | + |
| 672 | +test "each permission block is global and has its own invoke" { |
| 673 | + if (!is_darwin) return error.SkipZigTest; |
| 674 | + |
| 675 | + for (&permission_blocks) |*b| { |
| 676 | + try testing.expectEqual(&_NSConcreteGlobalBlock, b.isa); |
| 677 | + try testing.expectEqual(BLOCK_IS_GLOBAL, b.flags); |
| 678 | + try testing.expectEqual(@sizeOf(PermissionBlock), @as(usize, @intCast(b.descriptor.size))); |
| 679 | + } |
| 680 | + try testing.expect(permission_blocks[0].invoke != permission_blocks[1].invoke); |
| 681 | +} |
| 682 | + |
| 683 | +test "a main-queue hop for a slot that is not the pending one is ignored" { |
| 684 | + // The hop carries only an index. If the ticket it names is not the one |
| 685 | + // waiting — a stale fire, or a start that was already cancelled — it must |
| 686 | + // do nothing rather than answer somebody else's call. |
| 687 | + if (!is_darwin) return error.SkipZigTest; |
| 688 | + |
| 689 | + pending = null; |
| 690 | + beginOnMain(@ptrFromInt(1)); |
| 691 | + try testing.expect(pending == null); |
| 692 | +} |