also looking at this
fix(capabilities): stop the channel manifest claiming knowledge it does not have
#68
15 files
+227
-67
| @@ -91,54 +91,103 @@ pub const NamespaceDecl = struct { | ||
| 91 | 91 | /// deriving from: `craft:menu:action` is colon-separated and `craft:powerSleep` |
| 92 | 92 | /// is camel-cased, and both are load-bearing spellings a page already listens |
| 93 | 93 | /// for. Renaming them to be consistent would break every app. |
| 94 | /// | |
| 95 | /// `test/capabilities_test.zig` checks this list against every `_evt('craft:…')` | |
| 96 | /// in `craft-bridge.js`. It shipped with 22 of the 44 and claimed to be | |
| 97 | /// complete, which is the same overclaiming this whole mechanism exists to | |
| 98 | /// stop — so now the claim is enforced. | |
| 94 | 99 | pub const Channel = enum { |
| 100 | bluetooth_deviceconnected, | |
| 101 | bluetooth_devicedisconnected, | |
| 102 | bluetooth_devicefound, | |
| 103 | bonjour_found, | |
| 104 | bonjour_lost, | |
| 105 | deeplink, | |
| 106 | filedrop, | |
| 107 | fs_change, | |
| 108 | handoff_incoming, | |
| 109 | iap_failed, | |
| 110 | iap_productsloaded, | |
| 111 | iap_purchased, | |
| 112 | iap_restored, | |
| 113 | localserver_request, | |
| 114 | location_authchanged, | |
| 115 | location_error, | |
| 116 | location_update, | |
| 95 | 117 | menu_action, |
| 96 | theme, | |
| 97 | settings_open, | |
| 98 | window_focus, | |
| 99 | window_blur, | |
| 100 | window_resize, | |
| 101 | window_move, | |
| 118 | menubar_statechange, | |
| 119 | midi_message, | |
| 120 | networkchange, | |
| 121 | powersleep, | |
| 122 | powerwake, | |
| 102 | 123 | screen_change, |
| 103 | deep_link, | |
| 104 | file_drop, | |
| 124 | screensharing_change, | |
| 125 | serial_data, | |
| 126 | servicemenu_invoked, | |
| 127 | settings_open, | |
| 105 | 128 | shortcut, |
| 106 | fs_change, | |
| 107 | power_sleep, | |
| 108 | power_wake, | |
| 109 | network_change, | |
| 110 | bluetooth_device_found, | |
| 111 | bluetooth_device_connected, | |
| 112 | bluetooth_device_disconnected, | |
| 129 | shortcut_error, | |
| 130 | speechrecognition_final, | |
| 131 | speechrecognition_partial, | |
| 132 | theme, | |
| 113 | 133 | touchbar_action, |
| 114 | serial_data, | |
| 115 | speech_partial, | |
| 116 | speech_final, | |
| 134 | tray_menuaction, | |
| 135 | updateavailable, | |
| 136 | updatedownloaded, | |
| 137 | window_blur, | |
| 138 | window_close, | |
| 139 | window_focus, | |
| 140 | window_minimize, | |
| 141 | window_move, | |
| 142 | window_resize, | |
| 143 | window_restore, | |
| 117 | 144 | |
| 118 | 145 | pub fn eventName(self: Channel) []const u8 { |
| 119 | 146 | return switch (self) { |
| 147 | .bluetooth_deviceconnected => "craft:bluetooth:deviceConnected", | |
| 148 | .bluetooth_devicedisconnected => "craft:bluetooth:deviceDisconnected", | |
| 149 | .bluetooth_devicefound => "craft:bluetooth:deviceFound", | |
| 150 | .bonjour_found => "craft:bonjour:found", | |
| 151 | .bonjour_lost => "craft:bonjour:lost", | |
| 152 | .deeplink => "craft:deepLink", | |
| 153 | .filedrop => "craft:fileDrop", | |
| 154 | .fs_change => "craft:fs:change", | |
| 155 | .handoff_incoming => "craft:handoff:incoming", | |
| 156 | .iap_failed => "craft:iap:failed", | |
| 157 | .iap_productsloaded => "craft:iap:productsLoaded", | |
| 158 | .iap_purchased => "craft:iap:purchased", | |
| 159 | .iap_restored => "craft:iap:restored", | |
| 160 | .localserver_request => "craft:localServer:request", | |
| 161 | .location_authchanged => "craft:location:authChanged", | |
| 162 | .location_error => "craft:location:error", | |
| 163 | .location_update => "craft:location:update", | |
| 120 | 164 | .menu_action => "craft:menu:action", |
| 121 | .theme => "craft:theme", | |
| 122 | .settings_open => "craft:settings:open", | |
| 123 | .window_focus => "craft:window:focus", | |
| 124 | .window_blur => "craft:window:blur", | |
| 125 | .window_resize => "craft:window:resize", | |
| 126 | .window_move => "craft:window:move", | |
| 165 | .menubar_statechange => "craft:menubar:stateChange", | |
| 166 | .midi_message => "craft:midi:message", | |
| 167 | .networkchange => "craft:networkChange", | |
| 168 | .powersleep => "craft:powerSleep", | |
| 169 | .powerwake => "craft:powerWake", | |
| 127 | 170 | .screen_change => "craft:screen:change", |
| 128 | .deep_link => "craft:deepLink", | |
| 129 | .file_drop => "craft:fileDrop", | |
| 171 | .screensharing_change => "craft:screenSharing:change", | |
| 172 | .serial_data => "craft:serial:data", | |
| 173 | .servicemenu_invoked => "craft:serviceMenu:invoked", | |
| 174 | .settings_open => "craft:settings:open", | |
| 130 | 175 | .shortcut => "craft:shortcut", |
| 131 | .fs_change => "craft:fs:change", | |
| 132 | .power_sleep => "craft:powerSleep", | |
| 133 | .power_wake => "craft:powerWake", | |
| 134 | .network_change => "craft:networkChange", | |
| 135 | .bluetooth_device_found => "craft:bluetooth:deviceFound", | |
| 136 | .bluetooth_device_connected => "craft:bluetooth:deviceConnected", | |
| 137 | .bluetooth_device_disconnected => "craft:bluetooth:deviceDisconnected", | |
| 176 | .shortcut_error => "craft:shortcut:error", | |
| 177 | .speechrecognition_final => "craft:speechRecognition:final", | |
| 178 | .speechrecognition_partial => "craft:speechRecognition:partial", | |
| 179 | .theme => "craft:theme", | |
| 138 | 180 | .touchbar_action => "craft:touchbar:action", |
| 139 | .serial_data => "craft:serial:data", | |
| 140 | .speech_partial => "craft:speechRecognition:partial", | |
| 141 | .speech_final => "craft:speechRecognition:final", | |
| 181 | .tray_menuaction => "craft:tray:menuAction", | |
| 182 | .updateavailable => "craft:updateAvailable", | |
| 183 | .updatedownloaded => "craft:updateDownloaded", | |
| 184 | .window_blur => "craft:window:blur", | |
| 185 | .window_close => "craft:window:close", | |
| 186 | .window_focus => "craft:window:focus", | |
| 187 | .window_minimize => "craft:window:minimize", | |
| 188 | .window_move => "craft:window:move", | |
| 189 | .window_resize => "craft:window:resize", | |
| 190 | .window_restore => "craft:window:restore", | |
| 142 | 191 | }; |
| 143 | 192 | } |
| 144 | 193 | }; |
| @@ -173,6 +222,33 @@ pub fn registerEmitter(channel: Channel) Emitter { | ||
| 173 | 222 | return .{ .channel = channel }; |
| 174 | 223 | } |
| 175 | 224 | |
| 225 | /// What craft can honestly say about a channel. | |
| 226 | /// | |
| 227 | /// There is no `dead`. A source scan cannot establish absence: `craft:window:*` | |
| 228 | /// names are composed in JavaScript from `__craftDeliverWindowEvent('focus')`, | |
| 229 | /// so no Zig file contains the literal even though the emitter is right there. | |
| 230 | /// The shipped version reported a bare `false` for anything unregistered, which | |
| 231 | /// a page reasonably reads as "this will never fire" — and 21 of 22 channels | |
| 232 | /// said it, including `craft:menu:action` and `craft:theme`, both of which work. | |
| 233 | pub const Liveness = enum { | |
| 234 | /// Something in this build took out a permit to emit on it. | |
| 235 | live, | |
| 236 | /// Craft cannot prove it either way. Subscribe and see; do not disable a | |
| 237 | /// feature over it. | |
| 238 | unknown, | |
| 239 | ||
| 240 | pub fn text(self: Liveness) []const u8 { | |
| 241 | return switch (self) { | |
| 242 | .live => "live", | |
| 243 | .unknown => "unknown", | |
| 244 | }; | |
| 245 | } | |
| 246 | }; | |
| 247 | ||
| 248 | pub fn liveness(channel: Channel) Liveness { | |
| 249 | return if (live_channels[@backingInt(channel)]) .live else .unknown; | |
| 250 | } | |
| 251 | ||
| 176 | 252 | pub fn isLive(channel: Channel) bool { |
| 177 | 253 | return live_channels[@backingInt(channel)]; |
| 178 | 254 | } |
| @@ -257,8 +333,9 @@ pub fn buildManifest(gpa: std.mem.Allocator, registry: []const NamespaceDecl) ![ | ||
| 257 | 333 | if (index > 0) try out.append(gpa, ','); |
| 258 | 334 | try out.append(gpa, '"'); |
| 259 | 335 | try appendEscaped(gpa, &out, channel.eventName()); |
| 260 | try out.appendSlice(gpa, "\":"); | |
| 261 | try out.appendSlice(gpa, if (isLive(channel)) "true" else "false"); | |
| 336 | try out.appendSlice(gpa, "\":\""); | |
| 337 | try out.appendSlice(gpa, liveness(channel).text()); | |
| 338 | try out.append(gpa, '"'); | |
| 262 | 339 | } |
| 263 | 340 | try out.appendSlice(gpa, "}}"); |
| 264 | 341 | |
| @@ -281,17 +358,20 @@ test "a channel has no emitter until something registers one" { | ||
| 281 | 358 | try testing.expectEqualStrings("craft:fs:change", emitter.eventName()); |
| 282 | 359 | |
| 283 | 360 | // Registering one channel says nothing about any other. |
| 284 | try testing.expect(!isLive(.power_sleep)); | |
| 361 | try testing.expect(!isLive(.powersleep)); | |
| 285 | 362 | } |
| 286 | 363 | |
| 287 | 364 | test "every channel has a distinct event name" { |
| 288 | 365 | // A duplicate would make one of the two silently unreportable: the manifest |
| 289 | 366 | // is keyed by event name, so the second would overwrite the first. |
| 367 | // | |
| 368 | // Runtime rather than `inline for`: at 44 channels the nested comptime loop | |
| 369 | // exceeds Zig's backwards-branch budget. | |
| 290 | 370 | const all = std.enums.values(Channel); |
| 291 | inline for (all, 0..) |left, i| { | |
| 292 | inline for (all, 0..) |right, j| { | |
| 293 | if (i < j) try testing.expect(!std.mem.eql(u8, left.eventName(), right.eventName())); | |
| 294 | } | |
| 371 | var names: [all.len][]const u8 = undefined; | |
| 372 | for (all, 0..) |ch, i| names[i] = ch.eventName(); | |
| 373 | for (names, 0..) |a, i| { | |
| 374 | for (names[i + 1 ..]) |b| try testing.expect(!std.mem.eql(u8, a, b)); | |
| 295 | 375 | } |
| 296 | 376 | } |
| 297 | 377 | |
| @@ -388,8 +468,10 @@ test "the manifest reports every channel, live or not" { | ||
| 388 | 468 | // Every channel is present — a dead one has to be reported as dead, not |
| 389 | 469 | // omitted, or an app cannot tell "no emitter" from "channel I misspelled". |
| 390 | 470 | try testing.expectEqual(@as(usize, channel_count), channels.count()); |
| 391 | try testing.expect(channels.get("craft:menu:action").?.bool); | |
| 392 | try testing.expect(!channels.get("craft:fs:change").?.bool); | |
| 471 | try testing.expectEqualStrings("live", channels.get("craft:menu:action").?.string); | |
| 472 | // Not "dead" — craft cannot prove a channel has no emitter, and saying so | |
| 473 | // is what made the shipped manifest tell pages to disable working features. | |
| 474 | try testing.expectEqualStrings("unknown", channels.get("craft:fs:change").?.string); | |
| 393 | 475 | } |
| 394 | 476 | |
| 395 | 477 | test "a reason cannot break the manifest out of its JSON" { |