feat(rest): 添加番茄钟与多屏护眼休息
This commit is contained in:
1 parent
2832ec5790
commit
f86f2d4291
29 files changed
+2331
-12
No files matched your search
@@ -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 سحب الواجهة المصغرة
|
||||
继续 متابعة
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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 をドラッグ
|
||||
继续 続ける
|
||||
|
@@ -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 드래그
|
||||
继续 계속
|
||||
|
@@ -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
|
||||
继续 繼續
|
||||
|
@@ -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
@@ -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
@@ -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
@@ -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·달력 데이터는 메인 앱의 로컬 캐시에서만 읽습니다.
|
||||
|
||||
## 설치
|
||||
|
||||
|
||||
@@ -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 与日历数据只读取主应用的本地缓存。
|
||||
|
||||
## 安装
|
||||
|
||||
|
||||
@@ -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) 开发,感谢原项目的开源贡献。
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -210,6 +210,7 @@ struct SystemWidget: Widget {
|
||||
struct XStatsWidgets: WidgetBundle {
|
||||
var body: some Widget {
|
||||
SystemWidget()
|
||||
RestWidget()
|
||||
CalendarWidget()
|
||||
MonthCalendarWidget()
|
||||
TomorrowWorkWidget()
|
||||
|
||||
Reference in new issue
Block a user