Fix Finder tab space activation
4 files modified
2 files added
| | |
| | | <key>CFBundlePackageType</key> |
| | | <string>APPL</string> |
| | | <key>CFBundleShortVersionString</key> |
| | | <string>0.0.63</string> |
| | | <string>0.0.64</string> |
| | | <key>CFBundleVersion</key> |
| | | <string>20260613.1401</string> |
| | | <string>20260613.1606</string> |
| | | <key>LSMinimumSystemVersion</key> |
| | | <string>26.0</string> |
| | | <key>NSHighResolutionCapable</key> |
| | |
| | | "isMinimized": window.isMinimized, |
| | | "isFullscreen": window.isFullscreen, |
| | | "isGhost": window.isGhost, |
| | | "frame": frameDictionary(window.frame) as Any, |
| | | "spaceIDs": window.spaceIDs, |
| | | "app": appDictionary(window.app) |
| | | ] as [String: Any] |
| | |
| | | return result |
| | | } |
| | | |
| | | private static func frameDictionary(_ frame: CGRect?) -> Any { |
| | | guard let frame else { return NSNull() } |
| | | |
| | | return [ |
| | | "x": frame.origin.x, |
| | | "y": frame.origin.y, |
| | | "width": frame.size.width, |
| | | "height": frame.size.height |
| | | ] |
| | | } |
| | | |
| | | private static func appDictionary(_ app: AlignerApp) -> [String: Any] { |
| | | let pathSummary = DevelopmentDiagnostics.pathSummary(app.executablePath) |
| | | var result = [ |
| | |
| | | let privateActivationOutcome = activateViaPrivateWindowServerAPI(window) |
| | | let didPrivatelyActivate = privateActivationOutcome?.succeeded == true |
| | | |
| | | let currentAXMetadata = AXWindowMetadataReader.metadata(appCategorizer: appCategorizer) |
| | | let axWindow = axWindow( |
| | | for: window, |
| | | in: AXWindowMetadataReader.metadata(appCategorizer: appCategorizer), |
| | | in: currentAXMetadata, |
| | | operation: "windowActivation", |
| | | matchAttempt: "initial" |
| | | ) |
| | |
| | | "spaceFocusError": spaceFocusOutcome?.error |
| | | ]) |
| | | guard let axWindow else { |
| | | if let finderTabActivated = activateFinderTabPageIfNeeded( |
| | | window: window, |
| | | axMetadata: currentAXMetadata, |
| | | operation: "windowActivation", |
| | | reason: "axMissing" |
| | | ) { |
| | | DevelopmentDiagnostics.log("windowActivation.activate.finderTabResult", [ |
| | | "windowID": window.id, |
| | | "pid": window.app.processIdentifier, |
| | | "activated": finderTabActivated |
| | | ]) |
| | | return finderTabActivated ? .activated : .activationFailed |
| | | } |
| | | |
| | | let activated = activateApplication(for: window) |
| | | DevelopmentDiagnostics.log("windowActivation.activate.axMissing", [ |
| | | "windowID": window.id, |
| | |
| | | return nil |
| | | } |
| | | |
| | | guard let activationOutcome = activateViaPrivateWindowServerAPI(window), |
| | | activationOutcome.succeeded |
| | | else { |
| | | DevelopmentDiagnostics.log("windowClose.commandW.activationFailed", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "reason": reason |
| | | ]) |
| | | return nil |
| | | if let finderTabActivated = activateFinderTabPageIfNeeded( |
| | | window: window, |
| | | axMetadata: AXWindowMetadataReader.metadata(appCategorizer: appCategorizer), |
| | | operation: "windowClose", |
| | | reason: reason |
| | | ) { |
| | | guard finderTabActivated else { |
| | | DevelopmentDiagnostics.log("windowClose.commandW.finderTabActivationFailed", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "reason": reason |
| | | ]) |
| | | return .failed("finderTabActivationFailed") |
| | | } |
| | | } else { |
| | | guard let activationOutcome = activateViaPrivateWindowServerAPI(window), |
| | | activationOutcome.succeeded |
| | | else { |
| | | DevelopmentDiagnostics.log("windowClose.commandW.activationFailed", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "reason": reason |
| | | ]) |
| | | return nil |
| | | } |
| | | } |
| | | |
| | | Thread.sleep(forTimeInterval: 0.08) |
| | |
| | | && metadata.subrole == .standard |
| | | } |
| | | return finderCandidates.count == 1 ? finderCandidates[0] : nil |
| | | } |
| | | |
| | | private func activateFinderTabPageIfNeeded( |
| | | window: AlignerWindow, |
| | | axMetadata: [AXWindowMetadata], |
| | | operation: String, |
| | | reason: String |
| | | ) -> Bool? { |
| | | guard isFinderCGOnlyPageCandidate(window), |
| | | let processIdentifier = window.app.processIdentifier |
| | | else { |
| | | return nil |
| | | } |
| | | |
| | | guard let host = finderTabHostAXWindow(for: window, in: axMetadata) else { |
| | | DevelopmentDiagnostics.log("\(operation).finderTab.hostMissing", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "reason": reason, |
| | | "spaceIDs": window.spaceIDs |
| | | ]) |
| | | return false |
| | | } |
| | | |
| | | let hostPrivateActivationSucceeded: Bool |
| | | if let hostWindowID = host.windowID, |
| | | let privateActivationBridge { |
| | | hostPrivateActivationSucceeded = privateActivationBridge.activate( |
| | | processIdentifier: processIdentifier, |
| | | windowID: hostWindowID |
| | | ).succeeded |
| | | } else { |
| | | hostPrivateActivationSucceeded = false |
| | | } |
| | | |
| | | 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 = focusWindowResult(host.element, processIdentifier: processIdentifier) |
| | | let raiseAfterResult = raiseWindowResult(host.element) |
| | | |
| | | let didFocusOrRaise = focusBeforeResult.success |
| | | || raiseBeforeResult.success |
| | | || focusAfterResult.success |
| | | || raiseAfterResult.success |
| | | let activated = selectedTab |
| | | && (hostPrivateActivationSucceeded || applicationActivated || didFocusOrRaise) |
| | | |
| | | DevelopmentDiagnostics.log("\(operation).finderTab.result", [ |
| | | "windowID": window.id, |
| | | "pid": processIdentifier, |
| | | "reason": reason, |
| | | "hostWindowID": host.windowID, |
| | | "hostTitleHash": DevelopmentDiagnostics.stableFingerprint(host.title), |
| | | "hostTitleLength": host.title?.count, |
| | | "hostPrivateActivated": hostPrivateActivationSucceeded, |
| | | "applicationActivated": applicationActivated, |
| | | "selectedTab": selectedTab, |
| | | "focusBeforeAXError": String(describing: focusBeforeResult.error), |
| | | "raiseBeforeAXError": String(describing: raiseBeforeResult.error), |
| | | "focusAfterAXError": String(describing: focusAfterResult.error), |
| | | "raiseAfterAXError": String(describing: raiseAfterResult.error), |
| | | "activated": activated |
| | | ]) |
| | | |
| | | return activated |
| | | } |
| | | |
| | | private func isFinderCGOnlyPageCandidate(_ window: AlignerWindow) -> Bool { |
| | | window.identifierSource == .cgWindow |
| | | && window.app.bundleIdentifier == "com.apple.finder" |
| | | && !window.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty |
| | | } |
| | | |
| | | private func finderTabHostAXWindow( |
| | | for window: AlignerWindow, |
| | | in axMetadata: [AXWindowMetadata] |
| | | ) -> AXWindowMetadata? { |
| | | guard let processIdentifier = window.app.processIdentifier else { return nil } |
| | | let candidates = axMetadata.filter { metadata in |
| | | metadata.processIdentifier == processIdentifier |
| | | && metadata.subrole == .standard |
| | | && metadata.frame?.isEmpty == false |
| | | } |
| | | guard !candidates.isEmpty else { return nil } |
| | | |
| | | let targetSpaceIDs = Set(window.spaceIDs) |
| | | let candidateIDs = candidates.compactMap(\.windowID) |
| | | let candidateSpacesByID = targetSpaceIDs.isEmpty ? [:] : spaceIDsByWindowIDProvider(candidateIDs) |
| | | let spaceCompatibleCandidates = targetSpaceIDs.isEmpty |
| | | ? candidates |
| | | : candidates.filter { metadata in |
| | | 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 scoredHosts = hostCandidates.compactMap { metadata -> (metadata: AXWindowMetadata, score: CGFloat)? in |
| | | guard let score = frameOverlapScore(targetFrame, metadata.frame), |
| | | score >= 0.85 |
| | | else { |
| | | return nil |
| | | } |
| | | |
| | | return (metadata, score) |
| | | } |
| | | .sorted { lhs, rhs in |
| | | if lhs.score != rhs.score { |
| | | return lhs.score > rhs.score |
| | | } |
| | | return (lhs.metadata.windowID ?? UInt32.max) < (rhs.metadata.windowID ?? UInt32.max) |
| | | } |
| | | |
| | | if let best = scoredHosts.first { |
| | | if scoredHosts.count == 1 { |
| | | return best.metadata |
| | | } |
| | | |
| | | let secondBest = scoredHosts[1] |
| | | if best.score - secondBest.score >= 0.05 { |
| | | return best.metadata |
| | | } |
| | | } |
| | | |
| | | return hostCandidates.count == 1 ? hostCandidates[0] : nil |
| | | } |
| | | |
| | | private func selectFinderTab( |
| | | _ window: AlignerWindow, |
| | | in host: AXWindowMetadata |
| | | ) -> Bool { |
| | | let normalizedTargetTitle = normalizedWindowTitle(window.title) |
| | | guard !normalizedTargetTitle.isEmpty else { return false } |
| | | |
| | | if normalizedWindowTitle(host.title ?? "") == normalizedTargetTitle { |
| | | return true |
| | | } |
| | | |
| | | guard let tabButton = finderTabButton( |
| | | matchingNormalizedTitle: normalizedTargetTitle, |
| | | in: host.element |
| | | ) else { |
| | | DevelopmentDiagnostics.log("windowActivation.finderTab.tabButtonMissing", [ |
| | | "windowID": window.id, |
| | | "hostWindowID": host.windowID, |
| | | "targetTitleHash": DevelopmentDiagnostics.stableFingerprint(window.title), |
| | | "targetTitleLength": window.title.count |
| | | ]) |
| | | return false |
| | | } |
| | | |
| | | let tabButtonRole = axStringAttribute(kAXRoleAttribute as String, for: tabButton) |
| | | let tabButtonSubrole = axStringAttribute(kAXSubroleAttribute as String, for: tabButton) |
| | | let pressResult = AXUIElementPerformAction(tabButton, kAXPressAction as CFString) |
| | | DevelopmentDiagnostics.log("windowActivation.finderTab.pressTab", [ |
| | | "windowID": window.id, |
| | | "hostWindowID": host.windowID, |
| | | "tabButtonRole": tabButtonRole, |
| | | "tabButtonSubrole": tabButtonSubrole, |
| | | "axError": String(describing: pressResult) |
| | | ]) |
| | | guard pressResult == .success else { return false } |
| | | |
| | | for _ in 0..<10 { |
| | | Thread.sleep(forTimeInterval: 0.04) |
| | | let selectedTitle = normalizedWindowTitle( |
| | | axStringAttribute(kAXTitleAttribute as String, for: host.element) ?? "" |
| | | ) |
| | | if selectedTitle == normalizedTargetTitle { |
| | | return true |
| | | } |
| | | } |
| | | |
| | | let currentHostTitle = axStringAttribute(kAXTitleAttribute as String, for: host.element) ?? "" |
| | | DevelopmentDiagnostics.log("windowActivation.finderTab.selectionVerificationFailed", [ |
| | | "windowID": window.id, |
| | | "hostWindowID": host.windowID, |
| | | "targetTitleHash": DevelopmentDiagnostics.stableFingerprint(window.title), |
| | | "targetTitleLength": window.title.count, |
| | | "currentHostTitleHash": DevelopmentDiagnostics.stableFingerprint(currentHostTitle), |
| | | "currentHostTitleLength": currentHostTitle.count |
| | | ]) |
| | | return false |
| | | } |
| | | |
| | | private func finderTabButton( |
| | | matchingNormalizedTitle targetTitle: String, |
| | | in root: AXUIElement |
| | | ) -> AXUIElement? { |
| | | var visitedCount = 0 |
| | | |
| | | func search(_ element: AXUIElement, depth: Int, insideTabGroup: Bool) -> AXUIElement? { |
| | | visitedCount += 1 |
| | | guard visitedCount <= 240, depth <= 9 else { return nil } |
| | | |
| | | let role = axStringAttribute(kAXRoleAttribute as String, for: element) |
| | | let subrole = axStringAttribute(kAXSubroleAttribute as String, for: element) |
| | | let title = axStringAttribute(kAXTitleAttribute as String, for: element) |
| | | ?? axStringAttribute(kAXDescriptionAttribute as String, for: element) |
| | | ?? axStringAttribute(kAXValueAttribute as String, for: element) |
| | | let isTabGroup = role == "AXTabGroup" |
| | | let isTabControl = subrole == "AXTabButton" |
| | | || (insideTabGroup && (role == kAXRadioButtonRole as String || role == kAXButtonRole as String)) |
| | | |
| | | if normalizedWindowTitle(title ?? "") == targetTitle, |
| | | isTabControl { |
| | | return element |
| | | } |
| | | |
| | | guard isTabGroup || insideTabGroup || depth < 7 else { return nil } |
| | | for child in axChildren(of: element) { |
| | | if let match = search(child, depth: depth + 1, insideTabGroup: insideTabGroup || isTabGroup) { |
| | | return match |
| | | } |
| | | } |
| | | |
| | | return nil |
| | | } |
| | | |
| | | return search(root, depth: 0, insideTabGroup: false) |
| | | } |
| | | |
| | | private func axChildren(of element: AXUIElement) -> [AXUIElement] { |
| | | var value: CFTypeRef? |
| | | guard AXUIElementCopyAttributeValue(element, kAXChildrenAttribute as CFString, &value) == .success, |
| | | let children = value as? [AXUIElement] |
| | | else { |
| | | return [] |
| | | } |
| | | |
| | | return children |
| | | } |
| | | |
| | | private func axStringAttribute(_ attribute: String, for element: AXUIElement) -> String? { |
| | | var value: CFTypeRef? |
| | | guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success, |
| | | let value |
| | | else { |
| | | return nil |
| | | } |
| | | |
| | | return value as? String |
| | | } |
| | | |
| | | private func frameOverlapScore(_ lhs: CGRect?, _ rhs: CGRect?) -> CGFloat? { |
| | | guard let lhs, |
| | | let rhs, |
| | | !lhs.isEmpty, |
| | | !rhs.isEmpty |
| | | else { |
| | | return nil |
| | | } |
| | | |
| | | let intersection = lhs.intersection(rhs) |
| | | guard !intersection.isNull, |
| | | !intersection.isEmpty |
| | | else { |
| | | return nil |
| | | } |
| | | |
| | | let denominator = min(lhs.width * lhs.height, rhs.width * rhs.height) |
| | | guard denominator > 0 else { return nil } |
| | | return (intersection.width * intersection.height) / denominator |
| | | } |
| | | |
| | | private func cgWindowFingerprint(for window: AlignerWindow) -> AXWindowFingerprint? { |
| | |
| | | } |
| | | |
| | | restorableAXWindowsByID = nextRestorableAXWindowsByID |
| | | return cgRecords + axOnlyRecords |
| | | let records = cgRecords + axOnlyRecords |
| | | let attributedRecords = FinderTabSpaceAttributionPolicy.attributedRecords(records) |
| | | DevelopmentDiagnostics.log("windowEnumeration.finderTabSpaceAttribution", [ |
| | | "finderNoSpaceCandidateCount": records.filter { record in |
| | | record.app.bundleIdentifier == "com.apple.finder" |
| | | && record.identifierSource == .cgWindow |
| | | && record.spaceIDs.isEmpty |
| | | && !record.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty |
| | | && record.frame?.isEmpty == false |
| | | }.count, |
| | | "finderFullscreenHostCount": records.filter { record in |
| | | record.app.bundleIdentifier == "com.apple.finder" |
| | | && record.isFullscreen |
| | | && !record.spaceIDs.isEmpty |
| | | && record.subrole == .standard |
| | | && record.frame?.isEmpty == false |
| | | }.count, |
| | | "attributedCount": zip(records, attributedRecords).filter { before, after in |
| | | before.spaceIDs.isEmpty && !after.spaceIDs.isEmpty |
| | | }.count, |
| | | "attributedWindowIDs": zip(records, attributedRecords).compactMap { before, after in |
| | | before.spaceIDs.isEmpty && !after.spaceIDs.isEmpty ? after.id : nil |
| | | } |
| | | ]) |
| | | return attributedRecords |
| | | } |
| | | |
| | | private func app( |
| New file |
| | |
| | | import CoreGraphics |
| | | import Foundation |
| | | |
| | | public enum FinderTabSpaceAttributionPolicy { |
| | | private static let finderBundleIdentifier = "com.apple.finder" |
| | | private static let minimumOverlapRatio: CGFloat = 0.85 |
| | | private static let minimumBestScoreGap: CGFloat = 0.05 |
| | | |
| | | public static func attributedRecords(_ records: [WindowEnumerationRecord]) -> [WindowEnumerationRecord] { |
| | | let hosts = records.filter(isFinderFullscreenHost) |
| | | guard !hosts.isEmpty else { return records } |
| | | |
| | | return records.map { record in |
| | | guard isFinderTabCandidate(record), |
| | | let host = bestHost(for: record, in: hosts) |
| | | else { |
| | | return record |
| | | } |
| | | |
| | | return copy( |
| | | record, |
| | | isFullscreen: true, |
| | | spaceIDs: host.spaceIDs |
| | | ) |
| | | } |
| | | } |
| | | |
| | | private static func isFinderTabCandidate(_ record: WindowEnumerationRecord) -> Bool { |
| | | WindowEnumerationPolicy.shouldInclude(record) |
| | | && record.identifierSource == .cgWindow |
| | | && record.app.bundleIdentifier == finderBundleIdentifier |
| | | && record.spaceIDs.isEmpty |
| | | && !record.isMinimized |
| | | && !record.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty |
| | | && record.frame?.isEmpty == false |
| | | } |
| | | |
| | | private static func isFinderFullscreenHost(_ record: WindowEnumerationRecord) -> Bool { |
| | | WindowEnumerationPolicy.shouldInclude(record) |
| | | && record.app.bundleIdentifier == finderBundleIdentifier |
| | | && record.isFullscreen |
| | | && !record.spaceIDs.isEmpty |
| | | && record.subrole == .standard |
| | | && record.frame?.isEmpty == false |
| | | } |
| | | |
| | | private static func bestHost( |
| | | for candidate: WindowEnumerationRecord, |
| | | in hosts: [WindowEnumerationRecord] |
| | | ) -> WindowEnumerationRecord? { |
| | | let compatibleHosts = hosts.filter { host in |
| | | sameProcess(candidate, host) |
| | | } |
| | | let scoredHosts = compatibleHosts.compactMap { host -> (record: WindowEnumerationRecord, score: CGFloat)? in |
| | | guard let score = overlapScore(candidate.frame, host.frame), |
| | | score >= minimumOverlapRatio |
| | | else { |
| | | return nil |
| | | } |
| | | |
| | | return (host, score) |
| | | } |
| | | .sorted { lhs, rhs in |
| | | if lhs.score != rhs.score { |
| | | return lhs.score > rhs.score |
| | | } |
| | | return lhs.record.id < rhs.record.id |
| | | } |
| | | |
| | | guard let best = scoredHosts.first else { return nil } |
| | | if scoredHosts.count == 1 { |
| | | return best.record |
| | | } |
| | | |
| | | let sameSpaceCompetitors = scoredHosts.filter { $0.record.spaceIDs == best.record.spaceIDs } |
| | | if sameSpaceCompetitors.count == scoredHosts.count { |
| | | return best.record |
| | | } |
| | | |
| | | let secondBest = scoredHosts[1] |
| | | return best.score - secondBest.score >= minimumBestScoreGap ? best.record : nil |
| | | } |
| | | |
| | | private static func sameProcess( |
| | | _ lhs: WindowEnumerationRecord, |
| | | _ rhs: WindowEnumerationRecord |
| | | ) -> Bool { |
| | | guard let lhsPID = lhs.app.processIdentifier, |
| | | let rhsPID = rhs.app.processIdentifier |
| | | else { |
| | | return false |
| | | } |
| | | |
| | | return lhsPID == rhsPID |
| | | } |
| | | |
| | | private static func overlapScore(_ lhs: CGRect?, _ rhs: CGRect?) -> CGFloat? { |
| | | guard let lhs, |
| | | let rhs, |
| | | !lhs.isEmpty, |
| | | !rhs.isEmpty |
| | | else { |
| | | return nil |
| | | } |
| | | |
| | | let intersection = lhs.intersection(rhs) |
| | | guard !intersection.isNull, |
| | | !intersection.isEmpty |
| | | else { |
| | | return nil |
| | | } |
| | | |
| | | let denominator = min(lhs.width * lhs.height, rhs.width * rhs.height) |
| | | guard denominator > 0 else { return nil } |
| | | return (intersection.width * intersection.height) / denominator |
| | | } |
| | | |
| | | private static func copy( |
| | | _ record: WindowEnumerationRecord, |
| | | isFullscreen: Bool, |
| | | spaceIDs: [UInt64] |
| | | ) -> WindowEnumerationRecord { |
| | | WindowEnumerationRecord( |
| | | id: record.id, |
| | | app: record.app, |
| | | activationPolicy: record.activationPolicy, |
| | | title: record.title, |
| | | identifierSource: record.identifierSource, |
| | | size: record.size, |
| | | frame: record.frame, |
| | | subrole: record.subrole, |
| | | isMinimized: record.isMinimized, |
| | | isFullscreen: isFullscreen, |
| | | isGhost: record.isGhost, |
| | | isDesktopElement: record.isDesktopElement, |
| | | isSystemCritical: record.isSystemCritical, |
| | | isInteractable: record.isInteractable, |
| | | hasAXBacking: record.hasAXBacking, |
| | | isOnscreen: record.isOnscreen, |
| | | spaceIDs: spaceIDs |
| | | ) |
| | | } |
| | | } |
| | |
| | | XCTAssertNil(WindowEnumerationPolicy.window(from: tinyRecord)) |
| | | } |
| | | |
| | | func testFinderTabSpaceAttributionInheritsFullscreenHostSpaceForMatchingFrame() { |
| | | let finderApp = AlignerApp( |
| | | bundleIdentifier: "com.apple.finder", |
| | | name: "访达", |
| | | category: .finder, |
| | | processIdentifier: 74678 |
| | | ) |
| | | let frame = CGRect(x: 0, y: 0, width: 960, height: 1080) |
| | | let host = WindowEnumerationRecord( |
| | | id: 33112, |
| | | app: finderApp, |
| | | title: "下载", |
| | | size: frame.size, |
| | | frame: frame, |
| | | subrole: .standard, |
| | | isFullscreen: true, |
| | | hasAXBacking: true, |
| | | spaceIDs: [843] |
| | | ) |
| | | let inactiveTab = WindowEnumerationRecord( |
| | | id: 42286, |
| | | app: finderApp, |
| | | title: "Appcache", |
| | | size: frame.size, |
| | | frame: frame, |
| | | subrole: .standard, |
| | | hasAXBacking: false, |
| | | isOnscreen: false |
| | | ) |
| | | |
| | | let attributed = FinderTabSpaceAttributionPolicy.attributedRecords([inactiveTab, host]) |
| | | |
| | | XCTAssertEqual(attributed[0].spaceIDs, [843]) |
| | | XCTAssertTrue(attributed[0].isFullscreen) |
| | | } |
| | | |
| | | func testFinderTabSpaceAttributionDoesNotGuessNoSpaceWindowsWithoutHostOverlap() { |
| | | let finderApp = AlignerApp( |
| | | bundleIdentifier: "com.apple.finder", |
| | | name: "访达", |
| | | category: .finder, |
| | | processIdentifier: 74678 |
| | | ) |
| | | let otherApp = AlignerApp( |
| | | bundleIdentifier: "com.example.Other", |
| | | name: "Other", |
| | | category: .generic, |
| | | processIdentifier: 74678 |
| | | ) |
| | | let hostFrame = CGRect(x: 0, y: 0, width: 960, height: 1080) |
| | | let farFrame = CGRect(x: 1200, y: 0, width: 500, height: 600) |
| | | let host = WindowEnumerationRecord( |
| | | id: 33112, |
| | | app: finderApp, |
| | | title: "下载", |
| | | size: hostFrame.size, |
| | | frame: hostFrame, |
| | | subrole: .standard, |
| | | isFullscreen: true, |
| | | hasAXBacking: true, |
| | | spaceIDs: [843] |
| | | ) |
| | | let farFinderTab = WindowEnumerationRecord( |
| | | id: 42286, |
| | | app: finderApp, |
| | | title: "Appcache", |
| | | size: farFrame.size, |
| | | frame: farFrame, |
| | | subrole: .standard, |
| | | hasAXBacking: false, |
| | | isOnscreen: false |
| | | ) |
| | | let nonFinderWindow = WindowEnumerationRecord( |
| | | id: 50222, |
| | | app: otherApp, |
| | | title: "Appcache", |
| | | size: hostFrame.size, |
| | | frame: hostFrame, |
| | | subrole: .standard, |
| | | hasAXBacking: false, |
| | | isOnscreen: false |
| | | ) |
| | | |
| | | let attributed = FinderTabSpaceAttributionPolicy.attributedRecords([farFinderTab, nonFinderWindow, host]) |
| | | |
| | | XCTAssertEqual(attributed[0].spaceIDs, []) |
| | | XCTAssertFalse(attributed[0].isFullscreen) |
| | | XCTAssertEqual(attributed[1].spaceIDs, []) |
| | | XCTAssertFalse(attributed[1].isFullscreen) |
| | | } |
| | | |
| | | func testFinderTabSpaceAttributionHandlesFinderSplitViewHostsByFrame() { |
| | | let finderApp = AlignerApp( |
| | | bundleIdentifier: "com.apple.finder", |
| | | name: "访达", |
| | | category: .finder, |
| | | processIdentifier: 74678 |
| | | ) |
| | | let leftFrame = CGRect(x: 0, y: 0, width: 960, height: 1080) |
| | | let rightFrame = CGRect(x: 960, y: 0, width: 960, height: 1080) |
| | | let leftHost = WindowEnumerationRecord( |
| | | id: 33112, |
| | | app: finderApp, |
| | | title: "下载", |
| | | size: leftFrame.size, |
| | | frame: leftFrame, |
| | | subrole: .standard, |
| | | isFullscreen: true, |
| | | hasAXBacking: true, |
| | | spaceIDs: [843] |
| | | ) |
| | | let rightHost = WindowEnumerationRecord( |
| | | id: 41557, |
| | | app: finderApp, |
| | | title: "应用程序", |
| | | size: rightFrame.size, |
| | | frame: rightFrame, |
| | | subrole: .standard, |
| | | isFullscreen: true, |
| | | hasAXBacking: true, |
| | | spaceIDs: [843] |
| | | ) |
| | | let leftInactiveTab = WindowEnumerationRecord( |
| | | id: 42286, |
| | | app: finderApp, |
| | | title: "Appcache", |
| | | size: leftFrame.size, |
| | | frame: leftFrame, |
| | | subrole: .standard, |
| | | hasAXBacking: false, |
| | | isOnscreen: false |
| | | ) |
| | | let rightInactiveTab = WindowEnumerationRecord( |
| | | id: 61385, |
| | | app: finderApp, |
| | | title: "build", |
| | | size: rightFrame.size, |
| | | frame: rightFrame, |
| | | subrole: .standard, |
| | | hasAXBacking: false, |
| | | isOnscreen: false |
| | | ) |
| | | |
| | | let attributed = FinderTabSpaceAttributionPolicy.attributedRecords([ |
| | | leftInactiveTab, |
| | | rightInactiveTab, |
| | | leftHost, |
| | | rightHost |
| | | ]) |
| | | |
| | | XCTAssertEqual(attributed[0].spaceIDs, [843]) |
| | | XCTAssertEqual(attributed[1].spaceIDs, [843]) |
| | | XCTAssertTrue(attributed[0].isFullscreen) |
| | | XCTAssertTrue(attributed[1].isFullscreen) |
| | | } |
| | | |
| | | func testWindowEnumerationPolicyKeepsMinimizedAndFullscreenCGOnlyOffscreenWindows() { |
| | | let minimized = makeWindowRecord( |
| | | title: "Minimized Document.md", |
| New file |
| | |
| | | #!/bin/bash |
| | | # Round01 real-desktop QA for Finder tabs/pages. It verifies that CG-only |
| | | # Finder pages inherit the high-confidence fullscreen Space from their visible |
| | | # host, and that clicking such a card actually selects the matching Finder tab |
| | | # instead of only reporting a WindowServer activation success. |
| | | |
| | | 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" |
| | | 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}" |
| | | |
| | | fail() { |
| | | echo "Round01 Finder tabs live QA failed: $*" >&2 |
| | | exit 1 |
| | | } |
| | | |
| | | aligner_pids_for_current_app() { |
| | | ps -axo pid=,args= | while read -r pid command; do |
| | | case "$command" in |
| | | "$APP/Contents/MacOS/Aligner"*) echo "$pid" ;; |
| | | esac |
| | | 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" |
| | | } |
| | | |
| | | dev_log_size() { |
| | | if [ -f "$DEV_LOG" ]; then |
| | | stat -f%z "$DEV_LOG" |
| | | else |
| | | echo 0 |
| | | fi |
| | | } |
| | | |
| | | extract_dev_log_since() { |
| | | local offset="$1" |
| | | local output="$2" |
| | | if [ ! -f "$DEV_LOG" ]; then |
| | | : >"$output" |
| | | return |
| | | fi |
| | | |
| | | dd if="$DEV_LOG" bs=1 skip="$offset" 2>/dev/null >"$output" || : >"$output" |
| | | } |
| | | |
| | | wait_for_report() { |
| | | local report="$1" |
| | | local mode="$2" |
| | | /usr/bin/python3 - "$report" "$REPORT_WAIT" "$mode" <<'PY' |
| | | import json |
| | | import sys |
| | | import time |
| | | |
| | | path = sys.argv[1] |
| | | timeout = float(sys.argv[2]) |
| | | mode = sys.argv[3] |
| | | deadline = time.monotonic() + timeout |
| | | last_report = None |
| | | |
| | | def ready(report): |
| | | if report.get("snapshotLoaded") is not True: |
| | | return False |
| | | if mode == "loaded": |
| | | return True |
| | | if mode == "activated": |
| | | return ( |
| | | report.get("quickSwitchVisible") is False |
| | | and report.get("lastActivationResult") is not None |
| | | ) |
| | | return False |
| | | |
| | | while time.monotonic() < deadline: |
| | | try: |
| | | with open(path, "r", encoding="utf-8") as file: |
| | | report = json.load(file) |
| | | last_report = report |
| | | if ready(report): |
| | | sys.exit(0) |
| | | except FileNotFoundError: |
| | | pass |
| | | except json.JSONDecodeError: |
| | | pass |
| | | time.sleep(0.2) |
| | | |
| | | if last_report is not None: |
| | | print(json.dumps(last_report, indent=2, ensure_ascii=False), file=sys.stderr) |
| | | print(f"Finder tabs report {path} did not reach mode={mode} within {timeout:.1f}s", file=sys.stderr) |
| | | sys.exit(1) |
| | | PY |
| | | } |
| | | |
| | | run_snapshot_dump() { |
| | | local snapshot="$1" |
| | | local stderr_log="$2" |
| | | stop_current_aligner |
| | | ALIGNER_DIAGNOSTICS_INCLUDE_SENSITIVE=1 \ |
| | | "$APP/Contents/MacOS/Aligner" \ |
| | | --round0-skip-permissions \ |
| | | --round01-dump-window-snapshot \ |
| | | --round01-dump-window-snapshot-pretty >"$snapshot" 2>"$stderr_log" |
| | | } |
| | | |
| | | run_report() { |
| | | local report="$1" |
| | | local app_log="$2" |
| | | stop_current_aligner |
| | | rm -f "$report" |
| | | ALIGNER_DIAGNOSTICS_INCLUDE_SENSITIVE=1 \ |
| | | "$APP/Contents/MacOS/Aligner" \ |
| | | --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" |
| | | } |
| | | |
| | | run_click() { |
| | | local app_group_index="$1" |
| | | local window_index="$2" |
| | | local report="$3" |
| | | local app_log="$4" |
| | | stop_current_aligner |
| | | rm -f "$report" |
| | | ALIGNER_DIAGNOSTICS_INCLUDE_SENSITIVE=1 \ |
| | | "$APP/Contents/MacOS/Aligner" \ |
| | | --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" |
| | | } |
| | | |
| | | focused_finder_window_report() { |
| | | local output="$1" |
| | | swift - "$output" <<'SWIFT' |
| | | import AppKit |
| | | import ApplicationServices |
| | | import Foundation |
| | | |
| | | func fail(_ message: String, code: Int32 = 2) -> Never { |
| | | fputs(message + "\n", stderr) |
| | | exit(code) |
| | | } |
| | | |
| | | func jsonString(_ value: String) -> String { |
| | | let data = try! JSONSerialization.data(withJSONObject: ["value": value], options: []) |
| | | let text = String(data: data, encoding: .utf8)! |
| | | let prefix = "{\"value\":\"" |
| | | let suffix = "\"}" |
| | | return String(text.dropFirst(prefix.count).dropLast(suffix.count)) |
| | | } |
| | | |
| | | func stableFingerprint(_ text: String) -> String { |
| | | var hash: UInt64 = 0xcbf29ce484222325 |
| | | for byte in text.utf8 { |
| | | hash ^= UInt64(byte) |
| | | hash = hash &* 0x100000001b3 |
| | | } |
| | | return String(format: "%016llx", hash) |
| | | } |
| | | |
| | | func title(of element: AXUIElement) -> String? { |
| | | var value: CFTypeRef? |
| | | guard AXUIElementCopyAttributeValue(element, kAXTitleAttribute as CFString, &value) == .success else { |
| | | return nil |
| | | } |
| | | return value as? String |
| | | } |
| | | |
| | | func focusedWindowID(_ element: AXUIElement) -> UInt32? { |
| | | var value: CFTypeRef? |
| | | if AXUIElementCopyAttributeValue(element, "AXWindowNumber" as CFString, &value) == .success, |
| | | let number = value as? NSNumber { |
| | | return number.uint32Value |
| | | } |
| | | return nil |
| | | } |
| | | |
| | | let outputPath = CommandLine.arguments[1] |
| | | guard let finder = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == "com.apple.finder" }) else { |
| | | fail("Finder is not running") |
| | | } |
| | | |
| | | let appElement = AXUIElementCreateApplication(finder.processIdentifier) |
| | | var windowValue: CFTypeRef? |
| | | var axError = AXUIElementCopyAttributeValue(appElement, kAXFocusedWindowAttribute as CFString, &windowValue) |
| | | if axError != .success || windowValue == nil || CFGetTypeID(windowValue!) != AXUIElementGetTypeID() { |
| | | axError = AXUIElementCopyAttributeValue(appElement, kAXMainWindowAttribute as CFString, &windowValue) |
| | | } |
| | | guard axError == .success, |
| | | let rawWindow = windowValue, |
| | | CFGetTypeID(rawWindow) == AXUIElementGetTypeID() |
| | | else { |
| | | fail("Finder focused/main window is unavailable: \(axError)") |
| | | } |
| | | |
| | | let window = rawWindow as! AXUIElement |
| | | let windowTitle = title(of: window) ?? "" |
| | | let output = """ |
| | | { |
| | | "bundleIdentifier": "com.apple.finder", |
| | | "pid": \(finder.processIdentifier), |
| | | "focusedWindowID": \(focusedWindowID(window).map(String.init) ?? "null"), |
| | | "title": "\(jsonString(windowTitle))", |
| | | "titleLength": \(windowTitle.count), |
| | | "titleHash": "\(stableFingerprint(windowTitle))" |
| | | } |
| | | """ |
| | | try output.write(toFile: outputPath, atomically: true, encoding: .utf8) |
| | | SWIFT |
| | | } |
| | | |
| | | extract_attributed_ids() { |
| | | local log_file="$1" |
| | | /usr/bin/python3 - "$log_file" <<'PY' |
| | | import re |
| | | import sys |
| | | |
| | | ids = [] |
| | | for line in open(sys.argv[1], "r", encoding="utf-8", errors="replace"): |
| | | if "event=windowEnumeration.finderTabSpaceAttribution" not in line: |
| | | continue |
| | | match = re.search(r'attributedWindowIDs="?(\[[^\]"]*\])"?', line) |
| | | if not match: |
| | | continue |
| | | ids = [int(value) for value in re.findall(r"\d+", match.group(1))] |
| | | |
| | | if not ids: |
| | | print("No attributed Finder tab/page windowIDs found in this QA run", file=sys.stderr) |
| | | sys.exit(3) |
| | | |
| | | print(",".join(str(value) for value in sorted(set(ids)))) |
| | | PY |
| | | } |
| | | |
| | | select_target() { |
| | | 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 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} |
| | | with open(snapshot_path, "r", encoding="utf-8") as file: |
| | | snapshot = json.load(file) |
| | | with open(report_path, "r", encoding="utf-8") as file: |
| | | report = json.load(file) |
| | | |
| | | def require(condition, message): |
| | | if not condition: |
| | | print(message, file=sys.stderr) |
| | | sys.exit(4) |
| | | |
| | | snapshot_windows = {window.get("id"): window for window in snapshot.get("windows", [])} |
| | | finder_attributed = [ |
| | | window for window_id, window in snapshot_windows.items() |
| | | if window_id in attributed_ids |
| | | and window.get("app", {}).get("bundleIdentifier") == "com.apple.finder" |
| | | and window.get("identifierSource") == "cgWindow" |
| | | and window.get("spaceIDs") |
| | | and window.get("title") |
| | | ] |
| | | require(finder_attributed, "snapshot must contain at least one attributed Finder CG-only tab/page") |
| | | |
| | | root = report.get("rootView", {}) |
| | | cards = [] |
| | | for column in root.get("waterfallColumns", []): |
| | | if column.get("bundleIdentifier") != "com.apple.finder": |
| | | continue |
| | | for card in column.get("cards", []): |
| | | if card.get("windowID") not in attributed_ids: |
| | | continue |
| | | visible = card.get("visibleFrame") or {} |
| | | if visible.get("width", 0) <= 1 or visible.get("height", 0) <= 1: |
| | | continue |
| | | title = card.get("title") or snapshot_windows.get(card.get("windowID"), {}).get("title") or "" |
| | | if not title.strip(): |
| | | continue |
| | | cards.append((column, card, title)) |
| | | |
| | | require(cards, "Quick Switch report must expose at least one visible attributed Finder tab/page card") |
| | | |
| | | title_counts = {} |
| | | for _, _, title in cards: |
| | | title_counts[title] = title_counts.get(title, 0) + 1 |
| | | |
| | | cards.sort(key=lambda item: ( |
| | | 0 if title_counts[item[2]] == 1 else 1, |
| | | 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"), |
| | | } |
| | | |
| | | 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']}") |
| | | PY |
| | | } |
| | | |
| | | assert_click_report() { |
| | | local report="$1" |
| | | local target="$2" |
| | | /usr/bin/python3 - "$report" "$target" <<'PY' |
| | | import json |
| | | import sys |
| | | |
| | | with open(sys.argv[1], "r", encoding="utf-8") as file: |
| | | report = json.load(file) |
| | | with open(sys.argv[2], "r", encoding="utf-8") as file: |
| | | target = json.load(file) |
| | | |
| | | def require(condition, message): |
| | | if not condition: |
| | | print(message, file=sys.stderr) |
| | | print(json.dumps({ |
| | | "targetWindowID": target.get("windowID"), |
| | | "lastCommittedWindowID": report.get("lastCommittedWindowID"), |
| | | "lastActivationWindowID": report.get("lastActivationWindowID"), |
| | | "lastActivationResult": report.get("lastActivationResult"), |
| | | "lastActivationError": report.get("lastActivationError"), |
| | | "quickSwitchVisible": report.get("quickSwitchVisible"), |
| | | }, indent=2, ensure_ascii=False), file=sys.stderr) |
| | | sys.exit(5) |
| | | |
| | | require(report.get("lastCommittedWindowID") == target["windowID"], "commit must target the clicked Finder card") |
| | | require(report.get("lastActivationWindowID") == target["windowID"], "activation must receive exact Finder tab/page windowID") |
| | | require(report.get("lastActivationResult") == "activated", "activation result must be activated") |
| | | require(report.get("quickSwitchVisible") is False, "Quick Switch must close after click activation") |
| | | PY |
| | | } |
| | | |
| | | assert_focused_finder_title() { |
| | | local target="$1" |
| | | local focused="$2" |
| | | /usr/bin/python3 - "$target" "$focused" <<'PY' |
| | | import json |
| | | import sys |
| | | |
| | | with open(sys.argv[1], "r", encoding="utf-8") as file: |
| | | target = json.load(file) |
| | | with open(sys.argv[2], "r", encoding="utf-8") as file: |
| | | focused = json.load(file) |
| | | |
| | | target_title = target.get("title", "").strip() |
| | | focused_title = focused.get("title", "").strip() |
| | | if target_title != focused_title: |
| | | print("Focused Finder window title must match clicked tab/page title", file=sys.stderr) |
| | | print(json.dumps({ |
| | | "targetWindowID": target.get("windowID"), |
| | | "targetTitleHash": target.get("titleHash"), |
| | | "targetTitleLength": target.get("titleLength"), |
| | | "focusedWindowID": focused.get("focusedWindowID"), |
| | | "focusedTitleHash": focused.get("titleHash"), |
| | | "focusedTitleLength": focused.get("titleLength"), |
| | | }, indent=2, ensure_ascii=False), file=sys.stderr) |
| | | sys.exit(6) |
| | | |
| | | print(json.dumps({ |
| | | "case": "finderTabRealActivation", |
| | | "targetWindowID": target.get("windowID"), |
| | | "targetSpaceID": target.get("primarySpaceID"), |
| | | "targetTitleHash": target.get("titleHash"), |
| | | "targetTitleLength": target.get("titleLength"), |
| | | "focusedWindowID": focused.get("focusedWindowID"), |
| | | "focusedTitleHash": focused.get("titleHash"), |
| | | "focusedTitleLength": focused.get("titleLength"), |
| | | }, indent=2, ensure_ascii=False)) |
| | | PY |
| | | } |
| | | |
| | | mkdir -p "$REPORT_DIR" |
| | | stop_current_aligner |
| | | "$SCRIPT_DIR/package-app.sh" >&2 |
| | | |
| | | SNAPSHOT_JSON="$REPORT_DIR/snapshot.json" |
| | | SNAPSHOT_STDERR="$REPORT_DIR/snapshot.stderr.log" |
| | | 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" |
| | | |
| | | SNAPSHOT_LOG_OFFSET="$(dev_log_size)" |
| | | run_snapshot_dump "$SNAPSHOT_JSON" "$SNAPSHOT_STDERR" |
| | | sleep 0.4 |
| | | extract_dev_log_since "$SNAPSHOT_LOG_OFFSET" "$SNAPSHOT_DEV_LOG" |
| | | 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")" |
| | | |
| | | 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" |
| | | |
| | | assert_click_report "$CLICK_REPORT" "$TARGET_JSON" |
| | | assert_focused_finder_title "$TARGET_JSON" "$FOCUSED_JSON" |
| | | |
| | | stop_current_aligner |
| | | |
| | | cat <<EOF |
| | | Round01 Finder tabs live QA passed |
| | | 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. |
| | | EOF |