From ed99117bf80cd22196e9acb01d0c398bdc559e5b Mon Sep 17 00:00:00 2001 From: lbb00 Date: Fri, 4 Sep 2026 18:30:50 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(devtools):=20=E6=A8=A1=E6=8B=9F?= =?UTF-8?q?=E5=99=A8=E6=9C=BA=E8=BA=AB=E4=B8=8E=E7=8A=B6=E6=80=81=E6=A0=8F?= =?UTF-8?q?=E6=94=B9=E7=94=A8=20@devicekit/frame=20=E7=BB=98=E5=88=B6?= =?UTF-8?q?=EF=BC=8C=E7=AA=97=E5=8F=A3=E4=BF=A1=E6=81=AF=E9=9A=8F=E6=9C=BA?= =?UTF-8?q?=E5=9E=8B=E4=B8=8E=E6=9C=9D=E5=90=91=E5=8F=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 模拟器里的机身、状态栏、挖孔和 Home 条不再由 devtools 自己画,改由开源包 @devicekit/frame 的 元素绘制,机型数据来自 @devicekit/devices(171 台,含 iOS/Android/HarmonyOS)。devtools 的机型下拉只保留 CLASSIC_DEVICES 精选机型。 NativeDeviceInfo 的宽高、状态栏高和安全区改为已按当前朝向解析,去掉 notchType;safe-area 的 CDP 覆盖透传 left/right,横屏时 env(safe-area-inset-left) 能拿到真实值。 小程序侧 wx.getWindowInfo()/getSystemInfoSync() 不再固定返回 iPhone X:dimina-electron-runtime 新增 host-env.ts 作为唯一算法(windowHeight = 屏高 − 上下安全区,safeArea 用屏幕坐标,screenTop = 状态栏高),切机型时通过 hostEnvUpdate 推给小程序;默认导航栏页面 CSS 安全区 top 为 0。 面板宽度与自动缩放统一从 frameOuterSize 推导。测试与 e2e 里的设备名改用 DEVICE_NAMES 常量。 Co-Authored-By: Claude Fable 5.1 --- README.md | 2 + .../devtools/docs/ios-safe-area-and-notch.md | 258 ++++------- .../devtools/docs/native-bridge-protocol.md | 2 +- .../e2e/device-frame-integration.spec.ts | 396 ++++++++++++++++ packages/devtools/e2e/devtools-panel.spec.ts | 5 +- .../devtools/e2e/fixtures/tabbar-app/app.json | 3 +- .../tabbar-app/pages/custom/custom.js | 8 + .../tabbar-app/pages/custom/custom.json | 4 + .../tabbar-app/pages/custom/custom.wxml | 6 + .../tabbar-app/pages/custom/custom.wxss | 11 + ...native-host-white-screen-reconcile.spec.ts | 7 +- .../e2e/window-info-follows-device.spec.ts | 421 ++++++++++++++++++ packages/devtools/package.json | 2 + .../src/main/services/safe-area/index.test.ts | 117 ++++- .../src/main/services/safe-area/index.ts | 86 +++- .../services/views/native-simulator-view.ts | 28 +- .../src/preload/runtime/native-host.ts | 4 + .../simulator-panel-auto-zoom.test.tsx | 52 +-- ...ator-panel-collapse-on-deactivate.test.tsx | 3 +- ...mulator-panel-compiling-indicator.test.tsx | 3 +- .../simulator-panel-fallback-banner.test.tsx | 3 +- ...lator-panel-follow-layout-reorder.test.tsx | 3 +- ...or-panel-internal-devtools-button.test.tsx | 3 +- .../simulator-panel-page-path-bar.test.tsx | 3 +- ...lator-panel-runtime-error-overlay.test.tsx | 3 +- .../components/simulator-panel.test.tsx | 109 +++++ .../components/simulator-panel.tsx | 74 ++- .../controllers/use-device-auto-zoom.test.tsx | 9 +- .../controllers/use-device.test.tsx | 135 ++++++ .../project-runtime/controllers/use-device.ts | 93 +++- ...runtime-controller-compile-events.test.tsx | 6 +- ...t-runtime-controller-compile-logs.test.tsx | 6 +- .../use-project-runtime-controller.ts | 38 +- .../use-simulator-explicit-relaunch.test.tsx | 5 +- .../use-simulator-force-relaunch.test.tsx | 5 +- .../use-simulator-hot-reload.test.tsx | 5 +- .../use-simulator-soft-reload.test.tsx | 5 +- .../use-simulator-thumbnail.test.tsx | 5 +- .../project-runtime/project-runtime.tsx | 2 + .../devtools/src/renderer/shared/constants.ts | 17 - .../sync-impls/system-info.test.ts | 100 +++++ .../service-host/sync-impls/system-info.ts | 52 ++- packages/devtools/src/shared/ipc-channels.ts | 3 +- .../devtools/src/shared/ipc-schemas.test.ts | 50 +++ packages/devtools/src/shared/ipc-schemas.ts | 5 +- .../device-shell/device-shell-types.ts | 3 - .../simulator/device-shell/device-shell.css | 91 +--- .../device-shell/device-shell.test.tsx | 207 +++++++++ .../simulator/device-shell/device-shell.tsx | 127 +++--- .../src/simulator/device-shell/status-bar.css | 88 ---- .../src/simulator/device-shell/status-bar.tsx | 71 --- .../simulator-api-device-follow.test.ts | 71 ++- .../devtools/src/simulator/simulator-api.ts | 16 + .../devtools/src/simulator/simulator-app.tsx | 1 - .../src/simulator/simulator-mini-app.ts | 57 ++- packages/devtools/src/simulator/types.ts | 8 + .../e2e/native-host-device.spec.ts | 5 +- packages/dimina-electron-runtime/src/index.ts | 2 +- .../src/main/ipc/bridge-router.ts | 8 +- .../src/shared/bridge-channels.test.ts | 217 +++++++++ .../src/shared/bridge-channels.ts | 49 +- .../src/shared/dmb-resource-url.ts | 6 + .../src/shared/host-env.ts | 117 +++++ .../src/shared/runtime-types.ts | 9 +- .../src/simulator-ui/miniapp-frame.tsx | 8 +- .../src/simulator-ui/miniapp-host.ts | 1 + pnpm-lock.yaml | 31 ++ 67 files changed, 2609 insertions(+), 741 deletions(-) create mode 100644 packages/devtools/e2e/device-frame-integration.spec.ts create mode 100644 packages/devtools/e2e/fixtures/tabbar-app/pages/custom/custom.js create mode 100644 packages/devtools/e2e/fixtures/tabbar-app/pages/custom/custom.json create mode 100644 packages/devtools/e2e/fixtures/tabbar-app/pages/custom/custom.wxml create mode 100644 packages/devtools/e2e/fixtures/tabbar-app/pages/custom/custom.wxss create mode 100644 packages/devtools/e2e/window-info-follows-device.spec.ts create mode 100644 packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel.test.tsx create mode 100644 packages/devtools/src/renderer/modules/main/features/project-runtime/controllers/use-device.test.tsx create mode 100644 packages/devtools/src/service-host/sync-impls/system-info.test.ts create mode 100644 packages/devtools/src/shared/ipc-schemas.test.ts create mode 100644 packages/devtools/src/simulator/device-shell/device-shell.test.tsx delete mode 100644 packages/devtools/src/simulator/device-shell/status-bar.css delete mode 100644 packages/devtools/src/simulator/device-shell/status-bar.tsx create mode 100644 packages/dimina-electron-runtime/src/shared/bridge-channels.test.ts create mode 100644 packages/dimina-electron-runtime/src/shared/host-env.ts diff --git a/README.md b/README.md index fdb1ae0a..267cdbf5 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ console.log(`预览地址: http://localhost:${session.port}`) | [`@dimina-kit/workbench`](./packages/workbench) | 内嵌式 VS Code 编辑器(`@codingame/monaco-vscode-api`),带 WXML 语言特性与 dimina 配置的 JSON Schema | | [`@dimina-kit/inspect`](./packages/inspect) | 与宿主无关的 WXML 树提取与检查:Vue 运行时遍历、稳定 id、DOM 变更观察 | | [`@dimina-kit/view-anchor`](./packages/view-anchor) | 让主进程的原生视图(Electron `WebContentsView`)持续对齐某个 DOM 元素的几何位置 | +| [`@devicekit/devices`](https://www.npmjs.com/package/@devicekit/devices) | 手机和平板的机型表:屏幕、像素比、状态栏、安全区、挖孔、UA,以及页面可用尺寸的换算。不碰 DOM。已独立为开源项目([EchoTechFE/devicekit](https://github.com/EchoTechFE/devicekit)) | +| [`@devicekit/frame`](https://www.npmjs.com/package/@devicekit/frame) | `` 自定义元素:画机身、状态栏和挖孔,留出标题栏和 tab 栏,报出 webview 该摆在哪。已独立为开源项目([EchoTechFE/devicekit](https://github.com/EchoTechFE/devicekit)) | | [`@dimina-kit/design`](./packages/design) | devtools 那套外观:CSS 变量、基础样式、electron-deck 皮肤、Tailwind preset | | [`@dimina-kit/fs-core`](./packages/fs-core) | 零依赖的 OPFS WAL 文件系统内核,供 Web 端使用 | diff --git a/packages/devtools/docs/ios-safe-area-and-notch.md b/packages/devtools/docs/ios-safe-area-and-notch.md index 02c967fa..abc0d5a0 100644 --- a/packages/devtools/docs/ios-safe-area-and-notch.md +++ b/packages/devtools/docs/ios-safe-area-and-notch.md @@ -1,188 +1,102 @@ -# iOS Notch / Dynamic Island + CSS `env(safe-area-inset-*)` (native-host simulator) +# 刘海 / 灵动岛、`env(safe-area-inset-*)` 与 JS `safeArea`(native-host simulator) -The native-host simulator reproduces iOS devices with a notch / Dynamic Island, -so that: +simulator 要把选中的设备当真机来模拟,做到三件事: -1. The device bezel shows a visual **notch / Dynamic Island** + a status bar - (time / signal / battery), matching the selected device profile. -2. A mini-program page laid out edge-to-edge resolves CSS - `env(safe-area-inset-top|right|bottom|left)` to the device's real insets, so - pinned headers / tabBars / action sheets avoid the notch and home indicator - exactly as on-device. -3. CSS insets follow the selected device profile. The public JS APIs currently - take different paths and do not all return the same `safeArea`; see below. +1. 设备外壳画出**刘海 / 灵动岛**和状态栏(时间、信号、电池),和选中的机型一致。 +2. 贴边布局的小程序页面里,CSS `env(safe-area-inset-top|right|bottom|left)` 解析成该机型的真实内边距,吸顶头部、tabBar、action sheet 像真机一样避开刘海和 Home 指示条。 +3. JS 侧 `wx.getSystemInfoSync().safeArea` 和 CSS 内边距来自同一份机型数据。 -## Single source of truth: device profile +## 单一数据源:`@devicekit/devices` 机型表 -`DEVICES` in `src/renderer/shared/constants.ts` is the single source of truth. -Each entry carries the notch / safe-area fields: +机型数据不再写在 devtools 里。`@devicekit/devices` 提供 171 台机型的 `DeviceProfile`,每条带 `os`、`screen`(竖屏尺寸)、`pixelRatio`、`statusBarHeight`、`safeAreaInsets`,横屏机型另带 `safeAreaInsetsLandscape`。刘海/灵动岛不再靠 `notchType` 枚举描述,而是由 `@devicekit/frame` 根据机型名和屏幕类别自己画。 -```ts -notchType: 'none' | 'notch' | 'dynamic-island' -safeAreaInsets: { top: number; right: number; bottom: number; left: number } +devtools 只用其中几个入口: + +| 入口 | 用途 | +|---|---| +| `CLASSIC_DEVICES` | 工具栏设备下拉只列这份精选子集(≤20 台,同一批对象,按 iOS → Android → HarmonyOS 分组) | +| `findDevice(name)` / `DEFAULT_DEVICE` | 按名字回查机型;找不到时回落到默认机型 | +| `resolveDevice` / `statusBarHeightFor` / `safeAreaInsetsFor` | 按当前横竖屏解析出已经旋转过的数值 | +| `PLATFORM_DEFAULTS` | 第一份设备信息到达前,DeviceShell 用平台默认状态栏高度占位 | + +## 设备信息流 + +``` +工具栏设备 / 横竖屏选择(renderer,use-device.ts) + → setNativeDeviceInfo(NativeDeviceInfo) ipc-schemas.ts 校验 + ├→ bridge 缓存 + DEVICE_CHANGE → simulator WCV(DeviceShell → ) + ├→ safe-area service 对每个 render-host guest 重发 CDP override + └→ HostEnvUpdate → service-host hostEnvSnapshot(getSystemInfoSync 等同步 API) ``` -`statusBarHeight` is kept = `safeAreaInsets.top` for `getSystemInfoSync` -back-compat; `safeAreaInsets.top` is the canonical value. Seeded data: +`NativeDeviceInfo`(`packages/dimina-electron-runtime/src/shared/runtime-types.ts`)里的数值**已经按当前方向解析好**:横屏时 `screenWidth/screenHeight` 已交换,`safeAreaInsets` 是横屏那一组。字段: + +- `device`:机型表里的名字,自定义或旧版 payload 没有这项; +- `platform`:`'ios' | 'android' | 'harmony'`,`orientation`:`'portrait' | 'landscape'`; +- `screenWidth`、`screenHeight`、`pixelRatio`、`statusBarHeight`、`safeAreaInsets`。 + +`deviceInfoToHostEnv`(`packages/dimina-electron-runtime/src/shared/host-env.ts`)是**唯一**从设备数值推导窗口信息的地方,同步 `getSystemInfoSync`、异步 `getSystemInfo`、spawn 时的 host-env 快照和 fe 的 `hostEnvUpdate` 都用它的结果:`windowWidth = screenWidth`、`windowHeight = screenHeight − safeAreaInsets.top − safeAreaInsets.bottom`(与 dimina iOS / Android / Harmony 三端 native 一致)、`screenTop = statusBarHeight`,并透传 `safeAreaInsets`、`deviceOrientation` 和下文的 `safeArea` 矩形。切设备时 bridge 的 `setDevice` 会对每个运行中的小程序发 `hostEnvUpdate` 给 service(fe 的 `host-env.js` 合并快照),所以 `wx.getWindowInfo()` 不重启也跟着变。 + +## 视觉:状态栏、刘海、Home 指示条 + +`src/simulator/device-shell/device-shell.tsx` 不再自己画状态栏。它把 `NativeDeviceInfo` 交给 `@devicekit/frame/react` 的 `DeviceFrame`: + +- `device` 有名字时按名字取表;没有名字时用 `fallbackProfile()` 把当前方向的数值反算回竖屏 `DeviceProfile`,交给 `deviceProfile` 属性; +- `orientation`、`embedded`、`statusBarTextStyle` 直接透传。状态栏文字颜色由 `MiniAppFrame` 的 `statusBar` render prop 通过 `StatusBarTextStyleBridge` 在 effect 里回传,避免渲染期 setState。 -| device | notchType | top | bottom | -|---------------|------------------|-----|--------| -| iPhone SE | none | 20 | 0 | -| iPhone X | notch | 44 | 34 | -| iPhone 14 | notch | 47 | 34 | -| iPhone 14 Pro | dynamic-island | 54 | 34 | -| iPhone 16 Pro | dynamic-island | 59 | 34 | -| iPhone 17 Pro | dynamic-island | 59 | 34 | +`MiniAppFrame` 只需要知道顶部要让出 `statusBarHeight`(取 `safeAreaInsets.top`)、底部要让出 `bottomInset`(取 `safeAreaInsets.bottom`),刘海、灵动岛和 Home 指示条都由 frame 画。NavigationBar 的平台样式跟随 `device.platform`(iOS 用 iOS 样式,Android/HarmonyOS 用 Android 样式),只有设备到达前回落到 `miniApp.platform`。 -`left` / `right` are `0` in portrait (landscape is out of scope). +外壳几何由 `frameOuterSize(profile, orientation)` 决定(屏幕尺寸 + 2 × 边框),renderer 的面板宽度和自动缩放都从它推导,见 `project-runtime/lib/device-geometry.ts`。 -## Device-info flow (native-host) +## CSS `env(safe-area-inset-*)` 注入:CDP `Emulation.setSafeAreaInsetsOverride` -The selected device reaches the simulator UI/API state, the render-host CDP -override, and the service-host snapshot. All three update when the user switches -device: +`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 重新申请。 +- **重发时机**:(1) guest attach(页面栈新页面),(2) 设备或横竖屏切换(对所有已 attach 的 guest 重发)。 +- **只注入 webview 真正贴着的边**,页面自己的 `env()` padding 不会和外壳已覆盖的区域重复计算(`guestInsets()`): + - `top`:自定义导航栏页(`navigationStyle: custom`,页面全出血到屏幕顶部)取设备当前方向的 `safeAreaInsets.top`;默认导航栏页为 0,因为 webview 本来就从外壳导航栏下方开始,三端 native 也是这样。 + - `bottom`:tab 页为 0(外壳 tabBar 的背景延伸到底部内边距,页面内容不贴底);非 tab 页取 `safeAreaInsets.bottom`(页面全出血到设备底部,自己用 `env(safe-area-inset-bottom)` 避让)。 + - `left` / `right`:直接取设备当前方向的 `safeAreaInsets.left/right`,横屏灵动岛机型不再是 0。 +- **`webContents.debugger` 独占**。外部工具(`--remote-debugging-port`)已经 attach 时 `attach()` 会抛错,只记警告、内边距保持 0,没有纯 CSS 回退。 + +## 底部安全区:一个机制 + +Home 指示条由 frame 画,是绝对定位的透明覆盖层,不占布局空间。底部安全区由谁填充取决于页面: + +- tab 页:外壳 tabBar 的背景延伸过底部内边距(`padding-bottom = safeAreaInsets.bottom`,`tab-bar.tsx`),指示条压在 tabBar 颜色上。 +- 非 tab 页:页面 webview 全出血到设备底部,指示条压在页面内容上。 + +因为 tab 页已经由外壳让出底部,其 `env(safe-area-inset-bottom)` 被覆盖成 0,避免页面重复避让。 + +## JS `safeArea` + +`safeArea` 是屏幕坐标下的矩形,不是内边距,由 `deviceInfoToHostEnv` 生成并随快照下发: ``` -toolbar device picker (renderer) - → SimulatorChannel.SetDeviceInfo (src/main/ipc/simulator.ts) - ├→ bridge caches device + DEVICE_CHANGE - │ → simulator WCV / DeviceShell visual state - │ → SimulatorMiniApp.currentDevice for async system-info handlers - ├→ safe-area service re-applies CDP override to each render-host webview - └→ HostEnvUpdate → service-host hostEnvSnapshot for sync handlers +left = insets.left +top = insets.top +right = screenWidth − insets.right +bottom = screenHeight − insets.bottom +width = right − left +height = windowHeight (= screenHeight − insets.top − insets.bottom) ``` -- **Transport renderer → simulator.** The simulator is a top-level - `WebContentsView` (not a `` of the main window), so device changes go - via IPC, not `webview.send`. The toolbar picker drives - `SimulatorChannel.SetDeviceInfo`; main caches it on the bridge and relays - `DEVICE_CHANGE` to the live `simulatorWc`. `SimulatorMiniApp` records the - event for simulator-resident API handlers, while DeviceShell subscribes and - re-renders. Before the first live event, the initial device comes from the - `NATIVE_HOST_ENABLED` boot config cached by the bridge. -- **DeviceShell device prop = single `device` object** (dims + platform + - notchType + safeAreaInsets), initialized from `miniApp.getInitialDevice()` and - updated from `SIMULATOR_EVENTS.DEVICE_CHANGE`. DeviceShell re-renders; the WCV - bounds track the bezel rect via the layout pipeline. - -## Visual: status bar + notch / Dynamic Island - -The **status bar** is the first child of `.device-shell` (above the nav-bar), -`flex: 0 0 {safeAreaInsets.top}px`: - -- Left: time (static `9:41`). Right: signal / wifi / battery glyphs - (`.device-shell__status-icons`). -- The nav-bar is the 44pt title row beneath the status bar. - -Notch shape (centered, overlapping the status bar), driven by `notchType`: - -- `none`: nothing (SE-class). Status bar full width. -- `notch`: a black rounded pill anchored to the top, centered, ~`160×30`, bottom - corners rounded. Status icons sit on either side. -- `dynamic-island`: a smaller black pill (~`125×37`), fully rounded, small top - margin (~`11px`), centered. Status icons on either side. - -It renders inside `.device-shell` (the positioning context), clipped by -`border-radius: 38px; overflow: hidden`. The notch shape is keyed by `notchType` -in `status-bar.tsx` so visual + safe-area stay consistent. - -## CSS `env(safe-area-inset-*)` injection — CDP `Emulation.setSafeAreaInsetsOverride` - -`env(safe-area-inset-*)` is UA-defined and cannot be overridden by an author -stylesheet, so the inset comes from CDP. `src/main/services/safe-area/index.ts` -sends `Emulation.setSafeAreaInsetsOverride` per render-host `` guest, -driven off the simulator WCV's **`did-attach-webview`** event — the earliest -point each guest `WebContents` is available, before the page paints. - -- The `wc.debugger` session itself is not owned by safe-area: it goes through - the shared `CdpSessionBroker` (`src/main/services/cdp-session/index.ts`). Its - six service consumers are safe-area, elements-forward, render-inspect, - network-forward, console-forward's CDP injection, and simulator-storage. - (`service-console` attaches to service-host separately.) `wc.debugger` is a - single-owner API, so without the broker any two - of them attaching independently would steal/detach each other's session on - the same guest. Per guest: `broker.acquire(wc)` - returns a `CdpSessionLease`; safe-area calls - `lease.send('Emulation.setSafeAreaInsetsOverride', { insets })` on it. `insets` - carries **all 8 fields** (`top/topMax/right/rightMax/bottom/bottomMax/left/ - leftMax`, base == max) — omitting `*Max` leaves `env(safe-area-max-inset-*)` - at 0. Safe-area tracks each guest's page type in its own `Map` - (`isTabPage`, independent of the lease), and drops it on the guest's - `destroyed` event; the lease itself is dropped on the broker's `onDetach` - (an external tool stealing the session, or the wc dying) so the next - `override` re-acquires instead of sending through a dead lease. -- **Re-apply triggers:** (1) guest attach (new page in the stack), (2) device - change (reapply to all attached guests). -- **Inject only what the webview actually borders**, so the page's own `env()` - padding never double-counts a region the shell already covers: - - `top` = custom-nav page ? `device.safeAreaInsets.top` : `0` (the default nav - bar covers the notch with its opaque bar). - - `bottom` is per page TYPE: - - tab page → `0`. The shell tabBar extends its background through the bottom - inset; the page content sits above the tabBar and never borders the bottom - unsafe zone. - - non-tab page → `device.safeAreaInsets.bottom`. The page is full-bleed to - the device bottom; the shell reserves nothing, so the page opts in via its - own `env(safe-area-inset-bottom)`. The page type is read from the - render-host URL's `isTab` flag, captured in `will-attach-webview` - (`guestWc.getURL()` is empty at `did-attach`) and stored per guest so a - device-change reapply reuses it. - - `left` / `right` = `0` (portrait). - This keeps CSS `env()` aligned with the unsafe region actually bordering the - page webview. JS `safeArea` follows separate paths described below. -- **`webContents.debugger` is exclusive.** If an external tool - (`--remote-debugging-port`) is attached to the render-host guest, `attach()` - throws and we cannot take over its session — log a warning and leave insets at - 0. There is no CSS-only fallback. - -## Bottom safe area — one mechanism - -The home-indicator pill (`.device-shell__home-indicator`) is an absolute overlay -pinned to the device bottom — it reserves no layout space and is transparent. -What fills the bottom safe area depends on the page: - -- *tab page* → the shell tabBar's `background` extends through the bottom inset - (`padding-bottom` = `safeAreaInsets.bottom`, `tab-bar.tsx`), so the strip is - the tabBar's color and the pill sits on it. -- *non-tab page* → the page webview is full-bleed to the device bottom (no - reserved strip); the pill overlays the page content. - -Because the DeviceShell already reserves the bottom, the page's -`env(safe-area-inset-bottom)` is overridden to 0 on tab pages — the page's own -`env(bottom)` must not double-count. - -## JS `safeArea`: the public APIs currently diverge - -`safeArea.bottom` is a coordinate, not an inset. On a device with a home -indicator it should equal `windowHeight - safeAreaInsets.bottom`. The current -public paths are: - -| Public API | Resolution path | Current result | -| --- | --- | --- | -| `wx.getSystemInfoSync()` | `sync-api-patch.ts` → service-host `sync-impls/system-info.ts` | includes `safeArea`, but sets `bottom = windowHeight` | -| `wx.getWindowInfo()` | upstream service `hostEnvResolvers.getWindowInfo` reads the service-host `HostEnvSnapshot` locally | does not include `safeArea`, because the snapshot has no such field | -| `wx.getSystemInfo()` / `wx.getSystemInfoAsync()` | bridge `invokeAPI` → simulator `buildSystemInfo()` | includes the device bottom inset and sets `bottom = windowHeight - bottomInset` | - -The simulator also exposes a local `getWindowInfo` handler whose -`safeArea.bottom` is `windowBounds.height`, but a normal business call does not -reach it: upstream service intercepts `getWindowInfo` in `hostEnvResolvers` -before bridge dispatch. In contrast, the async system-info APIs are not local -host-env resolvers and do reach `buildSystemInfo()`. - -The initial snapshot and later `HostEnvUpdate` payload are built by `deviceInfoToHostEnv` in -`packages/dimina-electron-runtime/src/shared/bridge-channels.ts`; it carries -`statusBarHeight` but neither `safeAreaInsets` nor `safeArea`. The similarly -named devtools file only re-exports that runtime module. CSS -`env(safe-area-inset-bottom)` is independent of these JS paths and comes from -the CDP override. - -## Key files - -| file | role | +`src/service-host/sync-impls/system-info.ts` 收到带 `safeArea` 的快照时原样透出,同时输出 `screenTop` 和 `deviceOrientation`;旧版快照没有 `safeArea` 时才退回按 `safeAreaInsets` 或 `statusBarHeight` 自行推导。`wx.getWindowInfo()` 由上游 service 的 `hostEnvResolvers` 从同一份快照挑字段,因此含 `safeArea` 和 `screenTop`。 + +已知差异:`platform` 对 HarmonyOS 机型给的是 `harmony`,微信和 dimina native 都是 `ohos`。 + +## 关键文件 + +| 文件 | 作用 | |---|---| -| `src/renderer/shared/constants.ts` | `DEVICES` profile (notchType + safeAreaInsets) | -| `src/main/ipc/simulator.ts` | `SimulatorChannel.SetDeviceInfo` → bridge cache → `DEVICE_CHANGE`; sends `deviceInfoToHostEnv` | -| `packages/dimina-electron-runtime/src/shared/bridge-channels.ts` | `deviceInfoToHostEnv` (device profile → service-host host-env) | -| `src/main/services/safe-area/index.ts` | per-guest `Emulation.setSafeAreaInsetsOverride` (driven off `did-attach-webview`) | -| `src/simulator/device-shell/status-bar.tsx` | status bar + notch / Dynamic Island visual | -| `src/service-host/sync-impls/system-info.ts` | `getSystemInfoSync().safeArea` | +| [`@devicekit/devices`](https://www.npmjs.com/package/@devicekit/devices) | 机型表、`CLASSIC_DEVICES`、`resolveDevice` / `statusBarHeightFor` / `safeAreaInsetsFor` | +| [`@devicekit/frame`](https://www.npmjs.com/package/@devicekit/frame) | ``:外壳、状态栏、刘海/灵动岛、Home 指示条、`frameOuterSize` | +| `src/renderer/.../project-runtime/controllers/use-device.ts` | 设备/横竖屏选择 → `NativeDeviceInfo` → `setNativeDeviceInfo` | +| `src/renderer/.../project-runtime/lib/device-geometry.ts` | 由 `frameOuterSize` 推导面板宽度 | +| `src/main/ipc/simulator.ts` | `SetDeviceInfo` → bridge 缓存 → `DEVICE_CHANGE`;`deviceInfoToHostEnv` | +| `packages/dimina-electron-runtime/src/shared/host-env.ts` | `deviceInfoToHostEnv` / `makeHostEnvUpdateMessage`(`NativeDeviceInfo` → `HostEnvSnapshot`) | +| `src/main/services/safe-area/index.ts` | 每个 guest 的 `Emulation.setSafeAreaInsetsOverride`(含 left/right) | +| `src/simulator/device-shell/device-shell.tsx` | 把 `NativeDeviceInfo` 接到 `DeviceFrame` + `MiniAppFrame` | +| `src/service-host/sync-impls/system-info.ts` | `getSystemInfoSync().safeArea` / `deviceOrientation` | diff --git a/packages/devtools/docs/native-bridge-protocol.md b/packages/devtools/docs/native-bridge-protocol.md index 15af50be..cb02e177 100644 --- a/packages/devtools/docs/native-bridge-protocol.md +++ b/packages/devtools/docs/native-bridge-protocol.md @@ -255,7 +255,7 @@ simulator 的 NavigationBar 由 DeviceShell 渲染;胶囊(capsule)的尺 **视觉**(`packages/dimina-electron-runtime/src/simulator-ui/navigation-bar.tsx`、`menu-capsule.tsx`、`navigation-bar.css`、`menu-capsule.css`): -- status bar 高度:iOS 44、Android 24。DeviceShell 的视觉布局直接取平台常量 `STATUS_BAR_HEIGHT_IOS = 44` / `STATUS_BAR_HEIGHT_ANDROID = 24`(`device-shell.tsx`,按 `platform` 选用);同一组值另由 `hostEnvSnapshot`(`simulator-mini-app.ts`,`statusBarHeight = ios?44:24`)在 spawn 时下发给 service-host 的 sync 实现(getSystemInfo / 胶囊 geometry)。nav bar 高度 44(`NavigationBarProps.navBarHeight`) +- status bar 高度:来自选中机型(`@devicekit/devices` 的 `statusBarHeight` / `safeAreaInsets.top`,按横竖屏解析),由 renderer 的 `use-device.ts` 打包成 `NativeDeviceInfo` 下发;DeviceShell 把它交给 `` 画状态栏,并作为 `MiniAppFrame.statusBarHeight` 让出布局空间;同一份数值经 `deviceInfoToHostEnv` 进入 `hostEnvSnapshot` 供 service-host 的 `getSystemInfoSync` 使用。第一份设备信息到达前用 `PLATFORM_DEFAULTS[platform].statusBarHeight` 占位。详见 [iOS 安全区与刘海](./ios-safe-area-and-notch.md)。 - 标题对齐:iOS center / Android left(`titleAlign`) - 返回箭头(`stackDepth > 1`)/ 返回首页按钮(`homeButtonVisible`,两者可并存)。返回首页按钮的判据收敛在 `shouldShowHomeButton`(`navigate-home.ts`):非应用首页(manifest `entryPagePath`,缺省 `pages[0]`)+ 非 tabBar 页(页面配置 `homeButton: true` 也不能突破这两条排除),且「页面栈栈底(自动规则)或页面配置 `homeButton: true`(此时与返回箭头并存显示)」;`wx.hideHomeButton()` 隐藏调用页自己的按钮。点击返回首页:首页是 tabBar 页走 switchTab(保留其它 tab 状态并露出 tabBar,自带清非 tab 栈),首页非 tab 时栈底走 redirectTo 原地替换、非栈底走 reLaunch 清整栈——路由判定收敛在 `resolveHomeNavAction`,`DeviceShell.handleHome` 只负责分发。注意 tab 分支回给调用方的动词虽是 `switchTab`,终态归约走的却是 `reduceNavigateHomeToTab` 而非普通 `reduceSwitchTab`——后者会还原目标 tab 的缓存子栈(可能落在子栈栈顶的内页),前者落 tab 根页、把每个 tab 裁到根、并销毁所有非 tab 页。已经在首页时整个动作幂等短路(`isAtHome`),不重开也不发多余生命周期 - loading 转圈(show/hideNavigationBarLoading → `state.loading`) diff --git a/packages/devtools/e2e/device-frame-integration.spec.ts b/packages/devtools/e2e/device-frame-integration.spec.ts new file mode 100644 index 00000000..69afcd23 --- /dev/null +++ b/packages/devtools/e2e/device-frame-integration.spec.ts @@ -0,0 +1,396 @@ +/** + * E2E (native-host): the simulator machine body is `` + * (the shared @devicekit/frame package), driven by the toolbar's + * device/orientation selects through the SAME `setNativeDeviceInfo` IPC path + * that feeds the mini-app's own `wx.getSystemInfoSync()`. + * + * This pins the real user path end to end: + * toolbar ` (the only ` -> setNativeDeviceInfo IPC -> main process + * host-env + `hostEnvUpdate` push into the RUNNING dimina service -> what the + * page's own JS (`wx.getWindowInfo` / `wx.getSystemInfo(Sync)`) and the page's + * own CSS (`env(safe-area-inset-*)`) observe. + * + * Expected numbers: + * - iPhone 15 portrait values are written out literally, straight from + * FIX-SPEC-window-info.md I1's worked example (screen 393x852, statusBar 54, + * insets top 59 / bottom 34 in @devicekit/devices). Spelling them out is + * deliberate: importing `deviceInfoToHostEnv` here would make the spec agree + * with the implementation by construction instead of pinning the contract. + * Before the fix `wx.getWindowInfo()` answered with the spawn-time iPhone X + * constants {375, 812, 375, 768, 44} and had no safeArea/screenTop at all. + * - The second device (Pixel 8) is read from the @devicekit/devices table and + * run through I1's formula, so a table edit can't silently desync the spec. + */ +import { test, expect, _electron, type ElectronApplication, type Page as PwPage } from '@playwright/test' +import path from 'path' +import fs from 'fs' +import { fileURLToPath } from 'url' +import { + openProjectInUI, + waitForSimulatorWebview, + waitSimulatorReady, + closeProject, + ipcInvoke, + pollUntil, + evalInSimulator, + evalInWebContentsByUrl, + RENDER_GUEST_URL_MARKER, + findMainWindow, +} from './helpers' +import { AutomationChannel } from '../src/shared/ipc-channels' +import { + DEVICE_NAMES, + findDevice, + resolveDevice, + orientedScreen, + safeAreaInsetsFor, + statusBarHeightFor, +} from '@devicekit/devices' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const FIXTURE_DIR = path.resolve(__dirname, 'fixtures', 'tabbar-app') +const SERVICE_URL_MARKER = 'service-host/service.html' + +let electronApp: ElectronApplication +let mainWindow: PwPage +let workbench: PwPage + +// ── Expected values ──────────────────────────────────────────────────── +/** FIX-SPEC I1's worked example for iPhone 15 portrait. */ +const IPHONE_15_WINDOW_INFO = { + pixelRatio: 3, + screenWidth: 393, + screenHeight: 852, + windowWidth: 393, + windowHeight: 759, + statusBarHeight: 54, + safeArea: { left: 0, top: 59, right: 393, bottom: 818, width: 393, height: 759 }, + 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' } +const CUSTOM_NAV_INSETS = { top: '59px', bottom: '34px' } + +/** The fields the three code paths (sync binding, async simulator handler, + * spawn snapshot) must agree on — FIX-SPEC I3. */ +type WindowFields = Pick< + typeof IPHONE_15_WINDOW_INFO, + 'screenWidth' | 'screenHeight' | 'windowWidth' | 'windowHeight' | 'statusBarHeight' | 'safeArea' | 'screenTop' +> + +function windowFieldsOf(info: Record): WindowFields { + return { + screenWidth: info.screenWidth as number, + screenHeight: info.screenHeight as number, + windowWidth: info.windowWidth as number, + windowHeight: info.windowHeight as number, + statusBarHeight: info.statusBarHeight as number, + safeArea: info.safeArea as WindowFields['safeArea'], + screenTop: info.screenTop as number, + } +} + +const IPHONE_15_SHARED_FIELDS = windowFieldsOf(IPHONE_15_WINDOW_INFO) + +// ── Toolbar driving ──────────────────────────────────────────────────── +async function selectDevice(win: PwPage, deviceName: string): Promise { + const sel = win.locator('select', { has: win.locator(`option[value="${deviceName}"]`) }).first() + await sel.selectOption(deviceName) +} + +async function waitForFrameDevice(app: ElectronApplication, deviceName: string): Promise { + await pollUntil( + () => evalInSimulator(app, `(() => { + const el = document.querySelector('device-frame') + return el ? el.getAttribute('device') : null + })()`).catch(() => null), + (name) => name === deviceName, + 15000, + 300, + ) +} + +// ── service-host readbacks ───────────────────────────────────────────── +/** `wx.getWindowInfo()` called synchronously inside the running service, the + * way a page's own JS calls it. Serialized whole so nothing is dropped. */ +async function readWindowInfo(app: ElectronApplication): Promise> { + const json = await evalInWebContentsByUrl(app, SERVICE_URL_MARKER, `(() => { + const w = globalThis.wx + if (!w || typeof w.getWindowInfo !== 'function') throw new Error('wx.getWindowInfo missing') + return JSON.stringify(w.getWindowInfo()) + })()`) + return JSON.parse(json) as Record +} + +async function readSystemInfoSync(app: ElectronApplication): Promise> { + const json = await evalInWebContentsByUrl(app, SERVICE_URL_MARKER, `(() => { + const w = globalThis.wx + if (!w || typeof w.getSystemInfoSync !== 'function') throw new Error('wx.getSystemInfoSync missing') + return JSON.stringify(w.getSystemInfoSync()) + })()`) + return JSON.parse(json) as Record +} + +/** + * Async `wx.getSystemInfo({ success })`. Every settlement path is collected + * over a fixed window rather than first-wins: in this runtime the returned + * promise resolves `undefined` while the real payload arrives on `success`, + * so a naive `await` would read nothing. + */ +async function readSystemInfoAsync(app: ElectronApplication): Promise<{ + success?: Record + events: Array<{ tag: string; res: unknown }> +}> { + const json = await evalInWebContentsByUrl(app, SERVICE_URL_MARKER, `(() => { + const w = globalThis.wx + if (!w || typeof w.getSystemInfo !== 'function') throw new Error('wx.getSystemInfo missing') + return new Promise((resolve) => { + const events = [] + setTimeout(() => resolve(JSON.stringify({ events })), 6000) + try { + const ret = w.getSystemInfo({ + success: (res) => events.push({ tag: 'success', res }), + fail: (res) => events.push({ tag: 'fail', res }), + }) + if (ret && typeof ret.then === 'function') { + ret.then( + (res) => events.push({ tag: 'promise', res: res === undefined ? null : res }), + (err) => events.push({ tag: 'promise-reject', res: String(err) }), + ) + } + } catch (err) { + events.push({ tag: 'threw', res: String(err) }) + } + }) + })()`) + const parsed = JSON.parse(json) as { events: Array<{ tag: string; res: unknown }> } + const success = parsed.events.find((e) => e.tag === 'success') + return { success: success?.res as Record | undefined, events: parsed.events } +} + +async function navigateTo(app: ElectronApplication, url: string): Promise { + await evalInWebContentsByUrl(app, SERVICE_URL_MARKER, `(() => { + globalThis.wx.navigateTo({ url: ${JSON.stringify(url)} }) + return true + })()`) +} + +// ── render-guest readbacks ───────────────────────────────────────────── +interface GuestInsets { + /** First 60 chars of the guest's body text, used to tell page guests apart. */ + marker: string + /** env(safe-area-inset-*) read off a probe this spec injects. */ + probeTop: string + probeBottom: string + /** Same values read off the fixture page's OWN CSS, where it has probes + * (pages/custom/custom.wxss) — the actual user path for env(). */ + pageTop: string | null + pageBottom: string | null +} + +const GUEST_INSETS_EXPR = `(() => { + const probe = document.createElement('div') + probe.style.cssText = 'position:fixed;left:0;top:0;width:0;height:0;' + + 'padding-top:env(safe-area-inset-top);padding-bottom:env(safe-area-inset-bottom);' + document.body.appendChild(probe) + const cs = getComputedStyle(probe) + const pageTopEl = document.querySelector('.safe-probe-top') + const pageBottomEl = document.querySelector('.safe-probe-bottom') + const out = { + marker: (document.body ? document.body.innerText : '').slice(0, 60), + probeTop: cs.paddingTop, + probeBottom: cs.paddingBottom, + pageTop: pageTopEl ? getComputedStyle(pageTopEl).paddingTop : null, + pageBottom: pageBottomEl ? getComputedStyle(pageBottomEl).paddingBottom : null, + } + probe.remove() + return out +})()` + +/** + * Measure EVERY live render-host guest. `evalInWebContentsByUrl` only reaches + * the first match, which stops being the visible page the moment a + * `wx.navigateTo` stacks a second guest. Each `executeJavaScript` races a + * deadline because a call issued against a guest that is being torn down never + * settles and would otherwise hang until the test timeout. + */ +async function measureAllGuests(app: ElectronApplication): Promise { + return app.evaluate(async ({ webContents }, payload) => { + const withDeadline = (promise: Promise): Promise => Promise.race([ + promise, + new Promise((resolve) => setTimeout(() => resolve(null), 3000)), + ]) + const guests = webContents.getAllWebContents() + .filter((wc) => !wc.isDestroyed() && wc.getURL().includes(payload.marker) && !wc.isLoading()) + const out: unknown[] = [] + for (const wc of guests) { + const value = await withDeadline(wc.executeJavaScript(payload.expression)) + if (value) out.push(value) + } + return out + }, { marker: RENDER_GUEST_URL_MARKER, expression: GUEST_INSETS_EXPR }) as Promise +} + +/** Wait for the guest whose page content contains `text`, then hand it back. */ +async function guestShowing(app: ElectronApplication, text: string): Promise { + const guests = await pollUntil( + () => measureAllGuests(app).catch(() => []), + (list) => list.some((g) => g.marker.includes(text)), + 20000, + 500, + ) + return guests.find((g) => g.marker.includes(text))! +} + +test.describe('window info follows the selected device', () => { + test.describe.configure({ mode: 'serial' }) + test.setTimeout(240_000) + + test.beforeAll(async () => { + const appPath = path.resolve(__dirname, 'electron-entry.js') + const userDataDir = path.resolve( + process.env.DIMINA_DEVTOOLS_DATA_DIR + ?? path.resolve(__dirname, '..', 'node_modules', '.cache', 'devtools-e2e'), + 'userdata', + `window-info-${process.pid}`, + ) + fs.mkdirSync(userDataDir, { recursive: true }) + + // DIMINA_NATIVE_HOST is scoped to THIS launch, never `process.env` at + // module scope — a top-level mutation would flip every other spec sharing + // the --workers=1 runner into native-host mode. + electronApp = await _electron.launch({ + args: [appPath, 'auto', '--auto-port', '0', `--user-data-dir=${userDataDir}`], + env: { ...process.env, NODE_ENV: 'test', DIMINA_NATIVE_HOST: '1', DIMINA_E2E_USER_DATA_DIR: userDataDir }, + }) + + mainWindow = await findMainWindow(electronApp) + await mainWindow.waitForLoadState('domcontentloaded') + + await electronApp.evaluate(async ({ BrowserWindow }) => { + const win = BrowserWindow.getAllWindows()[0] + if (win && !win.isVisible()) { + await new Promise((resolve) => { + win.once('show', resolve) + setTimeout(resolve, 5000) + }) + } + if (win) { + win.setPosition(-2000, -2000) + win.blur() + } + }) + + await pollUntil( + () => ipcInvoke(mainWindow, AutomationChannel.GetPort), + (val) => typeof val === 'number' && val > 0, + 10000, + 100, + ) + + workbench = await openProjectInUI(electronApp, FIXTURE_DIR, { waitMs: 20000 }) + await waitForSimulatorWebview(electronApp) + await pollUntil( + () => evalInSimulator( + electronApp, + `(() => !!document.querySelector('.device-shell-root'))()`, + ).catch(() => false), + (ok) => ok === true, + 25000, + 300, + ) + await waitSimulatorReady(electronApp) + + // The mini-app booted on the toolbar's default device (iPhone X). Every + // test below runs against iPhone 15 selected AFTERWARDS, with no restart — + // that is the whole point: the running service must re-read the device. + await selectDevice(workbench, DEVICE_NAMES.iPhone_15) + await waitForFrameDevice(electronApp, DEVICE_NAMES.iPhone_15) + }) + + test.afterAll(async () => { + await closeProject(electronApp).catch(() => {}) + await electronApp?.close().catch(() => {}) + }) + + test('1. wx.getWindowInfo() reports the device selected after boot', async () => { + const info = await pollUntil( + () => readWindowInfo(electronApp).catch(() => null), + (i) => i !== null && i.screenWidth === IPHONE_15_WINDOW_INFO.screenWidth, + 20000, + 400, + ) + expect( + info, + 'wx.getWindowInfo() must answer with the CURRENTLY selected device; ' + + 'the spawn-time iPhone X constants (375x812, statusBarHeight 44, no safeArea) mean ' + + 'the hostEnvUpdate push never reached the running service', + ).toEqual(IPHONE_15_WINDOW_INFO) + }) + + test('2. wx.getSystemInfoSync() agrees with getWindowInfo on the window fields', async () => { + const info = await readSystemInfoSync(electronApp) + expect(windowFieldsOf(info)).toEqual(IPHONE_15_SHARED_FIELDS) + }) + + test('3. async wx.getSystemInfo() agrees with the sync answer', async () => { + const { success, events } = await readSystemInfoAsync(electronApp) + expect( + success, + `wx.getSystemInfo success never fired; settlement events: ${JSON.stringify(events)}`, + ).toBeTruthy() + 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 () => { + // 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) + + 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) + }) + + test('5. a navigationStyle:custom page sees the full top inset', async () => { + await navigateTo(electronApp, '/pages/custom/custom') + const custom = await guestShowing(electronApp, 'CUSTOM NAV PAGE') + expect( + { top: custom.probeTop, bottom: custom.probeBottom }, + 'custom navigation bar: the page draws its own header, so it owns the cutout inset', + ).toEqual(CUSTOM_NAV_INSETS) + // The same numbers through the page's OWN wxss (pages/custom/custom.wxss + // declares padding-top/bottom: env(safe-area-inset-*)), which is how a real + // mini-app consumes env(). + expect( + { top: custom.pageTop, bottom: custom.pageBottom }, + 'the fixture page\'s own env()-based padding should resolve to the same insets', + ).toEqual(CUSTOM_NAV_INSETS) + }) + + test('6. switching to a second device again moves the window info', async () => { + const profile = findDevice(DEVICE_NAMES.Pixel_8) + expect(profile, 'sanity: Pixel 8 must exist in the @devicekit/devices table').toBeTruthy() + const resolved = resolveDevice(profile!) + const screen = orientedScreen(profile!, 'portrait') + const insets = safeAreaInsetsFor(resolved, 'portrait') + const statusBarHeight = statusBarHeightFor(resolved, 'portrait') + // FIX-SPEC I1's formula, re-derived here from the device table. + const windowHeight = screen.height - insets.top - insets.bottom + + await selectDevice(workbench, DEVICE_NAMES.Pixel_8) + await waitForFrameDevice(electronApp, DEVICE_NAMES.Pixel_8) + + const info = await pollUntil( + () => readWindowInfo(electronApp).catch(() => null), + (i) => i !== null && i.screenWidth === screen.width, + 20000, + 400, + ) + expect(info).toEqual({ + pixelRatio: resolved.pixelRatio, + screenWidth: screen.width, + screenHeight: screen.height, + windowWidth: screen.width, + windowHeight, + statusBarHeight, + safeArea: { + left: insets.left, + top: insets.top, + right: screen.width - insets.right, + bottom: screen.height - insets.bottom, + width: screen.width - insets.right - insets.left, + height: windowHeight, + }, + screenTop: statusBarHeight, + }) + expect( + info!.windowHeight, + 'windowHeight must stay screenHeight minus both vertical insets after the second switch', + ).toBe(windowHeight) + }) +}) diff --git a/packages/devtools/package.json b/packages/devtools/package.json index 83b6ff1c..0651b3fe 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -109,6 +109,8 @@ "spike:popout": "electron spike/popout/harness.mjs" }, "dependencies": { + "@devicekit/devices": "^0.2.1", + "@devicekit/frame": "^0.2.1", "@dimina-kit/design": "workspace:*", "@dimina-kit/devkit": "workspace:*", "@dimina-kit/electron-deck": "workspace:*", diff --git a/packages/devtools/src/main/services/safe-area/index.test.ts b/packages/devtools/src/main/services/safe-area/index.test.ts index 4f681811..6850fb4d 100644 --- a/packages/devtools/src/main/services/safe-area/index.test.ts +++ b/packages/devtools/src/main/services/safe-area/index.test.ts @@ -77,7 +77,7 @@ describe('createSafeAreaController teardown routing', () => { const controller = createSafeAreaController({ connections }) const wc = makeWc(7) - controller.applyToGuest(wc, null, false) + controller.applyToGuest(wc, null, { isTabPage: false, isCustomNav: false }) // The connection was acquired for this guest. expect(connections.get(wc.id), 'guest connection must be live before destroy').toBeDefined() @@ -109,7 +109,7 @@ describe('createSafeAreaController per-page-type bottom inset', () => { it('a non-tab page gets the real bottom inset (page opts in via env)', () => { const sink: Array<{ method: string; params: unknown }> = [] const controller = createSafeAreaController() - controller.applyToGuest(makeWc(1, sink), DEVICE, false) + controller.applyToGuest(makeWc(1, sink), DEVICE, { isTabPage: false, isCustomNav: true }) const insets = lastInsets(sink) expect(insets.top).toBe(47) expect(insets.bottom).toBe(34) @@ -119,7 +119,7 @@ describe('createSafeAreaController per-page-type bottom inset', () => { it('a tab page gets bottom 0 (the shell tabBar fills the safe area)', () => { const sink: Array<{ method: string; params: unknown }> = [] const controller = createSafeAreaController() - controller.applyToGuest(makeWc(2, sink), DEVICE, true) + controller.applyToGuest(makeWc(2, sink), DEVICE, { isTabPage: true, isCustomNav: true }) const insets = lastInsets(sink) expect(insets.top).toBe(47) expect(insets.bottom).toBe(0) @@ -130,8 +130,8 @@ describe('createSafeAreaController per-page-type bottom inset', () => { const sinkTab: Array<{ method: string; params: unknown }> = [] const sinkPage: Array<{ method: string; params: unknown }> = [] const controller = createSafeAreaController() - controller.applyToGuest(makeWc(3, sinkTab), DEVICE, true) - controller.applyToGuest(makeWc(4, sinkPage), DEVICE, false) + controller.applyToGuest(makeWc(3, sinkTab), DEVICE, { isTabPage: true, isCustomNav: true }) + controller.applyToGuest(makeWc(4, sinkPage), DEVICE, { isTabPage: false, isCustomNav: true }) sinkTab.length = 0 sinkPage.length = 0 controller.reapplyAll(DEVICE) @@ -147,7 +147,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, false) + controller.applyToGuest(wc, DEVICE, { isTabPage: false, isCustomNav: true }) expect(lastInsets(sink).bottom).toBe(34) // Something outside safe-area detaches the shared debugger session @@ -163,11 +163,47 @@ describe('createSafeAreaController per-page-type bottom inset', () => { }) }) +// device.safeAreaInsets carries per-edge insets straight from the +// @devicekit/devices table (e.g. iPhone 15 landscape: left/right 59 from the +// Dynamic Island rotating into a side notch). `guestInsets` must forward +// right/left instead of hardcoding 0. +const LANDSCAPE_DEVICE = { safeAreaInsets: { top: 0, right: 59, bottom: 21, left: 59 } } as never + +describe('createSafeAreaController per-edge left/right insets', () => { + function lastFullInsets(sink: Array<{ method: string; params: unknown }>) { + const call = [...sink].reverse().find((c) => c.method === 'Emulation.setSafeAreaInsetsOverride') + return (call?.params as { + insets: { top: number; right: number; rightMax: number; bottom: number; left: number; leftMax: number } + }).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 }) + 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', () => { + const sink: Array<{ method: string; params: unknown }> = [] + const controller = createSafeAreaController() + controller.applyToGuest(makeWc(9, sink), LANDSCAPE_DEVICE, { isTabPage: true, isCustomNav: false }) + const insets = lastFullInsets(sink) + expect(insets.bottom).toBe(0) + expect(insets.left).toBe(59) + expect(insets.right).toBe(59) + }) +}) + 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, false) + controller.applyToGuest(wc, null, { isTabPage: false, isCustomNav: false }) expect(wc.debugger.attach).toHaveBeenCalled() controller.dispose() @@ -179,7 +215,7 @@ describe('createSafeAreaController broker ownership', () => { const broker = createCdpSessionBroker() const wc = makeWc(7) const controller = createSafeAreaController({ broker }) - controller.applyToGuest(wc, null, false) + controller.applyToGuest(wc, null, { isTabPage: false, isCustomNav: false }) expect(wc.debugger.attach).toHaveBeenCalled() controller.dispose() @@ -188,3 +224,68 @@ describe('createSafeAreaController broker ownership', () => { expect(wc.debugger.detach).not.toHaveBeenCalled() }) }) + +// A default-navigation-bar page's guest already starts BELOW the shell-drawn +// navigation bar, which itself covers the notch — the same layout dimina's +// native containers produce. Surfacing the device top inset there would push +// the page content down a second time. Only a custom-nav (full-bleed) page +// borders the unsafe top zone and needs the real inset. +describe('createSafeAreaController per-page navigation-style top 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; topMax: number; bottom: number; bottomMax: number } + }).insets + } + + 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 }) + const insets = lastInsets(sink) + expect(insets.top).toBe(0) + expect(insets.topMax).toBe(0) + }) + + 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 }) + 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', () => { + 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 }) + + expect(lastInsets(defaultNavTab).bottom).toBe(0) + expect(lastInsets(defaultNavPage).bottom).toBe(34) + expect(lastInsets(customNavTab).bottom).toBe(0) + 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 }) + defaultNav.length = 0 + customNav.length = 0 + + const NEXT_DEVICE = { safeAreaInsets: { top: 59, right: 0, bottom: 34, left: 0 } } as never + controller.reapplyAll(NEXT_DEVICE) + + expect(lastInsets(defaultNav).top).toBe(0) + expect(lastInsets(customNav).top).toBe(59) + }) +}) diff --git a/packages/devtools/src/main/services/safe-area/index.ts b/packages/devtools/src/main/services/safe-area/index.ts index 4164b336..8cc330de 100644 --- a/packages/devtools/src/main/services/safe-area/index.ts +++ b/packages/devtools/src/main/services/safe-area/index.ts @@ -13,17 +13,23 @@ import { createCdpSessionBroker, type CdpSessionBroker, type CdpSessionLease } f * `` guest). Driven from `did-attach-webview` so the value resolves * before the page paints. * - * The DeviceShell reserves the TOP chrome (status/nav) for every page, so we - * always surface the TOP inset — a full-bleed / custom-nav page needs it to - * clear the notch. The BOTTOM inset is per page TYPE (WeChat parity): + * The TOP inset is per navigation STYLE, matching what dimina's native + * containers lay out: + * - default navigation bar → the shell draws the nav bar, which already + * covers the notch, and the guest starts below it → TOP 0. Surfacing the + * device inset here would push the page content down a second time. + * - custom navigation bar → the guest is full-bleed to the device top and + * borders the unsafe zone itself → the real TOP inset. + * The BOTTOM inset is per page TYPE (WeChat parity): * - tab page → the shell draws the tabBar and extends its background through * the home-indicator safe area; the guest (page content sits ABOVE the * tabBar) does not border the bottom unsafe zone → BOTTOM 0. * - non-tab page → the guest is full-bleed to the device bottom, so surface * the real bottom inset and let the page opt in via its own * `env(safe-area-inset-bottom)`; the shell reserves nothing there. - * The attaching guest's page type is read from its render-host URL (`isTab`) - * in view-manager's `did-attach-webview`. (Design doc: docs/ios-safe-area-and-notch.md.) + * Both come off the attaching guest's render-host URL (`isTab`, `navStyle`), + * read in view-manager's `did-attach-webview`. + * (Design doc: docs/ios-safe-area-and-notch.md.) */ /** The 8-field CDP `SafeAreaInsets` shape (base + *Max). Omitting `*Max` leaves @@ -39,24 +45,58 @@ interface CdpSafeAreaInsets { leftMax: number } -function guestInsets(device: NativeDeviceInfo | null, isTabPage: boolean): CdpSafeAreaInsets { - const top = device?.safeAreaInsets.top ?? 0 +/** What the guest's own page contributes to the inset policy: whether the + * shell draws a tabBar under it, and whether it draws a navigation bar over + * the notch for it. */ +export interface GuestPageInsetPolicy { + isTabPage: boolean + isCustomNav: boolean +} + +/** + * Read a guest's inset policy off its render-host URL (`isTab`, `navStyle` — + * both written by `buildRenderHostDocumentUrl`). An unparseable URL degrades to + * the default-nav, non-tab policy rather than failing the attach. + */ +export function parseGuestPageInsetPolicy(src: string): GuestPageInsetPolicy { + try { + const params = new URL(src).searchParams + return { + isTabPage: params.get('isTab') === '1', + isCustomNav: params.get('navStyle') === 'custom', + } + } catch { + return { isTabPage: false, isCustomNav: false } + } +} + +function guestInsets(device: NativeDeviceInfo | null, page: GuestPageInsetPolicy): CdpSafeAreaInsets { + // Only a custom-nav (full-bleed) page borders the unsafe top zone; a + // default-nav page already starts below the shell nav bar, which covers the + // notch itself. + const top = page.isCustomNav ? (device?.safeAreaInsets.top ?? 0) : 0 // A tab page's content sits above the shell-drawn tabBar (which fills the // bottom safe area), so it never borders the bottom unsafe zone. A non-tab // page is full-bleed to the device bottom, so surface the real inset for its // own `env(safe-area-inset-bottom)` opt-in. - const bottom = isTabPage ? 0 : (device?.safeAreaInsets.bottom ?? 0) - return { top, topMax: top, right: 0, rightMax: 0, bottom, bottomMax: bottom, left: 0, leftMax: 0 } + const bottom = page.isTabPage ? 0 : (device?.safeAreaInsets.bottom ?? 0) + // Left/right come straight from the device (e.g. a landscape Dynamic Island + // rotates into a side notch) — unlike top/bottom they don't depend on page + // type, since neither shell chrome nor the tabBar reserves horizontal space. + const right = device?.safeAreaInsets.right ?? 0 + const left = device?.safeAreaInsets.left ?? 0 + return { top, topMax: top, right, rightMax: right, bottom, bottomMax: bottom, left, leftMax: left } } export interface SafeAreaController { /** Attach the debugger to a freshly-attached render-host guest and push the - * current device's insets. `isTabPage` selects the bottom-inset policy (0 for - * tab pages, the real inset for full-bleed non-tab pages). No-op (warn) if the - * guest is already claimed by an external CDP client — env then stays 0. */ - applyToGuest(guestWc: WebContents, device: NativeDeviceInfo | null, isTabPage: boolean): void + * current device's insets. `page` selects the top policy (real inset only for + * a custom-nav page) and the bottom policy (0 for tab pages, the real inset + * for full-bleed non-tab pages). No-op (warn) if the guest is already claimed + * by an external CDP client — env then stays 0. */ + applyToGuest(guestWc: WebContents, device: NativeDeviceInfo | null, page: GuestPageInsetPolicy): void /** Re-push insets to every still-attached guest after a device change (each - * guest keeps the page type it attached with). */ + * guest keeps the page policy it attached with). */ reapplyAll(device: NativeDeviceInfo | null): void /** Release this controller's session leases (teardown). Does not itself * detach the shared debugger session — see cdp-session/index.ts. */ @@ -73,11 +113,11 @@ export function createSafeAreaController(options: { connections?: ConnectionRegi // independently testable/usable. const broker = options.broker ?? createCdpSessionBroker({ connections: options.connections }) - // Each guest's page type, fixed for its life — tracked SEPARATELY from the + // Each guest's page policy, fixed for its life — tracked SEPARATELY from the // lease so a lost session (external detach) doesn't lose the policy: a // later `override`/`reapplyAll` can reacquire and keep applying the same - // isTabPage this guest attached with. - const pageType = new Map() + // policy this guest attached with. + const pageType = new Map() // Current lease per guest, if any. Cleared (not just left stale) on // `lease.onDetach` — an external detach or a real Chrome DevTools window // stealing the session — so the next `override` reacquires instead of @@ -95,7 +135,7 @@ export function createSafeAreaController(options: { connections?: ConnectionRegi return lease } - function override(wc: WebContents, device: NativeDeviceInfo | null, isTabPage: boolean): void { + function override(wc: WebContents, device: NativeDeviceInfo | null, page: GuestPageInsetPolicy): void { if (wc.isDestroyed()) return const lease = ensureLease(wc) if (!lease) { @@ -106,17 +146,17 @@ export function createSafeAreaController(options: { connections?: ConnectionRegi return } void lease - .send('Emulation.setSafeAreaInsetsOverride', { insets: guestInsets(device, isTabPage) }) + .send('Emulation.setSafeAreaInsetsOverride', { insets: guestInsets(device, page) }) .catch((err: unknown) => { console.warn('[safe-area] setSafeAreaInsetsOverride failed:', err instanceof Error ? err.message : err) }) } return { - applyToGuest: (wc, device, isTabPage) => { + applyToGuest: (wc, device, page) => { if (!wc || wc.isDestroyed()) return const isFirstTime = !pageType.has(wc) - pageType.set(wc, isTabPage) + pageType.set(wc, page) if (isFirstTime) { const forget = (): void => { pageType.delete(wc); leases.delete(wc) } if (options.connections) { @@ -125,10 +165,10 @@ export function createSafeAreaController(options: { connections?: ConnectionRegi wc.once('destroyed', forget) } } - override(wc, device, isTabPage) + override(wc, device, page) }, reapplyAll: (device) => { - for (const [wc, isTabPage] of pageType) override(wc, device, isTabPage) + for (const [wc, page] of pageType) override(wc, device, page) }, dispose: () => { // Release our leases only — the shared session's actual detach is the diff --git a/packages/devtools/src/main/services/views/native-simulator-view.ts b/packages/devtools/src/main/services/views/native-simulator-view.ts index ee51efa3..68ba51c6 100644 --- a/packages/devtools/src/main/services/views/native-simulator-view.ts +++ b/packages/devtools/src/main/services/views/native-simulator-view.ts @@ -9,7 +9,8 @@ import { handleCustomApiBridgeRequest, type CustomApiBridgeRequest, } from '../simulator/custom-apis.js' -import type { SafeAreaController } from '../safe-area/index.js' +import type { GuestPageInsetPolicy, SafeAreaController } from '../safe-area/index.js' +import { parseGuestPageInsetPolicy } from '../safe-area/index.js' import { configureMiniappSession, miniappPartition } from './miniapp-partition.js' import { refreshGuestStylesheets } from './refresh-styles.js' import { parseRoute } from '../../../shared/simulator-route.js' @@ -280,23 +281,22 @@ export function createNativeSimulatorView( // them with contextIsolation/sandbox off so the render runtime + its preload // share the page realm. (A top-level WebContentsView can host these guests; a // `` guest cannot — that's the whole point of Option A.) - // Page type (`isTab`) of each attaching guest, captured from its render-host - // URL in will-attach (where `params.src` carries the full URL) and consumed - // FIFO in the matching did-attach — `guestWc.getURL()` is still empty there. + // Inset policy (`isTab`, `navStyle`) of each attaching guest, captured from + // its render-host URL in will-attach (where `params.src` carries the full + // URL) and consumed FIFO in the matching did-attach — `guestWc.getURL()` is + // still empty there. // Per-attach scope: a fresh simWc + handlers are built on every (re)attach. // (The guest's `bgColor` query param — WeChat/Android/Harmony white-flash // parity — is consumed entirely outside main: device-shell.tsx's `` // CSS background and render-host/preload.cjs both read it directly, since // `WebContents` has no `setBackgroundColor` for main to call here.) - const pendingGuestIsTab: boolean[] = [] + const pendingGuestPages: GuestPageInsetPolicy[] = [] simWc.on('will-attach-webview', (_event, webPreferences, params) => { ;(webPreferences as Electron.WebPreferences).partition = partition params.partition = partition webPreferences.contextIsolation = false ;(webPreferences as Electron.WebPreferences).sandbox = false - let isTab = false - try { isTab = new URL(params.src).searchParams.get('isTab') === '1' } catch { /* keep false */ } - pendingGuestIsTab.push(isTab) + pendingGuestPages.push(parseGuestPageInsetPolicy(params.src)) }) simWc.on('did-attach-webview', (_event, guestWc) => { // Scale the nested render-host page with the device zoom. The host WCV is @@ -309,13 +309,13 @@ export function createNativeSimulatorView( guestWc.setZoomFactor(currentZoomFactor) } catch { /* guest not ready; setNativeSimulatorViewBounds re-applies */ } // Simulate this device's CSS env(safe-area-inset-*) on the fresh guest - // before it paints, so notch-aware page layout resolves correctly. The - // bottom inset is page-type-dependent (see services/safe-area): a tab - // page's content sits above the shell tabBar (bottom 0); a non-tab page - // is full-bleed (real bottom inset). The page type was captured from the + // before it paints, so notch-aware page layout resolves correctly. Both + // the top and the bottom inset depend on the page (see services/safe-area): + // only a custom-nav page borders the unsafe top zone, and only a non-tab + // page borders the bottom one. The policy was captured from the // render-host URL in will-attach (FIFO). - const isTabGuest = pendingGuestIsTab.shift() ?? false - safeArea.applyToGuest(guestWc, ctx.bridge?.getDevice() ?? null, isTabGuest) + const guestPage = pendingGuestPages.shift() ?? { isTabPage: false, isCustomNav: false } + safeArea.applyToGuest(guestWc, ctx.bridge?.getDevice() ?? null, guestPage) // Page-level resource loads (images/fonts/page fetch) run in THIS guest's // network stack, never the simulator's — without this, only wx.request // (forwarded to the simulator) shows in the Network panel and everything diff --git a/packages/devtools/src/preload/runtime/native-host.ts b/packages/devtools/src/preload/runtime/native-host.ts index 755d7251..21e029bb 100644 --- a/packages/devtools/src/preload/runtime/native-host.ts +++ b/packages/devtools/src/preload/runtime/native-host.ts @@ -28,6 +28,10 @@ export interface RenderHostUrlOptions { /** Whether this page is a tabBar page. Surfaced on the URL so main can pick * the bottom safe-area policy at `did-attach-webview` (services/safe-area). */ isTab?: boolean + /** The page's resolved `navigationStyle` (page ∪ app-level). Surfaced on the + * URL as `navStyle` so main can pick the TOP safe-area policy at + * `did-attach-webview` (services/safe-area). */ + navigationStyle?: 'default' | 'custom' /** The page's resolved `window.backgroundColor` (page ∪ app-level, already * defaulted — see `pageBackgroundColor` in page-stack-controller.ts). * Surfaced on the URL as `bgColor`: render-host/preload.cjs reads it and diff --git a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-auto-zoom.test.tsx b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-auto-zoom.test.tsx index 733a3f0d..573aff29 100644 --- a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-auto-zoom.test.tsx +++ b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-auto-zoom.test.tsx @@ -36,8 +36,9 @@ vi.mock('@dimina-kit/view-anchor', () => ({ })) import { SimulatorPanel } from './simulator-panel' +import { DEVICE_NAMES, findDevice } from '@devicekit/devices' -const DEVICE = { name: 'iPhone X', width: 375, height: 812 } +const DEVICE = findDevice(DEVICE_NAMES.iPhone_X)! const publisher = { set: vi.fn(), @@ -93,13 +94,14 @@ describe('SimulatorPanel: auto-fit zoom', () => { bind.opts.publish({ visible: true, - bounds: { x: 0, y: 0, width: DEVICE.width, height: DEVICE.height }, + bounds: { x: 0, y: 0, width: DEVICE.screen.width, height: DEVICE.screen.height }, }) // The DeviceShell desk contributes 24px padding on both sides and the - // handset has a 1px border on both edges. 88% is the largest whole percent - // that keeps the complete framed handset inside this 375x812 region. - expect(lastZoom()).toBe(88) + // frame adds its bezel + border on both edges (frameOuterSize). 85% is the + // largest whole percent that keeps the complete framed handset inside this + // 375x812 region. + expect(lastZoom()).toBe(85) const zoomSelect = container.querySelectorAll('select')[1] expect(zoomSelect?.className).toContain('w-[76px]') @@ -112,13 +114,13 @@ describe('SimulatorPanel: auto-fit zoom', () => { const bind = anchorCalls[0]! publisher.set.mockClear() - // The desk frame leaves a 44% whole-percent fit for a half-size region. + // The desk frame leaves a 42% whole-percent fit for a half-size region. bind.opts.publish({ visible: true, - bounds: { x: 0, y: 0, width: DEVICE.width / 2, height: DEVICE.height / 2 }, + bounds: { x: 0, y: 0, width: DEVICE.screen.width / 2, height: DEVICE.screen.height / 2 }, }) - expect(lastZoom()).toBe(44) + expect(lastZoom()).toBe(42) }) it('recomputes on every re-measure without any additional listener', () => { @@ -127,16 +129,16 @@ describe('SimulatorPanel: auto-fit zoom', () => { bind.opts.publish({ visible: true, - bounds: { x: 0, y: 0, width: DEVICE.width / 2, height: DEVICE.height / 2 }, + bounds: { x: 0, y: 0, width: DEVICE.screen.width / 2, height: DEVICE.screen.height / 2 }, }) - expect(lastZoom()).toBe(44) + expect(lastZoom()).toBe(42) // Panel widened — the SAME publish callback (no new observer) re-derives. bind.opts.publish({ visible: true, - bounds: { x: 0, y: 0, width: DEVICE.width, height: DEVICE.height }, + bounds: { x: 0, y: 0, width: DEVICE.screen.width, height: DEVICE.screen.height }, }) - expect(lastZoom()).toBe(88) + expect(lastZoom()).toBe(85) }) it('caps the computed zoom at 100 even when the box is far larger than the device', () => { @@ -145,7 +147,7 @@ describe('SimulatorPanel: auto-fit zoom', () => { bind.opts.publish({ visible: true, - bounds: { x: 0, y: 0, width: DEVICE.width * 3, height: DEVICE.height * 3 }, + bounds: { x: 0, y: 0, width: DEVICE.screen.width * 3, height: DEVICE.screen.height * 3 }, }) expect(lastZoom()).toBe(100) @@ -156,16 +158,16 @@ describe('SimulatorPanel: auto-fit zoom', () => { const bind = anchorCalls[0]! bind.opts.publish({ visible: true, - bounds: { x: 0, y: 0, width: DEVICE.width / 2, height: DEVICE.height / 2 }, + bounds: { x: 0, y: 0, width: DEVICE.screen.width / 2, height: DEVICE.screen.height / 2 }, }) - expect(lastZoom()).toBe(44) + expect(lastZoom()).toBe(42) rerender(panelElement(75)) // A resize after switching back must NOT recompute — it stays at the fixed 75. bind.opts.publish({ visible: true, - bounds: { x: 0, y: 0, width: DEVICE.width * 3, height: DEVICE.height * 3 }, + bounds: { x: 0, y: 0, width: DEVICE.screen.width * 3, height: DEVICE.screen.height * 3 }, }) expect(lastZoom()).toBe(75) }) @@ -176,27 +178,27 @@ describe('SimulatorPanel: auto-fit zoom', () => { bind.opts.publish({ visible: true, - bounds: { x: 0, y: 0, width: DEVICE.width / 2, height: DEVICE.height / 2 }, + bounds: { x: 0, y: 0, width: DEVICE.screen.width / 2, height: DEVICE.screen.height / 2 }, }) - expect(lastZoom()).toBe(44) + expect(lastZoom()).toBe(42) bind.opts.publish({ visible: true, bounds: { x: 0, y: 0, width: 0, height: 0 } }) - expect(lastZoom()).toBe(44) + expect(lastZoom()).toBe(42) }) it('recomputes against the new device size after switching devices while auto is selected', () => { const { rerender } = render(panelElement(AUTO_ZOOM)) const bind = anchorCalls[0]! - // The original device plus its desk frame fits this box at 88%. + // The original device plus its desk frame fits this box at 85%. bind.opts.publish({ visible: true, - bounds: { x: 0, y: 0, width: DEVICE.width, height: DEVICE.height }, + bounds: { x: 0, y: 0, width: DEVICE.screen.width, height: DEVICE.screen.height }, }) - expect(lastZoom()).toBe(88) + expect(lastZoom()).toBe(85) // Switch to a device twice as wide/tall — the same box now only fits it at 46%. - const BIGGER_DEVICE = { name: 'iPhone 16 Pro Max (test double)', width: DEVICE.width * 2, height: DEVICE.height * 2 } + const BIGGER_DEVICE = { ...DEVICE, name: 'iPhone 16 Pro Max (test double)', screen: { width: DEVICE.screen.width * 2, height: DEVICE.screen.height * 2 } } rerender( { ) bind.opts.publish({ visible: true, - bounds: { x: 0, y: 0, width: DEVICE.width, height: DEVICE.height }, + bounds: { x: 0, y: 0, width: DEVICE.screen.width, height: DEVICE.screen.height }, }) expect(lastZoom()).toBe(46) }) @@ -228,7 +230,7 @@ describe('SimulatorPanel: auto-fit zoom', () => { // turn into a panel-width change, so the geometry ResizeObserver alone // cannot be relied on to trigger a re-measure. The zoom-effect's // dependency array must include `device` so update() is still forced. - const SAME_WIDTH_SHORTER_DEVICE = { name: 'iPhone SE', width: DEVICE.width, height: 667 } + const SAME_WIDTH_SHORTER_DEVICE = { ...DEVICE, name: DEVICE_NAMES.iPhone_SE, screen: { width: DEVICE.screen.width, height: 667 } } rerender(panelElement(AUTO_ZOOM, SAME_WIDTH_SHORTER_DEVICE)) expect(anchor.handle.update).toHaveBeenCalled() diff --git a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-collapse-on-deactivate.test.tsx b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-collapse-on-deactivate.test.tsx index 4424dd54..106d1c04 100644 --- a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-collapse-on-deactivate.test.tsx +++ b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-collapse-on-deactivate.test.tsx @@ -45,8 +45,9 @@ vi.mock('@dimina-kit/view-anchor', () => ({ })) import { SimulatorPanel } from './simulator-panel' +import { DEVICE_NAMES, findDevice } from '@devicekit/devices' -const DEVICE = { name: 'iPhone X', width: 375, height: 812 } +const DEVICE = findDevice(DEVICE_NAMES.iPhone_X)! const publisher = { set: vi.fn(), diff --git a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-compiling-indicator.test.tsx b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-compiling-indicator.test.tsx index fd64f3b4..1068d861 100644 --- a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-compiling-indicator.test.tsx +++ b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-compiling-indicator.test.tsx @@ -26,8 +26,9 @@ vi.mock('@dimina-kit/view-anchor', () => ({ })) import { SimulatorPanel } from './simulator-panel' +import { DEVICE_NAMES, findDevice } from '@devicekit/devices' -const DEVICE = { name: 'iPhone X', width: 375, height: 812 } +const DEVICE = findDevice(DEVICE_NAMES.iPhone_X)! const publisher = { set: vi.fn(), diff --git a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-fallback-banner.test.tsx b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-fallback-banner.test.tsx index 1540360a..2f3c2a14 100644 --- a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-fallback-banner.test.tsx +++ b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-fallback-banner.test.tsx @@ -28,12 +28,13 @@ vi.mock('@dimina-kit/view-anchor', () => ({ })) import { SimulatorPanel } from './simulator-panel' +import { DEVICE_NAMES, findDevice } from '@devicekit/devices' type PanelPropsWithRuntime = Parameters[0] & { runtimeStatus: SessionRuntimeStatusPayload | null } -const DEVICE = { name: 'iPhone X', width: 375, height: 812 } +const DEVICE = findDevice(DEVICE_NAMES.iPhone_X)! const REQUESTED = 'pages/removed/removed' const RESOLVED = 'pages/index/index' diff --git a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-follow-layout-reorder.test.tsx b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-follow-layout-reorder.test.tsx index cfb24904..e5e03341 100644 --- a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-follow-layout-reorder.test.tsx +++ b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-follow-layout-reorder.test.tsx @@ -82,8 +82,9 @@ vi.mock('@dimina-kit/electron-deck/dock-react', () => ({ })) import { SimulatorPanel } from './simulator-panel' +import { DEVICE_NAMES, findDevice } from '@devicekit/devices' -const DEVICE = { name: 'iPhone X', width: 375, height: 812 } +const DEVICE = findDevice(DEVICE_NAMES.iPhone_X)! /** The simulator native slot's anchor (bound on the `native-simulator` region). */ function simulatorAnchor() { diff --git a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-internal-devtools-button.test.tsx b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-internal-devtools-button.test.tsx index 4dc37974..9a3bba87 100644 --- a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-internal-devtools-button.test.tsx +++ b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-internal-devtools-button.test.tsx @@ -22,8 +22,9 @@ vi.mock('@dimina-kit/view-anchor', () => ({ })) import { SimulatorPanel } from './simulator-panel' +import { DEVICE_NAMES, findDevice } from '@devicekit/devices' -const DEVICE = { name: 'iPhone X', width: 375, height: 812 } +const DEVICE = findDevice(DEVICE_NAMES.iPhone_X)! const publisher = { set: vi.fn(), diff --git a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-page-path-bar.test.tsx b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-page-path-bar.test.tsx index 67a485fa..e86cbb3e 100644 --- a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-page-path-bar.test.tsx +++ b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-page-path-bar.test.tsx @@ -15,6 +15,7 @@ vi.mock('@dimina-kit/view-anchor', () => ({ })) import { SimulatorPanel } from './simulator-panel' +import { DEVICE_NAMES, findDevice } from '@devicekit/devices' const publisher = { set: vi.fn(), @@ -27,7 +28,7 @@ const publisher = { function panel(currentPage: string) { const props: Parameters[0] = { - device: { name: 'iPhone 15', width: 393, height: 852 }, + device: findDevice(DEVICE_NAMES.iPhone_15)!, zoom: 100, onDeviceChange: () => {}, onZoomChange: () => {}, diff --git a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-runtime-error-overlay.test.tsx b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-runtime-error-overlay.test.tsx index b27c3dab..1e2b5f41 100644 --- a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-runtime-error-overlay.test.tsx +++ b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel-runtime-error-overlay.test.tsx @@ -29,12 +29,13 @@ vi.mock('@dimina-kit/view-anchor', () => ({ })) import { SimulatorPanel } from './simulator-panel' +import { DEVICE_NAMES, findDevice } from '@devicekit/devices' type PanelPropsWithRuntime = Parameters[0] & { runtimeStatus: SessionRuntimeStatusPayload | null } -const DEVICE = { name: 'iPhone X', width: 375, height: 812 } +const DEVICE = findDevice(DEVICE_NAMES.iPhone_X)! const publisher = { set: vi.fn(), diff --git a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel.test.tsx b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel.test.tsx new file mode 100644 index 00000000..5001a939 --- /dev/null +++ b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel.test.tsx @@ -0,0 +1,109 @@ +/** + * SimulatorPanel's device/orientation pickers against the @devicekit/devices + * table: the device (portrait/landscape) reports changes via onOrientationChange. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, cleanup, fireEvent } from '@testing-library/react' +import type { Placement } from '@dimina-kit/view-anchor' +import type { PlacementPublisher } from '@dimina-kit/electron-deck/client' +import { CLASSIC_DEVICES, DEFAULT_DEVICE, DEVICES } from '@devicekit/devices' +import { PlacementPublisherContext } from '@/shared/placement-publisher-context' + +interface AnchorHandle { + update: ReturnType + dispose: ReturnType + pulse: ReturnType +} +vi.mock('@dimina-kit/view-anchor', () => ({ + createPlacementAnchor: ( + _el: HTMLElement, + _opts: { visible: boolean; followGeometry?: boolean; guardDisplayNone?: boolean; publish: (p: Placement) => void }, + ): AnchorHandle => ({ update: vi.fn(), dispose: vi.fn(), pulse: vi.fn() }), +})) + +import { SimulatorPanel } from './simulator-panel' + +const publisher = { + set: vi.fn(), + remove: vi.fn(), + dispose: vi.fn(), +} as unknown as PlacementPublisher<{ zoom?: number }> + +function panelElement(onOrientationChange: (o: 'portrait' | 'landscape') => void = () => {}) { + return ( + + {}} + onOrientationChange={onOrientationChange} + onZoomChange={() => {}} + compileStatus={{ status: 'ready', message: '' }} + currentPage="pages/index/index" + copied={false} + onCopyPagePath={() => {}} + /> + + ) +} + +beforeEach(() => { + cleanup() +}) + +describe('SimulatorPanel: device picker grouped by platform', () => { + it('lists three optgroups labelled iOS / Android / HarmonyOS', () => { + const { container } = render(panelElement()) + const groups = Array.from(container.querySelectorAll('optgroup')) + expect(groups.map((g) => g.getAttribute('label')).sort()).toEqual( + ['Android', 'HarmonyOS', 'iOS'].sort(), + ) + }) + + it('lists exactly the classic subset, each once, and not the full table', () => { + const { container } = render(panelElement()) + const options = Array.from(container.querySelectorAll('optgroup option'), (o) => (o as HTMLOptionElement).value) + expect(options).toEqual(CLASSIC_DEVICES.map((d) => d.name)) + expect(options.length).toBeLessThan(DEVICES.length) + }) + + it('puts each classic device under the optgroup of its own platform', () => { + const { container } = render(panelElement()) + for (const group of Array.from(container.querySelectorAll('optgroup'))) { + const os = { iOS: 'ios', Android: 'android', HarmonyOS: 'harmony' }[group.getAttribute('label') ?? ''] + for (const o of Array.from(group.querySelectorAll('option'))) { + expect(CLASSIC_DEVICES.find((d) => d.name === o.value)?.os, o.value).toBe(os) + } + } + }) +}) + +describe('SimulatorPanel: orientation picker', () => { + function findOrientationSelect(container: HTMLElement): HTMLSelectElement | undefined { + return Array.from(container.querySelectorAll('select')).find( + (el) => + el.querySelector('option[value="portrait"]') && + el.querySelector('option[value="landscape"]'), + ) + } + + it('renders a select offering portrait and landscape', () => { + const { container } = render(panelElement()) + const select = findOrientationSelect(container) + expect(select).toBeTruthy() + }) + + it('reports the new orientation via onOrientationChange', () => { + const onOrientationChange = vi.fn() + const { container } = render(panelElement(onOrientationChange)) + const select = findOrientationSelect(container)! + + fireEvent.change(select, { target: { value: 'landscape' } }) + + expect(onOrientationChange).toHaveBeenCalledWith('landscape') + }) +}) diff --git a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel.tsx b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel.tsx index 36b20a11..39c8b016 100644 --- a/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel.tsx +++ b/packages/devtools/src/renderer/modules/main/features/project-runtime/components/simulator-panel.tsx @@ -18,10 +18,16 @@ import { useDockLayoutEpoch } from "@dimina-kit/electron-deck/dock-react"; import { cn } from "@/shared/lib/utils"; import { AUTO_ZOOM, - DEVICES, + SIM_PANEL_PADDING, ZOOM_OPTIONS, type ZoomSetting, } from "@/shared/constants"; +import { frameOuterSize } from "@devicekit/frame"; +import { + CLASSIC_DEVICES, + type DeviceProfile, + type Orientation, +} from "@devicekit/devices"; import { FallbackBanner, RuntimeErrorOverlay, @@ -29,16 +35,21 @@ import { type SimulatorRuntimeStatus, } from "./simulator-runtime-banners"; -interface Device { - name: string; - width: number; - height: number; -} +// The toolbar dropdown can't fit the full 171-device table, so it only +// offers CLASSIC_DEVICES, grouped by platform in the order that list is +// already sorted in (iOS → Android → HarmonyOS). +const DEVICE_GROUPS: Array<{ label: string; devices: readonly DeviceProfile[] }> = [ + { label: "iOS", devices: CLASSIC_DEVICES.filter((d) => d.os === "ios") }, + { label: "Android", devices: CLASSIC_DEVICES.filter((d) => d.os === "android") }, + { label: "HarmonyOS", devices: CLASSIC_DEVICES.filter((d) => d.os === "harmony") }, +]; interface SimulatorPanelProps { - device: Device; + device: DeviceProfile; + orientation?: Orientation; zoom: ZoomSetting; onDeviceChange: (e: React.ChangeEvent) => void; + onOrientationChange?: (orientation: Orientation) => void; onZoomChange: (e: React.ChangeEvent) => void; compileStatus: { status: string; message: string }; /** Visible page as `pagePath?k=v&…`, shown in the page-path bar with params @@ -58,10 +69,11 @@ interface SimulatorPanelProps { onOpenInternalDevtools?: () => void; } -// DeviceShell's scrollable desk reserves 24px on each edge and the handset has -// a 1px border on each edge. Auto-fit must include this fixed frame or a phone -// that numerically matches its region still overflows by a few pixels. -const AUTO_FIT_FRAME = 2 * (24 + 1); +// DeviceShell's scrollable desk reserves SIM_PANEL_PADDING on each edge around +// the frame's outer box (screen + bezel + border, see frameOuterSize). Auto-fit +// must include this fixed margin or a phone that numerically matches its region +// still overflows by a few pixels. +const AUTO_FIT_FRAME = 2 * SIM_PANEL_PADDING; // Resolves the auto-fit zoom percent from the measured device-region box: the // largest whole-percent scale (capped at 100) that lets the framed device fit @@ -72,21 +84,23 @@ const AUTO_FIT_FRAME = 2 * (24 + 1); // collapsing to 0. function computeAutoZoom( bounds: Bounds, - device: { width: number; height: number }, + frame: { width: number; height: number }, fallback: number, ): number { if (bounds.width <= 0 || bounds.height <= 0) return fallback; const ratio = Math.min( - bounds.width / (device.width + AUTO_FIT_FRAME), - bounds.height / (device.height + AUTO_FIT_FRAME), + bounds.width / (frame.width + AUTO_FIT_FRAME), + bounds.height / (frame.height + AUTO_FIT_FRAME), ); return Math.max(1, Math.min(100, Math.floor(ratio * 100))); } export function SimulatorPanel({ device, + orientation = "portrait", zoom, onDeviceChange, + onOrientationChange = () => {}, onZoomChange, compileStatus, currentPage, @@ -136,8 +150,9 @@ export function SimulatorPanel({ // `publish` via ref so that callback's identity can stay pinned to // `[publisher]` instead of being recreated on every zoom change. const zoomModeRef = useRef(zoom); - // Device dimensions, read live inside `publish` for the same reason. + // Device + orientation, read live inside `publish` for the same reason. const deviceRef = useRef(device); + const orientationRef = useRef(orientation); const anchorHandleRef = useRef(null); // Whether the simulator has reached 'ready' at least once since mount. The @@ -182,7 +197,11 @@ export function SimulatorPanel({ const mode = zoomModeRef.current; zoomRef.current = mode === AUTO_ZOOM - ? computeAutoZoom(p.bounds, deviceRef.current, zoomRef.current) + ? computeAutoZoom( + p.bounds, + frameOuterSize(deviceRef.current, orientationRef.current), + zoomRef.current, + ) : mode; } publisher?.set({ @@ -235,10 +254,11 @@ export function SimulatorPanel({ useLayoutEffect(() => { zoomModeRef.current = zoom; deviceRef.current = device; + orientationRef.current = orientation; }); useLayoutEffect(() => { anchorHandleRef.current?.update({ visible: true, publish }); - }, [zoom, device, publish]); + }, [zoom, device, orientation, publish]); // Follow a pure-translate layout reorder. A dock preset change (simulator // left↔right flip, devtools-position move) reorders this panel's slot @@ -271,12 +291,24 @@ export function SimulatorPanel({
+