Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Fix native macOS tabs being treated as separate windows (#68)
macOS accessibility API reports native tabs as separate AXWindow elements,
causing AeroSpace to tile each tab as its own window. This uses
CGWindowListCopyWindowInfo (public API) to detect inactive tabs: if a
window is not on-screen but another window from the same app is, it's
likely a background native tab.

Changes:
- windowLevelCache.swift: extended CG window info cache with bounds and
  PID tracking; added isLikelyNativeTab() detection function
- MacWindow.swift: check for native tabs before tiling new windows
- normalizeLayoutReason.swift: demote tiled windows that become inactive
  tabs to popup container; prevent tab windows from being promoted back

Works with Terminal.app, Finder, Safari, and any app using native macOS
tabs. No private APIs used.
  • Loading branch information
Eden Rochman committed Mar 26, 2026
commit 0c7c8e48e1bd71c4edc823fefcafa35fbc2f54b1
21 changes: 21 additions & 0 deletions Sources/AppBundle/normalizeLayoutReason.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,34 @@ func normalizeLayoutReason() async throws {
try await _normalizeLayoutReason(workspace: workspace, windows: windows)
}
try await _normalizeLayoutReason(workspace: focus.workspace, windows: macosMinimizedWindowsContainer.children.filterIsInstance(of: Window.self))
try await demoteNativeTabsToPopup()
try await validateStillPopups()
}

/// Move tiled windows that have become inactive native tabs to the popup container.
/// This handles the case where a tiled window becomes a background tab after the user opens a new tab.
/// https://github.com/nikitabobko/AeroSpace/issues/68
@MainActor
private func demoteNativeTabsToPopup() async throws {
for workspace in Workspace.all {
for window in workspace.allLeafWindowsRecursive {
guard let macWindow = window as? MacWindow else { continue }
if isLikelyNativeTab(windowId: macWindow.windowId, appPid: macWindow.macApp.pid) {
macWindow.bind(to: macosPopupWindowsContainer, adaptiveWeight: WEIGHT_AUTO, index: INDEX_BIND_LAST)
}
}
}
}

@MainActor
private func validateStillPopups() async throws {
for node in macosPopupWindowsContainer.children {
let popup = (node as! MacWindow)
// Don't promote native tabs back to tiling — they were intentionally placed in popup container
// https://github.com/nikitabobko/AeroSpace/issues/68
if isLikelyNativeTab(windowId: popup.windowId, appPid: popup.macApp.pid) {
continue
}
let windowLevel = getWindowLevel(for: popup.windowId)
if try await popup.isWindowHeuristic(windowLevel) {
try await popup.relayoutWindow(on: focus.workspace)
Expand Down
8 changes: 8 additions & 0 deletions Sources/AppBundle/tree/MacWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,14 @@ extension Window {
@MainActor
private func unbindAndGetBindingDataForNewWindow(_ windowId: UInt32, _ macApp: MacApp, _ workspace: Workspace, window: Window?) async throws -> BindingData {
let windowLevel = getWindowLevel(for: windowId)

// Tab detection heuristic: if a window is not on screen but the same app has an
// on-screen window, it's likely an inactive macOS native tab.
// https://github.com/nikitabobko/AeroSpace/issues/68
if isLikelyNativeTab(windowId: windowId, appPid: macApp.pid) {
return BindingData(parent: macosPopupWindowsContainer, adaptiveWeight: WEIGHT_AUTO, index: INDEX_BIND_LAST)
}

return switch try await macApp.getAxUiElementWindowType(windowId, windowLevel) {
case .popup: BindingData(parent: macosPopupWindowsContainer, adaptiveWeight: WEIGHT_AUTO, index: INDEX_BIND_LAST)
case .dialog: BindingData(parent: workspace, adaptiveWeight: WEIGHT_AUTO, index: INDEX_BIND_LAST)
Expand Down
69 changes: 59 additions & 10 deletions Sources/AppBundle/windowLevelCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,77 @@ import CoreGraphics
import Foundation

@MainActor
private var cache: [UInt32: MacOsWindowLevel] = [:]
private var levelCache: [UInt32: MacOsWindowLevel] = [:]

@MainActor
func getWindowLevel(for windowId: UInt32) -> MacOsWindowLevel? {
if let existing = cache[windowId] { return existing }
private struct CgWindowInfo {
let level: MacOsWindowLevel
let bounds: CGRect
let ownerPid: pid_t
}

@MainActor
private var cgWindowInfoCache: [UInt32: CgWindowInfo] = [:]

var result: [UInt32: MacOsWindowLevel] = [:]
@MainActor
private func refreshCgWindowInfoCache() {
let options = CGWindowListOption(arrayLiteral: .excludeDesktopElements, .optionOnScreenOnly)
guard let cfArray = CGWindowListCopyWindowInfo(options, CGWindowID(0)) as? [CFDictionary] else { return nil }
guard let cfArray = CGWindowListCopyWindowInfo(options, CGWindowID(0)) as? [CFDictionary] else { return }

var levels: [UInt32: MacOsWindowLevel] = [:]
var infos: [UInt32: CgWindowInfo] = [:]

for elem in cfArray {
let dict = elem as NSDictionary

guard let _windowId = dict[kCGWindowNumber] else { continue }
let windowId = ((_windowId as! CFNumber) as NSNumber).uint32Value

guard let _windowLayer = dict[kCGWindowLayer] else { continue }
let windowLayer = ((_windowLayer as! CFNumber) as NSNumber).intValue

guard let _windowId = dict[kCGWindowNumber] else { continue }
let windowId = ((_windowId as! CFNumber) as NSNumber).uint32Value
guard let _pid = dict[kCGWindowOwnerPID] else { continue }
let pid = ((_pid as! CFNumber) as NSNumber).int32Value

result[windowId] = .new(windowLevel: windowLayer)
var bounds = CGRect.zero
if let boundsDict = dict[kCGWindowBounds] {
CGRectMakeWithDictionaryRepresentation(boundsDict as! CFDictionary, &bounds)
}

let level = MacOsWindowLevel.new(windowLevel: windowLayer)
levels[windowId] = level
infos[windowId] = CgWindowInfo(level: level, bounds: bounds, ownerPid: pid)
}
levelCache = levels
cgWindowInfoCache = infos
}

@MainActor
func getWindowLevel(for windowId: UInt32) -> MacOsWindowLevel? {
if let existing = levelCache[windowId] { return existing }
refreshCgWindowInfoCache()
return levelCache[windowId]
}

/// Detect macOS native tabs: the AX API reports tabs as separate windows, but only the active
/// tab appears in CGWindowListCopyWindowInfo(.optionOnScreenOnly). If a window is NOT on screen
/// but another window from the same app IS on screen, it's likely an inactive native tab.
/// https://github.com/nikitabobko/AeroSpace/issues/68
@MainActor
func isLikelyNativeTab(windowId: UInt32, appPid: pid_t) -> Bool {
refreshCgWindowInfoCache()

// If this window IS on screen, it's either a real window or the active tab — tile it normally.
if cgWindowInfoCache[windowId] != nil { return false }

// This window is NOT on screen. Check if the same app has at least one normal window on screen.
// If so, this off-screen window is likely an inactive native tab.
for (_, info) in cgWindowInfoCache {
if info.ownerPid == appPid && info.level == .normalWindow {
return true
}
}
cache = result
return result[windowId]
return false
}

enum MacOsWindowLevel: Sendable, Equatable {
Expand Down
Loading