From 5395d533326283f2382124c8f244e44304fcbad8 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 12 Nov 2026 08:42:17 +0100 Subject: [PATCH] fix(native): the top bar keeps clear of a camera in the corner of the screen The iOS app hands the page the safe area it measures, a floor under env() where WebKit reports none, and on a phone the floating bars keep clear of the side insets as well. --- .../App/App/TrekBridgeViewController.swift | 47 +++++++++++++++++++ client/src/mobile/mobile.css | 21 +++++++-- client/tests/unit/mobile/shellChrome.test.tsx | 23 +++++++++ 3 files changed, 88 insertions(+), 3 deletions(-) diff --git a/client/ios/App/App/TrekBridgeViewController.swift b/client/ios/App/App/TrekBridgeViewController.swift index a7f44e124..39d0bdf0f 100644 --- a/client/ios/App/App/TrekBridgeViewController.swift +++ b/client/ios/App/App/TrekBridgeViewController.swift @@ -42,6 +42,7 @@ class TrekBridgeViewController: CAPBridgeViewController { if let webView, let inner = webView.navigationDelegate { let proxy = TrekNavigationProxy(inner: inner) proxy.onErrorPage = { [weak self] error in self?.errorPageChanged(error) } + proxy.onPageShown = { [weak self] in self?.publishSafeArea(force: true) } navigationProxy = proxy webView.navigationDelegate = proxy } @@ -88,6 +89,49 @@ class TrekBridgeViewController: CAPBridgeViewController { webView?.underPageBackgroundColor = color } + // MARK: - The safe area the page keeps clear of + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + publishSafeArea() + } + + override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + publishSafeArea() + } + + private var publishedSafeArea: UIEdgeInsets? + + /// WebKit's env(safe-area-inset-*) is the window's safe area only where the + /// web view overlaps it. Where a release lays the web view out below the + /// status bar it reports 0, yet a camera in a corner can still reach into + /// the page. The part of the window's safe area (and of the status bar) that + /// falls inside the web view goes to the page as --trek-native-safe-*, which + /// mobile.css takes as a floor under env(), never in its place. + func publishSafeArea(force: Bool = false) { + guard let webView, let window = view.window else { return } + let frame = webView.convert(webView.bounds, to: window) + let safe = window.safeAreaInsets + let statusBar = window.windowScene?.statusBarManager?.statusBarFrame.height ?? 0 + let insets = UIEdgeInsets( + top: max(0, max(safe.top, statusBar) - frame.minY), + left: max(0, safe.left - frame.minX), + bottom: max(0, safe.bottom - (window.bounds.maxY - frame.maxY)), + right: max(0, safe.right - (window.bounds.maxX - frame.maxX)) + ) + if !force, insets == publishedSafeArea { return } + publishedSafeArea = insets + let px = { (v: CGFloat) in "'\(Int(v.rounded(.up)))px'" } + let js = "(function(s){" + + "s.setProperty('--trek-native-safe-top',\(px(insets.top)));" + + "s.setProperty('--trek-native-safe-left',\(px(insets.left)));" + + "s.setProperty('--trek-native-safe-right',\(px(insets.right)));" + + "s.setProperty('--trek-native-safe-bottom',\(px(insets.bottom)));" + + "})(document.documentElement.style)" + webView.evaluateJavaScript(js, completionHandler: nil) + } + // MARK: - A proxy's sign-in page /// Over a proxy's sign-in page (Pangolin, authentik, ...) the server's UI is @@ -714,6 +758,8 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate { private var serverShown = false /// Told on the main thread whether the main frame now shows an error page in place of the server's. var onErrorPage: ((Bool) -> Void)? + /// Told when a page has finished loading: a new document starts without the safe area the controller handed the last one. + var onPageShown: (() -> Void)? init(inner: WKNavigationDelegate) { self.inner = inner @@ -766,6 +812,7 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate { /// page once (TrekServerStore.takeWelcomeURL); it is on the server's origin. 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 let welcome = TrekServerStore.takeWelcomeURL() { webView.load(URLRequest(url: welcome)) diff --git a/client/src/mobile/mobile.css b/client/src/mobile/mobile.css index 2d9c7a191..5301044c1 100644 --- a/client/src/mobile/mobile.css +++ b/client/src/mobile/mobile.css @@ -62,7 +62,14 @@ * 52px/26px offsets stand in for the phone status bar / home indicator, so * real devices derive them from the safe-area insets. */ --bottom-nav-h: calc(84px + env(safe-area-inset-bottom, 0px)); - --m-safe-top: calc(env(safe-area-inset-top, 0px) + 12px); + /* The safe area the iOS app measures (TrekBridgeViewController + * publishSafeArea) is a floor under env(): where a release lays the web view + * out below the status bar, WebKit reports 0 while a camera in a corner still + * reaches into the page. In a browser the variables are unset: env() alone. */ + --m-safe-t: max(env(safe-area-inset-top, 0px), var(--trek-native-safe-top, 0px)); + --m-safe-l: max(env(safe-area-inset-left, 0px), var(--trek-native-safe-left, 0px)); + --m-safe-r: max(env(safe-area-inset-right, 0px), var(--trek-native-safe-right, 0px)); + --m-safe-top: calc(var(--m-safe-t) + 12px); /* The navigation rail's share of the leading edge (wide windows only), what * every full-width fixed bar and the page column start after. */ --m-rail-w: 0px; @@ -75,10 +82,10 @@ * reads the same values as the shell. */ html:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-root { --bottom-nav-h: env(safe-area-inset-bottom, 0px); - --m-rail-w: calc(env(safe-area-inset-left, 0px) + 100px); + --m-rail-w: calc(var(--m-safe-l) + 100px); } html[dir='rtl']:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m-root { - --m-rail-w: calc(env(safe-area-inset-right, 0px) + 100px); + --m-rail-w: calc(var(--m-safe-r) + 100px); } .dark .m-root { @@ -570,6 +577,14 @@ html[dir='rtl']:has(.m-shell[data-size='wide'], .m-shell[data-size='split']) .m- inset-inline-start: var(--m-rail-w, 0px); inset-inline-end: 0; } +/* Without the rail the floating bars keep clear of the sides' safe area too: + a camera in a corner can come as a side inset. Physical sides, as the + insets are the screen's, not the text direction's. On a wide window the + rail takes the leading side and a bar may centre itself (mx-auto). */ +html:not(:has(.m-shell[data-size='wide'], .m-shell[data-size='split'])) :is(.m-bar-x, .m-span-x) { + margin-left: var(--m-safe-l, 0px); + margin-right: var(--m-safe-r, 0px); +} /* A screen whose background is a map reaches under the rail: the map runs to the window's edge and the rail floats over it like the top controls do. Its own bars then keep to the page column with m-bar-x / m-span-x. */ diff --git a/client/tests/unit/mobile/shellChrome.test.tsx b/client/tests/unit/mobile/shellChrome.test.tsx index c7d18e58b..2fd63763f 100644 --- a/client/tests/unit/mobile/shellChrome.test.tsx +++ b/client/tests/unit/mobile/shellChrome.test.tsx @@ -48,3 +48,26 @@ describe('MGlassBar', () => { expect(container.firstElementChild).toBe(screen.getByText('bar').parentElement); }); }); + +describe('safe area in the touch shell', () => { + // jsdom applies no stylesheet and has no env(), so the rules are read from source. + const css = readFileSync(resolve(root, 'src/mobile/mobile.css'), 'utf8'); + + it('FE-MOB-CHROME-004: the top clearance takes env() with the measurement of the app as a floor, never in its place', () => { + expect(css).toContain('--m-safe-t: max(env(safe-area-inset-top, 0px), var(--trek-native-safe-top, 0px));'); + expect(css).toContain('--m-safe-top: calc(var(--m-safe-t) + 12px);'); + expect(css).toContain('--m-safe-l: max(env(safe-area-inset-left, 0px), var(--trek-native-safe-left, 0px));'); + expect(css).toContain('--m-safe-r: max(env(safe-area-inset-right, 0px), var(--trek-native-safe-right, 0px));'); + }); + + it('FE-MOB-CHROME-005: without the rail the floating bars keep clear of a side inset, such as a camera in a corner', () => { + const narrow = "html:not(:has(.m-shell[data-size='wide'], .m-shell[data-size='split'])) :is(.m-bar-x, .m-span-x) {"; + const rule = css.split(narrow)[1]?.split('}')[0] ?? ''; + expect(rule).toContain('margin-left: var(--m-safe-l, 0px);'); + expect(rule).toContain('margin-right: var(--m-safe-r, 0px);'); + // On a wide window the bars keep no side margin, so mx-auto still centres them. + for (const cls of ['m-bar-x', 'm-span-x']) { + expect(css.split(`\n.${cls} {`)[1]?.split('}')[0] ?? '').not.toContain('margin'); + } + }); +});