also looking at this
feat(ios): location recording moves to the manager it always shared — ratchet 23 -> 18
#125
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.
| @@ -588,6 +588,485 @@ fn readTrackBytes(allocator: std.mem.Allocator, path: []const u8) !?[]u8 { | ||
| 588 | 588 | return buf; |
| 589 | 589 | } |
| 590 | 590 | |
| 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`. | |
| 607 | const state_file_name = "craft-location-recording-state.json"; | |
| 608 | ||
| 609 | /// The absolute path of `<AppSupport>/craft-location-recording-state.json`. | |
| 610 | fn 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. | |
| 620 | const 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()`. | |
| 629 | pub 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. | |
| 659 | pub 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`. | |
| 667 | fn 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. | |
| 685 | fn 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. | |
| 743 | pub 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. | |
| 755 | fn 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. | |
| 797 | pub 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. | |
| 815 | pub 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. | |
| 822 | fn 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. | |
| 861 | pub 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. | |
| 868 | fn 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. | |
| 882 | fn 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. | |
| 932 | fn 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. | |
| 973 | fn 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`. | |
| 991 | fn 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`. | |
| 1019 | fn 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. | |
| 1047 | fn 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 | ||
| 1053 | extern "c" fn dlsym(handle: ?*anyopaque, symbol: [*:0]const u8) ?*anyopaque; | |
| 1054 | ||
| 1055 | /// dyld's "search every image" pseudo-handle, (void *)-2 on Darwin. | |
| 1056 | const RTLD_DEFAULT: ?*anyopaque = @ptrFromInt(@as(usize, @bitCast(@as(isize, -2)))); | |
| 1057 | ||
| 1058 | /// An autoreleased `NSString` for a Zig slice, via `stringWithUTF8String:`. | |
| 1059 | fn 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 | ||
| 591 | 1070 | // ============================================================================= |
| 592 | 1071 | // Tests. |
| 593 | 1072 | // |
| @@ -1182,3 +1661,227 @@ test "with no recording on the host, the handler answers rather than failing" { | ||
| 1182 | 1661 | |
| 1183 | 1662 | try bridge.handleMessage(A.read_location_recording, "{}"); |
| 1184 | 1663 | } |
| 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 | ||
| 1675 | const test_state_name = "craft-locrecording-test-state.json"; | |
| 1676 | ||
| 1677 | fn 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 | ||
| 1684 | fn 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 | ||
| 1690 | test "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 | ||
| 1708 | test "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 | ||
| 1721 | test "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 | ||
| 1749 | test "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 | ||
| 1764 | test "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 | ||
| 1778 | test "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 | ||
| 1787 | test "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 | ||
| 1805 | test "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 | ||
| 1818 | test "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 | ||
| 1827 | test "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 | ||
| 1845 | test "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 | ||
| 1856 | test "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 | ||
| 1884 | test "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 | } | |