Skip to content

Add pomodoro-sit-timer - #14

Open
1602WinXP wants to merge 9 commits into
MiniMax-AI:mainfrom
1602WinXP:add-pomodoro
Open

1602WinXP wants to merge 9 commits into
MiniMax-AI:mainfrom
1602WinXP:add-pomodoro

Conversation

@1602WinXP

@1602WinXP 1602WinXP commented Oct 4, 2026 •

Copy link
Copy Markdown

新增 pomodoro-sit-timer:一个只计时、页内久坐提醒的番茄钟

Add pomodoro-sit-timer: a Pomodoro timer with in-page sit reminders.

基线: d9d9523(上游 main) · 分支: add-pomodoro · 9 个 commit,20 个文件


English summary

A Pomodoro timer that only counts: when a 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. Four interface languages, three colour modes; the alert sound can be a single
file or a folder, in order or shuffled, with single-track loop and two-way volume
normalisation. No framework, no CDN, no build step, no third-party runtime
dependencies.

No network (no outbound requests, no telemetry, no subprocesses, no model API),
no secrets, and exactly one file written — a state.json under
context.dataDir. Three boundaries are called out in their own sections below:
the sound-path boundary (an absolute path may point anywhere on the machine, on
purpose), the DNS-rebinding guard, and logs carrying error codes only.

macOS and Linux are unverified, and the README says so.


是什么

一个只负责计时的番茄钟。工作段走完时在页面内弹出久坐提醒并播放提示音,但不锁状态、
不限制休息时长
—— 想走多久走多久,回来点一下「继续工作」就接上。

  • 包目录:plugins/1602winxp/pomodoro-sit-timer/
  • 插件 ID:pomodoro-sit-timer(全仓库唯一)
  • 版本 1.0.0 · 许可 MIT

主要功能:

  • 工作段 —— 1–180 分钟,另有 15 / 25 / 45 / 60 快捷档。「应用」只影响下一段,运行中或暂停中的段长度不变。
  • 久坐提醒 —— 只在页面内触发。不要通知权限、不锁屏、不强制休息。「暂停」记住剩余时间,继续时接着走。
  • 提醒音 —— 单个音频文件或整个文件夹,可顺序 / 随机播放,支持单曲循环与双向响度标准化。
  • 界面语言 —— 中文 / English / 日本語 / 한국어;配色 —— 跟随系统 / 白天 / 夜间。
  • 统计 —— 完成段数与累计专注,可一键清空。

无框架、无 CDN、无构建步骤、无任何第三方运行时依赖,离线可用。

截图

四张均在 Windows 的 MiniMax Code 内实机截取,面板宽 463 px,会话统计已清零,不含个人数据。英文 README 配英文界面那两张,中文 README 配中文界面那两张。图中提醒音是内置的 sounds/ 文件夹,顺序播放、音量标准化已开启。

English 简体中文
docs/preview.png docs/preview.zh-CN.png
docs/preview-settings.png docs/preview-settings.zh-CN.png

声音路径的边界(主动说明)

  • 相对路径锁死在插件目录内。 relative(pluginRoot, target) 不得以 .. 开头,结果也不得是绝对路径,越界即拒(sound_path_escapes_plugin)。
  • 绝对路径允许指向本机任意位置,这是有意的。 应用内提示文案和 README 都写明了这一点;只接受 .mp3 / .wav / .ogg,单文件上限 25 MB(MAX_SOUND_BYTES),文件夹上限 500 首 / 200 MB(MAX_TRACKS / MAX_PLAYLIST_BYTES)。理由是:这是用户自己键入的路径,不是 URL 参数驱动的任意文件读取 —— 攻击者能控制的输入里没有这一项。

DNS rebinding 防护

服务只绑回环地址,而页面处在不可信输入的位置,所以每个请求在进入路由之前先验 Host:非回环一律 403。

原理是 —— 互联网上的页面可以把自己的域名解析到 127.0.0.1 再来请求本服务,而浏览器判断同源比的是键入的域名、不是解析到的地址,所以被 rebind 的请求会带着攻击者的域名出现在 Host 里。

这一条在本应用上有实际意义,不是形式主义:结合上一节的「绝对路径」,被 rebind 的页面可以先 POST 一个任意绝对路径到 /api/settings/sound,再从 /api/sound 把字节读走。没有这道检查,那就是一条完整的读任意文件链。

Origin 头作为第二层:存在时也必须是回环来源。回环 Host(127.0.0.1、localhost、IPv6 回环)与实际绑定 host 放行;不带 Origin 的请求也放行。两处拒绝都只回一个固定的 403 JSON,不回显任何请求方内容。

数据与访问范围

  • 不联网。 Node 进程无任何出站请求,无遥测,不 spawn 子进程,不调模型 API。
  • 写 —— 只有一处:context.dataDir 指向的目录下的 state.json,记录时长、阶段、剩余时间、完成段数、累计专注秒数以及你的提醒音设置。路径完全取自 context.dataDir,不硬编码、不向上拼接,不写该目录以外的任何位置。落盘是原子的:先写同目录的 state.json.tmp,再 rename 覆盖 state.json,所以读到的永远是完整一份,不会是写了一半的内容(中途被杀最多留下一个无人引用的 .tmp,下次写会被覆盖)。多次落盘经由一条链串行,不会两次写交叉。
  • 读 —— 你填进声音框的音频文件或文件夹(只读),以及插件目录里的内置 sounds/。不扫描音乐库,磁盘其他位置一律不碰。
  • 浏览器存储 —— 只存视图偏好:语言、配色、音量标准化开关、实测参考音量。不存计时或会话数据。
  • 无密钥,无 Host connector 访问,不需要任何配置。
  • 日志只记错误码({ reason: 'ENOENT' } 这类),不记 error.message —— fs 的报错会把绝对路径连同操作系统用户名写进 message,而 dataDir 按契约不透明,那串路径不该跟着日志离开进程。Node 入口里 error.message 出现 0 次。

服务只绑 context.listen.host / context.listen.port,只提供 surface.path 与自己的路由。start(context) 在监听器 accept 之后才 resolve,dispose 关掉 server、定时器和已打开的文件流。stdout / stdin 不碰。


测试环境与结果

MiniMax Code 桌面版 3.1.1,Windows(10.0.26200,x64)。 本仓库文档以 3.0.73 为基准,本插件在 3.1.1 上开发并测试。Node 24.14.0。

在客户端自带浏览器、463 px 视口下逐项验证:安装与打开;1 / 15 / 25 / 45 / 60 分钟的倒计时;提醒触发并响铃;「继续」开启下一段;「暂停」保留剩余时间;统计与清空;文件夹的顺序与随机播放;单曲锁定(含试听也跟随锁定、以及顺序与随机两种模式下取消锁定都从同一首接着走);88 字符的超长文件名不撑出卡片;相对声音路径在客户端重启后仍能解析;响度标准化(含取消再勾一次换基准);四种界面语言;三种配色。

  • 仓库门禁 —— npm run check 55/55 通过;npm run validate 对本包报 OK(0 错误 0 警告)。
  • 独立复制验证 6 项 —— 只把插件目录复制到空临时目录(旁边没有任何仓库内容)启动:页面 200、内置 sounds/ 解析成功、返回真实 RIFF/WAVE 字节、页面无任何外部资源标签与外部 URL。用来证明「单独复制即可运行」这句话是真的。
  • 包级冒烟 22 项 —— 以仓库副本本身作 pluginRoot 真起 start(context):页面路由、/api/state、相对路径解析、真实 RIFF/WAVE 字节、state.json 内容、start() 返回 { dispose }、dispose 真关监听器,以及同一 dataDir 下二次启动仍能重新解析相对路径并继续供音频。
  • 回环守卫 16 项 —— 回环 Host 放行、127.0.0.1.evil.com / localhost.evil.com 这类前缀伪装拒绝、外部 Origin 拒绝、Origin: null 拒绝、403 响应体不回显。用 node:http 直接构造 Host 头,因为 fetch 不允许设置它。
  • 试听偏移 11 项 —— p=1 与 p=2 必须落在不同文件;负数 / 非数字 / 空 / 溢出成 Infinity / NaN / 缺省一律回落到已布防那一首而不是报错。
  • 曲目两行 25 + 34 + 21 项 —— 轮播与锁定、取消锁定不换歌(两种模式)、清空文件夹同时清锁、文件名上限与头部截断、.card 放开 grid item 的自动最小尺寸、四语包同一套键且没有哪一包在别的包已翻译时还留英文。

全部 164 项断言都放在包外 —— miniapp/ 里放一个 .tmp-* 目录会被一起发布。

反向验证

每个套件都配一份反向脚本:把一份临时副本改回坏的状态,要求套件变红。测不出东西的测试等于没测。

撤掉的东西 被谁抓住
藏起 sounds/ 冒烟 5 条断言 FAIL(sound_file_not_found / 404)
摘掉回环守卫 守卫 9 条断言 FAIL —— 其中一条直接复现攻击面:带 Host: evil.com:49660 的请求拿到了完整 state JSON
把曲目标签的 if (preview) 守卫放宽成 if (true) 4 条 FAIL,红出来的值正是当时报的 chime-bright.wav
换回英文单位 / 「正在播放」 8 条 FAIL
去掉 .card { min-width: 0 }、direction: rtl、宽度上限 5 条 FAIL
去掉「解锁后重对齐轮播」和「清空文件夹清锁」 3 条 FAIL

尚未核对:macOS 与 Linux。 音频路径处理、临时目录解析与页面版式在这两个系统上都没有实机验证过。README 里也写明了。

几个值得注意的实现选择

  • 相对路径每次启动重新解析。 客户端每次都从一个新的临时目录运行包,把解析结果当绝对路径存下来,下次启动就指向一个不存在的目录,/api/sound 会 410,页面只能报成「音频读不出来」—— 听起来像「文件夹没生效」。所以原始输入和解析结果分开存,启动时重解析。
  • 重启时把运行中或暂停的段视为「中断」,不是「完成」。 专注时间只在进程活着的时候累加,所以关一夜电脑绝不会凭空记一段专注。代价是重启会丢掉当前段。
  • 试听是请求级偏移,不改「下一次真实提醒」要播哪一首,所以连按试听不会污染后续播放;但单曲锁定优先级最高,锁定时试听也只播锁定那一首。
  • 响度标准化是双向的(放大 + 缩小),不是只往上拉 —— 只放大不缩小的话,热曲原样不动、安静曲卡在上限,曲目之间还是忽大忽小。做法是量出一首的峰值,当作整个列表的目标音量,此后每一首都对齐到它。这个「基准首」是标准化第一次对你的音频生效时正在播的那一首:刚装上时是第一次播放的那首;如果你手动勾上这个框,就是勾上那一刻正在听的那首 —— 所以想让当前这首当基准,取消再勾一次即可。量出的值钳制在 0.25–0.89,写进 localStorage 跨重启沿用,换文件夹也不重置;近乎无声的文件不会被选为基准。
  • 勾上「单曲循环」会丢弃试听态。 勾选本身改变了「下一首怎么选」,试听给出的答案从此不再描述任何真实的东西;不丢弃的话,那一行会写出一个锁定后试听根本不会再播的文件。取消勾选同样不换歌 —— 轮播游标会被重新对齐到刚才在放的那一首,所以从那里接着往下走。随机模式没有可算的公式,所以在两个播放周期内搜一步能落到该文件的偏移。

许可

MIT,见 LICENSE。

评审清单

  • 包在 plugins/1602winxp/pomodoro-sit-timer/;plugin.json.name 与目录名一致且全仓库唯一
  • 英文 README.md 含字面标题 ## Tested environment 与 ## Data & access;README.zh-CN.md 齐备且双向链接
  • 两份根 README 的应用表格各加一行
  • LICENSE(MIT)已附
  • npm run check 0 错误 0 警告
  • 运行时载荷恰好是 miniapp/client 与 miniapp/node;测试与文档都在 miniapp/ 之外
  • 已装进 MiniMax Code 打开并逐项走通主要功能
  • 未验证行为在 PR 与 README 里都写明

给评审者的一点说明

sounds/chime-*.wav 是示例,不是回落音。 不配路径时,提醒是页面用 Web Audio 接口现场合成的提示音 —— 三个正弦音,G5 / C6 / E6,背后没有任何音频文件。配置的文件读不出来时用的也是它,所以音频被移动或删除都不会让提醒静默。README 和应用内文案都明确写了这一点,因为「内置提示音」听起来像随包文件,而它不是。


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Counts a 1-180 minute work segment, then shows an in-page sit reminder with a
sound. No timer lock and no enforced rest length; press 继续 to start the next
segment when ready.

Reminder sound accepts a single file or a folder, in sequence or shuffled, with
single-track loop and peak loudness matching. Relative paths resolve against the
installed plugin root and are re-resolved at every start, because the client runs
from a fresh temporary copy on each launch.

No framework, no CDN, no build step, no network access, no subprocesses, no
third-party runtime dependencies. npm run check reports OK with no warnings.
Captured in MiniMax Code on Windows at the 463 px panel width, with the session
statistics cleared to zero, so no personal data is committed.
README.md now shows the English-interface screenshots and quotes the interface's own
English labels (Resume, Alert sound, Loop this track, Normalise volume, Preview,
Restore default, Colour mode) instead of the Chinese ones. The Chinese
screenshots move to preview.zh-CN.png / preview-settings.zh-CN.png.

Two localisation fixes found while doing this:
- the English locked-track tooltip contained a leftover Chinese 试听
- the connection label was hardcoded to 已连接 in the HTML, so an English
  interface showed Chinese until the first fetch resolved; it now carries
  data-i18n and a language switch re-applies the remembered connection state
…n-label fix

It still showed 已连接 on an otherwise English interface, so the English README
showed a bug that had already been fixed.
A page on the open internet can resolve its own name to 127.0.0.1 and then fetch
this server; the browser still treats that as same-origin, because an origin
compares the NAME that was typed, not the address it resolved to. A rebound
request therefore arrives carrying the attacker name in Host.

That matters here specifically because /api/sound reads the configured audio and
absolute paths may point anywhere on the machine, so a rebound page could POST an
arbitrary path to /api/settings/sound and read the bytes back from /api/sound.

Both rejections answer a bare 403 with a fixed body and echo nothing the
requester sent. Loopback Host values (127.0.0.1, localhost, the IPv6 loopback)
and the bound host stay allowed, as does a request with no Origin at all.
Three descriptions were loose enough to mislead a reader who has not seen the
implementation:

- Normalise volume said the reference was measured from "the first track you
  played", which invites reading the first entry of the folder. It is whichever
  track is playing the first time normalisation reaches your audio, so ticking
  the box yourself re-picks the one you are listening to at that moment. Also
  spelled out: it is measured once, reused across restarts and across folders,
  and a near-silent file is never chosen as the reference.

- The state.json bullet said only that the write is atomic, which left out that
  saves are also serialised -- something docs/security.md asks for explicitly --
  and where the path comes from. It now names the tmp file, the rename, and that
  the path is taken from context.dataDir alone with nothing written outside it.

- The Files read bullet implied the read set was bounded, while an absolute path
  may point anywhere on the machine. It now points at the Alert sound section,
  which is where that boundary is explained.

All three say the same thing in both READMEs.
…ttons

试听 was a black box: you pressed it and a different file came out, with nothing
on screen saying which. The server now answers /api/sound with an
x-sound-next-track header naming the file the NEXT press will fetch, resolved by
the same currentTrack() call that served the response, so the hint cannot drift
from the real order. The label sits to the left of the button and hides when
there is nothing to walk to: no folder, a single file, or a lock.

应用 and 恢复默认音 are gone from the sound card. Enter in the path box already
commits, and the server treats an empty path and null the same way, so clearing
the field and pressing Enter is exactly what 恢复默认音 did. Verified in the
client at 463 px: Enter still posts /api/settings/sound, and clearing leaves
soundPath, soundInput, soundRelative, soundMode and soundPinned all empty with
/api/sound answering 404 -- no path survives, so nothing can point outside the
plugin.

The field hint carries both instructions in all four languages, since the
buttons were the discoverable part. The dead btn.restoreDefault key is gone; all
four locale blocks still carry 80 keys.
"falls back to the built-in chime" invited the reading that a bundled audio
file sits behind it. There is no such file. playChime() builds three sine
oscillators in the page -- G5 at 783.99 Hz, C6 at 1046.5 Hz, E6 at 1318.51 Hz,
each with a 30 ms attack to 0.22 and an exponential decay, roughly 0.97 s in
total. No disk read, no request off the machine.

The three sounds/chime-*.wav files in this package are samples, not a fallback:
they are only heard if the path box is pointed at them. Both READMEs and the
in-app field hint now say so in all four languages.
Six defects in the alert-sound card, found by driving it in the client's
embedded browser at 463 px.

试听 state now belongs to 试听. Every reconcile also warms the armed
track, and that path ran `auditionName = preview ? served : null`, so a
poll landing a moment after a press wiped the label back to the armed
file. The pin handler reads the label, so locking always locked
chime-bright no matter what was playing. Only a request carrying an
audition offset may write the label or the hint now, and
clearAudition() hands the display back when a real reminder plays or the
sound is re-armed.

The 下一首 row is always on screen. It used to disappear whenever there
was nothing distinct to walk to — a lock, a single file, the
synthesised chime — so the card lost a line and jumped under the
pointer. When the next track is the current one it now reads a dash
rather than repeating a name the line above already shows.

Ticking 单曲循环 is itself a change to how the next track is picked, so
it drops the audition, the same way a new path or a mode switch does.
Before this the row kept the answer computed before the lock existed
and named a file the lock stops 试听 from ever playing.

Releasing a lock no longer changes the song. While a track is pinned
every selection returns that file, so the playlist cursor never moved
and unticking snapped the armed track back to wherever it had been
frozen. rebaseAfterRelease() moves the arming point onto the track
that was playing instead. Shuffle has no arithmetic for this, so the
step is searched over two playlist cycles. Separately, clearing the
folder now clears the lock with it: the pin is a file name, so it used
to survive and return silently with the next folder holding that name.

Long file names are truncated from the front, keeping the extension
readable, and the name may occupy at most the row width minus 7em —
the widest label any pack ships, so label plus name always fit. The
card needed `min-width: 0` for any of that to work: as a grid item its
automatic minimum is its min-content width, so one long file name was
growing the track past the panel and every width above it was computed
against the inflated value.

The two track rows are their own full-width lines, and 试听 shares its
row with the two switches. The current-track label reads 当前曲目
(Current track / 現在の曲 / 현재 곡): 正在播放 only held while something
was audibly playing, which between reminders it is not. The Chinese
pack's unit.h and unit.m were still English, so 累计专注 rendered as
0m.

The 下一首 row is fed by the snapshot as well as by the audition
header, using the same selector, so it is right before the first press
instead of appearing on one.

Screenshots in docs/ are the current UI in both languages; both READMEs
describe the rows, the dash, the confirm key and the lock-release
behaviour.

Verified in the client's embedded browser at 463 px: audition then
lock keeps the auditioned track; the row names what the next press
really fetches; releasing the lock carries on from the same track in
both in-order and shuffle; an 88-character name is shortened from the
front without pushing the card wider. Unverified: macOS and Linux.
@1602WinXP
1602WinXP marked this pull request as ready for review October 4, 2026 20:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant