ReviewOS

also looking at this

craft-native/craft

feat(ios): location recording moves to the manager it always shared — ratchet 23 -> 18

#125
Merged glennmichael123 wants to merge feat/ios-location-recording into main
6 files +1653 -170

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

packages/zig/src/bridge_mobile_locrecording.zigmodified+703-0
Changes to packages/zig/src/bridge_mobile_locrecording.zig
@@ -588,6 +588,485 @@ fn readTrackBytes(allocator: std.mem.Allocator, path: []const u8) !?[]u8 {
588588 return buf;
589589}
590590
591// =============================================================================
592// The on-disk format of a recording.
593//
594// `bridge_mobile_location.zig` owns the `CLLocationManager`, the delegate and
595// the lifecycle; it calls in here for every byte that reaches the disk. The
596// dependency runs one way — nothing in this file imports that one — because a
597// recording really is what `CraftWebView.Coordinator` makes it: a flag on the
598// shared delegate plus two files. This is the half with a format to get wrong.
599//
600// Both files keep Swift's names, Swift's location and Swift's key spellings, so
601// a build with the Zig recorder removed reads back a recording this one wrote.
602// That is not hypothetical tidiness: `restoreLocationRecordingState` is still
603// in the template and still runs whenever the adoption export is absent.
604// =============================================================================
605
606/// The lifecycle mirror, beside the track. `CraftApp.swift:3156-3159`.
607const state_file_name = "craft-location-recording-state.json";
608
609/// The absolute path of `<AppSupport>/craft-location-recording-state.json`.
610fn stateFilePath(allocator: std.mem.Allocator) ![]u8 {
611 const directory = try applicationSupportPath(allocator);
612 defer allocator.free(directory);
613
614 return std.fmt.allocPrint(allocator, "{s}/" ++ state_file_name, .{directory});
615}
616
617/// The largest state file worth reading. The document is four scalars; anything
618/// approaching this is not a state file, and reading it into memory to discover
619/// that is the wrong order.
620const max_state_bytes: u64 = 64 * 1024;
621
622/// What `persistLocationRecordingState` writes and `restoreLocationRecordingState`
623/// reads back, with the same four keys and the same types.
624///
625/// `started_at` is milliseconds — `Date().timeIntervalSince1970 * 1000`
626/// (`CraftApp.swift:3222`) — and stays an `f64` the whole way, because that is
627/// what Swift wrote and rounding it to an integer here would change a value the
628/// page compares against its own `Date.now()`.
629pub const State = struct {
630 active: bool = false,
631 paused: bool = false,
632 /// Owned by the `State`. Null is `NSNull` on the wire, which is what a
633 /// recording that has never started has.
634 id: ?[]const u8 = null,
635 started_at: ?f64 = null,
636
637 pub fn deinit(self: *State, allocator: std.mem.Allocator) void {
638 if (self.id) |id| allocator.free(id);
639 self.id = null;
640 }
641};
642
643/// Read the persisted state, applying `restoreLocationRecordingState`'s own guard.
644///
645/// Swift opens with
646/// `guard … state["active"] as? Bool == true else { return }`
647/// (`CraftApp.swift:3197-3201`), and every `return` there leaves the four
648/// instance variables at their defaults. So a file that says `active:false` —
649/// which is exactly what a *stopped* recording leaves behind, `id` and
650/// `startedAt` still populated — restores as nothing at all.
651///
652/// That guard is the whole reason this returns a `State` rather than the file's
653/// contents. A reader that handed back the stopped recording's `id` would
654/// answer `{"id":"ABC-…"}` where Swift answers `{"id":null}`, which was one of
655/// the three blockers recorded against migrating this recorder (#124). It is a
656/// rule, and this is the rule, ported rather than approximated.
657///
658/// A missing file is `.{}`, not an error: there has never been a recording.
659pub fn readState(allocator: std.mem.Allocator) !State {
660 const path = try stateFilePath(allocator);
661 defer allocator.free(path);
662 return readStateFrom(allocator, path);
663}
664
665/// `readState` against a named file, so the guard above is testable without a
666/// test writing into `~/Library/Application Support`.
667fn readStateFrom(allocator: std.mem.Allocator, path: []const u8) !State {
668 const bytes = (try readSmallFile(allocator, path, max_state_bytes)) orelse return .{};
669 defer allocator.free(bytes);
670
671 // `JSONSerialization.jsonObject` throwing, or the cast to `[String: Any]`
672 // failing, both land on Swift's `else { return }`. Unreadable state is no
673 // state, not an error — the recording is gone either way, and saying so
674 // beats refusing every later call because one file is torn.
675 return decodeState(allocator, bytes) catch |err| {
676 std.log.warn(
677 "location recording: state file at '{s}' is unreadable ({}); treating it as no recording, exactly as restoreLocationRecordingState's guard does",
678 .{ path, err },
679 );
680 return .{};
681 };
682}
683
684/// The guard and the four reads, with no file anywhere near them.
685fn decodeState(allocator: std.mem.Allocator, bytes: []const u8) !State {
686 var parsed = std.json.parseFromSlice(std.json.Value, allocator, bytes, .{}) catch {
687 return .{};
688 };
689 defer parsed.deinit();
690
691 const object = switch (parsed.value) {
692 .object => |o| o,
693 else => return .{},
694 };
695
696 // `as? Bool == true`: a missing key, a null, a number, a string all fail
697 // the cast and land on the same `return`.
698 const active = switch (object.get("active") orelse return .{}) {
699 .bool => |b| b,
700 else => return .{},
701 };
702 if (!active) return .{};
703
704 var state: State = .{ .active = true };
705 errdefer state.deinit(allocator);
706
707 // Past the guard, Swift's reads are `as? Bool ?? false` and `as? String` /
708 // `as? TimeInterval`, so a wrong type here is a default rather than a
709 // refusal.
710 if (object.get("paused")) |value| {
711 state.paused = switch (value) {
712 .bool => |b| b,
713 else => false,
714 };
715 }
716 if (object.get("id")) |value| {
717 switch (value) {
718 .string => |s| state.id = try allocator.dupe(u8, s),
719 else => {},
720 }
721 }
722 if (object.get("startedAt")) |value| {
723 state.started_at = switch (value) {
724 .float => |f| f,
725 .integer => |i| @floatFromInt(i),
726 else => null,
727 };
728 }
729
730 return state;
731}
732
733/// Mirror the lifecycle to disk, atomically, then protect the file.
734///
735/// `persistLocationRecordingState` (`CraftApp.swift:3179-3195`) writes all four
736/// keys every time, with `.atomic`, and then re-applies protection. Each of
737/// those three is load-bearing and each is done here.
738///
739/// Unlike Swift, a failure is **returned**. Swift catches and prints, so memory
740/// and file drift with nothing to signal it — a divergence its own recorder
741/// comment calls out. A caller here can refuse the action instead of reporting
742/// a recording that was never persisted.
743pub fn writeState(allocator: std.mem.Allocator, state: State) !void {
744 const path = try stateFilePath(allocator);
745 defer allocator.free(path);
746
747 const bytes = try encodeState(allocator, state);
748 defer allocator.free(bytes);
749
750 try writeFileAtomic(allocator, path, bytes);
751 protectFile(path);
752}
753
754/// The four keys, in `persistLocationRecordingState`'s spelling.
755fn encodeState(allocator: std.mem.Allocator, state: State) ![]u8 {
756 var out: std.ArrayList(u8) = .empty;
757 defer out.deinit(allocator);
758
759 try out.appendSlice(allocator, "{\"active\":");
760 try out.appendSlice(allocator, if (state.active) "true" else "false");
761 try out.appendSlice(allocator, ",\"paused\":");
762 try out.appendSlice(allocator, if (state.paused) "true" else "false");
763 try out.appendSlice(allocator, ",\"id\":");
764 if (state.id) |id| {
765 // `appendJsonEscaped` escapes the contents and writes no delimiters, so
766 // the quotes are this caller's job. Without them an id containing a
767 // quote does not merely look wrong — it tears the document, and the
768 // whole recording reads back as none.
769 try out.append(allocator, '"');
770 try bridge_error.appendJsonEscaped(allocator, &out, id);
771 try out.append(allocator, '"');
772 } else {
773 try out.appendSlice(allocator, "null");
774 }
775 try out.appendSlice(allocator, ",\"startedAt\":");
776 if (state.started_at) |started| {
777 // `allocPrint`, not a stack buffer: an `f64` rendered with `{d}` can
778 // need 347 bytes, and a `NoSpaceLeft` here would lose a live recording
779 // to a formatting detail. Growing the allocation cannot overflow.
780 const rendered = try std.fmt.allocPrint(allocator, "{d}", .{started});
781 defer allocator.free(rendered);
782 try out.appendSlice(allocator, rendered);
783 } else {
784 try out.appendSlice(allocator, "null");
785 }
786 try out.appendSlice(allocator, "}");
787
788 return out.toOwnedSlice(allocator);
789}
790
791/// Start a fresh track, replacing whatever the last recording left.
792///
793/// `startLocationRecording` writes an empty `Data()` with `.atomic` before it
794/// touches any state (`CraftApp.swift:3213-3220`), so a new recording never
795/// inherits the previous route's samples, and a failure refuses the start
796/// rather than recording into the old file.
797pub fn resetTrack(allocator: std.mem.Allocator) !void {
798 const path = try trackFilePath(allocator);
799 defer allocator.free(path);
800
801 try writeFileAtomic(allocator, path, "");
802 protectFile(path);
803}
804
805/// Append one already-shaped sample object, with the newline that separates it.
806///
807/// `appendRecordedLocation` (`CraftApp.swift:3288-3302`) opens the track, seeks
808/// to the end and writes the JSON plus `0x0A`. `length` + `writePositionalAll`
809/// is that pair, and it is the reason this is not a read-modify-write: a route
810/// of 176,000 fixes rewritten once per second is not a recorder.
811///
812/// The sample arrives as bytes that `didUpdateLocations` already shaped for the
813/// page, so the track holds exactly what the event carried. Nothing is
814/// reparsed, and nothing can be rounded on the way in.
815pub fn appendSample(allocator: std.mem.Allocator, sample_json: []const u8) !void {
816 const path = try trackFilePath(allocator);
817 defer allocator.free(path);
818 return appendSampleTo(allocator, path, sample_json);
819}
820
821/// `appendSample` against a named track.
822fn appendSampleTo(allocator: std.mem.Allocator, path: []const u8, sample_json: []const u8) !void {
823 const io = io_context.get();
824
825 // Swift's `FileHandle(forWritingTo:)` throws when the file is not there,
826 // and its `catch` prints and drops the sample. Opening write-only without
827 // creating keeps that: a track that does not exist means no recording is
828 // running, and creating one here would start a route nobody asked for.
829 const file = std.Io.Dir.cwd().openFile(io, path, .{ .mode = .write_only }) catch |err| {
830 std.log.warn(
831 "location recording: could not open the track at '{s}' to append a sample ({}); the fix is lost",
832 .{ path, err },
833 );
834 return bridge_error.BridgeError.NativeCallFailed;
835 };
836 defer file.close(io);
837
838 const end = file.length(io) catch |err| {
839 std.log.warn("location recording: could not measure the track at '{s}' ({})", .{ path, err });
840 return bridge_error.BridgeError.NativeCallFailed;
841 };
842
843 // One write, not two. A sample and its newline that reach the file
844 // separately can be torn apart by a kill between them, and a half-written
845 // line is the one thing the reader has to throw away.
846 const line = try std.fmt.allocPrint(allocator, "{s}\n", .{sample_json});
847 defer allocator.free(line);
848
849 file.writePositionalAll(io, line, end) catch |err| {
850 std.log.warn("location recording: could not append to the track at '{s}' ({})", .{ path, err });
851 return bridge_error.BridgeError.NativeCallFailed;
852 };
853}
854
855/// How many samples `readLocationRecording` would return.
856///
857/// `getLocationRecordingState` reports `loadRecordedLocations().count`
858/// (`CraftApp.swift:3274`), so this has to agree with the reader line for line
859/// — the same blank-line and non-object drops — or the page is told it has a
860/// number of fixes it cannot then read back.
861pub fn sampleCount(allocator: std.mem.Allocator) !usize {
862 const path = try trackFilePath(allocator);
863 defer allocator.free(path);
864 return countSamplesIn(allocator, path);
865}
866
867/// `sampleCount` against a named track.
868fn countSamplesIn(allocator: std.mem.Allocator, path: []const u8) !usize {
869 const bytes = (try readTrackBytes(allocator, path)) orelse return 0;
870 defer allocator.free(bytes);
871
872 var count: usize = 0;
873 var lines = std.mem.splitScalar(u8, bytes, '\n');
874 while (lines.next()) |line| {
875 if (line.len == 0) continue;
876 if (try lineIsJsonObject(allocator, line)) count += 1;
877 }
878 return count;
879}
880
881/// Read a small file whole, or null when it is not there.
882fn readSmallFile(allocator: std.mem.Allocator, path: []const u8, ceiling: u64) !?[]u8 {
883 const io = io_context.get();
884
885 const file = std.Io.Dir.cwd().openFile(io, path, .{}) catch |err| switch (err) {
886 error.FileNotFound => return null,
887 else => {
888 std.log.warn("location recording: could not open '{s}': {}", .{ path, err });
889 return bridge_error.BridgeError.NativeCallFailed;
890 },
891 };
892 defer file.close(io);
893
894 const size = file.length(io) catch |err| {
895 std.log.warn("location recording: could not measure '{s}': {}", .{ path, err });
896 return bridge_error.BridgeError.NativeCallFailed;
897 };
898 if (size > ceiling) {
899 std.log.warn(
900 "location recording: '{s}' is {d} bytes, over the {d}-byte ceiling; refusing to read it",
901 .{ path, size, ceiling },
902 );
903 return bridge_error.BridgeError.NativeCallFailed;
904 }
905
906 const buf = try allocator.alloc(u8, @intCast(size));
907 errdefer allocator.free(buf);
908
909 var read: usize = 0;
910 while (read < buf.len) {
911 const n = file.readStreaming(io, &.{buf[read..]}) catch |err| switch (err) {
912 error.EndOfStream => break,
913 else => {
914 std.log.warn("location recording: could not read '{s}': {}", .{ path, err });
915 return bridge_error.BridgeError.NativeCallFailed;
916 },
917 };
918 if (n == 0) break;
919 read += n;
920 }
921
922 return buf[0..read];
923}
924
925/// Write `bytes` to `path` so that a reader sees either the old file or the
926/// whole new one, never a half-written document.
927///
928/// This is what `Data.write(options: .atomic)` does — a sibling temp file and a
929/// rename — and it matters for the same reason it does there: the state file is
930/// read at launch, and a launch that lands mid-write would restore a torn
931/// recording.
932fn writeFileAtomic(allocator: std.mem.Allocator, path: []const u8, bytes: []const u8) !void {
933 const io = io_context.get();
934
935 const temp_path = try std.fmt.allocPrint(allocator, "{s}.tmp", .{path});
936 defer allocator.free(temp_path);
937
938 {
939 const file = std.Io.Dir.cwd().createFile(io, temp_path, .{}) catch |err| {
940 std.log.warn("location recording: could not create '{s}': {}", .{ temp_path, err });
941 return bridge_error.BridgeError.NativeCallFailed;
942 };
943 defer file.close(io);
944
945 file.writeStreamingAll(io, bytes) catch |err| {
946 std.log.warn("location recording: could not write '{s}': {}", .{ temp_path, err });
947 return bridge_error.BridgeError.NativeCallFailed;
948 };
949 }
950
951 const dir = std.Io.Dir.cwd();
952 std.Io.Dir.rename(dir, temp_path, dir, path, io) catch |err| {
953 std.log.warn("location recording: could not move '{s}' into place at '{s}': {}", .{ temp_path, path, err });
954 // A temp file left behind is litter; a temp file left behind *and*
955 // reported as success is a recording nobody can find.
956 std.Io.Dir.deleteFile(dir, io, temp_path) catch {};
957 return bridge_error.BridgeError.NativeCallFailed;
958 };
959}
960
961/// Apply `protectLocationFile`'s two attributes: data protection, and exclusion
962/// from backup. `CraftApp.swift:3304-3311`.
963///
964/// Best effort, and deliberately so — Swift's are both `try?`. A route that
965/// records is better than a route refused because one attribute would not
966/// stick, and the two failures differ in kind: without protection the file is
967/// readable before first unlock, without the backup flag it leaves the device
968/// in an iCloud backup. Both are logged by name so neither is silent.
969///
970/// Off Darwin this does nothing and says nothing: there is no data protection
971/// to apply, and the host tests write their fixtures into the working
972/// directory.
973fn protectFile(path: []const u8) void {
974 if (!is_darwin) return;
975
976 const ns_path = makeNSString(path) orelse {
977 std.log.warn("location recording: could not make an NSString for '{s}'; file attributes not applied", .{path});
978 return;
979 };
980
981 setProtection(ns_path, path);
982 excludeFromBackup(ns_path, path);
983}
984
985/// `NSFileProtectionKey` → `NSFileProtectionCompleteUntilFirstUserAuthentication`.
986///
987/// Both names are `extern NSString * const`, so their values are not in any
988/// header and are read out of whatever loaded Foundation rather than spelled
989/// out here — the same `dlsym` route `bestAccuracy` takes in
990/// `bridge_mobile_location.zig`.
991fn setProtection(ns_path: Id, path: []const u8) void {
992 const key = nsStringConstant("NSFileProtectionKey") orelse {
993 std.log.warn("location recording: NSFileProtectionKey is not in this process; '{s}' is unprotected", .{path});
994 return;
995 };
996 const value = nsStringConstant("NSFileProtectionCompleteUntilFirstUserAuthentication") orelse {
997 std.log.warn("location recording: NSFileProtectionCompleteUntilFirstUserAuthentication is not in this process; '{s}' is unprotected", .{path});
998 return;
999 };
1000
1001 const NSDictionary = objc.objc_getClass("NSDictionary") orelse return;
1002 const sel_dict = objc.sel_registerName("dictionaryWithObject:forKey:") orelse return;
1003 const attributes = objc.msgSendId2(NSDictionary, sel_dict, value, key) orelse return;
1004
1005 const NSFileManager = objc.objc_getClass("NSFileManager") orelse return;
1006 const sel_default = objc.sel_registerName("defaultManager") orelse return;
1007 const manager = objc.msgSendId(NSFileManager, sel_default) orelse return;
1008
1009 const sel_set = objc.sel_registerName("setAttributes:ofItemAtPath:error:") orelse return;
1010 const SetFn = *const fn (Id, Id, Id, Id, ?*anyopaque) callconv(.c) bool;
1011 const set: SetFn = @ptrCast(&objc.objc_msgSend);
1012
1013 if (!set(manager, sel_set, attributes, ns_path, null)) {
1014 std.log.warn("location recording: setAttributes: refused for '{s}'; the file is readable before first unlock", .{path});
1015 }
1016}
1017
1018/// `NSURLIsExcludedFromBackupKey` = YES, via `NSURL`.
1019fn excludeFromBackup(ns_path: Id, path: []const u8) void {
1020 const key = nsStringConstant("NSURLIsExcludedFromBackupKey") orelse {
1021 std.log.warn("location recording: NSURLIsExcludedFromBackupKey is not in this process; '{s}' will be backed up", .{path});
1022 return;
1023 };
1024
1025 const NSURL = objc.objc_getClass("NSURL") orelse return;
1026 const sel_file_url = objc.sel_registerName("fileURLWithPath:") orelse return;
1027 const url = objc.msgSendId1(NSURL, sel_file_url, ns_path) orelse return;
1028
1029 const NSNumber = objc.objc_getClass("NSNumber") orelse return;
1030 const sel_with_bool = objc.sel_registerName("numberWithBool:") orelse return;
1031 const WithBoolFn = *const fn (Id, Id, bool) callconv(.c) Id;
1032 const with_bool: WithBoolFn = @ptrCast(&objc.objc_msgSend);
1033 const yes = with_bool(NSNumber, sel_with_bool, true) orelse return;
1034
1035 const sel_set = objc.sel_registerName("setResourceValue:forKey:error:") orelse return;
1036 const SetFn = *const fn (Id, Id, Id, Id, ?*anyopaque) callconv(.c) bool;
1037 const set: SetFn = @ptrCast(&objc.objc_msgSend);
1038
1039 if (!set(url, sel_set, yes, key, null)) {
1040 std.log.warn("location recording: setResourceValue: refused for '{s}'; the recording will be included in backups", .{path});
1041 }
1042}
1043
1044/// Read an `extern NSString * const` out of the loaded frameworks.
1045///
1046/// The symbol is the *cell*, not the string: dereference it to get the object.
1047fn nsStringConstant(name: [*:0]const u8) ?Id {
1048 const symbol = dlsym(RTLD_DEFAULT, name) orelse return null;
1049 const cell: *const Id = @ptrCast(@alignCast(symbol));
1050 return cell.*;
1051}
1052
1053extern "c" fn dlsym(handle: ?*anyopaque, symbol: [*:0]const u8) ?*anyopaque;
1054
1055/// dyld's "search every image" pseudo-handle, (void *)-2 on Darwin.
1056const RTLD_DEFAULT: ?*anyopaque = @ptrFromInt(@as(usize, @bitCast(@as(isize, -2))));
1057
1058/// An autoreleased `NSString` for a Zig slice, via `stringWithUTF8String:`.
1059fn makeNSString(text: []const u8) ?Id {
1060 var buf: [std.fs.max_path_bytes + 1]u8 = undefined;
1061 if (text.len >= buf.len) return null;
1062 @memcpy(buf[0..text.len], text);
1063 buf[text.len] = 0;
1064
1065 const NSString = objc.objc_getClass("NSString") orelse return null;
1066 const sel = objc.sel_registerName("stringWithUTF8String:") orelse return null;
1067 return objc.msgSendId1(NSString, sel, @as([*:0]const u8, @ptrCast(&buf)));
1068}
1069
5911070// =============================================================================
5921071// Tests.
5931072//
@@ -1182,3 +1661,227 @@ test "with no recording on the host, the handler answers rather than failing" {
11821661
11831662 try bridge.handleMessage(A.read_location_recording, "{}");
11841663}
1664
1665// =============================================================================
1666// The on-disk format.
1667//
1668// The guard in `decodeState` is the one that closes the third of the three
1669// blockers #124 lists, so it is pinned from both sides: a stopped recording's
1670// leftovers must read back as nothing, and a live one must read back whole.
1671// Everything here runs against bytes or against a file this test owns; nothing
1672// touches `~/Library/Application Support`.
1673// =============================================================================
1674
1675const test_state_name = "craft-locrecording-test-state.json";
1676
1677fn writeTestFile(name: []const u8, contents: []const u8) !void {
1678 const io = io_context.get();
1679 const file = try std.Io.Dir.cwd().createFile(io, name, .{});
1680 defer file.close(io);
1681 try file.writeStreamingAll(io, contents);
1682}
1683
1684fn removeTestFile(name: []const u8) void {
1685 std.Io.Dir.cwd().deleteFile(io_context.get(), name) catch |err| {
1686 std.log.debug("locrecording test file cleanup failed: {}", .{err});
1687 };
1688}
1689
1690test "a stopped recording's leftovers read back as no recording at all" {
1691 // The exact divergence #124's third blocker names. `stopLocationRecording`
1692 // sets `active:false` but leaves `id` and `startedAt` in the file, and
1693 // Swift's `guard state["active"] as? Bool == true else { return }` means a
1694 // relaunch after a stop restores nothing. A reader that handed back the id
1695 // would answer `{"id":"ABC-123"}` where Swift answers `{"id":null}`.
1696 var state = try decodeState(
1697 testing.allocator,
1698 \\{"active":false,"paused":false,"id":"ABC-123","startedAt":1756900000000}
1699 );
1700 defer state.deinit(testing.allocator);
1701
1702 try testing.expect(!state.active);
1703 try testing.expect(!state.paused);
1704 try testing.expect(state.id == null);
1705 try testing.expect(state.started_at == null);
1706}
1707
1708test "an active recording restores all four fields" {
1709 var state = try decodeState(
1710 testing.allocator,
1711 \\{"active":true,"paused":true,"id":"ABC-123","startedAt":1756900000000}
1712 );
1713 defer state.deinit(testing.allocator);
1714
1715 try testing.expect(state.active);
1716 try testing.expect(state.paused);
1717 try testing.expectEqualStrings("ABC-123", state.id.?);
1718 try testing.expectEqual(@as(f64, 1756900000000), state.started_at.?);
1719}
1720
1721test "the active guard is a Bool cast, not a truthiness test" {
1722 // `as? Bool` fails for each of these, and every failure lands on the same
1723 // `return`. A JSON reader that coerced would restore a recording Swift
1724 // does not.
1725 const not_bools = [_][]const u8{
1726 \\{"active":"true","id":"ABC","startedAt":1}
1727 ,
1728 \\{"active":1,"id":"ABC","startedAt":1}
1729 ,
1730 \\{"active":null,"id":"ABC","startedAt":1}
1731 ,
1732 \\{"paused":false,"id":"ABC","startedAt":1}
1733 ,
1734 \\["active",true]
1735 ,
1736 \\not json at all
1737 ,
1738 };
1739
1740 for (not_bools) |document| {
1741 var state = try decodeState(testing.allocator, document);
1742 defer state.deinit(testing.allocator);
1743 try testing.expect(!state.active);
1744 try testing.expect(state.id == null);
1745 try testing.expect(state.started_at == null);
1746 }
1747}
1748
1749test "past the guard a wrong type is a default, not a refusal" {
1750 // Swift's remaining reads are `as? Bool ?? false`, `as? String` and
1751 // `as? TimeInterval`, all of which degrade rather than throw.
1752 var state = try decodeState(
1753 testing.allocator,
1754 \\{"active":true,"paused":"yes","id":42,"startedAt":"soon"}
1755 );
1756 defer state.deinit(testing.allocator);
1757
1758 try testing.expect(state.active);
1759 try testing.expect(!state.paused);
1760 try testing.expect(state.id == null);
1761 try testing.expect(state.started_at == null);
1762}
1763
1764test "the encoded state carries the four keys Swift's decoder looks for" {
1765 const encoded = try encodeState(testing.allocator, .{
1766 .active = true,
1767 .paused = false,
1768 .id = "ABC-123",
1769 .started_at = 1756900000000,
1770 });
1771 defer testing.allocator.free(encoded);
1772
1773 try testing.expectEqualStrings(
1774 \\{"active":true,"paused":false,"id":"ABC-123","startedAt":1756900000000}
1775 , encoded);
1776}
1777
1778test "a recording that has never started encodes its nulls, not empty strings" {
1779 const encoded = try encodeState(testing.allocator, .{});
1780 defer testing.allocator.free(encoded);
1781
1782 try testing.expectEqualStrings(
1783 \\{"active":false,"paused":false,"id":null,"startedAt":null}
1784 , encoded);
1785}
1786
1787test "state survives a round trip through the encoder and the guard" {
1788 const encoded = try encodeState(testing.allocator, .{
1789 .active = true,
1790 .paused = true,
1791 .id = "route-9",
1792 .started_at = 1756900123456,
1793 });
1794 defer testing.allocator.free(encoded);
1795
1796 var state = try decodeState(testing.allocator, encoded);
1797 defer state.deinit(testing.allocator);
1798
1799 try testing.expect(state.active);
1800 try testing.expect(state.paused);
1801 try testing.expectEqualStrings("route-9", state.id.?);
1802 try testing.expectEqual(@as(f64, 1756900123456), state.started_at.?);
1803}
1804
1805test "an id with quotes in it survives rather than tearing the document" {
1806 const encoded = try encodeState(testing.allocator, .{
1807 .active = true,
1808 .id = "a\"b\\c",
1809 });
1810 defer testing.allocator.free(encoded);
1811
1812 var state = try decodeState(testing.allocator, encoded);
1813 defer state.deinit(testing.allocator);
1814
1815 try testing.expectEqualStrings("a\"b\\c", state.id.?);
1816}
1817
1818test "a missing state file is no recording, not an error" {
1819 removeTestFile(test_state_name);
1820
1821 var state = try readStateFrom(testing.allocator, test_state_name);
1822 defer state.deinit(testing.allocator);
1823
1824 try testing.expect(!state.active);
1825}
1826
1827test "appended samples land one per line, byte for byte" {
1828 removeTestFile(test_track_name);
1829 defer removeTestFile(test_track_name);
1830
1831 // The track has to exist first: `FileHandle(forWritingTo:)` throws when it
1832 // does not, and appending would otherwise start a route nobody asked for.
1833 try writeTestFile(test_track_name, "");
1834
1835 try appendSampleTo(testing.allocator, test_track_name, "{\"latitude\":1.5}");
1836 try appendSampleTo(testing.allocator, test_track_name, "{\"latitude\":2.5}");
1837
1838 const text = try readTrackBytes(testing.allocator, test_track_name) orelse
1839 return error.TestExpectedTrack;
1840 defer testing.allocator.free(text);
1841
1842 try testing.expectEqualStrings("{\"latitude\":1.5}\n{\"latitude\":2.5}\n", text);
1843}
1844
1845test "appending to a track that is not there loses the sample rather than creating one" {
1846 removeTestFile(test_track_name);
1847
1848 // No recording is running, so there is no route for this fix to join.
1849 // Creating the file here would invent one.
1850 try testing.expectError(
1851 bridge_error.BridgeError.NativeCallFailed,
1852 appendSampleTo(testing.allocator, test_track_name, "{\"latitude\":1.5}"),
1853 );
1854}
1855
1856test "the sample count agrees with the reader, drop for drop" {
1857 removeTestFile(test_track_name);
1858 defer removeTestFile(test_track_name);
1859
1860 // `getLocationRecordingState` reports `loadRecordedLocations().count`, so a
1861 // blank line and a non-object have to be uncounted here exactly as they are
1862 // unread there — otherwise the page is told it has fixes it cannot read.
1863 try writeTestFile(test_track_name,
1864 \\{"latitude":1}
1865 \\
1866 \\[1,2]
1867 \\{"latitude":2}
1868 \\torn-half-
1869 );
1870
1871 try testing.expectEqual(@as(usize, 2), try countSamplesIn(testing.allocator, test_track_name));
1872
1873 const text = try readTrackBytes(testing.allocator, test_track_name) orelse
1874 return error.TestExpectedTrack;
1875 defer testing.allocator.free(text);
1876
1877 const shaped = try shapeRecording(testing.allocator, text);
1878 defer testing.allocator.free(shaped);
1879
1880 // Two objects in, two objects out: the count is the reader's own answer.
1881 try testing.expectEqualStrings("[{\"latitude\":1},{\"latitude\":2}]", shaped);
1882}
1883
1884test "an absent track counts zero rather than refusing" {
1885 removeTestFile(test_track_name);
1886 try testing.expectEqual(@as(usize, 0), try countSamplesIn(testing.allocator, test_track_name));
1887}