ReviewOS

also looking at this

craft-native/craft

feat(prefs): a Settings… item and a preference store that keeps its types

#58
Merged glennmichael123 wants to merge feat/settings-and-prefs into main
14 files +2289 -10
docs/BRIDGE_API.mdmodified+99-0
Changes to docs/BRIDGE_API.md
@@ -9,6 +9,7 @@ The Craft JavaScript Bridge provides a seamless interface for your web applicati
99- [System Tray API](#system-tray-api)
1010- [Application Menu (macOS)](#application-menu-macos)
1111- [Global Shortcuts (macOS)](#global-shortcuts-macos)
12- [Settings and Preferences (macOS)](#settings-and-preferences-macos)
1213- [Window API](#window-api)
1314- [App API](#app-api)
1415- [TypeScript Support](#typescript-support)
@@ -367,6 +368,102 @@ unsubscribe function.
367368| `isRegistered(id): Promise<boolean>` | whether the id is known, enabled or not |
368369| `list(): Promise<Shortcut[]>` | `{ id, accelerator, key, enabled }` for each |
369370
371## Settings and Preferences (macOS)
372
373### The Settings… item
374
375craft's default App menu ships a **Settings… ⌘,** where every Mac app has it,
376between About and Hide. Choosing it — or pressing ⌘, — fires a `craft:settings:open`
377event into the page. An app only has to listen:
378
379```javascript
380window.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
386rather than duplicating it:
387
388```javascript
389gearButton.onclick = () => window.craft.settings.open('toolbar')
390```
391
392That call is page-local; nothing crosses the bridge for it.
393
394It is **not** delivered as `craft:menu:action` with a reserved id, deliberately.
395An app that never calls `craft.menu.set()` should not have to subscribe to the
396menu API to catch ⌘,, and a "reserved" menu id would be indistinguishable from
397an app-declared item that happened to pick the same string.
398
399Calling `craft.menu.set()` replaces the whole bar, so the default item goes with
400it — 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
407same `craft:settings:open` event arrives either way. The `id` is required —
408items without one are skipped.
409
410### `window.craft.prefs`
411
412A small preference store over the platform's own mechanism, so `defaults read`
413and a native settings pane both see what the app wrote.
414
415```javascript
416await window.craft.prefs.set('theme', 'dark')
417await window.craft.prefs.set('fontSize', 13)
418await window.craft.prefs.set('firstRunDone', true)
419
420const 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
433raises an Objective-C exception for a value that is not a property-list type,
434and craft cannot catch one, so refusing containers in the page is what makes
435that crash unreachable rather than merely unlikely. Serialise structure
436yourself:
437
438```javascript
439await craft.prefs.set('recents', JSON.stringify(list))
440const list = JSON.parse(await craft.prefs.get('recents', '[]'))
441```
442
443Refusals 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
445match `/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/`), and `PREFS_FOREIGN_VALUE` when
446something 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
450declares the expected type, so a value left behind by an older build of the app
451degrades to the default instead of to a surprise.
452
453**Where it goes.** Preferences land in the app's own domain — the bundle
454identifier inside a packaged `.app`, the executable's name otherwise. That
455means preferences set while developing against the shared `craft` binary are
456in a different domain from the packaged app's, and every dev-mode app shares
457one. `info()` reports which domain is live and how to read it:
458
459```javascript
460const { domain, readCommand } = await craft.prefs.info()
461// readCommand: "defaults read com.example.myapp"
462```
463
464craft namespaces its keys, so `clear()` and `keys()` cannot disturb the window
465positions and inspector state AppKit and WebKit keep in the same domain.
466
370467## Window API
371468
372469Control the application window from JavaScript.
@@ -601,6 +698,8 @@ async function onComplete() {
601698| Window Control | ✅ | ✅ | ✅ |
602699| Hide Dock Icon | ✅ | ➖ | ➖ |
603700| Global Shortcuts | ✅ | �� | �� |
701| Settings… item | ✅ | �� | �� |
702| Preferences | ✅ | �� | �� |
604703
605704✅ Implemented | �� In Progress | ➖ Not Applicable
606705
packages/typescript/src/index.tsmodified+4-0
Changes to packages/typescript/src/index.ts
@@ -552,6 +552,10 @@ export type {
552552 CraftDatabaseAPI,
553553 CraftHttpAPI,
554554 CraftCryptoAPI,
555 CraftPreferencesAPI,
556 CraftPreferencesInfo,
557 CraftSettingsAPI,
558 PreferenceValue,
555559 CraftEventType,
556560 CraftEventMap,
557561 CraftEventHandler,
packages/typescript/src/types.tsmodified+108-0
Changes to packages/typescript/src/types.ts
@@ -1142,8 +1142,116 @@ export interface CraftBridgeAPI {
11421142 * shortcut that can never fire is worse than one that was never accepted.
11431143 */
11441144 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 */
1167export type PreferenceValue = string | number | boolean
1168
1169export 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
11451181}
11461182
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 */
1191export 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 */
1243export 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
11471255/** A global hotkey, as `craft.shortcuts.list()` reports it. */
11481256export interface GlobalShortcut {
11491257 /** The id the app registered it under. */
packages/zig/build.zigmodified+33-0
Changes to packages/zig/build.zig
@@ -605,6 +605,31 @@ pub fn build(b: *std.Build) void {
605605 }),
606606 });
607607
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
608633 const config_tests = b.addTest(.{
609634 .root_module = b.createModule(.{
610635 .root_source_file = b.path("test/config_test.zig"),
@@ -941,6 +966,8 @@ pub fn build(b: *std.Build) void {
941966 const run_space_list_tests = b.addRunArtifact(space_list_tests);
942967 const run_local_tls_tests = b.addRunArtifact(local_tls_tests);
943968 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);
944971 const run_key_codes_tests = b.addRunArtifact(key_codes_tests);
945972 const run_accelerator_tests = b.addRunArtifact(accelerator_tests);
946973 const run_macos_hotkey_tests = b.addRunArtifact(macos_hotkey_tests);
@@ -1051,6 +1078,8 @@ pub fn build(b: *std.Build) void {
10511078 test_step.dependOn(&run_space_list_tests.step);
10521079 test_step.dependOn(&run_local_tls_tests.step);
10531080 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);
10541083 test_step.dependOn(&run_key_codes_tests.step);
10551084 test_step.dependOn(&run_accelerator_tests.step);
10561085 test_step.dependOn(&run_macos_hotkey_tests.step);
@@ -1154,6 +1183,10 @@ pub fn build(b: *std.Build) void {
11541183 const test_menu_roles_step = b.step("test:menu-roles", "Run menu role table tests");
11551184 test_menu_roles_step.dependOn(&run_menu_roles_tests.step);
11561185
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
11571190 const test_key_codes_step = b.step("test:key-codes", "Run key code table tests");
11581191 test_key_codes_step.dependOn(&run_key_codes_tests.step);
11591192
packages/zig/src/bridge_contracts.zigmodified+10-7
Changes to packages/zig/src/bridge_contracts.zig
@@ -3,14 +3,17 @@
33//! `test/injected_js_test.zig` drives the real injected JavaScript and then
44//! decodes what it posted with the very code the bridge dispatches to — which
55//! 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.
89//!
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/`.
1415
1516pub const menu = ;
1617pub const shortcuts = ;
18pub const prefs = ;
19pub const prefs_actions = ;
packages/zig/src/bridge_menu.zigmodified+23-3
Changes to packages/zig/src/bridge_menu.zig
@@ -324,17 +324,37 @@ pub const MenuBridge = struct {
324324 defer self.allocator.free(key_cstr);
325325 const ns_key = macos.msgSend1(macos.msgSend0(NSString, "alloc"), "initWithUTF8String:", key_cstr.ptr);
326326
327 // "settings" is craft's own role, not an AppKit one. `menu_roles.zig`
328 // can only hold real action selectors and AppKit publishes none for the
329 // Settings item, so this routes to the same target the default menu
330 // bar uses — which is what lets an app that replaces the whole bar with
331 // `craft.menu.set()` keep a working Cmd+, and the same
332 // `craft:settings:open` event.
333 const is_settings = if (role) |r|
334 std.ascii.eqlIgnoreCase(r, "settings") or std.ascii.eqlIgnoreCase(r, "preferences")
335 else
336 false;
337
327338 // Role items act through the responder chain; id items through the
328339 // registered target. An unknown role falls back to an id item rather
329340 // than a dead one, so a newer JS surface degrades to an event the
330341 // page can still handle.
331 const role_sel = if (role) |r| roleSelector(r) else null;
332 const action_sel = if (role_sel) |s| macos.sel(s) else macos.sel("craftMenuAction:");
342 const role_sel = if (is_settings) null else if (role) |r| roleSelector(r) else null;
343 const action_sel = if (is_settings)
344 macos.sel(macos.settings_action_selector)
345 else if (role_sel) |s|
346 macos.sel(s)
347 else
348 macos.sel("craftMenuAction:");
333349
334350 const NSMenuItem = macos.getClass("NSMenuItem");
335351 const item = macos.msgSend3(macos.msgSend0(NSMenuItem, "alloc"), "initWithTitle:action:keyEquivalent:", ns_label, action_sel, ns_key);
336352
337 if (role_sel == null) {
353 if (is_settings) {
354 if (macos.ensureSettingsTarget()) |target| {
355 _ = macos.msgSend1(item, "setTarget:", target);
356 }
357 } else if (role_sel == null) {
338358 if (ensureMenuTarget()) |target| {
339359 _ = macos.msgSend1(item, "setTarget:", target);
340360 }
packages/zig/src/bridge_prefs.zigadded+264-0
Changes to packages/zig/src/bridge_prefs.zig
@@ -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
28const std = ;
29const builtin = ;
30const bridge_error = ;
31const logging = ;
32const prefs = ;
33const prefs_macos = ;
34
35const log = logging.prefs;
36const BridgeError = bridge_error.BridgeError;
37
38const actions = ;
39
40pub const action_get = actions.get;
41pub const action_set = actions.set;
42pub const action_delete = actions.delete;
43pub const action_clear = actions.clear;
44pub const action_keys = actions.keys;
45pub 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.
55pub const action_unknown = actions.unknown;
56
57pub 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.
202fn 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.
212fn 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
232const testing = std.testing;
233
234test "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
249test "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
257test "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}
packages/zig/src/bridge_prefs_actions.zigadded+19-0
Changes to packages/zig/src/bridge_prefs_actions.zig
@@ -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
11pub const get = "prefs:get";
12pub const set = "prefs:set";
13pub const delete = "prefs:delete";
14pub const clear = "prefs:clear";
15pub const keys = "prefs:keys";
16pub const info = "prefs:info";
17
18/// What an unrecognised action is reported under. Never the bytes that arrived.
19pub const unknown = "prefs:unknownAction";
packages/zig/src/js/craft-bridge.jsmodified+175-0
Changes to packages/zig/src/js/craft-bridge.js
@@ -360,6 +360,22 @@
360360 }
361361 }
362362
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
363379 // -------------------------------------------------------------------------
364380 // window — full surface from bridge_window.zig
365381 // -------------------------------------------------------------------------
@@ -562,6 +578,165 @@
562578 onError: _evt('craft:shortcut:error'),
563579 }
564580
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
565740 // -------------------------------------------------------------------------
566741 // theme — system appearance
567742 // -------------------------------------------------------------------------
packages/zig/src/logging.zigmodified+1-0
Changes to packages/zig/src/logging.zig
@@ -291,6 +291,7 @@ pub const system = scoped("System");
291291pub const webview = scoped("WebView");
292292pub const marketplace = scoped("Marketplace");
293293pub const shortcuts = scoped("Shortcuts");
294pub const prefs = scoped("Prefs");
294295
295296// ============================================
296297// Tests
packages/zig/src/macos.zigmodified+78-0
Changes to packages/zig/src/macos.zig
@@ -4024,6 +4024,7 @@ var global_service_menu_bridge: ?*.ServiceMenu
40244024var global_serial_bridge: ?*.SerialBridge = null;
40254025var global_focus_bridge: ?*.FocusBridge = null;
40264026var global_screen_sharing_bridge: ?*.ScreenSharingBridge = null;
4027var global_prefs_bridge: ?*.PrefsBridge = null;
40274028
40284029pub fn setGlobalTrayHandle(handle: *anyopaque) void {
40294030 global_tray_handle_for_bridge = handle;
@@ -4312,6 +4313,11 @@ pub fn setupBridgeHandlers(allocator: std.mem.Allocator, tray_handle: ?*anyopaqu
43124313 global_screen_sharing_bridge = try allocator.create(T);
43134314 global_screen_sharing_bridge.?.* = T.init(allocator);
43144315 }
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 }
43154321
43164322 // theme + dragOut + deepLink: native modules with their own state, no
43174323 // shared bridge struct — they install handlers directly via init().
@@ -4675,6 +4681,8 @@ pub fn handleBridgeMessageJSON(json_str: []const u8) !void {
46754681 if (global_focus_bridge) |bridge| try bridge.handleMessage(action, data_json_str);
46764682 } else if (std.mem.eql(u8, msg_type, "screenSharing")) {
46774683 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);
46784686 } else if (std.mem.eql(u8, msg_type, "debug")) {
46794687 // Handle debug messages
46804688 if (comptime builtin.mode == .debug) {
@@ -6080,6 +6088,56 @@ fn menuRole(comptime name: []const u8) [*:0]const u8 {
60806088/// Declaring the bar as data rather than as ~40 lines of msgSend per menu is
60816089/// what keeps it readable at this size — and it keeps the default bar and an
60826090/// 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.
6097pub const settings_action_selector: [*:0]const u8 = "craftOpenSettings:";
6098
6099var settingsTargetClass: objc.Class = null;
6100var settings_target: ?objc.id = null;
6101
6102fn 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.
6120pub 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
60836141const DefaultItem = struct {
60846142 title: []const u8 = "",
60856143 /// What the item performs. Null is a separator.
@@ -6090,6 +6148,9 @@ const DefaultItem = struct {
60906148 extra_modifiers: c_ulong = 0,
60916149 /// Append the app name to the title, as the App menu does: "Quit Craft".
60926150 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,
60936154};
60946155
60956156const DefaultMenu = struct {
@@ -6111,6 +6172,12 @@ const DefaultMenu = struct {
61116172const app_menu_items = [_]DefaultItem{
61126173 .{ .title = "About", .selector = menuRole("about"), .with_app_name = true },
61136174 .{},
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 .{},
61146181 .{ .title = "Hide", .selector = menuRole("hide"), .key = "h", .with_app_name = true },
61156182 .{ .title = "Hide Others", .selector = menuRole("hideOthers"), .key = "h", .extra_modifiers = modifier_option },
61166183 .{ .title = "Show All", .selector = menuRole("showAll") },
@@ -6257,6 +6324,17 @@ pub fn createApplicationMenu() void {
62576324 sel(selector),
62586325 createNSString(item.key),
62596326 );
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 }
62606338 if (item.extra_modifiers != 0) {
62616339 msgSendVoid1Ulong(native_item, "setKeyEquivalentModifierMask:", modifier_command | item.extra_modifiers);
62626340 }
packages/zig/src/prefs.zigadded+709-0
Changes to packages/zig/src/prefs.zig
@@ -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
21const std = ;
22const bridge_error = ;
23
24/// Namespace for every key craft.prefs owns, inside a domain it shares with
25/// AppKit, WebKit and the app itself.
26pub const prefix = "craft.prefs.";
27
28/// Longest key an app may use, before prefixing.
29pub 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.
34pub const max_value_bytes = 8 * 1024;
35
36pub const Error = error{
37 InvalidKey,
38 ValueTooLarge,
39 UnsupportedValue,
40 BackendFailure,
41 OutOfMemory,
42};
43
44/// The only value types craft.prefs stores.
45pub 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.
67pub 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
74pub 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.
85pub 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.
110pub 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
119pub const FullKeyBuf = [prefix.len + max_key_len]u8;
120
121/// `theme` -> `craft.prefs.theme`, validating on the way.
122pub 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.
130pub 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
137pub 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
147pub 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"}`
242pub 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"}`
252pub const KeyShape = struct {
253 k: []const u8 = "",
254};
255
256pub 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.
263pub 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`.
282pub 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`.
321pub 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.
344pub 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
442const testing = std.testing;
443
444fn testStore(mem: *MemoryBackend) Store {
445 return .{ .backend = mem.backend() };
446}
447
448test "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
465test "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
484test "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
499test "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
512test "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
526test "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
535test "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
555test "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
578test "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
587test "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
599test "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
610test "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
623test "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
642fn 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
649test "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
668test "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
678test "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
689test "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}
packages/zig/src/prefs_macos.zigadded+478-0
Changes to packages/zig/src/prefs_macos.zig
@@ -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
27const std = ;
28const builtin = ;
29const prefs = ;
30
31const is_macos = builtin.os.tag == .macos;
32
33const CFTypeRef = ?*anyopaque;
34const CFIndex = isize;
35const CFTypeID = usize;
36
37const kCFStringEncodingUTF8: u32 = 0x0800_0100;
38const kCFNumberSInt64Type: CFIndex = 4;
39const kCFNumberFloat64Type: CFIndex = 6;
40
41extern "c" fn CFRelease(cf: CFTypeRef) void;
42extern "c" fn CFGetTypeID(cf: CFTypeRef) CFTypeID;
43extern "c" fn CFBooleanGetTypeID() CFTypeID;
44extern "c" fn CFNumberGetTypeID() CFTypeID;
45extern "c" fn CFStringGetTypeID() CFTypeID;
46extern "c" fn CFCopyTypeIDDescription(type_id: CFTypeID) CFTypeRef;
47
48extern "c" fn CFStringCreateWithBytes(alloc: CFTypeRef, bytes: [*]const u8, num_bytes: CFIndex, encoding: u32, external: bool) CFTypeRef;
49extern "c" fn CFStringGetLength(s: CFTypeRef) CFIndex;
50extern "c" fn CFStringGetMaximumSizeForEncoding(len: CFIndex, encoding: u32) CFIndex;
51extern "c" fn CFStringGetCString(s: CFTypeRef, buffer: [*]u8, size: CFIndex, encoding: u32) bool;
52
53extern "c" fn CFNumberCreate(alloc: CFTypeRef, number_type: CFIndex, value_ptr: *const anyopaque) CFTypeRef;
54extern "c" fn CFNumberGetValue(number: CFTypeRef, number_type: CFIndex, value_ptr: *anyopaque) bool;
55extern "c" fn CFNumberIsFloatType(number: CFTypeRef) bool;
56extern "c" fn CFBooleanGetValue(b: CFTypeRef) bool;
57
58extern "c" fn CFArrayGetCount(array: CFTypeRef) CFIndex;
59extern "c" fn CFArrayGetValueAtIndex(array: CFTypeRef, index: CFIndex) CFTypeRef;
60
61extern "c" fn CFPreferencesCopyValue(key: CFTypeRef, app_id: CFTypeRef, user: CFTypeRef, host: CFTypeRef) CFTypeRef;
62extern "c" fn CFPreferencesSetValue(key: CFTypeRef, value: CFTypeRef, app_id: CFTypeRef, user: CFTypeRef, host: CFTypeRef) void;
63extern "c" fn CFPreferencesCopyKeyList(app_id: CFTypeRef, user: CFTypeRef, host: CFTypeRef) CFTypeRef;
64extern "c" fn CFPreferencesAppSynchronize(app_id: CFTypeRef) bool;
65
66extern "c" fn CFBundleGetMainBundle() CFTypeRef;
67extern "c" fn CFBundleGetIdentifier(bundle: CFTypeRef) CFTypeRef;
68
69extern "c" const kCFBooleanTrue: CFTypeRef;
70extern "c" const kCFBooleanFalse: CFTypeRef;
71extern "c" const kCFPreferencesCurrentUser: CFTypeRef;
72extern "c" const kCFPreferencesAnyHost: CFTypeRef;
73extern "c" const kCFPreferencesCurrentApplication: CFTypeRef;
74
75/// Which preferences domain to read and write.
76pub 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
90pub 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.
237fn 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.
275fn 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
297fn 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
305fn 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.
324pub 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
345extern "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
354const testing = std.testing;
355const test_domain = "dev.craft.prefs.selftest";
356
357fn scratchBackend() Backend {
358 return Backend.init(.{ .named = test_domain });
359}
360
361test "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
397test "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
411test "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
441test "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
467extern "c" fn CFArrayCreate(alloc: CFTypeRef, values: [*]const CFTypeRef, count: CFIndex, callbacks: ?*const anyopaque) CFTypeRef;
468
469test "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}
packages/zig/test/injected_js_test.zigmodified+288-0
Changes to packages/zig/test/injected_js_test.zig
@@ -18,6 +18,8 @@ const js = ;
1818const testing = std.testing;
1919const contracts = ;
2020const bridge_menu = contracts.menu;
21const prefs = contracts.prefs;
22const prefs_actions = contracts.prefs_actions;
2123const shortcut_registry = contracts.shortcuts;
2224
2325// 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" {
527529 registry.register(try fx.text("posted[0].d")),
528530 );
529531}
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.
539fn memoryStore(mem: *prefs.MemoryBackend) prefs.Store {
540 return .{ .backend = mem.backend() };
541}
542
543test "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
589test "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
623test "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
655test "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
691test "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
725test "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
759test "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
777test "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
797test "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}