From a04275f31f0a2c7d8667e000175284a7f12aab4e Mon Sep 17 00:00:00 2001 From: lbb00 Date: Wed, 19 Aug 2026 17:25:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(devtools):=20=E6=A8=A1=E6=8B=9F=E5=99=A8?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E9=A1=B5=E9=9D=A2=E7=BA=A7=E5=B1=8F=E5=B9=95?= =?UTF-8?q?=E6=96=B9=E5=90=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设备壳按当前栈顶页面的有效方向旋转,安全区与窗口几何按页面当前方向解析并按 bridgeId 路由到对应 render guest,不向所有 guest 广播。 - 窗口尺寸只上报原始事实,两条通道(wx.onWindowResize / 页面的 onResize)的判据 在 service 层,与三端 native 共用同一份实现;上报时机也一致:窗口尺寸真的变了, 以及每次路由落地报落点页(后者不看尺寸是否与上次相同)。 - 与 native 的刻意分歧:模拟器在 pageShow 之前发布落点页几何(getSystemInfoSync 读的是主进程缓存快照,必须先写进去 onShow 才能同步读到)。 - 方向状态按页面登记、按页面释放,控制器带 census(),测试在页面反复开关一轮后 断言账本精确回到基线。 同时更新 dimina submodule 指针到页面方向实现。 Co-Authored-By: Claude Opus 5 (1M context) --- dimina | 2 +- docs/landscape-orientation-matrix.md | 215 ++++++ docs/landscape-support.md | 113 +++ packages/compiler/package.json | 8 +- .../e2e/fixtures/landscape-tabbar-app/app.js | 1 + .../fixtures/landscape-tabbar-app/app.json | 24 + .../fixtures/landscape-tabbar-app/app.wxss | 57 ++ .../pages/detail/detail.js | 52 ++ .../pages/detail/detail.json | 4 + .../pages/detail/detail.wxml | 6 + .../pages/detail/detail.wxss | 13 + .../pages/portrait/portrait.js | 28 + .../pages/portrait/portrait.json | 4 + .../pages/portrait/portrait.wxml | 2 + .../pages/portrait/portrait.wxss | 3 + .../landscape-tabbar-app/pages/tab1/tab1.js | 98 +++ .../landscape-tabbar-app/pages/tab1/tab1.json | 3 + .../landscape-tabbar-app/pages/tab1/tab1.wxml | 10 + .../landscape-tabbar-app/pages/tab1/tab1.wxss | 3 + .../landscape-tabbar-app/pages/tab2/tab2.js | 49 ++ .../landscape-tabbar-app/pages/tab2/tab2.json | 4 + .../landscape-tabbar-app/pages/tab2/tab2.wxml | 5 + .../landscape-tabbar-app/pages/tab2/tab2.wxss | 3 + .../landscape-tabbar-app/project.config.json | 5 + .../e2e/simulator-orientation-tabbar.spec.ts | 281 ++++++++ .../e2e/simulator-orientation.spec.ts | 682 ++++++++++++++++++ ...ridge-router-multi-session-overlap.test.ts | 131 +++- .../ipc/bridge-router-root-page-close.test.ts | 29 +- .../devtools/src/main/ipc/bridge-router.ts | 7 +- .../ipc/simulator-set-device-info.test.ts | 144 ++++ packages/devtools/src/main/ipc/simulator.ts | 30 +- .../notifications/renderer-notifier.ts | 16 + .../src/main/services/safe-area/index.test.ts | 215 +++++- .../src/main/services/safe-area/index.ts | 135 +++- .../services/views/native-simulator-view.ts | 22 +- .../src/main/services/views/view-manager.ts | 17 + .../workbench-context-page-safe-area.test.ts | 136 ++++ .../src/main/services/workbench-context.ts | 13 + .../src/preload/runtime/native-host.ts | 15 + .../devtools/src/preload/shared/api-compat.ts | 5 +- .../components/simulator-panel.tsx | 32 + .../controllers/use-device-auto-zoom.test.tsx | 11 +- .../use-device-orientation.test.tsx | 247 +++++++ .../project-runtime/controllers/use-device.ts | 99 ++- ...runtime-controller-compile-events.test.tsx | 4 + ...t-runtime-controller-compile-logs.test.tsx | 4 + .../use-project-runtime-controller.ts | 36 +- .../project-runtime/lib/device-geometry.ts | 16 + .../project-runtime/project-runtime.tsx | 4 +- .../src/renderer/shared/api/project-api.ts | 24 + .../src/renderer/shared/api/view-api.ts | 10 + .../src/service-host/sync-api-patch.test.ts | 33 + .../src/service-host/sync-api-patch.ts | 4 +- .../sync-impls/system-info.test.ts | 112 +++ .../service-host/sync-impls/system-info.ts | 70 +- packages/devtools/src/shared/ipc-channels.ts | 14 +- packages/devtools/src/shared/ipc-schemas.ts | 3 + .../device-shell-tab-bar-commit.test.tsx | 183 +++++ .../device-shell/device-shell.test.tsx | 448 ++++++++++++ .../simulator/device-shell/device-shell.tsx | 106 ++- .../orientation-controller-churn.test.tsx | 245 +++++++ .../orientation-controller.test.ts | 364 ++++++++++ .../device-shell/orientation-controller.ts | 150 ++++ .../device-shell/use-orientation.test.tsx | 264 +++++++ .../simulator/device-shell/use-orientation.ts | 175 +++++ .../src/simulator/simulator-api.test.ts | 123 +++- .../devtools/src/simulator/simulator-api.ts | 115 ++- .../simulator-mini-app-initial-device.test.ts | 110 +++ .../src/simulator/simulator-mini-app.ts | 59 +- packages/devtools/src/simulator/types.ts | 7 + .../e2e/dist-assets-current.spec.ts | 91 +++ .../e2e/electron-entry.js | 54 +- .../e2e/fixtures/landscape-app/app.js | 1 + .../e2e/fixtures/landscape-app/app.json | 12 + .../e2e/fixtures/landscape-app/app.wxss | 25 + .../pages/auto-page/auto-page.js | 13 + .../pages/auto-page/auto-page.json | 3 + .../pages/auto-page/auto-page.wxml | 1 + .../pages/auto-page/auto-page.wxss | 3 + .../fixtures/landscape-app/pages/home/home.js | 9 + .../landscape-app/pages/home/home.json | 1 + .../landscape-app/pages/home/home.wxml | 3 + .../landscape-app/pages/home/home.wxss | 3 + .../pages/landscape-page/landscape-page.js | 14 + .../pages/landscape-page/landscape-page.json | 3 + .../pages/landscape-page/landscape-page.wxml | 1 + .../pages/landscape-page/landscape-page.wxss | 3 + .../landscape-app/project.config.json | 5 + .../fixtures/orientation-app-landscape/app.js | 10 + .../orientation-app-landscape/app.json | 14 + .../orientation-app-landscape/app.wxss | 13 + .../pages/autopage/autopage.js | 13 + .../pages/autopage/autopage.json | 3 + .../pages/autopage/autopage.wxml | 1 + .../pages/autopage/autopage.wxss | 3 + .../pages/entry/entry.js | 29 + .../pages/entry/entry.json | 1 + .../pages/entry/entry.wxml | 1 + .../pages/entry/entry.wxss | 3 + .../pages/mid/mid.js | 14 + .../pages/mid/mid.json | 1 + .../pages/mid/mid.wxml | 1 + .../pages/mid/mid.wxss | 3 + .../pages/portraitpage/portraitpage.js | 13 + .../pages/portraitpage/portraitpage.json | 3 + .../pages/portraitpage/portraitpage.wxml | 1 + .../pages/portraitpage/portraitpage.wxss | 3 + .../project.config.json | 5 + .../dimina-electron-runtime/e2e/helpers.ts | 49 ++ .../e2e/native-host-audio.spec.ts | 4 + .../e2e/native-host-navigate-data.spec.ts | 4 + ...native-host-orientation-app-config.spec.ts | 325 +++++++++ .../native-host-orientation-config.spec.ts | 321 +++++++++ .../e2e/native-host-orientation-stack.spec.ts | 299 ++++++++ .../e2e/native-host-page-stack.spec.ts | 4 + .../e2e/native-host-render.spec.ts | 4 + .../native-host-switchtab-rerender.spec.ts | 4 + packages/dimina-electron-runtime/package.json | 4 + .../src/main/ipc/bridge-router.ts | 290 +++++++- .../src/main/ipc/container-routing.test.ts | 30 + .../src/main/ipc/container-routing.ts | 32 + .../src/main/ipc/window-resize.test.ts | 123 ++++ .../src/main/ipc/window-resize.ts | 72 ++ .../src/main/runtime-events.ts | 32 + .../src/shared/bridge-channels.ts | 64 +- .../src/shared/page-orientation.test.ts | 250 +++++++ .../src/shared/page-orientation.ts | 317 ++++++++ .../src/shared/page-resize-host-env.test.ts | 68 ++ .../src/shared/page-resize-host-env.ts | 33 + .../src/shared/page-window-size.test.ts | 81 +++ .../src/shared/runtime-types.ts | 6 + .../src/shared/service-host-channels.ts | 9 + .../src/simulator-ui/home-button-rule.test.ts | 2 +- .../miniapp-frame-nav-serialization.test.tsx | 3 + ...pp-frame-navigate-home-idempotent.test.tsx | 5 +- .../miniapp-frame-switch-tab-orphan.test.tsx | 6 +- .../src/simulator-ui/miniapp-frame.tsx | 81 ++- .../src/simulator-ui/miniapp-host.ts | 5 + .../src/simulator-ui/miniapp-routing.ts | 16 +- .../simulator-ui/navigate-home-reduce.test.ts | 4 +- .../src/simulator-ui/navigate-home.ts | 5 +- .../navigation-bar-config.test.ts | 133 ++++ .../src/simulator-ui/navigation-bar-config.ts | 95 +++ .../page-stack-controller.test.ts | 137 +--- .../src/simulator-ui/page-stack-controller.ts | 127 +--- .../src/simulator-ui/tab-bar-state.test.ts | 51 ++ .../src/simulator-ui/tab-bar-state.ts | 10 + pnpm-lock.yaml | 640 ++++++++-------- 148 files changed, 8902 insertions(+), 885 deletions(-) create mode 100644 docs/landscape-orientation-matrix.md create mode 100644 docs/landscape-support.md create mode 100644 packages/devtools/e2e/fixtures/landscape-tabbar-app/app.js create mode 100644 packages/devtools/e2e/fixtures/landscape-tabbar-app/app.json create mode 100644 packages/devtools/e2e/fixtures/landscape-tabbar-app/app.wxss create mode 100644 packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/detail/detail.js create mode 100644 packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/detail/detail.json create mode 100644 packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/detail/detail.wxml create mode 100644 packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/detail/detail.wxss create mode 100644 packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/portrait/portrait.js create mode 100644 packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/portrait/portrait.json create mode 100644 packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/portrait/portrait.wxml create mode 100644 packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/portrait/portrait.wxss create mode 100644 packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/tab1/tab1.js create mode 100644 packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/tab1/tab1.json create mode 100644 packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/tab1/tab1.wxml create mode 100644 packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/tab1/tab1.wxss create mode 100644 packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/tab2/tab2.js create mode 100644 packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/tab2/tab2.json create mode 100644 packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/tab2/tab2.wxml create mode 100644 packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/tab2/tab2.wxss create mode 100644 packages/devtools/e2e/fixtures/landscape-tabbar-app/project.config.json create mode 100644 packages/devtools/e2e/simulator-orientation-tabbar.spec.ts create mode 100644 packages/devtools/e2e/simulator-orientation.spec.ts create mode 100644 packages/devtools/src/main/ipc/simulator-set-device-info.test.ts create mode 100644 packages/devtools/src/main/services/workbench-context-page-safe-area.test.ts create mode 100644 packages/devtools/src/renderer/modules/main/features/project-runtime/controllers/use-device-orientation.test.tsx create mode 100644 packages/devtools/src/service-host/sync-impls/system-info.test.ts create mode 100644 packages/devtools/src/simulator/device-shell/device-shell-tab-bar-commit.test.tsx create mode 100644 packages/devtools/src/simulator/device-shell/device-shell.test.tsx create mode 100644 packages/devtools/src/simulator/device-shell/orientation-controller-churn.test.tsx create mode 100644 packages/devtools/src/simulator/device-shell/orientation-controller.test.ts create mode 100644 packages/devtools/src/simulator/device-shell/orientation-controller.ts create mode 100644 packages/devtools/src/simulator/device-shell/use-orientation.test.tsx create mode 100644 packages/devtools/src/simulator/device-shell/use-orientation.ts create mode 100644 packages/devtools/src/simulator/simulator-mini-app-initial-device.test.ts create mode 100644 packages/dimina-electron-runtime/e2e/dist-assets-current.spec.ts create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/landscape-app/app.js create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/landscape-app/app.json create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/landscape-app/app.wxss create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/auto-page/auto-page.js create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/auto-page/auto-page.json create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/auto-page/auto-page.wxml create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/auto-page/auto-page.wxss create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/home/home.js create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/home/home.json create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/home/home.wxml create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/home/home.wxss create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/landscape-page/landscape-page.js create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/landscape-page/landscape-page.json create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/landscape-page/landscape-page.wxml create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/landscape-page/landscape-page.wxss create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/landscape-app/project.config.json create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/app.js create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/app.json create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/app.wxss create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/autopage/autopage.js create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/autopage/autopage.json create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/autopage/autopage.wxml create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/autopage/autopage.wxss create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/entry/entry.js create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/entry/entry.json create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/entry/entry.wxml create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/entry/entry.wxss create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/mid/mid.js create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/mid/mid.json create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/mid/mid.wxml create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/mid/mid.wxss create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/portraitpage/portraitpage.js create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/portraitpage/portraitpage.json create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/portraitpage/portraitpage.wxml create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/portraitpage/portraitpage.wxss create mode 100644 packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/project.config.json create mode 100644 packages/dimina-electron-runtime/e2e/native-host-orientation-app-config.spec.ts create mode 100644 packages/dimina-electron-runtime/e2e/native-host-orientation-config.spec.ts create mode 100644 packages/dimina-electron-runtime/e2e/native-host-orientation-stack.spec.ts create mode 100644 packages/dimina-electron-runtime/src/main/ipc/container-routing.test.ts create mode 100644 packages/dimina-electron-runtime/src/main/ipc/container-routing.ts create mode 100644 packages/dimina-electron-runtime/src/main/ipc/window-resize.test.ts create mode 100644 packages/dimina-electron-runtime/src/main/ipc/window-resize.ts create mode 100644 packages/dimina-electron-runtime/src/shared/page-orientation.test.ts create mode 100644 packages/dimina-electron-runtime/src/shared/page-orientation.ts create mode 100644 packages/dimina-electron-runtime/src/shared/page-resize-host-env.test.ts create mode 100644 packages/dimina-electron-runtime/src/shared/page-resize-host-env.ts create mode 100644 packages/dimina-electron-runtime/src/shared/page-window-size.test.ts create mode 100644 packages/dimina-electron-runtime/src/shared/service-host-channels.ts create mode 100644 packages/dimina-electron-runtime/src/simulator-ui/navigation-bar-config.test.ts create mode 100644 packages/dimina-electron-runtime/src/simulator-ui/navigation-bar-config.ts diff --git a/dimina b/dimina index 1cbde0ea..5d74afa1 160000 --- a/dimina +++ b/dimina @@ -1 +1 @@ -Subproject commit 1cbde0ea07e8916b83e70f0450e636b298555ed1 +Subproject commit 5d74afa152bbb7329f3a274bb88af619fee1893c diff --git a/docs/landscape-orientation-matrix.md b/docs/landscape-orientation-matrix.md new file mode 100644 index 00000000..783a290a --- /dev/null +++ b/docs/landscape-orientation-matrix.md @@ -0,0 +1,215 @@ +# 屏幕方向验收矩阵 + +本文列出页面方向功能的成员全集、预期行为与当前覆盖状态。每个端、配置和入口独立登记,不使用其他成员的结论代替。 + +原生端最近一次模拟器验证为 2026-08-19(resize 语义返工后重跑,2026-08-17 那一轮跑的是旧语义、结果已作废)。逐项证据(截图、页面自报几何、logcat/hilog 生命周期序列):Android 13 项在 +`dimina-test/results/android-reverify-20260818/`;iOS 15 项在 `dimina-test/results/ios-matrix-r2c-20260819/`, +另有 iOS 复验在 `ios-reverify-20260818/`;HarmonyOS 的 firstframe / fault-injection / host-handoff 三支在 +`dimina-test/results/landscape-rework-20260818/`。汇总判决见该目录的 `RESULTS.md`。 +驱动脚本为该仓库的 `scripts/orientation-matrix-{android,ios,harmony}.sh`。物理真机仍未覆盖。 +这三支矩阵走的都是方向请求成功的路径;请求被平台拒绝的路径由 +`scripts/orientation-fault-injection-harmony.sh` 单独覆盖,见下面的「方向请求失败时的页面可见性」。 + +## 维度全集 + +| 维度 | 取值 | +| --- | --- | +| 设备方向 | portrait / landscape | +| app 级 `window.pageOrientation` | 缺省 / portrait / auto / landscape | +| 页面级 `pageOrientation` | 缺省 / portrait / auto / landscape | +| 路由入口 | 冷启动 / navigateTo / navigateBack / redirectTo / switchTab / reLaunch / 退出重开 | +| 页面类型 | 普通页 / tab 页 / tab 子栈内页 | +| 生命周期 | 首次显示 / 隐藏 / 恢复显示 / 卸载 / 前后台 / 进程重建 | +| 窗口变化 | 设备旋转 / TabBar 显隐 / 分屏 / 折叠 / 多任务 | + +## 宿主能力开关 + +原生页面方向能力默认关闭;模拟器产品内默认启用。 + +| 端 | 默认状态 | 显式启用 | 关闭态行为 | 状态 | +| --- | --- | --- | --- | --- | +| Android | 关闭 | `setPageOrientationEnabled(true)` | 保持原 Activity 方向和 resize 生命周期 | 已覆盖 | +| iOS | 关闭 | `setup(..., pageOrientationEnabled: true)` | 不注册方向监听,不改变窗口方向 | 已覆盖 | +| HarmonyOS | 关闭 | `DMPApp.init(..., { pageOrientationEnabled: true })` | 不注册方向监听,不调用窗口方向接口 | 已覆盖 | +| 模拟器 | 启用 | 无需配置 | 不适用 | 已覆盖 | + +Android library manifest 保留原有竖屏策略;显式启用的宿主需要自行覆盖 `DiminaActivity` 的方向与 `configChanges`。 + +## 配置优先级 + +页面合法配置优先于 app 合法配置;两级都没有合法值时使用 `portrait`。非法值等同于缺省。 + +| app 级 \ 页面级 | 缺省 | portrait | auto | landscape | +| --- | --- | --- | --- | --- | +| 缺省 | portrait | portrait | auto | landscape | +| portrait | portrait | portrait | auto | landscape | +| auto | auto | portrait | auto | landscape | +| landscape | landscape | portrait | auto | landscape | + +| 配置分支 | 状态 | +| --- | --- | +| 两级缺省回落 portrait | 已覆盖 | +| app 级 portrait / auto / landscape | 已覆盖 | +| 页面级 portrait / auto / landscape | 已覆盖 | +| 页面级固定方向覆盖 app 级固定方向 | 已覆盖 | +| 页面级 auto 覆盖 app 级固定方向 | 已覆盖 | +| 页面非法值回落 app 级 | 已覆盖 | +| app 非法值回落 portrait | 已覆盖 | + +## 冷启动与退出 + +| 设备方向 | 首页有效配置 | 首页显示 | 退出后设备方向 | 状态 | +| --- | --- | --- | --- | --- | +| portrait | portrait | portrait | portrait | 已覆盖 | +| portrait | landscape | landscape | portrait | 已覆盖 | +| portrait | auto | portrait | portrait | 已覆盖 | +| landscape | portrait | portrait | landscape | 已覆盖 | +| landscape | landscape | landscape | landscape | 已覆盖 | +| landscape | auto | landscape | landscape | 已覆盖 | + +模拟器的小程序方向不写回设备方向。Android 退出后由宿主 Activity 决定方向;iOS 退出后由宿主页面决定方向;HarmonyOS 最后一个小程序退出后请求 `UNSPECIFIED`。 + +## 路由入口 + +每个入口都按落点页面自己的有效配置重新计算方向。 + +| 入口 | 场景 | 预期 | 模拟器 | Android | iOS | HarmonyOS | +| --- | --- | --- | --- | --- | --- | --- | +| navigateTo | 竖屏页 → 横屏页 | 切到横屏 | 已覆盖 | 已覆盖 | 已覆盖 | 已覆盖 | +| navigateBack(1) | 横屏页 → 竖屏页 | 切回竖屏 | 已覆盖 | 已覆盖 | 已覆盖 | 已覆盖 | +| navigateBack(delta>1) | 跨过不同方向的中间页 | 使用最终落点页方向 | 已覆盖 | 已覆盖 | 已覆盖 | 已覆盖 | +| redirectTo | 跨方向替换栈顶 | 使用新栈顶方向 | 已覆盖 | 已覆盖 | 已覆盖 | 已覆盖 | +| reLaunch | 跨方向清空页面栈 | 使用新根页方向 | 已覆盖 | 已覆盖 | 已覆盖 | 已覆盖 | +| switchTab | 切到未访问 tab | 使用目标 tab 配置 | 已覆盖 | 已覆盖 | 已覆盖 | **未验证** | +| switchTab | 恢复缓存 tab 子栈 | 使用恢复后栈顶页配置 | 已覆盖 | 已覆盖 | 已覆盖 | **未验证** | +| 重复进入和返回 | 同一路径执行两轮以上 | 每轮方向一致且状态不残留 | 已覆盖 | 已覆盖 | 已覆盖 | 已覆盖 | + +**switchTab 这两行此前是错标的**:真机探针用的 `fe/example/base/app.json` 里根本没有 `tabBar`, +宿主判不出目标是 tab 页,那几步实际执行的是 `navigateTo` / `navigateBack`(容器日志里能直接看到发出的是 +`{"name":"navigateTo"}`)。Android 与 iOS 已分别由 `orientation-matrix-android-prepare.sh` 的 `WITH_TABBAR=1` +和 `orientation-matrix-ios-prepare.sh` 注入纯文字 tabBar 后重跑坐实;HarmonyOS 尚未补同样的准备步骤, +在补上之前这两行按**未验证**记。 + +几何上报与 `pageShow` 的先后**逐端不同,且是刻意的**:三端 native 在 `pageShow` **之后**上报(几何取自当时活的 +窗口,pageShow 之前它还是上一页的),kit 模拟器在 `pageShow` **之前**发布(它的 `getSystemInfoSync` 读主进程 +缓存的 hostEnv 快照,必须先写进去 `onShow` 才读得到落点页尺寸)。service 因此不要求登记收件人时该页已经 show。 +被替换或清除的页面不会在后续返回操作中复活。 + +## 设备旋转 + +| 场景 | 预期 | 模拟器 | Android | iOS | HarmonyOS | +| --- | --- | --- | --- | --- | --- | +| auto 页旋转一次 | 跟随设备并派发一次 resize | 已覆盖 | 已覆盖 | 部分覆盖 | 部分覆盖 | +| auto 页连续旋转 | 每次变化独立结算 | 已覆盖 | 已覆盖 | 部分覆盖 | 部分覆盖 | +| 固定页旋转设备 | 页面保持固定方向且不派发 resize | 已覆盖 | 已覆盖 | 部分覆盖 | 部分覆盖 | +| 固定页期间设备方向变化后返回 auto 页 | auto 页使用当前设备方向 | 已覆盖 | 已覆盖 | 部分覆盖 | 部分覆盖 | +| 系统旋转锁关闭自动旋转 | auto 尊重系统策略,固定方向仍生效 | 不适用 | 已覆盖 | 未完整覆盖 | 未完整覆盖 | +| landscapeLeft ↔ landscapeRight | 几何和安全区保持正确 | 未覆盖 | 未覆盖 | 未覆盖 | 未覆盖 | + +iOS 无法读取系统旋转锁状态;当前 `auto` 可能根据物理姿态切换方向。 + +## resize 回调 + +| 场景 | 预期 | 状态 | +| --- | --- | --- | +| auto 页窗口几何变化 | `Page.onResize` 和组件 `resize` 各派发一次 | 已覆盖 | +| app 级窗口基线变化 | `wx.onWindowResize` 派发一次 | 已覆盖 | +| 几何重复 | 页面通道可按宿主上报派发,窗口通道不重复派发 | 已覆盖 | +| 固定方向页设备旋转 | 页面和窗口通道都保持沉默 | 已覆盖 | +| 16ms 内连续上报 | 合并并使用最后一份几何 | 已覆盖 | +| resize 结算前页面隐藏或卸载 | 不派发给旧页面 | 已覆盖 | +| resize 结算前 hide → show | 旧 generation 失效;只有新上报可进入新显示周期 | 已覆盖 | +| `offWindowResize(listener)` | 精确移除同一函数引用 | 已覆盖 | +| `offWindowResize()` | 清空当前会话的窗口监听 | 已覆盖 | +| 页面卸载 | app 级窗口监听保持有效 | 已覆盖 | +| 页面隐藏期间窗口方向变化后返回 | 见下表:能押后 pageShow 的页面只看到最终几何、不补发 resize;押不了的页面必须补发一次 | HarmonyOS 两条分支各有红绿探针;Android 与 iOS 仅代码级 | +| 同一份几何被宿主重复上报 | 窗口几何变化这条路径会与上次结算的几何比较,相同就不再上报;路由落地这条路径不做比较,每次都上报当前页 | HarmonyOS 已覆盖(红绿);Android 由 `WindowGeometryLedger` 承担(`decide` 纯判定,基线只经 `record` 推进);iOS 只在真实旋转上报 | + +## TabBar 与页面几何 + +| 场景 | 预期 | 状态 | +| --- | --- | --- | +| tab 页显示 TabBar | `windowHeight` 扣除 TabBar 占用空间 | 模拟器已覆盖;Android 成立,iOS 和 HarmonyOS 不成立 | +| `hideTabBar` | ack 前发布增大的窗口高度 | 已覆盖 | +| `showTabBar` | ack 前发布缩小的窗口高度 | 已覆盖 | +| 单次显隐 | 只发布一次 resize | 已覆盖 | +| 修改文字、图标或角标 | 不改变页面窗口高度 | 已覆盖 | +| 横屏 tab 子栈切换 | 安全区、方向和窗口高度属于当前栈顶页 | 已覆盖 | + +## 时序与状态回收 + +| 场景 | 预期 | 状态 | +| --- | --- | --- | +| navigateTo 尚未完成时 navigateBack | 最终栈和方向收敛,不留孤儿页 | 已覆盖 | +| 快速连续 navigateTo / navigateBack | 按调度顺序提交,迟到结果失效 | 已覆盖 | +| 页面恢复显示 | resize 早于 `pageShow` | 已覆盖 | +| 方向请求被后续页面替代 | 旧 generation 不产生副作用 | 已覆盖 | +| 方向请求被平台拒绝 | 页面可见性不依赖请求成败,pageShow 仍放行 | 见下表逐端登记 | +| 方向请求被受理但窗口方向已经是目标值 | 不会有几何事件,pageShow 由请求返回后重取判据放行 | HarmonyOS 已修(代码级);Android/iOS 判据只读已生效事实,不适用 | +| 方向请求被受理但窗口自始至终不转 | 无解信号,pageShow 会挂起 | **未覆盖**:三端都只能靠超时兜底,本轮不引入 | +| 页面反复打开和关闭 | 方向状态数量与存活页面数量一致 | 已覆盖 | +| tab 子栈反复切换 | 缓存页不重复登记,也不误释放 | 已覆盖 | +| 会话销毁 | 清理窗口监听、页面方向状态和待处理请求 | 已覆盖 | + +## 方向请求失败时的页面可见性 + +押后的 pageShow 必须由某个一定会到来的事实放行。平台请求被拒绝时窗口不会变化,等待窗口回调就是 +永久挂起,所以每一端都要各自登记它靠什么收敛。 + +| 端 | 押后判据 | 请求被拒时的兜底 | 覆盖 | +| --- | --- | --- | --- | +| HarmonyOS | `requestsNewOrientation`(有没有正在飞的请求) | `DMPPageLifecycle.onShow` 在请求结局上自行放行 | 故障注入已覆盖(`dimina-test/results/landscape-fix-verify-20260817/harmony-fault-injection/`,红绿双向);单测只覆盖到 `settleOrientationRequest` 纯函数,编排层未覆盖 | +| iOS | 页面声明的方向 mask 与实际窗口尺寸不一致 | `handleGeometryUpdateFailure` 释放挂起的 pageShow | 单测覆盖 `shouldReleasePageShowAfterOrientationFailure`;未做故障注入 | +| Android | 已生效的 `deviceOrientation` 与实际布局长宽关系不一致 | 不适用:判据只读已生效的事实,不依赖飞行中的请求 | — | + +HarmonyOS 的判据依赖请求本身,这正是它需要显式兜底、而 Android 不需要的原因。 + +## 返回页的几何:pageShow 结算还是补发 resize + +微信的语义是:隐藏期间的窗口变化不给隐藏页补发 `Page.onResize`,返回页的几何在 `pageShow` 结算。 +能不能做到这一点,取决于容器**能不能证明窗口接下来一定会变**——只有能证明时才敢把 pageShow 押到几何落地。 + +| 端 / 页面类型 | 能否证明窗口会变 | pageShow 时机 | 返回时是否补发 resize | +| --- | --- | --- | --- | +| Android 全屏 | 能:判据只读已经布局出来的几何 | 几何落地后 | 否 | +| Android 多窗口(分屏 / 自由窗口 / 画中画) | **不能**:系统忽略 `setRequestedOrientation` | 立即 | **是** | +| iOS(全部) | 能:页面方向 mask 与窗口尺寸比对 | 几何落地后 | 否 | +| HarmonyOS 固定方向页(全屏窗口) | 能:目标方向 ≠ 当前 `deviceOrientation` | 几何落地后 | 否 | +| HarmonyOS auto 页 | **不能**:auto 请求的是「跟随传感器」,容器读不到设备姿态 | 立即(否则可能永远等不到几何事件) | **是** | +| HarmonyOS 非全屏窗口 | **不能**:`getWindowStatus()` 不是 `FULL_SCREEN` 时窗口不跟方向请求转;读不到窗口状态同样按不能算 | 立即 | **是** | + +「不能证明」的那几行不是跨端偏差而是同一条判据的必然分岔:押后 pageShow 的前提是容器能**证明**窗口接下来一定会变, +证明不了就不押后。代价是 JS `onShow` 读到那一刻仍未转过来的几何(HarmonyOS auto 页真机实测 `w=816 h=349`), +窗口转到位后由补发的 `onResize` 纠正;反过来押错了则是 pageShow 永远没有放行者。 +iOS 另有一条兜底:方向请求被平台拒绝时释放挂起的 pageShow 并补报一次当前几何。 +判据与取证以 `dimina-test/results/landscape-rework-20260818/RESULTS.md` 为准(resize 语义返工后的那一轮); +更早的 `landscape-fix-verify-20260817/RESULTS.md` F3/F4/F5 一节记录了押后判据的原始取证, +其中关于「相同几何要不要重复上报」的结论已被返工推翻。 + +## 已知跨端偏差 + +| 项 | Android | iOS | HarmonyOS | 说明 | +| --- | --- | --- | --- | --- | +| tab 页 `windowHeight` 扣除 TabBar | 成立 | 不成立 | 不成立 | iOS `DMPUIManager`、HarmonyOS `DMPDeviceUtils.buildMetrics` 都按窗口高减安全区计算,没有 TabBar 项;与微信的实测比对尚未做 | + +## 原生平台约束 + +| 平台 | 当前约束 | +| --- | --- | +| Android | 宿主必须覆盖 `DiminaActivity` manifest 配置;非 tab 页面由独立 Activity 承载 | +| iOS | 只支持一个活跃 `UIWindowScene`;宿主必须转发页面方向 mask | +| HarmonyOS | 外层 `Navigation` 必须使用 `NavigationMode.Stack`;退出只能恢复为 `UNSPECIFIED` | + +## 尚未完整覆盖 + +| 维度 | Android | iOS | HarmonyOS | 模拟器 | +| --- | --- | --- | --- | --- | +| 物理真机完整路由矩阵 | 未完整覆盖 | 未完整覆盖 | 未完整覆盖 | 不适用 | +| 左右横屏安全区 | 未覆盖 | 未覆盖 | 未覆盖 | 仅对称模型 | +| 前后台期间旋转 | 未完整覆盖 | 未完整覆盖 | 未完整覆盖 | 不适用 | +| 进程或页面容器重建 | 未完整覆盖 | 未完整覆盖 | 未完整覆盖 | 未完整覆盖 | +| 分屏和自由窗口 | 未覆盖 | 未覆盖 | 未覆盖 | 未覆盖 | +| 折叠屏展开与合拢 | 未覆盖 | 不适用 | 未覆盖 | 未覆盖 | +| iPad 多任务 | 不适用 | 未覆盖 | 不适用 | 未覆盖 | + +未覆盖成员保持显式标记,不由其他平台、入口或设备形态的状态推断。 diff --git a/docs/landscape-support.md b/docs/landscape-support.md new file mode 100644 index 00000000..d56810d3 --- /dev/null +++ b/docs/landscape-support.md @@ -0,0 +1,113 @@ +# 横竖屏支持 + +本文说明 Dimina Kit 模拟器与 Android、iOS、HarmonyOS 原生容器的页面方向行为。上游 SDK 接入方式见 [`dimina/docs/page-orientation.md`](../dimina/docs/page-orientation.md)。当前仅覆盖 WebView 渲染路径。 + +配置、设备方向与路由入口的覆盖状态见 [`landscape-orientation-matrix.md`](./landscape-orientation-matrix.md)。 + +## 配置 + +| 配置 | 位置 | 取值 | 默认 | +| --- | --- | --- | --- | +| `pageOrientation` | `app.json` 的 `window` 段或页面 `.json` | `portrait` / `auto` / `landscape` | `portrait` | + +页面合法配置优先于 app 配置;页面没有合法配置时回落到 `app.json.window.pageOrientation`;两级都没有合法值时使用 `portrait`。非法值等同于未配置。 + +有效方向按以下规则计算: + +```text +effective = configured === 'auto' ? deviceOrientation : configured +``` + +`resizable` 属于 iPad、PC 等可调整窗口大小的设备能力,不在当前支持范围内。 + +## 窗口尺寸回调 + +支持以下公开回调: + +- `Page.onResize(result)` +- 组件 `pageLifetimes` 里声明的 `resize` +- `wx.onWindowResize(listener)` +- `wx.offWindowResize(listener?)` + +回调对象为: + +```js +{ + size: { windowWidth, windowHeight }, + deviceOrientation: 'portrait' | 'landscape' +} +``` + +`wx.offWindowResize()` 传入注册时的同一个函数引用时精确移除该监听;不传参数时移除当前小程序会话通过该 API 注册的全部窗口监听。监听属于小程序会话,不随注册页面卸载。 + +## 事件派发 + +宿主在两种时候上报:一是窗口尺寸真的变了(与上一次结算的尺寸比较,相同就不报),二是每次路由落地——后者不看尺寸变没变,落到哪一页就报哪一页。 +两条通道的判据都在 service 层,上报在 16ms 窗口内合并结算。 + +1. `wx.onWindowResize` 会把这次的宽、高、方向和上一次比较,三者都没变就不触发。比较基准由整个小程序共用,不是每页一份;初值为空,所以第一次上报一定会触发一次。 +2. 页面的 `onResize` 与组件的 `resize` 只发给这次上报点到的那一页,不因为尺寸和上次相同就跳过。 +3. 固定方向页面两条通道一起抑制;`auto` 页面按上面两条判据派发。被抑制的上报仍然推进 app 级基线, + 也仍然刷新 `wx.getWindowInfo` / `getSystemInfoSync` 读到的窗口事实。 +4. 隐藏页不接收页面和组件 resize。 +5. 页面在结算前隐藏、卸载或重新显示时,旧显示周期登记的 resize 失效。 +6. `deviceOrientation` 缺失时按 `windowWidth > windowHeight` 推导。 + +## 模拟器几何 + +横屏通过重新计算设备和页面几何实现,不对页面做 CSS 旋转: + +- 横屏时交换 `screenWidth` 与 `screenHeight`。 +- 手机横屏时状态栏高度为 0。 +- 导航栏和 tabBar 高度不随方向变化。 +- `windowWidth` 为当前屏幕宽度。 +- `windowHeight` 扣除页面实际占用的导航栏、tabBar 和安全区空间。 +- `navigationStyle: 'custom'` 时导航栏不占据页面布局空间。 +- rpx 按当前窗口宽度换算。 + +CSS `env(safe-area-inset-*)` 与同步系统信息使用同一份逐页方向几何。不同方向的隐藏 tab 子栈不会覆盖当前页面的安全区状态。 + +## 原生宿主能力开关 + +原生页面方向能力默认关闭。旧宿主只升级 SDK 时,不注册方向专用监听、不调用系统方向接口,也不改变原有 resize 生命周期。 + +显式启用方式: + +- Android:`setPageOrientationEnabled(true)` +- iOS:`setup(..., pageOrientationEnabled: true)` +- HarmonyOS:`DMPApp.init(..., { pageOrientationEnabled: true })` + +Android 的配置字段不改变既有 data class 构造器;`MiniApp.openApp` 和 `DiminaActivity.launch` 保留原有 JVM 调用形状。 + +## 路由和几何时序 + +- 进入固定方向页面时切换到该页方向。 +- 返回、重定向、重启或切换 tab 后,以落点页面自己的配置重新计算方向。 +- `auto` 页面始终根据当前设备方向计算。 +- 页面恢复显示时,目标页面几何先写入 host-env,再派发 `pageShow`。 +- TabBar 显隐先发布新窗口几何,再确认 API 调用;一次显隐只发布一次 resize。 +- 被新请求替代的异步方向或路由结果通过单调 generation/epoch 失效,不复活旧页面或旧几何。 + +## 设备方向与退出恢复 + +`portrait` 和 `landscape` 为固定方向;`auto` 跟随系统允许的方向。模拟器固定方向页面禁用旋转控件;设备方向在小程序会话之间保留。 + +- Android 的方向请求属于 `DiminaActivity`;Activity 退出后由宿主 Activity 决定方向。 +- iOS 小程序页面与宿主共用 `UIWindowScene`;退出后由宿主页面决定方向,不保证恢复进入前的精确横竖方向。 +- HarmonyOS 小程序与宿主共用窗口;最后一个小程序退出后请求 `UNSPECIFIED`。宿主此前动态设置的 preferred orientation 无法精确恢复。 + +## 已知限制 + +- iOS 当前只支持一个活跃 `UIWindowScene`。 +- iOS 无法读取系统旋转锁状态,`auto` 可能根据物理姿态切换方向。 +- iOS 宿主必须允许 Portrait、LandscapeLeft、LandscapeRight,并使用 `DMPNavigationController` 或实现 `DMPPageOrientationForwarding`。 +- HarmonyOS 宿主外层 `Navigation` 必须使用 `NavigationMode.Stack`。 +- 横屏 safe area 会移动到对应屏幕边缘;不同设备形态仍需分别校验。 +- `navigateTo` 跨方向时,会话级 host-env 在被盖住页面的 `onHide` 执行前已经切到目标页面几何;目标页面的 `pageShow` 始终读取自己的几何。 +- 左右横屏、前后台恢复、进程重建、分屏、折叠屏和 iPad 多任务尚未完整覆盖。 + +## 当前覆盖状态 + +模拟器已覆盖配置优先级、冷启动、设备旋转、重复旋转、跨方向路由、Tab 子栈恢复、快速重入、TabBar 显隐几何和页面状态回收。 + +Android、iOS、HarmonyOS 已覆盖配置解析、窗口方向、resize 与主要路由入口。各端仍需在发布门禁中持续覆盖重复前进/返回、前后台、系统旋转及对应真机设备形态;未覆盖项保持显式标记,不跨端推断。 diff --git a/packages/compiler/package.json b/packages/compiler/package.json index 9cc5743b..6980fec1 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -65,15 +65,15 @@ "autoprefixer": "^10.5.3", "buffer": "^6.0.3", "cheerio": "^1.2.0", - "cssnano": "^8.0.5", + "cssnano": "^8.0.6", "esbuild": "^0.28.2", "estree-walker": "^3.0.3", "events": "^3.3.0", "htmlparser2": "^12.0.0", - "less": "^4.8.1", + "less": "^4.9.0", "magic-string": "^0.30.21", "memfs": "^4.57.8", - "oxc-parser": "^0.142.0", + "oxc-parser": "^0.144.0", "oxc-walker": "^1.1.1", "path-browserify": "^1.0.1", "postcss": "^8.5.26", @@ -84,7 +84,7 @@ "util": "^0.12.5" }, "peerDependencies": { - "@oxc-parser/binding-wasm32-wasi": "^0.142.0", + "@oxc-parser/binding-wasm32-wasi": "^0.144.0", "esbuild-wasm": "^0.28.1" }, "peerDependenciesMeta": { diff --git a/packages/devtools/e2e/fixtures/landscape-tabbar-app/app.js b/packages/devtools/e2e/fixtures/landscape-tabbar-app/app.js new file mode 100644 index 00000000..6241c06e --- /dev/null +++ b/packages/devtools/e2e/fixtures/landscape-tabbar-app/app.js @@ -0,0 +1 @@ +App({}) diff --git a/packages/devtools/e2e/fixtures/landscape-tabbar-app/app.json b/packages/devtools/e2e/fixtures/landscape-tabbar-app/app.json new file mode 100644 index 00000000..a9daae30 --- /dev/null +++ b/packages/devtools/e2e/fixtures/landscape-tabbar-app/app.json @@ -0,0 +1,24 @@ +{ + "pages": [ + "pages/tab1/tab1", + "pages/tab2/tab2", + "pages/detail/detail", + "pages/portrait/portrait" + ], + "window": { + "navigationBarTitleText": "Landscape TabBar", + "navigationBarBackgroundColor": "#ffffff", + "navigationBarTextStyle": "black", + "pageOrientation": "auto" + }, + "tabBar": { + "color": "#999999", + "selectedColor": "#1890ff", + "backgroundColor": "#ffffff", + "borderStyle": "black", + "list": [ + { "pagePath": "pages/tab1/tab1", "text": "Tab1" }, + { "pagePath": "pages/tab2/tab2", "text": "Tab2" } + ] + } +} diff --git a/packages/devtools/e2e/fixtures/landscape-tabbar-app/app.wxss b/packages/devtools/e2e/fixtures/landscape-tabbar-app/app.wxss new file mode 100644 index 00000000..17998fee --- /dev/null +++ b/packages/devtools/e2e/fixtures/landscape-tabbar-app/app.wxss @@ -0,0 +1,57 @@ +page { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 24rpx; + color: #333; + background-color: #f5f5f5; +} + +.page-marker { + font-size: 40rpx; + font-weight: 700; + padding: 20rpx 30rpx; + color: #1a1a1a; +} + +/* Probes carry JSON the spec parses; kept visible (not display:none) so the + screenshots double as human-readable evidence of the reported geometry. */ +.probe { + font-size: 18rpx; + line-height: 1.5; + padding: 6rpx 30rpx; + color: #555; + word-break: break-all; +} + +.btn { + display: inline-block; + margin: 12rpx 30rpx; + padding: 0 30rpx; + height: 64rpx; + line-height: 64rpx; + text-align: center; + background: #1890ff; + color: #fff; + border-radius: 12rpx; + font-size: 24rpx; +} + +/* Full-bleed strip a `navigationStyle: custom` page paints at the very top of + the screen — a visual check that nothing (status bar / nav bar) is reserved + above it. */ +.top-strip { + height: 60rpx; + line-height: 60rpx; + background: #d4380d; + color: #fff; + font-size: 22rpx; + padding-left: 30rpx; +} + +/* Marks the last row of a page so a spec (and a screenshot) can tell whether + the tab bar overlaps the page's own content. */ +.bottom-sentinel { + background: #52c41a; + color: #fff; + font-size: 22rpx; + padding: 8rpx 30rpx; +} diff --git a/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/detail/detail.js b/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/detail/detail.js new file mode 100644 index 00000000..329aa32a --- /dev/null +++ b/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/detail/detail.js @@ -0,0 +1,52 @@ +// Non-tab page reached by `wx.navigateTo`, pinned to `pageOrientation: "landscape"` — a page-level value that overrides app.json's "auto". +// Entering it turns the simulator landscape even on a portrait device; leaving it restores whatever orientation was on screen before. +function readGeometry() { + const sys = wx.getSystemInfoSync() + const win = wx.getWindowInfo() + return { + systemInfo: { + screenWidth: sys.screenWidth, + screenHeight: sys.screenHeight, + windowWidth: sys.windowWidth, + windowHeight: sys.windowHeight, + statusBarHeight: sys.statusBarHeight, + deviceOrientation: sys.deviceOrientation, + safeArea: sys.safeArea, + }, + windowInfo: { + screenWidth: win.screenWidth, + screenHeight: win.screenHeight, + windowWidth: win.windowWidth, + windowHeight: win.windowHeight, + statusBarHeight: win.statusBarHeight, + safeArea: win.safeArea, + }, + } +} + +Page({ + data: { + geometryText: '', + resizeCount: 0, + resizeText: '', + }, + onLoad() { + this.publishGeometry() + }, + onShow() { + this.publishGeometry() + }, + onResize(res) { + this.setData({ + resizeCount: this.data.resizeCount + 1, + resizeText: JSON.stringify(res), + }) + this.publishGeometry() + }, + publishGeometry() { + this.setData({ geometryText: JSON.stringify(readGeometry()) }) + }, + goBack() { + wx.navigateBack() + }, +}) diff --git a/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/detail/detail.json b/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/detail/detail.json new file mode 100644 index 00000000..66d0b209 --- /dev/null +++ b/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/detail/detail.json @@ -0,0 +1,4 @@ +{ + "navigationBarTitleText": "Landscape Detail", + "pageOrientation": "landscape" +} diff --git a/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/detail/detail.wxml b/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/detail/detail.wxml new file mode 100644 index 00000000..8c721b79 --- /dev/null +++ b/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/detail/detail.wxml @@ -0,0 +1,6 @@ +DETAIL LANDSCAPE PAGE +{{geometryText}} +{{resizeText}} + + +DETAIL BOTTOM SENTINEL diff --git a/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/detail/detail.wxss b/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/detail/detail.wxss new file mode 100644 index 00000000..349118d1 --- /dev/null +++ b/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/detail/detail.wxss @@ -0,0 +1,13 @@ +.page-detail { + color: #096dd9; +} + +/* Makes the four CSS `env(safe-area-inset-*)` values readable through + getComputedStyle, so a test can compare them against the `safeArea` this + same page's `wx.getSystemInfoSync()` reports. */ +.probe-env { + padding-top: env(safe-area-inset-top, 0px); + padding-right: env(safe-area-inset-right, 0px); + padding-bottom: env(safe-area-inset-bottom, 0px); + padding-left: env(safe-area-inset-left, 0px); +} diff --git a/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/portrait/portrait.js b/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/portrait/portrait.js new file mode 100644 index 00000000..e5a298a1 --- /dev/null +++ b/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/portrait/portrait.js @@ -0,0 +1,28 @@ +// Non-tab page pinned to `pageOrientation: "portrait"`. +// Entering it from a landscape device proves a mini-app's forced orientation only changes what is drawn — the simulated device's own orientation is never written back, so it is still landscape after the project closes. +function readGeometry() { + const sys = wx.getSystemInfoSync() + return { + systemInfo: { + screenWidth: sys.screenWidth, + screenHeight: sys.screenHeight, + windowWidth: sys.windowWidth, + windowHeight: sys.windowHeight, + statusBarHeight: sys.statusBarHeight, + deviceOrientation: sys.deviceOrientation, + safeArea: sys.safeArea, + }, + } +} + +Page({ + data: { + geometryText: '', + }, + onLoad() { + this.setData({ geometryText: JSON.stringify(readGeometry()) }) + }, + onShow() { + this.setData({ geometryText: JSON.stringify(readGeometry()) }) + }, +}) diff --git a/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/portrait/portrait.json b/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/portrait/portrait.json new file mode 100644 index 00000000..a5955f70 --- /dev/null +++ b/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/portrait/portrait.json @@ -0,0 +1,4 @@ +{ + "navigationBarTitleText": "Fixed Portrait", + "pageOrientation": "portrait" +} diff --git a/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/portrait/portrait.wxml b/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/portrait/portrait.wxml new file mode 100644 index 00000000..515eabaf --- /dev/null +++ b/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/portrait/portrait.wxml @@ -0,0 +1,2 @@ +PORTRAIT FIXED PAGE +{{geometryText}} diff --git a/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/portrait/portrait.wxss b/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/portrait/portrait.wxss new file mode 100644 index 00000000..0e89cd01 --- /dev/null +++ b/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/portrait/portrait.wxss @@ -0,0 +1,3 @@ +.page-portrait { + color: #722ed1; +} diff --git a/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/tab1/tab1.js b/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/tab1/tab1.js new file mode 100644 index 00000000..b671e0f7 --- /dev/null +++ b/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/tab1/tab1.js @@ -0,0 +1,98 @@ +// Tab page with the DEFAULT navigation bar. +// No `pageOrientation` of its own, so it resolves through the fallback chain to app.json's `window.pageOrientation` ("auto") and therefore follows the simulated device's own orientation. +// +// The geometry probes below render straight into the DOM so the e2e reads the numbers the mini-app actually sees, not a console line. +let activePage = null + +function windowResizeListener(res) { + if (!activePage) return + activePage.setData({ + winResizeCount: (activePage.data.winResizeCount || 0) + 1, + winResizeText: JSON.stringify(res), + }) +} + +function readGeometry() { + const sys = wx.getSystemInfoSync() + const win = wx.getWindowInfo() + return { + systemInfo: { + screenWidth: sys.screenWidth, + screenHeight: sys.screenHeight, + windowWidth: sys.windowWidth, + windowHeight: sys.windowHeight, + statusBarHeight: sys.statusBarHeight, + deviceOrientation: sys.deviceOrientation, + safeArea: sys.safeArea, + }, + windowInfo: { + screenWidth: win.screenWidth, + screenHeight: win.screenHeight, + windowWidth: win.windowWidth, + windowHeight: win.windowHeight, + statusBarHeight: win.statusBarHeight, + safeArea: win.safeArea, + }, + } +} + +Page({ + data: { + geometryText: '', + resizeCount: 0, + resizeText: '', + winResizeCount: 0, + winResizeText: '', + tabBarToggleText: '', + }, + onLoad() { + activePage = this + if (typeof wx.onWindowResize === 'function') wx.onWindowResize(windowResizeListener) + this.publishGeometry() + }, + onShow() { + activePage = this + this.publishGeometry() + }, + onUnload() { + if (typeof wx.offWindowResize === 'function') wx.offWindowResize(windowResizeListener) + if (activePage === this) activePage = null + }, + onResize(res) { + this.setData({ + resizeCount: this.data.resizeCount + 1, + resizeText: JSON.stringify(res), + }) + this.publishGeometry() + }, + publishGeometry() { + this.setData({ geometryText: JSON.stringify(readGeometry()) }) + }, + // The window height is read INSIDE the success callback: hiding the bar hands its height to the page viewport, and a mini-app is entitled to see that the moment the call it made reports success. + hideTabBar() { + const self = this + wx.hideTabBar({ + success() { + self.setData({ + tabBarToggleText: JSON.stringify({ call: 'hideTabBar', windowHeight: wx.getWindowInfo().windowHeight }), + }) + }, + }) + }, + showTabBar() { + const self = this + wx.showTabBar({ + success() { + self.setData({ + tabBarToggleText: JSON.stringify({ call: 'showTabBar', windowHeight: wx.getWindowInfo().windowHeight }), + }) + }, + }) + }, + goDetail() { + wx.navigateTo({ url: '/pages/detail/detail' }) + }, + goPortrait() { + wx.navigateTo({ url: '/pages/portrait/portrait' }) + }, +}) diff --git a/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/tab1/tab1.json b/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/tab1/tab1.json new file mode 100644 index 00000000..be90f8e3 --- /dev/null +++ b/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/tab1/tab1.json @@ -0,0 +1,3 @@ +{ + "navigationBarTitleText": "Tab One" +} diff --git a/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/tab1/tab1.wxml b/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/tab1/tab1.wxml new file mode 100644 index 00000000..3345c9f4 --- /dev/null +++ b/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/tab1/tab1.wxml @@ -0,0 +1,10 @@ +TAB1 DEFAULT NAV +{{geometryText}} +{{resizeText}} +{{winResizeText}} +{{tabBarToggleText}} + + + + +TAB1 BOTTOM SENTINEL diff --git a/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/tab1/tab1.wxss b/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/tab1/tab1.wxss new file mode 100644 index 00000000..4be53fea --- /dev/null +++ b/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/tab1/tab1.wxss @@ -0,0 +1,3 @@ +.page-tab1 { + color: #1a1a1a; +} diff --git a/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/tab2/tab2.js b/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/tab2/tab2.js new file mode 100644 index 00000000..72511abc --- /dev/null +++ b/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/tab2/tab2.js @@ -0,0 +1,49 @@ +// Tab page with `navigationStyle: "custom"`: no navigation bar and no status bar reserved above it, so the page is full-bleed from the very top of the screen. +// Orientation still comes from app.json's `window.pageOrientation` ("auto"), so it follows the simulated device like tab1 does. +function readGeometry() { + const sys = wx.getSystemInfoSync() + const win = wx.getWindowInfo() + return { + systemInfo: { + screenWidth: sys.screenWidth, + screenHeight: sys.screenHeight, + windowWidth: sys.windowWidth, + windowHeight: sys.windowHeight, + statusBarHeight: sys.statusBarHeight, + deviceOrientation: sys.deviceOrientation, + safeArea: sys.safeArea, + }, + windowInfo: { + screenWidth: win.screenWidth, + screenHeight: win.screenHeight, + windowWidth: win.windowWidth, + windowHeight: win.windowHeight, + statusBarHeight: win.statusBarHeight, + safeArea: win.safeArea, + }, + } +} + +Page({ + data: { + geometryText: '', + resizeCount: 0, + resizeText: '', + }, + onLoad() { + this.publishGeometry() + }, + onShow() { + this.publishGeometry() + }, + onResize(res) { + this.setData({ + resizeCount: this.data.resizeCount + 1, + resizeText: JSON.stringify(res), + }) + this.publishGeometry() + }, + publishGeometry() { + this.setData({ geometryText: JSON.stringify(readGeometry()) }) + }, +}) diff --git a/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/tab2/tab2.json b/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/tab2/tab2.json new file mode 100644 index 00000000..64c8fc6c --- /dev/null +++ b/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/tab2/tab2.json @@ -0,0 +1,4 @@ +{ + "navigationBarTitleText": "Tab Two", + "navigationStyle": "custom" +} diff --git a/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/tab2/tab2.wxml b/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/tab2/tab2.wxml new file mode 100644 index 00000000..67a8db22 --- /dev/null +++ b/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/tab2/tab2.wxml @@ -0,0 +1,5 @@ +TAB2 CUSTOM NAV — TOP EDGE +TAB2 CUSTOM NAV +{{geometryText}} +{{resizeText}} +TAB2 BOTTOM SENTINEL diff --git a/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/tab2/tab2.wxss b/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/tab2/tab2.wxss new file mode 100644 index 00000000..be86ef44 --- /dev/null +++ b/packages/devtools/e2e/fixtures/landscape-tabbar-app/pages/tab2/tab2.wxss @@ -0,0 +1,3 @@ +.page-tab2 { + color: #d4380d; +} diff --git a/packages/devtools/e2e/fixtures/landscape-tabbar-app/project.config.json b/packages/devtools/e2e/fixtures/landscape-tabbar-app/project.config.json new file mode 100644 index 00000000..11cfead3 --- /dev/null +++ b/packages/devtools/e2e/fixtures/landscape-tabbar-app/project.config.json @@ -0,0 +1,5 @@ +{ + "appid": "devtools_landscape_tabbar_fixture", + "projectname": "devtools-landscape-tabbar-fixture", + "description": "Fixture mini-app for e2e landscape navigationBar / tabBar layout tests" +} diff --git a/packages/devtools/e2e/simulator-orientation-tabbar.spec.ts b/packages/devtools/e2e/simulator-orientation-tabbar.spec.ts new file mode 100644 index 00000000..c324b518 --- /dev/null +++ b/packages/devtools/e2e/simulator-orientation-tabbar.spec.ts @@ -0,0 +1,281 @@ +/** + * E2E: `switchTab` crossing a tab substack that holds a landscape page pushed by `navigateTo` — the two cells `simulator-orientation.spec.ts` does not cover (that file only exercises `navigateTo` / `navigateBack` crossings and plain tab-to-tab switching while both tabs stay on the same orientation). + * + * `reduceSwitchTab` (page-stack-controller.ts) has two branches: restore a previously-visited tab from its cached substack, or open a fresh page when the target tab has never been visited. + * The two tests below exercise one branch each and MUST run in this file order (`test.describe.configure({ mode: 'serial' })`, one shared app across both): the substack a `navigateTo` lands on stays cached on its tab across `switchTab`s — nothing in this UI ever discards it — so whichever test pushes a page onto tab2 has to run AFTER the one that needs tab2 to still be a fresh, never-visited tab. + * + * - "a tab landed via switchTab uses its own config" (runs first) drives the + * FRESH-OPEN branch: the landscape page is pushed onto tab1 (the tab already active), then switchTab targets tab2, which has no cache yet and must be opened from scratch — its own `auto` config on a portrait device, not anything inherited from tab1's now-hidden landscape page. + * It resets both tabs back to a clean stack before returning, so it never contaminates the tab the next test needs untouched. + * - "switchTab restores a tab's own orientation" (runs second) drives the + * CACHED-restore branch: tab2 is switched to first (a clean single-entry substack, established by the previous test), a landscape page is pushed onto tab2's substack, then switchTab returns to tab1 — which must read back its own untouched cache, not the landscape page buried in tab2's. + * + * The devtools tabBar is unmounted while a pushed (non-tab) page is on top — `simulator-orientation.spec.ts` already pins `tabBar === null` for that state — so a real click can never issue `switchTab` while the landscape page is on screen. `App.callWxMethod` over the automation WebSocket calls the SAME `wx.switchTab` a mini-app would call directly from that page (real apps commonly wire a "return to tab" button this way), which is the mechanism used below instead of a tabBar click for that specific step. + */ +import { test, expect, _electron, type ElectronApplication, type Page as PwPage } from '@playwright/test' +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' +import { WebSocket } from 'ws' +import { + orientedDeviceMetrics, + pageWindowSize, + type Orientation, +} from '@dimina-kit/electron-runtime/shared/page-orientation' +import { + ipcInvoke, + openProjectInUI, + pollUntil, + waitForSimulatorWebview, + waitSimulatorReady, + closeProject, +} from './helpers' +import { AutomationChannel } from '../src/shared/ipc-channels' +import { DEVICES } from '../src/renderer/shared/constants' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const FIXTURE_DIR = path.resolve(__dirname, 'fixtures', 'landscape-tabbar-app') + +const DEVICE = DEVICES[1]! + +let electronApp: ElectronApplication +let mainWindow: PwPage +let autoPort = 0 + +// One-shot JSON-RPC call to the miniprogram-automator WebSocket server — mirrors the helper in native-host-wx-method.spec.ts / native-host-current-page.spec.ts. +function wsCall>( + method: string, + params: Record = {}, + timeoutMs = 12000, +): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${autoPort}`) + const timer = setTimeout(() => { ws.close(); reject(new Error(`wsCall ${method} timed out`)) }, timeoutMs) + ws.on('open', () => ws.send(JSON.stringify({ id: 'orient-tabbar', method, params }))) + ws.on('message', (raw) => { + let msg: { id?: string; result?: unknown; error?: { message?: string } } + try { msg = JSON.parse(String(raw)) } catch { return } + if (msg.id !== 'orient-tabbar') return + clearTimeout(timer) + ws.close() + if (msg.error) reject(new Error(msg.error.message || 'rpc error')) + else resolve(msg.result as T) + }) + ws.on('error', (err) => { clearTimeout(timer); reject(err) }) + }) +} + +async function callWx(method: string, args: unknown[] = []): Promise { + await wsCall('App.callWxMethod', { method, args }) +} + +// ── Shell introspection (trimmed to what these two tests read) ────────────── + +interface ShellSnapshot { + screen: { width: number; height: number } | null + tabBar: { width: number; height: number } | null + navTitle: string + navCustom: boolean + selectedTab: string | null +} + +const SHELL_SNAPSHOT_EXPR = `(() => { + const rect = (el) => el + ? (({ width, height }) => ({ width: Math.round(width), height: Math.round(height) }))(el.getBoundingClientRect()) + : null + const shellEl = document.querySelector('.device-shell') + const navEl = document.querySelector('.nav-bar') + const tabBarEl = document.querySelector('.dmb-tab-bar') + const selected = document.querySelector('.dmb-tab-bar__item.is-selected') + return JSON.stringify({ + screen: shellEl ? { width: shellEl.clientWidth, height: shellEl.clientHeight } : null, + tabBar: rect(tabBarEl), + navTitle: (document.querySelector('.nav-bar__title-text') || {}).textContent || '', + navCustom: !!navEl && navEl.classList.contains('nav-bar--custom'), + selectedTab: selected ? (selected.querySelector('.dmb-tab-bar__text') || {}).textContent || '' : null, + }) +})()` + +async function evalInSim(expr: string): Promise { + return electronApp.evaluate(async ({ webContents }, expression) => { + const all = webContents.getAllWebContents() + const sim = all.find((wc) => wc.getURL().includes('simulator.html')) ?? all.find((wc) => wc.getType() === 'webview') + if (!sim || sim.isLoading()) throw new Error('simulator webview not ready') + return sim.executeJavaScript(expression) + }, expr) as Promise +} + +async function readShell(): Promise { + return JSON.parse(await evalInSim(SHELL_SNAPSHOT_EXPR)) as ShellSnapshot +} + +async function waitForShell(predicate: (s: ShellSnapshot) => boolean, timeout = 15000): Promise { + return pollUntil(readShell, predicate, timeout, 250) +} + +const isLandscape = (s: ShellSnapshot): boolean => !!s.screen && s.screen.width > s.screen.height + +interface Geometry { + systemInfo: { windowWidth: number; windowHeight: number; deviceOrientation: Orientation } +} + +async function readGeometry(marker: string): Promise { + const raw = await electronApp.evaluate(async ({ webContents }, payload) => { + for (const wc of webContents.getAllWebContents()) { + if (!wc.getURL().includes('__frame__')) continue + if (wc.isLoading()) continue + try { + const out = await wc.executeJavaScript( + `(() => document.querySelector(${JSON.stringify(`.${payload.marker}`)}) ? (document.querySelector('.probe-geometry').textContent || '') : null)()`, + ) + if (out) return out as string + } catch { /* a guest torn down mid-iteration is not the one we want */ } + } + return null + }, { marker }) + if (!raw) return null + try { return JSON.parse(raw) as Geometry } catch { return null } +} + +async function waitForGeometry(marker: string, predicate: (g: Geometry) => boolean, timeout = 15000): Promise { + const g = await pollUntil(() => readGeometry(marker), (v) => !!v && predicate(v), timeout, 250) + expect(g, `page .${marker} should publish its geometry probe`).toBeTruthy() + return g! +} + +/** Fire a real click on an element inside a render guest (drives `bindtap`). */ +async function tapInGuest(marker: string, selector: string): Promise { + const ok = await electronApp.evaluate(async ({ webContents }, payload) => { + for (const wc of webContents.getAllWebContents()) { + if (!wc.getURL().includes('__frame__') || wc.isLoading()) continue + try { + const out = await wc.executeJavaScript( + `(() => { const el = document.querySelector(${JSON.stringify(`.${payload.marker}`)}) ? document.querySelector(${JSON.stringify(payload.selector)}) : null; if (!el) return false; el.dispatchEvent(new MouseEvent('click', { bubbles: true })); return true })()`, + ) + if (out) return true + } catch { /* a guest torn down mid-iteration is not the one we want */ } + } + return false + }, { marker, selector }) + expect(ok, `tap ${selector} inside .${marker}`).toBe(true) +} + +/** Click a tabBar item by its label — the same button a user presses. */ +async function tapTab(text: string): Promise { + const ok = await evalInSim(`(() => { + const item = [...document.querySelectorAll('.dmb-tab-bar__item')] + .find((b) => ((b.querySelector('.dmb-tab-bar__text') || {}).textContent || '').trim() === ${JSON.stringify(text)}) + if (!item) return false + item.click() + return true + })()`) + expect(ok, `tabBar item "${text}" should exist and be clickable`).toBe(true) +} + +/** Unwind back to tab1 so a test never inherits state left over from another. */ +async function resetToTab1(): Promise { + for (let attempt = 0; attempt < 6; attempt++) { + const s = await readShell() + if (s.tabBar && s.selectedTab === 'Tab1' && !s.navCustom) return + const wentBack = await evalInSim(`(() => { + const b = document.querySelector('.nav-bar__back') + if (!b) return false + b.click() + return true + })()`) + if (!wentBack) await tapTab('Tab1') + await new Promise((r) => setTimeout(r, 500)) + } + await waitForShell((v) => !!v.tabBar && v.selectedTab === 'Tab1' && !v.navCustom) +} + +test.describe('simulator orientation: switchTab crossing a landscape tab substack', () => { + test.describe.configure({ mode: 'serial' }) + test.setTimeout(180_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', + `orientation-tabbar-${process.pid}`, + ) + fs.mkdirSync(userDataDir, { recursive: true }) + + 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 electronApp.firstWindow() + await mainWindow.waitForLoadState('domcontentloaded') + + autoPort = await pollUntil( + () => ipcInvoke(mainWindow, AutomationChannel.GetPort), + (val) => typeof val === 'number' && val > 0, + 10000, + 100, + ) as number + + await openProjectInUI(mainWindow, FIXTURE_DIR, { waitMs: 40000 }) + await waitForSimulatorWebview(electronApp) + await waitSimulatorReady(electronApp) + await waitForShell((s) => !!s.tabBar, 40000) + }) + + test.afterAll(async () => { + await closeProject(mainWindow).catch(() => {}) + await electronApp?.close().catch(() => {}) + }) + + test('a tab landed via switchTab uses its own config, unaffected by a landscape page hidden in another tab\'s substack', async () => { + await resetToTab1() + + // Push the landscape page onto TAB1's own substack via the existing button. + await tapInGuest('page-tab1', '.goto-detail-btn') + const pushed = await waitForShell((v) => v.navTitle === 'Landscape Detail', 20000) + expect(isLandscape(pushed)).toBe(true) + expect(pushed.tabBar).toBeNull() + + // switchTab to tab2, which has no cached substack yet — must be opened fresh from its own config, not inherit anything from tab1's hidden page. + await callWx('switchTab', [{ url: '/pages/tab2/tab2' }]) + const landed = await waitForShell((v) => !!v.tabBar && v.selectedTab === 'Tab2', 20000) + expect(isLandscape(landed), 'tab2 must show its own portrait orientation, not the landscape page hidden on tab1').toBe(false) + expect(landed.navCustom, 'tab2 keeps its own navigationStyle: custom').toBe(true) + + const g = await waitForGeometry('page-tab2', (v) => v.systemInfo.deviceOrientation === 'portrait', 20000) + const portraitMetrics = orientedDeviceMetrics( + { screenWidth: DEVICE.width, screenHeight: DEVICE.height, statusBarHeight: DEVICE.statusBarHeight }, + 'portrait', + ) + const expected = pageWindowSize(portraitMetrics, { navigationStyle: 'custom', isTab: true, bottomInset: DEVICE.safeAreaInsets.bottom }) + expect(g.systemInfo.windowHeight).toBe(expected.windowHeight) + + // Leave both tabs on a clean single-entry stack: tab1 still has the landscape page cached on top of its substack at this point, and the next test needs a genuinely untouched tab1 to restore. + await resetToTab1() + }) + + test('switchTab restores a tab\'s own orientation after crossing a landscape page pushed onto a DIFFERENT tab', async () => { + await resetToTab1() + + // tab2 already has a clean, single-entry cached substack from the previous test — switching to it here does not open it fresh. + await tapTab('Tab2') + await waitForShell((v) => !!v.tabBar && v.selectedTab === 'Tab2' && !isLandscape(v)) + + // Push the landscape page onto TAB2's substack — tab1's cache stays untouched. + // No button reaches this from tab2, so this goes through the same wx.navigateTo a mini-app itself would call. + await callWx('navigateTo', [{ url: '/pages/detail/detail' }]) + const pushed = await waitForShell((v) => v.navTitle === 'Landscape Detail', 20000) + expect(isLandscape(pushed), 'the pushed page must turn the screen landscape').toBe(true) + expect(pushed.tabBar, 'a pushed non-tab page hides the tabBar').toBeNull() + + // switchTab back to tab1 — its substack was never touched by the push above, so restoring it must read tab1's own (auto, portrait-device) config. + await callWx('switchTab', [{ url: '/pages/tab1/tab1' }]) + const restored = await waitForShell((v) => !!v.tabBar && v.selectedTab === 'Tab1', 20000) + expect(isLandscape(restored), 'tab1 must show its own portrait orientation, not the landscape page left behind on tab2').toBe(false) + expect(restored.navTitle).toBe('Tab One') + + const g = await waitForGeometry('page-tab1', (v) => v.systemInfo.deviceOrientation === 'portrait', 20000) + expect(g.systemInfo.windowWidth).toBe(DEVICE.width) + }) +}) diff --git a/packages/devtools/e2e/simulator-orientation.spec.ts b/packages/devtools/e2e/simulator-orientation.spec.ts new file mode 100644 index 00000000..f92d318c --- /dev/null +++ b/packages/devtools/e2e/simulator-orientation.spec.ts @@ -0,0 +1,682 @@ +/** + * E2E: screen orientation inside the REAL devtools application UI — the phone shell drawn by DeviceShell in the simulator WebContentsView, driven by the renderer toolbar's own rotate control and by `pageOrientation` config. + * + * The runtime package's own orientation specs cover the bare runtime harness (`packages/dimina-electron-runtime/e2e/native-host-orientation-*.spec.ts`) against fixtures with neither a tabBar nor `navigationStyle`. + * What this file guards is the part only the devtools shell can answer: where the navigation bar and the tabBar end up once the screen turns, whether either clips or overlaps the page, and whether the simulated device's own orientation survives the mini-app forcing a different one and then exiting. + * + * Geometry expectations are derived from the same pure functions the product derives them from (`shared/page-orientation`), so a deliberate metric change moves test and product together while an accidental layout regression still fails. + */ +import { test, expect, _electron, type ElectronApplication, type Page as PwPage } from '@playwright/test' +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' +import { + NAV_BAR_HEIGHT, + orientedDeviceMetrics, + pageWindowSize, + tabBarReservedHeight, + type Orientation, +} from '@dimina-kit/electron-runtime/shared/page-orientation' +import { + closeProject, + evalInSimulator, + ipcInvoke, + openProjectInUI, + pollUntil, + waitForSimulatorWebview, + waitSimulatorReady, +} from './helpers' +import { SimulatorChannel } from '../src/shared/ipc-channels' +import { DEVICES } from '../src/renderer/shared/constants' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const FIXTURE_DIR = path.resolve(__dirname, 'fixtures', 'landscape-tabbar-app') + +/** + * Screenshots are evidence for a human reviewer — every orientation-relevant state gets one, under a stable path so a run can be inspected afterwards. + */ +const SHOT_DIR = path.resolve(__dirname, '..', 'node_modules', '.cache', 'landscape-shots') + +/** The device ProjectRuntime opens with (`use-project-runtime-controller.ts` default). */ +const DEVICE = DEVICES[1]! +const BOTTOM_INSET = DEVICE.safeAreaInsets.bottom + +let electronApp: ElectronApplication +let mainWindow: PwPage + +// ── Expected geometry, derived from the product's own pure functions ───────── + +function metricsAt(orientation: Orientation) { + return orientedDeviceMetrics( + { screenWidth: DEVICE.width, screenHeight: DEVICE.height, statusBarHeight: DEVICE.statusBarHeight }, + orientation, + ) +} + +function expectedWindow( + orientation: Orientation, + chrome: { navigationStyle: 'default' | 'custom'; isTab: boolean }, +) { + return pageWindowSize(metricsAt(orientation), { ...chrome, bottomInset: BOTTOM_INSET }) +} + +// ── Shell introspection ────────────────────────────────────────────────────── + +interface Rect { x: number; y: number; width: number; height: number } + +interface ShellSnapshot { + /** Inner size of `.device-shell` (bezel border excluded) — the device's logical screen. */ + screen: { width: number; height: number } | null + /** The screen's own content box in viewport coordinates — the frame every chrome rect is checked against. */ + screenBox: Rect | null + statusBar: Rect | null + nav: Rect | null + navCustom: boolean + navTitle: string + viewport: Rect | null + tabBar: Rect | null + tabTexts: string[] + selectedTab: string | null + /** The one render-host webview currently displayed (others are `display:none`). */ + visibleWebview: Rect | null + webviewCount: number +} + +// Rects are rounded to whole pixels: the simulator renders under a zoom factor, which leaves sub-pixel residue (a 44pt bar measures 43.998) that says nothing about the layout being asserted here. +const SHELL_SNAPSHOT_EXPR = `(() => { + const rect = (el) => el + ? (({ x, y, width, height }) => ({ + x: Math.round(x), y: Math.round(y), width: Math.round(width), height: Math.round(height), + }))(el.getBoundingClientRect()) + : null + const shellEl = document.querySelector('.device-shell') + const navEl = document.querySelector('.nav-bar') + const tabBarEl = document.querySelector('.dmb-tab-bar') + const selected = document.querySelector('.dmb-tab-bar__item.is-selected') + const visible = [...document.querySelectorAll('.device-shell__webview')] + .find((w) => w.style.display !== 'none') + const shellRect = rect(shellEl) + return JSON.stringify({ + screen: shellEl ? { width: shellEl.clientWidth, height: shellEl.clientHeight } : null, + // The bezel draws a 1px border, so the screen's content box is inset from the element's border box by clientTop/clientLeft. + screenBox: shellEl && shellRect + ? { + x: Math.round(shellRect.x + shellEl.clientLeft), + y: Math.round(shellRect.y + shellEl.clientTop), + width: shellEl.clientWidth, + height: shellEl.clientHeight, + } + : null, + statusBar: rect(document.querySelector('.device-statusbar')), + nav: rect(navEl), + navCustom: !!navEl && navEl.classList.contains('nav-bar--custom'), + navTitle: (document.querySelector('.nav-bar__title-text') || {}).textContent || '', + viewport: rect(document.querySelector('.device-shell__viewport')), + tabBar: rect(tabBarEl), + tabTexts: [...document.querySelectorAll('.dmb-tab-bar__text')].map((e) => e.textContent || ''), + selectedTab: selected ? (selected.querySelector('.dmb-tab-bar__text') || {}).textContent || '' : null, + visibleWebview: rect(visible), + webviewCount: document.querySelectorAll('.device-shell__webview').length, + }) +})()` + +async function readShell(): Promise { + return JSON.parse(await evalInSimulator(electronApp, SHELL_SNAPSHOT_EXPR)) as ShellSnapshot +} + +async function waitForShell( + predicate: (s: ShellSnapshot) => boolean, + timeout = 15000, +): Promise { + return pollUntil(readShell, predicate, timeout, 250) +} + +const isLandscape = (s: ShellSnapshot): boolean => !!s.screen && s.screen.width > s.screen.height + +// ── Page (render-guest) introspection ──────────────────────────────────────── + +interface Geometry { + systemInfo: { + screenWidth: number + screenHeight: number + windowWidth: number + windowHeight: number + statusBarHeight: number + deviceOrientation: Orientation + safeArea: { left: number; top: number; right: number; bottom: number; width: number; height: number } + } + windowInfo?: Geometry['systemInfo'] +} + +/** + * Read one page's DOM by the marker class its wxml carries. + * Tab substacks keep hidden pages mounted, so the marker (unique per page) is what identifies the guest, not "the only frame that exists". + */ +async function readGuest(marker: string, expression: string): Promise { + const raw = await electronApp.evaluate(async ({ webContents }, payload) => { + for (const wc of webContents.getAllWebContents()) { + if (!wc.getURL().includes('__frame__')) continue + if (wc.isLoading()) continue + try { + const out = await wc.executeJavaScript( + `(() => document.querySelector(${JSON.stringify(`.${payload.marker}`)}) ? JSON.stringify(${payload.expression}) : null)()`, + ) + if (out !== null && out !== undefined) return out as string + } catch { /* a guest torn down mid-iteration is not the one we want */ } + } + return null + }, { marker, expression }) + return raw === null ? null : (JSON.parse(raw) as T) +} + +async function readGeometry(marker: string): Promise { + const text = await readGuest(marker, `(document.querySelector('.probe-geometry') || {}).textContent || ''`) + if (!text) return null + try { return JSON.parse(text) as Geometry } catch { return null } +} + +async function waitForGeometry( + marker: string, + predicate: (g: Geometry) => boolean, + timeout = 15000, +): Promise { + const g = await pollUntil(() => readGeometry(marker), (v) => !!v && predicate(v), timeout, 250) + expect(g, `page .${marker} should publish its geometry probe`).toBeTruthy() + return g! +} + +/** Fire a real click on an element inside a render guest (drives `bindtap`). */ +async function tapInGuest(marker: string, selector: string): Promise { + const ok = await readGuest( + marker, + `(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return false; el.dispatchEvent(new MouseEvent('click', { bubbles: true })); return true })()`, + ) + expect(ok, `tap ${selector} inside .${marker}`).toBe(true) +} + +/** + * Unwind the page stack back to tab1 so a test never inherits a pushed page from the one before it — including from a test that stopped mid-flow. + */ +async function resetToTab1(): Promise { + for (let attempt = 0; attempt < 6; attempt++) { + const s = await readShell() + if (s.tabBar && s.selectedTab === 'Tab1' && !s.navCustom) return + const wentBack = await evalInSimulator(electronApp, `(() => { + const b = document.querySelector('.nav-bar__back') + if (!b) return false + b.click() + return true + })()`) + if (!wentBack) await tapTab('Tab1') + await new Promise((r) => setTimeout(r, 500)) + } + await waitForShell((v) => !!v.tabBar && v.selectedTab === 'Tab1' && !v.navCustom) +} + +/** Click a tabBar item by its label — the same button a user presses. */ +async function tapTab(text: string): Promise { + const ok = await evalInSimulator(electronApp, `(() => { + const item = [...document.querySelectorAll('.dmb-tab-bar__item')] + .find((b) => ((b.querySelector('.dmb-tab-bar__text') || {}).textContent || '').trim() === ${JSON.stringify(text)}) + if (!item) return false + item.click() + return true + })()`) + expect(ok, `tabBar item "${text}" should exist and be clickable`).toBe(true) +} + +// ── Toolbar rotate control (real renderer UI) ──────────────────────────────── + +const rotateButton = () => mainWindow.locator('[data-testid="sim-rotate-device"]') + +async function clickRotate(): Promise { + const btn = rotateButton() + await expect(btn, 'the rotate control must be enabled for an `auto` page').toBeEnabled({ timeout: 10000 }) + await btn.click() +} + +// ── Screenshots ────────────────────────────────────────────────────────────── + +async function shot(name: string): Promise { + const b64 = await electronApp.evaluate(async ({ webContents }) => { + const all = webContents.getAllWebContents() + const sim = all.find((wc) => wc.getURL().includes('simulator.html')) + if (!sim) return null + // `capturePage` returns whatever is composited right now, which can still be the frame from before the last DOM change — a screenshot of a page the assertions have already moved past. + // Await two animation frames in the shell AND in every render guest first, so the capture is of a committed frame rather than a race with the compositor. + const painted = all.filter((wc) => wc === sim || wc.getURL().includes('__frame__')) + await Promise.all(painted.map((wc) => (wc.isLoading() + ? Promise.resolve(null) + : wc.executeJavaScript( + 'new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(1))))', + ).catch(() => null)))) + const img = await sim.capturePage() + return img.toPNG().toString('base64') + }) + expect(b64, `screenshot ${name} should capture the simulator view`).toBeTruthy() + fs.writeFileSync(path.join(SHOT_DIR, `${name}.png`), Buffer.from(b64 as string, 'base64')) +} + +// ── Layout invariants shared by every orientation ──────────────────────────── + +function expectChromeInsideScreen(s: ShellSnapshot): void { + expect(s.screenBox, 'the phone shell must be mounted').toBeTruthy() + const screen = s.screenBox! + for (const [label, r] of Object.entries({ nav: s.nav, viewport: s.viewport, tabBar: s.tabBar })) { + if (!r) continue + expect(r.x, `${label} must not start left of the screen`).toBeGreaterThanOrEqual(screen.x - 1) + expect(r.x + r.width, `${label} must not overflow the right edge`).toBeLessThanOrEqual(screen.x + screen.width + 1) + expect(r.y, `${label} must not start above the screen`).toBeGreaterThanOrEqual(screen.y - 1) + expect(r.y + r.height, `${label} must not overflow the bottom edge`).toBeLessThanOrEqual(screen.y + screen.height + 1) + expect(r.height, `${label} must have a non-zero height (not collapsed/clipped away)`).toBeGreaterThan(0) + } +} + +function expectNoTabBarOverlap(s: ShellSnapshot): void { + expect(s.tabBar, 'a tab page must render the tabBar').toBeTruthy() + expect(s.viewport, 'the page viewport must be mounted').toBeTruthy() + expect( + s.viewport!.y + s.viewport!.height, + 'the tabBar must sit below the page viewport, not on top of it', + ).toBeLessThanOrEqual(s.tabBar!.y + 1) + expect( + Math.abs((s.tabBar!.y + s.tabBar!.height) - (s.screenBox!.y + s.screenBox!.height)), + 'the tabBar must end flush with the bottom of the screen', + ).toBeLessThanOrEqual(1) +} + +// ── Suite ──────────────────────────────────────────────────────────────────── + +test.describe('simulator orientation: navigation bar + tabBar', () => { + test.describe.configure({ mode: 'serial' }) + test.setTimeout(180_000) + + test.beforeAll(async () => { + fs.mkdirSync(SHOT_DIR, { recursive: true }) + 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', + `orientation-${process.pid}`, + ) + fs.mkdirSync(userDataDir, { recursive: true }) + + electronApp = await _electron.launch({ + args: [appPath, `--user-data-dir=${userDataDir}`], + env: { ...process.env, NODE_ENV: 'test', DIMINA_NATIVE_HOST: '1', DIMINA_E2E_USER_DATA_DIR: userDataDir }, + }) + mainWindow = await electronApp.firstWindow() + await mainWindow.waitForLoadState('domcontentloaded') + + await openProjectInUI(mainWindow, FIXTURE_DIR, { waitMs: 40000 }) + await waitForSimulatorWebview(electronApp) + await waitSimulatorReady(electronApp) + await waitForShell((s) => !!s.tabBar && s.webviewCount >= 1, 40000) + }) + + test.afterAll(async () => { + await closeProject(mainWindow).catch(() => {}) + await electronApp?.close().catch(() => {}) + }) + + test('portrait baseline: nav bar, tabBar and page area tile the device screen', async () => { + const s = await waitForShell((v) => !isLandscape(v) && !!v.tabBar) + const portrait = metricsAt('portrait') + + expect(s.screen).toEqual({ width: portrait.screenWidth, height: portrait.screenHeight }) + expect(s.statusBar?.height, 'portrait keeps the status bar').toBe(DEVICE.statusBarHeight) + expect(s.nav?.height, 'default nav bar spans status bar + 44pt row').toBe(DEVICE.statusBarHeight + NAV_BAR_HEIGHT) + expect(s.navTitle).toBe('Tab One') + expect(s.tabBar?.height, 'tabBar reserves row + bottom inset + border').toBe(tabBarReservedHeight(BOTTOM_INSET)) + expect(s.tabTexts).toEqual(['Tab1', 'Tab2']) + expect(s.selectedTab).toBe('Tab1') + + const expected = expectedWindow('portrait', { navigationStyle: 'default', isTab: true }) + expect(s.viewport?.height, 'page viewport is the screen minus nav bar and tabBar').toBe(expected.windowHeight) + expectChromeInsideScreen(s) + expectNoTabBarOverlap(s) + + const g = await waitForGeometry('page-tab1', (v) => v.systemInfo.deviceOrientation === 'portrait') + expect(g.systemInfo.windowWidth).toBe(expected.windowWidth) + expect(g.systemInfo.windowHeight).toBe(expected.windowHeight) + expect(g.systemInfo.screenWidth).toBe(portrait.screenWidth) + expect(g.systemInfo.screenHeight).toBe(portrait.screenHeight) + expect(g.windowInfo?.windowHeight, 'getWindowInfo agrees with getSystemInfoSync').toBe(expected.windowHeight) + + await shot('01-portrait-tab1-baseline') + }) + + test('toolbar rotate control turns the screen landscape without clipping nav bar or tabBar', async () => { + await clickRotate() + + const s = await waitForShell(isLandscape) + const landscape = metricsAt('landscape') + + expect(s.screen, 'landscape swaps the device width and height') + .toEqual({ width: landscape.screenWidth, height: landscape.screenHeight }) + expect(s.statusBar, 'a phone in landscape draws no status bar').toBeNull() + expect(s.nav?.height, 'the nav bar keeps its 44pt row with no status bar above it').toBe(NAV_BAR_HEIGHT) + expect(s.nav?.width, 'the nav bar spans the full landscape width').toBe(landscape.screenWidth) + expect(s.navTitle, 'the nav bar still shows the page title').toBe('Tab One') + expect(s.tabBar?.height, 'the tabBar keeps its height across orientations').toBe(tabBarReservedHeight(BOTTOM_INSET)) + expect(s.tabBar?.width).toBe(landscape.screenWidth) + expect(s.tabTexts).toEqual(['Tab1', 'Tab2']) + + const expected = expectedWindow('landscape', { navigationStyle: 'default', isTab: true }) + expect(s.viewport?.height).toBe(expected.windowHeight) + expect(s.viewport?.width).toBe(expected.windowWidth) + expectChromeInsideScreen(s) + expectNoTabBarOverlap(s) + + const g = await waitForGeometry('page-tab1', (v) => v.systemInfo.deviceOrientation === 'landscape') + expect(g.systemInfo.windowWidth).toBe(expected.windowWidth) + expect(g.systemInfo.windowHeight).toBe(expected.windowHeight) + expect(g.systemInfo.statusBarHeight, 'landscape reports no status bar height').toBe(0) + // Landscape safe area is recomputed, not rotated: the notch moves to both side edges and the top frees up entirely (`orientedSafeAreaInsets`). + expect(g.systemInfo.safeArea.top, 'no status bar means no top inset').toBe(0) + expect(g.systemInfo.safeArea.left, 'the notch eats into the left edge in landscape').toBe(DEVICE.statusBarHeight) + expect(g.systemInfo.safeArea.right).toBe(landscape.screenWidth - DEVICE.statusBarHeight) + + // The page's own viewport must have the shape of the reported window and must not overflow it. + // Simulator zoom scales the guest viewport uniformly (its px count is the logical size divided by the zoom factor), so the zoom-invariant statement is the aspect ratio, not the pixel count. + const view = await readGuest<{ w: number; h: number; scrollW: number }>( + 'page-tab1', + `({ w: window.innerWidth, h: window.innerHeight, scrollW: document.documentElement.scrollWidth })`, + ) + expect(view!.w, 'the page area itself is landscape-shaped').toBeGreaterThan(view!.h) + expect(view!.w / view!.h).toBeCloseTo(expected.windowWidth / expected.windowHeight, 1) + expect(view!.scrollW, 'the page must not overflow its viewport horizontally').toBeLessThanOrEqual(view!.w + 1) + + await shot('02-landscape-tab1-navbar-tabbar') + }) + + test('landscape tab2 with navigationStyle custom reaches the very top of the screen', async () => { + await tapTab('Tab2') + const s = await waitForShell((v) => isLandscape(v) && v.selectedTab === 'Tab2' && v.navCustom) + + expect(s.navCustom, 'a custom nav bar is taken out of the layout flow').toBe(true) + expect(s.statusBar, 'custom nav in landscape reserves no status bar either').toBeNull() + expect(Math.abs(s.viewport!.y - s.screenBox!.y), 'the page starts at the top edge of the screen') + .toBeLessThanOrEqual(1) + + const expected = expectedWindow('landscape', { navigationStyle: 'custom', isTab: true }) + expect(s.viewport?.height, 'a custom-nav tab page only gives up the tabBar').toBe(expected.windowHeight) + expectChromeInsideScreen(s) + expectNoTabBarOverlap(s) + + const g = await waitForGeometry('page-tab2', (v) => v.systemInfo.windowWidth === expected.windowWidth) + expect(g.systemInfo.windowHeight).toBe(expected.windowHeight) + expect(g.systemInfo.statusBarHeight).toBe(0) + + // The page's own first row must be painted at y=0 of its viewport: nothing (status bar or nav bar) is allowed to push it down. + const stripTop = await readGuest('page-tab2', `document.querySelector('.tab2-top-strip').getBoundingClientRect().top`) + expect(Math.abs(stripTop ?? NaN), 'the custom-nav page paints its first row at the screen top').toBeLessThanOrEqual(1) + + await shot('03-landscape-tab2-custom-nav') + }) + + test('switching tabs in landscape keeps both pages laid out and painted', async () => { + await tapTab('Tab1') + const back = await waitForShell((v) => isLandscape(v) && v.selectedTab === 'Tab1' && !v.navCustom) + expect(back.navTitle, "switching back restores tab1's own nav bar").toBe('Tab One') + expect(back.viewport?.height).toBe(expectedWindow('landscape', { navigationStyle: 'default', isTab: true }).windowHeight) + expectNoTabBarOverlap(back) + // A blank page after a tab switch is the failure this guards: the marker must still be laid out with a real box in the restored guest. + const tab1Marker = await readGuest<{ text: string; width: number; height: number }>( + 'page-tab1', + `(() => { const el = document.querySelector('.page-tab1'); const r = el.getBoundingClientRect(); return { text: el.textContent, width: r.width, height: r.height } })()`, + ) + expect(tab1Marker?.text).toContain('TAB1') + expect(tab1Marker!.width).toBeGreaterThan(0) + expect(tab1Marker!.height).toBeGreaterThan(0) + + await tapTab('Tab2') + const second = await waitForShell((v) => isLandscape(v) && v.selectedTab === 'Tab2' && v.navCustom) + expect(Math.abs(second.viewport!.y - second.screenBox!.y)).toBeLessThanOrEqual(1) + const tab2Marker = await readGuest<{ text: string; width: number; height: number }>( + 'page-tab2', + `(() => { const el = document.querySelector('.page-tab2'); const r = el.getBoundingClientRect(); return { text: el.textContent, width: r.width, height: r.height } })()`, + ) + expect(tab2Marker?.text).toContain('TAB2') + expect(tab2Marker!.height).toBeGreaterThan(0) + + await tapTab('Tab1') + const third = await waitForShell((v) => isLandscape(v) && v.selectedTab === 'Tab1' && !v.navCustom) + expect(third.screen).toEqual(second.screen) + expectChromeInsideScreen(third) + await shot('04-landscape-tab1-after-tab-churn') + + // Hand the next test a portrait baseline again. + await clickRotate() + await waitForShell((v) => !isLandscape(v)) + }) + + test('navigateTo a page-level landscape page reports landscape; navigateBack restores the tab page', async () => { + await resetToTab1() + const before = await waitForShell((v) => !isLandscape(v) && v.selectedTab === 'Tab1') + expect(before.screen!.height).toBeGreaterThan(before.screen!.width) + await shot('05-portrait-tab1-before-navigate') + + await tapInGuest('page-tab1', '.goto-detail-btn') + + const during = await waitForShell((v) => v.navTitle === 'Landscape Detail', 20000) + expect(during.tabBar, 'a non-tab page hides the tabBar').toBeNull() + + const detailExpected = expectedWindow('landscape', { navigationStyle: 'default', isTab: false }) + const g = await waitForGeometry('page-detail', (v) => v.systemInfo.deviceOrientation === 'landscape', 20000) + expect(g.systemInfo.windowWidth, 'the page-level landscape config wins over the portrait device') + .toBe(detailExpected.windowWidth) + expect(g.systemInfo.windowHeight).toBe(detailExpected.windowHeight) + expect(g.systemInfo.statusBarHeight).toBe(0) + await shot('06-landscape-detail-page-orientation') + + await expect( + rotateButton(), + 'a fixed-orientation page disables the rotate control', + ).toBeDisabled({ timeout: 10000 }) + + await tapInGuest('page-detail', '.detail-back-btn') + const after = await waitForShell((v) => v.navTitle === 'Tab One' && !!v.tabBar, 20000) + expect(after.screen, 'going back restores the orientation the stack had before the push') + .toEqual({ width: DEVICE.width, height: DEVICE.height }) + expect(after.viewport?.height, 'the restored tab page gets its portrait window area back') + .toBe(expectedWindow('portrait', { navigationStyle: 'default', isTab: true }).windowHeight) + expectNoTabBarOverlap(after) + await shot('07-portrait-tab1-after-back') + }) + + /** + * A page reads its geometry in `onShow`, so the host-env snapshot must already describe the page being shown by then. + * Routing publishes the incoming page's resize before it dispatches the lifecycle events (see the runtime MiniAppFrame routing commit) — without that ordering the restored tab page would keep reading the popped landscape page's metrics forever, since its own size never changed and no `onResize` would ever correct it. + */ + test('a page restored by navigateBack reads its own window metrics in onShow', async () => { + await resetToTab1() + await tapInGuest('page-tab1', '.goto-detail-btn') + await waitForShell((v) => v.navTitle === 'Landscape Detail', 20000) + await waitForGeometry('page-detail', (v) => v.systemInfo.deviceOrientation === 'landscape', 20000) + + await tapInGuest('page-detail', '.detail-back-btn') + await waitForShell((v) => v.navTitle === 'Tab One' && !!v.tabBar, 20000) + const restored = await waitForGeometry('page-tab1', (v) => v.systemInfo.deviceOrientation === 'portrait', 8000) + expect(restored.systemInfo.windowWidth, 'the restored tab page must not report the popped page geometry') + .toBe(DEVICE.width) + }) + + /** + * Route linkage: entering a page pinned to an orientation different from the one on screen switches the screen — the drawn phone shell, not just the geometry the mini-app is told. `useOrientation` registers the top page during render, so the very first render that receives a routed-in page already measures it at its own orientation. + */ + test('the phone shell follows a route-driven orientation change', async () => { + await resetToTab1() + await tapInGuest('page-tab1', '.goto-detail-btn') + const during = await waitForShell((v) => v.navTitle === 'Landscape Detail', 20000) + expect(isLandscape(during), 'the shell must draw the pushed page landscape').toBe(true) + expect(during.statusBar, 'landscape drops the status bar on the pushed page too').toBeNull() + expect(during.nav?.height).toBe(NAV_BAR_HEIGHT) + expect(during.viewport?.height) + .toBe(expectedWindow('landscape', { navigationStyle: 'default', isTab: false }).windowHeight) + }) + + /** + * `safeArea` must describe the orientation actually on screen — the same `orientedSafeAreaInsets` answer the device-rotation path delivers. + * The insets travel with the screen metrics in the resize host-env patch, so a page pinned to landscape never reads a top inset for a status bar that is not being drawn. + */ + test('safeArea follows a route-driven orientation change', async () => { + await resetToTab1() + await tapInGuest('page-tab1', '.goto-detail-btn') + await waitForShell((v) => v.navTitle === 'Landscape Detail', 20000) + const g = await waitForGeometry('page-detail', (v) => v.systemInfo.deviceOrientation === 'landscape', 20000) + expect(g.systemInfo.safeArea.top, 'no status bar in landscape means no top inset').toBe(0) + expect(g.systemInfo.safeArea.left, 'the notch moves to the side edges in landscape').toBe(DEVICE.statusBarHeight) + }) + + /** + * The CSS side of the same fact. `env(safe-area-inset-*)` is injected into the page's render guest by main, while `safeArea` is computed from the host-env snapshot in the service host — two processes, one orientation. + * Resolving the CSS insets against the DEVICE orientation instead of the PAGE's is what this pins: on a portrait device a landscape-pinned page would be told sides of 0 in CSS while JS reports 44, and a layout written against `env(safe-area-inset-left)` would slide under the notch. + */ + test('CSS env(safe-area-inset-*) agrees with the page safeArea on a page-level landscape page', async () => { + await resetToTab1() + await tapInGuest('page-tab1', '.goto-detail-btn') + await waitForShell((v) => v.navTitle === 'Landscape Detail', 20000) + const g = await waitForGeometry('page-detail', (v) => v.systemInfo.deviceOrientation === 'landscape', 20000) + + // `safeArea` is a rect on the oriented screen; the insets are its margins. + const { screenWidth, screenHeight, safeArea } = g.systemInfo + const fromJs = { + top: safeArea.top, + right: screenWidth - safeArea.right, + bottom: screenHeight - safeArea.bottom, + left: safeArea.left, + } + // The page is landscape on a portrait device, so this must NOT be the device's own portrait answer — otherwise the comparison below could pass with both sides wrong in the same way. + expect(fromJs, 'the landscape notch sits on both side edges, not the top').toEqual({ + top: 0, + right: DEVICE.statusBarHeight, + bottom: 21, + left: DEVICE.statusBarHeight, + }) + + const fromCss = await pollUntil( + () => readGuest>( + 'page-detail', + `(() => { const s = getComputedStyle(document.querySelector('.probe-env')); + return { top: s.paddingTop, right: s.paddingRight, bottom: s.paddingBottom, left: s.paddingLeft } })()`, + ), + (v) => !!v && v.left !== '0px', + 15000, + 250, + ) + expect(fromCss, 'the page must resolve env(safe-area-inset-*) to real values').toBeTruthy() + expect({ + top: parseFloat(fromCss!.top), + right: parseFloat(fromCss!.right), + bottom: parseFloat(fromCss!.bottom), + left: parseFloat(fromCss!.left), + }, 'CSS env(safe-area-inset-*) must describe the same orientation as wx.getSystemInfoSync().safeArea') + .toEqual(fromJs) + + await shot('11-landscape-detail-css-env-insets') + }) + + test('a mini-app forcing landscape never writes back to a portrait device', async () => { + await resetToTab1() + await tapInGuest('page-tab1', '.goto-detail-btn') + await waitForShell((v) => v.navTitle === 'Landscape Detail', 20000) + await waitForGeometry('page-detail', (v) => v.systemInfo.deviceOrientation === 'landscape', 20000) + + await closeProject(mainWindow) + expect( + (await ipcInvoke<{ deviceOrientation?: string } | null>(mainWindow, SimulatorChannel.GetDeviceInfo))?.deviceOrientation, + 'closing a project must leave the simulated device on its own orientation', + ).toBe('portrait') + + // The user-visible proof: reopening draws the `auto` entry tab portrait. + await openProjectInUI(mainWindow, FIXTURE_DIR, { waitMs: 40000 }) + await waitSimulatorReady(electronApp) + const s = await waitForShell((v) => !!v.tabBar && v.navTitle === 'Tab One', 40000) + expect(s.screen, 'the device is still portrait after the landscape session ended') + .toEqual({ width: DEVICE.width, height: DEVICE.height }) + await expect( + rotateButton(), + 'the rotate control is usable again on the auto entry page', + ).toBeEnabled({ timeout: 10000 }) + }) + + test('a mini-app forcing portrait never writes back to a landscape device', async () => { + await resetToTab1() + await clickRotate() + const rotated = await waitForShell(isLandscape) + expect(rotated.screen!.width).toBeGreaterThan(rotated.screen!.height) + await shot('08-landscape-device-tab1') + + await tapInGuest('page-tab1', '.goto-portrait-btn') + await waitForShell((v) => v.navTitle === 'Fixed Portrait', 20000) + const forced = await waitForGeometry('page-portrait', (v) => v.systemInfo.deviceOrientation === 'portrait', 20000) + expect(forced.systemInfo.screenWidth, 'a fixed-portrait page renders portrait on a landscape device') + .toBe(DEVICE.width) + expect(forced.systemInfo.screenHeight).toBe(DEVICE.height) + await shot('09-portrait-forced-page-on-landscape-device') + + await closeProject(mainWindow) + expect( + (await ipcInvoke<{ deviceOrientation?: string } | null>(mainWindow, SimulatorChannel.GetDeviceInfo))?.deviceOrientation, + 'the mini-app forcing portrait must not rotate the simulated device', + ).toBe('landscape') + + await openProjectInUI(mainWindow, FIXTURE_DIR, { waitMs: 40000 }) + await waitSimulatorReady(electronApp) + const reopened = await waitForShell((v) => !!v.tabBar && v.navTitle === 'Tab One', 40000) + expect(reopened.screen, 'the device is still landscape after the portrait-forcing session ended') + .toEqual({ width: DEVICE.height, height: DEVICE.width }) + expectChromeInsideScreen(reopened) + expectNoTabBarOverlap(reopened) + await shot('10-landscape-tab1-after-reopen') + + // Leave the shared device state as the suite found it. + await clickRotate() + await waitForShell((v) => !isLandscape(v)) + }) + + /** + * `wx.hideTabBar` unmounts the bar and the page viewport grows into its space, so the call changes the window the mini-app reports. + * Its success callback is entitled to read that new window: the shell commits the tab-bar state and publishes the new geometry BEFORE it acks the call, so by the time the callback runs main's host-env snapshot already describes the grown page. `showTabBar` is the same fact in reverse. + */ + test('hideTabBar / showTabBar report the new window height inside their own success callback', async () => { + await resetToTab1() + // The previous test ends by rotating the device back to portrait, and Page.onResize now settles through a 16ms merge window (see dimina/fe/packages/service/src/core/runtime.js settleResize), so the page's geometry probe can still be publishing the pre-rotate (landscape) snapshot for a few ms after resetToTab1() resolves. `windowHeight > 0` is true for either orientation, so pin the predicate to the orientation this suite actually reset to, or the probe's own stale reading becomes "baseline". + const withBar = await waitForGeometry( + 'page-tab1', + (v) => v.systemInfo.windowHeight > 0 && v.systemInfo.deviceOrientation === 'portrait', + ) + const baseline = withBar.systemInfo.windowHeight + const reserved = tabBarReservedHeight(BOTTOM_INSET) + + const readToggle = async (call: 'hideTabBar' | 'showTabBar'): Promise => { + const raw = await pollUntil( + () => readGuest('page-tab1', `(document.querySelector('.probe-tabbar-toggle') || {}).textContent || ''`), + (v) => !!v && v.includes(`"${call}"`), + 15000, + 250, + ) + return (JSON.parse(raw!) as { windowHeight: number }).windowHeight + } + + await tapInGuest('page-tab1', '.hide-tabbar-btn') + expect( + await readToggle('hideTabBar'), + 'the success callback must already see the viewport the bar just gave back', + ).toBe(baseline + reserved) + const hidden = await waitForShell((v) => !v.tabBar) + expect(hidden.viewport?.height, 'the shell itself grew the page area too').toBe(baseline + reserved) + await shot('12-portrait-tab1-tabbar-hidden') + + await tapInGuest('page-tab1', '.show-tabbar-btn') + expect( + await readToggle('showTabBar'), + 'showing the bar takes the height back before the caller is told it succeeded', + ).toBe(baseline) + const shown = await waitForShell((v) => !!v.tabBar) + expect(shown.viewport?.height).toBe(baseline) + expectNoTabBarOverlap(shown) + }) +}) diff --git a/packages/devtools/src/main/ipc/bridge-router-multi-session-overlap.test.ts b/packages/devtools/src/main/ipc/bridge-router-multi-session-overlap.test.ts index ee807737..f84233d0 100644 --- a/packages/devtools/src/main/ipc/bridge-router-multi-session-overlap.test.ts +++ b/packages/devtools/src/main/ipc/bridge-router-multi-session-overlap.test.ts @@ -163,7 +163,16 @@ vi.mock('@dimina-kit/electron-runtime/main/service-host-window', () => ({ })) import { BRIDGE_CHANNELS as C } from '../../shared/bridge-channels.js' -import type { DisposePayload, ActivePagePayload, PageLifecyclePayload, SpawnRequest, SpawnResult } from '../../shared/bridge-channels.js' +import type { + ActivePagePayload, + DisposePayload, + PageLifecyclePayload, + SessionActivePayload, + SpawnRequest, + SpawnResult, +} from '../../shared/bridge-channels.js' +import type { PageResizePayload } from '@dimina-kit/electron-runtime/shared/page-orientation' +import type { SessionOrientationPayload } from '../services/notifications/renderer-notifier.js' import type { WorkbenchContext } from '../services/workbench-context.js' type AnyFn = (...args: unknown[]) => unknown @@ -204,16 +213,26 @@ afterEach(() => { globalThis.fetch = originalFetch }) -function makeCtx(): { ctx: WorkbenchContext; simulatorWc: MockWc } { +function makeCtx(): { + ctx: WorkbenchContext + simulatorWc: MockWc + /** Every `session:orientationChanged` push main made, oldest first. */ + orientationPushes: SessionOrientationPayload[] +} { const simulatorWc = stubs.makeWebContents() + const orientationPushes: SessionOrientationPayload[] = [] const ctx = { registry: { add: (_fn: AnyFn) => {} }, simulatorApis: { has: (_name: string) => false, invoke: async () => ({}) }, windows: { mainWindow: { webContents: simulatorWc } }, workspace: { getSession: () => undefined }, connections: createConnectionRegistry(), + notify: { + sessionRuntimeStatus: () => {}, + sessionOrientationChanged: (payload: SessionOrientationPayload) => { orientationPushes.push(payload) }, + }, } as unknown as WorkbenchContext - return { ctx, simulatorWc } + return { ctx, simulatorWc, orientationPushes } } async function spawnSession( @@ -243,22 +262,22 @@ async function openSecondPage(simulatorWc: MockWc, appSessionId: string, pagePat return res.bridgeId } -function emitDispose(simulatorWc: MockWc, payload: DisposePayload): void { - const listeners = stubs.onListeners.get(C.DISPOSE) - if (!listeners) throw new Error('DISPOSE handler not registered') +function emitOn(channel: string, simulatorWc: MockWc, payload: unknown): void { + const listeners = stubs.onListeners.get(channel) + if (!listeners) throw new Error(`no ipcMain.on listener for ${channel}`) for (const fn of [...listeners]) (fn as AnyFn)({ sender: simulatorWc }, payload) } +function emitDispose(simulatorWc: MockWc, payload: DisposePayload): void { + emitOn(C.DISPOSE, simulatorWc, payload) +} + function emitActivePage(simulatorWc: MockWc, payload: ActivePagePayload): void { - const listeners = stubs.onListeners.get(C.ACTIVE_PAGE) - if (!listeners) throw new Error('ACTIVE_PAGE handler not registered') - for (const fn of [...listeners]) (fn as AnyFn)({ sender: simulatorWc }, payload) + emitOn(C.ACTIVE_PAGE, simulatorWc, payload) } function emitPageLifecycle(simulatorWc: MockWc, payload: PageLifecyclePayload): void { - const listeners = stubs.onListeners.get(C.PAGE_LIFECYCLE) - if (!listeners) throw new Error('PAGE_LIFECYCLE handler not registered') - for (const fn of [...listeners]) (fn as AnyFn)({ sender: simulatorWc }, payload) + emitOn(C.PAGE_LIFECYCLE, simulatorWc, payload) } /** Flush the microtask queue so any fire-and-forget dispose tail settles. */ @@ -266,6 +285,94 @@ async function flush(n = 10): Promise { for (let i = 0; i < n; i++) await Promise.resolve() } +/** The shell on screen declaring itself, as DeviceShell does when it turns active. */ +function emitSessionActive(simulatorWc: MockWc, appSessionId: string): void { + emitOn(C.SESSION_ACTIVE, simulatorWc, { appSessionId } satisfies SessionActivePayload) +} + +function emitPageResize(simulatorWc: MockWc, appSessionId: string, bridgeId: string): void { + emitOn(C.PAGE_RESIZE, simulatorWc, { + appSessionId, + bridgeId, + size: { screenWidth: 390, screenHeight: 844, windowWidth: 390, windowHeight: 700 }, + deviceOrientation: 'landscape', + dispatchWindow: false, dispatchPage: false, + canRotate: false, + } satisfies PageResizePayload) +} + +function lastPushFor(pushes: SessionOrientationPayload[], appSessionId: string): SessionOrientationPayload { + const found = [...pushes].reverse().find(p => p.appSessionId === appSessionId) + if (!found) throw new Error(`no session:orientationChanged push for ${appSessionId}`) + return found +} + +/** + * The outgoing session keeps reporting geometry after the incoming one has taken the screen, and its teardown arrives last of all — so "who reported last" cannot stand in for "who is visible". + * The shell that owns the screen declares itself; main marks every broadcast accordingly, and the renderer's panel mirror honors nothing else. + */ +describe('bridge-router — which session is on screen is declared, not inferred', () => { + it('marks only the declared session\'s report as the visible one', async () => { + const { ctx, simulatorWc, orientationPushes } = makeCtx() + installBridgeRouter(ctx) + const A = await spawnSession(simulatorWc, { pagePath: ROOT_A }) + emitSessionActive(simulatorWc, A.result.appSessionId) + // B boots invisibly behind A and publishes its own geometry straight away. + const B = await spawnSession(simulatorWc, { pagePath: ROOT_B }) + emitPageResize(simulatorWc, A.result.appSessionId, A.result.bridgeId) + emitPageResize(simulatorWc, B.result.appSessionId, B.result.bridgeId) + + expect(lastPushFor(orientationPushes, A.result.appSessionId).active).toBe(true) + expect( + lastPushFor(orientationPushes, B.result.appSessionId).active, + 'a still-booting session must not be able to move the panel under the one on screen', + ).toBe(false) + }) + + it('hands the screen to the promoted session, and the replaced one stays invisible through its teardown', async () => { + const { ctx, simulatorWc, orientationPushes } = makeCtx() + installBridgeRouter(ctx) + const A = await spawnSession(simulatorWc, { pagePath: ROOT_A }) + emitSessionActive(simulatorWc, A.result.appSessionId) + const B = await spawnSession(simulatorWc, { pagePath: ROOT_B }) + emitSessionActive(simulatorWc, B.result.appSessionId) + emitPageResize(simulatorWc, B.result.appSessionId, B.result.bridgeId) + emitPageResize(simulatorWc, A.result.appSessionId, A.result.bridgeId) + expect(lastPushFor(orientationPushes, B.result.appSessionId).active).toBe(true) + expect( + lastPushFor(orientationPushes, A.result.appSessionId).active, + 'the replaced session goes on reporting for a while — none of it may reach the panel', + ).toBe(false) + + emitDispose(simulatorWc, { bridgeId: A.result.appSessionId }) + await flush() + const teardown = lastPushFor(orientationPushes, A.result.appSessionId) + expect(teardown.orientation).toBeNull() + expect( + teardown.active, + 'the outgoing session\'s teardown must not release the screen the promoted session just took', + ).toBe(false) + }) + + it('reports the visible session\'s own teardown as visible, releasing the screen', async () => { + const { ctx, simulatorWc, orientationPushes } = makeCtx() + installBridgeRouter(ctx) + const A = await spawnSession(simulatorWc, { pagePath: ROOT_A }) + emitSessionActive(simulatorWc, A.result.appSessionId) + emitDispose(simulatorWc, { bridgeId: A.result.appSessionId }) + await flush() + + const teardown = lastPushFor(orientationPushes, A.result.appSessionId) + expect(teardown.orientation).toBeNull() + expect(teardown.active, 'closing the project must let the panel fall back to the device').toBe(true) + + // The claim dies with the session: a later spawn that has not claimed the screen must not inherit it. + const B = await spawnSession(simulatorWc, { pagePath: ROOT_B }) + emitPageResize(simulatorWc, B.result.appSessionId, B.result.bridgeId) + expect(lastPushFor(orientationPushes, B.result.appSessionId).active).toBe(false) + }) +}) + describe('bridge-router — overlapping app sessions on one simulator webContents (soft reload)', () => { it('disposes the OLDER session (A) via DISPOSE even after a NEWER session (B) spawned on the same wc', async () => { const { ctx, simulatorWc } = makeCtx() diff --git a/packages/devtools/src/main/ipc/bridge-router-root-page-close.test.ts b/packages/devtools/src/main/ipc/bridge-router-root-page-close.test.ts index 946f7062..cbf362dd 100644 --- a/packages/devtools/src/main/ipc/bridge-router-root-page-close.test.ts +++ b/packages/devtools/src/main/ipc/bridge-router-root-page-close.test.ts @@ -8,6 +8,8 @@ * The refusal is only right when the root page is the session's LAST page — * there `DISPOSE` owns the teardown, and closing the page alone would leave a * session with no pages at all. + * + * Retiring that page must not cost the session its service→container direction: the service host stamps every message with the bridgeId it was spawned under (the root page's), so a router that can only answer for THAT page stops answering at all once it is closed — every `wx.*` call the mini-app makes afterwards would hang unanswered. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -114,7 +116,7 @@ vi.mock('@dimina-kit/electron-runtime/main/service-host-window', () => ({ constructServiceHostWindow: vi.fn(() => stubs.makeBrowserWindow()), })) -import { BRIDGE_CHANNELS as C } from '../../shared/bridge-channels.js' +import { BRIDGE_CHANNELS as C, SIMULATOR_EVENTS as E } from '../../shared/bridge-channels.js' import type { PageOpenResult, SpawnRequest, SpawnResult } from '../../shared/bridge-channels.js' import type { BridgeRouterHandle } from './bridge-router.js' import type { WorkbenchContext } from '../services/workbench-context.js' @@ -200,6 +202,13 @@ function closePage(simulatorWc: MockWc, bridgeId: string): void { ;(handle as AnyFn)({ sender: simulatorWc }, { bridgeId }) } +/** One service→container message, exactly as the service-host preload sends it. */ +function serviceInvoke(sender: unknown, bridgeId: string, msg: unknown): void { + const handle = stubs.eventHandlers.get(C.SERVICE_INVOKE) + if (!handle) throw new Error('SERVICE_INVOKE handler not registered') + ;(handle as AnyFn)({ sender }, { bridgeId, msg }) +} + function pageCount(bridge: BridgeRouterHandle): number { return bridge.census!().pageSessions } @@ -224,6 +233,24 @@ describe('PAGE_CLOSE — the launch page after navigation replaced it', () => { expect(bridge.census!().appSessions).toBe(1) }) + it('still routes the service host\'s messages once its spawn page is gone', async () => { + const { bridge, simulatorWc } = makeHarness() + const spawned = await spawnSession(simulatorWc, ROOT_PAGE) + const second = await openPage(simulatorWc, spawned.appSessionId, SECOND_PAGE) + const serviceWc = bridge.getServiceWcForBridge(second.bridgeId) + closePage(simulatorWc, spawned.bridgeId) + simulatorWc.send.mockClear() + + // The envelope carries the spawn (root) bridgeId — the service host has no other id to stamp — while the body names the page actually calling. + serviceInvoke(serviceWc, spawned.bridgeId, { + type: 'invokeAPI', + target: 'container', + body: { name: 'navigateTo', bridgeId: second.bridgeId, params: { url: `/${SECOND_PAGE}` } }, + }) + + expect(simulatorWc.send.mock.calls.map(([channel]) => channel)).toContain(E.NAV_ACTION) + }) + it('keeps the launch page when it is the session\'s only page', async () => { const { bridge, simulatorWc } = makeHarness() const spawned = await spawnSession(simulatorWc, ROOT_PAGE) diff --git a/packages/devtools/src/main/ipc/bridge-router.ts b/packages/devtools/src/main/ipc/bridge-router.ts index 43443b72..8a7420ba 100644 --- a/packages/devtools/src/main/ipc/bridge-router.ts +++ b/packages/devtools/src/main/ipc/bridge-router.ts @@ -7,12 +7,16 @@ import { type RuntimeEvents, } from '@dimina-kit/electron-runtime/main/runtime-context' import type { SessionRuntimeStatus, SyncStorageChange } from '@dimina-kit/electron-runtime' +import type { SessionOrientationPayload } from '../services/notifications/renderer-notifier.js' import type { MessageEnvelope } from '../../shared/bridge-channels.js' import { runtimeAssetPaths } from '../utils/paths.js' type DevtoolsBridgeContext = Omit & { events?: RuntimeEvents - notify?: { sessionRuntimeStatus(payload: SessionRuntimeStatus): void } + notify?: { + sessionRuntimeStatus(payload: SessionRuntimeStatus): void + sessionOrientationChanged(payload: SessionOrientationPayload): void + } appData?: { evictBridge(appId: string, bridgeId: string): void onServiceToRender(appId: string, message: MessageEnvelope): void @@ -36,6 +40,7 @@ export function installBridgeRouter(ctx: DevtoolsBridgeContext): void { const events = createRuntimeEvents() ctx.events = events events.on('session-status', (payload) => ctx.notify?.sessionRuntimeStatus(payload)) + events.on('session-orientation', (payload) => ctx.notify?.sessionOrientationChanged(payload)) events.on('app-data-evict', ({ appId, bridgeId }) => { ctx.appData?.evictBridge(appId, bridgeId) }) diff --git a/packages/devtools/src/main/ipc/simulator-set-device-info.test.ts b/packages/devtools/src/main/ipc/simulator-set-device-info.test.ts new file mode 100644 index 00000000..fc0bf896 --- /dev/null +++ b/packages/devtools/src/main/ipc/simulator-set-device-info.test.ts @@ -0,0 +1,144 @@ +/** + * `simulator:set-device-info` must not push window geometry to a running service host. + * + * DeviceShell is the only place that knows the visible page's orientation config and chrome (nav bar / tab bar), and it publishes the resulting geometry over PAGE_RESIZE. + * A raw device→host-env mapping knows neither, so sending it here would install a snapshot that `wx.getSystemInfoSync()` answers from — with the device's own orientation and only the status bar deducted — until the shell's PAGE_RESIZE arrives. + * + * The device identity (model / brand / system / platform / pixelRatio / portrait-baseline safe-area insets) still has to reach the service host right away: nothing else republishes it when the user picks another phone. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const stub = vi.hoisted(() => { + type Handler = (...args: unknown[]) => unknown + const handled = new Map() + return { + handled, + ipcMain: { + handle: vi.fn((channel: string, fn: Handler) => { + handled.set(channel, fn) + }), + removeHandler: vi.fn((channel: string) => { + handled.delete(channel) + }), + on: vi.fn(), + removeListener: vi.fn(), + removeAllListeners: vi.fn(), + }, + } +}) + +vi.mock('electron', () => ({ + ipcMain: stub.ipcMain, + default: { ipcMain: stub.ipcMain }, +})) + +const DEVICE = { + brand: 'Apple', + model: 'iPhone 14', + system: 'iOS 16.0', + platform: 'ios', + pixelRatio: 3, + screenWidth: 390, + screenHeight: 844, + statusBarHeight: 47, + notchType: 'dynamic-island' as const, + safeAreaInsets: { top: 47, right: 0, bottom: 34, left: 0 }, + deviceOrientation: 'portrait' as const, +} + +beforeEach(() => { + stub.handled.clear() + stub.ipcMain.handle.mockClear() + vi.resetModules() +}) + +async function setupSimulatorIpc() { + const { registerSimulatorIpc } = await import('./simulator.js') + const serviceWc = { isDestroyed: () => false, send: vi.fn() } + const ctx = { + views: { reapplySafeArea: vi.fn() }, + notify: {}, + senderPolicy: undefined, + simulatorApis: { invoke: vi.fn() }, + bridge: { + setDevice: vi.fn(), + getDevice: vi.fn(() => null), + getServiceWc: () => serviceWc, + }, + } + const disposable = registerSimulatorIpc(ctx as never) + return { ctx, serviceWc, disposable } +} + +describe('registerSimulatorIpc: simulator:set-device-info → service-host host-env update', () => { + it('sends the device identity but no window geometry — DeviceShell owns that and publishes it over PAGE_RESIZE', async () => { + const { serviceWc, disposable } = await setupSimulatorIpc() + const handler = stub.handled.get('simulator:set-device-info') + expect(handler).toBeDefined() + + await handler?.({}, DEVICE) + + expect(serviceWc.send).toHaveBeenCalledTimes(1) + const [, patch] = serviceWc.send.mock.calls[0] as [string, Record] + + for (const key of [ + 'screenWidth', + 'screenHeight', + 'windowWidth', + 'windowHeight', + 'statusBarHeight', + 'deviceOrientation', + ]) { + expect(patch).not.toHaveProperty(key) + } + expect(patch).toMatchObject({ + brand: 'Apple', + model: 'iPhone 14', + system: 'iOS 16.0', + platform: 'ios', + pixelRatio: 3, + safeAreaInsets: { top: 47, right: 0, bottom: 34, left: 0 }, + }) + + await disposable.dispose() + }) + + it('still caches the selection and re-applies the CSS safe-area override', async () => { + const { ctx, disposable } = await setupSimulatorIpc() + await stub.handled.get('simulator:set-device-info')?.({}, DEVICE) + + expect(ctx.bridge.setDevice).toHaveBeenCalledWith(DEVICE) + expect(ctx.views.reapplySafeArea).toHaveBeenCalledWith(DEVICE) + + await disposable.dispose() + }) + + it('keeps the pushed deviceOrientation — the payload schema must not strip the one field the rotate button changes', async () => { + const { ctx, disposable } = await setupSimulatorIpc() + await stub.handled.get('simulator:set-device-info')?.( + {}, + { ...DEVICE, deviceOrientation: 'landscape' }, + ) + + expect(ctx.bridge.setDevice).toHaveBeenCalledWith( + expect.objectContaining({ deviceOrientation: 'landscape' }), + ) + + await disposable.dispose() + }) +}) + +describe('deviceIdentityHostEnv', () => { + it('keeps a landscape device from leaking its swapped screen size into the patch', async () => { + const { deviceIdentityHostEnv } = await import('./simulator.js') + const patch = deviceIdentityHostEnv({ ...DEVICE, deviceOrientation: 'landscape' }) + expect(Object.keys(patch).sort()).toEqual([ + 'brand', + 'model', + 'pixelRatio', + 'platform', + 'safeAreaInsets', + 'system', + ]) + }) +}) diff --git a/packages/devtools/src/main/ipc/simulator.ts b/packages/devtools/src/main/ipc/simulator.ts index d8aedcc8..75b1ec2a 100644 --- a/packages/devtools/src/main/ipc/simulator.ts +++ b/packages/devtools/src/main/ipc/simulator.ts @@ -6,12 +6,38 @@ import { SimulatorSoftReloadSchema, } from '../../shared/ipc-schemas.js' import { deviceInfoToHostEnv } from '../../shared/bridge-channels.js' +import type { HostEnvSnapshot } from '../../shared/bridge-channels.js' +import type { NativeDeviceInfo } from '../../shared/ipc-channels.js' // eslint-disable-next-line no-restricted-syntax -- grandfathered(workbench-context): shrink-only import type { WorkbenchContext } from '../services/workbench-context.js' import type { Disposable } from '@dimina-kit/electron-deck/main' import { validate } from '../utils/ipc-schema.js' import { IpcRegistry } from '../utils/ipc-registry.js' +/** + * Host-env keys that describe the mini-app WINDOW rather than the device. + * Only DeviceShell can compute them: they depend on the top page's own orientation config and chrome (nav bar / tabBar), which a raw device record knows nothing about. + * It publishes them over PAGE_RESIZE. + */ +const WINDOW_GEOMETRY_KEYS = [ + 'screenWidth', + 'screenHeight', + 'windowWidth', + 'windowHeight', + 'statusBarHeight', + 'deviceOrientation', +] as const + +/** + * The device-identity slice of a host-env snapshot — model / brand / system / platform / pixelRatio / portrait-baseline safe-area insets. + * Switching the simulated phone must refresh these on a running service host immediately, but pushing the device's raw geometry alongside them would install a snapshot that ignores the current page's orientation and chrome, so `wx.getSystemInfoSync()` would report wrong dimensions until DeviceShell's PAGE_RESIZE lands. + */ +export function deviceIdentityHostEnv(device: NativeDeviceInfo): Partial { + const patch: Partial = { ...deviceInfoToHostEnv(device) } + for (const key of WINDOW_GEOMETRY_KEYS) delete patch[key] + return patch +} + export function registerSimulatorIpc(ctx: Pick): Disposable { return new IpcRegistry(ctx.senderPolicy) .handle(SimulatorChannel.AttachNative, (_, ...args: unknown[]) => { @@ -39,11 +65,13 @@ export function registerSimulatorIpc(ctx: Pick ctx.bridge?.getDevice() ?? null) .handle(SimulatorCustomApiChannel.Invoke, (_, ...args: unknown[]) => { const [name, params] = validate(SimulatorCustomApiChannel.Invoke, SimulatorCustomApiInvokeSchema, args) return ctx.simulatorApis.invoke(name, params) diff --git a/packages/devtools/src/main/services/notifications/renderer-notifier.ts b/packages/devtools/src/main/services/notifications/renderer-notifier.ts index b468f831..79a7de76 100644 --- a/packages/devtools/src/main/services/notifications/renderer-notifier.ts +++ b/packages/devtools/src/main/services/notifications/renderer-notifier.ts @@ -59,6 +59,17 @@ export interface SessionRuntimeStatusPayload { pageFallback?: { requested: string; resolved: string } } +/** + * Payload for the `session:orientationChanged` push — main's translation of device-shell's `PAGE_RESIZE.canRotate` (see the runtime's `'session-orientation'` event). `orientation: null` means no session is forcing anything (no session, or `disposeAppSession` just tore it down). + */ +export interface SessionOrientationPayload { + appSessionId: string + orientation: 'portrait' | 'landscape' | null + canRotate: boolean + /** Whether the reporting session is the one the simulator declared on screen. */ + active: boolean +} + /** * Payload for the `project:compileLog` push — one filtered dmcc log line. * `at` is stamped in the main process when the line is captured. @@ -104,6 +115,8 @@ export interface RendererNotifier { projectStatus(payload: ProjectStatusPayload): void /** Broadcast a session's post-compile runtime lifecycle to the main renderer. */ sessionRuntimeStatus(payload: SessionRuntimeStatusPayload): void + /** Broadcast a session's forced-orientation change to the main renderer. */ + sessionOrientationChanged(payload: SessionOrientationPayload): void /** Push one per-line dmcc compile-log entry to the main renderer. */ compileLog(payload: CompileLogPayload): void /** Ask the main renderer to navigate back to its landing screen. */ @@ -181,6 +194,9 @@ export function createRendererNotifier(ctx: NotifierContext): RendererNotifier { sessionRuntimeStatus(payload) { sendToMain(SessionChannel.RuntimeStatus, payload) }, + sessionOrientationChanged(payload) { + sendToMain(SessionChannel.OrientationChanged, payload) + }, compileLog(payload) { sendToMain(ProjectChannel.CompileLog, payload) }, 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..f5387a75 100644 --- a/packages/devtools/src/main/services/safe-area/index.test.ts +++ b/packages/devtools/src/main/services/safe-area/index.test.ts @@ -71,13 +71,24 @@ function makeWc( const DEVICE = { safeAreaInsets: { top: 47, right: 0, bottom: 34, left: 0 } } as never +/** A notched phone held upright — the case where page and device orientation can disagree. */ +const NOTCHED_PORTRAIT = { + statusBarHeight: 44, + notchType: 'notch', + deviceOrientation: 'portrait', + safeAreaInsets: { top: 44, right: 0, bottom: 34, left: 0 }, +} as never + +/** A page whose orientation main has not heard about yet. */ +const page = (bridgeId: string | null, isTabPage = false) => ({ bridgeId, isTabPage }) + describe('createSafeAreaController teardown routing', () => { it('routes guest prune through the connection registry; destroy cleans both', () => { const connections = createConnectionRegistry() const controller = createSafeAreaController({ connections }) const wc = makeWc(7) - controller.applyToGuest(wc, null, false) + controller.applyToGuest(wc, null, page(null)) // The connection was acquired for this guest. expect(connections.get(wc.id), 'guest connection must be live before destroy').toBeDefined() @@ -109,7 +120,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, page('bridge_1')) const insets = lastInsets(sink) expect(insets.top).toBe(47) expect(insets.bottom).toBe(34) @@ -119,19 +130,36 @@ 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, page('bridge_2', true)) const insets = lastInsets(sink) expect(insets.top).toBe(47) expect(insets.bottom).toBe(0) expect(insets.bottomMax).toBe(0) }) + it('landscape moves the notch onto both sides and frees the top', () => { + // A notched phone rotated: WeChat's own landscape safe area for this class of screen is top 0 / sides = the notch depth / a thinner home indicator. + const landscapeDevice = { + statusBarHeight: 44, + notchType: 'notch', + deviceOrientation: 'landscape', + safeAreaInsets: { top: 44, right: 0, bottom: 34, left: 0 }, + } as never + const sink: Array<{ method: string; params: unknown }> = [] + const controller = createSafeAreaController() + controller.applyToGuest(makeWc(11, sink), landscapeDevice, page('bridge_11')) + const call = [...sink].reverse().find((c) => c.method === 'Emulation.setSafeAreaInsetsOverride') + expect(call?.params).toMatchObject({ + insets: { top: 0, left: 44, leftMax: 44, right: 44, rightMax: 44, bottom: 21, bottomMax: 21 }, + }) + }) + it('reapplyAll keeps each guest its attached page type', () => { 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, page('bridge_3', true)) + controller.applyToGuest(makeWc(4, sinkPage), DEVICE, page('bridge_4')) sinkTab.length = 0 sinkPage.length = 0 controller.reapplyAll(DEVICE) @@ -139,15 +167,12 @@ describe('createSafeAreaController per-page-type bottom inset', () => { expect(lastInsets(sinkPage).bottom).toBe(34) }) - // A codex adversarial review caught this: `applyToGuest` never subscribed to - // `lease.onDetach`, so after an external detach `reapplyAll`/`override` kept - // calling `.send()` on a dead lease instead of reacquiring — env overrides - // would silently stop recovering for a still-live guest. + // Guards that `applyToGuest` subscribes to `lease.onDetach`: without it, an external detach would leave `reapplyAll`/`override` calling `.send()` on a dead lease instead of reacquiring — env overrides would silently stop recovering for a still-live guest. it('reacquires and keeps applying insets after the debugger session is externally detached', () => { const sink: Array<{ method: string; params: unknown }> = [] const wc = makeWc(5, sink) const controller = createSafeAreaController() - controller.applyToGuest(wc, DEVICE, false) + controller.applyToGuest(wc, DEVICE, page('bridge_5')) expect(lastInsets(sink).bottom).toBe(34) // Something outside safe-area detaches the shared debugger session @@ -163,11 +188,177 @@ describe('createSafeAreaController per-page-type bottom inset', () => { }) }) +/** + * The insets a guest receives must describe the orientation ITS OWN page is showing, which is what `wx.getSystemInfoSync().safeArea` reports for that page. + * A page-level `pageOrientation` makes the two disagree with the device: a landscape page on an upright phone reads sides of 44 in JS, so CSS `env(safe-area-inset-left/right)` has to say 44 too. + */ +describe('createSafeAreaController per-page orientation', () => { + function lastInsets(sink: Array<{ method: string; params: unknown }>) { + const call = [...sink].reverse().find((c) => c.method === 'Emulation.setSafeAreaInsetsOverride') + return (call?.params as { insets: Record } | undefined)?.insets + } + + it('a page pinned to landscape on an upright device gets landscape insets', () => { + const sink: Array<{ method: string; params: unknown }> = [] + const controller = createSafeAreaController() + controller.applyToGuest(makeWc(20, sink), NOTCHED_PORTRAIT, page('bridge_detail')) + expect(lastInsets(sink)).toMatchObject({ top: 44, left: 0, right: 0, bottom: 34 }) + + sink.length = 0 + controller.recordPageOrientation('bridge_detail', 'landscape', NOTCHED_PORTRAIT) + expect(lastInsets(sink)).toMatchObject({ + top: 0, left: 44, leftMax: 44, right: 44, rightMax: 44, bottom: 21, bottomMax: 21, + }) + }) + + it('an orientation reported before the guest attaches is applied on attach', () => { + const sink: Array<{ method: string; params: unknown }> = [] + const controller = createSafeAreaController() + // Routing publishes the incoming page's resize before React mounts its + // , so main can know the orientation before the guest exists. + controller.recordPageOrientation('bridge_early', 'landscape', NOTCHED_PORTRAIT) + controller.applyToGuest(makeWc(21, sink), NOTCHED_PORTRAIT, page('bridge_early')) + expect(lastInsets(sink)).toMatchObject({ top: 0, left: 44, right: 44, bottom: 21 }) + }) + + it('only the named page is re-pushed; a hidden tab-substack guest keeps its own orientation', () => { + const sinkTop: Array<{ method: string; params: unknown }> = [] + const sinkHidden: Array<{ method: string; params: unknown }> = [] + const controller = createSafeAreaController() + controller.applyToGuest(makeWc(22, sinkTop), NOTCHED_PORTRAIT, page('bridge_top', true)) + controller.applyToGuest(makeWc(23, sinkHidden), NOTCHED_PORTRAIT, page('bridge_hidden', true)) + sinkTop.length = 0 + sinkHidden.length = 0 + + controller.recordPageOrientation('bridge_top', 'landscape', NOTCHED_PORTRAIT) + + expect(lastInsets(sinkTop)).toMatchObject({ top: 0, left: 44, right: 44 }) + expect(sinkHidden, 'the hidden guest must not be touched at all').toHaveLength(0) + + // A later device change must not spread the top page's orientation either. + controller.reapplyAll(NOTCHED_PORTRAIT) + expect(lastInsets(sinkTop)).toMatchObject({ top: 0, left: 44, right: 44 }) + expect(lastInsets(sinkHidden)).toMatchObject({ top: 44, left: 0, right: 0 }) + }) + + it('a guest whose page never reported an orientation follows the device', () => { + const sink: Array<{ method: string; params: unknown }> = [] + const landscapeDevice = { ...(NOTCHED_PORTRAIT as object), deviceOrientation: 'landscape' } as never + const controller = createSafeAreaController() + controller.applyToGuest(makeWc(24, sink), landscapeDevice, page('bridge_silent')) + expect(lastInsets(sink)).toMatchObject({ top: 0, left: 44, right: 44, bottom: 21 }) + }) +}) + +/** + * The orientation ledger belongs to the PAGE, not to whichever WebContents is currently rendering it. + * A page keeps its bridgeId across a render-guest swap (bridge-router's `ensureRenderBound` rebinds the same page to a new sender), and routing can publish a resize for a page whose `` never mounts — so guest destruction can neither be the thing that drops an entry nor the only thing that can. + */ +describe('createSafeAreaController orientation ledger lifetime', () => { + function lastInsets(sink: Array<{ method: string; params: unknown }>) { + const call = [...sink].reverse().find((c) => c.method === 'Emulation.setSafeAreaInsetsOverride') + return (call?.params as { insets: Record } | undefined)?.insets + } + + it('keeps the page orientation when its render guest is replaced', () => { + const sinkOld: Array<{ method: string; params: unknown }> = [] + const sinkNew: Array<{ method: string; params: unknown }> = [] + const controller = createSafeAreaController() + const oldGuest = makeWc(30, sinkOld) + controller.applyToGuest(oldGuest, NOTCHED_PORTRAIT, page('bridge_swap')) + controller.recordPageOrientation('bridge_swap', 'landscape', NOTCHED_PORTRAIT) + + // The page reloads its render host: same bridgeId, a new WebContents. + const newGuest = makeWc(31, sinkNew) + controller.applyToGuest(newGuest, NOTCHED_PORTRAIT, page('bridge_swap')) + oldGuest.emit('destroyed') + sinkNew.length = 0 + + controller.reapplyAll(NOTCHED_PORTRAIT) + + expect( + lastInsets(sinkNew), + 'the surviving guest must keep the orientation its page reported', + ).toMatchObject({ top: 0, left: 44, right: 44 }) + }) + + it('forgets a page whose guest never attached, so an interrupted route leaks nothing', () => { + const sink: Array<{ method: string; params: unknown }> = [] + const controller = createSafeAreaController() + // Routing published the incoming page's resize, then the route failed and React never mounted a `` for it: no guest will ever carry it. + controller.recordPageOrientation('bridge_ghost', 'landscape', NOTCHED_PORTRAIT) + + controller.forgetPageOrientation('bridge_ghost') + + // bridgeIds are never reused, but the entry must be gone all the same: a later guest claiming it would otherwise inherit a dead page's rotation. + controller.applyToGuest(makeWc(32, sink), NOTCHED_PORTRAIT, page('bridge_ghost')) + expect(lastInsets(sink)).toMatchObject({ top: 44, left: 0, right: 0 }) + }) + + it('forgetting one page leaves every other page\'s orientation intact', () => { + const sinkKept: Array<{ method: string; params: unknown }> = [] + const controller = createSafeAreaController() + controller.applyToGuest(makeWc(33, sinkKept), NOTCHED_PORTRAIT, page('bridge_kept')) + controller.recordPageOrientation('bridge_kept', 'landscape', NOTCHED_PORTRAIT) + controller.recordPageOrientation('bridge_gone', 'landscape', NOTCHED_PORTRAIT) + sinkKept.length = 0 + + controller.forgetPageOrientation('bridge_gone') + controller.reapplyAll(NOTCHED_PORTRAIT) + + expect(lastInsets(sinkKept)).toMatchObject({ top: 0, left: 44, right: 44 }) + }) +}) + +/** + * Every ledger this controller owns is per page or per guest, so a churn cycle that opens and closes the same number of each must leave every count exactly where it started. + * A count that only "looks small" hides the leak class these ledgers are prone to: an entry whose owner has an end the ledger never hears about. + */ +describe('createSafeAreaController ledger returns to baseline after churn', () => { + it('page open/close churn leaves no orientation entry and no guest behind', () => { + const registry = createConnectionRegistry() + const controller = createSafeAreaController({ connections: registry }) + const baseline = controller.census() + expect(baseline).toEqual({ guests: 0, leases: 0, pageOrientations: 0 }) + + for (let round = 0; round < 5; round++) { + const bridgeId = `bridge_churn_${round}` + const wc = makeWc(200 + round) + controller.applyToGuest(wc, NOTCHED_PORTRAIT, page(bridgeId)) + controller.recordPageOrientation(bridgeId, 'landscape', NOTCHED_PORTRAIT) + // The page ends, then its guest is destroyed — the real order for a navigateBack: main closes the page, React unmounts the ``. + controller.forgetPageOrientation(bridgeId) + wc.emit('destroyed') + } + + expect(controller.census(), 'five open/close rounds must leave nothing tracked').toEqual(baseline) + }) + + it('a page whose guest is swapped several times still ends with one forget', () => { + const controller = createSafeAreaController() + const baseline = controller.census() + + controller.recordPageOrientation('bridge_long', 'landscape', NOTCHED_PORTRAIT) + for (let round = 0; round < 3; round++) { + const wc = makeWc(300 + round) + controller.applyToGuest(wc, NOTCHED_PORTRAIT, page('bridge_long')) + wc.emit('destroyed') + expect( + controller.census().pageOrientations, + 'a guest swap must not end the page it was rendering', + ).toBe(1) + } + controller.forgetPageOrientation('bridge_long') + + expect(controller.census()).toEqual(baseline) + }) +}) + 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, page(null)) expect(wc.debugger.attach).toHaveBeenCalled() controller.dispose() @@ -179,7 +370,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, page(null)) expect(wc.debugger.attach).toHaveBeenCalled() controller.dispose() diff --git a/packages/devtools/src/main/services/safe-area/index.ts b/packages/devtools/src/main/services/safe-area/index.ts index 4164b336..ddced4c7 100644 --- a/packages/devtools/src/main/services/safe-area/index.ts +++ b/packages/devtools/src/main/services/safe-area/index.ts @@ -2,6 +2,7 @@ import type { WebContents } from 'electron' import type { ConnectionRegistry } from '@dimina-kit/electron-deck/main' import type { NativeDeviceInfo } from '../../../shared/ipc-channels.js' import { createCdpSessionBroker, type CdpSessionBroker, type CdpSessionLease } from '../cdp-session/index.js' +import { orientedSafeAreaInsets, type Orientation } from '@dimina-kit/electron-runtime/shared/page-orientation' /** * CSS `env(safe-area-inset-*)` simulation for render-host `` guests. @@ -24,6 +25,10 @@ import { createCdpSessionBroker, type CdpSessionBroker, type CdpSessionLease } f * `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.) + * + * Which orientation the insets are resolved AGAINST is per page, not per device: `pageOrientation` lets a page run landscape on an upright phone (and the reverse), and `wx.getSystemInfoSync().safeArea` already answers for the page. + * Both sides therefore go through the same `orientedSafeAreaInsets`, fed by the same authority — DeviceShell's `PAGE_RESIZE`, which reaches here as the runtime's `'session-orientation'` event and is routed by `bridgeId`. + * Spraying one orientation over every guest would be wrong: a tab substack keeps hidden pages mounted, and those keep their own. */ /** The 8-field CDP `SafeAreaInsets` shape (base + *Max). Omitting `*Max` leaves @@ -39,28 +44,76 @@ interface CdpSafeAreaInsets { leftMax: number } -function guestInsets(device: NativeDeviceInfo | null, isTabPage: boolean): CdpSafeAreaInsets { - const top = device?.safeAreaInsets.top ?? 0 +/** What main knows about the page a render guest is showing. */ +export interface GuestPage { + /** + * The `bridgeId` query param of the guest's render-host URL. + * Keys the per-page orientation this guest's insets are resolved against; null when the URL could not be parsed, which falls the guest back to the device. + */ + bridgeId: string | null + /** Selects the bottom-inset policy (see the module comment). */ + isTabPage: boolean +} + +function guestInsets( + device: NativeDeviceInfo | null, + isTabPage: boolean, + orientation: Orientation, +): CdpSafeAreaInsets { + // Insets follow the orientation on screen: in landscape the notch moves off the top edge and onto both sides, which is what WeChat's own base library resolves `env(safe-area-inset-*)` to for a landscape notched phone. + const insets = device + ? orientedSafeAreaInsets( + { statusBarHeight: device.statusBarHeight, hasNotch: device.notchType !== 'none', safeAreaInsets: device.safeAreaInsets }, + orientation, + ) + : { top: 0, right: 0, bottom: 0, left: 0 } + const top = insets.top // 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 = isTabPage ? 0 : insets.bottom + return { + top, + topMax: top, + right: insets.right, + rightMax: insets.right, + bottom, + bottomMax: bottom, + left: insets.left, + leftMax: insets.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 - /** Re-push insets to every still-attached guest after a device change (each - * guest keeps the page type it attached with). */ + * insets for the orientation its page shows (already reported for that `bridgeId`, else the device's). + * 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: GuestPage): void + /** Record the orientation one page shows and re-push that page's guest alone. + * Accepted before the guest attaches — routing publishes a page's resize before its `` mounts — so the first push is already correct. */ + recordPageOrientation(bridgeId: string, orientation: Orientation, device: NativeDeviceInfo | null): void + /** Drop a closed page's recorded orientation. The page's own lifetime is the + * only thing that ends the entry: a page keeps its bridgeId across a render guest swap, and an entry can exist before any guest attaches at all. */ + forgetPageOrientation(bridgeId: string): void + /** Re-push insets to every still-attached guest after a device change. Each + * guest keeps the page type it attached with and the orientation its own page last reported; only the inset magnitudes follow the new device. */ 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. */ dispose(): void + /** Point-in-time size of every ledger this controller owns. Leak coverage + * asserts EXACT equality around a churn cycle: each of these grows per page or per guest, so only the owner's own counts can show one of them being retained after the thing it belongs to is gone. */ + census(): SafeAreaCensus +} + +export interface SafeAreaCensus { + /** Attached render guests still tracked. */ + guests: number + /** CDP leases currently held. */ + leases: number + /** Pages whose reported orientation is still recorded. */ + pageOrientations: number } export function createSafeAreaController(options: { connections?: ConnectionRegistry, broker?: CdpSessionBroker } = {}): SafeAreaController { @@ -73,11 +126,13 @@ 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 - // 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() + // Each guest's page identity, fixed for its life — tracked SEPARATELY from the lease so a lost session (external detach) doesn't lose it: a later `override`/`reapplyAll` can reacquire and keep applying the same policy this guest attached with. + const guests = new Map() + // The orientation each page currently shows, as last reported by DeviceShell. + // Keyed by bridgeId rather than by guest so an orientation that arrives before the page's `` attaches is not lost. + // An entry lives for as long as its PAGE does: `forgetPageOrientation` (driven by page close / session teardown) ends it, and dispose() clears the lot. + // Guest destruction must not — the same page can be handed a replacement guest. + const pageOrientations = 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 +150,13 @@ export function createSafeAreaController(options: { connections?: ConnectionRegi return lease } - function override(wc: WebContents, device: NativeDeviceInfo | null, isTabPage: boolean): void { + /** The orientation this guest's own page shows; the device's until it reports. */ + function orientationFor(page: GuestPage, device: NativeDeviceInfo | null): Orientation { + const reported = page.bridgeId === null ? undefined : pageOrientations.get(page.bridgeId) + return reported ?? device?.deviceOrientation ?? 'portrait' + } + + function override(wc: WebContents, device: NativeDeviceInfo | null, page: GuestPage): void { if (wc.isDestroyed()) return const lease = ensureLease(wc) if (!lease) { @@ -106,29 +167,45 @@ export function createSafeAreaController(options: { connections?: ConnectionRegi return } void lease - .send('Emulation.setSafeAreaInsetsOverride', { insets: guestInsets(device, isTabPage) }) + .send('Emulation.setSafeAreaInsetsOverride', { + insets: guestInsets(device, page.isTabPage, orientationFor(page, device)), + }) .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) + const isFirstTime = !guests.has(wc) + guests.set(wc, page) if (isFirstTime) { - const forget = (): void => { pageType.delete(wc); leases.delete(wc) } + // Releases only what belongs to THIS WebContents. + // The page's recorded orientation is deliberately left alone: the runtime can hand the same bridgeId a replacement guest, and dropping the entry here would make the surviving page fall back to the device orientation while its JS still reports its own. `forgetPageOrientation` ends that entry. + const release = (): void => { + guests.delete(wc) + leases.delete(wc) + } if (options.connections) { - options.connections.acquire(wc).own(forget) + options.connections.acquire(wc).own(release) } else { - wc.once('destroyed', forget) + wc.once('destroyed', release) } } - override(wc, device, isTabPage) + override(wc, device, page) + }, + recordPageOrientation: (bridgeId, orientation, device) => { + pageOrientations.set(bridgeId, orientation) + for (const [wc, page] of guests) { + if (page.bridgeId === bridgeId) override(wc, device, page) + } + }, + forgetPageOrientation: (bridgeId) => { + pageOrientations.delete(bridgeId) }, reapplyAll: (device) => { - for (const [wc, isTabPage] of pageType) override(wc, device, isTabPage) + for (const [wc, page] of guests) override(wc, device, page) }, dispose: () => { // Release our leases only — the shared session's actual detach is the @@ -136,8 +213,14 @@ export function createSafeAreaController(options: { connections?: ConnectionRegi // still be using it). for (const lease of leases.values()) lease.dispose() leases.clear() - pageType.clear() + guests.clear() + pageOrientations.clear() if (ownsBroker) broker.dispose() }, + census: () => ({ + guests: guests.size, + leases: leases.size, + pageOrientations: pageOrientations.size, + }), } } 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 272729e4..adf96bc7 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,7 @@ import { handleCustomApiBridgeRequest, type CustomApiBridgeRequest, } from '../simulator/custom-apis.js' -import type { SafeAreaController } from '../safe-area/index.js' +import type { GuestPage, SafeAreaController } 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' @@ -296,23 +296,21 @@ 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. + // Page identity (`bridgeId`, `isTab`) of each attaching guest, captured from its render-host URL in will-attach (`params.src` has the full URL) and consumed FIFO in the matching did-attach — `guestWc.getURL()` is 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: GuestPage[] = [] 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) + let query: URLSearchParams | null = null + try { query = new URL(params.src).searchParams } catch { /* guest then follows the device */ } + pendingGuestPages.push({ bridgeId: query?.get('bridgeId') ?? null, isTabPage: query?.get('isTab') === '1' }) }) simWc.on('did-attach-webview', (_event, guestWc) => { // Scale the nested render-host page with the device zoom. The host WCV is @@ -328,10 +326,10 @@ export function createNativeSimulatorView( // 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 - // render-host URL in will-attach (FIFO). - const isTabGuest = pendingGuestIsTab.shift() ?? false - safeArea.applyToGuest(guestWc, ctx.bridge?.getDevice() ?? null, isTabGuest) + // is full-bleed (real bottom inset). + // The insets resolve against the orientation this page shows, keyed by the bridgeId captured above. + const guestPage = pendingGuestPages.shift() ?? { bridgeId: null, isTabPage: 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/main/services/views/view-manager.ts b/packages/devtools/src/main/services/views/view-manager.ts index 4dafee8f..98d8535d 100644 --- a/packages/devtools/src/main/services/views/view-manager.ts +++ b/packages/devtools/src/main/services/views/view-manager.ts @@ -1,5 +1,6 @@ import type { WebContents } from 'electron' import type { NativeDeviceInfo } from '../../../shared/ipc-channels.js' +import type { Orientation } from '@dimina-kit/electron-runtime/shared/page-orientation' // eslint-disable-next-line no-restricted-syntax -- grandfathered(workbench-context): shrink-only import { type WorkbenchContext } from '../workbench-context.js' import type { HostToolbarMessageSubscription } from './host-toolbar-port-channel.js' @@ -218,6 +219,18 @@ export interface ViewManager { * pick it up automatically when they attach (`did-attach-webview`). */ reapplySafeArea(device: NativeDeviceInfo | null): void + /** + * NATIVE-HOST ONLY. + * Re-resolve ONE page's CSS `env(safe-area-inset-*)` override against the orientation that page now shows, so it keeps agreeing with the `safeArea` its own `wx.getSystemInfoSync()` reports. + * Routed by `bridgeId`: a tab substack keeps hidden pages mounted at their own orientation, which the top page's must not overwrite. + */ + setPageSafeAreaOrientation(bridgeId: string, orientation: Orientation): void + /** + * NATIVE-HOST ONLY. + * Release a closed page's recorded safe-area orientation. + * Driven by the page's own end (PAGE_CLOSE / session teardown), never by its render guest being destroyed — a page can be handed a replacement guest and must keep the orientation it reported. + */ + forgetPageSafeAreaOrientation(bridgeId: string): void /** * Window-resize entry point. Re-applies the settings overlay's bounds only. * Simulator + DevTools overlay geometry is anchor-published (the renderer's @@ -449,6 +462,10 @@ export function createViewManager(ctx: ViewManagerContext): ViewManager { refreshSimulatorStyles: nativeSimulator.refreshSimulatorStyles, detachSimulator: nativeSimulator.detachSimulator, reapplySafeArea: (device) => safeArea.reapplyAll(device), + setPageSafeAreaOrientation: (bridgeId, orientation) => { + safeArea.recordPageOrientation(bridgeId, orientation, ctx.bridge?.getDevice() ?? null) + }, + forgetPageSafeAreaOrientation: (bridgeId) => safeArea.forgetPageOrientation(bridgeId), showSettings: overlayPanels.showSettings, hideSettings: overlayPanels.hideSettings, showPopover: overlayPanels.showPopover, diff --git a/packages/devtools/src/main/services/workbench-context-page-safe-area.test.ts b/packages/devtools/src/main/services/workbench-context-page-safe-area.test.ts new file mode 100644 index 00000000..fb23ee0b --- /dev/null +++ b/packages/devtools/src/main/services/workbench-context-page-safe-area.test.ts @@ -0,0 +1,136 @@ +/** + * The runtime's `'session-orientation'` event is what tells main which orientation a page now shows. + * Two consumers read it, and they must not be collapsed into one: the renderer mirror wants the SESSION's orientation, the render guest's CSS `env(safe-area-inset-*)` wants the reporting PAGE's — the insets have to keep agreeing with the `safeArea` that page's own `wx.getSystemInfoSync()` returns. + * + * Routing the guest side by `bridgeId` is the load-bearing part: a tab substack keeps hidden pages mounted at their own orientation, so a session-wide re-push would stamp the top page's orientation onto them. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { getPath: vi.fn(() => '/tmp/dimina-test-userdata'), isPackaged: true }, + webContents: { + fromId: vi.fn(() => null), + getAllWebContents: vi.fn(() => []), + }, + default: {}, +})) + +vi.mock('fs', async () => { + const real = await vi.importActual('fs') + return { + ...real, + default: real, + existsSync: vi.fn(() => false), + readFileSync: vi.fn(() => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + } +}) + +let createWorkbenchContext: typeof import('./workbench-context.js').createWorkbenchContext + +beforeEach(async () => { + vi.resetModules() + ;({ createWorkbenchContext } = await import('./workbench-context.js')) +}) + +function fakeMainWindow(): import('electron').BrowserWindow { + const wc = { id: 1, isDestroyed: () => false, send: vi.fn(), getURL: () => '' } + return { + webContents: wc, + isDestroyed: () => false, + } as unknown as import('electron').BrowserWindow +} + +function buildContext() { + return createWorkbenchContext({ + mainWindow: fakeMainWindow(), + preloadPath: '/fake/preload.js', + rendererDir: '/fake/renderer', + }) +} + +describe('workbench-context: session-orientation drives the reporting page\'s safe area', () => { + it('routes the orientation to that one bridgeId', () => { + const ctx = buildContext() + const spy = vi.spyOn(ctx.views, 'setPageSafeAreaOrientation').mockImplementation(() => {}) + + ctx.events.emit('session-orientation', { + appSessionId: 'app_1', + bridgeId: 'bridge_detail', + orientation: 'landscape', + canRotate: false, + active: true, + }) + + expect( + spy, + 'the guest showing this page must have its env(safe-area-inset-*) resolved ' + + 'against the orientation the page reported, not the device orientation', + ).toHaveBeenCalledWith('bridge_detail', 'landscape') + }) + + /** + * `active` says which session the USER is looking at; a page's own `env(safe-area-inset-*)` is per-page and has to be right before the page is ever shown — a soft-reload session paints its first frame while still hidden. + */ + it('routes the orientation of a page in a session that is not on screen too', () => { + const ctx = buildContext() + const spy = vi.spyOn(ctx.views, 'setPageSafeAreaOrientation').mockImplementation(() => {}) + + ctx.events.emit('session-orientation', { + appSessionId: 'app_2', + bridgeId: 'bridge_booting', + orientation: 'landscape', + canRotate: false, + active: false, + }) + + expect(spy).toHaveBeenCalledWith('bridge_booting', 'landscape') + }) + + it('leaves every guest alone when no page is reporting (session teardown)', () => { + const ctx = buildContext() + const spy = vi.spyOn(ctx.views, 'setPageSafeAreaOrientation').mockImplementation(() => {}) + + ctx.events.emit('session-orientation', { + appSessionId: 'app_1', + bridgeId: null, + orientation: null, + canRotate: true, + active: true, + }) + + expect(spy).not.toHaveBeenCalled() + }) + + /** + * The recorded orientation belongs to the PAGE. `'page-closed'` is the only event that carries the page's own end — a render guest being destroyed does not mean the page ended (the same bridgeId can be handed a replacement guest), and the session-level teardown signal names no page at all. + */ + it('releases the page\'s recorded orientation when the page closes', () => { + const ctx = buildContext() + const spy = vi.spyOn(ctx.views, 'forgetPageSafeAreaOrientation').mockImplementation(() => {}) + + ctx.events.emit('page-closed', { appSessionId: 'app_1', bridgeId: 'bridge_detail' }) + + expect(spy).toHaveBeenCalledWith('bridge_detail') + }) + + it('still mirrors the session orientation to the renderer', () => { + const ctx = buildContext() + vi.spyOn(ctx.views, 'setPageSafeAreaOrientation').mockImplementation(() => {}) + const sessionOrientationChanged = vi.fn() + ctx.notify = { sessionOrientationChanged } as unknown as typeof ctx.notify + + ctx.events.emit('session-orientation', { + appSessionId: 'app_1', + bridgeId: 'bridge_detail', + orientation: 'landscape', + canRotate: false, + active: true, + }) + + expect(sessionOrientationChanged).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/devtools/src/main/services/workbench-context.ts b/packages/devtools/src/main/services/workbench-context.ts index 2dfd2f00..0039615d 100644 --- a/packages/devtools/src/main/services/workbench-context.ts +++ b/packages/devtools/src/main/services/workbench-context.ts @@ -341,6 +341,19 @@ export function createWorkbenchContext(opts: CreateContextOptions): WorkbenchCon ctx.registry.add(ctx.events.on('session-status', (payload) => { ctx.notify?.sessionRuntimeStatus(payload) })) + ctx.registry.add(ctx.events.on('session-orientation', (payload) => { + ctx.notify?.sessionOrientationChanged(payload) + // The reporting page's render guest resolves its CSS env(safe-area-inset-*) against this same orientation, so the insets keep agreeing with the `safeArea` that page's own `wx.getSystemInfoSync()` returns. + // Routed by bridgeId — hidden tab-substack guests hold their own orientation. + if (payload.bridgeId !== null && payload.orientation !== null) { + ctx.views.setPageSafeAreaOrientation(payload.bridgeId, payload.orientation) + } + })) + ctx.registry.add(ctx.events.on('page-closed', ({ bridgeId }) => { + // The page is what owns its recorded safe-area orientation. + // Releasing it here rather than off the render guest's destruction is what lets a page survive a render-host swap with its orientation intact, and what stops a page whose guest never attached from leaving an entry behind. + ctx.views.forgetPageSafeAreaOrientation(bridgeId) + })) ctx.registry.add(ctx.events.on('app-data-evict', ({ appId, bridgeId }) => { ctx.appData?.evictBridge(appId, bridgeId) })) diff --git a/packages/devtools/src/preload/runtime/native-host.ts b/packages/devtools/src/preload/runtime/native-host.ts index 755d7251..bde48816 100644 --- a/packages/devtools/src/preload/runtime/native-host.ts +++ b/packages/devtools/src/preload/runtime/native-host.ts @@ -1,6 +1,7 @@ import { ipcRenderer } from 'electron' import { BRIDGE_CHANNELS as C } from '../../shared/bridge-channels.js' import type { NativeDeviceInfo } from '../../shared/ipc-channels.js' +import type { PageResizePayload } from '@dimina-kit/electron-runtime/shared/page-orientation' // (extension required: preload tsconfig is moduleResolution node16) import type { ActivePagePayload, @@ -13,6 +14,7 @@ import type { PageOpenRequest, PageOpenResult, PageStackPayload, + SessionActivePayload, SpawnRequest, SpawnResult, } from '../../shared/bridge-channels.js' @@ -51,6 +53,13 @@ export interface DiminaNativeHostBridge { notifyActivePage(payload: ActivePagePayload): void /** Tell main the full ordered page stack (for automation's App.getPageStack). */ notifyPageStack(payload: PageStackPayload): void + /** Tell main the visible top page's window geometry changed (PAGE_RESIZE). */ + notifyResize(payload: PageResizePayload): void + /** + * Tell main this app session's shell is the one on screen. + * Soft reload mounts two shells at once, so main cannot infer visibility from who published geometry last — the shell that owns the screen declares it. + */ + notifySessionActive(payload: SessionActivePayload): void createRenderHostUrl(opts: RenderHostUrlOptions): string renderPreloadUrl: string /** @@ -116,6 +125,12 @@ function buildBridge(cfg: NativeHostConfig): DiminaNativeHostBridge { notifyPageStack(payload) { ipcRenderer.send(C.PAGE_STACK, payload) }, + notifyResize(payload) { + ipcRenderer.send(C.PAGE_RESIZE, payload) + }, + notifySessionActive(payload) { + ipcRenderer.send(C.SESSION_ACTIVE, payload) + }, createRenderHostUrl(opts) { // Same-origin document on `dmb-resource://////__frame__.html` // (path depth tracks the page's package directory depth) so relative diff --git a/packages/devtools/src/preload/shared/api-compat.ts b/packages/devtools/src/preload/shared/api-compat.ts index 22f31a3b..bebed629 100644 --- a/packages/devtools/src/preload/shared/api-compat.ts +++ b/packages/devtools/src/preload/shared/api-compat.ts @@ -1,3 +1,4 @@ +import { normalizeDeviceOrientation } from '@dimina-kit/electron-runtime/shared/page-orientation' import { performRequest } from '../../shared/request-core.js' type Callback = ((payload: T) => void) | undefined @@ -53,11 +54,13 @@ function ensureWxApi(wx: Record): void { if (typeof wx.getSystemSetting !== 'function') { wx.getSystemSetting = (opts: { success?: Callback; complete?: Callback } = {}) => { + // No device snapshot available on this fallback (browser-only) path — derive from the window dimensions, matching WeChat's own guidance to trust the reported size over a device-reported orientation. + const { windowWidth, windowHeight } = buildWindowInfo() const info = { bluetoothEnabled: false, locationEnabled: true, wifiEnabled: true, - deviceOrientation: 'portrait', + deviceOrientation: normalizeDeviceOrientation({ windowWidth, windowHeight }), } call(opts.success, info) call(opts.complete, undefined) 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 58a83d0d..44df331b 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 @@ -40,6 +40,9 @@ interface SimulatorPanelProps { zoom: ZoomSetting; onDeviceChange: (e: React.ChangeEvent) => void; onZoomChange: (e: React.ChangeEvent) => void; + /** Whether the rotate button is enabled — false while the active session pins the top page to a fixed orientation. Defaults to true (no session wired). */ + canRotateDevice?: boolean; + onRotateDevice?: () => void; compileStatus: { status: string; message: string }; currentPage: string; copied: boolean; @@ -86,6 +89,8 @@ export function SimulatorPanel({ zoom, onDeviceChange, onZoomChange, + canRotateDevice = true, + onRotateDevice = () => {}, compileStatus, currentPage, copied, @@ -287,6 +292,33 @@ export function SimulatorPanel({ ))} + {/* Rotates the simulated device. Disabled while the active session + pins the top page to a fixed orientation (canRotateDevice=false) — + WeChat parity: only an `auto` page follows a manual rotation. */} + {/* Persistent, never covers the device region below (contract: "不遮内容"). */} diff --git a/packages/devtools/src/renderer/modules/main/features/project-runtime/controllers/use-device-auto-zoom.test.tsx b/packages/devtools/src/renderer/modules/main/features/project-runtime/controllers/use-device-auto-zoom.test.tsx index 41b3929f..04c9505d 100644 --- a/packages/devtools/src/renderer/modules/main/features/project-runtime/controllers/use-device-auto-zoom.test.tsx +++ b/packages/devtools/src/renderer/modules/main/features/project-runtime/controllers/use-device-auto-zoom.test.tsx @@ -4,10 +4,19 @@ * string value to the right branch (AUTO_ZOOM stays the sentinel, everything * else becomes a number). */ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { renderHook, act } from '@testing-library/react' import type React from 'react' import { AUTO_ZOOM, DEVICES } from '@/shared/constants' + +// useDevice mounts a session-orientation subscription, reads back main's cached device orientation, and can push device info over the preload IPC bridge — none of which exist in this bridge-free unit test. +// Stub all three so mounting the hook doesn't throw; getNativeDeviceInfo resolves null (no cached device) so the gate opens immediately with the portrait default. +vi.mock('@/shared/api', () => ({ + setNativeDeviceInfo: vi.fn(async () => {}), + getNativeDeviceInfo: vi.fn(async () => null), + onSessionOrientationChanged: vi.fn(() => () => {}), +})) + import { useDevice } from './use-device' function changeEvent(value: string): React.ChangeEvent { diff --git a/packages/devtools/src/renderer/modules/main/features/project-runtime/controllers/use-device-orientation.test.tsx b/packages/devtools/src/renderer/modules/main/features/project-runtime/controllers/use-device-orientation.test.tsx new file mode 100644 index 00000000..d2e49e64 --- /dev/null +++ b/packages/devtools/src/renderer/modules/main/features/project-runtime/controllers/use-device-orientation.test.tsx @@ -0,0 +1,247 @@ +/** + * Guards two orientation contracts in useDevice: + * - the session-orientation mirror follows the session main reports as the + * VISIBLE one (`active`), which the simulator's promotion layer declares. + * Whoever reported last says nothing about who is on screen: during a soft reload two sessions report, and the outgoing one keeps reporting after the incoming one has taken the screen. + * - the FIRST `sendDeviceInfo` push is held back until the read-back of + * main's persisted orientation resolves, so a ProjectRuntime remount never overwrites main's cache with the local portrait default. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, act, waitFor } from '@testing-library/react' +import { DEVICES } from '@/shared/constants' + +interface OrientationPayload { + appSessionId: string + orientation: 'portrait' | 'landscape' | null + canRotate: boolean + /** Whether this report comes from the session currently declared visible. */ + active: boolean +} + +const { orientationListeners, setNativeDeviceInfo, getNativeDeviceInfo } = vi.hoisted(() => ({ + orientationListeners: [] as Array<(p: OrientationPayload) => void>, + setNativeDeviceInfo: vi.fn(async () => {}), + getNativeDeviceInfo: vi.fn(async (): Promise<{ deviceOrientation?: 'portrait' | 'landscape' } | null> => null), +})) + +function emitOrientation(payload: OrientationPayload): void { + for (const fn of [...orientationListeners]) fn(payload) +} + +vi.mock('@/shared/api', () => ({ + setNativeDeviceInfo, + getNativeDeviceInfo, + onSessionOrientationChanged: vi.fn((handler: (p: OrientationPayload) => void) => { + orientationListeners.push(handler) + return () => { + const i = orientationListeners.indexOf(handler) + if (i >= 0) orientationListeners.splice(i, 1) + } + }), +})) + +import { useDevice } from './use-device' + +beforeEach(() => { + orientationListeners.length = 0 + setNativeDeviceInfo.mockClear() + getNativeDeviceInfo.mockReset() + getNativeDeviceInfo.mockResolvedValue(null) +}) + +/** + * `appOrientation` isn't exposed directly (only `orientedDevice`/`canRotate` are — matching the hook's real public contract), so tests observe the mirror through the panel geometry it drives: DEVICES[1] is 375×812 portrait, so a landscape effective orientation swaps to width>height. + */ +function isLandscape(orientedDevice: { width: number; height: number }): boolean { + return orientedDevice.width > orientedDevice.height +} + +describe('useDevice: the session-orientation mirror follows the declared visible session', () => { + it('adopts the report of the session that is on screen', async () => { + const { result } = renderHook(() => useDevice({ initialDevice: DEVICES[1]! })) + await waitFor(() => expect(getNativeDeviceInfo).toHaveBeenCalled()) + + act(() => emitOrientation({ appSessionId: 'session-A', orientation: 'landscape', canRotate: false, active: true })) + expect(isLandscape(result.current.orientedDevice)).toBe(true) + expect(result.current.canRotate).toBe(false) + }) + + it('ignores a session that is not the one on screen, however loudly it reports', async () => { + const { result } = renderHook(() => useDevice({ initialDevice: DEVICES[1]! })) + await waitFor(() => expect(getNativeDeviceInfo).toHaveBeenCalled()) + + act(() => emitOrientation({ appSessionId: 'session-A', orientation: 'landscape', canRotate: false, active: true })) + // A soft-reload session boots invisibly and publishes its geometry while session-A still owns the screen. + act(() => emitOrientation({ appSessionId: 'session-B', orientation: 'portrait', canRotate: true, active: false })) + + expect(isLandscape(result.current.orientedDevice)).toBe(true) + expect(result.current.canRotate).toBe(false) + }) + + it("ignores the teardown of a session that was already replaced on screen", async () => { + const { result } = renderHook(() => useDevice({ initialDevice: DEVICES[1]! })) + await waitFor(() => expect(getNativeDeviceInfo).toHaveBeenCalled()) + + act(() => emitOrientation({ appSessionId: 'session-A', orientation: 'landscape', canRotate: false, active: true })) + act(() => emitOrientation({ appSessionId: 'session-B', orientation: null, canRotate: true, active: false })) + + expect(isLandscape(result.current.orientedDevice)).toBe(true) + expect(result.current.canRotate).toBe(false) + }) + + it("falls back to the device on the visible session's own teardown", async () => { + const { result } = renderHook(() => useDevice({ initialDevice: DEVICES[1]! })) + await waitFor(() => expect(getNativeDeviceInfo).toHaveBeenCalled()) + + act(() => emitOrientation({ appSessionId: 'session-A', orientation: 'landscape', canRotate: false, active: true })) + act(() => emitOrientation({ appSessionId: 'session-A', orientation: null, canRotate: true, active: true })) + + expect(isLandscape(result.current.orientedDevice)).toBe(false) + expect(result.current.canRotate).toBe(true) + }) + + it('keeps the promoted session after a hot reload, not the outgoing one that reports last', async () => { + const { result, rerender } = renderHook(() => useDevice({ initialDevice: DEVICES[1]! })) + await waitFor(() => expect(getNativeDeviceInfo).toHaveBeenCalled()) + + // The outgoing session owns the screen and pins a landscape page. + act(() => emitOrientation({ appSessionId: 'session-A', orientation: 'landscape', canRotate: false, active: true })) + expect(isLandscape(result.current.orientedDevice)).toBe(true) + + // A fresh launch round begins. + // Nothing about it may disturb the mirror — session-A is still the shell the user is looking at. + rerender() + expect( + isLandscape(result.current.orientedDevice), + 'the outgoing session is still on screen while the new one boots', + ).toBe(true) + + // Pushing the device down to the live session (main re-broadcasts it) makes session-A report again, after the new session already exists. + act(() => emitOrientation({ appSessionId: 'session-A', orientation: 'landscape', canRotate: false, active: true })) + + // The new session is promoted and republishes its own top page: a portrait page that is still pinned, which no fallback can produce (falling back to the device would leave the rotate control enabled). + act(() => emitOrientation({ appSessionId: 'session-B', orientation: 'portrait', canRotate: false, active: true })) + + // Only now is the outgoing session disposed — its teardown arrives last. + act(() => emitOrientation({ appSessionId: 'session-A', orientation: null, canRotate: true, active: false })) + + expect( + isLandscape(result.current.orientedDevice), + 'the promoted session decides the panel geometry, not whoever spoke last', + ).toBe(false) + expect( + result.current.canRotate, + 'session-B is still being mirrored — its teardown-less predecessor must not have reset the panel to "no session"', + ).toBe(false) + }) + + it("adopts the promoted session's landscape page even though the outgoing one was portrait", async () => { + const { result } = renderHook(() => useDevice({ initialDevice: DEVICES[1]! })) + await waitFor(() => expect(getNativeDeviceInfo).toHaveBeenCalled()) + + act(() => emitOrientation({ appSessionId: 'session-A', orientation: 'portrait', canRotate: true, active: true })) + act(() => emitOrientation({ appSessionId: 'session-B', orientation: 'landscape', canRotate: false, active: false })) + expect( + isLandscape(result.current.orientedDevice), + 'the incoming session is still hidden — the panel must not rotate under the visible one', + ).toBe(false) + + act(() => emitOrientation({ appSessionId: 'session-B', orientation: 'landscape', canRotate: false, active: true })) + act(() => emitOrientation({ appSessionId: 'session-A', orientation: null, canRotate: true, active: false })) + + expect(isLandscape(result.current.orientedDevice)).toBe(true) + expect(result.current.canRotate).toBe(false) + }) +}) + +describe('useDevice: sendDeviceInfo held back until orientation read-back resolves', () => { + it('queues an early push instead of sending the local portrait default, then flushes with the corrected orientation once read-back resolves', async () => { + let resolveReadBack!: (v: { deviceOrientation: 'portrait' | 'landscape' } | null) => void + getNativeDeviceInfo.mockReturnValue(new Promise((resolve) => { + resolveReadBack = resolve + })) + + const { result } = renderHook(() => useDevice({ initialDevice: DEVICES[1]! })) + + act(() => { + result.current.sendDeviceInfo(result.current.device) + }) + // Read-back hasn't resolved yet — must NOT have pushed anything (that would overwrite main's cache with the still-default portrait value). + expect(setNativeDeviceInfo).not.toHaveBeenCalled() + + await act(async () => { + resolveReadBack({ deviceOrientation: 'landscape' }) + await Promise.resolve() + await Promise.resolve() + }) + + expect(setNativeDeviceInfo).toHaveBeenCalledTimes(1) + expect(setNativeDeviceInfo).toHaveBeenCalledWith( + expect.objectContaining({ deviceOrientation: 'landscape' }), + ) + expect(result.current.deviceOrientation).toBe('landscape') + }) + + it('a rotation during the read-back wins — the late persisted value must not undo the click', async () => { + let resolveReadBack!: (v: { deviceOrientation: 'portrait' | 'landscape' } | null) => void + getNativeDeviceInfo.mockReturnValue(new Promise((resolve) => { + resolveReadBack = resolve + })) + + const { result } = renderHook(() => useDevice({ initialDevice: DEVICES[1]! })) + + // Main still holds portrait and has not answered yet; the user rotates. + act(() => { + result.current.handleRotateDevice() + }) + expect(result.current.deviceOrientation).toBe('landscape') + expect(setNativeDeviceInfo).not.toHaveBeenCalled() + + await act(async () => { + resolveReadBack({ deviceOrientation: 'portrait' }) + await Promise.resolve() + await Promise.resolve() + }) + + expect(result.current.deviceOrientation).toBe('landscape') + expect(isLandscape(result.current.orientedDevice)).toBe(true) + // The queued push flushes with the user's orientation, not main's stale one. + expect(setNativeDeviceInfo).toHaveBeenCalledTimes(1) + expect(setNativeDeviceInfo).toHaveBeenCalledWith( + expect.objectContaining({ deviceOrientation: 'landscape' }), + ) + }) + + it('a call after read-back resolves pushes immediately (the gate never blocks user actions again)', async () => { + getNativeDeviceInfo.mockResolvedValue(null) + const { result } = renderHook(() => useDevice({ initialDevice: DEVICES[1]! })) + await waitFor(() => expect(getNativeDeviceInfo).toHaveBeenCalled()) + // Let the resolved promise's .then() run. + await act(async () => { + await Promise.resolve() + }) + + act(() => { + result.current.handleRotateDevice() + }) + expect(setNativeDeviceInfo).toHaveBeenCalledWith( + expect.objectContaining({ deviceOrientation: 'landscape' }), + ) + }) + it('a rejected read-back still opens the gate, so rotation keeps working for the rest of the mount', async () => { + // Main keeps whatever orientation it already had; what must not happen is the gate staying shut, which would silently swallow every later rotation and device switch. + getNativeDeviceInfo.mockRejectedValue(new Error('invoke failed')) + const { result } = renderHook(() => useDevice({ initialDevice: DEVICES[1]! })) + await waitFor(() => expect(getNativeDeviceInfo).toHaveBeenCalled()) + await act(async () => { + await Promise.resolve() + }) + + act(() => { + result.current.handleRotateDevice() + }) + expect(setNativeDeviceInfo).toHaveBeenCalledWith( + expect.objectContaining({ deviceOrientation: 'landscape' }), + ) + }) +}) diff --git a/packages/devtools/src/renderer/modules/main/features/project-runtime/controllers/use-device.ts b/packages/devtools/src/renderer/modules/main/features/project-runtime/controllers/use-device.ts index 5c36d68b..e028ee27 100644 --- a/packages/devtools/src/renderer/modules/main/features/project-runtime/controllers/use-device.ts +++ b/packages/devtools/src/renderer/modules/main/features/project-runtime/controllers/use-device.ts @@ -6,11 +6,13 @@ import { useState, } from 'react' import type { RefObject } from 'react' +import type { Orientation } from '@dimina-kit/electron-runtime/shared/page-orientation' import { AUTO_ZOOM, DEVICES, type ZoomSetting } from '@/shared/constants' -import { setNativeDeviceInfo } from '@/shared/api' +import { getNativeDeviceInfo, onSessionOrientationChanged, setNativeDeviceInfo } from '@/shared/api' import { clampPanelWidth, computeSimPanelWidth, + orientedDeviceSize, } from '../lib/device-geometry' import type { DeviceType } from './use-project-runtime-controller' @@ -21,6 +23,16 @@ export interface UseDeviceProps { export interface DeviceHookResult { device: DeviceType zoom: ZoomSetting + /** + * The simulated device's own orientation — user-controlled via the rotate button, persists across mini-app sessions (device switches, relaunches), and is never written back to by the mini-app itself. + * See shared/page-orientation.ts for how it combines with a page's own orientation config into what's actually shown. + */ + deviceOrientation: Orientation + handleRotateDevice: () => void + /** Whether the rotate control should be enabled — false while the active session's top page is pinned to a fixed orientation. True with no session. */ + canRotate: boolean + /** `device` sized at the currently-displayed orientation (`appOrientation ?? deviceOrientation`) — what SimulatorPanel should render at. */ + orientedDevice: { name: string; width: number; height: number } simPanelWidth: number setSimPanelWidth: (width: number) => void handleDeviceChange: (e: React.ChangeEvent) => void @@ -50,12 +62,26 @@ export function useDevice(props: UseDeviceProps): DeviceHookResult { const [device, setDevice] = useState(initialDevice) const [zoom, setZoom] = useState(85) + // Persists across device switches / relaunches within THIS mount — only the rotate button changes it, never a mini-app or a device swap. + // Defaults to portrait until the read-back below (main/ipc/simulator.ts's GetDeviceInfo, returning ctx.bridge.getDevice()) resolves and corrects it — see `orientationReadyRef` for how the FIRST `sendDeviceInfo` push is held back so this local default is never the one that overwrites main's cache. + const [deviceOrientation, setDeviceOrientation] = useState('portrait') + const deviceOrientationRef = useRef(deviceOrientation) + // The active session's forced orientation (main's translation of the top page's effective orientation), or null with no session — see shared/page-orientation.ts: this is a MIRROR only, device-shell remains the sole authority that computes it. + const [appOrientation, setAppOrientation] = useState(null) + const [canRotate, setCanRotate] = useState(true) + const effectiveOrientation = appOrientation ?? deviceOrientation const [simPanelWidth, setSimPanelWidth] = useState(() => computeSimPanelWidth(initialDevice.width), ) const simPanelWidthRef = useRef(simPanelWidth) const deviceRef = useRef(device) - + // Gates the FIRST `sendDeviceInfo` push: while false, `sendDeviceInfo` only records `d` in `pendingDeviceRef` instead of pushing it, so a mount-time caller (use-simulator.ts pushes the device before every attach) can never overwrite main's persisted orientation with the local portrait default — main's cache is the one that's still correct, untouched, for as long as this gate holds. + // Read-back and any subsequent push are independent of this gate once it flips true (never blocks user actions again). + const orientationReadyRef = useRef(false) + const pendingDeviceRef = useRef(null) + // Counts user rotations. + // The read-back below stamps this when it starts and adopts main's persisted orientation only if the stamp still matches on arrival: a rotation that happens while the read is in flight is the newer intent, and letting the older value land would silently undo the click. + const orientationMutationRef = useRef(0) useEffect(() => { simPanelWidthRef.current = simPanelWidth }, [simPanelWidth]) @@ -64,7 +90,17 @@ export function useDevice(props: UseDeviceProps): DeviceHookResult { deviceRef.current = device }, [device]) - const sendDeviceInfo = useCallback((d: DeviceType) => { + // This panel shows one phone, so it mirrors exactly one session: the one the simulator declared as being on screen, which main marks with `active`. + // Every other report belongs to a shell the user cannot see — a soft-reload session booting behind the live one, or the outgoing session still reporting (and finally tearing down) after the swap — and moving the panel for any of them would rotate it under the mini-app actually on screen. + useEffect(() => onSessionOrientationChanged((payload) => { + if (!payload.active) return + // `orientation: null` means the session on screen forces nothing (its top page is `auto`) or has just ended: both fall back to the device's own orientation, which is what a null `appOrientation` renders. + setAppOrientation(payload.orientation) + setCanRotate(payload.canRotate) + }), []) + + // The actual IPC push, factored out so both `sendDeviceInfo` and the gate-flush below (once the orientation read-back resolves) share it. + const pushDeviceInfo = useCallback((d: DeviceType) => { // The simulator is a main-process WebContentsView, so there is no renderer // to receive `device:change`. The mini-app's authoritative // `wx.getSystemInfoSync()` runs in the hidden service-host window off its @@ -83,9 +119,49 @@ export function useDevice(props: UseDeviceProps): DeviceHookResult { statusBarHeight: d.statusBarHeight, notchType: d.notchType, safeAreaInsets: { ...d.safeAreaInsets }, + deviceOrientation: deviceOrientationRef.current, }) }, []) + const sendDeviceInfo = useCallback((d: DeviceType) => { + if (!orientationReadyRef.current) { + // Queue instead of pushing now — main already holds the correct orientation from before this mount; pushing the local portrait default here would overwrite it with the wrong value. + // The read-back effect flushes this (with the corrected `deviceOrientationRef`) the moment it resolves. + pendingDeviceRef.current = d + return + } + pushDeviceInfo(d) + }, [pushDeviceInfo]) + + // One-shot read-back of the orientation main already holds (persists across this window's ProjectRuntime mounts — main/ipc/simulator.ts's GetDeviceInfo, `ctx.bridge.getDevice()`). + // Runs once per mount; opens the `sendDeviceInfo` gate and flushes whatever queued while it was closed. + useEffect(() => { + let cancelled = false + const issuedAt = orientationMutationRef.current + const openGate = () => { + orientationReadyRef.current = true + const pending = pendingDeviceRef.current + pendingDeviceRef.current = null + if (pending) pushDeviceInfo(pending) + } + void getNativeDeviceInfo().then((info) => { + if (cancelled) return + // Skipped when the user rotated while this was in flight — the queued push below then flushes the user's orientation, not the stale one. + if (info?.deviceOrientation && orientationMutationRef.current === issuedAt) { + deviceOrientationRef.current = info.deviceOrientation + setDeviceOrientation(info.deviceOrientation) + } + openGate() + }).catch(() => { + // A failed read-back leaves main's orientation as the better value, but the gate must still open: leaving it shut would silently drop every device switch and rotation for the rest of this mount. + if (!cancelled) openGate() + }) + return () => { + cancelled = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + const handleDeviceChange = useCallback( (e: React.ChangeEvent) => { const d = DEVICES.find((item) => item.name === e.target.value) ?? DEVICES[1]! @@ -94,11 +170,20 @@ export function useDevice(props: UseDeviceProps): DeviceHookResult { // React layout state is the single width authority: the panel re-renders // at the new width, and the simulator/DevTools view anchors re-measure // and publish the precise rects to main (no width IPC side-channel). - setSimPanelWidth(computeSimPanelWidth(d.width)) + // Sized at the currently-DISPLAYED orientation, not the device's raw portrait width — switching device model mid-landscape-session must not snap the panel back to a portrait width. + setSimPanelWidth(computeSimPanelWidth(orientedDeviceSize(d, effectiveOrientation).width)) }, - [sendDeviceInfo], + [sendDeviceInfo, effectiveOrientation], ) + const handleRotateDevice = useCallback(() => { + const next: Orientation = deviceOrientationRef.current === 'portrait' ? 'landscape' : 'portrait' + orientationMutationRef.current += 1 + deviceOrientationRef.current = next + setDeviceOrientation(next) + sendDeviceInfo(deviceRef.current) + }, [sendDeviceInfo]) + const handleZoomChange = useCallback( (e: React.ChangeEvent) => { setZoom(e.target.value === AUTO_ZOOM ? AUTO_ZOOM : (Number(e.target.value) as ZoomSetting)) @@ -136,6 +221,10 @@ export function useDevice(props: UseDeviceProps): DeviceHookResult { return { device, zoom, + deviceOrientation, + handleRotateDevice, + canRotate, + orientedDevice: { name: device.name, ...orientedDeviceSize(device, effectiveOrientation) }, simPanelWidth, setSimPanelWidth, handleDeviceChange, diff --git a/packages/devtools/src/renderer/modules/main/features/project-runtime/controllers/use-project-runtime-controller-compile-events.test.tsx b/packages/devtools/src/renderer/modules/main/features/project-runtime/controllers/use-project-runtime-controller-compile-events.test.tsx index 59620ea7..d517dbf5 100644 --- a/packages/devtools/src/renderer/modules/main/features/project-runtime/controllers/use-project-runtime-controller-compile-events.test.tsx +++ b/packages/devtools/src/renderer/modules/main/features/project-runtime/controllers/use-project-runtime-controller-compile-events.test.tsx @@ -40,6 +40,10 @@ vi.mock('./use-session', () => ({ vi.mock('./use-device', () => ({ useDevice: vi.fn(() => ({ device: { name: 'fake-device', width: 375, height: 812 }, + orientedDevice: { name: 'fake-device', width: 375, height: 812 }, + deviceOrientation: 'portrait', + canRotate: true, + handleRotateDevice: vi.fn(), zoom: 100, simPanelWidth: 400, simPanelWidthRef: { current: 400 }, diff --git a/packages/devtools/src/renderer/modules/main/features/project-runtime/controllers/use-project-runtime-controller-compile-logs.test.tsx b/packages/devtools/src/renderer/modules/main/features/project-runtime/controllers/use-project-runtime-controller-compile-logs.test.tsx index 4cedfeda..da742efa 100644 --- a/packages/devtools/src/renderer/modules/main/features/project-runtime/controllers/use-project-runtime-controller-compile-logs.test.tsx +++ b/packages/devtools/src/renderer/modules/main/features/project-runtime/controllers/use-project-runtime-controller-compile-logs.test.tsx @@ -32,6 +32,10 @@ vi.mock('./use-session', () => ({ vi.mock('./use-device', () => ({ useDevice: vi.fn(() => ({ device: { name: 'fake-device', width: 375, height: 812 }, + orientedDevice: { name: 'fake-device', width: 375, height: 812 }, + deviceOrientation: 'portrait', + canRotate: true, + handleRotateDevice: vi.fn(), zoom: 100, simPanelWidth: 400, simPanelWidthRef: { current: 400 }, diff --git a/packages/devtools/src/renderer/modules/main/features/project-runtime/controllers/use-project-runtime-controller.ts b/packages/devtools/src/renderer/modules/main/features/project-runtime/controllers/use-project-runtime-controller.ts index e235b36f..83569e3e 100644 --- a/packages/devtools/src/renderer/modules/main/features/project-runtime/controllers/use-project-runtime-controller.ts +++ b/packages/devtools/src/renderer/modules/main/features/project-runtime/controllers/use-project-runtime-controller.ts @@ -1,14 +1,13 @@ -import type React from 'react' import { useEffect, useRef } from 'react' import type { RefObject } from 'react' -import { DEVICES, SIM_PANEL_PADDING, type ZoomSetting } from '@/shared/constants' +import { DEVICES, SIM_PANEL_PADDING } from '@/shared/constants' import type { AppInfo, ProjectStatus, SessionRuntimeStatusPayload } from '@/shared/api' import type { CompileConfig } from '@/shared/types' import type { AppDataPanelSource, StoragePanelSource, WxmlPanelSource } from '@dimina-kit/inspect' import { DEFAULT_RIGHT_PANE_STATE } from '../types' import type { RightPaneState, RightPaneTabId } from '../types' -import { useDevice } from './use-device' +import { useDevice, type DeviceHookResult } from './use-device' import { useSession } from './use-session' import type { CompileEvent, CompileLogEntry } from './use-session' import { useSimulator } from './use-simulator' @@ -47,16 +46,11 @@ interface SessionSlice { watcherDead: boolean } -interface DeviceSlice { - device: DeviceType - zoom: ZoomSetting - simPanelWidth: number - setSimPanelWidth: (width: number) => void - handleDeviceChange: (e: React.ChangeEvent) => void - handleZoomChange: (e: React.ChangeEvent) => void - handleSplitterDrag: (e: React.MouseEvent) => void - sendDeviceInfo: (device: DeviceType) => void -} +/** + * What the controller republishes from the device hook: everything the hook returns except its internal refs, which exist for the hook's own callers. + * Derived rather than re-declared so a field added to the hook cannot silently go missing here. + */ +type DeviceSlice = Omit interface SimulatorSlice { simulatorRef: RefObject @@ -120,20 +114,20 @@ export function useProjectRuntimeController( // ── Compose sub-hooks ──────────────────────────────────────────────────── - const deviceHook = useDevice({ initialDevice }) - const sessionHook = useSession({ projectPath, }) - // Sync simulator panel width when device changes — separate from the - // openProject effect so device switches don't re-open the project. + const deviceHook = useDevice({ initialDevice }) + + // Sync simulator panel width when the device or its displayed orientation changes — separate from the openProject effect so these don't re-open the project. + // Sized at orientedDevice.width, not the device's raw portrait width, so a landscape session keeps a landscape-wide panel. useEffect(() => { if (sessionHook.compileStatus.status === 'ready') { - deviceHook.setSimPanelWidth(deviceHook.device.width + SIM_PANEL_PADDING * 2) + deviceHook.setSimPanelWidth(deviceHook.orientedDevice.width + SIM_PANEL_PADDING * 2) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [deviceHook.device.width, sessionHook.compileStatus.status, deviceHook.setSimPanelWidth]) + }, [deviceHook.orientedDevice.width, sessionHook.compileStatus.status, deviceHook.setSimPanelWidth]) const simulatorHook = useSimulator({ compileStatus: sessionHook.compileStatus, @@ -187,6 +181,10 @@ export function useProjectRuntimeController( device: { device: deviceHook.device, zoom: deviceHook.zoom, + deviceOrientation: deviceHook.deviceOrientation, + handleRotateDevice: deviceHook.handleRotateDevice, + canRotate: deviceHook.canRotate, + orientedDevice: deviceHook.orientedDevice, simPanelWidth: deviceHook.simPanelWidth, setSimPanelWidth: deviceHook.setSimPanelWidth, handleDeviceChange: deviceHook.handleDeviceChange, diff --git a/packages/devtools/src/renderer/modules/main/features/project-runtime/lib/device-geometry.ts b/packages/devtools/src/renderer/modules/main/features/project-runtime/lib/device-geometry.ts index 16b77451..a67df7e6 100644 --- a/packages/devtools/src/renderer/modules/main/features/project-runtime/lib/device-geometry.ts +++ b/packages/devtools/src/renderer/modules/main/features/project-runtime/lib/device-geometry.ts @@ -1,5 +1,21 @@ +import { orientedDeviceMetrics, type Orientation } from '@dimina-kit/electron-runtime/shared/page-orientation' import { SIM_PANEL_PADDING, MIN_PANEL_WIDTH_PX } from '@/shared/constants' +/** + * Device width/height at the given display orientation — landscape swaps them, matching how device-shell renders the phone. + * Single source of truth (`orientedDeviceMetrics`) shared with the simulator shell and main; panel sizing only needs the screen dimensions, not the status-bar split. + */ +export function orientedDeviceSize( + device: { width: number; height: number; statusBarHeight: number }, + orientation: Orientation, +): { width: number; height: number } { + const m = orientedDeviceMetrics( + { screenWidth: device.width, screenHeight: device.height, statusBarHeight: device.statusBarHeight }, + orientation, + ) + return { width: m.screenWidth, height: m.screenHeight } +} + /** Calculate simulator panel width from device width. */ export function computeSimPanelWidth(deviceWidth: number): number { return deviceWidth + SIM_PANEL_PADDING * 2 diff --git a/packages/devtools/src/renderer/modules/main/features/project-runtime/project-runtime.tsx b/packages/devtools/src/renderer/modules/main/features/project-runtime/project-runtime.tsx index 20a4f897..5045f33f 100644 --- a/packages/devtools/src/renderer/modules/main/features/project-runtime/project-runtime.tsx +++ b/packages/devtools/src/renderer/modules/main/features/project-runtime/project-runtime.tsx @@ -221,10 +221,12 @@ export function ProjectRuntime({ project }: ProjectRuntimeProps) { if (panelId === 'simulator') { return ( (SessionChannel.RuntimeStatus, (status) => handler(status)) } +/** + * Subscribe to the active session's forced-orientation broadcasts (mirrors `onSessionRuntimeStatus`). + * Returns the transport unsubscribe function. + */ +export function onSessionOrientationChanged( + handler: (payload: SessionOrientationPayload) => void, +): () => void { + return on<[SessionOrientationPayload]>(SessionChannel.OrientationChanged, (payload) => handler(payload)) +} + /** Capture a screenshot of the simulator and save it as a thumbnail. */ export function captureThumbnail(projectPath: string): Promise { return invoke(ProjectChannel.CaptureThumbnail, projectPath) diff --git a/packages/devtools/src/renderer/shared/api/view-api.ts b/packages/devtools/src/renderer/shared/api/view-api.ts index 4f7399d3..d71e5279 100644 --- a/packages/devtools/src/renderer/shared/api/view-api.ts +++ b/packages/devtools/src/renderer/shared/api/view-api.ts @@ -59,6 +59,16 @@ export function setNativeDeviceInfo(device: NativeDeviceInfo): Promise { return invoke(SimulatorChannel.SetDeviceInfo, device) } +/** + * NATIVE-HOST ONLY. + * Read back the device main already holds. + * A project window that remounts restores the simulated device's orientation from this rather than pushing its own default, so the orientation survives closing and reopening a project. + * Null before any device has been pushed. + */ +export function getNativeDeviceInfo(): Promise { + return invoke(SimulatorChannel.GetDeviceInfo) +} + /** Show the compile-popover overlay anchored below `top`/`left`. */ export function showPopover(payload: PopoverShowPayload): Promise { return invoke(PopoverChannel.Show, payload) diff --git a/packages/devtools/src/service-host/sync-api-patch.test.ts b/packages/devtools/src/service-host/sync-api-patch.test.ts index 5c618161..2c7b0ae5 100644 --- a/packages/devtools/src/service-host/sync-api-patch.test.ts +++ b/packages/devtools/src/service-host/sync-api-patch.test.ts @@ -119,6 +119,39 @@ describe('sync-api-patch — SYNC storage write notify', () => { }) }) + it('patches getWindowInfo onto every namespace so its safeArea comes from the same snapshot as getSystemInfoSync', async () => { + vi.resetModules() + ;(globalThis as unknown as { __diminaSpawnContext: unknown }).__diminaSpawnContext = { + appId: 'wxAPP', + hostEnvSnapshot: { + screenWidth: 390, + screenHeight: 844, + windowWidth: 390, + windowHeight: 753, + statusBarHeight: 47, + deviceOrientation: 'portrait', + safeAreaInsets: { top: 47, right: 0, bottom: 34, left: 0 }, + }, + } + ;(globalThis as unknown as { wx: unknown }).wx = {} + await import('./sync-api-patch.js') + + const wx = (globalThis as unknown as { + wx: { getWindowInfo: () => { safeArea?: unknown; windowHeight?: number } } + }).wx + expect(typeof wx.getWindowInfo).toBe('function') + const info = wx.getWindowInfo() + expect(info.windowHeight).toBe(753) + expect(info.safeArea).toEqual({ + left: 0, + top: 47, + right: 390, + bottom: 810, + width: 390, + height: 763, + }) + }) + it('does NOT throw when DiminaServiceBridge is absent (pool-warming stub / non-native runtime) — the sync write still lands', async () => { const wx = await loadPatchedWx(undefined) diff --git a/packages/devtools/src/service-host/sync-api-patch.ts b/packages/devtools/src/service-host/sync-api-patch.ts index 4cf2f429..5314d048 100644 --- a/packages/devtools/src/service-host/sync-api-patch.ts +++ b/packages/devtools/src/service-host/sync-api-patch.ts @@ -1,4 +1,4 @@ -import { getAccountInfoSync, getSystemInfoSync } from './sync-impls/system-info.js' +import { getAccountInfoSync, getSystemInfoSync, getWindowInfo } from './sync-impls/system-info.js' import { clearStorageSync, getStorageInfoSync, @@ -62,6 +62,8 @@ function patchNamespace(ns: ApiNamespace | undefined): void { } ns.getStorageInfoSync = () => getStorageInfoSync.call(spawnContext) ns.getSystemInfoSync = () => getSystemInfoSync.call(spawnContext) + // Same snapshot as getSystemInfoSync, so `getWindowInfo().safeArea` reports the same rect instead of the field the service's own resolver drops (see sync-impls/system-info.ts `getWindowInfo`). + ns.getWindowInfo = () => getWindowInfo.call(spawnContext) ns.getAccountInfoSync = () => getAccountInfoSync.call(spawnContext) ns.getMenuButtonBoundingClientRect = () => getMenuButtonBoundingClientRect.call(spawnContext) } diff --git a/packages/devtools/src/service-host/sync-impls/system-info.test.ts b/packages/devtools/src/service-host/sync-impls/system-info.test.ts new file mode 100644 index 00000000..89e27b59 --- /dev/null +++ b/packages/devtools/src/service-host/sync-impls/system-info.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import { getSystemInfoSync, getWindowInfo } from './system-info' + +describe('getSystemInfoSync safeArea', () => { + it('reads the portrait-baseline safeAreaInsets snapshot verbatim in portrait', () => { + const info = getSystemInfoSync.call({ + hostEnvSnapshot: { + screenWidth: 390, + screenHeight: 844, + statusBarHeight: 47, + deviceOrientation: 'portrait', + safeAreaInsets: { top: 47, right: 0, bottom: 34, left: 0 }, + }, + }) + expect(info.safeArea).toEqual({ + left: 0, + top: 47, + right: 390, + bottom: 810, // 844 - 34 + width: 390, + height: 763, // 844 - 47 - 34 + }) + }) + + it('measures the landscape rect against the landscape screen — oriented insets pair with the oriented size, never with the portrait one', () => { + const info = getSystemInfoSync.call({ + hostEnvSnapshot: { + // deviceInfoToHostEnv already swapped these for landscape. + screenWidth: 844, + screenHeight: 390, + windowWidth: 844, + windowHeight: 390, + statusBarHeight: 0, + deviceOrientation: 'landscape', + // Landscape insets: the notch moved off the top and onto both sides, and the home indicator is thinner (orientedSafeAreaInsets). + safeAreaInsets: { top: 0, right: 47, bottom: 21, left: 47 }, + }, + }) + expect(info.safeArea).toEqual({ + left: 47, + top: 0, + right: 797, + bottom: 369, + width: 750, + height: 369, + }) + // Un-swapping back to portrait would have produced a rect wider than the screen is tall and edges on the wrong axis. + expect(info.safeArea.right).toBeLessThanOrEqual(info.screenWidth) + expect(info.safeArea.bottom).toBeLessThanOrEqual(info.screenHeight) + }) + + it('getWindowInfo reports the same safeArea rect as getSystemInfoSync — the raw host-env snapshot the service would otherwise pick from carries only safeAreaInsets', () => { + const snapshot = { + screenWidth: 390, + screenHeight: 844, + windowWidth: 390, + windowHeight: 753, + statusBarHeight: 47, + deviceOrientation: 'portrait', + safeAreaInsets: { top: 47, right: 0, bottom: 34, left: 0 }, + } + const win = getWindowInfo.call({ hostEnvSnapshot: snapshot }) + expect(win.safeArea).toEqual(getSystemInfoSync.call({ hostEnvSnapshot: snapshot }).safeArea) + expect(win.safeArea).toEqual({ + left: 0, + top: 47, + right: 390, + bottom: 810, + width: 390, + height: 763, + }) + }) + + it('getWindowInfo carries the window geometry and nothing from the device/app-info groups', () => { + const win = getWindowInfo.call({ + hostEnvSnapshot: { + model: 'iPhone 14', + pixelRatio: 3, + screenWidth: 844, + screenHeight: 390, + windowWidth: 844, + windowHeight: 346, + statusBarHeight: 0, + deviceOrientation: 'landscape', + safeAreaInsets: { top: 0, right: 47, bottom: 21, left: 47 }, + }, + }) + expect(win).toEqual({ + pixelRatio: 3, + screenWidth: 844, + screenHeight: 390, + windowWidth: 844, + windowHeight: 346, + statusBarHeight: 0, + safeArea: { left: 47, top: 0, right: 797, bottom: 369, width: 750, height: 369 }, + }) + }) + + it('falls back to {top: statusBarHeight, right/bottom/left: 0} when the snapshot has no safeAreaInsets', () => { + const info = getSystemInfoSync.call({ + hostEnvSnapshot: { screenWidth: 390, screenHeight: 844, statusBarHeight: 47 }, + }) + expect(info.safeArea).toEqual({ + left: 0, + top: 47, + right: 390, + bottom: 844, + width: 390, + height: 797, // 844 - 47 + }) + }) +}) diff --git a/packages/devtools/src/service-host/sync-impls/system-info.ts b/packages/devtools/src/service-host/sync-impls/system-info.ts index fb11f977..2daa65a8 100644 --- a/packages/devtools/src/service-host/sync-impls/system-info.ts +++ b/packages/devtools/src/service-host/sync-impls/system-info.ts @@ -1,6 +1,13 @@ +interface SafeAreaInsets { + top: number + right: number + bottom: number + left: number +} + interface SpawnContext { appId?: string - hostEnvSnapshot?: Partial + hostEnvSnapshot?: Partial & { safeAreaInsets?: SafeAreaInsets } } export interface SystemInfo { @@ -37,7 +44,7 @@ export function getSystemInfoSync(this: SpawnContext): SystemInfo { const windowWidth = numberOr(snapshot.windowWidth, globalThis.innerWidth, screenWidth) const windowHeight = numberOr(snapshot.windowHeight, globalThis.innerHeight, screenHeight) const statusBarHeight = numberOr(snapshot.statusBarHeight, 0) - const safeAreaBottom = windowHeight + const deviceOrientation = stringOr(snapshot.deviceOrientation, 'portrait') return { brand: stringOr(snapshot.brand, 'devtools'), @@ -54,16 +61,57 @@ export function getSystemInfoSync(this: SpawnContext): SystemInfo { platform: stringOr(snapshot.platform, navigator.platform || 'ios'), fontSizeSetting: 16, SDKVersion: stringOr(snapshot.SDKVersion, '3.0.0'), - deviceOrientation: 'portrait', + deviceOrientation, theme: stringOr(snapshot.theme, globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'), - safeArea: { - width: windowWidth, - height: windowHeight - statusBarHeight, - top: statusBarHeight, - bottom: safeAreaBottom, - left: 0, - right: windowWidth, - }, + // The insets main resolved (`HostEnvSnapshot.safeAreaInsets`, via `orientedSafeAreaInsets`) already describe the orientation on screen — in landscape the notch sits on the sides, not the top — so the rect is measured against the equally-oriented `screenWidth`/`screenHeight`. + // Pairing oriented insets with un-swapped portrait dimensions would place the edges on the wrong axis entirely. + safeArea: safeAreaRect( + screenWidth, + screenHeight, + snapshot.safeAreaInsets ?? { top: statusBarHeight, right: 0, bottom: 0, left: 0 }, + ), + } +} + +/** The window subset `wx.getWindowInfo()` reports. */ +export interface WindowInfo { + pixelRatio: number + screenWidth: number + screenHeight: number + windowWidth: number + windowHeight: number + statusBarHeight: number + safeArea: SystemInfo['safeArea'] +} + +/** + * `wx.getWindowInfo()` — the window fields of `getSystemInfoSync()`, derived from that same call so both APIs report one geometry. + * + * The service's own resolver (`api/common/index.js` `hostEnvResolvers`) picks these keys off the raw `hostEnv.systemInfo` object main pushes, which carries `safeAreaInsets` but no computed `safeArea` rect — so `safeArea` would be missing there. + * Deriving from `getSystemInfoSync` keeps the rect (and its portrait-baseline definition) identical across both APIs. + */ +export function getWindowInfo(this: SpawnContext): WindowInfo { + const info = getSystemInfoSync.call(this) + return { + pixelRatio: info.pixelRatio, + screenWidth: info.screenWidth, + screenHeight: info.screenHeight, + windowWidth: info.windowWidth, + windowHeight: info.windowHeight, + statusBarHeight: info.statusBarHeight, + safeArea: info.safeArea, + } +} + +/** A safe-area rect from a screen and the insets describing that same screen's orientation. */ +function safeAreaRect(screenWidth: number, screenHeight: number, insets: SafeAreaInsets) { + return { + left: insets.left, + top: insets.top, + right: screenWidth - insets.right, + bottom: screenHeight - insets.bottom, + width: screenWidth - insets.left - insets.right, + height: screenHeight - insets.top - insets.bottom, } } diff --git a/packages/devtools/src/shared/ipc-channels.ts b/packages/devtools/src/shared/ipc-channels.ts index ccf34600..19095b8b 100644 --- a/packages/devtools/src/shared/ipc-channels.ts +++ b/packages/devtools/src/shared/ipc-channels.ts @@ -18,6 +18,11 @@ export const SimulatorChannel = { // window — the authoritative `wx.getSystemInfoSync()` source — so the // mini-app sees the selected device without a relaunch. SetDeviceInfo: 'simulator:set-device-info', + /** + * Read back the device main already holds (including its orientation). + * A remounting project window restores from this instead of pushing its own default, so the simulated device's orientation survives closing and reopening a project — the mini-app never owns that state. + */ + GetDeviceInfo: 'simulator:get-device-info', // Ask main to soft-reload the LIVE simulator WCV after a watcher rebuild: // main forwards a SIMULATOR_EVENTS.RELAUNCH into the shell (which boots a // new app session and swaps when ready) instead of destroying the view. @@ -32,6 +37,8 @@ export const SimulatorChannel = { CurrentPage: 'simulator:current-page', } as const +import { SERVICE_HOST_CHANNELS } from '@dimina-kit/electron-runtime/shared/bridge-channels' + /** iPhone bezel cutout family driving the device-shell notch visual. */ export type { NativeDeviceInfo, @@ -47,8 +54,9 @@ export const ServiceHostChannel = { * (device metrics) so subsequent `wx.getSystemInfoSync()` reflects a device * change without a relaunch. The service-host preload mutates * `__diminaSpawnContext.hostEnvSnapshot` in place (see `service-host/preload.cjs`). + * Shared with the runtime's own writers (orientation changes push through the same channel), so the name lives in one place. */ - HostEnvUpdate: 'service-host:host-env:update', + HostEnvUpdate: SERVICE_HOST_CHANNELS.HostEnvUpdate, /** * NATIVE-HOST ONLY. Deliver an AppData-panel edit (`{bridgeId, data}`) into * the service-host window. The preload resolves the page instance via @@ -203,6 +211,10 @@ export const ProjectChannel = { export const SessionChannel = { RuntimeStatus: 'session:runtimeStatus', + /** + * Main → renderer push of the active mini-app session's forced screen orientation (device-shell's `PAGE_RESIZE.canRotate` translated by main — see orientation-controller.ts). `orientation: null` means no session is forcing anything (no session, or it just tore down) — the renderer panel geometry falls back to the user-controlled device orientation. + */ + OrientationChanged: 'session:orientationChanged', } as const // ── Project file system (sandboxed to active project root) ──────────────── diff --git a/packages/devtools/src/shared/ipc-schemas.ts b/packages/devtools/src/shared/ipc-schemas.ts index 514cc46d..51ab8954 100644 --- a/packages/devtools/src/shared/ipc-schemas.ts +++ b/packages/devtools/src/shared/ipc-schemas.ts @@ -109,6 +109,9 @@ export const SimulatorSetDeviceInfoSchema = z.tuple([ bottom: z.number().finite().min(0).max(400), left: z.number().finite().min(0).max(400), }), + // The user-controlled device orientation the rotate button pushes. + // Absent in this list, zod strips it and main caches a device that never rotates. + deviceOrientation: z.enum(['portrait', 'landscape']).optional(), }), ]) diff --git a/packages/devtools/src/simulator/device-shell/device-shell-tab-bar-commit.test.tsx b/packages/devtools/src/simulator/device-shell/device-shell-tab-bar-commit.test.tsx new file mode 100644 index 00000000..a86e43a8 --- /dev/null +++ b/packages/devtools/src/simulator/device-shell/device-shell-tab-bar-commit.test.tsx @@ -0,0 +1,183 @@ +/** + * Tab-bar visibility is shell state that changes the geometry the session reports: `wx.hideTabBar` hands the bar's reserved height back to the page viewport and `wx.showTabBar` takes it away again. + * A mini-app may read `wx.getWindowInfo()` synchronously inside that very call's success callback, so the new geometry has to be on the wire BEFORE the call is acked — which means the mutation lands through the shell's commit authority, in the same "commit → publish → tell the caller" order a route uses. + */ +import { describe, expect, it, vi } from 'vitest' +import { act, render } from '@testing-library/react' +import { SIMULATOR_EVENTS as E } from '../../shared/bridge-channels' +import type { TabActionPayload } from '../../shared/bridge-channels' +import type { NativeDeviceInfo } from '../../shared/ipc-channels' +import { tabBarReservedHeight } from './orientation-controller' +import { DeviceShell } from './device-shell' + +const DEVICE: NativeDeviceInfo = { + brand: 'Apple', + model: 'iPhone 14', + system: 'iOS 16.0', + platform: 'ios', + pixelRatio: 3, + screenWidth: 390, + screenHeight: 844, + statusBarHeight: 47, + notchType: 'dynamic-island', + safeAreaInsets: { top: 47, right: 0, bottom: 34, left: 0 }, + deviceOrientation: 'portrait', +} + +const HOME = 'pages/home/home' +const ROOT_BRIDGE_ID = 'bridge_root' +const RESERVED = tabBarReservedHeight(DEVICE.safeAreaInsets.bottom) + +/** What the shell told main, in the order it told it. */ +type Trace = + | { kind: 'resize'; windowHeight: number } + | { kind: 'ack'; ok: boolean; errMsg: string } + +function makeMiniApp() { + const listeners = new Map void>>() + const trace: Trace[] = [] + + const subscribe = (channel: string, listener: (payload: never) => void): (() => void) => { + let bucket = listeners.get(channel) + if (!bucket) { + bucket = new Set() + listeners.set(channel, bucket) + } + bucket.add(listener) + return () => { bucket?.delete(listener) } + } + + const miniApp = { + appId: 'demo', + appSessionId: 's1', + pagePath: HOME, + query: {}, + rootWindowConfig: {}, + resourceBaseUrl: '', + apiRegistry: {}, + getInitialDevice: () => DEVICE, + getRenderPreloadUrl: () => '', + getTabBarConfig: () => ({ list: [{ pagePath: HOME, text: 'Home' }] }), + getHomePagePath: () => HOME, + createRenderHostUrl: () => 'about:blank', + openPage: vi.fn(), + closePage: vi.fn(), + notifyLifecycle: vi.fn(), + notifyApiResponse: vi.fn(), + notifyActivePage: vi.fn(), + notifyPageStack: vi.fn(), + notifySessionActive: vi.fn(), + notifyResize: vi.fn((payload: { size: { windowHeight: number } }) => { + trace.push({ kind: 'resize', windowHeight: payload.size.windowHeight }) + }), + notifyNavCallback: vi.fn((payload: { ok: boolean; errMsg: string }) => { + trace.push({ kind: 'ack', ok: payload.ok, errMsg: payload.errMsg }) + }), + onSimulatorEvent: subscribe, + onSessionEvent: subscribe, + } + + return { + miniApp, + trace, + emitTabAction(name: TabActionPayload['name'], params: Record = {}): void { + for (const fn of listeners.get(E.TAB_ACTION) ?? []) { + (fn as unknown as (p: TabActionPayload) => void)({ + appSessionId: 's1', + bridgeId: ROOT_BRIDGE_ID, + name, + params, + callbacks: {}, + }) + } + }, + } +} + +function mountShell(h: ReturnType) { + return render( + , + ) +} + +/** Height of the last geometry the shell published, or -1 if it published none. */ +function lastHeight(trace: Trace[]): number { + for (let i = trace.length - 1; i >= 0; i--) { + const entry = trace[i]! + if (entry.kind === 'resize') return entry.windowHeight + } + return -1 +} + +/** + * Height the mini-app would read in the ack's callback: the newest geometry published strictly BEFORE the shell acked. -1 when nothing was published first, which is exactly the failure this file guards against. + */ +function heightVisibleAtAck(trace: Trace[]): number { + const ackAt = trace.findIndex(entry => entry.kind === 'ack') + expect(ackAt, 'the shell must have acked the tab-bar call').toBeGreaterThanOrEqual(0) + return lastHeight(trace.slice(0, ackAt)) +} + +describe('DeviceShell commits a tab-bar geometry change before acking it', () => { + it('hideTabBar publishes the grown viewport before the success callback can read it', () => { + const h = makeMiniApp() + mountShell(h) + const withBar = lastHeight(h.trace) + expect(withBar, 'the mounted tab page must have reported its geometry').toBeGreaterThan(0) + h.trace.length = 0 + + act(() => { h.emitTabAction('hideTabBar') }) + + expect( + heightVisibleAtAck(h.trace), + 'hideTabBar hands the bar height to the page, and its success callback reads the window synchronously', + ).toBe(withBar + RESERVED) + }) + + it('showTabBar publishes the shrunk viewport before its own ack', () => { + const h = makeMiniApp() + mountShell(h) + const withBar = lastHeight(h.trace) + act(() => { h.emitTabAction('hideTabBar') }) + h.trace.length = 0 + + act(() => { h.emitTabAction('showTabBar') }) + + expect( + heightVisibleAtAck(h.trace), + 'showTabBar takes the height back, and is symmetric with hideTabBar', + ).toBe(withBar) + }) + + it('acks only after the geometry publish, never the other way round', () => { + const h = makeMiniApp() + mountShell(h) + h.trace.length = 0 + + act(() => { h.emitTabAction('hideTabBar') }) + + const kinds = h.trace.map(entry => entry.kind) + expect(kinds.indexOf('resize'), 'the geometry must go out at all').toBeGreaterThanOrEqual(0) + expect( + kinds.indexOf('resize'), + 'the caller is told the call succeeded only once main already holds the new geometry', + ).toBeLessThan(kinds.indexOf('ack')) + expect(kinds.filter(kind => kind === 'resize')).toHaveLength(1) + }) + + it('leaves the reported geometry alone for a tab-bar change that does not move it', () => { + const h = makeMiniApp() + mountShell(h) + const withBar = lastHeight(h.trace) + h.trace.length = 0 + + act(() => { h.emitTabAction('setTabBarItem', { index: 0, text: 'Renamed' }) }) + + const ack = h.trace.find(entry => entry.kind === 'ack') + expect(ack, 'the call must still be acked').toEqual({ kind: 'ack', ok: true, errMsg: 'setTabBarItem:ok' }) + expect( + lastHeight(h.trace) === -1 ? withBar : lastHeight(h.trace), + 'text/icon edits keep the bar in the layout flow, so the page viewport must not move', + ).toBe(withBar) + }) +}) diff --git a/packages/devtools/src/simulator/device-shell/device-shell.test.tsx b/packages/devtools/src/simulator/device-shell/device-shell.test.tsx new file mode 100644 index 00000000..1c5d09ba --- /dev/null +++ b/packages/devtools/src/simulator/device-shell/device-shell.test.tsx @@ -0,0 +1,448 @@ +/** + * DeviceShell routing as an atomic, serialized transaction. + * + * A route reads the stack, opens a page over IPC and reduces the result back onto the stack it read. + * Two of those interleaving — or a React state update standing in for "the stack moved" — lets both reduce from the same stack, so the loser's page disappears from the shell while main keeps it registered and the mini-app is told both calls succeeded. + * The stack transitions here are therefore driven through the real component and asserted on what main observes (PAGE_STACK, closePage, the nav callbacks). + */ +import { describe, expect, it, vi } from 'vitest' +import { act, render } from '@testing-library/react' +import { SIMULATOR_EVENTS as E } from '../../shared/bridge-channels' +import type { NavActionPayload } from '../../shared/bridge-channels' +import type { NativeDeviceInfo } from '../../shared/ipc-channels' +import { DeviceShell } from './device-shell' + +const DEVICE: NativeDeviceInfo = { + brand: 'Apple', + model: 'iPhone 14', + system: 'iOS 16.0', + platform: 'ios', + pixelRatio: 3, + screenWidth: 390, + screenHeight: 844, + statusBarHeight: 47, + notchType: 'dynamic-island', + safeAreaInsets: { top: 47, right: 0, bottom: 34, left: 0 }, + deviceOrientation: 'portrait', +} + +const ROOT_BRIDGE_ID = 'bridge_root' +const DETAIL = 'pages/detail/detail' + +interface PendingOpen { + pagePath: string + settle: () => void +} + +function makeMiniApp( + opts: { + autoOpen?: boolean + rootWindowConfig?: Record + openWindowConfig?: Record + } = {}, +) { + const autoOpen = opts.autoOpen ?? true + const listeners = new Map void>>() + const pendingOpens: PendingOpen[] = [] + let openCount = 0 + + const subscribe = (channel: string, listener: (payload: never) => void): (() => void) => { + let bucket = listeners.get(channel) + if (!bucket) { + bucket = new Set() + listeners.set(channel, bucket) + } + bucket.add(listener) + return () => { bucket?.delete(listener) } + } + + const openResult = (pagePath: string) => ({ + bridgeId: `bridge_${++openCount}`, + pagePath, + isTab: false, + windowConfig: opts.openWindowConfig ?? {}, + }) + + const miniApp = { + appId: 'demo', + appSessionId: 's1', + pagePath: 'pages/home/home', + query: {}, + rootWindowConfig: opts.rootWindowConfig ?? {}, + resourceBaseUrl: '', + apiRegistry: {}, + getInitialDevice: () => DEVICE, + getRenderPreloadUrl: () => '', + getTabBarConfig: () => null, + getHomePagePath: () => 'pages/home/home', + createRenderHostUrl: () => 'about:blank', + openPage: vi.fn((pagePath: string) => { + if (autoOpen) return Promise.resolve(openResult(pagePath)) + return new Promise((resolve) => { + pendingOpens.push({ pagePath, settle: () => resolve(openResult(pagePath)) }) + }) + }), + closePage: vi.fn(), + notifyLifecycle: vi.fn(), + notifyNavCallback: vi.fn(), + notifyApiResponse: vi.fn(), + notifyResize: vi.fn(), + notifyActivePage: vi.fn(), + notifyPageStack: vi.fn(), + notifySessionActive: vi.fn(), + onSimulatorEvent: subscribe, + onSessionEvent: subscribe, + } + + return { + miniApp, + pendingOpens, + emitNavAction(payload: Omit): void { + for (const fn of listeners.get(E.NAV_ACTION) ?? []) { + (fn as unknown as (p: NavActionPayload) => void)({ + appSessionId: 's1', + callbacks: {}, + ...payload, + }) + } + }, + emitDeviceChange(device: NativeDeviceInfo): void { + for (const fn of listeners.get(E.DEVICE_CHANGE) ?? []) { + (fn as unknown as (p: NativeDeviceInfo) => void)(device) + } + }, + /** Geometry the shell last reported to main. */ + lastResize(): { bridgeId: string; size: { windowWidth: number; windowHeight: number } } | undefined { + return miniApp.notifyResize.mock.calls.at(-1)?.[0] as never + }, + /** Every report's page-channel verdict, in order, as `bridgeId:dispatchPage`. */ + pageDispatches(): string[] { + return miniApp.notifyResize.mock.calls.map((c) => { + const p = c[0] as { bridgeId: string; dispatchPage: boolean } + return `${p.bridgeId}:${p.dispatchPage}` + }) + }, + /** Every window height the shell reported to main, in order. */ + reportedHeights(): number[] { + return miniApp.notifyResize.mock.calls.map( + c => (c[0] as { size: { windowHeight: number } }).size.windowHeight, + ) + }, + /** Routes the shell reported to main, most recent first. */ + lastStack(): string[] { + const calls = miniApp.notifyPageStack.mock.calls + const last = calls.at(-1)?.[0] as Array<{ pagePath: string }> | undefined + return (last ?? []).map(e => e.pagePath) + }, + navVerdicts(): Array<{ ok: boolean; errMsg: string }> { + return miniApp.notifyNavCallback.mock.calls.map(c => c[0] as { ok: boolean; errMsg: string }) + }, + } +} + +function mountShell(h: ReturnType) { + return render( + , + ) +} + +/** Let every queued route (and the IPC round trips it awaits) run to completion. */ +async function settle(): Promise { + await act(async () => { await Promise.resolve() }) + await act(async () => { await Promise.resolve() }) + await act(async () => { await Promise.resolve() }) +} + +/** + * The toolbar's device selection reaches the shell over DEVICE_CHANGE, and the shell is what tells main the session's geometry. + * Two facts have to move: the rendered bezel (React state) and the snapshot a SYNCHRONOUS route publishes against — a route arriving in the same batch publishes before React commits, so a device that only advanced with state would hand the incoming page the previous device's metrics in its `onShow`. + */ +describe('DeviceShell follows the selected device', () => { + const SMALL_DEVICE: NativeDeviceInfo = { + ...DEVICE, + model: 'iPhone SE', + screenWidth: 320, + screenHeight: 568, + statusBarHeight: 20, + notchType: 'none', + safeAreaInsets: { top: 20, right: 0, bottom: 0, left: 0 }, + } + + it('reports the newly selected device geometry to main', async () => { + const h = makeMiniApp() + mountShell(h) + expect(h.lastResize()?.size.windowWidth).toBe(DEVICE.screenWidth) + + await act(async () => { h.emitDeviceChange(SMALL_DEVICE) }) + + expect( + h.lastResize()?.size.windowWidth, + 'a device switch must move the geometry main holds for the top page', + ).toBe(SMALL_DEVICE.screenWidth) + }) + + it('publishes the incoming page against the device selected mid-route', async () => { + const h = makeMiniApp({ autoOpen: false }) + mountShell(h) + + // The route parks on its PAGE_OPEN; the device changes while it is parked. + await act(async () => { + h.emitNavAction({ bridgeId: ROOT_BRIDGE_ID, name: 'navigateTo', params: { url: `/${DETAIL}` } }) + }) + await act(async () => { h.emitDeviceChange(SMALL_DEVICE) }) + await act(async () => { + h.pendingOpens.forEach(p => p.settle()) + h.pendingOpens.length = 0 + }) + await settle() + + const published = h.miniApp.notifyResize.mock.calls + .map(c => c[0] as { bridgeId: string; size: { windowWidth: number } }) + .filter(r => r.bridgeId === 'bridge_1') + expect(published.length, 'the pushed page must have been published at all').toBeGreaterThan(0) + for (const resize of published) { + expect( + resize.size.windowWidth, + 'the page being shown must never read the metrics of the device already switched away from', + ).toBe(SMALL_DEVICE.screenWidth) + } + }) +}) + +/** + * A soft reload keeps two shells mounted at once and swaps them in one commit. + * Which of them the user is looking at is not something main can infer from who reported geometry last — the outgoing session keeps reporting after the incoming one has taken the screen. + * The shell that is on screen says so, and republishes its top page so the geometry main mirrors describes the session that is actually visible even though nothing about its size changed. + */ +describe('DeviceShell declares itself as the session on screen', () => { + it('stays silent while it is the hidden, still-booting session', () => { + const h = makeMiniApp() + mountShell(h) + expect(h.miniApp.notifySessionActive).not.toHaveBeenCalled() + }) + + it('claims the screen on promotion, then republishes the top page behind that claim', () => { + const h = makeMiniApp() + const view = mountShell(h) + h.miniApp.notifyResize.mockClear() + + view.rerender( + , + ) + + expect(h.miniApp.notifySessionActive).toHaveBeenCalledTimes(1) + const declaredAt = h.miniApp.notifySessionActive.mock.invocationCallOrder[0]! + const afterClaim = h.miniApp.notifyResize.mock.invocationCallOrder.filter(at => at > declaredAt) + expect( + afterClaim.length, + 'a promoted session whose page never changed size still has to republish, or main keeps mirroring the session it replaced', + ).toBeGreaterThan(0) + expect( + (h.miniApp.notifyResize.mock.calls.at(-1)?.[0] as { bridgeId: string }).bridgeId, + 'the republished geometry describes the top of this shell\'s own stack', + ).toBe(ROOT_BRIDGE_ID) + }) + + /** + * 这次补发只为「换了前台会话」这一件事。 + * 跟着顶页一起补发的话,MiniAppFrame 路由时已经报过的那份几何会被第二个发布者再报一次,路由几何就不再只有一个 owner。 + */ + it('does not republish again when a route moves the top page of an already-active shell', async () => { + const h = makeMiniApp() + const view = render( + , + ) + await settle() + h.miniApp.notifyResize.mockClear() + h.miniApp.notifySessionActive.mockClear() + + await act(async () => { + h.emitNavAction({ bridgeId: ROOT_BRIDGE_ID, name: 'navigateTo', params: { url: '/pages/a/a' } }) + }) + await settle() + + const claimedAgain = h.miniApp.notifySessionActive.mock.calls.length + expect(claimedAgain, 'the shell was already the active session').toBe(0) + expect( + h.miniApp.notifyResize.mock.calls.filter( + call => (call[0] as { bridgeId: string }).bridgeId === 'bridge_1', + ).length, + 'the landing page must be reported exactly once for this route', + ).toBe(1) + view.unmount() + }) +}) + +describe('DeviceShell routing serializes concurrent NAV_ACTIONs', () => { + it('lands both of two back-to-back navigateTo pushes', async () => { + const h = makeMiniApp() + mountShell(h) + + await act(async () => { + h.emitNavAction({ bridgeId: ROOT_BRIDGE_ID, name: 'navigateTo', params: { url: `/${DETAIL}` } }) + h.emitNavAction({ bridgeId: ROOT_BRIDGE_ID, name: 'navigateTo', params: { url: `/${DETAIL}` } }) + }) + await settle() + + expect( + h.lastStack(), + 'the second push must reduce from the stack the first one landed', + ).toEqual(['pages/home/home', DETAIL, DETAIL]) + expect(h.miniApp.closePage, 'neither push may be silently discarded').not.toHaveBeenCalled() + expect(h.navVerdicts().map(v => v.ok)).toEqual([true, true]) + }) + + it('pops two pages for two back-to-back navigateBacks, closing each exactly once', async () => { + const h = makeMiniApp() + mountShell(h) + + await act(async () => { + h.emitNavAction({ bridgeId: ROOT_BRIDGE_ID, name: 'navigateTo', params: { url: `/${DETAIL}` } }) + }) + await settle() + await act(async () => { + h.emitNavAction({ bridgeId: 'bridge_1', name: 'navigateTo', params: { url: `/${DETAIL}` } }) + }) + await settle() + expect(h.lastStack()).toEqual(['pages/home/home', DETAIL, DETAIL]) + h.miniApp.closePage.mockClear() + + await act(async () => { + h.emitNavAction({ bridgeId: 'bridge_2', name: 'navigateBack', params: { delta: 1 } }) + h.emitNavAction({ bridgeId: 'bridge_2', name: 'navigateBack', params: { delta: 1 } }) + }) + await settle() + + expect(h.lastStack(), 'each back must pop the stack the previous one left').toEqual(['pages/home/home']) + expect( + h.miniApp.closePage.mock.calls.map(c => c[0]).sort(), + 'the two backs must close two different pages', + ).toEqual(['bridge_1', 'bridge_2']) + }) + + it('publishes the restored page geometry before its pageShow lifecycle', async () => { + const h = makeMiniApp() + mountShell(h) + await act(async () => { + h.emitNavAction({ bridgeId: ROOT_BRIDGE_ID, name: 'navigateTo', params: { url: '/pages/a/a' } }) + }) + h.miniApp.notifyResize.mockClear() + h.miniApp.notifyLifecycle.mockClear() + + await act(async () => { + h.emitNavAction({ bridgeId: 'bridge_1', name: 'navigateBack', params: { delta: 1 } }) + }) + + const resizeAt = h.miniApp.notifyResize.mock.invocationCallOrder[0] + const showCall = h.miniApp.notifyLifecycle.mock.calls.findIndex((call) => + call[0] === ROOT_BRIDGE_ID && call[1] === 'pageShow') + expect(showCall).toBeGreaterThanOrEqual(0) + expect(resizeAt).toBeLessThan(h.miniApp.notifyLifecycle.mock.invocationCallOrder[showCall]!) + }) + + it('runs a navigateBack issued mid-navigateTo after the push it interrupts', async () => { + const h = makeMiniApp({ autoOpen: false }) + mountShell(h) + + await act(async () => { + h.emitNavAction({ bridgeId: ROOT_BRIDGE_ID, name: 'navigateTo', params: { url: `/${DETAIL}` } }) + }) + // The back arrives while the push is still waiting for its PAGE_OPEN. + await act(async () => { + h.emitNavAction({ bridgeId: ROOT_BRIDGE_ID, name: 'navigateBack', params: { delta: 1 } }) + }) + await act(async () => { + h.pendingOpens.forEach(p => p.settle()) + h.pendingOpens.length = 0 + }) + await settle() + + expect( + h.lastStack(), + 'the back must see the pushed page and pop it, not fail on a one-deep stack', + ).toEqual(['pages/home/home']) + expect(h.navVerdicts().map(v => v.ok)).toEqual([true, true]) + }) + + it('keeps issue order when the first route\'s page opens after the second one\'s', async () => { + const h = makeMiniApp({ autoOpen: false }) + mountShell(h) + + await act(async () => { + h.emitNavAction({ bridgeId: ROOT_BRIDGE_ID, name: 'navigateTo', params: { url: '/pages/first/first' } }) + h.emitNavAction({ bridgeId: ROOT_BRIDGE_ID, name: 'navigateTo', params: { url: '/pages/second/second' } }) + }) + + // Settle whatever PAGE_OPENs are outstanding, newest first — a route may not depend on its own open winning the race against a later route's. + for (let round = 0; round < 2; round++) { + await act(async () => { + const outstanding = h.pendingOpens.splice(0, h.pendingOpens.length).reverse() + outstanding.forEach(p => p.settle()) + }) + await settle() + } + + expect(h.lastStack()).toEqual([ + 'pages/home/home', + 'pages/first/first', + 'pages/second/second', + ]) + }) +}) + +/** + * The launch page's window geometry is published from the shell's own seed of the frame's layout, before the frame reports a layout of its own. + * A page declaring `navigationStyle: "custom"` gets the whole screen height; if the seed assumed a default navigation bar, main would cache the short window the page reads in `onShow`, and the correction would arrive as a `Page.onResize` that no real container sends for a page that never resized. + */ +describe('DeviceShell seeds the launch page geometry from its window config', () => { + const SCREEN_HEIGHT = DEVICE.screenHeight + const DEFAULT_CHROME = DEVICE.statusBarHeight + 44 + + it('publishes the full screen height for a custom navigation style, once', async () => { + const h = makeMiniApp({ rootWindowConfig: { navigationStyle: 'custom' } }) + mountShell(h) + await settle() + + expect(h.reportedHeights()).toEqual([SCREEN_HEIGHT]) + }) + + it('reserves the navigation bar for a default navigation style', async () => { + const h = makeMiniApp() + mountShell(h) + await settle() + + expect(h.reportedHeights()).toEqual([SCREEN_HEIGHT - DEFAULT_CHROME]) + }) +}) + +/** + * The page channel carries whichever page a report names, with no geometry test, and a route commit names its landing page. + * The pages here are `auto` so the fixed-orientation suppression is not what any verdict comes from. + */ +describe('DeviceShell reports the page channel for whichever page a route lands on', () => { + const AUTO = { pageOrientation: 'auto' } + + it('reports the landing page on a route that moved no geometry, and again when the device rotates', async () => { + const h = makeMiniApp({ rootWindowConfig: AUTO, openWindowConfig: AUTO }) + mountShell(h) + await settle() + expect(h.pageDispatches()).toEqual([`${ROOT_BRIDGE_ID}:true`]) + h.miniApp.notifyResize.mockClear() + + await act(async () => { + h.emitNavAction({ bridgeId: ROOT_BRIDGE_ID, name: 'navigateTo', params: { url: `/${DETAIL}` } }) + }) + await settle() + expect( + h.pageDispatches(), + 'the pushed page lands at the root page geometry and is still the page the report names', + ).toEqual(['bridge_1:true']) + h.miniApp.notifyResize.mockClear() + + await act(async () => { + h.emitDeviceChange({ ...DEVICE, deviceOrientation: 'landscape' }) + }) + await settle() + expect(h.pageDispatches()).toEqual(['bridge_1:true']) + }) +}) diff --git a/packages/devtools/src/simulator/device-shell/device-shell.tsx b/packages/devtools/src/simulator/device-shell/device-shell.tsx index f849eb57..2f1db807 100644 --- a/packages/devtools/src/simulator/device-shell/device-shell.tsx +++ b/packages/devtools/src/simulator/device-shell/device-shell.tsx @@ -1,14 +1,8 @@ /** - * The phone the simulator pretends to be: bezel, screen geometry, status bar, - * notch and home indicator, plus the devtools-only layers that ride along - * (the UI extension mount point and the capsule "more" menu). - * - * The mini-app inside it is `MiniAppFrame`, which the runtime owns. Everything - * this file computes from the selected device reaches the frame as two numbers, - * so a host with no device pretense renders the same mini-app by passing - * different ones — or none. + * The simulated device owns physical geometry and orientation. + * MiniAppFrame owns navigation; its committed layout snapshot is the only input this host uses to publish page window geometry. */ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { SIMULATOR_EVENTS as E } from '../../shared/bridge-channels' import type { DeviceShellProps } from './device-shell-types' import { attachApiCallForwarding } from '../api-call-forwarding' @@ -20,8 +14,11 @@ import { } from './simulator-ui-extension-layer' import { MiniAppFrame, + makeLaunchPageEntry, type CapsuleMoreContext, + type MiniAppFrameLayoutState, } from '@dimina-kit/electron-runtime/simulator-ui' +import { useOrientation } from './use-orientation' import './device-shell.css' export type { DeviceShellProps } from './device-shell-types' @@ -29,43 +26,81 @@ export type { DeviceShellProps } from './device-shell-types' const STATUS_BAR_HEIGHT_IOS = 44 const STATUS_BAR_HEIGHT_ANDROID = 24 +/** + * Mirror of the layout MiniAppFrame starts with, for the render pass before its first layout callback arrives. + * The launch page comes from the frame's own builder rather than a second hand-written literal: this seed feeds the first geometry publish, and a launch page declaring `navigationStyle: "custom"` would otherwise be published as if a navigation bar were reserved out of its window — then corrected by a `Page.onResize` the real containers never send. + */ +function initialLayout(miniApp: DeviceShellProps['miniApp'], bridgeId: string): MiniAppFrameLayoutState { + const top = makeLaunchPageEntry(miniApp, bridgeId) + return { + top, + mounted: [{ entry: top, visible: true }], + tabBarVisible: !!miniApp.getTabBarConfig(), + } +} + export function DeviceShell( { miniApp, bridgeId, platform = 'ios', active = true }: DeviceShellProps, ) { const embedded = new URLSearchParams(window.location.search).get('embedded') === '1' - // The selected device drives the bezel size + status bar height + notch. - // Initial value rides the native-host bridge config (race-free); live toolbar - // changes arrive over DEVICE_CHANGE. + const orientationHost = miniApp as typeof miniApp & Required> const [device, setDevice] = useState(() => miniApp.getInitialDevice()) - useEffect(() => miniApp.onSimulatorEvent(E.DEVICE_CHANGE, setDevice), [miniApp]) - - // DeviceShell draws the WHOLE phone at fixed device-logical size on a gray - // desk that fills the WCV and scrolls when the phone overflows the region. - // Only the chrome metrics below are derived from the device. - const statusBarHeight = embedded ? 0 : (device?.safeAreaInsets.top - ?? (platform === 'ios' ? STATUS_BAR_HEIGHT_IOS : STATUS_BAR_HEIGHT_ANDROID)) - const bottomInset = embedded ? 0 : (device?.safeAreaInsets.bottom ?? 0) + const [layout, setLayout] = useState(() => initialLayout(miniApp, bridgeId)) const notchType = device?.notchType ?? 'none' + const { orientedMetrics, orientedBottomInset, publishTopResize, applyDevice } = useOrientation(orientationHost, layout, device) + // Follows the top page's effective orientation, not the device's portrait baseline: a notched phone's landscape home indicator is thinner, and this is the same inset the page's reported window height is computed against. + const bottomInset = embedded ? 0 : orientedBottomInset + + useEffect(() => miniApp.onSimulatorEvent(E.DEVICE_CHANGE, (next) => { + applyDevice(next) + setDevice(next) + }), [applyDevice, miniApp]) - // ── invokeAPI fallback (main → simulator) — see api-call-forwarding.ts ───── - // Stays out of the frame: it dispatches into this product's own wx.* handler - // registry, which the runtime knows nothing about. useEffect(() => attachApiCallForwarding(miniApp), [miniApp]) + // 软重载期间两个 shell 同时挂着,主进程要按「谁在屏幕上」而不是「谁最后报过几何」来定方向与视图尺寸,所以升为前台时补发一次当前顶页的几何(emitSessionOrientation 带 active 标记,见 bridge-router 的 applyPageResize)。 + // + // 只跟 active 的跃迁走,不跟 layout.top 走:路由落地那条几何 MiniAppFrame 自己已经发过,这里再跟着顶页变化发一次就是同一份几何的第二个发布者,主进程的 hostEnv 与会话方向会被重复改写,路由几何也不再只有一个 owner。 + const topRef = useRef(layout.top) + useEffect(() => { + topRef.current = layout.top + }, [layout.top]) + useEffect(() => { + if (!active) return + miniApp.notifySessionActive() + publishTopResize(topRef.current) + }, [active, miniApp, publishTopResize]) + + const statusBarHeight = embedded ? 0 : (orientedMetrics?.statusBarHeight + ?? (platform === 'ios' ? STATUS_BAR_HEIGHT_IOS : STATUS_BAR_HEIGHT_ANDROID)) const handleMore = useCallback((context: CapsuleMoreContext) => { dispatchSimulatorCapsuleMore(context.appId, context.appName, context.pagePath) }, []) + const publishLayout = useCallback((next: MiniAppFrameLayoutState) => { + setLayout(next) + publishTopResize(next.top, next.tabBarVisible) + }, [publishTopResize]) + const frameLayout = useMemo(() => (next: MiniAppFrameLayoutState) => { + setLayout((previous) => { + const sameMounted = previous.mounted.length === next.mounted.length + && previous.mounted.every((page, index) => { + const candidate = next.mounted[index] + return candidate?.entry === page.entry && candidate.visible === page.visible + }) + if (previous.top === next.top + && sameMounted + && previous.tabBarVisible === next.tabBarVisible) return previous + return next + }) + }, []) return (
( + onLayoutState={frameLayout} + onLayoutCommit={publishLayout} + statusBar={embedded || statusBarHeight <= 0 ? undefined : ({ textStyle }) => ( - {/* Home-indicator pill — an absolute overlay at the device bottom - (gesture-bar devices only; the home-button SE class has bottom - inset 0). It is NOT in flow: a tab page sees the tabBar's color - behind it, a non-tab page is full-bleed so its own content shows - through. The page reserves bottom space only via its own - env(safe-area-inset-*). */} {bottomInset > 0 && (
} + +function makeMiniApp(opts: { tabBar?: TabBarSpec; rootPagePath?: string } = {}) { + const rootPagePath = opts.rootPagePath ?? 'pages/home/home' + const tabPaths = new Set((opts.tabBar?.list ?? []).map((item) => item.pagePath)) + const listeners = new Map void>>() + let openCount = 0 + + const subscribe = (channel: string, listener: (payload: never) => void): (() => void) => { + let bucket = listeners.get(channel) + if (!bucket) { + bucket = new Set() + listeners.set(channel, bucket) + } + bucket.add(listener) + return () => { bucket?.delete(listener) } + } + + const miniApp = { + appId: 'demo', + appSessionId: 's1', + pagePath: rootPagePath, + query: {}, + rootWindowConfig: {}, + resourceBaseUrl: '', + apiRegistry: {}, + getInitialDevice: () => null, + getRenderPreloadUrl: () => '', + getTabBarConfig: () => opts.tabBar ?? null, + getHomePagePath: () => rootPagePath, + createRenderHostUrl: () => 'about:blank', + openPage: vi.fn((pagePath: string) => Promise.resolve({ + bridgeId: `bridge_${++openCount}`, + pagePath, + isTab: tabPaths.has(pagePath), + windowConfig: {}, + })), + closePage: vi.fn(), + notifyLifecycle: vi.fn(), + notifyNavCallback: vi.fn(), + notifyApiResponse: vi.fn(), + notifyResize: vi.fn(), + notifyActivePage: vi.fn(), + notifyPageStack: vi.fn(), + notifySessionActive: vi.fn(), + onSimulatorEvent: subscribe, + onSessionEvent: subscribe, + } + + return { + miniApp, + emitNavAction(payload: Omit): void { + for (const fn of listeners.get(E.NAV_ACTION) ?? []) { + (fn as unknown as (p: NavActionPayload) => void)({ + appSessionId: 's1', + callbacks: {}, + ...payload, + }) + } + }, + } +} + +function mountShell(h: ReturnType) { + return render( + , + ) +} + +/** Let every queued route (and the IPC round trips it awaits) run to completion. */ +async function settle(): Promise { + await act(async () => { await Promise.resolve() }) + await act(async () => { await Promise.resolve() }) + await act(async () => { await Promise.resolve() }) +} + +/** + * Capture the single `OrientationController` instance `useOrientation` constructs, by spying on a prototype method every instance calls during render — the spy still runs the real implementation, it only observes `this`. + */ +function captureController(): { get: () => OrientationController } { + let captured: OrientationController | undefined + const original = OrientationController.prototype.openPage + vi.spyOn(OrientationController.prototype, 'openPage').mockImplementation( + function (this: OrientationController, ...args: Parameters) { + // eslint-disable-next-line @typescript-eslint/no-this-alias -- capturing the constructed instance is the point of this spy + captured = this + return original.apply(this, args) + }, + ) + return { + get: () => { + if (!captured) throw new Error('OrientationController.openPage was never called') + return captured + }, + } +} + +function knownCount(ctrl: OrientationController): number { + return Array.from(ctrl.knownBridgeIds()).length +} + +describe('OrientationController resource census across route churn', () => { + // Each test installs its own prototype spy to capture the instance `useOrientation` constructs; left in place it would wrap the previous test's wrapper instead of the real method on the next `captureController()`. + afterEach(() => { + vi.restoreAllMocks() + }) + + it('returns to its baseline page count after repeated navigateTo/navigateBack round trips', async () => { + const spy = captureController() + const h = makeMiniApp() + mountShell(h) + await settle() + + const ctrl = spy.get() + const baseline = knownCount(ctrl) + expect(baseline, 'only the root page is mounted before any route runs').toBe(1) + + const ROUNDS = 6 + for (let round = 0; round < ROUNDS; round++) { + await act(async () => { + h.emitNavAction({ bridgeId: ROOT_BRIDGE_ID, name: 'navigateTo', params: { url: `/${DETAIL}` } }) + }) + await settle() + expect(knownCount(ctrl), `round ${round}: pushing a page must register its orientation state`).toBe(baseline + 1) + + await act(async () => { + h.emitNavAction({ bridgeId: 'irrelevant', name: 'navigateBack', params: { delta: 1 } }) + }) + await settle() + expect(knownCount(ctrl), `round ${round}: popping it back must release that state, not accumulate it`).toBe(baseline) + } + }) + + it('returns to its baseline page count after repeated redirectTo/reLaunch churn', async () => { + const spy = captureController() + const h = makeMiniApp() + mountShell(h) + await settle() + + const ctrl = spy.get() + const baseline = knownCount(ctrl) + + const ROUNDS = 5 + for (let round = 0; round < ROUNDS; round++) { + await act(async () => { + h.emitNavAction({ bridgeId: 'irrelevant', name: 'redirectTo', params: { url: `/${DETAIL}` } }) + }) + await settle() + expect(knownCount(ctrl), `round ${round}: redirectTo replaces the top in place, count must not grow`).toBe(baseline) + + await act(async () => { + h.emitNavAction({ bridgeId: 'irrelevant', name: 'reLaunch', params: { url: '/pages/home/home' } }) + }) + await settle() + expect(knownCount(ctrl), `round ${round}: reLaunch tears down every prior page, count must fall back to one`).toBe(baseline) + } + }) + + /** + * switchTab is the one route that LEAVES a page alive, cached inside a tab substack, instead of tearing it down — so its count contract is not "returns to baseline" but "grows by exactly what got cached, and a cache restore neither duplicates a registration nor releases a substack it didn't touch". reLaunch is the one route that tears every substack down regardless of which tab is active, so it is what brings the count back to one at the end. + */ + it('grows and restores precisely across switchTab, and reLaunch releases every cached substack', async () => { + const spy = captureController() + const h = makeMiniApp({ + tabBar: { list: [{ pagePath: TAB1, text: 'Tab1' }, { pagePath: TAB2, text: 'Tab2' }] }, + rootPagePath: TAB1, + }) + mountShell(h) + await settle() + + const ctrl = spy.get() + const baseline = knownCount(ctrl) + expect(baseline, 'only the root tab page is mounted before any route runs').toBe(1) + + // A tab that has never been visited must be opened fresh — and the tab switched away from must stay cached, not released. + await act(async () => { + h.emitNavAction({ bridgeId: ROOT_BRIDGE_ID, name: 'switchTab', params: { url: `/${TAB2}` } }) + }) + await settle() + expect(knownCount(ctrl), 'a freshly-opened tab grows the count by one; the tab left behind stays cached').toBe(baseline + 1) + + // Switching back must restore tab1 from its cache — no re-registration — and must not release tab2's substack behind it. + await act(async () => { + h.emitNavAction({ bridgeId: 'irrelevant', name: 'switchTab', params: { url: `/${TAB1}` } }) + }) + await settle() + expect(knownCount(ctrl), 'restoring a cached tab must neither duplicate its registration nor release the other tab').toBe(baseline + 1) + + // Push a page onto the active tab's own substack, then leave via switchTab: the pushed page is now cached inside that hidden substack. + await act(async () => { + h.emitNavAction({ bridgeId: ROOT_BRIDGE_ID, name: 'navigateTo', params: { url: `/${DETAIL}` } }) + }) + await settle() + expect(knownCount(ctrl), 'the pushed page registers its own orientation state').toBe(baseline + 2) + + await act(async () => { + h.emitNavAction({ bridgeId: 'irrelevant', name: 'switchTab', params: { url: `/${TAB2}` } }) + }) + await settle() + expect( + knownCount(ctrl), + 'a page cached inside a hidden tab substack stays tracked — an unrelated switchTab must not release it', + ).toBe(baseline + 2) + + // Repeated cache restores must not duplicate either tab root or the depth-two hidden substack. + // Exact count after every hop catches both leaks and premature release. + for (let round = 0; round < 8; round++) { + const target = round % 2 === 0 ? TAB1 : TAB2 + await act(async () => { + h.emitNavAction({ bridgeId: 'irrelevant', name: 'switchTab', params: { url: `/${target}` } }) + }) + await settle() + expect( + knownCount(ctrl), + `switchTab round ${round}: cached depth-two substack must remain exactly accounted for`, + ).toBe(baseline + 2) + } + + // reLaunch to a non-tab page tears every tab substack down in one shot, including whatever is cached inside them. + await act(async () => { + h.emitNavAction({ bridgeId: 'irrelevant', name: 'reLaunch', params: { url: '/pages/home/home' } }) + }) + await settle() + expect(knownCount(ctrl), 'reLaunch releases every tab substack and everything cached inside them').toBe(baseline) + }) +}) diff --git a/packages/devtools/src/simulator/device-shell/orientation-controller.test.ts b/packages/devtools/src/simulator/device-shell/orientation-controller.test.ts new file mode 100644 index 00000000..fad10a45 --- /dev/null +++ b/packages/devtools/src/simulator/device-shell/orientation-controller.test.ts @@ -0,0 +1,364 @@ +import { describe, expect, it } from "vitest"; +import { + computeResizePayload, + NAV_BAR_HEIGHT, + OrientationController, + pageWindowSize, + tabBarReservedHeight, + TAB_BAR_HEIGHT, +} from "./orientation-controller"; + +// Notch-free: its safe-area insets are 0 in either orientation, so cases that are not about insets read the same numbers whichever way the page faces. +const PORTRAIT_DEVICE = { + screenWidth: 390, + screenHeight: 844, + statusBarHeight: 47, + notchType: "none", + safeAreaInsets: { top: 0, right: 0, bottom: 0, left: 0 }, +}; + +// iPhone X profile: portrait home indicator 34, landscape 21. +const NOTCHED_DEVICE = { + screenWidth: 390, + screenHeight: 844, + statusBarHeight: 44, + notchType: "notch", + safeAreaInsets: { top: 44, right: 0, bottom: 34, left: 0 }, +}; + +/** + * A host report. + * These gating tests only vary the window size — the screen dimensions ride along in `size` for the business callbacks and never enter the dispatch verdict. + */ +function report(windowWidth: number, windowHeight: number) { + return { screenWidth: windowWidth, screenHeight: windowHeight + NAV_BAR_HEIGHT, windowWidth, windowHeight }; +} + +describe("OrientationController", () => { + describe("openPage / closePage", () => { + it("resolves state from the page config", () => { + const ctrl = new OrientationController(); + expect(ctrl.openPage("a", "landscape")).toEqual({ + originalPageOrientation: "landscape", + }); + }); + + it("is idempotent for an already-known bridgeId", () => { + const ctrl = new OrientationController(); + const first = ctrl.openPage("a", "auto"); + expect(ctrl.openPage("a", "auto")).toBe(first); + }); + + it("releases tracked state", () => { + const ctrl = new OrientationController(); + ctrl.openPage("a", "auto"); + ctrl.buildResizePayload("s1", "a", "portrait", report(390, 700)); + ctrl.closePage("a"); + expect(ctrl.getState("a")).toBeUndefined(); + expect(() => + ctrl.buildResizePayload("s1", "a", "portrait", report(390, 700)), + ).toThrow(/unknown bridgeId/); + }); + + it("lists every tracked bridgeId", () => { + const ctrl = new OrientationController(); + ctrl.openPage("a", "auto"); + ctrl.openPage("b", "landscape"); + expect(Array.from(ctrl.knownBridgeIds()).sort()).toEqual(["a", "b"]); + }); + }); + + describe("effectiveFor", () => { + it("an auto page follows the device orientation", () => { + const ctrl = new OrientationController(); + ctrl.openPage("a", "auto"); + expect(ctrl.effectiveFor("a", "portrait")).toBe("portrait"); + expect(ctrl.effectiveFor("a", "landscape")).toBe("landscape"); + }); + + it("a fixed-orientation page ignores the device orientation", () => { + const ctrl = new OrientationController(); + ctrl.openPage("a", "landscape"); + expect(ctrl.effectiveFor("a", "portrait")).toBe("landscape"); + expect(ctrl.effectiveFor("a", "landscape")).toBe("landscape"); + }); + + it("falls back to the device orientation for an unregistered page", () => { + const ctrl = new OrientationController(); + expect(ctrl.effectiveFor("ghost", "landscape")).toBe("landscape"); + }); + + }); + + describe("buildResizePayload", () => { + it("throws for an unregistered bridgeId", () => { + const ctrl = new OrientationController(); + expect(() => + ctrl.buildResizePayload("s1", "ghost", "portrait", report(1, 1)), + ).toThrow(/unknown bridgeId/); + }); + + it("dispatches both channels on the first frame of a fresh app lifetime", () => { + // The app-global baseline starts empty, so the first geometry of a lifetime is a change for the window channel too. + const ctrl = new OrientationController(); + ctrl.openPage("a", "auto"); + const payload = ctrl.buildResizePayload("s1", "a", "portrait", report(390, 700)); + expect(payload).toMatchObject({ + appSessionId: "s1", + bridgeId: "a", + deviceOrientation: "portrait", + dispatchWindow: true, + dispatchPage: true, + canRotate: true, + }); + }); + + it("keeps the page channel open when a report repeats the geometry, and closes only the window channel", () => { + const ctrl = new OrientationController(); + ctrl.openPage("a", "auto"); + ctrl.buildResizePayload("s1", "a", "portrait", report(390, 700)); + const second = ctrl.buildResizePayload("s1", "a", "landscape", report(844, 320)); + expect(second.dispatchWindow).toBe(true); + expect(second.dispatchPage).toBe(true); + expect(second.deviceOrientation).toBe("landscape"); + // Same geometry again: the app-global baseline did not move, so the window channel closes. + // The page channel carries whichever page is being reported regardless of geometry. + const third = ctrl.buildResizePayload("s1", "a", "landscape", report(844, 320)); + expect(third.dispatchWindow).toBe(false); + expect(third.dispatchPage).toBe(true); + }); + + it("gives a cached page returning to a geometry another page already reported its own Page.onResize", () => { + const ctrl = new OrientationController(); + ctrl.openPage("a", "auto"); + ctrl.openPage("b", "auto"); + const portrait = report(390, 700); + const landscape = report(844, 320); + ctrl.buildResizePayload("s1", "a", "portrait", portrait); + // "b" rotates the window to landscape while "a" stays cached in portrait. + ctrl.buildResizePayload("s1", "b", "portrait", portrait); + ctrl.buildResizePayload("s1", "b", "landscape", landscape); + const back = ctrl.buildResizePayload("s1", "a", "landscape", landscape); + // The window has not moved since "b" reported it, so only the page that is now on screen hears about it. + expect(back.dispatchWindow).toBe(false); + expect(back.dispatchPage).toBe(true); + }); + + it("stays silent on both channels for a page pinned to a fixed orientation", () => { + const ctrl = new OrientationController(); + ctrl.openPage("a", "landscape"); + const first = ctrl.buildResizePayload("s1", "a", "portrait", report(844, 320)); + expect(first.dispatchWindow).toBe(false); + expect(first.dispatchPage).toBe(false); + expect(first.canRotate).toBe(false); + }); + }); +}); + +describe("tabBarReservedHeight", () => { + it("adds the content-box row height, the bottom safe-area padding, and the 1px border-top", () => { + // tab-bar.css: box-sizing:content-box + border-top:1px; tab-bar.tsx: inline padding-bottom = bottomInset. + // A home-button device has bottomInset 0. + expect(tabBarReservedHeight(0)).toBe(TAB_BAR_HEIGHT + 1); + expect(tabBarReservedHeight(34)).toBe(TAB_BAR_HEIGHT + 34 + 1); + }); +}); + +describe("pageWindowSize", () => { + const oriented = { screenWidth: 390, screenHeight: 844, statusBarHeight: 47 }; + + it("subtracts the status bar and nav bar for a default non-tab page", () => { + expect( + pageWindowSize(oriented, { + navigationStyle: "default", + isTab: false, + bottomInset: 0, + }), + ).toEqual({ + windowWidth: 390, + windowHeight: 844 - 47 - NAV_BAR_HEIGHT, + }); + }); + + it("subtracts the REAL tab-bar-reserved height (row + bottom inset + border), not just the 50px row", () => { + expect( + pageWindowSize(oriented, { + navigationStyle: "default", + isTab: true, + bottomInset: 34, + }), + ).toEqual({ + windowWidth: 390, + windowHeight: 844 - 47 - NAV_BAR_HEIGHT - tabBarReservedHeight(34), + }); + }); + + it("navigationStyle: custom reserves NEITHER the status bar NOR the nav bar — both are position:absolute overlays (status-bar.css always; navigation-bar.css .nav-bar--custom)", () => { + expect( + pageWindowSize(oriented, { + navigationStyle: "custom", + isTab: false, + bottomInset: 0, + }), + ).toEqual({ + windowWidth: 390, + windowHeight: 844, + }); + }); + + it("a custom-nav tabBar page still reserves the real tab bar height", () => { + expect( + pageWindowSize(oriented, { + navigationStyle: "custom", + isTab: true, + bottomInset: 34, + }), + ).toEqual({ + windowWidth: 390, + windowHeight: 844 - tabBarReservedHeight(34), + }); + }); + + it("never returns a negative height", () => { + const tiny = { screenWidth: 100, screenHeight: 50, statusBarHeight: 47 }; + expect( + pageWindowSize(tiny, { + navigationStyle: "default", + isTab: true, + bottomInset: 34, + }).windowHeight, + ).toBe(0); + }); +}); + +describe("computeResizePayload", () => { + it("swaps dimensions and drops the status bar for a landscape auto page", () => { + const ctrl = new OrientationController(); + ctrl.openPage("a", "auto"); + const payload = computeResizePayload( + ctrl, + "s1", + { bridgeId: "a", reservesTabBar: false, navBarStyle: "default" }, + PORTRAIT_DEVICE, + "landscape", + ); + expect(payload.deviceOrientation).toBe("landscape"); + // Both pairs swap together, and the screen keeps the chrome the window gives up — that difference is the whole reason a host reports both. + expect(payload.size).toEqual({ + screenWidth: 844, + screenHeight: 390, + windowWidth: 844, + windowHeight: 390 - NAV_BAR_HEIGHT, + }); + expect(payload.canRotate).toBe(true); + }); + + it("keeps a fixed-orientation page silent on its first frame even though it visibly differs from the device", () => { + const ctrl = new OrientationController(); + ctrl.openPage("a", "landscape"); + const payload = computeResizePayload( + ctrl, + "s1", + { bridgeId: "a", reservesTabBar: true, navBarStyle: "default" }, + NOTCHED_DEVICE, + "portrait", + ); + expect(payload.deviceOrientation).toBe("landscape"); + expect(payload.dispatchWindow).toBe(false); + expect(payload.dispatchPage).toBe(false); + expect(payload.canRotate).toBe(false); + // The page is displayed landscape even though the device is portrait, so it reserves the LANDSCAPE home indicator (21), not the device's 34. + expect(payload.size).toEqual({ + // The screen follows the orientation the page SHOWS, not the device's own. + screenWidth: 844, + screenHeight: 390, + windowWidth: 844, + windowHeight: 390 - NAV_BAR_HEIGHT - tabBarReservedHeight(21), + }); + }); + + it("shares the geometry baseline across bridgeIds: the same geometry reported for a different page is not a change", () => { + const ctrl = new OrientationController(); + ctrl.openPage("a", "auto"); + ctrl.openPage("b", "auto"); + + const first = computeResizePayload( + ctrl, + "s1", + { bridgeId: "a", reservesTabBar: false, navBarStyle: "default" }, + PORTRAIT_DEVICE, + "landscape", + ); + expect(first.dispatchWindow).toBe(true); + + const second = computeResizePayload( + ctrl, + "s1", + { bridgeId: "b", reservesTabBar: false, navBarStyle: "default" }, + PORTRAIT_DEVICE, + "landscape", + ); + expect(second.dispatchWindow).toBe(false); + // The page channel is not baseline-driven: it carries whichever page is being reported, so "b" still gets its own Page.onResize. + expect(second.dispatchPage).toBe(true); + }); + + + it("silences only the window channel when the same geometry is recomputed for an auto page", () => { + const ctrl = new OrientationController(); + ctrl.openPage("a", "auto"); + + const first = computeResizePayload( + ctrl, + "s1", + { bridgeId: "a", reservesTabBar: false, navBarStyle: "default" }, + PORTRAIT_DEVICE, + "landscape", + ); + expect(first.dispatchWindow).toBe(true); + expect(first.dispatchPage).toBe(true); + + const second = computeResizePayload( + ctrl, + "s1", + { bridgeId: "a", reservesTabBar: false, navBarStyle: "default" }, + PORTRAIT_DEVICE, + "landscape", + ); + expect(second.dispatchWindow).toBe(false); + expect(second.dispatchPage).toBe(true); + }); + + it("reserves the tab bar at the page's own effective orientation's inset, not the device's portrait baseline", () => { + const ctrl = new OrientationController(); + ctrl.openPage("a", "auto"); + const target = { + bridgeId: "a", + reservesTabBar: true, + navBarStyle: "default" as const, + }; + + const portrait = computeResizePayload( + ctrl, + "s1", + target, + NOTCHED_DEVICE, + "portrait", + ); + expect(portrait.size.windowHeight).toBe( + 844 - 44 - NAV_BAR_HEIGHT - tabBarReservedHeight(34), + ); + + // The landscape home indicator is thinner (21), so the tab bar gives 13px back — the same number the spawn seed derives from the oriented host env. + const landscape = computeResizePayload( + ctrl, + "s1", + target, + NOTCHED_DEVICE, + "landscape", + ); + expect(landscape.size.windowHeight).toBe( + 390 - NAV_BAR_HEIGHT - tabBarReservedHeight(21), + ); + }); +}); diff --git a/packages/devtools/src/simulator/device-shell/orientation-controller.ts b/packages/devtools/src/simulator/device-shell/orientation-controller.ts new file mode 100644 index 00000000..7ebbd4dc --- /dev/null +++ b/packages/devtools/src/simulator/device-shell/orientation-controller.ts @@ -0,0 +1,150 @@ +/** + * DeviceShell's authority over screen orientation (see shared/page-orientation.ts for the semantics this wraps). + * One `PageOrientationState` lives per bridgeId for as long as that page stays mounted (visible or cached in a tab substack); DeviceShell registers/releases entries as pages open and close, reads the current top page's effective orientation to size the phone shell, and reports resize payloads gated the same way WeChat's base library gates `Page.onResize` / `wx.onWindowResize`. + * + * Effective orientation is a pure function of page configuration and device orientation. + * Bringing a cached page back to the foreground recomputes from its own immutable configuration without an explicit restore step. + */ +import { + canUserRotate, + EMPTY_RESIZE_BASELINE, + effectiveOrientation, + orientedDeviceMetrics, + orientedSafeAreaInsets, + pageWindowSize, + resolvePageOrientationState, + shouldDispatchResize, + type DeviceMetricsInput, + type Orientation, + type PageOrientationState, + type PageResizePayload, + type ResizeBaseline, + type ResizeReportSize, + type SafeAreaInput, +} from '@dimina-kit/electron-runtime/shared/page-orientation' + +/** + * The chrome geometry the phone shell renders is the same formula the router seeds a spawn's host env with — one implementation in shared/page-orientation.ts, re-exported here for the shell-side callers. + */ +export { + NAV_BAR_HEIGHT, + pageWindowSize, + TAB_BAR_HEIGHT, + tabBarReservedHeight, +} from '@dimina-kit/electron-runtime/shared/page-orientation' + +export class OrientationController { + private readonly states = new Map() + /** App-global geometry baseline, shared by every bridgeId — see shouldDispatchResize's module doc. */ + private lastDispatched: ResizeBaseline = EMPTY_RESIZE_BASELINE + /** Registers a freshly-mounted page's orientation state from its resolved window config. Re-registering an already-known bridgeId is a no-op — a page's config never changes after it opens. */ + openPage(bridgeId: string, pageOrientation: unknown): PageOrientationState { + const existing = this.states.get(bridgeId) + if (existing) return existing + const state = resolvePageOrientationState(pageOrientation) + this.states.set(bridgeId, state) + return state + } + + /** Releases a torn-down page's orientation config so the map never outlives its page. */ + closePage(bridgeId: string): void { + this.states.delete(bridgeId) + } + + /** Bridge ids this controller currently tracks — used to diff against the live mounted set. */ + knownBridgeIds(): IterableIterator { + return this.states.keys() + } + + getState(bridgeId: string): PageOrientationState | undefined { + return this.states.get(bridgeId) + } + + /** What `bridgeId` should currently show. Falls back to the device orientation for an unregistered page (shouldn't happen once `openPage` runs before first paint). */ + effectiveFor(bridgeId: string, deviceOrientation: Orientation): Orientation { + const state = this.states.get(bridgeId) + return state ? effectiveOrientation(state, deviceOrientation) : deviceOrientation + } + + /** + * Build the `PAGE_RESIZE` payload for `bridgeId` at its current effective orientation. `dispatchWindow`/`dispatchPage` follow the gating rules (`shouldDispatchResize`): the window channel fires on a change against the app-global baseline, the page channel carries whichever page this report names without any geometry comparison, and both are silent together for a fixed-orientation page. + * Every report records the app-global baseline whether or not it dispatched, so a suppressed report still becomes the next comparison's basis. + */ + buildResizePayload( + appSessionId: string, + bridgeId: string, + deviceOrientation: Orientation, + size: ResizeReportSize, + ): PageResizePayload { + const state = this.states.get(bridgeId) + if (!state) { + throw new Error(`[orientation-controller] buildResizePayload: unknown bridgeId ${bridgeId}`) + } + const effective = effectiveOrientation(state, deviceOrientation) + const next = { ...size, deviceOrientation: effective } + const { dispatchWindow, dispatchPage } = shouldDispatchResize({ state, previous: this.lastDispatched, next }) + this.lastDispatched = next + return { + appSessionId, + bridgeId, + size, + deviceOrientation: effective, + dispatchWindow, + dispatchPage, + canRotate: canUserRotate(state), + } + } +} + +/** The subset of a `PageEntry` a resize computation needs — kept structural so this module doesn't depend on page-stack-controller's types. */ +export interface ResizeTargetPage { + bridgeId: string + /** + * Whether the tab bar currently takes layout space away from this page — `page.isTab && tabBarState.visible`, NOT `page.isTab` alone: `wx.hideTabBar` unmounts the bar and hands its height back to the page viewport. + */ + reservesTabBar: boolean + navBarStyle: 'default' | 'custom' +} + +/** + * A device profile as the shell holds it: portrait-baseline metrics plus the notch descriptor. + * Takes `notchType` rather than `SafeAreaInput`'s `hasNotch` so callers hand over `NativeDeviceInfo` untouched and the one boolean the inset formula needs is derived in a single place. + */ +export type ResizeDeviceProfile = DeviceMetricsInput & { + notchType: string + safeAreaInsets: SafeAreaInput['safeAreaInsets'] +} + +/** + * Full "did the visible page's geometry change" pipeline: derive the page's window size at its effective orientation, record it as on-screen, and build the gated resize payload. + * DeviceShell's one call site for every trigger — route change or device rotation. + */ +export function computeResizePayload( + ctrl: OrientationController, + appSessionId: string, + page: ResizeTargetPage, + device: ResizeDeviceProfile, + deviceOrientation: Orientation, +): PageResizePayload { + const effective = ctrl.effectiveFor(page.bridgeId, deviceOrientation) + // The tab bar reserves the inset the page is actually displayed with, which follows the page's effective orientation — a notched phone's landscape home indicator is thinner than its portrait one. + // Deriving it here rather than taking it as a parameter keeps it the same rule the spawn seed applies (`bridge-router` feeds `withPageWindowSize` the host env's already-oriented insets), so `getSystemInfoSync().windowHeight` cannot answer one number at launch and another on the first frame. + const bottomInset = orientedSafeAreaInsets( + { ...device, hasNotch: device.notchType !== 'none' }, + effective, + ).bottom + const oriented = orientedDeviceMetrics(device, effective) + const window = pageWindowSize(oriented, { + navigationStyle: page.navBarStyle, + // `PageChrome.isTab` means "the tab bar is in the layout flow below this page", which on a live shell also depends on whether it is hidden. + isTab: page.reservesTabBar, + bottomInset, + }) + // The screen dimensions ride along with the window ones: the base library hands `size` to the callbacks untouched, and the native hosts put both pairs in it. + const size = { + screenWidth: oriented.screenWidth, + screenHeight: oriented.screenHeight, + ...window, + } + return ctrl.buildResizePayload(appSessionId, page.bridgeId, deviceOrientation, size) +} diff --git a/packages/devtools/src/simulator/device-shell/use-orientation.test.tsx b/packages/devtools/src/simulator/device-shell/use-orientation.test.tsx new file mode 100644 index 00000000..da1ce05f --- /dev/null +++ b/packages/devtools/src/simulator/device-shell/use-orientation.test.tsx @@ -0,0 +1,264 @@ +/** + * `useOrientation` — the shell-side landing of every geometry trigger. + * + * Guards these invariants: + * - the top page is registered before its geometry is read, so the metrics the + * shell renders at describe the page that is actually on top even on the very first render that receives it; + * - `publishTopResize` lets a synchronous route publish the incoming page's + * geometry ahead of the lifecycle events it dispatches; + * - the reported window height follows the tab bar's VISIBILITY, not merely + * the page's tab-route flag, because `wx.hideTabBar` hands its reserved height back to the page. + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { + NAV_BAR_HEIGHT, + tabBarReservedHeight, +} from "@dimina-kit/electron-runtime/shared/page-orientation"; +import type { + PageOrientationConfig, + PageResizePayload, +} from "@dimina-kit/electron-runtime/shared/page-orientation"; +import type { NativeDeviceInfo } from "../../shared/ipc-channels"; +import type { + MountedEntry, + PageEntry, +} from "@dimina-kit/electron-runtime/simulator-ui"; +import { useOrientation } from "./use-orientation"; + +const DEVICE: NativeDeviceInfo = { + brand: "Apple", + model: "iPhone 14", + system: "iOS 16.0", + platform: "ios", + pixelRatio: 3, + screenWidth: 390, + screenHeight: 844, + statusBarHeight: 47, + notchType: "dynamic-island", + safeAreaInsets: { top: 47, right: 0, bottom: 34, left: 0 }, + deviceOrientation: "portrait", +}; + +function page( + bridgeId: string, + isTab: boolean, + pageOrientation: PageOrientationConfig = "auto", +): PageEntry { + return { + bridgeId, + pagePath: `pages/${bridgeId}/${bridgeId}`, + query: {}, + isTab, + windowConfig: { pageOrientation }, + navBar: { + title: bridgeId, + style: "default", + backgroundColor: "#ffffff", + textStyle: "black", + loading: false, + homeButtonVisible: false, + }, + }; +} + +interface Harness { + miniApp: { + appSessionId: string; + notifyResize: ReturnType; + }; + resizes: () => PageResizePayload[]; +} + +function makeHarness(): Harness { + const notifyResize = vi.fn(); + const miniApp = { + appSessionId: "s1", + notifyResize, + }; + return { + miniApp, + resizes: () => + notifyResize.mock.calls.map((c) => c[0] as PageResizePayload), + }; +} + +function mount(h: Harness, entries: PageEntry[], tabBarVisible: boolean) { + const mounted: MountedEntry[] = entries.map((entry, i) => ({ + entry, + visible: i === entries.length - 1, + })); + const top = entries[entries.length - 1]!; + return renderHook( + (props: { + mounted: MountedEntry[]; + top: PageEntry; + tabBarVisible: boolean; + }) => + useOrientation( + h.miniApp as never, + { + top: props.top, + mounted: props.mounted, + tabBarVisible: props.tabBarVisible, + }, + DEVICE, + ), + { initialProps: { mounted, top, tabBarVisible } }, + ); +} + +beforeEach(() => { + vi.useRealTimers(); +}); + +describe("useOrientation: the top page is registered before it is measured", () => { + it("a routed-in fixed-orientation page sizes the shell on the render that receives it", () => { + const h = makeHarness(); + const tabPage = page("a", true); + const detail = page("b", false, "landscape"); + const { result, rerender } = mount(h, [tabPage], true); + + expect(result.current.orientedMetrics).toEqual({ + screenWidth: 390, + screenHeight: 844, + statusBarHeight: 47, + }); + + rerender({ + mounted: [ + { entry: tabPage, visible: false }, + { entry: detail, visible: true }, + ], + top: detail, + tabBarVisible: true, + }); + + expect( + result.current.orientedMetrics, + "nothing re-renders the shell after the layout effect, so the render itself must already know the page", + ).toEqual({ screenWidth: 844, screenHeight: 390, statusBarHeight: 0 }); + }); +}); + +describe("useOrientation: publishTopResize", () => { + it("reports the given page as the visible top at its own effective orientation", () => { + const h = makeHarness(); + const detail = page("b", false, "landscape"); + const { result } = mount(h, [page("a", true)], true); + h.miniApp.notifyResize.mockClear(); + + act(() => result.current.publishTopResize(detail)); + + const payload = h.resizes().at(-1)!; + expect(payload.bridgeId).toBe("b"); + expect(payload.deviceOrientation).toBe("landscape"); + expect(payload.size).toEqual({ + screenWidth: 844, + screenHeight: 390, + windowWidth: 844, + windowHeight: 390 - NAV_BAR_HEIGHT, + }); + }); + + it("reports a restored tab page with the tab bar it reserves again", () => { + const h = makeHarness(); + const tabPage = page("a", true); + const { result } = mount(h, [tabPage], true); + h.miniApp.notifyResize.mockClear(); + + act(() => result.current.publishTopResize(tabPage)); + + const payload = h.resizes().at(-1)!; + expect(payload.deviceOrientation).toBe("portrait"); + expect(payload.size.windowHeight).toBe( + 844 - 47 - NAV_BAR_HEIGHT - tabBarReservedHeight(34), + ); + expect( + payload.dispatchWindow, + "republishing an unchanged geometry must not fire another wx.onWindowResize", + ).toBe(false); + expect( + payload.dispatchPage, + "the page channel still carries the restored page, which is how it re-reads the window it came back into", + ).toBe(true); + }); +}); + +describe("useOrientation: only a changed window republishes from the layout effect", () => { + it("a layout state rebuilt for a navigation-bar change publishes nothing", () => { + // A report refreshes main's host-env snapshot and re-emits the session orientation, so republishing behind every `setNavigationBarTitle` would make the route geometry no longer have a single publisher. + const h = makeHarness(); + const first = page("a", false); + const { rerender } = mount(h, [first], false); + const before = h.resizes().length; + expect(before).toBeGreaterThan(0); + + const renamed: PageEntry = { + ...first, + navBar: { ...first.navBar, title: "a new title", loading: true }, + }; + rerender({ + mounted: [{ entry: renamed, visible: true }], + top: renamed, + tabBarVisible: false, + }); + + expect(h.resizes().length).toBe(before); + }); + + it("a route commit onto the same page still reports, with the window channel silent", () => { + // The report itself is what refreshes main's host-env snapshot and the session orientation, so it goes out on every route commit. + // The window channel is baseline-driven and nothing moved; the page channel carries the committed page regardless. + const h = makeHarness(); + const only = page("a", false); + const { result } = mount(h, [only], false); + const before = h.resizes().length; + + act(() => result.current.publishTopResize(only)); + + expect(h.resizes().length).toBe(before + 1); + expect(h.resizes().at(-1)!.dispatchPage).toBe(true); + expect(h.resizes().at(-1)!.dispatchWindow).toBe(false); + }); +}); + +describe("useOrientation: tab bar visibility drives the reported window height", () => { + it("hiding the tab bar hands its reserved height back to the page", () => { + const h = makeHarness(); + const tabPage = page("a", true); + const { rerender } = mount(h, [tabPage], true); + + const withBar = h.resizes().at(-1)!; + expect(withBar.size.windowHeight).toBe( + 844 - 47 - NAV_BAR_HEIGHT - tabBarReservedHeight(34), + ); + + rerender({ + mounted: [{ entry: tabPage, visible: true }], + top: tabPage, + tabBarVisible: false, + }); + + const withoutBar = h.resizes().at(-1)!; + expect(withoutBar.size.windowHeight).toBe(844 - 47 - NAV_BAR_HEIGHT); + expect(withoutBar.dispatchWindow).toBe(true); + expect(withoutBar.dispatchPage).toBe(true); + }); + + it("a non-tab page is unaffected by the tab bar flag", () => { + const h = makeHarness(); + const plain = page("a", false); + const { rerender } = mount(h, [plain], true); + const before = h.resizes().at(-1)!.size.windowHeight; + + rerender({ + mounted: [{ entry: plain, visible: true }], + top: plain, + tabBarVisible: false, + }); + + expect(h.resizes().at(-1)!.size.windowHeight).toBe(before); + }); +}); + diff --git a/packages/devtools/src/simulator/device-shell/use-orientation.ts b/packages/devtools/src/simulator/device-shell/use-orientation.ts new file mode 100644 index 00000000..2d361f75 --- /dev/null +++ b/packages/devtools/src/simulator/device-shell/use-orientation.ts @@ -0,0 +1,175 @@ +import { useCallback, useLayoutEffect, useRef, useState } from 'react' +import { + orientedDeviceMetrics, + orientedSafeAreaInsets, + type Orientation, + type OrientedMetrics, +} from '@dimina-kit/electron-runtime/shared/page-orientation' +import type { NativeDeviceInfo } from '../../shared/ipc-channels' +import type { SimulatorMiniApp } from '../simulator-mini-app' +import type { MiniAppFrameLayoutState } from '@dimina-kit/electron-runtime/simulator-ui' +import { + computeResizePayload, + OrientationController, +} from './orientation-controller' + +export interface UseOrientationResult { + /** Oriented device metrics for the current top page; null before a device is known. */ + orientedMetrics: OrientedMetrics | null + /** + * Bottom safe-area inset at the top page's effective orientation — the same number the page's window height is computed against, so the chrome the shell paints there and the room the page is told it has agree. + */ + orientedBottomInset: number + /** + * Publish the authoritative `PAGE_RESIZE` for `page` as the visible top page, right now. + * A synchronous route calls this before dispatching the lifecycle events of the transition, so `onShow` reads a host-env snapshot that already describes the page being shown. + */ + publishTopResize: (page: MiniAppFrameLayoutState['top'], tabBarVisible?: boolean) => void + /** + * Advance the device the next synchronous publish resolves geometry against. + * DEVICE_CHANGE and a route can arrive in the same batch, and routing publishes before React commits — so the device the shell reports has to move the instant the change arrives, not one commit later. + */ + applyDevice: (device: NativeDeviceInfo | null) => void +} + +/** + * Everything that goes into a resize report. + * Two reports built from identical inputs would carry identical geometry, so re-publishing one only adds a `Page.onResize` the host never had a reason to send. + */ +interface ResizeInputs { + bridgeId: string + reservesTabBar: boolean + navBarStyle: MiniAppFrameLayoutState['top']['navBar']['style'] + device: NativeDeviceInfo + deviceOrientation: Orientation +} + +function sameResizeInputs(previous: ResizeInputs | null, next: ResizeInputs): boolean { + return previous !== null + && previous.bridgeId === next.bridgeId + && previous.reservesTabBar === next.reservesTabBar + && previous.navBarStyle === next.navBarStyle + && previous.device === next.device + && previous.deviceOrientation === next.deviceOrientation +} + +/** Geometry facts used by synchronous route commits. */ +interface LiveSnapshot { + mounted: MiniAppFrameLayoutState['mounted'] + device: NativeDeviceInfo | null + deviceOrientation: Orientation + tabBarVisible: boolean +} + +/** + * DeviceShell's orientation glue: owns the `OrientationController` instance, keeps its tracked pages in sync with what's mounted, reports the visible top page's geometry to main on every relevant change (route, device rotation), and returns the metrics DeviceShell renders the phone shell at. + * See orientation-controller.ts for the underlying pure, unit-tested semantics this wires into React. + */ +export function useOrientation( + miniApp: SimulatorMiniApp, + layout: MiniAppFrameLayoutState, + device: NativeDeviceInfo | null, +): UseOrientationResult { + const { top, mounted, tabBarVisible } = layout + const deviceOrientation: Orientation = device?.deviceOrientation ?? 'portrait' + + const [orientation] = useState(() => new OrientationController()) + // The top page is registered during RENDER, before `orientedMetrics` below reads it. + // Registering only from the layout effect would measure every freshly routed page one render too late: the shell would paint the orientation of the page it replaced and nothing would schedule the render that corrects it. `openPage` returns the existing state for a page it already tracks, so repeating it every render (twice under StrictMode) changes nothing. + orientation.openPage(top.bridgeId, top.windowConfig.pageOrientation) + // The live snapshot advances in the same layout effect that reconciles the controller's page ledger, so synchronous route commits cannot publish against a page set that has already been torn down. + const liveRef = useRef({ mounted, device, deviceOrientation, tabBarVisible }) + const publishedRef = useRef(null) + + useLayoutEffect(() => { + liveRef.current = { mounted, device, deviceOrientation, tabBarVisible } + const live = new Set(mounted.map(m => m.entry.bridgeId)) + for (const { entry } of mounted) { + orientation.openPage(entry.bridgeId, entry.windowConfig.pageOrientation) + } + for (const bridgeId of orientation.knownBridgeIds()) { + if (!live.has(bridgeId)) orientation.closePage(bridgeId) + } + if (!device) return + const inputs: ResizeInputs = { + bridgeId: top.bridgeId, + reservesTabBar: top.isTab && tabBarVisible, + navBarStyle: top.navBar.style, + device, + deviceOrientation, + } + // The layout state is rebuilt for anything the frame renders — a navigation bar title, a loading spinner — and none of that moves the page's window. + // Publishing per layout object would put a `Page.onResize` behind `setNavigationBarTitle`, since the page channel is not geometry-deduped downstream (see shouldDispatchResize). + // Route commits publish through `publishTopResize` and record their inputs here, so the effect that follows one of them stays quiet. + if (sameResizeInputs(publishedRef.current, inputs)) return + publishedRef.current = inputs + miniApp.notifyResize(computeResizePayload( + orientation, + miniApp.appSessionId ?? '', + { bridgeId: inputs.bridgeId, reservesTabBar: inputs.reservesTabBar, navBarStyle: inputs.navBarStyle }, + device, + deviceOrientation, + )) + }, [mounted, orientation, deviceOrientation, device, miniApp, top, tabBarVisible]) + + // Routing is synchronous: the reducer decides the new top and immediately dispatches its lifecycle events. + // The resize has to ride that same tick — the layout effect below only runs after React commits, by which time the page has already read its metrics in `onShow`, and a restored page whose own size did not change would never get a corrective `onResize`. + // Reads the live snapshot rather than this render's closure so the caller cannot publish against a device selection that has already moved on. + const applyDevice = useCallback((next: NativeDeviceInfo | null) => { + liveRef.current = { + ...liveRef.current, + device: next, + deviceOrientation: next?.deviceOrientation ?? 'portrait', + } + }, []) + + const publishTopResize = useCallback(( + page: MiniAppFrameLayoutState['top'], + committedTabBarVisible?: boolean, + ) => { + const snap = liveRef.current + const visibleTabBar = committedTabBarVisible ?? snap.tabBarVisible + if (committedTabBarVisible !== undefined) { + publishedRef.current = snap.device + ? { + bridgeId: page.bridgeId, + reservesTabBar: page.isTab && committedTabBarVisible, + navBarStyle: page.navBar.style, + device: snap.device, + deviceOrientation: snap.deviceOrientation, + } + : null + const committedMounted = snap.mounted.some(item => item.entry.bridgeId === page.bridgeId) + ? snap.mounted.map(item => ({ + ...item, + visible: item.entry.bridgeId === page.bridgeId, + })) + : [{ entry: page, visible: true }, ...snap.mounted.map(item => ({ ...item, visible: false }))] + liveRef.current = { ...snap, mounted: committedMounted, tabBarVisible: committedTabBarVisible } + } + if (!snap.device) return + orientation.openPage(page.bridgeId, page.windowConfig.pageOrientation) + miniApp.notifyResize(computeResizePayload( + orientation, + miniApp.appSessionId ?? '', + { + bridgeId: page.bridgeId, + reservesTabBar: page.isTab && visibleTabBar, + navBarStyle: page.navBar.style, + }, + snap.device, + snap.deviceOrientation, + )) + }, [miniApp, orientation]) + + const topEffective = orientation.effectiveFor(top.bridgeId, deviceOrientation) + return { + orientedMetrics: device ? orientedDeviceMetrics(device, topEffective) : null, + // What the shell PAINTS at the bottom (home indicator, tab-bar padding) has to be the same inset `computeResizePayload` takes out of the page's window, or the page would be told it has less room than the chrome actually occupies. + orientedBottomInset: device + ? orientedSafeAreaInsets({ ...device, hasNotch: device.notchType !== 'none' }, topEffective).bottom + : 0, + publishTopResize, + applyDevice, + } +} diff --git a/packages/devtools/src/simulator/simulator-api.test.ts b/packages/devtools/src/simulator/simulator-api.test.ts index e040b075..7c5c420d 100644 --- a/packages/devtools/src/simulator/simulator-api.test.ts +++ b/packages/devtools/src/simulator/simulator-api.test.ts @@ -11,11 +11,14 @@ * getSystemInfoSync.statusBarHeight → falls back to 0 * - safeArea.height and safeArea.bottom differ accordingly. * The tests pin these divergent values as-is (characterization, not bug fix). + * + * `__deviceInfo` / `getDeviceMetrics()` state the PORTRAIT baseline; the reported screen geometry is that baseline resolved for the orientation the page is showing, which the mocked `.dimina-native-webview__root` rect (`WB`) states — see `resolveScreenGeometry` in simulator-api.ts. + * Scenes A and B are both portrait, so the baseline passes through; the landscape suites below pin the re-orientation. */ import { beforeEach, afterEach, describe, expect, it } from 'vitest' -import type { MiniAppContext } from './types' -import { getWindowInfo, getSystemInfoSync } from './simulator-api' +import type { DeviceMetrics, MiniAppContext } from './types' +import { getSystemSetting, getWindowInfo, getSystemInfoSync } from './simulator-api' // ─── shared mock helpers ────────────────────────────────────────────────────── @@ -84,13 +87,14 @@ describe('getWindowInfo', () => { windowWidth: 300, windowHeight: 600, statusBarHeight: 44, + // Portrait-baseline device dims + insets (__deviceInfo.screenWidth/ screenHeight/safeAreaInsets), NOT the mocked viewport rect (WB). safeArea: { - width: 300, - height: 556, // 600 - 44 + width: 390, + height: 766, // 844 - 44 - 34 top: 44, - bottom: 600, + bottom: 810, // 844 - 34 left: 0, - right: 300, + right: 390, }, }) }) @@ -122,6 +126,104 @@ describe('getWindowInfo', () => { }) }) +// ─── Landscape: the page is showing the long edge ───────────────────────────── +// +// The device stays a portrait-baseline iPhone; only the viewport says the page turned. +// Both this path and the native-host one (shared/page-resize-host-env.ts) must answer with the same coordinate system — the notch moves from the top edge to both sides, the top frees up, and the home indicator thins to 21. + +const NOTCHED_DEVICE: DeviceMetrics = { + pixelRatio: 3, + screenWidth: 390, + screenHeight: 844, + statusBarHeight: 44, + safeAreaInsets: { top: 44, right: 0, bottom: 34, left: 0 }, + hasNotch: true, + deviceOrientation: 'portrait', +} + +/** A context whose viewport rect and device metrics are both explicit. */ +function makeDeviceMockThis( + viewport: { width: number; height: number }, + device: DeviceMetrics, +): MiniAppContext { + return { + appId: 'test-app', + createCallbackFunction: (fn: unknown) => (fn ? (fn as (...a: unknown[]) => void) : undefined), + parent: { + el: { + querySelector: (_sel: string) => ({ getBoundingClientRect: () => ({ ...viewport }) }), + } as unknown as Element, + getStatusBarRect: () => ({ height: device.statusBarHeight }), + }, + getDeviceMetrics: () => device, + } as unknown as MiniAppContext +} + +describe('screen geometry follows the orientation the page is showing', () => { + afterEach(() => { + delete (window as Window & { __deviceInfo?: unknown }).__deviceInfo + }) + + it('rotates the device baseline and rebuilds safeArea for a landscape viewport', () => { + const result = getSystemInfoSync.call(makeDeviceMockThis({ width: 844, height: 390 }, NOTCHED_DEVICE)) + + expect(result).toMatchObject({ + screenWidth: 844, + screenHeight: 390, + statusBarHeight: 0, + deviceOrientation: 'landscape', + safeArea: { + top: 0, + left: 44, // the notch's own depth, now on the side edges + right: 800, // 844 - 44 + bottom: 369, // 390 - 21 home indicator + width: 756, // 844 - 44 - 44 + height: 369, + }, + }) + }) + + it('reports the same landscape rect through getWindowInfo', () => { + const result = getWindowInfo.call(makeDeviceMockThis({ width: 844, height: 390 }, NOTCHED_DEVICE)) + + expect(result).toMatchObject({ + screenWidth: 844, + screenHeight: 390, + statusBarHeight: 0, + safeArea: { top: 0, left: 44, right: 800, bottom: 369, width: 756, height: 369 }, + }) + }) + + it('keeps a page pinned to portrait in portrait while the simulated device is rotated', () => { + // DeviceShell sizes the shell from the top page's EFFECTIVE orientation, so a portrait-pinned page keeps a portrait viewport on a rotated device. + // Reading the toolbar's device rotation instead would report landscape geometry for a page that never turned. + const rotatedDevice: DeviceMetrics = { ...NOTCHED_DEVICE, deviceOrientation: 'landscape' } + const context = makeDeviceMockThis({ width: 390, height: 844 }, rotatedDevice) + + expect(getSystemInfoSync.call(context)).toMatchObject({ + screenWidth: 390, + screenHeight: 844, + statusBarHeight: 44, + deviceOrientation: 'portrait', + safeArea: { top: 44, left: 0, right: 390, bottom: 810, width: 390, height: 766 }, + }) + expect(getSystemSetting.call(context)).toMatchObject({ deviceOrientation: 'portrait' }) + }) + + it('leaves a notch-less device with no side insets in landscape', () => { + const flatDevice: DeviceMetrics = { + ...NOTCHED_DEVICE, + hasNotch: false, + statusBarHeight: 24, + safeAreaInsets: { top: 24, right: 0, bottom: 0, left: 0 }, + } + + expect(getSystemInfoSync.call(makeDeviceMockThis({ width: 844, height: 390 }, flatDevice))).toMatchObject({ + safeArea: { top: 0, left: 0, right: 844, bottom: 390, width: 844, height: 390 }, + }) + }) +}) + describe('getSystemInfoSync', () => { let mockThis: MiniAppContext @@ -155,13 +257,14 @@ describe('getSystemInfoSync', () => { fontSizeSetting: 16, SDKVersion: '3.0.0', deviceOrientation: 'portrait', + // Portrait-baseline device dims + insets, NOT the mocked viewport rect. safeArea: { - width: 300, - height: 522, // 600 - 44 - 34 + width: 390, + height: 766, // 844 - 44 - 34 top: 44, - bottom: 566, // 600 - 34 + bottom: 810, // 844 - 34 left: 0, - right: 300, + right: 390, }, }) }) diff --git a/packages/devtools/src/simulator/simulator-api.ts b/packages/devtools/src/simulator/simulator-api.ts index 66b7c0aa..d61e90a2 100644 --- a/packages/devtools/src/simulator/simulator-api.ts +++ b/packages/devtools/src/simulator/simulator-api.ts @@ -6,7 +6,12 @@ * (via AppManager.registerApi → MiniApp.invokeApi). */ -import type { MiniAppContext } from './types' +import { + normalizeDeviceOrientation, + orientedDeviceMetrics, + orientedSafeAreaInsets, +} from '@dimina-kit/electron-runtime/shared/page-orientation' +import type { DeviceMetrics, MiniAppContext } from './types' import { bindCallbacks } from './simulator-api-helpers' import { setStorageSync, @@ -84,21 +89,18 @@ export function canIUse(this: MiniAppContext, { success, complete }: { success?: export function getWindowInfo(this: MiniAppContext, { success, complete }: { success?: unknown; complete?: unknown } = {}) { const { onSuccess, onComplete } = bindCallbacks(this, { success, complete }) - const { wb, di, dev, pixelRatio, screenWidth, screenHeight, windowWidth, windowHeight } = readWindowMetrics(this) + const { wb, di, dev, pixelRatio, windowWidth, windowHeight } = readWindowMetrics(this) const bar = this.parent?.getStatusBarRect?.() ?? { height: dev?.statusBarHeight ?? 0 } - const statusBarHeight = (di['statusBarHeight'] as number | undefined) ?? bar.height + const portraitStatusBarHeight = (di['statusBarHeight'] as number | undefined) ?? bar.height + const geometry = resolveScreenGeometry(di, dev, wb, portraitStatusBarHeight) const info = { - pixelRatio, screenWidth, screenHeight, windowWidth, windowHeight, - statusBarHeight, - safeArea: { - width: wb.width, - height: wb.height - statusBarHeight, - top: statusBarHeight, - bottom: wb.height, - left: 0, - right: wb.width, - }, + pixelRatio, + screenWidth: geometry.screenWidth, + screenHeight: geometry.screenHeight, + windowWidth, windowHeight, + statusBarHeight: geometry.statusBarHeight, + safeArea: geometry.safeArea, } onSuccess?.(info) onComplete?.() @@ -107,12 +109,14 @@ export function getWindowInfo(this: MiniAppContext, { success, complete }: { suc export function getSystemSetting(this: MiniAppContext, { success, complete }: { success?: unknown; complete?: unknown } = {}) { const { onSuccess, onComplete } = bindCallbacks(this, { success, complete }) + const { windowWidth, windowHeight } = readWindowMetrics(this) const info = { bluetoothEnabled: false, locationEnabled: true, wifiEnabled: true, - deviceOrientation: 'portrait', + // Same rule as getSystemInfoSync's: what the page shows, not how the simulated device is rotated (see resolveScreenGeometry). + deviceOrientation: normalizeDeviceOrientation({ windowWidth, windowHeight }), } onSuccess?.(info) onComplete?.() @@ -141,34 +145,83 @@ function readWindowMetrics(miniApp: MiniAppContext) { } } +/** + * The one place this path resolves the screen-geometry family — orientation, screen dimensions, status bar height and safeArea — so they can never describe two different orientations at once. + * + * Everything follows the orientation the page is ACTUALLY showing, which the live viewport rect states directly: DeviceShell sizes the phone shell from the top page's effective orientation (device-shell/orientation-controller.ts), so a page pinned to portrait stays portrait on a rotated device. + * The simulated device's own rotation (`dev.deviceOrientation`, the toolbar control) is deliberately NOT consulted here — it would hand such a page landscape geometry. + * + * safeArea follows that orientation too, the same coordinate system the native-host path uses (shared/page-resize-host-env.ts → service-host/sync-impls/system-info.ts): in landscape the notch leaves the top edge for both sides and the home indicator gets thinner. + * That is what WeChat itself does — its base library re-asks native for a fresh `safeArea` whenever `deviceOrientation` changes instead of transforming the portrait one, and `getSystemInfoSync` passes the current native value straight through. + * Keeping portrait insets next to landscape dimensions would produce a rect that matches neither. + * + * `di` (window.__deviceInfo) keeps its existing override priority over `dev` (SimulatorMiniApp.getDeviceMetrics()); both state the PORTRAIT baseline, so they are re-oriented here. `wb` is the last resort when no device model is known at all, and it is already in the current orientation — it is folded back to a portrait baseline first so the single re-orientation below cannot swap an already-swapped rect. + */ +function resolveScreenGeometry( + di: Record, + dev: DeviceMetrics | undefined, + wb: { width: number; height: number }, + portraitStatusBarHeight: number, +) { + const orientation = normalizeDeviceOrientation({ windowWidth: wb.width, windowHeight: wb.height }) + const landscape = orientation === 'landscape' + const baselineWidth = (di['screenWidth'] as number | undefined) ?? dev?.screenWidth + ?? (landscape ? wb.height : wb.width) + const baselineHeight = (di['screenHeight'] as number | undefined) ?? dev?.screenHeight + ?? (landscape ? wb.width : wb.height) + const baselineInsets = (di['safeAreaInsets'] as DeviceMetrics['safeAreaInsets'] | undefined) + ?? dev?.safeAreaInsets + ?? { top: portraitStatusBarHeight, right: 0, bottom: 0, left: 0 } + const metrics = orientedDeviceMetrics( + { screenWidth: baselineWidth, screenHeight: baselineHeight, statusBarHeight: portraitStatusBarHeight }, + orientation, + ) + const insets = orientedSafeAreaInsets( + { + statusBarHeight: portraitStatusBarHeight, + // Without a selected device only __deviceInfo speaks, and it has no notch field: a bottom inset in portrait is a home indicator, and only screens with one have a cutout to move to the sides. + hasNotch: dev?.hasNotch ?? baselineInsets.bottom > 0, + safeAreaInsets: baselineInsets, + }, + orientation, + ) + return { + orientation, + screenWidth: metrics.screenWidth, + screenHeight: metrics.screenHeight, + statusBarHeight: metrics.statusBarHeight, + safeArea: { + left: insets.left, + top: insets.top, + right: metrics.screenWidth - insets.right, + bottom: metrics.screenHeight - insets.bottom, + width: metrics.screenWidth - insets.left - insets.right, + height: metrics.screenHeight - insets.top - insets.bottom, + }, + } +} + function buildSystemInfo(miniApp: MiniAppContext) { - const { wb, di, dev, pixelRatio, screenWidth, screenHeight, windowWidth, windowHeight } = readWindowMetrics(miniApp) - const statusBarHeight = (di['statusBarHeight'] as number | undefined) ?? dev?.statusBarHeight ?? 0 - // Bottom inset sourced from safeAreaInsets.bottom (the single source — the - // legacy flat `safeAreaBottom` field is decommissioned). - const bottomInset = (di['safeAreaInsets'] as { bottom?: number } | undefined)?.bottom - ?? dev?.safeAreaInsets?.bottom ?? 0 + const { wb, di, dev, pixelRatio, windowWidth, windowHeight } = readWindowMetrics(miniApp) + const portraitStatusBarHeight = (di['statusBarHeight'] as number | undefined) ?? dev?.statusBarHeight ?? 0 + const geometry = resolveScreenGeometry(di, dev, wb, portraitStatusBarHeight) return { brand: di['brand'] || 'devtools', model: di['model'] || 'devtools', - pixelRatio, screenWidth, screenHeight, windowWidth, windowHeight, - statusBarHeight, + pixelRatio, + screenWidth: geometry.screenWidth, + screenHeight: geometry.screenHeight, + windowWidth, windowHeight, + statusBarHeight: geometry.statusBarHeight, language: 'zh_CN', version: '8.0.5', system: di['system'] || 'iOS 16.0', platform: di['platform'] || 'ios', fontSizeSetting: 16, SDKVersion: '3.0.0', - deviceOrientation: 'portrait', - safeArea: { - width: wb.width, - height: wb.height - statusBarHeight - bottomInset, - top: statusBarHeight, - bottom: wb.height - bottomInset, - left: 0, - right: wb.width, - }, + deviceOrientation: geometry.orientation, + safeArea: geometry.safeArea, } } diff --git a/packages/devtools/src/simulator/simulator-mini-app-initial-device.test.ts b/packages/devtools/src/simulator/simulator-mini-app-initial-device.test.ts new file mode 100644 index 00000000..9b10c656 --- /dev/null +++ b/packages/devtools/src/simulator/simulator-mini-app-initial-device.test.ts @@ -0,0 +1,110 @@ +/** + * `SimulatorMiniApp.getInitialDevice()` must report the device that is selected NOW, not the one frozen into the native-host bridge config when the simulator document loaded. + * + * DeviceShell reads it once for its very first render and only then registers its own DEVICE_CHANGE listener. + * A device switched between `spawn()` resolving and DeviceShell mounting reaches the app (it subscribes before spawning) but not the shell's listener, and nothing replays it — so the shell would keep drawing the boot device until the user happens to switch again. + */ +import { afterEach, describe, expect, it } from 'vitest' +import { SIMULATOR_EVENTS } from '../shared/bridge-channels' +import type { NativeDeviceInfo } from '../shared/ipc-channels' +import { SimulatorMiniApp } from './simulator-mini-app' + +type Listener = (payload: unknown) => void + +const BOOT_DEVICE: NativeDeviceInfo = { + brand: 'Apple', + model: 'iPhone SE', + system: 'iOS 16.0', + platform: 'ios', + pixelRatio: 2, + screenWidth: 375, + screenHeight: 667, + statusBarHeight: 20, + notchType: 'none', + safeAreaInsets: { top: 20, right: 0, bottom: 0, left: 0 }, + deviceOrientation: 'portrait', +} + +const SWITCHED_DEVICE: NativeDeviceInfo = { + ...BOOT_DEVICE, + model: 'iPhone 14 Pro Max', + pixelRatio: 3, + screenWidth: 430, + screenHeight: 932, + statusBarHeight: 54, + notchType: 'dynamic-island', + safeAreaInsets: { top: 54, right: 0, bottom: 34, left: 0 }, + deviceOrientation: 'landscape', +} + +function installNativeHostMock() { + const listeners = new Map>() + const host = { + enabled: true, + device: BOOT_DEVICE, + spawn: async () => ({ + appSessionId: 's1', + bridgeId: 'b1', + pagePath: 'pages/index/index', + resolvedPagePath: 'pages/index/index', + pageFallbackApplied: false, + serviceWcId: 1, + resourceBaseUrl: '', + root: 'main', + manifest: { pages: ['pages/index/index'], entryPagePath: 'pages/index/index', source: 'app-config' }, + rootWindowConfig: {}, + }), + dispose: () => {}, + openPage: async () => ({ bridgeId: 'unused', pagePath: 'unused', windowConfig: {}, isTab: false }), + closePage: () => {}, + notifyLifecycle: () => {}, + notifyNavCallback: () => {}, + notifyApiResponse: () => {}, + notifyActivePage: () => {}, + notifyPageStack: () => {}, + notifyResize: () => {}, + createRenderHostUrl: () => 'about:blank', + renderPreloadUrl: 'about:blank', + onSimulatorEvent: (channel: string, listener: Listener) => { + let set = listeners.get(channel) + if (!set) { set = new Set(); listeners.set(channel, set) } + set.add(listener) + return () => { set?.delete(listener) } + }, + } + window.__diminaNativeHost = host as unknown as Window['__diminaNativeHost'] + return { + emitDeviceChange: (device: NativeDeviceInfo) => { + for (const fn of listeners.get(SIMULATOR_EVENTS.DEVICE_CHANGE) ?? []) fn(device) + }, + } +} + +afterEach(() => { + delete (window as { __diminaNativeHost?: unknown }).__diminaNativeHost +}) + +describe('SimulatorMiniApp.getInitialDevice', () => { + it('returns the boot config device before any DEVICE_CHANGE arrives', async () => { + installNativeHostMock() + const app = new SimulatorMiniApp({ appId: 'a', scene: 1001, pagePath: 'pages/index/index' }) + await app.spawn() + + expect(app.getInitialDevice()).toEqual(BOOT_DEVICE) + }) + + it('returns a device switched between spawn resolving and the shell mounting', async () => { + const host = installNativeHostMock() + const app = new SimulatorMiniApp({ appId: 'a', scene: 1001, pagePath: 'pages/index/index' }) + await app.spawn() + + host.emitDeviceChange(SWITCHED_DEVICE) + + expect(app.getInitialDevice()).toEqual(SWITCHED_DEVICE) + expect(app.getDeviceMetrics()).toMatchObject({ + screenWidth: 430, + screenHeight: 932, + deviceOrientation: 'landscape', + }) + }) +}) diff --git a/packages/devtools/src/simulator/simulator-mini-app.ts b/packages/devtools/src/simulator/simulator-mini-app.ts index 3aa53331..287640cf 100644 --- a/packages/devtools/src/simulator/simulator-mini-app.ts +++ b/packages/devtools/src/simulator/simulator-mini-app.ts @@ -10,10 +10,12 @@ import type { PageStackEntry, PageStackPayload, PageWindowConfig, + SessionActivePayload, SpawnRequest, SpawnResult, TabBarConfig, } from '../shared/bridge-channels' +import type { PageResizePayload } from '@dimina-kit/electron-runtime/shared/page-orientation' import type { NativeDeviceInfo } from '../shared/ipc-channels' import type { DeviceMetrics } from './types' @@ -35,6 +37,8 @@ interface NativeHostBridge { notifyApiResponse(payload: ApiResponsePayload): void notifyActivePage(payload: ActivePagePayload): void notifyPageStack(payload: PageStackPayload): void + notifyResize(payload: PageResizePayload): void + notifySessionActive(payload: SessionActivePayload): void createRenderHostUrl(opts: { bridgeId: string; appId: string; root: string; pagePath: string; isTab?: boolean; backgroundColor?: string }): string renderPreloadUrl: string device?: NativeDeviceInfo @@ -88,10 +92,8 @@ export class SimulatorMiniApp { private readonly apiNamespaces: string[] /** * Latest device delivered over SIMULATOR_EVENTS.DEVICE_CHANGE (live toolbar - * switches). Cleared on dispose(): main's sticky device selection reaches a - * fresh spawn through its boot config (getInitialDevice), which is always - * re-delivered with the latest selection — a live value held across dispose - * would shadow a newer boot config with a stale device. + * switches); `getInitialDevice()` prefers it over the boot config, which is a snapshot frozen at preload-install time. + * Cleared on dispose() along with the subscription — the next spawn re-subscribes before it requests the session, so every change from then on is observed. */ private currentDevice: NativeDeviceInfo | null = null private unsubscribeDeviceChange: (() => void) | null = null @@ -233,19 +235,32 @@ export class SimulatorMiniApp { getNativeHost().notifyPageStack({ appSessionId, stack }) } + /** + * Report the visible top page's window geometry (PAGE_RESIZE). + * Main always refreshes the host-env snapshot from this; it also dispatches `pageResize` to the service host when `payload.dispatchPage` is true and fires `wx.onWindowResize` listeners when `payload.dispatchWindow` is true (DeviceShell already applied WeChat's gating — see orientation-controller.ts). + */ + notifyResize(payload: PageResizePayload): void { + if (!this.appSessionId) return + getNativeHost().notifyResize(payload) + } + + /** + * Claim the screen for this session. + * DeviceShell calls it the moment it becomes the visible shell — during a soft reload two shells are mounted and both report geometry, so main only learns which one the user sees because the visible one says so. + */ + notifySessionActive(): void { + const appSessionId = this.appSessionId + if (!appSessionId) return + getNativeHost().notifySessionActive({ appSessionId }) + } + getTabBarConfig(): TabBarConfig | null { return this.manifest?.tabBar ?? null } /** - * The app's own home page — both the target of the nav-bar home button and - * the page its visibility rule compares the current page against. Only a - * compiled manifest knows it: `entryPagePath`, else `pages[0]`. A 'fallback' - * manifest is built from the spawn request itself, so its entry is whichever - * page this session happened to launch into — reading it would let a - * deep-linked inner page masquerade as home. That case and the no-manifest - * case both return '', which turns the home-button rule off rather than - * guessing a page. + * The app's own home page. + * A fallback manifest reflects the launch request, not the compiled home page, so it deliberately disables the home rule. */ getHomePagePath(): string { const manifest = this.manifest @@ -254,25 +269,18 @@ export class SimulatorMiniApp { } /** - * The device selected when this simulator booted (delivered by main on the - * native-host bridge config — the renderer pushes SetDeviceInfo before - * AttachNative). DeviceShell uses it as the initial bezel size + notch; live - * changes arrive over SIMULATOR_EVENTS.DEVICE_CHANGE. Null on the pre-spawn - * default path. + * The newest live device selection, falling back to the boot-time bridge snapshot before DEVICE_CHANGE has been observed. */ getInitialDevice(): NativeDeviceInfo | null { - return getNativeHost().device ?? null + return this.currentDevice ?? getNativeHost().device ?? null } /** * Metric fallbacks for the simulator-resident wx.* API handlers - * (readWindowMetrics in simulator-api.ts): the CURRENT device — a live - * DEVICE_CHANGE wins over the boot config device — or, when no device was - * ever selected, the host-env snapshot defaults (the same source the sync - * service-host wx.getSystemInfoSync reports). + * (readWindowMetrics in simulator-api.ts): the current device, or — when no device was ever selected — the host-env snapshot defaults (the same source the sync service-host wx.getSystemInfoSync reports). */ getDeviceMetrics(): DeviceMetrics { - const device = this.currentDevice ?? this.getInitialDevice() + const device = this.getInitialDevice() if (device) { return { pixelRatio: device.pixelRatio, @@ -280,6 +288,8 @@ export class SimulatorMiniApp { screenHeight: device.screenHeight, statusBarHeight: device.statusBarHeight, safeAreaInsets: device.safeAreaInsets, + hasNotch: device.notchType !== 'none', + deviceOrientation: device.deviceOrientation ?? 'portrait', } } const snap = this.getHostEnvSnapshot() @@ -289,6 +299,8 @@ export class SimulatorMiniApp { screenHeight: snap.screenHeight, statusBarHeight: snap.statusBarHeight, safeAreaInsets: { top: 0, right: 0, bottom: 0, left: 0 }, + hasNotch: false, + deviceOrientation: snap.deviceOrientation ?? 'portrait', } } @@ -319,6 +331,7 @@ export class SimulatorMiniApp { statusBarHeight, language, theme: prefersDarkMode() ? 'dark' : 'light', + deviceOrientation: device?.deviceOrientation ?? 'portrait', } } diff --git a/packages/devtools/src/simulator/types.ts b/packages/devtools/src/simulator/types.ts index 106b559b..f4dfacb2 100644 --- a/packages/devtools/src/simulator/types.ts +++ b/packages/devtools/src/simulator/types.ts @@ -1,3 +1,5 @@ +import type { Orientation } from '@dimina-kit/electron-runtime/shared/page-orientation' + /** Callback type used by API functions */ export type Callback = (...args: unknown[]) => void @@ -16,6 +18,11 @@ export interface DeviceMetrics { /** Per-edge safe-area insets (portrait). Single source of truth for the * bottom inset — there is no separate `safeAreaBottom` field. */ safeAreaInsets: { top: number; right: number; bottom: number; left: number } + /** Whether the screen has a notch/dynamic island: in landscape it moves to + * both side edges, which is the only thing the portrait insets cannot say. */ + hasNotch: boolean + /** The simulated device's own orientation (user-controlled, not the mini-app's effective one). */ + deviceOrientation?: Orientation } /** diff --git a/packages/dimina-electron-runtime/e2e/dist-assets-current.spec.ts b/packages/dimina-electron-runtime/e2e/dist-assets-current.spec.ts new file mode 100644 index 00000000..08213fd5 --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/dist-assets-current.spec.ts @@ -0,0 +1,91 @@ +/** + * The whole e2e suite boots this package's `dist/`, and the parts of it that decide mini-app semantics are not built here at all: `build-assets.mjs` copies them verbatim out of `packages/devtools/dist` (the devtools build is what injects the simulator's service-API overlays into the dimina service bundle — the overlays that make sync `FileSystemManager` methods throw and that route audio/upload/WebSocket through the container). + * + * A copy left behind by an older devtools build therefore does not fail loudly: the runtime boots fine and the mini-app silently gets upstream behaviour instead of the simulator's, so unrelated-looking specs go red while the source tree is innocent. + * This spec restates `build-assets.mjs`'s postcondition — every copied asset is byte-identical to the devtools build it came from — so a stale copy is reported as itself. + * + * Not a substitute for building devtools: it compares the copy against `packages/devtools/dist`, so it can only catch drift between the two. + */ +import { test, expect } from '@playwright/test' +import { createHash } from 'crypto' +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const PACKAGE_DIR = path.resolve(__dirname, '..') +const DEVTOOLS_DIR = path.resolve(PACKAGE_DIR, '..', 'devtools') + +/** Mirrors the asset list in `build-assets.mjs`. */ +const COPIED_DIRS = ['dist/simulator', 'dist/service-host', 'dist/render-host', 'dist/native-host'] +const COPIED_FILES: Array<{ from: string, to: string }> = [ + { from: 'dist/preload/windows/simulator.cjs', to: 'dist/preload/simulator.cjs' }, +] + +const REBUILD_HINT = 'run `pnpm --filter @dimina-kit/electron-runtime build:assets`' + +function listFiles(dir: string): string[] { + if (!fs.existsSync(dir)) return [] + const out: string[] = [] + for (const entry of fs.readdirSync(dir, { withFileTypes: true, recursive: true })) { + if (!entry.isFile()) continue + out.push(path.relative(dir, path.join(entry.parentPath, entry.name))) + } + return out.sort() +} + +function hashFile(file: string): string { + return createHash('sha1').update(fs.readFileSync(file)).digest('hex') +} + +test.describe('dist assets copied from the devtools build', () => { + test('every copied asset matches its devtools source', () => { + expect( + fs.existsSync(path.join(DEVTOOLS_DIR, 'dist')), + 'packages/devtools/dist is missing — the comparison source has not been built', + ).toBe(true) + + const drift: string[] = [] + for (const rel of COPIED_DIRS) { + const mine = path.join(PACKAGE_DIR, rel) + const theirs = path.join(DEVTOOLS_DIR, rel) + const mineFiles = new Set(listFiles(mine)) + const theirsFiles = listFiles(theirs) + for (const file of theirsFiles) { + if (!mineFiles.has(file)) { + drift.push(`${rel}/${file}: missing from this package's dist`) + continue + } + mineFiles.delete(file) + if (hashFile(path.join(mine, file)) !== hashFile(path.join(theirs, file))) { + drift.push(`${rel}/${file}: differs from the devtools build`) + } + } + for (const file of mineFiles) drift.push(`${rel}/${file}: left over, not in the devtools build`) + } + for (const { from, to } of COPIED_FILES) { + const mine = path.join(PACKAGE_DIR, to) + const theirs = path.join(DEVTOOLS_DIR, from) + if (!fs.existsSync(mine)) drift.push(`${to}: missing from this package's dist`) + else if (hashFile(mine) !== hashFile(theirs)) drift.push(`${to}: differs from ${from}`) + } + + expect(drift, `dist assets are out of date — ${REBUILD_HINT}:\n${drift.join('\n')}`).toEqual([]) + }) + + /** + * Matching the devtools build is not enough on its own: `build-native-host.mjs` copies whatever `dimina/fe/packages/service/dist` happens to hold, and that dist only carries the simulator's service-API overlays when it was produced by the injecting container build. + * A bundle built straight from upstream sources copies over just as cleanly and hands the mini-app upstream behaviour — sync FSM methods answering `undefined` instead of throwing, audio/upload/WebSocket bypassing the container backends. + */ + test('the shipped service bundle carries the simulator service-API overlays', () => { + const overlaySource = path.join(DEVTOOLS_DIR, 'src/simulator/service-apis/file/index.js') + const reason = /SYNC_UNSUPPORTED_REASON\s*=\s*'([^']+)'/.exec(fs.readFileSync(overlaySource, 'utf8'))?.[1] + expect(reason, `could not read the overlay marker out of ${overlaySource}`).toBeTruthy() + + const bundle = path.join(PACKAGE_DIR, 'dist/native-host/service/service.js') + expect( + fs.readFileSync(bundle, 'utf8').includes(reason!), + `${bundle} was built without the overlays — ${REBUILD_HINT}`, + ).toBe(true) + }) +}) diff --git a/packages/dimina-electron-runtime/e2e/electron-entry.js b/packages/dimina-electron-runtime/e2e/electron-entry.js index b6d44e49..b46aaf9f 100644 --- a/packages/dimina-electron-runtime/e2e/electron-entry.js +++ b/packages/dimina-electron-runtime/e2e/electron-entry.js @@ -163,6 +163,24 @@ function getPageStack(appId) { })) } +/** + * The routes the SERVICE host's own page stack currently holds (`getCurrentPages()`), resolved through the bridge so it always reads the session that owns `appId` — never a not-yet-destroyed service window from a just-closed session. + * + * `getCurrentPage` above answers a different question: it reads `pagePath` off the RENDER guest's URL, which is fixed at guest-creation time, long before the service host has booted its bundle and instantiated the root `Page`. + * The route APIs (`navigateTo`/`redirectTo`/`switchTab`/`reLaunch`) resolve their `url` against `router.getPageInfo().route` in the service host, so they need THIS fact, not the render guest's URL. + * + * Returns `[]` when no service host is connected yet. + */ +async function getServicePageRoutes(appId) { + const bridge = getBridge() + const serviceWc = bridge.getServiceWc(appId) + if (!serviceWc || serviceWc.isDestroyed() || serviceWc.isLoading()) return [] + return serviceWc.executeJavaScript(`(() => { + if (typeof getCurrentPages !== 'function') return [] + return getCurrentPages().map((p) => (p && p.route) || '') + })()`).catch(() => []) +} + function getPageData(appId, path) { const bridge = getBridge() const bridgeId = bridge.getActiveBridgeId(appId) @@ -216,7 +234,28 @@ function waitForActivePage(bridge, { since, timeoutMs }) { async function runNativeHostNav(bridge, serviceWc, method, args) { const arg = method === 'navigateBack' ? (args[0] ?? { delta: 1 }) : (args[0] ?? {}) const since = bridge.getActiveBridgeId() - await serviceWc.executeJavaScript(`wx.${method}(${JSON.stringify(arg)})`) + // `executeJavaScript` does not marshal a thrown renderer error back here: any exception inside the dispatched script surfaces as Electron's fixed, detail-free "Script failed to execute" string, with the real message and stack reachable only from the service host's own console. + // So the script RETURNS its outcome instead of throwing, and main re-raises it with the renderer's message/stack attached. + // Promise semantics are preserved: the script still resolves through whatever `wx.()` returns, so a rejected nav still fails this call — just with a readable reason. + const outcome = await serviceWc.executeJavaScript(`(() => { + const describe = (e) => ({ msg: String((e && e.message) || e), stack: String((e && e.stack) || '') }) + try { + if (typeof wx === 'undefined') return { ok: false, phase: 'no-wx' } + if (typeof wx.${method} !== 'function') return { ok: false, phase: 'no-method' } + return Promise.resolve(wx.${method}(${JSON.stringify(arg)})).then( + () => ({ ok: true }), + (e) => ({ ok: false, phase: 'rejected', ...describe(e) }), + ) + } catch (e) { + return { ok: false, phase: 'threw', ...describe(e) } + } + })()`) + if (!outcome.ok) { + throw new Error( + `[e2e] wx.${method}(${JSON.stringify(arg)}) ${outcome.phase} in the service host` + + `${outcome.msg ? `: ${outcome.msg}` : ''}${outcome.stack ? `\n${outcome.stack}` : ''}`, + ) + } const timeoutMs = method === 'navigateBack' ? 1500 : 2000 await waitForActivePage(bridge, { since, timeoutMs }) return { result: undefined } @@ -293,14 +332,27 @@ function setDeviceHook(device) { } } +/** + * Simulate the user rotating the physical device: broadcasts DEVICE_CHANGE to every mounted DeviceShell via the runtime's public `setDevice()`, WITHOUT the direct `service-host:host-env:update` push `setDeviceHook` also does. + * + * That direct push (see setDeviceHook above) writes `deviceInfoToHostEnv(device)` straight into the running service host's snapshot — a raw overwrite that is page-orientation-UNAWARE (it derives windowWidth/windowHeight purely from the device's own orientation) and fires no dispatch/event at all. + * That is fine for device-SIZE e2e (native-host-device.spec.ts, which never depends on per-page orientation), but wrong for page-orientation e2e: it would stomp a fixed-orientation page's snapshot with the naive device-only geometry, and it can never be the signal that gates `Page.onResize` / `wx.onWindowResize` dispatch because it never goes through DeviceShell's own dispatch-gated PAGE_RESIZE pipeline. + * Orientation e2e needs the real wire: DEVICE_CHANGE -> DeviceShell recomputes effectiveOrientation from device + page state -> notifyResize -> main. + */ +function rotateDeviceHook(device) { + runtime.setDevice(device) +} + globalThis.__diminaE2eHooks = { openProject: openProjectHook, closeProject: closeProjectHook, getCurrentPage, getPageStack, + getServicePageRoutes, getPageData, callWxMethod, setDevice: setDeviceHook, + rotateDevice: rotateDeviceHook, } })() diff --git a/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/app.js b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/app.js new file mode 100644 index 00000000..6241c06e --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/app.js @@ -0,0 +1 @@ +App({}) diff --git a/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/app.json b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/app.json new file mode 100644 index 00000000..26d99fa8 --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/app.json @@ -0,0 +1,12 @@ +{ + "pages": [ + "pages/home/home", + "pages/landscape-page/landscape-page", + "pages/auto-page/auto-page" + ], + "window": { + "navigationBarTitleText": "Landscape Fixture", + "navigationBarBackgroundColor": "#ffffff", + "navigationBarTextStyle": "black" + } +} diff --git a/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/app.wxss b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/app.wxss new file mode 100644 index 00000000..f58c1bb9 --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/app.wxss @@ -0,0 +1,25 @@ +page { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 28rpx; + color: #333; + background-color: #f5f5f5; +} + +.page-marker { + font-size: 40rpx; + font-weight: 700; + padding: 40rpx; + color: #1a1a1a; +} + +.btn { + display: block; + width: 80%; + margin: 20rpx auto; + height: 80rpx; + line-height: 80rpx; + text-align: center; + background: #1890ff; + color: #fff; + border-radius: 12rpx; +} diff --git a/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/auto-page/auto-page.js b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/auto-page/auto-page.js new file mode 100644 index 00000000..aa2809d9 --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/auto-page/auto-page.js @@ -0,0 +1,13 @@ +// pageOrientation: 'auto' — follows the simulated device's own orientation. resizeCount/lastResize record every Page.onResize call so the e2e can assert it fires exactly once per rotation, with the payload shape { size: { windowWidth, windowHeight }, deviceOrientation }. +Page({ + data: { + resizeCount: 0, + lastResize: null, + }, + onResize(res) { + this.setData({ + resizeCount: this.data.resizeCount + 1, + lastResize: res, + }) + }, +}) diff --git a/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/auto-page/auto-page.json b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/auto-page/auto-page.json new file mode 100644 index 00000000..aa38c811 --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/auto-page/auto-page.json @@ -0,0 +1,3 @@ +{ + "pageOrientation": "auto" +} diff --git a/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/auto-page/auto-page.wxml b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/auto-page/auto-page.wxml new file mode 100644 index 00000000..6c04c28e --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/auto-page/auto-page.wxml @@ -0,0 +1 @@ +AUTO ORIENTATION PAGE diff --git a/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/auto-page/auto-page.wxss b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/auto-page/auto-page.wxss new file mode 100644 index 00000000..7d433da7 --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/auto-page/auto-page.wxss @@ -0,0 +1,3 @@ +.page-auto { + color: #1a1a1a; +} diff --git a/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/home/home.js b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/home/home.js new file mode 100644 index 00000000..9ee91829 --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/home/home.js @@ -0,0 +1,9 @@ +// Default (unconfigured) page — pageOrientation falls back to app.json's window (unset here too), so this page is fixed portrait. +Page({ + goLandscape() { + wx.navigateTo({ url: '/pages/landscape-page/landscape-page' }) + }, + goAuto() { + wx.navigateTo({ url: '/pages/auto-page/auto-page' }) + }, +}) diff --git a/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/home/home.json b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/home/home.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/home/home.json @@ -0,0 +1 @@ +{} diff --git a/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/home/home.wxml b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/home/home.wxml new file mode 100644 index 00000000..0f6f6219 --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/home/home.wxml @@ -0,0 +1,3 @@ +HOME PAGE + + diff --git a/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/home/home.wxss b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/home/home.wxss new file mode 100644 index 00000000..cf099f36 --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/home/home.wxss @@ -0,0 +1,3 @@ +.page-home { + color: #1a1a1a; +} diff --git a/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/landscape-page/landscape-page.js b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/landscape-page/landscape-page.js new file mode 100644 index 00000000..9094ad1c --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/landscape-page/landscape-page.js @@ -0,0 +1,14 @@ +// pageOrientation: 'landscape' — a FIXED-orientation page. +// A page whose computed orientation isn't 'auto' and has never called A fixed-orientation page must never receive Page.onResize, no matter how the simulated device rotates under it. resizeCount/lastResize record every onResize call so the e2e can assert that gate holds (or catch it firing). +Page({ + data: { + resizeCount: 0, + lastResize: null, + }, + onResize(res) { + this.setData({ + resizeCount: this.data.resizeCount + 1, + lastResize: res, + }) + }, +}) diff --git a/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/landscape-page/landscape-page.json b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/landscape-page/landscape-page.json new file mode 100644 index 00000000..70a2e795 --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/landscape-page/landscape-page.json @@ -0,0 +1,3 @@ +{ + "pageOrientation": "landscape" +} diff --git a/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/landscape-page/landscape-page.wxml b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/landscape-page/landscape-page.wxml new file mode 100644 index 00000000..a4d17b35 --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/landscape-page/landscape-page.wxml @@ -0,0 +1 @@ +LANDSCAPE PAGE diff --git a/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/landscape-page/landscape-page.wxss b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/landscape-page/landscape-page.wxss new file mode 100644 index 00000000..b7822cf1 --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/pages/landscape-page/landscape-page.wxss @@ -0,0 +1,3 @@ +.page-landscape { + color: #1a1a1a; +} diff --git a/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/project.config.json b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/project.config.json new file mode 100644 index 00000000..9640f72f --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/landscape-app/project.config.json @@ -0,0 +1,5 @@ +{ + "appid": "devtools_landscape_fixture", + "projectname": "devtools-landscape-fixture", + "description": "Fixture mini-app for e2e page-orientation (landscape) tests" +} diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/app.js b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/app.js new file mode 100644 index 00000000..367c9309 --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/app.js @@ -0,0 +1,10 @@ +// App.onLaunch runs before any page's onLoad — a synchronous wx.getSystemInfoSync() call here is the EARLIEST point the cold-start orientation seed (bridge-router.ts's resolvePageWindowConfig / resolvePageOrientationState) can be observed, before DeviceShell has even mounted to send its first PAGE_RESIZE. globalData carries the snapshot so the root page can fold it into its own page data for the e2e to read back through getPageData. +App({ + globalData: {}, + onLaunch() { + const info = wx.getSystemInfoSync() + this.globalData.onLaunchWindowWidth = info.windowWidth + this.globalData.onLaunchWindowHeight = info.windowHeight + }, +}) + diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/app.json b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/app.json new file mode 100644 index 00000000..c28349ff --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/app.json @@ -0,0 +1,14 @@ +{ + "pages": [ + "pages/entry/entry", + "pages/autopage/autopage", + "pages/portraitpage/portraitpage", + "pages/mid/mid" + ], + "window": { + "navigationBarTitleText": "Orientation App Landscape Fixture", + "navigationBarBackgroundColor": "#ffffff", + "navigationBarTextStyle": "black", + "pageOrientation": "landscape" + } +} diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/app.wxss b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/app.wxss new file mode 100644 index 00000000..b4683849 --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/app.wxss @@ -0,0 +1,13 @@ +page { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 28rpx; + color: #333; + background-color: #f5f5f5; +} + +.page-marker { + font-size: 40rpx; + font-weight: 700; + padding: 40rpx; + color: #1a1a1a; +} diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/autopage/autopage.js b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/autopage/autopage.js new file mode 100644 index 00000000..551ed7ac --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/autopage/autopage.js @@ -0,0 +1,13 @@ +// pageOrientation: 'auto' overrides the app-level 'landscape' and follows the simulated device's own orientation instead. resizeCount/lastResize record every Page.onResize call for the e2e to assert on. +Page({ + data: { + resizeCount: 0, + lastResize: null, + }, + onResize(res) { + this.setData({ + resizeCount: this.data.resizeCount + 1, + lastResize: res, + }) + }, +}) diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/autopage/autopage.json b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/autopage/autopage.json new file mode 100644 index 00000000..aa38c811 --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/autopage/autopage.json @@ -0,0 +1,3 @@ +{ + "pageOrientation": "auto" +} diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/autopage/autopage.wxml b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/autopage/autopage.wxml new file mode 100644 index 00000000..f9989895 --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/autopage/autopage.wxml @@ -0,0 +1 @@ +AUTO PAGE diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/autopage/autopage.wxss b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/autopage/autopage.wxss new file mode 100644 index 00000000..7d433da7 --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/autopage/autopage.wxss @@ -0,0 +1,3 @@ +.page-auto { + color: #1a1a1a; +} diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/entry/entry.js b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/entry/entry.js new file mode 100644 index 00000000..4ce5b8dc --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/entry/entry.js @@ -0,0 +1,29 @@ +// No page-level pageOrientation — resolves to app.json's window.pageOrientation ('landscape'), a FIXED orientation. resizeCount/lastResize record every Page.onResize call so the e2e can assert the fixed-orientation gate holds for a page that inherited its orientation rather than declaring it. +// +// onLoadWindowWidth/onLoadWindowHeight capture a SYNCHRONOUS wx.getSystemInfoSync() call made from onLoad itself — the earliest a page's own code can observe its geometry, before any PAGE_RESIZE from DeviceShell could have corrected a wrong cold-start seed. onLaunchWindowWidth/onLaunchWindowHeight fold in App.onLaunch's own (even earlier) synchronous reading via globalData, so both observation points the cold-start seed exists for are asserted on, not just one. +Page({ + data: { + resizeCount: 0, + lastResize: null, + onLoadWindowWidth: null, + onLoadWindowHeight: null, + onLaunchWindowWidth: null, + onLaunchWindowHeight: null, + }, + onLoad() { + const info = wx.getSystemInfoSync() + const app = getApp() + this.setData({ + onLoadWindowWidth: info.windowWidth, + onLoadWindowHeight: info.windowHeight, + onLaunchWindowWidth: app.globalData.onLaunchWindowWidth, + onLaunchWindowHeight: app.globalData.onLaunchWindowHeight, + }) + }, + onResize(res) { + this.setData({ + resizeCount: this.data.resizeCount + 1, + lastResize: res, + }) + }, +}) diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/entry/entry.json b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/entry/entry.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/entry/entry.json @@ -0,0 +1 @@ +{} diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/entry/entry.wxml b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/entry/entry.wxml new file mode 100644 index 00000000..65d216d3 --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/entry/entry.wxml @@ -0,0 +1 @@ +ENTRY PAGE diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/entry/entry.wxss b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/entry/entry.wxss new file mode 100644 index 00000000..b1d6b893 --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/entry/entry.wxss @@ -0,0 +1,3 @@ +.page-entry { + color: #1a1a1a; +} diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/mid/mid.js b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/mid/mid.js new file mode 100644 index 00000000..bd30f5fc --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/mid/mid.js @@ -0,0 +1,14 @@ +// No page-level pageOrientation — resolves to app.json's window.pageOrientation ('landscape'), a FIXED orientation. +// Used as a middle page of a three-deep stack so route tests can assert each layer resolves its own orientation independent of its neighbors. resizeCount/lastResize record every Page.onResize call for the e2e to assert on. +Page({ + data: { + resizeCount: 0, + lastResize: null, + }, + onResize(res) { + this.setData({ + resizeCount: this.data.resizeCount + 1, + lastResize: res, + }) + }, +}) diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/mid/mid.json b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/mid/mid.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/mid/mid.json @@ -0,0 +1 @@ +{} diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/mid/mid.wxml b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/mid/mid.wxml new file mode 100644 index 00000000..a88b5299 --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/mid/mid.wxml @@ -0,0 +1 @@ +MID PAGE diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/mid/mid.wxss b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/mid/mid.wxss new file mode 100644 index 00000000..12a21414 --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/mid/mid.wxss @@ -0,0 +1,3 @@ +.page-mid { + color: #1a1a1a; +} diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/portraitpage/portraitpage.js b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/portraitpage/portraitpage.js new file mode 100644 index 00000000..900925a4 --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/portraitpage/portraitpage.js @@ -0,0 +1,13 @@ +// pageOrientation: 'portrait' overrides the app-level 'landscape', a FIXED orientation independent of both the app default and the device. resizeCount/lastResize record every Page.onResize call for the e2e to assert on. +Page({ + data: { + resizeCount: 0, + lastResize: null, + }, + onResize(res) { + this.setData({ + resizeCount: this.data.resizeCount + 1, + lastResize: res, + }) + }, +}) diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/portraitpage/portraitpage.json b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/portraitpage/portraitpage.json new file mode 100644 index 00000000..3184d92b --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/portraitpage/portraitpage.json @@ -0,0 +1,3 @@ +{ + "pageOrientation": "portrait" +} diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/portraitpage/portraitpage.wxml b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/portraitpage/portraitpage.wxml new file mode 100644 index 00000000..81675a9a --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/portraitpage/portraitpage.wxml @@ -0,0 +1 @@ +PORTRAIT PAGE diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/portraitpage/portraitpage.wxss b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/portraitpage/portraitpage.wxss new file mode 100644 index 00000000..684cacc8 --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/pages/portraitpage/portraitpage.wxss @@ -0,0 +1,3 @@ +.page-portrait { + color: #1a1a1a; +} diff --git a/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/project.config.json b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/project.config.json new file mode 100644 index 00000000..a56c30cd --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/fixtures/orientation-app-landscape/project.config.json @@ -0,0 +1,5 @@ +{ + "appid": "devtools_orientation_app_landscape_fixture", + "projectname": "devtools-orientation-app-landscape-fixture", + "description": "Fixture mini-app for e2e app-level window.pageOrientation:landscape resolution and cold-start/exit-recovery tests" +} diff --git a/packages/dimina-electron-runtime/e2e/helpers.ts b/packages/dimina-electron-runtime/e2e/helpers.ts index eaa0e23c..e9827271 100644 --- a/packages/dimina-electron-runtime/e2e/helpers.ts +++ b/packages/dimina-electron-runtime/e2e/helpers.ts @@ -1,3 +1,4 @@ +import { expect } from '@playwright/test' import type { Page, ElectronApplication } from '@playwright/test' import fs from 'fs' import os from 'os' @@ -174,6 +175,7 @@ export async function getCurrentPage( * spec in isolation) the main-process CDP evaluate channel can occasionally * fail a single round-trip with a generic "Script failed to execute" error * that carries no further detail. + * That wording is Electron's fixed text for ANY renderer-side throw, so it is not by itself evidence of a load flake: a `wx.()` that throws inside the service host is reported here with its real message and stack, because `runNativeHostNav` in electron-entry.js returns the failure rather than letting it throw across that boundary. * * This does NOT retry: `method` here can be a NAV method (navigateTo/ * redirectTo/reLaunch/switchTab/navigateBack), and a retry from this side of @@ -201,6 +203,53 @@ export async function callWxMethod( }, { method, args, appId }) } +/** + * Routes held by the SERVICE host's own page stack (`getCurrentPages()`), for the session that owns `appId`. + * + * This is the readiness fact `callWxMethod(…, 'navigateTo' | 'redirectTo' | 'switchTab' | 'reLaunch')` depends on, and it is NOT the same fact `getCurrentPage` reports: that one reads `pagePath` off the render guest's URL, which is fixed when the guest is created — before the service host has booted its bundle and instantiated the root `Page`. + * Those route APIs resolve their `url` against `router.getPageInfo().route` in the service host, so calling one while this list is still empty makes the mini-app framework dereference an undefined base route and throw. + * + * Poll this to an entry containing the expected page before the first nav of a freshly opened (or reopened) session. + */ +export async function getServicePageRoutes( + electronApp: ElectronApplication, + appId?: string, +): Promise { + return electronApp.evaluate((_electron, appId) => { + const hooks = (globalThis as Record).__diminaE2eHooks as { + getServicePageRoutes: (appId?: string) => Promise + } + return hooks.getServicePageRoutes(appId) + }, appId) +} + +/** + * Block until the session that owns `appId` can actually be navigated. + * + * A freshly opened (or reopened) project reaches "the DeviceShell is mounted and a render guest exists" several hundred ms before the SERVICE host has booted its bundle and instantiated the root `Page`. + * Every route API resolves its `url` against `router.getPageInfo().route` in the service host, so a nav issued in that window makes the mini-app framework dereference an undefined base route and throw — a failure that only shows up under load, and lands on whichever spec happens to be running. + * + * Call this after opening a project and before the first nav of that session. + */ +export async function waitForServicePageReady( + electronApp: ElectronApplication, + appId?: string, + timeoutMs = 20000, +): Promise { + const routes = await pollUntil( + () => getServicePageRoutes(electronApp, appId).catch(() => [] as string[]), + (r) => r.some((route) => route.includes('pages/')), + timeoutMs, + 250, + ) + // `pollUntil` returns its last attempt on timeout rather than throwing, so assert here: a session whose service host never instantiates a page must fail on that fact, not further downstream on the nav it breaks. + expect( + routes.some((route) => route.includes('pages/')), + `the service host must hold a page before any nav call; saw ${JSON.stringify(routes)}`, + ).toBe(true) + return routes +} + export async function getPageData( electronApp: ElectronApplication, appId: string, diff --git a/packages/dimina-electron-runtime/e2e/native-host-audio.spec.ts b/packages/dimina-electron-runtime/e2e/native-host-audio.spec.ts index f08760af..25528bc6 100644 --- a/packages/dimina-electron-runtime/e2e/native-host-audio.spec.ts +++ b/packages/dimina-electron-runtime/e2e/native-host-audio.spec.ts @@ -72,6 +72,7 @@ import { getPageData, callWxMethod, RENDER_GUEST_URL_MARKER, + waitForServicePageReady, } from './helpers' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -159,6 +160,9 @@ test.describe('native-host audio event bridge e2e', () => { 25000, 300, ) + // The route APIs resolve against the SERVICE host's own page stack, which is still empty for a few hundred ms after the render guest mounts. + // Navigating inside that window throws in the mini-app framework. + await waitForServicePageReady(electronApp) }) test.afterAll(async () => { diff --git a/packages/dimina-electron-runtime/e2e/native-host-navigate-data.spec.ts b/packages/dimina-electron-runtime/e2e/native-host-navigate-data.spec.ts index 34454a10..be159ed1 100644 --- a/packages/dimina-electron-runtime/e2e/native-host-navigate-data.spec.ts +++ b/packages/dimina-electron-runtime/e2e/native-host-navigate-data.spec.ts @@ -48,6 +48,7 @@ import { getCurrentPage, getPageData, callWxMethod, + waitForServicePageReady, } from './helpers' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -119,6 +120,9 @@ test.describe('native-host navigateTo target page gets a mounted service instanc 25000, 300, ) + // The route APIs resolve against the SERVICE host's own page stack, which is still empty for a few hundred ms after the render guest mounts. + // Navigating inside that window throws in the mini-app framework. + await waitForServicePageReady(electronApp) }) test.afterAll(async () => { diff --git a/packages/dimina-electron-runtime/e2e/native-host-orientation-app-config.spec.ts b/packages/dimina-electron-runtime/e2e/native-host-orientation-app-config.spec.ts new file mode 100644 index 00000000..49793d93 --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/native-host-orientation-app-config.spec.ts @@ -0,0 +1,325 @@ +/** + * E2E (native-host only): app-level `window.pageOrientation` resolution and its interaction with the cold-start seed and exit/reopen recovery. + * + * Contract pinned here (see docs/landscape-support.md and bridge-router.ts's `resolvePageWindowConfig` / cold-start seed): + * + * 1. A page with no `pageOrientation` of its own inherits app.json's + * `window.pageOrientation`. + * When that app-level value is a fixed orientation, the ROOT page must already report that orientation on its very first frame — the cold-start seed uses the root page's EFFECTIVE orientation, not the raw device orientation, because DeviceShell only sends its first authoritative PAGE_RESIZE after the spawn already resolved. + * This is asserted from a SYNCHRONOUS `wx.getSystemInfoSync()` call the fixture itself makes from App.onLaunch and the entry page's own onLoad (see fixtures/orientation-app-landscape/app.js and pages/entry/entry.js) — polling the geometry via a later snapshot would pass even if the seed were broken and the page only turned landscape after a subsequent PAGE_RESIZE corrected it. + * 2. A page-level `pageOrientation` overrides the app-level one, in both + * directions: a page-level 'auto' escapes an app-level fixed orientation and follows the device again; a page-level fixed value overrides an app-level fixed value with its own. + * 3. The simulated device's own orientation is never written back to by a + * mini-app's forced orientation, at the APP level exactly as at the page level: closing the session and reopening it must show the device's real, untouched orientation. + * Because the entry page here is itself fixed by app-level config, the exit-recovery assertion routes through an 'auto' page instead of the entry page — only an 'auto' page can reveal what the device orientation actually is. + * + * Fixtures: + * - e2e/fixtures/orientation-app-landscape — app.json's window carries + * `pageOrientation: "landscape"`. pages/entry (no page-level config, inherits the app's landscape), pages/autopage ('auto', escapes the app-level landscape and follows the device), pages/portraitpage ('portrait', overrides the app-level landscape). + * - e2e/fixtures/landscape-app — a wholly UNCONFIGURED app (no + * window.pageOrientation) whose home page also carries no page-level config, so both levels resolve to the DEFAULT_PAGE_ORIENTATION fallback ('portrait'). + * Reused as-is (not modified) to cover the all-default corner of the config matrix combined with exit-recovery. + * + * Driving mechanism: `__diminaE2eHooks.rotateDevice()`, not `setDevice()` — see native-host-orientation-config.spec.ts's module doc comment for why `setDevice`'s raw host-env push cannot stand in for a real rotation. `wx.navigateTo`/`navigateBack` go through `callWxMethod`, matching native-host-orientation-config.spec.ts; no fixture button taps are needed. + * + * Geometry is read from the SERVICE host's own `wx.getSystemInfoSync()` (`readServiceSystemInfo`) — the authoritative channel the mini-app's own code observes, exactly as the sibling orientation specs do. + */ +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 { + openProject, + waitForSimulatorWebview, + closeProject, + pollUntil, + evalInSimulator, + getCurrentPage, + getPageData, + waitForServicePageReady, + callWxMethod, +} from './helpers' +import type { NativeDeviceInfo } from '@dimina-kit/electron-runtime' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const APP_LANDSCAPE_DIR = path.resolve(__dirname, 'fixtures', 'orientation-app-landscape') +const DEFAULT_APP_DIR = path.resolve(__dirname, 'fixtures', 'landscape-app') + +const ENTRY_ROUTE = 'pages/entry/entry' +const AUTO_ROUTE = 'pages/autopage/autopage' +const PORTRAIT_ROUTE = 'pages/portraitpage/portraitpage' +const DEFAULT_AUTO_ROUTE = 'pages/auto-page/auto-page' + +let electronApp: ElectronApplication +let mainWindow: PwPage + +// ── Device rotation (see the module doc comment for why this goes through rotateDevice, not setDevice) ───────────────────────────────────────── + +function device(orientation: 'portrait' | 'landscape'): NativeDeviceInfo { + return { + brand: 'Apple', + model: 'iPhone SE', + system: 'iOS 15.0', + platform: 'ios', + pixelRatio: 2, + screenWidth: 375, + screenHeight: 667, + statusBarHeight: 20, + notchType: 'none', + safeAreaInsets: { top: 20, right: 0, bottom: 0, left: 0 }, + deviceOrientation: orientation, + } +} + +async function rotateDevice(app: ElectronApplication, orientation: 'portrait' | 'landscape'): Promise { + await app.evaluate((_electron, d) => { + const hooks = (globalThis as Record).__diminaE2eHooks as { rotateDevice: (device: unknown) => void } + hooks.rotateDevice(d) + }, device(orientation)) +} + +// ── Geometry (service-host wx.getSystemInfoSync()) ──────────────────── + +interface ReportedInfo { + windowWidth?: number + windowHeight?: number +} + +async function readServiceSystemInfo(app: ElectronApplication): Promise { + return pollUntil( + () => app.evaluate(async ({ webContents }) => { + const svc = webContents.getAllWebContents().find( + (wc) => !wc.isDestroyed() && wc.getURL().includes('/service-host/service.html'), + ) + if (!svc) throw new Error('service.html not found') + return svc.executeJavaScript(`(() => { + const w = globalThis.wx + if (!w || typeof w.getSystemInfoSync !== 'function') throw new Error('wx.getSystemInfoSync missing') + const i = w.getSystemInfoSync() + return { windowWidth: i.windowWidth, windowHeight: i.windowHeight } + })()`) + }).catch(() => ({}) as ReportedInfo), + (info) => + typeof info.windowWidth === 'number' && Number.isFinite(info.windowWidth) + && typeof info.windowHeight === 'number' && Number.isFinite(info.windowHeight), + 20_000, + 400, + ) +} + +/** Poll until the reported viewport matches `expected` — orientation changes + * are asynchronous, so a single snapshot right after a triggering action can race a not-yet-landed update. */ +async function waitForOrientation(app: ElectronApplication, expected: 'portrait' | 'landscape'): Promise { + return pollUntil( + () => readServiceSystemInfo(app), + (info) => { + const w = info.windowWidth ?? -1 + const h = info.windowHeight ?? -1 + return expected === 'landscape' ? w > h : w < h + }, + 15_000, + 400, + ) +} + +async function waitForRoute(app: ElectronApplication, appId: string, route: string): Promise { + await pollUntil( + () => getCurrentPage(app, appId).catch(() => null), + (r) => !!r && typeof r.path === 'string' && r.path.includes(route), + 15_000, + 500, + ) +} + +// ── Cold-start seed snapshot (fixtures/orientation-app-landscape's entry page) ── + +interface EntryLoadSnapshot { + onLoadWindowWidth?: number + onLoadWindowHeight?: number + onLaunchWindowWidth?: number + onLaunchWindowHeight?: number +} + +async function getEntryLoadData(app: ElectronApplication, appId: string): Promise { + const data = await getPageData(app, appId) + return (data && typeof data === 'object') ? (data as EntryLoadSnapshot) : {} +} + +/** + * Poll only until the entry page's onLoad/onLaunch snapshot has been WRITTEN — those fields are each a synchronous read taken exactly once (App.onLaunch, Page.onLoad) and never change again afterwards, so polling their VALUE the way `waitForOrientation` does would defeat the point: a broken cold-start seed that only self-corrects after a later PAGE_RESIZE would still eventually satisfy a value-based poll. + * Only the snapshot's ARRIVAL is legitimately async (the page needs to instantiate first); the comparison itself must run on the one value that was captured. + */ +async function waitForEntryLoadSnapshot(app: ElectronApplication, appId: string): Promise { + return pollUntil( + () => getEntryLoadData(app, appId), + (d) => typeof d.onLoadWindowWidth === 'number' && typeof d.onLaunchWindowWidth === 'number', + 15_000, + 300, + ) +} + +test.describe('native-host app-level window.pageOrientation resolution + exit-recovery e2e', () => { + test.describe.configure({ mode: 'serial' }) + test.setTimeout(240_000) + + test.beforeAll(async () => { + test.setTimeout(180_000) + const appPath = path.resolve(__dirname, 'electron-entry.js') + const userDataDir = path.resolve( + process.env.DIMINA_DEVTOOLS_DATA_DIR + ?? path.resolve(__dirname, '..', 'node_modules', '.cache', 'electron-runtime-e2e'), + 'userdata', + `nh-orientation-app-config-${process.pid}`, + ) + fs.mkdirSync(userDataDir, { recursive: true }) + + electronApp = await _electron.launch({ + args: [appPath, `--user-data-dir=${userDataDir}`], + env: { ...process.env, NODE_ENV: 'test', DIMINA_E2E_USER_DATA_DIR: userDataDir }, + }) + + mainWindow = await electronApp.firstWindow() + 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() + } + }) + }) + + test.afterEach(async () => { + await closeProject(electronApp).catch(() => {}) + }) + + test.afterAll(async () => { + await closeProject(electronApp).catch(() => {}) + await electronApp?.close().catch(() => {}) + }) + + async function openFixtureAndWait(dir: string): Promise { + const { appId } = await openProject(electronApp, dir) + await waitForSimulatorWebview(electronApp) + await pollUntil( + () => evalInSimulator( + electronApp, + `(() => !!document.querySelector('.device-shell-root'))()`, + ).catch(() => false), + (ok) => ok === true, + 25_000, + 300, + ) + await pollUntil( + () => getCurrentPage(electronApp, appId).catch(() => null), + (r) => !!r && typeof r.path === 'string' && r.path.includes('pages/'), + 20_000, + 500, + ) + await waitForServicePageReady(electronApp, appId) + return appId + } + + /** Set the device before opening (persists into the session spawn), open, then re-sync the now-live session. */ + async function openFixture(dir: string, initialOrientation: 'portrait' | 'landscape'): Promise { + await rotateDevice(electronApp, initialOrientation) + const appId = await openFixtureAndWait(dir) + await rotateDevice(electronApp, initialOrientation) + await new Promise((r) => setTimeout(r, 1000)) + return appId + } + + test('portrait device + app-level landscape: entry page renders landscape on the very first frame; exit and reopen leave the device portrait', async () => { + const appId = await openFixture(APP_LANDSCAPE_DIR, 'portrait') + + // No polling on the VALUE here — see waitForEntryLoadSnapshot's doc comment. + // Both a page's own onLoad and App.onLaunch are synchronous calls that ran once, before this test could have observed any later PAGE_RESIZE correction. + const snapshot = await waitForEntryLoadSnapshot(electronApp, appId) + expect( + snapshot.onLoadWindowWidth!, + `entry's own synchronous wx.getSystemInfoSync() call in onLoad must already read landscape — the cold-start seed exists precisely so this call does not need a later PAGE_RESIZE to correct it (got ${snapshot.onLoadWindowWidth}x${snapshot.onLoadWindowHeight})`, + ).toBeGreaterThan(snapshot.onLoadWindowHeight!) + expect( + snapshot.onLaunchWindowWidth!, + `App.onLaunch's own (even earlier) synchronous read must also already be landscape (got ${snapshot.onLaunchWindowWidth}x${snapshot.onLaunchWindowHeight})`, + ).toBeGreaterThan(snapshot.onLaunchWindowHeight!) + + await closeProject(electronApp) + + // Reopen WITHOUT touching the device — the entry page's forced landscape must not have overwritten the device's real (portrait) orientation. + const reopenedAppId = await openFixtureAndWait(APP_LANDSCAPE_DIR) + await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + AUTO_ROUTE }]) + await waitForRoute(electronApp, reopenedAppId, AUTO_ROUTE) + const afterReopen = await waitForOrientation(electronApp, 'portrait') + expect( + afterReopen.windowHeight!, + 'the auto page must show the device is still portrait after exit — the entry page\'s forced landscape must not have been written back', + ).toBeGreaterThan(afterReopen.windowWidth!) + }) + + test('landscape device + app-level landscape: entry page renders landscape', async () => { + await openFixture(APP_LANDSCAPE_DIR, 'landscape') + const info = await waitForOrientation(electronApp, 'landscape') + expect( + info.windowWidth!, + `device and app-level config agree on landscape (got ${info.windowWidth}x${info.windowHeight})`, + ).toBeGreaterThan(info.windowHeight!) + }) + + test('landscape device + a wholly unconfigured app: home page renders portrait (the default fallback); exit and reopen leave the device landscape', async () => { + await openFixture(DEFAULT_APP_DIR, 'landscape') + const info = await waitForOrientation(electronApp, 'portrait') + expect( + info.windowHeight!, + `an app with no window.pageOrientation and a page with no pageOrientation of its own must resolve to the 'portrait' default, even under a landscape device (got ${info.windowWidth}x${info.windowHeight})`, + ).toBeGreaterThan(info.windowWidth!) + + await closeProject(electronApp) + + const reopenedAppId = await openFixtureAndWait(DEFAULT_APP_DIR) + await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + DEFAULT_AUTO_ROUTE }]) + await waitForRoute(electronApp, reopenedAppId, DEFAULT_AUTO_ROUTE) + const afterReopen = await waitForOrientation(electronApp, 'landscape') + expect( + afterReopen.windowWidth!, + 'the auto page must show the device is still landscape after exit — the home page\'s default-resolved portrait must not have been written back', + ).toBeGreaterThan(afterReopen.windowHeight!) + }) + + test('app-level landscape + page-level auto: navigating to the auto page under a portrait device shows portrait, escaping the app-level landscape', async () => { + const appId = await openFixture(APP_LANDSCAPE_DIR, 'portrait') + await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + AUTO_ROUTE }]) + await waitForRoute(electronApp, appId, AUTO_ROUTE) + const info = await waitForOrientation(electronApp, 'portrait') + expect( + info.windowHeight!, + `pageOrientation:'auto' must override the app-level 'landscape' and follow the (portrait) device (got ${info.windowWidth}x${info.windowHeight})`, + ).toBeGreaterThan(info.windowWidth!) + }) + + test('app-level landscape + page-level portrait: navigateTo flips portrait; navigateBack restores the entry page\'s own (app-inherited) landscape', async () => { + const appId = await openFixture(APP_LANDSCAPE_DIR, 'portrait') + await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + PORTRAIT_ROUTE }]) + await waitForRoute(electronApp, appId, PORTRAIT_ROUTE) + const during = await waitForOrientation(electronApp, 'portrait') + expect( + during.windowHeight!, + `pageOrientation:'portrait' must override the app-level 'landscape' (got ${during.windowWidth}x${during.windowHeight})`, + ).toBeGreaterThan(during.windowWidth!) + + await callWxMethod(electronApp, 'navigateBack', [{ delta: 1 }]) + await waitForRoute(electronApp, appId, ENTRY_ROUTE) + const after = await waitForOrientation(electronApp, 'landscape') + expect( + after.windowWidth!, + 'navigateBack must restore the entry page\'s own orientation (the app-inherited landscape), not stay stuck on the portrait page it left', + ).toBeGreaterThan(after.windowHeight!) + }) +}) diff --git a/packages/dimina-electron-runtime/e2e/native-host-orientation-config.spec.ts b/packages/dimina-electron-runtime/e2e/native-host-orientation-config.spec.ts new file mode 100644 index 00000000..70642a7c --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/native-host-orientation-config.spec.ts @@ -0,0 +1,321 @@ +/** + * E2E (native-host only): config-driven page orientation. `pageOrientation` on a page's own json (falling back to app.json's `window`) picks the page's fixed orientation ('portrait' default, 'landscape') or lets it follow the simulated device ('auto'). + * + * Contract pinned here (plus route linkage — the subset this file was scoped to cover): + * + * 1. A `pageOrientation: 'landscape'` page reports windowWidth > + * windowHeight via `wx.getSystemInfoSync()` — the AUTHORITATIVE channel the mini-app's own code reads (see readServiceSystemInfo below) — and NEVER fires `Page.onResize` while the underlying device rotates under it (fixed orientation => no dispatch). + * 2. A `pageOrientation: 'auto'` page follows the device: rotating it + * fires exactly one `Page.onResize` with `{ size: { windowWidth, windowHeight }, deviceOrientation }`, and the reported dims are landscape. + * 3. Device-shell chrome: the status bar (`.device-statusbar`, see + * status-bar.tsx) is NOT RENDERED at all in landscape — device-shell.tsx gates it with `!embedded && statusBarHeight > 0 && `, so the DOM node itself is absent, not merely collapsed to zero height; the nav bar (`.nav-bar`, see navigation-bar.tsx) stays mounted and visible. + * 4. Route linkage: `navigateTo` into a fixed-landscape page flips the + * screen to landscape; `navigateBack` restores the orientation the previous (portrait) page had. + * 5. Second-interaction regression (repo CLAUDE.md "操作后的二次交互"): + * two CONSECUTIVE device rotations on an auto page each fire their own `onResize` (not swallowed/coalesced across rotations); entering the fixed-landscape page, going back, then entering it again reaches the correct orientation BOTH times. + * + * Observation channels: + * - GEOMETRY: `wx.getSystemInfoSync()` inside the service-host window + * (service.html) — the same channel native-host-device.spec.ts uses. + * It is the actual `wx` the mini-app's own code calls, so it is the most authoritative read available; it also carries `deviceOrientation` alongside window dims in one round trip. (The alternative DOM-measure channel — evalInSimulator against the device-shell ``'s own rect — was NOT used for geometry assertions: it measures the HOST's layout of the render-guest container, one layer further from what the mini-app's own JS actually observes, and this harness's session never resizes its WebContentsView bounds in response to orientation anyway — see native-host-device.spec.ts's doc comment on the same tradeoff. + * DOM measurement IS used below for the chrome checks (status bar / nav bar), where there is no `wx` API equivalent to read.) + * - EVENTS: `Page.onResize` — the fixture pages + * (fixtures/landscape-app/pages/{landscape-page,auto-page}) record call count + last argument into `data`, read back through `getPageData` (the same App-data-tap mechanism native-host-navigate-data.spec.ts uses for page data assertions). + * - CHROME: `evalInSimulator` DOM queries against the device-shell's own + * `.device-statusbar` / `.nav-bar` elements. + * + * Rotation-driving mechanism: `hooks.rotateDevice()`, added to electron-entry.js alongside the pre-existing `setDevice` hook. + * It broadcasts DEVICE_CHANGE to the mounted DeviceShell(s) ONLY, deliberately skipping the raw `service-host:host-env:update` push `setDevice` also does — see rotateDeviceHook's doc comment in electron-entry.js for why: that push is orientation-unaware and never gates through DeviceShell's own dispatch logic, so it cannot stand in for a real rotation in tests that assert onResize dispatch/gating. + */ +import { test, expect, useSharedProject } from './fixtures' +import path from 'path' +import { fileURLToPath } from 'url' +import { + evalInSimulator, + pollUntil, + callWxMethod, + getCurrentPage, + getPageData, + waitForServicePageReady, +} from './helpers' +import type { ElectronApplication } from '@playwright/test' +import type { NativeDeviceInfo } from '@dimina-kit/electron-runtime' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const FIXTURE_DIR = path.resolve(__dirname, 'fixtures', 'landscape-app') +const APP_ID = 'devtools_landscape_fixture' // fixtures/landscape-app/project.config.json appid + +const HOME_ROUTE = 'pages/home/home' +const LANDSCAPE_ROUTE = 'pages/landscape-page/landscape-page' +const AUTO_ROUTE = 'pages/auto-page/auto-page' + +// ── Device rotation (see the module doc comment for why this bypasses `setDevice`'s raw host-env push and only exercises the DEVICE_CHANGE -> DeviceShell wire) ── + +/** + * screenWidth/screenHeight stay PORTRAIT-baseline (see NativeDeviceInfo's own doc comment) — only `deviceOrientation` changes between calls, mirroring exactly what a real rotate action changes on the device state. + */ +function device(orientation: 'portrait' | 'landscape'): NativeDeviceInfo { + return { + brand: 'Apple', + model: 'iPhone 14 Pro', + system: 'iOS 16.3', + platform: 'ios', + pixelRatio: 3, + screenWidth: 393, + screenHeight: 852, + statusBarHeight: 54, + notchType: 'dynamic-island', + safeAreaInsets: { top: 54, right: 0, bottom: 34, left: 0 }, + deviceOrientation: orientation, + } +} + +async function rotateDevice(app: ElectronApplication, orientation: 'portrait' | 'landscape'): Promise { + await app.evaluate((_electron, d) => { + const hooks = (globalThis as Record).__diminaE2eHooks as { rotateDevice: (device: unknown) => void } + hooks.rotateDevice(d) + }, device(orientation)) +} + +// ── Geometry (service-host wx.getSystemInfoSync(), see the module doc comment) ── + +interface ReportedInfo { + windowWidth?: number + windowHeight?: number + deviceOrientation?: string +} + +async function readServiceSystemInfo(app: ElectronApplication): Promise { + return pollUntil( + () => app.evaluate(async ({ webContents }) => { + const svc = webContents.getAllWebContents().find( + (wc) => !wc.isDestroyed() && wc.getURL().includes('/service-host/service.html'), + ) + if (!svc) throw new Error('service.html not found') + return svc.executeJavaScript(`(() => { + const w = globalThis.wx + if (!w || typeof w.getSystemInfoSync !== 'function') throw new Error('wx.getSystemInfoSync missing') + const i = w.getSystemInfoSync() + return { windowWidth: i.windowWidth, windowHeight: i.windowHeight, deviceOrientation: i.deviceOrientation } + })()`) + }).catch(() => ({}) as ReportedInfo), + (info) => + typeof info.windowWidth === 'number' && Number.isFinite(info.windowWidth) + && typeof info.windowHeight === 'number' && Number.isFinite(info.windowHeight), + 20_000, + 400, + ) +} + +// ── Page data (onResize count/payload, see fixtures/landscape-app pages) ── + +async function getData(app: ElectronApplication): Promise> { + const data = await getPageData(app, APP_ID) + return (data && typeof data === 'object') ? (data as Record) : {} +} + +async function waitForRoute(app: ElectronApplication, route: string): Promise { + await pollUntil( + () => getCurrentPage(app, APP_ID).catch(() => null), + (r) => !!r && typeof r.path === 'string' && r.path.includes(route), + 15_000, + 500, + ) +} + +// ── Chrome (device-shell status bar / nav bar DOM, see status-bar.tsx / navigation-bar.tsx) ── + +interface ChromeProbe { + present: boolean + height: number +} + +async function probeStatusBar(app: ElectronApplication): Promise { + return evalInSimulator(app, `(() => { + const el = document.querySelector('.device-statusbar') + if (!el) return { present: false, height: 0 } + return { present: true, height: el.getBoundingClientRect().height } + })()`) +} + +async function probeNavBar(app: ElectronApplication): Promise { + return evalInSimulator(app, `(() => { + const el = document.querySelector('.nav-bar') + if (!el) return { present: false, height: 0 } + return { present: true, height: el.getBoundingClientRect().height } + })()`) +} + +test.describe('native-host config-driven page orientation', () => { + test.describe.configure({ mode: 'serial' }) + test.setTimeout(180_000) + + useSharedProject(test, FIXTURE_DIR) + + // DeviceShell must be MOUNTED (its DEVICE_CHANGE listener subscribed) before anything below is meaningful — a rotation broadcast before mount is simply missed (no catch-up/replay for DEVICE_CHANGE). + // This has to run in `beforeAll` — BEFORE `beforeEach` below ever rotates the device — because Playwright always finishes every `beforeAll` before the first `beforeEach` of the first test, whereas a wait placed inside test 1's own body would run AFTER that first `beforeEach` already fired. + test.beforeAll(async ({ _workerElectron }) => { + await pollUntil( + () => evalInSimulator( + _workerElectron.app, + `(() => document.querySelectorAll('.device-shell__webview').length)()`, + ).catch(() => 0), + (n) => n >= 1, + 25_000, + 300, + ) + // The route APIs resolve against the SERVICE host's own page stack, which is still empty for a few hundred ms after the render guest mounts. + // Navigating inside that window throws in the mini-app framework. + await waitForServicePageReady(_workerElectron.app) + }) + + // Device orientation is NOT part of useSharedProject's afterEach reset (that only unwinds the page stack + clears storage), so pin a known baseline before every test regardless of what the previous test rotated to. + test.beforeEach(async ({ electronApp }) => { + await rotateDevice(electronApp, 'portrait') + }) + + test('landscape-orientation page reports a landscape viewport and never fires onResize on device rotation', async ({ electronApp }) => { + await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + LANDSCAPE_ROUTE }]) + await waitForRoute(electronApp, LANDSCAPE_ROUTE) + + const info = await readServiceSystemInfo(electronApp) + expect( + info.windowWidth!, + `fixed-landscape page should report windowWidth > windowHeight (got ${info.windowWidth}x${info.windowHeight})`, + ).toBeGreaterThan(info.windowHeight!) + + // Rotate the underlying device twice (both directions) — a fixed page must never dispatch onResize regardless of device rotation. + await rotateDevice(electronApp, 'landscape') + await new Promise((r) => setTimeout(r, 1000)) + await rotateDevice(electronApp, 'portrait') + await new Promise((r) => setTimeout(r, 1000)) + + const data = await getData(electronApp) + expect( + data.resizeCount ?? 0, + 'fixed-orientation page must never receive Page.onResize, even across two device rotations', + ).toBe(0) + + await callWxMethod(electronApp, 'navigateBack', [{ delta: 1 }]) + await waitForRoute(electronApp, HOME_ROUTE) + }) + + test('auto-orientation page follows device rotation and fires exactly one onResize', async ({ electronApp }) => { + await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + AUTO_ROUTE }]) + await waitForRoute(electronApp, AUTO_ROUTE) + + const before = await getData(electronApp) + expect(before.resizeCount ?? 0, 'auto page should have received no resize calls before any rotation').toBe(0) + + await rotateDevice(electronApp, 'landscape') + + const after = await pollUntil( + () => getData(electronApp), + (d) => Number(d.resizeCount ?? 0) > 0, + 15_000, + 500, + ) + expect(after.resizeCount, 'one rotation should dispatch exactly one onResize (coalesced, not doubled)').toBe(1) + + const last = after.lastResize as { size?: { windowWidth?: number; windowHeight?: number }; deviceOrientation?: string } | null + expect(last?.deviceOrientation, 'onResize payload should report the new device orientation').toBe('landscape') + expect( + last?.size?.windowWidth, + `onResize size should be landscape (got ${JSON.stringify(last?.size)})`, + ).toBeGreaterThan(last?.size?.windowHeight ?? Number.POSITIVE_INFINITY) + + await callWxMethod(electronApp, 'navigateBack', [{ delta: 1 }]) + await waitForRoute(electronApp, HOME_ROUTE) + }) + + test('landscape hides the status bar chrome but keeps the nav bar', async ({ electronApp }) => { + await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + LANDSCAPE_ROUTE }]) + await waitForRoute(electronApp, LANDSCAPE_ROUTE) + + const statusBar = await probeStatusBar(electronApp) + // device-shell.tsx renders `` behind `statusBarHeight > 0` — in landscape (phone) that's false, so the `.device-statusbar` node is entirely absent, not merely collapsed to height 0. + expect( + statusBar.present, + `phone landscape must not render the status bar node at all — got present=${statusBar.present} height=${statusBar.height}`, + ).toBe(false) + + const navBar = await probeNavBar(electronApp) + expect(navBar.present, 'nav bar must stay mounted in landscape (nav bar height does not change with orientation)').toBe(true) + expect(navBar.height, 'nav bar must keep a non-zero rendered height in landscape').toBeGreaterThan(0) + + await callWxMethod(electronApp, 'navigateBack', [{ delta: 1 }]) + await waitForRoute(electronApp, HOME_ROUTE) + }) + + test('navigateTo a landscape page flips the screen; navigateBack restores portrait', async ({ electronApp }) => { + const beforeInfo = await readServiceSystemInfo(electronApp) + expect( + beforeInfo.windowHeight!, + `home page (default portrait) should start taller than wide (got ${beforeInfo.windowWidth}x${beforeInfo.windowHeight})`, + ).toBeGreaterThan(beforeInfo.windowWidth!) + + await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + LANDSCAPE_ROUTE }]) + await waitForRoute(electronApp, LANDSCAPE_ROUTE) + const duringInfo = await readServiceSystemInfo(electronApp) + expect(duringInfo.windowWidth!, 'navigateTo a fixed-landscape page should flip the screen to landscape').toBeGreaterThan(duringInfo.windowHeight!) + + await callWxMethod(electronApp, 'navigateBack', [{ delta: 1 }]) + await waitForRoute(electronApp, HOME_ROUTE) + const afterInfo = await readServiceSystemInfo(electronApp) + expect( + afterInfo.windowHeight!, + 'navigateBack should restore the portrait viewport the entry page had before routing away', + ).toBeGreaterThan(afterInfo.windowWidth!) + }) + + test('two consecutive rotations on an auto page each fire their own onResize', async ({ electronApp }) => { + await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + AUTO_ROUTE }]) + await waitForRoute(electronApp, AUTO_ROUTE) + + await rotateDevice(electronApp, 'landscape') + const first = await pollUntil( + () => getData(electronApp), + (d) => Number(d.resizeCount ?? 0) >= 1, + 15_000, + 500, + ) + expect(first.resizeCount, 'first rotation should fire the first onResize').toBe(1) + + await rotateDevice(electronApp, 'portrait') + const second = await pollUntil( + () => getData(electronApp), + (d) => Number(d.resizeCount ?? 0) >= 2, + 15_000, + 500, + ) + expect(second.resizeCount, 'a second, consecutive rotation must fire a second onResize — not be swallowed or coalesced across rotations').toBe(2) + const last = second.lastResize as { deviceOrientation?: string } | null + expect(last?.deviceOrientation, 'the second onResize payload should report the second rotation\'s orientation').toBe('portrait') + + await callWxMethod(electronApp, 'navigateBack', [{ delta: 1 }]) + await waitForRoute(electronApp, HOME_ROUTE) + }) + + test('enter the landscape page, go back, and enter it again — geometry is correct both times', async ({ electronApp }) => { + await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + LANDSCAPE_ROUTE }]) + await waitForRoute(electronApp, LANDSCAPE_ROUTE) + const first = await readServiceSystemInfo(electronApp) + expect(first.windowWidth!, 'first entry into the landscape page should flip landscape').toBeGreaterThan(first.windowHeight!) + + await callWxMethod(electronApp, 'navigateBack', [{ delta: 1 }]) + await waitForRoute(electronApp, HOME_ROUTE) + const backHome = await readServiceSystemInfo(electronApp) + expect(backHome.windowHeight!, 'back on the entry page should restore portrait').toBeGreaterThan(backHome.windowWidth!) + + await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + LANDSCAPE_ROUTE }]) + await waitForRoute(electronApp, LANDSCAPE_ROUTE) + const second = await readServiceSystemInfo(electronApp) + expect( + second.windowWidth!, + 're-entering the landscape page a second time should flip landscape again (not get stuck on the first exit\'s restored portrait)', + ).toBeGreaterThan(second.windowHeight!) + + await callWxMethod(electronApp, 'navigateBack', [{ delta: 1 }]) + await waitForRoute(electronApp, HOME_ROUTE) + }) +}) diff --git a/packages/dimina-electron-runtime/e2e/native-host-orientation-stack.spec.ts b/packages/dimina-electron-runtime/e2e/native-host-orientation-stack.spec.ts new file mode 100644 index 00000000..894ee5df --- /dev/null +++ b/packages/dimina-electron-runtime/e2e/native-host-orientation-stack.spec.ts @@ -0,0 +1,299 @@ +/** + * E2E (native-host only): page-stack orientation resolution across route entries other than plain navigateTo/navigateBack(1) (already covered by native-host-orientation-config.spec.ts), device rotation while a fixed-orientation page is on screen, and rapid re-entrant navigation. + * + * Contract pinned here (see shared/page-orientation.ts and DeviceShell's OrientationController, which is the single authority every route entry funnels through): + * + * 1. Each page keeps its OWN `PageOrientationState` for as long as it stays + * mounted (visible or cached under a hidden stack entry). + * Backgrounding a page never touches its state; bringing it back to the foreground — through navigateBack at ANY delta, redirectTo, or reLaunch — recomputes its effective orientation fresh from that page's own config, never from whatever page was in between. + * 2. `redirectTo`/`reLaunch` replace page-stack entries outright: the + * pages they discard are gone, along with any orientation they were showing — there is nothing left to "restore" back to. + * 3. An 'auto' page's effective orientation is a live function of the + * CURRENT device orientation, recomputed on every visit — including a re-visit via navigateBack past a fixed-orientation page that forced a different orientation while it was on top. "Restore what a page showed on the way in" is a fixed-orientation-only illusion; an 'auto' page never has a fixed value to restore to. + * 4. Back-to-back route calls (navigateTo immediately followed by + * navigateBack, issued before the entered page's own orientation/ geometry round trip necessarily lands — see the last test's own doc comment for what that leaves in flight) must still leave the page stack and the displayed orientation in a consistent end state — not stuck on whichever page's geometry happened to be mid-transition. + * + * Fixture: e2e/fixtures/orientation-app-landscape — app.json's window is 'landscape'. pages/entry (root, inherits landscape), pages/autopage ('auto', follows the device), pages/portraitpage ('portrait', fixed), pages/mid (no page-level config, inherits landscape same as entry — used as the middle layer of a three-deep stack so a page under app-level inheritance, not just an explicit config, is also exercised mid-stack). + * + * Driving mechanism: `__diminaE2eHooks.rotateDevice()`, not `setDevice()` — see native-host-orientation-config.spec.ts's module doc comment. + * Geometry is read from the SERVICE host's own `wx.getSystemInfoSync()`, the authoritative channel the mini-app's own code observes. + */ +import { test, expect, useSharedProject } from './fixtures' +import path from 'path' +import { fileURLToPath } from 'url' +import { + pollUntil, + callWxMethod, + getCurrentPage, + getPageStack, + waitForServicePageReady, +} from './helpers' +import type { ElectronApplication } from '@playwright/test' +import type { NativeDeviceInfo } from '@dimina-kit/electron-runtime' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const FIXTURE_DIR = path.resolve(__dirname, 'fixtures', 'orientation-app-landscape') +const APP_ID = 'devtools_orientation_app_landscape_fixture' // fixtures/orientation-app-landscape/project.config.json appid + +const ENTRY_ROUTE = 'pages/entry/entry' +const AUTO_ROUTE = 'pages/autopage/autopage' +const PORTRAIT_ROUTE = 'pages/portraitpage/portraitpage' +const MID_ROUTE = 'pages/mid/mid' + +// ── Device rotation (see the module doc comment) ────────────────────── + +function device(orientation: 'portrait' | 'landscape'): NativeDeviceInfo { + return { + brand: 'Apple', + model: 'iPhone 14 Pro', + system: 'iOS 16.3', + platform: 'ios', + pixelRatio: 3, + screenWidth: 393, + screenHeight: 852, + statusBarHeight: 54, + notchType: 'dynamic-island', + safeAreaInsets: { top: 54, right: 0, bottom: 34, left: 0 }, + deviceOrientation: orientation, + } +} + +async function rotateDevice(app: ElectronApplication, orientation: 'portrait' | 'landscape'): Promise { + await app.evaluate((_electron, d) => { + const hooks = (globalThis as Record).__diminaE2eHooks as { rotateDevice: (device: unknown) => void } + hooks.rotateDevice(d) + }, device(orientation)) +} + +// ── Geometry (service-host wx.getSystemInfoSync()) ──────────────────── + +interface ReportedInfo { + windowWidth?: number + windowHeight?: number +} + +async function readServiceSystemInfo(app: ElectronApplication): Promise { + return pollUntil( + () => app.evaluate(async ({ webContents }) => { + const svc = webContents.getAllWebContents().find( + (wc) => !wc.isDestroyed() && wc.getURL().includes('/service-host/service.html'), + ) + if (!svc) throw new Error('service.html not found') + return svc.executeJavaScript(`(() => { + const w = globalThis.wx + if (!w || typeof w.getSystemInfoSync !== 'function') throw new Error('wx.getSystemInfoSync missing') + const i = w.getSystemInfoSync() + return { windowWidth: i.windowWidth, windowHeight: i.windowHeight } + })()`) + }).catch(() => ({}) as ReportedInfo), + (info) => + typeof info.windowWidth === 'number' && Number.isFinite(info.windowWidth) + && typeof info.windowHeight === 'number' && Number.isFinite(info.windowHeight), + 20_000, + 400, + ) +} + +/** Poll until the reported viewport matches `expected` — orientation changes + * are asynchronous, so a single snapshot right after a triggering action can race a not-yet-landed update. */ +async function waitForOrientation(app: ElectronApplication, expected: 'portrait' | 'landscape'): Promise { + return pollUntil( + () => readServiceSystemInfo(app), + (info) => { + const w = info.windowWidth ?? -1 + const h = info.windowHeight ?? -1 + return expected === 'landscape' ? w > h : w < h + }, + 15_000, + 400, + ) +} + +async function waitForRoute(app: ElectronApplication, route: string): Promise { + await pollUntil( + () => getCurrentPage(app, APP_ID).catch(() => null), + (r) => !!r && typeof r.path === 'string' && r.path.includes(route), + 15_000, + 500, + ) +} + +/** + * The stack main reports, once it has settled. + * + * Main drops its stored stack the moment a page closes and the shell republishes it from its next commit (see `disposePageSession` and the `notifyPageStack` effect), so a single snapshot taken right after a route entry that discards pages can land in that window and read empty. + */ +async function waitForPageStack(app: ElectronApplication): Promise { + const stack = await pollUntil( + () => getPageStack(app, APP_ID).catch(() => []), + (entries) => entries.length > 0, + 15_000, + 300, + ) + return stack.map((e) => e.path) +} + +test.describe('native-host page-stack orientation across route entries and rapid navigation', () => { + test.describe.configure({ mode: 'serial' }) + test.setTimeout(180_000) + + useSharedProject(test, FIXTURE_DIR) + + test.beforeAll(async ({ _workerElectron }) => { + await waitForServicePageReady(_workerElectron.app, APP_ID) + }) + + // Device orientation is not part of useSharedProject's afterEach reset, so pin a known baseline before every test regardless of what the previous test rotated to. + test.beforeEach(async ({ electronApp }) => { + await rotateDevice(electronApp, 'portrait') + }) + + test('navigateBack({delta:2}) across a three-layer, cross-orientation stack lands on the bottom page\'s own orientation', async ({ electronApp }) => { + await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + PORTRAIT_ROUTE }]) + await waitForRoute(electronApp, PORTRAIT_ROUTE) + await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + MID_ROUTE }]) + await waitForRoute(electronApp, MID_ROUTE) + const beforeBack = await waitForOrientation(electronApp, 'landscape') + expect(beforeBack.windowWidth!, 'mid (inherited landscape) must be showing landscape before the multi-pop').toBeGreaterThan(beforeBack.windowHeight!) + + await callWxMethod(electronApp, 'navigateBack', [{ delta: 2 }]) + await waitForRoute(electronApp, ENTRY_ROUTE) + const stack = await waitForPageStack(electronApp) + expect(stack.join(','), 'popping 2 must discard both portraitpage and mid, leaving only entry').toContain(ENTRY_ROUTE) + expect(stack.length, 'no leftover intermediate pages after a multi-level pop').toBe(1) + + const after = await waitForOrientation(electronApp, 'landscape') + expect( + after.windowWidth!, + 'landing 2 levels back must resolve to the LANDING page\'s own orientation (entry\'s inherited landscape), not the portrait page it skipped over', + ).toBeGreaterThan(after.windowHeight!) + }) + + test('redirectTo across orientations replaces the stack top; there is no earlier page left to restore', async ({ electronApp }) => { + await callWxMethod(electronApp, 'redirectTo', [{ url: '/' + PORTRAIT_ROUTE }]) + await waitForRoute(electronApp, PORTRAIT_ROUTE) + const info = await waitForOrientation(electronApp, 'portrait') + expect(info.windowHeight!, 'redirectTo must show the new page\'s own orientation').toBeGreaterThan(info.windowWidth!) + + const stack = await waitForPageStack(electronApp) + expect(stack.length, 'redirectTo replaces the page it was called from — the landscape entry page it replaced is gone, not just hidden').toBe(1) + expect(stack[0]).toContain(PORTRAIT_ROUTE) + }) + + test('reLaunch across orientations clears the whole stack to the new root page\'s own orientation', async ({ electronApp }) => { + await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + PORTRAIT_ROUTE }]) + await waitForRoute(electronApp, PORTRAIT_ROUTE) + + await callWxMethod(electronApp, 'reLaunch', [{ url: '/' + MID_ROUTE }]) + await waitForRoute(electronApp, MID_ROUTE) + const stack = await waitForPageStack(electronApp) + expect(stack.length, 'reLaunch clears the entire prior stack (entry + portraitpage), leaving only the new root').toBe(1) + expect(stack[0]).toContain(MID_ROUTE) + + const info = await waitForOrientation(electronApp, 'landscape') + expect(info.windowWidth!, 'the reLaunch target\'s own (inherited landscape) orientation must apply').toBeGreaterThan(info.windowHeight!) + }) + + test('a three-layer stack alternating landscape/portrait/landscape resolves each layer correctly on both the way in and the way back out', async ({ electronApp }) => { + const entry = await waitForOrientation(electronApp, 'landscape') + expect(entry.windowWidth!, 'layer 1 (entry): landscape').toBeGreaterThan(entry.windowHeight!) + + await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + PORTRAIT_ROUTE }]) + await waitForRoute(electronApp, PORTRAIT_ROUTE) + const layer2In = await waitForOrientation(electronApp, 'portrait') + expect(layer2In.windowHeight!, 'layer 2 (portraitpage): portrait, entering').toBeGreaterThan(layer2In.windowWidth!) + + await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + MID_ROUTE }]) + await waitForRoute(electronApp, MID_ROUTE) + const layer3In = await waitForOrientation(electronApp, 'landscape') + expect(layer3In.windowWidth!, 'layer 3 (mid): landscape, entering').toBeGreaterThan(layer3In.windowHeight!) + + await callWxMethod(electronApp, 'navigateBack', [{ delta: 1 }]) + await waitForRoute(electronApp, PORTRAIT_ROUTE) + const layer2Out = await waitForOrientation(electronApp, 'portrait') + expect(layer2Out.windowHeight!, 'layer 2 (portraitpage): portrait, on the way back out').toBeGreaterThan(layer2Out.windowWidth!) + + await callWxMethod(electronApp, 'navigateBack', [{ delta: 1 }]) + await waitForRoute(electronApp, ENTRY_ROUTE) + const layer1Out = await waitForOrientation(electronApp, 'landscape') + expect(layer1Out.windowWidth!, 'layer 1 (entry): landscape, on the way back out').toBeGreaterThan(layer1Out.windowHeight!) + }) + + test('rotating the device on an auto page (the rotate control\'s own reachable path), then visiting a fixed-portrait page and back, leaves the auto page following the current device — not the fixed page\'s orientation it just left', async ({ electronApp }) => { + await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + AUTO_ROUTE }]) + await waitForRoute(electronApp, AUTO_ROUTE) + const baseline = await waitForOrientation(electronApp, 'portrait') + expect(baseline.windowHeight!, 'auto page starts portrait, tracking the portrait device').toBeGreaterThan(baseline.windowWidth!) + + // The rotate control is enabled here: an 'auto' page's canRotate is true (orientation-controller.ts's canRotateFor), so this rotation is one a real user could trigger through the UI. + await rotateDevice(electronApp, 'landscape') + const rotated = await waitForOrientation(electronApp, 'landscape') + expect(rotated.windowWidth!, 'the auto page follows the rotation to landscape').toBeGreaterThan(rotated.windowHeight!) + + await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + PORTRAIT_ROUTE }]) + await waitForRoute(electronApp, PORTRAIT_ROUTE) + const onFixed = await waitForOrientation(electronApp, 'portrait') + expect(onFixed.windowHeight!, 'the fixed portrait page forces portrait regardless of the (landscape) device').toBeGreaterThan(onFixed.windowWidth!) + + await callWxMethod(electronApp, 'navigateBack', [{ delta: 1 }]) + await waitForRoute(electronApp, AUTO_ROUTE) + const back = await waitForOrientation(electronApp, 'landscape') + expect( + back.windowWidth!, + 'back on the auto page, it must show landscape (the current device) — not the portrait it just displayed on the fixed page it left', + ).toBeGreaterThan(back.windowHeight!) + }) + + test('the device orientation changing while a fixed-orientation page is on screen (the rotate control is disabled for it, but the device state itself can still move — e.g. a device-model switch) is picked up by the auto page underneath once control returns to it', async ({ electronApp }) => { + await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + AUTO_ROUTE }]) + await waitForRoute(electronApp, AUTO_ROUTE) + const baseline = await waitForOrientation(electronApp, 'portrait') + expect(baseline.windowHeight!, 'auto page starts portrait, tracking the portrait device').toBeGreaterThan(baseline.windowWidth!) + + await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + MID_ROUTE }]) + await waitForRoute(electronApp, MID_ROUTE) + const onFixed = await waitForOrientation(electronApp, 'landscape') + expect(onFixed.windowWidth!, 'mid (inherited landscape) is fixed regardless of the device').toBeGreaterThan(onFixed.windowHeight!) + + // mid's canRotate is false — the rotate control is disabled while it is the top page — but the device's own orientation is still driven here through the same hook the rest of this suite uses, standing in for a non-rotate-control source of a device change (e.g. switching the simulated device model in the toolbar). + await rotateDevice(electronApp, 'landscape') + await new Promise((r) => setTimeout(r, 500)) + const stillFixed = await readServiceSystemInfo(electronApp) + expect(stillFixed.windowWidth!, 'mid stays landscape — a fixed page never reacts to the device').toBeGreaterThan(stillFixed.windowHeight!) + + await callWxMethod(electronApp, 'navigateBack', [{ delta: 1 }]) + await waitForRoute(electronApp, AUTO_ROUTE) + const back = await waitForOrientation(electronApp, 'landscape') + expect( + back.windowWidth!, + 'the auto page must pick up the device\'s NEW orientation (landscape) on return — not the portrait it displayed before the fixed page took over', + ).toBeGreaterThan(back.windowHeight!) + }) + + test('navigateTo a landscape page immediately followed by navigateBack, before its own orientation round trip necessarily lands, ends in a consistent state on the originating page', async ({ electronApp }) => { + await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + AUTO_ROUTE }]) + await waitForRoute(electronApp, AUTO_ROUTE) + const baselineStack = await getPageStack(electronApp, APP_ID) + + // callWxMethod's own await (runNativeHostNav → waitForActivePage in electron-entry.js) DOES wait for the active page to switch to the page just navigated to — so by the time the first call below resolves, mid IS the active page. + // What it does NOT wait for is mid's own orientation/geometry round trip: DeviceShell computes the effective orientation and relays it back over PAGE_RESIZE as a separate, uncoordinated step after the active-page switch. + // Firing navigateBack immediately, with no waitForRoute()/waitForOrientation() in between, races that in-flight PAGE_RESIZE against the pop — a late PAGE_RESIZE computed for mid must not land on (or get attributed to) whatever page is on top by the time it arrives. + await callWxMethod(electronApp, 'navigateTo', [{ url: '/' + MID_ROUTE }]) + await callWxMethod(electronApp, 'navigateBack', [{ delta: 1 }]) + + await waitForRoute(electronApp, AUTO_ROUTE) + const stack = await pollUntil( + () => getPageStack(electronApp, APP_ID), + (s) => s.length === baselineStack.length, + 15_000, + 400, + ) + expect(stack.length, 'the push/pop pair must cancel out — no orphaned mid page left on the stack').toBe(baselineStack.length) + + const info = await waitForOrientation(electronApp, 'portrait') + expect( + info.windowHeight!, + 'the final state must be the auto page\'s own (portrait) orientation — not stuck on mid\'s landscape mid-transition', + ).toBeGreaterThan(info.windowWidth!) + }) +}) diff --git a/packages/dimina-electron-runtime/e2e/native-host-page-stack.spec.ts b/packages/dimina-electron-runtime/e2e/native-host-page-stack.spec.ts index d843cf9a..eb9d0d12 100644 --- a/packages/dimina-electron-runtime/e2e/native-host-page-stack.spec.ts +++ b/packages/dimina-electron-runtime/e2e/native-host-page-stack.spec.ts @@ -38,6 +38,7 @@ import { getPageStack, getCurrentPage, callWxMethod, + waitForServicePageReady, type PageStackEntry, } from './helpers' @@ -105,6 +106,9 @@ test.describe('native-host App.getPageStack tracks full in-app navigation stack' 25000, 300, ) + // A mounted render guest is not a navigable session: the guest's URL carries its pagePath from creation, while the route APIs resolve against the SERVICE host's own page stack, which is still empty for a few hundred more ms. + // Navigating inside that window throws in the mini-app framework. + await waitForServicePageReady(electronApp) }) test.afterAll(async () => { diff --git a/packages/dimina-electron-runtime/e2e/native-host-render.spec.ts b/packages/dimina-electron-runtime/e2e/native-host-render.spec.ts index 8eb86cf2..68fbcaa8 100644 --- a/packages/dimina-electron-runtime/e2e/native-host-render.spec.ts +++ b/packages/dimina-electron-runtime/e2e/native-host-render.spec.ts @@ -36,6 +36,7 @@ import { getPageData, callWxMethod, RENDER_GUEST_URL_MARKER, + waitForServicePageReady, } from './helpers' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -82,6 +83,9 @@ test.describe('native-host render path e2e', () => { await openProject(electronApp, FIXTURE_DIR) await waitForSimulatorWebview(electronApp) + // The route APIs resolve against the SERVICE host's own page stack, which is still empty for a few hundred ms after the render guest mounts. + // Navigating inside that window throws in the mini-app framework. + await waitForServicePageReady(electronApp) }) test.afterAll(async () => { diff --git a/packages/dimina-electron-runtime/e2e/native-host-switchtab-rerender.spec.ts b/packages/dimina-electron-runtime/e2e/native-host-switchtab-rerender.spec.ts index 81e54671..4ca4bcfe 100644 --- a/packages/dimina-electron-runtime/e2e/native-host-switchtab-rerender.spec.ts +++ b/packages/dimina-electron-runtime/e2e/native-host-switchtab-rerender.spec.ts @@ -17,6 +17,7 @@ import { fileURLToPath } from 'url' import { openProject, waitForSimulatorWebview, closeProject, pollUntil, evalInSimulator, evalInWebContentsByUrl, getCurrentPage, callWxMethod, + waitForServicePageReady, } from './helpers' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -76,6 +77,9 @@ test.describe('native-host switchTab keeps rendered content on return', () => { await waitForSimulatorWebview(electronApp) await pollUntil(() => evalInSimulator(electronApp, `(() => document.querySelectorAll('.device-shell__webview').length)()`).catch(() => 0), (n) => n >= 1, 30000, 400) + // The route APIs resolve against the SERVICE host's own page stack, which is still empty for a few hundred ms after the render guest mounts. + // Navigating inside that window throws in the mini-app framework. + await waitForServicePageReady(electronApp) await waitActive('pages/home/home') }) test.afterAll(async () => { await closeProject(electronApp).catch(() => {}); await electronApp?.close().catch(() => {}) }) diff --git a/packages/dimina-electron-runtime/package.json b/packages/dimina-electron-runtime/package.json index 5e5eef5e..1ae32d48 100644 --- a/packages/dimina-electron-runtime/package.json +++ b/packages/dimina-electron-runtime/package.json @@ -30,6 +30,10 @@ "types": "./dist/shared/bridge-channels.d.ts", "default": "./dist/shared/bridge-channels.js" }, + "./shared/page-orientation": { + "types": "./dist/shared/page-orientation.d.ts", + "default": "./dist/shared/page-orientation.js" + }, "./shared/simulator-api-metadata": { "types": "./dist/shared/simulator-api-metadata.d.ts", "default": "./dist/shared/simulator-api-metadata.js" diff --git a/packages/dimina-electron-runtime/src/main/ipc/bridge-router.ts b/packages/dimina-electron-runtime/src/main/ipc/bridge-router.ts index 295c6dc8..123d7592 100644 --- a/packages/dimina-electron-runtime/src/main/ipc/bridge-router.ts +++ b/packages/dimina-electron-runtime/src/main/ipc/bridge-router.ts @@ -2,9 +2,21 @@ import { app, BrowserWindow, ipcMain, protocol, session as electronSession, webC import type { IpcMainEvent, IpcMainInvokeEvent, WebContents } from 'electron' import path from 'node:path' import { pathToFileURL } from 'node:url' -import { BRIDGE_CHANNELS as C, SIMULATOR_EVENTS as E, deviceInfoToHostEnv } from '../../shared/bridge-channels.js' +import { BRIDGE_CHANNELS as C, SERVICE_HOST_CHANNELS, SIMULATOR_EVENTS as E, deviceInfoToHostEnv } from '../../shared/bridge-channels.js' +import { pageResizeHostEnv } from '../../shared/page-resize-host-env.js' import type { NativeDeviceInfo, SyncStorageChange } from '../../shared/runtime-types.js' import { apiCallWatchdogMs, isPersistentSimulatorApi } from '../../shared/simulator-api-metadata.js' +import { + effectiveOrientation, + isPageOrientationConfig, + resolvePageOrientationState, + withPageWindowSize, +} from '../../shared/page-orientation.js' +import type { + Orientation, + PageResizePayload, +} from '../../shared/page-orientation.js' +import { routeContainerPage, stalePageApiErrMsg } from './container-routing.js' import { resolveRuntimeAssetPaths } from '../utils/paths.js' import { createSessionListenerBag } from './session-listener-bag.js' import type { SessionListenerBag } from './session-listener-bag.js' @@ -25,6 +37,7 @@ import type { PageOpenResult, PageStackEntry, PageStackPayload, + SessionActivePayload, PageWindowConfig, RenderInvokePayload, RenderPublishPayload, @@ -74,6 +87,8 @@ import { type AppLifecycleController, type AppLifecycleEvent, } from './app-lifecycle.js' +import { createWindowResizeController, type WindowResizeController } from './window-resize.js' +import type { PageClosedEvent, SessionOrientationEvent } from '../runtime-events.js' // The compiled `logic.js` ships a RELATIVE `//# sourceMappingURL=logic.js.map`. // `injectLogicBundle` loads it via `executeJavaScript`, which gives the injected @@ -332,6 +347,11 @@ interface RouterState { simulatorWcIdToAppSessionIds: Map> /** renderWc → bridgeId. */ wcIdToBridgeId: Map + /** + * The app session the simulator declared as the one on screen (`SESSION_ACTIVE`), or null when nothing has claimed it. + * Only that session's `'session-orientation'` broadcasts are marked `active`, so a soft-reload session booting behind the visible one — and the outgoing one's later teardown — cannot move the renderer's panel geometry. + */ + activeAppSessionId: string | null /** requestId → pending API_CALL forwarded to a simulator window. */ pendingApiCalls: Map /** Pre-warm pool for service-host windows; null when pooling is disabled. */ @@ -368,6 +388,11 @@ interface RouterState { * appSessionId. Fired on main-window foreground/background and service errors. */ appLifecycle: AppLifecycleController + /** + * Per-session `wx.onWindowResize` listener registry (keep subscriptions — see `window-resize.ts`). + * Fired on every dispatchable PAGE_RESIZE. + */ + windowResize: WindowResizeController /** Main-process WebSocket transport. Uses Node net/tls through `ws`, never Chromium. */ nativeWebSocket: NativeWebSocketService /** @@ -379,6 +404,16 @@ interface RouterState { * with ghost AppData tabs after a respawn. */ evictAppDataBridges: (ap: AppSession) => void + /** + * Broadcasts the orientation an app session currently forces (or `null` on teardown). + * Indirected through state (like `evictAppDataBridges`) so `disposeAppSession` — which only takes `state` — can reach `ctx.events` without threading `ctx` through the whole dispose chain. + */ + emitSessionOrientation: (event: SessionOrientationEvent) => void + /** + * Announces one page's end, so main-process consumers holding per-page state keyed by `bridgeId` release it. + * Indirected through state for the same reason as `emitSessionOrientation`. + */ + emitPageClosed: (event: PageClosedEvent) => void } /** Default timeout for a simulator-forwarded API call. */ @@ -450,6 +485,8 @@ export interface BridgeResourceCensus { simulatorWcBindings: number renderWcBindings: number pendingApiCalls: number + /** Total `wx.onWindowResize` listener ids held across every live session. */ + windowResizeListeners: number /** * `listenerCount('destroyed')` per unique live simulator wc (keyed by wc id). * One teardown hook per live session — a count above the hosted-session count @@ -599,6 +636,7 @@ export function installBridgeRouter(ctx: RuntimeContext): void { serviceWcIdToAppSessionId: new Map(), simulatorWcIdToAppSessionIds: new Map(), wcIdToBridgeId: new Map(), + activeAppSessionId: null, pendingApiCalls: new Map(), pool: null, emitRenderEvent: () => {}, @@ -607,6 +645,7 @@ export function installBridgeRouter(ctx: RuntimeContext): void { connections: ctx.connections, debugTap: createDebugTap({ enabled: resolveDebugTapEnabled() }), appLifecycle: createAppLifecycleController(), + windowResize: createWindowResizeController(), nativeWebSocket: createNativeWebSocketService({ idleTimeoutMs: socketIdleTimeoutMsFromEnv(), }), @@ -615,6 +654,8 @@ export function installBridgeRouter(ctx: RuntimeContext): void { ctx.events.emit('app-data-evict', { appId: ap.appId, bridgeId: page.bridgeId }) } }, + emitSessionOrientation: (event) => { ctx.events.emit('session-orientation', event) }, + emitPageClosed: (event) => { ctx.events.emit('page-closed', event) }, } ctx.registry.add(() => state.nativeWebSocket.dispose()) @@ -846,6 +887,7 @@ export function installBridgeRouter(ctx: RuntimeContext): void { simulatorWcBindings, renderWcBindings: state.wcIdToBridgeId.size, pendingApiCalls: state.pendingApiCalls.size, + windowResizeListeners: state.windowResize.count(), simulatorDestroyedListeners, } }, @@ -934,6 +976,17 @@ export function installBridgeRouter(ctx: RuntimeContext): void { ipcMain.on(C.PAGE_STACK, onPageStack) ctx.registry.add(() => { ipcMain.removeListener(C.PAGE_STACK, onPageStack) }) + // DeviceShell → main: the session whose shell is on screen. + // Recorded rather than inferred — during a soft reload both sessions publish geometry, and "whoever reported last" would hand the screen to the invisible one. + const onSessionActive = (event: IpcMainEvent, payload: SessionActivePayload): void => { + const ap = state.appSessions.get(payload.appSessionId) + if (!ap) return + if (!senderBoundToSession(state, event.sender, ap)) return + state.activeAppSessionId = payload.appSessionId + } + ipcMain.on(C.SESSION_ACTIVE, onSessionActive) + ctx.registry.add(() => { ipcMain.removeListener(C.SESSION_ACTIVE, onSessionActive) }) + ipcMain.handle(C.SPAWN, async (event, opts: SpawnRequest): Promise => { return handleSpawn(state, ctx, event, opts) }) @@ -964,6 +1017,14 @@ export function installBridgeRouter(ctx: RuntimeContext): void { ipcMain.on(C.PAGE_LIFECYCLE, onPageLifecycle) ctx.registry.add(() => { ipcMain.removeListener(C.PAGE_LIFECYCLE, onPageLifecycle) }) + // DeviceShell → main: orientation/size changed. + // DeviceShell owns the dispatch gate (payload.dispatchWindow / payload.dispatchPage) and the current device; main only mirrors the geometry into the session's host-env snapshot and, per channel, fires the service-side pageResize message and/or every registered wx.onWindowResize listener. + const onPageResize = (event: IpcMainEvent, payload: PageResizePayload): void => { + handlePageResize(state, event.sender, currentDevice, payload) + } + ipcMain.on(C.PAGE_RESIZE, onPageResize) + ctx.registry.add(() => { ipcMain.removeListener(C.PAGE_RESIZE, onPageResize) }) + const onNavCallback = (event: IpcMainEvent, payload: NavCallbackPayload): void => { handleNavCallback(state, event.sender, payload) } @@ -1007,7 +1068,7 @@ export function installBridgeRouter(ctx: RuntimeContext): void { tapIn(C.SERVICE_INVOKE, event.sender, payload) const ap = appByWc(state, event.sender) if (!ap) return - const page = state.pageSessions.get(payload.bridgeId) ?? state.pageSessions.get(ap.appSessionId) + const page = serviceSenderPage(state, ap, payload.bridgeId) if (!page) return routeFromService(state, ap, page, payload.msg, ctx) } @@ -1177,19 +1238,6 @@ async function handleSpawn( resourceServer = await startDiminaResourceServer(path.resolve(pkgRoot, root)) resourceBaseUrl = resourceServer.baseUrl } - // The selected device (renderer toolbar) is the authoritative source for the - // logical dims a spawn must report. The simulator-supplied `hostEnvSnapshot` - // is derived from the device baked into the simulator at BOOT time, so on a - // RESPAWN after a live device change it still carries the boot device. Layer - // the live `currentDevice` on top so every spawn/respawn reports the selected - // device — matching what the live `SetDeviceInfo` HostEnvUpdate pushes to an - // already-running service host. Pre-selection (null) → simulator snapshot wins. - const selectedDevice = ctx.bridge?.getDevice?.() ?? null - const hostEnv = makeHostEnv({ - ...opts.hostEnvSnapshot, - ...(selectedDevice ? deviceInfoToHostEnv(selectedDevice) : {}), - }) - // app-config.json lives at `//app-config.json` on the dev // server, or at the local server root for the fallback path. const appConfig = await loadAppConfig( @@ -1211,6 +1259,31 @@ async function handleSpawn( const rootWindowConfig = resolvePageWindowConfig(appConfig, resolvedPagePath) const isTab = isTabPage(appConfig, resolvedPagePath) + // The selected device (renderer toolbar) is the authoritative source for the logical dims a spawn must report. + // The simulator-supplied `hostEnvSnapshot` is derived from the device baked into the simulator at BOOT time, so on a RESPAWN after a live device change it still carries the boot device. + // Layer the live `currentDevice` on top so every spawn/respawn reports the selected device — matching what the live `SetDeviceInfo` HostEnvUpdate pushes to an already-running service host. + // Pre-selection (null) → simulator snapshot wins. + const selectedDevice = ctx.bridge?.getDevice?.() ?? null + // Seed with the ROOT PAGE's EFFECTIVE orientation, not the raw device orientation: DeviceShell mounts and sends the first PAGE_RESIZE only AFTER spawn resolves, so a page pinned to a non-auto pageOrientation must already report its effective geometry here — a synchronous wx.getSystemInfoSync() from App.onLaunch / the root page's onLoad would otherwise read the device's orientation instead of the page's. + // Reuses the SAME pure functions DeviceShell itself resolves orientation with (resolvePageOrientationState → effectiveOrientation) so main's seed and DeviceShell's later authoritative PAGE_RESIZE value can never disagree — one policy, two callers. + const bootDeviceOrientation: Orientation = selectedDevice?.deviceOrientation ?? 'portrait' + const rootOrientationState = resolvePageOrientationState(rootWindowConfig.pageOrientation) + const effectiveRootOrientation = effectiveOrientation(rootOrientationState, bootDeviceOrientation) + const orientedHostEnv = makeHostEnv({ + ...opts.hostEnvSnapshot, + ...(selectedDevice + ? deviceInfoToHostEnv({ ...selectedDevice, deviceOrientation: effectiveRootOrientation }) + : {}), + }) + // `deviceInfoToHostEnv` only knows the device, so its `windowHeight` is the screen minus the status bar. + // The window a page actually gets is the screen minus the chrome that page keeps in flow, which is what the shell reports in its first PAGE_RESIZE. + // Apply the same `pageWindowSize` formula to the seed with the ROOT PAGE's chrome, so `App.onLaunch` and the root page's `onLoad` read the height the page ends up with instead of a taller one that silently shrinks on the first frame. + const hostEnv = withPageWindowSize(orientedHostEnv, { + navigationStyle: rootWindowConfig.navigationStyle, + isTab, + bottomInset: orientedHostEnv.safeAreaInsets?.bottom ?? 0, + }) + // Acquire a pre-warmed service-host window when pooling is enabled; otherwise // construct one fresh (default). A pooled/fallback window is warmed on // about:blank and must be navigated to the spawn URL below; the fresh path @@ -1522,6 +1595,59 @@ function handlePageLifecycle(state: RouterState, sender: WebContents, payload: P }) } +function handlePageResize( + state: RouterState, + sender: WebContents, + device: NativeDeviceInfo | null, + payload: PageResizePayload, +): void { + const ap = state.appSessions.get(payload.appSessionId) + if (!ap) return + if (!senderBoundToSession(state, sender, ap)) return + // A late resize for a page that already closed (or was never a member of this session) must not resurrect stale geometry into a live session's hostEnv. `ap.pages` is the same closed-page ledger PAGE_CLOSE/disposePageSession maintain — no separate liveness tracking needed. bridgeIds are never reused (newBridgeId is timestamp+random), so this alone also rules out a stale payload landing on a DIFFERENT page that reused the same id. + if (!ap.pages.has(payload.bridgeId)) return + applyPageResize(state, ap, device, payload) +} + +/** + * Refresh `ap.hostEnv` unconditionally and broadcast the session's forced orientation; the two resize channels then fire independently — `payload.dispatchPage` gates the service-side `pageResize` message (drives `Page.onResize` / component `resize`), `payload.dispatchWindow` gates every `wx.onWindowResize` listener registered for this session. + */ +function applyPageResize( + state: RouterState, + ap: AppSession, + device: NativeDeviceInfo | null, + payload: PageResizePayload, +): void { + const patch = pageResizeHostEnv(payload, device) + ap.hostEnv = { ...ap.hostEnv, ...patch } + // Mirrors the web container's own theme-change push (miniApp.js): the service host's `core/host-env.js` already listens for this message type and replaces `snapshot.systemInfo` wholesale with the given object. + forwardToService(ap, { type: 'hostEnvUpdate', target: 'service', body: { systemInfo: ap.hostEnv } }) + // The synchronous host APIs (`wx.getSystemInfoSync` and friends) read the spawn context's `hostEnvSnapshot`, which only this direct IPC channel patches — the bus message above feeds dimina's own store and never reaches it, so a page pinned to landscape would keep reporting portrait metrics. + if (!ap.serviceWc.isDestroyed()) { + ap.serviceWc.send(SERVICE_HOST_CHANNELS.HostEnvUpdate, patch) + } + + state.emitSessionOrientation({ + appSessionId: ap.appSessionId, + bridgeId: payload.bridgeId, + orientation: payload.deviceOrientation, + canRotate: payload.canRotate, + active: state.activeAppSessionId === ap.appSessionId, + }) + + if (payload.dispatchPage) { + forwardToService(ap, { + type: 'pageResize', + target: 'service', + body: { bridgeId: payload.bridgeId, size: payload.size, deviceOrientation: payload.deviceOrientation }, + }) + } + if (!payload.dispatchWindow) return + for (const id of state.windowResize.listeners(ap.appSessionId)) { + sendCallback(ap, id, { size: payload.size, deviceOrientation: payload.deviceOrientation }) + } +} + function handleNavCallback(state: RouterState, sender: WebContents, payload: NavCallbackPayload): void { const ap = state.appSessions.get(payload.appSessionId) if (!ap) return @@ -1741,6 +1867,27 @@ function maybeSendResourceLoaded(ap: AppSession, page: PageSession): void { // ── Message routing ────────────────────────────────────────────────────────── +/** + * The page a service→container message is handled against before the message's own `body.bridgeId` refines it (`routeContainerPage`). + * + * The envelope's bridgeId is the service host's SPAWN id — the root page — and it never changes for the life of the window, so it stops resolving the moment navigation retires the launch page (`redirectTo`/`reLaunch`/"back to home" off it, which PAGE_CLOSE allows precisely because the session lives on). + * Dropping the message there would kill the session's whole service→container direction: every `wx.*` call the service makes travels this way, so the mini-app would go on running with every API call silently unanswered. + * The session's active page is the honest stand-in; a page the session still holds is better than none. + */ +function serviceSenderPage( + state: RouterState, + ap: AppSession, + senderBridgeId: string, +): PageSession | undefined { + const named = state.pageSessions.get(senderBridgeId) + if (named) return named + const active = ap.activeBridgeId ? ap.pages.get(ap.activeBridgeId) : undefined + if (active) return active + let last: PageSession | undefined + for (const page of ap.pages.values()) last = page + return last +} + function routeFromService( state: RouterState, ap: AppSession, @@ -1757,8 +1904,19 @@ function routeFromService( return } if (msg.target === 'container') { - const page = pageFromMsg(state, ap, msg) ?? defaultPage - handleContainerMsg(ap, page, msg, ctx, state) + const routing = routeContainerPage(ap.pages, readBridgeId(msg), defaultPage) + if (routing.staleBridgeId !== null && msg.type === 'invokeAPI') { + const body = msg.body as { name?: unknown, params?: unknown } | undefined + const name = String(body?.name ?? '') + const errMsg = stalePageApiErrMsg(name) + if (errMsg) { + // The calling page closed before its own call reached main. + // Answering the caller here is the only honest outcome: the substituted page is somebody else's, and reporting `ok` for it would hide that the requested page is gone. + failActionCallback(ap, normalizeParams(body?.params), errMsg) + return + } + } + handleContainerMsg(ap, routing.page, msg, ctx, state) } } @@ -2119,6 +2277,26 @@ function handleAppLifecycleToggle( return false } +// wx.onWindowResize / offWindowResize (keep subscription). +// The service encodes the listener as a keep callback id in `params.success` for both register and unregister, matching handleAppLifecycleToggle's pattern. +// Returns true when `name` matched (fully handled), false to fall through. +function handleWindowResizeToggle( + state: RouterState, + ap: AppSession, + name: string, + params: Record, +): boolean { + if (name === 'onWindowResize') { + state.windowResize.register(ap.appSessionId, params.success) + return true + } + if (name === 'offWindowResize') { + state.windowResize.unregister(ap.appSessionId, params.success) + return true + } + return false +} + // pageScrollTo acts on the page's render guest (scroll its document), which // only the main process can reach — run the scroll script in the invoking // page's render webContents rather than forwarding to the simulator. @@ -2304,6 +2482,8 @@ async function handleSimulatorApi( if (handleAppLifecycleToggle(state, ap, name, params)) return + if (handleWindowResizeToggle(state, ap, name, params)) return + if (name === 'pageScrollTo') { handlePageScrollApi(ap, page, params) return @@ -2373,16 +2553,12 @@ function forwardApiCallToSimulator( const keep = params.keep === true || isPersistentSimulatorApi(name) const timer = keep ? undefined - : setTimeout(() => { - const pending = state.pendingApiCalls.get(requestId) - if (!pending) return - state.pendingApiCalls.delete(requestId) - const target = state.appSessions.get(pending.appSessionId) - if (!target) return - const fail = { errMsg: `${pending.name}:fail no handler (timeout)` } - sendCallback(target, pending.callbacks.fail, fail) - sendCallback(target, pending.callbacks.complete, fail) - }, apiCallWatchdogMs(name, params)) + : armApiCallWatchdog( + state, + requestId, + apiName => `${apiName}:fail no handler (timeout)`, + apiCallWatchdogMs(name, params), + ) state.pendingApiCalls.set(requestId, { appSessionId: ap.appSessionId, @@ -2519,6 +2695,28 @@ function sendCallback(ap: AppSession, id: unknown, args: unknown): void { }) } +/** + * Arm the deadline a pending API call dies on. + * Whoever wins the race — the ack or this timer — the record is removed exactly once, so the caller's `fail` and `complete` fire at most once. `reason` builds the errMsg from the call's own recorded name, which outlives the local variable the caller used. + */ +function armApiCallWatchdog( + state: RouterState, + requestId: string, + reason: (apiName: string) => string, + timeoutMs: number, +): ReturnType { + return setTimeout(() => { + const pending = state.pendingApiCalls.get(requestId) + if (!pending) return + state.pendingApiCalls.delete(requestId) + const target = state.appSessions.get(pending.appSessionId) + if (!target) return + const fail = { errMsg: reason(pending.name) } + sendCallback(target, pending.callbacks.fail, fail) + sendCallback(target, pending.callbacks.complete, fail) + }, timeoutMs) +} + // ── Resource helpers ──────────────────────────────────────────────────────── function makeLoadResource(ap: AppSession, page: PageSession, target: 'service' | 'render'): MessageEnvelope { @@ -2589,13 +2787,6 @@ function ensureRenderBound(state: RouterState, sender: WebContents, bridgeId: st return page } -function pageFromMsg(state: RouterState, ap: AppSession, msg: MessageEnvelope): PageSession | undefined { - const target = readBridgeId(msg) - if (!target) return undefined - const page = ap.pages.get(target) - return page -} - function appByWc(state: RouterState, wc: WebContents): AppSession | undefined { if (wc.isDestroyed()) return undefined const appSessionId = state.serviceWcIdToAppSessionId.get(wc.id) @@ -2679,16 +2870,13 @@ function disposePageSession(state: RouterState, ap: AppSession, page: PageSessio } ap.pages.delete(page.bridgeId) state.pageSessions.delete(page.bridgeId) - // A page is closed before the shell has re-rendered and reported its new top, - // so these two would go on naming a page that no longer exists — and callers - // read them meanwhile (panels resolving a target, automation reading the - // stack). Clear them and let the shell's next ACTIVE_PAGE / PAGE_STACK fill - // them in. `getActiveBridgeId`'s own root fallback is guarded on the root - // page still being in `ap.pages`, which this delete has already settled. + // A closed page cannot remain either the targeting authority or the owner of pending API work. + // The frame republishes the new top immediately afterwards. if (ap.activeBridgeId === page.bridgeId) { ap.activeBridgeId = null } ap.pageStack = undefined + state.emitPageClosed({ appSessionId: ap.appSessionId, bridgeId: page.bridgeId }) } // Drain any pending API calls owned by this app session. One-shot calls @@ -2719,6 +2907,8 @@ function closeSessionPages(state: RouterState, ap: AppSession): void { try { page.renderWc.close() } catch { /* guest already gone */ } } state.pageSessions.delete(page.bridgeId) + // Session teardown ends every page it owns, and consumers keyed by bridgeId have no other way to learn that: the session-level 'session-orientation' teardown carries no page identity. + state.emitPageClosed({ appSessionId: ap.appSessionId, bridgeId: page.bridgeId }) } } @@ -2802,7 +2992,20 @@ async function disposeAppSession( ap.registryHandle = null void registryHandle?.dispose() state.appLifecycle.dispose(appSessionId) + state.windowResize.dispose(appSessionId) state.nativeWebSocket.disposeOwner(appSessionId) + // No mini-app forces an orientation anymore — the renderer's host-env mirror falls back to the device orientation, so closing a session restores the phone's own orientation; the user may always rotate freely once no session constrains the top page. + // Only true for the session that actually held the screen: a soft reload disposes the OUTGOING session after the incoming one was promoted, and that teardown must leave the promoted session's mirror alone. + // The claim dies here with the session that made it. + const wasOnScreen = state.activeAppSessionId === appSessionId + if (wasOnScreen) state.activeAppSessionId = null + state.emitSessionOrientation({ + appSessionId, + bridgeId: null, + orientation: null, + canRotate: true, + active: wasOnScreen, + }) // Evict AppData bridges FIRST — eviction enumerates `ap.pages`, which the // page teardown below progressively empties (and finally clears). @@ -2926,7 +3129,14 @@ function resolvePageWindowConfig(appConfig: RawAppConfig, pagePath: string): Pag const normalized = normalizePagePath(pagePath) const appWindow = appConfig.app?.window ?? {} const pageWindow = appConfig.modules?.[normalized] ?? {} + // Untyped JSON in, so a garbage `pageOrientation` (bad compiler output, hand- edited config) is filtered out rather than trusted at the PageWindowConfig type — an invalid value is treated as unconfigured (falls through to the next source, ultimately DEFAULT_PAGE_ORIENTATION in resolvePageOrientationState). + const pageOrientation = isPageOrientationConfig(pageWindow.pageOrientation) + ? pageWindow.pageOrientation + : isPageOrientationConfig(appWindow.pageOrientation) + ? appWindow.pageOrientation + : undefined return { + pageOrientation, navigationBarTitleText: pageWindow.navigationBarTitleText ?? appWindow.navigationBarTitleText ?? '', navigationBarBackgroundColor: diff --git a/packages/dimina-electron-runtime/src/main/ipc/container-routing.test.ts b/packages/dimina-electron-runtime/src/main/ipc/container-routing.test.ts new file mode 100644 index 00000000..70b3db02 --- /dev/null +++ b/packages/dimina-electron-runtime/src/main/ipc/container-routing.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { routeContainerPage, stalePageApiErrMsg } from './container-routing.js' + +interface Page { bridgeId: string } + +const root: Page = { bridgeId: 'root' } +const child: Page = { bridgeId: 'child' } +const pages = new Map([['root', root], ['child', child]]) + +describe('routeContainerPage', () => { + it('handles a message that names no page against the default page', () => { + expect(routeContainerPage(pages, undefined, root)).toEqual({ page: root, staleBridgeId: null }) + }) + + it('routes a message to the page it names', () => { + expect(routeContainerPage(pages, 'child', root)).toEqual({ page: child, staleBridgeId: null }) + }) + + it('reports the named page as stale once it left the session', () => { + const closed = new Map([['root', root]]) + expect(routeContainerPage(closed, 'child', root)).toEqual({ page: root, staleBridgeId: 'child' }) + }) +}) + +describe('stalePageApiErrMsg', () => { + it('lets app-scoped APIs run against the default page', () => { + expect(stalePageApiErrMsg('request')).toBeNull() + expect(stalePageApiErrMsg('showToast')).toBeNull() + }) +}) diff --git a/packages/dimina-electron-runtime/src/main/ipc/container-routing.ts b/packages/dimina-electron-runtime/src/main/ipc/container-routing.ts new file mode 100644 index 00000000..a00686bc --- /dev/null +++ b/packages/dimina-electron-runtime/src/main/ipc/container-routing.ts @@ -0,0 +1,32 @@ +/** + * Resolving which page a service→container message acts on. + * + * The service names the page it is running for (`msg.body.bridgeId`). + * A message that names nothing is app-scoped and is handled against the session's default page. + * A message that names a page which is no longer in the session is a different thing entirely: the page really is gone, and silently substituting another page would let its call act on — and report success for — someone else's page. `onUnload` reaches main after the page's PAGE_CLOSE, so this is a routine ordering, not an exotic one. + * + * The default page is still handed back for the stale case so app-scoped traffic keeps working; `staleBridgeId` tells the caller the page identity was substituted, and page-scoped APIs refuse to run on the substitute. + */ + +export interface ContainerPageRouting { + /** The page to handle the message against. */ + page: TPage + /** The page the message named, when that page is already gone; else null. */ + staleBridgeId: string | null +} + +export function routeContainerPage( + pages: ReadonlyMap, + namedBridgeId: string | undefined, + fallback: TPage, +): ContainerPageRouting { + if (!namedBridgeId) return { page: fallback, staleBridgeId: null } + const page = pages.get(namedBridgeId) + if (page) return { page, staleBridgeId: null } + return { page: fallback, staleBridgeId: namedBridgeId } +} + +/** No currently supported API is scoped to a stale page identity. */ +export function stalePageApiErrMsg(_name: string): string | null { + return null +} diff --git a/packages/dimina-electron-runtime/src/main/ipc/window-resize.test.ts b/packages/dimina-electron-runtime/src/main/ipc/window-resize.test.ts new file mode 100644 index 00000000..bb5d1600 --- /dev/null +++ b/packages/dimina-electron-runtime/src/main/ipc/window-resize.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest' +import { createAppLifecycleController } from './app-lifecycle.js' +import { createWindowResizeController } from './window-resize.js' + +describe('createWindowResizeController', () => { + it('ignores a null/undefined callback id on register', () => { + const controller = createWindowResizeController() + controller.register('app-1', undefined) + controller.register('app-1', null) + expect(controller.listeners('app-1')).toEqual([]) + }) + + it('returns an empty snapshot for an unknown session', () => { + const controller = createWindowResizeController() + expect(controller.listeners('unknown')).toEqual([]) + }) + + it('registers a callback id and lists it back', () => { + const controller = createWindowResizeController() + controller.register('app-1', 'cb-1') + expect(controller.listeners('app-1')).toEqual(['cb-1']) + }) + + it('keeps a registered callback across multiple listener reads (keep semantics)', () => { + const controller = createWindowResizeController() + controller.register('app-1', 'cb-1') + expect(controller.listeners('app-1')).toEqual(['cb-1']) + expect(controller.listeners('app-1')).toEqual(['cb-1']) + }) + + it('dedupes the same callback id registered twice', () => { + const controller = createWindowResizeController() + controller.register('app-1', 'cb-1') + controller.register('app-1', 'cb-1') + expect(controller.listeners('app-1')).toEqual(['cb-1']) + }) + + it('isolates callback ids per session', () => { + const controller = createWindowResizeController() + controller.register('app-1', 'cb-1') + controller.register('app-2', 'cb-2') + expect(controller.listeners('app-1')).toEqual(['cb-1']) + expect(controller.listeners('app-2')).toEqual(['cb-2']) + }) + + it('unregisters a single callback id, leaving the others in place', () => { + const controller = createWindowResizeController() + controller.register('app-1', 'cb-1') + controller.register('app-1', 'cb-2') + controller.unregister('app-1', 'cb-1') + expect(controller.listeners('app-1')).toEqual(['cb-2']) + }) + + it('unregister with no callback id clears every listener of the session', () => { + const controller = createWindowResizeController() + controller.register('app-1', 'cb-1') + controller.register('app-1', 'cb-2') + controller.unregister('app-1') + expect(controller.listeners('app-1')).toEqual([]) + }) + + it('unregister on an unknown session is a no-op', () => { + const controller = createWindowResizeController() + expect(() => controller.unregister('unknown', 'cb-1')).not.toThrow() + expect(() => controller.unregister('unknown')).not.toThrow() + }) + + it('dispose drops all listeners for a session without affecting others', () => { + const controller = createWindowResizeController() + controller.register('app-1', 'cb-1') + controller.register('app-2', 'cb-2') + controller.dispose('app-1') + expect(controller.listeners('app-1')).toEqual([]) + expect(controller.listeners('app-2')).toEqual(['cb-2']) + }) + + // `count()` is the ledger's own report for leak assertions: a session that ended, or a listener that was removed, must leave nothing standing in for it — one retained entry per project open is how this grows unbounded. + it('counts every listener across sessions and returns to zero as they go', () => { + const controller = createWindowResizeController() + expect(controller.count()).toBe(0) + + controller.register('app-1', 'cb-1') + controller.register('app-1', 'cb-2') + controller.register('app-2', 'cb-3') + expect(controller.count()).toBe(3) + + controller.unregister('app-1', 'cb-1') + expect(controller.count()).toBe(2) + controller.unregister('app-1', 'cb-2') + expect(controller.count()).toBe(1) + controller.dispose('app-2') + expect(controller.count()).toBe(0) + }) + + it('repeated register/dispose cycles leave the count exactly at baseline', () => { + const controller = createWindowResizeController() + for (let round = 0; round < 5; round++) { + controller.register(`app-${round}`, 'cb-a') + controller.register(`app-${round}`, 'cb-b') + controller.dispose(`app-${round}`) + } + expect(controller.count()).toBe(0) + }) + + it('dispose on an unknown session is a no-op', () => { + const controller = createWindowResizeController() + expect(() => controller.dispose('unknown')).not.toThrow() + }) + + // The service dedups keep callbacks by function identity, so one listener reused across two subscription APIs reaches main under a single id. + // Each registry owns its own entry for that id: removing the resize listener leaves the app-lifecycle listener registered. + it('removing an id shared with an app-lifecycle listener leaves that listener registered', () => { + const resize = createWindowResizeController() + const lifecycle = createAppLifecycleController() + resize.register('app-1', 'cb-shared') + lifecycle.register('app-1', 'onAppShow', 'cb-shared') + + resize.unregister('app-1', 'cb-shared') + + expect(resize.listeners('app-1')).toEqual([]) + expect(lifecycle.listeners('app-1', 'onAppShow')).toEqual(['cb-shared']) + }) +}) diff --git a/packages/dimina-electron-runtime/src/main/ipc/window-resize.ts b/packages/dimina-electron-runtime/src/main/ipc/window-resize.ts new file mode 100644 index 00000000..9c16c4b5 --- /dev/null +++ b/packages/dimina-electron-runtime/src/main/ipc/window-resize.ts @@ -0,0 +1,72 @@ +/** + * Runtime per-app-session registry for `wx.onWindowResize` listeners. + * + * `wx.onWindowResize(listener)` arrives as a keep subscription — the service encodes `listener` as a persistent callback id in `params.success` (service `callback.store(fn, keep=true)`), so the router stores the id here and re-fires it via `sendCallback` on every dispatched PAGE_RESIZE, the same pattern `app-lifecycle.ts` uses for `wx.onAppShow`. + * + * `wx.offWindowResize(listener)` carries the id the matching `on` produced — the service keeps its own `listener → evtId` map for this API — so `off` removes exactly that listener. `wx.offWindowResize()` with no argument carries no id and clears every listener of the session (WeChat contract). + * + * The ids are opaque here. + * Whether two ids collide across APIs is the service's business; this registry only ever adds and removes what the service names, and never touches another API's registry. + */ + +export interface WindowResizeController { + /** Store a keep callback id for a session. Null/undefined ids are ignored. */ + register(appSessionId: string, callbackId: unknown): void + /** + * Remove a listener. + * With `callbackId`, removes only that id; without one, clears every listener registered for the session. + */ + unregister(appSessionId: string, callbackId?: unknown): void + /** Snapshot of the registered callback ids (empty for an unknown session). */ + listeners(appSessionId: string): unknown[] + /** + * Total registered listeners across every session. + * The ledger's own count, for leak assertions that must return to a baseline exactly after churn — an emptied-but-retained session bucket is itself a leak, so a session with no listeners contributes 0 and leaves no trace. + */ + count(): number + /** Drop all listeners for a torn-down session. */ + dispose(appSessionId: string): void +} + +export function createWindowResizeController(): WindowResizeController { + const sessions = new Map>() + + return { + register(appSessionId, callbackId) { + if (callbackId === undefined || callbackId === null) return + let ids = sessions.get(appSessionId) + if (!ids) { + ids = new Set() + sessions.set(appSessionId, ids) + } + ids.add(callbackId) + }, + + unregister(appSessionId, callbackId) { + if (callbackId === undefined || callbackId === null) { + sessions.delete(appSessionId) + return + } + const ids = sessions.get(appSessionId) + if (!ids) return + ids.delete(callbackId) + // Drop the bucket with its last listener: an empty Set left behind is an entry that outlives every reason for it to exist. + if (ids.size === 0) sessions.delete(appSessionId) + }, + + listeners(appSessionId) { + const ids = sessions.get(appSessionId) + return ids ? Array.from(ids) : [] + }, + + count() { + let total = 0 + for (const ids of sessions.values()) total += ids.size + return total + }, + + dispose(appSessionId) { + sessions.delete(appSessionId) + }, + } +} diff --git a/packages/dimina-electron-runtime/src/main/runtime-events.ts b/packages/dimina-electron-runtime/src/main/runtime-events.ts index c397a43a..49b6e9bc 100644 --- a/packages/dimina-electron-runtime/src/main/runtime-events.ts +++ b/packages/dimina-electron-runtime/src/main/runtime-events.ts @@ -1,5 +1,6 @@ import type { SyncStorageChange } from '../shared/runtime-types.js' import type { MessageEnvelope } from '../shared/bridge-channels.js' +import type { Orientation } from '../shared/page-orientation.js' export interface SessionRuntimeStatus { appId: string @@ -9,11 +10,42 @@ export interface SessionRuntimeStatus { pageFallback?: { requested: string; resolved: string } } +/** + * Broadcast on every PAGE_RESIZE and on session teardown so the renderer's host-env mirror can track the orientation an app session forces, without recomputing it — DeviceShell is the sole authority (see `shared/page-orientation.ts`). `orientation: null` means no session is forcing one (falls back to the device orientation); `canRotate` mirrors whether the top page lets the user rotate the simulated device. + */ +export interface SessionOrientationEvent { + appSessionId: string + /** + * The page this orientation belongs to. + * Consumers doing per-page work — the CSS `env(safe-area-inset-*)` override of that page's own render guest — route by it, so a hidden tab-substack guest is never given the top page's orientation. `null` on teardown, where no page is reporting one. + */ + bridgeId: string | null + orientation: Orientation | null + canRotate: boolean + /** + * Whether this report comes from the session the simulator declared as the one on screen (`SESSION_ACTIVE`). + * Consumers mirroring "what the user is looking at" — the renderer's panel geometry and rotate control — must honor nothing else: during a soft reload the outgoing session keeps reporting after the incoming one has taken the screen, and its eventual teardown arrives last of all. + * Per-page consumers (a render guest's own safe-area override) ignore this and route by `bridgeId` instead. + */ + active: boolean +} + +/** + * A page's own end — `PAGE_CLOSE` or the teardown of the session it belongs to. + * Consumers holding per-page state keyed by `bridgeId` release it here rather than off the render guest's `'destroyed'`: a page outlives its guest across a render-host swap, and a page can exist before any guest attaches. + */ +export interface PageClosedEvent { + appSessionId: string + bridgeId: string +} + export interface RuntimeEventMap { 'session-status': SessionRuntimeStatus 'app-data-evict': { appId: string; bridgeId: string } 'app-data-message': { appId: string; message: MessageEnvelope } 'storage-changed': { appId: string; change: SyncStorageChange } + 'session-orientation': SessionOrientationEvent + 'page-closed': PageClosedEvent } export interface RuntimeEvents { diff --git a/packages/dimina-electron-runtime/src/shared/bridge-channels.ts b/packages/dimina-electron-runtime/src/shared/bridge-channels.ts index f0846f27..d3ca5891 100644 --- a/packages/dimina-electron-runtime/src/shared/bridge-channels.ts +++ b/packages/dimina-electron-runtime/src/shared/bridge-channels.ts @@ -1,4 +1,8 @@ -import type { NativeDeviceInfo } from './runtime-types.js' +import type { NativeDeviceInfo, SafeAreaInsets } from './runtime-types.js' +import type { Orientation, PageOrientationConfig } from './page-orientation.js' +import { orientedDeviceMetrics, orientedSafeAreaInsets } from './page-orientation.js' + +export { SERVICE_HOST_CHANNELS } from './service-host-channels.js' export const BRIDGE_CHANNELS = { SPAWN: 'dmb:spawn', @@ -6,6 +10,11 @@ export const BRIDGE_CHANNELS = { PAGE_OPEN: 'dmb:page:open', PAGE_CLOSE: 'dmb:page:close', PAGE_LIFECYCLE: 'dmb:page:lifecycle', + /** + * simulator (DeviceShell) → main: the page window changed orientation/size. + * Payload is `PageResizePayload` (shared/page-orientation). + */ + PAGE_RESIZE: 'dmb:page:resize', NAV_CALLBACK: 'dmb:nav:callback', SERVICE_INVOKE: 'dmb:service:invoke', SERVICE_PUBLISH: 'dmb:service:publish', @@ -36,6 +45,10 @@ export const BRIDGE_CHANNELS = { * this to report multi-page stacks. Fire-and-forget. */ PAGE_STACK: 'dmb:page-stack', + /** simulator (DeviceShell) → main: the app session whose shell is on screen. + * Soft reload has two sessions reporting at once, so main cannot infer it from who published last. + * Fire-and-forget; the claim dies with the session. */ + SESSION_ACTIVE: 'dmb:session-active', } as const export const SIMULATOR_EVENTS = { @@ -69,35 +82,19 @@ export const SimulatorCustomApiBridgeChannel = { Response: 'simulator:custom-apis:bridge-response', } as const -/** - * `simulator:relaunch` payload. `url` is a full simulator URL (same format as - * the simulator page's own location / AttachNative), carrying the appId and - * the page route the new session must boot at. - */ +/** `simulator:relaunch` payload: a full simulator URL (same format as the simulator page's own location / AttachNative) carrying the appId + page route the new session must boot at. */ export interface RelaunchPayload { url: string } export const CHANNELS = BRIDGE_CHANNELS -/** - * Reply to a `NATIVE_HOST_ENABLED` sendSync. Main supplies render-host asset - * URLs (preload still needs a `file://` path for the guest preload script). - * The pageFrame document itself is built per-bridge as `dmb-resource://…` - * via `buildRenderHostDocumentUrl` — `renderHostHtmlUrl` is only a legacy - * placeholder kept for the config shape. - */ +/** Reply to a `NATIVE_HOST_ENABLED` sendSync. Main supplies render-host asset URLs (preload still needs a `file://` path for the guest preload script); the pageFrame document itself is built per-bridge as `dmb-resource://…` via `buildRenderHostDocumentUrl` — `renderHostHtmlUrl` is only a legacy placeholder kept for the config shape. */ export interface NativeHostConfig { enabled: boolean renderHostHtmlUrl: string renderPreloadUrl: string - /** - * The currently-selected device, if the renderer already pushed it before the - * simulator WCV's preload installed (it does — SetDeviceInfo precedes - * AttachNative). DeviceShell reads this as its initial device so it never - * mounts with the wrong bezel size while waiting for the first DEVICE_CHANGE. - * Absent only on the pre-spawn default path. - */ + /** The currently-selected device, if the renderer already pushed it before the simulator WCV's preload installed (it does — SetDeviceInfo precedes AttachNative); DeviceShell reads this as its initial device so it never mounts with the wrong bezel size while waiting for the first DEVICE_CHANGE. Absent only on the pre-spawn default path. */ device?: NativeDeviceInfo } @@ -159,6 +156,10 @@ export interface HostEnvSnapshot { statusBarHeight: number language: string theme: string + /** Orientation the mini-app window currently shows. */ + deviceOrientation?: Orientation + /** Safe-area insets for the orientation currently on screen (see `orientedSafeAreaInsets`). */ + safeAreaInsets?: SafeAreaInsets [key: string]: unknown } @@ -176,17 +177,24 @@ export interface HostEnvSnapshot { * update pushed — otherwise a respawn would silently revert to the boot device. */ export function deviceInfoToHostEnv(d: NativeDeviceInfo): Partial { + const deviceOrientation: Orientation = d.deviceOrientation ?? 'portrait' + const m = orientedDeviceMetrics(d, deviceOrientation) return { brand: d.brand, model: d.model, system: d.system, platform: d.platform, pixelRatio: d.pixelRatio, - screenWidth: d.screenWidth, - screenHeight: d.screenHeight, - windowWidth: d.screenWidth, - windowHeight: Math.max(0, d.screenHeight - d.statusBarHeight), - statusBarHeight: d.statusBarHeight, + screenWidth: m.screenWidth, + screenHeight: m.screenHeight, + windowWidth: m.screenWidth, + windowHeight: Math.max(0, m.screenHeight - m.statusBarHeight), + statusBarHeight: m.statusBarHeight, + deviceOrientation, + safeAreaInsets: orientedSafeAreaInsets( + { statusBarHeight: d.statusBarHeight, hasNotch: d.notchType !== 'none', safeAreaInsets: d.safeAreaInsets }, + deviceOrientation, + ), } } @@ -199,6 +207,7 @@ export function deviceInfoToHostEnv(d: NativeDeviceInfo): Partial { + it('accepts the three documented values', () => { + expect(isPageOrientationConfig('portrait')).toBe(true) + expect(isPageOrientationConfig('auto')).toBe(true) + expect(isPageOrientationConfig('landscape')).toBe(true) + }) + + it('rejects values outside the enum, matching the WeChat devtools validator', () => { + expect(isPageOrientationConfig('Portrait')).toBe(false) + expect(isPageOrientationConfig('LANDSCAPE')).toBe(false) + expect(isPageOrientationConfig('')).toBe(false) + expect(isPageOrientationConfig('vertical')).toBe(false) + expect(isPageOrientationConfig(undefined)).toBe(false) + expect(isPageOrientationConfig(null)).toBe(false) + expect(isPageOrientationConfig(0)).toBe(false) + expect(isPageOrientationConfig({})).toBe(false) + }) +}) + +describe('resolvePageOrientationState', () => { + it('treats an unknown pageOrientation value as portrait', () => { + expect(resolvePageOrientationState('sideways')).toEqual({ + originalPageOrientation: 'portrait', + }) + }) + + it('treats a missing pageOrientation value as portrait', () => { + expect(resolvePageOrientationState(undefined)).toEqual({ + originalPageOrientation: 'portrait', + }) + }) + + it('treats null as portrait', () => { + expect(resolvePageOrientationState(null)).toEqual({ + originalPageOrientation: 'portrait', + }) + }) + + it('is case-sensitive: a differently-cased value is still dirty and falls back to portrait', () => { + expect(resolvePageOrientationState('Auto')).toEqual({ + originalPageOrientation: 'portrait', + }) + }) + + it('resolves "auto" from the config', () => { + expect(resolvePageOrientationState('auto')).toEqual({ + originalPageOrientation: 'auto', + }) + }) + + it('resolves a fixed "landscape" config', () => { + expect(resolvePageOrientationState('landscape')).toEqual({ + originalPageOrientation: 'landscape', + }) + }) + + it('resolves a fixed "portrait" config', () => { + expect(resolvePageOrientationState('portrait')).toEqual({ + originalPageOrientation: 'portrait', + }) + }) +}) + +describe('computedOrientationConfig', () => { + it('returns the resolved originalPageOrientation', () => { + const state: PageOrientationState = { + originalPageOrientation: 'landscape', + } + expect(computedOrientationConfig(state)).toBe('landscape') + }) +}) + +describe('effectiveOrientation', () => { + it('resolves to the device orientation when computed config is "auto"', () => { + const state: PageOrientationState = { originalPageOrientation: 'auto' } + expect(effectiveOrientation(state, 'landscape')).toBe('landscape') + expect(effectiveOrientation(state, 'portrait')).toBe('portrait') + }) + + it('resolves to the fixed computed config, ignoring the device orientation', () => { + const state: PageOrientationState = { originalPageOrientation: 'landscape' } + expect(effectiveOrientation(state, 'portrait')).toBe('landscape') + }) +}) + +describe('canUserRotate', () => { + it('allows manual rotation when the computed config is "auto"', () => { + const state: PageOrientationState = { originalPageOrientation: 'auto' } + expect(canUserRotate(state)).toBe(true) + }) + + it('disables manual rotation for a fixed "landscape" page', () => { + const state: PageOrientationState = { originalPageOrientation: 'landscape' } + expect(canUserRotate(state)).toBe(false) + }) + + it('disables manual rotation for a fixed "portrait" page', () => { + const state: PageOrientationState = { originalPageOrientation: 'portrait' } + expect(canUserRotate(state)).toBe(false) + }) +}) + +describe('orientedDeviceMetrics', () => { + it('returns the device metrics unchanged in portrait', () => { + expect(orientedDeviceMetrics(device, 'portrait')).toEqual({ + screenWidth: 375, + screenHeight: 667, + statusBarHeight: 20, + }) + }) + + it('swaps width and height in landscape', () => { + const result = orientedDeviceMetrics(device, 'landscape') + expect(result.screenWidth).toBe(667) + expect(result.screenHeight).toBe(375) + }) + + it('zeroes the status bar height in landscape, matching phone devtools semantics', () => { + const result = orientedDeviceMetrics(device, 'landscape') + expect(result.statusBarHeight).toBe(0) + }) + + it('keeps the original status bar height in portrait even when it is 0', () => { + const notch = { screenWidth: 390, screenHeight: 844, statusBarHeight: 0 } + expect(orientedDeviceMetrics(notch, 'portrait').statusBarHeight).toBe(0) + }) +}) + +describe('normalizeDeviceOrientation', () => { + it('uses the supplied orientation when it is a valid value', () => { + expect(normalizeDeviceOrientation({ windowWidth: 375, windowHeight: 667 }, 'landscape')).toBe('landscape') + expect(normalizeDeviceOrientation({ windowWidth: 667, windowHeight: 375 }, 'portrait')).toBe('portrait') + }) + + it('falls back to width/height comparison when the orientation is missing', () => { + expect(normalizeDeviceOrientation({ windowWidth: 667, windowHeight: 375 })).toBe('landscape') + expect(normalizeDeviceOrientation({ windowWidth: 375, windowHeight: 667 })).toBe('portrait') + }) + + it('falls back to width/height comparison when the orientation value is invalid', () => { + expect(normalizeDeviceOrientation({ windowWidth: 667, windowHeight: 375 }, 'undefined')).toBe('landscape') + expect(normalizeDeviceOrientation({ windowWidth: 375, windowHeight: 667 }, 'sideways')).toBe('portrait') + expect(normalizeDeviceOrientation({ windowWidth: 667, windowHeight: 375 }, null)).toBe('landscape') + expect(normalizeDeviceOrientation({ windowWidth: 667, windowHeight: 375 }, 42)).toBe('landscape') + }) + + it('treats an exact square as portrait, since width is not strictly greater than height', () => { + expect(normalizeDeviceOrientation({ windowWidth: 400, windowHeight: 400 })).toBe('portrait') + }) +}) + +describe('shouldDispatchResize', () => { + const autoState: PageOrientationState = { originalPageOrientation: 'auto' } + const fixedState: PageOrientationState = { originalPageOrientation: 'landscape' } + + const portrait = { windowWidth: 375, windowHeight: 667, deviceOrientation: 'portrait' as const } + const landscape = { windowWidth: 667, windowHeight: 375, deviceOrientation: 'landscape' as const } + + it('leaves the window channel silent when the geometry did not move', () => { + expect( + shouldDispatchResize({ state: autoState, previous: portrait, next: { ...portrait } }), + ).toEqual({ dispatchWindow: false, dispatchPage: true }) + }) + + it('reports the page channel on a landing whose window never moved', () => { + // A route commit names its landing page without comparing geometry, so a page returning into a window that rotated while it was hidden re-reads its own window instead of keeping a stale rpx basis. + expect( + shouldDispatchResize({ state: autoState, previous: landscape, next: { ...landscape } }), + ).toEqual({ dispatchWindow: false, dispatchPage: true }) + }) + + it('opens the window channel on the very first report, whose baseline is still empty', () => { + expect( + shouldDispatchResize({ state: autoState, previous: EMPTY_RESIZE_BASELINE, next: landscape }), + ).toEqual({ dispatchWindow: true, dispatchPage: true }) + }) + + it('suppresses both channels for a fixed-orientation page, even though the geometry moved', () => { + expect( + shouldDispatchResize({ state: fixedState, previous: portrait, next: landscape }), + ).toEqual({ dispatchWindow: false, dispatchPage: false }) + }) + + it('dispatches for an "auto" page when the device orientation changed', () => { + expect( + shouldDispatchResize({ state: autoState, previous: portrait, next: landscape }), + ).toEqual({ dispatchWindow: true, dispatchPage: true }) + }) + + it('dispatches for an "auto" page when only the window size changed but the orientation label did not', () => { + expect( + shouldDispatchResize({ + state: autoState, + previous: portrait, + next: { windowWidth: 390, windowHeight: 667, deviceOrientation: 'portrait' }, + }), + ).toEqual({ dispatchWindow: true, dispatchPage: true }) + }) +}) + +describe('orientedSafeAreaInsets', () => { + // iPhone X profile: the numbers WeChat itself ships for this screen. + const notched = { + statusBarHeight: 44, + hasNotch: true, + safeAreaInsets: { top: 44, right: 0, bottom: 34, left: 0 }, + } + const flat = { + statusBarHeight: 20, + hasNotch: false, + safeAreaInsets: { top: 20, right: 0, bottom: 0, left: 0 }, + } + + it('returns the portrait insets untouched in portrait', () => { + expect(orientedSafeAreaInsets(notched, 'portrait')).toEqual({ top: 44, right: 0, bottom: 34, left: 0 }) + }) + + it('moves the notch from the top edge onto both sides in landscape', () => { + expect(orientedSafeAreaInsets(notched, 'landscape')).toEqual({ top: 0, right: 44, bottom: 21, left: 44 }) + }) + + it('leaves a device without a notch inset-free in landscape', () => { + expect(orientedSafeAreaInsets(flat, 'landscape')).toEqual({ top: 0, right: 0, bottom: 0, left: 0 }) + }) + + it('computes the landscape insets rather than transposing the portrait ones', () => { + const landscape = orientedSafeAreaInsets(notched, 'landscape') + // Transposing would have carried the portrait bottom (34) across; the real landscape home indicator is thinner, and the top frees up entirely. + expect(landscape.bottom).not.toBe(notched.safeAreaInsets.bottom) + expect(landscape.top).toBe(0) + }) +}) diff --git a/packages/dimina-electron-runtime/src/shared/page-orientation.ts b/packages/dimina-electron-runtime/src/shared/page-orientation.ts new file mode 100644 index 00000000..583cfc79 --- /dev/null +++ b/packages/dimina-electron-runtime/src/shared/page-orientation.ts @@ -0,0 +1,317 @@ +/** + * Screen-orientation policy, shared by the simulator shell (which owns the page stack and is therefore the authority on the effective orientation), the renderer panel geometry, and the main-process resize dispatcher. + * + * The semantics mirror WeChat's documented `pageOrientation` configuration: each page resolves its own json falling back to `app.json`'s `window` section, and only pages whose resolved config is `auto` follow the device. + * + * Everything here is pure so all three consumers derive identical answers from the same inputs — the effective orientation has exactly one authority and nobody re-implements the rules locally. + */ + +export type Orientation = 'portrait' | 'landscape' +export type PageOrientationConfig = 'portrait' | 'auto' | 'landscape' + +/** Orientation config default when a page and the app both stay silent. */ +export const DEFAULT_PAGE_ORIENTATION: PageOrientationConfig = 'portrait' + +const PAGE_ORIENTATION_CONFIGS: readonly PageOrientationConfig[] = ['portrait', 'auto', 'landscape'] + +export interface PageOrientationState { + /** Config value resolved from `page.json` ?? `app.json`.window ?? portrait. */ + originalPageOrientation: PageOrientationConfig +} + +export interface DeviceMetricsInput { + /** Portrait-baseline screen width in px. */ + screenWidth: number + /** Portrait-baseline screen height in px. */ + screenHeight: number + statusBarHeight: number +} + +export interface OrientedMetrics { + screenWidth: number + screenHeight: number + statusBarHeight: number +} + +export interface ResizeSize { + windowWidth: number + windowHeight: number +} + +/** + * The `size` a host reports. + * The base library passes this object straight through to the callbacks, so what it carries is the host's choice: the documented `windowWidth`/`windowHeight` plus the whole screen, which is the same pair the native hosts send. + * The two differ by the chrome the system keeps — status bar and navigation bar — and both swap width/height on rotation. + */ +export interface ResizeReportSize extends ResizeSize { + screenWidth: number + screenHeight: number +} + +/** The object `Page.onResize`, the `resize` page lifetime and + * `wx.onWindowResize` listeners all receive, matching WeChat's payload. */ +export interface PageResizeDetail { + size: ResizeReportSize + deviceOrientation: Orientation +} + +/** DeviceShell → main payload for the `PAGE_RESIZE` channel. */ +export interface PageResizePayload { + appSessionId: string + bridgeId: string + size: ResizeReportSize + deviceOrientation: Orientation + /** + * Whether this change fires `wx.onWindowResize`. + * DeviceShell applies the gating rules (see {@link shouldDispatchResize}); main refreshes the host-env snapshot regardless of either dispatch field. + */ + dispatchWindow: boolean + /** Whether this change fires `Page.onResize` / component `resize` — independent of {@link dispatchWindow}. */ + dispatchPage: boolean + /** + * Whether the top page lets the user rotate the simulated device (see + * {@link canUserRotate}). Main relays it to the renderer so the rotate + * control reflects the page currently on screen. + */ + canRotate: boolean +} + +export function isOrientation(value: unknown): value is Orientation { + return value === 'portrait' || value === 'landscape' +} + +export function isPageOrientationConfig(value: unknown): value is PageOrientationConfig { + return typeof value === 'string' && PAGE_ORIENTATION_CONFIGS.includes(value as PageOrientationConfig) +} + +/** + * Build a page's orientation state from its resolved window config. + * Unknown or missing values fall back to portrait, which is also WeChat's default. + */ +export function resolvePageOrientationState(configured: unknown): PageOrientationState { + return { + originalPageOrientation: isPageOrientationConfig(configured) + ? configured + : DEFAULT_PAGE_ORIENTATION, + } +} + +/** The page's resolved orientation configuration. */ +export function computedOrientationConfig(state: PageOrientationState): PageOrientationConfig { + return state.originalPageOrientation +} + +/** What the page actually shows: `auto` follows the device, else the config. */ +export function effectiveOrientation( + state: PageOrientationState, + deviceOrientation: Orientation, +): Orientation { + const computed = computedOrientationConfig(state) + return computed === 'auto' ? deviceOrientation : computed +} + +/** + * Whether the user may rotate the simulated device while this page is on top. + * Pages pinned to a fixed orientation ignore device rotation, so the control is inert for them. + */ +export function canUserRotate(state: PageOrientationState): boolean { + return computedOrientationConfig(state) === 'auto' +} + +/** + * Device metrics for a given orientation. + * Landscape swaps the portrait baseline width/height and drops the status bar, which is what WeChat does on phones; the navigation bar and tab bar keep their heights. + */ +export function orientedDeviceMetrics( + device: DeviceMetricsInput, + orientation: Orientation, +): OrientedMetrics { + if (orientation !== 'landscape') { + return { + screenWidth: device.screenWidth, + screenHeight: device.screenHeight, + statusBarHeight: device.statusBarHeight, + } + } + return { + screenWidth: device.screenHeight, + screenHeight: device.screenWidth, + statusBarHeight: 0, + } +} + +export interface SafeAreaInsetsShape { + top: number + right: number + bottom: number + left: number +} + +export interface SafeAreaInput { + /** Portrait-baseline status bar height. In landscape the notch eats this much off each side. */ + statusBarHeight: number + /** Whether the screen has a notch or dynamic island cutting into it. */ + hasNotch: boolean + /** Portrait-baseline insets. */ + safeAreaInsets: SafeAreaInsetsShape +} + +/** + * Home-indicator inset a notched iPhone keeps at the bottom in landscape. + * WeChat lists 21 for every notched iPhone it ships a profile for, and its base library's safe-area fallback for 812x375@3x resolves `--safe-area-inset-bottom: 21px` — two independent statements of the same number, against 34 in portrait. + */ +const LANDSCAPE_HOME_INDICATOR = 21 + +/** + * Safe-area insets for a given orientation. + * Landscape is not the portrait insets rotated: the notch moves from the top edge to BOTH side edges, the top frees up entirely, and the home indicator gets thinner. + * + * WeChat's own client recomputes this rather than transforming — on every orientation change its base library re-asks native for a fresh `safeArea` instead of deriving one — so the simulator, which stands in for native here, has to produce the landscape values itself. + * The side inset equals the status bar height because that is the notch's own depth. + */ +export function orientedSafeAreaInsets( + device: SafeAreaInput, + orientation: Orientation, +): SafeAreaInsetsShape { + if (orientation !== 'landscape') return device.safeAreaInsets + const side = device.hasNotch ? device.statusBarHeight : 0 + return { + top: 0, + right: side, + bottom: device.hasNotch ? LANDSCAPE_HOME_INDICATOR : 0, + left: side, + } +} + +/** Navigation bar height; fixed, it does not follow the orientation. */ +export const NAV_BAR_HEIGHT = 44 + +/** + * The tab bar row's own content height. + * This is not the height the tab bar occupies in the layout — see {@link tabBarReservedHeight}. + */ +export const TAB_BAR_HEIGHT = 50 + +/** + * Height a tab bar actually reserves in the layout flow: the row's content box (`content-box` sizing puts padding on top of the row height), the home-indicator inset its background pads into, and its 1px top border. + */ +export function tabBarReservedHeight(bottomInset: number): number { + return TAB_BAR_HEIGHT + bottomInset + 1 +} + +/** The chrome a page keeps in the layout flow around its window area. */ +export interface PageChrome { + /** `custom` takes the navigation bar out of flow, so nothing is reserved above the page. */ + navigationStyle?: 'default' | 'custom' + /** Whether this is a tabBar page, which keeps the tab bar in flow below it. */ + isTab: boolean + /** Portrait-baseline bottom safe-area inset the tab bar pads its background into. */ + bottomInset: number +} + +/** + * A page's window size on a given oriented screen: the screen minus the chrome that stays in the layout flow. + * The status bar never reserves flow space by itself — the default navigation bar's box already spans it — so a `custom` navigation style leaves the full screen height above the tab bar. + * + * Every consumer that reports `windowWidth`/`windowHeight` to a mini-app goes through here: the simulator shell when it measures a live page, and the router when it seeds a spawn's host env before the shell has measured anything. + * One formula, so the seed and the first measured frame agree. + */ +export function pageWindowSize(oriented: OrientedMetrics, chrome: PageChrome): ResizeSize { + const reservedTop = chrome.navigationStyle === 'custom' + ? 0 + : oriented.statusBarHeight + NAV_BAR_HEIGHT + const reservedBottom = chrome.isTab ? tabBarReservedHeight(chrome.bottomInset) : 0 + return { + windowWidth: oriented.screenWidth, + windowHeight: Math.max(0, oriented.screenHeight - reservedTop - reservedBottom), + } +} + +/** Screen metrics plus the window fields a host-env snapshot reports. */ +export interface WindowMetricsFields extends OrientedMetrics { + windowWidth: number + windowHeight: number +} + +/** + * Rewrite a snapshot's window size to what `chrome` leaves of the snapshot's own (already oriented) screen metrics, leaving every other field untouched. + */ +export function withPageWindowSize(env: T, chrome: PageChrome): T { + return { ...env, ...pageWindowSize(env, chrome) } +} + +/** + * WeChat's own guidance is to trust the window dimensions over a reported orientation, so an absent or malformed value is derived from the size. + */ +export function normalizeDeviceOrientation( + size: ResizeSize, + deviceOrientation?: unknown, +): Orientation { + if (isOrientation(deviceOrientation)) return deviceOrientation + return size.windowWidth > size.windowHeight ? 'landscape' : 'portrait' +} + +/** App-global geometry baseline a resize report is compared against. */ +export interface ResizeBaseline { + windowWidth: number + windowHeight: number + deviceOrientation: string +} + +/** + * Sentinel baseline before any resize has ever been reported, mirroring WeChat's own `d="",p=0,h=0` module-level init — it is guaranteed to differ from any real geometry, so the very first-ever report already counts as a change instead of being special-cased as silent. + */ +export const EMPTY_RESIZE_BASELINE: ResizeBaseline = { windowWidth: 0, windowHeight: 0, deviceOrientation: '' } + +export interface ResizeDispatchInput { + state: PageOrientationState + /** App-global geometry baseline, shared by every page. */ + previous: ResizeBaseline + next: { windowWidth: number, windowHeight: number, deviceOrientation: Orientation } +} + +export interface ResizeDispatchResult { + /** Gates `wx.onWindowResize`: fires only when the app-global baseline actually moved. */ + dispatchWindow: boolean + /** Gates `Page.onResize` / component `resize`: fires for whichever page is being reported. */ + dispatchPage: boolean +} + +/** + * Resize gating. Two channels with different rules: + * + * - The window channel (`wx.onWindowResize`) is app-global: it fires when the + * geometry moved against the baseline every page shares. + * One baseline for the whole mini-app, not one per page. + * - The page channel (`Page.onResize` / component `resize`) applies no geometry + * test at all: it carries whichever page the report names, so deciding WHEN to report is the host's job. + * + * Both stay silent for a page pinned to a fixed orientation. `resolveResizeDispatch` in dimina's `fe/packages/service/src/core/runtime.js` encodes the same two rules. + * + * A route commit reports its landing page unconditionally. + * Suppressing it when the geometry happens to match would leave a page returning from a landscape page into a still-landscape window rendering at the portrait rpx basis, with no callback to correct it — reporting the landing page is what keeps its layout answering to the window it is actually in. + * + * Deciding WHEN to report is the caller's job — see `OrientationController`, which publishes on every route commit and on device rotation. + * + * Main's `wx.onWindowResize` listener table forks off this same decision point — see `applyPageResize` in bridge-router.ts. + * Any change belongs in both. + */ +export function shouldDispatchResize( + { state, previous, next }: ResizeDispatchInput, +): ResizeDispatchResult { + const suppressed = computedOrientationConfig(state) !== 'auto' + + return { + dispatchWindow: movedAgainst(previous, next) && !suppressed, + dispatchPage: !suppressed, + } +} + +function movedAgainst( + baseline: ResizeBaseline, + next: { windowWidth: number, windowHeight: number, deviceOrientation: Orientation }, +): boolean { + return baseline.windowWidth !== next.windowWidth + || baseline.windowHeight !== next.windowHeight + || baseline.deviceOrientation !== next.deviceOrientation +} diff --git a/packages/dimina-electron-runtime/src/shared/page-resize-host-env.test.ts b/packages/dimina-electron-runtime/src/shared/page-resize-host-env.test.ts new file mode 100644 index 00000000..c0a0658d --- /dev/null +++ b/packages/dimina-electron-runtime/src/shared/page-resize-host-env.test.ts @@ -0,0 +1,68 @@ +/** + * The host-env patch a `PAGE_RESIZE` installs. + * + * Every geometry field it carries has to describe the SAME orientation — the one the page being resized is actually showing. + * A page pinned to landscape on a portrait phone gets landscape screen metrics, so it must also get landscape safe-area insets; pairing them with the device's portrait insets would report a top inset for a status bar that is not drawn and put the notch on an edge it does not occupy. + */ +import { describe, expect, it } from 'vitest' +import { pageResizeHostEnv } from './page-resize-host-env.js' +import type { NativeDeviceInfo } from './runtime-types.js' + +/** iPhone 14: notched, portrait baseline 390x844. */ +const DEVICE: NativeDeviceInfo = { + brand: 'Apple', + model: 'iPhone 14', + system: 'iOS 16.0', + platform: 'ios', + pixelRatio: 3, + screenWidth: 390, + screenHeight: 844, + statusBarHeight: 47, + notchType: 'dynamic-island', + safeAreaInsets: { top: 47, right: 0, bottom: 34, left: 0 }, + deviceOrientation: 'portrait', +} + +const LANDSCAPE_RESIZE = { + size: { windowWidth: 844, windowHeight: 346 }, + deviceOrientation: 'landscape' as const, +} + +const PORTRAIT_RESIZE = { + size: { windowWidth: 390, windowHeight: 753 }, + deviceOrientation: 'portrait' as const, +} + +describe('pageResizeHostEnv', () => { + it('always carries the reported window size and orientation', () => { + expect(pageResizeHostEnv(PORTRAIT_RESIZE, null)).toEqual({ + windowWidth: 390, + windowHeight: 753, + deviceOrientation: 'portrait', + }) + }) + + it('resolves the screen metrics against the resize orientation, not the device one', () => { + const patch = pageResizeHostEnv(LANDSCAPE_RESIZE, DEVICE) + expect(patch.screenWidth).toBe(844) + expect(patch.screenHeight).toBe(390) + expect(patch.statusBarHeight).toBe(0) + }) + + it('resolves the safe-area insets against the resize orientation too', () => { + const patch = pageResizeHostEnv(LANDSCAPE_RESIZE, DEVICE) + expect(patch.safeAreaInsets, 'a page drawn landscape must not keep the portrait insets') + .toEqual({ top: 0, right: 47, bottom: 21, left: 47 }) + }) + + it('keeps the portrait insets for a page drawn portrait on a rotated device', () => { + const patch = pageResizeHostEnv(PORTRAIT_RESIZE, { ...DEVICE, deviceOrientation: 'landscape' }) + expect(patch.safeAreaInsets).toEqual({ top: 47, right: 0, bottom: 34, left: 0 }) + expect(patch.screenWidth).toBe(390) + expect(patch.statusBarHeight).toBe(47) + }) + + it('leaves the insets alone when no device is selected', () => { + expect(pageResizeHostEnv(LANDSCAPE_RESIZE, null)).not.toHaveProperty('safeAreaInsets') + }) +}) diff --git a/packages/dimina-electron-runtime/src/shared/page-resize-host-env.ts b/packages/dimina-electron-runtime/src/shared/page-resize-host-env.ts new file mode 100644 index 00000000..64cff09f --- /dev/null +++ b/packages/dimina-electron-runtime/src/shared/page-resize-host-env.ts @@ -0,0 +1,33 @@ +/** + * The host-env fields a `PAGE_RESIZE` replaces — the page-driven counterpart to `deviceInfoToHostEnv` (shared/bridge-channels.ts), which answers the same question for a device change. + */ +import type { HostEnvSnapshot } from './bridge-channels.js' +import type { NativeDeviceInfo } from './runtime-types.js' +import { orientedDeviceMetrics, orientedSafeAreaInsets, type Orientation } from './page-orientation.js' + +/** + * Every field is resolved against the orientation the resized page is SHOWING rather than the device's own — a page pinned to landscape on a portrait phone reports landscape metrics. + * + * The safe-area insets travel with those metrics: `getSystemInfoSync` builds its `safeArea` rect by measuring the insets against the screen dimensions in the same snapshot (devtools service-host/sync-impls/system-info.ts), so leaving the device's portrait insets next to landscape dimensions would reserve a top edge for a status bar that is not drawn and put the notch on the wrong axis. + * Without a selected device only the size and the orientation are known. + */ +export function pageResizeHostEnv( + resize: { size: { windowWidth: number, windowHeight: number }, deviceOrientation: Orientation }, + device: NativeDeviceInfo | null, +): Partial { + const patch: Partial = { + windowWidth: resize.size.windowWidth, + windowHeight: resize.size.windowHeight, + deviceOrientation: resize.deviceOrientation, + } + if (!device) return patch + const metrics = orientedDeviceMetrics(device, resize.deviceOrientation) + patch.screenWidth = metrics.screenWidth + patch.screenHeight = metrics.screenHeight + patch.statusBarHeight = metrics.statusBarHeight + patch.safeAreaInsets = orientedSafeAreaInsets( + { statusBarHeight: device.statusBarHeight, hasNotch: device.notchType !== 'none', safeAreaInsets: device.safeAreaInsets }, + resize.deviceOrientation, + ) + return patch +} diff --git a/packages/dimina-electron-runtime/src/shared/page-window-size.test.ts b/packages/dimina-electron-runtime/src/shared/page-window-size.test.ts new file mode 100644 index 00000000..48057912 --- /dev/null +++ b/packages/dimina-electron-runtime/src/shared/page-window-size.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { + NAV_BAR_HEIGHT, + orientedDeviceMetrics, + pageWindowSize, + tabBarReservedHeight, + withPageWindowSize, +} from './page-orientation.js' + +/** iPhone 14 portrait baseline. */ +const device = { screenWidth: 390, screenHeight: 844, statusBarHeight: 47 } +const BOTTOM_INSET = 34 + +describe('pageWindowSize', () => { + it('reserves the status bar and the navigation bar on a default page', () => { + expect(pageWindowSize(device, { isTab: false, bottomInset: BOTTOM_INSET })).toEqual({ + windowWidth: 390, + windowHeight: 844 - 47 - NAV_BAR_HEIGHT, + }) + }) + + it('leaves the full screen height to a custom-navigation page', () => { + const size = pageWindowSize(device, { + navigationStyle: 'custom', + isTab: false, + bottomInset: BOTTOM_INSET, + }) + expect(size).toEqual({ windowWidth: 390, windowHeight: 844 }) + }) + + it('reserves the tab bar, its home-indicator padding and its border on a tab page', () => { + expect(tabBarReservedHeight(BOTTOM_INSET)).toBe(85) + const size = pageWindowSize(device, { isTab: true, bottomInset: BOTTOM_INSET }) + expect(size.windowHeight).toBe(844 - 47 - NAV_BAR_HEIGHT - 85) + }) + + it('drops the status bar but keeps the navigation bar in landscape', () => { + const oriented = orientedDeviceMetrics(device, 'landscape') + expect(pageWindowSize(oriented, { isTab: false, bottomInset: BOTTOM_INSET })).toEqual({ + windowWidth: 844, + windowHeight: 390 - NAV_BAR_HEIGHT, + }) + }) + + it('never reports a negative height when the chrome exceeds the screen', () => { + const tiny = { screenWidth: 100, screenHeight: 40, statusBarHeight: 47 } + expect(pageWindowSize(tiny, { isTab: true, bottomInset: BOTTOM_INSET }).windowHeight).toBe(0) + }) +}) + +describe('withPageWindowSize', () => { + /** A host-env seed carries the device's screen, so its window fields start as the whole screen minus the status bar. */ + const seed = { + screenWidth: 390, + screenHeight: 844, + statusBarHeight: 47, + windowWidth: 390, + windowHeight: 844 - 47, + model: 'iPhone 14', + } + + it('replaces the window size with what the page chrome leaves', () => { + const seeded = withPageWindowSize(seed, { isTab: false, bottomInset: BOTTOM_INSET }) + expect(seeded.windowHeight).toBe(844 - 47 - NAV_BAR_HEIGHT) + expect(seeded.windowWidth).toBe(390) + }) + + it('agrees with the size the shell measures for the same page', () => { + const chrome = { navigationStyle: 'default' as const, isTab: true, bottomInset: BOTTOM_INSET } + const seeded = withPageWindowSize(seed, chrome) + const measured = pageWindowSize(orientedDeviceMetrics(device, 'portrait'), chrome) + expect({ windowWidth: seeded.windowWidth, windowHeight: seeded.windowHeight }).toEqual(measured) + }) + + it('keeps every other snapshot field untouched', () => { + const seeded = withPageWindowSize(seed, { isTab: false, bottomInset: BOTTOM_INSET }) + expect(seeded.model).toBe('iPhone 14') + expect(seeded.screenHeight).toBe(844) + expect(seeded.statusBarHeight).toBe(47) + }) +}) diff --git a/packages/dimina-electron-runtime/src/shared/runtime-types.ts b/packages/dimina-electron-runtime/src/shared/runtime-types.ts index 3205ed54..d2e4be38 100644 --- a/packages/dimina-electron-runtime/src/shared/runtime-types.ts +++ b/packages/dimina-electron-runtime/src/shared/runtime-types.ts @@ -1,3 +1,5 @@ +import type { Orientation } from './page-orientation.js' + export type NotchType = 'none' | 'notch' | 'dynamic-island' export interface SafeAreaInsets { @@ -19,6 +21,10 @@ export interface NativeDeviceInfo { statusBarHeight: number notchType: NotchType safeAreaInsets: SafeAreaInsets + /** + * Orientation of the simulated device itself, which the user controls and which survives across mini-app sessions. `screenWidth`/`screenHeight` stay portrait-baseline regardless; consumers derive the rotated metrics through `orientedDeviceMetrics`. + */ + deviceOrientation?: Orientation } /** Change emitted by synchronous storage APIs running in the service host. */ diff --git a/packages/dimina-electron-runtime/src/shared/service-host-channels.ts b/packages/dimina-electron-runtime/src/shared/service-host-channels.ts new file mode 100644 index 00000000..f1280b41 --- /dev/null +++ b/packages/dimina-electron-runtime/src/shared/service-host-channels.ts @@ -0,0 +1,9 @@ +/** + * Electron IPC channels the main process uses to talk to a service-host window directly, bypassing the mini-app message bus. + * + * `HostEnvUpdate` patches the spawn context's `hostEnvSnapshot` — the object the synchronous host APIs (`wx.getSystemInfoSync`, `wx.getWindowInfo`, …) read on every call. + * It is the ONLY way those APIs learn about new geometry: the framework-level `hostEnvUpdate` bus message feeds dimina's own host-env store instead, so a writer that needs both must send both. + */ +export const SERVICE_HOST_CHANNELS = { + HostEnvUpdate: 'service-host:host-env:update', +} as const diff --git a/packages/dimina-electron-runtime/src/simulator-ui/home-button-rule.test.ts b/packages/dimina-electron-runtime/src/simulator-ui/home-button-rule.test.ts index 98ad00af..0c266b89 100644 --- a/packages/dimina-electron-runtime/src/simulator-ui/home-button-rule.test.ts +++ b/packages/dimina-electron-runtime/src/simulator-ui/home-button-rule.test.ts @@ -26,7 +26,7 @@ */ import { describe, expect, it } from 'vitest' import type { PageWindowConfig, TabBarConfig } from '../shared/bridge-channels.js' -import { navBarFromConfig } from './page-stack-controller.js' +import { navBarFromConfig } from './navigation-bar-config.js' import { resolveHomeNavAction, shouldShowHomeButton } from './navigate-home.js' const HOME = 'pages/home/home' diff --git a/packages/dimina-electron-runtime/src/simulator-ui/miniapp-frame-nav-serialization.test.tsx b/packages/dimina-electron-runtime/src/simulator-ui/miniapp-frame-nav-serialization.test.tsx index 8a1021f1..3a1e020c 100644 --- a/packages/dimina-electron-runtime/src/simulator-ui/miniapp-frame-nav-serialization.test.tsx +++ b/packages/dimina-electron-runtime/src/simulator-ui/miniapp-frame-nav-serialization.test.tsx @@ -114,8 +114,11 @@ describe('MiniAppFrame — the home button is clicked twice inside one tick', () expect(latestStack(recorder)).toEqual([HOME_PAGE]) expect(visiblePagePath(container)).toBe(HOME_PAGE) expect(recorder.closedPages).toEqual([ROOT_BRIDGE_ID]) + const home = recorder.openedEntries.find((page) => page.pagePath === HOME_PAGE)! expect(recorder.lifecycles).toEqual([ + { bridgeId: ROOT_BRIDGE_ID, event: 'pageShow' }, { bridgeId: ROOT_BRIDGE_ID, event: 'pageUnload' }, + { bridgeId: home.bridgeId, event: 'pageShow' }, ]) // Ledger: an opened page is either still mounted or was handed back to the // host for teardown. Anything else is a render host nobody owns. diff --git a/packages/dimina-electron-runtime/src/simulator-ui/miniapp-frame-navigate-home-idempotent.test.tsx b/packages/dimina-electron-runtime/src/simulator-ui/miniapp-frame-navigate-home-idempotent.test.tsx index aff6b23c..98d0ba5d 100644 --- a/packages/dimina-electron-runtime/src/simulator-ui/miniapp-frame-navigate-home-idempotent.test.tsx +++ b/packages/dimina-electron-runtime/src/simulator-ui/miniapp-frame-navigate-home-idempotent.test.tsx @@ -40,9 +40,12 @@ describe('MiniAppFrame — navigateHome runs again on the home page', () => { expect(visiblePagePath(container)).toBe(HOME_PAGE) }) - // The trip to home tears down the launch page and says so over the bridge. + // The trip to home tears down the launch page and says so over the bridge, and the home page it lands on is announced as the new visible top. + const home = recorder.openedEntries.find((page) => page.pagePath === HOME_PAGE)! expect(recorder.lifecycles).toEqual([ + { bridgeId: ROOT_BRIDGE_ID, event: 'pageShow' }, { bridgeId: ROOT_BRIDGE_ID, event: 'pageUnload' }, + { bridgeId: home.bridgeId, event: 'pageShow' }, ]) const openedBefore = recorder.openedPages.length const closedBefore = recorder.closedPages.length diff --git a/packages/dimina-electron-runtime/src/simulator-ui/miniapp-frame-switch-tab-orphan.test.tsx b/packages/dimina-electron-runtime/src/simulator-ui/miniapp-frame-switch-tab-orphan.test.tsx index b99146e9..948bcc93 100644 --- a/packages/dimina-electron-runtime/src/simulator-ui/miniapp-frame-switch-tab-orphan.test.tsx +++ b/packages/dimina-electron-runtime/src/simulator-ui/miniapp-frame-switch-tab-orphan.test.tsx @@ -84,11 +84,13 @@ describe('MiniAppFrame — a deep-linked non-tab launch page is left behind by s await serviceNav(recorder, 'switchTab', HOME_PAGE) expect(countClosed(recorder.closedPages, ROOT_BRIDGE_ID)).toBe(1) - // The service layer hears about the page leaving the screen and dying only - // through these bridge calls, so the delivered sequence is the assertion. + // The service layer hears about a page reaching the screen, leaving it and dying only through these bridge calls, so the delivered sequence is the assertion. + const tab = recorder.openedEntries.find((page) => page.pagePath === HOME_PAGE)! expect(recorder.lifecycles).toEqual([ + { bridgeId: ROOT_BRIDGE_ID, event: 'pageShow' }, { bridgeId: ROOT_BRIDGE_ID, event: 'pageHide' }, { bridgeId: ROOT_BRIDGE_ID, event: 'pageUnload' }, + { bridgeId: tab.bridgeId, event: 'pageShow' }, ]) }) }) diff --git a/packages/dimina-electron-runtime/src/simulator-ui/miniapp-frame.tsx b/packages/dimina-electron-runtime/src/simulator-ui/miniapp-frame.tsx index 731eb46a..be214294 100644 --- a/packages/dimina-electron-runtime/src/simulator-ui/miniapp-frame.tsx +++ b/packages/dimina-electron-runtime/src/simulator-ui/miniapp-frame.tsx @@ -31,14 +31,11 @@ import { enumerateMounted, makeInitialShellState, mutatePageNavBar, - navBarFromConfig, - normalizePath, pageBackgroundColor, - reduceNavBar, type PageEntry, type SideEffect, } from './page-stack-controller.js' -import { shouldShowHomeButton } from './navigate-home.js' +import { reduceNavBar } from './navigation-bar-config.js' import { commitShell, commitTabBar, @@ -48,6 +45,7 @@ import { doReLaunch, doRedirectTo, doSwitchTab, + makeLaunchPageEntry, type MiniAppFrameState, type ShellNavPayload, } from './miniapp-routing.js' @@ -73,6 +71,12 @@ export interface FrameChromeState { textStyle: NavigationBarTextStyle } +export interface MiniAppFrameLayoutState { + top: PageEntry + mounted: ReturnType + tabBarVisible: boolean +} + export interface MiniAppFrameProps { host: MiniAppHost /** The bridgeId of the page the host already spawned — the stack bottom. */ @@ -100,6 +104,10 @@ export interface MiniAppFrameProps { statusBar?: (chrome: FrameChromeState) => ReactNode /** Host chrome drawn above everything — extension layers, a home indicator. */ deviceOverlay?: ReactNode + /** Host-owned geometry authority can observe the committed page/layout state. */ + onLayoutState?: (state: MiniAppFrameLayoutState) => void + /** Publishes geometry synchronously before a tab-bar API is acknowledged. */ + onLayoutCommit?: (state: MiniAppFrameLayoutState) => void } export function MiniAppFrame({ @@ -111,35 +119,16 @@ export function MiniAppFrame({ onMore, statusBar, deviceOverlay, + onLayoutState, + onLayoutCommit, }: MiniAppFrameProps) { const preload = useMemo(() => host.getRenderPreloadUrl(), [host]) const tabBarConfig = useMemo(() => host.getTabBarConfig(), [host]) - const initialEntry = useMemo(() => { - const pagePath = normalizePath(host.pagePath) - const windowConfig = host.rootWindowConfig ?? {} - const isTab = !!host.getTabBarConfig()?.list.some( - item => normalizePath(item.pagePath) === pagePath, - ) - return { - bridgeId, - pagePath, - query: { ...host.query }, - isTab, - windowConfig, - // The launch page is the stack bottom, so a non-home, non-tab launch - // page gets the home button by the automatic rule. - navBar: navBarFromConfig(windowConfig, host.appId, { - homeButtonVisible: shouldShowHomeButton({ - pagePath, - homePagePath: host.getHomePagePath(), - isTab, - isStackBottom: true, - forcedByConfig: windowConfig.homeButton === true, - }), - }), - } - }, [host, bridgeId]) + const initialEntry = useMemo( + () => makeLaunchPageEntry(host, bridgeId), + [host, bridgeId], + ) const [{ shell, tabBar }, setState] = useState(() => ({ shell: makeInitialShellState(initialEntry), @@ -155,6 +144,16 @@ export function MiniAppFrame({ const stateRef = useRef({ shell, tabBar }) const applySideEffects = useCallback((effects: SideEffect[]) => { + // 几何要先于 pageShow 到达主进程:模拟器里 `getSystemInfoSync` 读的是主进程缓存的 hostEnv 快照,`onShow` 里同步读到的必须已经是落地页自己的尺寸。 + // 三端 native 不需要这一步,它们的同步接口每次都现读窗口。 + // + // 这条上报因此排在 pageShow 之前,而 service 的 pageResize 不能因为「这一页还没 show」就把它丢掉——收件人在 16ms 合并窗结算时才定,那时 pageShow 早已送达(见 fe/packages/service/src/core/runtime.js 的 pageResize/settleResize)。 + const currentShell = stateRef.current.shell + onLayoutCommit?.({ + top: currentShell.stack[currentShell.stack.length - 1], + mounted: enumerateMounted(currentShell), + tabBarVisible: stateRef.current.tabBar.visible, + }) for (const effect of effects) { if (effect.kind === 'lifecycle') { host.notifyLifecycle(effect.bridgeId, effect.event) @@ -162,7 +161,7 @@ export function MiniAppFrame({ host.closePage(effect.bridgeId) } } - }, [host]) + }, [host, onLayoutCommit]) // ── NavigationBar dynamic updates ────────────────────────────────────────── useEffect(() => { @@ -183,12 +182,21 @@ export function MiniAppFrame({ // ── TabBar dynamic API ──────────────────────────────────────────────────── useEffect(() => { const listener = (payload: TabActionPayload) => { - const next = applyTabAction(stateRef.current.tabBar, { + const previous = stateRef.current.tabBar + const next = applyTabAction(previous, { kind: 'apply', name: payload.name, params: payload.params, }) commitTabBar(stateRef, setState, next.state) + if (previous.visible !== next.state.visible) { + const currentShell = stateRef.current.shell + onLayoutCommit?.({ + top: currentShell.stack[currentShell.stack.length - 1], + mounted: enumerateMounted(currentShell), + tabBarVisible: next.state.visible, + }) + } host.notifyNavCallback({ ok: next.ok, errMsg: next.errMsg, @@ -196,7 +204,7 @@ export function MiniAppFrame({ }) } return host.onSessionEvent(E.TAB_ACTION, listener) - }, [host]) + }, [host, onLayoutCommit]) // ── Routing controller (navigateTo / Back / redirectTo / reLaunch / switchTab / Home) ─ // Every routing operation opens its page asynchronously and only then reads @@ -314,6 +322,9 @@ export function MiniAppFrame({ // ── Rendering ───────────────────────────────────────────────────────────── const top = shell.stack[shell.stack.length - 1] const mounted = enumerateMounted(shell) + useEffect(() => { + onLayoutState?.({ top, mounted, tabBarVisible: tabBar.visible }) + }, [mounted, onLayoutState, tabBar.visible, top]) const handleMore = useCallback(() => { onMore?.({ appId: host.appId, @@ -345,6 +356,12 @@ export function MiniAppFrame({ host.notifyActivePage(top.bridgeId) }, [host, top.bridgeId]) + // The launch page is the one page no routing reduction ever installs, so it is also the one page nothing else would announce as visible — every other top gets its pageShow from the reducers (see showTop in page-stack-controller). + // Declared after the layout effects above so the page's geometry is published before its onShow runs, same order routing transitions get from applySideEffects. + useEffect(() => { + host.notifyLifecycle(initialEntry.bridgeId, 'pageShow') + }, [host, initialEntry.bridgeId]) + return ( <> {statusBar?.({ textStyle: top.navBar.textStyle })} diff --git a/packages/dimina-electron-runtime/src/simulator-ui/miniapp-host.ts b/packages/dimina-electron-runtime/src/simulator-ui/miniapp-host.ts index f2966107..7c446eab 100644 --- a/packages/dimina-electron-runtime/src/simulator-ui/miniapp-host.ts +++ b/packages/dimina-electron-runtime/src/simulator-ui/miniapp-host.ts @@ -13,6 +13,7 @@ * not by importing anything from the runtime. */ import type { + ApiResponsePayload, NavCallbackPayload, PageLifecycleEvent, PageOpenResult, @@ -20,6 +21,7 @@ import type { PageWindowConfig, TabBarConfig, } from '../shared/bridge-channels.js' +import type { PageResizePayload } from '../shared/page-orientation.js' export interface MiniAppHost { readonly appId: string @@ -55,6 +57,9 @@ export interface MiniAppHost { closePage(bridgeId: string): void notifyLifecycle(bridgeId: string, event: PageLifecycleEvent): void notifyNavCallback(payload: Omit): void + notifyApiResponse?(payload: Omit): void + /** Publish the visible top page's authoritative window geometry. */ + notifyResize?(payload: PageResizePayload): void /** Which page is the visible top of stack — panels and automation target it. */ notifyActivePage(bridgeId: string): void /** The full ordered stack, bottom→top, on every stack change. */ diff --git a/packages/dimina-electron-runtime/src/simulator-ui/miniapp-routing.ts b/packages/dimina-electron-runtime/src/simulator-ui/miniapp-routing.ts index cdeb6217..a5401262 100644 --- a/packages/dimina-electron-runtime/src/simulator-ui/miniapp-routing.ts +++ b/packages/dimina-electron-runtime/src/simulator-ui/miniapp-routing.ts @@ -12,8 +12,8 @@ import type { Dispatch, MutableRefObject, SetStateAction } from 'react' import type { NavActionPayload } from '../shared/bridge-channels.js' import type { MiniAppHost } from './miniapp-host.js' +import { navBarFromConfig } from './navigation-bar-config.js' import { - navBarFromConfig, normalizePath, parseUrl, reduceNavigateBack, @@ -101,6 +101,20 @@ function makePageEntry( } } +/** + * The launch page's PageEntry: the stack bottom the host already spawned before MiniAppFrame mounted. + * Exported because the embedding device host has to seed its own mirror of the frame's layout from the same values — it publishes the page's window geometry, and `navigationStyle` decides whether a navigation bar is reserved out of that window. + * A second hand-written seed drifts. + */ +export function makeLaunchPageEntry(host: MiniAppHost, bridgeId: string): PageEntry { + const pagePath = normalizePath(host.pagePath) + const windowConfig = host.rootWindowConfig ?? {} + const isTab = !!host.getTabBarConfig()?.list.some( + item => normalizePath(item.pagePath) === pagePath, + ) + return makePageEntry(host, { bridgeId, pagePath, isTab, windowConfig }, { ...host.query }, true) +} + export async function doNavigateTo( host: MiniAppHost, ref: StateRef, diff --git a/packages/dimina-electron-runtime/src/simulator-ui/navigate-home-reduce.test.ts b/packages/dimina-electron-runtime/src/simulator-ui/navigate-home-reduce.test.ts index ce44b066..705ec33c 100644 --- a/packages/dimina-electron-runtime/src/simulator-ui/navigate-home-reduce.test.ts +++ b/packages/dimina-electron-runtime/src/simulator-ui/navigate-home-reduce.test.ts @@ -151,9 +151,9 @@ describe('reduceNavigateHomeToTab — deep link into a non-tab page, no tab visi expect(closedIds(effects)).toEqual(['d']) }) - it('emits no pageShow for a freshly opened root, whose renderer reports its own', () => { + it('shows the freshly opened root: nothing else tells the service it is visible', () => { const { effects } = reduceNavigateHomeToTab(state, TAB_A, tabA) - expect(lifecycleIds(effects, 'pageShow')).toEqual([]) + expect(lifecycleIds(effects, 'pageShow')).toEqual(['a-root']) }) }) diff --git a/packages/dimina-electron-runtime/src/simulator-ui/navigate-home.ts b/packages/dimina-electron-runtime/src/simulator-ui/navigate-home.ts index b63bc71d..61efef84 100644 --- a/packages/dimina-electron-runtime/src/simulator-ui/navigate-home.ts +++ b/packages/dimina-electron-runtime/src/simulator-ui/navigate-home.ts @@ -115,9 +115,8 @@ export function reduceNavigateHomeToTab( if (prevTop && prevTop.bridgeId !== homeRoot.bridgeId && survivors.has(prevTop.bridgeId)) { effects.push({ kind: 'lifecycle', bridgeId: prevTop.bridgeId, event: 'pageHide' }) } - if (cachedRoot) { - // Restored from cache — a freshly opened page gets its own lifecycle from - // the renderer init path. + if (!prevTop || prevTop.bridgeId !== homeRoot.bridgeId) { + // Restored from cache or opened for this transition — either way the home root is the visible top now and the service only learns that from a pageShow (see page-stack-controller's showTop). effects.push({ kind: 'lifecycle', bridgeId: homeRoot.bridgeId, event: 'pageShow' }) } diff --git a/packages/dimina-electron-runtime/src/simulator-ui/navigation-bar-config.test.ts b/packages/dimina-electron-runtime/src/simulator-ui/navigation-bar-config.test.ts new file mode 100644 index 00000000..b2bba838 --- /dev/null +++ b/packages/dimina-electron-runtime/src/simulator-ui/navigation-bar-config.test.ts @@ -0,0 +1,133 @@ +/** + * `NavigationBarState` producers: what a page's merged window config implies, and what the dynamic `wx.setNavigationBar*` / `wx.hideHomeButton` calls do to it afterwards. + */ +import { describe, it, expect } from 'vitest' +import { applyColorMutation, navBarFromConfig, reduceNavBar } from './navigation-bar-config.js' +import { makeDefaultNavigationBarState, type NavigationBarState } from './navigation-bar.js' + +function makeNavBar(overrides: Partial = {}): NavigationBarState { + return makeDefaultNavigationBarState({ + title: '', + backgroundColor: '#000000', + textStyle: 'white', + style: 'default', + homeButtonVisible: false, + loading: false, + ...overrides, + }) +} + +// ── navBarFromConfig ───────────────────────────────────────────────────── + +describe('navBarFromConfig', () => { + it('falls back to defaults (#ffffff bg, black text, default style) and uses fallback title when config is empty', () => { + const state = navBarFromConfig({}, 'my-app-id') + expect(state).toMatchObject({ + title: 'my-app-id', + backgroundColor: '#ffffff', + textStyle: 'black', + style: 'default', + homeButtonVisible: false, + }) + }) + + it('uses navigationBarTitleText when supplied (overriding the fallback)', () => { + expect(navBarFromConfig({ navigationBarTitleText: 'Hello' }, 'fallback').title).toBe('Hello') + }) + + it('respects navigationBarTextStyle: white', () => { + expect(navBarFromConfig({ navigationBarTextStyle: 'white' }, 'x').textStyle).toBe('white') + }) + + it('respects a custom navigationBarBackgroundColor', () => { + expect(navBarFromConfig({ navigationBarBackgroundColor: '#abcdef' }, 'x').backgroundColor).toBe('#abcdef') + }) + + it("respects navigationStyle: 'custom'", () => { + expect(navBarFromConfig({ navigationStyle: 'custom' }, 'x').style).toBe('custom') + }) + + it('shows the home button only when config.homeButton === true (strict equality)', () => { + expect(navBarFromConfig({ homeButton: true }, 'x').homeButtonVisible).toBe(true) + // Defensive: non-true truthy values are rejected. + expect(navBarFromConfig({ homeButton: 1 as unknown as boolean }, 'x').homeButtonVisible).toBe(false) + }) +}) + +// ── reduceNavBar ───────────────────────────────────────────────────────── + +describe('reduceNavBar', () => { + it('setNavigationBarTitle updates the title field', () => { + const next = reduceNavBar(makeNavBar({ title: 'old' }), 'setNavigationBarTitle', { title: 'new' }) + expect(next.title).toBe('new') + }) + + it('setNavigationBarColor delegates to applyColorMutation (frontColor white → textStyle white)', () => { + const next = reduceNavBar(makeNavBar({ textStyle: 'black' }), 'setNavigationBarColor', { frontColor: '#ffffff' }) + expect(next.textStyle).toBe('white') + }) + + it('showNavigationBarLoading flips loading=true', () => { + expect(reduceNavBar(makeNavBar({ loading: false }), 'showNavigationBarLoading', {}).loading).toBe(true) + }) + + it('hideNavigationBarLoading flips loading=false', () => { + expect(reduceNavBar(makeNavBar({ loading: true }), 'hideNavigationBarLoading', {}).loading).toBe(false) + }) + + it('hideHomeButton flips homeButtonVisible=false', () => { + expect(reduceNavBar(makeNavBar({ homeButtonVisible: true }), 'hideHomeButton', {}).homeButtonVisible).toBe(false) + }) + + it('returns the same state reference for unknown API names (no mutation, no throw)', () => { + const prev = makeNavBar({ title: 'unchanged' }) + const next = reduceNavBar(prev, 'wxBananaApi', {}) + expect(next).toBe(prev) + }) +}) + +// ── applyColorMutation ──────────────────────────────────────────────────── + +describe('applyColorMutation', () => { + it('frontColor #ffffff (any case) sets textStyle=white', () => { + expect(applyColorMutation(makeNavBar({ textStyle: 'black' }), { frontColor: '#FFFFFF' }).textStyle).toBe('white') + }) + + it('frontColor #000000 sets textStyle=black', () => { + expect(applyColorMutation(makeNavBar({ textStyle: 'white' }), { frontColor: '#000000' }).textStyle).toBe('black') + }) + + it('illegal frontColor (e.g. #ff0000) keeps the previous textStyle', () => { + const prev = makeNavBar({ textStyle: 'white' }) + expect(applyColorMutation(prev, { frontColor: '#ff0000' }).textStyle).toBe('white') + }) + + it('passes through backgroundColor when supplied as a string', () => { + expect(applyColorMutation(makeNavBar(), { backgroundColor: '#123456' }).backgroundColor).toBe('#123456') + }) + + it('animation: whitelisted timingFunc (easeIn) is preserved with duration in ms', () => { + const next = applyColorMutation(makeNavBar(), { + animation: { duration: 250, timingFunc: 'easeIn' }, + }) + expect(next.colorAnimation).toEqual({ durationMs: 250, timingFunc: 'easeIn' }) + }) + + it("animation: non-whitelisted timingFunc (e.g. 'bounce') falls back to 'linear'", () => { + const next = applyColorMutation(makeNavBar(), { + animation: { duration: 100, timingFunc: 'bounce' }, + }) + expect(next.colorAnimation?.timingFunc).toBe('linear') + }) + + it('animation: NaN duration clamps to 0 (defensive)', () => { + const next = applyColorMutation(makeNavBar(), { + animation: { duration: Number.NaN, timingFunc: 'linear' }, + }) + expect(next.colorAnimation?.durationMs).toBe(0) + }) + + it('returns undefined colorAnimation when no animation field is supplied', () => { + expect(applyColorMutation(makeNavBar(), {}).colorAnimation).toBeUndefined() + }) +}) diff --git a/packages/dimina-electron-runtime/src/simulator-ui/navigation-bar-config.ts b/packages/dimina-electron-runtime/src/simulator-ui/navigation-bar-config.ts new file mode 100644 index 00000000..ebdf0d6a --- /dev/null +++ b/packages/dimina-electron-runtime/src/simulator-ui/navigation-bar-config.ts @@ -0,0 +1,95 @@ +/** + * Everything that produces a `NavigationBarState`: the initial state a page's merged window config implies, and the dynamic `wx.setNavigationBar*` / `wx.hideHomeButton` mutations applied over it afterwards. + * + * Kept apart from the page-stack reducers — these touch one page's bar, never the stack — and from `navigation-bar.tsx`, which only renders the state. + */ +import type { PageWindowConfig } from '../shared/bridge-channels.js' +import { makeDefaultNavigationBarState, type NavigationBarState } from './navigation-bar.js' + +/** + * Build the initial NavigationBar state from a page's merged window config (app-config.json `window` ∪ page-level overrides). + * The fallback title is used when `navigationBarTitleText` is unset (typically the appId). `opts.homeButtonVisible` sets the home button verbatim — callers that know the page's stack position pass the `shouldShowHomeButton` verdict here so the home/tab exclusions apply. + * Without it only the page config speaks. + */ +export function navBarFromConfig( + config: PageWindowConfig, + fallbackTitle: string, + opts?: { homeButtonVisible?: boolean }, +): NavigationBarState { + const background = (config.navigationBarBackgroundColor as string | undefined) ?? '#ffffff' + const text = (config.navigationBarTextStyle as 'black' | 'white' | undefined) ?? 'black' + const style = (config.navigationStyle as 'default' | 'custom' | undefined) ?? 'default' + const title = (config.navigationBarTitleText as string | undefined) ?? fallbackTitle + const homeButtonVisible = opts?.homeButtonVisible ?? (config.homeButton === true) + return makeDefaultNavigationBarState({ + title, + backgroundColor: background, + textStyle: text, + style, + homeButtonVisible, + }) +} + +/** + * Reduce one of the dynamic NavigationBar APIs (setNavigationBarTitle / setNavigationBarColor / show|hideNavigationBarLoading / hideHomeButton) over a page's nav-bar state. + * Unknown names fall through to `prev`. + */ +export function reduceNavBar( + prev: NavigationBarState, + name: string, + params: Record, +): NavigationBarState { + switch (name) { + case 'setNavigationBarTitle': + return { ...prev, title: typeof params.title === 'string' ? params.title : prev.title } + case 'setNavigationBarColor': + return applyColorMutation(prev, params) + case 'showNavigationBarLoading': + return { ...prev, loading: true } + case 'hideNavigationBarLoading': + return { ...prev, loading: false } + case 'hideHomeButton': + return { ...prev, homeButtonVisible: false } + default: + return prev + } +} + +const ALLOWED_TIMING_FUNCS = ['linear', 'easeIn', 'easeOut', 'easeInOut'] as const +type TimingFunc = typeof ALLOWED_TIMING_FUNCS[number] + +/** + * Apply `wx.setNavigationBarColor` to a navBar state: + * - frontColor must be `#ffffff` or `#000000` (WeChat constraint); other + * values are ignored and previous textStyle is preserved. + * - backgroundColor passes through if it's a string. + * - animation `{ duration, timingFunc }` is normalized to ms + a whitelisted + * timingFunc, defaulting to 0ms / linear when missing or invalid. + */ +export function applyColorMutation( + prev: NavigationBarState, + params: Record, +): NavigationBarState { + const front = typeof params.frontColor === 'string' ? params.frontColor.toLowerCase() : undefined + const textStyle = front === '#ffffff' ? 'white' : front === '#000000' ? 'black' : prev.textStyle + const background = typeof params.backgroundColor === 'string' ? params.backgroundColor : prev.backgroundColor + + const animation = (() => { + const raw = params.animation + if (!raw || typeof raw !== 'object') return undefined + const obj = raw as Record + const duration = typeof obj.duration === 'number' && Number.isFinite(obj.duration) ? Math.max(0, obj.duration) : 0 + const timing = typeof obj.timingFunc === 'string' ? obj.timingFunc : 'linear' + const timingFunc: TimingFunc = (ALLOWED_TIMING_FUNCS as readonly string[]).includes(timing) + ? (timing as TimingFunc) + : 'linear' + return { durationMs: duration, timingFunc } + })() + + return { + ...prev, + textStyle, + backgroundColor: background, + colorAnimation: animation, + } +} diff --git a/packages/dimina-electron-runtime/src/simulator-ui/page-stack-controller.test.ts b/packages/dimina-electron-runtime/src/simulator-ui/page-stack-controller.test.ts index c03c4792..60258adf 100644 --- a/packages/dimina-electron-runtime/src/simulator-ui/page-stack-controller.test.ts +++ b/packages/dimina-electron-runtime/src/simulator-ui/page-stack-controller.test.ts @@ -1,14 +1,11 @@ import { describe, it, expect } from 'vitest' import { - applyColorMutation, enumerateMounted, makeInitialShellState, mutatePageNavBar, - navBarFromConfig, normalizePath, pageBackgroundColor, parseUrl, - reduceNavBar, reduceNavigateBack, reduceNavigateTo, reduceReLaunch, @@ -17,19 +14,7 @@ import { type PageEntry, type ShellState, } from './page-stack-controller.js' -import { makeDefaultNavigationBarState, type NavigationBarState } from './navigation-bar.js' - -function makeNavBar(overrides: Partial = {}): NavigationBarState { - return makeDefaultNavigationBarState({ - title: '', - backgroundColor: '#000000', - textStyle: 'white', - style: 'default', - homeButtonVisible: false, - loading: false, - ...overrides, - }) -} +import { makeDefaultNavigationBarState } from './navigation-bar.js' // ── helpers ─────────────────────────────────────────────────────────────── @@ -133,7 +118,10 @@ describe('reduceNavigateTo', () => { expect(bridgeIds(next.stack)).toEqual([tabA.bridgeId, page1.bridgeId]) expect(bridgeIds(next.tabStacks[tabA.pagePath])).toEqual([tabA.bridgeId, page1.bridgeId]) - expect(effects).toEqual([{ kind: 'lifecycle', bridgeId: tabA.bridgeId, event: 'pageHide' }]) + expect(effects).toEqual([ + { kind: 'lifecycle', bridgeId: tabA.bridgeId, event: 'pageHide' }, + { kind: 'lifecycle', bridgeId: page1.bridgeId, event: 'pageShow' }, + ]) }) }) @@ -374,43 +362,6 @@ describe('normalizePath', () => { }) }) -// ── navBarFromConfig ───────────────────────────────────────────────────── - -describe('navBarFromConfig', () => { - it('falls back to defaults (#ffffff bg, black text, default style) and uses fallback title when config is empty', () => { - const state = navBarFromConfig({}, 'my-app-id') - expect(state).toMatchObject({ - title: 'my-app-id', - backgroundColor: '#ffffff', - textStyle: 'black', - style: 'default', - homeButtonVisible: false, - }) - }) - - it('uses navigationBarTitleText when supplied (overriding the fallback)', () => { - expect(navBarFromConfig({ navigationBarTitleText: 'Hello' }, 'fallback').title).toBe('Hello') - }) - - it('respects navigationBarTextStyle: white', () => { - expect(navBarFromConfig({ navigationBarTextStyle: 'white' }, 'x').textStyle).toBe('white') - }) - - it('respects a custom navigationBarBackgroundColor', () => { - expect(navBarFromConfig({ navigationBarBackgroundColor: '#abcdef' }, 'x').backgroundColor).toBe('#abcdef') - }) - - it("respects navigationStyle: 'custom'", () => { - expect(navBarFromConfig({ navigationStyle: 'custom' }, 'x').style).toBe('custom') - }) - - it('shows the home button only when config.homeButton === true (strict equality)', () => { - expect(navBarFromConfig({ homeButton: true }, 'x').homeButtonVisible).toBe(true) - // Defensive: non-true truthy values are rejected. - expect(navBarFromConfig({ homeButton: 1 as unknown as boolean }, 'x').homeButtonVisible).toBe(false) - }) -}) - // ── pageBackgroundColor ──────────────────────────────────────────────────── describe('pageBackgroundColor', () => { @@ -423,84 +374,6 @@ describe('pageBackgroundColor', () => { }) }) -// ── reduceNavBar ───────────────────────────────────────────────────────── - -describe('reduceNavBar', () => { - it('setNavigationBarTitle updates the title field', () => { - const next = reduceNavBar(makeNavBar({ title: 'old' }), 'setNavigationBarTitle', { title: 'new' }) - expect(next.title).toBe('new') - }) - - it('setNavigationBarColor delegates to applyColorMutation (frontColor white → textStyle white)', () => { - const next = reduceNavBar(makeNavBar({ textStyle: 'black' }), 'setNavigationBarColor', { frontColor: '#ffffff' }) - expect(next.textStyle).toBe('white') - }) - - it('showNavigationBarLoading flips loading=true', () => { - expect(reduceNavBar(makeNavBar({ loading: false }), 'showNavigationBarLoading', {}).loading).toBe(true) - }) - - it('hideNavigationBarLoading flips loading=false', () => { - expect(reduceNavBar(makeNavBar({ loading: true }), 'hideNavigationBarLoading', {}).loading).toBe(false) - }) - - it('hideHomeButton flips homeButtonVisible=false', () => { - expect(reduceNavBar(makeNavBar({ homeButtonVisible: true }), 'hideHomeButton', {}).homeButtonVisible).toBe(false) - }) - - it('returns the same state reference for unknown API names (no mutation, no throw)', () => { - const prev = makeNavBar({ title: 'unchanged' }) - const next = reduceNavBar(prev, 'wxBananaApi', {}) - expect(next).toBe(prev) - }) -}) - -// ── applyColorMutation ──────────────────────────────────────────────────── - -describe('applyColorMutation', () => { - it('frontColor #ffffff (any case) sets textStyle=white', () => { - expect(applyColorMutation(makeNavBar({ textStyle: 'black' }), { frontColor: '#FFFFFF' }).textStyle).toBe('white') - }) - - it('frontColor #000000 sets textStyle=black', () => { - expect(applyColorMutation(makeNavBar({ textStyle: 'white' }), { frontColor: '#000000' }).textStyle).toBe('black') - }) - - it('illegal frontColor (e.g. #ff0000) keeps the previous textStyle', () => { - const prev = makeNavBar({ textStyle: 'white' }) - expect(applyColorMutation(prev, { frontColor: '#ff0000' }).textStyle).toBe('white') - }) - - it('passes through backgroundColor when supplied as a string', () => { - expect(applyColorMutation(makeNavBar(), { backgroundColor: '#123456' }).backgroundColor).toBe('#123456') - }) - - it('animation: whitelisted timingFunc (easeIn) is preserved with duration in ms', () => { - const next = applyColorMutation(makeNavBar(), { - animation: { duration: 250, timingFunc: 'easeIn' }, - }) - expect(next.colorAnimation).toEqual({ durationMs: 250, timingFunc: 'easeIn' }) - }) - - it("animation: non-whitelisted timingFunc (e.g. 'bounce') falls back to 'linear'", () => { - const next = applyColorMutation(makeNavBar(), { - animation: { duration: 100, timingFunc: 'bounce' }, - }) - expect(next.colorAnimation?.timingFunc).toBe('linear') - }) - - it('animation: NaN duration clamps to 0 (defensive)', () => { - const next = applyColorMutation(makeNavBar(), { - animation: { duration: Number.NaN, timingFunc: 'linear' }, - }) - expect(next.colorAnimation?.durationMs).toBe(0) - }) - - it('returns undefined colorAnimation when no animation field is supplied', () => { - expect(applyColorMutation(makeNavBar(), {}).colorAnimation).toBeUndefined() - }) -}) - // ── mutatePageNavBar ───────────────────────────────────────────────────── describe('mutatePageNavBar', () => { diff --git a/packages/dimina-electron-runtime/src/simulator-ui/page-stack-controller.ts b/packages/dimina-electron-runtime/src/simulator-ui/page-stack-controller.ts index 8346cb88..56b26dd9 100644 --- a/packages/dimina-electron-runtime/src/simulator-ui/page-stack-controller.ts +++ b/packages/dimina-electron-runtime/src/simulator-ui/page-stack-controller.ts @@ -12,7 +12,7 @@ * unit-test it without faking React / IPC. */ import type { PageWindowConfig } from '../shared/bridge-channels.js' -import { makeDefaultNavigationBarState, type NavigationBarState } from './navigation-bar.js' +import type { NavigationBarState } from './navigation-bar.js' export interface PageEntry { bridgeId: string @@ -38,6 +38,17 @@ export type SideEffect = | { kind: 'lifecycle'; bridgeId: string; event: 'pageShow' | 'pageHide' | 'pageUnload' } | { kind: 'closePage'; bridgeId: string } +/** + * Whoever becomes the visible top gets `pageShow` — a page opened for this very transition included. + * Nothing else in this container announces a page's visibility: the render host reports resources and readiness, never that its page is on screen, and the service treats a page as hidden until a `pageShow` says otherwise (`Runtime.pageStates[bridgeId].shown`). + * Without one the page's `onShow` never runs and everything the service gates on visibility — `Page.onResize` among them — is dropped for the life of that page. + * + * Re-announcing a page that is already shown is inert (the service's `pageShow` returns early when `shown`), so callers do not have to know whether the top they are installing is fresh or restored from a tab cache. + */ +function showTop(bridgeId: string): SideEffect { + return { kind: 'lifecycle', bridgeId, event: 'pageShow' } +} + export interface UrlParts { pagePath: string query: Record @@ -159,12 +170,12 @@ export function reduceNavigateTo( ? { ...state.tabStacks, [state.currentTabPath]: nextStack } : state.tabStacks, } - return { - next, - effects: prevTop - ? [{ kind: 'lifecycle', bridgeId: prevTop.bridgeId, event: 'pageHide' }] - : [], + const effects: SideEffect[] = [] + if (prevTop) { + effects.push({ kind: 'lifecycle', bridgeId: prevTop.bridgeId, event: 'pageHide' }) } + effects.push(showTop(newEntry.bridgeId)) + return { next, effects } } export function reduceNavigateBack( @@ -226,6 +237,7 @@ export function reduceRedirectTo( effects.push({ kind: 'lifecycle', bridgeId: prevTop.bridgeId, event: 'pageUnload' }) effects.push({ kind: 'closePage', bridgeId: prevTop.bridgeId }) } + effects.push(showTop(newEntry.bridgeId)) return { next, effects } } @@ -259,6 +271,7 @@ export function reduceReLaunch( effects.push({ kind: 'lifecycle', bridgeId, event: 'pageUnload' }) effects.push({ kind: 'closePage', bridgeId }) } + effects.push(showTop(newEntry.bridgeId)) return { next, effects } } @@ -269,7 +282,7 @@ export function reduceReLaunch( * 2. If the target tab already has a saved substack, restore it as the * visible stack. Otherwise build a fresh single-page stack with the * newly-opened tab entry passed in by the caller. - * 3. Lifecycle: pageHide prev top, pageShow restored top. + * 3. Lifecycle: pageHide prev top, pageShow the new top (restored or fresh). * 4. Every substack survives, so a page held by any tab is never torn down. * A page held by none — the visible page of a session with no active tab — * belongs to nothing the switch preserves and gets pageUnload + closePage. @@ -318,10 +331,8 @@ export function reduceSwitchTab( // preserves. Pages still held by a tab substack survive untouched: keeping // them is the per-tab cache semantics this shell mirrors from iOS/Harmony. effects.push(...teardownDropped(state, next)) - if (!freshlyOpenedEntry) { - // Restored from cache — emit pageShow. (Newly-opened pages get their - // own lifecycle from the renderer init path.) - effects.push({ kind: 'lifecycle', bridgeId: newTop.bridgeId, event: 'pageShow' }) + if (!prevTop || prevTop.bridgeId !== newTop.bridgeId) { + effects.push(showTop(newTop.bridgeId)) } return { next, effects } } @@ -369,7 +380,7 @@ export function enumerateMounted(state: ShellState): MountedEntry[] { return Array.from(byBridgeId.values()) } -// ── NavigationBar derivations ─────────────────────────────────────────── +// ── Page surface derivations ──────────────────────────────────────────── /** * The page's own body background — WeChat/Android/Harmony parity: primes the @@ -383,98 +394,6 @@ export function enumerateMounted(state: ShellState): MountedEntry[] { export function pageBackgroundColor(config: PageWindowConfig): string { return (config.backgroundColor as string | undefined) ?? '#ffffff' } -/** - * Build the initial NavigationBar state from a page's merged window config - * (app-config.json `window` ∪ page-level overrides). The fallback title is - * used when `navigationBarTitleText` is unset (typically the appId). - * `opts.homeButtonVisible` sets the home button verbatim — callers that know - * the page's stack position pass the `shouldShowHomeButton` verdict here so - * the home/tab exclusions apply. Without it only the page config speaks. - */ -export function navBarFromConfig( - config: PageWindowConfig, - fallbackTitle: string, - opts?: { homeButtonVisible?: boolean }, -): NavigationBarState { - const background = (config.navigationBarBackgroundColor as string | undefined) ?? '#ffffff' - const text = (config.navigationBarTextStyle as 'black' | 'white' | undefined) ?? 'black' - const style = (config.navigationStyle as 'default' | 'custom' | undefined) ?? 'default' - const title = (config.navigationBarTitleText as string | undefined) ?? fallbackTitle - const homeButtonVisible = opts?.homeButtonVisible ?? (config.homeButton === true) - return makeDefaultNavigationBarState({ - title, - backgroundColor: background, - textStyle: text, - style, - homeButtonVisible, - }) -} - -/** - * Reduce one of the dynamic NavigationBar APIs (setNavigationBarTitle / - * setNavigationBarColor / show|hideNavigationBarLoading / hideHomeButton) - * over a page's nav-bar state. Unknown names fall through to `prev`. - */ -export function reduceNavBar( - prev: NavigationBarState, - name: string, - params: Record, -): NavigationBarState { - switch (name) { - case 'setNavigationBarTitle': - return { ...prev, title: typeof params.title === 'string' ? params.title : prev.title } - case 'setNavigationBarColor': - return applyColorMutation(prev, params) - case 'showNavigationBarLoading': - return { ...prev, loading: true } - case 'hideNavigationBarLoading': - return { ...prev, loading: false } - case 'hideHomeButton': - return { ...prev, homeButtonVisible: false } - default: - return prev - } -} - -const ALLOWED_TIMING_FUNCS = ['linear', 'easeIn', 'easeOut', 'easeInOut'] as const -type TimingFunc = typeof ALLOWED_TIMING_FUNCS[number] - -/** - * Apply `wx.setNavigationBarColor` to a navBar state: - * - frontColor must be `#ffffff` or `#000000` (WeChat constraint); other - * values are ignored and previous textStyle is preserved. - * - backgroundColor passes through if it's a string. - * - animation `{ duration, timingFunc }` is normalized to ms + a whitelisted - * timingFunc, defaulting to 0ms / linear when missing or invalid. - */ -export function applyColorMutation( - prev: NavigationBarState, - params: Record, -): NavigationBarState { - const front = typeof params.frontColor === 'string' ? params.frontColor.toLowerCase() : undefined - const textStyle = front === '#ffffff' ? 'white' : front === '#000000' ? 'black' : prev.textStyle - const background = typeof params.backgroundColor === 'string' ? params.backgroundColor : prev.backgroundColor - - const animation = (() => { - const raw = params.animation - if (!raw || typeof raw !== 'object') return undefined - const obj = raw as Record - const duration = typeof obj.duration === 'number' && Number.isFinite(obj.duration) ? Math.max(0, obj.duration) : 0 - const timing = typeof obj.timingFunc === 'string' ? obj.timingFunc : 'linear' - const timingFunc: TimingFunc = (ALLOWED_TIMING_FUNCS as readonly string[]).includes(timing) - ? (timing as TimingFunc) - : 'linear' - return { durationMs: duration, timingFunc } - })() - - return { - ...prev, - textStyle, - backgroundColor: background, - colorAnimation: animation, - } -} - // ── NavigationBar mutator (shared by IPC handler) ─────────────────────── /** diff --git a/packages/dimina-electron-runtime/src/simulator-ui/tab-bar-state.test.ts b/packages/dimina-electron-runtime/src/simulator-ui/tab-bar-state.test.ts index 8f781475..360a125e 100644 --- a/packages/dimina-electron-runtime/src/simulator-ui/tab-bar-state.test.ts +++ b/packages/dimina-electron-runtime/src/simulator-ui/tab-bar-state.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest' import { + changesReportedGeometry, makeInitialTabBarState, applyTabAction, } from './tab-bar-state.js' @@ -515,3 +516,53 @@ describe('applyTabAction — immutability', () => { expect(prev).toEqual(snapshot) }) }) + +// ---- changesReportedGeometry ------------------------------------------------ + +/** + * The shell republishes the top page's geometry — and delays the caller's ack until it has — exactly when this predicate says the change moves it. + * The judgement lives here rather than in a list of action names at the call site, so a new action can never be forgotten. + */ +describe('changesReportedGeometry', () => { + const base = makeInitialTabBarState(makeConfig(3)) + + it('is true when the bar leaves the layout flow', () => { + const hidden = applyTabAction(base, { kind: 'apply', name: 'hideTabBar', params: {} }).state + expect(changesReportedGeometry(base, hidden)).toBe(true) + }) + + it('is true when the bar comes back into the layout flow', () => { + const hidden = applyTabAction(base, { kind: 'apply', name: 'hideTabBar', params: {} }).state + const shown = applyTabAction(hidden, { kind: 'apply', name: 'showTabBar', params: {} }).state + expect(changesReportedGeometry(hidden, shown)).toBe(true) + }) + + it('is false for text, icon, style, badge and red-dot edits', () => { + const edits: Array<[string, Record]> = [ + ['setTabBarItem', { index: 0, text: 'renamed' }], + ['setTabBarStyle', { color: '#123456' }], + ['setTabBarBadge', { index: 1, text: '9' }], + ['removeTabBarBadge', { index: 1 }], + ['showTabBarRedDot', { index: 2 }], + ['hideTabBarRedDot', { index: 2 }], + ] + for (const [name, params] of edits) { + const next = applyTabAction(base, { + kind: 'apply', + name: name as 'setTabBarItem', + params, + }).state + expect(changesReportedGeometry(base, next), `${name} keeps the bar in flow`).toBe(false) + } + }) + + it('is false for a rejected action, which leaves the state untouched', () => { + const rejected = applyTabAction(base, { + kind: 'apply', + name: 'hideTabBarRedDot', + params: { index: 99 }, + }) + expect(rejected.ok).toBe(false) + expect(changesReportedGeometry(base, rejected.state)).toBe(false) + }) +}) diff --git a/packages/dimina-electron-runtime/src/simulator-ui/tab-bar-state.ts b/packages/dimina-electron-runtime/src/simulator-ui/tab-bar-state.ts index 08ca59ae..e439b584 100644 --- a/packages/dimina-electron-runtime/src/simulator-ui/tab-bar-state.ts +++ b/packages/dimina-electron-runtime/src/simulator-ui/tab-bar-state.ts @@ -27,6 +27,16 @@ function cloneConfig(config: TabBarConfig): TabBarConfig { } } +/** + * Whether moving from `prev` to `next` changes the geometry the shell reports for the top page. + * Only the bar's presence takes layout space away from the page viewport — `wx.hideTabBar` hands that height to the page and `wx.showTabBar` takes it back, while text / icon / style / badge edits leave the layout alone. + * + * The shell asks this instead of listing the API names that move geometry, so a future action cannot be forgotten at the call site. + */ +export function changesReportedGeometry(prev: TabBarState, next: TabBarState): boolean { + return prev.visible !== next.visible +} + export type TabBarAction = | { kind: 'reset'; config: TabBarConfig | null } | { kind: 'visibility'; visible: boolean } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73462cc7..f23340a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,8 +44,8 @@ importers: specifier: ^7.29.7 version: 7.29.7 '@oxc-parser/binding-wasm32-wasi': - specifier: ^0.142.0 - version: 0.142.0 + specifier: ^0.144.0 + version: 0.144.0 '@vue/compiler-sfc': specifier: ^3.5.41 version: 3.5.41 @@ -62,8 +62,8 @@ importers: specifier: ^1.2.0 version: 1.2.0 cssnano: - specifier: ^8.0.5 - version: 8.0.5(postcss@8.5.26) + specifier: ^8.0.6 + version: 8.0.6(postcss@8.5.26) esbuild: specifier: ^0.28.2 version: 0.28.2 @@ -77,8 +77,8 @@ importers: specifier: ^12.0.0 version: 12.0.0 less: - specifier: ^4.8.1 - version: 4.8.1 + specifier: ^4.9.0 + version: 4.9.0 magic-string: specifier: ^0.30.21 version: 0.30.21 @@ -86,11 +86,11 @@ importers: specifier: ^4.57.8 version: 4.57.8(tslib@2.8.1) oxc-parser: - specifier: ^0.142.0 - version: 0.142.0 + specifier: ^0.144.0 + version: 0.144.0 oxc-walker: specifier: ^1.1.1 - version: 1.1.1(@oxc-project/types@0.142.0)(oxc-parser@0.142.0)(rolldown@1.0.3) + version: 1.1.1(@oxc-project/types@0.144.0)(oxc-parser@0.144.0)(rolldown@1.0.3) path-browserify: specifier: ^1.0.1 version: 1.0.1 @@ -158,7 +158,7 @@ importers: version: 5.9.2 vitest: specifier: ^4.1.4 - version: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)) + version: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)) packages/devtools: dependencies: @@ -252,7 +252,7 @@ importers: version: 2.0.0-alpha.41(@babel/runtime@7.29.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(vite@8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)) + version: 6.0.1(vite@8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)) '@vitest/coverage-v8': specifier: ^4.1.4 version: 4.1.4(vitest@4.1.4) @@ -318,10 +318,10 @@ importers: version: 5.9.2 vite: specifier: ^8.0.8 - version: 8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0) + version: 8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0) vitest: specifier: ^4.1.4 - version: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)) + version: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)) packages/dimina-electron-runtime: dependencies: @@ -364,7 +364,7 @@ importers: version: 8.18.1 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)) + version: 6.0.1(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)) '@vitest/coverage-v8': specifier: ^4.1.4 version: 4.1.4(vitest@4.1.4) @@ -388,7 +388,7 @@ importers: version: 5.9.2 vitest: specifier: ^4.1.4 - version: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)) + version: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)) packages/electron-deck: dependencies: @@ -422,7 +422,7 @@ importers: version: 18.3.7(@types/react@18.3.28) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)) + version: 6.0.1(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)) '@vitest/coverage-v8': specifier: ^4.1.4 version: 4.1.4(vitest@4.1.4) @@ -446,10 +446,10 @@ importers: version: 5.9.2 vite: specifier: ^8.0.16 - version: 8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0) + version: 8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0) vitest: specifier: ^4.1.4 - version: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)) + version: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)) packages/eslint-config: devDependencies: @@ -506,7 +506,7 @@ importers: version: 5.9.2 vitest: specifier: ^4.1.4 - version: 4.1.4(@types/node@24.12.2)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)) + version: 4.1.4(@types/node@24.12.2)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)) packages/inspect: dependencies: @@ -546,7 +546,7 @@ importers: version: 5.9.2 vitest: specifier: ^4.1.4 - version: 4.1.4(@types/node@24.12.2)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)) + version: 4.1.4(@types/node@24.12.2)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)) packages/typescript-config: {} @@ -566,7 +566,7 @@ importers: version: 18.3.28 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)) + version: 6.0.1(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)) '@vitest/coverage-v8': specifier: ^4.1.4 version: 4.1.4(vitest@4.1.4) @@ -590,7 +590,7 @@ importers: version: 5.9.2 vitest: specifier: ^4.1.4 - version: 4.1.4(@types/node@24.12.2)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)) + version: 4.1.4(@types/node@24.12.2)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)) packages/workbench: dependencies: @@ -708,10 +708,10 @@ importers: version: 5.9.2 vite: specifier: ^8.0.8 - version: 8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0) + version: 8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0) vitest: specifier: ^4.1.4 - version: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)) + version: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)) packages: @@ -1009,26 +1009,26 @@ packages: '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} - '@emnapi/core@1.11.2': - resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} - '@emnapi/core@1.9.2': resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} + '@emnapi/core@2.0.0-alpha.3': + resolution: {integrity: sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==} + '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - '@emnapi/runtime@1.11.2': - resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} - '@emnapi/runtime@1.9.2': resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} + '@emnapi/runtime@2.0.0-alpha.3': + resolution: {integrity: sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==} + '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} - '@emnapi/wasi-threads@1.2.2': - resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@emnapi/wasi-threads@2.0.1': + resolution: {integrity: sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==} '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} @@ -1731,6 +1731,13 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@napi-rs/wasm-runtime@1.2.3': + resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1751,121 +1758,120 @@ packages: resolution: {integrity: sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==} engines: {node: ^18.17.0 || >=20.5.0} - '@oxc-parser/binding-android-arm-eabi@0.142.0': - resolution: {integrity: sha512-ZiRGDutGsv1G6bL/ozy/koC0Sv39T1DqyoC4KD1DOy9ZoACm1O5UWhEK2c02Qdk+4lfLVkvFa/mQ0fm/4h1BtQ==} + '@oxc-parser/binding-android-arm-eabi@0.144.0': + resolution: {integrity: sha512-IaoGBEp/huvja99PxI/b72TbKFzA/UzxxAka7f233dc/Tg/rRTX9Qn8IquFLWwWf4IddN/5TaJ8S4Subbjq7wQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxc-parser/binding-android-arm64@0.142.0': - resolution: {integrity: sha512-WZkvGRLNQTz8lR9zP5nLjUdlroRCopBu3g9zF1p/laE6DzT1UbQo8Rdz5MWhaJUPYg/6gp+jo7HUgsyKaN1FtQ==} + '@oxc-parser/binding-android-arm64@0.144.0': + resolution: {integrity: sha512-u6fJu8XQXP99+9pYO3jq7F1D7V9fyFuDBShYFlr+gY+GcJzhveeN/zoMfuXxX6XBquJO0kjqKd7BjhJ7pClWXQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxc-parser/binding-darwin-arm64@0.142.0': - resolution: {integrity: sha512-l4khS8LQOOVYsGRVARo1gSaCT/aBSceUVXgtovWc2+drnxVuDr082WA3OCHVdVzIz5JIrP/y9CWsSKxBDNmYGg==} + '@oxc-parser/binding-darwin-arm64@0.144.0': + resolution: {integrity: sha512-o9xGSmMQcboJLjwI+acFf6xa7nYdp0/nRFE8ry4Xrt8OviQ9ITFDBUkAXVJMOLchSV9Pu981GxJuW0mt4i6vQQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxc-parser/binding-darwin-x64@0.142.0': - resolution: {integrity: sha512-QBsNF3nqlXmcH2B1YOPqQYmCJoy4HuIjUxGbBO/k5JAJUl68ghU2psRY2zPk+RyBaWqKP/qfL4oaFgEMCdwskA==} + '@oxc-parser/binding-darwin-x64@0.144.0': + resolution: {integrity: sha512-2yNm4tX++W3KLbyziVhs5alSb74a3C1uNDu/1P/AQj1ux8yZYuvbCAeJCCrGkr8J18ZmnBAzDthdTZBEAEb71w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxc-parser/binding-freebsd-x64@0.142.0': - resolution: {integrity: sha512-b7Q7m4Cqc6XqNhri3R+QhU+GVy646Pn+bkdhrDdWym/Fdi0ZUa+d73H9dm5H91JtbtAQ/z1d8XKMW3oOV8a4tQ==} + '@oxc-parser/binding-freebsd-x64@0.144.0': + resolution: {integrity: sha512-TG4CjY1OjynplkF9nAQ9m9zboPJksnbAF+U/9xQGSXyIt+5sQRitwfQrUgjrG17/up9G8k/boNjLD2zp4xq1Kw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxc-parser/binding-linux-arm-gnueabihf@0.142.0': - resolution: {integrity: sha512-3riVS5IhdH3uCZj1Y9ftDQlR0dvLsIlw/edrRqk8JhgNd5K0XSs+UBtgh50N13CAlW9/TXj6sVGXaKNBocd0Yg==} + '@oxc-parser/binding-linux-arm-gnueabihf@0.144.0': + resolution: {integrity: sha512-i0T9NagVmqc+rbSyBr5mDKj7TCMIBRrSteQlQJt1WhWIH/sZeOP9GB09H9w98YdinuZkDIPmO7Fz0jDC7bMvSA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxc-parser/binding-linux-arm-musleabihf@0.142.0': - resolution: {integrity: sha512-NmXUOpgpTSkhl795TiXmWppTwmSJ92RC1qvD6e4XOF+slgmo3e6Ah+kEu+6AN8s7NAOEwqGmir58MgSQSWmBSA==} + '@oxc-parser/binding-linux-arm-musleabihf@0.144.0': + resolution: {integrity: sha512-YUsEqM3WMS3mOON+TFf7RzS0QthzEifx7tpUQu0GSF2MsT+D6t154ZBs6WhWaCZNl0GuVDEvndCyEAUBHzSHGw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxc-parser/binding-linux-arm64-gnu@0.142.0': - resolution: {integrity: sha512-gc0EXsKtXgerujmU2Bql3u1L1HsSQ2774R83idq/FoNMPVV/RY/1ErFsvnit7KoiP/sLvzQixeUo4Ut0ic0wmw==} + '@oxc-parser/binding-linux-arm64-gnu@0.144.0': + resolution: {integrity: sha512-LlWH4kt+IET3qIAe0e0IFLNlQ3CVUAfN//UFsA6N0/FghMh/FBk1e+wzvgG+t8WSnXkvf8B1TovquS2EJras9g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@oxc-parser/binding-linux-arm64-musl@0.142.0': - resolution: {integrity: sha512-F2XvmWSE0uWpie+jHKKIFgdVOe9ypGhkEZxKx5DuW215K6cbAC274yYaPkcM7EqY4Df3Weyhpcz3lsURyH2LVg==} + '@oxc-parser/binding-linux-arm64-musl@0.144.0': + resolution: {integrity: sha512-ajXbXIWBWUD4U3IQxr2p6DiXwD7GPHEBLa+JteKhIfvLmBEBdTjO28lP+5r3AF2qal8cxLERfTnGs64Z22ZuXw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@oxc-parser/binding-linux-ppc64-gnu@0.142.0': - resolution: {integrity: sha512-wLMbT21U/QxknQsk+VvNF0b9D2/aGWhcaQQQ+VYlE8FwD5+GoWZIPPXNzyHmkYyhm0KB3itL+TBavjMatqNnYA==} + '@oxc-parser/binding-linux-ppc64-gnu@0.144.0': + resolution: {integrity: sha512-/+sDzL/4cWEwdqenKo/DX3gkkxu7H7ytFAtealDey/Gd59yPWn64obVk6wXKVjVfXMciUUUTySxZG9AIMX3RNQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - '@oxc-parser/binding-linux-riscv64-gnu@0.142.0': - resolution: {integrity: sha512-+G8F/4ckwT7FCJV4H2bt09xEzJbjNCfuL4Sp1AYNaFtFMVtgIGMuJlteT82U+K0UIZ/DzAR/LDlMFnEuajG7Kw==} + '@oxc-parser/binding-linux-riscv64-gnu@0.144.0': + resolution: {integrity: sha512-dMVhPBbrd8y6aeLd7Ihn9OZhKO8QgCQVtLBTRgbmf4lKrcR61SpaQRJPJuocTc/Cn5SJMm+alHYPnzkbOGM7Dg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - '@oxc-parser/binding-linux-riscv64-musl@0.142.0': - resolution: {integrity: sha512-hTsHtTLxMAfCo+rpF5K3qZJKW2NpPN/CHd4mYB3y7XlSdspHkd2gehDIofP64AacA9nWQw2tY3O7wR6UY8IVOA==} + '@oxc-parser/binding-linux-riscv64-musl@0.144.0': + resolution: {integrity: sha512-jQ8O0+b6J2IhJgm0DnqEJq8hG9OocmF1b4TBWCk08CRWqTmLZj/+lYs7w3OA60nb2SiqOmthQyJPacrCi7y+oQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - '@oxc-parser/binding-linux-s390x-gnu@0.142.0': - resolution: {integrity: sha512-6y7qYY3TCUDYjqswImdTGl92y+KA/80twALegQPN27kfY+bG7Ib1+L3jbmrCZQx6wrVnai9IPsEZp07I0hx7JQ==} + '@oxc-parser/binding-linux-s390x-gnu@0.144.0': + resolution: {integrity: sha512-/mZxZtcGrzuvqPLPV7gjavbROYs/dHy6+yQ2Sl/2to/+qoC/v6CcruGFnfQPzQbXXTYReXJzLb5QY9KmgCbJOg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - '@oxc-parser/binding-linux-x64-gnu@0.142.0': - resolution: {integrity: sha512-i69kAWU+2LgoH5bR+zWiiu+UzAw7Oxkwv7COeJTeY19pn4e70nKQcr9Pm6cL2Z0Z54d+gl9qADlK/0yyuCPiBA==} + '@oxc-parser/binding-linux-x64-gnu@0.144.0': + resolution: {integrity: sha512-/caRGFHcarHZlBrucBwQwBbzqhD+UfZZ/r7soocS0/mp6/5KTq+1Zl/OQx5lFLcN+GpUPYszbrvQU9MCFLEzJg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@oxc-parser/binding-linux-x64-musl@0.142.0': - resolution: {integrity: sha512-4SQs678MmjYVrmhAgCWD4o0vpaFszXw9xLX5p2Z9MMFcltxiLkA88wQjh80YHjPrXtpyZ2CWI5m+1yNKM0m2Pw==} + '@oxc-parser/binding-linux-x64-musl@0.144.0': + resolution: {integrity: sha512-qFtwAo6BWuWDjh57QDdZdYi746GW0mIeoZSGK2jJqlxIjo389Y/7lrriTOI+ou7tTvusOrSYGQZ+e+nDswt2vQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@oxc-parser/binding-openharmony-arm64@0.142.0': - resolution: {integrity: sha512-YHpx9N7Ln3a++Tc8rv+H7mrK1zyJQOAwCFg8LZ3lTs1T5afGWeZrLPhPT9HLnIwSjCyJqPWVMIrMxbjcmBr2oQ==} + '@oxc-parser/binding-openharmony-arm64@0.144.0': + resolution: {integrity: sha512-n+NgMGWWEYpH+rlkMhDvLR2k8vJDHQp3j8SoS86IS6J0hc4kuDaiYAAvu9dF86xjeGYy+h9WLj12sylmBJV9sg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxc-parser/binding-wasm32-wasi@0.142.0': - resolution: {integrity: sha512-3pLDyY3+oogW73RM5uehNgAiR/Xfb7fvO2Q1Z1gIqZ2+50XDVQmBVlRkHXZTU4gKnQHpwETNsYQVsJ3joVB2iA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] + '@oxc-parser/binding-wasm32-wasi@0.144.0': + resolution: {integrity: sha512-G+wbfbSCpdpBlJX+0+e/EKHQ852bmjySHCt8yTKzvKZlEgfIx9T+caYFXa5Ek40xsb7kdqyB4K3AD6UEVZ9FYA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} - '@oxc-parser/binding-win32-arm64-msvc@0.142.0': - resolution: {integrity: sha512-Had/VeVY28Oyb0K+Q4FV8KCzoBycIh93oDK6pCbya9lkzdq+ikMHMgBubsdqqlybjJmQRawCQRrnBRHyQwYvcQ==} + '@oxc-parser/binding-win32-arm64-msvc@0.144.0': + resolution: {integrity: sha512-fShxpJiCBOdG4+jBAvahTTFUDI5djXc/+IPC1ldeC8LbyCW0h9m/7oP8DRZWI7WT2Ahv8sHtZz4ugECylCFpTA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxc-parser/binding-win32-ia32-msvc@0.142.0': - resolution: {integrity: sha512-GGi3+YphVHavvgs6gum2UXoNCqzHAmPt/nXkn8ZQZstV2Q1qZD1Mn8fz/nWrDkefHQtrG/+1/XrbMxsBTo6Svw==} + '@oxc-parser/binding-win32-ia32-msvc@0.144.0': + resolution: {integrity: sha512-vFrYV+C3lJhIiSdNhdkZHnZ0YIClgTSluXaPMYjlGslVPD+uJg6K1s2xNL/X/gdBcy9IIbjbp0vNBwQhdMMdkw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxc-parser/binding-win32-x64-msvc@0.142.0': - resolution: {integrity: sha512-Ny/Wv4Us1LGC/ljwNTp+Hx3r/pH15EFfeDF0p+n898gt+TtRd6C9SccHcuUhDiNTb8s5tt7jdeAMDRQZ4Vq6hg==} + '@oxc-parser/binding-win32-x64-msvc@0.144.0': + resolution: {integrity: sha512-0ASbKSwdeihMekyy7y4jC0CwW3XBDZk5Sw64m/W7IReVQHaduqLYssF9KCJA2oHG9oldnl/1CMxqCoImXfqQkA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1876,8 +1882,8 @@ packages: '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} - '@oxc-project/types@0.142.0': - resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} + '@oxc-project/types@0.144.0': + resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==} '@parcel/watcher-android-arm64@2.5.6': resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} @@ -3114,11 +3120,6 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - browserslist@4.28.7: - resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - browserslist@4.28.8: resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -3416,20 +3417,20 @@ packages: engines: {node: '>=4'} hasBin: true - cssnano-preset-default@8.0.5: - resolution: {integrity: sha512-R9O+oRNnKcVBf7GZZ7nfBcOiBZZwi3kR1HtKirBHel/gTtHLMHOCsL2H3QGy1161CPystJV4EiKniC7XyUKfcw==} + cssnano-preset-default@8.0.6: + resolution: {integrity: sha512-U5MLdiyveJNVCVR0uISgHvCkoYLLB0xeJZSY+VnBESzv1lhY04x54u9MYUNvIKmEs6hwqHVdzztajBOUf8VD4A==} engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} peerDependencies: postcss: ^8.5.26 - cssnano-utils@6.0.3: - resolution: {integrity: sha512-HskzChO3gRkXBQSWg68DfwoVfptRUV2GvuiQBvt+C7mUw4VB0CvPjkC4iF4JSf7yW1/66Hsc6WJtqNqD5Ydt2Q==} + cssnano-utils@6.0.4: + resolution: {integrity: sha512-j1z2mW4MqtcGM4I8TxXAdOXUifp1DZ5/bZnyYnCpHlzQ/ilCj2voLxE0aqEKnBDA5hF6HahSY13JU8kyZ1s8Mg==} engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} peerDependencies: postcss: ^8.5.26 - cssnano@8.0.5: - resolution: {integrity: sha512-Yigb8Apuqi/jWwii1XFmZwgAPZ2RHDUx/ANodBZsEQusYIFryChhwOQNKco0A7V6zgFqDDy3LqefGK6OnniGMA==} + cssnano@8.0.6: + resolution: {integrity: sha512-KDwqW0R35qIGDDWse4vRhrNtF558sUOcfuqCbB/h0bUP4+aBUpbGT5X2bQlE1gEACk+nDFAL045VyesanJFEug==} engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} peerDependencies: postcss: ^8.5.26 @@ -4563,8 +4564,8 @@ packages: lazy-val@1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} - less@4.8.1: - resolution: {integrity: sha512-jQ3lRIo1aUtiWVYXZ7mk4+V4BjCGswF3IxTLJ+4RUta8ZiHh8lhkig2G8dya2eCcyR1dYUvzuV46EkJN8PSwww==} + less@4.9.0: + resolution: {integrity: sha512-umRhrCH7fCi8Uj2RcwKjJdvUORTjeWqkdKx0LbcZvjIwsAVsnIAGcxHaqowPeBFBjQuWOeC/bve0AlpFzF/+SQ==} engines: {node: '>=18'} hasBin: true @@ -5015,8 +5016,8 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} - oxc-parser@0.142.0: - resolution: {integrity: sha512-kKR+jPiRJYJDexVoziIg/FVGvr1fT1FZSSJOk6tVoMKKSlsf1Cso+cgGCJkOEDWOP174vRntCPFKg+AS7InWvw==} + oxc-parser@0.144.0: + resolution: {integrity: sha512-eacM4wMgGWXctHubY262yo+50E76qtQBqe+uK73YEV1IT3qP12Acbnf9Nc8t+agIAdnko9iVT4KF83/d0EjY5w==} engines: {node: ^20.19.0 || >=22.12.0} oxc-walker@1.1.1: @@ -5180,38 +5181,38 @@ packages: peerDependencies: postcss: ^8.4.38 - postcss-colormin@8.0.3: - resolution: {integrity: sha512-kypgzYcOcrKsrAZyId3TMumHtIiwZxgK1h5B33S4RjQNV02RHKrXCWP8ndyx5S0R5mk4pkcIfJ95o3BwIN8r2Q==} + postcss-colormin@8.0.4: + resolution: {integrity: sha512-iQ8Eh6Fb3FJx32zduprL33D80bPnE9F4nm+EqvvoHvoK72H7XjvH4GzbD7VlIowmtzf96tgtkjZgmQ/bAi/CYA==} engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} peerDependencies: postcss: ^8.5.26 - postcss-convert-values@8.0.3: - resolution: {integrity: sha512-14lU1u5MeX8oGQ4zMhiMYhFcav9ebgmjjxTYwk77pmZ5A9Rq8hr5X/XZaTfffvbIGMOoLK82YDsLxA+1hFGbbA==} + postcss-convert-values@8.0.4: + resolution: {integrity: sha512-ifBmAJBfpDymi7r/CqxYRJWlG+BLdVmusUC/kFPOqH/+TitdBeHA68CYqY79wXL+iQnqg6e4LGHA2Mte14X6Ig==} engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} peerDependencies: postcss: ^8.5.26 - postcss-discard-comments@8.0.3: - resolution: {integrity: sha512-oDe4ITEfp1/113ebPi/ujyfWX2E9+vbHhY0dPnypgHwIgw8LBQ7MczmtAbccw8gUs+Zlmgt80qtTKS0t8eCi+Q==} + postcss-discard-comments@8.0.4: + resolution: {integrity: sha512-SU1uLYRQPKLJJmqEmqzu+Ze+9xDurBm7m/LOzXG/ANBAfAGLjbQF+oIyZnf3jV3F155q5rRMYXsoTNEJsPyHng==} engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} peerDependencies: postcss: ^8.5.26 - postcss-discard-duplicates@8.0.3: - resolution: {integrity: sha512-6f6ZVBozciZ0nG7nurrHk+K2yNeBgEoOjZvj5JwFThK982tSPyOjtClbkTYgqwDuSFjBvtu5C9Iz/2QEPvUg+Q==} + postcss-discard-duplicates@8.0.4: + resolution: {integrity: sha512-/ugMYYTE+IpHpTmwz6/S8w+blCz9pZNroUgadTdlbPnHoLLWis9QvwmoyWui2mOgFzuRM6Me7AnoGU3i0Ua2ng==} engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} peerDependencies: postcss: ^8.5.26 - postcss-discard-empty@8.0.3: - resolution: {integrity: sha512-IpqNmuH9djHODVELb5c4tC6274pxjEWqrpGtTu7BR8TtOz3beqTv7JjE9xSuGOacphh9XZbbyEebvfMxYC5PTA==} + postcss-discard-empty@8.0.4: + resolution: {integrity: sha512-utsxD6q3E9FCwxBRTe5/Jh9ticSOUmfovDfX6zrLuuX8ZfycSkrsqog2ZwGtVVrAS9SMxAhD73QHe9U/gopVJA==} engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} peerDependencies: postcss: ^8.5.26 - postcss-discard-overridden@8.0.3: - resolution: {integrity: sha512-G2Ksn7kkNsNlsYsgfVn0YFfcGewJTuc1KOENvoVtwu2bSEyR28+GaomozkV4DPUBbKF8Nvco/9rLyKkgf/TY6w==} + postcss-discard-overridden@8.0.4: + resolution: {integrity: sha512-NqupxmnSSfWPJJDYbQk1qXWRwr+lw4Kyp/LSOjqwTpy/vSwo34EVXYKywgemSWpgh39P7Cs/G+tqynbRlXoJhw==} engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} peerDependencies: postcss: ^8.5.26 @@ -5246,38 +5247,38 @@ packages: yaml: optional: true - postcss-merge-longhand@8.0.3: - resolution: {integrity: sha512-/Byag1rLsEffmnidL+8HGwr0AsQWiaY2gpChEKwKP4+YcBtzz44rKVxAJLdhQtRLFrpGiBoLzCdCfH/ZTkozuA==} + postcss-merge-longhand@8.0.4: + resolution: {integrity: sha512-ammolHhMvuTz/L9YN/ge1SWbUDcD3Y9o2gXlo3S6H4MCJHheyVPwcI/LAnSuNT4gcUmSF9pNTXo7+3M/2MFPAQ==} engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} peerDependencies: postcss: ^8.5.26 - postcss-merge-rules@8.0.3: - resolution: {integrity: sha512-gBnrjp2ebQyrBNDqxORirC6ZM6g4cHKglAwGf1JiuKmDPjUJbNM6WhxYMYkHf+hxysnZfq27+TtxGrYESv3GMQ==} + postcss-merge-rules@8.0.4: + resolution: {integrity: sha512-bppiIHxg0zUCwdt9MVYy6nM2dBgPBJdZPD5Y3Eg61p79JVaNQXTzghQKVcaGX2vi5v2rz9+zydIIFLTxSh1akw==} engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} peerDependencies: postcss: ^8.5.26 - postcss-minify-font-values@8.0.3: - resolution: {integrity: sha512-kAXxTCIVub5LZvyKTr9AObrRxri0WtWpNVsG6R9NdBKHDHYu5WNAnZRntgOforsKeFeZRZvOOXnTqOANQeyzKg==} + postcss-minify-font-values@8.0.4: + resolution: {integrity: sha512-wZZgJ87U5WbQCEM0EOY/oM7M1a+sqW6vrOClGC9lNbsY0HYmbMFvFuukBn8PzF+ikKmaT0QB+uSAHTRLzMkD+A==} engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} peerDependencies: postcss: ^8.5.26 - postcss-minify-gradients@8.0.3: - resolution: {integrity: sha512-0O9UDPjIB4OikREx/aOwPY3pco23txEpXpdTlzfoSrVQllNh5j9GaCkThA5ylsKcDWz5sunV3tFW9+zlHFWUww==} + postcss-minify-gradients@8.0.4: + resolution: {integrity: sha512-78cIzfNlG4uMT1wgh6svmAw/sFwQPZ9JRqq4zxJpcdOMvXQz3Hh1ZBdX5GeBONp0AoqNVQiHZ2VnQeF+HldT/Q==} engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} peerDependencies: postcss: ^8.5.26 - postcss-minify-params@8.0.3: - resolution: {integrity: sha512-DuSZEJbWxUX7wvIkz6K4qN8zs5unI/MSg7gcH4AXXA3524OcI4BOBUhR8B8OIBIi9FfcBbfgHYlATVhMeDBXNg==} + postcss-minify-params@8.0.4: + resolution: {integrity: sha512-TDx9O/ni7KazRK32pVvoo3MjExyVxWE0949vB2XZ0oHnxdAtQFHa9oG21wWkvsOL3osjiOVejwDLe6a0d0EuSw==} engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} peerDependencies: postcss: ^8.5.26 - postcss-minify-selectors@8.0.4: - resolution: {integrity: sha512-+rqBW9gYNLq2RNBwfVx2QdElj2cTiqbNwah+bw1XvyhCnRe4uFLNW2kny0VspFyYR7OGuVyWZaz2K4DbX5J9sw==} + postcss-minify-selectors@8.0.5: + resolution: {integrity: sha512-i+PlhVCaPa7xevjhpActH2PrP27kDNSuuFf/ZGk8YrIcd1pd3Mfcfiv8H7dbY1/vz7U+QparioAtJaFV+IEwQg==} engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} peerDependencies: postcss: ^8.5.26 @@ -5288,74 +5289,74 @@ packages: peerDependencies: postcss: ^8.2.14 - postcss-normalize-charset@8.0.3: - resolution: {integrity: sha512-losd0Uu4XVpiKN5tcGh32QxpB9t3S09PMQaW6I2GszKl9wOR0I34DY0a8ApO50jzfBC52lv8jPpkvh5ltbgajg==} + postcss-normalize-charset@8.0.4: + resolution: {integrity: sha512-ihNV/Q9V/+Q9NTqgoK4FOwI7ipqJlsaxoTWxkGyCMi4ArLKX8HqLYIqATB/8xhXd9K88PCXqdR8218u9uuyc4w==} engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} peerDependencies: postcss: ^8.5.26 - postcss-normalize-display-values@8.0.3: - resolution: {integrity: sha512-IaCp/Rp7bg0e8Hv0Wc4G+niuuA61iGOSzhGBUeo6P6qyoumyFbOzYbYWZSkzMNsC4o2gfHAj3q2VbQfds8Huzw==} + postcss-normalize-display-values@8.0.4: + resolution: {integrity: sha512-F8DGtzelJDV6S5mhcugc+EJ8sXI+kFv2PBedkfMWqd8mvTE4qyy3kva6AQwjy2+L2lZ9TGTkSumdq+n3EhGeAw==} engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} peerDependencies: postcss: ^8.5.26 - postcss-normalize-positions@8.0.3: - resolution: {integrity: sha512-OA/n9pI6W66Sv1vki0MLv/0QpDECV8i3UHwlXPVnv3XewsBjx310NQDV+ybMx2ZZQc/RHS7Uf8ES+oxLVhd0iA==} + postcss-normalize-positions@8.0.4: + resolution: {integrity: sha512-anOMhqe6Z0OP4jvchK1v1hIG08kO7rsp8uHrxT2+XaKp8wbvVcUO3QHUzRmQDazWUPtRzy1h2UlixyJenF42sg==} engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} peerDependencies: postcss: ^8.5.26 - postcss-normalize-repeat-style@8.0.3: - resolution: {integrity: sha512-lMCbxiLBd7awGEoRM1WD02R08XpEGDHg3x7z9VSWnZibgSGHNJ+k7aheucuLBcxPAHWPudwc8O65sYD2FDx0Hg==} + postcss-normalize-repeat-style@8.0.4: + resolution: {integrity: sha512-vY8+k+/bFT7B8gzlHVye8FxBTMEpUAQxAgb4UYFAGGsKmXfwEvc7VPs+kpFNrTeqVKAvVh8+VVhZ+f3S4TWJPg==} engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} peerDependencies: postcss: ^8.5.26 - postcss-normalize-string@8.0.3: - resolution: {integrity: sha512-F8jkemEEIGDjerUzTa5w18CQc4GfhpJdEk78LdtwOjLjG1DlaDZyCdec+PyxCHjSY7vsbULXhlk24vSA+eUtIg==} + postcss-normalize-string@8.0.4: + resolution: {integrity: sha512-dWnV1frIV1XUlSYzJudceAbQ7PGyho9EgVHQXMcAoog6Nwnet3G9rz8IXcCb0EF2eefmpYG9hDT9+yWJOxc5zw==} engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} peerDependencies: postcss: ^8.5.26 - postcss-normalize-timing-functions@8.0.3: - resolution: {integrity: sha512-6MO4j7ySljCmhPKx6GLkIpDdV6nOhb5UaB8j/0i0EbfooH1NeVQG60Zt7qAQ9h21HUwV6kQDIbWGqU78DhPHtw==} + postcss-normalize-timing-functions@8.0.4: + resolution: {integrity: sha512-VANJsokAWdQ03ZJwmcdEKXJjVHRtMgalB/BgbCgN3CRTdbwB5TXlSDRmdShSCxpnZs8WLAm0mPa//p5o5D/ZTg==} engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} peerDependencies: postcss: ^8.5.26 - postcss-normalize-unicode@8.0.3: - resolution: {integrity: sha512-gfMC9A0z8d1HrS792ZFIUMZeTysN/GrtQEiyV/aSJPBwqOOak9BTGTSUp1VHozNuEeQs4MUoO+fBQzDXpRX28A==} + postcss-normalize-unicode@8.0.4: + resolution: {integrity: sha512-SxVEoFdYed0bxxwZwAXnaAZ2lzNb5jtiQvYWyMIEqnGOzCuztWpVO8LvsqpOfUIacOb6oYaWi7WOAZR36OhYqw==} engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} peerDependencies: postcss: ^8.5.26 - postcss-normalize-url@8.0.3: - resolution: {integrity: sha512-ksA0HgWATlnIzDaB9gFPslXxJkTa7aHZK3jWSbbyJdFJuRIY0BOL0eNMRrZNpe6//g4Af/iB00ETbT0cNmCzKQ==} + postcss-normalize-url@8.0.4: + resolution: {integrity: sha512-JKZW8KRHkLEYkUb/55n3knDUMZeyvxlTv7ZA82bcHiCjL81b0Yyf9fIUBAYKZ6ucDgKfCDM78xhXuteVlk/o9Q==} engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} peerDependencies: postcss: ^8.5.26 - postcss-normalize-whitespace@8.0.3: - resolution: {integrity: sha512-Hz/IeeZXk1686EZtnCKKIkU8Mu+PGTxPhkuXnKxJZCD8lkVgD9blIhymu3I/itL4FoOyFTWzh7hQvIri8SbrXg==} + postcss-normalize-whitespace@8.0.4: + resolution: {integrity: sha512-LdEzfN2xKS52Hn3iGK4szK8E9d/lCkcrEMsxi4wY5ibXyKDyNmQW+YMlPX8C0uYhdfeFHQLktPOkXJGUZjJDUw==} engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} peerDependencies: postcss: ^8.5.26 - postcss-ordered-values@8.0.3: - resolution: {integrity: sha512-Sp62UbMrsCNcPvtnme1Kz+nNJK3tqCqRvGiKdL7ugYgu3/m8buMlFy3QO/BJ0dJQHHJP2u6CESrw4xhKFJTvvA==} + postcss-ordered-values@8.0.4: + resolution: {integrity: sha512-hJ8elrQlYgAynaap0275AhUWTq8+aeWRjkKfRTT/id2AAttjbUWvPQUgzEnANU3KHHDdna86KyNrdk4wslGZNQ==} engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} peerDependencies: postcss: ^8.5.26 - postcss-reduce-initial@8.0.3: - resolution: {integrity: sha512-z2cMLQtjr+gXd4QIg2O6c21xGsk1QV2HNKaiYVvxZYRrvyuAqPAHFfRSo+7zbGc/n7rilzEFkljJN5WihG99AQ==} + postcss-reduce-initial@8.0.4: + resolution: {integrity: sha512-6SK1CZN9tmVHXhFA+Up7aNjJzIYKgo9I4yHluQKoO8tIa5iGc88x8BiJmMDUvrDtYgt3IZIruS3AGCAdQMAY9Q==} engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} peerDependencies: postcss: ^8.5.26 - postcss-reduce-transforms@8.0.3: - resolution: {integrity: sha512-MKebqhCviCaOlz9ZnlQxviw0R94q3POvxv3m2Xk1IEyBrk2lsNiKBJsuwXLWHWkQB3y+pQDE0FA26J4+NYhzLg==} + postcss-reduce-transforms@8.0.4: + resolution: {integrity: sha512-ZGU7/R0GmjE3x2mOGB0g9Ohx74X+JjYX62RB5IBYLOe6R0fuHGZ76HtTY2CdM5NE8Yey/ew5clEf8iZQnjDjWA==} engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} peerDependencies: postcss: ^8.5.26 @@ -5368,14 +5369,14 @@ packages: resolution: {integrity: sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==} engines: {node: '>=4'} - postcss-svgo@8.0.4: - resolution: {integrity: sha512-cmxRK3zz5BLFO/r/crOHy4QosyNCHpyAG9fOjtOSAEKkajcAK5xyURRWcotPOXcz88VbzIrYJCWVAGjrX4GeJw==} + postcss-svgo@8.0.5: + resolution: {integrity: sha512-8B5r9VfLVD2lANKxDi4FXeP2MX6NdaGIQwaMVXXy5DhzAPO3CdHqPYqknF63y7tuQLB+rSvYyNltW/tHHg4fAg==} engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} peerDependencies: postcss: ^8.5.26 - postcss-unique-selectors@8.0.3: - resolution: {integrity: sha512-uKwlnCNyKmny7yFPOnQJAN82Er0WzGQbSlpZHTKTqlTQsvZF+skA4ANMHqKh+68jQYet6IotH3dtxs1VdyBdxw==} + postcss-unique-selectors@8.0.4: + resolution: {integrity: sha512-Sby0EtmD1mlvcZSWP5eXjxhxVgvXE5QVRzd+8NH0IbF+lYQIM57ybwAvJYYtI/fexMgCWcRnDETovjKOcLJb8w==} engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} peerDependencies: postcss: ^8.5.26 @@ -5877,8 +5878,8 @@ packages: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} - stylehacks@8.0.3: - resolution: {integrity: sha512-cHciVnyMEuLOYt6AGCTyzRjEvBTvA+0H/QThOFgKTI9uuFK4RA34gLVEt/Uuz11rtSPvZHjMWLjTazF9pLMPog==} + stylehacks@8.0.4: + resolution: {integrity: sha512-irgZeyYBFVkb8k7yTRQ9No/bTniTGqzptGVMG1/Mj3N2YnI8nIozzY1pcok4i01NfrCSqvc0PdQB6O6kr6dJHw==} engines: {node: ^22.11.0 || ^24.11.0 || >=26.0} peerDependencies: postcss: ^8.5.26 @@ -6565,7 +6566,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.0 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.7 + browserslist: 4.28.8 lru-cache: 5.1.1 semver: 6.3.1 @@ -6948,37 +6949,37 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/core@1.11.2': - dependencies: - '@emnapi/wasi-threads': 1.2.2 - tslib: 2.8.1 - '@emnapi/core@1.9.2': dependencies: '@emnapi/wasi-threads': 1.2.1 tslib: 2.8.1 optional: true - '@emnapi/runtime@1.10.0': + '@emnapi/core@2.0.0-alpha.3': dependencies: + '@emnapi/wasi-threads': 2.0.1 tslib: 2.8.1 - optional: true - '@emnapi/runtime@1.11.2': + '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 + optional: true '@emnapi/runtime@1.9.2': dependencies: tslib: 2.8.1 optional: true + '@emnapi/runtime@2.0.0-alpha.3': + dependencies: + tslib: 2.8.1 + '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.2.2': + '@emnapi/wasi-threads@2.0.1': dependencies: tslib: 2.8.1 @@ -7630,12 +7631,6 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': - dependencies: - '@emnapi/core': 1.11.2 - '@emnapi/runtime': 1.11.2 - '@tybys/wasm-util': 0.10.3 - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': dependencies: '@emnapi/core': 1.9.2 @@ -7643,6 +7638,12 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)': + dependencies: + '@emnapi/core': 2.0.0-alpha.3 + '@emnapi/runtime': 2.0.0-alpha.3 + '@tybys/wasm-util': 0.10.3 + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -7669,74 +7670,74 @@ snapshots: dependencies: semver: 7.8.5 - '@oxc-parser/binding-android-arm-eabi@0.142.0': + '@oxc-parser/binding-android-arm-eabi@0.144.0': optional: true - '@oxc-parser/binding-android-arm64@0.142.0': + '@oxc-parser/binding-android-arm64@0.144.0': optional: true - '@oxc-parser/binding-darwin-arm64@0.142.0': + '@oxc-parser/binding-darwin-arm64@0.144.0': optional: true - '@oxc-parser/binding-darwin-x64@0.142.0': + '@oxc-parser/binding-darwin-x64@0.144.0': optional: true - '@oxc-parser/binding-freebsd-x64@0.142.0': + '@oxc-parser/binding-freebsd-x64@0.144.0': optional: true - '@oxc-parser/binding-linux-arm-gnueabihf@0.142.0': + '@oxc-parser/binding-linux-arm-gnueabihf@0.144.0': optional: true - '@oxc-parser/binding-linux-arm-musleabihf@0.142.0': + '@oxc-parser/binding-linux-arm-musleabihf@0.144.0': optional: true - '@oxc-parser/binding-linux-arm64-gnu@0.142.0': + '@oxc-parser/binding-linux-arm64-gnu@0.144.0': optional: true - '@oxc-parser/binding-linux-arm64-musl@0.142.0': + '@oxc-parser/binding-linux-arm64-musl@0.144.0': optional: true - '@oxc-parser/binding-linux-ppc64-gnu@0.142.0': + '@oxc-parser/binding-linux-ppc64-gnu@0.144.0': optional: true - '@oxc-parser/binding-linux-riscv64-gnu@0.142.0': + '@oxc-parser/binding-linux-riscv64-gnu@0.144.0': optional: true - '@oxc-parser/binding-linux-riscv64-musl@0.142.0': + '@oxc-parser/binding-linux-riscv64-musl@0.144.0': optional: true - '@oxc-parser/binding-linux-s390x-gnu@0.142.0': + '@oxc-parser/binding-linux-s390x-gnu@0.144.0': optional: true - '@oxc-parser/binding-linux-x64-gnu@0.142.0': + '@oxc-parser/binding-linux-x64-gnu@0.144.0': optional: true - '@oxc-parser/binding-linux-x64-musl@0.142.0': + '@oxc-parser/binding-linux-x64-musl@0.144.0': optional: true - '@oxc-parser/binding-openharmony-arm64@0.142.0': + '@oxc-parser/binding-openharmony-arm64@0.144.0': optional: true - '@oxc-parser/binding-wasm32-wasi@0.142.0': + '@oxc-parser/binding-wasm32-wasi@0.144.0': dependencies: - '@emnapi/core': 1.11.2 - '@emnapi/runtime': 1.11.2 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + '@emnapi/core': 2.0.0-alpha.3 + '@emnapi/runtime': 2.0.0-alpha.3 + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3) - '@oxc-parser/binding-win32-arm64-msvc@0.142.0': + '@oxc-parser/binding-win32-arm64-msvc@0.144.0': optional: true - '@oxc-parser/binding-win32-ia32-msvc@0.142.0': + '@oxc-parser/binding-win32-ia32-msvc@0.144.0': optional: true - '@oxc-parser/binding-win32-x64-msvc@0.142.0': + '@oxc-parser/binding-win32-x64-msvc@0.144.0': optional: true '@oxc-project/types@0.124.0': {} '@oxc-project/types@0.133.0': {} - '@oxc-project/types@0.142.0': {} + '@oxc-project/types@0.144.0': {} '@parcel/watcher-android-arm64@2.5.6': optional: true @@ -8543,20 +8544,20 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@vitejs/plugin-react@6.0.1(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0))': + '@vitejs/plugin-react@6.0.1(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 - vite: 8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0) + vite: 8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0) - '@vitejs/plugin-react@6.0.1(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0))': + '@vitejs/plugin-react@6.0.1(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 - vite: 8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0) + vite: 8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0) - '@vitejs/plugin-react@6.0.1(vite@8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0))': + '@vitejs/plugin-react@6.0.1(vite@8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 - vite: 8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0) + vite: 8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0) '@vitest/coverage-v8@4.1.4(vitest@4.1.4)': dependencies: @@ -8570,7 +8571,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)) + vitest: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)) '@vitest/expect@4.1.4': dependencies: @@ -8581,37 +8582,37 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.4(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0))': + '@vitest/mocker@4.1.4(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0) + vite: 8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0) - '@vitest/mocker@4.1.4(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0))': + '@vitest/mocker@4.1.4(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0) + vite: 8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0) - '@vitest/mocker@4.1.4(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0))': + '@vitest/mocker@4.1.4(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.16(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0) + vite: 8.0.16(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0) - '@vitest/mocker@4.1.4(vite@8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0))': + '@vitest/mocker@4.1.4(vite@8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0) + vite: 8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0) '@vitest/pretty-format@4.1.4': dependencies: @@ -8982,19 +8983,11 @@ snapshots: browserslist@4.28.6: dependencies: baseline-browser-mapping: 2.10.43 - caniuse-lite: 1.0.30001806 + caniuse-lite: 1.0.30001809 electron-to-chromium: 1.5.393 node-releases: 2.0.51 update-browserslist-db: 1.2.3(browserslist@4.28.6) - browserslist@4.28.7: - dependencies: - baseline-browser-mapping: 2.11.12 - caniuse-lite: 1.0.30001806 - electron-to-chromium: 1.5.393 - node-releases: 2.0.51 - update-browserslist-db: 1.2.3(browserslist@4.28.7) - browserslist@4.28.8: dependencies: baseline-browser-mapping: 2.11.12 @@ -9102,7 +9095,7 @@ snapshots: caniuse-api@4.0.0: dependencies: browserslist: 4.28.8 - caniuse-lite: 1.0.30001806 + caniuse-lite: 1.0.30001809 caniuse-lite@1.0.30001788: {} @@ -9334,46 +9327,46 @@ snapshots: cssesc@3.0.0: {} - cssnano-preset-default@8.0.5(postcss@8.5.26): + cssnano-preset-default@8.0.6(postcss@8.5.26): dependencies: browserslist: 4.28.8 - cssnano-utils: 6.0.3(postcss@8.5.26) + cssnano-utils: 6.0.4(postcss@8.5.26) postcss: 8.5.26 postcss-calc: 10.1.1(postcss@8.5.26) - postcss-colormin: 8.0.3(postcss@8.5.26) - postcss-convert-values: 8.0.3(postcss@8.5.26) - postcss-discard-comments: 8.0.3(postcss@8.5.26) - postcss-discard-duplicates: 8.0.3(postcss@8.5.26) - postcss-discard-empty: 8.0.3(postcss@8.5.26) - postcss-discard-overridden: 8.0.3(postcss@8.5.26) - postcss-merge-longhand: 8.0.3(postcss@8.5.26) - postcss-merge-rules: 8.0.3(postcss@8.5.26) - postcss-minify-font-values: 8.0.3(postcss@8.5.26) - postcss-minify-gradients: 8.0.3(postcss@8.5.26) - postcss-minify-params: 8.0.3(postcss@8.5.26) - postcss-minify-selectors: 8.0.4(postcss@8.5.26) - postcss-normalize-charset: 8.0.3(postcss@8.5.26) - postcss-normalize-display-values: 8.0.3(postcss@8.5.26) - postcss-normalize-positions: 8.0.3(postcss@8.5.26) - postcss-normalize-repeat-style: 8.0.3(postcss@8.5.26) - postcss-normalize-string: 8.0.3(postcss@8.5.26) - postcss-normalize-timing-functions: 8.0.3(postcss@8.5.26) - postcss-normalize-unicode: 8.0.3(postcss@8.5.26) - postcss-normalize-url: 8.0.3(postcss@8.5.26) - postcss-normalize-whitespace: 8.0.3(postcss@8.5.26) - postcss-ordered-values: 8.0.3(postcss@8.5.26) - postcss-reduce-initial: 8.0.3(postcss@8.5.26) - postcss-reduce-transforms: 8.0.3(postcss@8.5.26) - postcss-svgo: 8.0.4(postcss@8.5.26) - postcss-unique-selectors: 8.0.3(postcss@8.5.26) - - cssnano-utils@6.0.3(postcss@8.5.26): + postcss-colormin: 8.0.4(postcss@8.5.26) + postcss-convert-values: 8.0.4(postcss@8.5.26) + postcss-discard-comments: 8.0.4(postcss@8.5.26) + postcss-discard-duplicates: 8.0.4(postcss@8.5.26) + postcss-discard-empty: 8.0.4(postcss@8.5.26) + postcss-discard-overridden: 8.0.4(postcss@8.5.26) + postcss-merge-longhand: 8.0.4(postcss@8.5.26) + postcss-merge-rules: 8.0.4(postcss@8.5.26) + postcss-minify-font-values: 8.0.4(postcss@8.5.26) + postcss-minify-gradients: 8.0.4(postcss@8.5.26) + postcss-minify-params: 8.0.4(postcss@8.5.26) + postcss-minify-selectors: 8.0.5(postcss@8.5.26) + postcss-normalize-charset: 8.0.4(postcss@8.5.26) + postcss-normalize-display-values: 8.0.4(postcss@8.5.26) + postcss-normalize-positions: 8.0.4(postcss@8.5.26) + postcss-normalize-repeat-style: 8.0.4(postcss@8.5.26) + postcss-normalize-string: 8.0.4(postcss@8.5.26) + postcss-normalize-timing-functions: 8.0.4(postcss@8.5.26) + postcss-normalize-unicode: 8.0.4(postcss@8.5.26) + postcss-normalize-url: 8.0.4(postcss@8.5.26) + postcss-normalize-whitespace: 8.0.4(postcss@8.5.26) + postcss-ordered-values: 8.0.4(postcss@8.5.26) + postcss-reduce-initial: 8.0.4(postcss@8.5.26) + postcss-reduce-transforms: 8.0.4(postcss@8.5.26) + postcss-svgo: 8.0.5(postcss@8.5.26) + postcss-unique-selectors: 8.0.4(postcss@8.5.26) + + cssnano-utils@6.0.4(postcss@8.5.26): dependencies: postcss: 8.5.26 - cssnano@8.0.5(postcss@8.5.26): + cssnano@8.0.6(postcss@8.5.26): dependencies: - cssnano-preset-default: 8.0.5(postcss@8.5.26) + cssnano-preset-default: 8.0.6(postcss@8.5.26) lilconfig: 3.1.3 postcss: 8.5.26 @@ -10786,7 +10779,7 @@ snapshots: lazy-val@1.0.5: {} - less@4.8.1: + less@4.9.0: dependencies: copy-anything: 3.0.5 parse-node-version: 1.0.1 @@ -11267,35 +11260,34 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 - oxc-parser@0.142.0: + oxc-parser@0.144.0: dependencies: - '@oxc-project/types': 0.142.0 + '@oxc-project/types': 0.144.0 optionalDependencies: - '@oxc-parser/binding-android-arm-eabi': 0.142.0 - '@oxc-parser/binding-android-arm64': 0.142.0 - '@oxc-parser/binding-darwin-arm64': 0.142.0 - '@oxc-parser/binding-darwin-x64': 0.142.0 - '@oxc-parser/binding-freebsd-x64': 0.142.0 - '@oxc-parser/binding-linux-arm-gnueabihf': 0.142.0 - '@oxc-parser/binding-linux-arm-musleabihf': 0.142.0 - '@oxc-parser/binding-linux-arm64-gnu': 0.142.0 - '@oxc-parser/binding-linux-arm64-musl': 0.142.0 - '@oxc-parser/binding-linux-ppc64-gnu': 0.142.0 - '@oxc-parser/binding-linux-riscv64-gnu': 0.142.0 - '@oxc-parser/binding-linux-riscv64-musl': 0.142.0 - '@oxc-parser/binding-linux-s390x-gnu': 0.142.0 - '@oxc-parser/binding-linux-x64-gnu': 0.142.0 - '@oxc-parser/binding-linux-x64-musl': 0.142.0 - '@oxc-parser/binding-openharmony-arm64': 0.142.0 - '@oxc-parser/binding-wasm32-wasi': 0.142.0 - '@oxc-parser/binding-win32-arm64-msvc': 0.142.0 - '@oxc-parser/binding-win32-ia32-msvc': 0.142.0 - '@oxc-parser/binding-win32-x64-msvc': 0.142.0 - - oxc-walker@1.1.1(@oxc-project/types@0.142.0)(oxc-parser@0.142.0)(rolldown@1.0.3): + '@oxc-parser/binding-android-arm-eabi': 0.144.0 + '@oxc-parser/binding-android-arm64': 0.144.0 + '@oxc-parser/binding-darwin-arm64': 0.144.0 + '@oxc-parser/binding-darwin-x64': 0.144.0 + '@oxc-parser/binding-freebsd-x64': 0.144.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.144.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.144.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.144.0 + '@oxc-parser/binding-linux-arm64-musl': 0.144.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.144.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.144.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.144.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.144.0 + '@oxc-parser/binding-linux-x64-gnu': 0.144.0 + '@oxc-parser/binding-linux-x64-musl': 0.144.0 + '@oxc-parser/binding-openharmony-arm64': 0.144.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.144.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.144.0 + '@oxc-parser/binding-win32-x64-msvc': 0.144.0 + + oxc-walker@1.1.1(@oxc-project/types@0.144.0)(oxc-parser@0.144.0)(rolldown@1.0.3): optionalDependencies: - '@oxc-project/types': 0.142.0 - oxc-parser: 0.142.0 + '@oxc-project/types': 0.144.0 + oxc-parser: 0.144.0 rolldown: 1.0.3 p-cancelable@2.1.1: {} @@ -11411,7 +11403,7 @@ snapshots: postcss-selector-parser: 7.1.5 postcss-value-parser: 4.2.0 - postcss-colormin@8.0.3(postcss@8.5.26): + postcss-colormin@8.0.4(postcss@8.5.26): dependencies: '@colordx/core': 5.5.0 browserslist: 4.28.8 @@ -11419,26 +11411,26 @@ snapshots: postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-convert-values@8.0.3(postcss@8.5.26): + postcss-convert-values@8.0.4(postcss@8.5.26): dependencies: browserslist: 4.28.8 postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-discard-comments@8.0.3(postcss@8.5.26): + postcss-discard-comments@8.0.4(postcss@8.5.26): dependencies: postcss: 8.5.26 postcss-selector-parser: 7.1.5 - postcss-discard-duplicates@8.0.3(postcss@8.5.26): + postcss-discard-duplicates@8.0.4(postcss@8.5.26): dependencies: postcss: 8.5.26 - postcss-discard-empty@8.0.3(postcss@8.5.26): + postcss-discard-empty@8.0.4(postcss@8.5.26): dependencies: postcss: 8.5.26 - postcss-discard-overridden@8.0.3(postcss@8.5.26): + postcss-discard-overridden@8.0.4(postcss@8.5.26): dependencies: postcss: 8.5.26 @@ -11462,40 +11454,40 @@ snapshots: postcss: 8.5.26 yaml: 2.9.0 - postcss-merge-longhand@8.0.3(postcss@8.5.26): + postcss-merge-longhand@8.0.4(postcss@8.5.26): dependencies: postcss: 8.5.26 postcss-value-parser: 4.2.0 - stylehacks: 8.0.3(postcss@8.5.26) + stylehacks: 8.0.4(postcss@8.5.26) - postcss-merge-rules@8.0.3(postcss@8.5.26): + postcss-merge-rules@8.0.4(postcss@8.5.26): dependencies: browserslist: 4.28.8 caniuse-api: 4.0.0 - cssnano-utils: 6.0.3(postcss@8.5.26) + cssnano-utils: 6.0.4(postcss@8.5.26) postcss: 8.5.26 postcss-selector-parser: 7.1.5 - postcss-minify-font-values@8.0.3(postcss@8.5.26): + postcss-minify-font-values@8.0.4(postcss@8.5.26): dependencies: postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-minify-gradients@8.0.3(postcss@8.5.26): + postcss-minify-gradients@8.0.4(postcss@8.5.26): dependencies: '@colordx/core': 5.5.0 - cssnano-utils: 6.0.3(postcss@8.5.26) + cssnano-utils: 6.0.4(postcss@8.5.26) postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-minify-params@8.0.3(postcss@8.5.26): + postcss-minify-params@8.0.4(postcss@8.5.26): dependencies: browserslist: 4.28.8 - cssnano-utils: 6.0.3(postcss@8.5.26) + cssnano-utils: 6.0.4(postcss@8.5.26) postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-minify-selectors@8.0.4(postcss@8.5.26): + postcss-minify-selectors@8.0.5(postcss@8.5.26): dependencies: browserslist: 4.28.8 caniuse-api: 4.0.0 @@ -11508,64 +11500,64 @@ snapshots: postcss: 8.5.26 postcss-selector-parser: 6.1.4 - postcss-normalize-charset@8.0.3(postcss@8.5.26): + postcss-normalize-charset@8.0.4(postcss@8.5.26): dependencies: postcss: 8.5.26 - postcss-normalize-display-values@8.0.3(postcss@8.5.26): + postcss-normalize-display-values@8.0.4(postcss@8.5.26): dependencies: postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-normalize-positions@8.0.3(postcss@8.5.26): + postcss-normalize-positions@8.0.4(postcss@8.5.26): dependencies: postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-normalize-repeat-style@8.0.3(postcss@8.5.26): + postcss-normalize-repeat-style@8.0.4(postcss@8.5.26): dependencies: postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-normalize-string@8.0.3(postcss@8.5.26): + postcss-normalize-string@8.0.4(postcss@8.5.26): dependencies: postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-normalize-timing-functions@8.0.3(postcss@8.5.26): + postcss-normalize-timing-functions@8.0.4(postcss@8.5.26): dependencies: postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-normalize-unicode@8.0.3(postcss@8.5.26): + postcss-normalize-unicode@8.0.4(postcss@8.5.26): dependencies: browserslist: 4.28.8 postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-normalize-url@8.0.3(postcss@8.5.26): + postcss-normalize-url@8.0.4(postcss@8.5.26): dependencies: postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-normalize-whitespace@8.0.3(postcss@8.5.26): + postcss-normalize-whitespace@8.0.4(postcss@8.5.26): dependencies: postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-ordered-values@8.0.3(postcss@8.5.26): + postcss-ordered-values@8.0.4(postcss@8.5.26): dependencies: - cssnano-utils: 6.0.3(postcss@8.5.26) + cssnano-utils: 6.0.4(postcss@8.5.26) postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-reduce-initial@8.0.3(postcss@8.5.26): + postcss-reduce-initial@8.0.4(postcss@8.5.26): dependencies: browserslist: 4.28.8 caniuse-api: 4.0.0 postcss: 8.5.26 - postcss-reduce-transforms@8.0.3(postcss@8.5.26): + postcss-reduce-transforms@8.0.4(postcss@8.5.26): dependencies: postcss: 8.5.26 postcss-value-parser: 4.2.0 @@ -11580,13 +11572,13 @@ snapshots: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss-svgo@8.0.4(postcss@8.5.26): + postcss-svgo@8.0.5(postcss@8.5.26): dependencies: postcss: 8.5.26 postcss-value-parser: 4.2.0 svgo: 4.0.2 - postcss-unique-selectors@8.0.3(postcss@8.5.26): + postcss-unique-selectors@8.0.4(postcss@8.5.26): dependencies: postcss: 8.5.26 postcss-selector-parser: 7.1.5 @@ -12208,7 +12200,7 @@ snapshots: dependencies: min-indent: 1.0.1 - stylehacks@8.0.3(postcss@8.5.26): + stylehacks@8.0.4(postcss@8.5.26): dependencies: browserslist: 4.28.8 postcss: 8.5.26 @@ -12511,12 +12503,6 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - update-browserslist-db@1.2.3(browserslist@4.28.7): - dependencies: - browserslist: 4.28.7 - escalade: 3.2.0 - picocolors: 1.1.1 - update-browserslist-db@1.3.1(browserslist@4.28.8): dependencies: browserslist: 4.28.8 @@ -12567,7 +12553,7 @@ snapshots: extsprintf: 1.4.1 optional: true - vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0): + vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -12579,11 +12565,11 @@ snapshots: esbuild: 0.28.2 fsevents: 2.3.3 jiti: 2.7.0 - less: 4.8.1 + less: 4.9.0 sass: 1.102.0 yaml: 2.9.0 - vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0): + vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -12595,11 +12581,11 @@ snapshots: esbuild: 0.28.1 fsevents: 2.3.3 jiti: 2.7.0 - less: 4.8.1 + less: 4.9.0 sass: 1.102.0 yaml: 2.9.0 - vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0): + vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -12611,11 +12597,11 @@ snapshots: esbuild: 0.28.2 fsevents: 2.3.3 jiti: 2.7.0 - less: 4.8.1 + less: 4.9.0 sass: 1.102.0 yaml: 2.9.0 - vite@8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0): + vite@8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -12627,14 +12613,14 @@ snapshots: esbuild: 0.28.1 fsevents: 2.3.3 jiti: 1.21.7 - less: 4.8.1 + less: 4.9.0 sass: 1.102.0 yaml: 2.9.0 - vitest@4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)): + vitest@4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.4 - '@vitest/mocker': 4.1.4(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.4(vite@8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.4 '@vitest/runner': 4.1.4 '@vitest/snapshot': 4.1.4 @@ -12651,7 +12637,7 @@ snapshots: tinyexec: 1.1.1 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0) + vite: 8.0.16(@types/node@22.19.17)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.17 @@ -12660,10 +12646,10 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)): + vitest@4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.4 - '@vitest/mocker': 4.1.4(vite@8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.4(vite@8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.4 '@vitest/runner': 4.1.4 '@vitest/snapshot': 4.1.4 @@ -12680,7 +12666,7 @@ snapshots: tinyexec: 1.1.1 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0) + vite: 8.0.8(@types/node@22.19.17)(esbuild@0.28.1)(jiti@1.21.7)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.17 @@ -12689,10 +12675,10 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.4(@types/node@24.12.2)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)): + vitest@4.1.4(@types/node@24.12.2)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.4 - '@vitest/mocker': 4.1.4(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.4(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.4 '@vitest/runner': 4.1.4 '@vitest/snapshot': 4.1.4 @@ -12709,7 +12695,7 @@ snapshots: tinyexec: 1.1.1 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0) + vite: 8.0.16(@types/node@24.12.2)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.12.2 @@ -12718,10 +12704,10 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.4(@types/node@24.12.2)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)): + vitest@4.1.4(@types/node@24.12.2)(@vitest/coverage-v8@4.1.4)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.4 - '@vitest/mocker': 4.1.4(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.4(vite@8.0.16(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.4 '@vitest/runner': 4.1.4 '@vitest/snapshot': 4.1.4 @@ -12738,7 +12724,7 @@ snapshots: tinyexec: 1.1.1 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.8.1)(sass@1.102.0)(yaml@2.9.0) + vite: 8.0.16(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.0)(sass@1.102.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.12.2