Skip to content
This repository was archived by the owner on Sep 9, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions packages/devtools/docs/ios-safe-area-and-notch.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,11 @@ devtools 只用其中几个入口:
`env(safe-area-inset-*)` 由 UA 定义,作者样式改不了,所以走 CDP。`src/main/services/safe-area/index.ts` 在 simulator WCV 的 `did-attach-webview` 时对每个 render-host guest 发 `Emulation.setSafeAreaInsetsOverride`,这是 guest `WebContents` 可用的最早时刻,页面还没绘制。

- `wc.debugger` 会话不归 safe-area 管,走共享的 `CdpSessionBroker`(`src/main/services/cdp-session/index.ts`)。`wc.debugger` 是单 owner API,没有 broker 时多个消费者会互相抢会话。safe-area 每个 guest 拿一个 `CdpSessionLease`,在上面 `send('Emulation.setSafeAreaInsetsOverride', { insets })`。`insets` 带全部 8 个字段(`top/topMax/right/rightMax/bottom/bottomMax/left/leftMax`,base 等于 max),漏掉 `*Max` 会让 `env(safe-area-max-inset-*)` 停在 0。
- 每个 guest 的页面策略(`isTabPage` 来自 URL 的 `isTab=1`,`isCustomNav` 来自 `navStyle=custom`,两者都由 `dmb-resource-url.ts` 按页面的 `windowConfig` 写进 render-host URL) `will-attach-webview` 时读出并存下(`parseGuestPageInsetPolicy`)(`did-attach` 时 `getURL()` 还是空),设备切换重发时复用;guest `destroyed` 时清掉。lease 在 broker `onDetach` 时丢弃,下次 override 重新申请。
- 每个 guest 的页面策略只有 `isCustomNav`,来自 URL 的 `navStyle=custom`(由 `dmb-resource-url.ts` 按页面的 `windowConfig` 写进 render-host URL)。它在 `will-attach-webview` 时读出并存下(`parseGuestPageInsetPolicy``did-attach` 时 `getURL()` 还是空),设备切换重发时复用;guest `destroyed` 时清掉。lease 在 broker `onDetach` 时丢弃,下次 override 重新申请。
- **重发时机**:(1) guest attach(页面栈新页面),(2) 设备或横竖屏切换(对所有已 attach 的 guest 重发)。
- **只注入 webview 真正贴着的边**,页面自己的 `env()` padding 不会和外壳已覆盖的区域重复计算(`guestInsets()`):
- **按页面导航样式处理顶部,底部始终使用设备值**(`guestInsets()`):
- `top`:自定义导航栏页(`navigationStyle: custom`,页面全出血到屏幕顶部)取设备当前方向的 `safeAreaInsets.top`;默认导航栏页为 0,因为 webview 本来就从外壳导航栏下方开始,三端 native 也是这样。
- `bottom`:tab 页为 0(外壳 tabBar 的背景延伸到底部内边距,页面内容不贴底);非 tab 页取 `safeAreaInsets.bottom`(页面全出血到设备底部,自己用 `env(safe-area-inset-bottom)` 避让)
- `bottom`:所有 render WebView 都取设备当前方向的 `safeAreaInsets.bottom`;tabBar 是否存在不改变页面的 `env(safe-area-inset-bottom)`。
- `left` / `right`:直接取设备当前方向的 `safeAreaInsets.left/right`,横屏灵动岛机型不再是 0。
- **`webContents.debugger` 独占**。外部工具(`--remote-debugging-port`)已经 attach 时 `attach()` 会抛错,只记警告、内边距保持 0,没有纯 CSS 回退。

Expand All @@ -68,7 +68,7 @@ Home 指示条由 frame 画,是绝对定位的透明覆盖层,不占布局
- tab 页:外壳 tabBar 的背景延伸过底部内边距(`padding-bottom = safeAreaInsets.bottom`,`tab-bar.tsx`),指示条压在 tabBar 颜色上。
- 非 tab 页:页面 webview 全出血到设备底部,指示条压在页面内容上。

因为 tab 页已经由外壳让出底部,其 `env(safe-area-inset-bottom)` 被覆盖成 0,避免页面重复避让
tab 页的外壳 tabBar 仍会延伸到底部内边距;同时,该页 WebView 的 `env(safe-area-inset-bottom)` 保持设备值,和其他 render WebView 一致

## JS `safeArea`

Expand Down
10 changes: 5 additions & 5 deletions packages/devtools/docs/simulator-render-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,11 +228,11 @@ service host. Two things keep it working:

The DeviceShell's `.device-shell__home-indicator` strip is sized to
`device.safeAreaInsets.bottom` (gesture-bar devices only; home-button SE-class
has bottom inset 0). The page's `env(safe-area-inset-bottom)` is injected as 0 —
the shell reserves the bottom, so there is a single bottom-inset mechanism. The
top safe area reaches the page via the CDP `setSafeAreaInsetsOverride`; the notch
is an in-screen occluder drawn by the DeviceShell's status bar. (See the
safe-area doc for the CDP wiring.)
has bottom inset 0). Every render WebView receives that same value as
`env(safe-area-inset-bottom)` through the CDP `setSafeAreaInsetsOverride`; a tab
bar does not change the injected value. The top safe area still depends on the
page navigation style. The notch is an in-screen occluder drawn by the
DeviceShell's status bar. (See the safe-area doc for the CDP wiring.)

## Key files

Expand Down
19 changes: 8 additions & 11 deletions packages/devtools/e2e/window-info-follows-device.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,9 @@ const IPHONE_15_WINDOW_INFO = {
screenTop: 54,
}
/** env(safe-area-inset-*) the fixture's pages should see on iPhone 15
* portrait: default-nav pages start BELOW the navigation bar so their top
* inset is already consumed (I4); the bottom inset only applies where no tab
* bar covers it. */
const DEFAULT_NAV_TAB_INSETS = { top: '0px', bottom: '0px' }
const DEFAULT_NAV_NON_TAB_INSETS = { top: '0px', bottom: '34px' }
* portrait: default-nav pages start below the navigation bar, so their top
* inset is already consumed; every render WebView gets the device bottom inset. */
const DEFAULT_NAV_INSETS = { top: '0px', bottom: '34px' }
const CUSTOM_NAV_INSETS = { top: '59px', bottom: '34px' }

/** The fields the three code paths (sync binding, async simulator handler,
Expand Down Expand Up @@ -344,21 +342,20 @@ test.describe('window info follows the selected device', () => {
expect(windowFieldsOf(success!)).toEqual(IPHONE_15_SHARED_FIELDS)
})

test('4. default-nav pages see no top inset; bottom inset only without a tab bar', async () => {
test('4. default-nav pages see no top inset and the device bottom inset', async () => {
// The tab page the app booted into.
const home = await guestShowing(electronApp, 'HOME PAGE')
expect(
{ top: home.probeTop, bottom: home.probeBottom },
'tab page with the default navigation bar: guest already starts below the nav bar, '
+ 'and the tab bar covers the home indicator',
).toEqual(DEFAULT_NAV_TAB_INSETS)
'tab page with the default navigation bar: the guest starts below the nav bar',
).toEqual(DEFAULT_NAV_INSETS)

await navigateTo(electronApp, '/pages/detail/detail')
const detail = await guestShowing(electronApp, 'DETAIL PAGE')
expect(
{ top: detail.probeTop, bottom: detail.probeBottom },
'non-tab page with the default navigation bar: no top inset, but the home indicator is exposed',
).toEqual(DEFAULT_NAV_NON_TAB_INSETS)
'non-tab page with the default navigation bar gets the same device bottom inset',
).toEqual(DEFAULT_NAV_INSETS)
})

test('5. a navigationStyle:custom page sees the full top inset', async () => {
Expand Down
82 changes: 44 additions & 38 deletions packages/devtools/src/main/services/safe-area/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,15 @@ import { describe, it, expect, vi } from 'vitest'
import type { WebContents } from 'electron'

import { createConnectionRegistry } from '@dimina-kit/electron-deck/main'
import { createSafeAreaController } from './index.js'
import { createSafeAreaController, parseGuestPageInsetPolicy } from './index.js'
import { createCdpSessionBroker } from '../cdp-session/index.js'

type AnyFn = (...args: unknown[]) => unknown

/** Minimal emitter-backed WebContents fake (id/once/emit/isDestroyed + the
* debugger surface safe-area touches). `sink` captures every `sendCommand`. */
// The broker (see cdp-session/index.ts, which safe-area now goes through
// instead of touching wc.debugger directly) reads isAttached()/on()/
// removeListener() in addition to attach()/detach()/sendCommand() — this fake
// grows the same surface. Existing assertions (connection-routed teardown,
// per-page-type bottom inset) are unchanged; only the mock's surface area
// needed to widen to match the broker's dependency.
// The broker reads isAttached()/on()/removeListener() in addition to
// attach()/detach()/sendCommand(), so this fake provides that debugger surface.
function makeWc(
id: number,
sink?: Array<{ method: string; params: unknown }>,
Expand Down Expand Up @@ -77,7 +73,7 @@ describe('createSafeAreaController teardown routing', () => {
const controller = createSafeAreaController({ connections })
const wc = makeWc(7)

controller.applyToGuest(wc, null, { isTabPage: false, isCustomNav: false })
controller.applyToGuest(wc, null, { isCustomNav: false })

// The connection was acquired for this guest.
expect(connections.get(wc.id), 'guest connection must be live before destroy').toBeDefined()
Expand All @@ -100,42 +96,52 @@ describe('createSafeAreaController teardown routing', () => {
})
})

describe('createSafeAreaController per-page-type bottom inset', () => {
describe('createSafeAreaController bottom inset', () => {
function lastInsets(sink: Array<{ method: string; params: unknown }>) {
const call = [...sink].reverse().find((c) => c.method === 'Emulation.setSafeAreaInsetsOverride')
return (call?.params as { insets: { top: number; bottom: number; bottomMax: number } }).insets
}

it('a non-tab page gets the real bottom inset (page opts in via env)', () => {
it('forwards the real bottom inset to a render guest', () => {
const sink: Array<{ method: string; params: unknown }> = []
const controller = createSafeAreaController()
controller.applyToGuest(makeWc(1, sink), DEVICE, { isTabPage: false, isCustomNav: true })
controller.applyToGuest(makeWc(1, sink), DEVICE, { isCustomNav: true })
const insets = lastInsets(sink)
expect(insets.top).toBe(47)
expect(insets.bottom).toBe(34)
expect(insets.bottomMax).toBe(34)
})

it('a tab page gets bottom 0 (the shell tabBar fills the safe area)', () => {
it('keeps the device bottom inset for a policy parsed from a tab-page URL', () => {
const sink: Array<{ method: string; params: unknown }> = []
const controller = createSafeAreaController()
controller.applyToGuest(makeWc(2, sink), DEVICE, { isTabPage: true, isCustomNav: true })
const policy = parseGuestPageInsetPolicy('https://example.test/render-host?isTab=1&navStyle=default')

controller.applyToGuest(makeWc(18, sink), DEVICE, policy)

expect(lastInsets(sink).bottom).toBe(34)
})

it('forwards the same real bottom inset to another render guest', () => {
const sink: Array<{ method: string; params: unknown }> = []
const controller = createSafeAreaController()
controller.applyToGuest(makeWc(2, sink), DEVICE, { isCustomNav: true })
const insets = lastInsets(sink)
expect(insets.top).toBe(47)
expect(insets.bottom).toBe(0)
expect(insets.bottomMax).toBe(0)
expect(insets.bottom).toBe(34)
expect(insets.bottomMax).toBe(34)
})

it('reapplyAll keeps each guest its attached page type', () => {
it('reapplyAll keeps the device bottom inset for every guest', () => {
const sinkTab: Array<{ method: string; params: unknown }> = []
const sinkPage: Array<{ method: string; params: unknown }> = []
const controller = createSafeAreaController()
controller.applyToGuest(makeWc(3, sinkTab), DEVICE, { isTabPage: true, isCustomNav: true })
controller.applyToGuest(makeWc(4, sinkPage), DEVICE, { isTabPage: false, isCustomNav: true })
controller.applyToGuest(makeWc(3, sinkTab), DEVICE, { isCustomNav: true })
controller.applyToGuest(makeWc(4, sinkPage), DEVICE, { isCustomNav: true })
sinkTab.length = 0
sinkPage.length = 0
controller.reapplyAll(DEVICE)
expect(lastInsets(sinkTab).bottom).toBe(0)
expect(lastInsets(sinkTab).bottom).toBe(34)
expect(lastInsets(sinkPage).bottom).toBe(34)
})

Expand All @@ -147,7 +153,7 @@ describe('createSafeAreaController per-page-type bottom inset', () => {
const sink: Array<{ method: string; params: unknown }> = []
const wc = makeWc(5, sink)
const controller = createSafeAreaController()
controller.applyToGuest(wc, DEVICE, { isTabPage: false, isCustomNav: true })
controller.applyToGuest(wc, DEVICE, { isCustomNav: true })
expect(lastInsets(sink).bottom).toBe(34)

// Something outside safe-area detaches the shared debugger session
Expand All @@ -156,7 +162,7 @@ describe('createSafeAreaController per-page-type bottom inset', () => {
sink.length = 0

// reapplyAll must reacquire (not silently no-op on a stale lease) and
// keep applying the SAME page-type policy this guest attached with.
// keep applying the same navigation-style policy this guest attached with.
controller.reapplyAll(DEVICE)
expect(sink.length).toBeGreaterThan(0)
expect(lastInsets(sink).bottom).toBe(34)
Expand All @@ -180,20 +186,20 @@ describe('createSafeAreaController per-edge left/right insets', () => {
it('forwards the device safeAreaInsets right/left into the CDP override', () => {
const sink: Array<{ method: string; params: unknown }> = []
const controller = createSafeAreaController()
controller.applyToGuest(makeWc(8, sink), LANDSCAPE_DEVICE, { isTabPage: false, isCustomNav: false })
controller.applyToGuest(makeWc(8, sink), LANDSCAPE_DEVICE, { isCustomNav: false })
const insets = lastFullInsets(sink)
expect(insets.right).toBe(59)
expect(insets.rightMax).toBe(59)
expect(insets.left).toBe(59)
expect(insets.leftMax).toBe(59)
})

it('a tab page still gets bottom 0 but keeps the real left/right insets', () => {
it('forwards the device bottom and left/right insets to a render guest', () => {
const sink: Array<{ method: string; params: unknown }> = []
const controller = createSafeAreaController()
controller.applyToGuest(makeWc(9, sink), LANDSCAPE_DEVICE, { isTabPage: true, isCustomNav: false })
controller.applyToGuest(makeWc(9, sink), LANDSCAPE_DEVICE, { isCustomNav: false })
const insets = lastFullInsets(sink)
expect(insets.bottom).toBe(0)
expect(insets.bottom).toBe(21)
expect(insets.left).toBe(59)
expect(insets.right).toBe(59)
})
Expand All @@ -203,7 +209,7 @@ describe('createSafeAreaController broker ownership', () => {
it('disposes a private (non-injected) broker on dispose(), detaching self-attached sessions', () => {
const wc = makeWc(6)
const controller = createSafeAreaController() // no broker injected -> owns a private one
controller.applyToGuest(wc, null, { isTabPage: false, isCustomNav: false })
controller.applyToGuest(wc, null, { isCustomNav: false })
expect(wc.debugger.attach).toHaveBeenCalled()

controller.dispose()
Expand All @@ -215,7 +221,7 @@ describe('createSafeAreaController broker ownership', () => {
const broker = createCdpSessionBroker()
const wc = makeWc(7)
const controller = createSafeAreaController({ broker })
controller.applyToGuest(wc, null, { isTabPage: false, isCustomNav: false })
controller.applyToGuest(wc, null, { isCustomNav: false })
expect(wc.debugger.attach).toHaveBeenCalled()

controller.dispose()
Expand All @@ -241,7 +247,7 @@ describe('createSafeAreaController per-page navigation-style top inset', () => {
it('a default navigation-bar page gets top 0 (the shell nav bar already clears the notch)', () => {
const sink: Array<{ method: string; params: unknown }> = []
const controller = createSafeAreaController()
controller.applyToGuest(makeWc(10, sink), DEVICE, { isTabPage: false, isCustomNav: false })
controller.applyToGuest(makeWc(10, sink), DEVICE, { isCustomNav: false })
const insets = lastInsets(sink)
expect(insets.top).toBe(0)
expect(insets.topMax).toBe(0)
Expand All @@ -250,35 +256,35 @@ describe('createSafeAreaController per-page navigation-style top inset', () => {
it('a custom navigation-bar page gets the real device top inset', () => {
const sink: Array<{ method: string; params: unknown }> = []
const controller = createSafeAreaController()
controller.applyToGuest(makeWc(11, sink), DEVICE, { isTabPage: false, isCustomNav: true })
controller.applyToGuest(makeWc(11, sink), DEVICE, { isCustomNav: true })
const insets = lastInsets(sink)
expect(insets.top).toBe(47)
expect(insets.topMax).toBe(47)
})

it('the bottom inset stays page-type driven regardless of navigation style', () => {
it('the device bottom inset is the same for every navigation style', () => {
const defaultNavTab: Array<{ method: string; params: unknown }> = []
const defaultNavPage: Array<{ method: string; params: unknown }> = []
const customNavTab: Array<{ method: string; params: unknown }> = []
const customNavPage: Array<{ method: string; params: unknown }> = []
const controller = createSafeAreaController()
controller.applyToGuest(makeWc(12, defaultNavTab), DEVICE, { isTabPage: true, isCustomNav: false })
controller.applyToGuest(makeWc(13, defaultNavPage), DEVICE, { isTabPage: false, isCustomNav: false })
controller.applyToGuest(makeWc(14, customNavTab), DEVICE, { isTabPage: true, isCustomNav: true })
controller.applyToGuest(makeWc(15, customNavPage), DEVICE, { isTabPage: false, isCustomNav: true })
controller.applyToGuest(makeWc(12, defaultNavTab), DEVICE, { isCustomNav: false })
controller.applyToGuest(makeWc(13, defaultNavPage), DEVICE, { isCustomNav: false })
controller.applyToGuest(makeWc(14, customNavTab), DEVICE, { isCustomNav: true })
controller.applyToGuest(makeWc(15, customNavPage), DEVICE, { isCustomNav: true })

expect(lastInsets(defaultNavTab).bottom).toBe(0)
expect(lastInsets(defaultNavTab).bottom).toBe(34)
expect(lastInsets(defaultNavPage).bottom).toBe(34)
expect(lastInsets(customNavTab).bottom).toBe(0)
expect(lastInsets(customNavTab).bottom).toBe(34)
expect(lastInsets(customNavPage).bottom).toBe(34)
})

it('reapplyAll after a device change keeps each guest its navigation style', () => {
const defaultNav: Array<{ method: string; params: unknown }> = []
const customNav: Array<{ method: string; params: unknown }> = []
const controller = createSafeAreaController()
controller.applyToGuest(makeWc(16, defaultNav), DEVICE, { isTabPage: false, isCustomNav: false })
controller.applyToGuest(makeWc(17, customNav), DEVICE, { isTabPage: false, isCustomNav: true })
controller.applyToGuest(makeWc(16, defaultNav), DEVICE, { isCustomNav: false })
controller.applyToGuest(makeWc(17, customNav), DEVICE, { isCustomNav: true })
defaultNav.length = 0
customNav.length = 0

Expand Down
Loading
Loading