diff --git a/App/Info.plist b/App/Info.plist index 866dacf..3b65a90 100644 --- a/App/Info.plist +++ b/App/Info.plist @@ -38,7 +38,11 @@ $(MACOSX_DEPLOYMENT_TARGET) LSUIElement + NSCalendarsFullAccessUsageDescription + 在本机日历面板显示您选择的系统日程,不修改或上传日程内容。 NSHumanReadableCopyright © 2026 XStats + NSRemindersFullAccessUsageDescription + 在本机日历面板显示您选择的到期提醒,不修改或上传提醒内容。 diff --git a/App/XStats.entitlements b/App/XStats.entitlements index 3c7f2ac..b754995 100644 --- a/App/XStats.entitlements +++ b/App/XStats.entitlements @@ -8,5 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later --> group.work.12306.xstats + com.apple.security.personal-information.calendars + diff --git a/App/ar.lproj/InfoPlist.strings b/App/ar.lproj/InfoPlist.strings new file mode 100644 index 0000000..66f97d7 --- /dev/null +++ b/App/ar.lproj/InfoPlist.strings @@ -0,0 +1,4 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later +"NSCalendarsFullAccessUsageDescription" = "يعرض أحداث التقويم المحددة محليًا دون تعديلها أو رفعها."; +"NSRemindersFullAccessUsageDescription" = "يعرض التذكيرات المستحقة المحددة محليًا دون تعديلها أو رفعها."; diff --git a/App/de.lproj/InfoPlist.strings b/App/de.lproj/InfoPlist.strings new file mode 100644 index 0000000..7a6140c --- /dev/null +++ b/App/de.lproj/InfoPlist.strings @@ -0,0 +1,4 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later +"NSCalendarsFullAccessUsageDescription" = "Zeigt ausgewählte Kalendertermine lokal an, ohne sie zu ändern oder hochzuladen."; +"NSRemindersFullAccessUsageDescription" = "Zeigt ausgewählte fällige Erinnerungen lokal an, ohne sie zu ändern oder hochzuladen."; diff --git a/App/en.lproj/InfoPlist.strings b/App/en.lproj/InfoPlist.strings new file mode 100644 index 0000000..c84aab7 --- /dev/null +++ b/App/en.lproj/InfoPlist.strings @@ -0,0 +1,4 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later +"NSCalendarsFullAccessUsageDescription" = "Show your selected calendar events locally without modifying or uploading them."; +"NSRemindersFullAccessUsageDescription" = "Show your selected due reminders locally without modifying or uploading them."; diff --git a/App/es.lproj/InfoPlist.strings b/App/es.lproj/InfoPlist.strings new file mode 100644 index 0000000..cda1a47 --- /dev/null +++ b/App/es.lproj/InfoPlist.strings @@ -0,0 +1,4 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later +"NSCalendarsFullAccessUsageDescription" = "Muestra localmente los eventos seleccionados sin modificarlos ni subirlos."; +"NSRemindersFullAccessUsageDescription" = "Muestra localmente los recordatorios seleccionados sin modificarlos ni subirlos."; diff --git a/App/fr.lproj/InfoPlist.strings b/App/fr.lproj/InfoPlist.strings new file mode 100644 index 0000000..f4c0310 --- /dev/null +++ b/App/fr.lproj/InfoPlist.strings @@ -0,0 +1,4 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later +"NSCalendarsFullAccessUsageDescription" = "Affiche localement les événements sélectionnés sans les modifier ni les téléverser."; +"NSRemindersFullAccessUsageDescription" = "Affiche localement les rappels sélectionnés sans les modifier ni les téléverser."; diff --git a/App/ja.lproj/InfoPlist.strings b/App/ja.lproj/InfoPlist.strings new file mode 100644 index 0000000..6225c88 --- /dev/null +++ b/App/ja.lproj/InfoPlist.strings @@ -0,0 +1,4 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later +"NSCalendarsFullAccessUsageDescription" = "選択したカレンダーの予定をこの Mac に表示します。内容の変更やアップロードは行いません。"; +"NSRemindersFullAccessUsageDescription" = "選択した期限付きリマインダーをこの Mac に表示します。内容の変更やアップロードは行いません。"; diff --git a/App/ko.lproj/InfoPlist.strings b/App/ko.lproj/InfoPlist.strings new file mode 100644 index 0000000..60d08a8 --- /dev/null +++ b/App/ko.lproj/InfoPlist.strings @@ -0,0 +1,4 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later +"NSCalendarsFullAccessUsageDescription" = "선택한 캘린더 일정을 이 Mac에 표시하며 수정하거나 업로드하지 않습니다."; +"NSRemindersFullAccessUsageDescription" = "선택한 기한이 있는 미리 알림을 이 Mac에 표시하며 수정하거나 업로드하지 않습니다."; diff --git a/App/zh-Hans.lproj/InfoPlist.strings b/App/zh-Hans.lproj/InfoPlist.strings new file mode 100644 index 0000000..9e0a774 --- /dev/null +++ b/App/zh-Hans.lproj/InfoPlist.strings @@ -0,0 +1,4 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later +"NSCalendarsFullAccessUsageDescription" = "在本机日历面板显示您选择的系统日程,不修改或上传日程内容。"; +"NSRemindersFullAccessUsageDescription" = "在本机日历面板显示您选择的到期提醒,不修改或上传提醒内容。"; diff --git a/App/zh-Hant.lproj/InfoPlist.strings b/App/zh-Hant.lproj/InfoPlist.strings new file mode 100644 index 0000000..e79f41f --- /dev/null +++ b/App/zh-Hant.lproj/InfoPlist.strings @@ -0,0 +1,4 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later +"NSCalendarsFullAccessUsageDescription" = "在本機日曆面板顯示您選擇的系統行程,不修改或上傳行程內容。"; +"NSRemindersFullAccessUsageDescription" = "在本機日曆面板顯示您選擇的到期提醒,不修改或上傳提醒內容。"; diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 18c3591..5f1f7bd 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -54,7 +54,10 @@ GitHub Actions 只在分支 push 且 `server/**` 发生变化时构建并发布 构建固定使用 `arm64`,仅支持 Apple Silicon Mac。 有 Developer ID Application 证书时,Taskfile 会自动使用钥匙串中第一个证书的唯一指纹;否则使用项目默认的 ad-hoc 签名。 桌面小组件与主应用通过 `group.work.12306.xstats` App Group 共享只读展示摘要。 +菜单栏日历提供“日期与星期”“日期与农历”“农历”“自定义”四种显示方式,自定义可自由组合 y/M/d 字段与分隔符,年份可省略,输入时显示预览并提示无效格式,独立于月历的星期开关;支持农历字号与对比度、可选悬停展开,按分钟刷新,休眠或关闭功能后停止。日程和到期提醒默认关闭,通过明确的授权按钮请求 EventKit 权限,只在日历面板可见时查询当前 42 格日期范围;只读展示,不修改日程,也不向 Widget、设置备份或服务端传递内容。列表 ID 仅保存在本机,显示偏好参与设置备份。节假日覆盖年份来自内置 Tyme 数据并随应用更新;假期倒计时与最多 3 天的请假建议只基于已公布的中国调休与双休安排,跨到未知年份时停止推算。 用于正式签名的 Apple Developer 团队须为主应用和 Widget 扩展启用同一个 App Group; +EventKit 授权还依赖主 App 的 `com.apple.security.personal-information.calendars` entitlement,配置来源为 `project.yml`。即使已有 Info.plist 权限说明,Hardened Runtime 缺少此 entitlement 仍会直接拒绝授权且不弹窗。本地预览和发布时应检查最终 App 签名中的 entitlement;系统授权必须由用户点击允许访问后决定。 + 小组件不读取 AI 登录凭据,也不自行请求额度或 IP 服务。日历节假日与黄历由主应用预计算,Widget 只读共享摘要;主应用未生成数据时,小组件显示空状态。 网络测速在检测到 VPN 或代理时提供“直连 / 经代理”选择,默认直连并绑定物理网卡;经代理的延迟使用目标 HTTP 往返而非本机代理提前应答的 TCP 握手。国内三网节点只支持 TCP 建连计时,因此始终尝试直连;无法取得直连路径时显示原因而不提供虚假的延迟。测速线路随设置备份保存,切换线路会清除上一路线的结果。 番茄钟与护眼休息为默认关闭的本机功能:通用设置只显示模块开关,时长、目标与声音由独立设置弹层中的系统下拉菜单调整;启用后手动开始,可暂停、重置当前阶段或跳过;切换阶段时保留各自剩余时间;有未完成进度时用“继续”恢复,点“开始”从完整时长启动,点“重置”恢复完整时长但保持暂停;跳过或自动进入下一阶段也从完整时长开始;单次模式在一轮专注与休息后暂停,循环模式连续运行,每完成四轮专注进入长休。“工作番茄”需手动点“开始工作”,随后自动交替专注与休息,每四轮进入长休;暂停或切换运行方式不结束工作时段;切换运行方式会暂停当前阶段并保留剩余时间,修改时长会暂停并回到完整专注阶段,但保留已完成轮数;点“结束工作”才结束并回到待开始状态。每日完成数只保存在本机偏好中,不加入设置备份。`RestSession` 用 `mach_continuous_time` 的绝对截止点计算阶段,唤醒后重算而非累加定时器 tick;多屏幕布、独立菜单栏计时入口与 Mini HUD 由主应用创建;HUD 可拖动并保存位置,支持数字、圆环和沙漏样式,Esc 或长按可以跳过,休息时可再专注五分钟。白噪音、粉红噪音和雨声由 `AVAudioSourceNode` 实时合成,仅在休息阶段播放。休息 Widget 从 App Group 读取阶段和预计截止时间,实际刷新受 WidgetKit 管理,不能作为秒级提醒来源。 diff --git a/Packages/XStatsKit/Sources/Localization/CalendarTranslations.swift b/Packages/XStatsKit/Sources/Localization/CalendarTranslations.swift index ea85f7c..2f9967e 100644 --- a/Packages/XStatsKit/Sources/Localization/CalendarTranslations.swift +++ b/Packages/XStatsKit/Sources/Localization/CalendarTranslations.swift @@ -124,5 +124,50 @@ extension Translations { 子时显示当日早子时;23 点起的晚子时按次日干支计算 Zi shows 00:00–00:59; late Zi from 23:00 uses the next day's cycle 吉 Lucky 凶 Unlucky +日期与星期 Date and weekday +显示方式 Display style +日期与农历 Date and lunar date +日期格式 Date format +年:yyyy 或 yy;月:MM 或 M;日:dd 或 d。分隔符可自行填写。 Year: yyyy or yy; month: MM or M; day: dd or d. Choose your own separators. +例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。 Examples: yyyy-MM-dd, M/d, dd/MM. The year is optional. +请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。 Include M for month and d for day; use y for year. Invalid formats temporarily display month/day. +按住 ⌘ 拖动菜单栏图标可调整位置。 Hold ⌘ while dragging the menu bar icon to reposition it. +放大农历文字 Larger lunar text +增强农历对比度 Stronger lunar contrast +假期倒计时与请假建议 Holiday countdown and leave ideas +预览 Preview +悬停展开日历 Open calendar on hover +打开日历 Open calendar +重新显示入口 Show menu bar item again +日程与提醒事项 Events and reminders +显示系统日程 Show calendar events +显示到期提醒 Show due reminders +仅在本机展示日程与到期提醒,不修改内容。 Displays events and due reminders locally without modifying them. +选择列表 Choose lists +暂无可用列表 No lists available +需要授权后才能显示 Permission is needed to display content +访问未获允许,请在系统设置中开启 Access is not allowed. Enable it in System Settings. +允许访问 Allow access +打开系统设置 Open System Settings +读取失败,请重试 Could not load. Try again. +节假日数据 Holiday data +最新数据年份:{} Latest data year: {} +假日数据随应用更新;未收录年份不推断调休。 Holiday data is updated with the app. Makeup workdays are not inferred for uncovered years. +查看更新 View updates +日程尚未授权 Calendar access not granted +提醒事项尚未授权 Reminders access not granted +无标题 Untitled +全天 All day +更多内容请在系统日历或提醒事项中查看 View more in Calendar or Reminders. +尚未选择任何列表 No lists selected +这一天没有日程或到期提醒 No events or due reminders on this day +假期中 Holiday in progress +还有 {} 天 In {} days +{} · 连休 {} 天 {} · {} days off +请假建议 Leave ideas +请 {} 天,连休 {} 天 Take {} leave days for {} days off +请假日期:{} Leave dates: {} +按双休及已公布的中国调休安排计算,实际请假以单位批准为准。 Based on a two-day weekend and published Chinese makeup workdays. Leave requires your employer’s approval. +有日程或提醒 Has events or reminders """# } diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv index 1959ee3..0c6005a 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv @@ -1564,3 +1564,48 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依 “{}”只剩 {} 可用。请检查不再需要的文件。 لم يتبقَّ على «{}» سوى {}. راجع الملفات التي لم تعد بحاجة إليها. 安装标识 معرّف التثبيت 复制安装标识 نسخ معرّف التثبيت +日期与星期 التاريخ ويوم الأسبوع +显示方式 نمط العرض +放大农历文字 تكبير نص التقويم القمري +增强农历对比度 زيادة تباين النص القمري +假期倒计时与请假建议 العد التنازلي للعطلات واقتراحات الإجازة +预览 معاينة +悬停展开日历 فتح التقويم عند مرور المؤشر +打开日历 فتح التقويم +重新显示入口 إظهار عنصر شريط القوائم مجددًا +日程与提醒事项 الأحداث والتذكيرات +显示系统日程 عرض أحداث التقويم +显示到期提醒 عرض التذكيرات المستحقة +仅在本机展示日程与到期提醒,不修改内容。 يعرض الأحداث والتذكيرات المستحقة محليًا دون تعديلها. +选择列表 اختيار القوائم +暂无可用列表 لا توجد قوائم متاحة +需要授权后才能显示 يلزم الإذن لعرض المحتوى +访问未获允许,请在系统设置中开启 الوصول غير مسموح. فعّله في إعدادات النظام. +允许访问 السماح بالوصول +打开系统设置 فتح إعدادات النظام +读取失败,请重试 تعذر التحميل. أعد المحاولة. +节假日数据 بيانات العطلات +最新数据年份:{} أحدث سنة للبيانات: {} +假日数据随应用更新;未收录年份不推断调休。 تُحدَّث بيانات العطلات مع التطبيق. لا تُخمَّن أيام العمل التعويضية للسنوات غير المشمولة. +查看更新 عرض التحديثات +日程尚未授权 لم يُمنح الوصول إلى التقويم +提醒事项尚未授权 لم يُمنح الوصول إلى التذكيرات +无标题 بلا عنوان +全天 طوال اليوم +更多内容请在系统日历或提醒事项中查看 اعرض المزيد في التقويم أو التذكيرات. +尚未选择任何列表 لم تُحدَّد أي قائمة +这一天没有日程或到期提醒 لا توجد أحداث أو تذكيرات مستحقة في هذا اليوم +假期中 العطلة جارية +还有 {} 天 بعد {} أيام +{} · 连休 {} 天 {} · {} أيام عطلة متتالية +请假建议 اقتراحات الإجازة +请 {} 天,连休 {} 天 خذ {} أيام إجازة للحصول على {} أيام راحة +请假日期:{} تواريخ الإجازة: {} +按双休及已公布的中国调休安排计算,实际请假以单位批准为准。 تستند إلى عطلة أسبوعية ليومين وأيام العمل التعويضية المعلنة في الصين. تخضع الإجازة لموافقة جهة العمل. +有日程或提醒 توجد أحداث أو تذكيرات +日期与农历 التاريخ والتاريخ القمري +日期格式 تنسيق التاريخ +年:yyyy 或 yy;月:MM 或 M;日:dd 或 d。分隔符可自行填写。 السنة: yyyy أو yy؛ الشهر: MM أو M؛ اليوم: dd أو d. اختر الفواصل كما تريد. +例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。 أمثلة: yyyy-MM-dd، M/d، dd/MM. السنة اختيارية. +请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。 أدرج M للشهر وd لليوم، واستخدم y للسنة. يُعرض التنسيق غير الصالح مؤقتًا كشهر/يوم. +按住 ⌘ 拖动菜单栏图标可调整位置。 اضغط على ⌘ أثناء سحب أيقونة شريط القوائم لتغيير موضعها. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv index c88b52e..6aa0eb0 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv @@ -1564,3 +1564,48 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依 “{}”只剩 {} 可用。请检查不再需要的文件。 Auf „{}“ sind nur noch {} verfügbar. Prüfe Dateien, die du nicht mehr benötigst. 安装标识 Installationskennung 复制安装标识 Installationskennung kopieren +日期与星期 Datum und Wochentag +显示方式 Anzeigeformat +放大农历文字 Größere Mondkalenderschrift +增强农历对比度 Höherer Mondkalenderkontrast +假期倒计时与请假建议 Feiertags-Countdown und Urlaubstipps +预览 Vorschau +悬停展开日历 Kalender bei Mausberührung öffnen +打开日历 Kalender öffnen +重新显示入口 Menüleisteneintrag wieder einblenden +日程与提醒事项 Termine und Erinnerungen +显示系统日程 Kalendertermine anzeigen +显示到期提醒 Fällige Erinnerungen anzeigen +仅在本机展示日程与到期提醒,不修改内容。 Zeigt Termine und fällige Erinnerungen lokal an, ohne sie zu ändern. +选择列表 Listen auswählen +暂无可用列表 Keine Listen verfügbar +需要授权后才能显示 Zum Anzeigen ist eine Berechtigung erforderlich +访问未获允许,请在系统设置中开启 Zugriff nicht erlaubt. Bitte in den Systemeinstellungen aktivieren. +允许访问 Zugriff erlauben +打开系统设置 Systemeinstellungen öffnen +读取失败,请重试 Laden fehlgeschlagen. Erneut versuchen. +节假日数据 Feiertagsdaten +最新数据年份:{} Aktuellstes Datenjahr: {} +假日数据随应用更新;未收录年份不推断调休。 Feiertagsdaten werden mit der App aktualisiert. Ausgleichsarbeitstage werden für fehlende Jahre nicht geschätzt. +查看更新 Updates ansehen +日程尚未授权 Kalenderzugriff nicht erlaubt +提醒事项尚未授权 Erinnerungszugriff nicht erlaubt +无标题 Ohne Titel +全天 Ganztägig +更多内容请在系统日历或提醒事项中查看 Weitere Einträge in Kalender oder Erinnerungen ansehen. +尚未选择任何列表 Keine Listen ausgewählt +这一天没有日程或到期提醒 Keine Termine oder fälligen Erinnerungen an diesem Tag +假期中 Feiertage laufen +还有 {} 天 In {} Tagen +{} · 连休 {} 天 {} · {} freie Tage +请假建议 Urlaubstipps +请 {} 天,连休 {} 天 {} Urlaubstage für {} freie Tage +请假日期:{} Urlaubstage: {} +按双休及已公布的中国调休安排计算,实际请假以单位批准为准。 Berechnet mit zwei freien Wochenendtagen und veröffentlichten chinesischen Ausgleichsarbeitstagen. Urlaub bedarf der Genehmigung des Arbeitgebers. +有日程或提醒 Termine oder Erinnerungen vorhanden +日期与农历 Datum und Monddatum +日期格式 Datumsformat +年:yyyy 或 yy;月:MM 或 M;日:dd 或 d。分隔符可自行填写。 Jahr: yyyy oder yy; Monat: MM oder M; Tag: dd oder d. Trennzeichen frei wählen. +例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。 Beispiele: yyyy-MM-dd, M/d, dd/MM. Das Jahr ist optional. +请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。 M für Monat und d für Tag angeben; y für das Jahr verwenden. Ungültige Formate erscheinen vorübergehend als Monat/Tag. +按住 ⌘ 拖动菜单栏图标可调整位置。 Zum Verschieben das Menüleistensymbol bei gedrückter ⌘-Taste ziehen. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv index 57d040d..81f768a 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv @@ -1564,3 +1564,48 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依 “{}”只剩 {} 可用。请检查不再需要的文件。 En «{}» solo quedan {} disponibles. Revisa los archivos que ya no necesitas. 安装标识 Identificador de instalación 复制安装标识 Copiar identificador de instalación +日期与星期 Fecha y día +显示方式 Estilo de visualización +放大农历文字 Texto lunar más grande +增强农历对比度 Mayor contraste lunar +假期倒计时与请假建议 Cuenta atrás y sugerencias de permiso +预览 Vista previa +悬停展开日历 Abrir calendario al pasar el cursor +打开日历 Abrir calendario +重新显示入口 Volver a mostrar el icono +日程与提醒事项 Eventos y recordatorios +显示系统日程 Mostrar eventos del calendario +显示到期提醒 Mostrar recordatorios con fecha +仅在本机展示日程与到期提醒,不修改内容。 Muestra eventos y recordatorios localmente sin modificarlos. +选择列表 Elegir listas +暂无可用列表 No hay listas disponibles +需要授权后才能显示 Se necesita permiso para mostrar el contenido +访问未获允许,请在系统设置中开启 Acceso no permitido. Actívalo en Ajustes del Sistema. +允许访问 Permitir acceso +打开系统设置 Abrir Ajustes del Sistema +读取失败,请重试 No se pudo cargar. Inténtalo de nuevo. +节假日数据 Datos de festivos +最新数据年份:{} Último año de datos: {} +假日数据随应用更新;未收录年份不推断调休。 Los datos se actualizan con la app. No se deducen días laborables compensatorios para años sin datos. +查看更新 Ver actualizaciones +日程尚未授权 Acceso al calendario no concedido +提醒事项尚未授权 Acceso a recordatorios no concedido +无标题 Sin título +全天 Todo el día +更多内容请在系统日历或提醒事项中查看 Consulta más en Calendario o Recordatorios. +尚未选择任何列表 Ninguna lista seleccionada +这一天没有日程或到期提醒 No hay eventos ni recordatorios para este día +假期中 De vacaciones +还有 {} 天 Dentro de {} días +{} · 连休 {} 天 {} · {} días libres +请假建议 Ideas de permiso +请 {} 天,连休 {} 天 Pide {} días para descansar {} +请假日期:{} Fechas de permiso: {} +按双休及已公布的中国调休安排计算,实际请假以单位批准为准。 Se basa en fines de semana de dos días y días compensatorios publicados en China. Los permisos requieren aprobación de la empresa. +有日程或提醒 Tiene eventos o recordatorios +日期与农历 Fecha y fecha lunar +日期格式 Formato de fecha +年:yyyy 或 yy;月:MM 或 M;日:dd 或 d。分隔符可自行填写。 Año: yyyy o yy; mes: MM o M; día: dd o d. Puedes elegir los separadores. +例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。 Ejemplos: yyyy-MM-dd, M/d, dd/MM. El año es opcional. +请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。 Incluye M para el mes y d para el día; usa y para el año. Los formatos inválidos se muestran temporalmente como mes/día. +按住 ⌘ 拖动菜单栏图标可调整位置。 Mantén pulsada ⌘ y arrastra el icono de la barra de menús para moverlo. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv index 0ab2e80..a3de338 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv @@ -1564,3 +1564,48 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依 “{}”只剩 {} 可用。请检查不再需要的文件。 « {} » : il ne reste que {} disponibles. Vérifiez les fichiers dont vous n’avez plus besoin. 安装标识 Identifiant d’installation 复制安装标识 Copier l’identifiant d’installation +日期与星期 Date et jour +显示方式 Style d’affichage +放大农历文字 Texte lunaire agrandi +增强农历对比度 Contraste lunaire renforcé +假期倒计时与请假建议 Compte à rebours et idées de congés +预览 Aperçu +悬停展开日历 Ouvrir le calendrier au survol +打开日历 Ouvrir le calendrier +重新显示入口 Réafficher l’entrée de la barre des menus +日程与提醒事项 Événements et rappels +显示系统日程 Afficher les événements +显示到期提醒 Afficher les rappels à échéance +仅在本机展示日程与到期提醒,不修改内容。 Affiche localement les événements et rappels sans les modifier. +选择列表 Choisir les listes +暂无可用列表 Aucune liste disponible +需要授权后才能显示 Une autorisation est nécessaire pour afficher le contenu +访问未获允许,请在系统设置中开启 Accès non autorisé. Activez-le dans Réglages Système. +允许访问 Autoriser l’accès +打开系统设置 Ouvrir Réglages Système +读取失败,请重试 Échec du chargement. Réessayez. +节假日数据 Données des jours fériés +最新数据年份:{} Année des dernières données : {} +假日数据随应用更新;未收录年份不推断调休。 Les données suivent les mises à jour de l’app. Aucun jour de travail compensatoire n’est déduit pour les années manquantes. +查看更新 Voir les mises à jour +日程尚未授权 Accès au calendrier non autorisé +提醒事项尚未授权 Accès aux rappels non autorisé +无标题 Sans titre +全天 Toute la journée +更多内容请在系统日历或提醒事项中查看 Consultez la suite dans Calendrier ou Rappels. +尚未选择任何列表 Aucune liste sélectionnée +这一天没有日程或到期提醒 Aucun événement ni rappel à échéance ce jour +假期中 Période de congés en cours +还有 {} 天 Dans {} jours +{} · 连休 {} 天 {} · {} jours de repos +请假建议 Idées de congés +请 {} 天,连休 {} 天 Posez {} jours pour {} jours de repos +请假日期:{} Dates à poser : {} +按双休及已公布的中国调休安排计算,实际请假以单位批准为准。 Calcul selon un week-end de deux jours et les jours compensatoires publiés en Chine. Les congés restent soumis à l’accord de l’employeur. +有日程或提醒 Événements ou rappels présents +日期与农历 Date et date lunaire +日期格式 Format de date +年:yyyy 或 yy;月:MM 或 M;日:dd 或 d。分隔符可自行填写。 Année : yyyy ou yy ; mois : MM ou M ; jour : dd ou d. Choisissez vos séparateurs. +例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。 Exemples : yyyy-MM-dd, M/d, dd/MM. L’année est facultative. +请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。 Incluez M pour le mois et d pour le jour ; utilisez y pour l’année. Un format invalide affiche provisoirement mois/jour. +按住 ⌘ 拖动菜单栏图标可调整位置。 Maintenez ⌘ en faisant glisser l’icône de la barre des menus pour la déplacer. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv index 8730e2d..7756769 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv @@ -1564,3 +1564,48 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依 “{}”只剩 {} 可用。请检查不再需要的文件。 「{}」の空き容量は残り {} です。不要なファイルを確認してください。 安装标识 インストール識別子 复制安装标识 インストール識別子をコピー +日期与星期 日付と曜日 +显示方式 表示形式 +放大农历文字 旧暦の文字を拡大 +增强农历对比度 旧暦のコントラストを強調 +假期倒计时与请假建议 休日のカウントダウンと休暇案 +预览 プレビュー +悬停展开日历 ポインタを合わせて開く +打开日历 カレンダーを開く +重新显示入口 メニューバー項目を再表示 +日程与提醒事项 予定とリマインダー +显示系统日程 カレンダーの予定を表示 +显示到期提醒 期限付きリマインダーを表示 +仅在本机展示日程与到期提醒,不修改内容。 予定と期限付きリマインダーをこの Mac に表示し、内容は変更しません。 +选择列表 リストを選択 +暂无可用列表 利用できるリストがありません +需要授权后才能显示 表示するにはアクセス許可が必要です +访问未获允许,请在系统设置中开启 アクセスが許可されていません。システム設定で許可してください。 +允许访问 アクセスを許可 +打开系统设置 システム設定を開く +读取失败,请重试 読み込めませんでした。再試行してください。 +节假日数据 祝日データ +最新数据年份:{} 最新データの年:{} +假日数据随应用更新;未收录年份不推断调休。 祝日データはアプリの更新で配信します。未収録の年の振替出勤日は推測しません。 +查看更新 更新を確認 +日程尚未授权 カレンダーへのアクセス未許可 +提醒事项尚未授权 リマインダーへのアクセス未許可 +无标题 名称未設定 +全天 終日 +更多内容请在系统日历或提醒事项中查看 その他の項目はカレンダーまたはリマインダーで確認できます。 +尚未选择任何列表 リストが選択されていません +这一天没有日程或到期提醒 この日の予定や期限付きリマインダーはありません +假期中 休日期間中 +还有 {} 天 あと {} 日 +{} · 连休 {} 天 {} · {} 日間の連休 +请假建议 休暇の提案 +请 {} 天,连休 {} 天 {} 日の休暇で {} 連休 +请假日期:{} 休暇を取る日:{} +按双休及已公布的中国调休安排计算,实际请假以单位批准为准。 週休2日と公表済みの中国の振替出勤日で計算しています。休暇は勤務先の承認に従ってください。 +有日程或提醒 予定またはリマインダーあり +日期与农历 日付と旧暦 +日期格式 日付の書式 +年:yyyy 或 yy;月:MM 或 M;日:dd 或 d。分隔符可自行填写。 年:yyyy または yy、月:MM または M、日:dd または d。区切り文字は自由に指定できます。 +例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。 例:yyyy-MM-dd、M月d日、dd/MM。年は省略できます。 +请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。 月の M と日の d を含め、年には y を使ってください。無効な書式は一時的に月/日で表示します。 +按住 ⌘ 拖动菜单栏图标可调整位置。 ⌘ を押しながらメニューバーのアイコンをドラッグすると移動できます。 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv index 71d3e5c..56a5201 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv @@ -1564,3 +1564,48 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依 “{}”只剩 {} 可用。请检查不再需要的文件。 “{}”의 남은 공간은 {}입니다. 더 이상 필요하지 않은 파일을 확인하세요. 安装标识 설치 식별자 复制安装标识 설치 식별자 복사 +日期与星期 날짜와 요일 +显示方式 표시 방식 +放大农历文字 음력 글자 확대 +增强农历对比度 음력 대비 강화 +假期倒计时与请假建议 휴일 카운트다운 및 휴가 제안 +预览 미리 보기 +悬停展开日历 마우스를 올리면 열기 +打开日历 캘린더 열기 +重新显示入口 메뉴 막대 항목 다시 표시 +日程与提醒事项 일정 및 미리 알림 +显示系统日程 캘린더 일정 표시 +显示到期提醒 기한이 있는 미리 알림 표시 +仅在本机展示日程与到期提醒,不修改内容。 이 Mac에서 일정과 기한이 있는 미리 알림을 표시하며 내용을 변경하지 않습니다. +选择列表 목록 선택 +暂无可用列表 사용 가능한 목록 없음 +需要授权后才能显示 표시하려면 권한이 필요합니다 +访问未获允许,请在系统设置中开启 접근이 허용되지 않았습니다. 시스템 설정에서 허용하세요. +允许访问 접근 허용 +打开系统设置 시스템 설정 열기 +读取失败,请重试 불러오지 못했습니다. 다시 시도하세요. +节假日数据 공휴일 데이터 +最新数据年份:{} 최신 데이터 연도: {} +假日数据随应用更新;未收录年份不推断调休。 공휴일 데이터는 앱과 함께 업데이트됩니다. 미수록 연도의 대체 근무일은 추정하지 않습니다. +查看更新 업데이트 보기 +日程尚未授权 캘린더 접근이 허용되지 않음 +提醒事项尚未授权 미리 알림 접근이 허용되지 않음 +无标题 제목 없음 +全天 하루 종일 +更多内容请在系统日历或提醒事项中查看 더 많은 항목은 캘린더 또는 미리 알림에서 확인하세요. +尚未选择任何列表 선택한 목록 없음 +这一天没有日程或到期提醒 이 날짜에 일정이나 기한이 있는 미리 알림이 없습니다 +假期中 휴일 기간 +还有 {} 天 {}일 남음 +{} · 连休 {} 天 {} · {}일 연휴 +请假建议 휴가 제안 +请 {} 天,连休 {} 天 {}일 휴가로 {}일 연휴 +请假日期:{} 휴가 날짜: {} +按双休及已公布的中国调休安排计算,实际请假以单位批准为准。 주 5일 근무와 발표된 중국 대체 근무일을 기준으로 계산합니다. 휴가는 회사의 승인이 필요합니다. +有日程或提醒 일정 또는 미리 알림 있음 +日期与农历 날짜와 음력 +日期格式 날짜 형식 +年:yyyy 或 yy;月:MM 或 M;日:dd 或 d。分隔符可自行填写。 연도: yyyy 또는 yy, 월: MM 또는 M, 일: dd 또는 d. 구분 기호는 자유롭게 입력하세요. +例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。 예: yyyy-MM-dd, M/d, dd/MM. 연도는 생략할 수 있습니다. +请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。 월 M과 일 d를 포함하고 연도에는 y를 사용하세요. 잘못된 형식은 임시로 월/일로 표시됩니다. +按住 ⌘ 拖动菜单栏图标可调整位置。 ⌘ 키를 누른 채 메뉴 막대 아이콘을 드래그하여 위치를 조정하세요. diff --git a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv index 820f9c9..e35b4cd 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv @@ -1565,3 +1565,48 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依 “{}”只剩 {} 可用。请检查不再需要的文件。 「{}」只剩 {} 可用。請檢查不再需要的檔案。 安装标识 安裝識別碼 复制安装标识 複製安裝識別碼 +日期与星期 日期與星期 +显示方式 顯示方式 +放大农历文字 放大農曆文字 +增强农历对比度 增強農曆對比度 +假期倒计时与请假建议 假期倒數與請假建議 +预览 預覽 +悬停展开日历 懸停展開日曆 +打开日历 開啟日曆 +重新显示入口 重新顯示入口 +日程与提醒事项 行程與提醒事項 +显示系统日程 顯示系統行程 +显示到期提醒 顯示到期提醒 +仅在本机展示日程与到期提醒,不修改内容。 僅在本機顯示行程與到期提醒,不修改內容。 +选择列表 選擇列表 +暂无可用列表 暫無可用列表 +需要授权后才能显示 需要授權後才能顯示 +访问未获允许,请在系统设置中开启 存取未獲允許,請在系統設定中開啟 +允许访问 允許存取 +打开系统设置 開啟系統設定 +读取失败,请重试 讀取失敗,請重試 +节假日数据 節假日資料 +最新数据年份:{} 最新資料年份:{} +假日数据随应用更新;未收录年份不推断调休。 假日資料隨應用程式更新;未收錄年份不推斷調休。 +查看更新 查看更新 +日程尚未授权 行程尚未授權 +提醒事项尚未授权 提醒事項尚未授權 +无标题 無標題 +全天 全天 +更多内容请在系统日历或提醒事项中查看 更多內容請在系統日曆或提醒事項中查看 +尚未选择任何列表 尚未選擇任何列表 +这一天没有日程或到期提醒 這一天沒有行程或到期提醒 +假期中 假期中 +还有 {} 天 還有 {} 天 +{} · 连休 {} 天 {} · 連休 {} 天 +请假建议 請假建議 +请 {} 天,连休 {} 天 請 {} 天,連休 {} 天 +请假日期:{} 請假日期:{} +按双休及已公布的中国调休安排计算,实际请假以单位批准为准。 按雙休及已公布的中國調休安排計算,實際請假以單位批准為準。 +有日程或提醒 有行程或提醒 +日期与农历 日期與農曆 +日期格式 日期格式 +年:yyyy 或 yy;月:MM 或 M;日:dd 或 d。分隔符可自行填写。 年:yyyy 或 yy;月:MM 或 M;日:dd 或 d。分隔符號可自行填寫。 +例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。 例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。 +请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。 請包含月份 M 和日期 d;年份使用 y。格式無效時暫以月/日顯示。 +按住 ⌘ 拖动菜单栏图标可调整位置。 按住 ⌘ 拖移選單列圖示可調整位置。 diff --git a/Packages/XStatsKit/Sources/XStatsUI/AppKit/StatusPanel.swift b/Packages/XStatsKit/Sources/XStatsUI/AppKit/StatusPanel.swift index 291518a..725c7c6 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/AppKit/StatusPanel.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/AppKit/StatusPanel.swift @@ -85,7 +85,7 @@ final class StatusPanel: NSPanel { func refreshHeight() { guard isVisible else { return } let frame = targetFrame() - guard abs(frame.height - self.frame.height) > 1 else { return } + guard frame != self.frame else { return } setFrame(frame, display: true) invalidateShadow() } @@ -95,7 +95,7 @@ final class StatusPanel: NSPanel { guard contentView != nil, abs(overflow) > 1 else { return } let height = min(max(frame.height + overflow, minHeight), availableHeight()) guard abs(height - frame.height) > 1 else { return } - setFrame(NSRect(x: frame.minX, y: frame.maxY - height, width: width, height: height), display: true) + setFrame(NSRect(x: frame.minX, y: frame.maxY - height, width: frame.width, height: height), display: true) invalidateShadow() } @@ -132,22 +132,27 @@ final class StatusPanel: NSPanel { private func targetFrame() -> NSRect { let visible = (anchorScreen ?? NSScreen.main)?.visibleFrame ?? .zero + let natural = makeMeasuringContent().fittingSize.height + return Self.constrainedFrame(anchor: anchor, visible: visible, + preferredSize: NSSize(width: width, height: max(natural, minHeight))) + } + + /// 显示器可用区始终优先于首选尺寸,窄屏和低分辨率下不能让最小高度把面板挤出屏幕。 + static func constrainedFrame(anchor: NSRect, visible: NSRect, preferredSize: NSSize) -> NSRect { let margin = DS.Space.s2 let top = min(anchor.minY - DS.Size.panelGap, visible.maxY) - - let natural = makeMeasuringContent().fittingSize.height - let height = min(max(natural, minHeight), availableHeight()) - - var x = anchor.midX - width / 2 - x = min(max(x, visible.minX + margin), visible.maxX - width - margin) - return NSRect(x: x, y: top - height, width: width, height: height) + let height = min(preferredSize.height, max(1, top - visible.minY - margin)) + let fittedWidth = min(preferredSize.width, max(1, visible.width - 2 * margin)) + var x = anchor.midX - fittedWidth / 2 + x = min(max(x, visible.minX + margin), visible.maxX - fittedWidth - margin) + return NSRect(x: x, y: top - height, width: fittedWidth, height: height) } /// 菜单栏下方到屏幕底边(留一点边距)能放下的最大高度 private func availableHeight() -> CGFloat { let visible = (anchorScreen ?? NSScreen.main)?.visibleFrame ?? .zero let top = min(anchor.minY - DS.Size.panelGap, visible.maxY) - return max(minHeight, top - visible.minY - DS.Space.s2) + return max(1, top - visible.minY - DS.Space.s2) } // MARK: 事件 diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAgenda.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAgenda.swift new file mode 100644 index 0000000..43b2957 --- /dev/null +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAgenda.swift @@ -0,0 +1,182 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +@preconcurrency import EventKit +import Foundation +import Localization +import Observation + +struct CalendarAgendaSource: Identifiable, Sendable { + let id: String + let title: String +} + +struct CalendarAgendaItem: Identifiable, Sendable { + let id: String + let title: String + let source: String + let start: Date + let end: Date + let isAllDay: Bool + let isReminder: Bool + + /// 全天及跨天事件的结束时间是开区间,不能在次日多画一个日程标记。 + func occurs(on date: Date, calendar: Calendar) -> Bool { + guard let day = calendar.dateInterval(of: .day, for: date) else { return false } + if isReminder || end <= start { return start >= day.start && start < day.end } + return start < day.end && end > day.start + } +} + +struct CalendarAgendaQuery: Equatable, Sendable { + let start: Date + let end: Date + let events: Bool + let reminders: Bool + let eventIDs: Set? + let reminderIDs: Set? + let revision: Int +} + +/// EventKit 对象留在工作 actor 内;界面只接收值类型,不在主线程同步查询日程。 +private actor CalendarAgendaReader { + private lazy var store = EKEventStore() + private var pending: [UUID: (Any, CheckedContinuation<[CalendarAgendaItem]?, Never>)] = [:] + + func sources(reminders: Bool) -> [CalendarAgendaSource] { + let type: EKEntityType = reminders ? .reminder : .event + guard EKEventStore.authorizationStatus(for: type) == .fullAccess else { return [] } + return store.calendars(for: type).map { .init(id: $0.calendarIdentifier, title: $0.title) } + .sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending } + } + + func load(_ query: CalendarAgendaQuery) async -> [CalendarAgendaItem]? { + guard !Task.isCancelled else { return nil } + var result: [CalendarAgendaItem] = [] + if query.events && EKEventStore.authorizationStatus(for: .event) == .fullAccess { + let calendars = store.calendars(for: .event).filter { query.eventIDs?.contains($0.calendarIdentifier) ?? true } + // EventKit 会把空列表视为“所有日历”;用户明确取消全部时必须跳过查询。 + if !calendars.isEmpty { + let predicate = store.predicateForEvents(withStart: query.start, end: query.end, calendars: calendars) + result = store.events(matching: predicate).map { + .init(id: $0.calendarItemIdentifier + ":" + String($0.startDate.timeIntervalSince1970), + title: $0.title ?? "", source: $0.calendar.title, start: $0.startDate, end: $0.endDate, + isAllDay: $0.isAllDay, isReminder: false) + } + } + } + if query.reminders && EKEventStore.authorizationStatus(for: .reminder) == .fullAccess { + let calendars = store.calendars(for: .reminder).filter { query.reminderIDs?.contains($0.calendarIdentifier) ?? true } + if !calendars.isEmpty { + guard let reminders = await reminders(query, calendars: calendars) else { return nil } + result += reminders + } + } + return result.sorted { + if $0.start != $1.start { return $0.start < $1.start } + return $0.id < $1.id + } + } + + private func reminders(_ query: CalendarAgendaQuery, calendars: [EKCalendar]) async -> [CalendarAgendaItem]? { + let id = UUID() + let predicate = store.predicateForIncompleteReminders(withDueDateStarting: query.start, ending: query.end, calendars: calendars) + return await withTaskCancellationHandler { + guard !Task.isCancelled else { return nil } + return await withCheckedContinuation { continuation in + let token = store.fetchReminders(matching: predicate) { [weak self] reminders in + let items = reminders.map { reminders in + reminders.compactMap { reminder -> CalendarAgendaItem? in + guard let components = reminder.dueDateComponents else { return nil } + var calendar = components.calendar ?? Calendar(identifier: .gregorian) + calendar.timeZone = components.timeZone ?? .autoupdatingCurrent + guard let date = calendar.date(from: components), date >= query.start, date < query.end else { return nil } + return .init(id: reminder.calendarItemIdentifier, title: reminder.title ?? "", + source: reminder.calendar.title, start: date, end: date, + isAllDay: components.hour == nil, isReminder: true) + } + } + Task { await self?.finish(id, items: items) } + } + pending[id] = (token, continuation) + } + } onCancel: { + Task { await self.cancel(id) } + } + } + + private func finish(_ id: UUID, items: [CalendarAgendaItem]?) { + pending.removeValue(forKey: id)?.1.resume(returning: items) + } + + private func cancel(_ id: UUID) { + guard let (token, continuation) = pending.removeValue(forKey: id) else { return } + store.cancelFetchRequest(token) + continuation.resume(returning: nil) + } +} + +@MainActor +@Observable +final class CalendarAgendaController { + private(set) var items: [CalendarAgendaItem] = [] + private(set) var eventSources: [CalendarAgendaSource] = [] + private(set) var reminderSources: [CalendarAgendaSource] = [] + private(set) var eventsAccess = EKEventStore.authorizationStatus(for: .event) + private(set) var remindersAccess = EKEventStore.authorizationStatus(for: .reminder) + private(set) var isLoading = false + private(set) var isRequesting = false + private(set) var failed = false + var revision = 0 + @ObservationIgnored private let reader = CalendarAgendaReader() + @ObservationIgnored private var generation = UUID() + + func refreshAuthorization() { + eventsAccess = EKEventStore.authorizationStatus(for: .event) + remindersAccess = EKEventStore.authorizationStatus(for: .reminder) + revision += 1 + } + + /// 只能由明确的授权按钮调用;启用功能、导入偏好和打开日历均不自动弹出系统授权。 + func requestAccess(reminders: Bool) async { + guard !isRequesting else { return } + isRequesting = true + defer { isRequesting = false } + do { + let store = EKEventStore() + if reminders { _ = try await store.requestFullAccessToReminders() } + else { _ = try await store.requestFullAccessToEvents() } + failed = false + } catch { failed = true } + refreshAuthorization() + if reminders { reminderSources = await reader.sources(reminders: true) } + else { eventSources = await reader.sources(reminders: false) } + } + + func refreshSources(events: Bool, reminders: Bool) async { + refreshAuthorization() + eventSources = events ? await reader.sources(reminders: false) : [] + reminderSources = reminders ? await reader.sources(reminders: true) : [] + } + + func load(_ query: CalendarAgendaQuery) async { + let current = UUID() + generation = current + items = [] + failed = false + guard query.events || query.reminders else { isLoading = false; return } + isLoading = true + let result = await reader.load(query) + guard generation == current, !Task.isCancelled else { return } + items = result ?? [] + failed = result == nil + isLoading = false + } + + func clear() { + generation = UUID() + items = [] + isLoading = false + failed = false + } +} diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAgendaView.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAgendaView.swift new file mode 100644 index 0000000..62f1261 --- /dev/null +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAgendaView.swift @@ -0,0 +1,120 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +import EventKit +import Localization +import SwiftUI + +struct CalendarAgendaView: View { + @Environment(AppModel.self) private var model + let day: CalendarDay + + private var items: [CalendarAgendaItem] { + model.calendarAgenda.items.filter { $0.occurs(on: day.date, calendar: CalendarEngine.gregorian()) } + } + + var body: some View { + let agenda = model.calendarAgenda + let preferences = model.settings.calendarPreferences + VStack(alignment: .leading, spacing: 10) { + HStack { + Label(tr("日程与提醒事项"), systemImage: "calendar.badge.clock").font(.subheadline.weight(.medium)) + Spacer() + Text(day.date.formatted(.dateTime.month().day().locale(L10n.locale))).font(.caption).foregroundStyle(.secondary) + } + if preferences.showEvents && agenda.eventsAccess != .fullAccess { + accessNotice(tr("日程尚未授权")) + } + if preferences.showReminders && agenda.remindersAccess != .fullAccess { + accessNotice(tr("提醒事项尚未授权")) + } + if agenda.isLoading { + ProgressView().controlSize(.small) + } else if agenda.failed { + Button(tr("读取失败,请重试")) { agenda.revision += 1 }.buttonStyle(.borderless) + } else if !items.isEmpty { + ForEach(items.prefix(40)) { item in + HStack(alignment: .top, spacing: 10) { + Image(systemName: item.isReminder ? "checklist" : "calendar") + .foregroundStyle(item.isReminder ? .orange : Color.accentColor) + VStack(alignment: .leading, spacing: 3) { + Text(item.title.isEmpty ? tr("无标题") : item.title).font(.subheadline) + .frame(maxWidth: .infinity, alignment: .leading) + Text(item.source).font(.caption).foregroundStyle(.secondary) + } + Text(item.isAllDay ? tr("全天") : item.start.formatted(.dateTime.hour().minute().locale(L10n.locale))) + .font(.caption).foregroundStyle(.secondary) + } + } + if items.count > 40 { + Text(tr("更多内容请在系统日历或提醒事项中查看")).font(.caption).foregroundStyle(.secondary) + } + } else if (preferences.showEvents && agenda.eventsAccess == .fullAccess) || + (preferences.showReminders && agenda.remindersAccess == .fullAccess) { + let noEvents = !preferences.showEvents || model.settings.calendarEventSourceIDs?.isEmpty == true + let noReminders = !preferences.showReminders || model.settings.calendarReminderSourceIDs?.isEmpty == true + Text(tr(noEvents && noReminders ? "尚未选择任何列表" : "这一天没有日程或到期提醒")) + .font(.caption).foregroundStyle(.secondary) + } + } + .padding(12) + .background(DS.Palette.textSecondary.opacity(0.05), in: RoundedRectangle(cornerRadius: 10)) + } + + private func accessNotice(_ title: String) -> some View { + HStack { + Text(title).font(.caption).foregroundStyle(.secondary) + Spacer() + Button(tr("日历设置…")) { model.openMainWindow(.settingsMenuBar) }.buttonStyle(.borderless) + } + } +} + +struct CalendarHolidayPlanView: View { + let plan: CalendarHolidayPlan + + var body: some View { + VStack(alignment: .leading, spacing: 9) { + HStack { + Label(tr(plan.name), systemImage: "sun.max").font(.subheadline.weight(.medium)) + Spacer() + Text(plan.daysUntil == 0 ? tr("假期中") : tr("还有 \(number(plan.daysUntil)) 天")) + .font(.subheadline).foregroundStyle(Color.accentColor) + } + Text(tr("\(range(plan.start, plan.end)) · 连休 \(number(plan.totalDays)) 天")) + .font(.caption).foregroundStyle(.secondary) + if !plan.leaveOptions.isEmpty { + DisclosureGroup(tr("请假建议")) { + VStack(alignment: .leading, spacing: 10) { + ForEach(plan.leaveOptions) { option in + VStack(alignment: .leading, spacing: 3) { + Text(tr("请 \(number(option.leaveDates.count)) 天,连休 \(number(option.totalDays)) 天")) + .font(.subheadline) + Text(range(option.start, option.end)).font(.caption).foregroundStyle(.secondary) + let dates = option.leaveDates.map { $0.formatted(.dateTime.month().day().locale(L10n.locale)) } + Text(tr("请假日期:\(dates.formatted(.list(type: .and).locale(L10n.locale)))")) + .font(.caption).foregroundStyle(.secondary) + } + } + Text(tr("按双休及已公布的中国调休安排计算,实际请假以单位批准为准。")) + .font(.caption).foregroundStyle(.secondary) + }.padding(.top, 8) + } + .font(.caption) + } + } + .padding(12) + .background(Color.accentColor.opacity(0.06), in: RoundedRectangle(cornerRadius: 10)) + } + + private func number(_ value: Int) -> String { value.formatted(.number.locale(L10n.locale)) } + + private func range(_ start: Date, _ end: Date) -> String { + let formatter = DateIntervalFormatter() + formatter.locale = L10n.locale + formatter.calendar = CalendarEngine.gregorian() + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter.string(from: start, to: end) + } +} diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarEngine.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarEngine.swift index 95cc5f5..7337ff2 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarEngine.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarEngine.swift @@ -104,6 +104,26 @@ enum CalendarEngine { return self.day(year: year, month: month, day: day, timeZone: timeZone) } + /// 菜单栏省略农历年份,但保留月份、初一及闰月标识,避免月初或跨月时产生歧义。 + static func lunarDateTitle(at date: Date, locale: Locale, timeZone: TimeZone = .autoupdatingCurrent) -> String? { + let parts = gregorian(timeZone: timeZone).dateComponents([.year, .month, .day], from: date) + guard let year = parts.year, let month = parts.month, let day = parts.day, + (1899...2101).contains(year), let solar = try? SolarDay.fromYmd(year, month, day) else { return nil } + if locale.language.languageCode?.identifier == "zh" { + let lunar = solar.getLunarDay() + let title = lunar.lunarMonth.getName() + lunar.getName() + return locale.language.script?.identifier == "Hant" + ? title.applyingTransform(StringTransform("Hans-Hant"), reverse: false) ?? title : title + } + // 系统为其他语言提供农历月份名称和本地化数字;中文沿用 Tyme 的“初一、廿一”等写法。 + let formatter = DateFormatter() + formatter.locale = locale + formatter.calendar = Calendar(identifier: .chinese) + formatter.timeZone = timeZone + formatter.setLocalizedDateFormatFromTemplate("MMMMd") + return formatter.string(from: date) + } + /// 保留同月的公历选择,并用当前时区重建 Date;不能复用旧时区的绝对时间。 static func selection(_ previous: CalendarDay?, in month: CalendarMonth, timeZone: TimeZone = .autoupdatingCurrent) -> CalendarDay? { @@ -157,6 +177,7 @@ enum CalendarEngine { /// 年份覆盖来自固定版本的数据本身;未收录年份不能把 nil 解读为“无需调休”。 static func hasHolidayData(year: Int) -> Bool { holidayYears.contains(year) } + static var latestHolidayYear: Int? { holidayYears.max() } private static let holidayYears: Set = { let data = Array(LegalHoliday.DATA.utf8) return Set(stride(from: 0, to: data.count, by: 13).compactMap { index in diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarHolidayPlan.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarHolidayPlan.swift new file mode 100644 index 0000000..57d311d --- /dev/null +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarHolidayPlan.swift @@ -0,0 +1,87 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Foundation +@preconcurrency import Tyme4Swift + +struct CalendarLeaveOption: Identifiable, Equatable { + var id: Int { leaveDates.count } + let start: Date + let end: Date + let leaveDates: [Date] + let totalDays: Int +} + +struct CalendarHolidayPlan { + let name: String + let start: Date + let end: Date + let daysUntil: Int + let totalDays: Int + let leaveOptions: [CalendarLeaveOption] +} + +/// 请假建议只使用已公布的工作/休息日。未知年份截断计算,不把周末当作已确认无需补班。 +struct CalendarWorkday { + let date: Date + let isWork: Bool + let holidayName: String? +} + +extension CalendarHolidayPlan { + static func make(days: [CalendarWorkday], today: Date, calendar: Calendar) -> Self? { + let today = calendar.startOfDay(for: today) + guard let seed = days.firstIndex(where: { $0.date >= today && !$0.isWork && $0.holidayName != nil }) else { return nil } + var first = seed + var last = seed + while first > 0 && !days[first - 1].isWork { first -= 1 } + while last + 1 < days.count && !days[last + 1].isWork { last += 1 } + var options: [Int: CalendarLeaveOption] = [:] + for before in 0...3 { + for after in 0...(3 - before) where before + after > 0 { + var start = first + var end = last + var used = 0 + while start > 0 && used + (days[start - 1].isWork ? 1 : 0) <= before { + start -= 1 + if days[start].isWork { used += 1 } + } + used = 0 + while end + 1 < days.count && used + (days[end + 1].isWork ? 1 : 0) <= after { + end += 1 + if days[end].isWork { used += 1 } + } + let leave = days[start...end].filter(\.isWork).map(\.date) + guard !leave.isEmpty, leave.allSatisfy({ $0 >= today }) else { continue } + let total = end - start + 1 + if options[leave.count].map({ $0.totalDays < total }) ?? true { + options[leave.count] = .init(start: days[start].date, end: days[end].date, leaveDates: leave, totalDays: total) + } + } + } + return .init(name: days[seed].holidayName ?? "", start: days[first].date, end: days[last].date, + daysUntil: max(0, calendar.dateComponents([.day], from: today, to: days[first].date).day ?? 0), + totalDays: last - first + 1, leaveOptions: options.values.sorted { $0.id < $1.id }) + } +} + +extension CalendarEngine { + static func holidayPlan(from date: Date, timeZone: TimeZone = .autoupdatingCurrent) -> CalendarHolidayPlan? { + let calendar = gregorian(timeZone: timeZone) + let today = calendar.startOfDay(for: date) + var days: [CalendarWorkday] = [] + for offset in -21...370 { + guard let date = calendar.date(byAdding: .day, value: offset, to: today) else { continue } + let parts = calendar.dateComponents([.year, .month, .day, .weekday], from: date) + guard let year = parts.year, let month = parts.month, let day = parts.day, let weekday = parts.weekday else { continue } + guard hasHolidayData(year: year) else { + if offset >= 0 { break } + continue + } + guard let solar = try? SolarDay.fromYmd(year, month, day) else { continue } + let holiday = solar.legalHoliday + days.append(.init(date: date, isWork: holiday?.isWork ?? (weekday != 1 && weekday != 7), holidayName: holiday?.name)) + } + return CalendarHolidayPlan.make(days: days, today: today, calendar: calendar) + } +} diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarMenuBarController.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarMenuBarController.swift index 1750e0f..d7cc227 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarMenuBarController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarMenuBarController.swift @@ -5,13 +5,15 @@ import AppKit import Localization import SwiftUI -/// 独立日历入口:只按分钟刷新日期文本,关闭后移除定时器,不参与 MetricsHub 的秒级采样。 +/// 独立日历入口:按分钟刷新,关闭或休眠时停止。 @MainActor final class CalendarMenuBarController: NSObject { private let model: AppModel private var item: NSStatusItem? private var panel: StatusPanel? private var timer: Timer? + private var hoverView: CalendarHoverView? + private var hoverTask: Task? private var observers: [(NotificationCenter, NSObjectProtocol)] = [] init(model: AppModel) { @@ -20,6 +22,11 @@ final class CalendarMenuBarController: NSObject { } func start() { + model.openCalendar = { [weak self] in self?.show() } + model.restoreCalendarEntry = { [weak self] in + self?.item?.isVisible = true + self?.update() + } update() observeSettings() for (center, name) in [ @@ -28,13 +35,25 @@ final class CalendarMenuBarController: NSObject { (NSWorkspace.shared.notificationCenter, NSWorkspace.didWakeNotification), ] { let token = center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in - MainActor.assumeIsolated { self?.refreshTitle() } + MainActor.assumeIsolated { self?.update() } } observers.append((center, token)) } + let center = NSWorkspace.shared.notificationCenter + observers.append((center, center.addObserver(forName: NSWorkspace.willSleepNotification, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { + self?.timer?.invalidate() + self?.timer = nil + self?.hoverTask?.cancel() + self?.dismiss() + } + })) } func stop() { + hoverTask?.cancel() + hoverView?.removeFromSuperview() + hoverView = nil timer?.invalidate() timer = nil for (center, token) in observers { center.removeObserver(token) } @@ -47,6 +66,7 @@ final class CalendarMenuBarController: NSObject { private func observeSettings() { withObservationTracking { _ = model.settings.calendarEnabled + _ = model.settings.calendarPreferences _ = model.settings.calendarFeatures _ = model.settings.calendarFirstWeekday _ = model.settings.language @@ -61,6 +81,9 @@ final class CalendarMenuBarController: NSObject { func update() { guard model.settings.calendarEnabled else { + hoverTask?.cancel() + hoverView?.removeFromSuperview() + hoverView = nil dismiss() if let item { NSStatusBar.system.removeStatusItem(item) } item = nil @@ -78,28 +101,59 @@ final class CalendarMenuBarController: NSObject { item.button?.imagePosition = .imageLeading item.button?.font = .monospacedDigitSystemFont(ofSize: 12, weight: .regular) self.item = item + } + if timer == nil { timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in MainActor.assumeIsolated { self?.refreshTitle() } } timer?.tolerance = 2 } + if model.settings.calendarPreferences.openOnHover, let button = item?.button { + if hoverView == nil { + let view = CalendarHoverView(frame: button.bounds) + view.autoresizingMask = [.width, .height] + view.entered = { [weak self] in self?.scheduleHover() } + view.exited = { [weak self] in self?.hoverTask?.cancel() } + button.addSubview(view) + hoverView = view + } + } else { + hoverTask?.cancel() + hoverView?.removeFromSuperview() + hoverView = nil + } refreshTitle() panel?.refreshHeight() } private func refreshTitle() { guard let button = item?.button else { return } + let preferences = model.settings.calendarPreferences + let title = preferences.title(at: Date(), locale: L10n.locale) + button.title = title.isEmpty ? "" : " " + title + button.image = NSImage(systemSymbolName: "calendar", accessibilityDescription: tr("日历")) let formatter = DateFormatter() formatter.locale = L10n.locale formatter.calendar = CalendarEngine.gregorian() formatter.timeZone = .autoupdatingCurrent - formatter.setLocalizedDateFormatFromTemplate(model.settings.calendarFeatures.contains(.weekdays) ? "MdEEE" : "Md") - button.title = " " + formatter.string(from: Date()) formatter.dateStyle = .full button.toolTip = formatter.string(from: Date()) + button.setAccessibilityLabel(tr("日历") + " · " + formatter.string(from: Date())) + } + + private func scheduleHover() { + hoverTask?.cancel() + guard panel?.isVisible != true else { return } + hoverTask = Task { @MainActor [weak self] in + // 略过鼠标经过菜单栏时的瞬时进入;离开图标立即取消。 + try? await Task.sleep(for: .milliseconds(300)) + guard !Task.isCancelled, let self, self.panel?.isVisible != true else { return } + self.show() + } } @objc private func clicked(_ sender: NSStatusBarButton) { + hoverTask?.cancel() if NSApp.currentEvent?.type == .rightMouseUp, let item { let menu = NSMenu() menu.addItem(withTitle: tr("日历设置…"), action: #selector(openSettings), keyEquivalent: "").target = self @@ -113,7 +167,7 @@ final class CalendarMenuBarController: NSObject { } func show() { - guard let button = item?.button, let window = button.window else { return } + guard model.settings.calendarEnabled else { return } if panel == nil { let model = self.model let panel = StatusPanel(width: CalendarPopover.width, minHeight: 380, @@ -123,8 +177,14 @@ final class CalendarMenuBarController: NSObject { self.panel = panel } refreshTitle() - let anchor = window.convertToScreen(button.convert(button.bounds, to: nil)) - panel?.toggle(below: anchor, on: window.screen) + if let button = item?.button, let window = button.window, item?.isVisible == true { + let anchor = window.convertToScreen(button.convert(button.bounds, to: nil)) + panel?.toggle(below: anchor, on: window.screen) + } else if let screen = NSApp.keyWindow?.screen ?? NSScreen.main { + // 菜单栏入口隐藏时,设置页仍可打开日历。 + let visible = screen.visibleFrame + panel?.toggle(below: NSRect(x: visible.midX, y: visible.maxY, width: 1, height: 1), on: screen) + } } func dismiss() { panel?.dismiss() } @@ -134,3 +194,17 @@ final class CalendarMenuBarController: NSObject { model.openMainWindow(.settingsMenuBar) } } + +@MainActor +private final class CalendarHoverView: NSView { + var entered: () -> Void = {} + var exited: () -> Void = {} + override func hitTest(_ point: NSPoint) -> NSView? { nil } + override func updateTrackingAreas() { + super.updateTrackingAreas() + trackingAreas.forEach(removeTrackingArea) + addTrackingArea(NSTrackingArea(rect: .zero, options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], owner: self)) + } + override func mouseEntered(with event: NSEvent) { entered() } + override func mouseExited(with event: NSEvent) { exited() } +} diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPopover.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPopover.swift index b4aa1a3..160011c 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPopover.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPopover.swift @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import Foundation +import EventKit import Localization import SwiftUI @@ -18,6 +19,7 @@ struct CalendarPopover: View { @State private var lastTodayID: String? @State private var showsDayDetails: Bool @State private var almanac: CalendarAlmanac? + @State private var holidayPlan: CalendarHolidayPlan? private let referenceDate: Date? init(referenceDate: Date? = nil, showsDayDetails: Bool = false) { @@ -31,6 +33,7 @@ struct CalendarPopover: View { _almanac = State(initialValue: showsDayDetails ? today.flatMap { CalendarEngine.almanac(for: $0) } : nil) _lastTodayID = State(initialValue: today?.id) _days = State(initialValue: CalendarEngine.month(year: month.year, month: month.month, firstWeekday: 2)) + _holidayPlan = State(initialValue: CalendarEngine.holidayPlan(from: referenceDate ?? Date())) } var body: some View { @@ -45,10 +48,20 @@ struct CalendarPopover: View { PageScroll { VStack(spacing: 16) { if showsDayDetails, let selected, let almanac { + if model.settings.calendarPreferences.showEvents || model.settings.calendarPreferences.showReminders { + CalendarAgendaView(day: selected) + } CalendarAlmanacView(day: selected, almanac: almanac, features: model.settings.calendarFeatures) } else { calendarGrid(today: today) } + if !showsDayDetails, let selected, model.settings.calendarPreferences.showEvents || model.settings.calendarPreferences.showReminders { + CalendarAgendaView(day: selected) + } + if !showsDayDetails, model.settings.calendarFeatures.contains(.holidays), + model.settings.calendarPreferences.showHolidayOverview, let holidayPlan { + CalendarHolidayPlanView(plan: holidayPlan) + } if model.settings.calendarFeatures.contains(.holidays), !CalendarEngine.hasHolidayData(year: month.year) { Text(tr("该年份暂无中国法定假日与调休数据")) .font(.caption) @@ -67,22 +80,41 @@ struct CalendarPopover: View { month = CalendarMonth(year: today.year, month: today.month) } lastTodayID = newID + holidayPlan = CalendarEngine.holidayPlan(from: referenceDate ?? context.date) } } - .frame(width: Self.width) + .frame(width: isSnapshot ? Self.width : nil) + .frame(maxWidth: isSnapshot ? nil : .infinity) .frame(maxHeight: isSnapshot ? nil : .infinity, alignment: .top) .fixedSize(horizontal: false, vertical: isSnapshot) .background(DS.Palette.background, in: RoundedRectangle(cornerRadius: DS.Radius.xl)) .appLanguageEnvironment() .overlay { RoundedRectangle(cornerRadius: DS.Radius.xl).strokeBorder(DS.Palette.border) } - .onAppear { reload() } + .onAppear { + reload() + if !isSnapshot { model.calendarAgenda.refreshAuthorization() } + } + .task(id: agendaQuery) { + guard !isSnapshot else { return } + await model.calendarAgenda.load(agendaQuery) + } + .onDisappear { if !isSnapshot { model.calendarAgenda.clear() } } + .onReceive(NotificationCenter.default.publisher(for: .EKEventStoreChanged)) { _ in + if !isSnapshot { model.calendarAgenda.refreshAuthorization() } + } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + if !isSnapshot { model.calendarAgenda.refreshAuthorization() } + } .onChange(of: month) { _, newMonth in yearInput = String(newMonth.year) reload() } .onChange(of: selected?.id) { _, _ in refreshAlmanac() } .onChange(of: model.settings.calendarFirstWeekday) { _, _ in reload() } - .onReceive(NotificationCenter.default.publisher(for: .NSSystemTimeZoneDidChange)) { _ in reload() } + .onReceive(NotificationCenter.default.publisher(for: .NSSystemTimeZoneDidChange)) { _ in + reload() + holidayPlan = CalendarEngine.holidayPlan(from: referenceDate ?? Date()) + } .onReceive(NotificationCenter.default.publisher(for: .NSCalendarDayChanged)) { _ in guard referenceDate == nil, let today = CalendarEngine.today() else { return } if selected?.id == lastTodayID { goToToday(today) } @@ -90,6 +122,18 @@ struct CalendarPopover: View { } } + private var agendaQuery: CalendarAgendaQuery { + let calendar = CalendarEngine.gregorian() + let start = calendar.startOfDay(for: days.first?.date ?? referenceDate ?? Date()) + let last = calendar.startOfDay(for: days.last?.date ?? start) + let end = calendar.date(byAdding: .day, value: 1, to: last) ?? last + let settings = model.settings + return .init(start: start, end: end, + events: settings.calendarPreferences.showEvents, reminders: settings.calendarPreferences.showReminders, + eventIDs: settings.calendarEventSourceIDs, reminderIDs: settings.calendarReminderSourceIDs, + revision: model.calendarAgenda.revision) + } + private func header(today: CalendarDay?) -> some View { HStack(spacing: 10) { Image(systemName: "calendar").foregroundStyle(Color.accentColor) @@ -139,7 +183,9 @@ struct CalendarPopover: View { LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 4), count: 7), spacing: 5) { ForEach(days) { day in CalendarDayCell(day: day, features: features, isCurrentMonth: day.month == month.month, - isToday: day.id == today?.id, isSelected: day.id == selected?.id) { + isToday: day.id == today?.id, isSelected: day.id == selected?.id, + preferences: model.settings.calendarPreferences, + hasAgenda: model.calendarAgenda.items.contains { $0.occurs(on: day.date, calendar: CalendarEngine.gregorian()) }) { selected = day if day.month != month.month, CalendarMonth.years.contains(day.year) { month = CalendarMonth(year: day.year, month: day.month) @@ -218,6 +264,8 @@ private struct CalendarDayCell: View { let isCurrentMonth: Bool let isToday: Bool let isSelected: Bool + let preferences: CalendarPreferences + let hasAgenda: Bool let action: () -> Void private var holiday: CalendarDay.Holiday? { features.contains(.holidays) ? day.holiday : nil } @@ -229,7 +277,7 @@ private struct CalendarDayCell: View { VStack(spacing: 3) { Text(String(day.day)).font(.system(size: 21, weight: isToday ? .semibold : .regular, design: .rounded)) Text(tr(day.subtitle(features: features))) - .font(.system(size: 10)) + .font(.system(size: preferences.largeLunarText ? 13 : 10, weight: preferences.strongerLunarText ? .semibold : .regular)) .lineLimit(1) .minimumScaleFactor(0.75) if features.contains(.ganzhi) { @@ -238,7 +286,7 @@ private struct CalendarDayCell: View { } .foregroundStyle(foreground) .frame(maxWidth: .infinity) - .frame(height: 66) + .frame(height: preferences.largeLunarText ? 78 : 70) .background(isSelected ? Color.accentColor : holiday != nil ? foreground.opacity(0.05) : .clear, in: RoundedRectangle(cornerRadius: 10)) .overlay { @@ -256,12 +304,18 @@ private struct CalendarDayCell: View { Circle().fill(isSelected ? .white : Color.accentColor).frame(width: 5, height: 5).padding(5) } } + .overlay(alignment: .bottom) { + if hasAgenda { + Circle().fill(isSelected ? .white : Color.accentColor).frame(width: 4, height: 4).padding(.bottom, 3) + } + } .opacity(isCurrentMonth ? 1 : 0.35) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityLabel([day.id, features.contains(.lunar) ? day.lunarSummary : "", tr(day.subtitle(features: features)), - holiday.map { tr($0.isWork ? "调休上班" : "放假") } ?? "", isToday ? tr("今天") : ""] + holiday.map { tr($0.isWork ? "调休上班" : "放假") } ?? "", isToday ? tr("今天") : "", + hasAgenda ? tr("有日程或提醒") : ""] .filter { !$0.isEmpty }.joined(separator: ", ")) .accessibilityAddTraits(isSelected ? .isSelected : []) } diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPreferences.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPreferences.swift new file mode 100644 index 0000000..1fa21ff --- /dev/null +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPreferences.swift @@ -0,0 +1,89 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Foundation +import Localization + +/// 可跨设备同步的日历呈现偏好;本机日历列表标识另存,不进入设置备份。 +public struct CalendarPreferences: Codable, Equatable, Sendable { + public enum Display: String, Codable, CaseIterable, Identifiable, Sendable { + case standard, dateLunar, lunar, custom + public var id: String { rawValue } + var title: String { + switch self { + case .standard: tr("日期与星期") + case .dateLunar: tr("日期与农历") + case .lunar: tr("农历") + case .custom: tr("自定义") + } + } + } + + public var display: Display = .standard + public var dateFormat = "MM/dd" + public var largeLunarText = false + public var strongerLunarText = false + public var openOnHover = false + public var showHolidayOverview = true + public var showEvents = false + public var showReminders = false + + public init() {} + + private enum CodingKeys: String, CodingKey { + case display, dateFormat, largeLunarText, strongerLunarText, openOnHover + case showHolidayOverview, showEvents, showReminders + } + + public init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + let savedDisplay = try values.decodeIfPresent(String.self, forKey: .display) + // 旧的“仅日期”对应自定义月日;已移除的图标模式回到默认日期显示。 + display = savedDisplay == "compact" ? .custom : savedDisplay.flatMap(Display.init(rawValue:)) ?? .standard + dateFormat = String((try values.decodeIfPresent(String.self, forKey: .dateFormat) ?? "MM/dd").prefix(64)) + largeLunarText = try values.decodeIfPresent(Bool.self, forKey: .largeLunarText) ?? false + strongerLunarText = try values.decodeIfPresent(Bool.self, forKey: .strongerLunarText) ?? false + openOnHover = try values.decodeIfPresent(Bool.self, forKey: .openOnHover) ?? false + showHolidayOverview = try values.decodeIfPresent(Bool.self, forKey: .showHolidayOverview) ?? true + showEvents = try values.decodeIfPresent(Bool.self, forKey: .showEvents) ?? false + showReminders = try values.decodeIfPresent(Bool.self, forKey: .showReminders) ?? false + } + + /// 只接受年月日字段和分隔文字;避免误用周历年 Y、年内日 D 或时间字段后显示错误日期。 + var isDateFormatValid: Bool { + guard !dateFormat.isEmpty, dateFormat.count <= 64, !dateFormat.contains(where: \.isNewline) else { return false } + var quoted = false + var hasMonth = false + var hasDay = false + for character in dateFormat { + if character == "'" { quoted.toggle(); continue } + guard !quoted else { continue } + if character == "M" { hasMonth = true } + else if character == "d" { hasDay = true } + else if character.isASCII && character.isLetter && character != "y" { return false } + } + return !quoted && hasMonth && hasDay + } + + @MainActor + func title(at date: Date, locale: Locale, timeZone: TimeZone = .autoupdatingCurrent) -> String { + let formatter = DateFormatter() + formatter.locale = locale + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + formatter.calendar = calendar + formatter.timeZone = timeZone + switch display { + case .standard: formatter.setLocalizedDateFormatFromTemplate("MdEEE") + case .custom: + // 用户明确选择顺序,不能再用本地化模板把日月重新排回月日。 + formatter.dateFormat = isDateFormatValid ? dateFormat : "MM/dd" + case .dateLunar, .lunar: + formatter.setLocalizedDateFormatFromTemplate("Md") + let solar = formatter.string(from: date) + guard let lunar = CalendarEngine.lunarDateTitle(at: date, locale: locale, timeZone: timeZone) else { return solar } + return display == .lunar ? lunar : [solar, lunar].joined(separator: " · ") + } + return formatter.string(from: date) + } +} diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarSettings.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarSettings.swift index db1e50b..31ee7c4 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarSettings.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarSettings.swift @@ -1,6 +1,8 @@ // Copyright (C) 2026 ysicing // SPDX-License-Identifier: AGPL-3.0-or-later +import AppKit +import EventKit import Localization import SwiftUI @@ -28,42 +30,206 @@ struct CalendarOptionsButton: View { } } -private struct CalendarOptionsPopover: View { +struct CalendarOptionsPopover: View { @Environment(AppModel.self) private var model + @Environment(\.isSnapshot) private var isSnapshot var body: some View { @Bindable var settings = model.settings - ScrollView { - SettingsGroup(caption: tr("日历")) { - GroupRow(showsDivider: false) { - SettingRow(title: tr("每周开始于")) { - Picker(tr("每周开始于"), selection: $settings.calendarFirstWeekday) { - Text(tr("星期一")).tag(2) - Text(tr("星期日")).tag(1) + PageScroll { + VStack(spacing: DS.Space.s4) { + presentationOptions + SettingsGroup(caption: tr("日历")) { + GroupRow(showsDivider: false) { + SettingRow(title: tr("每周开始于")) { + Picker(tr("每周开始于"), selection: $settings.calendarFirstWeekday) { + Text(tr("星期一")).tag(2) + Text(tr("星期日")).tag(1) + } + .labelsHidden() + .fixedSize() + } + } + ForEach(CalendarFeature.allCases) { feature in + GroupRow { + SettingRow(title: feature.title, subtitle: feature == .ganzhi ? tr("控制月历格中的日干支;黄历详情始终显示年月日干支") : nil) { + DSToggle(isOn: Binding(get: { settings.calendarFeatures.contains(feature) }, set: { enabled in + if enabled { settings.calendarFeatures.insert(feature) } + else { settings.calendarFeatures.remove(feature) } + }), label: feature.title) + } } - .labelsHidden() - .fixedSize() } - } - ForEach(CalendarFeature.allCases) { feature in GroupRow { - SettingRow(title: feature.title, subtitle: feature == .ganzhi ? tr("控制月历格中的日干支;黄历详情始终显示年月日干支") : nil) { - DSToggle(isOn: Binding(get: { settings.calendarFeatures.contains(feature) }, set: { enabled in - if enabled { settings.calendarFeatures.insert(feature) } - else { settings.calendarFeatures.remove(feature) } - }), label: feature.title) + Text(tr("公历始终显示。藏历与回历显示在日期详情中;梅雨天按传统历法推算,并非天气预报。")) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + GroupRow { + SettingRow(title: tr("放大农历文字")) { + DSToggle(isOn: $settings.calendarPreferences.largeLunarText, label: tr("放大农历文字")) + } + } + GroupRow { + SettingRow(title: tr("增强农历对比度")) { + DSToggle(isOn: $settings.calendarPreferences.strongerLunarText, label: tr("增强农历对比度")) + } + } + GroupRow { + SettingRow(title: tr("假期倒计时与请假建议")) { + DSToggle(isOn: $settings.calendarPreferences.showHolidayOverview, label: tr("假期倒计时与请假建议")) } } } - GroupRow { - Text(tr("公历始终显示。藏历与回历显示在日期详情中;梅雨天按传统历法推算,并非天气预报。")) - .dsFont(.xs) - .foregroundStyle(DS.Palette.textSecondary) - .fixedSize(horizontal: false, vertical: true) + agendaOptions + } + .padding(.vertical, DS.Space.s4) + } + .frame(width: 460) + .frame(height: isSnapshot ? nil : 560) + .fixedSize(horizontal: false, vertical: isSnapshot) + .background(DS.Palette.background) + .appLanguageEnvironment() + .task(id: [settings.calendarPreferences.showEvents, settings.calendarPreferences.showReminders]) { + guard !isSnapshot else { return } + await model.calendarAgenda.refreshSources(events: settings.calendarPreferences.showEvents, + reminders: settings.calendarPreferences.showReminders) + } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + guard !isSnapshot else { return } + Task { await model.calendarAgenda.refreshSources(events: settings.calendarPreferences.showEvents, + reminders: settings.calendarPreferences.showReminders) } + } + } + + private var presentationOptions: some View { + @Bindable var settings = model.settings + return SettingsGroup(caption: tr("菜单栏")) { + GroupRow(showsDivider: false) { + SettingRow(title: tr("显示方式")) { + Picker(tr("显示方式"), selection: $settings.calendarPreferences.display) { + ForEach(CalendarPreferences.Display.allCases) { Text($0.title).tag($0) } + }.labelsHidden().fixedSize() } } - .padding(DS.Space.s4) + if settings.calendarPreferences.display == .custom { + GroupRow { + VStack(alignment: .leading, spacing: 6) { + TextField("yyyy-MM-dd", text: Binding(get: { settings.calendarPreferences.dateFormat }, + set: { settings.calendarPreferences.dateFormat = String($0.prefix(64)) })) + .textFieldStyle(.roundedBorder) + .accessibilityLabel(tr("日期格式")) + Text(tr("年:yyyy 或 yy;月:MM 或 M;日:dd 或 d。分隔符可自行填写。")) + .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + Text(tr("例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。")) + .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + if !settings.calendarPreferences.isDateFormatValid { + Text(tr("请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。")) + .font(.caption).foregroundStyle(.red).fixedSize(horizontal: false, vertical: true) + } + } + } + } + GroupRow { + let preview = settings.calendarPreferences.title(at: Date(), locale: L10n.locale) + HStack { + Text(tr("预览")).foregroundStyle(.secondary) + Spacer() + Image(systemName: "calendar") + Text(preview).font(.system(size: 12)).monospacedDigit().lineLimit(1) + } + } + GroupRow { + SettingRow(title: tr("悬停展开日历")) { + DSToggle(isOn: $settings.calendarPreferences.openOnHover, label: tr("悬停展开日历")) + } + } + GroupRow { + VStack(alignment: .leading, spacing: 8) { + ViewThatFits(in: .horizontal) { + HStack { recoveryButtons }.fixedSize() + VStack(alignment: .leading, spacing: 8) { recoveryButtons }.fixedSize() + } + Text(tr("按住 ⌘ 拖动菜单栏图标可调整位置。")) + .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + }.frame(maxWidth: .infinity, alignment: .leading) + } } - .frame(width: 420, height: 480) } + + @ViewBuilder private var recoveryButtons: some View { + Button(tr("打开日历")) { model.openCalendar() } + Button(tr("重新显示入口")) { model.restoreCalendarEntry() } + } + + private var agendaOptions: some View { + @Bindable var settings = model.settings + return SettingsGroup(caption: tr("日程与提醒事项")) { + GroupRow(showsDivider: false) { + SettingRow(title: tr("显示系统日程")) { + DSToggle(isOn: $settings.calendarPreferences.showEvents, label: tr("显示系统日程")) + } + } + if settings.calendarPreferences.showEvents { + GroupRow { agendaAccess(reminders: false) } + } + GroupRow { + SettingRow(title: tr("显示到期提醒")) { + DSToggle(isOn: $settings.calendarPreferences.showReminders, label: tr("显示到期提醒")) + } + } + if settings.calendarPreferences.showReminders { + GroupRow { agendaAccess(reminders: true) } + } + GroupRow { + Text(tr("仅在本机展示日程与到期提醒,不修改内容。")) + .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + }.frame(maxWidth: .infinity, alignment: .leading) + } + } + + @ViewBuilder private func agendaAccess(reminders: Bool) -> some View { + let agenda = model.calendarAgenda + let access = reminders ? agenda.remindersAccess : agenda.eventsAccess + let sources = reminders ? agenda.reminderSources : agenda.eventSources + if access == .fullAccess { + DisclosureGroup(tr("选择列表")) { + VStack(alignment: .leading, spacing: 8) { + if sources.isEmpty { Text(tr("暂无可用列表")).foregroundStyle(.secondary) } + ForEach(sources) { source in + Toggle(source.title, isOn: Binding(get: { + let ids = reminders ? model.settings.calendarReminderSourceIDs : model.settings.calendarEventSourceIDs + return ids?.contains(source.id) ?? true + }, set: { selected in + var ids = (reminders ? model.settings.calendarReminderSourceIDs : model.settings.calendarEventSourceIDs) + ?? Set(sources.map(\.id)) + if selected { ids.insert(source.id) } else { ids.remove(source.id) } + if reminders { model.settings.calendarReminderSourceIDs = ids } + else { model.settings.calendarEventSourceIDs = ids } + })) + .toggleStyle(.checkbox) + } + }.frame(maxWidth: .infinity, alignment: .leading).padding(.top, 6) + } + } else { + VStack(alignment: .leading, spacing: 6) { + Text(tr(access == .notDetermined || access == .writeOnly ? "需要授权后才能显示" : "访问未获允许,请在系统设置中开启")) + .font(.caption).foregroundStyle(.secondary) + if access == .notDetermined || access == .writeOnly { + Button(tr("允许访问")) { Task { await agenda.requestAccess(reminders: reminders) } } + .disabled(agenda.isRequesting) + } else { + Button(tr("打开系统设置")) { + let destination = reminders ? "Privacy_Reminders" : "Privacy_Calendars" + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?" + destination) { + NSWorkspace.shared.open(url) + } + } + } + if agenda.failed { Text(tr("读取失败,请重试")).font(.caption).foregroundStyle(.secondary) } + } + } + } + } diff --git a/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.en.md b/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.en.md index 89578a7..ed4ee34 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.en.md +++ b/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.en.md @@ -8,6 +8,8 @@ This policy explains what XStats processes on your Mac and which features contac CPU, GPU, memory, network, temperature, fan, and process metrics come from local system interfaces. Those metrics, their history, and process lists are processed on your Mac and are not uploaded to the XStats update service. Preferences are stored locally. +Calendar events and due reminders are off by default. After you explicitly grant access, XStats reads the visible month range from your selected lists through macOS calendar APIs while the panel is open. It displays the contents locally without creating, modifying, deleting, or uploading records, and does not include them in settings backups or widget summaries. Closing the panel clears its displayed list. List selections stay on this Mac. You can turn reading off in calendar settings or revoke access in System Settings. + 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 bba458a..a9632e5 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.zh-Hans.md +++ b/Packages/XStatsKit/Sources/XStatsUI/Resources/Legal/Privacy.zh-Hans.md @@ -8,6 +8,8 @@ CPU、GPU、内存、网络、温度、风扇、进程等监控数据来自本机系统接口,其指标、历史记录和进程列表仅在本机处理,不上传到 XStats 更新服务。设置保存在本机应用偏好中。 +日历中的系统日程与到期提醒默认关闭。你主动授权后,XStats 通过 macOS 日历接口在面板打开时读取所选列表的当前月历范围,仅在本机展示,不创建、修改或删除记录,不上传内容,也不将其写入设置备份或小组件摘要。关闭面板后清空展示列表;列表选择保存在本机,可在日历设置中关闭读取,也可在系统设置中撤销权限。 + “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/Snapshot/SnapshotRenderer.swift b/Packages/XStatsKit/Sources/XStatsUI/Snapshot/SnapshotRenderer.swift index b973289..e3f15d7 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Snapshot/SnapshotRenderer.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Snapshot/SnapshotRenderer.swift @@ -129,6 +129,7 @@ enum SnapshotRenderer { defer { defaults.removePersistentDomain(forName: name) } let settings = AppSettings(defaults: defaults) settings.calendarEnabled = true + settings.language = L10n.language let model = AppModel(settings: settings, historyURL: nil, aiUsageProviders: []) guard let september = CalendarEngine.day(year: 2026, month: 9, day: 23)?.date, let summer = CalendarEngine.day(year: 2024, month: 6, day: 11)?.date else { return } @@ -138,6 +139,22 @@ enum SnapshotRenderer { settings.calendarFeatures = CalendarFeature.defaults write(CalendarPopover(referenceDate: september), model: model, appearance: appearance, to: outputDirectory.appendingPathComponent("calendar-\(suffix).png")) + settings.calendarPreferences.largeLunarText = true + settings.calendarPreferences.strongerLunarText = true + settings.calendarPreferences.showEvents = true + settings.calendarPreferences.showReminders = true + write(CalendarPopover(referenceDate: september), model: model, appearance: appearance, + to: outputDirectory.appendingPathComponent("calendar-agenda-\(suffix).png")) + write(CalendarOptionsPopover(), model: model, appearance: appearance, + to: outputDirectory.appendingPathComponent("calendar-options-\(suffix).png")) + settings.calendarPreferences.display = .custom + settings.calendarPreferences.dateFormat = "yyyy-MM-dd" + write(CalendarOptionsPopover(), model: model, appearance: appearance, + to: outputDirectory.appendingPathComponent("calendar-custom-\(suffix).png")) + settings.calendarPreferences.display = .dateLunar + write(CalendarOptionsPopover(), model: model, appearance: appearance, + to: outputDirectory.appendingPathComponent("calendar-lunar-\(suffix).png")) + settings.calendarPreferences = CalendarPreferences() write(CalendarPopover(referenceDate: september, showsDayDetails: true), model: model, appearance: appearance, to: outputDirectory.appendingPathComponent("calendar-almanac-\(suffix).png")) settings.calendarFeatures = Set(CalendarFeature.allCases) diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift b/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift index 03563d3..9a185d5 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift @@ -12,6 +12,7 @@ import Updates public final class AppModel { public let settings: AppSettings let rest: RestController + let calendarAgenda = CalendarAgendaController() public let aiUsage: AIUsageController @ObservationIgnored let sub2apiDrafts = Dictionary(uniqueKeysWithValues: AIProviderID.allCases.map { ($0, Sub2APIDraft()) }) @@ -62,6 +63,8 @@ public final class AppModel { public private(set) var launchAtLoginError: String? @ObservationIgnored var openSettings: () -> Void = {} + @ObservationIgnored var openCalendar: () -> Void = {} + @ObservationIgnored var restoreCalendarEntry: () -> Void = {} @ObservationIgnored var openAIUsageSettings: () -> Void = {} @ObservationIgnored var openMainWindow: (PanelTab?) -> Void = { _ in } @ObservationIgnored var openEgressWindow: () -> Void = {} diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift b/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift index d0c8fca..75be414 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift @@ -445,6 +445,16 @@ public final class AppSettings { public var calendarEnabled: Bool { didSet { defaults.set(calendarEnabled, forKey: Keys.calendarEnabled) } } + public var calendarPreferences: CalendarPreferences { + didSet { defaults.set(try? JSONEncoder().encode(calendarPreferences), forKey: Keys.calendarPreferences) } + } + /// nil 表示全部列表,空集合表示明确不显示任何列表;列表 ID 仅在本机有意义。 + var calendarEventSourceIDs: Set? { + didSet { defaults.set(calendarEventSourceIDs.map { Array($0).sorted() }, forKey: "calendarEventSourceIDs") } + } + var calendarReminderSourceIDs: Set? { + didSet { defaults.set(calendarReminderSourceIDs.map { Array($0).sorted() }, forKey: "calendarReminderSourceIDs") } + } /// 休息提醒完全由本机计时;旧用户默认关闭。 public var restEnabled: Bool { didSet { @@ -699,6 +709,10 @@ public final class AppSettings { 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) + calendarPreferences = defaults.data(forKey: Keys.calendarPreferences) + .flatMap { try? JSONDecoder().decode(CalendarPreferences.self, from: $0) } ?? CalendarPreferences() + calendarEventSourceIDs = defaults.stringArray(forKey: "calendarEventSourceIDs").map { Set($0) } + calendarReminderSourceIDs = defaults.stringArray(forKey: "calendarReminderSourceIDs").map { Set($0) } calendarFeatures = defaults.stringArray(forKey: Keys.calendarFeatures) .map(CalendarFeature.restored(from:)) ?? CalendarFeature.defaults calendarFirstWeekday = defaults.integer(forKey: Keys.calendarFirstWeekday) == 1 ? 1 : 2 @@ -819,6 +833,7 @@ public final class AppSettings { static let restSound = "restSound" static let restHUDStyle = "restHUDStyle" static let calendarEnabled = "calendarEnabled" + static let calendarPreferences = "calendarPreferences" static let calendarFeatures = "calendarFeatures" static let calendarFirstWeekday = "calendarFirstWeekday" static let menuBarItems = "menuBarItems" diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/SettingsDocument.swift b/Packages/XStatsKit/Sources/XStatsUI/State/SettingsDocument.swift index e5f28b7..45e6102 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/SettingsDocument.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/SettingsDocument.swift @@ -17,6 +17,7 @@ import WebDAVSync public struct SettingsDocument: Codable, Equatable, Sendable { public var schema: Int? = 1 public var calendarEnabled: Bool? + public var calendarPreferences: CalendarPreferences? public var restEnabled: Bool? public var restWorkMinutes: Int? public var restBreakMinutes: Int? @@ -66,6 +67,7 @@ extension AppSettings { public func exportDocument() -> SettingsDocument { var doc = SettingsDocument() doc.calendarEnabled = calendarEnabled + doc.calendarPreferences = calendarPreferences doc.restEnabled = restEnabled doc.restWorkMinutes = restWorkMinutes doc.restBreakMinutes = restBreakMinutes @@ -120,6 +122,7 @@ extension AppSettings { } assign(\.calendarEnabled, doc.calendarEnabled) + assign(\.calendarPreferences, doc.calendarPreferences) assign(\.restEnabled, doc.restEnabled) assign(\.restWorkMinutes, option(doc.restWorkMinutes, in: Self.restWorkOptions)) assign(\.restBreakMinutes, option(doc.restBreakMinutes, in: Self.restBreakOptions)) diff --git a/Packages/XStatsKit/Tests/XStatsUITests/CalendarEnhancementTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/CalendarEnhancementTests.swift new file mode 100644 index 0000000..ab752a6 --- /dev/null +++ b/Packages/XStatsKit/Tests/XStatsUITests/CalendarEnhancementTests.swift @@ -0,0 +1,170 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Foundation +import Testing +@testable import XStatsUI + +@MainActor +struct CalendarEnhancementTests { + let zone = TimeZone(identifier: "Asia/Shanghai")! + + @Test func menuBarPresetsShowWeekdayAndFullLunarDate() throws { + var preferences = CalendarPreferences() + let date = try #require(ISO8601DateFormatter().date(from: "2026-09-28T16:30:00Z")) + #expect(preferences.title(at: date, locale: Locale(identifier: "en_US"), timeZone: zone).contains("Tue")) + let locale = Locale(identifier: "zh_Hans_CN") + preferences.display = .lunar + #expect(preferences.title(at: date, locale: locale, timeZone: zone) == "八月十九") + #expect(preferences.title(at: date, locale: locale, timeZone: TimeZone(secondsFromGMT: 0)!) == "八月十八") + preferences.display = .dateLunar + let combined = preferences.title(at: date, locale: locale, timeZone: zone) + #expect(combined.contains("29") && combined.contains("八月十九")) + } + + @Test func lunarTitlePreservesMonthStartsAndLeapMonths() throws { + let leap = try #require(ISO8601DateFormatter().date(from: "2025-07-25T04:00:00Z")) + #expect(CalendarEngine.lunarDateTitle(at: leap, locale: Locale(identifier: "zh_Hans_CN"), timeZone: zone) == "闰六月初一") + #expect(CalendarEngine.lunarDateTitle(at: leap, locale: Locale(identifier: "zh_Hant_TW"), timeZone: zone) == "閏六月初一") + let newYear = try #require(ISO8601DateFormatter().date(from: "2026-02-17T04:00:00Z")) + #expect(CalendarEngine.lunarDateTitle(at: newYear, locale: Locale(identifier: "zh_Hans_CN"), timeZone: zone) == "正月初一") + } + + @Test(arguments: ["en_US", "en_GB", "zh_Hans_CN"]) + func customDateKeepsUserOrderAndSeparators(localeID: String) throws { + var preferences = CalendarPreferences() + preferences.display = .custom + let date = try #require(ISO8601DateFormatter().date(from: "2026-09-28T04:00:00Z")) + let locale = Locale(identifier: localeID) + for (format, expected) in [("yyyy-MM-dd", "2026-09-28"), ("M月d日", "9月28日"), + ("dd/MM", "28/09"), ("d.M.yy", "28.9.26")] { + preferences.dateFormat = format + #expect(preferences.isDateFormatValid) + #expect(preferences.title(at: date, locale: locale, timeZone: zone) == expected) + } + } + + @Test(arguments: ["", "yyyy", "MM", "YYYY-MM-dd", "MM-DD", "HH:mm:ss", "MM/dd '", "MM\ndd"]) + func invalidCustomDatesUseReadableFallback(format: String) throws { + var preferences = CalendarPreferences() + preferences.display = .custom + preferences.dateFormat = format + #expect(!preferences.isDateFormatValid) + let date = try #require(ISO8601DateFormatter().date(from: "2026-09-28T04:00:00Z")) + #expect(preferences.title(at: date, locale: Locale(identifier: "en_US"), timeZone: zone) == "09/28") + } + + @Test func retiredDisplayModesDecodeWithoutDiscardingOtherPreferences() throws { + let icon = try JSONDecoder().decode(CalendarPreferences.self, + from: Data(#"{"display":"dayNumber","showEvents":true}"#.utf8)) + #expect(icon.display == .standard) + #expect(icon.showEvents) + let compact = try JSONDecoder().decode(CalendarPreferences.self, from: Data(#"{"display":"compact"}"#.utf8)) + #expect(compact.display == .custom) + #expect(compact.dateFormat == "MM/dd") + let custom = try JSONDecoder().decode(CalendarPreferences.self, + from: Data(#"{"display":"custom","dateFormat":"d.M.yyyy","largeLunarText":true}"#.utf8)) + #expect(custom.display == .custom) + #expect(custom.dateFormat == "d.M.yyyy") + #expect(custom.largeLunarText) + } + + @Test func calendarPreferencesKeepSafeDefaultsForOlderDocuments() throws { + let old = try JSONDecoder().decode(CalendarPreferences.self, from: Data("{}".utf8)) + #expect(old == CalendarPreferences()) + #expect(!old.showEvents && !old.showReminders && !old.openOnHover) + let future = try JSONDecoder().decode(CalendarPreferences.self, from: Data(#"{"display":"future"}"#.utf8)) + #expect(future.display == .standard) + } + + @Test func displayPreferencesRoundTripWithoutSyncingLocalCalendarIDs() throws { + let name = "CalendarEnhancementTests.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + let settings = AppSettings(defaults: defaults) + settings.calendarPreferences.display = .custom + settings.calendarPreferences.dateFormat = "dd.MM.yyyy" + settings.calendarPreferences.largeLunarText = true + settings.calendarEventSourceIDs = ["local-private-id"] + settings.calendarReminderSourceIDs = [] + let restored = AppSettings(defaults: defaults) + #expect(restored.calendarPreferences == settings.calendarPreferences) + #expect(restored.calendarReminderSourceIDs == []) + let data = try JSONEncoder().encode(settings.exportDocument()) + #expect(!String(decoding: data, as: UTF8.self).contains("local-private-id")) + settings.apply(try JSONDecoder().decode(SettingsDocument.self, from: Data("{}".utf8))) + #expect(settings.calendarPreferences.display == .custom) + #expect(settings.calendarEventSourceIDs == ["local-private-id"]) + } + + @Test func allDayAndOvernightEventsExcludeTheirEndDay() throws { + let calendar = CalendarEngine.gregorian(timeZone: zone) + let start = try #require(calendar.date(from: DateComponents(year: 2026, month: 9, day: 28))) + let next = try #require(calendar.date(byAdding: .day, value: 1, to: start)) + let event = CalendarAgendaItem(id: "event", title: "", source: "", start: start, end: next, + isAllDay: true, isReminder: false) + #expect(event.occurs(on: start, calendar: calendar)) + #expect(!event.occurs(on: next, calendar: calendar)) + let overnight = CalendarAgendaItem(id: "night", title: "", source: "", start: next.addingTimeInterval(-1800), + end: next.addingTimeInterval(1800), isAllDay: false, isReminder: false) + #expect(overnight.occurs(on: start, calendar: calendar)) + #expect(overnight.occurs(on: next, calendar: calendar)) + let reminder = CalendarAgendaItem(id: "todo", title: "", source: "", start: next, end: next, + isAllDay: false, isReminder: true) + #expect(!reminder.occurs(on: start, calendar: calendar)) + #expect(reminder.occurs(on: next, calendar: calendar)) + } + + @Test func eventDayMatchingRespectsDSTInsteadOfFixed24Hours() throws { + let zone = try #require(TimeZone(identifier: "America/Los_Angeles")) + let calendar = CalendarEngine.gregorian(timeZone: zone) + let start = try #require(calendar.date(from: DateComponents(year: 2026, month: 3, day: 8))) + let next = try #require(calendar.date(byAdding: .day, value: 1, to: start)) + #expect(next.timeIntervalSince(start) == 23 * 3600) + let event = CalendarAgendaItem(id: "dst", title: "", source: "", start: next, end: next, + isAllDay: false, isReminder: true) + #expect(!event.occurs(on: start, calendar: calendar)) + } + + @Test func holidaySuggestionsBridgePublishedHolidaysUsingOnlyFutureWorkdays() throws { + let today = try #require(CalendarEngine.day(year: 2026, month: 9, day: 23, timeZone: zone)) + let plan = try #require(CalendarEngine.holidayPlan(from: today.date, timeZone: zone)) + #expect(plan.daysUntil == 2) + #expect(plan.totalDays == 3) + let bridge = try #require(plan.leaveOptions.first { $0.leaveDates.count == 3 }) + #expect(bridge.totalDays == 13) + let calendar = CalendarEngine.gregorian(timeZone: zone) + #expect(bridge.leaveDates.map { calendar.component(.day, from: $0) } == [28, 29, 30]) + let during = try #require(CalendarEngine.day(year: 2026, month: 9, day: 26, timeZone: zone)) + let ongoing = try #require(CalendarEngine.holidayPlan(from: during.date, timeZone: zone)) + #expect(ongoing.daysUntil == 0) + #expect(ongoing.leaveOptions.allSatisfy { $0.leaveDates.allSatisfy { $0 >= calendar.startOfDay(for: during.date) } }) + let unknown = try #require(CalendarEngine.day(year: 2027, month: 1, day: 1, timeZone: zone)) + #expect(CalendarEngine.holidayPlan(from: unknown.date, timeZone: zone) == nil) + } + + @Test func makeupSaturdayIsCountedAsLeaveRatherThanAFreeWeekend() throws { + let calendar = CalendarEngine.gregorian(timeZone: zone) + let today = try #require(calendar.date(from: DateComponents(year: 2026, month: 9, day: 1))) + let days = (0..<10).map { index in + CalendarWorkday(date: calendar.date(byAdding: .day, value: index, to: today)!, + isWork: ![2, 3, 5].contains(index), holidayName: index == 2 ? "holiday" : nil) + } + let plan = try #require(CalendarHolidayPlan.make(days: days, today: today, calendar: calendar)) + #expect(plan.totalDays == 2) + let option = try #require(plan.leaveOptions.first { $0.leaveDates.count == 1 }) + #expect(option.totalDays == 4) + #expect(option.leaveDates == [days[4].date]) + } + + @Test(arguments: [CGFloat(10), CGFloat(500), CGFloat(1000)]) + func panelRemainsInsideShortNarrowScreen(_ anchorX: CGFloat) { + let visible = CGRect(x: 0, y: 0, width: 400, height: 300) + let frame = StatusPanel.constrainedFrame(anchor: CGRect(x: anchorX, y: 320, width: 20, height: 20), visible: visible, + preferredSize: CGSize(width: 560, height: 700)) + #expect(frame.minX >= visible.minX) + #expect(frame.maxX <= visible.maxX) + #expect(frame.minY >= visible.minY) + #expect(frame.maxY <= visible.maxY) + } +} diff --git a/project.yml b/project.yml index d7c4a09..b2de8ad 100644 --- a/project.yml +++ b/project.yml @@ -52,9 +52,13 @@ targets: LSUIElement: true LSApplicationCategoryType: public.app-category.utilities NSHumanReadableCopyright: © 2026 XStats + NSCalendarsFullAccessUsageDescription: 在本机日历面板显示您选择的系统日程,不修改或上传日程内容。 + NSRemindersFullAccessUsageDescription: 在本机日历面板显示您选择的到期提醒,不修改或上传提醒内容。 entitlements: path: App/XStats.entitlements properties: + # EventKit 在 Hardened Runtime 下请求日历与提醒事项访问所需;仍由用户单独授权。 + com.apple.security.personal-information.calendars: true com.apple.security.application-groups: - group.work.12306.xstats settings: diff --git a/scripts/arm64_release_test.sh b/scripts/arm64_release_test.sh index a14e02f..584354a 100644 --- a/scripts/arm64_release_test.sh +++ b/scripts/arm64_release_test.sh @@ -19,6 +19,19 @@ fi WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT +# Hardened Runtime 在缺少日历 entitlement 时会直接拒绝 EventKit 授权,不显示系统弹窗。 +python3 - <<'PY' +import plistlib + +with open("App/XStats.entitlements", "rb") as source: + entitlements = plistlib.load(source) +assert entitlements.get("com.apple.security.personal-information.calendars") is True, "缺少 EventKit 日历访问 entitlement" +with open("App/Info.plist", "rb") as source: + info = plistlib.load(source) +for key in ("NSCalendarsFullAccessUsageDescription", "NSRemindersFullAccessUsageDescription"): + assert info.get(key), f"缺少权限说明:{key}" +PY + # appcast.py 只读它自己上一级目录的 CHANGELOG.md,没有路径参数。所以把脚本复制到 # 临时目录、在那里配一份 fixture:测试从此与仓库真实的版本历史无关——归档旧版本、 # 发布新版本都不会让它失效。 diff --git a/web/privacy.html b/web/privacy.html index f141cec..3139b7f 100644 --- a/web/privacy.html +++ b/web/privacy.html @@ -33,6 +33,7 @@

CPU、GPU、内存、网络、磁盘、温度、风扇、进程与电池等所有指标都在本机读取并显示,不会上传,也不会进入安装统计;应用没有广告或第三方分析 SDK。

    +
  • 日程与提醒事项默认关闭;主动授权后仅在日历面板打开时读取所选列表的月历范围,在本机只读展示,不上传、不修改记录,也不写入设置备份或小组件摘要。可在日历设置中关闭或在系统设置中撤销权限。
  • 公网 IP打开网络详情时向 Cloudflare 查询一次公网地址,10 分钟内不重复,可在设置中关闭
  • 归属地归属地、ASN、网络类型与纯净度评分由 cleanip.io 提供:这台 Mac 直接向它查询自己的公网 IP,只发送这个地址、不带其他信息,不经过 XStats 的服务器
  • 连接探测打开网络详情时每秒 ping 一次你选择的目标(后台每 10 秒),只在显示网络项时运行,可关闭