| @@ -0,0 +1,668 @@ |
| 1 | +//! What the app was configured to allow, read from the file Swift reads. |
| 2 | +//! |
| 3 | +//! Every capability in `CraftApp.swift`'s dispatcher is gated on a |
| 4 | +//! `config.enable*` flag, and until now Zig had none of them. That gap was |
| 5 | +//! recorded twice as it was hit — `bridge_mobile_system.zig` for `enableShare`, |
| 6 | +//! `bridge_mobile_location.zig` for `enableGeolocation` — and worked around |
| 7 | +//! once, by reading an Info.plist key the generator writes from the same flag. |
| 8 | +//! This file closes it properly: 32 actions Zig already serves are gated in the |
| 9 | +//! spec and were being served unconditionally, and 33 of the 48 still on the |
| 10 | +//! shim are behind a flag rather than behind any Objective-C difficulty. |
| 11 | +//! |
| 12 | +//! ## One file, two readers, so the parse has to match |
| 13 | +//! |
| 14 | +//! `craft.config.json` is written by `packages/ios/src/index.ts:366` and |
| 15 | +//! bundled as an optional resource by `project.yml.template`. Swift decodes it |
| 16 | +//! out of the bundle at `CraftApp.swift:172-178`. Zig now decodes the same |
| 17 | +//! bytes — and it has to reach the *same verdict*, because the hand-off table |
| 18 | +//! means both runtimes serve actions in one process from one file. Two |
| 19 | +//! opinions about whether clipboard is enabled is one app that both allows and |
| 20 | +//! refuses the same capability depending on which arm answered. |
| 21 | +//! |
| 22 | +//! That is why the decode below is all-or-nothing rather than per-key, which |
| 23 | +//! looks like a bug until you check what Swift does. `CraftConfig`'s properties |
| 24 | +//! carry default values, but a synthesized `Decodable` does not use them: it |
| 25 | +//! calls `decode(Bool.self, forKey:)`, which *throws* when the key is missing. |
| 26 | +//! `try?` turns the throw into nil, and line 178 falls back to `CraftConfig()` |
| 27 | +//! — every flag false. So a config missing one of its 40 required keys disables |
| 28 | +//! all 35 capabilities, not the one key. Reading it per-key here would be more |
| 29 | +//! useful and would disagree with Swift on the same file. |
| 30 | +//! |
| 31 | +//! Each of these was verified against a real `swiftc` decode rather than |
| 32 | +//! assumed, because "faithful" is a claim about another language's behaviour: |
| 33 | +//! |
| 34 | +//! - a missing required key throws -> all false |
| 35 | +//! - `1` for a `Bool` throws -> all false (not coerced) |
| 36 | +//! - `null` for a `Bool` throws -> all false |
| 37 | +//! - `"true"` for a `Bool` throws -> all false |
| 38 | +//! - a non-string in `trustedOrigins` throws-> all false |
| 39 | +//! - an unknown extra key is ignored |
| 40 | +//! - `devServerURL` absent or null is fine (it is the one optional) |
| 41 | +//! |
| 42 | +//! ## What a disabled capability answers, and why it is not what Swift answers |
| 43 | +//! |
| 44 | +//! `CAPABILITY_DISABLED`, through the ordinary error path. |
| 45 | +//! |
| 46 | +//! Swift replies that on 5 of its 65 gated cases — `share`, `secureClear`, |
| 47 | +//! `getCurrentPosition`, `watchPosition`, `startLocationRecording`. The other |
| 48 | +//! **60** have no `else` at all: the guard fails, the case falls out of the |
| 49 | +//! switch, and the callback is never settled. The page's promise stays pending |
| 50 | +//! until `craft-bridge.js`'s timeout fires, thirty seconds later, with no |
| 51 | +//! indication that a configuration flag is why. |
| 52 | +//! |
| 53 | +//! Those 60 are not a contract to port. They are the same defect as the five |
| 54 | +//! `ota*` actions the conformance test already catches, at twelve times the |
| 55 | +//! scale, and the 5 correct sites say plainly what the intended answer was. So |
| 56 | +//! this diverges from the spec deliberately and in one direction: an action |
| 57 | +//! that Swift would hang, Zig refuses with a code naming the reason. |
| 58 | +//! |
| 59 | +//! ## Absent means off |
| 60 | +//! |
| 61 | +//! No bundled config file is every flag false, matching Swift's fallback. It is |
| 62 | +//! also the safe direction for what is really a permissions surface: a |
| 63 | +//! capability nobody configured is one nobody asked for. The cost is that a |
| 64 | +//! host app which never generated a config serves nothing, which is why the |
| 65 | +//! read logs once at warn rather than failing silently. |
| 66 | + |
| 67 | +const std = ; |
| 68 | +const builtin = ; |
| 69 | +const objc_runtime = ; |
| 70 | +const compat_mutex = ; |
| 71 | +const io_context = ; |
| 72 | + |
| 73 | +const objc = objc_runtime.objc; |
| 74 | +const is_darwin = builtin.target.os.tag.isDarwin(); |
| 75 | + |
| 76 | +/// A capability flag in `craft.config.json`. |
| 77 | +/// |
| 78 | +/// One member per `Bool` in Swift's `CraftConfig` that gates an action. |
| 79 | +/// `darkMode` is a `Bool` too but gates nothing, so it is absent here and |
| 80 | +/// present in `required_keys` — the decode needs it, the gate does not. |
| 81 | +pub const Feature = enum { |
| 82 | + ar, |
| 83 | + audio_recording, |
| 84 | + background_location, |
| 85 | + background_tasks, |
| 86 | + biometric, |
| 87 | + bluetooth, |
| 88 | + calendar, |
| 89 | + camera, |
| 90 | + clipboard, |
| 91 | + contacts, |
| 92 | + deep_links, |
| 93 | + file_download, |
| 94 | + file_picker, |
| 95 | + geolocation, |
| 96 | + haptics, |
| 97 | + health_kit, |
| 98 | + in_app_purchase, |
| 99 | + keep_awake, |
| 100 | + live_activities, |
| 101 | + local_database, |
| 102 | + local_notifications, |
| 103 | + ml_kit, |
| 104 | + motion_sensors, |
| 105 | + nfc, |
| 106 | + orientation_lock, |
| 107 | + pdf_viewer, |
| 108 | + push_notifications, |
| 109 | + qr_scanner, |
| 110 | + screen_capture, |
| 111 | + secure_storage, |
| 112 | + share, |
| 113 | + social_auth, |
| 114 | + speech_recognition, |
| 115 | + video_recording, |
| 116 | + watch_app, |
| 117 | + |
| 118 | + /// The exact key in `craft.config.json`. |
| 119 | + /// |
| 120 | + /// Spelled out rather than derived from the member name: `enableNFC`, |
| 121 | + /// `enableAR`, `enablePDFViewer` and `enableMLKit` are not the |
| 122 | + /// camel-casing of `nfc`, `ar`, `pdf_viewer` or `ml_kit`, and a derivation |
| 123 | + /// that got them wrong would look up a key that is not there — which, under |
| 124 | + /// the all-or-nothing rule above, disables the entire app rather than |
| 125 | + /// failing visibly. |
| 126 | + pub fn jsonKey(self: Feature) []const u8 { |
| 127 | + return switch (self) { |
| 128 | + .ar => "enableAR", |
| 129 | + .audio_recording => "enableAudioRecording", |
| 130 | + .background_location => "enableBackgroundLocation", |
| 131 | + .background_tasks => "enableBackgroundTasks", |
| 132 | + .biometric => "enableBiometric", |
| 133 | + .bluetooth => "enableBluetooth", |
| 134 | + .calendar => "enableCalendar", |
| 135 | + .camera => "enableCamera", |
| 136 | + .clipboard => "enableClipboard", |
| 137 | + .contacts => "enableContacts", |
| 138 | + .deep_links => "enableDeepLinks", |
| 139 | + .file_download => "enableFileDownload", |
| 140 | + .file_picker => "enableFilePicker", |
| 141 | + .geolocation => "enableGeolocation", |
| 142 | + .haptics => "enableHaptics", |
| 143 | + .health_kit => "enableHealthKit", |
| 144 | + .in_app_purchase => "enableInAppPurchase", |
| 145 | + .keep_awake => "enableKeepAwake", |
| 146 | + .live_activities => "enableLiveActivities", |
| 147 | + .local_database => "enableLocalDatabase", |
| 148 | + .local_notifications => "enableLocalNotifications", |
| 149 | + .ml_kit => "enableMLKit", |
| 150 | + .motion_sensors => "enableMotionSensors", |
| 151 | + .nfc => "enableNFC", |
| 152 | + .orientation_lock => "enableOrientationLock", |
| 153 | + .pdf_viewer => "enablePDFViewer", |
| 154 | + .push_notifications => "enablePushNotifications", |
| 155 | + .qr_scanner => "enableQRScanner", |
| 156 | + .screen_capture => "enableScreenCapture", |
| 157 | + .secure_storage => "enableSecureStorage", |
| 158 | + .share => "enableShare", |
| 159 | + .social_auth => "enableSocialAuth", |
| 160 | + .speech_recognition => "enableSpeechRecognition", |
| 161 | + .video_recording => "enableVideoRecording", |
| 162 | + .watch_app => "enableWatchApp", |
| 163 | + }; |
| 164 | + } |
| 165 | +}; |
| 166 | + |
| 167 | +/// Which flags are on. |
| 168 | +pub const Flags = std.EnumSet(Feature); |
| 169 | + |
| 170 | +/// The keys Swift's synthesized `init(from:)` calls `decode` for. |
| 171 | +/// |
| 172 | +/// Missing any one of them throws, and the throw disables everything. Only |
| 173 | +/// `devServerURL` is optional (`String?` -> `decodeIfPresent`), so it is not |
| 174 | +/// listed. |
| 175 | +const string_keys = [_][]const u8{ "appName", "bundleId", "backgroundColor" }; |
| 176 | +const extra_bool_keys = [_][]const u8{"darkMode"}; |
| 177 | +const array_of_string_keys = [_][]const u8{"trustedOrigins"}; |
| 178 | +const optional_string_keys = [_][]const u8{"devServerURL"}; |
| 179 | + |
| 180 | +/// Decode `json` the way Swift decodes it. |
| 181 | +/// |
| 182 | +/// Returns the empty set for anything Swift's `JSONDecoder` would throw on, |
| 183 | +/// which is the same value line 178 falls back to. Pure and allocator-taking so |
| 184 | +/// the faithfulness above is testable without a bundle, a simulator, or a run |
| 185 | +/// loop. |
| 186 | +pub fn parse(allocator: std.mem.Allocator, json: []const u8) Flags { |
| 187 | + var parsed = std.json.parseFromSlice(std.json.Value, allocator, json, .{ |
| 188 | + // Swift keeps the *first* of a duplicated key and does not throw — |
| 189 | + // verified, because Zig's default here is `.error`, which would have |
| 190 | + // made a duplicate disable the whole app where Swift shrugs and reads |
| 191 | + // the first value. A hand-edited config that pastes a flag twice is |
| 192 | + // exactly the case this covers. |
| 193 | + .duplicate_field_behavior = .use_first, |
| 194 | + }) catch { |
| 195 | + return Flags.empty; |
| 196 | + }; |
| 197 | + defer parsed.deinit(); |
| 198 | + |
| 199 | + const root = switch (parsed.value) { |
| 200 | + .object => |o| o, |
| 201 | + // A top-level array or scalar is not a `CraftConfig`. |
| 202 | + else => return Flags.empty, |
| 203 | + }; |
| 204 | + |
| 205 | + inline for (string_keys) |key| { |
| 206 | + const v = root.get(key) orelse return Flags.empty; |
| 207 | + if (v != .string) return Flags.empty; |
| 208 | + } |
| 209 | + |
| 210 | + inline for (extra_bool_keys) |key| { |
| 211 | + const v = root.get(key) orelse return Flags.empty; |
| 212 | + if (v != .bool) return Flags.empty; |
| 213 | + } |
| 214 | + |
| 215 | + inline for (array_of_string_keys) |key| { |
| 216 | + const v = root.get(key) orelse return Flags.empty; |
| 217 | + if (v != .array) return Flags.empty; |
| 218 | + for (v.array.items) |item| { |
| 219 | + if (item != .string) return Flags.empty; |
| 220 | + } |
| 221 | + } |
| 222 | + |
| 223 | + inline for (optional_string_keys) |key| { |
| 224 | + // Absent is fine; so is an explicit null. Present and not a string is |
| 225 | + // a `typeMismatch`, which throws like any other. |
| 226 | + if (root.get(key)) |v| { |
| 227 | + if (v != .string and v != .null) return Flags.empty; |
| 228 | + } |
| 229 | + } |
| 230 | + |
| 231 | + var flags_out = Flags.empty; |
| 232 | + inline for (comptime std.enums.values(Feature)) |feature| { |
| 233 | + const v = root.get(feature.jsonKey()) orelse return Flags.empty; |
| 234 | + // `.bool` only. A JSON `1` is `.integer` here and `NSNumber`-bridged in |
| 235 | + // Swift, and Swift throws on it rather than coercing — verified, not |
| 236 | + // assumed, because coercing would be the obvious thing to write. |
| 237 | + if (v != .bool) return Flags.empty; |
| 238 | + if (v.bool) flags_out.insert(feature); |
| 239 | + } |
| 240 | + return flags_out; |
| 241 | +} |
| 242 | + |
| 243 | +var cache_mutex: compat_mutex.Mutex = .{}; |
| 244 | +var cached: ?Flags = null; |
| 245 | + |
| 246 | +/// The bundled configuration, read once. |
| 247 | +/// |
| 248 | +/// Swift reads it once in `Coordinator.init` and holds it for the life of the |
| 249 | +/// process; a file that changed underneath would give the two runtimes |
| 250 | +/// different answers, which is exactly what this file exists to prevent. |
| 251 | +pub fn flags() Flags { |
| 252 | + cache_mutex.lock(); |
| 253 | + defer cache_mutex.unlock(); |
| 254 | + if (cached) |f| return f; |
| 255 | + const f = readFromBundle(); |
| 256 | + cached = f; |
| 257 | + return f; |
| 258 | +} |
| 259 | + |
| 260 | +/// Whether `feature` was enabled for this app. |
| 261 | +pub fn isEnabled(feature: Feature) bool { |
| 262 | + return flags().contains(feature); |
| 263 | +} |
| 264 | + |
| 265 | +/// A config larger than this is not one we wrote. |
| 266 | +/// |
| 267 | +/// The generator's output is about 1.5 KiB. The cap exists so a resource that |
| 268 | +/// happens to be named `craft.config.json` and is actually a video cannot be |
| 269 | +/// read into memory before being rejected as unparseable. |
| 270 | +const max_config_bytes = 1 << 20; |
| 271 | + |
| 272 | +fn readFromBundle() Flags { |
| 273 | + if (!is_darwin) return Flags.empty; |
| 274 | + |
| 275 | + const path = bundleResourcePath() orelse { |
| 276 | + std.log.warn( |
| 277 | + "ios config: no craft.config.json in the main bundle; " ++ |
| 278 | + "every capability is disabled, matching Swift's fallback", |
| 279 | + .{}, |
| 280 | + ); |
| 281 | + return Flags.empty; |
| 282 | + }; |
| 283 | + |
| 284 | + const allocator = std.heap.c_allocator; |
| 285 | + const bytes = readConfigFile(allocator, std.mem.span(path)) catch { |
| 286 | + // Bundled but unreadable. Swift's `try?` flattens this into the same |
| 287 | + // all-false as "no file", and so does this — but it says which one |
| 288 | + // happened, because they call for different fixes. |
| 289 | + return Flags.empty; |
| 290 | + } orelse return Flags.empty; |
| 291 | + defer allocator.free(bytes); |
| 292 | + |
| 293 | + const f = parse(allocator, bytes); |
| 294 | + if (f.count() == 0) { |
| 295 | + // Not necessarily an error — a config with every flag off is legal and |
| 296 | + // is the generator's default. Said once, at info, because the |
| 297 | + // alternative reading (a malformed file disabling everything) is |
| 298 | + // otherwise indistinguishable from it in the field. |
| 299 | + std.log.info( |
| 300 | + "ios config: craft.config.json enables no capabilities; " ++ |
| 301 | + "if that is unexpected, check every required key is present, " ++ |
| 302 | + "since one missing key disables all of them", |
| 303 | + .{}, |
| 304 | + ); |
| 305 | + } |
| 306 | + return f; |
| 307 | +} |
| 308 | + |
| 309 | +/// The config file's bytes, or null if there is no file there. |
| 310 | +/// |
| 311 | +/// Takes the path rather than finding it, so the host tests can drive the read |
| 312 | +/// against a real file on any platform instead of only against an app bundle |
| 313 | +/// that exists on exactly one. |
| 314 | +fn readConfigFile(allocator: std.mem.Allocator, path: []const u8) !?[]u8 { |
| 315 | + const io = io_context.get(); |
| 316 | + |
| 317 | + const file = std.Io.Dir.cwd().openFile(io, path, .{}) catch |err| switch (err) { |
| 318 | + error.FileNotFound => return null, |
| 319 | + else => { |
| 320 | + std.log.warn("ios config: could not open '{s}': {}", .{ path, err }); |
| 321 | + return error.ConfigUnreadable; |
| 322 | + }, |
| 323 | + }; |
| 324 | + defer file.close(io); |
| 325 | + |
| 326 | + const info = file.stat(io) catch |err| { |
| 327 | + std.log.warn("ios config: could not stat '{s}': {}", .{ path, err }); |
| 328 | + return error.ConfigUnreadable; |
| 329 | + }; |
| 330 | + |
| 331 | + if (info.size > max_config_bytes) { |
| 332 | + std.log.warn( |
| 333 | + "ios config: craft.config.json is {d} bytes, over the {d}-byte ceiling; " ++ |
| 334 | + "refusing to read it", |
| 335 | + .{ info.size, max_config_bytes }, |
| 336 | + ); |
| 337 | + return error.ConfigUnreadable; |
| 338 | + } |
| 339 | + |
| 340 | + const buf = try allocator.alloc(u8, @intCast(info.size)); |
| 341 | + errdefer allocator.free(buf); |
| 342 | + |
| 343 | + var read: usize = 0; |
| 344 | + while (read < buf.len) { |
| 345 | + const n = file.readStreaming(io, &.{buf[read..]}) catch |err| switch (err) { |
| 346 | + error.EndOfStream => break, |
| 347 | + else => { |
| 348 | + std.log.warn("ios config: could not read '{s}': {}", .{ path, err }); |
| 349 | + return error.ConfigUnreadable; |
| 350 | + }, |
| 351 | + }; |
| 352 | + if (n == 0) break; |
| 353 | + read += n; |
| 354 | + } |
| 355 | + |
| 356 | + // A short read is not a truncated config to parse leniently: JSON that |
| 357 | + // stops early is either invalid (all-false, correctly) or — worse — |
| 358 | + // valid-looking with the tail of the flags missing, which under the |
| 359 | + // all-or-nothing rule is also all-false. Refusing here says why. |
| 360 | + if (read != buf.len) { |
| 361 | + std.log.warn( |
| 362 | + "ios config: read {d} of {d} bytes from '{s}'", |
| 363 | + .{ read, buf.len, path }, |
| 364 | + ); |
| 365 | + allocator.free(buf); |
| 366 | + return error.ConfigUnreadable; |
| 367 | + } |
| 368 | + |
| 369 | + return buf; |
| 370 | +} |
| 371 | + |
| 372 | +/// `[[NSBundle mainBundle] pathForResource:@"craft.config" ofType:@"json"]`. |
| 373 | +fn bundleResourcePath() ?[*:0]const u8 { |
| 374 | + const NSBundle = objc.objc_getClass("NSBundle") orelse return null; |
| 375 | + const sel_main = objc.sel_registerName("mainBundle") orelse return null; |
| 376 | + const bundle = objc.msgSendId(NSBundle, sel_main) orelse return null; |
| 377 | + |
| 378 | + const NSString = objc.objc_getClass("NSString") orelse return null; |
| 379 | + const sel_string = objc.sel_registerName("stringWithUTF8String:") orelse return null; |
| 380 | + const name = objc.msgSendId1(NSString, sel_string, @as([*:0]const u8, "craft.config")) orelse |
| 381 | + return null; |
| 382 | + const ext = objc.msgSendId1(NSString, sel_string, @as([*:0]const u8, "json")) orelse |
| 383 | + return null; |
| 384 | + |
| 385 | + const sel_path = objc.sel_registerName("pathForResource:ofType:") orelse return null; |
| 386 | + const path = objc.msgSendId2(bundle, sel_path, name, ext) orelse return null; |
| 387 | + |
| 388 | + return objc.getNSStringUTF8(path); |
| 389 | +} |
| 390 | + |
| 391 | +/// The feature an action is gated on, or null if Zig serving it is ungated. |
| 392 | +/// |
| 393 | +/// Null means one of two different things, and the conformance test in |
| 394 | +/// `test/ios_config_gate_test.zig` is what keeps them from being confused: |
| 395 | +/// either the spec gates the action on nothing, or Zig does not serve it and |
| 396 | +/// the shim applies its own gate. A Zig-served action that the spec gates and |
| 397 | +/// this table omits fails that test rather than shipping ungated, which is the |
| 398 | +/// state all 32 entries below were in before this file existed. |
| 399 | +pub fn gateFor(action: []const u8) ?Feature { |
| 400 | + const table = comptime [_]struct { []const u8, Feature }{ |
| 401 | + .{ "addContact", .contacts }, |
| 402 | + .{ "cancelAllBackgroundTasks", .background_tasks }, |
| 403 | + .{ "cancelAllNotifications", .local_notifications }, |
| 404 | + .{ "cancelBackgroundTask", .background_tasks }, |
| 405 | + .{ "cancelNotification", .local_notifications }, |
| 406 | + .{ "clipboardRead", .clipboard }, |
| 407 | + .{ "clipboardWrite", .clipboard }, |
| 408 | + .{ "createCalendarEvent", .calendar }, |
| 409 | + .{ "dbExecute", .local_database }, |
| 410 | + .{ "dbQuery", .local_database }, |
| 411 | + .{ "deleteCalendarEvent", .calendar }, |
| 412 | + .{ "getCalendarEvents", .calendar }, |
| 413 | + .{ "getContacts", .contacts }, |
| 414 | + .{ "getCurrentPosition", .geolocation }, |
| 415 | + .{ "getPendingNotifications", .local_notifications }, |
| 416 | + .{ "haptic", .haptics }, |
| 417 | + .{ "lockOrientation", .orientation_lock }, |
| 418 | + .{ "openCamera", .camera }, |
| 419 | + .{ "pickContact", .contacts }, |
| 420 | + .{ "pickFile", .file_picker }, |
| 421 | + .{ "pickImage", .camera }, |
| 422 | + .{ "saveFile", .file_download }, |
| 423 | + .{ "scheduleBackgroundTask", .background_tasks }, |
| 424 | + .{ "secureClear", .secure_storage }, |
| 425 | + .{ "secureGet", .secure_storage }, |
| 426 | + .{ "secureRemove", .secure_storage }, |
| 427 | + .{ "secureSet", .secure_storage }, |
| 428 | + .{ "setKeepAwake", .keep_awake }, |
| 429 | + .{ "share", .share }, |
| 430 | + .{ "startMotionUpdates", .motion_sensors }, |
| 431 | + .{ "unlockOrientation", .orientation_lock }, |
| 432 | + .{ "watchPosition", .geolocation }, |
| 433 | + }; |
| 434 | + inline for (table) |entry| { |
| 435 | + if (std.mem.eql(u8, action, entry[0])) return entry[1]; |
| 436 | + } |
| 437 | + return null; |
| 438 | +} |
| 439 | + |
| 440 | +const testing = std.testing; |
| 441 | + |
| 442 | +/// A config with every required key present, so a test can remove exactly one |
| 443 | +/// thing and see the effect of removing that one thing. |
| 444 | +fn completeConfig(allocator: std.mem.Allocator, comptime overrides: []const u8) ![]u8 { |
| 445 | + @setEvalBranchQuota(200_000); |
| 446 | + var buf: std.ArrayListUnmanaged(u8) = .empty; |
| 447 | + errdefer buf.deinit(allocator); |
| 448 | + |
| 449 | + try buf.append(allocator, '{'); |
| 450 | + if (overrides.len != 0) { |
| 451 | + try buf.appendSlice(allocator, overrides); |
| 452 | + try buf.append(allocator, ','); |
| 453 | + } |
| 454 | + |
| 455 | + // Every required key, except one the caller already spelled. Emitting it |
| 456 | + // twice and relying on which duplicate wins would make each test depend on |
| 457 | + // `duplicate_field_behavior`, so a change to that option would quietly |
| 458 | + // rewrite what these tests assert instead of failing one of them. |
| 459 | + inline for (comptime string_keys ++ extra_bool_keys ++ array_of_string_keys) |key| { |
| 460 | + if (comptime !mentions(overrides, key)) { |
| 461 | + try buf.appendSlice(allocator, "\"" ++ key ++ "\":" ++ comptime placeholderFor(key) ++ ","); |
| 462 | + } |
| 463 | + } |
| 464 | + inline for (comptime std.enums.values(Feature)) |feature| { |
| 465 | + if (comptime !mentions(overrides, feature.jsonKey())) { |
| 466 | + try buf.appendSlice(allocator, "\""); |
| 467 | + try buf.appendSlice(allocator, feature.jsonKey()); |
| 468 | + try buf.appendSlice(allocator, "\":true,"); |
| 469 | + } |
| 470 | + } |
| 471 | + if (buf.items[buf.items.len - 1] == ',') _ = buf.pop(); |
| 472 | + try buf.append(allocator, '}'); |
| 473 | + return buf.toOwnedSlice(allocator); |
| 474 | +} |
| 475 | + |
| 476 | +/// Whether `overrides` already spells `"key":`. |
| 477 | +fn mentions(comptime overrides: []const u8, comptime key: []const u8) bool { |
| 478 | + return std.mem.indexOf(u8, overrides, "\"" ++ key ++ "\":") != null; |
| 479 | +} |
| 480 | + |
| 481 | +fn placeholderFor(comptime key: []const u8) []const u8 { |
| 482 | + if (std.mem.eql(u8, key, "trustedOrigins")) return "[]"; |
| 483 | + if (std.mem.eql(u8, key, "darkMode")) return "true"; |
| 484 | + return "\"T\""; |
| 485 | +} |
| 486 | + |
| 487 | +test "a complete config enables exactly the flags it sets" { |
| 488 | + const json = try completeConfig(testing.allocator, "\"enableShare\":false,\"enableNFC\":false"); |
| 489 | + defer testing.allocator.free(json); |
| 490 | + |
| 491 | + const f = parse(testing.allocator, json); |
| 492 | + |
| 493 | + // The two overrides land last, and `std.json` keeps the last value for a |
| 494 | + // duplicated key, so these are the false ones. |
| 495 | + try testing.expect(!f.contains(.share)); |
| 496 | + try testing.expect(!f.contains(.nfc)); |
| 497 | + try testing.expect(f.contains(.clipboard)); |
| 498 | + try testing.expect(f.contains(.ml_kit)); |
| 499 | + try testing.expectEqual(std.enums.values(Feature).len - 2, f.count()); |
| 500 | +} |
| 501 | + |
| 502 | +test "one missing key disables every capability, as Swift's decoder does" { |
| 503 | + // The behaviour this whole file is shaped around, and the one that looks |
| 504 | + // like a bug: `CraftConfig`'s properties have defaults, but a synthesized |
| 505 | + // `Decodable` never uses them — it throws `keyNotFound`, `try?` swallows |
| 506 | + // it, and `CraftApp.swift:178` falls back to an all-false `CraftConfig()`. |
| 507 | + // Verified against a real swiftc decode before being written here. |
| 508 | + const complete = try completeConfig(testing.allocator, ""); |
| 509 | + defer testing.allocator.free(complete); |
| 510 | + try testing.expectEqual(std.enums.values(Feature).len, parse(testing.allocator, complete).count()); |
| 511 | + |
| 512 | + // Drop `enableHaptics` alone. Per-key decoding would lose one capability. |
| 513 | + const needle = ",\"enableHaptics\":true"; |
| 514 | + const at = std.mem.indexOf(u8, complete, needle).?; |
| 515 | + const without = try std.mem.concat(testing.allocator, u8, &.{ |
| 516 | + complete[0..at], complete[at + needle.len ..], |
| 517 | + }); |
| 518 | + defer testing.allocator.free(without); |
| 519 | + |
| 520 | + try testing.expectEqual(@as(usize, 0), parse(testing.allocator, without).count()); |
| 521 | +} |
| 522 | + |
| 523 | +test "a required non-capability key is required too" { |
| 524 | + // `darkMode`, `appName`, `bundleId`, `backgroundColor` and `trustedOrigins` |
| 525 | + // gate nothing, so it is tempting to ignore them. Swift decodes all five, |
| 526 | + // and throwing on them is how a config that is merely *stale* — written by |
| 527 | + // an older generator that had fewer keys — disables the app rather than |
| 528 | + // half-enabling it. |
| 529 | + inline for (.{ "\"darkMode\":true,", "\"appName\":\"T\",", "\"trustedOrigins\":[]," }) |needle| { |
| 530 | + const complete = try completeConfig(testing.allocator, ""); |
| 531 | + defer testing.allocator.free(complete); |
| 532 | + const at = std.mem.indexOf(u8, complete, needle).?; |
| 533 | + const without = try std.mem.concat(testing.allocator, u8, &.{ |
| 534 | + complete[0..at], complete[at + needle.len ..], |
| 535 | + }); |
| 536 | + defer testing.allocator.free(without); |
| 537 | + try testing.expectEqual(@as(usize, 0), parse(testing.allocator, without).count()); |
| 538 | + } |
| 539 | +} |
| 540 | + |
| 541 | +test "a wrongly typed flag is a throw, not a coercion" { |
| 542 | + // Each of these was run through `swiftc` before being pinned. `1` is the |
| 543 | + // one worth stating: JSON has no bool/number distinction in many writers, |
| 544 | + // Foundation bridges both to `NSNumber`, and coercing would be the |
| 545 | + // reasonable-looking choice — but Swift throws, so an app whose config was |
| 546 | + // hand-edited to `"enableCamera": 1` has *no* capabilities, not camera. |
| 547 | + inline for (.{ "1", "0", "null", "\"true\"", "[]" }) |bad| { |
| 548 | + const json = try completeConfig(testing.allocator, "\"enableCamera\":" ++ bad); |
| 549 | + defer testing.allocator.free(json); |
| 550 | + try testing.expectEqual(@as(usize, 0), parse(testing.allocator, json).count()); |
| 551 | + } |
| 552 | +} |
| 553 | + |
| 554 | +test "an unknown key is ignored, and devServerURL may be absent or null" { |
| 555 | + const with_extra = try completeConfig(testing.allocator, "\"somethingNewer\":42"); |
| 556 | + defer testing.allocator.free(with_extra); |
| 557 | + try testing.expectEqual(std.enums.values(Feature).len, parse(testing.allocator, with_extra).count()); |
| 558 | + |
| 559 | + // `devServerURL` is the one `String?` in the struct, so it is the one key |
| 560 | + // whose absence is not a throw. |
| 561 | + const with_null = try completeConfig(testing.allocator, "\"devServerURL\":null"); |
| 562 | + defer testing.allocator.free(with_null); |
| 563 | + try testing.expectEqual(std.enums.values(Feature).len, parse(testing.allocator, with_null).count()); |
| 564 | + |
| 565 | + const with_bad = try completeConfig(testing.allocator, "\"devServerURL\":7"); |
| 566 | + defer testing.allocator.free(with_bad); |
| 567 | + try testing.expectEqual(@as(usize, 0), parse(testing.allocator, with_bad).count()); |
| 568 | +} |
| 569 | + |
| 570 | +test "trustedOrigins is checked element by element" { |
| 571 | + // `[String]` throws on the first non-string element, so an array that is |
| 572 | + // mostly strings still disables the app. |
| 573 | + const bad = try completeConfig(testing.allocator, "\"trustedOrigins\":[\"https://a\",3]"); |
| 574 | + defer testing.allocator.free(bad); |
| 575 | + try testing.expectEqual(@as(usize, 0), parse(testing.allocator, bad).count()); |
| 576 | + |
| 577 | + const good = try completeConfig(testing.allocator, "\"trustedOrigins\":[\"https://a\"]"); |
| 578 | + defer testing.allocator.free(good); |
| 579 | + try testing.expectEqual(std.enums.values(Feature).len, parse(testing.allocator, good).count()); |
| 580 | +} |
| 581 | + |
| 582 | +test "malformed or non-object JSON is all-false rather than a crash" { |
| 583 | + inline for (.{ "", "{", "[]", "null", "\"a string\"", "{\"appName\":}" }) |bad| { |
| 584 | + try testing.expectEqual(@as(usize, 0), parse(testing.allocator, bad).count()); |
| 585 | + } |
| 586 | +} |
| 587 | + |
| 588 | +test "every capability key is spelled the way Swift spells it" { |
| 589 | + // A key that does not exist in the file reads as missing, and a missing key |
| 590 | + // disables all 35 — so a single typo here is not a one-flag bug, it is an |
| 591 | + // app that serves nothing. The four irregular ones are the risk: |
| 592 | + // camel-casing the enum member would produce `enableNfc`, `enableAr`, |
| 593 | + // `enablePdfViewer` and `enableMlKit`, none of which are in the file. |
| 594 | + try testing.expectEqualStrings("enableNFC", Feature.nfc.jsonKey()); |
| 595 | + try testing.expectEqualStrings("enableAR", Feature.ar.jsonKey()); |
| 596 | + try testing.expectEqualStrings("enablePDFViewer", Feature.pdf_viewer.jsonKey()); |
| 597 | + try testing.expectEqualStrings("enableMLKit", Feature.ml_kit.jsonKey()); |
| 598 | + |
| 599 | + @setEvalBranchQuota(10_000); |
| 600 | + inline for (comptime std.enums.values(Feature)) |feature| { |
| 601 | + const key = feature.jsonKey(); |
| 602 | + try testing.expect(std.mem.startsWith(u8, key, "enable")); |
| 603 | + // Distinctness: two members sharing a key would silently make one of |
| 604 | + // them read the other's flag. |
| 605 | + inline for (comptime std.enums.values(Feature)) |other| { |
| 606 | + if (feature != other) try testing.expect(!std.mem.eql(u8, key, other.jsonKey())); |
| 607 | + } |
| 608 | + } |
| 609 | +} |
| 610 | + |
| 611 | +test "gateFor answers for a served action and stays quiet for the rest" { |
| 612 | + try testing.expectEqual(Feature.clipboard, gateFor("clipboardRead").?); |
| 613 | + try testing.expectEqual(Feature.camera, gateFor("pickImage").?); |
| 614 | + try testing.expectEqual(Feature.secure_storage, gateFor("secureSet").?); |
| 615 | + |
| 616 | + // `openURL` is served and ungated in the spec; `startAR` is gated but not |
| 617 | + // served here, so the shim's own gate applies and this must not answer. |
| 618 | + try testing.expect(gateFor("openURL") == null); |
| 619 | + try testing.expect(gateFor("startAR") == null); |
| 620 | + try testing.expect(gateFor("") == null); |
| 621 | +} |
| 622 | + |
| 623 | +test "a duplicated key keeps the first value, as Swift does" { |
| 624 | + // Zig's default for a repeated field is `error.DuplicateField`, which would |
| 625 | + // have disabled every capability over a config that pastes one flag twice. |
| 626 | + // Swift reads the first and carries on — verified with swiftc — so the |
| 627 | + // parse opts into `.use_first`. Without that option this test fails, which |
| 628 | + // is the point of having it. |
| 629 | + const json = try completeConfig(testing.allocator, "\"enableShare\":false"); |
| 630 | + defer testing.allocator.free(json); |
| 631 | + |
| 632 | + const at = std.mem.indexOf(u8, json, "\"enableShare\":false").?; |
| 633 | + const with_dup = try std.mem.concat(testing.allocator, u8, &.{ |
| 634 | + json[0 .. at + "\"enableShare\":false".len], |
| 635 | + ",\"enableShare\":true", |
| 636 | + json[at + "\"enableShare\":false".len ..], |
| 637 | + }); |
| 638 | + defer testing.allocator.free(with_dup); |
| 639 | + |
| 640 | + const f = parse(testing.allocator, with_dup); |
| 641 | + try testing.expect(!f.contains(.share)); // first wins, not last |
| 642 | + try testing.expectEqual(std.enums.values(Feature).len - 1, f.count()); |
| 643 | +} |
| 644 | + |
| 645 | +test "the config file read distinguishes absent from unreadable" { |
| 646 | + // Swift's `try?` flattens both into the same all-false. This does too, at |
| 647 | + // the gate — but the read itself keeps them apart, because "no config was |
| 648 | + // generated" and "the config is there and I could not open it" call for |
| 649 | + // different fixes and only one of them is normal. |
| 650 | + const io = io_context.get(); |
| 651 | + const missing = try readConfigFile(testing.allocator, "craft-config-does-not-exist.json"); |
| 652 | + try testing.expect(missing == null); |
| 653 | + |
| 654 | + const name = "craft-config-read-probe.json"; |
| 655 | + const file = try std.Io.Dir.cwd().createFile(io, name, .{}); |
| 656 | + defer std.Io.Dir.cwd().deleteFile(io, name) catch {}; |
| 657 | + { |
| 658 | + defer file.close(io); |
| 659 | + try file.writeStreamingAll(io, "{\"appName\":\"T\"}"); |
| 660 | + } |
| 661 | + |
| 662 | + const bytes = (try readConfigFile(testing.allocator, name)).?; |
| 663 | + defer testing.allocator.free(bytes); |
| 664 | + try testing.expectEqualStrings("{\"appName\":\"T\"}", bytes); |
| 665 | + |
| 666 | + // And a real file that is not a config is all-false rather than a crash. |
| 667 | + try testing.expectEqual(@as(usize, 0), parse(testing.allocator, bytes).count()); |
| 668 | +} |