Closes #63.
Craft never installed an NSApplicationDelegate. Both halves of that show up as the same dead end: closing the last window left a process running with no window, no Dock response and no way back — force-quit was the only exit — and clicking the Dock icon did nothing, because no reopen path existed.
The blocker the issue does not mention
Reopen could not simply be implemented. NSWindow.releasedWhenClosed defaults to YES for a window built with initWithContentRect:, so closing it deallocated it: nothing left to show, and [NSApp windows] no longer listing it. Reopening a released window is a use-after-free, not a missing feature.
Turning it off is what makes the rest possible — and it is worth more than it costs. Measured: the window, its WKWebView, the DOM and the JavaScript context all survive a close. A value assigned to window before closing was still there after reopening, so reopen restores the app as the user left it rather than reloading it.
The default is not one answer
YES is right for an ordinary windowed app and wrong for the apps that exist precisely to outlive their window. So the rule is derived from the shape of the app, in one tested place (lifecycle_policy.zig):
| shape | quits on last window close |
|---|---|
| windowed | yes |
| tray / menubar-only | no |
--keep-running / keepRunning: true | no |
--quit-on-close / keepRunning: false | yes |
The setting is tri-state and has to be. Unset means craft decides; false is a tray app saying it does want to go away with its window — which is the case most likely to reach for the flag at all. Collapsing unset and false would make the setting unable to express it. There is an SDK test for exactly that confusion, because if (x) and if (x !== undefined) are both wrong here and both easy to write.
Install point
The delegate goes in on both startup paths, and on the tray path before finishLaunching — a delegate installed after launch has already missed the callbacks that only fire during it. That ordering is what #65's notification work needs, which is why it is here now rather than at the first call site that wants it.
Reopen orders only craft's own windows front: [NSApp windows] includes the offscreen windows AppKit keeps for menus and tooltips, and ordering one of those front puts an empty frame on screen.
Verified against a build of main, side by side
main | this branch | |
|---|---|---|
| close last window | process stranded | exits |
close, then real kAEReopenApplication | nothing (0 focus events) | window returns |
The reopen was driven with an actual Apple Event sent to the pid — the same kAEReopenApplication the Dock sends — not a simulation. The window came back reporting FOCUSED-1-ALIVE-BEFORE-CLOSE, which is the marker the page set before closing: proof of both the reopen path and the surviving JS context in one observation.
All four policy combinations were run end to end:
windowed (default) -> exited
windowed --keep-running -> still running
tray (default) -> still running
tray --quit-on-close -> exitedNot reached, and stated rather than hidden
--headless. AppKit counts visible windows, and a headless window was never on screen, so closing it does not trigger the check. Unchanged from before; a headless run is ended by whatever is driving it.
A dead test file, revived
src/cli.zig was only ever built as a module, never as a test root — so the manifest tests already living in it had no artifact to run in and had never run once. They do now, alongside the new flag tests. Confirmed the usual way: a deliberately broken assertion fails the build, and passes again when restored.
Verified
zig build · zig build test · zig build test-js · x86_64-windows cross-build · zig fmt --check · tsc --noEmit · 498 bun tests (was 493) · pickier 38 warnings, unchanged from main.