Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c65d1e60f8 |
No files matched your search
@@ -59,6 +59,7 @@
|
||||
1C961F9CF4F1BEDF04ACAEE0 /* Switch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C96189273B7482124C36F4C /* Switch.swift */; };
|
||||
1C961FA2D2D99AE54EBECA67 /* titles_show_running_windows_light@2x.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 1C9610B2E76F1E429C8D3660 /* titles_show_running_windows_light@2x.jpg */; };
|
||||
1C961FB59566B8314771F58E /* thumbnails_show_preview_focused_window_light@2x.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 1C961EF84B061A68FD9AEC46 /* thumbnails_show_preview_focused_window_light@2x.jpg */; };
|
||||
346D342C2EEA72A4009590BE /* WindowCaptureEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 346D342B2EEA72A4009590BE /* WindowCaptureEvents.swift */; };
|
||||
442E26DB1F7DA80AF794CCB6 /* Pods_unit_tests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C7E6F409CA442F83B7BE71AD /* Pods_unit_tests.framework */; };
|
||||
4807A6C623A9CD190052A53E /* SkyLight.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4807A6C523A9CD190052A53E /* SkyLight.framework */; };
|
||||
48F3E16224EC0D8800A1C64B /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BABDA79F8EF76E3ACDD5F /* Localizable.strings */; };
|
||||
@@ -349,6 +350,7 @@
|
||||
1C961F5740647DBFA0540789 /* thumbnails_show_tabs_as_windows_light@2x.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "thumbnails_show_tabs_as_windows_light@2x.jpg"; sourceTree = "<group>"; };
|
||||
1C961F68C77AAE38E5A2E046 /* app_icons_show_running_applications_light@2x.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "app_icons_show_running_applications_light@2x.jpg"; sourceTree = "<group>"; };
|
||||
1C961FB3DCF4906FEEF4A23D /* TableGroupView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TableGroupView.swift; sourceTree = "<group>"; };
|
||||
346D342B2EEA72A4009590BE /* WindowCaptureEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowCaptureEvents.swift; sourceTree = "<group>"; };
|
||||
4807A6C523A9CD190052A53E /* SkyLight.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SkyLight.framework; path = ../../../../System/Library/PrivateFrameworks/SkyLight.framework; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
@@ -1895,6 +1897,7 @@
|
||||
BF0C8643F524515224BF1618 /* KeyboardEventsTestable.swift */,
|
||||
BF0C8C69316EAAF6DC9AE391 /* CliEvents.swift */,
|
||||
BF0C8FAF34022E23582797F3 /* ScrollwheelEvents.swift */,
|
||||
346D342B2EEA72A4009590BE /* WindowCaptureEvents.swift */,
|
||||
);
|
||||
path = events;
|
||||
sourceTree = "<group>";
|
||||
@@ -2434,6 +2437,7 @@
|
||||
D04BA446D702C5E252AF2319 /* TitleLabel.swift in Sources */,
|
||||
D04BAD43C122A1C7E88B0362 /* MouseEvents.swift in Sources */,
|
||||
D04BAFF30A98CF287F85DA1E /* BlacklistsTab.swift in Sources */,
|
||||
346D342C2EEA72A4009590BE /* WindowCaptureEvents.swift in Sources */,
|
||||
D04BA56789A2AF52557A9BFF /* AppCenterCrashes.swift in Sources */,
|
||||
D04BADA3F2DAB820D6FC6E75 /* AppCenterApplication.m in Sources */,
|
||||
D04BAA16F21AC41A23DDA63D /* ControlsTab.swift in Sources */,
|
||||
|
||||
@@ -64,9 +64,7 @@ extension AXUIElement {
|
||||
// if their are common updates, we avoid congestion by only retrying the latest update
|
||||
if let pid, callType == .updateWindow || callType == .updateAppWindows {
|
||||
let unresponsiveAppsMapKey = "\(callType.rawValue)\(pid)"
|
||||
axCallsRetriesQueueUnresponsiveAppsMap.lock.lock()
|
||||
let time = axCallsRetriesQueueUnresponsiveAppsMap.map[unresponsiveAppsMapKey]
|
||||
axCallsRetriesQueueUnresponsiveAppsMap.lock.unlock()
|
||||
let time = axCallsRetriesQueueUnresponsiveAppsMap.withLock { $0[unresponsiveAppsMapKey] }
|
||||
if let time {
|
||||
if startTimeInNanoseconds > time {
|
||||
// new most recent call; replace and retry
|
||||
@@ -485,9 +483,9 @@ enum DebounceType: Int {
|
||||
case windowTitleChanged
|
||||
}
|
||||
|
||||
class ConcurrentMap<K: Hashable, V> {
|
||||
var map = [K: V]()
|
||||
let lock = NSLock()
|
||||
final class ConcurrentMap<K: Hashable, V> {
|
||||
private var map = [K: V]()
|
||||
private let lock = NSLock()
|
||||
|
||||
@discardableResult
|
||||
func withLock<T>(_ block: (inout [K: V]) -> T) -> T {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -207,7 +207,7 @@ extension NSImage {
|
||||
|
||||
extension CGImage {
|
||||
func nsImage() -> NSImage {
|
||||
return NSImage(cgImage: self, size: NSSize(width: width, height: height))
|
||||
return NSImage(cgImage: self, size: size())
|
||||
}
|
||||
|
||||
static func named(_ imageName: String) -> CGImage {
|
||||
@@ -215,6 +215,19 @@ extension CGImage {
|
||||
let imageSource = CGImageSourceCreateWithURL(imageURL as CFURL, nil)!
|
||||
return CGImageSourceCreateImageAtIndex(imageSource, 0, nil)!
|
||||
}
|
||||
|
||||
func size() -> NSSize {
|
||||
return NSSize(width: width, height: height)
|
||||
}
|
||||
}
|
||||
|
||||
extension CVPixelBuffer {
|
||||
func size() -> NSSize {
|
||||
NSSize(
|
||||
width: CVPixelBufferGetWidth(self),
|
||||
height: CVPixelBufferGetHeight(self)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension pid_t {
|
||||
|
||||
@@ -41,7 +41,7 @@ class BackgroundWork {
|
||||
missionControlThread = BackgroundThreadWithRunLoop("missionControlThread", .userInteractive)
|
||||
// we listen to CLI commands (CFMessagePort events)
|
||||
cliEventsThread = BackgroundThreadWithRunLoop("cliEventsThread", .userInteractive)
|
||||
// logThreadsAndQueuesOnRepeat()
|
||||
// logThreadsAndQueuesOnRepeat()
|
||||
}
|
||||
|
||||
static func startCrashReportsQueue() {
|
||||
@@ -149,6 +149,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])
|
||||
@@ -168,3 +172,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()
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,7 @@ class Preferences {
|
||||
"previewFocusedWindow": "false",
|
||||
"screenRecordingPermissionSkipped": "false",
|
||||
"trackpadHapticFeedbackEnabled": "true",
|
||||
"videoThumbnailsAndPreview": "true",
|
||||
]
|
||||
|
||||
// system preferences
|
||||
@@ -125,6 +126,7 @@ class Preferences {
|
||||
static var blacklist: [BlacklistEntry] { CachedUserDefaults.json("blacklist", [BlacklistEntry].self) }
|
||||
static var previewFocusedWindow: Bool { CachedUserDefaults.bool("previewFocusedWindow") }
|
||||
static var screenRecordingPermissionSkipped: Bool { CachedUserDefaults.bool("screenRecordingPermissionSkipped") }
|
||||
static var videoThumbnailsAndPreview: Bool { CachedUserDefaults.bool("videoThumbnailsAndPreview") }
|
||||
|
||||
// macro values
|
||||
static var appearanceStyle: AppearanceStylePreference { CachedUserDefaults.macroPref("appearanceStyle", AppearanceStylePreference.allCases) }
|
||||
|
||||
@@ -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
|
||||
@@ -91,16 +91,16 @@ 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 size,
|
||||
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)
|
||||
view.thumbnail.updateWithResizedCopy(screenshot, thumbnailSize)
|
||||
}
|
||||
App.app.previewPanel.updateImageIfShowing(cgWindowId, screenshot, size)
|
||||
App.app.previewPanel.updateIfShowing(cgWindowId, screenshot, position, size)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
-19
@@ -139,7 +139,7 @@ class Windows {
|
||||
}
|
||||
if Windows.list.count > 0 {
|
||||
moveFocusedWindowIndexAfterWindowDestroyedInBackground(index)
|
||||
App.app.refreshOpenUi([], .refreshUiAfterExternalEvent)
|
||||
App.app.refreshOpenUi([], .refreshUiAfterExternalEvent, windowRemoved: true)
|
||||
} else {
|
||||
App.app.hideUi()
|
||||
}
|
||||
@@ -328,8 +328,8 @@ class Windows {
|
||||
}
|
||||
|
||||
// dispatch screenshot requests off the main-thread, then wait for completion
|
||||
static func refreshThumbnailsAsync(_ windows: [Window], _ source: RefreshCausedBy) {
|
||||
guard !windows.isEmpty && ScreenRecordingPermission.status == .granted
|
||||
static func refreshThumbnailsAsync(_ windows: [Window], _ source: RefreshCausedBy, windowRemoved: Bool = false) {
|
||||
guard (!windows.isEmpty || windowRemoved) && ScreenRecordingPermission.status == .granted
|
||||
&& !Preferences.onlyShowApplications()
|
||||
&& (!Appearance.hideThumbnails || Preferences.previewFocusedWindow) else { return }
|
||||
var eligibleWindows = [Window]()
|
||||
@@ -338,23 +338,16 @@ class Windows {
|
||||
eligibleWindows.append(window)
|
||||
}
|
||||
}
|
||||
if eligibleWindows.isEmpty { 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
guard (!eligibleWindows.isEmpty || windowRemoved) else { return }
|
||||
if Preferences.videoThumbnailsAndPreview && App.app.appIsBeingUsed, #available(macOS 12.3, *) {
|
||||
WindowCaptureEvents.toggleOn(source != .refreshOnlyThumbnailsAfterShowUi ? eligibleWindows : [])
|
||||
return
|
||||
}
|
||||
if !Preferences.videoThumbnailsAndPreview, #available(macOS 14.0, *) {
|
||||
WindowCapture.oneTimeScreenshots(eligibleWindows, source)
|
||||
return
|
||||
}
|
||||
WindowCapture.oneTimeScreenshotsPrivateApi(eligibleWindows, source)
|
||||
}
|
||||
|
||||
static func refreshWhichWindowsToShowTheUser() {
|
||||
|
||||
@@ -13,7 +13,7 @@ class AccessibilityEvents {
|
||||
if let (title, _, _, isMinimized, isFullscreen) = try element.windowAttributes() {
|
||||
let size = try element.size()
|
||||
let position = try element.position()
|
||||
DispatchQueue.main.async {
|
||||
DispatchQueue.main.async { [weak window] in
|
||||
if let window = (window != nil ? window : (Windows.list.first { $0.isEqualRobust(element, wid) })) {
|
||||
window.title = window.bestEffortTitle(title)
|
||||
window.size = size
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import Cocoa
|
||||
import ScreenCaptureKit
|
||||
|
||||
@available(macOS 12.3, *)
|
||||
class WindowCaptureEvents {
|
||||
private static var streams = [CGWindowID: SCStream]()
|
||||
private static var streamOutputs = [CGWindowID: StreamOutput]()
|
||||
|
||||
static func toggleOn(_ 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(error, shareableContent == nil)
|
||||
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 toggleOff() {
|
||||
for stream in streams.values {
|
||||
stream.stopCapture { error in
|
||||
if let error { Logger.error(error) }
|
||||
}
|
||||
}
|
||||
streams.removeAll()
|
||||
streamOutputs.removeAll()
|
||||
Logger.debug()
|
||||
}
|
||||
|
||||
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 { error in
|
||||
if let error { Logger.error(error) }
|
||||
}
|
||||
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() {
|
||||
let wid_ = wid
|
||||
DispatchQueue.main.async {
|
||||
if let window = (Windows.list.first { $0.cgWindowId == 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.pixelFormat = kCVPixelFormatType_32BGRA
|
||||
config.showsCursor = false
|
||||
if video {
|
||||
config.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(60))
|
||||
config.queueDepth = 8
|
||||
}
|
||||
// config.scalesToFit = true
|
||||
return config
|
||||
}
|
||||
|
||||
private func setWindowSize(_ window: SCWindow) {
|
||||
let backingScaleFactor = Int(NSScreen.preferred.backingScaleFactor)
|
||||
width = Int(window.frame.width) * backingScaleFactor
|
||||
height = Int(window.frame.height) * backingScaleFactor
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
class WindowCapture {
|
||||
@available(macOS 14.0, *)
|
||||
static func oneTimeScreenshots(_ windowsToScreenshot: [Window], _ source: RefreshCausedBy) {
|
||||
let windows = windowsToScreenshot.compactMap { $0.cgWindowId }
|
||||
if !windows.isEmpty {
|
||||
SCShareableContent.getExcludingDesktopWindows(true, onScreenWindowsOnly: false) { shareableContent, error in
|
||||
guard let shareableContent, error == nil else {
|
||||
Logger.error(error, shareableContent == nil)
|
||||
return
|
||||
}
|
||||
if source == .refreshOnlyThumbnailsAfterShowUi && !App.app.appIsBeingUsed { 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 {
|
||||
if source == .refreshOnlyThumbnailsAfterShowUi && !App.app.appIsBeingUsed { return }
|
||||
oneTimeCaptures(windows, shareableContent, source)
|
||||
Logger.debug(windows)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 14.0, *)
|
||||
private static func oneTimeCaptures(_ windows: [CGWindowID], _ shareableContent: SCShareableContent, _ source: RefreshCausedBy) {
|
||||
for wid in windows {
|
||||
if let scWindow = shareableContent.windows.first(where: { $0.windowID == wid }) {
|
||||
oneTimeCapture(scWindow, source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 14.0, *)
|
||||
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
|
||||
if let error {
|
||||
Logger.error(error)
|
||||
return
|
||||
}
|
||||
if source == .refreshOnlyThumbnailsAfterShowUi && !App.app.appIsBeingUsed { return }
|
||||
if let sampleBuffer, sampleBuffer.isValid, let pixelBuffer = sampleBuffer.imageBuffer {
|
||||
let wid = scWindow.windowID
|
||||
DispatchQueue.main.async {
|
||||
if source == .refreshOnlyThumbnailsAfterShowUi && !App.app.appIsBeingUsed { return }
|
||||
if let window = (Windows.list.first { $0.cgWindowId == wid }) {
|
||||
window.refreshThumbnail(.pixelBuffer(pixelBuffer))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func oneTimeScreenshotsPrivateApi(_ 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 = oneTimeCapturePrivateApi(wid) {
|
||||
if source == .refreshOnlyThumbnailsAfterShowUi && !App.app.appIsBeingUsed { return }
|
||||
DispatchQueue.main.async { [weak window] in
|
||||
if source == .refreshOnlyThumbnailsAfterShowUi && !App.app.appIsBeingUsed { return }
|
||||
window?.refreshThumbnail(.cgImage(cgImage))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func oneTimeCapturePrivateApi(_ 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
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -69,6 +69,9 @@ class App: AppCenterApplication {
|
||||
}
|
||||
hideAllTooltips()
|
||||
MainMenu.toggle(enabled: true)
|
||||
if #available(macOS 12.3, *), Preferences.videoThumbnailsAndPreview {
|
||||
WindowCaptureEvents.toggleOff()
|
||||
}
|
||||
}
|
||||
|
||||
/// some tooltips may not be hidden when the main window is hidden; we force it through a private API
|
||||
@@ -197,8 +200,8 @@ class App: AppCenterApplication {
|
||||
CGWarpMouseCursorPosition(point)
|
||||
}
|
||||
|
||||
func refreshOpenUi(_ windowsToScreenshot: [Window], _ source: RefreshCausedBy) {
|
||||
Windows.refreshThumbnailsAsync(windowsToScreenshot, source)
|
||||
func refreshOpenUi(_ windowsToScreenshot: [Window], _ source: RefreshCausedBy, windowRemoved: Bool = false) {
|
||||
Windows.refreshThumbnailsAsync(windowsToScreenshot, source, windowRemoved: windowRemoved)
|
||||
guard appIsBeingUsed else { return }
|
||||
if source == .refreshUiAfterExternalEvent {
|
||||
if !Windows.updatesBeforeShowing() { hideUi(); return }
|
||||
|
||||
@@ -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: "")),
|
||||
|
||||
@@ -3,8 +3,6 @@ import Cocoa
|
||||
/// this is a lightweight view which displays an image using its CALayer
|
||||
/// it is an alternative to NSImageView, which doesn't have internal complexities and performance costs
|
||||
class LightImageView: NSView {
|
||||
var image: CGImage? { get { layer!.contents as! CGImage? } }
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("Class only supports programmatic initialization")
|
||||
}
|
||||
@@ -28,35 +26,38 @@ class LightImageView: NSView {
|
||||
]
|
||||
}
|
||||
|
||||
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
|
||||
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,20 +27,10 @@ class PreviewPanel: NSPanel {
|
||||
setAccessibilitySubrole(.unknown)
|
||||
}
|
||||
|
||||
func updateImageIfShowing(_ id: CGWindowID?, _ preview: CGImage, _ size: CGSize) {
|
||||
if isVisible && id == currentId {
|
||||
previewView.updateWithResizedCopy(preview, size)
|
||||
}
|
||||
}
|
||||
|
||||
func show(_ id: CGWindowID, _ preview: CGImage, _ position: CGPoint, _ size: CGSize) {
|
||||
func show(_ id: CGWindowID, _ preview: CALayerContents, _ position: CGPoint, _ size: CGSize) {
|
||||
if id != currentId {
|
||||
repositionAndResize(position, size)
|
||||
previewView.updateWithResizedCopy(preview, size)
|
||||
var frame = NSRect(origin: position, size: size)
|
||||
// Flip Y coordinate from Quartz (0,0 at bottom-left) to Cocoa coordinates (0,0 at top-left)
|
||||
// Always use the primary screen as reference since all coordinates are relative to it
|
||||
frame.origin.y = NSScreen.screens.first!.frame.maxY - frame.maxY
|
||||
setFrame(frame, display: false)
|
||||
}
|
||||
if id != currentId || !isVisible {
|
||||
if Preferences.previewFadeInAnimation {
|
||||
@@ -60,6 +50,21 @@ class PreviewPanel: NSPanel {
|
||||
level = NSWindow.Level(rawValue: App.app.thumbnailsPanel.level.rawValue - 1)
|
||||
}
|
||||
}
|
||||
|
||||
func updateIfShowing(_ id: CGWindowID?, _ preview: CALayerContents, _ position: CGPoint, _ size: CGSize) {
|
||||
if isVisible && id == currentId {
|
||||
repositionAndResize(position, size)
|
||||
previewView.updateWithResizedCopy(preview, size)
|
||||
}
|
||||
}
|
||||
|
||||
private func repositionAndResize( _ position: CGPoint, _ size: CGSize) {
|
||||
var frame = NSRect(origin: position, size: size)
|
||||
// Flip Y coordinate from Quartz (0,0 at bottom-left) to Cocoa coordinates (0,0 at top-left)
|
||||
// Always use the primary screen as reference since all coordinates are relative to it
|
||||
frame.origin.y = NSScreen.screens.first!.frame.maxY - frame.maxY
|
||||
setFrame(frame, display: false)
|
||||
}
|
||||
}
|
||||
|
||||
private class BorderView: NSView {
|
||||
|
||||
@@ -317,7 +317,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)
|
||||
}
|
||||
|
||||
@@ -335,12 +335,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)
|
||||
@@ -366,8 +366,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)
|
||||
@@ -580,10 +580,10 @@ class ThumbnailView: FlippedView {
|
||||
return ((ThumbnailsPanel.maxThumbnailsHeight() - 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
|
||||
@@ -592,7 +592,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)
|
||||
|
||||
@@ -37,7 +37,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),
|
||||
|
||||
@@ -364,6 +364,7 @@ extension Popover: NSPopoverDelegate {
|
||||
}
|
||||
|
||||
class AppearanceTab: NSObject {
|
||||
static var livePreviewOptionRows = [TableGroupView.RowInfo]()
|
||||
static var customizeStyleButton: NSButton!
|
||||
static var animationsButton: NSButton!
|
||||
static var customizeStyleSheet: CustomizeStyleSheet!
|
||||
@@ -380,7 +381,8 @@ class AppearanceTab: NSObject {
|
||||
private static func makeView() -> NSStackView {
|
||||
let appearanceView = makeAppearanceView()
|
||||
let multipleScreensView = makeMultipleScreensView()
|
||||
let view = TableGroupSetView(originalViews: [appearanceView, multipleScreensView, animationsButton])
|
||||
let livePreviewView = makeLivePreviewView()
|
||||
let view = TableGroupSetView(originalViews: [appearanceView, multipleScreensView, livePreviewView, animationsButton])
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.widthAnchor.constraint(equalToConstant: view.fittingSize.width).isActive = true
|
||||
return view
|
||||
@@ -410,6 +412,17 @@ class AppearanceTab: NSObject {
|
||||
return table
|
||||
}
|
||||
|
||||
private static func makeLivePreviewView() -> NSView {
|
||||
livePreviewOptionRows.removeAll()
|
||||
let table = TableGroupView(title: NSLocalizedString("Video thumbnails and preview", comment: ""),
|
||||
width: PreferencesWindow.width)
|
||||
let enableSwitch = LabelAndControl.makeSwitch("videoThumbnailsAndPreview")
|
||||
_ = table.addRow(leftText: NSLocalizedString("Enable for video. Disable for image", comment: ""),
|
||||
rightViews: [enableSwitch])
|
||||
table.fit()
|
||||
return table
|
||||
}
|
||||
|
||||
private static func getCustomizeStyleButtonTitle() -> String {
|
||||
if Preferences.appearanceStyle == .thumbnails {
|
||||
return NSLocalizedString("Customize Thumbnails style…", comment: "")
|
||||
|
||||
Reference in new issue
Block a user