Conversation
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
marked this pull request as ready for review
October 4, 2026 20:21
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
新增 pomodoro-sit-timer:一个只计时、页内久坐提醒的番茄钟
基线:
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.jsonundercontext.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/pomodoro-sit-timer(全仓库唯一)1.0.0· 许可 MIT主要功能:
无框架、无 CDN、无构建步骤、无任何第三方运行时依赖,离线可用。
截图
四张均在 Windows 的 MiniMax Code 内实机截取,面板宽 463 px,会话统计已清零,不含个人数据。英文 README 配英文界面那两张,中文 README 配中文界面那两张。图中提醒音是内置的
sounds/文件夹,顺序播放、音量标准化已开启。docs/preview.pngdocs/preview.zh-CN.pngdocs/preview-settings.pngdocs/preview-settings.zh-CN.png声音路径的边界(主动说明)
relative(pluginRoot, target)不得以..开头,结果也不得是绝对路径,越界即拒(sound_path_escapes_plugin)。.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,不回显任何请求方内容。数据与访问范围
context.dataDir指向的目录下的state.json,记录时长、阶段、剩余时间、完成段数、累计专注秒数以及你的提醒音设置。路径完全取自context.dataDir,不硬编码、不向上拼接,不写该目录以外的任何位置。落盘是原子的:先写同目录的state.json.tmp,再 rename 覆盖state.json,所以读到的永远是完整一份,不会是写了一半的内容(中途被杀最多留下一个无人引用的.tmp,下次写会被覆盖)。多次落盘经由一条链串行,不会两次写交叉。sounds/。不扫描音乐库,磁盘其他位置一律不碰。{ 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 check55/55 通过;npm run validate对本包报OK(0 错误 0 警告)。sounds/解析成功、返回真实 RIFF/WAVE 字节、页面无任何外部资源标签与外部 URL。用来证明「单独复制即可运行」这句话是真的。pluginRoot真起start(context):页面路由、/api/state、相对路径解析、真实 RIFF/WAVE 字节、state.json内容、start()返回{ dispose }、dispose 真关监听器,以及同一dataDir下二次启动仍能重新解析相对路径并继续供音频。127.0.0.1.evil.com/localhost.evil.com这类前缀伪装拒绝、外部 Origin 拒绝、Origin: null拒绝、403 响应体不回显。用node:http直接构造Host头,因为 fetch 不允许设置它。p=1与p=2必须落在不同文件;负数 / 非数字 / 空 / 溢出成 Infinity / NaN / 缺省一律回落到已布防那一首而不是报错。.card放开 grid item 的自动最小尺寸、四语包同一套键且没有哪一包在别的包已翻译时还留英文。全部 164 项断言都放在包外 ——
miniapp/里放一个.tmp-*目录会被一起发布。反向验证
每个套件都配一份反向脚本:把一份临时副本改回坏的状态,要求套件变红。测不出东西的测试等于没测。
sounds/sound_file_not_found/ 404)Host: evil.com:49660的请求拿到了完整 state JSONif (preview)守卫放宽成if (true)chime-bright.wav.card { min-width: 0 }、direction: rtl、宽度上限尚未核对:macOS 与 Linux。 音频路径处理、临时目录解析与页面版式在这两个系统上都没有实机验证过。README 里也写明了。
几个值得注意的实现选择
/api/sound会 410,页面只能报成「音频读不出来」—— 听起来像「文件夹没生效」。所以原始输入和解析结果分开存,启动时重解析。许可
MIT,见
LICENSE。评审清单
plugins/1602winxp/pomodoro-sit-timer/;plugin.json.name与目录名一致且全仓库唯一README.md含字面标题## Tested environment与## Data & access;README.zh-CN.md齐备且双向链接LICENSE(MIT)已附npm run check0 错误 0 警告miniapp/client与miniapp/node;测试与文档都在miniapp/之外给评审者的一点说明
sounds/chime-*.wav是示例,不是回落音。 不配路径时,提醒是页面用 Web Audio 接口现场合成的提示音 —— 三个正弦音,G5 / C6 / E6,背后没有任何音频文件。配置的文件读不出来时用的也是它,所以音频被移动或删除都不会让提醒静默。README 和应用内文案都明确写了这一点,因为「内置提示音」听起来像随包文件,而它不是。Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.