From e099dffb9f85db3077e5bad1948598894e877625 Mon Sep 17 00:00:00 2001 From: Peter Pistorius Date: Thu, 14 May 2026 02:02:40 +0200 Subject: [PATCH] Replace ScreenCaptureKit with SkyLight for tile previews (#21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move live tile previews off per-window SCStream onto a polled SkyLight private API (CGSHWCaptureWindowList). Switch window enumeration from SCShareableContent to CGWindowListCopyWindowInfo. Tighten the onboarding window and auto-relaunch when Accessibility is granted. ScreenCaptureKit is gone from the runtime path. Screen Recording is still required on current macOS — CGSHWCaptureWindowList is gated on it and the menu-bar recording indicator still attributes capture to us. --- .changeset/3b0b7691.md | 5 + Sources/cmdcmd/LabelAssigner.swift | 8 +- Sources/cmdcmd/Onboarding.swift | 125 +++++--- Sources/cmdcmd/Overlay.swift | 105 +++---- Sources/cmdcmd/SkyLightCapture.swift | 65 ++++ Sources/cmdcmd/Tile.swift | 452 +++++---------------------- Sources/cmdcmd/WindowInfo.swift | 56 ++++ Sources/cmdcmd/main.swift | 4 - 8 files changed, 334 insertions(+), 486 deletions(-) create mode 100644 .changeset/3b0b7691.md create mode 100644 Sources/cmdcmd/SkyLightCapture.swift create mode 100644 Sources/cmdcmd/WindowInfo.swift diff --git a/.changeset/3b0b7691.md b/.changeset/3b0b7691.md new file mode 100644 index 0000000..d386f18 --- /dev/null +++ b/.changeset/3b0b7691.md @@ -0,0 +1,5 @@ +--- +bump: minor +--- + +Replace per-window `SCStream` with polled `CGSHWCaptureWindowList` (private SkyLight) for live tile previews. Window enumeration also moves off ScreenCaptureKit to `CGWindowListCopyWindowInfo`, eliminating the SCK setup race fixed in #18 and dropping the CPU/GPU cost of N concurrent streams. Screen Recording permission is still required — current macOS gates `CGSHWCaptureWindowList` on it and still attributes capture to the app via the menu-bar indicator. See issue #21. diff --git a/Sources/cmdcmd/LabelAssigner.swift b/Sources/cmdcmd/LabelAssigner.swift index a2a4d7c..f204761 100644 --- a/Sources/cmdcmd/LabelAssigner.swift +++ b/Sources/cmdcmd/LabelAssigner.swift @@ -17,17 +17,17 @@ final class LabelAssigner { /// in the assignment map keep their existing prefix; new tiles claim the /// next non-colliding prefix. func assign(_ tiles: [Tile]) -> [CGWindowID: String] { - let presentIDs = Set(tiles.map { CGWindowID($0.scWindow.windowID) }) + let presentIDs = Set(tiles.map { CGWindowID($0.window.windowID) }) entries = entries.filter { presentIDs.contains($0.key) } var used: [String: String] = [:] // prefix -> appKey for (_, e) in entries { used[e.prefix] = e.appKey } for tile in tiles { - let id = CGWindowID(tile.scWindow.windowID) + let id = tile.window.windowID if entries[id] != nil { continue } - let appName = tile.scWindow.owningApplication?.applicationName ?? "?" - let appKey = tile.scWindow.owningApplication?.bundleIdentifier ?? appName + let appName = tile.window.applicationName.isEmpty ? "?" : tile.window.applicationName + let appKey = tile.window.bundleIdentifier ?? appName let natural = Self.naturalPrefix(appName: appName) guard !natural.isEmpty else { continue } diff --git a/Sources/cmdcmd/Onboarding.swift b/Sources/cmdcmd/Onboarding.swift index 958bbe5..0ad6546 100644 --- a/Sources/cmdcmd/Onboarding.swift +++ b/Sources/cmdcmd/Onboarding.swift @@ -22,6 +22,15 @@ enum Permission { } } + var pendingGuidance: String { + switch self { + case .screenRecording: + "Find ⌘ ⌘ in the list and turn it on. We'll detect it and relaunch automatically." + case .accessibility: + "Find ⌘ ⌘ in the list and turn it on. We'll detect it and relaunch automatically." + } + } + var settingsURL: URL { switch self { case .screenRecording: @@ -54,7 +63,7 @@ enum Permission { final class Onboarding { private var window: NSWindow? private var rows: [Permission: PermissionRow] = [:] - private var continueButton: NSButton! + private var pollTimer: Timer? private let onComplete: () -> Void private let permissions: [Permission] = [.screenRecording, .accessibility] @@ -70,19 +79,18 @@ final class Onboarding { private func present() { let w = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 520, height: 400), + contentRect: NSRect(x: 0, y: 0, width: 480, height: 100), styleMask: [.titled, .closable], backing: .buffered, defer: false ) w.title = "Welcome to ⌘ ⌘" - w.center() w.isReleasedWhenClosed = false let stack = NSStackView() stack.orientation = .vertical stack.alignment = .leading - stack.spacing = 16 + stack.spacing = 14 stack.translatesAutoresizingMaskIntoConstraints = false let heading = NSTextField(labelWithString: "Two permissions to get started") @@ -94,73 +102,93 @@ final class Onboarding { ) blurb.font = NSFont.systemFont(ofSize: 13) blurb.textColor = .secondaryLabelColor - blurb.preferredMaxLayoutWidth = 472 + blurb.preferredMaxLayoutWidth = 432 stack.addArrangedSubview(blurb) for p in permissions { let row = PermissionRow(permission: p) { [weak self] in - p.request() - NSWorkspace.shared.open(p.settingsURL) - self?.refresh() + self?.grantTapped(p) } rows[p] = row stack.addArrangedSubview(row.view) } - continueButton = NSButton(title: "Continue", target: self, action: #selector(didTapContinue)) - continueButton.bezelStyle = .rounded - continueButton.keyEquivalent = "\r" - continueButton.translatesAutoresizingMaskIntoConstraints = false - - let bottomRow = NSView() - bottomRow.translatesAutoresizingMaskIntoConstraints = false - bottomRow.addSubview(continueButton) - NSLayoutConstraint.activate([ - continueButton.trailingAnchor.constraint(equalTo: bottomRow.trailingAnchor), - continueButton.topAnchor.constraint(equalTo: bottomRow.topAnchor), - continueButton.bottomAnchor.constraint(equalTo: bottomRow.bottomAnchor), - bottomRow.heightAnchor.constraint(greaterThanOrEqualToConstant: 28), - ]) - stack.addArrangedSubview(bottomRow) - let content = NSView() content.addSubview(stack) NSLayoutConstraint.activate([ stack.topAnchor.constraint(equalTo: content.topAnchor, constant: 24), stack.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 24), stack.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -24), - stack.bottomAnchor.constraint(lessThanOrEqualTo: content.bottomAnchor, constant: -24), - bottomRow.leadingAnchor.constraint(equalTo: stack.leadingAnchor), - bottomRow.trailingAnchor.constraint(equalTo: stack.trailingAnchor), + stack.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -24), ]) for r in rows.values { r.view.leadingAnchor.constraint(equalTo: stack.leadingAnchor).isActive = true r.view.trailingAnchor.constraint(equalTo: stack.trailingAnchor).isActive = true } w.contentView = content + content.layoutSubtreeIfNeeded() + w.setContentSize(content.fittingSize) + w.center() window = w NSApp.activate(ignoringOtherApps: true) w.makeKeyAndOrderFront(nil) refresh() + startPolling() + } + + private func grantTapped(_ p: Permission) { + p.request() + NSWorkspace.shared.open(p.settingsURL) + rows[p]?.setPending(true) + } + + private func startPolling() { + pollTimer?.invalidate() + pollTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + self?.refresh() + } } - @discardableResult - private func refresh() -> Bool { + private func stopPolling() { + pollTimer?.invalidate() + pollTimer = nil + } + + private func refresh() { var allGranted = true for p in permissions { let granted = p.granted() rows[p]?.setGranted(granted) if !granted { allGranted = false } } - return allGranted + if allGranted { + stopPolling() + relaunchOrComplete() + } } - @objc private func didTapContinue() { - guard refresh() else { return } + /// Accessibility (and Screen Recording, when it was here) often need a + /// fresh process before global event taps / capture sessions register + /// against the new TCC state. Relaunching is the only reliable way to get + /// a clean install. If the relaunch fails for any reason, fall back to + /// inline `onComplete` so the user isn't stuck. + private func relaunchOrComplete() { + let bundleURL = Bundle.main.bundleURL + let config = NSWorkspace.OpenConfiguration() + config.createsNewApplicationInstance = true window?.orderOut(nil) - window = nil - onComplete() + NSWorkspace.shared.openApplication(at: bundleURL, configuration: config) { [weak self] app, error in + DispatchQueue.main.async { + if app != nil, error == nil { + NSApp.terminate(nil) + } else { + Log.write("onboarding relaunch failed: \(error?.localizedDescription ?? "nil"); continuing in-process") + self?.window = nil + self?.onComplete() + } + } + } } } @@ -168,7 +196,9 @@ private final class PermissionRow { let view: NSView private let dot: NSView private let button: NSButton + private let rationale: NSTextField private let permission: Permission + private var pending = false init(permission: Permission, onGrant: @escaping () -> Void) { self.permission = permission @@ -191,7 +221,8 @@ private final class PermissionRow { let rationale = NSTextField(wrappingLabelWithString: permission.rationale) rationale.font = NSFont.systemFont(ofSize: 12) rationale.textColor = .secondaryLabelColor - rationale.preferredMaxLayoutWidth = 340 + rationale.preferredMaxLayoutWidth = 300 + self.rationale = rationale let textStack = NSStackView(views: [title, rationale]) textStack.orientation = .vertical @@ -201,6 +232,7 @@ private final class PermissionRow { let btn = ButtonWrapper.make(title: "Grant", action: onGrant) btn.translatesAutoresizingMaskIntoConstraints = false + btn.setContentHuggingPriority(.required, for: .horizontal) self.button = btn container.addSubview(dot) @@ -227,8 +259,27 @@ private final class PermissionRow { func setGranted(_ granted: Bool) { dot.layer?.backgroundColor = (granted ? NSColor.systemGreen : NSColor.systemOrange).cgColor - button.title = granted ? "Granted" : "Grant" - button.isEnabled = !granted + if granted { + pending = false + button.title = "Granted" + button.isEnabled = false + rationale.stringValue = permission.rationale + } else if pending { + button.title = "Waiting…" + button.isEnabled = false + } else { + button.title = "Grant" + button.isEnabled = true + } + } + + func setPending(_ value: Bool) { + pending = value + if value { + button.title = "Waiting…" + button.isEnabled = false + rationale.stringValue = permission.pendingGuidance + } } } diff --git a/Sources/cmdcmd/Overlay.swift b/Sources/cmdcmd/Overlay.swift index c896a25..c992a80 100644 --- a/Sources/cmdcmd/Overlay.swift +++ b/Sources/cmdcmd/Overlay.swift @@ -1,5 +1,4 @@ import AppKit -import ScreenCaptureKit @_silgen_name("_AXUIElementGetWindow") private func _AXUIElementGetWindow(_ axEl: AXUIElement, _ wid: UnsafeMutablePointer) -> AXError @@ -76,7 +75,7 @@ final class Overlay { } private static func usageKey(for tile: Tile) -> String { - usageKey(pid: tile.ownerPID, bundleIdentifier: tile.scWindow.owningApplication?.bundleIdentifier) + usageKey(pid: tile.ownerPID, bundleIdentifier: tile.window.bundleIdentifier) } private static func recordUse(of app: NSRunningApplication) { @@ -106,17 +105,6 @@ final class Overlay { guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return } Self.recordUse(of: app) } - prewarmShareable() - } - - private func prewarmShareable() { - Task { - do { - _ = try await SCShareableContent.excludingDesktopWindows(true, onScreenWindowsOnly: true) - } catch { - Log.write("SCShareableContent prewarm failed: \(error)") - } - } } private var isPicking = false @@ -173,12 +161,12 @@ final class Overlay { Task { await prepareAndShow(gen: gen, screen: screen) } } - private func renderOverlay(content: SCShareableContent, screen: NSScreen) { + private func renderOverlay(windows: [WindowInfo], screen: NSScreen) { guard visible else { return } let t0 = CFAbsoluteTimeGetCurrent() let displayBounds = CGDisplayBounds(Self.displayID(for: screen)) let visibleFrame = screen.visibleFrame - let candidates = content.windows + let candidates = windows .filter(Self.isCapturable) .filter { Self.windowMostlyOn(displayBounds: displayBounds, window: $0) } let tFilter = CFAbsoluteTimeGetCurrent() @@ -263,17 +251,10 @@ final class Overlay { } private func prepareAndShow(gen: Int, screen: NSScreen) async { - let scContent: SCShareableContent? - do { - scContent = try await SCShareableContent.excludingDesktopWindows(true, onScreenWindowsOnly: true) - } catch { - Log.write("SCShareableContent failed: \(error)") - scContent = nil - } - guard let content = scContent else { return } + let windows = WindowInfo.enumerate() await MainActor.run { guard self.visible, gen == self.refreshGeneration else { return } - self.renderOverlay(content: content, screen: screen) + self.renderOverlay(windows: windows, screen: screen) } } @@ -321,7 +302,7 @@ final class Overlay { } } -private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> Bool { +private static func windowMostlyOn(displayBounds: CGRect, window: WindowInfo) -> Bool { let inter = window.frame.intersection(displayBounds) guard !inter.isNull else { return false } let interArea = inter.width * inter.height @@ -339,39 +320,38 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B let ar = usageRanks[Self.usageKey(for: a)] ?? Int.max let br = usageRanks[Self.usageKey(for: b)] ?? Int.max if ar != br { return ar < br } - let asr = savedRanks[CGWindowID(a.scWindow.windowID)] ?? Int.max - let bsr = savedRanks[CGWindowID(b.scWindow.windowID)] ?? Int.max + let asr = savedRanks[CGWindowID(a.window.windowID)] ?? Int.max + let bsr = savedRanks[CGWindowID(b.window.windowID)] ?? Int.max if asr != bsr { return asr < bsr } - return a.scWindow.windowID < b.scWindow.windowID + return a.window.windowID < b.window.windowID } } else if saved.isEmpty { return tiles } else { - let presentIDs = Set(tiles.map { CGWindowID($0.scWindow.windowID) }) + let presentIDs = Set(tiles.map { CGWindowID($0.window.windowID) }) let knownInOrder = saved.filter { presentIDs.contains($0) } let knownIDs = Set(knownInOrder) - let known = knownInOrder.compactMap { wid in tiles.first(where: { CGWindowID($0.scWindow.windowID) == wid }) } - let unknown = tiles.filter { !knownIDs.contains(CGWindowID($0.scWindow.windowID)) } + let known = knownInOrder.compactMap { wid in tiles.first(where: { CGWindowID($0.window.windowID) == wid }) } + let unknown = tiles.filter { !knownIDs.contains(CGWindowID($0.window.windowID)) } return known + unknown } } - private func installTiles(candidates: [SCWindow]) { - let mcTiles: [Tile] = candidates.compactMap { w -> Tile? in - guard let pid = w.owningApplication?.processID else { return nil } - return Tile(scWindow: w, ownerPID: pid) + private func installTiles(candidates: [WindowInfo]) { + let mcTiles: [Tile] = candidates.map { w in + Tile(window: w, ownerPID: w.processID) } let ordered = orderTiles(mcTiles) - savedOrder = ordered.map { CGWindowID($0.scWindow.windowID) } + savedOrder = ordered.map { CGWindowID($0.window.windowID) } allTiles = ordered for t in ordered { window?.contentView?.layer?.addSublayer(t.layer) } rebuildDisplayed() - let widMatch = prevPickedWindowID.flatMap { wid in tiles.firstIndex(where: { CGWindowID($0.scWindow.windowID) == wid }) } - let titleMatch = tiles.firstIndex(where: { $0.ownerPID == prevFrontPID && ($0.scWindow.title ?? "") == prevFrontTitle }) + let widMatch = prevPickedWindowID.flatMap { wid in tiles.firstIndex(where: { CGWindowID($0.window.windowID) == wid }) } + let titleMatch = tiles.firstIndex(where: { $0.ownerPID == prevFrontPID && ($0.window.title ?? "") == prevFrontTitle }) let pidMatch = tiles.firstIndex(where: { $0.ownerPID == prevFrontPID }) if let i = widMatch ?? titleMatch ?? pidMatch { selectedIndex = i @@ -397,7 +377,7 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B t.layer.isHidden = !visibleSet.contains(ObjectIdentifier(t)) t.layer.opacity = 1.0 t.setLabel(nil) - t.tintColorName = paneColors[CGWindowID(t.scWindow.windowID)] + t.tintColorName = paneColors[CGWindowID(t.window.windowID)] } tiles = displayed applyTileLabels() @@ -419,7 +399,7 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B tileLabels = labelAssigner.assign(allTiles) let buffer = pickBuffer for t in allTiles { - let id = CGWindowID(t.scWindow.windowID) + let id = CGWindowID(t.window.windowID) let label = tileLabels[id] let matched: Int if !buffer.isEmpty, let label, label.hasPrefix(buffer) { @@ -439,8 +419,8 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B private static func matches(tile: Tile, query: String) -> Bool { let q = query.trimmingCharacters(in: .whitespaces) if q.isEmpty { return true } - let app = tile.scWindow.owningApplication?.applicationName ?? "" - let title = tile.scWindow.title ?? "" + let app = tile.window.applicationName + let title = tile.window.title ?? "" let haystack = app + " " + title return haystack.localizedCaseInsensitiveContains(q) } @@ -496,7 +476,7 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B private func tagSelectedColor(_ name: String?) { guard tiles.indices.contains(selectedIndex) else { return } - let id = CGWindowID(tiles[selectedIndex].scWindow.windowID) + let id = CGWindowID(tiles[selectedIndex].window.windowID) if let name { paneColors[id] = name } else { paneColors.removeValue(forKey: id) } tiles[selectedIndex].tintColorName = name } @@ -508,7 +488,7 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B let start = lastLetterJump == needle ? selectedIndex + 1 : 0 let order = Array(start.. B guard tiles.indices.contains(selectedIndex) else { return } let tile = tiles[selectedIndex] let pid = tile.ownerPID - let windowID = CGWindowID(tile.scWindow.windowID) + let windowID = CGWindowID(tile.window.windowID) pressCloseButton(pid: pid, windowID: windowID) let removed = tile @@ -569,7 +549,7 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B removed.layer.removeFromSuperlayer() Task { await removed.stop() } - savedOrder = allTiles.map { CGWindowID($0.scWindow.windowID) } + savedOrder = allTiles.map { CGWindowID($0.window.windowID) } if !tiles.indices.contains(selectedIndex) { selectedIndex = max(0, tiles.count - 1) } @@ -654,9 +634,6 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B w?.orderOut(nil) clearLayers() } - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in - self?.prewarmShareable() - } } @@ -673,7 +650,7 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B let (rects, cols) = GridLayout.frames(count: tiles.count, bounds: bounds, aspectRatio: ar) gridCols = cols for (tile, cell) in zip(tiles, rects) { - let src = tile.scWindow.frame + let src = tile.window.frame let srcAR = src.width / max(1, src.height) let cellAR = cell.width / max(1, cell.height) let fitted: CGRect @@ -714,8 +691,8 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B guard tiles.indices.contains(selectedIndex), !isPicking else { return } let tile = tiles[selectedIndex] let pid = tile.ownerPID - let windowID = CGWindowID(tile.scWindow.windowID) - let title = tile.scWindow.title + let windowID = CGWindowID(tile.window.windowID) + let title = tile.window.title prevFrontPID = 0 prevPickedWindowID = windowID isPicking = true @@ -841,7 +818,7 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B let bi = allTiles.firstIndex(where: { $0 === other }) { allTiles.swapAt(ai, bi) } - savedOrder = allTiles.map { CGWindowID($0.scWindow.windowID) } + savedOrder = allTiles.map { CGWindowID($0.window.windowID) } selectedIndex = target renumberTiles() } @@ -870,7 +847,7 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B let bi = allTiles.firstIndex(where: { $0 === b }) { allTiles.swapAt(ai, bi) } - savedOrder = allTiles.map { CGWindowID($0.scWindow.windowID) } + savedOrder = allTiles.map { CGWindowID($0.window.windowID) } selectedIndex = target renumberTiles() layoutTilesAnimated() @@ -896,7 +873,7 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B let bounds = window?.contentView?.bounds ?? .zero let pad: CGFloat = 4 let avail = bounds.insetBy(dx: pad, dy: pad) - let src = tiles[selectedIndex].scWindow.frame + let src = tiles[selectedIndex].window.frame let srcAR = src.width / max(1, src.height) let availAR = avail.width / max(1, avail.height) let target: CGRect @@ -956,12 +933,16 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B "TextInputMenuAgent", "Wallpaper", ] - private static func isCapturable(_ w: SCWindow) -> Bool { - guard let app = w.owningApplication else { return false } - if app.processID == getpid() { return false } - if systemOwners.contains(app.applicationName) { return false } + private static func isCapturable(_ w: WindowInfo) -> Bool { + if w.processID == getpid() { return false } + if w.applicationName.isEmpty { return false } + if systemOwners.contains(w.applicationName) { return false } if w.frame.width < 200 || w.frame.height < 200 { return false } - if !w.isOnScreen && w.windowLayer != 0 { return false } + if !w.isOnScreen && w.layer != 0 { return false } + // CGWindowListCopyWindowInfo returns every layer including menus, + // tooltips, and floating panels. Only the normal window layer (0) is + // user-facing app content. + if w.layer != 0 { return false } return true } @@ -1000,13 +981,13 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B guard config.tilePicksMode == .letters else { return } let candidate = pickBuffer + ch let matches = tiles.filter { tile in - guard let label = tileLabels[CGWindowID(tile.scWindow.windowID)] else { return false } + guard let label = tileLabels[CGWindowID(tile.window.windowID)] else { return false } return label.hasPrefix(candidate) } guard !matches.isEmpty else { return } pickBuffer = candidate if matches.count == 1, matches[0].layer.isHidden == false, - let label = tileLabels[CGWindowID(matches[0].scWindow.windowID)], + let label = tileLabels[CGWindowID(matches[0].window.windowID)], label == candidate { if let idx = tiles.firstIndex(where: { $0 === matches[0] }) { selectedIndex = idx diff --git a/Sources/cmdcmd/SkyLightCapture.swift b/Sources/cmdcmd/SkyLightCapture.swift new file mode 100644 index 0000000..285d37d --- /dev/null +++ b/Sources/cmdcmd/SkyLightCapture.swift @@ -0,0 +1,65 @@ +import CoreGraphics +import Foundation + +/// Thin wrapper around the private SkyLight per-window capture entrypoint. +/// +/// `CGSHWCaptureWindowList` pulls a snapshot directly from the WindowServer's +/// IOSurface backing store for a CGWindowID — the same path Mission Control +/// uses for its live previews. It needs no Screen Recording TCC grant, but is +/// private SPI: every macOS release may rename or remove the symbol. We +/// resolve via `dlsym` and let the caller fall back to ScreenCaptureKit when +/// `shared` is nil. +final class SkyLightCapture: @unchecked Sendable { + static let shared: SkyLightCapture? = SkyLightCapture() + + private typealias MainConnectionIDFn = @convention(c) () -> UInt32 + private typealias CaptureWindowListFn = @convention(c) ( + _ cid: UInt32, + _ windows: UnsafePointer, + _ count: UInt32, + _ options: UInt32 + ) -> Unmanaged? + + private let mainConnection: MainConnectionIDFn + private let captureWindowList: CaptureWindowListFn + private let cid: UInt32 + + // Values from reverse-engineered NUIKit/CGSInternal headers. Pulled + // together they give "give me the full window pixels without WindowServer + // applying the corner-rounding clip mask." + private static let kCGSCaptureNominalResolution: UInt32 = 0x0200 + private static let kCGSCaptureIgnoreGlobalClipShape: UInt32 = 0x0800 + + private init?() { + guard let handle = dlopen("/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight", RTLD_LAZY) else { + Log.write("SkyLightCapture: dlopen failed") + return nil + } + guard let mainSym = dlsym(handle, "CGSMainConnectionID"), + let captureSym = dlsym(handle, "CGSHWCaptureWindowList") else { + Log.write("SkyLightCapture: required symbols missing") + return nil + } + self.mainConnection = unsafeBitCast(mainSym, to: MainConnectionIDFn.self) + self.captureWindowList = unsafeBitCast(captureSym, to: CaptureWindowListFn.self) + self.cid = self.mainConnection() + Log.write("SkyLightCapture: ready cid=\(self.cid)") + } + + /// One-shot capture of a single window. Returns nil if the WindowServer + /// produces no image (e.g. window minimized, gone, or wrong CGS state). + func captureImage(windowID: CGWindowID) -> CGImage? { + var wid: UInt32 = windowID + let opts = Self.kCGSCaptureNominalResolution | Self.kCGSCaptureIgnoreGlobalClipShape + guard let unmanaged = withUnsafePointer(to: &wid, { ptr in + captureWindowList(cid, ptr, 1, opts) + }) else { + return nil + } + let array = unmanaged.takeRetainedValue() as NSArray + guard array.count > 0 else { return nil } + let first = array[0] + guard CFGetTypeID(first as CFTypeRef) == CGImage.typeID else { return nil } + return (first as! CGImage) + } +} diff --git a/Sources/cmdcmd/Tile.swift b/Sources/cmdcmd/Tile.swift index 5b13429..2643b3b 100644 --- a/Sources/cmdcmd/Tile.swift +++ b/Sources/cmdcmd/Tile.swift @@ -1,51 +1,15 @@ import AppKit -import ScreenCaptureKit -import CoreImage -import CoreMedia -import CoreVideo +import CoreGraphics - -final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { +final class Tile: NSObject { static let colorNames = ["green", "blue", "red", "yellow", "orange", "purple"] private static let cacheLock = NSLock() private static var frameCache: [CGWindowID: CGImage] = [:] private static var frameCacheOrder: [CGWindowID] = [] private static let frameCacheLimit = 100 - private static let ciContext = CIContext(options: [.useSoftwareRenderer: false]) - private static let cacheQueue = DispatchQueue(label: "cmdcmd.tile.cache", qos: .utility) - - // SCContentFilter / SCStream init have crashed (objc_retain of an - // SCWindow during concurrent copyWithZone) when tiles install in - // parallel. Serialize the synchronous setup; async work still overlaps. - private static let setupQueue = DispatchQueue(label: "cmdcmd.tile.setup", qos: .userInteractive) - - private static func makeFilter(for window: SCWindow) async -> SCContentFilter { - await withCheckedContinuation { cont in - setupQueue.async { - cont.resume(returning: SCContentFilter(desktopIndependentWindow: window)) - } - } - } - - private static func makeStream( - filter: SCContentFilter, - configuration: SCStreamConfiguration, - output: Tile, - sampleHandlerQueue: DispatchQueue - ) async throws -> SCStream { - try await withCheckedThrowingContinuation { cont in - setupQueue.async { - let stream = SCStream(filter: filter, configuration: configuration, delegate: output) - do { - try stream.addStreamOutput(output, type: .screen, sampleHandlerQueue: sampleHandlerQueue) - cont.resume(returning: stream) - } catch { - cont.resume(throwing: error) - } - } - } - } + private static let captureQueue = DispatchQueue(label: "cmdcmd.tile.capture", qos: .userInteractive, attributes: .concurrent) + private static let pollInterval: TimeInterval = 1.0 / 15.0 static func cachedFrame(for id: CGWindowID) -> CGImage? { cacheLock.lock(); defer { cacheLock.unlock() } @@ -86,7 +50,7 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { return NSColor(srgbRed: r, green: g, blue: b, alpha: 1) } - var scWindow: SCWindow + var window: WindowInfo let ownerPID: pid_t let layer: CALayer private let content: CALayer @@ -97,30 +61,15 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { private let idleDot: CALayer private var lastSignificantChangeAt: CFAbsoluteTime = CFAbsoluteTimeGetCurrent() private(set) var isIdle: Bool = false - private var stream: SCStream? + private var pollTimer: DispatchSourceTimer? private var cancelled = false private var hasRenderedFrame = false private var hasRenderedLiveFrame = false - private var lastPixelBuffer: CVPixelBuffer? var suppressFrames = false - private let queue = DispatchQueue(label: "cmdcmd.tile", qos: .userInteractive) - private var restartAttempts = 0 - private static let maxRestartAttempts = 6 - // Frame-delivery diagnostics. Updated only on the SCStream sample queue. - private var loggedFirstDelivery = false private var loggedFirstLiveFrame = false - private var completeCount = 0 - private var skippedIdle = 0 - private var skippedBlank = 0 - private var skippedSuspended = 0 - private var skippedOther = 0 - private var lastSkipLogAt: CFAbsoluteTime = 0 - private var lastHeartbeatLogAt: CFAbsoluteTime = 0 - private var lastFrameAt: CFAbsoluteTime = 0 - private var watchdog: DispatchSourceTimer? - - init(scWindow: SCWindow, ownerPID: pid_t) { - self.scWindow = scWindow + + init(window: WindowInfo, ownerPID: pid_t) { + self.window = window self.ownerPID = ownerPID let outer = CALayer() @@ -188,10 +137,14 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { self.titlePill = pill self.titleText = pillText self.idleDot = dot - self.windowTitle = scWindow.title ?? "" + // kCGWindowName needs Screen Recording on macOS 12.3+, which we no + // longer ask for. Fall back to the owning-app name so the pill still + // labels each tile. + let rawTitle = (window.title ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + self.windowTitle = rawTitle.isEmpty ? window.applicationName : rawTitle super.init() - if let cached = Tile.cachedFrame(for: CGWindowID(scWindow.windowID)) { + if let cached = Tile.cachedFrame(for: window.windowID) { CATransaction.begin() CATransaction.setDisableActions(true) inner.contents = cached @@ -386,307 +339,96 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { CATransaction.commit() } - private static let thumbFloor: CGFloat = 700 - private static let thumbCeiling: CGFloat = 2200 - private static let thumbHeadroom: CGFloat = 1.5 - - /// Target snapshot resolution (longest side, in pixels) sized to the tile's - /// current on-screen footprint. Many small tiles → smaller thumbs; a few - /// large tiles → sharper thumbs. Live capture is unaffected and always - /// runs at full window resolution. - private func currentThumbMaxDim() -> CGFloat { - let scale = NSScreen.main?.backingScaleFactor ?? 2 - let longest = max(layer.frame.width, layer.frame.height) - guard longest > 0 else { return 1400 } - let target = longest * scale * Self.thumbHeadroom - return max(Self.thumbFloor, min(Self.thumbCeiling, target)) - } - - private func captureConfig(maxDim: CGFloat? = nil) -> SCStreamConfiguration { - let config = SCStreamConfiguration() - let scale = NSScreen.main?.backingScaleFactor ?? 2 - var w = scWindow.frame.width * scale - var h = scWindow.frame.height * scale - if let m = maxDim { - let largest = max(w, h) - if largest > m { - let factor = m / largest - w *= factor - h *= factor - } + /// Single-shot SkyLight capture used to seed the tile before the live + /// poll has a frame. Cheap to call: returns nil and lets the cached + /// thumbnail keep showing if SkyLight has nothing for this window yet. + func snapshot() async { + if cancelled || hasRenderedLiveFrame { return } + guard let sl = SkyLightCapture.shared else { return } + let wid = window.windowID + let image: CGImage? = await withCheckedContinuation { (cont: CheckedContinuation) in + Tile.captureQueue.async { cont.resume(returning: sl.captureImage(windowID: wid)) } + } + guard let image else { return } + if cancelled || hasRenderedLiveFrame { return } + Tile.setCachedFrame(image, for: wid) + await MainActor.run { + guard !self.hasRenderedLiveFrame else { return } + CATransaction.begin() + CATransaction.setDisableActions(true) + self.content.contents = image + CATransaction.commit() + self.hasRenderedFrame = true + self.lastSignificantChangeAt = CFAbsoluteTimeGetCurrent() } - config.width = max(64, Int(w)) - config.height = max(64, Int(h)) - config.pixelFormat = kCVPixelFormatType_32BGRA - config.showsCursor = false - config.scalesToFit = true - config.ignoreShadowsSingleWindow = true - return config } - private func captureImageSafely(filter: SCContentFilter, config: SCStreamConfiguration) async throws -> CGImage { - try await withCheckedThrowingContinuation { cont in - SCScreenshotManager.captureImage(contentFilter: filter, configuration: config) { image, error in - if let image { - cont.resume(returning: image) - } else { - cont.resume(throwing: error ?? NSError( - domain: "cmdcmd.Tile", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "captureImage returned no image"])) - } - } - } + /// Start a 15 fps SkyLight poll. No-op if SkyLight is unavailable on this + /// macOS — tiles then just keep their cached thumbnail. + func start() async { + if cancelled { return } + guard let sl = SkyLightCapture.shared else { return } + await MainActor.run { self.startPolling(sl: sl) } } - func snapshot() async { - if cancelled || hasRenderedLiveFrame { return } - let filter = await Tile.makeFilter(for: scWindow) - if cancelled || hasRenderedLiveFrame { return } - let config = captureConfig(maxDim: currentThumbMaxDim()) - do { - let image = try await captureImageSafely(filter: filter, config: config) - if cancelled || hasRenderedLiveFrame { return } - Tile.setCachedFrame(image, for: CGWindowID(scWindow.windowID)) - await MainActor.run { - guard !self.hasRenderedLiveFrame else { return } + private func startPolling(sl: SkyLightCapture) { + stopPolling() + let wid = window.windowID + let t = DispatchSource.makeTimerSource(queue: Tile.captureQueue) + t.schedule(deadline: .now(), repeating: Tile.pollInterval, leeway: .milliseconds(10)) + t.setEventHandler { [weak self] in + guard let self, !self.cancelled, !self.suppressFrames else { return } + guard let image = sl.captureImage(windowID: wid) else { return } + Tile.setCachedFrame(image, for: wid) + DispatchQueue.main.async { [weak self] in + guard let self, !self.cancelled, !self.suppressFrames else { return } CATransaction.begin() CATransaction.setDisableActions(true) self.content.contents = image CATransaction.commit() self.hasRenderedFrame = true + self.hasRenderedLiveFrame = true self.lastSignificantChangeAt = CFAbsoluteTimeGetCurrent() + if !self.loggedFirstLiveFrame { + self.loggedFirstLiveFrame = true + Log.write("tile first live frame wid=\(wid) size=\(image.width)x\(image.height)") + } } - } catch { - Log.write("tile snapshot failed wid=\(scWindow.windowID): \(error)") - } - } - - func start() async { - if cancelled { return } - let filter = await Tile.makeFilter(for: scWindow) - if cancelled { return } - let config = captureConfig() - config.minimumFrameInterval = CMTime(value: 1, timescale: 30) - config.queueDepth = 3 - - do { - let s = try await Tile.makeStream(filter: filter, configuration: config, output: self, sampleHandlerQueue: queue) - if cancelled { return } - try await s.startCapture() - if cancelled { - try? await s.stopCapture() - return - } - self.stream = s - let title = scWindow.title ?? "" - let bid = scWindow.owningApplication?.bundleIdentifier ?? "?" - Log.write("tile stream started wid=\(scWindow.windowID) bid=\(bid) title=\"\(title)\" size=\(config.width)x\(config.height) attempt=\(restartAttempts)") - startWatchdog() - } catch { - Log.write("tile start failed wid=\(scWindow.windowID): \(Tile.describe(error))") } + t.resume() + pollTimer = t } - private static func describe(_ error: Error) -> String { - let ns = error as NSError - var info = "domain=\(ns.domain) code=\(ns.code)" - if let underlying = ns.userInfo[NSUnderlyingErrorKey] as? NSError { - info += " underlying=\(underlying.domain)/\(underlying.code)" - } - if let reason = ns.localizedFailureReason { - info += " reason=\"\(reason)\"" - } - info += " desc=\"\(ns.localizedDescription)\"" - return info + private func stopPolling() { + pollTimer?.cancel() + pollTimer = nil } - private func cacheLastFrameDeferred() { - let id = CGWindowID(self.scWindow.windowID) - let q = self.queue - let cap = currentThumbMaxDim() - Tile.cacheQueue.async { - var pb: CVPixelBuffer? - q.sync { - pb = self.lastPixelBuffer - self.lastPixelBuffer = nil - } - guard let pb else { return } - let ci = CIImage(cvPixelBuffer: pb) - let extent = ci.extent - let largest = max(extent.width, extent.height) - let factor = largest > cap ? cap / largest : 1 - let scaled = factor < 1 - ? ci.transformed(by: CGAffineTransform(scaleX: factor, y: factor)) - : ci - if let cg = Tile.ciContext.createCGImage(scaled, from: scaled.extent) { - Tile.setCachedFrame(cg, for: id) - } - } + func stop() async { + cancelled = true + suppressFrames = true + stopPolling() } - func stop() async { + func stopSync(group: DispatchGroup) { cancelled = true suppressFrames = true - stopWatchdog() - cacheLastFrameDeferred() - guard let s = stream else { return } - self.stream = nil - try? await s.stopCapture() + stopPolling() } - /// Underlying window resized after capture started. Tear down the existing - /// stream (its config is fixed at the old dimensions) and rebuild from the - /// fresh `scWindow.frame`. + /// Underlying window resized after capture started. The polled SkyLight + /// path reads the current backing store on every tick, so we just need to + /// reset the "first frame" gates and let the next poll repaint. func refreshAfterResize(live: Bool) async { - if cancelled { return } - if let s = stream { - self.stream = nil - stopWatchdog() - try? await s.stopCapture() - } if cancelled { return } hasRenderedLiveFrame = false loggedFirstLiveFrame = false await snapshot() - if live && !cancelled { + if live && !cancelled, pollTimer == nil { await start() } } - func stopSync(group: DispatchGroup) { - cancelled = true - suppressFrames = true - stopWatchdog() - cacheLastFrameDeferred() - guard let s = stream else { return } - self.stream = nil - group.enter() - s.stopCapture { _ in group.leave() } - } - - func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { - if cancelled { return } - guard type == .screen, sampleBuffer.isValid, - let pixelBuffer = sampleBuffer.imageBuffer, - let surface = CVPixelBufferGetIOSurface(pixelBuffer)?.takeUnretainedValue() else { return } - - let attachments = (CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) as? [[SCStreamFrameInfo: Any]])?.first - let statusRaw = (attachments?[.status] as? Int).flatMap(SCFrameStatus.init(rawValue:)) - if !loggedFirstDelivery { - loggedFirstDelivery = true - Log.write("tile first delivery wid=\(scWindow.windowID) status=\(Tile.describe(statusRaw))") - } - if statusRaw == .idle || statusRaw == .blank || statusRaw == .suspended { - switch statusRaw { - case .idle: skippedIdle += 1 - case .blank: skippedBlank += 1 - case .suspended: skippedSuspended += 1 - default: skippedOther += 1 - } - maybeLogSkipSummary() - return - } - if statusRaw == .complete { - completeCount += 1 - lastFrameAt = CFAbsoluteTimeGetCurrent() - maybeLogHeartbeat() - } - - var significantChange = false - if let attachments { - let dirtyRectsRaw = attachments[.dirtyRects] as? [[String: Any]] ?? [] - var dirtyArea: CGFloat = 0 - for d in dirtyRectsRaw { - if let r = CGRect(dictionaryRepresentation: d as CFDictionary) { - dirtyArea += r.width * r.height - } - } - var totalArea: CGFloat = 0 - if let crDict = attachments[.contentRect] as? [String: Any], - let cr = CGRect(dictionaryRepresentation: crDict as CFDictionary) { - totalArea = cr.width * cr.height - } - if totalArea > 0, dirtyArea / totalArea > 0.005 { - lastSignificantChangeAt = CFAbsoluteTimeGetCurrent() - significantChange = true - } - } - - self.lastPixelBuffer = pixelBuffer - if restartAttempts != 0 { restartAttempts = 0 } - - if !loggedFirstLiveFrame { - loggedFirstLiveFrame = true - let w = CVPixelBufferGetWidth(pixelBuffer) - let h = CVPixelBufferGetHeight(pixelBuffer) - Log.write("tile first live frame wid=\(scWindow.windowID) status=\(Tile.describe(statusRaw)) size=\(w)x\(h) skipped(idle=\(skippedIdle) blank=\(skippedBlank) suspended=\(skippedSuspended) other=\(skippedOther))") - } - - if suppressFrames { return } - - DispatchQueue.main.async { [weak self] in - guard let self, !self.suppressFrames else { return } - CATransaction.begin() - CATransaction.setDisableActions(true) - self.content.contents = surface - CATransaction.commit() - self.hasRenderedFrame = true - self.hasRenderedLiveFrame = true - } - } - - private func maybeLogSkipSummary() { - let now = CFAbsoluteTimeGetCurrent() - if lastSkipLogAt == 0 { lastSkipLogAt = now; return } - if now - lastSkipLogAt < 5 { return } - lastSkipLogAt = now - Log.write("tile skip summary wid=\(scWindow.windowID) live=\(loggedFirstLiveFrame) complete=\(completeCount) idle=\(skippedIdle) blank=\(skippedBlank) suspended=\(skippedSuspended) other=\(skippedOther)") - } - - private func maybeLogHeartbeat() { - let now = CFAbsoluteTimeGetCurrent() - if lastHeartbeatLogAt == 0 { lastHeartbeatLogAt = now; return } - if now - lastHeartbeatLogAt < 5 { return } - lastHeartbeatLogAt = now - Log.write("tile heartbeat wid=\(scWindow.windowID) complete=\(completeCount) skipped(idle=\(skippedIdle) blank=\(skippedBlank) suspended=\(skippedSuspended) other=\(skippedOther))") - } - - /// Fires every 5s; if no frame arrived in the last 5s, log a stall. - private func startWatchdog() { - stopWatchdog() - let t = DispatchSource.makeTimerSource(queue: queue) - t.schedule(deadline: .now() + 5, repeating: 5) - t.setEventHandler { [weak self] in - guard let self, !self.cancelled else { return } - let now = CFAbsoluteTimeGetCurrent() - let elapsed = self.lastFrameAt == 0 ? -1 : now - self.lastFrameAt - if self.lastFrameAt == 0 || elapsed > 5 { - Log.write("tile stall wid=\(self.scWindow.windowID) sinceLastFrame=\(String(format: "%.1f", elapsed))s complete=\(self.completeCount) skipped(idle=\(self.skippedIdle) blank=\(self.skippedBlank) suspended=\(self.skippedSuspended) other=\(self.skippedOther)) streamAlive=\(self.stream != nil)") - } - } - t.resume() - watchdog = t - } - - private func stopWatchdog() { - watchdog?.cancel() - watchdog = nil - } - - private static func describe(_ status: SCFrameStatus?) -> String { - guard let status else { return "nil" } - switch status { - case .complete: return "complete" - case .idle: return "idle" - case .blank: return "blank" - case .suspended: return "suspended" - case .started: return "started" - case .stopped: return "stopped" - @unknown default: return "raw(\(status.rawValue))" - } - } - func updateActivity(now: CFAbsoluteTime) { let elapsed = now - lastSignificantChangeAt let activeWithin: CFTimeInterval = 0.5 @@ -707,52 +449,4 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { idleDot.opacity = target CATransaction.commit() } - - func stream(_ stream: SCStream, didStopWithError error: Error) { - let title = scWindow.title ?? "" - let bid = scWindow.owningApplication?.bundleIdentifier ?? "?" - Log.write("tile stream stopped wid=\(scWindow.windowID) bid=\(bid) title=\"\(title)\" hadFrame=\(hasRenderedLiveFrame) suppressed=\(suppressFrames) cancelled=\(cancelled) complete=\(completeCount) skipped(idle=\(skippedIdle) blank=\(skippedBlank) suspended=\(skippedSuspended) other=\(skippedOther)): \(Tile.describe(error))") - self.stream = nil - stopWatchdog() - promoteLastFrameToLayer() - if cancelled { return } - let attempt = restartAttempts + 1 - restartAttempts = attempt - guard attempt <= Self.maxRestartAttempts else { - Log.write("tile giving up restart wid=\(scWindow.windowID) after \(attempt) attempts") - return - } - let delay = min(5.0, 0.5 * Double(attempt)) - Log.write("tile scheduling restart wid=\(scWindow.windowID) attempt=\(attempt) delay=\(delay)s") - Task { [weak self] in - try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) - guard let self, !self.cancelled, self.stream == nil else { return } - await self.start() - } - } - - /// Convert the most recent pixel buffer into a CGImage and assign it to the - /// tile's layer. Used when the live stream stops so the tile retains a - /// stable image instead of a recycled IOSurface. - private func promoteLastFrameToLayer() { - let q = self.queue - Tile.cacheQueue.async { [weak self] in - guard let self else { return } - var pb: CVPixelBuffer? - q.sync { - pb = self.lastPixelBuffer - } - guard let pb else { return } - let ci = CIImage(cvPixelBuffer: pb) - guard let cg = Tile.ciContext.createCGImage(ci, from: ci.extent) else { return } - Tile.setCachedFrame(cg, for: CGWindowID(self.scWindow.windowID)) - DispatchQueue.main.async { [weak self] in - guard let self, !self.cancelled else { return } - CATransaction.begin() - CATransaction.setDisableActions(true) - self.content.contents = cg - CATransaction.commit() - } - } - } } diff --git a/Sources/cmdcmd/WindowInfo.swift b/Sources/cmdcmd/WindowInfo.swift new file mode 100644 index 0000000..7bc5506 --- /dev/null +++ b/Sources/cmdcmd/WindowInfo.swift @@ -0,0 +1,56 @@ +import AppKit +import CoreGraphics + +/// Plain snapshot of the per-window facts we used to lean on `SCWindow` for. +/// Populated from `CGWindowListCopyWindowInfo` + `NSRunningApplication` so the +/// app never needs to spin up ScreenCaptureKit just to enumerate windows +/// (which would light the screen-recording indicator). +struct WindowInfo { + let windowID: CGWindowID + let frame: CGRect + let title: String? + let applicationName: String + let bundleIdentifier: String? + let processID: pid_t + let layer: Int + let isOnScreen: Bool + + /// All currently on-screen windows, in WindowServer Z-order (front-most first). + static func enumerate() -> [WindowInfo] { + let opts: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements] + guard let raw = CGWindowListCopyWindowInfo(opts, kCGNullWindowID) as? [[String: Any]] else { + return [] + } + var bundleCache: [pid_t: String?] = [:] + return raw.compactMap { entry in + guard let id = entry[kCGWindowNumber as String] as? UInt32, + let pidNum = entry[kCGWindowOwnerPID as String] as? Int32, + let boundsDict = entry[kCGWindowBounds as String] as? [String: Any], + let frame = CGRect(dictionaryRepresentation: boundsDict as CFDictionary) + else { return nil } + let pid = pid_t(pidNum) + let owner = (entry[kCGWindowOwnerName as String] as? String) ?? "" + let title = entry[kCGWindowName as String] as? String + let layer = (entry[kCGWindowLayer as String] as? Int) ?? 0 + let onScreen = (entry[kCGWindowIsOnscreen as String] as? Bool) ?? false + let bundleID: String? + if let cached = bundleCache[pid] { + bundleID = cached + } else { + let resolved = NSRunningApplication(processIdentifier: pid)?.bundleIdentifier + bundleCache[pid] = resolved + bundleID = resolved + } + return WindowInfo( + windowID: CGWindowID(id), + frame: frame, + title: title, + applicationName: owner, + bundleIdentifier: bundleID, + processID: pid, + layer: layer, + isOnScreen: onScreen + ) + } + } +} diff --git a/Sources/cmdcmd/main.swift b/Sources/cmdcmd/main.swift index 6fe62bd..613411d 100644 --- a/Sources/cmdcmd/main.swift +++ b/Sources/cmdcmd/main.swift @@ -1,6 +1,5 @@ import AppKit import CoreGraphics -import ScreenCaptureKit import Sparkle let args = CommandLine.arguments @@ -142,9 +141,6 @@ appDelegate.settingsFactory = { } func startApp() { - Task { - _ = try? await SCShareableContent.current - } let fire = { overlay.toggle() dumpState(tracker: tracker)