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, '