From 9ba7cd3b47fb91eaaa8d0c3df8b7286a89be1756 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Sun, 28 Jun 2026 21:19:30 +0800
Subject: [PATCH] Freeze 0.1.12 local QA baseline
---
C1.source/Tests/AlignerCoreTests/EntitlementPolicyTests.swift | 203 ++
.gitignore | 4
C1.source/Package.swift | 2
C1.source/Sources/Aligner/Diagnostics/Round015EntitlementLaunchOptions.swift | 327 +++
C3.tools/round1-app-shelf-fixture-qa.sh | 232 ++
C3.tools/round1-window-activation-fixture-qa.sh | 102 +
C3.tools/round1-horizontal-waterfall-fixture-qa.sh | 38
C1.source/CODEGRAPH.md | 546 ++++++
C1.source/Sources/AlignerCore/Screenshots/ScreenCaptureKitScreenshotProvider.swift | 4
C1.source/Sources/Aligner/AlignerApplicationDelegate.swift | 84
C1.source/Sources/AlignerCore/Windows/WindowEnumerationPolicy.swift | 18
C3.tools/release-manifest-template.json | 25
C3.tools/round015-macos14-compatibility-qa.sh | 86 +
K2.项目管理/QA成果地图.md | 115 +
C3.tools/round1-vertical-keyboard-app-focus-fixture-qa.sh | 39
C3.tools/round1-window-index-progressive-filter-fixture-qa.sh | 73
C3.tools/verify-release-artifact.sh | 56
C3.tools/round1-main-ui-qa.sh | 3
K2.项目管理/Aligner2接收验证记录-2026-06-28.md | 83
C1.source/Sources/Aligner/QuickSwitchRootView.swift | 530 +++++-
K2.项目管理/研发交付验收流程.md | 14
C1.source/Sources/Aligner/Infrastructure/Windows/CGWindowAXWindowService.swift | 731 ++++++--
.codebase-memory/.gitattributes | 3
K1.业务知识/设计成果地图.md | 66
K2.项目管理/迁移资产清单.md | 37
C1.source/Sources/AlignerCore/Commerce/EntitlementState.swift | 336 +++
C3.tools/round015-entitlement-fixture-qa.sh | 299 +++
.codebase-memory/graph.db.zst | 0
C1.source/Sources/Aligner/DebugWindowActivationService.swift | 13
C3.tools/release-app.sh | 132 +
C1.source/Sources/Aligner/QuickSwitchSessionController.swift | 72
CHANGELOG.md | 59
C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift | 29
README.md | 14
C3.tools/round1-dock-reopen-fixture-qa.sh | 177 ++
K1.业务知识/成果地图.md | 81
K2.项目管理/新窗口接手指南.md | 107 +
C1.source/AGENTS.md | 29
C3.tools/round1-space-filter-fixture-qa.sh | 45
C3.tools/round1-trigger-session-qa.sh | 4
.codebase-memory/artifact.json | 11
C3.tools/round1-obsidian-space-activation-qa.sh | 11
C3.tools/generate-release-manifest.sh | 181 ++
C1.source/Resources/Aligner-Info.plist | 6
C3.tools/public-latest-manifest-template.json | 13
C3.tools/round1-mouse-interaction-fixture-qa.sh | 17
C1.source/Sources/AlignerCore/AppInfo.swift | 2
47 files changed, 4,643 insertions(+), 416 deletions(-)
diff --git a/.codebase-memory/.gitattributes b/.codebase-memory/.gitattributes
new file mode 100644
index 0000000..7ffe250
--- /dev/null
+++ b/.codebase-memory/.gitattributes
@@ -0,0 +1,3 @@
+# Auto-generated by codebase-memory-mcp
+# Prevent merge conflicts on compressed artifact
+graph.db.zst merge=ours binary
diff --git a/.codebase-memory/artifact.json b/.codebase-memory/artifact.json
new file mode 100644
index 0000000..cabbc3f
--- /dev/null
+++ b/.codebase-memory/artifact.json
@@ -0,0 +1,11 @@
+{
+ "schema_version": 1,
+ "commit": "1fea9bfc8a7639bb3573a7cf2a449ab55065ab9e",
+ "indexed_at": "2026-06-27T17:07:38Z",
+ "project": "Users-ar-Projects-Aligner2-03-O",
+ "nodes": 3988,
+ "edges": 12382,
+ "original_size": 7864320,
+ "compressed_size": 1453118,
+ "compression_level": 9
+}
\ No newline at end of file
diff --git a/.codebase-memory/graph.db.zst b/.codebase-memory/graph.db.zst
new file mode 100644
index 0000000..9accbeb
--- /dev/null
+++ b/.codebase-memory/graph.db.zst
Binary files differ
diff --git a/.gitignore b/.gitignore
index 1c093f0..3cd86d2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
.DS_Store
# SwiftPM
+.build/
C1.source/.build/
C1.source/.swiftpm/
C1.source/Package.resolved
@@ -13,5 +14,6 @@
# Logs
*.log
-# Build and release artifacts
+# Local build and release artifacts. Formal release recoverability is tracked
+# by source commit, release tag, manifest, SHA256, and GitHub Release assets.
C2.builds/
diff --git a/C1.source/AGENTS.md b/C1.source/AGENTS.md
index f6dd313..52f9d10 100644
--- a/C1.source/AGENTS.md
+++ b/C1.source/AGENTS.md
@@ -1,25 +1,26 @@
# C1.source/AGENTS.md - 源码目录规则
-适用于 `/Users/ar/Projects/Aligner/03-O/C1.source`。
+适用于 `/Users/ar/Projects/Aligner2/03-O/C1.source`。
## 当前事实
- 本目录是 SwiftPM 源码根目录。
-- 本目录不是 Git 根目录;Git 命令在 `/Users/ar/Projects/Aligner/03-O` 下执行。
+- 本目录不是 Git 根目录;Git 命令在 `/Users/ar/Projects/Aligner2/03-O` 下执行。
- App 名:`Aligner`。
- Bundle ID:`com.ar.Aligner`。
-- 最低 macOS:`26.0`。
+- 最低 macOS:`14.0`。
- 当前产品入口:只有 `quickSwitch`。
- 默认 Quick Switch 快捷键:`Option+Tab`。
## 读取顺序
-1. `/Users/ar/Projects/Aligner/AGENTS.md`
-2. `/Users/ar/Projects/Aligner/PROJECT.md`
-3. `/Users/ar/Projects/Aligner/GLOSSARY.md`
-4. `/Users/ar/Projects/Aligner/03-O/README.md`
-5. 当前任务相关的 Round / 架构文档
-6. UI 修改时读 `/Users/ar/Projects/Aligner/DESIGN.md`
+1. `/Users/ar/Projects/Aligner2/AGENTS.md`
+2. `/Users/ar/Projects/Aligner2/PROJECT.md`
+3. `/Users/ar/Projects/Aligner2/GLOSSARY.md`
+4. `/Users/ar/Projects/Aligner2/03-O/README.md`
+5. `/Users/ar/Projects/Aligner2/03-O/C1.source/CODEGRAPH.md`
+6. 当前任务相关的 Round / 架构文档
+7. UI 修改时读 `/Users/ar/Projects/Aligner2/DESIGN.md`
## 代码边界
@@ -36,20 +37,22 @@
- 过滤幽灵窗口和非任务系统浮层。
- 全屏 App 窗口必须可发现。
- 永远不要吞掉或阻挡 `Command+Option+Esc` Force Quit。
+- 非平凡 Quick Switch 改动前,先读 `CODEGRAPH.md` 中对应 node,再读 node 指向的源码和 QA。
+- 用户要求“冻结版本”时,必须在打 tag 前更新并校验 `CODEGRAPH.md`。
## 验证
-从 `/Users/ar/Projects/Aligner/03-O/C1.source` 执行:
+从 `/Users/ar/Projects/Aligner2/03-O/C1.source` 执行:
```bash
-swift build 2>&1 | head -c 6000
-swift test 2>&1 | head -c 6000
+set -o pipefail; swift build 2>&1 | head -c 6000
+set -o pipefail; swift test 2>&1 | head -c 6000
```
打包时执行:
```bash
-/Users/ar/Projects/Aligner/03-O/C3.tools/package-app.sh 2>&1 | head -c 6000
+set -o pipefail; /Users/ar/Projects/Aligner2/03-O/C3.tools/package-app.sh 2>&1 | head -c 6000
```
如果不能运行验证,说明具体原因和剩余风险。
diff --git a/C1.source/CODEGRAPH.md b/C1.source/CODEGRAPH.md
new file mode 100644
index 0000000..16ab1e2
--- /dev/null
+++ b/C1.source/CODEGRAPH.md
@@ -0,0 +1,546 @@
+# Aligner CODEGRAPH
+
+最后更新:2026-06-28
+
+## 用途
+
+这是 Aligner 自维护的轻量工程地图,用来减少后续任务的代码定位成本和上下文重建 token。它不是完整架构文档,也不是自动生成的调用图;它只记录稳定入口、影响面、QA 入口和已保护行为。
+
+Aligner2 同时维护两类代码地图:
+
+- `codegraph`:人工维护的本文件,记录模块边界、关键入口、受保护行为、QA 入口和最后验证基线。
+- `codebase-memory`:`codebase-memory-mcp` 生成的自动代码知识图谱,保存到 `../.codebase-memory/graph.db.zst`,用于查询调用链、影响面、死代码候选和架构概览。
+
+两者不互相替代。自动图谱结果必须回读源码、测试和 QA 证据。
+
+使用规则:
+
+- 非平凡 Quick Switch 任务,先读本文件,再读节点指向的源码和 QA。
+- 本文件只做索引,不替代源码、测试和真实 QA。
+- 版本冻结时必须更新本文件,并在打 tag 前校验路径。
+- 每个节点保持短、硬、可核对;不要写成长篇说明。
+
+当前基线:
+
+- 功能系列:`0.1.x`
+- 当前可核对 tag:`v0.1.12-build20260625.2320`
+- 最近本地验收候选包:`0.1.12 / 20260625.2320`
+- 最后冻结前验证:2026-06-28,`swift build`、`swift test`、`round015-entitlement-fixture-qa.sh`、`round1-main-ui-qa.sh` 通过;主 UI QA 日志目录 `/tmp/aligner-round01-main-ui-qa-20260628_210821`。
+- 发布事实:`0.1.12` 是本地 QA 候选代码冻结 / rollback baseline;不是正式官网 release,Developer ID、notarization、staple、Gatekeeper、macOS 14 Phase C、下载源和正式发布 manifest 仍未闭环。
+- Git 根目录:`/Users/ar/Projects/Aligner2/03-O`
+- SwiftPM 根目录:`/Users/ar/Projects/Aligner2/03-O/C1.source`
+- codebase-memory 共享产物:`/Users/ar/Projects/Aligner2/03-O/.codebase-memory/graph.db.zst`
+- codebase-memory 项目名:`Users-ar-Projects-Aligner2-03-O`
+- codebase-memory 索引范围:`C1.source`、`C3.tools`
+- codebase-memory 当前规模:`3988` nodes / `12382` edges
+
+## 更新触发
+
+以下情况必须更新 CODEGRAPH:
+
+1. 用户要求“冻结版本”。
+2. 新增或修改用户可见交互。
+3. 修改 Quick Switch 状态模型、窗口枚举、Space 归属、激活、关闭、主题、视图切换或键盘导航。
+4. 新增、移动或删除核心 QA 脚本 / fixture / report 入口。
+5. 移动、重命名或拆分本文件记录的核心源码。
+6. 修复反复出现的高风险问题,并补充已保护行为。
+
+## 节点索引
+
+| Node | 负责范围 | 优先读取 |
+| --- | --- | --- |
+| `QuickSwitch.Session` | overlay 生命周期、回调 wiring、提交窗口、Space 点击、关闭请求 | `Sources/Aligner/QuickSwitchSessionController.swift` |
+| `QuickSwitch.View` | 三栏 UI、App Shelf、Waterfall、主题、键盘和鼠标交互 | `Sources/Aligner/QuickSwitchRootView.swift` |
+| `QuickSwitch.Waterfall` | 纵栏 / 横栏瀑布、卡片、缩略图、对齐和过滤态动画 | `Sources/Aligner/QuickSwitchRootView.swift` |
+| `QuickSwitch.Snapshot` | 窗口候选集、App 分组、窗口排序、Space 主归属 | `Sources/AlignerCore/Windows/QuickSwitchSnapshot.swift` |
+| `QuickSwitch.ViewModel` | Space Lane / App Shelf / Waterfall 的展示模型和 Space 过滤投影 | `Sources/AlignerCore/Windows/QuickSwitchViewModel.swift` |
+| `QuickSwitch.Focus` | 初始选择、方向键移动、视觉优先级 | `Sources/AlignerCore/Windows/QuickSwitchFocusPolicy.swift` |
+| `QuickSwitch.Windows` | CG/AX 窗口枚举、激活、关闭、Finder 多页签 | `Sources/Aligner/Infrastructure/Windows/CGWindowAXWindowService.swift` |
+| `QuickSwitch.Spaces` | Space 枚举、窗口 Space 归属、Space 激活 | `Sources/Aligner/Infrastructure/PrivateAPI/SkyLightSpaceEnumerator.swift` |
+| `QuickSwitch.ThemeAndSettings` | 视图模式、主题、设置持久化、Settings 入口 | `Sources/AlignerCore/Preferences/AlignerPreferences.swift` |
+| `QuickSwitch.TriggerAndPermissions` | 快捷键、权限、Force Quit 放行、权限窗口 | `Sources/Aligner/AlignerApplicationDelegate.swift` |
+| `QuickSwitch.Screenshots` | 真实截图、ScreenCaptureKit、骨架 fallback | `Sources/AlignerCore/Screenshots/ScreenCaptureKitScreenshotProvider.swift` |
+| `Commerce.Entitlement` | Round01.5 试用、授权、Pro feature gate 模型 | `Sources/AlignerCore/Commerce/` |
+
+## Node: QuickSwitch.Session
+
+职责:
+
+- 打开 / 关闭 Quick Switch overlay。
+- 将 view 的用户动作接到 session 行为。
+- 处理窗口提交、Space filter、Space 激活、窗口关闭。
+- 控制截图刷新和 debug / QA report 状态。
+
+核心文件:
+
+- `Sources/Aligner/QuickSwitchSessionController.swift`
+- `Sources/Aligner/QuickSwitchOverlayHost.swift`
+- `Sources/Aligner/OverlayWindowController.swift`
+- `Sources/Aligner/WindowCoordinator.swift`
+- `Sources/Aligner/AlignerApplicationDelegate.swift`
+
+关键入口:
+
+- `QuickSwitchSessionController.show(...)`
+- `QuickSwitchSessionController.hide(reason:)`
+- `QuickSwitchSessionController.commitSelection(_:source:)`
+- `QuickSwitchSessionController.handleSpaceLaneClick(_:clickCount:)`
+- `QuickSwitchSessionController.requestClose(_:)`
+- `AlignerApplicationDelegate.applicationShouldHandleReopen(_:hasVisibleWindows:)`
+
+已保护行为:
+
+- `Option+Tab` 打开后 view 必须成为 first responder,键盘立即可用。
+- Dock reopen 只负责打开 Quick Switch,复用 `Option+Tab` 的打开路径;不得改主题、视图模式或布局选择。
+- 提交窗口前先隐藏 Quick Switch overlay,再执行窗口激活。
+- Force Quit `Command+Option+Esc` 不得被 Aligner 阻挡。
+- 关闭窗口返回 `.requested` 时不做乐观删除;必须刷新验证目标是否真的消失。
+- 目标 stale / `windowNotFound` 时允许从当前展示中移除,避免用户继续点残留卡片。
+
+QA 入口:
+
+- `../C3.tools/round1-quick-switch-entry-qa.sh`
+- `../C3.tools/round1-trigger-session-qa.sh`
+- `../C3.tools/round1-dock-reopen-fixture-qa.sh`
+- `../C3.tools/round1-overlay-qa.sh`
+- `../C3.tools/round1-main-ui-qa.sh`
+- `../C3.tools/round1-close-fixture-qa.sh`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-trigger-session-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-dock-reopen-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-overlay-qa-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-close-card-confirmation-report.json`
+
+## Node: QuickSwitch.Snapshot
+
+职责:
+
+- 从原始窗口集合构造 Quick Switch 候选集。
+- 按 App 分组,按 Space / 标题 / window id 排序。
+- 计算窗口 primary Space。
+- 保留最小化和全屏窗口,过滤隐藏 App、幽灵窗口和系统浮层。
+
+核心文件:
+
+- `Sources/AlignerCore/Windows/QuickSwitchSnapshot.swift`
+- `Sources/AlignerCore/Windows/WindowEnumerationPolicy.swift`
+- `Sources/AlignerCore/Windows/FinderTabSpaceAttributionPolicy.swift`
+- `Sources/AlignerCore/Spaces/WindowSpaceMappingPolicy.swift`
+- `Sources/AlignerCore/Spaces/SpaceEnumerationPolicy.swift`
+- `Sources/Aligner/QuickSwitchSnapshotLoader.swift`
+- `Sources/Aligner/Infrastructure/Windows/CGWindowAXWindowService.swift`
+
+关键入口:
+
+- `QuickSwitchSnapshotBuilder.snapshot(displays:windows:)`
+- `WindowEnumerationPolicy.window(from:)`
+- `FinderTabSpaceAttributionPolicy.attributedRecords(_:)`
+- `WindowSpaceMappingPolicy.mergedSpaceIDsByWindowID(...)`
+- `LiveQuickSwitchSnapshotLoader.load()`
+
+已保护行为:
+
+- 最小化窗口必须展示,选中后恢复。
+- 隐藏 App 窗口不展示。
+- 全屏 App 窗口必须可发现。
+- Finder Split View / 多页签必须优先用高置信 frame / host 关系补 Space 归属。
+- No-space 窗口不得无脑归属到当前 Space;只在数据层高置信时归属。
+- 窗口排序保持稳定:Space 顺序优先,其次标题,再次 window id。
+
+QA 入口:
+
+- `Tests/AlignerCoreTests/AlignerCoreTests.swift`
+- `../C3.tools/round1-candidate-filter-fixture-qa.sh`
+- `../C3.tools/round1-finder-tabs-live-qa.sh`
+- `../C3.tools/round1-minimized-window-qa.sh`
+- `../C3.tools/round1-session-snapshot-qa.sh`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-multi-page-activation-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-multi-page-close-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-minimized-window-qa-snapshot.json`
+
+## Node: QuickSwitch.ViewModel
+
+职责:
+
+- 把 snapshot 投影为三栏 UI 模型。
+- 生成 Space Lane、App Shelf、Waterfall 的展示数据。
+- 处理 Space filter 投影。
+- 识别“单全屏窗口 Space 直接激活”。
+- 为 Split View Space 输出左右 App 名称。
+
+核心文件:
+
+- `Sources/AlignerCore/Windows/QuickSwitchViewModel.swift`
+- `Sources/AlignerCore/Windows/QuickSwitchFocusPolicy.swift`
+
+关键入口:
+
+- `QuickSwitchViewModelBuilder.viewModel(from:currentSpaceIDs:)`
+- `QuickSwitchSpaceFilterPolicy.projectedViewModel(from:lockedSpaceID:preferredSelection:)`
+- `QuickSwitchSpaceFilterPolicy.singleFullscreenSelection(inSpaceID:viewModel:)`
+- `QuickSwitchFocusPolicy.initialSelection(in:)`
+- `QuickSwitchFocusPolicy.nextSelection(from:in:direction:columnHistory:)`
+
+已保护行为:
+
+- Space filter 只影响 App Shelf + Waterfall,不过滤顶部 Space Lane。
+- 锁定 Space 后,App Shelf 只显示该 Space 下有窗口的 App。
+- No-space 窗口不进入 Space filter 结果,除非已有高置信 Space 归属。
+- 点击空 Space 后保持锁定并显示空态;再次点击同 Space 解锁。
+- 锁定后关闭最后一个窗口,不自动解锁。
+- 单全屏应用窗口 Space 不提供过滤;点击时直接激活对应全屏窗口,并释放原锁定。
+- Split View App 名称按窗口 frame 的左右顺序输出。
+
+QA 入口:
+
+- `Tests/AlignerCoreTests/AlignerCoreTests.swift`
+- `../C3.tools/round1-space-filter-fixture-qa.sh`
+- `../C3.tools/round1-space-lane-split-view-fixture-qa.sh`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-space-filter-switch-a2-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-space-filter-fullscreen-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-space-lane-split-view-fixture-report.json`
+
+## Node: QuickSwitch.View
+
+职责:
+
+- 绘制 Space Lane / App Shelf / Waterfall。
+- 处理鼠标 hover、点击、滚动和键盘输入。
+- 维护 hover、keyboard focus、selection、pending window shortcut filter 等 UI 状态。
+- 生成 debug / QA report。
+
+核心文件:
+
+- `Sources/Aligner/QuickSwitchRootView.swift`
+
+关键入口:
+
+- `QuickSwitchRootView.keyDown(with:)`
+- `QuickSwitchRootView.performKeyboardCommand(_:)`
+- `QuickSwitchRootView.handleAppShelfIndexKey(_:)`
+- `QuickSwitchRootView.handleWindowIndexKey(symbol:)`
+- `QuickSwitchRootView.focusAppShelfItemForKeyboard(...)`
+- `QuickSwitchRootView.moveKeyboardAppFocus(cycleForward:)`
+- `QuickSwitchRootView.moveVerticalSelectionWithinFocusedColumn(_:)`
+- `QuickSwitchRootView.moveSelection(_:)`
+- `QuickSwitchRootView.commitCurrentSelection()`
+- `QuickSwitchRootView.makeDebugReport()`
+
+已保护行为:
+
+- App Shelf 序号为 `1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ`,最多 36 个一键入口。
+- App 序号键在纵栏和横栏瀑布下一视同仁,等效于 hover 到对应 App 图标。
+- App Shelf 鼠标 hover 只作用于真实 hover App:图标本身放大并轻微上浮,但不显示蓝色矩形底或蓝色矩形边框;纵栏瀑布和横栏瀑布下都适用。
+- 鼠标停留在 App 图标上不动时,即使 view-model / 截图刷新触发布局重建,真实 hover App 必须保持放大、上浮和 Waterfall 联动;不能自行恢复普通尺寸。
+- App Shelf hover 不做邻近 App 视觉跟随;左右邻居不放大、不上浮、不暴露 `magneticNeighbor`,也不触发 hover 语义、不显示 close button、不联动 Waterfall。
+- 用户开启 Reduce Motion 时,App Shelf 不做磁性放大动效,只保留稳定状态切换。
+- App Shelf 图标主体点击必须提交该 App 在当前 Waterfall 投影中的第一窗口;App close button 只能由明确的小热区触发,不能抢占图标主体点击。
+- 按住 Option 输入 App 序号后进入 0.8s 窗口序号 pending。
+- pending 第一键时,Waterfall 中该 App 的窗口保持亮度,其它窗口 dim;dim 只作用于 Waterfall 区。
+- 0.8s 内第二键命中窗口时直接 commit;提交前 report 记录 target pre-commit filter。
+- 若 App 当前可见 Waterfall 集合中只有一个窗口,第一键直接 commit,不等待第二键。
+- 横栏瀑布下 Enter 提交当前 App 对应的默认窗口。
+- 纵栏瀑布下左右键在 App Shelf 循环移动;上下键在当前 App column 内移动且不循环。
+- 横栏瀑布下方向键在卡片网格中移动,保留既有横栏交互。
+- close button 只在真实 hover 到 App / Window 卡片时显示,不因键盘 App focus 批量显示。
+
+QA 入口:
+
+- `../C3.tools/round1-keyboard-navigation-fixture-qa.sh`
+- `../C3.tools/round1-vertical-keyboard-app-focus-fixture-qa.sh`
+- `../C3.tools/round1-mouse-interaction-fixture-qa.sh`
+- `../C3.tools/round1-app-shelf-fixture-qa.sh`
+- `../C3.tools/round1-horizontal-waterfall-fixture-qa.sh`
+- `../C3.tools/round1-window-index-progressive-filter-fixture-qa.sh`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-keyboard-navigation-fixture-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-horizontal-waterfall-keyboard-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-app-shelf-fixture-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-app-shelf-horizontal-fixture-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-app-shelf-narrow-fixture-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-app-shelf-dark-fixture-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-app-shelf-hover-persist-vertical-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-app-shelf-hover-persist-horizontal-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-vertical-keyboard-window-shortcut-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-horizontal-waterfall-window-shortcut-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-window-index-progressive-filter-commit-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-window-index-progressive-filter-single-window-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-window-index-progressive-filter-horizontal-light-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-window-index-progressive-filter-horizontal-dark-report.json`
+
+## Node: QuickSwitch.Waterfall
+
+职责:
+
+- 维护纵栏瀑布和横栏瀑布两种 Waterfall 展示。
+- 处理 Space filter 后的整体居中和动画。
+- 控制 App hover 联动到对应 column / window 卡片。
+- 控制缩略图、骨架缩略图、标题栏、选中态和 hover 态。
+
+核心文件:
+
+- `Sources/Aligner/QuickSwitchRootView.swift`
+- `Sources/AlignerCore/Preferences/AlignerPreferences.swift`
+
+关键入口:
+
+- `QuickSwitchRootView.setWaterfallViewMode(_:)`
+- `QuickSwitchRootView.rebuildWaterfallColumns()`
+- `QuickSwitchRootView.layoutLayers()`
+- `QuickSwitchRootView.ensureCurrentSelectionVisible(...)`
+- `QuickSwitchRootView.waterfallColumnVisualStates(for:)`
+- `QuickSwitchRootView.waterfallCardVisualStates(for:)`
+
+已保护行为:
+
+- 纵栏窗口卡片固定高度为 138 pt,卡片间距 12 pt,纵向排列步进 150 pt。
+- 横栏瀑布窗口卡片沿用同一固定高度和截图截取规则。
+- Space filter 后,剩余 columns / cards 作为整体居中,并使用丝滑位置动画。
+- 过滤态居中不能被选中 column 强行对齐 App Shelf 图标拉偏。
+- App 图标 hover 时,对应 App 的全部窗口标题栏联动高亮并产生阴影凸起。
+- 鼠标 hover App 图标时,column header 使用浅灰填充;该 column 第一张窗口卡片使用当前应用 column 第一个窗口高亮标准。
+- 切换视图时不能全部退化为骨架缩略图;已有截图应继续展示。
+
+QA 入口:
+
+- `../C3.tools/round1-waterfall-fixture-qa.sh`
+- `../C3.tools/round1-horizontal-waterfall-fixture-qa.sh`
+- `../C3.tools/round1-app-column-alignment-fixture-qa.sh`
+- `../C3.tools/round1-app-shelf-fixture-qa.sh`
+- `../C3.tools/round1-view-mode-entry-qa.sh`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-waterfall-fixture-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-horizontal-waterfall-layout-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-horizontal-waterfall-filter-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-app-column-alignment-regular-hover-middle-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-view-mode-entry-horizontal-report.json`
+
+## Node: QuickSwitch.Spaces
+
+职责:
+
+- 枚举 Space。
+- 将窗口映射到 Space。
+- 渲染 Space Lane。
+- 处理 Space hover / lock / unlock / activate。
+
+核心文件:
+
+- `Sources/Aligner/Infrastructure/PrivateAPI/SkyLightSpaceEnumerator.swift`
+- `Sources/Aligner/Infrastructure/PrivateAPI/SkyLightOverlayScreenSpaceStateProvider.swift`
+- `Sources/Aligner/Infrastructure/PrivateAPI/PrivateSpaceActivationBridge.swift`
+- `Sources/Aligner/SpaceActivationService.swift`
+- `Sources/AlignerCore/Spaces/SpaceEnumerationPolicy.swift`
+- `Sources/AlignerCore/Spaces/WindowSpaceMappingPolicy.swift`
+- `Sources/Aligner/QuickSwitchSessionController.swift`
+- `Sources/Aligner/QuickSwitchRootView.swift`
+
+关键入口:
+
+- `SpaceEnumerationPolicy.normalizedDisplays(from:knownPhysicalDisplayUUIDs:)`
+- `WindowSpaceMappingPolicy.mergedSpaceIDsByWindowID(...)`
+- `QuickSwitchSessionController.handleSpaceLaneClick(_:clickCount:)`
+- `QuickSwitchSessionController.activateLockedSpace(_:)`
+- `QuickSwitchRootView.visualStates(for:)`
+- `QuickSwitchRootView.spaceLaneDebugReports()`
+
+已保护行为:
+
+- App 窗口 hover 时,对应 App 图标和窗口所在 Space 都要高亮。
+- 空 Space tile 填充保持透明;hover/current/associated 反馈只走边框/阴影,不出现灰色渐变。
+- 深色主题下 Space tile hover / 浅色高亮态必须切换深色前景,避免浅底白字。
+- 直接双击任意普通 Space tile,或双击当前已锁定 Space:关闭 Quick Switch 并切到该 Space,不提交窗口、不激活 App。
+- 单击空白区域:如果当前有 Space filter 则退出 filter;否则保持原行为。
+- 单全屏窗口 Space 直接激活,不进入过滤。
+
+QA 入口:
+
+- `../C3.tools/round1-space-filter-fixture-qa.sh`
+- `../C3.tools/round1-space-lane-dark-hover-fixture-qa.sh`
+- `../C3.tools/round1-space-lane-split-view-fixture-qa.sh`
+- `../C3.tools/round1-three-zone-linkage-fixture-qa.sh`
+- `../C3.tools/round1-obsidian-space-activation-qa.sh`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-space-dark-hover-light-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-space-dark-hover-split-base-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-three-zone-linkage-space-click-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-space-filter-lock-then-fullscreen-report.json`
+
+## Node: QuickSwitch.Windows
+
+职责:
+
+- CG / AX 窗口枚举。
+- 普通窗口、最小化窗口、全屏窗口、Finder 多页签的激活和关闭。
+- 多页签 / 非激活页签的精准激活与关闭。
+
+核心文件:
+
+- `Sources/Aligner/Infrastructure/Windows/CGWindowAXWindowService.swift`
+- `Sources/Aligner/Infrastructure/PrivateAPI/PrivateWindowActivationBridge.swift`
+- `Sources/Aligner/DebugWindowActivationService.swift`
+- `Sources/Aligner/DebugWindowCloseService.swift`
+- `Sources/Aligner/QuickSwitchCloseRequest.swift`
+- `Sources/AlignerCore/Protocols/ServiceProtocols.swift`
+
+关键入口:
+
+- `CGWindowAXWindowService.visibleWindows(for:)`
+- `CGWindowAXWindowService.activate(window:)`
+- `CGWindowAXWindowService.close(window:)`
+- `CGWindowAXWindowService.close(app:)`
+- `PrivateWindowActivationBridge.activate(windowID:processIdentifier:)`
+- `PrivateAXWindowIDReader.windowID(for:)`
+
+已保护行为:
+
+- Finder 多页签 / Split View 下,非激活页签仍应能展示并尽量精准激活。
+- Finder CG-only / 多页签激活必须有边界:交互路径使用 1.5 秒预算,循环切 tab 兜底最多 5 轮;超过边界时快速失败,不继续 24 轮高耗时等待。
+- Finder 直接 AX / WindowServer 激活不能直接视为精准成功;必须校验 Finder 前台标题,标题不匹配时进入有预算的页签 fallback。
+- Quick Switch activation report 必须记录 `lastActivationDurationMilliseconds`,用于 QA 判断窗口提交耗时。
+- 窗口卡片点击命中必须跟随 CALayer presentation frame,动画中的视觉位置就是可点击位置;不得用旧 model frame 造成点到视觉卡片却提交错窗口。
+- App Shelf 图标主体点击优先打开该 App 的第一个窗口;关闭按钮只在明确命中自身视觉区域时触发,不得抢占图标主体点击。
+- Live 枚举不得把低置信 AX-only 普通窗口当成真实窗口:非最小化、非全屏且无高置信 Space 的 AX-only 候选应跳过。
+- 窗口卡片精准激活不得把 `.appActivatedOnly` 当成成功;该结果只表示 App 激活诊断,不证明目标窗口已前置。
+- 窗口激活明确失败、目标不存在或 unsupported 时,本次投影视图应抑制该 windowID,避免继续展示可重复点击的 stale 卡片。
+- 真实激活 / 关闭服务必须和 live snapshot 使用同源 SkyLight Space resolver,避免 Finder、多页签、跨 Space fallback 拿空 Space 数据。
+- 普通窗口激活应按目标窗口执行 `AXFocusedWindow`、`AXMain`、`AXRaise`,AX 短暂缺失或首次动作失败时允许一次短重读重试。
+- 多页签关闭不能把目标仍存在的卡片假移除;关闭请求后必须刷新验证。
+- 对不可关闭、已消失或 stale 的目标,应静默移除展示残留,不把技术诊断暴露给用户。
+- 关闭确认的“不再提醒”是全局偏好,不是单窗口或单 App 维度。
+
+QA 入口:
+
+- `../C3.tools/round1-window-activation-fixture-qa.sh`
+- `../C3.tools/round1-finder-tabs-live-qa.sh`
+- `../C3.tools/round1-multi-page-identity-fixture-qa.sh`
+- `../C3.tools/round1-mouse-interaction-fixture-qa.sh`
+- `../C3.tools/round1-horizontal-waterfall-fixture-qa.sh`
+- `../C3.tools/round1-obsidian-space-activation-qa.sh`
+- `../C3.tools/round1-close-fixture-qa.sh`
+- `../C3.tools/round1-main-ui-qa.sh`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-window-activation-normal-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-window-activation-failure-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-mouse-app-only-fixture-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-mouse-interaction-fixture-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-multi-page-activation-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-multi-page-close-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-close-card-optout-toggle-report.json`
+
+## Node: QuickSwitch.ThemeAndSettings
+
+职责:
+
+- 存储和应用浅色 / 深色主题。
+- 存储和应用纵栏 / 横栏瀑布视图模式。
+- 管理右上角主题切换和视图切换按钮。
+- 管理设置页入口和偏好写入。
+
+核心文件:
+
+- `Sources/AlignerCore/Preferences/AlignerPreferences.swift`
+- `Sources/Aligner/AlignerApplicationDelegate.swift`
+- `Sources/Aligner/SettingsWindowController.swift`
+- `Sources/Aligner/QuickSwitchRootView.swift`
+
+关键入口:
+
+- `PreferenceKey.quickSwitchWaterfallViewMode`
+- `PreferenceKey.theme`
+- `UserDefaultsPreferenceStore.read()`
+- `UserDefaultsPreferenceStore.write(_:)`
+- `AlignerApplicationDelegate.setQuickSwitchWaterfallViewMode(_:)`
+- `AlignerApplicationDelegate.toggleQuickSwitchThemePreference()`
+- `QuickSwitchRootView.setTheme(_:)`
+- `QuickSwitchRootView.setWaterfallViewMode(_:)`
+
+已保护行为:
+
+- 视图切换实时生效。
+- 设置窗口使用 AppKit,不用 SwiftUI 绘制。
+- 深色主题保留渐变背景和暗色整体氛围。
+- 主题切换按钮使用一个按钮、两个状态图标;默认图标为半明半暗圆盘。
+- 深色模式下 Space tile hover 浅色高亮时,文字、数字、角标线必须同步改为深色前景。
+
+QA 入口:
+
+- `../C3.tools/round1-theme-toggle-fixture-qa.sh`
+- `../C3.tools/round1-view-mode-entry-qa.sh`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-theme-toggle-light-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-theme-toggle-dark-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-view-mode-entry-settings-report.json`
+- `../C2.builds/Z-研发中间产物/qa-reports/round01-view-mode-entry-toggle-to-horizontal-report.json`
+
+## Node: Commerce.Entitlement
+
+职责:
+
+- 定义 Round01.5 官网直发 Pro 试用、授权、离线宽限和校验失败的基础状态模型。
+- 提供 `windowShortcutActivation` 的纯函数授权判定。
+- 支持 fixture-only 启动参数和 QA report,用于在接入真实支付前验证 Pro gate 口径。
+
+核心文件:
+
+- `Sources/AlignerCore/Commerce/EntitlementState.swift`
+- `Sources/Aligner/Diagnostics/Round015EntitlementLaunchOptions.swift`
+- `Sources/Aligner/QuickSwitchRootView.swift`
+- `Sources/Aligner/QuickSwitchSessionController.swift`
+
+关键入口:
+
+- `EntitlementPolicy.evaluate(snapshot:feature:now:)`
+- `EntitlementPolicy.offlineGrace(lastAttemptAt:)`
+- `FixtureEntitlementProvider.entitlement(for:at:)`
+- `Round015EntitlementLaunchOptions.parse(arguments:)`
+- `QuickSwitchRootView.handleAppShelfIndexKey(_:)`
+- `QuickSwitchRootView.handleWindowIndexKey(symbol:)`
+
+已保护行为:
+
+- Round01.5 只 gate `windowShortcutActivation`:两键窗口码直达,以及单窗口 App 的 App 序号自动激活。
+- Quick Switch overlay 打开、普通 App 序号聚焦、鼠标点击窗口、方向键导航、`Enter`、Space filter、窗口枚举和截图不受 Pro gate 影响。
+- `trialActive`、`proUnlocked`、`offlineGrace` 允许窗口码;`trialExpired`、无有效试用下的 `verifyFailed` 拦截窗口码。
+- Pro gate 拦截后 overlay 不关闭,基础 Quick Switch 路径保持可用。
+- QA report / log 不输出明文 license key、instance id、邮箱或订单信息。
+
+QA 入口:
+
+- `Tests/AlignerCoreTests/EntitlementPolicyTests.swift`
+- `../C3.tools/round015-entitlement-fixture-qa.sh`
+- `../C2.builds/Z-研发中间产物/qa-reports/round015-entitlement-fixture-qa.json`
+
+## 基础验证入口
+
+从 SwiftPM 根目录执行:
+
+```bash
+swift build 2>&1 | head -c 6000
+swift test 2>&1 | head -c 6000
+```
+
+macOS 14 兼容验证入口:
+
+```bash
+../C3.tools/round015-macos14-compatibility-qa.sh
+```
+
+打包入口:
+
+```bash
+../C3.tools/package-app.sh 2>&1 | head -c 6000
+```
+
+主要 QA 脚本目录:
+
+- `../C3.tools/`
+
+主要 QA 报告目录:
+
+- `../C2.builds/Z-研发中间产物/qa-reports/`
+
+## 冻结规则
+
+用户要求“冻结版本”时,Agent 必须自动更新本文件,并在打 tag 前完成:
+
+- 路径存在性校验。
+- QA 入口存在性校验。
+- 已保护行为与本轮变更一致性校验。
+- `最后更新`、`当前基线`、相关节点 `QA 入口` 与 `已保护行为` 更新。
+
+如果用户只说 `更新codegraph`,只更新本文件,不改源码、不打包、不 tag;除非用户明确说 `更新codegraph并提交`。
diff --git a/C1.source/Package.swift b/C1.source/Package.swift
index 9b6797e..b7555ef 100644
--- a/C1.source/Package.swift
+++ b/C1.source/Package.swift
@@ -5,7 +5,7 @@
let package = Package(
name: "Aligner",
platforms: [
- .macOS("26.0")
+ .macOS("14.0")
],
products: [
.library(name: "AlignerCore", targets: ["AlignerCore"]),
diff --git a/C1.source/Resources/Aligner-Info.plist b/C1.source/Resources/Aligner-Info.plist
index 3c86740..67da45f 100644
--- a/C1.source/Resources/Aligner-Info.plist
+++ b/C1.source/Resources/Aligner-Info.plist
@@ -17,11 +17,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
- <string>0.1.3</string>
+ <string>0.1.12</string>
<key>CFBundleVersion</key>
- <string>20260620.1337</string>
+ <string>20260625.2320</string>
<key>LSMinimumSystemVersion</key>
- <string>26.0</string>
+ <string>14.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSPrincipalClass</key>
diff --git a/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift b/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift
index 89c2a16..9904f96 100644
--- a/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift
+++ b/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift
@@ -14,6 +14,7 @@
private let round1SnapshotDumpOptions = Round1SnapshotDumpOptions.parse(arguments: ProcessInfo.processInfo.arguments)
private let round1ScreenshotProbeOptions = Round1ScreenshotProbeOptions.parse(arguments: ProcessInfo.processInfo.arguments)
private let round1QuickSwitchOptions = Round1QuickSwitchLaunchOptions.parse(arguments: ProcessInfo.processInfo.arguments)
+ private let round015EntitlementOptions = Round015EntitlementLaunchOptions.parse(arguments: ProcessInfo.processInfo.arguments)
private var permissionWindowController: PermissionsWindowController?
private var debugOverlayController: Round0DebugOverlayController?
private var round1PerformancePoCController: Round1PerformancePoCController?
@@ -22,6 +23,7 @@
private var quickSwitchTriggerService: CGEventTapTriggerService?
private var settingsWindowController: SettingsWindowController?
private var isRunningQuickSwitchLifecycle = false
+ private var lastQuickSwitchOpenRequestSource: String?
private var statusItem: NSStatusItem?
private var verticalWaterfallViewModeMenuItems: [NSMenuItem] = []
private var horizontalWaterfallViewModeMenuItems: [NSMenuItem] = []
@@ -87,7 +89,8 @@
"hasVisibleWindows": flag,
"activationPolicy": sender.activationPolicy().rawValue
])
- return true
+ openQuickSwitchFromDockReopen(hasVisibleWindows: flag)
+ return false
}
private func configureStatusItem() {
@@ -441,10 +444,13 @@
}
private func showQuickSwitchIfRequested() {
- guard round1QuickSwitchOptions.openQuickSwitch || round1QuickSwitchOptions.lifecycleCycles != nil else { return }
+ guard round1QuickSwitchOptions.openQuickSwitch
+ || round1QuickSwitchOptions.lifecycleCycles != nil
+ || round1QuickSwitchOptions.simulateDockReopenAfter != nil else { return }
DevelopmentDiagnostics.log("app.quickSwitch.launchOptionRequested", [
"openQuickSwitch": round1QuickSwitchOptions.openQuickSwitch,
- "lifecycleCycles": round1QuickSwitchOptions.lifecycleCycles
+ "lifecycleCycles": round1QuickSwitchOptions.lifecycleCycles,
+ "simulateDockReopenAfter": round1QuickSwitchOptions.simulateDockReopenAfter
])
if let lifecycleCycles = round1QuickSwitchOptions.lifecycleCycles {
@@ -452,9 +458,23 @@
return
}
- openQuickSwitchFromTrigger()
- writeQuickSwitchReportIfNeeded()
+ if let simulateDockReopenAfter = round1QuickSwitchOptions.simulateDockReopenAfter {
+ DispatchQueue.main.asyncAfter(deadline: .now() + simulateDockReopenAfter) { [weak self] in
+ guard let self else { return }
+ let hasVisibleWindows = NSApp.windows.contains { $0.isVisible }
+ self.openQuickSwitchFromDockReopen(hasVisibleWindows: hasVisibleWindows)
+ self.writeQuickSwitchReportIfNeeded()
+ self.scheduleQuickSwitchAutoHideAndQuitIfNeeded()
+ }
+ return
+ }
+ openQuickSwitchFromTrigger(source: "launchOption")
+ writeQuickSwitchReportIfNeeded()
+ scheduleQuickSwitchAutoHideAndQuitIfNeeded()
+ }
+
+ private func scheduleQuickSwitchAutoHideAndQuitIfNeeded() {
if let autoHideAfter = round1QuickSwitchOptions.autoHideAfter {
DispatchQueue.main.asyncAfter(deadline: .now() + autoHideAfter) { [weak self] in
self?.hideQuickSwitchSession(reason: .timeout)
@@ -533,9 +553,19 @@
}
}
- private func openQuickSwitchFromTrigger() {
+ private func openQuickSwitchFromDockReopen(hasVisibleWindows: Bool) {
+ DevelopmentDiagnostics.log("app.quickSwitch.openFromDockReopen", [
+ "hasVisibleWindows": hasVisibleWindows,
+ "alreadyVisible": quickSwitchSessionController?.isVisible ?? false
+ ])
+ openQuickSwitchFromTrigger(source: "dockReopen")
+ }
+
+ private func openQuickSwitchFromTrigger(source: String = "trigger") {
+ lastQuickSwitchOpenRequestSource = source
let accessibility = accessibilityStatus()
DevelopmentDiagnostics.log("app.quickSwitch.openFromTrigger", [
+ "source": source,
"accessibility": permissionStatusString(accessibility),
"screenRecording": permissionStatusString(screenRecordingStatus()),
"alreadyVisible": quickSwitchSessionController?.isVisible ?? false
@@ -617,6 +647,8 @@
report["resolvedWaterfallViewMode"] = quickSwitchWaterfallViewMode().rawValue
report["preferenceTheme"] = preferenceStore.read().theme.rawValue
report["resolvedQuickSwitchTheme"] = quickSwitchThemePreference().rawValue
+ report["lastQuickSwitchOpenRequestSource"] = lastQuickSwitchOpenRequestSource
+ report["authorization"] = round015EntitlementOptions.authorizationReportDictionary()
report["aboutVersion"] = versionBuild.version
report["aboutBuild"] = versionBuild.build
report["statusMenu"] = statusMenuReportDictionary()
@@ -702,7 +734,11 @@
return DebugWindowActivationService()
}
- return CGWindowAXWindowService(spaceIDsByWindowIDProvider: { _ in [:] })
+ let context = quickSwitchWindowSpaceContext()
+ return CGWindowAXWindowService(
+ spaceIDsByWindowIDProvider: context.spaceIDsByWindowIDProvider,
+ fullscreenSpaceIDs: context.fullscreenSpaceIDs
+ )
}
private func quickSwitchWaterfallViewMode() -> QuickSwitchWaterfallViewMode {
@@ -728,7 +764,39 @@
return DebugWindowCloseService()
}
- return CGWindowAXWindowService(spaceIDsByWindowIDProvider: { _ in [:] })
+ let context = quickSwitchWindowSpaceContext()
+ return CGWindowAXWindowService(
+ spaceIDsByWindowIDProvider: context.spaceIDsByWindowIDProvider,
+ fullscreenSpaceIDs: context.fullscreenSpaceIDs
+ )
+ }
+
+ private func quickSwitchWindowSpaceContext() -> (
+ spaceIDsByWindowIDProvider: ([UInt32]) -> [UInt32: [UInt64]],
+ fullscreenSpaceIDs: Set<UInt64>
+ ) {
+ do {
+ let spaceEnumerator = try SkyLightSpaceEnumerator()
+ let displays = try spaceEnumerator.displays()
+ let fullscreenSpaceIDs = Set(
+ displays
+ .flatMap(\.spaces)
+ .filter { $0.type == .fullscreen }
+ .map(\.id)
+ )
+ return (
+ spaceIDsByWindowIDProvider: spaceEnumerator.spaceIDsByWindowID(windowIDs:),
+ fullscreenSpaceIDs: fullscreenSpaceIDs
+ )
+ } catch {
+ DevelopmentDiagnostics.log("app.quickSwitch.windowSpaceContextUnavailable", [
+ "error": DevelopmentDiagnostics.errorSummaryString(error)
+ ])
+ return (
+ spaceIDsByWindowIDProvider: { _ in [:] },
+ fullscreenSpaceIDs: []
+ )
+ }
}
private var quickSwitchConfirmBeforeClose: Bool {
diff --git a/C1.source/Sources/Aligner/DebugWindowActivationService.swift b/C1.source/Sources/Aligner/DebugWindowActivationService.swift
index 50ef5c4..2a7df3b 100644
--- a/C1.source/Sources/Aligner/DebugWindowActivationService.swift
+++ b/C1.source/Sources/Aligner/DebugWindowActivationService.swift
@@ -3,6 +3,19 @@
final class DebugWindowActivationService: WindowActivationServiceProtocol {
func activate(window: AlignerWindow) throws -> WindowActivationResult {
+ switch ProcessInfo.processInfo.environment["ALIGNER_DEBUG_WINDOW_ACTIVATION_RESULT"] {
+ case "windowNotFound":
+ return .windowNotFound
+ case "activationFailed":
+ return .activationFailed
+ case "unsupported":
+ return .unsupported
+ case "appActivatedOnly":
+ return .appActivatedOnly
+ default:
+ break
+ }
+
if window.title.contains("App Activated Only") {
return .appActivatedOnly
}
diff --git a/C1.source/Sources/Aligner/Diagnostics/Round015EntitlementLaunchOptions.swift b/C1.source/Sources/Aligner/Diagnostics/Round015EntitlementLaunchOptions.swift
new file mode 100644
index 0000000..975ea8e
--- /dev/null
+++ b/C1.source/Sources/Aligner/Diagnostics/Round015EntitlementLaunchOptions.swift
@@ -0,0 +1,327 @@
+import Foundation
+import AlignerCore
+
+struct Round015EntitlementLaunchOptions {
+ let fixtureState: EntitlementFixtureState?
+ let now: Date?
+ let trialStartedAt: Date?
+ let lastSuccessfulVerifyAt: Date?
+ let verifyResult: VerifyResult?
+ let usesEphemeralLicenseStore: Bool
+ let debugLicenseKey: DebugLicenseKey?
+
+ enum VerifyResult: String {
+ case success
+ case networkFailure
+ case invalid
+ case revoked
+ case serverError
+ case timeout
+ }
+
+ enum DebugLicenseKey: String {
+ case valid
+ case invalid
+ case revoked
+ }
+
+ static func parse(arguments: [String]) -> Round015EntitlementLaunchOptions {
+ Round015EntitlementLaunchOptions(
+ fixtureState: stringValue(for: "--round015-license-state", in: arguments)
+ .flatMap(EntitlementFixtureState.init(rawValue:)),
+ now: dateValue(for: "--round015-now", in: arguments),
+ trialStartedAt: dateValue(for: "--round015-trial-started-at", in: arguments),
+ lastSuccessfulVerifyAt: dateValue(for: "--round015-last-successful-verify-at", in: arguments),
+ verifyResult: stringValue(for: "--round015-verify-result", in: arguments)
+ .flatMap(VerifyResult.init(rawValue:)),
+ usesEphemeralLicenseStore: arguments.contains("--round015-ephemeral-license-store"),
+ debugLicenseKey: stringValue(for: "--round015-debug-license-key", in: arguments)
+ .flatMap(DebugLicenseKey.init(rawValue:))
+ )
+ }
+
+ func authorizationReportDictionary() -> [String: Any] {
+ guard let snapshot else {
+ return [
+ "state": "notConfigured",
+ "proFeature": ProFeature.windowShortcutActivation.rawValue,
+ "proFeatureUnlocked": false,
+ "fixtureEnabled": false,
+ "ephemeralLicenseStore": usesEphemeralLicenseStore,
+ "licenseKeyPresent": debugLicenseKey != nil,
+ "licenseKeyFingerprint": debugLicenseKey.map { DevelopmentDiagnostics.stableFingerprint($0.rawValue) } ?? NSNull()
+ ]
+ }
+
+ let evaluationNow = effectiveNow
+ let entitlement = EntitlementPolicy.evaluate(
+ snapshot: snapshot,
+ feature: .windowShortcutActivation,
+ now: evaluationNow
+ )
+
+ var report: [String: Any] = [
+ "state": stateLabel(entitlement),
+ "proFeature": ProFeature.windowShortcutActivation.rawValue,
+ "proFeatureUnlocked": entitlement.isProAllowed,
+ "decision": decisionLabel(entitlement),
+ "decisionReason": decisionReasonLabel(entitlement),
+ "fixtureEnabled": fixtureState != nil,
+ "fixtureState": fixtureState?.rawValue ?? NSNull(),
+ "ephemeralLicenseStore": usesEphemeralLicenseStore,
+ "now": Self.string(from: evaluationNow),
+ "trialStartedAt": trialStartedAtString(from: snapshot.trialState) ?? NSNull(),
+ "trialEndsAt": trialEndsAtString(from: snapshot.trialState) ?? NSNull(),
+ "trialDaysRemaining": trialDaysRemaining(from: snapshot.trialState, now: evaluationNow) ?? NSNull(),
+ "lastVerifyResult": lastVerifyResultLabel(from: snapshot.validationFreshness),
+ "lastSuccessfulVerifyAt": lastSuccessfulVerifyAtString(from: snapshot.validationFreshness) ?? NSNull(),
+ "offlineGraceEndsAt": offlineGraceEndsAtString(from: snapshot.validationFreshness) ?? NSNull(),
+ "offlineGraceRemainingDays": offlineGraceRemainingDays(from: snapshot.validationFreshness, now: evaluationNow) ?? NSNull(),
+ "licenseKeyPresent": debugLicenseKey != nil || licenseKeyPresent(in: snapshot.licenseState),
+ "licenseKeyFingerprint": licenseFingerprint(from: snapshot.licenseState)
+ ?? debugLicenseKey.map { DevelopmentDiagnostics.stableFingerprint($0.rawValue) }
+ ?? NSNull()
+ ]
+
+ if let verifyResult {
+ report["fixtureVerifyResult"] = verifyResult.rawValue
+ }
+ if let debugLicenseKey {
+ report["debugLicenseKeyKind"] = debugLicenseKey.rawValue
+ }
+
+ return report
+ }
+
+ private var effectiveNow: Date {
+ now ?? Date()
+ }
+
+ private var snapshot: EntitlementSnapshot? {
+ guard fixtureState != nil
+ || trialStartedAt != nil
+ || lastSuccessfulVerifyAt != nil
+ || verifyResult != nil
+ || debugLicenseKey != nil
+ else {
+ return nil
+ }
+
+ if fixtureState == nil {
+ return snapshotFromOverrides(base: EntitlementFixtureState.trialExpired)
+ }
+
+ return snapshotFromOverrides(base: fixtureState ?? .trialExpired)
+ }
+
+ private func snapshotFromOverrides(base fixtureState: EntitlementFixtureState) -> EntitlementSnapshot {
+ var snapshot = FixtureEntitlementProvider(
+ fixtureState: fixtureState,
+ now: effectiveNow
+ ).snapshot
+
+ if let trialStartedAt {
+ snapshot.trialState = trialState(
+ from: snapshot.trialState,
+ startedAt: trialStartedAt,
+ now: effectiveNow
+ )
+ }
+
+ if let debugLicenseKey {
+ snapshot.licenseState = licenseState(for: debugLicenseKey)
+ }
+
+ if let verifyResult {
+ snapshot.validationFreshness = validationFreshness(
+ for: verifyResult,
+ now: effectiveNow,
+ lastSuccessfulVerifyAt: lastSuccessfulVerifyAt
+ )
+ } else if let lastSuccessfulVerifyAt {
+ snapshot.validationFreshness = .valid(
+ lastValidAt: lastSuccessfulVerifyAt,
+ nextCheckAt: lastSuccessfulVerifyAt.addingTimeInterval(24 * 60 * 60)
+ )
+ }
+
+ return snapshot
+ }
+
+ private func trialState(from base: TrialState, startedAt: Date, now: Date) -> TrialState {
+ let expiresAt = startedAt.addingTimeInterval(EntitlementPolicy.defaultTrialDuration)
+ switch base {
+ case .expired:
+ return now < expiresAt
+ ? .active(startedAt: startedAt, expiresAt: expiresAt)
+ : .expired(startedAt: startedAt, expiredAt: expiresAt)
+ default:
+ return .active(startedAt: startedAt, expiresAt: expiresAt)
+ }
+ }
+
+ private func licenseState(for debugLicenseKey: DebugLicenseKey) -> LicenseState {
+ switch debugLicenseKey {
+ case .valid:
+ return .activated(
+ fingerprint: DevelopmentDiagnostics.stableFingerprint("round015-valid"),
+ instanceID: "round015-fixture-instance",
+ status: .active
+ )
+ case .invalid:
+ return .revokedOrExpiredRemote(reason: .invalid)
+ case .revoked:
+ return .revokedOrExpiredRemote(reason: .revoked)
+ }
+ }
+
+ private func validationFreshness(
+ for verifyResult: VerifyResult,
+ now: Date,
+ lastSuccessfulVerifyAt: Date?
+ ) -> ValidationFreshness {
+ switch verifyResult {
+ case .success:
+ let lastValidAt = lastSuccessfulVerifyAt ?? now
+ return .valid(
+ lastValidAt: lastValidAt,
+ nextCheckAt: lastValidAt.addingTimeInterval(24 * 60 * 60)
+ )
+ case .networkFailure, .serverError, .timeout:
+ return EntitlementPolicy.offlineGrace(lastAttemptAt: now)
+ case .invalid:
+ return .hardFailure(reason: .invalid)
+ case .revoked:
+ return .hardFailure(reason: .revoked)
+ }
+ }
+
+ private func stateLabel(_ entitlement: EffectiveEntitlement) -> String {
+ switch entitlement {
+ case let .proAllowed(reason):
+ return reason.rawValue
+ case let .proDenied(reason):
+ return reason.rawValue
+ }
+ }
+
+ private func decisionLabel(_ entitlement: EffectiveEntitlement) -> String {
+ entitlement.isProAllowed ? "allowed" : "blocked"
+ }
+
+ private func decisionReasonLabel(_ entitlement: EffectiveEntitlement) -> String {
+ switch entitlement {
+ case let .proAllowed(reason):
+ return reason.rawValue
+ case let .proDenied(reason):
+ return reason.rawValue
+ }
+ }
+
+ private func trialStartedAtString(from state: TrialState) -> String? {
+ switch state {
+ case let .active(startedAt, _), let .expired(startedAt, _):
+ return Self.string(from: startedAt)
+ case .notInitialized, .invalidLocalRecord:
+ return nil
+ }
+ }
+
+ private func trialEndsAtString(from state: TrialState) -> String? {
+ switch state {
+ case let .active(_, expiresAt), let .expired(_, expiresAt):
+ return Self.string(from: expiresAt)
+ case .notInitialized, .invalidLocalRecord:
+ return nil
+ }
+ }
+
+ private func trialDaysRemaining(from state: TrialState, now: Date) -> Int? {
+ guard case let .active(_, expiresAt) = state else { return nil }
+ return max(0, Int(ceil(expiresAt.timeIntervalSince(now) / (24 * 60 * 60))))
+ }
+
+ private func lastVerifyResultLabel(from freshness: ValidationFreshness) -> String {
+ switch freshness {
+ case .neverChecked:
+ return "neverChecked"
+ case .valid:
+ return "success"
+ case .temporaryFailure:
+ return "temporaryFailure"
+ case let .hardFailure(reason):
+ return reason.rawValue
+ }
+ }
+
+ private func lastSuccessfulVerifyAtString(from freshness: ValidationFreshness) -> String? {
+ switch freshness {
+ case let .valid(lastValidAt, _):
+ return Self.string(from: lastValidAt)
+ case .neverChecked, .temporaryFailure, .hardFailure:
+ return nil
+ }
+ }
+
+ private func offlineGraceEndsAtString(from freshness: ValidationFreshness) -> String? {
+ switch freshness {
+ case let .temporaryFailure(_, graceUntil):
+ return Self.string(from: graceUntil)
+ case .neverChecked, .valid, .hardFailure:
+ return nil
+ }
+ }
+
+ private func offlineGraceRemainingDays(from freshness: ValidationFreshness, now: Date) -> Int? {
+ guard case let .temporaryFailure(_, graceUntil) = freshness else { return nil }
+ return max(0, Int(ceil(graceUntil.timeIntervalSince(now) / (24 * 60 * 60))))
+ }
+
+ private func licenseKeyPresent(in state: LicenseState) -> Bool {
+ switch state {
+ case .activated:
+ return true
+ case .none, .deactivatedLocal, .revokedOrExpiredRemote, .mismatch:
+ return false
+ }
+ }
+
+ private func licenseFingerprint(from state: LicenseState) -> String? {
+ switch state {
+ case let .activated(fingerprint, _, _):
+ return fingerprint
+ case .none, .deactivatedLocal, .revokedOrExpiredRemote, .mismatch:
+ return nil
+ }
+ }
+
+ private static func dateValue(for key: String, in arguments: [String]) -> Date? {
+ guard let value = stringValue(for: key, in: arguments) else { return nil }
+
+ return iso8601Date(from: value, fractionalSeconds: true)
+ ?? iso8601Date(from: value, fractionalSeconds: false)
+ }
+
+ private static func stringValue(for key: String, in arguments: [String]) -> String? {
+ let prefix = "\(key)="
+ guard let argument = arguments.first(where: { $0.hasPrefix(prefix) }) else {
+ return nil
+ }
+
+ return String(argument.dropFirst(prefix.count))
+ }
+
+ private static func string(from date: Date) -> String {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ return formatter.string(from: date)
+ }
+
+ private static func iso8601Date(from value: String, fractionalSeconds: Bool) -> Date? {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = fractionalSeconds
+ ? [.withInternetDateTime, .withFractionalSeconds]
+ : [.withInternetDateTime]
+ return formatter.date(from: value)
+ }
+}
diff --git a/C1.source/Sources/Aligner/Infrastructure/Windows/CGWindowAXWindowService.swift b/C1.source/Sources/Aligner/Infrastructure/Windows/CGWindowAXWindowService.swift
index 21abec8..15fbdda 100644
--- a/C1.source/Sources/Aligner/Infrastructure/Windows/CGWindowAXWindowService.swift
+++ b/C1.source/Sources/Aligner/Infrastructure/Windows/CGWindowAXWindowService.swift
@@ -25,6 +25,51 @@
let flags: CGEventFlags
}
+ private struct FinderTabActivationBudget {
+ let startedAt: TimeInterval
+ let limit: TimeInterval
+
+ init(limit: TimeInterval) {
+ startedAt = ProcessInfo.processInfo.systemUptime
+ self.limit = limit
+ }
+
+ var elapsed: TimeInterval {
+ ProcessInfo.processInfo.systemUptime - startedAt
+ }
+
+ var elapsedMilliseconds: Int {
+ CGWindowAXWindowService.milliseconds(elapsed)
+ }
+
+ var remaining: TimeInterval {
+ max(0, limit - elapsed)
+ }
+
+ var isExhausted: Bool {
+ remaining <= 0
+ }
+ }
+
+ private struct WindowFocusAttempt {
+ let focused: AXActionResult
+ let main: AXActionResult
+ let raised: AXActionResult
+
+ var success: Bool {
+ focused.success || main.success || raised.success
+ }
+ }
+
+ private static let finderTabActivationBudgetLimit: TimeInterval = 1.50
+ private static let finderTabCycleMaximumAttempts = 5
+ private static let finderTabHostRepresentationScanLimit = 5
+ private static let finderTabFastTitleMatchAttempts = 4
+ private static let finderTabFastTitleMatchDelay: TimeInterval = 0.03
+ private static let finderTabWindowMenuVerificationInitialDelay: TimeInterval = 0.08
+ private static let finderTabWindowMenuVerificationAttempts = 8
+ private static let finderTabWindowMenuVerificationDelay: TimeInterval = 0.04
+
init(
spaceIDsByWindowIDProvider: @escaping ([UInt32]) -> [UInt32: [UInt64]],
appCategorizer: any AppCategorizer = BundleIDAppCategorizer(),
@@ -82,51 +127,49 @@
throw AXWindowRestoreError.setMinimizedFailed(minimizeResult)
}
- let focusedBeforeActivateResult = focusWindowResult(
+ let beforeActivateAttempt = focusMainRaiseWindowResult(
restorableWindow.element,
processIdentifier: restorableWindow.processIdentifier
)
- let raisedBeforeActivateResult = raiseWindowResult(restorableWindow.element)
- let focusedBeforeActivate = focusedBeforeActivateResult.success
- let raisedBeforeActivate = raisedBeforeActivateResult.success
guard activateApplication(processIdentifier: restorableWindow.processIdentifier) else {
DevelopmentDiagnostics.log("windowActivation.restore.activateApplicationFailed", [
"windowID": windowID,
"pid": restorableWindow.processIdentifier,
- "focusedBeforeActivate": focusedBeforeActivate,
- "focusBeforeAXError": String(describing: focusedBeforeActivateResult.error),
- "raisedBeforeActivate": raisedBeforeActivate,
- "raiseBeforeAXError": String(describing: raisedBeforeActivateResult.error)
+ "focusedBeforeActivate": beforeActivateAttempt.focused.success,
+ "focusBeforeAXError": String(describing: beforeActivateAttempt.focused.error),
+ "mainBeforeActivate": beforeActivateAttempt.main.success,
+ "mainBeforeAXError": String(describing: beforeActivateAttempt.main.error),
+ "raisedBeforeActivate": beforeActivateAttempt.raised.success,
+ "raiseBeforeAXError": String(describing: beforeActivateAttempt.raised.error)
])
return .activationFailed
}
- let focusedAfterActivateResult = focusWindowResult(
+ let afterActivateAttempt = focusMainRaiseWindowResult(
restorableWindow.element,
processIdentifier: restorableWindow.processIdentifier
)
- let raisedAfterActivateResult = raiseWindowResult(restorableWindow.element)
- let focusedAfterActivate = focusedAfterActivateResult.success
- let raisedAfterActivate = raisedAfterActivateResult.success
- let restored = focusedBeforeActivate || focusedAfterActivate || raisedBeforeActivate || raisedAfterActivate
+ let restored = beforeActivateAttempt.success || afterActivateAttempt.success
DevelopmentDiagnostics.log("windowActivation.restore.end", [
"windowID": windowID,
"pid": restorableWindow.processIdentifier,
- "focusedBeforeActivate": focusedBeforeActivate,
- "focusBeforeAXError": String(describing: focusedBeforeActivateResult.error),
- "raisedBeforeActivate": raisedBeforeActivate,
- "raiseBeforeAXError": String(describing: raisedBeforeActivateResult.error),
- "focusedAfterActivate": focusedAfterActivate,
- "focusAfterAXError": String(describing: focusedAfterActivateResult.error),
- "raisedAfterActivate": raisedAfterActivate,
- "raiseAfterAXError": String(describing: raisedAfterActivateResult.error),
+ "focusedBeforeActivate": beforeActivateAttempt.focused.success,
+ "focusBeforeAXError": String(describing: beforeActivateAttempt.focused.error),
+ "mainBeforeActivate": beforeActivateAttempt.main.success,
+ "mainBeforeAXError": String(describing: beforeActivateAttempt.main.error),
+ "raisedBeforeActivate": beforeActivateAttempt.raised.success,
+ "raiseBeforeAXError": String(describing: beforeActivateAttempt.raised.error),
+ "focusedAfterActivate": afterActivateAttempt.focused.success,
+ "focusAfterAXError": String(describing: afterActivateAttempt.focused.error),
+ "mainAfterActivate": afterActivateAttempt.main.success,
+ "mainAfterAXError": String(describing: afterActivateAttempt.main.error),
+ "raisedAfterActivate": afterActivateAttempt.raised.success,
+ "raiseAfterAXError": String(describing: afterActivateAttempt.raised.error),
"result": restored ? "restored" : "activationFailed"
])
- return focusedBeforeActivate || focusedAfterActivate || raisedBeforeActivate || raisedAfterActivate
- ? .restored
- : .activationFailed
+ return restored ? .restored : .activationFailed
}
func activate(window: AlignerWindow) throws -> WindowActivationResult {
@@ -215,68 +258,129 @@
}
let activated = activateApplication(for: window)
+ let retryAttempt = activated
+ ? retryFocusMainRaiseWindow(
+ for: window,
+ delay: 0.10,
+ matchAttempt: "axMissingPostActivate"
+ )
+ : nil
+ let retrySucceeded = retryAttempt?.success ?? false
DevelopmentDiagnostics.log("windowActivation.activate.axMissing", [
"windowID": window.id,
"appName": window.app.name,
"pid": window.app.processIdentifier,
"privateActivated": didPrivatelyActivate,
"applicationActivated": activated,
- "result": didPrivatelyActivate ? "activated" : (activated ? "appActivatedOnly" : "windowNotFound")
+ "retryFocused": retryAttempt?.focused.success,
+ "retryFocusAXError": retryAttempt.map { String(describing: $0.focused.error) },
+ "retryMain": retryAttempt?.main.success,
+ "retryMainAXError": retryAttempt.map { String(describing: $0.main.error) },
+ "retryRaised": retryAttempt?.raised.success,
+ "retryRaiseAXError": retryAttempt.map { String(describing: $0.raised.error) },
+ "result": (didPrivatelyActivate || retrySucceeded) ? "activated" : (activated ? "appActivatedOnly" : "windowNotFound")
])
- if didPrivatelyActivate {
+ if didPrivatelyActivate || retrySucceeded {
return .activated
}
return activated ? .appActivatedOnly : .windowNotFound
}
- let focusedBeforeActivateResult = focusWindowResult(
+ let beforeActivateAttempt = focusMainRaiseWindowResult(
axWindow.element,
processIdentifier: axWindow.processIdentifier
)
- let raisedBeforeActivateResult = raiseWindowResult(axWindow.element)
- let focusedBeforeActivate = focusedBeforeActivateResult.success
- let raisedBeforeActivate = raisedBeforeActivateResult.success
guard didPrivatelyActivate || activateApplication(for: window) else {
DevelopmentDiagnostics.log("windowActivation.activate.applicationActivationFailed", [
"windowID": window.id,
"pid": window.app.processIdentifier,
"privateActivated": didPrivatelyActivate,
- "focusedBeforeActivate": focusedBeforeActivate,
- "focusBeforeAXError": String(describing: focusedBeforeActivateResult.error),
- "raisedBeforeActivate": raisedBeforeActivate,
- "raiseBeforeAXError": String(describing: raisedBeforeActivateResult.error)
+ "focusedBeforeActivate": beforeActivateAttempt.focused.success,
+ "focusBeforeAXError": String(describing: beforeActivateAttempt.focused.error),
+ "mainBeforeActivate": beforeActivateAttempt.main.success,
+ "mainBeforeAXError": String(describing: beforeActivateAttempt.main.error),
+ "raisedBeforeActivate": beforeActivateAttempt.raised.success,
+ "raiseBeforeAXError": String(describing: beforeActivateAttempt.raised.error)
])
return .activationFailed
}
- let focusedAfterActivateResult = focusWindowResult(
+ let afterActivateAttempt = focusMainRaiseWindowResult(
axWindow.element,
processIdentifier: axWindow.processIdentifier
)
- let raisedAfterActivateResult = raiseWindowResult(axWindow.element)
- let focusedAfterActivate = focusedAfterActivateResult.success
- let raisedAfterActivate = raisedAfterActivateResult.success
+ let retryAttempt = (didPrivatelyActivate || beforeActivateAttempt.success || afterActivateAttempt.success)
+ ? nil
+ : retryFocusMainRaiseWindow(
+ for: window,
+ delay: 0.10,
+ matchAttempt: "postActivateRetry"
+ )
- let didFocusOrRaise = focusedBeforeActivate || focusedAfterActivate || raisedBeforeActivate || raisedAfterActivate
+ let didFocusOrRaise = beforeActivateAttempt.success || afterActivateAttempt.success || (retryAttempt?.success ?? false)
+ let finderDirectFallbackResult: Bool?
+ if (didPrivatelyActivate || didFocusOrRaise),
+ isFinderCGOnlyPageCandidate(window),
+ let processIdentifier = window.app.processIdentifier {
+ let normalizedTargetTitle = normalizedWindowTitle(window.title)
+ let focusedTitle = finderFocusedOrMainWindowTitle(processIdentifier: processIdentifier)
+ let directTitleMatched = normalizedWindowTitle(focusedTitle ?? "") == normalizedTargetTitle
+ DevelopmentDiagnostics.log("windowActivation.activate.finderDirectVerification", [
+ "windowID": window.id,
+ "pid": processIdentifier,
+ "directTitleMatched": directTitleMatched,
+ "focusedTitleHash": DevelopmentDiagnostics.stableFingerprint(focusedTitle),
+ "focusedTitleLength": focusedTitle?.count,
+ "targetTitleHash": DevelopmentDiagnostics.stableFingerprint(window.title),
+ "targetTitleLength": window.title.count
+ ])
+ if directTitleMatched {
+ finderDirectFallbackResult = true
+ } else {
+ finderDirectFallbackResult = activateFinderTabPageIfNeeded(
+ window: window,
+ axMetadata: AXWindowMetadataReader.metadata(appCategorizer: appCategorizer),
+ operation: "windowActivation",
+ reason: "directAXTitleMismatch"
+ )
+ }
+ } else {
+ finderDirectFallbackResult = nil
+ }
+
+ let finderPreciseActivationFailed = finderDirectFallbackResult == false
+ let activated = finderDirectFallbackResult ?? (didPrivatelyActivate || didFocusOrRaise)
DevelopmentDiagnostics.log("windowActivation.activate.end", [
"windowID": window.id,
"pid": window.app.processIdentifier,
"privateActivated": didPrivatelyActivate,
- "focusedBeforeActivate": focusedBeforeActivate,
- "focusBeforeAXError": String(describing: focusedBeforeActivateResult.error),
- "raisedBeforeActivate": raisedBeforeActivate,
- "raiseBeforeAXError": String(describing: raisedBeforeActivateResult.error),
- "focusedAfterActivate": focusedAfterActivate,
- "focusAfterAXError": String(describing: focusedAfterActivateResult.error),
- "raisedAfterActivate": raisedAfterActivate,
- "raiseAfterAXError": String(describing: raisedAfterActivateResult.error),
- "result": (didFocusOrRaise || didPrivatelyActivate) ? "activated" : "appActivatedOnly"
+ "focusedBeforeActivate": beforeActivateAttempt.focused.success,
+ "focusBeforeAXError": String(describing: beforeActivateAttempt.focused.error),
+ "mainBeforeActivate": beforeActivateAttempt.main.success,
+ "mainBeforeAXError": String(describing: beforeActivateAttempt.main.error),
+ "raisedBeforeActivate": beforeActivateAttempt.raised.success,
+ "raiseBeforeAXError": String(describing: beforeActivateAttempt.raised.error),
+ "focusedAfterActivate": afterActivateAttempt.focused.success,
+ "focusAfterAXError": String(describing: afterActivateAttempt.focused.error),
+ "mainAfterActivate": afterActivateAttempt.main.success,
+ "mainAfterAXError": String(describing: afterActivateAttempt.main.error),
+ "raisedAfterActivate": afterActivateAttempt.raised.success,
+ "raiseAfterAXError": String(describing: afterActivateAttempt.raised.error),
+ "retryFocused": retryAttempt?.focused.success,
+ "retryFocusAXError": retryAttempt.map { String(describing: $0.focused.error) },
+ "retryMain": retryAttempt?.main.success,
+ "retryMainAXError": retryAttempt.map { String(describing: $0.main.error) },
+ "retryRaised": retryAttempt?.raised.success,
+ "retryRaiseAXError": retryAttempt.map { String(describing: $0.raised.error) },
+ "finderDirectFallbackResult": finderDirectFallbackResult,
+ "result": finderPreciseActivationFailed ? "activationFailed" : (activated ? "activated" : "appActivatedOnly")
])
- return didPrivatelyActivate || focusedBeforeActivate || focusedAfterActivate || raisedBeforeActivate || raisedAfterActivate
- ? .activated
- : .appActivatedOnly
+ if finderPreciseActivationFailed {
+ return .activationFailed
+ }
+ return activated ? .activated : .appActivatedOnly
}
func close(window: AlignerWindow) throws -> WindowCloseResult {
@@ -620,14 +724,41 @@
return nil
}
- guard let host = finderTabHostAXWindow(for: window, in: axMetadata) else {
+ let budget = FinderTabActivationBudget(limit: Self.finderTabActivationBudgetLimit)
+ let hostLookupStartedAt = ProcessInfo.processInfo.systemUptime
+ let host = finderTabHostAXWindow(for: window, in: axMetadata, budget: budget)
+ DevelopmentDiagnostics.log("\(operation).finderTab.hostLookup", [
+ "windowID": window.id,
+ "pid": processIdentifier,
+ "reason": reason,
+ "hostFound": host != nil,
+ "hostWindowID": host?.windowID,
+ "elapsedMilliseconds": Self.elapsedMilliseconds(since: hostLookupStartedAt),
+ "overallElapsedMilliseconds": budget.elapsedMilliseconds,
+ "budgetMilliseconds": Self.milliseconds(budget.limit),
+ "budgetRemainingMilliseconds": Self.milliseconds(budget.remaining)
+ ])
+
+ guard let host else {
+ if budget.isExhausted {
+ DevelopmentDiagnostics.log("\(operation).finderTab.hostMissingBudgetExceeded", [
+ "windowID": window.id,
+ "pid": processIdentifier,
+ "reason": reason,
+ "elapsedMilliseconds": budget.elapsedMilliseconds,
+ "budgetMilliseconds": Self.milliseconds(budget.limit)
+ ])
+ return false
+ }
+
let normalizedTargetTitle = normalizedWindowTitle(window.title)
let applicationActivated = activateApplication(processIdentifier: processIdentifier)
let selectedViaWindowMenu = !normalizedTargetTitle.isEmpty
&& selectFinderWindowMenuItem(
processIdentifier: processIdentifier,
window: window,
- normalizedTargetTitle: normalizedTargetTitle
+ normalizedTargetTitle: normalizedTargetTitle,
+ budget: budget
)
DevelopmentDiagnostics.log("\(operation).finderTab.hostlessWindowMenuResult", [
"windowID": window.id,
@@ -637,7 +768,9 @@
"selectedViaWindowMenu": selectedViaWindowMenu,
"spaceIDs": window.spaceIDs,
"targetTitleHash": DevelopmentDiagnostics.stableFingerprint(window.title),
- "targetTitleLength": window.title.count
+ "targetTitleLength": window.title.count,
+ "elapsedMilliseconds": budget.elapsedMilliseconds,
+ "budgetMilliseconds": Self.milliseconds(budget.limit)
])
if selectedViaWindowMenu {
return true
@@ -647,7 +780,9 @@
"windowID": window.id,
"pid": processIdentifier,
"reason": reason,
- "spaceIDs": window.spaceIDs
+ "spaceIDs": window.spaceIDs,
+ "elapsedMilliseconds": budget.elapsedMilliseconds,
+ "budgetMilliseconds": Self.milliseconds(budget.limit)
])
return false
}
@@ -664,26 +799,32 @@
}
let applicationActivated = activateApplication(processIdentifier: processIdentifier)
- let focusBeforeResult = focusWindowResult(host.element, processIdentifier: processIdentifier)
- let raiseBeforeResult = raiseWindowResult(host.element)
- let selectedTab = selectFinderTab(window, in: host)
- Thread.sleep(forTimeInterval: 0.05)
- let focusAfterResult: AXActionResult?
- let raiseAfterResult: AXActionResult?
+ let beforeAttempt = focusMainRaiseWindowResult(host.element, processIdentifier: processIdentifier)
+ let selectedTabStartedAt = ProcessInfo.processInfo.systemUptime
+ let selectedTab = budget.isExhausted ? false : selectFinderTab(window, in: host, budget: budget)
+ DevelopmentDiagnostics.log("\(operation).finderTab.selectElapsed", [
+ "windowID": window.id,
+ "pid": processIdentifier,
+ "hostWindowID": host.windowID,
+ "selectedTab": selectedTab,
+ "elapsedMilliseconds": Self.elapsedMilliseconds(since: selectedTabStartedAt),
+ "overallElapsedMilliseconds": budget.elapsedMilliseconds,
+ "budgetMilliseconds": Self.milliseconds(budget.limit),
+ "budgetRemainingMilliseconds": Self.milliseconds(budget.remaining)
+ ])
+ if selectedTab, budget.remaining > 0 {
+ Thread.sleep(forTimeInterval: min(0.05, budget.remaining))
+ }
+ let afterAttempt: WindowFocusAttempt?
if selectedTab {
// For Finder tab/page cards, re-focusing the original host AX window after
// selecting the tab can switch Finder back to the host's previous tab.
- focusAfterResult = nil
- raiseAfterResult = nil
+ afterAttempt = nil
} else {
- focusAfterResult = focusWindowResult(host.element, processIdentifier: processIdentifier)
- raiseAfterResult = raiseWindowResult(host.element)
+ afterAttempt = focusMainRaiseWindowResult(host.element, processIdentifier: processIdentifier)
}
- let didFocusOrRaise = focusBeforeResult.success
- || raiseBeforeResult.success
- || (focusAfterResult?.success ?? false)
- || (raiseAfterResult?.success ?? false)
+ let didFocusOrRaise = beforeAttempt.success || (afterAttempt?.success ?? false)
let activated = selectedTab
&& (hostPrivateActivationSucceeded || applicationActivated || didFocusOrRaise)
@@ -697,11 +838,16 @@
"hostPrivateActivated": hostPrivateActivationSucceeded,
"applicationActivated": applicationActivated,
"selectedTab": selectedTab,
- "focusBeforeAXError": String(describing: focusBeforeResult.error),
- "raiseBeforeAXError": String(describing: raiseBeforeResult.error),
- "focusAfterAXError": focusAfterResult.map { String(describing: $0.error) } ?? "skippedAfterTabSelection",
- "raiseAfterAXError": raiseAfterResult.map { String(describing: $0.error) } ?? "skippedAfterTabSelection",
- "activated": activated
+ "focusBeforeAXError": String(describing: beforeAttempt.focused.error),
+ "mainBeforeAXError": String(describing: beforeAttempt.main.error),
+ "raiseBeforeAXError": String(describing: beforeAttempt.raised.error),
+ "focusAfterAXError": afterAttempt.map { String(describing: $0.focused.error) } ?? "skippedAfterTabSelection",
+ "mainAfterAXError": afterAttempt.map { String(describing: $0.main.error) } ?? "skippedAfterTabSelection",
+ "raiseAfterAXError": afterAttempt.map { String(describing: $0.raised.error) } ?? "skippedAfterTabSelection",
+ "activated": activated,
+ "elapsedMilliseconds": budget.elapsedMilliseconds,
+ "budgetMilliseconds": Self.milliseconds(budget.limit),
+ "budgetExceeded": budget.isExhausted
])
return activated
@@ -715,7 +861,8 @@
private func finderTabHostAXWindow(
for window: AlignerWindow,
- in axMetadata: [AXWindowMetadata]
+ in axMetadata: [AXWindowMetadata],
+ budget: FinderTabActivationBudget
) -> AXWindowMetadata? {
guard let processIdentifier = window.app.processIdentifier else { return nil }
let candidates = axMetadata.filter { metadata in
@@ -735,16 +882,9 @@
return !Set(candidateSpacesByID[windowID] ?? []).isDisjoint(with: targetSpaceIDs)
}
let spaceScopedCandidates = spaceCompatibleCandidates.isEmpty ? candidates : spaceCompatibleCandidates
- let selectableHostCandidates = spaceScopedCandidates.filter { metadata in
- finderHostCanRepresentTarget(window, in: metadata)
- }
-
- let hostCandidates = selectableHostCandidates.isEmpty
- ? spaceScopedCandidates
- : selectableHostCandidates
let targetFrame = matchingFrame(for: window)
- let scoredHosts = hostCandidates.compactMap { metadata -> (metadata: AXWindowMetadata, score: CGFloat)? in
+ let frameScoredHosts = spaceScopedCandidates.compactMap { metadata -> (metadata: AXWindowMetadata, score: CGFloat)? in
guard let score = frameOverlapScore(targetFrame, metadata.frame),
score >= 0.85
else {
@@ -760,39 +900,66 @@
return (lhs.metadata.windowID ?? UInt32.max) < (rhs.metadata.windowID ?? UInt32.max)
}
- if let best = scoredHosts.first {
- if scoredHosts.count == 1 {
- if selectableHostCandidates.isEmpty {
- DevelopmentDiagnostics.log("windowActivation.finderTab.frameInferredHost", [
- "windowID": window.id,
- "hostWindowID": best.metadata.windowID,
- "score": Double(best.score),
- "spaceIDCount": targetSpaceIDs.count,
- "candidateCount": hostCandidates.count,
- "targetTitleHash": DevelopmentDiagnostics.stableFingerprint(window.title),
- "targetTitleLength": window.title.count
- ])
- }
+ if let best = frameScoredHosts.first {
+ if frameScoredHosts.count == 1 {
+ DevelopmentDiagnostics.log("windowActivation.finderTab.frameInferredHost", [
+ "windowID": window.id,
+ "hostWindowID": best.metadata.windowID,
+ "score": Double(best.score),
+ "spaceIDCount": targetSpaceIDs.count,
+ "candidateCount": spaceScopedCandidates.count,
+ "targetTitleHash": DevelopmentDiagnostics.stableFingerprint(window.title),
+ "targetTitleLength": window.title.count
+ ])
return best.metadata
}
- let secondBest = scoredHosts[1]
+ let secondBest = frameScoredHosts[1]
if best.score - secondBest.score >= 0.05 {
- if selectableHostCandidates.isEmpty {
- DevelopmentDiagnostics.log("windowActivation.finderTab.frameInferredHost", [
- "windowID": window.id,
- "hostWindowID": best.metadata.windowID,
- "score": Double(best.score),
- "secondBestScore": Double(secondBest.score),
- "spaceIDCount": targetSpaceIDs.count,
- "candidateCount": hostCandidates.count,
- "targetTitleHash": DevelopmentDiagnostics.stableFingerprint(window.title),
- "targetTitleLength": window.title.count
- ])
- }
+ DevelopmentDiagnostics.log("windowActivation.finderTab.frameInferredHost", [
+ "windowID": window.id,
+ "hostWindowID": best.metadata.windowID,
+ "score": Double(best.score),
+ "secondBestScore": Double(secondBest.score),
+ "spaceIDCount": targetSpaceIDs.count,
+ "candidateCount": spaceScopedCandidates.count,
+ "targetTitleHash": DevelopmentDiagnostics.stableFingerprint(window.title),
+ "targetTitleLength": window.title.count
+ ])
return best.metadata
}
}
+
+ guard !budget.isExhausted else {
+ DevelopmentDiagnostics.log("windowActivation.finderTab.hostLookupBudgetExceeded", [
+ "windowID": window.id,
+ "candidateCount": spaceScopedCandidates.count,
+ "spaceIDCount": targetSpaceIDs.count,
+ "elapsedMilliseconds": budget.elapsedMilliseconds,
+ "budgetMilliseconds": Self.milliseconds(budget.limit)
+ ])
+ return nil
+ }
+
+ let representationScanCandidates = Array(spaceScopedCandidates.prefix(Self.finderTabHostRepresentationScanLimit))
+ let selectableHostCandidates = representationScanCandidates.filter { metadata in
+ finderHostCanRepresentTarget(window, in: metadata, budget: budget)
+ }
+
+ if spaceScopedCandidates.count > representationScanCandidates.count {
+ DevelopmentDiagnostics.log("windowActivation.finderTab.hostRepresentationScanLimited", [
+ "windowID": window.id,
+ "candidateCount": spaceScopedCandidates.count,
+ "scannedCandidateCount": representationScanCandidates.count,
+ "selectableCandidateCount": selectableHostCandidates.count,
+ "elapsedMilliseconds": budget.elapsedMilliseconds,
+ "budgetMilliseconds": Self.milliseconds(budget.limit)
+ ])
+ }
+
+ let hostCandidates = selectableHostCandidates.isEmpty
+ ? spaceScopedCandidates
+ : selectableHostCandidates
if hostCandidates.count == 1 {
if selectableHostCandidates.isEmpty {
@@ -820,7 +987,8 @@
private func finderHostCanRepresentTarget(
_ window: AlignerWindow,
- in host: AXWindowMetadata
+ in host: AXWindowMetadata,
+ budget: FinderTabActivationBudget
) -> Bool {
let normalizedTargetTitle = normalizedWindowTitle(window.title)
guard !normalizedTargetTitle.isEmpty else { return false }
@@ -829,12 +997,14 @@
return true
}
- return finderTabControl(matchingNormalizedTitle: normalizedTargetTitle, in: host.element) != nil
+ guard !budget.isExhausted else { return false }
+ return finderTabControl(matchingNormalizedTitle: normalizedTargetTitle, in: host.element, budget: budget) != nil
}
private func selectFinderTab(
_ window: AlignerWindow,
- in host: AXWindowMetadata
+ in host: AXWindowMetadata,
+ budget: FinderTabActivationBudget
) -> Bool {
let normalizedTargetTitle = normalizedWindowTitle(window.title)
guard !normalizedTargetTitle.isEmpty else { return false }
@@ -845,28 +1015,35 @@
guard let tabControl = finderTabControl(
matchingNormalizedTitle: normalizedTargetTitle,
- in: host.element
+ in: host.element,
+ budget: budget
) else {
DevelopmentDiagnostics.log("windowActivation.finderTab.tabButtonMissing", [
"windowID": window.id,
"hostWindowID": host.windowID,
"targetTitleHash": DevelopmentDiagnostics.stableFingerprint(window.title),
- "targetTitleLength": window.title.count
+ "targetTitleLength": window.title.count,
+ "elapsedMilliseconds": budget.elapsedMilliseconds,
+ "budgetMilliseconds": Self.milliseconds(budget.limit)
])
if let processIdentifier = window.app.processIdentifier,
- selectFinderWindowMenuItem(
- processIdentifier: processIdentifier,
- window: window,
- normalizedTargetTitle: normalizedTargetTitle
- ) {
- return true
- }
- if let processIdentifier = window.app.processIdentifier,
+ !budget.isExhausted,
selectFinderTabByCyclingTabs(
processIdentifier: processIdentifier,
host: host,
window: window,
- normalizedTargetTitle: normalizedTargetTitle
+ normalizedTargetTitle: normalizedTargetTitle,
+ budget: budget
+ ) {
+ return true
+ }
+ if let processIdentifier = window.app.processIdentifier,
+ !budget.isExhausted,
+ selectFinderWindowMenuItem(
+ processIdentifier: processIdentifier,
+ window: window,
+ normalizedTargetTitle: normalizedTargetTitle,
+ budget: budget
) {
return true
}
@@ -885,7 +1062,13 @@
"axError": String(describing: pressResult)
])
if pressResult == .success,
- finderHostTitleMatchesTarget(host, normalizedTargetTitle: normalizedTargetTitle) {
+ finderHostTitleMatchesTarget(
+ host,
+ normalizedTargetTitle: normalizedTargetTitle,
+ attempts: Self.finderTabFastTitleMatchAttempts,
+ delay: Self.finderTabFastTitleMatchDelay,
+ budget: budget
+ ) {
return true
}
@@ -901,7 +1084,13 @@
"axError": String(describing: selectedChildrenResult)
])
if selectedChildrenResult == .success,
- finderHostTitleMatchesTarget(host, normalizedTargetTitle: normalizedTargetTitle) {
+ finderHostTitleMatchesTarget(
+ host,
+ normalizedTargetTitle: normalizedTargetTitle,
+ attempts: Self.finderTabFastTitleMatchAttempts,
+ delay: Self.finderTabFastTitleMatchDelay,
+ budget: budget
+ ) {
return true
}
}
@@ -917,25 +1106,35 @@
"axError": String(describing: valueResult)
])
if valueResult == .success,
- finderHostTitleMatchesTarget(host, normalizedTargetTitle: normalizedTargetTitle) {
- return true
- }
-
- if let processIdentifier = window.app.processIdentifier,
- selectFinderWindowMenuItem(
- processIdentifier: processIdentifier,
- window: window,
- normalizedTargetTitle: normalizedTargetTitle
+ finderHostTitleMatchesTarget(
+ host,
+ normalizedTargetTitle: normalizedTargetTitle,
+ attempts: Self.finderTabFastTitleMatchAttempts,
+ delay: Self.finderTabFastTitleMatchDelay,
+ budget: budget
) {
return true
}
if let processIdentifier = window.app.processIdentifier,
+ !budget.isExhausted,
selectFinderTabByCyclingTabs(
processIdentifier: processIdentifier,
host: host,
window: window,
- normalizedTargetTitle: normalizedTargetTitle
+ normalizedTargetTitle: normalizedTargetTitle,
+ budget: budget
+ ) {
+ return true
+ }
+
+ if let processIdentifier = window.app.processIdentifier,
+ !budget.isExhausted,
+ selectFinderWindowMenuItem(
+ processIdentifier: processIdentifier,
+ window: window,
+ normalizedTargetTitle: normalizedTargetTitle,
+ budget: budget
) {
return true
}
@@ -947,27 +1146,24 @@
"targetTitleHash": DevelopmentDiagnostics.stableFingerprint(window.title),
"targetTitleLength": window.title.count,
"currentHostTitleHash": DevelopmentDiagnostics.stableFingerprint(currentHostTitle),
- "currentHostTitleLength": currentHostTitle.count
+ "currentHostTitleLength": currentHostTitle.count,
+ "elapsedMilliseconds": budget.elapsedMilliseconds,
+ "budgetMilliseconds": Self.milliseconds(budget.limit),
+ "budgetExceeded": budget.isExhausted
])
return false
}
- private func finderTabButton(
- matchingNormalizedTitle targetTitle: String,
- in root: AXUIElement
- ) -> AXUIElement? {
- finderTabControl(matchingNormalizedTitle: targetTitle, in: root)?.button
- }
-
private func finderTabControl(
matchingNormalizedTitle targetTitle: String,
- in root: AXUIElement
+ in root: AXUIElement,
+ budget: FinderTabActivationBudget
) -> FinderTabControl? {
var visitedCount = 0
func search(_ element: AXUIElement, depth: Int, tabGroup: AXUIElement?) -> FinderTabControl? {
visitedCount += 1
- guard visitedCount <= 240, depth <= 9 else { return nil }
+ guard visitedCount <= 240, depth <= 9, !budget.isExhausted else { return nil }
let role = axStringAttribute(kAXRoleAttribute as String, for: element)
let subrole = axStringAttribute(kAXSubroleAttribute as String, for: element)
@@ -1001,15 +1197,27 @@
_ host: AXWindowMetadata,
normalizedTargetTitle: String,
attempts: Int = 10,
- delay: TimeInterval = 0.04
+ delay: TimeInterval = 0.04,
+ budget: FinderTabActivationBudget? = nil
) -> Bool {
- for _ in 0..<attempts {
- Thread.sleep(forTimeInterval: delay)
+ let attemptCount = max(1, attempts)
+ for attempt in 0..<attemptCount {
+ if attempt > 0 {
+ let sleepDuration = budget.map { min(delay, $0.remaining) } ?? delay
+ if sleepDuration > 0 {
+ Thread.sleep(forTimeInterval: sleepDuration)
+ }
+ }
+
let selectedTitle = normalizedWindowTitle(
axStringAttribute(kAXTitleAttribute as String, for: host.element) ?? ""
)
if selectedTitle == normalizedTargetTitle {
return true
+ }
+
+ if budget?.isExhausted == true {
+ break
}
}
@@ -1019,8 +1227,19 @@
private func selectFinderWindowMenuItem(
processIdentifier: Int32,
window: AlignerWindow,
- normalizedTargetTitle: String
+ normalizedTargetTitle: String,
+ budget: FinderTabActivationBudget
) -> Bool {
+ guard !budget.isExhausted else {
+ DevelopmentDiagnostics.log("windowActivation.finderTab.windowMenuSkippedBudgetExceeded", [
+ "windowID": window.id,
+ "pid": processIdentifier,
+ "elapsedMilliseconds": budget.elapsedMilliseconds,
+ "budgetMilliseconds": Self.milliseconds(budget.limit)
+ ])
+ return false
+ }
+
let appElement = AXUIElementCreateApplication(processIdentifier)
guard let menuBar = axElementAttribute(kAXMenuBarAttribute as String, for: appElement) else {
DevelopmentDiagnostics.log("windowActivation.finderTab.windowMenuMissing", [
@@ -1032,11 +1251,14 @@
for menuBarItem in axChildren(of: menuBar) where isWindowMenuBarItem(menuBarItem) {
let openResult = AXUIElementPerformAction(menuBarItem, kAXPressAction as CFString)
- Thread.sleep(forTimeInterval: 0.08)
+ if budget.remaining > 0 {
+ Thread.sleep(forTimeInterval: min(0.08, budget.remaining))
+ }
guard openResult == .success,
let menuItem = finderWindowMenuItem(
matchingNormalizedTitle: normalizedTargetTitle,
- in: menuBarItem
+ in: menuBarItem,
+ budget: budget
)
else {
continue
@@ -1054,21 +1276,27 @@
processIdentifier: processIdentifier,
window: window,
normalizedTargetTitle: normalizedTargetTitle,
- initialDelay: 0.18,
- attempts: 18,
- delay: 0.06,
- requiredConsecutiveMatches: 3
+ initialDelay: Self.finderTabWindowMenuVerificationInitialDelay,
+ attempts: Self.finderTabWindowMenuVerificationAttempts,
+ delay: Self.finderTabWindowMenuVerificationDelay,
+ requiredConsecutiveMatches: 2,
+ budget: budget
) {
DevelopmentDiagnostics.log("windowActivation.finderTab.windowMenuSucceeded", [
"windowID": window.id,
- "pid": processIdentifier
+ "pid": processIdentifier,
+ "elapsedMilliseconds": budget.elapsedMilliseconds,
+ "budgetMilliseconds": Self.milliseconds(budget.limit)
])
return true
}
DevelopmentDiagnostics.log("windowActivation.finderTab.windowMenuVerificationFailed", [
"windowID": window.id,
- "pid": processIdentifier
+ "pid": processIdentifier,
+ "elapsedMilliseconds": budget.elapsedMilliseconds,
+ "budgetMilliseconds": Self.milliseconds(budget.limit),
+ "budgetExceeded": budget.isExhausted
])
}
@@ -1076,7 +1304,10 @@
"windowID": window.id,
"pid": processIdentifier,
"targetTitleHash": DevelopmentDiagnostics.stableFingerprint(window.title),
- "targetTitleLength": window.title.count
+ "targetTitleLength": window.title.count,
+ "elapsedMilliseconds": budget.elapsedMilliseconds,
+ "budgetMilliseconds": Self.milliseconds(budget.limit),
+ "budgetExceeded": budget.isExhausted
])
return false
}
@@ -1088,13 +1319,14 @@
private func finderWindowMenuItem(
matchingNormalizedTitle targetTitle: String,
- in root: AXUIElement
+ in root: AXUIElement,
+ budget: FinderTabActivationBudget
) -> AXUIElement? {
var visitedCount = 0
func search(_ element: AXUIElement, depth: Int) -> AXUIElement? {
visitedCount += 1
- guard visitedCount <= 500, depth <= 8 else { return nil }
+ guard visitedCount <= 500, depth <= 8, !budget.isExhausted else { return nil }
let role = axStringAttribute(kAXRoleAttribute as String, for: element)
let title = axStringAttribute(kAXTitleAttribute as String, for: element)
@@ -1116,6 +1348,15 @@
return search(root, depth: 0)
}
+ private func finderFocusedOrMainWindowTitle(processIdentifier: Int32) -> String? {
+ let appElement = AXUIElementCreateApplication(processIdentifier)
+ let focusedWindow = axElementAttribute(kAXFocusedWindowAttribute as String, for: appElement)
+ ?? axElementAttribute(kAXMainWindowAttribute as String, for: appElement)
+ return focusedWindow.flatMap {
+ axStringAttribute(kAXTitleAttribute as String, for: $0)
+ }
+ }
+
private func finderFocusedWindowTitleMatches(
processIdentifier: Int32,
window: AlignerWindow,
@@ -1123,19 +1364,18 @@
initialDelay: TimeInterval,
attempts: Int,
delay: TimeInterval,
- requiredConsecutiveMatches: Int
+ requiredConsecutiveMatches: Int,
+ budget: FinderTabActivationBudget
) -> Bool {
- let appElement = AXUIElementCreateApplication(processIdentifier)
var consecutiveMatches = 0
var lastTitle: String?
- Thread.sleep(forTimeInterval: initialDelay)
- for _ in 0..<attempts {
- let focusedWindow = axElementAttribute(kAXFocusedWindowAttribute as String, for: appElement)
- ?? axElementAttribute(kAXMainWindowAttribute as String, for: appElement)
- let title = focusedWindow.flatMap {
- axStringAttribute(kAXTitleAttribute as String, for: $0)
- }
+ if budget.remaining > 0 {
+ Thread.sleep(forTimeInterval: min(initialDelay, budget.remaining))
+ }
+ let attemptCount = max(1, attempts)
+ for attempt in 0..<attemptCount {
+ let title = finderFocusedOrMainWindowTitle(processIdentifier: processIdentifier)
lastTitle = title
if normalizedWindowTitle(title ?? "") == normalizedTargetTitle {
consecutiveMatches += 1
@@ -1146,7 +1386,13 @@
if consecutiveMatches >= requiredConsecutiveMatches {
return true
}
- Thread.sleep(forTimeInterval: delay)
+ if budget.isExhausted || attempt == attemptCount - 1 {
+ break
+ }
+ let sleepDuration = min(delay, budget.remaining)
+ if sleepDuration > 0 {
+ Thread.sleep(forTimeInterval: sleepDuration)
+ }
}
DevelopmentDiagnostics.log("windowActivation.finderTab.focusedTitleMismatch", [
@@ -1155,7 +1401,10 @@
"targetTitleHash": DevelopmentDiagnostics.stableFingerprint(window.title),
"targetTitleLength": window.title.count,
"lastTitleHash": DevelopmentDiagnostics.stableFingerprint(lastTitle),
- "lastTitleLength": lastTitle?.count
+ "lastTitleLength": lastTitle?.count,
+ "elapsedMilliseconds": budget.elapsedMilliseconds,
+ "budgetMilliseconds": Self.milliseconds(budget.limit),
+ "budgetExceeded": budget.isExhausted
])
return false
}
@@ -1164,10 +1413,23 @@
processIdentifier: Int32,
host: AXWindowMetadata,
window: AlignerWindow,
- normalizedTargetTitle: String
+ normalizedTargetTitle: String,
+ budget: FinderTabActivationBudget
) -> Bool {
- let maximumAttempts = 24
+ let maximumAttempts = Self.finderTabCycleMaximumAttempts
for attempt in 1...maximumAttempts {
+ guard !budget.isExhausted else {
+ DevelopmentDiagnostics.log("windowActivation.finderTab.cycleBudgetExceeded", [
+ "windowID": window.id,
+ "hostWindowID": host.windowID,
+ "attempt": attempt,
+ "attemptLimit": maximumAttempts,
+ "elapsedMilliseconds": budget.elapsedMilliseconds,
+ "budgetMilliseconds": Self.milliseconds(budget.limit)
+ ])
+ return false
+ }
+
for shortcut in Self.finderNextTabShortcuts {
guard postKeyboardShortcut(to: processIdentifier, shortcut: shortcut) else {
DevelopmentDiagnostics.log("windowActivation.finderTab.cyclePostFailed", [
@@ -1182,14 +1444,18 @@
if finderHostTitleMatchesTarget(
host,
normalizedTargetTitle: normalizedTargetTitle,
- attempts: 4,
- delay: 0.035
+ attempts: 2,
+ delay: 0.025,
+ budget: budget
) {
DevelopmentDiagnostics.log("windowActivation.finderTab.cycleSucceeded", [
"windowID": window.id,
"hostWindowID": host.windowID,
"attempt": attempt,
- "shortcut": shortcut.name
+ "attemptLimit": maximumAttempts,
+ "shortcut": shortcut.name,
+ "elapsedMilliseconds": budget.elapsedMilliseconds,
+ "budgetMilliseconds": Self.milliseconds(budget.limit)
])
return true
}
@@ -1199,7 +1465,10 @@
DevelopmentDiagnostics.log("windowActivation.finderTab.cycleFailed", [
"windowID": window.id,
"hostWindowID": host.windowID,
- "attemptCount": maximumAttempts
+ "attemptCount": maximumAttempts,
+ "elapsedMilliseconds": budget.elapsedMilliseconds,
+ "budgetMilliseconds": Self.milliseconds(budget.limit),
+ "budgetExceeded": budget.isExhausted
])
return false
}
@@ -1601,10 +1870,33 @@
}
let spaceIDs = spaceIDsByWindowID[resolvedWindowID] ?? []
- nextRestorableAXWindowsByID[resolvedWindowID] = RestorableAXWindow(
- processIdentifier: axWindow.processIdentifier,
- element: axWindow.element
- )
+ let isFullscreen = !Set(spaceIDs).isDisjoint(with: fullscreenSpaceIDs)
+ guard WindowEnumerationPolicy.shouldIncludeAXOnlyRecord(
+ identifierSource: identifierSource,
+ isMinimized: axWindow.isMinimized,
+ isFullscreen: isFullscreen,
+ spaceIDs: spaceIDs
+ ) else {
+ DevelopmentDiagnostics.log("windowEnumeration.axOnly.lowConfidenceSkipped", [
+ "windowID": resolvedWindowID,
+ "pid": axWindow.processIdentifier,
+ "appName": axWindow.app.name,
+ "bundleIdentifier": axWindow.app.bundleIdentifier,
+ "titleHash": DevelopmentDiagnostics.stableFingerprint(axWindow.title ?? ""),
+ "titleLength": axWindow.title?.count ?? 0,
+ "identifierSource": String(describing: identifierSource),
+ "isMinimized": axWindow.isMinimized,
+ "isFullscreen": isFullscreen,
+ "spaceIDCount": spaceIDs.count
+ ])
+ continue
+ }
+ if axWindow.isMinimized {
+ nextRestorableAXWindowsByID[resolvedWindowID] = RestorableAXWindow(
+ processIdentifier: axWindow.processIdentifier,
+ element: axWindow.element
+ )
+ }
axOnlyRecords.append(WindowEnumerationRecord(
id: resolvedWindowID,
app: axWindow.app,
@@ -1615,7 +1907,7 @@
frame: axWindow.frame,
subrole: axWindow.subrole,
isMinimized: axWindow.isMinimized,
- isFullscreen: !Set(spaceIDs).isDisjoint(with: fullscreenSpaceIDs),
+ isFullscreen: isFullscreen,
isGhost: false,
isDesktopElement: false,
isSystemCritical: isSystemCritical(app: axWindow.app),
@@ -1713,6 +2005,64 @@
return activated
}
+ private func retryFocusMainRaiseWindow(
+ for window: AlignerWindow,
+ delay: TimeInterval,
+ matchAttempt: String
+ ) -> WindowFocusAttempt? {
+ if delay > 0 {
+ Thread.sleep(forTimeInterval: delay)
+ }
+
+ let retryMetadata = AXWindowMetadataReader.metadata(appCategorizer: appCategorizer)
+ guard let retryAXWindow = axWindow(
+ for: window,
+ in: retryMetadata,
+ operation: "windowActivation",
+ matchAttempt: matchAttempt
+ ) else {
+ DevelopmentDiagnostics.log("windowActivation.activate.retryAXLookupMissing", [
+ "windowID": window.id,
+ "pid": window.app.processIdentifier,
+ "attempt": matchAttempt,
+ "candidateCount": retryMetadata.filter { $0.processIdentifier == window.app.processIdentifier }.count,
+ "matchingTitleCandidateCount": matchingAXTitleCandidateCount(for: window, in: retryMetadata),
+ "matchingSizeCandidateCount": matchingAXSizeCandidateCount(for: window, in: retryMetadata)
+ ])
+ return nil
+ }
+
+ let attempt = focusMainRaiseWindowResult(
+ retryAXWindow.element,
+ processIdentifier: retryAXWindow.processIdentifier
+ )
+ DevelopmentDiagnostics.log("windowActivation.activate.retryAXFocus", [
+ "windowID": window.id,
+ "pid": retryAXWindow.processIdentifier,
+ "attempt": matchAttempt,
+ "matchedWindowID": retryAXWindow.windowID,
+ "matchedTitleHash": DevelopmentDiagnostics.stableFingerprint(retryAXWindow.title),
+ "focused": attempt.focused.success,
+ "focusAXError": String(describing: attempt.focused.error),
+ "main": attempt.main.success,
+ "mainAXError": String(describing: attempt.main.error),
+ "raised": attempt.raised.success,
+ "raiseAXError": String(describing: attempt.raised.error)
+ ])
+ return attempt
+ }
+
+ private func focusMainRaiseWindowResult(
+ _ window: AXUIElement,
+ processIdentifier: Int32
+ ) -> WindowFocusAttempt {
+ WindowFocusAttempt(
+ focused: focusWindowResult(window, processIdentifier: processIdentifier),
+ main: mainWindowResult(window),
+ raised: raiseWindowResult(window)
+ )
+ }
+
private func focusWindowResult(_ window: AXUIElement, processIdentifier: Int32) -> AXActionResult {
let appElement = AXUIElementCreateApplication(processIdentifier)
let error = AXUIElementSetAttributeValue(
@@ -1723,10 +2073,27 @@
return AXActionResult(error: error)
}
+ private func mainWindowResult(_ window: AXUIElement) -> AXActionResult {
+ let error = AXUIElementSetAttributeValue(
+ window,
+ kAXMainAttribute as CFString,
+ kCFBooleanTrue
+ )
+ return AXActionResult(error: error)
+ }
+
private func raiseWindowResult(_ window: AXUIElement) -> AXActionResult {
AXActionResult(error: AXUIElementPerformAction(window, kAXRaiseAction as CFString))
}
+ private static func elapsedMilliseconds(since startedAt: TimeInterval) -> Int {
+ milliseconds(ProcessInfo.processInfo.systemUptime - startedAt)
+ }
+
+ private static func milliseconds(_ interval: TimeInterval) -> Int {
+ Int((max(0, interval) * 1_000).rounded())
+ }
+
private func activationPolicy(from runningApplication: NSRunningApplication?) -> AlignerActivationPolicy {
guard let runningApplication else { return .unknown }
diff --git a/C1.source/Sources/Aligner/QuickSwitchRootView.swift b/C1.source/Sources/Aligner/QuickSwitchRootView.swift
index 3b0a67e..7bb50aa 100644
--- a/C1.source/Sources/Aligner/QuickSwitchRootView.swift
+++ b/C1.source/Sources/Aligner/QuickSwitchRootView.swift
@@ -58,6 +58,7 @@
private var appShelfContentWidth: CGFloat = 0
private var appShelfScrollOffset: CGFloat = 0
private var appShelfTrackingArea: NSTrackingArea?
+ private var lastKnownMouseWindowLocation: CGPoint?
private var hoveredAppGroupIndex: Int?
private var hoveredWindowID: UInt32?
private var hoveredSpaceID: UInt64?
@@ -416,6 +417,27 @@
static let compressedIndexFontSize: CGFloat = 14
static let indexWidth: CGFloat = 18
static let indexHeight: CGFloat = 24
+ static let magneticFirstNeighborInfluence: CGFloat = 0
+ static let magneticSecondNeighborInfluence: CGFloat = 0
+ static let magneticHoverLift: CGFloat = 4
+ static let magneticFirstNeighborLift: CGFloat = 0
+ static let magneticSecondNeighborLift: CGFloat = 0
+ static let magneticAnimationDuration: CFTimeInterval = 0.16
+ static let magneticAnimationTimingName = CAMediaTimingFunctionName.easeOut
+ static let closeButtonHitOutset: CGFloat = 0
+ }
+
+ private struct AppShelfMagneticState {
+ let influence: CGFloat
+ let lift: CGFloat
+
+ var isActive: Bool {
+ influence > 0
+ }
+
+ var isNeighbor: Bool {
+ influence > 0 && influence < 1
+ }
}
private enum RootLayoutMetrics {
@@ -737,6 +759,7 @@
override func mouseMoved(with event: NSEvent) {
guard debugHoveredAppGroupIndex == nil else { return }
+ lastKnownMouseWindowLocation = event.locationInWindow
clearWindowIndexKeyPending()
let toggleHovered = viewModeToggleButtonContains(windowLocation: event.locationInWindow)
@@ -773,6 +796,7 @@
override func mouseExited(with event: NSEvent) {
guard debugHoveredAppGroupIndex == nil else { return }
+ lastKnownMouseWindowLocation = nil
clearWindowIndexKeyPending()
isViewModeToggleButtonHovered = false
suppressViewModeToggleHoverPreview = false
@@ -791,51 +815,75 @@
])
return
}
+
+ if handleMouseDown(at: event.locationInWindow, clickCount: max(1, event.clickCount), allowBackgroundClick: true) {
+ return
+ }
+
+ super.mouseDown(with: event)
+ }
+
+ @discardableResult
+ private func handleMouseDown(at windowLocation: CGPoint, clickCount: Int, allowBackgroundClick: Bool) -> Bool {
clearWindowIndexKeyPending()
- if handleCloseConfirmationMouseDown(at: event.locationInWindow) {
- return
+ if handleCloseConfirmationMouseDown(at: windowLocation) {
+ return true
}
- if viewModeToggleButtonContains(windowLocation: event.locationInWindow) {
+ if viewModeToggleButtonContains(windowLocation: windowLocation) {
clickViewModeToggleButton(source: "mouse")
- return
+ return true
}
- if themeToggleButtonContains(windowLocation: event.locationInWindow) {
+ if themeToggleButtonContains(windowLocation: windowLocation) {
clickThemeToggleButton(source: "mouse")
- return
+ return true
}
- if let closeTarget = closeButtonTarget(at: event.locationInWindow) {
+ if let closeTarget = waterfallCloseButtonTarget(at: windowLocation) {
beginCloseConfirmation(for: closeTarget)
- return
+ return true
}
- if let card = waterfallCard(at: event.locationInWindow) {
+ if let card = waterfallCard(at: windowLocation) {
clickWindowCard(card)
- return
+ return true
}
- if let segment = spaceLaneSegment(at: event.locationInWindow) {
- clickSpaceLaneSegment(segment, clickCount: max(1, event.clickCount))
- return
+ if let segment = spaceLaneSegment(at: windowLocation) {
+ clickSpaceLaneSegment(segment, clickCount: max(1, clickCount))
+ return true
}
- if let item = appShelfItem(at: event.locationInWindow) {
+ if let item = appShelfIconItem(at: windowLocation) {
+ if let closeTarget = appShelfCloseButtonTarget(at: windowLocation) {
+ beginCloseConfirmation(for: closeTarget)
+ } else {
+ clickAppShelfItem(item)
+ }
+ return true
+ }
+
+ if let closeTarget = closeButtonTarget(at: windowLocation) {
+ beginCloseConfirmation(for: closeTarget)
+ return true
+ }
+
+ if let item = appShelfItem(at: windowLocation) {
clickAppShelfItem(item)
- return
+ return true
}
DevelopmentDiagnostics.log("quickSwitch.view.mouseDown.miss", [
- "x": event.locationInWindow.x,
- "y": event.locationInWindow.y,
+ "x": windowLocation.x,
+ "y": windowLocation.y,
"bounds": NSStringFromRect(bounds)
])
- if !suppressEventBackgroundClicks, onBackgroundClick?() == true {
- return
+ if allowBackgroundClick, !suppressEventBackgroundClicks, onBackgroundClick?() == true {
+ return true
}
- super.mouseDown(with: event)
+ return false
}
func setDebugHoveredAppGroupIndex(_ index: Int?) {
@@ -1176,6 +1224,7 @@
needsLayout = true
ensureCurrentSelectionVisible(horizontalIntent: .alignWithAppShelf(animated: false))
+ refreshHoverTargetAfterViewRebuild()
needsLayout = true
if animatedProjection, let transitionSnapshot {
layoutSubtreeIfNeeded()
@@ -1538,6 +1587,14 @@
"appShelfHoverIconSize": Double(AppShelfMetrics.hoverIconSize),
"appShelfSelectedIconSize": Double(AppShelfMetrics.selectedIconSize),
"appShelfMinIconSize": Double(AppShelfMetrics.minIconSize),
+ "appShelfMagneticHoverEnabled": appShelfMagneticHoverEnabled,
+ "appShelfMagneticFirstNeighborInfluence": Double(AppShelfMetrics.magneticFirstNeighborInfluence),
+ "appShelfMagneticSecondNeighborInfluence": Double(AppShelfMetrics.magneticSecondNeighborInfluence),
+ "appShelfMagneticHoverLift": Double(AppShelfMetrics.magneticHoverLift),
+ "appShelfMagneticFirstNeighborLift": Double(AppShelfMetrics.magneticFirstNeighborLift),
+ "appShelfMagneticSecondNeighborLift": Double(AppShelfMetrics.magneticSecondNeighborLift),
+ "appShelfMagneticAnimationDurationMilliseconds": Double(AppShelfMetrics.magneticAnimationDuration * 1_000),
+ "appShelfMagneticAnimationTimingName": AppShelfMetrics.magneticAnimationTimingName.rawValue,
"appShelfItems": appShelfItemReports(),
"waterfallFrame": dictionary(from: waterfallLayer.frame),
"waterfallViewMode": waterfallViewMode.rawValue,
@@ -2147,6 +2204,11 @@
}
recordMouseCommand("hover-app:\(appGroupIndex)")
hoverAppGroup(appGroupIndex)
+ case "reapply-view-model":
+ if let currentViewModel {
+ apply(viewModel: currentViewModel)
+ }
+ recordMouseCommand("reapply-view-model")
case "click-app" where parts.count == 2:
guard let appGroupIndex = Int(parts[1]) else {
recordMouseCommand("invalid:\(command)")
@@ -2154,6 +2216,20 @@
}
recordMouseCommand("click-app:\(appGroupIndex)")
clickAppShelfItem(appGroupIndex: appGroupIndex)
+ case "click-app-center" where parts.count == 2:
+ guard let appGroupIndex = Int(parts[1]) else {
+ recordMouseCommand("invalid:\(command)")
+ return
+ }
+ recordMouseCommand("click-app-center:\(appGroupIndex)")
+ clickAppShelfItemViaMouse(appGroupIndex: appGroupIndex, probe: .iconCenter)
+ case "click-app-near-close" where parts.count == 2:
+ guard let appGroupIndex = Int(parts[1]) else {
+ recordMouseCommand("invalid:\(command)")
+ return
+ }
+ recordMouseCommand("click-app-near-close:\(appGroupIndex)")
+ clickAppShelfItemViaMouse(appGroupIndex: appGroupIndex, probe: .nearCloseOutsideHit)
case "hover-app-close" where parts.count == 2:
guard let appGroupIndex = Int(parts[1]) else {
recordMouseCommand("invalid:\(command)")
@@ -2294,6 +2370,7 @@
}
private func hoverAppGroup(_ appGroupIndex: Int, source: HoverTargetSource = .debug) {
+ lastKnownMouseWindowLocation = appShelfItemCenterWindowLocation(for: appGroupIndex)
updateHoverState(
appGroupIndex: appGroupIndex,
windowID: nil,
@@ -2416,6 +2493,64 @@
}
}
+ private enum AppShelfMouseClickProbe {
+ case iconCenter
+ case nearCloseOutsideHit
+ }
+
+ private func clickAppShelfItemViaMouse(appGroupIndex: Int, probe: AppShelfMouseClickProbe) {
+ guard let windowLocation = appShelfSyntheticMouseDownLocation(for: appGroupIndex, probe: probe) else {
+ DevelopmentDiagnostics.log("quickSwitch.view.clickAppShelfViaMouse.missingLocation", [
+ "appGroupIndex": appGroupIndex
+ ])
+ return
+ }
+ _ = handleMouseDown(at: windowLocation, clickCount: 1, allowBackgroundClick: false)
+ }
+
+ private func appShelfSyntheticMouseDownLocation(
+ for appGroupIndex: Int,
+ probe: AppShelfMouseClickProbe
+ ) -> CGPoint? {
+ layoutSubtreeIfNeeded()
+ guard let item = appShelfItems.first(where: { $0.item.appGroupIndex == appGroupIndex }) else {
+ return nil
+ }
+
+ let iconFrame = item.iconLayer.frame
+ let closeFrame = item.closeButton.containerLayer.frame
+ let pointInItem: CGPoint
+ switch probe {
+ case .iconCenter:
+ pointInItem = CGPoint(x: iconFrame.midX, y: iconFrame.midY)
+ case .nearCloseOutsideHit:
+ let fallbackPoint = CGPoint(
+ x: iconFrame.maxX - 4,
+ y: iconFrame.maxY - 14
+ )
+ if closeFrame.width > 0, closeFrame.height > 0 {
+ let candidate = CGPoint(x: closeFrame.minX - 4, y: closeFrame.midY)
+ pointInItem = iconFrame.contains(candidate) ? candidate : fallbackPoint
+ } else {
+ pointInItem = fallbackPoint
+ }
+ }
+
+ let pointInView = CGPoint(
+ x: glassLayer.frame.minX
+ + shelfLayer.frame.minX
+ + appShelfContentLayer.frame.minX
+ + item.containerLayer.frame.minX
+ + pointInItem.x,
+ y: glassLayer.frame.minY
+ + shelfLayer.frame.minY
+ + appShelfContentLayer.frame.minY
+ + item.containerLayer.frame.minY
+ + pointInItem.y
+ )
+ return convert(pointInView, to: nil)
+ }
+
private func scrollAppShelf(to targetOffset: CGFloat) {
layoutSubtreeIfNeeded()
let previousOffset = appShelfScrollOffset
@@ -2536,8 +2671,26 @@
private func refreshHoverTarget(at windowLocation: CGPoint) {
guard debugHoveredAppGroupIndex == nil else { return }
+ lastKnownMouseWindowLocation = windowLocation
let target = resolveHoverTarget(at: windowLocation)
setHoverTarget(target, alignsWaterfallWithAppShelf: target.shouldAlignWaterfallWithAppShelf)
+ }
+
+ private func refreshHoverTargetAfterViewRebuild() {
+ guard debugHoveredAppGroupIndex == nil, currentViewModel != nil else { return }
+
+ layoutSubtreeIfNeeded()
+ let windowLocation = lastKnownMouseWindowLocation ?? window?.mouseLocationOutsideOfEventStream
+ guard let windowLocation else { return }
+
+ let locationInView = convert(windowLocation, from: nil)
+ guard bounds.contains(locationInView) else {
+ lastKnownMouseWindowLocation = nil
+ setHoverTarget(.none)
+ return
+ }
+
+ refreshHoverTarget(at: windowLocation)
}
private func resolveHoverTarget(at windowLocation: CGPoint) -> HoverTarget {
@@ -4717,42 +4870,90 @@
}
private func appShelfItem(at windowLocation: CGPoint) -> AppShelfItemLayers? {
- let locationInView = convert(windowLocation, from: nil)
- let locationInShelfContent = CGPoint(
- x: locationInView.x - glassLayer.frame.minX - shelfLayer.frame.minX - appShelfContentLayer.frame.minX,
- y: locationInView.y - glassLayer.frame.minY - shelfLayer.frame.minY - appShelfContentLayer.frame.minY
- )
+ let locationInShelfContent = appShelfContentLocation(at: windowLocation)
return appShelfItems.first { item in
- item.containerLayer.frame.contains(locationInShelfContent)
+ interactiveFrame(for: item.containerLayer).contains(locationInShelfContent)
}
+ }
+
+ private func appShelfIconItem(at windowLocation: CGPoint) -> AppShelfItemLayers? {
+ let locationInAppShelfContent = appShelfContentLocation(at: windowLocation)
+
+ return appShelfItems.first { item in
+ let itemFrame = interactiveFrame(for: item.containerLayer)
+ let locationInItem = CGPoint(
+ x: locationInAppShelfContent.x - itemFrame.minX,
+ y: locationInAppShelfContent.y - itemFrame.minY
+ )
+ return interactiveFrame(for: item.iconLayer).contains(locationInItem)
+ }
+ }
+
+ private func appShelfContentLocation(at windowLocation: CGPoint) -> CGPoint {
+ let locationInView = convert(windowLocation, from: nil)
+ let glassFrame = interactiveFrame(for: glassLayer)
+ let shelfFrame = interactiveFrame(for: shelfLayer)
+ let appShelfContentFrame = interactiveFrame(for: appShelfContentLayer)
+
+ return CGPoint(
+ x: locationInView.x - glassFrame.minX - shelfFrame.minX - appShelfContentFrame.minX,
+ y: locationInView.y - glassFrame.minY - shelfFrame.minY - appShelfContentFrame.minY
+ )
+ }
+
+ private func appShelfItemCenterWindowLocation(for appGroupIndex: Int) -> CGPoint? {
+ guard let item = appShelfItems.first(where: { $0.item.appGroupIndex == appGroupIndex }) else {
+ return nil
+ }
+
+ let pointInView = CGPoint(
+ x: glassLayer.frame.minX
+ + shelfLayer.frame.minX
+ + appShelfContentLayer.frame.minX
+ + item.containerLayer.frame.midX,
+ y: glassLayer.frame.minY
+ + shelfLayer.frame.minY
+ + appShelfContentLayer.frame.minY
+ + item.containerLayer.frame.midY
+ )
+ return convert(pointInView, to: nil)
}
private func waterfallCard(at windowLocation: CGPoint) -> WaterfallCardLayers? {
let locationInView = convert(windowLocation, from: nil)
+ let glassFrame = interactiveFrame(for: glassLayer)
+ let waterfallFrame = interactiveFrame(for: waterfallLayer)
+ let waterfallContentFrame = interactiveFrame(for: waterfallContentLayer)
let locationInWaterfallContent = CGPoint(
- x: locationInView.x - glassLayer.frame.minX - waterfallLayer.frame.minX - waterfallContentLayer.frame.minX,
- y: locationInView.y - glassLayer.frame.minY - waterfallLayer.frame.minY - waterfallContentLayer.frame.minY
+ x: locationInView.x - glassFrame.minX - waterfallFrame.minX - waterfallContentFrame.minX,
+ y: locationInView.y - glassFrame.minY - waterfallFrame.minY - waterfallContentFrame.minY
)
- for column in waterfallColumns where column.containerLayer.frame.contains(locationInWaterfallContent) {
+ for column in waterfallColumns where interactiveFrame(for: column.containerLayer).contains(locationInWaterfallContent) {
+ let columnFrame = interactiveFrame(for: column.containerLayer)
+ let cardsClipFrame = interactiveFrame(for: column.cardsClipLayer)
let locationInColumn = CGPoint(
- x: locationInWaterfallContent.x - column.containerLayer.frame.minX,
- y: locationInWaterfallContent.y - column.containerLayer.frame.minY
+ x: locationInWaterfallContent.x - columnFrame.minX,
+ y: locationInWaterfallContent.y - columnFrame.minY
)
- guard column.cardsClipLayer.frame.contains(locationInColumn) else {
+ guard cardsClipFrame.contains(locationInColumn) else {
continue
}
let locationInCardsClip = CGPoint(
- x: locationInColumn.x - column.cardsClipLayer.frame.minX,
- y: locationInColumn.y - column.cardsClipLayer.frame.minY
+ x: locationInColumn.x - cardsClipFrame.minX,
+ y: locationInColumn.y - cardsClipFrame.minY
)
- if let card = column.cards.first(where: { $0.containerLayer.frame.contains(locationInCardsClip) }) {
+ if let card = column.cards.first(where: { interactiveFrame(for: $0.containerLayer).contains(locationInCardsClip) }) {
return card
}
}
return nil
+ }
+
+ private func interactiveFrame(for layer: CALayer) -> CGRect {
+ layer.presentation()?.frame ?? layer.frame
}
private func waterfallCard(appGroupIndex: Int, windowIndex: Int) -> WaterfallCardLayers? {
@@ -4889,50 +5090,69 @@
let isHovered = item.item.appGroupIndex == effectiveHoveredAppGroupIndex
let showsSelectedVisual = shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered)
let isSpaceFocused = spaceFocusWindowCount(for: item.item.appGroupIndex) > 0
+ let magneticState = appShelfMagneticState(for: item)
let visualIconSize = appShelfVisualIconSize(
forBaseIconSize: iconSize,
selected: showsSelectedVisual,
hovered: isHovered,
- spaceFocused: isSpaceFocused
+ spaceFocused: isSpaceFocused,
+ magneticState: magneticState
)
- let backgroundPadding: CGFloat = (showsSelectedVisual || isHovered)
- ? 4
- : isSpaceFocused
- ? 6
- : 2
+ let backgroundPadding: CGFloat = isSpaceFocused ? 6 : 2
let iconX = (bounds.width - visualIconSize) / 2
- let iconY = bounds.height - visualIconSize - 2
+ let iconY = bounds.height - visualIconSize - 2 + magneticState.lift
let badgeText = appShelfBadgeText(for: item)
let badgeWidth = max(18, CGFloat(badgeText.count) * 7 + 8)
+
+ let previousBackgroundFrame = item.backgroundLayer.presentation()?.frame ?? item.backgroundLayer.frame
+ let previousIconFrame = item.iconLayer.presentation()?.frame ?? item.iconLayer.frame
+ let previousIndexFrame = item.indexLayer.presentation()?.frame ?? item.indexLayer.frame
+ let previousBadgeFrame = item.badgeLayer.presentation()?.frame ?? item.badgeLayer.frame
+ let previousCloseFrame = item.closeButton.containerLayer.presentation()?.frame
+ ?? item.closeButton.containerLayer.frame
item.containerLayer.zPosition = appShelfZPosition(
selected: showsSelectedVisual,
hovered: isHovered,
- spaceFocused: isSpaceFocused
+ spaceFocused: isSpaceFocused,
+ magneticState: magneticState
)
item.backgroundLayer.backgroundColor = appShelfBackgroundColor(
selected: showsSelectedVisual,
hovered: isHovered,
- spaceFocused: isSpaceFocused
+ spaceFocused: isSpaceFocused,
+ magneticState: magneticState
).cgColor
- item.backgroundLayer.borderColor = (showsSelectedVisual
- ? NSColor.controlAccentColor.withAlphaComponent(0.40)
- : isHovered
- ? NSColor.controlAccentColor.withAlphaComponent(0.36)
- : NSColor.clear
- ).cgColor
- item.backgroundLayer.borderWidth = (showsSelectedVisual || isHovered) ? 1 : 0
+ item.backgroundLayer.borderColor = NSColor.clear.cgColor
+ item.backgroundLayer.borderWidth = 0
+ item.containerLayer.borderColor = NSColor.clear.cgColor
+ item.containerLayer.borderWidth = 0
+ item.iconLayer.borderColor = NSColor.clear.cgColor
+ item.iconLayer.borderWidth = 0
item.backgroundLayer.shadowColor = NSColor.black.cgColor
- item.backgroundLayer.shadowOpacity = showsSelectedVisual ? 0.11 : isHovered ? 0.07 : isSpaceFocused ? 0.18 : 0
- item.backgroundLayer.shadowRadius = showsSelectedVisual ? 10 : isHovered ? 8 : isSpaceFocused ? 18 : 0
- item.backgroundLayer.shadowOffset = CGSize(width: 0, height: isSpaceFocused ? -4 : -2)
+ item.backgroundLayer.shadowOpacity = appShelfShadowOpacity(
+ selected: showsSelectedVisual,
+ hovered: isHovered,
+ spaceFocused: isSpaceFocused,
+ magneticState: magneticState
+ )
+ item.backgroundLayer.shadowRadius = appShelfShadowRadius(
+ selected: showsSelectedVisual,
+ hovered: isHovered,
+ spaceFocused: isSpaceFocused,
+ magneticState: magneticState
+ )
+ item.backgroundLayer.shadowOffset = CGSize(
+ width: 0,
+ height: isSpaceFocused ? -4 : magneticState.isNeighbor ? -1 : -2
+ )
item.backgroundLayer.frame = CGRect(
x: iconX - backgroundPadding,
y: iconY - backgroundPadding,
width: visualIconSize + backgroundPadding * 2,
height: visualIconSize + backgroundPadding * 2
)
- item.backgroundLayer.shadowPath = (showsSelectedVisual || isHovered || isSpaceFocused)
+ item.backgroundLayer.shadowPath = isSpaceFocused
? CGPath(
roundedRect: item.backgroundLayer.bounds,
cornerWidth: item.backgroundLayer.cornerRadius,
@@ -4974,6 +5194,12 @@
)
item.selectedIndicatorLayer.frame = .zero
item.selectedIndicatorLayer.backgroundColor = NSColor.clear.cgColor
+
+ animateAppShelfLayerFrame(item.backgroundLayer, from: previousBackgroundFrame)
+ animateAppShelfLayerFrame(item.iconLayer, from: previousIconFrame)
+ animateAppShelfLayerFrame(item.indexLayer, from: previousIndexFrame)
+ animateAppShelfLayerFrame(item.badgeLayer, from: previousBadgeFrame)
+ animateAppShelfLayerFrame(item.closeButton.containerLayer, from: previousCloseFrame)
}
private var appShelfIndexColor: NSColor {
@@ -5056,6 +5282,75 @@
private var effectiveHoveredAppGroupIndex: Int? {
debugHoveredAppGroupIndex ?? hoveredAppGroupIndex
+ }
+
+ private var appShelfMagneticHoverEnabled: Bool {
+ !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
+ }
+
+ private func appShelfMagneticState(for item: AppShelfItemLayers) -> AppShelfMagneticState {
+ guard appShelfMagneticHoverEnabled,
+ let hoveredAppGroupIndex = effectiveHoveredAppGroupIndex,
+ item.item.appGroupIndex == hoveredAppGroupIndex
+ else {
+ return AppShelfMagneticState(influence: 0, lift: 0)
+ }
+
+ return AppShelfMagneticState(
+ influence: 1,
+ lift: AppShelfMetrics.magneticHoverLift
+ )
+ }
+
+ private func animateAppShelfLayerFrame(_ layer: CALayer, from previousFrame: CGRect) {
+ guard appShelfMagneticHoverEnabled,
+ previousFrame.width > 0,
+ previousFrame.height > 0,
+ appShelfFrameIsFinite(previousFrame),
+ appShelfFrameIsFinite(layer.frame)
+ else {
+ layer.removeAnimation(forKey: "appShelfMagneticPosition")
+ layer.removeAnimation(forKey: "appShelfMagneticBounds")
+ return
+ }
+
+ let previousPosition = CGPoint(x: previousFrame.midX, y: previousFrame.midY)
+ let previousBounds = CGRect(origin: .zero, size: previousFrame.size)
+ let nextPosition = layer.position
+ let nextBounds = layer.bounds
+ let moved = abs(previousPosition.x - nextPosition.x) > 0.25
+ || abs(previousPosition.y - nextPosition.y) > 0.25
+ let resized = abs(previousBounds.width - nextBounds.width) > 0.25
+ || abs(previousBounds.height - nextBounds.height) > 0.25
+
+ guard moved || resized else { return }
+
+ let timing = CAMediaTimingFunction(name: AppShelfMetrics.magneticAnimationTimingName)
+ if moved {
+ let animation = CABasicAnimation(keyPath: "position")
+ animation.fromValue = previousPosition
+ animation.toValue = nextPosition
+ animation.duration = AppShelfMetrics.magneticAnimationDuration
+ animation.timingFunction = timing
+ animation.isRemovedOnCompletion = true
+ layer.add(animation, forKey: "appShelfMagneticPosition")
+ }
+ if resized {
+ let animation = CABasicAnimation(keyPath: "bounds")
+ animation.fromValue = previousBounds
+ animation.toValue = nextBounds
+ animation.duration = AppShelfMetrics.magneticAnimationDuration
+ animation.timingFunction = timing
+ animation.isRemovedOnCompletion = true
+ layer.add(animation, forKey: "appShelfMagneticBounds")
+ }
+ }
+
+ private func appShelfFrameIsFinite(_ frame: CGRect) -> Bool {
+ frame.origin.x.isFinite
+ && frame.origin.y.isFinite
+ && frame.size.width.isFinite
+ && frame.size.height.isFinite
}
private var activeSpaceFocusID: UInt64? {
@@ -5178,7 +5473,8 @@
forBaseIconSize iconSize: CGFloat,
selected: Bool,
hovered: Bool,
- spaceFocused: Bool
+ spaceFocused: Bool,
+ magneticState: AppShelfMagneticState
) -> CGFloat {
if selected || hovered {
return AppShelfMetrics.selectedIconSize
@@ -5186,10 +5482,19 @@
if spaceFocused {
return min(AppShelfMetrics.selectedIconSize, iconSize + 8)
}
- return iconSize
+ guard magneticState.isNeighbor else {
+ return iconSize
+ }
+ let growth = AppShelfMetrics.hoverIconSize - iconSize
+ return iconSize + growth * magneticState.influence
}
- private func appShelfZPosition(selected: Bool, hovered: Bool, spaceFocused: Bool) -> CGFloat {
+ private func appShelfZPosition(
+ selected: Bool,
+ hovered: Bool,
+ spaceFocused: Bool,
+ magneticState: AppShelfMagneticState
+ ) -> CGFloat {
switch (selected, hovered, spaceFocused) {
case (true, true, _):
return 30
@@ -5200,21 +5505,44 @@
case (false, false, true):
return 12
case (false, false, false):
- return 0
+ return magneticState.isNeighbor ? 6 + magneticState.influence : 0
}
}
- private func appShelfBackgroundColor(selected: Bool, hovered: Bool, spaceFocused: Bool) -> NSColor {
- if selected {
- return themePalette.appShelfBackgroundSelected
- }
- if hovered {
- return themePalette.appShelfBackgroundHovered
- }
+ private func appShelfBackgroundColor(
+ selected: Bool,
+ hovered: Bool,
+ spaceFocused: Bool,
+ magneticState: AppShelfMagneticState
+ ) -> NSColor {
if spaceFocused {
return themePalette.appShelfBackgroundSpaceFocused
}
return .clear
+ }
+
+ private func appShelfShadowOpacity(
+ selected: Bool,
+ hovered: Bool,
+ spaceFocused: Bool,
+ magneticState: AppShelfMagneticState
+ ) -> Float {
+ if spaceFocused {
+ return 0.18
+ }
+ return 0
+ }
+
+ private func appShelfShadowRadius(
+ selected: Bool,
+ hovered: Bool,
+ spaceFocused: Bool,
+ magneticState: AppShelfMagneticState
+ ) -> CGFloat {
+ if spaceFocused {
+ return 18
+ }
+ return 0
}
private func appShelfBadgeText(for item: AppShelfItemLayers) -> String {
@@ -6133,11 +6461,7 @@
}
private func appShelfCloseButtonTarget(at windowLocation: NSPoint) -> CloseTarget? {
- let locationInView = convert(windowLocation, from: nil)
- let locationInAppShelfContent = CGPoint(
- x: locationInView.x - glassLayer.frame.minX - shelfLayer.frame.minX - appShelfContentLayer.frame.minX,
- y: locationInView.y - glassLayer.frame.minY - shelfLayer.frame.minY - appShelfContentLayer.frame.minY
- )
+ let locationInAppShelfContent = appShelfContentLocation(at: windowLocation)
for item in appShelfItems {
let target = CloseTarget.app(appGroupIndex: item.item.appGroupIndex)
@@ -6145,43 +6469,59 @@
canClose(app: item.item.app)
else { continue }
+ let itemFrame = interactiveFrame(for: item.containerLayer)
+ let closeFrame = interactiveFrame(for: item.closeButton.containerLayer)
let locationInItem = CGPoint(
- x: locationInAppShelfContent.x - item.containerLayer.frame.minX,
- y: locationInAppShelfContent.y - item.containerLayer.frame.minY
+ x: locationInAppShelfContent.x - itemFrame.minX,
+ y: locationInAppShelfContent.y - itemFrame.minY
)
- if item.closeButton.containerLayer.frame.insetBy(dx: -8, dy: -8).contains(locationInItem) {
+ if appShelfCloseHitFrame(for: closeFrame).contains(locationInItem) {
return target
}
}
return nil
}
+ private func appShelfCloseHitFrame(for closeFrame: CGRect) -> CGRect {
+ closeFrame.insetBy(
+ dx: -AppShelfMetrics.closeButtonHitOutset,
+ dy: -AppShelfMetrics.closeButtonHitOutset
+ )
+ }
+
private func waterfallCloseButtonTarget(at windowLocation: NSPoint) -> CloseTarget? {
let locationInView = convert(windowLocation, from: nil)
+ let glassFrame = interactiveFrame(for: glassLayer)
+ let waterfallFrame = interactiveFrame(for: waterfallLayer)
+ let waterfallContentFrame = interactiveFrame(for: waterfallContentLayer)
let locationInWaterfallContent = CGPoint(
- x: locationInView.x - glassLayer.frame.minX - waterfallLayer.frame.minX - waterfallContentLayer.frame.minX,
- y: locationInView.y - glassLayer.frame.minY - waterfallLayer.frame.minY - waterfallContentLayer.frame.minY
+ x: locationInView.x - glassFrame.minX - waterfallFrame.minX - waterfallContentFrame.minX,
+ y: locationInView.y - glassFrame.minY - waterfallFrame.minY - waterfallContentFrame.minY
)
- for column in waterfallColumns where column.containerLayer.frame.contains(locationInWaterfallContent) {
+ for column in waterfallColumns where interactiveFrame(for: column.containerLayer).contains(locationInWaterfallContent) {
+ let columnFrame = interactiveFrame(for: column.containerLayer)
+ let cardsClipFrame = interactiveFrame(for: column.cardsClipLayer)
let locationInColumn = CGPoint(
- x: locationInWaterfallContent.x - column.containerLayer.frame.minX,
- y: locationInWaterfallContent.y - column.containerLayer.frame.minY
+ x: locationInWaterfallContent.x - columnFrame.minX,
+ y: locationInWaterfallContent.y - columnFrame.minY
)
- guard column.cardsClipLayer.frame.contains(locationInColumn) else {
+ guard cardsClipFrame.contains(locationInColumn) else {
continue
}
let locationInCardsClip = CGPoint(
- x: locationInColumn.x - column.cardsClipLayer.frame.minX,
- y: locationInColumn.y - column.cardsClipLayer.frame.minY
+ x: locationInColumn.x - cardsClipFrame.minX,
+ y: locationInColumn.y - cardsClipFrame.minY
)
- for card in column.cards where card.containerLayer.frame.contains(locationInCardsClip) {
+ for card in column.cards where interactiveFrame(for: card.containerLayer).contains(locationInCardsClip) {
guard card.closeButton.containerLayer.opacity > 0 else { continue }
+ let cardFrame = interactiveFrame(for: card.containerLayer)
+ let closeFrame = interactiveFrame(for: card.closeButton.containerLayer)
let locationInCard = CGPoint(
- x: locationInCardsClip.x - card.containerLayer.frame.minX,
- y: locationInCardsClip.y - card.containerLayer.frame.minY
+ x: locationInCardsClip.x - cardFrame.minX,
+ y: locationInCardsClip.y - cardFrame.minY
)
- if card.closeButton.containerLayer.frame.insetBy(dx: -8, dy: -8).contains(locationInCard) {
+ if closeFrame.insetBy(dx: -8, dy: -8).contains(locationInCard) {
return .window(
appGroupIndex: card.item.appGroupIndex,
windowIndex: card.item.windowIndex,
@@ -6872,6 +7212,7 @@
private func appShelfItemReports() -> [[String: Any]] {
appShelfItems.enumerated().map { index, item in
let visibleFrame = item.containerLayer.frame.offsetBy(dx: appShelfContentLayer.frame.minX, dy: 0)
+ let magneticState = appShelfMagneticState(for: item)
return [
"index": index,
"name": item.item.app.name,
@@ -6882,13 +7223,25 @@
"isSelected": item.item.appGroupIndex == effectiveSelection?.appGroupIndex,
"isHovered": item.item.appGroupIndex == effectiveHoveredAppGroupIndex,
"isSpaceFocused": spaceFocusWindowCount(for: item.item.appGroupIndex) > 0,
+ "isMagneticNeighbor": magneticState.isNeighbor,
+ "magneticInfluence": Double(magneticState.influence),
+ "magneticLift": Double(magneticState.lift),
"visualStates": appShelfVisualStates(for: item),
"zPosition": Double(item.containerLayer.zPosition),
"hasMinimizedWindows": item.item.hasMinimizedWindows,
"hasFullscreenWindows": item.item.hasFullscreenWindows,
"frame": dictionary(from: item.containerLayer.frame),
"visibleFrame": dictionary(from: visibleFrame),
+ "containerBorderWidth": Double(item.containerLayer.borderWidth),
+ "containerBorderColor": colorDictionary(from: item.containerLayer.borderColor),
"iconFrame": dictionary(from: item.iconLayer.frame),
+ "iconBorderWidth": Double(item.iconLayer.borderWidth),
+ "iconBorderColor": colorDictionary(from: item.iconLayer.borderColor),
+ "backgroundFrame": dictionary(from: item.backgroundLayer.frame),
+ "backgroundColor": colorDictionary(from: item.backgroundLayer.backgroundColor),
+ "backgroundBorderWidth": Double(item.backgroundLayer.borderWidth),
+ "backgroundShadowOpacity": Double(item.backgroundLayer.shadowOpacity),
+ "backgroundShadowRadius": Double(item.backgroundLayer.shadowRadius),
"indexText": item.indexLayer.string ?? "",
"indexFrame": dictionary(from: item.indexLayer.frame),
"indexVisible": !item.indexLayer.isHidden && item.indexLayer.opacity > 0,
@@ -6901,6 +7254,7 @@
"badgeText": item.badgeLayer.string ?? "",
"closeButtonVisible": item.closeButton.containerLayer.opacity > 0,
"closeButtonFrame": dictionary(from: item.closeButton.containerLayer.frame),
+ "closeButtonHitFrame": dictionary(from: appShelfCloseHitFrame(for: item.closeButton.containerLayer.frame)),
"closeButtonSize": Double(item.closeButton.containerLayer.bounds.width),
"closeButtonFillColor": colorDictionary(from: item.closeButton.containerLayer.backgroundColor),
"closeButtonGlyphColor": colorDictionary(from: item.closeButton.glyphLayer.strokeColor),
@@ -6913,6 +7267,7 @@
var states: [String] = []
let isSelected = item.item.appGroupIndex == effectiveSelection?.appGroupIndex
let isHovered = item.item.appGroupIndex == effectiveHoveredAppGroupIndex
+ let magneticState = appShelfMagneticState(for: item)
if shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered) {
states.append("selected")
} else if isSelected {
@@ -6921,6 +7276,9 @@
if isHovered {
states.append("hover")
}
+ if magneticState.isNeighbor {
+ states.append("magneticNeighbor")
+ }
if spaceFocusWindowCount(for: item.item.appGroupIndex) > 0 {
states.append("spaceFocused")
let direction = spaceFocusDirection(for: item.item.appGroupIndex)
diff --git a/C1.source/Sources/Aligner/QuickSwitchSessionController.swift b/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
index f6b860a..8122f9a 100644
--- a/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
+++ b/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
@@ -57,6 +57,7 @@
private var lastActivationWindowID: UInt32?
private var lastActivationResult: WindowActivationResult?
private var lastActivationError: String?
+ private var lastActivationDurationMilliseconds: Double?
private var lastSpaceActivationSpaceID: UInt64?
private var lastSpaceActivationDidRequestFocus: Bool?
private var lastSpaceActivationDisplayIdentifier: String?
@@ -70,6 +71,7 @@
private var lastCloseError: String?
private var pendingCloseVerification: PendingCloseVerification?
private var suppressedCloseTargets: [SuppressedCloseTarget] = []
+ private var suppressedActivationWindowIDs = Set<UInt32>()
private var lastDismissReason: DismissReason?
private var lastSystemCriticalAction: String?
private var lastSystemCriticalMonitorSource: String?
@@ -173,6 +175,7 @@
lastActivationWindowID = nil
lastActivationResult = nil
lastActivationError = nil
+ lastActivationDurationMilliseconds = nil
lastSpaceActivationSpaceID = nil
lastSpaceActivationDidRequestFocus = nil
lastSpaceActivationDisplayIdentifier = nil
@@ -186,6 +189,7 @@
lastCloseError = nil
pendingCloseVerification = nil
suppressedCloseTargets = []
+ suppressedActivationWindowIDs = []
cancelPendingLockedSpaceUnlock()
lockedSpaceFilterID = nil
selectionBeforeSpaceFilter = nil
@@ -376,6 +380,7 @@
"lastActivationWindowID": lastActivationWindowID ?? NSNull(),
"lastActivationResult": lastActivationResult.map(Self.activationResultString) ?? NSNull(),
"lastActivationError": lastActivationError ?? NSNull(),
+ "lastActivationDurationMilliseconds": lastActivationDurationMilliseconds ?? NSNull(),
"lastSpaceActivationSpaceID": lastSpaceActivationSpaceID ?? NSNull(),
"lastSpaceActivationDidRequestFocus": lastSpaceActivationDidRequestFocus ?? NSNull(),
"lastSpaceActivationDisplayIdentifier": lastSpaceActivationDisplayIdentifier ?? NSNull(),
@@ -388,6 +393,8 @@
"lastCloseResult": lastCloseResult.map(Self.closeResultString) ?? NSNull(),
"lastCloseError": lastCloseError ?? NSNull(),
"suppressedCloseTargetCount": suppressedCloseTargets.count,
+ "suppressedActivationWindowCount": suppressedActivationWindowIDs.count,
+ "suppressedActivationWindowIDs": Array(suppressedActivationWindowIDs).sorted(),
"lastDismissReason": lastDismissReason.map(Self.dismissReasonString) ?? NSNull(),
"overlayLevel": Int(coordinator.currentLevel),
"overlayMainMenuLevel": Int(CGWindowLevelForKey(.mainMenuWindow)),
@@ -568,9 +575,11 @@
lastCommitSource = source
lastActivationWindowID = selection.windowID
lastActivationError = nil
+ lastActivationDurationMilliseconds = nil
guard let window = window(for: selection, in: viewModel ?? currentViewModel) else {
lastActivationResult = .windowNotFound
+ suppressActivationWindowID(selection.windowID, reason: "selectionMissing")
DevelopmentDiagnostics.log("quickSwitch.activation.commit.windowNotFound", [
"source": source.rawValue,
"appGroupIndex": selection.appGroupIndex,
@@ -587,8 +596,10 @@
])
hide(reason: .userClosed)
+ let activationStartedAt = CACurrentMediaTime()
do {
lastActivationResult = try windowActivationService.activate(window: window)
+ lastActivationDurationMilliseconds = (CACurrentMediaTime() - activationStartedAt) * 1_000
DevelopmentDiagnostics.log("quickSwitch.activation.commit.result", [
"source": source.rawValue,
"windowID": window.id,
@@ -597,9 +608,11 @@
"titleHash": DevelopmentDiagnostics.stableFingerprint(window.title),
"titleLength": window.title.count,
"titleIsEmpty": window.title.isEmpty,
- "result": lastActivationResult.map(Self.activationResultString)
+ "result": lastActivationResult.map(Self.activationResultString),
+ "durationMilliseconds": lastActivationDurationMilliseconds
])
} catch {
+ lastActivationDurationMilliseconds = (CACurrentMediaTime() - activationStartedAt) * 1_000
let errorSummary = DevelopmentDiagnostics.errorSummaryString(error)
lastActivationResult = .failed(errorSummary)
lastActivationError = errorSummary
@@ -609,10 +622,15 @@
"appName": window.app.name,
"titleHash": DevelopmentDiagnostics.stableFingerprint(window.title),
"titleLength": window.title.count,
- "titleIsEmpty": window.title.isEmpty
+ "titleIsEmpty": window.title.isEmpty,
+ "durationMilliseconds": lastActivationDurationMilliseconds
]
DevelopmentDiagnostics.errorSummaryFields(error).forEach { fields[$0.key] = $0.value }
DevelopmentDiagnostics.log("quickSwitch.activation.commit.error", fields)
+ }
+
+ if shouldSuppressAfterActivationResult(lastActivationResult) {
+ suppressActivationWindowID(window.id, reason: lastActivationResult.map(Self.activationResultString) ?? "unknown")
}
if shouldCloseAfterActivation(lastActivationResult) {
@@ -629,7 +647,7 @@
private func handleSpaceLaneClick(_ spaceID: UInt64, clickCount: Int) {
if clickCount >= 2 {
- if pendingLockedSpaceUnlockSpaceID == spaceID {
+ if pendingLockedSpaceUnlockSpaceID == spaceID || lockedSpaceFilterID == spaceID {
cancelPendingLockedSpaceUnlock()
activateLockedSpace(spaceID)
} else {
@@ -961,18 +979,37 @@
])
}
+ private func suppressActivationWindowID(_ windowID: UInt32, reason: String) {
+ guard windowID != 0 else { return }
+ suppressedActivationWindowIDs.insert(windowID)
+
+ guard let sourceViewModel else { return }
+ let visibleViewModel = projectedViewModel(from: sourceViewModel, preferredSelection: view.effectiveSelectionForProjection())
+ self.currentViewModel = visibleViewModel
+ view.applyProjected(viewModel: visibleViewModel)
+ DevelopmentDiagnostics.log("quickSwitch.activation.suppressWindow", [
+ "windowID": windowID,
+ "reason": reason,
+ "remainingWindowCount": visibleViewModel.windowCount,
+ "suppressedActivationWindowCount": suppressedActivationWindowIDs.count
+ ])
+ }
+
private func viewModel(
_ viewModel: QuickSwitchViewModel,
- excluding suppressedTargets: [SuppressedCloseTarget]
+ excluding suppressedTargets: [SuppressedCloseTarget],
+ suppressedActivationWindowIDs: Set<UInt32> = []
) -> QuickSwitchViewModel {
- guard !suppressedTargets.isEmpty else { return viewModel }
+ guard !suppressedTargets.isEmpty || !suppressedActivationWindowIDs.isEmpty else { return viewModel }
var nextGlobalIndex = 0
let columns = viewModel.waterfallColumns.compactMap { column -> QuickSwitchWaterfallColumnViewModel? in
let windows = column.windows.filter { card in
- !suppressedTargets.contains { target in
+ let isActivationSuppressed = suppressedActivationWindowIDs.contains(card.window.id)
+ let isCloseSuppressed = suppressedTargets.contains { target in
suppressedTarget(target, matches: card)
}
+ return !isActivationSuppressed && !isCloseSuppressed
}
guard !windows.isEmpty else { return nil }
@@ -1021,7 +1058,11 @@
from sourceViewModel: QuickSwitchViewModel,
preferredSelection: QuickSwitchSelection? = nil
) -> QuickSwitchViewModel {
- let closeProjectedViewModel = viewModel(sourceViewModel, excluding: suppressedCloseTargets)
+ let closeProjectedViewModel = viewModel(
+ sourceViewModel,
+ excluding: suppressedCloseTargets,
+ suppressedActivationWindowIDs: suppressedActivationWindowIDs
+ )
return QuickSwitchSpaceFilterPolicy.projectedViewModel(
from: closeProjectedViewModel,
lockedSpaceID: lockedSpaceFilterID,
@@ -1078,9 +1119,18 @@
private func shouldCloseAfterActivation(_ result: WindowActivationResult?) -> Bool {
switch result {
- case .activated, .restored, .appActivatedOnly:
+ case .activated, .restored:
return true
- case .windowNotFound, .unsupported, .activationFailed, .failed, nil:
+ case .appActivatedOnly, .windowNotFound, .unsupported, .activationFailed, .failed, nil:
+ return false
+ }
+ }
+
+ private func shouldSuppressAfterActivationResult(_ result: WindowActivationResult?) -> Bool {
+ switch result {
+ case .windowNotFound, .unsupported, .activationFailed, .failed:
+ return true
+ case .activated, .restored, .appActivatedOnly, nil:
return false
}
}
@@ -1534,6 +1584,7 @@
let lifecycleCycles: Int?
let lifecycleInterval: TimeInterval
let lifecycleVisibleDuration: TimeInterval
+ let simulateDockReopenAfter: TimeInterval?
static func parse(arguments: [String]) -> Round1QuickSwitchLaunchOptions {
Round1QuickSwitchLaunchOptions(
@@ -1563,7 +1614,8 @@
waterfallViewMode: waterfallViewModeValue(for: "--round01-waterfall-view-mode", in: arguments),
lifecycleCycles: intValue(for: "--round01-quick-switch-lifecycle-cycles", in: arguments),
lifecycleInterval: timeInterval(for: "--round01-quick-switch-lifecycle-interval", in: arguments) ?? 0.02,
- lifecycleVisibleDuration: timeInterval(for: "--round01-quick-switch-lifecycle-visible-duration", in: arguments) ?? 0.16
+ lifecycleVisibleDuration: timeInterval(for: "--round01-quick-switch-lifecycle-visible-duration", in: arguments) ?? 0.16,
+ simulateDockReopenAfter: timeInterval(for: "--round01-simulate-dock-reopen-after", in: arguments)
)
}
diff --git a/C1.source/Sources/AlignerCore/AppInfo.swift b/C1.source/Sources/AlignerCore/AppInfo.swift
index 018f8ca..7f392bf 100644
--- a/C1.source/Sources/AlignerCore/AppInfo.swift
+++ b/C1.source/Sources/AlignerCore/AppInfo.swift
@@ -3,5 +3,5 @@
public enum AlignerAppInfo {
public static let name = "Aligner"
public static let bundleIdentifier = "com.ar.Aligner"
- public static let minimumMacOSVersion = "26.0"
+ public static let minimumMacOSVersion = "14.0"
}
diff --git a/C1.source/Sources/AlignerCore/Commerce/EntitlementState.swift b/C1.source/Sources/AlignerCore/Commerce/EntitlementState.swift
new file mode 100644
index 0000000..7dadf3d
--- /dev/null
+++ b/C1.source/Sources/AlignerCore/Commerce/EntitlementState.swift
@@ -0,0 +1,336 @@
+import Foundation
+
+public enum ProFeature: String, CaseIterable, Codable, Equatable, Sendable {
+ case windowShortcutActivation
+}
+
+public enum TrialInvalidReason: String, Codable, Equatable, Sendable {
+ case corruptedRecord
+ case expiresBeforeStart
+ case startedInFuture
+ case timeRollback
+}
+
+public enum TrialState: Codable, Equatable, Sendable {
+ case notInitialized
+ case active(startedAt: Date, expiresAt: Date)
+ case expired(startedAt: Date, expiredAt: Date)
+ case invalidLocalRecord(reason: TrialInvalidReason)
+}
+
+public enum LicenseActivationStatus: String, Codable, Equatable, Sendable {
+ case active
+ case disabled
+ case expired
+}
+
+public enum LicenseRemoteFailureReason: String, Codable, Equatable, Sendable {
+ case invalid
+ case revoked
+ case disabled
+ case expired
+ case unknown
+}
+
+public enum LicenseMismatchReason: String, Codable, Equatable, Sendable {
+ case store
+ case product
+ case variant
+}
+
+public enum LicenseState: Codable, Equatable, Sendable {
+ case none
+ case activated(fingerprint: String, instanceID: String, status: LicenseActivationStatus)
+ case deactivatedLocal
+ case revokedOrExpiredRemote(reason: LicenseRemoteFailureReason)
+ case mismatch(reason: LicenseMismatchReason)
+}
+
+public enum ValidationHardFailureReason: String, Codable, Equatable, Sendable {
+ case invalid
+ case revoked
+ case disabled
+ case expired
+ case productMismatch
+ case storeMismatch
+ case variantMismatch
+ case serverRejected
+ case unknown
+}
+
+public enum ValidationFreshness: Codable, Equatable, Sendable {
+ case neverChecked
+ case valid(lastValidAt: Date, nextCheckAt: Date)
+ case temporaryFailure(lastAttemptAt: Date, graceUntil: Date)
+ case hardFailure(reason: ValidationHardFailureReason)
+}
+
+public enum EntitlementAllowedReason: String, Codable, Equatable, Sendable {
+ case trialActive
+ case licenseValid
+ case offlineGrace
+}
+
+public enum EntitlementDeniedReason: String, Codable, Equatable, Sendable {
+ case trialNotInitialized
+ case trialExpired
+ case noLicense
+ case licenseDeactivated
+ case licenseRevokedOrExpired
+ case licenseMismatch
+ case licenseDisabled
+ case licenseExpired
+ case verificationRequired
+ case graceExpired
+ case hardFailure
+ case invalidLocalRecord
+ case unsupportedFeature
+}
+
+public enum EffectiveEntitlement: Codable, Equatable, Sendable {
+ case proAllowed(reason: EntitlementAllowedReason)
+ case proDenied(reason: EntitlementDeniedReason)
+
+ public var isProAllowed: Bool {
+ switch self {
+ case .proAllowed:
+ return true
+ case .proDenied:
+ return false
+ }
+ }
+}
+
+public struct EntitlementSnapshot: Codable, Equatable, Sendable {
+ public var trialState: TrialState
+ public var licenseState: LicenseState
+ public var validationFreshness: ValidationFreshness
+
+ public init(
+ trialState: TrialState,
+ licenseState: LicenseState,
+ validationFreshness: ValidationFreshness
+ ) {
+ self.trialState = trialState
+ self.licenseState = licenseState
+ self.validationFreshness = validationFreshness
+ }
+}
+
+public protocol ClockProtocol: Sendable {
+ var now: Date { get }
+}
+
+public struct SystemClock: ClockProtocol {
+ public init() {}
+
+ public var now: Date {
+ Date()
+ }
+}
+
+public protocol LicenseStoreProtocol: Sendable {
+ func loadEntitlementSnapshot() throws -> EntitlementSnapshot
+ func saveEntitlementSnapshot(_ snapshot: EntitlementSnapshot) throws
+}
+
+public enum LicenseValidationResult: Equatable, Sendable {
+ case success(lastValidAt: Date, nextCheckAt: Date)
+ case temporaryFailure(lastAttemptAt: Date, graceUntil: Date)
+ case hardFailure(reason: ValidationHardFailureReason)
+}
+
+public protocol LicenseValidationClientProtocol: Sendable {
+ func validateLicense(fingerprint: String, instanceID: String) async throws -> LicenseValidationResult
+}
+
+public protocol EntitlementProviderProtocol: Sendable {
+ func entitlement(for feature: ProFeature, at now: Date) -> EffectiveEntitlement
+}
+
+public enum EntitlementPolicy {
+ public static let defaultTrialDuration: TimeInterval = 30 * 24 * 60 * 60
+ public static let defaultOfflineGraceDuration: TimeInterval = 14 * 24 * 60 * 60
+
+ public static func trialStarted(at startedAt: Date, duration: TimeInterval = defaultTrialDuration) -> TrialState {
+ .active(startedAt: startedAt, expiresAt: startedAt.addingTimeInterval(duration))
+ }
+
+ public static func offlineGrace(
+ lastAttemptAt: Date,
+ duration: TimeInterval = defaultOfflineGraceDuration
+ ) -> ValidationFreshness {
+ .temporaryFailure(lastAttemptAt: lastAttemptAt, graceUntil: lastAttemptAt.addingTimeInterval(duration))
+ }
+
+ public static func evaluate(
+ snapshot: EntitlementSnapshot,
+ feature: ProFeature,
+ now: Date
+ ) -> EffectiveEntitlement {
+ switch feature {
+ case .windowShortcutActivation:
+ break
+ }
+
+ let licenseDecision = decisionForLicense(
+ state: snapshot.licenseState,
+ freshness: snapshot.validationFreshness,
+ now: now
+ )
+ if case .proAllowed = licenseDecision {
+ return licenseDecision
+ }
+
+ let trialDecision = decisionForTrial(state: snapshot.trialState, now: now)
+ if case .proAllowed = trialDecision {
+ return trialDecision
+ }
+
+ if case .none = snapshot.licenseState {
+ if case .notInitialized = snapshot.trialState {
+ return .proDenied(reason: .noLicense)
+ }
+ return trialDecision
+ }
+
+ return licenseDecision
+ }
+
+ private static func decisionForTrial(state: TrialState, now: Date) -> EffectiveEntitlement {
+ switch state {
+ case .notInitialized:
+ return .proDenied(reason: .trialNotInitialized)
+ case let .active(startedAt, expiresAt):
+ guard expiresAt > startedAt else {
+ return .proDenied(reason: .invalidLocalRecord)
+ }
+ guard now >= startedAt else {
+ return .proDenied(reason: .invalidLocalRecord)
+ }
+ guard now < expiresAt else {
+ return .proDenied(reason: .trialExpired)
+ }
+ return .proAllowed(reason: .trialActive)
+ case .expired:
+ return .proDenied(reason: .trialExpired)
+ case .invalidLocalRecord:
+ return .proDenied(reason: .invalidLocalRecord)
+ }
+ }
+
+ private static func decisionForLicense(
+ state: LicenseState,
+ freshness: ValidationFreshness,
+ now: Date
+ ) -> EffectiveEntitlement {
+ switch state {
+ case .none:
+ return .proDenied(reason: .noLicense)
+ case .deactivatedLocal:
+ return .proDenied(reason: .licenseDeactivated)
+ case .revokedOrExpiredRemote:
+ return .proDenied(reason: .licenseRevokedOrExpired)
+ case .mismatch:
+ return .proDenied(reason: .licenseMismatch)
+ case let .activated(_, _, status):
+ switch status {
+ case .active:
+ return decisionForValidationFreshness(freshness, now: now)
+ case .disabled:
+ return .proDenied(reason: .licenseDisabled)
+ case .expired:
+ return .proDenied(reason: .licenseExpired)
+ }
+ }
+ }
+
+ private static func decisionForValidationFreshness(
+ _ freshness: ValidationFreshness,
+ now: Date
+ ) -> EffectiveEntitlement {
+ switch freshness {
+ case .neverChecked:
+ return .proDenied(reason: .verificationRequired)
+ case let .valid(_, nextCheckAt):
+ return now <= nextCheckAt
+ ? .proAllowed(reason: .licenseValid)
+ : .proDenied(reason: .verificationRequired)
+ case let .temporaryFailure(_, graceUntil):
+ return now < graceUntil
+ ? .proAllowed(reason: .offlineGrace)
+ : .proDenied(reason: .graceExpired)
+ case .hardFailure:
+ return .proDenied(reason: .hardFailure)
+ }
+ }
+}
+
+public enum EntitlementFixtureState: String, CaseIterable, Codable, Equatable, Sendable {
+ case trialActive
+ case trialExpired
+ case proUnlocked
+ case offlineGrace
+ case verifyFailed
+}
+
+public struct FixtureEntitlementProvider: EntitlementProviderProtocol {
+ public let snapshot: EntitlementSnapshot
+
+ public init(fixtureState: EntitlementFixtureState, now: Date) {
+ let startedAt = now.addingTimeInterval(-24 * 60 * 60)
+ let expiredAt = now.addingTimeInterval(-24 * 60 * 60)
+ let expiredTrial = TrialState.expired(
+ startedAt: now.addingTimeInterval(-31 * 24 * 60 * 60),
+ expiredAt: expiredAt
+ )
+ let activeLicense = LicenseState.activated(
+ fingerprint: "fixture-fingerprint",
+ instanceID: "fixture-instance",
+ status: .active
+ )
+
+ switch fixtureState {
+ case .trialActive:
+ snapshot = EntitlementSnapshot(
+ trialState: .active(
+ startedAt: startedAt,
+ expiresAt: now.addingTimeInterval(29 * 24 * 60 * 60)
+ ),
+ licenseState: .none,
+ validationFreshness: .neverChecked
+ )
+ case .trialExpired:
+ snapshot = EntitlementSnapshot(
+ trialState: expiredTrial,
+ licenseState: .none,
+ validationFreshness: .neverChecked
+ )
+ case .proUnlocked:
+ snapshot = EntitlementSnapshot(
+ trialState: expiredTrial,
+ licenseState: activeLicense,
+ validationFreshness: .valid(
+ lastValidAt: now,
+ nextCheckAt: now.addingTimeInterval(24 * 60 * 60)
+ )
+ )
+ case .offlineGrace:
+ snapshot = EntitlementSnapshot(
+ trialState: expiredTrial,
+ licenseState: activeLicense,
+ validationFreshness: EntitlementPolicy.offlineGrace(lastAttemptAt: now)
+ )
+ case .verifyFailed:
+ snapshot = EntitlementSnapshot(
+ trialState: expiredTrial,
+ licenseState: activeLicense,
+ validationFreshness: .hardFailure(reason: .serverRejected)
+ )
+ }
+ }
+
+ public func entitlement(for feature: ProFeature, at now: Date) -> EffectiveEntitlement {
+ EntitlementPolicy.evaluate(snapshot: snapshot, feature: feature, now: now)
+ }
+}
diff --git a/C1.source/Sources/AlignerCore/Screenshots/ScreenCaptureKitScreenshotProvider.swift b/C1.source/Sources/AlignerCore/Screenshots/ScreenCaptureKitScreenshotProvider.swift
index 813aa2d..3f53fec 100644
--- a/C1.source/Sources/AlignerCore/Screenshots/ScreenCaptureKitScreenshotProvider.swift
+++ b/C1.source/Sources/AlignerCore/Screenshots/ScreenCaptureKitScreenshotProvider.swift
@@ -256,7 +256,9 @@
configuration.height = max(1, Int(contentInfo.contentRect.height * CGFloat(contentInfo.pointPixelScale)))
configuration.showsCursor = false
configuration.ignoreShadowsSingleWindow = true
- configuration.includeChildWindows = true
+ if #available(macOS 14.2, *) {
+ configuration.includeChildWindows = true
+ }
return configuration
}
diff --git a/C1.source/Sources/AlignerCore/Windows/WindowEnumerationPolicy.swift b/C1.source/Sources/AlignerCore/Windows/WindowEnumerationPolicy.swift
index 16c3e0c..359bc1f 100644
--- a/C1.source/Sources/AlignerCore/Windows/WindowEnumerationPolicy.swift
+++ b/C1.source/Sources/AlignerCore/Windows/WindowEnumerationPolicy.swift
@@ -93,6 +93,24 @@
return record.isMinimized || record.isFullscreen || isLargeEnough(record.size)
}
+ public static func shouldIncludeAXOnlyRecord(
+ identifierSource: AlignerWindow.IdentifierSource,
+ isMinimized: Bool,
+ isFullscreen: Bool,
+ spaceIDs: [UInt64]
+ ) -> Bool {
+ if identifierSource == .syntheticAX {
+ return isMinimized
+ }
+ if isMinimized {
+ return true
+ }
+ if isFullscreen {
+ return !spaceIDs.isEmpty
+ }
+ return false
+ }
+
private static func hasSupportedActivationPolicy(_ record: WindowEnumerationRecord) -> Bool {
record.activationPolicy == .regular
|| (record.activationPolicy == .accessory && isKnownAccessoryMainWindow(record))
diff --git a/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift b/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift
index 6224f6e..7c025f4 100644
--- a/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift
+++ b/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift
@@ -6,7 +6,7 @@
func testAppIdentity() {
XCTAssertEqual(AlignerAppInfo.name, "Aligner")
XCTAssertEqual(AlignerAppInfo.bundleIdentifier, "com.ar.Aligner")
- XCTAssertEqual(AlignerAppInfo.minimumMacOSVersion, "26.0")
+ XCTAssertEqual(AlignerAppInfo.minimumMacOSVersion, "14.0")
}
func testRound0OnlyDefinesQuickSwitchEntryPoint() {
@@ -173,6 +173,33 @@
)
}
+ func testWindowEnumerationPolicyRejectsLowConfidenceAXOnlyRegularWindows() {
+ XCTAssertFalse(WindowEnumerationPolicy.shouldIncludeAXOnlyRecord(
+ identifierSource: .cgWindow,
+ isMinimized: false,
+ isFullscreen: false,
+ spaceIDs: []
+ ))
+ XCTAssertTrue(WindowEnumerationPolicy.shouldIncludeAXOnlyRecord(
+ identifierSource: .cgWindow,
+ isMinimized: true,
+ isFullscreen: false,
+ spaceIDs: []
+ ))
+ XCTAssertTrue(WindowEnumerationPolicy.shouldIncludeAXOnlyRecord(
+ identifierSource: .cgWindow,
+ isMinimized: false,
+ isFullscreen: true,
+ spaceIDs: [42]
+ ))
+ XCTAssertFalse(WindowEnumerationPolicy.shouldIncludeAXOnlyRecord(
+ identifierSource: .syntheticAX,
+ isMinimized: false,
+ isFullscreen: false,
+ spaceIDs: []
+ ))
+ }
+
func testMinimizedWindowFallbackPolicyAvoidsOccupiedSyntheticIDCollisions() {
let windowID = MinimizedWindowFallbackPolicy.syntheticWindowID(
processIdentifier: 42,
diff --git a/C1.source/Tests/AlignerCoreTests/EntitlementPolicyTests.swift b/C1.source/Tests/AlignerCoreTests/EntitlementPolicyTests.swift
new file mode 100644
index 0000000..33c13cb
--- /dev/null
+++ b/C1.source/Tests/AlignerCoreTests/EntitlementPolicyTests.swift
@@ -0,0 +1,203 @@
+import Foundation
+import XCTest
+@testable import AlignerCore
+
+final class EntitlementPolicyTests: XCTestCase {
+ private let now = Date(timeIntervalSince1970: 1_800_000_000)
+
+ func testTrialIsAllowedBeforeThirtyDayBoundary() {
+ let startedAt = now.addingTimeInterval(-29 * 24 * 60 * 60 - 23 * 60 * 60 - 59 * 60)
+ let snapshot = EntitlementSnapshot(
+ trialState: EntitlementPolicy.trialStarted(at: startedAt),
+ licenseState: .none,
+ validationFreshness: .neverChecked
+ )
+
+ XCTAssertEqual(
+ EntitlementPolicy.evaluate(snapshot: snapshot, feature: .windowShortcutActivation, now: now),
+ .proAllowed(reason: .trialActive)
+ )
+ }
+
+ func testTrialExpiresAtThirtyDayBoundary() {
+ let startedAt = now.addingTimeInterval(-EntitlementPolicy.defaultTrialDuration)
+ let snapshot = EntitlementSnapshot(
+ trialState: EntitlementPolicy.trialStarted(at: startedAt),
+ licenseState: .none,
+ validationFreshness: .neverChecked
+ )
+
+ XCTAssertEqual(
+ EntitlementPolicy.evaluate(snapshot: snapshot, feature: .windowShortcutActivation, now: now),
+ .proDenied(reason: .trialExpired)
+ )
+ }
+
+ func testInvalidTrialRecordIsDeniedWhenNoLicenseExists() {
+ let snapshot = EntitlementSnapshot(
+ trialState: .active(startedAt: now, expiresAt: now.addingTimeInterval(-1)),
+ licenseState: .none,
+ validationFreshness: .neverChecked
+ )
+
+ XCTAssertEqual(
+ EntitlementPolicy.evaluate(snapshot: snapshot, feature: .windowShortcutActivation, now: now),
+ .proDenied(reason: .invalidLocalRecord)
+ )
+ }
+
+ func testValidLicenseAllowsProAfterTrialExpired() {
+ let snapshot = EntitlementSnapshot(
+ trialState: expiredTrial(),
+ licenseState: activeLicense(),
+ validationFreshness: .valid(
+ lastValidAt: now.addingTimeInterval(-60),
+ nextCheckAt: now.addingTimeInterval(60)
+ )
+ )
+
+ XCTAssertEqual(
+ EntitlementPolicy.evaluate(snapshot: snapshot, feature: .windowShortcutActivation, now: now),
+ .proAllowed(reason: .licenseValid)
+ )
+ }
+
+ func testOfflineGraceAllowsProBeforeFourteenDayBoundary() {
+ let lastAttemptAt = now.addingTimeInterval(-13 * 24 * 60 * 60 - 23 * 60 * 60 - 59 * 60)
+ let snapshot = EntitlementSnapshot(
+ trialState: expiredTrial(),
+ licenseState: activeLicense(),
+ validationFreshness: EntitlementPolicy.offlineGrace(lastAttemptAt: lastAttemptAt)
+ )
+
+ XCTAssertEqual(
+ EntitlementPolicy.evaluate(snapshot: snapshot, feature: .windowShortcutActivation, now: now),
+ .proAllowed(reason: .offlineGrace)
+ )
+ }
+
+ func testOfflineGraceExpiresAtFourteenDayBoundary() {
+ let lastAttemptAt = now.addingTimeInterval(-EntitlementPolicy.defaultOfflineGraceDuration)
+ let snapshot = EntitlementSnapshot(
+ trialState: expiredTrial(),
+ licenseState: activeLicense(),
+ validationFreshness: EntitlementPolicy.offlineGrace(lastAttemptAt: lastAttemptAt)
+ )
+
+ XCTAssertEqual(
+ EntitlementPolicy.evaluate(snapshot: snapshot, feature: .windowShortcutActivation, now: now),
+ .proDenied(reason: .graceExpired)
+ )
+ }
+
+ func testHardFailureIsDeniedWhenTrialIsExpired() {
+ let snapshot = EntitlementSnapshot(
+ trialState: expiredTrial(),
+ licenseState: activeLicense(),
+ validationFreshness: .hardFailure(reason: .revoked)
+ )
+
+ XCTAssertEqual(
+ EntitlementPolicy.evaluate(snapshot: snapshot, feature: .windowShortcutActivation, now: now),
+ .proDenied(reason: .hardFailure)
+ )
+ }
+
+ func testActiveTrialStillAllowsWhenLicenseVerificationFails() {
+ let snapshot = EntitlementSnapshot(
+ trialState: EntitlementPolicy.trialStarted(at: now.addingTimeInterval(-60)),
+ licenseState: activeLicense(),
+ validationFreshness: .hardFailure(reason: .serverRejected)
+ )
+
+ XCTAssertEqual(
+ EntitlementPolicy.evaluate(snapshot: snapshot, feature: .windowShortcutActivation, now: now),
+ .proAllowed(reason: .trialActive)
+ )
+ }
+
+ func testDeactivatedLicenseIsDeniedAfterTrialExpired() {
+ let snapshot = EntitlementSnapshot(
+ trialState: expiredTrial(),
+ licenseState: .deactivatedLocal,
+ validationFreshness: .neverChecked
+ )
+
+ XCTAssertEqual(
+ EntitlementPolicy.evaluate(snapshot: snapshot, feature: .windowShortcutActivation, now: now),
+ .proDenied(reason: .licenseDeactivated)
+ )
+ }
+
+ func testDisabledLicenseIsDeniedAfterTrialExpired() {
+ let snapshot = EntitlementSnapshot(
+ trialState: expiredTrial(),
+ licenseState: .activated(fingerprint: "abc123", instanceID: "instance-1", status: .disabled),
+ validationFreshness: .valid(
+ lastValidAt: now.addingTimeInterval(-60),
+ nextCheckAt: now.addingTimeInterval(60)
+ )
+ )
+
+ XCTAssertEqual(
+ EntitlementPolicy.evaluate(snapshot: snapshot, feature: .windowShortcutActivation, now: now),
+ .proDenied(reason: .licenseDisabled)
+ )
+ }
+
+ func testProductMismatchIsDeniedAfterTrialExpired() {
+ let snapshot = EntitlementSnapshot(
+ trialState: expiredTrial(),
+ licenseState: .mismatch(reason: .product),
+ validationFreshness: .neverChecked
+ )
+
+ XCTAssertEqual(
+ EntitlementPolicy.evaluate(snapshot: snapshot, feature: .windowShortcutActivation, now: now),
+ .proDenied(reason: .licenseMismatch)
+ )
+ }
+
+ func testRemoteRevokedLicenseIsDeniedAfterTrialExpired() {
+ let snapshot = EntitlementSnapshot(
+ trialState: expiredTrial(),
+ licenseState: .revokedOrExpiredRemote(reason: .revoked),
+ validationFreshness: .neverChecked
+ )
+
+ XCTAssertEqual(
+ EntitlementPolicy.evaluate(snapshot: snapshot, feature: .windowShortcutActivation, now: now),
+ .proDenied(reason: .licenseRevokedOrExpired)
+ )
+ }
+
+ func testFixtureProviderCoversRound015NamedStates() {
+ let expectations: [EntitlementFixtureState: EffectiveEntitlement] = [
+ .trialActive: .proAllowed(reason: .trialActive),
+ .trialExpired: .proDenied(reason: .trialExpired),
+ .proUnlocked: .proAllowed(reason: .licenseValid),
+ .offlineGrace: .proAllowed(reason: .offlineGrace),
+ .verifyFailed: .proDenied(reason: .hardFailure)
+ ]
+
+ for (fixtureState, expectedEntitlement) in expectations {
+ let provider = FixtureEntitlementProvider(fixtureState: fixtureState, now: now)
+ XCTAssertEqual(
+ provider.entitlement(for: .windowShortcutActivation, at: now),
+ expectedEntitlement,
+ "fixture state \(fixtureState.rawValue)"
+ )
+ }
+ }
+
+ private func activeLicense() -> LicenseState {
+ .activated(fingerprint: "abc123", instanceID: "instance-1", status: .active)
+ }
+
+ private func expiredTrial() -> TrialState {
+ .expired(
+ startedAt: now.addingTimeInterval(-31 * 24 * 60 * 60),
+ expiredAt: now.addingTimeInterval(-24 * 60 * 60)
+ )
+ }
+}
diff --git a/C3.tools/generate-release-manifest.sh b/C3.tools/generate-release-manifest.sh
new file mode 100755
index 0000000..4df118c
--- /dev/null
+++ b/C3.tools/generate-release-manifest.sh
@@ -0,0 +1,181 @@
+#!/bin/bash
+# Generate internal and public release manifests from an existing Aligner release
+# directory. This does not upload, notarize, staple, or mark artifacts as public.
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+OUTPUT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+SOURCE_ROOT="$OUTPUT_ROOT/C1.source"
+INFO_PLIST="$SOURCE_ROOT/Resources/Aligner-Info.plist"
+
+fail() {
+ echo "generate-release-manifest.sh failed: $*" >&2
+ exit 1
+}
+
+usage() {
+ cat <<'USAGE' >&2
+Usage:
+ 03-O/C3.tools/generate-release-manifest.sh <release-dir>
+
+Optional environment:
+ ALIGNER_RELEASE_DOWNLOAD_URL
+ ALIGNER_RELEASE_NOTES_URL
+ ALIGNER_RELEASE_NOTARIZED=1
+ ALIGNER_NOTARIZATION_REQUEST_ID
+ ALIGNER_SIGNING_IDENTITY
+ ALIGNER_TEAM_ID
+USAGE
+}
+
+RELEASE_DIR="${1:-}"
+[ -n "$RELEASE_DIR" ] || { usage; exit 2; }
+[ -d "$RELEASE_DIR" ] || fail "release dir does not exist: $RELEASE_DIR"
+
+VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$INFO_PLIST" 2>/dev/null)" \
+ || fail "unable to read CFBundleShortVersionString"
+BUILD="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$INFO_PLIST" 2>/dev/null)" \
+ || fail "unable to read CFBundleVersion"
+MINIMUM_MACOS="$(/usr/libexec/PlistBuddy -c 'Print :LSMinimumSystemVersion' "$INFO_PLIST" 2>/dev/null)" \
+ || fail "unable to read LSMinimumSystemVersion"
+DMG_NAME="Aligner-${VERSION}-build${BUILD}.dmg"
+DMG_PATH="$RELEASE_DIR/$DMG_NAME"
+SUMS_PATH="$RELEASE_DIR/SHA256SUMS.txt"
+INTERNAL_MANIFEST="$RELEASE_DIR/release-manifest.json"
+PUBLIC_MANIFEST="$RELEASE_DIR/public-latest.json"
+
+[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \
+ || fail "version must be MAJOR.MINOR.PATCH, got: $VERSION"
+[[ "$BUILD" =~ ^[0-9]{8}\.[0-9]{4}$ ]] \
+ || fail "build must be YYYYMMDD.HHMM, got: $BUILD"
+[ -f "$DMG_PATH" ] || fail "missing expected DMG: $DMG_PATH"
+[ -f "$SUMS_PATH" ] || fail "missing SHA256SUMS.txt: $SUMS_PATH"
+
+DMG_SHA256="$(awk -v name="$DMG_NAME" '$2 == name { print $1 }' "$SUMS_PATH")"
+[ -n "$DMG_SHA256" ] || fail "SHA256SUMS.txt does not contain $DMG_NAME"
+ACTUAL_SHA="$(shasum -a 256 "$DMG_PATH" | awk '{ print $1 }')"
+[ "$DMG_SHA256" = "$ACTUAL_SHA" ] || fail "SHA256 mismatch for $DMG_NAME"
+DMG_SIZE="$(stat -f%z "$DMG_PATH")"
+
+GIT_BRANCH="$(git -C "$OUTPUT_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
+GIT_COMMIT="$(git -C "$OUTPUT_ROOT" rev-parse HEAD 2>/dev/null || true)"
+EXPECTED_GIT_TAG="v${VERSION}-build${BUILD}"
+ACTUAL_GIT_TAG="$(git -C "$OUTPUT_ROOT" describe --tags --exact-match 2>/dev/null || true)"
+if [ "$ACTUAL_GIT_TAG" = "$EXPECTED_GIT_TAG" ]; then
+ GIT_TAG="$ACTUAL_GIT_TAG"
+else
+ GIT_TAG=""
+fi
+if git -C "$OUTPUT_ROOT" diff --quiet --ignore-submodules -- 2>/dev/null \
+ && git -C "$OUTPUT_ROOT" diff --cached --quiet --ignore-submodules -- 2>/dev/null \
+ && [ -z "$(git -C "$OUTPUT_ROOT" ls-files --others --exclude-standard)" ]; then
+ SOURCE_STATUS="clean"
+else
+ SOURCE_STATUS="dirty"
+fi
+
+CREATED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
+CREATED_BY="$(id -un)"
+NOTARIZED=false
+if [ "${ALIGNER_RELEASE_NOTARIZED:-0}" = "1" ]; then
+ NOTARIZED=true
+fi
+
+/usr/bin/python3 - \
+ "$INTERNAL_MANIFEST" \
+ "$PUBLIC_MANIFEST" \
+ "$VERSION" \
+ "$BUILD" \
+ "$MINIMUM_MACOS" \
+ "$DMG_PATH" \
+ "$DMG_SHA256" \
+ "$DMG_SIZE" \
+ "$GIT_BRANCH" \
+ "$GIT_COMMIT" \
+ "$GIT_TAG" \
+ "$EXPECTED_GIT_TAG" \
+ "$SOURCE_STATUS" \
+ "$CREATED_AT" \
+ "$CREATED_BY" \
+ "$NOTARIZED" <<'PY'
+import json
+import os
+import pathlib
+import sys
+
+(
+ internal_path,
+ public_path,
+ version,
+ build,
+ minimum_macos,
+ dmg_path,
+ sha256,
+ size,
+ git_branch,
+ git_commit,
+ git_tag,
+ expected_git_tag,
+ source_status,
+ created_at,
+ created_by,
+ notarized_text,
+) = sys.argv[1:]
+
+notarized = notarized_text.lower() == "true"
+download_url = os.environ.get("ALIGNER_RELEASE_DOWNLOAD_URL", "")
+release_notes_url = os.environ.get("ALIGNER_RELEASE_NOTES_URL", "")
+
+internal = {
+ "product": "Aligner",
+ "version": version,
+ "build": build,
+ "gitBranch": git_branch,
+ "gitCommit": git_commit,
+ "gitTag": git_tag,
+ "expectedGitTag": expected_git_tag,
+ "sourceStatus": source_status,
+ "buildCommand": "03-O/C3.tools/package-app.sh",
+ "signingIdentity": os.environ.get("ALIGNER_SIGNING_IDENTITY", ""),
+ "teamID": os.environ.get("ALIGNER_TEAM_ID", ""),
+ "notarizationRequestID": os.environ.get("ALIGNER_NOTARIZATION_REQUEST_ID", ""),
+ "notarizationStatus": "passed" if notarized else "not-submitted",
+ "stapleStatus": "not-run",
+ "codesignVerifyStatus": "not-run",
+ "spctlStatus": "not-run",
+ "dmgPath": dmg_path,
+ "dmgSHA256": sha256,
+ "dmgSize": int(size),
+ "qaCommands": [],
+ "qaReports": [],
+ "createdAt": created_at,
+ "createdBy": created_by,
+}
+
+public = {
+ "product": "Aligner",
+ "version": version,
+ "build": build,
+ "minimumMacOS": minimum_macos or "14.0",
+ "architecture": "arm64",
+ "downloadURL": download_url,
+ "sha256": sha256,
+ "size": int(size),
+ "notarized": notarized,
+ "releaseNotesURL": release_notes_url,
+ "publishedAt": created_at if download_url else "",
+}
+
+pathlib.Path(internal_path).write_text(
+ json.dumps(internal, indent=2, ensure_ascii=False, sort_keys=True) + "\n",
+ encoding="utf-8",
+)
+pathlib.Path(public_path).write_text(
+ json.dumps(public, indent=2, ensure_ascii=False, sort_keys=True) + "\n",
+ encoding="utf-8",
+)
+PY
+
+echo "Internal manifest: $INTERNAL_MANIFEST"
+echo "Public manifest: $PUBLIC_MANIFEST"
diff --git a/C3.tools/public-latest-manifest-template.json b/C3.tools/public-latest-manifest-template.json
new file mode 100644
index 0000000..35e9a3c
--- /dev/null
+++ b/C3.tools/public-latest-manifest-template.json
@@ -0,0 +1,13 @@
+{
+ "product": "Aligner",
+ "version": "0.0.0",
+ "build": "YYYYMMDD.HHMM",
+ "minimumMacOS": "14.0",
+ "architecture": "arm64",
+ "downloadURL": "",
+ "sha256": "",
+ "size": 0,
+ "notarized": false,
+ "releaseNotesURL": "",
+ "publishedAt": ""
+}
diff --git a/C3.tools/release-app.sh b/C3.tools/release-app.sh
new file mode 100755
index 0000000..21ca174
--- /dev/null
+++ b/C3.tools/release-app.sh
@@ -0,0 +1,132 @@
+#!/bin/bash
+# Formal direct-download release guardrail for Aligner.
+# This script is intentionally separate from package-app.sh, which remains the
+# local QA package entry and may use ad-hoc or local-only signing.
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+OUTPUT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+SOURCE_ROOT="$OUTPUT_ROOT/C1.source"
+INFO_PLIST="$SOURCE_ROOT/Resources/Aligner-Info.plist"
+
+fail() {
+ echo "release-app.sh failed: $*" >&2
+ exit 1
+}
+
+usage() {
+ cat <<'USAGE'
+Usage:
+ 03-O/C3.tools/release-app.sh --check-tools
+ 03-O/C3.tools/release-app.sh --check-config
+ 03-O/C3.tools/release-app.sh --check-notary-profile
+
+Required environment for a real public release:
+ ALIGNER_DEVELOPER_IDENTITY="Developer ID Application: ..."
+ ALIGNER_NOTARY_PROFILE="<notarytool keychain profile>"
+ ALIGNER_RELEASE_GITHUB_REPOSITORY="<owner/repo>"
+
+Phase 1 currently establishes release-chain guardrails only. It refuses to
+produce a public release until Developer ID signing, notarization, stapling,
+Gatekeeper validation, manifest generation, and upload policy are all wired.
+USAGE
+}
+
+read_plist_value() {
+ /usr/libexec/PlistBuddy -c "Print :$1" "$INFO_PLIST" 2>/dev/null \
+ || fail "unable to read $1 from $INFO_PLIST"
+}
+
+assert_tool() {
+ command -v "$1" >/dev/null 2>&1 || fail "missing required tool: $1"
+}
+
+assert_xcrun_tool() {
+ xcrun --find "$1" >/dev/null 2>&1 || fail "missing required xcrun tool: $1"
+}
+
+assert_tools() {
+ assert_tool codesign
+ assert_tool hdiutil
+ assert_tool shasum
+ assert_tool spctl
+ assert_tool xcrun
+ assert_xcrun_tool notarytool
+ assert_xcrun_tool stapler
+
+ echo "Release tools OK"
+}
+
+assert_config() {
+ local version
+ local build
+ local minimum_macos
+
+ assert_tools
+
+ version="$(read_plist_value CFBundleShortVersionString)"
+ build="$(read_plist_value CFBundleVersion)"
+ minimum_macos="$(read_plist_value LSMinimumSystemVersion)"
+
+ [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \
+ || fail "CFBundleShortVersionString must be MAJOR.MINOR.PATCH, got: $version"
+ [[ "$build" =~ ^[0-9]{8}\.[0-9]{4}$ ]] \
+ || fail "CFBundleVersion must be YYYYMMDD.HHMM, got: $build"
+ [[ "$minimum_macos" =~ ^[0-9]+(\.[0-9]+)?$ ]] \
+ || fail "LSMinimumSystemVersion must be numeric, got: $minimum_macos"
+ [ "$minimum_macos" = "14.0" ] \
+ || fail "LSMinimumSystemVersion must be 14.0 for Round01.5 macOS 14 compatibility, got: $minimum_macos"
+
+ if [ -z "${ALIGNER_DEVELOPER_IDENTITY:-}" ]; then
+ fail "missing ALIGNER_DEVELOPER_IDENTITY; formal releases must use Developer ID Application signing"
+ fi
+ if [[ "${ALIGNER_DEVELOPER_IDENTITY:-}" != Developer\ ID\ Application:* ]]; then
+ fail "ALIGNER_DEVELOPER_IDENTITY must start with 'Developer ID Application:'"
+ fi
+ if [ -z "${ALIGNER_NOTARY_PROFILE:-}" ]; then
+ fail "missing ALIGNER_NOTARY_PROFILE; formal releases must use notarytool"
+ fi
+ if [ -z "${ALIGNER_RELEASE_GITHUB_REPOSITORY:-}" ]; then
+ fail "missing ALIGNER_RELEASE_GITHUB_REPOSITORY; expected owner/repo for GitHub Releases"
+ fi
+ if [[ "${ALIGNER_RELEASE_GITHUB_REPOSITORY:-}" != */* ]]; then
+ fail "ALIGNER_RELEASE_GITHUB_REPOSITORY must use owner/repo format"
+ fi
+
+ echo "Release config OK for Aligner $version build $build, minimum macOS $minimum_macos"
+}
+
+assert_notary_profile() {
+ assert_config
+
+ xcrun notarytool history \
+ --keychain-profile "$ALIGNER_NOTARY_PROFILE" \
+ --output-format json >/dev/null 2>&1 \
+ || fail "notarytool profile is not usable: $ALIGNER_NOTARY_PROFILE"
+
+ echo "Notary profile OK: $ALIGNER_NOTARY_PROFILE"
+}
+
+case "${1:-}" in
+ --check-tools)
+ assert_tools
+ ;;
+ --check-config)
+ assert_config
+ ;;
+ --check-notary-profile)
+ assert_notary_profile
+ ;;
+ -h|--help)
+ usage
+ ;;
+ "")
+ assert_config
+ fail "formal release packaging is not enabled in Phase 1 skeleton yet"
+ ;;
+ *)
+ usage
+ fail "unknown argument: $1"
+ ;;
+esac
diff --git a/C3.tools/release-manifest-template.json b/C3.tools/release-manifest-template.json
new file mode 100644
index 0000000..507c652
--- /dev/null
+++ b/C3.tools/release-manifest-template.json
@@ -0,0 +1,25 @@
+{
+ "product": "Aligner",
+ "version": "0.0.0",
+ "build": "YYYYMMDD.HHMM",
+ "gitBranch": "",
+ "gitCommit": "",
+ "gitTag": "",
+ "expectedGitTag": "v0.0.0-buildYYYYMMDD.HHMM",
+ "sourceStatus": "unknown",
+ "buildCommand": "03-O/C3.tools/release-app.sh",
+ "signingIdentity": "Developer ID Application: <Name> (<TEAMID>)",
+ "teamID": "",
+ "notarizationRequestID": "",
+ "notarizationStatus": "not-submitted",
+ "stapleStatus": "not-run",
+ "codesignVerifyStatus": "not-run",
+ "spctlStatus": "not-run",
+ "dmgPath": "",
+ "dmgSHA256": "",
+ "dmgSize": 0,
+ "qaCommands": [],
+ "qaReports": [],
+ "createdAt": "",
+ "createdBy": ""
+}
diff --git a/C3.tools/round015-entitlement-fixture-qa.sh b/C3.tools/round015-entitlement-fixture-qa.sh
new file mode 100755
index 0000000..356abef
--- /dev/null
+++ b/C3.tools/round015-entitlement-fixture-qa.sh
@@ -0,0 +1,299 @@
+#!/bin/bash
+# Round01.5 Phase 1/2 local QA:
+# - Entitlement model unit tests
+# - App target build and authorization report field smoke test
+# - Fixture state coverage
+# - Release manifest template validity
+# - Formal release script refuses missing Developer ID / notarization config
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+OUTPUT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+SOURCE_ROOT="$OUTPUT_ROOT/C1.source"
+# shellcheck source=build-output-paths.sh
+source "$SCRIPT_DIR/build-output-paths.sh"
+
+APP="$BUILD_CURRENT_APP"
+REPORT="$BUILD_REPORT_ROOT/round015-entitlement-fixture-qa.json"
+AUTHORIZATION_REPORT="$BUILD_REPORT_ROOT/round015-authorization-report.json"
+SWIFT_TEST_LOG="$BUILD_REPORT_ROOT/round015-entitlement-swift-test.log"
+SWIFT_BUILD_LOG="$BUILD_REPORT_ROOT/round015-swift-build.log"
+RELEASE_MISSING_LOG="$BUILD_REPORT_ROOT/round015-release-missing-config.log"
+RELEASE_CONFIG_LOG="$BUILD_REPORT_ROOT/round015-release-config.log"
+MANIFEST_LOG="$BUILD_REPORT_ROOT/round015-generate-release-manifest.log"
+REPORT_WAIT="${ALIGNER_ROUND015_REPORT_WAIT:-12.0}"
+APP_PID=""
+
+fail() {
+ echo "round015-entitlement-fixture-qa.sh failed: $*" >&2
+ exit 1
+}
+
+aligner_pids_for_current_app() {
+ ps -axo pid=,args= | while read -r pid command; do
+ if [[ "$command" == "$APP/Contents/MacOS/Aligner"* ]]; then
+ echo "$pid"
+ fi
+ done
+}
+
+stop_current_aligner() {
+ for pid in $(aligner_pids_for_current_app); do
+ kill "$pid" 2>/dev/null || true
+ done
+
+ for _ in {1..30}; do
+ [ -z "$(aligner_pids_for_current_app)" ] && return
+ sleep 0.1
+ done
+
+ fail "current Aligner app did not exit before QA"
+}
+
+cleanup() {
+ if [ -n "${APP_PID:-}" ]; then
+ kill "$APP_PID" 2>/dev/null || true
+ wait "$APP_PID" 2>/dev/null || true
+ APP_PID=""
+ fi
+ stop_current_aligner
+}
+
+trap cleanup EXIT
+
+run_swift_tests() {
+ (
+ cd "$SOURCE_ROOT"
+ swift test --filter EntitlementPolicyTests
+ ) >"$SWIFT_TEST_LOG" 2>&1 || {
+ head -c 6000 "$SWIFT_TEST_LOG" >&2
+ fail "EntitlementPolicyTests failed"
+ }
+}
+
+run_app_build() {
+ (
+ cd "$SOURCE_ROOT"
+ swift build -c debug
+ ) >"$SWIFT_BUILD_LOG" 2>&1 || {
+ head -c 6000 "$SWIFT_BUILD_LOG" >&2
+ fail "swift build failed"
+ }
+}
+
+validate_manifest_templates() {
+ /usr/bin/python3 -m json.tool "$SCRIPT_DIR/release-manifest-template.json" >/dev/null
+ /usr/bin/python3 -m json.tool "$SCRIPT_DIR/public-latest-manifest-template.json" >/dev/null
+}
+
+validate_release_guardrails() {
+ if (
+ unset ALIGNER_DEVELOPER_IDENTITY ALIGNER_NOTARY_PROFILE ALIGNER_RELEASE_GITHUB_REPOSITORY
+ "$SCRIPT_DIR/release-app.sh" --check-config
+ ) >"$RELEASE_MISSING_LOG" 2>&1; then
+ fail "release-app.sh must reject missing Developer ID config"
+ fi
+
+ rg -q "ALIGNER_DEVELOPER_IDENTITY" "$RELEASE_MISSING_LOG" \
+ || fail "release missing-config log must mention ALIGNER_DEVELOPER_IDENTITY"
+
+ ALIGNER_DEVELOPER_IDENTITY="Developer ID Application: Fixture (TEAMID)" \
+ ALIGNER_NOTARY_PROFILE="fixture-profile" \
+ ALIGNER_RELEASE_GITHUB_REPOSITORY="fixture/aligner" \
+ "$SCRIPT_DIR/release-app.sh" --check-config >"$RELEASE_CONFIG_LOG" 2>&1 \
+ || {
+ head -c 6000 "$RELEASE_CONFIG_LOG" >&2
+ fail "release-app.sh --check-config should accept complete fixture config"
+ }
+}
+
+release_dir_for_current_version() {
+ local version
+ local build
+
+ version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$SOURCE_ROOT/Resources/Aligner-Info.plist" 2>/dev/null)" \
+ || fail "unable to read CFBundleShortVersionString"
+ build="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$SOURCE_ROOT/Resources/Aligner-Info.plist" 2>/dev/null)" \
+ || fail "unable to read CFBundleVersion"
+ printf '%s/Aligner-%s-build%s\n' "$BUILD_ROOT" "$version" "$build"
+}
+
+validate_manifest_generation() {
+ local release_dir
+ release_dir="$(release_dir_for_current_version)"
+
+ "$SCRIPT_DIR/generate-release-manifest.sh" "$release_dir" >"$MANIFEST_LOG" 2>&1 || {
+ head -c 6000 "$MANIFEST_LOG" >&2
+ fail "generate-release-manifest.sh failed"
+ }
+
+ /usr/bin/python3 - "$release_dir/release-manifest.json" "$release_dir/public-latest.json" <<'PY'
+import json
+import pathlib
+import sys
+
+internal_path = pathlib.Path(sys.argv[1])
+public_path = pathlib.Path(sys.argv[2])
+internal = json.loads(internal_path.read_text(encoding="utf-8"))
+public = json.loads(public_path.read_text(encoding="utf-8"))
+
+def fail(message):
+ print(json.dumps({"internal": internal, "public": public}, indent=2, ensure_ascii=False), file=sys.stderr)
+ print(message, file=sys.stderr)
+ sys.exit(1)
+
+if internal.get("product") != "Aligner" or public.get("product") != "Aligner":
+ fail("manifest product must be Aligner")
+if internal.get("version") != public.get("version"):
+ fail("internal/public version must match")
+if internal.get("build") != public.get("build"):
+ fail("internal/public build must match")
+expected_tag = f"v{internal.get('version')}-build{internal.get('build')}"
+if internal.get("expectedGitTag") != expected_tag:
+ fail("internal expectedGitTag must match version/build")
+if internal.get("gitTag") not in ("", expected_tag):
+ fail("internal gitTag must be empty or match expectedGitTag")
+if internal.get("dmgSHA256") != public.get("sha256"):
+ fail("internal/public SHA must match")
+if public.get("notarized") is not False:
+ fail("public manifest must not claim notarized without explicit release env")
+if public.get("downloadURL") != "":
+ fail("public manifest must not invent a download URL")
+if "license" in json.dumps(internal, ensure_ascii=False).lower():
+ fail("release manifest must not contain license material")
+PY
+}
+
+wait_for_authorization_report() {
+ /usr/bin/python3 - "$AUTHORIZATION_REPORT" "$REPORT_WAIT" <<'PY'
+import json
+import sys
+import time
+
+path = sys.argv[1]
+timeout = float(sys.argv[2])
+deadline = time.time() + timeout
+last_report = None
+
+while time.time() < deadline:
+ try:
+ with open(path, "r", encoding="utf-8") as file:
+ report = json.load(file)
+ last_report = report
+ auth = report.get("authorization", {})
+ if auth.get("fixtureState") == "trialActive":
+ sys.exit(0)
+ except FileNotFoundError:
+ pass
+ except json.JSONDecodeError:
+ pass
+ time.sleep(0.1)
+
+if last_report is not None:
+ print(json.dumps(last_report, indent=2, ensure_ascii=False), file=sys.stderr)
+print("authorization report did not become ready", file=sys.stderr)
+sys.exit(1)
+PY
+}
+
+assert_authorization_report() {
+ /usr/bin/python3 - "$AUTHORIZATION_REPORT" <<'PY'
+import json
+import sys
+
+with open(sys.argv[1], "r", encoding="utf-8") as file:
+ report = json.load(file)
+
+def fail(message):
+ print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
+ print(message, file=sys.stderr)
+ sys.exit(1)
+
+auth = report.get("authorization")
+if not isinstance(auth, dict):
+ fail("authorization report must be an object")
+if auth.get("fixtureState") != "trialActive":
+ fail("authorization.fixtureState must be trialActive")
+if auth.get("state") != "trialActive":
+ fail("authorization.state must be trialActive")
+if auth.get("proFeature") != "windowShortcutActivation":
+ fail("authorization.proFeature must be windowShortcutActivation")
+if auth.get("proFeatureUnlocked") is not True:
+ fail("trialActive must unlock the Pro feature in report")
+if auth.get("licenseKeyPresent") is not False:
+ fail("trialActive fixture without debug key must not report licenseKeyPresent")
+if "fixture-valid-key" in json.dumps(report, ensure_ascii=False):
+ fail("report must not contain fixture plaintext license material")
+PY
+}
+
+run_authorization_report_smoke() {
+ "$SCRIPT_DIR/package-app.sh" >/dev/null
+ stop_current_aligner
+ rm -f "$AUTHORIZATION_REPORT"
+
+ "$APP/Contents/MacOS/Aligner" \
+ --round0-skip-permissions \
+ --round01-open-quick-switch \
+ --round01-fixture-app-count=1 \
+ --round01-fixture-windows-per-app=1 \
+ --round01-disable-screenshot-refresh \
+ --round01-quick-switch-report="$AUTHORIZATION_REPORT" \
+ --round015-license-state=trialActive \
+ --round015-now=2026-06-24T00:00:00Z &
+
+ APP_PID=$!
+ wait_for_authorization_report
+ assert_authorization_report
+
+ kill "$APP_PID" 2>/dev/null || true
+ wait "$APP_PID" 2>/dev/null || true
+ APP_PID=""
+}
+
+write_report() {
+ /usr/bin/python3 - "$REPORT" "$SWIFT_TEST_LOG" "$SWIFT_BUILD_LOG" "$AUTHORIZATION_REPORT" "$RELEASE_MISSING_LOG" "$RELEASE_CONFIG_LOG" <<'PY'
+import json
+import os
+import pathlib
+import sys
+from datetime import datetime, timezone
+
+report = {
+ "qa": "round015-entitlement-fixture",
+ "createdAt": datetime.now(timezone.utc).isoformat(),
+ "checks": {
+ "entitlementPolicyTests": "passed",
+ "swiftBuild": "passed",
+ "authorizationReportFixture": "passed",
+ "releaseManifestTemplates": "passed",
+ "releaseManifestGeneration": "passed",
+ "releaseGuardRejectsMissingDeveloperID": "passed",
+ "releaseGuardAcceptsCompleteFixtureConfig": "passed",
+ "sensitiveLicenseMaterialInReport": "absent",
+ },
+ "logs": {
+ "swiftTest": sys.argv[2],
+ "swiftBuild": sys.argv[3],
+ "authorizationReport": sys.argv[4],
+ "releaseMissingConfig": sys.argv[5],
+ "releaseConfig": sys.argv[6],
+ "manifestGeneration": os.environ.get("ROUND015_MANIFEST_LOG", ""),
+ },
+}
+pathlib.Path(sys.argv[1]).write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n")
+PY
+}
+
+run_swift_tests
+run_app_build
+validate_manifest_templates
+validate_release_guardrails
+run_authorization_report_smoke
+validate_manifest_generation
+ROUND015_MANIFEST_LOG="$MANIFEST_LOG" \
+write_report
+
+echo "Round01.5 entitlement fixture QA passed"
+echo "Report: $REPORT"
diff --git a/C3.tools/round015-macos14-compatibility-qa.sh b/C3.tools/round015-macos14-compatibility-qa.sh
new file mode 100755
index 0000000..cfe91e6
--- /dev/null
+++ b/C3.tools/round015-macos14-compatibility-qa.sh
@@ -0,0 +1,86 @@
+#!/bin/bash
+# Verify Aligner still builds and tests with macOS 14.0 as the deployment target.
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+OUTPUT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+SOURCE_ROOT="$OUTPUT_ROOT/C1.source"
+REPORT_ROOT="$OUTPUT_ROOT/C2.builds/Z-研发中间产物/qa-reports"
+REPORT="$REPORT_ROOT/round015-macos14-compatibility-qa.json"
+TMP_ROOT=""
+
+cleanup() {
+ if [ -n "$TMP_ROOT" ]; then
+ rm -rf "$TMP_ROOT"
+ fi
+}
+trap cleanup EXIT
+
+fail() {
+ local message="$1"
+ mkdir -p "$REPORT_ROOT"
+ cat > "$REPORT" <<JSON
+{
+ "qa": "round015-macos14-compatibility",
+ "status": "failed",
+ "minimumMacOS": "14.0",
+ "message": "$message"
+}
+JSON
+ echo "FAIL: $message" >&2
+ exit 1
+}
+
+run_step() {
+ local name="$1"
+ shift
+ echo "==> $name"
+ "$@"
+}
+
+TMP_ROOT="$(mktemp -d /tmp/aligner-macos14-compatibility.XXXXXX)"
+rsync -a --exclude .build --exclude .swiftpm "$SOURCE_ROOT/" "$TMP_ROOT/C1.source/"
+
+run_step "swift-build-macos14" swift build --package-path "$TMP_ROOT/C1.source"
+run_step "swift-test-macos14" swift test --package-path "$TMP_ROOT/C1.source"
+
+PACKAGE_MINIMUM="$(/usr/bin/grep -F '.macOS("14.0")' "$SOURCE_ROOT/Package.swift" || true)"
+PLIST_MINIMUM="$(/usr/libexec/PlistBuddy -c 'Print :LSMinimumSystemVersion' "$SOURCE_ROOT/Resources/Aligner-Info.plist" 2>/dev/null || true)"
+APP_INFO_MINIMUM="$(/usr/bin/grep -F 'minimumMacOSVersion = "14.0"' "$SOURCE_ROOT/Sources/AlignerCore/AppInfo.swift" || true)"
+PUBLIC_TEMPLATE_MINIMUM="$(/usr/bin/grep -F '"minimumMacOS": "14.0"' "$OUTPUT_ROOT/C3.tools/public-latest-manifest-template.json" || true)"
+
+if [ -z "$PACKAGE_MINIMUM" ]; then
+ fail "Package.swift does not declare macOS 14.0"
+fi
+
+if [ "$PLIST_MINIMUM" != "14.0" ]; then
+ fail "Info.plist LSMinimumSystemVersion is $PLIST_MINIMUM, expected 14.0"
+fi
+
+if [ -z "$APP_INFO_MINIMUM" ]; then
+ fail "AlignerAppInfo.minimumMacOSVersion is not 14.0"
+fi
+
+if [ -z "$PUBLIC_TEMPLATE_MINIMUM" ]; then
+ fail "public latest manifest template minimumMacOS is not 14.0"
+fi
+
+mkdir -p "$REPORT_ROOT"
+cat > "$REPORT" <<JSON
+{
+ "qa": "round015-macos14-compatibility",
+ "status": "passed",
+ "minimumMacOS": "14.0",
+ "checks": [
+ "Package.swift declares macOS 14.0",
+ "Info.plist LSMinimumSystemVersion is 14.0",
+ "AlignerAppInfo.minimumMacOSVersion is 14.0",
+ "public latest manifest template minimumMacOS is 14.0",
+ "swift build passes from clean temporary source copy",
+ "swift test passes from clean temporary source copy"
+ ]
+}
+JSON
+
+echo "PASS: $REPORT"
diff --git a/C3.tools/round1-app-shelf-fixture-qa.sh b/C3.tools/round1-app-shelf-fixture-qa.sh
index 2cb4cdb..59281ed 100755
--- a/C3.tools/round1-app-shelf-fixture-qa.sh
+++ b/C3.tools/round1-app-shelf-fixture-qa.sh
@@ -10,14 +10,22 @@
# shellcheck source=build-output-paths.sh
source "$SCRIPT_DIR/build-output-paths.sh"
APP="$BUILD_CURRENT_APP"
+DOMAIN="com.ar.Aligner"
+THEME_KEY="com.ar.Aligner.preferences.appearance.theme"
REPORT="$BUILD_REPORT_ROOT/round01-app-shelf-fixture-report.json"
NARROW_REPORT="$BUILD_REPORT_ROOT/round01-app-shelf-narrow-fixture-report.json"
+DARK_REPORT="$BUILD_REPORT_ROOT/round01-app-shelf-dark-fixture-report.json"
+HORIZONTAL_REPORT="$BUILD_REPORT_ROOT/round01-app-shelf-horizontal-fixture-report.json"
+PERSIST_VERTICAL_REPORT="$BUILD_REPORT_ROOT/round01-app-shelf-hover-persist-vertical-report.json"
+PERSIST_HORIZONTAL_REPORT="$BUILD_REPORT_ROOT/round01-app-shelf-hover-persist-horizontal-report.json"
FIXTURE_APP_COUNT="${ALIGNER_ROUND1_APP_SHELF_FIXTURE_COUNT:-60}"
HOVER_INDEX="${ALIGNER_ROUND1_APP_SHELF_HOVER_INDEX:-5}"
NARROW_FIXTURE_APP_COUNT="${ALIGNER_ROUND1_APP_SHELF_NARROW_FIXTURE_COUNT:-10}"
NARROW_HOVER_INDEX="${ALIGNER_ROUND1_APP_SHELF_NARROW_HOVER_INDEX:-5}"
NARROW_OVERLAY_WIDTH="${ALIGNER_ROUND1_APP_SHELF_NARROW_OVERLAY_WIDTH:-780}"
REPORT_WAIT="${ALIGNER_ROUND1_APP_SHELF_REPORT_WAIT:-6.0}"
+OLD_THEME_SET=0
+OLD_THEME=""
fail() {
echo "Round01 App Shelf fixture QA failed: $*" >&2
@@ -43,6 +51,23 @@
done
fail "current Aligner app did not exit before QA"
+}
+
+preserve_user_theme() {
+ if OLD_THEME="$(/usr/bin/defaults read "$DOMAIN" "$THEME_KEY" 2>/dev/null)"; then
+ OLD_THEME_SET=1
+ else
+ OLD_THEME_SET=0
+ OLD_THEME=""
+ fi
+}
+
+restore_user_theme() {
+ if [ "$OLD_THEME_SET" -eq 1 ]; then
+ /usr/bin/defaults write "$DOMAIN" "$THEME_KEY" -string "$OLD_THEME"
+ else
+ /usr/bin/defaults delete "$DOMAIN" "$THEME_KEY" >/dev/null 2>&1 || true
+ fi
}
wait_for_loaded_report() {
@@ -84,7 +109,9 @@
local expected_rows="$4"
local expected_icon_size="$5"
local expected_scrollable="$6"
- /usr/bin/python3 - "$report" "$fixture_app_count" "$hover_index" "$expected_rows" "$expected_icon_size" "$expected_scrollable" <<'PY'
+ local expected_theme="$7"
+ local expected_mode="$8"
+ /usr/bin/python3 - "$report" "$fixture_app_count" "$hover_index" "$expected_rows" "$expected_icon_size" "$expected_scrollable" "$expected_theme" "$expected_mode" <<'PY'
import json
import sys
@@ -94,6 +121,8 @@
expected_rows = int(sys.argv[4])
expected_icon_size = int(sys.argv[5])
expected_scrollable = sys.argv[6].lower() == "true"
+expected_theme = sys.argv[7]
+expected_mode = sys.argv[8]
with open(path, "r", encoding="utf-8") as file:
report = json.load(file)
@@ -117,7 +146,9 @@
segments = root.get("spaceLaneSegments", [])
require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
+require(report.get("resolvedQuickSwitchTheme") == expected_theme, "fixture Quick Switch theme must match the deterministic QA theme")
require(report.get("appCount") == fixture_app_count, "fixture appCount must match requested count")
+require(root.get("waterfallViewMode") == expected_mode, "fixture Waterfall mode must match expected mode")
require(root.get("appShelfItemCount") == fixture_app_count, "App Shelf item count must match fixture count")
require(len(items) == fixture_app_count, "App Shelf item reports must match fixture count")
require(root.get("appShelfRows") == expected_rows, "fixture App Shelf row count must match expected rows")
@@ -126,6 +157,12 @@
require(root.get("appShelfMinIconSize") == 36, "App Shelf min icon size must be 36pt")
require(root.get("appShelfSelectedIconSize") == 68, "App Shelf selected icon size must be 68pt")
require(root.get("appShelfHoverIconSize") == 68, "App Shelf hover icon size must be 68pt")
+require(root.get("appShelfMagneticFirstNeighborInfluence") == 0, "App Shelf first neighbor must not visually follow hovered App")
+require(root.get("appShelfMagneticSecondNeighborInfluence") == 0, "App Shelf second neighbor must not visually follow hovered App")
+require(root.get("appShelfMagneticHoverLift") == 4, "App Shelf magnetic hover lift must be 4pt")
+require(root.get("appShelfMagneticFirstNeighborLift") == 0, "App Shelf first neighbor lift must be disabled")
+require(root.get("appShelfMagneticSecondNeighborLift") == 0, "App Shelf second neighbor lift must be disabled")
+require(root.get("appShelfMagneticAnimationDurationMilliseconds") == 160, "App Shelf magnetic animation duration must stay short and native-feeling")
require(root.get("appShelfScrollable") is expected_scrollable, "fixture App Shelf scrollable state must match expected")
if expected_scrollable:
require(root.get("appShelfContentWidth", 0) > root.get("appShelfVisibleWidth", 0), "scrollable fixture App Shelf content must overflow visible width")
@@ -135,17 +172,58 @@
require(root.get("appShelfMaxScrollOffset", 0) == 0, "non-scrollable fixture App Shelf must expose zero max scroll offset")
require(root.get("appShelfHoveredIndex") == hover_index, "fixture App Shelf must expose requested hovered index")
require(len(columns) == fixture_app_count, "Waterfall column reports must match fixture count")
-require(sum(1 for item in items if item.get("isSelected") is True) == 1, "App Shelf must expose one selected app")
+selected_indexes = [index for index, item in enumerate(items) if item.get("isSelected") is True]
+require(len(selected_indexes) == 1, "App Shelf must expose one selected app")
+selected_index = selected_indexes[0]
require(sum(1 for item in items if item.get("isHovered") is True) == 1, "App Shelf must expose one hovered app")
require(sum(1 for column in columns if column.get("isHovered") is True) == 1, "Waterfall must expose one hovered column")
-require(items[0].get("isSelected") is True, "fixture first app should be selected")
require(items[hover_index].get("isHovered") is True, "fixture requested app should be hovered")
require(columns[hover_index].get("isHovered") is True, "fixture requested app column should be hovered")
-require("selectedVisualSuppressed" in items[0].get("visualStates", []), "non-hovered selected app must suppress selected visual state")
+if selected_index != hover_index:
+ require("selectedVisualSuppressed" in items[selected_index].get("visualStates", []), "non-hovered selected app must suppress selected visual state")
require("hover" in items[hover_index].get("visualStates", []), "hovered app must expose hover visual state")
require("hover" in columns[hover_index].get("visualStates", []), "hovered column must expose hover visual state")
-require(items[0].get("iconFrame", {}).get("width") == expected_icon_size, "non-hovered selected app icon must match normal fixture icon size")
+if selected_index != hover_index:
+ require(items[selected_index].get("iconFrame", {}).get("width") == expected_icon_size, "non-hovered selected app icon must match normal fixture icon size")
require(items[hover_index].get("iconFrame", {}).get("width") == 68, "hovered app icon must be 68pt")
+require(items[hover_index].get("magneticInfluence") == 1, "hovered app must expose full magnetic influence")
+require(items[hover_index].get("magneticLift") == 4, "hovered app must lift by 4pt")
+require(items[hover_index].get("backgroundColor", {}).get("alpha", 1) == 0, "hovered App icon must not draw a blue background rectangle")
+require(items[hover_index].get("backgroundBorderWidth", -1) == 0, "hovered App icon background border must be removed")
+require(items[hover_index].get("backgroundShadowOpacity", -1) == 0, "hovered App icon background shadow must be removed")
+require(items[hover_index].get("containerBorderWidth", -1) == 0, "hovered App item container border must be removed")
+require(items[hover_index].get("iconBorderWidth", -1) == 0, "hovered App icon layer border must be removed")
+require(all(item.get("backgroundBorderWidth", -1) == 0 for item in items), "App Shelf background borders must be removed in every view mode")
+require(all(item.get("containerBorderWidth", -1) == 0 for item in items), "App Shelf container borders must be removed in every view mode")
+require(all(item.get("iconBorderWidth", -1) == 0 for item in items), "App Shelf icon borders must be removed in every view mode")
+require(all(item.get("backgroundColor", {}).get("alpha", 0) == 0 for item in items if item.get("isSpaceFocused") is not True), "App Shelf non-space-focused items must not draw blue background rectangles")
+
+magnetic_enabled = root.get("appShelfMagneticHoverEnabled") is True
+neighbor_indexes = {
+ index
+ for index in (hover_index - 2, hover_index - 1, hover_index + 1, hover_index + 2)
+ if 0 <= index < len(items)
+}
+first_neighbor_indexes = {
+ index
+ for index in (hover_index - 1, hover_index + 1)
+ if 0 <= index < len(items)
+}
+second_neighbor_indexes = {
+ index
+ for index in (hover_index - 2, hover_index + 2)
+ if 0 <= index < len(items)
+}
+
+require(neighbor_indexes, "fixture must include App Shelf neighbors")
+require(all(items[index].get("iconFrame", {}).get("width") == expected_icon_size for index in neighbor_indexes), "neighbor App icons must not grow or visually follow hover")
+require(all(items[index].get("iconFrame", {}).get("height") == expected_icon_size for index in neighbor_indexes), "neighbor App icons must keep normal height")
+require(all(items[index].get("magneticInfluence") == 0 for index in neighbor_indexes), "neighbor App icons must expose zero magnetic influence")
+require(all(items[index].get("magneticLift") == 0 for index in neighbor_indexes), "neighbor App icons must expose zero lift")
+require(all(items[index].get("isHovered") is False for index in neighbor_indexes), "neighbors must not gain hover semantics")
+require(all("hover" not in items[index].get("visualStates", []) for index in neighbor_indexes), "neighbors must not expose hover visual state")
+require(all("magneticNeighbor" not in items[index].get("visualStates", []) for index in neighbor_indexes), "neighbors must not expose magnetic neighbor state")
+require(all(items[index].get("closeButtonVisible") is False for index in neighbor_indexes), "neighbors must not show App close buttons")
legacy_blue_reference = {"red": 135 / 255, "green": 200 / 255, "blue": 1.0, "alpha": 1.0}
hovered_title_color = columns[hover_index].get("appNameColor", {})
@@ -156,11 +234,16 @@
return all(abs(color.get(channel, -1) - legacy_blue_reference[channel]) <= 0.01 for channel in ("red", "green", "blue"))
hovered_header_channels = [hovered_header_color.get(channel, -1) for channel in ("red", "green", "blue")]
-require(not is_legacy_blue(hovered_header_color), "hovered column header must no longer use the previous blue fill")
-require(max(hovered_header_channels) - min(hovered_header_channels) <= 0.035, "hovered column header must use a neutral gray fill")
-require(0.16 <= hovered_header_color.get("alpha", 0) <= 0.30, "hovered column header gray fill must stay lightweight")
-require(hovered_title_color.get("alpha", 0) >= 0.8, "hovered column title must remain readable")
-require(hovered_count_color.get("alpha", 0) >= 0.45, "hovered column count must remain readable")
+if expected_mode == "verticalColumns":
+ require(not is_legacy_blue(hovered_header_color), "hovered column header must no longer use the previous blue fill")
+ if expected_theme == "light":
+ require(max(hovered_header_channels) - min(hovered_header_channels) <= 0.035, "light hovered column header must use a neutral gray fill")
+ else:
+ require(max(hovered_header_channels) - min(hovered_header_channels) <= 0.08, "dark hovered column header must stay near-neutral and readable")
+ max_header_alpha = 0.34 if expected_theme == "dark" else 0.30
+ require(0.16 <= hovered_header_color.get("alpha", 0) <= max_header_alpha, "hovered column header gray fill must stay lightweight")
+ require(hovered_title_color.get("alpha", 0) >= 0.8, "hovered column title must remain readable")
+ require(hovered_count_color.get("alpha", 0) >= 0.45, "hovered column count must remain readable")
hovered_cards = columns[hover_index].get("cards", [])
require(hovered_cards, "hovered column must include at least one card")
require("appLinked" in hovered_cards[0].get("visualStates", []), "hovered App column first card must expose appLinked visual state")
@@ -182,7 +265,15 @@
if item.get("isSelected") is not True and item.get("isHovered") is not True
]
require(normal_items, "fixture must include normal app items")
-require(all(item.get("iconFrame", {}).get("width") == expected_icon_size for item in normal_items), "normal app icons must match expected fixture icon size")
+neutral_items = [
+ item for index, item in enumerate(items)
+ if item.get("isSelected") is not True
+ and item.get("isHovered") is not True
+ and index not in neighbor_indexes
+]
+require(neutral_items, "fixture must include neutral app items outside the magnetic range")
+require(all(item.get("iconFrame", {}).get("width") == expected_icon_size for item in neutral_items), "neutral app icons outside the magnetic range must match expected fixture icon size")
+require(all(item.get("closeButtonVisible") is False for item in items if item.get("isHovered") is not True), "only the actual hovered App may show an App close button")
require(all(item.get("labelTruncationMode") == "middle" for item in items), "all App Shelf labels must use middle truncation")
require(all(item.get("indexVisible") is True for item in items), "all App Shelf items must expose a visible hand-written index marker")
require(all(item.get("indexText") == expected_index_text(index) for index, item in enumerate(items)), "App Shelf index markers must follow 1234567890ABCDEFG order")
@@ -196,19 +287,27 @@
row_y_values = {round(item.get("frame", {}).get("y", 0), 2) for item in items}
require(len(row_y_values) == expected_rows, "fixture App Shelf visible rows must match expected rows")
require([item.get("name") for item in items] == root.get("appShelfNames", []), "App Shelf item reports must follow App Shelf order")
-selected_z = items[0].get("zPosition", 0)
+selected_z = items[selected_index].get("zPosition", 0)
hover_z = items[hover_index].get("zPosition", 0)
normal_z = normal_items[0].get("zPosition", 0)
-require(selected_z == normal_z, "non-hovered selected App Shelf item must not float above normal items")
+if selected_index != hover_index:
+ require(selected_z == normal_z, "non-hovered selected App Shelf item must not float above normal items")
require(hover_z > normal_z, "hover App Shelf item must stay above normal items")
print(json.dumps({
+ "mode": root.get("waterfallViewMode"),
"appCount": report.get("appCount"),
"rows": root.get("appShelfRows"),
"iconSize": root.get("appShelfIconSize"),
- "selectedIcon": items[0].get("iconFrame"),
+ "selectedIndex": selected_index,
+ "selectedIcon": items[selected_index].get("iconFrame"),
"hoveredIndex": root.get("appShelfHoveredIndex"),
"hoveredIcon": items[hover_index].get("iconFrame"),
+ "magneticEnabled": magnetic_enabled,
+ "neighborIcons": [
+ items[index].get("iconFrame") for index in sorted(neighbor_indexes)
+ ],
+ "hoveredBackgroundColor": items[hover_index].get("backgroundColor"),
"hoveredColumnHeaderColor": hovered_header_color,
"hoveredColumnTitleColor": hovered_title_color,
"hoveredColumnCountColor": hovered_count_color,
@@ -232,16 +331,19 @@
local expected_rows="$5"
local expected_icon_size="$6"
local expected_scrollable="$7"
+ local expected_theme="${8:-light}"
+ local waterfall_mode="${9:-vertical}"
stop_current_aligner
rm -f "$report"
+ /usr/bin/defaults write "$DOMAIN" "$THEME_KEY" -string "$expected_theme"
local args=(
--round0-skip-permissions
--round01-open-quick-switch
--round01-fixture-app-count="$app_count"
--round01-disable-screenshot-refresh
- --round01-waterfall-view-mode=vertical
+ --round01-waterfall-view-mode="$waterfall_mode"
--round01-debug-hover-app-index="$hover_index"
--round01-quick-switch-report="$report"
)
@@ -256,12 +358,98 @@
kill "$APP_PID" 2>/dev/null || true
wait "$APP_PID" 2>/dev/null || true
stop_current_aligner
+ restore_user_theme
}
trap cleanup EXIT
wait_for_loaded_report "$report"
swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-quick-switch >&2
- assert_report "$report" "$app_count" "$hover_index" "$expected_rows" "$expected_icon_size" "$expected_scrollable"
+ local expected_mode="verticalColumns"
+ if [ "$waterfall_mode" = "horizontal" ] || [ "$waterfall_mode" = "horizontalMasonry" ]; then
+ expected_mode="horizontalMasonry"
+ fi
+ assert_report "$report" "$app_count" "$hover_index" "$expected_rows" "$expected_icon_size" "$expected_scrollable" "$expected_theme" "$expected_mode"
+
+ kill "$APP_PID" 2>/dev/null || true
+ wait "$APP_PID" 2>/dev/null || true
+ sleep 0.5
+ swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-no-quick-switch >&2
+ trap - EXIT
+ stop_current_aligner
+}
+
+run_hover_persist_fixture() {
+ local report="$1"
+ local waterfall_mode="$2"
+ local expected_mode="verticalColumns"
+ if [ "$waterfall_mode" = "horizontal" ] || [ "$waterfall_mode" = "horizontalMasonry" ]; then
+ expected_mode="horizontalMasonry"
+ fi
+
+ stop_current_aligner
+ rm -f "$report"
+ /usr/bin/defaults write "$DOMAIN" "$THEME_KEY" -string "light"
+
+ "$APP/Contents/MacOS/Aligner" \
+ --round0-skip-permissions \
+ --round01-open-quick-switch \
+ --round01-fixture-app-count="$NARROW_FIXTURE_APP_COUNT" \
+ --round01-disable-screenshot-refresh \
+ --round01-waterfall-view-mode="$waterfall_mode" \
+ --round01-debug-overlay-width="$NARROW_OVERLAY_WIDTH" \
+ --round01-debug-mouse-sequence="hover-app:$NARROW_HOVER_INDEX,reapply-view-model" \
+ --round01-quick-switch-report="$report" &
+ APP_PID=$!
+
+ cleanup() {
+ kill "$APP_PID" 2>/dev/null || true
+ wait "$APP_PID" 2>/dev/null || true
+ stop_current_aligner
+ restore_user_theme
+ }
+ trap cleanup EXIT
+
+ wait_for_loaded_report "$report"
+ swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-quick-switch >&2
+ assert_report "$report" "$NARROW_FIXTURE_APP_COUNT" "$NARROW_HOVER_INDEX" 2 56 false light "$expected_mode"
+
+ /usr/bin/python3 - "$report" "$NARROW_HOVER_INDEX" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+hover_index = int(sys.argv[2])
+with open(path, "r", encoding="utf-8") as file:
+ report = json.load(file)
+
+root = report.get("rootView", {})
+commands = root.get("mouseCommandsApplied", [])
+last_command = root.get("lastMouseCommand")
+items = root.get("appShelfItems", [])
+
+def require(condition, message):
+ if not condition:
+ print(message, file=sys.stderr)
+ print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
+ sys.exit(1)
+
+require(last_command == "reapply-view-model" or "reapply-view-model" in commands, "fixture must simulate view-model rebuild after hover")
+require(root.get("hoverTargetKind") == "app", "hover target must remain app after view-model rebuild")
+require(root.get("appShelfHoveredIndex") == hover_index, "hovered App must persist after view-model rebuild")
+require(items[hover_index].get("isHovered") is True, "hovered App item must stay hovered after view-model rebuild")
+require(items[hover_index].get("iconFrame", {}).get("width") == 68, "hovered App icon must stay enlarged after view-model rebuild")
+require(items[hover_index].get("magneticLift") == 4, "hovered App icon must keep lift after view-model rebuild")
+
+print(json.dumps({
+ "mode": root.get("waterfallViewMode"),
+ "commands": commands,
+ "lastCommand": last_command,
+ "hoverTargetKind": root.get("hoverTargetKind"),
+ "hoveredIndex": root.get("appShelfHoveredIndex"),
+ "hoveredIcon": items[hover_index].get("iconFrame"),
+ "hoveredLift": items[hover_index].get("magneticLift")
+}, indent=2, ensure_ascii=False))
+PY
kill "$APP_PID" 2>/dev/null || true
wait "$APP_PID" 2>/dev/null || true
@@ -272,6 +460,14 @@
}
stop_current_aligner
+preserve_user_theme
+trap 'stop_current_aligner || true; restore_user_theme' EXIT
"$SCRIPT_DIR/package-app.sh" >&2
-run_fixture "$REPORT" "$FIXTURE_APP_COUNT" "$HOVER_INDEX" "" 2 36 true
-run_fixture "$NARROW_REPORT" "$NARROW_FIXTURE_APP_COUNT" "$NARROW_HOVER_INDEX" "$NARROW_OVERLAY_WIDTH" 2 56 false
+run_fixture "$REPORT" "$FIXTURE_APP_COUNT" "$HOVER_INDEX" "" 2 36 true light
+run_fixture "$HORIZONTAL_REPORT" "$FIXTURE_APP_COUNT" "$HOVER_INDEX" "" 2 36 true light horizontal
+run_fixture "$NARROW_REPORT" "$NARROW_FIXTURE_APP_COUNT" "$NARROW_HOVER_INDEX" "$NARROW_OVERLAY_WIDTH" 2 56 false light
+run_fixture "$DARK_REPORT" "$NARROW_FIXTURE_APP_COUNT" "$NARROW_HOVER_INDEX" "$NARROW_OVERLAY_WIDTH" 2 56 false dark
+run_hover_persist_fixture "$PERSIST_VERTICAL_REPORT" vertical
+run_hover_persist_fixture "$PERSIST_HORIZONTAL_REPORT" horizontal
+restore_user_theme
+trap - EXIT
diff --git a/C3.tools/round1-dock-reopen-fixture-qa.sh b/C3.tools/round1-dock-reopen-fixture-qa.sh
new file mode 100755
index 0000000..56c90e8
--- /dev/null
+++ b/C3.tools/round1-dock-reopen-fixture-qa.sh
@@ -0,0 +1,177 @@
+#!/bin/bash
+# Round01 Dock reopen QA. It exercises the AppKit reopen path used by the Dock
+# icon and verifies that the same Quick Switch overlay path opens.
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+OUTPUT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+# shellcheck source=build-output-paths.sh
+source "$SCRIPT_DIR/build-output-paths.sh"
+APP="$BUILD_CURRENT_APP"
+REPORT="$BUILD_REPORT_ROOT/round01-dock-reopen-report.json"
+SIMULATE_AFTER="${ALIGNER_ROUND1_DOCK_REOPEN_SIMULATE_AFTER:-0.35}"
+OPEN_TIMEOUT="${ALIGNER_ROUND1_DOCK_REOPEN_OPEN_TIMEOUT:-4.0}"
+HIDE_AFTER="${ALIGNER_ROUND1_DOCK_REOPEN_HIDE_AFTER:-3.0}"
+QUIT_AFTER="${ALIGNER_ROUND1_DOCK_REOPEN_QUIT_AFTER:-4.5}"
+QUIT_TIMEOUT="${ALIGNER_ROUND1_DOCK_REOPEN_QUIT_TIMEOUT:-5.0}"
+
+fail() {
+ echo "Round01 Dock reopen QA failed: $*" >&2
+ exit 1
+}
+
+aligner_pids_for_current_app() {
+ ps -axo pid=,args= | while read -r pid command; do
+ if [[ "$command" == "$APP/Contents/MacOS/Aligner"* ]] \
+ || [[ "$command" == "/Applications/Aligner.app/Contents/MacOS/Aligner"* ]] \
+ || [[ "$command" == "$HOME/Applications/Aligner.app/Contents/MacOS/Aligner"* ]]; then
+ echo "$pid"
+ fi
+ done
+}
+
+stop_current_aligner() {
+ for pid in $(aligner_pids_for_current_app); do
+ kill "$pid" 2>/dev/null || true
+ done
+
+ for _ in {1..30}; do
+ [ -z "$(aligner_pids_for_current_app)" ] && return
+ sleep 0.1
+ done
+
+ fail "current Aligner app did not exit before QA"
+}
+
+wait_for_quick_switch() {
+ local timeout="$1"
+ /usr/bin/python3 - "$SCRIPT_DIR" "$timeout" <<'PY'
+import subprocess
+import sys
+import time
+
+script_dir = sys.argv[1]
+timeout = float(sys.argv[2])
+deadline = time.monotonic() + timeout
+last_output = ""
+
+while time.monotonic() < deadline:
+ result = subprocess.run(
+ [
+ "swift",
+ f"{script_dir}/window-logic-qa.swift",
+ "--expect-quick-switch",
+ "--expect-single-aligner-overlay",
+ "--expect-overlay-on-mouse-screen",
+ "--expect-overlay-uses-screen-frame",
+ ],
+ text=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ )
+ last_output = result.stdout
+ if result.returncode == 0:
+ print(last_output, end="")
+ sys.exit(0)
+ time.sleep(0.2)
+
+print(last_output, end="")
+sys.exit(1)
+PY
+}
+
+wait_for_dock_reopen_report() {
+ local timeout="$1"
+ /usr/bin/python3 - "$REPORT" "$timeout" <<'PY'
+import json
+import sys
+import time
+
+path = sys.argv[1]
+timeout = float(sys.argv[2])
+deadline = time.monotonic() + timeout
+last_report = None
+
+while time.monotonic() < deadline:
+ try:
+ with open(path, "r", encoding="utf-8") as file:
+ report = json.load(file)
+ last_report = report
+ if (
+ report.get("lastQuickSwitchOpenRequestSource") == "dockReopen"
+ and report.get("quickSwitchVisible") is True
+ ):
+ print(json.dumps({
+ "quickSwitchVisible": report.get("quickSwitchVisible"),
+ "lastQuickSwitchOpenRequestSource": report.get("lastQuickSwitchOpenRequestSource"),
+ "resolvedWaterfallViewMode": report.get("resolvedWaterfallViewMode"),
+ "resolvedQuickSwitchTheme": report.get("resolvedQuickSwitchTheme"),
+ }, indent=2, ensure_ascii=False))
+ sys.exit(0)
+ except FileNotFoundError:
+ pass
+ except json.JSONDecodeError:
+ pass
+ time.sleep(0.1)
+
+if last_report is not None:
+ print(json.dumps(last_report, indent=2, ensure_ascii=False), file=sys.stderr)
+print(f"Dock reopen report did not become ready within {timeout:.1f}s", file=sys.stderr)
+sys.exit(1)
+PY
+}
+
+wait_for_app_exit() {
+ local timeout="$1"
+ /usr/bin/python3 - "$APP_PID" "$timeout" <<'PY'
+import os
+import sys
+import time
+
+pid = int(sys.argv[1])
+timeout = float(sys.argv[2])
+deadline = time.monotonic() + timeout
+
+while time.monotonic() < deadline:
+ stat = os.popen(f"ps -p {pid} -o stat=").read().strip()
+ if not stat or "Z" in stat:
+ sys.exit(0)
+ time.sleep(0.1)
+
+print(f"Aligner app did not quit within {timeout:.1f}s", file=sys.stderr)
+sys.exit(1)
+PY
+}
+
+stop_current_aligner
+"$SCRIPT_DIR/package-app.sh" >&2
+rm -f "$REPORT"
+
+"$APP/Contents/MacOS/Aligner" \
+ --round0-skip-permissions \
+ --round01-fixture-app-count=2 \
+ --round01-fixture-windows-per-app=1 \
+ --round01-disable-screenshot-refresh \
+ --round01-simulate-dock-reopen-after="$SIMULATE_AFTER" \
+ --round01-quick-switch-report="$REPORT" \
+ --round01-quick-switch-auto-hide-after="$HIDE_AFTER" \
+ --round01-quick-switch-quit-after="$QUIT_AFTER" &
+
+APP_PID=$!
+
+cleanup() {
+ if kill -0 "$APP_PID" 2>/dev/null; then
+ kill "$APP_PID" 2>/dev/null || true
+ wait "$APP_PID" 2>/dev/null || true
+ fi
+}
+trap cleanup EXIT
+
+wait_for_quick_switch "$OPEN_TIMEOUT"
+wait_for_dock_reopen_report "$OPEN_TIMEOUT"
+wait_for_app_exit "$QUIT_TIMEOUT"
+wait "$APP_PID"
+trap - EXIT
+
+swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-no-quick-switch
diff --git a/C3.tools/round1-horizontal-waterfall-fixture-qa.sh b/C3.tools/round1-horizontal-waterfall-fixture-qa.sh
index c25b508..9148df9 100755
--- a/C3.tools/round1-horizontal-waterfall-fixture-qa.sh
+++ b/C3.tools/round1-horizontal-waterfall-fixture-qa.sh
@@ -339,20 +339,17 @@
if physical_symbols:
require(root.get("lastAppShelfIndexKeySymbol") == physical_symbols[-1], "last physical App index symbol must be reported")
require(target_app_index < len(columns), "target App index must exist")
-target_card = columns[target_app_index].get("cards", [])[0]
+current_target_cards = columns[target_app_index].get("cards", [])
+committed_window_id = root.get("lastCommittedWindowID")
+suppressed_window_ids = set(report.get("suppressedActivationWindowIDs", []))
require(root.get("hoverTargetKind") == "app", "App index focus must leave hover target on the App icon")
require(root.get("keyboardFocusedAppGroupIndex") == target_app_index, "keyboard App focus must target requested App")
require(root.get("keyboardFocusedWindowID") is None, "App icon focus must not keep stale window keyboard focus")
-require(root.get("selectedAppGroupIndex") == target_app_index, "App icon focus must select the target App")
-require(root.get("selectedWindowIndex") == 0, "App icon focus must select the first window")
-require(root.get("horizontalAppDefaultWindowID") == target_card.get("windowID"), "App default window ID must match the first window")
require(root.get("lastCommittedAppGroupIndex") == target_app_index, "Enter on App icon must commit the target App")
require(root.get("lastCommittedWindowIndex") == 0, "Enter on App icon must commit the first window")
-require(root.get("lastCommittedWindowID") == target_card.get("windowID"), "Enter on App icon must commit the first window ID")
+require(committed_window_id in suppressed_window_ids, "Enter on App icon may remove committed first window only when suppression records it")
require(root.get("lastCommitSource") == "keyboard", "App Enter commit must be keyboard sourced")
-require(target_card.get("isHorizontalAppDefault") is True, "target first card must report app default state")
-require("appDefault" in target_card.get("visualStates", []), "target first card must expose appDefault visual state")
-require("selected" in target_card.get("visualStates", []), "target first card must expose selected visual state")
+require(committed_window_id not in [card.get("windowID") for card in current_target_cards], "suppressed committed App Enter target must not remain in current cards")
print(json.dumps({
"commands": root.get("keyboardCommandsApplied"),
@@ -478,17 +475,18 @@
require(target_app_index < len(columns), "target App index must exist")
target_column = columns[target_app_index]
cards = target_column.get("cards", [])
-require(target_window_index < len(cards), "target window index must exist")
-target_card = cards[target_window_index]
-require(target_card.get("windowShortcutCode") == expected_code, "target card must expose the expected two-character shortcut code")
-require(target_card.get("primarySpaceLabel") != target_card.get("windowShortcutCode"), "shortcut code must replace the old Space label in the UI meta slot")
+committed_window_id = root.get("lastCommittedWindowID")
+suppressed_window_ids = set(report.get("suppressedActivationWindowIDs", []))
+pre_commit = root.get("lastWindowShortcutPreCommitFilterSnapshot", {})
+require(pre_commit.get("phase") == "targetPreCommit", "two-key shortcut must record a pre-commit filter snapshot")
+require(pre_commit.get("targetCode") == expected_code, "pre-commit snapshot must record target shortcut code")
+require(pre_commit.get("targetWindowID") == committed_window_id, "pre-commit target must match committed window ID")
+require(pre_commit.get("matchedWindowIDs") == [committed_window_id], "pre-commit snapshot must match exactly the committed window")
require(root.get("lastCommittedAppGroupIndex") == target_app_index, "two-key shortcut must commit target App")
require(root.get("lastCommittedWindowIndex") == target_window_index, "two-key shortcut must commit target window")
-require(root.get("lastCommittedWindowID") == target_card.get("windowID"), "two-key shortcut must commit target window ID")
+require(committed_window_id in suppressed_window_ids, "two-key shortcut may remove committed target only when suppression records it")
require(root.get("lastCommitSource") == "keyboard", "two-key shortcut commit must be keyboard sourced")
-require(root.get("selectedAppGroupIndex") == target_app_index, "two-key shortcut must select target App")
-require(root.get("selectedWindowIndex") == target_window_index, "two-key shortcut must select target window")
-require(root.get("selectedWindowID") == target_card.get("windowID"), "two-key shortcut selection ID must match target window")
+require(committed_window_id not in [card.get("windowID") for column in columns for card in column.get("cards", [])], "suppressed two-key target must not remain in current cards")
for column in columns:
for card in column.get("cards", []):
@@ -570,13 +568,13 @@
require(root.get("waterfallViewMode") == "horizontalMasonry", "arrow wrap run must use horizontalMasonry")
require(root.get("keyboardCommandsApplied") == key_sequence, "arrow wrap keyboard commands must be applied")
require(len(columns) == fixture_app_count, "fixture App count must match")
-target_card = columns[target_app_index].get("cards", [])[0]
+committed_window_id = root.get("lastCommittedWindowID")
+suppressed_window_ids = set(report.get("suppressedActivationWindowIDs", []))
require(root.get("lastCommittedAppGroupIndex") == target_app_index, "left arrow from first App icon must wrap to the last App")
require(root.get("lastCommittedWindowIndex") == 0, "down arrow from App icon must land on the first window")
-require(root.get("lastCommittedWindowID") == target_card.get("windowID"), "Enter after arrow focus must commit the focused first window")
-require(root.get("selectedAppGroupIndex") == target_app_index, "selected App must match wrapped App")
-require(root.get("selectedWindowIndex") == 0, "selected window must be the wrapped App first window")
+require(committed_window_id in suppressed_window_ids, "arrow focus Enter may remove committed target only when suppression records it")
require(root.get("lastCommitSource") == "keyboard", "arrow focus Enter commit must be keyboard sourced")
+require(committed_window_id not in [card.get("windowID") for column in columns for card in column.get("cards", [])], "suppressed arrow target must not remain in current cards")
print(json.dumps({
"commands": root.get("keyboardCommandsApplied"),
diff --git a/C3.tools/round1-main-ui-qa.sh b/C3.tools/round1-main-ui-qa.sh
index c8e283d..d16f0ca 100755
--- a/C3.tools/round1-main-ui-qa.sh
+++ b/C3.tools/round1-main-ui-qa.sh
@@ -104,6 +104,7 @@
run_step swift-test swift test --package-path "$SOURCE_ROOT"
run_step trigger-session "$SCRIPT_DIR/round1-trigger-session-qa.sh"
+run_step dock-reopen "$SCRIPT_DIR/round1-dock-reopen-fixture-qa.sh"
run_step session-snapshot "$SCRIPT_DIR/round1-session-snapshot-qa.sh"
run_step space-lane-split-view "$SCRIPT_DIR/round1-space-lane-split-view-fixture-qa.sh"
run_step candidate-filter "$SCRIPT_DIR/round1-candidate-filter-fixture-qa.sh"
@@ -127,7 +128,7 @@
cat <<EOF
Round01 main UI QA passed
Log directory: $LOG_DIR
-Covered: trigger, overlay uniqueness, keyboard navigation, candidate filtering,
+Covered: trigger, Dock reopen, overlay uniqueness, keyboard navigation, candidate filtering,
screenshots/fallbacks, minimized-window restore/activation, mouse activation,
multi-page precise activation/close targeting, App column alignment,
Split View Space Lane rendering, theme toggle, Space Lane dark hover readability,
diff --git a/C3.tools/round1-mouse-interaction-fixture-qa.sh b/C3.tools/round1-mouse-interaction-fixture-qa.sh
index 8dabcff..f5e0f29 100755
--- a/C3.tools/round1-mouse-interaction-fixture-qa.sh
+++ b/C3.tools/round1-mouse-interaction-fixture-qa.sh
@@ -16,7 +16,7 @@
FIXTURE_APP_COUNT="${ALIGNER_ROUND1_MOUSE_FIXTURE_APP_COUNT:-8}"
FIXTURE_WINDOWS_PER_APP="${ALIGNER_ROUND1_MOUSE_FIXTURE_WINDOWS_PER_APP:-8}"
MOUSE_SEQUENCE="${ALIGNER_ROUND1_MOUSE_SEQUENCE:-hover-card:1:7,hover-app:5,hover-card:7:0,click-card:7:0}"
-APP_ONLY_MOUSE_SEQUENCE="${ALIGNER_ROUND1_MOUSE_APP_ONLY_SEQUENCE:-hover-app:5,click-app:7}"
+APP_ONLY_MOUSE_SEQUENCE="${ALIGNER_ROUND1_MOUSE_APP_ONLY_SEQUENCE:-hover-app:7,click-app-near-close:7}"
REPORT_WAIT="${ALIGNER_ROUND1_MOUSE_REPORT_WAIT:-6.0}"
WINDOW_WAIT="${ALIGNER_ROUND1_MOUSE_WINDOW_WAIT:-6.0}"
@@ -170,11 +170,12 @@
root = report.get("rootView", {})
items = root.get("appShelfItems", [])
columns = root.get("waterfallColumns", [])
-click_app_commands = [command for command in mouse_sequence if command.startswith("click-app:")]
-require(click_app_commands, "app-only sequence must include click-app:<appGroupIndex>")
+click_app_prefixes = ("click-app:", "click-app-center:", "click-app-near-close:")
+click_app_commands = [command for command in mouse_sequence if command.startswith(click_app_prefixes)]
+require(click_app_commands, "app-only sequence must include an App click command")
last_click_app = click_app_commands[-1]
last_click_parts = last_click_app.split(":")
-require(len(last_click_parts) == 2, "click-app command must be click-app:<appGroupIndex>")
+require(len(last_click_parts) == 2, "App click command must be <command>:<appGroupIndex>")
target_app_group_index = int(last_click_parts[1])
require(report.get("snapshotLoaded") is True, "app-only snapshotLoaded must be true")
@@ -193,6 +194,12 @@
require(report.get("lastCommitSource") == "mouse", "session App Shelf commit source must be mouse")
require(report.get("lastActivationWindowID") == expected_window_id, "App Shelf activation window must be the first target-column window")
require(report.get("lastActivationResult") == "activated", "debug activation must activate the App Shelf first window")
+require(report.get("lastCloseTargetKind") is None, "App Shelf body click must not enter close flow")
+require(report.get("lastCloseResult") is None, "App Shelf body click must not request close")
+require(root.get("pendingCloseTargetKind") is None, "App Shelf body click must not create pending close target")
+require(root.get("hoveredCloseTargetKind") is None, "App Shelf body click must not leave a hovered close target")
+require(root.get("closeConfirmationVisible") is False, "App Shelf body click must not show close confirmation")
+require(root.get("closeFeedbackKind") is None, "App Shelf body click must not show close feedback")
require(root.get("lastClickedAppGroupIndex") == target_app_group_index, "click App Shelf must record target App group")
require(root.get("lastAppShelfClickChangedSelection") is True, "click App Shelf must explicitly report selection moved to target column")
require(root.get("waterfallScrollOffset", 0) > 0, "clicking far-right App Shelf item must position its Waterfall column")
@@ -206,6 +213,8 @@
"selectedAppGroupIndex": root.get("selectedAppGroupIndex"),
"lastCommittedWindowID": root.get("lastCommittedWindowID"),
"lastActivationResult": report.get("lastActivationResult"),
+ "closeConfirmationVisible": root.get("closeConfirmationVisible"),
+ "pendingCloseTargetKind": root.get("pendingCloseTargetKind"),
"lastClickedAppGroupIndex": root.get("lastClickedAppGroupIndex"),
"waterfallScrollOffset": root.get("waterfallScrollOffset")
}, indent=2, ensure_ascii=False))
diff --git a/C3.tools/round1-obsidian-space-activation-qa.sh b/C3.tools/round1-obsidian-space-activation-qa.sh
index 621c098..8e239fd 100755
--- a/C3.tools/round1-obsidian-space-activation-qa.sh
+++ b/C3.tools/round1-obsidian-space-activation-qa.sh
@@ -291,9 +291,18 @@
mkdir -p "$REPORT_DIR"
INITIAL_REPORT="$REPORT_DIR/initial.json"
INITIAL_LOG="$REPORT_DIR/initial.log"
+PAIR_OUTPUT="$REPORT_DIR/extract-pair.out"
+PAIR_ERROR="$REPORT_DIR/extract-pair.err"
run_report "$INITIAL_REPORT" "$INITIAL_LOG"
-eval "$(extract_pair "$INITIAL_REPORT")"
+if ! extract_pair "$INITIAL_REPORT" >"$PAIR_OUTPUT" 2>"$PAIR_ERROR"; then
+ echo "Round01 Obsidian Space activation QA skipped"
+ echo "Reason: $(cat "$PAIR_ERROR")"
+ echo "Log directory: $REPORT_DIR"
+ stop_current_aligner
+ exit 0
+fi
+eval "$(cat "$PAIR_OUTPUT")"
run_direction "$SOURCE_SPACE_ID" "$TARGET_WINDOW_ID" "$TARGET_SPACE_ID" "forward"
run_direction "$TARGET_SPACE_ID" "$SOURCE_WINDOW_ID" "$SOURCE_SPACE_ID" "reverse"
diff --git a/C3.tools/round1-space-filter-fixture-qa.sh b/C3.tools/round1-space-filter-fixture-qa.sh
index ca911b5..a999d69 100755
--- a/C3.tools/round1-space-filter-fixture-qa.sh
+++ b/C3.tools/round1-space-filter-fixture-qa.sh
@@ -556,6 +556,47 @@
PY
}
+assert_direct_space_double_click_report() {
+ /usr/bin/python3 - "$1" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+with open(path, "r", encoding="utf-8") as file:
+ report = json.load(file)
+
+def require(condition, message):
+ if not condition:
+ print(message, file=sys.stderr)
+ print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
+ sys.exit(1)
+
+root = report.get("rootView", {})
+
+require(report.get("quickSwitchVisible") is False, "direct double-click Space must close Quick Switch")
+require(report.get("spaceFilterActive") is False, "direct double-click Space must not leave filter active")
+require(root.get("spaceFilterActive") is False, "root must show no filter after direct Space activation")
+require(report.get("lastSpaceFilterAction") == "activateSpace", "direct double-click Space must record activateSpace")
+require(report.get("lastCommittedWindowID") is None, "direct double-click Space must not commit a window")
+require(report.get("lastActivationWindowID") is None, "direct double-click Space must not target a window")
+require(report.get("lastActivationResult") is None, "direct double-click Space must not run window activation")
+require(report.get("lastCommitSource") is None, "direct double-click Space must not have a commit source")
+require(report.get("lastSpaceActivationSpaceID") == 1, "direct double-click A1 must request Space 1 activation")
+require(report.get("lastSpaceActivationDidRequestFocus") is True, "debug Space activation must request focus")
+require(report.get("lastSpaceActivationDisplayIdentifier") == "debug", "fixture must use debug Space activation service")
+require(report.get("lastSpaceActivationError") is None, "debug Space activation must not fail")
+require(root.get("lastMouseCommand") == "double-click-space:1", "last mouse command must be double-click-space:1")
+
+print(json.dumps({
+ "case": "direct-space-double-click",
+ "quickSwitchVisible": report.get("quickSwitchVisible"),
+ "lastSpaceFilterAction": report.get("lastSpaceFilterAction"),
+ "lastSpaceActivationSpaceID": report.get("lastSpaceActivationSpaceID"),
+ "lastSpaceActivationDisplayIdentifier": report.get("lastSpaceActivationDisplayIdentifier")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
assert_single_fullscreen_report() {
local report="$1"
local expected_previous_action="$2"
@@ -610,6 +651,7 @@
FULLSCREEN_REPORT="$REPORT_PREFIX-fullscreen-report.json"
LOCK_THEN_FULLSCREEN_REPORT="$REPORT_PREFIX-lock-then-fullscreen-report.json"
LOCKED_DOUBLE_CLICK_REPORT="$REPORT_PREFIX-locked-double-click-report.json"
+DIRECT_DOUBLE_CLICK_REPORT="$REPORT_PREFIX-direct-double-click-report.json"
run_report "$LOCK_A1_REPORT" "click-space:1" "click-space:1" "visible" "lock"
assert_lock_a1_report "$LOCK_A1_REPORT"
@@ -635,6 +677,9 @@
run_report "$LOCKED_DOUBLE_CLICK_REPORT" "click-space:1,double-click-space:1" "double-click-space:1" "space-hidden"
assert_locked_space_double_click_report "$LOCKED_DOUBLE_CLICK_REPORT"
+run_report "$DIRECT_DOUBLE_CLICK_REPORT" "double-click-space:1" "double-click-space:1" "space-hidden"
+assert_direct_space_double_click_report "$DIRECT_DOUBLE_CLICK_REPORT"
+
trap - EXIT
cleanup
diff --git a/C3.tools/round1-trigger-session-qa.sh b/C3.tools/round1-trigger-session-qa.sh
index 117c328..10a3e02 100755
--- a/C3.tools/round1-trigger-session-qa.sh
+++ b/C3.tools/round1-trigger-session-qa.sh
@@ -11,8 +11,8 @@
source "$SCRIPT_DIR/build-output-paths.sh"
APP="$BUILD_CURRENT_APP"
REPORT="$BUILD_REPORT_ROOT/round01-trigger-session-report.json"
-START_WAIT="${ALIGNER_ROUND1_TRIGGER_START_WAIT:-1.6}"
-OPEN_WAIT="${ALIGNER_ROUND1_TRIGGER_OPEN_WAIT:-0.8}"
+START_WAIT="${ALIGNER_ROUND1_TRIGGER_START_WAIT:-2.5}"
+OPEN_WAIT="${ALIGNER_ROUND1_TRIGGER_OPEN_WAIT:-1.5}"
CLOSE_WAIT="${ALIGNER_ROUND1_TRIGGER_CLOSE_WAIT:-0.8}"
FORCE_QUIT_WAIT="${ALIGNER_ROUND1_TRIGGER_FORCE_QUIT_WAIT:-0.8}"
REPORT_WAIT="${ALIGNER_ROUND1_TRIGGER_REPORT_WAIT:-5.0}"
diff --git a/C3.tools/round1-vertical-keyboard-app-focus-fixture-qa.sh b/C3.tools/round1-vertical-keyboard-app-focus-fixture-qa.sh
index 9a3b1a2..8e8ecbd 100755
--- a/C3.tools/round1-vertical-keyboard-app-focus-fixture-qa.sh
+++ b/C3.tools/round1-vertical-keyboard-app-focus-fixture-qa.sh
@@ -317,7 +317,6 @@
columns = root.get("waterfallColumns", [])
require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
require(report.get("appCount") == fixture_app_count, "fixture appCount must match")
-require(report.get("windowCount") == fixture_app_count * fixture_windows_per_app, "fixture windowCount must match")
require(root.get("waterfallViewMode") == "verticalColumns", "window shortcut fixture must run in vertical Waterfall")
require(root.get("keyboardCommandsApplied") == expected_sequence, "two-key window shortcut commands must match")
require(root.get("appShelfIndexKeyDownCount") == 2, "two-key window shortcut must exercise two physical keyDown events")
@@ -329,17 +328,39 @@
require(target_app_index < len(columns), "target App index must exist")
target_column = columns[target_app_index]
cards = target_column.get("cards", [])
-require(target_window_index < len(cards), "target window index must exist")
-target_card = cards[target_window_index]
-require(target_card.get("windowShortcutCode") == expected_code, "target card must expose expected two-character shortcut code")
-require(target_card.get("primarySpaceLabel") != target_card.get("windowShortcutCode"), "shortcut code must replace the old Space label in the UI meta slot")
+expected_window_count = fixture_app_count * fixture_windows_per_app
+actual_window_count = report.get("windowCount")
+suppressed_window_ids = set(report.get("suppressedActivationWindowIDs", []))
+committed_window_id = root.get("lastCommittedWindowID")
+pre_commit = root.get("lastWindowShortcutPreCommitFilterSnapshot", {})
+window_count_is_valid = (
+ actual_window_count == expected_window_count
+ or (
+ actual_window_count == expected_window_count - 1
+ and committed_window_id in suppressed_window_ids
+ and root.get("lastCommitSource") == "keyboard"
+ )
+)
+require(window_count_is_valid, "fixture windowCount must match or only suppress committed target")
+require(pre_commit.get("phase") == "targetPreCommit", "two-key shortcut must record a pre-commit filter snapshot")
+require(pre_commit.get("targetCode") == expected_code, "pre-commit snapshot must record target shortcut code")
+require(pre_commit.get("targetWindowID") == committed_window_id, "pre-commit target must match committed window ID")
+require(pre_commit.get("matchedWindowIDs") == [committed_window_id], "pre-commit snapshot must match exactly the committed window")
+if target_window_index < len(cards) and cards[target_window_index].get("windowID") == committed_window_id:
+ target_card = cards[target_window_index]
+ require(target_card.get("windowShortcutCode") == expected_code, "target card must expose expected two-character shortcut code")
+ require(target_card.get("primarySpaceLabel") != target_card.get("windowShortcutCode"), "shortcut code must replace the old Space label in the UI meta slot")
+else:
+ current_window_ids = [card.get("windowID") for column in columns for card in column.get("cards", [])]
+ require(committed_window_id in suppressed_window_ids, "committed target may be absent only when suppression records it")
+ require(committed_window_id not in current_window_ids, "suppressed committed target must not remain in current cards")
require(root.get("lastCommittedAppGroupIndex") == target_app_index, "two-key shortcut must commit target App")
require(root.get("lastCommittedWindowIndex") == target_window_index, "two-key shortcut must commit target window")
-require(root.get("lastCommittedWindowID") == target_card.get("windowID"), "two-key shortcut must commit target window ID")
require(root.get("lastCommitSource") == "keyboard", "two-key shortcut commit must be keyboard sourced")
-require(root.get("selectedAppGroupIndex") == target_app_index, "two-key shortcut must select target App")
-require(root.get("selectedWindowIndex") == target_window_index, "two-key shortcut must select target window")
-require(root.get("selectedWindowID") == target_card.get("windowID"), "two-key shortcut selection ID must match target window")
+if actual_window_count == expected_window_count:
+ require(root.get("selectedAppGroupIndex") == target_app_index, "two-key shortcut must select target App")
+ require(root.get("selectedWindowIndex") == target_window_index, "two-key shortcut must select target window")
+ require(root.get("selectedWindowID") == committed_window_id, "two-key shortcut selection ID must match target window")
for column in columns:
for card in column.get("cards", []):
diff --git a/C3.tools/round1-window-activation-fixture-qa.sh b/C3.tools/round1-window-activation-fixture-qa.sh
index 56dba71..889b88d 100755
--- a/C3.tools/round1-window-activation-fixture-qa.sh
+++ b/C3.tools/round1-window-activation-fixture-qa.sh
@@ -9,10 +9,12 @@
OUTPUT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# shellcheck source=build-output-paths.sh
source "$SCRIPT_DIR/build-output-paths.sh"
+SOURCE_ROOT="$OUTPUT_ROOT/C1.source"
APP="$BUILD_CURRENT_APP"
MINIMIZED_REPORT="$BUILD_REPORT_ROOT/round01-window-activation-minimized-report.json"
NORMAL_REPORT="$BUILD_REPORT_ROOT/round01-window-activation-normal-report.json"
APP_ONLY_REPORT="$BUILD_REPORT_ROOT/round01-window-activation-app-only-report.json"
+FAILURE_REPORT="$BUILD_REPORT_ROOT/round01-window-activation-failure-report.json"
REPORT_WAIT="${ALIGNER_ROUND1_ACTIVATION_REPORT_WAIT:-6.0}"
fail() {
@@ -39,6 +41,38 @@
done
fail "current Aligner app did not exit before QA"
+}
+
+assert_activation_budget_source() {
+ /usr/bin/python3 - "$SOURCE_ROOT/Sources/Aligner/Infrastructure/Windows/CGWindowAXWindowService.swift" "$SOURCE_ROOT/Sources/Aligner/QuickSwitchSessionController.swift" <<'PY'
+import re
+import sys
+
+window_service_path, session_path = sys.argv[1:3]
+window_service = open(window_service_path, "r", encoding="utf-8").read()
+session = open(session_path, "r", encoding="utf-8").read()
+
+def fail(message):
+ print(message, file=sys.stderr)
+ sys.exit(1)
+
+cycle_match = re.search(r"finderTabCycleMaximumAttempts\s*=\s*(\d+)", window_service)
+if not cycle_match:
+ fail("Finder tab cycling attempt limit is missing")
+if int(cycle_match.group(1)) != 5:
+ fail(f"Finder tab cycling attempt limit must be 5, got {cycle_match.group(1)}")
+
+budget_match = re.search(r"finderTabActivationBudgetLimit:\s*TimeInterval\s*=\s*([0-9.]+)", window_service)
+if not budget_match:
+ fail("Finder tab activation budget limit is missing")
+if float(budget_match.group(1)) > 1.5:
+ fail(f"Finder tab activation budget must stay at or below 1.5s, got {budget_match.group(1)}")
+
+if "lastActivationDurationMilliseconds" not in session:
+ fail("Quick Switch activation report must expose lastActivationDurationMilliseconds")
+
+print("Finder activation source guardrails passed")
+PY
}
wait_for_activation_report() {
@@ -124,6 +158,8 @@
require(report.get("lastActivationWindowID") == expected_window_id, "activation window must match selected target")
require(report.get("lastActivationResult") == expected_result, "activation result must match expected path")
require(report.get("lastActivationError") is None, "debug activation should not record an error")
+duration = report.get("lastActivationDurationMilliseconds")
+require(isinstance(duration, (int, float)) and duration >= 0, "activation report must include non-negative duration")
require(len(selected_cards) == 1, "activation fixture must expose exactly one selected card")
require(selected_cards[0].get("windowID") == expected_window_id, "selected card must match activation target")
require(selected_cards[0].get("isMinimized") is expected_minimized, "selected card minimized flag must match activation target")
@@ -131,6 +167,7 @@
print(json.dumps({
"commitSource": report.get("lastCommitSource"),
"activationResult": report.get("lastActivationResult"),
+ "activationDurationMilliseconds": report.get("lastActivationDurationMilliseconds"),
"selectedWindowID": root.get("selectedWindowID"),
"quickSwitchVisible": report.get("quickSwitchVisible"),
"selectedIsMinimized": selected_cards[0].get("isMinimized")
@@ -138,9 +175,53 @@
PY
}
+assert_activation_failure_suppression_report() {
+ /usr/bin/python3 - "$FAILURE_REPORT" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+
+with open(path, "r", encoding="utf-8") as file:
+ report = json.load(file)
+
+def require(condition, message):
+ if not condition:
+ print(message, file=sys.stderr)
+ print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
+ sys.exit(1)
+
+root = report.get("rootView", {})
+cards = [
+ card
+ for column in root.get("waterfallColumns", [])
+ for card in column.get("cards", [])
+]
+visible_window_ids = [card.get("windowID") for card in cards]
+
+require(report.get("snapshotLoaded") is True, "activation failure snapshotLoaded must be true")
+require(report.get("quickSwitchVisible") is False, "activation failure still hides Quick Switch after commit")
+require(report.get("lastActivationResult") == "activationFailed", "forced activation failure must be reported")
+require(report.get("lastActivationWindowID") == 40100, "forced activation failure must target the clicked window")
+require(report.get("suppressedActivationWindowCount") == 1, "activation failure must suppress exactly one target")
+require(40100 in report.get("suppressedActivationWindowIDs", []), "failed windowID must be recorded as suppressed")
+require(40100 not in visible_window_ids, "failed activation target must be removed from the projected Waterfall")
+require(report.get("windowCount") == 2, "projected view must keep remaining windows only")
+require(report.get("suppressedCloseTargetCount") == 0, "activation suppression must not use close suppression")
+
+print(json.dumps({
+ "activationResult": report.get("lastActivationResult"),
+ "lastActivationWindowID": report.get("lastActivationWindowID"),
+ "suppressedActivationWindowIDs": report.get("suppressedActivationWindowIDs"),
+ "visibleWindowIDs": visible_window_ids
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+assert_activation_budget_source
stop_current_aligner
"$SCRIPT_DIR/package-app.sh" >&2
-rm -f "$MINIMIZED_REPORT" "$NORMAL_REPORT" "$APP_ONLY_REPORT"
+rm -f "$MINIMIZED_REPORT" "$NORMAL_REPORT" "$APP_ONLY_REPORT" "$FAILURE_REPORT"
"$APP/Contents/MacOS/Aligner" \
--round0-skip-permissions \
@@ -203,5 +284,24 @@
kill "$APP_PID" 2>/dev/null || true
wait "$APP_PID" 2>/dev/null || true
+stop_current_aligner
+
+ALIGNER_DEBUG_WINDOW_ACTIVATION_RESULT=activationFailed "$APP/Contents/MacOS/Aligner" \
+ --round0-skip-permissions \
+ --round01-open-quick-switch \
+ --round01-waterfall-view-mode=vertical \
+ --round01-fixture-window-activation \
+ --round01-disable-screenshot-refresh \
+ --round01-debug-window-activation \
+ --round01-debug-mouse-sequence="click-card:0:1" \
+ --round01-quick-switch-report="$FAILURE_REPORT" &
+
+APP_PID=$!
+wait_for_activation_report "$FAILURE_REPORT"
+swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-no-quick-switch >&2
+assert_activation_failure_suppression_report
+
+kill "$APP_PID" 2>/dev/null || true
+wait "$APP_PID" 2>/dev/null || true
trap - EXIT
stop_current_aligner
diff --git a/C3.tools/round1-window-index-progressive-filter-fixture-qa.sh b/C3.tools/round1-window-index-progressive-filter-fixture-qa.sh
index af1a9f4..396cfae 100755
--- a/C3.tools/round1-window-index-progressive-filter-fixture-qa.sh
+++ b/C3.tools/round1-window-index-progressive-filter-fixture-qa.sh
@@ -564,11 +564,14 @@
root = report.get("rootView", {})
columns = root.get("waterfallColumns", [])
cards = [card for column in columns for card in column.get("cards", [])]
-target_column = next((column for column in columns if column.get("appGroupIndex") == target_app_index), None)
+all_window_ids = sorted(int(card.get("windowID")) for card in cards)
+committed_window_id = root.get("lastCommittedWindowID")
+require(isinstance(committed_window_id, int), "commit must report a committed window ID")
+target_window_id = int(committed_window_id)
require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
-require(report.get("appCount") == fixture_app_count, "fixture appCount must match")
-require(report.get("windowCount") == fixture_app_count * fixture_windows_per_app, "fixture windowCount must match")
+require(report.get("appCount") == fixture_app_count, "multi-window fixture must keep target App visible after suppressing one window")
+require(report.get("windowCount") == fixture_app_count * fixture_windows_per_app - 1, "commit run must suppress the activated target window from visible cards")
require(root.get("waterfallViewMode") == expected_mode, "commit run must use horizontal Waterfall")
require(root.get("keyboardCommandsApplied") == expected_sequence, "commit key sequence must be applied")
require(root.get("appShelfIndexKeyDownCount") == 2, "commit must exercise two physical keyDown events")
@@ -579,45 +582,35 @@
require(root.get("windowIndexKeyPendingAppSymbol") is None, "commit must clear legacy pending App symbol")
require(root.get("lastCommitSource") == "keyboard", "valid shortcut commit must be keyboard sourced")
-require(target_column is not None, "target App column must exist")
-target_cards = target_column.get("cards", [])
-require(target_window_index < len(target_cards), "target window index must exist")
-target_card = target_cards[target_window_index]
-target_window_id = int(target_card.get("windowID"))
-all_window_ids = sorted(int(card.get("windowID")) for card in cards)
-dimmed_ids = sorted(window_id for window_id in all_window_ids if window_id != target_window_id)
-
-require(target_card.get("windowShortcutCode") == target_code, "target card shortcut code must match committed code")
require(root.get("lastCommittedAppGroupIndex") == target_app_index, "commit must target expected App")
require(root.get("lastCommittedWindowIndex") == target_window_index, "commit must target expected window index")
require(root.get("lastCommittedWindowID") == target_window_id, "commit must target expected window ID")
-require(root.get("selectedAppGroupIndex") == target_app_index, "selection must land on target App")
-require(root.get("selectedWindowIndex") == target_window_index, "selection must land on target window")
-require(root.get("selectedWindowID") == target_window_id, "selectedWindowID must match target window")
+require(report.get("suppressedActivationWindowIDs") == [target_window_id], "commit must suppress the activated target window")
+require(report.get("suppressedActivationWindowCount") == 1, "commit must report one suppressed activation window")
+require(target_window_id not in all_window_ids, "committed target window must not remain in visible cards after activation")
snapshot = root.get("lastWindowShortcutPreCommitFilterSnapshot")
require(isinstance(snapshot, dict), "pre-commit filter snapshot must be an object")
require(snapshot.get("phase") == "targetPreCommit", "pre-commit snapshot phase must be targetPreCommit")
require(snapshot.get("targetCode") == target_code, "pre-commit snapshot targetCode must match committed shortcut")
-require(int(snapshot.get("targetWindowID")) == target_window_id, "pre-commit snapshot targetWindowID must match target card")
+require(int(snapshot.get("targetWindowID")) == target_window_id, "pre-commit snapshot targetWindowID must match committed window")
require(sorted_ints(snapshot.get("matchedWindowIDs")) == [target_window_id], "pre-commit snapshot must match only the target window")
-require(sorted_ints(snapshot.get("dimmedWindowIDs")) == dimmed_ids, "pre-commit snapshot must dim all non-target Waterfall windows")
-
-states = target_card.get("visualStates", [])
-opacity = target_card.get("windowShortcutFilterOpacity")
-require(target_card.get("windowShortcutFilterState") == "target", "target card must report target filter state")
-require(is_number(opacity), "target card must expose numeric filter opacity")
-require(0 <= opacity <= 1.01, "target card opacity must be normalized")
-require("shortcutTarget" in states, "target card must expose shortcutTarget visual state")
+require(sorted_ints(snapshot.get("dimmedWindowIDs")) == all_window_ids, "pre-commit snapshot must dim all non-target visible Waterfall windows")
+require(root.get("windowShortcutFilterActive") is True, "commit must preserve target pre-commit filter state for QA evidence")
+require(root.get("windowShortcutFilterPhase") == "targetPreCommit", "commit must preserve target pre-commit filter phase")
+require(root.get("windowShortcutFilterPrefix") == target_code, "commit filter prefix must match target code")
+require(sorted_ints(root.get("windowShortcutFilterMatchedWindowIDs")) == [target_window_id], "commit filter must keep target as matched evidence")
+require(sorted_ints(root.get("windowShortcutFilterDimmedWindowIDs")) == all_window_ids, "commit filter must dim all non-target visible windows")
+require(root.get("windowShortcutFilterMatchedCount") == 1, "commit filter must report one matched window")
+require(root.get("windowShortcutFilterDimmedCount") == len(all_window_ids), "commit filter dimmed count must match visible non-target windows")
for card in cards:
- if int(card.get("windowID")) == target_window_id:
- continue
card_states = card.get("visualStates", [])
card_opacity = card.get("windowShortcutFilterOpacity")
- require(card.get("windowShortcutFilterState") == "dimmed", "non-target cards must remain dimmed in target pre-commit state")
+ require(card.get("windowShortcutFilterState") == "dimmed", "visible non-target cards must remain dimmed in target pre-commit state")
require(is_number(card_opacity), "non-target cards must expose numeric filter opacity")
- require("shortcutDimmed" in card_states, "non-target cards must expose shortcutDimmed in target pre-commit state")
+ require(0 <= float(card_opacity) <= 1.01, "non-target card opacity must be normalized")
+ require("shortcutDimmed" in card_states, "visible non-target cards must expose shortcutDimmed in target pre-commit state")
require("shortcutTarget" not in card_states, "non-target cards must not expose shortcutTarget")
for node in root.get("appShelfItems", []) + root.get("spaceLaneSegments", []):
@@ -639,7 +632,7 @@
"dimmedCount": len(snapshot.get("dimmedWindowIDs", [])),
"lastCommitSource": root.get("lastCommitSource"),
"lastWindowIndexKeyCommitCode": root.get("lastWindowIndexKeyCommitCode"),
- "targetCardState": target_card.get("windowShortcutFilterState"),
+ "suppressedActivationWindowIDs": report.get("suppressedActivationWindowIDs"),
}
with open(summary_file, "w", encoding="utf-8") as file:
json.dump(summary, file, indent=2, ensure_ascii=False)
@@ -782,11 +775,14 @@
root = report.get("rootView", {})
columns = root.get("waterfallColumns", [])
cards = [card for column in columns for card in column.get("cards", [])]
-target_column = next((column for column in columns if column.get("appGroupIndex") == target_app_index), None)
+all_window_ids = sorted(int(card.get("windowID")) for card in cards)
+committed_window_id = root.get("lastCommittedWindowID")
+require(isinstance(committed_window_id, int), "direct commit must report a committed window ID")
+target_window_id = int(committed_window_id)
require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
-require(report.get("appCount") == fixture_app_count, "fixture appCount must match")
-require(report.get("windowCount") == fixture_app_count, "single-window fixture must have one window per App")
+require(report.get("appCount") == fixture_app_count - 1, "single-window fixture must hide the committed target App after activation")
+require(report.get("windowCount") == fixture_app_count - 1, "single-window fixture must hide the committed target window after activation")
require(root.get("waterfallViewMode") == expected_mode, "single-window run must use horizontal Waterfall")
require(root.get("keyboardCommandsApplied") == expected_sequence, "single-window key sequence must direct-commit")
require(root.get("appShelfIndexKeyDownCount") == 1, "single-window direct commit must use one physical keyDown")
@@ -802,17 +798,13 @@
require(root.get("windowShortcutFilterDimmedCount") == 0, "single-window direct commit must not leave dimmed filter count")
require(root.get("lastWindowShortcutPreCommitFilterSnapshot") is None, "single-window direct commit must not expose two-key pre-commit snapshot")
-require(target_column is not None, "target App column must exist")
-target_cards = target_column.get("cards", [])
-require(len(target_cards) == 1, "target App must have exactly one visible window")
-target_card = target_cards[0]
-target_window_id = int(target_card.get("windowID"))
require(root.get("lastCommittedAppGroupIndex") == target_app_index, "direct commit must target expected App")
require(root.get("lastCommittedWindowIndex") == 0, "direct commit must target the only window")
require(root.get("lastCommittedWindowID") == target_window_id, "direct commit must target expected window ID")
-require(root.get("selectedAppGroupIndex") == target_app_index, "selection must land on target App")
-require(root.get("selectedWindowIndex") == 0, "selection must land on the only window")
-require(root.get("selectedWindowID") == target_window_id, "selectedWindowID must match target window")
+require(report.get("suppressedActivationWindowIDs") == [target_window_id], "direct commit must suppress the activated target window")
+require(report.get("suppressedActivationWindowCount") == 1, "direct commit must report one suppressed activation window")
+require(target_window_id not in all_window_ids, "direct-committed target window must not remain in visible cards after activation")
+require(target_app_index not in [column.get("appGroupIndex") for column in columns], "single-window target App must not remain visible after activation")
for card in cards:
opacity = card.get("windowShortcutFilterOpacity")
@@ -833,6 +825,7 @@
"targetWindowID": target_window_id,
"lastCommitSource": root.get("lastCommitSource"),
"lastWindowIndexKeyCommitCode": root.get("lastWindowIndexKeyCommitCode"),
+ "suppressedActivationWindowIDs": report.get("suppressedActivationWindowIDs"),
"cardCount": len(cards)
}
with open(summary_file, "w", encoding="utf-8") as file:
diff --git a/C3.tools/verify-release-artifact.sh b/C3.tools/verify-release-artifact.sh
new file mode 100755
index 0000000..5baaee1
--- /dev/null
+++ b/C3.tools/verify-release-artifact.sh
@@ -0,0 +1,56 @@
+#!/bin/bash
+# Verify a packaged Aligner release directory before it can be referenced by a
+# release manifest or uploaded as a direct-download artifact.
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+OUTPUT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+SOURCE_ROOT="$OUTPUT_ROOT/C1.source"
+INFO_PLIST="$SOURCE_ROOT/Resources/Aligner-Info.plist"
+
+fail() {
+ echo "verify-release-artifact.sh failed: $*" >&2
+ exit 1
+}
+
+usage() {
+ echo "Usage: 03-O/C3.tools/verify-release-artifact.sh <release-dir>" >&2
+}
+
+RELEASE_DIR="${1:-}"
+[ -n "$RELEASE_DIR" ] || { usage; exit 2; }
+[ -d "$RELEASE_DIR" ] || fail "release dir does not exist: $RELEASE_DIR"
+
+VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$INFO_PLIST" 2>/dev/null)" \
+ || fail "unable to read CFBundleShortVersionString"
+BUILD="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$INFO_PLIST" 2>/dev/null)" \
+ || fail "unable to read CFBundleVersion"
+EXPECTED_DMG="Aligner-${VERSION}-build${BUILD}.dmg"
+DMG="$RELEASE_DIR/$EXPECTED_DMG"
+SUMS="$RELEASE_DIR/SHA256SUMS.txt"
+
+[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \
+ || fail "version must be MAJOR.MINOR.PATCH, got: $VERSION"
+[[ "$BUILD" =~ ^[0-9]{8}\.[0-9]{4}$ ]] \
+ || fail "build must be YYYYMMDD.HHMM, got: $BUILD"
+[ -f "$DMG" ] || fail "missing expected DMG: $DMG"
+[ -f "$SUMS" ] || fail "missing SHA256SUMS.txt: $SUMS"
+
+EXPECTED_SHA="$(awk -v name="$EXPECTED_DMG" '$2 == name { print $1 }' "$SUMS")"
+[ -n "$EXPECTED_SHA" ] || fail "SHA256SUMS.txt does not contain $EXPECTED_DMG"
+ACTUAL_SHA="$(shasum -a 256 "$DMG" | awk '{ print $1 }')"
+[ "$EXPECTED_SHA" = "$ACTUAL_SHA" ] || fail "SHA256 mismatch for $EXPECTED_DMG"
+
+if codesign -dv "$DMG" >/tmp/aligner-dmg-codesign.$$ 2>&1; then
+ if ! rg -q "Developer ID Application" /tmp/aligner-dmg-codesign.$$; then
+ rm -f /tmp/aligner-dmg-codesign.$$
+ fail "DMG signature is not Developer ID Application"
+ fi
+else
+ rm -f /tmp/aligner-dmg-codesign.$$
+ fail "DMG is not signed; formal release artifacts must be Developer ID signed"
+fi
+rm -f /tmp/aligner-dmg-codesign.$$
+
+echo "Release artifact verified: $DMG"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 43a5db9..bef662f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,64 @@
# Changelog
+## 0.1.12 / 20260625.2320
+
+- 修复 App Shelf 图标点击误触关闭的问题:图标主体点击优先打开该 App 的第一个窗口,只有明确点到关闭按钮视觉区域才进入关闭确认。
+- 过滤低置信 AX-only 普通窗口候选:非最小化、非全屏且没有高置信 Space 归属的 AX-only 残留不再进入 Quick Switch,避免 App badge 数量和 Waterfall 卡片虚高。
+- 激活明确失败时,本次投影视图会抑制失败 windowID,避免用户继续点到同一张不可激活的 stale 卡片。
+- 增强 App Shelf 鼠标 fixture 和窗口激活 fixture,覆盖 near-close 图标点击不进关闭流、激活失败目标被移除。
+
+## 0.1.11 / 20260625.1705
+
+- 优化 Finder 多页签 / CG-only 窗口激活的等待体验:交互路径增加 1.5 秒预算和阶段耗时日志,避免一次点击长时间卡在系统等待态。
+- Finder 页签循环兜底从 24 轮收敛为最多 5 轮;5 轮无法命中时快速返回失败,不再继续高耗时硬转。
+- Finder 直接 AX / WindowServer 激活后新增前台标题校验:标题不匹配时进入有预算的页签 fallback,避免把“看似激活成功但实际跳错页签”记录为成功。
+- Window activation QA 报告新增 `lastActivationDurationMilliseconds`,并补源码 guardrail 验证 5 轮循环上限和预算字段。
+
+## 0.1.10 / 20260624.2359
+
+- 修复 Quick Switch 窗口卡片点击偶发不准的问题:鼠标命中测试改用动画中的 presentation frame,避免过滤、对齐或横栏滚动动画期间点到视觉卡片却按旧位置提交。
+- 强化真实窗口激活链路:窗口级 AX 动作从 focus / raise 扩展为 focus / main / raise,并在 AX 短暂缺失或首次动作失败时重读一次目标窗口再尝试前置。
+- 激活 / 关闭服务接入 live Space resolver,和 snapshot 使用同一套 SkyLight Space 数据,降低 Finder、多页签、跨 Space 场景的错误 host fallback。
+- `appActivatedOnly` 不再作为窗口卡片精准激活成功语义;它只保留为诊断结果。
+- 修正 Obsidian 跨 Space live QA 在环境不满足时的脚本崩溃,改为明确 skipped 并记录原因。
+
+## 0.1.9 / 20260624.2019
+
+- 新增 Dock reopen 打开 Quick Switch:用户从 Dock 重新点击 / 双击 Aligner 图标时,复用 `Option+Tab` 的 Quick Switch 打开路径。
+- Dock reopen 只负责打开 Quick Switch,不改主题、视图模式或布局选择,全部沿用现有默认逻辑。
+- 增加 Dock reopen fixture QA,并纳入主 UI 回归入口;同步放宽真实 `Option+Tab` 触发 QA 的默认等待,降低事件 tap 启动时序误判。
+
+## 0.1.8 / 20260624.1311
+
+- 将 Aligner 的最低系统版本下探到 macOS 14.0,统一 SwiftPM、Info.plist、AppInfo 和 public manifest 元数据。
+- 为 `ScreenCaptureKit` 的 `includeChildWindows` 增加 macOS 14.2 可用性保护,保证 macOS 14.0 / 14.1 可编译运行。
+- 增加 macOS 14 兼容 QA 入口,并补 release manifest / release config 的最低系统版本 guardrail。
+
+## 0.1.7 / 20260622.1828
+
+- 修复 App Shelf 真实 hover 在 view-model / 截图刷新后丢失的问题:鼠标停在 App 图标上不动时,图标保持放大和上浮,Waterfall 联动保持有效。
+- 纵栏瀑布和横栏瀑布共用同一套 hover 持久化逻辑。
+- 增强 App Shelf fixture QA,新增 view-model rebuild 后 hover 仍保持的纵栏 / 横栏回归。
+
+## 0.1.6 / 20260622.1734
+
+- 修复 App Shelf hover 视觉覆盖不完整的问题:纵栏瀑布和横栏瀑布下都去掉 App 图标外侧蓝色矩形边框。
+- 保留真实 hover App 图标本身放大和轻微上浮,不恢复邻居图标跟随。
+- 增强 App Shelf fixture QA,新增横栏瀑布 case,并断言背景层、容器层、图标层边框均为 0。
+
+## 0.1.5 / 20260622.1717
+
+- 收敛 App Shelf hover 视觉:保留真实 hover App 图标放大效果,但移除图标背后的蓝色矩形底。
+- 取消邻近 App 图标视觉跟随,左右邻居不再放大、不上浮、不暴露 `magneticNeighbor`。
+- 增强 App Shelf fixture QA,验证 hover 背景透明、邻居保持普通尺寸、close button 只属于真实 hover App。
+
+## 0.1.4 / 20260622.1641
+
+- 优化 App Shelf hover 动效为 Magnetic Dock Lite:当前 App 图标放大并轻微上浮,邻近图标做轻量跟随。
+- 邻近 App 只做视觉动效,不触发 hover 语义、不显示关闭按钮、不联动 Waterfall / Space。
+- 用户开启 Reduce Motion 时禁用磁性放大动效。
+- 增强 App Shelf fixture QA,覆盖浅色 / 深色主题、邻近图标动效、hover 语义隔离和横栏瀑布回归。
+
## 0.1.3 / 20260620.1337
- 增加 Option 两位窗口码输入过程中的 Waterfall 实时筛选反馈:第一键筛选 App 时,匹配窗口保持亮,其它 Waterfall 窗口变暗。
diff --git "a/K1.\344\270\232\345\212\241\347\237\245\350\257\206/\346\210\220\346\236\234\345\234\260\345\233\276.md" "b/K1.\344\270\232\345\212\241\347\237\245\350\257\206/\346\210\220\346\236\234\345\234\260\345\233\276.md"
new file mode 100644
index 0000000..7c6c983
--- /dev/null
+++ "b/K1.\344\270\232\345\212\241\347\237\245\350\257\206/\346\210\220\346\236\234\345\234\260\345\233\276.md"
@@ -0,0 +1,81 @@
+# Aligner2 成果地图
+
+> 状态:迁移初稿
+> 用途:让新 Agent 快速知道“已有成果在哪里、哪些是事实源、哪些只是参考”。
+
+## 1. 成果地图规则
+
+- 代码事实以 `03-O/C1.source/` 为准。
+- 人工工程地图以 `03-O/C1.source/CODEGRAPH.md` 为准。
+- 自动结构化图谱以 `03-O/.codebase-memory/graph.db.zst` 为准,只用于查询,不替代源码阅读。
+- 产品、设计、QA、发布结论必须回写各自责任文档,不能只留在会话中。
+
+当前 codebase-memory:
+
+| 项 | 值 |
+| --- | --- |
+| 项目名 | `Users-ar-Projects-Aligner2-03-O` |
+| 产物 | `03-O/.codebase-memory/graph.db.zst` |
+| 索引范围 | `C1.source`、`C3.tools` |
+| 节点 / 边 | `3988` / `12382` |
+| 生成时间 | `2026-06-27T17:07:38Z` |
+| 使用规则 | 查询结果必须回读源码、测试和 QA 证据 |
+
+## 2. 代码成果
+
+| 成果 | 路径 | 说明 |
+| --- | --- | --- |
+| SwiftPM 包 | `03-O/C1.source/Package.swift` | `AlignerCore` library + `Aligner` executable |
+| App 壳 | `03-O/C1.source/Sources/Aligner/` | AppKit、权限、窗口、trigger、session wiring |
+| 核心逻辑 | `03-O/C1.source/Sources/AlignerCore/` | 窗口候选、Space、偏好、权限模型、截图策略等 |
+| 单元测试 | `03-O/C1.source/Tests/AlignerCoreTests/` | 核心策略和授权模型测试 |
+| 工具脚本 | `03-O/C3.tools/` | 构建、发布、诊断、QA fixture |
+
+## 3. Quick Switch 成果
+
+| 成果 | 入口 | 当前状态 |
+| --- | --- | --- |
+| Overlay 生命周期 | `QuickSwitchSessionController.swift` | 已形成 |
+| 三栏 UI | `QuickSwitchRootView.swift` | 已形成,文件较大,后续改动必须读 CODEGRAPH |
+| 窗口候选集 | `QuickSwitchSnapshot.swift`、`WindowEnumerationPolicy.swift` | 已形成 |
+| Space / 显示器 | `SpaceEnumerationPolicy.swift`、`WindowSpaceMappingPolicy.swift` | 已形成,仍需核对多显示器视觉排序差异 |
+| Finder / 多页签 | `FinderTabSpaceAttributionPolicy.swift`、`CGWindowAXWindowService.swift` | 已形成,属于高风险保护区 |
+| 截图与 fallback | `ScreenCaptureKitScreenshotProvider.swift` | 已形成 |
+| 授权模型 | `Sources/AlignerCore/Commerce/` | Round01.5 中,仍需补 QA gate |
+
+## 4. 文档成果
+
+| 类型 | 事实源 | 说明 |
+| --- | --- | --- |
+| 项目事实 | `PROJECT.md` | 项目定位、硬事实、目录职责 |
+| 术语 | `GLOSSARY.md` | 正式术语 |
+| 设计 | `DESIGN.md` | UI / 交互 / 视觉 / 动效规则 |
+| 当前进度 | `02-P/plan-progress.markdown` | 唯一跨角色活跃台账 |
+| 角色协作 | `02-P/角色协作宪章.md` | 当前有效角色边界 |
+| 事件地图 | `02-P/事件地图.md` | 历史压缩地图 |
+| 工程地图 | `03-O/C1.source/CODEGRAPH.md` | 人工代码地图 |
+| QA / 交付流程 | `03-O/K2.项目管理/研发交付验收流程.md` | 当前有效交付门禁 |
+
+## 5. QA 成果
+
+| 成果 | 路径 / 证据 | 当前状态 |
+| --- | --- | --- |
+| 本地构建 | `cd 03-O/C1.source && swift build` | 2026-06-28 通过 |
+| 单元测试 | `cd 03-O/C1.source && swift test` | 2026-06-28 通过,`104 tests, 0 failures` |
+| 授权 fixture | `03-O/C3.tools/round015-entitlement-fixture-qa.sh` | 2026-06-28 通过 |
+| 主 UI 聚合 QA | `03-O/C3.tools/round1-main-ui-qa.sh` | 2026-06-28 通过,日志 `/tmp/aligner2-main-ui-qa-20260628-final.log` |
+| 接收验证记录 | `03-O/K2.项目管理/Aligner2接收验证记录-2026-06-28.md` | 当前迁移包本地接收事实源 |
+
+## 6. 不作为当前成果的材料
+
+- 原始 `.codex-sessionhistory` 和 `.codex/session-history`:只作为追溯材料,不迁入 Aligner2 正文。
+- `X-*`、`旧`、`废除`、`归档` 文档:默认历史参考。
+- `03-O/C2.builds/`:本地构建输出,不作为 Git 项目资产。
+- `.build/`、`.swiftpm/`、`.DS_Store`:本地缓存或系统噪声。
+
+## 7. 当前缺口
+
+- `03-O/.codebase-memory/graph.db.zst` 已生成;迁移后如源码变化明显,需重新索引。
+- `CODEGRAPH.md` 顶层索引已补 Waterfall、Trigger、Permissions、Screenshots、Entitlement 等节点;后续需要继续补各节点细节。
+- 多显示器视觉排序规则与当前代码排序需要核对差异并标注当前事实。
+- Round01.5 发布链路缺正式 release manifest 证据闭环。
diff --git "a/K1.\344\270\232\345\212\241\347\237\245\350\257\206/\350\256\276\350\256\241\346\210\220\346\236\234\345\234\260\345\233\276.md" "b/K1.\344\270\232\345\212\241\347\237\245\350\257\206/\350\256\276\350\256\241\346\210\220\346\236\234\345\234\260\345\233\276.md"
new file mode 100644
index 0000000..a01afa5
--- /dev/null
+++ "b/K1.\344\270\232\345\212\241\347\237\245\350\257\206/\350\256\276\350\256\241\346\210\220\346\236\234\345\234\260\345\233\276.md"
@@ -0,0 +1,66 @@
+# Aligner2 设计成果地图
+
+> 状态:迁移初稿
+> Owner:设计总监
+> 用途:让研发、QA、新 Agent 快速知道当前设计事实源、关键证据和不能擅自改的边界。
+
+## 1. 设计事实源
+
+| 层级 | 路径 | 用途 |
+| --- | --- | --- |
+| 最高设计规则 | `DESIGN.md` | 当前 UI / 交互 / 视觉 / 动效规则 |
+| 术语 | `GLOSSARY.md` | Space Lane、App Shelf、Waterfall 等标准说法 |
+| Round01 设计稿 | `02-P/Round01-QuickSwitch-MVP/02.ui设计稿/` | Quick Switch 初始视觉规格和图稿 |
+| Round01.1 增补 | `02-P/Round01.1-QuickSwitch-MVP2/` | Space filter、横栏、两位码、主题等增补 |
+| 设计决策日志 | `03-O/K1.业务知识/设计决策日志.md` | 长期设计决策 |
+
+## 2. 当前强规则
+
+- Quick Switch 是三栏结构:`Space Lane / App Shelf / Waterfall`。
+- App hover 或键盘聚焦时,Waterfall 应与 App 图标中心对齐;允许右侧留空,不为填满破坏对齐。
+- 真实截图优先;骨架缩略图只在权限、失败、性能或不可捕获时作为降级,不得伪装成真实截图。
+- 最小化窗口必须显示并可恢复。
+- 隐藏 App、幽灵窗口、系统浮层必须过滤。
+- 全屏 App 窗口必须可发现。
+- 系统关键窗口不能被永久遮挡。
+- 横栏 / 纵栏是当前双模式,不能随意删除。
+- 深色主题不是简单反色,需要保留已确认的暗色层次和可读性。
+- AppKit-first 是当前设计/实现边界;Waterfall、App Shelf、Window Card 不得为了省事改成全 SwiftUI 主状态重绘。
+
+## 3. 关键设计证据
+
+建议随 Aligner2 保留或索引的关键证据:
+
+- `02-P/Round01-QuickSwitch-MVP/02.ui设计稿/1.quick-switch-light-glass-v2-effect-20260527.png`
+- `02-P/Round01-QuickSwitch-MVP/02.ui设计稿/2.quick-switch-dark-v3-effect-20260527.png`
+- `01-I/02.3.产品需求-UI设计/SCR-20260526-sinj.png`
+- `01-I/02.3.产品需求-UI设计/SCR-20260526-sinj-2.png`
+- `01-I/02.3.产品需求-UI设计/SCR-20260617-pfku.png`
+- `01-I/02.3.产品需求-UI设计/SCR-20260526-srnp.png`
+- `02-P/Round01-QuickSwitch-MVP/02.ui设计稿/AppIcon-D1-BigBars-Handoff-20260604/`
+
+只作为归档索引,不作为当前设计规则:
+
+- 早期长录屏和 onboarding 截图。
+- 纯视觉参考风格图。
+- 早期三分栏 Excalidraw 草图。
+- 未被 `DESIGN.md`、当前 Round 或冻结记录继承的旧原型。
+
+## 4. 研发不得擅自修改
+
+- 不得把归档原型、竞品参考或早期 HTML 原型升级为当前需求。
+- 不得把真实截图默认降级为骨架。
+- 不得改变 Space / App / Window 关系表达。
+- 不得破坏 App Shelf 与 Waterfall 对齐。
+- 不得改变稳定排序、两位码、Space filter、横栏 / 纵栏键盘语义。
+- 不得为了实现方便改成 WebView、跨平台 UI 栈或全 SwiftUI 主绘制路径。
+- 不得用强品牌色、大面积蓝紫填充、营销页式装饰替代 macOS 原生克制感。
+
+## 5. 设计变更流程
+
+涉及 UI / 交互 / 视觉 / 文案的任务:
+
+1. 先读 `DESIGN.md` 和本文件。
+2. 开发只能提出可行性反馈或实现已确认设计。
+3. 如需改变设计规则,必须由设计总监或 Boss 明确确认。
+4. QA 按设计事实源验证,不用实现便利性替代视觉验收。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/Aligner2\346\216\245\346\224\266\351\252\214\350\257\201\350\256\260\345\275\225-2026-06-28.md" "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/Aligner2\346\216\245\346\224\266\351\252\214\350\257\201\350\256\260\345\275\225-2026-06-28.md"
new file mode 100644
index 0000000..2c0a91f
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/Aligner2\346\216\245\346\224\266\351\252\214\350\257\201\350\256\260\345\275\225-2026-06-28.md"
@@ -0,0 +1,83 @@
+# Aligner2 接收验证记录
+
+> 日期:2026-06-28
+> 范围:验证 Aligner2 迁移包在本机可构建、可测试、可生成关键 QA 证据。
+> 结论:Aligner2 本地接收验证通过;主 UI 聚合 QA 已完整通过。
+
+## 1. 通过项
+
+```bash
+cd /Users/ar/Projects/Aligner2/03-O/C1.source
+swift build
+```
+
+结果:通过,`Build complete!`。
+
+```bash
+cd /Users/ar/Projects/Aligner2/03-O/C1.source
+swift test
+```
+
+结果:通过,`104 tests, 0 failures`。
+
+```bash
+cd /Users/ar/Projects/Aligner2/03-O
+C3.tools/round015-entitlement-fixture-qa.sh
+```
+
+结果:通过。
+
+报告:
+
+- `/Users/ar/Projects/Aligner2/03-O/C2.builds/Z-研发中间产物/qa-reports/round015-entitlement-fixture-qa.json`
+
+```bash
+cd /Users/ar/Projects/Aligner2/03-O
+C3.tools/round1-main-ui-qa.sh
+```
+
+结果:通过。
+
+- `swift-test`
+- `trigger-session`
+- `dock-reopen`
+- `session-snapshot`
+- `space-lane-split-view`
+- `candidate-filter`
+- `screenshot-session`
+- `keyboard-navigation`
+- `vertical-keyboard-app-focus`
+- `window-activation`
+- `multi-page-identity`
+- `mouse-interaction`
+- `app-column-alignment`
+- `horizontal-waterfall`
+- `theme-toggle`
+- `space-lane-dark-hover`
+- `window-index-progressive-filter`
+- `no-quick-switch-residual`
+
+日志:
+
+- `/tmp/aligner2-main-ui-qa-20260628-final.log`
+- `/tmp/aligner-round01-main-ui-qa-20260628_021013`
+
+## 2. 修复记录
+
+初次接收验证暴露出若干 QA 断言口径问题:提交 / 激活后目标窗口会被记录到 `suppressedActivationWindowIDs`,但部分 fixture 仍要求该目标窗口继续出现在当前可见卡片中。
+
+已修正的 QA fixture:
+
+- `03-O/C3.tools/round1-vertical-keyboard-app-focus-fixture-qa.sh`
+- `03-O/C3.tools/round1-horizontal-waterfall-fixture-qa.sh`
+- `03-O/C3.tools/round1-window-index-progressive-filter-fixture-qa.sh`
+
+修正性质:
+
+- 验证提交记录、pre-commit snapshot、目标窗口抑制记录和可见卡片状态一致。
+- 不改变产品源码和用户可见行为。
+- 不把已激活后被抑制的目标窗口错误要求为仍在 Waterfall 可见。
+
+## 3. 当前判断
+
+Aligner2 作为迁移实验包的本地接收验证已通过。仍未完成的是正式发布外部条件:Developer ID、notarization、Gatekeeper、macOS 14 真实环境 Phase C,以及正式 release tag / manifest 闭环。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/QA\346\210\220\346\236\234\345\234\260\345\233\276.md" "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/QA\346\210\220\346\236\234\345\234\260\345\233\276.md"
new file mode 100644
index 0000000..6f601e7
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/QA\346\210\220\346\236\234\345\234\260\345\233\276.md"
@@ -0,0 +1,115 @@
+# Aligner2 QA 成果地图
+
+> 状态:迁移初稿
+> 用途:让新 QA / 新 Agent 快速知道有哪些门禁、脚本、证据和风险。
+
+## 1. QA 事实源
+
+| 类型 | 路径 | 用途 |
+| --- | --- | --- |
+| 交付流程 | `03-O/K2.项目管理/研发交付验收流程.md` | 开发自查、代码审核、QA、Boss 验收门禁 |
+| 当前任务状态 | `02-P/plan-progress.markdown` | 活跃任务和 Go / No-Go |
+| 工程地图 | `03-O/C1.source/CODEGRAPH.md` | 按节点定位 QA 入口 |
+| QA 脚本 | `03-O/C3.tools/` | 唯一可执行 QA 入口 |
+| Round QA 计划 | `02-P/Round*/03.qa/` | 单轮测试计划、矩阵、执行记录 |
+| 构建报告目录 | `03-O/C2.builds/Z-研发中间产物/qa-reports/` | 本地生成,不进 Git |
+
+## 2. 核心门禁
+
+| 门禁 | 命令 / 入口 | 说明 |
+| --- | --- | --- |
+| Swift build | `cd 03-O/C1.source && swift build` | 基础编译 |
+| Swift test | `cd 03-O/C1.source && swift test` | 核心单测 |
+| 本地 QA 包 | `03-O/C3.tools/package-app.sh` | 生成本地 `.app` / `.dmg` |
+| 主 UI QA | `03-O/C3.tools/round1-main-ui-qa.sh` | 聚合 Quick Switch 主要 fixture |
+| 性能门禁 | `03-O/C3.tools/round1-main-ui-performance-gate.sh` | 200 窗口、lifecycle、hang 风险 |
+| macOS 14 编译门禁 | `03-O/C3.tools/round015-macos14-compatibility-qa.sh` | macOS 14 编译和关键 fixture |
+| 授权 fixture | `03-O/C3.tools/round015-entitlement-fixture-qa.sh` | Round01.5 Pro 授权状态 |
+| 发布 guardrail | `03-O/C3.tools/release-app.sh`、`generate-release-manifest.sh`、`verify-release-artifact.sh` | 正式发布链路,不等同本地 QA 包 |
+
+## 3. 关键脚本组
+
+Session / overlay:
+
+- `round1-trigger-session-qa.sh`
+- `round1-quick-switch-entry-qa.sh`
+- `round1-overlay-qa.sh`
+- `round1-dock-reopen-fixture-qa.sh`
+- `round1-permission-gate-qa.sh`
+- `round1-system-critical-fixture-qa.sh`
+
+窗口 / 候选 / 截图:
+
+- `round1-candidate-filter-fixture-qa.sh`
+- `round1-session-snapshot-qa.sh`
+- `round1-screenshot-session-qa.sh`
+- `round1-minimized-window-qa.sh`
+- `round1-finder-tabs-live-qa.sh`
+- `round1-obsidian-space-activation-qa.sh`
+- `round1-window-activation-fixture-qa.sh`
+
+UI / 交互:
+
+- `round1-keyboard-navigation-fixture-qa.sh`
+- `round1-vertical-keyboard-app-focus-fixture-qa.sh`
+- `round1-mouse-interaction-fixture-qa.sh`
+- `round1-app-shelf-fixture-qa.sh`
+- `round1-waterfall-fixture-qa.sh`
+- `round1-horizontal-waterfall-fixture-qa.sh`
+- `round1-app-column-alignment-fixture-qa.sh`
+- `round1-space-filter-fixture-qa.sh`
+- `round1-theme-toggle-fixture-qa.sh`
+- `round1-window-index-progressive-filter-fixture-qa.sh`
+
+真实环境 / 半自动:
+
+- `round1-dual-screen-placement-qa.sh`
+- `round1-fullscreen-overlay-qa.sh`
+- `round1-alttab-conflict-qa.sh`
+
+## 4. 版本与验收状态
+
+| 版本 / build | 状态 | 说明 |
+| --- | --- | --- |
+| `0.1.0 / 20260619.2255` | frozen baseline | 明确可回滚冻结基线 |
+| `0.1.1 / 20260620.0136` | frozen | 深浅主题 |
+| `0.1.2 / 20260620.0319` | frozen | 深色 Space hover 可读性 |
+| `0.1.3 / 20260620.1337` | historical tagged candidate | 旧 Git HEAD / tag 基线 |
+| `0.1.12 / 20260625.2320` | frozen local QA baseline | tag `v0.1.12-build20260625.2320`,本地 manifest `sourceStatus=clean`;不是正式官网 release |
+
+## 5. Aligner2 接收 QA 结果
+
+2026-06-28 已完成本地接收验证:
+
+```bash
+cd /Users/ar/Projects/Aligner2/03-O/C1.source
+swift build
+swift test
+
+cd /Users/ar/Projects/Aligner2/03-O
+C3.tools/round015-entitlement-fixture-qa.sh
+C3.tools/round1-main-ui-qa.sh
+```
+
+结果:
+
+- `swift build`:通过。
+- `swift test`:通过,`104 tests, 0 failures`。
+- `round015-entitlement-fixture-qa.sh`:通过,报告 `03-O/C2.builds/Z-研发中间产物/qa-reports/round015-entitlement-fixture-qa.json`。
+- `round1-main-ui-qa.sh`:通过,运行目录 `/tmp/aligner-round01-main-ui-qa-20260628_210821`。
+
+本次 QA 脚本修正:
+
+- `round1-vertical-keyboard-app-focus-fixture-qa.sh`
+- `round1-horizontal-waterfall-fixture-qa.sh`
+- `round1-window-index-progressive-filter-fixture-qa.sh`
+
+修正性质:提交 / 激活后目标窗口会进入 `suppressedActivationWindowIDs`,QA 断言应验证提交记录、pre-commit snapshot 和抑制记录,不应继续要求目标窗口留在当前可见卡片里。这是验收断言口径修正,不是产品行为变更。
+
+性能、签名、公证、Gatekeeper、macOS 14 真机验证仍可能需要真实环境和外部条件;不能运行时必须记录原因。
+
+## 6. 当前 QA 风险
+
+- `0.1.12` 已完成本地 QA 候选代码冻结和本地 manifest;仍不是正式官网 release。
+- macOS 14 Phase C 未完成。
+- Developer ID / notarization / Gatekeeper 正式发布链路未完成。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\346\226\260\347\252\227\345\217\243\346\216\245\346\211\213\346\214\207\345\215\227.md" "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\346\226\260\347\252\227\345\217\243\346\216\245\346\211\213\346\214\207\345\215\227.md"
new file mode 100644
index 0000000..bcd9447
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\346\226\260\347\252\227\345\217\243\346\216\245\346\211\213\346\214\207\345\215\227.md"
@@ -0,0 +1,107 @@
+# Aligner2 新窗口接手指南
+
+> 用途:验证 Aligner2 是否能让新电脑、新 session、新 Agent 不依赖旧长会话接上工作。
+
+## 1. 接手目标
+
+接手者只读取 Aligner2 项目内文件,应该能回答:
+
+- 项目是什么,当前产品重点是什么。
+- 当前做到哪,哪些是已完成成果,哪些仍在进行。
+- 自己是什么角色,可以领取哪些任务,不能碰哪些东西。
+- 源码、工程地图、QA 门禁和发布规则在哪里。
+- 下一步应做什么,遇到冲突应退回给谁。
+
+## 2. 必读顺序
+
+根目录 `AGENTS.md` 是唯一入口;本指南不另设独立读取顺序。新窗口接手时按 `AGENTS.md` 的顺序执行:
+
+1. `PROJECT.md`
+2. `02-P/plan-progress.markdown`
+3. `02-P/角色协作宪章.md`
+4. `03-O/K2.项目管理/新窗口接手指南.md`
+5. `GLOSSARY.md`
+6. `INDEX.md`
+7. 就近的 `AGENTS.md`
+8. `03-O/C1.source/CODEGRAPH.md`
+9. `DESIGN.md`
+10. 当前任务相关的 `02-P/` 下有效 Round / PRD / QA / 架构文档
+
+不得从旧长会话、旧 `GTD.md` 或历史 Round TODO 直接领取活跃任务。
+
+## 3. 当前事实快照
+
+| 项 | 当前值 |
+| --- | --- |
+| 工作区 | `/Users/ar/Projects/Aligner2` |
+| Git 根目录 | `/Users/ar/Projects/Aligner2/03-O` |
+| SwiftPM 根目录 | `/Users/ar/Projects/Aligner2/03-O/C1.source` |
+| 当前产品重点 | Quick Switch / 快捷三栏 |
+| 当前迁移基线 | 旧 Aligner dirty 工作树 |
+| 最新本地候选 | `0.1.12 / 20260625.2320` |
+| 当前 tag 基线 | `v0.1.3-build20260620.1337` |
+| 人工工程地图 | `03-O/C1.source/CODEGRAPH.md` |
+| 自动代码图谱 | `03-O/.codebase-memory/graph.db.zst` |
+
+## 4. 角色领取规则
+
+- PMO:只能维护台账、事件地图、交接和风险,不碰代码。
+- 产品经理:只能维护需求、业务规则和验收口径,不碰代码和设计方案。
+- 设计总监:只能维护设计规则和设计验收,不碰源码和 QA 放行。
+- 架构师:只能维护技术边界和方案,不替产品、设计、QA 做结论。
+- 开发:只能实现已确认任务,不改需求、设计、架构边界和发布规则。
+- 代码审核员:只能审查并提出问题,不把 review 意见改成架构/需求/设计决策。
+- QA:只能验证和出证据,不改生产源码、需求或设计。
+- 发布 / 运营:只能在 QA 放行后处理包、manifest、下载源和上线证据。
+
+如果任务归属不清,回写 `plan-progress.markdown` 的 proposal / blocker,不自行开工。
+
+## 5. 接手自检命令
+
+```bash
+cd /Users/ar/Projects/Aligner2/03-O
+git status --short --branch
+git diff --check
+codebase-memory-mcp cli list_projects '{}'
+
+cd /Users/ar/Projects/Aligner2/03-O/C1.source
+swift build
+swift test
+```
+
+GUI、签名、公证、Gatekeeper、macOS 14 真机验证按 `03-O/K2.项目管理/QA成果地图.md` 决定是否运行。
+
+## 6. 不迁移的内容
+
+- 原始长会话记录。
+- `.build/`、`.swiftpm/`、DerivedData、`.DS_Store`。
+- 旧 `C2.builds/X-旧版build成果/` 和 `C2.builds/Z-研发中间产物/` 的大体积产物。
+- 未被事件地图、成果地图或台账吸收的旧口头结论。
+
+## 7. 成功判定
+
+新 Agent 只读本指南和必读顺序文件后,能独立完成:
+
+- 说明当前项目状态和迁移基线。
+- 选出自己角色可领取的任务。
+- 定位源码和 QA 入口。
+- 不依赖旧 session 继续推进一个小任务。
+
+若做不到,缺口必须回写 `02-P/plan-progress.markdown` 的 `A2-MIG-005`。
+
+## 8. 本次接手演练结果
+
+2026-06-28 已完成一次新 Agent 接手演练。结论:可接手。
+
+已验证:
+
+- 能说明 Aligner2 的迁移目的、当前产品重点、Git / SwiftPM 根目录。
+- 能识别 `plan-progress.markdown` 是唯一活跃任务入口。
+- 能说明角色边界和不可踩踏规则。
+- 能定位 `CODEGRAPH.md`、`codebase-memory`、QA 成果地图和接收验证记录。
+- 能指出外部发布条件仍未完成,不把本地接收 QA 等同正式发布。
+
+演练反馈已处理:
+
+- 统一本指南与根 `AGENTS.md` 的读取顺序。
+- 将主 UI QA 最终通过状态回写到台账和 QA 成果地图。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\347\240\224\345\217\221\344\272\244\344\273\230\351\252\214\346\224\266\346\265\201\347\250\213.md" "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\347\240\224\345\217\221\344\272\244\344\273\230\351\252\214\346\224\266\346\265\201\347\250\213.md"
index f2828d5..e1714ea 100644
--- "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\347\240\224\345\217\221\344\272\244\344\273\230\351\252\214\346\224\266\346\265\201\347\250\213.md"
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\347\240\224\345\217\221\344\272\244\344\273\230\351\252\214\346\224\266\346\265\201\347\250\213.md"
@@ -2,8 +2,8 @@
> 状态:当前有效
> 建立日期:2026-05-30
-> 适用范围:Aligner 每个涉及代码实现、脚本实现、构建产物或用户可见行为变更的开发事项。
-> 任务记录入口:`/Users/ar/Projects/Aligner/02-P/GTD.md`
+> 适用范围:Aligner2 每个涉及代码实现、脚本实现、构建产物或用户可见行为变更的开发事项。
+> 任务记录入口:`/Users/ar/Projects/Aligner2/02-P/plan-progress.markdown`
## 流程定义
@@ -20,7 +20,7 @@
-> 需要用户真机验收时再通知 Boss
```
-只有代码审核和 QA 都通过,事项才允许在 `GTD.md` 中标记为完成。
+只有代码审核和 QA 都通过,事项才允许在 `plan-progress.markdown` 中标记为完成。
## 角色责任
@@ -39,11 +39,11 @@
- QA 发现问题时,回到开发修复;修复后必须再次执行相关 QA。
- 自动化能覆盖的优先自动化;窗口层级、全屏、双屏、Split View、系统关键窗口等真实 macOS 场景可使用半自动或手测矩阵补足。
- 用户不作为常规 QA;用户是最后一个验收者,只在 QA 认为需要真机体验确认时通知。
-- 每个事项的状态、责任角色、Review 角色、QA 结论和关键证据应记录在 `GTD.md` 对应 Round / 会议 / Todo 下。
+- 每个事项的状态、责任角色、Review 角色、QA 结论和关键证据应记录在 `plan-progress.markdown`;Round / PRD / QA 文档只作为细节和证据附件。
## 状态记录规范
-`GTD.md` 是当前唯一 Todo 扎口文件。任务状态使用以下格式:
+`02-P/plan-progress.markdown` 是当前唯一跨角色活跃任务扎口文件。任务状态使用以下格式:
- `[ ]`:todo。
- `[ ] [in-progress]`:正在处理。
@@ -61,8 +61,8 @@
## 当前配套依据
-- `/Users/ar/Projects/Aligner/02-P/GTD.md`:统一 Todo、状态和验收证据入口。
-- `/Users/ar/Projects/Aligner/02-P/AGENTS.md`:过程文档规则,要求测试计划和验收记录写入当前 Round 的 QA 区域,长期流程经验沉淀到 `03-O/K2.项目管理/`。
+- `/Users/ar/Projects/Aligner2/02-P/plan-progress.markdown`:统一活跃任务、状态、阻塞和验收证据入口。
+- `/Users/ar/Projects/Aligner2/02-P/AGENTS.md`:过程文档规则,要求测试计划和验收记录写入当前 Round 的 QA 区域,长期流程经验沉淀到 `03-O/K2.项目管理/`。
- `/Users/ar/.codex/rules/qa-workflow.md`:QA 通用规则,要求覆盖真实用户节奏、窗口层级、过渡过程和可复核证据。
- 当前 Round 的 PRD / QA / 架构文档:定义本轮范围、验收口径和具体测试矩阵。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\350\277\201\347\247\273\350\265\204\344\272\247\346\270\205\345\215\225.md" "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\350\277\201\347\247\273\350\265\204\344\272\247\346\270\205\345\215\225.md"
new file mode 100644
index 0000000..0625427
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\350\277\201\347\247\273\350\265\204\344\272\247\346\270\205\345\215\225.md"
@@ -0,0 +1,37 @@
+# Aligner2 迁移资产清单
+
+> 状态:迁移初稿
+> 目标:说明哪些从 Aligner 迁入 Aligner2,哪些只做索引或排除。
+
+## 1. 已迁入
+
+| 类别 | 来源 | 目标 | 备注 |
+| --- | --- | --- | --- |
+| Git 历史 | `/Users/ar/Projects/Aligner/03-O/.git` | `/Users/ar/Projects/Aligner2/03-O/.git` | 使用 clone 迁入,避免复制旧 worktree 绝对路径 |
+| 当前工作树 | `/Users/ar/Projects/Aligner/03-O` | `/Users/ar/Projects/Aligner2/03-O` | 同步当前 dirty 工作树,排除 build/cache |
+| 输入资料 | `/Users/ar/Projects/Aligner/01-I` | `/Users/ar/Projects/Aligner2/01-I` | 保留研究、原点、设计参考 |
+| 过程资料 | `/Users/ar/Projects/Aligner/02-P` | `/Users/ar/Projects/Aligner2/02-P` | 保留 Round、PRD、QA、计划文档 |
+| 根文档 | `AGENTS.md`、`PROJECT.md`、`INDEX.md`、`DESIGN.md`、`GLOSSARY.md` | 同名路径 | 已更新当前入口路径 |
+
+## 2. 明确排除
+
+| 类别 | 路径 | 原因 |
+| --- | --- | --- |
+| SwiftPM 缓存 | `03-O/.build/`、`03-O/C1.source/.build/` | 可再生成,体积大 |
+| 本地构建产物 | `03-O/C2.builds/X-旧版build成果/` | 体积大,历史包不放迁移正文 |
+| 研发中间产物 | `03-O/C2.builds/Z-研发中间产物/` | QA report / trace / app 副本不作为迁移核心 |
+| 原始会话记录 | `.codex-sessionhistory/`、`.codex/session-history/` | 不搬长会话噪声,只提炼结论 |
+| 系统噪声 | `.DS_Store` | 无项目价值 |
+
+## 3. 风险说明
+
+- Aligner2 当前代码基线继承旧 Aligner 的 dirty 工作树,不是干净 tag。
+- `CHANGELOG.md` 与 Info.plist 已到 `0.1.12 / 20260625.2320`,但 Git HEAD 仍为 `v0.1.3-build20260620.1337`。
+- `C2.builds/` 被排除后,历史 DMG 不在 Aligner2 内;可回滚性应依赖源码 commit、tag、manifest、SHA256 和外部分发资产。
+- 未提交的 `CODEGRAPH.md`、Round01.5 授权代码、release 脚本等已作为当前工作树迁入,但仍需要后续提交治理。
+
+## 4. 后续建议
+
+- 在 Aligner2 中拆分一次治理提交:规则 / 文档 / CODEGRAPH / .gitignore。
+- 再拆源码提交:macOS 14、授权模型、release 脚本、Quick Switch 修复、QA 脚本。
+- 正式迁移新电脑前,至少完成一次 `swift build`、`swift test` 和 codebase-memory 索引。
diff --git a/README.md b/README.md
index 062459b..eb9da88 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
# 03-O — 输出成果
> O = Output。本目录存放 Aligner 项目所有产出物。
-> **IN git** — 需要版本管理的都在这里。
+> **IN git** — 源码、工具、知识和规则放在这里;构建产物默认不进入 Git。
> Git 仓库根目录:`03-O/`。不要在项目根目录或 `03-O/C1.source/` 单独初始化 Git 仓库。
## 目录结构
@@ -9,10 +9,10 @@
| 路径 | 内容 | git |
|------|------|:---:|
| `C1.source/` | **源代码** — Sources/ + Tests/ + Package.swift | ✅ |
-| `C2.builds/` | **构建输出** — 根目录只保留当前发布包目录、旧版归档、研发中间产物 | ✅ |
-| `C2.builds/Aligner-<Version>-build<YYYYMMDD.HHMM>/` | **当前最新包** — 最新可分发 DMG + `SHA256SUMS.txt` | ✅ |
-| `C2.builds/X-旧版build成果/` | **历史存档** — 旧 DMG / 旧发布包归档,不放旧 `.app` | ✅ |
-| `C2.builds/Z-研发中间产物/` | **内部工作区** — 当前 `.app`、QA 报告、trace、截图样本、图标试验 | ✅ |
+| `C2.builds/` | **本地构建输出** — 根目录只保留当前发布包目录、旧版归档、研发中间产物 | 🚫 |
+| `C2.builds/Aligner-<Version>-build<YYYYMMDD.HHMM>/` | **当前最新包** — 最新可分发 DMG + `SHA256SUMS.txt` + release manifest | 🚫 |
+| `C2.builds/X-旧版build成果/` | **历史存档** — 旧 DMG / 旧发布包归档,不放旧 `.app` | 🚫 |
+| `C2.builds/Z-研发中间产物/` | **内部工作区** — 当前 `.app`、QA 报告、trace、截图样本、图标试验 | 🚫 |
| `C3.tools/` | **工具脚本** — 打包脚本、窗口诊断 | ✅ |
| `K1.业务知识/` | **业务知识** — 窗口行为、Space 枚举、AX API、架构共识 | ✅ |
| `K2.项目管理/` | **项目管理知识** — SOP、协作规范、经验教训 | ✅ |
@@ -21,6 +21,7 @@
- `03-O/` 是唯一 Git 仓库根目录。
- `C1.source/`、`C3.tools/`、`K1.业务知识/`、`K2.项目管理/` 都随 `03-O/` 一起版本管理。
+- `C2.builds/` 是本地构建输出和归档目录,不进入 Git;正式可回滚版本通过源码 commit、release tag、release manifest、DMG SHA256 和 GitHub Release asset 绑定。
- `C1.source/` 只是 SwiftPM / Xcode 工程根,不是 Git 根。
- 过程文档 `02-P/` 和输入材料 `01-I/` 不进入该 Git 仓库。
@@ -28,11 +29,12 @@
- `C2.builds/` 根目录永远保持清爽:只放一个当前最新包目录、`X-旧版build成果/`、`Z-研发中间产物/`。
- 当前最新包目录命名为 `Aligner-<Version>-build<YYYYMMDD.HHMM>/`,目录内放当前 `.dmg` 和 `SHA256SUMS.txt`。
+- 正式测试包 / 发布包还必须包含 release manifest;manifest 是发布证据,不代表 `C2.builds/` 进入 Git。
- `X-旧版build成果/` 只存历史可恢复包;旧 `.app` 不进入归档(避免 macOS 搜出一堆旧 App 影响系统表现)。
- `Z-研发中间产物/current/Aligner.app` 是研发和 QA 脚本使用的当前可运行 `.app`,不是分发包。
- `Z-研发中间产物/qa-reports/` 存放 Round QA JSON、trace、XML、截图样本等中间产物,不允许散落在 `C2.builds/` 根目录。
- 可验收 / 可分发 DMG 命名格式:`Aligner-<Version>-build<YYYYMMDD.HHMM>.dmg`
-- DMG 文件名中的 version / build 必须与 `Z-研发中间产物/current/Aligner.app/Contents/Info.plist` 中的 `CFBundleShortVersionString` / `CFBundleVersion` 一致。
+- DMG 文件名中的 version / build 必须与 `C1.source/Resources/Aligner-Info.plist` 中的 `CFBundleShortVersionString` / `CFBundleVersion` 一致;`Z-研发中间产物/current/Aligner.app` 只是 QA 可运行副本,不是版本事实源。
- 本地开发包优先使用稳定代码签名身份。若只能使用 ad-hoc 签名,Accessibility / Screen Recording 的 TCC 授权可能在重打包后失效,表现为系统开关已打开但 App 内仍显示未授权。
- `C3.tools/package-app.sh` 默认只打包不启动;需要打包后自动启动时显式设置 `ALIGNER_OPEN_AFTER_BUILD=1`。
- `C3.tools/check-signing.sh` 可检查本机当前会使用的签名身份。推荐长期固定 `Aligner Local Code Signing`;未创建该身份时,可临时使用 `ALIGNER_CODE_SIGN_IDENTITY` 指定现有稳定本地签名身份。
--
Gitblit v1.9.3