ReviewOS

also looking at this

craft-native/craft

feat(cli): --headless, and make DevTools reachable at all

#61
Merged glennmichael123 wants to merge feat/headless into main
9 files +340 -6
docs/BRIDGE_API.mdmodified+57-0
Changes to docs/BRIDGE_API.md
@@ -10,6 +10,7 @@ The Craft JavaScript Bridge provides a seamless interface for your web applicati
1010- [Application Menu (macOS)](#application-menu-macos)
1111- [Global Shortcuts (macOS)](#global-shortcuts-macos)
1212- [Settings and Preferences (macOS)](#settings-and-preferences-macos)
13- [Headless mode](#headless-mode)
1314- [Window API](#window-api)
1415- [App API](#app-api)
1516- [TypeScript Support](#typescript-support)
@@ -464,6 +465,61 @@ const { domain, readCommand } = await craft.prefs.info()
464465craft namespaces its keys, so `clear()` and `keys()` cannot disturb the window
465466positions and inspector state AppKit and WebKit keep in the same domain.
466467
468## Headless mode
469
470`craft --headless` builds the window and never puts it on screen. The page
471loads, JavaScript runs, and the webview stays capturable — a snapshot taken
472after script has mutated the DOM reflects the mutation.
473
474```bash
475craft http://localhost:3000 --headless --timing
476```
477
478Also `headless: true` in `craft.json`, and `window.headless` on `AppConfig`.
479
480The motivating case is measurement: a perf loop that launches craft repeatedly
481no longer flashes a window at the operator each time, which is worth doing on
482its own terms.
483
484### What it cannot do
485
486**It does not animate.** An unshown window does not drive the compositor, so
487`requestAnimationFrame` stops after the first frame and page timers fall to
488roughly 1Hz. That means, silently:
489
490- CSS and JavaScript animations stay on frame one
491- canvas and WebGL render loops never advance
492- charting libraries that redraw on rAF show their initial state
493- "wait until the spinner stops" — implemented with `setInterval` — crawls
494
495Drive the page with explicit calls, and take readiness from load completion
496rather than from a polling loop inside the page. No invisible window
497configuration avoids this: what drives the compositor is the window being
498*unoccluded*, which needs activation, which means stealing focus and showing a
499Dock icon — a worse trade for the operator this mode exists to help.
500
501`--headless` and `--menubar-only` are contradictory (a menubar app has no
502window to hide) and passing both is an error rather than a silent no-op.
503
504### Inspecting a running window
505
506`craft --dev-tools` enables the Web Inspector and makes the webview appear in
507Safari's **Develop** menu.
508
509This flag is new because there was previously no way to turn DevTools *on*:
510the setting defaulted to off and the only flag, `--no-devtools`, set it off
511again. And even when it was enabled through `craft.json`, the inspector did not
512work on macOS 13.3 or later, which introduced `isInspectable` defaulting to
513`NO` — craft set only the older `developerExtrasEnabled`, which stopped being
514sufficient.
515
516It is a **debugging affordance, not automation**. It opens no port and prints
517no endpoint, because there is nothing to print: WebKit on macOS has no
518socket-based inspector server — an inspectable `WKWebView` holds no internet
519sockets at all — and `safaridriver` refuses to attach to anything that is not
520Safari. Driving a craft page from another process needs a control channel craft
521owns, which does not exist yet.
522
467523## Window API
468524
469525Control the application window from JavaScript.
@@ -697,6 +753,7 @@ async function onComplete() {
697753| Tray Menu | �� | �� | �� |
698754| Window Control | ✅ | ✅ | ✅ |
699755| Hide Dock Icon | ✅ | ➖ | ➖ |
756| Headless | ✅ | �� | �� |
700757| Global Shortcuts | ✅ | �� | �� |
701758| Settings… item | ✅ | �� | �� |
702759| Preferences | ✅ | �� | �� |
packages/typescript/src/__tests__/headless.test.tsadded+56-0
Changes to packages/typescript/src/__tests__/headless.test.ts
@@ -0,0 +1,56 @@
1/**
2 * `headless` and the DevTools flag (craft-native/craft#48).
3 *
4 * The issue is that there is no way to drive a craft window from outside the
5 * process, and that measurement loops had to spawn *visible* windows over and
6 * over — which the issue rightly calls hostile to photosensitive users.
7 */
8
9import type { AppConfig } from '../types'
10import { describe, expect, it } from 'bun:test'
11
12async function argsFor(config: AppConfig): Promise<string[]> {
13 const { CraftApp } = await import('../index')
14 const app = new (CraftApp as unknown as { new (c: AppConfig): { buildArgs: () => string[] } })(config)
15 return (app as unknown as { buildArgs: () => string[] }).buildArgs()
16}
17
18describe('headless', () => {
19 it('passes the flag through', async () => {
20 expect(await argsFor({ window: { headless: true } })).toContain('--headless')
21 })
22
23 it('is absent unless asked for', async () => {
24 expect(await argsFor({ window: { width: 900 } })).not.toContain('--headless')
25 })
26
27 it('does not change what the window is, only whether it is shown', async () => {
28 // A headless run has to build the same window a visible one would, or a
29 // measurement taken from it says nothing about the visible case.
30 const args = await argsFor({ window: { headless: true, width: 900, height: 700, title: 'T' } })
31 expect(args).toContain('--headless')
32 expect(args[args.indexOf('--width') + 1]).toBe('900')
33 expect(args[args.indexOf('--height') + 1]).toBe('700')
34 expect(args[args.indexOf('--title') + 1]).toBe('T')
35 })
36})
37
38describe('devTools', () => {
39 it('is pushed in both directions', async () => {
40 // The regression: the binary defaults DevTools off and had no flag that
41 // turned them on, so the SDK pushing only the negative meant `devTools:
42 // true` — what dev mode sets — silently did nothing.
43 expect(await argsFor({ window: { devTools: true } })).toContain('--dev-tools')
44 expect(await argsFor({ window: { devTools: false } })).toContain('--no-devtools')
45 })
46
47 it('always has an opinion, because the SDK fills one in', async () => {
48 // `window: {}` does not mean "unset": the SDK defaults `devTools` to
49 // `detectDevMode()`, so exactly one of the two flags is always sent. That
50 // is what makes the positive flag necessary — without it, dev mode asked
51 // for DevTools and got silence.
52 const args = await argsFor({ window: {} })
53 const sent = args.filter(a => a === '--dev-tools' || a === '--no-devtools')
54 expect(sent).toHaveLength(1)
55 })
56})
packages/typescript/src/index.tsmodified+7-0
Changes to packages/typescript/src/index.ts
@@ -412,8 +412,13 @@ export class CraftApp {
412412 args.push('--dark')
413413 if (window?.hotReload)
414414 args.push('--hot-reload')
415 // Both directions. The binary defaults DevTools *off* and used to have no
416 // flag that turned them on, so pushing only the negative meant the SDK's
417 // `devTools: true` — which is what dev mode sets — silently did nothing.
415418 if (window?.devTools === false)
416419 args.push('--no-devtools')
420 else if (window?.devTools === true)
421 args.push('--dev-tools')
417422 if (window?.systemTray)
418423 args.push('--system-tray')
419424 if (window?.hideDockIcon)
@@ -422,6 +427,8 @@ export class CraftApp {
422427 args.push('--menubar-only')
423428 if (window?.titlebarHidden)
424429 args.push('--titlebar-hidden')
430 if (window?.headless)
431 args.push('--headless')
425432 if (window?.webSidebarMaterial) {
426433 args.push('--web-sidebar-material')
427434 if (window?.webSidebarWidth)
packages/typescript/src/types.tsmodified+19-0
Changes to packages/typescript/src/types.ts
@@ -57,6 +57,25 @@ export interface WindowOptions {
5757 */
5858 title?: string
5959
60 /**
61 * Build the window without ever putting it on screen (macOS).
62 *
63 * The page loads and runs JavaScript exactly as it would visibly, and it
64 * remains capturable — a snapshot taken after script has mutated the DOM
65 * reflects the mutation.
66 *
67 * **It does not animate.** An unshown window does not drive the compositor,
68 * so `requestAnimationFrame` stops after one frame and page timers fall to
69 * roughly 1Hz. Anything that advances itself — CSS or canvas animation, a
70 * charting library redrawing on rAF, "wait until the spinner stops" — sees
71 * the first frame forever. Drive the page with explicit calls and take
72 * readiness from load completion, not from a polling loop inside the page.
73 *
74 * Contradicts `menubarOnly`, which has no window to hide; passing both is an
75 * error rather than a silent no-op.
76 */
77 headless?: boolean
78
6079 /**
6180 * Remember this window's size and position across launches, under this name
6281 * (macOS).
packages/zig/src/cli.zigmodified+41-0
Changes to packages/zig/src/cli.zig
@@ -40,6 +40,10 @@ pub const WindowOptions = struct {
4040 // process. NSImage decodes everything Cocoa can render, so any common
4141 // raster format works; .icns is preferred for crispness across sizes.
4242 icon: ?[]const u8 = null,
43 // Build the window and never put it on screen. The page still loads, still
44 // runs JavaScript, and can still be captured — what it loses is anything
45 // that advances itself; see `--help`.
46 headless: bool = false,
4347 // The name AppKit shows for the app: the App menu title and the
4448 // "About X" / "Hide X" / "Quit X" items. Without it those read
4549 // `NSProcessInfo.processName`, which for a bare binary is the executable
@@ -160,6 +164,7 @@ const BundleConfig = struct {
160164 hideDockIcon: ?bool = null,
161165 menubarOnly: ?bool = null,
162166 titlebarHidden: ?bool = null,
167 headless: ?bool = null,
163168};
164169
165170/// Absolute path of the running executable's directory.
@@ -258,6 +263,7 @@ fn applyScalarConfig(cfg: BundleConfig, options: *WindowOptions) void {
258263 if (cfg.systemTray) |v| options.system_tray = v;
259264 if (cfg.hideDockIcon) |v| options.hide_dock_icon = v;
260265 if (cfg.titlebarHidden) |v| options.titlebar_hidden = v;
266 if (cfg.headless) |v| options.headless = v;
261267 // A menubar app with a dock icon and no tray is not a menubar app, so the
262268 // one flag sets all three rather than making every manifest repeat them.
263269 if (cfg.menubarOnly) |v| {
@@ -344,6 +350,22 @@ test "a manifest can ask for its window frame to be remembered" {
344350 try std.testing.expectEqualStrings("main", parsed.value.frameAutosave.?);
345351}
346352
353test "a manifest can ask to run headless" {
354 const source =
355 \\{"title":"Probe","headless":true}
356 ;
357 const parsed = try std.json.parseFromSlice(BundleConfig, std.testing.allocator, source, .{
358 .ignore_unknown_fields = true,
359 .allocate = .alloc_always,
360 });
361 defer parsed.deinit();
362 try std.testing.expect(parsed.value.headless.?);
363
364 var options = WindowOptions{};
365 applyScalarConfig(parsed.value, &options);
366 try std.testing.expect(options.headless);
367}
368
347369test "unknown manifest keys are ignored rather than failing the launch" {
348370 const source =
349371 \\{"title":"Hush","somethingNewer":true}
@@ -446,6 +468,14 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !Wind
446468 options.titlebar_hidden = true;
447469 } else if (std.mem.eql(u8, arg, "--debug")) {
448470 // Already handled in first pass
471 } else if (std.mem.eql(u8, arg, "--dev-tools") or std.mem.eql(u8, arg, "--devtools")) {
472 // Until this existed there was no way to turn DevTools *on*: the
473 // field defaults to false here, `--no-devtools` only sets it false
474 // again, and the only other writer was the bundle manifest. So the
475 // inspector was unreachable from the command line and from the SDK,
476 // which pushes `--no-devtools` when it wants them off and nothing
477 // when it wants them on.
478 options.dev_tools = true;
449479 } else if (std.mem.eql(u8, arg, "--no-devtools")) {
450480 options.dev_tools = false;
451481 } else if (std.mem.eql(u8, arg, "--dark")) {
@@ -458,6 +488,8 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !Wind
458488 options.system_tray = true;
459489 } else if (std.mem.eql(u8, arg, "--hide-dock-icon")) {
460490 options.hide_dock_icon = true;
491 } else if (std.mem.eql(u8, arg, "--headless")) {
492 options.headless = true;
461493 } else if (std.mem.eql(u8, arg, "--menubar-only")) {
462494 options.menubar_only = true;
463495 options.system_tray = true; // Menubar-only implies system tray
@@ -560,6 +592,8 @@ fn printHelp() void {
560592 \\ --system-tray Show system tray icon
561593 \\ --hide-dock-icon Hide dock icon (menubar-only mode, macOS)
562594 \\ --menubar-only Menubar-only mode (no window, system tray only)
595 \\ --dev-tools Enable WebKit DevTools and make the webview
596 \\ inspectable from Safari's Develop menu
563597 \\ --no-devtools Disable WebKit DevTools
564598 \\ --titlebar-hidden Hide window titlebar
565599 \\ --native-sidebar Use native macOS sidebar (Finder-style)
@@ -572,6 +606,13 @@ fn printHelp() void {
572606 \\ --sidebar-config <J> Sidebar JSON configuration
573607 \\ --sidebar-width <W> Sidebar width in pixels (default: 220)
574608 \\ --icon <PATH> Path to dock icon image (PNG/JPG/ICNS)
609 \\ --headless Build the window without showing it (macOS).
610 \\ The page loads and runs JavaScript as normal.
611 \\ It does NOT animate: an unshown window does
612 \\ not drive the compositor, so requestAnimation-
613 \\ Frame stops after one frame and page timers
614 \\ fall to roughly 1Hz. Drive the page with
615 \\ explicit calls; do not wait for it to settle.
575616 \\ --app-name <NAME> Name in the menu bar and in About/Hide/Quit
576617 \\ (macOS; defaults to the executable name)
577618 \\ --frame-autosave <NAME>
packages/zig/src/macos.zigmodified+60-4
Changes to packages/zig/src/macos.zig
@@ -92,6 +92,13 @@ pub const WindowStyle = struct {
9292 web_sidebar_width: u32 = 286,
9393 web_sidebar_material_opacity: f64 = 0.78,
9494 benchmark: bool = false, // Benchmark mode: skip bridge setup for fastest window creation
95 /// Build the window and never put it on screen.
96 ///
97 /// Deliberately *not* folded into `benchmark`: that mode strips the user
98 /// content controller, the injected craft script, the message handler and
99 /// both webview globals, so nothing can talk to the page afterwards.
100 /// Headless needs the full construction path and one less call at the end.
101 headless: bool = false,
95102 /// Name under which AppKit remembers this window's frame across launches.
96103 /// Null leaves the window forgetting its geometry every time, which is
97104 /// what craft did until now.
@@ -390,6 +397,13 @@ pub fn msgSendFloat(target: anytype, selector: [*:0]const u8) f64 {
390397}
391398
392399/// Message send that returns bool
400/// `-[NSObject respondsToSelector:]` and friends: a BOOL-returning message
401/// whose one argument is itself a selector.
402pub fn msgSendBool1Sel(target: anytype, selector: [*:0]const u8, arg: objc.SEL) bool {
403 const msg = @as(*const fn (@TypeOf(target), objc.SEL, objc.SEL) callconv(.c) bool, @ptrCast(&objc.objc_msgSend));
404 return msg(target, sel(selector), arg);
405}
406
393407pub fn msgSendBool(target: anytype, selector: [*:0]const u8) bool {
394408 const msg = @as(*const fn (@TypeOf(target), objc.SEL) callconv(.c) bool, @ptrCast(&objc.objc_msgSend));
395409 return msg(target, sel(selector));
@@ -552,6 +566,30 @@ fn applyFrameAutosave(window: objc.id, name: []const u8) void {
552566 _ = msgSend1(window, "setFrameAutosaveName:", key);
553567}
554568
569/// Make a webview inspectable from Safari's Develop menu.
570///
571/// `developerExtrasEnabled` alone stopped being enough at macOS 13.3, which
572/// introduced `isInspectable` defaulting to **NO**. Measured on 26.3.1: a
573/// webview with `developerExtrasEnabled = YES` and nothing else reports
574/// `isInspectable = 0`, so craft's `--dev-tools` has been setting a preference
575/// with no effect for the whole of that range — while a comment in the View
576/// menu asserted the opposite.
577///
578/// Guarded by `respondsToSelector:` rather than a version check: the selector
579/// is absent before 13.3 and sending it unguarded would raise
580/// `doesNotRecognizeSelector`.
581///
582/// Note this is a *debugging affordance*, not automation. It puts the app in
583/// Safari's Develop menu; it opens no port and prints no endpoint. WebKit on
584/// macOS has no socket-based inspector server — verified by observing that an
585/// inspectable webview holds no internet sockets at all — so there is nothing
586/// for a WebDriver-style tool to attach to.
587fn setWebViewInspectable(webview: objc.id) void {
588 const selector = sel("setInspectable:");
589 if (!msgSendBool1Sel(webview, "respondsToSelector:", selector)) return;
590 msgSendVoid1(webview, "setInspectable:", true);
591}
592
555593pub fn createWindow(title: []const u8, width: u32, height: u32, html: []const u8) !objc.id {
556594 return createWindowWithStyle(title, width, height, html, null, .{});
557595}
@@ -778,6 +816,9 @@ pub fn createWindowWithStyle(title: []const u8, width: u32, height: u32, html: ?
778816 // Create WKWebView with configuration
779817 const webview_alloc = msgSend0(WKWebView, "alloc");
780818 const webview = msgSend2(webview_alloc, "initWithFrame:configuration:", frame, config);
819 // `developerExtrasEnabled` above is necessary and, since macOS 13.3, not
820 // sufficient. See `setWebViewInspectable`.
821 if (style.dev_tools) setWebViewInspectable(webview);
781822 startup_timing.mark(.webview_created);
782823 // Start the load before any cosmetic setup.
783824 //
@@ -932,8 +973,10 @@ pub fn createWindowWithStyle(title: []const u8, width: u32, height: u32, html: ?
932973 setAppearance(window, is_dark);
933974 }
934975
935 // Center window if no custom position specified
936 if (style.x == null or style.y == null) {
976 // Center window if no custom position specified. Not when headless:
977 // centring is positioning relative to a screen, and there is no screen
978 // in play.
979 if (!style.headless and (style.x == null or style.y == null)) {
937980 msgSendVoid0(window, "center");
938981 }
939982
@@ -6237,8 +6280,10 @@ const edit_menu_items = [_]DefaultItem{
62376280
62386281/// Reload and Force Reload land on the focused WKWebView, which implements
62396282/// `reload:` and `reloadFromOrigin:` as action selectors. Dev consoles stay
6240/// reachable by right-click when `dev_tools` is enabled (the webview sets
6241/// `setInspectable:YES`).
6283/// reachable by right-click when `dev_tools` is enabled — which needs both
6284/// `developerExtrasEnabled` *and* `setInspectable:` since macOS 13.3. This
6285/// comment used to claim the webview set the latter; nothing did, and the
6286/// string appeared nowhere else in the repository.
62426287const view_menu_items = [_]DefaultItem{
62436288 .{ .title = "Reload", .selector = menuRole("reload"), .key = "r" },
62446289 .{ .title = "Force Reload", .selector = menuRole("forceReload"), .key = "r", .extra_modifiers = modifier_shift },
@@ -6442,6 +6487,17 @@ pub fn runApp() void {
64426487/// activation policy when launched outside an .app bundle, so the window
64436488/// stays behind other apps with no Dock icon and never becomes key.
64446489/// Tray/menubar apps must NOT call this — see the runApp() comment above.
6490/// Run with no Dock icon and no app-switcher entry.
6491///
6492/// `NSApplicationActivationPolicyAccessory`, the same policy menubar apps use.
6493/// For a headless run this is the difference between invisible and "invisible
6494/// except for the icon bouncing in the Dock".
6495pub fn setAccessoryActivationPolicy() void {
6496 const NSApplication = getClass("NSApplication");
6497 const app = msgSend0(NSApplication, "sharedApplication");
6498 _ = msgSend1(app, "setActivationPolicy:", @as(c_long, 1));
6499}
6500
64456501pub fn activateWindowedApp() void {
64466502 const NSApplication = getClass("NSApplication");
64476503 const app = msgSend0(NSApplication, "sharedApplication");
packages/zig/src/main.zigmodified+25-1
Changes to packages/zig/src/main.zig
@@ -108,6 +108,9 @@ pub const WindowStyle = if (builtin.os.tag == .macos) macos.WindowStyle else str
108108 /// and ignored: remembering a window frame is `setFrameAutosaveName:`, an
109109 /// AppKit facility with no counterpart wired up on GTK or Win32 yet.
110110 frame_autosave: ?[]const u8 = null,
111 /// Same: not showing a window is a macOS-side decision about ordering and
112 /// activation, and GTK and Win32 have their own.
113 headless: bool = false,
111114};
112115
113116pub const Window = struct {
@@ -182,6 +185,8 @@ pub const App = struct {
182185 system_trays: std.ArrayList(*SystemTray),
183186 bridge: ?*BridgeAPI = null,
184187 notifications: ?*Notifications = null,
188 /// Run without putting anything on screen. See `runMacOS`.
189 headless: bool = false,
185190
186191 const Self = @This();
187192
@@ -370,12 +375,31 @@ pub const App = struct {
370375 // Show windows if we don't have a system tray
371376 // If we have a system tray, windows were already shown in the correct order
372377 // (after tray creation) by the caller
373 if (self.system_tray == null) {
378 // Headless is these two calls not happening, and almost nothing
379 // else: `createWindowWithStyle` has never ordered its window on
380 // screen — that is `showAllWindows`'s job, and activation is what
381 // gives the process a Dock icon. Skip both and the window exists,
382 // loads, runs JavaScript and can be captured, without ever being
383 // drawn anywhere. Measured, including that a snapshot taken after
384 // JavaScript mutates the DOM reflects the mutation.
385 //
386 // What it costs is in `--help` and the docs, because it is not
387 // obvious and it is not recoverable: an unoccluded window is what
388 // drives the compositor, so `requestAnimationFrame` never fires a
389 // second time and page timers fall to about 1Hz. Anything that
390 // advances itself — animations, canvas loops, "wait for the
391 // spinner to stop" — sees frame one forever. Driving the page with
392 // explicit calls works; waiting for it to drive itself does not.
393 if (self.system_tray == null and !self.headless) {
374394 macos.showAllWindows();
375395 // Windowed app: claim regular activation (Dock icon, key
376396 // window, foreground) — launching from a bare binary would
377397 // otherwise leave the window buried with no Dock presence.
378398 macos.activateWindowedApp();
399 } else if (self.headless) {
400 // No Dock icon and no app-switcher entry either; the point is
401 // to be invisible, and a bouncing Dock icon is not that.
402 macos.setAccessoryActivationPolicy();
379403 }
380404
381405 // Now run the event loop
packages/zig/src/minimal.zigmodified+17-1
Changes to packages/zig/src/minimal.zig
@@ -48,6 +48,14 @@ pub fn main(init: std.process.Init) !void {
4848 if (options.app_name) |name| craft.macos.setProcessName(name);
4949 }
5050
51 // A menubar-only app has no window by definition, so asking for both is a
52 // contradiction rather than a no-op. Saying so beats silently ignoring one
53 // of them and leaving the operator to work out which.
54 if (options.headless and options.menubar_only) {
55 std.debug.print("Error: --headless and --menubar-only are contradictory — a menubar app has no window to hide\n", .{});
56 std.process.exit(1);
57 }
58
5159 // In benchmark mode, disable dev_tools for lower overhead
5260 const effective_dev_tools = if (options.benchmark) false else options.dev_tools;
5361
@@ -68,6 +76,7 @@ pub fn main(init: std.process.Init) !void {
6876 }
6977
7078 var app = craft.App.init(allocator);
79 app.headless = options.headless;
7180 defer app.deinit();
7281
7382 // Initialize platform FIRST (must be called before creating windows or system tray)
@@ -112,6 +121,7 @@ pub fn main(init: std.process.Init) !void {
112121 .dev_tools = effective_dev_tools,
113122 .native_sidebar = true,
114123 .benchmark = options.benchmark,
124 .headless = options.headless,
115125 .frame_autosave = options.frame_autosave,
116126 },
117127 );
@@ -150,6 +160,7 @@ pub fn main(init: std.process.Init) !void {
150160 .dev_tools = effective_dev_tools,
151161 .native_sidebar = true,
152162 .benchmark = options.benchmark,
163 .headless = options.headless,
153164 .frame_autosave = options.frame_autosave,
154165 },
155166 );
@@ -190,6 +201,7 @@ pub fn main(init: std.process.Init) !void {
190201 .system_tray = options.system_tray,
191202 .dev_tools = effective_dev_tools,
192203 .benchmark = options.benchmark,
204 .headless = options.headless,
193205 .frame_autosave = options.frame_autosave,
194206 .web_sidebar_material = options.web_sidebar_material,
195207 .web_sidebar_width = options.web_sidebar_width,
@@ -224,6 +236,7 @@ pub fn main(init: std.process.Init) !void {
224236 .system_tray = options.system_tray,
225237 .dev_tools = effective_dev_tools,
226238 .benchmark = options.benchmark,
239 .headless = options.headless,
227240 .frame_autosave = options.frame_autosave,
228241 .web_sidebar_material = options.web_sidebar_material,
229242 .web_sidebar_width = options.web_sidebar_width,
@@ -417,6 +430,7 @@ fn runWithSystemTray(allocator: std.mem.Allocator, options: cli.WindowOptions) !
417430 .system_tray = options.system_tray,
418431 .dev_tools = options.dev_tools,
419432 .frame_autosave = options.frame_autosave,
433 .headless = options.headless,
420434 },
421435 );
422436 } else if (options.html) |html| {
@@ -440,6 +454,7 @@ fn runWithSystemTray(allocator: std.mem.Allocator, options: cli.WindowOptions) !
440454 .system_tray = options.system_tray,
441455 .dev_tools = options.dev_tools,
442456 .frame_autosave = options.frame_autosave,
457 .headless = options.headless,
443458 },
444459 );
445460 }
@@ -461,7 +476,8 @@ fn runWithSystemTray(allocator: std.mem.Allocator, options: cli.WindowOptions) !
461476 // Using orderFront (in showWindows) prevents app activation which would hide the tray
462477 // IMPORTANT: We must show windows even in menubar-only mode to trigger WebView loading
463478 // Without this, the WebView won't load its HTML/JavaScript content
464 app.showWindows();
479 // The tray path shows its windows itself, after the status item exists.
480 if (!options.headless) app.showWindows();
465481
466482 // Benchmark mode measures time-to-ready and exits. The windowed paths
467483 // already honour it; the tray path did not, so `--benchmark` on a menubar
packages/zig/test/cli_test.zigmodified+58-0
Changes to packages/zig/test/cli_test.zig
@@ -312,3 +312,61 @@ test "parseArgs - --frame-autosave leaves the explicit geometry alone" {
312312test "parseArgs - --frame-autosave without a value is an error, not a silent skip" {
313313 try testing.expectError(cli.CliError.MissingValue, parse(&.{ "craft", "--frame-autosave" }));
314314}
315
316test "parseArgs - --headless is off unless asked for" {
317 var options = try parse(&.{ "craft", "http://localhost:3000" });
318 defer freeOptions(&options);
319 try testing.expect(!options.headless);
320}
321
322test "parseArgs - --headless" {
323 var options = try parse(&.{ "craft", "--headless" });
324 defer freeOptions(&options);
325 try testing.expect(options.headless);
326}
327
328test "parseArgs - --headless leaves the window's own options alone" {
329 // Headless is about whether the window is shown, not about what it is.
330 // A headless run has to build the same window a visible one would, or a
331 // screenshot of it is not evidence about the visible case.
332 var options = try parse(&.{ "craft", "--headless", "--width", "900", "--height", "700", "--title", "T" });
333 defer freeOptions(&options);
334 try testing.expect(options.headless);
335 try testing.expectEqual(@as(u32, 900), options.width);
336 try testing.expectEqual(@as(u32, 700), options.height);
337 try testing.expectEqualStrings("T", options.title);
338}
339
340test "parseArgs - DevTools can be turned on" {
341 // The regression this exists for: `dev_tools` defaults to false and the
342 // only flag that existed set it false again, so the inspector was
343 // unreachable from the command line entirely.
344 var options = try parse(&.{ "craft", "--dev-tools" });
345 defer freeOptions(&options);
346 try testing.expect(options.dev_tools);
347
348 var alias = try parse(&.{ "craft", "--devtools" });
349 defer freeOptions(&alias);
350 try testing.expect(alias.dev_tools);
351}
352
353test "parseArgs - DevTools stay off by default, and --no-devtools still wins" {
354 var off = try parse(&.{"craft"});
355 defer freeOptions(&off);
356 try testing.expect(!off.dev_tools);
357
358 // Last flag wins, in both orders.
359 var on_then_off = try parse(&.{ "craft", "--dev-tools", "--no-devtools" });
360 defer freeOptions(&on_then_off);
361 try testing.expect(!on_then_off.dev_tools);
362
363 var off_then_on = try parse(&.{ "craft", "--no-devtools", "--dev-tools" });
364 defer freeOptions(&off_then_on);
365 try testing.expect(off_then_on.dev_tools);
366}
367
368test "parseArgs - a manifest can ask for headless" {
369 var options = try parse(&.{ "craft", "--headless" });
370 defer freeOptions(&options);
371 try testing.expect(options.headless);
372}