feat: improve thumbnails with screen-capture kit
BREAKING CHANGE: Better thumbnails with ScreenCaptureKit for users on macOS >= 14 * No more WindowServer memory leaks (closes #4255) * No more purple UI from macOS bothering users * Fullscreen thumbnails contain top part (closes #3652) * Thumbnails contain dialogs (e.g. Save/Print panel) * Show thumbnails for off-screen windows * Memory usage drastically reduced (e.g. don't save full-resolution if Preview feature is not used) * Limitations of ScreenCaptureKit compared to private API: can't capture minimized/hidden/tabbed windows
This commit is contained in:
1 parent
e2b47790a9
commit
7821d7c979
20 files changed
+393
-89
No files matched your search
@@ -63,6 +63,7 @@
|
||||
4807A6C623A9CD190052A53E /* SkyLight.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4807A6C523A9CD190052A53E /* SkyLight.framework */; };
|
||||
48F3E16224EC0D8800A1C64B /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BABDA79F8EF76E3ACDD5F /* Localizable.strings */; };
|
||||
5883F0A829A5182C0071DB65 /* TrackpadEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5883F0A729A5182C0071DB65 /* TrackpadEvents.swift */; };
|
||||
5F04852E2F09AC9700235D4A /* WindowCaptureEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F04852D2F09AC9700235D4A /* WindowCaptureEvents.swift */; };
|
||||
5F21C9E02C6E94240091F72F /* ShortcutsWhenActiveSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F21C9DF2C6E94240091F72F /* ShortcutsWhenActiveSheet.swift */; };
|
||||
5F21C9E22C6E94410091F72F /* AdditionalControlsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F21C9E12C6E94410091F72F /* AdditionalControlsSheet.swift */; };
|
||||
5F21C9E42C6E94700091F72F /* CustomizeStyleSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F21C9E32C6E94700091F72F /* CustomizeStyleSheet.swift */; };
|
||||
@@ -353,6 +354,7 @@
|
||||
481FE54624D2D387001032F1 /* alt-tab-macos-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "alt-tab-macos-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
5883F0A729A5182C0071DB65 /* TrackpadEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackpadEvents.swift; sourceTree = "<group>"; };
|
||||
59DD4BE63D42EEFF1182BA7F /* Pods-alt-tab-macos.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-alt-tab-macos.release.xcconfig"; path = "Target Support Files/Pods-alt-tab-macos/Pods-alt-tab-macos.release.xcconfig"; sourceTree = "<group>"; };
|
||||
5F04852D2F09AC9700235D4A /* WindowCaptureEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowCaptureEvents.swift; sourceTree = "<group>"; };
|
||||
5F084EAF2EF15E6B00CD212A /* TabbedWindowDetection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabbedWindowDetection.swift; sourceTree = "<group>"; };
|
||||
5F21C9DF2C6E94240091F72F /* ShortcutsWhenActiveSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsWhenActiveSheet.swift; sourceTree = "<group>"; };
|
||||
5F21C9E12C6E94410091F72F /* AdditionalControlsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdditionalControlsSheet.swift; sourceTree = "<group>"; };
|
||||
@@ -1881,6 +1883,7 @@
|
||||
D04BAC8857A527C2E15D6598 /* events */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5F04852D2F09AC9700235D4A /* WindowCaptureEvents.swift */,
|
||||
D04BA8FDCF137D892114F5F3 /* AccessibilityEvents.swift */,
|
||||
D04BA4A621A8A15660F973C1 /* KeyboardEvents.swift */,
|
||||
D04BA76AEE37D6656EB80126 /* RunningApplicationsEvents.swift */,
|
||||
@@ -2446,6 +2449,7 @@
|
||||
1C96108A0CB2565233FE100B /* TableGroupView.swift in Sources */,
|
||||
1C9618872DD3863A8EFE8414 /* Appearance.swift in Sources */,
|
||||
BF0C83578F7292F307E30751 /* PopupButtonLikeSystemSettings.swift in Sources */,
|
||||
5F04852E2F09AC9700235D4A /* WindowCaptureEvents.swift in Sources */,
|
||||
1C961F9CF4F1BEDF04ACAEE0 /* Switch.swift in Sources */,
|
||||
1C96182D2ECA735740ED474E /* ImageTextButtonView.swift in Sources */,
|
||||
BF0C860F1779DDE8C0171EA0 /* WindowlessAppIndicator.swift in Sources */,
|
||||
|
||||
@@ -15,24 +15,6 @@ extension CGWindowID {
|
||||
return CGSCopySpacesForWindows(CGS_CONNECTION, CGSSpaceMask.all.rawValue, [self] as CFArray) as! [CGSSpaceID]
|
||||
}
|
||||
|
||||
func screenshot() -> CGImage? {
|
||||
// we use CGSHWCaptureWindowList because it can screenshot minimized windows, which CGWindowListCreateImage can't
|
||||
var windowId_ = self
|
||||
let list = CGSHWCaptureWindowList(CGS_CONNECTION, &windowId_, 1, [.ignoreGlobalClipShape, .bestResolution, .fullSize]).takeRetainedValue() as! [CGImage]
|
||||
return list.first
|
||||
// // CGWindowListCreateImage
|
||||
// return CGWindowListCreateImage(.null, .optionIncludingWindow, self, [.boundsIgnoreFraming, .bestResolution])
|
||||
//
|
||||
// // CGSCaptureWindowsContentsToRectWithOptions
|
||||
// var windowId_ = self
|
||||
// if Testt.sampleCgImage == nil {
|
||||
// Testt.sampleCgImage = CGWindowListCreateImage(.null, .optionIncludingWindow, self, [.boundsIgnoreFraming, .bestResolution])!
|
||||
// }
|
||||
// var image = Testt.sampleCgImage!
|
||||
// CGSCaptureWindowsContentsToRectWithOptions(cgsMainConnectionId, &windowId_, true, .zero, [.windowCaptureNominalResolution, .captureIgnoreGlobalClipShape], &image)
|
||||
// return image
|
||||
}
|
||||
|
||||
private func cgProperty<T>(_ key: String, _ type: T.Type) -> T? {
|
||||
var value: AnyObject?
|
||||
CGSCopyWindowProperty(CGS_CONNECTION, self, key as CFString, &value)
|
||||
|
||||
@@ -221,6 +221,15 @@ extension CGImage {
|
||||
}
|
||||
}
|
||||
|
||||
extension CVPixelBuffer {
|
||||
func size() -> NSSize {
|
||||
NSSize(
|
||||
width: CVPixelBufferGetWidth(self),
|
||||
height: CVPixelBufferGetHeight(self)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension pid_t {
|
||||
func isZombie() -> Bool {
|
||||
var kinfo = kinfo_proc()
|
||||
|
||||
@@ -51,7 +51,7 @@ class BackgroundWork {
|
||||
missionControlThread = BackgroundThreadWithRunLoop("missionControl", .userInteractive)
|
||||
// we listen to CLI commands (CFMessagePort events)
|
||||
cliEventsThread = BackgroundThreadWithRunLoop("cliMessages", .userInteractive)
|
||||
// logThreadsAndQueuesOnRepeat()
|
||||
// logThreadsAndQueuesOnRepeat()
|
||||
}
|
||||
|
||||
static func startCrashReportsQueue() {
|
||||
@@ -154,6 +154,10 @@ class BackgroundWork {
|
||||
|
||||
class LabeledOperationQueue: OperationQueue, @unchecked Sendable {
|
||||
let strongUnderlyingQueue: DispatchQueue
|
||||
private var _activeCallbacks: Int32 = 0
|
||||
var activeCallbacks: Int {
|
||||
Int(OSAtomicAdd32(0, &_activeCallbacks))
|
||||
}
|
||||
|
||||
init(_ label: String, _ qos: DispatchQoS, _ maxConcurrentOperationCount: Int) {
|
||||
strongUnderlyingQueue = DispatchQueue(label: label, attributes: [.concurrent])
|
||||
@@ -173,3 +177,12 @@ class LabeledOperationQueue: OperationQueue, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension LabeledOperationQueue {
|
||||
@inline(__always)
|
||||
func trackCallbacks<T>(_ body: () throws -> T) rethrows -> T {
|
||||
OSAtomicIncrement32(&_activeCallbacks)
|
||||
defer { OSAtomicDecrement32(&_activeCallbacks) }
|
||||
return try body()
|
||||
}
|
||||
}
|
||||
@@ -134,8 +134,13 @@ class ScreenRecordingPermission {
|
||||
@available(macOS 12.3, *)
|
||||
private static func checkWithSCShareableContent() -> Bool {
|
||||
return runWithTimeout { completion in
|
||||
SCShareableContent.getWithCompletionHandler { shareableContent, error in
|
||||
SCShareableContent.getExcludingDesktopWindows(true, onScreenWindowsOnly: false) { shareableContent, error in
|
||||
// this callback runs on a GCD queue, not on the thread that called getWithCompletionHandler
|
||||
if #available(macOS 14.0, *), let shareableContent, error == nil {
|
||||
DispatchQueue.main.async {
|
||||
WindowCaptureScreenshots.cachedSCWindows = shareableContent.windows
|
||||
}
|
||||
}
|
||||
completion(error != nil ? false : (shareableContent != nil))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ class Window {
|
||||
var lastFocusOrder = Int.zero
|
||||
var creationOrder = Int.zero
|
||||
var title: String!
|
||||
var thumbnail: CGImage?
|
||||
var thumbnail: CALayerContents?
|
||||
var icon: CGImage? { get { application.icon } }
|
||||
var shouldShowTheUser = true
|
||||
var isTabbed: Bool = false
|
||||
@@ -94,13 +94,13 @@ class Window {
|
||||
CFRunLoopAddSource(BackgroundWork.accessibilityEventsThread.runLoop, AXObserverGetRunLoopSource(axObserver), .commonModes)
|
||||
}
|
||||
|
||||
func refreshThumbnail(_ screenshot: CGImage) {
|
||||
func refreshThumbnail(_ screenshot: CALayerContents) {
|
||||
thumbnail = screenshot
|
||||
if !App.app.appIsBeingUsed || !shouldShowTheUser { return }
|
||||
if let position, let size,
|
||||
let view = (ThumbnailsView.recycledViews.first { $0.window_?.cgWindowId == cgWindowId }) {
|
||||
if !view.thumbnail.isHidden {
|
||||
let thumbnailSize = ThumbnailView.thumbnailSize(screenshot, false)
|
||||
let thumbnailSize = ThumbnailView.thumbnailSize(screenshot.size(), false)
|
||||
let newSize = thumbnailSize.width != view.thumbnail.frame.width || thumbnailSize.height != view.thumbnail.frame.height
|
||||
view.thumbnail.updateWithResizedCopy(screenshot, thumbnailSize)
|
||||
// if the thumbnail size has changed, we need to refresh the open UI
|
||||
|
||||
+4
-15
@@ -339,21 +339,10 @@ class Windows {
|
||||
}
|
||||
}
|
||||
guard (!eligibleWindows.isEmpty || windowRemoved) else { return }
|
||||
screenshotEligibleWindowsAndUpdateUi(eligibleWindows, source)
|
||||
}
|
||||
|
||||
private static func screenshotEligibleWindowsAndUpdateUi(_ eligibleWindows: [Window], _ source: RefreshCausedBy) {
|
||||
for window in eligibleWindows {
|
||||
BackgroundWork.screenshotsQueue.addOperation { [weak window] in
|
||||
if source == .refreshOnlyThumbnailsAfterShowUi && !App.app.appIsBeingUsed { return }
|
||||
if let wid = window?.cgWindowId, let cgImage = wid.screenshot() {
|
||||
if source == .refreshOnlyThumbnailsAfterShowUi && !App.app.appIsBeingUsed { return }
|
||||
DispatchQueue.main.async { [weak window] in
|
||||
if source == .refreshOnlyThumbnailsAfterShowUi && !App.app.appIsBeingUsed { return }
|
||||
window?.refreshThumbnail(cgImage)
|
||||
}
|
||||
}
|
||||
}
|
||||
if #available(macOS 14.0, *) {
|
||||
WindowCaptureScreenshots.oneTimeScreenshots(eligibleWindows, source)
|
||||
} else {
|
||||
WindowCaptureScreenshotsPrivateApi.oneTimeScreenshots(eligibleWindows, source)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ class AccessibilityEvents {
|
||||
let position = try element.position()
|
||||
DispatchQueue.main.async { [weak window] in
|
||||
if let window = (window != nil ? window : (Windows.list.first { $0.isEqualRobust(element, wid) })) {
|
||||
let needsRefresh = window.size != size || window.position != position || window.isMinimized != isMinimized || window.isFullscreen != isFullscreen
|
||||
window.title = window.bestEffortTitle(title)
|
||||
window.size = size
|
||||
window.position = position
|
||||
@@ -23,7 +24,9 @@ class AccessibilityEvents {
|
||||
window.isFullscreen = isFullscreen
|
||||
App.app.checkIfShortcutsShouldBeDisabled(window, nil)
|
||||
}
|
||||
App.app.refreshOpenUi([window], .refreshUiAfterExternalEvent)
|
||||
if needsRefresh {
|
||||
App.app.refreshOpenUi([window], .refreshUiAfterExternalEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ class SpacesEvents {
|
||||
@objc private static func handleEvent(_ notification: Notification) {
|
||||
ScreensEvents.debouncerScreenAndSpace.debounce(.spaceEvent) {
|
||||
Logger.debug { notification.name.rawValue }
|
||||
ThumbnailsPanel.updateMaxPossibleThumbnailSize()
|
||||
// Workaround for Safari full-screen videos
|
||||
// when full-screening a video, Safari spawns a second full-screen window called "Safari"
|
||||
// this window doesn't emit resize/move events. It doesn't pass isActualWindow on creation. It's added on focusedWindowChanged
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import Cocoa
|
||||
import ScreenCaptureKit
|
||||
|
||||
@available(macOS 14.0, *)
|
||||
class WindowCaptureScreenshots {
|
||||
// SCShareableContent.getExcludingDesktopWindows is expensive for the OS; we cache as much as possible
|
||||
static var cachedSCWindows = [SCWindow]()
|
||||
|
||||
static func oneTimeScreenshots(_ windowsToScreenshot: [Window], _ source: RefreshCausedBy) {
|
||||
let windows = windowsToScreenshot.compactMap { $0.cgWindowId }
|
||||
guard !windows.isEmpty else { return }
|
||||
let (cachedWindows, notCachedWindows) = sortCachedAndNotCached(windows)
|
||||
Logger.debug { "cached:\(cachedWindows.map { $0.windowID }) notCached:\(notCachedWindows)" }
|
||||
handleCachedWindows(cachedWindows, source)
|
||||
handleNotCachedWindows(notCachedWindows, source)
|
||||
}
|
||||
|
||||
private static func handleCachedWindows(_ cachedWindows: [SCWindow], _ source: RefreshCausedBy) {
|
||||
guard !cachedWindows.isEmpty else { return }
|
||||
for cachedWindow in cachedWindows {
|
||||
oneTimeCapture(cachedWindow, source)
|
||||
}
|
||||
}
|
||||
|
||||
private static func handleNotCachedWindows(_ notCachedWindows: [CGWindowID], _ source: RefreshCausedBy) {
|
||||
guard !notCachedWindows.isEmpty else { return }
|
||||
SCShareableContent.getExcludingDesktopWindows(true, onScreenWindowsOnly: false) { shareableContent, error in
|
||||
guard let shareableContent, error == nil else { Logger.error { "\(shareableContent == nil) \(error)" }; return }
|
||||
guard source != .refreshOnlyThumbnailsAfterShowUi || App.app.appIsBeingUsed else { return }
|
||||
// this callback is executed on an undetermined queue; we move execution to main-thread
|
||||
DispatchQueue.main.async {
|
||||
cachedSCWindows = shareableContent.windows
|
||||
guard source != .refreshOnlyThumbnailsAfterShowUi || App.app.appIsBeingUsed else { return }
|
||||
for notCachedWindow in notCachedWindows {
|
||||
if let cachedWindow = (cachedSCWindows.first { $0.windowID == notCachedWindow }) {
|
||||
oneTimeCapture(cachedWindow, source)
|
||||
} else {
|
||||
Logger.error { "wid:\(notCachedWindow) was not found in SCShareableContent windows" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func sortCachedAndNotCached(_ windows: [CGWindowID]) -> ([SCWindow], [CGWindowID]) {
|
||||
var cachedWindows = [SCWindow]()
|
||||
var notCachedWindows = [CGWindowID]()
|
||||
for window in windows {
|
||||
if let cachedWindow = (cachedSCWindows.first { $0.windowID == window }) {
|
||||
cachedWindows.append(cachedWindow)
|
||||
} else {
|
||||
notCachedWindows.append(window)
|
||||
}
|
||||
}
|
||||
return (cachedWindows, notCachedWindows)
|
||||
}
|
||||
|
||||
private static func oneTimeCapture(_ scWindow: SCWindow, _ source: RefreshCausedBy) {
|
||||
let config = SCStreamConfiguration.forWindow(scWindow, false)
|
||||
let filter = SCContentFilter(desktopIndependentWindow: scWindow)
|
||||
SCScreenshotManager.captureSampleBuffer(contentFilter: filter, configuration: config) { sampleBuffer, error in
|
||||
guard let sampleBuffer, error == nil else { Logger.error { "\(sampleBuffer == nil) \(error)" }; return }
|
||||
SCScreenshotManager.captureImage(contentFilter: filter, configuration: config) { cgImage, error in
|
||||
guard let cgImage, error == nil else { Logger.error { "\(cgImage == nil) \(error)" }; return }
|
||||
guard source != .refreshOnlyThumbnailsAfterShowUi || App.app.appIsBeingUsed else { return }
|
||||
DispatchQueue.main.async {
|
||||
guard source != .refreshOnlyThumbnailsAfterShowUi || App.app.appIsBeingUsed else { return }
|
||||
if let window = (Windows.list.first { $0.cgWindowId == scWindow.windowID }) {
|
||||
window.refreshThumbnail(.cgImage(cgImage))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class WindowCaptureScreenshotsPrivateApi {
|
||||
static func oneTimeScreenshots(_ eligibleWindows: [Window], _ source: RefreshCausedBy) {
|
||||
for window in eligibleWindows {
|
||||
BackgroundWork.screenshotsQueue.addOperation { [weak window] in
|
||||
guard source != .refreshOnlyThumbnailsAfterShowUi || App.app.appIsBeingUsed else { return }
|
||||
guard let wid = window?.cgWindowId, let cgImage = oneTimeCapture(wid) else { return }
|
||||
guard source != .refreshOnlyThumbnailsAfterShowUi || App.app.appIsBeingUsed else { return }
|
||||
DispatchQueue.main.async { [weak window] in
|
||||
guard source != .refreshOnlyThumbnailsAfterShowUi || App.app.appIsBeingUsed else { return }
|
||||
window?.refreshThumbnail(.cgImage(cgImage))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func oneTimeCapture(_ wid: CGWindowID) -> CGImage? {
|
||||
// we use CGSHWCaptureWindowList because it can screenshot minimized windows, which CGWindowListCreateImage can't
|
||||
var windowId_ = wid
|
||||
let list = CGSHWCaptureWindowList(CGS_CONNECTION, &windowId_, 1, [.ignoreGlobalClipShape, .bestResolution, .fullSize]).takeRetainedValue() as! [CGImage]
|
||||
return list.first
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 12.3, *)
|
||||
class WindowCaptureVideos {
|
||||
private static var streams = [CGWindowID: SCStream]()
|
||||
private static var streamOutputs = [CGWindowID: StreamOutput]()
|
||||
// SCStream.backgroundColor is [unowned], so we must keep own these variables
|
||||
static let scStreamBackgroundColorDark = NSColor(white: 0.23, alpha: 1).cgColor
|
||||
static let scStreamBackgroundColorLight = NSColor.white.cgColor
|
||||
|
||||
static func startCapturing(_ windowsWhichMayHaveChanged: [Window]) {
|
||||
let windowsToShow = Set<CGWindowID>(Windows.list.filter { !$0.isWindowlessApp && $0.shouldShowTheUser }.compactMap { $0.cgWindowId })
|
||||
let windowsAlreadyStreaming = Set<CGWindowID>(streams.keys)
|
||||
let windowsToStop = windowsAlreadyStreaming.subtracting(windowsToShow)
|
||||
stopCaptures(windowsToStop)
|
||||
let windowsToStart = windowsToShow.subtracting(windowsAlreadyStreaming)
|
||||
let windowsWhichMayHaveChanged_ = windowsWhichMayHaveChanged.compactMap { $0.cgWindowId }
|
||||
if !windowsToStart.isEmpty || !windowsWhichMayHaveChanged_.isEmpty {
|
||||
SCShareableContent.getExcludingDesktopWindows(true, onScreenWindowsOnly: false) { shareableContent, error in
|
||||
guard let shareableContent, error == nil else {
|
||||
Logger.error { "\(shareableContent == nil) \(error)" }
|
||||
return
|
||||
}
|
||||
// this callback is executed on an undetermined queue
|
||||
// we move execution to main-thread to avoid races with starting/stopping streams and the app being shown/hidden
|
||||
DispatchQueue.main.async {
|
||||
guard App.app.appIsBeingUsed else { return }
|
||||
startCaptures(windowsToStart, shareableContent)
|
||||
updateCaptures(windowsWhichMayHaveChanged_, shareableContent)
|
||||
Logger.debug { streams.keys }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func stopCapturing() {
|
||||
Logger.debug { streams.keys }
|
||||
for stream in streams.values {
|
||||
stream.stopCapture()
|
||||
}
|
||||
streams.removeAll()
|
||||
streamOutputs.removeAll()
|
||||
}
|
||||
|
||||
private static func updateCaptures(_ windowsWhichMayHaveChanged: [CGWindowID], _ shareableContent: SCShareableContent) {
|
||||
for wid in windowsWhichMayHaveChanged {
|
||||
if let stream = streams[wid],
|
||||
let scWindow = shareableContent.windows.first(where: { $0.windowID == wid }) {
|
||||
stream.updateConfiguration(SCStreamConfiguration.forWindow(scWindow, true)) { error in
|
||||
if let error { Logger.error { error } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func startCaptures(_ windowsToStart: Set<CGWindowID>, _ shareableContent: SCShareableContent) {
|
||||
for wid in windowsToStart {
|
||||
if let scWindow = shareableContent.windows.first(where: { $0.windowID == wid }) {
|
||||
startCapture(scWindow)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static func startCapture(_ window: SCWindow) {
|
||||
let wid = window.windowID
|
||||
let output = StreamOutput(wid)
|
||||
let config = SCStreamConfiguration.forWindow(window, true)
|
||||
let filter = SCContentFilter(desktopIndependentWindow: window)
|
||||
let stream = SCStream(filter: filter, configuration: config, delegate: output)
|
||||
do {
|
||||
try stream.addStreamOutput(output, type: .screen, sampleHandlerQueue: BackgroundWork.screenshotsQueue.strongUnderlyingQueue)
|
||||
stream.startCapture { error in
|
||||
if let error { Logger.error { error } }
|
||||
}
|
||||
streams[wid] = stream
|
||||
streamOutputs[wid] = output
|
||||
} catch {
|
||||
Logger.error { error }
|
||||
}
|
||||
}
|
||||
|
||||
private static func stopCaptures(_ windowsToStop: Set<CGWindowID>) {
|
||||
for wid in windowsToStop {
|
||||
stopCapture(wid)
|
||||
}
|
||||
}
|
||||
|
||||
private static func stopCapture(_ wid: CGWindowID) {
|
||||
if let stream = streams[wid] {
|
||||
stream.stopCapture()
|
||||
streams.removeValue(forKey: wid)
|
||||
streamOutputs.removeValue(forKey: wid)
|
||||
}
|
||||
}
|
||||
|
||||
class StreamOutput: NSObject, SCStreamOutput, SCStreamDelegate {
|
||||
let wid: CGWindowID
|
||||
|
||||
init(_ wid: CGWindowID) {
|
||||
self.wid = wid
|
||||
}
|
||||
|
||||
// from SCStreamOutput; handle captured samples
|
||||
func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) {
|
||||
BackgroundWork.screenshotsQueue.trackCallbacks {
|
||||
if sampleBuffer.isValid,
|
||||
let pixelBuffer = sampleBuffer.pixelBuffer() {
|
||||
DispatchQueue.main.async {
|
||||
if let window = (Windows.list.first { $0.cgWindowId == self.wid }) {
|
||||
window.refreshThumbnail(.pixelBuffer(pixelBuffer))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// from SCStreamDelegate; handle errors when opening a stream
|
||||
func stream(_ stream: SCStream, didStopWithError error: any Error) {
|
||||
BackgroundWork.screenshotsQueue.trackCallbacks {
|
||||
Logger.error { error }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 12.3, *)
|
||||
extension SCStreamConfiguration {
|
||||
static func forWindow(_ window: SCWindow, _ video: Bool) -> SCStreamConfiguration {
|
||||
let config = SCStreamConfiguration()
|
||||
config.setWindowSize(window)
|
||||
config.showsCursor = false
|
||||
if video {
|
||||
config.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(60))
|
||||
config.queueDepth = 8
|
||||
// ~60% memory reduction compared to kCVPixelFormatType_32BGRA
|
||||
config.pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange
|
||||
// kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange doesn't have transparency, so we end up with an opaque background color around the window corners
|
||||
// we use a background color that try and hide these corners as much as possible
|
||||
config.backgroundColor = Appearance.currentTheme == .dark ? WindowCaptureVideos.scStreamBackgroundColorDark : WindowCaptureVideos.scStreamBackgroundColorLight
|
||||
}
|
||||
// config.scalesToFit = true
|
||||
return config
|
||||
}
|
||||
|
||||
private func setWindowSize(_ window: SCWindow) {
|
||||
let originalSize = NSSize(width: window.frame.width * NSScreen.preferred.backingScaleFactor, height: window.frame.height * NSScreen.preferred.backingScaleFactor)
|
||||
guard originalSize.width > 0, originalSize.height > 0 else { return }
|
||||
if Preferences.previewFocusedWindow {
|
||||
width = Int(originalSize.width)
|
||||
height = Int(originalSize.height)
|
||||
} else {
|
||||
// capture screenshots as small as needed for the thumbnails
|
||||
let maxSize = ThumbnailsPanel.maxPossibleThumbnailSize
|
||||
guard maxSize.width > 0, maxSize.height > 0 else { return }
|
||||
let scale = min(maxSize.width / originalSize.width, maxSize.height / originalSize.height)
|
||||
width = Int((originalSize.width * scale).rounded())
|
||||
height = Int((originalSize.height * scale).rounded())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension CMSampleBuffer {
|
||||
@available(macOS 12.3, *)
|
||||
func pixelBuffer() -> CVPixelBuffer? {
|
||||
if let attachmentsArray = CMSampleBufferGetSampleAttachmentsArray(self, createIfNecessary: false) as? [[SCStreamFrameInfo: Any]],
|
||||
let attachments = attachmentsArray.first,
|
||||
let statusRawValue = attachments[SCStreamFrameInfo.status] as? Int,
|
||||
let status = SCFrameStatus(rawValue: statusRawValue),
|
||||
status == .complete || status == .started { // new frame was generated
|
||||
return imageBuffer
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@available(macOS 12.3, *)
|
||||
func metalTexture(_ device: MTLDevice) -> MTLTexture? {
|
||||
guard let pixelBuffer = pixelBuffer(),
|
||||
let surface = CVPixelBufferGetIOSurface(pixelBuffer)?.takeUnretainedValue() else { return nil }
|
||||
let desc = MTLTextureDescriptor.texture2DDescriptor(
|
||||
pixelFormat: .bgra8Unorm,
|
||||
width: CVPixelBufferGetWidth(pixelBuffer),
|
||||
height: CVPixelBufferGetHeight(pixelBuffer),
|
||||
mipmapped: false
|
||||
)
|
||||
return device.makeTexture(descriptor: desc, iosurface: surface, plane: 0)
|
||||
}
|
||||
}
|
||||
@@ -304,6 +304,7 @@ extension App: NSApplicationDelegate {
|
||||
Spaces.refresh()
|
||||
SpacesEvents.observe()
|
||||
ScreensEvents.observe()
|
||||
ThumbnailsPanel.updateMaxPossibleThumbnailSize()
|
||||
SystemAppearanceEvents.observe()
|
||||
SystemScrollerStyleEvents.observe()
|
||||
Applications.initialDiscovery()
|
||||
|
||||
@@ -35,7 +35,7 @@ final class DebugMenu: NSPanel {
|
||||
while self.running {
|
||||
var sample: [String: Double] = [:]
|
||||
for queue in self.queues {
|
||||
sample[queue.strongUnderlyingQueue.label] = Double(queue.operationCount)
|
||||
sample[queue.strongUnderlyingQueue.label] = Double(queue.operationCount + queue.activeCallbacks)
|
||||
}
|
||||
DispatchQueue.main.async { self.graphView.addData(sample) }
|
||||
Thread.sleep(forTimeInterval: 0.1)
|
||||
|
||||
@@ -29,7 +29,7 @@ class FeedbackWindow: NSWindow {
|
||||
|
||||
private func setupView() {
|
||||
let appIcon = LightImageView()
|
||||
appIcon.updateWithResizedCopy(App.appIcon, NSSize(width: 80, height: 80))
|
||||
appIcon.updateWithResizedCopy(.cgImage(App.appIcon), NSSize(width: 80, height: 80))
|
||||
appIcon.fit(80, 80)
|
||||
let appText = StackView([
|
||||
BoldLabel(NSLocalizedString("Share improvement ideas, or report bugs", comment: "")),
|
||||
|
||||
@@ -27,37 +27,40 @@ class LightImageView: NSView {
|
||||
layerContentsRedrawPolicy = .never
|
||||
}
|
||||
|
||||
func updateWithResizedCopy(_ image: CGImage?, _ size: NSSize) {
|
||||
func updateWithResizedCopy(_ caLayerContents: CALayerContents, _ size: NSSize) {
|
||||
let scaleFactor = NSScreen.preferred.backingScaleFactor
|
||||
if let image {
|
||||
// alternatively, we could set layer!.contentsGravity to .center, and use the lines bellow to resize ourselves
|
||||
// let scaledSize = NSSize(width: size.width * scaleFactor, height: size.height * scaleFactor)
|
||||
// layer!.contents = image.resizedCopyWithCoreGraphics(scaledSize, fixBitmapInfo)
|
||||
// it would produce subjectively better quality, but the resizing would be done on the CPU so poor performance
|
||||
// alternatively, we could set layer!.contentsGravity to .center, and use the lines bellow to resize ourselves
|
||||
// let scaledSize = NSSize(width: size.width * scaleFactor, height: size.height * scaleFactor)
|
||||
// layer!.contents = image.resizedCopyWithCoreGraphics(scaledSize, fixBitmapInfo)
|
||||
// it would produce subjectively better quality, but the resizing would be done on the CPU so poor performance
|
||||
switch caLayerContents {
|
||||
case .cgImage(let image?):
|
||||
layer!.contents = image
|
||||
layer!.contentsScale = scaleFactor
|
||||
case .pixelBuffer(let pixelBuffer?):
|
||||
layer!.contents = CVPixelBufferGetIOSurface(pixelBuffer)?.takeUnretainedValue()
|
||||
default: break
|
||||
}
|
||||
layer!.contentsScale = scaleFactor
|
||||
if frame.size != size {
|
||||
frame.size = size
|
||||
}
|
||||
}
|
||||
|
||||
/// this schedules the image update for the next cycle
|
||||
/// The Panel will show with previous pictures, then they each will get updated quickly. It's a trade-off of getting AltTab interactive faster at the price of wrong data being shown
|
||||
/// I feel the UX is better without it. I may reconsider
|
||||
func updateWithResizedCopyAsync(_ image: CGImage?, _ size: NSSize) {
|
||||
layer!.contentsScale = NSScreen.preferred.backingScaleFactor
|
||||
frame.size = size
|
||||
if let image {
|
||||
// set image on another cycle so it doesn't block initial rendering
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.layer!.contents = image
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func releaseImage() {
|
||||
layer!.contents = nil
|
||||
}
|
||||
}
|
||||
|
||||
enum CALayerContents {
|
||||
case cgImage(CGImage?)
|
||||
case pixelBuffer(CVPixelBuffer?)
|
||||
|
||||
func size() -> NSSize? {
|
||||
switch self {
|
||||
case .cgImage(let image):
|
||||
return image?.size()
|
||||
case .pixelBuffer(let pixelBuffer):
|
||||
return pixelBuffer?.size()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,10 +27,10 @@ class PreviewPanel: NSPanel {
|
||||
setAccessibilitySubrole(.unknown)
|
||||
}
|
||||
|
||||
func show(_ id: CGWindowID, _ image: CGImage, _ position: CGPoint, _ size: CGSize) {
|
||||
func show(_ id: CGWindowID, _ preview: CALayerContents, _ position: CGPoint, _ size: CGSize) {
|
||||
if id != currentId {
|
||||
repositionAndResize(position, size)
|
||||
previewView.updateWithResizedCopy(image, size)
|
||||
previewView.updateWithResizedCopy(preview, size)
|
||||
}
|
||||
if id != currentId || !isVisible {
|
||||
if Preferences.previewFadeInAnimation {
|
||||
@@ -51,10 +51,10 @@ class PreviewPanel: NSPanel {
|
||||
}
|
||||
}
|
||||
|
||||
func updateIfShowing(_ id: CGWindowID?, _ image: CGImage, _ position: CGPoint, _ size: CGSize) {
|
||||
func updateIfShowing(_ id: CGWindowID?, _ preview: CALayerContents, _ position: CGPoint, _ size: CGSize) {
|
||||
if isVisible && id == currentId {
|
||||
repositionAndResize(position, size)
|
||||
previewView.updateWithResizedCopy(image, size)
|
||||
previewView.updateWithResizedCopy(preview, size)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -323,7 +323,7 @@ class ThumbnailView: FlippedView {
|
||||
|
||||
private func updateAppIcon(_ element: Window, _ title: String) {
|
||||
let appIconSize = ThumbnailView.iconSize()
|
||||
appIcon.updateWithResizedCopy(element.icon, appIconSize)
|
||||
appIcon.updateWithResizedCopy(.cgImage(element.icon), appIconSize)
|
||||
appIcon.setAccessibilityLabel(title)
|
||||
}
|
||||
|
||||
@@ -341,12 +341,12 @@ class ThumbnailView: FlippedView {
|
||||
))
|
||||
if !thumbnail.isHidden {
|
||||
if let screenshot = element.thumbnail {
|
||||
let thumbnailSize = ThumbnailView.thumbnailSize(screenshot, false)
|
||||
let thumbnailSize = ThumbnailView.thumbnailSize(element.size, false)
|
||||
thumbnail.updateWithResizedCopy(screenshot, thumbnailSize)
|
||||
} else {
|
||||
// if no thumbnail, show appIcon instead
|
||||
let thumbnailSize = ThumbnailView.thumbnailSize(element.icon, true)
|
||||
thumbnail.updateWithResizedCopy(element.icon, thumbnailSize)
|
||||
let thumbnailSize = ThumbnailView.thumbnailSize(element.icon?.size(), true)
|
||||
thumbnail.updateWithResizedCopy(.cgImage(element.icon), thumbnailSize)
|
||||
}
|
||||
// for Accessibility > "speak items under the pointer"
|
||||
thumbnail.setAccessibilityLabel(element.title)
|
||||
@@ -372,8 +372,8 @@ class ThumbnailView: FlippedView {
|
||||
updateDockLabelIcon(element.dockLabel)
|
||||
setAccessibilityHelp(getAccessibilityHelp(element.application.localizedName, element.dockLabel))
|
||||
if !windowlessIcon.isHidden {
|
||||
let windowlessIconSize = ThumbnailView.thumbnailSize(element.icon, true)
|
||||
windowlessIcon.updateWithResizedCopy(element.icon, windowlessIconSize)
|
||||
let windowlessIconSize = ThumbnailView.thumbnailSize(element.icon?.size(), true)
|
||||
windowlessIcon.updateWithResizedCopy(.cgImage(element.icon), windowlessIconSize)
|
||||
}
|
||||
windowControlIcons.forEach { $0.window_ = element }
|
||||
showOrHideWindowControls(isShowingWindowControls)
|
||||
@@ -550,8 +550,8 @@ class ThumbnailView: FlippedView {
|
||||
return shadow
|
||||
}
|
||||
|
||||
static func maxThumbnailWidth() -> CGFloat {
|
||||
return ThumbnailsPanel.maxThumbnailsWidth() * Appearance.windowMaxWidthInRow - Appearance.interCellPadding * 2
|
||||
static func maxThumbnailWidth(_ screen: NSScreen = NSScreen.preferred) -> CGFloat {
|
||||
return ThumbnailsPanel.maxThumbnailsWidth(screen) * Appearance.windowMaxWidthInRow - Appearance.interCellPadding * 2
|
||||
}
|
||||
|
||||
static func widthOfComfortableReadability() -> CGFloat? {
|
||||
@@ -582,14 +582,14 @@ class ThumbnailView: FlippedView {
|
||||
/// The maximum height that a thumbnail can be drawn
|
||||
/// maxThumbnailsHeight = maxThumbnailHeight * rowCount + interCellPadding * (rowCount - 1)
|
||||
/// maxThumbnailHeight = (maxThumbnailsHeight - interCellPadding * (rowCount - 1)) / rowCount
|
||||
static func maxThumbnailHeight() -> CGFloat {
|
||||
return ((ThumbnailsPanel.maxThumbnailsHeight() - Appearance.interCellPadding) / Appearance.rowsCount - Appearance.interCellPadding).rounded()
|
||||
static func maxThumbnailHeight(_ screen: NSScreen = NSScreen.preferred) -> CGFloat {
|
||||
return ((ThumbnailsPanel.maxThumbnailsHeight(screen) - Appearance.interCellPadding) / Appearance.rowsCount - Appearance.interCellPadding).rounded()
|
||||
}
|
||||
|
||||
static func thumbnailSize(_ image: CGImage?, _ isWindowlessApp: Bool) -> NSSize {
|
||||
guard let image else { return NSSize(width: 0, height: 0) }
|
||||
let imageWidth = CGFloat(image.width)
|
||||
let imageHeight = CGFloat(image.height)
|
||||
static func thumbnailSize(_ imageSize: NSSize?, _ isWindowlessApp: Bool) -> NSSize {
|
||||
guard let imageSize else { return NSSize(width: 0, height: 0) }
|
||||
let imageWidth = imageSize.width
|
||||
let imageHeight = imageSize.height
|
||||
let thumbnailHeightMax = ThumbnailView.maxThumbnailHeight()
|
||||
- Appearance.edgeInsetsSize * 2
|
||||
- Appearance.intraCellPadding
|
||||
@@ -598,7 +598,7 @@ class ThumbnailView: FlippedView {
|
||||
- Appearance.edgeInsetsSize * 2
|
||||
// don't stretch very small windows; keep them 1:1 in the switcher
|
||||
if !isWindowlessApp && imageWidth < thumbnailWidthMax && imageHeight < thumbnailHeightMax {
|
||||
return NSSize(width: imageWidth, height: imageHeight)
|
||||
return imageSize
|
||||
}
|
||||
let thumbnailHeight = min(imageHeight, thumbnailHeightMax)
|
||||
let thumbnailWidth = min(imageWidth, thumbnailWidthMax)
|
||||
|
||||
@@ -4,6 +4,7 @@ class ThumbnailsPanel: NSPanel {
|
||||
var thumbnailsView = ThumbnailsView()
|
||||
override var canBecomeKey: Bool { true }
|
||||
private var didDisplayOnce = false
|
||||
static var maxPossibleThumbnailSize = NSSize.zero
|
||||
|
||||
convenience init() {
|
||||
self.init(contentRect: .zero, styleMask: .nonactivatingPanel, backing: .buffered, defer: false)
|
||||
@@ -72,22 +73,30 @@ class ThumbnailsPanel: NSPanel {
|
||||
thumbnailsView.scrollView.flashScrollers()
|
||||
}
|
||||
|
||||
static func maxThumbnailsWidth() -> CGFloat {
|
||||
static func maxThumbnailsWidth(_ screen: NSScreen = NSScreen.preferred) -> CGFloat {
|
||||
if Preferences.appearanceStyle == .titles,
|
||||
let readableWidth = ThumbnailView.widthOfComfortableReadability() {
|
||||
return (
|
||||
min(
|
||||
NSScreen.preferred.frame.width * Appearance.maxWidthOnScreen,
|
||||
screen.frame.width * Appearance.maxWidthOnScreen,
|
||||
readableWidth + Appearance.intraCellPadding * 2 + Appearance.appIconLabelSpacing + Appearance.iconSize
|
||||
// widthOfLongestTitle + Appearance.intraCellPadding * 2 + Appearance.appIconLabelSpacing + Appearance.iconSize
|
||||
) - Appearance.windowPadding * 2
|
||||
).rounded()
|
||||
}
|
||||
return (NSScreen.preferred.frame.width * Appearance.maxWidthOnScreen - Appearance.windowPadding * 2).rounded()
|
||||
return (screen.frame.width * Appearance.maxWidthOnScreen - Appearance.windowPadding * 2).rounded()
|
||||
}
|
||||
|
||||
static func maxThumbnailsHeight() -> CGFloat {
|
||||
return (NSScreen.preferred.frame.height * Appearance.maxHeightOnScreen - Appearance.windowPadding * 2).rounded()
|
||||
static func maxThumbnailsHeight(_ screen: NSScreen = NSScreen.preferred) -> CGFloat {
|
||||
return (screen.frame.height * Appearance.maxHeightOnScreen - Appearance.windowPadding * 2).rounded()
|
||||
}
|
||||
|
||||
static func updateMaxPossibleThumbnailSize() {
|
||||
let (w, h) = NSScreen.screens.reduce((CGFloat.zero, CGFloat.zero)) { acc, screen in
|
||||
(max(acc.0, ThumbnailView.maxThumbnailWidth(screen) * screen.backingScaleFactor),
|
||||
max(acc.1, ThumbnailView.maxThumbnailHeight(screen) * screen.backingScaleFactor))
|
||||
}
|
||||
maxPossibleThumbnailSize = NSSize(width: w, height: h)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ class ThumbnailsView {
|
||||
// it would be nicer to remove this whole "reset" logic, and instead update each component to check Appearance properties before showing
|
||||
// Maybe in some Appkit willDraw() function that triggers before drawing it
|
||||
NSScreen.updatePreferred()
|
||||
ThumbnailsPanel.updateMaxPossibleThumbnailSize()
|
||||
Appearance.update()
|
||||
updateBackgroundView()
|
||||
App.app.thumbnailsPanel.contentView = contentView
|
||||
|
||||
@@ -40,7 +40,7 @@ class PermissionsWindow: NSWindow {
|
||||
|
||||
private func setupView() {
|
||||
let appIcon = LightImageView()
|
||||
appIcon.updateWithResizedCopy(App.appIcon, NSSize(width: 80, height: 80))
|
||||
appIcon.updateWithResizedCopy(.cgImage(App.appIcon), NSSize(width: 80, height: 80))
|
||||
appIcon.fit(80, 80)
|
||||
let appText = TitleLabel(NSLocalizedString("AltTab needs some permissions", comment: ""))
|
||||
appText.preferredMaxLayoutWidth = 380
|
||||
|
||||
@@ -3,7 +3,7 @@ import Cocoa
|
||||
class AboutTab {
|
||||
static func initTab() -> NSView {
|
||||
let appIcon = LightImageView()
|
||||
appIcon.updateWithResizedCopy(App.appIcon, NSSize(width: 256, height: 256))
|
||||
appIcon.updateWithResizedCopy(.cgImage(App.appIcon), NSSize(width: 256, height: 256))
|
||||
appIcon.fit(256, 256)
|
||||
let appText = StackView([
|
||||
BoldLabel(App.name),
|
||||
|
||||
Reference in new issue
Block a user