From 6983a4ed2137bf1a8377e5d1cf82183e02e617f8 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Sat, 13 Jun 2026 14:08:17 +0800
Subject: [PATCH] Fix multi-page targeting and split view tile QA
---
C3.tools/round1-space-lane-split-view-fixture-qa.sh | 5
C1.source/Sources/Aligner/QuickSwitchSnapshotLoader.swift | 53 +++++
C3.tools/round1-main-ui-qa.sh | 4
C3.tools/round1-app-column-alignment-fixture-qa.sh | 8
C1.source/Sources/Aligner/QuickSwitchRootView.swift | 45 +++-
C1.source/Resources/Aligner-Info.plist | 4
C1.source/Sources/Aligner/AlignerApplicationDelegate.swift | 4
C1.source/Sources/Aligner/QuickSwitchSessionController.swift | 2
C1.source/Sources/Aligner/Infrastructure/Windows/CGWindowAXWindowService.swift | 197 +++++++++++++++++-
C3.tools/round1-multi-page-identity-fixture-qa.sh | 233 +++++++++++++++++++++++
10 files changed, 517 insertions(+), 38 deletions(-)
diff --git a/C1.source/Resources/Aligner-Info.plist b/C1.source/Resources/Aligner-Info.plist
index ca3405f..dd38074 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.62</string>
+ <string>0.0.63</string>
<key>CFBundleVersion</key>
- <string>20260612.2049</string>
+ <string>20260613.1401</string>
<key>LSMinimumSystemVersion</key>
<string>26.0</string>
<key>NSHighResolutionCapable</key>
diff --git a/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift b/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift
index 3334d05..ec6e5bf 100644
--- a/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift
+++ b/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift
@@ -666,6 +666,10 @@
return FixtureQuickSwitchSnapshotLoader(appCount: 1, mode: .splitView)
}
+ if round1QuickSwitchOptions.fixtureMultiPageIdentity {
+ return FixtureQuickSwitchSnapshotLoader(appCount: 1, mode: .multiPageIdentity)
+ }
+
if let fixtureAppCount = round1QuickSwitchOptions.fixtureAppCount {
return FixtureQuickSwitchSnapshotLoader(
appCount: fixtureAppCount,
diff --git a/C1.source/Sources/Aligner/Infrastructure/Windows/CGWindowAXWindowService.swift b/C1.source/Sources/Aligner/Infrastructure/Windows/CGWindowAXWindowService.swift
index 55b5f44..87b6c01 100644
--- a/C1.source/Sources/Aligner/Infrastructure/Windows/CGWindowAXWindowService.swift
+++ b/C1.source/Sources/Aligner/Infrastructure/Windows/CGWindowAXWindowService.swift
@@ -162,16 +162,12 @@
let privateActivationOutcome = activateViaPrivateWindowServerAPI(window)
let didPrivatelyActivate = privateActivationOutcome?.succeeded == true
- let axWindow = AXWindowMetadataReader
- .metadata(appCategorizer: appCategorizer)
- .first { metadata in
- if let windowID = metadata.windowID {
- return windowID == window.id
- }
-
- return metadata.processIdentifier == window.app.processIdentifier
- && metadata.title == window.title
- }
+ let axWindow = axWindow(
+ for: window,
+ in: AXWindowMetadataReader.metadata(appCategorizer: appCategorizer),
+ operation: "windowActivation",
+ matchAttempt: "initial"
+ )
DevelopmentDiagnostics.log("windowActivation.activate.axLookup", [
"windowID": window.id,
@@ -273,6 +269,15 @@
var matchedAXWindow = axWindow(for: window, in: axMetadata)
var finalAXMetadata = axMetadata
+ let shouldPreferFocusedPageClose = matchedAXWindow?.windowID != window.id
+ if shouldPreferFocusedPageClose,
+ let focusedPageCloseResult = closeFocusedPageViaCommandW(
+ window: window,
+ reason: matchedAXWindow == nil ? "axMissing" : "nonDirectAXMatch"
+ ) {
+ return focusedPageCloseResult
+ }
+
if matchedAXWindow == nil {
Thread.sleep(forTimeInterval: 0.12)
finalAXMetadata = AXWindowMetadataReader.metadata(appCategorizer: appCategorizer)
@@ -289,6 +294,14 @@
"matchingSizeCandidateCount": matchingAXSizeCandidateCount(for: window, in: finalAXMetadata)
])
return .windowNotFound
+ }
+
+ if matchedAXWindow.windowID != window.id,
+ let focusedPageCloseResult = closeFocusedPageViaCommandW(
+ window: window,
+ reason: "retryNonDirectAXMatch"
+ ) {
+ return focusedPageCloseResult
}
guard let closeButton = closeButton(for: matchedAXWindow) else {
@@ -364,13 +377,82 @@
}
}
+ private func closeFocusedPageViaCommandW(
+ window: AlignerWindow,
+ reason: String
+ ) -> WindowCloseResult? {
+ guard window.identifierSource == .cgWindow,
+ let processIdentifier = window.app.processIdentifier
+ else {
+ DevelopmentDiagnostics.log("windowClose.commandW.skipped", [
+ "windowID": window.id,
+ "reason": reason,
+ "identifierSource": String(describing: window.identifierSource),
+ "hasPID": window.app.processIdentifier != nil
+ ])
+ return nil
+ }
+
+ 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)
+ guard postCommandW(to: processIdentifier) else {
+ DevelopmentDiagnostics.log("windowClose.commandW.postFailed", [
+ "windowID": window.id,
+ "pid": processIdentifier,
+ "reason": reason
+ ])
+ return .failed("commandWPostFailed")
+ }
+
+ DevelopmentDiagnostics.log("windowClose.commandW.requested", [
+ "windowID": window.id,
+ "pid": processIdentifier,
+ "reason": reason
+ ])
+ return .requested
+ }
+
+ private func postCommandW(to processIdentifier: Int32) -> Bool {
+ let source = CGEventSource(stateID: .combinedSessionState)
+ guard let keyDown = CGEvent(
+ keyboardEventSource: source,
+ virtualKey: Self.commandWVirtualKeyCode,
+ keyDown: true
+ ),
+ let keyUp = CGEvent(
+ keyboardEventSource: source,
+ virtualKey: Self.commandWVirtualKeyCode,
+ keyDown: false
+ )
+ else {
+ return false
+ }
+
+ keyDown.flags = .maskCommand
+ keyUp.flags = .maskCommand
+ keyDown.postToPid(processIdentifier)
+ keyUp.postToPid(processIdentifier)
+ return true
+ }
+
private func axWindow(
for window: AlignerWindow,
in axMetadata: [AXWindowMetadata],
+ operation: String = "windowClose",
matchAttempt: String = "initial"
) -> AXWindowMetadata? {
if let directMatch = axMetadata.first(where: { $0.windowID == window.id }) {
- DevelopmentDiagnostics.log("windowClose.axMatch.directWindowID", [
+ DevelopmentDiagnostics.log("\(operation).axMatch.directWindowID", [
"windowID": window.id,
"pid": window.app.processIdentifier,
"attempt": matchAttempt
@@ -382,12 +464,35 @@
return nil
}
+ if let geometryFingerprint = axWindowGeometryFingerprint(for: window) {
+ let geometryMatches = axMetadata.filter { metadata in
+ AXWindowGeometryFingerprint(metadata) == geometryFingerprint
+ }
+ if geometryMatches.count == 1 {
+ DevelopmentDiagnostics.log("\(operation).axMatch.geometryFingerprint", [
+ "windowID": window.id,
+ "pid": processIdentifier,
+ "attempt": matchAttempt
+ ])
+ return geometryMatches[0]
+ }
+
+ if geometryMatches.count > 1 {
+ DevelopmentDiagnostics.log("\(operation).axMatch.geometryFingerprintAmbiguous", [
+ "windowID": window.id,
+ "pid": processIdentifier,
+ "candidateCount": geometryMatches.count,
+ "attempt": matchAttempt
+ ])
+ }
+ }
+
if let cgFingerprint = cgWindowFingerprint(for: window) {
let fingerprintMatches = axMetadata.filter { metadata in
AXWindowFingerprint(metadata) == cgFingerprint
}
if fingerprintMatches.count == 1 {
- DevelopmentDiagnostics.log("windowClose.axMatch.fingerprint", [
+ DevelopmentDiagnostics.log("\(operation).axMatch.fingerprint", [
"windowID": window.id,
"pid": processIdentifier,
"attempt": matchAttempt
@@ -396,10 +501,11 @@
}
if fingerprintMatches.count > 1 {
- DevelopmentDiagnostics.log("windowClose.axMatch.fingerprintAmbiguous", [
+ DevelopmentDiagnostics.log("\(operation).axMatch.fingerprintAmbiguous", [
"windowID": window.id,
"pid": processIdentifier,
- "candidateCount": fingerprintMatches.count
+ "candidateCount": fingerprintMatches.count,
+ "attempt": matchAttempt
])
}
}
@@ -409,8 +515,8 @@
metadata.processIdentifier == processIdentifier
&& normalizedWindowTitle(metadata.title ?? "") == normalizedTitle
}
- if titleMatches.count == 1 {
- DevelopmentDiagnostics.log("windowClose.axMatch.normalizedTitle", [
+ if !normalizedTitle.isEmpty, titleMatches.count == 1 {
+ DevelopmentDiagnostics.log("\(operation).axMatch.normalizedTitle", [
"windowID": window.id,
"pid": processIdentifier,
"attempt": matchAttempt
@@ -424,7 +530,7 @@
processIdentifier: processIdentifier,
in: axMetadata
) {
- DevelopmentDiagnostics.log("windowClose.axMatch.finderFallback", [
+ DevelopmentDiagnostics.log("\(operation).axMatch.finderFallback", [
"windowID": window.id,
"pid": processIdentifier,
"attempt": matchAttempt
@@ -433,10 +539,11 @@
}
if titleMatches.count > 1 {
- DevelopmentDiagnostics.log("windowClose.axMatch.titleAmbiguous", [
+ DevelopmentDiagnostics.log("\(operation).axMatch.titleAmbiguous", [
"windowID": window.id,
"pid": processIdentifier,
- "candidateCount": titleMatches.count
+ "candidateCount": titleMatches.count,
+ "attempt": matchAttempt
])
}
@@ -475,6 +582,29 @@
title: normalizedWindowTitle(title(rawWindow: rawWindow, axWindow: nil)),
size: bounds(rawWindow[kCGWindowBounds as String]).size
)
+ }
+
+ private func axWindowGeometryFingerprint(for window: AlignerWindow) -> AXWindowGeometryFingerprint? {
+ guard let processIdentifier = window.app.processIdentifier else { return nil }
+ let frame = cgWindowFrame(for: window) ?? window.frame
+ return AXWindowGeometryFingerprint(
+ processIdentifier: processIdentifier,
+ title: normalizedWindowTitle(window.title),
+ frame: frame
+ )
+ }
+
+ private func cgWindowFrame(for window: AlignerWindow) -> CGRect? {
+ guard let rawWindows = CGWindowListCopyWindowInfo(
+ [.optionIncludingWindow],
+ CGWindowID(window.id)
+ ) as? [[String: Any]],
+ let rawWindow = rawWindows.first
+ else {
+ return nil
+ }
+
+ return bounds(rawWindow[kCGWindowBounds as String])
}
private func matchingAXTitleCandidateCount(
@@ -984,6 +1114,7 @@
}
}
+ private static let commandWVirtualKeyCode: CGKeyCode = 13
}
private struct AXWindowMetadata {
@@ -1053,6 +1184,34 @@
}
}
+private struct AXWindowGeometryFingerprint: Hashable {
+ let processIdentifier: Int32
+ let title: String
+ let x: Int
+ let y: Int
+ let width: Int
+ let height: Int
+
+ init?(processIdentifier: Int32, title: String, frame: CGRect?) {
+ guard let frame else { return nil }
+
+ self.processIdentifier = processIdentifier
+ self.title = title
+ self.x = Int(frame.origin.x.rounded())
+ self.y = Int(frame.origin.y.rounded())
+ self.width = Int(frame.size.width.rounded())
+ self.height = Int(frame.size.height.rounded())
+ }
+
+ init?(_ metadata: AXWindowMetadata) {
+ self.init(
+ processIdentifier: metadata.processIdentifier,
+ title: normalizedWindowTitle(metadata.title ?? ""),
+ frame: metadata.frame
+ )
+ }
+}
+
private struct RestorableAXWindow {
let processIdentifier: Int32
let element: AXUIElement
diff --git a/C1.source/Sources/Aligner/QuickSwitchRootView.swift b/C1.source/Sources/Aligner/QuickSwitchRootView.swift
index 4ea3319..3d16422 100644
--- a/C1.source/Sources/Aligner/QuickSwitchRootView.swift
+++ b/C1.source/Sources/Aligner/QuickSwitchRootView.swift
@@ -3087,13 +3087,16 @@
private func layoutSpaceLaneSegmentContents(_ segment: SpaceLaneSegmentLayers) {
let bounds = segment.containerLayer.bounds
+ let splitAppNames = splitViewAppNames(for: segment.space)
+ let isSplitView = splitAppNames.count >= 2
segment.labelLayer.frame = CGRect(x: 10, y: bounds.height - 29, width: max(1, bounds.width - 48), height: 20)
segment.countLayer.frame = CGRect(x: bounds.width - 34, y: bounds.height - 29, width: 24, height: 20)
+ segment.countLayer.string = isSplitView ? "" : "\(segment.space.windowCount)"
+ segment.countLayer.isHidden = isSplitView
if segment.space.type == .fullscreen {
let markerFrame = fullscreenMarkerFrame(in: bounds)
- let splitAppNames = splitViewAppNames(for: segment.space)
- segment.appLayer.isHidden = splitAppNames.count >= 2
- if splitAppNames.count >= 2 {
+ segment.appLayer.isHidden = isSplitView
+ if isSplitView {
let labelY = markerFrame.midY - 8.5
let halfWidth = markerFrame.width / 2
for (index, splitAppLayer) in segment.splitAppLayers.enumerated() {
@@ -3128,7 +3131,7 @@
layoutFullscreenMarkerLayer(
fullscreenMarkerLayer,
in: bounds,
- isSplitView: splitViewAppNames(for: segment.space).count >= 2
+ isSplitView: isSplitView
)
}
@@ -4740,17 +4743,11 @@
let bounds = shapeLayer.bounds
if isSplitView {
- let insetBounds = bounds.insetBy(dx: 0.6, dy: 0.6)
- let radius = min(7, max(5, insetBounds.height * 0.22))
let path = CGMutablePath()
- path.addRoundedRect(
- in: insetBounds,
- cornerWidth: radius,
- cornerHeight: radius
- )
- path.move(to: CGPoint(x: bounds.midX, y: insetBounds.minY + 1.5))
- path.addLine(to: CGPoint(x: bounds.midX, y: insetBounds.maxY - 1.5))
+ path.move(to: CGPoint(x: bounds.midX, y: bounds.minY))
+ path.addLine(to: CGPoint(x: bounds.midX, y: bounds.maxY))
shapeLayer.path = path
+ shapeLayer.lineCap = .butt
return
}
@@ -4774,6 +4771,7 @@
path.addLine(to: CGPoint(x: 0, y: bounds.maxY - cornerLength))
shapeLayer.path = path
+ shapeLayer.lineCap = .round
}
private func fullscreenMarkerFrame(in segmentBounds: CGRect) -> CGRect {
@@ -5176,6 +5174,10 @@
markerLayer: segment.fullscreenMarkerLayer,
splitAppNames: splitAppNames
),
+ "fullscreenMarkerPathStyle": fullscreenMarkerPathStyle(
+ markerLayer: segment.fullscreenMarkerLayer,
+ splitAppNames: splitAppNames
+ ),
"fullscreenMarkerFrame": fullscreenMarkerFrame.map { dictionary(from: $0) } ?? NSNull(),
"fullscreenMarkerCenterRatio": fullscreenMarkerCenterRatio ?? NSNull(),
"splitViewAppNames": splitAppNames,
@@ -5187,6 +5189,8 @@
"windowBlockColors": segment.windowBlocks.map { colorDictionary(from: $0.backgroundColor) },
"windowBlockFrames": segment.windowBlocks.map { dictionary(from: $0.frame) },
"labelColor": colorDictionary(from: segment.labelLayer.foregroundColor),
+ "countText": string(from: segment.countLayer.string),
+ "countVisible": !segment.countLayer.isHidden && segment.countLayer.opacity > 0,
"countColor": colorDictionary(from: segment.countLayer.foregroundColor),
"appColor": colorDictionary(from: segment.appLayer.foregroundColor),
"fullscreenMarkerStrokeColor": colorDictionary(
@@ -5229,6 +5233,11 @@
private func fullscreenMarkerKind(markerLayer: CALayer?, splitAppNames: [String]) -> String {
guard markerLayer != nil else { return "none" }
return splitAppNames.count >= 2 ? "splitViewPair" : "cornerBracket"
+ }
+
+ private func fullscreenMarkerPathStyle(markerLayer: CALayer?, splitAppNames: [String]) -> String {
+ guard markerLayer != nil else { return "none" }
+ return splitAppNames.count >= 2 ? "centerDividerOnly" : "cornerBracket"
}
private func visualStates(for space: QuickSwitchSpaceViewModel) -> [String] {
@@ -5282,6 +5291,16 @@
]
}
+ private func string(from textLayerValue: Any?) -> String {
+ if let string = textLayerValue as? String {
+ return string
+ }
+ if let attributedString = textLayerValue as? NSAttributedString {
+ return attributedString.string
+ }
+ return ""
+ }
+
private func colorDictionary(from color: CGColor?) -> [String: Double] {
guard let color,
let nsColor = NSColor(cgColor: color)?.usingColorSpace(.deviceRGB)
diff --git a/C1.source/Sources/Aligner/QuickSwitchSessionController.swift b/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
index 645a35b..7fa4f39 100644
--- a/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
+++ b/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
@@ -1479,6 +1479,7 @@
let fixtureActivation: Bool
let fixtureSpaceFilter: Bool
let fixtureSplitView: Bool
+ let fixtureMultiPageIdentity: Bool
let debugHoveredAppGroupIndex: Int?
let debugOverlayWidth: CGFloat?
let debugKeySequence: [String]
@@ -1508,6 +1509,7 @@
fixtureActivation: arguments.contains("--round01-fixture-window-activation"),
fixtureSpaceFilter: arguments.contains("--round01-fixture-space-filter"),
fixtureSplitView: arguments.contains("--round01-fixture-split-view"),
+ fixtureMultiPageIdentity: arguments.contains("--round01-fixture-multi-page-identity"),
debugHoveredAppGroupIndex: intValue(for: "--round01-debug-hover-app-index", in: arguments),
debugOverlayWidth: cgFloatValue(for: "--round01-debug-overlay-width", in: arguments),
debugKeySequence: stringListValue(for: "--round01-debug-key-sequence", in: arguments),
diff --git a/C1.source/Sources/Aligner/QuickSwitchSnapshotLoader.swift b/C1.source/Sources/Aligner/QuickSwitchSnapshotLoader.swift
index 1cce8b6..121e38f 100644
--- a/C1.source/Sources/Aligner/QuickSwitchSnapshotLoader.swift
+++ b/C1.source/Sources/Aligner/QuickSwitchSnapshotLoader.swift
@@ -17,6 +17,7 @@
case activation
case spaceFilter
case splitView
+ case multiPageIdentity
}
final class LiveQuickSwitchSnapshotLoader: QuickSwitchSnapshotLoading {
@@ -91,6 +92,9 @@
}
if mode == .splitView {
return splitViewSnapshot()
+ }
+ if mode == .multiPageIdentity {
+ return multiPageIdentitySnapshot()
}
return layoutSnapshot()
@@ -578,4 +582,53 @@
currentSpaceIDs: [1]
)
}
+
+ private func multiPageIdentitySnapshot() -> QuickSwitchSnapshotLoad {
+ let displayUUID = "fixture-display-a"
+ let displays = [
+ AlignerDisplay(
+ uuid: displayUUID,
+ physical: true,
+ spaces: [
+ AlignerSpace(id: 1, type: .user, displayUUID: displayUUID, index: 1)
+ ]
+ )
+ ]
+
+ let app = AlignerApp(
+ bundleIdentifier: "com.example.multipage.identity",
+ name: "Multi Page App",
+ category: .generic,
+ processIdentifier: 70_001
+ )
+
+ let windows = [
+ AlignerWindow(
+ id: 70_101,
+ app: app,
+ title: "Shared Page",
+ frame: CGRect(x: 120, y: 120, width: 920, height: 640),
+ spaceIDs: [1]
+ ),
+ AlignerWindow(
+ id: 70_102,
+ app: app,
+ title: "Shared Page",
+ frame: CGRect(x: 160, y: 140, width: 920, height: 640),
+ spaceIDs: [1]
+ ),
+ AlignerWindow(
+ id: 70_103,
+ app: app,
+ title: "Shared Page",
+ frame: CGRect(x: 200, y: 160, width: 920, height: 640),
+ spaceIDs: [1]
+ )
+ ]
+
+ return QuickSwitchSnapshotLoad(
+ snapshot: QuickSwitchSnapshotBuilder.snapshot(displays: displays, windows: windows),
+ currentSpaceIDs: [1]
+ )
+ }
}
diff --git a/C3.tools/round1-app-column-alignment-fixture-qa.sh b/C3.tools/round1-app-column-alignment-fixture-qa.sh
index 963fc2b..5e63221 100755
--- a/C3.tools/round1-app-column-alignment-fixture-qa.sh
+++ b/C3.tools/round1-app-column-alignment-fixture-qa.sh
@@ -28,9 +28,11 @@
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
+ 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
}
diff --git a/C3.tools/round1-main-ui-qa.sh b/C3.tools/round1-main-ui-qa.sh
index aadf711..aa5c4d2 100755
--- a/C3.tools/round1-main-ui-qa.sh
+++ b/C3.tools/round1-main-ui-qa.sh
@@ -110,6 +110,7 @@
run_step screenshot-session "$SCRIPT_DIR/round1-screenshot-session-qa.sh"
run_step keyboard-navigation "$SCRIPT_DIR/round1-keyboard-navigation-fixture-qa.sh"
run_step window-activation "$SCRIPT_DIR/round1-window-activation-fixture-qa.sh"
+run_step multi-page-identity "$SCRIPT_DIR/round1-multi-page-identity-fixture-qa.sh"
run_step mouse-interaction "$SCRIPT_DIR/round1-mouse-interaction-fixture-qa.sh"
run_step app-column-alignment "$SCRIPT_DIR/round1-app-column-alignment-fixture-qa.sh"
run_step horizontal-waterfall "$SCRIPT_DIR/round1-horizontal-waterfall-fixture-qa.sh"
@@ -124,5 +125,6 @@
Log directory: $LOG_DIR
Covered: trigger, overlay uniqueness, keyboard navigation, candidate filtering,
screenshots/fallbacks, minimized-window restore/activation, mouse activation,
-App column alignment, Split View Space Lane rendering, and final no-residual check.
+multi-page precise activation/close targeting, App column alignment,
+Split View Space Lane rendering, and final no-residual check.
EOF
diff --git a/C3.tools/round1-multi-page-identity-fixture-qa.sh b/C3.tools/round1-multi-page-identity-fixture-qa.sh
new file mode 100755
index 0000000..cbf0dbd
--- /dev/null
+++ b/C3.tools/round1-multi-page-identity-fixture-qa.sh
@@ -0,0 +1,233 @@
+#!/bin/bash
+# Round01 multi-page identity fixture QA. It verifies that Quick Switch keeps
+# same-App/same-title pages distinct for activation and close requests.
+
+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"
+BUNDLE_ID="com.ar.Aligner"
+CONFIRM_DEFAULTS_KEY="QuickSwitchConfirmBeforeClose"
+REPORT_WAIT="${ALIGNER_ROUND1_MULTI_PAGE_REPORT_WAIT:-7.0}"
+ACTIVATION_REPORT="$BUILD_REPORT_ROOT/round01-multi-page-activation-report.json"
+CLOSE_REPORT="$BUILD_REPORT_ROOT/round01-multi-page-close-report.json"
+
+fail() {
+ echo "Round01 multi-page identity fixture 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"
+}
+
+reset_close_confirmation_preference() {
+ defaults delete "$BUNDLE_ID" "$CONFIRM_DEFAULTS_KEY" >/dev/null 2>&1 || true
+}
+
+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):
+ root = report.get("rootView", {})
+ if report.get("snapshotLoaded") is not True:
+ return False
+ if mode == "activated":
+ return (
+ report.get("quickSwitchVisible") is False
+ and report.get("lastActivationResult") is not None
+ )
+ if mode == "verifiedFailure":
+ return (
+ report.get("quickSwitchVisible") is True
+ and report.get("lastCloseResult") is not None
+ and root.get("closeFeedbackKind") == "failure"
+ )
+ 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"multi-page identity report {path} did not reach mode={mode} within {timeout:.1f}s", file=sys.stderr)
+sys.exit(1)
+PY
+}
+
+run_case() {
+ local report="$1"
+ local sequence="$2"
+ local mode="$3"
+ local activation_flag="${4:-}"
+ local close_flag="${5:-}"
+
+ stop_current_aligner
+ reset_close_confirmation_preference
+ rm -f "$report"
+
+ local args=(
+ --round0-skip-permissions
+ --round01-open-quick-switch
+ --round01-waterfall-view-mode=vertical
+ --round01-fixture-multi-page-identity
+ --round01-disable-screenshot-refresh
+ --round01-debug-mouse-sequence="$sequence"
+ --round01-quick-switch-report="$report"
+ )
+ if [ -n "$activation_flag" ]; then
+ args+=(--round01-debug-window-activation)
+ fi
+ if [ -n "$close_flag" ]; then
+ args+=(--round01-debug-window-close)
+ fi
+
+ "$APP/Contents/MacOS/Aligner" "${args[@]}" &
+ APP_PID=$!
+
+ cleanup() {
+ kill "$APP_PID" 2>/dev/null || true
+ wait "$APP_PID" 2>/dev/null || true
+ stop_current_aligner
+ }
+ trap cleanup EXIT
+
+ wait_for_report "$report" "$mode"
+ kill "$APP_PID" 2>/dev/null || true
+ wait "$APP_PID" 2>/dev/null || true
+ trap - EXIT
+ stop_current_aligner
+}
+
+assert_activation_report() {
+ /usr/bin/python3 - "$ACTIVATION_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", {})
+columns = root.get("waterfallColumns", [])
+cards = columns[0].get("cards", []) if columns else []
+ids = [card.get("windowID") for card in cards]
+
+require(report.get("snapshotLoaded") is True, "activation fixture must load")
+require(report.get("quickSwitchVisible") is False, "activation must close Quick Switch")
+require(report.get("appCount") == 1, "multi-page fixture must expose one App")
+require(report.get("windowCount") == 3, "multi-page fixture must expose three pages")
+require(ids == [70101, 70102, 70103], "same-title pages must keep stable windowID order")
+require(report.get("lastCommittedAppGroupIndex") == 0, "activation must target App group 0")
+require(report.get("lastCommittedWindowIndex") == 1, "activation must target second page index")
+require(report.get("lastCommittedWindowID") == 70102, "activation commit must target page windowID 70102")
+require(report.get("lastActivationWindowID") == 70102, "activation service must receive exact page windowID 70102")
+require(report.get("lastActivationResult") == "activated", "debug activation must report activated")
+require(root.get("selectedWindowID") == 70102, "selectedWindowID must track exact activated page")
+
+print(json.dumps({
+ "case": "multiPageActivation",
+ "windowIDs": ids,
+ "lastActivationWindowID": report.get("lastActivationWindowID")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+assert_close_report() {
+ /usr/bin/python3 - "$CLOSE_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", {})
+columns = root.get("waterfallColumns", [])
+cards = columns[0].get("cards", []) if columns else []
+ids = [card.get("windowID") for card in cards]
+
+require(report.get("snapshotLoaded") is True, "close fixture must load")
+require(report.get("quickSwitchVisible") is True, "close verification failure must keep Quick Switch visible")
+require(report.get("appCount") == 1, "multi-page close fixture must expose one App")
+require(report.get("windowCount") == 3, "fixture must still expose all three pages when debug close does not remove them")
+require(ids == [70101, 70102, 70103], "close must not reorder or fake-remove same-title pages")
+require(report.get("lastCloseTargetKind") == "window", "close must target a window/page")
+require(report.get("lastCloseAppGroupIndex") == 0, "close must target App group 0")
+require(report.get("lastCloseWindowID") == 70103, "close service must receive exact page windowID 70103")
+require(report.get("lastCloseResult") == "requested", "debug close must report requested")
+require(report.get("suppressedCloseTargetCount") == 0, "requested-but-still-present must not suppress the card")
+require(root.get("closeFeedbackKind") == "failure", "still-present debug close must show verification failure")
+
+print(json.dumps({
+ "case": "multiPageClose",
+ "windowIDs": ids,
+ "lastCloseWindowID": report.get("lastCloseWindowID"),
+ "closeFeedbackKind": root.get("closeFeedbackKind")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+stop_current_aligner
+"$SCRIPT_DIR/package-app.sh" >&2
+
+run_case "$ACTIVATION_REPORT" "click-card:0:1" "activated" "activation" ""
+assert_activation_report
+
+run_case "$CLOSE_REPORT" "click-card-close:0:2,confirm-close" "verifiedFailure" "" "close"
+assert_close_report
+
+echo "Round01 multi-page identity fixture QA passed."
diff --git a/C3.tools/round1-space-lane-split-view-fixture-qa.sh b/C3.tools/round1-space-lane-split-view-fixture-qa.sh
index 335fcd6..6a7ce26 100755
--- a/C3.tools/round1-space-lane-split-view-fixture-qa.sh
+++ b/C3.tools/round1-space-lane-split-view-fixture-qa.sh
@@ -116,8 +116,11 @@
require(split.get("type") == "fullscreen", "Split View tile must still be a fullscreen Space")
require(split.get("windowCount") == 2, "Split View tile must count both fullscreen windows")
require(split.get("appCount") == 2, "Split View tile must count both Split View apps")
+require(split.get("countVisible") is False, "Split View tile must hide the top-right count label")
+require(split.get("countText") == "", "Split View tile must clear the top-right count text")
require(split.get("windowBlockCount") == 0, "Split View tile must not draw normal window blocks")
require("splitView" in split.get("visualStates", []), "Split View tile must expose splitView visual state")
+require(split.get("fullscreenMarkerPathStyle") == "centerDividerOnly", "Split View marker must draw only the center divider")
require(
split.get("splitViewAppNames") == ["左侧应用", "右侧应用"],
"Split View app names must be ordered by physical window frame from left to right"
@@ -153,6 +156,8 @@
"splitSpace": split.get("label"),
"splitNames": split.get("splitViewAppNames"),
"markerKind": split.get("fullscreenMarkerKind"),
+ "markerPathStyle": split.get("fullscreenMarkerPathStyle"),
+ "countVisible": split.get("countVisible"),
"singleFullscreenLabels": [segment.get("label") for segment in single_fullscreen],
}, indent=2, ensure_ascii=False))
PY
--
Gitblit v1.9.3