diff --git a/README.md b/README.md index 20eaf54..2b6ee4d 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ The MiniMax Code Agent communicates through the host MCP client. Business reques | [Model Manager](plugins/ocoomber/openrouter-model-manager/) | Browse, search, and enable/disable models in your `~/.minimax/config.yaml` with instant save, bulk actions, one-click undo, and automatic backups | [ocoomber](https://github.com/ocoomber) | | [Self-drive Route Planner](plugins/hanzijie/self-drive-route-planner/) | **Official plugin** for planning driving routes with place search, route alternatives, demo mode, and Xiaohongshu 3:4 itinerary cards | [HanZijie](https://github.com/HanZijie) | | [Git Commit Tree](plugins/microbiosis/git-tree/) | Inspect a local Git repo's commit history with a swim-lane graph, branches/tags, commit detail, and per-file change stats; persisted filter preferences and optional auto-refresh | [Microbiosis](https://github.com/Microbiosis) | +| [Pomodoro Sit Reminder](plugins/1602winxp/pomodoro-sit-timer/) | A Pomodoro timer that only counts: an in-page sit reminder and sound when a segment ends, with no timer lock and no enforced rest length; reminders from a single file or a folder in sequence or shuffled, with single-track loop and loudness matching | [1602WinXP](https://github.com/1602WinXP) |
Preview: Token Usage Board diff --git a/README.zh-CN.md b/README.zh-CN.md index e18ec61..8e49ad6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -65,6 +65,7 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求 | [模型管理器](plugins/ocoomber/openrouter-model-manager/README.zh-CN.md) | 浏览、搜索并启用/停用 `~/.minimax/config.yaml` 中的模型,支持即时保存、批量操作、一键撤销和自动备份 | [ocoomber](https://github.com/ocoomber) | | [自驾规划](plugins/hanzijie/self-drive-route-planner/README.zh-CN.md) | 【官方插件】规划自驾路线、地点搜索、候选算路与小红书 3:4 行程图;支持演示模式 | [HanZijie](https://github.com/HanZijie) | | [Git 提交树](plugins/microbiosis/git-tree/README.zh-CN.md) | 查看本机 Git 仓库的提交历史:泳道提交图、分支/标签、提交详情与文件改动统计,支持筛选偏好持久化与可选自动刷新 | [Microbiosis](https://github.com/Microbiosis) | +| [番茄钟 · 久坐提醒](plugins/1602winxp/pomodoro-sit-timer/README.zh-CN.md) | 只计时的番茄钟:工作段结束在页面内弹出久坐提醒并响铃,不锁状态、不限制休息时长;提醒音支持单文件或文件夹、顺序/随机播放、单曲循环与响度匹配 | [1602WinXP](https://github.com/1602WinXP) |
预览:Token 用量看板 diff --git a/plugins/1602winxp/pomodoro-sit-timer/.minimax-plugin/plugin.json b/plugins/1602winxp/pomodoro-sit-timer/.minimax-plugin/plugin.json new file mode 100644 index 0000000..7f2b349 --- /dev/null +++ b/plugins/1602winxp/pomodoro-sit-timer/.minimax-plugin/plugin.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "name": "pomodoro-sit-timer", + "displayName": "番茄钟 · 久坐提醒", + "version": "1.0.0", + "description": "只计时的番茄钟:走完一个工作段就在页面内弹出久坐提醒并播放提示音,不锁状态、不限制休息时长,想继续工作时点一下「继续工作」。", + "author": "1602WinXP", + "icon": "icon.png", + "category": "Productivity", + "exampleQueries": [ + "打开我的久坐提醒番茄钟", + "把工作段改成 45 分钟", + "今天我已经完成了几段专注" + ], + "apps": [], + "mcpServers": [], + "skills": [] +} diff --git a/plugins/1602winxp/pomodoro-sit-timer/LICENSE b/plugins/1602winxp/pomodoro-sit-timer/LICENSE new file mode 100644 index 0000000..a58a784 --- /dev/null +++ b/plugins/1602winxp/pomodoro-sit-timer/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 1602WinXP + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/1602winxp/pomodoro-sit-timer/README.md b/plugins/1602winxp/pomodoro-sit-timer/README.md new file mode 100644 index 0000000..74eea93 --- /dev/null +++ b/plugins/1602winxp/pomodoro-sit-timer/README.md @@ -0,0 +1,83 @@ +# Pomodoro Sit Reminder + +English | [简体中文](README.zh-CN.md) + +A Pomodoro timer that only counts. When a work segment ends it shows an in-page sit reminder and plays a sound, but it never locks the timer and never enforces a rest length — walk away for as long as you like, then press **Resume** when you are back. + +Author: [1602WinXP](https://github.com/1602WinXP) · Version: `1.0.0` + +![Pomodoro Sit Reminder paused part-way through a segment, with the segment length settings](docs/preview.png) + +![Alert sound, appearance, and statistics settings](docs/preview-settings.png) + +*Both captured in MiniMax Code on Windows at a 463 px panel width, with the interface in English and the session statistics cleared to zero so no personal data is shown. The alert sound is the bundled `sounds/` folder, playing in order with volume normalisation on; the track rows show an audition, so **Next** names the file the following **Preview** press would fetch. The app is in dark mode.* + +## What it does + +- **Segment length** — 1–180 minutes, with 15 / 25 / 45 / 60 presets. `Apply` only affects the next segment; a running or paused one keeps its length. +- **Sit alert** — fires when the segment elapses, in the page only. No notification permission, no lock screen, no forced break. **Pause** keeps the remaining time and resumes from there. +- **Resume** — one button starts the next segment whenever you are ready. +- **Stats** — completed segments and total focus time, with a **Clear stats** button. +- **Alert sound** — a single audio file or a whole folder, in order or shuffled, with single-track loop and volume normalisation. See below. +- **Interface language** — 中文 / English / 日本語 / 한국어. +- **Colour mode** — System / Day / Night. + +## Install and use + +Copy this whole directory, including the hidden `.minimax-plugin` directory, into `.minimax/plugins/` inside your home folder: + +| System | Target path | +| --- | --- | +| Windows | `C:\Users\\.minimax\plugins\pomodoro-sit-timer` | +| macOS | `/Users//.minimax/plugins/pomodoro-sit-timer` | +| Linux | `/home//.minimax/plugins/pomodoro-sit-timer` | + +`.minimax` is hidden: enable "Show hidden items" in File Explorer, or press `Cmd + Shift + .` in Finder. If MiniMax Code has run before, the folder already exists. If you use a custom data directory (`MINIMAX_DATA_DIR`), put the plugin under `plugins/` there instead. + +Restart MiniMax Code, confirm the plugin is enabled, then open the app. The page is titled **Pomodoro · Sitting Alert** when the interface language is English; the plugin itself is listed under the display name `番茄钟 · 久坐提醒`, which is also the name its example queries use. + +To update, close the app, exit MiniMax Code, and replace the whole plugin directory. To uninstall, do the same and delete the directory; app data stored outside it may remain. + +## Alert sound + +The **Audio file or folder path** box accepts either a **single file** or a **folder**. A folder is scanned one level deep, keeps `.mp3` / `.wav` / `.ogg` only, and is sorted by name so that `2.mp3` comes before `10.mp3`. + +Leave it blank and the alert uses a chime the page **synthesises on the spot** — three sine tones (G5, C6, E6) built with the Web Audio API. There is no audio file behind it and no request leaves the machine. The same chime is used whenever a configured file cannot be read, so a moved or deleted track never silences the alert. The three `sounds/chime-*.wav` files shipped with this package are **samples, not a fallback**: they are only heard if you point the box at them. + +- **In order / Shuffle** — the icon button next to the path box toggles between the two. Shuffle reorders the whole folder each time you get through it, so a pass never repeats a track and never skips one. +- **Loop this track** — locks playback to one file. The lock lives on the server, so the file plays once per alert and stays put across restarts. While it is on, the order/shuffle button is disabled, and **Preview** plays the pinned track too. Turning it off does not change the song: playback carries on from the track you were listening to and moves on to the next one from there. +- **Normalise volume** — on by default, and it works in **both directions**: quiet tracks are lifted and loud ones are brought down, so a quiet folder and a loud folder play at the same level. One track is measured and becomes the target level for the whole playlist. That track is whichever one is playing the first time normalisation reaches your audio — on a fresh install, the first track you play; if you tick the box yourself, the one you are listening to at that moment, so un-ticking and re-ticking re-picks it. The measured level is clamped to 0.25–0.89, kept in browser storage, and reused across restarts and across folders. Gain is capped at 40×, and a near-silent file is never chosen as the reference. +- **Preview** — plays the next file in the running order rather than the one that is armed, so you can walk the whole folder. It does not change what the next real alert will play. +- **Current track / Next** — the two rows above the button name the file playing now and the file the next **Preview** press will fetch, so the button is not a black box. **Next** reads `–` whenever there is nothing distinct to fetch — a lock is on, a single file is configured, or no file at all — instead of repeating the name above it. Both rows stay on screen in every state, so the card keeps its height as you press. A long file name is shortened from the front, which keeps the extension readable; hover the current-track row for its full path. +- **Applying a path** — press Enter in the path box. There is no permanent Apply button: Enter commits, and clearing the box and pressing Enter goes back to the synthesised chime described above. A tick appears beside the box while an edit is unsaved and disappears once it is committed or reverted, and pressing the tick commits as well. + +A path such as `sounds` is resolved against the installed plugin directory, and a relative path cannot escape it. Because the client runs from a fresh temporary copy on every start, relative paths are re-resolved at launch rather than saved as absolute ones — an absolute path saved earlier would point at a directory that no longer exists. An absolute path is still accepted and may point anywhere on your machine. + +Three sample tones ship in `sounds/` (`chime-soft`, `chime-bright`, `chime-deep`). + +## Tested environment + +MiniMax Code desktop **3.1.1** on Windows (10.0.26200, x64). The repository's own docs are written against 3.0.73; this package was built and tested on 3.1.1. + +Verified during development, in the client's own embedded browser at a 463 px viewport: install and open; countdown across 1 / 15 / 25 / 45 / 60 minutes; the alert firing with sound; **Resume** starting the next segment; **Pause** holding the remaining time; stats and clearing them; folder playback in both in-order and shuffle modes; the single-track lock, including that Preview follows it and that releasing the lock carries on from the same track in both modes; a long file name held to the card without overflowing it; a relative sound path still resolving after a client restart; volume normalisation; all four interface languages; all three colour modes. + +**Unverified:** macOS and Linux. The audio path handling, the temporary-directory resolution and the layout have not been exercised on either. + +## Data & access + +- **Files read** — the audio file or folder you type into the sound box, read-only. The runtime also reads `sounds/` inside the installed plugin directory for the bundled samples. Nothing else on disk is touched; there is no scanning of your music library. A relative path is locked inside the plugin directory, but **an absolute path may point anywhere on your machine** — see the Alert sound section above for that boundary. +- **Files written** — exactly one: a `state.json` under the directory the Host passes as `context.dataDir`, holding the duration, phase, remaining time, completed-segment count, accumulated focus seconds, and your sound settings. The path comes from `context.dataDir` alone — nothing is hardcoded or walked up from — and nothing outside that directory is written. Each save writes a `state.json.tmp` beside it and then renames over `state.json`, so a reader always sees a complete file rather than a half-written one, and saves are chained so two of them cannot interleave. +- **Browser storage** — view preferences only: interface language, colour mode, whether volume normalisation is on, and the measured reference level. No timer or session data. +- **Network** — none. The runtime makes no outbound requests and has no telemetry. +- **Subprocesses** — none. +- **Configuration** — no API key, no account, no setup. + +The page shows only your own timer and your own stats. Because the alert is in-page, a segment that ends while the MiniApp tab is in the background may go unnoticed until you look at it again. After the alert, `Space` resumes work when the panel has keyboard focus; it is deliberately unbound while a segment is running, to avoid misfires. + +## Source and verification + +The page is `miniapp/client/index.html` (no framework, no CDN, no build step — it works offline), the Node entry is `miniapp/node/server.mjs`, and the Host API type definitions used for editor type-checking are in `miniapp/node/miniapp-api.ts`. There are no third-party runtime dependencies. + +## License + +[MIT](LICENSE). diff --git a/plugins/1602winxp/pomodoro-sit-timer/README.zh-CN.md b/plugins/1602winxp/pomodoro-sit-timer/README.zh-CN.md new file mode 100644 index 0000000..382d1df --- /dev/null +++ b/plugins/1602winxp/pomodoro-sit-timer/README.zh-CN.md @@ -0,0 +1,83 @@ +# 番茄钟 · 久坐提醒 + +[English](README.md) | 简体中文 + +一个只负责计时的番茄钟。工作段走完时在页面内弹出久坐提醒并播放提示音,但**不锁状态、不限制休息时长** —— 你想走多久走多久,回来点一下「继续」就开始下一段。 + +作者:[1602WinXP](https://github.com/1602WinXP) · 版本:`1.0.0` + +![番茄钟 · 久坐提醒,工作段进行到一半时暂停](docs/preview.zh-CN.png) + +![提醒音、配色与统计设置](docs/preview-settings.zh-CN.png) + +*两张均在 MiniMax Code(Windows)面板宽 463 px 下实机截取,会话统计已清零,不含个人数据。提醒音是内置的 `sounds/` 文件夹,顺序播放、音量标准化已开启;曲目那两行显示的是一次试听,所以「下一首」写的是再按一次「试听」会取到的那一首。界面为夜间模式。* + +## 功能 + +- **工作段** —— 1–180 分钟,另有 15 / 25 / 45 / 60 快捷档。 +- **久坐提醒** —— 工作段结束时在页面内触发。不要通知权限,不锁屏,不强制休息。 +- **继续** —— 你准备好了就按一下,直接开始下一段。 +- **统计** —— 完成段数与累计专注时长,可一键清空。 +- **提醒音** —— 单个音频文件或整个文件夹,可顺序 / 随机播放,支持单曲循环与响度匹配。详见下节。 +- **界面语言** —— 中文 / English / 日本語 / 한국어。 +- **配色** —— 跟随系统 / 白天 / 夜间。 + +## 安装与使用 + +把整个目录(**含隐藏的 `.minimax-plugin` 目录**)复制到用户主目录下的 `.minimax/plugins/`: + +| 系统 | 目标路径 | +| --- | --- | +| Windows | `C:\Users\<用户名>\.minimax\plugins\pomodoro-sit-timer` | +| macOS | `/Users/<用户名>/.minimax/plugins/pomodoro-sit-timer` | +| Linux | `/home/<用户名>/.minimax/plugins/pomodoro-sit-timer` | + +`.minimax` 是隐藏目录:Windows 上需在文件资源管理器开启「显示隐藏的项目」,macOS 上按 `Cmd + Shift + .`。只要用过 MiniMax Code,这个目录通常已经存在。如果配置了自定义数据目录(`MINIMAX_DATA_DIR`),请放到该目录下的 `plugins/` 里。 + +然后重启 MiniMax Code,确认插件已启用,打开「番茄钟 · 久坐提醒」,或直接让 Agent 帮你打开。 + +更新时先关闭应用并退出 MiniMax Code,再整体替换插件目录。卸载同理,删除该目录即可;存放在目录之外的应用数据可能仍会保留。 + +## 提醒音 + +路径框既可以填**单个文件**,也可以填**文件夹**。文件夹只扫描一层,只保留 `.mp3` / `.wav` / `.ogg`,按文件名排序(所以 `2.mp3` 会排在 `10.mp3` 前面)。 + +**留空时提醒音是页面现场合成的** —— 三个正弦音(G5、C6、E6),用 Web Audio 接口生成,**背后没有任何音频文件,也没有任何请求离开本机**。配置的文件读不出来时用的也是它,所以音频被移动或删除都不会让提醒静默。随包发的 `sounds/chime-*.wav` 是**示例、不是回落音**,只有你把路径框指向它们时才会响。 + +- **顺序 / 随机** —— 路径框右边的图标按钮在两者之间切换。随机模式每走完一轮就重新洗牌,因此一轮之内既不重复也不遗漏。 +- **单曲循环** —— 把播放锁定在某一个文件。锁定状态存在服务端,所以每次提醒只播一遍,且跨重启保持。锁定期间模式按钮会被禁用,**试听**也只播锁定的那一首。取消勾选**不会换歌**:从你正在听的那一首接着往下走,下一次就是它后面的那一首。 +- **音量标准化** —— 默认开启,且**双向**作用:安静的抬起来,响的压下去,所以安静的目录和响的目录听起来一样大。它会量出一首的峰值,当作整个播放列表的目标音量。被量到的那一首,是标准化第一次对你的音频生效时正在播的那首 —— 刚装上时就是第一次播放的那首;如果你自己勾上这个框,就是勾上那一刻正在听的那首(所以取消再勾一次就能换一首)。量出的值钳制在 0.25–0.89,存在浏览器存储里,跨重启、换目录都沿用。放大倍数上限 40×,接近静音的文件不会被选为基准。 +- **试听** —— 播放当前顺序里的下一首,而不是已布防的那一首,方便你把整个文件夹听一遍。它不会改变下一次真实提醒播放的内容。 +- **当前曲目 / 下一首** —— 按钮上面两行,分别写明现在放的是哪一首、再按一次「试听」会取哪一首,所以这个按钮不是黑盒。**没有第二首可取时**(锁定了、只配了一个文件、或者根本没配文件),「下一首」写 `–`,而不是把上面那行再抄一遍。两行在任何状态下都在,**卡片高度不会因为你按了按钮而变化**。文件名过长时从开头截断,扩展名始终可读;把鼠标停在当前曲目那一行可以看到完整路径。 +- **应用路径** —— 在路径框里按回车,没有常驻的「应用」按钮:回车即生效;清空路径再按回车就退回上面那个合成提示音。有未保存的改动时,路径框右边会出现一个勾,提交或还原后消失,点它同样是提交。 + +像 `sounds` 这样的相对路径以插件安装目录为基准解析。由于客户端每次启动都运行在全新的临时副本里,相对路径会在启动时重新解析,而不是把绝对路径存下来 —— 之前存下的绝对路径指向的目录届时已经不存在了。 + +`sounds/` 里附带三个示例音(`chime-soft`、`chime-bright`、`chime-deep`)。 + +## 已验证环境 + +MiniMax Code 桌面版 **3.1.1**,Windows(10.0.26200,x64)。本仓库文档以 3.0.73 为基准,本插件在 3.1.1 上开发并测试。 + +开发过程中在客户端自带浏览器、463 px 视口下验证过:安装与打开;1 / 15 / 25 / 45 / 60 分钟的倒计时;提醒触发并响铃;「继续」开启下一段;统计与清空;文件夹的顺序与随机播放;单曲锁定(含试听跟随锁定、以及顺序与随机两种模式下取消锁定都从同一首接着走);超长文件名不会撑出卡片;相对路径在客户端重启后仍能解析;响度匹配;四种界面语言;三种配色。 + +**未验证:** macOS 与 Linux。音频路径处理、临时目录解析与页面布局在这两个系统上都没有实测过。 + +## 数据与访问范围 + +- **读取的文件** —— 你填进声音路径框的音频文件或文件夹,只读。运行时还会读取插件安装目录下的 `sounds/` 以取用内置示例音。除此之外不触碰磁盘任何位置,也不会扫描你的音乐库。相对路径锁死在插件目录内,但**绝对路径可以指向本机任意位置** —— 边界见上文「提醒音」一节。 +- **写入的文件** —— 只有一处:`context.dataDir` 指向的目录下的 `state.json`,记录时长、阶段、剩余时间、完成段数、累计专注秒数以及你的提醒音设置。路径完全取自 `context.dataDir`,不硬编码也不向上拼接,该目录之外一律不写。每次落盘先写同目录的 `state.json.tmp` 再 rename 覆盖 `state.json`,所以读到的永远是完整一份而不是写了一半的内容;多次落盘经由一条链串行,不会两次写交叉。 +- **浏览器存储** —— 只存界面偏好:语言、配色、响度匹配开关,以及实测得到的参考音量。不存计时或会话数据。 +- **网络** —— 无。运行时没有任何对外请求,也没有遥测。 +- **子进程** —— 无。 +- **配置** —— 不需要 API key、不需要账号、不需要任何设置。 + +页面只显示你自己的计时和统计。由于提醒发生在页面内,工作段结束时若 MiniApp 标签页在后台,可能要等你回来看才会发现。 + +## 源码与验证情况 + +页面是 `miniapp/client/index.html`(无框架、无 CDN、无构建步骤,离线可用),Node 入口是 `miniapp/node/server.mjs`,供编辑器类型检查用的 Host API 类型定义在 `miniapp/node/miniapp-api.ts`。没有任何第三方运行时依赖。 + +## 许可 + +[MIT](LICENSE)。 diff --git a/plugins/1602winxp/pomodoro-sit-timer/docs/preview-settings.png b/plugins/1602winxp/pomodoro-sit-timer/docs/preview-settings.png new file mode 100644 index 0000000..6c1344c Binary files /dev/null and b/plugins/1602winxp/pomodoro-sit-timer/docs/preview-settings.png differ diff --git a/plugins/1602winxp/pomodoro-sit-timer/docs/preview-settings.zh-CN.png b/plugins/1602winxp/pomodoro-sit-timer/docs/preview-settings.zh-CN.png new file mode 100644 index 0000000..f7aa849 Binary files /dev/null and b/plugins/1602winxp/pomodoro-sit-timer/docs/preview-settings.zh-CN.png differ diff --git a/plugins/1602winxp/pomodoro-sit-timer/docs/preview.png b/plugins/1602winxp/pomodoro-sit-timer/docs/preview.png new file mode 100644 index 0000000..c8ce3d1 Binary files /dev/null and b/plugins/1602winxp/pomodoro-sit-timer/docs/preview.png differ diff --git a/plugins/1602winxp/pomodoro-sit-timer/docs/preview.zh-CN.png b/plugins/1602winxp/pomodoro-sit-timer/docs/preview.zh-CN.png new file mode 100644 index 0000000..64a8ebc Binary files /dev/null and b/plugins/1602winxp/pomodoro-sit-timer/docs/preview.zh-CN.png differ diff --git a/plugins/1602winxp/pomodoro-sit-timer/icon.png b/plugins/1602winxp/pomodoro-sit-timer/icon.png new file mode 100644 index 0000000..299a26c Binary files /dev/null and b/plugins/1602winxp/pomodoro-sit-timer/icon.png differ diff --git a/plugins/1602winxp/pomodoro-sit-timer/miniapp/client/index.html b/plugins/1602winxp/pomodoro-sit-timer/miniapp/client/index.html new file mode 100644 index 0000000..db7f404 --- /dev/null +++ b/plugins/1602winxp/pomodoro-sit-timer/miniapp/client/index.html @@ -0,0 +1,2752 @@ + + + + + + 番茄钟 · 久坐提醒 + + + +
+
+

番茄钟 · 久坐提醒

+
+ + + + + + + +
+

+
+ 0 + 已完成段数 +
+
+ + + +
+

当前工作段倒计时

+
25:00
+

准备开始

+
+
+
+
+ + +
+ +
+ +
+
+

工作段时长

+

+
+ + +
+
+ + + + +
+ +
+ +
+

提醒音

+

+
+
+ + + + + + + +
+
+
+ 当前曲目 + +
+
+ + + + + +
+ +
+ +
+

外观

+

+
+ + + +
+

+
+ +
+

统计

+

+
+
+
0
+
完成段数
+
+
+
0
+
累计专注
+
+
+
+ +
+ +
+
+ +

+ + +

+
+ + + + diff --git a/plugins/1602winxp/pomodoro-sit-timer/miniapp/miniapp.json b/plugins/1602winxp/pomodoro-sit-timer/miniapp/miniapp.json new file mode 100644 index 0000000..498d880 --- /dev/null +++ b/plugins/1602winxp/pomodoro-sit-timer/miniapp/miniapp.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "artifacts": { + "client": [ + "./miniapp/client" + ], + "node": [ + "./miniapp/node" + ] + }, + "runtime": { + "kind": "process", + "entry": "./miniapp/node/server.mjs", + "lifecycle": "on-demand" + }, + "surface": { + "path": "/dashboard" + }, + "mcpEndpoints": [] +} diff --git a/plugins/1602winxp/pomodoro-sit-timer/miniapp/node/miniapp-api.ts b/plugins/1602winxp/pomodoro-sit-timer/miniapp/node/miniapp-api.ts new file mode 100644 index 0000000..7a0e0fd --- /dev/null +++ b/plugins/1602winxp/pomodoro-sit-timer/miniapp/node/miniapp-api.ts @@ -0,0 +1,109 @@ +/** + * Agent-facing Mini App runtime authoring declarations. + * + * Copy this file into a generated plugin for type checking. It contains no Host implementation; + * the Host injects runtime values through start(context). + * Keep the .ts filename: Electron packaging excludes .d.ts files from dependency assets. + */ +export type JsonPrimitive = null | boolean | number | string; +export type JsonValue = JsonPrimitive | JsonObject | readonly JsonValue[]; +export type JsonObject = { readonly [key: string]: JsonValue }; + +declare const HOST_CONNECTOR_TOOL_REF: unique symbol; +export type HostConnectorToolRef = string & { + readonly [HOST_CONNECTOR_TOOL_REF]: 'HostConnectorToolRef'; +}; + +export interface HostConnectorTool { + readonly toolRef: HostConnectorToolRef; + readonly provider: string; + readonly name: string; + readonly description?: string; + readonly inputSchema: JsonValue; + readonly outputSchema?: JsonValue; +} + +export interface HostConnectorListResult { + readonly tools: readonly HostConnectorTool[]; + readonly partial: boolean; +} + +export interface HostConnectorCallOptions { + readonly signal?: AbortSignal; +} + +export interface HostConnectorCallResult { + readonly invocationId: string; + /** + * Raw provider result; it is not normalized by the Host and may be an object, array, or primitive. + * A single text-block array is one provider shape, not a global Host transport contract. + * Decode only a probe-observed envelope; preserve every other value, including direct strings. + */ + readonly value: JsonValue; +} + +export interface HostConnectorClient { + /** Candidate-safe inventory only; available before and after activation. */ + list(options?: HostConnectorCallOptions): Promise; + /** Activation-only business dispatch; call from request handling, never start(context). */ + call( + toolRef: HostConnectorToolRef, + arguments_: JsonObject, + options?: HostConnectorCallOptions, + ): Promise; +} + +export type HostConnectorErrorDisposition = + | 'not_dispatched' + | 'provider_reported' + | 'unknown_after_dispatch'; + +export type HostConnectorErrorCode = + | 'TOOL_REF_STALE' + | 'SERVICE_RESTARTED' + | 'REQUEST_CANCELLED' + | 'CONNECTOR_TIMEOUT' + | 'INVALID_ARGUMENTS' + | 'CONNECTOR_PROVIDER_ERROR' + | 'CONNECTOR_UNAVAILABLE' + | 'CONNECTOR_OUTCOME_UNKNOWN'; + +export interface HostConnectorError extends Error { + readonly code: HostConnectorErrorCode; + readonly disposition: HostConnectorErrorDisposition; + readonly retryable: boolean; + readonly invocationId?: string; + readonly diagnostic?: { + readonly issues: readonly { + readonly path: string; + readonly constraint: string; + readonly limit?: number; + }[]; + }; +} + +export interface MiniAppLogger { + debug(message: string, fields?: JsonObject): void; + info(message: string, fields?: JsonObject): void; + warn(message: string, fields?: JsonObject): void; + error(message: string, fields?: JsonObject): void; +} + +export interface MiniAppLifecycle { + dispose(): void | Promise; +} + +export interface MiniAppContext { + readonly pluginId: string; + readonly pluginRoot: string; + readonly dataDir: string; + readonly listen: Readonly<{ readonly host: '127.0.0.1'; readonly port: number }>; + readonly signal: AbortSignal; + readonly logger: MiniAppLogger; + readonly hostConnector?: HostConnectorClient; +} + +export interface MiniAppModule { + /** Resolve only after the listener accepts connections and every route is installed. */ + start(context: MiniAppContext): Promise; +} diff --git a/plugins/1602winxp/pomodoro-sit-timer/miniapp/node/server.mjs b/plugins/1602winxp/pomodoro-sit-timer/miniapp/node/server.mjs new file mode 100644 index 0000000..af70fea --- /dev/null +++ b/plugins/1602winxp/pomodoro-sit-timer/miniapp/node/server.mjs @@ -0,0 +1,1150 @@ +// @ts-check + +import { createReadStream } from 'node:fs'; +import { mkdir, readdir, readFile, rename, stat, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { basename, extname, isAbsolute, join, relative, resolve } from 'node:path'; +/** @typedef {import('./miniapp-api.js').MiniAppContext} MiniAppContext */ +/** @typedef {import('./miniapp-api.js').MiniAppLifecycle} MiniAppLifecycle */ + +const DEFAULT_DURATION_SEC = 25 * 60; +const MIN_DURATION_SEC = 1 * 60; +const MAX_DURATION_SEC = 3 * 60 * 60; +const RECONCILE_MS = 15_000; +const SSE_KEEPALIVE_MS = 25_000; +const MAX_BODY_BYTES = 4096; + +const POST_ROUTES = new Set([ + '/api/segment/start', + '/api/segment/pause', + '/api/segment/discard', + '/api/settings/duration', + '/api/settings/sound', + '/api/stats/reset', +]); + +/** + * A custom reminder sound is a bounded filesystem read, not a general proxy: + * audio extension only, existing regular file, size capped. + * + * Paths may be absolute (anywhere the user can reach) or relative. A relative + * path resolves against the plugin root so a shipped `sounds/` folder works for + * whoever installs the plugin, and is then pinned to stay inside that root — a + * relative path is a way to name bundled content, never a way to escape upward + * into the rest of the filesystem. + */ +const MAX_SOUND_BYTES = 25 * 1024 * 1024; +const MAX_PATH_CHARS = 1024; +const SOUND_TYPES = new Map([ + ['.mp3', 'audio/mpeg'], + ['.wav', 'audio/wav'], + ['.ogg', 'audio/ogg'], + ['.oga', 'audio/ogg'], + ['.opus', 'audio/ogg'], + ['.m4a', 'audio/mp4'], + ['.aac', 'audio/aac'], + ['.flac', 'audio/flac'], + ['.webm', 'audio/webm'], +]); + +/** Directory mode caps. A reminder picks one track, so a huge folder is only a cost. */ +const MAX_TRACKS = 500; +const MAX_PLAYLIST_BYTES = 200 * 1024 * 1024; + +/** + * Host/Origin loopback guards. + * + * A page on the open internet can reach a loopback server through DNS + * rebinding: it loads from `evil.com`, then a second DNS answer points + * `evil.com` at 127.0.0.1, and the browser still treats the request as + * same-origin — the origin compares the NAME that was typed, not the address + * it resolved to. A rebound request therefore arrives carrying a non-loopback + * `Host`, which is what these checks reject. + * + * Without them, a page like that could POST an arbitrary absolute path to + * /api/settings/sound and then read the bytes back from /api/sound. + */ +const LOOPBACK_NAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']); + +/** + * The host part of an authority, without the port. + * + * `[::1]:49660` -> `[::1]`, `127.0.0.1:49660` -> `127.0.0.1`, `evil.com` -> + * `evil.com`. A bare unbracketed IPv6 literal has more than one colon, so the + * tail must not be mistaken for a port. + * + * @param {string | undefined | null} authority + * @returns {string | null} lowercased host, or null when unusable + */ +function authorityHost(authority) { + const value = String(authority ?? '').trim().toLowerCase(); + if (value === '') return null; + if (value.startsWith('[')) { + const end = value.indexOf(']'); + return end === -1 ? null : value.slice(0, end + 1); + } + const colon = value.lastIndexOf(':'); + if (colon !== -1 && value.indexOf(':') === colon) return value.slice(0, colon); + return value; +} + +/** + * @param {string | undefined | null} authority + * @param {string} boundHost the host this server was told to listen on + */ +function isLoopbackAuthority(authority, boundHost) { + const host = authorityHost(authority); + if (host === null) return false; + return LOOPBACK_NAMES.has(host) || host === String(boundHost ?? '').toLowerCase(); +} + +/** + * Same check for an `Origin`, which a browser sends on cross-origin requests and + * on same-origin POSTs. A missing Origin is not a failure — the Host check + * already covers that case. + * + * @param {string} origin + * @param {string} boundHost + */ +function isLoopbackOrigin(origin, boundHost) { + let parsed; + try { + parsed = new URL(origin); + } catch { + return false; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false; + return isLoopbackAuthority(parsed.host, boundHost); +} + +/** + * @param {MiniAppContext} context + * @returns {Promise} + */ +export async function start(context) { + const dataDir = context.dataDir; + const stateFile = join(dataDir, 'state.json'); + const clientEntry = await readFile(join(context.pluginRoot, 'miniapp/client/index.html')); + // Base for every relative sound path. Pinned once at boot so a later change to + // context cannot redirect what a relative path resolves to mid-run. + const pluginRoot = resolve(context.pluginRoot); + + /** + * @typedef {'single' | 'sequence' | 'shuffle'} SoundMode + */ + + /** + * Node owns the deadline. The Client never keeps its own authoritative clock, so a + * throttled or backgrounded tab cannot drift the count. + * @type {{ durationSec: number, phase: 'idle' | 'running' | 'paused' | 'reminded', + * startedAt: number | null, remainingSec: number, bankedMinutes: number, + * completedSessions: number, focusSec: number, reminderSeq: number, + * soundPath: string | null, soundInput: string | null, + * soundRelative: boolean, soundMode: SoundMode, soundIndex: number, + * soundPinned: string | null, soundVersion: number }} + */ + const state = { + durationSec: DEFAULT_DURATION_SEC, + phase: 'idle', + startedAt: null, + remainingSec: DEFAULT_DURATION_SEC, + bankedMinutes: 0, + completedSessions: 0, + focusSec: 0, + reminderSeq: 0, + soundPath: null, + soundInput: null, + soundRelative: false, + soundMode: 'single', + soundIndex: 0, + // File name of the track 单曲循环 pinned, or null. Pinning is a selection + // decision (stay on this track), NOT repeating the buffer — the Client + // always plays a track exactly once. + soundPinned: null, + soundVersion: 0, + }; + + /** @type {Set} */ + const streams = new Set(); + /** @type {Set} */ + const soundStreams = new Set(); + /** @type {NodeJS.Timeout | null} */ + let deadlineTimer = null; + /** @type {NodeJS.Timeout | null} */ + let reconcileTimer = null; + /** @type {NodeJS.Timeout | null} */ + let keepaliveTimer = null; + let loadPromise = null; + let writeChain = Promise.resolve(); + + // ---------------------------------------------------------------- persistence + + /** @returns {Promise} true when this call performed the first read. */ + function ensureLoaded() { + if (!loadPromise) { + loadPromise = (async () => { + try { + const raw = await readFile(stateFile, 'utf8'); + const saved = JSON.parse(raw); + applySavedState(state, saved); + } catch (error) { + const code = /** @type {NodeJS.ErrnoException} */ (error).code; + if (code !== 'ENOENT') { + context.logger.warn('miniapp.state.load_failed', { reason: code ?? 'parse' }); + } + } + // A segment that was running or paused when the process stopped is + // interrupted, not completed. Do not award credit for time nobody was + // actually counting, and start clean from the configured duration. + // applySavedState never restores `phase`, so this is stated explicitly + // rather than left to a branch that can never be true. + state.phase = 'idle'; + state.startedAt = null; + state.bankedMinutes = 0; + state.remainingSec = state.durationSec; + // A relative sound path was resolved into the PREVIOUS run's temp + // directory, which no longer exists. Re-resolve it against this run's + // plugin root, or drop it — a stale absolute path would otherwise fail + // every load and silently fall back to the built-in chime. + await revalidateSavedSound(); + return true; + })(); + } + return loadPromise.then(() => true); + } + + /** + * Re-point a relative sound path at this run's plugin root. Absolute paths are + * left alone: the user chose a real location on their machine, and silently + * clearing it would be worse than letting the load report it as gone. + */ + async function revalidateSavedSound() { + if (state.soundPath === null) return; + if (state.soundRelative && typeof state.soundInput === 'string') { + const target = resolve(pluginRoot, state.soundInput); + const rel = relative(pluginRoot, target); + if (rel.startsWith('..') || isAbsolute(rel)) { + state.soundPath = null; + state.soundInput = null; + state.soundRelative = false; + state.soundVersion += 1; + return; + } + state.soundPath = target; + } + try { + await stat(state.soundPath); + } catch { + state.soundPath = null; + state.soundInput = null; + state.soundRelative = false; + state.soundVersion += 1; + } + } + + /** @returns {Promise} */ + function persist() { + const run = async () => { + await mkdir(dataDir, { recursive: true }); + const tmp = join(dataDir, 'state.json.tmp'); + const next = join(dataDir, 'state.json'); + await writeFile(tmp, `${JSON.stringify(state, null, 2)}\n`, 'utf8'); + await rename(tmp, next); + }; + // Run even if a previous write rejected, so one failure cannot wedge the chain. + const next = writeChain.then(run, run); + writeChain = next.then( + () => undefined, + () => undefined, + ); + return next; + } + + // ------------------------------------------------------------------- snapshot + + /** + * Async because resolving a directory playlist is. The resolved track is + * included so the Client can show what it is about to play, and it is derived + * from `reminderSeq` exactly as `/api/sound` derives it, so the two always + * agree on the same file for the same reminder. + */ + async function snapshot() { + const running = state.phase === 'running' && state.startedAt !== null; + const elapsedSec = running + ? Math.max(0, Math.floor((Date.now() - /** @type {number} */ (state.startedAt)) / 1000)) + : 0; + // remainingSec is frozen when a segment starts, so pausing and resuming keeps + // the leftover. A reminded segment has nothing left — reporting the full + // duration next to phase:"reminded" would be a self-contradictory snapshot. + const remainingSec = + state.phase === 'reminded' + ? 0 + : running + ? Math.max(0, state.remainingSec - elapsedSec) + : state.remainingSec; + const track = await currentTrack(state.reminderSeq); + // The same selector, one step further on. The 下一首 row is always on screen, + // so the Client needs an answer before the first 试听 rather than a row that + // only appears after a press — that would move the card under the pointer. + const nextTrack = await currentTrack(state.reminderSeq + 1); + return { + durationSec: state.durationSec, + phase: state.phase, + remainingSec, + startedAt: running ? state.startedAt : null, + completedSessions: state.completedSessions, + focusSec: state.focusSec, + liveSec: liveSec(), + reminderSeq: state.reminderSeq, + // Echoed back so the input stays editable. This is the operator's own + // configured value on a loopback, single-user surface, not a Host secret. + soundPath: state.soundPath, + // What the user actually typed. A relative path is shown back verbatim + // rather than as a temp-directory absolute path, which is neither portable + // nor recognisable, and would be wrong again after the next restart. + soundInput: state.soundInput, + soundMode: state.soundMode, + // File name 单曲循环 is pinned to, or null when the playlist rotates. + soundPinned: state.soundPinned, + // Resolved absolute path of the track this reminder will play, or null if + // the folder went empty or unreadable since it was configured. + soundTrack: track, + // Resolved absolute path of the track the NEXT 试听 will fetch, or null + // when there is nothing to walk to (no file, a single file, or a pin). + soundNext: nextTrack, + soundVersion: state.soundVersion, + serverTime: Date.now(), + }; + } + + function currentRemainingSec() { + const running = state.phase === 'running' && state.startedAt !== null; + if (!running) return state.remainingSec; + const elapsedSec = Math.max( + 0, + Math.floor((Date.now() - /** @type {number} */ (state.startedAt)) / 1000), + ); + return Math.max(0, state.remainingSec - elapsedSec); + } + + /** Seconds the current segment has been on the clock, capped at its deadline. */ + function segmentElapsedSec() { + if (state.phase !== 'running' || state.startedAt === null) return 0; + const cap = state.startedAt + state.remainingSec * 1000; + return Math.max(0, Math.floor((Math.min(Date.now(), cap) - state.startedAt) / 1000)); + } + + /** + * Bank whole elapsed minutes into the lifetime focus total. + * + * Idempotent: `bankedMinutes` records how many whole minutes are already in + * `focusSec`, so calling this any number of times, from anywhere, banks each + * minute exactly once. That is what lets the reconcile tick, pause and the + * deadline all call it without coordinating with each other. + * + * Quantised on purpose — a 4-second fragment is worth 0 minutes, not 1. It also + * removes sub-second bookkeeping entirely, and it never looks across a shutdown: + * all crediting happens while the process is demonstrably alive, so an overnight + * close can never bank time nobody spent at the keyboard. A restart costs at + * most the unfinished partial minute. + */ + function bankMinutes() { + if (state.phase !== 'running') return 0; + const whole = Math.floor(segmentElapsedSec() / 60); + if (whole <= state.bankedMinutes) return 0; + const delta = whole - state.bankedMinutes; + state.focusSec += delta * 60; + state.bankedMinutes = whole; + return delta; + } + + /** Banked minutes plus the sub-minute remainder still on the clock, for display. */ + function liveSec() { + if (state.phase !== 'running') return 0; + return Math.max(0, segmentElapsedSec() - state.bankedMinutes * 60); + } + + // Async because snapshot() resolves the playlist. Callers that only need to + // notify (no return value) may ignore the promise; anything that serialises or + // returns it MUST await, or a Promise stringifies to "{}". + function broadcast() { + void snapshot().then((snap) => { + const frame = `event: state\ndata: ${JSON.stringify(snap)}\n\n`; + for (const stream of streams) { + try { + stream.write(frame); + } catch { + streams.delete(stream); + } + } + }); + } + + // ------------------------------------------------------------- domain service + + function deadlineAt() { + // Anchored to the frozen remainingSec, not durationSec, so a segment resumed + // from a pause still lands on its original deadline. + return state.startedAt === null ? null : state.startedAt + state.remainingSec * 1000; + } + + function armDeadline() { + clearDeadline(); + if (state.phase !== 'running') return; + const target = deadlineAt(); + if (target === null) return; + const delay = Math.max(0, target - Date.now()); + deadlineTimer = setTimeout(() => { + void fireReminder(); + }, delay); + } + + function clearDeadline() { + if (deadlineTimer) { + clearTimeout(deadlineTimer); + deadlineTimer = null; + } + } + + /** Commit durably first, then broadcast, so a dropped frame never loses a completed segment. */ + async function fireReminder() { + if (state.phase !== 'running') return; + clearDeadline(); + // segmentElapsedSec is capped at the deadline, so this banks the full segment + // and never a second past it. + bankMinutes(); + state.phase = 'reminded'; + state.completedSessions += 1; + state.startedAt = null; + state.bankedMinutes = 0; + state.remainingSec = 0; + state.reminderSeq += 1; + await persist(); + broadcast(); + context.logger.info('miniapp.reminder.fired', { completedSessions: state.completedSessions }); + } + + /** Idle or reminded → a fresh full segment. Paused → resume the same one. */ + async function startSegment() { + if (state.phase === 'running') return; + if (state.phase !== 'paused') { + state.remainingSec = state.durationSec; + } + state.phase = 'running'; + state.startedAt = Date.now(); + state.bankedMinutes = 0; + armDeadline(); + armReconcile(); + await persist(); + broadcast(); + } + + /** Freeze at the current leftover instead of throwing the segment away. */ + async function pauseSegment() { + if (state.phase !== 'running') return; + clearDeadline(); + bankMinutes(); + state.remainingSec = currentRemainingSec(); + state.phase = 'paused'; + state.startedAt = null; + state.bankedMinutes = 0; + await persist(); + broadcast(); + } + + /** Give up a paused segment and go back to a full idle timer. */ + async function discardSegment() { + if (state.phase !== 'paused') return; + state.phase = 'idle'; + state.startedAt = null; + state.bankedMinutes = 0; + state.remainingSec = state.durationSec; + await persist(); + broadcast(); + } + + async function setDuration(seconds) { + if (!Number.isInteger(seconds) || seconds < MIN_DURATION_SEC || seconds > MAX_DURATION_SEC) { + return false; + } + state.durationSec = seconds; + // A segment's length is frozen when it starts. Changing the setting retimes the + // NEXT segment; it must not re-anchor a running or paused one, or a shorter + // setting would fire an instant reminder on a segment already in progress. + if (state.phase === 'idle') state.remainingSec = seconds; + await persist(); + broadcast(); + return true; + } + + /** + * A custom reminder sound is a bounded filesystem read, not a general proxy: + * audio extension only, existing regular file, size capped. A directory is + * scanned once into an ordered playlist and one track is served per reminder. + * + * @param {unknown} value + * @param {unknown} mode + * @returns {Promise<{ ok: true } | { ok: false, reason: string, detail?: string }>} + */ + async function setSound(value, mode) { + if (value === null || value === '') { + if (state.soundPath === null) return { ok: true }; + state.soundPath = null; + state.soundInput = null; + state.soundRelative = false; + state.soundMode = 'single'; + state.soundIndex = 0; + // The lock goes with the folder. It is a file NAME, so it survived the + // removal and came back silently the next time a folder holding a file of + // that name was configured — the app looked locked with nothing to show + // for it, and no box had been ticked. + state.soundPinned = null; + state.soundVersion += 1; + await persist(); + broadcast(); + return { ok: true }; + } + if (typeof value !== 'string') return { ok: false, reason: 'sound_path_not_a_string' }; + const raw = value.trim(); + if (raw.length === 0) return { ok: false, reason: 'sound_path_empty' }; + if (raw.length > MAX_PATH_CHARS) return { ok: false, reason: 'sound_path_too_long' }; + + // Relative paths name content shipped with the plugin, so they resolve + // against the plugin root and must not climb out of it. + // + // The RAW input is kept alongside the resolved path. A relative path resolves + // into the Host's runtime temp directory, which is a NEW directory on every + // restart — persisting only the resolved absolute path meant the config went + // stale the moment the runtime came back, and every reminder silently fell + // back to the built-in chime. Keeping the raw form lets it be re-resolved. + const isRelative = !isAbsolute(raw); + let target; + if (!isRelative) { + target = resolve(raw); + } else { + target = resolve(pluginRoot, raw); + const rel = relative(pluginRoot, target); + if (rel.startsWith('..') || isAbsolute(rel)) { + return { ok: false, reason: 'sound_path_escapes_plugin' }; + } + } + state.soundInput = raw; + state.soundRelative = isRelative; + + let info; + try { + info = await stat(target); + } catch { + return { ok: false, reason: 'sound_file_not_found' }; + } + + if (info.isDirectory()) { + const wanted = mode === 'shuffle' || mode === 'sequence' ? mode : 'sequence'; + const tracks = await scanDirectory(target); + if (tracks.error) return { ok: false, reason: tracks.error, detail: tracks.detail }; + // Re-point at the resolved directory so later relative re-entry is stable. + if (state.soundPath !== target || state.soundMode !== wanted) { + state.soundPath = target; + state.soundMode = wanted; + // Arm at the current reminder count so the next reminder plays track 0. + state.soundIndex = state.reminderSeq; + state.soundVersion += 1; + await persist(); + broadcast(); + } + return { ok: true }; + } + + // A file can only ever play alone, so any folder mode collapses to 'single'. + if (state.soundPath !== target || state.soundMode !== 'single') { + state.soundPath = target; + state.soundMode = 'single'; + state.soundIndex = state.reminderSeq; + state.soundVersion += 1; + await persist(); + broadcast(); + } + return { ok: true }; + } + + /** + * One level deep, audio extensions only, name-sorted so `sequence` is + * predictable across restarts. Subdirectories are not descended into: a + * reminder needs a flat list, and recursion would make the cost unbounded. + * @param {string} dir + * @returns {Promise<{ error: null, files: string[] } | { error: string, detail?: string }>} + */ + async function scanDirectory(dir) { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return { error: 'sound_dir_unreadable' }; + } + /** @type {string[]} */ + const files = []; + let totalBytes = 0; + for (const entry of entries) { + if (!entry.isFile()) continue; + const ext = extname(entry.name).toLowerCase(); + if (!SOUND_TYPES.has(ext)) continue; + files.push(join(dir, entry.name)); + } + if (files.length === 0) return { error: 'sound_dir_empty' }; + if (files.length > MAX_TRACKS) { + return { error: 'sound_dir_too_many', detail: String(MAX_TRACKS) }; + } + // Skip unreadable/oversized entries rather than failing the whole folder, but + // stop once the playlist is too large to serve cheaply. + const usable = []; + for (const file of files) { + if (totalBytes > MAX_PLAYLIST_BYTES) return { error: 'sound_dir_too_large' }; + try { + const info = await stat(file); + if (!info.isFile() || info.size > MAX_SOUND_BYTES) continue; + totalBytes += info.size; + } catch { + continue; + } + usable.push(file); + } + if (usable.length === 0) return { error: 'sound_dir_empty' }; + // Locale-aware so 2.mp3 sorts before 10.mp3 in a way a human expects. + usable.sort((a, b) => basename(a).localeCompare(basename(b), undefined, { numeric: true })); + return { error: null, files: usable }; + } + + /** + * The file a given reminder should play. + * + * Selection is a pure function of `seq` rather than a mutable cursor, because + * two independent requests ask for it — the snapshot the Client renders and + * the `/api/sound` fetch it plays — and they must land on the same track. A + * cursor advanced in one place would race the other and could hand the Client + * a track name it never plays. + * + * `sequence` walks the name-sorted playlist and wraps. `shuffle` applies a + * multiplicative hash to the step, which spreads short playlists across the + * folder instead of clustering on the first few entries the way a raw + * `step * k` would. The directory is re-read on every call, so a track added, + * removed or replaced outside the app takes effect immediately. + * + * @param {number} seq reminder sequence number + * @returns {Promise} + */ + async function currentTrack(seq) { + if (state.soundPath === null) return null; + if (state.soundMode === 'single') return state.soundPath; + const scanned = await scanDirectory(state.soundPath); + if (scanned.error) return null; + const files = scanned.files; + if (files.length === 1) return files[0]; + // 单曲循环 is the highest priority and outranks EVERYTHING, 试听 included. + // It used to be bypassed during a preview so that auditioning would keep + // moving; that made the lock look like it did nothing, because pressing + // 试听 played a different file. A lock you can walk past is not a lock. + if (state.soundPinned !== null) { + const pinned = files.find((f) => basename(f) === state.soundPinned); + if (pinned) return pinned; + state.soundPinned = null; + } + // `soundIndex` holds the reminder sequence the playlist was armed at, so + // configuring a folder always starts at its first track regardless of how + // many reminders happened earlier in the session. + return pickTrack(files, Math.max(0, seq - state.soundIndex)); + } + + /** + * The rotation itself, with no I/O and no state: which file does `step` + * reminders past the arming point resolve to? + * + * Split out of currentTrack so releasing a lock can ask the same question for a + * candidate step without re-reading the folder once per candidate. + * + * @param {string[]} files name-sorted playlist + * @param {number} step reminders since the playlist was armed + * @returns {string} + */ + function pickTrack(files, step) { + if (state.soundMode === 'shuffle') { + // Re-shuffle each time the playlist completes, then walk it to the end. + // A per-cycle permutation guarantees every track plays exactly once before + // any repeats, which a hash-mod does NOT: measured over 12 reminders with + // 5 tracks it reached only 3 of them. + const cycle = Math.floor(step / files.length); + const order = seededOrder(files.length, mix32(cycle + 1)); + return files[order[step % files.length]]; + } + return files[step % files.length]; + } + + /** + * Releasing 单曲循环 must not change which song the app is on. + * + * While a track is pinned every currentTrack() call returns that file, so the + * playlist cursor never moves. Unticking then snapped the armed track straight + * back to wherever the cursor had been frozen — the row visibly jumped to a + * different file for an action the user took to stop repeating, not to switch + * songs. Every music player resumes from the current track here. + * + * So the arming point moves instead: find the step that resolves to the track + * that was just released and set soundIndex to it. The search is bounded by two + * playlist cycles, which is enough for both modes; if nothing matches — the + * file may have been deleted meanwhile — the cursor is left where it was, which + * is the old behaviour rather than a worse one. + * + * @param {string} released file name the lock was holding + */ + async function rebaseAfterRelease(released) { + if (state.soundPath === null || state.soundMode === 'single') return; + const scanned = await scanDirectory(state.soundPath); + if (scanned.error) return; + const files = scanned.files; + if (files.length < 2) return; + if (!files.some((f) => basename(f) === released)) return; + const before = state.soundIndex; + for (let step = 0; step < files.length * 2; step += 1) { + if (basename(pickTrack(files, step)) !== released) continue; + state.soundIndex = state.reminderSeq - step; + return; + } + state.soundIndex = before; + } + + /** + * Avalanche a small integer into well-distributed 32 bits. + * + * A bare `Math.imul(n, K) >>> 0` is NOT enough: the low bits it produces stay + * correlated between neighbours, and `% length` reads only those low bits. + * The xor-shift rounds mix every input bit down into the low ones. + * + * @param {number} n + * @returns {number} unsigned 32-bit + */ + function mix32(n) { + let x = n >>> 0; + x = Math.imul(x ^ (x >>> 16), 2246822507); + x = Math.imul(x ^ (x >>> 13), 3266489909); + return (x ^ (x >>> 16)) >>> 0; + } + + /** + * A deterministic Fisher-Yates over `0..n-1`, seeded so the same cycle always + * yields the same order. mulberry32: small, fast, and good enough to shuffle + * a playlist where a full cycle is hours apart. + * + * @param {number} n + * @param {number} seed + * @returns {number[]} + */ + function seededOrder(n, seed) { + const order = Array.from({ length: n }, (_, i) => i); + let state = seed >>> 0; + const next = () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + for (let i = n - 1; i > 0; i -= 1) { + const j = Math.floor(next() * (i + 1)); + const tmp = order[i]; + order[i] = order[j]; + order[j] = tmp; + } + return order; + } + + async function resetStats() { + state.completedSessions = 0; + state.focusSec = 0; + await persist(); + broadcast(); + } + + // -------------------------------------------------------------------- routing + + const server = createServer((request, response) => { + void handle(request, response).catch(() => { + if (!response.headersSent) { + response.writeHead(500, { 'content-type': 'application/json; charset=utf-8' }); + } + response.end(JSON.stringify({ error: 'internal_error' })); + }); + }); + + /** @param {import('node:http').IncomingMessage} request @param {import('node:http').ServerResponse} response */ + async function handle(request, response) { + const url = new URL(request.url ?? '/', 'http://miniapp.local'); + const { pathname } = url; + const method = request.method ?? 'GET'; + + // DNS-rebinding guard, ahead of every route. See LOOPBACK_NAMES above: a + // rebound request carries the attacker's own name in Host, so refusing a + // non-loopback Host refuses the attack. Both rejections answer a bare 403 + // and echo nothing the requester sent. + if (!isLoopbackAuthority(request.headers.host, context.listen.host)) { + response.writeHead(403, { 'content-type': 'application/json; charset=utf-8' }); + response.end(JSON.stringify({ error: 'forbidden' })); + return; + } + const origin = request.headers.origin; + if (origin !== undefined && !isLoopbackOrigin(origin, context.listen.host)) { + response.writeHead(403, { 'content-type': 'application/json; charset=utf-8' }); + response.end(JSON.stringify({ error: 'forbidden' })); + return; + } + + if (method === 'GET' && (pathname === '/dashboard' || pathname === '/')) { + response.writeHead(200, { + 'content-type': 'text/html; charset=utf-8', + 'cache-control': 'no-store', + }); + response.end(clientEntry); + return; + } + + if (pathname === '/api/events') { + if (method !== 'GET') return sendJson(response, 405, { error: 'method_not_allowed' }); + await ensureLoaded(); + response.writeHead(200, { + 'content-type': 'text/event-stream; charset=utf-8', + 'cache-control': 'no-store', + connection: 'keep-alive', + 'x-accel-buffering': 'no', + }); + response.write(`event: state\ndata: ${JSON.stringify(await snapshot())}\n\n`); + streams.add(response); + request.on('close', () => { + streams.delete(response); + }); + context.signal.addEventListener( + 'abort', + () => { + streams.delete(response); + response.end(); + }, + { once: true }, + ); + return; + } + + if (pathname === '/api/sound') { + if (method !== 'GET') return sendJson(response, 405, { error: 'method_not_allowed' }); + await ensureLoaded(); + if (!state.soundPath) return sendJson(response, 404, { error: 'no_custom_sound' }); + // Serve the track this reminder resolves to, not the configured folder. + // `p` is a preview offset used by 试听 (audition). + // + // Without it, 试听 always returned the armed track, and that only moves + // when a real reminder fires — so pressing 试听 again and again played the + // identical file and shuffle looked broken. The offset is applied to the + // reminder sequence rather than mutating it, so auditioning the folder + // never changes what the next real reminder will play. + // + // A pinned track still wins here: 单曲循环 outranks 试听. + const preview = Number(url.searchParams.get('p')); + const offset = Number.isInteger(preview) && preview > 0 ? preview : 0; + const track = await currentTrack(state.reminderSeq + offset); + if (!track) return sendJson(response, 410, { error: 'sound_file_gone' }); + const contentType = SOUND_TYPES.get(extname(track).toLowerCase()); + if (!contentType) return sendJson(response, 500, { error: 'sound_type_unsupported' }); + // What the NEXT 试听 press will play, resolved by the same function that + // served this response, so the hint can never drift from the real order. + // `currentTrack` re-reads the folder, so this is one extra directory scan + // on a route the user only reaches by deliberately auditioning. + const next = await currentTrack(state.reminderSeq + offset + 1); + let info; + try { + info = await stat(track); + } catch { + return sendJson(response, 410, { error: 'sound_file_gone' }); + } + if (!info.isFile()) return sendJson(response, 410, { error: 'sound_not_a_file' }); + response.writeHead(200, { + 'content-type': contentType, + 'content-length': String(info.size), + // Tells the Client which file it actually got. 试听 auditions a different + // track than the armed one, so without this the label would keep showing + // the armed track while a different file was playing. + 'x-sound-track': basename(track), + // Disclosure for the audition button: pressing 试听 again is a black box + // unless the page says what it will do. Absent when there is nothing to + // walk to, and the Client falls back to the armed track. + ...(next ? { 'x-sound-next-track': basename(next) } : {}), + 'cache-control': 'no-store', + }); + const soundStream = createReadStream(track); + soundStreams.add(soundStream); + const dropSound = () => soundStreams.delete(soundStream); + soundStream.on('close', dropSound); + soundStream.on('error', () => { + dropSound(); + response.destroy(); + }); + soundStream.pipe(response); + return; + } + + if (pathname === '/api/state') { + if (method !== 'GET') return sendJson(response, 405, { error: 'method_not_allowed' }); + await ensureLoaded(); + return sendJson(response, 200, await snapshot()); + } + + if (!pathname.startsWith('/api/') || !POST_ROUTES.has(pathname)) { + return sendJson(response, 404, { error: 'not_found' }); + } + if (method !== 'POST') return sendJson(response, 405, { error: 'method_not_allowed' }); + + await ensureLoaded(); + let body; + try { + body = await readJsonBody(request); + } catch { + return sendJson(response, 400, { error: 'invalid_request_body' }); + } + + switch (pathname) { + case '/api/segment/start': + await startSegment(); + return sendJson(response, 200, await snapshot()); + case '/api/segment/pause': + await pauseSegment(); + return sendJson(response, 200, await snapshot()); + case '/api/segment/discard': + await discardSegment(); + return sendJson(response, 200, await snapshot()); + case '/api/settings/duration': { + const ok = await setDuration(Number(body.durationSec)); + if (!ok) { + return sendJson(response, 400, { + error: 'duration_out_of_range', + min: MIN_DURATION_SEC, + max: MAX_DURATION_SEC, + }); + } + return sendJson(response, 200, await snapshot()); + } + case '/api/settings/sound': { + // 单曲循环 pins a track by file name. Handled BEFORE setSound, and only + // when `path` is absent from the body — a pin-only request must not be + // read as "clear the sound", which is what an undefined path means. + if (body.path === undefined && (body.pin === true || body.pin === false)) { + const current = await currentTrack(state.reminderSeq); + if (body.pin !== true) { + const released = state.soundPinned; + state.soundPinned = null; + if (released !== null) await rebaseAfterRelease(released); + } else { + // Pin the track the Client says it is PLAYING, not the armed one. + // Ticking the box during an audition used to lock a different file + // than the one on screen, so the label appeared to swap songs. + // + // The name is only ever basename()d and compared against the scanned + // folder, so it cannot steer a read outside it; an unknown name + // falls back to the armed track. + let match = null; + if (typeof body.track === 'string' && state.soundPath !== null) { + const wanted = basename(body.track.trim()); + const scanned = await scanDirectory(state.soundPath); + if (!scanned.error) { + match = scanned.files.find((f) => basename(f) === wanted) || null; + } + } + const pinned = match || current; + state.soundPinned = pinned ? basename(pinned) : null; + } + state.soundVersion += 1; + await persist(); + broadcast(); + return sendJson(response, 200, await snapshot()); + } + const result = await setSound( + body.path === undefined ? null : body.path, + body.mode, + ); + if (!result.ok) return sendJson(response, 400, result); + return sendJson(response, 200, await snapshot()); + } + case '/api/stats/reset': + await resetStats(); + return sendJson(response, 200, await snapshot()); + default: + return sendJson(response, 404, { error: 'not_found' }); + } + } + + /** + * @param {import('node:http').ServerResponse} response + * @param {number} status + * @param {unknown} payload + */ + function sendJson(response, status, payload) { + response.writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + }); + response.end(JSON.stringify(payload)); + } + + // ------------------------------------------------------------------- lifecycle + + await new Promise((resolve, reject) => { + const onError = (error) => reject(error); + server.once('error', onError); + server.listen(context.listen.port, context.listen.host, () => { + server.off('error', onError); + resolve(); + }); + }); + + /** + * (Re)start the reconcile tick so it fires at 15/30/45/60s *from now*. Called on + * every segment start, which keeps the tick phase-aligned with the segment instead + * of drifting against a timer created once at boot. That way a whole minute is + * banked within one tick of being reached, bounding what a crash can cost. + */ + function armReconcile() { + if (reconcileTimer) clearInterval(reconcileTimer); + reconcileTimer = setInterval(() => { + if (state.phase !== 'running') return; + // Idempotent, so banking here and again on pause or completion is safe. + if (bankMinutes() > 0) void persist(); + const target = deadlineAt(); + if (target !== null && Date.now() >= target) void fireReminder(); + }, RECONCILE_MS); + reconcileTimer.unref?.(); + } + + armReconcile(); + + keepaliveTimer = setInterval(() => { + for (const stream of streams) { + try { + stream.write(': keepalive\n\n'); + } catch { + streams.delete(stream); + } + } + }, SSE_KEEPALIVE_MS); + keepaliveTimer.unref?.(); + + context.logger.info('miniapp.runtime.listening'); + + let disposed = false; + const dispose = async () => { + if (disposed) return; + disposed = true; + context.signal.removeEventListener('abort', onAbort); + clearDeadline(); + if (reconcileTimer) clearInterval(reconcileTimer); + if (keepaliveTimer) clearInterval(keepaliveTimer); + for (const stream of streams) { + try { + stream.end(); + } catch { + /* already gone */ + } + } + streams.clear(); + for (const soundStream of soundStreams) { + try { + soundStream.destroy(); + } catch { + /* already gone */ + } + } + soundStreams.clear(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + }; + const onAbort = () => { + void dispose().catch(() => undefined); + }; + context.signal.addEventListener('abort', onAbort, { once: true }); + if (context.signal.aborted) await dispose(); + + return { dispose }; +} + +/** + * @param {Record} target + * @param {unknown} saved + */ +function applySavedState(target, saved) { + if (!saved || typeof saved !== 'object') return; + const value = /** @type {Record} */ (saved); + if (Number.isInteger(value.durationSec)) target.durationSec = value.durationSec; + if (Number.isInteger(value.remainingSec) && value.remainingSec >= 0) { + target.remainingSec = value.remainingSec; + } + if (Number.isInteger(value.bankedMinutes) && value.bankedMinutes >= 0) { + target.bankedMinutes = value.bankedMinutes; + } + if (Number.isInteger(value.completedSessions) && value.completedSessions >= 0) { + target.completedSessions = value.completedSessions; + } + // Older packages stored a completion-gated total under completedFocusSec. Carry it + // over so an upgrade does not silently zero the number he already earned. + const savedFocus = Number.isInteger(value.focusSec) + ? value.focusSec + : Number.isInteger(value.completedFocusSec) + ? value.completedFocusSec + : null; + if (savedFocus !== null && savedFocus >= 0) target.focusSec = savedFocus; + if (Number.isInteger(value.reminderSeq) && value.reminderSeq >= 0) { + target.reminderSeq = value.reminderSeq; + } + // A persisted path can outlive the file or stop being an audio file, and a + // persisted directory can be swapped for a file. Re-check the shape here + // rather than trusting what was written on a previous run: an audio extension + // is only meaningful for the single-file mode. + if (typeof value.soundPath === 'string') { + const mode = + value.soundMode === 'sequence' || value.soundMode === 'shuffle' + ? value.soundMode + : 'single'; + const ext = extname(value.soundPath).toLowerCase(); + if (isAbsolute(value.soundPath) && (mode !== 'single' || SOUND_TYPES.has(ext))) { + target.soundPath = value.soundPath; + target.soundMode = mode; + // The raw form lets a relative path be re-resolved on the next boot. + if (typeof value.soundInput === 'string') target.soundInput = value.soundInput; + target.soundRelative = value.soundRelative === true; + } + } + if (Number.isInteger(value.soundIndex) && value.soundIndex >= 0) { + target.soundIndex = value.soundIndex; + } + if (typeof value.soundPinned === 'string' && value.soundPinned !== '') { + target.soundPinned = value.soundPinned; + } + if (Number.isInteger(value.soundVersion) && value.soundVersion >= 0) { + target.soundVersion = value.soundVersion; + } +} + +/** + * @param {import('node:http').IncomingMessage} request + * @returns {Promise>} + */ +async function readJsonBody(request) { + /** @type {Buffer[]} */ + const chunks = []; + let total = 0; + for await (const chunk of request) { + const buf = /** @type {Buffer} */ (chunk); + total += buf.length; + if (total > MAX_BODY_BYTES) throw new Error('payload_too_large'); + chunks.push(buf); + } + if (chunks.length === 0) return {}; + const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8')); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('invalid_json_shape'); + } + return /** @type {Record} */ (parsed); +} diff --git a/plugins/1602winxp/pomodoro-sit-timer/package.json b/plugins/1602winxp/pomodoro-sit-timer/package.json new file mode 100644 index 0000000..aab6b8d --- /dev/null +++ b/plugins/1602winxp/pomodoro-sit-timer/package.json @@ -0,0 +1,6 @@ +{ + "mcode": { + "schemaVersion": 2, + "miniApp": "./miniapp/miniapp.json" + } +} diff --git a/plugins/1602winxp/pomodoro-sit-timer/sounds/chime-bright.wav b/plugins/1602winxp/pomodoro-sit-timer/sounds/chime-bright.wav new file mode 100644 index 0000000..901d018 Binary files /dev/null and b/plugins/1602winxp/pomodoro-sit-timer/sounds/chime-bright.wav differ diff --git a/plugins/1602winxp/pomodoro-sit-timer/sounds/chime-deep.wav b/plugins/1602winxp/pomodoro-sit-timer/sounds/chime-deep.wav new file mode 100644 index 0000000..60a87a5 Binary files /dev/null and b/plugins/1602winxp/pomodoro-sit-timer/sounds/chime-deep.wav differ diff --git a/plugins/1602winxp/pomodoro-sit-timer/sounds/chime-soft.wav b/plugins/1602winxp/pomodoro-sit-timer/sounds/chime-soft.wav new file mode 100644 index 0000000..a66d972 Binary files /dev/null and b/plugins/1602winxp/pomodoro-sit-timer/sounds/chime-soft.wav differ diff --git a/plugins/1602winxp/pomodoro-sit-timer/tsconfig.json b/plugins/1602winxp/pomodoro-sit-timer/tsconfig.json new file mode 100644 index 0000000..0f61df4 --- /dev/null +++ b/plugins/1602winxp/pomodoro-sit-timer/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "allowJs": true, + "checkJs": true, + "noEmit": true, + "strict": false, + "skipLibCheck": true, + "types": [] + }, + "include": ["miniapp/node/**/*.ts", "miniapp/node/**/*.mjs"] +}