also looking at this
fix(webview): recover from renderer crashes, and stop dropping window.open()
#72
4 files
+729
-23
| @@ -640,6 +640,27 @@ pub fn build(b: *std.Build) void { | ||
| 640 | 640 | }), |
| 641 | 641 | }); |
| 642 | 642 | |
| 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 | ||
| 643 | 664 | // The conformance test: what craft declares, against what it dispatches. |
| 644 | 665 | const capability_conformance_tests = b.addTest(.{ |
| 645 | 666 | .root_module = b.createModule(.{ |
| @@ -1010,6 +1031,8 @@ pub fn build(b: *std.Build) void { | ||
| 1010 | 1031 | const run_local_tls_tests = b.addRunArtifact(local_tls_tests); |
| 1011 | 1032 | const run_menu_roles_tests = b.addRunArtifact(menu_roles_tests); |
| 1012 | 1033 | 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); | |
| 1013 | 1036 | const run_prefs_tests = b.addRunArtifact(prefs_tests); |
| 1014 | 1037 | const run_prefs_macos_tests = b.addRunArtifact(prefs_macos_tests); |
| 1015 | 1038 | const run_key_codes_tests = b.addRunArtifact(key_codes_tests); |
| @@ -1123,6 +1146,8 @@ pub fn build(b: *std.Build) void { | ||
| 1123 | 1146 | test_step.dependOn(&run_local_tls_tests.step); |
| 1124 | 1147 | test_step.dependOn(&run_menu_roles_tests.step); |
| 1125 | 1148 | 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); | |
| 1126 | 1151 | // macOS only: the registry describes the dispatch chain in macos.zig, and |
| 1127 | 1152 | // compiling it elsewhere drags the whole native graph into a test binary |
| 1128 | 1153 | // for a platform it does not describe. |
| @@ -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 | ||
| 30 | const std = ; | |
| 31 | ||
| 32 | pub 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:`. | |
| 41 | const 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. | |
| 48 | pub 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 | ||
| 65 | const testing = std.testing; | |
| 66 | ||
| 67 | test "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 | ||
| 73 | test "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 | ||
| 81 | test "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 | ||
| 88 | test "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 | ||
| 94 | test "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 | ||
| 113 | test "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 | ||
| 120 | test "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 | ||
| 127 | test "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 | ||
| 135 | test "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 | ||
| 142 | test "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 | } | |
| @@ -4,6 +4,8 @@ const javascript = ; | ||
| 4 | 4 | const native_sidebar_bootstrap = ; |
| 5 | 5 | const local_tls = ; |
| 6 | 6 | const menu_roles = ; |
| 7 | const external_link = ; | |
| 8 | const webview_recovery = ; | |
| 7 | 9 | |
| 8 | 10 | // Objective-C runtime types and functions (manual declarations to avoid @cImport issues) |
| 9 | 11 | pub const objc = struct { |
| @@ -843,6 +845,7 @@ pub fn createWindowWithStyle(title: []const u8, width: u32, height: u32, html: ? | ||
| 843 | 845 | const nsurl = msgSend1(getClass("NSURL"), "URLWithString:", url_str); |
| 844 | 846 | const request = msgSend1(getClass("NSURLRequest"), "requestWithURL:", nsurl); |
| 845 | 847 | _ = msgSend1(webview, "loadRequest:", request); |
| 848 | rememberContent(webview, .{ .request = request }); | |
| 846 | 849 | startup_timing.mark(.load_started); |
| 847 | 850 | } else if (html) |h| { |
| 848 | 851 | if (style.benchmark) { |
| @@ -866,6 +869,7 @@ pub fn createWindowWithStyle(title: []const u8, width: u32, height: u32, html: ? | ||
| 866 | 869 | const base_url = msgSend1(getClass("NSURL"), "URLWithString:", base_url_string); |
| 867 | 870 | |
| 868 | 871 | _ = msgSend2(webview, "loadHTMLString:baseURL:", html_str, base_url); |
| 872 | rememberContent(webview, .{ .html = .{ .string = html_str, .base_url = base_url } }); | |
| 869 | 873 | } |
| 870 | 874 | } |
| 871 | 875 | |
| @@ -882,21 +886,34 @@ pub fn createWindowWithStyle(title: []const u8, width: u32, height: u32, html: ? | ||
| 882 | 886 | .install(); |
| 883 | 887 | } |
| 884 | 888 | |
| 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) { | |
| 887 | 907 | setupUIDelegate(webview) catch |err| { |
| 888 | 908 | if (comptime builtin.mode == .debug) |
| 889 | 909 | std.debug.print("[Media] Failed to setup UI delegate: {}\n", .{err}); |
| 890 | 910 | }; |
| 891 | } | |
| 892 | 911 | |
| 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 | } | |
| 900 | 917 | |
| 901 | 918 | if (style.benchmark) { |
| 902 | 919 | // Benchmark mode: minimal setup — just set content view and return |
| @@ -5153,7 +5170,69 @@ fn handleMediaCapturePermission( | ||
| 5153 | 5170 | } |
| 5154 | 5171 | } |
| 5155 | 5172 | |
| 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". | |
| 5189 | fn 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. | |
| 5157 | 5236 | pub fn setupUIDelegate(webview: objc.id) !void { |
| 5158 | 5237 | if (comptime builtin.mode == .debug) |
| 5159 | 5238 | std.debug.print("[Media] Setting up WKUIDelegate for media permissions...\n", .{}); |
| @@ -5197,6 +5276,19 @@ pub fn setupUIDelegate(webview: objc.id) !void { | ||
| 5197 | 5276 | } |
| 5198 | 5277 | } |
| 5199 | 5278 | |
| 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 | ||
| 5200 | 5292 | // Register the class |
| 5201 | 5293 | objc.objc_registerClassPair(@ptrCast(delegateClass)); |
| 5202 | 5294 | if (comptime builtin.mode == .debug) |
| @@ -5289,6 +5381,14 @@ fn handleAuthChallenge( | ||
| 5289 | 5381 | challenge: objc.id, |
| 5290 | 5382 | completionHandler: objc.id, |
| 5291 | 5383 | ) 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 | ||
| 5292 | 5392 | const protectionSpace = msgSend0(challenge, "protectionSpace"); |
| 5293 | 5393 | if (protectionSpace == null) |
| 5294 | 5394 | return finishAuthChallenge(completionHandler, .perform_default_handling, null); |
| @@ -5309,19 +5409,154 @@ fn handleAuthChallenge( | ||
| 5309 | 5409 | finishAuthChallenge(completionHandler, .use_credential, credential); |
| 5310 | 5410 | } |
| 5311 | 5411 | |
| 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. | |
| 5313 | 5417 | /// |
| 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. | |
| 5424 | const LoadedContent = union(enum) { | |
| 5425 | none, | |
| 5426 | request: objc.id, | |
| 5427 | html: struct { string: objc.id, base_url: objc.id }, | |
| 5428 | }; | |
| 5429 | ||
| 5430 | const 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. | |
| 5437 | var 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. | |
| 5319 | 5441 | /// |
| 5320 | /// Call before loading, so the delegate is in place for the first request's | |
| 5321 | /// challenge. | |
| 5322 | pub 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. | |
| 5447 | fn 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 | ||
| 5477 | fn 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. | |
| 5486 | fn 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. | |
| 5507 | fn 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 | } | |
| 5324 | 5542 | |
| 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. | |
| 5559 | pub fn setupNavigationDelegate(webview: objc.id) !void { | |
| 5325 | 5560 | const superclass = getClass("NSObject"); |
| 5326 | 5561 | const className = "CraftNavigationDelegate"; |
| 5327 | 5562 | |
| @@ -5339,6 +5574,15 @@ pub fn setupNavigationDelegate(webview: objc.id) !void { | ||
| 5339 | 5574 | if (!objc.class_addMethod(@ptrCast(@alignCast(delegateClass)), method_sel, method_imp, method_types)) |
| 5340 | 5575 | return error.MethodAdditionFailed; |
| 5341 | 5576 | |
| 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 | ||
| 5342 | 5586 | objc.objc_registerClassPair(@ptrCast(delegateClass)); |
| 5343 | 5587 | } |
| 5344 | 5588 | |
| @@ -5350,8 +5594,10 @@ pub fn setupNavigationDelegate(webview: objc.id) !void { | ||
| 5350 | 5594 | const delegate = msgSend0(msgSend0(delegate_class_id, "alloc"), "init"); |
| 5351 | 5595 | msgSendVoid1(webview, "setNavigationDelegate:", delegate); |
| 5352 | 5596 | |
| 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 | } | |
| 5355 | 5601 | } |
| 5356 | 5602 | |
| 5357 | 5603 | // ============================================================================ |
| @@ -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 | ||
| 29 | const std = ; | |
| 30 | ||
| 31 | pub 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 | ||
| 39 | pub 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 | ||
| 48 | pub 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. | |
| 54 | pub 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. | |
| 73 | const max_tracked = 8; | |
| 74 | ||
| 75 | const Slot = struct { | |
| 76 | /// The webview this belongs to, as an opaque handle. Zero means free. | |
| 77 | key: usize = 0, | |
| 78 | state: State = .{}, | |
| 79 | }; | |
| 80 | ||
| 81 | var 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. | |
| 88 | pub 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. | |
| 110 | pub fn forget(key: usize) void { | |
| 111 | for (&slots) |*slot| { | |
| 112 | if (slot.key == key) slot.* = .{}; | |
| 113 | } | |
| 114 | } | |
| 115 | ||
| 116 | pub 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. | |
| 134 | pub 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 | ||
| 158 | const testing = std.testing; | |
| 159 | const second = std.time.ns_per_s; | |
| 160 | ||
| 161 | test "the first crash reloads" { | |
| 162 | var state: State = .{}; | |
| 163 | try testing.expectEqual(Action.reload, onCrash(&state, 0, .{})); | |
| 164 | } | |
| 165 | ||
| 166 | test "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 | ||
| 176 | test "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 | ||
| 191 | test "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 | ||
| 202 | test "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 | ||
| 213 | test "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 | ||
| 227 | test "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 | ||
| 238 | test "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 | ||
| 251 | test "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 | ||
| 259 | test "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 | ||
| 266 | test "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 | ||
| 277 | test "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 | } | |