From 9c7081b21de3406d50b88446e90aae12220f7c05 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 15 Sep 2026 01:30:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20ClockLab=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E5=8F=AF=E8=B0=83=E9=80=9F=E6=A1=8C=E9=9D=A2=E6=97=B6=E9=92=9F?= =?UTF-8?q?=EF=BC=88=E6=95=B0=E5=AD=97/=E8=A1=A8=E7=9B=98=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=80=8D=E7=8E=87=E3=80=81=E6=9A=82=E5=81=9C?= =?UTF-8?q?=E3=80=81=E5=80=92=E8=BD=AC=E3=80=81=E5=BF=AB=E8=BF=9B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一个用于演练/演示的桌面时钟工具:两个窗口 —— 控制窗口(普通)与时钟窗口(无边框、 可拖动、可拖边缘改大小,由控制窗口开与关)。 功能 - 两个窗口各自的「始终置顶」开关 - 显示样式:纯数字 / 圆形表盘,切换立即生效 - 实时设定时间(含「填入当前显示时间」) - 倍率:支持正数、负数(倒转)、0(暂停)、小数 - 暂停 / 继续(继续时回到暂停前的倍率,按钮文案会显示是几倍) - 快进五档:+5 秒 / +1 分钟 / +5 分钟 / +10 分钟 / +30 分钟 实现要点 - 走时是「基准时间 + 基准时刻 + 倍率」的锚点式模型(不累加、零漂移); 倍率、暂停、倒转、快进因此是同一套机制的四个用法,互不打架 - 跳转动画做在「呈现时间」这一层:数字滚动与表盘指针扫动共用同一个中间值, 所以永远同步,也不会跨 12 点倒着转一圈 - 无边框窗口的拖动(CSS app-region + 控件 no-drag)与拖边缘改大小 (自己算几何 + IPC setBounds,因为 frame:false 下系统边缘命中不可靠) 测试 152 例:走时模型 / 改大小几何 / 呈现时间 / 两个渲染进程(DOM 桩) --- .gitignore | 10 + .npmrc | 7 + DEVELOP.md | 205 +++ README.md | 110 +- build/icon.ico | Bin 0 -> 25730 bytes build/icon.png | Bin 0 -> 3381 bytes main.js | 243 +++ package.json | 75 + pnpm-lock.yaml | 2413 ++++++++++++++++++++++++++ pnpm-workspace.yaml | 6 + preload.js | 21 + renderer/clock.css | 214 +++ renderer/clock.html | 47 + renderer/clock.js | 190 ++ renderer/control.css | 297 ++++ renderer/control.html | 105 ++ renderer/control.js | 145 ++ renderer/presented-time.js | 74 + renderer/resize.js | 42 + src/clock-model.js | 36 + src/selftest.js | 257 +++ test/clock-model.test.js | 167 ++ test/presented-time.test.js | 188 ++ test/renderer-clock-anim.dom-test.js | 218 +++ test/renderer-clock.dom-test.js | 185 ++ test/renderer-control.dom-test.js | 132 ++ test/resize.test.js | 82 + 27 files changed, 5467 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 DEVELOP.md create mode 100644 build/icon.ico create mode 100644 build/icon.png create mode 100644 main.js create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 preload.js create mode 100644 renderer/clock.css create mode 100644 renderer/clock.html create mode 100644 renderer/clock.js create mode 100644 renderer/control.css create mode 100644 renderer/control.html create mode 100644 renderer/control.js create mode 100644 renderer/presented-time.js create mode 100644 renderer/resize.js create mode 100644 src/clock-model.js create mode 100644 src/selftest.js create mode 100644 test/clock-model.test.js create mode 100644 test/presented-time.test.js create mode 100644 test/renderer-clock-anim.dom-test.js create mode 100644 test/renderer-clock.dom-test.js create mode 100644 test/renderer-control.dom-test.js create mode 100644 test/resize.test.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c38753c --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# 依赖(几百 MB,且由 pnpm 从镜像还原) +node_modules/ + +# 构建产物(Windows zip / Linux tar.gz 各上百 MB,随 Release 或交付包分发,不进仓库) +dist/ + +# 日志与系统垃圾 +*.log +.DS_Store +Thumbs.db diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..94e2d91 --- /dev/null +++ b/.npmrc @@ -0,0 +1,7 @@ +# 依赖与二进制一律走国内镜像 / 加速站点(这台机器在离线内网环境外,直连官方源很慢甚至失败)。 +# 注意:这几个键对 pnpm 同样生效(pnpm 读 .npmrc),所以本机与构建机都不用再手配环境变量。 +registry=https://registry.npmmirror.com + +# electron 的运行时(postinstall 下载的那几十 MB)与 electron-builder 的打包工具二进制 +electron_mirror=https://npmmirror.com/mirrors/electron/ +electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/ diff --git a/DEVELOP.md b/DEVELOP.md new file mode 100644 index 0000000..3a57511 --- /dev/null +++ b/DEVELOP.md @@ -0,0 +1,205 @@ +# ClockLab · 开发文档 + +面向**要改这个项目**的人。使用者请看 [README.md](./README.md)。 + +--- + +## 一、项目结构 + +``` +main.js 主进程:建两个窗口、持有走时模型、注册全部 IPC、夹窗口尺寸 +preload.js contextBridge 暴露 window.clockApi(contextIsolation:true,无 nodeIntegration) +renderer/ + control.html/.css/.js 控制窗口:三块分区(时钟窗口 / 显示样式 / 时间与流速) + clock.html/.css/.js 时钟窗口:拖动区 + 8 条改大小热区 + 数字/表盘两种视图 + resize.js 拖边缘改大小的纯几何计算(单独拆出来是为了能在 node 里跑用例) + presented-time.js 呈现时间层:跳转动画(纯逻辑,浏览器/node 双向可加载) +src/ + clock-model.js 走时模型:基准时间 + 基准时刻 + 倍率(纯函数,可单测) + selftest.js GUI 自检(electron . --selftest),跑完出截图,只在开发机上用 +test/ 6 个文件、152 条用例(见第五节) +build/icon.ico, icon.png 程序图标 +.npmrc、pnpm-workspace.yaml 镜像与 pnpm 11 的依赖构建放行(见第六节,**别删**) +``` + +**分层原则**:凡是能用纯函数表达的(走时、改大小几何、呈现时间)都**单独成模块**,页面里只做接线。这样 152 条用例里有 103 条不需要浏览器环境就能跑。 + +--- + +## 二、三个关键实现 + +### 2.1 走时模型:锚点式,不累加 + +`src/clock-model.js` 只有三个动作,**每一个都是"重新锚定基准"**,没有一处累加: + +```js +displayMs(clock, now) = clock.baseMs + (now - clock.baseRealMs) * clock.rate + +setRate(clock, now, rate) // 倍率、暂停(0)、倒转(负)、小数都走这里 + clock.baseMs = displayMs(clock, now) // 先把当前显示时间固化 → 切倍率不跳变 + clock.baseRealMs = now + clock.rate = rate + if (rate !== 0) clock.lastRate = rate // 暂停时记住上次倍率,供「继续」用 + +shift(clock, now, deltaMs) // 快进/快退:与倍率完全解耦 +setTimeOfDay(clock, now, h, m, s) // 设定时间 +``` + +为什么这么写:如果用 `setInterval` 每次 `+1 秒`,倍率不是整数时会累积漂移,而且暂停/倒转都要另开分支。锚点式写法下,**倍率、暂停、倒转、快进是同一套机制的四个用法**,互不打架("先倒转、再暂停、再 +5 分钟"不会丢掉倒转的偏移)。 + +两个窗口各自用 `Date.now()`(同一台机器)跑同一个公式,画面 60fps 重算。 + +### 2.2 呈现时间层:跳转动画 + +`renderer/presented-time.js`。**数字和指针都从"呈现时间"推导**,而不是各做一套动画。 + +```js +function jumpBetween(previous, next) { // 同一真实时刻,两个参数算出的目标值之差 + const at = next.baseRealMs; + return targetAt(next, at) - targetAt(previous, at); +} +const animate = layer.snapshot !== null && snapshot.rate !== 0 + && Math.abs(jump) > JUMP_THRESHOLD_MS; // 1500ms +``` + +三个细节,**别在重构时弄丢**: + +1. **改倍率不会被误判成跳变** —— 比较的是"同一真实时刻的两个目标值",而模型在切倍率时先把当前显示时间固化进基准,两个公式在这一刻必然相等; +2. **数字和指针永远同步** —— 它们读同一个呈现时间,不会出现"数字到了、指针还在半路"; +3. **不会跨 12 点倒着转一圈** —— 动画的是**时间**,指针角度照样按正常公式从时间算(359°→1° 自然往前扫)。这是这类动画最经典的坑,用"动画时间而不是动画角度"直接绕开。 + +阈值 1500ms、缓动 0.35s `ease-out`。**动画没结束又来新跳变时,从当前呈现值重新出发**(不是跳回旧值重来)。 + +### 2.3 无边框窗口:拖动与改大小都是自己实现的 + +`frame: false` 之后,Windows 上**靠系统做边缘命中不可靠**,所以: + +- **拖动移动**:CSS `-webkit-app-region: drag`。整块 `.stage` 是拖动区,**按钮和 8 条热区单独声明 `no-drag`** —— 否则按钮会被拖动区盖住、点不动。 +- **拖边缘改大小**:渲染进程热区接 `pointerdown/move`,用「按下瞬间的窗口 bounds + 鼠标位移」算新 bounds,走 IPC 让主进程 `setBounds`。几何计算在 `renderer/resize.js`(纯函数,17 条用例)。 + - `pointerdown` 时用 `sendSync` 取 bounds —— **必须在这一刻拿到精确基准**; + - 顶到最小尺寸后继续拖,**被拖的那条边停住**,而不是把整个窗口推走(`resize.js` 里有专门处理与用例); + - 主进程 `applyBounds` 再夹一次最小值并挡掉 `NaN`。 + +最小尺寸 **260×150**(`main.js` 的 `MIN_SIZE`)。 + +--- + +## 三、环境与命令 + +```bash +pnpm install # 依赖 +npm start # 起应用 +npm test # 152 条用例(纯逻辑 + DOM 桩) +npm run selftest # GUI 自检:electron . --selftest,跑完出截图 +``` + +⚠️ **本机(Linux 无显示环境)跑 `npm start` 要 Xvfb**,而且 GPU 会刷 MESA 报错 —— 那是正常的。 + +--- + +## 四、改代码的几条约定 + +- **纯逻辑尽量往模块里放**(`src/clock-model.js`、`renderer/resize.js`、`renderer/presented-time.js`),页面只接线 —— 这样测试不用浏览器。 +- **改行为就同时改用例**,但**改断言之前先想清楚**:现有 152 条里有不少是钉边界的(例如"拖西边超过最小宽度时右边界不动"),它们是有意为之。 +- **数字输入相关的坑**:HTML 里 `type="number"` 的 `v-model`/取值在不同框架下会给 `number` 而不是字符串,本项目一律显式 `String(x)` 再用;**另一个坑是补零** —— `String(0)` 是 `"0"`,而时钟输入框按 `HH:MM:SS` 解析,所以必须 `pad2`(这个 bug 只在**凌晨 9 点前**才露出来,白天怎么测都是绿的)。 +- 注释写**为什么**,不写"这里做了什么"。 + +--- + +## 五、测试(152 条) + +| 文件 | 条数 | 覆盖 | +| --- | --- | --- | +| `test/clock-model.test.js` | 26 | 走时:零漂移、倍率切换不跳变、暂停/继续、倒转、快进 | +| `test/resize.test.js` | 17 | 改大小几何:四边四角、最小尺寸夹紧 | +| `test/presented-time.test.js` | 30 | 跳变判定、阈值、重定向、暂停不缓动、换倍率不算跳变 | +| `test/renderer-clock.dom-test.js` | 24 | 时钟窗口 DOM 桩:渲染、样式切换、热区接线 | +| `test/renderer-clock-anim.dom-test.js` | 26 | 跳转动画全链路:参数下发 → 呈现时间 → 数字 + 指针 | +| `test/renderer-control.dom-test.js` | 29 | 控制窗口:各按钮发出的调用、输入夹紧、置顶开关 | + +--- + +## 六、构建与打包 + +### 6.1 Windows(在 Windows 构建机上) + +**推荐在真 Windows 上打**,不要在这台 Linux 上跨平台打。踩过的坑见 6.3,能跑的流程是这样: + +```bat +:: 1) 依赖:必须 --ignore-scripts +:: electron 的 postinstall 会去下载运行时,网络不通时会把整个 install 拖垮 +pnpm install --ignore-scripts + +:: 2) 运行时:把 electron-v38.2.2-win32-x64.zip 手工解压到位 +:: ⚠️ node_modules/electron 是 pnpm 的**符号链接**,tar 拒绝"穿过符号链接"解压 +:: → 先解到临时目录,再 robocopy 进去 +mkdir D:\tmp\electron-dist +cd /d D:\tmp\electron-dist && tar -xf D:\tmp\electron-win-x64.zip +robocopy D:\tmp\electron-dist <项目>\node_modules\electron\dist /E + +:: 3) 出包:直接调 electron-builder 的 CLI(`pnpm run` 会先做依赖检查、可能 EPERM) +:: 用 --config.electronDist 明确告诉它运行时在哪,别让它走下载分支 +node node_modules\electron-builder\out\cli\cli.js --win --x64 ^ + --config.electronDist=D:\tmp\electron-dist + +:: 4) 压缩:electron-builder 的 zip target 会去下 7zip 工具(见 6.3),自己压更稳 +cd dist && tar -a -c -f ClockLab-1.0.0-win-x64.zip win-unpacked +``` + +产物:`dist\win-unpacked\`(目录版)+ 自己压的 zip。 + +### 6.2 Linux ARM64(在本机打) + +**手工拼**,不要用 electron-builder: + +```bash +# 1) 复制 Electron 运行时(本机是 arm64,node_modules/electron/dist 就是对的版本) +cp -a node_modules/electron/dist/. dist/ClockLab-1.0.0-linux-arm64/ +mv dist/ClockLab-1.0.0-linux-arm64/electron dist/ClockLab-1.0.0-linux-arm64/clocklab + +# 2) 应用代码进 resources/app/ +mkdir -p dist/ClockLab-1.0.0-linux-arm64/resources/app +cp main.js preload.js package.json dist/ClockLab-1.0.0-linux-arm64/resources/app/ +cp -a src renderer dist/ClockLab-1.0.0-linux-arm64/resources/app/ + +# 3) 打 tar.gz +tar -C dist -czf dist/ClockLab-1.0.0-linux-arm64.tar.gz ClockLab-1.0.0-linux-arm64 +``` + +**验证**:解压后 `./clocklab` 直接能跑(本机就是 arm64,Xvfb 下起得来即说明链接与资源都对)。 + +### 6.3 踩过的坑(**看这一节能省几个小时**) + +**① electron-builder 在"需要下载"这条分支上必崩** + +``` +⨯ Cannot read properties of undefined (reading 'ReadWrite') + at resolveCacheMode (app-builder-lib/src/util/electronGet.ts:131) +``` +根因:`ElectronDownloadCacheMode` 在运行环境里是 `undefined`,代码却直接读它的 `.ReadWrite`。**26.15.3 与 26.16.1 都有**,Windows 与 Linux 都复现。触发点是"它认为需要下载 Electron 运行时/7zip 工具" —— 所以: +- 用 `--config.electronDist=<运行时目录>` 让它**不需要下载**; +- 打 zip 时它仍要 7zip 工具,那条路也会崩 → **压缩自己来**。 + +**② 手工放运行时还不够,还得让它"知道"运行时在哪** +- electron 包装里的 `path.txt` **不是** electron-builder 的判据(它走 `@electron/get` 的缓存机制); +- 直接给它 `electronDist` 才是正解。 + +**③ 网络:这台环境从官方源下 Electron 很难** +- 直连 GitHub:`%TEMP%` 里会堆一排 `electron-download-*` 重试目录直到失败; +- npmmirror 镜像:`ELECTRON_MIRROR` 可用,但它在拼 `SHASUMS256.txt` 时可能 404; +- gh 加速站(`gh-proxy.com`):实测 403; +- **最稳**:**在能上网的机器上把 zip 下好**(`https://npmmirror.com/mirrors/electron/38.2.2/electron-v38.2.2-<平台>.zip`),再传过去手工放。 + +**④ pnpm 11 的 `strictDepBuilds`** +只要有依赖的构建脚本被跳过,`pnpm install` 会**直接以退出码 1 失败**(`ERR_PNPM_IGNORED_BUILDS`),而 electron 的 postinstall 正是下载运行时那一步。放行写在 `pnpm-workspace.yaml` 的 `allowBuilds`(`package.json` 的 `pnpm` 字段与 `.npmrc` 的 `onlyBuiltDependencies` 在 pnpm 11 已不生效),**这个文件别删**。 + +**⑤ `pnpm run` 会先做依赖状态检查** +`pnpm run dist:win` 可能触发一次 `install`,在 `node_modules` 被动过的目录上会 `ERR_PNPM_EPERM`(rename 失败)。**直接调 CLI**(`node node_modules/electron-builder/out/cli/cli.js …`)可绕过。 + +--- + +## 七、已知可以再做的 + +- **Windows 的 portable 单文件 exe** 还没做成(卡在 6.3 ① 的 7zip 工具那条路)—— 绿色版 zip 功能等价,只是多一步解压; +- **飞腾平台(LoongArch 不是 ARM64)不支持** —— 本项目产出的是 ARM64,龙芯需要另找运行时; +- 表盘目前没有"刻度数字"(只有刻度线与指针)。 diff --git a/README.md b/README.md index 04494e8..212f019 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,109 @@ -# ClockLab +# ClockLab · 可调速桌面时钟 -一个简单的本地时钟计时器 \ No newline at end of file +一个用于**演练、演示、排练**的小工具:一个**可以任意调速的时钟**(走快、走慢、暂停、**倒着走**、一键快进),配上随时可开的**满屏时钟窗口**。 + +典型用法:现场排练时把时钟投到第二块屏上,需要"跳过 30 分钟"或"把时间倒回去重来一遍"时,不用真的等 —— 在控制窗口点一下就行。 + +--- + +## 一、两个窗口 + +程序启动后只出来**一个**窗口: + +- **控制窗口** —— 普通窗口,所有操作都在这里; +- **时钟窗口** —— **默认不出现**,由控制窗口的「打开时钟窗口」按钮创建/关闭。 + +**时钟窗口的特点**:无标题栏、无工具栏、无边框 —— 投屏时不会露出窗口装饰。它**可以拖动移动**(按住空白处拖),**可以拖四条边和四个角改变大小**(最小 260×150)。 + +--- + +## 二、怎么操作 + +### 时钟窗口那一块 + +| 功能 | 说明 | +| --- | --- | +| **打开 / 关闭时钟窗口** | 按钮文案会跟着状态变;关掉随时能再开 | +| **时钟窗口始终置顶** | 让它压在所有窗口之上(现场投屏基本都要开) | +| **控制窗口始终置顶** | 单独一个开关,两个窗口互不影响 | + +> 提醒:时钟窗口用的是**比普通置顶更硬**的级别,**可能会盖住任务栏**。如果你希望它"温柔"一点,见《DEVELOP.md》里的说明。 + +### 显示样式 + +- **纯数字** —— 大字号电子钟,`HH:MM:SS` 加日期; +- **圆形表盘** —— 带时针 / 分针 / 秒针的表盘。 + +切换**立即生效**,不用重开窗口。 + +### 时间与流速 + +| 功能 | 说明 | +| --- | --- | +| **设定时间** | 填时 / 分 / 秒,点应用,时钟立刻跳到那个时间 | +| **填入当前显示时间** | 把输入框一键填成时钟**现在显示**的时间(改之前先看一下当前值) | +| **倍率** | 支持 **正数、负数、0、小数**:`1` 正常、`2` 两倍速、`-1` **倒着走**、`0` 暂停、`0.5` 半速 | +| **暂停 / 继续** | 暂停后按钮会变成「**继续(回到 ×N)**」,N 是暂停前的倍率,点一下按原速度接着走 | +| **快进** | `+5 秒` / `+1 分钟` / `+5 分钟` / `+10 分钟` / `+30 分钟`,点了立刻往前跳(与当前倍率不冲突,倒着走时点快进也是往前跳) | + +几个组合起来的效果: + +- 想现场"重新来一遍":倍率填 `-1`,时钟就倒着走;到点了点「暂停」,再点 `+30 分钟` 往前调。 +- 想演示"时间飞逝":倍率填 `10`,秒针会飞快转。 +- 只想让时钟停在那儿:点「暂停」。 + +### 跳转时的动画 + +**快进、设定时间、继续**这些**一次性跳变**会播放动画:数字滚到新值、表盘指针扫过去(约 0.35 秒)。 + +**正常走时、倒转、高倍率不加动画** —— 那时数字本来就在飞快变化,再加动画只会糊成一团。跳变幅度小于 1.5 秒也直接跳(免得正常走时每秒都抖一下)。 + +--- + +## 三、怎么运行 + +### Windows + +解压 `ClockLab-1.0.0-win-x64.zip`,双击里面的 **`ClockLab.exe`**。绿色版,不写注册表、不需要安装。 + +### Linux(ARM64) + +```bash +tar -xzf ClockLab-1.0.0-linux-arm64.tar.gz # 解压(约 290 MB) +cd ClockLab-1.0.0-linux-arm64 +./clocklab +``` + +> ⚠️ **解压前建议先校验**:`sha256sum ClockLab-1.0.0-linux-arm64.tar.gz` 对着交付时给的哈希看一眼。传输中断过的话,`tar` 会报 `异常的 EOF`,那就是没传完(不是包坏了)。 + +--- + +## 四、常见问题 + +**界面黑屏 / 花屏 / 一闪就退(国产平台常见)** + +多半是显卡驱动下的硬件加速不兼容,用这两个参数之一启动: + +```bash +./clocklab --disable-gpu # 关掉硬件加速(最常用) +./clocklab --in-process-gpu # 或者让 GPU 跑在主进程里 +``` + +**窗口拖不动 / 改不了大小** + +先确认拖的是**时钟窗口的空白处**(按钮和边缘热区不在拖动区内);改大小要把鼠标移到**四条边或四个角**上(光标会变成双向箭头)。 + +**文字太小 / 太大** + +直接拖时钟窗口的边缘改变尺寸,数字和表盘会跟着缩放。 + +--- + +## 五、这个项目在哪 + +源码与构建说明见同目录的 **[DEVELOP.md](./DEVELOP.md)**。已经打好的包在 `dist/`: + +``` +dist/ClockLab-1.0.0-win-x64.zip Windows x64 绿色版 +dist/ClockLab-1.0.0-linux-arm64.tar.gz Linux ARM64(飞腾 D2000 等) +``` diff --git a/build/icon.ico b/build/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..5946a753b6cfcaae87b2afb5201b6ec96346b7ab GIT binary patch literal 25730 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 VYfzAtH=~i#Qs}>d|NZBK{|DdH`8fap literal 0 HcmV?d00001 diff --git a/build/icon.png b/build/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..877e1a81f8c2d434b5b1d8db7719ddaecd3491ea GIT binary patch literal 3381 zcmXw6dpy(YAAk0>F~ebumC!iJN{kaxE?X`Ul}XbSw(^r(68+>lrX;Lfy5P`ZA>|-W z?x!#jYB{KstX#5m#oT7i*l(TJ>-YTge4fwi^}IjN^Lo8MpU?Y~K|bW7g3?9-095w7 zIvoZ8CNp6GDKFc6qwbvoKv}ZiY44HPTQl$5vNWG=?3u@Q`>E;AspsVS6mlMEX z)gPsYI9yKE*mLVRGx%hKYo0fpT9k{fdsrBJrpubH6dx}x`}5(S;x&=9{N@wwykh%h zTZ-TNPdaE>l>e`ZCl||9*_ZAP(2{nGH%z?zlpWz0*dtMO9Er5x-k~pW6zv)wc*~#q zC2z1xTPct&1}mxS!fCI5M;L$pdQg$H`t@(*II>}<(P!&p`^|%mA&SEFO^FVwHBnrW zJNlkAGIv>^5l^l6me+Qh5Xj*~f&(fq>)H)suG#CJ;ibT@P+I?3(dVNP=OM$ z)5YSP^I$z(Pbg+NA&F)dRL zL@7~i?(A<{agz$Km!I#y4i$tNKUU=zJ#%MKNXrmTLejN!V~5IauzMk+{aZ9 zZc=m=rNkfmSm_26#4$+8mQl?Q0aYx=XlTL2Hhfa`(3kYu?6$+Ab5-ZYp05{ktG*>j zXNRA>t&nPoTibp#eCyiZd%gzr%s7ftggv`?^{5eF;JdOC6BWN6zVG$E#lM{EEVGAa zA+uGvwO7x06%bumYy9H-#xFrL;qInY)u3Q))pb+kDAc`g(ma%ZzeVQw_EP)%C_Yo@ zw%EG53s>J^Zz;Er8qG^MUMD^WTB?NAcg=Gevomfw5S{bEyOoIE)j)9jOwhnNm>&_1 zH<;_Zf3@^83%d88uS27UH;=VN+6cndPh0+D+5XHF>1$1Sx*g~7G+{&9 zCtV&vZUp`=z4NYPyl%x~H*4zr;lHb0w_x#-floe2-8>k<4Fp9@ zo9F$uzIv~EzmLl!>MmThiQNa=;W|m%4)cc1g#W&_ZPpgUfofOyZQN~V5*v?!q4Bl+ zbE+j2l1;+>wQYiBiB>Hz7>CjK4H3j;-FJTQ)p3s62iz}aQb>$D^+bf9X(EjOOQFfm zXH~47XS`;}@#|5U+A%fu+xS29*(XF{?Vgtgn>`ob72hg;Xp(u&2y(bYdd;91j7MxT?ACpv2=*;ql$h^e8PYU ze68J_wT)+$m@4)+(s!L`Apo=+h%-2YNyGoNxCUvoEo4}FoyL_A&n|*`HE{aS#-U>4 zP9l#2&W-d*Y@|2v<;exU!;l(yR-Xr^MxwmHlwVRdK`U!2`a+}^Fj(nGdCg;8TJO1@ z(cA_dMdUb*4LjRSO(f`em@1J{1pysMjSL zfJry@BLCJ|)>UP{>PsCr-fD7kRnig(`Vb!HYS$EF4nk1eQ6 zy1P+qz8_->+s9K%OES1(xYL+<1lm1ts$QFP@c4z~sQ5D<81U&`FuM+`O~Q~3C5A=J zQ{G_zp31}#g@!>jam{m0(#aX8E~~1kq3*3X_V2?278#I&F+gnt0>CRCfRRZBG1*7P z17h}j$Bh!=EV@*-t7@`rVMU&qwX8b`cemRUBK__+Us)6ky!o!q&;H19Ej)` zCqr3Rd+!Mq-5D_D5aRCv=iHOM<^EoiaBCN`JEYNzCCwIx4r(&3Fe!RO=|b2|Y1W;e z@%wL_mUzN@?`g(!@xXaxb1=bbDS;qY%?8>d=Xlw}B*yclMLf8Jzra8;^e71WN3SD! zuaZ7rHt2Kt%&puGa=#?3y>`DXFI1>#RA#UkApH8RKcRv>=6QPSA!~hvs+8L5=Sx~W z_hrt~N|QI`^uzGwWo1Uc4{cRMp#d#Vcr;aoQEDeKz>vITe=FzO!0cvJsj6+37*asB zeBN?13}38QG^>?)W;1S=Ct z|0f=;CXO35veHDbC!K=nl}Kcp>U6uVb>?eB!2nM>wi+Y-!Si4xrw#HZ#JrTU;708N zRMNtSwJ>z+aky>4)FuSmx8Hz@k`A&~YCqDXtK8WRg2HX07J}YAX3(dSyfAk;*}_;B zUjadXp7%+?t*hkK4%txyK_}NfL?oc&*N$K~ABUGKh$~+|R4?R?T=Rk-50aMuS5wV$ z$-B2M6XMvr)FMmM#^g|(T>m+On*NeM8T`}WtD+X4j0blQqsuQR2yvpMdHh+B;6|UB zr+@ZZzze#jUF1k6C>*n<-nzac8ADRLb7HH+oD5R3CI!j zmX?CSabNVKSZSg9J=ax!MJcejg~S{(o^;_3}~M`M6#9T zPQhDf@b5~9dh$Mq#Un?ldfBpU$qMPnN0pn0h9?%vYE^=fmc6gPAGeBC|73h;Ovm`+ z0?SCnx@9MSf4`&l>e3dCv*RC!qlHwlVD@Upgwa+M`^Zxz`R5z6Z(lPHyaV^uiOw!tqDc;*#MXf3YYKcpEJuhC2xJyeKBZFqu z=`-+8JKi%*qI!xgr&08bCs@!FTSPyg#StL{!ctO4rFn2}s~uCn%1GCq|6L_GNN5U1 zrw%a>8pgm5_(S{$l3fSc3eAX|#~zAw!azWuAB-y6aeicyVGv0HQxh6V-y4^lH|w=* zXD*#YC%&>MK45#3Sp})-ODyLnaWQeX=rRrCa@$q!v)AcH(oIwXi)l@E6^PEkYP97MT<7pdq95ak@q zsAj1pp-~fA_iQ?I1*2omFiFN$2Ps~(iU;~K%Dq!eZ|cndH4&z^PQ|A@e2gWrbZOkp z>XkkYo#;>FKK(aJtgVsC>? z6P3c&&Sxvw(>r6v^4wDXSpN+}O0y_vN*z}+j)4k>xfm=*5cJ8Mdg~0`R+{R9md&>L7JSf<}1)h##Yw~fLjlKGj7lm473 zqk$2nKC;cAHmg)<9wL6*!24T*J%O0;?r(z+tf~*qn1Wq$-v+I#nmK_azl6o3zA{y! z8*5P`1B%A?2i;Ui8BrSXILo>1{?J}_4rL2RNl=JjJ z6>OsY*Dk_~IE@^2&~CFwVxUWarA?bEIJInHCcA6+&#esWh_~uxg4|$KQk3YSD9??Q zYQSWK<+r%B7YPNBD;|n1+bw!JsbJRN4JihKnkcIV%T$_^4{S@TGtOb-M66-VUkz}g zXS7*>p|jaOb9R$mb5$dP=ov@$65fEFh1cBPd^nlug_jEs)L(L}_~kq*kXh!Vw3hQ$ nj^?n9t;s#1@c)4Xs6Cd#7_{?PZ{k$SPv?H;Lr(V{=okJ6y_X%g literal 0 HcmV?d00001 diff --git a/main.js b/main.js new file mode 100644 index 0000000..113df9c --- /dev/null +++ b/main.js @@ -0,0 +1,243 @@ +'use strict'; + +const { app, BrowserWindow, ipcMain } = require('electron'); +const path = require('node:path'); +const clockModel = require('./src/clock-model'); + +const RENDERER_DIR = path.join(__dirname, 'renderer'); +const PRELOAD = path.join(__dirname, 'preload.js'); +const SELF_TEST = process.argv.includes('--selftest'); + +const MIN_SIZE = { width: 260, height: 150 }; +const DEFAULT_CLOCK_SIZE = { width: 520, height: 280 }; + +const state = { + clock: clockModel.create(Date.now()), + style: 'digital', + alwaysOnTop: { clock: true, control: false } +}; + +let controlWindow = null; +let clockWindow = null; + +function isAlive(win) { + return Boolean(win) && !win.isDestroyed(); +} + +function publicState() { + return { + style: state.style, + baseMs: state.clock.baseMs, + baseRealMs: state.clock.baseRealMs, + rate: state.clock.rate, + lastRate: state.clock.lastRate, + paused: state.clock.rate === 0, + clockWindowOpen: isAlive(clockWindow), + alwaysOnTop: { ...state.alwaysOnTop }, + serverNow: Date.now() + }; +} + +function broadcast() { + const snapshot = publicState(); + for (const win of [controlWindow, clockWindow]) { + if (isAlive(win)) win.webContents.send('state', snapshot); + } +} + +const WEB_PREFERENCES = { + preload: PRELOAD, + contextIsolation: true, + nodeIntegration: false +}; + +function createControlWindow() { + controlWindow = new BrowserWindow({ + width: 920, + height: 720, + minWidth: 780, + minHeight: 560, + title: 'ClockLab 控制台', + backgroundColor: '#12151c', + autoHideMenuBar: true, + show: false, + webPreferences: WEB_PREFERENCES + }); + + controlWindow.setAlwaysOnTop(state.alwaysOnTop.control); + controlWindow.loadFile(path.join(RENDERER_DIR, 'control.html')); + controlWindow.once('ready-to-show', () => controlWindow.show()); + controlWindow.on('closed', () => { + controlWindow = null; + // 控制窗口是唯一常驻入口,它被关掉就等于用户要退出整个程序(时钟窗口一起走) + if (!SELF_TEST) app.quit(); + }); +} + +function createClockWindow() { + if (isAlive(clockWindow)) return clockWindow; + + clockWindow = new BrowserWindow({ + width: DEFAULT_CLOCK_SIZE.width, + height: DEFAULT_CLOCK_SIZE.height, + minWidth: MIN_SIZE.width, + minHeight: MIN_SIZE.height, + frame: false, + resizable: true, + movable: true, + maximizable: false, + fullscreenable: false, + backgroundColor: '#05070c', + title: 'ClockLab 时钟', + show: false, + webPreferences: WEB_PREFERENCES + }); + + // screen-saver 级别:现场投影时希望它压在其他全屏窗口之上,普通 alwaysOnTop 在 Windows 上不够硬 + clockWindow.setAlwaysOnTop(state.alwaysOnTop.clock, 'screen-saver'); + clockWindow.loadFile(path.join(RENDERER_DIR, 'clock.html')); + clockWindow.once('ready-to-show', () => { + clockWindow.show(); + broadcast(); + }); + clockWindow.on('closed', () => { + clockWindow = null; + broadcast(); + }); + + return clockWindow; +} + +function closeClockWindow() { + if (isAlive(clockWindow)) clockWindow.close(); +} + +// 自定义拖边缘改大小最终落到这里:夹住最小尺寸,避免渲染进程算出 NaN 时把窗口搞坏 +function applyBounds(next) { + if (!isAlive(clockWindow)) return null; + const current = clockWindow.getBounds(); + const bounds = { + x: Number.isFinite(next.x) ? Math.round(next.x) : current.x, + y: Number.isFinite(next.y) ? Math.round(next.y) : current.y, + width: Math.max(MIN_SIZE.width, Number.isFinite(next.width) ? Math.round(next.width) : current.width), + height: Math.max(MIN_SIZE.height, Number.isFinite(next.height) ? Math.round(next.height) : current.height) + }; + clockWindow.setBounds(bounds); + return clockWindow.getBounds(); +} + +// 所有会改动时钟/窗口的动作都集中在这里:IPC 和自检脚本共用同一条代码路径, +// 自检才不会测到一份「和线上不一样」的逻辑。 +const actions = { + setRate(rate) { + const value = Number(rate); + if (Number.isFinite(value)) { + clockModel.setRate(state.clock, Date.now(), value); + broadcast(); + } + return publicState(); + }, + + togglePause() { + const now = Date.now(); + const target = state.clock.rate === 0 ? state.clock.lastRate || 1 : 0; + clockModel.setRate(state.clock, now, target); + broadcast(); + return publicState(); + }, + + fastForward(deltaMs) { + const delta = Number(deltaMs); + if (Number.isFinite(delta)) { + clockModel.shift(state.clock, Date.now(), delta); + broadcast(); + } + return publicState(); + }, + + setTime(h, m, s) { + const parts = [Number(h), Number(m), Number(s)]; + if (parts.every(Number.isFinite)) { + clockModel.setTimeOfDay(state.clock, Date.now(), parts[0], parts[1], parts[2]); + broadcast(); + } + return publicState(); + }, + + setStyle(style) { + if (style === 'digital' || style === 'analog') { + state.style = style; + broadcast(); + } + return publicState(); + }, + + setAlwaysOnTop(target, value) { + const key = target === 'control' ? 'control' : 'clock'; + state.alwaysOnTop[key] = Boolean(value); + const win = key === 'clock' ? clockWindow : controlWindow; + // 时钟窗口用 screen-saver 级别,普通置顶在 Windows 上会被某些全屏窗口压住 + if (isAlive(win)) win.setAlwaysOnTop(state.alwaysOnTop[key], key === 'clock' ? 'screen-saver' : 'normal'); + broadcast(); + return publicState(); + }, + + toggleClockWindow() { + if (isAlive(clockWindow)) closeClockWindow(); + else createClockWindow(); + broadcast(); + return publicState(); + }, + + closeClockWindow() { + closeClockWindow(); + return publicState(); + } +}; + +function registerIpc() { + ipcMain.handle('state:get', () => publicState()); + ipcMain.handle('rate:set', (_event, rate) => actions.setRate(rate)); + ipcMain.handle('pause:toggle', () => actions.togglePause()); + ipcMain.handle('fast-forward', (_event, deltaMs) => actions.fastForward(deltaMs)); + ipcMain.handle('time:set', (_event, payload) => actions.setTime(payload?.h, payload?.m, payload?.s)); + ipcMain.handle('style:set', (_event, style) => actions.setStyle(style)); + ipcMain.handle('always-on-top:set', (_event, payload) => actions.setAlwaysOnTop(payload?.target, payload?.value)); + ipcMain.handle('clock-window:toggle', () => actions.toggleClockWindow()); + ipcMain.handle('clock-window:close', () => actions.closeClockWindow()); + ipcMain.handle('control-window:close', () => { + if (isAlive(controlWindow)) controlWindow.close(); + return publicState(); + }); + + ipcMain.on('bounds:get-sync', (event) => { + event.returnValue = isAlive(clockWindow) ? clockWindow.getBounds() : null; + }); + + ipcMain.on('bounds:set', (_event, bounds) => { + if (bounds && typeof bounds === 'object') applyBounds(bounds); + }); +} + +app.whenReady().then(() => { + registerIpc(); + createControlWindow(); + + if (SELF_TEST) { + require('./src/selftest')({ + app, + state, + clockModel, + actions, + publicState, + applyBounds, + createClockWindow, + getClockWindow: () => clockWindow, + getControlWindow: () => controlWindow + }); + } +}); + +app.on('window-all-closed', () => { + if (!SELF_TEST) app.quit(); +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..bcf7166 --- /dev/null +++ b/package.json @@ -0,0 +1,75 @@ +{ + "name": "clocklab", + "version": "1.0.0", + "description": "ClockLab - 可调速桌面时钟(数字/表盘,支持倍率、暂停、倒转、快进)", + "author": "AskaEth", + "license": "MIT", + "private": true, + "main": "main.js", + "scripts": { + "start": "electron .", + "selftest": "electron . --selftest", + "test": "node test/clock-model.test.js && node test/resize.test.js && node test/presented-time.test.js && node test/renderer-clock.dom-test.js && node test/renderer-clock-anim.dom-test.js && node test/renderer-control.dom-test.js", + "dist:win": "electron-builder --win --x64", + "dist:linux": "electron-builder --linux --arm64" + }, + "devDependencies": { + "electron": "38.2.2", + "electron-builder": "26.15.3" + }, + "build": { + "appId": "com.askasoft.clocklab", + "productName": "ClockLab", + "directories": { + "output": "dist", + "buildResources": "build" + }, + "files": [ + "main.js", + "preload.js", + "src/**/*", + "renderer/**/*", + "!**/*.md" + ], + "win": { + "icon": "build/icon.ico", + "artifactName": "ClockLab-${version}-${arch}.${ext}", + "target": [ + { + "target": "portable", + "arch": [ + "x64" + ] + }, + { + "target": "zip", + "arch": [ + "x64" + ] + } + ] + }, + "portable": { + "artifactName": "ClockLab-${version}-portable-x64.exe" + }, + "linux": { + "icon": "build/icon.png", + "category": "Utility", + "artifactName": "ClockLab-${version}-${arch}.${ext}", + "target": [ + { + "target": "tar.gz", + "arch": [ + "arm64" + ] + }, + { + "target": "AppImage", + "arch": [ + "arm64" + ] + } + ] + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..86c5af2 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2413 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + electron: + specifier: 38.2.2 + version: 38.2.2(supports-color@7.1.0) + electron-builder: + specifier: 26.16.1 + version: 26.16.1(electron-builder-squirrel-windows@26.16.1)(supports-color@7.1.0) + +packages: + + '@electron/asar@3.2.1': + resolution: {integrity: sha512-hE2cQMZ5+4o7+6T2lUaVbxIzrOjZZfX7dB02xuapyYFJZEAiWTelq6J3mMoxzd0iONDvYLPVKecB5tyjIoVDVA==} + engines: {node: '>=10.12.0'} + hasBin: true + + '@electron/asar@3.4.1': + resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==} + engines: {node: '>=10.12.0'} + hasBin: true + + '@electron/fuses@1.8.0': + resolution: {integrity: sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==} + hasBin: true + + '@electron/get@2.0.0': + resolution: {integrity: sha512-d0XfNGayKLrYRqddW4f2Zdjaj71LfvZlCnUN8ojyNMr7WD8v6B+fqdCV8Etnwn/vUfFWFwMk/HtLEFZy01h4tQ==} + engines: {node: '>=12'} + + '@electron/get@3.0.0': + resolution: {integrity: sha512-hLv4BYFiyrNRI+U0Mm2X7RxCCdJLkDUn8GCEp9QJzbLpZRko+UaLlCjOMkj6TEtirNLPyBA7y1SeGfnpOB21aQ==} + engines: {node: '>=14'} + + '@electron/notarize@2.5.0': + resolution: {integrity: sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==} + engines: {node: '>= 10.0.0'} + + '@electron/osx-sign@1.3.3': + resolution: {integrity: sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==} + engines: {node: '>=12.0.0'} + hasBin: true + + '@electron/rebuild@4.2.0': + resolution: {integrity: sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ==} + engines: {node: '>=22.12.0'} + hasBin: true + + '@electron/universal@2.0.3': + resolution: {integrity: sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==} + engines: {node: '>=16.4'} + + '@electron/windows-sign@1.2.2': + resolution: {integrity: sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==} + engines: {node: '>=14.14'} + hasBin: true + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + + '@malept/cross-spawn-promise@2.0.0': + resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} + engines: {node: '>= 12.13.0'} + + '@malept/flatpak-bundler@0.4.0': + resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} + engines: {node: '>= 10.0.0'} + + '@noble/hashes@1.4.0': + resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} + engines: {node: '>= 16'} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@peculiar/asn1-schema@2.9.4': + resolution: {integrity: sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==} + engines: {node: '>=14'} + + '@peculiar/json-schema@1.1.12': + resolution: {integrity: sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==} + engines: {node: '>=8.0.0'} + + '@peculiar/utils@2.0.3': + resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==} + + '@peculiar/webcrypto@1.7.1': + resolution: {integrity: sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==} + engines: {node: '>=14.18.0'} + + '@sindresorhus/is@4.0.0': + resolution: {integrity: sha512-FyD2meJpDPjyNQejSjvnhpgI/azsQkA4lGbuu5BQZfjvJ9cbRZXzeWL2HceCekW4lixO9JPesIIQkSoLjeJHNQ==} + engines: {node: '>=10'} + + '@szmarczak/http-timer@4.0.6': + resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} + engines: {node: '>=10'} + + '@types/cacheable-request@6.0.3': + resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/fs-extra@9.0.13': + resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} + + '@types/glob@7.2.0': + resolution: {integrity: sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==} + + '@types/http-cache-semantics@4.2.0': + resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + + '@types/keyv@3.1.4': + resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} + + '@types/minimatch@6.0.0': + resolution: {integrity: sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA==} + deprecated: This is a stub types definition. minimatch provides its own type definitions, so you do not need this installed. + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@22.20.2': + resolution: {integrity: sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==} + + '@types/node@26.5.1': + resolution: {integrity: sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==} + + '@types/responselike@1.0.3': + resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + + '@xmldom/xmldom@0.8.15': + resolution: {integrity: sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==} + engines: {node: '>=10.0.0'} + + '@xmldom/xmldom@0.9.12': + resolution: {integrity: sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==} + engines: {node: '>=14.6'} + + abbrev@4.0.0: + resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} + engines: {node: ^20.17.0 || >=22.9.0} + + agent-base@7.0.2: + resolution: {integrity: sha512-k2/tQ1+8Zf50dEUJWklUP80LcE/+Ph+OJ6cf2Ff2fD/c/TtCe6ofnCoNMz9UnyxOQYlaAALZtEWETzn+1JjfHg==} + engines: {node: '>= 14'} + + agent-base@7.1.0: + resolution: {integrity: sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==} + engines: {node: '>= 14'} + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.0.0: + resolution: {integrity: sha512-8zjUtFJ3db/QoPXuuEMloS2AUf79/yeyttJ7Abr3hteopJu9HK8vsgGviGUMq+zyA6cZZO6gAyZoMTF6TgaEjA==} + engines: {node: '>=8'} + + ansi-styles@4.1.0: + resolution: {integrity: sha512-Qts4KCLKG+waHc9C4m07weIY8qyeixoS0h6RnbsNVD6Fw+pEZGW3vTyObL3WXpE09Mq4Oi7/lBEyLmOiLtlYWQ==} + engines: {node: '>=8'} + + app-builder-lib@26.16.1: + resolution: {integrity: sha512-FhaO6YOup01ZfQW0Z6gt3AyukJjv1gW4uFK47jTgwcHZKqyN/fSlK2LqPf9tAeZYLP2bRJLDzeOkRImsw2X4Pg==} + engines: {node: '>=14.0.0'} + peerDependencies: + dmg-builder: 26.16.1 + electron-builder-squirrel-windows: 26.16.1 + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + asn1js@3.0.10: + resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==} + engines: {node: '>=12.0.0'} + + async-exit-hook@2.0.1: + resolution: {integrity: sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==} + engines: {node: '>=0.12.0'} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + at-least-node@1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + + aws4@1.13.2: + resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} + + balanced-match@1.0.0: + resolution: {integrity: sha512-9Y0g0Q8rmSt+H33DfKv7FOc3v+iRI+o1lbzt8jGcIosYW37IIW/2XVYq5NPdmaD5NQ59Nk26Kl/vZbwW9Fr8vg==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bluebird@3.7.2: + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + + boolean@3.2.0: + resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + builder-util-runtime@9.7.0: + resolution: {integrity: sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==} + engines: {node: '>=12.0.0'} + + builder-util@26.16.0: + resolution: {integrity: sha512-RLyJhB7Si3YkzKR9ubQslWuXW3Vhs3CGe1i+SeixBZ0qTd1mk3XBmssvY22TlB6CS5blyko8Gu1JzpYk8UkYAg==} + engines: {node: '>=14.0.0'} + + bytestreamjs@2.0.1: + resolution: {integrity: sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==} + engines: {node: '>=6.0.0'} + + cacheable-lookup@5.0.4: + resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} + engines: {node: '>=10.6.0'} + + cacheable-request@7.0.2: + resolution: {integrity: sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + chalk@4.1.0: + resolution: {integrity: sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==} + engines: {node: '>=10'} + + chalk@4.1.1: + resolution: {integrity: sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==} + engines: {node: '>=10'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + chromium-pickle-js@0.2.0: + resolution: {integrity: sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==} + + ci-info@4.3.1: + resolution: {integrity: sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==} + engines: {node: '>=8'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone-response@1.0.3: + resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@5.0.0: + resolution: {integrity: sha512-JrDGPAKjMGSP1G0DUoaceEJ3DZgAfr/q6X7FVk4+U5KxUSKviYGM2k6zWkfyyBHy5rAtzgYJFa1ro2O9PtoxwQ==} + engines: {node: '>= 6'} + + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + + compare-version@0.1.2: + resolution: {integrity: sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==} + engines: {node: '>=0.10.0'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + config-chain@1.1.13: + resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cross-dirname@0.1.0: + resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + defer-to-connect@2.0.1: + resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} + engines: {node: '>=10'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + detect-node@2.1.0: + resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + + dir-compare@4.2.0: + resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} + + dmg-builder@26.16.1: + resolution: {integrity: sha512-pnI/3Qb24Uk+rMTgIUrsVUKosVgwmBUdF8Zeb8TexOSbpq8MWc7v6l+n+FrEqVkjNZwzBN+XpDS9ENgZ/rkWAw==} + + dotenv-expand@11.0.6: + resolution: {integrity: sha512-8NHi73otpWsZGBSZwwknTXS5pqMOrk9+Ssrna8xCaxkzEpU9OTf9R5ArQGVw03//Zmk9MOwLPng9WwndvpAJ5g==} + engines: {node: '>=12'} + + dotenv@16.4.5: + resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + duplexer2@0.1.4: + resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + + ejs@3.1.8: + resolution: {integrity: sha512-/sXZeMlhS0ArkfX2Aw780gJzXSMPnKjtspYZv+f3NiKLlubezAHDU5+9xz6gd3/NhG3txQCo6xlglmTS+oTGEQ==} + engines: {node: '>=0.10.0'} + hasBin: true + + electron-builder-squirrel-windows@26.16.1: + resolution: {integrity: sha512-w0y44wSaT1l6R7CAGmeHn4nHPfvzDyCAU1xJyi1w9SbPYJpYn76SmHDzqHf8Y7l91cPWTdPYBpGQtB2T5mJ08A==} + + electron-builder@26.16.1: + resolution: {integrity: sha512-LrLK65QX5PUYYODXqp23FKrV7CILTtVY7mrJckNknO9jLNSMiqFkKbSMiDRw4CjOADMPVDdWLxY4mezOZWswxg==} + engines: {node: '>=14.0.0'} + hasBin: true + + electron-publish@26.16.0: + resolution: {integrity: sha512-Vt3KzQIiw9BImvNOYtndg9Mjki+tl4+1sQiC/+G5j8khWaENOJFWodiB+sUl6yyHwtd37avehskdtPw7f8y/+Q==} + + electron-winstaller@5.4.0: + resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} + engines: {node: '>=8.0.0'} + + electron@38.2.2: + resolution: {integrity: sha512-OXSaVNXDlonXDjMRsFNQo1j5tzTKwKXh5/m46IjAFccBcZJZMISI+EjSI07oexIuhvKM8AZLuFuihVn4YjWWrA==} + engines: {node: '>= 12.20.55'} + hasBin: true + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + env-paths@2.2.0: + resolution: {integrity: sha512-6u0VYSCo/OW6IoD5WCLLy9JUGARbamfSavcNXry/eu8aHVFei6CD3Sw+VGX5alea1i9pgPHW0mbu6Xj0uBh7gA==} + engines: {node: '>=6'} + + err-code@2.0.3: + resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es6-error@4.1.1: + resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + exponential-backoff@3.1.3: + resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.7: + resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==} + + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + filelist@1.0.6: + resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + + fs-extra@11.3.1: + resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==} + engines: {node: '>=14.14'} + + fs-extra@11.4.0: + resolution: {integrity: sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==} + engines: {node: '>=14.14'} + + fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@9.0.0: + resolution: {integrity: sha512-pmEYSk3vYsG/bF651KPUXZ+hvjpgWYw/Gc7W9NFUe3ZVLczKKWIij3IKpOrQcdw4TILtibFslZ0UmR8Vvzig4g==} + engines: {node: '>=10'} + + fs-extra@9.0.1: + resolution: {integrity: sha512-h2iAoN838FqAFJY2/qVpzFXy+EBxfVE220PalAqQLDVsFOHLJrZvut5puAbCdNv6WJk+B8ihI+k0c7JK5erwqQ==} + engines: {node: '>=10'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + + glob@7.2.0: + resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + global-agent@3.0.0: + resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} + engines: {node: '>=10.0'} + + global-tunnel-ng@2.7.1: + resolution: {integrity: sha512-4s+DyciWBV0eK148wqXxcmVAbFVPqtc3sEtUE/GTQfuU80rySLcMhUmHKSHI7/LDj8q0gDYI1lIhRRB7ieRAqg==} + engines: {node: '>=0.10'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + got@11.8.5: + resolution: {integrity: sha512-o0Je4NvQObAuZPHLFoRSkdG2lTgtcynqymzg2Vupdx6PorhaT5MCbIyXG6d4D94kk8ZG57QeosgdiqfJWhEhlQ==} + engines: {node: '>=10.19.0'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hosted-git-info@4.1.0: + resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} + engines: {node: '>=10'} + + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + + http-proxy-agent@7.0.0: + resolution: {integrity: sha512-+ZT+iBxVUQ1asugqnD6oWoRiS25AkjNfG085dKJGtGxkdwLQrMKU5wJr2bOOFAXzKcTuqq+7fZlTMgG3SRfIYQ==} + engines: {node: '>= 14'} + + http2-wrapper@1.0.3: + resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} + engines: {node: '>=10.19.0'} + + https-proxy-agent@7.0.0: + resolution: {integrity: sha512-0euwPCRyAPSgGdzD1IVN9nJYHtBhJwb6XPfbpQcYbPCwrBidX6GzxmchnaF4sfF/jPb74Ojx5g4yTg3sixlyPw==} + engines: {node: '>= 14'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.4: + resolution: {integrity: sha512-VUA7WAWNCWfm6/8f9kAb8Y6iGBWnmCfgFS5dTrv2C38LLm1KUmpY388mCVCJCsMKQomvOQ1oW8/edXdChd9ZXQ==} + deprecated: Please update to ini >=1.3.6 to avoid a prototype pollution issue + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isbinaryfile@4.0.10: + resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} + engines: {node: '>= 8.0.0'} + + isbinaryfile@5.0.7: + resolution: {integrity: sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==} + engines: {node: '>= 18.0.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} + + isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} + engines: {node: '>=20'} + + jake@10.8.5: + resolution: {integrity: sha512-sVpxYeuAhWt0OTWITwT98oyV0GsXyMlXCF+3L1SuafBVUIr/uILGRB+NqwkzhgXKvoJpDIpQvqkUALgdmQsQxw==} + engines: {node: '>=10'} + hasBin: true + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + + keyv@4.0.0: + resolution: {integrity: sha512-U7ioE8AimvRVLfw4LffyOIRhL2xVgmE8T22L6i0BucSnBUyv4w+I7VN/zVZwRKHOI6ZRUcdMdWHQ8KSUvGpEog==} + + lazy-val@1.0.5: + resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + lowercase-keys@2.0.0: + resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} + engines: {node: '>=8'} + + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + + matcher@3.0.0: + resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} + engines: {node: '>=10'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + mimic-response@1.0.1: + resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} + engines: {node: '>=4'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + node-abi@4.35.0: + resolution: {integrity: sha512-ymk4aIzxdPopw2giv8Fs1Ec6vybGkjmyxUwVqhkI4MCy2tVfXdkOGGWieWVjL0THgH+7a8lRdevyupoYj3Js/Q==} + engines: {node: '>=22.12.0'} + + node-api-version@0.2.1: + resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} + + node-gyp@12.4.0: + resolution: {integrity: sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + + nopt@9.0.0: + resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + normalize-url@6.0.1: + resolution: {integrity: sha512-VU4pzAuh7Kip71XEmO9aNREYAdMHFGTVj/i+CaTImS8x0i1d3jUZkXhqluy/PRgjPLMgsLQulYY3PJ/aSbSjpQ==} + engines: {node: '>=10'} + + npm-conf@1.1.3: + resolution: {integrity: sha512-Yic4bZHJOt9RCFbRP3GgpqhScOY4HH3V2P8yBj6CeYq118Qr+BLXqT2JvpJ00mryLESpgOxf5XlFv4ZjXxLScw==} + engines: {node: '>=4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + p-cancelable@2.1.1: + resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} + engines: {node: '>=8'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + pe-library@0.4.1: + resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} + engines: {node: '>=12', npm: '>=6'} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pify@3.0.0: + resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} + engines: {node: '>=4'} + + pkijs@3.4.0: + resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==} + engines: {node: '>=16.0.0'} + + plist@3.1.0: + resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} + engines: {node: '>=10.4.0'} + + plist@3.1.1: + resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} + engines: {node: '>=10.4.0'} + + postject@1.0.0-alpha.6: + resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==} + engines: {node: '>=14.0.0'} + hasBin: true + + proc-log@6.0.0: + resolution: {integrity: sha512-KG/XsTDN901PNfPfAMmj6N/Ywg9tM+bHK8pAz+27fS4N4Pcr+4zoYBOcGSBu6ceXYNPxkLpa4ohtfxV1XcLAfA==} + engines: {node: ^20.17.0 || >=22.9.0} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + promise-retry@2.0.1: + resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} + engines: {node: '>=10'} + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + + proto-list@1.2.4: + resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.2.0: + resolution: {integrity: sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==} + engines: {node: '>=16.0.0'} + + quick-lru@5.1.1: + resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} + engines: {node: '>=10'} + + read-binary-file-arch@1.0.6: + resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==} + hasBin: true + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resedit@1.7.0: + resolution: {integrity: sha512-dbsZ0gk5opWPFlKMqvxCrLCuMZUVmsW3yTPT0tT4mYwo5fjQM8c4HMN9ZJt6dRDqDV/78m9SU4rv24PN4NiYaA==} + engines: {node: '>=12', npm: '>=6'} + + resolve-alpn@1.2.1: + resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} + + responselike@2.0.1: + resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + rimraf@2.6.2: + resolution: {integrity: sha512-lreewLK/BlghmxtfH36YYVg1i8IAce4TI7oao75I1g245+6BctqTVQiBP3YUJ9C6DQOXJmkYR9X9fCLtCOJc5w==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + roarr@2.15.3: + resolution: {integrity: sha512-AEjYvmAhlyxOeB9OqPUzQCo3kuAkNfuDk/HqWbZdFsqDFpapkTjiw+p4svNEoRLvuqNTxqfL+s+gtD4eDgZ+CA==} + engines: {node: '>=8.0'} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + sanitize-filename@1.6.4: + resolution: {integrity: sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==} + + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} + engines: {node: '>=11.0.0'} + + semver-compare@1.0.0: + resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} + + semver@5.5.0: + resolution: {integrity: sha512-4SJ3dm0WAwWy/NVeioZh5AntkdJoWKxHxcmyP622fOkgHa4z3R0TdBJICINyaSDE6uNwVc8gZr+ZinwZAH4xIA==} + hasBin: true + + semver@6.2.0: + resolution: {integrity: sha512-jdFC1VdUGT/2Scgbimf7FSx9iJLXoqfglSF+gJeuNWVpiE37OIbc1jywR/GJyFdz3mnkz2/id0L0J/cr0izR5A==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + serialize-error@7.0.1: + resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} + engines: {node: '>=10'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.0: + resolution: {integrity: sha512-mTozplhTX4tLKIHYji92OTZzVyZvi+Z1qRZDeBvQFI2XUB89wrRoj/xXad3c9NZ1GPJXXRvB+k41PQCPTMC+aA==} + engines: {node: '>=0.10.0'} + + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + + stat-mode@1.0.0: + resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} + engines: {node: '>= 6'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + sumchecker@3.0.1: + resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} + engines: {node: '>= 8.0'} + + supports-color@7.1.0: + resolution: {integrity: sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==} + engines: {node: '>=8'} + + tar@7.5.22: + resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} + engines: {node: '>=18'} + + temp-file@3.4.0: + resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==} + + temp@0.9.4: + resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==} + engines: {node: '>=6.0.0'} + + tiny-async-pool@1.3.0: + resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tmp-promise@3.0.3: + resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} + + tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} + engines: {node: '>=14.14'} + + truncate-utf8-bytes@1.0.2: + resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tunnel@0.0.6: + resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} + engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} + + type-fest@0.13.1: + resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} + engines: {node: '>=10'} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + + undici@6.28.1: + resolution: {integrity: sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==} + engines: {node: '>=18.17'} + + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + + universalify@1.0.0: + resolution: {integrity: sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug==} + engines: {node: '>= 10.0.0'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unzipper@0.12.5: + resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==} + + utf8-byte-length@1.0.5: + resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + webcrypto-core@1.9.2: + resolution: {integrity: sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==} + + which@2.0.1: + resolution: {integrity: sha512-N7GBZOTswtB9lkQBZA4+zAXrjEIWAUOB93AvzUiudRzRxhUdLURQ7D/gAIMY1gatT/LTbmbcv8SiYazy3eYB7w==} + engines: {node: '>= 8'} + hasBin: true + + which@5.0.0: + resolution: {integrity: sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + + which@6.0.1: + resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@electron/asar@3.2.1': + dependencies: + chromium-pickle-js: 0.2.0 + commander: 5.0.0 + glob: 7.2.0 + minimatch: 3.1.5 + optionalDependencies: + '@types/glob': 7.2.0 + + '@electron/asar@3.4.1': + dependencies: + commander: 5.0.0 + glob: 7.2.0 + minimatch: 3.1.5 + + '@electron/fuses@1.8.0': + dependencies: + chalk: 4.1.1 + fs-extra: 9.0.1 + minimist: 1.2.8 + + '@electron/get@2.0.0(supports-color@7.1.0)': + dependencies: + debug: 4.4.3(supports-color@7.1.0) + env-paths: 2.2.0 + fs-extra: 8.1.0 + got: 11.8.5 + progress: 2.0.3 + semver: 6.2.0 + sumchecker: 3.0.1(supports-color@7.1.0) + optionalDependencies: + global-agent: 3.0.0 + global-tunnel-ng: 2.7.1 + transitivePeerDependencies: + - supports-color + + '@electron/get@3.0.0(supports-color@7.1.0)': + dependencies: + debug: 4.4.3(supports-color@7.1.0) + env-paths: 2.2.0 + fs-extra: 8.1.0 + got: 11.8.5 + progress: 2.0.3 + semver: 6.2.0 + sumchecker: 3.0.1(supports-color@7.1.0) + optionalDependencies: + global-agent: 3.0.0 + transitivePeerDependencies: + - supports-color + + '@electron/notarize@2.5.0(supports-color@7.1.0)': + dependencies: + debug: 4.4.3(supports-color@7.1.0) + fs-extra: 9.0.1 + promise-retry: 2.0.1 + transitivePeerDependencies: + - supports-color + + '@electron/osx-sign@1.3.3(supports-color@7.1.0)': + dependencies: + compare-version: 0.1.2 + debug: 4.4.3(supports-color@7.1.0) + fs-extra: 10.1.0 + isbinaryfile: 4.0.10 + minimist: 1.2.8 + plist: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@electron/rebuild@4.2.0(supports-color@7.1.0)': + dependencies: + '@malept/cross-spawn-promise': 2.0.0 + debug: 4.4.3(supports-color@7.1.0) + node-abi: 4.35.0 + node-api-version: 0.2.1 + node-gyp: 12.4.0 + read-binary-file-arch: 1.0.6(supports-color@7.1.0) + transitivePeerDependencies: + - supports-color + + '@electron/universal@2.0.3(supports-color@7.1.0)': + dependencies: + '@electron/asar': 3.4.1 + '@malept/cross-spawn-promise': 2.0.0 + debug: 4.4.3(supports-color@7.1.0) + dir-compare: 4.2.0 + fs-extra: 11.4.0 + minimatch: 9.0.9 + plist: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@electron/windows-sign@1.2.2(supports-color@7.1.0)': + dependencies: + cross-dirname: 0.1.0 + debug: 4.4.3(supports-color@7.1.0) + fs-extra: 11.4.0 + minimist: 1.2.8 + postject: 1.0.0-alpha.6 + transitivePeerDependencies: + - supports-color + optional: true + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + + '@malept/cross-spawn-promise@2.0.0': + dependencies: + cross-spawn: 7.0.6 + + '@malept/flatpak-bundler@0.4.0(supports-color@7.1.0)': + dependencies: + debug: 4.4.3(supports-color@7.1.0) + fs-extra: 9.0.0 + lodash: 4.18.1 + tmp-promise: 3.0.3 + transitivePeerDependencies: + - supports-color + + '@noble/hashes@1.4.0': {} + + '@noble/hashes@1.8.0': {} + + '@peculiar/asn1-schema@2.9.4': + dependencies: + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/json-schema@1.1.12': + dependencies: + tslib: 2.8.1 + + '@peculiar/utils@2.0.3': + dependencies: + tslib: 2.8.1 + + '@peculiar/webcrypto@1.7.1': + dependencies: + '@peculiar/asn1-schema': 2.9.4 + '@peculiar/json-schema': 1.1.12 + '@peculiar/utils': 2.0.3 + tslib: 2.8.1 + webcrypto-core: 1.9.2 + + '@sindresorhus/is@4.0.0': {} + + '@szmarczak/http-timer@4.0.6': + dependencies: + defer-to-connect: 2.0.1 + + '@types/cacheable-request@6.0.3': + dependencies: + '@types/http-cache-semantics': 4.2.0 + '@types/keyv': 3.1.4 + '@types/node': 22.20.2 + '@types/responselike': 1.0.3 + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/fs-extra@9.0.13': + dependencies: + '@types/node': 26.5.1 + + '@types/glob@7.2.0': + dependencies: + '@types/minimatch': 6.0.0 + '@types/node': 26.5.1 + optional: true + + '@types/http-cache-semantics@4.2.0': {} + + '@types/keyv@3.1.4': + dependencies: + '@types/node': 22.20.2 + + '@types/minimatch@6.0.0': + dependencies: + minimatch: 3.1.5 + optional: true + + '@types/ms@2.1.0': {} + + '@types/node@22.20.2': + dependencies: + undici-types: 6.21.0 + + '@types/node@26.5.1': + dependencies: + undici-types: 8.9.0 + + '@types/responselike@1.0.3': + dependencies: + '@types/node': 22.20.2 + + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 22.20.2 + optional: true + + '@xmldom/xmldom@0.8.15': {} + + '@xmldom/xmldom@0.9.12': {} + + abbrev@4.0.0: {} + + agent-base@7.0.2(supports-color@7.1.0): + dependencies: + debug: 4.4.3(supports-color@7.1.0) + transitivePeerDependencies: + - supports-color + + agent-base@7.1.0(supports-color@7.1.0): + dependencies: + debug: 4.4.3(supports-color@7.1.0) + transitivePeerDependencies: + - supports-color + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.7 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-styles@4.0.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@4.1.0: + dependencies: + color-convert: 2.0.1 + + app-builder-lib@26.16.1(dmg-builder@26.16.1)(electron-builder-squirrel-windows@26.16.1)(supports-color@7.1.0): + dependencies: + '@electron/asar': 3.4.1 + '@electron/fuses': 1.8.0 + '@electron/get': 3.0.0(supports-color@7.1.0) + '@electron/notarize': 2.5.0(supports-color@7.1.0) + '@electron/osx-sign': 1.3.3(supports-color@7.1.0) + '@electron/rebuild': 4.2.0(supports-color@7.1.0) + '@electron/universal': 2.0.3(supports-color@7.1.0) + '@malept/flatpak-bundler': 0.4.0(supports-color@7.1.0) + '@noble/hashes': 1.8.0 + '@peculiar/webcrypto': 1.7.1 + '@types/fs-extra': 9.0.13 + ajv: 8.20.0 + asn1js: 3.0.10 + async-exit-hook: 2.0.1 + builder-util: 26.16.0(supports-color@7.1.0) + builder-util-runtime: 9.7.0(supports-color@7.1.0) + chromium-pickle-js: 0.2.0 + ci-info: 4.3.1 + debug: 4.4.3(supports-color@7.1.0) + dmg-builder: 26.16.1(electron-builder-squirrel-windows@26.16.1)(supports-color@7.1.0) + dotenv: 16.4.5 + dotenv-expand: 11.0.6 + ejs: 3.1.8 + electron-builder-squirrel-windows: 26.16.1(dmg-builder@26.16.1)(supports-color@7.1.0) + electron-publish: 26.16.0(supports-color@7.1.0) + fs-extra: 10.1.0 + hosted-git-info: 4.1.0 + isbinaryfile: 5.0.7 + jiti: 2.7.0 + js-yaml: 4.3.2 + json5: 2.2.3 + lazy-val: 1.0.5 + minimatch: 10.2.6 + pkijs: 3.4.0 + plist: 3.1.0 + proper-lockfile: 4.1.2 + resedit: 1.7.0 + semver: 7.7.4 + tar: 7.5.22 + temp-file: 3.4.0 + tiny-async-pool: 1.3.0 + unzipper: 0.12.5 + which: 5.0.0 + transitivePeerDependencies: + - supports-color + + argparse@2.0.1: {} + + asn1js@3.0.10: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.2.0 + tslib: 2.8.1 + + async-exit-hook@2.0.1: {} + + async@3.2.6: {} + + asynckit@0.4.0: {} + + at-least-node@1.0.0: {} + + aws4@1.13.2: {} + + balanced-match@1.0.0: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + bluebird@3.7.2: {} + + boolean@3.2.0: + optional: true + + brace-expansion@1.1.18: + dependencies: + balanced-match: 1.0.0 + concat-map: 0.0.1 + + brace-expansion@2.1.4: + dependencies: + balanced-match: 1.0.0 + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + buffer-crc32@0.2.13: {} + + buffer-from@1.1.2: {} + + builder-util-runtime@9.7.0(supports-color@7.1.0): + dependencies: + debug: 4.4.3(supports-color@7.1.0) + sax: 1.6.1 + transitivePeerDependencies: + - supports-color + + builder-util@26.16.0(supports-color@7.1.0): + dependencies: + '@types/debug': 4.1.13 + builder-util-runtime: 9.7.0(supports-color@7.1.0) + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3(supports-color@7.1.0) + fs-extra: 10.1.0 + http-proxy-agent: 7.0.0(supports-color@7.1.0) + https-proxy-agent: 7.0.0(supports-color@7.1.0) + js-yaml: 4.3.2 + sanitize-filename: 1.6.4 + source-map-support: 0.5.21 + stat-mode: 1.0.0 + temp-file: 3.4.0 + tiny-async-pool: 1.3.0 + transitivePeerDependencies: + - supports-color + + bytestreamjs@2.0.1: {} + + cacheable-lookup@5.0.4: {} + + cacheable-request@7.0.2: + dependencies: + clone-response: 1.0.3 + get-stream: 5.2.0 + http-cache-semantics: 4.2.0 + keyv: 4.0.0 + lowercase-keys: 2.0.0 + normalize-url: 6.0.1 + responselike: 2.0.1 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + chalk@4.1.0: + dependencies: + ansi-styles: 4.1.0 + supports-color: 7.1.0 + + chalk@4.1.1: + dependencies: + ansi-styles: 4.1.0 + supports-color: 7.1.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.1.0 + supports-color: 7.1.0 + + chownr@3.0.0: {} + + chromium-pickle-js@0.2.0: {} + + ci-info@4.3.1: {} + + ci-info@4.4.0: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone-response@1.0.3: + dependencies: + mimic-response: 1.0.1 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@5.0.0: {} + + commander@9.5.0: + optional: true + + compare-version@0.1.2: {} + + concat-map@0.0.1: {} + + config-chain@1.1.13: + dependencies: + ini: 1.3.4 + proto-list: 1.2.4 + optional: true + + core-util-is@1.0.3: {} + + cross-dirname@0.1.0: + optional: true + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.1 + + debug@4.4.3(supports-color@7.1.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 7.1.0 + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + defer-to-connect@2.0.1: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + optional: true + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + optional: true + + delayed-stream@1.0.0: {} + + detect-node@2.1.0: + optional: true + + dir-compare@4.2.0: + dependencies: + minimatch: 3.1.5 + p-limit: 3.1.0 + + dmg-builder@26.16.1(electron-builder-squirrel-windows@26.16.1)(supports-color@7.1.0): + dependencies: + app-builder-lib: 26.16.1(dmg-builder@26.16.1)(electron-builder-squirrel-windows@26.16.1)(supports-color@7.1.0) + builder-util: 26.16.0(supports-color@7.1.0) + fs-extra: 10.1.0 + js-yaml: 4.3.2 + transitivePeerDependencies: + - electron-builder-squirrel-windows + - supports-color + + dotenv-expand@11.0.6: + dependencies: + dotenv: 16.4.5 + + dotenv@16.4.5: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + duplexer2@0.1.4: + dependencies: + readable-stream: 2.3.8 + + ejs@3.1.8: + dependencies: + jake: 10.8.5 + + electron-builder-squirrel-windows@26.16.1(dmg-builder@26.16.1)(supports-color@7.1.0): + dependencies: + app-builder-lib: 26.16.1(dmg-builder@26.16.1)(electron-builder-squirrel-windows@26.16.1)(supports-color@7.1.0) + builder-util: 26.16.0(supports-color@7.1.0) + electron-winstaller: 5.4.0(supports-color@7.1.0) + transitivePeerDependencies: + - dmg-builder + - supports-color + + electron-builder@26.16.1(electron-builder-squirrel-windows@26.16.1)(supports-color@7.1.0): + dependencies: + app-builder-lib: 26.16.1(dmg-builder@26.16.1)(electron-builder-squirrel-windows@26.16.1)(supports-color@7.1.0) + builder-util: 26.16.0(supports-color@7.1.0) + builder-util-runtime: 9.7.0(supports-color@7.1.0) + chalk: 4.1.2 + ci-info: 4.4.0 + dmg-builder: 26.16.1(electron-builder-squirrel-windows@26.16.1)(supports-color@7.1.0) + fs-extra: 10.1.0 + lazy-val: 1.0.5 + simple-update-notifier: 2.0.0 + yargs: 17.7.3 + transitivePeerDependencies: + - electron-builder-squirrel-windows + - supports-color + + electron-publish@26.16.0(supports-color@7.1.0): + dependencies: + '@types/fs-extra': 9.0.13 + aws4: 1.13.2 + builder-util: 26.16.0(supports-color@7.1.0) + builder-util-runtime: 9.7.0(supports-color@7.1.0) + chalk: 4.1.2 + form-data: 4.0.6 + fs-extra: 10.1.0 + lazy-val: 1.0.5 + mime: 2.6.0 + transitivePeerDependencies: + - supports-color + + electron-winstaller@5.4.0(supports-color@7.1.0): + dependencies: + '@electron/asar': 3.2.1 + debug: 4.4.3(supports-color@7.1.0) + fs-extra: 7.0.1 + lodash: 4.18.1 + temp: 0.9.4 + optionalDependencies: + '@electron/windows-sign': 1.2.2(supports-color@7.1.0) + transitivePeerDependencies: + - supports-color + + electron@38.2.2(supports-color@7.1.0): + dependencies: + '@electron/get': 2.0.0(supports-color@7.1.0) + '@types/node': 22.20.2 + extract-zip: 2.0.1(supports-color@7.1.0) + transitivePeerDependencies: + - supports-color + + emoji-regex@8.0.0: {} + + encodeurl@1.0.2: + optional: true + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + env-paths@2.2.0: {} + + err-code@2.0.3: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + es6-error@4.1.1: + optional: true + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: + optional: true + + exponential-backoff@3.1.3: {} + + extract-zip@2.0.1(supports-color@7.1.0): + dependencies: + debug: 4.4.3(supports-color@7.1.0) + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.7: {} + + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + filelist@1.0.6: + dependencies: + minimatch: 5.1.9 + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-extra@11.3.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-extra@11.4.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-extra@7.0.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@8.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@9.0.0: + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 1.0.0 + + fs-extra@9.0.1: + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 1.0.0 + + fs.realpath@1.0.0: {} + + function-bind@1.1.2: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-stream@5.2.0: + dependencies: + pump: 3.0.4 + + glob@7.2.0: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + global-agent@3.0.0: + dependencies: + boolean: 3.2.0 + es6-error: 4.1.1 + matcher: 3.0.0 + roarr: 2.15.3 + semver: 7.8.5 + serialize-error: 7.0.1 + optional: true + + global-tunnel-ng@2.7.1: + dependencies: + encodeurl: 1.0.2 + lodash: 4.18.1 + npm-conf: 1.1.3 + tunnel: 0.0.6 + optional: true + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + optional: true + + gopd@1.2.0: {} + + got@11.8.5: + dependencies: + '@sindresorhus/is': 4.0.0 + '@szmarczak/http-timer': 4.0.6 + '@types/cacheable-request': 6.0.3 + '@types/responselike': 1.0.3 + cacheable-lookup: 5.0.4 + cacheable-request: 7.0.2 + decompress-response: 6.0.0 + http2-wrapper: 1.0.3 + lowercase-keys: 2.0.0 + p-cancelable: 2.1.1 + responselike: 2.0.1 + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + optional: true + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hosted-git-info@4.1.0: + dependencies: + lru-cache: 6.0.0 + + http-cache-semantics@4.2.0: {} + + http-proxy-agent@7.0.0(supports-color@7.1.0): + dependencies: + agent-base: 7.1.0(supports-color@7.1.0) + debug: 4.4.3(supports-color@7.1.0) + transitivePeerDependencies: + - supports-color + + http2-wrapper@1.0.3: + dependencies: + quick-lru: 5.1.1 + resolve-alpn: 1.2.1 + + https-proxy-agent@7.0.0(supports-color@7.1.0): + dependencies: + agent-base: 7.0.2(supports-color@7.1.0) + debug: 4.4.3(supports-color@7.1.0) + transitivePeerDependencies: + - supports-color + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + ini@1.3.4: + optional: true + + is-fullwidth-code-point@3.0.0: {} + + isarray@1.0.0: {} + + isbinaryfile@4.0.10: {} + + isbinaryfile@5.0.7: {} + + isexe@2.0.0: {} + + isexe@3.1.5: {} + + isexe@4.0.0: {} + + jake@10.8.5: + dependencies: + async: 3.2.6 + chalk: 4.1.0 + filelist: 1.0.6 + minimatch: 3.1.5 + + jiti@2.7.0: {} + + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@1.0.0: {} + + json-stringify-safe@5.0.1: + optional: true + + json5@2.2.3: {} + + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + keyv@4.0.0: + dependencies: + json-buffer: 3.0.1 + + lazy-val@1.0.5: {} + + lodash@4.18.1: {} + + lowercase-keys@2.0.0: {} + + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + + matcher@3.0.0: + dependencies: + escape-string-regexp: 4.0.0 + optional: true + + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@2.6.0: {} + + mimic-response@1.0.1: {} + + mimic-response@3.1.0: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.18 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.4 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.4 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + + ms@2.1.3: {} + + node-abi@4.35.0: + dependencies: + semver: 7.7.4 + + node-api-version@0.2.1: + dependencies: + semver: 7.7.4 + + node-gyp@12.4.0: + dependencies: + env-paths: 2.2.0 + exponential-backoff: 3.1.3 + graceful-fs: 4.2.11 + nopt: 9.0.0 + proc-log: 6.0.0 + semver: 7.7.4 + tar: 7.5.22 + tinyglobby: 0.2.17 + undici: 6.28.1 + which: 6.0.1 + + node-int64@0.4.0: {} + + nopt@9.0.0: + dependencies: + abbrev: 4.0.0 + + normalize-url@6.0.1: {} + + npm-conf@1.1.3: + dependencies: + config-chain: 1.1.13 + pify: 3.0.0 + optional: true + + object-keys@1.1.1: + optional: true + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + p-cancelable@2.1.1: {} + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + pe-library@0.4.1: {} + + pend@1.2.0: {} + + picomatch@4.0.7: {} + + pify@3.0.0: + optional: true + + pkijs@3.4.0: + dependencies: + '@noble/hashes': 1.4.0 + asn1js: 3.0.10 + bytestreamjs: 2.0.1 + pvtsutils: 1.3.6 + pvutils: 1.2.0 + tslib: 2.8.1 + + plist@3.1.0: + dependencies: + '@xmldom/xmldom': 0.8.15 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + + plist@3.1.1: + dependencies: + '@xmldom/xmldom': 0.9.12 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + + postject@1.0.0-alpha.6: + dependencies: + commander: 9.5.0 + optional: true + + proc-log@6.0.0: {} + + process-nextick-args@2.0.1: {} + + progress@2.0.3: {} + + promise-retry@2.0.1: + dependencies: + err-code: 2.0.3 + retry: 0.12.0 + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + + proto-list@1.2.4: + optional: true + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.2.0: {} + + quick-lru@5.1.1: {} + + read-binary-file-arch@1.0.6(supports-color@7.1.0): + dependencies: + debug: 4.4.3(supports-color@7.1.0) + transitivePeerDependencies: + - supports-color + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + resedit@1.7.0: + dependencies: + pe-library: 0.4.1 + + resolve-alpn@1.2.1: {} + + responselike@2.0.1: + dependencies: + lowercase-keys: 2.0.0 + + retry@0.12.0: {} + + rimraf@2.6.2: + dependencies: + glob: 7.2.0 + + roarr@2.15.3: + dependencies: + boolean: 3.2.0 + detect-node: 2.1.0 + globalthis: 1.0.4 + json-stringify-safe: 5.0.1 + semver-compare: 1.0.0 + sprintf-js: 1.1.3 + optional: true + + safe-buffer@5.1.2: {} + + sanitize-filename@1.6.4: + dependencies: + truncate-utf8-bytes: 1.0.2 + + sax@1.6.1: {} + + semver-compare@1.0.0: + optional: true + + semver@5.5.0: {} + + semver@6.2.0: {} + + semver@7.7.4: {} + + semver@7.8.5: {} + + serialize-error@7.0.1: + dependencies: + type-fest: 0.13.1 + optional: true + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@3.0.7: {} + + simple-update-notifier@2.0.0: + dependencies: + semver: 7.8.5 + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.0 + + source-map@0.6.0: {} + + sprintf-js@1.1.3: + optional: true + + stat-mode@1.0.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + sumchecker@3.0.1(supports-color@7.1.0): + dependencies: + debug: 4.4.3(supports-color@7.1.0) + transitivePeerDependencies: + - supports-color + + supports-color@7.1.0: + dependencies: + has-flag: 4.0.0 + + tar@7.5.22: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + + temp-file@3.4.0: + dependencies: + async-exit-hook: 2.0.1 + fs-extra: 10.1.0 + + temp@0.9.4: + dependencies: + mkdirp: 0.5.6 + rimraf: 2.6.2 + + tiny-async-pool@1.3.0: + dependencies: + semver: 5.5.0 + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tmp-promise@3.0.3: + dependencies: + tmp: 0.2.7 + + tmp@0.2.7: {} + + truncate-utf8-bytes@1.0.2: + dependencies: + utf8-byte-length: 1.0.5 + + tslib@2.8.1: {} + + tunnel@0.0.6: + optional: true + + type-fest@0.13.1: + optional: true + + undici-types@6.21.0: {} + + undici-types@8.9.0: {} + + undici@6.28.1: {} + + universalify@0.1.2: {} + + universalify@1.0.0: {} + + universalify@2.0.1: {} + + unzipper@0.12.5: + dependencies: + bluebird: 3.7.2 + duplexer2: 0.1.4 + fs-extra: 11.3.1 + graceful-fs: 4.2.11 + node-int64: 0.4.0 + + utf8-byte-length@1.0.5: {} + + util-deprecate@1.0.2: {} + + webcrypto-core@1.9.2: + dependencies: + '@peculiar/asn1-schema': 2.9.4 + '@peculiar/json-schema': 1.1.12 + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + which@2.0.1: + dependencies: + isexe: 2.0.0 + + which@5.0.0: + dependencies: + isexe: 3.1.5 + + which@6.0.1: + dependencies: + isexe: 4.0.0 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + xmlbuilder@15.1.1: {} + + y18n@5.0.8: {} + + yallist@4.0.0: {} + + yallist@5.0.0: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + + yocto-queue@0.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..7fb1dab --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +# pnpm 11 的 strictDepBuilds 默认为 true:只要有依赖的构建脚本被跳过,pnpm install 就直接以退出码 1 结束。 +# electron 的 postinstall 正是「下载运行时」那一步,必须放行,否则构建机上装依赖会中途失败。 +# (pnpm 11 起设置从 package.json 的 pnpm 字段和 .npmrc 搬到这里,且旧键 onlyBuiltDependencies 已不再生效。) +allowBuilds: + electron: true + electron-winstaller: true diff --git a/preload.js b/preload.js new file mode 100644 index 0000000..6af6f05 --- /dev/null +++ b/preload.js @@ -0,0 +1,21 @@ +'use strict'; + +const { contextBridge, ipcRenderer } = require('electron'); + +// 只把需要的动作暴露出去,渲染进程拿不到 ipcRenderer 本体,也不开 nodeIntegration +contextBridge.exposeInMainWorld('clockApi', { + getState: () => ipcRenderer.invoke('state:get'), + onState: (cb) => ipcRenderer.on('state', (_event, snapshot) => cb(snapshot)), + setRate: (rate) => ipcRenderer.invoke('rate:set', rate), + togglePause: () => ipcRenderer.invoke('pause:toggle'), + fastForward: (deltaMs) => ipcRenderer.invoke('fast-forward', deltaMs), + setTime: (h, m, s) => ipcRenderer.invoke('time:set', { h, m, s }), + setStyle: (style) => ipcRenderer.invoke('style:set', style), + setAlwaysOnTop: (target, value) => ipcRenderer.invoke('always-on-top:set', { target, value }), + toggleClockWindow: () => ipcRenderer.invoke('clock-window:toggle'), + closeClockWindow: () => ipcRenderer.invoke('clock-window:close'), + closeControlWindow: () => ipcRenderer.invoke('control-window:close'), + // 调整大小要在 mousedown 那一刻拿到精确的窗口位置,异步往返会让第一条 mousemove 用错基准 + getBoundsSync: () => ipcRenderer.sendSync('bounds:get-sync'), + setBounds: (bounds) => ipcRenderer.send('bounds:set', bounds) +}); diff --git a/renderer/clock.css b/renderer/clock.css new file mode 100644 index 0000000..88eba14 --- /dev/null +++ b/renderer/clock.css @@ -0,0 +1,214 @@ +:root { + --bg: #05070c; + --fg: #f2f6ff; + --accent: #4fd1c5; + --muted: #7d8aa5; + --handle: 10px; + --corner: 18px; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + height: 100%; + overflow: hidden; + background: var(--bg); + color: var(--fg); + user-select: none; + cursor: default; + font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif; +} + +/* 拖动区:无边框窗口靠它实现「按住任意空白处移动窗口」 */ +.stage { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: radial-gradient(120% 140% at 50% 0%, #131a29 0%, var(--bg) 70%); + -webkit-app-region: drag; +} + +.view { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.view[hidden] { + display: none; +} + +.digital-time { + font-family: Consolas, 'Cascadia Mono', 'DejaVu Sans Mono', monospace; + font-variant-numeric: tabular-nums; + font-weight: 600; + line-height: 1; + letter-spacing: 0.02em; + color: var(--fg); + text-shadow: 0 0 28px rgba(79, 209, 197, 0.35); + white-space: nowrap; +} + +.digital-date { + margin-top: 0.7em; + /* 用 vmin 跟着窗口缩放,投到大屏时日期才不会小得看不见 */ + font-size: clamp(13px, 5.5vmin, 64px); + letter-spacing: 0.18em; + color: var(--muted); + white-space: nowrap; +} + +.analog-view { + padding: 10px; +} + +.dial { + height: 100%; + width: 100%; + max-width: 100%; + max-height: 100%; +} + +.dial-face { + fill: #0b1020; + stroke: #26314a; + stroke-width: 2; +} + +.tick { + stroke: #46577a; + stroke-width: 2; + stroke-linecap: round; +} + +.tick-major { + stroke: #8fa4cc; + stroke-width: 4; +} + +.numeral { + fill: #8fa4cc; + font-family: Consolas, 'DejaVu Sans Mono', monospace; + font-size: 18px; + text-anchor: middle; + dominant-baseline: central; +} + +.hand { + stroke-linecap: round; +} + +.hand-hour { + stroke: #e6ecff; + stroke-width: 7; +} + +.hand-minute { + stroke: #c3cfe8; + stroke-width: 4.5; +} + +.hand-second { + stroke: var(--accent); + stroke-width: 2; +} + +.hand-cap { + fill: var(--accent); +} + +.badge { + position: absolute; + top: 10px; + left: 12px; + padding: 3px 10px; + border-radius: 999px; + font-size: 13px; + letter-spacing: 0.06em; + color: #0b1020; + background: var(--accent); + opacity: 0.92; + -webkit-app-region: no-drag; +} + +.badge[hidden] { + display: none; +} + +.close-btn { + position: absolute; + top: 8px; + right: 8px; + width: 26px; + height: 26px; + line-height: 1; + font-size: 18px; + border: 0; + border-radius: 8px; + color: #9fb0cc; + background: rgba(255, 255, 255, 0.06); + cursor: pointer; + opacity: 0; + transition: opacity 0.15s ease; + /* 关键:按钮必须退出拖动区,否则在无边框窗口里点不动 */ + -webkit-app-region: no-drag; +} + +.stage:hover .close-btn { + opacity: 1; +} + +.close-btn:hover { + color: #fff; + background: #e0555a; +} + +/* 自定义改大小的边缘热区:放在 stage 之外,天然不属于拖动区,鼠标事件能正常到达 */ +.rz { + position: fixed; + z-index: 20; + -webkit-app-region: no-drag; +} + +.rz-n, +.rz-s { + left: var(--corner); + right: var(--corner); + height: var(--handle); + cursor: ns-resize; +} + +.rz-w, +.rz-e { + top: var(--corner); + bottom: var(--corner); + width: var(--handle); + cursor: ew-resize; +} + +.rz-n { top: 0; } +.rz-s { bottom: 0; } +.rz-w { left: 0; } +.rz-e { right: 0; } + +.rz-nw, +.rz-ne, +.rz-sw, +.rz-se { + width: var(--corner); + height: var(--corner); +} + +.rz-nw { top: 0; left: 0; cursor: nwse-resize; } +.rz-se { bottom: 0; right: 0; cursor: nwse-resize; } +.rz-ne { top: 0; right: 0; cursor: nesw-resize; } +.rz-sw { bottom: 0; left: 0; cursor: nesw-resize; } diff --git a/renderer/clock.html b/renderer/clock.html new file mode 100644 index 0000000..1bdd0b0 --- /dev/null +++ b/renderer/clock.html @@ -0,0 +1,47 @@ + + + + + + ClockLab 时钟 + + + + + +
+
+
00:00:00
+
+
+ + + + + +
+ +
+
+
+
+
+
+
+
+ + + + + + diff --git a/renderer/clock.js b/renderer/clock.js new file mode 100644 index 0000000..0f06130 --- /dev/null +++ b/renderer/clock.js @@ -0,0 +1,190 @@ +'use strict'; + +const api = window.clockApi; + +const WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; + +const stage = document.getElementById('stage'); +const digitalView = document.getElementById('digitalView'); +const digitalTime = document.getElementById('digitalTime'); +const digitalDate = document.getElementById('digitalDate'); +const analogView = document.getElementById('analogView'); +const badge = document.getElementById('badge'); +const closeBtn = document.getElementById('closeBtn'); +const hands = { + hour: document.getElementById('hourHand'), + minute: document.getElementById('minuteHand'), + second: document.getElementById('secondHand') +}; + +// 主进程下发的走时参数:目标时间 = baseMs + (真实时间 - baseRealMs) × rate +let snapshot = { baseMs: Date.now(), baseRealMs: Date.now(), rate: 1, style: 'digital' }; + +// 呈现时间层:跳变时由它把「目标时间」缓动过来(数字滚、指针扫都是它的副产品) +const presented = presentedTime.create(); + +function pad2(value) { + return String(value).padStart(2, '0'); +} + +function buildDial() { + const ticks = document.getElementById('ticks'); + const numerals = document.getElementById('numerals'); + const ns = 'http://www.w3.org/2000/svg'; + for (let i = 0; i < 60; i += 1) { + const major = i % 5 === 0; + const angle = (i * 6 * Math.PI) / 180; + const outer = 90; + const inner = major ? 78 : 85; + const line = document.createElementNS(ns, 'line'); + line.setAttribute('x1', String(Math.sin(angle) * inner)); + line.setAttribute('y1', String(-Math.cos(angle) * inner)); + line.setAttribute('x2', String(Math.sin(angle) * outer)); + line.setAttribute('y2', String(-Math.cos(angle) * outer)); + line.setAttribute('class', major ? 'tick tick-major' : 'tick'); + ticks.appendChild(line); + } + for (let h = 1; h <= 12; h += 1) { + const angle = (h * 30 * Math.PI) / 180; + const text = document.createElementNS(ns, 'text'); + text.setAttribute('x', String(Math.sin(angle) * 62)); + text.setAttribute('y', String(-Math.cos(angle) * 62)); + text.setAttribute('class', 'numeral'); + text.textContent = String(h); + numerals.appendChild(text); + } +} + +// 数字时钟用等宽 tabular 字体,字符宽度与字号成正比,所以量一次比例就能按窗口尺寸精确铺满 +let fontRatio = null; + +function measureFontRatio() { + const previous = digitalTime.style.fontSize; + digitalTime.style.fontSize = '100px'; + const rect = digitalTime.getBoundingClientRect(); + fontRatio = { width: rect.width / 100, height: rect.height / 100 }; + digitalTime.style.fontSize = previous; +} + +function layoutDigital() { + if (snapshot.style !== 'digital' || !fontRatio || !fontRatio.width || !fontRatio.height) return; + const box = digitalView.getBoundingClientRect(); + const size = Math.min((box.width * 0.9) / fontRatio.width, (box.height * 0.6) / fontRatio.height); + digitalTime.style.fontSize = `${Math.max(12, Math.floor(size))}px`; +} + +function renderDigital(ms) { + const date = new Date(ms); + digitalTime.textContent = `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`; + digitalDate.textContent = `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${WEEKDAYS[date.getDay()]}`; +} + +function renderAnalog(ms) { + const date = new Date(ms); + // 秒针按毫秒连续走,这样在高倍率/负倍率下看起来是平滑扫过而不是一格一格跳 + const seconds = date.getSeconds() + date.getMilliseconds() / 1000; + const minutes = date.getMinutes() + seconds / 60; + const hours = (date.getHours() % 12) + minutes / 60; + hands.hour.setAttribute('transform', `rotate(${hours * 30} 0 0)`); + hands.minute.setAttribute('transform', `rotate(${minutes * 6} 0 0)`); + hands.second.setAttribute('transform', `rotate(${seconds * 6} 0 0)`); +} + +function updateBadge() { + const rate = snapshot.rate; + if (rate === 1) { + badge.hidden = true; + return; + } + badge.hidden = false; + if (rate === 0) badge.textContent = '暂停'; + else if (rate < 0) badge.textContent = `倒转 ×${rate}`; + else badge.textContent = `×${rate}`; +} + +function applyStyle() { + const analog = snapshot.style === 'analog'; + digitalView.hidden = analog; + analogView.hidden = !analog; + if (!analog) layoutDigital(); +} + +function frame() { + // 数字与表盘都只从「呈现时间」推导:平时它等于目标时间,跳变后的 0.35 秒里它是缓动中的中间值 + const ms = presentedTime.valueAt(presented, Date.now()); + if (snapshot.style === 'analog') renderAnalog(ms); + else renderDigital(ms); + // 自检脚本靠这个读数核对「主进程模型 → IPC → 画面」是否一致 + document.body.dataset.displayMs = String(Math.round(ms)); + document.body.dataset.style = snapshot.style; + requestAnimationFrame(frame); +} + +// —— 自定义拖边缘改大小 —— +// 无边框窗口在 Windows 上边缘命中不稳定,所以完全自己做:8 条热区接 pointer 事件, +// 用按下瞬间的窗口 bounds 加鼠标位移算出新 bounds,交给主进程 setBounds。 +let resize = null; + +function applyResize(screenX, screenY) { + const next = computeBounds( + resize.bounds, + resize.dir, + screenX - resize.startX, + screenY - resize.startY + ); + api.setBounds(next); +} + +for (const handle of document.querySelectorAll('.rz')) { + handle.addEventListener('pointerdown', (event) => { + if (event.button !== 0) return; + const bounds = api.getBoundsSync(); + if (!bounds) return; + resize = { dir: handle.dataset.dir, startX: event.screenX, startY: event.screenY, bounds }; + handle.setPointerCapture(event.pointerId); + event.preventDefault(); + }); + + handle.addEventListener('pointermove', (event) => { + if (!resize) return; + applyResize(event.screenX, event.screenY); + }); + + const stop = (event) => { + if (!resize) return; + resize = null; + try { + handle.releasePointerCapture(event.pointerId); + } catch { + /* 指针已经失效时 release 会抛,忽略即可 */ + } + }; + handle.addEventListener('pointerup', stop); + handle.addEventListener('pointercancel', stop); +} + +closeBtn.addEventListener('click', () => api.closeClockWindow()); + +// 万一鼠标松开的事件丢了(拖拽中窗口失焦等),不能让下一次移动继续改大小 +window.addEventListener('blur', () => { + resize = null; +}); + +// 主进程每次下发参数都走这里:先让呈现时间层判断这次要不要缓动,再更新画面 +function applyState(next) { + presentedTime.accept(presented, Date.now(), next); + snapshot = next; + applyStyle(); + updateBadge(); +} + +api.onState(applyState); + +new ResizeObserver(() => layoutDigital()).observe(stage); + +buildDial(); +measureFontRatio(); +api.getState().then((initial) => { + applyState(initial); + frame(); +}); diff --git a/renderer/control.css b/renderer/control.css new file mode 100644 index 0000000..4df8d30 --- /dev/null +++ b/renderer/control.css @@ -0,0 +1,297 @@ +:root { + --bg: #12151c; + --panel: #1a1f2b; + --panel-2: #222838; + --line: #2c3446; + --fg: #e8edf8; + --muted: #8b98b4; + --accent: #4fd1c5; + --accent-dark: #2aa79c; + --warn: #e0a355; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 18px 20px 26px; + min-height: 100vh; + background: var(--bg); + color: var(--fg); + font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif; + font-size: 14px; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 14px 18px; + border: 1px solid var(--line); + border-radius: 14px; + background: linear-gradient(180deg, #1d2432, #171c27); +} + +.brand { + display: flex; + align-items: center; + gap: 12px; +} + +.dot { + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 14px var(--accent); +} + +h1 { + margin: 0; + font-size: 17px; + font-weight: 600; + letter-spacing: 0.02em; +} + +.sub { + margin: 3px 0 0; + font-size: 12px; + color: var(--muted); +} + +.readout { + text-align: right; +} + +.readout-time { + font-family: Consolas, 'Cascadia Mono', monospace; + font-variant-numeric: tabular-nums; + font-size: 30px; + line-height: 1.1; + color: var(--accent); +} + +.readout-meta { + font-size: 12px; + color: var(--muted); +} + +.grid { + margin-top: 16px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + align-items: start; +} + +.card { + padding: 16px 18px 18px; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--panel); +} + +.wide-card { + grid-column: 1 / -1; +} + +h2 { + margin: 0 0 14px; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--accent); +} + +.row { + display: flex; + align-items: center; + gap: 10px; +} + +.row + .row { + margin-top: 12px; +} + +.row.wrap { + flex-wrap: wrap; +} + +.stack-tight { + flex-direction: column; + align-items: flex-start; + gap: 10px; +} + +.field + .field { + margin-top: 18px; + padding-top: 16px; + border-top: 1px dashed var(--line); +} + +.field-label { + margin-bottom: 10px; + font-size: 12px; + color: var(--muted); +} + +.btn { + padding: 8px 14px; + border: 1px solid var(--line); + border-radius: 9px; + background: var(--panel-2); + color: var(--fg); + font-size: 13px; + font-family: inherit; + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease, transform 0.06s ease; +} + +.btn:hover { + background: #2a3244; + border-color: #3b465e; +} + +.btn:active { + transform: translateY(1px); +} + +.btn-primary { + background: var(--accent-dark); + border-color: var(--accent-dark); + color: #06231f; + font-weight: 600; +} + +.btn-primary:hover { + background: var(--accent); + border-color: var(--accent); +} + +.btn-primary.is-on { + background: #3a2a33; + border-color: #6b3a44; + color: #ffb3bd; +} + +.btn-primary.is-on:hover { + background: #4a333e; +} + +.btn-warn { + border-color: #6b5326; + color: #f0c37a; +} + +/* 暂停中要一眼看出来,否则用户会以为时钟坏了 */ +.btn-warn.is-on { + background: #3b2f1a; + border-color: var(--warn); + color: #ffd79a; + font-weight: 600; +} + +.btn-ghost { + background: transparent; + color: var(--muted); +} + +.btn-chip { + padding: 6px 11px; + font-size: 12.5px; +} + +.btn.seg { + flex: 1; + padding: 10px 14px; +} + +.btn.seg.is-active { + background: var(--accent-dark); + border-color: var(--accent); + color: #06231f; + font-weight: 600; +} + +.segmented { + display: flex; + gap: 10px; +} + +.wide { + width: 100%; + padding: 11px 14px; + font-size: 14px; +} + +.input { + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 9px; + background: #0f131b; + color: var(--fg); + font-family: Consolas, 'Cascadia Mono', monospace; + font-size: 15px; + font-variant-numeric: tabular-nums; +} + +.input:focus { + outline: none; + border-color: var(--accent-dark); +} + +.num { + width: 62px; + text-align: center; +} + +.wide-num { + width: 96px; +} + +.time-inputs { + display: flex; + align-items: center; + gap: 4px; +} + +.colon { + color: var(--muted); + font-size: 16px; +} + +.check { + display: flex; + align-items: center; + gap: 9px; + cursor: pointer; + color: var(--fg); +} + +.check input { + width: 16px; + height: 16px; + accent-color: var(--accent-dark); + cursor: pointer; +} + +.hint { + margin: 14px 0 0; + font-size: 12px; + line-height: 1.55; + color: var(--muted); +} + +.presets { + margin-top: 10px; +} + +@media (max-width: 820px) { + .grid { + grid-template-columns: 1fr; + } +} diff --git a/renderer/control.html b/renderer/control.html new file mode 100644 index 0000000..c7bf4b4 --- /dev/null +++ b/renderer/control.html @@ -0,0 +1,105 @@ + + + + + + ClockLab 控制台 + + + +
+
+ +
+

ClockLab 时钟控制台

+

显示时间 = 基准时间 + 真实经过时间 × 倍率

+
+
+
+
--:--:--
+
连接中…
+
+
+ +
+
+

时钟窗口

+
+ +
+
+ + +
+

关掉控制窗口即退出整个程序,时钟窗口一并关闭。

+
+ +
+

显示样式

+
+ + +
+

切换立即生效,不需要重开时钟窗口。

+
+ +
+

时间与流速

+ +
+
设定时间(24 小时制)
+
+
+ + : + + : + +
+ + +
+
+ +
+
流速倍率(可正、可负、可 0、可小数)
+
+ + + +
+
+ + + + + + + + +
+

「暂停 / 继续」按钮等价于倍率 0 / 回到暂停前的那个倍率。倍率为负时时钟会一直往回走,不是卡住。

+
+ +
+
快进 / 快退(按当前显示时间整体平移,可与任意倍率叠加)
+
+ + + + + +
+
+
+
+ + + + diff --git a/renderer/control.js b/renderer/control.js new file mode 100644 index 0000000..2a1c08f --- /dev/null +++ b/renderer/control.js @@ -0,0 +1,145 @@ +'use strict'; + +const api = window.clockApi; + +const els = { + readoutTime: document.getElementById('readoutTime'), + readoutMeta: document.getElementById('readoutMeta'), + toggleClockWindow: document.getElementById('toggleClockWindow'), + aotClock: document.getElementById('aotClock'), + aotControl: document.getElementById('aotControl'), + styleGroup: document.getElementById('styleGroup'), + hh: document.getElementById('hh'), + mm: document.getElementById('mm'), + ss: document.getElementById('ss'), + applyTime: document.getElementById('applyTime'), + fillNow: document.getElementById('fillNow'), + rate: document.getElementById('rate'), + applyRate: document.getElementById('applyRate'), + togglePause: document.getElementById('togglePause') +}; + +let snapshot = { baseMs: Date.now(), baseRealMs: Date.now(), rate: 1, style: 'digital', paused: false, clockWindowOpen: false }; + +function pad2(value) { + return String(value).padStart(2, '0'); +} + +function displayMs(now) { + return snapshot.baseMs + (now - snapshot.baseRealMs) * snapshot.rate; +} + +function clampInt(value, min, max, fallback) { + const n = Number.parseInt(value, 10); + if (!Number.isFinite(n)) return fallback; + return Math.min(max, Math.max(min, n)); +} + +function renderReadout() { + const ms = displayMs(Date.now()); + const date = new Date(ms); + els.readoutTime.textContent = `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`; + + const rateText = snapshot.rate === 0 ? '暂停' : `×${snapshot.rate}`; + const runText = snapshot.rate === 0 ? '已暂停' : snapshot.rate < 0 ? '倒转中' : '运行中'; + els.readoutMeta.textContent = `${rateText} · ${runText} · 时钟窗口${snapshot.clockWindowOpen ? '已打开' : '已关闭'}`; + requestAnimationFrame(renderReadout); +} + +function syncControls() { + els.toggleClockWindow.textContent = snapshot.clockWindowOpen ? '关闭时钟窗口' : '打开时钟窗口'; + els.toggleClockWindow.classList.toggle('is-on', snapshot.clockWindowOpen); + + els.aotClock.checked = Boolean(snapshot.alwaysOnTop.clock); + els.aotControl.checked = Boolean(snapshot.alwaysOnTop.control); + + for (const button of els.styleGroup.querySelectorAll('.seg')) { + button.classList.toggle('is-active', button.dataset.style === snapshot.style); + } + + // 用户正在输入时不要抢走他的光标和内容 + if (document.activeElement !== els.rate) els.rate.value = String(snapshot.rate); + + if (snapshot.rate === 0) { + els.togglePause.textContent = `继续(回到 ×${snapshot.lastRate})`; + els.togglePause.classList.add('is-on'); + } else { + els.togglePause.textContent = '暂停'; + els.togglePause.classList.remove('is-on'); + } +} + +function accept(next) { + if (next) { + snapshot = next; + syncControls(); + } +} + +els.toggleClockWindow.addEventListener('click', async () => accept(await api.toggleClockWindow())); + +els.aotClock.addEventListener('change', async () => accept(await api.setAlwaysOnTop('clock', els.aotClock.checked))); +els.aotControl.addEventListener('change', async () => accept(await api.setAlwaysOnTop('control', els.aotControl.checked))); + +for (const button of els.styleGroup.querySelectorAll('.seg')) { + button.addEventListener('click', async () => accept(await api.setStyle(button.dataset.style))); +} + +els.applyTime.addEventListener('click', async () => { + const h = clampInt(els.hh.value, 0, 23, 0); + const m = clampInt(els.mm.value, 0, 59, 0); + const s = clampInt(els.ss.value, 0, 59, 0); + els.hh.value = String(h); + els.mm.value = String(m); + els.ss.value = String(s); + accept(await api.setTime(h, m, s)); +}); + +els.fillNow.addEventListener('click', () => { + // 三格都要补零:`String(0)` 是 "0",而输入框按 HH:MM:SS 解析, + // 上午 9 点前会填出 "0:39:27" 这种非法值,点应用时解析失败。 + const date = new Date(displayMs(Date.now())); + els.hh.value = pad2(date.getHours()); + els.mm.value = pad2(date.getMinutes()); + els.ss.value = pad2(date.getSeconds()); +}); + +function applyRateInput() { + const value = Number(els.rate.value); + if (!Number.isFinite(value)) { + els.rate.value = String(snapshot.rate); + return; + } + api.setRate(value).then(accept); +} + +els.applyRate.addEventListener('click', applyRateInput); + +els.rate.addEventListener('keydown', (event) => { + if (event.key === 'Enter') applyRateInput(); +}); + +els.togglePause.addEventListener('click', async () => accept(await api.togglePause())); + +document.body.addEventListener('click', (event) => { + const rate = event.target.dataset?.rate; + if (rate !== undefined) { + api.setRate(Number(rate)).then(accept); + return; + } + const delta = event.target.dataset?.delta; + if (delta !== undefined) { + api.fastForward(Number(delta)).then(accept); + } +}); + +api.onState((next) => { + snapshot = next; + syncControls(); +}); + +api.getState().then((initial) => { + snapshot = initial; + syncControls(); + renderReadout(); +}); diff --git a/renderer/presented-time.js b/renderer/presented-time.js new file mode 100644 index 0000000..e52adc8 --- /dev/null +++ b/renderer/presented-time.js @@ -0,0 +1,74 @@ +'use strict'; + +// 呈现时间层:主进程给的是「目标时间」,画面用的是「呈现时间」。 +// 平时两者相等;只有在发生一次性跳变(快进、设定时间)时,才用一小段时间把呈现时间缓动到新目标, +// 于是数字会自己「滚」过去、指针会「扫」过去 —— 数字和表盘都只从这一个值推导,不必各写一套动画。 +// +// 之所以动画「时间」而不是动画「字符 / 角度」:跨 12 点、跨 0 点时角度公式照旧, +// 不会出现 359°→1° 倒着转一圈;老式里程表那种数字观感也只是同一个值的副产品。 +// +// 拆成独立文件是为了能在 node 里直接跑用例(同 renderer/resize.js 的做法)。 + +const JUMP_THRESHOLD_MS = 1500; // 一步小于这个幅度就直接跳:正常走时每秒都在变,不该每秒动一次 +const EASE_MS = 350; // 缓动时长,够看清「滚」的过程,又不至于拖到下一次操作 + +function targetAt(snapshot, now) { + return snapshot.baseMs + (now - snapshot.baseRealMs) * snapshot.rate; +} + +// 两次参数之间的目标断点:在同一个真实时刻上,分别用新旧参数算时间再相减。 +// 取新参数的基准时刻来比,是因为改倍率时模型先把当前显示时间固化进了基准, +// 两个公式在这一刻必然相等 —— 「改变速度」不会被误判成「跳变」。 +function jumpBetween(previous, next) { + if (!previous) return 0; + const at = next.baseRealMs; + return targetAt(next, at) - targetAt(previous, at); +} + +function create() { + return { snapshot: null, offsetMs: 0, startMs: 0 }; +} + +// 尚未消化的偏移:缓动起点处的差值按三次缓出衰减到 0,越小说明越接近目标 +function pendingMs(layer, now) { + const t = (now - layer.startMs) / EASE_MS; + if (t >= 1) return 0; // 到点就彻底归零,不给浮点留残余(否则数字永远差几百毫秒) + if (t <= 0) return layer.offsetMs; + const left = 1 - t; + return layer.offsetMs * left * left * left; +} + +// 收到新的走时参数时调用:决定这一次是「直接跳」还是「缓动过去」 +function accept(layer, now, snapshot) { + // 只在「目标时间确实断了一截」时才缓动,所以: + // · 幅度太小(正常走时、+1 秒)直跳 —— 否则每秒都在滚,反而看不清时间; + // · rate 0(暂停)直跳 —— 暂停画面必须停在暂停那一刻的准确时间上, + // 让缓动挂在那里的话,暂停期间显示的就是一个走不完的中间值(看着像时间不对)。 + const jump = layer.snapshot ? jumpBetween(layer.snapshot, snapshot) : 0; + const animate = layer.snapshot !== null && snapshot.rate !== 0 && Math.abs(jump) > JUMP_THRESHOLD_MS; + + if (animate) { + // 从「当前呈现值」重新出发:缓动没结束又来一次跳变时,用旧目标减去尚未消化的偏移反推回去, + // 不能从跳变前的旧值重来,否则画面会先弹回去再滚一次。 + const presentedNow = targetAt(layer.snapshot, now) - pendingMs(layer, now); + layer.offsetMs = targetAt(snapshot, now) - presentedNow; + } else { + layer.offsetMs = 0; + } + + layer.snapshot = snapshot; + layer.startMs = now; +} + +// 每帧调用:这一刻该显示的时间 +function valueAt(layer, now) { + return targetAt(layer.snapshot, now) - pendingMs(layer, now); +} + +// 渲染进程里这几个函数要能被 clock.js 直接用,所以除了 CommonJS 导出,还挂成一个命名空间 +// (同 resize.js 的双向可用做法:浏览器读全局,node 测试读 require) +const presentedTime = { create, accept, valueAt, targetAt, jumpBetween, JUMP_THRESHOLD_MS, EASE_MS }; + +if (typeof module !== 'undefined' && module.exports) { + module.exports = presentedTime; +} diff --git a/renderer/resize.js b/renderer/resize.js new file mode 100644 index 0000000..14b9b3f --- /dev/null +++ b/renderer/resize.js @@ -0,0 +1,42 @@ +'use strict'; + +// 拖边缘改大小的纯计算部分单独放一个文件: +// 这段几何运算是最容易出错、但在无边框窗口里只能靠真鼠标才能触发的地方, +// 拆出来之后可以在 node 里直接跑用例,不必等到 Windows 上肉眼试。 + +const MIN_SIZE = { width: 260, height: 150 }; + +function computeBounds(start, dir, dx, dy, min) { + const limit = min || MIN_SIZE; + let x = start.x; + let y = start.y; + let width = start.width; + let height = start.height; + + if (dir.includes('e')) width += dx; + if (dir.includes('s')) height += dy; + if (dir.includes('w')) { + width -= dx; + x += dx; + } + if (dir.includes('n')) { + height -= dy; + y += dy; + } + + // 顶到最小尺寸后继续拖,被拖的那条边要停住,而不是把整个窗口推走 + if (width < limit.width) { + if (dir.includes('w')) x = start.x + start.width - limit.width; + width = limit.width; + } + if (height < limit.height) { + if (dir.includes('n')) y = start.y + start.height - limit.height; + height = limit.height; + } + + return { x: Math.round(x), y: Math.round(y), width: Math.round(width), height: Math.round(height) }; +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { computeBounds, MIN_SIZE }; +} diff --git a/src/clock-model.js b/src/clock-model.js new file mode 100644 index 0000000..65ea769 --- /dev/null +++ b/src/clock-model.js @@ -0,0 +1,36 @@ +'use strict'; + +// 走时模型:显示时间 = 基准时间 + (真实时间 - 基准时刻) × 倍率。 +// 之所以不用「setInterval 每次 +1 秒」,是因为定时器实际间隔会被系统调度拖后,误差会一直累积; +// 这里把「走多快」和「从哪一刻开始走」彻底分开,任何倍率变化/快进/暂停都只是重新锚定一次基准, +// 因此倍率、暂停(0)、倒转(负)、小数、快进这几种操作可以共用同一套代码,且不会漂移。 + +function create(now) { + return { baseMs: now, baseRealMs: now, rate: 1, lastRate: 1 }; +} + +function displayMs(clock, now) { + return clock.baseMs + (now - clock.baseRealMs) * clock.rate; +} + +// 改倍率前先把当前显示时间固化进基准,否则改倍率的瞬间时间会跳一下 +function setRate(clock, now, rate) { + clock.baseMs = displayMs(clock, now); + clock.baseRealMs = now; + clock.rate = rate; + if (rate !== 0) clock.lastRate = rate; +} + +function shift(clock, now, deltaMs) { + clock.baseMs = displayMs(clock, now) + deltaMs; + clock.baseRealMs = now; +} + +function setTimeOfDay(clock, now, h, m, s) { + const d = new Date(now); + d.setHours(h, m, s, 0); + clock.baseMs = d.getTime(); + clock.baseRealMs = now; +} + +module.exports = { create, displayMs, setRate, shift, setTimeOfDay }; diff --git a/src/selftest.js b/src/selftest.js new file mode 100644 index 0000000..05388f7 --- /dev/null +++ b/src/selftest.js @@ -0,0 +1,257 @@ +'use strict'; + +// 自检:electron . --selftest +// 只在开发机上跑:证明「走时模型 → IPC → 画面 → 窗口」整条链路真的通,并留下截图。 + +const fs = require('node:fs'); +const path = require('node:path'); + +const OUT_DIR = path.join(__dirname, '..', 'dist', 'selftest'); +const results = []; + +function check(name, ok, detail) { + results.push({ name, ok: Boolean(ok), detail }); + console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` [${detail}]` : ''}`); +} + +function skip(name, detail) { + console.log(`SKIP ${name}${detail ? ` [${detail}]` : ''}`); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForLoad(win) { + for (let i = 0; i < 120; i += 1) { + if (!win || win.isDestroyed()) return false; + if (!win.webContents.isLoading()) return true; + await sleep(50); + } + return false; +} + +module.exports = async function runSelftest(ctx) { + const { app, clockModel, actions, applyBounds, createClockWindow, getClockWindow, getControlWindow } = ctx; + let exitCode = 1; + + const readShown = () => getClockWindow().webContents.executeJavaScript('Number(document.body.dataset.displayMs)'); + const readStyle = () => getClockWindow().webContents.executeJavaScript('document.body.dataset.style'); + const expectedNow = (now) => { + const c = ctx.state.clock; + return c.baseMs + (now - c.baseRealMs) * c.rate; + }; + + try { + fs.mkdirSync(OUT_DIR, { recursive: true }); + + // —— 1. 纯模型:倍率 / 倒转 / 暂停 / 小数 / 快进 / 设定时间 —— + const t0 = 1_700_000_000_000; + const model = clockModel.create(t0); + check('rate=1:1 秒真实时间 = 1 秒显示时间', clockModel.displayMs(model, t0 + 1000) === t0 + 1000); + + clockModel.setRate(model, t0, 2); + check('倍率 2:1 秒真实时间走 2 秒', clockModel.displayMs(model, t0 + 1000) === t0 + 2000); + + clockModel.setRate(model, t0, -1); + check('倍率 -1:时间往回走', clockModel.displayMs(model, t0 + 1000) === t0 - 1000); + + clockModel.setRate(model, t0, 0); + check('倍率 0:时间冻结', clockModel.displayMs(model, t0 + 5000) === t0); + + clockModel.setRate(model, t0, 0.5); + check('倍率 0.5:半速', clockModel.displayMs(model, t0 + 1000) === t0 + 500); + + clockModel.setRate(model, t0, -1); + clockModel.shift(model, t0, 300_000); + check('负倍率下快进 +5 分钟:先固化显示时间再平移', clockModel.displayMs(model, t0) === t0 + 300_000); + + clockModel.setTimeOfDay(model, t0, 8, 30, 15); + const set = new Date(clockModel.displayMs(model, t0)); + check('设定时间 08:30:15 生效', set.getHours() === 8 && set.getMinutes() === 30 && set.getSeconds() === 15, + `${set.getHours()}:${set.getMinutes()}:${set.getSeconds()}`); + + const pauseModel = clockModel.create(t0); + clockModel.setRate(pauseModel, t0, 4); + clockModel.setRate(pauseModel, t0, 0); + check('暂停后仍记得上次倍率 4(供恢复)', pauseModel.rate === 0 && pauseModel.lastRate === 4); + + // —— 2. 端到端:主进程模型 → IPC → 时钟窗口画面 —— + actions.setStyle('digital'); + actions.setRate(1); + const clockWin = createClockWindow(); + await waitForLoad(clockWin); + clockWin.focus(); + await sleep(400); + + const first = await readShown(); + check('时钟窗口渲染进程已接上走时参数', Number.isFinite(first), `displayMs=${first}`); + + actions.setRate(60); + const wall0 = Date.now(); + const expect0 = expectedNow(wall0); + await sleep(600); + const shown = await readShown(); + const expect1 = expect0 + (Date.now() - wall0) * 60; + const drift = Math.abs(shown - expect1); + check('倍率 60:画面与模型一致(容差 3 秒显示时间)', drift < 3000, + `画面=${shown} 期望=${Math.round(expect1)} 偏差=${Math.round(drift)}ms`); + + actions.setRate(-1); + await sleep(150); + const back1 = await readShown(); + await sleep(500); + const back2 = await readShown(); + check('倒转时画面数字持续变小(不是卡住)', back2 < back1 - 300, `${back1} → ${back2}`); + + actions.setRate(0); + await sleep(150); + const frozen1 = await readShown(); + await sleep(500); + const frozen2 = await readShown(); + check('暂停时画面完全静止', frozen1 === frozen2, `${frozen1} → ${frozen2}`); + + actions.togglePause(); + await sleep(150); + const resumed1 = await readShown(); + await sleep(400); + const resumed2 = await readShown(); + check('恢复按钮找回上次倍率 -1(继续倒转)', resumed2 < resumed1, `${resumed1} → ${resumed2}`); + + actions.setRate(1); + await sleep(150); + const beforeFast = await readShown(); + actions.fastForward(300_000); + await sleep(120); // 缓动全长 350ms,此刻本该在半路上 + const duringFast = await readShown(); + const rolled = duringFast - beforeFast; + if (rolled >= 300_000 - 20_000) { + skip('快进 +5 分钟:中途读数(本轮读得太慢,动画已落定,判不出是滚过去还是瞬间跳)', + `已走 ${Math.round(rolled)}ms`); + } else { + check('快进 +5 分钟:数字滚过去而不是瞬间跳(120ms 时还在半路)', + rolled > 0 && rolled < 300_000, `已走 ${Math.round(rolled)}ms / 300000ms`); + } + await sleep(500); // 等缓动落定 + const afterFast = await readShown(); + check('快进 +5 分钟:缓动结束后精确落在 +300 秒', + Math.abs(afterFast - beforeFast - 300_000) < 3000, `跳变=${afterFast - beforeFast}ms`); + + actions.setTime(6, 0, 0); + await sleep(500); // 同样等缓动落定后再读 + const setShown = new Date(await readShown()); + check('设定时间后时钟滚到 06:00:00(缓动落定后的值)', + setShown.getHours() === 6 && setShown.getMinutes() === 0 && setShown.getSeconds() === 0, + `${setShown.getHours()}:${setShown.getMinutes()}:${setShown.getSeconds()}`); + + // —— 3. 自定义拖边缘改大小:合成鼠标事件真的拖热区 —— + actions.setRate(1); + const beforeBounds = clockWin.getBounds(); + const grabX = beforeBounds.width - 4; + const grabY = Math.floor(beforeBounds.height / 2); + clockWin.webContents.sendInputEvent({ type: 'mouseDown', x: grabX, y: grabY, button: 'left', clickCount: 1 }); + for (let step = 1; step <= 6; step += 1) { + clockWin.webContents.sendInputEvent({ type: 'mouseMove', x: grabX + step * 20, y: grabY, button: 'left', buttons: 1 }); + await sleep(40); + } + clockWin.webContents.sendInputEvent({ type: 'mouseUp', x: grabX + 120, y: grabY, button: 'left', clickCount: 1 }); + await sleep(250); + const afterEast = clockWin.getBounds(); + check('拖东边热区把窗口改宽(热区 → IPC → setBounds 全链路)', + afterEast.width > beforeBounds.width + 40, `${beforeBounds.width} → ${afterEast.width}`); + + const southY = afterEast.height - 4; + const midX = Math.floor(afterEast.width / 2); + clockWin.webContents.sendInputEvent({ type: 'mouseDown', x: midX, y: southY, button: 'left', clickCount: 1 }); + for (let step = 1; step <= 4; step += 1) { + clockWin.webContents.sendInputEvent({ type: 'mouseMove', x: midX, y: southY + step * 15, button: 'left', buttons: 1 }); + await sleep(40); + } + clockWin.webContents.sendInputEvent({ type: 'mouseUp', x: midX, y: southY + 60, button: 'left', clickCount: 1 }); + await sleep(250); + const afterSouth = clockWin.getBounds(); + check('拖南边热区把窗口改高', afterSouth.height > afterEast.height + 20, `${afterEast.height} → ${afterSouth.height}`); + + applyBounds({ x: afterSouth.x, y: afterSouth.y, width: 50, height: 40 }); + await sleep(100); + const clamped = clockWin.getBounds(); + check('尺寸被夹在最小值 260x150', clamped.width === 260 && clamped.height === 150, `${clamped.width}x${clamped.height}`); + applyBounds({ x: afterSouth.x, y: afterSouth.y, width: 640, height: 360 }); + + // —— 4. 拖动区 / no-drag 的布局前提,以及按钮没有被拖动区吃掉 —— + const regions = await clockWin.webContents.executeJavaScript(`(() => { + const read = (el) => { + const s = getComputedStyle(el); + return s.webkitAppRegion || s.appRegion || ''; + }; + return { + stage: read(document.getElementById('stage')), + close: read(document.getElementById('closeBtn')), + handle: read(document.querySelector('.rz-e')) + }; + })()`); + if (regions.stage && regions.close) { + check('拖动区与按钮的 app-region 布局正确(stage=drag, 按钮/热区=no-drag)', + regions.stage === 'drag' && regions.close === 'no-drag' && regions.handle === 'no-drag', + JSON.stringify(regions)); + } else { + skip('app-region 计算样式读取(当前 Chromium 不返回该属性)', JSON.stringify(regions)); + } + + const beforeClose = clockWin.getBounds(); + const closeX = beforeClose.width - 21; + clockWin.webContents.sendInputEvent({ type: 'mouseDown', x: closeX, y: 20, button: 'left', clickCount: 1 }); + clockWin.webContents.sendInputEvent({ type: 'mouseUp', x: closeX, y: 20, button: 'left', clickCount: 1 }); + await sleep(500); + check('点击时钟窗口右上角 × 能关掉窗口(按钮没被拖动区覆盖)', getClockWindow() === null); + + // —— 5. 置顶开关真的落到了 BrowserWindow 上 —— + const win = createClockWindow(); + await waitForLoad(win); + await sleep(300); + actions.setAlwaysOnTop('clock', false); + check('取消时钟窗口置顶后 isAlwaysOnTop()=false', win.isAlwaysOnTop() === false); + actions.setAlwaysOnTop('clock', true); + check('重新置顶后 isAlwaysOnTop()=true', win.isAlwaysOnTop() === true); + actions.setAlwaysOnTop('control', true); + check('控制窗口置顶开关生效', getControlWindow().isAlwaysOnTop() === true); + actions.setAlwaysOnTop('control', false); + check('控制窗口取消置顶生效', getControlWindow().isAlwaysOnTop() === false); + + // —— 6. 时钟窗口的开关按钮 —— + actions.toggleClockWindow(); + await sleep(500); + check('toggle 关闭时钟窗口后窗口对象被回收', getClockWindow() === null); + actions.toggleClockWindow(); + const reopened = createClockWindow(); + await waitForLoad(reopened); + await sleep(500); + check('toggle 重新打开时钟窗口,且沿用当前样式', + getClockWindow() !== null && (await readStyle()) === 'digital', `style=${await readStyle()}`); + + // —— 7. 截图留证 —— + actions.setRate(30); + await sleep(400); + fs.writeFileSync(path.join(OUT_DIR, 'clock-digital.png'), (await getClockWindow().webContents.capturePage()).toPNG()); + + actions.setStyle('analog'); + await sleep(500); + check('样式切换后时钟窗口进入表盘模式', (await readStyle()) === 'analog'); + fs.writeFileSync(path.join(OUT_DIR, 'clock-analog.png'), (await getClockWindow().webContents.capturePage()).toPNG()); + + actions.setRate(1); + await sleep(250); + fs.writeFileSync(path.join(OUT_DIR, 'control.png'), (await getControlWindow().webContents.capturePage()).toPNG()); + + const failed = results.filter((r) => !r.ok); + console.log(`\n自检结果:${results.length - failed.length}/${results.length} 通过`); + if (failed.length) console.log(`未通过:${failed.map((f) => f.name).join(';')}`); + console.log(`截图目录:${OUT_DIR}`); + exitCode = failed.length === 0 ? 0 : 1; + } catch (error) { + console.error('自检异常:', error); + exitCode = 2; + } finally { + app.exit(exitCode); + } +}; diff --git a/test/clock-model.test.js b/test/clock-model.test.js new file mode 100644 index 0000000..788d7e6 --- /dev/null +++ b/test/clock-model.test.js @@ -0,0 +1,167 @@ +'use strict'; + +// 纯逻辑自测(不需要 Electron、不需要显示器): +// node test/clock-model.test.js +// 走时模型是所有功能的地基,这里把它单独钉死,GUI 的问题就不会和算法问题混在一起。 + +const clockModel = require('../src/clock-model'); + +let passed = 0; +const failures = []; + +function check(name, ok, detail) { + if (ok) { + passed += 1; + console.log(`PASS ${name}${detail ? ` [${detail}]` : ''}`); + } else { + failures.push(name); + console.log(`FAIL ${name}${detail ? ` [${detail}]` : ''}`); + } +} + +const T0 = 1_700_000_000_000; // 固定基准时刻,测试不依赖真实时钟 +const SECOND = 1000; + +console.log('--- 正常走时 ---'); +{ + const c = clockModel.create(T0); + check('初始倍率为 1', c.rate === 1); + check('走 1 秒真实时间 = 1 秒显示时间', clockModel.displayMs(c, T0 + SECOND) === T0 + SECOND); + check('走 1 小时真实时间 = 1 小时显示时间', clockModel.displayMs(c, T0 + 3600 * SECOND) === T0 + 3600 * SECOND); +} + +{ + // 分 3600 次推进,验证「基准 + 经过时间 × 倍率」不会像逐帧累加那样漂移 + const c = clockModel.create(T0); + let now = T0; + for (let i = 0; i < 3600; i += 1) { + now += SECOND; + clockModel.displayMs(c, now); + } + check('一小时逐秒推进后零漂移', clockModel.displayMs(c, now) === T0 + 3600 * SECOND); +} + +console.log('--- 倍率 ---'); +{ + const c = clockModel.create(T0); + clockModel.setRate(c, T0, 2); + check('倍率 2:1 秒真实时间走 2 秒', clockModel.displayMs(c, T0 + SECOND) === T0 + 2 * SECOND); +} +{ + const c = clockModel.create(T0); + clockModel.setRate(c, T0, 0.5); + check('倍率 0.5:半速', clockModel.displayMs(c, T0 + SECOND) === T0 + 500); +} +{ + const c = clockModel.create(T0); + clockModel.setRate(c, T0, -1); + check('倍率 -1:走 1 秒真实时间倒退 1 秒', clockModel.displayMs(c, T0 + SECOND) === T0 - SECOND); + check('倍率 -1:再走 5 秒仍在继续倒退(不是卡住)', + clockModel.displayMs(c, T0 + 6 * SECOND) === T0 - 6 * SECOND); +} +{ + const c = clockModel.create(T0); + clockModel.setRate(c, T0, -2.5); + check('倍率 -2.5:小数 + 负数同时成立', clockModel.displayMs(c, T0 + 4 * SECOND) === T0 - 10 * SECOND); +} + +console.log('--- 切换倍率不跳变 ---'); +{ + const c = clockModel.create(T0); + const before = clockModel.displayMs(c, T0 + 3000); + clockModel.setRate(c, T0 + 3000, 7); + const after = clockModel.displayMs(c, T0 + 3000); + check('切倍率的瞬间显示时间连续(同一时刻前后相等)', before === after, `${before} → ${after}`); + check('切换后按新倍率走', clockModel.displayMs(c, T0 + 4000) === before + 7 * SECOND); +} + +console.log('--- 暂停 / 继续 ---'); +{ + const c = clockModel.create(T0); + clockModel.setRate(c, T0, 4); + clockModel.setRate(c, T0 + SECOND, 0); + const frozenAt = clockModel.displayMs(c, T0 + SECOND); + check('倍率 0:显示时间冻结', clockModel.displayMs(c, T0 + 60 * SECOND) === frozenAt); + check('暂停时记住了暂停前的倍率 4', c.lastRate === 4); + + clockModel.setRate(c, T0 + 60 * SECOND, c.lastRate); + check('继续:从暂停处按原倍率接着走', + clockModel.displayMs(c, T0 + 61 * SECOND) === frozenAt + 4 * SECOND); +} +{ + const c = clockModel.create(T0); + clockModel.setRate(c, T0, 0); + check('倍率一直是 0 时 lastRate 保持默认 1', c.lastRate === 1); +} + +console.log('--- 快进 / 快退 ---'); +{ + const c = clockModel.create(T0); + const before = clockModel.displayMs(c, T0 + 2000); + clockModel.shift(c, T0 + 2000, 5 * SECOND); + check('+5 秒:显示时间立刻加 5 秒', clockModel.displayMs(c, T0 + 2000) === before + 5 * SECOND); + check('快进后基准确实变了', c.baseMs === before + 5 * SECOND && c.baseRealMs === T0 + 2000); + check('快进后继续按倍率走', clockModel.displayMs(c, T0 + 3000) === before + 6 * SECOND); +} +{ + const c = clockModel.create(T0); + clockModel.setRate(c, T0, -1); + const before = clockModel.displayMs(c, T0 + 10 * SECOND); + clockModel.shift(c, T0 + 10 * SECOND, 30 * 60 * SECOND); + check('负倍率下 +30 分钟:先固化显示时间再平移(不与倒转打架)', + clockModel.displayMs(c, T0 + 10 * SECOND) === before + 30 * 60 * SECOND); + check('平移后负倍率继续生效', + clockModel.displayMs(c, T0 + 11 * SECOND) === before + 30 * 60 * SECOND - SECOND); +} +{ + const c = clockModel.create(T0); + clockModel.setRate(c, T0, 0); + const before = clockModel.displayMs(c, T0 + 1000); + clockModel.shift(c, T0 + 1000, 10 * 60 * SECOND); + check('暂停状态下快进仍然生效,且之后继续冻结', + clockModel.displayMs(c, T0 + 60 * SECOND) === before + 10 * 60 * SECOND); +} +{ + // 五个快进按钮的档位 + const deltas = [5 * SECOND, 60 * SECOND, 5 * 60 * SECOND, 10 * 60 * SECOND, 30 * 60 * SECOND]; + const c = clockModel.create(T0); + const before = clockModel.displayMs(c, T0); + for (const d of deltas) clockModel.shift(c, T0, d); + const total = deltas.reduce((a, b) => a + b, 0); + check('五档快进累加正确(+5s/+1m/+5m/+10m/+30m)', + clockModel.displayMs(c, T0) === before + total, `总计 ${total / 1000} 秒`); +} + +console.log('--- 设定时间 ---'); +{ + const c = clockModel.create(T0); + clockModel.setRate(c, T0, 60); + clockModel.setTimeOfDay(c, T0 + 5000, 8, 30, 15); + const d = new Date(clockModel.displayMs(c, T0 + 5000)); + check('设定为 08:30:15 且毫秒归零', + d.getHours() === 8 && d.getMinutes() === 30 && d.getSeconds() === 15 && d.getMilliseconds() === 0, + `${d.getHours()}:${d.getMinutes()}:${d.getSeconds()}.${d.getMilliseconds()}`); + check('设定后高倍率继续生效', clockModel.displayMs(c, T0 + 6000) === d.getTime() + 60 * SECOND); +} +{ + const c = clockModel.create(T0); + clockModel.setTimeOfDay(c, T0, 0, 0, 0); + const d = new Date(clockModel.displayMs(c, T0)); + check('设定为 00:00:00(边界)', d.getHours() === 0 && d.getMinutes() === 0 && d.getSeconds() === 0); +} +{ + const c = clockModel.create(T0); + clockModel.setTimeOfDay(c, T0, 23, 59, 59); + const d = new Date(clockModel.displayMs(c, T0 + 2000)); + check('23:59:59 后跨零点进位', d.getHours() === 0 && d.getMinutes() === 0 && d.getSeconds() === 1, + `${d.getHours()}:${d.getMinutes()}:${d.getSeconds()}`); +} + +console.log(''); +if (failures.length === 0) { + console.log(`全部通过:${passed}/${passed}`); + process.exit(0); +} else { + console.log(`通过 ${passed},失败 ${failures.length}:${failures.join(';')}`); + process.exit(1); +} diff --git a/test/presented-time.test.js b/test/presented-time.test.js new file mode 100644 index 0000000..259c8ac --- /dev/null +++ b/test/presented-time.test.js @@ -0,0 +1,188 @@ +'use strict'; + +// 呈现时间层的纯用例(不需要 Electron、不需要显示器): +// node test/presented-time.test.js +// 「什么时候该动画、动画到哪、被打断怎么办」是这次跳转动画最容易写错的地方, +// 所以把它从渲染代码里拆出来,先在 node 里钉死,Windows 上就只剩「观感」要试。 + +const presented = require('../renderer/presented-time'); + +const { JUMP_THRESHOLD_MS, EASE_MS } = presented; + +let passed = 0; +const failures = []; + +function check(name, ok, detail) { + if (ok) { + passed += 1; + console.log(`PASS ${name}${detail ? ` [${detail}]` : ''}`); + } else { + failures.push(name); + console.log(`FAIL ${name}${detail ? ` [${detail}]` : ''}`); + } +} + +const T0 = 1_700_000_000_000; // 固定基准时刻,测试不依赖真实时钟 +const SECOND = 1000; +const MINUTE = 60 * SECOND; + +// 走时参数就是主进程下发的那个快照,这里照原样造 +const snap = (baseMs, baseRealMs, rate) => ({ baseMs, baseRealMs, rate }); + +console.log('--- 平时不动画 ---'); +{ + const layer = presented.create(); + presented.accept(layer, T0, snap(T0, T0, 1)); + check('首个参数直接对齐(没有"上一个值"可以缓动)', presented.valueAt(layer, T0) === T0); + check('走 1 秒后呈现时间 = 目标时间,没有动画', presented.valueAt(layer, T0 + SECOND) === T0 + SECOND); + check('走 1 小时后依然相等(不会被动画层拖后腿)', + presented.valueAt(layer, T0 + 3600 * SECOND) === T0 + 3600 * SECOND); +} +{ + // 首个参数哪怕离当前显示时间很远(例如时钟窗口后打开),也不该滚一次 + const layer = presented.create(); + presented.accept(layer, T0, snap(T0 + 10 * MINUTE, T0, 1)); + check('首个参数:直接对齐,不做动画', presented.valueAt(layer, T0) === T0 + 10 * MINUTE); +} + +console.log(`--- 大跳变(> ${JUMP_THRESHOLD_MS}ms)缓动 ${EASE_MS}ms ---`); +{ + const layer = presented.create(); + presented.accept(layer, T0, snap(T0, T0, 1)); + const target = T0 + 30 * MINUTE; + presented.accept(layer, T0, snap(target, T0, 1)); // 快进 +30 分钟 + + check('跳变当帧显示的还是跳变前的时间(数字从这里开始滚)', + presented.valueAt(layer, T0) === T0, String(presented.valueAt(layer, T0))); + + const half = presented.valueAt(layer, T0 + EASE_MS / 2); + check('半程时停在两者之间', half > T0 && half < target, `${Math.round(half - T0)}ms`); + check('缓出:半程已经走完一大半路程(不是匀速)', half - T0 > (target - T0) / 2, + `${Math.round(((half - T0) / (target - T0)) * 100)}%`); + + let previous = -Infinity; + let monotonic = true; + for (let t = 0; t <= EASE_MS; t += 25) { + const value = presented.valueAt(layer, T0 + t); + if (value < previous) monotonic = false; + previous = value; + } + check('整段缓动单调向前(不会中途倒退)', monotonic); + + // 结束时要比的是「那一刻的目标时间」—— 目标本身还在按 ×1 继续走 + const settled = target + EASE_MS; + check(`满 ${EASE_MS}ms 精确落在目标上(不留残余,否则数字永远差几百毫秒)`, + presented.valueAt(layer, T0 + EASE_MS) === settled, String(presented.valueAt(layer, T0 + EASE_MS) - settled)); + check('之后一直等于目标时间', presented.valueAt(layer, T0 + 5000) === target + 5000); +} +{ + // 往回跳(设定到更早的时间)同样缓动,只是方向相反 + const layer = presented.create(); + presented.accept(layer, T0, snap(T0, T0, 1)); + const target = T0 - 10 * MINUTE; + presented.accept(layer, T0, snap(target, T0, 1)); + check('负向跳变当帧仍在原处', presented.valueAt(layer, T0) === T0); + + let previous = Infinity; + let monotonic = true; + for (let t = 0; t <= EASE_MS; t += 25) { + const value = presented.valueAt(layer, T0 + t); + if (value > previous) monotonic = false; + previous = value; + } + check('负向跳变整段单调往回', monotonic); + check('负向跳变落在目标上', presented.valueAt(layer, T0 + EASE_MS) === target + EASE_MS); +} + +console.log('--- 小跳变直接跳 ---'); +{ + const layer = presented.create(); + presented.accept(layer, T0, snap(T0, T0, 1)); + presented.accept(layer, T0, snap(T0 + JUMP_THRESHOLD_MS, T0, 1)); + check(`正好 ${JUMP_THRESHOLD_MS}ms:直接跳,不缓动`, + presented.valueAt(layer, T0) === T0 + JUMP_THRESHOLD_MS); + + const other = presented.create(); + presented.accept(other, T0, snap(T0, T0, 1)); + presented.accept(other, T0, snap(T0 + JUMP_THRESHOLD_MS + 1, T0, 1)); + check(`超过 ${JUMP_THRESHOLD_MS}ms:开始缓动(当帧还在原处)`, presented.valueAt(other, T0) === T0); +} + +console.log('--- 改倍率不是跳变 ---'); +{ + const before = snap(T0, T0, 1); + const visible = presented.targetAt(before, T0 + 5000); // 改倍率那一刻的显示时间 + const after = snap(visible, T0 + 5000, 60); // 模型先固化显示时间再换倍率 + + check('模型锚点式换倍率:同一时刻新旧参数算出的时间相等', + presented.jumpBetween(before, after) === 0, String(presented.jumpBetween(before, after))); + + const layer = presented.create(); + presented.accept(layer, T0, before); + presented.accept(layer, T0 + 5000, after); + check('换倍率后画面不缓动,当帧就是固化下来的那个值', + presented.valueAt(layer, T0 + 5000) === visible); + check('换倍率后立刻按新倍率走(不会被动画拦住)', + presented.valueAt(layer, T0 + 5016) === visible + 16 * 60, String(presented.valueAt(layer, T0 + 5016) - visible)); +} +{ + const before = snap(T0, T0, 1); + const after = snap(T0 + 5 * SECOND, T0, 1); // 一次真实的 +5 秒快进 + check('快进 +5 秒:参数比较出来的跳变幅度就是 5 秒', + presented.jumpBetween(before, after) === 5 * SECOND, String(presented.jumpBetween(before, after))); + check('没有上一个参数时不判定跳变', presented.jumpBetween(null, after) === 0); +} + +console.log('--- 缓动中又跳一次:从当前呈现值重新出发 ---'); +{ + const layer = presented.create(); + presented.accept(layer, T0, snap(T0, T0, 1)); + presented.accept(layer, T0, snap(T0 + 30 * MINUTE, T0, 1)); + const mid = presented.valueAt(layer, T0 + 200); + check('中途确实停在两者之间', mid > T0 && mid < T0 + 30 * MINUTE, `${Math.round(mid - T0)}ms`); + + // 还没滚完又点了一次 +1 分钟:模型的目标变成 31 分钟 + presented.accept(layer, T0 + 200, snap(T0 + 31 * MINUTE, T0 + 200, 1)); + check('重定向当帧接着当前呈现值,不跳回起点', + presented.valueAt(layer, T0 + 200) === mid, `${Math.round(presented.valueAt(layer, T0 + 200) - mid)}ms`); + check('重定向后不会倒退', presented.valueAt(layer, T0 + 201) >= mid); + check('重定向后仍然精确落在新目标上', + presented.valueAt(layer, T0 + 200 + EASE_MS) === T0 + 31 * MINUTE + EASE_MS); +} + +console.log('--- 暂停不缓动 ---'); +{ + const layer = presented.create(); + presented.accept(layer, T0, snap(T0, T0, 1)); + presented.accept(layer, T0, snap(T0 + 30 * MINUTE, T0, 1)); + const mid = presented.valueAt(layer, T0 + 100); + check('跳变后 100ms 正在缓动', mid > T0 && mid < T0 + 30 * MINUTE, `${Math.round(mid - T0)}ms`); + + // 暂停:模型把显示时间固化在暂停那一刻(也就是刚跳到的目标) + presented.accept(layer, T0 + 100, snap(T0 + 30 * MINUTE, T0 + 100, 0)); + check('暂停当帧就落到暂停时刻的准确时间(不会挂着走不完的中间值)', + presented.valueAt(layer, T0 + 100) === T0 + 30 * MINUTE); + check('暂停后画面纹丝不动', + presented.valueAt(layer, T0 + 9000) === presented.valueAt(layer, T0 + 100)); +} +{ + const layer = presented.create(); + presented.accept(layer, T0, snap(T0, T0, 1)); + presented.accept(layer, T0, snap(T0, T0, 0)); + check('暂停状态下快进也是直接落到新值(暂停画面必须是精确的暂停时间)', + presented.valueAt(layer, T0) === T0); + presented.accept(layer, T0 + 5000, snap(T0 + 5 * MINUTE, T0 + 5000, 0)); + check('暂停后快进:当帧就是新值,不滚', + presented.valueAt(layer, T0 + 5000) === T0 + 5 * MINUTE); + check('暂停后快进:之后继续定格在该值', + presented.valueAt(layer, T0 + 60 * SECOND) === T0 + 5 * MINUTE); +} + +console.log(''); +if (failures.length === 0) { + console.log(`全部通过:${passed}/${passed}`); + process.exit(0); +} else { + console.log(`通过 ${passed},失败 ${failures.length}:${failures.join(';')}`); + process.exit(1); +} diff --git a/test/renderer-clock-anim.dom-test.js b/test/renderer-clock-anim.dom-test.js new file mode 100644 index 0000000..af41281 --- /dev/null +++ b/test/renderer-clock-anim.dom-test.js @@ -0,0 +1,218 @@ +// 用最小 DOM 桩把「跳转动画」这条链路真的跑一遍:主进程下发参数 → 呈现时间层 → 数字 / 表盘 +// node test/renderer-clock-anim.dom-test.js +// 桩终究不是真浏览器:它能证明「缓动从哪里出发、落在哪里、被打断时连不连续」, +// 证明不了「滚起来顺不顺眼」——那部分只能在 Windows 上肉眼确认。 + +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); +const root = path.join(__dirname, '..'); +const read = (p) => fs.readFileSync(path.join(root, p), 'utf8'); + +let passed = 0; const failures = []; +const check = (n, ok, d) => { if (ok) { passed++; console.log(`PASS ${n}${d ? ` [${d}]` : ''}`); } else { failures.push(n); console.log(`FAIL ${n}${d ? ` [${d}]` : ''}`); } }; + +function makeEl(id) { + return { + id, textContent: '', hidden: false, style: {}, dataset: {}, children: [], attrs: {}, handlers: {}, + classList: { add() {}, remove() {}, toggle() {}, contains: () => false }, + appendChild(c) { this.children.push(c); }, + setAttribute(k, v) { this.attrs[k] = v; }, + getAttribute(k) { return this.attrs[k]; }, + getBoundingClientRect: () => ({ width: 520, height: 280, top: 0, left: 0 }), + addEventListener(type, fn) { (this.handlers[type] ||= []).push(fn); }, + setPointerCapture() {}, releasePointerCapture() {} + }; +} + +const els = new Map(); +const byId = (id) => { if (!els.has(id)) els.set(id, makeEl(id)); return els.get(id); }; +const body = makeEl('body'); + +const RealDate = Date; +const T0 = new RealDate(2026, 8, 14, 3, 0, 0, 0).getTime(); // 03:00:00.000 +const MIN = 60 * 1000; +let fakeNow = T0; + +class FakeDate extends RealDate { + constructor(...a) { if (a.length === 0) super(fakeNow); else super(...a); } + static now() { return fakeNow; } +} + +// 主进程下发的参数就是这个形状;这里照原样造 +const snap = (baseMs, baseRealMs, rate, style = 'digital') => ({ + baseMs, baseRealMs, rate, lastRate: rate, style, paused: rate === 0, + clockWindowOpen: true, alwaysOnTop: { clock: true, control: false } +}); + +let rafCb = null; +let stateCb = null; + +const sandbox = { + console, + Date: FakeDate, + requestAnimationFrame: (cb) => { rafCb = cb; return 1; }, + ResizeObserver: class { observe() {} }, + getComputedStyle: () => ({}), + addEventListener: () => {}, + document: { + body, + getElementById: byId, + querySelectorAll: () => [], + createElementNS: () => makeEl('svg-node') + }, + window: {} +}; +sandbox.window = sandbox; +sandbox.window.clockApi = { + getState: () => Promise.resolve(snap(T0, T0, 1)), + onState: (cb) => { stateCb = cb; }, + setRate: () => Promise.resolve({}), fastForward: () => Promise.resolve({}), + setTime: () => Promise.resolve({}), setStyle: () => Promise.resolve({}), + togglePause: () => Promise.resolve({}), setAlwaysOnTop: () => Promise.resolve({}), + toggleClockWindow: () => Promise.resolve({}), closeClockWindow: () => Promise.resolve({}), + closeControlWindow: () => Promise.resolve({}), getBoundsSync: () => null, setBounds: () => {} +}; + +const ctx = vm.createContext(sandbox); +try { + for (const file of ['renderer/resize.js', 'renderer/presented-time.js', 'renderer/clock.js']) { + vm.runInContext(read(file), ctx, { filename: file }); + } + check('渲染进程脚本加载无异常', true); +} catch (e) { + check('渲染进程脚本加载无异常', false, e.message); + console.log('\n致命:渲染进程脚本无法加载'); process.exit(1); +} + +// —— 读数小工具 —— +const frame = () => rafCb(); // 渲染一帧(真实时间就是 fakeNow) +const advance = (ms) => { fakeNow += ms; rafCb(); }; +const shown = () => Number(body.dataset.displayMs); +const text = () => byId('digitalTime').textContent; +const rot = (el) => Number(/rotate\(([-\d.eE]+)/.exec(el.attrs.transform)[1]); +const targetOf = (s, now) => s.baseMs + (now - s.baseRealMs) * s.rate; + +// 与 clock.js 同一套角度公式:秒针按毫秒连续走 +function anglesFor(ms) { + const d = new RealDate(ms); + const seconds = d.getSeconds() + d.getMilliseconds() / 1000; + const minutes = d.getMinutes() + seconds / 60; + const hours = (d.getHours() % 12) + minutes / 60; + return { hour: hours * 30, minute: minutes * 6, second: seconds * 6 }; +} + +// 三根指针是否都严格由「当前呈现时间」推出来(差 0.05° 以内 ≈ 0.1ms 的误差) +function handsMatchPresented() { + const expect = anglesFor(shown()); + return Math.abs(rot(byId('hourHand')) - expect.hour) < 0.05 && + Math.abs(rot(byId('minuteHand')) - expect.minute) < 0.05 && + Math.abs(rot(byId('secondHand')) - expect.second) < 0.05; +} + +setTimeout(() => { + console.log('--- 平时不动画 ---'); + frame(); + check('初始显示 03:00:00', text() === '03:00:00', text()); + advance(1000); + check('走 1 秒就是 1 秒(动画层不拖后腿)', text() === '03:00:01', text()); + + console.log('--- 快进 +30 分钟:数字滚过去 ---'); + const before = shown(); + stateCb(snap(before + 30 * MIN, fakeNow, 1)); // 主进程 shift:以此刻的显示时间为准整体平移 + frame(); + check('跳变当帧数字还在旧值上(从这里开始滚)', + shown() === before && text() === '03:00:01', `${text()} / ${shown() - before}`); + advance(175); + const mid = shown(); + check('半程时数字落在两者之间', mid > before && mid < before + 30 * MIN, `${Math.round(mid - before)}ms`); + check('半程时数字确实换过(不是干等)', text() !== '03:00:01' && text() !== '03:30:01', text()); + advance(175); + check('350ms 后落在 +30 分钟那一刻', shown() === before + 30 * MIN + 350, + `${shown() - (before + 30 * MIN + 350)}ms`); + check('落地后显示 03:30:01', text() === '03:30:01', text()); + + console.log('--- 小跳变直接跳 ---'); + const smallBase = shown(); + stateCb(snap(smallBase + 1000, fakeNow, 1)); + frame(); + check('+1 秒:当帧就直接到新值,不缓动', shown() === smallBase + 1000, `${shown() - smallBase}ms`); + + console.log('--- 改倍率不是跳变 ---'); + const rateBase = shown(); + stateCb(snap(rateBase, fakeNow, 60)); + frame(); + check('切 ×60:当帧显示时间不变(没有动画)', shown() === rateBase, `${shown() - rateBase}ms`); + advance(100); + check('切 ×60:之后立刻按新倍率走', shown() === rateBase + 6000, `${shown() - rateBase}ms`); + + console.log('--- 表盘:指针从同一个呈现时间扫过去 ---'); + const T11 = new RealDate(2026, 8, 14, 11, 0, 0, 0).getTime(); + const digitalBefore = shown(); + stateCb(snap(T11, fakeNow, 1, 'analog')); + frame(); + check('切表盘 + 跳变当帧:指针仍指在旧时间上', Math.abs(rot(byId('hourHand')) - anglesFor(digitalBefore).hour) < 0.05, + `${rot(byId('hourHand'))}° vs 目标 ${anglesFor(T11).hour}°`); + check('当帧还没到 11 点', Math.abs(rot(byId('hourHand')) - 330) > 1, `${rot(byId('hourHand'))}°`); + advance(175); + check('半程时三根指针都由当前呈现时间推出', handsMatchPresented(), + `时针 ${rot(byId('hourHand')).toFixed(2)}°`); + advance(175); + check('落地后时针指向 11 点整(330°)', Math.abs(rot(byId('hourHand')) - 330) < 0.05, `${rot(byId('hourHand'))}°`); + check('落地后三根指针依然与呈现时间一致', handsMatchPresented()); + + console.log('--- 跨 12 点:往前扫,不倒着转一圈 ---'); + // 11:00 → 次日 01:00(跨过 12 点),缓动过程中时间必须单调向前 + const T01 = new RealDate(2026, 8, 15, 1, 0, 0, 0).getTime(); + stateCb(snap(T01, fakeNow, 1, 'analog')); + const samples = []; + let handsAligned = true; + for (let i = 0; i <= 7; i += 1) { + if (i > 0) fakeNow += 50; + rafCb(); + samples.push(shown()); + if (!handsMatchPresented()) handsAligned = false; + } + check('缓动全程三根指针都由当前呈现时间推出(含跨 12 点那几帧)', handsAligned); + let monotonic = true; + for (let i = 1; i < samples.length; i++) if (samples[i] <= samples[i - 1]) monotonic = false; + check('缓动全程呈现时间单调向前(角度按公式跟随,所以不会倒着转一圈)', monotonic, + `${samples[0]} → ${samples[samples.length - 1]}`); + advance(350); + check('跨 12 点后落在次日 01:00', Math.abs(rot(byId('hourHand')) % 360 - 30) < 1, `${rot(byId('hourHand'))}°`); + + console.log('--- 缓动中又跳一次:从当前呈现值重新出发 ---'); + stateCb(snap(shown() + 30 * MIN, fakeNow, 1, 'digital')); + advance(100); + const midFlight = shown(); + const retargetTo = shown() + MIN; // 主进程在模型目标上再 +1 分钟 + stateCb(snap(retargetTo, fakeNow, 1, 'digital')); + frame(); + check('重定向当帧不弹回跳变前的旧值', shown() === midFlight, `${shown() - midFlight}ms`); + advance(350); + check('重定向后落在新目标上', shown() === targetOf(snap(retargetTo, fakeNow - 350, 1), fakeNow), + `${shown()} vs ${targetOf(snap(retargetTo, fakeNow - 350, 1), fakeNow)}`); + + console.log('--- 暂停不缓动 ---'); + const pausedAt = shown(); + stateCb(snap(pausedAt, fakeNow, 0)); + frame(); + check('暂停当帧就是暂停时刻的准确时间', shown() === pausedAt, `${shown() - pausedAt}ms`); + advance(5000); + check('暂停后画面纹丝不动', shown() === pausedAt, `${shown() - pausedAt}ms`); + + // 缓动没结束时按暂停:不能挂着走不完的中间值 + stateCb(snap(pausedAt + 30 * MIN, fakeNow, 1)); + advance(100); + const inFlight = shown(); + check('恢复后先正常滚起来', inFlight > pausedAt && inFlight < pausedAt + 30 * MIN, `${Math.round(inFlight - pausedAt)}ms`); + stateCb(snap(pausedAt + 30 * MIN, fakeNow, 0)); + frame(); + check('缓动中按暂停:立刻落到暂停时刻的准确值', shown() === pausedAt + 30 * MIN, `${shown() - (pausedAt + 30 * MIN)}ms`); + advance(5000); + check('暂停后继续保持该值', shown() === pausedAt + 30 * MIN); + + console.log(''); + if (!failures.length) { console.log(`跳转动画链路:全部通过 ${passed}/${passed}`); process.exit(0); } + console.log(`通过 ${passed},失败 ${failures.length}:${failures.join(';')}`); process.exit(1); +}, 30); diff --git a/test/renderer-clock.dom-test.js b/test/renderer-clock.dom-test.js new file mode 100644 index 0000000..879c727 --- /dev/null +++ b/test/renderer-clock.dom-test.js @@ -0,0 +1,185 @@ +// 用最小 DOM 桩把 renderer/clock.js + resize.js 真的跑一遍(不需要 Electron、不需要显示器) +// 注意:桩终究不是真浏览器,它能证明「接线和算法对」,证明不了「鼠标手感」。 +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); +const root = path.join(__dirname, '..'); +const read = (p) => fs.readFileSync(path.join(root, p), 'utf8'); + +let passed = 0; const failures = []; +const check = (n, ok, d) => { if (ok) { passed++; console.log(`PASS ${n}${d ? ` [${d}]` : ''}`); } else { failures.push(n); console.log(`FAIL ${n}${d ? ` [${d}]` : ''}`); } }; + +function makeEl(id) { + const el = { + id, textContent: '', hidden: false, style: {}, dataset: {}, children: [], attrs: {}, handlers: {}, + classList: { add() {}, remove() {}, toggle() {}, contains: () => false }, + appendChild(c) { this.children.push(c); }, + setAttribute(k, v) { this.attrs[k] = v; }, + getAttribute(k) { return this.attrs[k]; }, + getBoundingClientRect: () => ({ width: 520, height: 280, top: 0, left: 0 }), + addEventListener(type, fn) { (this.handlers[type] ||= []).push(fn); }, + setPointerCapture() {}, releasePointerCapture() {}, + fire(type, ev) { (this.handlers[type] || []).forEach((fn) => fn(ev)); } + }; + return el; +} + +const els = new Map(); +const byId = (id) => { if (!els.has(id)) els.set(id, makeEl(id)); return els.get(id); }; +const handles = ['n','s','w','e','nw','ne','sw','se'].map((dir) => { const el = makeEl(`rz-${dir}`); el.dataset.dir = dir; return el; }); + +const body = makeEl('body'); +const calls = { setBounds: [], closed: 0, rates: [] }; +let rafCb = null; +let stateCb = null; + +const RealDate = Date; +let fakeNow = new RealDate(2026, 8, 14, 3, 0, 0, 0).getTime(); +class FakeDate extends RealDate { + constructor(...a) { if (a.length === 0) super(fakeNow); else super(...a); } + static now() { return fakeNow; } +} + +const sandbox = { + console, + Date: FakeDate, + requestAnimationFrame: (cb) => { rafCb = cb; return 1; }, + ResizeObserver: class { observe() {} }, + getComputedStyle: () => ({ webkitAppRegion: 'drag' }), + addEventListener: () => {}, + document: { + body, + getElementById: byId, + querySelectorAll: (sel) => (sel === '.rz' ? handles : []), + createElementNS: () => makeEl('svg-node') + }, + window: {} +}; +sandbox.window = sandbox; +sandbox.window.clockApi = { + getState: () => Promise.resolve({ baseMs: fakeNow, baseRealMs: fakeNow, rate: 1, lastRate: 1, style: 'digital' }), + onState: (cb) => { stateCb = cb; }, + setRate: (r) => { calls.rates.push(r); return Promise.resolve({}); }, + setBounds: (b) => calls.setBounds.push(b), + getBoundsSync: () => ({ x: 400, y: 300, width: 520, height: 280 }), + closeClockWindow: () => { calls.closed++; return Promise.resolve({}); }, + togglePause: () => Promise.resolve({}), fastForward: () => Promise.resolve({}), + setTime: () => Promise.resolve({}), setStyle: () => Promise.resolve({}), + setAlwaysOnTop: () => Promise.resolve({}), toggleClockWindow: () => Promise.resolve({}), + closeControlWindow: () => Promise.resolve({}) +}; + +console.log('--- 加载 ---'); +let ctx; +try { + ctx = vm.createContext(sandbox); + vm.runInContext(read('renderer/resize.js'), ctx, { filename: 'resize.js' }); + vm.runInContext(read('renderer/presented-time.js'), ctx, { filename: 'presented-time.js' }); + vm.runInContext(read('renderer/clock.js'), ctx, { filename: 'clock.js' }); + check('resize.js + clock.js 在同一全局作用域下加载无异常(无重复声明冲突)', true); +} catch (e) { + check('resize.js + clock.js 在同一全局作用域下加载无异常', false, e.message); + console.log('\n致命:渲染进程脚本无法加载'); process.exit(1); +} + +setTimeout(() => { + console.log('--- 走时显示 ---'); + check('数字视图渲染 HH:MM:SS', byId('digitalTime').textContent === '03:00:00', byId('digitalTime').textContent); + const expectDate = new RealDate(fakeNow); + const wk = ['周日','周一','周二','周三','周四','周五','周六'][expectDate.getDay()]; + check('日期行正确', byId('digitalDate').textContent === `2026-09-14 ${wk}`, byId('digitalDate').textContent); + + fakeNow += 3661 * 1000; // 前进 1 小时 1 分 1 秒 + rafCb(); + check('走 1 小时后数字变成 04:01:01', byId('digitalTime').textContent === '04:01:01', byId('digitalTime').textContent); + check('画面读数与模型公式一致', Number(body.dataset.displayMs) === fakeNow, body.dataset.displayMs); + + console.log('--- 倍率 / 倒转 / 暂停(通过 IPC 下发状态) ---'); + fakeNow = new RealDate(2026, 8, 14, 3, 0, 0, 0).getTime(); + stateCb({ baseMs: fakeNow, baseRealMs: fakeNow, rate: 60, lastRate: 60, style: 'digital' }); + fakeNow += 1000; + rafCb(); + check('倍率 60:1 秒真实时间后画面走了 60 秒', byId('digitalTime').textContent === '03:01:00', byId('digitalTime').textContent); + check('倍率角标显示 ×60', byId('badge').hidden === false && byId('badge').textContent === '×60', byId('badge').textContent); + + // 每次下发状态都会把「当前显示时间」重新锚成基准,所以期望值要用同一公式算 + const revBase = fakeNow; + stateCb({ baseMs: revBase, baseRealMs: revBase, rate: -1, lastRate: -1, style: 'digital' }); + const revText0 = byId('digitalTime').textContent; + fakeNow += 5000; + rafCb(); + const expectRev = new RealDate(revBase - 5000); + const expectRevText = [expectRev.getHours(), expectRev.getMinutes(), expectRev.getSeconds()] + .map((n) => String(n).padStart(2, '0')).join(':'); + check('倒转:画面时间往回走(5 秒真实时间 = 倒退 5 秒)', + byId('digitalTime').textContent === expectRevText && byId('digitalTime').textContent < revText0, + `${revText0} → ${byId('digitalTime').textContent}(期望 ${expectRevText})`); + check('倍率角标显示「倒转 ×-1」', byId('badge').textContent === '倒转 ×-1', byId('badge').textContent); + + const pauseBase = fakeNow; + stateCb({ baseMs: pauseBase, baseRealMs: pauseBase, rate: 0, lastRate: -1, style: 'digital' }); + rafCb(); + const frozen = byId('digitalTime').textContent; + fakeNow += 9000; rafCb(); + check('暂停:9 秒真实时间过去,画面完全不动', byId('digitalTime').textContent === frozen, `${frozen} → ${byId('digitalTime').textContent}`); + check('暂停角标显示「暂停」', byId('badge').textContent === '暂停', byId('badge').textContent); + + stateCb({ baseMs: fakeNow, baseRealMs: fakeNow, rate: 1, lastRate: 1, style: 'digital' }); + rafCb(); + check('倍率回到 1 时角标隐藏', byId('badge').hidden === true); + + console.log('--- 样式切换 ---'); + stateCb({ baseMs: fakeNow, baseRealMs: fakeNow, rate: 1, lastRate: 1, style: 'analog' }); + check('切到表盘:analog 显示、digital 隐藏', + byId('analogView').hidden === false && byId('digitalView').hidden === true); + fakeNow = new RealDate(2026, 8, 14, 3, 0, 0, 0).getTime(); + stateCb({ baseMs: fakeNow, baseRealMs: fakeNow, rate: 1, lastRate: 1, style: 'analog' }); + fakeNow += 0; rafCb(); + check('03:00:00 时时针指向 90°(3 点方向)', byId('hourHand').attrs.transform === 'rotate(90 0 0)', byId('hourHand').attrs.transform); + check('分针指向 0°', byId('minuteHand').attrs.transform === 'rotate(0 0 0)', byId('minuteHand').attrs.transform); + check('秒针指向 0°', byId('secondHand').attrs.transform === 'rotate(0 0 0)', byId('secondHand').attrs.transform); + fakeNow += 15000; rafCb(); // 15 秒 + check('15 秒后秒针指向 90°', byId('secondHand').attrs.transform === 'rotate(90 0 0)', byId('secondHand').attrs.transform); + check('秒针按毫秒连续扫(15.5 秒 ≈ 93°)', (() => { fakeNow += 500; rafCb(); return byId('secondHand').attrs.transform === 'rotate(93 0 0)'; })(), byId('secondHand').attrs.transform); + + stateCb({ baseMs: fakeNow, baseRealMs: fakeNow, rate: 1, lastRate: 1, style: 'digital' }); + check('切回数字:digital 显示、analog 隐藏', + byId('digitalView').hidden === false && byId('analogView').hidden === true); + + console.log('--- 拖边缘改大小(模拟真实鼠标事件序列) ---'); + const east = handles.find((h) => h.dataset.dir === 'e'); + east.fire('pointerdown', { button: 0, screenX: 916, screenY: 440, pointerId: 1, preventDefault() {} }); + east.fire('pointermove', { screenX: 996, screenY: 440, pointerId: 1 }); + east.fire('pointermove', { screenX: 1036, screenY: 440, pointerId: 1 }); + east.fire('pointerup', { pointerId: 1 }); + const b1 = calls.setBounds.at(-1); + check('拖东边:宽度 520 → 640,左上角不动', + b1.width === 640 && b1.x === 400 && b1.y === 300 && b1.height === 280, JSON.stringify(b1)); + + const south = handles.find((h) => h.dataset.dir === 's'); + south.fire('pointerdown', { button: 0, screenX: 660, screenY: 576, pointerId: 1, preventDefault() {} }); + south.fire('pointermove', { screenX: 660, screenY: 636, pointerId: 1 }); + south.fire('pointerup', { pointerId: 1 }); + const b2 = calls.setBounds.at(-1); + check('拖南边:高度 280 → 340', b2.height === 340 && b2.y === 300, JSON.stringify(b2)); + + const west = handles.find((h) => h.dataset.dir === 'w'); + west.fire('pointerdown', { button: 0, screenX: 400, screenY: 440, pointerId: 1, preventDefault() {} }); + west.fire('pointermove', { screenX: 900, screenY: 440, pointerId: 1 }); + west.fire('pointerup', { pointerId: 1 }); + const b3 = calls.setBounds.at(-1); + check('拖西边超过最小宽度:停在 260,右边界不动(窗口不会被推走)', + b3.width === 260 && b3.x + b3.width === 920, JSON.stringify(b3)); + + const before = calls.setBounds.length; + south.fire('pointermove', { screenX: 660, screenY: 700, pointerId: 1 }); + check('抬起鼠标后再移动不会继续改大小', calls.setBounds.length === before); + + console.log('--- 关闭按钮 ---'); + byId('closeBtn').fire('click', {}); + check('点 × 调用了 closeClockWindow', calls.closed === 1, String(calls.closed)); + + console.log(''); + if (!failures.length) { console.log(`渲染进程逻辑:全部通过 ${passed}/${passed}`); process.exit(0); } + console.log(`通过 ${passed},失败 ${failures.length}:${failures.join(';')}`); process.exit(1); +}, 30); diff --git a/test/renderer-control.dom-test.js b/test/renderer-control.dom-test.js new file mode 100644 index 0000000..e15c236 --- /dev/null +++ b/test/renderer-control.dom-test.js @@ -0,0 +1,132 @@ +// 用最小 DOM 桩把 renderer/control.js 跑一遍,重点验证「暂停/继续」按钮的状态文案 +// 注意:桩不是真浏览器,只能证明接线与状态同步正确。 +const fs = require('fs'); const vm = require('vm'); const path = require('path'); +const root = path.join(__dirname, '..'); +const read = (p) => fs.readFileSync(path.join(root, p), 'utf8'); +let passed = 0; const failures = []; +const check = (n, ok, d) => { if (ok) { passed++; console.log(`PASS ${n}${d ? ` [${d}]` : ''}`); } else { failures.push(n); console.log(`FAIL ${n}${d ? ` [${d}]` : ''}`); } }; + +function makeEl(id) { + const classes = new Set(); + return { + id, textContent: '', value: '', checked: false, hidden: false, style: {}, dataset: {}, handlers: {}, children: [], + classList: { add: (c) => classes.add(c), remove: (c) => classes.delete(c), toggle: (c, on) => (on ? classes.add(c) : classes.delete(c)), contains: (c) => classes.has(c) }, + _classes: classes, + appendChild(c) { this.children.push(c); }, setAttribute() {}, getAttribute() {}, getBoundingClientRect: () => ({ width: 800, height: 600 }), + addEventListener(t, fn) { (this.handlers[t] ||= []).push(fn); }, + fire(t, ev = {}) { (this.handlers[t] || []).forEach((fn) => fn({ target: this, ...ev })); } + }; +} +const els = new Map(); +const byId = (id) => { if (!els.has(id)) els.set(id, makeEl(id)); return els.get(id); }; +const segButtons = ['digital', 'analog'].map((s) => { const el = makeEl(`seg-${s}`); el.dataset.style = s; return el; }); +const styleGroup = makeEl('styleGroup'); +styleGroup.querySelectorAll = () => segButtons; + +const rateChips = ['-1','-2','0','0.5','1','2','10','60'].map((r) => { const el = makeEl(`chip-${r}`); el.dataset.rate = r; return el; }); +const fastChips = ['5000','60000','300000','600000','1800000'].map((d) => { const el = makeEl(`fast-${d}`); el.dataset.delta = d; return el; }); +const allChips = [...rateChips, ...fastChips]; + +const body = makeEl('body'); +const calls = { setRate: [], fastForward: [], setTime: [], setStyle: [], aot: [], toggleClock: 0, togglePause: 0 }; +let stateCb = null; + +const sandbox = { + console, + requestAnimationFrame: () => 1, + document: { + body, activeElement: null, + getElementById: (id) => (id === 'styleGroup' ? styleGroup : byId(id)), + querySelectorAll: () => [], createElementNS: () => makeEl('x') + }, + window: {} +}; +sandbox.window = sandbox; +sandbox.window.clockApi = { + getState: () => Promise.resolve({ baseMs: Date.now(), baseRealMs: Date.now(), rate: 1, lastRate: 1, style: 'digital', clockWindowOpen: false, alwaysOnTop: { clock: true, control: false } }), + onState: (cb) => { stateCb = cb; }, + setRate: (r) => { calls.setRate.push(r); return Promise.resolve({}); }, + fastForward: (d) => { calls.fastForward.push(d); return Promise.resolve({}); }, + setTime: (h, m, s) => { calls.setTime.push([h, m, s]); return Promise.resolve({}); }, + setStyle: (s) => { calls.setStyle.push(s); return Promise.resolve({}); }, + setAlwaysOnTop: (t, v) => { calls.aot.push([t, v]); return Promise.resolve({}); }, + toggleClockWindow: () => { calls.toggleClock++; return Promise.resolve({}); }, + togglePause: () => { calls.togglePause++; return Promise.resolve({}); }, + closeClockWindow: () => Promise.resolve({}), closeControlWindow: () => Promise.resolve({}), + getBoundsSync: () => null, setBounds: () => {} +}; + +const ctx = vm.createContext(sandbox); +try { vm.runInContext(read('renderer/control.js'), ctx, { filename: 'control.js' }); check('control.js 加载无异常', true); } +catch (e) { check('control.js 加载无异常', false, e.message); process.exit(1); } + +setTimeout(() => { + console.log('--- 初始状态 ---'); + check('时钟窗口按钮初始为「打开时钟窗口」', byId('toggleClockWindow').textContent === '打开时钟窗口', byId('toggleClockWindow').textContent); + check('暂停按钮初始文案为「暂停」', byId('togglePause').textContent === '暂停', byId('togglePause').textContent); + check('时钟窗口置顶默认勾选', byId('aotClock').checked === true); + check('控制窗口置顶默认不勾选', byId('aotControl').checked === false); + + console.log('--- 暂停 / 继续按钮 ---'); + stateCb({ baseMs: Date.now(), baseRealMs: Date.now(), rate: 0, lastRate: 2, style: 'digital', clockWindowOpen: true, alwaysOnTop: { clock: true, control: false } }); + check('暂停后按钮变成「继续(回到 ×2)」', byId('togglePause').textContent === '继续(回到 ×2)', byId('togglePause').textContent); + check('暂停时按钮有高亮状态 is-on', byId('togglePause')._classes.has('is-on')); + check('暂停时倍率输入框显示 0', byId('rate').value === '0', byId('rate').value); + check('时钟窗口按钮反映已打开', byId('toggleClockWindow').textContent === '关闭时钟窗口', byId('toggleClockWindow').textContent); + + stateCb({ baseMs: Date.now(), baseRealMs: Date.now(), rate: 2, lastRate: 2, style: 'digital', clockWindowOpen: true, alwaysOnTop: { clock: true, control: false } }); + check('继续后按钮文案回到「暂停」', byId('togglePause').textContent === '暂停', byId('togglePause').textContent); + check('继续后高亮消失', !byId('togglePause')._classes.has('is-on')); + check('继续后倍率输入框显示 2', byId('rate').value === '2', byId('rate').value); + + stateCb({ baseMs: Date.now(), baseRealMs: Date.now(), rate: -1, lastRate: -1, style: 'analog', clockWindowOpen: true, alwaysOnTop: { clock: false, control: true } }); + check('负倍率时按钮仍是「暂停」', byId('togglePause').textContent === '暂停', byId('togglePause').textContent); + check('样式按钮高亮跟随状态(analog)', segButtons[1]._classes.has('is-active') && !segButtons[0]._classes.has('is-active')); + check('置顶勾选框跟随状态', byId('aotClock').checked === false && byId('aotControl').checked === true); + + console.log('--- 点击行为 ---'); + byId('togglePause').fire('click'); + check('点暂停按钮调用 togglePause', calls.togglePause === 1); + byId('toggleClockWindow').fire('click'); + check('点时钟窗口按钮调用 toggleClockWindow', calls.toggleClock === 1); + + segButtons[0].fire('click'); + check('点「纯数字」调用 setStyle(digital)', calls.setStyle.at(-1) === 'digital', String(calls.setStyle.at(-1))); + + body.fire('click', { target: rateChips.find((c) => c.dataset.rate === '-1') }); + check('点「-1 倒转」调用 setRate(-1)', calls.setRate.at(-1) === -1, String(calls.setRate.at(-1))); + body.fire('click', { target: rateChips.find((c) => c.dataset.rate === '0.5') }); + check('点「0.5」调用 setRate(0.5)', calls.setRate.at(-1) === 0.5, String(calls.setRate.at(-1))); + body.fire('click', { target: rateChips.find((c) => c.dataset.rate === '0') }); + check('点「0 暂停」调用 setRate(0)', calls.setRate.at(-1) === 0, String(calls.setRate.at(-1))); + + for (const d of ['5000','60000','300000','600000','1800000']) body.fire('click', { target: fastChips.find((c) => c.dataset.delta === d) }); + check('五个快进按钮分别调用 fastForward(5000/60000/300000/600000/1800000)', + JSON.stringify(calls.fastForward) === JSON.stringify([5000, 60000, 300000, 600000, 1800000]), JSON.stringify(calls.fastForward)); + + byId('rate').value = '-3.5'; + byId('applyRate').fire('click'); + check('手动输入倍率 -3.5 生效', calls.setRate.at(-1) === -3.5, String(calls.setRate.at(-1))); + byId('rate').value = 'abc'; + byId('applyRate').fire('click'); + check('倍率输入非法时回退到当前值,不发送 NaN', calls.setRate.at(-1) === -3.5, String(calls.setRate.at(-1))); + + byId('hh').value = '25'; byId('mm').value = '70'; byId('ss').value = '-3'; + byId('applyTime').fire('click'); + check('时间输入越界被夹到 23:59:00', JSON.stringify(calls.setTime.at(-1)) === JSON.stringify([23, 59, 0]), JSON.stringify(calls.setTime.at(-1))); + check('夹紧后的值回填到输入框', byId('hh').value === '23' && byId('mm').value === '59' && byId('ss').value === '0'); + + byId('fillNow').fire('click'); + check('「填入当前显示时间」把输入框填成合法时间', + /^\d{2}$/.test(byId('hh').value) && /^\d{2}$/.test(byId('mm').value) && /^\d{2}$/.test(byId('ss').value), + `${byId('hh').value}:${byId('mm').value}:${byId('ss').value}`); + + byId('aotClock').checked = false; byId('aotClock').fire('change'); + check('取消时钟置顶调用 setAlwaysOnTop(clock,false)', JSON.stringify(calls.aot.at(-1)) === JSON.stringify(['clock', false]), JSON.stringify(calls.aot.at(-1))); + byId('aotControl').checked = true; byId('aotControl').fire('change'); + check('打开控制窗口置顶调用 setAlwaysOnTop(control,true)', JSON.stringify(calls.aot.at(-1)) === JSON.stringify(['control', true]), JSON.stringify(calls.aot.at(-1))); + + console.log(''); + if (!failures.length) { console.log(`控制窗口逻辑:全部通过 ${passed}/${passed}`); process.exit(0); } + console.log(`通过 ${passed},失败 ${failures.length}:${failures.join(';')}`); process.exit(1); +}, 30); diff --git a/test/resize.test.js b/test/resize.test.js new file mode 100644 index 0000000..577f111 --- /dev/null +++ b/test/resize.test.js @@ -0,0 +1,82 @@ +'use strict'; + +// 拖边缘改大小的几何用例(纯计算,不需要 Electron): +// node test/resize.test.js +// 无边框窗口里这段代码只有真鼠标拖拽才能触发,先用纯函数把它钉死,Windows 上就只剩「手感」要试。 + +const { computeBounds, MIN_SIZE } = require('../renderer/resize'); + +let passed = 0; +const failures = []; + +function check(name, ok, detail) { + if (ok) { + passed += 1; + console.log(`PASS ${name}${detail ? ` [${detail}]` : ''}`); + } else { + failures.push(name); + console.log(`FAIL ${name}${detail ? ` [${detail}]` : ''}`); + } +} + +const start = { x: 400, y: 300, width: 520, height: 280 }; +const same = (a, b) => a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; + +console.log('--- 单边 ---'); +check('东边 +120:只有宽度变,左上角不动', + same(computeBounds(start, 'e', 120, 0), { x: 400, y: 300, width: 640, height: 280 })); +check('南边 +60:只有高度变', + same(computeBounds(start, 's', 0, 60), { x: 400, y: 300, width: 520, height: 340 })); +check('西边 -80:宽度增加 80,左边界左移 80', + same(computeBounds(start, 'w', -80, 0), { x: 320, y: 300, width: 600, height: 280 })); +check('北边 -50:高度增加 50,上边界上移 50', + same(computeBounds(start, 'n', 0, -50), { x: 400, y: 250, width: 520, height: 330 })); +check('东边往回拖 -100:窗口变窄,左上角不动', + same(computeBounds(start, 'e', -100, 0), { x: 400, y: 300, width: 420, height: 280 })); + +console.log('--- 四角 ---'); +check('右下角 +40/+30:宽高同时增加', + same(computeBounds(start, 'se', 40, 30), { x: 400, y: 300, width: 560, height: 310 })); +check('左上角 -40/-30:宽高增加且整体左上移', + same(computeBounds(start, 'nw', -40, -30), { x: 360, y: 270, width: 560, height: 310 })); +check('右上角 +40/-30:宽度增加、上边界上移', + same(computeBounds(start, 'ne', 40, -30), { x: 400, y: 270, width: 560, height: 310 })); +check('左下角 -40/+30:左边界左移、高度增加', + same(computeBounds(start, 'sw', -40, 30), { x: 360, y: 300, width: 560, height: 310 })); + +console.log('--- 最小尺寸 ---'); +check('东边往死里拖:宽度停在 260', computeBounds(start, 'e', -9999, 0).width === MIN_SIZE.width); +check('南边往死里拖:高度停在 150', computeBounds(start, 's', 0, -9999).height === MIN_SIZE.height); +{ + const r = computeBounds(start, 'w', 9999, 0); + check('西边往死里拖:宽度停在 260,且右边界不动(窗口不会被推走)', + r.width === MIN_SIZE.width && r.x + r.width === start.x + start.width, JSON.stringify(r)); +} +{ + const r = computeBounds(start, 'n', 0, 9999); + check('北边往死里拖:高度停在 150,且下边界不动', + r.height === MIN_SIZE.height && r.y + r.height === start.y + start.height, JSON.stringify(r)); +} +{ + const r = computeBounds(start, 'nw', 9999, 9999); + check('左上角往死里拖:宽高都停在最小值,右下角不动', + r.width === MIN_SIZE.width && r.height === MIN_SIZE.height && + r.x + r.width === start.x + start.width && r.y + r.height === start.y + start.height, JSON.stringify(r)); +} + +console.log('--- 边界 ---'); +check('位移为 0 时原样返回', same(computeBounds(start, 'e', 0, 0), start)); +check('结果都是整数(避免 setBounds 收到小数后抖动)', + Number.isInteger(computeBounds(start, 'e', 12.4, 0).width)); +check('负坐标(窗口在主屏左侧)也能算', + same(computeBounds({ x: -900, y: -200, width: 520, height: 280 }, 'w', -100, 0), + { x: -1000, y: -200, width: 620, height: 280 })); + +console.log(''); +if (failures.length === 0) { + console.log(`全部通过:${passed}/${passed}`); + process.exit(0); +} else { + console.log(`通过 ${passed},失败 ${failures.length}:${failures.join(';')}`); + process.exit(1); +}