feat(rest): 添加番茄钟与多屏护眼休息

This commit is contained in:
ysicing committed 2026-09-26 08:14:11 +08:00
1 parent 2832ec5790
commit f86f2d4291
29 files changed
+2331 -12

No files matched your search

+2
View File
@@ -56,6 +56,7 @@ GitHub Actions 只在分支 push 且 `server/**` 发生变化时构建并发布
桌面小组件与主应用通过 `group.work.12306.xstats` App Group 共享只读展示摘要。
用于正式签名的 Apple Developer 团队须为主应用和 Widget 扩展启用同一个 App Group;
小组件不读取 AI 登录凭据,也不自行请求额度或 IP 服务。日历节假日与黄历由主应用预计算,Widget 只读共享摘要;主应用未生成数据时,小组件显示空状态。
番茄钟与护眼休息为默认关闭的本机功能:通用设置只显示模块开关,时长、目标与声音由独立设置弹层中的系统下拉菜单调整;启用后手动开始,可暂停、重置当前阶段或跳过;切换阶段时保留各自剩余时间;有未完成进度时用“继续”恢复,点“开始”从完整时长启动,点“重置”恢复完整时长但保持暂停;跳过或自动进入下一阶段也从完整时长开始;单次模式在一轮专注与休息后暂停,循环模式连续运行,每完成四轮专注进入长休。每日完成数只保存在本机偏好中,不加入设置备份。`RestSession` 用 `mach_continuous_time` 的绝对截止点计算阶段,唤醒后重算而非累加定时器 tick;多屏幕布、独立菜单栏计时入口与 Mini HUD 由主应用创建;HUD 可拖动并保存位置,支持数字、圆环和沙漏样式,Esc 或长按可以跳过,休息时可再专注五分钟。白噪音、粉红噪音和雨声由 `AVAudioSourceNode` 实时合成,仅在休息阶段播放。休息 Widget 从 App Group 读取阶段和预计截止时间,实际刷新受 WidgetKit 管理,不能作为秒级提醒来源。
主应用与扩展的 Bundle ID 分别是 `work.12306.xstats.app` 和 `work.12306.xstats.app.widget`;
带 App Group 能力的 Developer ID provisioning profile 要分别安装,并通过
`XSTATS_APP_PROFILE`、`XSTATS_WIDGET_PROFILE` 指定 profile 名称后再签名构建。
@@ -175,6 +176,7 @@ git push
- `Packages/XStatsKit/Sources/WebDAVSync`:WebDAV 同步和钥匙串密码存储。
- `Packages/XStatsKit/Sources/WidgetData`:主应用与桌面小组件共享的无凭据展示摘要;AI 额度沿用模块总开关,本地用量开关单独传给 Widget,旧摘要缺少新字段时沿用原有总开关。
- `Packages/XStatsKit/Sources/XStatsUI`:界面、设置、弹窗和菜单栏渲染。
- `Packages/XStatsKit/Sources/XStatsUI/Rest`:本机休息计时、幕布、Mini HUD 与合成声音。
- `Packages/XStatsKit/Tests`:Swift 单元测试。
更深入的模块边界、采样策略和安全约束见 [ARCHITECTURE.md](ARCHITECTURE.md)。
@@ -1438,3 +1438,58 @@ Sub2API 账号平台与所选来源不匹配 منصة حساب Sub2API لا تط
查看今天的节日与黄历。 اعرض مناسبات اليوم والتقويم التقليدي.
明天上班吗 هل تعمل غدًا؟
查看明天是否上班或放假。 تحقق مما إذا كان الغد يوم عمل أم عطلة.
护眼休息 استراحة العينين
按时显示多屏柔和幕布;Esc 或长按随时跳过。全部计时留在本机。 اعرض ستارًا لطيفًا على جميع الشاشات عند موعد الاستراحة. اضغط Esc أو اضغط مطولًا للتخطي في أي وقت. يبقى المؤقت على هذا الـ Mac.
工作时长 مدة العمل
休息时长 مدة الاستراحة
迷你 HUD واجهة مصغرة
在屏幕边缘显示当前计时 إظهار المؤقت عند حافة الشاشة
显示或收起 إظهار أو إخفاء
休息声音 أصوات الاستراحة
仅在休息时本地实时合成 تُنشأ محليًا في الوقت الفعلي أثناء الاستراحة
白噪音 ضجيج أبيض
粉红噪音 ضجيج وردي
雨声 صوت المطر
让眼睛休息一下 أرح عينيك قليلًا
看看远处,轻轻眨眼,放松肩颈 انظر بعيدًا وارمش بلطف وأرخِ كتفيك
长按跳过 · 或按 Esc 随时返回 اضغط مطولًا للتخطي · أو اضغط Esc للعودة
休息中 في استراحة
下次休息 الاستراحة التالية
跳过休息 تخطي الاستراحة
重新计时 إعادة تشغيل المؤقت
收起迷你 HUD إخفاء الواجهة المصغرة
护眼休息未开启 استراحة العينين معطلة
查看下一次休息或当前休息倒计时。 اعرض العد التنازلي للاستراحة التالية أو نهاية الاستراحة الحالية.
收起到迷你 HUD طي إلى الواجهة المصغرة
番茄钟 مؤقت بومودورو
番茄钟与护眼休息 بومودورو واستراحة العينين
专注计时、每日目标与多屏休息幕布;所有数据留在本机。 مؤقت للتركيز وأهداف يومية وشاشة استراحة على جميع الشاشات. تبقى جميع البيانات على هذا الـ Mac.
专注 تركيز
短休 استراحة قصيرة
长休 استراحة طويلة
单次 مرة واحدة
循环 تكرار
计时中 جارٍ
已暂停 متوقف مؤقتًا
暂停 إيقاف مؤقت
开始 بدء
重置 إعادة ضبط
跳过 تخطي
今日目标 هدف اليوم
长休时长 مدة الاستراحة الطويلة
每完成 4 轮专注后进入长休 تبدأ استراحة طويلة بعد أربع جلسات تركيز
每日目标 الهدف اليومي
{} 轮 {} جلسات
打开番茄钟 فتح بومودورو
再专注 5 分钟 التركيز 5 دقائق إضافية
专注中 جارٍ التركيز
工作时长(分钟) مدة العمل (بالدقائق)
休息时长(分钟) مدة الاستراحة (بالدقائق)
长休时长(分钟) مدة الاستراحة الطويلة (بالدقائق)
迷你 HUD 样式 نمط الواجهة المصغرة
数字倒计时 عد تنازلي رقمي
进度圆环 حلقة التقدم
沙漏 ساعة رملية
切换迷你 HUD 样式 تبديل نمط الواجهة المصغرة
拖动迷你 HUD سحب الواجهة المصغرة
继续 متابعة
1 # Copyright (C) 2026 ysicing
1438 查看今天的节日与黄历。
1439 明天上班吗
1440 查看明天是否上班或放假。
1441 护眼休息
1442 按时显示多屏柔和幕布;Esc 或长按随时跳过。全部计时留在本机。
1443 工作时长
1444 休息时长
1445 迷你 HUD
1446 在屏幕边缘显示当前计时
1447 显示或收起
1448 休息声音
1449 仅在休息时本地实时合成
1450 白噪音
1451 粉红噪音
1452 雨声
1453 让眼睛休息一下
1454 看看远处,轻轻眨眼,放松肩颈
1455 长按跳过 · 或按 Esc 随时返回
1456 休息中
1457 下次休息
1458 跳过休息
1459 重新计时
1460 收起迷你 HUD
1461 护眼休息未开启
1462 查看下一次休息或当前休息倒计时。
1463 收起到迷你 HUD
1464 番茄钟
1465 番茄钟与护眼休息
1466 专注计时、每日目标与多屏休息幕布;所有数据留在本机。
1467 专注
1468 短休
1469 长休
1470 单次
1471 循环
1472 计时中
1473 已暂停
1474 暂停
1475 开始
1476 重置
1477 跳过
1478 今日目标
1479 长休时长
1480 每完成 4 轮专注后进入长休
1481 每日目标
1482 {} 轮
1483 打开番茄钟
1484 再专注 5 分钟
1485 专注中
1486 工作时长(分钟)
1487 休息时长(分钟)
1488 长休时长(分钟)
1489 迷你 HUD 样式
1490 数字倒计时
1491 进度圆环
1492 沙漏
1493 切换迷你 HUD 样式
1494 拖动迷你 HUD
1495 继续
@@ -1438,3 +1438,58 @@ Sub2API 账号平台与所选来源不匹配 Die Plattform des Sub2API-Kontos st
查看今天的节日与黄历。 Heutige Feiertage und Almanach anzeigen.
明天上班吗 Morgen arbeiten?
查看明天是否上班或放假。 Zeigt, ob morgen ein Arbeits- oder freier Tag ist.
护眼休息 Augenpause
按时显示多屏柔和幕布;Esc 或长按随时跳过。全部计时留在本机。 Zur Pausenzeit auf allen Bildschirmen eine sanfte Einblendung anzeigen. Mit Esc oder langem Drücken jederzeit überspringen. Der Timer bleibt auf diesem Mac.
工作时长 Arbeitsintervall
休息时长 Pausendauer
迷你 HUD Mini-HUD
在屏幕边缘显示当前计时 Timer am Bildschirmrand anzeigen
显示或收起 Ein- oder ausblenden
休息声音 Pausengeräusche
仅在休息时本地实时合成 Während Pausen lokal in Echtzeit erzeugt
白噪音 Weißes Rauschen
粉红噪音 Rosa Rauschen
雨声 Regen
让眼睛休息一下 Gönnen Sie Ihren Augen eine Pause
看看远处,轻轻眨眼,放松肩颈 Schauen Sie in die Ferne, blinzeln Sie sanft und entspannen Sie die Schultern
长按跳过 · 或按 Esc 随时返回 Zum Überspringen gedrückt halten · oder Esc drücken
休息中 Pause
下次休息 Nächste Pause
跳过休息 Pause überspringen
重新计时 Timer neu starten
收起迷你 HUD Mini-HUD ausblenden
护眼休息未开启 Augenpausen sind deaktiviert
查看下一次休息或当前休息倒计时。 Countdown bis zur nächsten Pause oder bis zum Ende der aktuellen Pause anzeigen.
收起到迷你 HUD Zum Mini-HUD verkleinern
番茄钟 Pomodoro-Timer
番茄钟与护眼休息 Pomodoro und Augenpausen
专注计时、每日目标与多屏休息幕布;所有数据留在本机。 Fokustimer, Tagesziele und Pausenansicht auf allen Bildschirmen. Alle Daten bleiben auf diesem Mac.
专注 Fokus
短休 Kurze Pause
长休 Lange Pause
单次 Einzeln
循环 Zyklus
计时中 Läuft
已暂停 Pausiert
暂停 Pause
开始 Start
重置 Zurücksetzen
跳过 Überspringen
今日目标 Heutiges Ziel
长休时长 Dauer der langen Pause
每完成 4 轮专注后进入长休 Nach vier Fokusphasen folgt eine lange Pause
每日目标 Tagesziel
{} 轮 {} Runden
打开番茄钟 Pomodoro öffnen
再专注 5 分钟 5 Minuten länger fokussieren
专注中 Im Fokus
工作时长(分钟) Arbeitsintervall (Minuten)
休息时长(分钟) Pausendauer (Minuten)
长休时长(分钟) Lange Pause (Minuten)
迷你 HUD 样式 Mini-HUD-Stil
数字倒计时 Digitale Restzeit
进度圆环 Fortschrittsring
沙漏 Sanduhr
切换迷你 HUD 样式 Mini-HUD-Stil wechseln
拖动迷你 HUD Mini-HUD ziehen
继续 Fortsetzen
1 # Copyright (C) 2026 ysicing
1438 查看今天的节日与黄历。
1439 明天上班吗
1440 查看明天是否上班或放假。
1441 护眼休息
1442 按时显示多屏柔和幕布;Esc 或长按随时跳过。全部计时留在本机。
1443 工作时长
1444 休息时长
1445 迷你 HUD
1446 在屏幕边缘显示当前计时
1447 显示或收起
1448 休息声音
1449 仅在休息时本地实时合成
1450 白噪音
1451 粉红噪音
1452 雨声
1453 让眼睛休息一下
1454 看看远处,轻轻眨眼,放松肩颈
1455 长按跳过 · 或按 Esc 随时返回
1456 休息中
1457 下次休息
1458 跳过休息
1459 重新计时
1460 收起迷你 HUD
1461 护眼休息未开启
1462 查看下一次休息或当前休息倒计时。
1463 收起到迷你 HUD
1464 番茄钟
1465 番茄钟与护眼休息
1466 专注计时、每日目标与多屏休息幕布;所有数据留在本机。
1467 专注
1468 短休
1469 长休
1470 单次
1471 循环
1472 计时中
1473 已暂停
1474 暂停
1475 开始
1476 重置
1477 跳过
1478 今日目标
1479 长休时长
1480 每完成 4 轮专注后进入长休
1481 每日目标
1482 {} 轮
1483 打开番茄钟
1484 再专注 5 分钟
1485 专注中
1486 工作时长(分钟)
1487 休息时长(分钟)
1488 长休时长(分钟)
1489 迷你 HUD 样式
1490 数字倒计时
1491 进度圆环
1492 沙漏
1493 切换迷你 HUD 样式
1494 拖动迷你 HUD
1495 继续
@@ -1438,3 +1438,58 @@ Sub2API 账号平台与所选来源不匹配 La plataforma de la cuenta de Sub2A
查看今天的节日与黄历。 Consulta las festividades y el almanaque de hoy.
明天上班吗 ¿Trabajas mañana?
查看明天是否上班或放假。 Consulta si mañana es laborable o festivo.
护眼休息 Descansos visuales
按时显示多屏柔和幕布;Esc 或长按随时跳过。全部计时留在本机。 Muestra una pantalla suave en todos los monitores al llegar la pausa. Pulsa Esc o mantén pulsado para omitirla. El temporizador permanece en este Mac.
工作时长 Intervalo de trabajo
休息时长 Duración del descanso
迷你 HUD Mini HUD
在屏幕边缘显示当前计时 Mostrar el temporizador en el borde de la pantalla
显示或收起 Mostrar u ocultar
休息声音 Sonidos de descanso
仅在休息时本地实时合成 Generados localmente en tiempo real durante los descansos
白噪音 Ruido blanco
粉红噪音 Ruido rosa
雨声 Lluvia
让眼睛休息一下 Descansa la vista
看看远处,轻轻眨眼,放松肩颈 Mira a lo lejos, parpadea suavemente y relaja los hombros
长按跳过 · 或按 Esc 随时返回 Mantén pulsado para omitir · o pulsa Esc para volver
休息中 En descanso
下次休息 Próximo descanso
跳过休息 Omitir descanso
重新计时 Reiniciar temporizador
收起迷你 HUD Ocultar Mini HUD
护眼休息未开启 Los descansos visuales están desactivados
查看下一次休息或当前休息倒计时。 Consulta la cuenta atrás hasta el próximo descanso o el final del actual.
收起到迷你 HUD Contraer al Mini HUD
番茄钟 Temporizador Pomodoro
番茄钟与护眼休息 Pomodoro y descansos visuales
专注计时、每日目标与多屏休息幕布;所有数据留在本机。 Temporizador de concentración, objetivos diarios y pantalla de descanso en todos los monitores. Los datos permanecen en este Mac.
专注 Concentración
短休 Descanso corto
长休 Descanso largo
单次 Una vez
循环 Ciclo
计时中 En marcha
已暂停 En pausa
暂停 Pausar
开始 Iniciar
重置 Reiniciar
跳过 Omitir
今日目标 Objetivo de hoy
长休时长 Duración del descanso largo
每完成 4 轮专注后进入长休 Después de cuatro sesiones de concentración llega un descanso largo
每日目标 Objetivo diario
{} 轮 {} sesiones
打开番茄钟 Abrir Pomodoro
再专注 5 分钟 Concentrarse 5 minutos más
专注中 Concentrándose
工作时长(分钟) Intervalo de trabajo (minutos)
休息时长(分钟) Duración del descanso (minutos)
长休时长(分钟) Descanso largo (minutos)
迷你 HUD 样式 Estilo de Mini HUD
数字倒计时 Cuenta atrás digital
进度圆环 Anillo de progreso
沙漏 Reloj de arena
切换迷你 HUD 样式 Cambiar estilo de Mini HUD
拖动迷你 HUD Arrastrar Mini HUD
继续 Continuar
1 # Copyright (C) 2026 ysicing
1438 查看今天的节日与黄历。
1439 明天上班吗
1440 查看明天是否上班或放假。
1441 护眼休息
1442 按时显示多屏柔和幕布;Esc 或长按随时跳过。全部计时留在本机。
1443 工作时长
1444 休息时长
1445 迷你 HUD
1446 在屏幕边缘显示当前计时
1447 显示或收起
1448 休息声音
1449 仅在休息时本地实时合成
1450 白噪音
1451 粉红噪音
1452 雨声
1453 让眼睛休息一下
1454 看看远处,轻轻眨眼,放松肩颈
1455 长按跳过 · 或按 Esc 随时返回
1456 休息中
1457 下次休息
1458 跳过休息
1459 重新计时
1460 收起迷你 HUD
1461 护眼休息未开启
1462 查看下一次休息或当前休息倒计时。
1463 收起到迷你 HUD
1464 番茄钟
1465 番茄钟与护眼休息
1466 专注计时、每日目标与多屏休息幕布;所有数据留在本机。
1467 专注
1468 短休
1469 长休
1470 单次
1471 循环
1472 计时中
1473 已暂停
1474 暂停
1475 开始
1476 重置
1477 跳过
1478 今日目标
1479 长休时长
1480 每完成 4 轮专注后进入长休
1481 每日目标
1482 {} 轮
1483 打开番茄钟
1484 再专注 5 分钟
1485 专注中
1486 工作时长(分钟)
1487 休息时长(分钟)
1488 长休时长(分钟)
1489 迷你 HUD 样式
1490 数字倒计时
1491 进度圆环
1492 沙漏
1493 切换迷你 HUD 样式
1494 拖动迷你 HUD
1495 继续
@@ -1438,3 +1438,58 @@ Sub2API 账号平台与所选来源不匹配 La plateforme du compte Sub2API ne
查看今天的节日与黄历。 Consultez les fêtes et l'almanach du jour.
明天上班吗 Travail demain ?
查看明天是否上班或放假。 Vérifiez si demain est un jour travaillé ou chômé.
护眼休息 Pauses visuelles
按时显示多屏柔和幕布;Esc 或长按随时跳过。全部计时留在本机。 Affiche un voile doux sur tous les écrans à l'heure de la pause. Appuyez sur Esc ou maintenez enfoncé pour passer. Le minuteur reste sur ce Mac.
工作时长 Période de travail
休息时长 Durée de la pause
迷你 HUD Mini HUD
在屏幕边缘显示当前计时 Afficher le minuteur au bord de l'écran
显示或收起 Afficher ou masquer
休息声音 Sons de pause
仅在休息时本地实时合成 Générés localement en temps réel pendant les pauses
白噪音 Bruit blanc
粉红噪音 Bruit rose
雨声 Pluie
让眼睛休息一下 Reposez vos yeux
看看远处,轻轻眨眼,放松肩颈 Regardez au loin, clignez doucement des yeux et détendez vos épaules
长按跳过 · 或按 Esc 随时返回 Maintenez pour passer · ou appuyez sur Esc pour revenir
休息中 En pause
下次休息 Prochaine pause
跳过休息 Passer la pause
重新计时 Redémarrer le minuteur
收起迷你 HUD Masquer le Mini HUD
护眼休息未开启 Les pauses visuelles sont désactivées
查看下一次休息或当前休息倒计时。 Voir le compte à rebours avant la prochaine pause ou la fin de celle-ci.
收起到迷你 HUD Réduire en Mini HUD
番茄钟 Minuteur Pomodoro
番茄钟与护眼休息 Pomodoro et pauses visuelles
专注计时、每日目标与多屏休息幕布;所有数据留在本机。 Minuteur de concentration, objectifs quotidiens et écran de pause sur tous les moniteurs. Les données restent sur ce Mac.
专注 Concentration
短休 Pause courte
长休 Pause longue
单次 Une fois
循环 Cycle
计时中 En cours
已暂停 En pause
暂停 Pause
开始 Démarrer
重置 Réinitialiser
跳过 Passer
今日目标 Objectif du jour
长休时长 Durée de la pause longue
每完成 4 轮专注后进入长休 Une pause longue suit quatre sessions de concentration
每日目标 Objectif quotidien
{} 轮 {} sessions
打开番茄钟 Ouvrir Pomodoro
再专注 5 分钟 Se concentrer 5 minutes de plus
专注中 Concentration en cours
工作时长(分钟) Période de travail (minutes)
休息时长(分钟) Durée de la pause (minutes)
长休时长(分钟) Pause longue (minutes)
迷你 HUD 样式 Style du Mini HUD
数字倒计时 Compte à rebours numérique
进度圆环 Anneau de progression
沙漏 Sablier
切换迷你 HUD 样式 Changer le style du Mini HUD
拖动迷你 HUD Faire glisser le Mini HUD
继续 Reprendre
1 # Copyright (C) 2026 ysicing
1438 查看今天的节日与黄历。
1439 明天上班吗
1440 查看明天是否上班或放假。
1441 护眼休息
1442 按时显示多屏柔和幕布;Esc 或长按随时跳过。全部计时留在本机。
1443 工作时长
1444 休息时长
1445 迷你 HUD
1446 在屏幕边缘显示当前计时
1447 显示或收起
1448 休息声音
1449 仅在休息时本地实时合成
1450 白噪音
1451 粉红噪音
1452 雨声
1453 让眼睛休息一下
1454 看看远处,轻轻眨眼,放松肩颈
1455 长按跳过 · 或按 Esc 随时返回
1456 休息中
1457 下次休息
1458 跳过休息
1459 重新计时
1460 收起迷你 HUD
1461 护眼休息未开启
1462 查看下一次休息或当前休息倒计时。
1463 收起到迷你 HUD
1464 番茄钟
1465 番茄钟与护眼休息
1466 专注计时、每日目标与多屏休息幕布;所有数据留在本机。
1467 专注
1468 短休
1469 长休
1470 单次
1471 循环
1472 计时中
1473 已暂停
1474 暂停
1475 开始
1476 重置
1477 跳过
1478 今日目标
1479 长休时长
1480 每完成 4 轮专注后进入长休
1481 每日目标
1482 {} 轮
1483 打开番茄钟
1484 再专注 5 分钟
1485 专注中
1486 工作时长(分钟)
1487 休息时长(分钟)
1488 长休时长(分钟)
1489 迷你 HUD 样式
1490 数字倒计时
1491 进度圆环
1492 沙漏
1493 切换迷你 HUD 样式
1494 拖动迷你 HUD
1495 继续
@@ -1438,3 +1438,58 @@ Sub2API 账号平台与所选来源不匹配 Sub2API アカウントのプラッ
查看今天的节日与黄历。 今日の祝祭日と暦注を表示します。
明天上班吗 明日は出勤?
查看明天是否上班或放假。 明日が勤務日か休日かを確認します。
护眼休息 目の休憩
按时显示多屏柔和幕布;Esc 或长按随时跳过。全部计时留在本机。 休憩時に全画面へ穏やかなオーバーレイを表示。Esc キーまたは長押しでいつでもスキップできます。タイマーはこの Mac 内で動作します。
工作时长 作業時間
休息时长 休憩時間
迷你 HUD ミニ HUD
在屏幕边缘显示当前计时 画面の端にタイマーを表示
显示或收起 表示/非表示
休息声音 休憩中の音
仅在休息时本地实时合成 休憩中に Mac 上でリアルタイム生成
白噪音 ホワイトノイズ
粉红噪音 ピンクノイズ
雨声 雨音
让眼睛休息一下 目を休めましょう
看看远处,轻轻眨眼,放松肩颈 遠くを見て、やさしくまばたきし、肩の力を抜きましょう
长按跳过 · 或按 Esc 随时返回 長押しでスキップ · Esc キーでも戻れます
休息中 休憩中
下次休息 次の休憩
跳过休息 休憩をスキップ
重新计时 タイマーを再開
收起迷你 HUD ミニ HUD を隠す
护眼休息未开启 目の休憩はオフです
查看下一次休息或当前休息倒计时。 次の休憩または現在の休憩の残り時間を表示します。
收起到迷你 HUD ミニ HUD に折りたたむ
番茄钟 ポモドーロタイマー
番茄钟与护眼休息 ポモドーロと目の休憩
专注计时、每日目标与多屏休息幕布;所有数据留在本机。 集中タイマー、毎日の目標、全画面の休憩オーバーレイ。データはこの Mac 内に保存されます。
专注 集中
短休 短い休憩
长休 長い休憩
单次 1 回
循环 繰り返し
计时中 計測中
已暂停 一時停止中
暂停 一時停止
开始 開始
重置 リセット
跳过 スキップ
今日目标 今日の目標
长休时长 長い休憩の時間
每完成 4 轮专注后进入长休 集中を 4 回終えると長い休憩に入ります
每日目标 1 日の目標
{} 轮 {} 回
打开番茄钟 ポモドーロを開く
再专注 5 分钟 あと 5 分集中する
专注中 集中中
工作时长(分钟) 作業時間(分)
休息时长(分钟) 休憩時間(分)
长休时长(分钟) 長い休憩の時間(分)
迷你 HUD 样式 ミニ HUD の表示
数字倒计时 数字カウントダウン
进度圆环 進捗リング
沙漏 砂時計
切换迷你 HUD 样式 ミニ HUD の表示を切り替え
拖动迷你 HUD ミニ HUD をドラッグ
继续 続ける
1 # Copyright (C) 2026 ysicing
1438 查看今天的节日与黄历。
1439 明天上班吗
1440 查看明天是否上班或放假。
1441 护眼休息
1442 按时显示多屏柔和幕布;Esc 或长按随时跳过。全部计时留在本机。
1443 工作时长
1444 休息时长
1445 迷你 HUD
1446 在屏幕边缘显示当前计时
1447 显示或收起
1448 休息声音
1449 仅在休息时本地实时合成
1450 白噪音
1451 粉红噪音
1452 雨声
1453 让眼睛休息一下
1454 看看远处,轻轻眨眼,放松肩颈
1455 长按跳过 · 或按 Esc 随时返回
1456 休息中
1457 下次休息
1458 跳过休息
1459 重新计时
1460 收起迷你 HUD
1461 护眼休息未开启
1462 查看下一次休息或当前休息倒计时。
1463 收起到迷你 HUD
1464 番茄钟
1465 番茄钟与护眼休息
1466 专注计时、每日目标与多屏休息幕布;所有数据留在本机。
1467 专注
1468 短休
1469 长休
1470 单次
1471 循环
1472 计时中
1473 已暂停
1474 暂停
1475 开始
1476 重置
1477 跳过
1478 今日目标
1479 长休时长
1480 每完成 4 轮专注后进入长休
1481 每日目标
1482 {} 轮
1483 打开番茄钟
1484 再专注 5 分钟
1485 专注中
1486 工作时长(分钟)
1487 休息时长(分钟)
1488 长休时长(分钟)
1489 迷你 HUD 样式
1490 数字倒计时
1491 进度圆环
1492 沙漏
1493 切换迷你 HUD 样式
1494 拖动迷你 HUD
1495 继续
@@ -1438,3 +1438,58 @@ Sub2API 账号平台与所选来源不匹配 Sub2API 계정 플랫폼이 선택
查看今天的节日与黄历。 오늘의 명절과 황력을 확인합니다.
明天上班吗 내일 출근하나요?
查看明天是否上班或放假。 내일이 근무일인지 휴일인지 확인합니다.
护眼休息 눈 휴식
按时显示多屏柔和幕布;Esc 或长按随时跳过。全部计时留在本机。 휴식 시간이 되면 모든 화면에 부드러운 오버레이를 표시합니다. Esc 또는 길게 눌러 언제든 건너뛸 수 있습니다. 타이머는 이 Mac에서만 실행됩니다.
工作时长 작업 시간
休息时长 휴식 시간
迷你 HUD 미니 HUD
在屏幕边缘显示当前计时 화면 가장자리에 타이머 표시
显示或收起 표시 또는 숨기기
休息声音 휴식 소리
仅在休息时本地实时合成 휴식 중 기기에서 실시간 합성
白噪音 백색 소음
粉红噪音 핑크 노이즈
雨声 빗소리
让眼睛休息一下 눈을 잠시 쉬게 하세요
看看远处,轻轻眨眼,放松肩颈 먼 곳을 바라보고 천천히 눈을 깜빡이며 어깨를 풀어 주세요
长按跳过 · 或按 Esc 随时返回 길게 눌러 건너뛰기 · Esc로 언제든 돌아가기
休息中 휴식 중
下次休息 다음 휴식
跳过休息 휴식 건너뛰기
重新计时 타이머 다시 시작
收起迷你 HUD 미니 HUD 숨기기
护眼休息未开启 눈 휴식이 꺼져 있습니다
查看下一次休息或当前休息倒计时。 다음 휴식 또는 현재 휴식의 남은 시간을 확인하세요.
收起到迷你 HUD 미니 HUD로 접기
番茄钟 뽀모도로 타이머
番茄钟与护眼休息 뽀모도로와 눈 휴식
专注计时、每日目标与多屏休息幕布;所有数据留在本机。 집중 타이머, 일일 목표, 모든 화면의 휴식 오버레이. 데이터는 이 Mac에만 저장됩니다.
专注 집중
短休 짧은 휴식
长休 긴 휴식
单次 한 번
循环 반복
计时中 진행 중
已暂停 일시 정지됨
暂停 일시 정지
开始 시작
重置 재설정
跳过 건너뛰기
今日目标 오늘의 목표
长休时长 긴 휴식 시간
每完成 4 轮专注后进入长休 집중 4회를 마칠 때마다 긴 휴식이 시작됩니다
每日目标 일일 목표
{} 轮 {}회
打开番茄钟 뽀모도로 열기
再专注 5 分钟 5분 더 집중하기
专注中 집중 중
工作时长(分钟) 작업 시간(분)
休息时长(分钟) 휴식 시간(분)
长休时长(分钟) 긴 휴식 시간(분)
迷你 HUD 样式 미니 HUD 스타일
数字倒计时 숫자 카운트다운
进度圆环 진행률 원형 표시
沙漏 모래시계
切换迷你 HUD 样式 미니 HUD 스타일 전환
拖动迷你 HUD 미니 HUD 드래그
继续 계속
1 # Copyright (C) 2026 ysicing
1438 查看今天的节日与黄历。
1439 明天上班吗
1440 查看明天是否上班或放假。
1441 护眼休息
1442 按时显示多屏柔和幕布;Esc 或长按随时跳过。全部计时留在本机。
1443 工作时长
1444 休息时长
1445 迷你 HUD
1446 在屏幕边缘显示当前计时
1447 显示或收起
1448 休息声音
1449 仅在休息时本地实时合成
1450 白噪音
1451 粉红噪音
1452 雨声
1453 让眼睛休息一下
1454 看看远处,轻轻眨眼,放松肩颈
1455 长按跳过 · 或按 Esc 随时返回
1456 休息中
1457 下次休息
1458 跳过休息
1459 重新计时
1460 收起迷你 HUD
1461 护眼休息未开启
1462 查看下一次休息或当前休息倒计时。
1463 收起到迷你 HUD
1464 番茄钟
1465 番茄钟与护眼休息
1466 专注计时、每日目标与多屏休息幕布;所有数据留在本机。
1467 专注
1468 短休
1469 长休
1470 单次
1471 循环
1472 计时中
1473 已暂停
1474 暂停
1475 开始
1476 重置
1477 跳过
1478 今日目标
1479 长休时长
1480 每完成 4 轮专注后进入长休
1481 每日目标
1482 {} 轮
1483 打开番茄钟
1484 再专注 5 分钟
1485 专注中
1486 工作时长(分钟)
1487 休息时长(分钟)
1488 长休时长(分钟)
1489 迷你 HUD 样式
1490 数字倒计时
1491 进度圆环
1492 沙漏
1493 切换迷你 HUD 样式
1494 拖动迷你 HUD
1495 继续
@@ -1439,3 +1439,58 @@ Sub2API 账号平台与所选来源不匹配 Sub2API 帳號平台與所選來源
查看今天的节日与黄历。 查看今天的節日與黃曆。
明天上班吗 明天要上班嗎
查看明天是否上班或放假。 查看明天是否上班或放假。
护眼休息 護眼休息
按时显示多屏柔和幕布;Esc 或长按随时跳过。全部计时留在本机。 按時在所有螢幕顯示柔和幕布;按 Esc 或長按隨時跳過。計時完全留在本機。
工作时长 工作時長
休息时长 休息時長
迷你 HUD 迷你 HUD
在屏幕边缘显示当前计时 在螢幕邊緣顯示目前計時
显示或收起 顯示或收起
休息声音 休息聲音
仅在休息时本地实时合成 僅在休息時於本機即時合成
白噪音 白噪音
粉红噪音 粉紅噪音
雨声 雨聲
让眼睛休息一下 讓眼睛休息一下
看看远处,轻轻眨眼,放松肩颈 看看遠方,輕輕眨眼,放鬆肩頸
长按跳过 · 或按 Esc 随时返回 長按跳過 · 或按 Esc 隨時返回
休息中 休息中
下次休息 下次休息
跳过休息 跳過休息
重新计时 重新計時
收起迷你 HUD 收起迷你 HUD
护眼休息未开启 護眼休息未開啟
查看下一次休息或当前休息倒计时。 查看下次休息或目前休息的倒數計時。
收起到迷你 HUD 收起到迷你 HUD
番茄钟 番茄鐘
番茄钟与护眼休息 番茄鐘與護眼休息
专注计时、每日目标与多屏休息幕布;所有数据留在本机。 專注計時、每日目標與多螢幕休息幕布;所有資料都留在本機。
专注 專注
短休 短休
长休 長休
单次 單次
循环 循環
计时中 計時中
已暂停 已暫停
暂停 暫停
开始 開始
重置 重設
跳过 跳過
今日目标 今日目標
长休时长 長休時長
每完成 4 轮专注后进入长休 每完成 4 輪專注後進入長休
每日目标 每日目標
{} 轮 {} 輪
打开番茄钟 開啟番茄鐘
再专注 5 分钟 再專注 5 分鐘
专注中 專注中
工作时长(分钟) 工作時長(分鐘)
休息时长(分钟) 休息時長(分鐘)
长休时长(分钟) 長休時長(分鐘)
迷你 HUD 样式 迷你 HUD 樣式
数字倒计时 數字倒數
进度圆环 進度圓環
沙漏 沙漏
切换迷你 HUD 样式 切換迷你 HUD 樣式
拖动迷你 HUD 拖曳迷你 HUD
继续 繼續
1 # Copyright (c) 2026 GiantAccel, LLC
1439 查看今天的节日与黄历。
1440 明天上班吗
1441 查看明天是否上班或放假。
1442 护眼休息
1443 按时显示多屏柔和幕布;Esc 或长按随时跳过。全部计时留在本机。
1444 工作时长
1445 休息时长
1446 迷你 HUD
1447 在屏幕边缘显示当前计时
1448 显示或收起
1449 休息声音
1450 仅在休息时本地实时合成
1451 白噪音
1452 粉红噪音
1453 雨声
1454 让眼睛休息一下
1455 看看远处,轻轻眨眼,放松肩颈
1456 长按跳过 · 或按 Esc 随时返回
1457 休息中
1458 下次休息
1459 跳过休息
1460 重新计时
1461 收起迷你 HUD
1462 护眼休息未开启
1463 查看下一次休息或当前休息倒计时。
1464 收起到迷你 HUD
1465 番茄钟
1466 番茄钟与护眼休息
1467 专注计时、每日目标与多屏休息幕布;所有数据留在本机。
1468 专注
1469 短休
1470 长休
1471 单次
1472 循环
1473 计时中
1474 已暂停
1475 暂停
1476 开始
1477 重置
1478 跳过
1479 今日目标
1480 长休时长
1481 每完成 4 轮专注后进入长休
1482 每日目标
1483 {} 轮
1484 打开番茄钟
1485 再专注 5 分钟
1486 专注中
1487 工作时长(分钟)
1488 休息时长(分钟)
1489 长休时长(分钟)
1490 迷你 HUD 样式
1491 数字倒计时
1492 进度圆环
1493 沙漏
1494 切换迷你 HUD 样式
1495 拖动迷你 HUD
1496 继续
@@ -8,9 +8,67 @@
extension Translations {
static var tables: [String] {
[english, webDAVEnglish, languagePickerEnglish, calendarEnglish]
[english, webDAVEnglish, languagePickerEnglish, calendarEnglish, restEnglish]
}
static let restEnglish = #"""
护眼休息 Eye breaks
按时显示多屏柔和幕布;Esc 或长按随时跳过。全部计时留在本机。 Show a gentle break screen on every display. Press Esc or hold to skip. Timers stay on this Mac.
工作时长 Work interval
工作时长(分钟) Work interval (minutes)
休息时长 Break duration
休息时长(分钟) Break duration (minutes)
迷你 HUD Mini HUD
迷你 HUD 样式 Mini HUD style
数字倒计时 Digital countdown
进度圆环 Progress ring
沙漏 Hourglass
切换迷你 HUD 样式 Switch Mini HUD style
拖动迷你 HUD Drag Mini HUD
在屏幕边缘显示当前计时 Show the timer at the edge of the screen
显示或收起 Show or hide
休息声音 Break sounds
仅在休息时本地实时合成 Generated locally during breaks
白噪音 White noise
粉红噪音 Pink noise
雨声 Rain
让眼睛休息一下 Give your eyes a break
看看远处,轻轻眨眼,放松肩颈 Look into the distance, blink gently, and relax your shoulders
长按跳过 · 或按 Esc 随时返回 Hold to skip · or press Esc to return
休息中 On a break
下次休息 Next break
跳过休息 Skip break
重新计时 Restart timer
收起迷你 HUD Hide Mini HUD
收起到迷你 HUD Collapse to Mini HUD
番茄钟 Pomodoro Timer
番茄钟与护眼休息 Pomodoro & Eye Breaks
专注计时、每日目标与多屏休息幕布;所有数据留在本机。 Focus timer, daily goals and a break screen on every display. All data stays on this Mac.
专注 Focus
短休 Short break
长休 Long break
单次 Single
循环 Cycle
计时中 Running
已暂停 Paused
暂停 Pause
开始 Start
继续 Continue
重置 Reset
跳过 Skip
今日目标 Today's goal
长休时长 Long break duration
长休时长(分钟) Long break duration (minutes)
每完成 4 轮专注后进入长休 Take a long break after four focus sessions
每日目标 Daily goal
{} 轮 {} sessions
打开番茄钟 Open Pomodoro
再专注 5 分钟 Focus 5 more minutes
专注中 Focusing
护眼休息未开启 Eye breaks are off
查看下一次休息或当前休息倒计时。 See the countdown to your next break or the end of this one.
"""#
static let english = #"""
Apple 智能 Apple Intelligence
设备不支持 Device not supported
@@ -22,6 +22,9 @@ public final class AppController: NSObject, NSApplicationDelegate {
private var updateWindow: UpdateWindowController!
private var speedTestWindow: SpeedTestWindowController!
private var egressWindow: EgressWindowController!
private var rest: RestController { model.rest }
private var restWindows: RestWindowController!
private var restMenuBar: RestMenuBarController!
private var updateTimer: Timer?
private var widgetTimer: Timer?
private let hotKeys = HotKeyCenter()
@@ -48,6 +51,21 @@ public final class AppController: NSObject, NSApplicationDelegate {
speedTestWindow.onVisibilityChange = { [weak self] _ in self?.updateActivationPolicy() }
egressWindow = EgressWindowController(model: model)
egressWindow.onVisibilityChange = { [weak self] _ in self?.updateActivationPolicy() }
restWindows = RestWindowController(rest: rest)
restMenuBar = RestMenuBarController(rest: rest, open: { [weak self] in
self?.model.openMainWindow(.rest)
}, toggleHUD: { [weak self] in
self?.restWindows.toggleHUD()
})
rest.onRestChange = { [weak self] active in
if active { self?.restWindows.showRest() } else { self?.restWindows.hideRest() }
}
rest.onStateChange = { [weak self] in self?.restMenuBar.update() }
model.toggleRestHUD = { [weak self] in self?.restWindows.toggleHUD() }
model.collapseToRestHUD = { [weak self] in
self?.restWindows.showHUD()
self?.mainWindow.close()
}
menuBar.update()
calendarMenuBar.start()
@@ -88,6 +106,10 @@ public final class AppController: NSObject, NSApplicationDelegate {
}
observeWorkspace()
observeRestSettings()
observeRestSound()
rest.sync()
restMenuBar.sync()
observeModel()
observeProbeSettings()
observeAIUsageSchedule()
@@ -173,6 +195,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
}
public func applicationWillTerminate(_ notification: Notification) {
rest.prepareForTermination()
widgetTimer?.invalidate()
calendarMenuBar.stop()
model.aiUsage.stop()
@@ -490,6 +513,8 @@ public final class AppController: NSObject, NSApplicationDelegate {
self.model.network.setPaused(true)
self.model.aiUsage.setPaused(true)
self.model.history.flush()
self.restWindows.hideRest()
self.rest.suspend()
Task { await self.model.hub.setPaused(true) }
}
})
@@ -500,6 +525,8 @@ public final class AppController: NSObject, NSApplicationDelegate {
guard let self else { return }
self.model.network.setPaused(false)
self.model.aiUsage.setPaused(false)
self.rest.sync()
if self.rest.phase.isResting && self.rest.isRunning { self.restWindows.ensureRestVisible() }
Task {
await self.model.hub.setPaused(false)
await self.model.fans.reapply()
@@ -508,6 +535,34 @@ public final class AppController: NSObject, NSApplicationDelegate {
})
}
}
private func observeRestSettings() {
withObservationTracking {
_ = model.settings.restEnabled
_ = model.settings.restWorkMinutes
_ = model.settings.restBreakMinutes
_ = model.settings.restLongBreakMinutes
} onChange: { [weak self] in
Task { @MainActor [weak self] in
guard let self else { return }
self.rest.restart()
self.restMenuBar.sync()
if !self.model.settings.restEnabled { self.restWindows.hideHUD() }
self.observeRestSettings()
}
}
}
private func observeRestSound() {
withObservationTracking {
_ = model.settings.restSound
} onChange: { [weak self] in
Task { @MainActor [weak self] in
self?.rest.syncSound()
self?.observeRestSound()
}
}
}
}
public enum XStatsApplication {
@@ -83,6 +83,7 @@ public struct MainWindowView: View {
case .battery: DetailPage { BatteryPopover() }
case .processes: ProcessesPage()
case .keepAwake: KeepAwakePage()
case .rest: RestPage()
case .cleaner: CleanerPage()
case .uninstaller: UninstallerPage()
case .startupItems: StartupItemsPage()
@@ -173,7 +174,7 @@ private struct MainSidebar: View {
private var navigation: some View {
VStack(alignment: .leading, spacing: DS.Space.s1) {
group(tr("监控"), PanelTab.monitors.filter { $0 != .aiUsage || model.settings.aiUsageEnabled })
group(tr("工具"), PanelTab.tools)
group(tr("工具"), PanelTab.tools.filter { $0 != .rest || model.settings.restEnabled })
group(tr("设置"), PanelTab.settings)
}
.sidebarGlider()
@@ -237,6 +238,11 @@ private struct PageHeader: View {
.frame(height: DS.Size.controlHeight)
.dsGlass(in: Capsule(), fallback: DS.Palette.primary.opacity(0.12))
}
if settings.restEnabled {
IconButton(systemName: "rectangle.inset.filled", help: tr("收起到迷你 HUD")) {
model.collapseToRestHUD()
}
}
if tab == .memory { PurgeMemoryButton() }
if tab == .network {
Button { model.openSpeedTestWindow() } label: {
@@ -0,0 +1,449 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import AppKit
import Darwin
import Foundation
import Localization
import Observation
import WidgetKit
/// mach_continuous_time 包含系统睡眠,和界面刷新次数无关。
enum RestClock {
static func now() -> UInt64 {
var info = mach_timebase_info_data_t()
mach_timebase_info(&info)
let ticks = mach_continuous_time()
return UInt64(info.denom).dividingFullWidth(
ticks.multipliedFullWidth(by: UInt64(info.numer))).quotient
}
}
enum RestPhase: String, CaseIterable, Identifiable {
case work, rest, longRest
var id: String { rawValue }
var isResting: Bool { self != .work }
}
/// 运行阶段按硬件绝对截止点计时;暂停时分别保存各阶段剩余时间。
struct RestSession {
var phase: RestPhase = .work
var deadline: UInt64
private(set) var phaseDuration: UInt64
private(set) var isRunning = false
private(set) var completedFocus = 0
private var pausedRemaining: UInt64
private var remainingByPhase: [RestPhase: UInt64]
private var activeDurationByPhase: [RestPhase: UInt64]
private var canUndoFocus = false
let workNanoseconds: UInt64
let restNanoseconds: UInt64
let longRestNanoseconds: UInt64
init(now: UInt64, workMinutes: Int, restMinutes: Int, longBreakMinutes: Int = 15) {
workNanoseconds = UInt64(max(1, workMinutes)) * 60_000_000_000
restNanoseconds = UInt64(max(1, restMinutes)) * 60_000_000_000
longRestNanoseconds = UInt64(max(1, longBreakMinutes)) * 60_000_000_000
phaseDuration = workNanoseconds
pausedRemaining = workNanoseconds
remainingByPhase = [.work: workNanoseconds, .rest: restNanoseconds, .longRest: longRestNanoseconds]
activeDurationByPhase = remainingByPhase
deadline = now + workNanoseconds
}
func remaining(at now: UInt64) -> TimeInterval {
let value = isRunning ? (deadline > now ? deadline - now : 0) : pausedRemaining
return TimeInterval(value) / 1_000_000_000
}
var canContinue: Bool { !isRunning && pausedRemaining > 0 && pausedRemaining < phaseDuration }
/// “开始”从完整时长起步;已有进度显示为“继续”,从暂停处恢复。
mutating func startOrContinue(at now: UInt64) {
if !canContinue { resetCurrentPhase(at: now) }
resume(at: now)
}
mutating func resume(at now: UInt64) {
guard !isRunning else { return }
deadline = now + pausedRemaining
isRunning = true
}
mutating func pause(at now: UInt64) {
guard isRunning else { return }
pausedRemaining = deadline > now ? deadline - now : 0
remainingByPhase[phase] = pausedRemaining
isRunning = false
}
/// 手动切换保存运行中或已暂停阶段的进度;同一阶段再次点击不等于重置。
mutating func select(_ selected: RestPhase, at now: UInt64) {
guard selected != phase else { return }
if isRunning { pausedRemaining = deadline > now ? deadline - now : 0 }
remainingByPhase[phase] = pausedRemaining
phase = selected
phaseDuration = activeDurationByPhase[selected] ?? duration(for: selected)
pausedRemaining = remainingByPhase[selected] ?? phaseDuration
deadline = now + pausedRemaining
isRunning = false
canUndoFocus = false
}
mutating func resetCurrentPhase(at now: UInt64) {
phaseDuration = duration(for: phase)
pausedRemaining = phaseDuration
remainingByPhase[phase] = phaseDuration
activeDurationByPhase[phase] = phaseDuration
deadline = now + phaseDuration
isRunning = false
canUndoFocus = false
}
/// 睡眠后可跨越多个阶段;单次模式在一次休息结束后停在下一轮专注起点。
@discardableResult mutating func advance(to now: UInt64, cycle: Bool = false) -> [UInt64] {
guard isRunning else { return [] }
var completedAt: [UInt64] = []
while now >= deadline {
let reached = deadline
remainingByPhase[phase] = duration(for: phase)
activeDurationByPhase[phase] = duration(for: phase)
if phase == .work {
completedFocus += 1
completedAt.append(reached)
canUndoFocus = true
phase = completedFocus.isMultiple(of: 4) ? .longRest : .rest
} else {
phase = .work
canUndoFocus = false
}
phaseDuration = duration(for: phase)
pausedRemaining = phaseDuration
remainingByPhase[phase] = phaseDuration
activeDurationByPhase[phase] = phaseDuration
deadline = reached + phaseDuration
if phase == .work && !cycle {
isRunning = false
deadline = now + phaseDuration
break
}
}
return completedAt
}
mutating func skip(at now: UInt64) {
remainingByPhase[phase] = duration(for: phase)
activeDurationByPhase[phase] = duration(for: phase)
phase = phase.isResting ? .work : .rest
phaseDuration = duration(for: phase)
pausedRemaining = phaseDuration
remainingByPhase[phase] = phaseDuration
activeDurationByPhase[phase] = phaseDuration
deadline = now + phaseDuration
isRunning = true
canUndoFocus = false
}
mutating func postponeFocus(at now: UInt64) {
guard phase.isResting else { return }
remainingByPhase[phase] = duration(for: phase)
activeDurationByPhase[phase] = duration(for: phase)
if canUndoFocus { completedFocus = max(0, completedFocus - 1) }
canUndoFocus = false
phase = .work
phaseDuration = 5 * 60_000_000_000
pausedRemaining = phaseDuration
remainingByPhase[phase] = phaseDuration
activeDurationByPhase[phase] = phaseDuration
deadline = now + phaseDuration
isRunning = true
}
private func duration(for phase: RestPhase) -> UInt64 {
switch phase {
case .work: workNanoseconds
case .rest: restNanoseconds
case .longRest: longRestNanoseconds
}
}
}
@MainActor @Observable
final class RestController {
private(set) var phase: RestPhase = .work
private(set) var isRunning = false
private(set) var canContinue = false
private(set) var secondsRemaining: TimeInterval = 0
private(set) var phaseDuration: TimeInterval = 0
private(set) var completedToday: Int
private var session: RestSession?
private var displayTimer: Timer?
private var deadlineTimer: Timer?
private var scheduledDeadline: UInt64?
private var hudVisible = false
private var pageVisible = false
private var menuBarVisible = false
private var restVisible = false
private var suspendedAt: UInt64?
private var lastFocusCredited = false
private var lastSharedDeadline: UInt64?
private var lastSharedRunning: Bool?
private var lastSharedPhase: RestPhase?
private var lastSharedLanguage: AppLanguage?
private let audio = RestSoundPlayer()
private let localDefaults: UserDefaults
private let defaults: UserDefaults?
private let clock: () -> UInt64
var onRestChange: ((Bool) -> Void)?
var onStateChange: (() -> Void)?
let settings: AppSettings
init(settings: AppSettings, localDefaults: UserDefaults = .standard,
sharedDefaults: UserDefaults? = UserDefaults(suiteName: "group.work.12306.xstats"),
clock: @escaping () -> UInt64 = { RestClock.now() }) {
self.settings = settings
self.localDefaults = localDefaults
defaults = sharedDefaults
self.clock = clock
let today = Self.todayKey()
completedToday = localDefaults.string(forKey: "rest.day") == today
? localDefaults.integer(forKey: "rest.completedToday") : 0
}
func sync() {
guard settings.restEnabled else { stop(); return }
refreshDay()
if session == nil {
session = RestSession(now: clock(), workMinutes: settings.restWorkMinutes,
restMinutes: settings.restBreakMinutes,
longBreakMinutes: settings.restLongBreakMinutes)
}
refresh()
}
func setHUDVisible(_ visible: Bool) {
hudVisible = visible
updateDisplayTimer()
if visible { refresh() }
}
func setPageVisible(_ visible: Bool) {
pageVisible = visible
updateDisplayTimer()
if visible { refresh() }
}
func setMenuBarVisible(_ visible: Bool) {
menuBarVisible = visible
updateDisplayTimer()
}
func startPause() {
guard settings.restEnabled else { return }
settleExpiredSession()
guard var session else { return }
let now = clock()
if session.isRunning {
session.pause(at: now)
} else {
session.startOrContinue(at: now)
}
self.session = session
refresh()
}
func selectPhase(_ selected: RestPhase) {
settleExpiredSession()
guard var session else { return }
session.select(selected, at: clock())
self.session = session
refresh()
}
func resetCurrentPhase() {
settleExpiredSession()
guard var session else { return }
session.resetCurrentPhase(at: clock())
self.session = session
refresh()
}
func restart() {
settleExpiredSession()
suspendedAt = nil
lastFocusCredited = false
session = RestSession(now: clock(), workMinutes: settings.restWorkMinutes,
restMinutes: settings.restBreakMinutes,
longBreakMinutes: settings.restLongBreakMinutes)
sync()
}
func skip() {
settleExpiredSession()
guard var session else { return }
session.skip(at: clock())
self.session = session
refresh()
}
func postponeFiveMinutes() {
settleExpiredSession()
guard var session else { return }
let previousCount = session.completedFocus
session.postponeFocus(at: clock())
if session.completedFocus < previousCount && lastFocusCredited { updateCompletedToday(by: -1) }
lastFocusCredited = false
self.session = session
refresh()
}
/// 手动操作可能抢在延迟的 Timer 回调前到达;先结算已过截止点,避免吞掉完成事件。
private func settleExpiredSession() {
guard let session, session.isRunning, clock() >= session.deadline else { return }
refresh()
}
private func refresh() {
guard var session else { return }
refreshDay()
let now = clock()
let completedAt = session.advance(to: now, cycle: settings.restCycleEnabled)
let nowDate = Date()
let today = Calendar.current.startOfDay(for: nowDate)
let credited = completedAt.filter { completion in
let date = nowDate.addingTimeInterval(-TimeInterval(now - completion) / 1_000_000_000)
let awake = suspendedAt.map { completion <= $0 } ?? true
return awake && date >= today
}
if let last = completedAt.last { lastFocusCredited = credited.contains(last) }
updateCompletedToday(by: credited.count)
suspendedAt = nil
self.session = session
phase = session.phase
isRunning = session.isRunning
canContinue = session.canContinue
secondsRemaining = session.remaining(at: now)
phaseDuration = TimeInterval(session.phaseDuration) / 1_000_000_000
if session.isRunning && scheduledDeadline != session.deadline {
deadlineTimer?.invalidate()
scheduledDeadline = session.deadline
deadlineTimer = Timer.scheduledTimer(withTimeInterval: max(0.001, secondsRemaining), repeats: false) { [weak self] _ in
MainActor.assumeIsolated {
guard let self else { return }
// 系统若在截止点前触发 Timer,重新按硬件截止点调度。
self.scheduledDeadline = nil
self.deadlineTimer = nil
self.refresh()
}
}
deadlineTimer?.tolerance = 0.001
} else if !session.isRunning {
deadlineTimer?.invalidate()
deadlineTimer = nil
scheduledDeadline = nil
}
let shouldShowRest = session.phase.isResting && session.isRunning
if restVisible != shouldShowRest {
restVisible = shouldShowRest
onRestChange?(shouldShowRest)
}
updateDisplayTimer()
syncSound()
onStateChange?()
// 截止点或状态变化时才更新 Widget 共享值,避免每秒磁盘写入。
if lastSharedDeadline != session.deadline || lastSharedRunning != session.isRunning
|| lastSharedPhase != session.phase || lastSharedLanguage != settings.language {
defaults?.set(settings.restEnabled, forKey: "rest.enabled")
defaults?.set(session.phase.rawValue, forKey: "rest.phase")
defaults?.set(session.isRunning, forKey: "rest.running")
defaults?.set(secondsRemaining, forKey: "rest.remaining")
defaults?.set(Date().addingTimeInterval(secondsRemaining).timeIntervalSince1970, forKey: "rest.deadline")
defaults?.set(phaseDuration, forKey: "rest.duration")
defaults?.set(settings.language.rawValue, forKey: "rest.language")
lastSharedDeadline = session.deadline
lastSharedRunning = session.isRunning
lastSharedPhase = session.phase
lastSharedLanguage = settings.language
WidgetCenter.shared.reloadTimelines(ofKind: "rest")
}
}
private func updateDisplayTimer() {
let needsUpdates = settings.restEnabled && isRunning && (restVisible || hudVisible || pageVisible || menuBarVisible)
if needsUpdates && displayTimer == nil {
displayTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
MainActor.assumeIsolated { self?.refresh() }
}
displayTimer?.tolerance = 0.1
} else if !needsUpdates {
displayTimer?.invalidate()
displayTimer = nil
}
}
func stop() {
suspend()
session = nil
suspendedAt = nil
lastFocusCredited = false
lastSharedDeadline = nil
secondsRemaining = 0
isRunning = false
canContinue = false
if restVisible { onRestChange?(false) }
restVisible = false
phase = .work
onStateChange?()
defaults?.set(false, forKey: "rest.enabled")
WidgetCenter.shared.reloadTimelines(ofKind: "rest")
}
func suspend() {
if session?.isRunning == true { suspendedAt = clock() }
displayTimer?.invalidate()
displayTimer = nil
deadlineTimer?.invalidate()
deadlineTimer = nil
scheduledDeadline = nil
audio.stop()
}
func prepareForTermination() {
let remaining = session?.remaining(at: clock()) ?? secondsRemaining
suspend()
defaults?.set(false, forKey: "rest.running")
defaults?.set(remaining, forKey: "rest.remaining")
WidgetCenter.shared.reloadTimelines(ofKind: "rest")
}
func syncSound() {
if settings.restEnabled && isRunning && phase.isResting {
audio.play(settings.restSound)
} else {
audio.stop()
}
}
private func refreshDay() {
let today = Self.todayKey()
guard localDefaults.string(forKey: "rest.day") != today else { return }
completedToday = 0
localDefaults.set(today, forKey: "rest.day")
localDefaults.set(0, forKey: "rest.completedToday")
}
private func updateCompletedToday(by count: Int) {
guard count != 0 else { return }
completedToday = max(0, completedToday + count)
localDefaults.set(completedToday, forKey: "rest.completedToday")
}
private static func todayKey() -> String {
let parts = Calendar.current.dateComponents([.year, .month, .day], from: Date())
return "\(parts.year ?? 0)-\(parts.month ?? 0)-\(parts.day ?? 0)"
}
}
@@ -0,0 +1,59 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import AppKit
import Localization
/// 番茄钟启用时才出现的独立菜单栏入口;左键打开主页面,右键切换 Mini HUD。
@MainActor
final class RestMenuBarController: NSObject {
private let rest: RestController
private let open: () -> Void
private let toggleHUD: () -> Void
private var item: NSStatusItem?
init(rest: RestController, open: @escaping () -> Void, toggleHUD: @escaping () -> Void) {
self.rest = rest
self.open = open
self.toggleHUD = toggleHUD
super.init()
}
func sync() {
guard rest.settings.restEnabled else {
if let item { NSStatusBar.system.removeStatusItem(item) }
item = nil
rest.setMenuBarVisible(false)
return
}
if item == nil {
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
item.autosaveName = "XStats.rest"
if let button = item.button {
button.target = self
button.action = #selector(clicked(_:))
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
button.image = NSImage(systemSymbolName: "timer", accessibilityDescription: tr("番茄钟"))
button.image?.isTemplate = true
}
self.item = item
}
rest.setMenuBarVisible(true)
update()
}
func update() {
guard let button = item?.button else { return }
if rest.isRunning {
let seconds = max(0, Int(rest.secondsRemaining.rounded(.up)))
button.title = String(format: " %02d:%02d", seconds / 60, seconds % 60)
} else {
button.title = ""
}
button.toolTip = tr("番茄钟") + " · " + tr(rest.isRunning ? "计时中" : "已暂停")
}
@objc private func clicked(_ sender: NSStatusBarButton) {
if NSApp.currentEvent?.type == .rightMouseUp { toggleHUD() } else { open() }
}
}
@@ -0,0 +1,103 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import Localization
import SwiftUI
/// 可选的番茄钟主页面:计时、阶段与今日目标在同一处操作。
struct RestPage: View {
@Environment(AppModel.self) private var model
var body: some View {
@Bindable var settings = model.settings
let rest = model.rest
let isBreak = rest.phase.isResting
let accent: Color = isBreak ? .green : .orange
PageScroll {
Card {
HStack(spacing: DS.Space.s2) {
ForEach(RestPhase.allCases) { phase in
Button(phase.title) { rest.selectPhase(phase) }
.buttonStyle(DSButtonStyle(kind: rest.phase == phase ? .primary : .secondary))
}
Spacer()
RestOptionsButton()
SegmentedControl(selection: $settings.restCycleEnabled,
options: [(false, tr("单次")), (true, tr("循环"))])
.frame(width: 150)
}
VStack(spacing: DS.Space.s4) {
ZStack {
Circle().stroke(accent.opacity(0.15), lineWidth: 8)
Circle()
.trim(from: 0, to: rest.phaseDuration > 0
? min(1, max(0, 1 - rest.secondsRemaining / rest.phaseDuration)) : 0)
.stroke(accent, style: StrokeStyle(lineWidth: 8, lineCap: .round))
.rotationEffect(.degrees(-90))
VStack(spacing: 4) {
Text(rest.phase.title)
.dsFont(.sm, weight: .medium)
.foregroundStyle(DS.Palette.textSecondary)
Text(Self.clock(rest.secondsRemaining))
.font(.system(size: 48, weight: .light, design: .rounded))
.monospacedDigit()
.foregroundStyle(DS.Palette.textPrimary)
Text(rest.isRunning ? tr("计时中") : tr("已暂停"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
}
}
.frame(width: 214, height: 214)
HStack(spacing: DS.Space.s2) {
Button(rest.isRunning ? tr("暂停") : rest.canContinue ? tr("继续") : tr("开始")) { rest.startPause() }
.buttonStyle(DSButtonStyle(kind: .primary))
Button(tr("重置")) { rest.resetCurrentPhase() }
.buttonStyle(DSButtonStyle(kind: .secondary))
Button(tr("跳过")) { rest.skip() }
.buttonStyle(DSButtonStyle(kind: .secondary))
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, DS.Space.s4)
}
Card {
HStack {
Label(tr("今日目标"), systemImage: "checkmark.circle")
.dsFont(.sm, weight: .semibold)
Spacer()
Text("\(rest.completedToday) / \(settings.restDailyGoal)")
.monospacedDigit()
.dsFont(.sm)
.foregroundStyle(DS.Palette.textSecondary)
}
ProgressView(value: Double(min(rest.completedToday, settings.restDailyGoal)),
total: Double(settings.restDailyGoal))
.tint(.green)
}
}
.onAppear {
rest.sync()
rest.setPageVisible(true)
}
.onDisappear { rest.setPageVisible(false) }
}
private static func clock(_ seconds: TimeInterval) -> String {
let whole = max(0, Int(seconds.rounded(.up)))
return String(format: "%02d:%02d", whole / 60, whole % 60)
}
}
extension RestPhase {
var title: String {
switch self {
case .work: tr("专注")
case .rest: tr("短休")
case .longRest: tr("长休")
}
}
}
@@ -0,0 +1,136 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import Localization
import SwiftUI
public enum RestHUDStyle: String, CaseIterable, Identifiable, Sendable {
case countdown, ring, hourglass
public var id: String { rawValue }
var title: String {
switch self {
case .countdown: tr("数字倒计时")
case .ring: tr("进度圆环")
case .hourglass: tr("沙漏")
}
}
var symbol: String {
switch self {
case .countdown: "numbersign"
case .ring: "circle.dotted.circle"
case .hourglass: "hourglass"
}
}
var next: Self {
switch self {
case .countdown: .ring
case .ring: .hourglass
case .hourglass: .countdown
}
}
}
/// 与菜单栏日历一致:通用页只保留模块开关,详细选项由独立弹出层承载。
struct RestOptionsButton: View {
@State private var isPresented = false
var body: some View {
Button(tr("设置")) { isPresented = true }
.buttonStyle(DSButtonStyle(kind: .secondary))
.popover(isPresented: $isPresented, arrowEdge: .top) {
RestOptionsPopover()
}
}
}
private struct RestOptionsPopover: View {
@Environment(AppModel.self) private var model
var body: some View {
@Bindable var settings = model.settings
ScrollView {
SettingsGroup(caption: tr("番茄钟")) {
GroupRow(showsDivider: false) {
SettingRow(title: tr("工作时长(分钟)")) {
Picker(tr("工作时长(分钟)"), selection: $settings.restWorkMinutes) {
ForEach(AppSettings.restWorkOptions, id: \.self) { minutes in
Text(tr("\(minutes) 分钟")).tag(minutes)
}
}
.labelsHidden()
.pickerStyle(.menu)
.frame(width: 160)
}
}
GroupRow {
SettingRow(title: tr("休息时长(分钟)")) {
Picker(tr("休息时长(分钟)"), selection: $settings.restBreakMinutes) {
ForEach(AppSettings.restBreakOptions, id: \.self) { minutes in
Text(tr("\(minutes) 分钟")).tag(minutes)
}
}
.labelsHidden()
.pickerStyle(.menu)
.frame(width: 160)
}
}
GroupRow {
SettingRow(title: tr("长休时长(分钟)"), subtitle: tr("每完成 4 轮专注后进入长休")) {
Picker(tr("长休时长(分钟)"), selection: $settings.restLongBreakMinutes) {
ForEach(AppSettings.restLongBreakOptions, id: \.self) { minutes in
Text(tr("\(minutes) 分钟")).tag(minutes)
}
}
.labelsHidden()
.pickerStyle(.menu)
.frame(width: 160)
}
}
GroupRow {
SettingRow(title: tr("每日目标")) {
Picker(tr("每日目标"), selection: $settings.restDailyGoal) {
ForEach(AppSettings.restDailyGoalOptions, id: \.self) { count in
Text(tr("\(count) 轮")).tag(count)
}
}
.labelsHidden()
.pickerStyle(.menu)
.frame(width: 160)
}
}
GroupRow {
SettingRow(title: tr("休息声音"), subtitle: tr("仅在休息时本地实时合成")) {
Picker(tr("休息声音"), selection: $settings.restSound) {
ForEach(RestSound.allCases) { sound in
Text(sound.title).tag(sound)
}
}
.labelsHidden()
.pickerStyle(.menu)
.frame(width: 160)
}
}
GroupRow {
SettingRow(title: tr("迷你 HUD 样式")) {
Picker(tr("迷你 HUD 样式"), selection: $settings.restHUDStyle) {
ForEach(RestHUDStyle.allCases) { style in
Text(style.title).tag(style)
}
}
.labelsHidden()
.pickerStyle(.menu)
.frame(width: 160)
}
}
}
.padding(DS.Space.s4)
}
.frame(width: 480, height: 410)
.appLanguageEnvironment()
}
}
@@ -0,0 +1,110 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import AVFoundation
import Localization
public enum RestSound: String, CaseIterable, Identifiable, Sendable {
case off, white, pink, rain
public var id: String { rawValue }
var title: String {
switch self {
case .off: tr("关闭")
case .white: tr("白噪音")
case .pink: tr("粉红噪音")
case .rain: tr("雨声")
}
}
}
/// 实时生成采样,无文件、下载或麦克风访问。render block 内不分配内存,也不访问 UI 状态。
@MainActor
final class RestSoundPlayer {
private var engine: AVAudioEngine?
private(set) var playing: RestSound = .off
func play(_ sound: RestSound) {
guard sound != playing else { return }
stop()
guard sound != .off else { return }
let engine = AVAudioEngine()
let format = AVAudioFormat(standardFormatWithSampleRate: 44_100, channels: 2)!
let node = makeNoiseSourceNode(format: format, mode: sound)
engine.attach(node)
engine.connect(node, to: engine.mainMixerNode, format: format)
engine.mainMixerNode.outputVolume = 0.16
do {
try engine.start()
self.engine = engine
playing = sound
} catch {
engine.stop()
}
}
func stop() {
engine?.stop()
engine = nil
playing = .off
}
}
/// AVAudioSourceNode 在音频线程调用此闭包;在非隔离函数中创建,避免继承播放器的 MainActor。
nonisolated private func makeNoiseSourceNode(format: AVAudioFormat, mode: RestSound) -> AVAudioSourceNode {
let generator = NoiseGenerator(mode: mode)
return AVAudioSourceNode(format: format) { _, _, frameCount, audioBufferList in
let buffers = UnsafeMutableAudioBufferListPointer(audioBufferList)
for frame in 0..<Int(frameCount) {
let sample = generator.next()
for buffer in buffers {
buffer.mData!.assumingMemoryBound(to: Float.self)[frame] = sample
}
}
return noErr
}
}
/// 单个 source node 的渲染回调独占采样状态,不向其他线程暴露。
nonisolated private final class NoiseGenerator {
let mode: RestSound
private var seed: UInt64 = 0x9E3779B97F4A7C15
private var lowA: Float = 0
private var pink0: Float = 0
private var pink1: Float = 0
private var pink2: Float = 0
private var pink3: Float = 0
private var pink4: Float = 0
private var pink5: Float = 0
private var pink6: Float = 0
private var drops: Float = 0
init(mode: RestSound) { self.mode = mode }
func next() -> Float {
seed ^= seed << 13
seed ^= seed >> 7
seed ^= seed << 17
let white = Float(seed & 0xFFFF) / 32767.5 - 1
switch mode {
case .off: return 0
case .white: return white * 0.35
case .pink:
// Paul Kellett 的 pink-noise 滤波系数;多极点近似 1/f 频谱。
// https://www.musicdsp.org/en/latest/Filters/76-pink-noise-filter.html
pink0 = 0.99886 * pink0 + white * 0.0555179
pink1 = 0.99332 * pink1 + white * 0.0750759
pink2 = 0.96900 * pink2 + white * 0.1538520
pink3 = 0.86650 * pink3 + white * 0.3104856
pink4 = 0.55000 * pink4 + white * 0.5329522
pink5 = -0.7616 * pink5 - white * 0.0168980
let sample = pink0 + pink1 + pink2 + pink3 + pink4 + pink5 + pink6 + white * 0.5362
pink6 = white * 0.115926
return sample * 0.11
case .rain:
lowA += 0.035 * (white - lowA)
if seed & 0x1FFF == 0 { drops = 0.75 }
drops *= 0.998
return lowA * 0.7 + white * drops * 0.25
}
}
}
@@ -0,0 +1,343 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import AppKit
import Localization
import SwiftUI
@MainActor
final class RestPanel: NSPanel {
var onEscape: (() -> Void)?
var hudDragEnabled = false
private var dragStartScreen: NSPoint?
private var dragStartOrigin: NSPoint?
override var canBecomeKey: Bool { true }
override func cancelOperation(_ sender: Any?) { onEscape?() }
override func keyDown(with event: NSEvent) {
if event.keyCode == 53 { onEscape?() } else { super.keyDown(with: event) }
}
override func sendEvent(_ event: NSEvent) {
if hudDragEnabled {
switch event.type {
case .leftMouseDown where isHUDDragRegion(event.locationInWindow):
dragStartScreen = convertPoint(toScreen: event.locationInWindow)
dragStartOrigin = frame.origin
return
case .leftMouseDragged:
if let dragStartScreen, let dragStartOrigin {
let current = convertPoint(toScreen: event.locationInWindow)
var origin = NSPoint(x: dragStartOrigin.x + current.x - dragStartScreen.x,
y: dragStartOrigin.y + current.y - dragStartScreen.y)
if let screen = NSScreen.screens.first(where: { $0.frame.contains(current) }) ?? self.screen ?? NSScreen.main {
let visible = screen.visibleFrame
origin.x = min(max(origin.x, visible.minX), visible.maxX - frame.width)
origin.y = min(max(origin.y, visible.minY), visible.maxY - frame.height)
}
setFrameOrigin(origin)
return
}
case .leftMouseUp where dragStartScreen != nil:
dragStartScreen = nil
dragStartOrigin = nil
return
default: break
}
}
super.sendEvent(event)
}
/// 下方控制按钮与顶部两个操作按钮保留正常点击,其余区域都能直接拖动。
private func isHUDDragRegion(_ point: NSPoint) -> Bool {
guard point.y > 44 else { return false }
let inHeader = point.y >= frame.height - 42
let overActionButtons = L10n.language.isRightToLeft ? point.x < 60 : point.x > frame.width - 60
return !inHeader || !overActionButtons
}
}
private struct RestOverlayView: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
let rest: RestController
@State private var breathing = false
@State private var holding = false
var body: some View {
let language = rest.settings.language.resolved
let accent = Color(red: 0.32, green: 0.89, blue: 0.53)
ZStack {
if reduceTransparency {
Color(nsColor: .windowBackgroundColor)
} else {
Color.black.opacity(0.25)
}
VStack(spacing: 22) {
ZStack {
Circle().fill(accent.opacity(0.20))
.frame(width: 195, height: 195)
.blur(radius: 28)
.scaleEffect(breathing && !reduceMotion ? 1.12 : 0.9)
Circle().stroke(accent.opacity(0.22), lineWidth: 2)
Circle()
.trim(from: 0, to: rest.phaseDuration > 0
? min(1, max(0, 1 - rest.secondsRemaining / rest.phaseDuration)) : 0)
.stroke(accent, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.rotationEffect(.degrees(-90))
VStack(spacing: 8) {
Text(rest.phase.title).font(.system(size: 14, weight: .semibold))
Text(formatted(rest.secondsRemaining))
.font(.system(size: 52, weight: .light, design: .rounded))
.monospacedDigit()
}
.foregroundStyle(accent)
}
.frame(width: 250, height: 250)
Text(tr("让眼睛休息一下"))
.font(.system(size: 34, weight: .medium, design: .rounded))
.tracking(-1)
Text(tr("看看远处,轻轻眨眼,放松肩颈"))
.font(.system(size: 16))
.foregroundStyle(.secondary)
HStack(spacing: 14) {
Text(tr("长按跳过 · 或按 Esc 随时返回"))
.font(.system(size: 13, weight: .medium))
.padding(.horizontal, 22)
.padding(.vertical, 12)
.background(.regularMaterial, in: Capsule())
.scaleEffect(holding ? 0.96 : 1)
.onLongPressGesture(minimumDuration: 0.8, pressing: { holding = $0 }) { rest.skip() }
.accessibilityAddTraits(.isButton)
.accessibilityAction { rest.skip() }
Button(tr("再专注 5 分钟")) { rest.postponeFiveMinutes() }
.buttonStyle(.plain)
.font(.system(size: 13, weight: .medium))
.padding(.horizontal, 22)
.padding(.vertical, 12)
.background(.regularMaterial, in: Capsule())
}
}
.frame(maxWidth: 680)
}
.ignoresSafeArea()
.environment(\.locale, L10n.locale(for: language))
.environment(\.layoutDirection, language.isRightToLeft ? .rightToLeft : .leftToRight)
.onAppear {
guard !reduceMotion else { return }
withAnimation(.easeInOut(duration: 3.5).repeatForever(autoreverses: true)) { breathing = true }
}
}
private func formatted(_ seconds: TimeInterval) -> String {
let total = max(0, Int(seconds.rounded(.up)))
return String(format: "%02d:%02d", total / 60, total % 60)
}
}
private struct RestHUDView: View {
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
let rest: RestController
let close: () -> Void
var body: some View {
let language = rest.settings.language.resolved
VStack(spacing: 8) {
HStack(spacing: 8) {
Label(rest.phase.title, systemImage: rest.phase.isResting ? "eye" : "timer")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(rest.phase.isResting ? .green : .orange)
Spacer(minLength: 0)
Image(systemName: "line.3.horizontal")
.font(.system(size: 10))
.foregroundStyle(.secondary)
.frame(width: 36, height: 20)
.help(tr("拖动迷你 HUD"))
Button {
rest.settings.restHUDStyle = rest.settings.restHUDStyle.next
} label: {
Image(systemName: rest.settings.restHUDStyle.symbol)
}
.buttonStyle(.plain)
.help(tr("切换迷你 HUD 样式"))
.accessibilityLabel(tr("切换迷你 HUD 样式"))
Button(action: close) { Image(systemName: "xmark") }
.buttonStyle(.plain)
.help(tr("收起迷你 HUD"))
}
display
.frame(maxWidth: .infinity)
.frame(height: 76)
HStack(spacing: 20) {
Button { rest.startPause() } label: {
Image(systemName: rest.isRunning ? "pause.fill" : "play.fill")
}
.help(tr(rest.isRunning ? "暂停" : rest.canContinue ? "继续" : "开始"))
Button { rest.resetCurrentPhase() } label: { Image(systemName: "arrow.counterclockwise") }
.help(tr("重置"))
Button { rest.skip() } label: { Image(systemName: "forward.end.fill") }
.help(tr("跳过"))
}
.buttonStyle(.plain)
}
.padding(14)
.frame(width: 250, height: 160)
.background {
if reduceTransparency {
RoundedRectangle(cornerRadius: 18).fill(Color(nsColor: .windowBackgroundColor))
} else {
RoundedRectangle(cornerRadius: 18).fill(.regularMaterial)
}
}
.environment(\.locale, L10n.locale(for: language))
.environment(\.layoutDirection, language.isRightToLeft ? .rightToLeft : .leftToRight)
}
@ViewBuilder
private var display: some View {
let progress = rest.phaseDuration > 0
? min(1, max(0, 1 - rest.secondsRemaining / rest.phaseDuration)) : 0
let tint: Color = rest.phase.isResting ? .green : .orange
let clock = String(format: "%02d:%02d", max(0, Int(rest.secondsRemaining.rounded(.up))) / 60,
max(0, Int(rest.secondsRemaining.rounded(.up))) % 60)
switch rest.settings.restHUDStyle {
case .countdown:
Text(clock)
.font(.system(size: 30, weight: .medium, design: .rounded))
.monospacedDigit()
case .ring:
ZStack {
Circle().stroke(tint.opacity(0.18), lineWidth: 5)
Circle().trim(from: 0, to: progress)
.stroke(tint, style: StrokeStyle(lineWidth: 5, lineCap: .round))
.rotationEffect(.degrees(-90))
Text(clock).font(.system(size: 14, weight: .semibold, design: .rounded)).monospacedDigit()
}
.frame(width: 72, height: 72)
case .hourglass:
VStack(spacing: 8) {
HStack(spacing: 12) {
Image(systemName: "hourglass")
.font(.system(size: 42, weight: .ultraLight))
.symbolRenderingMode(.hierarchical)
.foregroundStyle(tint)
.accessibilityHidden(true)
Text(clock)
.font(.system(size: 19, weight: .medium, design: .rounded))
.monospacedDigit()
}
GeometryReader { geometry in
ZStack(alignment: .leading) {
Capsule().fill(tint.opacity(0.14))
Capsule().fill(tint)
.frame(width: geometry.size.width * (1 - progress))
}
}
.frame(width: 142, height: 4)
}
}
}
}
@MainActor
final class RestWindowController {
private let rest: RestController
private var overlays: [RestPanel] = []
private var hud: RestPanel?
private var screenObserver: NSObjectProtocol?
init(rest: RestController) {
self.rest = rest
screenObserver = NotificationCenter.default.addObserver(forName: NSApplication.didChangeScreenParametersNotification,
object: nil, queue: .main) { [weak self] _ in
MainActor.assumeIsolated { self?.refreshScreens() }
}
}
func showRest() {
hideRest()
for screen in NSScreen.screens {
let panel = RestPanel(contentRect: screen.frame, styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered, defer: false, screen: screen)
panel.level = .floating
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
panel.isOpaque = false
panel.backgroundColor = .clear
panel.appearance = NSAppearance(named: .darkAqua)
panel.hasShadow = false
panel.onEscape = { [weak rest] in rest?.skip() }
let blur = NSVisualEffectView(frame: NSRect(origin: .zero, size: screen.frame.size))
blur.blendingMode = .behindWindow
blur.material = .underWindowBackground
blur.state = .active
let hosting = NSHostingView(rootView: RestOverlayView(rest: rest))
hosting.frame = blur.bounds
hosting.autoresizingMask = [.width, .height]
blur.addSubview(hosting)
panel.contentView = blur
panel.orderFrontRegardless()
overlays.append(panel)
}
overlays.first?.makeKey()
}
func ensureRestVisible() {
if overlays.isEmpty { showRest() }
}
func hideRest() {
overlays.forEach { $0.close() }
overlays.removeAll()
}
func toggleHUD() {
if hud?.isVisible == true { hideHUD(); return }
showHUD()
}
func showHUD() {
guard hud?.isVisible != true else { return }
let isNew = hud == nil
let panel = hud ?? RestPanel(contentRect: NSRect(x: 0, y: 0, width: 250, height: 160),
styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
hud = panel
panel.isReleasedWhenClosed = false
panel.level = .floating
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = true
panel.hudDragEnabled = true
panel.contentView = NSHostingView(rootView: RestHUDView(rest: rest) { [weak panel, weak rest] in
panel?.close()
rest?.setHUDVisible(false)
})
let visible = NSScreen.main?.visibleFrame ?? .zero
let defaultOrigin = NSPoint(x: visible.maxX - 266, y: visible.midY - 80)
if isNew {
panel.setFrameAutosaveName("XStatsRestHUD")
if !panel.setFrameUsingName("XStatsRestHUD") { panel.setFrameOrigin(defaultOrigin) }
}
if !NSScreen.screens.contains(where: { $0.visibleFrame.intersects(panel.frame) }) {
panel.setFrameOrigin(defaultOrigin)
}
panel.orderFrontRegardless()
rest.setHUDVisible(true)
}
func hideHUD() {
hud?.close()
rest.setHUDVisible(false)
}
private func refreshScreens() {
if rest.phase.isResting && rest.isRunning { showRest() }
if let hud, hud.isVisible {
if !NSScreen.screens.contains(where: { $0.visibleFrame.intersects(hud.frame) }) {
let visible = NSScreen.main?.visibleFrame ?? .zero
hud.setFrameOrigin(NSPoint(x: visible.maxX - 266, y: visible.midY - 80))
}
}
}
}
@@ -105,6 +105,16 @@ struct GeneralSettings: View {
}
SettingsGroup(caption: tr("可选功能")) {
GroupRow(showsDivider: false) {
SettingRow(title: tr("番茄钟与护眼休息"),
subtitle: tr("专注计时、每日目标与多屏休息幕布;所有数据留在本机。"),
icon: "eye") {
HStack(spacing: DS.Space.s2) {
if settings.restEnabled { RestOptionsButton() }
DSToggle(isOn: $settings.restEnabled, label: tr("番茄钟与护眼休息"))
}
}
}
GroupRow(showsDivider: false) {
SettingRow(title: tr("AI 用量与额度"),
subtitle: tr("自动检测 Codex / Claude 订阅额度;本地 Token 统计可单独关闭。"),
@@ -11,6 +11,7 @@ import Updates
@Observable
public final class AppModel {
public let settings: AppSettings
let rest: RestController
public let aiUsage: AIUsageController
@ObservationIgnored let sub2apiDrafts = Dictionary(uniqueKeysWithValues:
AIProviderID.allCases.map { ($0, Sub2APIDraft()) })
@@ -64,6 +65,8 @@ public final class AppModel {
@ObservationIgnored var openMainWindow: (PanelTab?) -> Void = { _ in }
@ObservationIgnored var openEgressWindow: () -> Void = {}
@ObservationIgnored var openSpeedTestWindow: () -> Void = {}
@ObservationIgnored var toggleRestHUD: () -> Void = {}
@ObservationIgnored var collapseToRestHUD: () -> Void = {}
@ObservationIgnored var quit: () -> Void = {}
public init(settings: AppSettings = AppSettings(), historyURL: URL? = HistoryDatabase.defaultURL,
@@ -73,6 +76,7 @@ public final class AppModel {
let store = MetricsStore()
let helper = HelperClient()
self.settings = settings
rest = RestController(settings: settings)
aiUsage = AIUsageController(settings: settings, providers: aiUsageProviders, quotaProviders: aiQuotaProviders,
quotaCacheURL: quotaCacheURL)
self.store = store
@@ -285,13 +285,13 @@ public enum AppearanceMode: String, CaseIterable, Identifiable, Sendable {
/// 主窗口侧边栏的页面
public enum PanelTab: String, CaseIterable, Identifiable, Sendable {
case overview, system, history, aiUsage, cpu, gpu, memory, disk, network, thermal, battery, processes, keepAwake, cleaner, uninstaller, startupItems
case overview, system, history, aiUsage, cpu, gpu, memory, disk, network, thermal, battery, processes, keepAwake, rest, cleaner, uninstaller, startupItems
case settingsGeneral, settingsMenuBar, settingsNotifications, settingsAccount, settingsHelper, settingsAbout
public var id: String { rawValue }
static let monitors: [PanelTab] = [.overview, .system, .history, .aiUsage, .cpu, .gpu, .memory, .disk, .network, .thermal, .battery]
static let tools: [PanelTab] = [.processes, .startupItems, .keepAwake, .cleaner, .uninstaller]
static let tools: [PanelTab] = [.processes, .startupItems, .keepAwake, .rest, .cleaner, .uninstaller]
// 暂时隐藏设置同步;保留枚举值和页面实现,避免影响已有配置并方便恢复。
static let settings: [PanelTab] = [.settingsGeneral, .settingsMenuBar, .settingsNotifications,
/* .settingsAccount, */ .settingsHelper, .settingsAbout]
@@ -316,6 +316,7 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable {
case .battery: tr("电池")
case .processes: tr("进程")
case .keepAwake: tr("防休眠")
case .rest: tr("番茄钟")
case .cleaner: tr("清理")
case .uninstaller: tr("卸载应用")
case .startupItems: tr("启动项")
@@ -343,6 +344,7 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable {
case .battery: "battery.75"
case .processes: "list.bullet.rectangle"
case .keepAwake: "cup.and.saucer"
case .rest: "timer"
case .cleaner: "eraser"
case .uninstaller: "trash"
case .startupItems: "power"
@@ -402,6 +404,34 @@ public final class AppSettings {
public var calendarEnabled: Bool {
didSet { defaults.set(calendarEnabled, forKey: Keys.calendarEnabled) }
}
/// 休息提醒完全由本机计时;旧用户默认关闭。
public var restEnabled: Bool {
didSet {
defaults.set(restEnabled, forKey: Keys.restEnabled)
if !restEnabled && panelTab == .rest { panelTab = .settingsGeneral }
}
}
public var restWorkMinutes: Int {
didSet { defaults.set(restWorkMinutes, forKey: Keys.restWorkMinutes) }
}
public var restBreakMinutes: Int {
didSet { defaults.set(restBreakMinutes, forKey: Keys.restBreakMinutes) }
}
public var restLongBreakMinutes: Int {
didSet { defaults.set(restLongBreakMinutes, forKey: Keys.restLongBreakMinutes) }
}
public var restDailyGoal: Int {
didSet { defaults.set(restDailyGoal, forKey: Keys.restDailyGoal) }
}
public var restCycleEnabled: Bool {
didSet { defaults.set(restCycleEnabled, forKey: Keys.restCycleEnabled) }
}
public var restSound: RestSound {
didSet { defaults.set(restSound.rawValue, forKey: Keys.restSound) }
}
public var restHUDStyle: RestHUDStyle {
didSet { defaults.set(restHUDStyle.rawValue, forKey: Keys.restHUDStyle) }
}
public var calendarFeatures: Set<CalendarFeature> {
didSet { defaults.set(calendarFeatures.map(\.rawValue).sorted(), forKey: Keys.calendarFeatures) }
}
@@ -559,6 +589,10 @@ public final class AppSettings {
}
public static let refreshOptions = [1, 2, 3, 5]
public static let restWorkOptions = [20, 25, 30, 45, 60]
public static let restBreakOptions = [3, 5, 10]
public static let restLongBreakOptions = [10, 15, 20, 30]
public static let restDailyGoalOptions = [4, 6, 8, 12]
public static let aiUsageRefreshOptions = [5, 15, 30, 60]
public static let batteryFloorOptions = [10, 20, 30, 40]
public static let fanSafetyOptions = [85, 90, 95, 100]
@@ -568,6 +602,19 @@ public final class AppSettings {
public init(defaults: UserDefaults = .standard) {
self.defaults = defaults
let isRestEnabled = defaults.bool(forKey: Keys.restEnabled)
restEnabled = isRestEnabled
restWorkMinutes = Self.restWorkOptions.contains(defaults.integer(forKey: Keys.restWorkMinutes))
? defaults.integer(forKey: Keys.restWorkMinutes) : 25
restBreakMinutes = Self.restBreakOptions.contains(defaults.integer(forKey: Keys.restBreakMinutes))
? defaults.integer(forKey: Keys.restBreakMinutes) : 5
restLongBreakMinutes = Self.restLongBreakOptions.contains(defaults.integer(forKey: Keys.restLongBreakMinutes))
? defaults.integer(forKey: Keys.restLongBreakMinutes) : 15
restDailyGoal = Self.restDailyGoalOptions.contains(defaults.integer(forKey: Keys.restDailyGoal))
? defaults.integer(forKey: Keys.restDailyGoal) : 8
restCycleEnabled = defaults.bool(forKey: Keys.restCycleEnabled)
restSound = defaults.string(forKey: Keys.restSound).flatMap(RestSound.init(rawValue:)) ?? .off
restHUDStyle = defaults.string(forKey: Keys.restHUDStyle).flatMap(RestHUDStyle.init(rawValue:)) ?? .countdown
calendarEnabled = defaults.bool(forKey: Keys.calendarEnabled)
calendarFeatures = defaults.stringArray(forKey: Keys.calendarFeatures)
.map(CalendarFeature.restored(from:)) ?? CalendarFeature.defaults
@@ -602,6 +649,7 @@ public final class AppSettings {
// 旧版 AI 助手设置页已移除,升级后仍留在设置分组。
panelTab = savedPanelTabValue == "settingsAI" || savedPanelTab == .settingsAccount
|| (savedPanelTab == .aiUsage && !isAIUsageEnabled)
|| (savedPanelTab == .rest && !isRestEnabled)
? .settingsGeneral : savedPanelTab
appearance = defaults.string(forKey: Keys.appearance).flatMap(AppearanceMode.init(rawValue:)) ?? .system
showDockIcon = defaults.bool(forKey: Keys.showDockIcon)
@@ -671,6 +719,14 @@ public final class AppSettings {
}
private enum Keys {
static let restEnabled = "restEnabled"
static let restWorkMinutes = "restWorkMinutes"
static let restBreakMinutes = "restBreakMinutes"
static let restLongBreakMinutes = "restLongBreakMinutes"
static let restDailyGoal = "restDailyGoal"
static let restCycleEnabled = "restCycleEnabled"
static let restSound = "restSound"
static let restHUDStyle = "restHUDStyle"
static let calendarEnabled = "calendarEnabled"
static let calendarFeatures = "calendarFeatures"
static let calendarFirstWeekday = "calendarFirstWeekday"
@@ -16,6 +16,14 @@ import WebDAVSync
public struct SettingsDocument: Codable, Equatable, Sendable {
public var schema: Int? = 1
public var calendarEnabled: Bool?
public var restEnabled: Bool?
public var restWorkMinutes: Int?
public var restBreakMinutes: Int?
public var restLongBreakMinutes: Int?
public var restDailyGoal: Int?
public var restCycleEnabled: Bool?
public var restSound: String?
public var restHUDStyle: String?
public var calendarFeatures: [String]?
public var calendarFirstWeekday: Int?
public var menuBarItems: [String]?
@@ -53,6 +61,14 @@ extension AppSettings {
public func exportDocument() -> SettingsDocument {
var doc = SettingsDocument()
doc.calendarEnabled = calendarEnabled
doc.restEnabled = restEnabled
doc.restWorkMinutes = restWorkMinutes
doc.restBreakMinutes = restBreakMinutes
doc.restLongBreakMinutes = restLongBreakMinutes
doc.restDailyGoal = restDailyGoal
doc.restCycleEnabled = restCycleEnabled
doc.restSound = restSound.rawValue
doc.restHUDStyle = restHUDStyle.rawValue
doc.calendarFeatures = calendarFeatures.map(\.rawValue).sorted()
doc.calendarFirstWeekday = calendarFirstWeekday
doc.menuBarItems = menuBarItems.map(\.rawValue).sorted()
@@ -95,6 +111,14 @@ extension AppSettings {
}
assign(\.calendarEnabled, doc.calendarEnabled)
assign(\.restEnabled, doc.restEnabled)
assign(\.restWorkMinutes, option(doc.restWorkMinutes, in: Self.restWorkOptions))
assign(\.restBreakMinutes, option(doc.restBreakMinutes, in: Self.restBreakOptions))
assign(\.restLongBreakMinutes, option(doc.restLongBreakMinutes, in: Self.restLongBreakOptions))
assign(\.restDailyGoal, option(doc.restDailyGoal, in: Self.restDailyGoalOptions))
assign(\.restCycleEnabled, doc.restCycleEnabled)
assign(\.restSound, doc.restSound.flatMap(RestSound.init(rawValue:)))
assign(\.restHUDStyle, doc.restHUDStyle.flatMap(RestHUDStyle.init(rawValue:)))
assign(\.calendarFeatures, doc.calendarFeatures.map(CalendarFeature.restored(from:)))
assign(\.calendarFirstWeekday, option(doc.calendarFirstWeekday, in: [1, 2]))
assign(\.menuBarItems, doc.menuBarItems.map { Set($0.compactMap(MenuBarItem.init(rawValue:))) })
@@ -0,0 +1,389 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import AppKit
import Foundation
import Testing
@testable import XStatsUI
@Suite struct RestSessionTests {
@Test func deadlineDoesNotAccumulateTickError() {
let start: UInt64 = 1_000_000_000
var session = RestSession(now: start, workMinutes: 20, restMinutes: 3)
session.resume(at: start)
let workEnd = start + 20 * 60_000_000_000
#expect(session.remaining(at: workEnd - 1_000_000) == 0.001)
session.advance(to: workEnd + 500_000_000)
#expect(session.phase == .rest)
#expect(session.remaining(at: workEnd + 500_000_000) == 179.5)
}
@Test func sleepingAcrossMultiplePhasesUsesAbsoluteDeadline() {
let start: UInt64 = 1_000_000_000
var session = RestSession(now: start, workMinutes: 20, restMinutes: 3)
session.resume(at: start)
let completedAt = session.advance(to: start + 24 * 60_000_000_000, cycle: true)
#expect(completedAt == [start + 20 * 60_000_000_000])
#expect(session.phase == .work)
#expect(session.remaining(at: start + 24 * 60_000_000_000) == 19 * 60)
}
@Test func skipStartsFreshWorkPeriod() {
var session = RestSession(now: 0, workMinutes: 20, restMinutes: 3)
session.resume(at: 0)
session.advance(to: 20 * 60_000_000_000)
session.skip(at: 21 * 60_000_000_000)
#expect(session.phase == .work)
#expect(session.remaining(at: 21 * 60_000_000_000) == 20 * 60)
}
@Test func pauseAndResumeKeepRemainingTime() {
var session = RestSession(now: 0, workMinutes: 25, restMinutes: 5)
#expect(!session.isRunning)
session.resume(at: 0)
session.pause(at: 10 * 60_000_000_000)
#expect(session.remaining(at: 40 * 60_000_000_000) == 15 * 60)
session.resume(at: 40 * 60_000_000_000)
#expect(session.deadline == 55 * 60_000_000_000)
}
@Test func returningFromBreakKeepsCompletedFocusCount() {
var session = RestSession(now: 0, workMinutes: 25, restMinutes: 5)
session.resume(at: 0)
session.advance(to: 25 * 60_000_000_000)
session.select(.work, at: 25 * 60_000_000_000)
#expect(session.phase == .work)
#expect(!session.isRunning)
#expect(session.completedFocus == 1)
#expect(session.remaining(at: 25 * 60_000_000_000) == 25 * 60)
}
@Test func resettingBreakKeepsItsPhaseAndCompletedFocusCount() {
var session = RestSession(now: 0, workMinutes: 25, restMinutes: 5)
session.resume(at: 0)
session.advance(to: 25 * 60_000_000_000)
session.resetCurrentPhase(at: 26 * 60_000_000_000)
#expect(session.phase == .rest)
#expect(!session.isRunning)
#expect(session.completedFocus == 1)
#expect(session.remaining(at: 26 * 60_000_000_000) == 5 * 60)
}
@Test func pausedSwitchKeepsEachPhasesRemainingTimeUntilExplicitReset() {
var session = RestSession(now: 0, workMinutes: 25, restMinutes: 5, longBreakMinutes: 15)
session.resume(at: 0)
session.pause(at: 5 * 60_000_000_000)
session.select(.rest, at: 5 * 60_000_000_000)
session.resume(at: 5 * 60_000_000_000)
session.pause(at: 6 * 60_000_000_000)
session.select(.longRest, at: 6 * 60_000_000_000)
#expect(session.remaining(at: 6 * 60_000_000_000) == 15 * 60)
session.select(.work, at: 6 * 60_000_000_000)
#expect(!session.isRunning)
#expect(session.remaining(at: 6 * 60_000_000_000) == 20 * 60)
session.select(.rest, at: 6 * 60_000_000_000)
#expect(session.remaining(at: 6 * 60_000_000_000) == 4 * 60)
session.select(.rest, at: 7 * 60_000_000_000)
#expect(session.remaining(at: 7 * 60_000_000_000) == 4 * 60)
session.resetCurrentPhase(at: 7 * 60_000_000_000)
#expect(session.remaining(at: 7 * 60_000_000_000) == 5 * 60)
}
@Test func switchingWhileRunningPreservesProgressAndContinueResumesIt() {
var session = RestSession(now: 0, workMinutes: 25, restMinutes: 5)
session.resume(at: 0)
session.select(.rest, at: 5 * 60_000_000_000)
session.select(.work, at: 5 * 60_000_000_000)
#expect(!session.isRunning)
#expect(session.canContinue)
#expect(session.remaining(at: 5 * 60_000_000_000) == 20 * 60)
session.startOrContinue(at: 5 * 60_000_000_000)
#expect(session.isRunning)
#expect(session.remaining(at: 5 * 60_000_000_000) == 20 * 60)
}
@Test func startAfterExplicitResetUsesFullDuration() {
var session = RestSession(now: 0, workMinutes: 25, restMinutes: 5)
session.resume(at: 0)
session.pause(at: 5 * 60_000_000_000)
#expect(session.canContinue)
session.resetCurrentPhase(at: 5 * 60_000_000_000)
#expect(!session.canContinue)
session.startOrContinue(at: 5 * 60_000_000_000)
#expect(session.remaining(at: 5 * 60_000_000_000) == 25 * 60)
}
@Test func automaticTransitionStartsNextBreakAtFullDuration() {
var session = RestSession(now: 0, workMinutes: 25, restMinutes: 5)
session.select(.rest, at: 0)
session.resume(at: 0)
session.pause(at: 60_000_000_000)
session.select(.work, at: 60_000_000_000)
session.resume(at: 60_000_000_000)
session.advance(to: 26 * 60_000_000_000)
#expect(session.phase == .rest)
#expect(session.remaining(at: 26 * 60_000_000_000) == 5 * 60)
}
@Test func skipStartsTargetPhaseFreshDespiteSavedPause() {
var session = RestSession(now: 0, workMinutes: 25, restMinutes: 5)
session.select(.rest, at: 0)
session.resume(at: 0)
session.pause(at: 60_000_000_000)
session.select(.work, at: 60_000_000_000)
session.skip(at: 60_000_000_000)
#expect(session.phase == .rest)
#expect(session.isRunning)
#expect(session.remaining(at: 60_000_000_000) == 5 * 60)
}
@Test func postponedFocusKeepsItsFiveMinuteScaleAfterSwitching() {
var session = RestSession(now: 0, workMinutes: 25, restMinutes: 5)
session.select(.rest, at: 0)
session.postponeFocus(at: 0)
session.pause(at: 60_000_000_000)
session.select(.longRest, at: 60_000_000_000)
session.select(.work, at: 60_000_000_000)
#expect(session.phaseDuration == 5 * 60_000_000_000)
#expect(session.remaining(at: 60_000_000_000) == 4 * 60)
}
@Test func singleModeStopsAfterBreakAndCycleContinues() {
var single = RestSession(now: 0, workMinutes: 25, restMinutes: 5)
single.resume(at: 0)
single.advance(to: 31 * 60_000_000_000, cycle: false)
#expect(single.phase == .work)
#expect(!single.isRunning)
#expect(single.completedFocus == 1)
var cycle = RestSession(now: 0, workMinutes: 25, restMinutes: 5)
cycle.resume(at: 0)
cycle.advance(to: 31 * 60_000_000_000, cycle: true)
#expect(cycle.phase == .work)
#expect(cycle.isRunning)
#expect(cycle.remaining(at: 31 * 60_000_000_000) == 24 * 60)
}
@Test func fourthFocusUsesLongBreak() {
var session = RestSession(now: 0, workMinutes: 25, restMinutes: 5, longBreakMinutes: 15)
session.resume(at: 0)
session.advance(to: 115 * 60_000_000_000, cycle: true)
#expect(session.phase == .longRest)
#expect(session.completedFocus == 4)
#expect(session.remaining(at: 115 * 60_000_000_000) == 15 * 60)
}
@Test func postponeAddsFiveMinutesAndDoesNotDoubleCountFocus() {
var session = RestSession(now: 0, workMinutes: 25, restMinutes: 5)
session.resume(at: 0)
session.advance(to: 25 * 60_000_000_000)
#expect(session.completedFocus == 1)
session.postponeFocus(at: 25 * 60_000_000_000)
#expect(session.phase == .work)
#expect(session.completedFocus == 0)
#expect(session.remaining(at: 25 * 60_000_000_000) == 5 * 60)
session.advance(to: 30 * 60_000_000_000)
#expect(session.completedFocus == 1)
}
}
@MainActor
@Suite struct RestSettingsTests {
@Test func userActionAfterDeadlineCreditsFocusBeforeChangingPhase() {
let defaults = UserDefaults(suiteName: "RestDeadlineActionTests.\(UUID())")!
let settings = AppSettings(defaults: defaults)
settings.restEnabled = true
var now: UInt64 = 0
let rest = RestController(settings: settings, localDefaults: defaults,
sharedDefaults: defaults, clock: { now })
rest.sync()
rest.startPause()
now = 25 * 60_000_000_000 + 1
rest.startPause()
#expect(rest.completedToday == 1)
#expect(rest.phase == .rest)
rest.stop()
}
@Test func switchingAfterDeadlineCreditsCompletedFocus() {
let defaults = UserDefaults(suiteName: "RestDeadlineSwitchTests.\(UUID())")!
let settings = AppSettings(defaults: defaults)
settings.restEnabled = true
var now: UInt64 = 0
let rest = RestController(settings: settings, localDefaults: defaults,
sharedDefaults: defaults, clock: { now })
rest.sync()
rest.startPause()
now = 25 * 60_000_000_000 + 1
rest.selectPhase(.longRest)
#expect(rest.completedToday == 1)
#expect(rest.phase == .longRest)
rest.stop()
}
@Test func controllerShowsContinueAfterSwitchingAwayFromActiveFocus() {
let defaults = UserDefaults(suiteName: "RestContinueTests.\(UUID())")!
let settings = AppSettings(defaults: defaults)
settings.restEnabled = true
var now: UInt64 = 0
let rest = RestController(settings: settings, localDefaults: defaults,
sharedDefaults: defaults, clock: { now })
rest.sync()
rest.startPause()
now = 5 * 60_000_000_000
rest.selectPhase(.rest)
rest.selectPhase(.work)
#expect(rest.canContinue)
#expect(rest.secondsRemaining == 20 * 60)
rest.startPause()
#expect(rest.isRunning)
#expect(rest.secondsRemaining == 20 * 60)
rest.stop()
}
@Test func hudWindowDragsFromTheTimerArea() throws {
let window = RestPanel(contentRect: NSRect(x: 500, y: 500, width: 250, height: 160),
styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
window.hudDragEnabled = true
let start = window.frame.origin
let down = try #require(NSEvent.mouseEvent(with: .leftMouseDown, location: NSPoint(x: 125, y: 80),
modifierFlags: [], timestamp: 0, windowNumber: window.windowNumber,
context: nil, eventNumber: 0, clickCount: 1, pressure: 1))
let dragged = try #require(NSEvent.mouseEvent(with: .leftMouseDragged, location: NSPoint(x: 100, y: 100),
modifierFlags: [], timestamp: 1, windowNumber: window.windowNumber,
context: nil, eventNumber: 1, clickCount: 1, pressure: 1))
window.sendEvent(down)
window.sendEvent(dragged)
#expect(window.frame.origin.x == start.x - 25)
#expect(window.frame.origin.y == start.y + 20)
window.close()
}
@Test func hudActionButtonsDoNotStartAWindowDrag() throws {
let window = RestPanel(contentRect: NSRect(x: 500, y: 500, width: 250, height: 160),
styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
window.hudDragEnabled = true
let start = window.frame.origin
let down = try #require(NSEvent.mouseEvent(with: .leftMouseDown, location: NSPoint(x: 220, y: 135),
modifierFlags: [], timestamp: 0, windowNumber: window.windowNumber,
context: nil, eventNumber: 0, clickCount: 1, pressure: 1))
let dragged = try #require(NSEvent.mouseEvent(with: .leftMouseDragged, location: NSPoint(x: 150, y: 100),
modifierFlags: [], timestamp: 1, windowNumber: window.windowNumber,
context: nil, eventNumber: 1, clickCount: 1, pressure: 1))
window.sendEvent(down)
window.sendEvent(dragged)
#expect(window.frame.origin == start)
window.close()
}
@Test func hudStylesCycleInAStableOrder() {
#expect(RestHUDStyle.countdown.next == .ring)
#expect(RestHUDStyle.ring.next == .hourglass)
#expect(RestHUDStyle.hourglass.next == .countdown)
}
@Test func disabledPomodoroDoesNotRestoreItsPage() {
let defaults = UserDefaults(suiteName: "RestRouteTests.\(UUID())")!
defaults.set("rest", forKey: "panelTab")
#expect(AppSettings(defaults: defaults).panelTab == .settingsGeneral)
defaults.set(true, forKey: "restEnabled")
#expect(AppSettings(defaults: defaults).panelTab == .rest)
}
@Test func controllerRunsFocusBreakAndPostponeWithoutDoubleCredit() {
let defaults = UserDefaults(suiteName: "RestFlowTests.\(UUID())")!
let settings = AppSettings(defaults: defaults)
settings.restEnabled = true
var now: UInt64 = 0
let rest = RestController(settings: settings, localDefaults: defaults,
sharedDefaults: defaults, clock: { now })
var curtainChanges: [Bool] = []
rest.onRestChange = { curtainChanges.append($0) }
rest.sync()
#expect(!rest.isRunning)
rest.startPause()
#expect(rest.isRunning)
now = 25 * 60_000_000_000
rest.sync()
#expect(rest.phase == .rest)
#expect(rest.completedToday == 1)
#expect(curtainChanges == [true])
rest.postponeFiveMinutes()
#expect(rest.phase == .work)
#expect(rest.completedToday == 0)
#expect(curtainChanges == [true, false])
now = 30 * 60_000_000_000
rest.sync()
#expect(rest.completedToday == 1)
rest.prepareForTermination()
#expect(defaults.bool(forKey: "rest.running") == false)
rest.stop()
}
@Test func sleepingDoesNotCreditUnattendedFocus() {
let defaults = UserDefaults(suiteName: "RestSleepTests.\(UUID())")!
let settings = AppSettings(defaults: defaults)
settings.restEnabled = true
var now: UInt64 = 0
let rest = RestController(settings: settings, localDefaults: defaults,
sharedDefaults: defaults, clock: { now })
rest.sync()
rest.startPause()
now = 10 * 60_000_000_000
rest.suspend()
now = 25 * 60_000_000_000
rest.sync()
#expect(rest.phase == .rest)
#expect(rest.completedToday == 0)
rest.postponeFiveMinutes()
#expect(rest.completedToday == 0)
rest.stop()
}
@Test func disabledByDefaultAndBackupRestoresOnlyValidChoices() throws {
let sourceDefaults = UserDefaults(suiteName: "RestSettingsTests.source.\(UUID())")!
let targetDefaults = UserDefaults(suiteName: "RestSettingsTests.target.\(UUID())")!
let source = AppSettings(defaults: sourceDefaults)
#expect(!source.restEnabled)
source.restEnabled = true
source.restWorkMinutes = 45
source.restBreakMinutes = 5
source.restLongBreakMinutes = 20
source.restDailyGoal = 12
source.restCycleEnabled = true
source.restSound = .rain
source.restHUDStyle = .hourglass
let document = try JSONDecoder().decode(SettingsDocument.self,
from: JSONEncoder().encode(source.exportDocument()))
let target = AppSettings(defaults: targetDefaults)
target.apply(document)
#expect(target.restEnabled)
#expect(target.restWorkMinutes == 45)
#expect(target.restBreakMinutes == 5)
#expect(target.restLongBreakMinutes == 20)
#expect(target.restDailyGoal == 12)
#expect(target.restCycleEnabled)
#expect(target.restSound == .rain)
#expect(target.restHUDStyle == .hourglass)
var invalid = SettingsDocument()
invalid.restWorkMinutes = 999
invalid.restLongBreakMinutes = -1
invalid.restDailyGoal = 0
invalid.restSound = "unknown"
invalid.restHUDStyle = "unknown"
target.apply(invalid)
#expect(target.restWorkMinutes == 45)
#expect(target.restLongBreakMinutes == 20)
#expect(target.restDailyGoal == 12)
#expect(target.restSound == .rain)
#expect(target.restHUDStyle == .hourglass)
}
}
+2 -2
View File
@@ -29,8 +29,8 @@ XStats shows CPU, GPU, memory, disk, network, battery, temperature and fan activ
- **System monitoring:** choose menu-bar metrics and styles; inspect trends, processes and hardware in popovers or the main window.
- **System tools:** fan control, keep-awake, cache cleanup, app removal and startup-item management, with previews and confirmation for destructive actions.
- **Network tools:** inspect connections, DNS and public IPs; optionally check IP reputation and connectivity.
- **Optional modules:** a separate menu-bar calendar, plus local Codex / Claude Code session-token statistics and subscription quota checks.
- **Desktop widgets:** system overview, AI quotas, festival/almanac calendar, full-month calendar, tomorrow's work status, IP cleanliness and public IP. AI, IP and calendar data come only from the main app's local cache.
- **Optional modules:** a separate menu-bar calendar; a local Pomodoro timer with a multi-display eye-break curtain, instant skip and Mini HUD; plus local Codex / Claude Code session-token statistics and subscription quota checks.
- **Desktop widgets:** system overview, Pomodoro timer, AI quotas, festival/almanac calendar, full-month calendar, tomorrow's work status, IP cleanliness and public IP. AI, IP and calendar data come only from the main app's local cache.
## Install
+2 -2
View File
@@ -29,8 +29,8 @@ XStats は CPU、GPU、メモリ、ディスク、ネットワーク、バッテ
- **システム監視**:メニューバーの項目と表示形式を選び、ポップオーバーやメイン画面で推移、プロセス、ハードウェア情報を確認できます。
- **システムツール**:ファン制御、スリープ防止、キャッシュ削除、アプリの削除、起動項目の管理。削除前には内容を確認できます。
- **ネットワークツール**:接続、DNS、公開 IP を確認し、必要に応じて IP 評価や疎通を調べます。
- **任意の機能**:独立したメニューバーカレンダー、Codex / Claude Code のローカルセッションの Token 統計とサブスクリプション利用枠の照会。
- **デスクトップウィジェット**:システム概要、AI 利用枠、祝祭日・暦注カレンダー、月間カレンダー、明日の勤務予定、IP の信頼度、公開 IP。AI・IP・カレンダーのデータはメインアプリのローカルキャッシュから読み取ります。
- **任意の機能**:独立したメニューバーカレンダー、複数画面の目の休憩オーバーレイと Mini HUD を備えたローカルのポモドーロタイマー、Codex / Claude Code のローカルセッションの Token 統計とサブスクリプション利用枠の照会。
- **デスクトップウィジェット**:システム概要、ポモドーロタイマー、AI 利用枠、祝祭日・暦注カレンダー、月間カレンダー、明日の勤務予定、IP の信頼度、公開 IP。AI・IP・カレンダーのデータはメインアプリのローカルキャッシュから読み取ります。
## インストール
+2 -2
View File
@@ -29,8 +29,8 @@ XStats는 CPU, GPU, 메모리, 디스크, 네트워크, 배터리, 온도와 팬
- **시스템 모니터링**: 메뉴 막대 지표와 표시 방식을 선택하고 팝오버 또는 메인 창에서 추이, 프로세스, 하드웨어 정보를 확인합니다.
- **시스템 도구**: 팬 제어, 잠자기 방지, 캐시 정리, 앱 제거, 시작 항목 관리. 삭제 전에는 대상 확인 단계가 있습니다.
- **네트워크 도구**: 연결, DNS, 공인 IP를 확인하고 필요할 때 IP 평판과 연결 상태를 검사합니다.
- **선택 기능**: 독립적인 메뉴 막대 달력, Codex / Claude Code 로컬 세션의 토큰 통계와 구독 한도 조회.
- **데스크톱 위젯**: 시스템 개요, AI 구독 한도, 명절·황력 달력, 월간 달력, 내일 근무 여부, IP 신뢰도, 공인 IP. AI·IP·달력 데이터는 메인 앱의 로컬 캐시에서만 읽습니다.
- **선택 기능**: 독립적인 메뉴 막대 달력, 다중 화면 눈 휴식 오버레이와 Mini HUD가 있는 로컬 뽀모도로 타이머, Codex / Claude Code 로컬 세션의 토큰 통계와 구독 한도 조회.
- **데스크톱 위젯**: 시스템 개요, 뽀모도로 타이머, AI 구독 한도, 명절·황력 달력, 월간 달력, 내일 근무 여부, IP 신뢰도, 공인 IP. AI·IP·달력 데이터는 메인 앱의 로컬 캐시에서만 읽습니다.
## 설치
+2 -2
View File
@@ -29,8 +29,8 @@ XStats 在菜单栏显示 CPU、GPU、内存、磁盘、网络、电池、温度
- **系统监控**:可自选菜单栏指标与显示样式,在弹窗或主窗口查看趋势、进程和硬件信息。
- **系统工具**:风扇控制、防休眠、缓存清理、应用卸载和启动项管理;危险操作先预览、再确认。
- **网络工具**:查看连接、DNS 和公网 IP;按需检测 IP 纯净度与连接情况。
- **可选模块**:独立菜单栏日历;Codex / Claude Code 本机会话 Token 统计和订阅额度查询。
- **桌面小组件**:系统概览、AI 额度、节日黄历日历、整月日历、明天上班吗、IP 纯净度和公网 IP;AI、IP 与日历数据只读取主应用的本地缓存。
- **可选模块**:独立菜单栏日历;本机番茄钟与多屏护眼休息幕布(可随时跳过、暂停或收起到 Mini HUD);Codex / Claude Code 本机会话 Token 统计和订阅额度查询。
- **桌面小组件**:系统概览、番茄钟、AI 额度、节日黄历日历、整月日历、明天上班吗、IP 纯净度和公网 IP;AI、IP 与日历数据只读取主应用的本地缓存。
## 安装
+5
View File
@@ -8,6 +8,11 @@ XStats 新增代码与修改采用 AGPL-3.0-or-later,范围见 [LICENSING.md](
的时间/模型筛选、总览、趋势与模型排行,以及 OpenUsage 的本地会话日志解析边界。
本轮 SwiftUI 界面与本机扫描器为 XStats 独立实现,未引入 CC Switch 的 Rust/React 源码或依赖。
## 粉红噪音滤波参考
番茄钟的粉红噪音使用 [Paul Kellett 的滤波系数](https://www.musicdsp.org/en/latest/Filters/76-pink-noise-filter.html)
近似 1/f 频谱;Swift 音频生成器由 XStats 独立编写,未引入音频素材或第三方音频依赖。
## gentpan/OpenStats
XStats 基于 [gentpan/OpenStats](https://github.com/gentpan/OpenStats) 开发,感谢原项目的开源贡献。
+124
View File
@@ -0,0 +1,124 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import Localization
import SwiftUI
import WidgetKit
private struct RestEntry: TimelineEntry {
let date: Date
let enabled: Bool
let isResting: Bool
let running: Bool
let remaining: TimeInterval
let deadline: Date
let duration: TimeInterval
}
private struct RestProvider: TimelineProvider {
func placeholder(in context: Context) -> RestEntry {
RestEntry(date: .now, enabled: true, isResting: false, running: true, remaining: 20 * 60,
deadline: .now.addingTimeInterval(20 * 60), duration: 20 * 60)
}
func getSnapshot(in context: Context, completion: @escaping (RestEntry) -> Void) {
completion(context.isPreview ? placeholder(in: context) : read())
}
func getTimeline(in context: Context, completion: @escaping (Timeline<RestEntry>) -> Void) {
let entry = read()
guard entry.enabled, entry.running, entry.deadline > entry.date else {
completion(Timeline(entries: [entry], policy: .after(entry.date.addingTimeInterval(3600))))
return
}
// 倒计时文字可由系统更新;圆环需要在阶段内提供新的 TimelineEntry。
var entries = [entry]
var date = entry.date
while date < entry.deadline {
date = min(date.addingTimeInterval(60), entry.deadline)
entries.append(RestEntry(date: date, enabled: entry.enabled, isResting: entry.isResting,
running: entry.running, remaining: entry.remaining,
deadline: entry.deadline, duration: entry.duration))
}
completion(Timeline(entries: entries, policy: .after(entry.deadline)))
}
private func read() -> RestEntry {
let defaults = UserDefaults(suiteName: "group.work.12306.xstats")
if let language = defaults?.string(forKey: "rest.language").flatMap(AppLanguage.init(rawValue:)) {
L10n.configure(language)
}
let now = Date()
return RestEntry(date: now, enabled: defaults?.bool(forKey: "rest.enabled") ?? false,
isResting: ["rest", "longRest"].contains(defaults?.string(forKey: "rest.phase") ?? ""),
running: defaults?.bool(forKey: "rest.running") ?? false,
remaining: defaults?.double(forKey: "rest.remaining") ?? 0,
deadline: Date(timeIntervalSince1970: defaults?.double(forKey: "rest.deadline") ?? 0),
duration: TimeInterval(defaults?.double(forKey: "rest.duration") ?? 0))
}
}
private struct RestWidgetView: View {
@Environment(\.widgetFamily) private var family
let entry: RestEntry
var body: some View {
HStack(spacing: 12) {
ZStack {
Circle().stroke(Color.cyan.opacity(0.17), lineWidth: 8)
Circle().trim(from: 0, to: progress)
.stroke(.cyan, style: StrokeStyle(lineWidth: 8, lineCap: .round))
.rotationEffect(.degrees(-90))
Image(systemName: entry.isResting ? "eye" : "timer")
.font(.system(size: family == .systemSmall ? 28 : 36, weight: .ultraLight))
}
.frame(width: family == .systemSmall ? 76 : 96, height: family == .systemSmall ? 76 : 96)
VStack(alignment: .leading, spacing: 6) {
Text(tr(entry.enabled ? (entry.running ? (entry.isResting ? "休息中" : "专注中") : "已暂停")
: "护眼休息未开启"))
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.secondary)
if entry.enabled {
if entry.running {
Text(entry.deadline, style: .timer)
.font(.system(size: 22, weight: .semibold, design: .rounded))
.monospacedDigit()
} else {
Text(String(format: "%02d:%02d", max(0, Int(entry.remaining)) / 60,
max(0, Int(entry.remaining)) % 60))
.font(.system(size: 22, weight: .semibold, design: .rounded))
.monospacedDigit()
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.containerBackground(.background, for: .widget)
.environment(\.locale, L10n.locale(for: L10n.language))
.environment(\.layoutDirection, L10n.language.isRightToLeft ? .rightToLeft : .leftToRight)
}
private var progress: Double {
guard entry.enabled, entry.duration > 0 else { return 0 }
let remaining = entry.running ? max(0, entry.deadline.timeIntervalSince(entry.date)) : entry.remaining
return min(1, max(0, 1 - remaining / entry.duration))
}
}
struct RestWidget: Widget {
init() {
let defaults = UserDefaults(suiteName: "group.work.12306.xstats")
let language = defaults?.string(forKey: "rest.language").flatMap(AppLanguage.init(rawValue:)) ?? .system
L10n.configure(language)
}
var body: some WidgetConfiguration {
StaticConfiguration(kind: "rest", provider: RestProvider()) { entry in
RestWidgetView(entry: entry)
}
.configurationDisplayName(tr("番茄钟"))
.description(tr("查看下一次休息或当前休息倒计时。"))
.supportedFamilies([.systemSmall, .systemMedium])
}
}
+1
View File
@@ -210,6 +210,7 @@ struct SystemWidget: Widget {
struct XStatsWidgets: WidgetBundle {
var body: some Widget {
SystemWidget()
RestWidget()
CalendarWidget()
MonthCalendarWidget()
TomorrowWorkWidget()