Skip to content
Merged

Dev #12

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)。

Expand Down Expand Up @@ -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(探针验证) |
Expand All @@ -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` | 快捷键录制控件(截图页用,录制期间挂起全局热键) |
Expand Down
10 changes: 10 additions & 0 deletions PITFALLS_DEV.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<vm_statistics64_data_t>.size / MemoryLayout<integer_t>.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。
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@
- 灵动岛

## TODO list
- [ ] 灵动岛中显示cpu,内存等使用情况
- [ ] 灵动岛中显示音乐
- [x] 灵动岛中显示cpu,内存等使用情况
- [x] 灵动岛中显示音乐
- [ ] 灵动岛显示宠物?如果太耗内存就不做
- [ ] 通过`添加右键菜单`按钮统一实现多个功能,现有右键菜单移除,全部通过上述按钮实现
- [x] 使用开发者账号构建app

## 下载安装

Expand Down
57 changes: 50 additions & 7 deletions Zeroflow/Models/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
}

Expand Down
Loading
Loading