diff --git a/AGENTS.md b/AGENTS.md index 327b9c5..3021208 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,91 +1,156 @@ # AGENTS.md -macOS 截图菜单栏工具(截图 + 画线/矩形/圆框/文字气泡/马赛克 + 撤销重做 + Dock 单击最小化 + 内置 ⌘⇥ 窗口缩略图切换器)。SwiftUI + AppKit,无第三方依赖,零测试/零 lint 目标。文档为中文: `README.md`(用户说明)、`需求文档.md`(需求与验收, 含第 12 章「内置 ⌘⇥ 窗口缩略图切换器」完整需求)、本文件(AI/开发者构建与架构速查)。 +This file provides guidance to latent (an AI coding agent) when working with code in this repository. -## 构建与部署 +macOS 截图菜单栏工具(截图 + 画线/矩形/圆框/文字气泡/马赛克标注 + 撤销重做 + Dock 单击最小化 + Dock 悬停预览 + 内置 ⌘⇥ 窗口缩略图切换器 + 访达右键菜单 + 中/英/日国际化)。SwiftUI + AppKit,无第三方依赖,零测试/零 lint 目标。前置:macOS 14+、Xcode 16+。踩坑记录见 `PITFALLS_DEV.md`(dev-test skill 维护)。 -前置: macOS 14+、Xcode 16+。没有测试/lint 命令,唯一验证 = `xcodebuild` 通过 + 手动运行。 +## 常用命令 + +### 构建(xcodebuild 通过 = 唯一验证方式,无测试/lint) ```bash -# Debug(需 -allowProvisioningUpdates:App Group 自动配置新 profile) +# 本地 Debug(必须带 -allowProvisioningUpdates:FinderSync 扩展的 App Group 需自动生成 provisioning) xcodebuild -project Zeroflow.xcodeproj -scheme Zeroflow -configuration Debug -derivedDataPath build -allowProvisioningUpdates build -# 产物一般在 build/Build/Products/Debug/Zeroflow.app open build/Build/Products/Debug/Zeroflow.app + +# CI 等价命令(ad-hoc 签名,无需团队证书/provisioning) +xcodebuild -project Zeroflow.xcodeproj -scheme Zeroflow -configuration Release -derivedDataPath build \ + CODE_SIGN_IDENTITY=- CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM= build ``` -```bash -# Release -/Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild \ - -project Zeroflow.xcodeproj \ - -scheme Zeroflow \ - -configuration Release \ - -derivedDataPath build \ - -allowProvisioningUpdates \ - build -# 产物一般在 build/Build/Products/Release/Zeroflow.app -open build/Build/Products/Release/Zeroflow.app -``` -- **构建必须带 `-allowProvisioningUpdates`**: FinderSync 扩展 target 带 App Group(`8NHN73Q43T.com.zeroflow.app`,Team 前缀)与沙盒授权,需要 Xcode 自动生成/更新带该 App Group 的 provisioning profile;不带会直接 `No profiles ... were found` BUILD FAILED。 -- **FinderSync 扩展必须沙盒化**: pkd(pluginkit)会静默拒绝非沙盒的扩展(`com.apple.security.app-sandbox` 缺失时 `pluginkit -m` 永远不出现、`-a` 返回 0 却无任何日志)。扩展与主 app 通过 App Group(`8NHN73Q43T.com.zeroflow.app`)共享设置:主 app 非沙盒但带 `com.apple.security.application-groups`,双方直接读写 group container 里的 `finder-sync-settings.plist` 文件——**不要用 `UserDefaults(suiteName:)` 读 App Group**(沙盒扩展里读不到,cfprefsd 报 `Container: (null)`)。登记/启用一键脚本:`scripts/install-findersync.sh [Debug|Release]`,内含两个致命坑的规避:① **pkd 内存会僵住**,新扩展 `pluginkit -a` 返回 0 却查不到,必须 `killall pkd`;② **LS 残留的旧路径插件记录**(如 build 目录)会让 pkd 解析到无效路径而拒绝,必须先 `lsregister -u` 所有旧路径的 app 及其 appex,再只注册 /Applications。诊断用扩展的 os_log(`log show --predicate 'subsystem == "com.zeroflow.app.finderSync"'`)。**右键菜单项被 Finder 硬编码在菜单底部,无 API 调整位置**;**macOS Sequoia/Tahoe 陈旧扩展 bug**: app 重建/覆盖安装后扩展显示已启用但右键菜单不出现,手动关开一次即恢复——已程序化复现(`pluginkit -e ignore`→`-e use` election 循环):脚本 `install-findersync.sh` 内建(支持 `--reload-only`),主 app 启动自愈走 `Zeroflow/Services/FinderSyncReloader.swift`,设置页有「刷新右键菜单扩展」按钮。完整开发流程见 skill `findersync-workflow`。 -- **FinderSync 扩展四个沙盒/协议坑**(功能 Debug 时踩过): - ① **真实家目录**: 沙盒进程里 `FileManager.default.homeDirectoryForCurrentUser` 返回的是容器目录(`~/Library/Containers//Data`),直接当 `directoryURLs` 会让 Finder 完全不监控任何用户路径、`menu(for:)` 永不触发。要用 `URL(fileURLWithPath: "/Users/\(NSUserName())")` 拼真实家目录。 - ② **`NSMenuItem.representedObject` 跨 XPC 不保留**: 点击动作回调里 `sender` 拿不到自定义 `representedObject`(会 `createFile: invalid sender`)。目标目录必须在动作里用 `FIFinderSyncController.default().targetedURL()`/`selectedItemURLs()` 重新推导(`targetDirectory()`),不要依赖 sender。 - ③ **右键目录无写权限**: `files.user-selected.read-write` 不覆盖 FinderSync 右键目标(`deny file-write-create`),写文件必须加 `com.apple.security.temporary-exception.files.home-relative-path.read-write`(值 `["/"]`,覆盖整个家目录;不入 App Store 可接受)。 - ④ **子进程继承沙盒**: 扩展里 `Process` 拉起的命令仍被沙盒约束,终端/pty 类命令(如 `wezterm start`)会 EPERM 秒退。自定义命令不能在扩展里执行:扩展把请求写进 group container 的 `finder-command-*.plist` 并广播分布式通知(沙盒发通知不能带 userInfo),由非沙盒的主 app 代为执行(`Zeroflow/Services/FinderCommandRunner.swift`,启动时也会消化积压请求,超过 30s 的请求丢弃;扩展侧主 app 未运行时会把它拉起)。 -- **产物路径不可靠**: Debug 产物可能落在 `build/Build/Intermediates.noindex/ArchiveIntermediates/.../Applications/Zeroflow.app`(`cp -R` 复制这个可能是坏符号链接)。用 `find /Build -name "Zeroflow.app"` 定位,复制用 `cp -R -L` 解引用,复制前 `rm -rf` 目标。 -- **dist/ 已被 gitignore**,是发布副本。部署流程: `rm -rf dist/Zeroflow.app && cp -R -L <找到的app> dist/ && pkill -f Zeroflow.app`,再 `open dist/Zeroflow.app`。 -- 给 `-derivedDataPath` 的目录若无写权限,构建会直接 `BUILD FAILED`(0644/只读目录),换一个可写路径。 -- **新增 .swift 文件自动纳入编译**: 工程用 `PBXFileSystemSynchronizedRootGroup`,不要手动改 pbxproj 加文件。 -- **新增 target 需手改 pbxproj**: 唯一的例外是 FinderSync 扩展 target(`FinderSyncExtension/`)。它也是 `PBXFileSystemSynchronizedRootGroup` 自动纳入源码,但 target 本身、Embed App Extensions 阶段、target dependency、FinderSync.framework 链接(App 用于 `FIFinderSyncController.isExtensionEnabled` 状态)都要在 pbxproj 里手工维护,改错会直接 `BUILD FAILED`。 - -## 架构要点 - -- 入口 `Zeroflow/ZeroflowApp.swift`(`@main` 手动建 `NSApplication` + `MenuBarController`); 菜单栏 `MenuBar/MenuBarController.swift`; 服务层 `Services/`: - - `GlobalHotkeyManager`: Carbon 全局热键,受截图总开关 `screenshotEnabled` 控制(`reapply()` 里判断)。 - - `ScreenCapture`: ScreenCaptureKit 截图; 按 displayID 缓存 `SCContentFilter`(屏幕参数变化通知里 `invalidateFilterCache()`)。 - - `CaptureCoordinator`: 权限→遮罩→捕获→编辑页 的协调器,`isActive` 防重入。 - - `DockClickMinimizer`: Dock 单击最小化(CGEventTap + AX),受 `dockClickMinimize` 开关控制。 - - `CommandTabSwitcher`: 内置 ⌘⇥ 窗口缩略图切换器(CGEventTap + AX),受 `cmdTabSwitcherEnabled` 开关控制; 会话中 ⇥/⇧⇥/方向键移动选择, 悬停卡片可退出/关闭/最小化/全屏(`WindowOps`, 本 app 窗口走主线程 NSWindow)。 - - `DockHoverPreviewController` + `Views/DockPreviewPanel`: Dock 悬停窗口预览(listen-only CGEventTap + Dock AX 探测独立拷贝自 DockClickMinimizer,受 `dockPreviewEnabled` 开关控制); 窗口来源与切换器同源(`WindowList.enumerate()` 按 bundleID 过滤, 含最小化瓦片), idle→pending→visible 状态机 + generation 作废, 面板非激活 NSPanel, 卡片点击激活(`WindowActivator`)/悬停关闭(`WindowOps`)。 - - `WindowList`: 窗口枚举(CGWindowList 公开 API)+ 幽灵窗口过滤 + 窗口级 MRU 排序 + 无窗口 app 占位卡(开关 `windowSwitcherShowWindowlessApps`, 排最后); `isMinimized` 用 SLS tags 位 60(对齐 AltTab, 不可用 `!isOnscreen` 充当——跨全屏 Space 窗口同样 offscreen, 误判会致同 app 全屏互切永远落回当前 Space); `CGSWindowServer`: SkyLight/CGS 私有 API dlsym 桥(SLS 批量枚举 + 可见/全量成员列表 + Space 拓扑 + 跨 Space 前台激活 `_SLPSSetFrontProcessWithOptions`(可选符号缺失退回公开 API); `PhantomWindowDetector`: 幽灵判定(对齐 AltTab cgsVerdict, 含 AX subrole 兜底); `WindowActivityTracker`: AX 焦点通知 + app 激活/Space 切换补记维护窗口级 MRU; `WindowThumbnailer`: SkyLight 私有 API(dlsym)抓缩略图 + 缓存/节流/降级; `WindowActivator`: SkyLight SLP 指定窗口置前(SLP→makeKey→AX main/raise, 探针验证对同 app 前台/非前台均生效) + AX 还原/前置/激活(本 app 窗口走主线程 makeKeyAndOrderFront, AX 后台线程会崩)。元素缺失时先 remote-token 暴力枚举造 AX 元素再 raise(`AXWindow.elementByBruteForce`, 失败才 app.activate 收尾); `AXWindow.element(for:)` 只信 `_AXUIElementGetWindow` 精确匹配、禁止 bounds 模糊兜底(同 app 两个全屏窗口 bounds 相同会误配); 启动需预热 CGS 连接。切换器模块完整需求见 `需求文档.md` 第 12 章。 - - `AccessibilityPermission` / `ScreenRecordingPermission`: 辅助功能 / 屏幕录制权限检测与申请。 - - `FinderCommandRunner`: 执行 Finder 扩展转交的「访达自定义命令」(请求文件 + 分布式通知 IPC, 非沙盒代执行, 见上方坑位④); 扩展侧在 `FinderSyncExtension/FinderSync.swift` 的 `CustomCommandRunner`。 - - `ZSLog`: 统一日志(stderr + `/tmp/zeroflow.log`)。 -- 编辑页就是 `Views/EditorView.swift`(单一 1300+ 行文件)。工具: `select / pencil(画线) / rect(矩形) / ellipse(圆框) / bubble(文字气泡) / mosaic(马赛克 半径 12/18/28)`。新增其他工具/图形也先加在这个文件。 - -### 编辑画布模型(`Models/EditDocument.swift`) - -- 所有元素存**图像坐标**(非视图坐标); `EditorView` 用 `displayRect`/`scaleFactor` + `toView`/`toImage`(及 Rect 版本)换算。改元素绘制/命中时必须走这两个函数。 -- `CanvasElement` 有 4 个 case: stroke / bubble / shape / mosaic,相等比较逐字段。 -- 只有 bubble / shape 可选中、移动、缩放、删除(Backspace/Delete); stroke、mosaic 提交后不可再选。 -- `EditDocument` 持有撤销/重做栈(上限 50),任何修改前先 `snapshotBeforeChange()`。 -- **BubbleElement / ShapeElement 的 id 不可换**: 重设位置/大小/文字时要用 `init(id:...)` 沿用原 id,否则编辑覆盖层、拖动状态(`elementDrag`)、`selectedElementID` 按 id 索引的簿记全部失效。 - -### 文字气泡的交互 - -- 创建气泡后立即进入文字编辑(自动把 `text` 缓冲区置空); 文字编辑覆盖层 `bubbleEditor` 是内缩 8pt 的白底 `TextEditor`,边缘/四角仍可拖动缩放。 -- **双击**气泡进入文字编辑(单击只选中)。 -- TextEditor 必须显式 `.foregroundColor(.black)`,否则深色模式下黑底白字串字看不见; 顺带 `.environment(\.colorScheme, .light)`。 -- 键盘(EditorView 内 NSEvent monitor): `⌘Z`/`⇧⌘Z`/`⌘S`/`⌘C`/`⌘L`(画线)/`T`(标注); Backspace/Delete(51/117) 删除选中气泡/形状(文字编辑中不生效); Esc(53) 文字编辑中=提交并退出编辑,否则关闭窗口。 -- 导出(`exportedImage()`)用 AppKit 按原图尺寸重绘,不依赖视图层级,改绘制逻辑记得同步这里。 - -### 马赛克 - -- `drawMosaic`(画布)与 `drawMosaicIntoCanvas`(导出)共用 `MosaicOverlayCache`(整图最近邻像素化叠加图按半径缓存一次),临时笔划/已提交笔划/导出共用同一张图,保证三处样式一致。改动时保持这个一致性。 - -### 设置(`Models/SettingsStore.swift` + `Views/SettingsView.swift`) - -- 三个标签页: 通用(开机自启)/ 截图(启用截图功能、快捷键、屏幕录制权限、保存)/ Dock(单击最小化开关 + 辅助功能权限引导)/ 切换(⌘⇥ 切换器)/ Dock 预览(悬停预览开关 + 悬停延时)/ 右键菜单(FinderSync)。 -- `screenshotEnabled`(默认关)是总开关: 关闭时全局热键不注册、菜单「立即截图」置灰、`CaptureCoordinator.startCapture` 直接忽略。 -- `dockClickMinimize` 开关变化由 `DockClickMinimizer` 监听通知启停 CGEventTap; `launchAtLogin` 变化经 `SMAppService.mainApp` 注册/注销(带防重入 + 注册失败回滚)。 -- 所有 UserDefaults key 与默认值见 `需求文档.md` FR-12.9。 - -## 调试辅助环境变量(进程环境) + +- dist/ 已 gitignore,是发布副本:`rm -rf dist/Zeroflow.app && cp -R -L dist/ && pkill -f Zeroflow.app && open dist/Zeroflow.app`。 +- 构建相关坑(provisioning、产物路径漂移、只读 derivedDataPath)见 `PITFALLS_DEV.md`。 + +### CI / 发布 + +- `.github/workflows/build.yml`:push/PR 到 main = Release 编译检查 + app zip artifact;推 `v*` tag = Release 构建 + create-dmg(背景 `dmg_bg.png`)+ 自动发 GitHub Release。 +- 发布 = `./create_release_tag.sh [vX.Y.Z]`:切 main、计算下一 patch tag 并推送,tag 触发 CI 出 DMG 发 Release。 + +### FinderSync 扩展登记 + +`scripts/install-findersync.sh [Debug|Release]`(支持 `--reload-only` 仅重载)。pkd/LS 相关坑见 `PITFALLS_DEV.md`;诊断用扩展 os_log:`log show --predicate 'subsystem == "com.zeroflow.app.finderSync"'`。 + +### 调试环境变量(进程环境) | 变量 | 作用 | |---|---| | `ZEROFLOW_AUTO_CAPTURE=1` | 启动 1.5s 后自动触发一次截图流程 | -| `ZEROFLOW_EDITOR_DEBUG=1` | 启动 1.5s 后直接打开编辑页(合成网格图),绕过截图 | +| `ZEROFLOW_EDITOR_DEBUG=1` | 启动 1.5s 后直接打开编辑页(合成网格图,绕过截图) | +| `ZEROFLOW_AUTO_FOCUS=` | 启动 2s 后用 WindowActivator 聚焦指定窗口 | | `ZEROFLOW_DOCK_PROBE=1` | 启动 1s 后 dump Dock 的 AX 布局到日志 | -| `ZEROFLOW_SWITCHER_DEBUG=1` | 启动 1s 后 dump 一次窗口列表(数量、按 app 分组、是否含最小化窗口)+ 打印「⌘⇥ tap 是否已安装」到日志 | -| `ZEROFLOW_DOCKPREVIEW_DEBUG=1` | 打印 Dock 悬停预览的命中/展示/隐藏决策与 Dock 项缓存重建日志 | +| `ZEROFLOW_SWITCHER_DEBUG=1` | 启动 1s 后 dump 窗口列表 + ⌘⇥ tap 安装状态,并输出事件级日志 | +| `ZEROFLOW_DOCKPREVIEW_DEBUG=1` | 打印 Dock 悬停预览的命中/展示/隐藏决策日志 | +| `ZEROFLOW_NOTCH_DEBUG=1` | 打印灵动岛刘海探测/弹出/收起决策日志 | +| `ZEROFLOW_NOTCH_FAKE=1` | 在主屏合成伪刘海(宽 180/高 24),便于无刘海机器调试面板 | | `ZEROFLOW_FAKE_PERMISSION=1` | 跳过真实权限检测(视为已授权) | + +## 项目架构 + +SwiftUI 视图 + AppKit/CG/AX(含私有 API dlsym 桥)服务层 + UserDefaults/App Group 设置。依赖方向:`ZeroflowApp` → `MenuBarController` / Views → Services → 系统框架;`SettingsStore` 全局单例,Views 与 Services 经 Combine 通知订阅设置变化。 + +### 五条模块链 + +- **截图链**:`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/睡眠/屏幕变化收起;无刘海或未授权则静默不生效。 +- **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`)。 + +### 编辑页不变量(`EditorView` + `EditDocument`) + +- 所有元素存**图像坐标**;与视图坐标互换必须走 `toView`/`toImage`(及 Rect 版本),不得直接用视图坐标。 +- `CanvasElement` 4 case:stroke / bubble / shape / mosaic;仅 bubble / shape 可选中、移动、缩放、删除。 +- 任何修改前先 `snapshotBeforeChange()`(撤销/重做栈上限 50);**BubbleElement / ShapeElement 的 id 不可换**,重设状态须 `init(id:)` 沿用原 id,否则选中/拖动簿记全失效。 +- 马赛克:画布绘制、临时笔划、导出共用 `MosaicOverlayCache`(按半径缓存像素化叠加图),改绘制时保持三处一致。 +- `exportedImage()` 用 AppKit 按原图尺寸重绘、不依赖视图层级,改绘制逻辑必须同步它。 + +### 设置与工程 + +- 全部 UserDefaults key 与默认值集中在 `SettingsStore.Keys` 枚举;`screenshotEnabled` 关 = 热键不注册 + 菜单置灰 + 捕获忽略。 +- **新增 .swift 文件自动纳入编译**(工程用 `PBXFileSystemSynchronizedRootGroup`),不要手改 pbxproj 加文件;新增 target 才需手改 pbxproj(当前仅 FinderSyncExtension 一个扩展 target,已配好)。 + +## 目录索引 + +### `Zeroflow/`(主 app target,非沙盒) + +| 文件 | 职责 | +|---|---| +| `ZeroflowApp.swift` | `@main` 入口:手动建 `NSApplication`,装配服务与 `MenuBarController` | + +### `Zeroflow/MenuBar/` + +| 文件 | 职责 | +|---|---| +| `MenuBarController.swift` | 菜单栏图标与菜单(立即截图/最近截图/设置/退出);debug 环境变量在此触发 | + +### `Zeroflow/Models/` + +| 文件 | 职责 | +|---|---| +| `EditDocument.swift` | 画布文档模型:`CanvasElement` 4 类元素、图像坐标、撤销/重做栈 | +| `L10n.swift` | 语言枚举(跟随系统/zh-Hans/en/ja)+ `L10n.tr` 查 `Translations.json`,缺 key 回退中文原文 | +| `SettingsStore.swift` | 全部设置项:`Keys` 常量与默认值、`@Published` 属性、App Group plist 桥接 FinderSync | +| `ShortcutKey.swift` | 快捷键值类型与三组默认键(截图 ⌘⇧S;切换器 ⌘⇥ / ⌥`) | + +### `Zeroflow/Services/` + +| 文件 | 职责 | +|---|---| +| `AccessibilityPermission.swift` | 辅助功能权限检测与引导跳系统设置 | +| `AXWindow.swift` | AX 窗口元素查找:`_AXUIElementGetWindow` 精确匹配(禁 bounds 模糊兜底)、remote-token 暴力枚举 | +| `CalendarService.swift` | EventKit 门面:授权状态/请求(macOS 14 全量)、区间日程查询(未授权返回 [],后台线程调用) | +| `CaptureCoordinator.swift` | 截图流程协调:权限→遮罩→捕获→编辑页,`isActive` 防重入 | +| `CGSWindowServer.swift` | SkyLight/CGS 私有 API dlsym 桥:SLS 批量枚举、Space 拓扑、跨 Space 激活 | +| `CommandTabSwitcher.swift` | ⌘⇥ 切换器:CGEventTap 拦截 + 会话键盘(⇥/⇧⇥/方向键)+ 悬停卡片操作 | +| `DockClickMinimizer.swift` | Dock 单击最小化:CGEventTap + Dock AX 命中 | +| `DockHoverPreviewController.swift` | Dock 悬停预览:listen-only CGEventTap + idle→pending→visible 状态机(generation 作废) | +| `FinderCommandRunner.swift` | 代执行扩展转交的自定义命令:请求文件 + 分布式通知 IPC,启动消化积压(>30s 丢弃) | +| `FinderSyncReloader.swift` | 陈旧扩展自愈:pluginkit ignore→use election 循环重载(Sequoia/Tahoe bug) | +| `GlobalHotkeyManager.swift` | Carbon 全局热键注册/注销,受 `screenshotEnabled` 总开关控制 | +| `NotchHotspotController.swift` | 灵动岛:listen-only CGEventTap 悬停/单击刘海热区弹出面板,状态机 + 收起重弹抑制 + 全局隐藏触发(切 Space/睡眠/屏幕变化) | +| `PhantomWindowDetector.swift` | 幽灵窗口判定(对齐 AltTab cgsVerdict + AX subrole 兜底) | +| `ScreenCapture.swift` | ScreenCaptureKit 截屏,按 displayID 缓存 `SCContentFilter` | +| `WindowActivator.swift` | 跨 app 窗口激活:还原最小化 → SLP 置前 → makeKey → AX main/raise(探针验证) | +| `WindowActivityTracker.swift` | 窗口级 MRU:AX 焦点通知 + app 激活/Space 切换补记 | +| `WindowList.swift` | CGWindowList 枚举 + 幽灵过滤 + MRU 排序 + 无窗口 app 占位卡;`SwitcherWindow` 模型 | +| `WindowOps.swift` | 卡片窗口操作(退出/关闭/最小化/全屏);本 app 窗口必须走主线程 NSWindow(AX 后台线程会崩) | +| `WindowThumbnailer.swift` | SkyLight `CGSHWCaptureWindowList` 抓缩略图(含最小化/离屏),缓存 + 节流 + 降级 | +| `ZSLog.swift` | 统一日志(stderr + `/tmp/zeroflow.log`) | + +### `Zeroflow/Views/` + +| 文件 | 职责 | +|---|---| +| `AccessibilityPermissionRow.swift` | 辅助功能权限状态与引导行(Dock / 切换 两标签页共用) | +| `CaptureOverlayView.swift` | 全屏选区遮罩(SwiftUI 包 NSView) | +| `DockPreviewPanel.swift` | Dock 悬停预览面板(非激活 NSPanel)+ `DockPreviewViewModel` | +| `EditorView.swift` | 截图编辑页(单一 ~1400 行文件):6 种工具 + 画布交互 + 键盘 + 导出 | +| `NotchPanelView.swift` | 灵动岛面板:月视图日历(今日高亮/日程打点/翻月/当日日程列表)+ 未授权引导,`NotchPanel` 非激活 NSPanel + `NotchCalendarViewModel` | +| `SaveDirectoryRow.swift` | 保存目录行:路径显示 / 选择 / 打开 | +| `SettingsView.swift` | 设置窗口:7 个标签页(截图/通用/切换/Dock/Dock 预览/灵动岛/右键菜单) | +| `ShortcutRecorderView.swift` | 快捷键录制控件(截图页用,录制期间挂起全局热键) | +| `WindowCardView.swift` | 窗口缩略图卡片(切换器瓦片与 Dock 预览共用 UI) | +| `WindowSwitcherPanel.swift` | ⌘⇥ 切换器面板(瓦片网格 + 选中)+ `WindowSwitcherViewModel` | + +### `Zeroflow/Resources/` + +| 文件 | 职责 | +|---|---| +| `Translations.json` | 三语文案表(中文 key → zh-Hans/en/ja) | + +### `FinderSyncExtension/`(沙盒扩展 target,App Group `8NHN73Q43T.com.zeroflow.app`) + +| 文件 | 职责 | +|---|---| +| `FinderSync.swift` | Finder 右键菜单(新建文件/自定义命令);读共享 plist 取设置;`CustomCommandRunner` 写请求文件 + 广播通知,主 app 未运行时拉起它 | + +### 仓库根目录与脚本 + +| 文件 | 职责 | +|---|---| +| `create_release_tag.sh` | 发布脚本:切 main → 计算下一 patch tag → 推送触发 CI | +| `scripts/install-findersync.sh` | FinderSync 扩展一键登记/启用(处理 pkd 僵死与 LS 残留) | +| `.github/workflows/build.yml` | Build & Release CI(编译检查 / tag 发 DMG) | + +非源码:`Zeroflow.entitlements`、`FinderSyncExtension/{Info.plist, entitlements}`(App Group 与沙盒授权)、`Resources/Assets.xcassets`。 diff --git a/PITFALLS_DEV.md b/PITFALLS_DEV.md new file mode 100644 index 0000000..d81b227 --- /dev/null +++ b/PITFALLS_DEV.md @@ -0,0 +1,88 @@ +# PITFALLS_DEV.md + +开发踩坑记录(dev-test skill 维护,新增条目按 `DEV-XXX` 递增编号,勿改已有编号)。 + +## DEV-001: 本地 xcodebuild 不带 `-allowProvisioningUpdates` 直接 BUILD FAILED +- **Pitfall:** 构建时报 `No profiles ... were found`,BUILD FAILED。 +- **Cause:** FinderSync 扩展 target 带 App Group(`8NHN73Q43T.com.zeroflow.app`)与沙盒授权,需要 Xcode 自动生成/更新含该 App Group 的 provisioning profile。 +- **Prevention:** 本地构建一律带 `-allowProvisioningUpdates`;无团队证书的环境用 CI 等价命令(`CODE_SIGN_IDENTITY=- CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM=`)。 + +## DEV-002: `-derivedDataPath` 指向无写权限目录直接 BUILD FAILED +- **Pitfall:** 给 `-derivedDataPath` 的目录为只读(如 0644)时,构建直接失败。 +- **Cause:** xcodebuild 需要在 derived data 目录写中间产物,无写权限即失败。 +- **Prevention:** 换一个可写路径。 + +## DEV-003: xcodebuild 产物路径不可靠,直接 `cp -R` 得到坏符号链接 +- **Pitfall:** Debug 产物可能落在 `build/Build/Intermediates.noindex/ArchiveIntermediates/.../Applications/Zeroflow.app`,用 `cp -R` 复制它得到的是坏符号链接。 +- **Cause:** 产物落点不总是确定的。 +- **Prevention:** 用 `find /Build -name "Zeroflow.app"` 定位;复制用 `cp -R -L` 解引用;复制前先 `rm -rf` 目标。 + +## DEV-004: 新增 target 需手改 pbxproj,改错直接 BUILD FAILED +- **Pitfall:** 以为 `.swift` 文件和 target 一样自动纳入工程,新增 target 时漏配;或手改 pbxproj 出错后构建失败。 +- **Cause:** 源文件走 `PBXFileSystemSynchronizedRootGroup` 自动纳入(不要手改 pbxproj 加文件),但 target 本身、Embed App Extensions 阶段、target dependency、FinderSync.framework 链接(App 用于 `FIFinderSyncController.isExtensionEnabled`)都是手工维护的。 +- **Prevention:** 新增 `.swift` 文件放对应目录即可;新增/调整 target 才动 pbxproj,改完立即构建验证。当前仅 `FinderSyncExtension/` 一个扩展 target,已配好。 + +## DEV-005: FinderSync 扩展未沙盒化会被 pkd 静默拒绝 +- **Pitfall:** `pluginkit -m` 永远不出现该扩展,`pluginkit -a` 返回 0 却无任何日志,右键菜单不出现。 +- **Cause:** pkd(pluginkit)静默拒绝非沙盒的 FinderSync 扩展(`com.apple.security.app-sandbox` 缺失)。 +- **Prevention:** 扩展 target 必须保持沙盒授权;排查扩展不可见时先核对 entitlements。 + +## DEV-006: pkd 进程僵死,新扩展登记不上 +- **Pitfall:** 新扩展 `pluginkit -a` 返回 0 但 `pluginkit -m` 查不到,反复登记无效。 +- **Cause:** pkd 内存会僵住,僵死的旧实例不响应新插件注册。 +- **Prevention:** 登记后查不到就 `killall pkd` 再查;`scripts/install-findersync.sh` 已内置处理。 + +## DEV-007: LaunchServices 残留旧路径插件记录导致 pkd 拒绝扩展 +- **Pitfall:** pkd 解析到无效路径(如 build 目录里的旧 app/appex)而拒绝扩展。 +- **Cause:** LS 记录了旧路径的插件(重复构建产物留下的登记),优先于新路径被解析。 +- **Prevention:** 先 `lsregister -u` 所有旧路径的 app 及其 appex,再只注册 /Applications;`install-findersync.sh` 已内置处理。 + +## DEV-008: macOS Sequoia/Tahoe 覆盖安装后 Finder 右键菜单消失(陈旧扩展实例) +- **Pitfall:** app 重建/覆盖安装后,扩展显示已启用(`FIFinderSyncController.isExtensionEnabled` 为 true、设置页绿色),但 Finder 右键菜单不出现;手动关闭再开启一次即恢复。 +- **Cause:** pkd 与 Finder 仍持有旧扩展实例;已程序化复现(`pluginkit -e ignore` → `-e use` 的 election 循环)。 +- **Prevention:** 主 app 启动自愈走 `Zeroflow/Services/FinderSyncReloader.swift`,设置页有「刷新右键菜单扩展」按钮;安装用 `scripts/install-findersync.sh`(支持 `--reload-only` 仅重载)。 + +## DEV-009: Finder 右键菜单位置无法调整 +- **Pitfall:** 尝试把扩展的右键菜单项放到菜单顶部/中间,无法实现。 +- **Cause:** 菜单项被 Finder 硬编码在菜单底部,无 API 可调整位置。 +- **Prevention:** 放弃调序,接受固定位置。 + +## DEV-010: 不要用 `UserDefaults(suiteName:)` 读 App Group +- **Pitfall:** 沙盒扩展里读不到主 app 写入的设置,cfprefsd 报 `Container: (null)`。 +- **Cause:** cfprefsd 在沙盒扩展进程里无法解析 App Group suite。 +- **Prevention:** 主 app(非沙盒但带 `com.apple.security.application-groups`)与扩展双方直接读写 group container 里的 `finder-sync-settings.plist` 文件(见 `Models/SettingsStore.swift` 与 `FinderSyncExtension/FinderSync.swift`)。 + +## DEV-011: 沙盒里 `homeDirectoryForCurrentUser` 返回容器目录,扩展不监控任何路径 +- **Pitfall:** 直接把 `FileManager.default.homeDirectoryForCurrentUser` 当 `directoryURLs` 会让 Finder 完全不监控用户路径,`menu(for:)` 永不触发。 +- **Cause:** 沙盒进程里该 API 返回 `~/Library/Containers//Data`,不是真实家目录。 +- **Prevention:** 用 `URL(fileURLWithPath: "/Users/\(NSUserName())")` 拼真实家目录。 + +## DEV-012: `NSMenuItem.representedObject` 跨 XPC 不保留 +- **Pitfall:** 点击菜单动作回调里 `sender` 拿不到自定义 `representedObject`,报 `createFile: invalid sender`。 +- **Cause:** FinderSync 菜单项动作跨 XPC 传递,representedObject 不会被保留。 +- **Prevention:** 在动作里用 `FIFinderSyncController.default().targetedURL()` / `selectedItemURLs()` 重新推导目标(`targetDirectory()`),不要依赖 sender。 + +## DEV-013: FinderSync 右键目标目录无写权限 +- **Pitfall:** 右键目录里新建文件失败,日志 `deny file-write-create`。 +- **Cause:** `files.user-selected.read-write` 权限不覆盖 FinderSync 右键目标。 +- **Prevention:** 加 `com.apple.security.temporary-exception.files.home-relative-path.read-write`(值 `["/"]`,覆盖整个家目录;不入 App Store 可接受)。 + +## DEV-014: 扩展里不能直接执行子进程命令(继承沙盒),需转交主 app 代执行 +- **Pitfall:** 扩展里 `Process` 拉起终端/pty 类命令(如 `wezterm start`)EPERM 秒退。 +- **Cause:** 子进程继承扩展的沙盒约束。 +- **Prevention:** 自定义命令不在扩展执行:扩展把请求写进 group container 的 `finder-command-*.plist` 并广播分布式通知(沙盒发通知不能带 userInfo),由非沙盒主 app `Zeroflow/Services/FinderCommandRunner.swift` 代执行(启动时消化积压请求,超过 30s 的丢弃;扩展侧主 app 未运行时会拉起它)。 + +## DEV-015: 新 SDK(27.0)下 EventKit/CG/NSScreen 的 API 形态变化 +- **Pitfall:** 灵动岛功能首编四处报错:`store.events(from:to:calendars:)` 不存在、`EKCalendar.color` 按 CGColor 用报类型错、`NSScreen.auxiliaryTopLeft/RightArea` 按 non-optional 用报可选错、`CGEvent.keyCode` 属性不存在。 +- **Cause:** EventKit 便捷查询只剩谓词版 `events(matching:)`;macOS 上 `EKCalendar.color` 一直是 NSColor(iOS 才是 CGColor);辅助区矩形在新 SDK 声明为可选;CGEvent 键码要走 `getIntegerValueField(.keyboardEventKeycode)`。 +- **Prevention:** 遇到系统框架 API 编译错先查本机 SDK 头文件(`/Applications/Xcode.app/.../System/Library/Frameworks/.framework/Headers/*.h`)再改实现,不要凭记忆按 iOS 或旧文档签名写。 + +## DEV-016: `animator().setFrameOrigin(_:)` 对 NSWindow 无效 +- **Pitfall:** 灵动岛面板从刘海后方 12pt 滑入,用 `animator().setFrameOrigin(final)` 做动画,面板最终停在起始位置(比目标高 12pt),像是"贴不到刘海下缘"。 +- **Cause:** NSWindow 经 animator 代理可动画的属性只有 frame/alphaValue/bounds 等,`setFrameOrigin:` 不在其中,调用静默无效。 +- **Prevention:** 窗口位移动画用 `animator().setFrame(_:display:)`(先 setFrame 到起始位置);或只动画 alphaValue/内容视图 transform。 + +## DEV-017: 窗口背景液态玻璃/磨砂的边界(macOS 26+) +- **Pitfall:** `CGWindowListCreateImage` 在 macOS 15 已 obsoleted(SDK 15+ 直接编译错误),系统磨砂(NSVisualEffectView)公开 API 不暴露模糊半径;`NSGlassEffectView` 的 `alphaValue` 会连 contentView 内容一起变透明,不能当「背景不透明度」用。 +- **Cause:** 液态玻璃透明度只能经 `tintColor` 的 alpha(染色深浅)控制;内容必须嵌 `contentView` 才保证在玻璃内(任意兄弟子视图 z 序无保证)。 +- **Prevention:** macOS 26+ 面板背景用 `NSGlassEffectView`(cornerRadius 对齐屏幕圆角,macOS 上无公开 API 查屏幕圆角,取 16);旧系统回退 `NSVisualEffectView(.hudWindow)`。若真需要面板背后区域的模糊快照,须 SCScreenshotManager + `SCContentFilter(display:excludingWindows: [自身 SCWindow])` 整屏捕获后裁剪再 `CIGaussianBlur`(输入先 `clampedToExtent()` 防边缘晕边);SCShareableContent 枚举耗时数百毫秒,需缓存。 diff --git a/Zeroflow.xcodeproj/project.pbxproj b/Zeroflow.xcodeproj/project.pbxproj index 29fabe8..61d1470 100644 --- a/Zeroflow.xcodeproj/project.pbxproj +++ b/Zeroflow.xcodeproj/project.pbxproj @@ -352,6 +352,7 @@ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Zeroflow 需要访问您的日历,以便在灵动岛面板中显示日程。"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", @@ -381,6 +382,7 @@ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Zeroflow 需要访问您的日历,以便在灵动岛面板中显示日程。"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", diff --git a/Zeroflow/MenuBar/MenuBarController.swift b/Zeroflow/MenuBar/MenuBarController.swift index 2d2694e..c03f118 100644 --- a/Zeroflow/MenuBar/MenuBarController.swift +++ b/Zeroflow/MenuBar/MenuBarController.swift @@ -4,6 +4,9 @@ import SwiftUI extension Notification.Name { /// 请求打开设置窗口(如无截屏权限时引导授权) static let zeroflowOpenSettings = Notification.Name("zeroflow.openSettings") + + /// 请求打开设置窗口并跳到「灵动岛」标签页(面板空态引导按钮) + static let zeroflowOpenNotchSettings = Notification.Name("zeroflow.openNotchSettings") } /// 菜单栏控制器:常驻菜单栏,提供「设置」与「退出」 @@ -23,6 +26,12 @@ final class MenuBarController: NSObject, NSApplicationDelegate { name: .zeroflowOpenSettings, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(openSettings), + name: .zeroflowOpenNotchSettings, + object: nil + ) NotificationCenter.default.addObserver( self, selector: #selector(screenshotEnabledDidChange(_:)), @@ -59,6 +68,9 @@ final class MenuBarController: NSObject, NSApplicationDelegate { // Dock 悬停预览:按开关初始状态启停(开关变化由 DockHoverPreviewController 自行监听恢复) DockHoverPreviewController.shared.reapply() + // 灵动岛刘海面板:按开关初始状态启停(开关变化由 NotchHotspotController 自行监听恢复) + NotchHotspotController.shared.reapply() + // 右键菜单扩展自愈:Sequoia/Tahoe 下 FinderSync 扩展更新后常有「已启用但右键菜单 // 不出现」的陈旧实例问题,启动时若已启用则重载一次,保证右键菜单立即可用。 DispatchQueue.main.asyncAfter(deadline: .now() + 2) { @@ -170,7 +182,7 @@ final class MenuBarController: NSObject, NSApplicationDelegate { contentViewController: NSHostingController(rootView: SettingsView()) ) window.title = L10n.tr("zeroflow 设置") - window.setContentSize(NSSize(width: 560, height: 640)) + window.setContentSize(NSSize(width: 860, height: 640)) window.styleMask = [.titled, .closable] window.isReleasedWhenClosed = false window.center() diff --git a/Zeroflow/Models/SettingsStore.swift b/Zeroflow/Models/SettingsStore.swift index 397c0a0..0a489de 100644 --- a/Zeroflow/Models/SettingsStore.swift +++ b/Zeroflow/Models/SettingsStore.swift @@ -31,6 +31,10 @@ final class SettingsStore: ObservableObject { static let finderCmdEnabled = "finderCmdEnabled" static let finderCmdTitle = "finderCmdTitle" static let finderCmdCommand = "finderCmdCommand" + static let notchEnabled = "notchEnabled" + static let notchHoverDelayMs = "notchHoverDelayMs" + static let notchShowCalendar = "notchShowCalendar" + static let notchPanelOpacity = "notchPanelOpacity" } private let defaults: UserDefaults @@ -101,6 +105,10 @@ final class SettingsStore: ObservableObject { Keys.finderCmdEnabled: false, Keys.finderCmdTitle: Self.defaultFinderCmdTitle, Keys.finderCmdCommand: Self.defaultFinderCmdCommand, + Keys.notchEnabled: false, + Keys.notchHoverDelayMs: 200, + Keys.notchShowCalendar: true, + Keys.notchPanelOpacity: 80, ]) _launchAtLogin = Published(initialValue: defaults.bool(forKey: Keys.launchAtLogin)) _saveDirectory = Published(initialValue: defaults.string(forKey: Keys.saveDirectory) ?? Self.defaultSaveDirectory) @@ -116,6 +124,10 @@ final class SettingsStore: ObservableObject { _finderCmdEnabled = Published(initialValue: defaults.bool(forKey: Keys.finderCmdEnabled)) _finderCmdTitle = Published(initialValue: defaults.string(forKey: Keys.finderCmdTitle) ?? Self.defaultFinderCmdTitle) _finderCmdCommand = Published(initialValue: defaults.string(forKey: Keys.finderCmdCommand) ?? Self.defaultFinderCmdCommand) + _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)) _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) @@ -393,6 +405,40 @@ final class SettingsStore: ObservableObject { } } + // MARK: - 灵动岛 + + @Published var notchEnabled: Bool { + didSet { + defaults.set(notchEnabled, forKey: Keys.notchEnabled) + NotificationCenter.default.post( + name: NotchHotspotController.didChangeNotification, + object: nil, + userInfo: ["enabled": notchEnabled] + ) + } + } + + /// 悬停刘海多久后弹出面板(毫秒);单击刘海可立即弹出 + @Published var notchHoverDelayMs: Int { + didSet { + defaults.set(notchHoverDelayMs, forKey: Keys.notchHoverDelayMs) + } + } + + /// 显示内容模块:日历。全部关闭时面板仍会弹出,显示「添加显示内容」引导 + @Published var notchShowCalendar: Bool { + didSet { + defaults.set(notchShowCalendar, forKey: Keys.notchShowCalendar) + } + } + + /// 面板不透明度(%):100 = 完全不透明,越小越透明 + @Published var notchPanelOpacity: Int { + didSet { + defaults.set(notchPanelOpacity, forKey: Keys.notchPanelOpacity) + } + } + @Published var cmdTabShortcut: ShortcutKey { didSet { defaults.set(Int(cmdTabShortcut.keyCode), forKey: Keys.cmdTabShortcutKeyCode) diff --git a/Zeroflow/Resources/Translations.json b/Zeroflow/Resources/Translations.json index a5dbe93..7b4f315 100644 --- a/Zeroflow/Resources/Translations.json +++ b/Zeroflow/Resources/Translations.json @@ -518,5 +518,100 @@ "zh-Hans": "命令中的 {path} 会替换为右键目标的绝对路径:右键文件夹=该文件夹、右键文件=其所在文件夹、右键空白处=当前目录;不含 {path} 则原样执行。路径含空格请给 {path} 加引号。命令由 Zeroflow 主 App 代为执行(登录 Shell,PATH 含 Homebrew),请保持 Zeroflow 常驻运行。", "en": "In the command, {path} is replaced with the absolute path of the right-click target: a folder uses itself, a file uses its parent folder, empty space uses the current directory; without {path} the command runs as-is. Quote {path} if paths may contain spaces. Commands are executed by the Zeroflow main app via your default login shell (PATH includes Homebrew), so keep Zeroflow running.", "ja": "コマンド内の {path} は右クリック対象の絶対パスに置き換えられます:フォルダーならそのフォルダー、ファイルならその親フォルダー、空白部分なら現在のディレクトリ。{path} がない場合はそのまま実行します。パスに空白が含まれる可能性がある場合は {path} を引用符で囲ってください。コマンドは Zeroflow 本体アプリがログインシェル経由で実行します(PATH に Homebrew を含む)。Zeroflow を常駐させてください。" + }, + "灵动岛": { + "zh-Hans": "灵动岛", + "en": "Notch", + "ja": "ノッチ" + }, + "启用灵动岛": { + "zh-Hans": "启用灵动岛", + "en": "Enable Notch Panel", + "ja": "ノッチパネルを有効にする" + }, + "鼠标悬停或单击屏幕顶部刘海区域,从刘海向下弹出日历面板;点击面板外部或按 Esc 收起。仅支持带刘海的机型。": { + "zh-Hans": "鼠标悬停或单击屏幕顶部刘海区域,从刘海向下弹出日历面板;点击面板外部或按 Esc 收起。仅支持带刘海的机型。", + "en": "Hover over or click the notch at the top of the screen to drop down a calendar panel; click outside the panel or press Esc to dismiss. Only works on Macs with a notch.", + "ja": "画面上部のノッチにカーソルを合わせるかクリックすると、カレンダーパネルが下に展開します。パネルの外側をクリックするか Esc で閉じます。ノッチ搭載機種のみ対応。" + }, + "鼠标停留在刘海区域多久后弹出面板;单击刘海区域可立即弹出。": { + "zh-Hans": "鼠标停留在刘海区域多久后弹出面板;单击刘海区域可立即弹出。", + "en": "How long the pointer must stay on the notch before the panel appears; clicking the notch opens it instantly.", + "ja": "ノッチ上に何ミリ秒とどまるとパネルを表示するか。クリックでは即座に開きます。" + }, + "显示内容": { + "zh-Hans": "显示内容", + "en": "Content", + "ja": "表示内容" + }, + "日历": { + "zh-Hans": "日历", + "en": "Calendar", + "ja": "カレンダー" + }, + "需要「辅助功能」权限才能监测刘海悬停": { + "zh-Hans": "需要「辅助功能」权限才能监测刘海悬停", + "en": "Accessibility permission is required to detect notch hover", + "ja": "ノッチのホバー検知には「アクセシビリティ」権限が必要です" + }, + "该权限与 Dock 预览等功能共用,若已开启则此处直接显示绿色。": { + "zh-Hans": "该权限与 Dock 预览等功能共用,若已开启则此处直接显示绿色。", + "en": "This permission is shared with Dock preview and other features; if already granted, it shows green here.", + "ja": "この権限は Dock プレビューなどと共通です。許可済みならここは緑色で表示されます。" + }, + "今天": { + "zh-Hans": "今天", + "en": "Today", + "ja": "今日" + }, + "全天": { + "zh-Hans": "全天", + "en": "All-day", + "ja": "終日" + }, + "没有日程": { + "zh-Hans": "没有日程", + "en": "No events", + "ja": "予定はありません" + }, + "无标题日程": { + "zh-Hans": "无标题日程", + "en": "Untitled event", + "ja": "タイトルなしの予定" + }, + "未获准访问日历,无法显示日程。": { + "zh-Hans": "未获准访问日历,无法显示日程。", + "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", + "ja": "表示内容がありません" + }, + "添加显示内容": { + "zh-Hans": "添加显示内容", + "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 のパネル背景を使用し、旧システムではシステムのすりガラスを表示します。不透明度は数値が小さいほど透明になります。変更はパネルを開き直すと反映されます。" } } diff --git a/Zeroflow/Services/CalendarService.swift b/Zeroflow/Services/CalendarService.swift new file mode 100644 index 0000000..9992290 --- /dev/null +++ b/Zeroflow/Services/CalendarService.swift @@ -0,0 +1,33 @@ +import Foundation +import EventKit + +/// EventKit 封装:日历权限 + 按时间区间查日程(灵动岛面板用)。 +/// 不做日历筛选,使用系统日历 App 中已勾选的全部日历(calendars: nil)。 +final class CalendarService { + static let shared = CalendarService() + + private let store = EKEventStore() + + /// 当前日历访问权限 + var status: EKAuthorizationStatus { + EKEventStore.authorizationStatus(for: .event) + } + + /// 请求完整访问权限(macOS 14+,首次调用弹系统授权窗) + func requestAccess(_ completion: @escaping (Bool) -> Void) { + store.requestFullAccessToEvents { granted, error in + if let error { + ZSLog("CalendarService: request access failed: \(error)") + } + DispatchQueue.main.async { completion(granted) } + } + } + + /// 取 [start, end) 区间内的日程。未授权时返回空数组。 + /// EKEventStore 查询可能较慢,调用方放到后台线程。 + func events(from start: Date, to end: Date) -> [EKEvent] { + guard EKEventStore.authorizationStatus(for: .event) == .fullAccess else { return [] } + let predicate = store.predicateForEvents(withStart: start, end: end, calendars: nil) + return store.events(matching: predicate) + } +} diff --git a/Zeroflow/Services/NotchHotspotController.swift b/Zeroflow/Services/NotchHotspotController.swift new file mode 100644 index 0000000..e2fe0c1 --- /dev/null +++ b/Zeroflow/Services/NotchHotspotController.swift @@ -0,0 +1,575 @@ +import AppKit +import ApplicationServices +import CoreGraphics + +/// 灵动岛:悬停/单击刘海区域,从刘海向下弹出磨砂面板(v1 内容 = 日历月视图)。 +/// +/// 实现要点(结构仿 `DockHoverPreviewController`): +/// - listen-only CGEventTap 监听 mouseMoved / mouseDown / keyDown,绝不吞事件: +/// 刘海热区只是几何判定(不依赖热区窗收事件),菜单栏图标点击、Esc 等原生行为零影响。 +/// - 刘海矩形由公开 API 推导:`safeAreaInsets.top > 0` 判定刘海屏, +/// `auxiliaryTopLeftArea` / `auxiliaryTopRightArea` 之间的水平间隙即刘海(竖向占满菜单栏条带); +/// 热区 = 刘海矩形左右各外扩 12pt(不纵向外扩,避免盖住菜单栏下方内容区)。 +/// - 状态机:idle →(悬停延时 / 单击热区)→ visible;visible 时指针在 (热区∪面板)外扩内 +/// 保活,移出 200ms 宽限收起;区域外任意 mouseDown 收起;Esc 收起;Space 切换 / +/// 锁屏睡眠 / 屏幕配置变化全局收起。 +/// - 无刘海屏静默不生效(合盖再开屏、屏幕配置变化时重新探测); +/// `ZEROFLOW_NOTCH_FAKE=1` 在主屏合成伪刘海便于调试,`ZEROFLOW_NOTCH_DEBUG=1` 输出决策日志。 +final class NotchHotspotController { + static let shared = NotchHotspotController() + + /// 开关变化通知:由 SettingsStore.notchEnabled 的 didSet 触发 + static let didChangeNotification = Notification.Name("zeroflow.notchDidChange") + + private static let debug = ProcessInfo.processInfo.environment["ZEROFLOW_NOTCH_DEBUG"] == "1" + private static let fakeNotch = ProcessInfo.processInfo.environment["ZEROFLOW_NOTCH_FAKE"] == "1" + + /// 热区对刘海的左右外扩 + private static let hotspotMargin: CGFloat = 12 + /// visible 后移出 热区∪面板(外扩) 的隐藏宽限(s):给鼠标移向面板留时间 + private static let hideGrace: TimeInterval = 0.2 + /// 保活判定对 热区/面板 的外扩:吞掉两者之间的缝隙,慢速移动不掉出保活区 + private static let keepAliveInset: CGFloat = 6 + /// move 事件处理节流间隔(s):几何判定足够快,但没必要每个 HID 事件都跑 + private static let moveThrottle: CFTimeInterval = 0.016 + /// Esc 键码 + private static let escKeyCode: UInt16 = 53 + + private enum Phase { case idle, pending, visible } + + // MARK: - 状态(lock 保护;tap 线程 / workQueue / 主线程共用) + + private let lock = NSLock() + private var phase: Phase = .idle + /// show 工作代数:悬停调度/单击开启时递增,使旧 show 作废 + private var showGen = 0 + /// 宽限隐藏代数:回到保活区即取消;与 showGen 分开避免互相误杀 + private var hideGen = 0 + /// 已调度、尚未触发的悬停 show 代数(防同一停留重复调度;移出热区即作废) + private var pendingShowGen: Int? + /// 刘海热区(主屏左上原点全局坐标);nil = 当前无刘海屏 + private var hotspotTL: CGRect? + /// 面板 frame(同坐标),visible 时用于保活/区域外判定 + private var panelFrameTL: CGRect? + private var lastMousePoint = CGPoint.zero + private var lastMoveProcessedAt = CFTimeInterval(0) + /// 收起后的重弹抑制:面板被 Esc/点击外部等收起后,若指针仍留在热区内, + /// 悬停不会再弹,直到指针离开热区(否则按 Esc 面板立刻 200ms 后重弹) + private var suppressReshow = false + + // 主线程专用 + private var panel: NotchPanel? + private var model: NotchCalendarViewModel? + private var panelShown = false + private var notchScreen: NSScreen? + + // MARK: - 基础设施 + + private var isRunning = false + private var thread: Thread? + private weak var tapRunLoop: CFRunLoop? + private var eventTap: CFMachPort? + private var runLoopSource: CFRunLoopSource? + private var workspaceObservers: [NSObjectProtocol] = [] + private let workQueue = DispatchQueue(label: "zeroflow.notch", qos: .userInitiated) + + private init() { + NotificationCenter.default.addObserver( + self, + selector: #selector(handleDidChange(_:)), + name: Self.didChangeNotification, + object: nil + ) + observeHideTriggers() + } + + // MARK: - 启停 + + /// 按开关当前状态启停(启动时 / 开关变化时调用) + func reapply() { + if SettingsStore.shared.notchEnabled { + start() + } else { + stop() + } + } + + @objc private func handleDidChange(_ notification: Notification) { + reapply() + } + + private func start() { + lock.lock() + guard !isRunning else { lock.unlock(); return } + isRunning = true + lock.unlock() + + refreshHotspot() + lock.lock() + let hasHotspot = hotspotTL != nil + lock.unlock() + if !hasHotspot { + ZSLog("Notch: no notch display detected, waiting for screen config change") + } + + let t = Thread { [weak self] in + self?.runTapLoop() + } + t.name = "zeroflow.notch" + t.qualityOfService = .userInitiated + thread = t + t.start() + ZSLog("Notch: started") + } + + private func stop() { + lock.lock() + guard isRunning else { lock.unlock(); return } + isRunning = false + let runLoop = tapRunLoop + let source = runLoopSource + let tap = eventTap + eventTap = nil + runLoopSource = nil + tapRunLoop = nil + lock.unlock() + + hidePanel(reason: "disabled") + if let source, let runLoop { + CFRunLoopRemoveSource(runLoop, source, .commonModes) + CFRunLoopStop(runLoop) + } + if let tap { CFMachPortInvalidate(tap) } + thread = nil + ZSLog("Notch: stopped") + } + + private func runTapLoop() { + guard let runLoop = CFRunLoopGetCurrent() else { return } + lock.lock(); tapRunLoop = runLoop; lock.unlock() + installTap(in: runLoop) + // 兜底:tap 创建失败(如权限授权晚了)时周期重试,授权后无需重启即生效 + installRetryTimer(in: runLoop) + CFRunLoopRun() + } + + private func installTap(in runLoop: CFRunLoop) { + // listen-only:只旁观,不吞事件(菜单栏/热键/Esc 原生行为均不受影响) + let mask = CGEventMask( + (1 << CGEventType.mouseMoved.rawValue) + | (1 << CGEventType.leftMouseDown.rawValue) + | (1 << CGEventType.rightMouseDown.rawValue) + | (1 << CGEventType.otherMouseDown.rawValue) + | (1 << CGEventType.keyDown.rawValue) + ) + guard let tap = CGEvent.tapCreate( + tap: .cghidEventTap, + place: .headInsertEventTap, + options: .listenOnly, + eventsOfInterest: mask, + callback: Self.eventTapCallback, + userInfo: Unmanaged.passUnretained(self).toOpaque() + ) else { + ZSLog("Notch: tap create failed (no accessibility permission?)") + return + } + lock.lock(); eventTap = tap; lock.unlock() + let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) + CFRunLoopAddSource(runLoop, source, .commonModes) + lock.lock(); runLoopSource = source; lock.unlock() + ZSLog("Notch: tap installed OK") + } + + private func installRetryTimer(in runLoop: CFRunLoop) { + var ctx = CFRunLoopTimerContext( + version: 0, + info: Unmanaged.passUnretained(self).toOpaque(), + retain: nil, + release: nil, + copyDescription: nil + ) + let timer = CFRunLoopTimerCreate( + kCFAllocatorDefault, + CFAbsoluteTimeGetCurrent() + 2, + 2, + 0, + 0, + Self.retryTimerCallback, + &ctx + ) + if let timer { CFRunLoopAddTimer(runLoop, timer, .commonModes) } + } + + private func retryCreateTapIfNeeded() { + lock.lock() + let missing = eventTap == nil && isRunning + let runLoop = tapRunLoop + lock.unlock() + if missing, let runLoop { installTap(in: runLoop) } + } + + // MARK: - 事件回调(tap 线程) + + private static let eventTapCallback: CGEventTapCallBack = { _, type, event, info in + guard let info else { return Unmanaged.passUnretained(event) } + let controller = Unmanaged.fromOpaque(info).takeUnretainedValue() + return controller.handle(type: type, event: event) + } + + private static let retryTimerCallback: @convention(c) (CFRunLoopTimer?, UnsafeMutableRawPointer?) -> Void = { _, info in + guard let info else { return } + let controller = Unmanaged.fromOpaque(info).takeUnretainedValue() + controller.retryCreateTapIfNeeded() + } + + private func handle(type: CGEventType, event: CGEvent) -> Unmanaged? { + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + lock.lock(); let tap = eventTap; lock.unlock() + if let tap { CGEvent.tapEnable(tap: tap, enable: true) } + return Unmanaged.passUnretained(event) + } + guard SettingsStore.shared.notchEnabled else { return Unmanaged.passUnretained(event) } + // 未授权辅助功能时静默跳过 + guard AccessibilityPermission.isGranted else { return Unmanaged.passUnretained(event) } + + switch type { + case .mouseMoved: + handleMouseMoved(event.location) + case .leftMouseDown: + handleMouseDown(at: event.location, isOpeningClick: true) + case .rightMouseDown, .otherMouseDown: + handleMouseDown(at: event.location, isOpeningClick: false) + case .keyDown: + if event.getIntegerValueField(.keyboardEventKeycode) == Int64(Self.escKeyCode) { + lock.lock() + let visible = phase == .visible + lock.unlock() + if visible { hidePanel(reason: "esc") } + } + default: + break + } + // listen-only:事件原样放行 + return Unmanaged.passUnretained(event) + } + + // MARK: - 状态机(tap 线程决策,workQueue/主线程执行) + + private func handleMouseMoved(_ point: CGPoint) { + lock.lock() + let now = CFAbsoluteTimeGetCurrent() + if now - lastMoveProcessedAt < Self.moveThrottle { + lastMousePoint = point // 记录最新点,节流不影响 show 到期时的取点 + lock.unlock() + return + } + lastMoveProcessedAt = now + lastMousePoint = point + let currentPhase = phase + let hotspot = hotspotTL + let panelFrame = panelFrameTL + lock.unlock() + + guard let hotspot else { return } // 无刘海屏:不响应 + + switch currentPhase { + case .idle: + if hotspot.contains(point) { + if !isSuppressingReshow() { + scheduleShowIfNone() + } + } else { + if isSuppressingReshow() { + clearSuppressReshow() + } + cancelPendingShow() + } + + case .pending: + // 悬停计时期间移出热区:作废在途 show + if !hotspot.contains(point) { + cancelPendingShow() + } + + case .visible: + // 显示中:仍在 (热区∪面板)外扩 内 → 保活;移出 → 200ms 宽限隐藏 + var inside = false + if hotspot.insetBy(dx: -Self.keepAliveInset, dy: -Self.keepAliveInset).contains(point) { inside = true } + if let panelFrame, panelFrame.insetBy(dx: -Self.keepAliveInset, dy: -Self.keepAliveInset).contains(point) { inside = true } + if inside { + bumpHideGen() + } else { + scheduleHideAfterGrace() + } + } + } + + private func handleMouseDown(at point: CGPoint, isOpeningClick: Bool) { + lock.lock() + let currentPhase = phase + let hotspot = hotspotTL + let panelFrame = panelFrameTL + lock.unlock() + + switch currentPhase { + case .idle, .pending: + // 未显示:左键单击热区立即弹出(悬停延时的另一条触发路径) + if isOpeningClick, let hotspot, hotspot.contains(point) { + openNow() + return + } + // 悬停计时期间点击热区外:取消在途 show + if currentPhase == .pending, let hotspot, !hotspot.contains(point) { + cancelPendingShow() + } + + case .visible: + // 显示中:点击在 (热区∪面板)内 → 保活(面板自身交互需要收到 mouseDown);区域外 → 收起 + var inside = false + if let hotspot, hotspot.insetBy(dx: -Self.keepAliveInset, dy: -Self.keepAliveInset).contains(point) { inside = true } + if let panelFrame, panelFrame.insetBy(dx: -Self.keepAliveInset, dy: -Self.keepAliveInset).contains(point) { inside = true } + if inside { + bumpHideGen() + } else { + hidePanel(reason: "mouseDown outside") + } + } + } + + // MARK: - 调度 + + /// 保活:取消待触发的宽限隐藏(不动在途 show) + private func bumpHideGen() { + lock.lock() + hideGen += 1 + lock.unlock() + } + + /// 宽限隐藏:移出 热区∪面板(外扩) 200ms 后仍 visible 且无在途 show 才真正隐藏。 + /// 期间回到保活区(bumpHideGen)或调度了新 show 都会取消。 + private func scheduleHideAfterGrace() { + lock.lock() + hideGen += 1 + let gen = hideGen + lock.unlock() + workQueue.asyncAfter(deadline: .now() + Self.hideGrace) { [weak self] in + guard let self else { return } + self.lock.lock() + let live = gen == self.hideGen && self.phase == .visible && self.pendingShowGen == nil + self.lock.unlock() + guard live else { return } + self.hidePanel(reason: "left hotspot+panel region") + } + } + + /// 仅当没有任何挂起的 show 时调度(防止热区内连续移动重复触发) + private func scheduleShowIfNone() { + lock.lock() + let none = pendingShowGen == nil + lock.unlock() + guard none else { return } + + lock.lock() + showGen += 1 + let gen = showGen + pendingShowGen = gen + phase = .pending + lock.unlock() + let delay = TimeInterval(max(50, SettingsStore.shared.notchHoverDelayMs)) / 1000.0 + workQueue.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self else { return } + self.lock.lock() + if self.pendingShowGen == gen { self.pendingShowGen = nil } + let live = gen == self.showGen && self.phase == .pending + let point = self.lastMousePoint + let hotspot = self.hotspotTL + self.lock.unlock() + guard live, let hotspot, hotspot.contains(point) else { return } + DispatchQueue.main.async { [weak self] in + self?.presentPanel(showGen: gen) + } + } + } + + /// 是否处于收起后的重弹抑制状态 + private func isSuppressingReshow() -> Bool { + lock.lock(); defer { lock.unlock() } + return suppressReshow + } + + private func clearSuppressReshow() { + lock.lock() + suppressReshow = false + lock.unlock() + } + + /// 单击热区立即弹出(无需等悬停延时) + private func openNow() { + lock.lock() + pendingShowGen = nil + suppressReshow = false + showGen += 1 + let gen = showGen + phase = .pending + lock.unlock() + DispatchQueue.main.async { [weak self] in + self?.presentPanel(showGen: gen) + } + } + + /// 移出热区/点击热区外时作废在途悬停 show + private func cancelPendingShow() { + lock.lock() + if pendingShowGen != nil { + pendingShowGen = nil + showGen += 1 + if phase == .pending { phase = .idle } + } + lock.unlock() + } + + private func hidePanel(reason: String) { + lock.lock() + let wasVisible = phase == .visible + phase = .idle + pendingShowGen = nil + panelFrameTL = nil + suppressReshow = true + showGen += 1 + hideGen += 1 + lock.unlock() + guard wasVisible else { return } + ZSLog("Notch: hide (\(reason))") + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.panel?.orderOut(nil) + self.panelShown = false + } + } + + // MARK: - 展示(主线程) + + /// 悬停延时到期 / 单击热区:主线程重建面板内容并滑入显示 + private func presentPanel(showGen gen: Int) { + lock.lock() + guard gen == showGen, phase == .pending else { + lock.unlock() + return + } + phase = .visible + lock.unlock() + + // 主线程重新探测(合盖/接屏等边缘情况的权威值),并校验代数仍有效 + guard let detected = refreshHotspot() else { + hidePanel(reason: "no notch") + return + } + lock.lock() + let live = gen == showGen && phase == .visible + lock.unlock() + guard live else { return } + + let viewModel = ensureModel() + viewModel.reset() + let panel = ensurePanel() + panel.present(hotspotTL: detected.hotspotTL, screen: detected.screen, model: viewModel) + panelShown = true + lock.lock() + panelFrameTL = toTopLeftCoords(panel.frame) + lock.unlock() + if Self.debug { + ZSLog("Notch: show gen=\(gen) hotspot=\(detected.hotspotTL) panel=\(panel.frame)") + } + } + + private func ensurePanel() -> NotchPanel { + if let panel { return panel } + let panel = NotchPanel() + self.panel = panel + return panel + } + + private func ensureModel() -> NotchCalendarViewModel { + if let model { return model } + let model = NotchCalendarViewModel() + self.model = model + return model + } + + // MARK: - 刘海探测(主线程) + + /// 探测刘海屏并计算热区。无刘海返回 nil;ZEROFLOW_NOTCH_FAKE=1 时在主屏合成伪刘海。 + @discardableResult + private func refreshHotspot() -> (screen: NSScreen, hotspotTL: CGRect)? { + let detected = detectNotch() + notchScreen = detected?.screen + lock.lock() + hotspotTL = detected?.hotspotTL + lock.unlock() + return detected + } + + private func detectNotch() -> (screen: NSScreen, hotspotTL: CGRect)? { + let unionMaxY = NSScreen.screens.reduce(CGRect.null) { $0.union($1.frame) }.maxY + for screen in NSScreen.screens { + let barHeight: CGFloat + let notchFrameCocoa: CGRect + if Self.fakeNotch { + guard screen == NSScreen.main else { continue } + barHeight = 24 + notchFrameCocoa = CGRect(x: screen.frame.midX - 90, y: screen.frame.maxY - barHeight, + width: 180, height: barHeight) + } else { + guard screen.safeAreaInsets.top > 0, + let left = screen.auxiliaryTopLeftArea, + let right = screen.auxiliaryTopRightArea, + right.minX - left.maxX > 4 else { continue } + barHeight = max(left.height, right.height, screen.safeAreaInsets.top) + notchFrameCocoa = CGRect(x: left.maxX, y: screen.frame.maxY - barHeight, + width: right.minX - left.maxX, height: barHeight) + } + // Cocoa(左下原点)→ 主屏左上原点全局坐标,并左右外扩成热区 + let notchTL = CGRect(x: notchFrameCocoa.minX, y: unionMaxY - notchFrameCocoa.maxY, + width: notchFrameCocoa.width, height: notchFrameCocoa.height) + let hotspot = notchTL.insetBy(dx: -Self.hotspotMargin, dy: 0) + if Self.debug { + ZSLog("Notch: detect screen=\(screen.localizedName) notchTL=\(notchTL) hotspot=\(hotspot)") + } + return (screen, hotspot) + } + return nil + } + + // MARK: - 全局隐藏触发 + + /// Space 切换 / 锁屏睡眠 / 屏幕配置变化时收起;屏幕配置变化同时重探刘海 + private func observeHideTriggers() { + let center = NSWorkspace.shared.notificationCenter + workspaceObservers = [ + center.addObserver(forName: NSWorkspace.activeSpaceDidChangeNotification, object: nil, queue: .main) { [weak self] _ in + self?.hidePanel(reason: "space changed") + }, + center.addObserver(forName: NSWorkspace.willSleepNotification, object: nil, queue: .main) { [weak self] _ in + self?.hidePanel(reason: "sleep") + }, + center.addObserver(forName: NSWorkspace.screensDidSleepNotification, object: nil, queue: .main) { [weak self] _ in + self?.hidePanel(reason: "screens sleep") + }, + ] + NotificationCenter.default.addObserver( + forName: NSApplication.didChangeScreenParametersNotification, object: nil, queue: .main + ) { [weak self] _ in + self?.hidePanel(reason: "screen config changed") + self?.refreshHotspot() + } + } + + // MARK: - 坐标(CGEvent 均为「主屏左上原点」的点坐标) + + /// Cocoa 矩形(左下原点)→ 主屏左上原点全局坐标 + private func toTopLeftCoords(_ rect: CGRect) -> CGRect { + let union = NSScreen.screens.reduce(CGRect.null) { $0.union($1.frame) } + return CGRect(x: rect.minX, y: union.maxY - rect.maxY, + width: rect.width, height: rect.height) + } +} diff --git a/Zeroflow/Views/NotchPanelView.swift b/Zeroflow/Views/NotchPanelView.swift new file mode 100644 index 0000000..4fb7b58 --- /dev/null +++ b/Zeroflow/Views/NotchPanelView.swift @@ -0,0 +1,514 @@ +import SwiftUI +import AppKit +import EventKit + +/// 灵动岛面板的一条日程(主线程由 EKEvent 转换生成) +struct NotchEventItem: Identifiable { + let id: String + let title: String + let timeText: String + let color: NSColor +} + +/// 月视图日历的视图模型(仅主线程读写)。面板每次弹出 reset() 回到今天。 +/// 数据加载走后台查询,代数(monthGen/dayGen)作废在途结果,面板关开/快速翻月不串数据。 +final class NotchCalendarViewModel: ObservableObject { + @Published private(set) var monthAnchor: Date = Date() + @Published private(set) var selectedDate: Date = Date() + /// 显示月份中有日程的日期(本地时区当日零点) + @Published private(set) var monthEventDays: Set = [] + @Published private(set) var dayEvents: [NotchEventItem] = [] + @Published private(set) var authStatus: EKAuthorizationStatus = CalendarService.shared.status + + private var monthGen = 0 + private var dayGen = 0 + + /// 日历与本地化随设置语言变化(跟随系统/zh/en/ja) + var calendar: Calendar { SettingsStore.shared.appLanguage.locale.calendar } + + var isAuthorized: Bool { authStatus == .fullAccess } + + // MARK: - 状态迁移 + + func reset() { + let now = Date() + monthAnchor = startOfMonth(now) + selectedDate = calendar.startOfDay(for: now) + refreshAuth() + if authStatus == .notDetermined { + CalendarService.shared.requestAccess { [weak self] _ in + guard let self else { return } + self.refreshAuth() + self.reloadMonth() + self.reloadDay() + } + } + reloadMonth() + reloadDay() + } + + func refreshAuth() { + let status = CalendarService.shared.status + if status != authStatus { + authStatus = status + } + } + + /// 翻月;选中日跟随到新月份同日(超出天数钳到月末) + func moveMonth(_ delta: Int) { + guard let next = calendar.date(byAdding: .month, value: delta, to: monthAnchor), + let range = calendar.range(of: .day, in: .month, for: next) else { return } + monthAnchor = startOfMonth(next) + var comps = calendar.dateComponents([.year, .month], from: next) + comps.day = min(calendar.component(.day, from: selectedDate), range.count) + if let moved = calendar.date(from: comps) { + selectedDate = calendar.startOfDay(for: moved) + } + reloadMonth() + reloadDay() + } + + func select(_ day: Date) { + let dayStart = calendar.startOfDay(for: day) + guard dayStart != selectedDate else { return } + selectedDate = dayStart + reloadDay() + } + + // MARK: - 数据加载 + + private func reloadMonth() { + monthGen += 1 + let gen = monthGen + guard let interval = calendar.dateInterval(of: .month, for: monthAnchor) else { return } + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + let events = CalendarService.shared.events(from: interval.start, to: interval.end) + DispatchQueue.main.async { [weak self] in + guard let self, gen == self.monthGen else { return } + var days = Set() + for event in events { + // 跨天日程在区间内的每一天都打点(EventKit 结束时间为独占的次日零点) + let from = max(event.startDate, interval.start) + let to = min(event.endDate, interval.end) + var day = self.calendar.startOfDay(for: from) + while day < to { + days.insert(day) + guard let next = self.calendar.date(byAdding: .day, value: 1, to: day) else { break } + day = next + } + } + self.monthEventDays = days + } + } + } + + private func reloadDay() { + dayGen += 1 + let gen = dayGen + let dayStart = selectedDate + guard let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) else { return } + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + let events = CalendarService.shared.events(from: dayStart, to: dayEnd) + DispatchQueue.main.async { [weak self] in + guard let self, gen == self.dayGen else { return } + self.dayEvents = events + .sorted { lhs, rhs in + if lhs.isAllDay != rhs.isAllDay { return lhs.isAllDay } + if lhs.startDate != rhs.startDate { return lhs.startDate < rhs.startDate } + return lhs.title < rhs.title + } + .map { self.makeItem($0) } + } + } + } + + private func makeItem(_ event: EKEvent) -> NotchEventItem { + // macOS 上 EKCalendar.color 即 NSColor(iOS 才是 CGColor) + let color = event.calendar?.color ?? .controlAccentColor + let timeText: String + if event.isAllDay { + timeText = L10n.tr("全天") + } else { + // EventKit 的 endDate 为独占(次日零点),回退 1s 取真实末日 + let lastDay = event.endDate.addingTimeInterval(-1) + if calendar.isDate(event.startDate, inSameDayAs: lastDay) { + let end = lastDay + if calendar.compare(event.startDate, to: end, toGranularity: .minute) == .orderedSame { + timeText = Self.timeFormatter.string(from: event.startDate) + } else { + timeText = "\(Self.timeFormatter.string(from: event.startDate)) – \(Self.timeFormatter.string(from: end))" + } + } else { + timeText = "\(Self.dayFormatter.string(from: event.startDate)) – \(Self.dayFormatter.string(from: lastDay))" + } + } + let title = (event.title?.isEmpty == false) ? event.title! : L10n.tr("无标题日程") + return NotchEventItem( + id: "\(event.calendarItemIdentifier)-\(event.startDate.timeIntervalSince1970)", + title: title, + timeText: timeText, + color: color + ) + } + + private static let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + return formatter + }() + + private static let dayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "M/d" + return formatter + }() + + // MARK: - 月网格 + + func startOfMonth(_ date: Date) -> Date { + calendar.date(from: calendar.dateComponents([.year, .month], from: date)) ?? date + } + + /// 月网格单元格:按周首日补前置空位 + 当月日期,末尾补齐到 6 行(面板高度稳定不跳) + func monthCells() -> [Date?] { + guard let interval = calendar.dateInterval(of: .month, for: monthAnchor) else { return [] } + let leading = (calendar.component(.weekday, from: interval.start) - calendar.firstWeekday + 7) % 7 + var cells: [Date?] = Array(repeating: nil, count: leading) + var day = interval.start + while day < interval.end { + cells.append(day) + guard let next = calendar.date(byAdding: .day, value: 1, to: day) else { break } + day = next + } + while cells.count < 42 { cells.append(nil) } + return cells + } + + /// 按周首日重排的极短星期符号 + var weekdaySymbols: [String] { + let symbols = calendar.veryShortStandaloneWeekdaySymbols + let first = calendar.firstWeekday - 1 + return Array(symbols[first...] + symbols[.. DateFormatter { + let formatter = DateFormatter() + formatter.calendar = calendar + formatter.locale = SettingsStore.shared.appLanguage.locale + formatter.setLocalizedDateFormatFromTemplate(template) + return formatter + } + + func dayNumber(_ date: Date) -> Int { + calendar.component(.day, from: date) + } + + func isToday(_ date: Date) -> Bool { + calendar.isDateInToday(date) + } + + func isSelected(_ date: Date) -> Bool { + date == selectedDate + } + + func hasEvents(_ date: Date) -> Bool { + monthEventDays.contains(date) + } +} + +/// 灵动岛面板内容:日历列(月历 + 当日日程)贴左,预留右侧空白区; +/// 无内容模块时显示「添加显示内容」引导。宽度 = 面板宽,日历列固定原尺寸不随宽度拉伸。 +struct NotchContentView: View { + @ObservedObject var model: NotchCalendarViewModel + + private let accent = Color.accentColor + private static let columns = Array(repeating: GridItem(.flexible(), spacing: 0), count: 7) + /// 日历列宽度(面板加宽前的内容宽,保持原比例) + private static let calendarColumnWidth: CGFloat = 332 + + var body: some View { + // 单模块居中;后续多模块在 HStack 内并排、整体居中 + HStack(alignment: .top, spacing: 16) { + if SettingsStore.shared.notchShowCalendar { + calendarColumn + } else { + emptyState + } + } + .padding(.vertical, 14) + .frame(width: NotchPanel.preferredWidth, alignment: .center) + } + + private var calendarColumn: some View { + VStack(spacing: 10) { + header + weekdayRow + monthGrid + Divider() + daySection + } + .frame(width: Self.calendarColumnWidth) + } + + private var emptyState: some View { + VStack(spacing: 10) { + Text(L10n.tr("暂无显示内容")) + .font(.system(size: 12)) + .foregroundColor(.secondary) + Button(L10n.tr("添加显示内容")) { + SettingsView.pendingTab = .notch + NotificationCenter.default.post(name: .zeroflowOpenNotchSettings, object: nil) + } + .controlSize(.small) + } + .padding(.vertical, 16) + } + + private var header: some View { + HStack(spacing: 8) { + Button(L10n.tr("今天")) { + model.reset() + } + .buttonStyle(.plain) + .font(.system(size: 11)) + .foregroundColor(.secondary) + + Spacer() + Text(model.monthTitle) + .font(.system(size: 14, weight: .semibold)) + Spacer() + + HStack(spacing: 4) { + monthNavButton("chevron.left") { model.moveMonth(-1) } + monthNavButton("chevron.right") { model.moveMonth(1) } + } + } + } + + private func monthNavButton(_ symbol: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: symbol) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.secondary) + .frame(width: 20, height: 20) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private var weekdayRow: some View { + HStack(spacing: 0) { + ForEach(model.weekdaySymbols, id: \.self) { symbol in + Text(symbol) + .font(.system(size: 10)) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity) + } + } + } + + private var monthGrid: some View { + LazyVGrid(columns: Self.columns, spacing: 4) { + ForEach(Array(model.monthCells().enumerated()), id: \.offset) { _, cell in + if let date = cell { + dayCell(date) + } else { + Color.clear.frame(height: 30) + } + } + } + } + + private func dayCell(_ date: Date) -> some View { + let selected = model.isSelected(date) + let today = model.isToday(date) + return VStack(spacing: 2) { + Text("\(model.dayNumber(date))") + .font(.system(size: 12, weight: selected || today ? .semibold : .regular)) + .foregroundColor(selected ? .white : (today ? accent : .primary)) + Circle() + .fill(selected ? Color.white.opacity(0.9) : accent) + .frame(width: 3, height: 3) + .opacity(model.hasEvents(date) ? 1 : 0) + } + .frame(maxWidth: .infinity) + .frame(height: 30) + .background { + if selected { + Circle() + .fill(accent) + .frame(width: 26, height: 26) + } else if today { + Circle() + .strokeBorder(accent.opacity(0.7), lineWidth: 1) + .frame(width: 26, height: 26) + } + } + .contentShape(Rectangle()) + .onTapGesture { + model.select(date) + } + } + + @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) + } + } + .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) + } + } + } + } + .padding(.vertical, 1) + } + .frame(maxHeight: 240) + } + } + } +} + +/// 灵动岛面板:非激活 borderless 面板,深色 HUD 磨砂,从刘海正下方滑入。 +/// 悬停/点击/Esc 判定全部由 NotchHotspotController 的 listen-only tap 完成, +/// 本面板只负责内容展示(不成为 key,无键盘交互)。 +final class NotchPanel: NSPanel { + static let preferredWidth: CGFloat = 560 + /// 圆角半径(≈Mac 屏幕圆角;屏幕圆角无公开 API 可查询) + static let cornerRadius: CGFloat = 16 + + init() { + super.init(contentRect: NSRect(x: 0, y: 0, width: Self.preferredWidth, height: 200), + styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false) + self.level = .screenSaver + self.isOpaque = false + self.backgroundColor = .clear + self.hasShadow = true + self.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + self.isReleasedWhenClosed = false + self.animationBehavior = .none + self.hidesOnDeactivate = false + // 固定深色外观:HUD 磨砂 + 白字(灵动岛风格,不随系统深浅模式变化) + self.appearance = NSAppearance(named: .darkAqua) + } + + override var canBecomeKey: Bool { false } + override var canBecomeMain: Bool { false } + + /// 依据刘海热区(主屏左上原点坐标)重建内容、测尺寸、定位并滑入显示。 + /// 面板顶边贴刘海下边缘(热区竖向范围 = 刘海竖向范围)、水平居中刘海; + /// 每次 present 重建 contentView(模型重置到今天,同时取最新透明度/内容设置)。 + 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) + // 热区 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) + // 从刘海后方 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) + setFrame(startFrame, display: false) + alphaValue = 0 + orderFront(nil) + invalidateShadow() + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.18 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + animator().setFrame(finalFrame, display: true) + animator().alphaValue = 1 + } + } + + /// 液态玻璃背景(macOS 26+)或磨砂底(旧系统)包裹内容,圆角≈屏幕圆角。 + /// 透明度经玻璃染色深浅控制——直接对玻璃设 alphaValue 会连内容一起变透明。 + private func makeContentView(model: NotchCalendarViewModel) -> NSView { + let hosting = NSHostingView(rootView: NotchContentView(model: model)) + 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 + } + let frost = NSVisualEffectView() + frost.material = .hudWindow + frost.blendingMode = .behindWindow + frost.state = .active + frost.wantsLayer = true + 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 + } + + private func pinEdges(of view: NSView, to container: NSView) { + NSLayoutConstraint.activate([ + view.leadingAnchor.constraint(equalTo: container.leadingAnchor), + view.trailingAnchor.constraint(equalTo: container.trailingAnchor), + view.topAnchor.constraint(equalTo: container.topAnchor), + view.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + } + + private static func clamp(_ value: CGFloat, _ minValue: CGFloat, _ maxValue: CGFloat) -> CGFloat { + guard maxValue >= minValue else { return minValue } + return min(max(value, minValue), maxValue) + } +} diff --git a/Zeroflow/Views/SettingsView.swift b/Zeroflow/Views/SettingsView.swift index 77e50bf..b3ccebd 100644 --- a/Zeroflow/Views/SettingsView.swift +++ b/Zeroflow/Views/SettingsView.swift @@ -9,47 +9,90 @@ enum SettingsTab: String, CaseIterable { case switcher = "切换" case dock = "Dock" case dockPreview = "Dock 预览" + case notch = "灵动岛" case finder = "右键菜单" var title: String { L10n.tr(rawValue) } } struct SettingsView: View { + /// 跨窗口跳转:外部(如灵动岛面板「添加显示内容」)先置此值再发通知,窗口首次打开时消费 + static var pendingTab: SettingsTab? + @StateObject private var store = SettingsStore.shared @State private var selectedTab: SettingsTab = .screenshot var body: some View { - VStack(spacing: 0) { - Picker("", selection: $selectedTab) { + HStack(spacing: 0) { + // 左侧:纵向标签栏(仿系统设置) + VStack(alignment: .leading, spacing: 4) { ForEach(SettingsTab.allCases, id: \.self) { tab in - Text(tab.title).tag(tab) + sidebarRow(tab) } + Spacer() } - .pickerStyle(.segmented) - .labelsHidden() - .padding(.bottom, 12) - - switch selectedTab { - case .general: - generalTab - case .screenshot: - screenshotTab - case .switcher: - switcherTab - case .dock: - dockTab - case .dockPreview: - dockPreviewTab - case .finder: - finderTab + .padding(10) + .frame(width: 168) + + Divider() + + // 右侧:当前标签的详细设置(Form .grouped 自带滚动) + Group { + switch selectedTab { + case .general: + generalTab + case .screenshot: + screenshotTab + case .switcher: + switcherTab + case .dock: + dockTab + case .dockPreview: + dockPreviewTab + case .notch: + notchTab + case .finder: + finderTab + } } + .frame(maxWidth: .infinity, maxHeight: .infinity) } - .frame(minWidth: 520, minHeight: 560) - .padding() + .frame(minWidth: 760, minHeight: 560) // 无权限触发截图时自动跳到「截图」标签页,让权限引导直接可见 .onReceive(NotificationCenter.default.publisher(for: .zeroflowOpenSettings)) { _ in selectedTab = .screenshot } + .onReceive(NotificationCenter.default.publisher(for: .zeroflowOpenNotchSettings)) { _ in + Self.pendingTab = nil + selectedTab = .notch + } + // 窗口首次打开时(通知先于视图存在)消费外部预置的标签页 + .onAppear { + if let tab = Self.pendingTab { + Self.pendingTab = nil + selectedTab = tab + } + } + } + + private func sidebarRow(_ tab: SettingsTab) -> some View { + let selected = selectedTab == tab + return Button { + selectedTab = tab + } label: { + Text(tab.title) + .font(.system(size: 13, weight: selected ? .medium : .regular)) + .foregroundColor(selected ? .white : .primary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(selected ? Color(nsColor: .controlAccentColor) : .clear) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) } // MARK: - 截图 @@ -245,6 +288,82 @@ struct SettingsView: View { .formStyle(.grouped) } + // MARK: - 灵动岛 + + private var notchTab: some View { + Form { + Section { + Toggle(L10n.tr("启用灵动岛"), isOn: $store.notchEnabled) + .onChange(of: store.notchEnabled) { enabled in + guard enabled else { return } + if !AccessibilityPermission.isGranted { + AccessibilityPermission.requestAuthorization() + } + } + } header: { + Text(L10n.tr("启用")) + } footer: { + Text(L10n.tr("鼠标悬停或单击屏幕顶部刘海区域,从刘海向下弹出日历面板;点击面板外部或按 Esc 收起。仅支持带刘海的机型。")) + } + + if store.notchEnabled { + Section { + VStack(alignment: .leading, spacing: 6) { + LabeledContent(L10n.tr("悬停延时")) { + Text("\(store.notchHoverDelayMs) ms") + .monospacedDigit() + .foregroundColor(.secondary) + } + Slider(value: Binding( + get: { Double(store.notchHoverDelayMs) }, + set: { store.notchHoverDelayMs = Int($0) } + ), in: 100...1000, step: 50) + } + } header: { + Text(L10n.tr("选项")) + } footer: { + Text(L10n.tr("鼠标停留在刘海区域多久后弹出面板;单击刘海区域可立即弹出。")) + } + + Section { + Toggle(L10n.tr("日历"), isOn: $store.notchShowCalendar) + } header: { + Text(L10n.tr("显示内容")) + } footer: { + Text(L10n.tr("弹出面板中显示的内容模块。全部关闭时,面板仍会弹出并提示添加显示内容。")) + } + + Section { + VStack(alignment: .leading, spacing: 6) { + LabeledContent(L10n.tr("不透明度")) { + Text("\(store.notchPanelOpacity)%") + .monospacedDigit() + .foregroundColor(.secondary) + } + 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("macOS 26 及以上使用液态玻璃面板背景,旧系统为系统磨砂;不透明度数值越小越透明。重新弹出面板后生效。")) + } + + Section { + AccessibilityPermissionRow(message: L10n.tr("需要「辅助功能」权限才能监测刘海悬停")) + Text(L10n.tr("该权限与 Dock 预览等功能共用,若已开启则此处直接显示绿色。")) + .font(.caption) + .foregroundColor(.secondary) + } header: { + Text(L10n.tr("权限")) + } + } + } + .formStyle(.grouped) + } + // MARK: - 右键菜单 private var finderTab: some View {