diff --git a/client/ios/App/App/TrekBridgeViewController.swift b/client/ios/App/App/TrekBridgeViewController.swift index f5fe90324..07070021e 100644 --- a/client/ios/App/App/TrekBridgeViewController.swift +++ b/client/ios/App/App/TrekBridgeViewController.swift @@ -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) + } +} diff --git a/client/src/api/client.behavior.test.ts b/client/src/api/client.behavior.test.ts index 087965f83..cc49538fe 100644 --- a/client/src/api/client.behavior.test.ts +++ b/client/src/api/client.behavior.test.ts @@ -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 }))) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index fa7dc7b24..efe3dbb5e 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -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()