diff --git a/plugins/windows-awake/.gitignore b/plugins/windows-awake/.gitignore new file mode 100644 index 000000000..23ce87649 --- /dev/null +++ b/plugins/windows-awake/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +src-ztools/dist/ +test-results/ +playwright-report/ +release/ +*.local diff --git a/plugins/windows-awake/CHANGELOG.md b/plugins/windows-awake/CHANGELOG.md new file mode 100644 index 000000000..14c4a3b4c --- /dev/null +++ b/plugins/windows-awake/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +## 1.0.0 - 2026-09-29 + +### Added + +- 首个版本:通过 Windows `SetThreadExecutionState` 阻止系统自动睡眠,可选是否同时保持屏幕点亮。 +- 唤醒时长支持 15 分钟 / 30 分钟 / 1 小时 / 2 小时 / 一直保持,以及 1 - 1440 分钟的自定义时长。 +- 息屏策略支持「不允许息屏」与「允许息屏」,运行中可随时切换并保留剩余时间,时长按新值重新计时。 +- 时长与息屏策略通过 ZTools 存储保存,插件或宿主重启后仍然保留。 +- 后台 PowerShell 守护进程按心跳与进程存在性上报真实状态,退出 ZTools 时自动清理,且不修改系统电源计划。 diff --git a/plugins/windows-awake/README.md b/plugins/windows-awake/README.md new file mode 100644 index 000000000..779c4dc71 --- /dev/null +++ b/plugins/windows-awake/README.md @@ -0,0 +1,96 @@ +# Windows 唤醒控制 + +通过 Windows 的 `SetThreadExecutionState` 阻止系统自动睡眠(可选同时保持屏幕点亮), +可以设置唤醒时长,也可以一直保持。 + +- 唤醒时长:15 分钟 / 30 分钟 / 1 小时 / 2 小时 / 一直保持 / 自定义(1 - 1440 分钟) +- 息屏策略:不允许息屏(`ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED`)或允许息屏(仅 `ES_SYSTEM_REQUIRED`) +- 只提交执行状态请求,不修改系统电源计划,到时自动释放 +- 运行中可以随时调整时长与息屏策略:时长按新值重新计时,策略立即生效并保留剩余时间 +- 关闭 ZTools 窗口后唤醒继续在后台保持;退出 ZTools 时守护进程自动清理 + +## 工作原理 + +`src-ztools/preload/services.js` 在插件数据目录写入 `awake-guard.ps1`,并用隐藏窗口的 +PowerShell 启动它。守护进程调用 `SetThreadExecutionState(ES_CONTINUOUS | ...)` 后保持存活, +按秒刷新心跳文件,直到以下任一情况发生: + +- 页面请求停止(写入 `guard-stop.flag`) +- 到达设定时长 +- 宿主 ZTools 进程退出(守护进程检查父进程号,缺失时退回按进程名判断) + +进程结束时 Windows 自动释放执行状态,界面通过心跳与进程存在性判断真实状态。 +页面只调用 `window.awakeBridge` 的 `start` / `stop` / `getStatus` / `getConfig` / `saveConfig`。 + +运行期间会在 ZTools 数据目录的 `windows-awake/` 子目录写入守护脚本、状态文件与心跳文件。 + +## 开发 + +```bash +npm install +npm run dev +``` + +开发页面默认运行在 `http://localhost:5173`,ZTools 从 +`src-ztools/plugin.json` 的 `development.main` 加载该页面。 + +## 构建 + +```bash +npm run build +``` + +构建脚本会完成类型检查和 Vite 页面构建。可安装的插件目录是 `src-ztools/`: + +- `plugin.json` +- `logo.png`(由 `logo.svg` 经 `npm run build:logo` 生成) +- `preload/` 及其 CommonJS 声明 +- `dist/` 中的 `index.html` 和前端资源 + +`src-ztools/plugin.json` 保持 `main: dist/index.html`,因此 `dist/` 只负责承载 +页面构建结果,不重复存放清单、Logo 或 preload。 + +## 测试 + +```bash +npm run test:guard # 直接驱动 PowerShell 守护进程,验证标志位、定时与自动清理 +npm run test:e2e # 在隔离的真实 ZTools 中安装插件并验证完整业务闭环 +``` + +`test:e2e` 覆盖:插件安装与页面绘制、开始/停止保持唤醒、真实守护进程与标志位、 +运行中切换息屏策略、一直保持与自定义时长、设置跨宿主重启保留、退出宿主后无残余进程、 +亮色与暗色主题截图。 + +## 打包与本地安装 + +```bash +npm run package:zpx +``` + +生成 `release/windows-awake-<版本>.zpx`(gzip 压缩的 asar,即 ZTools 的本地插件包格式), +打包副本会自动移除 `development` 入口,安装后的插件只加载本地构建页面。 + +安装方式: + +1. 执行 `ZTools.exe <插件包绝对路径>`,已运行的实例会打开「安装插件」页面并预览插件信息; + 也可以直接双击 `.zpx` 文件,或在 ZTools 设置中导入该文件。 +2. 确认插件信息后点击安装,并在「安全提示」中确认继续安装。 +3. 安装完成后搜索 `保持唤醒` / `唤醒控制` / `不锁屏` / `awake` 即可打开插件。 + +开发调试时也可以在 ZTools 的插件开发入口把 `src-ztools/` 作为开发项目导入, +直接加载 `npm run dev` 的开发页面。 + +## 结构 + +```text +logo.svg 图标源文件 +scripts/build-logo.mjs SVG → src-ztools/logo.png +scripts/build-zpx.mjs src-ztools → release/*.zpx +src/ Vue 页面源码 +src-ztools/plugin.json 插件清单 +src-ztools/preload/ 守护脚本与最小 preload 桥接 +src-ztools/dist/ Vite 页面构建结果 +release/ 本地安装包(npm run package:zpx 生成) +tests/guard/ 守护进程行为测试 +tests/e2e/ 真实 Electron 端到端测试 +``` diff --git a/plugins/windows-awake/docs/screenshots/awake-dark.png b/plugins/windows-awake/docs/screenshots/awake-dark.png new file mode 100644 index 000000000..2680cda64 Binary files /dev/null and b/plugins/windows-awake/docs/screenshots/awake-dark.png differ diff --git a/plugins/windows-awake/docs/screenshots/awake-light.png b/plugins/windows-awake/docs/screenshots/awake-light.png new file mode 100644 index 000000000..24a78a9a7 Binary files /dev/null and b/plugins/windows-awake/docs/screenshots/awake-light.png differ diff --git a/plugins/windows-awake/index.html b/plugins/windows-awake/index.html new file mode 100644 index 000000000..0fc0a4a4e --- /dev/null +++ b/plugins/windows-awake/index.html @@ -0,0 +1,12 @@ + + + + + + Windows 唤醒控制 + + +
+ + + diff --git a/plugins/windows-awake/logo.svg b/plugins/windows-awake/logo.svg new file mode 100644 index 000000000..524f5e971 --- /dev/null +++ b/plugins/windows-awake/logo.svg @@ -0,0 +1,42 @@ + + Windows 唤醒控制 + 蓝色渐变圆角方块中央是一只冒着热气的咖啡杯,代表保持系统唤醒。 + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/windows-awake/package-lock.json b/plugins/windows-awake/package-lock.json new file mode 100644 index 000000000..a469ffa26 --- /dev/null +++ b/plugins/windows-awake/package-lock.json @@ -0,0 +1,2431 @@ +{ + "name": "windows-awake", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "windows-awake", + "version": "1.0.0", + "dependencies": { + "vue": "^3.5.13" + }, + "devDependencies": { + "@electron/asar": "^4.3.1", + "@playwright/test": "^1.52.0", + "@vitejs/plugin-vue": "^5.2.1", + "@ztools-center/ztools-api-types": "^1.0.1", + "sharp": "^0.35.5", + "typescript": "^5.3.0", + "vite": "^6.0.11", + "vue-tsc": "^2.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@electron/asar": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-4.3.1.tgz", + "integrity": "sha512-6pI64z/tMSBkUTZLQFYhs2YiB7fS9sKA6gy7/2nH1Ju7GGwT32UPsj4Mxsws5NMmtR6C0zYdXVxEU/rGqJRCJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^13.0.2", + "minimatch": "^10.0.1" + }, + "bin": { + "asar": "bin/asar.mjs" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/asar/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.5.tgz", + "integrity": "sha512-J25QJU+B78T4FhhBsNpLJyVWOi31mwtpcMwywHmOKH65Q9IWGA81gPj+dnwlhU8wktVriYE+tFAaQgrnJRzAZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.5.tgz", + "integrity": "sha512-LDopB3zuZM5Ux9TT2luNEBJW/tYbGU2g1d+VpKk6I+gSKDb+/7sYE6M225gRQt4RbMX6MSwMsVR/phdjVUgRLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.5.tgz", + "integrity": "sha512-wlJEERGfeuHeBavCL2qVnNacOK43NDoZM4sjkeRPymd04OAE9T1zBqDJgmZ+CIsPTYKwdzpUC8vmOw84dwY4Tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.5.tgz", + "integrity": "sha512-4nJJGg5jbo2wwPP4JP+LfEBA3bvP8rU9CLuhp7jWvq9sxEyhjQFTFdrqi+/dHEin/pd8jpT0vcehIpnZtmEdcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.5.tgz", + "integrity": "sha512-DrZbyCDF1hneuO6jRbvZ2D7+PIBM6yIwYnJpg2vIk58T+wuFpiaGZrfUr59lDWw45bg+IrpTGLPiNi/Fk4w3Cg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.5.tgz", + "integrity": "sha512-gqfUVMJMB3mehqywxp6hTBFfgtMQykZY19+cfiaYP0toIJLb/1DZRJHVkQQGP13W4TAwfZDWeg1qBcheTRioXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.5.tgz", + "integrity": "sha512-CFmhpvAwzSaWMlN3VN7UtmoTihlZNzoP0juQib5TQRnYUyDV8dXeWOp29sobWAT6gXl/hQgAClLlEiYozQG3OQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.5.tgz", + "integrity": "sha512-Uc9H8eXCOayV6JLTH5bXKMId6qbhNHa818/BgYjm4jrlq3vZquC9cqyvHBw17xy5Mnj5f+I3gFK5JcEf3hSqrw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.5.tgz", + "integrity": "sha512-VcPr/szv/1BFw112Kt//fxulXt/JPqzzidU84iW68L2DdjnOO8QFUv2zTSYBEPHD6movBD4z+bbr5y60GYM7Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.5.tgz", + "integrity": "sha512-BnxtJ5/91BrIHYIkGrmjz/lbMhqEHt1dPFqIxIFR+jPn0xVc/oUSCtIT089zfp5ufwGDlYz2UC+Fe1SRBpYFbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.5.tgz", + "integrity": "sha512-LrYcHZwF+fAMNKHYTOQ5osWM4AZF7YF6D+XtsjDyEvljtt11twc+zHVXBLNEjxVSUnKYsOhvVz4Z213eW02COQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.5.tgz", + "integrity": "sha512-nj7QKQePAAUpCpJHtg0pR0W/b92A9NO17JS3BAQmHDn/yhmkir2p8llrKY9TOhleKIaSzy1JhxS3T9FVld6coA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.5.tgz", + "integrity": "sha512-5ylkX6dWMeBKge9nTU+Rxfb+ZfaCIJ9lRqIFaK0eAMcWp7OJbYnLveLgXmm0VrvuLKb8qIK+mHyH0qu88RM+iA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.5.tgz", + "integrity": "sha512-oHK4ZHYFDKjZviK34I+NwgfbGxgI7ztrNxj2hPTSSNFgeq1a/lEd7dHV2fdGAuTH4Iym3RHJg+vAbWaWG4B7Zg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.5.tgz", + "integrity": "sha512-UcetmHZ6XOXuUByiKZyQmb55ZPr0LABr3Ec/HB9wKZn6CEAFWZkE+hsJErJ9hbPBC7nI0dKuELx7CoV6IM7TMg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.5.tgz", + "integrity": "sha512-C5CmDPQBtvjVo8cgQsBs+w6WB0JLkiixhgi6hVLV11hERWdn/p0XcPU2OUcZzac9BPOFq7SbaHFa8r3SWEysCQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.5.tgz", + "integrity": "sha512-lHVQHJFKsuuxLMi3MQO9XVL8Tje3JR82CzB+QDKC5NWBcsIWuwsn9uIM5e3lBhI+fF1/s63qnyYqsg65+8rV/w==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.5.tgz", + "integrity": "sha512-3W9bTFcQNJn71cSJVM9RKIiZOy8DO/XLDii8Uv/Pm6WKqDRj7JV3ZfuXIEfyuy5LXpIzAbB/1M4Ukp9GKNa7nA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.5.tgz", + "integrity": "sha512-VDC7rRJlee/scpki96GZ27Omf6yU87s1YXwVTpjE5841faVlDYYT565rgfmoR1U0sqL7z5ivQSDjcsF6VRXyBA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.5.tgz", + "integrity": "sha512-z86Ok2p4pTdv5xqCKZsTooO7yBEiaJR/HzU3Wx8RmWsPoLppnMKROhJusQob8B3IE1ghC343kUW9rC2r+Wf3ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.5.tgz", + "integrity": "sha512-IzQmj+xXwQFGhMAMKMQVXkMwMZN3TqkJgAE0nSsqvVwWWciP4AIPMmWRqOQ2GfX7TUDZr+xqGFcBS36CRPGw0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.5.tgz", + "integrity": "sha512-F6qpTaPc9bwBH85kjy0/BLmLSW1uv7AoOXCoRIkg2arlgCYlWYcAbiMkvZuAcaWk9TpCRG//okznLAqLGshkMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.5.tgz", + "integrity": "sha512-igoDsTFhhwECBeGbUuLeIk7t8Y1apa+cs6mDWpx2EZ0ch7oEQgzHbFUXN9euoHekCAQzXdXApAGkV6jznS7tWw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.5.tgz", + "integrity": "sha512-U3teMeMbXFmaM5D+OTJpsOXd+wV/qftIeYF9kBKL4v73641qyJmoXFtA28DQLsnmlyayEsTe72xpLHrArq6vHw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.5.tgz", + "integrity": "sha512-ypfC34F3RKXvCXBglGqGMsUSMKlgwd1HX9AOAlx9RoZZ6GaI42YHVeKpzg3JG+wpBUJYTG+NNZhqbDWL8tBZkw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "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/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.43.tgz", + "integrity": "sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.43", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.43.tgz", + "integrity": "sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.43.tgz", + "integrity": "sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.43", + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-ssr": "3.5.43", + "@vue/shared": "3.5.43", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.28", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.43.tgz", + "integrity": "sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.43.tgz", + "integrity": "sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.43.tgz", + "integrity": "sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.43.tgz", + "integrity": "sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.43", + "@vue/runtime-core": "3.5.43", + "@vue/shared": "3.5.43", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.43.tgz", + "integrity": "sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.43.tgz", + "integrity": "sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==", + "license": "MIT" + }, + "node_modules/@ztools-center/ztools-api-types": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@ztools-center/ztools-api-types/-/ztools-api-types-1.0.3.tgz", + "integrity": "sha512-dv1eOAIasAupqKaQL/gESk1i2+RebdM/1gvZhrvH2D/bo3enCUsAGJ8nrHnlCLBSOGB81eC/SU0IH8BNsUlmvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.5.tgz", + "integrity": "sha512-KRWwmNLlPw5M7HcdYfm15oBv9n9LPtjzpzCIxS/phwqvPyxHSoKX6Y2YU3pxSPfy0CLquVgsx/j/hBi6OvH1Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.5", + "@rollup/rollup-android-arm64": "4.63.5", + "@rollup/rollup-darwin-arm64": "4.63.5", + "@rollup/rollup-darwin-x64": "4.63.5", + "@rollup/rollup-freebsd-arm64": "4.63.5", + "@rollup/rollup-freebsd-x64": "4.63.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.5", + "@rollup/rollup-linux-arm-musleabihf": "4.63.5", + "@rollup/rollup-linux-arm64-gnu": "4.63.5", + "@rollup/rollup-linux-arm64-musl": "4.63.5", + "@rollup/rollup-linux-loong64-gnu": "4.63.5", + "@rollup/rollup-linux-loong64-musl": "4.63.5", + "@rollup/rollup-linux-ppc64-gnu": "4.63.5", + "@rollup/rollup-linux-ppc64-musl": "4.63.5", + "@rollup/rollup-linux-riscv64-gnu": "4.63.5", + "@rollup/rollup-linux-riscv64-musl": "4.63.5", + "@rollup/rollup-linux-s390x-gnu": "4.63.5", + "@rollup/rollup-linux-x64-gnu": "4.63.5", + "@rollup/rollup-linux-x64-musl": "4.63.5", + "@rollup/rollup-openbsd-x64": "4.63.5", + "@rollup/rollup-openharmony-arm64": "4.63.5", + "@rollup/rollup-win32-arm64-msvc": "4.63.5", + "@rollup/rollup-win32-ia32-msvc": "4.63.5", + "@rollup/rollup-win32-x64-gnu": "4.63.5", + "@rollup/rollup-win32-x64-msvc": "4.63.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz", + "integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.43.tgz", + "integrity": "sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-sfc": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/server-renderer": "3.5.43", + "@vue/shared": "3.5.43" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/plugins/windows-awake/package.json b/plugins/windows-awake/package.json new file mode 100644 index 000000000..fc85592f0 --- /dev/null +++ b/plugins/windows-awake/package.json @@ -0,0 +1,28 @@ +{ + "name": "windows-awake", + "version": "1.0.0", + "description": "保持 Windows 唤醒,可设置唤醒时长并选择是否允许息屏", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "build:logo": "node scripts/build-logo.mjs", + "package:zpx": "npm run build && node scripts/build-zpx.mjs", + "preview": "vite preview", + "test:guard": "node tests/guard/guard.cjs", + "test:e2e": "npm run build && playwright test --config=playwright.e2e.config.js" + }, + "dependencies": { + "vue": "^3.5.13" + }, + "devDependencies": { + "@electron/asar": "^4.3.1", + "@playwright/test": "^1.52.0", + "@vitejs/plugin-vue": "^5.2.1", + "@ztools-center/ztools-api-types": "^1.0.1", + "sharp": "^0.35.5", + "typescript": "^5.3.0", + "vite": "^6.0.11", + "vue-tsc": "^2.0.0" + } +} diff --git a/plugins/windows-awake/playwright.e2e.config.js b/plugins/windows-awake/playwright.e2e.config.js new file mode 100644 index 000000000..0bda756c4 --- /dev/null +++ b/plugins/windows-awake/playwright.e2e.config.js @@ -0,0 +1,17 @@ +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + testDir: './tests/e2e', + outputDir: './test-results/playwright', + timeout: 45_000, + expect: { + timeout: 15_000, + }, + workers: 1, + fullyParallel: false, + reporter: [['list']], + use: { + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, +}) diff --git a/plugins/windows-awake/scripts/build-logo.mjs b/plugins/windows-awake/scripts/build-logo.mjs new file mode 100644 index 000000000..c17230eaa --- /dev/null +++ b/plugins/windows-awake/scripts/build-logo.mjs @@ -0,0 +1,19 @@ +// 将 logo.svg 渲染为插件清单引用的 src-ztools/logo.png。 +// 仅在需要重新生成图标时运行:node scripts/build-logo.mjs +import { readFile, writeFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import path from 'node:path' +import sharp from 'sharp' + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const sourcePath = path.join(projectRoot, 'logo.svg') +const targetPath = path.join(projectRoot, 'src-ztools', 'logo.png') + +const svg = await readFile(sourcePath) +const png = await sharp(svg, { density: 384 }) + .resize(256, 256, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }) + .png({ compressionLevel: 9 }) + .toBuffer() + +await writeFile(targetPath, png) +console.log(`已生成 ${targetPath}(${png.length} 字节)`) diff --git a/plugins/windows-awake/scripts/build-zpx.mjs b/plugins/windows-awake/scripts/build-zpx.mjs new file mode 100644 index 000000000..eae6aa28b --- /dev/null +++ b/plugins/windows-awake/scripts/build-zpx.mjs @@ -0,0 +1,68 @@ +// 将 src-ztools 打包为 ZTools 可安装的 .zpx(gzip 压缩的 asar)。 +// 运行方式:npm run package:zpx +import { createRequire } from 'node:module' +import { pipeline } from 'node:stream/promises' +import { createGzip } from 'node:zlib' +import { cp, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { createReadStream, createWriteStream } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const require = createRequire(import.meta.url) +const asar = require('@electron/asar') + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const pluginRoot = path.join(projectRoot, 'src-ztools') +const releaseDir = path.join(projectRoot, 'release') +const manifest = JSON.parse(await readFile(path.join(pluginRoot, 'plugin.json'), 'utf8')) + +// 安装前的自检:清单引用的入口必须都在插件目录内。 +for (const key of ['main', 'logo', 'preload']) { + const value = manifest[key] + if (typeof value !== 'string' || !value.trim()) continue + const target = path.join(pluginRoot, value) + try { + await stat(target) + } catch { + throw new Error(`插件清单引用的 ${key} 不存在:${target}`) + } +} + +await mkdir(releaseDir, { recursive: true }) +const targetZpx = path.join(releaseDir, `${manifest.name}-${manifest.version}.zpx`) +const stagingRoot = await mkdtemp(path.join(os.tmpdir(), `zpx-staging-${manifest.name}-`)) +const tempAsar = path.join(os.tmpdir(), `zpx-${manifest.name}-${Date.now()}.asar`) + +try { + // 打包副本移除 development 入口,安装后的插件只加载本地构建页面。 + const stagingPlugin = path.join(stagingRoot, 'plugin') + await cp(pluginRoot, stagingPlugin, { recursive: true }) + const productionManifest = { ...manifest } + delete productionManifest.development + await writeFile( + path.join(stagingPlugin, 'plugin.json'), + `${JSON.stringify(productionManifest, null, 2)}\n`, + 'utf8' + ) + + await asar.createPackage(stagingPlugin, tempAsar) + + // 校验 asar 内的清单与源码清单一致,避免打包出错。 + const packed = JSON.parse(asar.extractFile(tempAsar, 'plugin.json').toString('utf8')) + if (packed.name !== manifest.name || packed.version !== manifest.version) { + throw new Error('打包结果中的 plugin.json 与源码清单不一致') + } + const entries = asar.listPackage(tempAsar).map((entry) => String(entry).replace(/\\/g, '/').replace(/^\/+/, '')) + for (const required of [manifest.main, manifest.logo, manifest.preload]) { + const normalized = String(required).replace(/\\/g, '/') + if (!entries.includes(normalized)) throw new Error(`打包结果缺少 ${normalized}`) + } + + await pipeline(createReadStream(tempAsar), createGzip({ level: 9 }), createWriteStream(targetZpx)) + const size = (await stat(targetZpx)).size + console.log(`已生成插件包:${targetZpx}(${(size / 1024).toFixed(1)} KB,${entries.length} 个文件)`) +} finally { + await rm(tempAsar, { force: true }) + await rm(stagingRoot, { recursive: true, force: true }) +} diff --git a/plugins/windows-awake/src-ztools/logo.png b/plugins/windows-awake/src-ztools/logo.png new file mode 100644 index 000000000..307dec83f Binary files /dev/null and b/plugins/windows-awake/src-ztools/logo.png differ diff --git a/plugins/windows-awake/src-ztools/plugin.json b/plugins/windows-awake/src-ztools/plugin.json new file mode 100644 index 000000000..b91390eb7 --- /dev/null +++ b/plugins/windows-awake/src-ztools/plugin.json @@ -0,0 +1,25 @@ +{ + "$schema": "node_modules/@ztools-center/ztools-api-types/resource/ztools.schema.json", + "name": "windows-awake", + "title": "Windows 唤醒控制", + "description": "保持 Windows 唤醒,可设置唤醒时长并选择是否允许息屏", + "author": "404isfun", + "version": "1.0.0", + "platform": [ + "win32" + ], + "main": "dist/index.html", + "preload": "preload/services.js", + "logo": "logo.png", + "development": { + "main": "http://localhost:5173" + }, + "features": [ + { + "code": "awake", + "explain": "保持 Windows 唤醒,调节唤醒时长与息屏策略", + "icon": "logo.png", + "cmds": ["保持唤醒", "唤醒控制", "不锁屏", "windows awake", "awake"] + } + ] +} diff --git a/plugins/windows-awake/src-ztools/preload/guard-script.js b/plugins/windows-awake/src-ztools/preload/guard-script.js new file mode 100644 index 000000000..e90848714 --- /dev/null +++ b/plugins/windows-awake/src-ztools/preload/guard-script.js @@ -0,0 +1,75 @@ +// 唤醒守护进程的 PowerShell 源码。 +// 单独成文件:既可以保持 preload 逻辑清晰,也便于在 ZTools 之外独立验证原生调用。 +const GUARD_MARKER = '--zvc-awake-guard' +const GUARD_SCRIPT_NAME = 'awake-guard.ps1' + +const GUARD_SCRIPT = `param( + [int]$Seconds = 0, + [string]$HeartbeatFile = '', + [string]$StopFile = '', + [string]$AppProcessName = '', + [int]$ParentProcessId = 0, + [switch]$KeepDisplay +) + +# Holds a Windows execution state so the system (and optionally the display) stays +# awake until this process ends, the stop flag appears, or the deadline passes. +$ErrorActionPreference = 'Stop' + +Add-Type -Namespace ZvcAwake -Name Native -MemberDefinition '[System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] public static extern uint SetThreadExecutionState(uint esFlags);' + +$ES_CONTINUOUS = [uint32]2147483648 +$ES_SYSTEM_REQUIRED = [uint32]1 +$ES_DISPLAY_REQUIRED = [uint32]2 + +$flags = $ES_CONTINUOUS -bor $ES_SYSTEM_REQUIRED +if ($KeepDisplay) { $flags = $flags -bor $ES_DISPLAY_REQUIRED } +$flagsHex = '0x' + ([uint32]$flags).ToString('x8') + +function Write-Beat([string]$state) { + if (-not $HeartbeatFile) { return } + try { + Set-Content -LiteralPath $HeartbeatFile -Value ($state + '|' + $PID + '|' + $flagsHex) -Encoding ASCII -ErrorAction Stop + } catch { + } +} + +if ([ZvcAwake.Native]::SetThreadExecutionState([uint32]$flags) -eq 0) { + Write-Beat 'failed' + [Console]::Out.WriteLine('FAILED') + [Console]::Out.Flush() + exit 2 +} + +Write-Beat 'ready' +[Console]::Out.WriteLine('READY') +[Console]::Out.Flush() + +$deadline = [DateTime]::MaxValue +if ($Seconds -gt 0) { $deadline = (Get-Date).AddSeconds($Seconds) } + +$tick = 0 +while ($true) { + Start-Sleep -Milliseconds 500 + if ($StopFile -and (Test-Path -LiteralPath $StopFile)) { break } + if ((Get-Date) -ge $deadline) { break } + $tick = $tick + 1 + if (($tick % 4) -eq 0) { + # Exit as soon as the hosting ZTools process is gone (parent pid when known, + # otherwise any process using the application image name). + $hostAlive = $true + if ($ParentProcessId -gt 0) { + $hostAlive = @(Get-Process -Id $ParentProcessId -ErrorAction SilentlyContinue).Count -gt 0 + } elseif ($AppProcessName) { + $hostAlive = @(Get-Process -Name $AppProcessName -ErrorAction SilentlyContinue).Count -gt 0 + } + if (-not $hostAlive) { break } + Write-Beat 'hold' + } +} + +[ZvcAwake.Native]::SetThreadExecutionState($ES_CONTINUOUS) | Out-Null +exit 0 +` + +module.exports = { GUARD_SCRIPT, GUARD_MARKER, GUARD_SCRIPT_NAME } diff --git a/plugins/windows-awake/src-ztools/preload/package.json b/plugins/windows-awake/src-ztools/preload/package.json new file mode 100644 index 000000000..5bbefffba --- /dev/null +++ b/plugins/windows-awake/src-ztools/preload/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/plugins/windows-awake/src-ztools/preload/services.js b/plugins/windows-awake/src-ztools/preload/services.js new file mode 100644 index 000000000..c707eded6 --- /dev/null +++ b/plugins/windows-awake/src-ztools/preload/services.js @@ -0,0 +1,538 @@ +// Windows 唤醒控制插件:在 Node 侧托管一个最小化的唤醒守护进程。 +// 页面只调用 start / stop / getStatus / getConfig / saveConfig,其余实现细节全部留在这里。 +const { spawn, spawnSync } = require('node:child_process') +const fs = require('node:fs') +const path = require('node:path') + +const { GUARD_SCRIPT, GUARD_MARKER, GUARD_SCRIPT_NAME } = require('./guard-script.js') + +const CONFIG_KEY = 'awake-config' + +/** 自定义时长的取值边界(分钟)。0 表示一直保持。 */ +const MIN_DURATION_MINUTES = 1 +const MAX_DURATION_MINUTES = 1440 +const DEFAULT_CONFIG = Object.freeze({ durationMinutes: 30, keepDisplay: true }) + +/** 守护进程每 2 秒刷新一次心跳,超过 6 秒未刷新视为已退出。 */ +const HEARTBEAT_INTERVAL_MS = 2000 +const HEARTBEAT_FRESH_MS = 6000 +const READY_TIMEOUT_MS = 15000 +const STOP_TIMEOUT_MS = 6000 +const POLL_INTERVAL_MS = 120 + +/** SetThreadExecutionState 的标志位。 */ +const ES_CONTINUOUS = 0x80000000 +const ES_SYSTEM_REQUIRED = 0x00000001 +const ES_DISPLAY_REQUIRED = 0x00000002 + + +/** 当前页面会话中启动的守护进程;页面重新打开时该引用为空,需要依赖状态文件。 */ +let guardChild = null + +/** + * 读取宿主 API,延迟访问以便在开发预览中给出明确提示。 + * @returns {any} ZTools 全局对象。 + */ +function ztoolsApi() { + return window.ztools +} + +/** + * 判断当前系统是否为 Windows。 + * @returns {boolean} Windows 返回 true。 + */ +function isWindows() { + return process.platform === 'win32' +} + +/** + * 确认运行平台,非 Windows 时抛出可读错误。 + * @returns {void} + */ +function assertWindows() { + if (!isWindows()) { + throw new Error('该插件依赖 Windows 的 SetThreadExecutionState,只能在 Windows 上使用') + } +} + +/** + * 获取插件数据目录,用于存放守护脚本与运行时文件。 + * @returns {string} 数据目录绝对路径。 + */ +function dataDir() { + const dir = path.join(String(ztoolsApi().getPath('userData')), 'windows-awake') + fs.mkdirSync(dir, { recursive: true }) + return dir +} + +/** + * 计算守护脚本、状态、心跳和停止标记的文件路径。 + * @returns {{dir: string, script: string, state: string, heartbeat: string, stopFlag: string}} 文件路径集合。 + */ +function filePaths() { + const dir = dataDir() + return { + dir, + script: path.join(dir, GUARD_SCRIPT_NAME), + state: path.join(dir, 'guard-state.json'), + heartbeat: path.join(dir, 'guard-heartbeat.txt'), + stopFlag: path.join(dir, 'guard-stop.flag') + } +} + +/** + * 定位 powershell.exe,避免依赖 PATH。 + * @returns {string} PowerShell 可执行文件路径。 + */ +function powershellPath() { + const root = process.env.SystemRoot || process.env.windir || 'C:\\Windows' + const candidate = path.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') + return fs.existsSync(candidate) ? candidate : 'powershell.exe' +} + +/** + * 取当前应用的可执行文件名,父进程不可用时守护进程据此判断 ZTools 是否仍在运行。 + * @returns {string} 不含扩展名的进程名;无法解析时返回空字符串。 + */ +function appProcessName() { + const executable = typeof process.execPath === 'string' ? process.execPath : '' + if (!executable) return '' + const name = path.basename(executable, path.extname(executable)) + return name.replace(/[^A-Za-z0-9._-]/g, '') +} + +/** + * 取宿主进程号:插件页所在的渲染进程的父进程即 ZTools 主进程。 + * @returns {number} 父进程号;无法获取时返回 0。 + */ +function hostProcessId() { + const ppid = Number(process.ppid) + return Number.isInteger(ppid) && ppid > 0 ? ppid : 0 +} + +/** + * 把守护脚本写入数据目录,内容变化时覆盖。 + * @returns {string} 守护脚本路径。 + */ +function ensureGuardScript() { + const { script } = filePaths() + try { + if (fs.readFileSync(script, 'utf8') === GUARD_SCRIPT) return script + } catch { + // 文件不存在或不可读时直接重写。 + } + // 带 BOM 写入,保证 Windows PowerShell 5.1 按 UTF-8 解析脚本。 + fs.writeFileSync(script, `\uFEFF${GUARD_SCRIPT}`, { encoding: 'utf8' }) + return script +} + +/** + * 把时长规范化为合法分钟数。 + * @param {unknown} value 原始时长。 + * @param {number} fallback 非法时使用的默认值。 + * @returns {number} 0 到 1440 之间的整数分钟,0 表示一直保持。 + */ +function normalizeDuration(value, fallback) { + const parsed = Number(value) + if (!Number.isFinite(parsed)) return fallback + const rounded = Math.round(parsed) + if (rounded <= 0) return 0 + return Math.min(rounded, MAX_DURATION_MINUTES) +} + +/** + * 校验并补全配置对象。 + * @param {unknown} raw 读取到的原始配置。 + * @returns {{durationMinutes: number, keepDisplay: boolean}} 合法配置。 + */ +function normalizeConfig(raw) { + const source = raw && typeof raw === 'object' ? raw : {} + return { + durationMinutes: normalizeDuration(source.durationMinutes, DEFAULT_CONFIG.durationMinutes), + keepDisplay: source.keepDisplay !== false + } +} + +/** + * 读取持久化配置。 + * @returns {{durationMinutes: number, keepDisplay: boolean}} 当前配置。 + */ +function readConfig() { + try { + return normalizeConfig(ztoolsApi().dbStorage.getItem(CONFIG_KEY)) + } catch { + return { ...DEFAULT_CONFIG } + } +} + +/** + * 写入持久化配置。 + * @param {{durationMinutes?: unknown, keepDisplay?: unknown}} patch 需要更新的字段。 + * @returns {{durationMinutes: number, keepDisplay: boolean}} 写入后的完整配置。 + */ +function writeConfig(patch) { + const merged = { ...readConfig() } + if (patch && typeof patch === 'object') { + if (patch.durationMinutes !== undefined) { + merged.durationMinutes = normalizeDuration(patch.durationMinutes, merged.durationMinutes) + } + if (patch.keepDisplay !== undefined) { + merged.keepDisplay = patch.keepDisplay !== false + } + } + try { + ztoolsApi().dbStorage.setItem(CONFIG_KEY, merged) + } catch { + // 存储不可用时仍然让本次运行生效,只是无法记忆选择。 + } + return merged +} + +/** + * 判断进程是否仍存在。 + * @param {number} pid 进程号。 + * @returns {boolean} 存在返回 true。 + */ +function isPidAlive(pid) { + if (!Number.isInteger(pid) || pid <= 0) return false + try { + process.kill(pid, 0) + return true + } catch (error) { + return Boolean(error) && error.code === 'EPERM' + } +} + +/** + * 读取心跳文件,解析守护进程状态。 + * @returns {{state: string, pid: number, flags: number, fresh: boolean} | null} 心跳信息。 + */ +function readHeartbeat() { + const { heartbeat } = filePaths() + try { + const stat = fs.statSync(heartbeat) + const [state = '', pidText = '', flagsText = ''] = fs.readFileSync(heartbeat, 'utf8').trim().split('|') + return { + state, + pid: Number(pidText) || 0, + flags: Number(flagsText) || 0, + fresh: Date.now() - stat.mtimeMs <= HEARTBEAT_FRESH_MS + } + } catch { + return null + } +} + +/** + * 判断心跳是否表示守护进程正在正常工作。 + * @param {{state: string, pid: number, flags: number, fresh: boolean} | null} heartbeat 心跳信息。 + * @param {number} pid 期望的进程号。 + * @returns {boolean} 心跳有效返回 true。 + */ +function isHeartbeatHealthy(heartbeat, pid) { + return Boolean(heartbeat) && heartbeat.pid === pid && heartbeat.flags > 0 && heartbeat.fresh +} + +/** + * 读取守护进程状态文件。 + * @returns {{pid: number, startedAt: number, endsAt: number | null, keepDisplay: boolean, durationMinutes: number} | null} 状态信息。 + */ +function readGuardState() { + try { + const parsed = JSON.parse(fs.readFileSync(filePaths().state, 'utf8')) + if (!parsed || typeof parsed !== 'object') return null + const pid = Number(parsed.pid) + if (!Number.isInteger(pid) || pid <= 0) return null + return { + pid, + startedAt: Number(parsed.startedAt) || 0, + endsAt: parsed.endsAt === null || parsed.endsAt === undefined ? null : Number(parsed.endsAt) || 0, + keepDisplay: parsed.keepDisplay !== false, + durationMinutes: Number(parsed.durationMinutes) || 0 + } + } catch { + return null + } +} + +/** + * 删除运行时文件。 + * @returns {void} + */ +function cleanupRuntimeFiles() { + const paths = filePaths() + for (const file of [paths.state, paths.heartbeat, paths.stopFlag]) { + try { + fs.rmSync(file, { force: true }) + } catch { + // 文件已被删除或暂时被占用时忽略。 + } + } +} + +/** + * 汇总当前状态,供页面渲染。 + * @returns {object} 状态对象。 + */ +function collectStatus() { + const base = { + supported: isWindows(), + active: false, + stale: false, + pid: 0, + startedAt: 0, + endsAt: null, + durationMinutes: 0, + keepDisplay: DEFAULT_CONFIG.keepDisplay, + remainingSeconds: null, + elapsedSeconds: 0, + flags: 0 + } + if (!base.supported) return base + + const state = readGuardState() + if (!state) return base + + const heartbeat = readHeartbeat() + const ownedByThisPage = Boolean(guardChild) && guardChild.pid === state.pid + const alive = ownedByThisPage || isPidAlive(state.pid) + // 心跳既确认进程存活,也证明 SetThreadExecutionState 调用成功(flags 非 0)。 + if (!alive || !isHeartbeatHealthy(heartbeat, state.pid)) { + return { + ...base, + stale: true, + pid: state.pid, + startedAt: state.startedAt, + endsAt: state.endsAt, + keepDisplay: state.keepDisplay, + durationMinutes: state.durationMinutes + } + } + + const now = Date.now() + return { + ...base, + active: true, + pid: state.pid, + startedAt: state.startedAt, + endsAt: state.endsAt, + durationMinutes: state.durationMinutes, + keepDisplay: state.keepDisplay, + remainingSeconds: state.endsAt === null ? null : Math.max(0, Math.round((state.endsAt - now) / 1000)), + elapsedSeconds: state.startedAt ? Math.max(0, Math.round((now - state.startedAt) / 1000)) : 0, + flags: heartbeat.flags + } +} + +/** + * 轮询等待条件成立。 + * @param {() => boolean} predicate 判定函数。 + * @param {number} timeoutMs 超时毫秒数。 + * @returns {Promise} 条件成立返回 true,超时返回 false。 + */ +async function waitUntil(predicate, timeoutMs) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (predicate()) return true + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)) + } + return predicate() +} + +/** + * 校验 PID 归属后强制结束守护进程,避免误杀复用同一 PID 的其他进程。 + * @param {number} pid 进程号。 + * @returns {boolean} 该进程已不存在时返回 true。 + */ +function killGuardProcess(pid) { + if (!isPidAlive(pid)) return true + const command = `$p = Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}' -ErrorAction SilentlyContinue; if ($p -and $p.CommandLine -like '*${GUARD_SCRIPT_NAME}*' -and $p.CommandLine -like '*${GUARD_MARKER}*') { Stop-Process -Id ${pid} -Force }` + try { + spawnSync(powershellPath(), ['-NoProfile', '-NonInteractive', '-Command', command], { + windowsHide: true, + timeout: 15000 + }) + } catch { + // 校验或结束失败时不清除状态文件,让用户看到真实状态。 + } + return !isPidAlive(pid) +} + +/** + * 停止守护进程并清理运行时文件。 + * @returns {Promise} 停止后的状态。 + */ +async function stopGuard() { + const state = readGuardState() + if (!state) { + guardChild = null + cleanupRuntimeFiles() + return collectStatus() + } + + const paths = filePaths() + try { + fs.writeFileSync(paths.stopFlag, String(Date.now()), { encoding: 'utf8' }) + } catch { + // 无法写入停止标记时仍继续尝试等待与强制结束。 + } + + const child = guardChild && guardChild.pid === state.pid ? guardChild : null + let exited = Boolean(child) && (child.exitCode !== null || child.signalCode !== null) + if (child && !exited) { + exited = await Promise.race([ + new Promise((resolve) => child.once('exit', () => resolve(true))), + new Promise((resolve) => setTimeout(() => resolve(false), STOP_TIMEOUT_MS)) + ]) + } + if (!exited) { + // 页面重载后没有子进程引用,改用进程存在性与心跳判断。 + exited = await waitUntil( + () => !isPidAlive(state.pid) || !isHeartbeatHealthy(readHeartbeat(), state.pid), + STOP_TIMEOUT_MS + ) + } + if (!exited) exited = killGuardProcess(state.pid) + + guardChild = null + if (exited || !isPidAlive(state.pid)) { + cleanupRuntimeFiles() + } + return collectStatus() +} + +/** + * 启动守护进程,并按需保留剩余时长。 + * @param {{durationMinutes?: unknown, keepDisplay?: unknown, preserveRemaining?: unknown}} payload 启动参数。 + * @returns {Promise} 启动后的状态。 + * @throws 守护进程未能在限定时间内就绪时抛出可读错误。 + */ +async function startGuard(payload) { + assertWindows() + const patch = payload && typeof payload === 'object' ? payload : {} + const config = readConfig() + const durationMinutes = + patch.durationMinutes === undefined + ? config.durationMinutes + : normalizeDuration(patch.durationMinutes, config.durationMinutes) + const keepDisplay = patch.keepDisplay === undefined ? config.keepDisplay : patch.keepDisplay !== false + + const previous = collectStatus() + let seconds = durationMinutes === 0 ? 0 : durationMinutes * 60 + if (patch.preserveRemaining && previous.active && previous.remainingSeconds !== null) { + seconds = Math.max(0, previous.remainingSeconds) + } + + await stopGuard() + + const paths = filePaths() + const script = ensureGuardScript() + const startedAt = Date.now() + const endsAt = seconds > 0 ? startedAt + seconds * 1000 : null + const args = [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-WindowStyle', + 'Hidden', + '-File', + script, + '-Seconds', + String(seconds), + '-HeartbeatFile', + paths.heartbeat, + '-StopFile', + paths.stopFlag, + '-AppProcessName', + appProcessName(), + '-ParentProcessId', + String(hostProcessId()), + GUARD_MARKER + ] + if (keepDisplay) args.push('-KeepDisplay') + + let stderr = '' + const child = spawn(powershellPath(), args, { + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'] + }) + guardChild = child + child.stdout.setEncoding('utf8') + child.stdout.on('data', () => {}) + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk) => { + stderr = `${stderr}${chunk}`.slice(-2000) + }) + child.on('exit', () => { + if (guardChild === child) guardChild = null + }) + // 先落盘状态,保证页面重载后仍能读到本次唤醒。 + fs.writeFileSync( + paths.state, + JSON.stringify({ pid: child.pid, startedAt, endsAt, keepDisplay, durationMinutes }), + { encoding: 'utf8' } + ) + + // 守护进程成功调用 SetThreadExecutionState 后会写入 ready/hold 心跳。 + await waitUntil(() => { + const heartbeat = readHeartbeat() + if (heartbeat && heartbeat.pid === child.pid && ['ready', 'hold'].includes(heartbeat.state)) return true + return child.exitCode !== null + }, READY_TIMEOUT_MS) + + const heartbeat = readHeartbeat() + const started = + Boolean(heartbeat) && heartbeat.pid === child.pid && ['ready', 'hold'].includes(heartbeat.state) + if (!started) { + killGuardProcess(child.pid) + guardChild = null + cleanupRuntimeFiles() + const detail = stderr.trim().split(/\r?\n/).filter(Boolean).pop() + throw new Error( + `唤醒守护进程启动失败${detail ? `:${detail}` : ',请确认 PowerShell 可用且未被安全策略限制'}` + ) + } + + writeConfig({ durationMinutes, keepDisplay }) + return collectStatus() +} + +// 只暴露页面需要的业务方法。 +window.awakeBridge = Object.freeze({ + /** 读取当前配置。 */ + async getConfig() { + return readConfig() + }, + /** 保存页面上的时长与息屏选择。 */ + async saveConfig(patch) { + return writeConfig(patch) + }, + /** 读取当前唤醒状态,并清理已失效的守护进程。 */ + async getStatus() { + const status = collectStatus() + // 启动阶段会短暂缺少心跳,只有在启动窗口结束后才判定为幺留进程。 + if (status.stale && Date.now() - status.startedAt > READY_TIMEOUT_MS) { + await stopGuard() + return collectStatus() + } + return status + }, + /** 开始保持唤醒。 */ + async start(payload) { + return startGuard(payload) + }, + /** 停止保持唤醒。 */ + async stop() { + return stopGuard() + }, + /** 页面渲染需要的边界值。 */ + constants: Object.freeze({ + MIN_DURATION_MINUTES, + MAX_DURATION_MINUTES, + HEARTBEAT_INTERVAL_MS, + ES_CONTINUOUS, + ES_SYSTEM_REQUIRED, + ES_DISPLAY_REQUIRED + }) +}) diff --git a/plugins/windows-awake/src/App.vue b/plugins/windows-awake/src/App.vue new file mode 100644 index 000000000..f6c039020 --- /dev/null +++ b/plugins/windows-awake/src/App.vue @@ -0,0 +1,738 @@ + + + + + diff --git a/plugins/windows-awake/src/awake-format.ts b/plugins/windows-awake/src/awake-format.ts new file mode 100644 index 000000000..c4b8f1d00 --- /dev/null +++ b/plugins/windows-awake/src/awake-format.ts @@ -0,0 +1,71 @@ +/** 界面使用的常量与格式化工具。 */ + +/** 唤醒时长预设,value 为分钟数,0 表示一直保持。 */ +export const PRESETS = [ + { label: '15 分钟', value: 15 }, + { label: '30 分钟', value: 30 }, + { label: '1 小时', value: 60 }, + { label: '2 小时', value: 120 }, + { label: '一直保持', value: 0 } +] as const + +/** 桥接不可用(开发预览)时的兜底边界值,取值与 preload 保持一致。 */ +const FALLBACK_LIMITS = { min: 1, max: 1440 } + +/** 自定义时长的最小值(分钟)。 */ +export const MIN_DURATION_MINUTES = window.awakeBridge?.constants?.MIN_DURATION_MINUTES ?? FALLBACK_LIMITS.min + +/** 自定义时长的最大值(分钟)。 */ +export const MAX_DURATION_MINUTES = window.awakeBridge?.constants?.MAX_DURATION_MINUTES ?? FALLBACK_LIMITS.max + +/** + * 补零。 + * @param value 数字。 + * @returns 至少两位的字符串。 + */ +function pad(value: number): string { + return String(value).padStart(2, '0') +} + +/** + * 把秒数格式化为倒计时文本。 + * @param totalSeconds 总秒数。 + * @returns mm:ss 或 h:mm:ss。 + */ +export function formatClock(totalSeconds: number): string { + const seconds = Math.max(0, Math.floor(totalSeconds)) + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + if (hours > 0) return `${hours}:${pad(minutes)}:${pad(seconds % 60)}` + return `${pad(minutes)}:${pad(seconds % 60)}` +} + +/** + * 把秒数格式化为人类可读时长。 + * @param totalSeconds 总秒数。 + * @param withSeconds 不足 1 分钟时是否显示秒数。 + * @returns 例如 “1 小时 20 分钟”。 + */ +export function formatDuration(totalSeconds: number, withSeconds = false): string { + const seconds = Math.max(0, Math.round(totalSeconds)) + const minutes = Math.floor(seconds / 60) + const hours = Math.floor(minutes / 60) + const restMinutes = minutes % 60 + if (hours > 0) return restMinutes > 0 ? `${hours} 小时 ${restMinutes} 分钟` : `${hours} 小时` + if (minutes > 0) return `${minutes} 分钟` + return withSeconds ? `${seconds} 秒` : '不足 1 分钟' +} + +/** + * 把时间戳格式化为本地时间。 + * @param timestamp 时间戳(毫秒)。 + * @returns HH:mm:ss。 + */ +export function formatTime(timestamp: number): string { + return new Date(timestamp).toLocaleTimeString('zh-CN', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) +} diff --git a/plugins/windows-awake/src/awake-types.ts b/plugins/windows-awake/src/awake-types.ts new file mode 100644 index 000000000..a2da2a8a3 --- /dev/null +++ b/plugins/windows-awake/src/awake-types.ts @@ -0,0 +1,60 @@ +/** 页面与 preload 桥接共享的类型定义。 */ + +/** 当前唤醒状态。 */ +export interface AwakeStatus { + /** 当前平台是否支持。 */ + supported: boolean + /** 是否正在保持唤醒。 */ + active: boolean + /** 状态文件存在但守护进程已失效(内部标记)。 */ + stale?: boolean + /** 守护进程 PID。 */ + pid: number + /** 本轮开始时间戳。 */ + startedAt: number + /** 结束时间戳;null 表示一直保持。 */ + endsAt: number | null + /** 本轮使用的时长(分钟,0 表示一直保持)。 */ + durationMinutes: number + /** 是否同时保持屏幕点亮。 */ + keepDisplay: boolean + /** 剩余秒数;null 表示一直保持。 */ + remainingSeconds: number | null + /** 已保持秒数。 */ + elapsedSeconds: number + /** 守护进程实际提交给 SetThreadExecutionState 的标志位。 */ + flags: number +} + +/** 用户选择并持久化的配置。 */ +export interface AwakeConfig { + /** 唤醒时长(分钟,0 表示一直保持)。 */ + durationMinutes: number + /** true 表示不允许息屏。 */ + keepDisplay: boolean +} + +/** 启动参数。 */ +export interface AwakeStartPayload { + durationMinutes?: number + keepDisplay?: boolean + /** 运行中调整策略时保留剩余时长。 */ + preserveRemaining?: boolean +} + +/** preload 暴露的业务能力。 */ +export interface AwakeBridge { + getConfig(): Promise + saveConfig(patch: Partial): Promise + getStatus(): Promise + start(payload?: AwakeStartPayload): Promise + stop(): Promise + constants: { + MIN_DURATION_MINUTES: number + MAX_DURATION_MINUTES: number + HEARTBEAT_INTERVAL_MS: number + ES_CONTINUOUS: number + ES_SYSTEM_REQUIRED: number + ES_DISPLAY_REQUIRED: number + } +} diff --git a/plugins/windows-awake/src/env.d.ts b/plugins/windows-awake/src/env.d.ts new file mode 100644 index 000000000..a79d55fb6 --- /dev/null +++ b/plugins/windows-awake/src/env.d.ts @@ -0,0 +1,20 @@ +/// +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent, Record, unknown> + export default component +} + +// Preload 暴露的唤醒控制能力(对应 src-ztools/preload/services.js) +import type { AwakeBridge } from './awake-types' + +declare global { + interface Window { + /** 仅在 preload 成功注入时存在,开发预览中可能为 undefined。 */ + awakeBridge?: AwakeBridge + } +} + +export {} diff --git a/plugins/windows-awake/src/main.css b/plugins/windows-awake/src/main.css new file mode 100644 index 000000000..e6fb745f9 --- /dev/null +++ b/plugins/windows-awake/src/main.css @@ -0,0 +1,86 @@ +:root { + color-scheme: light dark; + + --awake-primary: var(--plugin-primary-color, #2f7cf6); + --awake-primary-soft: rgba(47, 124, 246, 0.12); + --awake-primary-soft: color-mix(in srgb, var(--awake-primary) 12%, transparent); + --awake-text: #1c2230; + --awake-text-weak: #5f6a7d; + --awake-surface: #ffffff; + --awake-surface-muted: #f1f3f7; + --awake-border: rgba(28, 34, 48, 0.13); + --awake-shadow: 0 1px 2px rgba(28, 34, 48, 0.06); + --awake-success: #10795a; + --awake-success-soft: rgba(16, 121, 90, 0.12); + --awake-success-border: rgba(16, 121, 90, 0.35); + --awake-danger: #c2352a; + --awake-danger-solid: #c2352a; + --awake-danger-soft: rgba(194, 53, 42, 0.12); + --awake-danger-border: rgba(194, 53, 42, 0.35); + --awake-radius: 12px; +} + +html, +body { + margin: 0; + padding: 0; + background: transparent; +} + +body { + font-family: 'Microsoft YaHei UI', 'Segoe UI', 'PingFang SC', system-ui, sans-serif; + font-size: 14px; + line-height: 1.5; + color: var(--awake-text); + background-color: #f4f5f8; + -webkit-font-smoothing: antialiased; +} + +button, +input { + font-family: inherit; +} + +button:focus-visible, +input:focus-visible { + outline: 2px solid var(--awake-primary); + outline-offset: 1px; +} + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-thumb { + border-radius: 4px; + background-color: rgba(120, 130, 148, 0.45); +} + +@media (prefers-color-scheme: dark) { + :root { + --awake-primary-soft: rgba(84, 152, 255, 0.18); + --awake-primary-soft: color-mix(in srgb, var(--awake-primary) 18%, transparent); + --awake-text: #eef1f7; + --awake-text-weak: #a3adbe; + --awake-surface: #262b35; + --awake-surface-muted: #20242d; + --awake-border: rgba(255, 255, 255, 0.14); + --awake-shadow: none; + --awake-success: #4fd6a0; + --awake-success-soft: rgba(79, 214, 160, 0.16); + --awake-success-border: rgba(79, 214, 160, 0.4); + --awake-danger: #ff8f83; + --awake-danger-solid: #c0453a; + --awake-danger-soft: rgba(255, 143, 131, 0.16); + --awake-danger-border: rgba(255, 143, 131, 0.4); + } + + body { + background-color: #1b1f27; + } + + ::-webkit-scrollbar-thumb { + background-color: rgba(180, 190, 205, 0.35); + } +} diff --git a/plugins/windows-awake/src/main.ts b/plugins/windows-awake/src/main.ts new file mode 100644 index 000000000..8a3c77bc5 --- /dev/null +++ b/plugins/windows-awake/src/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import './main.css' +import App from './App.vue' + +createApp(App).mount('#app') diff --git a/plugins/windows-awake/tests/e2e/plugin.spec.js b/plugins/windows-awake/tests/e2e/plugin.spec.js new file mode 100644 index 000000000..2f3fd619b --- /dev/null +++ b/plugins/windows-awake/tests/e2e/plugin.spec.js @@ -0,0 +1,515 @@ +import { expect, test, _electron as electron } from '@playwright/test' +import { spawnSync } from 'node:child_process' +import { constants as fsConstants } from 'node:fs' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') +const sourcePluginRoot = path.join(projectRoot, 'src-ztools') +const settingsUrlFragment = 'internal-plugins/setting/index.html' +/** 不允许息屏 = ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED。 */ +const KEEP_SYSTEM_AND_DISPLAY_FLAGS = 0x80000003 +/** 允许息屏 = ES_CONTINUOUS | ES_SYSTEM_REQUIRED。 */ +const KEEP_SYSTEM_FLAGS = 0x80000001 + +let dataRoot = '' +let legacyRoot = '' +let pluginRoot = '' +let pluginConfigPath = '' +let manifest = null +let expectedUrl = '' +let featureCode = '' +let electronApp = null + +/** + * 读取由当前运行中 ZTools 进程注入的宿主可执行文件路径。 + * @returns {Promise} ZTools 可执行文件绝对路径。 + * @throws 宿主路径缺失、不是绝对路径或不可执行时抛出。 + */ +async function resolveZToolsExecutable() { + const executablePath = String(process.env.ZTOOLS_E2E_EXECUTABLE_PATH || '').trim() + if (!executablePath) throw new Error('当前 ZTools 进程未提供宿主路径,请从 ZVC 中运行测试') + if (!path.isAbsolute(executablePath)) throw new Error('ZTOOLS_E2E_EXECUTABLE_PATH 必须是绝对路径') + try { + await fs.access(executablePath, fsConstants.X_OK) + return executablePath + } catch { + throw new Error(`当前 ZTools 宿主不存在或不可执行:${executablePath}`) + } +} + +/** + * 在设置插件的 WebContentsView 中执行受控脚本。 + * @param {import('@playwright/test').ElectronApplication} app 隔离的 Electron 应用。 + * @param {string} source 要执行的页面脚本。 + * @returns {Promise} 页面脚本执行结果。 + * @throws 未找到设置页时抛出错误。 + */ +async function executeInSettings(app, source) { + return app.evaluate(async ({ webContents }, script) => { + const contents = webContents + .getAllWebContents() + .find((item) => item.getURL().includes('internal-plugins/setting/index.html')) + if (!contents) throw new Error('未找到设置插件 WebContentsView') + return contents.executeJavaScript(script) + }, source) +} + +/** + * 在插件页面中执行脚本,用于读取状态和触发交互。 + * @param {import('@playwright/test').ElectronApplication} app 隔离的 Electron 应用。 + * @param {string} source 要执行的页面脚本。 + * @returns {Promise} 页面脚本执行结果。 + * @throws 未找到插件页面时抛出错误。 + */ +async function executeInPlugin(app, source) { + return app.evaluate( + async ({ webContents }, payload) => { + const contents = webContents.getAllWebContents().find((item) => item.getURL() === payload.url) + if (!contents) { + const urls = webContents.getAllWebContents().map((item) => item.getURL()) + throw new Error(`未找到插件页面:${payload.url}\n当前页面:${urls.join('\n')}`) + } + return contents.executeJavaScript(payload.source) + }, + { url: expectedUrl, source } + ) +} + +/** + * 读取设置插件正文,供 Playwright 轮询加载状态。 + * @param {import('@playwright/test').ElectronApplication} app 隔离的 Electron 应用。 + * @returns {Promise} 设置页正文;尚未完成加载时为空字符串。 + */ +async function readSettingsText(app) { + return app.evaluate(async ({ webContents }) => { + const contents = webContents + .getAllWebContents() + .find((item) => item.getURL().includes('internal-plugins/setting/index.html')) + if (!contents || contents.isLoading()) return '' + return contents.executeJavaScript('document.body?.innerText || ""') + }) +} + +/** + * 插件页面中某个元素的可读文本。 + * @param {import('@playwright/test').ElectronApplication} app 隔离的 Electron 应用。 + * @param {string} selector CSS 选择器。 + * @returns {Promise} 元素文本;元素不存在时返回空字符串。 + */ +async function readPluginText(app, selector) { + return executeInPlugin( + app, + `(() => { const el = document.querySelector(${JSON.stringify(selector)}); return el ? (el.textContent || '').trim() : '' })()` + ) +} + +/** + * 在插件页面中点击元素。 + * @param {import('@playwright/test').ElectronApplication} app 隔离的 Electron 应用。 + * @param {string} selector CSS 选择器。 + * @returns {Promise} 是否成功点击。 + */ +async function clickPluginElement(app, selector) { + return executeInPlugin( + app, + `(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return false; el.click(); return true })()` + ) +} + +/** + * 读取页面上的 aria-checked 状态。 + * @param {import('@playwright/test').ElectronApplication} app 隔离的 Electron 应用。 + * @param {string} selector CSS 选择器。 + * @returns {Promise} aria-checked 的值。 + */ +async function readPluginChecked(app, selector) { + return executeInPlugin( + app, + `(() => { const el = document.querySelector(${JSON.stringify(selector)}); return el ? String(el.getAttribute('aria-checked')) : 'missing' })()` + ) +} + +/** + * 读取插件内由 preload 提供的真实状态。 + * @param {import('@playwright/test').ElectronApplication} app 隔离的 Electron 应用。 + * @returns {Promise} awakeBridge.getStatus() 的结果。 + */ +async function readBridgeStatus(app) { + return executeInPlugin(app, 'window.awakeBridge.getStatus()') +} + +/** + * 写入输入框的值并触发 Vue 的 v-model 与 change 处理。 + * @param {import('@playwright/test').ElectronApplication} app 隔离的 Electron 应用。 + * @param {string} selector CSS 选择器。 + * @param {string} value 要写入的值。 + * @returns {Promise} 是否成功写入。 + */ +async function setPluginInputValue(app, selector, value) { + return executeInPlugin( + app, + `(() => { + const el = document.querySelector(${JSON.stringify(selector)}); + if (!el) return false; + el.value = ${JSON.stringify(value)}; + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + return true; + })()` + ) +} + +/** + * 枚举当前系统中由本插件守护脚本启动的 PowerShell 进程。 + * 查询脚本写入文件后以 -File 运行,避免嵌套引号在 -Command 中被重新解析。 + * @returns {Promise<{pid: number, commandLine: string}[]>} 守护进程列表。 + */ +async function listGuardProcesses() { + const queryScript = path.join(dataRoot, 'list-guards.ps1') + const source = [ + 'Get-CimInstance Win32_Process -Filter "Name = \'powershell.exe\'" |', + " Where-Object { $_.CommandLine -like '*awake-guard.ps1*' -and $_.CommandLine -like '*--zvc-awake-guard*' } |", + ' ForEach-Object { \'{0}|{1}\' -f $_.ProcessId, $_.CommandLine }' + ].join('\n') + await fs.writeFile(queryScript, `${'\uFEFF'}${source}\n`, 'utf8') + const result = spawnSync( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', queryScript], + { encoding: 'utf8', windowsHide: true } + ) + return String(result.stdout || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const separator = line.indexOf('|') + return { pid: Number(line.slice(0, separator)), commandLine: line.slice(separator + 1) } + }) + .filter((item) => Number.isInteger(item.pid) && item.pid > 0) +} + +/** + * 启动一个使用隔离数据目录的 ZTools 实例。 + * @returns {Promise} 隔离实例。 + */ +async function launchIsolatedApp() { + const executablePath = await resolveZToolsExecutable() + const developmentAppRoot = String(process.env.ZTOOLS_E2E_APP_ROOT || '').trim() + return electron.launch({ + executablePath, + args: developmentAppRoot ? [developmentAppRoot] : [], + env: { + ...Object.fromEntries(Object.entries(process.env).filter(([, value]) => value)), + ZTOOLS_DATA_ROOT: dataRoot, + ZTOOLS_E2E: '1', + ZTOOLS_LEGACY_USER_DATA_PATH: legacyRoot + } + }) +} + +/** + * 打开通用设置并通过内部 API 导入、安装开发插件。 + * @param {import('@playwright/test').ElectronApplication} app 隔离的 Electron 应用。 + * @returns {Promise} 无返回值。 + */ +async function prepareSettingsPage(app) { + const page = await app.firstWindow() + const searchInput = page.locator('.search-input') + await expect(searchInput).toBeVisible() + await searchInput.fill('通用设置') + await page.locator('.app-item, .list-item').filter({ hasText: '通用设置' }).first().click() + await expect.poll(() => readSettingsText(app), { timeout: 15_000 }).not.toBe('') + + const imported = await executeInSettings( + app, + `window.ztools.internal.importDevPlugin(${JSON.stringify(pluginConfigPath)})` + ) + expect(imported, JSON.stringify(imported)).toMatchObject({ success: true }) + const installed = await executeInSettings( + app, + `window.ztools.internal.installDevPlugin(${JSON.stringify(manifest.name)})` + ) + expect(installed, JSON.stringify(installed)).toMatchObject({ success: true }) +} + +/** + * 以指定功能 code 启动插件页面。 + * @param {import('@playwright/test').ElectronApplication} app 隔离的 Electron 应用。 + * @returns {Promise} 无返回值。 + */ +async function launchPlugin(app) { + const launched = await executeInSettings( + app, + `window.ztools.internal.launch({path: ${JSON.stringify(pluginRoot)}, type: 'plugin', name: ${JSON.stringify(manifest.title)}, param: {payload: '', type: 'text', code: ${JSON.stringify(featureCode)}}})` + ) + expect(launched, JSON.stringify(launched)).toMatchObject({ success: true }) + await expect + .poll( + async () => { + return app.evaluate( + async ({ webContents }, url) => { + const contents = webContents.getAllWebContents().find((item) => item.getURL() === url) + if (!contents || contents.isLoading()) return '' + return contents.executeJavaScript('document.body?.innerText || ""') + }, + expectedUrl + ) + }, + { timeout: 20_000 } + ) + .toContain('Windows 唤醒控制') +} + +/** + * 读取插件页面状态并统计截图中的非背景像素。 + * @param {import('@playwright/test').ElectronApplication} app 隔离的 Electron 应用。 + * @returns {Promise<{ready: boolean, text: string, png: string, width: number, height: number, nonBackgroundPixels: number}>} 页面状态和截图。 + */ +async function inspectPluginView(app) { + return app.evaluate(async ({ webContents }, targetUrl) => { + const contents = webContents.getAllWebContents().find((item) => item.getURL() === targetUrl) + if (!contents || contents.isLoading()) { + return { ready: false, text: '', png: '', width: 0, height: 0, nonBackgroundPixels: 0 } + } + + const text = await contents.executeJavaScript('document.body?.innerText || ""') + const image = await contents.capturePage() + const bitmap = image.toBitmap() + let nonBackgroundPixels = 0 + // 防止 DOM 已加载但 Electron 合成层仍为空白。 + for (let index = 0; index < bitmap.length; index += 4) { + const blue = bitmap[index] + const green = bitmap[index + 1] + const red = bitmap[index + 2] + const spread = Math.max(red, green, blue) - Math.min(red, green, blue) + if (spread > 8 || (red + green + blue) / 3 < 220) nonBackgroundPixels += 1 + } + + return { + ready: true, + text, + png: image.toPNG().toString('base64'), + width: image.getSize().width, + height: image.getSize().height, + nonBackgroundPixels + } + }, expectedUrl) +} + +test.describe.configure({ mode: 'serial' }) + +test.beforeAll(async () => { + dataRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'ztools-plugin-e2e-')) + legacyRoot = path.join(dataRoot, 'legacy') + await fs.mkdir(legacyRoot, { recursive: true }) + // 使用构建产物的隔离副本,并禁用开发入口以验证生产页面。 + pluginRoot = path.join(dataRoot, 'plugin') + await fs.cp(sourcePluginRoot, pluginRoot, { recursive: true }) + pluginConfigPath = path.join(pluginRoot, 'plugin.json') + manifest = JSON.parse(await fs.readFile(pluginConfigPath, 'utf8')) + delete manifest.development + await fs.writeFile(pluginConfigPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8') + featureCode = manifest.features?.[0]?.code || 'open' + expectedUrl = pathToFileURL(path.resolve(pluginRoot, manifest.main)).href + + electronApp = await launchIsolatedApp() + await prepareSettingsPage(electronApp) + await launchPlugin(electronApp) +}) + +test.afterAll(async () => { + await electronApp?.close() + electronApp = null + await fs.rm(dataRoot, { recursive: true, force: true }) +}) + +test('可在隔离的真实 ZTools 中安装、启动并控制 Windows 唤醒', async ({}, testInfo) => { + const baselineGuards = await listGuardProcesses() + + // 初始状态:未唤醒,桥接与常量可用。 + const constants = await executeInPlugin(electronApp, 'window.awakeBridge.constants') + expect(constants.MAX_DURATION_MINUTES).toBe(1440) + expect(constants.ES_DISPLAY_REQUIRED).toBe(2) + const initialStatus = await readBridgeStatus(electronApp) + expect(initialStatus.active).toBe(false) + await expect.poll(() => readPluginText(electronApp, '[data-testid="countdown"]')).toBe('未唤醒') + + // 选择 15 分钟 + 不允许息屏后开始保持唤醒。 + expect(await clickPluginElement(electronApp, '[data-testid="preset-15"]')).toBe(true) + await expect.poll(() => readPluginChecked(electronApp, '[data-testid="preset-15"]')).toBe('true') + await expect.poll(() => readPluginChecked(electronApp, '[data-testid="choice-no-screen-off"]')).toBe('true') + expect(await clickPluginElement(electronApp, '[data-testid="toggle"]')).toBe(true) + await expect.poll(() => readPluginText(electronApp, '[data-testid="toggle"]'), { timeout: 20_000 }).toBe( + '停止保持唤醒' + ) + + // preload 上报的状态必须与真实守护进程一致:15 分钟定时、同时保持屏幕点亮。 + await expect + .poll(async () => (await readBridgeStatus(electronApp)).active, { timeout: 20_000 }) + .toBe(true) + const running = await readBridgeStatus(electronApp) + expect(running.keepDisplay).toBe(true) + expect(running.flags).toBe(KEEP_SYSTEM_AND_DISPLAY_FLAGS) + expect(running.endsAt - running.startedAt).toBe(15 * 60 * 1000) + expect(running.remainingSeconds).toBeGreaterThan(14 * 60) + + // 状态与运行时文件写入隔离的数据目录,不会污染用户真实数据。 + const userDataPath = await executeInPlugin(electronApp, "window.ztools.getPath('userData')") + const runtimeDir = path.join(userDataPath, 'windows-awake') + expect(path.resolve(userDataPath).startsWith(path.resolve(dataRoot))).toBe(true) + const stateFile = path.join(runtimeDir, 'guard-state.json') + const statePayload = JSON.parse(await fs.readFile(stateFile, 'utf8')) + expect(statePayload.pid).toBe(running.pid) + expect(statePayload.keepDisplay).toBe(true) + expect(statePayload.durationMinutes).toBe(15) + + // 系统中确实存在带有本插件标记的守护进程,并且请求了屏幕唤醒。 + await expect + .poll(async () => (await listGuardProcesses()).length, { timeout: 20_000 }) + .toBe(baselineGuards.length + 1) + const guard = (await listGuardProcesses()).find((item) => item.pid === running.pid) + expect(guard).toBeTruthy() + expect(guard.commandLine).toContain('--zvc-awake-guard') + expect(guard.commandLine).toContain('-KeepDisplay') + + // 倒计时在界面中持续走字。 + const countdownBefore = await readPluginText(electronApp, '[data-testid="countdown"]') + expect(countdownBefore).toMatch(/^\d{2}:\d{2}$/) + await expect + .poll(() => readPluginText(electronApp, '[data-testid="countdown"]'), { timeout: 10_000 }) + .not.toBe(countdownBefore) + + // 运行中切换为允许息屏:立即下发新的标志位,并保留剩余时长。 + expect(await clickPluginElement(electronApp, '[data-testid="choice-allow-screen-off"]')).toBe(true) + await expect + .poll(async () => (await readBridgeStatus(electronApp)).flags, { timeout: 20_000 }) + .toBe(KEEP_SYSTEM_FLAGS) + const afterSwitch = await readBridgeStatus(electronApp) + expect(afterSwitch.keepDisplay).toBe(false) + expect(afterSwitch.pid).not.toBe(running.pid) + expect(Math.abs(afterSwitch.endsAt - running.endsAt)).toBeLessThan(10_000) + await expect + .poll(async () => (await listGuardProcesses()).length, { timeout: 20_000 }) + .toBe(baselineGuards.length + 1) + + const inspection = await inspectPluginView(electronApp) + expect(inspection.ready).toBe(true) + expect(inspection.text).toContain('保持唤醒中') + expect(inspection.width).toBeGreaterThan(0) + expect(inspection.height).toBeGreaterThan(0) + expect(inspection.nonBackgroundPixels).toBeGreaterThan(50) + const screenshotPath = testInfo.outputPath('awake-running.png') + await fs.writeFile(screenshotPath, Buffer.from(inspection.png, 'base64')) + await testInfo.attach('awake-running', { path: screenshotPath, contentType: 'image/png' }) + + // 暗色主题下同样可读:在渲染器中仿真系统深色偏好,卡片、选中项与状态色都应切换。 + const darkTheme = await electronApp.evaluate( + async ({ webContents }, url) => { + const contents = webContents.getAllWebContents().find((item) => item.getURL() === url) + contents.debugger.attach('1.3') + await contents.debugger.sendCommand('Emulation.setEmulatedMedia', { + features: [{ name: 'prefers-color-scheme', value: 'dark' }] + }) + return contents.executeJavaScript(`JSON.stringify({ + matches: window.matchMedia('(prefers-color-scheme: dark)').matches, + body: getComputedStyle(document.body).backgroundColor, + card: getComputedStyle(document.querySelector('.awake__card')).backgroundColor + })`) + }, + expectedUrl + ) + const darkStyles = JSON.parse(darkTheme) + expect(darkStyles.matches).toBe(true) + expect(darkStyles.body).toBe('rgb(27, 31, 39)') + expect(darkStyles.card).toBe('rgb(38, 43, 53)') + await expect + .poll(async () => (await inspectPluginView(electronApp)).nonBackgroundPixels > 50, { timeout: 10_000 }) + .toBe(true) + const darkInspection = await inspectPluginView(electronApp) + expect(darkInspection.text).toContain('保持唤醒中') + expect(darkInspection.text).toContain('允许息屏') + const darkScreenshotPath = testInfo.outputPath('awake-running-dark.png') + await fs.writeFile(darkScreenshotPath, Buffer.from(darkInspection.png, 'base64')) + await testInfo.attach('awake-running-dark', { path: darkScreenshotPath, contentType: 'image/png' }) + + // 停止保持唤醒:状态、界面与系统进程都要回到未唤醒。 + expect(await clickPluginElement(electronApp, '[data-testid="toggle"]')).toBe(true) + await expect.poll(() => readPluginText(electronApp, '[data-testid="toggle"]'), { timeout: 20_000 }).toBe( + '开始保持唤醒' + ) + expect((await readBridgeStatus(electronApp)).active).toBe(false) + await expect + .poll(async () => (await listGuardProcesses()).length, { timeout: 20_000 }) + .toBe(baselineGuards.length) + await expect.poll(() => fs.readFile(stateFile, 'utf8').then(() => true, () => false)).toBe(false) + + // 停止后界面保留最近一次选择,且不显示错误。 + await expect.poll(() => readPluginChecked(electronApp, '[data-testid="preset-15"]')).toBe('true') + await expect.poll(() => readPluginChecked(electronApp, '[data-testid="choice-allow-screen-off"]')).toBe('true') + expect(await readPluginText(electronApp, '[data-testid="error"]')).toBe('') + + // “一直保持”不带结束时间,界面显示不限时,同时可以切回不允许息屏。 + expect(await clickPluginElement(electronApp, '[data-testid="choice-no-screen-off"]')).toBe(true) + expect(await clickPluginElement(electronApp, '[data-testid="preset-0"]')).toBe(true) + expect(await clickPluginElement(electronApp, '[data-testid="toggle"]')).toBe(true) + await expect + .poll(async () => (await readBridgeStatus(electronApp)).active, { timeout: 20_000 }) + .toBe(true) + const endless = await readBridgeStatus(electronApp) + expect(endless.endsAt).toBe(null) + expect(endless.remainingSeconds).toBe(null) + expect(endless.keepDisplay).toBe(true) + expect(endless.flags).toBe(KEEP_SYSTEM_AND_DISPLAY_FLAGS) + await expect.poll(() => readPluginText(electronApp, '[data-testid="countdown"]')).toBe('不限时') + const endlessState = JSON.parse(await fs.readFile(stateFile, 'utf8')) + expect(endlessState.endsAt).toBe(null) + expect(endlessState.durationMinutes).toBe(0) + expect(await clickPluginElement(electronApp, '[data-testid="toggle"]')).toBe(true) + await expect + .poll(async () => (await listGuardProcesses()).length, { timeout: 20_000 }) + .toBe(baselineGuards.length) + + // 自定义时长写入宿主存储,并在重启后保留。 + expect(await clickPluginElement(electronApp, '[data-testid="preset-custom"]')).toBe(true) + await expect.poll(() => readPluginChecked(electronApp, '[data-testid="preset-custom"]')).toBe('true') + expect(await setPluginInputValue(electronApp, '[data-testid="custom-minutes"]', '20')).toBe(true) + await expect + .poll(async () => executeInPlugin(electronApp, 'window.awakeBridge.getConfig()'), { timeout: 10_000 }) + .toEqual({ durationMinutes: 20, keepDisplay: true }) + await expect.poll(() => readPluginChecked(electronApp, '[data-testid="preset-15"]')).toBe('false') + expect(await readPluginText(electronApp, '[data-testid="error"]')).toBe('') +}) + +test('重启 ZTools 后保留设置,且退出宿主时不留残余进程', async () => { + await electronApp.close() + electronApp = await launchIsolatedApp() + await prepareSettingsPage(electronApp) + await launchPlugin(electronApp) + + // 上一次会话的选择通过宿主存储保留下来。 + const config = await executeInPlugin(electronApp, 'window.awakeBridge.getConfig()') + expect(config).toEqual({ durationMinutes: 20, keepDisplay: true }) + await expect.poll(() => readPluginChecked(electronApp, '[data-testid="preset-custom"]')).toBe('true') + expect(await executeInPlugin(electronApp, 'document.querySelector(\'[data-testid="custom-minutes"]\').value')).toBe('20') + await expect.poll(() => readPluginChecked(electronApp, '[data-testid="choice-no-screen-off"]')).toBe('true') + expect((await readBridgeStatus(electronApp)).active).toBe(false) + + // 启动后直接关闭宿主,守护进程应自行退出。 + const baselineGuards = await listGuardProcesses() + expect(await clickPluginElement(electronApp, '[data-testid="toggle"]')).toBe(true) + await expect + .poll(async () => (await readBridgeStatus(electronApp)).active, { timeout: 20_000 }) + .toBe(true) + await expect + .poll(async () => (await listGuardProcesses()).length, { timeout: 20_000 }) + .toBe(baselineGuards.length + 1) + + await electronApp.close() + electronApp = null + await expect + .poll(async () => (await listGuardProcesses()).length, { timeout: 30_000 }) + .toBe(baselineGuards.length) +}) diff --git a/plugins/windows-awake/tests/guard/guard.cjs b/plugins/windows-awake/tests/guard/guard.cjs new file mode 100644 index 000000000..0a397d131 --- /dev/null +++ b/plugins/windows-awake/tests/guard/guard.cjs @@ -0,0 +1,230 @@ +// 独立验证唤醒守护进程:不依赖 ZTools,直接运行 PowerShell 守护脚本并检查真实行为。 +// 运行方式:npm run test:guard +const assert = require('node:assert/strict') +const { spawn, spawnSync } = require('node:child_process') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') + +const { GUARD_SCRIPT } = require('../../src-ztools/preload/guard-script.js') + +const ES_CONTINUOUS = 0x80000000 +const ES_SYSTEM_REQUIRED = 0x00000001 +const ES_DISPLAY_REQUIRED = 0x00000002 +const KEEP_SYSTEM_FLAGS = (ES_CONTINUOUS | ES_SYSTEM_REQUIRED) >>> 0 +const KEEP_SYSTEM_AND_DISPLAY_FLAGS = (ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED) >>> 0 +const READY_TIMEOUT_MS = 20_000 +const EXIT_TIMEOUT_MS = 8_000 + +const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zvc-awake-guard-')) +const liveGuards = new Set() +const scriptPath = path.join(workDir, 'awake-guard.ps1') +const heartbeatPath = path.join(workDir, 'heartbeat.txt') +const stopFlagPath = path.join(workDir, 'stop.flag') +fs.writeFileSync(scriptPath, `\uFEFF${GUARD_SCRIPT}`, 'utf8') + +/** + * 定位 powershell.exe。 + * @returns {string} PowerShell 可执行文件路径。 + */ +function powershellPath() { + const candidate = path.join( + process.env.SystemRoot || 'C:\\Windows', + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe' + ) + return fs.existsSync(candidate) ? candidate : 'powershell.exe' +} + +/** + * 等待条件成立。 + * @param {() => boolean} predicate 判定函数。 + * @param {number} timeoutMs 超时毫秒数。 + * @returns {Promise} 条件成立返回 true。 + */ +async function waitFor(predicate, timeoutMs) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (predicate()) return true + await new Promise((resolve) => setTimeout(resolve, 100)) + } + return predicate() +} + +/** + * 判断进程是否仍然存在。 + * @param {number} pid 进程号。 + * @returns {boolean} 存在返回 true。 + */ +function isAlive(pid) { + try { + process.kill(pid, 0) + return true + } catch (error) { + return Boolean(error) && error.code === 'EPERM' + } +} + +/** + * 读取心跳文件内容。 + * @returns {{state: string, pid: number, flags: number} | null} 解析后的心跳。 + */ +function readHeartbeat() { + try { + const [state = '', pid = '0', flags = '0'] = fs.readFileSync(heartbeatPath, 'utf8').trim().split('|') + return { state, pid: Number(pid), flags: Number(flags) } + } catch { + return null + } +} + +/** + * 启动一次守护进程。 + * @param {Record} options 启动选项。 + * @returns {{child: import('node:child_process').ChildProcess, stdout: () => string}} 进程句柄与输出读取函数。 + */ +function startGuard(options) { + fs.rmSync(heartbeatPath, { force: true }) + fs.rmSync(stopFlagPath, { force: true }) + const args = [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-WindowStyle', + 'Hidden', + '-File', + scriptPath, + '-Seconds', + String(options.seconds ?? 0), + '-HeartbeatFile', + heartbeatPath, + '-StopFile', + stopFlagPath, + '-AppProcessName', + String(options.appProcessName ?? 'node'), + '-ParentProcessId', + String(options.parentProcessId ?? 0), + '--zvc-awake-guard' + ] + if (options.keepDisplay) args.push('-KeepDisplay') + const child = spawn(powershellPath(), args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) + liveGuards.add(child) + child.on('exit', () => liveGuards.delete(child)) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', (chunk) => { + stdout += chunk + }) + child.stderr.on('data', (chunk) => { + stderr += chunk + }) + child.on('exit', () => { + if (stderr.trim()) console.log(` [guard stderr] ${stderr.trim().split(/\r?\n/).pop()}`) + }) + return { child, stdout: () => stdout } +} + +/** + * 等待守护进程结束。 + * @param {import('node:child_process').ChildProcess} child 守护进程。 + * @param {number} timeoutMs 超时毫秒数。 + * @returns {Promise} 已结束返回 true。 + */ +async function waitExit(child, timeoutMs) { + return Promise.race([ + new Promise((resolve) => child.once('exit', () => resolve(true))), + new Promise((resolve) => setTimeout(() => resolve(false), timeoutMs)) + ]) +} + +/** + * 通过停止标记结束守护进程,并确认进程确实退出。 + * @param {import('node:child_process').ChildProcess} child 守护进程。 + * @returns {Promise} 无返回值。 + */ +async function stopViaFlag(child) { + fs.writeFileSync(stopFlagPath, String(Date.now()), 'utf8') + const exited = await waitExit(child, EXIT_TIMEOUT_MS) + assert.equal(exited, true, '写入停止标记后守护进程应在 8 秒内退出') + assert.equal(isAlive(child.pid), false, '守护进程退出后不应再存在') +} + +/** 记录可选的系统级佐证信息。 */ +function logPowerRequests() { + const result = spawnSync('powercfg', ['/requests'], { encoding: 'utf8', windowsHide: true }) + const text = `${result.stdout || ''}${result.stderr || ''}`.trim() + console.log(` powercfg /requests → ${text ? text.split(/\r?\n/)[0] : '无输出(查询需要管理员权限)'}`) +} + +async function main() { + console.log(`守护脚本:${scriptPath}`) + + console.log('1. 不允许息屏:ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED') + const withDisplay = startGuard({ seconds: 0, appProcessName: 'node', keepDisplay: true }) + const readyWithDisplay = await waitFor(() => withDisplay.stdout().includes('READY'), READY_TIMEOUT_MS) + assert.equal(readyWithDisplay, true, '守护进程应输出 READY') + const beatWithDisplay = readHeartbeat() + assert.equal(beatWithDisplay.state, 'ready', '心跳状态应为 ready') + assert.equal(beatWithDisplay.pid, withDisplay.child.pid, '心跳 PID 应与守护进程一致') + assert.equal( + beatWithDisplay.flags, + KEEP_SYSTEM_AND_DISPLAY_FLAGS, + '不允许息屏时应同时请求保持系统与屏幕唤醒' + ) + await new Promise((resolve) => setTimeout(resolve, 2500)) + assert.equal(readHeartbeat().state, 'hold', '运行期间应持续刷新心跳') + logPowerRequests() + await stopViaFlag(withDisplay.child) + + console.log('2. 允许息屏:ES_CONTINUOUS | ES_SYSTEM_REQUIRED') + const systemOnly = startGuard({ seconds: 0, appProcessName: 'node', keepDisplay: false }) + assert.equal(await waitFor(() => systemOnly.stdout().includes('READY'), READY_TIMEOUT_MS), true) + assert.equal( + readHeartbeat().flags, + KEEP_SYSTEM_FLAGS, + '允许息屏时不应请求保持屏幕唤醒' + ) + await stopViaFlag(systemOnly.child) + + console.log('3. 定时唤醒:到时自动释放') + const timed = startGuard({ seconds: 2, appProcessName: 'node', keepDisplay: false }) + assert.equal(await waitFor(() => timed.stdout().includes('READY'), READY_TIMEOUT_MS), true) + assert.equal(await waitExit(timed.child, EXIT_TIMEOUT_MS), true, '定时唤醒应自动退出') + assert.equal(isAlive(timed.child.pid), false, '自动退出后不应残留进程') + + console.log('4. 宿主进程消失:守护进程自行清理') + const orphan = startGuard({ seconds: 0, appProcessName: 'zvc-no-such-process-xyz', keepDisplay: false }) + assert.equal(await waitFor(() => orphan.stdout().includes('READY'), READY_TIMEOUT_MS), true) + assert.equal(await waitExit(orphan.child, EXIT_TIMEOUT_MS), true, '没有宿主进程时守护进程应自行退出') + + console.log('5. 父进程消失:即使同名进程仍在也自行清理') + const noParent = startGuard({ seconds: 0, appProcessName: 'node', parentProcessId: 999999, keepDisplay: false }) + assert.equal(await waitFor(() => noParent.stdout().includes('READY'), READY_TIMEOUT_MS), true) + assert.equal(await waitExit(noParent.child, EXIT_TIMEOUT_MS), true, '父进程不存在时守护进程应自行退出') + + console.log('全部守护进程行为检查通过') +} + +main() + .catch((error) => { + console.error(`检查失败:${error && error.message ? error.message : error}`) + process.exitCode = 1 + }) + .finally(async () => { + // 断言失败时也必须结束仍在运行的守护进程,否则会一直占用唤醒状态。 + for (const child of liveGuards) { + try { + fs.writeFileSync(stopFlagPath, String(Date.now()), 'utf8') + const exited = await waitExit(child, 3000) + if (!exited) child.kill() + } catch { + child.kill() + } + } + fs.rmSync(workDir, { recursive: true, force: true }) + }) diff --git a/plugins/windows-awake/tsconfig.json b/plugins/windows-awake/tsconfig.json new file mode 100644 index 000000000..39b73bc6f --- /dev/null +++ b/plugins/windows-awake/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "strict": false, + "noImplicitAny": false, + "types": ["@ztools-center/ztools-api-types"] + }, + "include": ["src"] +} diff --git a/plugins/windows-awake/vite.config.js b/plugins/windows-awake/vite.config.js new file mode 100644 index 000000000..5ca697ff2 --- /dev/null +++ b/plugins/windows-awake/vite.config.js @@ -0,0 +1,13 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [vue()], + base: './', + build: { + outDir: fileURLToPath(new URL('./src-ztools/dist', import.meta.url)), + emptyOutDir: true + } +})