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.
This commit is contained in:
Maurice
2026-11-12 08:42:17 +01:00
parent ec08032e98
commit 5395d53332
3 changed files with 88 additions and 3 deletions
@@ -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))
+18 -3
View File
@@ -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. */
@@ -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');
}
});
});