From 421ed8c2195dccca61381522cf01c774fd089986 Mon Sep 17 00:00:00 2001 From: Quentin Eude Date: Tue, 25 Aug 2026 22:23:34 +0200 Subject: [PATCH 1/2] fix: preserve window identity across native tabs - Collapse native tab backing windows behind their representative - Carry runtime state across representative identity changes --- .../DaemonDesktopSynchronization.swift | 7 +- .../DefiMacOS/MacOSPlatform+Snapshot.swift | 14 +- ...acOSPlatform+WindowSnapshotDiscovery.swift | 126 ++++++- Sources/DefiMacOS/PlatformModels.swift | 3 + Sources/DefiMacOS/SnapshotEngine.swift | 47 +++ .../DefiMacOS/WindowDiscoverySupport.swift | 224 +++++++++++++ .../DefiRuntime/WindowReconciliation.swift | 112 +++++++ .../DefiMacOSTests/WindowDiscoveryTests.swift | 313 ++++++++++++++++++ .../WindowLifecycleTests.swift | 39 +++ docs/research/omniwm-native-tabs.md | 187 +++++++++++ 10 files changed, 1066 insertions(+), 6 deletions(-) create mode 100644 docs/research/omniwm-native-tabs.md diff --git a/Sources/DefiDaemon/DaemonDesktopSynchronization.swift b/Sources/DefiDaemon/DaemonDesktopSynchronization.swift index 44ee0f3..d6782b0 100644 --- a/Sources/DefiDaemon/DaemonDesktopSynchronization.swift +++ b/Sources/DefiDaemon/DaemonDesktopSynchronization.swift @@ -106,7 +106,7 @@ extension Daemon { } let previousSelectedWindowID = previousActiveMonitorID.flatMap { state.selectedWindowID(on: $0) - } + }.map { snapshot.windowIDReplacements[$0] ?? $0 } let mouseGestureEnded = snapshot.mouseResizeGestureObserved && !snapshot.leftMouseButtonDown let mouseInteractionEnded = @@ -175,7 +175,9 @@ extension Daemon { var nativeCursorWarpWindowID: WindowID? var nativeCursorWarpInputTimestamp: TimeInterval? var nativeFocusFrameMonitorID: MonitorID? - let previouslyManagedWindowIDs = Set(state.windows.keys) + let previouslyManagedWindowIDs = Set(state.windows.keys.map { + snapshot.windowIDReplacements[$0] ?? $0 + }) let enteringNativeFullscreenWindowIDs = snapshot.nativeFullscreenWindowIDs .subtracting(state.nativeFullscreenWindowIDs) platform.updateNativeFullscreenWindowIDs( @@ -196,6 +198,7 @@ extension Daemon { snapshot.windows, config: config, placementPreferences: placementPreferences, + windowIDReplacements: snapshot.windowIDReplacements, externallyChangedWindowIDs: Set(snapshot.externallyChangedFrames.keys), nativeFullscreenWindowIDs: snapshot.nativeFullscreenWindowIDs, viewports: viewportsByMonitor, diff --git a/Sources/DefiMacOS/MacOSPlatform+Snapshot.swift b/Sources/DefiMacOS/MacOSPlatform+Snapshot.swift index 595e734..7986c5c 100644 --- a/Sources/DefiMacOS/MacOSPlatform+Snapshot.swift +++ b/Sources/DefiMacOS/MacOSPlatform+Snapshot.swift @@ -357,7 +357,18 @@ extension SnapshotEngine { retainedWindowIDs: nextRetainedWindowIDs, cachedWindowIDs: cachedSnapshotWindowIDs ) - let removedWindowIDs = Set(previousElements.keys).subtracting(nextWindowIDs) + let windowIDReplacements = discovery.windowIDReplacements + let removedWindowIDs = Set(previousElements.keys) + .subtracting(nextWindowIDs) + .subtracting(windowIDReplacements.keys) + if windowIDReplacements.isEmpty == false { + let replacements = windowIDReplacements.sorted { + $0.key.rawValue < $1.key.rawValue + }.map { + "\($0.key.rawValue)->\($0.value.rawValue)" + }.joined(separator: ",") + frameCoordinator.recordTrace("window-identity-replaced [\(replacements)]") + } newlyDiscoveredWindowIDs = hasCompletedWindowSnapshot ? nextWindowIDs.subtracting(previousElements.keys) @@ -735,6 +746,7 @@ extension SnapshotEngine { focusedWindowID: focusedWindowID, nativeFocusChanged: nativeFocusChanged, removedWindowIDs: removedWindowIDs, + windowIDReplacements: windowIDReplacements, latestUserInputTimestamp: userInput.latestEventTimestamp, userInputAfterWindowTopology: userInputOccurredAfterWindowTopology( topologyInputTimestamp: topologyInputTimestamp, diff --git a/Sources/DefiMacOS/MacOSPlatform+WindowSnapshotDiscovery.swift b/Sources/DefiMacOS/MacOSPlatform+WindowSnapshotDiscovery.swift index 25948d3..f2e20d8 100644 --- a/Sources/DefiMacOS/MacOSPlatform+WindowSnapshotDiscovery.swift +++ b/Sources/DefiMacOS/MacOSPlatform+WindowSnapshotDiscovery.swift @@ -83,6 +83,7 @@ struct SnapshotWindowDiscoveryResult { let nextRetainedWindowIDs: Set let cachedSnapshotWindowIDs: Set let previouslyManagedApplicationWindows: [pid_t: [AXUIElement]] + let windowIDReplacements: [WindowID: WindowID] } extension SnapshotEngine { @@ -125,6 +126,7 @@ extension SnapshotEngine { var minimizedWindows = minimizedWindowElementsByProcess var transientGeometryWindows = transientGeometryWindowElementsByProcess var windows: [Window] = [] + var nextNativeWindowTabGroups: [WindowID: NativeWindowTabGroup] = [:] var nextRetainedWindowIDs = Set() var cachedSnapshotWindowIDs = Set() @@ -161,6 +163,8 @@ extension SnapshotEngine { for (windowID, element) in cachedElements { nextElements[windowID] = element nextProcessIDs[windowID] = processID + nextNativeWindowTabGroups[windowID] = + nativeWindowTabGroupsByWindowID[windowID] } } processIDsToRefresh = requestedProcessIDs @@ -290,10 +294,27 @@ onMain { $0.eventMonitor?.prepareForWindowDiscovery( } ) - for element in appWindows ?? [] { + let orderedWindowCandidates = (appWindows ?? []).enumerated().map { + index, element in let previousWindowID = previousWindowIDsByProcessAndElementHash[processID]?[ CFHash(element) ]?.first { CFEqual(previousElements[$0], element) } + return ( + index: index, + element: element, + previousWindowID: previousWindowID + ) + }.sorted { lhs, rhs in + windowDiscoveryCandidateComesFirst( + lhsPreviousWindowID: lhs.previousWindowID, + lhsIndex: lhs.index, + rhsPreviousWindowID: rhs.previousWindowID, + rhsIndex: rhs.index + ) + } + for candidate in orderedWindowCandidates { + let element = candidate.element + let previousWindowID = candidate.previousWindowID if previousWindowID.map(explicitlyDestroyedWindowIDs.contains) == true { continue } @@ -411,11 +432,46 @@ onMain { $0.eventMonitor?.prepareForWindowDiscovery( for: disposition, configuredFloating: decision.floating ) + var nativeTabGroup: NativeWindowTabGroup? + if refreshesWindowList || previousWindowID == nil { + // ponytail: native tab groups are small; index physical IDs if this scan grows. + let belongsToKnownNativeTabGroup = + nativeWindowTabGroupsByWindowID[tracked.id] != nil + || nativeWindowTabGroupsByWindowID.values.contains { + $0.backingWindowIDs.contains(tracked.id) + } + nativeTabGroup = AXMessagingTimeoutAccess.shared.withTimeout( + snapshotAccessibilityTimeoutSeconds, + elements: [element] + ) { + self.nativeWindowTabGroup( + in: element, + windowFrame: tracked.frame, + allowsTransientFrameMismatch: + belongsToKnownNativeTabGroup + ) + } + } else { + nativeTabGroup = previousWindowID.flatMap { + nativeWindowTabGroupsByWindowID[$0] + } + } + if let detectedGroup = nativeTabGroup { + nativeTabGroup = nativeWindowTabGroupRebindingKnownMembers( + detectedGroup, + representativeID: tracked.id, + processID: processID, + previousGroupsByRepresentativeID: + nativeWindowTabGroupsByWindowID, + previousProcessIDs: previousProcessIDs + ) + } windows.append(tracked) nextElements[tracked.id] = element nextProcessIDs[tracked.id] = processID + nextNativeWindowTabGroups[tracked.id] = nativeTabGroup } - + let previousWindows = previousWindowsByProcess[processID] ?? [] let discoveredWindowIDs = Set(nextElements.keys) let needsCachedWindowValidation = previousWindows.contains { @@ -474,12 +530,66 @@ onMain { $0.eventMonitor?.prepareForWindowDiscovery( windows.append(previousWindow) nextElements[previousWindow.id] = previousElement nextProcessIDs[previousWindow.id] = processID + nextNativeWindowTabGroups[previousWindow.id] = + nativeWindowTabGroupsByWindowID[previousWindow.id] if applicationWindows[processID]?.contains(where: { CFEqual($0, previousElement) }) != true { applicationWindows[processID, default: []].append(previousElement) } } + + let processNativeTabGroups = nextNativeWindowTabGroups.filter { + nextProcessIDs[$0.key] == processID + } + let newlyObservedProcessWindowIDs = Set( + windows.lazy.filter { $0.processID == processID }.map(\.id) + ).subtracting(previousElements.keys) + let additionalBackingWindowIDsByRepresentative: + [WindowID: Set] = Dictionary( + uniqueKeysWithValues: processNativeTabGroups.compactMap { + representativeID, group in + guard let previousGroup = + nativeWindowTabGroupsByWindowID[representativeID], + group.tabTitles.count == previousGroup.tabTitles.count + 1 + else { return nil } + return (representativeID, newlyObservedProcessWindowIDs) + } + ) + let nativeTabBackingIDsByRepresentative = + nativeTabBackingWindowIDsByRepresentative( + windows: windows.filter { $0.processID == processID }, + groupsByRepresentativeID: processNativeTabGroups, + retainedWindowIDs: processRetainedWindowIDs, + additionalBackingWindowIDsByRepresentative: + additionalBackingWindowIDsByRepresentative + ) + let nativeTabBackingIDs = Set( + nativeTabBackingIDsByRepresentative.values.flatMap { $0 } + ) + if nativeTabBackingIDs.isEmpty == false { + for (representativeID, backingWindowIDs) in + nativeTabBackingIDsByRepresentative + { + nextNativeWindowTabGroups[representativeID]?.backingWindowIDs = + backingWindowIDs + } + windows.removeAll { nativeTabBackingIDs.contains($0.id) } + for windowID in nativeTabBackingIDs { + nextElements[windowID] = nil + nextProcessIDs[windowID] = nil + nextNativeWindowTabGroups[windowID] = nil + } + let representativeIDs = nativeTabBackingIDsByRepresentative.keys.sorted { + $0.rawValue < $1.rawValue + }.map { String($0.rawValue) }.joined(separator: ",") + let backingIDs = nativeTabBackingIDs.sorted { + $0.rawValue < $1.rawValue + }.map { String($0.rawValue) }.joined(separator: ",") + frameCoordinator.recordTrace( + "native-tabs pid=\(processID) representatives=[\(representativeIDs)] backing=[\(backingIDs)]" + ) + } } resolveTransientOwners( windows: &windows, @@ -491,6 +601,15 @@ onMain { $0.eventMonitor?.prepareForWindowDiscovery( ? Set(nextProcessIDs.values) : topologyProcessIDs ) + let liveNativeWindowTabGroups = nextNativeWindowTabGroups.filter { + nextElements[$0.key] != nil + } + let windowIDReplacements = nativeWindowTabRepresentativeReplacements( + previousWindowIDs: Set(previousElements.keys), + nextWindowIDs: Set(nextElements.keys), + groupsByRepresentativeID: liveNativeWindowTabGroups + ) + nativeWindowTabGroupsByWindowID = liveNativeWindowTabGroups return SnapshotWindowDiscoveryResult( nextElements: nextElements, nextProcessIDs: nextProcessIDs, @@ -503,7 +622,8 @@ onMain { $0.eventMonitor?.prepareForWindowDiscovery( nextRetainedWindowIDs: nextRetainedWindowIDs, cachedSnapshotWindowIDs: cachedSnapshotWindowIDs, previouslyManagedApplicationWindows: - previouslyManagedApplicationWindows + previouslyManagedApplicationWindows, + windowIDReplacements: windowIDReplacements ) } diff --git a/Sources/DefiMacOS/PlatformModels.swift b/Sources/DefiMacOS/PlatformModels.swift index 274f265..bd98f94 100644 --- a/Sources/DefiMacOS/PlatformModels.swift +++ b/Sources/DefiMacOS/PlatformModels.swift @@ -58,6 +58,7 @@ public struct DesktopSnapshot: Sendable { public let focusedWindowID: WindowID? public let nativeFocusChanged: Bool public let removedWindowIDs: Set + public let windowIDReplacements: [WindowID: WindowID] public let latestUserInputTimestamp: TimeInterval public let userInputAfterWindowTopology: Bool public let externallyChangedFrames: [WindowID: Rect] @@ -80,6 +81,7 @@ public struct DesktopSnapshot: Sendable { focusedWindowID: WindowID?, nativeFocusChanged: Bool = false, removedWindowIDs: Set = [], + windowIDReplacements: [WindowID: WindowID] = [:], latestUserInputTimestamp: TimeInterval = 0, userInputAfterWindowTopology: Bool = false, externallyChangedFrames: [WindowID: Rect] = [:], @@ -101,6 +103,7 @@ public struct DesktopSnapshot: Sendable { self.focusedWindowID = focusedWindowID self.nativeFocusChanged = nativeFocusChanged self.removedWindowIDs = removedWindowIDs + self.windowIDReplacements = windowIDReplacements self.latestUserInputTimestamp = latestUserInputTimestamp self.userInputAfterWindowTopology = userInputAfterWindowTopology self.externallyChangedFrames = externallyChangedFrames diff --git a/Sources/DefiMacOS/SnapshotEngine.swift b/Sources/DefiMacOS/SnapshotEngine.swift index 2d258b3..17a2919 100644 --- a/Sources/DefiMacOS/SnapshotEngine.swift +++ b/Sources/DefiMacOS/SnapshotEngine.swift @@ -310,6 +310,11 @@ final class SnapshotEngine: @unchecked Sendable { set { read { $0.windowManagementCapabilities = newValue } } } + var nativeWindowTabGroupsByWindowID: [WindowID: NativeWindowTabGroup] { + get { read { $0.nativeWindowTabGroupsByWindowID } } + set { read { $0.nativeWindowTabGroupsByWindowID = newValue } } + } + var enhancedUIByProcess: [pid_t: Bool] { get { read { $0.enhancedUIByProcess } } set { read { $0.enhancedUIByProcess = newValue } } @@ -977,6 +982,47 @@ extension SnapshotEngine { return Rect(x: position.x, y: position.y, width: size.width, height: size.height) } + func nativeWindowTabGroup( + in element: AXUIElement, + windowFrame: Rect, + allowsTransientFrameMismatch: Bool = false + ) -> NativeWindowTabGroup? { + guard let children = copyElements(element, attribute: kAXChildrenAttribute) else { + return nil + } + for child in children { + guard value(child, attribute: kAXRoleAttribute, as: String.self) == kAXTabGroupRole, + let tabGroupFrame = frame(of: child), + allowsTransientFrameMismatch + || nativeTabGroupFrameIsInWindowChrome( + tabGroupFrame, + windowFrame: windowFrame + ), + let tabs = copyElements(child, attribute: kAXTabsAttribute), + tabs.count > 1 + else { continue } + let selectedTabTitle: String? + if let selectedTabValue = copyAttribute(child, name: kAXValueAttribute), + CFGetTypeID(selectedTabValue) == AXUIElementGetTypeID() + { + selectedTabTitle = value( + selectedTabValue as! AXUIElement, + attribute: kAXTitleAttribute, + as: String.self + ) + } else { + selectedTabTitle = nil + } + return NativeWindowTabGroup( + tabTitles: tabs.map { + value($0, attribute: kAXTitleAttribute, as: String.self) ?? "" + }, + selectedTabTitle: selectedTabTitle + ) + } + return nil + } + func windowAttributes( _ element: AXUIElement, processID: pid_t @@ -1103,6 +1149,7 @@ private struct Storage { var fallbackWindowAttributeReadCount = 0 var windowManagementCapabilities: [WindowID: WindowManagementCapabilities] = [:] + var nativeWindowTabGroupsByWindowID: [WindowID: NativeWindowTabGroup] = [:] var windowManagementMetadataReadCount = 0 var windowManagementMetadataReuseCount = 0 var privateWindowIDLookupCount = 0 diff --git a/Sources/DefiMacOS/WindowDiscoverySupport.swift b/Sources/DefiMacOS/WindowDiscoverySupport.swift index b07735a..36006ad 100644 --- a/Sources/DefiMacOS/WindowDiscoverySupport.swift +++ b/Sources/DefiMacOS/WindowDiscoverySupport.swift @@ -20,6 +20,230 @@ enum WindowDiscoveryResult { case discovered(Window, CGWindowID, RuleDecision) } +func windowDiscoveryCandidateComesFirst( + lhsPreviousWindowID: WindowID?, + lhsIndex: Int, + rhsPreviousWindowID: WindowID?, + rhsIndex: Int +) -> Bool { + if (lhsPreviousWindowID != nil) != (rhsPreviousWindowID != nil) { + return lhsPreviousWindowID != nil + } + return lhsIndex < rhsIndex +} + +struct NativeWindowTabGroup: Equatable, Sendable { + let tabTitles: [String] + let selectedTabTitle: String? + var backingWindowIDs: Set + + init( + tabTitles: [String], + selectedTabTitle: String? = nil, + backingWindowIDs: Set = [] + ) { + self.tabTitles = tabTitles + self.selectedTabTitle = selectedTabTitle + self.backingWindowIDs = backingWindowIDs + } +} + +func nativeTabGroupFrameIsInWindowChrome( + _ tabGroupFrame: Rect, + windowFrame: Rect +) -> Bool { + let tabGroupRight = tabGroupFrame.x + tabGroupFrame.width + let windowRight = windowFrame.x + windowFrame.width + return tabGroupFrame.width > 0 + && tabGroupFrame.height > 0 + && tabGroupFrame.height <= 80 + && tabGroupFrame.width >= windowFrame.width * 0.5 + && tabGroupFrame.x >= windowFrame.x - 4 + && tabGroupRight <= windowRight + 4 + && ( + abs(tabGroupFrame.x - windowFrame.x) <= 4 + || abs(tabGroupRight - windowRight) <= 4 + ) + && tabGroupFrame.y >= windowFrame.y - 2 + && tabGroupFrame.y + tabGroupFrame.height <= windowFrame.y + 96 +} + +func nativeWindowTabGroupRebindingKnownMembers( + _ detectedGroup: NativeWindowTabGroup, + representativeID: WindowID, + processID: pid_t, + previousGroupsByRepresentativeID: [WindowID: NativeWindowTabGroup], + previousProcessIDs: [WindowID: pid_t] +) -> NativeWindowTabGroup { + if let previousGroup = previousGroupsByRepresentativeID[representativeID], + previousGroup.tabTitles.count <= detectedGroup.tabTitles.count + { + var rebound = detectedGroup + rebound.backingWindowIDs = previousGroup.backingWindowIDs + return rebound + } + + let replacements = previousGroupsByRepresentativeID.filter { + previousRepresentativeID, previousGroup in + previousProcessIDs[previousRepresentativeID] == processID + && previousGroup.tabTitles.count == detectedGroup.tabTitles.count + && previousGroup.backingWindowIDs.contains(representativeID) + } + guard replacements.count == 1, + let replacement = replacements.first + else { return detectedGroup } + + var reboundBackingWindowIDs = replacement.value.backingWindowIDs + reboundBackingWindowIDs.insert(replacement.key) + reboundBackingWindowIDs.remove(representativeID) + guard reboundBackingWindowIDs.count == detectedGroup.tabTitles.count + || reboundBackingWindowIDs.count + 1 == detectedGroup.tabTitles.count + else { + return detectedGroup + } + var rebound = detectedGroup + rebound.backingWindowIDs = reboundBackingWindowIDs + return rebound +} + +func nativeWindowTabRepresentativeReplacements( + previousWindowIDs: Set, + nextWindowIDs: Set, + groupsByRepresentativeID: [WindowID: NativeWindowTabGroup] +) -> [WindowID: WindowID] { + let removedWindowIDs = previousWindowIDs.subtracting(nextWindowIDs) + let candidates = nextWindowIDs.subtracting(previousWindowIDs).compactMap { + representativeID -> (WindowID, WindowID)? in + guard let group = groupsByRepresentativeID[representativeID] else { + return nil + } + let previousRepresentatives = group.backingWindowIDs.intersection( + removedWindowIDs + ) + guard previousRepresentatives.count == 1, + let previousRepresentativeID = previousRepresentatives.first + else { return nil } + return (previousRepresentativeID, representativeID) + } + let candidateCounts = Dictionary(grouping: candidates, by: \.0).mapValues(\.count) + return Dictionary( + uniqueKeysWithValues: candidates.filter { candidateCounts[$0.0] == 1 } + ) +} + +func nativeTabBackingWindowIDsByRepresentative( + windows: [Window], + groupsByRepresentativeID: [WindowID: NativeWindowTabGroup], + retainedWindowIDs: Set = [], + additionalBackingWindowIDsByRepresentative: [WindowID: Set] = [:], + maximumFrameDistance: Double = 8 +) -> [WindowID: Set] { + let representatives = Set(groupsByRepresentativeID.keys) + let windowsByID = Dictionary(windows.map { ($0.id, $0) }) { current, _ in current } + let uniqueWindows = Array(windowsByID.values) + var backingWindowIDsByRepresentative: [WindowID: Set] = [:] + var claimedWindowIDs = Set() + + for representativeID in representatives.sorted(by: { $0.rawValue < $1.rawValue }) { + guard let representative = windowsByID[representativeID], + let processID = representative.processID, + let group = groupsByRepresentativeID[representativeID], + group.tabTitles.isEmpty == false + else { continue } + + var directTabTitles = group.tabTitles + if let selectedTabTitle = group.selectedTabTitle, + let selectedIndex = directTabTitles.firstIndex(of: selectedTabTitle) + { + directTabTitles.remove(at: selectedIndex) + } else { + directTabTitles = [] + } + var interpretations: [(titles: [String], requiresRetention: Bool)] = [] + if group.backingWindowIDs.count <= group.tabTitles.count + { + interpretations.append((group.tabTitles, false)) + } + if directTabTitles.isEmpty == false, + group.backingWindowIDs.count <= directTabTitles.count + { + interpretations.append((directTabTitles, true)) + } + + func matchingBackingWindowIDs( + titles: [String], + requiresRetention: Bool + ) -> Set? { + var remainingTitles = Dictionary(grouping: titles, by: { $0 }).mapValues(\.count) + let knownBackingWindows = group.backingWindowIDs.compactMap { windowsByID[$0] } + .filter { window in + representatives.contains(window.id) == false + && claimedWindowIDs.contains(window.id) == false + && window.processID == processID + && window.role == representative.role + && window.subrole == representative.subrole + } + guard knownBackingWindows.count <= titles.count else { return nil } + var backingWindowIDs = Set(knownBackingWindows.map(\.id)) + for window in knownBackingWindows { + guard let count = remainingTitles[window.title], count > 0 else { + return nil + } + remainingTitles[window.title] = count - 1 + } + remainingTitles = remainingTitles.filter { $0.value > 0 } + + let eligibleCandidates = uniqueWindows.filter { window in + window.id != representativeID + && representatives.contains(window.id) == false + && claimedWindowIDs.contains(window.id) == false + && backingWindowIDs.contains(window.id) == false + && window.processID == processID + && window.role == representative.role + && window.subrole == representative.subrole + && remainingTitles[window.title] != nil + && ( + !requiresRetention + || retainedWindowIDs.contains(window.id) + || additionalBackingWindowIDsByRepresentative[representativeID]? + .contains(window.id) == true + ) + } + let nearbyCandidates = eligibleCandidates.filter { + frameDistance($0.frame, representative.frame) <= maximumFrameDistance + } + let nearbyTitles = Dictionary(grouping: nearbyCandidates, by: \.title) + .mapValues(\.count) + let eligibleTitles = Dictionary(grouping: eligibleCandidates, by: \.title) + .mapValues(\.count) + let candidates: [Window] + if nearbyTitles == remainingTitles { + candidates = nearbyCandidates + } else if eligibleTitles == remainingTitles { + candidates = eligibleCandidates + } else { + return nil + } + backingWindowIDs.formUnion(candidates.lazy.map(\.id)) + return backingWindowIDs.count == titles.count ? backingWindowIDs : nil + } + + let matches = interpretations.compactMap { + matchingBackingWindowIDs( + titles: $0.titles, + requiresRetention: $0.requiresRetention + ) + } + guard let backingWindowIDs = matches.first, + matches.dropFirst().allSatisfy({ $0 == backingWindowIDs }) + else { continue } + backingWindowIDsByRepresentative[representativeID] = backingWindowIDs + claimedWindowIDs.formUnion(backingWindowIDs) + } + + return backingWindowIDsByRepresentative +} + struct AXWindowAttributes: Sendable { let minimized: Bool? let frame: Rect? diff --git a/Sources/DefiRuntime/WindowReconciliation.swift b/Sources/DefiRuntime/WindowReconciliation.swift index 2082143..efdd50f 100644 --- a/Sources/DefiRuntime/WindowReconciliation.swift +++ b/Sources/DefiRuntime/WindowReconciliation.swift @@ -165,6 +165,7 @@ public func reconcileWindows( _ discovered: [Window], config: Config, placementPreferences: PlacementPreferences = PlacementPreferences(), + windowIDReplacements: [WindowID: WindowID] = [:], externallyChangedWindowIDs: Set = [], nativeFullscreenWindowIDs: Set = [], viewports: [MonitorID: Rect] = [:], @@ -174,6 +175,14 @@ public func reconcileWindows( ) -> Set { var relocatedTransientIDs = Set() let discoveredIDs = Set(discovered.map(\.id)) + applyWindowIDReplacements( + windowIDReplacements, + discoveredWindows: Dictionary( + discovered.map { ($0.id, $0) }, + uniquingKeysWith: { _, latest in latest } + ), + state: &state + ) let fullscreenSpaceHidesOtherWindows = !nativeFullscreenWindowIDs.isEmpty || !state.nativeFullscreenWindowIDs.isEmpty for existingID in Array(state.windows.keys) @@ -260,6 +269,109 @@ public func reconcileWindows( return relocatedTransientIDs } +private func applyWindowIDReplacements( + _ replacements: [WindowID: WindowID], + discoveredWindows: [WindowID: Window], + state: inout RuntimeState +) { + for (previousID, replacementID) in replacements.sorted(by: { + $0.key.rawValue < $1.key.rawValue + }) { + guard previousID != replacementID, + state.windows[replacementID] == nil, + var replacement = discoveredWindows[replacementID], + let previous = state.windows.removeValue(forKey: previousID) + else { continue } + + replacement.floating = previous.floating + replacement.floatingOrigin = previous.floatingOrigin + replacement.forceTiling = previous.forceTiling + replacement.intrinsicSize = previous.intrinsicSize + replacement.minimumTiledWidth = previous.minimumTiledWidth + if previous.intrinsicSize { + replacement.frame.width = previous.frame.width + replacement.frame.height = previous.frame.height + } + state.windows[replacementID] = replacement + + for monitorIndex in state.monitors.indices { + for workspaceIndex in state.monitors[monitorIndex].workspaces.indices { + for columnIndex in state.monitors[monitorIndex].workspaces[workspaceIndex] + .columns.indices + { + state.monitors[monitorIndex].workspaces[workspaceIndex].columns[columnIndex] + .windows = state.monitors[monitorIndex].workspaces[workspaceIndex] + .columns[columnIndex].windows.map { + $0 == previousID ? replacementID : $0 + } + } + state.monitors[monitorIndex].workspaces[workspaceIndex].floatingWindows = + state.monitors[monitorIndex].workspaces[workspaceIndex].floatingWindows.map { + $0 == previousID ? replacementID : $0 + } + } + } + for windowID in state.windows.keys + where state.windows[windowID]?.transientOwnerID == previousID { + state.windows[windowID]?.transientOwnerID = replacementID + } + replace(previousID, with: replacementID, in: &state.nativeFullscreenWindowIDs) + replace( + previousID, + with: replacementID, + in: &state.nativeFullscreenFloatingWindowIDs + ) + replace( + previousID, + with: replacementID, + in: &state.pendingNativeFullscreenWidthResetWindowIDs + ) + replace( + previousID, + with: replacementID, + in: &state.nativeFullscreenTiledPlacements + ) + replace( + previousID, + with: replacementID, + in: &state.suspendedTiledPlacements + ) + } +} + +private func replace( + _ previousID: WindowID, + with replacementID: WindowID, + in windowIDs: inout Set +) { + guard windowIDs.remove(previousID) != nil else { return } + windowIDs.insert(replacementID) +} + +private func replace( + _ previousID: WindowID, + with replacementID: WindowID, + in placements: inout [WindowID: SuspendedTiledPlacement] +) { + var replaced: [WindowID: SuspendedTiledPlacement] = [:] + replaced.reserveCapacity(placements.count) + for (windowID, placement) in placements { + var column = placement.column + column.windows = column.windows.map { + $0 == previousID ? replacementID : $0 + } + replaced[windowID == previousID ? replacementID : windowID] = + SuspendedTiledPlacement( + monitorID: placement.monitorID, + workspaceID: placement.workspaceID, + columnIndex: placement.columnIndex, + windowIndex: placement.windowIndex, + column: column + ) + } + placements = replaced +} + private func reconcileNativeFullscreenWindows( _ nextWindowIDs: Set, state: inout RuntimeState diff --git a/Tests/DefiMacOSTests/WindowDiscoveryTests.swift b/Tests/DefiMacOSTests/WindowDiscoveryTests.swift index 2405028..f83db14 100644 --- a/Tests/DefiMacOSTests/WindowDiscoveryTests.swift +++ b/Tests/DefiMacOSTests/WindowDiscoveryTests.swift @@ -771,3 +771,316 @@ struct WindowClassificationReviewFeedbackTests { ) } } + +struct NativeWindowTabDiscoveryTests { + private let processID: pid_t = 42 + private let frame = Rect(x: 100, y: 40, width: 1_200, height: 900) + + @Test + func `Previously managed AX windows claim their CG identity first`() { + let existing = WindowID(rawValue: 42) + let previousWindowIDs: [WindowID?] = [nil, existing, nil, existing] + + let order = previousWindowIDs.indices.sorted { lhs, rhs in + windowDiscoveryCandidateComesFirst( + lhsPreviousWindowID: previousWindowIDs[lhs], + lhsIndex: lhs, + rhsPreviousWindowID: previousWindowIDs[rhs], + rhsIndex: rhs + ) + } + + #expect(order == [1, 3, 0, 2]) + } + + @Test + func `Native tab backing windows collapse behind their AX representative`() { + let representative = window(id: 1, title: "Defi") + let firstTab = window( + id: 2, + title: "Defi", + frame: Rect(x: 100, y: 40, width: 1_202, height: 899) + ) + let secondTab = window( + id: 3, + title: "Defi", + frame: Rect(x: 100, y: 40, width: 1_202, height: 900) + ) + let standalone = window(id: 4, title: "Other") + + let backingWindowIDs = nativeTabBackingWindowIDsByRepresentative( + windows: [representative, firstTab, secondTab, standalone, firstTab], + groupsByRepresentativeID: [ + representative.id: NativeWindowTabGroup(tabTitles: ["Defi", "Defi"]) + ] + ) + + #expect(backingWindowIDs[representative.id] == [firstTab.id, secondTab.id]) + } + + @Test + func `Ambiguous native tab membership leaves windows untouched`() { + let representative = window(id: 1, title: "Defi") + let candidates = (2...4).map { window(id: UInt64($0), title: "Defi") } + + let backingWindowIDs = nativeTabBackingWindowIDsByRepresentative( + windows: [representative] + candidates, + groupsByRepresentativeID: [ + representative.id: NativeWindowTabGroup(tabTitles: ["Defi", "Defi"]) + ] + ) + + #expect(backingWindowIDs.isEmpty) + } + + @Test + func `Known native tab members survive temporary frame divergence`() { + let representative = window(id: 1, title: "Defi") + let firstTab = window( + id: 2, + title: "Defi", + frame: Rect(x: 2_000, y: 40, width: 1_200, height: 900) + ) + let secondTab = window( + id: 3, + title: "Defi", + frame: Rect(x: -2_000, y: 40, width: 1_200, height: 900) + ) + + let backingWindowIDs = nativeTabBackingWindowIDsByRepresentative( + windows: [representative, firstTab, secondTab], + groupsByRepresentativeID: [ + representative.id: NativeWindowTabGroup( + tabTitles: ["Defi", "Defi"], + backingWindowIDs: [firstTab.id, secondTab.id] + ) + ] + ) + + #expect(backingWindowIDs[representative.id] == [firstTab.id, secondTab.id]) + } + + @Test + func `Known direct native tab backing stays coalesced when rediscovered`() { + let representative = window(id: 1, title: "Defi") + let backing = window(id: 2, title: "Defi") + + let backingWindowIDs = nativeTabBackingWindowIDsByRepresentative( + windows: [representative, backing], + groupsByRepresentativeID: [ + representative.id: NativeWindowTabGroup( + tabTitles: ["Defi", "Defi"], + selectedTabTitle: "Defi", + backingWindowIDs: [backing.id] + ) + ] + ) + + #expect(backingWindowIDs[representative.id] == [backing.id]) + } + + @Test + func `Unique native tab titles recover membership after a visible restart`() { + let representative = window(id: 1, title: "Selected") + let firstTab = window( + id: 2, + title: "First", + frame: Rect(x: 2_000, y: 40, width: 1_200, height: 900) + ) + let secondTab = window( + id: 3, + title: "Second", + frame: Rect(x: -2_000, y: 40, width: 1_200, height: 900) + ) + + let backingWindowIDs = nativeTabBackingWindowIDsByRepresentative( + windows: [representative, firstTab, secondTab], + groupsByRepresentativeID: [ + representative.id: NativeWindowTabGroup( + tabTitles: ["First", "Second"] + ) + ] + ) + + #expect(backingWindowIDs[representative.id] == [firstTab.id, secondTab.id]) + } + + @Test + func `Native tab representative swaps identity with a known member`() { + let oldRepresentativeID = WindowID(rawValue: 1) + let firstMemberID = WindowID(rawValue: 2) + let secondMemberID = WindowID(rawValue: 3) + + let rebound = nativeWindowTabGroupRebindingKnownMembers( + NativeWindowTabGroup(tabTitles: ["First", "Second"]), + representativeID: firstMemberID, + processID: processID, + previousGroupsByRepresentativeID: [ + oldRepresentativeID: NativeWindowTabGroup( + tabTitles: ["First", "Second"], + backingWindowIDs: [firstMemberID, secondMemberID] + ) + ], + previousProcessIDs: [oldRepresentativeID: processID] + ) + + #expect(rebound.backingWindowIDs == [oldRepresentativeID, secondMemberID]) + + let directRebound = nativeWindowTabGroupRebindingKnownMembers( + NativeWindowTabGroup( + tabTitles: ["First", "Second"], + selectedTabTitle: "First" + ), + representativeID: firstMemberID, + processID: processID, + previousGroupsByRepresentativeID: [ + oldRepresentativeID: NativeWindowTabGroup( + tabTitles: ["First", "Second"], + selectedTabTitle: "Second", + backingWindowIDs: [firstMemberID] + ) + ], + previousProcessIDs: [oldRepresentativeID: processID] + ) + + #expect(directRebound.backingWindowIDs == [oldRepresentativeID]) + + let resizedGroup = nativeWindowTabGroupRebindingKnownMembers( + NativeWindowTabGroup(tabTitles: ["First", "Second", "Third"]), + representativeID: oldRepresentativeID, + processID: processID, + previousGroupsByRepresentativeID: [ + oldRepresentativeID: NativeWindowTabGroup( + tabTitles: ["First", "Second"], + backingWindowIDs: [firstMemberID, secondMemberID] + ) + ], + previousProcessIDs: [oldRepresentativeID: processID] + ) + + #expect(resizedGroup.backingWindowIDs == [firstMemberID, secondMemberID]) + } + + @Test + func `Growing native tab group absorbs its newly observed backing window`() { + let representative = window(id: 1, title: "Third") + let knownBacking = window(id: 2, title: "First") + let newBacking = window(id: 3, title: "Second") + + let backingWindowIDs = nativeTabBackingWindowIDsByRepresentative( + windows: [representative, knownBacking, newBacking], + groupsByRepresentativeID: [ + representative.id: NativeWindowTabGroup( + tabTitles: ["First", "Second", "Third"], + selectedTabTitle: "Third", + backingWindowIDs: [knownBacking.id] + ) + ], + retainedWindowIDs: [knownBacking.id], + additionalBackingWindowIDsByRepresentative: [ + representative.id: [newBacking.id] + ] + ) + + #expect( + backingWindowIDs[representative.id] == [knownBacking.id, newBacking.id] + ) + } + + @Test + func `New native tab representative replaces the previous logical window`() { + let previousRepresentativeID = WindowID(rawValue: 1) + let newRepresentativeID = WindowID(rawValue: 2) + + let replacements = nativeWindowTabRepresentativeReplacements( + previousWindowIDs: [previousRepresentativeID], + nextWindowIDs: [newRepresentativeID], + groupsByRepresentativeID: [ + newRepresentativeID: NativeWindowTabGroup( + tabTitles: ["First", "Second"], + backingWindowIDs: [previousRepresentativeID] + ) + ] + ) + + #expect(replacements == [previousRepresentativeID: newRepresentativeID]) + } + + @Test + func `Ambiguous native tab replacement does not change logical identity`() { + let replacements = nativeWindowTabRepresentativeReplacements( + previousWindowIDs: [WindowID(rawValue: 1), WindowID(rawValue: 2)], + nextWindowIDs: [WindowID(rawValue: 3)], + groupsByRepresentativeID: [ + WindowID(rawValue: 3): NativeWindowTabGroup( + tabTitles: ["First", "Second", "Third"], + backingWindowIDs: [WindowID(rawValue: 1), WindowID(rawValue: 2)] + ) + ] + ) + + #expect(replacements.isEmpty) + } + + @Test + func `Selected AppKit tab absorbs the retained previous window`() { + let representative = window(id: 1, title: "Recents") + let previousTab = window( + id: 2, + title: "Desktop", + frame: Rect(x: -2_000, y: 40, width: 1_200, height: 900) + ) + + let backingWindowIDs = nativeTabBackingWindowIDsByRepresentative( + windows: [representative, previousTab], + groupsByRepresentativeID: [ + representative.id: NativeWindowTabGroup( + tabTitles: ["Desktop", "Recents"], + selectedTabTitle: "Recents" + ) + ], + retainedWindowIDs: [previousTab.id] + ) + + #expect(backingWindowIDs[representative.id] == [previousTab.id]) + } + + @Test + func `Only a title bar tab group is treated as native window tabs`() { + #expect( + nativeTabGroupFrameIsInWindowChrome( + Rect(x: 100, y: 72, width: 1_200, height: 28), + windowFrame: frame + ) + ) + #expect( + nativeTabGroupFrameIsInWindowChrome( + Rect(x: 243, y: 92, width: 1_057, height: 28), + windowFrame: frame + ) + ) + #expect( + nativeTabGroupFrameIsInWindowChrome( + Rect(x: 140, y: 240, width: 600, height: 40), + windowFrame: frame + ) == false + ) + } + + private func window( + id: UInt64, + title: String, + frame: Rect? = nil + ) -> Window { + Window( + id: WindowID(rawValue: id), + appID: "com.mitchellh.ghostty", + title: title, + frame: frame ?? self.frame, + role: kAXWindowRole, + subrole: kAXStandardWindowSubrole, + processID: processID, + monitorID: MonitorID(rawValue: 1) + ) + } +} diff --git a/Tests/DefiRuntimeTests/WindowLifecycleTests.swift b/Tests/DefiRuntimeTests/WindowLifecycleTests.swift index e28b698..9791965 100644 --- a/Tests/DefiRuntimeTests/WindowLifecycleTests.swift +++ b/Tests/DefiRuntimeTests/WindowLifecycleTests.swift @@ -158,6 +158,45 @@ struct WindowLifecycleTests { #expect(state.monitors[0].workspaces[0].columns.isEmpty) } + @Test + func `Reconcile preserves layout when a native tab replaces the window ID`() throws { + let config = Config() + var state = RuntimeState(config: config) + state.attachMonitor(monitorID) + let original = Window( + id: WindowID(rawValue: 1), + appID: "terminal", + title: "First", + frame: Rect(x: 0, y: 0, width: 1_200, height: 800), + monitorID: monitorID + ) + try discoverWindow(original, decision: RuleDecision(), state: &state) + state.monitors[0].workspaces[0].columns[0].width = .fraction(0.8) + state.monitors[0].workspaces[0].targetScrollOffset = 240 + let replacement = Window( + id: WindowID(rawValue: 2), + appID: original.appID, + title: "Second", + frame: original.frame, + monitorID: monitorID + ) + + reconcileWindows( + [replacement, replacement], + config: config, + windowIDReplacements: [original.id: replacement.id], + state: &state + ) + + let workspace = state.monitors[0].workspaces[0] + #expect(workspace.columns.count == 1) + #expect(workspace.columns[0].windows == [replacement.id]) + #expect(workspace.columns[0].width == .fraction(0.8)) + #expect(workspace.targetScrollOffset == 240) + #expect(state.selectedWindowID(on: monitorID) == replacement.id) + #expect(state.windows[original.id] == nil) + } + @Test func `Reconcile preserves intrinsic dimensions after applied gaps`() throws { let config = Config() diff --git a/docs/research/omniwm-native-tabs.md b/docs/research/omniwm-native-tabs.md new file mode 100644 index 0000000..ce22a6d --- /dev/null +++ b/docs/research/omniwm-native-tabs.md @@ -0,0 +1,187 @@ +# Comment OmniWM traite les onglets natifs macOS + +Recherche effectuée le 25 août 2026 sur OmniWM `v0.6.3`, commit +[`33b748b04fc85412feefafdf2ce72f5a2154e585`](https://github.com/BarutSRB/OmniWM/tree/33b748b04fc85412feefafdf2ce72f5a2154e585). +Les constats ci-dessous viennent du dépôt officiel et de la documentation Apple. + +## Conclusion + +OmniWM ne cherche pas à reconstruire un groupe d'onglets à partir de +`AXTabGroup` et des titres. Il traite le changement d'onglet natif comme un +remplacement de l'incarnation physique d'une seule fenêtre logique. Quand un +nouveau WindowServer ID apparaît et qu'une fenêtre compatible du même processus +n'est plus visible, OmniWM remplace l'ancien token par le nouveau sans retirer +puis réinsérer le nœud de layout. + +C'est le point à reprendre dans Defi. Le premier correctif de cette branche +savait masquer des fenêtres physiques derrière un représentant, mais le +représentant restait identifié par son `WindowID`. Si Ghostty change ce WindowID +lors de l'ouverture ou de la sélection d'un onglet, le runtime pouvait encore +voir une suppression suivie d'une création et modifier la colonne. + +## Le modèle d'identité d'OmniWM + +OmniWM sépare trois identités : + +- `WindowToken` contient le PID et le WindowServer ID courant. +- `WindowHandle` est une référence stable. Son token peut changer sans changer + l'objet tenu par le layout. +- `AXWindowRef` pointe vers l'`AXUIElement` courant. + +La documentation d'architecture explique explicitement qu'un rekey repointe le +handle lorsque l'application détruit et recrée sa fenêtre +([`ARCHITECTURE.md`, lignes 286-312](https://github.com/BarutSRB/OmniWM/blob/33b748b04fc85412feefafdf2ce72f5a2154e585/docs/ARCHITECTURE.md#L286-L312)). +`WindowModel.rekeyWindow` remplace les index par token et WindowServer ID tout en +gardant le même `WindowHandle` +([`WindowModel.swift`, lignes 270-316](https://github.com/BarutSRB/OmniWM/blob/33b748b04fc85412feefafdf2ce72f5a2154e585/Sources/OmniWM/Core/Workspace/WindowModel.swift#L270-L316)). + +Le commit qui a introduit le cas Ghostty dit pourquoi : Ghostty remplace son ID +de fenêtre de premier niveau pendant les changements d'onglet. Un remove/add +déplaçait la colonne et le viewport, alors qu'un rekey en place conserve les +deux sans interruption +([commit `3a402cb2`](https://github.com/BarutSRB/OmniWM/commit/3a402cb26c0f9887f8d47782ce7ab2d3cc7a51a9)). + +## Comment le remplacement est reconnu + +`AXEventHandler.structuralReplacementMatch` examine les fenêtres déjà gérées +par le même PID. Un ancien token ne devient candidat que dans deux cas : + +- sa destruction attend déjà dans le burst courant ; +- une observation WindowServer faisant autorité ne le voit plus parmi les + fenêtres visibles. + +La fonction refuse le remplacement si plusieurs anciens tokens correspondent +([`AXEventHandler.swift`, lignes 3350-3479](https://github.com/BarutSRB/OmniWM/blob/33b748b04fc85412feefafdf2ce72f5a2154e585/Sources/OmniWM/Core/Controller/AXEventHandler.swift#L3350-L3479)). +Un test verrouille le cas négatif : une nouvelle fenêtre visible de la même +application reste une nouvelle colonne et n'est pas transformée en onglet +([`RuntimeArchitectureTests.swift`, lignes 4518-4601](https://github.com/BarutSRB/OmniWM/blob/33b748b04fc85412feefafdf2ce72f5a2154e585/Tests/OmniWMTests/RuntimeArchitectureTests.swift#L4518-L4601)). + +La corrélation compare des faits structurels : bundle, workspace, mode, +rôle, sous-rôle, niveau WindowServer, parent et frame. Les frames peuvent +différer de 96 points sur leur centre et de 64 points sur leur taille. Le titre +est conservé dans les métadonnées, mais ne participe pas au match +([`WindowModel.swift`, lignes 12-39](https://github.com/BarutSRB/OmniWM/blob/33b748b04fc85412feefafdf2ce72f5a2154e585/Sources/OmniWM/Core/Workspace/WindowModel.swift#L12-L39), +[`AXEventHandler.swift`, lignes 3481-3599](https://github.com/BarutSRB/OmniWM/blob/33b748b04fc85412feefafdf2ce72f5a2154e585/Sources/OmniWM/Core/Controller/AXEventHandler.swift#L3481-L3599)). +Le passage d'une allowlist Ghostty/navigateurs à cette corrélation générique est +documenté par le +[`commit d88231b1`](https://github.com/BarutSRB/OmniWM/commit/d88231b18180162107ef8c47df6a34ca0ae8ab22). + +OmniWM gère aussi les deux ordres d'événements. Les créations et destructions +sont regroupées pendant 150 ms par PID et workspace. Une paire unique et +compatible est appliquée immédiatement. Si le burst est ambigu ou ne correspond +pas, OmniWM rejoue les événements comme des créations et suppressions ordinaires +([`AXEventHandler.swift`, lignes 2978-3147](https://github.com/BarutSRB/OmniWM/blob/33b748b04fc85412feefafdf2ce72f5a2154e585/Sources/OmniWM/Core/Controller/AXEventHandler.swift#L2978-L3147), +[`AXEventHandler.swift`, lignes 3608-3691](https://github.com/BarutSRB/OmniWM/blob/33b748b04fc85412feefafdf2ce72f5a2154e585/Sources/OmniWM/Core/Controller/AXEventHandler.swift#L3608-L3691)). + +## Ce que le rekey préserve + +Le rekey n'est pas un simple renommage dans un dictionnaire. OmniWM : + +- fait confirmer le nouveau binding par le gestionnaire AX avant de valider le + changement ; +- repointe le monde et les moteurs Niri/Dwindle vers le nouveau token ; +- migre le focus, les intentions en attente, le scratchpad, les transactions de + reveal et l'état d'application des frames ; +- force la prochaine frame sur la nouvelle fenêtre physique. + +Les chemins exacts sont +[`rekeyManagedWindowIdentity`](https://github.com/BarutSRB/OmniWM/blob/33b748b04fc85412feefafdf2ce72f5a2154e585/Sources/OmniWM/Core/Controller/AXEventHandler%2BManagedWindowIdentity.swift#L10-L77), +[`commitManagedWindowIdentityRebind`](https://github.com/BarutSRB/OmniWM/blob/33b748b04fc85412feefafdf2ce72f5a2154e585/Sources/OmniWM/Core/Controller/AXEventHandler%2BManagedWindowIdentity.swift#L504-L549), +[`WorldStore`](https://github.com/BarutSRB/OmniWM/blob/33b748b04fc85412feefafdf2ce72f5a2154e585/Sources/OmniWM/Core/World/WorldStore.swift#L249-L268) et +[`AXManager`](https://github.com/BarutSRB/OmniWM/blob/33b748b04fc85412feefafdf2ce72f5a2154e585/Sources/OmniWM/Core/Ax/AXManager.swift#L645-L739). + +Le test le plus proche du bug Ghostty vérifie que l'ancien nœud garde son ID, +que le nombre et l'ordre des colonnes ne changent pas, qu'aucune animation de +scroll ne démarre et que le focus suit le nouveau token +([`RuntimeArchitectureTests.swift`, lignes 4715-4829](https://github.com/BarutSRB/OmniWM/blob/33b748b04fc85412feefafdf2ce72f5a2154e585/Tests/OmniWMTests/RuntimeArchitectureTests.swift#L4715-L4829)). + +Pendant qu'un rebind attend sa confirmation AX, un rescan conserve aussi le +token source au lieu d'admettre une deuxième fois la cible. Cette protection +évite une colonne vide et une double identité +([`WindowAdmissionIdentity.swift`, lignes 40-109](https://github.com/BarutSRB/OmniWM/blob/33b748b04fc85412feefafdf2ce72f5a2154e585/Sources/OmniWM/Core/Controller/WindowAdmissionIdentity.swift#L40-L109), +[`commit 5dd1f042`](https://github.com/BarutSRB/OmniWM/commit/5dd1f042cb1f10fc415d1f0a38714e310402f4e4)). + +## Ce qu'OmniWM ne fait pas + +Une recherche sur tout le source au commit étudié ne trouve aucun usage de +`kAXTabsAttribute`, `kAXTabGroupRole`, `NSWindowTabGroup` ou +`tabbingIdentifier`. Les "native tabs" d'OmniWM reposent donc sur le +remplacement structurel d'une fenêtre visible, pas sur un modèle du contenu de +la barre d'onglets. Les colonnes tabulées propres à OmniWM sont une autre +fonctionnalité. + +Apple décrit [`kAXTabsAttribute`](https://developer.apple.com/documentation/applicationservices/kaxtabsattribute) +comme la liste des objets d'accessibilité affichés par une vue d'onglets, et +[`kAXTabGroupRole`](https://developer.apple.com/documentation/applicationservices/kaxtabgrouprole) +comme une vue d'onglets. Ces API exposent le contrôle UI. Elles ne promettent pas +une correspondance stable entre un onglet, un `AXWindow` et un `CGWindowID`. + +Cette approche ne couvre pas encore toutes les applications. L'issue officielle +ouverte [#595](https://github.com/BarutSRB/OmniWM/issues/595) rapporte qu'OmniWM +0.6.2 pouvait encore traiter des onglets Finder comme des fenêtres séparées et +faire glisser le layout à leur fermeture. Le rekey structurel est donc le bon +modèle pour Ghostty, pas une preuve qu'OmniWM a résolu tous les onglets natifs. + +## Différence avec la branche Defi actuelle + +Defi part du contrôle UI. `nativeWindowTabGroup` cherche un enfant +`AXTabGroup`, lit `AXTabs`, les titres et l'onglet sélectionné +([`SnapshotEngine.swift`, lignes 985-1024](../../Sources/DefiMacOS/SnapshotEngine.swift#L985-L1024)). +`nativeTabBackingWindowIDsByRepresentative` associe ensuite les fenêtres +physiques par PID, rôle, sous-rôle, multiensemble de titres et proximité des +frames, puis conserve les appartenances connues +([`WindowDiscoverySupport.swift`, lignes 109-215](../../Sources/DefiMacOS/WindowDiscoverySupport.swift#L109-L215)). +La découverte retire enfin ces backings du snapshot +([`MacOSPlatform+WindowSnapshotDiscovery.swift`, lignes 541-575](../../Sources/DefiMacOS/MacOSPlatform%2BWindowSnapshotDiscovery.swift#L541-L575)). + +Cette logique répond à la question "quelles fenêtres physiques appartiennent +au groupe ?". OmniWM répond d'abord à une autre question : "le nouveau +WindowServer ID est-il la nouvelle incarnation de la fenêtre logique déjà dans +la colonne ?". C'est cette seconde réponse qui empêche le saut de colonne. + +La branche ajoute maintenant `nativeWindowTabRepresentativeReplacements`. Le +helper forme une paire unique entre un ancien représentant disparu et le nouveau +représentant qui le contient parmi ses backings +([`WindowDiscoverySupport.swift`, lignes 109-132](../../Sources/DefiMacOS/WindowDiscoverySupport.swift#L109-L132)). +`DesktopSnapshot.windowIDReplacements` transporte cette paire jusqu'à +`reconcileWindows`, qui migre l'entrée runtime avant la phase remove/add +([`WindowReconciliation.swift`, lignes 163-260](../../Sources/DefiRuntime/WindowReconciliation.swift#L163-L260)). + +Les tests couvrent maintenant le match unique et ambigu dans la découverte, +puis la conservation de la colonne, de sa largeur, du scroll et du focus dans le +runtime. Il reste utile d'avoir un test d'intégration du cycle complet Ghostty, +notamment quand un rescan intervient pendant le remplacement +([`WindowDiscoveryTests.swift`, lignes 964-997](../../Tests/DefiMacOSTests/WindowDiscoveryTests.swift#L964-L997), +[`WindowLifecycleTests.swift`, lignes 161-199](../../Tests/DefiRuntimeTests/WindowLifecycleTests.swift#L161-L199)). + +## Recommandation pour Defi + +La propagation `windowIDReplacements` suit le bon modèle. Pour garder les +garanties qu'OmniWM a dû ajouter au fil de plusieurs correctifs : + +1. comparer le snapshot précédent au nouveau et former au plus une paire + `ancien WindowID -> nouveau WindowID` par processus quand l'ancien n'est plus + visible et que PID, rôle, sous-rôle, niveau et frame correspondent ; +2. refuser les matchs ambigus et traiter alors les fenêtres normalement ; +3. migrer l'entrée runtime, sa place dans la colonne, le focus, les largeurs, les + targets et les travaux de frame avant la phase remove/add ; +4. protéger cette paire pendant les rescans jusqu'à la fin du rebind ; +5. garder `AXTabGroup` comme preuve du groupe ou comme moyen d'exclure les + backings inactifs, mais ne plus utiliser les titres comme fondation de + l'identité. + +Defi dispose déjà de l'inventaire Quartz public et de `isOnscreen` +([`Platform.swift`, lignes 9-57](../../Sources/DefiMacOS/Platform.swift#L9-L57)). +Il faut commencer avec cette source et les snapshots existants. OmniWM obtient +son ensemble visible via `SkyLight.queryAllVisibleWindows`, une API privée qui +filtre aussi les tags et attributs WindowServer +([`SkyLight.swift`, lignes 898-943](https://github.com/BarutSRB/OmniWM/blob/33b748b04fc85412feefafdf2ce72f5a2154e585/Sources/OmniWM/Core/SkyLight/SkyLight.swift#L898-L943)). +Son README assume cet usage général des API privées +([`README.md`, lignes 422-428](https://github.com/BarutSRB/OmniWM/blob/33b748b04fc85412feefafdf2ce72f5a2154e585/README.md#L422-L428)). +La politique de Defi impose au contraire de démontrer l'insuffisance des API +publiques avant d'introduire un backend privé. + +Enfin, OmniWM est sous +[`GPL-2.0-only`](https://github.com/BarutSRB/OmniWM/blob/33b748b04fc85412feefafdf2ce72f5a2154e585/LICENSE), +alors que Defi est sous MIT. Il faut reprendre le modèle et réimplémenter le +comportement, sans copier son code. From 488e9267f7b8c2faa7f03897d0aef417b0d6726c Mon Sep 17 00:00:00 2001 From: Quentin Eude Date: Tue, 25 Aug 2026 22:48:23 +0200 Subject: [PATCH 2/2] fix: rebind pending focus across native tabs --- .../DaemonDesktopSynchronization.swift | 1 + .../DaemonPointerFocusRecovery.swift | 93 +++++++++++++++++++ .../DaemonPointerFocusRecoveryTests.swift | 43 +++++++++ 3 files changed, 137 insertions(+) create mode 100644 Tests/DefiDaemonTests/DaemonPointerFocusRecoveryTests.swift diff --git a/Sources/DefiDaemon/DaemonDesktopSynchronization.swift b/Sources/DefiDaemon/DaemonDesktopSynchronization.swift index d6782b0..3a46992 100644 --- a/Sources/DefiDaemon/DaemonDesktopSynchronization.swift +++ b/Sources/DefiDaemon/DaemonDesktopSynchronization.swift @@ -184,6 +184,7 @@ extension Daemon { snapshot.nativeFullscreenWindowIDs, activeWindowIDs: snapshot.activeNativeFullscreenWindowIDs ) + rebindFocusRequests(using: snapshot.windowIDReplacements) if pendingAnimatedFocus.map({ enteringNativeFullscreenWindowIDs.contains($0.windowID) }) == true { diff --git a/Sources/DefiDaemon/DaemonPointerFocusRecovery.swift b/Sources/DefiDaemon/DaemonPointerFocusRecovery.swift index 12df86f..accdd9f 100644 --- a/Sources/DefiDaemon/DaemonPointerFocusRecovery.swift +++ b/Sources/DefiDaemon/DaemonPointerFocusRecovery.swift @@ -3,8 +3,101 @@ import DefiModel import DefiRuntime import Foundation +func reboundPendingAnimatedFocus( + _ request: PendingAnimatedFocus, + using replacements: [WindowID: WindowID] +) -> PendingAnimatedFocus { + PendingAnimatedFocus( + windowID: replacements[request.windowID] ?? request.windowID, + previousSelectedWindowID: request.previousSelectedWindowID.map { + replacements[$0] ?? $0 + }, + monitorID: request.monitorID, + sourceWorkspaceID: request.sourceWorkspaceID, + commandGeneration: request.commandGeneration, + focusInputTimestamp: request.focusInputTimestamp, + cursorWarpInputTimestamp: request.cursorWarpInputTimestamp, + retryCount: request.retryCount + ) +} + +func reboundPendingWorkspaceFocus( + _ request: PendingWorkspaceFocus, + using replacements: [WindowID: WindowID] +) -> PendingWorkspaceFocus { + PendingWorkspaceFocus( + monitorID: request.monitorID, + requestedWorkspaceID: request.requestedWorkspaceID, + previousWorkspaceID: request.previousWorkspaceID, + requestedWindowID: + replacements[request.requestedWindowID] ?? request.requestedWindowID, + restoresPreviousWorkspaceOnCancellation: + request.restoresPreviousWorkspaceOnCancellation, + commandGeneration: request.commandGeneration, + focusInputTimestamp: request.focusInputTimestamp, + cursorWarpInputTimestamp: request.cursorWarpInputTimestamp, + retryCount: request.retryCount + ) +} + +func reboundDisplacedPointerFocusRecovery( + _ recovery: DisplacedPointerFocusRecovery, + using replacements: [WindowID: WindowID] +) -> DisplacedPointerFocusRecovery { + switch recovery { + case .command(let request, let timestamp): + .command( + reboundPendingAnimatedFocus(request, using: replacements), + timestamp: timestamp + ) + case .workspace(let request, let timestamp): + .workspace( + reboundPendingWorkspaceFocus(request, using: replacements), + timestamp: timestamp + ) + } +} + @MainActor extension Daemon { + func rebindFocusRequests(using replacements: [WindowID: WindowID]) { + guard replacements.isEmpty == false else { return } + + if let submittedCommandFocus { + let rebound = reboundPendingAnimatedFocus( + submittedCommandFocus, + using: replacements + ) + if rebound != submittedCommandFocus { + invalidateSubmittedCommandFocus(recoveringTo: rebound.windowID) + pendingAnimatedFocus = rebound + } + } + pendingAnimatedFocus = pendingAnimatedFocus.map { + reboundPendingAnimatedFocus($0, using: replacements) + } + + if let pendingWorkspaceFocus { + let rebound = reboundPendingWorkspaceFocus( + pendingWorkspaceFocus, + using: replacements + ) + if rebound != pendingWorkspaceFocus, + submittedWorkspaceFocusGeneration != nil + || submittedWorkspaceFocusRequestID != nil + { + invalidateSubmittedWorkspaceFocus( + recoveringTo: rebound.requestedWindowID + ) + } + self.pendingWorkspaceFocus = rebound + } + + displacedPointerFocusRecovery = displacedPointerFocusRecovery.map { + reboundDisplacedPointerFocusRecovery($0, using: replacements) + } + } + func requeueDisplacedPointerFocusAfterDisplayChange( _ recovery: DisplacedPointerFocusRecovery ) { diff --git a/Tests/DefiDaemonTests/DaemonPointerFocusRecoveryTests.swift b/Tests/DefiDaemonTests/DaemonPointerFocusRecoveryTests.swift new file mode 100644 index 0000000..e698e50 --- /dev/null +++ b/Tests/DefiDaemonTests/DaemonPointerFocusRecoveryTests.swift @@ -0,0 +1,43 @@ +import DefiModel +import Testing + +@testable import DefiDaemon + +struct DaemonPointerFocusRecoveryTests { + @Test( + "Native tab replacement keeps displaced command-focus recovery current", + .bug("https://github.com/qeude/Defi/pull/40#discussion_r3857044752") + ) + func nativeTabReplacementRebindsDisplacedCommandFocusRecovery() { + let previousID = WindowID(rawValue: 10) + let replacementID = WindowID(rawValue: 11) + let previousSelectionID = WindowID(rawValue: 20) + let replacementSelectionID = WindowID(rawValue: 21) + let request = PendingAnimatedFocus( + windowID: previousID, + previousSelectedWindowID: previousSelectionID, + monitorID: MonitorID(rawValue: 1), + sourceWorkspaceID: WorkspaceID(rawValue: "dev"), + commandGeneration: 2, + focusInputTimestamp: 3, + cursorWarpInputTimestamp: nil + ) + + let recovery = reboundDisplacedPointerFocusRecovery( + .command(request, timestamp: 4), + using: [ + previousID: replacementID, + previousSelectionID: replacementSelectionID, + ] + ) + + guard case .command(let reboundRequest, _) = recovery else { + Issue.record("Expected command-focus recovery") + return + } + #expect(reboundRequest.windowID == replacementID) + #expect( + reboundRequest.previousSelectedWindowID == replacementSelectionID + ) + } +}