Skip to content
Merged

Dev #13

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
2 changes: 1 addition & 1 deletion 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,宽:双模块取设置最大宽(`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/睡眠/屏幕变化收起;无刘海或未授权则静默不生效。
- **灵动岛**:`NotchHotspotController`(listen-only CGEventTap + idle→pending→visible 状态机;由公开 API 推导刘海热区:`safeAreaInsets.top` + `auxiliaryTopLeftArea`/`auxiliaryTopRightArea` 间隙)→ `NotchPanel`(非激活 NSPanel,固定深色 HUD,弹出/收起 = 从刘海矩形以弹簧动画「生长」展开/缩回(NotchSpringAnimator 主线程定时器积分,frame 0.5s 响应/阻尼比 0.72,全程不透明无透明度渐变,形状在刘海形药丸↔长方形间弹簧互变,打断无缝续接),宽:双模块取设置最大宽(`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
15 changes: 15 additions & 0 deletions PITFALLS_DEV.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,18 @@
- **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。

## DEV-021: macOS 窗口 frame 弹簧动画无公开 API,需主线程定时器积分实现
- **Pitfall:** 灵动岛面板要做 iPhone 式「从刘海长出/缩回」的弹簧动画,`NSAnimationContext` 只支持贝塞尔 `CAMediaTimingFunction`,`CASpringTimingFunction` 在 macOS 不公开,窗口 frame 无法用系统弹簧曲线动画。
- **Cause:** NSWindow 经 animator() 可动画的属性走 Core Animation 但只暴露贝塞尔曲线;UIKit 语义的弹簧参数(response/dampingFraction)动画必须自己积分。
- **Prevention:** 主线程 120Hz `Timer` 对弹簧微分方程半隐式欧拉积分(k=(2π/response)²,c=2ζ√k)后直接 `setFrame(_:display:)`(dt 远小于稳定域 2/ω 不会发散);打断续接需用各分量当前值与速度重建弹簧、作废旧动画的 completion(否则收起的 orderOut 会在重展开后误触发);Timer 必须加进 `.common` mode,否则面板按钮的事件跟踪期间动画冻结。参考 `NotchPanelView.swift` 的 `NotchSpringAnimator`。

## DEV-022: 窗口弹簧动画里 alpha 走独立快速淡入,会把「生长」动画感知成「从一侧滑入」
- **Pitfall:** 灵动岛面板做「从刘海长出」弹簧动画,frame 用弹簧、alpha 用独立的 0.15s 快速淡入:用户看到的是半透明面板从一侧快速滑到位 + 渐显,完全不是「从刘海逐渐长大」,方向感全错。
- **Cause:** 弹簧位移前 ~15% 时间就走完 85% 路程,独立淡入结束时可变大半已展开;肉眼能捕捉的只剩后段残留位移( leading 边缘扫过),感知成滑入。弹性动画里透明度与几何分开调,时序感知必然错位。
- **Prevention:** 「刘海生长/缩回」动画全程不透明(深色 HUD 起点盖住黑色刘海,出现/消失都无感,形状渐变本身就是全部视觉),透明度完全不参与动画;若别的窗口必须有淡入淡出,alpha 须由几何进度推导而非独立时长,打断时以当前值续接。

## DEV-023: 打断在途窗口动画时,严禁 setContentSize/setFrame 直接改窗口尺寸
- **Pitfall:** 面板「收起弹簧未播完→再次弹出」走打断路径时调用了 `setContentSize`(最终尺寸):窗口几何绕过弹簧直接跳变(尺寸瞬间到位、按原点锚定另一侧瞬移),随后位置再弹簧滑到位 → 用户看到「全尺寸面板从屏幕一侧平移到中间」,与弹簧动画预期完全不符。
- **Cause:** `setContentSize`/`setFrame` 是瞬时几何赋值;打断场景窗口处于中间态,直接改尺寸等于把「在途位移」换成「瞬移 + 平移收尾」。`setContentSize` 还按窗口原点(左下)锚定缩放,另一侧边缘瞬移,平移感更明显。
- **Prevention:** 打断动画只做一件事——弹簧目标重定向(以当前值/速度续接);任何路径都不要在动画在途时用 `setContentSize`/`setFrame` 改尺寸。`present` 的起始定位(setFrame 刘海矩形)只放在「窗口不可见」的全新展开分支。参考 `NotchPanel.present`。
32 changes: 20 additions & 12 deletions Zeroflow/Services/NotchHotspotController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import ApplicationServices
import Combine
import CoreGraphics

/// 灵动岛:悬停/单击刘海区域,从刘海向下弹出磨砂面板(v1 内容 = 日历月视图)。
/// 灵动岛:悬停/单击刘海区域,磨砂面板以 iOS 风格弹簧从刘海「生长」展开/缩回(v1 内容 = 日历月视图)。
///
/// 实现要点(结构仿 `DockHoverPreviewController`):
/// - listen-only CGEventTap 监听 mouseMoved / mouseDown / keyDown,绝不吞事件:
Expand Down Expand Up @@ -64,8 +64,8 @@ final class NotchHotspotController {
private var panelShown = false
private var notchScreen: NSScreen?
private var dayEventsCancellable: AnyCancellable?
/// 最近一次 present 的热区/屏幕(日程变化后 refit 复用)
private var lastPresented: (screen: NSScreen, hotspotTL: CGRect)?
/// 最近一次 present 的热区/刘海矩形/屏幕(日程变化后 refit 复用;收起动画缩回刘海用 notchTL)
private var lastPresented: (screen: NSScreen, hotspotTL: CGRect, notchTL: CGRect)?

// MARK: - 基础设施

Expand Down Expand Up @@ -446,18 +446,24 @@ final class NotchHotspotController {
ZSLog("Notch: hide (\(reason))")
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.panel?.orderOut(nil)
let notchTL = self.lastPresented?.notchTL
self.panelShown = false
self.dayEventsCancellable = nil
self.lastPresented = nil
SystemResourceMonitor.shared.setActive(false)
NowPlayingMonitor.shared.setActive(false)
// 视觉收起交给面板:弹簧缩回刘海再 orderOut;期间再 present 会无缝续接展开
if let notchTL {
self.panel?.dismiss(notchTL: notchTL)
} else {
self.panel?.orderOut(nil)
}
}
}

// MARK: - 展示(主线程)

/// 悬停延时到期 / 单击热区:主线程重建面板内容并滑入显示
/// 悬停延时到期 / 单击热区:主线程重建面板内容并以弹簧动画从刘海展开显示
private func presentPanel(showGen gen: Int) {
lock.lock()
guard gen == showGen, phase == .pending else {
Expand All @@ -480,13 +486,15 @@ final class NotchHotspotController {
let viewModel = ensureModel()
viewModel.reset()
let panel = ensurePanel()
panel.present(hotspotTL: detected.hotspotTL, screen: detected.screen, model: viewModel)
panel.present(hotspotTL: detected.hotspotTL, notchTL: detected.notchTL,
screen: detected.screen, model: viewModel)
panelShown = true
lastPresented = (detected.screen, detected.hotspotTL)
lastPresented = (detected.screen, detected.hotspotTL, detected.notchTL)
startMonitors()
startDayEventsRefit(model: viewModel)
lock.lock()
panelFrameTL = toTopLeftCoords(panel.frame)
// 用弹簧目标 frame,而非 present() 返回时刻的瞬时 frame(动画刚从刘海开始)
panelFrameTL = toTopLeftCoords(panel.targetFrame)
lock.unlock()
if Self.debug {
ZSLog("Notch: show gen=\(gen) hotspot=\(detected.hotspotTL) panel=\(panel.frame)")
Expand Down Expand Up @@ -535,15 +543,15 @@ final class NotchHotspotController {
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)
panelFrameTL = toTopLeftCoords(panel.targetFrame)
lock.unlock()
}

// MARK: - 刘海探测(主线程)

/// 探测刘海屏并计算热区。无刘海返回 nil;ZEROFLOW_NOTCH_FAKE=1 时在主屏合成伪刘海。
@discardableResult
private func refreshHotspot() -> (screen: NSScreen, hotspotTL: CGRect)? {
private func refreshHotspot() -> (screen: NSScreen, hotspotTL: CGRect, notchTL: CGRect)? {
let detected = detectNotch()
notchScreen = detected?.screen
lock.lock()
Expand All @@ -552,7 +560,7 @@ final class NotchHotspotController {
return detected
}

private func detectNotch() -> (screen: NSScreen, hotspotTL: CGRect)? {
private func detectNotch() -> (screen: NSScreen, hotspotTL: CGRect, notchTL: CGRect)? {
let unionMaxY = NSScreen.screens.reduce(CGRect.null) { $0.union($1.frame) }.maxY
for screen in NSScreen.screens {
let barHeight: CGFloat
Expand All @@ -578,7 +586,7 @@ final class NotchHotspotController {
if Self.debug {
ZSLog("Notch: detect screen=\(screen.localizedName) notchTL=\(notchTL) hotspot=\(hotspot)")
}
return (screen, hotspot)
return (screen, hotspotTL: hotspot, notchTL: notchTL)
}
return nil
}
Expand Down
Loading
Loading