ReviewOS

also looking at this

craft-native/craft

fix(webview): recover from renderer crashes, and stop dropping window.open()

#72
Merged glennmichael123 wants to merge fix/webview-delegates into main
4 files +729 -23
packages/zig/build.zigmodified+25-0
Changes to packages/zig/build.zig
@@ -640,6 +640,27 @@ pub fn build(b: *std.Build) void {
640640 }),
641641 });
642642
643 // Where a link that asks for a new window is allowed to go. Pure policy,
644 // so the whole drop list is provable without a browser.
645 const external_link_tests = b.addTest(.{
646 .root_module = b.createModule(.{
647 .root_source_file = b.path("src/external_link.zig"),
648 .target = target,
649 .optimize = optimize,
650 }),
651 });
652
653 // The reload budget that brings a window back after WebKit's content
654 // process dies. Pure, and takes its clock as an argument, so a crash loop
655 // is testable without crashing anything.
656 const webview_recovery_tests = b.addTest(.{
657 .root_module = b.createModule(.{
658 .root_source_file = b.path("src/webview_recovery.zig"),
659 .target = target,
660 .optimize = optimize,
661 }),
662 });
663
643664 // The conformance test: what craft declares, against what it dispatches.
644665 const capability_conformance_tests = b.addTest(.{
645666 .root_module = b.createModule(.{
@@ -1010,6 +1031,8 @@ pub fn build(b: *std.Build) void {
10101031 const run_local_tls_tests = b.addRunArtifact(local_tls_tests);
10111032 const run_menu_roles_tests = b.addRunArtifact(menu_roles_tests);
10121033 const run_capabilities_tests = b.addRunArtifact(capabilities_tests);
1034 const run_external_link_tests = b.addRunArtifact(external_link_tests);
1035 const run_webview_recovery_tests = b.addRunArtifact(webview_recovery_tests);
10131036 const run_prefs_tests = b.addRunArtifact(prefs_tests);
10141037 const run_prefs_macos_tests = b.addRunArtifact(prefs_macos_tests);
10151038 const run_key_codes_tests = b.addRunArtifact(key_codes_tests);
@@ -1123,6 +1146,8 @@ pub fn build(b: *std.Build) void {
11231146 test_step.dependOn(&run_local_tls_tests.step);
11241147 test_step.dependOn(&run_menu_roles_tests.step);
11251148 test_step.dependOn(&run_capabilities_tests.step);
1149 test_step.dependOn(&run_external_link_tests.step);
1150 test_step.dependOn(&run_webview_recovery_tests.step);
11261151 // macOS only: the registry describes the dispatch chain in macos.zig, and
11271152 // compiling it elsewhere drags the whole native graph into a test binary
11281153 // for a platform it does not describe.
packages/zig/src/external_link.zigadded+146-0
Changes to packages/zig/src/external_link.zig
@@ -0,0 +1,146 @@
1//! Where a link that wants a new window is allowed to go.
2//!
3//! `window.open()` and `target="_blank"` ask WKWebView for a second webview.
4//! Craft has nowhere to put one, so the request is answered by handing the URL
5//! to the system browser and creating nothing — the convention Electron
6//! settled on, and what a person clicking a link expects.
7//!
8//! Which URLs, though, is a security question rather than a routing one. The
9//! page asking is not necessarily trusted: an app that renders a chat
10//! transcript, a feed, or any third-party HTML is asking on behalf of whoever
11//! wrote that content. `NSWorkspace openURL:` is not a browser call — it is
12//! "ask LaunchServices to do whatever this URL means", and what it means is
13//! decided by whatever is installed on the machine.
14//!
15//! So this allows exactly `http` and `https` and drops everything else:
16//!
17//! - `file://` would hand LaunchServices a local path, which for a `.app`,
18//! a `.command`, or anything with a registered handler is a launch. The
19//! `shell.openExternal` bridge does allow `file://`, and should — the app
20//! itself is calling it, on a path it chose. This is a page calling it, on
21//! a URL nobody vetted, and the two do not deserve the same list.
22//! - Custom schemes reach any registered app: `x-apple.systempreferences:`
23//! opens System Settings panes, and every installed app that claims a
24//! scheme is one `window.open` away.
25//! - `javascript:` and `data:` are not the browser's to open at all.
26//!
27//! An app that genuinely wants a wider policy has `shell.openExternal`, where
28//! its own code decides. That is the difference this file is drawing.
29
30const std = ;
31
32pub const Decision = enum {
33 /// Hand it to the system browser and create no webview.
34 open_externally,
35 /// Create nothing and go nowhere. Logged, never silent.
36 drop,
37};
38
39/// The schemes a page may send to the system browser, lower-case and with the
40/// colon, so a prefix match cannot accept `https-evil:`.
41const allowed = [_][]const u8{ "http:", "https:" };
42
43/// What to do with a URL a page asked to open in a new window.
44///
45/// Nothing here parses the URL beyond its scheme: the scheme is the whole
46/// decision, and a parser that tried to do more would be a second place for
47/// the two sides to disagree about what a URL is.
48pub fn decide(url: []const u8) Decision {
49 const scheme_end = std.mem.indexOfScalar(u8, url, ':') orelse return .drop;
50 const scheme = url[0 .. scheme_end + 1];
51
52 // A URL is not required to be short, but a scheme is: RFC 3986 gives no
53 // limit, yet nothing legitimate is close, and this bounds the buffer.
54 if (scheme.len > 16) return .drop;
55
56 var lowered: [16]u8 = undefined;
57 for (scheme, 0..) |c, i| lowered[i] = std.ascii.toLower(c);
58
59 for (allowed) |ok| {
60 if (std.mem.eql(u8, lowered[0..scheme.len], ok)) return .open_externally;
61 }
62 return .drop;
63}
64
65const testing = std.testing;
66
67test "ordinary web links go to the browser" {
68 try testing.expectEqual(Decision.open_externally, decide("https://example.com/a"));
69 try testing.expectEqual(Decision.open_externally, decide("http://example.com"));
70 try testing.expectEqual(Decision.open_externally, decide("https://example.com:8443/a?b=c#d"));
71}
72
73test "the scheme is matched without regard to case" {
74 // `Https://` is a perfectly ordinary link, and a case-sensitive check is
75 // also a bypass: the drop list would be trivially escaped by shifting one
76 // letter.
77 try testing.expectEqual(Decision.open_externally, decide("HTTPS://example.com"));
78 try testing.expectEqual(Decision.open_externally, decide("HtTp://example.com"));
79}
80
81test "a file URL from a page is not opened" {
82 // `shell.openExternal` allows these because the app is calling it about a
83 // path it chose. A page is not the app.
84 try testing.expectEqual(Decision.drop, decide("file:///Applications/Calculator.app"));
85 try testing.expectEqual(Decision.drop, decide("FILE:///etc/passwd"));
86}
87
88test "script and inline-data URLs are not the browser's to open" {
89 try testing.expectEqual(Decision.drop, decide("javascript:alert(1)"));
90 try testing.expectEqual(Decision.drop, decide("JavaScript:alert(1)"));
91 try testing.expectEqual(Decision.drop, decide("data:text/html,<script>alert(1)</script>"));
92}
93
94test "custom schemes cannot reach installed apps" {
95 // Each of these is a real handler on a stock or common macOS install.
96 for ([_][]const u8{
97 "x-apple.systempreferences:com.apple.preference.security",
98 "ftp://example.com",
99 "smb://server/share",
100 "vnc://host",
101 "ssh://host",
102 "itms-apps://apps.apple.com/app/id1",
103 "zoommtg://zoom.us/join?confno=1",
104 "slack://open",
105 "craft://whatever",
106 "mailto:someone@example.com",
107 "tel:+15551234",
108 }) |url| {
109 try testing.expectEqual(Decision.drop, decide(url));
110 }
111}
112
113test "a scheme that merely starts with an allowed one is not allowed" {
114 // The colon is part of the match for this reason.
115 try testing.expectEqual(Decision.drop, decide("https-evil://example.com"));
116 try testing.expectEqual(Decision.drop, decide("httpx://example.com"));
117 try testing.expectEqual(Decision.drop, decide("httpsx:"));
118}
119
120test "something with no scheme at all goes nowhere" {
121 try testing.expectEqual(Decision.drop, decide(""));
122 try testing.expectEqual(Decision.drop, decide("example.com"));
123 try testing.expectEqual(Decision.drop, decide("//example.com"));
124 try testing.expectEqual(Decision.drop, decide("/relative/path"));
125}
126
127test "a leading space does not smuggle a scheme past the check" {
128 // WKWebView hands over an NSURL that has already been parsed, so this
129 // should not arise — but the check must not be the reason it is safe.
130 try testing.expectEqual(Decision.drop, decide(" https://example.com"));
131 try testing.expectEqual(Decision.drop, decide("\thttps://example.com"));
132 try testing.expectEqual(Decision.drop, decide("\nhttps://example.com"));
133}
134
135test "an absurdly long scheme is refused rather than truncated" {
136 var buf: [64]u8 = undefined;
137 @memset(&buf, 'a');
138 buf[63] = ':';
139 try testing.expectEqual(Decision.drop, decide(&buf));
140}
141
142test "a bare scheme with nothing after it is still a web link" {
143 // Degenerate, and NSWorkspace will do nothing useful with it, but it is
144 // not this file's job to invent a second opinion about URL syntax.
145 try testing.expectEqual(Decision.open_externally, decide("https:"));
146}
packages/zig/src/macos.zigmodified+269-23
Changes to packages/zig/src/macos.zig
@@ -4,6 +4,8 @@ const javascript = ;
44const native_sidebar_bootstrap = ;
55const local_tls = ;
66const menu_roles = ;
7const external_link = ;
8const webview_recovery = ;
79
810// Objective-C runtime types and functions (manual declarations to avoid @cImport issues)
911pub const objc = struct {
@@ -843,6 +845,7 @@ pub fn createWindowWithStyle(title: []const u8, width: u32, height: u32, html: ?
843845 const nsurl = msgSend1(getClass("NSURL"), "URLWithString:", url_str);
844846 const request = msgSend1(getClass("NSURLRequest"), "requestWithURL:", nsurl);
845847 _ = msgSend1(webview, "loadRequest:", request);
848 rememberContent(webview, .{ .request = request });
846849 startup_timing.mark(.load_started);
847850 } else if (html) |h| {
848851 if (style.benchmark) {
@@ -866,6 +869,7 @@ pub fn createWindowWithStyle(title: []const u8, width: u32, height: u32, html: ?
866869 const base_url = msgSend1(getClass("NSURL"), "URLWithString:", base_url_string);
867870
868871 _ = msgSend2(webview, "loadHTMLString:baseURL:", html_str, base_url);
872 rememberContent(webview, .{ .html = .{ .string = html_str, .base_url = base_url } });
869873 }
870874 }
871875
@@ -882,21 +886,34 @@ pub fn createWindowWithStyle(title: []const u8, width: u32, height: u32, html: ?
882886 .install();
883887 }
884888
885 // Set up UI delegate for camera/microphone permission handling (skip if not needed)
886 if (style.dev_tools) {
889 // Both delegates, in every non-benchmark window.
890 //
891 // The UI delegate used to be installed only with `--dev-tools`, which is
892 // not a condition any of its callbacks have anything to do with: a
893 // packaged app calling `getUserMedia()` got no delegate and so was denied
894 // the camera outright, and `window.open()` had nowhere to be handled. The
895 // navigation delegate used to install only under `CRAFT_ALLOW_LOCAL_TLS`,
896 // which left every shipped window without the callback that notices the
897 // content process has died.
898 //
899 // Skipped in benchmark mode alongside the file-drop hook and the bridge,
900 // so the startup numbers keep measuring the same window they always did.
901 //
902 // After the load, not before, and that is safe: `loadRequest:` only
903 // schedules the fetch, and every delegate callback arrives through the run
904 // loop, which is not entered until much later. (The note that used to be
905 // here said "before the load below" — the load moved above it.)
906 if (!style.benchmark) {
887907 setupUIDelegate(webview) catch |err| {
888908 if (comptime builtin.mode == .debug)
889909 std.debug.print("[Media] Failed to setup UI delegate: {}\n", .{err});
890910 };
891 }
892911
893 // Not gated on dev_tools: whether the inspector is available and whether a
894 // local certificate is trusted are unrelated questions. Before the load
895 // below, so it is in place for the first request's challenge.
896 setupNavigationDelegate(webview) catch |err| {
897 if (comptime builtin.mode == .debug)
898 std.debug.print("[Nav] Failed to setup navigation delegate: {}\n", .{err});
899 };
912 setupNavigationDelegate(webview) catch |err| {
913 if (comptime builtin.mode == .debug)
914 std.debug.print("[Nav] Failed to setup navigation delegate: {}\n", .{err});
915 };
916 }
900917
901918 if (style.benchmark) {
902919 // Benchmark mode: minimal setup — just set content view and return
@@ -5153,7 +5170,69 @@ fn handleMediaCapturePermission(
51535170 }
51545171}
51555172
5156/// Set up WKUIDelegate to handle media capture permission requests
5173/// `webView:createWebViewWithConfiguration:forNavigationAction:windowFeatures:`
5174///
5175/// WKWebView asks for this whenever the page wants a second window —
5176/// `window.open()`, `target="_blank"`, `<form target>`. If the delegate does
5177/// not implement it, WebKit's answer is to do nothing at all: `window.open()`
5178/// returns null and the link dies with no error, no console message and no
5179/// log. Every link in a rendered transcript or feed simply did nothing.
5180///
5181/// Craft has no second window to give (that is #67), so the answer is the one
5182/// Electron settled on: hand the URL to the system browser and return nil, so
5183/// no child webview is created.
5184///
5185/// Which URLs get handed over is decided by `external_link.zig`, and it is
5186/// deliberately narrower than `shell.openExternal`: this is a *page* asking,
5187/// possibly on behalf of content nobody vetted, and `NSWorkspace openURL:`
5188/// means "ask LaunchServices to do whatever this URL means".
5189fn handleCreateWebView(
5190 _: objc.id,
5191 _: objc.SEL,
5192 _: objc.id,
5193 _: objc.id,
5194 navigationAction: objc.id,
5195 _: objc.id,
5196) callconv(.c) objc.id {
5197 // Returning nil is the point of the method, so every path out returns nil.
5198 const request = msgSend0(navigationAction, "request");
5199 if (request == null) return null;
5200 const nsurl = msgSend0(request, "URL");
5201 if (nsurl == null) return null;
5202
5203 const absolute = msgSend0(nsurl, "absoluteString");
5204 if (absolute == null) return null;
5205 const raw = msgSend0(absolute, "UTF8String") orelse return null;
5206 const cstr: [*:0]const u8 = @ptrCast(@alignCast(raw));
5207 const url = std.mem.span(cstr);
5208
5209 switch (external_link.decide(url)) {
5210 .open_externally => {
5211 const NSWorkspace = getClass("NSWorkspace");
5212 const workspace = msgSend0(NSWorkspace, "sharedWorkspace");
5213 _ = msgSend1(workspace, "openURL:", nsurl);
5214 },
5215 // Logged, never silent: a dropped link that says nothing is the bug
5216 // this method exists to fix, and refusing one quietly is the same
5217 // experience by a different route.
5218 .drop => std.log.warn(
5219 "refused to open a new window for a URL the page cannot send to the browser: {s}",
5220 .{url},
5221 ),
5222 }
5223
5224 return null;
5225}
5226
5227/// Attach craft's `WKUIDelegate`.
5228///
5229/// Two callbacks, neither of which has anything to do with the other:
5230/// `getUserMedia()` permission, and the page asking for a second window.
5231///
5232/// Installed in every non-benchmark window. It used to be installed only under
5233/// `--dev-tools`, which meant a packaged app was denied the camera outright
5234/// and had `window.open()` silently dropped — neither of which is a debugging
5235/// concern.
51575236pub fn setupUIDelegate(webview: objc.id) !void {
51585237 if (comptime builtin.mode == .debug)
51595238 std.debug.print("[Media] Setting up WKUIDelegate for media permissions...\n", .{});
@@ -5197,6 +5276,19 @@ pub fn setupUIDelegate(webview: objc.id) !void {
51975276 }
51985277 }
51995278
5279 // `window.open()` / `target="_blank"`. Returns an object (the child
5280 // webview, or nil): @ = id return, self, _cmd, webView, configuration,
5281 // navigationAction, windowFeatures.
5282 if (!objc.class_addMethod(
5283 @ptrCast(@alignCast(delegateClass)),
5284 objc.sel_registerName("webView:createWebViewWithConfiguration:forNavigationAction:windowFeatures:"),
5285 @as(objc.IMP, @ptrCast(@constCast(&handleCreateWebView))),
5286 "@@:@@@@",
5287 )) {
5288 if (comptime builtin.mode == .debug)
5289 std.debug.print("[UI] Failed to add createWebView method\n", .{});
5290 }
5291
52005292 // Register the class
52015293 objc.objc_registerClassPair(@ptrCast(delegateClass));
52025294 if (comptime builtin.mode == .debug)
@@ -5289,6 +5381,14 @@ fn handleAuthChallenge(
52895381 challenge: objc.id,
52905382 completionHandler: objc.id,
52915383) callconv(.c) void {
5384 // The opt-in used to be enforced by not installing the delegate at all.
5385 // The delegate is installed in every window now — it is what notices the
5386 // content process has died — so the check has to live here instead.
5387 // Without this line, ungating the delegate would silently start trusting
5388 // local development certificates in every craft app ever shipped.
5389 if (!allowsLocalDevTLS())
5390 return finishAuthChallenge(completionHandler, .perform_default_handling, null);
5391
52925392 const protectionSpace = msgSend0(challenge, "protectionSpace");
52935393 if (protectionSpace == null)
52945394 return finishAuthChallenge(completionHandler, .perform_default_handling, null);
@@ -5309,19 +5409,154 @@ fn handleAuthChallenge(
53095409 finishAuthChallenge(completionHandler, .use_credential, credential);
53105410}
53115411
5312/// Attach a `WKNavigationDelegate` that trusts local development certificates.
5412// =============================================================================
5413// Recovering a window after the WebKit content process dies
5414// =============================================================================
5415
5416/// What craft put in a webview, kept so it can be put back.
53135417///
5314/// A no-op unless the process opted in: without `CRAFT_ALLOW_LOCAL_TLS` craft
5315/// installs no navigation delegate at all, which is exactly the behaviour it
5316/// has always had. Implementing only the auth-challenge method leaves every
5317/// other navigation decision — policy, redirects, provisional failures — at its
5318/// AppKit default.
5418/// `-reload` is Apple's documented recovery and it is wrong for half of
5419/// craft's windows. HTML loaded through `loadHTMLString:baseURL:` has no
5420/// request to repeat: reloading navigates to the synthetic base URL,
5421/// `http://localhost/`, where there is usually nothing listening — so a window
5422/// started from `--html` would come back as a connection error instead of the
5423/// app it was showing. Re-applying what was loaded is right for both paths.
5424const LoadedContent = union(enum) {
5425 none,
5426 request: objc.id,
5427 html: struct { string: objc.id, base_url: objc.id },
5428};
5429
5430const ContentSlot = struct {
5431 key: usize = 0,
5432 content: LoadedContent = .none,
5433};
5434
5435/// Sized to match `webview_recovery`'s budget table: the two are keyed the
5436/// same way and a window in one should be in the other.
5437var content_slots: [8]ContentSlot = @splat(.{});
5438
5439/// Hold on to what a webview was given, retaining it for as long as the window
5440/// might need it back.
53195441///
5320/// Call before loading, so the delegate is in place for the first request's
5321/// challenge.
5322pub fn setupNavigationDelegate(webview: objc.id) !void {
5323 if (!allowsLocalDevTLS()) return;
5442/// `requestWithURL:` and `URLWithString:` both hand back autoreleased objects,
5443/// so without the retain these are dangling by the time the run loop next
5444/// drains — which is long before any crash. The matching release would be at
5445/// window teardown; craft never tears one down today, and one request or one
5446/// HTML string per window is not a leak worth the risk of releasing early.
5447fn rememberContent(webview: objc.id, content: LoadedContent) void {
5448 switch (content) {
5449 .none => {},
5450 .request => |r| _ = msgSend0(r, "retain"),
5451 .html => |h| {
5452 _ = msgSend0(h.string, "retain");
5453 if (h.base_url != null) _ = msgSend0(h.base_url, "retain");
5454 },
5455 }
5456
5457 const key = @intFromPtr(webview);
5458 // Existing entry first, then a free one. Taking whichever comes first would
5459 // leave a stale duplicate behind for a webview that loads twice.
5460 for (&content_slots) |*slot| {
5461 if (slot.key == key) {
5462 slot.content = content;
5463 return;
5464 }
5465 }
5466 for (&content_slots) |*slot| {
5467 if (slot.key == 0) {
5468 slot.* = .{ .key = key, .content = content };
5469 return;
5470 }
5471 }
5472 // Full. Slot zero holds the earliest window registered, which is the one
5473 // least likely to still be around.
5474 content_slots[0] = .{ .key = key, .content = content };
5475}
5476
5477fn contentFor(webview: objc.id) LoadedContent {
5478 const key = @intFromPtr(webview);
5479 for (&content_slots) |slot| {
5480 if (slot.key == key) return slot.content;
5481 }
5482 return .none;
5483}
5484
5485/// Put the window's content back after the content process was replaced.
5486fn restoreContent(webview: objc.id) void {
5487 switch (contentFor(webview)) {
5488 .request => |r| _ = msgSend1(webview, "loadRequest:", r),
5489 .html => |h| _ = msgSend2(webview, "loadHTMLString:baseURL:", h.string, h.base_url),
5490 // Nothing recorded — a webview craft did not load. `reload` is the
5491 // best available guess and does no harm if there is nothing to reload.
5492 .none => _ = msgSend0(webview, "reload"),
5493 }
5494}
5495
5496/// `webViewWebContentProcessDidTerminate:`
5497///
5498/// WKWebView renders in a separate process, and when that process is killed
5499/// the view does not error or close — it goes white and stays white. This is
5500/// the only notification of it. Without this method craft had no route back
5501/// except the Reload menu item, which a user has to know to look for, and
5502/// which nothing on the blank screen suggests.
5503///
5504/// Logged at warning level rather than behind a debug check: an app that quietly
5505/// reloaded itself at 3am is something whoever reads the logs should be able to
5506/// find out about.
5507fn handleContentProcessTerminated(_: objc.id, _: objc.SEL, webview: objc.id) callconv(.c) void {
5508 const key = @intFromPtr(webview);
5509 const state = webview_recovery.stateFor(key);
5510 // `compat.nanoTimestamp` is CLOCK_MONOTONIC here, which is what a rate
5511 // budget wants: a clock the user can move is a clock that can hand out
5512 // extra reloads. It returns 0 if the clock cannot be read at all, which
5513 // reads as "no time passed" and keeps the burst intact — the conservative
5514 // direction.
5515 const now = .nanoTimestamp();
5516 const action = webview_recovery.onCrash(state, now, .{});
5517
5518 switch (action) {
5519 .reload => {
5520 std.log.warn(
5521 "WebKit content process terminated; restoring the window (attempt {d})",
5522 .{state.attempts},
5523 );
5524 restoreContent(webview);
5525 },
5526 .give_up => {
5527 std.log.warn(
5528 "WebKit content process terminated {d} times in quick succession; " ++
5529 "leaving the window alone so it stops looping",
5530 .{state.attempts},
5531 );
5532 const NSString = getClass("NSString");
5533 const html = msgSend1(
5534 NSString,
5535 "stringWithUTF8String:",
5536 @as([*:0]const u8, webview_recovery.give_up_page),
5537 );
5538 _ = msgSend2(webview, "loadHTMLString:baseURL:", html, @as(objc.id, null));
5539 },
5540 }
5541}
53245542
5543/// Attach craft's `WKNavigationDelegate`.
5544///
5545/// Two methods, for two unrelated jobs:
5546///
5547/// - `webViewWebContentProcessDidTerminate:` — the only notification that
5548/// WebKit's renderer died. Without it the window is white and stays white.
5549/// Every window needs this.
5550/// - `webView:didReceiveAuthenticationChallenge:completionHandler:` — trusts
5551/// a local development CA, and only when `CRAFT_ALLOW_LOCAL_TLS` says so.
5552/// That opt-in is enforced inside the handler, because the delegate itself
5553/// is no longer optional.
5554///
5555/// It used to install only under `CRAFT_ALLOW_LOCAL_TLS`, which meant every
5556/// shipped app ran without the crash notification. Everything not implemented
5557/// here — navigation policy, redirects, provisional failures — stays at its
5558/// AppKit default, as before.
5559pub fn setupNavigationDelegate(webview: objc.id) !void {
53255560 const superclass = getClass("NSObject");
53265561 const className = "CraftNavigationDelegate";
53275562
@@ -5339,6 +5574,15 @@ pub fn setupNavigationDelegate(webview: objc.id) !void {
53395574 if (!objc.class_addMethod(@ptrCast(@alignCast(delegateClass)), method_sel, method_imp, method_types))
53405575 return error.MethodAdditionFailed;
53415576
5577 // void; self, _cmd, webView
5578 if (!objc.class_addMethod(
5579 @ptrCast(@alignCast(delegateClass)),
5580 objc.sel_registerName("webViewWebContentProcessDidTerminate:"),
5581 @as(objc.IMP, @ptrCast(@constCast(&handleContentProcessTerminated))),
5582 "v@:@",
5583 ))
5584 return error.MethodAdditionFailed;
5585
53425586 objc.objc_registerClassPair(@ptrCast(delegateClass));
53435587 }
53445588
@@ -5350,8 +5594,10 @@ pub fn setupNavigationDelegate(webview: objc.id) !void {
53505594 const delegate = msgSend0(msgSend0(delegate_class_id, "alloc"), "init");
53515595 msgSendVoid1(webview, "setNavigationDelegate:", delegate);
53525596
5353 if (comptime builtin.mode == .debug)
5354 std.debug.print("[Nav] Local development TLS enabled via {s}\n", .{local_tls.env_var});
5597 if (comptime builtin.mode == .debug) {
5598 if (allowsLocalDevTLS())
5599 std.debug.print("[Nav] Local development TLS enabled via {s}\n", .{local_tls.env_var});
5600 }
53555601}
53565602
53575603// ============================================================================
packages/zig/src/webview_recovery.zigadded+289-0
Changes to packages/zig/src/webview_recovery.zig
@@ -0,0 +1,289 @@
1//! Bringing a window back after WebKit's content process dies.
2//!
3//! WKWebView renders in a separate process. When that process is killed — out
4//! of memory, a GPU fault, a WebKit bug — the view does not error, close, or
5//! notify the page. It goes white and stays white, and the only route back is
6//! `reload`, which craft offered exclusively as a menu item the user had to
7//! know to click. For an app meant to sit open for days, a blip became a dead
8//! window.
9//!
10//! Reloading is the whole fix, and reloading unconditionally is the whole
11//! problem: a page that reliably kills the renderer — an OOM loop, a bad
12//! WebGL context — would be reloaded into the same crash forever, pinning a
13//! core. So the reloads are budgeted.
14//!
15//! ## Why the budget is a rate and not a count
16//!
17//! A plain "three tries and stop" is wrong for the case this exists to serve.
18//! An app open for a week that loses its renderer once a day is healthy, and
19//! it would spend its three lives in the first three days and then be a white
20//! window for the rest of the week. What distinguishes a crash loop from bad
21//! luck is not how many crashes there have been but how close together they
22//! are — so a window that has stayed up for `reset_after_ns` gets its budget
23//! back. `attempts` counts a burst, not a lifetime.
24//!
25//! This module is pure: the caller supplies the clock, and nothing here talks
26//! to Objective-C. That is what lets a crash loop be tested without crashing
27//! anything.
28
29const std = ;
30
31pub const Action = enum {
32 /// Reload. The content process relaunches on the next load.
33 reload,
34 /// Stop reloading and show the page that says so. Reloading again would
35 /// just be the same crash.
36 give_up,
37};
38
39pub const Budget = struct {
40 /// Reloads allowed within one burst.
41 max_attempts: u8 = 3,
42 /// Stay up this long and the burst is over. A minute is far longer than a
43 /// crash loop takes to go round and far shorter than the gap between two
44 /// unrelated failures.
45 reset_after_ns: i128 = 60 * std.time.ns_per_s,
46};
47
48pub const State = struct {
49 attempts: u8 = 0,
50 last_crash_ns: ?i128 = null,
51};
52
53/// Record a crash and say what to do about it.
54pub fn onCrash(state: *State, now_ns: i128, budget: Budget) Action {
55 const in_same_burst = if (state.last_crash_ns) |last|
56 now_ns -| last < budget.reset_after_ns
57 else
58 false;
59
60 state.attempts = if (in_same_burst) state.attempts +| 1 else 1;
61 state.last_crash_ns = now_ns;
62
63 return if (state.attempts <= budget.max_attempts) .reload else .give_up;
64}
65
66// =============================================================================
67// Per-window state
68// =============================================================================
69
70/// One budget per webview, so a window in a crash loop cannot spend the budget
71/// of a window that is fine. Craft opens one window today; #67 opens more, and
72/// a single shared counter would be exactly the bug that lands then.
73const max_tracked = 8;
74
75const Slot = struct {
76 /// The webview this belongs to, as an opaque handle. Zero means free.
77 key: usize = 0,
78 state: State = .{},
79};
80
81var slots: [max_tracked]Slot = @splat(.{});
82
83/// The budget for `key`, creating one if this webview has not crashed before.
84///
85/// Full table: the least recently troubled slot is reused. Losing a count is
86/// the right failure — it costs one extra reload attempt for a window that is
87/// probably fine, where refusing to track would cost a window its recovery.
88pub fn stateFor(key: usize) *State {
89 for (&slots) |*slot| {
90 if (slot.key == key) return &slot.state;
91 }
92 for (&slots) |*slot| {
93 if (slot.key == 0) {
94 slot.* = .{ .key = key };
95 return &slot.state;
96 }
97 }
98
99 var oldest: *Slot = &slots[0];
100 for (&slots) |*slot| {
101 const slot_time = slot.state.last_crash_ns orelse std.math.minInt(i128);
102 const oldest_time = oldest.state.last_crash_ns orelse std.math.minInt(i128);
103 if (slot_time < oldest_time) oldest = slot;
104 }
105 oldest.* = .{ .key = key };
106 return &oldest.state;
107}
108
109/// Forget a webview's history. For window teardown, and for tests.
110pub fn forget(key: usize) void {
111 for (&slots) |*slot| {
112 if (slot.key == key) slot.* = .{};
113 }
114}
115
116pub fn resetAllForTesting() void {
117 slots = @splat(.{});
118}
119
120// =============================================================================
121// The page shown once craft stops trying
122// =============================================================================
123
124/// What the window shows after the budget runs out.
125///
126/// The alternative is the white window this whole file exists to prevent —
127/// bounded retries without this just reach the original bug more slowly. It
128/// says what happened and offers the reload craft will no longer do on its
129/// own, which is also how the burst gets broken: a person clicking it is not
130/// a crash loop.
131///
132/// Self-contained by necessity. There is no server, no bundle path, and no
133/// network guarantee at the point this is shown.
134pub const give_up_page =
135 \\<!doctype html><meta charset="utf-8">
136 \\<meta name="viewport" content="width=device-width,initial-scale=1">
137 \\<title>Page stopped responding</title>
138 \\<style>
139 \\:root{color-scheme:light dark}
140 \\body{margin:0;height:100vh;display:flex;align-items:center;justify-content:center;
141 \\background:#fff;color:#1d1d1f;
142 \\font:15px/1.5 -apple-system,BlinkMacSystemFont,"SF Pro Text",system-ui,sans-serif}
143 \\@media (prefers-color-scheme:dark){body{background:#1e1e1e;color:#f5f5f7}}
144 \\main{max-width:26rem;padding:2rem;text-align:center}
145 \\h1{font-size:1.0625rem;font-weight:600;margin:0 0 .5rem}
146 \\p{margin:0 0 1.25rem;opacity:.7}
147 \\button{font:inherit;font-weight:500;padding:.4rem 1.1rem;border-radius:.375rem;
148 \\border:1px solid rgba(0,0,0,.15);background:#fff;color:inherit;cursor:pointer}
149 \\@media (prefers-color-scheme:dark){button{background:#333;border-color:rgba(255,255,255,.15)}}
150 \\</style>
151 \\<main>
152 \\<h1>This page stopped responding</h1>
153 \\<p>It was reloaded a few times and kept failing, so it has been left alone.</p>
154 \\<button onclick="location.reload()">Reload</button>
155 \\</main>
156;
157
158const testing = std.testing;
159const second = std.time.ns_per_s;
160
161test "the first crash reloads" {
162 var state: State = .{};
163 try testing.expectEqual(Action.reload, onCrash(&state, 0, .{}));
164}
165
166test "a crash loop is cut off after the budget" {
167 var state: State = .{};
168 const budget: Budget = .{ .max_attempts = 3 };
169 try testing.expectEqual(Action.reload, onCrash(&state, 0, budget));
170 try testing.expectEqual(Action.reload, onCrash(&state, 1 * second, budget));
171 try testing.expectEqual(Action.reload, onCrash(&state, 2 * second, budget));
172 try testing.expectEqual(Action.give_up, onCrash(&state, 3 * second, budget));
173 try testing.expectEqual(Action.give_up, onCrash(&state, 4 * second, budget));
174}
175
176test "a window that stayed up gets its budget back" {
177 // The case a lifetime counter gets wrong: an app open for a week that
178 // loses its renderer once a day is healthy, and must not be a white window
179 // by Thursday.
180 var state: State = .{};
181 const budget: Budget = .{ .max_attempts = 3, .reset_after_ns = 60 * second };
182
183 var day: i128 = 0;
184 while (day < 7) : (day += 1) {
185 const t = day * 24 * 60 * 60 * second;
186 try testing.expectEqual(Action.reload, onCrash(&state, t, budget));
187 try testing.expectEqual(@as(u8, 1), state.attempts);
188 }
189}
190
191test "the reset boundary is exact" {
192 var state: State = .{};
193 const budget: Budget = .{ .max_attempts = 1, .reset_after_ns = 60 * second };
194
195 try testing.expectEqual(Action.reload, onCrash(&state, 0, budget));
196 // One nanosecond short of the window: still the same burst.
197 try testing.expectEqual(Action.give_up, onCrash(&state, 60 * second - 1, budget));
198 // Exactly the window, measured from the previous crash: a new burst.
199 try testing.expectEqual(Action.reload, onCrash(&state, 2 * 60 * second - 1, budget));
200}
201
202test "a burst is measured between crashes, not from the first one" {
203 // Crashes every 40 seconds never reset a 60-second window, however long it
204 // goes on — that is a slow loop, and it is still a loop.
205 var state: State = .{};
206 const budget: Budget = .{ .max_attempts = 3, .reset_after_ns = 60 * second };
207 try testing.expectEqual(Action.reload, onCrash(&state, 0, budget));
208 try testing.expectEqual(Action.reload, onCrash(&state, 40 * second, budget));
209 try testing.expectEqual(Action.reload, onCrash(&state, 80 * second, budget));
210 try testing.expectEqual(Action.give_up, onCrash(&state, 120 * second, budget));
211}
212
213test "a clock that goes backwards does not hand out extra reloads" {
214 // The caller passes CLOCK_MONOTONIC, so this should not happen on macOS or
215 // Linux — but `compat.nanoTimestamp` falls back to wall-clock on Windows,
216 // where an NTP correction can step backwards, and returns 0 outright if
217 // the clock cannot be read. Saturating subtraction makes all of that read
218 // as "no time passed", which keeps the burst intact rather than handing
219 // out a fresh budget on every backwards step.
220 var state: State = .{};
221 const budget: Budget = .{ .max_attempts = 2, .reset_after_ns = 60 * second };
222 try testing.expectEqual(Action.reload, onCrash(&state, 1000 * second, budget));
223 try testing.expectEqual(Action.reload, onCrash(&state, 500 * second, budget));
224 try testing.expectEqual(Action.give_up, onCrash(&state, 0, budget));
225}
226
227test "the attempt count cannot wrap around into a fresh budget" {
228 var state: State = .{};
229 const budget: Budget = .{ .max_attempts = 3, .reset_after_ns = 60 * second };
230 var i: usize = 0;
231 while (i < 1000) : (i += 1) {
232 _ = onCrash(&state, @as(i128, @intCast(i)), budget);
233 }
234 try testing.expectEqual(@as(u8, 255), state.attempts);
235 try testing.expectEqual(Action.give_up, onCrash(&state, 1000, budget));
236}
237
238test "each window has its own budget" {
239 resetAllForTesting();
240 const a = stateFor(0x1000);
241 const b = stateFor(0x2000);
242 const budget: Budget = .{ .max_attempts = 1 };
243
244 try testing.expectEqual(Action.reload, onCrash(a, 0, budget));
245 try testing.expectEqual(Action.give_up, onCrash(a, 1, budget));
246 // b has spent nothing, and a's loop must not have spent it for them.
247 try testing.expectEqual(Action.reload, onCrash(stateFor(0x2000), 2, budget));
248 _ = b;
249}
250
251test "the same window gets the same budget back" {
252 resetAllForTesting();
253 _ = onCrash(stateFor(0x1000), 0, .{});
254 try testing.expectEqual(@as(u8, 1), stateFor(0x1000).attempts);
255 _ = onCrash(stateFor(0x1000), second, .{});
256 try testing.expectEqual(@as(u8, 2), stateFor(0x1000).attempts);
257}
258
259test "a closed window is forgotten" {
260 resetAllForTesting();
261 _ = onCrash(stateFor(0x1000), 0, .{});
262 forget(0x1000);
263 try testing.expectEqual(@as(u8, 0), stateFor(0x1000).attempts);
264}
265
266test "more windows than the table holds still each get tracked" {
267 resetAllForTesting();
268 var i: usize = 1;
269 while (i <= max_tracked + 4) : (i += 1) {
270 const state = stateFor(i * 0x100);
271 try testing.expectEqual(Action.reload, onCrash(state, @intCast(i), .{}));
272 }
273 // The most recent windows are the ones still held.
274 try testing.expectEqual(@as(u8, 1), stateFor((max_tracked + 4) * 0x100).attempts);
275}
276
277test "the give-up page carries its own styling and a way back" {
278 // It is shown when there is no server, no bundle path and possibly no
279 // network, so anything it references it must contain.
280 try testing.expect(std.mem.indexOf(u8, give_up_page, "location.reload()") != null);
281 try testing.expect(std.mem.indexOf(u8, give_up_page, "<style>") != null);
282 try testing.expect(std.mem.indexOf(u8, give_up_page, "prefers-color-scheme") != null);
283 try testing.expect(std.mem.indexOf(u8, give_up_page, "src=") == null);
284 try testing.expect(std.mem.indexOf(u8, give_up_page, "href=") == null);
285 // No apostrophes: it is passed to Objective-C as a C string, but it is
286 // also the kind of literal that ends up inside an `evaluateJavaScript`
287 // single-quoted string one refactor later.
288 try testing.expect(std.mem.indexOfScalar(u8, give_up_page, '\'') == null);
289}