From 3e54f610e1d43841f2ce7035e8cee2e0eca0b6eb Mon Sep 17 00:00:00 2001 From: mcoder2014 <17683497+mcoder2014@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:25:03 +0800 Subject: [PATCH 1/2] feat: add Ulanzi D200X command executor --- .github/workflows/ulanzi-command-executor.yml | 56 + .gitignore | 1 + AGENTS.md | 19 + README.MD | 10 + .../unlanzi_d200x/command_executor/build.sh | 24 + .../UlanziDeckPlugin-SDK-APACHE-2.0.txt | 201 ++ .../THIRD_PARTY_NOTICES.md | 14 + .../assets/icons/action.svg | 5 + .../assets/icons/plugin.svg | 9 + .../assets/icons/running.svg | 7 + .../assets/icons/success.svg | 5 + .../en.json | 17 + .../libs/assets/u_active.svg | 3 + .../libs/assets/u_active_none.svg | 3 + .../libs/assets/u_check_checkbox.svg | 3 + .../libs/assets/u_check_none.svg | 3 + .../libs/assets/u_check_radio.svg | 4 + .../libs/assets/u_down.svg | 3 + .../libs/assets/u_file.svg | 3 + .../libs/assets/u_folder.svg | 4 + .../libs/assets/u_refresh.svg | 1 + .../libs/assets/u_tip_error.svg | 3 + .../libs/assets/u_tip_info.svg | 3 + .../libs/assets/u_tip_success.svg | 3 + .../libs/assets/u_tip_warn.svg | 3 + .../libs/css/uspi.css | 396 ++++ .../libs/js/constants.js | 46 + .../libs/js/eventEmitter.js | 47 + .../libs/js/timers.js | 87 + .../libs/js/ulanziApi.js | 914 ++++++++++ .../libs/js/utils.js | 567 ++++++ .../manifest.json | 51 + .../package.json | 6 + .../plugin/app.js | 6 + .../plugin/command-plugin.js | 311 ++++ .../plugin/command-runner.js | 231 +++ .../plugin/vendor/ulanzi-api/constants.js | 46 + .../plugin/vendor/ulanzi-api/ulanziApi.js | 872 +++++++++ .../plugin/vendor/ulanzi-api/utils.js | 225 +++ .../property-inspector/inspector.css | 95 + .../property-inspector/inspector.html | 127 ++ .../property-inspector/inspector.js | 86 + .../property-inspector/settings.js | 134 ++ .../zh_CN.json | 17 + .../command_executor/package-lock.json | 1621 +++++++++++++++++ .../command_executor/package.json | 21 + .../scripts/validate-package.mjs | 316 ++++ .../tests/command-plugin.test.mjs | 638 +++++++ .../tests/command-runner.test.mjs | 744 ++++++++ .../tests/fixtures/ulanzi-sdk-sha256.json | 25 + .../tests/inspector-settings.test.mjs | 531 ++++++ .../command_executor/tests/manifest.test.mjs | 172 ++ .../tests/package-validator.test.mjs | 220 +++ .../tests/sdk-vendor.test.mjs | 89 + .../command_executor/webpack.config.js | 29 + .../2026-07-25-command-executor-design.md | 200 ++ ...25-command-executor-implementation-plan.md | 1415 ++++++++++++++ plugins/unlanzi_d200x/docs/README.md | 31 + .../command_executor/installed-plugin.jpg | Bin 0 -> 67248 bytes .../command-executor-development-guide.md | 251 +++ .../docs/command-executor-installation.md | 155 ++ .../docs/command-executor-user-guide.md | 196 ++ .../docs/command-executor-validation.md | 221 +++ .../ulanzi-plugin-development-reference.md | 333 ++++ 64 files changed, 11879 insertions(+) create mode 100644 .github/workflows/ulanzi-command-executor.yml create mode 100755 plugins/unlanzi_d200x/command_executor/build.sh create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/LICENSES/UlanziDeckPlugin-SDK-APACHE-2.0.txt create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/THIRD_PARTY_NOTICES.md create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/assets/icons/action.svg create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/assets/icons/plugin.svg create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/assets/icons/running.svg create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/assets/icons/success.svg create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/en.json create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_active.svg create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_active_none.svg create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_check_checkbox.svg create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_check_none.svg create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_check_radio.svg create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_down.svg create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_file.svg create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_folder.svg create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_refresh.svg create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_tip_error.svg create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_tip_info.svg create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_tip_success.svg create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_tip_warn.svg create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/css/uspi.css create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/js/constants.js create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/js/eventEmitter.js create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/js/timers.js create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/js/ulanziApi.js create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/js/utils.js create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/manifest.json create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/package.json create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/plugin/app.js create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/plugin/command-plugin.js create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/plugin/command-runner.js create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/plugin/vendor/ulanzi-api/constants.js create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/plugin/vendor/ulanzi-api/ulanziApi.js create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/plugin/vendor/ulanzi-api/utils.js create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/property-inspector/inspector.css create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/property-inspector/inspector.html create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/property-inspector/inspector.js create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/property-inspector/settings.js create mode 100644 plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/zh_CN.json create mode 100644 plugins/unlanzi_d200x/command_executor/package-lock.json create mode 100644 plugins/unlanzi_d200x/command_executor/package.json create mode 100644 plugins/unlanzi_d200x/command_executor/scripts/validate-package.mjs create mode 100644 plugins/unlanzi_d200x/command_executor/tests/command-plugin.test.mjs create mode 100644 plugins/unlanzi_d200x/command_executor/tests/command-runner.test.mjs create mode 100644 plugins/unlanzi_d200x/command_executor/tests/fixtures/ulanzi-sdk-sha256.json create mode 100644 plugins/unlanzi_d200x/command_executor/tests/inspector-settings.test.mjs create mode 100644 plugins/unlanzi_d200x/command_executor/tests/manifest.test.mjs create mode 100644 plugins/unlanzi_d200x/command_executor/tests/package-validator.test.mjs create mode 100644 plugins/unlanzi_d200x/command_executor/tests/sdk-vendor.test.mjs create mode 100644 plugins/unlanzi_d200x/command_executor/webpack.config.js create mode 100644 plugins/unlanzi_d200x/docs/2026-07-25-command-executor-design.md create mode 100644 plugins/unlanzi_d200x/docs/2026-07-25-command-executor-implementation-plan.md create mode 100644 plugins/unlanzi_d200x/docs/README.md create mode 100644 plugins/unlanzi_d200x/docs/assets/command_executor/installed-plugin.jpg create mode 100644 plugins/unlanzi_d200x/docs/command-executor-development-guide.md create mode 100644 plugins/unlanzi_d200x/docs/command-executor-installation.md create mode 100644 plugins/unlanzi_d200x/docs/command-executor-user-guide.md create mode 100644 plugins/unlanzi_d200x/docs/command-executor-validation.md create mode 100644 plugins/unlanzi_d200x/docs/ulanzi-plugin-development-reference.md diff --git a/.github/workflows/ulanzi-command-executor.yml b/.github/workflows/ulanzi-command-executor.yml new file mode 100644 index 0000000..50224b0 --- /dev/null +++ b/.github/workflows/ulanzi-command-executor.yml @@ -0,0 +1,56 @@ +name: Ulanzi Command Executor + +on: + pull_request: + paths: + - '.github/workflows/ulanzi-command-executor.yml' + - 'plugins/unlanzi_d200x/**' + - 'README.MD' + - 'AGENTS.md' + push: + branches: + - master + paths: + - '.github/workflows/ulanzi-command-executor.yml' + - 'plugins/unlanzi_d200x/**' + +permissions: + contents: read + +jobs: + test-build: + name: Test and build Ulanzi command executor + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: plugins/unlanzi_d200x/command_executor/package-lock.json + + - name: Install dependencies + run: npm ci + working-directory: plugins/unlanzi_d200x/command_executor + + - name: Run tests + run: npm test + working-directory: plugins/unlanzi_d200x/command_executor + + - name: Check build script syntax + run: bash -n build.sh + working-directory: plugins/unlanzi_d200x/command_executor + + - name: Build plugin package + run: ./build.sh + working-directory: plugins/unlanzi_d200x/command_executor + + - name: Upload dry-run artifact + uses: actions/upload-artifact@v4 + with: + name: life_tools_ulanzi_d200x_command_executor_ci + path: plugins/unlanzi_d200x/command_executor/output/life_tools_ulanzi_d200x_command_executor.zip + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 08d730f..b51ab18 100644 --- a/.gitignore +++ b/.gitignore @@ -109,5 +109,6 @@ gui/**/dist/ gui/**/*.xcodeproj/ gui/**/*.xcworkspace/ plugins/**/dist/ +node_modules/ *.app *.dSYM/ diff --git a/AGENTS.md b/AGENTS.md index 90053ee..04eeebb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,7 @@ - `cli/video_subtitle/`:单视频自动生成中文字幕工具,详细说明见 `docs/cli/video_subtitle.md`。 - `emby_plugins/video_subtitle/`:Emby Server 插件,后端调用 `video_subtitle` 生成字幕,详细说明见 `docs/plugins/emby_video_subtitle.md`。 - `plugins/alfred_remote_upload/`:Alfred 5 Workflow,将剪贴板中的图片或 Finder 单文件上传到 SSH 主机,详细说明见 `docs/plugins/alfred_remote_upload.md`。 +- `plugins/unlanzi_d200x/command_executor/`:Ulanzi D200X 命令执行器,源码、测试和构建入口均在该目录,文档索引见 `plugins/unlanzi_d200x/docs/README.md`。 - `gui/interview_timer/`:macOS 面试悬浮计时 GUI 应用,说明见 `docs/gui/interview_timer.md`。 - `docs/cli/`:CLI 工具文档。 - `docs/plugins/`:插件程序文档。 @@ -79,6 +80,23 @@ plugins/alfred_remote_upload/build.sh plutil -lint plugins/alfred_remote_upload/workflow/info.plist ``` +Ulanzi D200X Command Executor 也不走根目录 `build.sh`。修改该插件时在 `plugins/unlanzi_d200x/command_executor` 目录运行: + +```bash +npm ci +npm test +bash -n build.sh +./build.sh +``` + +## Ulanzi D200X 插件规则 + +- `command_executor` 只支持 macOS 和 D200X 普通按键,不要把 Windows、Encoder 或交互式终端行为混入当前实现。 +- `com.ulanzi.commandexecutor.ulanziPlugin/libs/` 和 `plugin/vendor/ulanzi-api/` 是固定版本的官方 SDK 快照,不得直接修改;插件行为应在自有源码中实现。 +- 修改源码、配置、依赖或构建脚本时,同步核对 `plugins/unlanzi_d200x/docs/`,确保范围、命令和限制与实现一致。 +- 自动化验证不能代替实机验证。涉及 Studio 协议、配置持久化、按键反馈或并发执行时,必须在 Ulanzi Studio 和实体 D200X 上验证并记录环境与结果。 +- 官方 SDK 的诊断日志可能记录完整 WebSocket payload,其中可能包含命令和环境变量值;只允许在受控本机调试,文档、截图和提交内容不得泄露真实密钥或敏感配置。 + ## Go 代码规则 - 保持 Go 1.18 兼容,不随手升级语言版本。 @@ -171,6 +189,7 @@ emby_plugins/video_subtitle/install.sh --help `.github/workflows/release.yml` 负责 tag 发布,不是普通 CI。修改发布流程时要同时关注 Go、Python `video_subtitle` 和 Emby 插件三类产物。 `.github/workflows/swift-mac-app.yml` 负责 `gui/interview_timer` 的 Swift 单测、编译和未签名 `.app` 发布。 `.github/workflows/alfred-workflow.yml` 负责 `plugins/alfred_remote_upload` 的 macOS 离线测试、plist 校验和打包验证。 +`.github/workflows/ulanzi-command-executor.yml` 负责 Ulanzi D200X 命令执行器的 macOS 测试、构建和 PR 安装包上传。 - tag 触发规则保持 `v*`,避免普通分支 push 意外创建 Release;`pull_request` 只能做 dry-run,不能创建 Release。 - Go 测试放在 `.github/workflows/go-test.yml`,PR 时必须真实运行 `go test ./...`,不能做成只提醒不阻塞的 reminder。 diff --git a/README.MD b/README.MD index a359e7d..f0fc18f 100644 --- a/README.MD +++ b/README.MD @@ -59,6 +59,7 @@ | CLI | `video_subtitle` | `cli/video_subtitle/` | `video_subtitle` | 为单个视频生成简体中文字幕 | [docs/cli/video_subtitle.md](docs/cli/video_subtitle.md) | | Experimental CLI | `codex_inspector` | `cli/codex_inspector/` | `codex_inspector` | 本机只读查看 Codex 会话、token 用量、活跃度和记忆内容 | [docs/cli/codex_inspector.md](docs/cli/codex_inspector.md) | | Plugin | Alfred Remote Upload | `plugins/alfred_remote_upload/` | `Remote Upload.alfredworkflow` | 将剪贴板图片或 Finder 单文件上传到 SSH 主机,并复制远端路径 | [docs/plugins/alfred_remote_upload.md](docs/plugins/alfred_remote_upload.md) | +| Plugin | Ulanzi D200X Command Executor | `plugins/unlanzi_d200x/command_executor/` | `.ulanziPlugin` | 每个按键独立配置并执行 macOS Shell 命令 | [插件文档](plugins/unlanzi_d200x/docs/README.md) | | Plugin | Emby 字幕插件 | `emby_plugins/video_subtitle/` | `LifeTools.Emby.VideoSubtitle.Emby.dll` | 在 Emby 后台调用 `video_subtitle` 生成字幕 | [docs/plugins/emby_video_subtitle.md](docs/plugins/emby_video_subtitle.md) | | GUI | InterviewTimer | `gui/interview_timer/` | `InterviewTimer.app` | macOS 面试悬浮计时器 | [docs/gui/interview_timer.md](docs/gui/interview_timer.md) | @@ -150,6 +151,15 @@ plugins/alfred_remote_upload/tests/clipboard_integration.sh plugins/alfred_remote_upload/build.sh ``` +Ulanzi D200X Command Executor 验证与打包: + +```bash +cd plugins/unlanzi_d200x/command_executor +npm ci +npm test +./build.sh +``` + 具体工具的测试和排障入口见各自详细文档。 ## 发布 diff --git a/plugins/unlanzi_d200x/command_executor/build.sh b/plugins/unlanzi_d200x/command_executor/build.sh new file mode 100755 index 0000000..ece3e82 --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/build.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +plugin_name="com.ulanzi.commandexecutor.ulanziPlugin" +source_plugin="$script_dir/$plugin_name" +bundle="$source_plugin/dist/app.js" +output_root="$script_dir/output" +output_plugin="$output_root/$plugin_name" +archive="$output_root/life_tools_ulanzi_d200x_command_executor.zip" + +cd "$script_dir" +rm -f "$bundle" +rm -rf "$output_plugin" +rm -f "$archive" +npm run bundle +mkdir -p "$output_root" +/usr/bin/ditto --norsrc "$source_plugin" "$output_plugin" +node scripts/validate-package.mjs "$output_plugin" +( + cd "$output_root" + /usr/bin/ditto -c -k --norsrc --keepParent "$plugin_name" "$archive" +) +/usr/bin/unzip -t "$archive" diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/LICENSES/UlanziDeckPlugin-SDK-APACHE-2.0.txt b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/LICENSES/UlanziDeckPlugin-SDK-APACHE-2.0.txt new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/LICENSES/UlanziDeckPlugin-SDK-APACHE-2.0.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/THIRD_PARTY_NOTICES.md b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..f4d1c36 --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/THIRD_PARTY_NOTICES.md @@ -0,0 +1,14 @@ +# Third-party notices + +This plugin vendors unmodified files from the official Ulanzi SDK repositories. +The vendored SDK files contain no business modifications. + +| Component | Fixed commit | Vendored source path | License | +| --- | --- | --- | --- | +| `UlanziTechnology/plugin-common-node` | `112bd13a7ff9d45bd68656f7e069fd61851d1812` | `libs/constants.js`, `libs/ulanziApi.js`, `libs/utils.js` | Apache License 2.0 | +| `UlanziTechnology/plugin-common-html` | `79de0b0b087546e684afd23f97223f7a7bc392da` | `assets/`, `css/`, `js/` | Apache License 2.0 | +| `UlanziTechnology/UlanziDeckPlugin-SDK` | `550ab80c69285ecf259bd494a7fff767c14f0c0f` | `LICENSE` | Apache License 2.0 | + +Official SDK diagnostic logs may include commands or environment configuration intended for local debugging. Do not treat those values as this plugin's business configuration. + +The Apache License 2.0 text is included at `LICENSES/UlanziDeckPlugin-SDK-APACHE-2.0.txt`. diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/assets/icons/action.svg b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/assets/icons/action.svg new file mode 100644 index 0000000..aabc8d8 --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/assets/icons/action.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/assets/icons/plugin.svg b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/assets/icons/plugin.svg new file mode 100644 index 0000000..772f9f2 --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/assets/icons/plugin.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/assets/icons/running.svg b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/assets/icons/running.svg new file mode 100644 index 0000000..0a1001c --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/assets/icons/running.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/assets/icons/success.svg b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/assets/icons/success.svg new file mode 100644 index 0000000..7e5188b --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/assets/icons/success.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/en.json b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/en.json new file mode 100644 index 0000000..6bb47f2 --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/en.json @@ -0,0 +1,17 @@ +{ + "Localization": { + "page.title": "Command Executor", + "field.title.label": "Button title", + "field.title.placeholder": "Command", + "field.command.label": "Command (with arguments)", + "field.command.placeholder": "Enter the complete Shell command", + "field.command.hint": "Supports complete Shell syntax, multiple lines, and commands with 100–200 arguments.", + "field.cwd.label": "Working directory", + "field.cwd.placeholder": "Leave empty for $HOME, or use an absolute path or ~/...", + "field.environment.label": "Environment", + "field.environment.placeholder": "One NAME=VALUE entry per line", + "field.environment.hint": "Values are split at the first equals sign; later duplicate names take precedence.", + "preview.title": "Execution preview", + "security.notice": "Security notice: the command runs with your current macOS user permissions. Verify its source before saving it." + } +} diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_active.svg b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_active.svg new file mode 100644 index 0000000..51f2a90 --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_active.svg @@ -0,0 +1,3 @@ + + + diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_active_none.svg b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_active_none.svg new file mode 100644 index 0000000..0b38900 --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_active_none.svg @@ -0,0 +1,3 @@ + + + diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_check_checkbox.svg b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_check_checkbox.svg new file mode 100644 index 0000000..824baba --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_check_checkbox.svg @@ -0,0 +1,3 @@ + + + diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_check_none.svg b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_check_none.svg new file mode 100644 index 0000000..64e610b --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_check_none.svg @@ -0,0 +1,3 @@ + + + diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_check_radio.svg b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_check_radio.svg new file mode 100644 index 0000000..407ab1d --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_check_radio.svg @@ -0,0 +1,4 @@ + + + + diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_down.svg b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_down.svg new file mode 100644 index 0000000..4300974 --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_down.svg @@ -0,0 +1,3 @@ + + + diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_file.svg b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_file.svg new file mode 100644 index 0000000..e01bef3 --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_file.svg @@ -0,0 +1,3 @@ + + + diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_folder.svg b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_folder.svg new file mode 100644 index 0000000..2a3e8b6 --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_folder.svg @@ -0,0 +1,4 @@ + + + + diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_refresh.svg b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_refresh.svg new file mode 100644 index 0000000..8240a01 --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_refresh.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_tip_error.svg b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_tip_error.svg new file mode 100644 index 0000000..9097363 --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_tip_error.svg @@ -0,0 +1,3 @@ + + + diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_tip_info.svg b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_tip_info.svg new file mode 100644 index 0000000..059ca9d --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_tip_info.svg @@ -0,0 +1,3 @@ + + + diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_tip_success.svg b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_tip_success.svg new file mode 100644 index 0000000..a9338e3 --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_tip_success.svg @@ -0,0 +1,3 @@ + + + diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_tip_warn.svg b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_tip_warn.svg new file mode 100644 index 0000000..942b045 --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/assets/u_tip_warn.svg @@ -0,0 +1,3 @@ + + + diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/css/uspi.css b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/css/uspi.css new file mode 100644 index 0000000..2985f61 --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/css/uspi.css @@ -0,0 +1,396 @@ +:root { + --uspi-bodybg: #1e1f22; /* body背景色 */ + --uspi-inputbg: #18191B; /* 输入框背景色 */ + --uspi-textcolor: #fff; /* 文字颜色 */ + --uspi-unitcolor: #A6A6A6; /* 单位字体颜色 */ + --uspi-bordercolor: #3a3a3a; /* 边框颜色 */ + --uspi-borderradius: 4px; /* 边框圆角 */ + --uspi-width: 320px; /* 宽度 */ + --uspi-height: 32px; /* 高度 */ + --uspi-theme: #00FFE6; /* 主题颜色 */ + --uspi-label-width: 112px; /* 标签宽度 */ +} + + +*{ + box-sizing: border-box; +} + +html { + width: 100%; + padding: 0; + margin: 0; + min-height: 100vh; +} + +html,body{ + font-family: 'Source Han Sans SC', system-ui, -apple-system, Segoe UI, Roboto, Arial, "PingFang SC", "Microsoft Yahei", sans-serif; + font-size: 14px; + line-height: 20px; + color: var(--uspi-textcolor); +} + +body { + min-height: 100%; + padding: 0; + margin: 0; +} + +a{ + color: var(--uspi-theme); + cursor: pointer; +} + + +input, +textarea { + color: var(--uspi-textcolor); /* 改变可编辑区域内文字的颜色 */ + caret-color: #4AA3FF; /* 改变可编辑区域光标的颜色 */ +} + +input::placeholder, +textarea::placeholder, +select::placeholder { + color: #65686D; +} +button:focus, +textarea:focus, +input:focus, +select:focus, +option:focus, +details:focus, +summary:focus{ + outline: none; +} + +.uspi-item{ + display: flex; + align-items: center; + margin: 10px; +} +.uspi-item-label{ + width: var(--uspi-label-width); + color: var(--uspi-textcolor); + text-align: right; + margin-right: 10px; + +} +.uspi-item-label:after { + content: ": "; +} +.uspi-item-label.empty:after { + content: ""; +} +.uspi-item-value{ + width: var(--uspi-width); + display: flex; + align-items: center; + justify-content: space-between; +} +.uspi-item-value.no-label{ + width: auto; + margin-left: var(--uspi-label-width); + display: inline-block; +} + +select.uspi-item-value{ + padding: 0 6px; + color: var(--uspi-textcolor); + height: var(--uspi-height); + background-color: var(--uspi-inputbg); + border: 1px solid var(--uspi-inputbg); + border-radius: var(--uspi-borderradius); + line-height: var(--uspi-height); +} + +input.uspi-item-value{ + padding: 0 10px; + height: var(--uspi-height); + background-color: var(--uspi-inputbg); + border: 1px solid var(--uspi-inputbg); + border-radius: var(--uspi-borderradius); +} +input[type="number"]::-webkit-outer-spin-button, +input[type="number"]::-webkit-inner-spin-button{ + -webkit-appearance: none; + margin: 0; +} +textarea.uspi-item-value { + resize: none; + height: 68px; + max-height: 132px; + background-color: var(--uspi-inputbg); + border: 1px solid var(--uspi-inputbg); + border-radius: var(--uspi-borderradius); + padding: 8px; + color: var(--uspi-textcolor); +} +[type="file"] .uspi-item-value{ + /* padding: 0; */ + height: var(--uspi-height); + background-color: var(--uspi-inputbg); + border: 1px solid var(--uspi-inputbg); + border-radius: var(--uspi-borderradius); +} +[type="file"] .uspi-file-info{ + flex: 1; + padding: 0 10px; + color: var(--uspi-textcolor); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + background-color: var(--uspi-inputbg); + border: 1px solid var(--uspi-inputbg); +} +[type="file"] .uspi-file-label{ + display: flex; + justify-content: center; + cursor: pointer; + margin-right: 8px; +} + +input[type="radio"], +input[type="checkbox"]{ + display: none; +} + +input[type="radio"]+label, +input[type="checkbox"]+label{ + padding-left: 24px; + background: url(../assets/u_check_none.svg) no-repeat left center; + color: var(--uspi-textcolor); +} + +input[type="radio"]:checked+label{ + background: url(../assets/u_check_radio.svg) no-repeat left center; +} +input[type="checkbox"]:checked+label { + background: url(../assets/u_check_checkbox.svg) no-repeat left center; +} +input[type="range"]{ + flex: 1; + height: 4px; + background: var(--uspi-inputbg); + border-radius: 8px; + outline: none; +} +input[type="range"]+span{ + text-align: right; + min-width: 25px; + padding-left: 8px; +} + +.uspi-heading { + display: flex; + flex-basis: 100%; + align-items: center; + color: inherit; + font-size: 14px; + margin: 8px 0px; +} + +.uspi-heading::before, +.uspi-heading::after { + content: ""; + flex-grow: 1; + background: var(--uspi-bordercolor); + height: 1px; + font-size: 0px; + line-height: 0px; + margin: 0px 16px; +} + + +button{ + cursor: pointer; + padding: 0 16px; + height: var(--uspi-height); + background: none; + border: 1px solid var(--uspi-theme); + border-radius: 8px; + color: var(--uspi-theme); + width: 124px; +} +button.primary{ + background-color: var(--uspi-theme); + border: 1px solid var(--uspi-theme); + color: var(--uspi-bodybg); +} +button.uspi-item-value{ + display: block; + margin-left: auto; + margin-right: auto; + width: auto; +} + +button.default{ + border: 1px solid #fff; + background-color: #fff; + color: var(--uspi-bodybg); +} + +button.default-border{ + border: 1px solid #fff; + color: #fff; +} + + +button.disabled{ + cursor: not-allowed; + /* opacity: 0.6; */ + border: 1px solid #65686D; + color: #65686D; +} + + +hr{ + margin: 12px 16px; + border-style: none; + background: var(--uspi-bordercolor); + height: 1px; +} + +.tip{ + font-size: 12px; + color: var(--uspi-unitcolor); + margin: 0 10px; + line-height: 16px; + padding-left: 20px; +} +.tip.info{ + background: url(../assets/u_tip_info.svg) no-repeat left center; + background-size: 16px 16px; +} +.tip.success{ + background: url(../assets/u_tip_success.svg) no-repeat left center; + background-size: 16px 16px; +} +.tip.error{ + background: url(../assets/u_tip_error.svg) no-repeat left center; + background-size: 16px 16px; +} +.tip.warn{ + background: url(../assets/u_tip_warn.svg) no-repeat left center; + background-size: 16px 16px; +} + +details{ + color: var(--uspi-unitcolor); +} + +.uspi-label-placeholder{ + margin-left: var(--uspi-label-width); +} +.spinner { + width: 30px; + height: 30px; + border: 4px solid #555; + border-top: 4px solid rgba(255, 255, 255, 1); + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 6px; +} +.loading-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.6); + z-index: 999; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.uspi-select-row { + gap: 6px; +} + +.uspi-select-row select { + flex: 1; + min-width: 0; + padding: 0 6px; + color: var(--uspi-textcolor); + height: var(--uspi-height); + background-color: var(--uspi-inputbg); + border: 1px solid var(--uspi-inputbg); + border-radius: var(--uspi-borderradius); + line-height: var(--uspi-height); +} + +.uspi-refresh-btn { + flex: 0 0 var(--uspi-height); + width: var(--uspi-height); + height: var(--uspi-height); + padding: 0; + margin: 0; + border: 1px solid var(--uspi-inputbg); + border-radius: var(--uspi-borderradius); + background-color: var(--uspi-inputbg); + background-image: url(../assets/u_refresh.svg); + background-repeat: no-repeat; + background-position: center; + background-size: 14px 14px; + cursor: pointer; + opacity: 0.55; + transition: opacity 150ms ease; +} + +.uspi-refresh-btn:hover { + opacity: 1; +} + +.hidden{ + display: none; +} + + /* 滚动条整体样式 */ +::-webkit-scrollbar { + width: 4px; /* 垂直滚动条宽度 */ + height: 4px; /* 水平滚动条高度 */ +} + +/* 滚动条轨道 */ +::-webkit-scrollbar-track { + background: var(--uspi-bodybg); /* 轨道背景色 */ + border-radius: 4px; /* 轨道圆角 */ +} + +/* 滚动条滑块 */ +::-webkit-scrollbar-thumb { + background: #727476; /* 滑块背景色 */ + border-radius: 4px; /* 滑块圆角 */ + transition: background 0.3s; /* 过渡效果 */ +} + +/* 滚动条滑块悬停状态 */ +::-webkit-scrollbar-thumb:hover { + background: #a8a8a8; /* 悬停时的滑块颜色 */ +} + +/* 滚动条滑块激活状态(点击时) */ +::-webkit-scrollbar-thumb:active { + background: #888888; /* 激活时的滑块颜色 */ +} + +/* 滚动条角落(垂直和水平滚动条交汇处) */ +::-webkit-scrollbar-corner { + background: #f1f1f1; /* 角落背景色 */ +} + + + + + + diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/js/constants.js b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/js/constants.js new file mode 100644 index 0000000..f47f010 --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/js/constants.js @@ -0,0 +1,46 @@ + + +/** + * Events used for communicating with Ulanzi Stream Deck + */ +const Events = Object.freeze({ + CONNECTED: 'connected', + CLOSE: 'close', + ERROR: 'error', + ADD: 'add', + RUN: 'run', + PARAMFROMAPP: 'paramfromapp', + PARAMFROMPLUGIN: 'paramfromplugin', + SETACTIVE: 'setactive', + CLEAR: 'clear', + TOAST:'toast', + STATE:'state', + OPENURL:'openurl', + OPENVIEW:'openview', + SELECTDIALOG:'selectdialog', + LOGMESSAGE:'logMessage', + HOTKEY:'hotkey', + SHOWALERT:'showAlert', + SENDTOPROPERTYINSPECTOR:'sendToPropertyInspector', + SENDTOPLUGIN:'sendToPlugin', + GETSETTINGS:'getSettings', + SETSETTINGS:'setSettings', + DIDRECEIVESETTINGS:'didReceiveSettings', + SETGLOBALSETTINGS:'setGlobalSettings', + GETGLOBALSETTINGS:'getGlobalSettings', + DIDRECEIVEGLOBALSETTINGS:'didReceiveGlobalSettings', + KEYDOWN:'keydown', + KEYUP:'keyup', + DIALEDOWN:'dialdown', + DIALEUP:'dialup', + DIALROTATE:'dialrotate' +}); + +/** + * Errors received from WebSocket + */ +const SocketErrors = { + DEFAULT:'closed *****' +}; + + diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/js/eventEmitter.js b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/js/eventEmitter.js new file mode 100644 index 0000000..0e807e4 --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/js/eventEmitter.js @@ -0,0 +1,47 @@ +class ULANZIEventEmitter { + constructor (id, debug = false) { + + const eventList = new Map(); + const ALLEVENTS = "*"; + + eventList.hasWildcard = function(name, data) { + for(const [key, value] of this) { + if(key !== ALLEVENTS && key.includes(ALLEVENTS) && new RegExp(`^${key.split(/\*+/).join('.*')}$`).test(name)) { + if(data) value.pub(data, name); + else return true; + } + } + }; + + this.on = (name, fn) => { + if(!eventList.has(name)) eventList.set(name, ULANZIEventEmitter.pubSub()); + return eventList.get(name).sub(fn); + }; + + this.has = name => eventList.has(name); + this.hasMatch = name => eventList.has(name) || eventList.hasWildcard(name); + this.emit = (name, data) => { + eventList.has(name) && eventList.get(name).pub(data, name); + eventList.has(ALLEVENTS) && eventList.get(ALLEVENTS).pub(data, name); + eventList.hasWildcard(name, data); + }; + + return this; + } + + static pubSub() { + const subscribers = new Set(); + + const sub = fn => { + subscribers.add(fn); + return () => { + subscribers.delete(fn); + }; + }; + + const pub = (data, name) => subscribers.forEach(fn => fn(data, name)); + return Object.freeze({pub, sub}); + } +} + +const EventEmitter = new ULANZIEventEmitter(); \ No newline at end of file diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/js/timers.js b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/js/timers.js new file mode 100644 index 0000000..8d10cf6 --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/js/timers.js @@ -0,0 +1,87 @@ +/* global USDTimerWorker */ + +let USDTimerWorker = new Worker(URL.createObjectURL( + new Blob([timerFn.toString().replace(/^[^{]*{\s*/, '').replace(/\s*}[^}]*$/, '')], {type: 'text/javascript'}) +)); +USDTimerWorker.timerId = 1; +USDTimerWorker.timers = {}; +const USDDefaultTimeouts = { + timeout: 0, + interval: 10 +}; + +Object.freeze(USDDefaultTimeouts); + +function _setTimer(callback, delay, type, params) { + const id = USDTimerWorker.timerId++; + USDTimerWorker.timers[id] = {callback, params}; + USDTimerWorker.onmessage = (e) => { + if(USDTimerWorker.timers[e.data.id]) { + if(e.data.type === 'clearTimer') { + delete USDTimerWorker.timers[e.data.id]; + } else { + const cb = USDTimerWorker.timers[e.data.id].callback; + if(cb && typeof cb === 'function') cb(...USDTimerWorker.timers[e.data.id].params); + } + } + }; + USDTimerWorker.postMessage({type, id, delay}); + return id; +} + +function _setTimeoutUSD(...args) { + let [callback, delay = 0, ...params] = [...args]; + return _setTimer(callback, delay, 'setTimeout', params); +} + +function _setIntervalUSD(...args) { + let [callback, delay = 0, ...params] = [...args]; + return _setTimer(callback, delay, 'setInterval', params); +} + +function _clearTimeoutUSD(id) { + USDTimerWorker.postMessage({type: 'clearTimeout', id}); // USDTimerWorker.postMessage({type: 'clearInterval', id}); = same thing + delete USDTimerWorker.timers[id]; +} + +window.setTimeout = _setTimeoutUSD; +window.setInterval = _setIntervalUSD; +window.clearTimeout = _clearTimeoutUSD; //timeout and interval share the same timer-pool +window.clearInterval = _clearTimeoutUSD; + + + +function timerFn() { + + let timers = {}; + let debug = false; + let supportedCommands = ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval']; + + function log(e) {console.log('Worker-Info::Timers', timers);} + + function clearTimerAndRemove(id) { + if(timers[id]) { + if(debug) console.log('clearTimerAndRemove', id, timers[id], timers); + clearTimeout(timers[id]); + delete timers[id]; + postMessage({type: 'clearTimer', id: id}); + if(debug) log(); + } + } + + onmessage = function(e) { + // first see, if we have a timer with this id and remove it + // this automatically fulfils clearTimeout and clearInterval + supportedCommands.includes(e.data.type) && timers[e.data.id] && clearTimerAndRemove(e.data.id); + if(e.data.type === 'setTimeout') { + timers[e.data.id] = setTimeout(() => { + postMessage({id: e.data.id}); + clearTimerAndRemove(e.data.id); //cleaning up + }, Math.max(e.data.delay || 0)); + } else if(e.data.type === 'setInterval') { + timers[e.data.id] = setInterval(() => { + postMessage({id: e.data.id}); + }, Math.max(e.data.delay || USDDefaultTimeouts.interval)); + } + }; +} diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/js/ulanziApi.js b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/js/ulanziApi.js new file mode 100644 index 0000000..380f3e2 --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/js/ulanziApi.js @@ -0,0 +1,914 @@ +/// +/// + +class UlanziStreamDeck { + constructor() { + this.key = ""; + this.uuid = ""; + this.actionid = ""; + this.websocket = null; + this.language = "en"; + this.localization = null; + this.on = EventEmitter.on; + this.emit = EventEmitter.emit; + this.isMain = false; + } + + connect(uuid) { + // console.warn('===---connect:', window.location.search) + this.port = Utils.getQueryParams("port") || 3906; + this.address = Utils.getQueryParams("address") || "127.0.0.1"; + this.actionid = Utils.getQueryParams("actionid") || ""; + this.key = Utils.getQueryParams("key") || ""; + this.language = + Utils.getQueryParams("language") || Utils.getLanguage() || "en"; + this.language = Utils.adaptLanguage(this.language); + this.uuid = Utils.getQueryParams("uuid") || uuid; + this.controller = Utils.getQueryParams("controller") || "Keypad"; //Keypad 按键 ,Encoder 旋钮 + this.device = Utils.getQueryParams("device") || ""; + + this.mode = Utils.getQueryParams("mode") || ""; + if (this.mode == "simulate") { + document.documentElement.style.backgroundColor = "#1E1F22"; + document.body.style.backgroundColor = "#1E1F22"; + } + + if (this.websocket) { + this.websocket.close(); + this.websocket = null; + } + + //判断是否为主服务,约定主服务 uuid 为4位,action应大于4位 + const isMain = this.uuid.split(".").length == 4; + this.isMain = isMain; + + Utils.log( + `[ULANZIDECK] ${this.isMain ? "MAIN" : "CLIENT"} WEBSOCKET CONNECT:${ + this.uuid + }` + ); + this.websocket = new WebSocket(`ws://${this.address}:${this.port}`); + + this.websocket.onopen = () => { + Utils.log( + `[ULANZIDECK] ${this.isMain ? "MAIN" : "CLIENT"} WEBSOCKET OPEN:${ + this.uuid + }` + ); + const json = { + code: 0, + cmd: Events.CONNECTED, + actionid: this.actionid, + key: this.key, + uuid: this.uuid, + }; + + this.websocket.send(JSON.stringify(json)); + + this.emit(Events.CONNECTED, {}); + + //如果是主服务,则不进行本地化 + if (!isMain) { + this.localizeUI(); + } + }; + + this.websocket.onerror = (evt) => { + const error = `[ULANZIDECK] ${ + this.isMain ? "MAIN" : "CLIENT" + } WEBSOCKET ERROR: ${evt}, ${evt.data}, ${SocketErrors["DEFAULT"]}`; + Utils.warn(error); + this.emit(Events.ERROR, error); + }; + + this.websocket.onclose = (evt) => { + Utils.warn( + `[ULANZIDECK] ${this.isMain ? "MAIN" : "CLIENT"} WEBSOCKET CLOSED:${ + SocketErrors["DEFAULT"] + }` + ); + this.emit(Events.CLOSE); + }; + + this.websocket.onmessage = (evt) => { + Utils.log( + `[ULANZIDECK] ${this.isMain ? "MAIN" : "CLIENT"} WEBSOCKET MESSGE ` + ); + + const data = evt && evt.data ? JSON.parse(evt.data) : null; + + Utils.log( + `[ULANZIDECK] ${ + this.isMain ? "MAIN" : "CLIENT" + } WEBSOCKET MESSGE DATA:${JSON.stringify(data)}` + ); + + //没有数据或者有data.code属性,且cmdType不等于REQUEST,则返回 + if ( + !data || + (typeof data.code !== "undefined" && data.cmdType !== "REQUEST") + ) + return; + + Utils.log( + `[ULANZIDECK] ${this.isMain ? "MAIN" : "CLIENT"} WEBSOCKET MESSGE IN` + ); + + //没有key时,保存key + if (!this.key && data.uuid == this.uuid && data.key) { + this.key = data.key; + } + //没有actionid时,保存actionid + if (!this.actionid && data.uuid == this.uuid && data.actionid) { + this.actionid = data.actionid; + } + + if (isMain) { + //主服务回应上位机 + this.send(data.cmd, { + code: 0, + ...data, + }); + } + + //特殊处理clear,因为clear事件变量是数组形式 + if (data.cmd == "clear") { + if (data.param) { + for (let i = 0; i < data.param.length; i++) { + const context = this.encodeContext(data.param[i]); + data.param[i].context = context; + } + } + } else { + //拼接唯一id给功能页 + const context = this.encodeContext(data); + data.context = context; + } + + //引发事件 + this.emit(data.cmd, data); + }; + } + + /** + * 本地化 + */ + async localizeUI() { + const el = document.querySelector(".uspi-wrapper") || document.querySelector(".udpi-wrapper"); + if (!el) return Utils.warn("No element found to localize"); + + // this.language = Utils.getLanguage() || 'en'; + if (!this.localization) { + try { + const localJson = await Utils.readJson( + `${Utils.getPluginPath()}/${this.language}.json` + ); + this.localization = localJson["Localization"] + ? localJson["Localization"] + : null; + } catch (e) { + Utils.log(`${Utils.getPluginPath()}/${this.language}.json`); + Utils.warn(`No FILE found to localize: ${this.language}`); + } + } + if (!this.localization) return; + + const selectorsList = "[data-localize]"; + el.querySelectorAll(selectorsList).forEach((e) => { + const s = e.innerText.trim(); + let dl = e.dataset.localize; + + if (e.placeholder && e.placeholder.length) { + // console.log('e.placeholder:',e.placeholder) + e.placeholder = + this.localization[dl ? dl : e.placeholder] || e.placeholder; + } + if (e.title && e.title.length) { + // console.log('e.title:',e.title) + e.title = this.localization[dl ? dl : e.title] || e.title; + } + if (e.label) { + // console.log('e.label:',e.label) + e.label = this.localization[dl ? dl : e.label] || e.label; + } + if (e.textContent) { + // console.log('e.textContent:',e.textContent) + e.textContent = + this.localization[dl ? dl : e.textContent] || e.textContent; + } + + if (s) { + // console.log('s:',s) + e.innerHTML = this.localization[dl ? dl : s] || e.innerHTML; + } + }); + } + + t(key) { + return (this.localization && this.localization[key]) || key; + } + + /** + * 创建唯一值 + */ + encodeContext(jsn) { + return jsn.uuid + "___" + jsn.key + "___" + jsn.actionid; + } + + /** + * 解构唯一值 + */ + decodeContext(context) { + const de_ctx = context.split("___"); + return { + uuid: de_ctx[0], + key: de_ctx[1], + actionid: de_ctx[2], + }; + } + + /** + * Send JSON params to StreamDeck + * @param {string} cmd + * @param {object} params + */ + send(cmd, params) { + // console.warn('===--send:', JSON.stringify({ + // cmd, + // uuid: this.uuid, + // key: this.key, + // actionid: this.actionid, + // ...params, + // })) + this.websocket && + this.websocket.send( + JSON.stringify({ + cmd, + uuid: this.uuid, + key: this.key, + actionid: this.actionid, + ...params, + }) + ); + } + + /** + * 向上位机发送配置参数 + * @param {object} settings 必传 | 配置参数 + * @param {object} context 可选 | 唯一id。非必传,由action页面发出时可以不传,由主服务发出必传 + */ + sendParamFromPlugin(settings, context) { + const { uuid, key, actionid } = context ? this.decodeContext(context) : {}; + this.send(Events.PARAMFROMPLUGIN, { + uuid: uuid || this.uuid, + key: key || this.key, + actionid: actionid || this.actionid, + param: settings, + }); + } + + /** + * 请求上位机使⽤浏览器打开url + * @param {string} url 必传 | 直接远程地址和本地地址,⽀持打开插件根⽬录下的url链接(以/ ./ 起始的链接)。 + * 只能是基本路径,不能带参数,需要带参数请设置在param值里面 + * @param {local} boolean 可选 | 若为本地地址为true + * @param {object} param 可选 | 路径的参数值 + */ + openUrl(url, local, param) { + this.send(Events.OPENURL, { + url, + local: local ? true : false, + param: param ? param : null, + }); + } + + /** + * 请求上位机机显⽰弹窗;弹窗后,test.html需要主动关闭,测试到window.close()可以通知弹窗关闭 + * @param {string} url 必传 | 本地html路径,只能是基本路径,不能带参数,需要带参数请设置在param值里面 + * @param {string} width 可选 | 窗口宽度,默认200 + * @param {string} height 可选 | 窗口高度,默认200 + * @param {string} x 可选 | 窗口x坐标,不传值默认居中 + * @param {string} y 可选 | 窗口y坐标,不传值默认居中 + * @param {object} param 可选 | 路径的参数值 + */ + openView(url, width = 200, height = 200, x, y, param) { + const params = { + url, + width, + height, + }; + if (x) { + params.x = x; + } + if (y) { + params.y = y; + } + if (param) { + params.param = param; + } + this.send(Events.OPENVIEW, params); + } + + /** + * 请求上位机弹出Toast消息提⽰ + * @param {string} msg 必传 | 窗口级消息提示 + */ + toast(msg) { + this.send(Events.TOAST, { + msg, + }); + } + + /** + * 请求上位机弹出快捷键 + * @param {string} key 必传 | 快捷键 + */ + hotkey(key) { + this.send(Events.HOTKEY, { + keylist: key, + }); + } + + /** + * 请求上位机弹出日志消息提⽰ + * @param {string} msg 必传 | 保存到插件UUID.txt中 + * @param {string} level 可选 | 日志级别 info|debug|warn|error + */ + logMessage(msg, level) { + this.send(Events.LOGMESSAGE, { + message: msg, + level: level || "info", + }); + } + /** + * 主服务发出,上位机透传参数到action页面,此透传参数上位机不保存 + * @param {object} settings 必传 | 设置 + * @param {string} context 必传 | 唯一id,需要指定发送到哪个action + */ + sendToPropertyInspector(settings, context) { + const { uuid, key, actionid } = context ? this.decodeContext(context) : {}; + this.send(Events.SENDTOPROPERTYINSPECTOR, { + uuid: uuid, + key: key, + actionid: actionid, + payload: settings, + }); + } + + /** + * action页面发出,上位机透传参数到主服务,此透传参数上位机不保存 + * @param {object} settings 必传 | 设置 + */ + sendToPlugin(settings) { + this.send(Events.SENDTOPLUGIN, { + uuid: this.uuid, + key: this.key, + actionid: this.actionid, + payload: settings, + }); + } + + /** + * 请求上位机在按键上显示错误提示 + * @param {string} context 可选 | 唯一id。非必传,由action页面发出时可以不传,由主服务发出必传 + */ + showAlert(context) { + const { uuid, key, actionid } = context ? this.decodeContext(context) : {}; + this.send(Events.SHOWALERT, { + uuid: uuid || this.uuid, + key: key || this.key, + actionid: actionid || this.actionid, + }); + } + + /** + * 请求上位机发送已保存的参数,上位机接收后会触发didReceiveSettings事件转发至另一端 + * @param {string} context 可选 | 唯一id。非必传,由action页面发出时可以不传,由主服务发出必传 + */ + getSettings(context) { + const { uuid, key, actionid } = context ? this.decodeContext(context) : {}; + this.send(Events.GETSETTINGS, { + uuid: uuid || this.uuid, + key: key || this.key, + actionid: actionid || this.actionid, + }); + } + + /** + * 主动向上位机保存参数,上位机接收后会触发didReceiveSettings事件转发至另一端 + * @param {object} settings 必传 | 配置参数 + * @param {string} context 可选 | 唯一id。非必传,由action页面发出时可以不传,由主服务发出必传 + */ + setSettings(settings, context) { + console.warn('===---setSettings:', JSON.stringify(settings), context) + const { uuid, key, actionid } = context ? this.decodeContext(context) : {}; + this.send(Events.SETSETTINGS, { + uuid: uuid || this.uuid, + key: key || this.key, + actionid: actionid || this.actionid, + settings, + }); + } + + + /** + * 请求上位机发送已保存的全局参数,上位机接收后会触发didReceiveGlobalSettings事件转发至另一端 + * @param {string} context 可选 | 唯一id。非必传,由action页面发出时可以不传,由主服务发出必传 + */ + getGlobalSettings(context) { + const { uuid, key, actionid } = context ? this.decodeContext(context) : {}; + this.send(Events.GETGLOBALSETTINGS, { + uuid: uuid || this.uuid, + key: key || this.key, + actionid: actionid || this.actionid, + }); + } + + /** + * 主动向上位机保存参数,上位机接收后会触发didReceiveGlobalSettings事件转发至另一端 + * @param {object} settings 必传 | 配置参数 + * @param {string} context 可选 | 唯一id。非必传,由action页面发出时可以不传,由主服务发出必传 + */ + setGlobalSettings(settings, context) { + const { uuid, key, actionid } = context ? this.decodeContext(context) : {}; + this.send(Events.SETGLOBALSETTINGS, { + uuid: uuid || this.uuid, + key: key || this.key, + actionid: actionid || this.actionid, + settings, + }); + } + + /** + * 请求上位机弹出选择对话框:选择文件 + * @param {string} filter 可选 | 文件过滤器。筛选文件的类型,例如 "filter": "image(*.jpg *.png *.gif)" 或者 筛选文件 file(*.txt *.json) 等 + * 该请求的选择结果请通过 onSelectdialog 事件接收 + */ + selectFileDialog(filter) { + this.send(Events.SELECTDIALOG, { + type: "file", + filter, + }); + } + + /** + * 请求上位机弹出选择对话框:选择文件夹 + * 该请求的选择结果请通过 onSelectdialog 事件接收 + */ + selectFolderDialog() { + this.send(Events.SELECTDIALOG, { + type: "folder", + }); + } + + /** + * 设置图标-使⽤配置⾥的图标列表编号,请对照manifest.json + * @param {string} context 必传 |唯一id,每个message里面common库会自动拼接给出 + * @param {number} state 必传 | 图标列表编号, + * @param {string} text 可选 | icon是否显示文字 + */ + setStateIcon(context, state, text) { + const { uuid, key, actionid } = this.decodeContext(context); + this.send(Events.STATE, { + param: { + statelist: [ + { + uuid, + key, + actionid, + type: 0, + state, + textData: text || "", + showtext: text ? true : false, + }, + ], + }, + }); + } + + /** + * 设置图标-使⽤⾃定义图标 + * @param {string} context 必传 |唯一id,每个message里面common库会自动拼接给出 + * @param {string} data 必传 | base64格式的icon + * @param {string} text 可选 | icon是否显示文字 + */ + setBaseDataIcon(context, data, text) { + const { uuid, key, actionid } = this.decodeContext(context); + this.send(Events.STATE, { + param: { + statelist: [ + { + uuid, + key, + actionid, + type: 1, + data, + textData: text || "", + showtext: text ? true : false, + }, + ], + }, + }); + } + + /** + * 设置图标-使⽤本地图片文件 + * @param {string} context 必传 |唯一id,每个message里面common库会自动拼接给出 + * @param {string} path 必传 | 本地图片路径,⽀持打开插件根⽬录下的url链接(以/ ./ 起始的链接) + * @param {string} text 可选 | icon是否显示文字 + */ + setPathIcon(context, path, text) { + const { uuid, key, actionid } = this.decodeContext(context); + this.send(Events.STATE, { + param: { + statelist: [ + { + uuid, + key, + actionid, + type: 2, + path, + textData: text || "", + showtext: text ? true : false, + }, + ], + }, + }); + } + + /** + * 设置图标-使⽤⾃定义的动图 + * @param {string} context 必传 |唯一id,每个message里面common库会自动拼接给出 + * @param {string} gifdata 必传 | ⾃定义gif的base64编码数据 + * @param {string} text 可选 | icon是否显示文字 + */ + setGifDataIcon(context, gifdata, text) { + const { uuid, key, actionid } = this.decodeContext(context); + this.send(Events.STATE, { + param: { + statelist: [ + { + uuid, + key, + actionid, + type: 3, + gifdata, + textData: text || "", + showtext: text ? true : false, + }, + ], + }, + }); + } + + /** + * 设置图标-使⽤本地gif⽂件 + * @param {string} context 必传 |唯一id,每个message里面common库会自动拼接给出, + * @param {string} gifdata 必传 | 本地gif图片路径,⽀持打开插件根⽬录下的url链接(以/ ./ 起始的链接) + * @param {string} text 可选 | icon是否显示文字 + */ + setGifPathIcon(context, gifpath, text) { + const { uuid, key, actionid } = this.decodeContext(context); + this.send(Events.STATE, { + param: { + statelist: [ + { + uuid, + key, + actionid, + type: 4, + gifpath, + textData: text || "", + showtext: text ? true : false, + }, + ], + }, + }); + } + + /** + * 监听socket连接事件 + */ + onConnected(fn) { + if (!fn) { + Utils.error( + "A callback function for the connected event is required for onConnected." + ); + } + + this.on(Events.CONNECTED, (jsn) => fn(jsn)); + return this; + } + + /** + * 监听socket断开事件 + */ + onClose(fn) { + if (!fn) { + Utils.error( + "A callback function for the close event is required for onClose." + ); + } + + this.on(Events.CLOSE, (jsn) => fn(jsn)); + return this; + } + + /** + * 监听socket错误事件 + */ + onError(fn) { + if (!fn) { + Utils.error( + "A callback function for the error event is required for onError." + ); + } + + this.on(Events.ERROR, (jsn) => fn(jsn)); + return this; + } + + /** + * 接收上位机事件:add + */ + onAdd(fn) { + if (!fn) { + Utils.error( + "A callback function for the add event is required for onAdd." + ); + } + + this.on(Events.ADD, (jsn) => fn(jsn)); + return this; + } + + /** + * 接收上位机事件:paramfromapp + */ + onParamFromApp(fn) { + if (!fn) { + Utils.error( + "A callback function for the paramfromapp event is required for onParamFromApp." + ); + } + + this.on(Events.PARAMFROMAPP, (jsn) => fn(jsn)); + return this; + } + + /** + * 接收上位机事件:paramfromplugin + */ + onParamFromPlugin(fn) { + if (!fn) { + Utils.error( + "A callback function for the paramfromplugin event is required for onParamFromPlugin." + ); + } + + this.on(Events.PARAMFROMPLUGIN, (jsn) => fn(jsn)); + return this; + } + + /** + * 接收上位机事件:run + */ + onRun(fn) { + if (!fn) { + Utils.error( + "A callback function for the run event is required for onRun." + ); + } + + this.on(Events.RUN, (jsn) => fn(jsn)); + return this; + } + + /** + * 接收上位机事件:setactive + */ + onSetActive(fn) { + if (!fn) { + Utils.error( + "A callback function for the setactive event is required for onSetActive." + ); + } + + this.on(Events.SETACTIVE, (jsn) => fn(jsn)); + return this; + } + + /** + * 接收上位机事件:clear + */ + onClear(fn) { + if (!fn) { + Utils.error( + "A callback function for the clear event is required for onClear." + ); + } + + this.on(Events.CLEAR, (jsn) => fn(jsn)); + return this; + } + + /** + * 接收上位机事件:返回选择弹窗结果 + */ + onSelectdialog(fn) { + if (!fn) { + Utils.error( + "A callback function for the selectdialog event is required for onSelectdialog." + ); + } + + this.on(Events.SELECTDIALOG, (jsn) => fn(jsn)); + return this; + } + + /** + * 接收上位机事件:didReceiveSettings, 接受上位机保存的参数 + */ + onDidReceiveSettings(fn) { + if (!fn) { + Utils.error( + "A callback function for the didReceiveSettings event is required for onDidReceiveSettings." + ); + } + this.on(Events.DIDRECEIVESETTINGS, (jsn) => fn(jsn)); + return this; + } + + /** + * didReceiveGlobalSettings, 接受全局设置的参数 + */ + onDidReceiveGlobalSettings(fn) { + if (!fn) { + Utils.error( + "A callback function for the didReceiveGlobalSettings event is required for onDidReceiveGlobalSettings." + ); + } + this.on(Events.DIDRECEIVEGLOBALSETTINGS, (jsn) => fn(jsn)); + return this; + } + + /** + * + * 接收 主服务发给功能页的透传参数事件 + */ + onSendToPropertyInspector(fn) { + if (!fn) { + Utils.error( + "A callback function for the sendToPropertyInspector event is required for onSendToPropertyInspector." + ); + } + this.on(Events.SENDTOPROPERTYINSPECTOR, (jsn) => fn(jsn)); + return this; + } + + /** + * + * 接收 功能页发给主服务的透传参数事件 + */ + onSendToPlugin(fn) { + if (!fn) { + Utils.error( + "A callback function for the sendToPlugin event is required for onSendToPlugin." + ); + } + this.on(Events.SENDTOPLUGIN, (jsn) => fn(jsn)); + return this; + } + + /** + * 接收上位机事件:keydown, 接收上位机按键按下事件 + */ + onKeyDown(fn) { + if (!fn) { + Utils.error( + "A callback function for the keydown event is required for onKeyDown." + ); + } + this.on(Events.KEYDOWN, (jsn) => fn(jsn)); + return this; + } + /** + * 接收上位机事件:keyup, 接收上位机按键松开事件 + */ + onKeyUp(fn) { + if (!fn) { + Utils.error( + "A callback function for the keyup event is required for onKeyUp." + ); + } + this.on(Events.KEYUP, (jsn) => fn(jsn)); + return this; + } + /** + * 接收上位机事件:dialdown, 接收上位机旋钮按下事件 + */ + onDialDown(fn) { + if (!fn) { + Utils.error( + "A callback function for the dialdown event is required for onDialDown." + ); + } + this.on(Events.DIALEDOWN, (jsn) => fn(jsn)); + return this; + } + /** + * 接收上位机事件:dialup, 接收上位机旋钮松开事件 + */ + onDialUp(fn) { + if (!fn) { + Utils.error( + "A callback function for the dialup event is required for onDialUp." + ); + } + this.on(Events.DIALEUP, (jsn) => fn(jsn)); + return this; + } + /** + * 接收上位机事件:dialrotate, 接收上位机旋钮向左旋转事件 + */ + onDialRotateLeft(fn) { + if (!fn) { + Utils.error( + "A callback function for the dialrotate left event is required for onDialRotateLeft." + ); + } + this.on(Events.DIALROTATE, (jsn) => { + if (jsn.rotateEvent === "left") { + fn(jsn); + } + }); + return this; + } + + /** + * 接收上位机事件:dialrotate, 接收上位机旋钮向右旋转事件 + */ + onDialRotateRight(fn) { + if (!fn) { + Utils.error( + "A callback function for the dialrotate right event is required for onDialRotateRight." + ); + } + this.on(Events.DIALROTATE, (jsn) => { + if (jsn.rotateEvent === "right") { + fn(jsn); + } + }); + return this; + } + + /** + * 接收上位机事件:dialrotate, 接收上位机旋钮按住向左旋转事件 + */ + onDialRotateHoldLeft(fn) { + if (!fn) { + Utils.error( + "A callback function for the dialrotate hold-left event is required for onDialRotateHoldLeft." + ); + } + this.on(Events.DIALROTATE, (jsn) => { + if (jsn.rotateEvent === "hold-left") { + fn(jsn); + } + }); + return this; + } + + /** + * 接收上位机事件:dialrotate, 接收上位机旋钮按住向右旋转事件 + */ + onDialRotateHoldRight(fn) { + if (!fn) { + Utils.error( + "A callback function for the dialrotate hold-right event is required for onDialRotateHoldRight." + ); + } + this.on(Events.DIALROTATE, (jsn) => { + // 注意:原数据中有个拼写错误"hold—right",这里使用正确的连字符 + if (jsn.rotateEvent === "hold-right") { + fn(jsn); + } + }); + return this; + } + + /** + * 接收上位机事件:dialrotate, 接收上位机旋钮旋转事件 + */ + onDialRotate(fn) { + if (!fn) { + Utils.error( + "A callback function for the dialrotate event is required for onDialRotate." + ); + } + this.on(Events.DIALROTATE, (jsn) => fn(jsn)); + return this; + } +} + +const $UD = new UlanziStreamDeck(); diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/js/utils.js b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/js/utils.js new file mode 100644 index 0000000..8c56633 --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/libs/js/utils.js @@ -0,0 +1,567 @@ +class UlanziUtils { + + /** + * 获取表单数据 + * Returns the value from a form using the form controls name property + * @param {Element | string} form + * @returns + */ + getFormValue(form) { + if (typeof form === 'string') { + form = document.querySelector(form); + } + + const elements = form ? form.elements : ''; + + if (!elements) { + console.error('Could not find form!'); + } + + const formData = new FormData(form); + let formValue = {}; + + formData.forEach((value, key) => { + if (!Reflect.has(formValue, key)) { + formValue[key] = value; + return; + } + if (!Array.isArray(formValue[key])) { + formValue[key] = [formValue[key]]; + } + formValue[key].push(value); + }); + + return formValue; + } + + /** + * 重载表单数据 + * Sets the value of form controls using their name attribute and the jsn object key + * @param {*} jsn + * @param {Element | string} form + */ + setFormValue(jsn, form) { + if (!jsn) { + return; + } + + if (typeof form === 'string') { + form = document.querySelector(form); + } + + const elements = form ? form.elements : ''; + + if (!elements) { + console.error('Could not find form!'); + } + + Array.from(elements) + .filter((element) => element ? element.name : null) + .forEach((element) => { + const { name, type } = element; + const value = name in jsn ? jsn[name] : null; + const isCheckOrRadio = type === 'checkbox' || type === 'radio'; + + if (value === null) return; + + if (isCheckOrRadio) { + const isSingle = value === element.value; + console.warn('-----setFormValue isSingle:', isSingle, value, element.value) + if (isSingle || (Array.isArray(value) && value.includes(element.value))) { + element.checked = true; + } + } else { + element.value = value ? value : ''; + } + }); + } + + /** + * 延迟触发 + * This provides a slight delay before processing rapid events + * @param {function} fn + * @param {number} wait - delay before processing function (recommended time 150ms) + * @returns + */ + debounce(fn, wait = 150) { + let timeoutId = null + return (...args) => { + window.clearTimeout(timeoutId); + timeoutId = window.setTimeout(() => { + fn.apply(null, args); + }, wait); + }; + } + + /** + * 返回url的查询参数 + */ + getQueryParams(param) { + const searchParams = new URLSearchParams(window.location.search); + return searchParams.get(param); + } + + /** + * 获取浏览器语言 + * Returns the user language + */ + getLanguage() { + let userLanguage = navigator.languages && navigator.languages.length ? navigator.languages[0] : (navigator.language || navigator.userLanguage); + if (userLanguage == 'zh') { + userLanguage = 'zh_CN' + } else if (userLanguage.indexOf('zh-') >= 0) { + userLanguage = userLanguage.split('-').join('_') + } else if (userLanguage.indexOf('-') !== -1) { + userLanguage = userLanguage.replace(/-/g, '_'); + } + return this.adaptLanguage(userLanguage); + } + + /** + * 适配语言环境 + */ + adaptLanguage(ln) { + let userLanguage = ln; + if (ln.indexOf('zh') == 0) { + if(ln.indexOf('CN') > -1){ + userLanguage = 'zh_CN' + }else{ + userLanguage = 'zh_HK' + } + } else if (ln.indexOf('en') == 0) { + userLanguage = 'en' + } else if (userLanguage.indexOf('-') !== -1) { + userLanguage = userLanguage.replace(/-/g, '_'); + } + + return userLanguage + } + + /** + * JSON.parse优化 + * parse json + * @param {string} jsonString + * @returns {object} json + */ + parseJson(jsonString) { + if (typeof jsonString === 'object') return jsonString; + try { + const o = JSON.parse(jsonString); + if (o && typeof o === 'object') { + return o; + } + } catch (e) { } + + return false; + } + + /** + * 读取json文件 + * Reads a json file + * @param {string} path + * @returns {Promise} json + */ + async readJson(path) { + if (!path) { + console.error('A path is required to readJson.'); + } + + return new Promise((resolve, reject) => { + try { + const req = new XMLHttpRequest(); + req.onerror = reject; + req.overrideMimeType('application/json'); + req.open('GET', path, true); + req.onreadystatechange = (response) => { + if (req.readyState === 4) { + const jsonString = response && response.target && response.target.response || ''; + if (jsonString) { + try { + resolve(JSON.parse(jsonString)); + } catch (e) { + reject(); + } + } else { + reject(); + } + } + }; + + req.send(); + + } catch (e) { + reject(); + } + }); + } + + + /** + * 完整图片转base64 + * @param {string} url 图片地址 + * @param {number} width canvas宽度,默认196 + * @param {number} height canvas宽度,默认196 + * @param {HTMLCanvasElement} inCanvas canvas元素,默认创建 + * @param {boolean} returnCanvas 是否返回canvas,默认false。默认返回base64的图片路径,有些时候需要接着画布添加元素,所以我们添加这个变量 + * @return { string | HTMLCanvasElement } 默认返回base64的图片路径,returnCanvas为true返回画布 + */ + async drawImage(url, width = 196, height = 196, inCanvas, returnCanvas) { + const canvas = inCanvas && inCanvas instanceof HTMLCanvasElement ? inCanvas : document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + + const imgData = await this.loadImagePromise(url) + if (imgData.status == 'ok') { + ctx.drawImage(imgData.img, 0, 0, canvas.width, canvas.height); + } + return returnCanvas ? canvas : canvas.toDataURL('image/png'); //需要是否需要返回画布或者直接返回base64 + } + + /** + * 裁剪图片转base64 + * @param {string} url 图片地址 + * @param {number} offsetX 裁剪x的位置 + * @param {number} offsetY 裁剪y的位置 + * @param {number} width canvas宽度,默认196 + * @param {number} height canvas宽度,默认196 + * @param {HTMLCanvasElement} inCanvas canvas元素,默认创建 + * @param {boolean} returnCanvas 是否返回canvas,默认false。默认返回base64的图片路径,有些时候需要接着画布添加元素,所以我们添加这个变量 + * @return { string | HTMLCanvasElement } 默认返回base64的图片路径,returnCanvas为true返回画布 + */ + async cropImage(url, offsetX, offsetY, width = 196, height = 196, inCanvas, returnCanvas) { + const canvas = inCanvas && inCanvas instanceof HTMLCanvasElement ? inCanvas : document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + canvas.width = width; + canvas.height = height; + + + const imgData = await this.loadImagePromise(url) + if (imgData.status == 'ok') { + ctx.drawImage(imgData.img, offsetX, offsetY, width, height, 0, 0, canvas.width, canvas.height); + } + + return returnCanvas ? canvas : canvas.toDataURL('image/png'); //需要是否需要返回画布或者直接返回base64 + + }; + + /** + * 获取图片数据 + * @param {string} url 图片地址 + * @return {object} {url, status: 'ok', img} or {url, status: 'error'} + */ + loadImagePromise(url) { + return new Promise(resolve => { + const img = new Image(); + img.onload = () => resolve({ url, status: 'ok', img }); + img.onerror = () => resolve({ url, status: 'error' }); + img.src = url; + }); + } + + + getData(url, param) { + + param = Object.assign(param || {}, Utils.joinTimestamp()); + + //若参数有数组,进行特殊拼接 + url = url + '?' + Object.keys(param).map(e => { + let str = '' + //判断数组拼接 + if (param[e] instanceof Array) { + str = param[e].map((item) => { + return `${e}=${item}` + }).join('&') + } else { + str = `${e}=${param[e]}` + } + return str + }).join('&'); + // console.warn('=====getData url:', url) + return new Promise(function (resolve, reject) { + var req = new XMLHttpRequest(); + + req.timeout = 1500; // 设置超时时间为 5 秒 + + req.ontimeout = function () { + console.error('Request timed out'); + }; + + req.onload = function () { + // console.warn('=====getData onload:') + if (req.status === 200) { + // console.warn('=====getData success:') + resolve(req.response); + } else { + // console.warn('=====getData not 200:') + reject(Error(req.statusText)); + } + }; + + req.onerror = function () { + // console.warn('=====getData error:') + reject(Error('Network Error')); + }; + + req.open('GET', url, true); + req.send(); + }); + }; + + /** + * 获取接口数据 + * @param {string} url 接口地址 + * @param {object} param 接口参数 + * @param {string} method 请求方式:GET/POST/PUT/DELETE + * @param {object} headers 请求头 + */ + fetchData(url, param, method = 'GET', headers = {}) { + + if (method.toUpperCase() === 'GET') { + param = Object.assign(param || {}, Utils.joinTimestamp()); + + const tag = url.indexOf('?') >= 0 ? '&':'?' + + //若参数有数组,进行特殊拼接 + url = url + tag + Object.keys(param).map(e => { + let str = '' + //判断数组拼接 + if (param[e] instanceof Array) { + str = param[e].map((item) => { + return `${e}=${item}` + }).join('&') + } else { + str = `${e}=${param[e]}` + } + return str + }).join('&'); + } + + const opts = { + cache: 'no-cache', + headers, + method: method, + body: ['GET', 'HEAD'].includes(method) + ? undefined + : param, + }; + return new Promise(function (resolve, reject) { + Utils.fetchWithTimeout(url, opts) + .then(async (resp) => { + // console.warn('==fetch success:', url) + if (!resp) { + reject(new Error('No Resp')); + } + if (!resp.ok) { + const errData = await resp.json(); + if (errData) { + reject(errData); + } else { + reject(new Error(`{${resp.status}: ${await resp.text()}}`)); + } + + } else { + resolve(await resp.json()); + } + }) + .catch((err) => { + // console.warn('==fetch error:', JSON.stringify(err)) + reject(err); + }) + }); + } + + /** + * 封装fetch请求,设置超时时间 + */ + fetchWithTimeout(url, options = {}) { + const { timeout = 15000 } = options; // 设置默认超时时间为8000ms + // console.warn('====fetchWithTimeout timeout:', timeout) + + const controller = new AbortController(); + const id = setTimeout(() => controller.abort(), timeout); + + + // console.warn('==fetchWithTimeout:', url, JSON.stringify(options)) + const response = fetch(url, { + ...options, + signal: controller.signal + }).then((response) => { + // console.warn('==fetchWithTimeout success:', JSON.stringify(response)) + clearTimeout(id); + return response; + }).catch((error) => { + // console.warn('==fetchWithTimeout error:', JSON.stringify(error)) + clearTimeout(id); + throw error; + }); + + return response; + + } + + /** + * 获取随机时间戳 + */ + joinTimestamp() { + const now = new Date().getTime(); + return { _t: now }; + } + + + //判断是否为文件类型 + isFile(variable) { + return variable instanceof File; + } + + /** + * 浏览器file转base64 + */ + htmlFileToBase64(file) { + if (!this.isFile(file)) { + return Promise.reject(new Error('Not a file')); + } + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => resolve(reader.result); + reader.onerror = error => reject(error); + }); + } + + drawText(text, stroke = "#fff", background = "#000", wh = 196, textLabel, inCanvas) { + // console.log('==drawText:', text, textLabel) + const canvas = inCanvas ? inCanvas : document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + if(!inCanvas){ + canvas.width = wh; + canvas.height = wh; + if (background == "transparent") { + ctx.clearRect(0, 0, canvas.width, canvas.height); + } else { + ctx.fillStyle = background; + ctx.fillRect(0, 0, canvas.width, canvas.height); + } + + } + + + const font = `"Source Han Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif`; + const fSize = text.length > 6 ? 40 : 50; + + + // ctx.strokeStyle = "#000"; + // ctx.lineWidth = 4; + + ctx.fillStyle = stroke; + ctx.font = `bold ${fSize}px ${font}`; + ctx.textBaseline = 'middle'; + ctx.textAlign = 'center'; + + ctx.strokeText(text, ctx.canvas.width / 2, ctx.canvas.height / 2); + ctx.fillText(text, ctx.canvas.width / 2, ctx.canvas.height / 2 ); + + if(textLabel){ + ctx.font = `bold 24px ${font}`; + ctx.textBaseline = 'middle'; + ctx.textAlign = 'left'; + ctx.fillText(textLabel, 10, 20); + } + + + return canvas.toDataURL('image/png') + } + + getProperty(obj, dotSeparatedKeys, defaultValue) { + if (arguments.length > 1 && typeof dotSeparatedKeys !== 'string') return undefined; + if (typeof obj !== 'undefined' && typeof dotSeparatedKeys === 'string') { + const pathArr = dotSeparatedKeys.split('.'); + pathArr.forEach((key, idx, arr) => { + if (typeof key === 'string' && key.includes('[')) { + try { + // extract the array index as string + const pos = /\[([^)]+)\]/.exec(key)[1]; + // get the index string length (i.e. '21'.length === 2) + const posLen = pos.length; + arr.splice(idx + 1, 0, Number(pos)); + + // keep the key (array name) without the index comprehension: + // (i.e. key without [] (string of length 2) + // and the length of the index (posLen)) + arr[idx] = key.slice(0, -2 - posLen); // eslint-disable-line no-param-reassign + } catch (e) { + // do nothing + } + } + }); + // eslint-disable-next-line no-param-reassign, no-confusing-arrow + obj = pathArr.reduce((o, key) => (o && o[key] !== 'undefined' ? o[key] : undefined), obj); + } + return obj === undefined ? defaultValue : obj; + }; + + getProp(jsn, str, defaultValue = {}, sep = '.') { + const arr = str.split(sep); + return arr.reduce((obj, key) => (obj && obj.hasOwnProperty(key) ? obj[key] : defaultValue), jsn); + }; + + /** + * 获取插件根目录路径 + */ + getPluginPath(){ + let currentFilePath = location.pathname; + try { + currentFilePath = decodeURIComponent(currentFilePath); + } catch (error) { + console.warn('Failed to decode plugin path:', currentFilePath, error); + } + + // Use filesystem separators for paths passed to the desktop host. + currentFilePath = currentFilePath.replace(/\\/g, '/'); + if (location.protocol === 'file:' && location.hostname) { + currentFilePath = `//${location.hostname}${currentFilePath}`; + } + + const pathArr = currentFilePath.split('/'); + const idx = pathArr.findIndex(f => f.endsWith('ulanziPlugin')); + if (idx === -1) return ''; + + let folderPath = pathArr.slice(0, idx + 1).join('/'); + + // Chromium exposes Windows file URLs as /C:/path/to/file. Remove only + // that leading slash so filesystem consumers receive a valid C:/ path, + // while preserving Unix/macOS absolute paths such as /Users/.... + if (/^\/[A-Za-z]:\//.test(folderPath)) { + folderPath = folderPath.slice(1); + } + + return folderPath; + + } + + /** + * Logs a message + * @param {any} msg + */ + log(...msg) { + console.warn(`[${new Date().toLocaleString('zh-CN', { hour12: false })}]`, ...msg); + // this.getQueryParams('debug') && console.log(`[${new Date().toLocaleString('zh-CN', {hour12: false})}]`, ...msg); + } + + /** + * Logs a warning message + */ + warn(...msg) { + console.warn(`[${new Date().toLocaleString('zh-CN', { hour12: false })}]`, ...msg); + } + + /** + * Logs an error message + */ + error(...msg) { + console.error(`[${new Date().toLocaleString('zh-CN', { hour12: false })}]`, ...msg); + } +} + +const Utils = new UlanziUtils() \ No newline at end of file diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/manifest.json b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/manifest.json new file mode 100644 index 0000000..438f4c8 --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/manifest.json @@ -0,0 +1,51 @@ +{ + "Version": "0.1.0", + "Author": "mcoder2014", + "Name": "命令执行器", + "Description": "在当前 macOS 用户的登录 Shell 中执行每个按键独立配置的命令", + "Icon": "assets/icons/plugin.svg", + "Category": "命令执行器", + "CategoryIcon": "assets/icons/plugin.svg", + "CodePath": "dist/app.js", + "Type": "JavaScript", + "SupportedInMultiActions": false, + "UUID": "com.ulanzi.ulanzistudio.commandexecutor", + "Actions": [ + { + "Name": "执行命令", + "Icon": "assets/icons/action.svg", + "PropertyInspectorPath": "property-inspector/inspector.html", + "state": 0, + "States": [ + { + "Image": "assets/icons/action.svg" + }, + { + "Image": "assets/icons/running.svg" + }, + { + "Image": "assets/icons/success.svg" + } + ], + "Tooltip": "执行配置的 Shell 命令", + "UUID": "com.ulanzi.ulanzistudio.commandexecutor.runcommand", + "Controllers": [ + "Keypad" + ], + "Devices": [ + "D200X" + ], + "DisableAutomaticStates": true, + "SupportedInMultiActions": false + } + ], + "OS": [ + { + "Platform": "mac", + "MinimumVersion": "10.15" + } + ], + "Software": { + "MinVersion": "3.0.11" + } +} diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/package.json b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/package.json new file mode 100644 index 0000000..f9e9fd7 --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/package.json @@ -0,0 +1,6 @@ +{ + "name": "com.ulanzi.commandexecutor", + "version": "0.1.0", + "private": true, + "type": "module" +} diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/plugin/app.js b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/plugin/app.js new file mode 100644 index 0000000..914e6c9 --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/plugin/app.js @@ -0,0 +1,6 @@ +import UlanziApi from './vendor/ulanzi-api/ulanziApi.js'; +import { registerCommandPlugin } from './command-plugin.js'; + +const api = new UlanziApi(); +registerCommandPlugin(api); +api.connect('com.ulanzi.ulanzistudio.commandexecutor'); diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/plugin/command-plugin.js b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/plugin/command-plugin.js new file mode 100644 index 0000000..4691bb1 --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/plugin/command-plugin.js @@ -0,0 +1,311 @@ +import { runCommand } from "./command-runner.js"; + +const DEFAULT_TITLE = "执行命令"; +const SUCCESS_RESTORE_DELAY_MS = 1200; +const SETTINGS_FIELDS = [ + "title", + "command", + "workingDirectory", + "environment" +]; + +function normalizeField(field, value) { + if (field === "title") { + if (typeof value !== "string") { + return DEFAULT_TITLE; + } + return value.trim() || DEFAULT_TITLE; + } + + return typeof value === "string" ? value : ""; +} + +function createSettings(param) { + const settings = { + title: DEFAULT_TITLE, + command: "", + workingDirectory: "", + environment: "" + }; + + return mergeSettings(settings, param); +} + +function mergeSettings(settings, param) { + if (!param || typeof param !== "object") { + return settings; + } + + for (const field of SETTINGS_FIELDS) { + if (Object.hasOwn(param, field)) { + settings[field] = normalizeField(field, param[field]); + } + } + return settings; +} + +function hasSettings(param) { + return ( + param && + typeof param === "object" && + SETTINGS_FIELDS.some((field) => Object.hasOwn(param, field)) + ); +} + +function errorMessage(error) { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +function valueOrNone(value) { + return value === undefined || value === null || value === "" + ? "none" + : String(value); +} + +function buildLog(context, result, validationError, success) { + const stdout = valueOrNone(result?.stdout); + const stderr = valueOrNone(result?.stderr); + const lines = [ + success ? "命令执行完成" : "命令执行失败", + `context: ${context}`, + `code: ${valueOrNone(result?.code)}`, + `signal: ${valueOrNone(result?.signal)}`, + `spawn error: ${valueOrNone( + result?.spawnError ? errorMessage(result.spawnError) : null + )}`, + `validation error: ${valueOrNone( + validationError ? errorMessage(validationError) : null + )}`, + `stdout: ${stdout}`, + `stderr: ${stderr}` + ]; + + if (result?.stdoutTruncated) { + lines.push("stdout 输出已截断"); + } + if (result?.stderrTruncated) { + lines.push("stderr 输出已截断"); + } + return lines.join("\n"); +} + +function failureToast(result, validationError) { + if (validationError) { + return `命令执行失败:${errorMessage(validationError)}`; + } + if (result?.spawnError) { + return "命令启动失败"; + } + if (result?.signal) { + return `命令被信号 ${result.signal} 终止`; + } + if (result?.code !== 0) { + return `命令执行失败(退出码 ${valueOrNone(result?.code)})`; + } + return "命令执行失败"; +} + +function isSuccess(result) { + return !result?.spawnError && result?.code === 0 && !result?.signal; +} + +/** + * Registers the command action callbacks on one official SDK instance. + * State stays private and is isolated by button context; each run receives a + * settings snapshot, while SDK feedback is emitted only for a live instance. + */ +export function registerCommandPlugin(api, options = {}) { + const runCommandFn = options.runCommandFn ?? runCommand; + const setTimeoutFn = options.setTimeoutFn ?? globalThis.setTimeout; + const clearTimeoutFn = options.clearTimeoutFn ?? globalThis.clearTimeout; + const instances = new Map(); + + function createInstance(param) { + return { + settings: createSettings(param), + runningCount: 0, + failedInBurst: false, + restoreTimer: null + }; + } + + function clearRestoreTimer(instance) { + if (instance.restoreTimer === null) { + return; + } + clearTimeoutFn(instance.restoreTimer); + instance.restoreTimer = null; + } + + function currentState(instance) { + if (instance.runningCount > 0) { + return 1; + } + return instance.restoreTimer === null ? 0 : 2; + } + + function redraw(context, instance) { + api.setStateIcon( + context, + currentState(instance), + instance.settings.title + ); + } + + function updateInstance(context, param) { + let instance = instances.get(context); + if (!instance) { + instance = createInstance(param); + instances.set(context, instance); + return instance; + } + + clearRestoreTimer(instance); + mergeSettings(instance.settings, param); + return instance; + } + + function scheduleIdleRestore(context, instance) { + let timer; + timer = setTimeoutFn(() => { + if ( + instances.get(context) !== instance || + instance.runningCount !== 0 || + instance.restoreTimer !== timer + ) { + return; + } + + instance.restoreTimer = null; + api.setStateIcon(context, 0, instance.settings.title); + }, SUCCESS_RESTORE_DELAY_MS); + instance.restoreTimer = timer; + } + + async function execute(context, instance, settings) { + let result; + let validationError = null; + let success = false; + + try { + result = await runCommandFn(settings); + success = isSuccess(result); + } catch (error) { + validationError = error; + } + + try { + if (!success) { + instance.failedInBurst = true; + if (instances.get(context) === instance) { + api.showAlert(context); + api.toast(failureToast(result, validationError)); + } + api.logMessage( + buildLog(context, result, validationError, false), + "error" + ); + } else { + api.logMessage(buildLog(context, result, null, true), "info"); + } + } finally { + instance.runningCount -= 1; + if (instances.get(context) !== instance) { + return; + } + if (instance.runningCount > 0) { + api.setStateIcon(context, 1, instance.settings.title); + return; + } + if (instance.failedInBurst) { + api.setStateIcon(context, 0, instance.settings.title); + return; + } + + api.setStateIcon(context, 2, instance.settings.title); + scheduleIdleRestore(context, instance); + } + } + + api.onAdd((jsn) => { + const previous = instances.get(jsn.context); + if (previous) { + clearRestoreTimer(previous); + } + const instance = createInstance(jsn.param); + instances.set(jsn.context, instance); + api.setStateIcon(jsn.context, 0, instance.settings.title); + }); + + const updateFromParam = (jsn) => { + const instance = updateInstance(jsn.context, jsn.param); + redraw(jsn.context, instance); + }; + api.onParamFromApp(updateFromParam); + api.onParamFromPlugin(updateFromParam); + + api.onError((error) => { + console.error(`[Ulanzi] 连接错误: ${errorMessage(error)}`); + }); + + api.onSetActive((jsn) => { + if (jsn.active !== true) { + return; + } + + let instance = instances.get(jsn.context); + if (!instance && hasSettings(jsn.param)) { + instance = createInstance(jsn.param); + instances.set(jsn.context, instance); + } else if (instance && hasSettings(jsn.param)) { + instance = updateInstance(jsn.context, jsn.param); + } + if (instance) { + redraw(jsn.context, instance); + } + }); + + api.onRun((jsn) => { + let instance = instances.get(jsn.context); + if (!instance) { + instance = createInstance(jsn.param); + instances.set(jsn.context, instance); + } else if (hasSettings(jsn.param)) { + instance = updateInstance(jsn.context, jsn.param); + } + + clearRestoreTimer(instance); + if (instance.runningCount === 0) { + instance.failedInBurst = false; + } + instance.runningCount += 1; + api.setStateIcon(jsn.context, 1, instance.settings.title); + + const snapshot = { ...instance.settings }; + void execute(jsn.context, instance, snapshot).catch(() => {}); + }); + + api.onClear((jsn) => { + if (!Array.isArray(jsn.param)) { + return; + } + + for (const item of jsn.param) { + const context = + typeof item === "string" ? item : item?.context; + if (!context) { + continue; + } + + const instance = instances.get(context); + if (!instance) { + continue; + } + clearRestoreTimer(instance); + instances.delete(context); + } + }); +} diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/plugin/command-runner.js b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/plugin/command-runner.js new file mode 100644 index 0000000..3227444 --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/plugin/command-runner.js @@ -0,0 +1,231 @@ +import { spawn as defaultSpawn } from "node:child_process"; +import { constants as fileSystemConstants } from "node:fs"; +import * as defaultFileSystem from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; +import { StringDecoder } from "node:string_decoder"; + +const ENVIRONMENT_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; +const FALLBACK_SHELL = "/bin/zsh"; + +export const OUTPUT_LIMIT_BYTES = 16 * 1024; + +export function parseEnvironment(text = "") { + const entries = new Map(); + const lines = String(text).split(/\r?\n/); + + lines.forEach((line, index) => { + if (line.trim() === "") { + return; + } + + const separator = line.indexOf("="); + const name = separator >= 0 ? line.slice(0, separator).trim() : ""; + const value = separator >= 0 ? line.slice(separator + 1) : ""; + if (!ENVIRONMENT_NAME.test(name)) { + throw new Error(`环境变量第 ${index + 1} 行格式错误`); + } + if (value.includes("\0")) { + throw new Error(`环境变量第 ${index + 1} 行包含不支持的 NUL 字符`); + } + + entries.set(name, value); + }); + + return Object.fromEntries(entries); +} + +export function quoteShellValue(value) { + const text = String(value); + const escaped = text.replaceAll("'", `'\"'\"'`); + + return `'${escaped}'`; +} + +export function buildShellScript(command, environment) { + if (typeof command !== "string" || command.trim() === "") { + throw new Error("命令不能为空"); + } + if (command.includes("\0")) { + throw new Error("命令包含不支持的 NUL 字符"); + } + + const exports = Object.entries(environment).map( + ([name, value]) => `export ${name}=${quoteShellValue(value)}` + ); + + return exports.length === 0 ? command : `${exports.join("\n")}\n${command}`; +} + +export async function resolveWorkingDirectory(value, options = {}) { + const fileSystem = options.fileSystem ?? defaultFileSystem; + const homeDirectory = options.homeDirectory ?? homedir(); + let workingDirectory; + + if (value === undefined || value === null || value === "" || value === "~") { + workingDirectory = homeDirectory; + } else if (typeof value === "string" && value.startsWith("~/")) { + workingDirectory = path.join(homeDirectory, value.slice(2)); + } else if (typeof value === "string" && path.isAbsolute(value)) { + workingDirectory = value; + } else { + throw new Error("工作目录必须是绝对路径、~ 或 ~/ 开头的路径"); + } + + let status; + try { + status = await fileSystem.stat(workingDirectory); + } catch { + throw new Error("工作目录不存在或无法读取"); + } + if (!status.isDirectory()) { + throw new Error("工作目录不是目录"); + } + + try { + await fileSystem.access(workingDirectory, fileSystemConstants.X_OK); + } catch { + throw new Error("工作目录无法访问"); + } + + return workingDirectory; +} + +async function isExecutableFile(fileSystem, value) { + try { + const status = await fileSystem.stat(value); + if (!status.isFile()) { + return false; + } + await fileSystem.access(value, fileSystemConstants.X_OK); + return true; + } catch { + return false; + } +} + +export async function resolveShell(options = {}) { + const fileSystem = options.fileSystem ?? defaultFileSystem; + const baseEnvironment = options.baseEnvironment ?? process.env; + const candidate = baseEnvironment.SHELL; + + if ( + typeof candidate === "string" && + path.isAbsolute(candidate) && + (await isExecutableFile(fileSystem, candidate)) + ) { + return candidate; + } + + if (await isExecutableFile(fileSystem, FALLBACK_SHELL)) { + return FALLBACK_SHELL; + } + throw new Error("没有可执行的 Shell"); +} + +function appendBounded(chunks, state, chunk, limit) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + const remaining = Math.max(0, limit - state.bytes); + + if (remaining > 0) { + const accepted = buffer.subarray(0, remaining); + chunks.push(accepted); + state.bytes += accepted.length; + } + if (buffer.length > remaining) { + state.truncated = true; + } +} + +function createResult({ + code = null, + signal = null, + stdoutChunks = [], + stderrChunks = [], + stdoutTruncated = false, + stderrTruncated = false, + spawnError = null +} = {}) { + const stdoutDecoder = new StringDecoder("utf8"); + const stderrDecoder = new StringDecoder("utf8"); + const stdoutBuffer = Buffer.concat(stdoutChunks); + const stderrBuffer = Buffer.concat(stderrChunks); + + return { + code, + signal, + stdout: stdoutTruncated + ? stdoutDecoder.write(stdoutBuffer) + : stdoutDecoder.end(stdoutBuffer), + stderr: stderrTruncated + ? stderrDecoder.write(stderrBuffer) + : stderrDecoder.end(stderrBuffer), + stdoutTruncated, + stderrTruncated, + spawnError + }; +} + +export async function runCommand(settings = {}, options = {}) { + const spawnFn = options.spawnFn ?? defaultSpawn; + const fileSystem = options.fileSystem ?? defaultFileSystem; + const homeDirectory = options.homeDirectory ?? homedir(); + const baseEnvironment = options.baseEnvironment ?? process.env; + const outputLimitBytes = options.outputLimitBytes ?? OUTPUT_LIMIT_BYTES; + const environment = parseEnvironment(settings.environment); + const script = buildShellScript(settings.command, environment); + const workingDirectory = await resolveWorkingDirectory( + settings.workingDirectory, + { fileSystem, homeDirectory } + ); + const shell = await resolveShell({ fileSystem, baseEnvironment }); + const stdoutChunks = []; + const stderrChunks = []; + const stdoutState = { bytes: 0, truncated: false }; + const stderrState = { bytes: 0, truncated: false }; + let child; + + try { + child = spawnFn(shell, ["-lc", script], { + cwd: workingDirectory, + env: baseEnvironment, + stdio: ["ignore", "pipe", "pipe"] + }); + } catch (spawnError) { + return createResult({ spawnError }); + } + + return new Promise((resolve) => { + let settled = false; + const finish = (code, signal, spawnError) => { + if (settled) { + return; + } + settled = true; + resolve( + createResult({ + code, + signal, + stdoutChunks, + stderrChunks, + stdoutTruncated: stdoutState.truncated, + stderrTruncated: stderrState.truncated, + spawnError + }) + ); + }; + + child.stdout.on("data", (chunk) => { + appendBounded(stdoutChunks, stdoutState, chunk, outputLimitBytes); + }); + child.stderr.on("data", (chunk) => { + appendBounded(stderrChunks, stderrState, chunk, outputLimitBytes); + }); + child.on("error", (error) => { + finish(null, null, error); + }); + child.on("close", (code, signal) => { + finish(code, signal, null); + }); + }); +} diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/plugin/vendor/ulanzi-api/constants.js b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/plugin/vendor/ulanzi-api/constants.js new file mode 100644 index 0000000..4560ff9 --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/plugin/vendor/ulanzi-api/constants.js @@ -0,0 +1,46 @@ + + +/** + * Events used for communicating with Ulanzi Stream Deck + */ +export const Events = Object.freeze({ + CONNECTED: 'connected', + CLOSE: 'close', + ERROR: 'error', + ADD: 'add', + RUN: 'run', + PARAMFROMAPP: 'paramfromapp', + PARAMFROMPLUGIN: 'paramfromplugin', + SETACTIVE: 'setactive', + CLEAR: 'clear', + TOAST:'toast', + STATE:'state', + OPENURL:'openurl', + OPENVIEW:'openview', + SELECTDIALOG:'selectdialog', + LOGMESSAGE:'logMessage', + HOTKEY:'hotkey', + SHOWALERT:'showAlert', + SENDTOPROPERTYINSPECTOR:'sendToPropertyInspector', + SENDTOPLUGIN:'sendToPlugin', + GETSETTINGS:'getSettings', + SETSETTINGS:'setSettings', + DIDRECEIVESETTINGS:'didReceiveSettings', + SETGLOBALSETTINGS:'setGlobalSettings', + GETGLOBALSETTINGS:'getGlobalSettings', + DIDRECEIVEGLOBALSETTINGS:'didReceiveGlobalSettings', + KEYDOWN:'keydown', + KEYUP:'keyup', + DIALEDOWN:'dialdown', + DIALEUP:'dialup', + DIALROTATE:'dialrotate' +}); + +/** + * Errors received from WebSocket + */ +export const SocketErrors = { + DEFAULT:'closed *****' +}; + + diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/plugin/vendor/ulanzi-api/ulanziApi.js b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/plugin/vendor/ulanzi-api/ulanziApi.js new file mode 100644 index 0000000..10e4848 --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/plugin/vendor/ulanzi-api/ulanziApi.js @@ -0,0 +1,872 @@ +import WebSocket from "ws"; +import EventEmitter from "events"; +import { promises as fs } from "fs"; + +import { Events, SocketErrors } from "./constants.js"; +import Utils from "./utils.js"; + +export default class UlanziApi extends EventEmitter { + constructor() { + super(); + + this.key = ""; + this.uuid = ""; + this.actionid = ""; + this.websocket = null; + // language = 'en'; + // localization = null; + // localPathPrefix = '../../langs/'; + // on = EventEmitter.on; + // emit = EventEmitter.emit; + } + + connect(uuid, port = 3906, address = "127.0.0.1") { + //动态获取ip和端口 + const [argv_address, argv_port, argv_language] = process.argv.splice(2); + + this.address = argv_address || address; + this.port = argv_port || port; + this.language = Utils.adaptLanguage(argv_language || "en"); + + this.uuid = uuid; + + if (this.websocket) { + this.websocket.close(); + this.websocket = null; + } + + //判断是否为主服务,约定主服务 uuid 为4位,action应大于4位 + const isMain = this.uuid.split(".").length == 4; + + this.websocket = new WebSocket(`ws://${this.address}:${this.port}`); + + this.websocket.onopen = () => { + Utils.log(`[ULANZIDECK] MAIN WEBSOCKET OPEN: ${this.uuid}`); + const json = { + code: 0, + cmd: Events.CONNECTED, + uuid: this.uuid, + }; + + this.websocket.send(JSON.stringify(json)); + + this.emit(Events.CONNECTED, {}); + + //如果是主服务,则不进行本地化 + if (!isMain) { + // this.localizeUI(); //node 不做本地化 + } + }; + + this.websocket.onerror = (evt) => { + const error = `[ULANZIDECK] MAIN WEBSOCKET ERROR: ${JSON.stringify( + evt + )}, ${JSON.stringify(evt.data)}, ${SocketErrors[evt?.code || "DEFAULT"]}`; + Utils.warn(error); + + this.emit(Events.ERROR, error); + }; + + this.websocket.onclose = (evt) => { + Utils.warn( + `[ULANZIDECK] MAIN WEBSOCKET CLOSED: ${this.uuid}, ${ + SocketErrors[evt?.code || "DEFAULT"] + }` + ); + this.emit(Events.CLOSE); + }; + + this.websocket.onmessage = (evt) => { + Utils.log("[ULANZIDECK] MAIN WEBSOCKET MESSGE "); + + const data = evt?.data ? JSON.parse(evt.data) : null; + + Utils.log( + `[ULANZIDECK] MAIN WEBSOCKET MESSGE DATA: ${ + this.uuid + }, ${JSON.stringify(data)}` + ); + + //没有数据或者有data.code属性,且cmdType不等于REQUEST,则返回 + if ( + !data || + (typeof data.code !== "undefined" && data.cmdType !== "REQUEST") + ) + return; + + Utils.log("[ULANZIDECK] MAIN WEBSOCKET MESSGE IN"); + + //没有key时,保存key + if (!this.key && data.uuid == this.uuid && data.key) { + this.key = data.key; + } + //没有actionid时,保存actionid + if (!this.actionid && data.uuid == this.uuid && data.actionid) { + this.actionid = data.actionid; + } + + if (isMain) { + //主服务回应上位机 + this.send(data.cmd, { + code: 0, + ...data, + }); + } + + //特殊处理clear,因为clear事件变量是数组形式 + if (data.cmd == "clear") { + if (data.param) { + for (let i = 0; i < data.param.length; i++) { + const context = this.encodeContext(data.param[i]); + data.param[i].context = context; + } + } + } else { + //拼接唯一id给功能页 + const context = this.encodeContext(data); + data.context = context; + } + + //引发事件 + this.emit(data.cmd, data); + }; + } + + /** + * 本地化 + */ + async localizeUI() { + if (!this.localization) { + try { + const filePath = `${Utils.getPluginPath()}/${this.language}.json` + const data = await fs.readFile(filePath, 'utf8'); + const localJson = JSON.parse(data); + this.localization = localJson["Localization"] ?? null; + + // Utils.log(`---get ${Utils.getPluginPath()}/${this.language}.json success--`); + } catch (e) { + // Utils.log(`-- ${Utils.getPluginPath()}/${this.language}.json fail --error:`, e); + Utils.warn(`[ULANZIDECK] No FILE found to localize ${this.language}`); + } + } + } + + t(key) { + // console.log('--localization---key:', key, this.localization && this.localization[key],JSON.stringify(this.localization)); + return (this.localization && this.localization[key]) || key; + } + + /** + * 创建唯一值 + */ + encodeContext(jsn) { + return jsn.uuid + "___" + jsn.key + "___" + jsn.actionid; + } + + /** + * 解构唯一值 + */ + decodeContext(context) { + const de_ctx = context.split("___"); + return { + uuid: de_ctx[0], + key: de_ctx[1], + actionid: de_ctx[2], + }; + } + + /** + * Send JSON params to StreamDeck + * @param {string} cmd + * @param {object} params + */ + send(cmd, params) { + console.warn(`[ULANZIDECK] send: ${JSON.stringify({ + cmd, + uuid: this.uuid, + key: this.key, + actionid: this.actionid, + ...params, + })}`); + this.websocket && this.websocket.readyState === WebSocket.OPEN && + this.websocket.send( + JSON.stringify({ + cmd, + uuid: this.uuid, + key: this.key, + actionid: this.actionid, + ...params, + }) + ); + } + + /** + * 向上位机发送配置参数 + * @param {object} settings 必传 | 配置参数 + * @param {object} context 可选 | 唯一id。非必传,由action页面发出时可以不传,由主服务发出必传 + */ + sendParamFromPlugin(settings, context) { + const { uuid, key, actionid } = context ? this.decodeContext(context) : {}; + this.send(Events.PARAMFROMPLUGIN, { + uuid: uuid || this.uuid, + key: key || this.key, + actionid: actionid || this.actionid, + param: settings, + }); + } + + /** + * 请求上位机使⽤浏览器打开url + * @param {string} url 必传 | 直接远程地址和本地地址,⽀持打开插件根⽬录下的url链接(以/ ./ 起始的链接)。 + * 只能是基本路径,不能带参数,需要带参数请设置在param值里面 + * @param {local} boolean 可选 | 若为本地地址为true + * @param {object} param 可选 | 路径的参数值 + */ + openUrl(url, local, param) { + this.send(Events.OPENURL, { + url, + local: local ? true : false, + param: param ? param : null, + }); + } + + /** + * 请求上位机机显⽰弹窗;弹窗后,test.html需要主动关闭,测试到window.close()可以通知弹窗关闭 + * @param {string} url 必传 | 本地html路径,只能是基本路径,不能带参数,需要带参数请设置在param值里面 + * @param {string} width 可选 | 窗口宽度,默认200 + * @param {string} height 可选 | 窗口高度,默认200 + * @param {string} x 可选 | 窗口x坐标,不传值默认居中 + * @param {string} y 可选 | 窗口y坐标,不传值默认居中 + * @param {object} param 可选 | 路径的参数值 + */ + openView(url, width = 200, height = 200, x, y, param) { + const params = { + url, + width, + height, + }; + if (x) { + params.x = x; + } + if (y) { + params.y = y; + } + if (param) { + params.param = param; + } + this.send(Events.OPENVIEW, params); + } + + /** + * 请求上位机弹出Toast消息提⽰ + * @param {string} msg 必传 | 窗口级消息提示 + */ + toast(msg) { + this.send(Events.TOAST, { + msg, + }); + } + + /** + * 请求上位机弹出快捷键 + * @param {string} key 必传 | 快捷键 + */ + hotkey(key) { + this.send(Events.HOTKEY, { + keylist: key, + }); + } + + /** + * 请求上位机弹出日志消息提⽰ + * @param {string} msg 必传 | 保存到插件UUID.txt中 + * @param {string} level 可选 | 日志级别 info|debug|warn|error + */ + logMessage(msg, level) { + this.send(Events.LOGMESSAGE, { + message: msg, + level: level || "info", + }); + } + /** + * 主服务发出,上位机透传参数到action页面,此透传参数上位机不保存 + * @param {object} settings 必传 | 设置 + * @param {string} context 必传 | 唯一id,需要指定发送到哪个action + */ + sendToPropertyInspector(settings, context) { + const { uuid, key, actionid } = context ? this.decodeContext(context) : {}; + this.send(Events.SENDTOPROPERTYINSPECTOR, { + uuid: uuid, + key: key, + actionid: actionid, + payload: settings, + }); + } + + /** + * action页面发出,上位机透传参数到主服务,此透传参数上位机不保存 + * @param {object} settings 必传 | 设置 + */ + sendToPlugin(settings) { + this.send(Events.SENDTOPLUGIN, { + uuid: this.uuid, + key: this.key, + actionid: this.actionid, + payload: settings, + }); + } + + /** + * 请求上位机在按键上显示错误提示 + * @param {string} context 可选 | 唯一id。非必传,由action页面发出时可以不传,由主服务发出必传 + */ + showAlert(context) { + const { uuid, key, actionid } = context ? this.decodeContext(context) : {}; + this.send(Events.SHOWALERT, { + uuid: uuid || this.uuid, + key: key || this.key, + actionid: actionid || this.actionid, + }); + } + + /** + * 请求上位机发送已保存的参数,上位机接收后会触发didReceiveSettings事件转发至另一端 + * @param {string} context 可选 | 唯一id。非必传,由action页面发出时可以不传,由主服务发出必传 + */ + getSettings(context) { + const { uuid, key, actionid } = context ? this.decodeContext(context) : {}; + this.send(Events.GETSETTINGS, { + uuid: uuid || this.uuid, + key: key || this.key, + actionid: actionid || this.actionid, + }); + } + + /** + * 主动向上位机保存参数,上位机接收后会触发didReceiveSettings事件转发至另一端 + * @param {object} settings 必传 | 配置参数 + * @param {string} context 可选 | 唯一id。非必传,由action页面发出时可以不传,由主服务发出必传 + */ + setSettings(settings, context) { + console.warn('===---setSettings:', JSON.stringify(settings), context) + const { uuid, key, actionid } = context ? this.decodeContext(context) : {}; + this.send(Events.SETSETTINGS, { + uuid: uuid || this.uuid, + key: key || this.key, + actionid: actionid || this.actionid, + settings, + }); + } + + /** + * 请求上位机发送已保存的全局参数,上位机接收后会触发didReceiveGlobalSettings事件转发至另一端 + * @param {string} context 可选 | 唯一id。非必传,由action页面发出时可以不传,由主服务发出必传 + */ + getGlobalSettings(context) { + const { uuid, key, actionid } = context ? this.decodeContext(context) : {}; + this.send(Events.GETGLOBALSETTINGS, { + uuid: uuid || this.uuid, + key: key || this.key, + actionid: actionid || this.actionid, + }); + } + + /** + * 主动向上位机保存参数,上位机接收后会触发didReceiveGlobalSettings事件转发至另一端 + * @param {object} settings 必传 | 配置参数 + * @param {string} context 可选 | 唯一id。非必传,由action页面发出时可以不传,由主服务发出必传 + */ + setGlobalSettings(settings, context) { + const { uuid, key, actionid } = context ? this.decodeContext(context) : {}; + this.send(Events.SETGLOBALSETTINGS, { + uuid: uuid || this.uuid, + key: key || this.key, + actionid: actionid || this.actionid, + settings, + }); + } + + /** + * 请求上位机弹出选择对话框:选择文件 + * @param {string} filter 可选 | 文件过滤器。筛选文件的类型,例如 "filter": "image(*.jpg *.png *.gif)" 或者 筛选文件 file(*.txt *.json) 等 + * 该请求的选择结果请通过 onSelectdialog 事件接收 + */ + selectFileDialog(filter) { + this.send(Events.SELECTDIALOG, { + type: "file", + filter, + }); + } + + /** + * 请求上位机弹出选择对话框:选择文件夹 + * 该请求的选择结果请通过 onSelectdialog 事件接收 + */ + selectFolderDialog() { + this.send(Events.SELECTDIALOG, { + type: "folder", + }); + } + + /** + * 设置图标-使⽤配置⾥的图标列表编号,请对照manifest.json + * @param {string} context 必传 |唯一id,每个message里面common库会自动拼接给出 + * @param {number} state 必传 | 图标列表编号, + * @param {string} text 可选 | icon是否显示文字 + */ + setStateIcon(context, state, text) { + const { uuid, key, actionid } = this.decodeContext(context); + this.send(Events.STATE, { + param: { + statelist: [ + { + uuid, + key, + actionid, + type: 0, + state, + textData: text || "", + showtext: text ? true : false, + }, + ], + }, + }); + } + + /** + * 设置图标-使⽤⾃定义图标 + * @param {string} context 必传 |唯一id,每个message里面common库会自动拼接给出 + * @param {string} data 必传 | base64格式的icon + * @param {string} text 可选 | icon是否显示文字 + */ + setBaseDataIcon(context, data, text) { + const { uuid, key, actionid } = this.decodeContext(context); + this.send(Events.STATE, { + param: { + statelist: [ + { + uuid, + key, + actionid, + type: 1, + data, + textData: text || "", + showtext: text ? true : false, + }, + ], + }, + }); + } + + /** + * 设置图标-使⽤本地图片文件 + * @param {string} context 必传 |唯一id,每个message里面common库会自动拼接给出 + * @param {string} path 必传 | 本地图片路径,⽀持打开插件根⽬录下的url链接(以/ ./ 起始的链接) + * @param {string} text 可选 | icon是否显示文字 + */ + setPathIcon(context, path, text) { + const { uuid, key, actionid } = this.decodeContext(context); + this.send(Events.STATE, { + param: { + statelist: [ + { + uuid, + key, + actionid, + type: 2, + path, + textData: text || "", + showtext: text ? true : false, + }, + ], + }, + }); + } + + /** + * 设置图标-使⽤⾃定义的动图 + * @param {string} context 必传 |唯一id,每个message里面common库会自动拼接给出 + * @param {string} gifdata 必传 | ⾃定义gif的base64编码数据 + * @param {string} text 可选 | icon是否显示文字 + */ + setGifDataIcon(context, gifdata, text) { + const { uuid, key, actionid } = this.decodeContext(context); + this.send(Events.STATE, { + param: { + statelist: [ + { + uuid, + key, + actionid, + type: 3, + gifdata, + textData: text || "", + showtext: text ? true : false, + }, + ], + }, + }); + } + + /** + * 设置图标-使⽤本地gif⽂件 + * @param {string} context 必传 |唯一id,每个message里面common库会自动拼接给出, + * @param {string} gifdata 必传 | 本地gif图片路径,⽀持打开插件根⽬录下的url链接(以/ ./ 起始的链接) + * @param {string} text 可选 | icon是否显示文字 + */ + setGifPathIcon(context, gifpath, text) { + const { uuid, key, actionid } = this.decodeContext(context); + this.send(Events.STATE, { + param: { + statelist: [ + { + uuid, + key, + actionid, + type: 4, + gifpath, + textData: text || "", + showtext: text ? true : false, + }, + ], + }, + }); + } + + /** + * 监听socket连接事件 + * @param {import('../apiTypes.d.ts').OnConnected} fn + */ + onConnected(fn) { + if (!fn) { + Utils.error( + "A callback function for the connected event is required for onConnected." + ); + } + + this.on(Events.CONNECTED, (jsn) => fn(jsn)); + return this; + } + + /** + * 监听socket断开事件 + * @param {import('../apiTypes.d.ts').OnClose} fn + */ + onClose(fn) { + if (!fn) { + Utils.error( + "A callback function for the close event is required for onClose." + ); + } + + this.on(Events.CLOSE, (jsn) => fn(jsn)); + return this; + } + + /** + * 监听socket错误事件 + * @param {import('../apiTypes.d.ts').OnError} fn + */ + onError(fn) { + if (!fn) { + Utils.error( + "A callback function for the error event is required for onError." + ); + } + + this.on(Events.ERROR, (jsn) => fn(jsn)); + return this; + } + + /** + * 接收上位机事件:add + * @param {import('../apiTypes.d.ts').OnCmdAddResp} fn + */ + onAdd(fn) { + if (!fn) { + Utils.error( + "A callback function for the add event is required for onAdd." + ); + } + + this.on(Events.ADD, (jsn) => fn(jsn)); + return this; + } + + /** + * 接收上位机事件:paramfromapp + * @param {import('../apiTypes.d.ts').OnCmdParamFromAppResp} fn + */ + onParamFromApp(fn) { + if (!fn) { + Utils.error( + "A callback function for the paramfromapp event is required for onParamFromApp." + ); + } + + this.on(Events.PARAMFROMAPP, (jsn) => fn(jsn)); + return this; + } + + /** + * 接收上位机事件:paramfromplugin + * @param {import('../apiTypes.d.ts').OnCmdParamFromPluginResp} fn + */ + onParamFromPlugin(fn) { + if (!fn) { + Utils.error( + "A callback function for the paramfromplugin event is required for onParamFromPlugin." + ); + } + + this.on(Events.PARAMFROMPLUGIN, (jsn) => fn(jsn)); + return this; + } + + /** + * 接收上位机事件:run + * @param {import('../apiTypes.d.ts').OnCmdRunResp} fn + */ + onRun(fn) { + if (!fn) { + Utils.error( + "A callback function for the run event is required for onRun." + ); + } + + this.on(Events.RUN, (jsn) => fn(jsn)); + return this; + } + + /** + * 接收上位机事件:setactive + * @param {import('../apiTypes.d.ts').OnCmdSetActiveResp} fn + */ + onSetActive(fn) { + if (!fn) { + Utils.error( + "A callback function for the setactive event is required for onSetActive." + ); + } + + this.on(Events.SETACTIVE, (jsn) => fn(jsn)); + return this; + } + + /** + * 接收上位机事件:clear + * @param {import('../apiTypes.d.ts').OnCmdClearResp} fn + */ + onClear(fn) { + if (!fn) { + Utils.error( + "A callback function for the clear event is required for onClear." + ); + } + + this.on(Events.CLEAR, (jsn) => fn(jsn)); + return this; + } + + /** + * 接收上位机事件:返回选择弹窗结果 + * @param {import('../apiTypes.d.ts').OnCmdSelectDialogResp} fn + */ + onSelectdialog(fn) { + if (!fn) { + Utils.error( + "A callback function for the selectdialog event is required for onSelectdialog." + ); + } + + this.on(Events.SELECTDIALOG, (jsn) => fn(jsn)); + return this; + } + + /** + * 接收上位机事件:didReceiveSettings, 接受上位机保存的参数 + */ + onDidReceiveSettings(fn) { + if (!fn) { + Utils.error( + "A callback function for the didReceiveSettings event is required for onDidReceiveSettings." + ); + } + this.on(Events.DIDRECEIVESETTINGS, (jsn) => fn(jsn)); + return this; + } + + + /** + * didReceiveGlobalSettings, 接受全局设置的参数 + */ + onDidReceiveGlobalSettings(fn) { + if (!fn) { + Utils.error( + "A callback function for the didReceiveGlobalSettings event is required for onDidReceiveGlobalSettings." + ); + } + this.on(Events.DIDRECEIVEGLOBALSETTINGS, (jsn) => fn(jsn)); + return this; + } + + /** + * + * 接收 主服务发给功能页的透传参数事件 + */ + onSendToPropertyInspector(fn) { + if (!fn) { + Utils.error( + "A callback function for the sendToPropertyInspector event is required for onSendToPropertyInspector." + ); + } + this.on(Events.SENDTOPROPERTYINSPECTOR, (jsn) => fn(jsn)); + return this; + } + + /** + * + * 接收 功能页发给主服务的透传参数事件 + */ + onSendToPlugin(fn) { + if (!fn) { + Utils.error( + "A callback function for the sendToPlugin event is required for onSendToPlugin." + ); + } + this.on(Events.SENDTOPLUGIN, (jsn) => fn(jsn)); + return this; + } + + /** + * 接收上位机事件:keydown, 接收上位机按键按下事件 + */ + onKeyDown(fn) { + if (!fn) { + Utils.error( + "A callback function for the keydown event is required for onKeyDown." + ); + } + this.on(Events.KEYDOWN, (jsn) => fn(jsn)); + return this; + } + /** + * 接收上位机事件:keyup, 接收上位机按键松开事件 + */ + onKeyUp(fn) { + if (!fn) { + Utils.error( + "A callback function for the keyup event is required for onKeyUp." + ); + } + this.on(Events.KEYUP, (jsn) => fn(jsn)); + return this; + } + /** + * 接收上位机事件:dialdown, 接收上位机旋钮按下事件 + */ + onDialDown(fn) { + if (!fn) { + Utils.error( + "A callback function for the dialdown event is required for onDialDown." + ); + } + this.on(Events.DIALEDOWN, (jsn) => fn(jsn)); + return this; + } + /** + * 接收上位机事件:dialup, 接收上位机旋钮松开事件 + */ + onDialUp(fn) { + if (!fn) { + Utils.error( + "A callback function for the dialup event is required for onDialUp." + ); + } + this.on(Events.DIALEUP, (jsn) => fn(jsn)); + return this; + } + /** + * 接收上位机事件:dialrotate, 接收上位机旋钮向左旋转事件 + */ + onDialRotateLeft(fn) { + if (!fn) { + Utils.error( + "A callback function for the dialrotate left event is required for onDialRotateLeft." + ); + } + this.on(Events.DIALROTATE, (jsn) => { + if (jsn.rotateEvent === "left") { + fn(jsn); + } + }); + return this; + } + + /** + * 接收上位机事件:dialrotate, 接收上位机旋钮向右旋转事件 + */ + onDialRotateRight(fn) { + if (!fn) { + Utils.error( + "A callback function for the dialrotate right event is required for onDialRotateRight." + ); + } + this.on(Events.DIALROTATE, (jsn) => { + if (jsn.rotateEvent === "right") { + fn(jsn); + } + }); + return this; + } + + /** + * 接收上位机事件:dialrotate, 接收上位机旋钮按住向左旋转事件 + */ + onDialRotateHoldLeft(fn) { + if (!fn) { + Utils.error( + "A callback function for the dialrotate hold-left event is required for onDialRotateHoldLeft." + ); + } + this.on(Events.DIALROTATE, (jsn) => { + if (jsn.rotateEvent === "hold-left") { + fn(jsn); + } + }); + return this; + } + + /** + * 接收上位机事件:dialrotate, 接收上位机旋钮按住向右旋转事件 + */ + onDialRotateHoldRight(fn) { + if (!fn) { + Utils.error( + "A callback function for the dialrotate hold-right event is required for onDialRotateHoldRight." + ); + } + this.on(Events.DIALROTATE, (jsn) => { + // 注意:原数据中有个拼写错误"hold—right",这里使用正确的连字符 + if (jsn.rotateEvent === "hold-right") { + fn(jsn); + } + }); + return this; + } + + /** + * 接收上位机事件:dialrotate, 接收上位机旋钮旋转事件 + */ + onDialRotate(fn) { + if (!fn) { + Utils.error( + "A callback function for the dialrotate event is required for onDialRotate." + ); + } + this.on(Events.DIALROTATE, (jsn) => fn(jsn)); + return this; + } +} + + diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/plugin/vendor/ulanzi-api/utils.js b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/plugin/vendor/ulanzi-api/utils.js new file mode 100644 index 0000000..596d46e --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/plugin/vendor/ulanzi-api/utils.js @@ -0,0 +1,225 @@ +class UlanziUtils { + + + /** + * 获取表单数据 + * Returns the value from a form using the form controls name property + * @param {Element | string} form + * @returns + */ + getFormValue(form) { + if (typeof form === 'string') { + form = document.querySelector(form); + } + + const elements = form?.elements; + + if (!elements) { + console.error('Could not find form!'); + } + + const formData = new FormData(form); + let formValue = {}; + + formData.forEach((value, key) => { + if (!Reflect.has(formValue, key)) { + formValue[key] = value; + return; + } + if (!Array.isArray(formValue[key])) { + formValue[key] = [formValue[key]]; + } + formValue[key].push(value); + }); + + return formValue; + } + + /** + * 重载表单数据 + * Sets the value of form controls using their name attribute and the jsn object key + * @param {*} jsn + * @param {Element | string} form + */ + setFormValue(jsn, form) { + if (!jsn) { + return; + } + + if (typeof form === 'string') { + form = document.querySelector(form); + } + + const elements = form?.elements; + + if (!elements) { + console.error('Could not find form!'); + } + + Array.from(elements) + .filter((element) => element?.name) + .forEach((element) => { + const { name, type } = element; + const value = name in jsn ? jsn[name] : null; + const isCheckOrRadio = type === 'checkbox' || type === 'radio'; + + if (value === null) return; + + if (isCheckOrRadio) { + const isSingle = value === element.value; + if (isSingle || (Array.isArray(value) && value.includes(element.value))) { + element.checked = true; + } + } else { + element.value = value ?? ''; + } + }); + } + + /** + * 防抖 + * This provides a slight delay before processing rapid events + * @param {function} fn + * @param {number} wait - delay before processing function (recommended time 150ms) + * @returns + */ + debounce(fn, wait = 150) { + let timeoutId = null + return (...args) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + fn.apply(null, args); + }, wait); + }; + } + + + + /** + * JSON.parse优化 + * parse json + * @param {string} jsonString + * @returns {object} json + */ + parseJson(jsonString) { + if (typeof jsonString === 'object') return jsonString; + try { + const o = JSON.parse(jsonString); + if (o && typeof o === 'object') { + return o; + } + } catch (e) { } + + return false; + } + + + /** + * 获取随机时间戳 + */ + joinTimestamp() { + const now = new Date().getTime(); + return { _t: now }; + } + + /** + * 适配语言环境 + */ + adaptLanguage(ln) { + let userLanguage = ln; + if (ln.indexOf('zh') == 0) { + if(ln.indexOf('CN') > -1){ + userLanguage = 'zh_CN' + }else{ + userLanguage = 'zh_HK' + } + } else if (ln.indexOf('en') == 0) { + userLanguage = 'en' + } else if (userLanguage.indexOf('-') !== -1) { + userLanguage = userLanguage.replace(/-/g, '_'); + } + + return userLanguage + } + + /** + * 获取插件根目录路径 + */ + getPluginPath() { + const currentFilePath = process.argv[1]; + let split_tag = '/' + if (currentFilePath.indexOf('\\') > -1) { + split_tag = '\\' + } + const pathArr = currentFilePath.split(split_tag); + const idx = pathArr.findIndex(f => f.endsWith('ulanziPlugin')); + const __folderpath = `${pathArr.slice(0, idx + 1).join("/")}`; + + return __folderpath; + + } + + // 获取运行环境系统类型 + getSystemType() { + return process.platform === 'win32' ? 'windows' : 'mac'; + + } + getProperty(obj, dotSeparatedKeys, defaultValue) { + if (arguments.length > 1 && typeof dotSeparatedKeys !== 'string') return undefined; + if (typeof obj !== 'undefined' && typeof dotSeparatedKeys === 'string') { + const pathArr = dotSeparatedKeys.split('.'); + pathArr.forEach((key, idx, arr) => { + if (typeof key === 'string' && key.includes('[')) { + try { + // extract the array index as string + const pos = /\[([^)]+)\]/.exec(key)[1]; + // get the index string length (i.e. '21'.length === 2) + const posLen = pos.length; + arr.splice(idx + 1, 0, Number(pos)); + + // keep the key (array name) without the index comprehension: + // (i.e. key without [] (string of length 2) + // and the length of the index (posLen)) + arr[idx] = key.slice(0, -2 - posLen); // eslint-disable-line no-param-reassign + } catch (e) { + // do nothing + } + } + }); + // eslint-disable-next-line no-param-reassign, no-confusing-arrow + obj = pathArr.reduce((o, key) => (o && o[key] !== 'undefined' ? o[key] : undefined), obj); + } + return obj === undefined ? defaultValue : obj; + }; + + getProp(jsn, str, defaultValue = {}, sep = '.') { + const arr = str.split(sep); + return arr.reduce((obj, key) => (obj && obj.hasOwnProperty(key) ? obj[key] : defaultValue), jsn); + }; + + + + /** + * Logs a message + * @param {any} msg + */ + log(...msg) { + console.log(`[${new Date().toLocaleString('zh-CN', { hour12: false })}]`, ...msg); + } + + /** + * Logs a warning message + */ + warn(...msg) { + console.warn(`[${new Date().toLocaleString('zh-CN', { hour12: false })}]`, ...msg); + } + + /** + * Logs an error message + */ + error(...msg) { + console.error(`[${new Date().toLocaleString('zh-CN', { hour12: false })}]`, ...msg); + } +} +const Utils = new UlanziUtils(); +export default Utils \ No newline at end of file diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/property-inspector/inspector.css b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/property-inspector/inspector.css new file mode 100644 index 0000000..d6060f4 --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/property-inspector/inspector.css @@ -0,0 +1,95 @@ +body { + background: var(--uspi-bodybg); +} + +.udpi-wrapper { + width: 100%; + max-width: 560px; + padding: 14px 10px 22px; +} + +.panel-title { + margin: 0 10px 14px; + font-size: 18px; + line-height: 24px; +} + +.uspi-item { + align-items: flex-start; +} + +.uspi-item-label { + padding-top: 6px; +} + +textarea.uspi-item-value { + min-height: 132px; + max-height: 260px; + resize: vertical; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + line-height: 19px; +} + +textarea.environment-field { + min-height: 94px; +} + +.field-hint, +.security-notice, +.diagnostic { + margin: 4px 10px 10px 132px; + color: var(--uspi-unitcolor); + font-size: 12px; + line-height: 17px; +} + +.diagnostic { + min-height: 17px; + color: #ff8a8a; +} + +.preview { + margin: 4px 10px 14px 132px; + padding: 10px; + overflow-x: auto; + border: 1px solid var(--uspi-bordercolor); + border-radius: var(--uspi-borderradius); + background: #15171c; + color: #d8dee9; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + line-height: 18px; + white-space: pre-wrap; + word-break: break-word; +} + +.section-label { + margin: 16px 10px 6px 132px; + color: var(--uspi-textcolor); + font-weight: 600; +} + +.security-notice { + padding-left: 20px; + color: #f1c96b; +} + +@media (max-width: 460px) { + .uspi-item { + display: block; + } + + .uspi-item-label, + .uspi-item-value { + width: 100%; + text-align: left; + } + + .field-hint, + .security-notice, + .diagnostic, + .preview, + .section-label { + margin-left: 10px; + } +} diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/property-inspector/inspector.html b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/property-inspector/inspector.html new file mode 100644 index 0000000..fa5fe38 --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/property-inspector/inspector.html @@ -0,0 +1,127 @@ + + + + + + 命令执行器 + + + + + + + + + + + + + + + diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/property-inspector/inspector.js b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/property-inspector/inspector.js new file mode 100644 index 0000000..cb9e291 --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/property-inspector/inspector.js @@ -0,0 +1,86 @@ +(function () { + "use strict"; + + const ACTION_UUID = + "com.ulanzi.ulanzistudio.commandexecutor.runcommand"; + const { + normalizeSettings, + validateEnvironmentText, + buildExecutionPreview + } = globalThis.CommandExecutorSettings; + const api = $UD; + let currentSettings = normalizeSettings(); + let form = null; + let validationMessage = null; + let executionPreview = null; + let dirty = false; + + function updateDiagnostics() { + validationMessage.textContent = validateEnvironmentText( + currentSettings.environment, + api.language + ); + executionPreview.textContent = buildExecutionPreview( + currentSettings, + api.language + ); + } + + function renderSettings() { + if (!form) { + return; + } + Utils.setFormValue(currentSettings, form); + updateDiagnostics(); + } + + function applyIncomingSettings(value) { + if (dirty) { + return; + } + currentSettings = normalizeSettings(value); + renderSettings(); + } + + function captureSettings() { + currentSettings = normalizeSettings(Utils.getFormValue(form)); + dirty = true; + updateDiagnostics(); + } + + function flushSettings() { + if (!dirty) { + return; + } + api.sendParamFromPlugin(currentSettings); + dirty = false; + } + + const debouncedFlush = Utils.debounce(flushSettings, 200); + + function handleInput() { + captureSettings(); + debouncedFlush(); + } + + function handleChange() { + captureSettings(); + flushSettings(); + } + + api.connect(ACTION_UUID); + api.onConnected(() => { + form = document.querySelector("#property-inspector"); + validationMessage = document.querySelector("#validation-message"); + executionPreview = document.querySelector("#execution-preview"); + document + .querySelector(".udpi-wrapper") + .classList.remove("hidden"); + form.addEventListener("input", handleInput); + form.addEventListener("change", handleChange); + renderSettings(); + }); + api.onAdd((message) => applyIncomingSettings(message?.param)); + api.onParamFromApp((message) => applyIncomingSettings(message?.param)); + window.addEventListener("pagehide", flushSettings); +}()); diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/property-inspector/settings.js b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/property-inspector/settings.js new file mode 100644 index 0000000..e7bdf5e --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/property-inspector/settings.js @@ -0,0 +1,134 @@ +(function () { + "use strict"; + + const ENVIRONMENT_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; + const DEFAULT_SETTINGS = Object.freeze({ + title: "执行命令", + command: "", + workingDirectory: "", + environment: "" + }); + const TEXT = Object.freeze({ + en: Object.freeze({ + environmentLine: "Environment variable line", + nul: "contains an unsupported NUL character", + missingEquals: "is missing an equals sign", + invalidName: "has an invalid name", + workingDirectory: "Working directory", + environment: "Environment variables", + command: "Command", + none: "None", + empty: "(empty)" + }), + zh_CN: Object.freeze({ + environmentLine: "环境变量第", + nul: "行包含不支持的 NUL 字符", + missingEquals: "行缺少等号", + invalidName: "行格式错误", + workingDirectory: "工作目录", + environment: "环境变量", + command: "命令", + none: "无", + empty: "(空)" + }) + }); + + function getText(locale) { + return typeof locale === "string" && + locale.toLowerCase().startsWith("en") + ? TEXT.en + : TEXT.zh_CN; + } + + function normalizeSettings(value = {}) { + const settings = value && typeof value === "object" ? value : {}; + const title = + typeof settings.title === "string" ? settings.title.trim() : ""; + + return { + title: title || DEFAULT_SETTINGS.title, + command: + typeof settings.command === "string" ? settings.command : "", + workingDirectory: + typeof settings.workingDirectory === "string" + ? settings.workingDirectory + : "", + environment: + typeof settings.environment === "string" ? settings.environment : "" + }; + } + + function validateEnvironmentText(text = "", locale = "zh_CN") { + const lines = String(text).split(/\r?\n/); + const messages = getText(locale); + const messageForLine = (line, message) => + `${messages.environmentLine} ${line} ${message}`; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (line.trim() === "") { + continue; + } + if (line.includes("\0")) { + return messageForLine(index + 1, messages.nul); + } + + const separator = line.indexOf("="); + if (separator < 0) { + return messageForLine(index + 1, messages.missingEquals); + } + + const name = line.slice(0, separator).trim(); + if (!ENVIRONMENT_NAME.test(name)) { + return messageForLine(index + 1, messages.invalidName); + } + } + + return ""; + } + + function listEnvironmentNames(text) { + const names = []; + const knownNames = new Set(); + + String(text).split(/\r?\n/).forEach((line) => { + const separator = line.indexOf("="); + const name = separator >= 0 ? line.slice(0, separator).trim() : ""; + if ( + !line.includes("\0") && + ENVIRONMENT_NAME.test(name) && + !knownNames.has(name) + ) { + knownNames.add(name); + names.push(name); + } + }); + + return names; + } + + function buildExecutionPreview(value = {}, locale = "zh_CN") { + const settings = normalizeSettings(value); + const environmentNames = listEnvironmentNames(settings.environment); + const messages = getText(locale); + + return [ + "Shell: $SHELL -lc", + `${messages.workingDirectory}: ${settings.workingDirectory || "$HOME"}`, + `${messages.environment}: ${ + environmentNames.length > 0 + ? environmentNames.join(", ") + : messages.none + }`, + `${messages.command}:`, + settings.command || messages.empty + ].join("\n"); + } + + globalThis.CommandExecutorSettings = Object.freeze({ + DEFAULT_SETTINGS, + normalizeSettings, + validateEnvironmentText, + buildExecutionPreview + }); +}()); diff --git a/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/zh_CN.json b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/zh_CN.json new file mode 100644 index 0000000..8c8ff07 --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/com.ulanzi.commandexecutor.ulanziPlugin/zh_CN.json @@ -0,0 +1,17 @@ +{ + "Localization": { + "page.title": "命令执行器", + "field.title.label": "按键标题", + "field.title.placeholder": "执行命令", + "field.command.label": "命令(含参数)", + "field.command.placeholder": "输入完整 Shell 命令", + "field.command.hint": "支持完整 Shell 语法、多行命令及包含 100~200 个参数的长命令。", + "field.cwd.label": "工作目录", + "field.cwd.placeholder": "留空使用 $HOME,或填写绝对路径、~/...", + "field.environment.label": "环境变量", + "field.environment.placeholder": "每行一个 NAME=VALUE", + "field.environment.hint": "按第一个等号拆分;同名变量以后出现的值为准。", + "preview.title": "执行预览", + "security.notice": "安全提示:命令将以当前 macOS 用户权限执行,保存前请确认命令来源可信。" + } +} diff --git a/plugins/unlanzi_d200x/command_executor/package-lock.json b/plugins/unlanzi_d200x/command_executor/package-lock.json new file mode 100644 index 0000000..e2158a2 --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/package-lock.json @@ -0,0 +1,1621 @@ +{ + "name": "life-tools-ulanzi-command-executor", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "life-tools-ulanzi-command-executor", + "version": "0.1.0", + "dependencies": { + "ws": "8.18.0" + }, + "devDependencies": { + "webpack": "5.94.0", + "webpack-cli": "5.1.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webpack-cli/configtest": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-2.1.1.tgz", + "integrity": "sha512-wy0mglZpDSiSS0XHrVR+BAdId2+yxPSoJW8fsna3ZpYSlufjvxnP4YbKTCBZnNIcGN4r6ZPXV55X4mYExOfLmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.15.0" + }, + "peerDependencies": { + "webpack": "5.x.x", + "webpack-cli": "5.x.x" + } + }, + "node_modules/@webpack-cli/info": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-2.0.2.tgz", + "integrity": "sha512-zLHQdI/Qs1UyT5UBdWNqsARasIA+AaF8t+4u2aS2nEpBQh2mWIVb8qAklq0eUENnC5mOItrIB4LiS9xMtph18A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.15.0" + }, + "peerDependencies": { + "webpack": "5.x.x", + "webpack-cli": "5.x.x" + } + }, + "node_modules/@webpack-cli/serve": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-2.0.5.tgz", + "integrity": "sha512-lqaoKnRYBdo1UgDX8uF24AfGMifWK19TxPmM5FHc2vAGxrJ/qtyUyFBWoY1tISZdelsQ5fBcOusifo5o5wSJxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.15.0" + }, + "peerDependencies": { + "webpack": "5.x.x", + "webpack-cli": "5.x.x" + }, + "peerDependenciesMeta": { + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz", + "integrity": "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.396", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz", + "integrity": "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.24.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz", + "integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/envinfo": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.21.0.tgz", + "integrity": "sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow==", + "dev": true, + "license": "MIT", + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/interpret": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/loader-runner": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.49.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.49.0.tgz", + "integrity": "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/terser-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/watchpack": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", + "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack": { + "version": "5.94.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", + "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.5", + "@webassemblyjs/ast": "^1.12.1", + "@webassemblyjs/wasm-edit": "^1.12.1", + "@webassemblyjs/wasm-parser": "^1.12.1", + "acorn": "^8.7.1", + "acorn-import-attributes": "^1.9.5", + "browserslist": "^4.21.10", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.17.1", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.2.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.3.10", + "watchpack": "^2.4.1", + "webpack-sources": "^3.2.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-cli": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", + "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@discoveryjs/json-ext": "^0.5.0", + "@webpack-cli/configtest": "^2.1.1", + "@webpack-cli/info": "^2.0.2", + "@webpack-cli/serve": "^2.0.5", + "colorette": "^2.0.14", + "commander": "^10.0.1", + "cross-spawn": "^7.0.3", + "envinfo": "^7.7.3", + "fastest-levenshtein": "^1.0.12", + "import-local": "^3.0.2", + "interpret": "^3.1.1", + "rechoir": "^0.8.0", + "webpack-merge": "^5.7.3" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "5.x.x" + }, + "peerDependenciesMeta": { + "@webpack-cli/generators": { + "optional": true + }, + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/webpack-cli/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/webpack-merge": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", + "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.1.tgz", + "integrity": "sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/plugins/unlanzi_d200x/command_executor/package.json b/plugins/unlanzi_d200x/command_executor/package.json new file mode 100644 index 0000000..9fd9a6b --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/package.json @@ -0,0 +1,21 @@ +{ + "name": "life-tools-ulanzi-command-executor", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "test": "node --test tests/*.test.mjs", + "bundle": "webpack --config webpack.config.js", + "build": "./build.sh" + }, + "dependencies": { + "ws": "8.18.0" + }, + "devDependencies": { + "webpack": "5.94.0", + "webpack-cli": "5.1.4" + } +} diff --git a/plugins/unlanzi_d200x/command_executor/scripts/validate-package.mjs b/plugins/unlanzi_d200x/command_executor/scripts/validate-package.mjs new file mode 100644 index 0000000..e24b6ca --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/scripts/validate-package.mjs @@ -0,0 +1,316 @@ +import { + lstat, + readFile, + readdir, + realpath +} from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const PLUGIN_NAME = "com.ulanzi.commandexecutor.ulanziPlugin"; +const COMMAND_EXECUTOR_ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + ".." +); +const SOURCE_PLUGIN_ROOT = path.join(COMMAND_EXECUTOR_ROOT, PLUGIN_NAME); +const WORKTREE_ROOT = path.resolve(COMMAND_EXECUTOR_ROOT, "../../.."); +const REQUIRED_VENDOR_FILES = [ + "plugin/vendor/ulanzi-api/constants.js", + "plugin/vendor/ulanzi-api/ulanziApi.js", + "plugin/vendor/ulanzi-api/utils.js", + "libs/assets/u_active.svg", + "libs/assets/u_active_none.svg", + "libs/assets/u_check_checkbox.svg", + "libs/assets/u_check_none.svg", + "libs/assets/u_check_radio.svg", + "libs/assets/u_down.svg", + "libs/assets/u_file.svg", + "libs/assets/u_folder.svg", + "libs/assets/u_refresh.svg", + "libs/assets/u_tip_error.svg", + "libs/assets/u_tip_info.svg", + "libs/assets/u_tip_success.svg", + "libs/assets/u_tip_warn.svg", + "libs/css/uspi.css", + "libs/js/constants.js", + "libs/js/eventEmitter.js", + "libs/js/timers.js", + "libs/js/ulanziApi.js", + "libs/js/utils.js" +]; +const REQUIRED_RUNTIME_FILES = [ + "manifest.json", + "package.json", + "THIRD_PARTY_NOTICES.md", + "LICENSES/UlanziDeckPlugin-SDK-APACHE-2.0.txt", + "plugin/app.js", + "plugin/command-plugin.js", + "plugin/command-runner.js", + "property-inspector/inspector.html", + "property-inspector/inspector.css", + "property-inspector/settings.js", + "property-inspector/inspector.js", + "en.json", + "zh_CN.json", + ...REQUIRED_VENDOR_FILES +]; +const SDK_COMMITS = [ + "112bd13a7ff9d45bd68656f7e069fd61851d1812", + "79de0b0b087546e684afd23f97223f7a7bc392da", + "550ab80c69285ecf259bd494a7fff767c14f0c0f" +]; + +function isInside(root, candidate) { + const relative = path.relative(root, candidate); + return ( + relative === "" || + (relative !== ".." && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative)) + ); +} + +async function readJson(filePath, label) { + let source; + try { + source = await readFile(filePath, "utf8"); + } catch (error) { + throw new Error(`${label} is missing or unreadable: ${error.message}`); + } + + try { + return JSON.parse(source); + } catch (error) { + throw new Error(`${label} is not valid JSON: ${error.message}`); + } +} + +async function requireNonEmptyFile(filePath, label) { + let fileStat; + try { + fileStat = await lstat(filePath); + } catch { + throw new Error(`${label} must be a non-empty regular file`); + } + if (fileStat.isSymbolicLink()) { + throw new Error(`symbolic links are forbidden: ${label}`); + } + if (!fileStat.isFile() || fileStat.size === 0) { + throw new Error(`${label} must be a non-empty regular file`); + } +} + +function collectManifestResources(manifest) { + if (!Array.isArray(manifest.Actions)) { + throw new Error("manifest Actions must be an array"); + } + + const resources = [ + ["Icon", manifest.Icon], + ["CategoryIcon", manifest.CategoryIcon], + ["CodePath", manifest.CodePath] + ]; + manifest.Actions.forEach((action, actionIndex) => { + resources.push( + [`Actions[${actionIndex}].Icon`, action?.Icon], + [ + `Actions[${actionIndex}].PropertyInspectorPath`, + action?.PropertyInspectorPath + ] + ); + if (!Array.isArray(action?.States)) { + throw new Error(`manifest Actions[${actionIndex}].States must be an array`); + } + action.States.forEach((state, stateIndex) => { + resources.push([ + `Actions[${actionIndex}].States[${stateIndex}].Image`, + state?.Image + ]); + }); + }); + return resources; +} + +async function validateManifestResources(packageDirectory, manifest) { + for (const [label, relativePath] of collectManifestResources(manifest)) { + if ( + typeof relativePath !== "string" || + relativePath.length === 0 || + path.isAbsolute(relativePath) || + path.win32.isAbsolute(relativePath) + ) { + throw new Error(`manifest resource path escapes package: ${label}`); + } + + const resourcePath = path.resolve(packageDirectory, relativePath); + if (!isInside(packageDirectory, resourcePath)) { + throw new Error(`manifest resource path escapes package: ${label}`); + } + await requireNonEmptyFile( + resourcePath, + `manifest resource ${relativePath}` + ); + const realResourcePath = await realpath(resourcePath); + if (!isInside(packageDirectory, realResourcePath)) { + throw new Error(`manifest resource path escapes package: ${label}`); + } + } +} + +function isForbidden(relativePath, entryName, isDirectory) { + if ( + entryName === ".DS_Store" || + entryName.startsWith("._") || + entryName === "__MACOSX" || + entryName === "node_modules" + ) { + return true; + } + if (isDirectory && (entryName === "test" || entryName === "tests")) { + return true; + } + return ( + relativePath.endsWith(".map") || + /(?:^|[._-])(?:test|spec)\.[^/]+$/i.test(entryName) + ); +} + +async function validatePackageEntries(packageDirectory, relativeDirectory = "") { + const directory = path.join(packageDirectory, relativeDirectory); + for (const entry of await readdir(directory, { withFileTypes: true })) { + const relativePath = path.join(relativeDirectory, entry.name); + const entryStat = await lstat(path.join(packageDirectory, relativePath)); + if (entry.isSymbolicLink() || entryStat.isSymbolicLink()) { + throw new Error(`symbolic links are forbidden: ${relativePath}`); + } + if (isForbidden(relativePath, entry.name, entry.isDirectory())) { + throw new Error(`forbidden package entry: ${relativePath}`); + } + if (entry.isDirectory()) { + await validatePackageEntries(packageDirectory, relativePath); + } + } +} + +async function validateDist(packageDirectory) { + const distPath = path.join(packageDirectory, "dist"); + let distStat; + try { + distStat = await lstat(distPath); + } catch { + throw new Error("dist must contain only app.js as a regular file"); + } + if (distStat.isSymbolicLink()) { + throw new Error("symbolic links are forbidden: dist"); + } + if (!distStat.isDirectory()) { + throw new Error("dist must contain only app.js as a regular file"); + } + + const entries = await readdir(distPath, { withFileTypes: true }); + if ( + entries.length !== 1 || + entries[0].name !== "app.js" || + !entries[0].isFile() + ) { + throw new Error("dist must contain only app.js as a regular file"); + } + await requireNonEmptyFile(path.join(distPath, "app.js"), "dist/app.js"); +} + +function containsPathAtBoundary(source, absolutePath) { + let searchFrom = 0; + while (searchFrom < source.length) { + const index = source.indexOf(absolutePath, searchFrom); + if (index === -1) { + return false; + } + const nextCharacter = source[index + absolutePath.length]; + if ( + nextCharacter === undefined || + /[\/\\\s'"`()\]{},;:?#]/.test(nextCharacter) + ) { + return true; + } + searchFrom = index + absolutePath.length; + } + return false; +} + +async function validateBundlePaths(packageDirectory) { + const bundle = await readFile( + path.join(packageDirectory, "dist", "app.js"), + "utf8" + ); + const forbiddenRoots = new Set([ + SOURCE_PLUGIN_ROOT, + COMMAND_EXECUTOR_ROOT, + WORKTREE_ROOT + ]); + for (const absolutePath of forbiddenRoots) { + const pathSegments = path.relative( + path.parse(absolutePath).root, + absolutePath + ).split(path.sep).filter(Boolean); + if ( + pathSegments.length >= 3 && + containsPathAtBoundary(bundle, absolutePath) + ) { + throw new Error("dist/app.js contains absolute build path"); + } + } +} + +async function validatePackage(packageArgument) { + if (!packageArgument) { + throw new Error( + `usage: node scripts/validate-package.mjs <${PLUGIN_NAME} directory>` + ); + } + + const requestedDirectory = path.resolve(packageArgument); + if (path.basename(requestedDirectory) !== PLUGIN_NAME) { + throw new Error(`package basename must be ${PLUGIN_NAME}`); + } + const packageStat = await lstat(requestedDirectory); + if (packageStat.isSymbolicLink()) { + throw new Error("symbolic links are forbidden: package root"); + } + if (!packageStat.isDirectory()) { + throw new Error("package path must be a directory"); + } + const packageDirectory = await realpath(requestedDirectory); + + await validatePackageEntries(packageDirectory); + for (const relativePath of REQUIRED_RUNTIME_FILES) { + await requireNonEmptyFile( + path.join(packageDirectory, relativePath), + relativePath + ); + } + + const manifest = await readJson( + path.join(packageDirectory, "manifest.json"), + "manifest.json" + ); + await readJson(path.join(packageDirectory, "package.json"), "package.json"); + await validateManifestResources(packageDirectory, manifest); + await validateDist(packageDirectory); + await validateBundlePaths(packageDirectory); + + const noticesPath = path.join(packageDirectory, "THIRD_PARTY_NOTICES.md"); + const notices = await readFile(noticesPath, "utf8"); + for (const commit of SDK_COMMITS) { + if (!notices.includes(commit)) { + throw new Error(`THIRD_PARTY_NOTICES.md must declare SDK commit ${commit}`); + } + } +} + +try { + await validatePackage(process.argv[2]); + console.log(`Package validation passed: ${path.resolve(process.argv[2])}`); +} catch (error) { + console.error(`Package validation failed: ${error.message}`); + process.exitCode = 1; +} diff --git a/plugins/unlanzi_d200x/command_executor/tests/command-plugin.test.mjs b/plugins/unlanzi_d200x/command_executor/tests/command-plugin.test.mjs new file mode 100644 index 0000000..5966501 --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/tests/command-plugin.test.mjs @@ -0,0 +1,638 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { registerCommandPlugin } from "../com.ulanzi.commandexecutor.ulanziPlugin/plugin/command-plugin.js"; +import UlanziApi from "../com.ulanzi.commandexecutor.ulanziPlugin/plugin/vendor/ulanzi-api/ulanziApi.js"; + +const EVENT_METHODS = { + add: "onAdd", + paramFromApp: "onParamFromApp", + paramFromPlugin: "onParamFromPlugin", + run: "onRun", + setActive: "onSetActive", + clear: "onClear", + error: "onError" +}; + +class FakeUlanziApi { + constructor() { + this.callbacks = new Map(); + this.states = []; + this.alerts = []; + this.toasts = []; + this.logs = []; + } + + register(method, callback) { + assert.equal(typeof callback, "function"); + this.callbacks.set(method, callback); + return this; + } + + onAdd(callback) { + return this.register("onAdd", callback); + } + + onParamFromApp(callback) { + return this.register("onParamFromApp", callback); + } + + onParamFromPlugin(callback) { + return this.register("onParamFromPlugin", callback); + } + + onRun(callback) { + return this.register("onRun", callback); + } + + onSetActive(callback) { + return this.register("onSetActive", callback); + } + + onClear(callback) { + return this.register("onClear", callback); + } + + onError(callback) { + return this.register("onError", callback); + } + + setStateIcon(context, state, text) { + this.states.push({ context, state, text }); + } + + showAlert(context) { + this.alerts.push(context); + } + + toast(message) { + this.toasts.push(message); + } + + logMessage(message, level) { + this.logs.push({ message, level }); + } + + trigger(event, payload) { + const method = EVENT_METHODS[event]; + const callback = this.callbacks.get(method); + assert.ok(callback, `${method} callback was not registered`); + return callback(payload); + } +} + +class Deferred { + constructor() { + this.promise = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); + } +} + +function createFakeTimers() { + let nextId = 1; + const timers = new Map(); + const cleared = []; + + return { + cleared, + setTimeoutFn(callback, delay) { + const token = nextId; + nextId += 1; + timers.set(token, { callback, delay }); + return token; + }, + clearTimeoutFn(token) { + cleared.push(token); + timers.delete(token); + }, + pending() { + return [...timers.entries()].map(([token, timer]) => ({ + token, + delay: timer.delay + })); + }, + fire(token) { + const timer = timers.get(token); + assert.ok(timer, `timer ${token} does not exist`); + timers.delete(token); + timer.callback(); + } + }; +} + +function successResult(overrides = {}) { + return { + code: 0, + signal: null, + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + spawnError: null, + ...overrides + }; +} + +async function settleAsyncWork() { + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); +} + +function statesFor(api, context) { + return api.states + .filter((entry) => entry.context === context) + .map(({ state, text }) => ({ state, text })); +} + +test("registers every required official SDK event", () => { + const api = new FakeUlanziApi(); + + registerCommandPlugin(api, { + runCommandFn: async () => successResult() + }); + + assert.deepEqual( + [...api.callbacks.keys()].sort(), + Object.values(EVENT_METHODS).sort() + ); +}); + +test("handles the official SDK error event with a local diagnostic", () => { + const api = new UlanziApi(); + const diagnostics = []; + const originalConsoleError = console.error; + console.error = (...parts) => { + diagnostics.push(parts.map(String).join(" ")); + }; + + try { + registerCommandPlugin(api); + + assert.doesNotThrow(() => { + api.emit("error", "connection refused"); + }); + assert.deepEqual(diagnostics, [ + "[Ulanzi] 连接错误: connection refused" + ]); + } finally { + console.error = originalConsoleError; + } +}); + +test("routes normalized snapshots independently by context", async () => { + const api = new FakeUlanziApi(); + const calls = []; + registerCommandPlugin(api, { + runCommandFn: async (settings) => { + calls.push(settings); + return successResult(); + } + }); + + api.trigger("add", { + context: "context-a", + param: { + title: " 第一项 ", + command: " printf 'a' ", + workingDirectory: "/tmp/a", + environment: "A=1" + } + }); + api.trigger("add", { + context: "context-b", + param: { + title: "第二项", + command: "printf 'b'", + workingDirectory: "/tmp/b", + environment: "B=2" + } + }); + api.trigger("run", { context: "context-a" }); + api.trigger("run", { context: "context-b" }); + await settleAsyncWork(); + + assert.deepEqual(calls, [ + { + title: "第一项", + command: " printf 'a' ", + workingDirectory: "/tmp/a", + environment: "A=1" + }, + { + title: "第二项", + command: "printf 'b'", + workingDirectory: "/tmp/b", + environment: "B=2" + } + ]); + assert.deepEqual(statesFor(api, "context-a").slice(0, 2), [ + { state: 0, text: "第一项" }, + { state: 1, text: "第一项" } + ]); + assert.deepEqual(statesFor(api, "context-b").slice(0, 2), [ + { state: 0, text: "第二项" }, + { state: 1, text: "第二项" } + ]); +}); + +test("plugin updates affect only later runs and explicit empty command clears", async () => { + const api = new FakeUlanziApi(); + const calls = []; + registerCommandPlugin(api, { + runCommandFn: async (settings) => { + calls.push(settings); + return successResult(); + } + }); + + api.trigger("add", { + context: "context-a", + param: { + title: "原始", + command: "old", + workingDirectory: "/tmp/old", + environment: "OLD=1" + } + }); + api.trigger("paramFromPlugin", { + context: "context-a", + param: { + title: " ", + command: "new" + } + }); + api.trigger("run", { context: "context-a", param: {} }); + await settleAsyncWork(); + + assert.deepEqual(calls[0], { + title: "执行命令", + command: "new", + workingDirectory: "/tmp/old", + environment: "OLD=1" + }); + + api.trigger("paramFromPlugin", { + context: "context-a", + param: { command: "" } + }); + api.trigger("run", { context: "context-a" }); + await settleAsyncWork(); + + assert.equal(calls[1].command, ""); +}); + +test("run can initialize uncached context from params and reports validation throws", async () => { + const api = new FakeUlanziApi(); + const calls = []; + registerCommandPlugin(api, { + runCommandFn: async (settings) => { + calls.push(settings); + if (settings.command === "") { + throw new Error("命令不能为空"); + } + return successResult(); + } + }); + + api.trigger("run", { + context: "created-on-run", + param: { + title: " 即时执行 ", + command: "date", + workingDirectory: null, + environment: 42 + } + }); + api.trigger("run", { context: "empty-on-run" }); + await settleAsyncWork(); + + assert.deepEqual(calls, [ + { + title: "即时执行", + command: "date", + workingDirectory: "", + environment: "" + }, + { + title: "执行命令", + command: "", + workingDirectory: "", + environment: "" + } + ]); + assert.deepEqual(api.alerts, ["empty-on-run"]); + assert.match(api.toasts[0], /命令不能为空/); + assert.equal(api.logs.at(-1).level, "error"); + assert.match(api.logs.at(-1).message, /validation error: 命令不能为空/); + assert.deepEqual(statesFor(api, "empty-on-run"), [ + { state: 1, text: "执行命令" }, + { state: 0, text: "执行命令" } + ]); +}); + +test("a rejected runner is contained and cannot create an unhandled rejection", async () => { + const api = new FakeUlanziApi(); + const unhandled = []; + const onUnhandled = (reason) => { + unhandled.push(reason); + }; + process.on("unhandledRejection", onUnhandled); + + try { + registerCommandPlugin(api, { + runCommandFn: async () => { + throw new Error("runner rejected"); + } + }); + api.trigger("add", { + context: "context-a", + param: { command: "private-command", environment: "SECRET=value" } + }); + + const callbackResult = api.trigger("run", { context: "context-a" }); + assert.equal(callbackResult, undefined); + await settleAsyncWork(); + + assert.deepEqual(unhandled, []); + assert.deepEqual(api.alerts, ["context-a"]); + assert.equal(api.logs[0].level, "error"); + assert.match(api.logs[0].message, /validation error: runner rejected/); + assert.deepEqual(statesFor(api, "context-a").slice(-2), [ + { state: 1, text: "执行命令" }, + { state: 0, text: "执行命令" } + ]); + } finally { + process.off("unhandledRejection", onUnhandled); + } +}); + +test("spawn errors, nonzero exits, and signals are failures with safe feedback", async () => { + const api = new FakeUlanziApi(); + const results = [ + successResult({ code: null, spawnError: new Error("spawn EACCES") }), + successResult({ code: 7 }), + successResult({ code: null, signal: "SIGTERM" }) + ]; + registerCommandPlugin(api, { + runCommandFn: async () => results.shift() + }); + api.trigger("add", { + context: "context-a", + param: { + command: "do-not-show-this-command", + environment: "SECRET=do-not-show-this-value" + } + }); + + for (let index = 0; index < 3; index += 1) { + api.trigger("run", { context: "context-a" }); + await settleAsyncWork(); + } + + assert.equal(api.alerts.length, 3); + assert.equal(api.toasts.length, 3); + assert.match(api.toasts[0], /启动失败/); + assert.match(api.toasts[1], /退出码 7/); + assert.match(api.toasts[2], /SIGTERM/); + assert.doesNotMatch( + api.toasts.join("\n"), + /do-not-show-this-command|do-not-show-this-value/ + ); + assert.match(api.logs[0].message, /spawn error: spawn EACCES/); + assert.match(api.logs[1].message, /code: 7/); + assert.match(api.logs[2].message, /signal: SIGTERM/); + assert.ok(api.logs.every((entry) => entry.level === "error")); +}); + +test("logs bounded output and restores a successful state after 1200 ms", async () => { + const api = new FakeUlanziApi(); + const timers = createFakeTimers(); + registerCommandPlugin(api, { + runCommandFn: async () => + successResult({ + stdout: "standard output", + stderr: "standard error", + stdoutTruncated: true, + stderrTruncated: true + }), + setTimeoutFn: timers.setTimeoutFn, + clearTimeoutFn: timers.clearTimeoutFn + }); + api.trigger("add", { + context: "context-a", + param: { title: "成功按钮", command: "run" } + }); + + api.trigger("run", { context: "context-a" }); + await settleAsyncWork(); + + assert.deepEqual(statesFor(api, "context-a"), [ + { state: 0, text: "成功按钮" }, + { state: 1, text: "成功按钮" }, + { state: 2, text: "成功按钮" } + ]); + assert.deepEqual(timers.pending().map(({ delay }) => delay), [1200]); + assert.equal(api.logs[0].level, "info"); + assert.match(api.logs[0].message, /context: context-a/); + assert.match(api.logs[0].message, /code: 0/); + assert.match(api.logs[0].message, /signal: none/); + assert.match(api.logs[0].message, /stdout: standard output/); + assert.match(api.logs[0].message, /stderr: standard error/); + assert.match(api.logs[0].message, /stdout 输出已截断/); + assert.match(api.logs[0].message, /stderr 输出已截断/); + + timers.fire(timers.pending()[0].token); + assert.deepEqual(statesFor(api, "context-a").at(-1), { + state: 0, + text: "成功按钮" + }); +}); + +test("keeps running state until all concurrent runs succeed", async () => { + const api = new FakeUlanziApi(); + const first = new Deferred(); + const second = new Deferred(); + const pending = [first, second]; + registerCommandPlugin(api, { + runCommandFn: () => pending.shift().promise + }); + api.trigger("add", { + context: "context-a", + param: { title: "并发", command: "run" } + }); + + api.trigger("run", { context: "context-a" }); + api.trigger("run", { context: "context-a" }); + first.resolve(successResult()); + await settleAsyncWork(); + + const statesAfterFirst = statesFor(api, "context-a"); + assert.deepEqual(statesAfterFirst.at(-1), { state: 1, text: "并发" }); + assert.equal(statesAfterFirst.some(({ state }) => state === 2), false); + + second.resolve(successResult()); + await settleAsyncWork(); + assert.deepEqual(statesFor(api, "context-a").at(-1), { + state: 2, + text: "并发" + }); +}); + +test("one concurrent failure prevents a final success state", async () => { + const api = new FakeUlanziApi(); + const first = new Deferred(); + const second = new Deferred(); + const pending = [first, second]; + registerCommandPlugin(api, { + runCommandFn: () => pending.shift().promise + }); + api.trigger("add", { + context: "context-a", + param: { title: "并发", command: "run" } + }); + + api.trigger("run", { context: "context-a" }); + api.trigger("run", { context: "context-a" }); + first.resolve(successResult({ code: 2 })); + second.resolve(successResult()); + await settleAsyncWork(); + + const states = statesFor(api, "context-a"); + assert.deepEqual(states.at(-1), { state: 0, text: "并发" }); + assert.equal(states.some(({ state }) => state === 2), false); +}); + +test("running commands keep their settings snapshot across later edits", async () => { + const api = new FakeUlanziApi(); + const deferred = new Deferred(); + const calls = []; + registerCommandPlugin(api, { + runCommandFn: (settings) => { + calls.push(settings); + return deferred.promise; + } + }); + api.trigger("add", { + context: "context-a", + param: { + title: "旧标题", + command: "old", + workingDirectory: "/tmp/old", + environment: "OLD=1" + } + }); + + api.trigger("run", { context: "context-a" }); + api.trigger("paramFromApp", { + context: "context-a", + param: { + title: "新标题", + command: "new", + workingDirectory: "/tmp/new", + environment: "NEW=1" + } + }); + + assert.deepEqual(calls[0], { + title: "旧标题", + command: "old", + workingDirectory: "/tmp/old", + environment: "OLD=1" + }); + deferred.resolve(successResult()); + await settleAsyncWork(); + + assert.deepEqual(statesFor(api, "context-a").at(-1), { + state: 2, + text: "新标题" + }); +}); + +test("clear is safe, clears timers, and suppresses late run UI updates", async () => { + const api = new FakeUlanziApi(); + const timers = createFakeTimers(); + const successfulRun = new Deferred(); + const lateRun = new Deferred(); + const pending = [successfulRun, lateRun]; + registerCommandPlugin(api, { + runCommandFn: () => pending.shift().promise, + setTimeoutFn: timers.setTimeoutFn, + clearTimeoutFn: timers.clearTimeoutFn + }); + + assert.doesNotThrow(() => { + api.trigger("clear", { param: [{ context: "missing" }] }); + }); + + api.trigger("add", { + context: "context-a", + param: { command: "first" } + }); + api.trigger("run", { context: "context-a" }); + successfulRun.resolve(successResult()); + await settleAsyncWork(); + const successTimer = timers.pending()[0].token; + + api.trigger("paramFromApp", { + context: "context-a", + param: { command: "second" } + }); + assert.deepEqual(timers.cleared, [successTimer]); + + api.trigger("run", { context: "context-a" }); + const stateCountBeforeClear = api.states.length; + api.trigger("clear", { param: [{ context: "context-a" }] }); + lateRun.resolve(successResult({ code: 9 })); + await settleAsyncWork(); + + assert.equal(api.states.length, stateCountBeforeClear); + assert.deepEqual(api.alerts, []); + assert.deepEqual(api.toasts, []); +}); + +test("active redraws the current state and can initialize from settings", () => { + const api = new FakeUlanziApi(); + registerCommandPlugin(api, { + runCommandFn: async () => successResult() + }); + api.trigger("add", { + context: "context-a", + param: { title: "已有", command: "old" } + }); + + api.trigger("setActive", { context: "context-a", active: false }); + api.trigger("setActive", { context: "context-a", active: true }); + api.trigger("setActive", { + context: "context-b", + active: true, + param: { title: " 新建 ", command: "new" } + }); + + assert.deepEqual(statesFor(api, "context-a"), [ + { state: 0, text: "已有" }, + { state: 0, text: "已有" } + ]); + assert.deepEqual(statesFor(api, "context-b"), [ + { state: 0, text: "新建" } + ]); +}); + +test("an empty successful result still writes a completion log", async () => { + const api = new FakeUlanziApi(); + registerCommandPlugin(api, { + runCommandFn: async () => successResult() + }); + api.trigger("add", { + context: "context-a", + param: { command: "true" } + }); + + api.trigger("run", { context: "context-a" }); + await settleAsyncWork(); + + assert.equal(api.logs.length, 1); + assert.equal(api.logs[0].level, "info"); + assert.match(api.logs[0].message, /命令执行完成/); +}); diff --git a/plugins/unlanzi_d200x/command_executor/tests/command-runner.test.mjs b/plugins/unlanzi_d200x/command_executor/tests/command-runner.test.mjs new file mode 100644 index 0000000..b8364cc --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/tests/command-runner.test.mjs @@ -0,0 +1,744 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { constants as fileSystemConstants } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { PassThrough } from "node:stream"; +import test from "node:test"; + +import { + OUTPUT_LIMIT_BYTES, + buildShellScript, + parseEnvironment, + quoteShellValue, + runCommand, + resolveShell, + resolveWorkingDirectory +} from "../com.ulanzi.commandexecutor.ulanziPlugin/plugin/command-runner.js"; + +test("exports a 16 KiB default output limit", () => { + assert.equal(OUTPUT_LIMIT_BYTES, 16 * 1024); +}); + +test("parseEnvironment accepts empty input and ignores empty CRLF lines", () => { + assert.deepEqual(parseEnvironment(), {}); + assert.deepEqual(parseEnvironment("\r\n \r\n"), {}); + assert.deepEqual(parseEnvironment("FIRST=one\r\n\r\nSECOND=two\r\n"), { + FIRST: "one", + SECOND: "two" + }); +}); + +test("parseEnvironment splits on the first equals and lets the last name win", () => { + assert.deepEqual( + parseEnvironment(" TOKEN =first=value\nTOKEN=last=value=kept"), + { TOKEN: "last=value=kept" } + ); +}); + +test("parseEnvironment preserves __proto__ as an own enumerable variable", () => { + const parsed = parseEnvironment("__proto__=first\n__proto__=explicit"); + + assert.equal(Object.hasOwn(parsed, "__proto__"), true); + assert.deepEqual(Object.keys(parsed), ["__proto__"]); + assert.equal(parsed.__proto__, "explicit"); + assert.equal( + buildShellScript("printf '%s' \"$__proto__\"", parsed), + "export __proto__='explicit'\nprintf '%s' \"$__proto__\"" + ); +}); + +test("parseEnvironment reports an invalid name by line without leaking its value", () => { + assert.throws( + () => parseEnvironment("VALID=ok\nBAD-NAME=do-not-leak"), + (error) => { + assert.match(error.message, /2/); + assert.doesNotMatch(error.message, /do-not-leak/); + return true; + } + ); +}); + +test("parseEnvironment reports a NUL value by line without leaking its value", () => { + assert.throws( + () => parseEnvironment("VALID=ok\nSECRET=before\0after"), + (error) => { + assert.match(error.message, /2/); + assert.doesNotMatch(error.message, /before|after/); + return true; + } + ); +}); + +test("quoteShellValue preserves single quotes and empty values", () => { + assert.equal(quoteShellValue("a'b"), "'a'\"'\"'b'"); + assert.equal(quoteShellValue(""), "''"); +}); + +test("buildShellScript exports configured values before the original command", () => { + const command = "printf '%s' \"$VALUE\""; + + assert.equal( + buildShellScript(command, { VALUE: "a'b", EMPTY: "" }), + `export VALUE='a'"'"'b'\nexport EMPTY=''\n${command}` + ); + assert.equal(buildShellScript(command, {}), command); +}); + +test("buildShellScript rejects blank and NUL-containing commands", () => { + assert.throws(() => buildShellScript(" \n", {}), /命令不能为空/); + assert.throws(() => buildShellScript("printf x\0ignored", {}), /NUL/); +}); + +function createFileSystem({ + directories = [], + files, + executablePaths = [] +} = {}) { + const statCalls = []; + const accessCalls = []; + const regularFiles = + files ?? executablePaths.filter((path) => !directories.includes(path)); + + return { + statCalls, + accessCalls, + async stat(path) { + statCalls.push(path); + if (!directories.includes(path) && !regularFiles.includes(path)) { + throw new Error("path does not exist"); + } + return { + isDirectory() { + return directories.includes(path); + }, + isFile() { + return regularFiles.includes(path); + } + }; + }, + async access(path, mode) { + accessCalls.push([path, mode]); + if (!executablePaths.includes(path)) { + throw new Error("path is not executable"); + } + } + }; +} + +test("resolveWorkingDirectory expands home forms and accepts absolute paths", async () => { + const homeDirectory = "/Users/example"; + const fileSystem = createFileSystem({ + directories: [homeDirectory, "/Users/example/work", "/private/tmp/work"], + executablePaths: [homeDirectory, "/Users/example/work", "/private/tmp/work"] + }); + + assert.equal( + await resolveWorkingDirectory("", { fileSystem, homeDirectory }), + homeDirectory + ); + assert.equal( + await resolveWorkingDirectory("~", { fileSystem, homeDirectory }), + homeDirectory + ); + assert.equal( + await resolveWorkingDirectory("~/work", { fileSystem, homeDirectory }), + "/Users/example/work" + ); + assert.equal( + await resolveWorkingDirectory("/private/tmp/work", { + fileSystem, + homeDirectory + }), + "/private/tmp/work" + ); + assert.deepEqual( + fileSystem.accessCalls.map(([, mode]) => mode), + Array(4).fill(fileSystemConstants.X_OK) + ); +}); + +test("resolveWorkingDirectory rejects relative and named-home paths before stat", async () => { + const fileSystem = createFileSystem(); + const options = { fileSystem, homeDirectory: "/Users/example" }; + + await assert.rejects(resolveWorkingDirectory("relative/path", options), /绝对路径/); + await assert.rejects(resolveWorkingDirectory("~other/work", options), /绝对路径/); + assert.deepEqual(fileSystem.statCalls, []); +}); + +test("resolveWorkingDirectory rejects non-directories and inaccessible directories", async () => { + const notDirectory = { + async stat() { + return { + isDirectory() { + return false; + } + }; + }, + async access() { + assert.fail("access must not run for a non-directory"); + } + }; + const inaccessible = createFileSystem({ directories: ["/private/tmp/locked"] }); + + await assert.rejects( + resolveWorkingDirectory("/private/tmp/file", { + fileSystem: notDirectory, + homeDirectory: "/Users/example" + }), + /目录/ + ); + await assert.rejects( + resolveWorkingDirectory("/private/tmp/locked", { + fileSystem: inaccessible, + homeDirectory: "/Users/example" + }), + /访问/ + ); +}); + +test("resolveShell uses an executable absolute SHELL candidate", async () => { + const fileSystem = createFileSystem({ + executablePaths: ["/opt/homebrew/bin/zsh"] + }); + + assert.equal( + await resolveShell({ + fileSystem, + baseEnvironment: { SHELL: "/opt/homebrew/bin/zsh" } + }), + "/opt/homebrew/bin/zsh" + ); + assert.deepEqual(fileSystem.accessCalls, [ + ["/opt/homebrew/bin/zsh", fileSystemConstants.X_OK] + ]); +}); + +test("resolveShell falls back for missing, relative, or inaccessible candidates", async () => { + for (const shell of [undefined, "bin/zsh", "/missing/shell"]) { + const fileSystem = createFileSystem({ executablePaths: ["/bin/zsh"] }); + + assert.equal( + await resolveShell({ + fileSystem, + baseEnvironment: shell === undefined ? {} : { SHELL: shell } + }), + "/bin/zsh" + ); + } +}); + +test( + "resolveShell rejects an executable directory candidate and falls back", + { skip: process.platform !== "darwin" }, + async (t) => { + const temporaryDirectory = await mkdtemp( + path.join(tmpdir(), "command-runner-shell-dir-") + ); + t.after(async () => { + await rm(temporaryDirectory, { recursive: true, force: true }); + }); + + assert.equal( + await resolveShell({ + baseEnvironment: { SHELL: temporaryDirectory } + }), + "/bin/zsh" + ); + } +); + +test("runCommand rejects a directory fallback before spawning", async () => { + let spawnCalls = 0; + const fileSystem = createFileSystem({ + directories: ["/Users/example", "/bin/zsh"], + executablePaths: ["/Users/example", "/bin/zsh"] + }); + + await assert.rejects( + runCommand( + { command: "printf ok", workingDirectory: "", environment: "" }, + { + spawnFn() { + spawnCalls += 1; + return createChildProcess(); + }, + fileSystem, + homeDirectory: "/Users/example", + baseEnvironment: { SHELL: "relative/zsh" } + } + ), + /Shell/ + ); + assert.equal(spawnCalls, 0); +}); + +test("resolveShell fails when the fallback is not executable", async () => { + const fileSystem = createFileSystem(); + + await assert.rejects( + resolveShell({ + fileSystem, + baseEnvironment: { SHELL: "/missing/shell" } + }), + /Shell/ + ); +}); + +function createChildProcess({ + stdoutChunks = [], + stderrChunks = [], + code = 0, + signal = null, + spawnError = null, + closeAfterError = false +} = {}) { + const child = new EventEmitter(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + + queueMicrotask(() => { + for (const chunk of stdoutChunks) { + child.stdout.write(chunk); + } + for (const chunk of stderrChunks) { + child.stderr.write(chunk); + } + child.stdout.end(); + child.stderr.end(); + + if (spawnError) { + child.emit("error", spawnError); + if (closeAfterError) { + child.emit("close", code, signal); + } + return; + } + child.emit("close", code, signal); + }); + + return child; +} + +test("runCommand passes the complete script and exact spawn options", async () => { + const baseEnvironment = { + SHELL: "/custom/zsh", + INHERITED: "kept" + }; + const fileSystem = createFileSystem({ + directories: ["/Users/example"], + executablePaths: ["/Users/example", "/custom/zsh"] + }); + const calls = []; + const spawnFn = (...args) => { + calls.push(args); + return createChildProcess({ + stdoutChunks: ["done"], + stderrChunks: ["warning"], + code: null, + signal: "SIGTERM" + }); + }; + + const result = await runCommand( + { + command: "printf '%s' \"$VALUE\" | cat", + workingDirectory: "", + environment: "VALUE=a'b" + }, + { + spawnFn, + fileSystem, + homeDirectory: "/Users/example", + baseEnvironment + } + ); + + assert.equal(calls.length, 1); + assert.deepEqual(calls[0], [ + "/custom/zsh", + [ + "-lc", + `export VALUE='a'"'"'b'\nprintf '%s' "$VALUE" | cat` + ], + { + cwd: "/Users/example", + env: baseEnvironment, + stdio: ["ignore", "pipe", "pipe"] + } + ]); + assert.deepEqual(result, { + code: null, + signal: "SIGTERM", + stdout: "done", + stderr: "warning", + stdoutTruncated: false, + stderrTruncated: false, + spawnError: null + }); +}); + +test("runCommand drains streams, truncates each by bytes, and decodes after collection", async () => { + const multiByte = Buffer.from("你"); + const fileSystem = createFileSystem({ + directories: ["/Users/example"], + executablePaths: ["/Users/example", "/bin/zsh"] + }); + const spawnFn = () => + createChildProcess({ + stdoutChunks: [ + multiByte.subarray(0, 1), + multiByte.subarray(1), + Buffer.from("abcdef") + ], + stderrChunks: [Buffer.from("123"), Buffer.from("456")] + }); + + const result = await runCommand( + { command: "ignored by fake", workingDirectory: "", environment: "" }, + { + spawnFn, + fileSystem, + homeDirectory: "/Users/example", + baseEnvironment: {}, + outputLimitBytes: 5 + } + ); + + assert.equal(Buffer.byteLength(result.stdout), 5); + assert.equal(result.stdout, "你ab"); + assert.equal(result.stderr, "12345"); + assert.equal(result.stdoutTruncated, true); + assert.equal(result.stderrTruncated, true); +}); + +test("runCommand drops an incomplete UTF-8 code point at the byte limit", async () => { + const outputLimitBytes = 16 * 1024; + const fileSystem = createFileSystem({ + directories: ["/Users/example"], + executablePaths: ["/Users/example", "/bin/zsh"] + }); + const result = await runCommand( + { command: "ignored by fake", workingDirectory: "", environment: "" }, + { + spawnFn: () => + createChildProcess({ + stdoutChunks: [ + Buffer.alloc(outputLimitBytes - 1, "a"), + Buffer.from("你") + ] + }), + fileSystem, + homeDirectory: "/Users/example", + baseEnvironment: {}, + outputLimitBytes + } + ); + + assert.equal(result.stdoutTruncated, true); + assert.doesNotMatch(result.stdout, /\uFFFD/); + assert.equal(result.stdout, "a".repeat(outputLimitBytes - 1)); + assert.ok(Buffer.byteLength(result.stdout) <= outputLimitBytes); +}); + +test("runCommand replaces incomplete UTF-8 when output was not truncated", async () => { + const fileSystem = createFileSystem({ + directories: ["/Users/example"], + executablePaths: ["/Users/example", "/bin/zsh"] + }); + const result = await runCommand( + { command: "ignored by fake", workingDirectory: "", environment: "" }, + { + spawnFn: () => + createChildProcess({ + stdoutChunks: [Buffer.from([0xe4])] + }), + fileSystem, + homeDirectory: "/Users/example", + baseEnvironment: {}, + outputLimitBytes: 16 + } + ); + + assert.equal(result.stdoutTruncated, false); + assert.equal(result.stdout, "\uFFFD"); +}); + +test("runCommand keeps multibyte UTF-8 intact across stream chunks", async () => { + const text = Buffer.from("命令"); + const fileSystem = createFileSystem({ + directories: ["/Users/example"], + executablePaths: ["/Users/example", "/bin/zsh"] + }); + + const result = await runCommand( + { command: "ignored by fake", workingDirectory: "", environment: "" }, + { + spawnFn: () => + createChildProcess({ + stdoutChunks: [ + text.subarray(0, 1), + text.subarray(1, 4), + text.subarray(4) + ] + }), + fileSystem, + homeDirectory: "/Users/example", + baseEnvironment: {} + } + ); + + assert.equal(result.stdout, "命令"); + assert.equal(result.stdoutTruncated, false); +}); + +test("runCommand returns synchronous and asynchronous spawn failures", async () => { + const fileSystem = createFileSystem({ + directories: ["/Users/example"], + executablePaths: ["/Users/example", "/bin/zsh"] + }); + const synchronousError = new Error("synchronous spawn failure"); + const asynchronousError = new Error("asynchronous spawn failure"); + const settings = { + command: "ignored by fake", + workingDirectory: "", + environment: "" + }; + const options = { + fileSystem, + homeDirectory: "/Users/example", + baseEnvironment: {} + }; + + const synchronousResult = await runCommand(settings, { + ...options, + spawnFn() { + throw synchronousError; + } + }); + const asynchronousResult = await runCommand(settings, { + ...options, + spawnFn() { + return createChildProcess({ + spawnError: asynchronousError, + closeAfterError: true, + code: 127 + }); + } + }); + + assert.equal(synchronousResult.spawnError, synchronousError); + assert.equal(synchronousResult.code, null); + assert.equal(asynchronousResult.spawnError, asynchronousError); + assert.equal(asynchronousResult.code, null); +}); + +test("runCommand completes only once when close follows an asynchronous error", async () => { + const fileSystem = createFileSystem({ + directories: ["/Users/example"], + executablePaths: ["/Users/example", "/bin/zsh"] + }); + let thenCalls = 0; + + const result = await runCommand( + { command: "ignored by fake", workingDirectory: "", environment: "" }, + { + spawnFn: () => + createChildProcess({ + spawnError: new Error("spawn failed"), + closeAfterError: true, + code: 127 + }), + fileSystem, + homeDirectory: "/Users/example", + baseEnvironment: {} + } + ).then((value) => { + thenCalls += 1; + return value; + }); + + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(thenCalls, 1); + assert.equal(result.spawnError.message, "spawn failed"); +}); + +test("runCommand rejects validation failures before spawning", async () => { + let spawnCalls = 0; + const spawnFn = () => { + spawnCalls += 1; + return createChildProcess(); + }; + const validFileSystem = createFileSystem({ + directories: ["/Users/example"], + executablePaths: ["/Users/example", "/bin/zsh"] + }); + const commonOptions = { + spawnFn, + fileSystem: validFileSystem, + homeDirectory: "/Users/example", + baseEnvironment: {} + }; + + await assert.rejects( + runCommand( + { command: " ", workingDirectory: "", environment: "" }, + commonOptions + ), + /命令不能为空/ + ); + await assert.rejects( + runCommand( + { + command: "printf ok", + workingDirectory: "", + environment: "BAD-NAME=secret" + }, + commonOptions + ), + /1/ + ); + await assert.rejects( + runCommand( + { + command: "printf ok", + workingDirectory: "relative/path", + environment: "" + }, + commonOptions + ), + /绝对路径/ + ); + + const unusableFileSystem = createFileSystem({ + directories: ["/Users/example"], + executablePaths: ["/Users/example"] + }); + await assert.rejects( + runCommand( + { command: "printf ok", workingDirectory: "", environment: "" }, + { ...commonOptions, fileSystem: unusableFileSystem } + ), + /Shell/ + ); + assert.equal(spawnCalls, 0); +}); + +async function createIsolatedZshEnvironment(t, profile = "") { + const temporaryDirectory = await mkdtemp( + path.join(tmpdir(), "command-runner-zdotdir-") + ); + t.after(async () => { + await rm(temporaryDirectory, { recursive: true, force: true }); + }); + await writeFile(path.join(temporaryDirectory, ".zprofile"), profile); + + return { + temporaryDirectory, + baseEnvironment: { + ...process.env, + SHELL: "/bin/zsh", + ZDOTDIR: temporaryDirectory + } + }; +} + +test( + "configured environment overrides login profile and inherited values", + { skip: process.platform !== "darwin" }, + async (t) => { + const { temporaryDirectory, baseEnvironment } = + await createIsolatedZshEnvironment( + t, + "export COMMAND_EXECUTOR_TEST_VALUE='profile'\n" + ); + const result = await runCommand( + { + command: "printf '%s' \"$COMMAND_EXECUTOR_TEST_VALUE\"", + workingDirectory: temporaryDirectory, + environment: "COMMAND_EXECUTOR_TEST_VALUE=explicit" + }, + { + baseEnvironment: { + ...baseEnvironment, + COMMAND_EXECUTOR_TEST_VALUE: "inherited" + } + } + ); + + assert.equal(result.code, 0); + assert.equal(result.signal, null); + assert.equal(result.stdout, "explicit"); + assert.equal(result.stderr, ""); + assert.equal(result.spawnError, null); + } +); + +test( + "exports a configured __proto__ variable to the real shell", + { skip: process.platform !== "darwin" }, + async (t) => { + const { temporaryDirectory, baseEnvironment } = + await createIsolatedZshEnvironment(t); + const result = await runCommand( + { + command: "printf '%s' \"$__proto__\"", + workingDirectory: temporaryDirectory, + environment: "__proto__=first\n__proto__=explicit" + }, + { baseEnvironment } + ); + + assert.equal(result.code, 0); + assert.equal(result.stdout, "explicit"); + assert.equal(result.stderr, ""); + } +); + +test( + "preserves complete pipeline and quote semantics in the login shell", + { skip: process.platform !== "darwin" }, + async (t) => { + const { baseEnvironment } = await createIsolatedZshEnvironment(t); + const result = await runCommand( + { + command: + `printf '%s\\n' "first value" "a'b" | ` + + `awk 'NR == 2 { printf "%s", $0 }'`, + workingDirectory: "", + environment: "" + }, + { baseEnvironment } + ); + + assert.equal(result.code, 0); + assert.equal(result.stdout, "a'b"); + assert.equal(result.stderr, ""); + } +); + +test( + "passes 200 inline shell arguments without parsing them in JavaScript", + { skip: process.platform !== "darwin" }, + async (t) => { + const { baseEnvironment } = await createIsolatedZshEnvironment(t); + const args = Array.from( + { length: 200 }, + (_, index) => `arg${String(index + 1).padStart(3, "0")}` + ); + const result = await runCommand( + { + command: `set -- ${args.join(" ")}; printf '%s' "$#"`, + workingDirectory: "", + environment: "" + }, + { baseEnvironment } + ); + + assert.equal(result.code, 0); + assert.equal(result.stdout, "200"); + assert.equal(result.stderr, ""); + } +); diff --git a/plugins/unlanzi_d200x/command_executor/tests/fixtures/ulanzi-sdk-sha256.json b/plugins/unlanzi_d200x/command_executor/tests/fixtures/ulanzi-sdk-sha256.json new file mode 100644 index 0000000..51b525f --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/tests/fixtures/ulanzi-sdk-sha256.json @@ -0,0 +1,25 @@ +{ + "LICENSES/UlanziDeckPlugin-SDK-APACHE-2.0.txt": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4", + "libs/assets/u_active.svg": "a96c394b8a175932b6509d6e7c48df13174d8d684b4b4ddaebfa047d9cd26110", + "libs/assets/u_active_none.svg": "28682b245bdc74c674e9da73b0dce3efd2dc7b804d70fb72e8ee0d2f09b752d7", + "libs/assets/u_check_checkbox.svg": "880fdd1f67c5a602e96533cfdbb76a92daa868f96cf4552b0392c225d5cc729e", + "libs/assets/u_check_none.svg": "cc126d3957f27931a6e0b46c1ad4e762b3735548cde890a592fba0ef826e1560", + "libs/assets/u_check_radio.svg": "2555d9f51a769a143b14aa9eec6035323499863eae3b1ba8a5dc5e3af5eab57f", + "libs/assets/u_down.svg": "5441f01ec9073d0e19ee823ff9f1fea66fdcb74cd583ca998da36a86c7cd5740", + "libs/assets/u_file.svg": "240ec4b92721589ca7238a7d6b5b497cd06fd6b3de8caa99b32060b9d463bcdf", + "libs/assets/u_folder.svg": "64ac6d341b9d2981db245e4d281410bc938960f4acf09bf71fc03b974cbbe2e1", + "libs/assets/u_refresh.svg": "af16c113d1101e5fcd73018fa7f008ad0cd3dd96678b47e237face67b43c5ed1", + "libs/assets/u_tip_error.svg": "d551c95407b44d17c6c6546c625fe6de9202aed00ea5e52bad025c95743a8de2", + "libs/assets/u_tip_info.svg": "b2a22809ce8e69bba12394a52a76690c8e632e7278a68230e76e55db0112e908", + "libs/assets/u_tip_success.svg": "81107680e49add68ca6efe864c4a2bdbc7fb372f38029cfb4e7457960f48ef0d", + "libs/assets/u_tip_warn.svg": "ff79b63f7deabffadd398ba5430d7a3ce649f2883fcaf0e46789936357a0962e", + "libs/css/uspi.css": "b559dd379d2b616ee75c3d96a26264145f365139a42bdadd9d7bb816e6ae736f", + "libs/js/constants.js": "4d6581c19e34379cf28f4c56835545a81f8c036e4395f40f1e449debdb57302e", + "libs/js/eventEmitter.js": "1167eaf1c4dce87942186bd35042851a3925ba8f19932dbd42e8050f991027c5", + "libs/js/timers.js": "3b6948510c2136d8e58c9337682fbcd7d02aad8d67a5befa7bd1691e897aaae2", + "libs/js/ulanziApi.js": "ebd369d1616ef77d93701ecd0ddf95f0c1dc1e4e248aa6c3e3db5f68d250112b", + "libs/js/utils.js": "6f13551f6e2d771401e2ddf8763d22d692b5ecfc0dd2ddcaa4ddde5e7274691e", + "plugin/vendor/ulanzi-api/constants.js": "35c2cd088ebdcecd256412f5436555ec15a2510d9f09fedaef03f9014d227c62", + "plugin/vendor/ulanzi-api/ulanziApi.js": "4f4f307ae556a658669ab0bdf36016b1e151b25e4a5ec40451ab2c0724935168", + "plugin/vendor/ulanzi-api/utils.js": "37a9ba4fc1f1346c733dfc55ced35d29325cf1a7252a4d96fcfecd9958f8b8d1" +} diff --git a/plugins/unlanzi_d200x/command_executor/tests/inspector-settings.test.mjs b/plugins/unlanzi_d200x/command_executor/tests/inspector-settings.test.mjs new file mode 100644 index 0000000..8b44b0a --- /dev/null +++ b/plugins/unlanzi_d200x/command_executor/tests/inspector-settings.test.mjs @@ -0,0 +1,531 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import vm from "node:vm"; +import { fileURLToPath } from "node:url"; + +const testDirectory = path.dirname(fileURLToPath(import.meta.url)); +const pluginDirectory = path.resolve( + testDirectory, + "..", + "com.ulanzi.commandexecutor.ulanziPlugin" +); +const inspectorDirectory = path.join(pluginDirectory, "property-inspector"); + +async function loadSettings() { + const source = await readFile( + path.join(inspectorDirectory, "settings.js"), + "utf8" + ); + const context = vm.createContext({}); + vm.runInContext(source, context); + return context.CommandExecutorSettings; +} + +function copyFromVm(value) { + return JSON.parse(JSON.stringify(value)); +} + +function createInspectorHarness({ language = "zh_CN" } = {}) { + const listeners = new Map(); + const windowListeners = new Map(); + const timers = []; + let now = 0; + + class FakeElement { + constructor() { + this.listeners = new Map(); + this.textContent = ""; + Object.defineProperty(this, "innerHTML", { + set() { + assert.fail("inspector must not write user content through innerHTML"); + } + }); + } + + addEventListener(name, callback) { + this.listeners.set(name, callback); + } + + dispatch(name) { + const callback = this.listeners.get(name); + assert.ok(callback, `${name} listener was not registered`); + callback({ type: name }); + } + } + + const form = new FakeElement(); + form.values = { + title: "执行命令", + command: "", + workingDirectory: "", + environment: "" + }; + const validationMessage = new FakeElement(); + const executionPreview = new FakeElement(); + const wrapper = new FakeElement(); + wrapper.hidden = true; + wrapper.classList = { + remove(name) { + assert.equal(name, "hidden"); + wrapper.hidden = false; + } + }; + + const elements = new Map([ + ["#property-inspector", form], + ["#validation-message", validationMessage], + ["#execution-preview", executionPreview], + [".udpi-wrapper", wrapper] + ]); + const document = { + querySelector(selector) { + return elements.get(selector) ?? null; + } + }; + const fakeWindow = { + addEventListener(name, callback) { + windowListeners.set(name, callback); + }, + dispatch(name) { + const callback = windowListeners.get(name); + assert.ok(callback, `${name} listener was not registered`); + callback({ type: name }); + } + }; + const Utils = { + debounce(callback, wait) { + let pendingTimer = null; + return (...args) => { + if (pendingTimer) { + pendingTimer.active = false; + } + pendingTimer = { + active: true, + callback, + args, + dueAt: now + wait + }; + timers.push(pendingTimer); + }; + }, + getFormValue(target) { + assert.equal(target, form); + return { ...form.values }; + }, + setFormValue(settings, target) { + assert.equal(target, form); + form.values = { ...settings }; + } + }; + const api = { + language, + sent: [], + connect(uuid) { + this.connectedUuid = uuid; + }, + onConnected(callback) { + listeners.set("connected", callback); + }, + onAdd(callback) { + listeners.set("add", callback); + }, + onParamFromApp(callback) { + listeners.set("paramFromApp", callback); + }, + sendParamFromPlugin(settings) { + this.sent.push(JSON.parse(JSON.stringify(settings))); + }, + emit(name, message = {}) { + const callback = listeners.get(name); + assert.ok(callback, `${name} callback was not registered`); + callback(message); + } + }; + + return { + api, + document, + executionPreview, + form, + Utils, + validationMessage, + window: fakeWindow, + advance(milliseconds) { + now += milliseconds; + let ranTimer; + do { + ranTimer = false; + for (const timer of timers) { + if (timer.active && timer.dueAt <= now) { + timer.active = false; + timer.callback(...timer.args); + ranTimer = true; + } + } + } while (ranTimer); + } + }; +} + +async function runInspector(harness) { + const [settingsSource, inspectorSource] = await Promise.all([ + readFile(path.join(inspectorDirectory, "settings.js"), "utf8"), + readFile(path.join(inspectorDirectory, "inspector.js"), "utf8") + ]); + const context = vm.createContext({ + document: harness.document, + window: harness.window, + Utils: harness.Utils, + $UD: harness.api + }); + + vm.runInContext(settingsSource, context); + vm.runInContext(inspectorSource, context); +} + +test("normalizes defaults and preserves string configuration verbatim", async () => { + const { DEFAULT_SETTINGS, normalizeSettings } = await loadSettings(); + + assert.deepEqual(copyFromVm(DEFAULT_SETTINGS), { + title: "执行命令", + command: "", + workingDirectory: "", + environment: "" + }); + assert.deepEqual( + copyFromVm( + normalizeSettings({ + title: " 自定义标题 ", + command: " printf '%s' value\n", + workingDirectory: " ~/work ", + environment: " FOO=value \n" + }) + ), + { + title: "自定义标题", + command: " printf '%s' value\n", + workingDirectory: " ~/work ", + environment: " FOO=value \n" + } + ); + assert.deepEqual( + copyFromVm( + normalizeSettings({ + title: " \t ", + command: 100, + workingDirectory: null, + environment: {} + }) + ), + { + title: "执行命令", + command: "", + workingDirectory: "", + environment: "" + } + ); +}); + +test("environment validation accepts blank CRLF input, first equals, and duplicates", async () => { + const { validateEnvironmentText } = await loadSettings(); + + assert.equal(validateEnvironmentText(""), ""); + assert.equal(validateEnvironmentText("\r\n \r\n"), ""); + assert.equal( + validateEnvironmentText( + " TOKEN =first=value\r\nTOKEN=last=value=kept\r\n_EMPTY=\r\n" + ), + "" + ); +}); + +test("environment validation reports 1-based lines without leaking values", async () => { + const { validateEnvironmentText } = await loadSettings(); + const invalidLine = validateEnvironmentText( + "VALID=ok\nmissing-equals-secret\nAFTER=ok" + ); + const invalidName = validateEnvironmentText( + "VALID=ok\nBAD-NAME=do-not-leak" + ); + + assert.match(invalidLine, /2/); + assert.doesNotMatch(invalidLine, /missing-equals-secret/); + assert.match(invalidName, /2/); + assert.doesNotMatch(invalidName, /do-not-leak/); +}); + +test("environment validation rejects NUL without leaking the value", async () => { + const { validateEnvironmentText } = await loadSettings(); + const message = validateEnvironmentText( + "VALID=ok\nSECRET=before\0after" + ); + + assert.match(message, /2/); + assert.match(message, /NUL/); + assert.doesNotMatch(message, /before|after/); +}); + +test("validation and preview follow the selected English or Chinese locale", async () => { + const { + validateEnvironmentText, + buildExecutionPreview + } = await loadSettings(); + const environment = "VALID=ok\nBAD-NAME=do-not-leak"; + const englishError = validateEnvironmentText(environment, "en_US"); + const chineseError = validateEnvironmentText(environment, "zh_CN"); + const englishPreview = buildExecutionPreview({}, "en"); + const chinesePreview = buildExecutionPreview({}, "zh_CN"); + + assert.match(englishError, /Environment variable line 2/); + assert.doesNotMatch(englishError, /do-not-leak/); + assert.match(chineseError, /环境变量第 2 行/); + assert.doesNotMatch(chineseError, /do-not-leak/); + assert.match( + englishPreview, + /Working directory: \$HOME[\s\S]*Environment variables: None[\s\S]*Command:\n\(empty\)/ + ); + assert.doesNotMatch(englishPreview, /工作目录|环境变量|命令|(空)/); + assert.match( + chinesePreview, + /工作目录: \$HOME[\s\S]*环境变量: 无[\s\S]*命令:\n(空)/ + ); +}); + +test("execution preview contains names, cwd, and the complete 200-argument command", async () => { + const { buildExecutionPreview } = await loadSettings(); + const command = [ + "printf", + ...Array.from({ length: 200 }, (_, index) => `"arg-${index + 1}"`) + ].join(" "); + const preview = buildExecutionPreview({ + title: "忽略", + command, + workingDirectory: "/Users/example/work", + environment: "TOKEN=top-secret\nFOO=value=with=equals\nTOKEN=last-secret" + }); + + assert.match(preview, /\$SHELL -lc/); + assert.match(preview, /\/Users\/example\/work/); + assert.match(preview, /TOKEN/); + assert.match(preview, /FOO/); + assert.doesNotMatch(preview, /top-secret|last-secret|value=with=equals/); + assert.ok(preview.includes(command)); + assert.ok(preview.endsWith(command)); +}); + +test("execution preview uses safe empty fallbacks", async () => { + const { buildExecutionPreview } = await loadSettings(); + const preview = buildExecutionPreview({}); + + assert.match(preview, /\$HOME/); + assert.match(preview, /环境变量:\s*无/); + assert.match(preview, /命令:\s*\n(空)/); +}); + +test("pagehide flushes the latest complete command before the debounce expires", async () => { + const harness = createInspectorHarness({ language: "en" }); + await runInspector(harness); + harness.api.emit("connected"); + const command = [ + "printf", + ...Array.from({ length: 200 }, (_, index) => `"value-${index + 1}"`) + ].join(" "); + harness.form.values = { + title: " Latest command ", + command, + workingDirectory: "/Users/example/work", + environment: "BAD-NAME=do-not-leak" + }; + + harness.form.dispatch("input"); + + assert.ok(harness.executionPreview.textContent.includes(command)); + assert.match( + harness.validationMessage.textContent, + /Environment variable line 1/ + ); + assert.doesNotMatch( + harness.validationMessage.textContent, + /do-not-leak/ + ); + harness.advance(199); + assert.deepEqual(harness.api.sent, []); + + harness.window.dispatch("pagehide"); + + assert.deepEqual(harness.api.sent, [ + { + title: "Latest command", + command, + workingDirectory: "/Users/example/work", + environment: "BAD-NAME=do-not-leak" + } + ]); + harness.advance(1); + assert.equal(harness.api.sent.length, 1); +}); + +test("change flushes immediately without a later duplicate send", async () => { + const harness = createInspectorHarness(); + await runInspector(harness); + harness.api.emit("connected"); + harness.form.values = { + title: "改变", + command: "printf changed", + workingDirectory: "", + environment: "" + }; + + harness.form.dispatch("input"); + harness.form.dispatch("change"); + + assert.deepEqual(harness.api.sent, [ + { + title: "改变", + command: "printf changed", + workingDirectory: "", + environment: "" + } + ]); + harness.advance(200); + assert.equal(harness.api.sent.length, 1); +}); + +test("dirty local edits survive host echoes and later host updates are accepted", async () => { + const harness = createInspectorHarness(); + await runInspector(harness); + harness.api.emit("add", { + param: { + title: "初始", + command: "printf initial", + workingDirectory: "/initial", + environment: "INITIAL=1" + } + }); + harness.api.emit("connected"); + assert.equal(harness.form.values.command, "printf initial"); + + harness.form.values = { + title: " 本地最新 ", + command: "printf local-latest", + workingDirectory: "/local", + environment: "LOCAL=latest" + }; + harness.form.dispatch("input"); + harness.api.emit("paramFromApp", { + param: { + title: "宿主旧值", + command: "printf stale-host", + workingDirectory: "/host", + environment: "HOST=stale" + } + }); + + assert.equal(harness.form.values.command, "printf local-latest"); + assert.ok( + harness.executionPreview.textContent.includes("printf local-latest") + ); + harness.advance(200); + assert.deepEqual(harness.api.sent, [ + { + title: "本地最新", + command: "printf local-latest", + workingDirectory: "/local", + environment: "LOCAL=latest" + } + ]); + + harness.api.emit("paramFromApp", { + param: { + title: "宿主新值", + command: "printf accepted-host", + workingDirectory: "/accepted", + environment: "HOST=accepted" + } + }); + assert.equal(harness.form.values.command, "printf accepted-host"); + assert.ok( + harness.executionPreview.textContent.includes("printf accepted-host") + ); +}); + +test("inspector uses official classic scripts and safe automatic persistence", async () => { + const [html, source] = await Promise.all([ + readFile(path.join(inspectorDirectory, "inspector.html"), "utf8"), + readFile(path.join(inspectorDirectory, "inspector.js"), "utf8") + ]); + const expectedScripts = [ + "../libs/js/constants.js", + "../libs/js/eventEmitter.js", + "../libs/js/timers.js", + "../libs/js/utils.js", + "../libs/js/ulanziApi.js", + "./settings.js", + "./inspector.js" + ]; + const actualScripts = [ + ...html.matchAll(/]*)\bsrc="([^"]+)"[^>]*><\/script>/g) + ].map((match) => { + assert.doesNotMatch(match[1], /\btype\s*=\s*["']module["']/i); + return match[2]; + }); + + assert.deepEqual(actualScripts, expectedScripts); + assert.match( + html, + /