From 3beb670849435d63043f2fc667967f266113cda4 Mon Sep 17 00:00:00 2001 From: ysicing Date: Mon, 28 Sep 2026 15:11:14 +0800 Subject: [PATCH] =?UTF-8?q?chore(history):=20=E7=A7=BB=E9=99=A4=E5=B1=8F?= =?UTF-8?q?=E5=B9=95=E4=BD=BF=E7=94=A8=E6=97=B6=E9=97=B4=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ARCHITECTURE.md | 26 +- .../Sources/Localization/Resources/ar.tsv | 12 +- .../Sources/Localization/Resources/de.tsv | 12 +- .../Sources/Localization/Resources/es.tsv | 12 +- .../Sources/Localization/Resources/fr.tsv | 12 +- .../Sources/Localization/Resources/ja.tsv | 12 +- .../Sources/Localization/Resources/ko.tsv | 12 +- .../Localization/Resources/zh-Hant.tsv | 12 +- .../Sources/Localization/Translations.swift | 12 +- .../Sources/Metrics/HistoryDatabase.swift | 108 +---- .../Sources/Metrics/ScreenTime.swift | 104 ----- .../Sources/WidgetData/WidgetSnapshot.swift | 26 +- .../XStatsUI/AppKit/AppController.swift | 49 +-- .../Sources/XStatsUI/Panel/HistoryPage.swift | 79 +--- .../XStatsUI/Resources/Legal/Privacy.en.md | 4 +- .../Resources/Legal/Privacy.zh-Hans.md | 4 +- .../Sources/XStatsUI/State/AppModel.swift | 2 - .../XStatsUI/State/HistoryRecorder.swift | 6 +- .../XStatsUI/State/ScreenTimeTracker.swift | 263 ------------ .../Tests/MetricsTests/ScreenTimeTests.swift | 96 ----- .../WidgetDataTests/WidgetSnapshotTests.swift | 14 +- .../ScreenTimeTrackerTests.swift | 389 ------------------ Widget/SharedDataWidgets.swift | 41 +- Widget/XStatsWidget.swift | 1 - 24 files changed, 38 insertions(+), 1270 deletions(-) delete mode 100644 Packages/XStatsKit/Sources/Metrics/ScreenTime.swift delete mode 100644 Packages/XStatsKit/Sources/XStatsUI/State/ScreenTimeTracker.swift delete mode 100644 Packages/XStatsKit/Tests/MetricsTests/ScreenTimeTests.swift delete mode 100644 Packages/XStatsKit/Tests/XStatsUITests/ScreenTimeTrackerTests.swift diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1dda577..1d338dc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -388,30 +388,8 @@ merge concurrent edits, or use iCloud. A missing remote file requires an initial - `HistoryRecorder` folds each sample into a per-minute record (averages, CPU and temperature peaks, worst memory pressure) and writes it to `history.sqlite`; records older than 8 days are pruned hourly. Queries bucket by 1, 5 or 30 minutes and charts break lines across gaps. -- `ScreenTimeTracker` adds daily screen time to the `screen_time` table of the same database - (one row per local day, kept 90 days). It counts only while the display is awake, the session is - active and the screen is unlocked; `AppController` stops it on display sleep, system sleep, lock - and user switch. The active interval is computed from its start time with the current-idle cutoff - for the history page and widget snapshot. It is split at local midnight when suspended, at normal - termination, or on the existing hourly widget refresh. It has no independent periodic timer. - Each settlement reads the idle time since the last keyboard or pointer input (`CGEventSource`, - no permission) and drops the current continuous idle tail beyond five minutes. A return before - the next settlement cannot be reconstructed and may count that gap. Turning history back on records - a backfill boundary in `history_metadata`, so days while it was off are never estimated later. - Termination cancels an in-flight power-log read and skips a backfill that has not started. - On its first start per launch it reads `pmset -g log` once, off the main thread, with an - eight-second and 16 MiB limit. It seeds days without a row with display-on time, flagged as - estimated and not adjusted for idle or lock time. The log's first partial day is skipped, and - today is seeded only up to the moment live tracking starts. The widget snapshot carries today's - total rounded down to 15 minutes and refreshes with the - application's existing hourly widget timer or a state change. - Clearing history invalidates queued screen-time work, clears any queued recent-day result, - and atomically persists a backfill cutoff - with the deletion. Estimated daily totals on or before the cleared day are no longer imported, - including after relaunch; live tracking resumes from the clear time. Changes to the history - recording switch invalidate in-flight backfill results, even if the switch is enabled again. - Normal application termination uses AppKit's deferred termination reply to wait for the final - screen-time settlement before exiting. +- Daily screen-time collection and its widget were removed. App termination no longer waits for a + screen-time database write. ## Localization diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv index 4ff2731..c598177 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv @@ -855,7 +855,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 ذاكرة حزم P 此设备没有可调节的风扇 لا يحتوي هذا الـ Mac على مراوح قابلة للضبط 每个应用占整机 CPU 的比例:{} 个核心全部跑满为 100%,与顶部的总占用是同一把尺子。鼠标悬停在数值上可以看按单核计的占用(活动监视器的算法) نسبة استخدام CPU الإجمالية لكل تطبيق: تشغيل كل النوى البالغ عددها {} بكامل طاقتها يساوي 100%، كما في الإجمالي أعلاه. مرّر المؤشر فوق القيمة لرؤية الاستخدام لكل نواة وفق حساب مراقب النشاط 每个指标一个图标,点击弹出该项详情 رمز لكل مؤشر؛ انقر لعرض تفاصيله -主要指标每分钟写入本机数据库并保留 7 天,屏幕使用时间保留 90 天 تُحفظ المؤشرات الرئيسية محليًا كل دقيقة لمدة 7 أيام، ووقت الشاشة لمدة 90 يومًا. +主要指标每分钟写入本机数据库并保留 7 天 تُحفظ المؤشرات الرئيسية محليًا كل دقيقة لمدة 7 أيام. 每核 {} {} لكل نواة 每根柱子一个核心,按核心类型着色 عمود لكل نواة ملوّن حسب نوعها 每点 {} {} لكل نقطة @@ -1501,15 +1501,7 @@ VPN 不允许绕开它直连,TCP 建连会被本机代理提前应答,测不 手动开始工作,专注与休息自动交替;每 4 轮进入长休。 ابدأ العمل يدويًا. تتناوب فترات التركيز والراحة تلقائيًا، وتأتي استراحة طويلة بعد أربع فترات تركيز. 起身活动一下 انهض وتحرك قليلًا 走动几步,看看远处,让身体和眼睛都休息 امشِ قليلًا وانظر إلى البعيد لإراحة جسمك وعينيك -屏幕使用时间 وقت الشاشة -今天 {} اليوم {} -有记录的日期日均 {} المتوسط اليومي للأيام المسجلة: {} -浅色为根据系统电源日志估算,可能包含锁屏时间 تشير الأشرطة الفاتحة إلى تقديرات من سجل طاقة النظام، وقد تشمل وقت قفل الشاشة -今日屏幕使用时间 وقت الشاشة اليوم -XStats 运行时统计屏幕亮着且未锁定的时间 يُحتسب أثناء تشغيل XStats وقت إضاءة الشاشة عندما تكون غير مقفلة -查看今天屏幕亮着且未锁定的时间 اعرض مدة إضاءة الشاشة وهي غير مقفلة اليوم -定期更新 يُحدَّث دوريًا -同时清除指标和屏幕使用时间 مسح المؤشرات ووقت الشاشة معًا +清除本机记录的指标历史 مسح سجل المؤشرات المحفوظ محليًا XStats 反馈 · {} ملاحظات XStats · {} 应用版本:{} إصدار التطبيق: {} 系统版本:{} إصدار النظام: {} diff --git a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv index 16ed74e..8c39f67 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv @@ -855,7 +855,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Von uv geladener Py 此设备没有可调节的风扇 Dieser Mac hat keine regelbaren Lüfter 每个应用占整机 CPU 的比例:{} 个核心全部跑满为 100%,与顶部的总占用是同一把尺子。鼠标悬停在数值上可以看按单核计的占用(活动监视器的算法) Anteil jeder App an der gesamten CPU: Alle {} Kerne voll ausgelastet entsprechen 100 %, wie bei der Gesamtauslastung oben. Beim Bewegen des Zeigers über den Wert erscheint die Auslastung je Kern (wie in der Aktivitätsanzeige) 每个指标一个图标,点击弹出该项详情 Ein Symbol je Kennzahl; ein Klick öffnet die Details -主要指标每分钟写入本机数据库并保留 7 天,屏幕使用时间保留 90 天 Wichtige Messwerte werden jede Minute lokal gespeichert und 7 Tage aufbewahrt, die Bildschirmzeit 90 Tage. +主要指标每分钟写入本机数据库并保留 7 天 Wichtige Messwerte werden jede Minute lokal gespeichert und 7 Tage aufbewahrt. 每核 {} {} je Kern 每根柱子一个核心,按核心类型着色 Ein Balken je Kern, nach Kerntyp gefärbt 每点 {} {} je Punkt @@ -1501,15 +1501,7 @@ VPN 不允许绕开它直连,TCP 建连会被本机代理提前应答,测不 手动开始工作,专注与休息自动交替;每 4 轮进入长休。 Arbeit manuell beginnen. Fokus und Pausen wechseln automatisch; nach vier Fokusphasen folgt eine lange Pause. 起身活动一下 Aufstehen und bewegen 走动几步,看看远处,让身体和眼睛都休息 Gehen Sie ein paar Schritte und blicken Sie in die Ferne, um Körper und Augen zu entspannen -屏幕使用时间 Bildschirmzeit -今天 {} Heute {} -有记录的日期日均 {} Tagesdurchschnitt an erfassten Tagen: {} -浅色为根据系统电源日志估算,可能包含锁屏时间 Hellere Balken sind Schätzwerte aus dem Systemstromprotokoll und können Sperrzeiten enthalten -今日屏幕使用时间 Heutige Bildschirmzeit -XStats 运行时统计屏幕亮着且未锁定的时间 Zählt bei laufendem XStats die Zeit mit eingeschaltetem und entsperrtem Bildschirm -查看今天屏幕亮着且未锁定的时间 Zeigt die heutige Zeit mit eingeschaltetem und entsperrtem Bildschirm -定期更新 Wird regelmäßig aktualisiert -同时清除指标和屏幕使用时间 Messwerte und Bildschirmzeit zusammen löschen +清除本机记录的指标历史 Lokal gespeicherte Messwertverläufe löschen XStats 反馈 · {} XStats-Feedback · {} 应用版本:{} App-Version: {} 系统版本:{} Systemversion: {} diff --git a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv index aa85b2a..477c796 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv @@ -855,7 +855,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Cachés de paquetes 此设备没有可调节的风扇 Este Mac no tiene ventiladores ajustables 每个应用占整机 CPU 的比例:{} 个核心全部跑满为 100%,与顶部的总占用是同一把尺子。鼠标悬停在数值上可以看按单核计的占用(活动监视器的算法) Uso de CPU total por app: los {} núcleos a plena carga equivalen al 100%, igual que el total superior. Pasa el cursor sobre el valor para ver el uso por núcleo, como en Monitor de Actividad 每个指标一个图标,点击弹出该项详情 Un icono por métrica; pulsa para ver detalles -主要指标每分钟写入本机数据库并保留 7 天,屏幕使用时间保留 90 天 Las métricas principales se guardan localmente cada minuto y se conservan 7 días; el tiempo de pantalla, 90 días. +主要指标每分钟写入本机数据库并保留 7 天 Las métricas principales se guardan localmente cada minuto y se conservan 7 días. 每核 {} {} por núcleo 每根柱子一个核心,按核心类型着色 Una barra por núcleo, coloreada según su tipo 每点 {} {} por punto @@ -1501,15 +1501,7 @@ VPN 不允许绕开它直连,TCP 建连会被本机代理提前应答,测不 手动开始工作,专注与休息自动交替;每 4 轮进入长休。 Inicia el trabajo manualmente. La concentración y los descansos se alternan; tras cuatro sesiones, llega un descanso largo. 起身活动一下 Levántate y muévete 走动几步,看看远处,让身体和眼睛都休息 Camina un poco y mira a lo lejos para descansar el cuerpo y la vista -屏幕使用时间 Tiempo de pantalla -今天 {} Hoy {} -有记录的日期日均 {} Media diaria de los días registrados: {} -浅色为根据系统电源日志估算,可能包含锁屏时间 Las barras claras se estiman con el registro de energía del sistema y pueden incluir tiempo de bloqueo -今日屏幕使用时间 Tiempo de pantalla de hoy -XStats 运行时统计屏幕亮着且未锁定的时间 Cuenta el tiempo con la pantalla encendida y desbloqueada mientras XStats está abierto -查看今天屏幕亮着且未锁定的时间 Consulta cuánto tiempo ha estado la pantalla encendida y desbloqueada hoy -定期更新 Se actualiza periódicamente -同时清除指标和屏幕使用时间 Borrar métricas y tiempo de pantalla a la vez +清除本机记录的指标历史 Borrar el historial de métricas guardado en este dispositivo XStats 反馈 · {} Comentarios sobre XStats · {} 应用版本:{} Versión de la app: {} 系统版本:{} Versión del sistema: {} diff --git a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv index 9f4b207..9ad32de 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv @@ -855,7 +855,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Caches de paquets P 此设备没有可调节的风扇 Ce Mac ne dispose d’aucun ventilateur réglable 每个应用占整机 CPU 的比例:{} 个核心全部跑满为 100%,与顶部的总占用是同一把尺子。鼠标悬停在数值上可以看按单核计的占用(活动监视器的算法) Part de chaque app dans le CPU total : les {} cœurs entièrement utilisés représentent 100 %, comme le total ci-dessus. Survolez une valeur pour voir l’utilisation par cœur, comme dans Moniteur d’activité 每个指标一个图标,点击弹出该项详情 Une icône par mesure ; cliquez pour afficher ses détails -主要指标每分钟写入本机数据库并保留 7 天,屏幕使用时间保留 90 天 Les principales mesures sont enregistrées localement chaque minute et conservées 7 jours ; le temps d’écran est conservé 90 jours. +主要指标每分钟写入本机数据库并保留 7 天 Les principales mesures sont enregistrées localement chaque minute et conservées 7 jours. 每核 {} {} par cœur 每根柱子一个核心,按核心类型着色 Une barre par cœur, colorée selon son type 每点 {} {} par point @@ -1501,15 +1501,7 @@ VPN 不允许绕开它直连,TCP 建连会被本机代理提前应答,测不 手动开始工作,专注与休息自动交替;每 4 轮进入长休。 Démarrez le travail manuellement. Concentration et pauses alternent ; une longue pause suit quatre sessions. 起身活动一下 Levez-vous et bougez 走动几步,看看远处,让身体和眼睛都休息 Marchez un peu et regardez au loin pour reposer votre corps et vos yeux -屏幕使用时间 Temps d’écran -今天 {} Aujourd’hui {} -有记录的日期日均 {} Moyenne quotidienne des jours enregistrés : {} -浅色为根据系统电源日志估算,可能包含锁屏时间 Les barres claires sont estimées à partir du journal d’alimentation et peuvent inclure le temps verrouillé -今日屏幕使用时间 Temps d’écran du jour -XStats 运行时统计屏幕亮着且未锁定的时间 Compté pendant l’exécution de XStats : temps écran allumé et déverrouillé -查看今天屏幕亮着且未锁定的时间 Voir le temps passé aujourd’hui avec l’écran allumé et déverrouillé -定期更新 Mise à jour périodique -同时清除指标和屏幕使用时间 Effacer les mesures et le temps d’écran ensemble +清除本机记录的指标历史 Effacer l’historique des mesures enregistrées sur cet appareil XStats 反馈 · {} Commentaires XStats · {} 应用版本:{} Version de l’app : {} 系统版本:{} Version du système : {} diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv index eeeaab2..0fd4135 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv @@ -855,7 +855,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uvのPythonパッ 此设备没有可调节的风扇 このMacには調整可能なファンがありません 每个应用占整机 CPU 的比例:{} 个核心全部跑满为 100%,与顶部的总占用是同一把尺子。鼠标悬停在数值上可以看按单核计的占用(活动监视器的算法) 各アプリのCPU全体に対する割合です。全{}コアの最大使用時を100%とし、上の合計と同じ尺度です。値にポインタを重ねるとコア単位の使用率(アクティビティモニタ方式)を表示します 每个指标一个图标,点击弹出该项详情 指標ごとにアイコンを表示し、クリックで詳細を開きます -主要指标每分钟写入本机数据库并保留 7 天,屏幕使用时间保留 90 天 主要指標は毎分ローカルデータベースに記録して7日間、画面使用時間は90日間保持します。 +主要指标每分钟写入本机数据库并保留 7 天 主要指標を毎分ローカルデータベースに記録し、7日間保持します。 每核 {} コア当たり{} 每根柱子一个核心,按核心类型着色 コアごとに1本のバーを表示し、コアの種類で色分け 每点 {} 1点当たり{} @@ -1501,15 +1501,7 @@ VPN 不允许绕开它直连,TCP 建连会被本机代理提前应答,测不 手动开始工作,专注与休息自动交替;每 4 轮进入长休。 手動で仕事を始めると、集中と休憩が自動で切り替わり、4 回の集中後に長い休憩に入ります。 起身活动一下 立って体を動かしましょう 走动几步,看看远处,让身体和眼睛都休息 少し歩いて遠くを見て、体と目を休めましょう -屏幕使用时间 画面使用時間 -今天 {} 今日 {} -有记录的日期日均 {} 記録のある日の1日平均 {} -浅色为根据系统电源日志估算,可能包含锁屏时间 淡い色はシステムの電源ログによる推定値で、画面ロック中の時間を含む場合があります -今日屏幕使用时间 今日の画面使用時間 -XStats 运行时统计屏幕亮着且未锁定的时间 XStats の実行中に画面がオンでロックされていない時間を集計します -查看今天屏幕亮着且未锁定的时间 今日、画面がオンでロックされていない時間を確認します -定期更新 定期的に更新 -同时清除指标和屏幕使用时间 指標と画面使用時間をまとめて消去 +清除本机记录的指标历史 このデバイスに保存された指標の履歴を消去 XStats 反馈 · {} XStats フィードバック · {} 应用版本:{} アプリのバージョン:{} 系统版本:{} システムのバージョン:{} diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv index 4b35d9e..a9ad183 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv @@ -855,7 +855,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uv의 Python 패키 此设备没有可调节的风扇 이 Mac에는 조절 가능한 팬이 없습니다 每个应用占整机 CPU 的比例:{} 个核心全部跑满为 100%,与顶部的总占用是同一把尺子。鼠标悬停在数值上可以看按单核计的占用(活动监视器的算法) 앱별 전체 CPU 비율입니다. 코어 {}개가 모두 최대 사용 중일 때 100%로 상단 합계와 같은 기준입니다. 값 위에 포인터를 올리면 코어별 사용률(활성 상태 보기 방식)을 볼 수 있습니다 每个指标一个图标,点击弹出该项详情 지표마다 아이콘을 표시하며 클릭하면 상세 정보가 열립니다 -主要指标每分钟写入本机数据库并保留 7 天,屏幕使用时间保留 90 天 주요 지표는 매분 로컬 데이터베이스에 기록해 7일간, 화면 사용 시간은 90일간 보관합니다. +主要指标每分钟写入本机数据库并保留 7 天 주요 지표는 매분 로컬 데이터베이스에 기록해 7일간 보관합니다. 每核 {} 코어당 {} 每根柱子一个核心,按核心类型着色 코어당 막대 하나를 표시하고 코어 유형별로 색상 구분 每点 {} 점당 {} @@ -1501,15 +1501,7 @@ VPN 不允许绕开它直连,TCP 建连会被本机代理提前应答,测不 手动开始工作,专注与休息自动交替;每 4 轮进入长休。 업무를 직접 시작하면 집중과 휴식이 자동으로 번갈아 진행되며, 집중 4회 후 긴 휴식이 시작됩니다. 起身活动一下 일어나서 움직이세요 走动几步,看看远处,让身体和眼睛都休息 잠시 걷고 먼 곳을 바라보며 몸과 눈을 쉬게 하세요 -屏幕使用时间 화면 사용 시간 -今天 {} 오늘 {} -有记录的日期日均 {} 기록된 날의 하루 평균 {} -浅色为根据系统电源日志估算,可能包含锁屏时间 연한 색은 시스템 전원 로그의 추정치이며 화면 잠금 시간을 포함할 수 있습니다 -今日屏幕使用时间 오늘의 화면 사용 시간 -XStats 运行时统计屏幕亮着且未锁定的时间 XStats 실행 중 화면이 켜져 있고 잠기지 않은 시간을 집계합니다 -查看今天屏幕亮着且未锁定的时间 오늘 화면이 켜져 있고 잠기지 않은 시간을 확인합니다 -定期更新 정기적으로 업데이트 -同时清除指标和屏幕使用时间 지표와 화면 사용 시간을 함께 지웁니다 +清除本机记录的指标历史 기기에 저장된 지표 기록 삭제 XStats 反馈 · {} XStats 피드백 · {} 应用版本:{} 앱 버전: {} 系统版本:{} 시스템 버전: {} diff --git a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv index 7df20e7..a406c1e 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv @@ -856,7 +856,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uv 下載的 Python 此设备没有可调节的风扇 此設備沒有可調節的風扇 每个应用占整机 CPU 的比例:{} 个核心全部跑满为 100%,与顶部的总占用是同一把尺子。鼠标悬停在数值上可以看按单核计的占用(活动监视器的算法) 每個應用佔整機 CPU 的比例:{} 個核心全部跑滿為 100%,與頂部的總佔用是同一把尺子。鼠標懸停在數值上可以看按單核計的佔用(活動監視器的算法) 每个指标一个图标,点击弹出该项详情 每個指標一個圖標,點擊彈出該項詳情 -主要指标每分钟写入本机数据库并保留 7 天,屏幕使用时间保留 90 天 主要指標每分鐘寫入本機資料庫並保留 7 天,螢幕使用時間保留 90 天 +主要指标每分钟写入本机数据库并保留 7 天 主要指標每分鐘寫入本機資料庫並保留 7 天 每核 {} 每核 {} 每根柱子一个核心,按核心类型着色 每根柱子一個核心,按核心類型著色 每点 {} 每點 {} @@ -1502,15 +1502,7 @@ VPN 不允许绕开它直连,TCP 建连会被本机代理提前应答,测不 手动开始工作,专注与休息自动交替;每 4 轮进入长休。 手動開始工作,專注與休息自動交替;每 4 輪進入長休。 起身活动一下 起身活動一下 走动几步,看看远处,让身体和眼睛都休息 走動幾步,看看遠方,讓身體和眼睛都休息 -屏幕使用时间 螢幕使用時間 -今天 {} 今天 {} -有记录的日期日均 {} 有記錄的日期日均 {} -浅色为根据系统电源日志估算,可能包含锁屏时间 淺色為根據系統電源日誌估算,可能包含鎖屏時間 -今日屏幕使用时间 今日螢幕使用時間 -XStats 运行时统计屏幕亮着且未锁定的时间 XStats 執行時統計螢幕亮著且未鎖定的時間 -查看今天屏幕亮着且未锁定的时间 查看今天螢幕亮著且未鎖定的時間 -定期更新 定期更新 -同时清除指标和屏幕使用时间 同時清除指標與螢幕使用時間 +清除本机记录的指标历史 清除本機記錄的指標歷史 XStats 反馈 · {} XStats 意見回饋 · {} 应用版本:{} App 版本:{} 系统版本:{} 系統版本:{} diff --git a/Packages/XStatsKit/Sources/Localization/Translations.swift b/Packages/XStatsKit/Sources/Localization/Translations.swift index ef5be7e..6886afc 100644 --- a/Packages/XStatsKit/Sources/Localization/Translations.swift +++ b/Packages/XStatsKit/Sources/Localization/Translations.swift @@ -980,7 +980,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Python package and 此设备没有可调节的风扇 This Mac has no adjustable fans 每个应用占整机 CPU 的比例:{} 个核心全部跑满为 100%,与顶部的总占用是同一把尺子。鼠标悬停在数值上可以看按单核计的占用(活动监视器的算法) Each app's share of total CPU: all {} cores fully busy is 100%, the same scale as the total above. Hover a value to see it per core (the Activity Monitor way) 每个指标一个图标,点击弹出该项详情 One icon per metric; click for its details -主要指标每分钟写入本机数据库并保留 7 天,屏幕使用时间保留 90 天 Key metrics are written to a local database every minute and kept for 7 days; screen time is kept for 90 days. +主要指标每分钟写入本机数据库并保留 7 天 Key metrics are written to a local database every minute and kept for 7 days. 每核 {} {} per core 每根柱子一个核心,按核心类型着色 One bar per core, colored by core type 每点 {} {} per point @@ -1367,15 +1367,7 @@ Apple 智能本机模型当前不可用。 The on-device Apple Intelligence mode ,充电中 , charging ,已从程序坞移除 , removed from the Dock :{} : {} -屏幕使用时间 Screen Time -今天 {} Today {} -有记录的日期日均 {} Daily average on recorded days: {} -浅色为根据系统电源日志估算,可能包含锁屏时间 Lighter bars are estimated from the system power log and may include locked time -今日屏幕使用时间 Today's Screen Time -XStats 运行时统计屏幕亮着且未锁定的时间 Counted while XStats runs: time the screen is on and unlocked -查看今天屏幕亮着且未锁定的时间 See how long the screen has been on and unlocked today -定期更新 Updates periodically -同时清除指标和屏幕使用时间 Clear metrics and screen time together +清除本机记录的指标历史 Clear locally recorded metric history. XStats 反馈 · {} XStats Feedback · {} 应用版本:{} App version: {} 系统版本:{} System version: {} diff --git a/Packages/XStatsKit/Sources/Metrics/HistoryDatabase.swift b/Packages/XStatsKit/Sources/Metrics/HistoryDatabase.swift index fff0824..85414e5 100644 --- a/Packages/XStatsKit/Sources/Metrics/HistoryDatabase.swift +++ b/Packages/XStatsKit/Sources/Metrics/HistoryDatabase.swift @@ -51,8 +51,7 @@ public struct HistoryPoint: Sendable, Equatable { public var battery: Double? } -/// 本机 SQLite 历史库(~/Library/Application Support/XStats/history.sqlite):每分钟指标汇总保留 8 天(约 1 万行), -/// 屏幕使用时间一天一行,保留 90 天 +/// 本机 SQLite 历史库(~/Library/Application Support/XStats/history.sqlite):每分钟指标汇总保留 8 天(约 1 万行) public actor HistoryDatabase { public enum Error: Swift.Error { case open(String) @@ -83,12 +82,6 @@ public actor HistoryDatabase { cpu REAL, cpu_max REAL, memory REAL, pressure INTEGER, download REAL, upload REAL, gpu REAL, temperature REAL, power REAL, battery REAL ); - CREATE TABLE IF NOT EXISTS screen_time ( - day INTEGER PRIMARY KEY, seconds REAL NOT NULL, estimated INTEGER NOT NULL DEFAULT 0 - ); - CREATE TABLE IF NOT EXISTS history_metadata ( - key TEXT PRIMARY KEY, value REAL NOT NULL - ); """ guard sqlite3_exec(handle, schema, nil, nil, nil) == SQLITE_OK else { let message = String(cString: sqlite3_errmsg(handle)) @@ -164,105 +157,12 @@ public actor HistoryDatabase { sqlite3_step(statement) } - public func clear(at date: Date = Date()) { - // 删除数据和记录补录边界必须同时成功,重启不能复原用户已清除的历史。 - guard sqlite3_exec(db, "BEGIN IMMEDIATE; DELETE FROM samples; DELETE FROM screen_time;", nil, nil, nil) == SQLITE_OK else { + public func clear() { + guard sqlite3_exec(db, "BEGIN IMMEDIATE; DELETE FROM samples; COMMIT;", nil, nil, nil) == SQLITE_OK else { sqlite3_exec(db, "ROLLBACK;", nil, nil, nil) return } - var statement: OpaquePointer? - let sql = "INSERT OR REPLACE INTO history_metadata (key, value) VALUES ('screenTimeClearedAt', ?)" - guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { - sqlite3_exec(db, "ROLLBACK;", nil, nil, nil) - return - } - sqlite3_bind_double(statement, 1, date.timeIntervalSince1970) - let saved = sqlite3_step(statement) == SQLITE_DONE - sqlite3_finalize(statement) - guard saved else { - sqlite3_exec(db, "ROLLBACK;", nil, nil, nil) - return - } - sqlite3_exec(db, "COMMIT; VACUUM;", nil, nil, nil) - } - - // MARK: 屏幕使用时间(按当地零点的 Unix 时间戳一天一行) - - /// 实时记录:累加到当天 - public func addScreenTime(_ seconds: TimeInterval, day: Date) { - guard seconds > 0, seconds.isFinite else { return } - let sql = """ - INSERT INTO screen_time (day, seconds, estimated) VALUES (?, ?, 0) - ON CONFLICT(day) DO UPDATE SET seconds = seconds + excluded.seconds - """ - execute(sql, day: day, seconds: seconds) - } - - /// 此刻及之前的日期不再用电源日志补录;用于重新开启历史记录,关闭期间不应被估算回来 - public func blockScreenTimeBackfill(through date: Date) { - var statement: OpaquePointer? - let sql = """ - INSERT INTO history_metadata (key, value) VALUES ('screenTimeClearedAt', ?) - ON CONFLICT(key) DO UPDATE SET value = MAX(value, excluded.value) - """ - guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return } - defer { sqlite3_finalize(statement) } - sqlite3_bind_double(statement, 1, date.timeIntervalSince1970) - sqlite3_step(statement) - } - - /// 电源日志估算:只填补还没有记录的日期,不覆盖实时数据 - public func seedScreenTime(_ seconds: TimeInterval, day: Date) { - guard seconds > 0, seconds.isFinite else { return } - // 估算只有日汇总,无法从中扣除清除前的部分,因此清除当天也不再补录。 - var statement: OpaquePointer? - let sql = "SELECT value FROM history_metadata WHERE key = 'screenTimeClearedAt'" - guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return } - defer { sqlite3_finalize(statement) } - if sqlite3_step(statement) == SQLITE_ROW, - day.timeIntervalSince1970 <= sqlite3_column_double(statement, 0) { return } - execute("INSERT OR IGNORE INTO screen_time (day, seconds, estimated) VALUES (?, ?, 1)", day: day, seconds: seconds) - } - - /// `from` 当天起、`to` 当天之前(按零点比较) - public func screenTime(from start: Date, to end: Date) -> [ScreenTimeDay] { - let sql = "SELECT day, seconds, estimated FROM screen_time WHERE day >= ? AND day < ? ORDER BY day" - var statement: OpaquePointer? - guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return [] } - defer { sqlite3_finalize(statement) } - sqlite3_bind_int64(statement, 1, Int64(start.timeIntervalSince1970)) - sqlite3_bind_int64(statement, 2, Int64(end.timeIntervalSince1970)) - var days: [ScreenTimeDay] = [] - while sqlite3_step(statement) == SQLITE_ROW { - days.append(ScreenTimeDay(day: Date(timeIntervalSince1970: TimeInterval(sqlite3_column_int64(statement, 0))), - seconds: sqlite3_column_double(statement, 1), - estimated: sqlite3_column_int(statement, 2) != 0)) - } - return days - } - - public func hasScreenTime() -> Bool { - var statement: OpaquePointer? - guard sqlite3_prepare_v2(db, "SELECT 1 FROM screen_time LIMIT 1", -1, &statement, nil) == SQLITE_OK else { return false } - defer { sqlite3_finalize(statement) } - return sqlite3_step(statement) == SQLITE_ROW - } - - public func pruneScreenTime(before date: Date) { - var statement: OpaquePointer? - guard sqlite3_prepare_v2(db, "DELETE FROM screen_time WHERE day < ?", -1, &statement, nil) == SQLITE_OK else { return } - defer { sqlite3_finalize(statement) } - sqlite3_bind_int64(statement, 1, Int64(date.timeIntervalSince1970)) - sqlite3_step(statement) - } - - private func execute(_ sql: String, day: Date, seconds: TimeInterval) { - var statement: OpaquePointer? - guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return } - defer { sqlite3_finalize(statement) } - sqlite3_bind_int64(statement, 1, Int64(day.timeIntervalSince1970)) - sqlite3_bind_double(statement, 2, seconds) - sqlite3_step(statement) + sqlite3_exec(db, "VACUUM;", nil, nil, nil) } public func count() -> Int { diff --git a/Packages/XStatsKit/Sources/Metrics/ScreenTime.swift b/Packages/XStatsKit/Sources/Metrics/ScreenTime.swift deleted file mode 100644 index 7dc70e6..0000000 --- a/Packages/XStatsKit/Sources/Metrics/ScreenTime.swift +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright (C) 2026 ysicing -// SPDX-License-Identifier: AGPL-3.0-or-later - -import Foundation - -/// 某一天的屏幕使用时间;`estimated` 表示含有电源日志估算、可能包含锁屏时间的部分 -public struct ScreenTimeDay: Sendable, Equatable { - /// 当地零点 - public var day: Date - public var seconds: TimeInterval - public var estimated: Bool - - public init(day: Date, seconds: TimeInterval, estimated: Bool) { - self.day = day - self.seconds = seconds - self.estimated = estimated - } -} - -/// 屏幕使用时间的纯计算:按天拆分、解析系统电源日志 -public enum ScreenTime { - public static let retention: TimeInterval = 90 * 24 * 60 * 60 - - /// 把一段时间按当地自然日拆开,跨过零点的部分分别计入两天 - public static func split(from start: Date, to end: Date, calendar: Calendar = .current) -> [(day: Date, seconds: TimeInterval)] { - guard end > start else { return [] } - var parts: [(Date, TimeInterval)] = [] - var cursor = start - while cursor < end { - let day = calendar.startOfDay(for: cursor) - let next = calendar.date(byAdding: .day, value: 1, to: day) ?? end - let segmentEnd = min(next, end) - parts.append((day, segmentEnd.timeIntervalSince(cursor))) - cursor = segmentEnd - } - return parts - } - - /// 结算时距上次键鼠输入超过这么久,视为人已离开(例如开着防休眠让屏幕常亮) - public static let idleThreshold: TimeInterval = 5 * 60 - - /// 最后一次键鼠输入后的 `threshold` 秒内仍算在用;更久没有输入的部分不计入 - public static func activeEnd(at now: Date, idleSeconds: TimeInterval, threshold: TimeInterval = idleThreshold) -> Date { - min(now, now.addingTimeInterval(threshold - max(0, idleSeconds))) - } - - /// 从 `pmset -g log` 输出中取出屏幕点亮的时段。日志格式没有公开文档,只识别 - /// “Display is turned on/off” 两类行;日志末尾仍亮着的时段截止到 `now` - public static func displayIntervals(powerLog: String, now: Date) -> [DateInterval] { - let formatter = logDateFormatter() - var events: [(date: Date, on: Bool)] = [] - for line in powerLog.split(separator: "\n") where line.contains("Display is turned ") { - let on = line.contains("Display is turned on") - guard on || line.contains("Display is turned off"), - let date = timestamp(of: line, formatter: formatter) else { continue } - events.append((date, on)) - } - events.sort { $0.date < $1.date } - var intervals: [DateInterval] = [] - var litSince: Date? - for event in events { - if event.on { - if litSince == nil { litSince = event.date } - } else if let start = litSince { - intervals.append(DateInterval(start: start, end: event.date)) - litSince = nil - } - } - if let start = litSince, now > start { intervals.append(DateInterval(start: start, end: now)) } - return intervals - } - - /// 电源日志里最早一条记录的时间;它所在的那天之前没有数据,当天也只覆盖了一部分 - public static func logStart(powerLog: String) -> Date? { - let formatter = logDateFormatter() - for line in powerLog.split(separator: "\n") { - if let date = timestamp(of: line, formatter: formatter) { return date } - } - return nil - } - - /// 各时段按当地自然日累加 - public static func totals(of intervals: [DateInterval], calendar: Calendar = .current) -> [Date: TimeInterval] { - var totals: [Date: TimeInterval] = [:] - for interval in intervals { - for part in split(from: interval.start, to: interval.end, calendar: calendar) { - totals[part.day, default: 0] += part.seconds - } - } - return totals - } - - private static func logDateFormatter() -> DateFormatter { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.dateFormat = "yyyy-MM-dd HH:mm:ss Z" - return formatter - } - - /// 行首固定为 “2026-09-26 13:04:11 +0800” 这样 25 个字符 - private static func timestamp(of line: Substring, formatter: DateFormatter) -> Date? { - line.count > 25 ? formatter.date(from: String(line.prefix(25))) : nil - } -} diff --git a/Packages/XStatsKit/Sources/WidgetData/WidgetSnapshot.swift b/Packages/XStatsKit/Sources/WidgetData/WidgetSnapshot.swift index d0caf41..51ff8b1 100644 --- a/Packages/XStatsKit/Sources/WidgetData/WidgetSnapshot.swift +++ b/Packages/XStatsKit/Sources/WidgetData/WidgetSnapshot.swift @@ -17,17 +17,6 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable { } } - /// 当天的屏幕使用时间;主应用按 15 分钟取整写入,避免每分钟重载 Widget - public struct ScreenTimeUsage: Codable, Equatable, Sendable { - public let day: Date - public let seconds: Int - - public init(day: Date, seconds: Int) { - self.day = day - self.seconds = seconds - } - } - public struct CalendarSummary: Codable, Equatable, Sendable { public enum Schedule: String, Codable, Sendable { case work, weekend, holiday, makeupWork, makeupDayOff, unknown @@ -183,17 +172,13 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable { public var monthSummaries: [MonthSummary]? /// 写入日历摘要的主应用构建号;新版可能内置新的放假数据,构建号不同时不复用缓存。 public var calendarDataVersion: String? - /// 关闭历史记录时为 nil;可选以兼容旧版摘要 - public var screenTime: ScreenTimeUsage? - public init(aiEnabled: Bool = false, quotas: [Quota] = [], dailyTokens: [DailyTokenUsage]? = nil, localUsageEnabled: Bool? = nil, publicIPEnabled: Bool = false, addresses: [Address] = [], language: String = "system", calendarFirstWeekday: Int = 2, showsLunar: Bool = true, calendarDays: [CalendarSummary]? = nil, monthSummaries: [MonthSummary]? = nil, - showsSeasonal: Bool? = nil, calendarDataVersion: String? = nil, - screenTime: ScreenTimeUsage? = nil) { + showsSeasonal: Bool? = nil, calendarDataVersion: String? = nil) { self.aiEnabled = aiEnabled self.quotas = quotas self.dailyTokens = dailyTokens @@ -207,13 +192,6 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable { self.calendarDays = calendarDays self.monthSummaries = monthSummaries self.calendarDataVersion = calendarDataVersion - self.screenTime = screenTime - } - - /// 与当日 Token 相同:旧日的记录归零,不把昨天的时长当作今天 - public func todayScreenTime(at date: Date = .now, calendar: Calendar = .autoupdatingCurrent) -> Int? { - guard let screenTime else { return nil } - return calendar.isDate(screenTime.day, inSameDayAs: date) ? screenTime.seconds : 0 } public var showsLocalUsage: Bool { aiEnabled && localUsageEnabled != false } @@ -246,7 +224,6 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable { var kinds: [String] = [] if quotaChanged || displayed(quotas) != displayed(previous.quotas) { kinds.append(WidgetKind.aiQuota) } if localUsageChanged || dailyTokens != previous.dailyTokens { kinds.append(WidgetKind.todayTokens) } - if relocalized || screenTime != previous.screenTime { kinds.append(WidgetKind.screenTime) } if relocalized || publicIPEnabled != previous.publicIPEnabled || visibleAddresses != previous.visibleAddresses { kinds += [WidgetKind.ipPurity, WidgetKind.publicIP] @@ -310,7 +287,6 @@ public enum WidgetKind { public static let calendar = "work.12306.xstats.widget.calendar" public static let calendarMonth = "work.12306.xstats.widget.calendarMonth" public static let tomorrowWork = "work.12306.xstats.widget.tomorrowWork" - public static let screenTime = "work.12306.xstats.widget.screenTime" } public struct WidgetSnapshotStore { diff --git a/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift b/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift index 03ffc8d..859cd6e 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift @@ -31,8 +31,6 @@ public final class AppController: NSObject, NSApplicationDelegate { private let hotKeys = HotKeyCenter() private var workspaceObservers: [NSObjectProtocol] = [] private var screenLocked = false - private var screenTimePausedByWorkspace = false - private var preparingToTerminate = false public override init() { super.init() @@ -117,8 +115,6 @@ public final class AppController: NSObject, NSApplicationDelegate { } observeWorkspace() - model.screenTime.start() - observeScreenTimeHistorySetting() observeRestSettings() observeCleanerSetting() observeRestMode() @@ -133,13 +129,7 @@ public final class AppController: NSObject, NSApplicationDelegate { syncWidgetSnapshot() widgetTimer = Timer.scheduledTimer(withTimeInterval: 60 * 60, repeats: true) { [weak self] _ in MainActor.assumeIsolated { - guard let self else { return } - self.model.screenTime.checkpoint() - Task { @MainActor [weak self] in - guard let self else { return } - await self.model.screenTime.pending?.value - self.syncWidgetSnapshot() - } + self?.syncWidgetSnapshot() } } widgetTimer?.tolerance = 5 * 60 @@ -217,16 +207,6 @@ public final class AppController: NSObject, NSApplicationDelegate { } } - public func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { - guard !preparingToTerminate else { return .terminateLater } - preparingToTerminate = true - Task { @MainActor in - await model.screenTime.prepareForTermination() - sender.reply(toApplicationShouldTerminate: true) - } - return .terminateLater - } - public func applicationWillTerminate(_ notification: Notification) { rest.prepareForTermination() widgetTimer?.invalidate() @@ -408,8 +388,6 @@ public final class AppController: NSObject, NSApplicationDelegate { _ = model.aiUsage.states _ = model.aiUsage.quotaStates _ = model.network.publicResults - _ = model.settings.historyEnabled - _ = model.screenTime.today } onChange: { [weak self] in Task { @MainActor in guard let self else { return } @@ -493,9 +471,7 @@ public final class AppController: NSObject, NSApplicationDelegate { showsLunar: settings.calendarFeatures.contains(.lunar), calendarDays: calendarDays, monthSummaries: monthSummaries, showsSeasonal: settings.calendarFeatures.contains(.seasonal), - calendarDataVersion: dataVersion, - screenTime: settings.historyEnabled - ? .init(day: today, seconds: Int(model.screenTime.currentToday(at: .now)) / 900 * 900) : nil) + calendarDataVersion: dataVersion) guard snapshot != previous, widgetStore.save(snapshot) else { return } for kind in snapshot.changedWidgetKinds(from: previous) { WidgetCenter.shared.reloadTimelines(ofKind: kind) @@ -574,12 +550,10 @@ public final class AppController: NSObject, NSApplicationDelegate { } private func pauseForInactivity() { - screenTimePausedByWorkspace = true menuBar.dismissPopovers() model.network.setPaused(true) model.aiUsage.setPaused(true) model.history.flush() - model.screenTime.stop() restWindows.hideRest() rest.suspend() Task { await model.hub.setPaused(true) } @@ -588,10 +562,8 @@ public final class AppController: NSObject, NSApplicationDelegate { private func resumeFromInactivity() { // 系统唤醒时常仍停在锁屏界面,要等解锁后再恢复。 guard !screenLocked else { return } - screenTimePausedByWorkspace = false model.network.setPaused(false) model.aiUsage.setPaused(false) - model.screenTime.start() rest.sync() if rest.phase.isResting && rest.isRunning { restWindows.ensureRestVisible() } Task { @@ -600,23 +572,6 @@ public final class AppController: NSObject, NSApplicationDelegate { } } - private func observeScreenTimeHistorySetting() { - withObservationTracking { - _ = model.settings.historyEnabled - } onChange: { [weak self] in - Task { @MainActor [weak self] in - guard let self else { return } - self.model.screenTime.historySettingChanged() - if self.model.settings.historyEnabled && !self.screenTimePausedByWorkspace { - self.model.screenTime.start() - } else { - self.model.screenTime.stop() - } - self.observeScreenTimeHistorySetting() - } - } - } - private func observeRestSettings() { withObservationTracking { _ = model.settings.restEnabled diff --git a/Packages/XStatsKit/Sources/XStatsUI/Panel/HistoryPage.swift b/Packages/XStatsKit/Sources/XStatsUI/Panel/HistoryPage.swift index 1d63710..2acc005 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Panel/HistoryPage.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Panel/HistoryPage.swift @@ -7,7 +7,6 @@ struct HistoryPage: View { @Environment(AppModel.self) private var model @State private var hoverDate: Date? @State private var confirmingClear = false - @State private var displayNow = Date() var body: some View { @Bindable var settings = model.settings @@ -35,8 +34,6 @@ struct HistoryPage: View { } } - ScreenTimeCard(tracker: model.screenTime, now: displayNow) - if points.isEmpty { Card { Text(history.isQuerying ? tr("正在读取…") : tr("这段时间还没有记录。XStats 运行时每分钟记录一次,睡眠与锁屏期间不记录。")) @@ -78,33 +75,29 @@ struct HistoryPage: View { SettingsGroup(caption: tr("历史记录")) { GroupRow(showsDivider: false) { - SettingRow(title: tr("记录历史数据"), subtitle: tr("主要指标每分钟写入本机数据库并保留 7 天,屏幕使用时间保留 90 天")) { + SettingRow(title: tr("记录历史数据"), subtitle: tr("主要指标每分钟写入本机数据库并保留 7 天")) { DSToggle(isOn: $settings.historyEnabled, label: tr("记录历史数据")) } } GroupRow { - SettingRow(title: tr("清除历史"), subtitle: tr("同时清除指标和屏幕使用时间")) { + SettingRow(title: tr("清除历史"), subtitle: tr("清除本机记录的指标历史")) { Button(tr("清除…")) { confirmingClear = true } .buttonStyle(DSButtonStyle(kind: .secondary)) - .disabled(history.recordCount == 0 && !model.screenTime.hasHistory) + .disabled(history.recordCount == 0) } } } } .task { // 页面打开期间每分钟补上新记录的一点 - displayNow = Date() history.load() - model.screenTime.loadRecent() while !Task.isCancelled { do { try await Task.sleep(for: .seconds(60)) } catch { break } - displayNow = Date() if hoverDate == nil { history.load() } - model.screenTime.loadRecent() } } .confirmationDialog(tr("清除全部历史记录?"), isPresented: $confirmingClear, titleVisibility: .visible) { - Button(tr("清除"), role: .destructive) { history.clear(screenTime: model.screenTime) } + Button(tr("清除"), role: .destructive) { history.clear() } Button(tr("取消"), role: .cancel) {} } message: { Text(tr("已记录的数据会从本机删除,无法恢复。")) @@ -121,70 +114,6 @@ struct HistoryPage: View { } } -/// 最近 7 天的屏幕使用时间:今天的数值实时更新,柱高按 7 天内最长的一天缩放 -private struct ScreenTimeCard: View { - let tracker: ScreenTimeTracker - let now: Date - - private var days: [(day: Date, seconds: TimeInterval, estimated: Bool)] { - let calendar = Calendar.current - let today = calendar.startOfDay(for: now) - let recorded = Dictionary(tracker.recentDays.map { ($0.day, $0) }, uniquingKeysWith: { $1 }) - return (0..<7).reversed().compactMap { offset in - guard let day = calendar.date(byAdding: .day, value: -offset, to: today) else { return nil } - // 今天用实时值,其余日期用库里的记录 - let seconds = offset == 0 ? tracker.currentToday(at: now) : recorded[day]?.seconds ?? 0 - return (day, seconds, recorded[day]?.estimated ?? false) - } - } - - var body: some View { - let days = self.days - let longest = max(days.map(\.seconds).max() ?? 0, 60) - let recordedDays = days.filter { $0.seconds > 0 } - let average = recordedDays.isEmpty ? 0 : recordedDays.map(\.seconds).reduce(0, +) / Double(recordedDays.count) - Card { - CardHeader(icon: "display", title: tr("屏幕使用时间")) { - Text(tr("今天 \(Self.text(tracker.currentToday(at: now)))")) - .dsFont(.xs, weight: .medium) - .foregroundStyle(DS.Palette.textPrimary) - .monospacedDigit() - } - HStack(alignment: .bottom, spacing: DS.Space.s2) { - ForEach(days, id: \.day) { day in - VStack(spacing: DS.Space.s1) { - RoundedRectangle(cornerRadius: DS.Space.s1) - .fill(day.estimated ? DS.Palette.primarySoft : DS.Palette.primary) - .frame(height: max(2, DS.Size.chartHeight * 2 * day.seconds / longest)) - Text(day.day.formatted(.dateTime.weekday(.abbreviated).locale(L10n.locale))) - .dsFont(.xs) - .foregroundStyle(DS.Palette.textTertiary) - } - .frame(maxWidth: .infinity) - .help(day.day.formatted(.dateTime.month().day().locale(L10n.locale)) + " · " + Self.text(day.seconds)) - .accessibilityElement(children: .ignore) - .accessibilityLabel(day.day.formatted(.dateTime.month().day().weekday().locale(L10n.locale))) - .accessibilityValue(Self.text(day.seconds)) - } - } - .frame(height: DS.Size.chartHeight * 2 + DS.Space.s6, alignment: .bottom) - Text(recordedDays.isEmpty ? tr("XStats 运行时统计屏幕亮着且未锁定的时间") - : tr("有记录的日期日均 \(Self.text(average))")) - .dsFont(.xs) - .foregroundStyle(DS.Palette.textTertiary) - if days.contains(where: \.estimated) { - Text(tr("浅色为根据系统电源日志估算,可能包含锁屏时间")) - .dsFont(.xs) - .foregroundStyle(DS.Palette.textTertiary) - } - } - } - - static func text(_ seconds: TimeInterval) -> String { - Format.duration(minutes: Int(seconds / 60)) - } -} - private struct HistoryChartCard: View { struct Series { let label: String diff --git a/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.en.md b/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.en.md index 7daf000..3e11888 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.en.md +++ b/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.en.md @@ -1,12 +1,12 @@ # XStats Privacy Policy -Effective date: September 23, 2026 +Effective date: September 28, 2026 This policy explains what XStats processes on your Mac and which features contact external services. XStats-operated services retain only the update-statistics aggregates described in section 2; the app's other persisted data stays on your Mac and is not uploaded to XStats-operated servers. Network tests send requests needed for the test to independent providers, as described in section 3. Contact: i@xiai.me. ## 1. Data processed on your Mac -CPU, GPU, memory, network, temperature, fan, and process metrics come from local system interfaces. Those metrics, their history, hardware serial numbers, and process lists are processed on your Mac and are not uploaded to the XStats update service. While history is on, XStats also totals daily time with the screen on and unlocked, without sampling every minute. For the live display and when saving a total, it reads how long ago the last keyboard or pointer input occurred (not what the input was) and excludes the current continuous idle tail beyond five minutes. If you resume input before the next settlement, idle time in between may still be counted. Days while history was turned off are not filled in after you turn it back on. At each launch, it reads display on/off times from the system power log (`pmset -g log`) to fill in previous days; those estimates do not subtract idle or locked time. Results are kept for 90 days in the same local database as metric history. Preferences are stored locally. +CPU, GPU, memory, network, temperature, fan, and process metrics come from local system interfaces. Those metrics, their history, hardware serial numbers, and process lists are processed on your Mac and are not uploaded to the XStats update service. XStats no longer collects screen time or reads keyboard and pointer idle time for that purpose. Earlier screen-time records, if still present in the local history database, are no longer read or updated. Preferences are stored locally. AI Usage & Quotas is off by default. When enabled, it reads local Codex / Claude Code session logs to summarize token usage by model. It also checks subscription quotas. Codex first uses the installed local Codex CLI App Server, which maintains its own login; if unavailable, XStats reads cached CLI credentials and queries the quota service directly. Claude Code uses its local CLI credentials for direct quota requests. Requests send the login token to the respective provider; XStats does not save tokens in its preferences or statistics database, and does not send session-log contents with quota requests. A quota request may fail or be rate-limited without affecting local token statistics. Turning the feature off stops subsequent scans and quota requests. Apple Intelligence process explanations run on-device and do not send process information to another AI service. If you manually configure Sub2API as a backup quota source for Codex or Claude Code, the app contacts the HTTPS server specified for that source only when its automatic quota lookup fails. The two configurations are stored separately, and XStats verifies the queried account's platform. Your admin email and password are sent to the corresponding server to sign in. Passwords are stored separately in this Mac's Keychain; addresses, emails, and account IDs stay in local preferences. Background quota requests use `force=false` and do not trigger active probes. Removing one source's configuration deletes its locally stored Sub2API password and connection details. diff --git a/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.zh-Hans.md b/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.zh-Hans.md index 7fe57be..daf7c50 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.zh-Hans.md +++ b/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.zh-Hans.md @@ -1,12 +1,12 @@ # XStats 隐私政策 -生效日期:2026 年 9 月 23 日 +生效日期:2026 年 9 月 28 日 本政策说明 XStats 在 macOS 上处理哪些信息,以及哪些功能会连接外部服务。XStats 自有服务仅保存第 2 节所述的更新统计汇总;应用自身的其他持久化数据保存在本机,不上传到 XStats 自有服务器。联网测试会向相应第三方发送完成测试所需的请求,详见第 3 节。联系邮箱:i@xiai.me。 ## 1. 本机处理的数据 -CPU、GPU、内存、网络、温度、风扇、进程等监控数据来自本机系统接口,其指标、历史记录、硬件序列号和进程列表仅在本机处理,不上传到 XStats 更新服务。开启历史记录时,应用还会在本机统计每天屏幕亮着且未锁定的时间,不每分钟采样。实时展示与结算时会读取距离上次键鼠操作的时长(不读取操作内容),扣除当前连续空闲超过 5 分钟的尾段;若在下一次结算前重新操作,中间的空闲时间可能仍被计入。重新开启历史记录后,关闭期间的日期不会被补录。每次启动时读取系统电源日志(`pmset -g log`)中的屏幕开关时间以补齐此前几天;这部分估算不扣除空闲或锁屏时间。结果与指标历史存于同一本机数据库,保留 90 天。设置保存在本机应用偏好中。 +CPU、GPU、内存、网络、温度、风扇、进程等监控数据来自本机系统接口,其指标、历史记录、硬件序列号和进程列表仅在本机处理,不上传到 XStats 更新服务。XStats 不再统计屏幕使用时间,也不再为此读取键鼠空闲时长。旧版本保存的屏幕使用时间记录如仍在本机历史数据库中,新版不会读取或更新。设置保存在本机应用偏好中。 “AI 用量与额度”默认关闭;开启后读取本机 Codex / Claude Code 会话日志,按模型汇总 Token 用量,并查询相应的订阅额度。Codex 优先通过本机已安装的 Codex CLI App Server 读取额度,由 CLI 自行维护登录;若该方式不可用,才读取本机 CLI 登录凭据并直连额度接口。Claude Code 使用本机 CLI 凭据直连。查询请求会向对应服务发送登录令牌;令牌不写入 XStats 设置或统计数据库,日志内容不会随额度请求发送。接口可能变化或限流,额度查询失败不影响本机 Token 统计。关闭此功能后停止后续扫描和额度查询。Apple 智能进程解释在本机运行,不把进程信息发送给其他 AI 服务。 如果你为 Codex 或 Claude Code 手动配置 Sub2API 额度备选来源,只有对应来源的自动额度查询失败时,应用才会连接你为它填写的 HTTPS 服务器。两份配置分别保存,并核对查询账号的平台。管理员邮箱与密码在登录时发送到对应服务器;密码分别保存在本机钥匙串,地址、邮箱和账号 ID 只保存在本机设置中。后台额度请求使用 `force=false`,不触发主动探测。移除某个来源的配置会删除其本机保存的 Sub2API 密码和连接信息。 diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift b/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift index 4125cf5..03563d3 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift @@ -33,7 +33,6 @@ public final class AppModel { public let bluetooth = BluetoothController() let startupItems = StartupItemsController() public let history: HistoryRecorder - public let screenTime: ScreenTimeTracker public let sync: SyncController let diskTools: DiskToolsController @ObservationIgnored public let hub = MetricsHub() @@ -92,7 +91,6 @@ public final class AppModel { updates = UpdateController(settings: settings) alerts = AlertController(settings: settings) history = HistoryRecorder(settings: settings, databaseURL: historyURL) - screenTime = ScreenTimeTracker(settings: settings, database: history.database) sync = SyncController(settings: settings) diskTools = DiskToolsController(helper: helper) speedTest = SpeedTestController(settings: settings) diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/HistoryRecorder.swift b/Packages/XStatsKit/Sources/XStatsUI/State/HistoryRecorder.swift index 585cae8..1d1754f 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/HistoryRecorder.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/HistoryRecorder.swift @@ -47,7 +47,6 @@ public final class HistoryRecorder { public private(set) var batteryPoints: [HistoryPoint] = [] @ObservationIgnored private let settings: AppSettings - /// 屏幕使用时间与指标历史共用同一个本机库 @ObservationIgnored private(set) var database: HistoryDatabase? @ObservationIgnored private var accumulator = HistoryAccumulator() @ObservationIgnored private var lastPrune = Date.distantPast @@ -115,10 +114,11 @@ public final class HistoryRecorder { } } - func clear(screenTime: ScreenTimeTracker) { + func clear() { accumulator = HistoryAccumulator() + guard let database else { return } Task { - await screenTime.clearHistory() + await database.clear() load() } } diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/ScreenTimeTracker.swift b/Packages/XStatsKit/Sources/XStatsUI/State/ScreenTimeTracker.swift deleted file mode 100644 index ebb7390..0000000 --- a/Packages/XStatsKit/Sources/XStatsUI/State/ScreenTimeTracker.swift +++ /dev/null @@ -1,263 +0,0 @@ -// Copyright (C) 2026 ysicing -// SPDX-License-Identifier: AGPL-3.0-or-later - -import CoreGraphics -import Darwin -import Dispatch -import Foundation -import Metrics -import Observation - -/// 统计每天屏幕亮着、未锁定且有人在用的时间;结算时距上次键鼠输入超过 5 分钟的部分不计入。 -/// 屏幕休眠、系统睡眠、锁屏和切换用户由 AppController 调用 stop()/start(); -/// 运行中的时段按需计算,退出和系统状态变化时写入。 -@MainActor -@Observable -public final class ScreenTimeTracker { - /// 今天已累计的秒数(含本次运行前写入的部分) - public private(set) var today: TimeInterval = 0 - /// 最近 7 天,从早到晚;没有记录的日期不在其中 - public private(set) var recentDays: [ScreenTimeDay] = [] - /// 历史库中是否有任何屏幕时间记录,包含最近 7 天之外的日期。 - public private(set) var hasHistory = false - - @ObservationIgnored private let settings: AppSettings - @ObservationIgnored private let database: HistoryDatabase? - @ObservationIgnored private let now: () -> Date - @ObservationIgnored private let powerLog: @Sendable () -> String? - @ObservationIgnored private let idleSeconds: () -> TimeInterval - @ObservationIgnored private var backfillRead: Task<[Date: TimeInterval]?, Never>? - @ObservationIgnored private var historyWasEnabled: Bool - @ObservationIgnored private var calendar: Calendar { .current } - @ObservationIgnored private var segmentStart: Date? - @ObservationIgnored private var loadedDay: Date? - @ObservationIgnored private var backfilled = false - @ObservationIgnored private var lastPrune = Date.distantPast - @ObservationIgnored private var generation = 0 - @ObservationIgnored private var terminating = false - - init(settings: AppSettings, database: HistoryDatabase?, - now: @escaping () -> Date = Date.init, - powerLog: @escaping @Sendable () -> String? = { ScreenTimeTracker.readPowerLog() }, - idleSeconds: @escaping () -> TimeInterval = ScreenTimeTracker.systemIdleSeconds) { - self.settings = settings - self.database = database - self.now = now - self.powerLog = powerLog - self.idleSeconds = idleSeconds - historyWasEnabled = settings.historyEnabled - } - - /// 历史记录开关变化后由 AppController 调用。重新开启时写入补录边界, - /// 关闭期间的日期之后不会再被电源日志估算回来(跨重启同样生效)。 - func historySettingChanged() { - let enabled = settings.historyEnabled - defer { historyWasEnabled = enabled } - guard enabled, !historyWasEnabled else { return } - let enabledAt = now() - enqueue { await $0.database?.blockScreenTimeBackfill(through: enabledAt) } - } - - var isRunning: Bool { segmentStart != nil } - - /// 读盘值加上当前未结算时段,按结算时相同的空闲阈值截断;跨午夜时只取今天的部分。 - func currentToday(at date: Date) -> TimeInterval { - let todayKey = calendar.startOfDay(for: date) - let recorded = loadedDay == todayKey ? today : 0 - guard let segmentStart else { return recorded } - let end = ScreenTime.activeEnd(at: date, idleSeconds: idleSeconds()) - let live = ScreenTime.split(from: segmentStart, to: end, calendar: calendar) - .first(where: { $0.day == todayKey })?.seconds ?? 0 - return recorded + live - } - - /// 开始或恢复计时;重复调用无副作用。首次启动时先用电源日志补齐历史 - func start() { - guard !terminating, settings.historyEnabled, segmentStart == nil else { return } - let started = now() - segmentStart = started - if !backfilled { - backfilled = true - // 估算只到实时计时开始的时刻,之后由实时部分接续,二者不重叠 - let generation = self.generation - let recordingGeneration = settings.historyRecordingGeneration - enqueue { tracker in - guard tracker.generation == generation, - tracker.settings.historyRecordingGeneration == recordingGeneration else { return } - await tracker.backfillFromPowerLog(until: started) - } - } - enqueue { await $0.reloadToday() } - } - - /// 暂停计时并结算到此刻;重复调用无副作用 - func stop() { - guard segmentStart != nil else { return } - checkpoint() - segmentStart = nil - } - - /// 退出前完成最后一次结算;由 AppKit 延迟退出流程等待。 - func prepareForTermination() async { - terminating = true - // 读取电源日志可能要几秒;退出时放弃补录,不让用户等待 - backfillRead?.cancel() - stop() - await pending?.value - } - - /// 使清除前的在途任务失效,并把删除排在已有写入之后、新计时之前。 - func clearHistory() async { - generation += 1 - let clearedAt = now() - if segmentStart != nil { segmentStart = clearedAt } - today = 0 - recentDays = [] - hasHistory = false - enqueue { tracker in - await tracker.database?.clear(at: clearedAt) - await tracker.reloadToday() - // 之前排队的 loadRecent 可能把删除前的日期写回来。 - tracker.recentDays = [] - tracker.hasHistory = false - } - await pending?.value - } - - /// 借用现有的低频 Widget 刷新保存进度,不新增后台定时器。 - func checkpoint() { - guard let start = segmentStart else { return } - let current = now() - segmentStart = current - guard database != nil else { return } - // 结算间隔可达一小时;最后一次输入后超过阈值的部分视为离开(例如防休眠让屏幕常亮) - let end = ScreenTime.activeEnd(at: current, idleSeconds: idleSeconds()) - let parts = ScreenTime.split(from: start, to: end, calendar: calendar) - let generation = self.generation - let prune = current.timeIntervalSince(lastPrune) > 24 * 60 * 60 - if prune { lastPrune = current } - enqueue { tracker in - guard let database = tracker.database else { return } - for part in parts { - guard tracker.generation == generation else { return } - await database.addScreenTime(part.seconds, day: part.day) - } - if prune { await database.pruneScreenTime(before: current.addingTimeInterval(-ScreenTime.retention)) } - await tracker.reloadToday() - } - } - - /// 历史页打开时读取最近 7 天 - func loadRecent() { - enqueue { tracker in - guard let database = tracker.database else { return } - let calendar = tracker.calendar - let todayKey = calendar.startOfDay(for: tracker.now()) - guard let from = calendar.date(byAdding: .day, value: -6, to: todayKey), - let to = calendar.date(byAdding: .day, value: 1, to: todayKey) else { return } - tracker.recentDays = await database.screenTime(from: from, to: to) - tracker.hasHistory = await database.hasScreenTime() - } - } - - /// 数据库操作串行排队:估算补齐先于实时累加,页面读到的“今天”总与库里一致 - @ObservationIgnored private(set) var pending: Task? - - private func enqueue(_ work: @escaping @MainActor (ScreenTimeTracker) async -> Void) { - let previous = pending - pending = Task { [weak self] in - await previous?.value - guard let self else { return } - await work(self) - } - } - - /// 今天的数值始终从库里读取,包含电源日志估算与本次运行前的记录 - private func reloadToday() async { - guard let database else { return } - let todayKey = calendar.startOfDay(for: now()) - guard let tomorrow = calendar.date(byAdding: .day, value: 1, to: todayKey) else { return } - let total = await database.screenTime(from: todayKey, to: tomorrow).first?.seconds ?? 0 - loadedDay = todayKey - today = total - } - - /// 用系统电源日志补齐 App 未运行时的日期。只填补没有记录的日期;日志首日只覆盖一部分,不计入。 - /// 今天若还没有记录,补上零点到此刻的点亮时长,之后由实时计时接续。估算值不扣除空闲与锁屏。 - private func backfillFromPowerLog(until current: Date) async { - // 退出可能发生在补录排到之前;此时直接放弃,不再启动 pmset - guard !terminating, settings.historyEnabled, let database else { return } - let generation = self.generation - let recordingGeneration = settings.historyRecordingGeneration - let calendar = self.calendar - let powerLog = self.powerLog - let read = Task.detached { () -> [Date: TimeInterval]? in - guard let log = powerLog(), !Task.isCancelled, let logStart = ScreenTime.logStart(powerLog: log), - let firstFullDay = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: logStart)) - else { return nil } - let intervals = ScreenTime.displayIntervals(powerLog: log, now: current) - return ScreenTime.totals(of: intervals, calendar: calendar).filter { $0.key >= firstFullDay } - } - backfillRead = read - let totals = await read.value - backfillRead = nil - guard let totals, !read.isCancelled else { return } - let oldest = calendar.startOfDay(for: current.addingTimeInterval(-ScreenTime.retention)) - for (day, seconds) in totals where day >= oldest { - guard self.generation == generation, settings.historyEnabled, - settings.historyRecordingGeneration == recordingGeneration else { return } - await database.seedScreenTime(seconds, day: day) - } - } - - /// 距离最后一次键盘、鼠标或触控板输入的秒数;只读一个时间值,无需辅助功能权限 - nonisolated static func systemIdleSeconds() -> TimeInterval { - guard let anyInput = CGEventType(rawValue: ~0) else { return 0 } - return CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyInput) - } - - /// 电源日志可能很长;读取和子进程运行都受限,退出不会无期限等待补录。 - nonisolated static func readPowerLog( - executableURL: URL = URL(fileURLWithPath: "/usr/bin/pmset"), - arguments: [String] = ["-g", "log"], - timeout: TimeInterval = 8 - ) -> String? { - let process = Process() - process.executableURL = executableURL - process.arguments = arguments - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = FileHandle.nullDevice - do { try process.run() } catch { return nil } - let deadline = DispatchTime.now().uptimeNanoseconds + UInt64(max(0.1, min(timeout, 60)) * 1_000_000_000) - let maxBytes = 16 * 1024 * 1024 - var data = Data() - var buffer = [UInt8](repeating: 0, count: 16 * 1024) - var descriptor = pollfd(fd: pipe.fileHandleForReading.fileDescriptor, - events: Int16(POLLIN | POLLHUP), revents: 0) - var reachedEOF = false - while true { - if withUnsafeCurrentTask(body: { $0?.isCancelled ?? false }) { break } - let instant = DispatchTime.now().uptimeNanoseconds - guard instant < deadline else { break } - let remaining = deadline - instant - let waitMs = Int32(max(1, min(remaining / 1_000_000, 1_000))) - let ready = Darwin.poll(&descriptor, 1, waitMs) - if ready == 0 || (ready < 0 && errno == EINTR) { continue } - guard ready > 0 else { break } - let count = buffer.withUnsafeMutableBytes { bytes in - Darwin.read(descriptor.fd, bytes.baseAddress, bytes.count) - } - if count == 0 { reachedEOF = true; break } - guard count > 0, data.count + count <= maxBytes else { break } - data.append(contentsOf: buffer.prefix(count)) - } - if !reachedEOF && process.isRunning { - process.terminate() - if process.isRunning { Darwin.kill(process.processIdentifier, SIGKILL) } - } - process.waitUntilExit() - guard reachedEOF, process.terminationStatus == 0 else { return nil } - return String(decoding: data, as: UTF8.self) - } -} diff --git a/Packages/XStatsKit/Tests/MetricsTests/ScreenTimeTests.swift b/Packages/XStatsKit/Tests/MetricsTests/ScreenTimeTests.swift deleted file mode 100644 index 2d56890..0000000 --- a/Packages/XStatsKit/Tests/MetricsTests/ScreenTimeTests.swift +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright (C) 2026 ysicing -// SPDX-License-Identifier: AGPL-3.0-or-later - -import Foundation -@testable import Metrics -import Testing - -@Suite struct ScreenTimeTests { - private var shanghai: Calendar { - var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = TimeZone(identifier: "Asia/Shanghai")! - return calendar - } - - private func date(_ day: Int, _ hour: Int, _ minute: Int = 0) -> Date { - shanghai.date(from: DateComponents(year: 2026, month: 9, day: day, hour: hour, minute: minute))! - } - - @Test func splitAssignsEachSideOfMidnightToItsOwnDay() { - let parts = ScreenTime.split(from: date(25, 23, 30), to: date(26, 0, 45), calendar: shanghai) - #expect(parts.count == 2) - #expect(parts[0].day == date(25, 0) && parts[0].seconds == 30 * 60) - #expect(parts[1].day == date(26, 0) && parts[1].seconds == 45 * 60) - #expect(ScreenTime.split(from: date(26, 10), to: date(26, 10), calendar: shanghai).isEmpty) - #expect(ScreenTime.split(from: date(26, 11), to: date(26, 10), calendar: shanghai).isEmpty) - } - - @Test func powerLogPairsDisplayOnWithTheNextOff() throws { - let log = """ - 2026-09-24 07:00:00 +0800 Notification \tDisplay is turned off - 2026-09-24 09:00:00 +0800 Notification \tDisplay is turned on - 2026-09-24 09:30:00 +0800 Notification \tDisplay is turned on - 2026-09-24 11:00:00 +0800 Sleep \tEntering Sleep state due to 'Idle Sleep' - 2026-09-24 11:00:05 +0800 Notification \tDisplay is turned off - 2026-09-25 23:00:00 +0800 Notification \tDisplay is turned on - 2026-09-26 01:00:00 +0800 Notification \tDisplay is turned off - 2026-09-26 09:00:00 +0800 Notification \tDisplay is turned on - garbage line without a timestamp Display is turned on - """ - let now = date(26, 10) - let intervals = ScreenTime.displayIntervals(powerLog: log, now: now) - // 开头孤立的 off 无从配对;重复的 on 不另起一段;末尾仍亮着的截止到 now - #expect(intervals == [ - DateInterval(start: date(24, 9), end: date(24, 11, 0).addingTimeInterval(5)), - DateInterval(start: date(25, 23), end: date(26, 1)), - DateInterval(start: date(26, 9), end: now), - ]) - #expect(ScreenTime.logStart(powerLog: log) == date(24, 7)) - let totals = ScreenTime.totals(of: intervals, calendar: shanghai) - #expect(totals[date(24, 0)] == TimeInterval(2 * 3600 + 5)) - #expect(totals[date(25, 0)] == TimeInterval(3600)) - #expect(totals[date(26, 0)] == TimeInterval(3600 + 3600)) - } - - @Test func clearBoundaryPersistsAndBlocksOldEstimates() async throws { - let url = FileManager.default.temporaryDirectory.appendingPathComponent("screen-time-clear-\(UUID().uuidString).sqlite") - defer { for suffix in ["", "-wal", "-shm"] { try? FileManager.default.removeItem(atPath: url.path + suffix) } } - let database = try HistoryDatabase(url: url) - await database.seedScreenTime(3600, day: date(24, 0)) - await database.clear(at: date(25, 12)) - - let reopened = try HistoryDatabase(url: url) - await reopened.seedScreenTime(3600, day: date(24, 0)) - await reopened.seedScreenTime(3600, day: date(25, 0)) - await reopened.seedScreenTime(1800, day: date(26, 0)) - await reopened.addScreenTime(60, day: date(25, 0)) - #expect(await reopened.screenTime(from: date(24, 0), to: date(27, 0)) == [ - ScreenTimeDay(day: date(25, 0), seconds: 60, estimated: false), - ScreenTimeDay(day: date(26, 0), seconds: 1800, estimated: true) - ]) - } - - @Test func databaseAccumulatesLiveTimeAndNeverOverwritesItWithEstimates() async throws { - let url = FileManager.default.temporaryDirectory.appendingPathComponent("screen-time-\(UUID().uuidString).sqlite") - defer { for suffix in ["", "-wal", "-shm"] { try? FileManager.default.removeItem(atPath: url.path + suffix) } } - let database = try HistoryDatabase(url: url) - let day1 = date(24, 0), day2 = date(25, 0), day3 = date(26, 0) - - await database.addScreenTime(600, day: day1) - await database.addScreenTime(300, day: day1) - await database.seedScreenTime(9_999, day: day1) // 已有实时记录:估算被忽略 - await database.seedScreenTime(1_200, day: day2) // 没有记录:写入估算 - await database.addScreenTime(60, day: day2) // 估算日继续累加实时部分,仍标为含估算 - await database.addScreenTime(0, day: day3) // 0 秒不建行 - await database.addScreenTime(.nan, day: day3) - - let rows = await database.screenTime(from: day1, to: day3) - #expect(rows == [ScreenTimeDay(day: day1, seconds: 900, estimated: false), - ScreenTimeDay(day: day2, seconds: 1_260, estimated: true)]) - - await database.pruneScreenTime(before: day2) - #expect(await database.screenTime(from: day1, to: day3).map(\.day) == [day2]) - await database.clear() - #expect(await database.screenTime(from: day1, to: day3).isEmpty) - } -} diff --git a/Packages/XStatsKit/Tests/WidgetDataTests/WidgetSnapshotTests.swift b/Packages/XStatsKit/Tests/WidgetDataTests/WidgetSnapshotTests.swift index e5b66a3..7a03957 100644 --- a/Packages/XStatsKit/Tests/WidgetDataTests/WidgetSnapshotTests.swift +++ b/Packages/XStatsKit/Tests/WidgetDataTests/WidgetSnapshotTests.swift @@ -73,18 +73,6 @@ import WidgetData #expect(snapshot.calendarSummary(for: noon, calendar: buddhist) == nil) } -@Test func screenTimeResetsOnNewDayAndReloadsOnlyItsWidget() throws { - let noon = try #require(WidgetSnapshot.keyCalendar.date(from: DateComponents(year: 2026, month: 9, day: 26, hour: 12))) - let previous = WidgetSnapshot() - #expect(previous.todayScreenTime(at: noon) == nil, "history disabled or old snapshot shows nothing") - var snapshot = previous - snapshot.screenTime = .init(day: Calendar.autoupdatingCurrent.startOfDay(for: noon), seconds: 3 * 3600) - #expect(snapshot.todayScreenTime(at: noon) == 3 * 3600) - #expect(snapshot.todayScreenTime(at: noon.addingTimeInterval(24 * 3600)) == 0, "yesterday's time must not show as today") - #expect(snapshot.changedWidgetKinds(from: previous) == [WidgetKind.screenTime]) - #expect(try JSONDecoder().decode(WidgetSnapshot.self, from: JSONEncoder().encode(snapshot)) == snapshot) -} - @Test func seasonalDayToggleReloadsOnlyDailyCalendar() { let previous = WidgetSnapshot() var enabled = previous @@ -223,7 +211,7 @@ import WidgetData var relocalized = base relocalized.language = "japanese" - #expect(relocalized.changedWidgetKinds(from: base) == [WidgetKind.aiQuota, WidgetKind.todayTokens, WidgetKind.screenTime, + #expect(relocalized.changedWidgetKinds(from: base) == [WidgetKind.aiQuota, WidgetKind.todayTokens, WidgetKind.ipPurity, WidgetKind.publicIP, WidgetKind.calendar, WidgetKind.tomorrowWork, WidgetKind.calendarMonth]) diff --git a/Packages/XStatsKit/Tests/XStatsUITests/ScreenTimeTrackerTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/ScreenTimeTrackerTests.swift deleted file mode 100644 index 9d0bb40..0000000 --- a/Packages/XStatsKit/Tests/XStatsUITests/ScreenTimeTrackerTests.swift +++ /dev/null @@ -1,389 +0,0 @@ -// Copyright (C) 2026 ysicing -// SPDX-License-Identifier: AGPL-3.0-or-later - -import Foundation -@testable import Metrics -import Testing -@testable import XStatsUI - -/// 可手动推进的时钟 -@MainActor -private final class FakeEnvironment { - var now: Date - var idle: TimeInterval = 0 - init(now: Date) { self.now = now } -} - -@MainActor -@Suite struct ScreenTimeTrackerTests { - private let calendar = Calendar.current - - private func day(_ offset: Int, hour: Int = 0, minute: Int = 0) -> Date { - let base = calendar.date(from: DateComponents(year: 2026, month: 9, day: 20))! - let dayStart = calendar.date(byAdding: .day, value: offset, to: base)! - return calendar.date(byAdding: DateComponents(hour: hour, minute: minute), to: dayStart)! - } - - private func makeTracker(_ env: FakeEnvironment, log: String? = nil, - historyEnabled: Bool = true, - powerLog: (@Sendable () -> String?)? = nil) - throws -> (ScreenTimeTracker, HistoryDatabase, AppSettings, () -> Void) { - let suite = "ScreenTimeTrackerTests.\(UUID())" - let defaults = UserDefaults(suiteName: suite)! - let settings = AppSettings(defaults: defaults) - settings.historyEnabled = historyEnabled - let url = FileManager.default.temporaryDirectory.appendingPathComponent("screen-time-\(UUID().uuidString).sqlite") - let database = try HistoryDatabase(url: url) - let tracker = ScreenTimeTracker(settings: settings, database: database, - now: { env.now }, powerLog: powerLog ?? { log }, idleSeconds: { env.idle }) - let cleanup = { - defaults.removePersistentDomain(forName: suite) - for suffix in ["", "-wal", "-shm"] { try? FileManager.default.removeItem(atPath: url.path + suffix) } - } - return (tracker, database, settings, cleanup) - } - - @Test func elapsedTimeIsCalculatedWithoutPeriodicWrites() async throws { - let env = FakeEnvironment(now: day(1, hour: 10)) - let (tracker, database, _, cleanup) = try makeTracker(env) - defer { cleanup() } - tracker.start() - env.now = day(1, hour: 10, minute: 30) - #expect(tracker.currentToday(at: env.now) == 30 * 60) - #expect(await database.screenTime(from: day(1), to: day(2)).isEmpty) - tracker.stop() - await settle(tracker) - #expect(await database.screenTime(from: day(1), to: day(2)).first?.seconds == 30 * 60) - } - - @Test func disablingHistorySettlesCurrentSegmentAndStopsTracking() async throws { - let env = FakeEnvironment(now: day(1, hour: 10)) - let (tracker, database, settings, cleanup) = try makeTracker(env) - defer { cleanup() } - tracker.start() - env.now = day(1, hour: 10, minute: 20) - settings.historyEnabled = false - tracker.stop() - await settle(tracker) - #expect(await database.screenTime(from: day(1), to: day(2)).first?.seconds == 20 * 60) - tracker.start() - #expect(!tracker.isRunning) - settings.historyEnabled = true - tracker.start() - #expect(tracker.isRunning) - await tracker.prepareForTermination() - } - - private func settle(_ tracker: ScreenTimeTracker) async { - await tracker.pending?.value - } - - @Test(arguments: [false, true]) - func disablingDuringBackfillInvalidatesResults(reenable: Bool) async throws { - let (started, continuation) = AsyncStream.makeStream() - let release = DispatchSemaphore(value: 0) - let log = """ - 2026-09-20 09:00:00 +0800 Notification Display is turned on - 2026-09-20 10:00:00 +0800 Notification Display is turned off - 2026-09-21 09:00:00 +0800 Notification Display is turned on - 2026-09-21 10:00:00 +0800 Notification Display is turned off - """ - let env = FakeEnvironment(now: day(3, hour: 12)) - let (tracker, database, settings, cleanup) = try makeTracker(env, powerLog: { - continuation.yield(()) - release.wait() - return log - }) - defer { cleanup() } - tracker.start() - for await _ in started { break } - settings.historyEnabled = false - if reenable { settings.historyEnabled = true } - release.signal() - await settle(tracker) - #expect(await database.screenTime(from: day(0), to: day(4)).isEmpty) - await tracker.prepareForTermination() - } - - @Test func clearingHistoryInvalidatesQueuedWritesAndSurvivesRelaunch() async throws { - let env = FakeEnvironment(now: day(3, hour: 12)) - let (tracker, database, settings, cleanup) = try makeTracker(env) - defer { cleanup() } - tracker.start() - await settle(tracker) - env.now = day(3, hour: 12, minute: 1) - tracker.checkpoint() - await tracker.clearHistory() - #expect(await database.screenTime(from: day(0), to: day(4)).isEmpty) - #expect(tracker.today == 0 && tracker.recentDays.isEmpty) - env.now = day(3, hour: 12, minute: 2) - await tracker.prepareForTermination() - #expect(tracker.today == 60) - let log = """ - 2026-09-20 09:00:00 +0800 Notification Display is turned on - 2026-09-20 10:00:00 +0800 Notification Display is turned off - 2026-09-21 09:00:00 +0800 Notification Display is turned on - 2026-09-21 10:00:00 +0800 Notification Display is turned off - """ - let relaunched = ScreenTimeTracker(settings: settings, database: database, - now: { env.now }, powerLog: { log }, idleSeconds: { env.idle }) - relaunched.start() - await settle(relaunched) - #expect(await database.screenTime(from: day(0), to: day(3)).isEmpty) - #expect(relaunched.today == 60) - await relaunched.prepareForTermination() - } - - @Test func clearAfterQueuedRecentReadLeavesNoStaleBars() async throws { - let (started, continuation) = AsyncStream.makeStream() - let release = DispatchSemaphore(value: 0) - let env = FakeEnvironment(now: day(3, hour: 12)) - let (tracker, database, _, cleanup) = try makeTracker(env, powerLog: { - continuation.yield(()) - release.wait() - return nil - }) - defer { cleanup() } - await database.addScreenTime(3600, day: day(2)) - tracker.start() - tracker.loadRecent() - for await _ in started { break } - Task { release.signal() } - await tracker.clearHistory() - #expect(tracker.recentDays.isEmpty) - #expect(!tracker.hasHistory) - #expect(await database.screenTime(from: day(2), to: day(3)).isEmpty) - await tracker.prepareForTermination() - } - - @Test func screenTimeHistoryCanBeClearedWithoutMetricSamples() async throws { - let env = FakeEnvironment(now: day(3, hour: 12)) - let (tracker, database, _, cleanup) = try makeTracker(env) - defer { cleanup() } - await database.seedScreenTime(3600, day: day(2)) - #expect(await database.count() == 0) - tracker.loadRecent() - await settle(tracker) - #expect(tracker.hasHistory) - await tracker.clearHistory() - #expect(!tracker.hasHistory) - } - - @Test func powerLogCommandHasBoundedRuntime() async { - let result = await Task.detached { - let start = DispatchTime.now().uptimeNanoseconds - let log = ScreenTimeTracker.readPowerLog(executableURL: URL(fileURLWithPath: "/bin/sleep"), - arguments: ["5"], timeout: 0.15) - let elapsed = DispatchTime.now().uptimeNanoseconds - start - return (log, elapsed) - }.value - #expect(result.0 == nil) - #expect(result.1 < 3_000_000_000, "command took \(result.1) ns") - let output = await Task.detached { - ScreenTimeTracker.readPowerLog(executableURL: URL(fileURLWithPath: "/bin/echo"), - arguments: ["display on"], timeout: 1) - }.value - #expect(output == "display on\n") - } - - @Test func terminationWaitsForFinalPartialMinute() async throws { - let env = FakeEnvironment(now: day(1, hour: 10)) - let (tracker, database, _, cleanup) = try makeTracker(env) - defer { cleanup() } - tracker.start() - env.now = env.now.addingTimeInterval(35) - await tracker.prepareForTermination() - #expect(!tracker.isRunning) - #expect(await database.screenTime(from: day(1), to: day(2)).first?.seconds == 35) - tracker.start() - #expect(!tracker.isRunning, "wake notifications must not restart tracking during termination") - } - - @Test func countsEntireUnlockedIntervalUntilCheckpoint() async throws { - let env = FakeEnvironment(now: day(1, hour: 10)) - let (tracker, database, _, cleanup) = try makeTracker(env) - defer { cleanup() } - tracker.start() - env.now = day(1, hour: 10, minute: 10) - tracker.checkpoint() - env.now = day(1, hour: 10, minute: 30) - tracker.checkpoint() - tracker.stop() - await settle(tracker) - #expect(tracker.today == TimeInterval(30 * 60), "checkpoint records the entire unlocked interval") - let rows = await database.screenTime(from: day(1), to: day(2)) - #expect(rows.first?.seconds == TimeInterval(30 * 60), "rows: \(rows)") - #expect(rows.first?.estimated == false) - } - - @Test func pausedTimeAndDisabledHistoryAreNotCounted() async throws { - let env = FakeEnvironment(now: day(1, hour: 9)) - let (tracker, database, settings, cleanup) = try makeTracker(env) - defer { cleanup() } - tracker.start() - tracker.start() // 重复 start 不重置起点 - env.now = day(1, hour: 9, minute: 5) - tracker.stop() - tracker.stop() // 重复 stop 不重复结算 - env.now = day(1, hour: 12) // 暂停期间(锁屏、睡眠) - settings.historyEnabled = false - tracker.stop() - env.now = day(1, hour: 12, minute: 30) - tracker.start() // 关闭期间不会启动 - settings.historyEnabled = true - tracker.start() - env.now = day(1, hour: 12, minute: 32) - tracker.stop() - await settle(tracker) - #expect(!tracker.isRunning) - let rows = await database.screenTime(from: day(1), to: day(2)) - #expect(rows.first?.seconds == TimeInterval(7 * 60), "rows: \(rows)") - } - - @Test func sessionAcrossMidnightIsSplitBetweenDays() async throws { - let env = FakeEnvironment(now: day(1, hour: 23, minute: 50)) - let (tracker, database, _, cleanup) = try makeTracker(env) - defer { cleanup() } - tracker.start() - env.now = day(2, hour: 0, minute: 20) - #expect(tracker.currentToday(at: env.now) == 20 * 60) - #expect(await database.screenTime(from: day(1), to: day(3)).isEmpty) - tracker.stop() - await settle(tracker) - let rows = await database.screenTime(from: day(1), to: day(3)) - #expect(rows.map(\.seconds) == [TimeInterval(10 * 60), TimeInterval(20 * 60)], "rows: \(rows)") - } - - @Test func powerLogBackfillsOnlyFullyCoveredDaysWithoutRecords() async throws { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.dateFormat = "yyyy-MM-dd HH:mm:ss Z" - func line(_ date: Date, _ on: Bool) -> String { - "\(formatter.string(from: date)) Notification \tDisplay is turned \(on ? "on" : "off")" - } - let log = [ - line(day(0, hour: 20), true), line(day(0, hour: 22), false), // 日志首日:只覆盖一部分,不补 - line(day(1, hour: 9), true), line(day(1, hour: 12), false), // 已有实时记录:不覆盖 - line(day(2, hour: 9), true), line(day(2, hour: 11), false), // 补 2 小时估算 - line(day(3, hour: 8), true), // 今天:补到启动时刻 - ].joined(separator: "\n") - let env = FakeEnvironment(now: day(3, hour: 9)) - let (tracker, database, _, cleanup) = try makeTracker(env, log: log) - defer { cleanup() } - await database.addScreenTime(1_800, day: day(1)) - - tracker.start() - env.now = day(3, hour: 9, minute: 10) - tracker.stop() - await settle(tracker) - - let rows = await database.screenTime(from: day(0), to: day(4)) - #expect(rows == [ - ScreenTimeDay(day: day(1), seconds: 1_800, estimated: false), - ScreenTimeDay(day: day(2), seconds: 2 * 3600, estimated: true), - // 零点前没有数据;08:00 点亮到 09:00 启动为估算,其后 10 分钟为实时 - ScreenTimeDay(day: day(3), seconds: 3600 + 600, estimated: true), - ], "rows: \(rows)") - } - - @Test func missingOrUnreadablePowerLogIsIgnored() async throws { - let env = FakeEnvironment(now: day(1, hour: 9)) - let (tracker, database, _, cleanup) = try makeTracker(env, log: "pmset: not permitted") - defer { cleanup() } - tracker.start() - env.now = day(1, hour: 9, minute: 1) - tracker.stop() - await settle(tracker) - #expect(await database.screenTime(from: day(0), to: day(2)).map(\.seconds) == [TimeInterval(60)]) - } - - - @Test func timeAfterUserLeavesIsNotCountedEvenIfDisplayStaysOn() async throws { - // 防休眠让屏幕常亮:10:00 起无人操作,11:00 的定期结算只计到最后输入后的 5 分钟 - let env = FakeEnvironment(now: day(1, hour: 9)) - let (tracker, database, _, cleanup) = try makeTracker(env) - defer { cleanup() } - tracker.start() - env.now = day(1, hour: 11) - env.idle = 60 * 60 - tracker.checkpoint() - env.now = day(1, hour: 11, minute: 30) // 11:00 回来一直在用 - env.idle = 0 - tracker.stop() - await tracker.pending?.value - let rows = await database.screenTime(from: day(1), to: day(2)) - #expect(rows.first?.seconds == TimeInterval((65 + 30) * 60), "rows: \(rows)") - } - - @Test func liveScreenTimeStopsGrowingAfterIdleThreshold() async throws { - let env = FakeEnvironment(now: day(1, hour: 10)) - let (tracker, database, _, cleanup) = try makeTracker(env) - defer { cleanup() } - tracker.start() - - env.now = day(1, hour: 10, minute: 30) - env.idle = 30 * 60 - #expect(tracker.currentToday(at: env.now) == 5 * 60) - env.now = day(1, hour: 10, minute: 40) - env.idle = 40 * 60 - #expect(tracker.currentToday(at: env.now) == 5 * 60) - - tracker.stop() - await tracker.pending?.value - #expect(await database.screenTime(from: day(1), to: day(2)).first?.seconds == 5 * 60) - } - - @Test func reenablingHistoryDoesNotBackfillTheDisabledDays() async throws { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.dateFormat = "yyyy-MM-dd HH:mm:ss Z" - func line(_ date: Date, _ on: Bool) -> String { - "\(formatter.string(from: date)) Notification \tDisplay is turned \(on ? "on" : "off")" - } - let log = [line(day(0, hour: 8), false), - line(day(1, hour: 9), true), line(day(1, hour: 11), false), // 关闭历史期间 - line(day(2, hour: 9), true), line(day(2, hour: 10), false)].joined(separator: "\n") - let env = FakeEnvironment(now: day(3, hour: 9)) - let (tracker, database, settings, cleanup) = try makeTracker(env, log: log, historyEnabled: false) - defer { cleanup() } - tracker.start() // 关闭时不计时、不补录 - settings.historyEnabled = true - tracker.historySettingChanged() - tracker.start() - env.now = day(3, hour: 9, minute: 10) - tracker.stop() - await tracker.pending?.value - let rows = await database.screenTime(from: day(0), to: day(4)) - #expect(rows.map(\.day) == [day(3)], "disabled days were backfilled: \(rows)") - #expect(rows.first?.estimated == false) - - // 跨重启同样生效:新进程首次启动补录时也跳过这些日期 - let relaunched = ScreenTimeTracker(settings: settings, database: database, - now: { env.now }, powerLog: { log }, idleSeconds: { 0 }) - relaunched.start() - relaunched.stop() - await relaunched.pending?.value - #expect(await database.screenTime(from: day(0), to: day(3)).isEmpty) - } - - @Test func terminationCancelsASlowPowerLogRead() async throws { - let (startedRead, continuation) = AsyncStream.makeStream() - let env = FakeEnvironment(now: day(1, hour: 9)) - let (tracker, _, _, cleanup) = try makeTracker(env, powerLog: { - // 模拟读取很慢的 pmset:直到被取消才返回 - continuation.yield(()) - let deadline = Date().addingTimeInterval(10) - while Date() < deadline { - if withUnsafeCurrentTask(body: { $0?.isCancelled ?? false }) { return nil } - Thread.sleep(forTimeInterval: 0.01) - } - return nil - }) - defer { cleanup() } - tracker.start() - for await _ in startedRead { break } - let started = Date() - await tracker.prepareForTermination() - #expect(Date().timeIntervalSince(started) < 2, "termination waited for the power log read") - } -} diff --git a/Widget/SharedDataWidgets.swift b/Widget/SharedDataWidgets.swift index 914f8c8..71d202b 100644 --- a/Widget/SharedDataWidgets.swift +++ b/Widget/SharedDataWidgets.swift @@ -2,7 +2,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import Localization -import Metrics import SwiftUI import WidgetData import WidgetKit @@ -25,8 +24,7 @@ struct SharedDataProvider: TimelineProvider { addresses: [.init(family: "v4", ip: "203.0.113.8", countryCode: "CN", city: "上海", organization: "Example Network", purityScore: 85, purityGrade: "A", region: "上海", cityEnglish: "Shanghai", regionEnglish: "Shanghai", - asn: "AS4134", isNative: true, ipType: "Residential IP", riskFlags: [])], - screenTime: .init(day: now, seconds: 4 * 3600 + 30 * 60))) + asn: "AS4134", isNative: true, ipType: "Residential IP", riskFlags: [])])) } func getSnapshot(in context: Context, completion: @escaping (SharedDataEntry) -> Void) { @@ -205,43 +203,6 @@ struct TodayTokensWidget: Widget { } } -private struct ScreenTimeWidgetView: View { - let entry: SharedDataEntry - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - WidgetHeading(title: tr("今日屏幕使用时间"), symbol: "display") - if let seconds = entry.snapshot.todayScreenTime(at: entry.date) { - Text(Format.duration(minutes: seconds / 60)) - .font(.system(size: 28, weight: .semibold, design: .rounded)) - .lineLimit(2) - .minimumScaleFactor(0.6) - Spacer(minLength: 0) - // 借用主应用已有的 Widget 定期刷新,不额外启动屏幕时间计时器。 - Text(tr("定期更新")) - .font(.caption) - .foregroundStyle(.secondary) - } else { - WidgetEmptyState(enabled: false) - } - } - .padding() - .containerBackground(.background, for: .widget) - .environment(\.locale, L10n.locale) - } -} - -struct ScreenTimeWidget: Widget { - var body: some WidgetConfiguration { - StaticConfiguration(kind: WidgetKind.screenTime, provider: SharedDataProvider()) { entry in - ScreenTimeWidgetView(entry: entry) - } - .configurationDisplayName(tr("今日屏幕使用时间")) - .description(tr("查看今天屏幕亮着且未锁定的时间")) - .supportedFamilies([.systemSmall]) - } -} - private struct IPPurityWidgetView: View { @Environment(\.widgetFamily) private var family let entry: SharedDataEntry diff --git a/Widget/XStatsWidget.swift b/Widget/XStatsWidget.swift index 9e0cafd..687c5a3 100644 --- a/Widget/XStatsWidget.swift +++ b/Widget/XStatsWidget.swift @@ -216,7 +216,6 @@ struct XStatsWidgets: WidgetBundle { TomorrowWorkWidget() AIQuotaWidget() TodayTokensWidget() - ScreenTimeWidget() IPPurityWidget() PublicIPWidget() }