also looking at this
iOS Phase 8: the presented pickers — ratchet 58 → 53, last mechanism landed
#90
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.
| @@ -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 | ||
| 35 | const std = ; | |
| 36 | const builtin = ; | |
| 37 | const objc_runtime = ; | |
| 38 | ||
| 39 | const 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. | |
| 46 | pub 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. | |
| 61 | pub 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 | ||
| 71 | pub 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. | |
| 91 | pub 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. | |
| 126 | pub 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 | ||
| 131 | const testing = std.testing; | |
| 132 | ||
| 133 | test "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 | ||
| 146 | test "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 | ||
| 175 | test "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 | ||
| 186 | test "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 | } | |
| @@ -5,6 +5,7 @@ const request_context = ; | ||
| 5 | 5 | const bridge_error = ; |
| 6 | 6 | pub const ios_async = ; |
| 7 | 7 | pub const ios_events = ; |
| 8 | pub const ios_delegate = ; | |
| 8 | 9 | const bridge_mobile = ; |
| 9 | 10 | const bridge_mobile_clipboard = ; |
| 10 | 11 | const bridge_mobile_haptics = ; |
| @@ -388,6 +389,7 @@ fn buildShimError( | ||
| 388 | 389 | test { |
| 389 | 390 | _ = ios_async; |
| 390 | 391 | _ = ios_events; |
| 392 | _ = ios_delegate; | |
| 391 | 393 | } |
| 392 | 394 | |
| 393 | 395 | const testing = std.testing; |