fix(native): a signed-out first start no longer ends on Server not reachable

Connecting the app to a server as someone not signed in there showed
"Server not reachable" with the server up. The UI's first API call answered
401, the client redirected with a full page load while the first page was
still loading, WebKit cancelled that load (-999), and Capacitor answers every
failed navigation, a replaced one included, with its error page: the shell's
unreachable screen.

The client now waits for the first page to finish loading before such a
redirect inside the apps, which helps the binaries already out; and the iOS
shell puts a small navigation delegate in front of Capacitor's that lets a
cancelled or interrupted navigation pass without the error page.
This commit is contained in:
Maurice
2026-11-06 16:54:02 +01:00
parent 7e8e342e29
commit f81fe8cb17
3 changed files with 85 additions and 2 deletions
@@ -29,8 +29,20 @@ class TrekBridgeViewController: CAPBridgeViewController {
return configuration
}
/// Kept alive here: WKWebView holds its navigation delegate weakly.
private var navigationProxy: TrekNavigationProxy?
override func capacitorDidLoad() {
TrekTileStore.shared.prune()
// Capacitor shows its error page for every failed navigation, a replaced
// one included: the UI moving to /login while the first page still loads
// cancels that load (-999), and a first-time user landed on "Server not
// reachable" with a server that was there all along.
if let webView, let inner = webView.navigationDelegate {
let proxy = TrekNavigationProxy(inner: inner)
navigationProxy = proxy
webView.navigationDelegate = proxy
}
bridge?.registerPluginInstance(TrekShellPlugin())
// WebKit's own swipe back slides away a screenshot of the whole page,
// closes no sheet and takes every drag near the edge from the UI. The UI
@@ -272,3 +284,39 @@ final class TrekTileSchemeHandler: NSObject, WKURLSchemeHandler {
task.didFinish()
}
}
/// Stands in front of Capacitor's navigation delegate and hands it everything
/// but the failures of a navigation that another one replaced: a cancelled
/// load (NSURLErrorCancelled) or an interrupted frame load (WebKit 102). Those
/// are no fault, and Capacitor would answer them with its error page.
final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
private let inner: WKNavigationDelegate
init(inner: WKNavigationDelegate) {
self.inner = inner
}
static func isReplaced(_ error: Error) -> Bool {
let error = error as NSError
return (error.domain == NSURLErrorDomain && error.code == NSURLErrorCancelled)
|| (error.domain == "WebKitErrorDomain" && error.code == 102)
}
override func responds(to aSelector: Selector!) -> Bool {
super.responds(to: aSelector) || (inner as AnyObject).responds(to: aSelector)
}
override func forwardingTarget(for aSelector: Selector!) -> Any? {
(inner as AnyObject).responds(to: aSelector) ? inner : nil
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
if Self.isReplaced(error) { return }
inner.webView?(webView, didFail: navigation, withError: error)
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
if Self.isReplaced(error) { return }
inner.webView?(webView, didFailProvisionalNavigation: navigation, withError: error)
}
}
+20
View File
@@ -12,6 +12,8 @@ const { probeNow } = vi.hoisted(() => ({
probeNow: vi.fn(async (): Promise<'online' | 'offline' | 'proxy-wall'> => 'offline'),
}))
vi.mock('../sync/connectivity', () => ({ probeNow }))
const isNativeApp = vi.hoisted(() => vi.fn(() => false))
vi.mock('../native/platform', () => ({ isNativeApp }))
const { apiClient, adminApi, mapsApi, pluginsApi, parseInDev } = await import('./client')
@@ -357,6 +359,24 @@ describe('client > redirect handling', () => {
expect(loc.href).toBe('/login?redirect=' + encodeURIComponent('/trips/7?tab=map#day-2'))
})
it('FE-APIWIRE-019b: in the app it waits for the first page to finish loading before it leaves', async () => {
isNativeApp.mockReturnValue(true)
const ready = Object.getOwnPropertyDescriptor(Document.prototype, 'readyState')
Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true })
const loc = setLocation('/dashboard')
server.use(http.get('/api/auth/me', () => HttpResponse.json({ code: 'AUTH_REQUIRED' }, { status: 401 })))
await captureError(() => apiClient.get('/auth/me'))
expect(loc.href).not.toContain('/login')
window.dispatchEvent(new Event('load'))
expect(loc.href).toBe('/login?redirect=' + encodeURIComponent('/dashboard'))
delete (document as { readyState?: unknown }).readyState
if (ready) Object.defineProperty(Document.prototype, 'readyState', ready)
isNativeApp.mockReturnValue(false)
})
it('FE-APIWIRE-020: an MFA_REQUIRED 403 sends the user to the settings page', async () => {
const loc = setLocation('/dashboard')
server.use(http.get('/api/auth/me', () => HttpResponse.json({ code: 'MFA_REQUIRED' }, { status: 403 })))
+17 -2
View File
@@ -77,6 +77,7 @@ import { getSocketId } from './websocket'
import { probeNow } from '../sync/connectivity'
import { downloadBlob } from '../utils/fileDownload'
import { translateRateLimit } from './rateLimitMessage'
import { isNativeApp } from '../native/platform'
/**
* Validate a response payload against its @trek/shared Zod schema — but only in
@@ -150,6 +151,20 @@ export function isAuthPublicPath(pathname: string): boolean {
return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p))
}
/**
* A full page load to `url`. In the apps it waits for the first page to finish
* loading: started earlier, it cancels that load, and the shell answers a
* cancelled load with its "Server not reachable" page, which is where a
* signed-out user opening the app for the first time ended up.
*/
function hardNavigate(url: string): void {
const go = () => {
window.location.href = url
}
if (isNativeApp() && document.readyState !== 'complete') window.addEventListener('load', go, { once: true })
else go()
}
// Unregisters the SW before reloading so the navigation reaches the network.
// Without this, WorkBox's NavigationRoute serves the cached SPA shell and the
// upstream proxy (CF Access / Pangolin) never gets to challenge the user.
@@ -218,7 +233,7 @@ apiClient.interceptors.response.use(
const { pathname } = window.location
if (!isAuthPublicPath(pathname)) {
const currentPath = pathname + window.location.search + window.location.hash
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
hardNavigate('/login?redirect=' + encodeURIComponent(currentPath))
}
}
if (
@@ -226,7 +241,7 @@ apiClient.interceptors.response.use(
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
!window.location.pathname.startsWith('/settings')
) {
window.location.href = '/settings?mfa=required'
hardNavigate('/settings?mfa=required')
}
if (error.response?.status === 429) {
const translated = translateRateLimit()