feat: better recognizes tabbed windows

This commit is contained in:
lwouis committed 2026-03-28 11:57:48 +01:00
1 parent ff1e92303a
commit 170bd673e0
8 files changed
+253 -93

No files matched your search

+4
View File
@@ -258,6 +258,7 @@
D04BA6CC689662DECB2BE55E /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BAC0253EAB4D509C4813F /* InfoPlist.strings */; };
D04BA72B336C7175871B281C /* general@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = D04BAF6E2AB002763931645A /* general@2x.png */; };
D04BA737008AA2CD4E230A21 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA10777505D8A67ABD186 /* Application.swift */; };
BB0000000000000000000002 /* TabGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0000000000000000000001 /* TabGroup.swift */; };
D04BA73E90EFEF8247A5105D /* CGWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAC34CFD42A7F6F1F01C0 /* CGWindow.swift */; };
D04BA76A74267B1346D23687 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA6D57A1456C07318B8EA /* GridView.swift */; };
D04BA7B8D599E1A7A27FF5AE /* AcknowledgmentsTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA788228BA86D9EFBD1ED /* AcknowledgmentsTab.swift */; };
@@ -606,6 +607,7 @@
D04BA0E1C5DBC07108AC2F54 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
D04BA0F5EB832B8E142B654B /* 4 windows - 1 line.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "4 windows - 1 line.jpg"; sourceTree = "<group>"; };
D04BA10777505D8A67ABD186 /* Application.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = "<group>"; };
BB0000000000000000000001 /* TabGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabGroup.swift; sourceTree = "<group>"; };
D04BA107C8B8FE7FF8536606 /* too many windows - 4 lines - paginated.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "too many windows - 4 lines - paginated.jpg"; sourceTree = "<group>"; };
D04BA1232AFEEFE90D5CC827 /* debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = debug.xcconfig; sourceTree = "<group>"; };
D04BA18724DC58FA95DB33DB /* Podfile.lock */ = {isa = PBXFileReference; lastKnownFileType = file.lock; path = Podfile.lock; sourceTree = "<group>"; };
@@ -1418,6 +1420,7 @@
D04BA015A45DE7AFDC9794FE /* Window.swift */,
D04BA10777505D8A67ABD186 /* Application.swift */,
D04BA282BB16C1554595A968 /* Applications.swift */,
BB0000000000000000000001 /* TabGroup.swift */,
D04BAB74451B79FE18B8BEDF /* BackgroundWork.swift */,
D04BACABD048E62EBE4576CC /* DebugProfile.swift */,
D04BAC8857A527C2E15D6598 /* events */,
@@ -2561,6 +2564,7 @@
5F21C9E82C6E96A00091F72F /* SheetWindow.swift in Sources */,
D04BA737008AA2CD4E230A21 /* Application.swift in Sources */,
D04BA2A6FF9DDDC5A1A68E36 /* Applications.swift in Sources */,
BB0000000000000000000002 /* TabGroup.swift in Sources */,
AA974BA929B7D84C0099A29E /* PreviewPanel.swift in Sources */,
D04BA3CF766857381519B892 /* BackgroundWork.swift in Sources */,
D04BA48B00B4211A465C7337 /* DebugProfile.swift in Sources */,
+18
View File
@@ -241,6 +241,24 @@ extension AXUIElement {
func performAction(_ action: String) throws {
try throwIfNotSuccess(AXUIElementPerformAction(self, action as CFString))
}
/// Query the window's AXTabGroup child to detect OS-level tabs.
/// Returns tab titles if the window has tabs (always ≥ 2), nil otherwise.
/// `children` should come from the prior `.attributes([..., kAXChildrenAttribute])` call.
static func tabGroupInfo(_ children: [AXUIElement]?) -> [String]? {
guard let children else { return nil }
for child in children {
let a = try? child.attributes([kAXRoleAttribute, kAXChildrenAttribute])
guard a?.role == "AXTabGroup", let tabChildren = a?.children else { continue }
let titles = tabChildren.compactMap { tab -> String? in
let t = try? tab.attributes([kAXSubroleAttribute, kAXTitleAttribute])
guard t?.subrole == "AXTabButton" else { return nil }
return t?.title ?? ""
}
return titles.count >= 2 ? titles : nil
}
return nil
}
}
/// tests have shown that this ID has a range going from 0 to probably UInt.MAX
+102 -47
View File
@@ -1,47 +1,102 @@
import Foundation
import ScreenCaptureKit
if #available(macOS 13.1, *) {
SCShareableContent.getExcludingDesktopWindows(true, onScreenWindowsOnly: false) { content, error in
let normal = content!.windows.first { $0.windowID == 9345 }!
let tabbed = content!.windows.first { $0.windowID == 9867 }!
let hidden = content!.windows.first { $0.windowID == 10690 }!
Logger.error("normal", normal.title, normal.isActive, normal.isOnScreen, normal.windowLayer, normal.frame)
Logger.error("tabbed", tabbed.title, tabbed.isActive, tabbed.isOnScreen, tabbed.windowLayer, tabbed.frame)
Logger.error("hidden", hidden.title, hidden.isActive, hidden.isOnScreen, hidden.windowLayer, hidden.frame)
Logger.error("SCShareableContent", content!.windows.sorted { $0.windowID < $1.windowID }.map { $0.windowID })
}
}
let windows = CGWindowListCopyWindowInfo([.excludeDesktopElements, .optionOnScreenOnly], kCGNullWindowID) as! [CGWindow]
Logger.error("CGWindowListCopyWindowInfo extra", windows.sorted { $0.id()! < $1.id()! }.map { ($0.id()!, $0.title()) })
Logger.error("CGWindowListCopyWindowInfo", windows.sorted { $0.id()! < $1.id()! }.map { $0.id()! })
Spaces.refresh()
let spaceIdsAndIndexes = Spaces.idsAndIndexes.map { $0.0 }
let cgsWindowIds = Spaces.windowsInSpaces(spaceIdsAndIndexes)
let visibleCgsWindowIds = Spaces.windowsInSpaces(spaceIdsAndIndexes, false)
Logger.error("CGSCopyWindowsWithOptionsAndTags include invisible", cgsWindowIds.sorted { $0 < $1 })
Logger.error("CGSCopyWindowsWithOptionsAndTags exclude invisible", visibleCgsWindowIds.sorted { $0 < $1 })
// CGWindowListCopyWindowInfo extra [(28, "Clock"), (29, "Sound"), (30, "BentoBox-0"), (31, "Item-0"), (42, "Item-0"), (49, "Item-1"), (57, "Item-0"), (59, "raycastIcon"), (79, "Item-0"), (87, "Item-0"), (125, "com.bjango.istatmenus.memory"), (126, "com.bjango.istatmenus.battery"), (127, "com.bjango.istatmenus.cpu"), (128, "com.bjango.istatmenus.disks"), (129, "com.bjango.istatmenus.network"), (130, "com.bjango.istatmenus.sensors"), (131, "com.bjango.istatmenus.time"), , (9297, "BetterTouchTool"), (9325, ""), (9345, "alt-tab-macos [~/git/alt-tab-macos] – /Users/lwouis/git/alt-tab-macos/src/ui/App.swift"), (11123, "Item-0"), (11160, "Menubar"), (11281, "StatusIndicator")]
// SCShareableContent extra [(2, Optional("Display 1 Backstop")), (3, Optional("Display 2 Backstop")), (5, Optional("Cursor")), (16, Optional("Dock")), (19, Optional("")), (20, Optional("Notification Center")), (21, Optional("Accessibility")), (23, Optional("")), (24, Optional("")), (25, Optional("")), (26, Optional("")), (27, Optional("")), (28, Optional("Clock")), (29, Optional("Sound")), (30, Optional("BentoBox-0")), (31, Optional("Item-0")), (37, Optional("")), (42, Optional("Item-0")), (44, Optional("Spotlight")), (45, Optional("Spotlight")), (49, Optional("Item-1")), (56, Optional("")), (57, Optional("Item-0")), (58, Optional("")), (59, Optional("raycastIcon")), (61, Optional("")), (64, Optional("")), (72, Optional("")), (76, Optional("Logger.error(\"CGWindow…itle() ?? \"nil\") })... (2 lines)")), (79, Optional("Item-0")), (85, Optional("Handy")), (86, Optional("Recording")), (87, Optional("Item-0")), (95, Optional("")), (96, Optional("")), (97, Optional("")), (98, Optional("")), (99, Optional("")), (100, Optional("")), (101, Optional("")), (102, Optional("")), (103, Optional("")), (104, Optional("")), (105, Optional("")), (106, Optional("")), (107, Optional("")), (108, Optional("")), (109, Optional("")), (110, Optional("")), (111, Optional("")), (112, Optional("")), (113, Optional("")), (114, Optional("")), (115, Optional("")), (116, Optional("")), (117, Optional("")), (118, Optional("")), (119, Optional("")), (121, Optional("")), (122, Optional("")), (123, Optional("")), (124, Optional("")), (125, Optional("com.bjango.istatmenus.memory")), (126, Optional("com.bjango.istatmenus.battery")), (127, Optional("com.bjango.istatmenus.cpu")), (128, Optional("com.bjango.istatmenus.disks")), (129, Optional("com.bjango.istatmenus.network")), (130, Optional("com.bjango.istatmenus.sensors")), (131, Optional("com.bjango.istatmenus.time")), (132, Optional("")), (140, Optional("")), (202, Optional("(25811) Subscriptions - YouTube")), (203, Optional("")), (204, Optional("")), ), (206, Optional("")), (207, Optional("")), (215, Optional("")), (289, Optional("")), (334, Optional("")), (875, Optional("")), (1713, Optional("Display 2 Backstop")), (2148, Optional("")), (2150, Optional("")), (3302, Optional("")), (4384, Optional("")), (6249, Optional("Display 2 Backstop")), (9296, Optional("Colors")), (9297, Optional("BetterTouchTool")), (9309, Optional("")), (9310, Optional("")), (9311, Optional("")), (9312, Optional("")), (9313, Optional("Display 2 Backstop")), (9314, Optional("")), (9315, Optional("")), (9316, Optional("")), (9317, Optional("")), (9318, Optional("")), (9319, Optional("")), (9320, Optional("")), (9321, Optional("")), (9322, Optional("")), (9324, Optional("")), (9325, Optional("")), (9328, Optional("Wallpaper-6D2AE1E5-4293-4F35-A980-0E81D9E360BF")), (9329, Optional("")), (9331, Optional("")), (9339, Optional("")), (9340, Optional("")), (9341, Optional("")), (9342, Optional("")), (9343, Optional("")), , (9346, Optional("")), (9450, Optional("")), (9451, Optional("")), (9452, Optional("")), (9453, Optional("")), (9454, Optional("")), , (9456, Optional("")), , (9875, Optional("")), (9972, Optional("Wallpaper-9FA063D2-E6B4-4569-82CC-3C345CB5BEAD")), (9978, Optional("Offscreen Wallpaper Window")), (9995, Optional("Menubar")), (9996, Optional("underbelly")), , (10021, Optional("")), (10022, Optional("")), (10025, Optional("")), (10026, Optional("Wallpaper-1448183E-49F8-4B98-90FA-4D983B169ECD")), (10027, Optional("Fullscreen Backdrop")), (10028, Optional("")), (10032, Optional("")), (10033, Optional("")), (10034, Optional("")), (10035, Optional("com.lwouis.menubar-webview")), (10036, Optional("com.lwouis.webcam-zoom")), (10037, Optional("com.naotanhaocan.BetterMouse")), (10038, Optional("pro.betterdisplay.BetterDisplay")), (10039, Optional("com.raycast.macos")), (10040, Optional("io.github.hluk.CopyQ")), (10041, Optional("com.pais.handy")), (10042, Optional("com.bjango.istatmenus.status")), (10043, Optional("com.bjango.istatmenus.status")), (10044, Optional("com.bjango.istatmenus.status")), (10045, Optional("com.bjango.istatmenus.status")), (10046, Optional("com.bjango.istatmenus.status")), (10047, Optional("com.bjango.istatmenus.status")), (10048, Optional("com.bjango.istatmenus.status")), (10049, Optional("com.hegenberg.BetterTouchTool")), (10246, Optional("Menubar")), (10247, Optional("underbelly")), (10417, nil), (10419, Optional("Gesture Blocking Overlay")), (10443, Optional("Gesture Blocking Overlay")), (10469, nil), (10572, Optional("Keyboard Input")), , (10694, Optional("")), (10952, Optional("")), (11036, Optional("")), (11119, Optional("")), (11120, Optional("AltTab needs some permissions")), (11121, Optional("")), (11122, Optional("")), (11123, Optional("Item-0")), (11124, Optional("com.lwouis.alt-tab-macos")), (11125, Optional("General")), (11126, Optional("")), (11127, Optional("")), (11128, Optional("")), (11129, Optional("")), (11130, Optional("Send feedback")), (11160, Optional("Menubar")), (11Line truncated
// 1. CGWindowListCopyWindowInfo [28, 29, 30, 31, 42, 49, 57, 59, 79, 87, 125, 126, 127, 128, 129, 130, 131, 202, 9297, 9325, 9345, 11123, 11160, 11281]
// 2. CGSCopyWindowsWithOptionsAndTags include invisible [21, 28, 29, 30, 31, 42, 45, 49, 57, 59, 76, 79, 85, 86, 87, 125, 126, 127, 128, 129, 130, 131, 202, 203, 204, 205, 289, 334, 3302, 9297, 9325, 9328, 9345, 9455, 9972, 9978, 9995, 9996, 10020, 10021, 10022, 10025, 10026, 10027, 10028, 10246, 10247, 10419, 10443, 10690, 10694, 11121, 11123, 11160, 11161, 11246]
// 3. CGSCopyWindowsWithOptionsAndTags exclude invisible [28, 29, 30, 31, 42, 49, 57, 59, 79, 87, 125, 126, 127, 128, 129, 130, 131, 202, 9297, 9325, 9328, 9345, 9455, 9972, 9978, 9995, 10020, 10021, 10022, 10025, 10026, 10027, 11123, 11160]
// 4. SCShareableContent.getWithCompletionHandler [2, 3, 5, 16, 19, 20, 21, 23, 24, 25, 26, 27, 28, 29, 30, 31, 37, 42, 44, 45, 49, 56, 57, 58, 59, 61, 64, 72, 76, 79, 85, 86, 87, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 140, 202, 203, 204, 205, 206, 207, 215, 289, 334, 875, 1713, 2148, 2150, 3302, 4384, 6249, 9296, 9297, 9309, 9310, 9311, 9312, 9313, 9314, 9315, 9316, 9317, 9318, 9319, 9320, 9321, 9322, 9324, 9325, 9328, 9329, 9331, 9339, 9340, 9341, 9342, 9343, 9345, 9346, 9450, 9451, 9452, 9453, 9454, 9455, 9456, 9867, 9875, 9972, 9978, 9995, 9996, 10020, 10021, 10022, 10025, 10026, 10027, 10028, 10032, 10033, 10034, 10035, 10036, 10037, 10038, 10039, 10040, 10041, 10042, 10043, 10044, 10045, 10046, 10047, 10048, 10049, 10246, 10247, 10417, 10419, 10443, 10469, 10572, 10690, 10694, 10952, 11036, 11119, 11120, 11121, 11122, 11123, 11124, 11125, 11126, 11127, 11128, 11129, 11130, 11160, 11161, 11162, 11164, 11204, 11246, 11247, 11280, 11281]
// 5. SCShareableContent.getExcludingDesktopWindows(true, onScreenWindowsOnly: false) [5, 16, 20, 21, 23, 24, 25, 26, 27, 28, 29, 30, 31, 37, 42, 44, 45, 49, 56, 57, 58, 59, 61, 64, 72, 76, 79, 85, 86, 87, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 140, 202, 203, 204, 205, 206, 207, 215, 289, 334, 875, 2148, 2150, 3302, 4384, 9296, 9297, 9309, 9310, 9311, 9312, 9314, 9315, 9316, 9317, 9318, 9319, 9320, 9321, 9322, 9324, 9325, 9329, 9331, 9339, 9340, 9341, 9342, 9343, 9345, 9346, 9450, 9451, 9452, 9453, 9454, 9455, 9456, 9867, 9875, 9995, 10020, 10021, 10022, 10025, 10028, 10032, 10033, 10034, 10035, 10036, 10037, 10038, 10039, 10040, 10041, 10042, 10043, 10044, 10045, 10046, 10047, 10048, 10049, 10246, 10419, 10443, 10469, 10690, 10694, 10952, 11119, 11120, 11121, 11122, 11123, 11124, 11125, 11126, 11127, 11128, 11129, 11130, 11160, 11162, 11164, 11204, 11246, 11247, 11334, 11336, 11348]
// (202, Optional("(25811) Subscriptions - YouTube") | normal | 1,2,3,4
// (9345, Optional("alt-tab-macos [~/git/alt-tab-macos] – /Users/lwouis/git/alt-tab-macos/src/ui/App.swift")) | normal | 1,2,3,4
// (10690, Optional("Movies")) | hidden | 2,4
// (9867, Optional("lwouis")) | hidden + tabbed | 4
// (9455, Optional("~/git/alt-tab-macos/DerivedData/Build/Products/Release")) | another space | 2,3,4
// (10020, Optional("(25811) Ryuichi Sakamoto: Playing the Piano for the Isolated - YouTube 🔊")) | fullscreen | 2,3,4
// (205, Optional("(9+) K8s Migration Eng Test Day - Objectives & Guidelines") | minimized | 2,4
// Conclusions:
// * CGWindowListCopyWindowInfo returns the least windows
// * SCShareableContent returns the most windows
// * In order to detect tabs, we need some difference with non-tabbed windows. We need contrast.
// Today we make 2 calls to CGSCopyWindowsWithOptionsAndTags, with different options, and compare results.
// Tabbed Window Detection — Investigation Results
// =================================================
//
// macOS provides no public API to detect whether a window is an inactive OS tab.
// This file documents all approaches tested and their results.
//
// Test setup: Finder with 2 tabbed windows ("lwouis" inactive tab, "git" active tab)
// and 1 non-tabbed window ("Movies").
//
// MARK: - Approaches that DON'T work
//
// 1. CGWindowListCopyWindowInfo
// Returns the fewest windows. Inactive tabs don't appear at all.
// Can't distinguish "absent because tabbed" from "absent for other reasons."
//
// 2. CGSCopyWindowsWithOptionsAndTags (current heuristic)
// Comparing include-invisible vs exclude-invisible lists: inactive tabs appear
// in the include-invisible list but not the exclude-invisible list. However, so
// do windows from apps like Teams/WeChat that hide windows without destroying
// them. This is the source of false positives in the current detection.
//
// 3. SCShareableContent (macOS 13+)
// Returns the most windows (including inactive tabs). However isActive=false and
// isOnScreen=false for inactive tabs — same as for hidden, minimized, and
// other-space windows. No distinguishing signal.
//
// 4. SLSWindowIterator (parentId, tags, attributes, attachedWindows, spaceAttributes)
// - parentId: returns 0 for all tested windows (tabbed and non-tabbed)
// - attachedCount/attachedWindows: always 0/empty for tabbed windows
// - spaceAttributes/spaceTypeMask: no difference between tabbed and non-tabbed
// - tags (UInt from iterator): different patterns exist between visible and invisible
// windows, but the same pattern applies to both inactive tabs AND other invisible
// windows (hidden apps, other-space windows). No tab-specific bit.
//
// 5. SLSGetWindowTags (64-bit tags)
// Compared binary tag patterns across window states:
// Active tab (git): 1100000000000000000000000100000000010010000010000000000001
// Non-tabbed (Movies): 1100000000000000000000000100000000010010000010000000000001
// Inactive tab (lwouis): 1100000000000000000000000100000000010010000000000000000001
// The inactive tab differs in a few bits, but the same pattern appears for hidden
// and other-space windows. Not a reliable tab-specific signal.
//
// 6. SLSGetWindowSubLevel
// Returns 0 for all normal layer-0 windows regardless of tab state.
//
// 7. CGSCopyWindowGroup (movementGroup, orderingGroup, tabGroup, tabbingGroup)
// - movementGroup: empty for tabbed windows
// - orderingGroup: empty for tabbed windows
// - tabGroup: empty (speculative key, doesn't exist)
// - tabbingGroup: empty (speculative key, doesn't exist)
//
// 8. CGSCopyWindowProperty (broad key sweep)
// Tested ~40 speculative keys including kCGSWindowTabGroupID, kCGSWindowIsTab,
// kCGSWindowTabParent, kCGSWindowTabIndex, kCGSWindowTabbingIdentifier,
// kCGSWindowContainerID, TabGroupID, IsTab, etc.
// Only kCGSWindowTitle returned values. No tab-related properties exist.
//
// 9. SLSCopyAssociatedWindows
// Returns only the queried window itself. No sibling information.
//
// 10. Frame-based clustering (CGWindowListCopyWindowInfo frame comparison)
// Grouping same-pid windows by identical frame: the active tab's frame is known,
// but inactive tabs have a DIFFERENT frame (their last known position before being
// tabbed). Only works if tabs were at the same position before merging, which is
// not guaranteed. Unreliable.
//
// MARK: - Approach that WORKS: AXTabGroup (Accessibility API)
//
// Querying kAXChildrenAttribute on a window's AXUIElement reveals an AXTabGroup child
// element when the window has OS-level tabs. The AXTabGroup's children are the individual
// tab buttons. This provides a definitive, reliable signal.
//
// How it works:
// 1. Get kAXChildrenAttribute on the window's AXUIElement
// 2. Find the child with kAXRoleAttribute == "AXTabGroup"
// 3. Get that child's kAXChildrenAttribute
// 4. Filter children to those with kAXSubroleAttribute == "AXTabButton"
// (this excludes the "+" button which has role=AXButton, subrole=nil)
// 5. Each AXTabButton has:
// - kAXTitleAttribute: the tab's title (matches the window title)
// - kAXValueAttribute: 1 for active tab, 0 for inactive tab
// - _AXUIElementGetWindow: returns the parent window's WID (not individual tab WIDs)
//
// Example output for Finder with tabs "lwouis" and "git" (git is active):
// AXTabGroup has 3 children:
// [0] role=AXRadioButton subrole=AXTabButton title='lwouis' value=0
// [1] role=AXRadioButton subrole=AXTabButton title='git' value=1
// [2] role=AXButton subrole=nil title='' value=nil ← "+" button
//
// Non-tabbed windows (e.g. "Movies") have no AXTabGroup child at all.
//
// Properties:
// - Definitive: presence of AXTabGroup = tabbed, absence = not tabbed
// - Rich: provides tab count and all tab titles
// - Public API: uses standard Accessibility framework (no private APIs needed)
// - Works cross-space: querying via an existing AXUIElement reference works even
// when the window is on a different Space (unlike kAXWindowsAttribute on the app
// element, which only returns windows on the current Space)
// - Limitation: _AXUIElementGetWindow on tab buttons returns the parent window WID,
// not individual tab WIDs. Matching tabs to windows must use title comparison.
//
// Implementation: see AXUIElement.tabGroupInfo() in src/api-wrappers/AXUIElement.swift
+11 -3
View File
@@ -161,13 +161,21 @@ class Application: NSObject {
func manuallyUpdateWindow(_ axWindow: AXUIElement, _ wid: CGWindowID) throws {
guard wid != 0 && wid != TilesPanel.shared.windowNumber else { return } // some bogus "windows" have wid 0
let level = wid.level()
let a = try axWindow.attributes([kAXTitleAttribute, kAXSubroleAttribute, kAXRoleAttribute, kAXSizeAttribute, kAXPositionAttribute, kAXFullscreenAttribute, kAXMinimizedAttribute])
// if we query .children on ourselves, AppKit calls layout directly from our thread instead of IPC; we avoid this
let isSelf = pid == ProcessInfo.processInfo.processIdentifier
let keys = [kAXTitleAttribute, kAXSubroleAttribute, kAXRoleAttribute, kAXSizeAttribute, kAXPositionAttribute, kAXFullscreenAttribute, kAXMinimizedAttribute] + (isSelf ? [] : [kAXChildrenAttribute])
let a = try axWindow.attributes(keys)
let tabSiblingTitles = isSelf ? nil : TabGroup.extractTabTitles(a.children)
DispatchQueue.main.async { [weak self] in
guard let self else { return }
let findOrCreate = Windows.findOrCreate(axWindow, wid, self, level, a.title, a.subrole, a.role, a.size, a.position, a.isFullscreen, a.isMinimized)
guard let window = findOrCreate.0 else { return }
if findOrCreate.1 {
Logger.info { "manuallyUpdateWindows found a new window:\(window.debugId)" }
var tabStateChanged = false
if tabSiblingTitles != nil || window.tabbedSiblingWids != nil {
tabStateChanged = TabGroup.updateState(window, tabSiblingTitles)
}
if findOrCreate.1 || (tabStateChanged && App.appIsBeingUsed) {
if findOrCreate.1 { Logger.info { "manuallyUpdateWindows found a new window:\(window.debugId)" } }
App.refreshOpenUiAfterExternalEvent([window])
}
}
+95
View File
@@ -0,0 +1,95 @@
import Cocoa
class TabGroup {
/// Parse AXTabGroup children from a prior `.attributes([..., kAXChildrenAttribute])` call.
/// Returns tab titles if the window has tabs (always >= 2), nil otherwise.
static func extractTabTitles(_ children: [AXUIElement]?) -> [String]? {
AXUIElement.tabGroupInfo(children)
}
/// Find the active tab (non-isTabbed) sibling in the same tab group.
static func activeTabSibling(of window: Window) -> Window? {
guard let siblingWids = window.tabbedSiblingWids else { return nil }
return Windows.list.first { sibling in
sibling !== window && !sibling.isTabbed
&& sibling.cgWindowId != nil && siblingWids.contains(sibling.cgWindowId!)
}
}
/// When a window is removed from the list, update its former siblings' tab group.
/// If only 1 sibling remains, clear its tab state (a single window can't be tabbed).
static func removedWindowFromGroup(wid: CGWindowID?, siblingWids: [CGWindowID]) {
let remainingWids = siblingWids.filter { $0 != wid }
let remainingSiblings = Windows.list.filter { w in
w.cgWindowId != nil && remainingWids.contains(w.cgWindowId!)
}
if remainingSiblings.count <= 1 {
// no longer a tab group
for s in remainingSiblings {
s.tabbedSiblingWids = nil
s.isTabbed = false
}
} else {
// shrink the group
for s in remainingSiblings {
s.tabbedSiblingWids = remainingWids
}
}
}
/// Update tab state for a window and its siblings using AX-discovered tab titles.
/// Resolves titles to WIDs, propagates space info from active to inactive tabs,
/// and clears stale state on windows no longer in the group.
/// Returns true if any window's tab state or space changed.
@discardableResult
static func updateState(_ activeTab: Window, _ siblingTitles: [String]?) -> Bool {
var changed = false
guard let titles = siblingTitles else {
// only clear state if this window was the active tab of its group;
// inactive tabs report nil titles (no AXTabGroup child) but are still tabbed
if activeTab.tabbedSiblingWids != nil && !activeTab.isTabbed {
activeTab.tabbedSiblingWids = nil
changed = true
}
return changed
}
let pid = activeTab.application.pid
var siblingWids = [CGWindowID]()
if let wid = activeTab.cgWindowId { siblingWids.append(wid) }
var matchedSiblings = [Window]()
// remove one occurrence of the active tab's title (not all — there may be duplicate titles)
var remainingTitles = titles
if let i = remainingTitles.firstIndex(of: activeTab.title) {
remainingTitles.remove(at: i)
}
for title in remainingTitles {
if let sibling = (Windows.list.first { s in
s !== activeTab && s.application.pid == pid && s.title == title
&& !matchedSiblings.contains(where: { $0 === s })
}) {
matchedSiblings.append(sibling)
if let wid = sibling.cgWindowId { siblingWids.append(wid) }
}
}
if activeTab.tabbedSiblingWids != siblingWids { changed = true }
activeTab.tabbedSiblingWids = siblingWids
activeTab.isTabbed = false
for sibling in matchedSiblings {
if !sibling.isTabbed || sibling.tabbedSiblingWids != siblingWids || sibling.spaceIds != activeTab.spaceIds { changed = true }
sibling.tabbedSiblingWids = siblingWids
sibling.isTabbed = true
sibling.spaceIds = activeTab.spaceIds
sibling.spaceIndexes = activeTab.spaceIndexes
sibling.isOnAllSpaces = activeTab.isOnAllSpaces
}
for window in Windows.list where window !== activeTab && window.application.pid == pid
&& !matchedSiblings.contains(where: { $0 === window }) {
if window.tabbedSiblingWids != nil {
window.tabbedSiblingWids = nil
window.isTabbed = false
changed = true
}
}
return changed
}
}
+8 -4
View File
@@ -20,6 +20,7 @@ class Window {
var icon: CGImage? { get { application.icon } }
var shouldShowTheUser = true
var isTabbed: Bool = false
var tabbedSiblingWids: [CGWindowID]?
var isHidden: Bool { get { application.isHidden } }
var dockLabel: String? { get { application.dockLabel } }
var isFullscreen = false
@@ -269,7 +270,11 @@ class Window {
private func updateSpaces() {
guard let cgWindowId else { return }
let spaceIds = cgWindowId.spaces()
var spaceIds = cgWindowId.spaces()
// inactive tabs return no space from CGSCopySpacesForWindows; use the active tab sibling's space
if spaceIds.isEmpty, let activeTab = TabGroup.activeTabSibling(of: self) {
spaceIds = activeTab.spaceIds
}
self.spaceIds = spaceIds
self.spaceIndexes = spaceIds.compactMap { spaceId in Spaces.idsAndIndexes.first { $0.0 == spaceId }?.1 }
self.isOnAllSpaces = spaceIds.count > 1
@@ -300,9 +305,8 @@ class Window {
func referenceWindowForTabbedWindow() -> Window? {
// if the window is tabbed, we can't know its position/size before it's focused, so we use the currently
// visible window-tab. Its data will match the tabbed window's
// TODO: handle the case where the app has multiple window-groups. In that case, we need to find the right
// window-group, instead of picking the focused one
return isTabbed ? application.focusedWindow : self
// fallback to the focusedWindow
isTabbed ? (TabGroup.activeTabSibling(of: self) ?? application.focusedWindow) : self
}
// Determines if this window is the main application window
+4 -35
View File
@@ -87,27 +87,6 @@ class Windows {
}
}
/// tabs detection is a flaky work-around the lack of public API to observe OS tabs
/// see: https://github.com/lwouis/alt-tab-macos/issues/1540
private static func detectTabbedWindows(_ window: Window, _ cgsWindowIds: [CGWindowID], _ visibleCgsWindowIds: [CGWindowID], _ pidsWithVisibleWindow: Set<pid_t>) {
if let cgWindowId = window.cgWindowId {
if window.isMinimized || window.isHidden {
if #available(macOS 13.0, *) {
// not exact after window merging
window.isTabbed = !cgsWindowIds.contains(cgWindowId)
} else {
// not known
window.isTabbed = false
}
} else {
// a real inactive tab always has a visible sibling (the active tab) from the same app
// apps like Teams/WeChat hide windows without destroying them; those have no visible sibling
let notInVisibleList = !visibleCgsWindowIds.contains(cgWindowId)
window.isTabbed = notInVisibleList && pidsWithVisibleWindow.contains(window.application.pid)
}
}
}
static func updatesBeforeShowing() -> Bool {
if MissionControl.state() == .showAllWindows || MissionControl.state() == .showFrontWindows { return false }
if list.isEmpty { return true }
@@ -115,21 +94,7 @@ class Windows {
// workaround: when Preferences > Mission Control > "Displays have separate Spaces" is unchecked,
// switching between displays doesn't trigger .activeSpaceDidChangeNotification; we get the latest manually
Spaces.refresh()
let spaceIdsAndIndexes = Spaces.idsAndIndexes.map { $0.0 }
lazy var cgsWindowIds = Spaces.windowsInSpaces(spaceIdsAndIndexes)
lazy var visibleCgsWindowIds = Spaces.windowsInSpaces(spaceIdsAndIndexes, false)
lazy var pidsWithVisibleWindow: Set<pid_t> = {
let visibleSet = Set(visibleCgsWindowIds)
var pids = Set<pid_t>()
for window in list {
if let wid = window.cgWindowId, visibleSet.contains(wid) {
pids.insert(window.application.pid)
}
}
return pids
}()
for window in list {
detectTabbedWindows(window, cgsWindowIds, visibleCgsWindowIds, pidsWithVisibleWindow)
window.updateSpacesAndScreen()
refreshIfWindowShouldBeShownToTheUser(window)
}
@@ -552,6 +517,10 @@ class Windows {
Applications.manualWindowUpdatesThrottler.removeEntry(withKey: "\(wid)")
AccessibilityEvents.removeThrottlerEntries(wid: wid)
}
// when a tabbed window is removed, update its former siblings' tab group
if let siblingWids = w.tabbedSiblingWids {
TabGroup.removedWindowFromGroup(wid: w.cgWindowId, siblingWids: siblingWids)
}
}
if addWindowlessWindowIfNeeded {
windows.forEach { $0.application.addWindowlessWindowIfNeeded() }
+11 -4
View File
@@ -80,8 +80,7 @@ class AccessibilityEvents {
// for AXUIElement of apps, CFEqual or == don't work; looks like a Cocoa bug
return $0.application.pid == pid
}
// if we process the "shown" event too fast, the window won't be listed by CGSCopyWindowsWithOptionsAndTags
// it will thus be detected as isTabbed. We add a delay to work around this scenario
// if we process the "shown" event too fast, UI may not be ready; we add a delay to work around this
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) {
App.refreshOpenUiAfterExternalEvent(windows)
}
@@ -98,7 +97,11 @@ class AccessibilityEvents {
return
}
let level = wid.level()
let a = try element.attributes([kAXTitleAttribute, kAXSubroleAttribute, kAXRoleAttribute, kAXSizeAttribute, kAXPositionAttribute, kAXFullscreenAttribute, kAXMinimizedAttribute])
// if we query .children on ourselves, AppKit calls layout directly from our thread instead of IPC; we avoid this
let isSelf = pid == ProcessInfo.processInfo.processIdentifier
let keys = [kAXTitleAttribute, kAXSubroleAttribute, kAXRoleAttribute, kAXSizeAttribute, kAXPositionAttribute, kAXFullscreenAttribute, kAXMinimizedAttribute] + (isSelf ? [] : [kAXChildrenAttribute])
let a = try element.attributes(keys)
let tabSiblingTitles = isSelf ? nil : TabGroup.extractTabTitles(a.children)
DispatchQueue.main.async {
guard let app = Applications.findOrCreate(pid, false) else { return }
Logger.info { "\(type) wid:\(wid) app:\(app.debugId)" }
@@ -111,7 +114,11 @@ class AccessibilityEvents {
return
}
Logger.debug { "\(type) win:\(window.debugId)" }
if findOrCreate.1 {
var tabStateChanged = false
if tabSiblingTitles != nil || window.tabbedSiblingWids != nil {
tabStateChanged = TabGroup.updateState(window, tabSiblingTitles)
}
if findOrCreate.1 || (tabStateChanged && App.appIsBeingUsed) {
App.refreshOpenUiAfterExternalEvent([window])
}
if type == kAXMainWindowChangedNotification || type == kAXFocusedWindowChangedNotification {