ReviewOS

also looking at this

craft-native/craft

iOS: the health query pair — ratchet 31 → 29

#99
Merged glennmichael123 wants to merge feat/ios-health into main
8 files +922 -4
packages/ios/fixtures/zig-slice/build-and-run.shmodified+10-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 -framework AVFoundation \
67 -framework WatchConnectivity -framework CoreBluetooth -framework AVFoundation -framework HealthKit \
6868 -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __entitlements -Xlinker "$OUT/sim.entitlements" \
6969 -o "$APP/CraftSlice"
7070
@@ -350,4 +350,13 @@ C64="$(count 64)"
350350[ "$C64" -ge 1 ] || { echo "FAIL: stopAudioRecording did not return a real M4A"; exit 1; }
351351echo "ok: recording came back as a decodable M4A data URL (i=64 seen ${C64}x)"
352352
353C66="$(count 66)"
354# HealthKit is linked and the store exists on an iPhone simulator, so this is
355# HKStatisticsQuery's own answer. What is asserted is that it ANSWERS: the
356# spec's arm has no else, so before this the promise simply stayed pending.
357# requestHealthAuthorization is not exercised — it presents a sheet, and
358# simctl has no HealthKit privacy service to pre-grant.
359[ "$C66" -ge 1 ] || { echo "FAIL: getHealthData neither answered nor failed — it hung"; exit 1; }
360echo "ok: health query answered rather than hanging (i=66 seen ${C66}x)"
361
353362echo "PASS"
packages/ios/fixtures/zig-slice/craft.config.jsonmodified+1-1
Changes to packages/ios/fixtures/zig-slice/craft.config.json
@@ -29,7 +29,7 @@
2929 "enableLocalDatabase": true,
3030 "enableBluetooth": true,
3131 "enableNFC": false,
32 "enableHealthKit": false,
32 "enableHealthKit": true,
3333 "enableLiveActivities": false,
3434 "enableWatchApp": false,
3535 "enableBackgroundLocation": false,
packages/ios/fixtures/zig-slice/page.mmodified+19-1
Changes to packages/ios/fixtures/zig-slice/page.m
@@ -52,7 +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"
55 "var sentRecStart = false, sentRecStop = false, sentHealth = false;\n"
5656 "window.__craftBridgeError = function (ctx) {\n"
5757 " // The message crossed Zig's escaping with a backslash, quote, and\n"
5858 " // newline intact; anything mangled and this equality fails, i=7 never\n"
@@ -83,6 +83,10 @@ const char craft_slice_page[] =
8383 " // usage description is present, so this is the radio's own state and\n"
8484 " // not a missing class or a missing plist key. Swift answers all five\n"
8585 " // unusable states with one string; this one names which.\n"
86 " if (!sentHealth && ctx && ctx.error === true && ctx.id === 107) {\n"
87 " sentHealth = true;\n"
88 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:66});\n"
89 " }\n"
8690 " if (!sentBtStart && ctx && ctx.error === true && ctx.id === 103\n"
8791 " && ctx.code === 'PLATFORM_NOT_SUPPORTED') {\n"
8892 " sentBtStart = true;\n"
@@ -187,6 +191,15 @@ const char craft_slice_page[] =
187191 " // Nothing but a real download-and-move produces it.\n"
188192 " // The recorder is running. Give it a moment of audio before stopping,\n"
189193 " // or the encoder writes a container with no samples in it.\n"
194 " // Answering at all is the property: the spec's arm has no else, so an\n"
195 " // unauthorised HealthKit left the promise pending. When it does answer\n"
196 " // with data, unit 'count' proves the HKUnit round-tripped out through\n"
197 " // unitFromString and back through unitString.\n"
198 " if (!sentHealth && action === 'getHealthData' && payload\n"
199 " && typeof payload.value === 'number' && payload.unit === 'count') {\n"
200 " sentHealth = true;\n"
201 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:66});\n"
202 " }\n"
190203 " if (!sentRecStart && action === 'startAudioRecording' && payload === true) {\n"
191204 " sentRecStart = true;\n"
192205 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:62});\n"
@@ -359,6 +372,11 @@ const char craft_slice_page[] =
359372 " // asserts. The stop goes out once the start has been answered.\n"
360373 " bridge.postMessage({t:'mobile', a:'startBluetoothScan', i:103});\n"
361374 " bridge.postMessage({t:'mobile', a:'startAudioRecording', i:105});\n"
375 " // getHealthData only. requestHealthAuthorization presents a system\n"
376 " // sheet and simctl has no HealthKit privacy service, so it cannot be\n"
377 " // answered without a person — the same wall scheduleNotification hits.\n"
378 " bridge.postMessage({t:'mobile', a:'getHealthData',\n"
379 " d: JSON.stringify({type:'steps'}), i:107});\n"
362380 " // A Tier-0 action migrated in this phase, served by Zig directly.\n"
363381 " bridge.postMessage({t:'mobile', a:'getAppState', i:88});\n"
364382 " // 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
@@ -419,6 +419,9 @@ pub fn build(b: *std.Build) void {
419419 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_audiorec.zig", .{
420420 .root_source_file = b.path("src/bridge_mobile_audiorec.zig"),
421421 });
422 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile_health.zig", .{
423 .root_source_file = b.path("src/bridge_mobile_health.zig"),
424 });
422425 ios_conformance_tests.root_module.addAnonymousImport("src/bridge_mobile.zig", .{
423426 .root_source_file = b.path("src/bridge_mobile.zig"),
424427 });
packages/zig/src/bridge_mobile_health.zigadded+883-0
Changes to packages/zig/src/bridge_mobile_health.zig
@@ -0,0 +1,883 @@
1//! Two of the `mobile` namespace's three HealthKit actions:
2//! `requestHealthAuthorization` and `getHealthData`.
3//!
4//! ## Why two and not three, when the last two pairs moved whole
5//!
6//! `saveHealthWorkout` stays with the Swift shim this round, and the reason is
7//! the opposite of the one that kept `openPDF`/`closePDF` and the audio pair
8//! together. Those shared *module state*: a controller one stored and the other
9//! dismissed, a recorder one built and the other stopped. Splitting them left
10//! half a state machine in each language.
11//!
12//! Nothing flows between these three. The only thing they share is an
13//! `HKHealthStore`, which is a stateless handle — authorization lives in the
14//! system database, not in the object, and Apple supports more than one store
15//! per process. So Zig owning two of them and Swift the third produces no
16//! divided state and no wrong answer, only a smaller diff.
17//!
18//! What `saveHealthWorkout` costs is a third asynchronous stage:
19//! `HKWorkout` construction, then `save:withCompletion:`, then an
20//! `HKWorkoutRouteBuilder` chain for the optional `locations` array — each with
21//! its own completion, each able to fail after the previous succeeded. That is
22//! a round of its own, not a tail on this one.
23//!
24//! ## The store is created here, not inherited
25//!
26//! Swift builds its `HKHealthStore` in `Coordinator.init` behind
27//! `config.enableHealthKit && HKHealthStore.isHealthDataAvailable()`. This
28//! module builds its own, lazily, behind the same availability check — the
29//! config half is already applied by `ios_dispatch` before any handler runs.
30//! A second store is not a second authorization: both read the same system
31//! state, which is what makes the split above safe.
32//!
33//! ## Neither action can be exercised without a person
34//!
35//! `requestAuthorization(toShare:read:)` presents a system sheet, and no
36//! `simctl privacy` service covers HealthKit — unlike the microphone, which is
37//! why `bridge_mobile_audiorec.zig` could be proved end to end and this cannot.
38//! The fixture therefore asserts what a simulator *can* establish: that both
39//! actions are served by Zig and refuse honestly on a device with no health
40//! data, rather than hanging as the spec's gated arms do.
41
42const std = ;
43const builtin = ;
44const capabilities = ;
45const bridge_error = ;
46const objc_runtime = ;
47const ios_async = ;
48const compat_mutex = ;
49
50const objc = objc_runtime.objc;
51const BridgeError = bridge_error.BridgeError;
52const Id = ?*anyopaque;
53const is_darwin = builtin.target.os.tag.isDarwin();
54
55pub const A = struct {
56 pub const request_health_authorization = "requestHealthAuthorization";
57 pub const get_health_data = "getHealthData";
58};
59
60pub const capability_actions = [_]capabilities.ActionDecl{
61 .{ .name = A.request_health_authorization, .reply = .result },
62 .{ .name = A.get_health_data, .reply = .result },
63};
64
65/// The action this module was scoped to and, on purpose, does not serve.
66///
67/// Recorded as data rather than prose so a test can hold both properties the
68/// decision rests on: it is not declared, and it still falls out of
69/// `handleMessage` as `UnknownAction` — the one return `ios_dispatch.route`
70/// turns into a Swift-shim hand-off.
71pub const deliberately_unserved = [_][]const u8{
72 "saveHealthWorkout",
73};
74
75/// `requestHealthAuthorization`'s reply on success: the bare JSON `true`.
76const authorized_reply = "true";
77
78/// The four data types Swift's `getHealthData` switch recognises, with the
79/// HealthKit identifier and unit each maps to.
80///
81/// The identifiers are `HKQuantityTypeIdentifier` constants — plain
82/// `NSString`s whose values happen to equal their own names, which is *not*
83/// guaranteed and is why they are read through `dlsym` rather than spelled.
84/// The unit strings are the ones `HKUnit unitFromString:` parses, and they are
85/// also what comes back in the reply's `unit` field, so a change here is a
86/// change to the wire.
87const HealthType = struct {
88 /// The name the page sends.
89 name: []const u8,
90 /// The `HKQuantityTypeIdentifier*` symbol to resolve.
91 identifier_symbol: [*:0]const u8,
92 /// What `HKUnit unitFromString:` is given, and what the reply reports.
93 unit: [*:0]const u8,
94};
95
96const health_types = [_]HealthType{
97 .{ .name = "steps", .identifier_symbol = "HKQuantityTypeIdentifierStepCount", .unit = "count" },
98 .{ .name = "heartRate", .identifier_symbol = "HKQuantityTypeIdentifierHeartRate", .unit = "count/min" },
99 .{ .name = "activeEnergy", .identifier_symbol = "HKQuantityTypeIdentifierActiveEnergyBurned", .unit = "kcal" },
100 .{ .name = "distance", .identifier_symbol = "HKQuantityTypeIdentifierDistanceWalkingRunning", .unit = "m" },
101};
102
103fn healthTypeFor(name: []const u8) ?HealthType {
104 for (health_types) |entry| {
105 if (std.mem.eql(u8, entry.name, name)) return entry;
106 }
107 return null;
108}
109
110/// `HKStatisticsOptionCumulativeSum`, read from
111/// `HealthKit.framework/Headers/HKStatistics.h:42`.
112///
113/// This was `1 << 2` on the first attempt, which is
114/// `HKStatisticsOptionDiscreteMin` — an option HealthKit rejects for a
115/// cumulative type like step count, by *raising* inside
116/// `+[HKStatistics _validateOptions:forDataType:]`. The fixture caught it as a
117/// process abort, which was luck: had the wrong bit been a legal one for the
118/// type, the query would have computed a minimum and reported it as a sum.
119const statistics_option_cumulative_sum: c_ulong = 1 << 4;
120
121/// `HKQueryOptionStrictStartDate`, from `HKQuery.h:50`.
122const query_option_strict_start_date: c_ulong = 1 << 0;
123
124/// Swift's default window when the page sends no dates: the last seven days.
125const default_window_seconds: f64 = 7 * 24 * 60 * 60;
126
127pub const HealthBridge = struct {
128 allocator: std.mem.Allocator,
129
130 const Self = @This();
131
132 pub fn init(allocator: std.mem.Allocator) Self {
133 return .{ .allocator = allocator };
134 }
135
136 pub fn deinit(_: *Self) void {}
137
138 pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void {
139 if (std.mem.eql(u8, action, A.request_health_authorization)) {
140 try self.requestAuthorization(data);
141 } else if (std.mem.eql(u8, action, A.get_health_data)) {
142 try self.getHealthData(data);
143 } else {
144 // `saveHealthWorkout` lands here on purpose. See the module
145 // comment and `deliberately_unserved`.
146 return BridgeError.UnknownAction;
147 }
148 }
149
150 /// Ask for read and share permission on the types the page named.
151 fn requestAuthorization(self: *Self, data: []const u8) !void {
152 if (!is_darwin) return BridgeError.PlatformNotSupported;
153
154 const store = try healthStore();
155
156 var parsed = std.json.parseFromSlice(std.json.Value, self.allocator, data, .{}) catch |err| switch (err) {
157 error.OutOfMemory => return err,
158 else => return BridgeError.InvalidJSON,
159 };
160 defer parsed.deinit();
161
162 const requested = try readTypeNames(parsed.value);
163
164 const share = try shareTypeSet(self.allocator, requested);
165 const read = try readTypeSet(self.allocator, requested);
166
167 const ticket = ios_async.acquire(A.request_health_authorization) orelse {
168 std.log.warn(
169 "requestHealthAuthorization: no free reply slot; {d} native calls are already awaiting one",
170 .{ios_async.max_in_flight},
171 );
172 return BridgeError.NativeCallFailed;
173 };
174 errdefer ios_async.abandon(ticket);
175 publishCall(ticket, null);
176
177 const sel = objc.sel_registerName("requestAuthorizationToShareTypes:readTypes:completion:") orelse
178 return BridgeError.NativeCallFailed;
179 const RequestFn = *const fn (Id, objc.SEL, Id, Id, *anyopaque) callconv(.c) void;
180 const request: RequestFn = @ptrCast(&objc.objc_msgSend);
181 request(store, sel, share, read, @ptrCast(&auth_blocks[ticket.index]));
182 }
183
184 /// Sum one quantity type over a window and answer `{value, unit}`.
185 fn getHealthData(self: *Self, data: []const u8) !void {
186 if (!is_darwin) return BridgeError.PlatformNotSupported;
187
188 const store = try healthStore();
189
190 var parsed = std.json.parseFromSlice(std.json.Value, self.allocator, data, .{}) catch |err| switch (err) {
191 error.OutOfMemory => return err,
192 else => return BridgeError.InvalidJSON,
193 };
194 defer parsed.deinit();
195
196 const request = try readDataRequest(parsed.value);
197
198 const quantity_type = try quantityType(request.type.identifier_symbol);
199 const unit = try unitFromString(request.type.unit);
200 const predicate = try samplePredicate(request.start, request.end);
201
202 const ticket = ios_async.acquire(A.get_health_data) orelse {
203 std.log.warn(
204 "getHealthData: no free reply slot; {d} native calls are already awaiting one",
205 .{ios_async.max_in_flight},
206 );
207 return BridgeError.NativeCallFailed;
208 };
209 errdefer ios_async.abandon(ticket);
210 publishCall(ticket, unit);
211
212 const HKStatisticsQuery = objc.objc_getClass("HKStatisticsQuery") orelse
213 return BridgeError.PlatformNotSupported;
214 const sel_alloc = objc.sel_registerName("alloc") orelse return BridgeError.NativeCallFailed;
215 const sel_init = objc.sel_registerName(
216 "initWithQuantityType:quantitySamplePredicate:options:completionHandler:",
217 ) orelse return BridgeError.NativeCallFailed;
218 const allocated = objc.msgSendId(HKStatisticsQuery, sel_alloc) orelse
219 return BridgeError.NativeCallFailed;
220
221 const InitFn = *const fn (Id, objc.SEL, Id, Id, c_ulong, *anyopaque) callconv(.c) Id;
222 const initFn: InitFn = @ptrCast(&objc.objc_msgSend);
223 const query = initFn(
224 allocated,
225 sel_init,
226 quantity_type,
227 predicate,
228 statistics_option_cumulative_sum,
229 @ptrCast(&stats_blocks[ticket.index]),
230 ) orelse return BridgeError.NativeCallFailed;
231
232 const sel_execute = objc.sel_registerName("executeQuery:") orelse
233 return BridgeError.NativeCallFailed;
234 objc.msgSendVoid1(store, sel_execute, query);
235 }
236};
237
238/// The lazily-created `HKHealthStore`, or the reason there is none.
239///
240/// `+[HKHealthStore isHealthDataAvailable]` is Swift's own guard, and it is
241/// false on iPad and on any simulator that is not an iPhone. Creating the
242/// store anyway would give every later call a store that answers nothing.
243var store_instance: Id = null;
244
245fn healthStore() !Id {
246 if (store_instance) |existing| return existing;
247
248 const HKHealthStore = objc.objc_getClass("HKHealthStore") orelse {
249 std.log.warn(
250 "health: HKHealthStore is not in this process; the app does not link HealthKit",
251 .{},
252 );
253 return BridgeError.PlatformNotSupported;
254 };
255
256 const sel_available = objc.sel_registerName("isHealthDataAvailable") orelse
257 return BridgeError.NativeCallFailed;
258 if (!objc.msgSendBool(HKHealthStore, sel_available)) {
259 std.log.warn("health refused: this device has no health data store", .{});
260 return BridgeError.PlatformNotSupported;
261 }
262
263 // +1 and kept for the life of the process, matching the single store Swift
264 // builds once in `Coordinator.init`.
265 store_instance = objc.allocInit(HKHealthStore) catch return BridgeError.NativeCallFailed;
266 return store_instance;
267}
268
269/// The `types` array, defaulting to empty.
270///
271/// Swift's `body["types"] as? [String] ?? []` accepts a missing array and a
272/// mistyped one alike. A non-array is refused here rather than silently read
273/// as "no types", because the two produce different authorization sheets and
274/// the page cannot tell which it got.
275fn readTypeNames(payload: std.json.Value) !?std.json.Array {
276 const object = switch (payload) {
277 .object => |o| o,
278 else => return BridgeError.InvalidJSON,
279 };
280 const field = object.get("types") orelse return null;
281 return switch (field) {
282 .array => |a| a,
283 .null => null,
284 else => BridgeError.InvalidParameter,
285 };
286}
287
288/// Whether the page named `name` among the types it wants.
289///
290/// Null is an absent or explicitly null `types`, which matches nothing — the
291/// empty-set case Swift's `?? []` produces. Spelled as an optional rather than
292/// an empty `std.json.Array`, because an `Array` this file did not build has
293/// no allocator behind it and nothing here should be inspecting the fields of
294/// one it invented.
295fn hasType(requested: ?std.json.Array, name: []const u8) bool {
296 const items = requested orelse return false;
297 for (items.items) |item| {
298 switch (item) {
299 .string => |s| if (std.mem.eql(u8, s, name)) return true,
300 else => {},
301 }
302 }
303 return false;
304}
305
306/// `NSSet` of the types the app may write.
307///
308/// Always contains the workout type and, when HealthKit offers it, the workout
309/// route series — Swift seeds both unconditionally, before looking at what the
310/// page asked for.
311fn shareTypeSet(allocator: std.mem.Allocator, requested: ?std.json.Array) !Id {
312 var members: std.ArrayListUnmanaged(Id) = .empty;
313 defer members.deinit(allocator);
314
315 if (try workoutType()) |workout| try members.append(allocator, workout);
316 if (try workoutRouteType()) |route| try members.append(allocator, route);
317
318 // Swift adds these two to *both* sets when named.
319 if (hasType(requested, "activeEnergy")) {
320 if (quantityType("HKQuantityTypeIdentifierActiveEnergyBurned")) |t| {
321 try members.append(allocator, t);
322 } else |_| {}
323 }
324 if (hasType(requested, "distance")) {
325 if (quantityType("HKQuantityTypeIdentifierDistanceWalkingRunning")) |t| {
326 try members.append(allocator, t);
327 } else |_| {}
328 }
329
330 return setWith(members.items);
331}
332
333/// `NSSet` of the types the app may read.
334fn readTypeSet(allocator: std.mem.Allocator, requested: ?std.json.Array) !Id {
335 var members: std.ArrayListUnmanaged(Id) = .empty;
336 defer members.deinit(allocator);
337
338 inline for (health_types) |entry| {
339 if (hasType(requested, entry.name)) {
340 if (quantityType(entry.identifier_symbol)) |t| {
341 try members.append(allocator, t);
342 } else |_| {}
343 }
344 }
345 if (hasType(requested, "workouts")) {
346 if (try workoutType()) |workout| try members.append(allocator, workout);
347 }
348
349 return setWith(members.items);
350}
351
352fn setWith(members: []const Id) !Id {
353 const NSSet = objc.objc_getClass("NSSet") orelse return BridgeError.NativeCallFailed;
354 if (members.len == 0) {
355 const sel = objc.sel_registerName("set") orelse return BridgeError.NativeCallFailed;
356 return objc.msgSendId(NSSet, sel) orelse BridgeError.NativeCallFailed;
357 }
358 const sel = objc.sel_registerName("setWithObjects:count:") orelse
359 return BridgeError.NativeCallFailed;
360 const SetFn = *const fn (objc.Class, objc.SEL, [*]const Id, c_ulong) callconv(.c) Id;
361 const setWithObjects: SetFn = @ptrCast(&objc.objc_msgSend);
362 return setWithObjects(NSSet, sel, members.ptr, members.len) orelse BridgeError.NativeCallFailed;
363}
364
365fn workoutType() !?Id {
366 const HKObjectType = objc.objc_getClass("HKObjectType") orelse return null;
367 const sel = objc.sel_registerName("workoutType") orelse return null;
368 return objc.msgSendId(HKObjectType, sel);
369}
370
371fn workoutRouteType() !?Id {
372 const HKSeriesType = objc.objc_getClass("HKSeriesType") orelse return null;
373 const sel = objc.sel_registerName("workoutRouteType") orelse return null;
374 return objc.msgSendId(HKSeriesType, sel);
375}
376
377/// `+[HKQuantityType quantityTypeForIdentifier:]` for a `dlsym`'d identifier.
378fn quantityType(identifier_symbol: [*:0]const u8) !Id {
379 const identifier = try healthConstant(identifier_symbol);
380
381 const HKQuantityType = objc.objc_getClass("HKQuantityType") orelse
382 return BridgeError.PlatformNotSupported;
383 const sel = objc.sel_registerName("quantityTypeForIdentifier:") orelse
384 return BridgeError.NativeCallFailed;
385 return objc.msgSendId1(HKQuantityType, sel, identifier) orelse {
386 std.log.warn("health: HealthKit does not know {s}", .{std.mem.span(identifier_symbol)});
387 return BridgeError.InvalidParameter;
388 };
389}
390
391/// An `extern NSString * const` from HealthKit, read through its symbol.
392///
393/// Every `HKQuantityTypeIdentifier` happens to equal its own name today. That
394/// is not documented and not API, and a hand-spelled identifier that stops
395/// matching would make `quantityTypeForIdentifier:` answer nil — a refusal
396/// blamed on the page's `type` rather than on this file.
397fn healthConstant(symbol: [*:0]const u8) !Id {
398 const found = dlsym(RTLD_DEFAULT, symbol) orelse {
399 std.log.warn("health: {s} is not in this process", .{std.mem.span(symbol)});
400 return BridgeError.PlatformNotSupported;
401 };
402 const cell: *const Id = @ptrCast(@alignCast(found));
403 return cell.* orelse BridgeError.PlatformNotSupported;
404}
405
406fn unitFromString(unit: [*:0]const u8) !Id {
407 const HKUnit = objc.objc_getClass("HKUnit") orelse return BridgeError.PlatformNotSupported;
408 const sel = objc.sel_registerName("unitFromString:") orelse return BridgeError.NativeCallFailed;
409
410 const NSString = objc.objc_getClass("NSString") orelse return BridgeError.NativeCallFailed;
411 const sel_string = objc.sel_registerName("stringWithUTF8String:") orelse
412 return BridgeError.NativeCallFailed;
413 const ns_unit = objc.msgSendId1(NSString, sel_string, unit) orelse
414 return BridgeError.NativeCallFailed;
415
416 return objc.msgSendId1(HKUnit, sel, ns_unit) orelse BridgeError.NativeCallFailed;
417}
418
419/// `+[HKQuery predicateForSamplesWithStartDate:endDate:options:]`.
420fn samplePredicate(start: f64, end: f64) !Id {
421 const HKQuery = objc.objc_getClass("HKQuery") orelse return BridgeError.PlatformNotSupported;
422 const sel = objc.sel_registerName("predicateForSamplesWithStartDate:endDate:options:") orelse
423 return BridgeError.NativeCallFailed;
424
425 const start_date = try dateFromInterval(start);
426 const end_date = try dateFromInterval(end);
427
428 const PredicateFn = *const fn (objc.Class, objc.SEL, Id, Id, c_ulong) callconv(.c) Id;
429 const predicateFn: PredicateFn = @ptrCast(&objc.objc_msgSend);
430 return predicateFn(
431 HKQuery,
432 sel,
433 start_date,
434 end_date,
435 query_option_strict_start_date,
436 ) orelse BridgeError.NativeCallFailed;
437}
438
439fn dateFromInterval(interval: f64) !Id {
440 const NSDate = objc.objc_getClass("NSDate") orelse return BridgeError.NativeCallFailed;
441 const sel = objc.sel_registerName("dateWithTimeIntervalSince1970:") orelse
442 return BridgeError.NativeCallFailed;
443 const DateFn = *const fn (objc.Class, objc.SEL, f64) callconv(.c) Id;
444 const dateFn: DateFn = @ptrCast(&objc.objc_msgSend);
445 return dateFn(NSDate, sel, interval) orelse BridgeError.NativeCallFailed;
446}
447
448fn nowInterval() f64 {
449 const NSDate = objc.objc_getClass("NSDate") orelse return 0;
450 const sel_date = objc.sel_registerName("date") orelse return 0;
451 const now = objc.msgSendId(NSDate, sel_date) orelse return 0;
452 const sel_interval = objc.sel_registerName("timeIntervalSince1970") orelse return 0;
453 const IntervalFn = *const fn (Id, objc.SEL) callconv(.c) f64;
454 const intervalFn: IntervalFn = @ptrCast(&objc.objc_msgSend);
455 return intervalFn(now, sel_interval);
456}
457
458const DataRequest = struct {
459 type: HealthType,
460 /// Seconds since 1970, already converted from the page's milliseconds.
461 start: f64,
462 end: f64,
463};
464
465/// `type` is required; the dates are optional and in **milliseconds**.
466///
467/// Swift divides both by 1000, so the page's contract is milliseconds and the
468/// conversion is part of the wire format rather than an implementation detail.
469/// An absent window is the last seven days, which is Swift's
470/// `Calendar.current.date(byAdding: .day, value: -7, to: Date())` — computed
471/// here as a flat 604800 seconds, which differs from a calendar subtraction
472/// only across a daylight-saving boundary, and only by an hour.
473fn readDataRequest(payload: std.json.Value) !DataRequest {
474 const object = switch (payload) {
475 .object => |o| o,
476 else => return BridgeError.InvalidJSON,
477 };
478
479 const type_value = object.get("type") orelse return BridgeError.MissingData;
480 const type_name = switch (type_value) {
481 .string => |s| s,
482 else => return BridgeError.InvalidParameter,
483 };
484 const health_type = healthTypeFor(type_name) orelse {
485 // Swift's `default: rejectCallback("Unknown health data type")`.
486 std.log.warn("getHealthData refused: unknown type '{s}'", .{type_name});
487 return BridgeError.InvalidParameter;
488 };
489
490 const now = nowInterval();
491 const start = try optionalMilliseconds(object, "startDate") orelse (now - default_window_seconds);
492 const end = try optionalMilliseconds(object, "endDate") orelse now;
493
494 return .{ .type = health_type, .start = start, .end = end };
495}
496
497/// A millisecond timestamp as seconds, or null when the field is absent.
498fn optionalMilliseconds(object: std.json.ObjectMap, key: []const u8) !?f64 {
499 const value = object.get(key) orelse return null;
500 return switch (value) {
501 .float => |f| f / 1000.0,
502 .integer => |i| @as(f64, @floatFromInt(i)) / 1000.0,
503 .null => null,
504 // Swift's `as? Double` turns a string into nil and silently uses the
505 // default window. Refused here: a page that sent a date meant to
506 // constrain the query, and answering over a different window is a
507 // wrong number rather than a missing one.
508 else => BridgeError.InvalidParameter,
509 };
510}
511
512// ---------------------------------------------------------------------------
513// The two completions
514// ---------------------------------------------------------------------------
515
516/// What a slot's block needs that the block itself cannot carry.
517const PendingCall = struct {
518 ticket: ios_async.Ticket,
519 /// The `HKUnit` the statistics reply reports in, or null for the
520 /// authorization call which reports none.
521 unit: Id,
522};
523
524var pending_calls: [ios_async.max_in_flight]?PendingCall = @splat(null);
525var pending_mutex: compat_mutex.Mutex = .{};
526
527fn publishCall(ticket: ios_async.Ticket, unit: Id) void {
528 pending_mutex.lock();
529 defer pending_mutex.unlock();
530 pending_calls[ticket.index] = .{ .ticket = ticket, .unit = unit };
531}
532
533/// Read and clear, so a second fire is a no-op rather than a second reply.
534fn takeCall(index: u5) ?PendingCall {
535 pending_mutex.lock();
536 defer pending_mutex.unlock();
537 const call = pending_calls[index];
538 pending_calls[index] = null;
539 return call;
540}
541
542const BlockDescriptor = extern struct {
543 reserved: c_ulong = 0,
544 size: c_ulong,
545};
546
547/// `void (^)(BOOL, NSError *)` for authorization, and
548/// `void (^)(HKStatisticsQuery *, HKStatistics *, NSError *)` for the query.
549/// Same layout, different invokes.
550const HealthBlock = extern struct {
551 isa: ?*anyopaque,
552 flags: c_int,
553 reserved: c_int = 0,
554 invoke: *const anyopaque,
555 descriptor: *const BlockDescriptor,
556};
557
558/// 1 << 28 — a global block is never copied.
559const BLOCK_IS_GLOBAL: c_int = 1 << 28;
560
561const health_block_descriptor = BlockDescriptor{ .size = @sizeOf(HealthBlock) };
562
563extern var _NSConcreteGlobalBlock: anyopaque;
564
565fn makeAuthInvoke(comptime index: u5) *const anyopaque {
566 const S = struct {
567 fn invoke(_: *const HealthBlock, success: bool, err: Id) callconv(.c) void {
568 authorizationAnswered(index, success, err);
569 }
570 };
571 return @ptrCast(&S.invoke);
572}
573
574fn makeStatsInvoke(comptime index: u5) *const anyopaque {
575 const S = struct {
576 fn invoke(_: *const HealthBlock, _: Id, result: Id, err: Id) callconv(.c) void {
577 statisticsAnswered(index, result, err);
578 }
579 };
580 return @ptrCast(&S.invoke);
581}
582
583fn makeBlocks(comptime maker: fn (comptime u5) *const anyopaque) [ios_async.max_in_flight]HealthBlock {
584 var out: [ios_async.max_in_flight]HealthBlock = undefined;
585 for (&out, 0..) |*b, i| {
586 b.* = .{
587 .isa = &_NSConcreteGlobalBlock,
588 .flags = BLOCK_IS_GLOBAL,
589 .invoke = maker(@intCast(i)),
590 .descriptor = &health_block_descriptor,
591 };
592 }
593 return out;
594}
595
596var auth_blocks: [ios_async.max_in_flight]HealthBlock =
597 if (is_darwin) makeBlocks(makeAuthInvoke) else undefined;
598var stats_blocks: [ios_async.max_in_flight]HealthBlock =
599 if (is_darwin) makeBlocks(makeStatsInvoke) else undefined;
600
601fn authorizationAnswered(index: u5, success: bool, err: Id) void {
602 if (!is_darwin) return;
603
604 const call = takeCall(index) orelse {
605 std.log.warn(
606 "requestHealthAuthorization answered for slot {d} with no call recorded; ignored",
607 .{index},
608 );
609 return;
610 };
611
612 if (success) {
613 ios_async.deliverJson(call.ticket, authorized_reply);
614 return;
615 }
616 logNSError(A.request_health_authorization, err);
617 ios_async.deliverErrorCode(call.ticket, BridgeError.PermissionDenied);
618}
619
620fn statisticsAnswered(index: u5, result: Id, err: Id) void {
621 if (!is_darwin) return;
622
623 const call = takeCall(index) orelse {
624 std.log.warn("getHealthData answered for slot {d} with no call recorded; ignored", .{index});
625 return;
626 };
627
628 if (err != null) {
629 logNSError(A.get_health_data, err);
630 ios_async.deliverErrorCode(call.ticket, BridgeError.NativeCallFailed);
631 return;
632 }
633
634 const allocator = std.heap.c_allocator;
635 const json = shapeStatistics(allocator, result, call.unit) catch |shape_err| {
636 std.log.warn("getHealthData: could not shape the reply: {}", .{shape_err});
637 ios_async.deliverError(call.ticket);
638 return;
639 };
640 defer allocator.free(json);
641
642 ios_async.deliverJson(call.ticket, json);
643}
644
645/// `{"value":…,"unit":…}`.
646///
647/// Swift's `result?.sumQuantity()?.doubleValue(for: qUnit) ?? 0` — a window
648/// with no samples is zero, not an error, and that is carried across. The unit
649/// string comes from `HKUnit.unitString`, so the reply reports the unit
650/// HealthKit actually used rather than the one this file asked for.
651fn shapeStatistics(allocator: std.mem.Allocator, result: Id, unit: Id) ![]u8 {
652 var value: f64 = 0;
653
654 if (result) |statistics| {
655 const sel_sum = objc.sel_registerName("sumQuantity") orelse return error.SelectorNotFound;
656 if (objc.msgSendId(statistics, sel_sum)) |quantity| {
657 const sel_double = objc.sel_registerName("doubleValueForUnit:") orelse
658 return error.SelectorNotFound;
659 const DoubleFn = *const fn (Id, objc.SEL, Id) callconv(.c) f64;
660 const doubleFor: DoubleFn = @ptrCast(&objc.objc_msgSend);
661 value = doubleFor(quantity, sel_double, unit);
662 }
663 }
664
665 var out: std.ArrayListUnmanaged(u8) = .empty;
666 errdefer out.deinit(allocator);
667
668 try out.appendSlice(allocator, "{\"value\":");
669 if (std.math.isNan(value) or std.math.isInf(value)) {
670 // Not JSON, and a bare `nan` in the source `evaluateJavaScript:` parses
671 // loses the whole reply rather than one field.
672 try out.append(allocator, '0');
673 } else {
674 var buf: [64]u8 = undefined;
675 try out.appendSlice(allocator, try std.fmt.bufPrint(&buf, "{d}", .{value}));
676 }
677 try out.appendSlice(allocator, ",\"unit\":");
678 try out.append(allocator, '"');
679 try bridge_error.appendJsonEscaped(allocator, &out, unitString(unit) orelse "");
680 try out.append(allocator, '"');
681 try out.append(allocator, '}');
682
683 return out.toOwnedSlice(allocator);
684}
685
686fn unitString(unit: Id) ?[]const u8 {
687 const sel = objc.sel_registerName("unitString") orelse return null;
688 const ns = objc.msgSendId(unit, sel) orelse return null;
689 const utf8 = objc.getNSStringUTF8(ns) orelse return null;
690 return std.mem.span(utf8);
691}
692
693fn logNSError(action: []const u8, err: Id) void {
694 const ns_error = err orelse return;
695 const sel = objc.sel_registerName("localizedDescription") orelse return;
696 const ns_description = objc.msgSendId(ns_error, sel) orelse return;
697 const utf8 = objc.getNSStringUTF8(ns_description) orelse return;
698 std.log.warn("{s}: {s}", .{ action, std.mem.span(utf8) });
699}
700
701extern "c" fn dlsym(handle: ?*anyopaque, symbol: [*:0]const u8) ?*anyopaque;
702
703/// `RTLD_DEFAULT` — search every image already loaded into the process.
704const RTLD_DEFAULT: ?*anyopaque = @ptrFromInt(@as(usize, @bitCast(@as(isize, -2))));
705
706const testing = std.testing;
707
708test "the action names match the Swift case labels exactly" {
709 try testing.expectEqualStrings("requestHealthAuthorization", A.request_health_authorization);
710 try testing.expectEqualStrings("getHealthData", A.get_health_data);
711}
712
713test "saveHealthWorkout stays with the shim, and is neither declared nor routed" {
714 // The split is safe only because nothing flows between the three actions —
715 // the HKHealthStore is a stateless handle and authorization lives in the
716 // system database. `UnknownAction` is the one return `ios_dispatch.route`
717 // turns into a hand-off; anything else would steal the action from the arm
718 // that answers it today.
719 var bridge = HealthBridge.init(testing.allocator);
720 defer bridge.deinit();
721
722 try testing.expectEqual(@as(usize, 1), deliberately_unserved.len);
723 for (deliberately_unserved) |name| {
724 try testing.expectError(BridgeError.UnknownAction, bridge.handleMessage(name, "{}"));
725 for (capability_actions) |decl| {
726 try testing.expect(!std.mem.eql(u8, decl.name, name));
727 }
728 }
729}
730
731test "the four data types are the ones the Swift switch recognises" {
732 // The names are the page's vocabulary and the units are the reply's, so
733 // both halves are wire contract. A fifth entry here would answer an action
734 // Swift rejects as "Unknown health data type".
735 try testing.expectEqual(@as(usize, 4), health_types.len);
736 try testing.expectEqualStrings("count", std.mem.span(healthTypeFor("steps").?.unit));
737 try testing.expectEqualStrings("count/min", std.mem.span(healthTypeFor("heartRate").?.unit));
738 try testing.expectEqualStrings("kcal", std.mem.span(healthTypeFor("activeEnergy").?.unit));
739 try testing.expectEqualStrings("m", std.mem.span(healthTypeFor("distance").?.unit));
740 try testing.expect(healthTypeFor("sleep") == null);
741 try testing.expect(healthTypeFor("") == null);
742}
743
744test "the HealthKit option bits are the SDK's values, not a guess" {
745 // The first version of this test asserted `4` — and passed, because it
746 // pinned the same guess the code made. `4` is
747 // HKStatisticsOptionDiscreteMin; CumulativeSum is `1 << 4`. A constant
748 // test is only worth having when the value came from somewhere other than
749 // the code it is checking, so these two cite their headers:
750 // HKStatistics.h:42 and HKQuery.h:50.
751 try testing.expectEqual(@as(c_ulong, 16), statistics_option_cumulative_sum);
752 try testing.expectEqual(@as(c_ulong, 1), query_option_strict_start_date);
753
754 // And it is not any of the neighbouring options, which are the values a
755 // slip would land on.
756 const discrete_average: c_ulong = 1 << 1;
757 const discrete_min: c_ulong = 1 << 2;
758 const discrete_max: c_ulong = 1 << 3;
759 try testing.expect(statistics_option_cumulative_sum != discrete_average);
760 try testing.expect(statistics_option_cumulative_sum != discrete_min);
761 try testing.expect(statistics_option_cumulative_sum != discrete_max);
762}
763
764test "an unknown or missing type is refused rather than defaulted" {
765 if (!is_darwin) return error.SkipZigTest;
766
767 try expectDataRequestRefused("{}", BridgeError.MissingData);
768 try expectDataRequestRefused("{\"type\":7}", BridgeError.InvalidParameter);
769 try expectDataRequestRefused("{\"type\":\"sleep\"}", BridgeError.InvalidParameter);
770 try expectDataRequestRefused("[]", BridgeError.InvalidJSON);
771}
772
773fn expectDataRequestRefused(comptime json: []const u8, expected: anyerror) !void {
774 const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
775 defer parsed.deinit();
776 try testing.expectError(expected, readDataRequest(parsed.value));
777}
778
779test "the page's dates are milliseconds, and a mistyped one is refused" {
780 // Swift divides both by 1000, so milliseconds are the contract rather than
781 // an implementation detail. `as? Double` turns a string into nil and
782 // silently queries the default window instead — a wrong number rather than
783 // a missing one, which is why this refuses.
784 const parsed = try std.json.parseFromSlice(
785 std.json.Value,
786 testing.allocator,
787 "{\"startDate\":1600000000000,\"endDate\":1600000060000.0,\"bad\":\"x\"}",
788 .{},
789 );
790 defer parsed.deinit();
791 const object = parsed.value.object;
792
793 try testing.expectEqual(@as(f64, 1600000000), (try optionalMilliseconds(object, "startDate")).?);
794 try testing.expectEqual(@as(f64, 1600000060), (try optionalMilliseconds(object, "endDate")).?);
795 try testing.expect((try optionalMilliseconds(object, "absent")) == null);
796 try testing.expectError(BridgeError.InvalidParameter, optionalMilliseconds(object, "bad"));
797}
798
799test "the default window is Swift's seven days" {
800 try testing.expectEqual(@as(f64, 604800), default_window_seconds);
801}
802
803test "an absent types list matches nothing, and is not read as an array" {
804 // The empty-set case Swift's `?? []` produces. Spelled as an optional
805 // rather than an invented `std.json.Array`, because a value this file did
806 // not build has no allocator behind it and nothing should be inspecting
807 // its fields.
808 try testing.expect(!hasType(null, "steps"));
809
810 const parsed = try std.json.parseFromSlice(
811 std.json.Value,
812 testing.allocator,
813 "{\"types\":[\"steps\",\"workouts\",3]}",
814 .{},
815 );
816 defer parsed.deinit();
817 const names = (try readTypeNames(parsed.value)).?;
818 try testing.expect(hasType(names, "steps"));
819 try testing.expect(hasType(names, "workouts"));
820 // A non-string element is skipped rather than refused, matching `as?
821 // [String]`'s element-wise behaviour.
822 try testing.expect(!hasType(names, "heartRate"));
823
824 const absent = try std.json.parseFromSlice(std.json.Value, testing.allocator, "{}", .{});
825 defer absent.deinit();
826 try testing.expect((try readTypeNames(absent.value)) == null);
827
828 const wrong = try std.json.parseFromSlice(std.json.Value, testing.allocator, "{\"types\":\"steps\"}", .{});
829 defer wrong.deinit();
830 try testing.expectError(BridgeError.InvalidParameter, readTypeNames(wrong.value));
831}
832
833test "every declared action dispatches to something" {
834 var bridge = HealthBridge.init(testing.allocator);
835 defer bridge.deinit();
836
837 for (capability_actions) |decl| {
838 bridge.handleMessage(decl.name, "{}") catch |err| {
839 try testing.expect(err != BridgeError.UnknownAction);
840 continue;
841 };
842 }
843 try testing.expectError(BridgeError.UnknownAction, bridge.handleMessage("getDeviceInfo", "{}"));
844}
845
846test "both block families are global, distinct per slot, and distinct from each other" {
847 if (!is_darwin) return error.SkipZigTest;
848
849 for (&auth_blocks, &stats_blocks) |*auth, *stats| {
850 try testing.expectEqual(&_NSConcreteGlobalBlock, auth.isa);
851 try testing.expectEqual(&_NSConcreteGlobalBlock, stats.isa);
852 try testing.expectEqual(BLOCK_IS_GLOBAL, auth.flags);
853 try testing.expectEqual(BLOCK_IS_GLOBAL, stats.flags);
854 // The two families take different argument counts — (BOOL, NSError*)
855 // and (query, statistics, NSError*) — so sharing an invoke would read
856 // the error object out of the wrong register.
857 try testing.expect(auth.invoke != stats.invoke);
858 }
859 try testing.expect(auth_blocks[0].invoke != auth_blocks[1].invoke);
860 try testing.expect(stats_blocks[0].invoke != stats_blocks[1].invoke);
861}
862
863test "a completion for a slot with no recorded call is ignored" {
864 if (!is_darwin) return error.SkipZigTest;
865
866 pending_mutex.lock();
867 for (&pending_calls) |*entry| entry.* = null;
868 pending_mutex.unlock();
869
870 authorizationAnswered(0, true, null);
871 statisticsAnswered(0, null, null);
872}
873
874test "a window with no samples is zero, not an error" {
875 // Swift's `?? 0`. A page asking for yesterday's steps on a device that
876 // recorded none gets 0, and telling it the query failed would be a
877 // different answer.
878 if (!is_darwin) return error.SkipZigTest;
879
880 const json = try shapeStatistics(testing.allocator, null, null);
881 defer testing.allocator.free(json);
882 try testing.expectEqualStrings("{\"value\":0,\"unit\":\"\"}", json);
883}
packages/zig/src/ios_config.zigmodified+2-0
Changes to packages/zig/src/ios_config.zig
@@ -416,6 +416,7 @@ pub fn gateFor(action: []const u8) ?Feature {
416416 .{ "getCalendarEvents", .calendar },
417417 .{ "getContacts", .contacts },
418418 .{ "getCurrentPosition", .geolocation },
419 .{ "getHealthData", .health_kit },
419420 .{ "getPendingNotifications", .local_notifications },
420421 .{ "haptic", .haptics },
421422 .{ "lockOrientation", .orientation_lock },
@@ -424,6 +425,7 @@ pub fn gateFor(action: []const u8) ?Feature {
424425 .{ "pickContact", .contacts },
425426 .{ "pickFile", .file_picker },
426427 .{ "pickImage", .camera },
428 .{ "requestHealthAuthorization", .health_kit },
427429 .{ "recognizeText", .ml_kit },
428430 .{ "registerDeepLinkHandler", .deep_links },
429431 .{ "saveFile", .file_download },
packages/zig/src/ios_dispatch.zigmodified+2-0
Changes to packages/zig/src/ios_dispatch.zig
@@ -38,6 +38,7 @@ const bridge_mobile_siri = ;
3838const bridge_mobile_pdf = ;
3939const bridge_mobile_bluetooth = ;
4040const bridge_mobile_audiorec = ;
41const bridge_mobile_health = ;
4142// `bridge_mobile_speech.zig` is deliberately NOT in the chain. Its
4243// capability_actions table is empty — the two speech actions were researched
4344// in full and left with the Swift shim — so a module here would iterate,
@@ -285,6 +286,7 @@ const mobile_bridges = .{
285286 bridge_mobile_pdf.PdfBridge,
286287 bridge_mobile_bluetooth.BluetoothBridge,
287288 bridge_mobile_audiorec.AudioRecordingBridge,
289 bridge_mobile_health.HealthBridge,
288290};
289291
290292/// 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
@@ -55,6 +55,7 @@ const zig_sources = [_][]const u8{
5555 @embedFile("src/bridge_mobile_pdf.zig"),
5656 @embedFile("src/bridge_mobile_bluetooth.zig"),
5757 @embedFile("src/bridge_mobile_audiorec.zig"),
58 @embedFile("src/bridge_mobile_health.zig"),
5859 @embedFile("src/bridge_mobile_speech.zig"),
5960};
6061
@@ -99,7 +100,7 @@ const dispatch_end = "func webView(";
99100/// 48 after calendar and contacts. Speech was researched in full and left
100101/// with the shim: neither action replies, so unlike the earlier deferrals
101102/// there is no promise to strand and falling through costs the page nothing.
102const max_not_yet_migrated: usize = 31;
103const max_not_yet_migrated: usize = 29;
103104
104105fn dispatcherRegion() []const u8 {
105106 const begin = std.mem.indexOf(u8, swift_spec, dispatch_begin) orelse return "";