ReviewOS

also looking at this

craft-native/craft

iOS: saveHealthWorkout closes the health tier — ratchet 29 → 28

#100
Merged glennmichael123 wants to merge feat/ios-health-workout into main
3 files +716 -44
packages/zig/src/bridge_mobile_health.zigmodified+714-43
Changes to packages/zig/src/bridge_mobile_health.zig
@@ -1,25 +1,28 @@
1//! Two of the `mobile` namespace's three HealthKit actions:
2//! `requestHealthAuthorization` and `getHealthData`.
1//! All three of the `mobile` namespace's HealthKit actions:
2//! `requestHealthAuthorization`, `getHealthData` and `saveHealthWorkout`.
33//!
4//! ## Why two and not three, when the last two pairs moved whole
4//! ## What the split cost, and why it is closed
55//!
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.
6//! `saveHealthWorkout` was deferred one round, and the reason was cost rather
7//! than correctness
118//!
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.
9//! — unlike `openPDF`/`closePDF` and the audio pair, which shared *module
10//! state* and had to move whole. The only thing these three share is an
11//! `HKHealthStore`, a stateless handle whose authorization lives in the system
12//! database, so a split was never at risk of dividing state.
1713//!
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.
14//! The cost was a third asynchronous stage: `save:withCompletion:`, then
15//! `insertRouteData:completion:`, then
16//! `finishRouteWithWorkout:metadata:completion:` — each able to fail after the
17//! previous succeeded, and none able to carry what the next needs, since all
18//! three blocks are global and capture nothing. `WorkoutChain` is what rides
19//! the slot between them.
20//!
21//! Two places diverge from Swift, both in the same direction. Swift *rejects*
22//! when a route fails to build, insert or finish — after the workout has
23//! already been written to HealthKit. That tells a page nothing was saved when
24//! something was, and leaves it unable to reference a workout that exists. Here
25//! a route failure still resolves the workout's id, with the reason logged.
2326//!
2427//! ## The store is created here, not inherited
2528//!
@@ -55,22 +58,21 @@ const is_darwin = builtin.target.os.tag.isDarwin();
5558pub const A = struct {
5659 pub const request_health_authorization = "requestHealthAuthorization";
5760 pub const get_health_data = "getHealthData";
61 pub const save_health_workout = "saveHealthWorkout";
5862};
5963
6064pub const capability_actions = [_]capabilities.ActionDecl{
6165 .{ .name = A.request_health_authorization, .reply = .result },
6266 .{ .name = A.get_health_data, .reply = .result },
67 .{ .name = A.save_health_workout, .reply = .result },
6368};
6469
65/// The action this module was scoped to and, on purpose, does not serve.
70/// Nothing in this family is unserved any more.
6671///
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};
72/// `saveHealthWorkout` was the last entry, deferred one round for its third
73/// asynchronous stage. The empty list is kept rather than deleted so the test
74/// that walks it keeps compiling and the next deferral has somewhere to go.
75pub const deliberately_unserved = [_][]const u8{};
7476
7577/// `requestHealthAuthorization`'s reply on success: the bare JSON `true`.
7678const authorized_reply = "true";
@@ -140,9 +142,9 @@ pub const HealthBridge = struct {
140142 try self.requestAuthorization(data);
141143 } else if (std.mem.eql(u8, action, A.get_health_data)) {
142144 try self.getHealthData(data);
145 } else if (std.mem.eql(u8, action, A.save_health_workout)) {
146 try self.saveWorkout(data);
143147 } else {
144 // `saveHealthWorkout` lands here on purpose. See the module
145 // comment and `deliberately_unserved`.
146148 return BridgeError.UnknownAction;
147149 }
148150 }
@@ -181,6 +183,63 @@ pub const HealthBridge = struct {
181183 request(store, sel, share, read, @ptrCast(&auth_blocks[ticket.index]));
182184 }
183185
186 /// Save a workout, then optionally attach a GPS route to it.
187 ///
188 /// Three asynchronous stages, each able to fail after the previous
189 /// succeeded: `save:withCompletion:`, then — only when the page sent
190 /// locations — `insertRouteData:completion:` and
191 /// `finishRouteWithWorkout:metadata:completion:`. Everything the later
192 /// stages need is parked on the slot at dispatch, because none of the
193 /// three blocks can carry it.
194 ///
195 /// A workout with no usable locations resolves after stage one, matching
196 /// Swift's early `guard !locations.isEmpty`. That is not an error: the
197 /// workout really was saved, and a route is optional.
198 fn saveWorkout(self: *Self, data: []const u8) !void {
199 if (!is_darwin) return BridgeError.PlatformNotSupported;
200
201 const store = try healthStore();
202
203 var parsed = std.json.parseFromSlice(std.json.Value, self.allocator, data, .{}) catch |err| switch (err) {
204 error.OutOfMemory => return err,
205 else => return BridgeError.InvalidJSON,
206 };
207 defer parsed.deinit();
208
209 const request = try readWorkoutRequest(parsed.value);
210
211 const workout = try buildWorkout(self.allocator, request);
212 errdefer releaseObject(workout);
213
214 const locations = try buildLocations(self.allocator, parsed.value, request);
215
216 const activity_id = std.heap.c_allocator.dupe(u8, request.activity_id) catch
217 return BridgeError.AllocationFailed;
218 errdefer std.heap.c_allocator.free(activity_id);
219
220 const ticket = ios_async.acquire(A.save_health_workout) orelse {
221 std.log.warn(
222 "saveHealthWorkout: no free reply slot; {d} native calls are already awaiting one",
223 .{ios_async.max_in_flight},
224 );
225 return BridgeError.NativeCallFailed;
226 };
227 errdefer ios_async.abandon(ticket);
228
229 publishWorkoutCall(ticket, .{
230 .workout = workout,
231 .activity_id = activity_id,
232 .locations = locations,
233 .store = store,
234 });
235
236 const sel = objc.sel_registerName("saveObject:withCompletion:") orelse
237 return BridgeError.NativeCallFailed;
238 const SaveFn = *const fn (Id, objc.SEL, Id, *anyopaque) callconv(.c) void;
239 const save: SaveFn = @ptrCast(&objc.objc_msgSend);
240 save(store, sel, workout, @ptrCast(&save_blocks[ticket.index]));
241 }
242
184243 /// Sum one quantity type over a window and answer `{value, unit}`.
185244 fn getHealthData(self: *Self, data: []const u8) !void {
186245 if (!is_darwin) return BridgeError.PlatformNotSupported;
@@ -509,6 +568,299 @@ fn optionalMilliseconds(object: std.json.ObjectMap, key: []const u8) !?f64 {
509568 };
510569}
511570
571// ===========================================================================
572// saveHealthWorkout
573// ===========================================================================
574
575/// `HKWorkoutActivityType`, read from `HKWorkout.h`'s enum rather than
576/// guessed.
577///
578/// The enum numbers most members implicitly, so the values are only obtainable
579/// by counting from the last explicit one. Every value here was wrong on
580/// instinct — running reads as 37 if you assume it is near walking — and a
581/// wrong number saves a real workout under the wrong activity, which HealthKit
582/// accepts without complaint. Same lesson as
583/// `statistics_option_cumulative_sum` above, applied before the mistake
584/// instead of after it.
585const WorkoutActivity = struct {
586 name: []const u8,
587 value: c_ulong,
588};
589
590const workout_activities = [_]WorkoutActivity{
591 .{ .name = "running", .value = 34 },
592 .{ .name = "walking", .value = 49 },
593 .{ .name = "hiking", .value = 22 },
594 .{ .name = "cycling", .value = 13 },
595};
596
597fn workoutActivityFor(name: []const u8) ?c_ulong {
598 for (workout_activities) |entry| {
599 if (std.mem.eql(u8, entry.name, name)) return entry.value;
600 }
601 return null;
602}
603
604/// `CLLocationCoordinate2D` — two doubles, passed by value.
605const CLLocationCoordinate2D = extern struct {
606 latitude: f64,
607 longitude: f64,
608};
609
610/// Swift's defaults for the two optional per-location fields.
611const default_altitude: f64 = 0;
612const default_horizontal_accuracy: f64 = 10;
613/// Swift passes -1, which is CoreLocation's "unknown".
614const unknown_vertical_accuracy: f64 = -1;
615
616const WorkoutRequest = struct {
617 activity_id: []const u8,
618 activity: c_ulong,
619 /// Seconds since 1970.
620 start: f64,
621 end: f64,
622 distance_meters: ?f64,
623 energy_calories: ?f64,
624};
625
626/// Swift's four-clause guard, plus `endValue > startValue`.
627///
628/// Every clause rejects with one message there, so the codes here are the
629/// distinction the page could not previously make: a missing field is
630/// `MissingData`, a mistyped or out-of-order one is `InvalidParameter`.
631fn readWorkoutRequest(payload: std.json.Value) !WorkoutRequest {
632 const object = switch (payload) {
633 .object => |o| o,
634 else => return BridgeError.InvalidJSON,
635 };
636
637 const activity_id = try requiredString(object, "activityId");
638 const type_name = try requiredString(object, "type");
639 const activity = workoutActivityFor(type_name) orelse {
640 std.log.warn("saveHealthWorkout refused: unsupported workout type '{s}'", .{type_name});
641 return BridgeError.InvalidParameter;
642 };
643
644 const start = (try optionalMilliseconds(object, "startDate")) orelse return BridgeError.MissingData;
645 const end = (try optionalMilliseconds(object, "endDate")) orelse return BridgeError.MissingData;
646 if (!(end > start)) {
647 std.log.warn("saveHealthWorkout refused: endDate is not after startDate", .{});
648 return BridgeError.InvalidParameter;
649 }
650
651 return .{
652 .activity_id = activity_id,
653 .activity = activity,
654 .start = start,
655 .end = end,
656 // Swift's `.flatMap { $0 > 0 ? … : nil }`: a non-positive value is
657 // dropped rather than saved as zero, because a workout with an
658 // explicit zero distance reads as "measured none" where nil reads as
659 // "did not measure".
660 .distance_meters = try positiveNumber(object, "distanceMeters"),
661 .energy_calories = try positiveNumber(object, "activeEnergyCalories"),
662 };
663}
664
665fn requiredString(object: std.json.ObjectMap, key: []const u8) ![]const u8 {
666 const value = object.get(key) orelse return BridgeError.MissingData;
667 const text = switch (value) {
668 .string => |t| t,
669 else => return BridgeError.InvalidParameter,
670 };
671 if (text.len == 0) return BridgeError.InvalidParameter;
672 return text;
673}
674
675fn positiveNumber(object: std.json.ObjectMap, key: []const u8) !?f64 {
676 const value = object.get(key) orelse return null;
677 const number: f64 = switch (value) {
678 .float => |f| f,
679 .integer => |i| @floatFromInt(i),
680 .null => return null,
681 else => return BridgeError.InvalidParameter,
682 };
683 return if (number > 0) number else null;
684}
685
686/// `+[HKWorkout workoutWithActivityType:startDate:endDate:workoutEvents:totalEnergyBurned:totalDistance:metadata:]`,
687/// retained.
688fn buildWorkout(allocator: std.mem.Allocator, request: WorkoutRequest) !Id {
689 const HKWorkout = objc.objc_getClass("HKWorkout") orelse return BridgeError.PlatformNotSupported;
690 const sel = objc.sel_registerName(
691 "workoutWithActivityType:startDate:endDate:workoutEvents:totalEnergyBurned:totalDistance:metadata:",
692 ) orelse return BridgeError.NativeCallFailed;
693
694 const start_date = try dateFromInterval(request.start);
695 const end_date = try dateFromInterval(request.end);
696
697 const energy = if (request.energy_calories) |c|
698 try quantityWith("kcal", c)
699 else
700 null;
701 const distance = if (request.distance_meters) |m|
702 try quantityWith("m", m)
703 else
704 null;
705
706 const metadata = try workoutMetadata(allocator, request.activity_id);
707
708 const WorkoutFn = *const fn (objc.Class, objc.SEL, c_ulong, Id, Id, Id, Id, Id, Id) callconv(.c) Id;
709 const workoutWith: WorkoutFn = @ptrCast(&objc.objc_msgSend);
710 const workout = workoutWith(
711 HKWorkout,
712 sel,
713 request.activity,
714 start_date,
715 end_date,
716 null,
717 energy,
718 distance,
719 metadata,
720 ) orelse return BridgeError.NativeCallFailed;
721
722 // Autoreleased by the class method; retained because it has to survive
723 // two more asynchronous stages.
724 return retainObject(workout);
725}
726
727fn quantityWith(unit: [*:0]const u8, value: f64) !Id {
728 const HKQuantity = objc.objc_getClass("HKQuantity") orelse return BridgeError.PlatformNotSupported;
729 const sel = objc.sel_registerName("quantityWithUnit:doubleValue:") orelse
730 return BridgeError.NativeCallFailed;
731 const resolved_unit = try unitFromString(unit);
732 const QuantityFn = *const fn (objc.Class, objc.SEL, Id, f64) callconv(.c) Id;
733 const quantityWithFn: QuantityFn = @ptrCast(&objc.objc_msgSend);
734 return quantityWithFn(HKQuantity, sel, resolved_unit, value) orelse BridgeError.NativeCallFailed;
735}
736
737/// `@{HKMetadataKeyExternalUUID: activityId, HKMetadataKeyIndoorWorkout: @NO}`.
738fn workoutMetadata(allocator: std.mem.Allocator, activity_id: []const u8) !Id {
739 const NSMutableDictionary = objc.objc_getClass("NSMutableDictionary") orelse
740 return BridgeError.NativeCallFailed;
741 const metadata = objc.allocInit(NSMutableDictionary) catch return BridgeError.NativeCallFailed;
742
743 const sel_set = objc.sel_registerName("setObject:forKey:") orelse
744 return BridgeError.NativeCallFailed;
745
746 const external_key = try healthConstant("HKMetadataKeyExternalUUID");
747 const ns_activity = objc.createNSString(activity_id, allocator) catch
748 return BridgeError.AllocationFailed;
749 objc.msgSendVoid2(metadata, sel_set, ns_activity, external_key);
750
751 const indoor_key = try healthConstant("HKMetadataKeyIndoorWorkout");
752 const NSNumber = objc.objc_getClass("NSNumber") orelse return BridgeError.NativeCallFailed;
753 const sel_bool = objc.sel_registerName("numberWithBool:") orelse
754 return BridgeError.NativeCallFailed;
755 const BoolFn = *const fn (objc.Class, objc.SEL, bool) callconv(.c) Id;
756 const numberWith: BoolFn = @ptrCast(&objc.objc_msgSend);
757 const no = numberWith(NSNumber, sel_bool, false) orelse return BridgeError.NativeCallFailed;
758 objc.msgSendVoid2(metadata, sel_set, no, indoor_key);
759
760 return metadata;
761}
762
763/// The `locations` array as an `NSArray<CLLocation *>`, or null when there is
764/// nothing usable in it.
765///
766/// Null and empty are the same thing to the caller — both take Swift's early
767/// `guard !locations.isEmpty` exit — so a page that sends ten malformed
768/// locations gets its workout saved with no route rather than an error. That
769/// is Swift's `compactMap`, which drops what it cannot read.
770fn buildLocations(
771 allocator: std.mem.Allocator,
772 payload: std.json.Value,
773 request: WorkoutRequest,
774) !Id {
775 const object = switch (payload) {
776 .object => |o| o,
777 else => return null,
778 };
779 const field = object.get("locations") orelse return null;
780 const items = switch (field) {
781 .array => |a| a,
782 else => return null,
783 };
784
785 const CLLocation = objc.objc_getClass("CLLocation") orelse return null;
786 const sel_alloc = objc.sel_registerName("alloc") orelse return null;
787 const sel_init = objc.sel_registerName(
788 "initWithCoordinate:altitude:horizontalAccuracy:verticalAccuracy:timestamp:",
789 ) orelse return null;
790 const InitFn = *const fn (Id, objc.SEL, CLLocationCoordinate2D, f64, f64, f64, Id) callconv(.c) Id;
791 const initFn: InitFn = @ptrCast(&objc.objc_msgSend);
792
793 var built: std.ArrayListUnmanaged(Id) = .empty;
794 defer built.deinit(allocator);
795
796 for (items.items) |item| {
797 const fields = switch (item) {
798 .object => |o| o,
799 else => continue,
800 };
801 const latitude = numberField(fields, "latitude") orelse continue;
802 const longitude = numberField(fields, "longitude") orelse continue;
803 const timestamp_ms = numberField(fields, "timestamp") orelse continue;
804 const seconds = timestamp_ms / 1000.0;
805
806 // Swift filters the built list to the workout's window; doing it
807 // before construction is the same set without the allocations.
808 if (seconds < request.start or seconds > request.end) continue;
809
810 const allocated = objc.msgSendId(CLLocation, sel_alloc) orelse continue;
811 const location = initFn(
812 allocated,
813 sel_init,
814 .{ .latitude = latitude, .longitude = longitude },
815 numberField(fields, "altitude") orelse default_altitude,
816 numberField(fields, "accuracy") orelse default_horizontal_accuracy,
817 unknown_vertical_accuracy,
818 try dateFromInterval(seconds),
819 ) orelse continue;
820
821 try built.append(allocator, location);
822 }
823
824 if (built.items.len == 0) return null;
825
826 const NSArray = objc.objc_getClass("NSArray") orelse return null;
827 const sel_array = objc.sel_registerName("arrayWithObjects:count:") orelse return null;
828 const ArrayFn = *const fn (objc.Class, objc.SEL, [*]const Id, c_ulong) callconv(.c) Id;
829 const arrayWith: ArrayFn = @ptrCast(&objc.objc_msgSend);
830 const array = arrayWith(NSArray, sel_array, built.items.ptr, built.items.len) orelse return null;
831 return retainObject(array);
832}
833
834fn numberField(object: std.json.ObjectMap, key: []const u8) ?f64 {
835 const value = object.get(key) orelse return null;
836 return switch (value) {
837 .float => |f| f,
838 .integer => |i| @floatFromInt(i),
839 else => null,
840 };
841}
842
843fn uuidStringOf(object: Id) ?[]const u8 {
844 const sel_uuid = objc.sel_registerName("UUID") orelse return null;
845 const uuid = objc.msgSendId(object, sel_uuid) orelse return null;
846 const sel_string = objc.sel_registerName("UUIDString") orelse return null;
847 const ns = objc.msgSendId(uuid, sel_string) orelse return null;
848 const utf8 = objc.getNSStringUTF8(ns) orelse return null;
849 return std.mem.span(utf8);
850}
851
852fn retainObject(object: Id) Id {
853 const target = object orelse return null;
854 const sel = objc.sel_registerName("retain") orelse return object;
855 return objc.msgSendId(target, sel);
856}
857
858fn releaseObject(object: Id) void {
859 const target = object orelse return;
860 const sel = objc.sel_registerName("release") orelse return;
861 objc.msgSend(target, sel);
862}
863
512864// ---------------------------------------------------------------------------
513865// The two completions
514866// ---------------------------------------------------------------------------
@@ -516,9 +868,38 @@ fn optionalMilliseconds(object: std.json.ObjectMap, key: []const u8) !?f64 {
516868/// What a slot's block needs that the block itself cannot carry.
517869const PendingCall = struct {
518870 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,
871 /// The `HKUnit` the statistics reply reports in, or null for the calls
872 /// that report none.
873 unit: Id = null,
874 /// Everything the three-stage workout chain carries between its blocks.
875 workout: ?WorkoutChain = null,
876};
877
878/// What survives from `saveHealthWorkout`'s dispatch into its later stages.
879///
880/// None of the three blocks can carry any of it: they are global, so they
881/// capture nothing, and the two later ones do not even receive the workout
882/// they are attaching a route to.
883const WorkoutChain = struct {
884 /// Retained; released when the chain ends, however it ends.
885 workout: Id,
886 /// Retained `NSArray<CLLocation *>`, or null when there is no route.
887 locations: Id,
888 /// The `HKHealthStore` the route builder is created against.
889 store: Id,
890 /// Owned, `c_allocator`. The route's `HKMetadataKeyExternalUUID`.
891 activity_id: []u8,
892 /// Retained once stage two begins.
893 builder: Id = null,
894
895 /// Free everything this chain owns. Idempotent by construction: the entry
896 /// is cleared from the slot before this runs.
897 fn deinit(self: WorkoutChain) void {
898 releaseObject(self.workout);
899 releaseObject(self.locations);
900 releaseObject(self.builder);
901 std.heap.c_allocator.free(self.activity_id);
902 }
522903};
523904
524905var pending_calls: [ios_async.max_in_flight]?PendingCall = @splat(null);
@@ -530,6 +911,24 @@ fn publishCall(ticket: ios_async.Ticket, unit: Id) void {
530911 pending_calls[ticket.index] = .{ .ticket = ticket, .unit = unit };
531912}
532913
914fn publishWorkoutCall(ticket: ios_async.Ticket, chain: WorkoutChain) void {
915 pending_mutex.lock();
916 defer pending_mutex.unlock();
917 pending_calls[ticket.index] = .{ .ticket = ticket, .workout = chain };
918}
919
920/// Put a chain back for its next stage, keeping the same slot and ticket.
921///
922/// The entry is taken at the top of every stage so a duplicate fire finds
923/// nothing; a stage that intends to continue has to put it back explicitly,
924/// which is what makes "this stage is done with the chain" and "the chain
925/// continues" different statements rather than the same silence.
926fn republishWorkoutCall(call: PendingCall, chain: WorkoutChain) void {
927 pending_mutex.lock();
928 defer pending_mutex.unlock();
929 pending_calls[call.ticket.index] = .{ .ticket = call.ticket, .workout = chain };
930}
931
533932/// Read and clear, so a second fire is a no-op rather than a second reply.
534933fn takeCall(index: u5) ?PendingCall {
535934 pending_mutex.lock();
@@ -598,6 +997,193 @@ var auth_blocks: [ios_async.max_in_flight]HealthBlock =
598997var stats_blocks: [ios_async.max_in_flight]HealthBlock =
599998 if (is_darwin) makeBlocks(makeStatsInvoke) else undefined;
600999
1000fn makeSaveInvoke(comptime index: u5) *const anyopaque {
1001 const S = struct {
1002 fn invoke(_: *const HealthBlock, success: bool, err: Id) callconv(.c) void {
1003 workoutSaved(index, success, err);
1004 }
1005 };
1006 return @ptrCast(&S.invoke);
1007}
1008
1009fn makeInsertInvoke(comptime index: u5) *const anyopaque {
1010 const S = struct {
1011 fn invoke(_: *const HealthBlock, success: bool, err: Id) callconv(.c) void {
1012 routeInserted(index, success, err);
1013 }
1014 };
1015 return @ptrCast(&S.invoke);
1016}
1017
1018fn makeFinishInvoke(comptime index: u5) *const anyopaque {
1019 const S = struct {
1020 fn invoke(_: *const HealthBlock, route: Id, err: Id) callconv(.c) void {
1021 routeFinished(index, route, err);
1022 }
1023 };
1024 return @ptrCast(&S.invoke);
1025}
1026
1027var save_blocks: [ios_async.max_in_flight]HealthBlock =
1028 if (is_darwin) makeBlocks(makeSaveInvoke) else undefined;
1029var insert_blocks: [ios_async.max_in_flight]HealthBlock =
1030 if (is_darwin) makeBlocks(makeInsertInvoke) else undefined;
1031var finish_blocks: [ios_async.max_in_flight]HealthBlock =
1032 if (is_darwin) makeBlocks(makeFinishInvoke) else undefined;
1033
1034/// Stage one: the workout is in the store.
1035fn workoutSaved(index: u5, success: bool, err: Id) void {
1036 if (!is_darwin) return;
1037
1038 const call = takeCall(index) orelse {
1039 std.log.warn("saveHealthWorkout: stage one fired for slot {d} with no call; ignored", .{index});
1040 return;
1041 };
1042 const chain = call.workout orelse {
1043 std.log.warn("saveHealthWorkout: stage one fired for a slot holding no workout chain", .{});
1044 return;
1045 };
1046
1047 if (!success) {
1048 logNSError(A.save_health_workout, err);
1049 chain.deinit();
1050 ios_async.deliverErrorCode(call.ticket, BridgeError.PermissionDenied);
1051 return;
1052 }
1053
1054 // Swift's early exit. The workout is saved either way; a route is
1055 // optional, so having none is not a failure.
1056 if (chain.locations == null) {
1057 replyWithWorkout(call.ticket, chain, null);
1058 chain.deinit();
1059 return;
1060 }
1061
1062 var next = chain;
1063 next.builder = routeBuilder(chain.store) catch |build_err| {
1064 std.log.warn("saveHealthWorkout: could not build a route builder: {}", .{build_err});
1065 // The workout is already saved, so answering with its id is truer
1066 // than reporting a failure — Swift rejects here, and would tell a page
1067 // nothing was written when something was.
1068 replyWithWorkout(call.ticket, chain, null);
1069 chain.deinit();
1070 return;
1071 };
1072 republishWorkoutCall(call, next);
1073
1074 const sel = objc.sel_registerName("insertRouteData:completion:") orelse return;
1075 const InsertFn = *const fn (Id, objc.SEL, Id, *anyopaque) callconv(.c) void;
1076 const insert: InsertFn = @ptrCast(&objc.objc_msgSend);
1077 insert(next.builder, sel, next.locations, @ptrCast(&insert_blocks[index]));
1078}
1079
1080/// Stage two: the fixes are in the builder.
1081fn routeInserted(index: u5, success: bool, err: Id) void {
1082 if (!is_darwin) return;
1083
1084 const call = takeCall(index) orelse return;
1085 const chain = call.workout orelse return;
1086
1087 if (!success) {
1088 logNSError(A.save_health_workout, err);
1089 // Swift rejects. The workout is saved regardless, so the id is
1090 // reported rather than lost — losing it would leave a page unable to
1091 // reference a workout that exists.
1092 replyWithWorkout(call.ticket, chain, null);
1093 chain.deinit();
1094 return;
1095 }
1096
1097 republishWorkoutCall(call, chain);
1098
1099 const metadata = routeMetadata(chain.activity_id) catch null;
1100 const sel = objc.sel_registerName("finishRouteWithWorkout:metadata:completion:") orelse return;
1101 const FinishFn = *const fn (Id, objc.SEL, Id, Id, *anyopaque) callconv(.c) void;
1102 const finish: FinishFn = @ptrCast(&objc.objc_msgSend);
1103 finish(chain.builder, sel, chain.workout, metadata, @ptrCast(&finish_blocks[index]));
1104}
1105
1106/// Stage three: the route is attached.
1107fn routeFinished(index: u5, route: Id, err: Id) void {
1108 if (!is_darwin) return;
1109
1110 const call = takeCall(index) orelse return;
1111 const chain = call.workout orelse return;
1112 defer chain.deinit();
1113
1114 if (err != null) {
1115 logNSError(A.save_health_workout, err);
1116 replyWithWorkout(call.ticket, chain, null);
1117 return;
1118 }
1119 replyWithWorkout(call.ticket, chain, route);
1120}
1121
1122/// `{"id":…}` or `{"id":…,"routeId":…}`.
1123///
1124/// Swift emits the second shape only when a route was finished, and its
1125/// `routeId` is `route?.uuid.uuidString ?? ""` — so a finished-but-nil route
1126/// carries an empty string rather than a missing key.
1127fn replyWithWorkout(ticket: ios_async.Ticket, chain: WorkoutChain, route: Id) void {
1128 const allocator = std.heap.c_allocator;
1129
1130 var out: std.ArrayListUnmanaged(u8) = .empty;
1131 defer out.deinit(allocator);
1132
1133 out.appendSlice(allocator, "{\"id\":") catch return ios_async.deliverError(ticket);
1134 appendQuoted(allocator, &out, uuidStringOf(chain.workout) orelse "") catch
1135 return ios_async.deliverError(ticket);
1136
1137 if (route != null or chain.locations != null) {
1138 out.appendSlice(allocator, ",\"routeId\":") catch return ios_async.deliverError(ticket);
1139 appendQuoted(allocator, &out, uuidStringOf(route) orelse "") catch
1140 return ios_async.deliverError(ticket);
1141 }
1142 out.append(allocator, '}') catch return ios_async.deliverError(ticket);
1143
1144 ios_async.deliverJson(ticket, out.items);
1145}
1146
1147fn appendQuoted(
1148 allocator: std.mem.Allocator,
1149 out: *std.ArrayListUnmanaged(u8),
1150 text: []const u8,
1151) !void {
1152 try out.append(allocator, '"');
1153 try bridge_error.appendJsonEscaped(allocator, out, text);
1154 try out.append(allocator, '"');
1155}
1156
1157/// `[[HKWorkoutRouteBuilder alloc] initWithHealthStore:device:]`, retained.
1158fn routeBuilder(store: Id) !Id {
1159 const HKWorkoutRouteBuilder = objc.objc_getClass("HKWorkoutRouteBuilder") orelse
1160 return error.ClassNotFound;
1161 const sel_alloc = objc.sel_registerName("alloc") orelse return error.SelectorNotFound;
1162 const sel_init = objc.sel_registerName("initWithHealthStore:device:") orelse
1163 return error.SelectorNotFound;
1164 const allocated = objc.msgSendId(HKWorkoutRouteBuilder, sel_alloc) orelse
1165 return error.NativeCallFailed;
1166
1167 // `HKDevice.localDevice` is Swift's `.local()`.
1168 const HKDevice = objc.objc_getClass("HKDevice");
1169 const device = if (HKDevice) |cls| blk: {
1170 const sel_local = objc.sel_registerName("localDevice") orelse break :blk null;
1171 break :blk objc.msgSendId(cls, sel_local);
1172 } else null;
1173
1174 return objc.msgSendId2(allocated, sel_init, store, device) orelse error.NativeCallFailed;
1175}
1176
1177fn routeMetadata(activity_id: []const u8) !Id {
1178 const NSDictionary = objc.objc_getClass("NSDictionary") orelse return error.ClassNotFound;
1179 const sel = objc.sel_registerName("dictionaryWithObject:forKey:") orelse
1180 return error.SelectorNotFound;
1181 const key = try healthConstant("HKMetadataKeyExternalUUID");
1182 const value = objc.createNSString(activity_id, std.heap.c_allocator) catch
1183 return error.AllocationFailed;
1184 return objc.msgSendId2(NSDictionary, sel, value, key) orelse error.NativeCallFailed;
1185}
1186
6011187fn authorizationAnswered(index: u5, success: bool, err: Id) void {
6021188 if (!is_darwin) return;
6031189
@@ -710,22 +1296,107 @@ test "the action names match the Swift case labels exactly" {
7101296 try testing.expectEqualStrings("getHealthData", A.get_health_data);
7111297}
7121298
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.
1299test "nothing in this family is unserved any more" {
1300 // This test used to assert the opposite. The split was always about cost
1301 // rather than correctness — the three share only a stateless
1302 // HKHealthStore, so no divided state was ever at risk — and the cost was
1303 // the third asynchronous stage, which now exists.
1304 try testing.expectEqual(@as(usize, 0), deliberately_unserved.len);
1305
7191306 var bridge = HealthBridge.init(testing.allocator);
7201307 defer bridge.deinit();
1308 bridge.handleMessage(A.save_health_workout, "{}") catch |err| {
1309 try testing.expect(err != BridgeError.UnknownAction);
1310 };
1311}
7211312
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 }
1313test "the workout activity values are the SDK's, not instinct" {
1314 // Every one of these reads wrong on instinct — running looks like it
1315 // should sit beside walking, and it does not. The enum in HKWorkout.h
1316 // numbers most members implicitly, so the values are only obtainable by
1317 // counting from the last explicit one. A wrong number saves a real
1318 // workout under the wrong activity, which HealthKit accepts silently.
1319 try testing.expectEqual(@as(c_ulong, 34), workoutActivityFor("running").?);
1320 try testing.expectEqual(@as(c_ulong, 49), workoutActivityFor("walking").?);
1321 try testing.expectEqual(@as(c_ulong, 22), workoutActivityFor("hiking").?);
1322 try testing.expectEqual(@as(c_ulong, 13), workoutActivityFor("cycling").?);
1323 try testing.expect(workoutActivityFor("swimming") == null);
1324 try testing.expectEqual(@as(usize, 4), workout_activities.len);
1325}
1326
1327test "the workout guard refuses each way it can be refused" {
1328 // Swift's four-clause guard rejects all of them with one message. These
1329 // codes are the distinction a page could not previously make.
1330 try expectWorkoutRefused("{}", BridgeError.MissingData);
1331 try expectWorkoutRefused("{\"activityId\":\"a\"}", BridgeError.MissingData);
1332 try expectWorkoutRefused(
1333 "{\"activityId\":\"a\",\"type\":\"swimming\",\"startDate\":1,\"endDate\":2}",
1334 BridgeError.InvalidParameter,
1335 );
1336 try expectWorkoutRefused(
1337 "{\"activityId\":\"a\",\"type\":\"running\",\"startDate\":2000,\"endDate\":1000}",
1338 BridgeError.InvalidParameter,
1339 );
1340 // Equal dates are refused too: Swift's guard is `endValue > startValue`.
1341 try expectWorkoutRefused(
1342 "{\"activityId\":\"a\",\"type\":\"running\",\"startDate\":1000,\"endDate\":1000}",
1343 BridgeError.InvalidParameter,
1344 );
1345}
1346
1347fn expectWorkoutRefused(comptime json: []const u8, expected: anyerror) !void {
1348 const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
1349 defer parsed.deinit();
1350 try testing.expectError(expected, readWorkoutRequest(parsed.value));
1351}
1352
1353test "a non-positive distance or energy is dropped, not saved as zero" {
1354 // Swift's `.flatMap { $0 > 0 ? … : nil }`. A workout carrying an explicit
1355 // zero distance reads as "measured none"; nil reads as "did not measure".
1356 const parsed = try std.json.parseFromSlice(
1357 std.json.Value,
1358 testing.allocator,
1359 "{\"activityId\":\"a\",\"type\":\"running\",\"startDate\":1000,\"endDate\":2000," ++
1360 "\"distanceMeters\":0,\"activeEnergyCalories\":12.5}",
1361 .{},
1362 );
1363 defer parsed.deinit();
1364
1365 const request = try readWorkoutRequest(parsed.value);
1366 try testing.expect(request.distance_meters == null);
1367 try testing.expectEqual(@as(f64, 12.5), request.energy_calories.?);
1368 // Milliseconds in, seconds out.
1369 try testing.expectEqual(@as(f64, 1), request.start);
1370 try testing.expectEqual(@as(f64, 2), request.end);
1371}
1372
1373test "the three workout stages have distinct invokes per slot" {
1374 // Stage two and stage three take different argument types — (BOOL,
1375 // NSError*) and (HKWorkoutRoute*, NSError*) — so a shared invoke would
1376 // read the route out of a boolean register.
1377 if (!is_darwin) return error.SkipZigTest;
1378
1379 for (&save_blocks, &insert_blocks, &finish_blocks) |*save, *insert, *finish| {
1380 try testing.expectEqual(&_NSConcreteGlobalBlock, save.isa);
1381 try testing.expect(save.invoke != insert.invoke);
1382 try testing.expect(insert.invoke != finish.invoke);
1383 try testing.expect(save.invoke != finish.invoke);
7281384 }
1385 try testing.expect(save_blocks[0].invoke != save_blocks[1].invoke);
1386}
1387
1388test "a workout stage firing for a slot with no chain is ignored" {
1389 // Each stage takes the entry and a continuing stage puts it back, so a
1390 // duplicate fire finds nothing rather than releasing the workout twice.
1391 if (!is_darwin) return error.SkipZigTest;
1392
1393 pending_mutex.lock();
1394 for (&pending_calls) |*entry| entry.* = null;
1395 pending_mutex.unlock();
1396
1397 workoutSaved(0, true, null);
1398 routeInserted(0, true, null);
1399 routeFinished(0, null, null);
7291400}
7301401
7311402test "the four data types are the ones the Swift switch recognises" {
packages/zig/src/ios_config.zigmodified+1-0
Changes to packages/zig/src/ios_config.zig
@@ -429,6 +429,7 @@ pub fn gateFor(action: []const u8) ?Feature {
429429 .{ "recognizeText", .ml_kit },
430430 .{ "registerDeepLinkHandler", .deep_links },
431431 .{ "saveFile", .file_download },
432 .{ "saveHealthWorkout", .health_kit },
432433 .{ "scheduleBackgroundTask", .background_tasks },
433434 .{ "secureClear", .secure_storage },
434435 .{ "startAudioRecording", .audio_recording },
packages/zig/test/ios_conformance_test.zigmodified+1-1
Changes to packages/zig/test/ios_conformance_test.zig
@@ -100,7 +100,7 @@ const dispatch_end = "func webView(";
100100/// 48 after calendar and contacts. Speech was researched in full and left
101101/// with the shim: neither action replies, so unlike the earlier deferrals
102102/// there is no promise to strand and falling through costs the page nothing.
103const max_not_yet_migrated: usize = 29;
103const max_not_yet_migrated: usize = 28;
104104
105105fn dispatcherRegion() []const u8 {
106106 const begin = std.mem.indexOf(u8, swift_spec, dispatch_begin) orelse return "";