feat(native): the 'server not reachable' screen names the cause, small, for a report

A load that failed sent the app to the address screen with a general message only, and a tester's screenshot told nothing. The web view's own error (net::ERR_..., an HTTP status, NSURLErrorDomain and its code, a client certificate the proxy asked for) is kept natively and shown under the message once.
This commit is contained in:
Maurice
2026-11-12 10:11:01 +01:00
parent 46b9df54bf
commit a1d3ffc2ce
8 changed files with 81 additions and 7 deletions
@@ -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();
}
@@ -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);
}
@@ -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();
@@ -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)
}
}
+1
View File
@@ -352,6 +352,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
call.resolve([
"url": TrekServerStore.url ?? NSNull(),
"previousUrl": TrekServerStore.previousURL ?? NSNull(),
"failure": TrekServerStore.takeFailure() ?? NSNull(),
])
}
+12 -4
View File
@@ -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<void> {
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<void> {
// 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();
+11
View File
@@ -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;
}
+6 -1
View File
@@ -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