ReviewOS

also looking at this

craft-native/craft

iOS Phase 8: the presented pickers — ratchet 58 → 53, last mechanism landed

#90
Merged glennmichael123 wants to merge feat/ios-phase8-ui into main
11 files +5752 -12

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/ios_delegate.zigadded+193-0
Changes to packages/zig/src/ios_delegate.zig
@@ -0,0 +1,193 @@
1//! Building an Objective-C delegate class at runtime.
2//!
3//! Three files now need one — the script message handler in `ios.zig`, the
4//! `CLLocationManagerDelegate` in `bridge_mobile_location.zig`, and every
5//! presented picker (image, document, contact), each of which answers through
6//! a did-finish/did-cancel pair. The registration dance is the same every
7//! time: `objc_getClass` first so a second call is a no-op, then
8//! `objc_allocateClassPair`, then a `class_addMethod` per selector with its
9//! type encoding, then `objc_registerClassPair`.
10//!
11//! ## What this deliberately does not own
12//!
13//! It does not route callbacks to pending requests. That was tempting and
14//! would have been wrong: a location delegate serves a one-shot *and* a
15//! running stream simultaneously, a picker is modal and serves exactly one
16//! call, and a message handler serves none — it dispatches. Three different
17//! lifetimes, and a factory that guessed at one would push the other two into
18//! working around it. Each module keeps its own state and its own `ios_async`
19//! ticket; this only makes the class exist.
20//!
21//! It also does not retain the delegate *instance*. `instantiate` returns a
22//! +1 object and says so; the caller stores it in a module-level var, because
23//! only the caller knows whether the framework holds the delegate weakly
24//! (`CLLocationManager` and `UIImagePickerController` both do — a released
25//! instance is a crash, not a leak).
26//!
27//! ## Type encodings, which are the part that fails silently
28//!
29//! `class_addMethod`'s encoding string is not checked against the IMP. Get it
30//! wrong and the method still registers, still gets called, and reads its
31//! arguments from the wrong registers. There is no compile error and often no
32//! crash — just wrong values. The `enc` helpers below name the shapes these
33//! delegates actually use so a caller picks one rather than spelling it.
34
35const std = ;
36const builtin = ;
37const objc_runtime = ;
38
39const objc = objc_runtime.objc;
40
41/// Type encodings for the delegate signatures in use.
42///
43/// Every Objective-C method takes `self` (`@`) and `_cmd` (`:`) first; the
44/// leading character is the return type. Named rather than spelled at call
45/// sites because a typo here is invisible until an argument reads as garbage.
46pub const enc = struct {
47 /// `- (void)m:(id)a` — one object argument.
48 pub const void_one_object: [:0]const u8 = "v@:@";
49 /// `- (void)m:(id)a :(id)b` — two object arguments. The
50 /// did-finish-picking pair and `userContentController:didReceiveScriptMessage:`
51 /// are both this shape.
52 pub const void_two_objects: [:0]const u8 = "v@:@@";
53 /// `- (BOOL)m:(id)a :(id)b` — `application:didFinishLaunchingWithOptions:`.
54 ///
55 /// `B` is C99 `_Bool`: on 64-bit Apple platforms `__OBJC_BOOL_IS_BOOL` is
56 /// defined, so `BOOL` is `bool` rather than `signed char`.
57 pub const bool_two_objects: [:0]const u8 = "B@:@@";
58};
59
60/// One method to add to a class.
61pub const Method = struct {
62 /// The selector, e.g. `"imagePickerControllerDidCancel:"`.
63 selector: [:0]const u8,
64 /// The implementation. Must be an `export fn ... callconv(.c)` whose first
65 /// two parameters are `objc.id` (self) and `objc.SEL` (_cmd).
66 imp: *const anyopaque,
67 /// The type encoding. Use one of `enc`.
68 types: [:0]const u8,
69};
70
71pub const Error = error{
72 UnsupportedPlatform,
73 ClassNotFound,
74 ClassAllocationFailed,
75 MethodNotAdded,
76 SelectorNotFound,
77 InstantiationFailed,
78};
79
80/// Register `name` as a subclass of `superclass_name` carrying `methods`.
81///
82/// Idempotent: a second call with the same name returns the existing class
83/// without touching it. That matters because these registrations happen lazily
84/// on first use, and "first use" can be reached from more than one action.
85///
86/// A partially-built class is not registered. If any `class_addMethod` fails
87/// the pair is abandoned rather than registered with a missing method — a
88/// class whose delegate method is absent silently does nothing when the
89/// framework calls it, which is the failure this whole migration keeps
90/// finding.
91pub fn defineClass(
92 name: [:0]const u8,
93 superclass_name: [:0]const u8,
94 methods: []const Method,
95) Error!objc.Class {
96 if (!builtin.target.os.tag.isDarwin()) return Error.UnsupportedPlatform;
97
98 if (objc.objc_getClass(name.ptr)) |existing| return existing;
99
100 const superclass = objc.objc_getClass(superclass_name.ptr) orelse return Error.ClassNotFound;
101 const cls = objc.objc_allocateClassPair(superclass, name.ptr, 0) orelse
102 return Error.ClassAllocationFailed;
103
104 for (methods) |method| {
105 const sel = objc.sel_registerName(method.selector.ptr) orelse return Error.SelectorNotFound;
106 const imp: objc.IMP = @ptrCast(@constCast(method.imp));
107 if (!objc.class_addMethod(cls, sel, imp, method.types.ptr)) {
108 // Deliberately not registered. An unregistered class pair is
109 // leaked, which is one allocation for the life of the process and
110 // strictly better than a registered class that answers a delegate
111 // callback by doing nothing.
112 return Error.MethodNotAdded;
113 }
114 }
115
116 objc.objc_registerClassPair(cls);
117 return cls;
118}
119
120/// `[[cls alloc] init]`, +1.
121///
122/// The caller owns the reference and must keep it alive for as long as the
123/// framework might call back. Every delegate protocol here is held weakly by
124/// its owner, so storing this in a module-level var is not an optimisation —
125/// dropping it is a use-after-free the first time the user taps Cancel.
126pub fn instantiate(cls: objc.Class) Error!objc.id {
127 if (!builtin.target.os.tag.isDarwin()) return Error.UnsupportedPlatform;
128 return objc.allocInit(cls) catch Error.InstantiationFailed;
129}
130
131const testing = std.testing;
132
133test "a class is registered once and re-registration returns the same class" {
134 if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
135
136 const first = try defineClass("CraftDelegateFactoryProbe", "NSObject", &.{});
137 const second = try defineClass("CraftDelegateFactoryProbe", "NSObject", &.{});
138
139 // Same pointer, not merely both non-null: a second allocateClassPair for a
140 // taken name returns null, so a factory that did not check first would
141 // fail on the second call rather than returning what exists.
142 try testing.expectEqual(first, second);
143 try testing.expect(objc.objc_getClass("CraftDelegateFactoryProbe") != null);
144}
145
146test "a method added by the factory is actually reachable by selector" {
147 if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
148
149 const S = struct {
150 var called: bool = false;
151 export fn craftDelegateFactoryProbeMethod(_: objc.id, _: objc.SEL, _: objc.id) void {
152 called = true;
153 }
154 };
155
156 const cls = try defineClass("CraftDelegateFactoryProbe2", "NSObject", &.{
157 .{
158 .selector = "craftProbe:",
159 .imp = @ptrCast(&S.craftDelegateFactoryProbeMethod),
160 .types = enc.void_one_object,
161 },
162 });
163
164 const instance = try instantiate(cls);
165 const sel = objc.sel_registerName("craftProbe:") orelse return error.SelectorNotFound;
166
167 // The point of the test: registration is not enough, the runtime has to
168 // dispatch to it. A wrong encoding still registers and still dispatches,
169 // so this proves reachability rather than correctness of the encoding —
170 // which nothing can check but a real call with real arguments.
171 objc.msgSendVoid1(instance, sel, @as(objc.id, null));
172 try testing.expect(S.called);
173}
174
175test "an unknown superclass is refused rather than silently subclassing NSObject" {
176 if (!builtin.target.os.tag.isDarwin()) return error.SkipZigTest;
177
178 try testing.expectError(
179 Error.ClassNotFound,
180 defineClass("CraftDelegateFactoryProbe3", "NoSuchSuperclassExists", &.{}),
181 );
182 // And nothing was left half-built under that name.
183 try testing.expect(objc.objc_getClass("CraftDelegateFactoryProbe3") == null);
184}
185
186test "the encodings name the shapes these delegates actually use" {
187 // A typo in an encoding registers fine and reads arguments from the wrong
188 // registers — no compile error, often no crash, just wrong values. Naming
189 // them is the only defence available, so the names are pinned.
190 try testing.expectEqualStrings("v@:@", enc.void_one_object);
191 try testing.expectEqualStrings("v@:@@", enc.void_two_objects);
192 try testing.expectEqualStrings("B@:@@", enc.bool_two_objects);
193}
packages/zig/src/ios_dispatch.zigmodified+2-0
Changes to packages/zig/src/ios_dispatch.zig
@@ -5,6 +5,7 @@ const request_context = ;
55const bridge_error = ;
66pub const ios_async = ;
77pub const ios_events = ;
8pub const ios_delegate = ;
89const bridge_mobile = ;
910const bridge_mobile_clipboard = ;
1011const bridge_mobile_haptics = ;
@@ -388,6 +389,7 @@ fn buildShimError(
388389test {
389390 _ = ios_async;
390391 _ = ios_events;
392 _ = ios_delegate;
391393}
392394
393395const testing = std.testing;