also looking at this
feat(cli): --headless, and make DevTools reachable at all
#61
9 files
+340
-6
| @@ -10,6 +10,7 @@ The Craft JavaScript Bridge provides a seamless interface for your web applicati | ||
| 10 | 10 | - [Application Menu (macOS)](#application-menu-macos) |
| 11 | 11 | - [Global Shortcuts (macOS)](#global-shortcuts-macos) |
| 12 | 12 | - [Settings and Preferences (macOS)](#settings-and-preferences-macos) |
| 13 | - [Headless mode](#headless-mode) | |
| 13 | 14 | - [Window API](#window-api) |
| 14 | 15 | - [App API](#app-api) |
| 15 | 16 | - [TypeScript Support](#typescript-support) |
| @@ -464,6 +465,61 @@ const { domain, readCommand } = await craft.prefs.info() | ||
| 464 | 465 | craft namespaces its keys, so `clear()` and `keys()` cannot disturb the window |
| 465 | 466 | positions and inspector state AppKit and WebKit keep in the same domain. |
| 466 | 467 | |
| 468 | ## Headless mode | |
| 469 | ||
| 470 | `craft --headless` builds the window and never puts it on screen. The page | |
| 471 | loads, JavaScript runs, and the webview stays capturable — a snapshot taken | |
| 472 | after script has mutated the DOM reflects the mutation. | |
| 473 | ||
| 474 | ```bash | |
| 475 | craft http://localhost:3000 --headless --timing | |
| 476 | ``` | |
| 477 | ||
| 478 | Also `headless: true` in `craft.json`, and `window.headless` on `AppConfig`. | |
| 479 | ||
| 480 | The motivating case is measurement: a perf loop that launches craft repeatedly | |
| 481 | no longer flashes a window at the operator each time, which is worth doing on | |
| 482 | its 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 | |
| 488 | roughly 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 | ||
| 495 | Drive the page with explicit calls, and take readiness from load completion | |
| 496 | rather than from a polling loop inside the page. No invisible window | |
| 497 | configuration avoids this: what drives the compositor is the window being | |
| 498 | *unoccluded*, which needs activation, which means stealing focus and showing a | |
| 499 | Dock 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 | |
| 502 | window 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 | |
| 507 | Safari's **Develop** menu. | |
| 508 | ||
| 509 | This flag is new because there was previously no way to turn DevTools *on*: | |
| 510 | the setting defaulted to off and the only flag, `--no-devtools`, set it off | |
| 511 | again. And even when it was enabled through `craft.json`, the inspector did not | |
| 512 | work on macOS 13.3 or later, which introduced `isInspectable` defaulting to | |
| 513 | `NO` — craft set only the older `developerExtrasEnabled`, which stopped being | |
| 514 | sufficient. | |
| 515 | ||
| 516 | It is a **debugging affordance, not automation**. It opens no port and prints | |
| 517 | no endpoint, because there is nothing to print: WebKit on macOS has no | |
| 518 | socket-based inspector server — an inspectable `WKWebView` holds no internet | |
| 519 | sockets at all — and `safaridriver` refuses to attach to anything that is not | |
| 520 | Safari. Driving a craft page from another process needs a control channel craft | |
| 521 | owns, which does not exist yet. | |
| 522 | ||
| 467 | 523 | ## Window API |
| 468 | 524 | |
| 469 | 525 | Control the application window from JavaScript. |
| @@ -697,6 +753,7 @@ async function onComplete() { | ||
| 697 | 753 | | Tray Menu | �� | �� | �� | |
| 698 | 754 | | Window Control | ✅ | ✅ | ✅ | |
| 699 | 755 | | Hide Dock Icon | ✅ | ➖ | ➖ | |
| 756 | | Headless | ✅ | �� | �� | | |
| 700 | 757 | | Global Shortcuts | ✅ | �� | �� | |
| 701 | 758 | | Settings… item | ✅ | �� | �� | |
| 702 | 759 | | Preferences | ✅ | �� | �� | |
| @@ -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 | ||
| 9 | import type { AppConfig } from '../types' | |
| 10 | import { describe, expect, it } from 'bun:test' | |
| 11 | ||
| 12 | async 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 | ||
| 18 | describe('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 | ||
| 38 | describe('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 | }) | |
| @@ -412,8 +412,13 @@ export class CraftApp { | ||
| 412 | 412 | args.push('--dark') |
| 413 | 413 | if (window?.hotReload) |
| 414 | 414 | 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. | |
| 415 | 418 | if (window?.devTools === false) |
| 416 | 419 | args.push('--no-devtools') |
| 420 | else if (window?.devTools === true) | |
| 421 | args.push('--dev-tools') | |
| 417 | 422 | if (window?.systemTray) |
| 418 | 423 | args.push('--system-tray') |
| 419 | 424 | if (window?.hideDockIcon) |
| @@ -422,6 +427,8 @@ export class CraftApp { | ||
| 422 | 427 | args.push('--menubar-only') |
| 423 | 428 | if (window?.titlebarHidden) |
| 424 | 429 | args.push('--titlebar-hidden') |
| 430 | if (window?.headless) | |
| 431 | args.push('--headless') | |
| 425 | 432 | if (window?.webSidebarMaterial) { |
| 426 | 433 | args.push('--web-sidebar-material') |
| 427 | 434 | if (window?.webSidebarWidth) |
| @@ -57,6 +57,25 @@ export interface WindowOptions { | ||
| 57 | 57 | */ |
| 58 | 58 | title?: string |
| 59 | 59 | |
| 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 | ||
| 60 | 79 | /** |
| 61 | 80 | * Remember this window's size and position across launches, under this name |
| 62 | 81 | * (macOS). |
| @@ -40,6 +40,10 @@ pub const WindowOptions = struct { | ||
| 40 | 40 | // process. NSImage decodes everything Cocoa can render, so any common |
| 41 | 41 | // raster format works; .icns is preferred for crispness across sizes. |
| 42 | 42 | 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, | |
| 43 | 47 | // The name AppKit shows for the app: the App menu title and the |
| 44 | 48 | // "About X" / "Hide X" / "Quit X" items. Without it those read |
| 45 | 49 | // `NSProcessInfo.processName`, which for a bare binary is the executable |
| @@ -160,6 +164,7 @@ const BundleConfig = struct { | ||
| 160 | 164 | hideDockIcon: ?bool = null, |
| 161 | 165 | menubarOnly: ?bool = null, |
| 162 | 166 | titlebarHidden: ?bool = null, |
| 167 | headless: ?bool = null, | |
| 163 | 168 | }; |
| 164 | 169 | |
| 165 | 170 | /// Absolute path of the running executable's directory. |
| @@ -258,6 +263,7 @@ fn applyScalarConfig(cfg: BundleConfig, options: *WindowOptions) void { | ||
| 258 | 263 | if (cfg.systemTray) |v| options.system_tray = v; |
| 259 | 264 | if (cfg.hideDockIcon) |v| options.hide_dock_icon = v; |
| 260 | 265 | if (cfg.titlebarHidden) |v| options.titlebar_hidden = v; |
| 266 | if (cfg.headless) |v| options.headless = v; | |
| 261 | 267 | // A menubar app with a dock icon and no tray is not a menubar app, so the |
| 262 | 268 | // one flag sets all three rather than making every manifest repeat them. |
| 263 | 269 | if (cfg.menubarOnly) |v| { |
| @@ -344,6 +350,22 @@ test "a manifest can ask for its window frame to be remembered" { | ||
| 344 | 350 | try std.testing.expectEqualStrings("main", parsed.value.frameAutosave.?); |
| 345 | 351 | } |
| 346 | 352 | |
| 353 | test "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 | ||
| 347 | 369 | test "unknown manifest keys are ignored rather than failing the launch" { |
| 348 | 370 | const source = |
| 349 | 371 | \\{"title":"Hush","somethingNewer":true} |
| @@ -446,6 +468,14 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !Wind | ||
| 446 | 468 | options.titlebar_hidden = true; |
| 447 | 469 | } else if (std.mem.eql(u8, arg, "--debug")) { |
| 448 | 470 | // 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; | |
| 449 | 479 | } else if (std.mem.eql(u8, arg, "--no-devtools")) { |
| 450 | 480 | options.dev_tools = false; |
| 451 | 481 | } 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 | ||
| 458 | 488 | options.system_tray = true; |
| 459 | 489 | } else if (std.mem.eql(u8, arg, "--hide-dock-icon")) { |
| 460 | 490 | options.hide_dock_icon = true; |
| 491 | } else if (std.mem.eql(u8, arg, "--headless")) { | |
| 492 | options.headless = true; | |
| 461 | 493 | } else if (std.mem.eql(u8, arg, "--menubar-only")) { |
| 462 | 494 | options.menubar_only = true; |
| 463 | 495 | options.system_tray = true; // Menubar-only implies system tray |
| @@ -560,6 +592,8 @@ fn printHelp() void { | ||
| 560 | 592 | \\ --system-tray Show system tray icon |
| 561 | 593 | \\ --hide-dock-icon Hide dock icon (menubar-only mode, macOS) |
| 562 | 594 | \\ --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 | |
| 563 | 597 | \\ --no-devtools Disable WebKit DevTools |
| 564 | 598 | \\ --titlebar-hidden Hide window titlebar |
| 565 | 599 | \\ --native-sidebar Use native macOS sidebar (Finder-style) |
| @@ -572,6 +606,13 @@ fn printHelp() void { | ||
| 572 | 606 | \\ --sidebar-config <J> Sidebar JSON configuration |
| 573 | 607 | \\ --sidebar-width <W> Sidebar width in pixels (default: 220) |
| 574 | 608 | \\ --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. | |
| 575 | 616 | \\ --app-name <NAME> Name in the menu bar and in About/Hide/Quit |
| 576 | 617 | \\ (macOS; defaults to the executable name) |
| 577 | 618 | \\ --frame-autosave <NAME> |
| @@ -92,6 +92,13 @@ pub const WindowStyle = struct { | ||
| 92 | 92 | web_sidebar_width: u32 = 286, |
| 93 | 93 | web_sidebar_material_opacity: f64 = 0.78, |
| 94 | 94 | 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, | |
| 95 | 102 | /// Name under which AppKit remembers this window's frame across launches. |
| 96 | 103 | /// Null leaves the window forgetting its geometry every time, which is |
| 97 | 104 | /// what craft did until now. |
| @@ -390,6 +397,13 @@ pub fn msgSendFloat(target: anytype, selector: [*:0]const u8) f64 { | ||
| 390 | 397 | } |
| 391 | 398 | |
| 392 | 399 | /// Message send that returns bool |
| 400 | /// `-[NSObject respondsToSelector:]` and friends: a BOOL-returning message | |
| 401 | /// whose one argument is itself a selector. | |
| 402 | pub 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 | ||
| 393 | 407 | pub fn msgSendBool(target: anytype, selector: [*:0]const u8) bool { |
| 394 | 408 | const msg = @as(*const fn (@TypeOf(target), objc.SEL) callconv(.c) bool, @ptrCast(&objc.objc_msgSend)); |
| 395 | 409 | return msg(target, sel(selector)); |
| @@ -552,6 +566,30 @@ fn applyFrameAutosave(window: objc.id, name: []const u8) void { | ||
| 552 | 566 | _ = msgSend1(window, "setFrameAutosaveName:", key); |
| 553 | 567 | } |
| 554 | 568 | |
| 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. | |
| 587 | fn setWebViewInspectable(webview: objc.id) void { | |
| 588 | const selector = sel("setInspectable:"); | |
| 589 | if (!msgSendBool1Sel(webview, "respondsToSelector:", selector)) return; | |
| 590 | msgSendVoid1(webview, "setInspectable:", true); | |
| 591 | } | |
| 592 | ||
| 555 | 593 | pub fn createWindow(title: []const u8, width: u32, height: u32, html: []const u8) !objc.id { |
| 556 | 594 | return createWindowWithStyle(title, width, height, html, null, .{}); |
| 557 | 595 | } |
| @@ -778,6 +816,9 @@ pub fn createWindowWithStyle(title: []const u8, width: u32, height: u32, html: ? | ||
| 778 | 816 | // Create WKWebView with configuration |
| 779 | 817 | const webview_alloc = msgSend0(WKWebView, "alloc"); |
| 780 | 818 | 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); | |
| 781 | 822 | startup_timing.mark(.webview_created); |
| 782 | 823 | // Start the load before any cosmetic setup. |
| 783 | 824 | // |
| @@ -932,8 +973,10 @@ pub fn createWindowWithStyle(title: []const u8, width: u32, height: u32, html: ? | ||
| 932 | 973 | setAppearance(window, is_dark); |
| 933 | 974 | } |
| 934 | 975 | |
| 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)) { | |
| 937 | 980 | msgSendVoid0(window, "center"); |
| 938 | 981 | } |
| 939 | 982 | |
| @@ -6237,8 +6280,10 @@ const edit_menu_items = [_]DefaultItem{ | ||
| 6237 | 6280 | |
| 6238 | 6281 | /// Reload and Force Reload land on the focused WKWebView, which implements |
| 6239 | 6282 | /// `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. | |
| 6242 | 6287 | const view_menu_items = [_]DefaultItem{ |
| 6243 | 6288 | .{ .title = "Reload", .selector = menuRole("reload"), .key = "r" }, |
| 6244 | 6289 | .{ .title = "Force Reload", .selector = menuRole("forceReload"), .key = "r", .extra_modifiers = modifier_shift }, |
| @@ -6442,6 +6487,17 @@ pub fn runApp() void { | ||
| 6442 | 6487 | /// activation policy when launched outside an .app bundle, so the window |
| 6443 | 6488 | /// stays behind other apps with no Dock icon and never becomes key. |
| 6444 | 6489 | /// 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". | |
| 6495 | pub fn setAccessoryActivationPolicy() void { | |
| 6496 | const NSApplication = getClass("NSApplication"); | |
| 6497 | const app = msgSend0(NSApplication, "sharedApplication"); | |
| 6498 | _ = msgSend1(app, "setActivationPolicy:", @as(c_long, 1)); | |
| 6499 | } | |
| 6500 | ||
| 6445 | 6501 | pub fn activateWindowedApp() void { |
| 6446 | 6502 | const NSApplication = getClass("NSApplication"); |
| 6447 | 6503 | const app = msgSend0(NSApplication, "sharedApplication"); |
| @@ -108,6 +108,9 @@ pub const WindowStyle = if (builtin.os.tag == .macos) macos.WindowStyle else str | ||
| 108 | 108 | /// and ignored: remembering a window frame is `setFrameAutosaveName:`, an |
| 109 | 109 | /// AppKit facility with no counterpart wired up on GTK or Win32 yet. |
| 110 | 110 | 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, | |
| 111 | 114 | }; |
| 112 | 115 | |
| 113 | 116 | pub const Window = struct { |
| @@ -182,6 +185,8 @@ pub const App = struct { | ||
| 182 | 185 | system_trays: std.ArrayList(*SystemTray), |
| 183 | 186 | bridge: ?*BridgeAPI = null, |
| 184 | 187 | notifications: ?*Notifications = null, |
| 188 | /// Run without putting anything on screen. See `runMacOS`. | |
| 189 | headless: bool = false, | |
| 185 | 190 | |
| 186 | 191 | const Self = @This(); |
| 187 | 192 | |
| @@ -370,12 +375,31 @@ pub const App = struct { | ||
| 370 | 375 | // Show windows if we don't have a system tray |
| 371 | 376 | // If we have a system tray, windows were already shown in the correct order |
| 372 | 377 | // (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) { | |
| 374 | 394 | macos.showAllWindows(); |
| 375 | 395 | // Windowed app: claim regular activation (Dock icon, key |
| 376 | 396 | // window, foreground) — launching from a bare binary would |
| 377 | 397 | // otherwise leave the window buried with no Dock presence. |
| 378 | 398 | 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(); | |
| 379 | 403 | } |
| 380 | 404 | |
| 381 | 405 | // Now run the event loop |
| @@ -48,6 +48,14 @@ pub fn main(init: std.process.Init) !void { | ||
| 48 | 48 | if (options.app_name) |name| craft.macos.setProcessName(name); |
| 49 | 49 | } |
| 50 | 50 | |
| 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 | ||
| 51 | 59 | // In benchmark mode, disable dev_tools for lower overhead |
| 52 | 60 | const effective_dev_tools = if (options.benchmark) false else options.dev_tools; |
| 53 | 61 | |
| @@ -68,6 +76,7 @@ pub fn main(init: std.process.Init) !void { | ||
| 68 | 76 | } |
| 69 | 77 | |
| 70 | 78 | var app = craft.App.init(allocator); |
| 79 | app.headless = options.headless; | |
| 71 | 80 | defer app.deinit(); |
| 72 | 81 | |
| 73 | 82 | // Initialize platform FIRST (must be called before creating windows or system tray) |
| @@ -112,6 +121,7 @@ pub fn main(init: std.process.Init) !void { | ||
| 112 | 121 | .dev_tools = effective_dev_tools, |
| 113 | 122 | .native_sidebar = true, |
| 114 | 123 | .benchmark = options.benchmark, |
| 124 | .headless = options.headless, | |
| 115 | 125 | .frame_autosave = options.frame_autosave, |
| 116 | 126 | }, |
| 117 | 127 | ); |
| @@ -150,6 +160,7 @@ pub fn main(init: std.process.Init) !void { | ||
| 150 | 160 | .dev_tools = effective_dev_tools, |
| 151 | 161 | .native_sidebar = true, |
| 152 | 162 | .benchmark = options.benchmark, |
| 163 | .headless = options.headless, | |
| 153 | 164 | .frame_autosave = options.frame_autosave, |
| 154 | 165 | }, |
| 155 | 166 | ); |
| @@ -190,6 +201,7 @@ pub fn main(init: std.process.Init) !void { | ||
| 190 | 201 | .system_tray = options.system_tray, |
| 191 | 202 | .dev_tools = effective_dev_tools, |
| 192 | 203 | .benchmark = options.benchmark, |
| 204 | .headless = options.headless, | |
| 193 | 205 | .frame_autosave = options.frame_autosave, |
| 194 | 206 | .web_sidebar_material = options.web_sidebar_material, |
| 195 | 207 | .web_sidebar_width = options.web_sidebar_width, |
| @@ -224,6 +236,7 @@ pub fn main(init: std.process.Init) !void { | ||
| 224 | 236 | .system_tray = options.system_tray, |
| 225 | 237 | .dev_tools = effective_dev_tools, |
| 226 | 238 | .benchmark = options.benchmark, |
| 239 | .headless = options.headless, | |
| 227 | 240 | .frame_autosave = options.frame_autosave, |
| 228 | 241 | .web_sidebar_material = options.web_sidebar_material, |
| 229 | 242 | .web_sidebar_width = options.web_sidebar_width, |
| @@ -417,6 +430,7 @@ fn runWithSystemTray(allocator: std.mem.Allocator, options: cli.WindowOptions) ! | ||
| 417 | 430 | .system_tray = options.system_tray, |
| 418 | 431 | .dev_tools = options.dev_tools, |
| 419 | 432 | .frame_autosave = options.frame_autosave, |
| 433 | .headless = options.headless, | |
| 420 | 434 | }, |
| 421 | 435 | ); |
| 422 | 436 | } else if (options.html) |html| { |
| @@ -440,6 +454,7 @@ fn runWithSystemTray(allocator: std.mem.Allocator, options: cli.WindowOptions) ! | ||
| 440 | 454 | .system_tray = options.system_tray, |
| 441 | 455 | .dev_tools = options.dev_tools, |
| 442 | 456 | .frame_autosave = options.frame_autosave, |
| 457 | .headless = options.headless, | |
| 443 | 458 | }, |
| 444 | 459 | ); |
| 445 | 460 | } |
| @@ -461,7 +476,8 @@ fn runWithSystemTray(allocator: std.mem.Allocator, options: cli.WindowOptions) ! | ||
| 461 | 476 | // Using orderFront (in showWindows) prevents app activation which would hide the tray |
| 462 | 477 | // IMPORTANT: We must show windows even in menubar-only mode to trigger WebView loading |
| 463 | 478 | // 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(); | |
| 465 | 481 | |
| 466 | 482 | // Benchmark mode measures time-to-ready and exits. The windowed paths |
| 467 | 483 | // already honour it; the tray path did not, so `--benchmark` on a menubar |
| @@ -312,3 +312,61 @@ test "parseArgs - --frame-autosave leaves the explicit geometry alone" { | ||
| 312 | 312 | test "parseArgs - --frame-autosave without a value is an error, not a silent skip" { |
| 313 | 313 | try testing.expectError(cli.CliError.MissingValue, parse(&.{ "craft", "--frame-autosave" })); |
| 314 | 314 | } |
| 315 | ||
| 316 | test "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 | ||
| 322 | test "parseArgs - --headless" { | |
| 323 | var options = try parse(&.{ "craft", "--headless" }); | |
| 324 | defer freeOptions(&options); | |
| 325 | try testing.expect(options.headless); | |
| 326 | } | |
| 327 | ||
| 328 | test "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 | ||
| 340 | test "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 | ||
| 353 | test "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 | ||
| 368 | test "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 | } | |