8000 GitHub - ejbills/WindowKit: Swift package for macOS window discovery, tracking, and manipulation · GitHub
[go: up one dir, main page]

Skip to content
ejbillsPublic

About

Swift package for macOS window discovery, tracking, and manipulation

Resources

Stars

11 stars

Watchers

2 watching

Forks

Latest commit

 

History

200 Commits

Folders and files

Repository files navigation

WindowKit

Window discovery, tracking, and manipulation for macOS.

Warning: This package uses private macOS APIs. It may break with any macOS update.

Installation

Add WindowKit to your Package.swift:

dependencies: [
    .package(url: "https://github.com/ejbills/WindowKit.git", branch: "main")
]

Then add it to your target:

.target(name: "YourApp", dependencies: ["WindowKit"])

Requires macOS 14+ and Swift 5.9+.

Permissions

WindowKit needs two system permissions:

  • Accessibility — window tracking and manipulation
  • Screen Recording — window preview capture
let status = WindowKit.shared.permissionStatus

status.accessibilityGranted  // Bool
status.screenCaptureGranted  // Bool
status.allGranted            // both true

// Prompt the user for access
SystemPermissions.shared.requestAccessibility()
SystemPermissions.shared.requestScreenRecording()

// Or open System Settings directly
SystemPermissions.shared.openPrivacySettings(for: .accessibility)
SystemPermissions.shared.openPrivacySettings(for: .screenRecording)

SystemPermissions.shared is an ObservableObject — its currentState updates automatically via polling.

Usage

import WindowKit

// Start tracking all windows on the system
WindowKit.shared.beginTracking()

// Query windows
let all     = await WindowKit.shared.allWindows()
let safari  = await WindowKit.shared.windows(bundleID: "com.apple.Safari")
let byPID   = await WindowKit.shared.windows(pid: 1234)
let byApp   = await WindowKit.shared.windows(application: someApp)
let single  = await WindowKit.shared.window(withID: windowID)

// Force a refresh for one app or everything
await WindowKit.shared.refresh(application: someApp)
await WindowKit.shared.refreshAll()

// Stop when done
WindowKit.shared.endTracking()

CapturedWindow

Each window is a CapturedWindow struct:

// Identity
window.id                  // CGWindowID
window.title               // String?
window.ownerBundleID       // String?
window.ownerPID            // pid_t
window.ownerApplication    // NSRunningApplication?

// Geometry
window.bounds              // CGRect

// State
window.isMinimized         // Bool
window.isOwnerHidden       // Bool
window.isVisible           // Bool
window.desktopSpace        // Int?

// Timestamps
window.lastInteractionTime // Date
window.creationTime        // Date (first AX observation)

// Preview
window.preview             // CGImage?

// Accessibility elements
window.axElement           // AXUIElement (the window)
window.appAxElement        // AXUIElement (the owning app)
window.closeButton         // AXUIElement?

Application State (Observable)

WindowKit.shared is @Observable — SwiftUI views that read these properties re-render automatically with per-property granularity.

// Currently focused application (updated before .applicationActivated events emit)
let focused = WindowKit.shared.frontmostApplication

// All running applications with regular activation policy (includes windowless apps)
let running = WindowKit.shared.trackedApplications

// Applications in the process of launching (before windows are discovered)
let launching = WindowKit.shared.launchingApplications

In a SwiftUI view, just read the properties directly:

struct DockView: View {
    var body: some View {
        // Only re-renders when trackedApplications actually changes
        ForEach(WindowKit.shared.trackedApplications, id: \.processIdentifier) { app in
            AppIcon(app: app, isFocused: app == WindowKit.shared.frontmostApplication)
        }
    }
}

Per-App Window State

AppWindowState is an @Observable object scoped to a single application. Unlike trackedApplications (which invalidates every view when any app changes), views reading an AppWindowState only re-render when that specific app's windows change. State changes are animated by default.

let state = WindowKit.shared.windowState(for: pid)
// or
let state = WindowKit.shared.windowState(for: someApp)
Property Type Description
pid pid_t The process identifier
windows [CapturedWindow] All windows, sorted by last interaction
count Int Total window count
hasWindows Bool Whether the app has any windows
visibleCount Int Windows that aren't minimized or hidden
allMinimized / isMinimized Bool All windows are minimized
allHidden / isHidden Bool All windows are hidden
badgeLabel String? Dock badge text ("3" for counts, "" for dot-only, nil for none)
badgeCount Int? Numeric badge count parsed from badgeLabel, including labels like "3 notifications"; nil if absent or no number exists
hasBadge Bool Whether the app has a Dock badge
animation Animation? Animation for state changes (default .default, set nil to disable)

Dot-only Dock badges are reported through the same badge system: hasBadge == true, badgeLabel == "", and badgeCount == nil. WindowKit normalizes empty labels, dot-like labels, and generic no-count status labels such as "has notifications" to that dot-only representation.

struct DockIcon: View {
    let app: NSRunningApplication
    var body: some View {
        let state = WindowKit.shared.windowState(for: app.processIdentifier)
        AppIconView(app: app)
            .badge(state.count)          // only re-renders when THIS app changes
            .opacity(state.isHidden ? 0.5 : 1.0)
    }
}

State is invalidated automatically on window appear/disappear/change, preview capture, and app termination. No data is duplicated — all properties read through to the single window repository.

Dock badges can also be observed without a running app process:

let badge = WindowKit.shared.badgeState(forBundleIdentifier: "com.apple.MobileSMS")
// or, to disambiguate multiple Dock entries with the same bundle identifier:
let exactBadge = WindowKit.shared.badgeState(forBundleURL: appBundleURL)

Bundle-identifier badge state reads the app's Dock item directly, so it can update while the app is not running. If multiple Dock items share the same bundle identifier, WindowKit only reports a bundle-id badge when those Dock items agree on the same badge value; use the bundle-URL API when you need a specific Dock item.

Minimized Dock Windows (incl. App-less)

The per-app tracking pipeline only follows .regular apps, so minimized windows owned by menu-bar / agent / LSUIElement processes never appear in trackedApplications or windowState(for:). The native macOS Dock, however, parks every minimized window near Trash — including these "app-less" ones.

This channel surfaces exactly what the native Dock shows by observing the Dock process's accessibility tree (AXMinimizedWindowDockItem items). It is driven by Dock AX notifications (AXCreated / AXUIElementDestroyed) — no polling — and is a single authoritative source: it covers app-less windows, stays correct even when the native Dock is hidden, and needs no per-process window walking. Each entry is correlated to a CGWindowID and carries a WindowServer-captured preview thumbnail (which works even though the window is minimized). Restoring a window presses its Dock item (AXPress) — exactly what clicking the native Dock does.

Member Type Description
orphanedMinimizedWindows [DockMinimizedWindow] Observable, continuously-maintained set of the Dock's minimized windows. Each has id, optional windowID, title, and a preview: CGImage? thumbnail.
tracksOrphanedMinimizedWindows Bool Opt-in toggle (default true). Flipping it live starts/stops the subsystem; it polls one process on a background queue, so it is cheap.
refreshOrphanedMinimizedWindows() async Forces an immediate rebuild on the next poll tick. No-op when not tracking.
restoreOrphanedMinimizedWindow(id:) (String) Restores a window by pressing its native Dock item. id is a DockMinimizedWindow.id.
WindowKit.shared.beginTracking()   // starts this channel too (when enabled)

// Observe in SwiftUI — re-renders when the set changes.
ForEach(WindowKit.shared.orphanedMinimizedWindows) { window in
    MinimizedWindowChip(title: window.title, preview: window.preview)
        .onTapGesture { WindowKit.shared.restoreOrphanedMinimizedWindow(id: window.id) }
}

Requires Accessibility permission and is started/stopped alongside beginTracking() / endTracking().

Cmd+Tab Switcher Selection

WindowKit can report which item is highlighted in the macOS system Cmd+Tab switcher. This is pure observation of the Dock process's accessibility tree (the AXProcessSwitcherList element) — WindowKit does not install an event tap or intercept the keypress, so Cmd+Tab keeps behaving 100% natively. It's driven by Dock AX notifications (AXCreated / AXSelectedChildrenChanged / AXUIElementDestroyed), not polling.

Member Type Description
processSwitcherSelection AppSwitcherSelection? Observable current sele 8000 ction, or nil when the switcher is closed. Carries ownerPID, bundleIdentifier, title, and frame (the highlighted item's rect, in AX/Quartz top-left coordinates).
processSwitcherEvents AnyPublisher<AppSwitcherEvent, Never> Combine stream of .appeared / .selectionChanged / .dismissed.
tracksProcessSwitcher Bool Opt-in toggle (default true). Flipping it live starts/stops the subsystem.
WindowKit.shared.beginTracking()   // starts this channel too (when enabled)

// Observe in SwiftUI — re-renders as the user tabs through apps.
if let selection = WindowKit.shared.processSwitcherSelection {
    Text(selection.title ?? "")            // highlighted app
    // selection.ownerPID / selection.bundleIdentifier / selection.frame
}

// Or via Combine:
WindowKit.shared.processSwitcherEvents
    .sink { event in
        switch event {
        case .appeared(let sel):         // switcher opened
        case .selectionChanged(let sel): // user tabbed to a different app
        case .dismissed:                 // switcher closed
        }
    }
    .store(in: &cancellables)

Requires Accessibility permission and is started/stopped alongside beginTracking() / endTracking().

Minimize Transitions

WindowKit can announce every minimize as it begins, early enough for a host to draw its own animation, and gives a host time to cover the native animation of the minimizes and restores it performs itself.

  • Minimizes started elsewhere (yellow button, Cmd+M, title-bar double-click): the native Dock adds an AXMinimizedWindowDockItem to its accessibility tree ~30ms before its animation is visible, while the owner app's own miniaturized notification only arrives once the ~0.5s animation has finished. The item carries only the window title, so WindowKit resolves it to a cached window with that title (fuzzy matches only when no window has it exactly): the frontmost app's unminimized one first, then the most recently used. An item whose matches are all already minimized (one the Dock re-creates for an old minimize) is not announced. These announce with isPerformedByWindowKit == false.
  • Minimizes WindowKit performs (minimizeWindow, minimizeWindows, and toggleMinimizeWindow on a visible window): announced with a one-off full-resolution capture, then run as usual after coverLeadTime so a host can cover the native animation. Their Dock tile is expected and isn't announced again. Running them with the owner app hidden skips the native animation, but the Dock then keeps that window's tile for good, through restores and even after the window closes, and creates no new tile (so posts no announcement) for any later minimize of it.
  • Restores WindowKit performs (restoreWindow, restoreWindows, toggleMinimizeWindow and focusWindow on a minimized window): announced with a one-off full-resolution capture, then run as usual after coverLeadTime so a host can cover the native animation. Hiding the owner doesn't help here: the Dock replays the genie-out of any window it minimized with an animation, even while the owner is hidden.

WindowKit only announces its own transitions for windows that aren't fullscreen, whose owner isn't hidden, and that shouldAnnounceOwnTransition accepts; anything else runs as it would with tracking off. While transitions are tracked, a minimize, restore, toggle or focus of a window whose own transition is still in progress is dropped. With tracksMinimizeTransitions off, every wrapper (including the batch ones, which then loop the single-window wrappers) behaves as it did before transitions existed.

Member Type Description
minimizeTransitions AnyPublisher<MinimizeTransition, Never> Every minimize, and every restore WindowKit performs, as it begins, on the main thread. WindowKit's own transitions carry image, a one-off full-resolution capture that is never cached, and isPerformedByWindowKit == true.
tracksMinimizeTransitions Bool Opt-in toggle (default false). Flipping it live starts/stops the subsystem.
coverLeadTime TimeInterval How long WindowKit's own minimizes and restores wait after being announced before they start (default 0).
shouldAnnounceOwnTransition ((CapturedWindow, MinimizeTransition.Kind) -> Bool)? Asked on the main thread before WindowKit announces one of its own transitions. Return false for windows the host won't animate (another Space, no Screen Recording); they then run with no announcement, capture or lead-time wait.
minimizeWindows(_:) async Minimizes several windows: all are announced first and wait coverLeadTime once. Failures are skipped.
restoreWindows(_:) async Restores several windows: all are announced first and wait coverLeadTime once. Failures are skipped.

Requires Accessibility permission (plus Screen Recording for the announced preview). Minimizes started elsewhere are only seen with the native Dock's "Minimize windows into application icon" turned off, since that mode creates no per-window Dock item.

Events

Subscribe to window lifecycle changes via Combine:

WindowKit.shared.events
    .sink { event in
        switch event {
        case .windowAppeared(let window):
            // new window discovered
        case .windowDisappeared(let id):
            // window closed or owning app terminated
        case .windowChanged(let window):
            // title, bounds, minimized, or hidden state changed
        case .previewCaptured(let id, let image):
            // screenshot captured for window
        case .notificationBannerChanged:
            // notification banner state changed
        case .systemWoke:
            // system woke from sleep
        case .wakeRecoveryCompleted:
            // WindowKit finished post-wake AX recovery and full scan
        }
    }
    .store(in: &cancellables)

Process Events

Subscribe to application lifecycle events via Combine. These fire on the main queue and are always available (no beginTracking() required).

WindowKit.shared.processEvents
    .sink { event in
        switch event {
        case .applicationWillLaunch(let app):
            // app is about to launch
        case .applicationLaunched(let app):
            // app finished launching
        case .applicationTerminated(let pid):
            // app terminated
        case .applicationActivated(let app):
            // app became frontmost
        case .spaceChanged:
            // user switched spaces
        }
    }
    .store(in: &cancellables)

Manipulation

var window = await WindowKit.shared.window(withID: someID)!

// Focus
try window.bringToFront()

// Minimize / restore
try window.minimize()
try window.restore()
try window.toggleMinimize()     // returns new state

// Hide / show the owning application
try window.hide()
try window.unhide()
try window.toggleHidden()       // returns new state

// Fullscreen
try window.enterFullScreen()
try window.exitFullScreen()
try window.toggleFullScreen()

// Close window or quit app
try window.close()
window.quit()                   // graceful termination
window.quit(force: true)        // force kill

// Positioning
try window.setPosition(CGPoint(x: 100, y: 100))
try window.setSize(CGSize(width: 800, height: 600))
try window.setFrame(CGRect(x: 100, y: 100, width: 800, height: 600))
try window.setPositionAndSize(
    position: CGPoint(x: 100, y: 100),
    size: CGSize(width: 800, height: 600)
)

// Screen filling and tiling
try window.maximize()                 // visible frame of the window's screen
try window.fillLeftHalf()
try window.fillRightHalf()
try window.fillTopHalf()
try window.fillBottomHalf()
try window.fill(.topLeftQuarter)

Window Stash Capture

WindowStashCapture moves every window that matching processes create while it runs into a Space no display shows, before the owner can order them in (used to open another app's menu off screen).

let capture = try WindowStashCapture.begin { pid in pid == helperPID }
// ... trigger the windows ...
_ = capture.waitUntilOrderedOut(timeout: 0.5)
if !capture.end() {
    // A captured window was still ordered in, so the Space was left up.
    // Call end() again later; it destroys the Space once nothing captured is ordered in.
}

end() is safe to call any number of times, from any thread, and returns whether the Space is destroyed.

Badge Polling

Dock badges update automatically via event-driven triggers (app activation, notification banners, window changes). However, some apps like Messages and Microsoft Teams update their dock badge silently without firing a notification banner. To catch these, enable badge polling:

// Refresh immediately, then poll every 5 seconds for dock badge changes
WindowKit.shared.startBadgePolling()

// Stop when no longer needed (also stops automatically on endTracking())
WindowKit.shared.stopBadgePolling()

// Disable all badge refresh work, including event-driven notification refreshes
WindowKit.shared.badgeTrackingEnabled = false

Polling reads AXStatusLabel from cached dock element references, so each tick is lightweight. Only app states whose badge actually changed are invalidated.

Download/progress indicators drawn on Dock icons are not notification badges; they are visual progress overlays and are not reported as badgeLabel.

Configuration

// Skip screenshot capture when you only need window metadata
WindowKit.shared.headless = true

// Control how long preview images stay cached (default 30s)
WindowKit.shared.previewCacheDuration = 60

// Ignore specific PIDs (e.g. your own app) — set before beginTracking()
WindowKit.shared.ignoredPIDs = [ProcessInfo.processInfo.processIdentifier]

// Enable debug logging (uses os_log under the hood)
WindowKit.shared.logging = true

// Or pipe logs to your own handler
WindowKit.shared.logHandler = { level, message, details in
    print("[\(level)] \(message) \(details ?? "")")
}

License

MIT — see LICENSE.

Thanks to Louis Pontoise (AltTab) for permitting use of his private API work under MIT.

About

Swift package for macOS window discovery, tracking, and manipulation

Resources

Stars

11 stars

Watchers

2 watching

Forks

Releases

Packages

Contributors

Languages

0