feat(calendar): 完善日历显示与日程提醒集成

This commit is contained in:
ysicing committed 2026-09-28 20:47:54 +08:00
1 parent ef2a8050ee
commit 0a26452d7e
39 files changed
+1481 -48

No files matched your search

+4
View File
@@ -38,7 +38,11 @@
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>LSUIElement</key>
<true/>
<key>NSCalendarsFullAccessUsageDescription</key>
<string>在本机日历面板显示您选择的系统日程,不修改或上传日程内容。</string>
<key>NSHumanReadableCopyright</key>
<string>© 2026 XStats</string>
<key>NSRemindersFullAccessUsageDescription</key>
<string>在本机日历面板显示您选择的到期提醒,不修改或上传提醒内容。</string>
</dict>
</plist>
+2
View File
@@ -8,5 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later -->
<array>
<string>group.work.12306.xstats</string>
</array>
<key>com.apple.security.personal-information.calendars</key>
<true/>
</dict>
</plist>
+4
View File
@@ -0,0 +1,4 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
"NSCalendarsFullAccessUsageDescription" = "يعرض أحداث التقويم المحددة محليًا دون تعديلها أو رفعها.";
"NSRemindersFullAccessUsageDescription" = "يعرض التذكيرات المستحقة المحددة محليًا دون تعديلها أو رفعها.";
+4
View File
@@ -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.";
+4
View File
@@ -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.";
+4
View File
@@ -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.";
+4
View File
@@ -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.";
+4
View File
@@ -0,0 +1,4 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
"NSCalendarsFullAccessUsageDescription" = "選択したカレンダーの予定をこの Mac に表示します。内容の変更やアップロードは行いません。";
"NSRemindersFullAccessUsageDescription" = "選択した期限付きリマインダーをこの Mac に表示します。内容の変更やアップロードは行いません。";
+4
View File
@@ -0,0 +1,4 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
"NSCalendarsFullAccessUsageDescription" = "선택한 캘린더 일정을 이 Mac에 표시하며 수정하거나 업로드하지 않습니다.";
"NSRemindersFullAccessUsageDescription" = "선택한 기한이 있는 미리 알림을 이 Mac에 표시하며 수정하거나 업로드하지 않습니다.";
+4
View File
@@ -0,0 +1,4 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
"NSCalendarsFullAccessUsageDescription" = "在本机日历面板显示您选择的系统日程,不修改或上传日程内容。";
"NSRemindersFullAccessUsageDescription" = "在本机日历面板显示您选择的到期提醒,不修改或上传提醒内容。";
+4
View File
@@ -0,0 +1,4 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
"NSCalendarsFullAccessUsageDescription" = "在本機日曆面板顯示您選擇的系統行程,不修改或上傳行程內容。";
"NSRemindersFullAccessUsageDescription" = "在本機日曆面板顯示您選擇的到期提醒,不修改或上傳提醒內容。";
+3
View File
@@ -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 管理,不能作为秒级提醒来源。
@@ -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
"""#
}
@@ -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 للسنة. يُعرض التنسيق غير الصالح مؤقتًا كشهر/يوم.
按住 ⌘ 拖动菜单栏图标可调整位置。 اضغط على ⌘ أثناء سحب أيقونة شريط القوائم لتغيير موضعها.
1 # Copyright (C) 2026 ysicing
1564 “{}”只剩 {} 可用。请检查不再需要的文件。
1565 安装标识
1566 复制安装标识
1567 日期与星期
1568 显示方式
1569 放大农历文字
1570 增强农历对比度
1571 假期倒计时与请假建议
1572 预览
1573 悬停展开日历
1574 打开日历
1575 重新显示入口
1576 日程与提醒事项
1577 显示系统日程
1578 显示到期提醒
1579 仅在本机展示日程与到期提醒,不修改内容。
1580 选择列表
1581 暂无可用列表
1582 需要授权后才能显示
1583 访问未获允许,请在系统设置中开启
1584 允许访问
1585 打开系统设置
1586 读取失败,请重试
1587 节假日数据
1588 最新数据年份:{}
1589 假日数据随应用更新;未收录年份不推断调休。
1590 查看更新
1591 日程尚未授权
1592 提醒事项尚未授权
1593 无标题
1594 全天
1595 更多内容请在系统日历或提醒事项中查看
1596 尚未选择任何列表
1597 这一天没有日程或到期提醒
1598 假期中
1599 还有 {} 天
1600 {} · 连休 {} 天
1601 请假建议
1602 请 {} 天,连休 {} 天
1603 请假日期:{}
1604 按双休及已公布的中国调休安排计算,实际请假以单位批准为准。
1605 有日程或提醒
1606 日期与农历
1607 日期格式
1608 年:yyyy 或 yy;月:MM 或 M;日:dd 或 d。分隔符可自行填写。
1609 例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。
1610 请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。
1611 按住 ⌘ 拖动菜单栏图标可调整位置。
@@ -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.
1 # Copyright (C) 2026 ysicing
1564 “{}”只剩 {} 可用。请检查不再需要的文件。
1565 安装标识
1566 复制安装标识
1567 日期与星期
1568 显示方式
1569 放大农历文字
1570 增强农历对比度
1571 假期倒计时与请假建议
1572 预览
1573 悬停展开日历
1574 打开日历
1575 重新显示入口
1576 日程与提醒事项
1577 显示系统日程
1578 显示到期提醒
1579 仅在本机展示日程与到期提醒,不修改内容。
1580 选择列表
1581 暂无可用列表
1582 需要授权后才能显示
1583 访问未获允许,请在系统设置中开启
1584 允许访问
1585 打开系统设置
1586 读取失败,请重试
1587 节假日数据
1588 最新数据年份:{}
1589 假日数据随应用更新;未收录年份不推断调休。
1590 查看更新
1591 日程尚未授权
1592 提醒事项尚未授权
1593 无标题
1594 全天
1595 更多内容请在系统日历或提醒事项中查看
1596 尚未选择任何列表
1597 这一天没有日程或到期提醒
1598 假期中
1599 还有 {} 天
1600 {} · 连休 {} 天
1601 请假建议
1602 请 {} 天,连休 {} 天
1603 请假日期:{}
1604 按双休及已公布的中国调休安排计算,实际请假以单位批准为准。
1605 有日程或提醒
1606 日期与农历
1607 日期格式
1608 年:yyyy 或 yy;月:MM 或 M;日:dd 或 d。分隔符可自行填写。
1609 例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。
1610 请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。
1611 按住 ⌘ 拖动菜单栏图标可调整位置。
@@ -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.
1 # Copyright (C) 2026 ysicing
1564 “{}”只剩 {} 可用。请检查不再需要的文件。
1565 安装标识
1566 复制安装标识
1567 日期与星期
1568 显示方式
1569 放大农历文字
1570 增强农历对比度
1571 假期倒计时与请假建议
1572 预览
1573 悬停展开日历
1574 打开日历
1575 重新显示入口
1576 日程与提醒事项
1577 显示系统日程
1578 显示到期提醒
1579 仅在本机展示日程与到期提醒,不修改内容。
1580 选择列表
1581 暂无可用列表
1582 需要授权后才能显示
1583 访问未获允许,请在系统设置中开启
1584 允许访问
1585 打开系统设置
1586 读取失败,请重试
1587 节假日数据
1588 最新数据年份:{}
1589 假日数据随应用更新;未收录年份不推断调休。
1590 查看更新
1591 日程尚未授权
1592 提醒事项尚未授权
1593 无标题
1594 全天
1595 更多内容请在系统日历或提醒事项中查看
1596 尚未选择任何列表
1597 这一天没有日程或到期提醒
1598 假期中
1599 还有 {} 天
1600 {} · 连休 {} 天
1601 请假建议
1602 请 {} 天,连休 {} 天
1603 请假日期:{}
1604 按双休及已公布的中国调休安排计算,实际请假以单位批准为准。
1605 有日程或提醒
1606 日期与农历
1607 日期格式
1608 年:yyyy 或 yy;月:MM 或 M;日:dd 或 d。分隔符可自行填写。
1609 例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。
1610 请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。
1611 按住 ⌘ 拖动菜单栏图标可调整位置。
@@ -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.
1 # Copyright (C) 2026 ysicing
1564 “{}”只剩 {} 可用。请检查不再需要的文件。
1565 安装标识
1566 复制安装标识
1567 日期与星期
1568 显示方式
1569 放大农历文字
1570 增强农历对比度
1571 假期倒计时与请假建议
1572 预览
1573 悬停展开日历
1574 打开日历
1575 重新显示入口
1576 日程与提醒事项
1577 显示系统日程
1578 显示到期提醒
1579 仅在本机展示日程与到期提醒,不修改内容。
1580 选择列表
1581 暂无可用列表
1582 需要授权后才能显示
1583 访问未获允许,请在系统设置中开启
1584 允许访问
1585 打开系统设置
1586 读取失败,请重试
1587 节假日数据
1588 最新数据年份:{}
1589 假日数据随应用更新;未收录年份不推断调休。
1590 查看更新
1591 日程尚未授权
1592 提醒事项尚未授权
1593 无标题
1594 全天
1595 更多内容请在系统日历或提醒事项中查看
1596 尚未选择任何列表
1597 这一天没有日程或到期提醒
1598 假期中
1599 还有 {} 天
1600 {} · 连休 {} 天
1601 请假建议
1602 请 {} 天,连休 {} 天
1603 请假日期:{}
1604 按双休及已公布的中国调休安排计算,实际请假以单位批准为准。
1605 有日程或提醒
1606 日期与农历
1607 日期格式
1608 年:yyyy 或 yy;月:MM 或 M;日:dd 或 d。分隔符可自行填写。
1609 例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。
1610 请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。
1611 按住 ⌘ 拖动菜单栏图标可调整位置。
@@ -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 を使ってください。無効な書式は一時的に月/日で表示します。
按住 ⌘ 拖动菜单栏图标可调整位置。 ⌘ を押しながらメニューバーのアイコンをドラッグすると移動できます。
1 # Copyright (C) 2026 ysicing
1564 “{}”只剩 {} 可用。请检查不再需要的文件。
1565 安装标识
1566 复制安装标识
1567 日期与星期
1568 显示方式
1569 放大农历文字
1570 增强农历对比度
1571 假期倒计时与请假建议
1572 预览
1573 悬停展开日历
1574 打开日历
1575 重新显示入口
1576 日程与提醒事项
1577 显示系统日程
1578 显示到期提醒
1579 仅在本机展示日程与到期提醒,不修改内容。
1580 选择列表
1581 暂无可用列表
1582 需要授权后才能显示
1583 访问未获允许,请在系统设置中开启
1584 允许访问
1585 打开系统设置
1586 读取失败,请重试
1587 节假日数据
1588 最新数据年份:{}
1589 假日数据随应用更新;未收录年份不推断调休。
1590 查看更新
1591 日程尚未授权
1592 提醒事项尚未授权
1593 无标题
1594 全天
1595 更多内容请在系统日历或提醒事项中查看
1596 尚未选择任何列表
1597 这一天没有日程或到期提醒
1598 假期中
1599 还有 {} 天
1600 {} · 连休 {} 天
1601 请假建议
1602 请 {} 天,连休 {} 天
1603 请假日期:{}
1604 按双休及已公布的中国调休安排计算,实际请假以单位批准为准。
1605 有日程或提醒
1606 日期与农历
1607 日期格式
1608 年:yyyy 或 yy;月:MM 或 M;日:dd 或 d。分隔符可自行填写。
1609 例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。
1610 请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。
1611 按住 ⌘ 拖动菜单栏图标可调整位置。
@@ -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를 사용하세요. 잘못된 형식은 임시로 월/일로 표시됩니다.
按住 ⌘ 拖动菜单栏图标可调整位置。 ⌘ 키를 누른 채 메뉴 막대 아이콘을 드래그하여 위치를 조정하세요.
1 # Copyright (C) 2026 ysicing
1564 “{}”只剩 {} 可用。请检查不再需要的文件。
1565 安装标识
1566 复制安装标识
1567 日期与星期
1568 显示方式
1569 放大农历文字
1570 增强农历对比度
1571 假期倒计时与请假建议
1572 预览
1573 悬停展开日历
1574 打开日历
1575 重新显示入口
1576 日程与提醒事项
1577 显示系统日程
1578 显示到期提醒
1579 仅在本机展示日程与到期提醒,不修改内容。
1580 选择列表
1581 暂无可用列表
1582 需要授权后才能显示
1583 访问未获允许,请在系统设置中开启
1584 允许访问
1585 打开系统设置
1586 读取失败,请重试
1587 节假日数据
1588 最新数据年份:{}
1589 假日数据随应用更新;未收录年份不推断调休。
1590 查看更新
1591 日程尚未授权
1592 提醒事项尚未授权
1593 无标题
1594 全天
1595 更多内容请在系统日历或提醒事项中查看
1596 尚未选择任何列表
1597 这一天没有日程或到期提醒
1598 假期中
1599 还有 {} 天
1600 {} · 连休 {} 天
1601 请假建议
1602 请 {} 天,连休 {} 天
1603 请假日期:{}
1604 按双休及已公布的中国调休安排计算,实际请假以单位批准为准。
1605 有日程或提醒
1606 日期与农历
1607 日期格式
1608 年:yyyy 或 yy;月:MM 或 M;日:dd 或 d。分隔符可自行填写。
1609 例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。
1610 请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。
1611 按住 ⌘ 拖动菜单栏图标可调整位置。
@@ -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。格式無效時暫以月/日顯示。
按住 ⌘ 拖动菜单栏图标可调整位置。 按住 ⌘ 拖移選單列圖示可調整位置。
1 # Copyright (c) 2026 GiantAccel, LLC
1565 “{}”只剩 {} 可用。请检查不再需要的文件。
1566 安装标识
1567 复制安装标识
1568 日期与星期
1569 显示方式
1570 放大农历文字
1571 增强农历对比度
1572 假期倒计时与请假建议
1573 预览
1574 悬停展开日历
1575 打开日历
1576 重新显示入口
1577 日程与提醒事项
1578 显示系统日程
1579 显示到期提醒
1580 仅在本机展示日程与到期提醒,不修改内容。
1581 选择列表
1582 暂无可用列表
1583 需要授权后才能显示
1584 访问未获允许,请在系统设置中开启
1585 允许访问
1586 打开系统设置
1587 读取失败,请重试
1588 节假日数据
1589 最新数据年份:{}
1590 假日数据随应用更新;未收录年份不推断调休。
1591 查看更新
1592 日程尚未授权
1593 提醒事项尚未授权
1594 无标题
1595 全天
1596 更多内容请在系统日历或提醒事项中查看
1597 尚未选择任何列表
1598 这一天没有日程或到期提醒
1599 假期中
1600 还有 {} 天
1601 {} · 连休 {} 天
1602 请假建议
1603 请 {} 天,连休 {} 天
1604 请假日期:{}
1605 按双休及已公布的中国调休安排计算,实际请假以单位批准为准。
1606 有日程或提醒
1607 日期与农历
1608 日期格式
1609 年:yyyy 或 yy;月:MM 或 M;日:dd 或 d。分隔符可自行填写。
1610 例如 yyyy-MM-dd、M月d日、dd/MM;年份可省略。
1611 请包含月份 M 和日期 d;年份使用 y。格式无效时暂以月/日显示。
1612 按住 ⌘ 拖动菜单栏图标可调整位置。
@@ -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: 事件
@@ -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<String>?
let reminderIDs: Set<String>?
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
}
}
@@ -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)
}
}
@@ -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<Int> = {
let data = Array(LegalHoliday.DATA.utf8)
return Set(stride(from: 0, to: data.count, by: 13).compactMap { index in
@@ -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)
}
}
@@ -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<Void, Never>?
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() }
}
@@ -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 : [])
}
@@ -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)
}
}
@@ -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) }
}
}
}
}
@@ -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.
@@ -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 密码和连接信息。
@@ -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)
@@ -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 = {}
@@ -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<String>? {
didSet { defaults.set(calendarEventSourceIDs.map { Array($0).sorted() }, forKey: "calendarEventSourceIDs") }
}
var calendarReminderSourceIDs: Set<String>? {
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"
@@ -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))
@@ -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)
}
}
+4
View File
@@ -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:
+13
View File
@@ -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:测试从此与仓库真实的版本历史无关——归档旧版本、
# 发布新版本都不会让它失效。
+1
View File
@@ -33,6 +33,7 @@
<p>CPU、GPU、内存、网络、磁盘、温度、风扇、进程与电池等所有指标都在本机读取并显示,不会上传,也不会进入安装统计;应用没有广告或第三方分析 SDK。</p>
</div>
<ul class="fact-list">
<li><b>日程与提醒事项</b><span>默认关闭;主动授权后仅在日历面板打开时读取所选列表的月历范围,在本机只读展示,不上传、不修改记录,也不写入设置备份或小组件摘要。可在日历设置中关闭或在系统设置中撤销权限。</span></li>
<li><b>公网 IP</b><span>打开网络详情时向 Cloudflare 查询一次公网地址,10 分钟内不重复,可在设置中关闭</span></li>
<li><b>归属地</b><span>归属地、ASN、网络类型与纯净度评分由 cleanip.io 提供:这台 Mac 直接向它查询自己的公网 IP,只发送这个地址、不带其他信息,不经过 XStats 的服务器</span></li>
<li><b>连接探测</b><span>打开网络详情时每秒 ping 一次你选择的目标(后台每 10 秒),只在显示网络项时运行,可关闭</span></li>