fix: thumbnails could sometimes be the wrong size

* fixes issues when the user has monitors with different DPI
* fixes issues where screenshots after a window is resized could be wrong (because of cached SCWindow having stale frame.size)
This commit is contained in:
lwouis committed 2026-01-15 00:08:34 +01:00
1 parent 9ed8a199b0
commit 9d0b947a48
8 files changed
+217 -194

No files matched your search

+4 -8
View File
@@ -73,6 +73,7 @@
5F706EC02BA448B1004D542D /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5F706EBE2BA448B1004D542D /* Localizable.strings */; };
5F706EC32BA448D8004D542D /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5F706EC12BA448D8004D542D /* InfoPlist.strings */; };
5F882F382EC3C63400110A9F /* CustomRecorderControlTestable.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8DC2A355E29516DD2FAF /* CustomRecorderControlTestable.swift */; };
5F906A342F16A18500B99924 /* Screens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F906A332F16A18000B99924 /* Screens.swift */; };
76D02BB22BFE7C9E0056008D /* Pods_alt_tab_macos.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0712B3BEA2B3780398C0999 /* Pods_alt_tab_macos.framework */; };
AA974BA929B7D84C0099A29E /* PreviewPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA974BA829B7D84C0099A29E /* PreviewPanel.swift */; };
BF0C803A13C409E7F01FBCAB /* SkyLight.framework.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8B94D7994AB9007F6391 /* SkyLight.framework.swift */; };
@@ -96,7 +97,6 @@
BF0C8224CFD1ED511F3F0063 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C8D94228D90FE99D4A3C8 /* Localizable.strings */; };
BF0C823630E3DEAC576FAFE6 /* menubar-0@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = BF0C88BF67CF4D71002A60EB /* menubar-0@2x.png */; };
BF0C8249228867A3188E13F3 /* SystemAppearanceEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C869E544F968051715452 /* SystemAppearanceEvents.swift */; };
BF0C824E829664B6610C7BAC /* table-of-contents.js in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8EE00994AC6C8323EE12 /* table-of-contents.js */; };
BF0C826C7F8C5F68732FD75B /* DebugMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C80F8FAA00FF3D9682F26 /* DebugMenu.swift */; };
BF0C827A169F0DE86C8F12AD /* SystemScrollerStyleEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8B5D1A9658E64F8ECB56 /* SystemScrollerStyleEvents.swift */; };
BF0C827C61136E83F43C6436 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C8913DCD0FE4C494055B6 /* InfoPlist.strings */; };
@@ -131,7 +131,6 @@
BF0C866DE5D740AF7756CDCF /* ImageScalingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C854910CCD4FB493C3A5D /* ImageScalingTests.swift */; };
BF0C86B26D16AFC588C88DB4 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C824B8E96CBEA7A757FC3 /* Localizable.strings */; };
BF0C86BB79E417163E813179 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C80851C26EDB3DB98556C /* InfoPlist.strings */; };
BF0C86FD1DFA49B68F0014CE /* turbolinks.js in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8EC6F81536045569B1CF /* turbolinks.js */; };
BF0C875A28B98904F8F4A66A /* ATShortcut.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8BA452332236D972C60E /* ATShortcut.swift */; };
BF0C87732A6E660899D13BFD /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C886B1D6E707E9E6BE46C /* InfoPlist.strings */; };
BF0C87B5EDCFC573CE220137 /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C89231FD53C4D55E3CF9A /* Helpers.swift */; };
@@ -241,7 +240,6 @@
D04BA737008AA2CD4E230A21 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA10777505D8A67ABD186 /* Application.swift */; };
D04BA73E90EFEF8247A5105D /* CGWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAC34CFD42A7F6F1F01C0 /* CGWindow.swift */; };
D04BA76A74267B1346D23687 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA6D57A1456C07318B8EA /* GridView.swift */; };
D04BA775CF3F8D9394A1E256 /* NSScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA68C2561D9EE4FD851B8 /* NSScreen.swift */; };
D04BA7B8D599E1A7A27FF5AE /* AcknowledgmentsTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA788228BA86D9EFBD1ED /* AcknowledgmentsTab.swift */; };
D04BA7B9421871EDBD58F4E3 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BABA255CD25D8724A06CD /* Localizable.strings */; };
D04BA7BE7F3DD24D58ACE942 /* AppearanceTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA64F1F344007EA13BA05 /* AppearanceTab.swift */; };
@@ -374,6 +372,7 @@
5F2218B02F156BC6005769F9 /* mascot-squirrel.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "mascot-squirrel.png"; sourceTree = "<group>"; };
5F706EBD2BA448B1004D542D /* is */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = is; path = Localizable.strings; sourceTree = "<group>"; };
5F706EC22BA448D8004D542D /* is */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = is; path = InfoPlist.strings; sourceTree = "<group>"; };
5F906A332F16A18000B99924 /* Screens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Screens.swift; sourceTree = "<group>"; };
5F9F965C2EAE731200E25498 /* jetbrains.svg */ = {isa = PBXFileReference; lastKnownFileType = text; path = jetbrains.svg; sourceTree = "<group>"; };
5FAE000A2F13B49800FE6246 /* update_readme_and_website.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = update_readme_and_website.sh; sourceTree = "<group>"; };
5FE2AF752EAE849F004A4AC5 /* xcbeautify */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; path = xcbeautify; sourceTree = "<group>"; };
@@ -620,7 +619,6 @@
D04BA653BD073CB58E2CFC93 /* ThumbnailsPanel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThumbnailsPanel.swift; sourceTree = "<group>"; };
D04BA662DF59DF9A97FDA598 /* de */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = de; path = Localizable.strings; sourceTree = "<group>"; };
D04BA67D904743A293768CB1 /* frontpage.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = frontpage.jpg; sourceTree = "<group>"; };
D04BA68C2561D9EE4FD851B8 /* NSScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSScreen.swift; sourceTree = "<group>"; };
D04BA6BBFD0E87B8458E91D3 /* feature-suggestion.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = "feature-suggestion.md"; sourceTree = "<group>"; };
D04BA6D57A1456C07318B8EA /* GridView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GridView.swift; sourceTree = "<group>"; };
D04BA6ED38651224DB117A5A /* upload_symbols_to_appcenter.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = upload_symbols_to_appcenter.sh; sourceTree = "<group>"; };
@@ -1318,9 +1316,9 @@
D04BA0D80B24E72B9B981A1D /* logic */ = {
isa = PBXGroup;
children = (
5F906A332F16A18000B99924 /* Screens.swift */,
D04BACD976030676FD0761D5 /* Windows.swift */,
D04BA9B93823398A542FF7A0 /* Preferences.swift */,
D04BA68C2561D9EE4FD851B8 /* NSScreen.swift */,
D04BA7C6F2519091717F4B4E /* SystemPermissions.swift */,
D04BA7CF9C2D1BEC7C05AB24 /* Spaces.swift */,
D04BA015A45DE7AFDC9794FE /* Window.swift */,
@@ -2425,7 +2423,6 @@
D04BA8480A8FF466CA89DA5B /* main.swift in Sources */,
D04BAFB973C3D28718FAEB87 /* Windows.swift in Sources */,
D04BAC011A71E0418154F8CD /* Preferences.swift in Sources */,
D04BA775CF3F8D9394A1E256 /* NSScreen.swift in Sources */,
D04BADCDA9F9A6C3D6499877 /* SystemPermissions.swift in Sources */,
D04BABEECBC6D922298BC93A /* Spaces.swift in Sources */,
D04BA6187A91A847844B6ABB /* Window.swift in Sources */,
@@ -2462,6 +2459,7 @@
5883F0A829A5182C0071DB65 /* TrackpadEvents.swift in Sources */,
D04BAAAF5CFA991D3B078DB8 /* KeyboardEvents.swift in Sources */,
D04BA1766FBCB2E941D081A5 /* RunningApplicationsEvents.swift in Sources */,
5F906A342F16A18500B99924 /* Screens.swift in Sources */,
D04BA0AE9865276FF8EF5DEB /* UserDefaultsEvents.swift in Sources */,
D04BA4A11F821548EE3C5E95 /* Bash.swift in Sources */,
5F21C9E62C6E94920091F72F /* AnimationsSheet.swift in Sources */,
@@ -2510,8 +2508,6 @@
BF0C8AD478399D2E97AE3FB4 /* CustomRecorderControlTestable.swift in Sources */,
BF0C8A5FA46C5E74A071FF4C /* ScrollwheelEvents.swift in Sources */,
BF0C82E9AB495AAAA182764A /* CursorEvents.swift in Sources */,
BF0C86FD1DFA49B68F0014CE /* turbolinks.js in Sources */,
BF0C824E829664B6610C7BAC /* table-of-contents.js in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -1,5 +1,17 @@
import Cocoa
class Screens {
static var all = [ScreenUuid: NSScreen]()
static func refresh() {
all.removeAll()
for screen in NSScreen.screens {
guard let uuid = screen.uuid() else { continue }
all[uuid] = screen
}
}
}
extension NSScreen {
static var preferred: NSScreen = detectPreferred() ?? NSScreen.screens.first!
@@ -22,15 +34,12 @@ extension NSScreen {
/// * if NSScreen.screensHaveSeparateSpaces == false, and key window is on another screen than screens[0], it still returns screens[0]
/// we find the screen with the key window ourselves manually
static func active() -> NSScreen? {
if let app = Applications.find(NSWorkspace.shared.frontmostApplication?.processIdentifier) {
if let focusedWindow = app.focusedWindow {
// on the very first summon, this window may not have its spaces updated, which may land the wrong active screen
Windows.updatesWindowSpace(focusedWindow)
return NSScreen.screens.first { focusedWindow.isOnScreen($0) }
}
return NSScreen.withActiveMenubar()
}
return nil
guard let app = Applications.find(NSWorkspace.shared.frontmostApplication?.processIdentifier) else { return nil }
guard let focusedWindow = app.focusedWindow else { return NSScreen.withActiveMenubar() }
// on the very first summon, this window may not have its spaces updated, which may land the wrong active screen
focusedWindow.updateSpacesAndScreen()
guard let screenId = focusedWindow.screenId else { return nil }
return Screens.all[screenId]
}
/// there is only 1 active menubar. Other screens will show their menubar dimmed
@@ -55,13 +64,11 @@ extension NSScreen {
}
func uuid() -> ScreenUuid? {
if let screenNumber = number(),
// these APIs implicitly unwrap their return values, but it can actually be nil thus we check
let screenUuid = CGDisplayCreateUUIDFromDisplayID(screenNumber),
let uuid = CFUUIDCreateString(nil, screenUuid.takeRetainedValue()) {
return uuid
}
return nil
guard let screenNumber = number(),
// these APIs implicitly unwrap their return values, but it can actually be nil thus we check
let screenUuid = CGDisplayCreateUUIDFromDisplayID(screenNumber),
let uuid = CFUUIDCreateString(nil, screenUuid.takeRetainedValue()) else { return nil }
return uuid
}
// periphery:ignore
@@ -92,3 +99,4 @@ extension NSScreen {
}
typealias ScreenUuid = CFString
+22 -1
View File
@@ -29,6 +29,7 @@ class Window {
var size: CGSize?
var spaceIds = [CGSSpaceID.max]
var spaceIndexes = [SpaceIndex.max]
var screenId: ScreenUuid?
var axUiElement: AXUIElement?
var application: Application
var axObserver: AXObserver?
@@ -42,7 +43,7 @@ class Window {
self.axUiElement = axUiElement
self.application = application
cgWindowId = wid
Windows.updatesWindowSpace(self)
self.updateSpacesAndScreen()
self.isFullscreen = isFullscreen
self.isMinimized = isMinimized
self.position = position
@@ -243,6 +244,26 @@ class Window {
return application.localizedName ?? ""
}
func updateSpacesAndScreen() {
// macOS bug: if you tab a window, then move the tab group to another space, other tabs from the tab group will stay on the current space
// you can use the Dock to focus one of the other tabs and it will teleport that tab in the current space, proving that it's a macOS bug
// note: for some reason, it behaves differently if you minimize the tab group after moving it to another space
updateSpaces()
updateScreenId()
}
private func updateSpaces() {
guard let cgWindowId else { return }
let spaceIds = cgWindowId.spaces()
self.spaceIds = spaceIds
self.spaceIndexes = spaceIds.compactMap { spaceId in Spaces.idsAndIndexes.first { $0.0 == spaceId }?.1 }
self.isOnAllSpaces = spaceIds.count > 1
}
private func updateScreenId() {
screenId = NSScreen.screens.first { isOnScreen($0) }?.uuid()
}
/// window may not be visible on that screen (e.g. the window is not on the current Space)
func isOnScreen(_ screen: NSScreen) -> Bool {
if NSScreen.screensHaveSeparateSpaces {
+13 -27
View File
@@ -159,22 +159,20 @@ class Windows {
}
static func updateLastFocus(_ otherWindowAxUiElement: AXUIElement, _ otherWindowWid: CGWindowID) -> [Window]? {
if let focusedWindow = (list.first { $0.isEqualRobust(otherWindowAxUiElement, otherWindowWid) }) {
let focusedWindowOldFocusOrder = focusedWindow.lastFocusOrder
var windowsToRefresh = [focusedWindow]
list.forEach {
if $0.lastFocusOrder == focusedWindowOldFocusOrder {
$0.lastFocusOrder = 0
} else if $0.lastFocusOrder < focusedWindowOldFocusOrder {
$0.lastFocusOrder += 1
}
if $0.lastFocusOrder == 0 {
windowsToRefresh.append($0)
}
guard let focusedWindow = (list.first { $0.isEqualRobust(otherWindowAxUiElement, otherWindowWid) }) else { return nil }
// no need to update the list is the window is already lastFocusOrder 0
guard focusedWindow.lastFocusOrder != 0 && list.count > 1, let previousFocus = (list.first { $0.lastFocusOrder == 0 }) else { return [focusedWindow] }
// 2 windows have recently changed: the one which got focused, and the one who just lost focus
let windowsToRefresh = [focusedWindow, previousFocus]
let focusedWindowOldFocusOrder = focusedWindow.lastFocusOrder
list.forEach {
if $0.lastFocusOrder == focusedWindowOldFocusOrder {
$0.lastFocusOrder = 0
} else if $0.lastFocusOrder < focusedWindowOldFocusOrder {
$0.lastFocusOrder += 1
}
return windowsToRefresh
}
return nil
return windowsToRefresh
}
static func updateFocusedAndHoveredWindowIndex(_ newIndex: Int, _ fromMouse: Bool = false) {
@@ -306,7 +304,7 @@ class Windows {
lazy var visibleCgsWindowIds = Spaces.windowsInSpaces(spaceIdsAndIndexes, false)
for window in list {
detectTabbedWindows(window, cgsWindowIds, visibleCgsWindowIds)
updatesWindowSpace(window)
window.updateSpacesAndScreen()
refreshIfWindowShouldBeShownToTheUser(window)
}
refreshWhichWindowsToShowTheUser()
@@ -315,18 +313,6 @@ class Windows {
return true
}
static func updatesWindowSpace(_ window: Window) {
// macOS bug: if you tab a window, then move the tab group to another space, other tabs from the tab group will stay on the current space
// you can use the Dock to focus one of the other tabs and it will teleport that tab in the current space, proving that it's a macOS bug
// note: for some reason, it behaves differently if you minimize the tab group after moving it to another space
if let cgWindowId = window.cgWindowId {
let spaceIds = cgWindowId.spaces()
window.spaceIds = spaceIds
window.spaceIndexes = spaceIds.compactMap { spaceId in Spaces.idsAndIndexes.first { $0.0 == spaceId }?.1 }
window.isOnAllSpaces = spaceIds.count > 1
}
}
// dispatch screenshot requests off the main-thread, then wait for completion
static func refreshThumbnailsAsync(_ windows: [Window], _ source: RefreshCausedBy, windowRemoved: Bool = false) {
guard (!windows.isEmpty || windowRemoved) && ScreenRecordingPermission.status == .granted
+2 -4
View File
@@ -15,18 +15,16 @@ 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
window.isMinimized = isMinimized
window.updateSpacesAndScreen()
if window.isFullscreen != isFullscreen {
window.isFullscreen = isFullscreen
App.app.checkIfShortcutsShouldBeDisabled(window, nil)
}
if needsRefresh {
App.app.refreshOpenUi([window], .refreshUiAfterExternalEvent)
}
App.app.refreshOpenUi([window], .refreshUiAfterExternalEvent)
}
}
}
+1
View File
@@ -13,6 +13,7 @@ class ScreensEvents {
debouncerScreenAndSpace.debounce(.screenEvent) {
Logger.debug { notification.name.rawValue }
Spaces.refresh()
Screens.refresh()
// a screen added or removed, or screen resolution change can mess up layout; we reset components
App.app.resetPreferencesDependentComponents()
}
+150 -138
View File
@@ -56,8 +56,8 @@ class WindowCaptureScreenshots {
}
private static func oneTimeCapture(_ scWindow: SCWindow, _ source: RefreshCausedBy) {
guard !App.app.isTerminating else { return }
let config = SCStreamConfiguration.forWindow(scWindow, false)
guard !App.app.isTerminating, let window = (Windows.list.first { $0.cgWindowId == scWindow.windowID }), window.size != nil else { return }
let config = SCStreamConfiguration.forWindow(scWindow, window, false)
let filter = SCContentFilter(desktopIndependentWindow: scWindow)
ActiveWindowCaptures.increment()
SCScreenshotManager.captureImage(contentFilter: filter, configuration: config) { cgImage, error in
@@ -100,151 +100,163 @@ class WindowCaptureScreenshotsPrivateApi {
}
}
@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, *)
// 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 {
static func forWindow(_ scWindow: SCWindow, _ window: Window, _ video: Bool) -> SCStreamConfiguration {
let config = SCStreamConfiguration()
config.setWindowSize(window)
config.setWindowSize(scWindow, 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
}
// 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)
private func windowScaleFactor(_ window: Window) -> CGFloat {
if let screenId = window.screenId,
let screen = Screens.all[screenId] {
return screen.backingScaleFactor
}
return NSScreen.preferred.backingScaleFactor
}
private func setWindowSize(_ scWindow: SCWindow, _ window: Window) {
let scaleFactor = windowScaleFactor(window)
// we use window.size and not scWindow.frame, as scWindow is cached thus its size can be stale. window.size is always up-to-date
let size = window.size! // we checked non-nil earlier, up the stack
// window.size shows logical size. It doesn't change when the scaleFactor changes. We need to correct for this as we need to capture more and less pixels depending on DPI
let originalSize = NSSize(width: size.width * scaleFactor, height: size.height * scaleFactor)
guard originalSize.width > 0, originalSize.height > 0 else { return }
if Preferences.previewFocusedWindow {
width = Int(originalSize.width)
+1
View File
@@ -304,6 +304,7 @@ extension App: NSApplicationDelegate {
self.thumbnailsPanel = ThumbnailsPanel()
self.previewPanel = PreviewPanel()
Spaces.refresh()
Screens.refresh()
SpacesEvents.observe()
ScreensEvents.observe()
ThumbnailsPanel.updateMaxPossibleThumbnailSize()