also looking at this
feat(prefs): a Settings… item and a preference store that keeps its types
#58
14 files
+2289
-10
| @@ -9,6 +9,7 @@ The Craft JavaScript Bridge provides a seamless interface for your web applicati | ||
| 9 | 9 | - [System Tray API](#system-tray-api) |
| 10 | 10 | - [Application Menu (macOS)](#application-menu-macos) |
| 11 | 11 | - [Global Shortcuts (macOS)](#global-shortcuts-macos) |
| 12 | - [Settings and Preferences (macOS)](#settings-and-preferences-macos) | |
| 12 | 13 | - [Window API](#window-api) |
| 13 | 14 | - [App API](#app-api) |
| 14 | 15 | - [TypeScript Support](#typescript-support) |
| @@ -367,6 +368,102 @@ unsubscribe function. | ||
| 367 | 368 | | `isRegistered(id): Promise<boolean>` | whether the id is known, enabled or not | |
| 368 | 369 | | `list(): Promise<Shortcut[]>` | `{ id, accelerator, key, enabled }` for each | |
| 369 | 370 | |
| 371 | ## Settings and Preferences (macOS) | |
| 372 | ||
| 373 | ### The Settings… item | |
| 374 | ||
| 375 | craft's default App menu ships a **Settings… ⌘,** where every Mac app has it, | |
| 376 | between About and Hide. Choosing it — or pressing ⌘, — fires a `craft:settings:open` | |
| 377 | event into the page. An app only has to listen: | |
| 378 | ||
| 379 | ```javascript | |
| 380 | window.craft.settings.onOpen(({ source }) => { | |
| 381 | showSettingsPanel() | |
| 382 | }) | |
| 383 | ``` | |
| 384 | ||
| 385 | `source` is `'menu'` for the menu item. Your own UI can reach the same handler | |
| 386 | rather than duplicating it: | |
| 387 | ||
| 388 | ```javascript | |
| 389 | gearButton.onclick = () => window.craft.settings.open('toolbar') | |
| 390 | ``` | |
| 391 | ||
| 392 | That call is page-local; nothing crosses the bridge for it. | |
| 393 | ||
| 394 | It is **not** delivered as `craft:menu:action` with a reserved id, deliberately. | |
| 395 | An app that never calls `craft.menu.set()` should not have to subscribe to the | |
| 396 | menu API to catch ⌘,, and a "reserved" menu id would be indistinguishable from | |
| 397 | an app-declared item that happened to pick the same string. | |
| 398 | ||
| 399 | Calling `craft.menu.set()` replaces the whole bar, so the default item goes with | |
| 400 | it — as documented above. Declare it yourself to keep it: | |
| 401 | ||
| 402 | ```javascript | |
| 403 | { id: 'settings', role: 'settings', label: 'Settings…', shortcut: 'cmd+,' } | |
| 404 | ``` | |
| 405 | ||
| 406 | `role: 'settings'` (or `'preferences'`) resolves to craft's own handler, so the | |
| 407 | same `craft:settings:open` event arrives either way. The `id` is required — | |
| 408 | items without one are skipped. | |
| 409 | ||
| 410 | ### `window.craft.prefs` | |
| 411 | ||
| 412 | A small preference store over the platform's own mechanism, so `defaults read` | |
| 413 | and a native settings pane both see what the app wrote. | |
| 414 | ||
| 415 | ```javascript | |
| 416 | await window.craft.prefs.set('theme', 'dark') | |
| 417 | await window.craft.prefs.set('fontSize', 13) | |
| 418 | await window.craft.prefs.set('firstRunDone', true) | |
| 419 | ||
| 420 | const theme = await window.craft.prefs.get('theme', 'light') | |
| 421 | ``` | |
| 422 | ||
| 423 | | Call | What it does | | |
| 424 | |---|---| | |
| 425 | | `set(key, value)` | Write. Resolves once the value is **on disk**, not merely once the message was posted | | |
| 426 | | `get(key, fallback?)` | Read. `fallback` is returned for an absent key | | |
| 427 | | `delete(key)` | Remove. Resolves to whether it was there | | |
| 428 | | `clear()` | Remove every preference this app set. Resolves to how many | | |
| 429 | | `keys()` | Every key this app set, sorted | | |
| 430 | | `info()` | `{ domain, prefix, count, readCommand }` — see below | | |
| 431 | ||
| 432 | **Strings, numbers and booleans only.** Not a corner cut: the preferences API | |
| 433 | raises an Objective-C exception for a value that is not a property-list type, | |
| 434 | and craft cannot catch one, so refusing containers in the page is what makes | |
| 435 | that crash unreachable rather than merely unlikely. Serialise structure | |
| 436 | yourself: | |
| 437 | ||
| 438 | ```javascript | |
| 439 | await craft.prefs.set('recents', JSON.stringify(list)) | |
| 440 | const list = JSON.parse(await craft.prefs.get('recents', '[]')) | |
| 441 | ``` | |
| 442 | ||
| 443 | Refusals reject with a `code` you can branch on: `PREFS_UNSUPPORTED_VALUE`, | |
| 444 | `PREFS_NON_FINITE`, `PREFS_VALUE_TOO_LARGE` (8 KiB), `PREFS_BAD_KEY` (keys must | |
| 445 | match `/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/`), and `PREFS_FOREIGN_VALUE` when | |
| 446 | something outside craft wrote a value craft cannot represent. | |
| 447 | ||
| 448 | **Types survive the round trip.** A stored `true` reads back as `true`, not as | |
| 449 | `1`; an integer does not come back as a float. Passing a `fallback` also | |
| 450 | declares the expected type, so a value left behind by an older build of the app | |
| 451 | degrades to the default instead of to a surprise. | |
| 452 | ||
| 453 | **Where it goes.** Preferences land in the app's own domain — the bundle | |
| 454 | identifier inside a packaged `.app`, the executable's name otherwise. That | |
| 455 | means preferences set while developing against the shared `craft` binary are | |
| 456 | in a different domain from the packaged app's, and every dev-mode app shares | |
| 457 | one. `info()` reports which domain is live and how to read it: | |
| 458 | ||
| 459 | ```javascript | |
| 460 | const { domain, readCommand } = await craft.prefs.info() | |
| 461 | // readCommand: "defaults read com.example.myapp" | |
| 462 | ``` | |
| 463 | ||
| 464 | craft namespaces its keys, so `clear()` and `keys()` cannot disturb the window | |
| 465 | positions and inspector state AppKit and WebKit keep in the same domain. | |
| 466 | ||
| 370 | 467 | ## Window API |
| 371 | 468 | |
| 372 | 469 | Control the application window from JavaScript. |
| @@ -601,6 +698,8 @@ async function onComplete() { | ||
| 601 | 698 | | Window Control | ✅ | ✅ | ✅ | |
| 602 | 699 | | Hide Dock Icon | ✅ | ➖ | ➖ | |
| 603 | 700 | | Global Shortcuts | ✅ | �� | �� | |
| 701 | | Settings… item | ✅ | �� | �� | | |
| 702 | | Preferences | ✅ | �� | �� | | |
| 604 | 703 | |
| 605 | 704 | ✅ Implemented | �� In Progress | ➖ Not Applicable |
| 606 | 705 | |
| @@ -552,6 +552,10 @@ export type { | ||
| 552 | 552 | CraftDatabaseAPI, |
| 553 | 553 | CraftHttpAPI, |
| 554 | 554 | CraftCryptoAPI, |
| 555 | CraftPreferencesAPI, | |
| 556 | CraftPreferencesInfo, | |
| 557 | CraftSettingsAPI, | |
| 558 | PreferenceValue, | |
| 555 | 559 | CraftEventType, |
| 556 | 560 | CraftEventMap, |
| 557 | 561 | CraftEventHandler, |
| @@ -1142,8 +1142,116 @@ export interface CraftBridgeAPI { | ||
| 1142 | 1142 | * shortcut that can never fire is worse than one that was never accepted. |
| 1143 | 1143 | */ |
| 1144 | 1144 | shortcuts?: CraftGlobalShortcutsAPI |
| 1145 | ||
| 1146 | /** | |
| 1147 | * A small scalar preference store (macOS: CFPreferences). | |
| 1148 | */ | |
| 1149 | prefs?: CraftPreferencesAPI | |
| 1150 | ||
| 1151 | /** | |
| 1152 | * The Cmd+, convention: where the App menu's Settings… item arrives. | |
| 1153 | */ | |
| 1154 | settings?: CraftSettingsAPI | |
| 1155 | } | |
| 1156 | ||
| 1157 | /** | |
| 1158 | * The only value types `craft.prefs` stores. | |
| 1159 | * | |
| 1160 | * Anything else is refused in the page, before it can reach native. That is | |
| 1161 | * deliberate rather than a limitation dodged: the preferences API raises an | |
| 1162 | * Objective-C exception for a value that is not a property-list type, and Zig | |
| 1163 | * cannot catch one — so refusing containers here is what makes that crash | |
| 1164 | * unreachable. Serialise structure yourself: | |
| 1165 | * `prefs.set(k, JSON.stringify(v))`. | |
| 1166 | */ | |
| 1167 | export type PreferenceValue = string | number | boolean | |
| 1168 | ||
| 1169 | export interface CraftPreferencesInfo { | |
| 1170 | /** | |
| 1171 | * The preferences domain in use — the bundle identifier inside a packaged | |
| 1172 | * `.app`, the executable's name otherwise. | |
| 1173 | */ | |
| 1174 | domain: string | |
| 1175 | /** The key prefix craft namespaces its own preferences under. */ | |
| 1176 | prefix: string | |
| 1177 | /** How many keys the app currently has stored. */ | |
| 1178 | count: number | |
| 1179 | /** A copy-pasteable command that prints the domain. */ | |
| 1180 | readCommand: string | |
| 1145 | 1181 | } |
| 1146 | 1182 | |
| 1183 | /** | |
| 1184 | * A small preference store over the platform's own mechanism, so `defaults | |
| 1185 | * read` and a native settings pane both see what the app wrote. | |
| 1186 | * | |
| 1187 | * macOS only. Values are stored as native property-list types under a reserved | |
| 1188 | * key prefix, so clearing craft's preferences cannot disturb the AppKit and | |
| 1189 | * WebKit keys that share the same domain. | |
| 1190 | */ | |
| 1191 | export interface CraftPreferencesAPI { | |
| 1192 | /** | |
| 1193 | * Read a preference. | |
| 1194 | * | |
| 1195 | * `fallback` is returned when the key is absent — and also when what is | |
| 1196 | * stored is of a different type from the fallback, which is how a value left | |
| 1197 | * behind by an older build of the app degrades to the default rather than to | |
| 1198 | * a surprise. Call `get(key)` with no fallback to read whatever is stored. | |
| 1199 | * | |
| 1200 | * Rejects with `code: 'PREFS_FOREIGN_VALUE'` if something outside craft | |
| 1201 | * wrote a value craft cannot represent, rather than coercing it away. | |
| 1202 | */ | |
| 1203 | get: <T extends PreferenceValue>(key: string, fallback?: T) => Promise<T | undefined> | |
| 1204 | /** | |
| 1205 | * Write a preference. Resolves once the value is on disk, not merely once | |
| 1206 | * the message was posted. | |
| 1207 | * | |
| 1208 | * Rejects with `code: 'PREFS_UNSUPPORTED_VALUE'` for anything that is not a | |
| 1209 | * string, number or boolean; `'PREFS_NON_FINITE'` for NaN or Infinity; | |
| 1210 | * `'PREFS_VALUE_TOO_LARGE'` past 8 KiB; `'PREFS_BAD_KEY'` for a key outside | |
| 1211 | * `/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/`. | |
| 1212 | */ | |
| 1213 | set: (key: string, value: PreferenceValue) => Promise<void> | |
| 1214 | /** Remove a preference. Resolves to whether it was there to begin with. */ | |
| 1215 | delete: (key: string) => Promise<boolean> | |
| 1216 | /** | |
| 1217 | * Remove every preference this app has set, and nothing else. Resolves to | |
| 1218 | * how many were removed. | |
| 1219 | */ | |
| 1220 | clear: () => Promise<number> | |
| 1221 | /** Every key this app has set, sorted. */ | |
| 1222 | keys: () => Promise<string[]> | |
| 1223 | /** | |
| 1224 | * Which domain the preferences are actually landing in. | |
| 1225 | * | |
| 1226 | * Worth having: an unbundled dev-mode binary writes to a domain named after | |
| 1227 | * the executable while a packaged `.app` writes to its bundle identifier, so | |
| 1228 | * preferences set during development can appear to vanish once the app is | |
| 1229 | * packaged. | |
| 1230 | */ | |
| 1231 | info: () => Promise<CraftPreferencesInfo> | |
| 1232 | } | |
| 1233 | ||
| 1234 | /** | |
| 1235 | * The Cmd+, convention. | |
| 1236 | * | |
| 1237 | * craft's default App menu ships a `Settings…` item, and this is where its | |
| 1238 | * click arrives. An app that replaces the whole menu bar with | |
| 1239 | * `craft.menu.set()` loses the default item, and can declare | |
| 1240 | * `{ id: 'settings', role: 'settings', label: 'Settings…', shortcut: 'cmd+,' }` | |
| 1241 | * to get it back — the same handler answers either way. | |
| 1242 | */ | |
| 1243 | export interface CraftSettingsAPI { | |
| 1244 | /** Subscribe to Settings being opened. Returns an unsubscribe function. */ | |
| 1245 | onOpen: (handler: (event: { source: string }) => void) => () => void | |
| 1246 | /** | |
| 1247 | * Open settings from the app's own UI — a gear button, say — so it lands in | |
| 1248 | * the same handler as Cmd+, rather than needing a second code path. Purely | |
| 1249 | * page-local; nothing crosses the bridge. | |
| 1250 | */ | |
| 1251 | open: (source?: string) => Promise<void> | |
| 1252 | } | |
| 1253 | ||
| 1254 | ||
| 1147 | 1255 | /** A global hotkey, as `craft.shortcuts.list()` reports it. */ |
| 1148 | 1256 | export interface GlobalShortcut { |
| 1149 | 1257 | /** The id the app registered it under. */ |
| @@ -605,6 +605,31 @@ pub fn build(b: *std.Build) void { | ||
| 605 | 605 | }), |
| 606 | 606 | }); |
| 607 | 607 | |
| 608 | // The preference store's pure core: key rules, the value model, the wire | |
| 609 | // codec and the read/write bookkeeping, driven against an in-memory backend | |
| 610 | // so all of it is provable on a host with no preferences daemon. | |
| 611 | const prefs_tests = b.addTest(.{ | |
| 612 | .root_module = b.createModule(.{ | |
| 613 | .root_source_file = b.path("src/prefs.zig"), | |
| 614 | .target = target, | |
| 615 | .optimize = optimize, | |
| 616 | }), | |
| 617 | }); | |
| 618 | ||
| 619 | // The CoreFoundation backend. These touch the real preferences daemon, so | |
| 620 | // they run against a throwaway domain and remove every key they create. | |
| 621 | const prefs_macos_tests = b.addTest(.{ | |
| 622 | .root_module = b.createModule(.{ | |
| 623 | .root_source_file = b.path("src/prefs_macos.zig"), | |
| 624 | .target = target, | |
| 625 | .optimize = optimize, | |
| 626 | }), | |
| 627 | }); | |
| 628 | if (target_os == .macos) { | |
| 629 | prefs_macos_tests.root_module.linkFramework("CoreFoundation", .{}); | |
| 630 | prefs_macos_tests.root_module.link_libc = true; | |
| 631 | } | |
| 632 | ||
| 608 | 633 | const config_tests = b.addTest(.{ |
| 609 | 634 | .root_module = b.createModule(.{ |
| 610 | 635 | .root_source_file = b.path("test/config_test.zig"), |
| @@ -941,6 +966,8 @@ pub fn build(b: *std.Build) void { | ||
| 941 | 966 | const run_space_list_tests = b.addRunArtifact(space_list_tests); |
| 942 | 967 | const run_local_tls_tests = b.addRunArtifact(local_tls_tests); |
| 943 | 968 | const run_menu_roles_tests = b.addRunArtifact(menu_roles_tests); |
| 969 | const run_prefs_tests = b.addRunArtifact(prefs_tests); | |
| 970 | const run_prefs_macos_tests = b.addRunArtifact(prefs_macos_tests); | |
| 944 | 971 | const run_key_codes_tests = b.addRunArtifact(key_codes_tests); |
| 945 | 972 | const run_accelerator_tests = b.addRunArtifact(accelerator_tests); |
| 946 | 973 | const run_macos_hotkey_tests = b.addRunArtifact(macos_hotkey_tests); |
| @@ -1051,6 +1078,8 @@ pub fn build(b: *std.Build) void { | ||
| 1051 | 1078 | test_step.dependOn(&run_space_list_tests.step); |
| 1052 | 1079 | test_step.dependOn(&run_local_tls_tests.step); |
| 1053 | 1080 | test_step.dependOn(&run_menu_roles_tests.step); |
| 1081 | test_step.dependOn(&run_prefs_tests.step); | |
| 1082 | test_step.dependOn(&run_prefs_macos_tests.step); | |
| 1054 | 1083 | test_step.dependOn(&run_key_codes_tests.step); |
| 1055 | 1084 | test_step.dependOn(&run_accelerator_tests.step); |
| 1056 | 1085 | test_step.dependOn(&run_macos_hotkey_tests.step); |
| @@ -1154,6 +1183,10 @@ pub fn build(b: *std.Build) void { | ||
| 1154 | 1183 | const test_menu_roles_step = b.step("test:menu-roles", "Run menu role table tests"); |
| 1155 | 1184 | test_menu_roles_step.dependOn(&run_menu_roles_tests.step); |
| 1156 | 1185 | |
| 1186 | const test_prefs_step = b.step("test:prefs", "Run preference store tests"); | |
| 1187 | test_prefs_step.dependOn(&run_prefs_tests.step); | |
| 1188 | test_prefs_step.dependOn(&run_prefs_macos_tests.step); | |
| 1189 | ||
| 1157 | 1190 | const test_key_codes_step = b.step("test:key-codes", "Run key code table tests"); |
| 1158 | 1191 | test_key_codes_step.dependOn(&run_key_codes_tests.step); |
| 1159 | 1192 | |
| @@ -3,14 +3,17 @@ | ||
| 3 | 3 | //! `test/injected_js_test.zig` drives the real injected JavaScript and then |
| 4 | 4 | //! decodes what it posted with the very code the bridge dispatches to — which |
| 5 | 5 | //! is the only kind of test that can catch a JS side and a native side that |
| 6 | //! are each individually correct and disagree with each other. Both #27 (menu | |
| 7 | //! action names) and #47 (shortcut payload shape) were exactly that. | |
| 6 | //! are each individually correct and disagree with each other. #27 (menu action | |
| 7 | //! names), #47 (shortcut payload shape) and #51 (preference value tags) were | |
| 8 | //! all exactly that. | |
| 8 | 9 | //! |
| 9 | //! They arrive through one aggregate rather than as two named modules because | |
| 10 | //! both reach `bridge_error.zig`, and a file may belong to only one module per | |
| 11 | //! compilation. A test module also cannot `@import` outside its own directory, | |
| 12 | //! so this has to live here beside the code it re-exports rather than in | |
| 13 | //! `test/`. | |
| 10 | //! They arrive through one aggregate rather than as separate named modules | |
| 11 | //! because several of them reach `bridge_error.zig`, and a file may belong to | |
| 12 | //! only one module per compilation. A test module also cannot `@import` outside | |
| 13 | //! its own directory, so this has to live here beside the code it re-exports | |
| 14 | //! rather than in `test/`. | |
| 14 | 15 | |
| 15 | 16 | pub const menu = ; |
| 16 | 17 | pub const shortcuts = ; |
| 18 | pub const prefs = ; | |
| 19 | pub const prefs_actions = ; | |
| @@ -0,0 +1,264 @@ | ||
| 1 | //! `t: "prefs"` — the bridge adapter for the preference store. | |
| 2 | //! | |
| 3 | //! Thin on purpose: it decodes the action, hands the payload to `prefs.zig`, | |
| 4 | //! and turns the answer back into JSON. The rules live in `prefs.zig`, where | |
| 5 | //! they are testable against an in-memory backend; the platform lives in | |
| 6 | //! `prefs_macos.zig`. | |
| 7 | //! | |
| 8 | //! ## Why the wire actions are namespaced | |
| 9 | //! | |
| 10 | //! `_req` in `craft-bridge.js` keys its pending-reply queue by the **action | |
| 11 | //! name alone** — a documented limitation, and a real one. Of the six verbs | |
| 12 | //! this bridge needs, only `keys` is unclaimed: | |
| 13 | //! | |
| 14 | //! * `get` is already a request in the keychain and tags bridges; | |
| 15 | //! * `has` in keychain; `clear` in tags; | |
| 16 | //! * `set` and `delete` have no request-shaped competitor, but the keychain, | |
| 17 | //! crash-reporter and tags bridges all *emit* a result under those bare | |
| 18 | //! names even though their own facades never wait for one — a stray | |
| 19 | //! `{"ok":true}` that would shift a prefs caller off the queue and resolve | |
| 20 | //! it with a foreign payload. | |
| 21 | //! | |
| 22 | //! So the action on the wire is `prefs:get`, not `get`. `_req` then keys the | |
| 23 | //! queue under `prefs:get` for free, `sendResultToJS`/`sendErrorToJS` match it | |
| 24 | //! with no extra plumbing, and — unlike the one existing dodge in the tree, | |
| 25 | //! which hand-rolls its promise to get a qualified result key — the 30-second | |
| 26 | //! reaper that stops a caller hanging forever is preserved. | |
| 27 | ||
| 28 | const std = ; | |
| 29 | const builtin = ; | |
| 30 | const bridge_error = ; | |
| 31 | const logging = ; | |
| 32 | const prefs = ; | |
| 33 | const prefs_macos = ; | |
| 34 | ||
| 35 | const log = logging.prefs; | |
| 36 | const BridgeError = bridge_error.BridgeError; | |
| 37 | ||
| 38 | const actions = ; | |
| 39 | ||
| 40 | pub const action_get = actions.get; | |
| 41 | pub const action_set = actions.set; | |
| 42 | pub const action_delete = actions.delete; | |
| 43 | pub const action_clear = actions.clear; | |
| 44 | pub const action_keys = actions.keys; | |
| 45 | pub const action_info = actions.info; | |
| 46 | ||
| 47 | /// The action every reply about an unrecognised action is filed under. | |
| 48 | /// | |
| 49 | /// Never the bytes that arrived. Two reasons, both load-bearing: `craft.invoke` | |
| 50 | /// lets a page post a bare action name of its choosing, and echoing one back | |
| 51 | /// would reject every in-flight `keychain.get` and `tags.get` holding the same | |
| 52 | /// bare name; and `sendErrorToJS` interpolates the action into a single-quoted | |
| 53 | /// JS literal with no escaping, so echoing caller-supplied bytes is an | |
| 54 | /// injection. Every action string this file passes is a compile-time constant. | |
| 55 | pub const action_unknown = actions.unknown; | |
| 56 | ||
| 57 | pub const PrefsBridge = struct { | |
| 58 | allocator: std.mem.Allocator, | |
| 59 | native: prefs_macos.Backend, | |
| 60 | ||
| 61 | const Self = @This(); | |
| 62 | ||
| 63 | pub fn init(allocator: std.mem.Allocator) Self { | |
| 64 | return .{ | |
| 65 | .allocator = allocator, | |
| 66 | .native = prefs_macos.Backend.init(.current_application), | |
| 67 | }; | |
| 68 | } | |
| 69 | ||
| 70 | pub fn deinit(self: *Self) void { | |
| 71 | self.native.deinit(); | |
| 72 | } | |
| 73 | ||
| 74 | fn store(self: *Self) prefs.Store { | |
| 75 | return .{ .backend = self.native.backend() }; | |
| 76 | } | |
| 77 | ||
| 78 | pub fn handleMessage(self: *Self, action: []const u8, data: []const u8) !void { | |
| 79 | if (comptime builtin.os.tag != .macos) { | |
| 80 | bridge_error.sendErrorToJS(self.allocator, known(action), BridgeError.PlatformNotSupported); | |
| 81 | return; | |
| 82 | } | |
| 83 | self.handleMessageInternal(action, data) catch |err| { | |
| 84 | bridge_error.sendErrorToJS(self.allocator, known(action), translate(err)); | |
| 85 | }; | |
| 86 | } | |
| 87 | ||
| 88 | fn handleMessageInternal(self: *Self, action: []const u8, data: []const u8) !void { | |
| 89 | if (std.mem.eql(u8, action, action_get)) { | |
| 90 | try self.handleGet(data); | |
| 91 | } else if (std.mem.eql(u8, action, action_set)) { | |
| 92 | try self.handleSet(data); | |
| 93 | } else if (std.mem.eql(u8, action, action_delete)) { | |
| 94 | try self.handleDelete(data); | |
| 95 | } else if (std.mem.eql(u8, action, action_clear)) { | |
| 96 | try self.handleClear(); | |
| 97 | } else if (std.mem.eql(u8, action, action_keys)) { | |
| 98 | try self.handleKeys(); | |
| 99 | } else if (std.mem.eql(u8, action, action_info)) { | |
| 100 | try self.handleInfo(); | |
| 101 | } else { | |
| 102 | return BridgeError.UnknownAction; | |
| 103 | } | |
| 104 | } | |
| 105 | ||
| 106 | fn handleGet(self: *Self, data: []const u8) !void { | |
| 107 | const parsed = try self.parse(prefs.KeyShape, data); | |
| 108 | defer parsed.deinit(); | |
| 109 | ||
| 110 | const read = try self.store().get(self.allocator, parsed.value.k); | |
| 111 | defer if (read == .value and read.value == .string) self.allocator.free(read.value.string); | |
| 112 | ||
| 113 | var out: std.ArrayListUnmanaged(u8) = .empty; | |
| 114 | defer out.deinit(self.allocator); | |
| 115 | try prefs.appendReadJson(self.allocator, &out, read); | |
| 116 | bridge_error.sendResultToJS(self.allocator, action_get, out.items); | |
| 117 | } | |
| 118 | ||
| 119 | fn handleSet(self: *Self, data: []const u8) !void { | |
| 120 | const parsed = try self.parse(prefs.SetShape, data); | |
| 121 | defer parsed.deinit(); | |
| 122 | ||
| 123 | // Re-validated here rather than trusted: `craft.invoke` and a | |
| 124 | // hand-rolled `postMessage` both reach this without passing the JS | |
| 125 | // facade's checks. | |
| 126 | const request = try prefs.decodeSet(parsed.value); | |
| 127 | try self.store().set(request.key, request.value); | |
| 128 | bridge_error.sendResultToJS(self.allocator, action_set, "{\"ok\":true}"); | |
| 129 | } | |
| 130 | ||
| 131 | fn handleDelete(self: *Self, data: []const u8) !void { | |
| 132 | const parsed = try self.parse(prefs.KeyShape, data); | |
| 133 | defer parsed.deinit(); | |
| 134 | ||
| 135 | const existed = try self.store().remove(parsed.value.k); | |
| 136 | bridge_error.sendResultToJS(self.allocator, action_delete, if (existed) | |
| 137 | "{\"ok\":true,\"existed\":true}" | |
| 138 | else | |
| 139 | "{\"ok\":true,\"existed\":false}"); | |
| 140 | } | |
| 141 | ||
| 142 | fn handleClear(self: *Self) !void { | |
| 143 | const removed = try self.store().clear(self.allocator); | |
| 144 | const reply = try std.fmt.allocPrint(self.allocator, "", .{removed}); | |
| 145 | defer self.allocator.free(reply); | |
| 146 | bridge_error.sendResultToJS(self.allocator, action_clear, reply); | |
| 147 | } | |
| 148 | ||
| 149 | fn handleKeys(self: *Self) !void { | |
| 150 | const listed = try self.store().keys(self.allocator); | |
| 151 | defer { | |
| 152 | for (listed) |k| self.allocator.free(k); | |
| 153 | self.allocator.free(listed); | |
| 154 | } | |
| 155 | ||
| 156 | var out: std.ArrayListUnmanaged(u8) = .empty; | |
| 157 | defer out.deinit(self.allocator); | |
| 158 | try prefs.appendKeysJson(self.allocator, &out, listed); | |
| 159 | bridge_error.sendResultToJS(self.allocator, action_keys, out.items); | |
| 160 | } | |
| 161 | ||
| 162 | /// Which domain the preferences actually landed in, and how to read it. | |
| 163 | /// | |
| 164 | /// Not decoration: an unbundled dev-mode binary writes to a domain named | |
| 165 | /// after the executable, and a packaged `.app` writes to its bundle id, so | |
| 166 | /// preferences set in development appear to vanish when the app is | |
| 167 | /// packaged. This is what turns that into a question with an answer. | |
| 168 | fn handleInfo(self: *Self) !void { | |
| 169 | const domain = try prefs_macos.domainName(self.allocator); | |
| 170 | defer self.allocator.free(domain); | |
| 171 | ||
| 172 | const listed = try self.store().keys(self.allocator); | |
| 173 | defer { | |
| 174 | for (listed) |k| self.allocator.free(k); | |
| 175 | self.allocator.free(listed); | |
| 176 | } | |
| 177 | ||
| 178 | var out: std.ArrayListUnmanaged(u8) = .empty; | |
| 179 | defer out.deinit(self.allocator); | |
| 180 | try out.appendSlice(self.allocator, "{\"domain\":\""); | |
| 181 | try bridge_error.appendJsonEscaped(self.allocator, &out, domain); | |
| 182 | try out.appendSlice(self.allocator, "\",\"prefix\":\"" ++ prefs.prefix ++ "\",\"count\":"); | |
| 183 | const tail = try std.fmt.allocPrint(self.allocator, "{d},\"readCommand\":\"defaults read ", .{listed.len}); | |
| 184 | defer self.allocator.free(tail); | |
| 185 | try out.appendSlice(self.allocator, tail); | |
| 186 | try bridge_error.appendJsonEscaped(self.allocator, &out, domain); | |
| 187 | try out.appendSlice(self.allocator, "\"}"); | |
| 188 | ||
| 189 | bridge_error.sendResultToJS(self.allocator, action_info, out.items); | |
| 190 | } | |
| 191 | ||
| 192 | fn parse(self: *Self, comptime T: type, data: []const u8) !std.json.Parsed(T) { | |
| 193 | if (data.len == 0) return BridgeError.MissingData; | |
| 194 | return std.json.parseFromSlice(T, self.allocator, data, .{ | |
| 195 | .ignore_unknown_fields = true, | |
| 196 | .allocate = .alloc_always, | |
| 197 | }) catch BridgeError.InvalidJSON; | |
| 198 | } | |
| 199 | }; | |
| 200 | ||
| 201 | /// An action name safe to interpolate into a reply. | |
| 202 | fn known(action: []const u8) []const u8 { | |
| 203 | for ([_][]const u8{ action_get, action_set, action_delete, action_clear, action_keys, action_info }) |a| { | |
| 204 | if (std.mem.eql(u8, action, a)) return a; | |
| 205 | } | |
| 206 | return action_unknown; | |
| 207 | } | |
| 208 | ||
| 209 | /// Map onto the existing `BridgeError` set rather than adding variants: the | |
| 210 | /// code and message switches in `bridge_error.zig` are exhaustive, so a new one | |
| 211 | /// means editing both. | |
| 212 | fn translate(err: anyerror) BridgeError { | |
| 213 | return switch (err) { | |
| 214 | error.InvalidKey, error.ValueTooLarge, error.UnsupportedValue => BridgeError.InvalidParameter, | |
| 215 | error.BackendFailure => BridgeError.NativeCallFailed, | |
| 216 | error.OutOfMemory => BridgeError.AllocationFailed, | |
| 217 | error.MissingData => BridgeError.MissingData, | |
| 218 | error.InvalidJSON => BridgeError.InvalidJSON, | |
| 219 | error.UnknownAction => BridgeError.UnknownAction, | |
| 220 | error.PlatformNotSupported => BridgeError.PlatformNotSupported, | |
| 221 | else => blk: { | |
| 222 | log.debug("unmapped prefs error: {any}", .{err}); | |
| 223 | break :blk BridgeError.NativeCallFailed; | |
| 224 | }, | |
| 225 | }; | |
| 226 | } | |
| 227 | ||
| 228 | // ============================================================================= | |
| 229 | // Tests | |
| 230 | // ============================================================================= | |
| 231 | ||
| 232 | const testing = std.testing; | |
| 233 | ||
| 234 | test "an unrecognised action is filed under a constant, never the bytes that arrived" { | |
| 235 | // `sendErrorToJS` splices the action into a single-quoted JS literal with | |
| 236 | // no escaping, and `__craftBridgeError` drains the pending queue by it. So | |
| 237 | // echoing back what arrived is both a script injection and a way to reject | |
| 238 | // every in-flight `keychain.get`. | |
| 239 | try testing.expectEqualStrings(action_unknown, known("get")); | |
| 240 | try testing.expectEqualStrings(action_unknown, known("'+alert(1)+'")); | |
| 241 | try testing.expectEqualStrings(action_unknown, known("")); | |
| 242 | try testing.expectEqualStrings(action_unknown, known("prefs:nope")); | |
| 243 | ||
| 244 | for ([_][]const u8{ action_get, action_set, action_delete, action_clear, action_keys, action_info }) |a| { | |
| 245 | try testing.expectEqualStrings(a, known(a)); | |
| 246 | } | |
| 247 | } | |
| 248 | ||
| 249 | test "every wire action is namespaced" { | |
| 250 | // The single assertion that stops someone shortening these back to bare | |
| 251 | // names and silently reintroducing the pending-queue collision. | |
| 252 | for ([_][]const u8{ action_get, action_set, action_delete, action_clear, action_keys, action_info, action_unknown }) |a| { | |
| 253 | try testing.expect(std.mem.startsWith(u8, a, "prefs:")); | |
| 254 | } | |
| 255 | } | |
| 256 | ||
| 257 | test "store errors map onto the existing bridge error set" { | |
| 258 | try testing.expectEqual(BridgeError.InvalidParameter, translate(prefs.Error.InvalidKey)); | |
| 259 | try testing.expectEqual(BridgeError.InvalidParameter, translate(prefs.Error.ValueTooLarge)); | |
| 260 | try testing.expectEqual(BridgeError.InvalidParameter, translate(prefs.Error.UnsupportedValue)); | |
| 261 | try testing.expectEqual(BridgeError.NativeCallFailed, translate(prefs.Error.BackendFailure)); | |
| 262 | try testing.expectEqual(BridgeError.AllocationFailed, translate(prefs.Error.OutOfMemory)); | |
| 263 | try testing.expectEqual(BridgeError.UnknownAction, translate(BridgeError.UnknownAction)); | |
| 264 | } | |
| @@ -0,0 +1,19 @@ | ||
| 1 | //! The `prefs` bridge's action names, alone. | |
| 2 | //! | |
| 3 | //! Split out of `bridge_prefs.zig` so the JS-contract test can reference the | |
| 4 | //! same constants the bridge dispatches on without dragging in the CoreFoundation | |
| 5 | //! backend — a rename on either side then fails the build rather than silently | |
| 6 | //! producing a namespace that answers nothing. | |
| 7 | //! | |
| 8 | //! Namespace-qualified on purpose; see `bridge_prefs.zig` for why a bare `get` | |
| 9 | //! would be drained by another bridge's pending replies. | |
| 10 | ||
| 11 | pub const get = "prefs:get"; | |
| 12 | pub const set = "prefs:set"; | |
| 13 | pub const delete = "prefs:delete"; | |
| 14 | pub const clear = "prefs:clear"; | |
| 15 | pub const keys = "prefs:keys"; | |
| 16 | pub const info = "prefs:info"; | |
| 17 | ||
| 18 | /// What an unrecognised action is reported under. Never the bytes that arrived. | |
| 19 | pub const unknown = "prefs:unknownAction"; | |
| @@ -360,6 +360,22 @@ | ||
| 360 | 360 | } |
| 361 | 361 | } |
| 362 | 362 | |
| 363 | // The default App-menu Settings… item — and any `role: 'settings'` item an | |
| 364 | // app declares — fires this. Its own event, not `craft:menu:action`: an app | |
| 365 | // that never calls craft.menu.set() should not have to subscribe to a menu | |
| 366 | // API it does not use, and a "reserved" menu id would be indistinguishable | |
| 367 | // from an app-declared item that happened to pick the same string. | |
| 368 | window.__craftSettingsListeners = window.__craftSettingsListeners || 0 | |
| 369 | window.__craftSettingsOpen = function () { | |
| 370 | // The one genuinely silent case: an enabled Cmd+, in a page with no | |
| 371 | // handler. Saying so once beats leaving it a mystery, and it is why the | |
| 372 | // item can ship always-present rather than opt-in. | |
| 373 | if (window.__craftSettingsListeners === 0 && typeof console !== 'undefined' && console.info) { | |
| 374 | console.info('[craft] Settings… was chosen but nothing is listening. Subscribe with craft.settings.onOpen(() => { … }).') | |
| 375 | } | |
| 376 | window.dispatchEvent(new CustomEvent('craft:settings:open', { detail: { source: 'menu' } })) | |
| 377 | } | |
| 378 | ||
| 363 | 379 | // ------------------------------------------------------------------------- |
| 364 | 380 | // window — full surface from bridge_window.zig |
| 365 | 381 | // ------------------------------------------------------------------------- |
| @@ -562,6 +578,165 @@ | ||
| 562 | 578 | onError: _evt('craft:shortcut:error'), |
| 563 | 579 | } |
| 564 | 580 | |
| 581 | // ------------------------------------------------------------------------- | |
| 582 | // settings — the Cmd+, convention | |
| 583 | // ------------------------------------------------------------------------- | |
| 584 | // craft's default App menu ships a Settings… item; this is where its click | |
| 585 | // arrives. `open()` is page-local, so a gear button in your own toolbar | |
| 586 | // reaches the same handler without duplicating it. | |
| 587 | window.craft.settings = { | |
| 588 | onOpen: function (cb) { | |
| 589 | window.__craftSettingsListeners++ | |
| 590 | const off = _evt('craft:settings:open')(cb) | |
| 591 | let released = false | |
| 592 | return function () { | |
| 593 | if (released) return | |
| 594 | released = true | |
| 595 | window.__craftSettingsListeners-- | |
| 596 | off() | |
| 597 | } | |
| 598 | }, | |
| 599 | open: function (source) { | |
| 600 | window.dispatchEvent(new CustomEvent('craft:settings:open', | |
| 601 | { detail: { source: String(source || 'app') } })) | |
| 602 | return Promise.resolve() | |
| 603 | }, | |
| 604 | } | |
| 605 | ||
| 606 | // ------------------------------------------------------------------------- | |
| 607 | // prefs — a small scalar preference store (macOS: CFPreferences) | |
| 608 | // ------------------------------------------------------------------------- | |
| 609 | // Stores string, number and boolean, and nothing else on purpose. The | |
| 610 | // preferences API raises an Objective-C exception for a non-property-list | |
| 611 | // value, and Zig cannot catch one — so refusing containers here is what makes | |
| 612 | // that crash unreachable rather than merely unlikely. Serialise structure | |
| 613 | // yourself: prefs.set(k, JSON.stringify(v)). | |
| 614 | // | |
| 615 | // Wire actions are namespace-qualified (`prefs:get`, never a bare `get`) | |
| 616 | // because the pending-reply queue is keyed by action name alone. A bare | |
| 617 | // `get` would be drained by keychain.get and tags.get; a bare | |
| 618 | // `set`/`delete`/`clear` would be *resolved* by other bridges that emit a | |
| 619 | // result under those names without their own facades ever waiting for one. | |
| 620 | const PREFS_KEY_RE = /^[a-z0-9][\w.-]{0,63}$/i | |
| 621 | const PREFS_MAX_VALUE_BYTES = 8192 | |
| 622 | // A local plist read that has not answered in two seconds is not going to; | |
| 623 | // the 30-second default is for things like modal dialogs. | |
| 624 | const PREFS_TIMEOUT_MS = 2000 | |
| 625 | ||
| 626 | function _prefsErr(Ctor, code, message) { | |
| 627 | const e = new Ctor(message) | |
| 628 | e.code = code | |
| 629 | return e | |
| 630 | } | |
| 631 | ||
| 632 | function _prefsKey(fn, key) { | |
| 633 | if (typeof key === 'string' && PREFS_KEY_RE.test(key)) return null | |
| 634 | return _prefsErr(TypeError, 'PREFS_BAD_KEY', `craft.prefs.${fn}: invalid key ${_stringify(key)} — keys must match /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/`) | |
| 635 | } | |
| 636 | ||
| 637 | function _prefsUtf8Len(s) { | |
| 638 | if (typeof TextEncoder !== 'undefined') return new TextEncoder().encode(s).length | |
| 639 | return unescape(encodeURIComponent(s)).length | |
| 640 | } | |
| 641 | ||
| 642 | function _prefsEncode(key, value) { | |
| 643 | const q = JSON.stringify(key) | |
| 644 | const t = typeof value | |
| 645 | ||
| 646 | if (t === 'string') { | |
| 647 | const bytes = _prefsUtf8Len(value) | |
| 648 | if (bytes > PREFS_MAX_VALUE_BYTES) { | |
| 649 | return { err: _prefsErr(RangeError, 'PREFS_VALUE_TOO_LARGE', `craft.prefs.set(${q}): value is ${bytes} bytes, limit is ${PREFS_MAX_VALUE_BYTES} — write data this size with craft.fs, not prefs.`) } | |
| 650 | } | |
| 651 | return { d: { k: key, t: 's', s: value } } | |
| 652 | } | |
| 653 | ||
| 654 | if (t === 'boolean') return { d: { k: key, t: 'b', b: value } } | |
| 655 | ||
| 656 | if (t === 'number') { | |
| 657 | if (!isFinite(value)) { | |
| 658 | return { err: _prefsErr(TypeError, 'PREFS_NON_FINITE', `craft.prefs.set(${q}): ${String(value)} cannot be stored — prefs holds finite numbers only.`) } | |
| 659 | } | |
| 660 | if (Number.isInteger(value) && Math.abs(value) <= Number.MAX_SAFE_INTEGER) | |
| 661 | return { d: { k: key, t: 'i', i: value } } | |
| 662 | return { d: { k: key, t: 'd', n: value } } | |
| 663 | } | |
| 664 | ||
| 665 | const kind = value === null | |
| 666 | ? 'null' | |
| 667 | : Array.isArray(value) | |
| 668 | ? 'array' | |
| 669 | : t === 'object' ? ((value.constructor && value.constructor.name) || 'object') : t | |
| 670 | ||
| 671 | let msg = `craft.prefs.set(${q}): prefs stores string, number and boolean only — got ${kind}. ` | |
| 672 | + `Serialise it yourself: craft.prefs.set(${q}, JSON.stringify(value)) and JSON.parse(await craft.prefs.get(${q}, "null")).` | |
| 673 | if (value === null || t === 'undefined') | |
| 674 | msg += ` To remove a key, call craft.prefs.delete(${q}).` | |
| 675 | return { err: _prefsErr(TypeError, 'PREFS_UNSUPPORTED_VALUE', msg) } | |
| 676 | } | |
| 677 | ||
| 678 | window.craft.prefs = { | |
| 679 | get: function (key, fallback) { | |
| 680 | const bad = _prefsKey('get', key) | |
| 681 | if (bad) return Promise.reject(bad) | |
| 682 | return _req('prefs', 'prefs:get', _stringify({ k: key }), PREFS_TIMEOUT_MS).then(function (r) { | |
| 683 | const tag = r && r.t | |
| 684 | if (tag === 'none') return fallback | |
| 685 | if (tag === 'other') { | |
| 686 | throw _prefsErr(TypeError, 'PREFS_FOREIGN_VALUE', `craft.prefs.get(${JSON.stringify(key)}): the stored value is a ${r.cf || 'non-scalar'}, ` | |
| 687 | + `which craft.prefs cannot represent. Something outside craft wrote it (e.g. \`defaults write\`). ` | |
| 688 | + `craft.prefs.delete(${JSON.stringify(key)}) removes it.`) | |
| 689 | } | |
| 690 | let v | |
| 691 | if (tag === 's') v = r.s | |
| 692 | else if (tag === 'i') v = r.i | |
| 693 | else if (tag === 'd') v = r.n | |
| 694 | else if (tag === 'b') v = !!r.b | |
| 695 | else return fallback | |
| 696 | // A fallback also declares the expected type, so a value left behind by | |
| 697 | // an older build of the app hands back the default rather than a | |
| 698 | // surprise. Call get(key) with no fallback to read whatever is stored. | |
| 699 | if (fallback !== undefined && typeof v !== typeof fallback) return fallback | |
| 700 | return v | |
| 701 | }) | |
| 702 | }, | |
| 703 | ||
| 704 | set: function (key, value) { | |
| 705 | const bad = _prefsKey('set', key) | |
| 706 | if (bad) return Promise.reject(bad) | |
| 707 | const enc = _prefsEncode(key, value) | |
| 708 | if (enc.err) return Promise.reject(enc.err) | |
| 709 | // A request, not fire-and-forget: a resolved set() has to mean the bytes | |
| 710 | // are on disk, and fire-and-forget has no rejection channel at all. | |
| 711 | return _req('prefs', 'prefs:set', _stringify(enc.d), PREFS_TIMEOUT_MS) | |
| 712 | .then(function () { return undefined }) | |
| 713 | }, | |
| 714 | ||
| 715 | delete: function (key) { | |
| 716 | const bad = _prefsKey('delete', key) | |
| 717 | if (bad) return Promise.reject(bad) | |
| 718 | return _req('prefs', 'prefs:delete', _stringify({ k: key }), PREFS_TIMEOUT_MS) | |
| 719 | .then(function (r) { return !!(r && r.existed) }) | |
| 720 | }, | |
| 721 | ||
| 722 | clear: function () { | |
| 723 | return _req('prefs', 'prefs:clear', '{}', PREFS_TIMEOUT_MS) | |
| 724 | .then(function (r) { return (r && r.removed) || 0 }) | |
| 725 | }, | |
| 726 | ||
| 727 | keys: function () { | |
| 728 | return _req('prefs', 'prefs:keys', '{}', PREFS_TIMEOUT_MS) | |
| 729 | .then(function (r) { return (r && r.keys) || [] }) | |
| 730 | }, | |
| 731 | ||
| 732 | // Which preferences domain is actually in use, and the command to read it. | |
| 733 | // An unbundled dev binary writes to a domain named after the executable and | |
| 734 | // a packaged .app writes to its bundle id, so preferences set in | |
| 735 | // development can appear to vanish once the app is packaged. This makes | |
| 736 | // that a question with an answer. | |
| 737 | info: function () { return _req('prefs', 'prefs:info', '{}', PREFS_TIMEOUT_MS) }, | |
| 738 | } | |
| 739 | ||
| 565 | 740 | // ------------------------------------------------------------------------- |
| 566 | 741 | // theme — system appearance |
| 567 | 742 | // ------------------------------------------------------------------------- |
| @@ -291,6 +291,7 @@ pub const system = scoped("System"); | ||
| 291 | 291 | pub const webview = scoped("WebView"); |
| 292 | 292 | pub const marketplace = scoped("Marketplace"); |
| 293 | 293 | pub const shortcuts = scoped("Shortcuts"); |
| 294 | pub const prefs = scoped("Prefs"); | |
| 294 | 295 | |
| 295 | 296 | // ============================================ |
| 296 | 297 | // Tests |
| @@ -4024,6 +4024,7 @@ var global_service_menu_bridge: ?*.ServiceMenu | ||
| 4024 | 4024 | var global_serial_bridge: ?*.SerialBridge = null; |
| 4025 | 4025 | var global_focus_bridge: ?*.FocusBridge = null; |
| 4026 | 4026 | var global_screen_sharing_bridge: ?*.ScreenSharingBridge = null; |
| 4027 | var global_prefs_bridge: ?*.PrefsBridge = null; | |
| 4027 | 4028 | |
| 4028 | 4029 | pub fn setGlobalTrayHandle(handle: *anyopaque) void { |
| 4029 | 4030 | global_tray_handle_for_bridge = handle; |
| @@ -4312,6 +4313,11 @@ pub fn setupBridgeHandlers(allocator: std.mem.Allocator, tray_handle: ?*anyopaqu | ||
| 4312 | 4313 | global_screen_sharing_bridge = try allocator.create(T); |
| 4313 | 4314 | global_screen_sharing_bridge.?.* = T.init(allocator); |
| 4314 | 4315 | } |
| 4316 | if (global_prefs_bridge == null) { | |
| 4317 | const T = .PrefsBridge; | |
| 4318 | global_prefs_bridge = try allocator.create(T); | |
| 4319 | global_prefs_bridge.?.* = T.init(allocator); | |
| 4320 | } | |
| 4315 | 4321 | |
| 4316 | 4322 | // theme + dragOut + deepLink: native modules with their own state, no |
| 4317 | 4323 | // shared bridge struct — they install handlers directly via init(). |
| @@ -4675,6 +4681,8 @@ pub fn handleBridgeMessageJSON(json_str: []const u8) !void { | ||
| 4675 | 4681 | if (global_focus_bridge) |bridge| try bridge.handleMessage(action, data_json_str); |
| 4676 | 4682 | } else if (std.mem.eql(u8, msg_type, "screenSharing")) { |
| 4677 | 4683 | if (global_screen_sharing_bridge) |bridge| try bridge.handleMessage(action, data_json_str); |
| 4684 | } else if (std.mem.eql(u8, msg_type, "prefs")) { | |
| 4685 | if (global_prefs_bridge) |bridge| try bridge.handleMessage(action, data_json_str); | |
| 4678 | 4686 | } else if (std.mem.eql(u8, msg_type, "debug")) { |
| 4679 | 4687 | // Handle debug messages |
| 4680 | 4688 | if (comptime builtin.mode == .debug) { |
| @@ -6080,6 +6088,56 @@ fn menuRole(comptime name: []const u8) [*:0]const u8 { | ||
| 6080 | 6088 | /// Declaring the bar as data rather than as ~40 lines of msgSend per menu is |
| 6081 | 6089 | /// what keeps it readable at this size — and it keeps the default bar and an |
| 6082 | 6090 | /// app-declared one resolving roles through the same table. |
| 6091 | /// The action craft's own Settings… item performs. | |
| 6092 | /// | |
| 6093 | /// Deliberately not a `menu_roles.zig` role. That table can only hold real | |
| 6094 | /// AppKit action selectors, and AppKit publishes none for the Settings item — | |
| 6095 | /// a nil-target item naming a selector nobody implements is auto-disabled by | |
| 6096 | /// AppKit, which is how craft once shipped a menu bar of grey text. | |
| 6097 | pub const settings_action_selector: [*:0]const u8 = "craftOpenSettings:"; | |
| 6098 | ||
| 6099 | var settingsTargetClass: objc.Class = null; | |
| 6100 | var settings_target: ?objc.id = null; | |
| 6101 | ||
| 6102 | fn craftOpenSettingsCallback(_: objc.id, _: objc.SEL, _: objc.id) callconv(.c) void { | |
| 6103 | // A compile-time constant with nothing interpolated into it, so no caller | |
| 6104 | // bytes reach the page and there is nothing here to escape. | |
| 6105 | .evalJS( | |
| 6106 | "if(window.__craftSettingsOpen)window.__craftSettingsOpen();", | |
| 6107 | ) catch |err| { | |
| 6108 | if (comptime builtin.mode == .debug) | |
| 6109 | std.debug.print("[Menu] Settings chosen but no webview to notify: {any}\n", .{err}); | |
| 6110 | }; | |
| 6111 | } | |
| 6112 | ||
| 6113 | /// One retained target for every Settings item in the process. | |
| 6114 | /// | |
| 6115 | /// Cached, because `objc_allocateClassPair` returns nil for a name that is | |
| 6116 | /// already registered — a second unguarded call would hand back a null target | |
| 6117 | /// and AppKit would grey the item out. Retained, because NSMenuItem does not | |
| 6118 | /// retain its target: a local would be released and the item would message | |
| 6119 | /// freed memory on the first click. | |
| 6120 | pub fn ensureSettingsTarget() ?objc.id { | |
| 6121 | if (comptime builtin.os.tag != .macos) return null; | |
| 6122 | if (settings_target != null) return settings_target; | |
| 6123 | ||
| 6124 | if (settingsTargetClass == null) { | |
| 6125 | const NSObject = getClass("NSObject"); | |
| 6126 | settingsTargetClass = objc.objc_allocateClassPair(NSObject, "CraftSettingsTarget", 0); | |
| 6127 | if (settingsTargetClass == null) return null; | |
| 6128 | _ = objc.class_addMethod( | |
| 6129 | settingsTargetClass, | |
| 6130 | sel(settings_action_selector), | |
| 6131 | @ptrCast(@constCast(&craftOpenSettingsCallback)), | |
| 6132 | "v@:@", | |
| 6133 | ); | |
| 6134 | objc.objc_registerClassPair(settingsTargetClass); | |
| 6135 | } | |
| 6136 | ||
| 6137 | settings_target = msgSend0(msgSend0(settingsTargetClass, "alloc"), "init"); | |
| 6138 | return settings_target; | |
| 6139 | } | |
| 6140 | ||
| 6083 | 6141 | const DefaultItem = struct { |
| 6084 | 6142 | title: []const u8 = "", |
| 6085 | 6143 | /// What the item performs. Null is a separator. |
| @@ -6090,6 +6148,9 @@ const DefaultItem = struct { | ||
| 6090 | 6148 | extra_modifiers: c_ulong = 0, |
| 6091 | 6149 | /// Append the app name to the title, as the App menu does: "Quit Craft". |
| 6092 | 6150 | with_app_name: bool = false, |
| 6151 | /// Point the item at craft's own target instead of leaving it nil for the | |
| 6152 | /// responder chain. Only meaningful with `settings_action_selector`. | |
| 6153 | craft_target: bool = false, | |
| 6093 | 6154 | }; |
| 6094 | 6155 | |
| 6095 | 6156 | const DefaultMenu = struct { |
| @@ -6111,6 +6172,12 @@ const DefaultMenu = struct { | ||
| 6111 | 6172 | const app_menu_items = [_]DefaultItem{ |
| 6112 | 6173 | .{ .title = "About", .selector = menuRole("about"), .with_app_name = true }, |
| 6113 | 6174 | .{}, |
| 6175 | // Every Mac app has this, at exactly this position, on exactly this key. | |
| 6176 | // An app that calls `craft.menu.set()` replaces the whole bar and loses it, | |
| 6177 | // which is correct — it can declare `role: "settings"` to get it back, and | |
| 6178 | // the same target answers either way. | |
| 6179 | .{ .title = "Settings\u{2026}", .selector = settings_action_selector, .key = ",", .craft_target = true }, | |
| 6180 | .{}, | |
| 6114 | 6181 | .{ .title = "Hide", .selector = menuRole("hide"), .key = "h", .with_app_name = true }, |
| 6115 | 6182 | .{ .title = "Hide Others", .selector = menuRole("hideOthers"), .key = "h", .extra_modifiers = modifier_option }, |
| 6116 | 6183 | .{ .title = "Show All", .selector = menuRole("showAll") }, |
| @@ -6257,6 +6324,17 @@ pub fn createApplicationMenu() void { | ||
| 6257 | 6324 | sel(selector), |
| 6258 | 6325 | createNSString(item.key), |
| 6259 | 6326 | ); |
| 6327 | if (item.craft_target) { | |
| 6328 | const target = ensureSettingsTarget() orelse { | |
| 6329 | // Without a target AppKit auto-disables the item, and a | |
| 6330 | // permanently grey Cmd+, is a worse outcome than no Cmd+, | |
| 6331 | // at all — so it is omitted rather than added dead. | |
| 6332 | if (comptime builtin.mode == .debug) | |
| 6333 | std.debug.print("[Menu] Settings target unavailable; omitting the item\n", .{}); | |
| 6334 | continue; | |
| 6335 | }; | |
| 6336 | msgSendVoid1(native_item, "setTarget:", target); | |
| 6337 | } | |
| 6260 | 6338 | if (item.extra_modifiers != 0) { |
| 6261 | 6339 | msgSendVoid1Ulong(native_item, "setKeyEquivalentModifierMask:", modifier_command | item.extra_modifiers); |
| 6262 | 6340 | } |
packages/zig/src/prefs.zigadded+709-0
| @@ -0,0 +1,709 @@ | ||
| 1 | //! `window.craft.prefs` — a small preference store, minus the platform. | |
| 2 | //! | |
| 3 | //! Everything here is pure: key rules, the value model, the wire codec and the | |
| 4 | //! read/write bookkeeping, over a `Backend` seam that a test can fill with a | |
| 5 | //! hash map. The macOS half is `prefs_macos.zig`; the bridge adapter is | |
| 6 | //! `bridge_prefs.zig`. | |
| 7 | //! | |
| 8 | //! **Scalars only — string, number, boolean.** That is not a corner cut, it is | |
| 9 | //! what makes a whole class of crash unreachable. The preferences API raises | |
| 10 | //! `NSInvalidArgumentException` for any value that is not a property-list type, | |
| 11 | //! recursively, and Zig cannot catch an Objective-C exception — so a page that | |
| 12 | //! passed `{fn: () => {}}` through a container-accepting bridge would take the | |
| 13 | //! app down. With three scalar constructors there is no code path in craft that | |
| 14 | //! *can* build a CFArray or CFDictionary out of page input. An app that needs | |
| 15 | //! structure serialises it: `prefs.set(k, JSON.stringify(v))`. | |
| 16 | //! | |
| 17 | //! Every key craft owns is written under `prefix`, so `clear()` and `keys()` | |
| 18 | //! cannot touch the AppKit and WebKit keys that share the same domain — a | |
| 19 | //! dev-mode craft domain already holds `NSWindow Frame …` entries. | |
| 20 | ||
| 21 | const std = ; | |
| 22 | const bridge_error = ; | |
| 23 | ||
| 24 | /// Namespace for every key craft.prefs owns, inside a domain it shares with | |
| 25 | /// AppKit, WebKit and the app itself. | |
| 26 | pub const prefix = "craft.prefs."; | |
| 27 | ||
| 28 | /// Longest key an app may use, before prefixing. | |
| 29 | pub const max_key_len = 64; | |
| 30 | ||
| 31 | /// Largest string value. Preferences are read into memory eagerly by the OS | |
| 32 | /// and are the wrong place for documents; the refusal message says so and | |
| 33 | /// names `craft.fs` instead. | |
| 34 | pub const max_value_bytes = 8 * 1024; | |
| 35 | ||
| 36 | pub const Error = error{ | |
| 37 | InvalidKey, | |
| 38 | ValueTooLarge, | |
| 39 | UnsupportedValue, | |
| 40 | BackendFailure, | |
| 41 | OutOfMemory, | |
| 42 | }; | |
| 43 | ||
| 44 | /// The only value types craft.prefs stores. | |
| 45 | pub const Value = union(enum) { | |
| 46 | string: []const u8, | |
| 47 | int: i64, | |
| 48 | float: f64, | |
| 49 | boolean: bool, | |
| 50 | ||
| 51 | pub fn eql(self: Value, other: Value) bool { | |
| 52 | return switch (self) { | |
| 53 | .string => |s| other == .string and std.mem.eql(u8, s, other.string), | |
| 54 | .int => |i| other == .int and i == other.int, | |
| 55 | .float => |f| other == .float and f == other.float, | |
| 56 | .boolean => |b| other == .boolean and b == other.boolean, | |
| 57 | }; | |
| 58 | } | |
| 59 | }; | |
| 60 | ||
| 61 | /// A value in the domain that craft cannot represent. | |
| 62 | /// | |
| 63 | /// Only reachable by something outside craft writing the key — `defaults write | |
| 64 | /// … -array`, a native settings pane, an older build. Reported as itself rather | |
| 65 | /// than coerced, because silently turning an array into `undefined` is how an | |
| 66 | /// app loses data without noticing. | |
| 67 | pub const Foreign = struct { | |
| 68 | /// CoreFoundation's own name for the type, e.g. "CFArray". | |
| 69 | cf_type: []const u8, | |
| 70 | /// Set when the type was right but the value was too large to hand back. | |
| 71 | bytes: ?usize = null, | |
| 72 | }; | |
| 73 | ||
| 74 | pub const Read = union(enum) { | |
| 75 | absent, | |
| 76 | value: Value, | |
| 77 | foreign: Foreign, | |
| 78 | }; | |
| 79 | ||
| 80 | /// Where the values actually live. | |
| 81 | /// | |
| 82 | /// Backends see **full** keys (`craft.prefs.theme`) and know nothing about the | |
| 83 | /// prefix, the key rules or the wire format — all of that is tested up here, | |
| 84 | /// against `MemoryBackend`, on any host. | |
| 85 | pub const Backend = struct { | |
| 86 | ctx: *anyopaque, | |
| 87 | vtable: *const VTable, | |
| 88 | ||
| 89 | pub const VTable = struct { | |
| 90 | /// A returned `.value.string` is owned by `gpa`; the caller frees it. | |
| 91 | get: *const fn (ctx: *anyopaque, gpa: std.mem.Allocator, full_key: []const u8) Error!Read, | |
| 92 | set: *const fn (ctx: *anyopaque, full_key: []const u8, value: Value) Error!void, | |
| 93 | /// Returns whether the key was there to begin with. | |
| 94 | remove: *const fn (ctx: *anyopaque, full_key: []const u8) Error!bool, | |
| 95 | /// Every key in the domain, prefixed and unprefixed alike. The core | |
| 96 | /// filters, so a backend cannot accidentally hide one. | |
| 97 | keys: *const fn (ctx: *anyopaque, gpa: std.mem.Allocator) Error![][]u8, | |
| 98 | /// Flush to disk. Called once per mutating operation — not once per key. | |
| 99 | sync: *const fn (ctx: *anyopaque) Error!void, | |
| 100 | }; | |
| 101 | }; | |
| 102 | ||
| 103 | /// `^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$` | |
| 104 | /// | |
| 105 | /// Narrow on purpose, and it pays for itself four times over: a leading `@` | |
| 106 | /// can never reach a KVC path (`valueForKey:@"@sum"` throws); a key can never | |
| 107 | /// contain a quote, backslash or control byte, so echoing it into reply JSON is | |
| 108 | /// safe by construction; the bound sizes every buffer; and a key can never look | |
| 109 | /// like a command-line flag. | |
| 110 | pub fn validateKey(key: []const u8) Error!void { | |
| 111 | if (key.len == 0 or key.len > max_key_len) return Error.InvalidKey; | |
| 112 | if (!std.ascii.isAlphanumeric(key[0])) return Error.InvalidKey; | |
| 113 | for (key[1..]) |c| { | |
| 114 | const ok = std.ascii.isAlphanumeric(c) or c == '_' or c == '.' or c == '-'; | |
| 115 | if (!ok) return Error.InvalidKey; | |
| 116 | } | |
| 117 | } | |
| 118 | ||
| 119 | pub const FullKeyBuf = [prefix.len + max_key_len]u8; | |
| 120 | ||
| 121 | /// `theme` -> `craft.prefs.theme`, validating on the way. | |
| 122 | pub fn fullKey(buf: *FullKeyBuf, key: []const u8) Error![]const u8 { | |
| 123 | try validateKey(key); | |
| 124 | @memcpy(buf[0..prefix.len], prefix); | |
| 125 | @memcpy(buf[prefix.len..][0..key.len], key); | |
| 126 | return buf[0 .. prefix.len + key.len]; | |
| 127 | } | |
| 128 | ||
| 129 | /// The app-visible key, or null if this key is not craft's. | |
| 130 | pub fn stripPrefix(full: []const u8) ?[]const u8 { | |
| 131 | if (!std.mem.startsWith(u8, full, prefix)) return null; | |
| 132 | const rest = full[prefix.len..]; | |
| 133 | if (rest.len == 0) return null; | |
| 134 | return rest; | |
| 135 | } | |
| 136 | ||
| 137 | pub fn validateValue(v: Value) Error!void { | |
| 138 | switch (v) { | |
| 139 | .string => |s| if (s.len > max_value_bytes) return Error.ValueTooLarge, | |
| 140 | // A non-finite double has no property-list representation: it would | |
| 141 | // round-trip through the plist as a corrupt number or fail the write. | |
| 142 | .float => |f| if (!std.math.isFinite(f)) return Error.UnsupportedValue, | |
| 143 | else => {}, | |
| 144 | } | |
| 145 | } | |
| 146 | ||
| 147 | pub const Store = struct { | |
| 148 | backend: Backend, | |
| 149 | ||
| 150 | pub fn get(self: Store, gpa: std.mem.Allocator, key: []const u8) Error!Read { | |
| 151 | var buf: FullKeyBuf = undefined; | |
| 152 | const full = try fullKey(&buf, key); | |
| 153 | return self.backend.vtable.get(self.backend.ctx, gpa, full); | |
| 154 | } | |
| 155 | ||
| 156 | pub fn set(self: Store, key: []const u8, value: Value) Error!void { | |
| 157 | var buf: FullKeyBuf = undefined; | |
| 158 | const full = try fullKey(&buf, key); | |
| 159 | try validateValue(value); | |
| 160 | try self.backend.vtable.set(self.backend.ctx, full, value); | |
| 161 | try self.backend.vtable.sync(self.backend.ctx); | |
| 162 | } | |
| 163 | ||
| 164 | pub fn remove(self: Store, key: []const u8) Error!bool { | |
| 165 | var buf: FullKeyBuf = undefined; | |
| 166 | const full = try fullKey(&buf, key); | |
| 167 | const existed = try self.backend.vtable.remove(self.backend.ctx, full); | |
| 168 | try self.backend.vtable.sync(self.backend.ctx); | |
| 169 | return existed; | |
| 170 | } | |
| 171 | ||
| 172 | /// Every key the app has set, stripped of the prefix and sorted. | |
| 173 | /// | |
| 174 | /// Sorted so `keys()` is stable across launches: the backend's enumeration | |
| 175 | /// order is the preferences daemon's, which is not. | |
| 176 | pub fn keys(self: Store, gpa: std.mem.Allocator) Error![][]u8 { | |
| 177 | const all = try self.backend.vtable.keys(self.backend.ctx, gpa); | |
| 178 | defer { | |
| 179 | for (all) |k| gpa.free(k); | |
| 180 | gpa.free(all); | |
| 181 | } | |
| 182 | ||
| 183 | var out: std.ArrayListUnmanaged([]u8) = .empty; | |
| 184 | errdefer { | |
| 185 | for (out.items) |k| gpa.free(k); | |
| 186 | out.deinit(gpa); | |
| 187 | } | |
| 188 | ||
| 189 | for (all) |full| { | |
| 190 | const app_key = stripPrefix(full) orelse continue; | |
| 191 | const owned = gpa.dupe(u8, app_key) catch return Error.OutOfMemory; | |
| 192 | out.append(gpa, owned) catch { | |
| 193 | gpa.free(owned); | |
| 194 | return Error.OutOfMemory; | |
| 195 | }; | |
| 196 | } | |
| 197 | ||
| 198 | const slice = out.toOwnedSlice(gpa) catch return Error.OutOfMemory; | |
| 199 | std.mem.sort([]u8, slice, {}, struct { | |
| 200 | fn lessThan(_: void, a: []u8, b: []u8) bool { | |
| 201 | return std.mem.order(u8, a, b) == .lt; | |
| 202 | } | |
| 203 | }.lessThan); | |
| 204 | return slice; | |
| 205 | } | |
| 206 | ||
| 207 | /// Remove every key craft owns, and only those. | |
| 208 | /// | |
| 209 | /// Not `removePersistentDomainForName:` or its CF equivalent: craft shares | |
| 210 | /// this domain with AppKit and WebKit, and a dev-mode domain demonstrably | |
| 211 | /// already holds `NSWindow Frame …` entries. Wiping it would throw away | |
| 212 | /// window positions and inspector state along with the app's preferences. | |
| 213 | pub fn clear(self: Store, gpa: std.mem.Allocator) Error!usize { | |
| 214 | const all = try self.backend.vtable.keys(self.backend.ctx, gpa); | |
| 215 | defer { | |
| 216 | for (all) |k| gpa.free(k); | |
| 217 | gpa.free(all); | |
| 218 | } | |
| 219 | ||
| 220 | var removed: usize = 0; | |
| 221 | for (all) |full| { | |
| 222 | if (stripPrefix(full) == null) continue; | |
| 223 | if (try self.backend.vtable.remove(self.backend.ctx, full)) removed += 1; | |
| 224 | } | |
| 225 | // Once, not once per key: a sync is a write to the preferences daemon. | |
| 226 | try self.backend.vtable.sync(self.backend.ctx); | |
| 227 | return removed; | |
| 228 | } | |
| 229 | }; | |
| 230 | ||
| 231 | // ============================================================================= | |
| 232 | // The wire codec | |
| 233 | // ============================================================================= | |
| 234 | // | |
| 235 | // The type travels explicitly in both directions — `{"t":"i","i":42}`, never a | |
| 236 | // bare `42` — so neither side has to infer, and a tag that disagrees with the | |
| 237 | // field beside it is an error rather than a silent default. This is the half | |
| 238 | // most likely to drift from craft-bridge.js, which is why it is tested against | |
| 239 | // the real injected script in `test/injected_js_test.zig`. | |
| 240 | ||
| 241 | /// `{"k":"theme","t":"s","s":"dark"}` | |
| 242 | pub const SetShape = struct { | |
| 243 | k: []const u8 = "", | |
| 244 | t: []const u8 = "", | |
| 245 | s: ?[]const u8 = null, | |
| 246 | i: ?i64 = null, | |
| 247 | n: ?f64 = null, | |
| 248 | b: ?bool = null, | |
| 249 | }; | |
| 250 | ||
| 251 | /// `{"k":"theme"}` | |
| 252 | pub const KeyShape = struct { | |
| 253 | k: []const u8 = "", | |
| 254 | }; | |
| 255 | ||
| 256 | pub const SetRequest = struct { | |
| 257 | key: []const u8, | |
| 258 | value: Value, | |
| 259 | }; | |
| 260 | ||
| 261 | /// Turn a decoded payload into a key and a value, refusing any disagreement | |
| 262 | /// between the tag and the fields carried beside it. | |
| 263 | pub fn decodeSet(shape: SetShape) Error!SetRequest { | |
| 264 | try validateKey(shape.k); | |
| 265 | ||
| 266 | const value: Value = if (std.mem.eql(u8, shape.t, "s")) | |
| 267 | .{ .string = shape.s orelse return Error.UnsupportedValue } | |
| 268 | else if (std.mem.eql(u8, shape.t, "i")) | |
| 269 | .{ .int = shape.i orelse return Error.UnsupportedValue } | |
| 270 | else if (std.mem.eql(u8, shape.t, "d")) | |
| 271 | .{ .float = shape.n orelse return Error.UnsupportedValue } | |
| 272 | else if (std.mem.eql(u8, shape.t, "b")) | |
| 273 | .{ .boolean = shape.b orelse return Error.UnsupportedValue } | |
| 274 | else | |
| 275 | return Error.UnsupportedValue; | |
| 276 | ||
| 277 | try validateValue(value); | |
| 278 | return .{ .key = shape.k, .value = value }; | |
| 279 | } | |
| 280 | ||
| 281 | /// The reply to `prefs:get`. | |
| 282 | pub fn appendReadJson(gpa: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8), r: Read) !void { | |
| 283 | switch (r) { | |
| 284 | .absent => try out.appendSlice(gpa, "{\"t\":\"none\"}"), | |
| 285 | .foreign => |f| { | |
| 286 | try out.appendSlice(gpa, "{\"t\":\"other\",\"cf\":\""); | |
| 287 | try bridge_error.appendJsonEscaped(gpa, out, f.cf_type); | |
| 288 | try out.appendSlice(gpa, "\""); | |
| 289 | if (f.bytes) |n| { | |
| 290 | const num = try std.fmt.allocPrint(gpa, ",\"bytes\":{d}", .{n}); | |
| 291 | defer gpa.free(num); | |
| 292 | try out.appendSlice(gpa, num); | |
| 293 | } | |
| 294 | try out.appendSlice(gpa, "}"); | |
| 295 | }, | |
| 296 | .value => |v| switch (v) { | |
| 297 | .string => |s| { | |
| 298 | try out.appendSlice(gpa, "{\"t\":\"s\",\"s\":\""); | |
| 299 | try bridge_error.appendJsonEscaped(gpa, out, s); | |
| 300 | try out.appendSlice(gpa, "\"}"); | |
| 301 | }, | |
| 302 | .int => |i| { | |
| 303 | const text = try std.fmt.allocPrint(gpa, "", .{i}); | |
| 304 | defer gpa.free(text); | |
| 305 | try out.appendSlice(gpa, text); | |
| 306 | }, | |
| 307 | .float => |f| { | |
| 308 | const text = try std.fmt.allocPrint(gpa, "", .{f}); | |
| 309 | defer gpa.free(text); | |
| 310 | try out.appendSlice(gpa, text); | |
| 311 | }, | |
| 312 | .boolean => |b| try out.appendSlice(gpa, if (b) | |
| 313 | "{\"t\":\"b\",\"b\":true}" | |
| 314 | else | |
| 315 | "{\"t\":\"b\",\"b\":false}"), | |
| 316 | }, | |
| 317 | } | |
| 318 | } | |
| 319 | ||
| 320 | /// The reply to `prefs:keys`. | |
| 321 | pub fn appendKeysJson(gpa: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8), key_list: []const []const u8) !void { | |
| 322 | try out.appendSlice(gpa, "{\"keys\":["); | |
| 323 | for (key_list, 0..) |k, index| { | |
| 324 | if (index > 0) try out.append(gpa, ','); | |
| 325 | try out.append(gpa, '"'); | |
| 326 | // `validateKey` already rules out every byte JSON would need escaped, | |
| 327 | // but these come back from the preferences domain rather than from a | |
| 328 | // caller, so they are escaped anyway. | |
| 329 | try bridge_error.appendJsonEscaped(gpa, out, k); | |
| 330 | try out.append(gpa, '"'); | |
| 331 | } | |
| 332 | try out.appendSlice(gpa, "]}"); | |
| 333 | } | |
| 334 | ||
| 335 | // ============================================================================= | |
| 336 | // MemoryBackend | |
| 337 | // ============================================================================= | |
| 338 | ||
| 339 | /// A backend in a hash map. | |
| 340 | /// | |
| 341 | /// Not a production shim: it exists so every rule above is provable in | |
| 342 | /// `zig build test` on a machine with no preferences daemon, and so the | |
| 343 | /// sync-once-per-operation contract can be asserted with a counter. | |
| 344 | pub const MemoryBackend = struct { | |
| 345 | gpa: std.mem.Allocator, | |
| 346 | map: std.StringHashMapUnmanaged(Value) = .empty, | |
| 347 | syncs: usize = 0, | |
| 348 | ||
| 349 | pub fn init(gpa: std.mem.Allocator) MemoryBackend { | |
| 350 | return .{ .gpa = gpa }; | |
| 351 | } | |
| 352 | ||
| 353 | pub fn deinit(self: *MemoryBackend) void { | |
| 354 | var it = self.map.iterator(); | |
| 355 | while (it.next()) |entry| { | |
| 356 | self.gpa.free(entry.key_ptr.*); | |
| 357 | if (entry.value_ptr.* == .string) self.gpa.free(entry.value_ptr.string); | |
| 358 | } | |
| 359 | self.map.deinit(self.gpa); | |
| 360 | } | |
| 361 | ||
| 362 | pub fn backend(self: *MemoryBackend) Backend { | |
| 363 | return .{ .ctx = self, .vtable = &vtable }; | |
| 364 | } | |
| 365 | ||
| 366 | const vtable = Backend.VTable{ | |
| 367 | .get = getImpl, | |
| 368 | .set = setImpl, | |
| 369 | .remove = removeImpl, | |
| 370 | .keys = keysImpl, | |
| 371 | .sync = syncImpl, | |
| 372 | }; | |
| 373 | ||
| 374 | fn self_(ctx: *anyopaque) *MemoryBackend { | |
| 375 | return @ptrCast(@alignCast(ctx)); | |
| 376 | } | |
| 377 | ||
| 378 | fn getImpl(ctx: *anyopaque, gpa: std.mem.Allocator, full_key: []const u8) Error!Read { | |
| 379 | const me = self_(ctx); | |
| 380 | const found = me.map.get(full_key) orelse return .absent; | |
| 381 | return switch (found) { | |
| 382 | // Copied, because the caller owns and frees what it gets back — | |
| 383 | // the same contract the CoreFoundation backend has to honour. | |
| 384 | .string => |s| .{ .value = .{ .string = gpa.dupe(u8, s) catch return Error.OutOfMemory } }, | |
| 385 | else => .{ .value = found }, | |
| 386 | }; | |
| 387 | } | |
| 388 | ||
| 389 | fn setImpl(ctx: *anyopaque, full_key: []const u8, value: Value) Error!void { | |
| 390 | const me = self_(ctx); | |
| 391 | const stored: Value = switch (value) { | |
| 392 | .string => |s| .{ .string = me.gpa.dupe(u8, s) catch return Error.OutOfMemory }, | |
| 393 | else => value, | |
| 394 | }; | |
| 395 | errdefer if (stored == .string) me.gpa.free(stored.string); | |
| 396 | ||
| 397 | if (me.map.getEntry(full_key)) |entry| { | |
| 398 | if (entry.value_ptr.* == .string) me.gpa.free(entry.value_ptr.string); | |
| 399 | entry.value_ptr.* = stored; | |
| 400 | return; | |
| 401 | } | |
| 402 | const owned_key = me.gpa.dupe(u8, full_key) catch return Error.OutOfMemory; | |
| 403 | errdefer me.gpa.free(owned_key); | |
| 404 | me.map.put(me.gpa, owned_key, stored) catch return Error.OutOfMemory; | |
| 405 | } | |
| 406 | ||
| 407 | fn removeImpl(ctx: *anyopaque, full_key: []const u8) Error!bool { | |
| 408 | const me = self_(ctx); | |
| 409 | const entry = me.map.fetchRemove(full_key) orelse return false; | |
| 410 | me.gpa.free(entry.key); | |
| 411 | if (entry.value == .string) me.gpa.free(entry.value.string); | |
| 412 | return true; | |
| 413 | } | |
| 414 | ||
| 415 | fn keysImpl(ctx: *anyopaque, gpa: std.mem.Allocator) Error![][]u8 { | |
| 416 | const me = self_(ctx); | |
| 417 | var out: std.ArrayListUnmanaged([]u8) = .empty; | |
| 418 | errdefer { | |
| 419 | for (out.items) |k| gpa.free(k); | |
| 420 | out.deinit(gpa); | |
| 421 | } | |
| 422 | var it = me.map.keyIterator(); | |
| 423 | while (it.next()) |k| { | |
| 424 | const owned = gpa.dupe(u8, k.*) catch return Error.OutOfMemory; | |
| 425 | out.append(gpa, owned) catch { | |
| 426 | gpa.free(owned); | |
| 427 | return Error.OutOfMemory; | |
| 428 | }; | |
| 429 | } | |
| 430 | return out.toOwnedSlice(gpa) catch Error.OutOfMemory; | |
| 431 | } | |
| 432 | ||
| 433 | fn syncImpl(ctx: *anyopaque) Error!void { | |
| 434 | self_(ctx).syncs += 1; | |
| 435 | } | |
| 436 | }; | |
| 437 | ||
| 438 | // ============================================================================= | |
| 439 | // Tests | |
| 440 | // ============================================================================= | |
| 441 | ||
| 442 | const testing = std.testing; | |
| 443 | ||
| 444 | fn testStore(mem: *MemoryBackend) Store { | |
| 445 | return .{ .backend = mem.backend() }; | |
| 446 | } | |
| 447 | ||
| 448 | test "keys are namespaced, so clear cannot touch what craft does not own" { | |
| 449 | var mem = MemoryBackend.init(testing.allocator); | |
| 450 | defer mem.deinit(); | |
| 451 | const store = testStore(&mem); | |
| 452 | ||
| 453 | try store.set("theme", .{ .string = "dark" }); | |
| 454 | // What AppKit and WebKit leave in the same domain. A dev-mode craft domain | |
| 455 | // really does hold entries like this. | |
| 456 | try mem.backend().vtable.set(&mem, "NSWindow Frame Main", .{ .string = "0 0 800 600" }); | |
| 457 | ||
| 458 | try testing.expectEqual(@as(usize, 1), try store.clear(testing.allocator)); | |
| 459 | ||
| 460 | const survivor = try mem.backend().vtable.get(&mem, testing.allocator, "NSWindow Frame Main"); | |
| 461 | defer if (survivor == .value and survivor.value == .string) testing.allocator.free(survivor.value.string); | |
| 462 | try testing.expect(survivor == .value); | |
| 463 | } | |
| 464 | ||
| 465 | test "keys() reports app keys, stripped and sorted, and nothing else" { | |
| 466 | var mem = MemoryBackend.init(testing.allocator); | |
| 467 | defer mem.deinit(); | |
| 468 | const store = testStore(&mem); | |
| 469 | ||
| 470 | try store.set("theme", .{ .string = "dark" }); | |
| 471 | try store.set("fontSize", .{ .int = 13 }); | |
| 472 | try mem.backend().vtable.set(&mem, "NSWindow Frame Main", .{ .string = "x" }); | |
| 473 | ||
| 474 | const listed = try store.keys(testing.allocator); | |
| 475 | defer { | |
| 476 | for (listed) |k| testing.allocator.free(k); | |
| 477 | testing.allocator.free(listed); | |
| 478 | } | |
| 479 | try testing.expectEqual(@as(usize, 2), listed.len); | |
| 480 | try testing.expectEqualStrings("fontSize", listed[0]); | |
| 481 | try testing.expectEqualStrings("theme", listed[1]); | |
| 482 | } | |
| 483 | ||
| 484 | test "a clear syncs once, not once per key" { | |
| 485 | // A sync is a round trip to the preferences daemon. Doing one per key turns | |
| 486 | // clearing twenty preferences into twenty flushes. | |
| 487 | var mem = MemoryBackend.init(testing.allocator); | |
| 488 | defer mem.deinit(); | |
| 489 | const store = testStore(&mem); | |
| 490 | ||
| 491 | try store.set("a", .{ .int = 1 }); | |
| 492 | try store.set("b", .{ .int = 2 }); | |
| 493 | try store.set("c", .{ .int = 3 }); | |
| 494 | const before = mem.syncs; | |
| 495 | _ = try store.clear(testing.allocator); | |
| 496 | try testing.expectEqual(before + 1, mem.syncs); | |
| 497 | } | |
| 498 | ||
| 499 | test "every mutating call flushes before it returns" { | |
| 500 | // A resolved set() has to mean the bytes are on disk: craft's hot reload | |
| 501 | // kills the process abruptly, and an unflushed preference is a lost one. | |
| 502 | var mem = MemoryBackend.init(testing.allocator); | |
| 503 | defer mem.deinit(); | |
| 504 | const store = testStore(&mem); | |
| 505 | ||
| 506 | try store.set("a", .{ .int = 1 }); | |
| 507 | try testing.expectEqual(@as(usize, 1), mem.syncs); | |
| 508 | _ = try store.remove("a"); | |
| 509 | try testing.expectEqual(@as(usize, 2), mem.syncs); | |
| 510 | } | |
| 511 | ||
| 512 | test "reading gives back an owned copy the caller frees" { | |
| 513 | // The whole suite runs under the testing allocator, so a backend that | |
| 514 | // handed out a borrowed slice — the bug storage.zig:266 shipped — would | |
| 515 | // show up as a leak or a use-after-free rather than as nothing. | |
| 516 | var mem = MemoryBackend.init(testing.allocator); | |
| 517 | defer mem.deinit(); | |
| 518 | const store = testStore(&mem); | |
| 519 | ||
| 520 | try store.set("theme", .{ .string = "dark" }); | |
| 521 | const read = try store.get(testing.allocator, "theme"); | |
| 522 | defer testing.allocator.free(read.value.string); | |
| 523 | try testing.expectEqualStrings("dark", read.value.string); | |
| 524 | } | |
| 525 | ||
| 526 | test "an absent key is absent, not empty" { | |
| 527 | var mem = MemoryBackend.init(testing.allocator); | |
| 528 | defer mem.deinit(); | |
| 529 | const store = testStore(&mem); | |
| 530 | ||
| 531 | try testing.expect(try store.get(testing.allocator, "nothing") == .absent); | |
| 532 | try testing.expect(!try store.remove("nothing")); | |
| 533 | } | |
| 534 | ||
| 535 | test "setting a key twice replaces rather than accumulating" { | |
| 536 | var mem = MemoryBackend.init(testing.allocator); | |
| 537 | defer mem.deinit(); | |
| 538 | const store = testStore(&mem); | |
| 539 | ||
| 540 | try store.set("theme", .{ .string = "dark" }); | |
| 541 | try store.set("theme", .{ .string = "light" }); | |
| 542 | try store.set("theme", .{ .int = 3 }); | |
| 543 | ||
| 544 | const read = try store.get(testing.allocator, "theme"); | |
| 545 | try testing.expect(read.value.eql(.{ .int = 3 })); | |
| 546 | ||
| 547 | const listed = try store.keys(testing.allocator); | |
| 548 | defer { | |
| 549 | for (listed) |k| testing.allocator.free(k); | |
| 550 | testing.allocator.free(listed); | |
| 551 | } | |
| 552 | try testing.expectEqual(@as(usize, 1), listed.len); | |
| 553 | } | |
| 554 | ||
| 555 | test "key rules" { | |
| 556 | try validateKey("a"); | |
| 557 | try validateKey("theme"); | |
| 558 | try validateKey("window.main.width"); | |
| 559 | try validateKey("font-size_2"); | |
| 560 | try validateKey("0"); | |
| 561 | ||
| 562 | try testing.expectError(Error.InvalidKey, validateKey("")); | |
| 563 | // A leading `@` would reach a KVC path: `valueForKey:@"@sum"` throws. | |
| 564 | try testing.expectError(Error.InvalidKey, validateKey("@media")); | |
| 565 | try testing.expectError(Error.InvalidKey, validateKey("-leading")); | |
| 566 | try testing.expectError(Error.InvalidKey, validateKey(".leading")); | |
| 567 | try testing.expectError(Error.InvalidKey, validateKey("has space")); | |
| 568 | try testing.expectError(Error.InvalidKey, validateKey("has\"quote")); | |
| 569 | try testing.expectError(Error.InvalidKey, validateKey("has\\backslash")); | |
| 570 | try testing.expectError(Error.InvalidKey, validateKey("has\nnewline")); | |
| 571 | try testing.expectError(Error.InvalidKey, validateKey("has\x00nul")); | |
| 572 | var long_key: [max_key_len + 1]u8 = undefined; | |
| 573 | @memset(&long_key, 'a'); | |
| 574 | try testing.expectError(Error.InvalidKey, validateKey(&long_key)); | |
| 575 | try validateKey(long_key[0..max_key_len]); | |
| 576 | } | |
| 577 | ||
| 578 | test "a key can never be mistaken for a command-line flag" { | |
| 579 | // Not decorative. The convenience preferences API reads argv as a domain: | |
| 580 | // `craft -theme dark` makes CFPreferencesCopyAppValue("theme") return | |
| 581 | // "dark". craft.prefs reads the exact domain instead, and the key rules | |
| 582 | // are the second lock on that door. | |
| 583 | try testing.expectError(Error.InvalidKey, validateKey("-theme")); | |
| 584 | try testing.expectError(Error.InvalidKey, validateKey("--theme")); | |
| 585 | } | |
| 586 | ||
| 587 | test "the prefix round-trips and is the only thing stripPrefix accepts" { | |
| 588 | var buf: FullKeyBuf = undefined; | |
| 589 | try testing.expectEqualStrings("craft.prefs.theme", try fullKey(&buf, "theme")); | |
| 590 | try testing.expectEqualStrings("theme", stripPrefix("craft.prefs.theme").?); | |
| 591 | ||
| 592 | try testing.expect(stripPrefix("theme") == null); | |
| 593 | try testing.expect(stripPrefix("NSWindow Frame Main") == null); | |
| 594 | // The bare prefix names no key. | |
| 595 | try testing.expect(stripPrefix(prefix) == null); | |
| 596 | try testing.expect(stripPrefix("craft.prefs") == null); | |
| 597 | } | |
| 598 | ||
| 599 | test "an invalid key is refused before it reaches the backend" { | |
| 600 | var mem = MemoryBackend.init(testing.allocator); | |
| 601 | defer mem.deinit(); | |
| 602 | const store = testStore(&mem); | |
| 603 | ||
| 604 | try testing.expectError(Error.InvalidKey, store.set("has space", .{ .int = 1 })); | |
| 605 | try testing.expectError(Error.InvalidKey, store.get(testing.allocator, "@media")); | |
| 606 | try testing.expectError(Error.InvalidKey, store.remove("")); | |
| 607 | try testing.expectEqual(@as(usize, 0), mem.syncs); | |
| 608 | } | |
| 609 | ||
| 610 | test "value limits" { | |
| 611 | const big = try testing.allocator.alloc(u8, max_value_bytes + 1); | |
| 612 | defer testing.allocator.free(big); | |
| 613 | @memset(big, 'x'); | |
| 614 | try testing.expectError(Error.ValueTooLarge, validateValue(.{ .string = big })); | |
| 615 | try validateValue(.{ .string = big[0..max_value_bytes] }); | |
| 616 | ||
| 617 | try testing.expectError(Error.UnsupportedValue, validateValue(.{ .float = std.math.inf(f64) })); | |
| 618 | try testing.expectError(Error.UnsupportedValue, validateValue(.{ .float = std.math.nan(f64) })); | |
| 619 | try validateValue(.{ .float = 1.5 }); | |
| 620 | try validateValue(.{ .int = std.math.maxInt(i64) }); | |
| 621 | } | |
| 622 | ||
| 623 | test "decodeSet refuses a tag that disagrees with the fields beside it" { | |
| 624 | try testing.expect((try decodeSet(.{ .k = "a", .t = "s", .s = "x" })).value.eql(.{ .string = "x" })); | |
| 625 | try testing.expect((try decodeSet(.{ .k = "a", .t = "i", .i = 42 })).value.eql(.{ .int = 42 })); | |
| 626 | try testing.expect((try decodeSet(.{ .k = "a", .t = "d", .n = 1.5 })).value.eql(.{ .float = 1.5 })); | |
| 627 | try testing.expect((try decodeSet(.{ .k = "a", .t = "b", .b = true })).value.eql(.{ .boolean = true })); | |
| 628 | ||
| 629 | // A tag with nothing behind it would otherwise decode to a zero value — | |
| 630 | // `{"t":"i"}` silently becoming 0 is the failure this rules out. | |
| 631 | try testing.expectError(Error.UnsupportedValue, decodeSet(.{ .k = "a", .t = "i" })); | |
| 632 | try testing.expectError(Error.UnsupportedValue, decodeSet(.{ .k = "a", .t = "s" })); | |
| 633 | try testing.expectError(Error.UnsupportedValue, decodeSet(.{ .k = "a", .t = "b" })); | |
| 634 | // A tag carrying the wrong field is the same mistake wearing a hat. | |
| 635 | try testing.expectError(Error.UnsupportedValue, decodeSet(.{ .k = "a", .t = "s", .b = true })); | |
| 636 | try testing.expectError(Error.UnsupportedValue, decodeSet(.{ .k = "a", .t = "q", .i = 1 })); | |
| 637 | try testing.expectError(Error.UnsupportedValue, decodeSet(.{ .k = "a", .t = "", .i = 1 })); | |
| 638 | // And the key is still checked. | |
| 639 | try testing.expectError(Error.InvalidKey, decodeSet(.{ .k = "has space", .t = "i", .i = 1 })); | |
| 640 | } | |
| 641 | ||
| 642 | fn readJson(r: Read) ![]u8 { | |
| 643 | var out: std.ArrayListUnmanaged(u8) = .empty; | |
| 644 | errdefer out.deinit(testing.allocator); | |
| 645 | try appendReadJson(testing.allocator, &out, r); | |
| 646 | return out.toOwnedSlice(testing.allocator); | |
| 647 | } | |
| 648 | ||
| 649 | test "the get reply names its own type" { | |
| 650 | const cases = [_]struct { r: Read, want: []const u8 }{ | |
| 651 | .{ .r = .absent, .want = "{\"t\":\"none\"}" }, | |
| 652 | .{ .r = .{ .value = .{ .boolean = true } }, .want = "{\"t\":\"b\",\"b\":true}" }, | |
| 653 | .{ .r = .{ .value = .{ .boolean = false } }, .want = "{\"t\":\"b\",\"b\":false}" }, | |
| 654 | .{ .r = .{ .value = .{ .int = 42 } }, .want = "{\"t\":\"i\",\"i\":42}" }, | |
| 655 | .{ .r = .{ .value = .{ .int = -7 } }, .want = "{\"t\":\"i\",\"i\":-7}" }, | |
| 656 | .{ .r = .{ .value = .{ .float = 1.5 } }, .want = "{\"t\":\"d\",\"n\":1.5}" }, | |
| 657 | .{ .r = .{ .value = .{ .string = "dark" } }, .want = "{\"t\":\"s\",\"s\":\"dark\"}" }, | |
| 658 | .{ .r = .{ .foreign = .{ .cf_type = "CFArray" } }, .want = "{\"t\":\"other\",\"cf\":\"CFArray\"}" }, | |
| 659 | .{ .r = .{ .foreign = .{ .cf_type = "CFString", .bytes = 99 } }, .want = "{\"t\":\"other\",\"cf\":\"CFString\",\"bytes\":99}" }, | |
| 660 | }; | |
| 661 | for (cases) |c| { | |
| 662 | const json = try readJson(c.r); | |
| 663 | defer testing.allocator.free(json); | |
| 664 | try testing.expectEqualStrings(c.want, json); | |
| 665 | } | |
| 666 | } | |
| 667 | ||
| 668 | test "a stored value cannot break out of the reply it is delivered in" { | |
| 669 | const nasty = "a\"b\\c\nd\te\x00f\x1fg"; | |
| 670 | const json = try readJson(.{ .value = .{ .string = nasty } }); | |
| 671 | defer testing.allocator.free(json); | |
| 672 | ||
| 673 | const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); | |
| 674 | defer parsed.deinit(); | |
| 675 | try testing.expectEqualStrings(nasty, parsed.value.object.get("s").?.string); | |
| 676 | } | |
| 677 | ||
| 678 | test "the keys reply is a JSON array" { | |
| 679 | var out: std.ArrayListUnmanaged(u8) = .empty; | |
| 680 | defer out.deinit(testing.allocator); | |
| 681 | try appendKeysJson(testing.allocator, &out, &.{}); | |
| 682 | try testing.expectEqualStrings("{\"keys\":[]}", out.items); | |
| 683 | ||
| 684 | out.clearRetainingCapacity(); | |
| 685 | try appendKeysJson(testing.allocator, &out, &.{ "fontSize", "theme" }); | |
| 686 | try testing.expectEqualStrings("{\"keys\":[\"fontSize\",\"theme\"]}", out.items); | |
| 687 | } | |
| 688 | ||
| 689 | test "a whole round trip through the store keeps every scalar type" { | |
| 690 | var mem = MemoryBackend.init(testing.allocator); | |
| 691 | defer mem.deinit(); | |
| 692 | const store = testStore(&mem); | |
| 693 | ||
| 694 | const cases = [_]struct { key: []const u8, value: Value }{ | |
| 695 | .{ .key = "s", .value = .{ .string = "dark" } }, | |
| 696 | .{ .key = "i", .value = .{ .int = -42 } }, | |
| 697 | .{ .key = "d", .value = .{ .float = 1.5 } }, | |
| 698 | .{ .key = "b", .value = .{ .boolean = true } }, | |
| 699 | }; | |
| 700 | for (cases) |c| try store.set(c.key, c.value); | |
| 701 | for (cases) |c| { | |
| 702 | const read = try store.get(testing.allocator, c.key); | |
| 703 | defer if (read == .value and read.value == .string) testing.allocator.free(read.value.string); | |
| 704 | // A boolean must not come back as 1, and an int must not come back as | |
| 705 | // 1.0 — which is exactly what a store that inferred types would do. | |
| 706 | try testing.expect(read == .value); | |
| 707 | try testing.expect(read.value.eql(c.value)); | |
| 708 | } | |
| 709 | } | |
| @@ -0,0 +1,478 @@ | ||
| 1 | //! The macOS backend for `prefs.zig`: CoreFoundation preferences, exact domain. | |
| 2 | //! | |
| 3 | //! Two choices here are load-bearing and were settled by experiment, not by | |
| 4 | //! reading documentation. | |
| 5 | //! | |
| 6 | //! **1. The exact-domain quad, never `CFPreferencesCopyAppValue` and never | |
| 7 | //! `NSUserDefaults`.** The convenience APIs consult a *search list*, and the | |
| 8 | //! first entry in that list is `NSArgumentDomain` — the process's own command | |
| 9 | //! line. Measured: a binary launched as `./probe -zinjected 99` gets the string | |
| 10 | //! `"99"` back from `CFPreferencesCopyAppValue("zinjected")` while | |
| 11 | //! `CFPreferencesCopyValue` on the same key returns null. craft's entire | |
| 12 | //! interface is command-line flags, so under the convenience API | |
| 13 | //! `craft --title Hello` would make `prefs.get("title")` return `"Hello"` — and | |
| 14 | //! an argument-domain value cannot be removed at runtime, so the collision | |
| 15 | //! would be permanent. The quad reads and writes one domain and nothing else. | |
| 16 | //! | |
| 17 | //! **2. `CFGetTypeID`, never `objCType`.** The usual idiom for telling a stored | |
| 18 | //! boolean from the number 1 is `strcmp(objCType, @encode(BOOL))`, and it is | |
| 19 | //! wrong on every Mac: `@YES` is the `kCFBooleanTrue` singleton, whose | |
| 20 | //! `objCType` is `"c"`, while `@encode(BOOL)` is `"B"` on arm64. Measured: | |
| 21 | //! comparing type IDs distinguishes a boolean from an integer from a float | |
| 22 | //! correctly, in-process and after a round trip through the preferences daemon. | |
| 23 | //! `boolForKey:`/`integerForKey:` are also out — they *coerce*, so the string | |
| 24 | //! "YES" reads back as `true`, which is the wrong behaviour for a store whose | |
| 25 | //! whole point is faithful types. | |
| 26 | ||
| 27 | const std = ; | |
| 28 | const builtin = ; | |
| 29 | const prefs = ; | |
| 30 | ||
| 31 | const is_macos = builtin.os.tag == .macos; | |
| 32 | ||
| 33 | const CFTypeRef = ?*anyopaque; | |
| 34 | const CFIndex = isize; | |
| 35 | const CFTypeID = usize; | |
| 36 | ||
| 37 | const kCFStringEncodingUTF8: u32 = 0x0800_0100; | |
| 38 | const kCFNumberSInt64Type: CFIndex = 4; | |
| 39 | const kCFNumberFloat64Type: CFIndex = 6; | |
| 40 | ||
| 41 | extern "c" fn CFRelease(cf: CFTypeRef) void; | |
| 42 | extern "c" fn CFGetTypeID(cf: CFTypeRef) CFTypeID; | |
| 43 | extern "c" fn CFBooleanGetTypeID() CFTypeID; | |
| 44 | extern "c" fn CFNumberGetTypeID() CFTypeID; | |
| 45 | extern "c" fn CFStringGetTypeID() CFTypeID; | |
| 46 | extern "c" fn CFCopyTypeIDDescription(type_id: CFTypeID) CFTypeRef; | |
| 47 | ||
| 48 | extern "c" fn CFStringCreateWithBytes(alloc: CFTypeRef, bytes: [*]const u8, num_bytes: CFIndex, encoding: u32, external: bool) CFTypeRef; | |
| 49 | extern "c" fn CFStringGetLength(s: CFTypeRef) CFIndex; | |
| 50 | extern "c" fn CFStringGetMaximumSizeForEncoding(len: CFIndex, encoding: u32) CFIndex; | |
| 51 | extern "c" fn CFStringGetCString(s: CFTypeRef, buffer: [*]u8, size: CFIndex, encoding: u32) bool; | |
| 52 | ||
| 53 | extern "c" fn CFNumberCreate(alloc: CFTypeRef, number_type: CFIndex, value_ptr: *const anyopaque) CFTypeRef; | |
| 54 | extern "c" fn CFNumberGetValue(number: CFTypeRef, number_type: CFIndex, value_ptr: *anyopaque) bool; | |
| 55 | extern "c" fn CFNumberIsFloatType(number: CFTypeRef) bool; | |
| 56 | extern "c" fn CFBooleanGetValue(b: CFTypeRef) bool; | |
| 57 | ||
| 58 | extern "c" fn CFArrayGetCount(array: CFTypeRef) CFIndex; | |
| 59 | extern "c" fn CFArrayGetValueAtIndex(array: CFTypeRef, index: CFIndex) CFTypeRef; | |
| 60 | ||
| 61 | extern "c" fn CFPreferencesCopyValue(key: CFTypeRef, app_id: CFTypeRef, user: CFTypeRef, host: CFTypeRef) CFTypeRef; | |
| 62 | extern "c" fn CFPreferencesSetValue(key: CFTypeRef, value: CFTypeRef, app_id: CFTypeRef, user: CFTypeRef, host: CFTypeRef) void; | |
| 63 | extern "c" fn CFPreferencesCopyKeyList(app_id: CFTypeRef, user: CFTypeRef, host: CFTypeRef) CFTypeRef; | |
| 64 | extern "c" fn CFPreferencesAppSynchronize(app_id: CFTypeRef) bool; | |
| 65 | ||
| 66 | extern "c" fn CFBundleGetMainBundle() CFTypeRef; | |
| 67 | extern "c" fn CFBundleGetIdentifier(bundle: CFTypeRef) CFTypeRef; | |
| 68 | ||
| 69 | extern "c" const kCFBooleanTrue: CFTypeRef; | |
| 70 | extern "c" const kCFBooleanFalse: CFTypeRef; | |
| 71 | extern "c" const kCFPreferencesCurrentUser: CFTypeRef; | |
| 72 | extern "c" const kCFPreferencesAnyHost: CFTypeRef; | |
| 73 | extern "c" const kCFPreferencesCurrentApplication: CFTypeRef; | |
| 74 | ||
| 75 | /// Which preferences domain to read and write. | |
| 76 | pub const Domain = union(enum) { | |
| 77 | /// `kCFPreferencesCurrentApplication`: the bundle identifier inside a | |
| 78 | /// `.app`, the executable's name otherwise. What production uses. | |
| 79 | /// | |
| 80 | /// The dev/packaged difference is real and visible — an unbundled `craft` | |
| 81 | /// writes `~/Library/Preferences/craft.plist`, a packaged app writes its | |
| 82 | /// bundle id — which is why `prefs:info` reports the live domain rather | |
| 83 | /// than leaving an app to guess where its preferences went. | |
| 84 | current_application, | |
| 85 | /// A literal domain, so the native half can be tested against a throwaway | |
| 86 | /// domain instead of the developer's real one. | |
| 87 | named: []const u8, | |
| 88 | }; | |
| 89 | ||
| 90 | pub const Backend = struct { | |
| 91 | domain: Domain, | |
| 92 | /// Owned CFString for `.named`; null for `.current_application`. | |
| 93 | domain_ref: CFTypeRef = null, | |
| 94 | ||
| 95 | pub fn init(domain: Domain) Backend { | |
| 96 | var self = Backend{ .domain = domain }; | |
| 97 | if (comptime is_macos) { | |
| 98 | switch (domain) { | |
| 99 | .named => |name| self.domain_ref = cfString(name), | |
| 100 | .current_application => self.domain_ref = kCFPreferencesCurrentApplication, | |
| 101 | } | |
| 102 | } | |
| 103 | return self; | |
| 104 | } | |
| 105 | ||
| 106 | pub fn deinit(self: *Backend) void { | |
| 107 | if (comptime !is_macos) return; | |
| 108 | if (self.domain == .named) { | |
| 109 | if (self.domain_ref) |r| CFRelease(r); | |
| 110 | } | |
| 111 | self.domain_ref = null; | |
| 112 | } | |
| 113 | ||
| 114 | pub fn backend(self: *Backend) prefs.Backend { | |
| 115 | return .{ .ctx = self, .vtable = &vtable }; | |
| 116 | } | |
| 117 | ||
| 118 | const vtable = prefs.Backend.VTable{ | |
| 119 | .get = getImpl, | |
| 120 | .set = setImpl, | |
| 121 | .remove = removeImpl, | |
| 122 | .keys = keysImpl, | |
| 123 | .sync = syncImpl, | |
| 124 | }; | |
| 125 | ||
| 126 | fn self_(ctx: *anyopaque) *Backend { | |
| 127 | return @ptrCast(@alignCast(ctx)); | |
| 128 | } | |
| 129 | ||
| 130 | fn getImpl(ctx: *anyopaque, gpa: std.mem.Allocator, full_key: []const u8) prefs.Error!prefs.Read { | |
| 131 | if (comptime !is_macos) return .absent; | |
| 132 | const me = self_(ctx); | |
| 133 | ||
| 134 | const key = cfString(full_key) orelse return prefs.Error.BackendFailure; | |
| 135 | defer CFRelease(key); | |
| 136 | ||
| 137 | const value = CFPreferencesCopyValue(key, me.domain_ref, kCFPreferencesCurrentUser, kCFPreferencesAnyHost); | |
| 138 | if (value == null) return .absent; | |
| 139 | defer CFRelease(value); | |
| 140 | ||
| 141 | return readValue(gpa, value); | |
| 142 | } | |
| 143 | ||
| 144 | fn setImpl(ctx: *anyopaque, full_key: []const u8, value: prefs.Value) prefs.Error!void { | |
| 145 | if (comptime !is_macos) return prefs.Error.BackendFailure; | |
| 146 | const me = self_(ctx); | |
| 147 | ||
| 148 | const key = cfString(full_key) orelse return prefs.Error.BackendFailure; | |
| 149 | defer CFRelease(key); | |
| 150 | ||
| 151 | // Exactly three constructors exist here, and none of them can produce a | |
| 152 | // container. That is what makes the `NSInvalidArgumentException` path | |
| 153 | // unreachable rather than merely avoided — Zig could not catch it. | |
| 154 | const cf: CFTypeRef = switch (value) { | |
| 155 | .string => |s| cfString(s) orelse return prefs.Error.BackendFailure, | |
| 156 | .boolean => |b| if (b) kCFBooleanTrue else kCFBooleanFalse, | |
| 157 | .int => |i| blk: { | |
| 158 | var scratch: i64 = i; | |
| 159 | break :blk CFNumberCreate(null, kCFNumberSInt64Type, &scratch) orelse | |
| 160 | return prefs.Error.BackendFailure; | |
| 161 | }, | |
| 162 | .float => |f| blk: { | |
| 163 | var scratch: f64 = f; | |
| 164 | break :blk CFNumberCreate(null, kCFNumberFloat64Type, &scratch) orelse | |
| 165 | return prefs.Error.BackendFailure; | |
| 166 | }, | |
| 167 | }; | |
| 168 | // The booleans are immortal singletons; the other two are ours to free. | |
| 169 | defer switch (value) { | |
| 170 | .boolean => {}, | |
| 171 | else => CFRelease(cf), | |
| 172 | }; | |
| 173 | ||
| 174 | CFPreferencesSetValue(key, cf, me.domain_ref, kCFPreferencesCurrentUser, kCFPreferencesAnyHost); | |
| 175 | } | |
| 176 | ||
| 177 | fn removeImpl(ctx: *anyopaque, full_key: []const u8) prefs.Error!bool { | |
| 178 | if (comptime !is_macos) return false; | |
| 179 | const me = self_(ctx); | |
| 180 | ||
| 181 | const key = cfString(full_key) orelse return prefs.Error.BackendFailure; | |
| 182 | defer CFRelease(key); | |
| 183 | ||
| 184 | const existing = CFPreferencesCopyValue(key, me.domain_ref, kCFPreferencesCurrentUser, kCFPreferencesAnyHost); | |
| 185 | const existed = existing != null; | |
| 186 | if (existing) |e| CFRelease(e); | |
| 187 | ||
| 188 | // A null value is how CoreFoundation spells "remove". | |
| 189 | CFPreferencesSetValue(key, null, me.domain_ref, kCFPreferencesCurrentUser, kCFPreferencesAnyHost); | |
| 190 | return existed; | |
| 191 | } | |
| 192 | ||
| 193 | fn keysImpl(ctx: *anyopaque, gpa: std.mem.Allocator) prefs.Error![][]u8 { | |
| 194 | if (comptime !is_macos) return gpa.alloc([]u8, 0) catch prefs.Error.OutOfMemory; | |
| 195 | const me = self_(ctx); | |
| 196 | ||
| 197 | const list = CFPreferencesCopyKeyList(me.domain_ref, kCFPreferencesCurrentUser, kCFPreferencesAnyHost); | |
| 198 | // Null rather than an empty array is what a domain with no keys yet | |
| 199 | // returns; it is not a failure. | |
| 200 | if (list == null) return gpa.alloc([]u8, 0) catch prefs.Error.OutOfMemory; | |
| 201 | defer CFRelease(list); | |
| 202 | ||
| 203 | const count = CFArrayGetCount(list); | |
| 204 | var out: std.ArrayListUnmanaged([]u8) = .empty; | |
| 205 | errdefer { | |
| 206 | for (out.items) |k| gpa.free(k); | |
| 207 | out.deinit(gpa); | |
| 208 | } | |
| 209 | ||
| 210 | var index: CFIndex = 0; | |
| 211 | while (index < count) : (index += 1) { | |
| 212 | const item = CFArrayGetValueAtIndex(list, index); | |
| 213 | if (CFGetTypeID(item) != CFStringGetTypeID()) continue; | |
| 214 | const owned = copyCfString(gpa, item) catch continue orelse continue; | |
| 215 | out.append(gpa, owned) catch { | |
| 216 | gpa.free(owned); | |
| 217 | return prefs.Error.OutOfMemory; | |
| 218 | }; | |
| 219 | } | |
| 220 | return out.toOwnedSlice(gpa) catch prefs.Error.OutOfMemory; | |
| 221 | } | |
| 222 | ||
| 223 | fn syncImpl(ctx: *anyopaque) prefs.Error!void { | |
| 224 | if (comptime !is_macos) return; | |
| 225 | const me = self_(ctx); | |
| 226 | // Flushed before the reply goes back, so a resolved `set()` means the | |
| 227 | // bytes are on disk. Not the deprecated `-[NSUserDefaults synchronize]`: | |
| 228 | // Apple names non-app command-line processes as the case that still | |
| 229 | // needs this, and an unbundled dev-mode craft is exactly that — and | |
| 230 | // craft's hot reload kills the process abruptly. | |
| 231 | if (!CFPreferencesAppSynchronize(me.domain_ref)) return prefs.Error.BackendFailure; | |
| 232 | } | |
| 233 | }; | |
| 234 | ||
| 235 | /// Decide what a stored CoreFoundation value is. Boolean **before** number: | |
| 236 | /// `kCFBooleanTrue` also answers to several number-ish tests. | |
| 237 | fn readValue(gpa: std.mem.Allocator, value: CFTypeRef) prefs.Error!prefs.Read { | |
| 238 | const type_id = CFGetTypeID(value); | |
| 239 | ||
| 240 | if (type_id == CFBooleanGetTypeID()) { | |
| 241 | return .{ .value = .{ .boolean = CFBooleanGetValue(value) } }; | |
| 242 | } | |
| 243 | ||
| 244 | if (type_id == CFNumberGetTypeID()) { | |
| 245 | if (CFNumberIsFloatType(value)) { | |
| 246 | var f: f64 = 0; | |
| 247 | if (!CFNumberGetValue(value, kCFNumberFloat64Type, &f)) return prefs.Error.BackendFailure; | |
| 248 | return .{ .value = .{ .float = f } }; | |
| 249 | } | |
| 250 | var i: i64 = 0; | |
| 251 | if (!CFNumberGetValue(value, kCFNumberSInt64Type, &i)) return prefs.Error.BackendFailure; | |
| 252 | return .{ .value = .{ .int = i } }; | |
| 253 | } | |
| 254 | ||
| 255 | if (type_id == CFStringGetTypeID()) { | |
| 256 | const owned = (copyCfString(gpa, value) catch return prefs.Error.OutOfMemory) orelse | |
| 257 | return prefs.Error.BackendFailure; | |
| 258 | if (owned.len > prefs.max_value_bytes) { | |
| 259 | // Something outside craft wrote a string larger than craft will | |
| 260 | // hand back. Reported as itself rather than truncated. | |
| 261 | defer gpa.free(owned); | |
| 262 | return .{ .foreign = .{ .cf_type = "CFString", .bytes = owned.len } }; | |
| 263 | } | |
| 264 | return .{ .value = .{ .string = owned } }; | |
| 265 | } | |
| 266 | ||
| 267 | return .{ .foreign = .{ .cf_type = typeName(type_id) } }; | |
| 268 | } | |
| 269 | ||
| 270 | /// CoreFoundation's own name for a type id, for the `foreign` report. | |
| 271 | /// | |
| 272 | /// Static strings for the types worth naming; `CFCopyTypeIDDescription` would | |
| 273 | /// mean another allocation and another lifetime on a path that only exists | |
| 274 | /// because someone hand-edited the domain. | |
| 275 | fn typeName(type_id: CFTypeID) []const u8 { | |
| 276 | if (comptime !is_macos) return "unknown"; | |
| 277 | if (type_id == CFStringGetTypeID()) return "CFString"; | |
| 278 | if (type_id == CFNumberGetTypeID()) return "CFNumber"; | |
| 279 | if (type_id == CFBooleanGetTypeID()) return "CFBoolean"; | |
| 280 | const described = CFCopyTypeIDDescription(type_id); | |
| 281 | if (described == null) return "unknown"; | |
| 282 | defer CFRelease(described); | |
| 283 | ||
| 284 | // Best effort into a static buffer: the caller only formats it into JSON. | |
| 285 | var buf: [64]u8 = undefined; | |
| 286 | const max = CFStringGetMaximumSizeForEncoding(CFStringGetLength(described), kCFStringEncodingUTF8) + 1; | |
| 287 | if (max <= 0 or max > buf.len) return "unknown"; | |
| 288 | if (!CFStringGetCString(described, &buf, @intCast(buf.len), kCFStringEncodingUTF8)) return "unknown"; | |
| 289 | const span = std.mem.sliceTo(&buf, 0); | |
| 290 | // Copied into a comptime-known set so the returned slice outlives `buf`. | |
| 291 | for ([_][]const u8{ "CFArray", "CFDictionary", "CFData", "CFDate", "CFNull" }) |known| { | |
| 292 | if (std.mem.eql(u8, span, known)) return known; | |
| 293 | } | |
| 294 | return "unknown"; | |
| 295 | } | |
| 296 | ||
| 297 | fn cfString(s: []const u8) CFTypeRef { | |
| 298 | if (comptime !is_macos) return null; | |
| 299 | // Length-explicit, so it needs no NUL scan and returns null on invalid | |
| 300 | // UTF-8 — unlike `stringWithUTF8String:`, whose nil return is how a menu | |
| 301 | // item silently loses its title elsewhere in this codebase. | |
| 302 | return CFStringCreateWithBytes(null, s.ptr, @intCast(s.len), kCFStringEncodingUTF8, false); | |
| 303 | } | |
| 304 | ||
| 305 | fn copyCfString(gpa: std.mem.Allocator, s: CFTypeRef) !?[]u8 { | |
| 306 | const max = CFStringGetMaximumSizeForEncoding(CFStringGetLength(s), kCFStringEncodingUTF8) + 1; | |
| 307 | if (max <= 0) return null; | |
| 308 | ||
| 309 | const buf = try gpa.alloc(u8, @intCast(max)); | |
| 310 | errdefer gpa.free(buf); | |
| 311 | if (!CFStringGetCString(s, buf.ptr, @intCast(buf.len), kCFStringEncodingUTF8)) { | |
| 312 | gpa.free(buf); | |
| 313 | return null; | |
| 314 | } | |
| 315 | const len = std.mem.indexOfScalar(u8, buf, 0) orelse buf.len; | |
| 316 | return gpa.realloc(buf, len) catch buf[0..len]; | |
| 317 | } | |
| 318 | ||
| 319 | /// The domain preferences are actually landing in, for `prefs:info`. | |
| 320 | /// | |
| 321 | /// Bundle identifier inside a `.app`; the executable's name otherwise — which | |
| 322 | /// is why two different dev-mode apps run through the same `craft` binary share | |
| 323 | /// a domain, and why `info()` exists to make that visible instead of puzzling. | |
| 324 | pub fn domainName(gpa: std.mem.Allocator) ![]u8 { | |
| 325 | if (comptime !is_macos) return gpa.dupe(u8, "unsupported"); | |
| 326 | ||
| 327 | const bundle = CFBundleGetMainBundle(); | |
| 328 | if (bundle != null) { | |
| 329 | const identifier = CFBundleGetIdentifier(bundle); | |
| 330 | if (identifier != null) { | |
| 331 | if (try copyCfString(gpa, identifier)) |owned| return owned; | |
| 332 | } | |
| 333 | } | |
| 334 | ||
| 335 | var size: u32 = 0; | |
| 336 | _ = _NSGetExecutablePath(undefined, &size); | |
| 337 | if (size == 0 or size > 8192) return gpa.dupe(u8, "craft"); | |
| 338 | const path_buf = try gpa.alloc(u8, size); | |
| 339 | defer gpa.free(path_buf); | |
| 340 | if (_NSGetExecutablePath(path_buf.ptr, &size) != 0) return gpa.dupe(u8, "craft"); | |
| 341 | const exe_path = std.mem.sliceTo(path_buf, 0); | |
| 342 | return gpa.dupe(u8, std.fs.path.basename(exe_path)); | |
| 343 | } | |
| 344 | ||
| 345 | extern "c" fn _NSGetExecutablePath(buf: [*]u8, bufsize: *u32) c_int; | |
| 346 | ||
| 347 | // ============================================================================= | |
| 348 | // Tests | |
| 349 | // ============================================================================= | |
| 350 | // | |
| 351 | // These touch the real preferences daemon, so they run against a throwaway | |
| 352 | // domain and remove every key they create. That is what `Domain.named` is for. | |
| 353 | ||
| 354 | const testing = std.testing; | |
| 355 | const test_domain = "dev.craft.prefs.selftest"; | |
| 356 | ||
| 357 | fn scratchBackend() Backend { | |
| 358 | return Backend.init(.{ .named = test_domain }); | |
| 359 | } | |
| 360 | ||
| 361 | test "a value keeps its type through the preferences daemon" { | |
| 362 | if (comptime !is_macos) return error.SkipZigTest; | |
| 363 | ||
| 364 | var native = scratchBackend(); | |
| 365 | defer native.deinit(); | |
| 366 | const store = prefs.Store{ .backend = native.backend() }; | |
| 367 | defer _ = store.clear(testing.allocator) catch {}; | |
| 368 | ||
| 369 | // In-process, with no relaunch between the write and the read. That is the | |
| 370 | // load-bearing part: a plist round trip through a *fresh* process | |
| 371 | // normalises number types, so a test that relaunches passes even with a | |
| 372 | // wrong implementation, while craft's real read path is a long-running app | |
| 373 | // reading back what it just wrote. | |
| 374 | try store.set("aBool", .{ .boolean = true }); | |
| 375 | try store.set("anInt", .{ .int = 42 }); | |
| 376 | try store.set("aFloat", .{ .float = 1.5 }); | |
| 377 | try store.set("aString", .{ .string = "dark" }); | |
| 378 | ||
| 379 | const b = try store.get(testing.allocator, "aBool"); | |
| 380 | try testing.expect(b.value.eql(.{ .boolean = true })); | |
| 381 | ||
| 382 | // The one that the usual `objCType == @encode(BOOL)` idiom gets wrong: a | |
| 383 | // stored boolean must not read back as the integer 1. | |
| 384 | try testing.expect(b.value != .int); | |
| 385 | ||
| 386 | const i = try store.get(testing.allocator, "anInt"); | |
| 387 | try testing.expect(i.value.eql(.{ .int = 42 })); | |
| 388 | ||
| 389 | const f = try store.get(testing.allocator, "aFloat"); | |
| 390 | try testing.expect(f.value.eql(.{ .float = 1.5 })); | |
| 391 | ||
| 392 | const s = try store.get(testing.allocator, "aString"); | |
| 393 | defer testing.allocator.free(s.value.string); | |
| 394 | try testing.expectEqualStrings("dark", s.value.string); | |
| 395 | } | |
| 396 | ||
| 397 | test "delete removes the key, and reports whether it was there" { | |
| 398 | if (comptime !is_macos) return error.SkipZigTest; | |
| 399 | ||
| 400 | var native = scratchBackend(); | |
| 401 | defer native.deinit(); | |
| 402 | const store = prefs.Store{ .backend = native.backend() }; | |
| 403 | defer _ = store.clear(testing.allocator) catch {}; | |
| 404 | ||
| 405 | try store.set("goingAway", .{ .int = 1 }); | |
| 406 | try testing.expect(try store.remove("goingAway")); | |
| 407 | try testing.expect(try store.get(testing.allocator, "goingAway") == .absent); | |
| 408 | try testing.expect(!try store.remove("goingAway")); | |
| 409 | } | |
| 410 | ||
| 411 | test "keys and clear see only craft's own keys in a shared domain" { | |
| 412 | if (comptime !is_macos) return error.SkipZigTest; | |
| 413 | ||
| 414 | var native = scratchBackend(); | |
| 415 | defer native.deinit(); | |
| 416 | const store = prefs.Store{ .backend = native.backend() }; | |
| 417 | defer _ = store.clear(testing.allocator) catch {}; | |
| 418 | ||
| 419 | try store.set("theme", .{ .string = "dark" }); | |
| 420 | ||
| 421 | // What AppKit leaves in the same domain, written past the store so it has | |
| 422 | // no craft prefix. | |
| 423 | try native.backend().vtable.set(&native, "NSWindow Frame Selftest", .{ .string = "0 0 8 6" }); | |
| 424 | defer _ = native.backend().vtable.remove(&native, "NSWindow Frame Selftest") catch false; | |
| 425 | ||
| 426 | const listed = try store.keys(testing.allocator); | |
| 427 | defer { | |
| 428 | for (listed) |k| testing.allocator.free(k); | |
| 429 | testing.allocator.free(listed); | |
| 430 | } | |
| 431 | try testing.expectEqual(@as(usize, 1), listed.len); | |
| 432 | try testing.expectEqualStrings("theme", listed[0]); | |
| 433 | ||
| 434 | try testing.expectEqual(@as(usize, 1), try store.clear(testing.allocator)); | |
| 435 | ||
| 436 | const survivor = try native.backend().vtable.get(&native, testing.allocator, "NSWindow Frame Selftest"); | |
| 437 | defer if (survivor == .value and survivor.value == .string) testing.allocator.free(survivor.value.string); | |
| 438 | try testing.expect(survivor == .value); | |
| 439 | } | |
| 440 | ||
| 441 | test "a value craft cannot represent is reported, not coerced" { | |
| 442 | if (comptime !is_macos) return error.SkipZigTest; | |
| 443 | ||
| 444 | var native = scratchBackend(); | |
| 445 | defer native.deinit(); | |
| 446 | ||
| 447 | // An array, which only something outside craft could have written. | |
| 448 | const key = cfString(prefs.prefix ++ "foreign").?; | |
| 449 | defer CFRelease(key); | |
| 450 | const one = cfString("a").?; | |
| 451 | defer CFRelease(one); | |
| 452 | var values = [_]CFTypeRef{one}; | |
| 453 | const array = CFArrayCreate(null, &values, 1, null); | |
| 454 | defer CFRelease(array); | |
| 455 | ||
| 456 | const domain = cfString(test_domain).?; | |
| 457 | defer CFRelease(domain); | |
| 458 | CFPreferencesSetValue(key, array, domain, kCFPreferencesCurrentUser, kCFPreferencesAnyHost); | |
| 459 | defer CFPreferencesSetValue(key, null, domain, kCFPreferencesCurrentUser, kCFPreferencesAnyHost); | |
| 460 | ||
| 461 | const store = prefs.Store{ .backend = native.backend() }; | |
| 462 | const read = try store.get(testing.allocator, "foreign"); | |
| 463 | try testing.expect(read == .foreign); | |
| 464 | try testing.expectEqualStrings("CFArray", read.foreign.cf_type); | |
| 465 | } | |
| 466 | ||
| 467 | extern "c" fn CFArrayCreate(alloc: CFTypeRef, values: [*]const CFTypeRef, count: CFIndex, callbacks: ?*const anyopaque) CFTypeRef; | |
| 468 | ||
| 469 | test "the reported domain is a real one" { | |
| 470 | if (comptime !is_macos) return error.SkipZigTest; | |
| 471 | ||
| 472 | const name = try domainName(testing.allocator); | |
| 473 | defer testing.allocator.free(name); | |
| 474 | try testing.expect(name.len > 0); | |
| 475 | // Unbundled, this is the executable's name — which is the whole reason | |
| 476 | // `prefs:info` reports it. | |
| 477 | try testing.expect(std.mem.indexOfScalar(u8, name, '/') == null); | |
| 478 | } | |
| @@ -18,6 +18,8 @@ const js = ; | ||
| 18 | 18 | const testing = std.testing; |
| 19 | 19 | const contracts = ; |
| 20 | 20 | const bridge_menu = contracts.menu; |
| 21 | const prefs = contracts.prefs; | |
| 22 | const prefs_actions = contracts.prefs_actions; | |
| 21 | 23 | const shortcut_registry = contracts.shortcuts; |
| 22 | 24 | |
| 23 | 25 | // Supplied by build.zig as named imports — a test module cannot embed |
| @@ -527,3 +529,289 @@ test "an accelerator the native side cannot bind is refused, not accepted" { | ||
| 527 | 529 | registry.register(try fx.text("posted[0].d")), |
| 528 | 530 | ); |
| 529 | 531 | } |
| 532 | ||
| 533 | // ============================================================================= | |
| 534 | // Settings and preferences (#51) | |
| 535 | // ============================================================================= | |
| 536 | ||
| 537 | /// A store over an in-memory backend, so these tests are about the JS/native | |
| 538 | /// contract rather than about the preferences daemon. | |
| 539 | fn memoryStore(mem: *prefs.MemoryBackend) prefs.Store { | |
| 540 | return .{ .backend = mem.backend() }; | |
| 541 | } | |
| 542 | ||
| 543 | test "craft.prefs.set posts what the native decoder reads, for every scalar" { | |
| 544 | // The contract that matters: the type travels explicitly, so neither side | |
| 545 | // has to infer it. A boolean must not arrive as the number 1, and an | |
| 546 | // integer must not arrive as a float. | |
| 547 | var fx = try Fixture.init(); | |
| 548 | defer fx.deinit(); | |
| 549 | const ctx = fx.ctx; | |
| 550 | ||
| 551 | _ = try ctx.evaluate(WEBVIEW_HOST); | |
| 552 | _ = try ctx.evaluate(BRIDGE); | |
| 553 | _ = try ctx.evaluate( | |
| 554 | \\window.craft.prefs.set('theme', 'dark'); | |
| 555 | \\window.craft.prefs.set('dark', true); | |
| 556 | \\window.craft.prefs.set('fontSize', 13); | |
| 557 | \\window.craft.prefs.set('scale', 1.5); | |
| 558 | ); | |
| 559 | ||
| 560 | try testing.expectEqualStrings("4", try fx.text("String(posted.length)")); | |
| 561 | ||
| 562 | const expected = [_]prefs.Value{ | |
| 563 | .{ .string = "dark" }, | |
| 564 | .{ .boolean = true }, | |
| 565 | .{ .int = 13 }, | |
| 566 | .{ .float = 1.5 }, | |
| 567 | }; | |
| 568 | for (expected, 0..) |want, index| { | |
| 569 | // One buffer per expression: `bufPrint` returns a slice into the | |
| 570 | // buffer, so reusing it would leave the earlier slice pointing at the | |
| 571 | // later string. | |
| 572 | var t_buf: [64]u8 = undefined; | |
| 573 | var a_buf: [64]u8 = undefined; | |
| 574 | var d_buf: [64]u8 = undefined; | |
| 575 | try testing.expectEqualStrings("prefs", try fx.text(try std.fmt.bufPrint(&t_buf, "posted[{d}].t", .{index}))); | |
| 576 | try testing.expectEqualStrings(prefs_actions.set, try fx.text(try std.fmt.bufPrint(&a_buf, "posted[{d}].a", .{index}))); | |
| 577 | ||
| 578 | const payload = try fx.text(try std.fmt.bufPrint(&d_buf, "posted[{d}].d", .{index})); | |
| 579 | const parsed = try std.json.parseFromSlice(prefs.SetShape, testing.allocator, payload, .{ | |
| 580 | .ignore_unknown_fields = true, | |
| 581 | .allocate = .alloc_always, | |
| 582 | }); | |
| 583 | defer parsed.deinit(); | |
| 584 | const decoded = try prefs.decodeSet(parsed.value); | |
| 585 | try testing.expect(decoded.value.eql(want)); | |
| 586 | } | |
| 587 | } | |
| 588 | ||
| 589 | test "every wire action the prefs facade posts is namespaced" { | |
| 590 | // The assertion that stops someone shortening these back to bare names. | |
| 591 | // A bare `get` would be drained by keychain.get and tags.get; a bare | |
| 592 | // `set`/`delete`/`clear` would be *resolved* by other bridges that emit a | |
| 593 | // result under those names without their own facades ever waiting. | |
| 594 | var fx = try Fixture.init(); | |
| 595 | defer fx.deinit(); | |
| 596 | const ctx = fx.ctx; | |
| 597 | ||
| 598 | _ = try ctx.evaluate(WEBVIEW_HOST); | |
| 599 | _ = try ctx.evaluate(BRIDGE); | |
| 600 | _ = try ctx.evaluate( | |
| 601 | \\window.craft.prefs.get('a'); | |
| 602 | \\window.craft.prefs.set('a', 1); | |
| 603 | \\window.craft.prefs.delete('a'); | |
| 604 | \\window.craft.prefs.clear(); | |
| 605 | \\window.craft.prefs.keys(); | |
| 606 | \\window.craft.prefs.info(); | |
| 607 | ); | |
| 608 | ||
| 609 | try testing.expectEqualStrings("6", try fx.text("String(posted.length)")); | |
| 610 | try testing.expectEqualStrings( | |
| 611 | "true", | |
| 612 | try fx.text("String(posted.every(function (m) { return m.a.indexOf('prefs:') === 0 }))"), | |
| 613 | ); | |
| 614 | for ([_][]const u8{ | |
| 615 | prefs_actions.get, prefs_actions.set, prefs_actions.delete, | |
| 616 | prefs_actions.clear, prefs_actions.keys, prefs_actions.info, | |
| 617 | }, 0..) |want, index| { | |
| 618 | var buf: [64]u8 = undefined; | |
| 619 | try testing.expectEqualStrings(want, try fx.text(try std.fmt.bufPrint(&buf, "posted[{d}].a", .{index}))); | |
| 620 | } | |
| 621 | } | |
| 622 | ||
| 623 | test "a value prefs cannot store is refused before anything is posted" { | |
| 624 | // The refusal has to happen in the page, because the alternative is an | |
| 625 | // Objective-C exception in a process that cannot catch one. | |
| 626 | var fx = try Fixture.init(); | |
| 627 | defer fx.deinit(); | |
| 628 | const ctx = fx.ctx; | |
| 629 | ||
| 630 | _ = try ctx.evaluate(WEBVIEW_HOST); | |
| 631 | _ = try ctx.evaluate(BRIDGE); | |
| 632 | _ = try ctx.evaluate( | |
| 633 | \\var codes = []; | |
| 634 | \\function refuse(p) { p.then(function () { codes.push('RESOLVED') }, function (e) { codes.push(e.code) }) } | |
| 635 | \\refuse(window.craft.prefs.set('k', {})); | |
| 636 | \\refuse(window.craft.prefs.set('k', [])); | |
| 637 | \\refuse(window.craft.prefs.set('k', null)); | |
| 638 | \\refuse(window.craft.prefs.set('k', undefined)); | |
| 639 | \\refuse(window.craft.prefs.set('k', NaN)); | |
| 640 | \\refuse(window.craft.prefs.set('k', Infinity)); | |
| 641 | \\refuse(window.craft.prefs.set('k', 'x'.repeat(9000))); | |
| 642 | \\refuse(window.craft.prefs.set('@bad', 1)); | |
| 643 | \\refuse(window.craft.prefs.get('has space')); | |
| 644 | ); | |
| 645 | ||
| 646 | try testing.expectEqualStrings("0", try fx.text("String(posted.length)")); | |
| 647 | try testing.expectEqualStrings( | |
| 648 | "PREFS_UNSUPPORTED_VALUE,PREFS_UNSUPPORTED_VALUE,PREFS_UNSUPPORTED_VALUE," ++ | |
| 649 | "PREFS_UNSUPPORTED_VALUE,PREFS_NON_FINITE,PREFS_NON_FINITE," ++ | |
| 650 | "PREFS_VALUE_TOO_LARGE,PREFS_BAD_KEY,PREFS_BAD_KEY", | |
| 651 | try fx.text("codes.join(',')"), | |
| 652 | ); | |
| 653 | } | |
| 654 | ||
| 655 | test "the get reply the native side builds is the shape the facade unwraps" { | |
| 656 | var fx = try Fixture.init(); | |
| 657 | defer fx.deinit(); | |
| 658 | const ctx = fx.ctx; | |
| 659 | ||
| 660 | _ = try ctx.evaluate(WEBVIEW_HOST); | |
| 661 | _ = try ctx.evaluate(BRIDGE); | |
| 662 | _ = try ctx.evaluate( | |
| 663 | \\var got = {}; | |
| 664 | \\window.craft.prefs.get('dark').then(function (v) { got.dark = v; got.darkType = typeof v }); | |
| 665 | ); | |
| 666 | ||
| 667 | var mem = prefs.MemoryBackend.init(testing.allocator); | |
| 668 | defer mem.deinit(); | |
| 669 | const store = memoryStore(&mem); | |
| 670 | try store.set("dark", .{ .boolean = true }); | |
| 671 | ||
| 672 | const read = try store.get(testing.allocator, "dark"); | |
| 673 | var reply: std.ArrayListUnmanaged(u8) = .empty; | |
| 674 | defer reply.deinit(testing.allocator); | |
| 675 | try prefs.appendReadJson(testing.allocator, &reply, read); | |
| 676 | ||
| 677 | // Exactly what `sendResultToJS` evaluates in the page. | |
| 678 | const script = try std.fmt.allocPrint( | |
| 679 | testing.allocator, | |
| 680 | "window.__craftBridgeResult('{s}',{s});", | |
| 681 | .{ prefs_actions.get, reply.items }, | |
| 682 | ); | |
| 683 | defer testing.allocator.free(script); | |
| 684 | _ = try ctx.evaluate(script); | |
| 685 | ||
| 686 | // A stored boolean must arrive as `true`, not as `1`. | |
| 687 | try testing.expectEqualStrings("true", try fx.text("String(got.dark)")); | |
| 688 | try testing.expectEqualStrings("boolean", try fx.text("got.darkType")); | |
| 689 | } | |
| 690 | ||
| 691 | test "an absent key falls back, and a foreign value is an error rather than a shrug" { | |
| 692 | var fx = try Fixture.init(); | |
| 693 | defer fx.deinit(); | |
| 694 | const ctx = fx.ctx; | |
| 695 | ||
| 696 | _ = try ctx.evaluate(WEBVIEW_HOST); | |
| 697 | _ = try ctx.evaluate(BRIDGE); | |
| 698 | _ = try ctx.evaluate( | |
| 699 | \\var out = []; | |
| 700 | \\window.craft.prefs.get('missing', 'fallback').then(function (v) { out.push(v) }); | |
| 701 | ); | |
| 702 | ||
| 703 | var reply: std.ArrayListUnmanaged(u8) = .empty; | |
| 704 | defer reply.deinit(testing.allocator); | |
| 705 | try prefs.appendReadJson(testing.allocator, &reply, .absent); | |
| 706 | const absent_script = try std.fmt.allocPrint(testing.allocator, "window.__craftBridgeResult('{s}',{s});", .{ prefs_actions.get, reply.items }); | |
| 707 | defer testing.allocator.free(absent_script); | |
| 708 | _ = try ctx.evaluate(absent_script); | |
| 709 | try testing.expectEqualStrings("fallback", try fx.text("out.join(',')")); | |
| 710 | ||
| 711 | // Something outside craft wrote an array into the key. Coercing it to | |
| 712 | // `undefined` would lose data silently; the app is told instead. | |
| 713 | _ = try ctx.evaluate( | |
| 714 | \\var failure = null; | |
| 715 | \\window.craft.prefs.get('list').then(function () {}, function (e) { failure = e.code }); | |
| 716 | ); | |
| 717 | reply.clearRetainingCapacity(); | |
| 718 | try prefs.appendReadJson(testing.allocator, &reply, .{ .foreign = .{ .cf_type = "CFArray" } }); | |
| 719 | const foreign_script = try std.fmt.allocPrint(testing.allocator, "window.__craftBridgeResult('{s}',{s});", .{ prefs_actions.get, reply.items }); | |
| 720 | defer testing.allocator.free(foreign_script); | |
| 721 | _ = try ctx.evaluate(foreign_script); | |
| 722 | try testing.expectEqualStrings("PREFS_FOREIGN_VALUE", try fx.text("String(failure)")); | |
| 723 | } | |
| 724 | ||
| 725 | test "the keys reply is the shape the facade unwraps" { | |
| 726 | var fx = try Fixture.init(); | |
| 727 | defer fx.deinit(); | |
| 728 | const ctx = fx.ctx; | |
| 729 | ||
| 730 | _ = try ctx.evaluate(WEBVIEW_HOST); | |
| 731 | _ = try ctx.evaluate(BRIDGE); | |
| 732 | _ = try ctx.evaluate( | |
| 733 | \\var listed = null; | |
| 734 | \\window.craft.prefs.keys().then(function (k) { listed = k }); | |
| 735 | ); | |
| 736 | ||
| 737 | var mem = prefs.MemoryBackend.init(testing.allocator); | |
| 738 | defer mem.deinit(); | |
| 739 | const store = memoryStore(&mem); | |
| 740 | try store.set("theme", .{ .string = "dark" }); | |
| 741 | try store.set("fontSize", .{ .int = 13 }); | |
| 742 | ||
| 743 | const listed = try store.keys(testing.allocator); | |
| 744 | defer { | |
| 745 | for (listed) |k| testing.allocator.free(k); | |
| 746 | testing.allocator.free(listed); | |
| 747 | } | |
| 748 | var reply: std.ArrayListUnmanaged(u8) = .empty; | |
| 749 | defer reply.deinit(testing.allocator); | |
| 750 | try prefs.appendKeysJson(testing.allocator, &reply, listed); | |
| 751 | ||
| 752 | const script = try std.fmt.allocPrint(testing.allocator, "window.__craftBridgeResult('{s}',{s});", .{ prefs_actions.keys, reply.items }); | |
| 753 | defer testing.allocator.free(script); | |
| 754 | _ = try ctx.evaluate(script); | |
| 755 | ||
| 756 | try testing.expectEqualStrings("fontSize,theme", try fx.text("listed.join(',')")); | |
| 757 | } | |
| 758 | ||
| 759 | test "the Settings menu item reaches craft.settings.onOpen" { | |
| 760 | // Native evaluates exactly this string when the item is chosen — the same | |
| 761 | // compile-time constant `craftOpenSettingsCallback` passes to evalJS. | |
| 762 | var fx = try Fixture.init(); | |
| 763 | defer fx.deinit(); | |
| 764 | const ctx = fx.ctx; | |
| 765 | ||
| 766 | _ = try ctx.evaluate(WEBVIEW_HOST); | |
| 767 | _ = try ctx.evaluate(BRIDGE); | |
| 768 | _ = try ctx.evaluate( | |
| 769 | \\var opened = []; | |
| 770 | \\window.craft.settings.onOpen(function (d) { opened.push(d.source) }); | |
| 771 | ); | |
| 772 | ||
| 773 | _ = try ctx.evaluate("if(window.__craftSettingsOpen)window.__craftSettingsOpen();"); | |
| 774 | try testing.expectEqualStrings("menu", try fx.text("opened.join(',')")); | |
| 775 | } | |
| 776 | ||
| 777 | test "settings.open reaches the same handler without touching native" { | |
| 778 | // A gear button in the app's own UI should land in the same place as Cmd+, | |
| 779 | // rather than needing a second code path. | |
| 780 | var fx = try Fixture.init(); | |
| 781 | defer fx.deinit(); | |
| 782 | const ctx = fx.ctx; | |
| 783 | ||
| 784 | _ = try ctx.evaluate(WEBVIEW_HOST); | |
| 785 | _ = try ctx.evaluate(BRIDGE); | |
| 786 | _ = try ctx.evaluate( | |
| 787 | \\var opened = []; | |
| 788 | \\window.craft.settings.onOpen(function (d) { opened.push(d.source) }); | |
| 789 | \\window.craft.settings.open('toolbar'); | |
| 790 | ); | |
| 791 | ||
| 792 | try testing.expectEqualStrings("toolbar", try fx.text("opened.join(',')")); | |
| 793 | // Page-local: nothing crosses the bridge for it. | |
| 794 | try testing.expectEqualStrings("0", try fx.text("String(posted.length)")); | |
| 795 | } | |
| 796 | ||
| 797 | test "unsubscribing from settings stops delivery, and does not double-count" { | |
| 798 | var fx = try Fixture.init(); | |
| 799 | defer fx.deinit(); | |
| 800 | const ctx = fx.ctx; | |
| 801 | ||
| 802 | _ = try ctx.evaluate(WEBVIEW_HOST); | |
| 803 | _ = try ctx.evaluate(BRIDGE); | |
| 804 | _ = try ctx.evaluate( | |
| 805 | \\var hits = 0; | |
| 806 | \\var off = window.craft.settings.onOpen(function () { hits++ }); | |
| 807 | \\window.craft.settings.open(); | |
| 808 | \\off(); | |
| 809 | \\off(); | |
| 810 | \\window.craft.settings.open(); | |
| 811 | ); | |
| 812 | ||
| 813 | try testing.expectEqualStrings("1", try fx.text("String(hits)")); | |
| 814 | // The listener counter drives a console hint when nothing is listening; | |
| 815 | // an unsubscribe called twice must not push it negative and silence it. | |
| 816 | try testing.expectEqualStrings("0", try fx.text("String(window.__craftSettingsListeners)")); | |
| 817 | } | |