diff --git a/client/android/app/src/main/java/com/liketrek/trek/TrekServerStore.java b/client/android/app/src/main/java/com/liketrek/trek/TrekServerStore.java index f8bea85e9..82e21b319 100644 --- a/client/android/app/src/main/java/com/liketrek/trek/TrekServerStore.java +++ b/client/android/app/src/main/java/com/liketrek/trek/TrekServerStore.java @@ -156,6 +156,24 @@ final class TrekServerStore { } /** Start the next launch on the bundled address screen, even with a server remembered. */ + private static final String KEY_FAILURE = "last_failure"; + + /** + * Why the server's page did not load, in the WebView's own terms + * ("net::ERR_NAME_NOT_RESOLVED (-2)", "HTTP 503"): the address screen shows + * it small under its message, so a report carries the cause. + */ + static void setFailure(Context context, String failure) { + prefs(context).edit().putString(KEY_FAILURE, failure).commit(); + } + + /** The failure behind this address screen, once. */ + static String takeFailure(Context context) { + String failure = prefs(context).getString(KEY_FAILURE, null); + if (failure != null) prefs(context).edit().remove(KEY_FAILURE).commit(); + return failure; + } + static void showShellOnce(Context context) { prefs(context).edit().putBoolean(KEY_SHELL_ONCE, true).commit(); } diff --git a/client/android/app/src/main/java/com/liketrek/trek/TrekShellPlugin.java b/client/android/app/src/main/java/com/liketrek/trek/TrekShellPlugin.java index be9262cc1..b4eac2759 100644 --- a/client/android/app/src/main/java/com/liketrek/trek/TrekShellPlugin.java +++ b/client/android/app/src/main/java/com/liketrek/trek/TrekShellPlugin.java @@ -315,6 +315,8 @@ public class TrekShellPlugin extends Plugin { JSObject result = new JSObject(); result.put("url", url == null ? JSONObject.NULL : url); result.put("previousUrl", previous == null ? JSONObject.NULL : previous); + String failure = TrekServerStore.takeFailure(getContext()); + result.put("failure", failure == null ? JSONObject.NULL : failure); call.resolve(result); } diff --git a/client/android/app/src/main/java/com/liketrek/trek/TrekWebViewClient.java b/client/android/app/src/main/java/com/liketrek/trek/TrekWebViewClient.java index bad0dc398..a015ec889 100644 --- a/client/android/app/src/main/java/com/liketrek/trek/TrekWebViewClient.java +++ b/client/android/app/src/main/java/com/liketrek/trek/TrekWebViewClient.java @@ -143,7 +143,10 @@ class TrekWebViewClient extends BridgeWebViewClient { String server = TrekServerStore.getUrl(view.getContext()); Uri serverUri = server == null ? null : Uri.parse(server); if (serverUri != null && TrekClientIdentity.matches(String.valueOf(serverUri.getHost()), serverUri.getPort() > 0 ? serverUri.getPort() : 443, request.getHost(), request.getPort())) { - com.liketrek.trek.clientcert.ClientCertWatch.serverAsked(view.getContext(), server, onUnreachable); + com.liketrek.trek.clientcert.ClientCertWatch.serverAsked(view.getContext(), server, () -> { + TrekServerStore.setFailure(view.getContext(), "client certificate requested"); + onUnreachable.run(); + }); } } @@ -168,6 +171,7 @@ class TrekWebViewClient extends BridgeWebViewClient { }, 1500); return; } + TrekServerStore.setFailure(view.getContext(), error.getDescription() + " (" + error.getErrorCode() + ")"); onUnreachable.run(); return; } @@ -189,6 +193,7 @@ class TrekWebViewClient extends BridgeWebViewClient { if (!request.isForMainFrame()) { super.onReceivedHttpError(view, request, errorResponse); } else if (status >= 500 || (status >= 400 && wantedCertificate(request.getUrl()))) { + TrekServerStore.setFailure(view.getContext(), "HTTP " + status); onUnreachable.run(); } else if (status >= 400) { errorPageUrl = request.getUrl().toString(); diff --git a/client/ios/App/App/TrekBridgeViewController.swift b/client/ios/App/App/TrekBridgeViewController.swift index 39d0bdf0f..64b1c88fc 100644 --- a/client/ios/App/App/TrekBridgeViewController.swift +++ b/client/ios/App/App/TrekBridgeViewController.swift @@ -399,6 +399,22 @@ enum TrekKeyCommands { enum TrekServerStore { private static let key = "TrekServerURL" + private static let failureKey = "TrekLastFailure" + + /// Why the server's page did not load ("NSURLErrorDomain -1003"): the + /// address screen shows it small under its message, so a report carries + /// the cause. Taken once. + static func noteFailure(_ error: Error) { + let ns = error as NSError + UserDefaults.standard.set("\(ns.domain) \(ns.code)", forKey: failureKey) + } + + static func takeFailure() -> String? { + let failure = UserDefaults.standard.string(forKey: failureKey) + UserDefaults.standard.removeObject(forKey: failureKey) + return failure + } + private static let previousKey = "TrekPreviousServerURL" private static let checkedKey = "TrekServerCheck" @@ -813,7 +829,11 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate { func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { inner.webView?(webView, didFinish: navigation) onPageShown?() - if TrekSignInRedirect.onServer(server: TrekServerStore.url, url: webView.url) { serverShown = true } + if TrekSignInRedirect.onServer(server: TrekServerStore.url, url: webView.url) { + serverShown = true + // The server's page is up: an earlier failure is no cause any more. + _ = TrekServerStore.takeFailure() + } if let welcome = TrekServerStore.takeWelcomeURL() { webView.load(URLRequest(url: welcome)) } @@ -829,6 +849,7 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate { TrekClientCertWatch.serverAsked(host: space.host, port: space.port) { [weak self, weak webView] in guard let self, let webView else { return } // Capacitor's own answer to a failed load: the address screen (errorPath). + TrekServerStore.noteFailure(URLError(.clientCertificateRejected)) self.inner.webView?(webView, didFailProvisionalNavigation: nil, withError: URLError(.clientCertificateRejected)) } return @@ -853,6 +874,7 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate { http.statusCode >= 400, http.statusCode < 500, let asked = TrekClientIdentity.lastRequest, !asked.served, let url = http.url, url.host?.lowercased() == asked.host, (url.port ?? 443) == asked.port { decisionHandler(.cancel) + TrekServerStore.noteFailure(URLError(.clientCertificateRequired)) inner.webView?(webView, didFailProvisionalNavigation: nil, withError: URLError(.clientCertificateRequired)) return } @@ -870,11 +892,13 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate { func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { if Self.isReplaced(error) { return } + TrekServerStore.noteFailure(error) inner.webView?(webView, didFail: navigation, withError: error) } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { if Self.isReplaced(error) { return } + TrekServerStore.noteFailure(error) inner.webView?(webView, didFailProvisionalNavigation: navigation, withError: error) } } diff --git a/client/ios/App/App/TrekShellPlugin.swift b/client/ios/App/App/TrekShellPlugin.swift index c00b02ce8..94b641ae2 100644 --- a/client/ios/App/App/TrekShellPlugin.swift +++ b/client/ios/App/App/TrekShellPlugin.swift @@ -352,6 +352,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr call.resolve([ "url": TrekServerStore.url ?? NSNull(), "previousUrl": TrekServerStore.previousURL ?? NSNull(), + "failure": TrekServerStore.takeFailure() ?? NSNull(), ]) } diff --git a/client/native-shell/main.ts b/client/native-shell/main.ts index 503506fa8..341295928 100644 --- a/client/native-shell/main.ts +++ b/client/native-shell/main.ts @@ -1546,7 +1546,7 @@ async function connected(url: string, signInHosts: string[] = [], welcome?: stri }); } -function showUnreachable(server: string, cert?: ClientCertDemand): void { +function showUnreachable(server: string, cert?: ClientCertDemand, failure?: string): void { const certPanel = el('div', { className: 'gate', role: 'alert', hidden: true }); if (cert) fillCertPanel(certPanel, cert, () => void TrekShell.reload()); const retry = el('button', { type: 'button', className: cert ? 'linkbtn' : 'btn' }, t('native.offline.retry')); @@ -1576,7 +1576,14 @@ function showUnreachable(server: string, cert?: ClientCertDemand): void { : t('native.offline.hint', { server: new URL(server).host }) ), ], - [certPanel, retry, change, footLinks()] + [ + certPanel, + retry, + change, + // The cause in the web view's own terms, small: what a report needs, not a second message. + ...(failure ? [el('p', { className: 'diag', dir: 'ltr' }, failure.slice(0, 120))] : []), + footLinks(), + ] ) ) ); @@ -1664,8 +1671,9 @@ async function start(): Promise { letKeyboardClose(); let url: string | null; let previousUrl: string | null | undefined; + let failure: string | null | undefined; try { - ({ url, previousUrl } = await TrekShell.getServer()); + ({ url, previousUrl, failure } = await TrekShell.getServer()); } catch { // No native shell under us (a web preview, or a simulator without the // plugin): there is no remembered server, so start at the welcome screen @@ -1689,7 +1697,7 @@ async function start(): Promise { // On iOS the stored copy of its start page brings the offline-capable UI up anyway. const { opened } = await TrekShell.openOfflineCopy(); if (opened) return; - showUnreachable(url); + showUnreachable(url, undefined, failure ?? undefined); } } await SplashScreen.hide(); diff --git a/client/native-shell/shell.css b/client/native-shell/shell.css index 3520a47c1..a71532639 100644 --- a/client/native-shell/shell.css +++ b/client/native-shell/shell.css @@ -1556,3 +1556,14 @@ body.quiet::after { .plan-feats .plan-photos svg { color: var(--faint); } + +/* The cause of a failed load, for a report: small, quiet, and selectable. */ +.diag { + margin: 0; + text-align: center; + color: var(--faint); + font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; + font-size: 0.72rem; + user-select: text; + -webkit-user-select: text; +} diff --git a/client/src/native/trekShell.ts b/client/src/native/trekShell.ts index c5b82d866..23319ddc2 100644 --- a/client/src/native/trekShell.ts +++ b/client/src/native/trekShell.ts @@ -18,7 +18,12 @@ export interface TrekShellPlugin { * newer one (SERVER_CHECK in TrekServerStore). Set only while no server is, * so the address screen can check it again, pre-filled. */ - getServer(): Promise<{ url: string | null; previousUrl?: string | null }>; + /** + * The server picked, the one before an update asked to check it again, and + * why its page did not load last time (taken once; builds before 5.1.4 leave + * it out). + */ + getServer(): Promise<{ url: string | null; previousUrl?: string | null; failure?: string | null }>; /** * Remembers the server and loads it. The current page is gone afterwards. * `signInHosts` are the hosts of a proxy's sign-in page in front of it