diff --git a/MousePortal/Models/WindowLayoutSnapshot.swift b/MousePortal/Models/WindowLayoutSnapshot.swift index a327725..7f7eb3c 100644 --- a/MousePortal/Models/WindowLayoutSnapshot.swift +++ b/MousePortal/Models/WindowLayoutSnapshot.swift @@ -38,6 +38,15 @@ struct WindowDisplaySnapshot: Codable, Equatable { } } +/// 只用于核验同一系统会话中仍然存活的窗口,不是跨应用重启的永久 ID。 +struct WindowRuntimeIdentity: Codable, Equatable { + let bundleIdentifier: String + let processIdentifier: Int32 + let processLaunchDate: Date + let sessionIdentifier: String + let windowID: CGWindowID +} + /// 单个普通窗口在其所属显示器内的相对位置。 struct WindowPlacement: Codable, Equatable { let bundleIdentifier: String @@ -53,6 +62,9 @@ struct WindowPlacement: Codable, Equatable { let relativeY: Double let width: Double let height: Double + var runtimeIdentity: WindowRuntimeIdentity? = nil + // CG 捕获无法确认 AX 类型;这类记录只能通过精确身份恢复,不能猜测标题或序号。 + var requiresExactIdentity: Bool? = nil } enum WindowLayoutSnapshotKind: String, Codable, Equatable { diff --git a/MousePortal/Services/WindowIdentityResolver.swift b/MousePortal/Services/WindowIdentityResolver.swift new file mode 100644 index 0000000..f1fbf77 --- /dev/null +++ b/MousePortal/Services/WindowIdentityResolver.swift @@ -0,0 +1,61 @@ +import AppKit +import ApplicationServices +import Darwin + +enum WindowIdentityResolver { + private typealias GetWindowID = @convention(c) (AXUIElement, UnsafeMutablePointer) -> AXError + + // 私有接口:用于精确关联 AX 对象与 WindowServer 窗口,不能用标题/尺寸推测。 + // 动态加载失败时禁用这一层身份核验,继续原有匹配流程。 + private static let getWindowID: GetWindowID? = { + guard let handle = dlopen( + "/System/Library/Frameworks/ApplicationServices.framework/Frameworks/HIServices.framework/HIServices", + RTLD_LAZY | RTLD_LOCAL + ) else { return nil } + guard let symbol = dlsym(handle, "_AXUIElementGetWindow") else { + dlclose(handle) + return nil + } + // 保留库句柄,确保函数指针在进程存续期间有效。 + return unsafeBitCast(symbol, to: GetWindowID.self) + }() + + static func windowID(for element: AXUIElement) -> CGWindowID? { + guard let getWindowID else { return nil } + var windowID = kCGNullWindowID + guard getWindowID(element, &windowID) == .success, + windowID != kCGNullWindowID else { return nil } + return windowID + } + + static func currentSessionIdentifier() -> String? { + var length = 0 + guard sysctlbyname("kern.bootsessionuuid", nil, &length, nil, 0) == 0, + length > 0 else { return nil } + var bootSession = [CChar](repeating: 0, count: length) + guard sysctlbyname("kern.bootsessionuuid", &bootSession, &length, nil, 0) == 0 else { return nil } + var auditInfo = auditinfo_addr_t() + guard getaudit_addr(&auditInfo, Int32(MemoryLayout.size)) == 0 else { return nil } + return "\(String(cString: bootSession)):\(geteuid()):\(auditInfo.ai_asid)" + } + + static func identity( + for application: NSRunningApplication, + windowID: CGWindowID?, + sessionIdentifier: String? + ) -> WindowRuntimeIdentity? { + guard let windowID, windowID != kCGNullWindowID, + let sessionIdentifier, !sessionIdentifier.isEmpty, + let bundleIdentifier = application.bundleIdentifier, + let launchDate = application.launchDate, + application.processIdentifier > 0, + !application.isTerminated else { return nil } + return WindowRuntimeIdentity( + bundleIdentifier: bundleIdentifier, + processIdentifier: application.processIdentifier, + processLaunchDate: launchDate, + sessionIdentifier: sessionIdentifier, + windowID: windowID + ) + } +} diff --git a/MousePortal/Services/WindowLayoutEngine.swift b/MousePortal/Services/WindowLayoutEngine.swift index 8012d1b..c84fe22 100644 --- a/MousePortal/Services/WindowLayoutEngine.swift +++ b/MousePortal/Services/WindowLayoutEngine.swift @@ -9,6 +9,7 @@ struct WindowMatchCandidate: Equatable { let subrole: String let windowIndex: Int let frame: CGRect + var runtimeIdentity: WindowRuntimeIdentity? = nil } enum WindowLayoutEngine { @@ -66,30 +67,153 @@ enum WindowLayoutEngine { abs(actual.height - target.height) <= tolerance } - static func bestMatchIndex( - for placement: WindowPlacement, + static func matchWindowIndices( + for placements: [WindowPlacement], candidates: [WindowMatchCandidate], - excluding usedIndices: Set, - preferredIndex: Int? = nil - ) -> Int? { - if let preferredIndex, - candidates.indices.contains(preferredIndex), - !usedIndices.contains(preferredIndex), - candidates[preferredIndex].role == placement.role, - candidates[preferredIndex].subrole == placement.subrole { - return preferredIndex + preferredIndices: [Int?] + ) -> [Int?] { + var matches = [Int?](repeating: nil, count: placements.count) + var usedIndices = Set() + + for placementIndex in placements.indices { + guard let preferredIndex = preferredIndices[placementIndex], + candidates.indices.contains(preferredIndex), + !usedIndices.contains(preferredIndex), + exactWindowTypeMatches(placements[placementIndex], candidates[preferredIndex]) else { + continue + } + + matches[placementIndex] = preferredIndex + usedIndices.insert(preferredIndex) } - return candidates.indices - .filter { !usedIndices.contains($0) } - .filter { - candidates[$0].role == placement.role && - candidates[$0].subrole == placement.subrole && - hasMeaningfulIdentityMatch(placement, candidates[$0]) + // MousePortal 重启后重新枚举 AX 对象,只接受同应用、同进程启动、同会话的窗口 ID。 + for placementIndex in placements.indices where matches[placementIndex] == nil { + let placement = placements[placementIndex] + guard let identity = placement.runtimeIdentity, + identity.bundleIdentifier == placement.bundleIdentifier else { continue } + let identityMatches = candidates.indices.filter { + candidates[$0].runtimeIdentity == identity && exactWindowTypeMatches(placement, candidates[$0]) } - .max { lhs, rhs in - matchScore(placement, candidates[lhs]) < matchScore(placement, candidates[rhs]) + guard identityMatches.count == 1, + let matchIndex = identityMatches.first, + !usedIndices.contains(matchIndex) else { continue } + matches[placementIndex] = matchIndex + usedIndices.insert(matchIndex) + } + + // 这些字段只是相似度线索;真实窗口身份已在前两轮优先匹配。 + // 精确身份先保留;其余先最大化有效配对数量,再最大化整体相似度。 + let remainingPlacements = placements.indices.filter { + matches[$0] == nil && placements[$0].requiresExactIdentity != true + } + let remainingCandidates = candidates.indices.filter { !usedIndices.contains($0) } + let scores = remainingPlacements.map { placementIndex in + let placement = placements[placementIndex] + return remainingCandidates.map { candidateIndex -> Double in + let candidate = candidates[candidateIndex] + guard candidate.role == placement.role, + candidate.subrole == placement.subrole, + hasMeaningfulIdentityMatch(placement, candidate) else { return 0 } + return matchScore(placement, candidate) } + } + for (row, column) in maximumMetadataAssignment(scores).enumerated() { + guard let column else { continue } + let candidateIndex = remainingCandidates[column] + matches[remainingPlacements[row]] = candidateIndex + usedIndices.insert(candidateIndex) + } + + for placementIndex in placements.indices where matches[placementIndex] == nil { + let placement = placements[placementIndex] + guard placement.requiresExactIdentity != true else { continue } + guard let matchIndex = candidates.indices.first(where: { candidateIndex in + let candidate = candidates[candidateIndex] + return !usedIndices.contains(candidateIndex) && + candidate.role == placement.role && + candidate.subrole == placement.subrole && + candidate.windowIndex == placement.windowIndex + }) else { + continue + } + + matches[placementIndex] = matchIndex + usedIndices.insert(matchIndex) + } + + return matches + } + + private static func exactWindowTypeMatches(_ placement: WindowPlacement, _ candidate: WindowMatchCandidate) -> Bool { + if placement.requiresExactIdentity == true { + // CG 阶段未知的类型,直到重新枚举 AX 且精确身份命中后才能确认。 + return candidate.role == "AXWindow" && candidate.subrole == "AXStandardWindow" + } + return candidate.role == placement.role && candidate.subrole == placement.subrole + } + + // 匈牙利算法;每行附加一个虚拟候选,允许没有有效线索的窗口保持未匹配。 + private static func maximumMetadataAssignment(_ scores: [[Double]]) -> [Int?] { + let rows = scores.count + let realColumns = scores.first?.count ?? 0 + guard rows > 0, realColumns > 0 else { return [Int?](repeating: nil, count: rows) } + let columns = realColumns + rows + let cardinalityBonus = (scores.flatMap { $0 }.max() ?? 0) * Double(min(rows, realColumns)) + 1 + var rowPotential = [Double](repeating: 0, count: rows + 1) + var columnPotential = [Double](repeating: 0, count: columns + 1) + var rowAtColumn = [Int](repeating: 0, count: columns + 1) + var previousColumn = [Int](repeating: 0, count: columns + 1) + + for row in 1...rows { + rowAtColumn[0] = row + var column = 0 + var distances = [Double](repeating: .infinity, count: columns + 1) + var visited = [Bool](repeating: false, count: columns + 1) + repeat { + visited[column] = true + let currentRow = rowAtColumn[column] + var delta = Double.infinity + var nextColumn = 0 + for next in 1...columns where !visited[next] { + let score = next <= realColumns ? scores[currentRow - 1][next - 1] : 0 + let cost = score > 0 ? -(cardinalityBonus + score) : 0 + let distance = cost - rowPotential[currentRow] - columnPotential[next] + if distance < distances[next] { + distances[next] = distance + previousColumn[next] = column + } + if distances[next] < delta { + delta = distances[next] + nextColumn = next + } + } + for next in 0...columns { + if visited[next] { + rowPotential[rowAtColumn[next]] += delta + columnPotential[next] -= delta + } else { + distances[next] -= delta + } + } + column = nextColumn + } while rowAtColumn[column] != 0 + + repeat { + let previous = previousColumn[column] + rowAtColumn[column] = rowAtColumn[previous] + column = previous + } while column != 0 + } + + var matches = [Int?](repeating: nil, count: rows) + for column in 1...realColumns { + let row = rowAtColumn[column] + if row > 0, scores[row - 1][column - 1] > 0 { + matches[row - 1] = column - 1 + } + } + return matches } private static func hasMeaningfulIdentityMatch( @@ -112,18 +236,15 @@ enum WindowLayoutEngine { private static func matchScore(_ placement: WindowPlacement, _ candidate: WindowMatchCandidate) -> Double { var score = 0.0 - if let documentURL = placement.documentURL, - documentURL == candidate.documentURL { + if exactNonEmptyMatch(placement.documentURL, candidate.documentURL) { score += 1_000 } - if let identifier = placement.windowIdentifier, - identifier == candidate.windowIdentifier { + if exactNonEmptyMatch(placement.windowIdentifier, candidate.windowIdentifier) { score += 600 } - if let title = placement.windowTitle, - title == candidate.windowTitle { + if exactNonEmptyMatch(placement.windowTitle, candidate.windowTitle) { score += 300 } diff --git a/MousePortal/Services/WindowLayoutService.swift b/MousePortal/Services/WindowLayoutService.swift index c6a44fc..95578d5 100644 --- a/MousePortal/Services/WindowLayoutService.swift +++ b/MousePortal/Services/WindowLayoutService.swift @@ -8,13 +8,18 @@ protocol WindowLayoutSystemProviding { func captureWindows( displays: [WindowDisplaySnapshot], ignoring bundleIdentifiers: Set, - runtimeSnapshotID: UUID? + runtimeSnapshotID: UUID ) -> [WindowPlacement] + func captureVisibleWindows( + displays: [WindowDisplaySnapshot], + ignoring bundleIdentifiers: Set + ) -> [WindowPlacement]? func restoreWindows( from snapshot: WindowLayoutSnapshot, currentDisplays: [WindowDisplaySnapshot] ) -> WindowRestoreResult func runningApplications() -> [WindowApplicationOption] + func discardWindowIdentities(except snapshotIDs: Set) func logDisplayState(event: String) } @@ -24,8 +29,32 @@ final class SystemWindowLayoutProvider: WindowLayoutSystemProviding { let candidate: WindowMatchCandidate } - private var retainedSnapshotID: UUID? - private var retainedWindows: [AXUIElement] = [] + private var retainedWindowsBySnapshotID: [UUID: [AXUIElement]] = [:] + private let applications: () -> [NSRunningApplication] + private let visibleWindowInfo: () -> [[CFString: Any]]? + private let windowBounds: (CGWindowID) -> CGRect? + + init( + applications: @escaping () -> [NSRunningApplication] = { NSWorkspace.shared.runningApplications }, + visibleWindowInfo: @escaping () -> [[CFString: Any]]? = { + CGWindowListCopyWindowInfo( + [.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID + ) as? [[CFString: Any]] + }, + windowBounds: @escaping (CGWindowID) -> CGRect? = WindowServerGeometry.bounds(for:) + ) { + self.applications = applications + self.visibleWindowInfo = visibleWindowInfo + self.windowBounds = windowBounds + } + + func retainedWindows(for snapshotID: UUID) -> [AXUIElement]? { + retainedWindowsBySnapshotID[snapshotID] + } + + func discardWindowIdentities(except snapshotIDs: Set) { + retainedWindowsBySnapshotID = retainedWindowsBySnapshotID.filter { snapshotIDs.contains($0.key) } + } func currentDisplays() -> [WindowDisplaySnapshot] { activeDisplayIDs().map { displayID in @@ -42,11 +71,12 @@ final class SystemWindowLayoutProvider: WindowLayoutSystemProviding { func captureWindows( displays: [WindowDisplaySnapshot], ignoring bundleIdentifiers: Set, - runtimeSnapshotID: UUID? + runtimeSnapshotID: UUID ) -> [WindowPlacement] { let ownBundleIdentifier = Bundle.main.bundleIdentifier var placements: [WindowPlacement] = [] var capturedWindows: [AXUIElement] = [] + let sessionIdentifier = WindowIdentityResolver.currentSessionIdentifier() for application in restorableApplications() { guard let bundleIdentifier = application.bundleIdentifier, @@ -55,7 +85,7 @@ final class SystemWindowLayoutProvider: WindowLayoutSystemProviding { continue } - let windows = accessibleWindows(for: application) + let windows = accessibleWindows(for: application, sessionIdentifier: sessionIdentifier) for window in windows { guard let display = WindowLayoutEngine.display( containing: window.candidate.frame, @@ -79,17 +109,103 @@ final class SystemWindowLayoutProvider: WindowLayoutSystemProviding { relativeX: (windowFrame.minX - displayFrame.minX) / displayFrame.width, relativeY: (windowFrame.minY - displayFrame.minY) / displayFrame.height, width: windowFrame.width, - height: windowFrame.height + height: windowFrame.height, + runtimeIdentity: window.candidate.runtimeIdentity )) - if runtimeSnapshotID != nil { - capturedWindows.append(window.element) - } + capturedWindows.append(window.element) } } - if let runtimeSnapshotID { - retainedSnapshotID = runtimeSnapshotID - retainedWindows = capturedWindows + retainedWindowsBySnapshotID[runtimeSnapshotID] = capturedWindows + + return placements + } + + func captureVisibleWindows( + displays: [WindowDisplaySnapshot], + ignoring bundleIdentifiers: Set + ) -> [WindowPlacement]? { + let ownBundleIdentifier = Bundle.main.bundleIdentifier + let sessionIdentifier = WindowIdentityResolver.currentSessionIdentifier() + let applicationsByPID: [pid_t: NSRunningApplication] = Dictionary( + uniqueKeysWithValues: restorableApplications().compactMap { application in + guard let bundleIdentifier = application.bundleIdentifier, + bundleIdentifier != ownBundleIdentifier, + !bundleIdentifiers.contains(bundleIdentifier) else { + return nil + } + return (application.processIdentifier, application) + } + ) + guard let windowInfo = visibleWindowInfo() else { return nil } + var windowIndicesByBundleIdentifier: [String: Int] = [:] + var placements: [WindowPlacement] = [] + + for info in windowInfo { + guard let pidNumber = info[kCGWindowOwnerPID] as? NSNumber, + let application = applicationsByPID[pid_t(pidNumber.int32Value)], + let layerNumber = info[kCGWindowLayer] as? NSNumber, + layerNumber.intValue == 0 else { + continue + } + if let alphaNumber = info[kCGWindowAlpha] as? NSNumber, + alphaNumber.doubleValue <= 0 { + continue + } + + // CG 列表仅用于枚举;用原始边框判断显示器并保存尺寸,避开锁屏缩放动画。 + guard let windowID = (info[kCGWindowNumber] as? NSNumber)?.uint32Value, + windowID != kCGNullWindowID, + let frame = windowBounds(windowID), + !frame.isInfinite, + frame.origin.x.isFinite, frame.origin.y.isFinite, + frame.size.width.isFinite, frame.size.height.isFinite, + frame.size.width > 0, frame.size.height > 0 else { + // 不把部分成功或动画边框写成新布局,交给上层保留原有快照。 + print("锁屏窗口原始边框读取失败,放弃本次布局捕获") + return nil + } + guard let display = WindowLayoutEngine.display(containing: frame, from: displays) else { + continue + } + + let displayFrame = display.frame.cgRect + let matchesDisplayWidth = abs(frame.minX - displayFrame.minX) <= 1 && + abs(frame.width - displayFrame.width) <= 1 + let isMenuBarWindow = matchesDisplayWidth && + abs(frame.minY - displayFrame.minY) <= 1 && + frame.height <= 64 + let isFullScreenWindow = matchesDisplayWidth && + abs(frame.minY - displayFrame.minY) <= 1 && + abs(frame.height - displayFrame.height) <= 1 + guard !isMenuBarWindow, !isFullScreenWindow, + let bundleIdentifier = application.bundleIdentifier, + let identity = WindowIdentityResolver.identity( + for: application, windowID: windowID, sessionIdentifier: sessionIdentifier + ) else { + continue + } + + let windowIndex = windowIndicesByBundleIdentifier[bundleIdentifier, default: 0] + windowIndicesByBundleIdentifier[bundleIdentifier] = windowIndex + 1 + let title = (info[kCGWindowName] as? String).flatMap { $0.isEmpty ? nil : $0 } + placements.append(WindowPlacement( + bundleIdentifier: bundleIdentifier, + applicationName: application.localizedName ?? bundleIdentifier, + windowTitle: title, + documentURL: nil, + windowIdentifier: nil, + role: "", + subrole: "", + windowIndex: windowIndex, + displayIdentity: display.identity, + relativeX: (frame.minX - displayFrame.minX) / displayFrame.width, + relativeY: (frame.minY - displayFrame.minY) / displayFrame.height, + width: frame.width, + height: frame.height, + runtimeIdentity: identity, + requiresExactIdentity: true + )) } return placements @@ -119,7 +235,8 @@ final class SystemWindowLayoutProvider: WindowLayoutSystemProviding { initialApplySucceeded: Bool )] = [] - let runtimeWindows = retainedSnapshotID == snapshot.id ? retainedWindows : [] + let runtimeWindows = retainedWindows(for: snapshot.id) ?? [] + let sessionIdentifier = WindowIdentityResolver.currentSessionIdentifier() let placementsByBundleIdentifier = Dictionary( grouping: snapshot.windows.enumerated(), by: { $0.element.bundleIdentifier } @@ -131,27 +248,31 @@ final class SystemWindowLayoutProvider: WindowLayoutSystemProviding { continue } - let windows = applications.flatMap { accessibleWindows(for: $0) } + let windows = applications.flatMap { + accessibleWindows(for: $0, sessionIdentifier: sessionIdentifier) + } let candidates = windows.map(\.candidate) - var usedIndices = Set() - - for (placementIndex, placement) in indexedPlacements { - let preferredIndex = runtimeWindows.indices.contains(placementIndex) ? windows.indices.first { index in - !usedIndices.contains(index) && CFEqual(runtimeWindows[placementIndex], windows[index].element) + let preferredIndices: [Int?] = indexedPlacements.map { indexedPlacement in + let placementIndex = indexedPlacement.offset + return runtimeWindows.indices.contains(placementIndex) ? windows.indices.first { index in + CFEqual(runtimeWindows[placementIndex], windows[index].element) } : nil + } + let matchIndices = WindowLayoutEngine.matchWindowIndices( + for: indexedPlacements.map { $0.element }, + candidates: candidates, + preferredIndices: preferredIndices + ) + + for (groupIndex, indexedPlacement) in indexedPlacements.enumerated() { + let placement = indexedPlacement.element guard let display = displaysByIdentity[placement.displayIdentity], - let matchIndex = WindowLayoutEngine.bestMatchIndex( - for: placement, - candidates: candidates, - excluding: usedIndices, - preferredIndex: preferredIndex - ) else { + let matchIndex = matchIndices[groupIndex] else { skippedCount += 1 continue } - usedIndices.insert(matchIndex) let targetFrame = WindowLayoutEngine.targetFrame(for: placement, on: display) let window = windows[matchIndex].element restoreAttempts.append(( @@ -232,12 +353,15 @@ final class SystemWindowLayoutProvider: WindowLayoutSystemProviding { } private func restorableApplications() -> [NSRunningApplication] { - NSWorkspace.shared.runningApplications.filter { + applications().filter { !$0.isTerminated && $0.activationPolicy == .regular } } - private func accessibleWindows(for application: NSRunningApplication) -> [AccessibleWindow] { + private func accessibleWindows( + for application: NSRunningApplication, + sessionIdentifier: String? + ) -> [AccessibleWindow] { let applicationElement = AXUIElementCreateApplication(application.processIdentifier) guard let values = attribute(kAXWindowsAttribute as CFString, from: applicationElement) as? [AXUIElement] else { return [] @@ -261,7 +385,12 @@ final class SystemWindowLayoutProvider: WindowLayoutSystemProviding { role: kAXWindowRole as String, subrole: kAXStandardWindowSubrole as String, windowIndex: index, - frame: frame + frame: frame, + runtimeIdentity: WindowIdentityResolver.identity( + for: application, + windowID: WindowIdentityResolver.windowID(for: window), + sessionIdentifier: sessionIdentifier + ) ) ) } @@ -390,6 +519,7 @@ final class WindowLayoutService: ObservableObject { private let system: WindowLayoutSystemProviding private let defaults: UserDefaults + private let screenNotificationCenter: NotificationCenter private let accessibilityIsTrusted: () -> Bool private let legacySnapshotKey = "windowLayoutSnapshot" private let snapshotsKey = "windowLayoutSnapshots" @@ -397,6 +527,8 @@ final class WindowLayoutService: ObservableObject { private let rememberBeforeSleepOrLockKey = "windowRecoveryRememberBeforeSleepOrLock" private let automaticRestoreKey = "windowRecoveryAutomaticRestore" private let ignoredApplicationsKey = "windowRecoveryIgnoredApplications" + private let screenDidLockNotification = NSNotification.Name("com.apple.screenIsLocked") + private let screenDidUnlockNotification = NSNotification.Name("com.apple.screenIsUnlocked") private let requiredStableSampleCount = 3 private let stabilityCheckInterval: TimeInterval = 0.5 private let stabilityTimeout: TimeInterval = 8 @@ -414,10 +546,12 @@ final class WindowLayoutService: ObservableObject { init( system: WindowLayoutSystemProviding = SystemWindowLayoutProvider(), defaults: UserDefaults = .standard, + screenNotificationCenter: NotificationCenter = DistributedNotificationCenter.default(), accessibilityIsTrusted: @escaping () -> Bool = AXIsProcessTrusted ) { self.system = system self.defaults = defaults + self.screenNotificationCenter = screenNotificationCenter self.accessibilityIsTrusted = accessibilityIsTrusted self.isEnabled = defaults.bool(forKey: enabledKey) let savedRememberSetting = defaults.object(forKey: rememberBeforeSleepOrLockKey) as? Bool @@ -467,6 +601,8 @@ final class WindowLayoutService: ObservableObject { workspaceCenter.addObserver(self, selector: #selector(sessionDidBecomeActive), name: NSWorkspace.sessionDidBecomeActiveNotification, object: nil) workspaceCenter.addObserver(self, selector: #selector(systemWillSleep), name: NSWorkspace.willSleepNotification, object: nil) workspaceCenter.addObserver(self, selector: #selector(systemDidWake), name: NSWorkspace.didWakeNotification, object: nil) + screenNotificationCenter.addObserver(self, selector: #selector(screenDidLock), name: screenDidLockNotification, object: nil) + screenNotificationCenter.addObserver(self, selector: #selector(screenDidUnlock), name: screenDidUnlockNotification, object: nil) NotificationCenter.default.addObserver( self, selector: #selector(screenParametersDidChange), @@ -481,6 +617,7 @@ final class WindowLayoutService: ObservableObject { stabilityTimer?.invalidate() stabilityTimer = nil NSWorkspace.shared.notificationCenter.removeObserver(self) + screenNotificationCenter.removeObserver(self) NotificationCenter.default.removeObserver(self) } @@ -630,12 +767,40 @@ final class WindowLayoutService: ObservableObject { } @objc private func sessionDidBecomeActive() { + handleSessionDidBecomeActive(event: "sessionDidBecomeActive") + } + + @objc private func screenDidLock() { + sessionIsActive = false + guard lockRecoveryEnabled else { return } + if state == .snapshotFrozen, + let index = snapshots.firstIndex(where: { $0.id == recoverySnapshotID }) { + guard accessibilityIsTrusted() else { return } + let frozen = snapshots[index] + guard let windows = supplementWindowCapture(frozen.windows, displays: frozen.displays), + windows.count > frozen.windows.count else { return } + // 原有 AX 引用按数组下标保留;只追加缺失窗口,不替换已冻结的原始几何。 + snapshots[index] = WindowLayoutSnapshot( + id: frozen.id, kind: frozen.kind, name: frozen.name, capturedAt: frozen.capturedAt, + displays: frozen.displays, windows: windows + ) + persistSnapshots() + return + } + freezeSnapshotIfNeeded(event: "screenIsLocked", allowWindowServerFallback: true) + } + + @objc private func screenDidUnlock() { + handleSessionDidBecomeActive(event: "screenIsUnlocked") + } + + private func handleSessionDidBecomeActive(event: String) { sessionIsActive = true guard automaticRestoreEnabled else { resetRecoveryState() return } - system.logDisplayState(event: "sessionDidBecomeActive") + system.logDisplayState(event: event) if topologyIsReady { completeStableTopologyWait() @@ -667,11 +832,16 @@ final class WindowLayoutService: ObservableObject { } } - private func freezeSnapshotIfNeeded(event: String) { + private func freezeSnapshotIfNeeded( + event: String, + allowWindowServerFallback: Bool = false + ) { guard lockRecoveryEnabled else { return } system.logDisplayState(event: event) guard state == .normal else { return } - guard let automaticSnapshot = saveAutomaticLayout() else { return } + guard let automaticSnapshot = saveAutomaticLayout( + allowWindowServerFallback: allowWindowServerFallback + ) else { return } recoverySnapshotID = automaticSnapshot.id state = .snapshotFrozen } @@ -785,28 +955,72 @@ final class WindowLayoutService: ObservableObject { isEnabled && rememberBeforeSleepOrLockEnabled } - private func captureSnapshot(kind: WindowLayoutSnapshotKind) -> WindowLayoutSnapshot? { + private func captureSnapshot( + kind: WindowLayoutSnapshotKind, + allowWindowServerFallback: Bool = false + ) -> WindowLayoutSnapshot? { guard accessibilityIsTrusted() else { return nil } let displays = system.currentDisplays() guard !displays.isEmpty else { return nil } let snapshotID = UUID() + let accessibilityWindows = system.captureWindows( + displays: displays, + ignoring: ignoredBundleIdentifiers, + runtimeSnapshotID: snapshotID + ) + let windows: [WindowPlacement] + if allowWindowServerFallback { + guard let merged = supplementWindowCapture(accessibilityWindows, displays: displays) else { + system.discardWindowIdentities(except: Set(snapshots.map(\.id))) + return nil + } + windows = merged + } else { + windows = accessibilityWindows + } return WindowLayoutSnapshot( id: snapshotID, kind: kind, capturedAt: Date(), displays: displays, - windows: system.captureWindows( - displays: displays, - ignoring: ignoredBundleIdentifiers, - runtimeSnapshotID: kind == .automatic ? snapshotID : nil - ) + windows: windows ) } + private func supplementWindowCapture( + _ captured: [WindowPlacement], displays: [WindowDisplaySnapshot] + ) -> [WindowPlacement]? { + guard let visible = system.captureVisibleWindows( + displays: displays, ignoring: ignoredBundleIdentifiers + ) else { return nil } + let unverifiedApplications = Set(captured.filter { $0.runtimeIdentity == nil }.map(\.bundleIdentifier)) + var merged = captured + for window in visible { + // 无法核验已有 AX 记录的身份时,不猜测同应用中的去重关系。 + guard let identity = window.runtimeIdentity, + identity.bundleIdentifier == window.bundleIdentifier, + !unverifiedApplications.contains(window.bundleIdentifier), + !merged.contains(where: { $0.runtimeIdentity == identity }) else { continue } + merged.append(window) + } + return merged + } + @discardableResult - private func saveAutomaticLayout() -> WindowLayoutSnapshot? { - guard let newSnapshot = captureSnapshot(kind: .automatic) else { return nil } + private func saveAutomaticLayout( + allowWindowServerFallback: Bool = false + ) -> WindowLayoutSnapshot? { + guard let newSnapshot = captureSnapshot( + kind: .automatic, + allowWindowServerFallback: allowWindowServerFallback + ) else { return nil } + // AX 可能在锁屏通知前失效;空结果不能覆盖旧布局,也不能阻止后续锁屏重试。 + guard !newSnapshot.windows.isEmpty else { + system.discardWindowIdentities(except: Set(snapshots.map(\.id))) + print("自动布局未捕获到有效窗口,保留已有布局") + return nil + } snapshots.removeAll { $0.kind == .automatic && $0.displayLayoutSignature == newSnapshot.displayLayoutSignature @@ -817,6 +1031,7 @@ final class WindowLayoutService: ObservableObject { } private func persistSnapshots() { + system.discardWindowIdentities(except: Set(snapshots.map(\.id))) if let data = try? JSONEncoder().encode(snapshots) { defaults.set(data, forKey: snapshotsKey) } diff --git a/MousePortal/Services/WindowServerGeometry.swift b/MousePortal/Services/WindowServerGeometry.swift new file mode 100644 index 0000000..c20f375 --- /dev/null +++ b/MousePortal/Services/WindowServerGeometry.swift @@ -0,0 +1,34 @@ +import CoreGraphics +import Darwin + +enum WindowServerGeometry { + private typealias GetConnectionID = @convention(c) () -> Int32 + private typealias GetWindowBounds = @convention(c) ( + Int32, CGWindowID, UnsafeMutablePointer + ) -> CGError + + // 私有只读接口:锁屏动画期间 CGWindowList 的边框会缩小,SLS 边框保留原始几何。 + // 动态加载失败时返回 nil,不退回可能包含动画尺寸的 CG 边框。 + private static let functions: (connection: GetConnectionID, bounds: GetWindowBounds)? = { + guard let handle = dlopen( + "/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight", RTLD_LAZY | RTLD_LOCAL + ) else { return nil } + guard let connection = dlsym(handle, "SLSMainConnectionID"), + let bounds = dlsym(handle, "SLSGetWindowBounds") else { + dlclose(handle) + return nil + } + // 保留库句柄,确保函数指针在进程存续期间有效。 + return ( + unsafeBitCast(connection, to: GetConnectionID.self), + unsafeBitCast(bounds, to: GetWindowBounds.self) + ) + }() + + static func bounds(for windowID: CGWindowID) -> CGRect? { + guard windowID != kCGNullWindowID, let functions else { return nil } + var frame = CGRect.zero + guard functions.bounds(functions.connection(), windowID, &frame) == .success else { return nil } + return frame + } +} diff --git a/MousePortalTests/Services/SystemWindowLayoutProviderTests.swift b/MousePortalTests/Services/SystemWindowLayoutProviderTests.swift new file mode 100644 index 0000000..6c4a92f --- /dev/null +++ b/MousePortalTests/Services/SystemWindowLayoutProviderTests.swift @@ -0,0 +1,195 @@ +import XCTest +import AppKit +@testable import MousePortal + +final class SystemWindowLayoutProviderTests: XCTestCase { + func testLockCaptureUsesOriginalBoundsInsteadOfAnimationGeometry() throws { + let original = CGRect(x: 0, y: 33, width: 1512, height: 884) + var requestedIDs: [CGWindowID] = [] + let provider = SystemWindowLayoutProvider( + applications: { [TestWindowApplication()] }, + visibleWindowInfo: { [self.windowInfo(id: 41)] }, + windowBounds: { id in requestedIDs.append(id); return original } + ) + + let saved = try XCTUnwrap(provider.captureVisibleWindows(displays: [mainDisplay], ignoring: [])?.first) + + XCTAssertEqual(requestedIDs, [41]) + XCTAssertEqual(saved.width, 1512) + XCTAssertEqual(saved.height, 884) + XCTAssertEqual(saved.relativeX, 0) + XCTAssertEqual(saved.relativeY, 33.0 / 982.0, accuracy: 0.000001) + XCTAssertEqual(saved.bundleIdentifier, "com.example.TestEditor") + XCTAssertTrue(saved.role.isEmpty) + XCTAssertTrue(saved.subrole.isEmpty) + XCTAssertEqual(saved.requiresExactIdentity, true) + XCTAssertNotNil(saved.runtimeIdentity) + } + + func testOriginalBoundsDetermineDisplayAndNonMaximizedGeometry() throws { + let external = WindowDisplaySnapshot( + identity: WindowDisplayIdentity(rawValue: "external"), + frame: WindowRect(CGRect(x: -1600, y: -900, width: 1600, height: 900)), + visibleFrame: WindowRect(CGRect(x: -1600, y: -875, width: 1600, height: 875)), + isMain: false + ) + let provider = SystemWindowLayoutProvider( + applications: { [TestWindowApplication()] }, + visibleWindowInfo: { [self.windowInfo(id: 41)] }, + windowBounds: { _ in CGRect(x: -1200, y: -700, width: 900, height: 650) } + ) + + let saved = try XCTUnwrap(provider.captureVisibleWindows(displays: [mainDisplay, external], ignoring: [])?.first) + + XCTAssertEqual(saved.displayIdentity, external.identity) + XCTAssertEqual(saved.width, 900) + XCTAssertEqual(saved.height, 650) + XCTAssertEqual(saved.relativeX, 400.0 / 1600.0, accuracy: 0.000001) + XCTAssertEqual(saved.relativeY, 200.0 / 900.0, accuracy: 0.000001) + } + + func testFailedWindowEnumerationIsNotReportedAsAnEmptySuccessfulCapture() { + let provider = SystemWindowLayoutProvider( + applications: { [TestWindowApplication()] }, + visibleWindowInfo: { nil }, + windowBounds: { _ in XCTFail("枚举失败时不应读取边框"); return nil } + ) + XCTAssertNil(provider.captureVisibleWindows(displays: [mainDisplay], ignoring: [])) + } + + func testUnavailableOriginalBoundsDoNotFallBackToAnimatedBounds() { + let provider = SystemWindowLayoutProvider( + applications: { [TestWindowApplication()] }, + visibleWindowInfo: { [self.windowInfo(id: 41)] }, + windowBounds: { _ in nil } + ) + + XCTAssertNil(provider.captureVisibleWindows(displays: [mainDisplay], ignoring: [])) + } + + func testOneFailedBoundsReadDiscardsPartialCapture() { + let provider = SystemWindowLayoutProvider( + applications: { [TestWindowApplication()] }, + visibleWindowInfo: { [self.windowInfo(id: 41), self.windowInfo(id: 42)] }, + windowBounds: { id in id == 41 ? CGRect(x: 0, y: 33, width: 1512, height: 884) : nil } + ) + + XCTAssertNil(provider.captureVisibleWindows(displays: [mainDisplay], ignoring: [])) + } + + func testInvalidOriginalBoundsAreRejected() { + for frame in [CGRect.zero, CGRect.null, CGRect.infinite, + CGRect(x: CGFloat.nan, y: 33, width: 800, height: 600), + CGRect(x: 0, y: 33, width: -800, height: 600)] { + let provider = SystemWindowLayoutProvider( + applications: { [TestWindowApplication()] }, + visibleWindowInfo: { [self.windowInfo(id: 41)] }, + windowBounds: { _ in frame } + ) + XCTAssertNil(provider.captureVisibleWindows(displays: [mainDisplay], ignoring: [])) + } + } + + func testIgnoredAndInvisibleWindowsDoNotTriggerBoundsReads() { + var info = windowInfo(id: 41) + info[kCGWindowAlpha] = NSNumber(value: 0) + let provider = SystemWindowLayoutProvider( + applications: { [TestWindowApplication()] }, + visibleWindowInfo: { [info] }, + windowBounds: { _ in XCTFail("不可见或忽略的窗口不应读取边框"); return nil } + ) + + XCTAssertTrue(provider.captureVisibleWindows(displays: [mainDisplay], ignoring: [])?.isEmpty == true) + XCTAssertTrue(provider.captureVisibleWindows(displays: [mainDisplay], ignoring: ["com.example.TestEditor"])?.isEmpty == true) + } + + func testMenuBarAndFullscreenFilteringUsesOriginalBounds() { + let provider = SystemWindowLayoutProvider( + applications: { [TestWindowApplication()] }, + visibleWindowInfo: { [self.windowInfo(id: 41), self.windowInfo(id: 42)] }, + windowBounds: { id in CGRect(x: 0, y: 0, width: 1512, height: id == 41 ? 33 : 982) } + ) + + XCTAssertTrue(provider.captureVisibleWindows(displays: [mainDisplay], ignoring: [])?.isEmpty == true) + } + + func testOriginalBoundsAreRefreshedForEachCapture() throws { + var frame = CGRect(x: 100, y: 100, width: 900, height: 600) + let provider = SystemWindowLayoutProvider( + applications: { [TestWindowApplication()] }, + visibleWindowInfo: { [self.windowInfo(id: 41)] }, + windowBounds: { _ in frame } + ) + let first = try XCTUnwrap(provider.captureVisibleWindows(displays: [mainDisplay], ignoring: [])?.first) + frame = CGRect(x: 200, y: 150, width: 800, height: 700) + let second = try XCTUnwrap(provider.captureVisibleWindows(displays: [mainDisplay], ignoring: [])?.first) + + XCTAssertEqual(first.width, 900) + XCTAssertEqual(second.width, 800) + XCTAssertEqual(second.height, 700) + XCTAssertEqual(second.relativeX, 200.0 / 1512.0, accuracy: 0.000001) + XCTAssertEqual(second.relativeY, 150.0 / 982.0, accuracy: 0.000001) + } + + func testPrivateBoundsLookupRejectsNullWindowID() { + XCTAssertNil(WindowServerGeometry.bounds(for: kCGNullWindowID)) + } + + private var mainDisplay: WindowDisplaySnapshot { + WindowDisplaySnapshot( + identity: WindowDisplayIdentity(rawValue: "main"), + frame: WindowRect(CGRect(x: 0, y: 0, width: 1512, height: 982)), + visibleFrame: WindowRect(CGRect(x: 0, y: 33, width: 1512, height: 884)), + isMain: true + ) + } + + private func windowInfo(id: CGWindowID) -> [CFString: Any] { + [ + kCGWindowNumber: NSNumber(value: id), + kCGWindowOwnerPID: NSNumber(value: 123), + kCGWindowLayer: NSNumber(value: 0), + kCGWindowAlpha: NSNumber(value: 1), + kCGWindowBounds: CGRect(x: 75, y: 78, width: 1362, height: 797).dictionaryRepresentation + ] + } + + func testCapturingAnotherSnapshotPreservesEarlierRuntimeIdentities() { + let provider = SystemWindowLayoutProvider(applications: { [] }) + let automaticID = UUID() + let manualID = UUID() + let nextManualID = UUID() + + for id in [automaticID, manualID, nextManualID] { + _ = provider.captureWindows(displays: [], ignoring: [], runtimeSnapshotID: id) + } + + XCTAssertNotNil(provider.retainedWindows(for: automaticID)) + XCTAssertNotNil(provider.retainedWindows(for: manualID)) + XCTAssertNotNil(provider.retainedWindows(for: nextManualID)) + } + + func testPruningRemovesOnlyDeletedSnapshotIdentities() { + let provider = SystemWindowLayoutProvider(applications: { [] }) + let firstID = UUID() + let secondID = UUID() + _ = provider.captureWindows(displays: [], ignoring: [], runtimeSnapshotID: firstID) + _ = provider.captureWindows(displays: [], ignoring: [], runtimeSnapshotID: secondID) + + provider.discardWindowIdentities(except: [secondID]) + XCTAssertNil(provider.retainedWindows(for: firstID)) + XCTAssertNotNil(provider.retainedWindows(for: secondID)) + + provider.discardWindowIdentities(except: []) + XCTAssertNil(provider.retainedWindows(for: secondID)) + } +} + +private final class TestWindowApplication: NSRunningApplication, @unchecked Sendable { + override var bundleIdentifier: String? { "com.example.TestEditor" } + override var processIdentifier: pid_t { 123 } + override var isTerminated: Bool { false } + override var activationPolicy: NSApplication.ActivationPolicy { .regular } + override var localizedName: String? { "Test Editor" } + override var launchDate: Date? { Date(timeIntervalSince1970: 1000) } +} diff --git a/MousePortalTests/Services/WindowIdentityTests.swift b/MousePortalTests/Services/WindowIdentityTests.swift new file mode 100644 index 0000000..fd3723d --- /dev/null +++ b/MousePortalTests/Services/WindowIdentityTests.swift @@ -0,0 +1,190 @@ +import XCTest +import ApplicationServices +@testable import MousePortal + +final class WindowIdentityTests: XCTestCase { + func testReloadedSnapshotFindsOriginalWindowAfterMetadataAndOrderChange() throws { + let saved = placement(identity: identity(windowID: 41)) + let snapshot = WindowLayoutSnapshot(capturedAt: Date(), displays: [], windows: [saved]) + let reloaded = try JSONDecoder().decode( + WindowLayoutSnapshot.self, from: JSONEncoder().encode(snapshot) + ) + let candidates = [ + candidate(title: "Saved", index: 0, identity: identity(windowID: 99)), + candidate(title: "Changed", index: 1, identity: identity(windowID: 41)) + ] + + XCTAssertEqual(reloaded, snapshot) + XCTAssertEqual(WindowLayoutEngine.matchWindowIndices( + for: reloaded.windows, candidates: candidates, preferredIndices: [nil] + ), [1]) + } + + func testIdenticalDocumentsDoNotOverrideDistinctPersistedWindowIdentities() { + let placements = [ + placement(index: 0, identity: identity(windowID: 41)), + placement(index: 1, identity: identity(windowID: 42)) + ] + let candidates = [ + candidate(title: "Saved", index: 0, identity: identity(windowID: 42)), + candidate(title: "Saved", index: 1, identity: identity(windowID: 41)) + ] + + XCTAssertEqual(WindowLayoutEngine.matchWindowIndices( + for: placements, candidates: candidates, preferredIndices: [nil, nil] + ), [1, 0]) + } + + func testWindowIdentityRequiresSameApplicationProcessLaunchSessionAndWindow() { + let saved = placement(identity: identity()) + let mismatches = [ + identity(bundle: "com.example.Other"), + identity(pid: 200), + identity(launch: Date(timeIntervalSince1970: 2_000)), + identity(session: "different-boot-or-login"), + identity(windowID: 42) + ] + + for mismatch in mismatches { + let current = candidate(title: "Changed", index: 1, identity: mismatch) + XCTAssertEqual(WindowLayoutEngine.matchWindowIndices( + for: [saved], candidates: [current], preferredIndices: [nil] + ), [nil]) + } + } + + func testIdentityMustBelongToSavedApplication() { + let wrongAppIdentity = identity(bundle: "com.example.Other") + let saved = placement(identity: wrongAppIdentity) + let current = candidate(title: "Changed", index: 1, identity: wrongAppIdentity) + + XCTAssertEqual(WindowLayoutEngine.matchWindowIndices( + for: [saved], candidates: [current], preferredIndices: [nil] + ), [nil]) + } + + func testIdentityMismatchFallsBackToMetadata() { + let saved = placement(identity: identity(windowID: 41)) + let current = candidate(title: "Saved", index: 1, identity: identity(windowID: 99)) + + XCTAssertEqual(WindowLayoutEngine.matchWindowIndices( + for: [saved], candidates: [current], preferredIndices: [nil] + ), [0]) + } + + func testMissingCurrentIdentityFallsBackToIndex() { + let saved = placement(identity: identity()) + let current = candidate(title: "Changed", index: 0, identity: nil) + + XCTAssertEqual(WindowLayoutEngine.matchWindowIndices( + for: [saved], candidates: [current], preferredIndices: [nil] + ), [0]) + } + + func testRetainedObjectTakesPriorityOverPersistedHint() { + let saved = placement(identity: identity(windowID: 41)) + let candidates = [ + candidate(title: "Saved", index: 0, identity: identity(windowID: 41)), + candidate(title: "Changed", index: 1, identity: identity(windowID: 99)) + ] + + XCTAssertEqual(WindowLayoutEngine.matchWindowIndices( + for: [saved], candidates: candidates, preferredIndices: [1] + ), [1]) + } + + func testDuplicateCurrentIdentityDoesNotSelectAnArbitraryWindow() { + let saved = placement(identity: identity()) + let candidates = [ + candidate(title: "Changed", index: 1, identity: identity()), + candidate(title: "Changed", index: 2, identity: identity()) + ] + + XCTAssertEqual(WindowLayoutEngine.matchWindowIndices( + for: [saved], candidates: candidates, preferredIndices: [nil] + ), [nil]) + } + + func testUnverifiedWindowTypeCannotFallBackToMetadataOrIndexAfterReload() throws { + var saved = placement(identity: identity(windowID: 41)) + saved.requiresExactIdentity = true + let reloaded = try JSONDecoder().decode(WindowPlacement.self, from: JSONEncoder().encode(saved)) + for currentIdentity in [nil, identity(windowID: 99), identity(pid: 200)] { + let current = candidate(title: "Saved", index: 0, identity: currentIdentity) + XCTAssertEqual(WindowLayoutEngine.matchWindowIndices( + for: [reloaded], candidates: [current], preferredIndices: [nil] + ), [nil]) + } + } + + func testUnverifiedWindowTypeRequiresExactIdentityAndCurrentStandardWindow() { + let saved = WindowPlacement( + bundleIdentifier: "com.example.Editor", applicationName: "Editor", + windowTitle: "Saved", documentURL: nil, windowIdentifier: nil, + role: "", subrole: "", windowIndex: 0, + displayIdentity: WindowDisplayIdentity(rawValue: "main"), + relativeX: 0, relativeY: 0, width: 800, height: 600, + runtimeIdentity: identity(), requiresExactIdentity: true + ) + let standard = candidate(title: "Changed", index: 1, identity: identity()) + let dialog = WindowMatchCandidate( + windowTitle: "Saved", documentURL: nil, windowIdentifier: nil, + role: "AXWindow", subrole: "AXDialog", windowIndex: 0, + frame: standard.frame, runtimeIdentity: identity() + ) + XCTAssertEqual(WindowLayoutEngine.matchWindowIndices( + for: [saved], candidates: [standard], preferredIndices: [nil] + ), [0]) + XCTAssertEqual(WindowLayoutEngine.matchWindowIndices( + for: [saved], candidates: [dialog], preferredIndices: [nil] + ), [nil]) + } + + func testLegacyPlacementWithoutRuntimeIdentityStillDecodes() throws { + let saved = placement(identity: nil) + let data = try JSONEncoder().encode(saved) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertNil(json["runtimeIdentity"]) + + let decoded = try JSONDecoder().decode(WindowPlacement.self, from: data) + XCTAssertEqual(decoded, saved) + XCTAssertNil(decoded.runtimeIdentity) + XCTAssertNil(decoded.requiresExactIdentity) + } + + func testPrivateWindowLookupRejectsNonWindowElement() { + let application = AXUIElementCreateApplication(getpid()) + XCTAssertNil(WindowIdentityResolver.windowID(for: application)) + } + + private func identity( + bundle: String = "com.example.Editor", + pid: Int32 = 100, + launch: Date = Date(timeIntervalSince1970: 1_000.123456), + session: String = "boot:login", + windowID: CGWindowID = 41 + ) -> WindowRuntimeIdentity { + WindowRuntimeIdentity( + bundleIdentifier: bundle, processIdentifier: pid, processLaunchDate: launch, + sessionIdentifier: session, windowID: windowID + ) + } + + private func placement(index: Int = 0, identity: WindowRuntimeIdentity?) -> WindowPlacement { + WindowPlacement( + bundleIdentifier: "com.example.Editor", applicationName: "Editor", + windowTitle: "Saved", documentURL: "file:///same-document", windowIdentifier: nil, + role: "AXWindow", subrole: "AXStandardWindow", windowIndex: index, + displayIdentity: WindowDisplayIdentity(rawValue: "main"), + relativeX: 0, relativeY: 0, width: 800, height: 600, runtimeIdentity: identity + ) + } + + private func candidate(title: String, index: Int, identity: WindowRuntimeIdentity?) -> WindowMatchCandidate { + WindowMatchCandidate( + windowTitle: title, documentURL: title == "Saved" ? "file:///same-document" : nil, + windowIdentifier: nil, role: "AXWindow", subrole: "AXStandardWindow", windowIndex: index, + frame: CGRect(x: 0, y: 0, width: 800, height: 600), runtimeIdentity: identity + ) + } +} diff --git a/MousePortalTests/Services/WindowLayoutEngineTests.swift b/MousePortalTests/Services/WindowLayoutEngineTests.swift index fc85acb..f8fdd0d 100644 --- a/MousePortalTests/Services/WindowLayoutEngineTests.swift +++ b/MousePortalTests/Services/WindowLayoutEngineTests.swift @@ -120,8 +120,8 @@ final class WindowLayoutEngineTests: XCTestCase { ] XCTAssertEqual( - WindowLayoutEngine.bestMatchIndex(for: placement, candidates: candidates, excluding: []), - 1 + WindowLayoutEngine.matchWindowIndices(for: [placement], candidates: candidates, preferredIndices: [nil]), + [1] ) } @@ -130,13 +130,12 @@ final class WindowLayoutEngineTests: XCTestCase { let candidates = [candidate(windowIndex: 0), candidate(windowIndex: 1)] XCTAssertEqual( - WindowLayoutEngine.bestMatchIndex( - for: placement, + WindowLayoutEngine.matchWindowIndices( + for: [placement], candidates: candidates, - excluding: [], - preferredIndex: 1 + preferredIndices: [1] ), - 1 + [1] ) } @@ -145,13 +144,12 @@ final class WindowLayoutEngineTests: XCTestCase { let candidates = [candidate(windowIndex: 0), candidate(windowIndex: 1)] XCTAssertEqual( - WindowLayoutEngine.bestMatchIndex( - for: placement, + WindowLayoutEngine.matchWindowIndices( + for: [placement, placement], candidates: candidates, - excluding: [1], - preferredIndex: 1 + preferredIndices: [1, 1] ), - 0 + [1, 0] ) } @@ -160,8 +158,10 @@ final class WindowLayoutEngineTests: XCTestCase { let candidates = [candidate(windowIndex: 0), candidate(windowIndex: 1)] XCTAssertEqual( - WindowLayoutEngine.bestMatchIndex(for: placement, candidates: candidates, excluding: [0]), - 1 + WindowLayoutEngine.matchWindowIndices( + for: [placement, placement], candidates: candidates, preferredIndices: [0, nil] + ), + [0, 1] ) } @@ -169,14 +169,14 @@ final class WindowLayoutEngineTests: XCTestCase { let placement = placement() let candidates = [candidate(subrole: "AXDialog")] - XCTAssertNil(WindowLayoutEngine.bestMatchIndex( - for: placement, + XCTAssertEqual(WindowLayoutEngine.matchWindowIndices( + for: [placement], candidates: candidates, - excluding: [] - )) + preferredIndices: [nil] + ), [nil]) } - func testWindowMatchingRejectsUnrelatedFallbackWindow() { + func testWindowMatchingRejectsUnrelatedWindowWithDifferentIndex() { let placement = placement( documentURL: "file:///project/README.md", windowIndex: 0 @@ -187,15 +187,15 @@ final class WindowLayoutEngineTests: XCTestCase { windowIdentifier: "other-window", role: "AXWindow", subrole: "AXStandardWindow", - windowIndex: 0, + windowIndex: 1, frame: CGRect(x: 0, y: 0, width: 800, height: 600) )] - XCTAssertNil(WindowLayoutEngine.bestMatchIndex( - for: placement, + XCTAssertEqual(WindowLayoutEngine.matchWindowIndices( + for: [placement], candidates: candidates, - excluding: [] - )) + preferredIndices: [nil] + ), [nil]) } func testWindowMatchingAllowsRetainedRuntimeWindowWithoutMetadataMatch() { @@ -210,12 +210,205 @@ final class WindowLayoutEngineTests: XCTestCase { frame: CGRect(x: 300, y: 200, width: 500, height: 400) )] - XCTAssertEqual(WindowLayoutEngine.bestMatchIndex( - for: placement, + XCTAssertEqual(WindowLayoutEngine.matchWindowIndices( + for: [placement], candidates: candidates, - excluding: [], - preferredIndex: 0 - ), 0) + preferredIndices: [0] + ), [0]) + } + + func testBatchWindowMatchingReservesStrongMatchesBeforeIndexFallback() { + let placements = [ + placement( + windowTitle: "Codex - Microsoft Edge", + documentURL: "https://chatgpt.com/codex", + windowIndex: 0 + ), + placement( + windowTitle: "Game - Microsoft Edge", + documentURL: "https://game.example.com", + windowIndex: 1 + ), + placement( + windowTitle: "Agent Showcases - Microsoft Edge", + documentURL: "https://showcases.example.com/old", + windowIndex: 2 + ) + ] + let candidates = [ + candidate( + windowTitle: "VPC Console - Microsoft Edge", + documentURL: "https://vpc.example.com", + windowIndex: 0 + ), + candidate( + windowTitle: "Agent Showcases - Microsoft Edge", + documentURL: "https://showcases.example.com/new", + windowIndex: 1 + ) + ] + + XCTAssertEqual( + WindowLayoutEngine.matchWindowIndices( + for: placements, + candidates: candidates, + preferredIndices: [nil, nil, nil] + ), + [0, nil, 1] + ) + } + + func testBatchWindowMatchingDoesNotFallbackAcrossDifferentWindowIndices() { + let placements = [placement( + windowTitle: "Saved", + documentURL: "https://saved.example.com", + windowIndex: 1 + )] + let candidates = [candidate( + windowTitle: "Current", + documentURL: "https://current.example.com", + windowIndex: 0 + )] + + XCTAssertEqual( + WindowLayoutEngine.matchWindowIndices( + for: placements, + candidates: candidates, + preferredIndices: [nil] + ), + [nil] + ) + } + + func testMetadataAssignmentDoesNotLoseACompleteMatchingToOneHigherScore() { + let a = placement(windowTitle: "A", windowIdentifier: "id-a", windowIndex: 10) + let b = placement(windowTitle: "B", documentURL: "doc-b", windowIdentifier: "id-b", windowIndex: 20) + let x = candidate(windowTitle: "A", documentURL: "doc-b", windowIdentifier: "id-a", windowIndex: 0) + let y = candidate(windowTitle: "B", windowIdentifier: "id-b", windowIndex: 1) + + XCTAssertEqual(WindowLayoutEngine.matchWindowIndices( + for: [a, b], candidates: [x, y], preferredIndices: [nil, nil] + ), [0, 1]) + XCTAssertEqual(WindowLayoutEngine.matchWindowIndices( + for: [b, a], candidates: [y, x], preferredIndices: [nil, nil] + ), [0, 1]) + } + + func testMetadataAssignmentMaximizesTotalScoreAtEqualCardinality() { + let a = placement(windowTitle: "Shared", documentURL: "doc-a", windowIdentifier: "id-a", windowIndex: 10) + let b = placement(windowTitle: "Shared", windowIdentifier: "id-b", windowIndex: 20) + let x = candidate(windowTitle: "Shared", documentURL: "doc-a", windowIdentifier: "id-b", windowIndex: 0) + let y = candidate(windowTitle: "Shared", windowIdentifier: "id-a", windowIndex: 1) + + XCTAssertEqual(WindowLayoutEngine.matchWindowIndices( + for: [a, b], candidates: [x, y], preferredIndices: [nil, nil] + ), [1, 0]) + } + + func testMetadataAssignmentMatchesExhaustiveSmallGraphOptimum() { + var seed: UInt64 = 42 + func next(_ limit: Int) -> Int { + seed = seed &* 6364136223846793005 &+ 1 + return Int((seed >> 32) % UInt64(limit)) + } + func score(_ saved: WindowPlacement, _ current: WindowMatchCandidate) -> Int { + var value = saved.windowTitle == current.windowTitle ? 300 : 0 + if let document = saved.documentURL, document == current.documentURL { value += 1000 } + if let identifier = saved.windowIdentifier, identifier == current.windowIdentifier { value += 600 } + return value > 0 ? value + 80 : 0 + } + for _ in 0..<200 { + let placements = (0.., _ total: Int) { + if row == placements.count { + if used.count > best.count || (used.count == best.count && total > best.score) { + best = (used.count, total) + } + return + } + enumerate(row + 1, used, total) + for column in candidates.indices where !used.contains(column) { + let value = score(placements[row], candidates[column]) + if value > 0 { enumerate(row + 1, used.union([column]), total + value) } + } + } + enumerate(0, [], 0) + let matches = WindowLayoutEngine.matchWindowIndices( + for: placements, candidates: candidates, + preferredIndices: [Int?](repeating: nil, count: placements.count) + ) + let columns = matches.compactMap { $0 } + let total = matches.enumerated().reduce(0) { sum, pair in + guard let column = pair.element else { return sum } + return sum + score(placements[pair.offset], candidates[column]) + } + XCTAssertEqual(Set(columns).count, columns.count) + XCTAssertEqual(columns.count, best.count) + XCTAssertEqual(total, best.score) + } + } + + func testBatchWindowMatchingAllocatesStrongerDocumentMatchBeforeEarlierTitleMatch() { + let weak = placement(windowTitle: "Shared", windowIndex: 0) + let strong = placement(windowTitle: "Old title", documentURL: "file:///report.pdf", windowIndex: 1) + let candidates = [candidate(windowTitle: "Shared", documentURL: "file:///report.pdf", windowIndex: 0)] + + XCTAssertEqual(WindowLayoutEngine.matchWindowIndices( + for: [weak, strong], candidates: candidates, preferredIndices: [nil, nil] + ), [nil, 0]) + XCTAssertEqual(WindowLayoutEngine.matchWindowIndices( + for: [strong, weak], candidates: candidates, preferredIndices: [nil, nil] + ), [0, nil]) + } + + func testBatchWindowMatchingAllocatesIdentifierMatchWithoutDocumentURLs() { + let placements = [ + placement(windowTitle: "Shared", windowIndex: 0), + placement(windowTitle: "Old title", windowIdentifier: "editor-window", windowIndex: 1) + ] + let candidates = [candidate(windowTitle: "Shared", windowIdentifier: "editor-window", windowIndex: 0)] + + XCTAssertEqual(WindowLayoutEngine.matchWindowIndices( + for: placements, candidates: candidates, preferredIndices: [nil, nil] + ), [nil, 0]) + } + + func testBatchWindowMatchingDoesNotScoreBlankDocumentsAsIdentityEvidence() { + for blankDocument in ["", " \n "] { + let weak = placement(windowTitle: "Shared", documentURL: blankDocument, windowIndex: 0) + let strong = placement(windowTitle: "Old title", windowIdentifier: "editor-42", windowIndex: 1) + let candidates = [candidate( + windowTitle: "Shared", documentURL: blankDocument, + windowIdentifier: "editor-42", windowIndex: 0 + )] + + XCTAssertEqual(WindowLayoutEngine.matchWindowIndices( + for: [weak, strong], candidates: candidates, preferredIndices: [nil, nil] + ), [nil, 0]) + } + } + + func testBatchWindowMatchingKeepsRuntimeIdentitiesForIdenticalDocuments() { + let placements = [ + placement(documentURL: "file:///report.pdf", windowIndex: 0), + placement(documentURL: "file:///report.pdf", windowIndex: 1) + ] + let candidates = [ + candidate(documentURL: "file:///report.pdf", windowIndex: 0), + candidate(documentURL: "file:///report.pdf", windowIndex: 1) + ] + + XCTAssertEqual(WindowLayoutEngine.matchWindowIndices( + for: placements, candidates: candidates, preferredIndices: [1, 0] + ), [1, 0]) } func testSnapshotCodableRoundTripPreservesStableDisplayIdentity() throws { @@ -256,7 +449,9 @@ final class WindowLayoutEngineTests: XCTestCase { } private func placement( + windowTitle: String = "README", documentURL: String? = nil, + windowIdentifier: String? = nil, windowIndex: Int = 0, displayIdentity: WindowDisplayIdentity = WindowDisplayIdentity(rawValue: "main"), relativeX: Double = 0, @@ -267,9 +462,9 @@ final class WindowLayoutEngineTests: XCTestCase { WindowPlacement( bundleIdentifier: "com.example.Editor", applicationName: "Editor", - windowTitle: "README", + windowTitle: windowTitle, documentURL: documentURL, - windowIdentifier: nil, + windowIdentifier: windowIdentifier, role: "AXWindow", subrole: "AXStandardWindow", windowIndex: windowIndex, @@ -282,14 +477,16 @@ final class WindowLayoutEngineTests: XCTestCase { } private func candidate( + windowTitle: String = "README", documentURL: String? = nil, + windowIdentifier: String? = nil, windowIndex: Int = 0, subrole: String = "AXStandardWindow" ) -> WindowMatchCandidate { WindowMatchCandidate( - windowTitle: "README", + windowTitle: windowTitle, documentURL: documentURL, - windowIdentifier: nil, + windowIdentifier: windowIdentifier, role: "AXWindow", subrole: subrole, windowIndex: windowIndex, diff --git a/MousePortalTests/Services/WindowLayoutServiceTests.swift b/MousePortalTests/Services/WindowLayoutServiceTests.swift index 1a78da4..d86f10f 100644 --- a/MousePortalTests/Services/WindowLayoutServiceTests.swift +++ b/MousePortalTests/Services/WindowLayoutServiceTests.swift @@ -6,6 +6,7 @@ import AppKit final class WindowLayoutServiceTests: XCTestCase { private var defaults: UserDefaults! private var suiteName: String! + private let screenNotificationCenter = NotificationCenter() override func setUp() { super.setUp() @@ -23,14 +24,17 @@ final class WindowLayoutServiceTests: XCTestCase { func testSaveCurrentLayoutCapturesAndPersistsSnapshot() { let system = MockWindowLayoutSystemProvider() system.displays = [display(identity: "main")] - system.windows = [placement(displayIdentity: WindowDisplayIdentity(rawValue: "main"))] + var window = placement(displayIdentity: WindowDisplayIdentity(rawValue: "main")) + window.runtimeIdentity = runtimeIdentity() + system.windows = [window] let service = makeService(system: system) let snapshot = service.saveCurrentLayout() - let reloadedService = makeService(system: system) + let reloadedService = makeService(system: MockWindowLayoutSystemProvider()) XCTAssertEqual(snapshot?.windows.count, 1) XCTAssertEqual(reloadedService.snapshot, snapshot) + XCTAssertEqual(reloadedService.snapshot?.windows.first?.runtimeIdentity, window.runtimeIdentity) } func testManualSavesAppendMultipleSnapshots() { @@ -115,6 +119,7 @@ final class WindowLayoutServiceTests: XCTestCase { func testAutomaticSnapshotReplacesPreviousSnapshotForSameDisplayLayout() { let system = MockWindowLayoutSystemProvider() system.displays = [display(identity: "main")] + system.windows = [placement(displayIdentity: WindowDisplayIdentity(rawValue: "main"))] let service = makeService(system: system) service.setEnabled(true) let firstAutomaticID = service.snapshots.first?.id @@ -130,6 +135,7 @@ final class WindowLayoutServiceTests: XCTestCase { XCTAssertEqual(automaticSnapshots.count, 1) XCTAssertNotEqual(automaticSnapshots.first?.id, firstAutomaticID) XCTAssertEqual(system.captureRuntimeSnapshotIDs.last!, automaticSnapshots.first?.id) + XCTAssertEqual(system.lastRetainedSnapshotIDs, Set(service.snapshots.map(\.id))) } func testRememberBeforeSleepOrLockPersistsIndependentlyFromGlobalSwitch() { @@ -196,19 +202,32 @@ final class WindowLayoutServiceTests: XCTestCase { XCTAssertEqual(system.currentDisplayCallCount, displayCallCount) } - func testManualSnapshotDoesNotRetainRuntimeWindowIdentity() { + func testManualSnapshotRetainsRuntimeWindowIdentity() { let system = MockWindowLayoutSystemProvider() system.displays = [display(identity: "main")] let service = makeService(system: system) - _ = service.saveCurrentLayout() + let snapshot = service.saveCurrentLayout() + + XCTAssertEqual(system.captureRuntimeSnapshotIDs.last!, snapshot?.id) + } + + func testManualSnapshotDoesNotDiscardAutomaticRuntimeIdentities() { + let system = MockWindowLayoutSystemProvider() + system.displays = [display(identity: "main")] + system.windows = [placement(displayIdentity: WindowDisplayIdentity(rawValue: "main"))] + let service = makeService(system: system) + service.setEnabled(true) + let automaticID = service.snapshots.first!.id + let manualID = service.saveCurrentLayout()!.id - XCTAssertNil(system.captureRuntimeSnapshotIDs.last!) + XCTAssertEqual(system.lastRetainedSnapshotIDs, [automaticID, manualID]) } func testMatchingSnapshotsSortAutomaticBeforeManualSnapshots() { let system = MockWindowLayoutSystemProvider() system.displays = [display(identity: "main")] + system.windows = [placement(displayIdentity: WindowDisplayIdentity(rawValue: "main"))] let service = makeService(system: system) service.setEnabled(true) _ = service.saveCurrentLayout() @@ -222,6 +241,7 @@ final class WindowLayoutServiceTests: XCTestCase { func testPromoteAutomaticSnapshotMakesItManualAndPersists() { let system = MockWindowLayoutSystemProvider() system.displays = [display(identity: "main")] + system.windows = [placement(displayIdentity: WindowDisplayIdentity(rawValue: "main"))] let service = makeService(system: system) service.setEnabled(true) let snapshotID = service.snapshots.first!.id @@ -252,9 +272,268 @@ final class WindowLayoutServiceTests: XCTestCase { XCTAssertTrue(service.deleteSnapshot(id: first.id)) XCTAssertEqual(service.snapshots.map(\.id), [second.id]) + XCTAssertEqual(system.lastRetainedSnapshotIDs, [second.id]) XCTAssertEqual(makeService(system: system).snapshots.map(\.id), [second.id]) } + func testDeletingAutomaticSnapshotWaitsForNextScreenLockBeforeRecreatingIt() { + let system = MockWindowLayoutSystemProvider() + system.displays = [display(identity: "main")] + var window = placement(displayIdentity: WindowDisplayIdentity(rawValue: "main")) + window.runtimeIdentity = runtimeIdentity() + system.windows = [window] + system.visibleWindows = [window] + let service = makeService(system: system) + service.setEnabled(true) + let automaticSnapshotID = service.snapshots.first!.id + + XCTAssertTrue(service.deleteSnapshot(id: automaticSnapshotID)) + system.windows = [] + service.startMonitoring() + defer { service.stopMonitoring() } + + XCTAssertTrue(service.snapshots.isEmpty) + + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.sessionDidResignActiveNotification, + object: nil + ) + XCTAssertTrue(service.snapshots.isEmpty) + XCTAssertEqual(service.state, .normal) + + screenNotificationCenter.post( + name: NSNotification.Name("com.apple.screenIsLocked"), + object: nil + ) + + XCTAssertEqual(service.snapshots.count, 1) + XCTAssertEqual(service.snapshots.first?.kind, .automatic) + XCTAssertEqual(service.snapshots.first?.windows.count, 1) + XCTAssertNotEqual(service.snapshots.first?.id, automaticSnapshotID) + XCTAssertEqual(service.state, .snapshotFrozen) + XCTAssertEqual(system.captureVisibleWindowCallCount, 1) + let reloadedService = makeService(system: MockWindowLayoutSystemProvider()) + XCTAssertEqual(reloadedService.snapshot?.windows.first?.runtimeIdentity, window.runtimeIdentity) + } + + func testFailedLockCapturePreservesSavedLayoutAndAllowsNextLockToRetry() { + let system = MockWindowLayoutSystemProvider() + system.displays = [display(identity: "main")] + system.windows = [identifiedWindow(41)] + let service = makeService(system: system) + service.setEnabled(true) + let original = service.snapshot! + let persisted = defaults.data(forKey: "windowLayoutSnapshots") + system.windows = [] + service.startMonitoring() + defer { service.stopMonitoring() } + + for name in [NSWorkspace.sessionDidResignActiveNotification, NSWorkspace.willSleepNotification] { + NSWorkspace.shared.notificationCenter.post(name: name, object: nil) + XCTAssertEqual(service.snapshot, original) + XCTAssertEqual(service.state, .normal) + } + screenNotificationCenter.post(name: NSNotification.Name("com.apple.screenIsLocked"), object: nil) + + XCTAssertEqual(service.snapshot, original) + XCTAssertEqual(defaults.data(forKey: "windowLayoutSnapshots"), persisted) + XCTAssertEqual(system.lastRetainedSnapshotIDs, [original.id]) + XCTAssertEqual(system.captureVisibleWindowCallCount, 1) + XCTAssertEqual(service.state, .normal) + + screenNotificationCenter.post(name: NSNotification.Name("com.apple.screenIsUnlocked"), object: nil) + system.visibleWindows = original.windows + screenNotificationCenter.post(name: NSNotification.Name("com.apple.screenIsLocked"), object: nil) + XCTAssertEqual(service.snapshot?.windows, original.windows) + XCTAssertNotEqual(service.snapshot?.id, original.id) + XCTAssertEqual(system.captureVisibleWindowCallCount, 2) + XCTAssertEqual(service.state, .snapshotFrozen) + } + + func testStartingWithNoAutomaticLayoutDoesNotCreateOneUntilLock() { + defaults.set(true, forKey: "windowRecoveryEnabled") + let system = MockWindowLayoutSystemProvider() + system.displays = [display(identity: "main")] + system.visibleWindows = [identifiedWindow(41)] + let service = makeService(system: system) + service.startMonitoring() + defer { service.stopMonitoring() } + + XCTAssertTrue(service.snapshots.isEmpty) + XCTAssertEqual(system.captureCallCount, 0) + screenNotificationCenter.post(name: NSNotification.Name("com.apple.screenIsLocked"), object: nil) + XCTAssertEqual(service.snapshot?.windows, system.visibleWindows) + XCTAssertEqual(service.state, .snapshotFrozen) + } + + func testSuccessfulAXLockCaptureAlsoChecksWindowServerForMissingWindows() { + let system = MockWindowLayoutSystemProvider() + system.displays = [display(identity: "main")] + system.windows = [placement(displayIdentity: WindowDisplayIdentity(rawValue: "main"))] + let service = makeService(system: system) + service.setEnabled(true) + service.startMonitoring() + defer { service.stopMonitoring() } + + screenNotificationCenter.post(name: NSNotification.Name("com.apple.screenIsLocked"), object: nil) + + XCTAssertEqual(service.snapshot?.windows, system.windows) + XCTAssertEqual(system.captureVisibleWindowCallCount, 1) + } + + func testScreenLockKeepsSuccessfulPreLockSnapshot() { + let system = MockWindowLayoutSystemProvider() + system.displays = [display(identity: "main")] + system.windows = [placement(displayIdentity: WindowDisplayIdentity(rawValue: "main"))] + let service = makeService(system: system) + service.setEnabled(true) + service.startMonitoring() + defer { service.stopMonitoring() } + NSWorkspace.shared.notificationCenter.post(name: NSWorkspace.sessionDidResignActiveNotification, object: nil) + let frozen = service.snapshot + system.windows = [] + + screenNotificationCenter.post(name: NSNotification.Name("com.apple.screenIsLocked"), object: nil) + + XCTAssertEqual(service.snapshot, frozen) + XCTAssertEqual(service.state, .snapshotFrozen) + XCTAssertEqual(system.captureVisibleWindowCallCount, 1) + } + + func testScreenLockMergesPartialAXCaptureByExactIdentity() { + let system = MockWindowLayoutSystemProvider() + system.displays = [display(identity: "main")] + let first = identifiedWindow(41) + let missing = identifiedWindow(42) + system.windows = [first] + system.visibleWindows = [first, missing, missing] + let service = makeService(system: system) + service.setEnabled(true) + service.startMonitoring() + defer { service.stopMonitoring() } + + screenNotificationCenter.post(name: NSNotification.Name("com.apple.screenIsLocked"), object: nil) + + XCTAssertEqual(service.snapshot?.windows, [first, missing]) + XCTAssertEqual(system.captureVisibleWindowCallCount, 1) + XCTAssertEqual(makeService(system: system).snapshot?.windows, [first, missing]) + } + + func testScreenLockCompletesFrozenPartialSnapshotWithoutReplacingItsAXGeometryOrCache() { + let system = MockWindowLayoutSystemProvider() + system.displays = [display(identity: "main")] + let first = identifiedWindow(41) + let missing = identifiedWindow(42) + system.windows = [first] + let service = makeService(system: system) + service.setEnabled(true) + service.startMonitoring() + defer { service.stopMonitoring() } + NSWorkspace.shared.notificationCenter.post(name: NSWorkspace.sessionDidResignActiveNotification, object: nil) + let frozen = service.snapshot! + let captureCalls = system.captureCallCount + system.windows = [] + system.visibleWindows = [identifiedWindow(41, width: 600), missing] + + screenNotificationCenter.post(name: NSNotification.Name("com.apple.screenIsLocked"), object: nil) + + XCTAssertEqual(service.snapshot?.windows, [first, missing]) + XCTAssertEqual(service.snapshot?.id, frozen.id) + XCTAssertEqual(service.snapshot?.capturedAt, frozen.capturedAt) + XCTAssertEqual(system.captureCallCount, captureCalls) + XCTAssertEqual(system.lastRetainedSnapshotIDs, [frozen.id]) + XCTAssertEqual(service.state, .snapshotFrozen) + let completed = service.snapshot + screenNotificationCenter.post(name: NSNotification.Name("com.apple.screenIsLocked"), object: nil) + XCTAssertEqual(service.snapshot, completed) + system.visibleWindows = [] + screenNotificationCenter.post(name: NSNotification.Name("com.apple.screenIsLocked"), object: nil) + XCTAssertEqual(service.snapshot, completed) + system.visibleCaptureFails = true + screenNotificationCenter.post(name: NSNotification.Name("com.apple.screenIsLocked"), object: nil) + XCTAssertEqual(service.snapshot, completed) + } + + func testLockSupplementDoesNotGuessDuplicatesWhenAXIdentityIsUnavailable() { + let system = MockWindowLayoutSystemProvider() + system.displays = [display(identity: "main")] + system.windows = [placement(displayIdentity: WindowDisplayIdentity(rawValue: "main"))] + system.visibleWindows = [identifiedWindow(41), identifiedWindow(42, bundle: "com.example.Other")] + let service = makeService(system: system) + service.setEnabled(true) + service.startMonitoring() + defer { service.stopMonitoring() } + screenNotificationCenter.post(name: NSNotification.Name("com.apple.screenIsLocked"), object: nil) + + XCTAssertEqual(service.snapshot?.windows, system.windows + [system.visibleWindows[1]]) + } + + func testFailedLockSupplementDoesNotReplaceOldLayoutWithPartialAXCapture() { + let system = MockWindowLayoutSystemProvider() + system.displays = [display(identity: "main")] + system.windows = [identifiedWindow(41), identifiedWindow(42)] + let service = makeService(system: system) + service.setEnabled(true) + let saved = service.snapshot! + let persisted = defaults.data(forKey: "windowLayoutSnapshots") + system.windows = [identifiedWindow(41)] + system.visibleCaptureFails = true + service.startMonitoring() + defer { service.stopMonitoring() } + screenNotificationCenter.post(name: NSNotification.Name("com.apple.screenIsLocked"), object: nil) + XCTAssertEqual(service.snapshot, saved) + XCTAssertEqual(defaults.data(forKey: "windowLayoutSnapshots"), persisted) + XCTAssertEqual(system.lastRetainedSnapshotIDs, [saved.id]) + XCTAssertEqual(service.state, .normal) + } + + func testLockSupplementDoesNotDeduplicateDifferentProcessesWithTheSameWindowNumber() { + let system = MockWindowLayoutSystemProvider() + system.displays = [display(identity: "main")] + let first = identifiedWindow(41) + var otherProcess = first + otherProcess.runtimeIdentity = WindowRuntimeIdentity( + bundleIdentifier: first.bundleIdentifier, processIdentifier: 200, + processLaunchDate: Date(timeIntervalSince1970: 2000), sessionIdentifier: "boot:login", windowID: 41 + ) + system.windows = [first] + system.visibleWindows = [otherProcess] + let service = makeService(system: system) + service.setEnabled(true) + service.startMonitoring() + defer { service.stopMonitoring() } + screenNotificationCenter.post(name: NSNotification.Name("com.apple.screenIsLocked"), object: nil) + XCTAssertEqual(service.snapshot?.windows, [first, otherProcess]) + } + + func testSleepAndManualCaptureDoNotUseLockOnlySupplement() { + let system = MockWindowLayoutSystemProvider() + system.displays = [display(identity: "main")] + system.windows = [identifiedWindow(41)] + system.visibleWindows = [identifiedWindow(42)] + let service = makeService(system: system) + service.setEnabled(true) + _ = service.saveCurrentLayout() + service.startMonitoring() + defer { service.stopMonitoring() } + NSWorkspace.shared.notificationCenter.post(name: NSWorkspace.willSleepNotification, object: nil) + XCTAssertEqual(service.snapshot?.windows, system.windows) + XCTAssertEqual(system.captureVisibleWindowCallCount, 0) + } + + private func identifiedWindow(_ id: UInt32, bundle: String = "com.example.Editor", width: Double = 800) -> WindowPlacement { + WindowPlacement( + bundleIdentifier: bundle, applicationName: "Editor", windowTitle: "README", + documentURL: nil, windowIdentifier: nil, role: "AXWindow", subrole: "AXStandardWindow", + windowIndex: 0, displayIdentity: WindowDisplayIdentity(rawValue: "main"), + relativeX: 0.1, relativeY: 0.1, width: width, height: 600, + runtimeIdentity: WindowRuntimeIdentity( + bundleIdentifier: bundle, processIdentifier: 100, + processLaunchDate: Date(timeIntervalSince1970: 1000), sessionIdentifier: "boot:login", windowID: id + ) + ) + } + func testLegacySingleSnapshotMigratesToSnapshotCollection() throws { let system = MockWindowLayoutSystemProvider() let savedDisplay = display(identity: "main") @@ -436,6 +715,7 @@ final class WindowLayoutServiceTests: XCTestCase { WindowLayoutService( system: system, defaults: defaults, + screenNotificationCenter: screenNotificationCenter, accessibilityIsTrusted: { true } ) } @@ -468,6 +748,14 @@ final class WindowLayoutServiceTests: XCTestCase { ) } + private func runtimeIdentity() -> WindowRuntimeIdentity { + WindowRuntimeIdentity( + bundleIdentifier: "com.example.Editor", processIdentifier: 100, + processLaunchDate: Date(timeIntervalSince1970: 1_000.123456), + sessionIdentifier: "boot:login", windowID: 41 + ) + } + private func privateTimer(named name: String, in service: WindowLayoutService) -> Timer? { Mirror(reflecting: service).children .first { $0.label == name }? @@ -478,14 +766,18 @@ final class WindowLayoutServiceTests: XCTestCase { private final class MockWindowLayoutSystemProvider: WindowLayoutSystemProviding { var displays: [WindowDisplaySnapshot] = [] var windows: [WindowPlacement] = [] + var visibleWindows: [WindowPlacement] = [] + var visibleCaptureFails = false var applications: [WindowApplicationOption] = [] var restoreResult = WindowRestoreResult(restoredCount: 0, skippedCount: 0, failedCount: 0) private(set) var lastIgnoredBundleIdentifiers: Set = [] private(set) var restoreCallCount = 0 private(set) var captureCallCount = 0 + private(set) var captureVisibleWindowCallCount = 0 private(set) var currentDisplayCallCount = 0 private(set) var restoredSnapshotID: UUID? - private(set) var captureRuntimeSnapshotIDs: [UUID?] = [] + private(set) var captureRuntimeSnapshotIDs: [UUID] = [] + private(set) var lastRetainedSnapshotIDs: Set = [] func currentDisplays() -> [WindowDisplaySnapshot] { currentDisplayCallCount += 1 @@ -495,7 +787,7 @@ private final class MockWindowLayoutSystemProvider: WindowLayoutSystemProviding func captureWindows( displays: [WindowDisplaySnapshot], ignoring bundleIdentifiers: Set, - runtimeSnapshotID: UUID? + runtimeSnapshotID: UUID ) -> [WindowPlacement] { captureCallCount += 1 lastIgnoredBundleIdentifiers = bundleIdentifiers @@ -503,6 +795,14 @@ private final class MockWindowLayoutSystemProvider: WindowLayoutSystemProviding return windows } + func captureVisibleWindows( + displays: [WindowDisplaySnapshot], + ignoring bundleIdentifiers: Set + ) -> [WindowPlacement]? { + captureVisibleWindowCallCount += 1 + return visibleCaptureFails ? nil : visibleWindows + } + func restoreWindows( from snapshot: WindowLayoutSnapshot, currentDisplays: [WindowDisplaySnapshot] @@ -516,6 +816,10 @@ private final class MockWindowLayoutSystemProvider: WindowLayoutSystemProviding applications } + func discardWindowIdentities(except snapshotIDs: Set) { + lastRetainedSnapshotIDs = snapshotIDs + } + func logDisplayState(event: String) {} }