From 343237725f65445bab306e3fe4cdbd10949d4d4b Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Sat, 13 Jun 2026 18:47:36 +0800
Subject: [PATCH] Fix Finder tab page activation
---
C1.source/Sources/AlignerCore/Windows/FinderTabSpaceAttributionPolicy.swift | 32 ++
C3.tools/round1-finder-tabs-live-qa.sh | 138 +++++++---
C1.source/Sources/Aligner/QuickSwitchRootView.swift | 16 +
C1.source/Resources/Aligner-Info.plist | 4
C1.source/Sources/Aligner/Infrastructure/Windows/CGWindowAXWindowService.swift | 507 +++++++++++++++++++++++++++++++++++++--
C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift | 52 ++++
6 files changed, 675 insertions(+), 74 deletions(-)
diff --git a/C1.source/Resources/Aligner-Info.plist b/C1.source/Resources/Aligner-Info.plist
index fdeb485..6118a38 100644
--- a/C1.source/Resources/Aligner-Info.plist
+++ b/C1.source/Resources/Aligner-Info.plist
@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
- <string>0.0.64</string>
+ <string>0.0.65</string>
<key>CFBundleVersion</key>
- <string>20260613.1606</string>
+ <string>20260613.1843</string>
<key>LSMinimumSystemVersion</key>
<string>26.0</string>
<key>NSHighResolutionCapable</key>
diff --git a/C1.source/Sources/Aligner/Infrastructure/Windows/CGWindowAXWindowService.swift b/C1.source/Sources/Aligner/Infrastructure/Windows/CGWindowAXWindowService.swift
index b1b1cd3..e03dddb 100644
--- a/C1.source/Sources/Aligner/Infrastructure/Windows/CGWindowAXWindowService.swift
+++ b/C1.source/Sources/Aligner/Infrastructure/Windows/CGWindowAXWindowService.swift
@@ -14,6 +14,17 @@
private let privateSpaceActivationBridge: PrivateSpaceActivationBridge?
private var restorableAXWindowsByID: [UInt32: RestorableAXWindow] = [:]
+ private struct FinderTabControl {
+ let button: AXUIElement
+ let tabGroup: AXUIElement?
+ }
+
+ private struct KeyboardShortcut {
+ let name: String
+ let keyCode: CGKeyCode
+ let flags: CGEventFlags
+ }
+
init(
spaceIDsByWindowIDProvider: @escaping ([UInt32]) -> [UInt32: [UInt64]],
appCategorizer: any AppCategorizer = BundleIDAppCategorizer(),
@@ -118,7 +129,8 @@
: .activationFailed
}
- func activate(window: AlignerWindow) throws -> WindowActivationResult {
+ func activate(window requestedWindow: AlignerWindow) throws -> WindowActivationResult {
+ let window = refreshedFinderWindowForPreciseAction(requestedWindow, operation: "windowActivation")
DevelopmentDiagnostics.log("windowActivation.activate.start", [
"windowID": window.id,
"identifierSource": String(describing: window.identifierSource),
@@ -268,7 +280,8 @@
: .appActivatedOnly
}
- func close(window: AlignerWindow) throws -> WindowCloseResult {
+ func close(window requestedWindow: AlignerWindow) throws -> WindowCloseResult {
+ let window = refreshedFinderWindowForPreciseAction(requestedWindow, operation: "windowClose")
DevelopmentDiagnostics.log("windowClose.window.start", [
"windowID": window.id,
"identifierSource": String(describing: window.identifierSource),
@@ -345,6 +358,59 @@
processIdentifier: matchedAXWindow.processIdentifier,
attempt: "initial"
)
+ }
+
+ private func refreshedFinderWindowForPreciseAction(
+ _ window: AlignerWindow,
+ operation: String
+ ) -> AlignerWindow {
+ guard isFinderCGOnlyPageCandidate(window) else { return window }
+
+ guard let refreshedWindow = try? allWindows().first(where: { refreshed in
+ refreshed.id == window.id
+ && refreshed.identifierSource == window.identifierSource
+ && refreshed.app.bundleIdentifier == window.app.bundleIdentifier
+ }) else {
+ DevelopmentDiagnostics.log("\(operation).finderRefresh.missing", [
+ "windowID": window.id,
+ "spaceIDCount": window.spaceIDs.count,
+ "hasFrame": window.frame != nil
+ ])
+ return window
+ }
+
+ let mergedWindow = AlignerWindow(
+ id: window.id,
+ app: window.app.processIdentifier == nil ? refreshedWindow.app : window.app,
+ title: window.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ ? refreshedWindow.title
+ : window.title,
+ identifierSource: window.identifierSource,
+ isMinimized: window.isMinimized || refreshedWindow.isMinimized,
+ isFullscreen: window.isFullscreen || refreshedWindow.isFullscreen,
+ isGhost: window.isGhost,
+ frame: window.frame ?? refreshedWindow.frame,
+ spaceIDs: window.spaceIDs.isEmpty ? refreshedWindow.spaceIDs : window.spaceIDs
+ )
+
+ if mergedWindow.spaceIDs != window.spaceIDs
+ || mergedWindow.isFullscreen != window.isFullscreen
+ || (window.frame == nil && refreshedWindow.frame != nil) {
+ DevelopmentDiagnostics.log("\(operation).finderRefresh.merged", [
+ "windowID": window.id,
+ "originalSpaceIDCount": window.spaceIDs.count,
+ "refreshedSpaceIDCount": refreshedWindow.spaceIDs.count,
+ "mergedSpaceIDCount": mergedWindow.spaceIDs.count,
+ "originalIsFullscreen": window.isFullscreen,
+ "refreshedIsFullscreen": refreshedWindow.isFullscreen,
+ "mergedIsFullscreen": mergedWindow.isFullscreen,
+ "originalHasFrame": window.frame != nil,
+ "refreshedHasFrame": refreshedWindow.frame != nil,
+ "mergedHasFrame": mergedWindow.frame != nil
+ ])
+ }
+
+ return mergedWindow
}
private func closeButton(for axWindow: AXWindowMetadata) -> AXUIElement? {
@@ -635,13 +701,22 @@
let raiseBeforeResult = raiseWindowResult(host.element)
let selectedTab = selectFinderTab(window, in: host)
Thread.sleep(forTimeInterval: 0.05)
- let focusAfterResult = focusWindowResult(host.element, processIdentifier: processIdentifier)
- let raiseAfterResult = raiseWindowResult(host.element)
+ let focusAfterResult: AXActionResult?
+ let raiseAfterResult: AXActionResult?
+ 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
+ } else {
+ focusAfterResult = focusWindowResult(host.element, processIdentifier: processIdentifier)
+ raiseAfterResult = raiseWindowResult(host.element)
+ }
let didFocusOrRaise = focusBeforeResult.success
|| raiseBeforeResult.success
- || focusAfterResult.success
- || raiseAfterResult.success
+ || (focusAfterResult?.success ?? false)
+ || (raiseAfterResult?.success ?? false)
let activated = selectedTab
&& (hostPrivateActivationSucceeded || applicationActivated || didFocusOrRaise)
@@ -657,8 +732,8 @@
"selectedTab": selectedTab,
"focusBeforeAXError": String(describing: focusBeforeResult.error),
"raiseBeforeAXError": String(describing: raiseBeforeResult.error),
- "focusAfterAXError": String(describing: focusAfterResult.error),
- "raiseAfterAXError": String(describing: raiseAfterResult.error),
+ "focusAfterAXError": focusAfterResult.map { String(describing: $0.error) } ?? "skippedAfterTabSelection",
+ "raiseAfterAXError": raiseAfterResult.map { String(describing: $0.error) } ?? "skippedAfterTabSelection",
"activated": activated
])
@@ -692,8 +767,28 @@
guard let windowID = metadata.windowID else { return false }
return !Set(candidateSpacesByID[windowID] ?? []).isDisjoint(with: targetSpaceIDs)
}
- let hostCandidates = spaceCompatibleCandidates.isEmpty ? candidates : spaceCompatibleCandidates
- let targetFrame = cgWindowFrame(for: window) ?? window.frame
+ let spaceScopedCandidates = spaceCompatibleCandidates.isEmpty ? candidates : spaceCompatibleCandidates
+ let selectableHostCandidates = spaceScopedCandidates.filter { metadata in
+ finderHostCanRepresentTarget(window, in: metadata)
+ }
+
+ let hostCandidates: [AXWindowMetadata]
+ if targetSpaceIDs.isEmpty {
+ guard !selectableHostCandidates.isEmpty else {
+ DevelopmentDiagnostics.log("windowActivation.finderTab.noSelectableHost", [
+ "windowID": window.id,
+ "candidateCount": candidates.count,
+ "targetTitleHash": DevelopmentDiagnostics.stableFingerprint(window.title),
+ "targetTitleLength": window.title.count
+ ])
+ return nil
+ }
+ hostCandidates = selectableHostCandidates
+ } else {
+ hostCandidates = selectableHostCandidates.isEmpty ? spaceScopedCandidates : selectableHostCandidates
+ }
+
+ let targetFrame = matchingFrame(for: window)
let scoredHosts = hostCandidates.compactMap { metadata -> (metadata: AXWindowMetadata, score: CGFloat)? in
guard let score = frameOverlapScore(targetFrame, metadata.frame),
score >= 0.85
@@ -721,7 +816,33 @@
}
}
- return hostCandidates.count == 1 ? hostCandidates[0] : nil
+ if hostCandidates.count == 1 {
+ return hostCandidates[0]
+ }
+
+ DevelopmentDiagnostics.log("windowActivation.finderTab.hostAmbiguous", [
+ "windowID": window.id,
+ "candidateCount": hostCandidates.count,
+ "spaceIDCount": targetSpaceIDs.count,
+ "hasTargetFrame": targetFrame != nil,
+ "targetTitleHash": DevelopmentDiagnostics.stableFingerprint(window.title),
+ "targetTitleLength": window.title.count
+ ])
+ return nil
+ }
+
+ private func finderHostCanRepresentTarget(
+ _ window: AlignerWindow,
+ in host: AXWindowMetadata
+ ) -> Bool {
+ let normalizedTargetTitle = normalizedWindowTitle(window.title)
+ guard !normalizedTargetTitle.isEmpty else { return false }
+
+ if normalizedWindowTitle(host.title ?? "") == normalizedTargetTitle {
+ return true
+ }
+
+ return finderTabControl(matchingNormalizedTitle: normalizedTargetTitle, in: host.element) != nil
}
private func selectFinderTab(
@@ -735,7 +856,7 @@
return true
}
- guard let tabButton = finderTabButton(
+ guard let tabControl = finderTabControl(
matchingNormalizedTitle: normalizedTargetTitle,
in: host.element
) else {
@@ -745,9 +866,27 @@
"targetTitleHash": DevelopmentDiagnostics.stableFingerprint(window.title),
"targetTitleLength": window.title.count
])
+ if let processIdentifier = window.app.processIdentifier,
+ selectFinderWindowMenuItem(
+ processIdentifier: processIdentifier,
+ window: window,
+ normalizedTargetTitle: normalizedTargetTitle
+ ) {
+ return true
+ }
+ if let processIdentifier = window.app.processIdentifier,
+ selectFinderTabByCyclingTabs(
+ processIdentifier: processIdentifier,
+ host: host,
+ window: window,
+ normalizedTargetTitle: normalizedTargetTitle
+ ) {
+ return true
+ }
return false
}
+ let tabButton = tabControl.button
let tabButtonRole = axStringAttribute(kAXRoleAttribute as String, for: tabButton)
let tabButtonSubrole = axStringAttribute(kAXSubroleAttribute as String, for: tabButton)
let pressResult = AXUIElementPerformAction(tabButton, kAXPressAction as CFString)
@@ -758,16 +897,60 @@
"tabButtonSubrole": tabButtonSubrole,
"axError": String(describing: pressResult)
])
- guard pressResult == .success else { return false }
+ if pressResult == .success,
+ finderHostTitleMatchesTarget(host, normalizedTargetTitle: normalizedTargetTitle) {
+ return true
+ }
- for _ in 0..<10 {
- Thread.sleep(forTimeInterval: 0.04)
- let selectedTitle = normalizedWindowTitle(
- axStringAttribute(kAXTitleAttribute as String, for: host.element) ?? ""
+ if let tabGroup = tabControl.tabGroup {
+ let selectedChildrenResult = AXUIElementSetAttributeValue(
+ tabGroup,
+ kAXSelectedChildrenAttribute as CFString,
+ [tabButton] as CFArray
)
- if selectedTitle == normalizedTargetTitle {
+ DevelopmentDiagnostics.log("windowActivation.finderTab.setSelectedChildren", [
+ "windowID": window.id,
+ "hostWindowID": host.windowID,
+ "axError": String(describing: selectedChildrenResult)
+ ])
+ if selectedChildrenResult == .success,
+ finderHostTitleMatchesTarget(host, normalizedTargetTitle: normalizedTargetTitle) {
return true
}
+ }
+
+ let valueResult = AXUIElementSetAttributeValue(
+ tabButton,
+ kAXValueAttribute as CFString,
+ kCFBooleanTrue
+ )
+ DevelopmentDiagnostics.log("windowActivation.finderTab.setValue", [
+ "windowID": window.id,
+ "hostWindowID": host.windowID,
+ "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
+ ) {
+ return true
+ }
+
+ if let processIdentifier = window.app.processIdentifier,
+ selectFinderTabByCyclingTabs(
+ processIdentifier: processIdentifier,
+ host: host,
+ window: window,
+ normalizedTargetTitle: normalizedTargetTitle
+ ) {
+ return true
}
let currentHostTitle = axStringAttribute(kAXTitleAttribute as String, for: host.element) ?? ""
@@ -786,9 +969,16 @@
matchingNormalizedTitle targetTitle: String,
in root: AXUIElement
) -> AXUIElement? {
+ finderTabControl(matchingNormalizedTitle: targetTitle, in: root)?.button
+ }
+
+ private func finderTabControl(
+ matchingNormalizedTitle targetTitle: String,
+ in root: AXUIElement
+ ) -> FinderTabControl? {
var visitedCount = 0
- func search(_ element: AXUIElement, depth: Int, insideTabGroup: Bool) -> AXUIElement? {
+ func search(_ element: AXUIElement, depth: Int, tabGroup: AXUIElement?) -> FinderTabControl? {
visitedCount += 1
guard visitedCount <= 240, depth <= 9 else { return nil }
@@ -798,17 +988,18 @@
?? axStringAttribute(kAXDescriptionAttribute as String, for: element)
?? axStringAttribute(kAXValueAttribute as String, for: element)
let isTabGroup = role == "AXTabGroup"
+ let currentTabGroup = isTabGroup ? element : tabGroup
let isTabControl = subrole == "AXTabButton"
- || (insideTabGroup && (role == kAXRadioButtonRole as String || role == kAXButtonRole as String))
+ || (currentTabGroup != nil && (role == kAXRadioButtonRole as String || role == kAXButtonRole as String))
if normalizedWindowTitle(title ?? "") == targetTitle,
isTabControl {
- return element
+ return FinderTabControl(button: element, tabGroup: currentTabGroup)
}
- guard isTabGroup || insideTabGroup || depth < 7 else { return nil }
+ guard isTabGroup || currentTabGroup != nil || depth < 7 else { return nil }
for child in axChildren(of: element) {
- if let match = search(child, depth: depth + 1, insideTabGroup: insideTabGroup || isTabGroup) {
+ if let match = search(child, depth: depth + 1, tabGroup: currentTabGroup) {
return match
}
}
@@ -816,7 +1007,240 @@
return nil
}
- return search(root, depth: 0, insideTabGroup: false)
+ return search(root, depth: 0, tabGroup: nil)
+ }
+
+ private func finderHostTitleMatchesTarget(
+ _ host: AXWindowMetadata,
+ normalizedTargetTitle: String,
+ attempts: Int = 10,
+ delay: TimeInterval = 0.04
+ ) -> Bool {
+ for _ in 0..<attempts {
+ Thread.sleep(forTimeInterval: delay)
+ let selectedTitle = normalizedWindowTitle(
+ axStringAttribute(kAXTitleAttribute as String, for: host.element) ?? ""
+ )
+ if selectedTitle == normalizedTargetTitle {
+ return true
+ }
+ }
+
+ return false
+ }
+
+ private func selectFinderWindowMenuItem(
+ processIdentifier: Int32,
+ window: AlignerWindow,
+ normalizedTargetTitle: String
+ ) -> Bool {
+ let appElement = AXUIElementCreateApplication(processIdentifier)
+ guard let menuBar = axElementAttribute(kAXMenuBarAttribute as String, for: appElement) else {
+ DevelopmentDiagnostics.log("windowActivation.finderTab.windowMenuMissing", [
+ "windowID": window.id,
+ "pid": processIdentifier
+ ])
+ return false
+ }
+
+ for menuBarItem in axChildren(of: menuBar) where isWindowMenuBarItem(menuBarItem) {
+ let openResult = AXUIElementPerformAction(menuBarItem, kAXPressAction as CFString)
+ Thread.sleep(forTimeInterval: 0.08)
+ guard openResult == .success,
+ let menuItem = finderWindowMenuItem(
+ matchingNormalizedTitle: normalizedTargetTitle,
+ in: menuBarItem
+ )
+ else {
+ continue
+ }
+
+ let pressResult = AXUIElementPerformAction(menuItem, kAXPressAction as CFString)
+ DevelopmentDiagnostics.log("windowActivation.finderTab.windowMenuPress", [
+ "windowID": window.id,
+ "pid": processIdentifier,
+ "axError": String(describing: pressResult)
+ ])
+ guard pressResult == .success else { return false }
+
+ if finderFocusedWindowTitleMatches(
+ processIdentifier: processIdentifier,
+ window: window,
+ normalizedTargetTitle: normalizedTargetTitle,
+ initialDelay: 0.18,
+ attempts: 18,
+ delay: 0.06,
+ requiredConsecutiveMatches: 3
+ ) {
+ DevelopmentDiagnostics.log("windowActivation.finderTab.windowMenuSucceeded", [
+ "windowID": window.id,
+ "pid": processIdentifier
+ ])
+ return true
+ }
+
+ DevelopmentDiagnostics.log("windowActivation.finderTab.windowMenuVerificationFailed", [
+ "windowID": window.id,
+ "pid": processIdentifier
+ ])
+ }
+
+ DevelopmentDiagnostics.log("windowActivation.finderTab.windowMenuItemMissing", [
+ "windowID": window.id,
+ "pid": processIdentifier,
+ "targetTitleHash": DevelopmentDiagnostics.stableFingerprint(window.title),
+ "targetTitleLength": window.title.count
+ ])
+ return false
+ }
+
+ private func isWindowMenuBarItem(_ element: AXUIElement) -> Bool {
+ let title = normalizedWindowTitle(axStringAttribute(kAXTitleAttribute as String, for: element) ?? "")
+ return title == "window" || title == "窗口"
+ }
+
+ private func finderWindowMenuItem(
+ matchingNormalizedTitle targetTitle: String,
+ in root: AXUIElement
+ ) -> AXUIElement? {
+ var visitedCount = 0
+
+ func search(_ element: AXUIElement, depth: Int) -> AXUIElement? {
+ visitedCount += 1
+ guard visitedCount <= 500, depth <= 8 else { return nil }
+
+ let role = axStringAttribute(kAXRoleAttribute as String, for: element)
+ let title = axStringAttribute(kAXTitleAttribute as String, for: element)
+ ?? axStringAttribute(kAXDescriptionAttribute as String, for: element)
+ if role == kAXMenuItemRole as String,
+ normalizedWindowTitle(title ?? "") == targetTitle {
+ return element
+ }
+
+ for child in axChildren(of: element) {
+ if let match = search(child, depth: depth + 1) {
+ return match
+ }
+ }
+
+ return nil
+ }
+
+ return search(root, depth: 0)
+ }
+
+ private func finderFocusedWindowTitleMatches(
+ processIdentifier: Int32,
+ window: AlignerWindow,
+ normalizedTargetTitle: String,
+ initialDelay: TimeInterval,
+ attempts: Int,
+ delay: TimeInterval,
+ requiredConsecutiveMatches: Int
+ ) -> 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)
+ }
+ lastTitle = title
+ if normalizedWindowTitle(title ?? "") == normalizedTargetTitle {
+ consecutiveMatches += 1
+ } else {
+ consecutiveMatches = 0
+ }
+
+ if consecutiveMatches >= requiredConsecutiveMatches {
+ return true
+ }
+ Thread.sleep(forTimeInterval: delay)
+ }
+
+ DevelopmentDiagnostics.log("windowActivation.finderTab.focusedTitleMismatch", [
+ "windowID": window.id,
+ "pid": processIdentifier,
+ "targetTitleHash": DevelopmentDiagnostics.stableFingerprint(window.title),
+ "targetTitleLength": window.title.count,
+ "lastTitleHash": DevelopmentDiagnostics.stableFingerprint(lastTitle),
+ "lastTitleLength": lastTitle?.count
+ ])
+ return false
+ }
+
+ private func selectFinderTabByCyclingTabs(
+ processIdentifier: Int32,
+ host: AXWindowMetadata,
+ window: AlignerWindow,
+ normalizedTargetTitle: String
+ ) -> Bool {
+ let maximumAttempts = 24
+ for attempt in 1...maximumAttempts {
+ for shortcut in Self.finderNextTabShortcuts {
+ guard postKeyboardShortcut(to: processIdentifier, shortcut: shortcut) else {
+ DevelopmentDiagnostics.log("windowActivation.finderTab.cyclePostFailed", [
+ "windowID": window.id,
+ "hostWindowID": host.windowID,
+ "attempt": attempt,
+ "shortcut": shortcut.name
+ ])
+ return false
+ }
+
+ if finderHostTitleMatchesTarget(
+ host,
+ normalizedTargetTitle: normalizedTargetTitle,
+ attempts: 4,
+ delay: 0.035
+ ) {
+ DevelopmentDiagnostics.log("windowActivation.finderTab.cycleSucceeded", [
+ "windowID": window.id,
+ "hostWindowID": host.windowID,
+ "attempt": attempt,
+ "shortcut": shortcut.name
+ ])
+ return true
+ }
+ }
+ }
+
+ DevelopmentDiagnostics.log("windowActivation.finderTab.cycleFailed", [
+ "windowID": window.id,
+ "hostWindowID": host.windowID,
+ "attemptCount": maximumAttempts
+ ])
+ return false
+ }
+
+ private func postKeyboardShortcut(
+ to processIdentifier: Int32,
+ shortcut: KeyboardShortcut
+ ) -> Bool {
+ let source = CGEventSource(stateID: .combinedSessionState)
+ guard let keyDown = CGEvent(
+ keyboardEventSource: source,
+ virtualKey: shortcut.keyCode,
+ keyDown: true
+ ),
+ let keyUp = CGEvent(
+ keyboardEventSource: source,
+ virtualKey: shortcut.keyCode,
+ keyDown: false
+ )
+ else {
+ return false
+ }
+
+ keyDown.flags = shortcut.flags
+ keyUp.flags = shortcut.flags
+ keyDown.postToPid(processIdentifier)
+ keyUp.postToPid(processIdentifier)
+ return true
}
private func axChildren(of element: AXUIElement) -> [AXUIElement] {
@@ -841,6 +1265,18 @@
return value as? String
}
+ private func axElementAttribute(_ attribute: String, for element: AXUIElement) -> AXUIElement? {
+ var value: CFTypeRef?
+ guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success,
+ let value,
+ CFGetTypeID(value) == AXUIElementGetTypeID()
+ else {
+ return nil
+ }
+
+ return (value as! AXUIElement)
+ }
+
private func frameOverlapScore(_ lhs: CGRect?, _ rhs: CGRect?) -> CGFloat? {
guard let lhs,
let rhs,
@@ -859,7 +1295,12 @@
let denominator = min(lhs.width * lhs.height, rhs.width * rhs.height)
guard denominator > 0 else { return nil }
- return (intersection.width * intersection.height) / denominator
+ let overlap = (intersection.width * intersection.height) / denominator
+ let widthSimilarity = min(lhs.width, rhs.width) / max(lhs.width, rhs.width)
+ let heightSimilarity = min(lhs.height, rhs.height) / max(lhs.height, rhs.height)
+ let sizeSimilarity = min(widthSimilarity, heightSimilarity)
+ guard sizeSimilarity >= 0.80 else { return nil }
+ return min(overlap, sizeSimilarity)
}
private func cgWindowFingerprint(for window: AlignerWindow) -> AXWindowFingerprint? {
@@ -882,7 +1323,7 @@
private func axWindowGeometryFingerprint(for window: AlignerWindow) -> AXWindowGeometryFingerprint? {
guard let processIdentifier = window.app.processIdentifier else { return nil }
- let frame = cgWindowFrame(for: window) ?? window.frame
+ let frame = matchingFrame(for: window)
return AXWindowGeometryFingerprint(
processIdentifier: processIdentifier,
title: normalizedWindowTitle(window.title),
@@ -901,6 +1342,14 @@
}
return bounds(rawWindow[kCGWindowBounds as String])
+ }
+
+ private func matchingFrame(for window: AlignerWindow) -> CGRect? {
+ if isFinderCGOnlyPageCandidate(window) {
+ return window.frame ?? cgWindowFrame(for: window)
+ }
+
+ return cgWindowFrame(for: window) ?? window.frame
}
private func matchingAXTitleCandidateCount(
@@ -1435,6 +1884,12 @@
}
private static let commandWVirtualKeyCode: CGKeyCode = 13
+ private static let rightBracketVirtualKeyCode: CGKeyCode = 30
+ private static let tabVirtualKeyCode: CGKeyCode = 48
+ private static let finderNextTabShortcuts: [KeyboardShortcut] = [
+ KeyboardShortcut(name: "controlTab", keyCode: tabVirtualKeyCode, flags: .maskControl),
+ KeyboardShortcut(name: "commandShiftRightBracket", keyCode: rightBracketVirtualKeyCode, flags: [.maskCommand, .maskShift])
+ ]
}
private struct AXWindowMetadata {
diff --git a/C1.source/Sources/Aligner/QuickSwitchRootView.swift b/C1.source/Sources/Aligner/QuickSwitchRootView.swift
index 3d16422..c04faf7 100644
--- a/C1.source/Sources/Aligner/QuickSwitchRootView.swift
+++ b/C1.source/Sources/Aligner/QuickSwitchRootView.swift
@@ -1347,6 +1347,15 @@
if let card = waterfallCard(appGroupIndex: appGroupIndex, windowIndex: windowIndex) {
clickWindowCard(card)
}
+ case "click-card-id" where parts.count == 2:
+ guard let windowID = UInt32(parts[1]) else {
+ recordMouseCommand("invalid:\(command)")
+ return
+ }
+ recordMouseCommand("click-card-id:\(windowID)")
+ if let card = waterfallCard(windowID: windowID) {
+ clickWindowCard(card)
+ }
case "hover-card-close" where parts.count == 3:
guard let appGroupIndex = Int(parts[1]), let windowIndex = Int(parts[2]) else {
recordMouseCommand("invalid:\(command)")
@@ -3085,6 +3094,13 @@
.first(where: { $0.item.windowIndex == windowIndex })
}
+ private func waterfallCard(windowID: UInt32) -> WaterfallCardLayers? {
+ waterfallColumns
+ .lazy
+ .flatMap(\.cards)
+ .first(where: { $0.item.window.id == windowID })
+ }
+
private func layoutSpaceLaneSegmentContents(_ segment: SpaceLaneSegmentLayers) {
let bounds = segment.containerLayer.bounds
let splitAppNames = splitViewAppNames(for: segment.space)
diff --git a/C1.source/Sources/AlignerCore/Windows/FinderTabSpaceAttributionPolicy.swift b/C1.source/Sources/AlignerCore/Windows/FinderTabSpaceAttributionPolicy.swift
index 0de281d..9c40e17 100644
--- a/C1.source/Sources/AlignerCore/Windows/FinderTabSpaceAttributionPolicy.swift
+++ b/C1.source/Sources/AlignerCore/Windows/FinderTabSpaceAttributionPolicy.swift
@@ -4,6 +4,7 @@
public enum FinderTabSpaceAttributionPolicy {
private static let finderBundleIdentifier = "com.apple.finder"
private static let minimumOverlapRatio: CGFloat = 0.85
+ private static let minimumSizeSimilarityRatio: CGFloat = 0.80
private static let minimumBestScoreGap: CGFloat = 0.05
public static func attributedRecords(_ records: [WindowEnumerationRecord]) -> [WindowEnumerationRecord] {
@@ -52,8 +53,7 @@
sameProcess(candidate, host)
}
let scoredHosts = compatibleHosts.compactMap { host -> (record: WindowEnumerationRecord, score: CGFloat)? in
- guard let score = overlapScore(candidate.frame, host.frame),
- score >= minimumOverlapRatio
+ guard let score = frameCompatibilityScore(candidate.frame, host.frame)
else {
return nil
}
@@ -79,6 +79,18 @@
let secondBest = scoredHosts[1]
return best.score - secondBest.score >= minimumBestScoreGap ? best.record : nil
+ }
+
+ private static func frameCompatibilityScore(_ lhs: CGRect?, _ rhs: CGRect?) -> CGFloat? {
+ guard let overlap = overlapScore(lhs, rhs),
+ overlap >= minimumOverlapRatio,
+ let sizeSimilarity = sizeSimilarityScore(lhs, rhs),
+ sizeSimilarity >= minimumSizeSimilarityRatio
+ else {
+ return nil
+ }
+
+ return min(overlap, sizeSimilarity)
}
private static func sameProcess(
@@ -115,6 +127,22 @@
return (intersection.width * intersection.height) / denominator
}
+ private static func sizeSimilarityScore(_ lhs: CGRect?, _ rhs: CGRect?) -> CGFloat? {
+ guard let lhs,
+ let rhs,
+ lhs.width > 0,
+ lhs.height > 0,
+ rhs.width > 0,
+ rhs.height > 0
+ else {
+ return nil
+ }
+
+ let widthSimilarity = min(lhs.width, rhs.width) / max(lhs.width, rhs.width)
+ let heightSimilarity = min(lhs.height, rhs.height) / max(lhs.height, rhs.height)
+ return min(widthSimilarity, heightSimilarity)
+ }
+
private static func copy(
_ record: WindowEnumerationRecord,
isFullscreen: Bool,
diff --git a/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift b/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift
index 042e94c..cbc65ff 100644
--- a/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift
+++ b/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift
@@ -533,6 +533,58 @@
XCTAssertTrue(attributed[1].isFullscreen)
}
+ func testFinderTabSpaceAttributionRejectsContainingHostsWithDifferentSize() {
+ let finderApp = AlignerApp(
+ bundleIdentifier: "com.apple.finder",
+ name: "访达",
+ category: .finder,
+ processIdentifier: 74678
+ )
+ let splitRightFrame = CGRect(x: 960, y: 0, width: 960, height: 1080)
+ let fullDisplayFrame = CGRect(x: 0, y: 0, width: 1920, height: 1080)
+ let splitHost = WindowEnumerationRecord(
+ id: 33112,
+ app: finderApp,
+ title: "下载",
+ size: splitRightFrame.size,
+ frame: splitRightFrame,
+ subrole: .standard,
+ isFullscreen: true,
+ hasAXBacking: true,
+ spaceIDs: [843]
+ )
+ let containingHost = WindowEnumerationRecord(
+ id: 41557,
+ app: finderApp,
+ title: "应用程序",
+ size: fullDisplayFrame.size,
+ frame: fullDisplayFrame,
+ subrole: .standard,
+ isFullscreen: true,
+ hasAXBacking: true,
+ spaceIDs: [1112]
+ )
+ let inactiveTab = WindowEnumerationRecord(
+ id: 42286,
+ app: finderApp,
+ title: "Appcache",
+ size: splitRightFrame.size,
+ frame: splitRightFrame,
+ subrole: .standard,
+ hasAXBacking: false,
+ isOnscreen: false
+ )
+
+ let attributed = FinderTabSpaceAttributionPolicy.attributedRecords([
+ inactiveTab,
+ containingHost,
+ splitHost
+ ])
+
+ XCTAssertEqual(attributed[0].spaceIDs, [843])
+ XCTAssertTrue(attributed[0].isFullscreen)
+ }
+
func testWindowEnumerationPolicyKeepsMinimizedAndFullscreenCGOnlyOffscreenWindows() {
let minimized = makeWindowRecord(
title: "Minimized Document.md",
diff --git a/C3.tools/round1-finder-tabs-live-qa.sh b/C3.tools/round1-finder-tabs-live-qa.sh
index 99fc856..1773b03 100755
--- a/C3.tools/round1-finder-tabs-live-qa.sh
+++ b/C3.tools/round1-finder-tabs-live-qa.sh
@@ -14,7 +14,7 @@
RUN_ID="$(date +%Y%m%d_%H%M%S)"
REPORT_DIR="$BUILD_REPORT_ROOT/round01-finder-tabs-live-$RUN_ID"
DEV_LOG="$HOME/Library/Logs/Aligner/aligner-dev.log"
-REPORT_WAIT="${ALIGNER_ROUND1_FINDER_TABS_REPORT_WAIT:-7.0}"
+REPORT_WAIT="${ALIGNER_ROUND1_FINDER_TABS_REPORT_WAIT:-35.0}"
fail() {
echo "Round01 Finder tabs live QA failed: $*" >&2
@@ -128,17 +128,23 @@
--round0-skip-permissions \
--round01-open-quick-switch \
--round01-disable-screenshot-refresh \
- --round01-quick-switch-auto-hide-after=0.8 \
- --round01-quick-switch-quit-after=1.3 \
- --round01-quick-switch-report="$report" >"$app_log" 2>&1
- wait_for_report "$report" "loaded"
+ --round01-quick-switch-auto-hide-after=20.0 \
+ --round01-quick-switch-quit-after=21.0 \
+ --round01-quick-switch-report="$report" >"$app_log" 2>&1 &
+ local app_pid="$!"
+ if ! wait_for_report "$report" "loaded"; then
+ kill "$app_pid" 2>/dev/null || true
+ wait "$app_pid" 2>/dev/null || true
+ return 1
+ fi
+ kill "$app_pid" 2>/dev/null || true
+ wait "$app_pid" 2>/dev/null || true
}
run_click() {
- local app_group_index="$1"
- local window_index="$2"
- local report="$3"
- local app_log="$4"
+ local window_id="$1"
+ local report="$2"
+ local app_log="$3"
stop_current_aligner
rm -f "$report"
ALIGNER_DIAGNOSTICS_INCLUDE_SENSITIVE=1 \
@@ -146,10 +152,16 @@
--round0-skip-permissions \
--round01-open-quick-switch \
--round01-disable-screenshot-refresh \
- --round01-debug-mouse-sequence="click-card:$app_group_index:$window_index" \
- --round01-quick-switch-quit-after=1.8 \
- --round01-quick-switch-report="$report" >"$app_log" 2>&1
- wait_for_report "$report" "activated"
+ --round01-debug-mouse-sequence="click-card-id:$window_id" \
+ --round01-quick-switch-quit-after=34.0 \
+ --round01-quick-switch-report="$report" >"$app_log" 2>&1 &
+ local app_pid="$!"
+ if ! wait_for_report "$report" "activated"; then
+ kill "$app_pid" 2>/dev/null || true
+ wait "$app_pid" 2>/dev/null || true
+ return 1
+ fi
+ wait "$app_pid" 2>/dev/null || true
}
focused_finder_window_report() {
@@ -255,17 +267,19 @@
PY
}
-select_target() {
+select_targets() {
local snapshot="$1"
local report="$2"
local attributed_ids_csv="$3"
local output="$4"
/usr/bin/python3 - "$snapshot" "$report" "$attributed_ids_csv" "$output" <<'PY'
import json
+import os
import sys
snapshot_path, report_path, ids_csv, output_path = sys.argv[1:5]
attributed_ids = {int(value) for value in ids_csv.split(",") if value}
+max_targets = max(1, int(os.environ.get("ALIGNER_ROUND1_FINDER_TABS_MAX_TARGETS", "4")))
with open(snapshot_path, "r", encoding="utf-8") as file:
snapshot = json.load(file)
with open(report_path, "r", encoding="utf-8") as file:
@@ -314,25 +328,55 @@
item[1].get("globalIndex", 10**9),
item[1].get("windowID", 10**9),
))
-column, card, title = cards[0]
-target = {
- "windowID": card["windowID"],
- "appGroupIndex": column["appGroupIndex"],
- "windowIndex": card["windowIndex"],
- "primarySpaceID": card.get("primarySpaceID"),
- "title": title,
- "titleLength": len(title),
- "titleHash": card.get("titleHash") or snapshot_windows.get(card.get("windowID"), {}).get("titleHash"),
- "visibleFrame": card.get("visibleFrame"),
-}
+targets = []
+seen_window_ids = set()
+for column, card, title in cards:
+ window_id = card["windowID"]
+ if window_id in seen_window_ids:
+ continue
+ seen_window_ids.add(window_id)
+ targets.append({
+ "windowID": window_id,
+ "appGroupIndex": column["appGroupIndex"],
+ "windowIndex": card["windowIndex"],
+ "primarySpaceID": card.get("primarySpaceID"),
+ "title": title,
+ "titleLength": len(title),
+ "titleHash": card.get("titleHash") or snapshot_windows.get(window_id, {}).get("titleHash"),
+ "visibleFrame": card.get("visibleFrame"),
+ })
+ if len(targets) >= max_targets:
+ break
+
+require(targets, "Quick Switch report must expose at least one selectable Finder tab/page target")
with open(output_path, "w", encoding="utf-8") as file:
- json.dump(target, file, indent=2, ensure_ascii=False)
+ json.dump(targets, file, indent=2, ensure_ascii=False)
+print(f"TARGET_COUNT={len(targets)}")
+PY
+}
+
+target_at_index() {
+ local targets="$1"
+ local index="$2"
+ local output="$3"
+ /usr/bin/python3 - "$targets" "$index" "$output" <<'PY'
+import json
+import sys
+
+targets_path, index_text, output_path = sys.argv[1:4]
+index = int(index_text)
+with open(targets_path, "r", encoding="utf-8") as file:
+ targets = json.load(file)
+if index < 0 or index >= len(targets):
+ print(f"target index {index} is out of range", file=sys.stderr)
+ sys.exit(4)
+target = targets[index]
+with open(output_path, "w", encoding="utf-8") as file:
+ json.dump(target, file, indent=2, ensure_ascii=False)
print(f"TARGET_WINDOW_ID={target['windowID']}")
-print(f"APP_GROUP_INDEX={target['appGroupIndex']}")
-print(f"WINDOW_INDEX={target['windowIndex']}")
-print(f"TARGET_SPACE_ID={target['primarySpaceID']}")
+print(f"TARGET_SPACE_ID={target.get('primarySpaceID')}")
PY
}
@@ -416,11 +460,7 @@
SNAPSHOT_DEV_LOG="$REPORT_DIR/snapshot-dev.log"
INITIAL_REPORT="$REPORT_DIR/initial-report.json"
INITIAL_APP_LOG="$REPORT_DIR/initial-app.log"
-CLICK_REPORT="$REPORT_DIR/click-report.json"
-CLICK_APP_LOG="$REPORT_DIR/click-app.log"
-CLICK_DEV_LOG="$REPORT_DIR/click-dev.log"
-TARGET_JSON="$REPORT_DIR/target.json"
-FOCUSED_JSON="$REPORT_DIR/focused-finder.json"
+TARGETS_JSON="$REPORT_DIR/targets.json"
SNAPSHOT_LOG_OFFSET="$(dev_log_size)"
run_snapshot_dump "$SNAPSHOT_JSON" "$SNAPSHOT_STDERR"
@@ -429,16 +469,25 @@
ATTRIBUTED_IDS="$(extract_attributed_ids "$SNAPSHOT_DEV_LOG")"
run_report "$INITIAL_REPORT" "$INITIAL_APP_LOG"
-eval "$(select_target "$SNAPSHOT_JSON" "$INITIAL_REPORT" "$ATTRIBUTED_IDS" "$TARGET_JSON")"
+eval "$(select_targets "$SNAPSHOT_JSON" "$INITIAL_REPORT" "$ATTRIBUTED_IDS" "$TARGETS_JSON")"
-CLICK_LOG_OFFSET="$(dev_log_size)"
-run_click "$APP_GROUP_INDEX" "$WINDOW_INDEX" "$CLICK_REPORT" "$CLICK_APP_LOG"
-sleep 0.8
-extract_dev_log_since "$CLICK_LOG_OFFSET" "$CLICK_DEV_LOG"
-focused_finder_window_report "$FOCUSED_JSON"
+for ((target_index = 0; target_index < TARGET_COUNT; target_index++)); do
+ TARGET_JSON="$REPORT_DIR/target-$target_index.json"
+ CLICK_REPORT="$REPORT_DIR/click-report-$target_index.json"
+ CLICK_APP_LOG="$REPORT_DIR/click-app-$target_index.log"
+ CLICK_DEV_LOG="$REPORT_DIR/click-dev-$target_index.log"
+ FOCUSED_JSON="$REPORT_DIR/focused-finder-$target_index.json"
-assert_click_report "$CLICK_REPORT" "$TARGET_JSON"
-assert_focused_finder_title "$TARGET_JSON" "$FOCUSED_JSON"
+ eval "$(target_at_index "$TARGETS_JSON" "$target_index" "$TARGET_JSON")"
+ CLICK_LOG_OFFSET="$(dev_log_size)"
+ run_click "$TARGET_WINDOW_ID" "$CLICK_REPORT" "$CLICK_APP_LOG"
+ sleep 0.8
+ extract_dev_log_since "$CLICK_LOG_OFFSET" "$CLICK_DEV_LOG"
+ focused_finder_window_report "$FOCUSED_JSON"
+
+ assert_click_report "$CLICK_REPORT" "$TARGET_JSON"
+ assert_focused_finder_title "$TARGET_JSON" "$FOCUSED_JSON"
+done
stop_current_aligner
@@ -447,6 +496,7 @@
Log directory: $REPORT_DIR
Covered:
- Finder CG-only tab/page records inherit a high-confidence fullscreen Space.
-- Quick Switch click targets the exact Finder tab/page windowID.
-- Finder focused window title matches the clicked card after activation.
+- Quick Switch clicks target exact Finder tab/page windowIDs.
+- Finder focused window title matches each clicked card after activation.
+- Multiple Finder tab/page targets are exercised in one run to catch intermittent host mismatch.
EOF
--
Gitblit v1.9.3