diff --git a/AGENTS.md b/AGENTS.md index 347364c..2aabe7f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,7 @@ SwiftUI 视图 + AppKit/CG/AX(含私有 API dlsym 桥)服务层 + UserDefaults/A - **截图链**:`GlobalHotkeyManager`(Carbon 热键,受 `screenshotEnabled` 总开关控制)→ `CaptureCoordinator`(权限→遮罩→捕获→编辑页,`isActive` 防重入)→ `ScreenCapture`(ScreenCaptureKit,按 displayID 缓存 `SCContentFilter`)→ `CaptureOverlayView`(选区遮罩)→ `EditorView`(编辑页)。 - **窗口管理族**(切换器与 Dock 预览共用):`WindowList`(CGWindowList 枚举 + 幽灵过滤 + 窗口级 MRU + 无窗口 app 占位)→ `PhantomWindowDetector`(幽灵判定,对齐 AltTab)/ `WindowActivityTracker`(AX 焦点维护 MRU)/ `WindowThumbnailer`(SkyLight 抓缩略图,能截最小化/离屏)/ `WindowActivator`(跨 Space 前台激活)/ `WindowOps`(卡片操作);消费方 `CommandTabSwitcher`(⌘⇥ 面板)与 `DockHoverPreviewController` + `DockPreviewPanel`。`CGSWindowServer` 是上述私有 API 的 dlsym 桥,启动需预热。 - **Dock**:`DockClickMinimizer`(单击最小化),开关变化经通知启停 CGEventTap。 -- **灵动岛**:`NotchHotspotController`(listen-only CGEventTap + idle→pending→visible 状态机;由公开 API 推导刘海热区:`safeAreaInsets.top` + `auxiliaryTopLeftArea`/`auxiliaryTopRightArea` 间隙)→ `NotchPanel`(非激活 NSPanel,固定深色 HUD,宽 560/日历列固定原宽,顶边贴刘海下缘,圆角 16pt≈屏幕圆角(无公开 API);背景=液态玻璃 NSGlassEffectView(macOS 26+,不透明度设置控制染色深浅)/旧系统磨砂,内容嵌在 contentView 内;无内容模块时显示「添加显示内容」引导跳设置)→ `NotchCalendarViewModel` → `CalendarService`(EventKit,macOS 14 全量授权;后台线程查询)。悬停 ≥ 延时(设置项)或单击热区弹出,点击面板外/Esc/移出热区∪面板/切 Space/睡眠/屏幕变化收起;无刘海或未授权则静默不生效。 +- **灵动岛**:`NotchHotspotController`(listen-only CGEventTap + idle→pending→visible 状态机;由公开 API 推导刘海热区:`safeAreaInsets.top` + `auxiliaryTopLeftArea`/`auxiliaryTopRightArea` 间隙)→ `NotchPanel`(非激活 NSPanel,固定深色 HUD,宽:双模块取设置最大宽(`notchMaxWidth` 420–1200)/单模块按内容收缩(下限 420×120);高:内容驱动、上限设置最大高(`notchMaxHeight` 240–1000),日历日程列表装不下在剩余空间滚动(文字不缩),日程加载/切换后自适应重测面板高度(refit),系统资源卡拉伸至驱动卡高度、行均匀分布,仅固定部分超限才整体缩小;各模块包圆角半透明卡片背景,双卡片各占一半宽;顶边贴刘海下缘,圆角 16pt≈屏幕圆角(无公开 API);背景=液态玻璃 NSGlassEffectView(macOS 26+,系统默认外观不染色)/旧系统磨砂;无内容模块时显示「添加显示内容」引导跳设置)→ `NotchCalendarViewModel` → `CalendarService`(EventKit,macOS 14 全量授权;后台线程查询)。内容模块横排最多 2 个:日历 / 系统资源(`SystemResourceMonitor`:CPU host_processor_info、内存 host_statistics64、网络 NET_RT_IFLIST2、磁盘 IOBlockStorageDriver、GPU IOAccelerator,全免 root;维护近 30 点采样历史供磁盘/网络 sparkline,CPU/内存/GPU 为用量进度条(灰轨道=100%);弹出后 0.4s 快速补采使速率项首值即显)/ 正在播放(`NowPlayingMonitor`:MRNowPlayingRequest 类属性轮询,类不可用降级 osascript;控制走 MRMediaRemoteSendCommand)。悬停 ≥ 延时(设置项)或单击热区弹出,点击面板外/Esc/移出热区∪面板/切 Space/睡眠/屏幕变化收起;无刘海或未授权则静默不生效。 - **FinderSync 桥**:主 app(非沙盒)与扩展(沙盒)经 App Group 的共享文件通信——设置 plist `finder-sync-settings.plist`、命令请求 `finder-command-*.plist` + 分布式通知,自定义命令由主 app `FinderCommandRunner` 代执行;`FinderSyncReloader` 自愈陈旧扩展实例。**不要用 `UserDefaults(suiteName:)` 读 App Group**——原因及全部沙盒/登记坑见 `PITFALLS_DEV.md`。 - **基础**:`AccessibilityPermission` / `ScreenRecordingPermission`(权限检测)、`ZSLog`(日志,stderr + `/tmp/zeroflow.log`)、`L10n`(国际化:UI 文案一律 `L10n.tr("中文")` 作 key,新增文案需同步 `Resources/Translations.json`)。 @@ -111,6 +111,8 @@ SwiftUI 视图 + AppKit/CG/AX(含私有 API dlsym 桥)服务层 + UserDefaults/A | `FinderSyncReloader.swift` | 陈旧扩展自愈:pluginkit ignore→use election 循环重载(Sequoia/Tahoe bug) | | `GlobalHotkeyManager.swift` | Carbon 全局热键注册/注销,受 `screenshotEnabled` 总开关控制 | | `NotchHotspotController.swift` | 灵动岛:listen-only CGEventTap 悬停/单击刘海热区弹出面板,状态机 + 收起重弹抑制 + 全局隐藏触发(切 Space/睡眠/屏幕变化) | +| `SystemResourceMonitor.swift` | 灵动岛系统资源采样:CPU/内存/网络/磁盘/GPU,仅面板可见期间 2s 轮询(弹出后 0.4s 快速补采首个速率值),各源免 root(IOKit 注册表 + mach + sysctl) | +| `NowPlayingMonitor.swift` | 灵动岛正在播放:MRNowPlayingRequest 类属性轮询(降级 osascript JXA 子进程),MRMediaRemoteSendCommand 播放控制 | | `PhantomWindowDetector.swift` | 幽灵窗口判定(对齐 AltTab cgsVerdict + AX subrole 兜底) | | `ScreenCapture.swift` | ScreenCaptureKit 截屏,按 displayID 缓存 `SCContentFilter` | | `WindowActivator.swift` | 跨 app 窗口激活:还原最小化 → SLP 置前 → makeKey → AX main/raise(探针验证) | @@ -128,7 +130,7 @@ SwiftUI 视图 + AppKit/CG/AX(含私有 API dlsym 桥)服务层 + UserDefaults/A | `CaptureOverlayView.swift` | 全屏选区遮罩(SwiftUI 包 NSView) | | `DockPreviewPanel.swift` | Dock 悬停预览面板(非激活 NSPanel)+ `DockPreviewViewModel` | | `EditorView.swift` | 截图编辑页(单一 ~1400 行文件):6 种工具 + 画布交互 + 键盘 + 导出 | -| `NotchPanelView.swift` | 灵动岛面板:月视图日历(今日高亮/日程打点/翻月/当日日程列表)+ 未授权引导,`NotchPanel` 非激活 NSPanel + `NotchCalendarViewModel` | +| `NotchPanelView.swift` | 灵动岛面板:内容模块横排(月视图日历/系统资源列表/正在播放含播放控制),`NotchPanel` 非激活 NSPanel 宽高内容自适应(日程加载后自适应重测) + `NotchCalendarViewModel` | | `SaveDirectoryRow.swift` | 保存目录行:路径显示 / 选择 / 打开 | | `SettingsView.swift` | 设置窗口:7 个标签页(截图/通用/切换/Dock/Dock 预览/灵动岛/右键菜单) | | `ShortcutRecorderView.swift` | 快捷键录制控件(截图页用,录制期间挂起全局热键) | diff --git a/PITFALLS_DEV.md b/PITFALLS_DEV.md index ff395cf..ec381e3 100644 --- a/PITFALLS_DEV.md +++ b/PITFALLS_DEV.md @@ -91,3 +91,13 @@ - **Pitfall:** 「访达右键 → 打开终端」点击毫无反应,而「新建空文件」(纯扩展内动作)正常,极具迷惑性。扩展 os_log 仅报 `submit: failed to write`,底层为 `NSCocoaErrorDomain Code=513 / NSPOSIXErrorDomain Code=1 (EPERM)`;group container 里从未出现 `finder-command-*.plist`,主 app 日志零条 `finderCommand`。GitHub CI 的 ad-hoc 包必现,Team 签名构建正常。 - **Cause:** ad-hoc 签名(`TeamIdentifier=not set`)的沙盒扩展,系统不认可其 `com.apple.security.application-groups` entitlement,沙盒只放行 group container 的**读**、**写**被拒(EPERM)。扩展读设置正常所以菜单/文案都正常,只有「写请求文件」这一步死;非沙盒主 app 不受沙盒中介、读写都正常——这种读写不对称掩盖了问题。Team 签名(真证书)即可放行写,实测无需嵌入 provisioning profile。 - **Prevention:** 涉及 FinderSync 扩展的产物必须 Team 签名(`CODE_SIGN_IDENTITY="Apple Development" DEVELOPMENT_TEAM=8NHN73Q43T`)。CI 已加 `Import signing certificate` 步骤从 secrets 导入证书,发 tag 时断言 appex `TeamIdentifier=8NHN73Q43T`;无 secrets 回退 ad-hoc 仅限编译检查(那种包右键命令功能无效)。排障用 `log show --info --predicate 'subsystem == "com.zeroflow.app.finderSync"'`(必须 `--info`,否则只剩 error 级);`CustomCommandRunner.submit` 的失败日志已改为输出真实 NSError + 完整路径(之前只打布尔,排障信息为零)。 + +## DEV-019: MediaRemote 私有框架在 macOS 15.4+ 的可用读取路径与 JXA 细节 +- **Pitfall:** `MRMediaRemoteGetNowPlayingInfo` C 函数在 15.4+ 被封(符号在但回调永不来);legacy `MediaRemote` ObjC 类在 macOS 27 已不存在;JXA 里 NSBundle 调用 `-loadBundle` 报 "not a function"(方法名是 `-load`)。 +- **Cause:** Apple 逐步收紧 MediaRemote;唯一仍开放的进程内读取路径是 `MRNowPlayingRequest` 类属性(`localNowPlayingItem`/`localNowPlayingPlayerPath`),对无自动化授权的进程也开放(osascript JXA 与进程内 KVC 均实测可用)。播放控制 `MRMediaRemoteSendCommand` 的 dlsym 符号仍存在。 +- **Prevention:** 读取优先进程内 `(NSClassFromString("MRNowPlayingRequest") as AnyObject).value(forKey:)`(KVC 前一律 `responds(to:)` 守卫,未知 key 会抛 ObjC 异常崩溃);类缺失再降级 `osascript -l JavaScript` 子进程(bundle.load 而非 loadBundle,数值字段经 `.js` 转 String 再解析);控制走 dlsym `MRMediaRemoteSendCommand`,Swift block 参数用 `@convention(c)`(命令号 toggle=2/next=4/previous=5)。app 名/图标从 `playerPath.client.displayName/bundleIdentifier` + `NSWorkspace.runningApplications` 取,info dict 不含封面字节。 + +## DEV-020: IOKit 统计键的 C 宏不进 Swift,host_statistics64 计数要从结构体尺寸算 +- **Pitfall:** `kIOBlockStorageDriverStatisticsKey` 等按符号用报 "cannot find in scope";内存采样 count 直接用常量或拍脑袋会返回 KERN_INVALID_ARGUMENT。 +- **Cause:** 这些键是 IOKit 头文件里的字符串宏(`#define kIOBlockStorageDriverStatisticsKey "Statistics"`,读/写键为 `"Bytes (Read)"`/`"Bytes (Write)"`),Swift 侧不导入;`HOST_VM_INFO64_COUNT` 也是宏。 +- **Prevention:** IOKit 键直接写字符串值(真实值查 SDK 头文件 `IOKit.framework/Headers/storage/IOBlockStorageDriver.h`);`mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size)` 自算 count。资源监控数据源速记(macOS 27 实测,全免 root):CPU=host_processor_info 两次 ticks 差值;内存已用=active+inactive+speculative+wired+compressor−purgeable−external(≈活动监视器「内存已用」,≠top 的 PhysMem used=总−free,后者含缓存文件,别拿来对数);网络=sysctl NET_RT_IFLIST2 的 if_data64 逐接口差值(排除 IFT_LOOP);磁盘=IOService("IOBlockStorageDriver") 注册表 "Statistics" 的读写字节差值;GPU=IOAccelerator "PerformanceStatistics" 的 "Device Utilization %"(本机存在,瞬时值无需差分;备用 "GPU Activity(%)")。`swiftc | head` 会因管道 SIGPIPE 杀掉编译器且 `&&` 拿到的是 head 的退出码,验证脚本要重定向到文件再 grep。 diff --git a/README.md b/README.md index 69174c0..b9cd630 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,10 @@ - 灵动岛 ## TODO list -- [ ] 灵动岛中显示cpu,内存等使用情况 -- [ ] 灵动岛中显示音乐 +- [x] 灵动岛中显示cpu,内存等使用情况 +- [x] 灵动岛中显示音乐 - [ ] 灵动岛显示宠物?如果太耗内存就不做 -- [ ] 通过`添加右键菜单`按钮统一实现多个功能,现有右键菜单移除,全部通过上述按钮实现 +- [x] 使用开发者账号构建app ## 下载安装 diff --git a/Zeroflow/Models/SettingsStore.swift b/Zeroflow/Models/SettingsStore.swift index 0a489de..45c80b6 100644 --- a/Zeroflow/Models/SettingsStore.swift +++ b/Zeroflow/Models/SettingsStore.swift @@ -34,7 +34,11 @@ final class SettingsStore: ObservableObject { static let notchEnabled = "notchEnabled" static let notchHoverDelayMs = "notchHoverDelayMs" static let notchShowCalendar = "notchShowCalendar" - static let notchPanelOpacity = "notchPanelOpacity" + static let notchShowResources = "notchShowResources" + static let notchShowMusic = "notchShowMusic" + static let notchResourceItems = "notchResourceItems" + static let notchMaxWidth = "notchMaxWidth" + static let notchMaxHeight = "notchMaxHeight" } private let defaults: UserDefaults @@ -108,7 +112,11 @@ final class SettingsStore: ObservableObject { Keys.notchEnabled: false, Keys.notchHoverDelayMs: 200, Keys.notchShowCalendar: true, - Keys.notchPanelOpacity: 80, + Keys.notchShowResources: false, + Keys.notchShowMusic: false, + Keys.notchResourceItems: NotchResourceItem.allRawValues, + Keys.notchMaxWidth: 680, + Keys.notchMaxHeight: 600, ]) _launchAtLogin = Published(initialValue: defaults.bool(forKey: Keys.launchAtLogin)) _saveDirectory = Published(initialValue: defaults.string(forKey: Keys.saveDirectory) ?? Self.defaultSaveDirectory) @@ -127,7 +135,14 @@ final class SettingsStore: ObservableObject { _notchEnabled = Published(initialValue: defaults.bool(forKey: Keys.notchEnabled)) _notchHoverDelayMs = Published(initialValue: defaults.integer(forKey: Keys.notchHoverDelayMs)) _notchShowCalendar = Published(initialValue: defaults.bool(forKey: Keys.notchShowCalendar)) - _notchPanelOpacity = Published(initialValue: defaults.integer(forKey: Keys.notchPanelOpacity)) + _notchShowResources = Published(initialValue: defaults.bool(forKey: Keys.notchShowResources)) + _notchShowMusic = Published(initialValue: defaults.bool(forKey: Keys.notchShowMusic)) + _notchResourceItems = Published(initialValue: { + let stored = defaults.stringArray(forKey: Keys.notchResourceItems) ?? NotchResourceItem.allRawValues + return NotchResourceItem.allCases.filter { stored.contains($0.rawValue) }.map(\.rawValue) + }()) + _notchMaxWidth = Published(initialValue: defaults.integer(forKey: Keys.notchMaxWidth)) + _notchMaxHeight = Published(initialValue: defaults.integer(forKey: Keys.notchMaxHeight)) _shortcut = Published(initialValue: Self.loadShortcut(from: defaults)) _cmdTabShortcut = Published(initialValue: Self.loadCmdTabShortcut(from: defaults)) _appLanguage = Published(initialValue: AppLanguage(rawValue: defaults.string(forKey: Keys.appLanguage) ?? "") ?? .system) @@ -425,17 +440,45 @@ final class SettingsStore: ObservableObject { } } - /// 显示内容模块:日历。全部关闭时面板仍会弹出,显示「添加显示内容」引导 + /// 内容模块:日历。最多同时显示 2 个模块(设置页限制),全部关闭时面板仍会弹出并显示引导 @Published var notchShowCalendar: Bool { didSet { defaults.set(notchShowCalendar, forKey: Keys.notchShowCalendar) } } - /// 面板不透明度(%):100 = 完全不透明,越小越透明 - @Published var notchPanelOpacity: Int { + /// 内容模块:系统资源 + @Published var notchShowResources: Bool { didSet { - defaults.set(notchPanelOpacity, forKey: Keys.notchPanelOpacity) + defaults.set(notchShowResources, forKey: Keys.notchShowResources) + } + } + + /// 内容模块:正在播放 + @Published var notchShowMusic: Bool { + didSet { + defaults.set(notchShowMusic, forKey: Keys.notchShowMusic) + } + } + + /// 系统资源模块里显示的项目(NotchResourceItem.rawValue,固定顺序) + @Published var notchResourceItems: [String] { + didSet { + defaults.set(notchResourceItems, forKey: Keys.notchResourceItems) + } + } + + /// 面板最大宽度(pt)。显示 2 个模块时面板取该宽度(两卡片各占一半),单模块按内容收缩 + @Published var notchMaxWidth: Int { + didSet { + defaults.set(notchMaxWidth, forKey: Keys.notchMaxWidth) + } + } + + /// 面板最大高度(pt)。显示 2 个模块时面板取该高度,单模块按内容收缩(上限钳制) + @Published var notchMaxHeight: Int { + didSet { + defaults.set(notchMaxHeight, forKey: Keys.notchMaxHeight) } } diff --git a/Zeroflow/Resources/Translations.json b/Zeroflow/Resources/Translations.json index 7b4f315..377706f 100644 --- a/Zeroflow/Resources/Translations.json +++ b/Zeroflow/Resources/Translations.json @@ -584,21 +584,6 @@ "en": "Calendar access is not granted, so events can't be shown.", "ja": "カレンダーへのアクセスが許可されていないため、予定を表示できません。" }, - "弹出面板中显示的内容模块。全部关闭时,面板仍会弹出并提示添加显示内容。": { - "zh-Hans": "弹出面板中显示的内容模块。全部关闭时,面板仍会弹出并提示添加显示内容。", - "en": "Content modules shown in the panel. With all modules off, the panel still opens and offers to add content.", - "ja": "パネルに表示するコンテンツモジュール。すべてオフでもパネルは開き、コンテンツ追加を案内します。" - }, - "面板": { - "zh-Hans": "面板", - "en": "Panel", - "ja": "パネル" - }, - "不透明度": { - "zh-Hans": "不透明度", - "en": "Opacity", - "ja": "不透明度" - }, "暂无显示内容": { "zh-Hans": "暂无显示内容", "en": "No content yet", @@ -609,9 +594,104 @@ "en": "Add Content", "ja": "コンテンツを追加" }, - "macOS 26 及以上使用液态玻璃面板背景,旧系统为系统磨砂;不透明度数值越小越透明。重新弹出面板后生效。": { - "zh-Hans": "macOS 26 及以上使用液态玻璃面板背景,旧系统为系统磨砂;不透明度数值越小越透明。重新弹出面板后生效。", - "en": "On macOS 26 and later the panel uses a Liquid Glass background; older systems show the system frost. Lower opacity values are more transparent. Changes apply when the panel is reopened.", - "ja": "macOS 26 以降では Liquid Glass のパネル背景を使用し、旧システムではシステムのすりガラスを表示します。不透明度は数値が小さいほど透明になります。変更はパネルを開き直すと反映されます。" + "系统资源": { + "zh-Hans": "系统资源", + "en": "System Resources", + "ja": "システムリソース" + }, + "正在播放": { + "zh-Hans": "正在播放", + "en": "Now Playing", + "ja": "再生中" + }, + "资源项": { + "zh-Hans": "资源项", + "en": "Resource Items", + "ja": "リソース項目" + }, + "系统资源模块中显示的项目;CPU/内存/GPU 显示用量进度条(灰色为总量),磁盘/网络数值旁的曲线为最近约 1 分钟的速率趋势。": { + "zh-Hans": "系统资源模块中显示的项目;CPU/内存/GPU 显示用量进度条(灰色为总量),磁盘/网络数值旁的曲线为最近约 1 分钟的速率趋势。", + "en": "Items shown in the System Resources module; CPU/Memory/GPU show a usage bar (gray = total), while the curves beside Disk/Network show roughly the last minute of transfer rate.", + "ja": "システムリソースモジュールに表示する項目。CPU/メモリ/GPU は使用率バー(灰色は全体量)を表示し、ディスク/ネットワークの数値の横の曲線は直近約 1 分の転送速度の推移です。" + }, + "最大宽度": { + "zh-Hans": "最大宽度", + "en": "Maximum Width", + "ja": "最大幅" + }, + "最大高度": { + "zh-Hans": "最大高度", + "en": "Maximum Height", + "ja": "最大高さ" + }, + "鼠标停留在刘海区域多久后弹出面板;单击刘海区域可立即弹出。显示 2 个内容模块时面板固定为最大尺寸(两卡片各占一半宽度),单模块时按内容收缩;内容超出面板时整体等比缩小以完整显示。": { + "zh-Hans": "鼠标停留在刘海区域多久后弹出面板;单击刘海区域可立即弹出。显示 2 个内容模块时面板固定为最大尺寸(两卡片各占一半宽度),单模块时按内容收缩;内容超出面板时整体等比缩小以完整显示。", + "en": "How long the pointer must stay on the notch before the panel appears; clicking the notch opens it instantly. With 2 content modules the panel uses the maximum size (each card takes half the width); with a single module it shrinks to fit its content. Oversized content is scaled down proportionally to stay fully visible.", + "ja": "ホバーしてからパネルが表示されるまでの時間。ノッチをクリックすると即座に開きます。コンテンツモジュールを 2 つ表示する場合はパネルが最大サイズで固定され(各カードが幅の半分ずつ)、1 つの場合は内容に合わせて収縮します。収まりきらない内容は等比縮小して全体を表示します。" + }, + "弹出面板中显示的内容模块,最多同时显示 2 个。全部关闭时,面板仍会弹出并提示添加显示内容。": { + "zh-Hans": "弹出面板中显示的内容模块,最多同时显示 2 个。全部关闭时,面板仍会弹出并提示添加显示内容。", + "en": "Content modules shown in the panel, up to 2 at a time. With all modules off, the panel still opens and offers to add content.", + "ja": "パネルに表示するコンテンツモジュール(最大 2 つ)。すべてオフでもパネルは開き、コンテンツ追加を案内します。" + }, + "没有正在播放的音乐": { + "zh-Hans": "没有正在播放的音乐", + "en": "Nothing playing", + "ja": "再生中の音楽はありません" + }, + "未知应用": { + "zh-Hans": "未知应用", + "en": "Unknown App", + "ja": "不明なアプリ" + }, + "已暂停": { + "zh-Hans": "已暂停", + "en": "Paused", + "ja": "一時停止中" + }, + "暂停": { + "zh-Hans": "暂停", + "en": "Pause", + "ja": "一時停止" + }, + "播放": { + "zh-Hans": "播放", + "en": "Play", + "ja": "再生" + }, + "上一首": { + "zh-Hans": "上一首", + "en": "Previous Track", + "ja": "前の曲" + }, + "下一首": { + "zh-Hans": "下一首", + "en": "Next Track", + "ja": "次の曲" + }, + "读": { + "zh-Hans": "读", + "en": "Read", + "ja": "読み" + }, + "写": { + "zh-Hans": "写", + "en": "Write", + "ja": "書き" + }, + "内存": { + "zh-Hans": "内存", + "en": "Memory", + "ja": "メモリ" + }, + "磁盘": { + "zh-Hans": "磁盘", + "en": "Disk", + "ja": "ディスク" + }, + "网络": { + "zh-Hans": "网络", + "en": "Network", + "ja": "ネットワーク" } } diff --git a/Zeroflow/Services/NotchHotspotController.swift b/Zeroflow/Services/NotchHotspotController.swift index e2fe0c1..cd96ac8 100644 --- a/Zeroflow/Services/NotchHotspotController.swift +++ b/Zeroflow/Services/NotchHotspotController.swift @@ -1,5 +1,6 @@ import AppKit import ApplicationServices +import Combine import CoreGraphics /// 灵动岛:悬停/单击刘海区域,从刘海向下弹出磨砂面板(v1 内容 = 日历月视图)。 @@ -62,6 +63,9 @@ final class NotchHotspotController { private var model: NotchCalendarViewModel? private var panelShown = false private var notchScreen: NSScreen? + private var dayEventsCancellable: AnyCancellable? + /// 最近一次 present 的热区/屏幕(日程变化后 refit 复用) + private var lastPresented: (screen: NSScreen, hotspotTL: CGRect)? // MARK: - 基础设施 @@ -444,6 +448,10 @@ final class NotchHotspotController { guard let self else { return } self.panel?.orderOut(nil) self.panelShown = false + self.dayEventsCancellable = nil + self.lastPresented = nil + SystemResourceMonitor.shared.setActive(false) + NowPlayingMonitor.shared.setActive(false) } } @@ -474,6 +482,9 @@ final class NotchHotspotController { let panel = ensurePanel() panel.present(hotspotTL: detected.hotspotTL, screen: detected.screen, model: viewModel) panelShown = true + lastPresented = (detected.screen, detected.hotspotTL) + startMonitors() + startDayEventsRefit(model: viewModel) lock.lock() panelFrameTL = toTopLeftCoords(panel.frame) lock.unlock() @@ -482,6 +493,16 @@ final class NotchHotspotController { } } + /// 按当前启用模块启动数据轮询(仅面板可见期间运行,hidePanel 时全部停止) + private func startMonitors() { + if SettingsStore.shared.notchShowResources { + SystemResourceMonitor.shared.setActive(true) + } + if SettingsStore.shared.notchShowMusic { + NowPlayingMonitor.shared.setActive(true) + } + } + private func ensurePanel() -> NotchPanel { if let panel { return panel } let panel = NotchPanel() @@ -496,6 +517,28 @@ final class NotchHotspotController { return model } + /// 日历模块可见时订阅日程变化:日程异步加载完成 / 切换选中日期后重测面板高度 + /// (面板尺寸在日程加载前测量,首次弹出与切换日期都靠这里补齐真实高度) + private func startDayEventsRefit(model: NotchCalendarViewModel) { + dayEventsCancellable = nil + guard NotchContentView.orderedVisibleModules.contains(.calendar) else { return } + dayEventsCancellable = model.$dayEvents + .removeDuplicates() + .sink { [weak self] _ in self?.refitPanel() } + } + + /// 重测面板:顶边贴刘海不动,只调高度;同步保活判定用的 panelFrameTL + private func refitPanel() { + lock.lock() + let visible = phase == .visible + lock.unlock() + guard visible, let panel, let model, let presented = lastPresented else { return } + panel.refit(hotspotTL: presented.hotspotTL, screen: presented.screen, model: model) + lock.lock() + panelFrameTL = toTopLeftCoords(panel.frame) + lock.unlock() + } + // MARK: - 刘海探测(主线程) /// 探测刘海屏并计算热区。无刘海返回 nil;ZEROFLOW_NOTCH_FAKE=1 时在主屏合成伪刘海。 diff --git a/Zeroflow/Services/NowPlayingMonitor.swift b/Zeroflow/Services/NowPlayingMonitor.swift new file mode 100644 index 0000000..80ebaa7 --- /dev/null +++ b/Zeroflow/Services/NowPlayingMonitor.swift @@ -0,0 +1,247 @@ +import Foundation +import AppKit + +/// 「正在播放」模块的一条当前播放信息 +struct NowPlayingInfo { + let title: String + let artist: String? + let album: String? + let appBundleID: String? + let appName: String? + let isPlaying: Bool + let elapsed: Double? + let duration: Double? +} + +/// 灵动岛「正在播放」:轮询 MediaRemote 私有框架。 +/// 读取优先进程内 MRNowPlayingRequest 类属性(macOS 15.4+ 后唯一未封的读取路径, +/// 对无自动化授权的进程开放;已实测);类不可用时降级 osascript JXA 子进程。 +/// 播放控制走 MRMediaRemoteSendCommand(dlsym)。 +final class NowPlayingMonitor: ObservableObject { + static let shared = NowPlayingMonitor() + + private static let debug = ProcessInfo.processInfo.environment["ZEROFLOW_NOTCH_DEBUG"] == "1" + /// 轮询间隔(s) + private static let interval: TimeInterval = 2 + /// 连续多少次读不到内容后才清空展示(避免瞬时失败闪空) + private static let staleLimit = 3 + + enum Command: UInt { + case togglePlay = 2 + case next = 4 + case previous = 5 + } + + @Published private(set) var nowPlaying: NowPlayingInfo? + /// 播放控制是否可用(MRMediaRemoteSendCommand 符号是否找到) + private(set) var controlsAvailable = false + + private var timer: Timer? + private var inFlight = false + private var staleCount = 0 + private let queue = DispatchQueue(label: "zeroflow.notch.nowplaying", qos: .utility) + + /// MRNowPlayingRequest 类;加载失败则降级子进程 + private static let requestClass: AnyObject? = { + let bundle = Bundle(path: "/System/Library/PrivateFrameworks/MediaRemote.framework") + guard let bundle, bundle.load() else { return nil } + return NSClassFromString("MRNowPlayingRequest") as AnyObject? + }() + + private var useSubprocess: Bool + + private typealias SendCommandFn = @convention(c) (UInt, AnyObject, DispatchQueue, AnyObject?) -> Void + private var sendCommandFn: SendCommandFn? + private static let dummyTarget = NSObject() + + private init() { + useSubprocess = Self.requestClass == nil + if let handle = dlopen("/System/Library/PrivateFrameworks/MediaRemote.framework/MediaRemote", RTLD_LAZY), + let sym = dlsym(handle, "MRMediaRemoteSendCommand") { + sendCommandFn = unsafeBitCast(sym, to: SendCommandFn.self) + } + controlsAvailable = sendCommandFn != nil + if Self.debug { + ZSLog("NowPlaying: inProcess=\(!useSubprocess) sendCommand=\(controlsAvailable)") + } + } + + // MARK: - 生命周期 + + /// 面板显示时开始轮询、隐藏时停止;任意线程可调 + func setActive(_ active: Bool) { + DispatchQueue.main.async { [weak self] in + self?.setActiveOnMain(active) + } + } + + private func setActiveOnMain(_ active: Bool) { + if active { + guard timer == nil else { return } + let t = Timer(timeInterval: Self.interval, repeats: true) { [weak self] _ in + self?.tick() + } + RunLoop.main.add(t, forMode: .common) + timer = t + tick() + } else { + timer?.invalidate() + timer = nil + nowPlaying = nil + staleCount = 0 + } + } + + private func tick() { + guard !inFlight else { return } + inFlight = true + queue.async { [weak self] in + guard let self else { return } + let info = self.useSubprocess ? self.readViaSubprocess() : self.readInProcess() + DispatchQueue.main.async { + self.inFlight = false + guard self.timer != nil else { return } + if let info { + self.nowPlaying = info + self.staleCount = 0 + } else if self.nowPlaying != nil { + self.staleCount += 1 + if self.staleCount >= Self.staleLimit { + self.nowPlaying = nil + self.staleCount = 0 + } + } + } + } + } + + // MARK: - 发送播放控制 + + func send(_ command: Command) { + guard let fn = sendCommandFn else { return } + fn(command.rawValue, Self.requestClass ?? Self.dummyTarget, DispatchQueue.main, nil) + if Self.debug { ZSLog("NowPlaying: sent \(command)") } + // 发送后短暂延迟重查,让 UI 及时反映状态 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.tick() + } + } + + // MARK: - 读取:进程内 MRNowPlayingRequest + + private func readInProcess() -> NowPlayingInfo? { + guard let cls = Self.requestClass else { return nil } + guard cls.responds(to: Selector(("localNowPlayingItem"))) else { return nil } + guard let item = cls.value(forKey: "localNowPlayingItem") as? NSObject else { return nil } + guard item.responds(to: Selector(("nowPlayingInfo"))), + let info = item.value(forKey: "nowPlayingInfo") as? [String: Any] else { return nil } + guard let title = info["kMRMediaRemoteNowPlayingInfoTitle"] as? String, !title.isEmpty else { return nil } + + var appName: String? + var appBundleID: String? + if cls.responds(to: Selector(("localNowPlayingPlayerPath"))), + let path = cls.value(forKey: "localNowPlayingPlayerPath") as? NSObject, + path.responds(to: Selector(("client"))), + let client = path.value(forKey: "client") as? NSObject { + if client.responds(to: Selector(("displayName"))) { + appName = client.value(forKey: "displayName") as? String + } + if client.responds(to: Selector(("bundleIdentifier"))) { + appBundleID = client.value(forKey: "bundleIdentifier") as? String + } + } + if appName == nil, let bid = appBundleID { + appName = NSWorkspace.shared.runningApplications.first { $0.bundleIdentifier == bid }?.localizedName + } + + let rate = double(info, "kMRMediaRemoteNowPlayingInfoPlaybackRate") + let isPlaying = rate.map { $0 != 0 } ?? true + return NowPlayingInfo( + title: title, + artist: string(info, "kMRMediaRemoteNowPlayingInfoArtist"), + album: string(info, "kMRMediaRemoteNowPlayingInfoAlbum"), + appBundleID: appBundleID, + appName: appName, + isPlaying: isPlaying, + elapsed: double(info, "kMRMediaRemoteNowPlayingInfoElapsedTime"), + duration: double(info, "kMRMediaRemoteNowPlayingInfoDuration") + ) + } + + private func string(_ info: [String: Any], _ key: String) -> String? { + (info[key] as? String).flatMap { $0.isEmpty ? nil : $0 } + } + + private func double(_ info: [String: Any], _ key: String) -> Double? { + (info[key] as? NSNumber)?.doubleValue + } + + // MARK: - 读取:osascript JXA 子进程(降级路径) + + private func readViaSubprocess() -> NowPlayingInfo? { + guard let out = Self.runJXA() else { return nil } + let parts = out.trimmingCharacters(in: .whitespacesAndNewlines) + .split(separator: "\u{241F}", omittingEmptySubsequences: false) + guard parts.count >= 8, !parts[0].isEmpty, parts[0] != "NOCLASS", parts[0] != "NOITEM" else { return nil } + let num = { (s: Substring) -> Double? in + let t = s.trimmingCharacters(in: .whitespaces) + return t.isEmpty ? nil : Double(t) + } + return NowPlayingInfo( + title: String(parts[0]), + artist: parts[1].isEmpty ? nil : String(parts[1]), + album: parts[2].isEmpty ? nil : String(parts[2]), + appBundleID: parts[3].isEmpty ? nil : String(parts[3]), + appName: parts[4].isEmpty ? nil : String(parts[4]), + isPlaying: num(parts[7]).map { $0 != 0 } ?? true, + elapsed: num(parts[6]), + duration: num(parts[5]) + ) + } + + private static func runJXA() -> String? { + let p = Process() + p.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + p.arguments = ["-l", "JavaScript", "-e", jxaScript] + let pipe = Pipe() + p.standardOutput = pipe + p.standardError = Pipe() + do { try p.run() } catch { return nil } + // 看门狗:4s 未返回则终止,避免卡住轮询队列 + DispatchQueue.global().asyncAfter(deadline: .now() + 4) { + if p.isRunning { p.terminate() } + } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + p.waitUntilExit() + guard p.terminationStatus == 0 else { + if debug { ZSLog("NowPlaying: osascript exit=\(p.terminationStatus)") } + return nil + } + return String(data: data, encoding: .utf8) + } + + private static let jxaScript = """ + ObjC.import('Foundation'); + function run() { + var b = $.NSBundle.bundleWithPath('/System/Library/PrivateFrameworks/MediaRemote.framework'); + if (b.isNil()) return ''; + var loaded = b.load; + var cls = $.NSClassFromString('MRNowPlayingRequest'); + if (cls.isNil()) return 'NOCLASS'; + var item = cls.localNowPlayingItem; + if (item.isNil()) return 'NOITEM'; + var info = item.nowPlayingInfo; + if (info.isNil()) return 'NOINFO'; + var t = info.valueForKey('kMRMediaRemoteNowPlayingInfoTitle'); + if (t.isNil() || !t.js.length) return ''; + var app = '', bid = ''; + try { + var c = cls.localNowPlayingPlayerPath.client; + app = c.displayName && !c.displayName.isNil() ? c.displayName.js : ''; + bid = c.bundleIdentifier && !c.bundleIdentifier.isNil() ? c.bundleIdentifier.js : ''; + } catch (e) {} + function s(v) { return (v && !v.isNil() && typeof v.js !== 'undefined') ? String(v.js) : ''; } + return [t.js, s(info.valueForKey('kMRMediaRemoteNowPlayingInfoArtist')), s(info.valueForKey('kMRMediaRemoteNowPlayingInfoAlbum')), bid, app, s(info.valueForKey('kMRMediaRemoteNowPlayingInfoDuration')), s(info.valueForKey('kMRMediaRemoteNowPlayingInfoElapsedTime')), s(info.valueForKey('kMRMediaRemoteNowPlayingInfoPlaybackRate'))].join('␟'); + } + """ +} diff --git a/Zeroflow/Services/SystemResourceMonitor.swift b/Zeroflow/Services/SystemResourceMonitor.swift new file mode 100644 index 0000000..f7231af --- /dev/null +++ b/Zeroflow/Services/SystemResourceMonitor.swift @@ -0,0 +1,390 @@ +import Foundation +import IOKit + +/// 灵动岛「系统资源」模块的显示项 +enum NotchResourceItem: String, CaseIterable, Identifiable { + case cpu + case memory + case disk + case network + case gpu + + var id: String { rawValue } + + var title: String { + switch self { + case .cpu: return "CPU" + case .memory: return L10n.tr("内存") + case .disk: return L10n.tr("磁盘") + case .network: return L10n.tr("网络") + case .gpu: return "GPU" + } + } + + var symbol: String { + switch self { + case .cpu: return "cpu" + case .memory: return "memorychip" + case .disk: return "internaldrive" + case .network: return "network" + case .gpu: return "gauge.with.needle" + } + } + + static let allRawValues = allCases.map(\.rawValue) +} + +/// 一次采样结果;nil = 该项暂不可用(UI 显示「—」) +struct NotchResourceSample { + var cpuPercent: Double? + var memUsedBytes: Int64? + var memTotalBytes: Int64? + var diskReadBps: Int64? + var diskWriteBps: Int64? + var netDownBps: Int64? + var netUpBps: Int64? + var gpuPercent: Double? +} + +/// 各资源项的近期采样序列,供面板迷你趋势图(磁盘/网络)使用; +/// 百分比项存 0–100,速率项存字节/秒(视图内按窗口内峰值归一化) +struct NotchResourceHistory { + var cpu: [Double] = [] + var memory: [Double] = [] + var gpu: [Double] = [] + var disk: [Double] = [] + var network: [Double] = [] +} + +/// 灵动岛「系统资源」采样:CPU(host_processor_info)/内存(host_statistics64)/ +/// 网络(sysctl NET_RT_IFLIST2)/磁盘(IOKit IOBlockStorageDriver 统计)/GPU(IOAccelerator +/// PerformanceStatistics)。全部免 root、无权限要求;仅面板可见期间采样,弹出时先建基线 +/// 并在 0.4s 后快速补采,让速率项(CPU/磁盘/网络)首值不等固定 2s 间隔。 +final class SystemResourceMonitor: ObservableObject { + static let shared = SystemResourceMonitor() + + private static let debug = ProcessInfo.processInfo.environment["ZEROFLOW_NOTCH_DEBUG"] == "1" + /// 采样间隔(s) + private static let interval: TimeInterval = 2 + /// 弹出后快速补采间隔(s):速率项需两次采样差值,若只等固定间隔首值要 2s 才出现; + /// 建基线后 0.4s 先补采一次,之后由固定间隔接管 + private static let warmupDelay: TimeInterval = 0.4 + + @Published private(set) var sample = NotchResourceSample() + @Published private(set) var history = NotchResourceHistory() + + /// 历史序列容量(2s 采样间隔 → 约 1 分钟窗口) + static let historyMaxPoints = 30 + + private var timer: Timer? + private var warmupTimer: Timer? + private var busy = false + private let queue = DispatchQueue(label: "zeroflow.notch.resources", qos: .utility) + + // 速率计算的上一帧(tap 之外仅后台采样线程读写,主线程只发起点) + private var lastCPUTicks: (busy: Int, idle: Int)? + private var lastDisk: (read: UInt64, write: UInt64, at: Date)? + private var lastNet: [UInt32: (inBytes: Int64, outBytes: Int64)] = [:] + private var lastNetAt: Date? + + private init() {} + + // MARK: - 生命周期 + + /// 面板显示时开启采样、隐藏时停止;任意线程可调(内部回主线程) + func setActive(_ active: Bool) { + DispatchQueue.main.async { [weak self] in + self?.setActiveOnMain(active) + } + } + + private func setActiveOnMain(_ active: Bool) { + if active { + guard timer == nil else { return } + let t = Timer(timeInterval: Self.interval, repeats: true) { [weak self] _ in + self?.tick() + } + RunLoop.main.add(t, forMode: .common) + timer = t + tick() + // 快速补采:首次 tick 只建基线(速率项返回 nil,UI 显示「—」),0.4s 后补采一次 + // 即得首个速率值;面板若已隐藏(timer == nil)则补采自动作废 + let w = Timer(timeInterval: Self.warmupDelay, repeats: false) { [weak self] _ in + guard let self, self.timer != nil else { return } + self.tick() + } + RunLoop.main.add(w, forMode: .common) + warmupTimer = w + } else { + timer?.invalidate() + timer = nil + warmupTimer?.invalidate() + warmupTimer = nil + // 清基线,重新显示后重新累计速率(计数器可能被系统重置);趋势历史同步清空 + lastCPUTicks = nil + lastDisk = nil + lastNet = [:] + lastNetAt = nil + history = NotchResourceHistory() + } + } + + private func tick() { + guard !busy else { return } + busy = true + queue.async { [weak self] in + guard let self else { return } + let s = self.collect() + DispatchQueue.main.async { + self.busy = false + guard self.timer != nil else { return } + self.sample = s + self.appendHistory(s) + } + } + } + + // MARK: - 采样(后台线程) + + /// 追加本次采样到历史序列(主线程)。某项本次不可用时沿用上一值,保持趋势线连续 + private func appendHistory(_ s: NotchResourceSample) { + let memPercent: Double? = { + guard let used = s.memUsedBytes, let total = s.memTotalBytes, total > 0 else { return nil } + return Double(used) / Double(total) * 100 + }() + let diskBps: Double? = (s.diskReadBps == nil && s.diskWriteBps == nil) + ? nil : Double((s.diskReadBps ?? 0) + (s.diskWriteBps ?? 0)) + let netBps: Double? = (s.netDownBps == nil && s.netUpBps == nil) + ? nil : Double((s.netDownBps ?? 0) + (s.netUpBps ?? 0)) + history = NotchResourceHistory( + cpu: Self.push(history.cpu, s.cpuPercent), + memory: Self.push(history.memory, memPercent), + gpu: Self.push(history.gpu, s.gpuPercent), + disk: Self.push(history.disk, diskBps), + network: Self.push(history.network, netBps) + ) + } + + private static func push(_ array: [Double], _ value: Double?) -> [Double] { + var array = array + array.append(value ?? array.last ?? 0) + if array.count > historyMaxPoints { array.removeFirst(array.count - historyMaxPoints) } + return array + } + + private func collect() -> NotchResourceSample { + var s = NotchResourceSample() + s.cpuPercent = cpuPercent() + if let mem = memoryUsage() { + s.memUsedBytes = mem.used + s.memTotalBytes = mem.total + } + if let disk = diskRates() { + s.diskReadBps = disk.read + s.diskWriteBps = disk.write + } + if let net = netRates() { + s.netDownBps = net.down + s.netUpBps = net.up + } + s.gpuPercent = gpuPercent() + if Self.debug { + ZSLog("Resources: cpu=\(fmt(s.cpuPercent))% mem=\(fmtBytes(s.memUsedBytes))/\(fmtBytes(s.memTotalBytes)) disk=\(fmtBytes(s.diskReadBps))/s,\(fmtBytes(s.diskWriteBps))/s net=\(fmtBytes(s.netDownBps))/s,\(fmtBytes(s.netUpBps))/s gpu=\(fmt(s.gpuPercent))%") + } + return s + } + + private func fmt(_ v: Double?) -> String { v.map { String(format: "%.1f", $0) } ?? "nil" } + private func fmtBytes(_ v: Int64?) -> String { + guard let v else { return "nil" } + let bps = Double(v) + if bps < 1024 { return "\(Int(bps))B" } + if bps < 1_048_576 { return String(format: "%.1fKB", bps / 1024) } + return String(format: "%.1fMB", bps / 1_048_576) + } + + /// CPU 总占用:两次采样的 busy/idle ticks 差值;首次无基线返回 nil + private func cpuPercent() -> Double? { + var count: natural_t = 0 + var loads: processor_info_array_t? + var msgCount: mach_msg_type_number_t = 0 + let port = mach_host_self() + defer { _ = mach_port_deallocate(mach_task_self_, port) } + guard host_processor_info(port, PROCESSOR_CPU_LOAD_INFO, &count, &loads, &msgCount) == KERN_SUCCESS, + let loads, count > 0 else { return nil } + + var busy = 0 + var idle = 0 + for i in 0...stride) + vm_deallocate(mach_task_self_, vm_address_t(UInt(bitPattern: loads)), stride * vm_size_t(msgCount)) + + guard let last = lastCPUTicks else { + lastCPUTicks = (busy, idle) + return nil + } + lastCPUTicks = (busy, idle) + let deltaBusy = busy - last.busy + let deltaIdle = idle - last.idle + let total = deltaBusy + deltaIdle + guard total > 0 else { return nil } + return min(100, max(0, Double(deltaBusy) / Double(total) * 100)) + } + + /// 内存已用/总量,口径对齐活动监视器(App + Wired + Compressed) + private func memoryUsage() -> (used: Int64, total: Int64)? { + var stats = vm_statistics64() + var count = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size) + let port = mach_host_self() + defer { _ = mach_port_deallocate(mach_task_self_, port) } + let kr = withUnsafeMutablePointer(to: &stats) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + host_statistics64(port, HOST_VM_INFO64, $0, &count) + } + } + guard kr == KERN_SUCCESS else { return nil } + + let pageSize = UInt64(vm_kernel_page_size) + let pages = { (n: natural_t) -> Int64 in Int64(UInt64(n) * pageSize) } + let total = Int64(ProcessInfo.processInfo.physicalMemory) + let used = pages(stats.active_count) + pages(stats.inactive_count) + pages(stats.speculative_count) + + pages(stats.wire_count) + pages(stats.compressor_page_count) + - pages(stats.purgeable_count) - pages(stats.external_page_count) + return (max(0, min(used, total)), total) + } + + /// 磁盘吞吐:IOBlockStorageDriver 注册表的读写字节计数差值 + private func diskRates() -> (read: Int64, write: Int64)? { + var iterator: io_iterator_t = 0 + guard IOServiceGetMatchingServices(kIOMainPortDefault, IOServiceMatching("IOBlockStorageDriver"), &iterator) == KERN_SUCCESS else { return nil } + defer { IOObjectRelease(iterator) } + + var totalRead: UInt64 = 0 + var totalWrite: UInt64 = 0 + var found = false + while true { + let service = IOIteratorNext(iterator) + guard service != 0 else { break } + defer { IOObjectRelease(service) } + guard let prop = IORegistryEntryCreateCFProperty(service, Self.diskStatsKey as CFString, kCFAllocatorDefault, 0)? + .takeRetainedValue() as? [String: Any] else { continue } + if let read = Self.u64(prop[Self.diskReadKey]) { totalRead += read; found = true } + if let write = Self.u64(prop[Self.diskWriteKey]) { totalWrite += write; found = true } + } + guard found else { return nil } + + let now = Date() + defer { lastDisk = (totalRead, totalWrite, now) } + guard let last = lastDisk else { return nil } + let elapsed = now.timeIntervalSince(last.at) + guard elapsed > 0.1 else { return nil } + // 计数器被重置(睡眠等)时放弃本轮 + guard totalRead >= last.read, totalWrite >= last.write else { return nil } + let read = Double(totalRead - last.read) / elapsed + let write = Double(totalWrite - last.write) / elapsed + return (Int64(clamping: Int(read)), Int64(clamping: Int(write))) + } + + /// 网络吞吐:NET_RT_IFLIST2 各非 loopback 接口的字节计数差值(逐接口防新接口突刺) + private func netRates() -> (down: Int64, up: Int64)? { + var mib: [Int32] = [CTL_NET, PF_ROUTE, 0, 0, NET_RT_IFLIST2, 0] + var len: size_t = 0 + guard sysctl(&mib, 6, nil, &len, nil, 0) == 0, len > 0 else { return nil } + var buf = [UInt8](repeating: 0, count: len) + guard sysctl(&mib, 6, &buf, &len, nil, 0) == 0 else { return nil } + + var seen: [UInt32: (inBytes: Int64, outBytes: Int64)] = [:] + buf.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in + var offset = 0 + while offset + MemoryLayout.size <= len { + let hdr = raw.loadUnaligned(fromByteOffset: offset, as: if_msghdr.self) + let msgLen = Int(hdr.ifm_msglen) + guard msgLen > 0, offset + msgLen <= len else { break } + if hdr.ifm_type == RTM_IFINFO2 { + let msg = raw.loadUnaligned(fromByteOffset: offset, as: if_msghdr2.self) + if msg.ifm_data.ifi_type != UInt32(IFT_LOOP) { + seen[UInt32(msg.ifm_index)] = (Int64(bitPattern: msg.ifm_data.ifi_ibytes), Int64(bitPattern: msg.ifm_data.ifi_obytes)) + } + } + offset += msgLen + } + } + guard !seen.isEmpty else { return nil } + + let now = Date() + defer { + lastNet = seen + lastNetAt = now + } + guard let lastAt = lastNetAt, !lastNet.isEmpty else { return nil } + let elapsed = now.timeIntervalSince(lastAt) + guard elapsed > 0.1 else { return nil } + var down: Int64 = 0 + var up: Int64 = 0 + for (idx, cur) in seen { + guard let prev = lastNet[idx] else { continue } + let din = cur.inBytes - prev.inBytes + let dout = cur.outBytes - prev.outBytes + if din > 0 { down += din } + if dout > 0 { up += dout } + } + return (Int64(Double(down) / elapsed), Int64(Double(up) / elapsed)) + } + + /// GPU 占用:IOAccelerator 的 PerformanceStatistics 瞬时利用率 + private func gpuPercent() -> Double? { + var iterator: io_iterator_t = 0 + guard IOServiceGetMatchingServices(kIOMainPortDefault, IOServiceMatching("IOAccelerator"), &iterator) == KERN_SUCCESS else { return nil } + defer { IOObjectRelease(iterator) } + + var best: Double? + while true { + let service = IOIteratorNext(iterator) + guard service != 0 else { break } + defer { IOObjectRelease(service) } + guard let props = IORegistryEntryCreateCFProperty(service, "PerformanceStatistics" as CFString, kCFAllocatorDefault, 0)? + .takeRetainedValue() as? [String: Any] else { continue } + let values = ["Device Utilization %", "GPU Activity(%)"].compactMap { key -> Double? in + (props[key] as? Double).map { min(100, max(0, $0)) } + } + if let v = values.first { + best = max(best ?? 0, v) + } + } + return best + } + + /// IOKit 计数器经 NSNumber.stringValue 转 UInt64(兼容 128 位值) + private static func u64(_ value: Any?) -> UInt64? { + guard let number = value as? NSNumber else { return nil } + return UInt64(number.stringValue) + } + + /// IOBlockStorageDriver.h 的统计键(Swift 未导入这些 C 宏,直接给字符串值) + private static let diskStatsKey = "Statistics" + private static let diskReadKey = "Bytes (Read)" + private static let diskWriteKey = "Bytes (Write)" + + // MARK: - 展示格式化(供视图共用) + + static func formatBps(_ v: Int64?) -> String { + guard let v else { return "—" } + let bps = Double(v) + if bps < 1024 { return "\(Int(bps)) B/s" } + if bps < 1_048_576 { return String(format: "%.1f KB/s", bps / 1024) } + if bps < 1_073_741_824 { return String(format: "%.1f MB/s", bps / 1_048_576) } + return String(format: "%.2f GB/s", bps / 1_073_741_824) + } + + static func formatBytes(_ v: Int64) -> String { + let gb = Double(v) / 1_073_741_824 + if gb >= 10 { return String(format: "%.0f GB", gb) } + return String(format: "%.1f GB", gb) + } +} diff --git a/Zeroflow/Views/NotchPanelView.swift b/Zeroflow/Views/NotchPanelView.swift index 4fb7b58..5175268 100644 --- a/Zeroflow/Views/NotchPanelView.swift +++ b/Zeroflow/Views/NotchPanelView.swift @@ -3,7 +3,7 @@ import AppKit import EventKit /// 灵动岛面板的一条日程(主线程由 EKEvent 转换生成) -struct NotchEventItem: Identifiable { +struct NotchEventItem: Identifiable, Equatable { let id: String let title: String let timeText: String @@ -224,27 +224,170 @@ final class NotchCalendarViewModel: ObservableObject { } } -/// 灵动岛面板内容:日历列(月历 + 当日日程)贴左,预留右侧空白区; -/// 无内容模块时显示「添加显示内容」引导。宽度 = 面板宽,日历列固定原尺寸不随宽度拉伸。 +/// 灵动岛面板内容:已启用的内容模块横排(日历 / 系统资源 / 正在播放,最多同时 2 个), +/// 无模块时显示「添加显示内容」引导。双卡片时两卡片各占一半宽,面板高由内容驱动 +/// (上限 = 设置最大高);系统资源卡拉伸到驱动卡高度、行均匀分布;日历日程列表 +/// 装得下完整展开、装不下在剩余空间滚动(文字不缩放),仅固定部分也超限时才整体缩小; +/// 各模块包圆角半透明卡片背景。 struct NotchContentView: View { @ObservedObject var model: NotchCalendarViewModel + /// 显示宽度:nil = 测量模式(卡片自然尺寸);正式展示由 NotchPanel 传入面板尺寸 + var displayWidth: CGFloat? = nil + /// 显示高度:正式展示时传入面板高 + var displayHeight: CGFloat? = nil + /// 测量单模块尺寸时指定要渲染的模块;nil = 按设置显示 + var modulesOverride: [Module]? = nil + /// 测量双卡片拉伸高度时指定列宽;nil = 按自然宽/拉伸规则 + var columnWidthOverride: CGFloat? = nil + /// 测量系统资源卡拉伸高度时指定列高;nil = 按自然高 + var columnHeightOverride: CGFloat? = nil + /// 测量日历卡日程列表的最大高度;nil = 不限制(渲染时靠面板高度约束滚动) + var dayListMaxHeight: CGFloat? = nil + /// 测量日历卡固定部分高度时收起日程列表(只留选中日标题) + var collapseDayEvents = false + /// 各卡片的自然尺寸(含卡片内边距,由 NotchPanel 测量传入,顺序对齐可见模块) + var cardNaturals: [CGSize] = [] + /// 卡片内容等比缩放系数(≤1,由 NotchPanel 按槽位与自然尺寸计算) + var cardScale: CGFloat = 1 + + @ObservedObject private var resources = SystemResourceMonitor.shared + @ObservedObject private var music = NowPlayingMonitor.shared private let accent = Color.accentColor private static let columns = Array(repeating: GridItem(.flexible(), spacing: 0), count: 7) - /// 日历列宽度(面板加宽前的内容宽,保持原比例) + /// 面板内容内边距(测量与尺寸决策共用) + static let outerHPadding: CGFloat = 20 + static let outerVPadding: CGFloat = 12 + /// 卡片样式:间距 / 内边距 / 圆角(测量与尺寸决策共用) + static let cardGap: CGFloat = 14 + static let cardPadding: CGFloat = 14 + private static let cardRadius: CGFloat = 14 + /// 日历列自然宽度(单卡片测量/展示基准) private static let calendarColumnWidth: CGFloat = 332 + /// 系统资源列自然宽度 + private static let resourcesColumnWidth: CGFloat = 224 + /// 正在播放列自然宽度 + private static let musicColumnWidth: CGFloat = 224 + + /// 内容模块(固定顺序,最多同时显示 2 个;超出部分在设置页禁用开关) + enum Module: CaseIterable { + case calendar + case resources + case music + } var body: some View { - // 单模块居中;后续多模块在 HStack 内并排、整体居中 - HStack(alignment: .top, spacing: 16) { - if SettingsStore.shared.notchShowCalendar { - calendarColumn - } else { - emptyState + HStack(alignment: .top, spacing: Self.cardGap) { + ForEach(Array(visibleModules.enumerated()), id: \.element) { index, module in + moduleCard(module, index: index) } } - .padding(.vertical, 14) - .frame(width: NotchPanel.preferredWidth, alignment: .center) + .padding(.horizontal, Self.outerHPadding) + .padding(.vertical, Self.outerVPadding) + .frame(width: displayWidth, height: displayHeight, alignment: .center) + } + + /// 面板显示的模块(固定顺序,最多 2 个) + static var orderedVisibleModules: [Module] { + var enabled: [Module] = [] + if SettingsStore.shared.notchShowCalendar { enabled.append(.calendar) } + if SettingsStore.shared.notchShowResources { enabled.append(.resources) } + if SettingsStore.shared.notchShowMusic { enabled.append(.music) } + return Array(enabled.prefix(2)) + } + + /// 面板显示的模块数(面板尺寸决策用) + static var visibleModuleCount: Int { orderedVisibleModules.count } + + /// 测量单个模块卡片的尺寸(含卡片内边距,不含面板内边距)。 + /// columnWidth 非 nil 时以该列宽测量(双卡片按拉伸宽测高); + /// columnHeight 非 nil 时以该列高测量(资源卡拉伸到驱动卡高度); + /// dayListMaxHeight / collapseDayEvents 用于日历卡的全高/固定高两档测量。 + static func measureCardSize(index: Int, + columnWidth: CGFloat?, + columnHeight: CGFloat? = nil, + dayListMaxHeight: CGFloat? = nil, + collapseDayEvents: Bool = false, + model: NotchCalendarViewModel) -> CGSize { + let modules = orderedVisibleModules + guard index < modules.count else { return .zero } + let view = NotchContentView(model: model, + modulesOverride: [modules[index]], + columnWidthOverride: columnWidth, + columnHeightOverride: columnHeight, + dayListMaxHeight: dayListMaxHeight, + collapseDayEvents: collapseDayEvents) + let host = NSHostingView(rootView: view) + host.layoutSubtreeIfNeeded() + return CGSize(width: max(1, host.fittingSize.width - outerHPadding * 2), + height: max(1, host.fittingSize.height - outerVPadding * 2)) + } + + private var visibleModules: [Module] { modulesOverride ?? Self.orderedVisibleModules } + + /// 双卡片布局:两卡片各占一半宽 + private var isDualLayout: Bool { visibleModules.count == 2 } + + /// 正式渲染且双卡片时,列从固定自然宽改为填满卡片槽 + private var stretchForRender: Bool { displayWidth != nil && isDualLayout && columnWidthOverride == nil } + + /// 测量模式(卡片自适应内容自然尺寸) + private var isMeasuring: Bool { displayWidth == nil } + + private func renderColumnWidth(_ natural: CGFloat) -> CGFloat? { + columnWidthOverride ?? (stretchForRender ? nil : natural) + } + + @ViewBuilder private func moduleView(_ module: Module) -> some View { + switch module { + case .calendar: calendarColumn + case .resources: resourcesColumn + case .music: musicColumn + } + } + + @ViewBuilder private func moduleCard(_ module: Module, index: Int) -> some View { + if isMeasuring { + moduleView(module) + .padding(Self.cardPadding) + .background(cardBackground) + } else { + renderedCard(module, index: index) + } + } + + /// 正式渲染:卡片背景填满槽位,内容按 cardScale 等比缩放后贴顶 + /// (缩放锚点 = 顶部,留白只出现在底部),高度取卡片的渲染目标高 + /// (完整高或压缩到槽高的滚动高,由 NotchPanel.computeLayout 决定) + private func renderedCard(_ module: Module, index: Int) -> some View { + let natural = index < cardNaturals.count ? cardNaturals[index] : CGSize(width: 100, height: 100) + let slot = cardSlotSize + return moduleView(module) + .padding(Self.cardPadding) + .frame(width: isDualLayout ? slot.width : natural.width, + height: natural.height, alignment: .top) + .scaleEffect(cardScale, anchor: .top) + .frame(width: slot.width, height: slot.height, alignment: .top) + .background(cardBackground) + } + + /// 卡片槽位:双卡片各占一半宽,单卡片占满面板内容区 + private var cardSlotSize: CGSize { + let innerW = (displayWidth ?? 0) - Self.outerHPadding * 2 + let innerH = max(1, (displayHeight ?? 0) - Self.outerVPadding * 2) + if isDualLayout { + return CGSize(width: max(1, (innerW - Self.cardGap) / 2), height: innerH) + } + return CGSize(width: max(1, innerW), height: innerH) + } + + private var cardBackground: some View { + RoundedRectangle(cornerRadius: Self.cardRadius, style: .continuous) + .fill(Color.primary.opacity(0.07)) + .overlay { + RoundedRectangle(cornerRadius: Self.cardRadius, style: .continuous) + .strokeBorder(Color.primary.opacity(0.06), lineWidth: 0.5) + } } private var calendarColumn: some View { @@ -255,7 +398,8 @@ struct NotchContentView: View { Divider() daySection } - .frame(width: Self.calendarColumnWidth) + .frame(width: renderColumnWidth(Self.calendarColumnWidth)) + .frame(maxWidth: stretchForRender ? .infinity : nil) } private var emptyState: some View { @@ -272,6 +416,265 @@ struct NotchContentView: View { .padding(.vertical, 16) } + // MARK: - 系统资源模块 + + /// 资源行拉伸条件:测量时指定列高,或正式渲染双卡片(卡片槽高由面板决定) + private var stretchResourceRows: Bool { columnHeightOverride != nil || stretchForRender } + + private var resourcesColumn: some View { + VStack(alignment: .leading, spacing: 8) { + moduleHeader("cpu", L10n.tr("系统资源")) + ForEach(enabledResourceItems, id: \.self) { item in + resourceRow(item) + // 拉伸时各行平分剩余高度(标题固定顶部),行内内容垂直居中 + .frame(maxHeight: stretchResourceRows ? .infinity : nil) + } + } + .frame(width: renderColumnWidth(Self.resourcesColumnWidth)) + .frame(height: columnHeightOverride) + .frame(maxWidth: stretchForRender ? .infinity : nil, alignment: .leading) + } + + private func resourceRow(_ item: NotchResourceItem) -> some View { + HStack(spacing: 8) { + Text(item.title) + .font(.system(size: 12)) + .foregroundColor(.secondary) + // 百分比项(CPU/内存/GPU)= 用量进度条:灰轨道=总量 100%,彩色填充=当前用量; + // 速率项(磁盘/网络)无固定满格,保留迷你趋势图 + if isPercentItem(item) { + ResourceProgressBar(progress: normalizedProgress(item)) + .frame(maxWidth: .infinity) + .frame(height: 16) + } else { + ResourceSparkline(normalizedValues: normalizedHistory(item)) + .frame(maxWidth: .infinity) + .frame(height: 16) + } + Text(resourceValue(item)) + .font(.system(size: 12, weight: .medium)) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.8) + } + } + + /// 百分比项(CPU/内存/GPU)用进度条展示用量占比;磁盘/网络为速率无固定满格,走趋势图 + private func isPercentItem(_ item: NotchResourceItem) -> Bool { + switch item { + case .cpu, .memory, .gpu: return true + case .disk, .network: return false + } + } + + /// 百分比项当前用量 0–1;nil = 暂不可用(只显示空轨道,数值列同步显示「—」) + private func normalizedProgress(_ item: NotchResourceItem) -> Double? { + let s = resources.sample + switch item { + case .cpu: return s.cpuPercent.map { min(1, max(0, $0 / 100)) } + case .gpu: return s.gpuPercent.map { min(1, max(0, $0 / 100)) } + case .memory: + guard let used = s.memUsedBytes, let total = s.memTotalBytes, total > 0 else { return nil } + return min(1, max(0, Double(used) / Double(total))) + case .disk, .network: return nil + } + } + + /// 迷你趋势图数据(现仅速率项 磁盘/网络 消费):速率项按窗口内峰值归一化(峰值下限 200KB/s 防除零全矮) + private func normalizedHistory(_ item: NotchResourceItem) -> [Double] { + func normalize(_ values: [Double], scale: Double) -> [Double] { + values.map { min(1, max(0, $0 / scale)) } + } + let h = resources.history + switch item { + case .cpu: return normalize(h.cpu, scale: 100) + case .memory: return normalize(h.memory, scale: 100) + case .gpu: return normalize(h.gpu, scale: 100) + case .disk: + return normalize(h.disk, scale: max(h.disk.max() ?? 0, 200_000)) + case .network: + return normalize(h.network, scale: max(h.network.max() ?? 0, 200_000)) + } + } + + private var enabledResourceItems: [NotchResourceItem] { + SettingsStore.shared.notchResourceItems.compactMap { NotchResourceItem(rawValue: $0) } + } + + private func resourceValue(_ item: NotchResourceItem) -> String { + let s = resources.sample + switch item { + case .cpu: + return s.cpuPercent.map { String(format: "%.0f%%", $0) } ?? "—" + case .memory: + guard let used = s.memUsedBytes else { return "—" } + guard let total = s.memTotalBytes else { return SystemResourceMonitor.formatBytes(used) } + return "\(SystemResourceMonitor.formatBytes(used)) / \(SystemResourceMonitor.formatBytes(total))" + case .disk: + let text = "\(L10n.tr("读")) \(SystemResourceMonitor.formatBps(s.diskReadBps)) · \(L10n.tr("写")) \(SystemResourceMonitor.formatBps(s.diskWriteBps))" + return (s.diskReadBps == nil && s.diskWriteBps == nil) ? "—" : text + case .network: + let text = "↓ \(SystemResourceMonitor.formatBps(s.netDownBps)) · ↑ \(SystemResourceMonitor.formatBps(s.netUpBps))" + return (s.netDownBps == nil && s.netUpBps == nil) ? "—" : text + case .gpu: + return s.gpuPercent.map { String(format: "%.0f%%", $0) } ?? "—" + } + } + + // MARK: - 正在播放模块 + + private var musicColumn: some View { + VStack(alignment: .leading, spacing: 8) { + moduleHeader("music.note", L10n.tr("正在播放")) + if let info = music.nowPlaying { + musicBody(info) + } else { + Text(L10n.tr("没有正在播放的音乐")) + .font(.system(size: 12)) + .foregroundColor(.secondary) + .padding(.top, 2) + } + } + .frame(width: renderColumnWidth(Self.musicColumnWidth)) + .frame(maxWidth: stretchForRender ? .infinity : nil, alignment: .leading) + } + + private func musicBody(_ info: NowPlayingInfo) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + appIcon(info) + VStack(alignment: .leading, spacing: 1) { + Text(info.appName ?? L10n.tr("未知应用")) + .font(.system(size: 11)) + .foregroundColor(.secondary) + .lineLimit(1) + HStack(spacing: 4) { + Circle() + .fill(info.isPlaying ? Color.green : Color.secondary.opacity(0.5)) + .frame(width: 5, height: 5) + Text(info.isPlaying ? L10n.tr("正在播放") : L10n.tr("已暂停")) + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + } + } + + Text(info.title) + .font(.system(size: 13, weight: .semibold)) + .lineLimit(2) + + musicSubtitle(info) + + if let progress = musicProgress(info) { + VStack(alignment: .leading, spacing: 3) { + GeometryReader { proxy in + ZStack(alignment: .leading) { + Capsule().fill(Color.primary.opacity(0.15)) + Capsule().fill(Color.accentColor) + .frame(width: max(2, proxy.size.width * progress)) + } + } + .frame(height: 3) + HStack { + Text(Self.timeText(info.elapsed ?? 0)) + Spacer() + Text(Self.timeText(info.duration ?? 0)) + } + .font(.system(size: 10)) + .monospacedDigit() + .foregroundColor(.secondary) + } + } + + HStack(spacing: 18) { + musicButton("backward.fill", L10n.tr("上一首"), disabled: !music.controlsAvailable) { + NowPlayingMonitor.shared.send(.previous) + } + musicButton(info.isPlaying ? "pause.fill" : "play.fill", + info.isPlaying ? L10n.tr("暂停") : L10n.tr("播放"), + disabled: !music.controlsAvailable) { + NowPlayingMonitor.shared.send(.togglePlay) + } + musicButton("forward.fill", L10n.tr("下一首"), disabled: !music.controlsAvailable) { + NowPlayingMonitor.shared.send(.next) + } + } + .frame(maxWidth: .infinity) + } + } + + @ViewBuilder private func musicSubtitle(_ info: NowPlayingInfo) -> some View { + let parts = [info.artist, info.album].compactMap { $0 } + if !parts.isEmpty { + Text(parts.joined(separator: " · ")) + .font(.system(size: 11)) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + + private func musicProgress(_ info: NowPlayingInfo) -> Double? { + guard let duration = info.duration, duration > 0, let elapsed = info.elapsed else { return nil } + return min(1, max(0, elapsed / duration)) + } + + private static func timeText(_ t: Double) -> String { + let total = Int(max(0, t)) + let h = total / 3600, m = (total % 3600) / 60, s = total % 60 + if h > 0 { return String(format: "%d:%02d:%02d", h, m, s) } + return String(format: "%d:%02d", m, s) + } + + private func appIcon(_ info: NowPlayingInfo) -> some View { + Group { + if let bid = info.appBundleID, let icon = Self.icon(forBundleID: bid) { + Image(nsImage: icon) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + Image(systemName: "music.note") + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + } + .frame(width: 22, height: 22) + } + + private static let iconCache = NSCache() + + private static func icon(forBundleID bid: String) -> NSImage? { + if let cached = iconCache.object(forKey: bid as NSString) { return cached } + guard let app = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == bid }), + let icon = app.icon else { return nil } + iconCache.setObject(icon, forKey: bid as NSString) + return icon + } + + private func musicButton(_ symbol: String, _ label: String, disabled: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: symbol) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(disabled ? Color.secondary.opacity(0.4) : .primary) + .frame(width: 26, height: 22) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(disabled) + .help(label) + } + + private func moduleHeader(_ icon: String, _ title: String) -> some View { + HStack(spacing: 6) { + Image(systemName: icon) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.secondary) + Text(title) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.secondary) + Spacer() + } + } + private var header: some View { HStack(spacing: 8) { Button(L10n.tr("今天")) { @@ -358,54 +761,126 @@ struct NotchContentView: View { } } + /// 选中日详情:标题 + 授权提示/日程列表。列表高度弹性——面板装得下就完整展开, + /// 装不下就压缩到剩余空间内滚动(文字不缩放);collapseDayEvents 仅用于测量 + /// 日历固定部分高度(收起列表,只留标题) @ViewBuilder private var daySection: some View { VStack(alignment: .leading, spacing: 8) { Text(model.selectedTitle) .font(.system(size: 12, weight: .medium)) .foregroundColor(.secondary) - if !model.isAuthorized && model.authStatus != .notDetermined { - VStack(alignment: .leading, spacing: 8) { - Text(L10n.tr("未获准访问日历,无法显示日程。")) - .font(.system(size: 12)) - .foregroundColor(.secondary) - Button(L10n.tr("前往设置")) { - if let url = URL(string: "x-apple.systempreferences:com.apple.preference.privacy?Privacy_Calendars") { - NSWorkspace.shared.open(url) + if !collapseDayEvents { + if !model.isAuthorized && model.authStatus != .notDetermined { + VStack(alignment: .leading, spacing: 8) { + Text(L10n.tr("未获准访问日历,无法显示日程。")) + .font(.system(size: 12)) + .foregroundColor(.secondary) + Button(L10n.tr("前往设置")) { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.privacy?Privacy_Calendars") { + NSWorkspace.shared.open(url) + } } + .controlSize(.small) } - .controlSize(.small) - } - } else if model.dayEvents.isEmpty { - Text(L10n.tr("没有日程")) - .font(.system(size: 12)) - .foregroundColor(.secondary) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.vertical, 6) - } else { - ScrollView(.vertical, showsIndicators: false) { - VStack(alignment: .leading, spacing: 8) { - ForEach(model.dayEvents) { item in - HStack(alignment: .top, spacing: 8) { - RoundedRectangle(cornerRadius: 1.5) - .fill(Color(nsColor: item.color)) - .frame(width: 3, height: 28) - VStack(alignment: .leading, spacing: 2) { - Text(item.title) - .font(.system(size: 12, weight: .medium)) - .lineLimit(2) - Text(item.timeText) - .font(.system(size: 11)) - .foregroundColor(.secondary) + } else if model.dayEvents.isEmpty { + Text(L10n.tr("没有日程")) + .font(.system(size: 12)) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 6) + } else { + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 8) { + ForEach(model.dayEvents) { item in + HStack(alignment: .top, spacing: 8) { + RoundedRectangle(cornerRadius: 1.5) + .fill(Color(nsColor: item.color)) + .frame(width: 3, height: 28) + VStack(alignment: .leading, spacing: 2) { + Text(item.title) + .font(.system(size: 12, weight: .medium)) + .lineLimit(2) + Text(item.timeText) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } } } } + .padding(.vertical, 1) } - .padding(.vertical, 1) + // 高度不设上限:测量时由 dayListMaxHeight 兜底,渲染时靠面板高度约束 + // 压缩到剩余空间内滚动(文字保持原大) + .frame(maxHeight: dayListMaxHeight) + } + } + } + } +} + +/// 百分比资源项(CPU/内存/GPU)的用量进度条:灰色轨道 = 总量 100%(恒显完整宽度), +/// 彩色填充 = 当前用量(左→右),一眼区分已用/未用 +private struct ResourceProgressBar: View { + let progress: Double? + + var body: some View { + GeometryReader { proxy in + ZStack(alignment: .leading) { + Capsule() + .fill(Color.primary.opacity(0.25)) + if let progress, progress > 0 { + Capsule() + .fill(Color.accentColor) + .frame(width: proxy.size.width * min(1, max(0, progress))) } - .frame(maxHeight: 240) } } + .frame(height: 5) + } +} + +/// 资源行的迷你趋势图:无坐标轴的单色渐变面积线(数据已归一化到 0–1) +private struct ResourceSparkline: View { + let normalizedValues: [Double] + + var body: some View { + GeometryReader { proxy in + let w = proxy.size.width + let h = proxy.size.height + if normalizedValues.count >= 2, w > 0, h > 2 { + let step = w / CGFloat(normalizedValues.count - 1) + let points = normalizedValues.enumerated().map { pair -> CGPoint in + CGPoint(x: CGFloat(pair.offset) * step, + y: (1 - CGFloat(min(1, max(0, pair.element)))) * (h - 2) + 1) + } + ZStack { + areaPath(points: points, bottom: h) + .fill(LinearGradient(colors: [Color.accentColor.opacity(0.35), + Color.accentColor.opacity(0.05)], + startPoint: .top, endPoint: .bottom)) + linePath(points: points) + .stroke(Color.accentColor, + style: StrokeStyle(lineWidth: 1, lineCap: .round, lineJoin: .round)) + } + } + } + } + + private func linePath(points: [CGPoint]) -> Path { + var path = Path() + path.addLines(points) + return path + } + + private func areaPath(points: [CGPoint], bottom: CGFloat) -> Path { + var path = Path() + guard let first = points.first, let last = points.last else { return path } + path.move(to: CGPoint(x: first.x, y: bottom)) + path.addLines(points) + path.addLine(to: CGPoint(x: last.x, y: bottom)) + path.closeSubpath() + return path } } @@ -413,12 +888,14 @@ struct NotchContentView: View { /// 悬停/点击/Esc 判定全部由 NotchHotspotController 的 listen-only tap 完成, /// 本面板只负责内容展示(不成为 key,无键盘交互)。 final class NotchPanel: NSPanel { - static let preferredWidth: CGFloat = 560 + /// 单卡片时面板最小宽/高(呼吸边距下限);最大宽高由设置页配置 + static let minWidth: CGFloat = 420 + static let minHeight: CGFloat = 120 /// 圆角半径(≈Mac 屏幕圆角;屏幕圆角无公开 API 可查询) static let cornerRadius: CGFloat = 16 init() { - super.init(contentRect: NSRect(x: 0, y: 0, width: Self.preferredWidth, height: 200), + super.init(contentRect: NSRect(x: 0, y: 0, width: Self.minWidth, height: 200), styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false) self.level = .screenSaver self.isOpaque = false @@ -435,28 +912,114 @@ final class NotchPanel: NSPanel { override var canBecomeKey: Bool { false } override var canBecomeMain: Bool { false } + /// 面板布局决策结果(present 与 refit 共用) + private struct PanelLayout { + let width: CGFloat + let height: CGFloat + let naturals: [CGSize] + let scale: CGFloat + } + + /// 尺寸决策: + /// - 宽:双卡片 = 设置最大宽(两卡片各占一半);单卡片 = 内容自然宽(上下限钳制); + /// - 高:内容驱动(最高卡片的渲染目标高 + 面板内边距),上限钳制到设置最大高/屏幕。 + /// 日历卡日程列表装得下就完整展开;装不下压到槽高、列表在剩余空间滚动(文字不缩放); + /// 仅固定部分(收起日程)也超槽时才整体等比缩小兕底; + /// - 双卡片时系统资源卡拉伸到驱动卡目标高测量(渲染时行均匀分布填满卡片)。 + private static func computeLayout(screen: NSScreen, model: NotchCalendarViewModel) -> PanelLayout { + let maxW = min(CGFloat(SettingsStore.shared.notchMaxWidth), screen.frame.width - 40) + let maxH = min(CGFloat(SettingsStore.shared.notchMaxHeight), screen.frame.height - 80) + let outerH = NotchContentView.outerHPadding + let outerV = NotchContentView.outerVPadding + let modules = NotchContentView.orderedVisibleModules + let slotH = max(1, maxH - outerV * 2) + + // 日历卡两档测量:全高(日程全展开,测量上限放大)/ 固定高(收起日程列表) + func calendarHeights(index: Int, columnWidth: CGFloat?) -> (full: CGFloat, fixed: CGFloat, naturalWidth: CGFloat) { + let full = NotchContentView.measureCardSize(index: index, columnWidth: columnWidth, + dayListMaxHeight: 2000, model: model) + let fixed = NotchContentView.measureCardSize(index: index, columnWidth: columnWidth, + collapseDayEvents: true, model: model) + return (full.height, fixed.height, full.width) + } + // 渲染目标高:日程装得下用全高;装不下压到槽高(日程滚动);固定部分也超槽则保持固定高(缩小兕底) + func renderTarget(full: CGFloat, fixed: CGFloat) -> CGFloat { + fixed >= slotH ? max(1, fixed) : max(1, min(full, slotH)) + } + + if modules.count >= 2 { + let slotW = (maxW - outerH * 2 - NotchContentView.cardGap) / 2 + let columnW = slotW - NotchContentView.cardPadding * 2 + var naturals = Array(repeating: CGSize.zero, count: modules.count) + var targets: [CGFloat] = Array(repeating: 0, count: modules.count) + // 先测非资源卡(日历/正在播放)目标高,面板高度由其中最高者驱动 + for (i, module) in modules.enumerated() where module != .resources { + if module == .calendar { + let h = calendarHeights(index: i, columnWidth: columnW) + targets[i] = renderTarget(full: h.full, fixed: h.fixed) + naturals[i] = CGSize(width: h.naturalWidth, height: targets[i]) + } else { + let natural = NotchContentView.measureCardSize(index: i, columnWidth: columnW, model: model) + targets[i] = renderTarget(full: natural.height, fixed: natural.height) + naturals[i] = CGSize(width: natural.width, height: targets[i]) + } + } + // 资源卡拉伸到驱动卡目标高测量,渲染时与驱动卡等高 + for (i, module) in modules.enumerated() where module == .resources { + let natural = NotchContentView.measureCardSize(index: i, columnWidth: columnW, + columnHeight: max(1, targets.max() ?? slotH), + model: model) + targets[i] = natural.height + naturals[i] = natural + } + let scale = min(1, slotH / max(1, targets.max() ?? slotH)) + let height = Self.clamp(scale * max(1, targets.max() ?? slotH) + outerV * 2, + min(Self.minHeight, maxH), maxH) + return PanelLayout(width: maxW, height: height, naturals: naturals, scale: scale) + } + if modules.count == 1 { + let naturalWidth: CGFloat + let target: CGFloat + if modules[0] == .calendar { + let h = calendarHeights(index: 0, columnWidth: nil) + naturalWidth = h.naturalWidth + target = renderTarget(full: h.full, fixed: h.fixed) + } else { + let natural = NotchContentView.measureCardSize(index: 0, columnWidth: nil, model: model) + naturalWidth = natural.width + target = renderTarget(full: natural.height, fixed: natural.height) + } + let scale = min(1, (maxW - outerH * 2) / max(1, naturalWidth), slotH / target) + let width = Self.clamp(naturalWidth * scale + outerH * 2, min(Self.minWidth, maxW), maxW) + let height = Self.clamp(target * scale + outerV * 2, min(Self.minHeight, maxH), maxH) + return PanelLayout(width: width, height: height, + naturals: [CGSize(width: naturalWidth, height: target)], scale: scale) + } + return PanelLayout(width: Self.clamp(outerH * 2, min(Self.minWidth, maxW), maxW), + height: Self.clamp(outerV * 2, min(Self.minHeight, maxH), maxH), + naturals: [], scale: 1) + } + /// 依据刘海热区(主屏左上原点坐标)重建内容、测尺寸、定位并滑入显示。 /// 面板顶边贴刘海下边缘(热区竖向范围 = 刘海竖向范围)、水平居中刘海; - /// 每次 present 重建 contentView(模型重置到今天,同时取最新透明度/内容设置)。 + /// 每次 present 重建 contentView(模型重置到今天,同时取最新内容设置)。 + /// 尺寸决策见 computeLayout(日程异步加载完成后的高度修正走 refit)。 func present(hotspotTL: CGRect, screen: NSScreen, model: NotchCalendarViewModel) { - // 用临时视图测量内容高度(正式 contentView 由玻璃/磨砂容器包裹) - let measuring = NSHostingView(rootView: NotchContentView(model: model)) - measuring.layoutSubtreeIfNeeded() - var size = measuring.fittingSize - size = NSSize(width: Self.preferredWidth, - height: max(120, min(ceil(size.height), screen.frame.height - 80))) - contentView = makeContentView(model: model) - setContentSize(size) - - let x = Self.clamp(hotspotTL.midX - size.width / 2, - screen.frame.minX + 8, screen.frame.maxX - size.width - 8) + let layout = Self.computeLayout(screen: screen, model: model) + contentView = makeContentView(model: model, width: layout.width, height: layout.height, + cardNaturals: layout.naturals, scale: layout.scale) + setContentSize(NSSize(width: layout.width, height: layout.height)) + + let x = Self.clamp(hotspotTL.midX - layout.width / 2, + screen.frame.minX + 8, screen.frame.maxX - layout.width - 8) // 热区 TL 坐标最大 y = 刘海下边缘;转回 Cocoa 坐标作为面板顶边 let unionMaxY = NSScreen.screens.reduce(CGRect.null) { $0.union($1.frame) }.maxY - let finalOrigin = NSPoint(x: x, y: unionMaxY - hotspotTL.maxY - size.height) + let finalOrigin = NSPoint(x: x, y: unionMaxY - hotspotTL.maxY - layout.height) // 从刘海后方 12pt 处滑入;NSWindow 只有 frame 可动画(animator().setFrameOrigin 无效, // 面板会停在起始位置),必须用 animator().setFrame(_:display:) - let startFrame = NSRect(origin: NSPoint(x: finalOrigin.x, y: finalOrigin.y + 12), size: size) - let finalFrame = NSRect(origin: finalOrigin, size: size) + let startFrame = NSRect(origin: NSPoint(x: finalOrigin.x, y: finalOrigin.y + 12), + size: NSSize(width: layout.width, height: layout.height)) + let finalFrame = NSRect(origin: finalOrigin, size: NSSize(width: layout.width, height: layout.height)) setFrame(startFrame, display: false) alphaValue = 0 orderFront(nil) @@ -469,17 +1032,40 @@ final class NotchPanel: NSPanel { } } + /// 日程加载/切换后重算布局并调整面板:顶边贴刘海不动(向下生长/收缩),带短动画。 + /// contentView 按新尺寸重建;尺寸不变时跳过。 + func refit(hotspotTL: CGRect, screen: NSScreen, model: NotchCalendarViewModel) { + let layout = Self.computeLayout(screen: screen, model: model) + guard abs(layout.height - frame.height) > 0.5 || abs(layout.width - frame.width) > 0.5 else { return } + contentView = makeContentView(model: model, width: layout.width, height: layout.height, + cardNaturals: layout.naturals, scale: layout.scale) + let x = Self.clamp(hotspotTL.midX - layout.width / 2, + screen.frame.minX + 8, screen.frame.maxX - layout.width - 8) + let unionMaxY = NSScreen.screens.reduce(CGRect.null) { $0.union($1.frame) }.maxY + let target = NSRect(origin: NSPoint(x: x, y: unionMaxY - hotspotTL.maxY - layout.height), + size: NSSize(width: layout.width, height: layout.height)) + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.15 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + animator().setFrame(target, display: true) + } + invalidateShadow() + } + /// 液态玻璃背景(macOS 26+)或磨砂底(旧系统)包裹内容,圆角≈屏幕圆角。 - /// 透明度经玻璃染色深浅控制——直接对玻璃设 alphaValue 会连内容一起变透明。 - private func makeContentView(model: NotchCalendarViewModel) -> NSView { - let hosting = NSHostingView(rootView: NotchContentView(model: model)) + /// 不透明度不再可调:使用系统默认外观(玻璃不染色、磨砂不降 alpha)。 + private func makeContentView(model: NotchCalendarViewModel, width: CGFloat, height: CGFloat, + cardNaturals: [CGSize], scale: CGFloat) -> NSView { + let hosting = NSHostingView(rootView: NotchContentView(model: model, + displayWidth: width, + displayHeight: height, + cardNaturals: cardNaturals, + cardScale: scale)) hosting.translatesAutoresizingMaskIntoConstraints = false - let opacity = Double(SettingsStore.shared.notchPanelOpacity) / 100 if #available(macOS 26.0, *) { let glass = NSGlassEffectView() glass.cornerRadius = Self.cornerRadius glass.style = .regular - glass.tintColor = NSColor.black.withAlphaComponent(opacity * 0.35) glass.contentView = hosting pinEdges(of: hosting, to: glass) return glass @@ -492,7 +1078,6 @@ final class NotchPanel: NSPanel { frost.layer?.cornerRadius = Self.cornerRadius frost.layer?.cornerCurve = .continuous frost.layer?.masksToBounds = true - frost.alphaValue = opacity frost.addSubview(hosting) pinEdges(of: hosting, to: frost) return frost diff --git a/Zeroflow/Views/SettingsView.swift b/Zeroflow/Views/SettingsView.swift index b3ccebd..182a9ea 100644 --- a/Zeroflow/Views/SettingsView.swift +++ b/Zeroflow/Views/SettingsView.swift @@ -290,6 +290,22 @@ struct SettingsView: View { // MARK: - 灵动岛 + /// 已勾选 2 个内容模块时禁用未勾选的开关(面板最多同时显示 2 个) + private var isMaxNotchModulesSelected: Bool { + [store.notchShowCalendar, store.notchShowResources, store.notchShowMusic].filter { $0 }.count >= 2 + } + + /// 增减资源项并写回(Store 端按固定顺序过滤) + private func toggleNotchResourceItem(_ item: NotchResourceItem, on: Bool) { + var items = store.notchResourceItems + if on { + if !items.contains(item.rawValue) { items.append(item.rawValue) } + } else { + items.removeAll { $0 == item.rawValue } + } + store.notchResourceItems = items + } + private var notchTab: some View { Form { Section { @@ -319,36 +335,62 @@ struct SettingsView: View { set: { store.notchHoverDelayMs = Int($0) } ), in: 100...1000, step: 50) } + VStack(alignment: .leading, spacing: 6) { + LabeledContent(L10n.tr("最大宽度")) { + Text("\(store.notchMaxWidth) pt") + .monospacedDigit() + .foregroundColor(.secondary) + } + Slider(value: Binding( + get: { Double(store.notchMaxWidth) }, + set: { store.notchMaxWidth = Int($0) } + ), in: 420...1200, step: 10) + } + VStack(alignment: .leading, spacing: 6) { + LabeledContent(L10n.tr("最大高度")) { + Text("\(store.notchMaxHeight) pt") + .monospacedDigit() + .foregroundColor(.secondary) + } + Slider(value: Binding( + get: { Double(store.notchMaxHeight) }, + set: { store.notchMaxHeight = Int($0) } + ), in: 240...1000, step: 20) + } } header: { Text(L10n.tr("选项")) } footer: { - Text(L10n.tr("鼠标停留在刘海区域多久后弹出面板;单击刘海区域可立即弹出。")) + Text(L10n.tr("鼠标停留在刘海区域多久后弹出面板;单击刘海区域可立即弹出。显示 2 个内容模块时面板固定为最大尺寸(两卡片各占一半宽度),单模块时按内容收缩;内容超出面板时整体等比缩小以完整显示。")) } Section { Toggle(L10n.tr("日历"), isOn: $store.notchShowCalendar) + .disabled(isMaxNotchModulesSelected && !store.notchShowCalendar) + Toggle(L10n.tr("系统资源"), isOn: $store.notchShowResources) + .disabled(isMaxNotchModulesSelected && !store.notchShowResources) + Toggle(L10n.tr("正在播放"), isOn: $store.notchShowMusic) + .disabled(isMaxNotchModulesSelected && !store.notchShowMusic) } header: { Text(L10n.tr("显示内容")) } footer: { - Text(L10n.tr("弹出面板中显示的内容模块。全部关闭时,面板仍会弹出并提示添加显示内容。")) + Text(L10n.tr("弹出面板中显示的内容模块,最多同时显示 2 个。全部关闭时,面板仍会弹出并提示添加显示内容。")) } - Section { - VStack(alignment: .leading, spacing: 6) { - LabeledContent(L10n.tr("不透明度")) { - Text("\(store.notchPanelOpacity)%") - .monospacedDigit() - .foregroundColor(.secondary) + if store.notchShowResources { + Section { + ForEach(NotchResourceItem.allCases) { item in + Toggle(isOn: Binding( + get: { store.notchResourceItems.contains(item.rawValue) }, + set: { on in toggleNotchResourceItem(item, on: on) } + )) { + Text(item.title) + } } - Slider(value: Binding( - get: { Double(store.notchPanelOpacity) }, - set: { store.notchPanelOpacity = Int($0) } - ), in: 30...100, step: 5) + } header: { + Text(L10n.tr("资源项")) + } footer: { + Text(L10n.tr("系统资源模块中显示的项目;CPU/内存/GPU 显示用量进度条(灰色为总量),磁盘/网络数值旁的曲线为最近约 1 分钟的速率趋势。")) } - } header: { - Text(L10n.tr("面板")) - } footer: { - Text(L10n.tr("macOS 26 及以上使用液态玻璃面板背景,旧系统为系统磨砂;不透明度数值越小越透明。重新弹出面板后生效。")) } Section {