From 5b8c63e87bccc4563f45b90cc14cf85e7362eba8 Mon Sep 17 00:00:00 2001
From: minimax
Date: Fri, 23 Oct 2026 16:26:47 +0800
Subject: [PATCH 1/6] feat: add native WeChat project preview Mini App
---
README.md | 1 +
README.zh-CN.md | 1 +
.../wechat-miniprogram-preview/.gitignore | 4 +
.../.minimax-plugin/plugin.json | 22 +
.../wechat-miniprogram-preview/LICENSE | 21 +
.../wechat-miniprogram-preview/README.md | 142 +
.../README.zh-CN.md | 89 +
.../docs/architecture.md | 83 +
.../docs/demo-game-2d.md | 11 +
.../docs/demo-game-webgl.md | 11 +
.../wechat-miniprogram-preview/icon.png | Bin 0 -> 1574 bytes
.../miniapp/client/css-urls.mjs | 64 +
.../miniapp/client/device-config.js | 79 +
.../miniapp/client/game-runtime.css | 11 +
.../miniapp/client/game-runtime.js | 646 +
.../miniapp/client/icons.js | 13 +
.../miniapp/client/index.html | 50 +
.../miniapp/client/preview-chrome.css | 73 +
.../miniapp/client/shell.css | 119 +
.../miniapp/client/shell.js | 847 +
.../miniapp/client/sim-base.css | 47 +
.../miniapp/client/sim-runtime.js | 2699 ++
.../miniapp/client/style-values.mjs | 77 +
.../client/vendor/THIRD-PARTY-NOTICES.txt | 20 +
.../client/vendor/component-runtime.js | 6949 +++++
.../miniapp/miniapp.json | 20 +
.../miniapp/node/build-cache.mjs | 267 +
.../miniapp/node/compiler.mjs | 610 +
.../miniapp/node/demo-game-2d/assets/quad.png | Bin 0 -> 75 bytes
.../miniapp/node/demo-game-2d/game.js | 106 +
.../miniapp/node/demo-game-2d/game.json | 3 +
.../miniapp/node/demo-game-2d/palette.js | 8 +
.../node/demo-game-2d/project.config.json | 8 +
.../node/demo-game-webgl/assets/atlas.png | Bin 0 -> 127 bytes
.../miniapp/node/demo-game-webgl/game.js | 78 +
.../miniapp/node/demo-game-webgl/game.json | 3 +
.../node/demo-game-webgl/project.config.json | 8 +
.../miniapp/node/demo-game-webgl/shaders.js | 2 +
.../miniapp/node/demo/miniprogram/app.js | 4 +
.../miniapp/node/demo/miniprogram/app.json | 7 +
.../miniapp/node/demo/miniprogram/app.wxss | 1 +
.../miniprogram/components/counter/counter.js | 5 +
.../components/counter/counter.json | 1 +
.../components/counter/counter.wxml | 1 +
.../components/counter/counter.wxss | 1 +
.../demo/miniprogram/pages/detail/detail.js | 1 +
.../demo/miniprogram/pages/detail/detail.wxml | 1 +
.../demo/miniprogram/pages/index/index.js | 14 +
.../demo/miniprogram/pages/index/index.json | 1 +
.../demo/miniprogram/pages/index/index.wxml | 15 +
.../miniprogram/pages/settings/settings.js | 1 +
.../miniprogram/pages/settings/settings.wxml | 1 +
.../miniapp/node/demo/project.config.json | 1 +
.../miniapp/node/esm.mjs | 5 +
.../miniapp/node/game-compiler.mjs | 62 +
.../miniapp/node/mcp.mjs | 145 +
.../miniapp/node/module-graph.mjs | 93 +
.../miniapp/node/preview-assets.mjs | 18 +
.../miniapp/node/preview-chrome.mjs | 2 +
.../miniapp/node/preview-contexts.mjs | 340 +
.../miniapp/node/preview-service.mjs | 673 +
.../miniapp/node/preview-shells.mjs | 174 +
.../miniapp/node/project-access.mjs | 160 +
.../miniapp/node/project-builds.mjs | 369 +
.../miniapp/node/project-json.mjs | 118 +
.../miniapp/node/project-roots.json | 3 +
.../miniapp/node/project-storage.mjs | 484 +
.../miniapp/node/server.mjs | 484 +
.../node/vendor/THIRD-PARTY-NOTICES.txt | 349 +
.../miniapp/node/vendor/js-transform.mjs | 23750 ++++++++++++++++
.../miniapp/node/wxml.mjs | 189 +
.../miniapp/node/wxss.mjs | 435 +
.../wechat-miniprogram-preview/package.json | 22 +
.../wechat-miniprogram-preview/pnpm-lock.yaml | 970 +
.../scripts/build.mjs | 53 +
.../scripts/package.mjs | 95 +
.../servers.mcp.json | 12 +
.../skills/wechat-project-preview/SKILL.md | 54 +
.../src/component-runtime.mjs | 447 +
.../src/js-transform.mjs | 97 +
.../tests/build-cache.test.mjs | 321 +
.../tests/client.test.mjs | 259 +
.../tests/compiler-modules.test.mjs | 266 +
.../tests/component-bridge.test.mjs | 459 +
.../tests/component-parity.test.mjs | 427 +
.../tests/dependency-graph.test.mjs | 199 +
.../tests/game-compiler.test.mjs | 184 +
.../tests/game-http.test.mjs | 140 +
.../tests/game-runtime.test.mjs | 405 +
.../tests/game-service.test.mjs | 165 +
.../tests/helpers/game-dom.mjs | 50 +
.../tests/helpers/runtime-dom.mjs | 71 +
.../tests/mcp-protocol.test.mjs | 62 +
.../tests/page-stack.test.mjs | 159 +
.../tests/preview-contexts.test.mjs | 160 +
.../tests/preview-http.test.mjs | 258 +
.../tests/preview-service.test.mjs | 641 +
.../tests/preview-shells.test.mjs | 148 +
.../tests/preview.test.mjs | 82 +
.../tests/project-access.test.mjs | 128 +
.../tests/project-builds.test.mjs | 275 +
.../tests/project-discovery.test.mjs | 143 +
.../tests/project-storage.test.mjs | 480 +
.../tests/recovery-http.test.mjs | 70 +
.../tests/runtime-behavior.test.mjs | 288 +
.../tests/shell-protocol.test.mjs | 794 +
.../tests/shell-ui.test.mjs | 41 +
.../tests/state-migration.test.mjs | 159 +
.../tests/viewport-controls.test.mjs | 79 +
.../tests/viewport-styles.test.mjs | 165 +
.../tests/wxss-diagnostics.test.mjs | 115 +
111 files changed, 50043 insertions(+)
create mode 100644 plugins/adherez/wechat-miniprogram-preview/.gitignore
create mode 100644 plugins/adherez/wechat-miniprogram-preview/.minimax-plugin/plugin.json
create mode 100644 plugins/adherez/wechat-miniprogram-preview/LICENSE
create mode 100644 plugins/adherez/wechat-miniprogram-preview/README.md
create mode 100644 plugins/adherez/wechat-miniprogram-preview/README.zh-CN.md
create mode 100644 plugins/adherez/wechat-miniprogram-preview/docs/architecture.md
create mode 100644 plugins/adherez/wechat-miniprogram-preview/docs/demo-game-2d.md
create mode 100644 plugins/adherez/wechat-miniprogram-preview/docs/demo-game-webgl.md
create mode 100644 plugins/adherez/wechat-miniprogram-preview/icon.png
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/client/css-urls.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/client/device-config.js
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/client/game-runtime.css
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/client/game-runtime.js
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/client/icons.js
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/client/index.html
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/client/preview-chrome.css
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/client/shell.css
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/client/shell.js
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/client/sim-base.css
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/client/sim-runtime.js
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/client/style-values.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/client/vendor/THIRD-PARTY-NOTICES.txt
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/client/vendor/component-runtime.js
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/miniapp.json
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/build-cache.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/compiler.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/demo-game-2d/assets/quad.png
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/demo-game-2d/game.js
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/demo-game-2d/game.json
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/demo-game-2d/palette.js
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/demo-game-2d/project.config.json
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/demo-game-webgl/assets/atlas.png
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/demo-game-webgl/game.js
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/demo-game-webgl/game.json
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/demo-game-webgl/project.config.json
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/demo-game-webgl/shaders.js
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/demo/miniprogram/app.js
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/demo/miniprogram/app.json
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/demo/miniprogram/app.wxss
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/demo/miniprogram/components/counter/counter.js
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/demo/miniprogram/components/counter/counter.json
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/demo/miniprogram/components/counter/counter.wxml
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/demo/miniprogram/components/counter/counter.wxss
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/demo/miniprogram/pages/detail/detail.js
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/demo/miniprogram/pages/detail/detail.wxml
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/demo/miniprogram/pages/index/index.js
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/demo/miniprogram/pages/index/index.json
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/demo/miniprogram/pages/index/index.wxml
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/demo/miniprogram/pages/settings/settings.js
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/demo/miniprogram/pages/settings/settings.wxml
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/demo/project.config.json
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/esm.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/game-compiler.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/mcp.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/module-graph.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/preview-assets.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/preview-chrome.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/preview-contexts.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/preview-service.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/preview-shells.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/project-access.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/project-builds.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/project-json.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/project-roots.json
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/project-storage.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/server.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/vendor/THIRD-PARTY-NOTICES.txt
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/vendor/js-transform.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/wxml.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/miniapp/node/wxss.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/package.json
create mode 100644 plugins/adherez/wechat-miniprogram-preview/pnpm-lock.yaml
create mode 100644 plugins/adherez/wechat-miniprogram-preview/scripts/build.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/scripts/package.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/servers.mcp.json
create mode 100644 plugins/adherez/wechat-miniprogram-preview/skills/wechat-project-preview/SKILL.md
create mode 100644 plugins/adherez/wechat-miniprogram-preview/src/component-runtime.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/src/js-transform.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/tests/build-cache.test.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/tests/client.test.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/tests/compiler-modules.test.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/tests/component-bridge.test.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/tests/component-parity.test.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/tests/dependency-graph.test.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/tests/game-compiler.test.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/tests/game-http.test.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/tests/game-runtime.test.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/tests/game-service.test.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/tests/helpers/game-dom.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/tests/helpers/runtime-dom.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/tests/mcp-protocol.test.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/tests/page-stack.test.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/tests/preview-contexts.test.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/tests/preview-http.test.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/tests/preview-service.test.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/tests/preview-shells.test.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/tests/preview.test.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/tests/project-access.test.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/tests/project-builds.test.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/tests/project-discovery.test.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/tests/project-storage.test.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/tests/recovery-http.test.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/tests/runtime-behavior.test.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/tests/shell-protocol.test.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/tests/shell-ui.test.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/tests/state-migration.test.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/tests/viewport-controls.test.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/tests/viewport-styles.test.mjs
create mode 100644 plugins/adherez/wechat-miniprogram-preview/tests/wxss-diagnostics.test.mjs
diff --git a/README.md b/README.md
index 6361d109..90b6dab6 100644
--- a/README.md
+++ b/README.md
@@ -57,6 +57,7 @@ The MiniMax Code Agent communicates through the host MCP client. Business reques
| MiniApp | What it does | Author |
| --- | --- | --- |
+| [WeChat Mini Program & Game Preview](plugins/adherez/wechat-miniprogram-preview/) | Preview native WXML/WXSS/JS/TS projects and Canvas2D/WebGL mini games locally, with retained page stacks, live rebuilds and persistent Storage | [AdhereZ](https://github.com/AdhereZ) |
| [Dynamic Workflow Console](plugins/stevenjj33/workflow-console/) | Run and supervise MiniMax Code dynamic workflows from one page: live status, a stage-and-dependency flow graph, per-node output, approval and control, templates, and report export | [stevenjj33](https://github.com/stevenjj33) |
| [MiniMax Photo](plugins/webguhui/minimax-photo/) | Layered image editing with an in-app AI assistant, local recovery and programmatic editing tools | [WebGuHui](https://github.com/WebGuHui) |
| [MiniMax Video](plugins/webguhui/minimax-video/) | Local video editing with captions, a host Agent assistant, reviewable timeline edits, and MP4/MP3 export | [WebGuHui](https://github.com/WebGuHui) |
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 58b60d69..64e694d4 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -57,6 +57,7 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求
| MiniApp | 功能 | 作者 |
| --- | --- | --- |
+| [微信小程序与小游戏预览](plugins/adherez/wechat-miniprogram-preview/README.zh-CN.md) | 在既有 Mini App 中预览原生 WXML/WXSS/JS/TS 工程与 Canvas2D/WebGL 小游戏,支持页面栈保留、自动重建和持久 Storage | [AdhereZ](https://github.com/AdhereZ) |
| [动态工作流控制台](plugins/stevenjj33/workflow-console/README.zh-CN.md) | 在一个页面里运行并盯住 MiniMax Code 动态工作流:实时状态、按阶段与依赖画出的流程图、节点输出、审核与控制、模板与报告导出 | [stevenjj33](https://github.com/stevenjj33) |
| [MiniMax Photo](plugins/webguhui/minimax-photo/) | 图层图片编辑、应用内 AI 助手、本地恢复与程序化编辑工具 | [WebGuHui](https://github.com/WebGuHui) |
| [MiniMax Video](plugins/webguhui/minimax-video/) | 本地视频剪辑、字幕、宿主 Agent 助手、可预览的时间线修改与 MP4/MP3 导出 | [WebGuHui](https://github.com/WebGuHui) |
diff --git a/plugins/adherez/wechat-miniprogram-preview/.gitignore b/plugins/adherez/wechat-miniprogram-preview/.gitignore
new file mode 100644
index 00000000..3531bfa2
--- /dev/null
+++ b/plugins/adherez/wechat-miniprogram-preview/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+tests/.scratch/
+.DS_Store
+*.zip
diff --git a/plugins/adherez/wechat-miniprogram-preview/.minimax-plugin/plugin.json b/plugins/adherez/wechat-miniprogram-preview/.minimax-plugin/plugin.json
new file mode 100644
index 00000000..8ca67574
--- /dev/null
+++ b/plugins/adherez/wechat-miniprogram-preview/.minimax-plugin/plugin.json
@@ -0,0 +1,22 @@
+{
+ "schemaVersion": 1,
+ "name": "wechat-miniprogram-preview",
+ "displayName": "微信小程序与小游戏预览",
+ "version": "0.5.0-rc.1",
+ "description": "Agent 直接打开原生微信小程序与小游戏工程,在既有 Mini App 内展示。本地编译、自动更新、持久存储,页面只保留刷新预览,诊断在聊天中查询。",
+ "author": "AdhereZ",
+ "icon": "icon.png",
+ "category": "Code",
+ "exampleQueries": [
+ "打开微信小程序预览",
+ "预览当前工作区的小程序并读取构建错误",
+ "打开微信小游戏预览并查看运行日志"
+ ],
+ "apps": [],
+ "mcpServers": [
+ "servers.mcp.json"
+ ],
+ "skills": [
+ "skills/wechat-project-preview/SKILL.md"
+ ]
+}
diff --git a/plugins/adherez/wechat-miniprogram-preview/LICENSE b/plugins/adherez/wechat-miniprogram-preview/LICENSE
new file mode 100644
index 00000000..6704107b
--- /dev/null
+++ b/plugins/adherez/wechat-miniprogram-preview/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 AdhereZ
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/plugins/adherez/wechat-miniprogram-preview/README.md b/plugins/adherez/wechat-miniprogram-preview/README.md
new file mode 100644
index 00000000..0a130b25
--- /dev/null
+++ b/plugins/adherez/wechat-miniprogram-preview/README.md
@@ -0,0 +1,142 @@
+# WeChat Mini Program & Game Preview
+
+English | [简体中文](README.zh-CN.md)
+
+A local MiniMax Code Mini App for previewing an **existing native WeChat project**. It compiles
+WXML/WXSS/JavaScript/TypeScript into a browser approximation, or runs a Canvas2D/WebGL mini game,
+inside the same preview plugin. It does not replace your project with a generated HTML app.
+
+The page contains a phone preview and one **Refresh preview** button. Project selection, build
+errors, runtime logs and recovery are handled through the chat's MCP tools. The interface and
+bundled workflow skill are currently in Chinese.
+
+## Features
+
+- Native project discovery from `project.config.json`, `app.json`, or `game.json`; JS/TS, CommonJS,
+ ESM and prebuilt npm module graphs. Taro/uni-app projects must first produce native output.
+- Page/component rendering using bundled glass-easel 1.2.0 and its miniprogram adapter, with a
+ custom WXML/WXSS bridge and a limited `wx` API implementation.
+- Retained page stacks: `navigateTo`, `navigateBack`, `redirectTo`, `switchTab`, `reLaunch` and
+ `getCurrentPages`. Returning preserves instances, native input drafts, scroll positions and
+ animation timelines, rather than replaying the page's initial animation.
+- Separate Canvas2D/WebGL mini-game runtime, local images, orientation/DPR handling and chat-side
+ pause/resume/restart controls.
+- Source watching, debounced rebuilds, immutable build resources and project-scoped persistent
+ Storage with revision checks. Automatic rebuilds are **not state-preserving HMR**.
+- A fixed, decorative WeChat-style capsule and device notch. Capsule colors follow navigation
+ configuration (default white, explicit black supported), not scrolling page content. The
+ simulated status bar shows time, Wi-Fi and battery, without a cellular signal icon.
+
+## Install and open
+
+The runtime bundles are committed: **installation requires no npm install or build**.
+
+1. Copy this complete directory, including `.minimax-plugin`, into
+ `/plugins/wechat-miniprogram-preview/`. The default `dataDir` is `~/.minimax`; use your
+ configured directory if different. Do not include the `adherez` parent directory.
+2. Follow the repository's [installation instructions](../../../README.md#2-install). Do not
+ copy over a running plugin without first saving/exporting any pending preview data.
+3. In your project chat, ask:
+
+ ```text
+ Use the installed wechat-miniprogram-preview Mini App to preview this native WeChat project.
+ Reuse this plugin; do not create a replacement HTML Mini App or modify project code just to open it.
+ ```
+
+The bundled `wechat-project-preview` skill tells the Agent to list existing previews, open the
+explicit project path, and execute the returned **`hostOpenAction`** with the Host's `miniapp`
+tool. A successful compile alone does not open the side panel. Internal paths such as
+`/preview?previewId=...` are Host routes, not public links or ordinary browser URLs.
+
+Opening `/preview` without a preview ID shows an empty-state instruction; it never selects another
+chat's project automatically. Normal explicit project opens do not require a second directory
+authorization dialog. An occupied preview is not automatically taken over.
+
+## Tested environment
+
+- MiniMax Code **3.1.4**, macOS on Apple Silicon; Node.js 22 for development checks.
+- The local development version has been installed and started through the desktop Host. Native
+ Mini Program and Canvas2D/WebGL demo previews have been exercised during development.
+- Page-stack, capsule/default-color and notch changes have focused Node/jsdom tests and isolated
+ Chromium checks. The final cellular-icon removal was installed without an additional visual
+ acceptance pass; do not interpret that as a new full Host acceptance run.
+- Windows, Linux desktop hosts, real-device rendering, large game engines, and the exact
+ public-package install flow have not been manually verified. No claim of complete parity with
+ WeChat DevTools or a WeChat device is made.
+
+## Data & access
+
+- **Files read:** the explicit project directory's supported source/configuration/media files,
+ its configured source root and referenced local dependencies; the plugin's own runtime/demo
+ assets. No project scripts, package install commands, or Node business modules are executed by
+ the compiler. It checks canonical paths, directory identity and resource/size limits, rejecting
+ broad system/home/configuration roots and escaping links. These checks are not a per-chat
+ filesystem permission sandbox; only preview projects you trust.
+- **Files written:** preview descriptors (including canonical project paths), Storage and migration
+ metadata under the Host-provided **`context.dataDir`**. This directory is treated as opaque.
+ The runtime does not edit your project, read Host credentials, inspect chat history, or depend on
+ Host database locations. User-requested Storage export downloads a JSON file through the browser.
+- **Network:** Node makes no outbound requests. Its HTTP/MCP/SSE server binds only the Host-assigned
+ loopback address/port. The preview iframe allows **HTTPS image URLs authored by the project**
+ (`img-src https:`), which can contact arbitrary project-chosen image hosts and disclose usual
+ network metadata; there is no fixed host allowlist. `wx.request` and other network APIs are not
+ implemented; iframe `connect-src` is `none`. There is no analytics or upload service.
+- **Processes:** the plugin spawns none. MiniMax Code starts its Node runtime. Development-only
+ build/test commands are run explicitly by contributors, not during installation or startup.
+- **Agent turns:** the Mini App page does not run Agent turns and declares no `agentAccess` or
+ `hostConnectorAccess`. The surrounding chat Agent can invoke the declared `wechat-preview` MCP
+ tools and read build diagnostics/logs. Logs may contain application text; do not log secrets in
+ a project you preview. Storage recovery passes metadata to the Agent, not saved business data.
+- **Execution boundary:** project JavaScript runs in an opaque sandboxed iframe (`allow-scripts`,
+ no `allow-same-origin`), without Node or Host credentials. The interpreter requires CSP
+ `unsafe-eval`. This is not an arbitrary-malicious-code sandbox: loops can stall rendering,
+ HTTPS images can make requests, and browser scheduling is not a native debugger.
+
+## Refresh, state and recovery
+
+Within one live preview, navigation retains up to 10 page entries. Returning calls `onShow`; it
+does not suppress project code that deliberately reloads data there. Rebuild, manual refresh,
+device changes and runtime restarts replace the iframe: page-stack memory and uncommitted input
+are lost, while confirmed Storage and the last route persist.
+
+Storage is scoped by canonical project directory, not chat. Multiple previews of the same
+directory share saved data and use revision/CAS checks. A project has a 1 MiB JSON Storage limit.
+The process permits at most 8 preview descriptors, 4 active projects and 4 connected preview
+pages. Resource limits return diagnostics rather than silently closing another preview.
+
+On conflicts, lost responses or occupied previews, use `preview_status` / `preview_logs` and the
+reported recovery actions. `takeover`, `discard_local` and `close` require explicit confirmation.
+Do not keep creating new previews or editing private state files to bypass ownership. Before
+updates, export valuable pending data; stop/start only this Mini App if the Host retains an old
+runtime. Reopen its original `previewId` after restart. Saved Storage is not a backup of page memory.
+
+## Known limitations
+
+This is an H5 approximation, not the WeChat runtime or compiler. It does not implement login,
+payment, cloud services, scanning, real-device preview, upload/release, Worker/WASM, open-data
+contexts, game subpackage loading or the full WeChat API. WebAudio and several native controls
+are unsupported. Custom tab bars, complex WXS/CSS, native navigation transitions and EventChannel
+are not fully reproduced. The capsule is decoration, not a working WeChat menu/exit button.
+
+## Development
+
+From this package directory, use Node.js 22 and pnpm 9.15.9:
+
+```sh
+pnpm install --frozen-lockfile
+pnpm run build
+node --test tests/client.test.mjs tests/page-stack.test.mjs tests/viewport-controls.test.mjs
+node --test tests/project-access.test.mjs tests/preview-service.test.mjs tests/project-storage.test.mjs
+node --test tests/preview-http.test.mjs tests/game-http.test.mjs tests/shell-protocol.test.mjs
+node scripts/package.mjs /absolute/path/to/wechat-preview.zip
+```
+
+The build regenerates the committed vendor bundles and their notices. The packaging script
+includes the runtime, skill, README files, LICENSE and docs, but not dependencies, tests or build
+sources. Its isolated import/compile/jsdom checks do not prove Host rendering. From the repository
+root also run `npm run check` before contributing.
+
+[Architecture and tools](docs/architecture.md) · [MIT License](LICENSE). Third-party bundle
+licenses are preserved in `miniapp/client/vendor/THIRD-PARTY-NOTICES.txt` and
+`miniapp/node/vendor/THIRD-PARTY-NOTICES.txt`. No WeChat DevTools/WorkBuddy executable, SDK bundle,
+SVG asset or user project is included.
diff --git a/plugins/adherez/wechat-miniprogram-preview/README.zh-CN.md b/plugins/adherez/wechat-miniprogram-preview/README.zh-CN.md
new file mode 100644
index 00000000..e5db11b4
--- /dev/null
+++ b/plugins/adherez/wechat-miniprogram-preview/README.zh-CN.md
@@ -0,0 +1,89 @@
+# 微信小程序与小游戏本地预览
+
+[English](README.md) | 简体中文
+
+在 MiniMax Code 的既有 Mini App 中直接预览原生微信工程:小程序使用 WXML/WXSS/JS/TS,
+小游戏使用 Canvas2D/WebGL。预览器不是另造一份 HTML 业务应用,也不替代微信开发者工具。
+界面只保留手机预览和「刷新预览」,工程选择、日志和恢复在聊天中完成。
+
+## 功能
+
+- 原生工程发现、JS/TS/CommonJS/ESM 和预构建 npm 依赖图;不自动执行项目构建脚本。
+- glass-easel 组件层和自有 WXML/WXSS/Page/wx 兼容层;小游戏使用独立运行时。
+- 最多 10 层页面栈,返回保留原页实例、组件、输入草稿、滚动位置和动画时间线。
+- 文件监听与防抖重建、不可变构建资源、按工程持久化 Storage 及冲突恢复。
+- 固定胶囊、刘海、横竖屏和 DPR;胶囊颜色遵循导航配置,省略时默认白色,不采样滚动背景。
+ 状态栏保留时间、Wi-Fi、电池,不显示蜂窝信号图标。
+
+## 安装与使用
+
+运行时依赖已打包,安装不需要 npm install 或 build。
+
+1. 按仓库[安装说明](../../../README.zh-CN.md)把完整目录复制到
+ `/plugins/wechat-miniprogram-preview/`,保留 `.minimax-plugin`;不要套作者目录。
+ 默认 dataDir 为 `~/.minimax`,如有自定义则使用实际目录。
+2. 更新前保存或导出有价值的预览数据,不直接覆盖仍在运行的实例;按客户端说明启用插件。
+3. 在工程会话里说:
+
+ ```text
+ 用已安装的 wechat-miniprogram-preview 预览当前原生微信工程。
+ 不新建替代 HTML Mini App,不为打开预览修改业务代码。
+ ```
+
+Agent 按专属 Skill 执行 `preview_list`、`preview_open_project`,然后必须执行返回的
+`hostOpenAction`(Host 的 `miniapp open`)。构建成功不等于面板打开。内部相对路径不是
+普通浏览器 URL;无 previewId 时显示空态,不自动选其他会话的工程。正常显式工程路径
+不需要插件额外的目录授权面板;已占用预览不自动抢占。
+
+## Tested environment
+
+- 开发期间使用 MiniMax Code **3.1.4**、macOS Apple Silicon、Node.js 22。
+- 开发版已在桌面 Host 安装、启动,使用小程序和 Canvas2D/WebGL 示例验证过基础预览。
+- 页面栈、胶囊默认颜色、刘海有定向 Node/jsdom 和隔离 Chromium 证据;最后移除蜂窝图标后
+ 按要求没有追加视觉验收,不能说这次又做了一轮完整 Host 验收。
+- 尚未手工验证 Windows/Linux、真机、大型游戏引擎及这个公开贡献包的完整安装流程。
+
+## Data & access
+
+- **读文件**:显式指定工程的支持源码、配置、本地资源和依赖,以及插件自身文件和示例。
+ 有 canonical path、目录身份和数量/大小限制,拒绝系统/主目录/敏感配置目录及越界链接。
+ 不执行项目 Node 代码、不安装项目依赖、不读取宿主凭据和聊天数据库。目录检查不是按会话
+ 的文件权限沙箱,仅预览可信工程。
+- **写文件**:只向 Host 提供的 `context.dataDir` 写预览记录、工程路径、Storage 和迁移元数据,
+ 把该目录位置视为不透明。运行时不修改业务工程。用户请求导出时由浏览器下载 JSON。
+- **网络**:Node 无出站请求,只监听 Host 分配的 loopback 端口。预览允许业务工程指定的
+ **任意 HTTPS 图片地址**,可能向这些图片主机发送普通网络元数据;没有固定域名白名单。
+ `wx.request` 等网络 API 不支持,iframe 的 `connect-src` 为 `none`。没有遥测或上传服务。
+- **进程**:插件不创建子进程,由 Host 启动 Node。构建/测试仅在开发者显式运行时发生。
+- **Agent**:页面没有 `agentAccess`,不自行运行 Agent;聊天 Agent 可调用声明的 MCP 工具并
+ 读取构建诊断和日志,应用日志可能包含业务文本,不要在工程里记录密钥。恢复通道只传元数据,
+ 不把 Storage 内容发送到 Agent。
+- **隔离**:业务 JS 在 opaque sandbox iframe 中运行,没有 Node 或 Host 凭据。解释器需要
+ `unsafe-eval`,不是任意恶意代码安全容器;死循环仍可能卡住渲染,远程图片仍可发起请求。
+
+## 页面栈、数据与恢复
+
+前进隐藏但保留旧页,返回只卸载弹出页,重新调用原页 `onShow`,不重复首次加载。
+业务自己的 `onShow` 刷新逻辑照常执行。手动刷新、源码重建、切设备和插件重启会替换 iframe,
+不保留内存页面栈/未提交输入;只恢复已保存 Storage 和当前路由,不是 HMR。
+
+Storage 按 canonical 工程目录隔离,每工程 1 MiB。同工程多个预览共享持久数据,通过 revision
+CAS 防覆盖;最多 8 个预览记录、4 个活动工程、4 个连接页面。超限不会自动关闭其他窗口。
+冲突、响应丢失、页面被占用时,通过 `preview_status` 和 `preview_recover` 处理;
+`takeover`、`discard_local`、`close` 必须先确认影响,不手改私有数据或反复 create_new。
+重启后用原 previewId 重新打开,持久 Storage 不等于页面内存备份。
+
+## 能力边界
+
+不支持微信登录、支付、云服务、扫码、真机预览、上传发布、Worker/WASM、开放数据域、游戏分包、
+完整 WebAudio 或所有原生控件;复杂 WXS/CSS、自定义 tabBar、原生转场和 EventChannel 不保证一致。
+胶囊是装饰,不能操作微信菜单/退出。Taro/uni-app 需先用自己的构建生成原生工程。
+
+## 开发与许可证
+
+构建、定向测试、打包命令见英文 README;第三方运行时 bundle 和许可证必须一并提交。
+仓库根目录执行 `npm run check`。测试、源码和文档位于 runtime payload 外;不提交 node_modules、
+个人工程、私有会话日志或凭据。
+
+[实现说明](docs/architecture.md) · [MIT LICENSE](LICENSE)。未附带官方微信/WorkBuddy SDK、
+可执行包或复制的 SVG 素材;第三方开源依赖的声明保留在两个 vendor 目录中。
diff --git a/plugins/adherez/wechat-miniprogram-preview/docs/architecture.md b/plugins/adherez/wechat-miniprogram-preview/docs/architecture.md
new file mode 100644
index 00000000..ad9c8cb7
--- /dev/null
+++ b/plugins/adherez/wechat-miniprogram-preview/docs/architecture.md
@@ -0,0 +1,83 @@
+# Architecture
+
+```text
+Chat Agent + wechat-project-preview skill
+ -> managed wechat-preview MCP endpoint
+ -> project/path validation + PreviewService
+ -> module/WXML/WXSS compilation + immutable build cache + source watcher
+ -> preview descriptor / active-frame lease / project Storage revision
+ -> authenticated shell requests a self-contained document
+ -> sandboxed data iframe
+ |- Mini Program: glass-easel + component adapter + Page/wx bridge
+ `- Mini Game: Canvas2D/WebGL + scheduler/input/resource adapters
+ -> status, route and storage messages bound to the active frame identity
+```
+
+## Package layout
+
+- `miniapp/node/server.mjs`: Host entry, HTTP routing, CSP and MCP.
+- `miniapp/node/compiler.mjs`, `module-graph.mjs`, `wxml.mjs`, `wxss.mjs`: native project compilation.
+- `miniapp/node/game-compiler.mjs`: game entry graph and configuration.
+- `miniapp/node/preview-service.mjs`, `preview-contexts.mjs`, `preview-shells.mjs`: context ownership,
+ operation identity and metadata-only recovery.
+- `miniapp/node/project-storage.mjs`: project-level versioned Storage, atomic writes and migration.
+- `miniapp/client/shell.js`: single-refresh page, frame lifecycle, storage drain and recovery UI.
+- `miniapp/client/sim-runtime.js`: retained page entries, built-in component adapters and wx stubs.
+- `src/component-runtime.mjs`: glass-easel bridge source; its browser bundle is committed under
+ `miniapp/client/vendor/`.
+- `miniapp/client/game-runtime.js`: separate game runtime; does not load glass-easel.
+- `src/js-transform.mjs`: Sucrase-based module transform/analyzer source, bundled into Node vendor.
+- `tests/`: explicit Node test files, with synthetic temporary projects and jsdom helpers.
+
+## MCP tools
+
+| Tool | Purpose |
+| --- | --- |
+| `preview_open_project` | Read/build an explicit native project and return its Host open action |
+| `preview_list` | List descriptors without choosing or stealing one |
+| `preview_status`, `preview_logs` | Inspect build/runtime/connection diagnostics |
+| `preview_navigate` | Request an explicit route; this is not the app's push/back navigation |
+| `preview_rebuild` | Compile and request a safe frame reload |
+| `preview_game_control` | Pause, resume or restart a selected game preview |
+| `preview_recover` | Retry, export or explicitly confirm discard/takeover/close on a live shell |
+| `preview_close` | Close an idle context without deleting project source or saved Storage |
+
+An `accepted` command needs a subsequent runtime acknowledgement. Old frame messages cannot
+be relabeled as current: project, preview, build, server epoch, lease epoch, client and instance
+identities are checked. The shell owns pending in-memory Storage; a metadata response never
+proves an export file was downloaded successfully.
+
+## Page stack and rendering
+
+`navigateTo` retains the previous Page/Component/DOM root and pushes another entry. Inactive
+entries remain laid out, but use `inert`, `aria-hidden`, hidden visibility and zero opacity so
+they cannot receive user input and do not restart CSS animations when revealed. Their viewport
+dimensions/variables are retained while another page changes navigation layout.
+
+`navigateBack(delta)` disposes only popped entries. `redirectTo` replaces the top entry;
+`switchTab` retains created tab pages; `reLaunch` disposes the stack and tab cache. Page data and
+native drafts survive ordinary navigation. They do not survive a new iframe or runtime.
+
+This is not the official multi-WebView renderer: native transitions, gestures, process isolation
+between pages and complete EventChannel semantics are outside its scope.
+
+## Dependencies and reproducibility
+
+Development dependencies are pinned in `package.json` and `pnpm-lock.yaml`. `scripts/build.mjs`
+generates self-contained browser/Node bundles and license notices. The public package includes
+both sources and generated runtime assets; installation never resolves npm modules.
+
+`scripts/package.mjs` stages only installable files, verifies import/compile/basic jsdom startup,
+rejects links and node_modules, and creates a ZIP with a SHA-256 result. `miniapp.publish` accepts
+an extracted, complete package directory rather than a ZIP and does not run the build.
+
+## Security and compatibility
+
+See the README's **Data & access** section before opening a project. Host loopback authentication
+is external to this package. Never send its credentials to the iframe or expose a generic file
+proxy. The Node runtime does not spawn tools, request remote resources or run business code.
+The iframe may load project-provided HTTPS images; do not describe the preview as fully offline.
+
+Focused unit/protocol tests, isolated Chromium checks, desktop Host checks and official WeChat
+device checks establish different claims. A green build or mocked lifecycle test alone is not
+visual acceptance or proof of full WeChat compatibility.
diff --git a/plugins/adherez/wechat-miniprogram-preview/docs/demo-game-2d.md b/plugins/adherez/wechat-miniprogram-preview/docs/demo-game-2d.md
new file mode 100644
index 00000000..bf1ab37a
--- /dev/null
+++ b/plugins/adherez/wechat-miniprogram-preview/docs/demo-game-2d.md
@@ -0,0 +1,11 @@
+# Orbit Post:Canvas2D 自写示例
+
+这是用于本地 H5 小游戏预览的独立原生工程,不需要微信账号。它不证明真机、发布或完整微信 SDK 兼容。
+
+- `game.js` 使用 `wx.createCanvas`、`wx.createImage`、touch、Storage、`onShow/onHide` 和 RAF。
+- 首个 Canvas 显示动画;第二个 Canvas 不挂到页面,用于本地 PNG 像素读回。
+- 点击画面收集一个信号,分数写入 `orbitPostScore`,刷新后从 Storage 恢复。
+- 业务自行按 `pixelRatio` 扩大 backing store,再将 2D 坐标缩放到逻辑像素。
+- `GameGlobal.__demoGame` 暴露示例状态和 PNG 导出供验收,不是宿主 bridge。
+
+`assets/quad.png` 为本项目自行生成的 2×2 RGBA 图片,四个像素依次为红、绿、蓝、白。代码和资源未复制微信或其他产品的实现。
diff --git a/plugins/adherez/wechat-miniprogram-preview/docs/demo-game-webgl.md b/plugins/adherez/wechat-miniprogram-preview/docs/demo-game-webgl.md
new file mode 100644
index 00000000..58630e36
--- /dev/null
+++ b/plugins/adherez/wechat-miniprogram-preview/docs/demo-game-webgl.md
@@ -0,0 +1,11 @@
+# Signal Atlas:WebGL 自写示例
+
+这是用于本地 H5 小游戏预览的独立原生工程,不需要微信账号。它不证明真机、发布或完整微信 SDK 兼容。
+
+- `shaders.js` 提供真正的 GLSL vertex/fragment shader,由 `game.js` 以 CommonJS 加载。
+- `wx.createImage` 解码本地 PNG,再由 `texImage2D` 上传到真实 WebGL 纹理;不使用绘图 mock。
+- 画面持续旋转;点击切换纹理反色;`onHide` 停止动画,`onShow` 恢复。
+- `GameGlobal.__demoGame.readCenter()` 返回真实像素和 GL 错误码,`snapshot()` 导出 PNG。
+- WebGL 不可用时明确报错,不以 Canvas2D 替代后声称通过。
+
+`assets/atlas.png` 为本项目自行生成的 32×32 RGBA 四色图片。代码和资源未复制微信或其他产品的实现。WebGL framebuffer 的读回原点在左下,不能直接把读回行序当成 Canvas2D 的屏幕行序。
diff --git a/plugins/adherez/wechat-miniprogram-preview/icon.png b/plugins/adherez/wechat-miniprogram-preview/icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..299a26cbf5c1427dffa9cd575cf721307d570765
GIT binary patch
literal 1574
zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrV7=|>;uumf=gr;N9Fb5F_7BDH
zN*t40jx3m@A#jD`h{82~A;W#9hIKB=6V;QM-fp_!Iw3Du_^7z>7spdwD|5UT=(0U-
z4vcs42{>&0A!&-l#UFng7Vv9zUfN`Qd~;|s@7+uz9O)JMQ;7Q
z+VlNc*1f)P;(=w;neIK@5%Dq?bm#T^{r{9*@ia!gnj^~T2}4Q1*Th_Vu`>}GpIDCO
z#4fN@Vt4rd)BClU(Q~fW#@QT3`o97jTK?xMOo;EM)AkEGZ>^la!>4}Tg|#orV!ID-
z4G8cmi29Kul*I@Nov(+hUWIHbj*XAkN|#&6e?ev6^ZSxvcK?~yFA_-*H<;a0#(2g^
zzl72KdH~l2CW&?ibaY*)m{GcGsq4HCVQW8`+3MMAA9>%$$6x1gcEXjIh6=|UTHH*2
zPm^w)+0I})`*&FN5&Nu1Y+r6yc*L8(Sa9m$yrfKZ#~fa1&NHPdC8Y+RdyG?WF};29
zt~O0_E62oyX%mAzpJu)CiI9%EV`HT-k0aLM2a{@p^XrK&uP*%0esO=1w)UU=+zCGMvNmy=
z|6P>ouW5@p2v)su`cxCfV?$ymt*7pfdX*%ncOdCuPzej4`!;F|NW(R
z>9S1?D}6X`#NYh7GVkf}bgjz@`%TThovQS5e3ma}8>P8_foSNz|MS*u5I(ouy{)ZH
zDAVlt_qYF?TCQcTsM{ODK6ko9)iJH7$x{Rh??u)A%=k6?)3WE!yQWM!akgaN_W!DX
z-#&jmHN3X`^}*WR`-q(SpO3zY`t01fRdCqjHPcc83_B1GbPU2zNIsfpf)(T$7XRi5-O#h75#2n`^6HJMF!qC&lGw+h(&G*h?
zk*k_rMVov+YqaeSTOYYZzjSt0F6+COd#ca&eqZ;_rtH&`lb%gyI!#lfHFBb5N}kls
z$lZDCNY^>py$dH>z2A9YX;J*2GNpMejVH=I&vYJ{xB2|{X>r$mYq=dibJoiERAfK)
zQ9W)K_T5@<--_8kB%7Wkzk0OuD!*pi4Mquhsb5FS-xaZWR
zZ|9tq^y-Rf{wEFBD;u8QSW~|%f9dnxxlsofN1ZNZ>HPit!>su-YkBth3z|N4bh|s-
zYxS08(KkMRy%ipDKxc+#k7En7+RrklI~Sdmo1bg6`PiN0n#CTy2vIV@izrM+Ju?Ht
m|NpbhtN4MXJsYT)XJ&Z(ZtAoneFio_5e83JKbLh*2~7YK=FPVN
literal 0
HcmV?d00001
diff --git a/plugins/adherez/wechat-miniprogram-preview/miniapp/client/css-urls.mjs b/plugins/adherez/wechat-miniprogram-preview/miniapp/client/css-urls.mjs
new file mode 100644
index 00000000..18b92eef
--- /dev/null
+++ b/plugins/adherez/wechat-miniprogram-preview/miniapp/client/css-urls.mjs
@@ -0,0 +1,64 @@
+function quotedEnd(source, start) {
+ const quote = source[start];
+ let index = start + 1;
+ while (index < source.length) {
+ if (source[index] === '\\') index += 2;
+ else if (source[index++] === quote) return index;
+ }
+ return -1;
+}
+
+function decodeCssString(source) {
+ return source.replace(/\\(?:([\da-f]{1,6})(?:\r\n|[\t\n\r\f ])?|\r\n|[\n\r\f]|([\s\S]))/gi, (_match, hexadecimal, escaped) => {
+ if (hexadecimal) {
+ const code = parseInt(hexadecimal, 16);
+ return code === 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff) ? '\ufffd' : String.fromCodePoint(code);
+ }
+ return escaped || '';
+ });
+}
+
+function quoteCssString(source) {
+ return '"' + source.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/[\n\r\f]/g, (char) => '\\' + char.charCodeAt(0).toString(16) + ' ') + '"';
+}
+
+// transform receives the decoded resource name, not CSS escape syntax. Unchanged
+// values retain their original bytes; changed URLs are serialized exactly once.
+export function rewriteCssUrls(value, transform) {
+ const source = String(value);
+ let index = 0;
+ let output = '';
+ while (index < source.length) {
+ const char = source[index];
+ if (char === '"' || char === "'") {
+ const end = quotedEnd(source, index);
+ if (end < 0) { output += source.slice(index); break; }
+ output += source.slice(index, end); index = end; continue;
+ }
+ if (source.startsWith('/*', index)) {
+ const close = source.indexOf('*/', index + 2);
+ const end = close < 0 ? source.length : close + 2;
+ output += source.slice(index, end); index = end; continue;
+ }
+ if ((index === 0 || !/[\w-]/.test(source[index - 1])) && /^url\s*\(/i.test(source.slice(index))) {
+ const open = source.indexOf('(', index);
+ let start = open + 1;
+ while (start < source.length && /\s/.test(source[start])) start++;
+ const quoted = source[start] === '"' || source[start] === "'";
+ let end = quoted ? quotedEnd(source, start) : start;
+ if (!quoted) while (end < source.length && source[end] !== ')') end += source[end] === '\\' ? 2 : 1;
+ if (end < 0 || end >= source.length) { output += source.slice(index); break; }
+ let close = end;
+ while (close < source.length && /\s/.test(source[close])) close++;
+ if (source[close] !== ')') { output += source[index++]; continue; }
+ const raw = quoted ? source.slice(start + 1, end - 1) : source.slice(start, end).trim();
+ const decoded = decodeCssString(raw);
+ const result = transform(decoded);
+ output += typeof result !== 'string' || result === decoded ? source.slice(index, close + 1) : 'url(' + quoteCssString(result) + ')';
+ index = close + 1;
+ continue;
+ }
+ output += source[index++];
+ }
+ return output;
+}
diff --git a/plugins/adherez/wechat-miniprogram-preview/miniapp/client/device-config.js b/plugins/adherez/wechat-miniprogram-preview/miniapp/client/device-config.js
new file mode 100644
index 00000000..63a2db6c
--- /dev/null
+++ b/plugins/adherez/wechat-miniprogram-preview/miniapp/client/device-config.js
@@ -0,0 +1,79 @@
+(function (global) {
+ 'use strict';
+ var profiles = {
+ '390x844': {
+ id: '390x844', label: 'iPhone 14 · 390 × 844', model: 'iPhone 14 (H5 preview)',
+ width: 390, height: 844, pixelRatio: 3, statusBarHeight: 47, navHeight: 44, hasNotch: true,
+ capsule: { width: 87, height: 32, top: 51, right: 7 }, safeAreaBottom: 34,
+ platform: 'ios', system: 'iOS 17.0', version: '8.0.50', sdkVersion: '3.5.0',
+ },
+ '375x667': {
+ id: '375x667', label: 'iPhone SE · 375 × 667', model: 'iPhone SE (H5 preview)',
+ width: 375, height: 667, pixelRatio: 2, statusBarHeight: 20, navHeight: 44, hasNotch: false,
+ capsule: { width: 87, height: 32, top: 24, right: 7 }, safeAreaBottom: 0,
+ platform: 'ios', system: 'iOS 17.0', version: '8.0.50', sdkVersion: '3.5.0',
+ },
+ '430x932': {
+ id: '430x932', label: 'iPhone 14 Pro Max · 430 × 932', model: 'iPhone 14 Pro Max (H5 preview)',
+ width: 430, height: 932, pixelRatio: 3, statusBarHeight: 47, navHeight: 44, hasNotch: true,
+ capsule: { width: 87, height: 32, top: 51, right: 7 }, safeAreaBottom: 34,
+ platform: 'ios', system: 'iOS 17.0', version: '8.0.50', sdkVersion: '3.5.0',
+ },
+ };
+ var defaultId = '390x844';
+ function clone(profile) {
+ return {
+ id: profile.id, label: profile.label, model: profile.model, width: profile.width, height: profile.height,
+ pixelRatio: profile.pixelRatio, statusBarHeight: profile.statusBarHeight, navHeight: profile.navHeight, hasNotch: profile.hasNotch,
+ capsule: { width: profile.capsule.width, height: profile.capsule.height, top: profile.capsule.top, right: profile.capsule.right },
+ safeAreaBottom: profile.safeAreaBottom, platform: profile.platform, system: profile.system,
+ version: profile.version, sdkVersion: profile.sdkVersion,
+ };
+ }
+ function get(id) {
+ return clone(Object.prototype.hasOwnProperty.call(profiles, id) ? profiles[id] : profiles[defaultId]);
+ }
+ global.__WX_PREVIEW_DEVICES__ = Object.freeze({
+ defaultId: defaultId,
+ ids: Object.freeze(Object.keys(profiles)),
+ get: get,
+ metrics: function (id, options) {
+ var device = get(id);
+ options = options || {};
+ var navigationTop = options.customNavigation ? 0 : device.statusBarHeight + device.navHeight;
+ var topTab = options.tabBarVisible && options.tabBarPosition === 'top';
+ var bottomTab = options.tabBarVisible && !topTab;
+ var expectedTop = navigationTop + (topTab ? 49 : 0);
+ var expectedHeight = device.height - expectedTop - (bottomTab ? 49 + device.safeAreaBottom : 0);
+ function measured(value, fallback, maximum) {
+ return typeof value === 'number' && isFinite(value) && value >= 0 ? Math.min(maximum, value) : fallback;
+ }
+ var contentTop = measured(options.contentTop, expectedTop, device.height);
+ var contentHeight = measured(options.contentHeight, expectedHeight, device.height - contentTop);
+ var insets = {
+ top: Math.max(0, device.statusBarHeight - contentTop), right: 0,
+ bottom: Math.max(0, contentTop + contentHeight - (device.height - device.safeAreaBottom)), left: 0,
+ };
+ return {
+ screenWidth: device.width, screenHeight: device.height, windowWidth: device.width,
+ windowHeight: contentHeight, screenTop: contentTop, statusBarHeight: device.statusBarHeight,
+ contentTop: contentTop, contentWidth: device.width, contentHeight: contentHeight, safeAreaInsets: insets,
+ cssVariables: {
+ '--wx-rpx': String(device.width / 750) + 'px',
+ '--wx-vh': String(contentHeight / 100) + 'px',
+ '--wx-content-height': String(contentHeight) + 'px',
+ '--wx-safe-area-inset-top': String(insets.top) + 'px',
+ '--wx-safe-area-inset-right': String(insets.right) + 'px',
+ '--wx-safe-area-inset-bottom': String(insets.bottom) + 'px',
+ '--wx-safe-area-inset-left': String(insets.left) + 'px',
+ },
+ safeArea: { left: 0, right: device.width, top: device.statusBarHeight, bottom: device.height - device.safeAreaBottom,
+ width: device.width, height: device.height - device.statusBarHeight - device.safeAreaBottom },
+ menu: { width: device.capsule.width, height: device.capsule.height, top: device.capsule.top,
+ bottom: device.capsule.top + device.capsule.height, left: device.width - device.capsule.right - device.capsule.width,
+ right: device.width - device.capsule.right },
+ };
+ },
+ list: Object.freeze(Object.keys(profiles).map(function (id) { return Object.freeze(get(id)); })),
+ });
+})(window);
diff --git a/plugins/adherez/wechat-miniprogram-preview/miniapp/client/game-runtime.css b/plugins/adherez/wechat-miniprogram-preview/miniapp/client/game-runtime.css
new file mode 100644
index 00000000..3a7fcfd1
--- /dev/null
+++ b/plugins/adherez/wechat-miniprogram-preview/miniapp/client/game-runtime.css
@@ -0,0 +1,11 @@
+:root { color-scheme: light; --wx-game-width: 390px; --wx-game-height: 844px; --wx-capsule-width: 87px; --wx-capsule-height: 32px; --wx-capsule-top: 51px; --wx-capsule-right: 7px; }
+html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; background: #fff; }
+body { position: relative; isolation: isolate; }
+/* Keep the native-style capsule above a game's canvas without putting it in the viewport. */
+#wx-game-viewport { position: relative; z-index: 0; width: var(--wx-game-width); height: var(--wx-game-height); overflow: hidden; touch-action: none; }
+#wx-game-canvas { display: block; width: 100%; height: 100%; touch-action: none; }
+#wx-game-overlay { position: fixed; inset: 0; z-index: 999; display: grid; place-items: center; background: rgba(255,255,255,.55); color: #333; font: 14px/1.5 sans-serif; pointer-events: auto; }
+#wx-game-overlay[hidden] { display: none; }
+
+/* DevTools uses a separate, always-white capsule for games. */
+#wx-capsule { color: var(--wx-chrome-on-dark); border-color: var(--wx-chrome-dark-border); background: var(--wx-chrome-game-surface); }
diff --git a/plugins/adherez/wechat-miniprogram-preview/miniapp/client/game-runtime.js b/plugins/adherez/wechat-miniprogram-preview/miniapp/client/game-runtime.js
new file mode 100644
index 00000000..8847ca38
--- /dev/null
+++ b/plugins/adherez/wechat-miniprogram-preview/miniapp/client/game-runtime.js
@@ -0,0 +1,646 @@
+/* Independent browser-backed Mini Game runtime. No Page/WXML component engine. */
+(function () {
+ 'use strict';
+ const build = window.__WX_BUILD__ || {};
+ const frame = window.__WX_PREVIEW_FRAME__ || {};
+ const native = {
+ setTimeout: window.setTimeout.bind(window), clearTimeout: window.clearTimeout.bind(window),
+ requestAnimationFrame: window.requestAnimationFrame?.bind(window), cancelAnimationFrame: window.cancelAnimationFrame?.bind(window),
+ Image: window.Image, Audio: window.Audio, Worker: window.Worker,
+ createObjectURL: window.URL.createObjectURL?.bind(window.URL), revokeObjectURL: window.URL.revokeObjectURL?.bind(window.URL),
+ windowAdd: window.addEventListener.bind(window), windowRemove: window.removeEventListener.bind(window),
+ documentAdd: document.addEventListener.bind(document), documentRemove: document.removeEventListener.bind(document),
+ };
+ const viewport = document.getElementById('wx-game-viewport') || document.body.appendChild(Object.assign(document.createElement('main'), { id: 'wx-game-viewport' }));
+ const overlay = document.createElement('div');
+ overlay.id = 'wx-game-overlay'; overlay.hidden = true; overlay.setAttribute('role', 'status');
+ document.body.appendChild(overlay);
+ let initialized = false;
+ let disposed = false;
+ let failed = false;
+ let finalStorageWrite = false;
+ let reportingError = false;
+ let visible = true;
+ let userPaused = false;
+ let quiescing = false;
+ let quiesceRequest = null;
+ let paused = false;
+ let fps = 60;
+ let device = window.__WX_PREVIEW_DEVICES__.get(window.__WX_PREVIEW_DEVICES__.defaultId);
+ let orientation = build.game?.deviceOrientation === 'landscape' ? 'landscape' : 'portrait';
+ let mainCanvas = null;
+ let storage = Object.create(null);
+ let status = 'rendered';
+ const details = [];
+ const detailKeys = new Set();
+ const events = new Map();
+ const timers = new Map();
+ const animationFrames = new Map();
+ const canvases = new Set();
+ const images = new Set();
+ const audios = new Set();
+ const graphics = new Set();
+ const trackedGraphics = new WeakSet();
+ const listeners = new Set();
+ const pendingCompletions = new Map();
+ const cleanupCallbacks = new Set();
+ const completionTimers = new Set();
+ const objectUrls = new Map();
+ const touches = new Map();
+ const modules = new Map();
+ let nextTimer = 1;
+ let nextAnimation = 1;
+ let nativeAnimation = null;
+ let lastFrame = -Infinity;
+ let logs = [];
+ let logTimer = null;
+
+ function post(message) {
+ const identity = {};
+ for (const key of ['previewId', 'projectId', 'serverEpoch', 'buildId', 'clientId', 'instanceId', 'leaseEpoch']) identity[key] = frame[key];
+ window.parent.postMessage({ ...message, ...identity, source: 'wx-sim' }, '*');
+ }
+ function diagnostic(level, code, message) {
+ if (disposed) return;
+ if (level === 'error' || status !== 'error' && level === 'limited') status = level;
+ const key = code + ':' + message;
+ if (!detailKeys.has(key) && details.length < 100) {
+ detailKeys.add(key); details.push({ code: String(code).slice(0, 100), message: String(message).slice(0, 2000) });
+ }
+ post({ type: 'preview-status', status, details: details.slice() });
+ }
+ function error(err, code = 'game-runtime') {
+ diagnostic('error', code, err?.message || String(err));
+ if (!reportingError) {
+ reportingError = true;
+ try { emit('Error', err?.message || String(err)); } finally { reportingError = false; }
+ }
+ }
+ function call(fn, self, args = []) {
+ if (disposed || typeof fn !== 'function') return;
+ try { return fn.apply(self, args); } catch (err) { error(err, 'game-callback'); }
+ }
+ function emit(name, value) { for (const fn of [...(events.get(name) || [])]) call(fn, null, [value]); }
+ function on(name, callback) {
+ active();
+ if (typeof callback !== 'function') throw new TypeError('Event listener must be a function');
+ let set = events.get(name); if (!set) events.set(name, set = new Set()); set.add(callback);
+ }
+ function off(name, callback) { if (callback) events.get(name)?.delete(callback); else events.delete(name); }
+ function format(value) {
+ if (value instanceof Error) return value.message;
+ if (typeof value === 'string') return value;
+ try { return JSON.stringify(value); } catch { return String(value); }
+ }
+ function flushLogs() {
+ if (logTimer !== null) native.clearTimeout(logTimer);
+ logTimer = null;
+ if (logs.length) post({ type: 'log', entries: logs.splice(0, 100) });
+ }
+ const consoles = {};
+ for (const level of ['log', 'info', 'warn', 'error', 'debug']) {
+ consoles[level] = console[level].bind(console);
+ console[level] = (...args) => {
+ consoles[level](...args);
+ if (disposed) return;
+ logs.push({ level, source: 'runtime', message: args.map(format).join(' ').slice(0, 4000) });
+ if (logs.length >= 100) flushLogs();
+ else if (logTimer === null) logTimer = native.setTimeout(flushLogs, 100);
+ };
+ }
+ function listen(target, name, callback, options) {
+ const add = target === window ? native.windowAdd : native.documentAdd;
+ const remove = target === window ? native.windowRemove : native.documentRemove;
+ add(name, callback, options); cleanupCallbacks.add(() => remove(name, callback, options));
+ }
+ listen(window, 'error', (event) => error(event.error || event.message));
+ listen(window, 'unhandledrejection', (event) => error(event.reason, 'game-unhandled-rejection'));
+ function active() { if (disposed) throw new Error('game:fail preview disposed or stopped'); }
+
+ function dimensions() {
+ const landscape = orientation === 'landscape';
+ return { width: landscape ? device.height : device.width, height: landscape ? device.width : device.height };
+ }
+ function menuRect() {
+ const landscape = orientation === 'landscape';
+ const { width } = dimensions();
+ // DevTools game chrome has its own geometry, separate from program navigation.
+ const capsuleWidth = 88, capsuleHeight = 32, right = 10;
+ const top = landscape ? 12 : device.hasNotch ? 44 : 10 + (build.game?.showStatusBar ? device.statusBarHeight : 0);
+ return {
+ width: capsuleWidth, height: capsuleHeight, top,
+ bottom: top + capsuleHeight,
+ left: width - right - capsuleWidth,
+ right: width - right,
+ };
+ }
+ function systemInfo() {
+ const { width, height } = dimensions();
+ const landscape = orientation === 'landscape';
+ const safe = landscape
+ ? { left: device.statusBarHeight, right: width - device.statusBarHeight, top: 0, bottom: height - device.safeAreaBottom }
+ : { left: 0, right: width, top: device.statusBarHeight, bottom: height - device.safeAreaBottom };
+ return {
+ brand: 'devtools', model: device.model, platform: device.platform, system: device.system,
+ version: device.version, SDKVersion: device.sdkVersion, pixelRatio: device.pixelRatio,
+ screenWidth: width, screenHeight: height, windowWidth: width, windowHeight: height, screenTop: 0,
+ statusBarHeight: landscape ? 0 : device.statusBarHeight, language: 'zh_CN', theme: 'light', deviceOrientation: orientation,
+ safeArea: { ...safe, width: safe.right - safe.left, height: safe.bottom - safe.top },
+ };
+ }
+ function resize() {
+ const { width, height } = dimensions();
+ const menu = menuRect();
+ document.documentElement.style.setProperty('--wx-game-width', width + 'px');
+ document.documentElement.style.setProperty('--wx-game-height', height + 'px');
+ document.documentElement.style.setProperty('--wx-capsule-width', menu.width + 'px');
+ document.documentElement.style.setProperty('--wx-capsule-height', menu.height + 'px');
+ document.documentElement.style.setProperty('--wx-capsule-top', menu.top + 'px');
+ document.documentElement.style.setProperty('--wx-capsule-right', (width - menu.right) + 'px');
+ viewport.style.width = width + 'px'; viewport.style.height = height + 'px';
+ if (mainCanvas) { mainCanvas.style.width = width + 'px'; mainCanvas.style.height = height + 'px'; }
+ emit('WindowResize', { windowWidth: width, windowHeight: height, deviceOrientation: orientation });
+ }
+
+ function scheduleTimer(record) {
+ if (paused || disposed) return;
+ record.due = performance.now() + record.remaining;
+ record.nativeId = native.setTimeout(() => {
+ record.nativeId = null;
+ if (paused || disposed || !timers.has(record.id)) return;
+ if (!record.repeat) timers.delete(record.id);
+ call(record.callback, window, record.args);
+ if (record.repeat && timers.has(record.id) && !disposed) { record.remaining = record.delay; scheduleTimer(record); }
+ }, record.remaining);
+ }
+ function timer(callback, delay, repeat, args) {
+ active();
+ if (typeof callback !== 'function') throw new TypeError('String timers are not supported in Mini Game preview');
+ if (timers.size >= 2048) throw new Error('game timers: limit exceeded');
+ const milliseconds = Math.max(repeat ? 1 : 0, Math.min(2147483647, Number(delay) || 0));
+ const record = { id: nextTimer++, callback, delay: milliseconds, remaining: milliseconds, repeat, args, nativeId: null };
+ timers.set(record.id, record); scheduleTimer(record); return record.id;
+ }
+ function clearTimer(id) { const record = timers.get(id); if (record?.nativeId !== null) native.clearTimeout(record?.nativeId); timers.delete(id); }
+ window.setTimeout = (fn, delay, ...args) => timer(fn, delay, false, args);
+ window.setInterval = (fn, delay, ...args) => timer(fn, delay, true, args);
+ window.clearTimeout = window.clearInterval = clearTimer;
+
+ function cancelNativeFrame() {
+ if (nativeAnimation === null) return;
+ if (native.requestAnimationFrame) native.cancelAnimationFrame(nativeAnimation); else native.clearTimeout(nativeAnimation);
+ nativeAnimation = null;
+ }
+ function pumpFrame(timestamp) {
+ nativeAnimation = null;
+ if (paused || disposed) return;
+ if (timestamp - lastFrame >= 1000 / fps - 0.5) {
+ lastFrame = timestamp;
+ const work = [...animationFrames.entries()]; animationFrames.clear();
+ for (const [, callback] of work) call(callback, window, [timestamp]);
+ }
+ queueFrame();
+ }
+ function queueFrame() {
+ if (paused || disposed || !animationFrames.size || nativeAnimation !== null) return;
+ nativeAnimation = native.requestAnimationFrame ? native.requestAnimationFrame(pumpFrame) : native.setTimeout(() => pumpFrame(performance.now()), 1000 / fps);
+ }
+ window.requestAnimationFrame = (callback) => {
+ if (typeof callback !== 'function') throw new TypeError('requestAnimationFrame needs a function');
+ active();
+ if (animationFrames.size >= 2048) throw new Error('game animation callbacks: limit exceeded');
+ const id = nextAnimation++; animationFrames.set(id, callback); queueFrame(); return id;
+ };
+ window.cancelAnimationFrame = (id) => { animationFrames.delete(id); if (!animationFrames.size) cancelNativeFrame(); };
+
+ function state(requestId) {
+ post({ type: 'game-state', ...(requestId ? { requestId } : {}), paused, reason: failed ? 'error' : disposed ? 'disposed' : quiescing ? 'quiescing' : !visible ? 'hidden' : userPaused ? 'user' : 'running', framesPerSecond: fps });
+ }
+ function updatePause(requestId) {
+ const next = disposed || quiescing || userPaused || !visible;
+ if (paused !== next) {
+ paused = next;
+ if (paused) {
+ for (const record of timers.values()) {
+ if (record.nativeId !== null) {
+ native.clearTimeout(record.nativeId); record.nativeId = null;
+ record.remaining = Math.max(0, record.due - performance.now());
+ }
+ }
+ cancelNativeFrame(); touches.clear();
+ for (const audio of audios) audio.suspend();
+ emit('Hide', {});
+ } else {
+ lastFrame = -Infinity;
+ for (const record of timers.values()) scheduleTimer(record);
+ queueFrame(); emit('Show', { scene: 1001, query: {} });
+ for (const [key, deliver] of [...pendingCompletions]) {
+ if (paused || disposed) break;
+ pendingCompletions.delete(key); deliver();
+ }
+ }
+ }
+ viewport.inert = paused;
+ overlay.hidden = !paused;
+ overlay.textContent = disposed ? '小游戏预览已停止' : quiescing ? '正在保存小游戏状态' : userPaused ? '小游戏已暂停' : '小游戏已隐藏';
+ state(requestId);
+ }
+
+ function normalizedPath(source, base = '') {
+ if (typeof source !== 'string' || !source || source.includes('\0') || /[\\]|^[a-z][a-z0-9+.-]*:|^\/\//i.test(source)) throw new Error('Invalid local game path');
+ const parts = source.startsWith('/') ? [] : base.split('/').filter(Boolean);
+ for (const part of source.replace(/^\//, '').split('/')) {
+ if (!part || part === '.') continue;
+ if (part === '..') { if (!parts.length) throw new Error('Game path escapes project'); parts.pop(); }
+ else parts.push(part);
+ }
+ return parts.join('/');
+ }
+ function asset(source, kind) {
+ if (typeof source !== 'string') throw new Error(kind + ':fail invalid asset path');
+ if (source.startsWith('data:')) {
+ if (new RegExp('^data:' + (kind === 'image' ? 'image/' : 'audio/'), 'i').test(source)) return source;
+ throw new Error(kind + ':fail unsupported data URL');
+ }
+ if (source.startsWith('blob:') && objectUrls.has(source)) {
+ if (objectUrls.get(source).startsWith(kind + '/')) return source;
+ throw new Error(kind + ':fail object URL has incompatible media type');
+ }
+ const name = normalizedPath(source.split(/[?#]/)[0]);
+ const url = Object.prototype.hasOwnProperty.call(frame.assets || {}, name) ? frame.assets[name] : null;
+ if (typeof url !== 'string' || !new RegExp('^data:' + (kind === 'image' ? 'image/' : 'audio/'), 'i').test(url)) {
+ throw new Error(kind + ':fail local asset unavailable: ' + name);
+ }
+ return url;
+ }
+ function loadModule(id) {
+ active();
+ if (modules.has(id)) return modules.get(id).exports;
+ if (!Object.prototype.hasOwnProperty.call(build.modules || {}, id)) throw new Error('Game module not found: ' + id);
+ const module = { exports: {} }; modules.set(id, module);
+ const require = (request) => {
+ active();
+ if (typeof request !== 'string') throw new Error('Game module name must be a string');
+ const owner = id.slice(0, Math.max(0, id.lastIndexOf('/')));
+ const bases = request.startsWith('.') || request.startsWith('/') ? [normalizedPath(request, owner)]
+ : [normalizedPath(request, owner), normalizedPath(request), normalizedPath('miniprogram_npm/' + request)];
+ const target = bases.flatMap((base) => [base, base + '.js', base + '.ts', base + '.json', base + '/index.js']).find((name) => Object.prototype.hasOwnProperty.call(build.modules, name));
+ if (!target) throw new Error('Game module not found: ' + request);
+ return loadModule(target);
+ };
+ require.async = (request) => Promise.resolve().then(() => require(request));
+ try { new Function('require', 'module', 'exports', String(build.modules[id]) + '\n//# sourceURL=minigame://' + id)(require, module, module.exports); }
+ catch (err) { modules.delete(id); throw err; }
+ return module.exports;
+ }
+
+ function trackTarget(target, deferred = false) {
+ const add = target.addEventListener.bind(target), remove = target.removeEventListener.bind(target);
+ target.addEventListener = (type, callback, options) => {
+ active();
+ if (!callback) return;
+ const capture = typeof options === 'boolean' ? options : !!options?.capture;
+ if (typeof options === 'object' && options.signal?.aborted) return;
+ if ([...listeners].some((record) => record.target === target && record.type === type && record.callback === callback && record.capture === capture)) return;
+ const actualOptions = typeof options === 'object' ? { ...options, once: false } : options;
+ const record = { target, type, callback, options: actualOptions, capture, remove, enabled: true, once: !!options?.once };
+ if (options?.signal) {
+ record.abort = () => removeRecord(record);
+ options.signal.addEventListener('abort', record.abort, { once: true });
+ }
+ record.wrapped = (event) => completion(record, () => {
+ if (!record.enabled) return;
+ if (record.once) removeRecord(record);
+ typeof callback === 'function' ? call(callback, target, [event]) : call(callback.handleEvent, callback, [event]);
+ }, deferred);
+ listeners.add(record); add(type, record.wrapped, actualOptions);
+ };
+ target.removeEventListener = (type, callback, options) => {
+ const capture = typeof options === 'boolean' ? options : !!options?.capture;
+ for (const record of listeners) if (record.target === target && record.type === type && record.callback === callback && record.capture === capture) removeRecord(record);
+ };
+ }
+ function removeRecord(record) {
+ record.enabled = false; record.remove(record.type, record.wrapped, record.options);
+ if (record.abort) record.options.signal.removeEventListener('abort', record.abort);
+ pendingCompletions.delete(record); listeners.delete(record);
+ }
+ function completion(key, deliver, defer) {
+ if (disposed) return;
+ if (!paused) { deliver(); return; }
+ if (!defer) return;
+ if (!pendingCompletions.has(key) && pendingCompletions.size >= 512) {
+ diagnostic('limited', 'game-completions', 'Paused resource callback limit exceeded'); return;
+ }
+ pendingCompletions.set(key, deliver);
+ }
+ function graphicsContext(gl) {
+ if (trackedGraphics.has(gl)) return gl;
+ trackedGraphics.add(gl);
+ const allocations = new Map();
+ const kinds = ['Buffer', 'Framebuffer', 'Program', 'Renderbuffer', 'Shader', 'Texture', 'Query', 'Sampler', 'TransformFeedback', 'VertexArray'];
+ for (const kind of kinds) {
+ if (typeof gl['create' + kind] !== 'function' || typeof gl['delete' + kind] !== 'function') continue;
+ const create = gl['create' + kind].bind(gl), destroy = gl['delete' + kind].bind(gl), values = new Set();
+ allocations.set(kind, { values, destroy });
+ gl['create' + kind] = (...args) => { if (disposed) return null; const resource = create(...args); if (resource) values.add(resource); return resource; };
+ gl['delete' + kind] = (resource) => { values.delete(resource); return destroy(resource); };
+ }
+ const resource = { dispose() {
+ for (const { values, destroy } of allocations.values()) { for (const value of values) destroy(value); values.clear(); }
+ gl.getExtension('WEBGL_lose_context')?.loseContext();
+ } };
+ graphics.add(resource); return gl;
+ }
+ function createCanvas(offscreen = false, options = {}) {
+ if (disposed) throw new Error('createCanvas:fail preview disposed');
+ const canvas = document.createElement('canvas');
+ const size = dimensions();
+ canvas.width = Math.max(1, Math.min(16384, Number(options.width) || size.width));
+ canvas.height = Math.max(1, Math.min(16384, Number(options.height) || size.height));
+ if (!offscreen && !mainCanvas) {
+ mainCanvas = canvas; canvas.id = 'wx-game-canvas';
+ canvas.style.width = size.width + 'px'; canvas.style.height = size.height + 'px'; viewport.appendChild(canvas);
+ }
+ const getContext = canvas.getContext.bind(canvas); const contexts = new Map();
+ canvas.getContext = (name, attributes) => {
+ if (disposed) return null;
+ if (contexts.has(name)) return contexts.get(name);
+ const context = getContext(name, attributes);
+ if (!context) { diagnostic('limited', 'context:' + name, 'Browser graphics context is unavailable: ' + name); return null; }
+ if (/^(webgl2?|experimental-webgl)$/.test(name)) graphicsContext(context);
+ contexts.set(name, context); return context;
+ };
+ canvas.requestAnimationFrame = window.requestAnimationFrame;
+ canvas.cancelAnimationFrame = window.cancelAnimationFrame;
+ canvas.createImage = createImage;
+ trackTarget(canvas); canvases.add(canvas); return canvas;
+ }
+ function createImage() {
+ if (disposed) throw new Error('createImage:fail preview disposed');
+ const image = new native.Image();
+ const add = image.addEventListener.bind(image), remove = image.removeEventListener.bind(image);
+ for (const name of ['load', 'error']) {
+ let callback = null;
+ const key = {};
+ const listener = (event) => completion(key, () => call(callback, image, [event]), true);
+ add(name, listener);
+ Object.defineProperty(image, 'on' + name, { configurable: true, get: () => callback, set(value) {
+ if (disposed && value !== null) active();
+ callback = typeof value === 'function' ? value : null;
+ if (!callback) pendingCompletions.delete(key);
+ } });
+ cleanupCallbacks.add(() => { pendingCompletions.delete(key); callback = null; remove(name, listener); });
+ }
+ const descriptor = Object.getOwnPropertyDescriptor(window.HTMLImageElement.prototype, 'src');
+ Object.defineProperty(image, 'src', { configurable: true, get() { return descriptor.get.call(image); }, set(value) {
+ if (disposed) throw new Error('image:fail preview disposed');
+ try { descriptor.set.call(image, asset(value, 'image')); }
+ catch (err) {
+ diagnostic('limited', 'game-image', err.message); image.removeAttribute('src');
+ const timer = native.setTimeout(() => { completionTimers.delete(timer); if (!disposed) image.dispatchEvent(new Event('error')); }, 0);
+ completionTimers.add(timer);
+ }
+ } });
+ images.add(image); trackTarget(image, true); return image;
+ }
+
+ function innerAudioContext() {
+ if (disposed) throw new Error('createInnerAudioContext:fail preview disposed');
+ const audio = new native.Audio();
+ let destroyed = false;
+ let autoplay = false;
+ let audioGeneration = 0;
+ const callbacks = new Map();
+ const removers = [];
+ const completionKeys = new Map();
+ const ensureAudio = () => { if (disposed || destroyed) throw new Error('audio:fail context destroyed or preview disposed'); };
+ const dispatch = (name, value) => {
+ if (disposed || destroyed) return;
+ for (const callback of callbacks.get(name) || []) {
+ let keys = completionKeys.get(name); if (!keys) completionKeys.set(name, keys = new Map());
+ let key = keys.get(callback); if (!key) keys.set(callback, key = {});
+ completion(key, () => { if (!destroyed && callbacks.get(name)?.has(callback)) call(callback, null, [value]); }, true);
+ }
+ };
+ const api = {};
+ for (const [name, domEvent] of Object.entries({ Play: 'play', Pause: 'pause', Ended: 'ended', Canplay: 'canplay', TimeUpdate: 'timeupdate', Waiting: 'waiting', Seeking: 'seeking', Seeked: 'seeked' })) {
+ const listener = () => {
+ if (name === 'Play' && paused) { audio.pause(); return; }
+ dispatch(name, {});
+ };
+ audio.addEventListener(domEvent, listener); removers.push(() => audio.removeEventListener(domEvent, listener));
+ }
+ const mediaError = () => dispatch('Error', { errMsg: 'audio:fail media decode or load failed', errCode: audio.error?.code || 0 });
+ audio.addEventListener('error', mediaError); removers.push(() => audio.removeEventListener('error', mediaError));
+ for (const name of ['Play', 'Pause', 'Stop', 'Ended', 'Canplay', 'TimeUpdate', 'Waiting', 'Seeking', 'Seeked', 'Error']) {
+ api['on' + name] = (callback) => { ensureAudio(); if (typeof callback !== 'function') throw new TypeError('Audio listener must be a function'); let set = callbacks.get(name); if (!set) callbacks.set(name, set = new Set()); set.add(callback); };
+ api['off' + name] = (callback) => { if (callback) callbacks.get(name)?.delete(callback); else callbacks.delete(name); };
+ }
+ Object.defineProperty(api, 'src', { get: () => audio.src, set(value) {
+ ensureAudio();
+ try {
+ audio.src = asset(value, 'audio');
+ if (autoplay && !paused) api.play();
+ } catch (err) { audio.pause(); audio.removeAttribute('src'); diagnostic('limited', 'game-audio', err.message); dispatch('Error', { errMsg: err.message }); }
+ } });
+ for (const name of ['loop', 'volume', 'playbackRate']) Object.defineProperty(api, name, { get: () => audio[name], set: (value) => { ensureAudio(); audio[name] = value; } });
+ Object.defineProperty(api, 'autoplay', { get: () => autoplay, set: (value) => { ensureAudio(); autoplay = !!value; if (autoplay && audio.src && !paused) api.play(); } });
+ for (const name of ['duration', 'currentTime', 'paused']) Object.defineProperty(api, name, { get: () => audio[name] });
+ api.play = () => {
+ ensureAudio();
+ if (paused) { dispatch('Error', { errMsg: 'audio:fail preview paused' }); return; }
+ const generation = audioGeneration;
+ try {
+ Promise.resolve(audio.play()).then(() => { if (disposed || destroyed || paused || generation !== audioGeneration) audio.pause(); }, (err) => {
+ if (disposed || destroyed || paused || generation !== audioGeneration) return;
+ diagnostic('limited', 'audio-playback', err.message); dispatch('Error', { errMsg: 'audio:fail ' + err.message });
+ });
+ } catch (err) { diagnostic('limited', 'audio-playback', err.message); dispatch('Error', { errMsg: 'audio:fail ' + err.message }); }
+ };
+ api.pause = () => { ensureAudio(); audioGeneration++; audio.pause(); };
+ api.stop = () => { ensureAudio(); audioGeneration++; audio.pause(); audio.currentTime = 0; dispatch('Stop', {}); };
+ api.seek = (time) => { ensureAudio(); audio.currentTime = Math.max(0, Number(time) || 0); };
+ const record = { suspend: () => { audioGeneration++; audio.pause(); }, dispose() {
+ if (destroyed) return; destroyed = true; audioGeneration++; audio.pause(); audio.removeAttribute('src'); audio.load(); callbacks.clear();
+ for (const keys of completionKeys.values()) for (const key of keys.values()) pendingCompletions.delete(key); completionKeys.clear();
+ for (const remove of removers) remove(); audios.delete(record);
+ } };
+ api.destroy = record.dispose; audios.add(record); return api;
+ }
+
+ const MAX_STORAGE_BYTES = 1024 * 1024;
+ const badKeys = new Set(['__proto__', 'prototype', 'constructor']);
+ function storageKey(key) { const text = String(key); if (badKeys.has(text)) throw new Error('storage:fail unsafe key'); return text; }
+ function encodeStorage(value) {
+ const ancestors = new Set(); let nodes = 0;
+ const invalid = () => { throw new Error('storage:fail only bounded JSON data without unsafe keys is supported'); };
+ function visit(item, depth) {
+ if (++nodes > 100000 || depth > 64) invalid();
+ if (item === null || ['string', 'boolean'].includes(typeof item)) return;
+ if (typeof item === 'number') { if (!Number.isFinite(item)) invalid(); return; }
+ if (!item || typeof item !== 'object' || ancestors.has(item)) invalid();
+ const array = Array.isArray(item), prototype = Object.getPrototypeOf(item);
+ if (array ? prototype !== Array.prototype : prototype !== Object.prototype && prototype !== null) invalid();
+ if (Object.getOwnPropertySymbols(item).length || (array && item.length > 100000)) invalid();
+ const descriptors = Object.getOwnPropertyDescriptors(item), keys = Object.keys(descriptors);
+ if (array && keys.length !== item.length + 1) invalid();
+ ancestors.add(item);
+ for (const key of keys) {
+ if (array && key === 'length') continue;
+ storageKey(key); const descriptor = descriptors[key];
+ if (!descriptor.enumerable || !('value' in descriptor) || array && !/^(0|[1-9]\d*)$/.test(key)) invalid();
+ visit(descriptor.value, depth + 1);
+ }
+ ancestors.delete(item);
+ }
+ if (!value || typeof value !== 'object' || Array.isArray(value)) invalid();
+ visit(value, 0); const encoded = JSON.stringify(value);
+ if (new TextEncoder().encode(encoded).length > MAX_STORAGE_BYTES) throw new Error('storage:fail quota exceeded (1 MiB per project)');
+ return encoded;
+ }
+ const clone = (value) => value === undefined ? undefined : JSON.parse(JSON.stringify(value));
+ function writable() { if (disposed || (quiescing && !finalStorageWrite)) throw new Error('storage:fail temporarily unavailable during preview transition'); }
+ function writeStorage(next) { writable(); storage = JSON.parse(encodeStorage(next)); post({ type: 'storage', data: storage }); }
+ function asynchronous(name, run) {
+ return (options = {}) => {
+ const callbackStyle = ['success', 'fail', 'complete'].some((key) => typeof options[key] === 'function');
+ const promise = Promise.resolve().then(() => run(options)).then((value) => {
+ const result = { errMsg: name + ':ok', ...value }; call(options.success, null, [result]); call(options.complete, null, [result]); return result;
+ }, (err) => {
+ const result = { errMsg: name + ':fail ' + (err?.message || String(err)) }; call(options.fail, null, [result]); call(options.complete, null, [result]);
+ if (!callbackStyle) throw result; return result;
+ });
+ if (callbackStyle) { promise.catch(() => {}); return; } return promise;
+ };
+ }
+ const unsupportedNames = new Set();
+ function unsupported(name, sync = false) {
+ unsupportedNames.add(name);
+ const fail = () => { const message = name + ':fail not supported in local Mini Game preview'; diagnostic('limited', 'api:' + name, message); throw new Error(message); };
+ return sync ? fail : asynchronous(name, fail);
+ }
+ const wx = {
+ createCanvas: () => createCanvas(), createOffscreenCanvas: (options) => createCanvas(true, options), createImage, createInnerAudioContext: innerAudioContext,
+ getSystemInfoSync: systemInfo, getWindowInfo: systemInfo, getDeviceInfo: systemInfo,
+ getMenuButtonBoundingClientRect: menuRect,
+ getSystemInfo: asynchronous('getSystemInfo', systemInfo), getSystemInfoAsync: asynchronous('getSystemInfoAsync', systemInfo),
+ getLaunchOptionsSync: () => ({ scene: 1001, query: {} }), getEnterOptionsSync: () => ({ scene: 1001, query: {} }),
+ setPreferredFramesPerSecond(value) { active(); if (!Number.isFinite(value) || value < 1) throw new Error('setPreferredFramesPerSecond:fail expected positive fps'); fps = Math.min(60, Math.floor(value)); state(); },
+ setStorageSync(key, value) { writable(); const next = Object.assign(Object.create(null), storage); next[storageKey(key)] = value; writeStorage(next); },
+ getStorageSync(key) { const value = storage[storageKey(key)]; return value === undefined ? '' : clone(value); },
+ removeStorageSync(key) { const next = Object.assign(Object.create(null), storage); delete next[storageKey(key)]; writeStorage(next); },
+ clearStorageSync: () => writeStorage(Object.create(null)),
+ getStorageInfoSync: () => ({ keys: Object.keys(storage), currentSize: new TextEncoder().encode(JSON.stringify(storage)).length / 1024, limitSize: 1024 }),
+ getPerformance: () => ({ now: performance.now.bind(performance) }),
+ };
+ wx.setStorage = asynchronous('setStorage', (o) => wx.setStorageSync(o.key, o.data));
+ wx.getStorage = asynchronous('getStorage', (o) => { if (!Object.prototype.hasOwnProperty.call(storage, storageKey(o.key))) throw new Error('data not found'); return { data: wx.getStorageSync(o.key) }; });
+ wx.removeStorage = asynchronous('removeStorage', (o) => wx.removeStorageSync(o.key));
+ wx.clearStorage = asynchronous('clearStorage', wx.clearStorageSync);
+ wx.getStorageInfo = asynchronous('getStorageInfo', wx.getStorageInfoSync);
+ for (const name of ['TouchStart', 'TouchMove', 'TouchEnd', 'TouchCancel', 'Show', 'Hide', 'Error', 'WindowResize']) { wx['on' + name] = (fn) => on(name, fn); wx['off' + name] = (fn) => off(name, fn); }
+ for (const name of ['request', 'downloadFile', 'uploadFile', 'connectSocket', 'login', 'checkSession', 'requestPayment', 'loadSubpackage', 'getUserInfo', 'authorize']) wx[name] = unsupported(name);
+ for (const name of ['createWorker', 'getOpenDataContext', 'getSharedCanvas', 'getFileSystemManager']) wx[name] = unsupported(name, true);
+ wx.canIUse = (name) => typeof wx[name] === 'function' && !unsupportedNames.has(name);
+ window.wx = new Proxy(wx, { get(target, name) {
+ if (typeof name !== 'string' || name in target) return target[name];
+ if (name === 'then' || name === 'toJSON') return undefined;
+ if (name === 'cloud') return new Proxy({}, { get: (_value, method) => unsupported('cloud.' + String(method)) });
+ return unsupported(name, /^(?:create|get.*Sync)/.test(name));
+ } });
+ window.GameGlobal = window;
+ window.Image = function (width, height) {
+ const image = createImage();
+ if (width !== undefined) image.width = width;
+ if (height !== undefined) image.height = height;
+ return image;
+ };
+ window.Image.prototype = native.Image.prototype;
+ window.URL.createObjectURL = (blob) => {
+ active();
+ if (!native.createObjectURL) throw new Error('createObjectURL:fail unavailable in this browser');
+ if (objectUrls.size >= 1024) throw new Error('createObjectURL:fail preview resource limit exceeded');
+ const url = native.createObjectURL(blob); objectUrls.set(url, String(blob?.type || '')); return url;
+ };
+ window.URL.revokeObjectURL = (url) => { objectUrls.delete(String(url)); native.revokeObjectURL?.(url); };
+ window.Worker = function () { return unsupported('Worker', true)(); };
+ window.WXWebAssembly = new Proxy({}, { get: (_target, name) => unsupported('WXWebAssembly.' + String(name)) });
+ window.WebAssembly = new Proxy({}, { get: (_target, name) => unsupported('WebAssembly.' + String(name), true) });
+
+ const pointerNames = { pointerdown: 'TouchStart', pointermove: 'TouchMove', pointerup: 'TouchEnd', pointercancel: 'TouchCancel' };
+ for (const [domName, name] of Object.entries(pointerNames)) listen(document, domName, (event) => {
+ if (paused || disposed || !mainCanvas || (domName === 'pointerdown' && !viewport.contains(event.target))) return;
+ if (domName !== 'pointerdown' && !touches.has(event.pointerId)) return;
+ const box = mainCanvas.getBoundingClientRect(), size = dimensions();
+ const point = { identifier: event.pointerId ?? 0, clientX: (event.clientX - box.left) * size.width / (box.width || size.width), clientY: (event.clientY - box.top) * size.height / (box.height || size.height) };
+ point.pageX = point.clientX; point.pageY = point.clientY;
+ if (domName === 'pointerup' || domName === 'pointercancel') touches.delete(point.identifier); else touches.set(point.identifier, point);
+ event.preventDefault();
+ emit(name, { touches: [...touches.values()].map((item) => ({ ...item })), changedTouches: [{ ...point }], timeStamp: performance.now() });
+ }, { capture: true, passive: false });
+ for (const name of ['pointerdown', 'pointermove', 'pointerup', 'click', 'keydown', 'keyup', 'input', 'touchstart', 'touchmove', 'touchend']) listen(window, name, (event) => {
+ if (paused || disposed) { event.preventDefault(); event.stopImmediatePropagation(); }
+ }, { capture: true, passive: false });
+ function setVisibility(value) { visible = value; updatePause(); }
+ listen(document, 'visibilitychange', () => setVisibility(document.visibilityState !== 'hidden'));
+
+ function releaseResources() {
+ disposed = true; paused = true;
+ for (const record of timers.values()) if (record.nativeId !== null) native.clearTimeout(record.nativeId);
+ timers.clear(); animationFrames.clear(); cancelNativeFrame();
+ for (const timer of completionTimers) native.clearTimeout(timer); completionTimers.clear();
+ for (const record of [...listeners]) removeRecord(record);
+ for (const cleanup of cleanupCallbacks) cleanup(); cleanupCallbacks.clear(); pendingCompletions.clear();
+ for (const audio of [...audios]) audio.dispose();
+ for (const gl of graphics) gl.dispose(); graphics.clear();
+ for (const image of images) { image.removeAttribute('src'); } images.clear();
+ for (const canvas of canvases) { canvas.width = 0; canvas.height = 0; canvas.remove(); } canvases.clear();
+ for (const url of objectUrls.keys()) native.revokeObjectURL?.(url); objectUrls.clear();
+ events.clear(); touches.clear(); modules.clear(); flushLogs();
+ viewport.inert = true; overlay.hidden = false; overlay.textContent = failed ? '小游戏启动失败,请查看诊断后重新启动' : '小游戏预览已停止';
+ for (const level of Object.keys(consoles)) console[level] = consoles[level];
+ }
+ function dispose(requestId) {
+ if (!disposed) {
+ userPaused = true; updatePause(); releaseResources();
+ }
+ post({ type: 'game-disposed', ...(requestId ? { requestId } : {}) });
+ }
+ function initialize(data) {
+ if (initialized || disposed) return;
+ initialized = true; device = window.__WX_PREVIEW_DEVICES__.get(data.device); visible = data.visible !== false;
+ if (build.projectKind !== 'minigame' || String(build.runtimeVersion) !== '2' || typeof build.entryModule !== 'string') throw new Error('Unsupported Mini Game payload');
+ storage = JSON.parse(encodeStorage(data.storage || {})); resize();
+ paused = !visible;
+ trackTarget(window); trackTarget(document);
+ loadModule(normalizedPath(build.entryModule));
+ if (!mainCanvas) diagnostic('limited', 'game-canvas-missing', 'Game entry has not created a screen canvas');
+ if (visible) emit('Show', { scene: 1001, query: {} });
+ updatePause(); post({ type: 'started' }); post({ type: 'preview-status', status, details: details.slice() });
+ }
+ native.windowAdd('message', (event) => {
+ const data = event.data;
+ if (event.source !== window.parent || !data || data.source !== 'wx-shell' || !['previewId', 'projectId', 'serverEpoch', 'buildId', 'clientId', 'instanceId', 'leaseEpoch'].every((key) => data[key] === frame[key])) return;
+ try {
+ if (data.type === 'init') initialize(data);
+ else if (data.type === 'dispose') dispose(data.requestId);
+ else if (!initialized || disposed) return;
+ else if (data.type === 'game-control') {
+ if (!['pause', 'resume'].includes(data.action)) throw new Error('Unknown game control action');
+ userPaused = data.action === 'pause'; updatePause(data.requestId);
+ } else if (data.type === 'visibility') setVisibility(data.visible === true);
+ else if (data.type === 'quiesce-storage') {
+ quiescing = true; quiesceRequest = data.requestId; finalStorageWrite = true;
+ try { updatePause(); } finally { finalStorageWrite = false; }
+ post({ type: 'storage-flushed', requestId: data.requestId, storage: clone(storage), data: clone(storage) });
+ } else if (data.type === 'flush-storage') post({ type: 'storage-flushed', requestId: data.requestId, storage: clone(storage), data: clone(storage) });
+ else if (data.type === 'resume-storage' && (!data.requestId || data.requestId === quiesceRequest)) { quiescing = false; quiesceRequest = null; updatePause(); }
+ } catch (err) {
+ error(err, 'game-startup');
+ if (data.type === 'init') { failed = true; releaseResources(); state(); post({ type: 'startup-error' }); }
+ }
+ });
+ native.windowAdd('pagehide', () => dispose());
+ post({ type: 'ready' });
+})();
diff --git a/plugins/adherez/wechat-miniprogram-preview/miniapp/client/icons.js b/plugins/adherez/wechat-miniprogram-preview/miniapp/client/icons.js
new file mode 100644
index 00000000..bd2dcef8
--- /dev/null
+++ b/plugins/adherez/wechat-miniprogram-preview/miniapp/client/icons.js
@@ -0,0 +1,13 @@
+// Shapes from packages/ui/src/assets/icons; bundled for the standalone Mini App.
+'use strict';
+const previewIcons = {
+ folder: ``,
+ more: ``,
+ refresh: ``,
+ close: ``,
+ mobile: ``,
+};
+document.querySelectorAll('[data-icon]').forEach((element) => {
+ const svg = previewIcons[element.dataset.icon];
+ if (svg) element.innerHTML = svg;
+});
diff --git a/plugins/adherez/wechat-miniprogram-preview/miniapp/client/index.html b/plugins/adherez/wechat-miniprogram-preview/miniapp/client/index.html
new file mode 100644
index 00000000..8c91627b
--- /dev/null
+++ b/plugins/adherez/wechat-miniprogram-preview/miniapp/client/index.html
@@ -0,0 +1,50 @@
+
+
+
+
+
+ 微信小程序与小游戏预览 · MCode
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
等待预览工程
+
请在聊天中让 Agent 打开要预览的小程序或小游戏。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/adherez/wechat-miniprogram-preview/miniapp/client/preview-chrome.css b/plugins/adherez/wechat-miniprogram-preview/miniapp/client/preview-chrome.css
new file mode 100644
index 00000000..2d19d326
--- /dev/null
+++ b/plugins/adherez/wechat-miniprogram-preview/miniapp/client/preview-chrome.css
@@ -0,0 +1,73 @@
+/* WeChat simulation colors are independent of the desktop tool-shell theme. */
+:root {
+ --wx-chrome-on-light: #000000;
+ --wx-chrome-on-dark: #ffffff;
+ --wx-chrome-light-surface: rgba(255, 255, 255, .6);
+ --wx-chrome-dark-surface: rgba(0, 0, 0, .15);
+ --wx-chrome-game-surface: rgba(0, 0, 0, .6);
+ --wx-chrome-light-border: rgba(0, 0, 0, .1);
+ --wx-chrome-dark-border: rgba(255, 255, 255, .25);
+}
+
+/* Shared native-style WeChat chrome. This is decorative and never handles input. */
+#wx-capsule {
+ position: fixed;
+ top: var(--wx-capsule-top, 51px);
+ right: var(--wx-capsule-right, 7px);
+ width: var(--wx-capsule-width, 87px);
+ height: var(--wx-capsule-height, 32px);
+ box-sizing: border-box;
+ border: 1px solid var(--wx-chrome-light-border);
+ border-radius: 27px;
+ background: var(--wx-chrome-light-surface);
+ display: flex;
+ align-items: center;
+ font-size: 0;
+ line-height: 1;
+ color: var(--wx-chrome-on-light);
+ z-index: 1000;
+ pointer-events: none;
+}
+
+#wx-capsule[data-tone="light"] {
+ color: var(--wx-chrome-on-dark);
+ border-color: var(--wx-chrome-dark-border);
+ background: var(--wx-chrome-dark-surface);
+}
+
+#wx-capsule .wx-capsule-part {
+ box-sizing: border-box;
+ display: flex;
+ flex: 1;
+ height: 30px;
+ padding: 6.4px 0;
+ align-items: center;
+ justify-content: center;
+}
+
+#wx-capsule .wx-capsule-separator {
+ flex: 0 0 1px;
+ height: 17.2px;
+ background: var(--wx-chrome-dark-border);
+}
+
+/* Same 22x7 and 17x17 silhouettes as DevTools, without a font or copied SVG. */
+#wx-capsule .wx-capsule-menu {
+ display: block;
+ width: 22px;
+ height: 7px;
+ background:
+ radial-gradient(ellipse 2.444px 2.333px at 2.444px 3.733px, currentColor 95%, transparent 100%),
+ radial-gradient(ellipse 3.667px 3.5px at 11px 3.5px, currentColor 95%, transparent 100%),
+ radial-gradient(ellipse 2.444px 2.333px at 19.556px 3.733px, currentColor 95%, transparent 100%);
+}
+
+#wx-capsule .wx-capsule-exit {
+ box-sizing: border-box;
+ display: block;
+ width: 17px;
+ height: 17px;
+ border: 2px solid currentColor;
+ border-radius: 50%;
+ background: radial-gradient(circle 2.942px, currentColor 95%, transparent 100%);
+}
diff --git a/plugins/adherez/wechat-miniprogram-preview/miniapp/client/shell.css b/plugins/adherez/wechat-miniprogram-preview/miniapp/client/shell.css
new file mode 100644
index 00000000..fe3cd2c6
--- /dev/null
+++ b/plugins/adherez/wechat-miniprogram-preview/miniapp/client/shell.css
@@ -0,0 +1,119 @@
+:root {
+ color-scheme: light dark;
+ --mcode-bg: #ffffff;
+ --mcode-canvas: #f5f5f5;
+ --mcode-surface: #ffffff;
+ --mcode-surface-muted: #fafafa;
+ --mcode-hover: #f0f0f0;
+ --mcode-text: #171717;
+ --mcode-text-muted: #666666;
+ --mcode-text-subtle: #767676;
+ --mcode-border: #0a0a0a14;
+ --mcode-control-border: #d9d9d9;
+ --mcode-accent: #0094fc;
+ --mcode-success: #04b54b;
+ --mcode-warning: #b85b0d;
+ --mcode-danger: #d6293b;
+ --mcode-danger-surface: #fff4f4;
+ --mcode-primary-action-bg: #171717;
+ --mcode-primary-action-text: #ffffff;
+ --mcode-shadow: #0000000f;
+ --mcode-overlay: #00000026;
+ --device-bezel: #1a1a1a;
+ --device-hardware: #000000;
+ --device-notch-edge: #ffffff;
+ --device-screen: #ffffff;
+ --device-empty-bg: #ffffff;
+ --device-empty-text: #333333;
+ --device-empty-secondary: #767676;
+ --device-shadow-ambient: #00000026;
+ --device-shadow-contact: #0000001a;
+}
+[data-theme="dark"] {
+ --mcode-bg: #171717;
+ --mcode-canvas: #1c1c1c;
+ --mcode-surface: #262626;
+ --mcode-surface-muted: #262626;
+ --mcode-hover: #333333;
+ --mcode-text: #ededed;
+ --mcode-text-muted: #a3a3a3;
+ --mcode-text-subtle: #949494;
+ --mcode-border: #ffffff14;
+ --mcode-control-border: #525252;
+ --mcode-accent: #70b9ed;
+ --mcode-success: #58c990;
+ --mcode-warning: #f3a35c;
+ --mcode-danger: #ff8290;
+ --mcode-danger-surface: #342125;
+ --mcode-primary-action-bg: #ffffff;
+ --mcode-primary-action-text: #171717;
+ --mcode-shadow: #00000033;
+ --mcode-overlay: #00000066;
+ --device-shadow-ambient: #00000040;
+ --device-shadow-contact: #00000033;
+}
+* { box-sizing: border-box; }
+html, body { margin: 0; height: 100%; }
+body { display: flex; flex-direction: column; overflow: hidden; background: var(--mcode-bg); color: var(--mcode-text); font: 14px/1.5 ui-sans-serif, -apple-system, system-ui, "Segoe UI", sans-serif; }
+[hidden] { display: none !important; }
+h1, h2, h3, p { margin: 0; }
+h1 { font-size: 14px; line-height: 22px; font-weight: 500; }
+h2 { font-size: 16px; line-height: 24px; font-weight: 500; }
+h3 { font-size: 13px; line-height: 20px; font-weight: 500; }
+button { color: inherit; font: inherit; }
+button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 32px; padding: 6px 12px; border: 1px solid var(--mcode-control-border); border-radius: 8px; background: var(--mcode-surface); cursor: pointer; white-space: nowrap; transition: background-color .15s ease-out, border-color .15s ease-out; }
+button:hover { background: var(--mcode-hover); }
+button:active { border-color: var(--mcode-text-subtle); }
+button:disabled { opacity: .45; cursor: not-allowed; }
+:focus-visible { outline: 2px solid var(--mcode-accent); outline-offset: 3px; }
+[data-icon] { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; }
+[data-icon] svg { display: block; width: 16px; height: 16px; }
+.app-header { flex: 0 0 56px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 0 12px 0 16px; border-bottom: .5px solid var(--mcode-border); background: var(--mcode-bg); }
+.project-summary { display: flex; align-items: center; min-width: 0; }
+.project-summary { flex: 1; gap: 12px; }
+#project-name { min-width: 0; max-width: 360px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.preview-kind { display: inline-flex; align-items: center; gap: 5px; flex: 0 0 auto; padding: 3px 8px; border-radius: 6px; background: var(--mcode-surface-muted); color: var(--mcode-text-muted); font-size: 12px; line-height: 20px; }
+.preview-kind [data-icon] svg { width: 14px; height: 14px; }
+main { flex: 1; min-height: 0; display: flex; flex-direction: column; }
+#notice { flex: 0 0 auto; padding: 10px 16px; background: var(--mcode-danger-surface); color: var(--mcode-danger); font-size: 12px; overflow-wrap: anywhere; }
+.workspace { display: flex; flex: 1; min-height: 0; background: var(--mcode-canvas); }
+.preview-area { display: flex; flex: 1; min-width: 0; min-height: 0; flex-direction: column; align-items: center; gap: 16px; padding: 20px; }
+#device-stage { position: relative; flex: 1; width: 100%; min-width: 0; min-height: 0; display: flex; align-items: center; justify-content: center; }
+#device-wrap { position: relative; flex: 0 0 auto; }
+.phone-frame { position: absolute; top: 0; left: 0; border: 8px solid var(--device-bezel); border-radius: 44px; background: var(--device-hardware); overflow: hidden; transform-origin: top left; box-shadow: 0 20px 60px var(--device-shadow-ambient), 0 4px 16px var(--device-shadow-contact); }
+.phone-screen { position: relative; overflow: hidden; background: var(--device-screen); }
+#simulator { display: block; width: 100%; height: 100%; border: 0; background: var(--device-screen); color-scheme: light; }
+/* DevTools notch overlays the logical screen, including games; it is not page content. */
+.phone-notch { position: absolute; z-index: 3; top: 0; left: 50%; width: 212px; height: 33px; transform: translateX(-50%); border: 1px solid var(--device-notch-edge); border-top: 0; border-radius: 0 0 20px 20px; background: var(--device-hardware); pointer-events: none; }
+.phone-home-indicator { position: absolute; z-index: 3; bottom: 14.5px; left: 50%; width: 134px; height: 5px; transform: translateX(-50%); border-radius: 3px; background: var(--device-hardware); pointer-events: none; }
+.phone-frame[data-legacy="true"] { border-radius: 28px; }
+.phone-frame[data-legacy="true"] .phone-home-indicator { display: none; }
+.phone-frame[data-landscape="true"] .phone-notch { top: 50%; left: 0; width: 33px; height: 212px; transform: translateY(-50%); border-top: 1px solid var(--device-notch-edge); border-left: 0; border-radius: 0 20px 20px 0; }
+.phone-frame[data-game="true"] .phone-home-indicator { display: none; }
+.empty { position: absolute; z-index: 2; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 12px; padding: 32px; background: var(--device-empty-bg); color: var(--device-empty-text); text-align: center; }
+.empty-icon { color: var(--device-empty-secondary); margin-bottom: 4px; }
+.empty-icon svg { width: 32px; height: 32px; }
+.empty p { max-width: 280px; color: var(--device-empty-secondary); font-size: 13px; line-height: 22px; }
+.preview-footer { display: flex; flex: 0 0 auto; width: 100%; min-width: 0; align-items: center; flex-direction: column; gap: 16px; color: var(--mcode-text-subtle); font-size: 12px; line-height: 20px; text-align: center; }
+.preview-notice { display: flex; min-height: 40px; flex-direction: column; justify-content: center; max-width: 100%; }
+.runtime-notice { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; column-gap: 8px; }
+.footer-refresh { gap: 6px; padding: 8px 16px; border-radius: 20px; min-height: 36px; background: var(--mcode-surface); color: var(--mcode-text); font-size: 13px; box-shadow: 0 2px 8px var(--mcode-shadow); }
+.footer-refresh [data-icon] svg { width: 14px; height: 14px; }
+[data-status="error"] { color: var(--mcode-danger); }
+[data-status="building"], [data-status="starting"], [data-status="limited"] { color: var(--mcode-warning); }
+@media (max-width: 520px) {
+ .app-header { padding: 0 12px; gap: 8px; }
+ .project-summary { gap: 8px; }
+ .preview-kind { padding: 4px; }
+ .preview-kind > span:last-child { display: none; }
+ .preview-area { padding: 16px; gap: 12px; }
+ .preview-footer { gap: 12px; }
+}
+@media (max-height: 560px) {
+ .preview-area { padding: 12px 20px; gap: 8px; }
+ .preview-footer { gap: 8px; }
+ .preview-notice { min-height: 20px; }
+}
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after { animation: none !important; transition: none !important; }
+}
diff --git a/plugins/adherez/wechat-miniprogram-preview/miniapp/client/shell.js b/plugins/adherez/wechat-miniprogram-preview/miniapp/client/shell.js
new file mode 100644
index 00000000..69edf257
--- /dev/null
+++ b/plugins/adherez/wechat-miniprogram-preview/miniapp/client/shell.js
@@ -0,0 +1,847 @@
+'use strict';
+const $ = (id) => document.getElementById(id);
+const devices = window.__WX_PREVIEW_DEVICES__;
+const uid = (prefix) => prefix + '-' + (globalThis.crypto?.randomUUID?.() || Date.now().toString(36) + '-' + Math.random().toString(36).slice(2));
+const clientId = uid('client');
+const registrationId = uid('shell-registration');
+let previewId = new URLSearchParams(location.search).get('previewId') || '';
+let frame = $('simulator');
+let state = null;
+let identity = null;
+let selectionGeneration = 0;
+let route = '';
+let requestedRoute = '';
+let commandSeq = 0;
+let pendingCommand = null;
+let loadedKey = '';
+let ready = false;
+let started = false;
+let startupTimer = null;
+let failedStartupBuild = '';
+let connected = true;
+let closed = false;
+let ownershipLost = false;
+let bindingRetired = false;
+let quiescing = false;
+let busy = false;
+let automaticBlocked = false;
+let mutationPending = false;
+let pendingLoadKey = '';
+let pendingClosed = false;
+let rebuildBusy = false;
+let storage = {};
+let acknowledgedStorage = '{}';
+let ackRevision = 0;
+let storageWrites = Promise.resolve();
+let storageProblem = null;
+let uncertainOperation = null;
+let pendingDocument = null;
+let reconnectRequired = false;
+let runtimeStatus = { status: 'idle', details: [] };
+let frameKind = 'miniprogram';
+let frameLayout = null;
+let gameState = null;
+let gameDisposed = false;
+let retirement = null;
+let retirementIncomplete = false;
+let storageWarnings = [];
+let lifecycle = Promise.resolve();
+const storageFlushes = new Map();
+const gameDisposals = new Map();
+let recoveryChannel = null;
+let recoveryEvents = null;
+let recoveryBinding = '';
+let recoveryRevision = 0;
+let recoveryFingerprint = '';
+let recoveryReport = null;
+let recoveryWrites = Promise.resolve();
+let recoveryRegistration = null;
+let connectionProbe = null;
+const recoveryReceipts = new Map();
+const theme = matchMedia('(prefers-color-scheme: dark)');
+const syncTheme = () => { document.documentElement.dataset.theme = theme.matches ? 'dark' : 'light'; };
+syncTheme();
+theme.addEventListener('change', syncTheme);
+
+class ApiError extends Error {
+ constructor(message, code, details, uncertain = false) { super(message); this.code = code; this.details = details; this.uncertain = uncertain; }
+}
+async function api(endpoint, body, userAction) {
+ let response;
+ let value;
+ const abort = new AbortController();
+ const timer = setTimeout(() => abort.abort(), 8000);
+ try {
+ response = await fetch('/api/' + endpoint, body === undefined ? { signal: abort.signal } : {
+ method: 'POST', headers: { 'content-type': 'application/json', ...(userAction ? { 'x-preview-user-action': userAction } : {}) }, body: JSON.stringify(body),
+ signal: abort.signal,
+ });
+ value = await response.json().catch(() => null);
+ } catch { throw new ApiError('连接中断或请求超时,操作结果尚未确认。', 'OUTCOME_UNKNOWN', null, body !== undefined); }
+ finally { clearTimeout(timer); }
+ if (!response.ok || value?.ok === false) throw new ApiError(value?.message || '操作失败(' + response.status + ')', value?.code || 'REQUEST_FAILED', value?.details,
+ response.status >= 500 || value?.details?.committed === true || ['TRANSITION_OUTCOME_UNKNOWN', 'PREVIEW_RECOVERING'].includes(value?.code));
+ if (!value) throw new ApiError('服务返回无法解析的结果,请重新确认操作状态。', 'OUTCOME_UNKNOWN', null, body !== undefined);
+ return value;
+}
+const endpoint = (name, id = previewId) => 'previews/' + encodeURIComponent(id) + '/' + name;
+const sameIdentity = (a, b) => !!a && !!b && ['previewId', 'projectId', 'serverEpoch', 'buildId', 'clientId', 'instanceId', 'leaseEpoch'].every((key) => a[key] === b[key]);
+const dirty = () => JSON.stringify(storage) !== acknowledgedStorage;
+function selectedDevice() { return devices.get(state?.deviceId || devices.defaultId); }
+function requireReadyBuild(value = state) { if (value?.build?.status !== 'ok') throw new ApiError('当前构建不可用,请先在聊天中修复并重新构建;本地快照保持不变。', 'BUILD_NOT_AVAILABLE'); }
+function isGame() { return (state?.projectKind || state?.activeProject?.projectKind || state?.build?.manifest?.projectKind) === 'minigame'; }
+function layoutForBuild(deviceId = selectedDevice().id) {
+ return { deviceId, projectKind: isGame() ? 'minigame' : 'miniprogram', landscape: isGame() && state?.build?.manifest?.game?.deviceOrientation === 'landscape' };
+}
+function displayedLayout() { return frameLayout || layoutForBuild(); }
+function notice(message) { $('notice').textContent = message || ''; $('notice').hidden = !message; }
+function setEmpty(title, message) {
+ $('empty').hidden = false;
+ $('empty').querySelector('h2').textContent = title;
+ $('empty').querySelector('p').textContent = message;
+}
+function syncControls() {
+ const own = !!identity && !ownershipLost;
+ $('refresh-preview').disabled = !previewId || !state || closed || busy || rebuildBusy || !connected
+ || !recoveryChannel || ownershipLost || !!uncertainOperation || !!pendingDocument || !!storageProblem
+ || (!!state.lease && !own) || state.build?.status === 'building';
+ reportRecovery();
+}
+function recoveryState() {
+ const code = storageProblem?.code;
+ const status = bindingRetired ? 'ownership_lost' : pendingDocument ? 'document_failed' : uncertainOperation ? 'outcome_unknown' : closed ? 'closed'
+ : reconnectRequired ? 'reconnect_required' : ownershipLost ? 'ownership_lost' : !connected ? 'disconnected'
+ : code === 'STORAGE_CONFLICT' ? 'storage_conflict' : code === 'STORAGE_FLUSH_TIMEOUT' ? 'storage_timeout'
+ : failedStartupBuild ? 'startup_error' : started ? 'ready' : 'waiting';
+ return {
+ identity: identity?.serverEpoch === state?.serverEpoch ? identity : null,
+ contextLeaseEpoch: state?.leaseEpoch || 0, status, dirty: dirty() || retirementIncomplete,
+ exportAvailable: !!identity || started || Object.keys(storage).length > 0 || !!uncertainOperation || !!retirementIncomplete,
+ ...(uncertainOperation?.body?.operationId || storageProblem?.details?.operationId ? { operationId: uncertainOperation?.body?.operationId || storageProblem.details.operationId } : {}),
+ ...(closed && uncertainOperation ? { message: '预览已关闭,操作结果待确认。' } : storageProblem?.message ? { message: storageProblem.message.slice(0, 1000) } : {}),
+ };
+}
+function reportRecovery(force = false) {
+ if (!recoveryChannel || !state) return;
+ const value = recoveryState();
+ const fingerprint = JSON.stringify([value, storage]);
+ if (fingerprint === recoveryFingerprint) {
+ if (force && recoveryReport) {
+ const channel = recoveryChannel, report = recoveryReport;
+ recoveryWrites = recoveryWrites.catch(() => {}).then(() => channel === recoveryChannel && api('shells/' + encodeURIComponent(channel) + '/state', report)).catch(() => {});
+ }
+ return;
+ }
+ recoveryFingerprint = fingerprint;
+ recoveryReport = { ...value, revision: ++recoveryRevision };
+ const report = recoveryReport, channel = recoveryChannel;
+ recoveryWrites = recoveryWrites.catch(() => {}).then(() => {
+ if (channel !== recoveryChannel) return;
+ return api('shells/' + encodeURIComponent(channel) + '/state', report);
+ }).catch(() => { notice('预览恢复通道暂时不可用,本地快照仍保留;请在聊天中重试。'); });
+}
+function registerRecovery() {
+ const binding = state?.serverEpoch + ':' + previewId + ':' + state?.projectId;
+ if (recoveryChannel && recoveryBinding === binding) { reportRecovery(true); return recoveryWrites; }
+ if (recoveryRegistration?.binding === binding) return recoveryRegistration.promise;
+ const registration = { binding, promise: null };
+ registration.promise = establishRecovery(binding).finally(() => { if (recoveryRegistration === registration) recoveryRegistration = null; });
+ recoveryRegistration = registration;
+ return registration.promise;
+}
+async function establishRecovery(binding) {
+ recoveryEvents?.close(); recoveryChannel = null; recoveryBinding = binding;
+ recoveryRevision = 0; recoveryFingerprint = ''; recoveryReport = null; recoveryReceipts.clear();
+ const input = { previewId, clientId, registrationId, serverEpoch: state.serverEpoch };
+ let result;
+ try { result = await api('shells', input); }
+ catch (error) { if (!error.uncertain) throw error; result = await api('shells', input); }
+ if (binding !== recoveryBinding || typeof result.channelId !== 'string') throw new ApiError('恢复通道登记失败。', 'INVALID_RESPONSE');
+ recoveryChannel = result.channelId;
+ openRecoveryEvents(recoveryChannel);
+ reportRecovery(true);
+ await recoveryWrites;
+}
+function openRecoveryEvents(channel) {
+ recoveryEvents?.close();
+ recoveryEvents = new EventSource('/api/shells/' + encodeURIComponent(channel) + '/events');
+ listen(recoveryEvents, previewId, state.serverEpoch, selectionGeneration, channel);
+ recoveryEvents.addEventListener('connected', () => {
+ if (channel !== recoveryChannel) return;
+ reportRecovery(true);
+ for (const receipt of recoveryReceipts.values()) if (!receipt.acknowledged) acknowledgeRecovery(receipt, channel);
+ });
+ recoveryEvents.addEventListener('recovery-command', (event) => {
+ if (channel !== recoveryChannel) return;
+ let command; try { command = JSON.parse(event.data); } catch { return; }
+ receiveRecovery(command, channel);
+ });
+}
+function matchesRecovery(command, channel) {
+ reportRecovery();
+ const report = recoveryReport;
+ return channel === recoveryChannel && command.previewId === previewId && command.serverEpoch === state?.serverEpoch
+ && command.clientId === clientId && command.stateRevision === report?.revision
+ && command.contextLeaseEpoch === state?.leaseEpoch
+ && command.contextLeaseEpoch === report.contextLeaseEpoch
+ && (command.identity === null && report.identity === null || sameIdentity(command.identity, report.identity));
+}
+async function acknowledgeRecovery(receipt, channel) {
+ if (receipt.acking) return;
+ receipt.acking = true;
+ try {
+ const result = await receipt.result;
+ for (let attempt = 0; attempt < 2 && channel === recoveryChannel; attempt++) {
+ try { await api('shells/' + encodeURIComponent(channel) + '/ack', result); receipt.acknowledged = true; return; }
+ catch (error) { if (!error.uncertain) return; }
+ }
+ } finally { receipt.acking = false; }
+}
+function receiveRecovery(command, channel) {
+ if (!command || typeof command.requestId !== 'string' || !Number.isSafeInteger(command.stateRevision)) return;
+ const receiptKey = channel + ':' + command.requestId;
+ const signature = JSON.stringify(command);
+ const previous = recoveryReceipts.get(receiptKey);
+ if (previous) {
+ if (previous.signature !== signature) return;
+ acknowledgeRecovery(previous, channel); return;
+ }
+ const result = schedule(async () => {
+ const revalidate = () => { if (!matchesRecovery(command, channel)) throw new ApiError('恢复请求已过期,未执行。', 'STALE_FRAME'); };
+ revalidate();
+ if (bindingRetired && command.action !== 'export_local') throw new ApiError('此窗口只保留旧工程快照,不能操作新工程。', 'STALE_FRAME');
+ if (command.action === 'retry') await retryRecovery(revalidate);
+ else if (command.action === 'export_local') {
+ if (!recoveryState().exportAvailable) throw new ApiError('当前窗口没有可导出的本地快照。', 'LOCAL_SNAPSHOT_UNAVAILABLE');
+ downloadStorage(storage, 'miniapp-storage-unsaved.json');
+ notice('已发起本地快照导出;保存结果请在下载位置确认。');
+ return { requestId: command.requestId, stateRevision: command.stateRevision, status: 'export_requested' };
+ } else if (command.action === 'discard_local') {
+ if (closed || ownershipLost || !identity || uncertainOperation || pendingDocument) throw new ApiError('当前归属不能放弃并重载,请先确认操作结果。', 'STALE_FRAME');
+ requireReadyBuild();
+ if (!window.confirm('将放弃当前未保存修改并载入服务端数据。建议先在聊天中请求导出。确定继续吗?')) throw new ApiError('已取消放弃本地修改。', 'CANCELED');
+ revalidate();
+ await api(endpoint('storage'));
+ revalidate(); requireReadyBuild();
+ await transitionFrame({ discard: true });
+ } else if (command.action === 'takeover') {
+ if (closed || uncertainOperation || pendingDocument) throw new ApiError('先确认未完成操作,不能接管。', 'OUTCOME_UNKNOWN');
+ if (!window.confirm('接管会使当前运行窗口失去写入权限,未提交数据不会自动带入。确定接管吗?')) throw new ApiError('已取消接管。', 'CANCELED');
+ revalidate();
+ const latest = await api(endpoint('state'));
+ revalidate();
+ if (latest.serverEpoch !== command.serverEpoch || latest.projectId !== state.projectId || latest.leaseEpoch !== command.contextLeaseEpoch) throw new ApiError('预览归属已变化,未接管。', 'STALE_FRAME');
+ requireReadyBuild(latest);
+ state = latest;
+ if (!latest.lease && identity) { await disposeGameFrame(); revalidate(); resetFrame(); }
+ await transitionFrame({ takeover: !!latest.lease });
+ } else if (command.action === 'close') {
+ if (closed) throw new ApiError('此预览已经关闭。', 'PREVIEW_CONTEXT_NOT_FOUND');
+ if (!window.confirm('保存可提交的数据并关闭此预览?')) throw new ApiError('已取消关闭。', 'CANCELED');
+ revalidate();
+ await drainCurrent('close');
+ } else throw new ApiError('不支持的恢复操作。', 'INVALID_ARGUMENTS');
+ return { requestId: command.requestId, stateRevision: command.stateRevision, status: 'completed' };
+ }).catch((error) => {
+ notice(error.message);
+ return { requestId: command.requestId, stateRevision: command.stateRevision, status: 'failed', message: error.message.slice(0, 1000) };
+ });
+ const receipt = { signature, result, acknowledged: false, acking: false };
+ recoveryReceipts.set(receiptKey, receipt);
+ while (recoveryReceipts.size > 64) recoveryReceipts.delete(recoveryReceipts.keys().next().value);
+ acknowledgeRecovery(receipt, channel);
+}
+function post(message, target = frame, targetIdentity = identity) {
+ if (targetIdentity && target?.contentWindow) target.contentWindow.postMessage({ source: 'wx-shell', ...message, ...targetIdentity }, '*');
+}
+async function disposeGameFrame() {
+ if (!identity || frameKind !== 'minigame' || gameDisposed) return;
+ const owner = identity, source = frame.contentWindow, requestId = uid('dispose');
+ await new Promise((resolve) => {
+ const finish = (acknowledged) => {
+ const pending = gameDisposals.get(requestId); if (!pending) return;
+ clearTimeout(pending.timer); gameDisposals.delete(requestId);
+ if (sameIdentity(owner, identity)) gameDisposed = true;
+ if (!acknowledged) notice('小游戏清理确认超时;将销毁旧 iframe,不继续执行旧页面。');
+ resolve();
+ };
+ gameDisposals.set(requestId, { owner, source, finish, timer: setTimeout(() => finish(false), 800) });
+ post({ type: 'dispose', requestId });
+ });
+}
+function resetFrame() {
+ if (frameKind === 'minigame' && !gameDisposed) post({ type: 'dispose', requestId: uid('dispose') });
+ clearTimeout(startupTimer); startupTimer = null;
+ failedStartupBuild = '';
+ for (const pending of storageFlushes.values()) { clearTimeout(pending.timer); pending.reject(new ApiError('页面归属已经改变。', 'STALE_FRAME')); }
+ storageFlushes.clear();
+ frame.removeAttribute('src'); frame.hidden = true; frame.setAttribute('aria-busy', 'false');
+ identity = null; ready = false; started = false; loadedKey = ''; quiescing = false;
+ frameKind = 'miniprogram'; frameLayout = null; gameState = null; gameDisposed = false;
+}
+function retireCurrentFrame() {
+ if (!identity) return;
+ const owner = identity;
+ if (retirement && sameIdentity(retirement.owner, owner)) return retirement.promise;
+ const current = { owner, promise: null }; retirement = current;
+ current.promise = (async () => {
+ try { const value = await quiesceCurrent(); if (sameIdentity(owner, identity)) storage = validStorage(value); }
+ catch { retirementIncomplete = true; }
+ if (!sameIdentity(owner, identity)) return;
+ if (frameKind === 'minigame') {
+ await disposeGameFrame();
+ if (sameIdentity(owner, identity) && (closed || ownershipLost)) resetFrame();
+ }
+ renderDetails();
+ })().finally(() => { if (retirement === current) retirement = null; });
+ return current.promise;
+}
+function storageFailure(error) {
+ storageProblem = error; automaticBlocked = true;
+ notice(error.code === 'STORAGE_CONFLICT' ? 'Storage 与其他预览冲突。当前页面与未保存快照已保留,请导出或明确放弃本地修改。' : error.message);
+ syncControls();
+}
+function validStorage(value) {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new ApiError('Storage 快照必须是 JSON 对象。', 'INVALID_STORAGE');
+ const encoded = JSON.stringify(value);
+ if (new TextEncoder().encode(encoded).length > 1024 * 1024) throw new ApiError('Storage 超过 1 MiB,未提交。', 'STORAGE_TOO_LARGE');
+ return JSON.parse(encoded);
+}
+function downloadStorage(value, filename) {
+ const url = URL.createObjectURL(new Blob([JSON.stringify(value, null, 2)], { type: 'application/json' }));
+ const link = document.createElement('a');
+ try {
+ link.href = url; link.download = filename; document.body.append(link); link.click();
+ } finally { link.remove(); setTimeout(() => URL.revokeObjectURL(url), 0); }
+}
+async function checkedOutcome(outcome, request) {
+ const result = outcome.result;
+ let stale = result?.identity && Object.hasOwn(outcome, 'currentIdentity') && !sameIdentity(result.identity, outcome.currentIdentity);
+ if (result?.state && !result.closed) {
+ const current = await api(endpoint('state', request.previewId));
+ stale ||= current.serverEpoch !== result.state.serverEpoch || current.projectId !== result.state.projectId || current.leaseEpoch !== result.state.leaseEpoch;
+ }
+ if (stale) { uncertainOperation = null; loseOwnership(); throw new ApiError('此前操作已完成,但预览已再次变更。不会应用旧响应或抢回租约。', 'STALE_FRAME'); }
+ return result;
+}
+async function operationRequest(name, body, userAction, resolveOutcome = true) {
+ const id = body.previewId;
+ try { return await api(endpoint(name, id), body, userAction); }
+ catch (first) {
+ if (!first.uncertain) throw first;
+ try { return await api(endpoint(name, id), body, userAction); }
+ catch (second) {
+ let committed = first.details?.committed === true || second.details?.committed === true;
+ if (!second.uncertain && !['TRANSITION_OUTCOME_UNKNOWN', 'STALE_FRAME', 'PREVIEW_CONTEXT_NOT_FOUND'].includes(second.code)) throw second;
+ if (resolveOutcome) {
+ const outcome = await api(endpoint('operations/' + encodeURIComponent(body.operationId), id)).catch(() => null);
+ committed ||= outcome?.committed === true;
+ if (outcome?.status === 'committed' && outcome.result) {
+ return checkedOutcome(outcome, body);
+ }
+ }
+ if (committed) throw new ApiError('数据已经提交,页面交接尚待恢复。请确认操作结果;不会重复提交本地快照。', 'PREVIEW_RECOVERING', { committed: true, operationId: body.operationId }, true);
+ throw new ApiError('操作响应丢失,尚不能确定是否提交。请重新确认操作结果;当前快照没有被丢弃。', 'OUTCOME_UNKNOWN', null, true);
+ }
+ }
+}
+function enqueueStorage(value) {
+ const owner = identity;
+ storageWrites = storageWrites.then(async () => {
+ if (!sameIdentity(owner, identity) || (storageProblem && storageProblem.code !== 'STORAGE_FLUSH_TIMEOUT') || uncertainOperation || pendingDocument || ownershipLost || closed) return;
+ const encoded = JSON.stringify(value);
+ if (encoded === acknowledgedStorage) return;
+ const body = { ...owner, operationId: uid('write'), baseRevision: ackRevision, data: value, ...(quiescing ? { drain: true } : {}) };
+ const accept = (result) => {
+ if (!sameIdentity(owner, identity)) return;
+ if (!Number.isSafeInteger(result.committedRevision)) throw new ApiError('Storage 确认缺少 revision。', 'OUTCOME_UNKNOWN', null, true);
+ ackRevision = result.committedRevision; acknowledgedStorage = encoded;
+ if (storageProblem?.code === 'STORAGE_FLUSH_TIMEOUT') {
+ storageProblem = null;
+ notice('之前的刷新未完成;当前页面已恢复,后续 Storage 修改已保存。可手动重试刷新。');
+ renderDetails();
+ }
+ reportRecovery();
+ };
+ try { accept(await operationRequest('storage', body)); }
+ catch (error) {
+ if (error.uncertain) uncertainOperation = { name: 'storage', body, accept };
+ storageFailure(error);
+ }
+ });
+}
+function quiesceCurrent() {
+ if (!identity || !ready || !started) return Promise.resolve(storage);
+ const requestId = uid('quiesce');
+ const owner = identity;
+ quiescing = true;
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ storageFlushes.delete(requestId);
+ reject(new ApiError('页面未响应 Storage 同步;当前页面没有被替换。', 'STORAGE_FLUSH_TIMEOUT'));
+ }, 2000);
+ storageFlushes.set(requestId, { owner, source: frame.contentWindow, timer, resolve, reject });
+ post({ type: 'quiesce-storage', requestId });
+ });
+}
+function resumeCurrent() {
+ if (identity && quiescing) post({ type: 'resume-storage' });
+ quiescing = false;
+}
+function renderPreviewStatus() {
+ const build = state?.build;
+ const needsRecovery = !!storageProblem || !!uncertainOperation || !!pendingDocument || ownershipLost;
+ let status = !connected || needsRecovery ? 'error' : build?.status === 'error' ? 'error' : build?.status === 'building' ? 'building' : runtimeStatus.status;
+ let message = !previewId ? '请在聊天中让 Agent 打开工程' : bindingRetired ? '工程已切换,此窗口仅保留旧工程快照'
+ : closed ? '预览已关闭,请在聊天中继续'
+ : !connected ? '连接中断,正在重连' : needsRecovery ? '预览需要处理,请在聊天中让 Agent 继续'
+ : build?.status === 'error' ? '构建未通过,原页面保持不变;详情可在聊天中查看'
+ : status === 'building' ? '正在构建' : status === 'starting' ? '正在启动预览'
+ : status === 'error' ? '预览运行异常,请在聊天中查看原因'
+ : isGame() && gameState?.paused ? '小游戏已暂停,可在聊天中继续'
+ : status === 'limited' || build?.warnings?.length || storageWarnings.length ? '部分能力受限,详情可在聊天中查看'
+ : status === 'rendered' ? '' : '等待预览';
+ if (closed && !needsRecovery) status = 'idle';
+ $('preview-status').textContent = message;
+ $('preview-status').dataset.status = status;
+ $('preview-status').hidden = !message;
+}
+function renderDetails() {
+ const project = state?.activeProject || state?.projects?.find((item) => item.id === state?.projectId);
+ $('project-name').textContent = project?.name || '微信小程序与小游戏预览';
+ $('project-name').title = $('project-name').textContent;
+ $('project-kind-label').textContent = project ? isGame() ? '小游戏 · 本地' : '小程序 · 本地' : '本地预览';
+ renderPreviewStatus(); syncControls(); sizeDevice();
+}
+async function mountFrame(result, deviceId, layout = layoutForBuild(deviceId)) {
+ if (!result.identity || result.identity.previewId !== previewId || result.identity.clientId !== clientId
+ || !result.storage || !Number.isSafeInteger(result.storage.revision)) throw new ApiError('交接响应身份或 Storage 无效。', 'OUTCOME_UNKNOWN', null, true);
+ if (closed || bindingRetired || result.identity.serverEpoch !== state?.serverEpoch || result.identity.projectId !== state?.projectId) throw new ApiError('已确认页面不再属于当前预览,未加载旧文档。', 'STALE_FRAME');
+ const pending = { result, deviceId, layout };
+ pendingDocument = pending;
+ // The new lease is already committed. If document transfer fails, the old DOM
+ // may stay visible but can never resume writing under its obsolete identity.
+ ownershipLost = true;
+ const snapshot = validStorage(result.storage.data);
+ if (state) { state.lease = result.identity; state.leaseEpoch = result.identity.leaseEpoch; }
+ const assertCurrent = () => {
+ if (pendingDocument !== pending || closed || pendingClosed || bindingRetired || state?.serverEpoch !== result.identity.serverEpoch
+ || state?.projectId !== result.identity.projectId || state?.leaseEpoch !== result.identity.leaseEpoch) throw new ApiError('文档加载期间预览归属已变化,旧文档没有重新挂载。', 'STALE_FRAME');
+ };
+ const query = new URLSearchParams(Object.fromEntries(['instanceId', 'buildId', 'leaseEpoch', 'serverEpoch', 'clientId'].map((key) => [key, String(result.identity[key])])));
+ let documentResult;
+ try {
+ documentResult = await api(endpoint('document', result.identity.previewId) + '?' + query);
+ assertCurrent();
+ if (!sameIdentity(documentResult.identity, result.identity) || typeof documentResult.html !== 'string' || documentResult.html.length > 160 * 1024 * 1024) throw new ApiError('预览文档身份或大小无效。', 'INVALID_DOCUMENT');
+ } catch (error) {
+ if (['STALE_FRAME', 'PREVIEW_CONTEXT_NOT_FOUND', 'PROJECT_ACCESS_REQUIRED', 'PROJECT_ACCESS_CHANGED', 'PROJECT_NOT_FOUND', 'PROJECT_NOT_DIRECTORY', 'PROJECT_PATH_FORBIDDEN'].includes(error.code)) {
+ pendingDocument = null;
+ const latest = await api(endpoint('state', result.identity.previewId)).catch(() => null);
+ if (latest) applyState(latest);
+ if (error.code === 'PREVIEW_CONTEXT_NOT_FOUND') markClosed();
+ storageProblem = new ApiError('已确认页面的租约或工程身份已失效。保留本地快照供导出;请在聊天中选择其他预览或明确接管,不会自动抢回。', error.code);
+ notice(storageProblem.message); syncControls(); throw storageProblem;
+ }
+ automaticBlocked = true; storageProblem = new ApiError('新页面交接已确认,但文档加载失败。旧页面不可再写入;请重新加载已确认页面。' + error.message, 'DOCUMENT_LOAD_FAILED');
+ notice(storageProblem.message); syncControls(); throw storageProblem;
+ }
+ const bytes = new TextEncoder().encode(documentResult.html);
+ let binary = '';
+ for (let start = 0; start < bytes.length; start += 16384) binary += String.fromCharCode(...bytes.subarray(start, start + 16384));
+ const documentUrl = 'data:text/html;base64,' + btoa(binary);
+ await disposeGameFrame();
+ assertCurrent();
+ resetFrame();
+ const replacement = frame.cloneNode(false); replacement.removeAttribute('src'); frame.replaceWith(replacement); frame = replacement;
+ identity = result.identity; storage = snapshot; ackRevision = result.storage.revision; acknowledgedStorage = JSON.stringify(storage);
+ frameKind = layout.projectKind; frameLayout = layout;
+ frame.title = isGame() ? '微信小游戏 H5 预览' : '微信小程序 H5 预览';
+ storageWarnings = Array.isArray(result.warnings) ? result.warnings.slice(0, 20).filter((item) => item && typeof item.message === 'string') : [];
+ storageProblem = null; uncertainOperation = null; pendingDocument = null; reconnectRequired = false; automaticBlocked = false; ownershipLost = false; retirementIncomplete = false;
+ if (state) { state.lease = identity; state.leaseEpoch = identity.leaseEpoch; state.deviceId = deviceId; }
+ loadedKey = identity.projectId + '/' + identity.buildId + '/' + deviceId;
+ // Only the latest confirmed context snapshot can carry a pre-attach target
+ // across a lease change; late events from the old lease cannot navigate it.
+ const carry = state?.command;
+ if (!isGame() && carry?.type === 'navigate' && carry.seq > commandSeq && (!carry.projectId || carry.projectId === identity.projectId)
+ && (!carry.serverEpoch || carry.serverEpoch === identity.serverEpoch) && (!carry.buildId || carry.buildId === identity.buildId)) {
+ requestedRoute = state.requestedRoute || carry.url;
+ commandSeq = carry.seq;
+ }
+ if (pendingCommand?.leaseEpoch !== undefined && pendingCommand.leaseEpoch !== identity.leaseEpoch) pendingCommand = null;
+ if (pendingCommand?.type === 'reload' && pendingCommand.buildId === identity.buildId) { commandSeq = Math.max(commandSeq, pendingCommand.seq); pendingCommand = null; }
+ if (state?.command?.type === 'reload' && state.command.buildId === identity.buildId) commandSeq = Math.max(commandSeq, state.command.seq);
+ frame.hidden = false; frame.setAttribute('aria-busy', 'true'); runtimeStatus = { status: 'starting', details: [] };
+ setEmpty('正在启动预览', '正在加载页面与运行环境…'); notice('');
+ const owner = identity;
+ frame.src = documentUrl;
+ startupTimer = setTimeout(() => { if (sameIdentity(owner, identity) && !started) startupFailure('预览未能完成启动,请在聊天中查看原因后刷新。'); }, 10000);
+ renderDetails();
+}
+function startupFailure(message) {
+ clearTimeout(startupTimer); startupTimer = null; automaticBlocked = true;
+ failedStartupBuild = identity?.buildId || '';
+ runtimeStatus = { status: 'error', details: [{ code: 'startup', message }] };
+ frame.setAttribute('aria-busy', 'false'); setEmpty('预览启动失败', message); notice(message); renderDetails();
+}
+async function transitionFrame({ takeover = false, discard = false } = {}) {
+ if (!state || state.build?.status !== 'ok' || closed) return;
+ if (uncertainOperation || pendingDocument) throw new ApiError('请先确认上一次操作或重新加载已确认页面。', 'OUTCOME_UNKNOWN');
+ if (state.lease && !identity && !takeover) { showOtherOwner(); return; }
+ const oldIdentity = identity;
+ const deviceId = selectedDevice().id;
+ const action = takeover ? 'takeover' : oldIdentity ? 'reload' : 'attach';
+ let finalStorage = storage;
+ mutationPending = true;
+ try {
+ if (oldIdentity && !takeover) finalStorage = await quiesceCurrent();
+ await storageWrites;
+ if (uncertainOperation) throw new ApiError('Storage 写入结果尚未确定,请先确认。', 'OUTCOME_UNKNOWN', null, true);
+ if (!discard && !takeover && storageProblem?.code === 'STORAGE_CONFLICT') throw storageProblem;
+ storage = validStorage(finalStorage);
+ reportRecovery();
+ const changed = !!oldIdentity && !discard && !takeover && dirty();
+ const nextLayout = layoutForBuild(deviceId);
+ const body = { action, serverEpoch: state.serverEpoch, previewId, projectId: state.projectId, expectedLeaseEpoch: state.leaseEpoch,
+ operationId: uid('frame'), next: { buildId: state.build.buildId, clientId, instanceId: uid('instance') },
+ ...(oldIdentity && !takeover ? { previous: oldIdentity } : {}), dirty: changed,
+ ...(changed ? { baseRevision: ackRevision, data: storage } : {}) };
+ const accept = async (result) => {
+ const expected = { ...body.next, previewId: body.previewId, projectId: body.projectId, serverEpoch: body.serverEpoch, leaseEpoch: body.expectedLeaseEpoch + 1 };
+ if (!sameIdentity(result.identity, expected) || result.operationId !== body.operationId) throw new ApiError('交接响应与请求身份不一致。', 'OUTCOME_UNKNOWN', null, true);
+ await mountFrame(result, deviceId, nextLayout);
+ };
+ try { await accept(await operationRequest('frame', body)); }
+ catch (error) { if (error.uncertain) uncertainOperation = { name: 'frame', body, accept }; throw error; }
+
+ } catch (error) {
+ automaticBlocked = true;
+ if (!error.uncertain && !pendingDocument && !ownershipLost) resumeCurrent();
+ storageFailure(error); throw error;
+ } finally { mutationPending = false; if (pendingClosed) markClosed(); syncControls(); }
+}
+function showOtherOwner() {
+ automaticBlocked = true; runtimeStatus = { status: 'waiting', details: [] };
+ setEmpty('预览已在另一窗口打开', '请在聊天中选择其他预览,或明确请求接管。不会自动带入其他窗口的未保存数据。'); renderDetails();
+}
+function markClosed() {
+ pendingClosed = false; closed = true; automaticBlocked = true;
+ pendingDocument = null;
+ connectionStatus('已关闭', 'idle');
+ if (identity) { ownershipLost = true; retireCurrentFrame(); }
+ runtimeStatus = { status: 'idle', details: [] };
+ setEmpty('预览已关闭', '请在聊天中让 Agent 选择或打开工程;此链接不会自动新建预览。');
+ if (dirty()) storageFailure(new ApiError('预览已关闭。本地未确认数据仍可导出,但不能再提交。', 'PREVIEW_CONTEXT_NOT_FOUND'));
+ renderDetails();
+}
+function schedule(work) {
+ const next = lifecycle.then(async () => { busy = true; syncControls(); try { await retirement?.promise; return await work(); } finally { busy = false; renderDetails(); queueMicrotask(() => { runCommand(state?.command); maybeLoad(); }); } });
+ lifecycle = next.catch((error) => { notice(error.message); });
+ return next;
+}
+function maybeLoad() {
+ if (automaticBlocked && failedStartupBuild && state?.build?.status === 'ok' && state.build.buildId !== failedStartupBuild
+ && !ownershipLost && !closed && !storageProblem && !uncertainOperation && !pendingDocument) {
+ automaticBlocked = false; failedStartupBuild = '';
+ }
+ if (!state || !recoveryChannel || closed || automaticBlocked || uncertainOperation || pendingDocument || busy || state.build?.status !== 'ok') return;
+ const key = state.projectId + '/' + state.build.buildId + '/' + selectedDevice().id;
+ if (loadedKey !== key && pendingLoadKey !== key) {
+ pendingLoadKey = key;
+ schedule(() => transitionFrame()).catch(() => {}).finally(() => {
+ if (pendingLoadKey === key) pendingLoadKey = '';
+ maybeLoad();
+ });
+ }
+}
+function applyState(next) {
+ if (!next || next.previewId !== previewId || closed || bindingRetired) return;
+ if (state && next.serverEpoch === state.serverEpoch && next.leaseEpoch < state.leaseEpoch) return;
+ if (state && next.serverEpoch === state.serverEpoch && next.projectId === state.projectId
+ && Number.isFinite(next.build?.generation) && (next.build.generation < (state.build?.generation || 0)
+ || next.build.generation === state.build?.generation && ['ok', 'error'].includes(state.build.status) && ['idle', 'building'].includes(next.build.status))) next = { ...next, build: state.build };
+ if (state && next.projectId !== state.projectId && (identity || started || pendingDocument || uncertainOperation || retirementIncomplete || Object.keys(storage).length)) {
+ bindingRetired = true; ownershipLost = true; automaticBlocked = true;
+ ++selectionGeneration; pendingDocument = null;
+ retireCurrentFrame();
+ storageFailure(new ApiError('预览已切换到另一工程。此窗口保留原工程标题与本地快照,聊天中仍可导出;请让 Agent 打开新工程预览。', 'PROJECT_CONTEXT_CHANGED'));
+ setEmpty('工程已切换', '此窗口只保留旧工程快照,不会把它写入新工程。请在聊天中继续。');
+ renderDetails(); return;
+ }
+ if (state && next.serverEpoch !== state.serverEpoch && (identity || pendingDocument || uncertainOperation)) {
+ state = next; ownershipLost = true; automaticBlocked = true;
+ pendingDocument = null; uncertainOperation = null; reconnectRequired = false;
+ retireCurrentFrame();
+ storageFailure(new ApiError('预览服务已重启,旧页面身份失效,未确认操作不能继续重放。请先导出本地快照,再在聊天中明确重新连接。', 'SERVER_RESTARTED'));
+ setEmpty('预览服务已重启', '旧页面不能继续提交,请在聊天中让 Agent 导出快照并恢复预览。');
+ registerRecovery().catch((error) => notice(error.message));
+ return;
+ }
+ if (identity && !sameIdentity(identity, next.lease && { ...next.lease, previewId, projectId: next.projectId, serverEpoch: next.serverEpoch }) && !mutationPending && !ownershipLost) {
+ loseOwnership();
+ }
+ const changedBinding = state && (state.projectId !== next.projectId || state.serverEpoch !== next.serverEpoch);
+ if (state?.projectId !== next.projectId) route = '';
+ state = next;
+ if (changedBinding) registerRecovery().catch((error) => notice(error.message));
+ if (!identity && !busy) sizeDevice();
+ if (!route) route = next.route || '';
+ runtimeStatus = identity ? runtimeStatus : next.preview || next.runtime || runtimeStatus;
+ if (next.build?.status === 'error') { automaticBlocked = !!storageProblem; if (!identity) setEmpty('构建未通过', '修复诊断后会重新构建。'); }
+ if (!identity && next.lease) showOtherOwner();
+ renderDetails(); runCommand(next.command); maybeLoad();
+}
+function loseOwnership() {
+ ownershipLost = true; automaticBlocked = true;
+ if (identity) retireCurrentFrame();
+ storageFailure(new ApiError('此预览已被另一个窗口接管。当前未保存快照只可导出,不会自动抢回。', 'STALE_FRAME'));
+ setEmpty('预览已被接管', '当前页面已停止提交,请在聊天中请求导出本地快照或选择其他预览。');
+ syncControls();
+}
+function runCommand(command) {
+ command ||= pendingCommand;
+ if (!command || !identity || ownershipLost || closed) return;
+ if ((command.previewId && command.previewId !== previewId) || (command.projectId && command.projectId !== identity.projectId)
+ || (command.serverEpoch && command.serverEpoch !== identity.serverEpoch) || (command.leaseEpoch !== undefined && command.leaseEpoch !== identity.leaseEpoch)
+ || (command.buildId && command.buildId !== identity.buildId)) return;
+ if (command.seq > commandSeq && (!pendingCommand || command.seq > pendingCommand.seq)) { pendingCommand = command; if (command.type === 'navigate') requestedRoute = command.url; }
+ if (!pendingCommand || (!started && pendingCommand.type !== 'reload') || busy || storageProblem || uncertainOperation || pendingDocument) return;
+ command = pendingCommand; pendingCommand = null; commandSeq = command.seq;
+ if (command.type === 'navigate' && !isGame()) post({ type: 'navigate', url: command.url });
+ if (command.type === 'game-control' && isGame()) post({ type: 'game-control', action: command.url, requestId: 'command-' + command.seq });
+ if (command.type === 'reload') {
+ if (state?.build?.status !== 'ok') return;
+ automaticBlocked = false; schedule(() => transitionFrame()).catch(() => {});
+ }
+}
+function listen(events, id, epoch, generation, channel) {
+ const current = () => channel === recoveryChannel && generation === selectionGeneration && id === previewId && !closed && !bindingRetired;
+ const receive = (callback) => (event) => {
+ if (!current()) return;
+ let data; try { data = JSON.parse(event.data); } catch { return; }
+ if (data.previewId !== id || (data.serverEpoch !== (state?.serverEpoch || epoch) && callback !== applyState)) return;
+ callback(data);
+ };
+ events.addEventListener('open', () => {
+ if (!current()) return;
+ connectionStatus('已连接', 'ok');
+ api(endpoint('state', id)).then(async (next) => {
+ if (!current()) return;
+ applyState(next);
+ if (!current()) return;
+ await registerRecovery();
+ if (current()) { renderDetails(); maybeLoad(); }
+ }).catch((error) => { if (error.code === 'PREVIEW_CONTEXT_NOT_FOUND') markClosed(); });
+ });
+ events.addEventListener('error', () => {
+ if (!current()) return;
+ connectionStatus('连接中断 · 正在重连', 'error');
+ if (connectionProbe) return;
+ connectionProbe = api(endpoint('state', id)).then(async (next) => {
+ if (!current()) return;
+ applyState(next);
+ if (current()) await registerRecovery();
+ }).catch((error) => { if (error.code === 'PREVIEW_CONTEXT_NOT_FOUND') markClosed(); }).finally(() => { connectionProbe = null; });
+ });
+ events.addEventListener('state', receive(applyState));
+ events.addEventListener('build', receive((data) => {
+ if (data.projectId !== state?.projectId || (Number.isFinite(data.build?.generation) && data.build.generation < (state?.build?.generation || 0))) return;
+ state = { ...state, build: data.build }; renderDetails(); maybeLoad();
+ }));
+ events.addEventListener('command', receive(runCommand));
+ events.addEventListener('runtime', receive((data) => {
+ if (!identity || ownershipLost || (data.buildId && data.buildId !== identity.buildId) || (data.leaseEpoch && data.leaseEpoch !== identity.leaseEpoch)) return;
+ runtimeStatus = { status: data.status, details: data.details }; renderDetails();
+ }));
+ events.addEventListener('frame-active', receive((data) => {
+ if (mutationPending || data.leaseEpoch <= (state?.leaseEpoch || 0)) return;
+ state = { ...state, lease: data, leaseEpoch: data.leaseEpoch };
+ if (identity && !sameIdentity(identity, data)) loseOwnership();
+ else if (!identity) showOtherOwner();
+ }));
+ events.addEventListener('closed', receive(() => { if (mutationPending) pendingClosed = true; else markClosed(); }));
+ events.addEventListener('storage-updated', receive((data) => { if (data.originPreviewId !== id && data.revision > ackRevision) notice('其他预览已更新此工程 Storage。当前运行内存保持不变,下一次写入可能需要处理冲突。'); }));
+
+}
+async function openCurrentPreview() {
+ if (!previewId) {
+ setEmpty('等待预览工程', '请在聊天中让 Agent 打开要预览的小程序或小游戏。');
+ renderDetails(); return;
+ }
+ const generation = ++selectionGeneration;
+ try {
+ const next = await api(endpoint('state'));
+ if (generation !== selectionGeneration) return;
+ state = next; sizeDevice();
+ await registerRecovery();
+ if (generation !== selectionGeneration || closed || bindingRetired) return;
+ renderDetails();
+ if (state.lease) showOtherOwner();
+ else if (state.build?.status === 'ok') await transitionFrame();
+ } catch (error) {
+ if (error.code === 'PREVIEW_CONTEXT_NOT_FOUND') markClosed();
+ else { connected = false; setEmpty('无法打开预览', '请在聊天中让 Agent 检查工程与预览状态。'); notice(error.message); renderDetails(); }
+ }
+}
+async function drainCurrent(name, targetProjectId) {
+ if (uncertainOperation || pendingDocument) throw new ApiError('请先确认未完成操作或重新加载已确认页面。', 'OUTCOME_UNKNOWN');
+ const owner = identity;
+ mutationPending = true;
+ try {
+ const finalStorage = owner ? await quiesceCurrent() : storage;
+ await storageWrites;
+ if (uncertainOperation) throw new ApiError('Storage 写入结果尚未确定。', 'OUTCOME_UNKNOWN', null, true);
+ if (storageProblem?.code === 'STORAGE_CONFLICT') throw storageProblem;
+ storage = validStorage(finalStorage);
+ const changed = !!owner && dirty();
+ const body = { ...(owner || { previewId, projectId: state.projectId, serverEpoch: state.serverEpoch, expectedLeaseEpoch: state.leaseEpoch }), operationId: uid(name), dirty: changed,
+ ...(changed ? { baseRevision: ackRevision, data: storage } : {}), ...(targetProjectId ? { targetProjectId } : {}) };
+ const accept = async (result) => {
+ if (result.storage) { storage = validStorage(result.storage.data); ackRevision = result.storage.revision; acknowledgedStorage = JSON.stringify(storage); }
+ await disposeGameFrame();
+ resetFrame(); storageProblem = null; uncertainOperation = null; automaticBlocked = false;
+ if (result.closed) { pendingClosed = false; closed = true; markClosed(); }
+ else if (result.state) { state = result.state; route = result.state.route || ''; }
+ else if (result.previewId) { state = result; route = result.route || ''; }
+ };
+ try { const result = await operationRequest(name, body); await accept(result); return result; }
+ catch (error) { if (error.uncertain) uncertainOperation = { name, body, accept }; throw error; }
+ } catch (error) { if (!error.uncertain && !ownershipLost) resumeCurrent(); storageFailure(error); throw error; }
+ finally { mutationPending = false; if (pendingClosed) markClosed(); }
+}
+function connectionStatus(_message, status) {
+ connected = status === 'ok'; renderPreviewStatus(); syncControls();
+}
+window.addEventListener('message', (event) => {
+ const data = event.data;
+ if (!data || data.source !== 'wx-sim' || event.origin !== 'null') return;
+ if (data.type === 'game-disposed') {
+ const pending = gameDisposals.get(data.requestId);
+ if (pending && event.source === pending.source && sameIdentity(data, pending.owner)) pending.finish(true);
+ return;
+ }
+ if (data.type === 'storage-flushed') {
+ const pending = storageFlushes.get(data.requestId);
+ if (!pending || event.source !== pending.source || data.projectId !== pending.owner.projectId || data.instanceId !== pending.owner.instanceId) return;
+ if (frameKind === 'minigame' && !sameIdentity(data, pending.owner)) return;
+ try { const value = validStorage(data.storage ?? data.data); clearTimeout(pending.timer); storageFlushes.delete(data.requestId); pending.resolve(value); } catch (error) { pending.reject(error); }
+ return;
+ }
+ if (!identity || event.source !== frame.contentWindow || data.projectId !== identity.projectId || data.instanceId !== identity.instanceId || closed || ownershipLost) return;
+ if (frameKind === 'minigame' && !sameIdentity(data, identity)) return;
+ if (data.type === 'ready' && !ready) {
+ ready = true; post({ type: 'init', storage, ...(!isGame() ? { route: requestedRoute || route || state?.route || state?.command?.url } : {}), device: frameLayout?.deviceId || selectedDevice().id, visible: !document.hidden });
+ } else if (data.type === 'started' && ready && !started) {
+ clearTimeout(startupTimer); started = true; frame.setAttribute('aria-busy', 'false'); $('empty').hidden = true; syncControls(); runCommand(state?.command);
+ } else if (data.type === 'game-state' && isGame() && typeof data.paused === 'boolean') {
+ gameState = { paused: data.paused, reason: String(data.reason || '').slice(0, 100), ...(Number.isFinite(data.framesPerSecond) ? { framesPerSecond: Math.max(1, Math.min(60, data.framesPerSecond)) } : {}) };
+ api(endpoint('game-state'), { ...identity, ...gameState }).catch(() => {}); syncControls();
+ } else if (data.type === 'storage') {
+ try { storage = validStorage(data.data); reportRecovery(); enqueueStorage(storage); } catch (error) { storageFailure(error); }
+ } else if (data.type === 'preview-status' && ['rendered', 'limited', 'error'].includes(data.status) && Array.isArray(data.details)) {
+ runtimeStatus = { status: data.status, details: data.details.slice(0, 100).filter((item) => item && typeof item.message === 'string').map((item) => ({ code: String(item.code || 'runtime').slice(0, 100), message: item.message.slice(0, 2000), ...(typeof item.route === 'string' ? { route: item.route } : {}) })) };
+ api(endpoint('preview-status'), { ...identity, ...runtimeStatus }).catch(() => {}); renderDetails();
+ } else if (data.type === 'startup-error') startupFailure('预览启动失败,请在聊天中查看原因后刷新。');
+ else if (data.type === 'log' && Array.isArray(data.entries)) api(endpoint('logs'), { ...identity, entries: data.entries.slice(0, 100).filter((item) => item && typeof item === 'object').map((item) => ({
+ level: ['log', 'info', 'warn', 'error', 'debug'].includes(item.level) ? item.level : 'log',
+ message: String(item.message || '').slice(0, 4000),
+ source: item.source === 'startup' ? 'runtime' : ['console', 'runtime', 'build'].includes(item.source) ? item.source : 'console',
+ ...(typeof item.route === 'string' ? { route: item.route.slice(0, 2048) } : {}),
+ })) }).catch(() => {});
+ else if (data.type === 'route' && !isGame() && typeof data.route === 'string' && !quiescing) {
+ if (requestedRoute && data.route.replace(/^\//, '') !== requestedRoute.replace(/^\//, '')) return;
+ route = data.route.slice(0, 2048); requestedRoute = '';
+ api(endpoint('route'), { ...identity, route, title: typeof data.title === 'string' ? data.title : '' }).catch(() => {});
+ }
+});
+async function refreshPreview() {
+ if (!previewId || !state || closed || busy || rebuildBusy || ownershipLost || uncertainOperation || pendingDocument || storageProblem) return;
+ rebuildBusy = true;
+ return schedule(async () => {
+ const current = { previewId, projectId: state.projectId, serverEpoch: state.serverEpoch };
+ const next = await api(endpoint('rebuild'), { previewId, serverEpoch: state.serverEpoch });
+ if (closed || current.previewId !== previewId || current.projectId !== state?.projectId || current.serverEpoch !== state?.serverEpoch || ownershipLost) throw new ApiError('刷新期间预览归属已变化,未替换页面。', 'STALE_FRAME');
+ applyState(next);
+ if (state.build?.status !== 'ok') {
+ notice('构建未通过,原页面与数据保持不变;请在聊天中查看具体错误。');
+ return;
+ }
+ automaticBlocked = false;
+ await transitionFrame();
+ }).catch((error) => notice(error.message)).finally(() => { rebuildBusy = false; syncControls(); });
+}
+async function retryRecovery(revalidate) {
+ if (reconnectRequired) {
+ if (!window.confirm('交接已提交,但旧构建不可用。请先导出本地快照;确定从服务端最新 Storage 连接最新构建吗?')) throw new ApiError('已取消连接最新构建。', 'CANCELED');
+ revalidate();
+ const latest = await api(endpoint('state'));
+ revalidate();
+ if (latest.serverEpoch !== state.serverEpoch || latest.lease || latest.reconnectTarget?.clientId !== clientId) throw new ApiError('恢复目标已改变,请重新选择预览或明确接管。', 'STALE_FRAME');
+ requireReadyBuild(latest);
+ await disposeGameFrame(); revalidate();
+ state = latest; reconnectRequired = false; resetFrame(); ownershipLost = false; automaticBlocked = false;
+ await transitionFrame(); return;
+ }
+ if (pendingDocument) { const pending = pendingDocument; await mountFrame(pending.result, pending.deviceId, pending.layout); return; }
+ const pending = uncertainOperation;
+ if (!pending) {
+ if (storageProblem?.code === 'STORAGE_FLUSH_TIMEOUT' && identity && !ownershipLost && !closed) {
+ requireReadyBuild();
+ storageProblem = null; automaticBlocked = false; await transitionFrame(); return;
+ }
+ if (!connected && !closed) {
+ const previousEpoch = state.serverEpoch;
+ const latest = await api(endpoint('state')); applyState(latest);
+ if (latest.serverEpoch !== previousEpoch || !latest.lease || ownershipLost) throw new ApiError('请在聊天中明确重新连接或接管预览;不会自动提交旧快照。', 'RECOVERY_NOT_AVAILABLE');
+ openRecoveryEvents(recoveryChannel); return;
+ }
+ throw new ApiError('当前状态没有可重试的同一操作,请在聊天中重新读取预览状态。', 'RECOVERY_NOT_AVAILABLE');
+ }
+ let result;
+ const outcome = await api(endpoint('operations/' + pending.body.operationId, pending.body.previewId));
+ if (outcome.status === 'committed' && outcome.result) {
+ result = await checkedOutcome(outcome, pending.body);
+ }
+ else if (outcome.status === 'recovering') {
+ const recovery = await api(endpoint('operations/' + pending.body.operationId + '/recover', pending.body.previewId), { serverEpoch: pending.body.serverEpoch, previewId: pending.body.previewId, operationId: pending.body.operationId, clientId });
+ if (recovery.status === 'reconnect_required') {
+ if (recovery.state?.previewId !== pending.body.previewId || recovery.state.serverEpoch !== pending.body.serverEpoch || recovery.state.reconnectTarget?.clientId !== clientId) throw new ApiError('恢复响应不属于当前窗口。', 'STALE_FRAME');
+ state = recovery.state; uncertainOperation = null; ownershipLost = true; automaticBlocked = true; reconnectRequired = true;
+ storageFailure(new ApiError('交接已提交,但目标构建无法恢复。本地快照可导出;请明确连接最新构建,不会自动重放旧快照。', 'RECONNECT_REQUIRED'));
+ return;
+ }
+ const recovered = await api(endpoint('operations/' + pending.body.operationId, pending.body.previewId));
+ if (recovered.status !== 'committed' || !recovered.result) throw new ApiError('恢复尚未完成,页面和快照继续保留。', 'OUTCOME_UNKNOWN');
+ result = await checkedOutcome(recovered, pending.body);
+ } else if (!outcome.committed && !outcome.closed) result = await operationRequest(pending.name, pending.body, pending.userAction);
+ else {
+ uncertainOperation = null; automaticBlocked = true; ownershipLost = true;
+ if (outcome.closed) markClosed();
+ else {
+ try { applyState(await api(endpoint('state'))); }
+ catch (error) { if (error.code === 'PREVIEW_CONTEXT_NOT_FOUND') markClosed(); }
+ }
+ const expired = new ApiError('原数据已提交,但精确响应已过期。请先在聊天中导出快照,再明确接管或关闭;不会自动重放、抢回租约或复活已关闭预览。', 'TRANSITION_OUTCOME_UNKNOWN', { committed: true, operationId: pending.body.operationId });
+ storageFailure(expired);
+ throw expired;
+ }
+ await pending.accept(result); uncertainOperation = null; storageProblem = null; automaticBlocked = false; resumeCurrent(); renderDetails();
+}
+$('refresh-preview').addEventListener('click', refreshPreview);
+function sizeDevice() {
+ const layout = displayedLayout(), device = devices.get(layout.deviceId), landscape = layout.landscape;
+ const screenWidth = landscape ? device.height : device.width, screenHeight = landscape ? device.width : device.height;
+ const width = screenWidth + 16, height = screenHeight + 16;
+ const scale = Math.min(1, Math.max(1, $('device-stage').clientWidth) / width, Math.max(1, $('device-stage').clientHeight) / height);
+ const phone = $('phone-frame'); phone.dataset.legacy = String(device.safeAreaBottom === 0); phone.style.width = width + 'px'; phone.style.height = height + 'px'; phone.style.transform = 'scale(' + scale + ')';
+ phone.dataset.landscape = String(landscape); phone.dataset.game = String(isGame());
+ phone.querySelector('.phone-notch').hidden = !device.hasNotch;
+ const screen = phone.querySelector('.phone-screen'); screen.style.width = screenWidth + 'px'; screen.style.height = screenHeight + 'px';
+ $('device-wrap').style.width = Math.round(width * scale) + 'px'; $('device-wrap').style.height = Math.round(height * scale) + 'px';
+}
+window.addEventListener('beforeunload', (event) => { if (dirty() || retirementIncomplete || uncertainOperation || pendingDocument || busy) { event.preventDefault(); event.returnValue = ''; } });
+document.addEventListener('visibilitychange', () => { if (frameKind === 'minigame' && identity && !ownershipLost && !closed) post({ type: 'visibility', visible: !document.hidden }); });
+window.addEventListener('pagehide', () => { if (frameKind === 'minigame') post({ type: 'dispose', requestId: uid('pagehide') }); clearTimeout(startupTimer); recoveryEvents?.close(); });
+new ResizeObserver(sizeDevice).observe($('device-stage')); sizeDevice();
+schedule(openCurrentPreview).catch((error) => notice(error.message));
diff --git a/plugins/adherez/wechat-miniprogram-preview/miniapp/client/sim-base.css b/plugins/adherez/wechat-miniprogram-preview/miniapp/client/sim-base.css
new file mode 100644
index 00000000..c4d3331f
--- /dev/null
+++ b/plugins/adherez/wechat-miniprogram-preview/miniapp/client/sim-base.css
@@ -0,0 +1,47 @@
+:root{--wx-rpx:calc(100vw / 750);--wx-vh:1vh;--wx-content-height:100vh;--wx-safe-area-inset-top:0px;--wx-safe-area-inset-right:0px;--wx-safe-area-inset-bottom:34px;--wx-safe-area-inset-left:0px;--wx-status-height:47px;--wx-nav-height:44px;--wx-capsule-width:87px;--wx-capsule-height:32px;--wx-capsule-top:51px;--wx-capsule-right:7px;--wx-safe-bottom:34px;font-family:-apple-system,system-ui,'Segoe UI',sans-serif;color:#111;background:#fff;font-size:16px;line-height:1.5;--wx-green:#07c160;--wx-gray:#f7f7f7;--wx-border:rgba(0,0,0,.1)}:where(#wx-status,#wx-nav,#wx-back,#wx-capsule,#wx-capsule *,#wx-tabbar,#wx-tabbar *,#wx-overlay,#wx-overlay *){box-sizing:border-box}html,body{margin:0;width:100%;height:100%;overflow:hidden}body{display:flex;flex-direction:column}button,input,textarea,select{font:inherit}button{cursor:pointer}button:focus-visible,[tabindex]:focus-visible,input:focus-visible,textarea:focus-visible{outline:2px solid var(--wx-green);outline-offset:2px}[hidden]{display:none!important}
+#wx-status{height:var(--wx-status-height);flex-shrink:0;display:flex;align-items:center;justify-content:space-between;padding:0 24px;font-size:15px;font-weight:600;line-height:20px}
+.wx-status-time{font-variant-numeric:tabular-nums}
+.wx-status-icons{display:flex;align-items:center;gap:6px;line-height:1}
+.wx-status-icons svg{display:block;flex-shrink:0}
+.wx-status-overlay{position:absolute;z-index:20;top:0;left:0;width:100%;pointer-events:none}
+#wx-nav{height:var(--wx-nav-height);flex-shrink:0;display:flex;align-items:center;justify-content:center;position:relative}
+#wx-nav.wx-nav-custom{position:absolute;z-index:20;top:var(--wx-status-height);left:0;right:0;background:transparent!important;pointer-events:none}
+#wx-nav.wx-nav-custom #wx-title,#wx-nav.wx-nav-custom #wx-back{visibility:hidden}
+#wx-title{margin:0;font-size:17px;font-weight:600;max-width:calc(100% - 200px);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+#wx-back{position:absolute;left:8px;border:0;border-radius:8px;padding:0;background:transparent;color:inherit;width:40px;height:40px;display:flex;align-items:center;justify-content:center;line-height:1}
+#wx-back:hover{background:color-mix(in srgb,currentColor 6%,transparent)}
+#wx-back:active{background:color-mix(in srgb,currentColor 10%,transparent)}
+#wx-back:focus-visible{outline:2px solid currentColor;outline-offset:-2px}
+#wx-back svg{display:block;flex-shrink:0}
+/* A covered stack entry retains its render tree, scroll offsets and animations. */
+wx-page[data-wx-page-inactive]{visibility:hidden;opacity:0;pointer-events:none}
+#wx-viewport{flex:1;min-height:0;position:relative;transform:translateZ(0);isolation:isolate;overflow:hidden}wx-page{display:block;position:absolute;inset:0;overflow:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch}#wx-tabbar{display:flex;flex-shrink:0;height:calc(49px + var(--wx-safe-bottom));padding:2px 0 calc(2px + var(--wx-safe-bottom));border-top:1px solid var(--wx-border);z-index:10}#wx-tabbar button{position:relative;background:transparent;border:0;padding:4px;flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;font-size:11px;color:inherit}#wx-tabbar img{width:26px;height:26px}.wx-tab-badge{position:absolute;top:0;left:55%;background:#fa5151;color:#fff;border-radius:10px;padding:0 5px;min-width:8px;min-height:8px;font-size:10px}.wx-tabbar-top #wx-tabbar{height:49px;padding:2px 0;order:0;border-top:0;border-bottom:1px solid var(--wx-border)}.wx-tabbar-top #wx-viewport{order:1}.wx-nav-custom #wx-viewport{flex:1}
+wx-view,wx-scroll-view,wx-swiper,wx-swiper-item,wx-rich-text,wx-form,wx-checkbox-group,wx-radio-group,wx-picker-view,wx-picker-view-column,wx-cover-view{display:block}wx-text{display:inline;white-space:pre-wrap}wx-text[selectable]{user-select:text}wx-block{display:contents}wx-image{display:inline-block;width:320px;height:240px;overflow:hidden;vertical-align:middle}wx-image img{display:block;width:100%;height:100%;object-fit:fill}wx-input,wx-textarea{display:block;min-height:28px}wx-input input,wx-textarea textarea{display:block;width:100%;height:100%;min-height:inherit;border:0;background:transparent;color:inherit;font:inherit;outline-offset:2px;padding:0}wx-textarea{width:300px;height:150px}wx-textarea textarea{resize:none}wx-input input::placeholder,wx-textarea textarea::placeholder{color:#999}wx-switch{display:inline-flex;vertical-align:middle}wx-switch input{appearance:none;width:52px;height:32px;background:#ddd;border-radius:18px;position:relative;border:1px solid #ddd;margin:0;cursor:pointer}wx-switch input::after{content:'';position:absolute;left:1px;top:1px;width:28px;height:28px;border-radius:50%;background:#fff;box-shadow:0 1px 3px #0002}wx-switch input:checked{background:var(--wx-green);border-color:var(--wx-green)}wx-switch input:checked::after{left:21px}wx-checkbox,wx-radio{display:inline-flex;align-items:center;gap:8px;vertical-align:middle;cursor:pointer}wx-checkbox input,wx-radio input{position:absolute;opacity:0;width:24px;height:24px}.wx-checkbox-box,.wx-radio-box{width:24px;height:24px;border:1px solid #ccc;border-radius:4px;color:var(--wx-green);display:inline-flex;align-items:center;justify-content:center}.wx-radio-box{border-radius:50%}[data-checked]>.wx-checkbox-box::after,[data-checked]>.wx-radio-box::after{content:'✓';font-size:19px}wx-slider{display:flex;align-items:center;gap:12px;padding:12px 0}wx-slider input{flex:1;min-width:0;accent-color:var(--wx-green)}.wx-slider-value{min-width:28px;font-size:14px}wx-progress{display:flex;align-items:center;gap:12px}.wx-progress-track{flex:1;background:#ebebeb;border-radius:4px;overflow:hidden}.wx-progress-bar{height:6px;background:var(--wx-green)}.wx-progress-info{font-size:14px}wx-icon{display:inline-flex;vertical-align:middle}wx-picker,wx-navigator,wx-label{display:block;cursor:pointer}wx-video{display:block;width:300px;height:225px}wx-video video{width:100%;height:100%}wx-swiper{height:150px;position:relative;overflow:hidden}.wx-swiper-track{display:flex;width:100%;height:100%;transition:transform .3s}.wx-swiper-track>wx-swiper-item{flex:0 0 100%;height:100%;overflow:hidden}.wx-swiper-dots{position:absolute;bottom:8px;left:0;width:100%;display:flex;justify-content:center;gap:6px}.wx-swiper-dots button{border:0;width:6px;height:6px;border-radius:50%;padding:0;background:#0003}.wx-swiper-dots button[aria-current=true]{background:#0009}wx-rich-text img{max-width:100%}.wx-unsupported{min-height:100px;background:#f7f7f7;border:1px dashed #aaa;display:flex;align-items:center;justify-content:center}.wx-unsupported-label{font-size:13px;color:#777}.wx-error-panel{padding:20px;background:#fff4f4;color:#b42332;font:13px/1.6 ui-monospace,monospace;white-space:pre-wrap;overflow-wrap:anywhere}
+#wx-overlay{position:fixed;inset:0;pointer-events:none;z-index:100}#wx-overlay>*{pointer-events:auto}.wx-mask{position:absolute;inset:0;background:#0006;display:flex;align-items:center;justify-content:center}.wx-modal{width:80%;max-width:340px;background:#fff;border-radius:12px;overflow:hidden}.wx-modal-title{text-align:center;font-size:18px;font-weight:600;padding:24px 24px 0}.wx-modal-content{padding:20px 24px;color:#666;text-align:center;font-size:15px;white-space:pre-wrap}.wx-modal-input{display:block;width:calc(100% - 48px);margin:20px 24px;border:0;border-bottom:1px solid #eee;padding:8px}.wx-modal-actions{display:flex;border-top:1px solid #eee}.wx-modal-actions button{flex:1;border:0;background:#fff;padding:16px;font-size:17px;font-weight:500}.wx-modal-actions button+button{border-left:1px solid #eee}.wx-toast{position:absolute;top:45%;left:50%;transform:translate(-50%,-50%);min-width:120px;max-width:80%;padding:24px 16px;background:#000c;color:#fff;text-align:center;border-radius:12px;font-size:15px}.wx-toast-icon{margin-bottom:12px}.wx-toast-text{min-width:0;padding:12px 20px}.wx-spinner{display:inline-block;width:32px;height:32px;border:3px solid #ffffff66;border-top-color:#fff;border-radius:50%;animation:wx-spin 1s linear infinite}.wx-sheet-mask{align-items:flex-end}.wx-sheet{width:100%;background:#ededed;border-radius:12px 12px 0 0;overflow:hidden;padding-bottom:20px}.wx-sheet button{display:block;width:100%;border:0;border-bottom:1px solid #eee;background:#fff;padding:16px;font-size:17px}.wx-sheet-title{background:#fff;color:#888;font-size:13px;text-align:center;padding:16px}.wx-sheet .wx-sheet-cancel{margin-top:8px}.wx-sheet-selected{color:var(--wx-green)}.wx-hidden-native{position:absolute;opacity:0;width:1px;height:1px}.wx-image-preview{position:absolute;inset:0;background:#000;display:flex;align-items:center;justify-content:center}.wx-image-preview img{max-width:100%;max-height:100%;object-fit:contain}.wx-image-preview button{position:absolute;top:44px;right:16px;background:#fff;border:0;border-radius:20px;padding:8px 16px}@keyframes wx-spin{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
+/* Preview SDK defaults have zero specificity, so business WXSS stays authoritative. */
+:where(wx-button){--wx-button-background:#f8f8f8;--wx-button-color:#000;--wx-button-border:transparent;appearance:none;position:relative;display:block;width:auto;margin:0 auto;padding:0 14px;border:0 solid var(--wx-button-border);border-radius:5px;line-height:2.55555556;text-align:center;background:var(--wx-button-background);color:var(--wx-button-color);font-size:18px;font-weight:400;font-family:inherit;cursor:pointer;user-select:none;overflow:hidden;box-sizing:border-box}
+:where(wx-button)::after{content:'';position:absolute;inset:0;width:200%;height:200%;border:1px solid rgba(0,0,0,.2);border-radius:10px;box-sizing:border-box;transform:scale(.5);transform-origin:0 0;pointer-events:none}
+:where(wx-button[type=primary]){--wx-button-background:#1aad19;--wx-button-color:#fff}
+:where(wx-button[type=warn]){--wx-button-background:#e64340;--wx-button-color:#fff}
+:where(wx-button[plain]){--wx-button-background:transparent;--wx-button-border:#353535;--wx-button-color:#353535;border-width:1px}
+:where(wx-button[plain][type=primary]){--wx-button-border:#1aad19;--wx-button-color:#1aad19}
+:where(wx-button[plain][type=warn]){--wx-button-border:#e64340;--wx-button-color:#e64340}
+:where(wx-button[plain])::after{border:0}
+:where(wx-button[disabled]){--wx-button-background:#f7f7f7;--wx-button-color:rgba(0,0,0,.3);cursor:default}
+:where(wx-button[disabled][type=primary]){--wx-button-background:#9ed99d;--wx-button-color:rgba(255,255,255,.6)}
+:where(wx-button[disabled][type=warn]){--wx-button-background:#ec8b89;--wx-button-color:rgba(255,255,255,.6)}
+:where(wx-button[plain][disabled]){--wx-button-background:transparent;--wx-button-color:rgba(0,0,0,.2);--wx-button-border:rgba(0,0,0,.2)}
+:where(wx-button[size=mini]){display:inline-block;width:auto;padding:0 1.34em;font-size:13px;line-height:2.3}
+:where(wx-button[loading])::before{content:'';display:inline-block;width:1em;height:1em;margin-right:.34em;vertical-align:-.15em;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:wx-spin 1s linear infinite}
+:where(body[data-style-v2] wx-button){padding:8px 24px;font-size:17px;font-weight:700;line-height:1.41176471;border-radius:4px}
+:where(body[data-style-v2] wx-button:not([size=mini])){width:184px}
+:where(body[data-style-v2] wx-button)::after{display:none}
+:where(body[data-style-v2] wx-button[type=default]){--wx-button-background:#f2f2f2;--wx-button-color:#06ae56}
+:where(body[data-style-v2] wx-button[type=primary]){--wx-button-background:#07c160;--wx-button-color:#fff}
+:where(body[data-style-v2] wx-button[type=warn]){--wx-button-background:#f2f2f2;--wx-button-color:#fa5151}
+:where(body[data-style-v2] wx-button[disabled]){--wx-button-background:#fafafa;--wx-button-color:rgba(0,0,0,.18)}
+:where(body[data-style-v2] wx-button[plain]){--wx-button-background:transparent;--wx-button-color:#353535;--wx-button-border:#353535}
+:where(body[data-style-v2] wx-button[plain][type=primary]){--wx-button-color:#06ae56;--wx-button-border:#179c16}
+:where(body[data-style-v2] wx-button[plain][type=warn]){--wx-button-color:#e64340;--wx-button-border:#e64340}
+:where(body[data-style-v2] wx-button[plain][disabled]){--wx-button-background:transparent;--wx-button-color:rgba(0,0,0,.2);--wx-button-border:rgba(0,0,0,.2)}
+:where(body[data-style-v2] wx-button[size=mini]){width:auto;padding:0 .75em;font-size:16px;line-height:2}
diff --git a/plugins/adherez/wechat-miniprogram-preview/miniapp/client/sim-runtime.js b/plugins/adherez/wechat-miniprogram-preview/miniapp/client/sim-runtime.js
new file mode 100644
index 00000000..d4baf77f
--- /dev/null
+++ b/plugins/adherez/wechat-miniprogram-preview/miniapp/client/sim-runtime.js
@@ -0,0 +1,2699 @@
+/* WeChat Mini Program H5 approximation runtime.
+ * Executes the build produced by node/compiler.mjs inside a sandboxed iframe.
+ * It reproduces layout and basic interaction only; native capabilities are stubbed and
+ * reported through the console so the shell and the Agent can see them. */
+(function () {
+ 'use strict';
+
+ var BUILD = window.__WX_BUILD__;
+ var FRAME = window.__WX_PREVIEW_FRAME__ || {};
+ var PROJECT_ID = FRAME.projectId || (location.pathname.match(/\/sim\/([0-9a-f]{12})\//) || [])[1] || '';
+ var INSTANCE_ID = FRAME.instanceId || new URLSearchParams(location.search || '').get('instance') || '';
+ var PREVIEW_ID = FRAME.previewId || new URLSearchParams(location.search || '').get('previewId') || '';
+ var viewport = document.getElementById('wx-viewport');
+ var overlay = document.getElementById('wx-overlay');
+ var navEl = document.getElementById('wx-nav');
+ var statusEl = document.getElementById('wx-status');
+ var titleEl = document.getElementById('wx-title');
+ var backEl = document.getElementById('wx-back');
+ var capsuleEl = document.getElementById('wx-capsule');
+ var tabbarEl = document.getElementById('wx-tabbar');
+ var devices = window.__WX_PREVIEW_DEVICES__;
+ var DEVICE = devices.get(devices.defaultId);
+ var viewportMetrics = null;
+ function updateViewportMetrics(entry) {
+ var route = entry ? entry.route : BUILD.entryPage;
+ var config = entry ? entry.window : windowFor(route);
+ var options = { customNavigation: config.navigationStyle === 'custom', tabBarVisible: entry ? !tabbarEl.hidden : isTabRoute(route), tabBarPosition: tabBar && tabBar.position };
+ if (entry && viewport.clientHeight > 0) {
+ options.contentHeight = viewport.clientHeight;
+ var top = 0;
+ for (var element = viewport; element; element = element.offsetParent) top += element.offsetTop || 0;
+ options.contentTop = top;
+ }
+ viewportMetrics = devices.metrics(DEVICE.id, options);
+ Object.keys(viewportMetrics.cssVariables).forEach(function (key) { document.documentElement.style.setProperty(key, viewportMetrics.cssVariables[key]); });
+ return viewportMetrics;
+ }
+ function applyDevice(deviceId) {
+ DEVICE = devices.get(deviceId);
+ var root = document.documentElement.style;
+ root.setProperty('--wx-status-height', DEVICE.statusBarHeight + 'px');
+ root.setProperty('--wx-nav-height', DEVICE.navHeight + 'px');
+ root.setProperty('--wx-capsule-width', DEVICE.capsule.width + 'px');
+ root.setProperty('--wx-capsule-height', DEVICE.capsule.height + 'px');
+ root.setProperty('--wx-capsule-top', DEVICE.capsule.top + 'px');
+ root.setProperty('--wx-capsule-right', DEVICE.capsule.right + 'px');
+ root.setProperty('--wx-safe-bottom', DEVICE.safeAreaBottom + 'px');
+ }
+ applyDevice(DEVICE.id);
+
+ // ------------------------------------------------------------------ bridge + console
+ function post(message) {
+ message.source = 'wx-sim';
+ message.projectId = PROJECT_ID;
+ message.instanceId = INSTANCE_ID;
+ try {
+ window.parent.postMessage(message, '*');
+ } catch (error) {
+ /* standalone */
+ }
+ }
+ var pendingLogs = [];
+ var logTimer = null;
+ var currentRoute = '';
+ function diagnosticState(route) { return { route: route, status: 'rendered', details: [], keys: new Set(), warned: new Set() }; }
+ var appDiagnostics = diagnosticState('');
+ var executingDiagnostics = null;
+ var creatingDiagnostics = null;
+ var previewMounted = false;
+ function diagnosticsFor(owner) {
+ if (owner && owner === appInstance) return appDiagnostics;
+ if (owner && owner.__diagnostics) return owner.__diagnostics;
+ if (owner && owner.__page && owner.__page.__diagnostics) return owner.__page.__diagnostics;
+ return executingDiagnostics || creatingDiagnostics || (pageStack && currentEntry() && currentEntry().diagnostics) || appDiagnostics;
+ }
+ function reportPreviewStatus(status, code, message, owner) {
+ var scope = diagnosticsFor(owner);
+ if (status === 'error' || (status === 'limited' && scope.status !== 'error')) scope.status = status;
+ var key = code + ':' + message;
+ if (message && !scope.keys.has(key) && scope.details.length < 100) {
+ scope.keys.add(key);
+ scope.details.push({ code: String(code || 'runtime').slice(0, 100), message: String(message).slice(0, 2000), route: scope.route });
+ }
+ var visible = pageStack && currentEntry() ? currentEntry().diagnostics : scope;
+ var scopes = visible === appDiagnostics ? [appDiagnostics] : [appDiagnostics, visible];
+ var combined = scopes.some(function (item) { return item.status === 'error'; }) ? 'error' : scopes.some(function (item) { return item.status === 'limited'; }) ? 'limited' : 'rendered';
+ if (previewMounted || combined === 'error') post({ type: 'preview-status', status: combined, details: scopes.reduce(function (all, item) { return all.concat(item.details); }, []) });
+ }
+ function reportRuntimeError(error, code, owner) {
+ reportPreviewStatus('error', code || 'runtime-error', error && error.message ? error.message : String(error), owner);
+ }
+ function formatArg(value) {
+ if (value instanceof Error) return value.stack || value.message;
+ if (typeof value === 'string') return value;
+ if (typeof value === 'undefined') return 'undefined';
+ if (typeof value === 'function') return '[Function ' + (value.name || 'anonymous') + ']';
+ try {
+ var seen = [];
+ return JSON.stringify(value, function (_key, item) {
+ if (item && typeof item === 'object') {
+ if (seen.indexOf(item) >= 0) return '[Circular]';
+ seen.push(item);
+ }
+ return item;
+ });
+ } catch (error) {
+ return String(value);
+ }
+ }
+ function emitLog(level, args, source) {
+ var message = Array.prototype.map.call(args, formatArg).join(' ');
+ pendingLogs.push({ level: level, message: message.slice(0, 4000), source: source || 'console', route: currentRoute });
+ if (!logTimer) {
+ logTimer = setTimeout(function () {
+ logTimer = null;
+ var batch = pendingLogs.splice(0, pendingLogs.length);
+ if (batch.length) post({ type: 'log', entries: batch });
+ }, 120);
+ }
+ }
+ var nativeConsole = {};
+ ['log', 'info', 'warn', 'error', 'debug'].forEach(function (level) {
+ nativeConsole[level] = console[level].bind(console);
+ console[level] = function () {
+ nativeConsole[level].apply(null, arguments);
+ emitLog(level, arguments, 'console');
+ };
+ });
+ window.addEventListener('error', function (event) {
+ emitLog('error', [event.error || event.message], 'runtime');
+ reportRuntimeError(event.error || event.message);
+ });
+ window.addEventListener('unhandledrejection', function (event) {
+ emitLog('error', ['Unhandled promise rejection:', event.reason], 'runtime');
+ reportRuntimeError(event.reason, 'unhandled-rejection');
+ });
+ function warnOnce(key, message) {
+ var scope = diagnosticsFor();
+ if (scope.warned.has(key)) return;
+ scope.warned.add(key);
+ emitLog('warn', ['[H5 预览] ' + message], 'runtime');
+ reportPreviewStatus('limited', key, message);
+ }
+
+ // ------------------------------------------------------------------ utils
+ function clone(value) {
+ if (value === undefined) return undefined;
+ try {
+ return structuredClone(value);
+ } catch (error) {
+ return JSON.parse(JSON.stringify(value));
+ }
+ }
+ function camel(name) {
+ return name.replace(/-([a-z])/g, function (_m, ch) {
+ return ch.toUpperCase();
+ });
+ }
+ function convertRpx(text, ownerFile) {
+ return window.WxComponentRuntime.rewriteCssUrls(window.WxComponentRuntime.convertStyleValues(text), function (url) {
+ if (!url || /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(url)) return url;
+ return resolveAsset(url, ownerFile || currentRoute);
+ });
+ }
+ function parsePath(pathText) {
+ var tokens = [];
+ String(pathText).replace(/[^.[\]]+|\[(\d+)\]/g, function (match, index) {
+ tokens.push(index !== undefined ? Number(index) : match);
+ return match;
+ });
+ return tokens;
+ }
+ function setPath(target, pathText, value) {
+ var tokens = parsePath(pathText);
+ var node = target;
+ for (var i = 0; i < tokens.length - 1; i++) {
+ var key = tokens[i];
+ if (node[key] === null || typeof node[key] !== 'object') node[key] = typeof tokens[i + 1] === 'number' ? [] : {};
+ node = node[key];
+ }
+ node[tokens[tokens.length - 1]] = value;
+ return tokens[0];
+ }
+ function getPath(target, pathText) {
+ var tokens = parsePath(pathText);
+ var node = target;
+ for (var i = 0; i < tokens.length; i++) {
+ if (node === null || node === undefined) return undefined;
+ node = node[tokens[i]];
+ }
+ return node;
+ }
+ function dirOf(id) {
+ var index = id.lastIndexOf('/');
+ return index < 0 ? '' : id.slice(0, index);
+ }
+ function normalize(pathText) {
+ var parts = [];
+ pathText.split('/').forEach(function (part) {
+ if (!part || part === '.') return;
+ if (part === '..') parts.pop();
+ else parts.push(part);
+ });
+ return parts.join('/');
+ }
+ function resolveFrom(fromId, spec) {
+ if (spec.charAt(0) === '/') return normalize(spec);
+ return normalize((dirOf(fromId) ? dirOf(fromId) + '/' : '') + spec);
+ }
+ function resolveAsset(src, ownerId) {
+ if (!src || typeof src !== 'string') return src;
+ if (/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(src)) return src;
+ var suffix = src.match(/[?#].*$/);
+ var local = src.indexOf('asset/') === 0 ? src : 'asset/' + resolveFrom(ownerId || '', src.split(/[?#]/)[0]) + (suffix ? suffix[0] : '');
+ return previewAsset(local);
+ }
+ function previewAsset(src) {
+ if (!src || /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(src)) return src;
+ if (FRAME.assets && src.indexOf('asset/') === 0) {
+ var key = src.slice(6).split(/[?#]/)[0];
+ var dataUrl = Object.prototype.hasOwnProperty.call(FRAME.assets, key) ? FRAME.assets[key] : null;
+ if (typeof dataUrl === 'string' && /^data:/i.test(dataUrl)) return dataUrl;
+ warnOnce('asset:' + key, 'Local preview asset is unavailable: ' + key);
+ return '';
+ }
+ if (!PREVIEW_ID) return src;
+ var hash = src.indexOf('#');
+ var fragment = hash < 0 ? '' : src.slice(hash);
+ var base = hash < 0 ? src : src.slice(0, hash);
+ var at = base.indexOf('?');
+ var query = new URLSearchParams(at < 0 ? '' : base.slice(at + 1));
+ query.set('previewId', PREVIEW_ID);
+ return (at < 0 ? base : base.slice(0, at)) + '?' + query.toString() + fragment;
+ }
+
+ // ------------------------------------------------------------------ modules
+ var moduleCache = {};
+ window.__wxInteropDefault = function (mod) {
+ return mod && mod.__esModule ? mod.default : mod;
+ };
+ window.__wxExportStar = function (target, mod) {
+ Object.keys(mod || {}).forEach(function (key) {
+ if (key !== 'default' && key !== '__esModule') target[key] = mod[key];
+ });
+ };
+ window.getRegExp = function (pattern, flags) {
+ return new RegExp(pattern, flags);
+ };
+ window.getDate = function () {
+ var args = Array.prototype.slice.call(arguments);
+ return args.length ? new (Function.prototype.bind.apply(Date, [null].concat(args)))() : new Date();
+ };
+ function resolveModule(fromId, spec) {
+ var bases = [];
+ if (spec.charAt(0) === '/' || spec.charAt(0) === '.') bases.push(resolveFrom(fromId, spec));
+ else bases.push(resolveFrom(fromId, spec), normalize(spec), 'miniprogram_npm/' + spec);
+ for (var i = 0; i < bases.length; i++) {
+ var base = bases[i];
+ var candidates = [base, base + '.js', base + '.wxs', base + '.json', base + '/index.js'];
+ for (var j = 0; j < candidates.length; j++) {
+ if (Object.prototype.hasOwnProperty.call(BUILD.modules, candidates[j])) return candidates[j];
+ }
+ }
+ return null;
+ }
+ function loadModule(id) {
+ if (moduleCache[id]) return moduleCache[id].exports;
+ var source = BUILD.modules[id];
+ if (source === undefined) throw new Error('module "' + id + '" is not defined');
+ var module = { exports: {}, id: id };
+ moduleCache[id] = module;
+ var factory;
+ try {
+ factory = new Function('require', 'module', 'exports', source + '\n//# sourceURL=wx:///' + id);
+ } catch (error) {
+ delete moduleCache[id];
+ throw new Error(id + ': ' + error.message);
+ }
+ factory.call(module.exports, makeRequire(id), module, module.exports);
+ return module.exports;
+ }
+ function makeRequire(fromId) {
+ var req = function (spec) {
+ var id = resolveModule(fromId, String(spec));
+ if (!id) throw new Error('module "' + spec + '" is not defined (required from ' + fromId + ')');
+ return loadModule(id);
+ };
+ req.async = function (spec) {
+ return Promise.resolve().then(function () {
+ return req(spec);
+ });
+ };
+ return req;
+ }
+ window.require = makeRequire('app.js');
+ window.requirePlugin = function (name) {
+ warnOnce('plugin:' + name, 'requirePlugin("' + name + '") 不支持,插件能力需要在微信开发者工具中验证');
+ return {};
+ };
+
+ // ------------------------------------------------------------------ definitions
+ var appInstance = null;
+ var defining = null;
+ var unitDefs = {};
+ window.App = function (def) {
+ appInstance = def || {};
+ if (!appInstance.globalData) appInstance.globalData = appInstance.globalData || {};
+ };
+ window.getApp = function () {
+ return appInstance || { globalData: {} };
+ };
+ window.Page = function (def) {
+ if (defining) {
+ defining.result = { type: 'page', def: def || {} };
+ getComponentEngine().register(defining.id, def || {}, true);
+ }
+ };
+ window.Component = function (def) {
+ if (defining) {
+ defining.result = { type: 'component', def: def || {} };
+ getComponentEngine().register(defining.id, def || {});
+ }
+ };
+ window.Behavior = function (def) {
+ return getComponentEngine().behavior(def || {});
+ };
+ window.getCurrentPages = function () {
+ return pageStack.map(function (entry) {
+ return entry.instance;
+ });
+ };
+ function loadUnitDef(id) {
+ if (unitDefs[id]) return unitDefs[id];
+ var unit = BUILD.units[id];
+ if (!unit || !unit.js) throw new Error('"' + id + '" has no script');
+ var previousDefinition = defining;
+ defining = { id: id, result: null };
+ try {
+ loadModule(unit.js);
+ } finally {
+ var result = defining.result;
+ defining = previousDefinition;
+ if (result) unitDefs[id] = result;
+ }
+ if (!unitDefs[id]) throw new Error(unit.js + ' did not call ' + (unit.kind === 'page' ? 'Page()' : 'Component()'));
+ return unitDefs[id];
+ }
+
+ // ------------------------------------------------------------------ expressions
+ var exprCache = {};
+ var GLOBAL_PASS = { undefined: 1, NaN: 1, Infinity: 1, Math: 1, JSON: 1, Number: 1, String: 1, parseInt: 1, parseFloat: 1, Date: 1, Array: 1, Object: 1, Boolean: 1, isNaN: 1, encodeURIComponent: 1, decodeURIComponent: 1 };
+ var scopeHandler = {
+ has: function (_target, key) {
+ return typeof key === 'string' && !GLOBAL_PASS[key];
+ },
+ get: function (target, key) {
+ if (key === Symbol.unscopables) return undefined;
+ return target[key];
+ },
+ };
+ function compileExpr(expr) {
+ if (exprCache[expr]) return exprCache[expr];
+ var fn;
+ try {
+ fn = new Function('__s', 'with(__s){return (' + expr + ');}');
+ } catch (error) {
+ warnOnce('expr:' + expr, 'WXML 表达式无法解析:{{' + expr + '}} (' + error.message + ')');
+ fn = function () {
+ return undefined;
+ };
+ }
+ exprCache[expr] = fn;
+ return fn;
+ }
+ function evalExpr(expr, scope) {
+ try {
+ return compileExpr(expr)(new Proxy(scope, scopeHandler));
+ } catch (error) {
+ warnOnce('eval:' + expr + ':' + error.message, 'WXML 表达式求值失败:{{' + expr + '}} — ' + error.message);
+ return undefined;
+ }
+ }
+ function interpolate(value, scope) {
+ if (typeof value !== 'string') return value;
+ if (value.indexOf('{{') < 0) return value;
+ var single = value.match(/^\{\{([\s\S]*?)\}\}$/);
+ if (single && single[1].indexOf('}}') < 0) return evalExpr(single[1], scope);
+ return value.replace(/\{\{([\s\S]*?)\}\}/g, function (_m, expr) {
+ var result = evalExpr(expr, scope);
+ return result === undefined || result === null ? '' : String(result);
+ });
+ }
+ function evalTemplateData(value, scope) {
+ if (typeof value !== 'string') return {};
+ var single = value.match(/^\{\{([\s\S]*)\}\}$/);
+ if (!single) return {};
+ var expr = single[1].trim();
+ if (/^\.\.\.|^[\w$]+\s*:|,/.test(expr)) {
+ var result = evalExpr('({' + expr + '})', scope);
+ return result && typeof result === 'object' ? result : {};
+ }
+ var obj = evalExpr(expr, scope);
+ return obj && typeof obj === 'object' ? obj : {};
+ }
+ function truthy(value) {
+ return !!value;
+ }
+
+ // ------------------------------------------------------------------ template files
+ var fileCtxCache = {};
+ function fileCtx(id) {
+ if (fileCtxCache[id]) return fileCtxCache[id];
+ var template = BUILD.templates[id];
+ var ctx = { id: id, nodes: template ? template.ast : [], templates: {}, wxs: {} };
+ fileCtxCache[id] = ctx;
+ var scan = function (nodes) {
+ nodes.forEach(function (node) {
+ if (node.t !== 'e') return;
+ if (node.n === 'template' && typeof node.a.name === 'string') ctx.templates[node.a.name] = { nodes: node.c, file: ctx };
+ if (node.n === 'wxs' && typeof node.a.module === 'string' && typeof node.a.src === 'string') {
+ try {
+ ctx.wxs[node.a.module] = loadModule(node.a.src);
+ } catch (error) {
+ console.error('WXS module "' + node.a.module + '" failed in ' + id + '.wxml:', error);
+ reportRuntimeError(error, 'wxs-error');
+ }
+ }
+ if (node.n !== 'template') scan(node.c);
+ });
+ };
+ scan(ctx.nodes);
+ ctx.nodes.forEach(function (node) {
+ if (node.t === 'e' && node.n === 'import' && typeof node.a.src === 'string') {
+ var imported = fileCtx(node.a.src);
+ Object.keys(imported.templates).forEach(function (name) {
+ if (imported.templates[name].file === imported && !ctx.templates[name]) ctx.templates[name] = imported.templates[name];
+ });
+ }
+ });
+ return ctx;
+ }
+ function withWxs(base, file) {
+ var names = Object.keys(file.wxs);
+ if (!names.length) return base;
+ var scope = Object.create(base);
+ names.forEach(function (name) {
+ scope[name] = file.wxs[name];
+ });
+ return scope;
+ }
+
+ // ------------------------------------------------------------------ render (AST -> vnodes)
+ var EVENT_ATTR = /^(capture-bind|capture-catch|mut-bind|bind|catch):?(.+)$/;
+ var SKIP_ATTR = /^(wx:|generic:|mark:|slot$|is$|data$)/;
+ var UNSUPPORTED_TAGS = { map: 1, canvas: 1, camera: 1, 'web-view': 1, 'live-player': 1, 'live-pusher': 1, ad: 1, editor: 1, 'open-data': 1, 'official-account': 1, audio: 1, 'voip-room': 1 };
+ var renderDepth = 0;
+
+ function renderNodes(nodes, scope, ctx, out, depth) {
+ var chain = null;
+ for (var i = 0; i < nodes.length; i++) {
+ var node = nodes[i];
+ if (node.t === 'x') {
+ chain = null;
+ var text = interpolate(node.v, scope);
+ if (text !== undefined && text !== null && text !== '') out.push({ text: String(text) });
+ continue;
+ }
+ var a = node.a;
+ if ('wx:for' in a || 'wx:for-items' in a) {
+ chain = null;
+ renderFor(node, scope, ctx, out, depth);
+ continue;
+ }
+ if ('wx:if' in a) {
+ chain = truthy(interpolate(a['wx:if'], scope));
+ if (!chain) continue;
+ } else if ('wx:elif' in a) {
+ if (chain) continue;
+ chain = truthy(interpolate(a['wx:elif'], scope));
+ if (!chain) continue;
+ } else if ('wx:else' in a) {
+ if (chain) {
+ chain = null;
+ continue;
+ }
+ chain = null;
+ } else {
+ chain = null;
+ }
+ renderElement(node, scope, ctx, out, depth, undefined);
+ }
+ }
+
+ function renderFor(node, scope, ctx, out, depth) {
+ var a = node.a;
+ var list = interpolate(a['wx:for'] !== undefined ? a['wx:for'] : a['wx:for-items'], scope);
+ var itemName = typeof a['wx:for-item'] === 'string' ? a['wx:for-item'] : 'item';
+ var indexName = typeof a['wx:for-index'] === 'string' ? a['wx:for-index'] : 'index';
+ var keyName = typeof a['wx:key'] === 'string' ? a['wx:key'] : null;
+ var entries = [];
+ if (Array.isArray(list)) list.forEach(function (item, index) { entries.push([index, item]); });
+ else if (typeof list === 'number') for (var n = 0; n < list; n++) entries.push([n, n]);
+ else if (typeof list === 'string') list.split('').forEach(function (ch, index) { entries.push([index, ch]); });
+ else if (list && typeof list === 'object') Object.keys(list).forEach(function (key) { entries.push([key, list[key]]); });
+ entries.forEach(function (entry) {
+ var itemScope = Object.create(scope);
+ itemScope[itemName] = entry[1];
+ itemScope[indexName] = entry[0];
+ if ('wx:if' in a && !truthy(interpolate(a['wx:if'], itemScope))) return;
+ var key;
+ if (keyName) key = 'k:' + String(keyName === '*this' ? entry[1] : entry[1] && typeof entry[1] === 'object' ? entry[1][keyName] : entry[0]);
+ renderElement(node, itemScope, ctx, out, depth, key);
+ });
+ }
+
+ function collectCommon(node, scope, ctx) {
+ var attrs = {};
+ var ev = [];
+ var dataset = {};
+ var props = {};
+ Object.keys(node.a).forEach(function (name) {
+ var raw = node.a[name];
+ var eventMatch = name.match(EVENT_ATTR);
+ if (eventMatch) {
+ var handler = interpolate(raw, scope);
+ if (typeof handler === 'string' && handler) {
+ ev.push({ type: eventMatch[2].toLowerCase(), handler: handler, catch: eventMatch[1].indexOf('catch') >= 0, capture: eventMatch[1].indexOf('capture-') === 0, mut: eventMatch[1] === 'mut-bind', owner: ctx.owner });
+ }
+ return;
+ }
+ if (SKIP_ATTR.test(name)) return;
+ var value = interpolate(raw, scope);
+ if (name.indexOf('data-') === 0) {
+ dataset[camel(name.slice(5).toLowerCase())] = value;
+ return;
+ }
+ props[name] = value;
+ });
+ return { attrs: attrs, ev: ev, dataset: dataset, props: props };
+ }
+
+ function styleScope(kind, id) {
+ return kind === 'app' ? 'app' : kind + ':' + encodeURIComponent(id);
+ }
+ function acceptedStyleScopes(owner) {
+ if (!owner) return [];
+ var page = owner.__isPage ? owner : owner.__page;
+ var own = owner.__styleToken || styleScope(owner.__isPage ? 'page' : 'component', owner.route || owner.is);
+ var scopes = [own];
+ if (owner.__isPage || owner.__styleIsolation !== 'isolated') {
+ scopes.push('app');
+ if (page) {
+ scopes.push(styleScope('page', page.route));
+ if (page.__sharedStyleScopes) page.__sharedStyleScopes.forEach(function (scope) { scopes.push(scope); });
+ }
+ }
+ return Array.from(new Set(scopes));
+ }
+ function classBindings(value, owner) {
+ var text = value === undefined || value === null || value === false ? '' : String(value);
+ var external = owner && owner.__externalClasses;
+ var bindings = [];
+ text.split(/\s+/).filter(Boolean).forEach(function (name) {
+ if (external && Object.prototype.hasOwnProperty.call(external, name)) {
+ var source = external[name];
+ classBindings(source.value, source.owner).forEach(function (binding) {
+ bindings.push({ name: binding.name, scopes: binding.scopes, external: true });
+ });
+ } else bindings.push({ name: name, scopes: acceptedStyleScopes(owner), external: false });
+ });
+ return bindings;
+ }
+ function applyStyleScopes(el, vnode) {
+ el.setAttribute('data-wx-style-scope', acceptedStyleScopes(vnode.owner).join(' '));
+ var external = [];
+ classBindings(vnode.props['class'], vnode.owner).forEach(function (binding) {
+ if (binding.external) binding.scopes.forEach(function (scope) { external.push(scope + '|' + encodeURIComponent(binding.name)); });
+ });
+ if (external.length) el.setAttribute('data-wx-external', Array.from(new Set(external)).join(' '));
+ else el.removeAttribute('data-wx-external');
+ }
+ function refreshPageStyleScopes(page) {
+ if (!page || !page.__el) return;
+ page.__el.setAttribute('data-wx-style-scope', acceptedStyleScopes(page).join(' '));
+ if (renderDepth || !page.__stylesDirty) return;
+ page.__stylesDirty = false;
+ Array.prototype.forEach.call(page.__el.querySelectorAll('*'), function (el) {
+ if (el.__v && el.__v.props) {
+ // Forwarded external classes may change without a child data update.
+ // Their resolved class and scope must be refreshed together.
+ var classes = classBindings(el.__v.props['class'], el.__v.owner).map(function (binding) { return binding.name; }).join(' ');
+ var extra = el.__adapter && el.__adapter.className ? el.__adapter.className(el.__v) : '';
+ var value = [classes, extra].filter(Boolean).join(' ');
+ if (value) el.setAttribute('class', value);
+ else el.removeAttribute('class');
+ applyStyleScopes(el, el.__v);
+ }
+ if (el.__syncPlaceholder) el.__syncPlaceholder();
+ });
+ }
+ function classValue(value, ctx) {
+ return classBindings(value, ctx.owner).map(function (binding) { return binding.name; }).join(' ');
+ }
+
+ function renderElement(node, scope, ctx, out, depth, key) {
+ if (depth > 200) {
+ warnOnce('depth', '模板嵌套过深,已停止渲染');
+ return;
+ }
+ var tag = node.n;
+ if (tag === 'block') {
+ renderNodes(node.c, scope, ctx, out, depth + 1);
+ return;
+ }
+ if (tag === 'import' || tag === 'wxs') return;
+ if (tag === 'template') {
+ if (typeof node.a.name === 'string') return;
+ var name = interpolate(node.a.is, scope);
+ var def = ctx.file.templates[name];
+ if (!def) {
+ warnOnce('tpl:' + name, ' 未定义');
+ return;
+ }
+ var data = evalTemplateData(node.a.data, scope);
+ var tctx = Object.assign({}, ctx, { file: def.file });
+ renderNodes(def.nodes, withWxs(data, def.file), tctx, out, depth + 1);
+ return;
+ }
+ if (tag === 'include') {
+ var included = fileCtx(node.a.src);
+ var ictx = Object.assign({}, ctx, { file: included });
+ var content = included.nodes.filter(function (child) {
+ return !(child.t === 'e' && (child.n === 'template' || child.n === 'wxs' || child.n === 'import'));
+ });
+ renderNodes(content, withWxs(scope, included), ictx, out, depth + 1);
+ return;
+ }
+ if (tag === 'slot') {
+ out.push({ type: 'slot', slotName: interpolate(node.a.name, scope) || '' });
+ return;
+ }
+ var common = collectCommon(node, scope, ctx);
+ var slot = typeof node.a.slot === 'string' ? interpolate(node.a.slot, scope) : undefined;
+ var compId = ctx.using && ctx.using[tag];
+ if (compId) {
+ var slots = [];
+ renderNodes(node.c, scope, ctx, slots, depth + 1);
+ out.push({
+ type: 'comp:' + compId,
+ comp: compId,
+ tag: tag.indexOf('-') > 0 ? tag : 'wx-c-' + tag,
+ props: common.props,
+ ev: common.ev,
+ dataset: common.dataset,
+ children: slots,
+ key: key,
+ owner: ctx.owner,
+ ownerFile: ctx.file.id,
+ slot: slot,
+ cls: classValue(common.props['class'], ctx),
+ });
+ return;
+ }
+ var children = [];
+ renderNodes(node.c, scope, ctx, children, depth + 1);
+ out.push({
+ type: tag,
+ tag: tag,
+ props: common.props,
+ ev: common.ev,
+ dataset: common.dataset,
+ children: children,
+ key: key,
+ owner: ctx.owner,
+ ownerFile: ctx.file.id,
+ slot: slot,
+ cls: classValue(common.props['class'], ctx),
+ });
+ }
+
+ // ------------------------------------------------------------------ DOM patching
+ var ATTR_NAME = /^[a-zA-Z_][\w-]*$/;
+ var REFLECT_SKIP = { class: 1, style: 1, value: 1, checked: 1 };
+
+ function applyAttributes(el, vnode, extraClass) {
+ var prev = el.__attrs || {};
+ var next = {};
+ Object.keys(vnode.props).forEach(function (name) {
+ if (!ATTR_NAME.test(name) || REFLECT_SKIP[name]) return;
+ var value = vnode.props[name];
+ if (value === false || value === null || value === undefined || value === 'false') return;
+ if (typeof value === 'object') return;
+ next[name] = value === true ? '' : String(value);
+ });
+ Object.keys(prev).forEach(function (name) {
+ if (!(name in next)) el.removeAttribute(name);
+ });
+ Object.keys(next).forEach(function (name) {
+ if (el.getAttribute(name) !== next[name]) el.setAttribute(name, next[name]);
+ });
+ el.__attrs = next;
+ var cls = [vnode.cls, extraClass].filter(Boolean).join(' ');
+ if ((el.getAttribute('class') || '') !== cls) {
+ if (cls) el.setAttribute('class', cls);
+ else el.removeAttribute('class');
+ }
+ var style = vnode.props.style;
+ var styleText = style === undefined || style === null || style === false ? '' : convertRpx(style, vnode.ownerFile);
+ if (el.__styleText !== styleText) {
+ el.setAttribute('style', styleText);
+ el.__styleText = styleText;
+ }
+ if (truthy(vnode.props.hidden) && vnode.props.hidden !== 'false') el.setAttribute('hidden', '');
+ else el.removeAttribute('hidden');
+ el.__wx = { ev: vnode.ev, dataset: vnode.dataset, id: vnode.props.id ? String(vnode.props.id) : '', owner: vnode.owner };
+ applyStyleScopes(el, vnode);
+ }
+
+ // ------------------------------------------------------------------ instances
+ var dirty = new Set();
+ var flushScheduled = false;
+ function scheduleRender(instance, callback) {
+ dirty.add(instance);
+ if (callback) (instance.__callbacks = instance.__callbacks || []).push(callback);
+ if (flushScheduled) return;
+ flushScheduled = true;
+ Promise.resolve().then(flush);
+ }
+ function flush() {
+ flushScheduled = false;
+ var items = Array.from(dirty);
+ dirty.clear();
+ items.forEach(function (item) {
+ if (!item.__alive) return;
+ try {
+ item.__render();
+ } catch (error) {
+ console.error('Render failed for ' + (item.route || item.is) + ':', error);
+ reportRuntimeError(error, 'render-error', item);
+ }
+ });
+ items.forEach(function (item) {
+ var callbacks = item.__callbacks;
+ item.__callbacks = null;
+ if (!item.__alive) return;
+ (callbacks || []).forEach(function (fn) {
+ safeCall(fn, item, []);
+ });
+ });
+ }
+ function safeCall(fn, self, args) {
+ var previousDiagnostics = executingDiagnostics;
+ executingDiagnostics = diagnosticsFor(self);
+ try {
+ return fn.apply(self, args);
+ } catch (error) {
+ console.error(error);
+ reportRuntimeError(error, 'callback-error', self);
+ return undefined;
+ } finally {
+ executingDiagnostics = previousDiagnostics;
+ }
+ }
+ function makeSetData(instance) {
+ return function (patch, callback) {
+ if (!instance.__alive || !patch || typeof patch !== 'object') return;
+ Object.keys(patch).forEach(function (pathText) {
+ var value = patch[pathText];
+ setPath(instance.data, pathText, value === undefined ? undefined : value);
+ });
+ scheduleRender(instance, callback);
+ };
+ }
+ function selectorQueryFor(rootGetter) {
+ return function () {
+ return createSelectorQuery(rootGetter);
+ };
+ }
+ var componentEngine = null;
+ function getComponentEngine() {
+ if (componentEngine) return componentEngine;
+ if (!window.WxComponentRuntime) throw new Error('Component runtime bundle is missing');
+ componentEngine = window.WxComponentRuntime.createRuntime({
+ units: BUILD.units,
+ loadDefinition: loadUnitDef,
+ render: function (id, data, owner) {
+ var unit = BUILD.units[id];
+ var file = fileCtx(id);
+ var result = [];
+ renderNodes(file.nodes, withWxs(data, file), { owner: owner, file: file, using: unit.using }, result, 0);
+ return result;
+ },
+ prepareOwner: function (owner, definition, host, page, serial) {
+ if (definition.synthetic) return;
+ var id = definition.id;
+ var pageComponent = BUILD.units[id].kind === 'page';
+ owner.__page = page;
+ owner.__diagnostics = page.__diagnostics || creatingDiagnostics;
+ (definition.notices || []).forEach(function (message) { reportPreviewStatus('limited', 'component-option', message, owner); });
+ owner.getPageId = function () { return page.__pageId; };
+ owner.animate = function () { reportPreviewStatus('limited', 'component-animate', 'this.animate() is not supported in H5 preview', owner); };
+ owner.clearAnimation = function () { reportPreviewStatus('limited', 'component-clear-animation', 'this.clearAnimation() is not supported in H5 preview', owner); };
+ owner.__host = host;
+ owner.__isPage = pageComponent;
+ owner.__styleIsolation = definition.isolation;
+ if (pageComponent) {
+ owner.route = id;
+ owner.__styleToken = styleScope('page', id);
+ owner.__sharedStyleScopes = new Set();
+ return;
+ }
+ var base = styleScope('component', id);
+ var token = base + ':instance-' + serial;
+ owner.__styleToken = token;
+ var css = BUILD.componentStyles && BUILD.componentStyles[id];
+ if (host) host.setAttribute('data-wx-style-host', token);
+ if (css) {
+ var sheet = document.createElement('style');
+ sheet.textContent = css
+ .split('[data-wx-style-scope~=' + JSON.stringify(base) + ']').join('[data-wx-style-scope~=' + JSON.stringify(token) + ']')
+ .split('[data-wx-external~="' + base + '|').join('[data-wx-external~="' + token + '|')
+ .split('[data-wx-comp=' + JSON.stringify(id) + ']').join('[data-wx-style-host=' + JSON.stringify(token) + ']');
+ document.head.appendChild(sheet);
+ owner.__previewStyle = sheet;
+ }
+ if (definition.isolation === 'shared' && page) {
+ page.__sharedStyleScopes.add(token);
+ page.__stylesDirty = true;
+ }
+ },
+ updateOwner: function (owner, vnode, hasExternalClass) {
+ var external = {};
+ var previous = owner.__externalClasses || {};
+ var changed = false;
+ Object.keys(vnode.props).forEach(function (name) {
+ if (hasExternalClass(name)) {
+ external[name] = { value: vnode.props[name], owner: vnode.owner };
+ if (!previous[name] || previous[name].value !== vnode.props[name] || previous[name].owner !== vnode.owner) changed = true;
+ }
+ });
+ if (Object.keys(previous).length !== Object.keys(external).length) changed = true;
+ owner.__externalClasses = external;
+ return changed;
+ },
+ disposeOwner: function (owner, page) {
+ if (owner.__previewStyle) { owner.__previewStyle.remove(); owner.__previewStyle = null; }
+ if (owner.__styleIsolation === 'shared' && page && page.__sharedStyleScopes) {
+ page.__sharedStyleScopes.delete(owner.__styleToken);
+ page.__stylesDirty = true;
+ refreshPageStyleScopes(page);
+ }
+ },
+ updateHost: function (el, vnode, id) {
+ el.__v = vnode;
+ applyAttributes(el, vnode, '');
+ el.setAttribute('data-wx-comp', id);
+ },
+ createNative: function (vnode) {
+ var adapter = ADAPTERS[vnode.tag] || (UNSUPPORTED_TAGS[vnode.tag] ? UNSUPPORTED_ADAPTER : GENERIC_ADAPTER);
+ var el = adapter.create(vnode);
+ el.__adapter = adapter;
+ return el;
+ },
+ nativeContainer: function (el) { return el.__adapter.container ? el.__adapter.container(el) : el; },
+ updateNative: function (el, vnode, previous, page) {
+ el.__v = vnode;
+ el.__page = page;
+ var adapter = el.__adapter;
+ applyAttributes(el, vnode, adapter.className ? adapter.className(vnode) : '');
+ adapter.update(el, vnode, previous, page);
+ },
+ afterNative: function (el, vnode, previous, page) { if (el.__adapter.after) el.__adapter.after(el, vnode, previous, page); },
+ disposeNative: function (el) { if (el.__dispose) { el.__dispose(); el.__dispose = null; } },
+ afterRender: function (page) { page.__stylesDirty = true; refreshPageStyleScopes(page); },
+ invoke: invokeHandler,
+ reportError: function (error, owner) { reportRuntimeError(error, 'component-error', owner); },
+ reportWarning: function (message) { warnOnce('component:' + message, message); },
+ });
+ return componentEngine;
+ }
+
+ var pageCounter = 0;
+ function createPageInstance(route, query, el) {
+ var loaded = loadUnitDef(route);
+ var def = loaded.def;
+ var instance = { route: route, __route__: route, options: query, __isPage: true, __pageId: ++pageCounter, __el: el, __alive: true, __diagnostics: creatingDiagnostics, __sharedStyleScopes: new Set() };
+ var componentPage = loaded.type === 'component';
+ if (!componentPage) {
+ instance.data = clone(def.data) || {};
+ Object.keys(def).forEach(function (name) {
+ if (name === 'data') return;
+ var value = def[name];
+ instance[name] = typeof value === 'function' ? value.bind(instance) : value;
+ });
+ instance.setData = makeSetData(instance);
+ }
+ var handle = getComponentEngine().createPageRoot({ id: route, container: el, caller: instance, componentPage: componentPage, query: query });
+ if (componentPage) {
+ instance = handle.caller;
+ instance.route = route;
+ instance.__route__ = route;
+ instance.options = query;
+ instance.__isPage = true;
+ instance.__pageId = pageCounter;
+ instance.__el = el;
+ instance.__alive = true;
+ } else {
+ instance.selectComponent = function (selector) { return handle.select(selector, false); };
+ instance.selectAllComponents = function (selector) { return handle.select(selector, true); };
+ instance.createSelectorQuery = selectorQueryFor(function () { return el; });
+ instance.createIntersectionObserver = function () { return stubIntersectionObserver(); };
+ }
+ instance.__handle = handle;
+ instance.getOpenerEventChannel = function () { return { emit: function () {}, on: function () {}, once: function () {}, off: function () {} }; };
+ instance.__render = function () { if (instance.__alive) handle.update(instance.data); };
+ instance.__call = function (name, args) {
+ if (instance.__alive && typeof instance[name] === 'function') return safeCall(instance[name], instance, args || []);
+ return undefined;
+ };
+ return instance;
+ }
+
+ // ------------------------------------------------------------------ events
+ function nodeInfo(el) {
+ var info = el && el.__wx;
+ return { id: info ? info.id : '', dataset: info ? info.dataset : {}, offsetLeft: el ? el.offsetLeft : 0, offsetTop: el ? el.offsetTop : 0 };
+ }
+ function invokeHandler(handler, event) {
+ var owner = handler.owner;
+ var fn = owner && owner[handler.handler];
+ if (typeof fn !== 'function') {
+ warnOnce('handler:' + handler.handler, '事件处理函数 "' + handler.handler + '" 未在 ' + (owner && (owner.route || owner.is) || '当前页面') + ' 中定义');
+ return undefined;
+ }
+ return safeCall(fn, owner, [event]);
+ }
+ function closestWx(el) {
+ while (el && el !== document.body) {
+ if (el.nodeType === 1 && el.__wx) return el;
+ el = el.parentNode;
+ }
+ return null;
+ }
+ function dispatchEventPath(type, startEl, detail, nativeEvent, options) {
+ return getComponentEngine().dispatch(type, startEl, detail, nativeEvent, options || {});
+ }
+ function dispatchBubbling(type, startEl, detail, nativeEvent) {
+ return dispatchEventPath(type, startEl, detail, nativeEvent, { bubbles: true, composed: true, capturePhase: true });
+ }
+ function fireLocal(el, type, detail) {
+ return dispatchEventPath(type, el, detail, null, {});
+ }
+ var longPressTimer = null;
+ var longPressFired = false;
+ var pressStart = null;
+ ['pointerdown', 'pointerup', 'click', 'keydown', 'keyup'].forEach(function (type) {
+ document.addEventListener(type, function (event) {
+ var element = event.target && event.target.closest ? event.target.closest('wx-button') : null;
+ if (!element || !element.__v || !isOn(element.__v.props.disabled)) return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ }, true);
+ });
+ document.addEventListener('pointerdown', function (event) {
+ if (!viewport.contains(event.target)) return;
+ longPressFired = false;
+ pressStart = event;
+ dispatchBubbling('touchstart', event.target, {}, event);
+ clearTimeout(longPressTimer);
+ longPressTimer = setTimeout(function () {
+ longPressFired = true;
+ dispatchBubbling('longpress', event.target, { x: event.clientX, y: event.clientY }, event);
+ dispatchBubbling('longtap', event.target, { x: event.clientX, y: event.clientY }, event);
+ }, 350);
+ }, true);
+ document.addEventListener('pointermove', function (event) {
+ if (!pressStart) return;
+ if (Math.abs(event.clientX - pressStart.clientX) + Math.abs(event.clientY - pressStart.clientY) > 10) clearTimeout(longPressTimer);
+ dispatchBubbling('touchmove', event.target, {}, event);
+ }, true);
+ document.addEventListener('pointerup', function (event) {
+ clearTimeout(longPressTimer);
+ if (pressStart) dispatchBubbling('touchend', event.target, {}, event);
+ pressStart = null;
+ }, true);
+ document.addEventListener('click', function (event) {
+ if (!viewport.contains(event.target)) return;
+ if (longPressFired) {
+ longPressFired = false;
+ var hasLong = closestHandler(event.target, 'longpress');
+ if (hasLong) return;
+ }
+ dispatchBubbling('tap', event.target, { x: event.clientX, y: event.clientY }, event);
+ });
+ function closestHandler(el, type) {
+ while (el && el !== document.body) {
+ if (el.__wx && el.__wx.ev.some(function (h) { return h.type === type; })) return el;
+ el = el.parentNode;
+ }
+ return null;
+ }
+
+ // ------------------------------------------------------------------ built-in components
+ function elementFor(tag) {
+ return document.createElement('wx-' + tag);
+ }
+ var GENERIC_ADAPTER = {
+ create: function (vnode) {
+ if (['view', 'text', 'cover-view', 'swiper-item', 'checkbox-group', 'radio-group'].indexOf(vnode.tag) < 0) {
+ warnOnce('tag:' + vnode.tag, '<' + vnode.tag + '> 仅提供 H5 布局占位,未实现组件行为');
+ }
+ return elementFor(vnode.tag);
+ },
+ update: function () {},
+ };
+ var UNSUPPORTED_ADAPTER = {
+ create: function (vnode) {
+ var el = elementFor(vnode.tag);
+ el.classList.add('wx-unsupported');
+ var label = document.createElement('span');
+ label.className = 'wx-unsupported-label';
+ label.textContent = '<' + vnode.tag + '> 暂不支持 H5 预览';
+ label.__wxInternal = true;
+ el.appendChild(label);
+ warnOnce('tag:' + vnode.tag, '<' + vnode.tag + '> 暂不支持 H5 预览,请在微信开发者工具或真机验证');
+ return el;
+ },
+ update: function () {},
+ className: function () { return 'wx-unsupported'; },
+ container: function () { return null; },
+ };
+ function inner(el, tagName, setup) {
+ if (!el.__inner) {
+ el.__inner = document.createElement(tagName);
+ el.__inner.__wxInternal = true;
+ if (setup) setup(el.__inner);
+ el.appendChild(el.__inner);
+ }
+ return el.__inner;
+ }
+ function isOn(value) {
+ return value === true || value === '' || (value !== undefined && value !== null && value !== false && value !== 'false' && value !== 0);
+ }
+ function setControlValue(control, value, el) {
+ var text = value === undefined || value === null ? '' : String(value);
+ // Unrelated setData (including onShow/timers) must not erase a native draft.
+ if (el.__lastValue !== text) {
+ if (control.value !== text) control.value = text;
+ }
+ el.__lastValue = text;
+ }
+ var placeholderCounter = 0;
+ var placeholderProperties = ['color', 'font-size', 'font-family', 'font-weight', 'font-style', 'letter-spacing', 'word-spacing', 'text-transform', 'text-decoration', 'text-indent', 'line-height', 'opacity'];
+ function configurePlaceholder(el, control, vnode) {
+ if (!el.__placeholder) {
+ var token = 'placeholder-' + (++placeholderCounter);
+ control.setAttribute('data-wx-placeholder', token);
+ var mirror = document.createElement('wx-placeholder');
+ mirror.setAttribute('aria-hidden', 'true');
+ mirror.setAttribute('data-wx-placeholder-mirror', token);
+ mirror.__wxInternal = true;
+ mirror.style.setProperty('display', 'none', 'important');
+ el.appendChild(mirror);
+ var sheet = document.createElement('style');
+ sheet.setAttribute('data-wx-placeholder-sheet', token);
+ document.head.appendChild(sheet);
+ el.__placeholder = { token: token, mirror: mirror, sheet: sheet };
+ el.__syncPlaceholder = function () {
+ var state = el.__placeholder;
+ if (!state || !el.__v) return;
+ var current = el.__v;
+ var placeholderVnode = { props: { class: current.props['placeholder-class'] || '' }, owner: current.owner };
+ state.mirror.__v = placeholderVnode;
+ state.mirror.className = classBindings(placeholderVnode.props.class, current.owner).map(function (binding) { return binding.name; }).join(' ');
+ applyStyleScopes(state.mirror, placeholderVnode);
+ var declarations = document.createElement('span').style;
+ if (el.isConnected) {
+ var computed = getComputedStyle(state.mirror);
+ placeholderProperties.forEach(function (name) {
+ var value = computed.getPropertyValue(name);
+ if (value) declarations.setProperty(name, value);
+ });
+ }
+ var inline = document.createElement('span').style;
+ inline.cssText = convertRpx(current.props['placeholder-style'] || '', current.ownerFile);
+ placeholderProperties.forEach(function (name) {
+ var value = inline.getPropertyValue(name);
+ if (value) declarations.setProperty(name, value, inline.getPropertyPriority(name));
+ });
+ state.sheet.textContent = ':where([data-wx-placeholder-mirror="' + state.token + '"]){color:#999;opacity:1}' +
+ '[data-wx-placeholder="' + state.token + '"]::placeholder{' + declarations.cssText + '}';
+ };
+ var previousDispose = el.__dispose;
+ el.__dispose = function () {
+ if (previousDispose) previousDispose();
+ el.__placeholder.sheet.remove();
+ el.__placeholder.mirror.remove();
+ el.__placeholder = null;
+ el.__syncPlaceholder = null;
+ };
+ }
+ el.__syncPlaceholder();
+ }
+ function inputLikeAdapter(tagName) {
+ return {
+ create: function (vnode) {
+ var el = elementFor(vnode.tag);
+ var control = inner(el, tagName);
+ control.addEventListener('input', function () {
+ var result = fireLocal(el, 'input', { value: control.value, cursor: control.selectionStart, keyCode: 0 });
+ if (typeof result === 'string' || typeof result === 'number') control.value = String(result);
+ else if (result && typeof result === 'object' && 'value' in result) control.value = String(result.value);
+ });
+ control.addEventListener('focus', function () { fireLocal(el, 'focus', { value: control.value, height: 0 }); });
+ control.addEventListener('blur', function () { fireLocal(el, 'blur', { value: control.value }); });
+ control.addEventListener('keydown', function (event) {
+ if (event.key === 'Enter' && (tagName === 'input' || event.metaKey || event.ctrlKey)) fireLocal(el, 'confirm', { value: control.value });
+ });
+ return el;
+ },
+ update: function (el, vnode, prev) {
+ var p = vnode.props;
+ var control = el.__inner;
+ if (tagName === 'input') {
+ var type = p.password !== undefined && isOn(p.password) ? 'password' : p.type === 'number' || p.type === 'digit' ? 'text' : 'text';
+ if (control.type !== type) control.type = type;
+ control.inputMode = p.type === 'number' ? 'numeric' : p.type === 'digit' ? 'decimal' : 'text';
+ }
+ control.placeholder = p.placeholder === undefined || p.placeholder === null ? '' : String(p.placeholder);
+ control.disabled = isOn(p.disabled);
+ var max = p.maxlength === undefined ? 140 : Number(p.maxlength);
+ if (max > 0) control.maxLength = max;
+ else control.removeAttribute('maxlength');
+ configurePlaceholder(el, control, vnode);
+ setControlValue(control, p.value, el);
+ var focus = isOn(p.focus) || (!prev && isOn(p['auto-focus']));
+ if (focus && (!prev || !isOn(prev.props.focus))) setTimeout(function () { if (el.isConnected && !storageQuiescing) control.focus(); }, 0);
+ },
+ container: function () { return null; },
+ };
+ }
+ function checkAdapter(kind) {
+ return {
+ create: function (vnode) {
+ var el = elementFor(vnode.tag);
+ var control = inner(el, 'input', function (input) { input.type = kind; });
+ var box = document.createElement('span');
+ box.className = 'wx-' + kind + '-box';
+ box.__wxInternal = true;
+ el.insertBefore(box, control.nextSibling);
+ el.__box = box;
+ el.addEventListener('click', function (event) {
+ if (event.target === control) return;
+ if (control.disabled) return;
+ if (kind === 'radio') control.checked = true;
+ else control.checked = !control.checked;
+ control.dispatchEvent(new Event('change', { bubbles: true }));
+ });
+ control.addEventListener('change', function () {
+ el.toggleAttribute('data-checked', control.checked);
+ var group = el.parentNode;
+ while (group && group.tagName !== (kind === 'radio' ? 'WX-RADIO-GROUP' : 'WX-CHECKBOX-GROUP')) group = group.parentNode;
+ if (!group) return;
+ if (kind === 'radio') {
+ Array.prototype.forEach.call(group.querySelectorAll('wx-radio'), function (other) {
+ if (other !== el && other.__inner) {
+ other.__inner.checked = false;
+ other.removeAttribute('data-checked');
+ }
+ });
+ fireLocal(group, 'change', { value: el.__v.props.value === undefined ? '' : String(el.__v.props.value) });
+ } else {
+ var values = [];
+ Array.prototype.forEach.call(group.querySelectorAll('wx-checkbox'), function (item) {
+ if (item.__inner && item.__inner.checked) values.push(item.__v.props.value === undefined ? '' : String(item.__v.props.value));
+ });
+ fireLocal(group, 'change', { value: values });
+ }
+ });
+ return el;
+ },
+ update: function (el, vnode, prev) {
+ var control = el.__inner;
+ var checked = isOn(vnode.props.checked);
+ if (!prev || isOn(prev.props.checked) !== checked) control.checked = checked;
+ el.toggleAttribute('data-checked', control.checked);
+ control.disabled = isOn(vnode.props.disabled);
+ if (vnode.props.color) el.__box.style.color = String(vnode.props.color);
+ },
+ container: function (el) { return el; },
+ };
+ }
+
+ var ADAPTERS = {
+ image: {
+ create: function (vnode) {
+ var el = elementFor('image');
+ var img = inner(el, 'img');
+ img.alt = '';
+ img.addEventListener('load', function () {
+ if (el.__v && el.__v.props.mode === 'widthFix') el.style.height = 'auto';
+ fireLocal(el, 'load', { width: img.naturalWidth, height: img.naturalHeight });
+ });
+ img.addEventListener('error', function () { fireLocal(el, 'error', { errMsg: 'GET ' + img.src + ' failed' }); });
+ return el;
+ },
+ update: function (el, vnode) {
+ var img = el.__inner;
+ var src = resolveAsset(vnode.props.src, vnode.ownerFile);
+ if (src && img.getAttribute('src') !== src) img.setAttribute('src', src);
+ if (!src) img.removeAttribute('src');
+ el.setAttribute('data-mode', vnode.props.mode || 'scaleToFill');
+ },
+ container: function () { return null; },
+ },
+ input: inputLikeAdapter('input'),
+ textarea: inputLikeAdapter('textarea'),
+ button: {
+ create: function (vnode) {
+ var el = elementFor('button');
+ el.setAttribute('role', 'button');
+ el.tabIndex = 0;
+ el.addEventListener('click', function () {
+ var p = el.__v.props;
+ if (isOn(p.disabled)) return;
+ var formType = p['form-type'];
+ if (formType === 'submit' || formType === 'reset') {
+ var form = el.parentNode;
+ while (form && form.tagName !== 'WX-FORM') form = form.parentNode;
+ if (form && form.__formAction) form.__formAction(formType);
+ }
+ if (p['open-type']) warnOnce('open-type:' + p['open-type'], '
+
+
+${capsuleMarkup}
+
+
+
+
+
+
+
+`);
+}
+
+function renderSimHtml(result, previewId) {
+ const search = `?previewId=${encodeURIComponent(previewId)}`;
+ // Keep the decorative capsule at body level, like the native preview chrome.
+ return `
+
+