diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index d5477aa..2bbb42b 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -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)。 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv index c67467f..b22c5d4 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv @@ -1438,3 +1438,58 @@ Sub2API 账号平台与所选来源不匹配 منصة حساب Sub2API لا تط 查看今天的节日与黄历。 اعرض مناسبات اليوم والتقويم التقليدي. 明天上班吗 هل تعمل غدًا؟ 查看明天是否上班或放假。 تحقق مما إذا كان الغد يوم عمل أم عطلة. +护眼休息 استراحة العينين +按时显示多屏柔和幕布;Esc 或长按随时跳过。全部计时留在本机。 اعرض ستارًا لطيفًا على جميع الشاشات عند موعد الاستراحة. اضغط Esc أو اضغط مطولًا للتخطي في أي وقت. يبقى المؤقت على هذا الـ Mac. +工作时长 مدة العمل +休息时长 مدة الاستراحة +迷你 HUD واجهة مصغرة +在屏幕边缘显示当前计时 إظهار المؤقت عند حافة الشاشة +显示或收起 إظهار أو إخفاء +休息声音 أصوات الاستراحة +仅在休息时本地实时合成 تُنشأ محليًا في الوقت الفعلي أثناء الاستراحة +白噪音 ضجيج أبيض +粉红噪音 ضجيج وردي +雨声 صوت المطر +让眼睛休息一下 أرح عينيك قليلًا +看看远处,轻轻眨眼,放松肩颈 انظر بعيدًا وارمش بلطف وأرخِ كتفيك +长按跳过 · 或按 Esc 随时返回 اضغط مطولًا للتخطي · أو اضغط Esc للعودة +休息中 في استراحة +下次休息 الاستراحة التالية +跳过休息 تخطي الاستراحة +重新计时 إعادة تشغيل المؤقت +收起迷你 HUD إخفاء الواجهة المصغرة +护眼休息未开启 استراحة العينين معطلة +查看下一次休息或当前休息倒计时。 اعرض العد التنازلي للاستراحة التالية أو نهاية الاستراحة الحالية. +收起到迷你 HUD طي إلى الواجهة المصغرة +番茄钟 مؤقت بومودورو +番茄钟与护眼休息 بومودورو واستراحة العينين +专注计时、每日目标与多屏休息幕布;所有数据留在本机。 مؤقت للتركيز وأهداف يومية وشاشة استراحة على جميع الشاشات. تبقى جميع البيانات على هذا الـ Mac. +专注 تركيز +短休 استراحة قصيرة +长休 استراحة طويلة +单次 مرة واحدة +循环 تكرار +计时中 جارٍ +已暂停 متوقف مؤقتًا +暂停 إيقاف مؤقت +开始 بدء +重置 إعادة ضبط +跳过 تخطي +今日目标 هدف اليوم +长休时长 مدة الاستراحة الطويلة +每完成 4 轮专注后进入长休 تبدأ استراحة طويلة بعد أربع جلسات تركيز +每日目标 الهدف اليومي +{} 轮 {} جلسات +打开番茄钟 فتح بومودورو +再专注 5 分钟 التركيز 5 دقائق إضافية +专注中 جارٍ التركيز +工作时长(分钟) مدة العمل (بالدقائق) +休息时长(分钟) مدة الاستراحة (بالدقائق) +长休时长(分钟) مدة الاستراحة الطويلة (بالدقائق) +迷你 HUD 样式 نمط الواجهة المصغرة +数字倒计时 عد تنازلي رقمي +进度圆环 حلقة التقدم +沙漏 ساعة رملية +切换迷你 HUD 样式 تبديل نمط الواجهة المصغرة +拖动迷你 HUD سحب الواجهة المصغرة +继续 متابعة diff --git a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv index f765545..5ab2923 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv @@ -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 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv index a788ec8..f2c9262 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv @@ -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 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv index 3213328..ff3de48 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv @@ -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 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv index 782de03..83ed239 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv @@ -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 をドラッグ +继续 続ける diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv index 6065517..d428f95 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv @@ -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 드래그 +继续 계속 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv index 4f716ac..0aa4315 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv @@ -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 +继续 繼續 diff --git a/Packages/XStatsKit/Sources/Localization/Translations.swift b/Packages/XStatsKit/Sources/Localization/Translations.swift index cca045e..751da79 100644 --- a/Packages/XStatsKit/Sources/Localization/Translations.swift +++ b/Packages/XStatsKit/Sources/Localization/Translations.swift @@ -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 diff --git a/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift b/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift index c9e2a5c..ff377f5 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift @@ -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 { diff --git a/Packages/XStatsKit/Sources/XStatsUI/Panel/MainWindowView.swift b/Packages/XStatsKit/Sources/XStatsUI/Panel/MainWindowView.swift index 539aae2..d9be5e4 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Panel/MainWindowView.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Panel/MainWindowView.swift @@ -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: { diff --git a/Packages/XStatsKit/Sources/XStatsUI/Rest/RestController.swift b/Packages/XStatsKit/Sources/XStatsUI/Rest/RestController.swift new file mode 100644 index 0000000..3ec2436 --- /dev/null +++ b/Packages/XStatsKit/Sources/XStatsUI/Rest/RestController.swift @@ -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)" + } +} diff --git a/Packages/XStatsKit/Sources/XStatsUI/Rest/RestMenuBarController.swift b/Packages/XStatsKit/Sources/XStatsUI/Rest/RestMenuBarController.swift new file mode 100644 index 0000000..8d8263c --- /dev/null +++ b/Packages/XStatsKit/Sources/XStatsUI/Rest/RestMenuBarController.swift @@ -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() } + } +} diff --git a/Packages/XStatsKit/Sources/XStatsUI/Rest/RestPage.swift b/Packages/XStatsKit/Sources/XStatsUI/Rest/RestPage.swift new file mode 100644 index 0000000..1f0b809 --- /dev/null +++ b/Packages/XStatsKit/Sources/XStatsUI/Rest/RestPage.swift @@ -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("长休") + } + } +} diff --git a/Packages/XStatsKit/Sources/XStatsUI/Rest/RestSettings.swift b/Packages/XStatsKit/Sources/XStatsUI/Rest/RestSettings.swift new file mode 100644 index 0000000..083c4a7 --- /dev/null +++ b/Packages/XStatsKit/Sources/XStatsUI/Rest/RestSettings.swift @@ -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() + } +} diff --git a/Packages/XStatsKit/Sources/XStatsUI/Rest/RestSound.swift b/Packages/XStatsKit/Sources/XStatsUI/Rest/RestSound.swift new file mode 100644 index 0000000..ddbe517 --- /dev/null +++ b/Packages/XStatsKit/Sources/XStatsUI/Rest/RestSound.swift @@ -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.. 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 + } + } +} diff --git a/Packages/XStatsKit/Sources/XStatsUI/Rest/RestWindows.swift b/Packages/XStatsKit/Sources/XStatsUI/Rest/RestWindows.swift new file mode 100644 index 0000000..a457f29 --- /dev/null +++ b/Packages/XStatsKit/Sources/XStatsUI/Rest/RestWindows.swift @@ -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)) + } + } + } +} diff --git a/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift b/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift index d1e49c9..32ff37d 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift @@ -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 统计可单独关闭。"), diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift b/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift index b76c74b..57993c5 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift @@ -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 diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift b/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift index 5983530..f5c3961 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift @@ -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 { 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" diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/SettingsDocument.swift b/Packages/XStatsKit/Sources/XStatsUI/State/SettingsDocument.swift index a88219d..f154358 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/SettingsDocument.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/SettingsDocument.swift @@ -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:))) }) diff --git a/Packages/XStatsKit/Tests/XStatsUITests/RestSessionTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/RestSessionTests.swift new file mode 100644 index 0000000..0246be1 --- /dev/null +++ b/Packages/XStatsKit/Tests/XStatsUITests/RestSessionTests.swift @@ -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) + } +} diff --git a/README.en.md b/README.en.md index 0626abe..052c3f8 100644 --- a/README.en.md +++ b/README.en.md @@ -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 diff --git a/README.ja.md b/README.ja.md index a8c381a..63a8aeb 100644 --- a/README.ja.md +++ b/README.ja.md @@ -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・カレンダーのデータはメインアプリのローカルキャッシュから読み取ります。 ## インストール diff --git a/README.ko.md b/README.ko.md index 636ad5a..708352c 100644 --- a/README.ko.md +++ b/README.ko.md @@ -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·달력 데이터는 메인 앱의 로컬 캐시에서만 읽습니다. ## 설치 diff --git a/README.md b/README.md index f64e8c1..a7f3740 100644 --- a/README.md +++ b/README.md @@ -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 与日历数据只读取主应用的本地缓存。 ## 安装 diff --git a/ThirdPartyNotices.md b/ThirdPartyNotices.md index 5d57198..f552865 100644 --- a/ThirdPartyNotices.md +++ b/ThirdPartyNotices.md @@ -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) 开发,感谢原项目的开源贡献。 diff --git a/Widget/RestWidget.swift b/Widget/RestWidget.swift new file mode 100644 index 0000000..9f92998 --- /dev/null +++ b/Widget/RestWidget.swift @@ -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) -> 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]) + } +} diff --git a/Widget/XStatsWidget.swift b/Widget/XStatsWidget.swift index 32f0a87..687c5a3 100644 --- a/Widget/XStatsWidget.swift +++ b/Widget/XStatsWidget.swift @@ -210,6 +210,7 @@ struct SystemWidget: Widget { struct XStatsWidgets: WidgetBundle { var body: some Widget { SystemWidget() + RestWidget() CalendarWidget() MonthCalendarWidget() TomorrowWorkWidget()