ReviewOS

also looking at this

craft-native/craft

iOS: the audio recording pair — ratchet 33 → 31

#98
Merged glennmichael123 wants to merge feat/ios-audio-recording into main
9 files +744 -3
packages/ios/fixtures/zig-slice/Info.plistmodified+2-0
Changes to packages/ios/fixtures/zig-slice/Info.plist
@@ -14,5 +14,7 @@
1414 <array><string>UIInterfaceOrientationPortrait</string></array>
1515 <key>NSBluetoothAlwaysUsageDescription</key>
1616 <string>The slice fixture asks for Bluetooth so startBluetoothScan reaches CBCentralManager rather than stopping at the plist guard.</string>
17 <key>NSMicrophoneUsageDescription</key>
18 <string>The slice fixture asks for the microphone so startAudioRecording reaches AVAudioRecorder rather than stopping at the plist guard.</string>
1719</dict>
1820</plist>
packages/ios/fixtures/zig-slice/build-and-run.shmodified+19-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 -framework CoreBluetooth \
67 -framework WatchConnectivity -framework CoreBluetooth -framework AVFoundation \
6868 -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __entitlements -Xlinker "$OUT/sim.entitlements" \
6969 -o "$APP/CraftSlice"
7070
@@ -92,6 +92,13 @@ echo "==> installing and launching"
9292xcrun simctl uninstall "$UDID" "$BUNDLE_ID" 2>/dev/null || true
9393xcrun simctl install "$UDID" "$APP"
9494
95# Pre-grant the microphone. Unlike notifications — which simctl has no service
96# for — audio recording *can* be authorised without a human, which is the whole
97# reason this pair is verifiable end to end and scheduleNotification is not.
98# Granted before launch, because simctl terminates a running app on a
99# permission change.
100xcrun simctl privacy "$UDID" grant microphone "$BUNDLE_ID" 2>/dev/null || true
101
95102LOG="$OUT/console.log"
96103xcrun simctl launch --console-pty "$UDID" "$BUNDLE_ID" > "$LOG" 2>&1 &
97104LAUNCH_PID=$!
@@ -332,4 +339,15 @@ C58="$(count 58)"
332339[ "$C58" -ge 1 ] || { echo "FAIL: stopBluetoothScan did not resolve"; exit 1; }
333340echo "ok: stopping with nothing running still answers true (i=58 seen ${C58}x)"
334341
342C62="$(count 62)"
343[ "$C62" -ge 1 ] || { echo "FAIL: startAudioRecording never started the recorder"; exit 1; }
344echo "ok: microphone granted and AVAudioRecorder running (i=62 seen ${C62}x)"
345
346C64="$(count 64)"
347# The reply base64-decodes to a file whose bytes 4..8 are 'ftyp' — the ISO base
348# media signature every M4A carries. AVAudioRecorder wrote those bytes from a
349# live capture; nothing short of the whole path produces them.
350[ "$C64" -ge 1 ] || { echo "FAIL: stopAudioRecording did not return a real M4A"; exit 1; }
351echo "ok: recording came back as a decodable M4A data URL (i=64 seen ${C64}x)"
352
335353echo "PASS"
packages/ios/fixtures/zig-slice/craft.config.jsonmodified+1-1
Changes to packages/ios/fixtures/zig-slice/craft.config.json
@@ -23,7 +23,7 @@
2323 "enableFilePicker": false,
2424 "enableFileDownload": true,
2525 "enableSocialAuth": false,
26 "enableAudioRecording": false,
26 "enableAudioRecording": true,
2727 "enableVideoRecording": false,
2828 "enableMotionSensors": true,
2929 "enableLocalDatabase": true,
packages/ios/fixtures/zig-slice/page.mmodified+21-0
Changes to packages/ios/fixtures/zig-slice/page.m
@@ -52,6 +52,7 @@ const char craft_slice_page[] =
5252 "var sentPdfOpen = false, sentPdfClose = false;\n"
5353 "var sentDownload = false, sentWatch = false, sentSettings = false;\n"
5454 "var sentBtStart = false, sentBtStop = false;\n"
55 "var sentRecStart = false, sentRecStop = false;\n"
5556 "window.__craftBridgeError = function (ctx) {\n"
5657 " // The message crossed Zig's escaping with a backslash, quote, and\n"
5758 " // newline intact; anything mangled and this equality fails, i=7 never\n"
@@ -184,6 +185,25 @@ const char craft_slice_page[] =
184185 " // was parsed and routed — and the donation ran without throwing.\n"
185186 " // A bare string path, landed inside the app container's Documents.\n"
186187 " // Nothing but a real download-and-move produces it.\n"
188 " // The recorder is running. Give it a moment of audio before stopping,\n"
189 " // or the encoder writes a container with no samples in it.\n"
190 " if (!sentRecStart && action === 'startAudioRecording' && payload === true) {\n"
191 " sentRecStart = true;\n"
192 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:62});\n"
193 " setTimeout(function () {\n"
194 " bridge.postMessage({t:'mobile', a:'stopAudioRecording', i:106});\n"
195 " }, 700);\n"
196 " }\n"
197 " // An M4A the encoder actually produced: 'ftyp' sits at offset 4 of\n"
198 " // every ISO base media file, and a stub has no way to emit one.\n"
199 " if (!sentRecStop && action === 'stopAudioRecording' && typeof payload === 'string'\n"
200 " && payload.indexOf('data:audio/m4a;base64,') === 0) {\n"
201 " var raw = atob(payload.slice('data:audio/m4a;base64,'.length));\n"
202 " if (raw.slice(4, 8) === 'ftyp' && raw.length > 500) {\n"
203 " sentRecStop = true;\n"
204 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:64});\n"
205 " }\n"
206 " }\n"
187207 " if (!sentBtStop && action === 'stopBluetoothScan' && payload === true) {\n"
188208 " sentBtStop = true;\n"
189209 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:58});\n"
@@ -338,6 +358,7 @@ const char craft_slice_page[] =
338358 " // for the promise Swift drops — and would mask the state refusal this\n"
339359 " // asserts. The stop goes out once the start has been answered.\n"
340360 " bridge.postMessage({t:'mobile', a:'startBluetoothScan', i:103});\n"
361 " bridge.postMessage({t:'mobile', a:'startAudioRecording', i:105});\n"
341362 " // A Tier-0 action migrated in this phase, served by Zig directly.\n"
342363 " bridge.postMessage({t:'mobile', a:'getAppState', i:88});\n"
343364 " // 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
@@ -416,6 +416,9 @@ pub fn build(b: *std.Build) void {
416416 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_bluetooth.zig", .{
417417 .root_source_file = b.path("src/bridge_mobile_bluetooth.zig"),
418418 });
419 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_audiorec.zig", .{
420 .root_source_file = b.path("src/bridge_mobile_audiorec.zig"),
421 });
419422 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile.zig", .{
420423 .root_source_file = b.path("src/bridge_mobile.zig"),
421424 });
packages/zig/src/bridge_mobile_audiorec.zigadded+692-0
Changes to packages/zig/src/bridge_mobile_audiorec.zig
@@ -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
45const std = ;
46const builtin = ;
47const capabilities = ;
48const bridge_error = ;
49const objc_runtime = ;
50const ios_async = ;
51const io_context = ;
52
53const objc = objc_runtime.objc;
54const BridgeError = bridge_error.BridgeError;
55const Id = ?*anyopaque;
56const is_darwin = builtin.target.os.tag.isDarwin();
57
58pub const A = struct {
59 pub const start_audio_recording = "startAudioRecording";
60 pub const stop_audio_recording = "stopAudioRecording";
61};
62
63pub 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.
72const audio_format_mpeg4_aac: c_int = 0x61616320;
73
74/// `AVAudioQuality.high`.
75const audio_quality_high: c_int = 0x60;
76
77/// `AVAudioSessionCategoryOptions` — none, matching Swift's two-argument
78/// `setCategory(_:mode:)`.
79const no_category_options: c_ulong = 0;
80
81/// The prefix Swift concatenates ahead of the base64.
82const m4a_data_url_prefix = "data:audio/m4a;base64,";
83
84/// `startAudioRecording`'s reply once the recorder is running.
85const 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.
94const 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.
100var recorder: Id = null;
101var recording_path: ?[]u8 = null;
102var pending: ?ios_async.Ticket = null;
103
104pub 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.
234fn 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]`.
274fn 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.
288const key_microphone_usage = "NSMicrophoneUsageDescription";
289
290fn 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
319const BlockDescriptor = extern struct {
320 reserved: c_ulong = 0,
321 size: c_ulong,
322};
323
324/// `void (^)(BOOL granted)`.
325const 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.
334const BLOCK_IS_GLOBAL: c_int = 1 << 28;
335
336const permission_block_descriptor = BlockDescriptor{ .size = @sizeOf(PermissionBlock) };
337
338extern var _NSConcreteGlobalBlock: anyopaque;
339
340fn 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
349fn 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
362var 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.
367var 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.
375fn 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.
383fn 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
406fn 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:`.
453fn 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)}`.
479fn 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
493const Number = union(enum) { int: c_int, double: f64 };
494
495fn 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.
524fn 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`.
534fn 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
560fn 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
570fn 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
578fn 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
584const dispatch_function_t = *const fn (?*anyopaque) callconv(.c) void;
585extern "c" fn dispatch_async_f(queue: *anyopaque, context: ?*anyopaque, work: dispatch_function_t) void;
586extern var _dispatch_main_q: anyopaque;
587
588extern "c" fn dlsym(handle: ?*anyopaque, symbol: [*:0]const u8) ?*anyopaque;
589
590/// `RTLD_DEFAULT` — search every image already loaded into the process.
591const RTLD_DEFAULT: ?*anyopaque = @ptrFromInt(@as(usize, @bitCast(@as(isize, -2))));
592
593const testing = std.testing;
594
595test "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
600test "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
613test "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
620test "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
634test "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
641test "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
650test "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
659test "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
672test "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
683test "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}
packages/zig/src/ios_config.zigmodified+2-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 .{ "startAudioRecording", .audio_recording },
432433 .{ "startBluetoothScan", .bluetooth },
433434 .{ "secureGet", .secure_storage },
434435 .{ "secureRemove", .secure_storage },
@@ -436,6 +437,7 @@ pub fn gateFor(action: []const u8) ?Feature {
436437 .{ "setKeepAwake", .keep_awake },
437438 .{ "share", .share },
438439 .{ "startMotionUpdates", .motion_sensors },
440 .{ "stopAudioRecording", .audio_recording },
439441 .{ "takeScreenshot", .screen_capture },
440442 .{ "unlockOrientation", .orientation_lock },
441443 .{ "watchPosition", .geolocation },
packages/zig/src/ios_dispatch.zigmodified+2-0
Changes to packages/zig/src/ios_dispatch.zig
@@ -37,6 +37,7 @@ const bridge_mobile_auth = ;
3737const bridge_mobile_siri = ;
3838const bridge_mobile_pdf = ;
3939const bridge_mobile_bluetooth = ;
40const bridge_mobile_audiorec = ;
4041// `bridge_mobile_speech.zig` is deliberately NOT in the chain. Its
4142// capability_actions table is empty — the two speech actions were researched
4243// in full and left with the Swift shim — so a module here would iterate,
@@ -283,6 +284,7 @@ const mobile_bridges = .{
283284 bridge_mobile_siri.SiriBridge,
284285 bridge_mobile_pdf.PdfBridge,
285286 bridge_mobile_bluetooth.BluetoothBridge,
287 bridge_mobile_audiorec.AudioRecordingBridge,
286288};
287289
288290/// 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
@@ -54,6 +54,7 @@ const zig_sources = [_][]const u8{
5454 @embedFile("src/bridge_mobile_siri.zig"),
5555 @embedFile("src/bridge_mobile_pdf.zig"),
5656 @embedFile("src/bridge_mobile_bluetooth.zig"),
57 @embedFile("src/bridge_mobile_audiorec.zig"),
5758 @embedFile("src/bridge_mobile_speech.zig"),
5859};
5960
@@ -98,7 +99,7 @@ const dispatch_end = "func webView(";
9899/// 48 after calendar and contacts. Speech was researched in full and left
99100/// with the shim: neither action replies, so unlike the earlier deferrals
100101/// there is no promise to strand and falling through costs the page nothing.
101const max_not_yet_migrated: usize = 33;
102const max_not_yet_migrated: usize = 31;
102103
103104fn dispatcherRegion() []const u8 {
104105 const begin = std.mem.indexOf(u8, swift_spec, dispatch_begin) orelse return "";