Compare commits

...
12 Commits
Author SHA1 Message Date
lwouis 33b8cc3755 wip: single metal view 2026-01-03 17:59:30 +01:00
Suat Kocar 4383bc5633 feat: add live preview using ScreenCaptureKit
Adds real-time video preview of windows instead of static thumbnails.
Uses ScreenCaptureKit (macOS 12.3+) for efficient window capture.

Features:
- Configurable quality (Thumbnail to Native)
- Configurable frame rate (5-120 FPS)
- Configurable scope (Selected Window/App/All Windows)
- Incremental stream management for performance
- CIContext reuse for efficient frame processing
2026-01-03 17:59:11 +01:00
lwouis a5ba058d24 refactor: clarify names and organize tidier 2026-01-03 17:56:18 +01:00
lwouis ecaf47d8e6 refactor: logs overall: lazy + reviewed them 1 by 1 for better dx 2026-01-03 17:56:17 +01:00
lwouis 3a41117ea0 perf: improve mouse hover algo and core-animation side 2026-01-03 17:56:17 +01:00
lwouis 76e4aaf49a perf: do login item and plist updates later to accelerate launch 2026-01-03 17:56:16 +01:00
lwouis 239899da8d refactor: add logs 2026-01-03 17:56:16 +01:00
lwouis b175b06f31 perf: don't compute diffs of running-apps; directly get new/old 2026-01-03 17:56:15 +01:00
lwouis 5289b89ffc chore: document why we check mouse events on main-thread 2026-01-03 17:56:15 +01:00
lwouis bf5d73de01 refactor: repeatingKeyThread -> repeatingKeyQueue 2026-01-03 17:56:14 +01:00
lwouis 7d93711f9f chore: document why we use kvo for processes diffing 2026-01-03 17:56:14 +01:00
lwouis 054bd085ab fix: permission checks might freeze or restart the app (closes #5112) 2026-01-03 17:56:13 +01:00
50 changed files with 1001 additions and 399 deletions

No files matched your search

+12
View File
@@ -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 */; };
@@ -75,11 +76,13 @@
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 */; };
BF0C803A5A3148C3E5F5B064 /* MetalThumbnailsView.metal in Sources */ = {isa = PBXBuildFile; fileRef = BF0C80E288D7EBC42B5D165F /* MetalThumbnailsView.metal */; };
BF0C80484AA63306D0DB4DB2 /* SpacesEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8DF008F1EB5F23291A13 /* SpacesEvents.swift */; };
BF0C8052AE41B1B10E42BFCE /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C875C983226CB16DBD90F /* InfoPlist.strings */; };
BF0C8053A58DFB383A3E3351 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C8E237569FA2A8FE20707 /* InfoPlist.strings */; };
BF0C807D26E465F467B066F7 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C849EBA5A547912BD8BB9 /* Localizable.strings */; };
BF0C80C69EFEF93C18539925 /* PreferencesMigrations.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8F58B38F29B2662E6C2D /* PreferencesMigrations.swift */; };
BF0C80CA549018A0099A6B42 /* MetalThumbnailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C8E476869D8097FE4A9F1 /* MetalThumbnailsView.swift */; };
BF0C80FCECC1386EB73B66E5 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C8E9B9EF1485A14AE3316 /* Localizable.strings */; };
BF0C812C068CD751CE9BFB67 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C8D5993267A74D4875C1A /* Localizable.strings */; };
BF0C81480366BAD71BC9A158 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = BF0C8ABA423E72218605CA4F /* InfoPlist.strings */; };
@@ -349,6 +352,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>"; };
@@ -372,6 +376,7 @@
BF0C809C9DE36EA1317816FB /* nn */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = nn; path = Localizable.strings; sourceTree = "<group>"; };
BF0C80A05C11C8ADC366EC4A /* lb */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = lb; path = InfoPlist.strings; sourceTree = "<group>"; };
BF0C80CD087C6384E0340F4E /* convert_font_to_png.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = convert_font_to_png.sh; sourceTree = "<group>"; };
BF0C80E288D7EBC42B5D165F /* MetalThumbnailsView.metal */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.metal; path = MetalThumbnailsView.metal; sourceTree = "<group>"; };
BF0C80F8FAA00FF3D9682F26 /* DebugMenu.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DebugMenu.swift; sourceTree = "<group>"; };
BF0C810FA0F6BC70E5886E11 /* HelperExtensionsTestable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HelperExtensionsTestable.swift; sourceTree = "<group>"; };
BF0C8113492C0FFCB4454BC9 /* sl */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = sl; path = Localizable.strings; sourceTree = "<group>"; };
@@ -515,6 +520,7 @@
BF0C8E1C49845D813673D7A2 /* 20MB-photo.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "20MB-photo.jpg"; sourceTree = "<group>"; };
BF0C8E1FBCBC7B64C69D933C /* titles-style-full.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "titles-style-full.jpg"; sourceTree = "<group>"; };
BF0C8E25373671B528AD448D /* screen-recording@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "screen-recording@2x.png"; sourceTree = "<group>"; };
BF0C8E476869D8097FE4A9F1 /* MetalThumbnailsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MetalThumbnailsView.swift; sourceTree = "<group>"; };
BF0C8E478D68ED85DAE6BF43 /* ga */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = ga; path = InfoPlist.strings; sourceTree = "<group>"; };
BF0C8E54658B4DAEA0528200 /* menubar-2@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "menubar-2@2x.png"; sourceTree = "<group>"; };
BF0C8E5D2BD5F7F93DF77DF5 /* sk */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.strings; name = sk; path = InfoPlist.strings; sourceTree = "<group>"; };
@@ -1593,6 +1599,8 @@
D04BA258B56193958D60978A /* ThumbnailView.swift */,
D04BAA998119CAA8B70A2B67 /* ThumbnailsView.swift */,
AA974BA829B7D84C0099A29E /* PreviewPanel.swift */,
BF0C8E476869D8097FE4A9F1 /* MetalThumbnailsView.swift */,
BF0C80E288D7EBC42B5D165F /* MetalThumbnailsView.metal */,
);
path = "main-window";
sourceTree = "<group>";
@@ -1895,6 +1903,7 @@
BF0C8643F524515224BF1618 /* KeyboardEventsTestable.swift */,
BF0C8C69316EAAF6DC9AE391 /* CliEvents.swift */,
BF0C8FAF34022E23582797F3 /* ScrollwheelEvents.swift */,
346D342B2EEA72A4009590BE /* WindowCaptureEvents.swift */,
);
path = events;
sourceTree = "<group>";
@@ -2434,6 +2443,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 */,
@@ -2472,6 +2482,8 @@
BF0C8E5F4E4D5F7DAEE07C20 /* ThumbnailsPanelBackgroundView.swift in Sources */,
BF0C8AD478399D2E97AE3FB4 /* CustomRecorderControlTestable.swift in Sources */,
BF0C8A5FA46C5E74A071FF4C /* ScrollwheelEvents.swift in Sources */,
BF0C80CA549018A0099A6B42 /* MetalThumbnailsView.swift in Sources */,
BF0C803A5A3148C3E5F5B064 /* MetalThumbnailsView.metal in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
+6 -8
View File
@@ -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
@@ -85,7 +83,7 @@ extension AXUIElement {
// should we give up?
let timePassedInSeconds = Float(DispatchTime.now().uptimeNanoseconds - startTimeInNanoseconds) / 1_000_000_000
if timePassedInSeconds >= axCallsRetriesQueueTimeoutInSeconds {
Logger.info("AX call failed for more than \(Int(axCallsRetriesQueueTimeoutInSeconds))s. Giving up on it", logFromContext(file, function, line, context, callType))
Logger.info { "AX call failed for more than \(Int(axCallsRetriesQueueTimeoutInSeconds))s. Giving up on it. \(logFromContext(file, function, line, context, callType))" }
if let pid, callType == .updateWindow || callType == .updateAppWindows {
let unresponsiveAppsMapKey = "\(callType.rawValue)\(pid)"
axCallsRetriesQueueUnresponsiveAppsMap.withLock { $0.removeValue(forKey: unresponsiveAppsMapKey) }
@@ -93,7 +91,7 @@ extension AXUIElement {
return
}
// retry
Logger.info(logFromContext(file, function, line, context, callType))
Logger.info { "(pid:\(pid) wid:\(wid)) \(logFromContext(file, function, line, context, callType))" }
retryAxCallUntilTimeout(file: file, function: function, line: line, context: context, after: .now() + humanPerceptionDelay, debounceType: debounceType, pid: pid, wid: wid, retriesQueue: true, startTimeInNanoseconds: startTimeInNanoseconds, callType: callType, block: block)
}
@@ -484,9 +482,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 {
-18
View File
@@ -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)
+28 -2
View File
@@ -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 {
@@ -315,7 +328,7 @@ extension DispatchTimeInterval {
}
extension NSRunningApplication {
var id: String { "pid:\(String(describing: processIdentifier)) app:\(bundleIdentifier ?? bundleURL?.absoluteString ?? executableURL?.absoluteString ?? localizedName ?? "nil")" }
func debugId() -> String { "(pid:\(processIdentifier) \(bundleIdentifier ?? bundleURL?.absoluteString ?? executableURL?.absoluteString ?? localizedName))" }
}
// 250ms is similar to human delay in processing changes on screen
@@ -334,3 +347,16 @@ extension NSTouch.Phase {
}
}
}
/// this changes the behavior of interpolating optional values (e.g. "\(optionalValue)")
/// default is to return a compiler warning "string interpolation produces a debug description for an optional value; did you mean to make this explicit?"
/// instead, we either print the value, or print "nil"
extension String.StringInterpolation {
mutating func appendInterpolation<T>(_ value: T?) {
if let value {
appendInterpolation(value)
} else {
appendLiteral("nil")
}
}
}
+11 -11
View File
@@ -34,27 +34,27 @@ class Logger {
return .error
}
static func debug(_ items: Any?..., file: String = #file, function: String = #function, line: Int = #line, context: Any? = nil) {
custom(.debug, items, file: file, function: function, line: line, context: context)
static func debug(_ message: @escaping () -> Any?, file: String = #file, function: String = #function, line: Int = #line, context: Any? = nil) {
custom(level: .debug, file: file, function: function, line: line, context: context, message)
}
static func info(_ items: Any?..., file: String = #file, function: String = #function, line: Int = #line, context: Any? = nil) {
custom(.info, items, file: file, function: function, line: line, context: context)
static func info(_ message: @escaping () -> Any?, file: String = #file, function: String = #function, line: Int = #line, context: Any? = nil) {
custom(level: .info, file: file, function: function, line: line, context: context, message)
}
static func warning(_ items: Any?..., file: String = #file, function: String = #function, line: Int = #line, context: Any? = nil) {
custom(.warning, items, file: file, function: function, line: line, context: context)
static func warning(_ message: @escaping () -> Any?, file: String = #file, function: String = #function, line: Int = #line, context: Any? = nil) {
custom(level: .warning, file: file, function: function, line: line, context: context, message)
}
static func error(_ items: Any?..., file: String = #file, function: String = #function, line: Int = #line, context: Any? = nil) {
custom(.error, items, file: file, function: function, line: line, context: context)
static func error(_ message: @escaping () -> Any?, file: String = #file, function: String = #function, line: Int = #line, context: Any? = nil) {
custom(level: .error, file: file, function: function, line: line, context: context, message)
}
private static func custom(_ level: SwiftyBeaver.Level, _ items: [Any?], file: String = #file, function: String = #function, line: Int = #line, context: Any? = nil) {
let message = items.map { "\($0 ?? "nil")" }.joined(separator: " ")
logger.custom(level: level, message: "[\(threadName())] \(message)", file: file, function: function, line: line, context: context)
private static func custom(level: SwiftyBeaver.Level, file: String = #file, function: String = #function, line: Int = #line, context: Any? = nil, _ message: @escaping () -> Any?) {
logger.custom(level: level, message: { "[\(threadName())] \(message())" }(), file: file, function: function, line: line, context: context)
}
private static func threadName() -> String {
if Thread.isMainThread {
return "main"
+1 -1
View File
@@ -18,7 +18,7 @@ class MissionControl {
stateLock.lock()
defer { stateLock.unlock() }
state_ = state
Logger.info(state)
Logger.info { state }
}
// on macOS < 12, this is the way we used to guess if Mission Control is active
+1 -1
View File
@@ -12,7 +12,7 @@ class IOKitPrototype {
kIOHIDDeviceUsageKey: kHIDUsage_GD_Keyboard
] as CFDictionary)
IOHIDManagerRegisterInputValueCallback(manager, keyboardEventHandler, nil)
IOHIDManagerScheduleWithRunLoop(manager, BackgroundWork.keyboardAndTrackpadEventsThread.runLoop!, CFRunLoopMode.commonModes.rawValue)
IOHIDManagerScheduleWithRunLoop(manager, BackgroundWork.keyboardAndMouseAndTrackpadEventsThread.runLoop!, CFRunLoopMode.commonModes.rawValue)
IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone))
}
}
@@ -13,7 +13,7 @@ if #available(macOS 13.1, *) {
}
}
let windows = CGWindowListCopyWindowInfo([.excludeDesktopElements, .optionOnScreenOnly], kCGNullWindowID) as! [CGWindow]
Logger.error("CGWindowListCopyWindowInfo extra", windows.sorted { $0.id()! < $1.id()! }.map { ($0.id()!, $0.title() ?? "nil") })
Logger.error("CGWindowListCopyWindowInfo extra", windows.sorted { $0.id()! < $1.id()! }.map { ($0.id()!, $0.title()) })
Logger.error("CGWindowListCopyWindowInfo", windows.sorted { $0.id()! < $1.id()! }.map { $0.id()! })
Spaces.refresh()
let spaceIdsAndIndexes = Spaces.idsAndIndexes.map { $0.0 }
+2 -2
View File
@@ -70,7 +70,7 @@ class ATShortcut {
}
func executeAction(_ isARepeat: Bool) {
Logger.info("executeAction", id)
Logger.info { self.id }
ATShortcut.lastEventIsARepeat = isARepeat
ControlsTab.executeAction(id)
}
@@ -95,7 +95,7 @@ class ATShortcut {
}
if state == .up {
// ensure timers don't keep running if their shortcut is UP
KeyRepeatTimer.deactivateTimerForRepeatingKey(id)
KeyRepeatTimer.stopTimerForRepeatingKey(id)
}
}
}
+10 -7
View File
@@ -19,7 +19,10 @@ class Application: NSObject {
var dockLabel: String?
var focusedWindow: Window? = nil
var alreadyRequestedToQuit = false
var debugId: String { "(pid:\(String(describing: pid)) \(bundleIdentifier ?? bundleURL?.absoluteString ?? executableURL?.absoluteString ?? localizedName ?? "nil"))" }
func debugId() -> String {
return "(pid:\(pid) \(bundleIdentifier ?? bundleURL?.absoluteString ?? executableURL?.absoluteString ?? localizedName))"
}
static let notifications = [
kAXApplicationActivatedNotification,
@@ -65,7 +68,7 @@ class Application: NSObject {
bundleURL = runningApplication.bundleURL
executableURL = runningApplication.executableURL
super.init()
Logger.debug(debugId)
Logger.debug { self.debugId() }
observeEventsIfEligible()
kvObservers = [
runningApplication.observe(\.isFinishedLaunching, options: [.new]) { [weak self] _, _ in
@@ -83,7 +86,7 @@ class Application: NSObject {
}
deinit {
Logger.debug("Deinit app", debugId)
Logger.debug { self.debugId() }
}
func removeWindowslessAppWindow() {
@@ -106,7 +109,7 @@ class Application: NSObject {
}
func manuallyUpdateWindows() {
AXUIElement.retryAxCallUntilTimeout(context: debugId, pid: pid, callType: .updateAppWindows) { [weak self] in
AXUIElement.retryAxCallUntilTimeout(context: debugId(), pid: pid, callType: .updateAppWindows) { [weak self] in
guard let self, let axUiElement = self.axUiElement else { return }
var atLeastOneActualWindow = false
let axWindows = try axUiElement.allWindows(self.pid)
@@ -200,17 +203,17 @@ class Application: NSObject {
private func observeEvents() {
guard let axObserver else { return }
AXUIElement.retryAxCallUntilTimeout(context: debugId, pid: pid, callType: .subscribeToAppNotification) { [weak self] in
AXUIElement.retryAxCallUntilTimeout(context: debugId(), pid: pid, callType: .subscribeToAppNotification) { [weak self] in
guard let self, !self.isReallyFinishedLaunching else { return }
if try self.axUiElement!.subscribeToNotification(axObserver, Application.notifications.first!) {
Logger.debug("Subscribed to app", self.debugId)
Logger.debug { "Subscribed to app: \(self.debugId())" }
if !self.isReallyFinishedLaunching {
// some apps have `isFinishedLaunching == true` but are actually not finished, and will return .cannotComplete
// we consider them ready when the first subscription succeeds
// windows opened before that point won't send a notification, so check those windows manually here
self.isReallyFinishedLaunching = true
for notification in Application.notifications.dropFirst() {
AXUIElement.retryAxCallUntilTimeout(context: self.debugId, pid: self.pid, callType: .subscribeToAppNotification) { [weak self] in
AXUIElement.retryAxCallUntilTimeout(context: self.debugId(), pid: self.pid, callType: .subscribeToAppNotification) { [weak self] in
try self?.axUiElement!.subscribeToNotification(axObserver, notification)
}
}
+1 -1
View File
@@ -49,7 +49,7 @@ class Applications {
let zombies = believedAlive.subtracting(confirmedAlive)
for (index, window) in Windows.list.enumerated().reversed() {
if let wid = window.cgWindowId, zombies.contains(wid) {
Logger.error("----", window.title, window.application.bundleIdentifier)
Logger.debug { window.debugId() }
Windows.removeWindow(index, window.application.pid)
}
}
+51 -33
View File
@@ -2,64 +2,69 @@ import Foundation
// queues and dedicated threads to observe background events such as keyboard inputs, or accessibility events
class BackgroundWork {
// we use an OperationQueue tied to the global DispatchQueue to call blocking APIs in parallel
// we use Threads when the APIs we observe require a RunLoop (e.g. CGEvent.tapCreate, AXObserverGetRunLoopSource, CFMessagePortCreateRunLoopSource)
// when possible, we prefer OperationQueues
static var accessibilityEventsThread: BackgroundThreadWithRunLoop!
static var keyboardAndMouseAndTrackpadEventsThread: BackgroundThreadWithRunLoop!
static var missionControlThread: BackgroundThreadWithRunLoop!
static var cliEventsThread: BackgroundThreadWithRunLoop!
// we use an OperationQueue for most tasks, especially when we need to call blocking APIs in parallel
static var repeatingKeyQueue: LabeledOperationQueue!
static var screenshotsQueue: LabeledOperationQueue!
static var accessibilityCommandsQueue: LabeledOperationQueue!
static var axCallsFirstAttemptQueue: LabeledOperationQueue!
static var axCallsRetriesQueue: LabeledOperationQueue!
static var axCallsManualDiscoveryQueue: LabeledOperationQueue!
static var crashReportsQueue: LabeledOperationQueue!
// we use Threads to observe events in sequence
static var accessibilityEventsThread: BackgroundThreadWithRunLoop!
static var keyboardAndTrackpadEventsThread: BackgroundThreadWithRunLoop!
static var systemPermissionsThread: BackgroundThreadWithRunLoop!
static var repeatingKeyThread: BackgroundThreadWithRunLoop!
static var missionControlThread: BackgroundThreadWithRunLoop!
static var cliEventsThread: BackgroundThreadWithRunLoop!
static var debugMenu: DebugMenu!
static var permissionsCheckOnTimerQueue: LabeledOperationQueue!
static var permissionsSystemCallsQueue: LabeledOperationQueue!
private static var debugMenu: DebugMenu!
private static var totalPotentialThreadCount = 0
static func preStart() {
// we make calls to the system permissions API to know if permissions are granted. We do this on a timer
permissionsCheckOnTimerQueue = LabeledOperationQueue("permissionsCheckOnTimer", .userInteractive, 1)
// if macOS is overwhelmed, let's reduce the pressure on it by calling permission APIs one at a time
permissionsSystemCallsQueue = LabeledOperationQueue("permissionsSystemCalls", .userInteractive, 1)
}
static func start() {
screenshotsQueue = LabeledOperationQueue("screenshotsQueue", .userInteractive, 8)
screenshotsQueue = LabeledOperationQueue("screenshots", .userInteractive, 8)
// calls to focus/close/minimize/etc windows
// They are tried once and if they timeout we don't retry. The OS seems to still execute them even if the call timed out
accessibilityCommandsQueue = LabeledOperationQueue("accessibilityCommandsQueue", .userInteractive, 4)
accessibilityCommandsQueue = LabeledOperationQueue("axCommands", .userInteractive, 4)
// calls to the AX APIs can block for a long time (e.g. if an app is unresponsive)
// We first try the AX calls on axCallsFirstAttemptQueue. If we get a timeout, we move to axCallsRetriesQueue and retry there for a while
axCallsFirstAttemptQueue = LabeledOperationQueue("axCallsFirstAttemptQueue", .userInteractive, 8)
axCallsRetriesQueue = LabeledOperationQueue("axCallsRetriesQueue", .userInteractive, 8)
axCallsFirstAttemptQueue = LabeledOperationQueue("axCallsFirst", .userInteractive, 8)
axCallsRetriesQueue = LabeledOperationQueue("axCallsRetry", .userInteractive, 8)
// we separate calls to manuallyUpdateWindows since those can be very heavy and numerous
axCallsManualDiscoveryQueue = LabeledOperationQueue("axCallsManualDiscoveryQueue", .userInteractive, 8)
axCallsManualDiscoveryQueue = LabeledOperationQueue("axCallsManual", .userInteractive, 8)
// we time key repeat on a background queue. We handle their consequence on the main-thread
repeatingKeyQueue = LabeledOperationQueue("repeatingKey", .userInteractive, 1)
// we observe app and windows notifications. They arrive on this thread, and are handled off the main thread initially
accessibilityEventsThread = BackgroundThreadWithRunLoop("accessibilityEventsThread", .userInteractive)
accessibilityEventsThread = BackgroundThreadWithRunLoop("axEvents", .userInteractive)
// we listen to as any keyboard events as possible on a background thread, as it's more available/reliable than the main thread
keyboardAndTrackpadEventsThread = BackgroundThreadWithRunLoop("keyboardAndTrackpadEventsThread", .userInteractive)
// we time key repeat on a background thread for precision. We handle their consequence on the main-thread
repeatingKeyThread = BackgroundThreadWithRunLoop("repeatingKeyThread", .userInteractive)
keyboardAndMouseAndTrackpadEventsThread = BackgroundThreadWithRunLoop("inputDevices", .userInteractive)
// we main Mission Control state on a background thread. We protect reads from main-thread with an NSLock
missionControlThread = BackgroundThreadWithRunLoop("missionControlThread", .userInteractive)
missionControlThread = BackgroundThreadWithRunLoop("missionControl", .userInteractive)
// we listen to CLI commands (CFMessagePort events)
cliEventsThread = BackgroundThreadWithRunLoop("cliEventsThread", .userInteractive)
// logThreadsAndQueuesOnRepeat()
cliEventsThread = BackgroundThreadWithRunLoop("cliMessages", .userInteractive)
// logThreadsAndQueuesOnRepeat()
}
static func startCrashReportsQueue() {
if crashReportsQueue == nil {
// crash reports can be sent off the main thread
crashReportsQueue = LabeledOperationQueue("crashReportsQueue", .utility, 1)
crashReportsQueue = LabeledOperationQueue("crashReports", .utility, 1)
}
}
static func startSystemPermissionThread() {
// not 100% sure this shouldn't be on the main-thread; it doesn't do anything except dispatch to main.async
systemPermissionsThread = BackgroundThreadWithRunLoop("systemPermissionsThread", .utility)
}
static func addPotentialThreadCount(_ additionalCount: Int) {
totalPotentialThreadCount += additionalCount
// a macos process has a soft limit of 64 threads. We need to be careful to don't spawn too many threads through DispatchQueues
assert(totalPotentialThreadCount <= 43)
assert(totalPotentialThreadCount <= 45)
}
// useful during development to inspect how many threads are used by AltTab
@@ -82,7 +87,7 @@ class BackgroundWork {
map[queue.underlyingQueue!.label] = queue.operations.reduce(0) { $1.isExecuting ? $0 + 1 : $0 }
}
let prettyPrintMap = map.sorted { $0.key < $1.key }.map { "\($0.key): \($0.value)" }.joined(separator: ", ")
Logger.debug(prettyPrintMap)
Logger.debug { prettyPrintMap }
}
private static func logThreads() -> Void {
@@ -109,8 +114,8 @@ class BackgroundWork {
vm_deallocate(mach_task_self_,
vm_address_t(bitPattern: threads),
vm_size_t(count) * vm_size_t(MemoryLayout<thread_t>.size))
Logger.info(namedThreads.count, "Named threads:", namedThreads.sorted())
Logger.info(unnamedThreadsCount, "Unnamed threads (e.g. from GCD queues)")
Logger.info { "\(namedThreads.count) named threads:\(namedThreads.sorted())" }
Logger.info { "\(unnamedThreadsCount) unnamed threads (e.g. from GCD queues)" }
}
class BackgroundThreadWithRunLoop: Thread {
@@ -128,8 +133,8 @@ class BackgroundWork {
}
override func main() {
Logger.debug(name)
// the RunLoop is lazy; calling this initialize it
Logger.debug { "Thread ready" }
// the RunLoop is lazy; calling this initializes it
runLoop = CFRunLoopGetCurrent()
addDummySourceToPreventRunLoopTermination()
threadStartSemaphore.signal()
@@ -149,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])
@@ -168,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()
}
}
+22 -20
View File
@@ -2,52 +2,54 @@ import Cocoa
import ShortcutRecorder
class KeyRepeatTimer {
static var timer: Timer?
static var timer = DispatchSource.makeTimerSource(queue: BackgroundWork.repeatingKeyQueue.strongUnderlyingQueue)
static var timerIsSuspended = true
static var currentTimerShortcutName: String?
static func toggleRepeatingKeyPreviousWindow() {
static func startRepeatingKeyPreviousWindow() {
if let shortcut = ControlsTab.shortcuts["previousWindowShortcut"],
// events already repeat when using a shortcut with a keycode; no need for artificial repeat
shortcut.shortcut.keyCode == .none {
activateTimerForRepeatingKey(shortcut) {
startTimerForRepeatingKey(shortcut) {
App.app.previousWindowShortcutWithRepeatingKey()
}
}
}
static func toggleRepeatingKeyNextWindow() {
static func startRepeatingKeyNextWindow() {
if let shortcut = ControlsTab.shortcuts[Preferences.indexToName("nextWindowShortcut", App.app.shortcutIndex)] {
activateTimerForRepeatingKey(shortcut) {
startTimerForRepeatingKey(shortcut) {
ControlsTab.executeAction(Preferences.indexToName("nextWindowShortcut", App.app.shortcutIndex))
}
}
}
static func deactivateTimerForRepeatingKey(_ shortcutName: String) {
static func stopTimerForRepeatingKey(_ shortcutName: String) {
if shortcutName == currentTimerShortcutName {
Logger.debug(shortcutName)
Logger.debug { shortcutName }
currentTimerShortcutName = nil
timer?.invalidate()
timer.suspend()
timerIsSuspended = true
}
}
private static func activateTimerForRepeatingKey(_ atShortcut: ATShortcut, _ block: @escaping () -> Void) {
if ((timer == nil || !timer!.isValid) && atShortcut.state != .up) {
let repeatRate = ticksToSeconds(CachedUserDefaults.globalString("KeyRepeat") ?? "6")
let initialDelay = ticksToSeconds(CachedUserDefaults.globalString("InitialKeyRepeat") ?? "25")
timer = Timer(fire: Date(timeIntervalSinceNow: initialDelay), interval: repeatRate, repeats: true) { _ in
handleEvent(atShortcut, block)
}
timer!.tolerance = repeatRate * 0.1
CFRunLoopAddTimer(BackgroundWork.repeatingKeyThread.runLoop, timer!, .commonModes)
currentTimerShortcutName = atShortcut.id
}
private static func startTimerForRepeatingKey(_ atShortcut: ATShortcut, _ block: @escaping () -> Void) {
guard timerIsSuspended && atShortcut.state != .up else { return }
currentTimerShortcutName = atShortcut.id
// reading these user defaults every time guarantees we have the latest value, if the user has updated those
let repeatRate = ticksToSeconds(CachedUserDefaults.globalString("KeyRepeat") ?? "6")
let initialDelay = ticksToSeconds(CachedUserDefaults.globalString("InitialKeyRepeat") ?? "25")
Logger.debug { "\(currentTimerShortcutName) repeatRate:\(repeatRate)s initialDelay:\(initialDelay)s" }
timer.schedule(deadline: .now() + initialDelay, repeating: repeatRate, leeway: .milliseconds(Int(repeatRate * 1000 / 10)))
timer.setEventHandler { handleEvent(atShortcut, block) }
timer.resume()
timerIsSuspended = false
}
private static func handleEvent(_ atShortcut: ATShortcut, _ block: @escaping () -> Void) {
DispatchQueue.main.async {
if atShortcut.state == .up {
deactivateTimerForRepeatingKey(atShortcut.id)
stopTimerForRepeatingKey(atShortcut.id)
} else {
block()
}
+2
View File
@@ -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) }
+1
View File
@@ -19,6 +19,7 @@ class PreferencesMigrations {
}
private static func updateToNewPreferences(_ versionInPlist: String) {
Logger.debug { "App-version:\(App.version), Plist-version:\(versionInPlist)" }
// x.compare(y) is .orderedDescending if x > y
if versionInPlist.compare("7.27.0", options: .numeric) != .orderedDescending {
migrateCursorFollowFocus()
+59 -66
View File
@@ -4,58 +4,56 @@ import ScreenCaptureKit.SCShareableContent
// macOS has some privacy restrictions. The user needs to grant certain permissions, app by app, in System Preferences > Security & Privacy
class SystemPermissions {
static var preStartupPermissionsPassed = false
static var timerPermissionsToUpdatePermissionsWindow: Timer?
static var timerPermissionsRemovedWhileAltTabIsRunning: Timer?
private static var timer: DispatchSourceTimer!
static func ensurePermissionsAreGranted(_ continueAppStartup: @escaping () -> Void) {
let startupBlock = {
pollPermissionsRemovedWhileAltTabIsRunning()
continueAppStartup()
}
if AccessibilityPermission.update() != .notGranted && ScreenRecordingPermission.update() != .notGranted {
preStartupPermissionsPassed = true
startupBlock()
static func ensurePermissionsAreGranted() {
timer = DispatchSource.makeTimerSource(queue: BackgroundWork.permissionsCheckOnTimerQueue.strongUnderlyingQueue)
timer.setEventHandler(handler: checkPermissionsOnTimer)
setImmediateTimer()
timer.resume()
}
private static func checkPermissionsOnTimer() {
AccessibilityPermission.update()
ScreenRecordingPermission.update()
if !preStartupPermissionsPassed {
checkPermissionsPreStartup()
} else {
App.app.permissionsWindow.show(startupBlock)
checkPermissionsPostStartup()
}
DispatchQueue.main.async {
Menubar.togglePermissionCallout(ScreenRecordingPermission.status == .skipped)
App.app.permissionsWindow.updatePermissionViews()
}
}
static func pollPermissionsToUpdatePermissionsWindow(_ startupBlock: @escaping () -> Void) {
timerPermissionsToUpdatePermissionsWindow = Timer(timeInterval: 0.1, repeats: true) { _ in
checkPermissionsToUpdatePermissionsWindow(startupBlock)
}
timerPermissionsToUpdatePermissionsWindow!.tolerance = 0.1
CFRunLoopAddTimer(BackgroundWork.systemPermissionsThread.runLoop, timerPermissionsToUpdatePermissionsWindow!, .commonModes)
}
private static func pollPermissionsRemovedWhileAltTabIsRunning() {
timerPermissionsRemovedWhileAltTabIsRunning = Timer(timeInterval: 5, repeats: true) { _ in
private static func checkPermissionsPreStartup() {
Logger.info { "accessibility:\(AccessibilityPermission.status) screenRecording:\(ScreenRecordingPermission.status)" }
if AccessibilityPermission.status != .notGranted && ScreenRecordingPermission.status != .notGranted {
DispatchQueue.main.async {
checkPermissionsWhileAltTabIsRunning()
preStartupPermissionsPassed = true
App.app.permissionsWindow?.close()
setInfrequentTimer()
App.app.continueAppLaunchAfterPermissionsAreGranted()
}
} else {
DispatchQueue.main.async {
App.app.permissionsWindow.show()
}
}
timerPermissionsRemovedWhileAltTabIsRunning!.tolerance = 1
CFRunLoopAddTimer(BackgroundWork.systemPermissionsThread.runLoop, timerPermissionsRemovedWhileAltTabIsRunning!, .commonModes)
}
private static func checkPermissionsWhileAltTabIsRunning() {
Logger.debug(AccessibilityPermission.status, ScreenRecordingPermission.status, preStartupPermissionsPassed, ScreenRecordingPermission.status, Appearance.hideThumbnails, Preferences.previewFocusedWindow)
AccessibilityPermission.update()
Logger.debug(AccessibilityPermission.status)
private static func checkPermissionsPostStartup() {
Logger.debug { "accessibility:\(AccessibilityPermission.status) screenRecording:\(ScreenRecordingPermission.status)" }
if AccessibilityPermission.status == .notGranted {
Logger.info("accessibilityPermission not granted; restarting")
App.app.restart()
Logger.error { "Accessibility permission revoked while AltTab was running; restarting" }
DispatchQueue.main.async { App.app.restart() }
}
if ScreenRecordingPermission.status == .skipped || (Appearance.hideThumbnails && !Preferences.previewFocusedWindow) { return }
ScreenRecordingPermission.update()
Logger.debug(ScreenRecordingPermission.status)
Menubar.togglePermissionCallout(ScreenRecordingPermission.status == .skipped)
if ScreenRecordingPermission.status == .notGranted {
// permission check may yield a false negative during wake-up
// we restart after 2 negative checks
// permission check may yield a false negative during wake-up; we restart after 2 negative checks
if ScreenRecordingPermission.flakyCounter >= 2 {
Logger.info("screenRecordingPermission not granted 3 times; restarting")
App.app.restart()
Logger.error { "Screen-recording permission revoked while AltTab was running (checked 2 times); restarting" }
DispatchQueue.main.async { App.app.restart() }
} else {
ScreenRecordingPermission.flakyCounter += 1
}
@@ -64,30 +62,16 @@ class SystemPermissions {
}
}
private static func checkPermissionsToUpdatePermissionsWindow(_ startupBlock: @escaping () -> Void) {
AccessibilityPermission.update()
ScreenRecordingPermission.update()
Logger.debug(AccessibilityPermission.status, ScreenRecordingPermission.status, preStartupPermissionsPassed)
DispatchQueue.main.async {
Menubar.togglePermissionCallout(ScreenRecordingPermission.status == .skipped)
if AccessibilityPermission.status != App.app.permissionsWindow?.accessibilityView?.permissionStatus {
App.app.permissionsWindow?.accessibilityView.updatePermissionStatus(AccessibilityPermission.status)
}
if #available(macOS 10.15, *), ScreenRecordingPermission.status != App.app.permissionsWindow?.screenRecordingView?.permissionStatus {
App.app.permissionsWindow?.screenRecordingView?.updatePermissionStatus(ScreenRecordingPermission.status)
}
if !preStartupPermissionsPassed {
if AccessibilityPermission.status != .notGranted && ScreenRecordingPermission.status != .notGranted {
preStartupPermissionsPassed = true
App.app.permissionsWindow?.close()
startupBlock()
}
} else {
if AccessibilityPermission.status == .notGranted || ScreenRecordingPermission.status == .notGranted {
App.app.restart()
}
}
}
static func setInfrequentTimer() {
timer.schedule(deadline: .now() + 5, repeating: 5, leeway: .seconds(1))
}
static func setFrequentTimer() {
timer.schedule(deadline: .now() + 0.5, repeating: 0.5, leeway: .milliseconds(500))
}
private static func setImmediateTimer() {
timer.schedule(deadline: .now(), repeating: .never, leeway: .never)
}
}
@@ -116,6 +100,12 @@ class ScreenRecordingPermission {
}
private static func detect() -> PermissionStatus {
if #available(macOS 15.0, *) {
// since macOS 15.0, CGPreflightScreenCaptureAccess now returns the live-value of the permission
// before macOS 15.0, it would return the frozen-value at the time the app was launch
return CGPreflightScreenCaptureAccess() ? .granted :
(Preferences.screenRecordingPermissionSkipped ? .skipped : .notGranted)
}
if #available(macOS 10.15, *) {
return isGrantedOnSomeDisplay() ? .granted :
(Preferences.screenRecordingPermissionSkipped ? .skipped : .notGranted)
@@ -176,12 +166,15 @@ class ScreenRecordingPermission {
private static func runWithTimeout(_ block: @escaping (@escaping (Bool) -> Void) -> Void) -> Bool {
let semaphore = DispatchSemaphore(value: 0)
var result = false
block { r in
result = r
semaphore.signal()
BackgroundWork.permissionsSystemCallsQueue.addOperation {
block { r in
result = r
semaphore.signal()
}
}
let timeoutResult = semaphore.wait(timeout: .now() + 1)
let timeoutResult = semaphore.wait(timeout: .now() + 6)
if timeoutResult == .timedOut {
Logger.error { "Screen-recording permission call timed out after 6s" }
return false
}
return result
+16 -13
View File
@@ -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
@@ -33,7 +33,10 @@ class Window {
var application: Application
var axObserver: AXObserver?
var rowIndex: Int?
var debugId: String { "(wid:\(cgWindowId.map { String(describing: $0) } ?? "nil")) \(title ?? "nil")) \(application.debugId)" }
func debugId() -> String {
return "\(application.debugId()) (wid:\(cgWindowId) title:\(title))"
}
init(_ axUiElement: AXUIElement, _ application: Application, _ wid: CGWindowID, _ axTitle: String?, _ isFullscreen: Bool, _ isMinimized: Bool, _ position: CGPoint?, _ size: CGSize?) {
self.axUiElement = axUiElement
@@ -52,7 +55,7 @@ class Window {
// It may be responsive now since it has a window; we attempt again
application.observeEventsIfEligible()
checkIfFocused(application, wid)
Logger.debug(debugId)
Logger.debug { self.debugId() }
observeEvents()
}
@@ -61,11 +64,11 @@ class Window {
title = bestEffortTitle(nil)
Window.globalCreationCounter += 1
creationOrder = Window.globalCreationCounter
Logger.debug(debugId)
Logger.debug { self.debugId() }
}
deinit {
Logger.debug(debugId)
Logger.debug { self.debugId() }
}
func isEqualRobust(_ otherWindowAxUiElement: AXUIElement, _ otherWindowWid: CGWindowID?) -> Bool {
@@ -77,12 +80,12 @@ class Window {
private func observeEvents() {
AXObserverCreate(application.pid, AccessibilityEvents.axObserverCallback, &axObserver)
guard let axObserver else { return }
AXUIElement.retryAxCallUntilTimeout(context: debugId, pid: application.pid, callType: .subscribeToWindowNotification) { [weak self] in
AXUIElement.retryAxCallUntilTimeout(context: debugId(), pid: application.pid, callType: .subscribeToWindowNotification) { [weak self] in
guard let self else { return }
if try self.axUiElement!.subscribeToNotification(axObserver, Window.notifications.first!) {
Logger.debug("Subscribed to window", self.debugId)
Logger.debug { "Subscribed to window: \(self.debugId())" }
for notification in Window.notifications.dropFirst() {
AXUIElement.retryAxCallUntilTimeout(context: self.debugId, pid: self.application.pid, callType: .subscribeToWindowNotification) { [weak self] in
AXUIElement.retryAxCallUntilTimeout(context: self.debugId(), pid: self.application.pid, callType: .subscribeToWindowNotification) { [weak self] in
try self?.axUiElement!.subscribeToNotification(axObserver, notification)
}
}
@@ -91,16 +94,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)
}
}
@@ -292,7 +295,7 @@ class Window {
/// some apps will not trigger AXApplicationActivated, where we usually update application.focusedWindow
/// workaround: we check and possibly do it here
private func checkIfFocused(_ application: Application, _ wid: CGWindowID) {
AXUIElement.retryAxCallUntilTimeout(context: debugId, pid: application.pid, callType: .updateWindow) {
AXUIElement.retryAxCallUntilTimeout(context: debugId(), pid: application.pid, callType: .updateWindow) {
let focusedWid = try application.axUiElement?.focusedWindow()?.cgWindowId()
if wid == focusedWid {
application.focusedWindow = self
+15 -20
View File
@@ -74,7 +74,7 @@ class Windows {
static func updateIsFullscreenOnCurrentSpace() {
let windowsOnCurrentSpace = Windows.list.filter { !$0.isWindowlessApp }
for window in windowsOnCurrentSpace {
AXUIElement.retryAxCallUntilTimeout(context: window.debugId, after: .now() + humanPerceptionDelay, callType: .updateWindow) { [weak window] in
AXUIElement.retryAxCallUntilTimeout(context: window.debugId(), after: .now() + humanPerceptionDelay, callType: .updateWindow) { [weak window] in
guard let window else { return }
try AccessibilityEvents.updateWindowSizeAndPositionAndFullscreen(window.axUiElement!, window.cgWindowId!, window)
}
@@ -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()
}
@@ -239,7 +239,7 @@ class Windows {
let nextIndex = windowIndexAfterCycling(step)
// don't wrap-around at the end, if key-repeat
if (((step > 0 && nextIndex < focusedWindowIndex) || (step < 0 && nextIndex > focusedWindowIndex)) &&
(!allowWrap || ATShortcut.lastEventIsARepeat || KeyRepeatTimer.timer?.isValid ?? false))
(!allowWrap || ATShortcut.lastEventIsARepeat || !KeyRepeatTimer.timerIsSuspended))
// don't cycle to another row, if !allowWrap
|| (!allowWrap && list[nextIndex].rowIndex != list[focusedWindowIndex].rowIndex) {
return
@@ -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,21 +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 {
if App.app.appIsBeingUsed, #available(macOS 12.3, *) {
WindowCaptureEvents.toggleOn(source != .refreshOnlyThumbnailsAfterShowUi ? eligibleWindows : [])
}
} else {
if #available(macOS 14.0, *) {
WindowCapture.oneTimeScreenshots(eligibleWindows, source)
} else {
WindowCapture.oneTimeScreenshotsPrivateApi(eligibleWindows, source)
}
}
}
+7 -7
View File
@@ -5,15 +5,15 @@ import ApplicationServices.HIServices.AXNotificationConstants
class AccessibilityEvents {
static let axObserverCallback: AXObserverCallback = { _, element, notificationName, _ in
let type = notificationName as String
Logger.debug(type)
AXUIElement.retryAxCallUntilTimeout(callType: .axEventEntrypoint) { try handleEvent(type, element) }
Logger.debug { type }
AXUIElement.retryAxCallUntilTimeout(context: "(type:\(type)", callType: .axEventEntrypoint) { try handleEvent(type, element) }
}
static func updateWindowSizeAndPositionAndFullscreen(_ element: AXUIElement, _ wid: CGWindowID, _ window: Window?) throws {
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
@@ -33,7 +33,7 @@ class AccessibilityEvents {
// events are handled concurrently, thus we check that the app is still running
let pid = try element.pid()
if try pid != ProcessInfo.processInfo.processIdentifier || (element.subrole() != kAXUnknownSubrole) {
Logger.info(type, pid, try element.title() ?? "nil")
Logger.info { "\(type) (pid:\(pid) title:\(try? element.title()))" }
switch type {
case kAXApplicationActivatedNotification: try applicationActivated(element, pid)
case kAXApplicationHiddenNotification,
@@ -121,7 +121,7 @@ class AccessibilityEvents {
if let windows = Windows.updateLastFocus(element, wid) {
App.app.refreshOpenUi(windows, .refreshUiAfterExternalEvent)
} else {
AXUIElement.retryAxCallUntilTimeout(context: "wid:\(wid) pid:\(pid)", pid: pid, callType: .updateWindow) {
AXUIElement.retryAxCallUntilTimeout(context: "(wid:\(wid) pid:\(pid))", pid: pid, callType: .updateWindow) {
if let (title, role, subrole, isMinimized, isFullscreen) = try element.windowAttributes() {
let position = try element.position()
let size = try element.size()
@@ -167,7 +167,7 @@ class AccessibilityEvents {
private static func windowTitleChanged(_ element: AXUIElement, _ pid: pid_t) throws {
let wid = try element.cgWindowId()
AXUIElement.retryAxCallUntilTimeout(context: "\(wid)", debounceType: .windowTitleChanged, pid: pid, wid: wid, callType: .updateWindow) {
AXUIElement.retryAxCallUntilTimeout(context: "(wid:\(wid) pid:\(pid))", debounceType: .windowTitleChanged, pid: pid, wid: wid, callType: .updateWindow) {
if let (title, _, _, isMinimized, isFullscreen) = try element.windowAttributes() {
DispatchQueue.main.async {
if let window = (Windows.list.first { $0.isEqualRobust(element, wid) }), title != window.title {
@@ -183,7 +183,7 @@ class AccessibilityEvents {
private static func windowResizedOrMoved(_ element: AXUIElement, _ pid: pid_t) throws {
let wid = try element.cgWindowId()
AXUIElement.retryAxCallUntilTimeout(context: "\(wid)", debounceType: .windowResizedOrMoved, pid: pid, wid: wid, callType: .updateWindow) {
AXUIElement.retryAxCallUntilTimeout(context: "(wid:\(wid) pid:\(pid))", debounceType: .windowResizedOrMoved, pid: pid, wid: wid, callType: .updateWindow) {
try updateWindowSizeAndPositionAndFullscreen(element, wid, nil)
}
}
+4 -4
View File
@@ -7,7 +7,7 @@ class CliEvents {
let source = CFMessagePortCreateRunLoopSource(nil, messagePort, 0) {
CFRunLoopAddSource(BackgroundWork.cliEventsThread.runLoop, source, .commonModes)
} else {
Logger.error("Can't listen on message port. Is another AltTab already running?")
Logger.error { "Can't listen on message port. Is another AltTab already running?" }
// TODO: should we quit or restart here?
// It's complex since AltTab can be restarted sometimes,
// and the new instance may coexist with the old for some duration
@@ -16,16 +16,16 @@ class CliEvents {
}
private static let handleEvent: CFMessagePortCallBack = { (_: CFMessagePort?, _: Int32, _ data: CFData?, _: UnsafeMutableRawPointer?) in
Logger.debug()
Logger.debug { "" }
if let data,
let message = String(data: data as Data, encoding: .utf8) {
Logger.info(message)
Logger.info { message }
let output = CliServer.executeCommandAndSendReponse(message)
if let responseData = try? CliServer.jsonEncoder.encode(output) as CFData {
return Unmanaged.passRetained(responseData)
}
}
Logger.error("Failed to decode message")
Logger.error { "Failed to decode message" }
return nil
}
}
+2 -2
View File
@@ -13,7 +13,7 @@ class DockEvents {
AXUIElement.retryAxCallUntilTimeout(callType: .subscribeToDockNotification) {
if try axUiElement!.subscribeToNotification(axObserver!, notification.rawValue, nil) {
if notification == MissionControlState.showDesktop {
Logger.debug("Subscribed to Dock")
Logger.debug { "Subscribed to Dock" }
}
}
}
@@ -29,7 +29,7 @@ class DockEvents {
}
private static let handleEvent: AXObserverCallback = { _, _, notificationName, _ in
Logger.debug(notificationName)
Logger.debug { notificationName }
MissionControl.setState(MissionControlState(rawValue: notificationName as String)!)
}
}
+2 -2
View File
@@ -46,7 +46,7 @@ class KeyboardEvents {
fn(shortcutId, shortcut)
}
}
Logger.info("toggleGlobalShortcuts", shouldDisable)
Logger.info { "disabled:\(shouldDisable)" }
globalShortcutsAreDisabled = shouldDisable
}
}
@@ -97,7 +97,7 @@ class KeyboardEvents {
userInfo: nil)
if let eventTap {
let runLoopSource = CFMachPortCreateRunLoopSource(nil, eventTap, 0)
CFRunLoopAddSource(BackgroundWork.keyboardAndTrackpadEventsThread.runLoop, runLoopSource, .commonModes)
CFRunLoopAddSource(BackgroundWork.keyboardAndMouseAndTrackpadEventsThread.runLoop, runLoopSource, .commonModes)
} else {
App.app.restart()
}
+13 -1
View File
@@ -13,7 +13,19 @@ class KeyboardEventsTestable {
@discardableResult
func handleKeyboardEvent(_ globalId: Int?, _ shortcutState: ShortcutState?, _ keyCode: UInt32?, _ modifiers: NSEvent.ModifierFlags?, _ isARepeat: Bool) -> Bool {
Logger.debug(globalId, shortcutState, keyCode, modifiers, isARepeat, NSEvent.modifierFlags)
if let globalId, let shortcutState {
Logger.debug {
let shortcut = KeyboardEventsTestable.globalShortcutsIds.first { $0.value == globalId }
return "globalShortcut:\(shortcut?.key ?? "") state:\(shortcutState)"
}
} else {
// TODO: use proper pattern from SwiftBeaver to not compute SymbolicModifierFlagsTransformer when logs are off
Logger.debug {
let modifiersAsString = modifiers.flatMap { SymbolicModifierFlagsTransformer.shared.transformedValue(NSNumber(value: $0.rawValue)) }
let keyCodeAsString = keyCode.flatMap { SymbolicKeyCodeTransformer.shared.transformedValue(NSNumber(value: $0)) }
return "keys:\(modifiersAsString ?? "")\(keyCodeAsString ?? "") isARepeat:\(isARepeat)"
}
}
var someShortcutTriggered = false
for shortcut in ControlsTab.shortcuts.values {
if shortcut.matches(globalId, shortcutState, keyCode, modifiers) && shortcut.shouldTrigger() {
+1
View File
@@ -30,6 +30,7 @@ class MouseEvents {
if let eventTap {
MouseEvents.toggle(false)
let runLoopSource = CFMachPortCreateRunLoopSource(nil, eventTap, 0)
// we run on main-thread directly since all we do is check UI coordinates, which we must do on main-thread
CFRunLoopAddSource(CFRunLoopGetMain(), runLoopSource, .commonModes)
} else {
App.app.restart()
@@ -2,24 +2,23 @@ import Cocoa
class RunningApplicationsEvents {
private static var appsObserver: NSKeyValueObservation!
private static var previousValueOfRunningApps: Set<NSRunningApplication>!
static func observe() {
previousValueOfRunningApps = Set(NSWorkspace.shared.runningApplications)
appsObserver = NSWorkspace.shared.observe(\.runningApplications, options: [.old, .new], changeHandler: { (_, _) in handleEvent() })
// we can't observe NSWorkspace.didLaunchApplicationNotification or NSWorkspace.didTerminateApplicationNotification
// these only trigger for some apps, mostly GUI app. We need to track all processes as any could spawn a window
appsObserver = NSWorkspace.shared.observe(\.runningApplications, options: [.old, .new], changeHandler: { (_, change) in handleEvent(change) })
}
private static func handleEvent() {
let workspaceApps = Set(NSWorkspace.shared.runningApplications)
let added = workspaceApps.subtracting(previousValueOfRunningApps)
let removed = previousValueOfRunningApps.subtracting(workspaceApps)
Logger.info("added:", added.map { $0.id }, "removed:", removed.map { $0.id })
if !added.isEmpty {
Applications.addRunningApplications(Array(added))
private static func handleEvent(_ change: NSKeyValueObservedChange<[NSRunningApplication]>) {
let launched = change.newValue
let quit = change.oldValue
if let launched {
Logger.debug { "launched:\(launched.map { $0.debugId() })" }
Applications.addRunningApplications(launched)
}
if !removed.isEmpty {
Applications.removeRunningApplications(Array(removed))
if let quit {
Logger.debug { "quit:\(quit.map { $0.debugId() })" }
Applications.removeRunningApplications(quit)
}
previousValueOfRunningApps = workspaceApps
}
}
+4 -4
View File
@@ -6,14 +6,14 @@ class ScreensEvents {
}
@objc private static func handleEvent(_ notification: Notification) {
Logger.debug(notification.name.rawValue)
Logger.debug { notification.name.rawValue }
Spaces.refresh()
// a screen added or removed, or screen resolution change can mess up layout; we reset components
App.app.resetPreferencesDependentComponents()
// a screen added or removed can shuffle windows around Spaces; we refresh them
App.app.refreshOpenUi(Windows.list, .refreshUiAfterExternalEvent)
Logger.info("screens", NSScreen.screens.map { ($0.uuid() ?? "nil" as CFString, $0.frame) })
Logger.info("spaces", Spaces.screenSpacesMap)
Logger.info("current space", Spaces.currentSpaceIndex, Spaces.currentSpaceId)
Logger.info { "screens:\(NSScreen.screens.map { ($0.uuid() ?? "nil" as CFString, $0.frame) })" }
Logger.info { "spaces:\(Spaces.screenSpacesMap)" }
Logger.info { "current space:\(Spaces.currentSpaceIndex) (id:\(Spaces.currentSpaceId))" }
}
}
+1 -1
View File
@@ -28,7 +28,7 @@ class ScrollwheelEvents {
userInfo: nil)
if let eventTap {
let runLoopSource = CFMachPortCreateRunLoopSource(nil, eventTap, 0)
CFRunLoopAddSource(BackgroundWork.keyboardAndTrackpadEventsThread.runLoop, runLoopSource, .commonModes)
CFRunLoopAddSource(BackgroundWork.keyboardAndMouseAndTrackpadEventsThread.runLoop, runLoopSource, .commonModes)
} else {
App.app.restart()
}
+2 -2
View File
@@ -6,10 +6,10 @@ class SpacesEvents {
}
@objc private static func handleEvent(_ notification: Notification) {
Logger.debug(notification.name.rawValue)
Logger.debug { notification.name.rawValue }
// if UI was kept open during Space transition, the Spaces may be obsolete; we refresh them
App.app.refreshOpenUi(Windows.list, .refreshUiAfterExternalEvent)
Logger.info("current space", Spaces.currentSpaceIndex, Spaces.currentSpaceId)
Logger.info { "current space:\(Spaces.currentSpaceIndex) (id:\(Spaces.currentSpaceId))" }
// 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
@@ -8,8 +8,8 @@ class SystemAppearanceEvents {
}
@objc private static func handleEvent(_ notification: Notification) {
Logger.debug(notification.name.rawValue)
Logger.info(UserDefaults.standard.string(forKey: "AppleInterfaceStyle") ?? "Light")
Logger.debug { notification.name.rawValue }
Logger.info { UserDefaults.standard.string(forKey: "AppleInterfaceStyle") ?? "Light" }
// fix layout issues by resetting components
App.app.resetPreferencesDependentComponents()
}
@@ -6,8 +6,8 @@ class SystemScrollerStyleEvents {
}
@objc private static func handleEvent(_ notification: Notification) {
Logger.debug(notification.name.rawValue)
Logger.info(App.app.thumbnailsPanel.thumbnailsView.scrollView.scrollerStyle == .overlay ? "overlay" : "legacy")
Logger.debug { notification.name.rawValue }
Logger.info { App.app.thumbnailsPanel.thumbnailsView.scrollView.scrollerStyle == .overlay ? "overlay" : "legacy" }
// force overlay style after a change in System Preference > General > Show scroll bars
App.app.thumbnailsPanel?.thumbnailsView.scrollView.scrollerStyle = .overlay
}
+1 -1
View File
@@ -30,7 +30,7 @@ class TrackpadEvents {
userInfo: nil)
if let eventTap {
let runLoopSource = CFMachPortCreateRunLoopSource(nil, eventTap, 0)
CFRunLoopAddSource(BackgroundWork.keyboardAndTrackpadEventsThread.runLoop, runLoopSource, .commonModes)
CFRunLoopAddSource(BackgroundWork.keyboardAndMouseAndTrackpadEventsThread.runLoop, runLoopSource, .commonModes)
} else {
App.app.restart()
}
+1 -1
View File
@@ -14,7 +14,7 @@ class UserDefaultsEvents: NSObject {
}
private func handleEvent(_ keyPath: String?) {
Logger.debug(keyPath, PoliciesTab.policyLock, Preferences.updatePolicy)
Logger.debug { "\(keyPath ?? "keyPath:nil") updatePolicy:\(Preferences.updatePolicy) policyLock:\(PoliciesTab.policyLock)" }
guard !PoliciesTab.policyLock else { return }
let buttons = PoliciesTab.updatesPolicyDropdown!
let id = buttonIdToUpdate()
+264
View File
@@ -0,0 +1,264 @@
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 pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
App.app.thumbnailsPanel.thumbnailsView.metalView.enqueue(pixelBuffer, pts, for: self.wid)
// TODO: if static thumbnails then we need to draw the metal view on-event, instead of 60hz
// DispatchQueue.main.async { [wid = self.wid] in
// 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)
}
}
}
}
fileprivate let scStreamBackgroundColorDark = NSColor.init(white: 0.23, alpha: 1).cgColor
fileprivate let scStreamBackgroundColorLight = NSColor.white.cgColor
@available(macOS 12.3, *)
extension SCStreamConfiguration {
static func forWindow(_ window: SCWindow, _ video: Bool) -> SCStreamConfiguration {
let config = SCStreamConfiguration()
config.setWindowSize(window)
// ~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 ? scStreamBackgroundColorDark : scStreamBackgroundColorLight
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
}
@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)
}
}
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
}
}
+43 -37
View File
@@ -56,7 +56,7 @@ class App: AppCenterApplication {
}
func hideUi(_ keepPreview: Bool = false) {
Logger.info(appIsBeingUsed)
Logger.info { "appIsBeingUsed:\(self.appIsBeingUsed)" }
guard appIsBeingUsed else { return } // already hidden
appIsBeingUsed = false
isFirstSummon = true
@@ -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
@@ -115,7 +118,7 @@ class App: AppCenterApplication {
func focusTarget() {
guard appIsBeingUsed else { return } // already hidden
let focusedWindow = Windows.focusedWindow()
Logger.info(focusedWindow?.debugId)
Logger.info { focusedWindow?.debugId() }
focusSelectedWindow(focusedWindow)
}
@@ -124,7 +127,7 @@ class App: AppCenterApplication {
}
@objc func checkPermissions(_ sender: NSMenuItem) {
permissionsWindow.show({})
permissionsWindow.show()
}
@objc func supportProject() {
@@ -173,7 +176,7 @@ class App: AppCenterApplication {
func previousWindowShortcutWithRepeatingKey() {
cycleSelection(.trailing)
KeyRepeatTimer.toggleRepeatingKeyPreviousWindow()
KeyRepeatTimer.startRepeatingKeyPreviousWindow()
}
func focusSelectedWindow(_ selectedWindow: Window?) {
@@ -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 }
@@ -217,7 +220,7 @@ class App: AppCenterApplication {
func showUiOrCycleSelection(_ shortcutIndex: Int, _ forceDoNothingOnRelease_: Bool) {
forceDoNothingOnRelease = forceDoNothingOnRelease_
Logger.debug(shortcutIndex, self.shortcutIndex, isFirstSummon)
Logger.debug { "isFirstSummon:\(self.isFirstSummon) shortcutIndex:\(shortcutIndex)" }
App.app.appIsBeingUsed = true
if isFirstSummon || shortcutIndex != self.shortcutIndex {
NSScreen.updatePreferred()
@@ -242,7 +245,7 @@ class App: AppCenterApplication {
}
} else {
cycleSelection(.leading)
KeyRepeatTimer.toggleRepeatingKeyNextWindow()
KeyRepeatTimer.startRepeatingKeyNextWindow()
}
}
@@ -253,7 +256,7 @@ class App: AppCenterApplication {
refreshOpenUi([], .showUi)
guard appIsBeingUsed else { return }
thumbnailsPanel.show()
KeyRepeatTimer.toggleRepeatingKeyNextWindow()
KeyRepeatTimer.startRepeatingKeyNextWindow()
Windows.refreshThumbnailsAsync(Windows.list, .refreshOnlyThumbnailsAfterShowUi)
}
@@ -278,7 +281,7 @@ extension App: NSApplicationDelegate {
appCenterDelegate = AppCenterCrash()
App.shared.disableRelaunchOnLogin()
Logger.initialize()
Logger.info("Launching AltTab \(App.version)")
Logger.info { "Launching AltTab \(App.version)" }
#if DEBUG
UserDefaults.standard.set(true, forKey: "NSConstraintBasedLayoutVisualizeMutuallyExclusiveConstraints")
#endif
@@ -287,35 +290,38 @@ extension App: NSApplicationDelegate {
#endif
AXUIElement.setGlobalTimeout()
Preferences.initialize()
BackgroundWork.startSystemPermissionThread()
permissionsWindow = PermissionsWindow()
SystemPermissions.ensurePermissionsAreGranted { [weak self] in
guard let self else { return }
Logger.info("System Permissions are granted")
BackgroundWork.start()
NSScreen.updatePreferred()
Appearance.update()
Menubar.initialize()
MainMenu.loadFromXib()
self.thumbnailsPanel = ThumbnailsPanel()
self.previewPanel = PreviewPanel()
Spaces.refresh()
SpacesEvents.observe()
ScreensEvents.observe()
SystemAppearanceEvents.observe()
SystemScrollerStyleEvents.observe()
Applications.initialDiscovery()
self.preferencesWindow = PreferencesWindow()
self.feedbackWindow = FeedbackWindow()
KeyboardEvents.addEventHandlers()
MouseEvents.observe()
TrackpadEvents.observe()
CliEvents.observe()
Logger.info("AltTab finished launching")
#if DEBUG
BackgroundWork.preStart()
SystemPermissions.ensurePermissionsAreGranted()
}
func continueAppLaunchAfterPermissionsAreGranted() {
Logger.info { "System permissions are granted; continuing launch" }
BackgroundWork.start()
NSScreen.updatePreferred()
Appearance.update()
Menubar.initialize()
MainMenu.loadFromXib()
self.thumbnailsPanel = ThumbnailsPanel()
self.previewPanel = PreviewPanel()
Spaces.refresh()
SpacesEvents.observe()
ScreensEvents.observe()
SystemAppearanceEvents.observe()
SystemScrollerStyleEvents.observe()
Applications.initialDiscovery()
self.preferencesWindow = PreferencesWindow()
self.feedbackWindow = FeedbackWindow()
KeyboardEvents.addEventHandlers()
MouseEvents.observe()
TrackpadEvents.observe()
CliEvents.observe()
// login item and plist updates can be done a bit later, to accelerate launch
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { GeneralTab.startAtLoginCallback() }
Logger.info { "Finished launching AltTab" }
#if DEBUG
// self.showPreferencesWindow()
#endif
}
#endif
}
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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: "")),
@@ -88,7 +88,7 @@ class FeedbackWindow: NSWindow {
func openTicket() {
URLSession.shared.dataTask(with: prepareRequest(), completionHandler: { data, response, error in
if error != nil || response == nil || (response as! HTTPURLResponse).statusCode != 201 {
Logger.error("HTTP call failed:", response, error, data.flatMap { String(data: $0, encoding: .utf8) })
Logger.error { "HTTP call failed. response:\(response) error:\(error) data:\(data.flatMap { String(data: $0, encoding: .utf8) })" }
}
}).resume()
issueTitle.stringValue = ""
+3
View File
@@ -4,6 +4,7 @@ class Menubar {
static var statusItem: NSStatusItem!
static var menu: NSMenu!
static var permissionCalloutMenuItems: [NSMenuItem]?
static var isShowingPermissionCallout = false
static func addMenuItem(_ title: String, _ action: Selector, _ keyEquivalent: String, _ symbolName: String?, _ color: NSColor? = nil) {
let item = menu.addItem(withTitle: title, action: action, keyEquivalent: keyEquivalent)
@@ -41,6 +42,8 @@ class Menubar {
// NSMenuItem.isHidden isn't reliable with custom views. We add/remove to hide/show these items
static func togglePermissionCallout(_ show: Bool) {
guard isShowingPermissionCallout != show else { return }
isShowingPermissionCallout = show
permissionCalloutMenuItems?.enumerated().forEach { offset, element in
if show && !menu.items.contains(element) {
menu.insertItem(element, at: offset)
+28 -24
View File
@@ -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")
}
@@ -26,33 +24,25 @@ class LightImageView: NSView {
"position": NSNull(),
"contentsScale": NSNull()
]
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
}
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
}
layer!.contentsScale = scaleFactor
if frame.size != size {
frame.size = size
}
}
@@ -60,3 +50,17 @@ class LightImageView: NSView {
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()
}
}
}
@@ -0,0 +1,35 @@
#include <metal_stdlib>
using namespace metal;
struct VertexOut {
float4 position [[position]];
float2 uv;
};
vertex VertexOut thumbnailVertex(
uint vid [[vertex_id]],
const device float4 *verts [[buffer(0)]]
) {
VertexOut out;
float4 v = verts[vid];
out.position = float4(v.xy, 0.0, 1.0);
out.uv = v.zw;
return out;
}
fragment float4 thumbnailFragment(
VertexOut in [[stage_in]],
texture2d<float> yTex [[texture(0)]],
texture2d<float> cbcrTex [[texture(1)]]
) {
constexpr sampler s(address::clamp_to_edge, filter::linear);
float y = yTex.sample(s, in.uv).r;
float2 cbcr = cbcrTex.sample(s, in.uv).rg - float2(0.5, 0.5);
float r = y + 1.402 * cbcr.y;
float g = y - 0.344136 * cbcr.x - 0.714136 * cbcr.y;
float b = y + 1.772 * cbcr.x;
return float4(r, g, b, 1.0);
}
@@ -0,0 +1,192 @@
import AppKit
import Metal
import QuartzCore
import ScreenCaptureKit
final class MetalThumbnailsView: NSView {
override var wantsUpdateLayer: Bool { true }
override func makeBackingLayer() -> CALayer { metalLayer }
private let device: MTLDevice
private let commandQueue: MTLCommandQueue
private let metalLayer: CAMetalLayer
private let pipelineState: MTLRenderPipelineState
private var thumbnails = ConcurrentMap<CGWindowID, ThumbnailFrame>()
private var thumbnailsToPaint = ConcurrentMap<CGWindowID, Bool>()
var thumbnailRects = [CGWindowID: NSRect]()
// we use AnyObject to avoid compiler warning about CADisplayLink being only for macOS >= 14.0
private var displayLink: AnyObject?
private var isDrawing = false
init() {
device = MTLCreateSystemDefaultDevice()!
commandQueue = device.makeCommandQueue()!
metalLayer = CAMetalLayer()
pipelineState = MetalThumbnailsView.makePipelineState(device, metalLayer)
super.init(frame: .zero)
metalLayer.device = device
metalLayer.pixelFormat = .bgra8Unorm
metalLayer.isOpaque = false
metalLayer.framebufferOnly = true
metalLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0
metalLayer.actions = [
"contents": NSNull(),
"bounds": NSNull(),
"position": NSNull()
]
if #available(macOS 14.0, *) {
setupDisplayLink()
}
}
@available(macOS 14.0, *)
func setupDisplayLink() {
displayLink = displayLink(target: self, selector: #selector(frameTick))
displayLink!.add(to: .main, forMode: .common)
}
@objc private func frameTick() {
guard !isDrawing else { return }
isDrawing = true
let start = nowNs()
drawFrame()
let end = nowNs()
let durationNs = toNs(end - start)
Logger.error { Double(durationNs) / 1_000_000.0 }
isDrawing = false
}
required init?(coder: NSCoder) {
fatalError("Class only supports programmatic initialization")
}
override func layout() {
super.layout()
metalLayer.frame = bounds
metalLayer.drawableSize = CGSize(
width: bounds.width * metalLayer.contentsScale,
height: bounds.height * metalLayer.contentsScale
)
}
private static func makePipelineState(_ device: MTLDevice, _ metalLayer: CAMetalLayer) -> MTLRenderPipelineState {
let library = device.makeDefaultLibrary()!
let descriptor = MTLRenderPipelineDescriptor()
descriptor.vertexFunction = library.makeFunction(name: "thumbnailVertex")
descriptor.fragmentFunction = library.makeFunction(name: "thumbnailFragment")
descriptor.colorAttachments[0].pixelFormat = metalLayer.pixelFormat
let attachment = descriptor.colorAttachments[0]!
attachment.isBlendingEnabled = true
attachment.rgbBlendOperation = .add
attachment.alphaBlendOperation = .add
attachment.sourceRGBBlendFactor = .sourceAlpha
attachment.destinationRGBBlendFactor = .oneMinusSourceAlpha
attachment.sourceAlphaBlendFactor = .one
attachment.destinationAlphaBlendFactor = .oneMinusSourceAlpha
do {
return try device.makeRenderPipelineState(descriptor: descriptor)
} catch {
Logger.error { error }
fatalError("\(error)")
}
}
private func makePlaneTexture(_ pixelBuffer: CVPixelBuffer, plane: Int, pixelFormat: MTLPixelFormat) -> MTLTexture? {
guard let surface = CVPixelBufferGetIOSurface(pixelBuffer)?.takeUnretainedValue() else { return nil }
let width = CVPixelBufferGetWidthOfPlane(pixelBuffer, plane)
let height = CVPixelBufferGetHeightOfPlane(pixelBuffer, plane)
let desc = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: pixelFormat, width: width, height: height, mipmapped: false)
desc.storageMode = .shared
desc.usage = [.shaderRead]
return device.makeTexture(descriptor: desc, iosurface: surface, plane: plane)
}
func enqueue(_ pixelBuffer: CVPixelBuffer, _ pts: CMTime, for id: CGWindowID) {
guard let yTex = makePlaneTexture(pixelBuffer, plane: 0, pixelFormat: .r8Unorm),
let cbcrTex = makePlaneTexture(pixelBuffer, plane: 1, pixelFormat: .rg8Unorm)
else { return }
let frame = ThumbnailFrame(yTex: yTex, cbcrTex: cbcrTex, pts: pts)
thumbnails.withLock { t in
t[id] = frame
}
thumbnailsToPaint.withLock { t in
t[id] = true
let x = t.filter { $0.value }.count
Logger.error { x }
}
Logger.error { (Windows.list.first {$0.cgWindowId == id }?.title) }
}
func drawFrame() {
let t0 = nowNs()
let drawable = metalLayer.nextDrawable()
let t1 = nowNs()
Logger.error { "nextDrawable() took \((t1 - t0)/1_000_000) ms" }
guard
let drawable,
let commandBuffer = commandQueue.makeCommandBuffer()
else { return }
let renderPass = MTLRenderPassDescriptor()
renderPass.colorAttachments[0].texture = drawable.texture
renderPass.colorAttachments[0].loadAction = .clear
renderPass.colorAttachments[0].storeAction = .store
renderPass.colorAttachments[0].clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 0)
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPass) else { return }
encoder.setRenderPipelineState(pipelineState)
let thumbnailsToPaintIds = thumbnailsToPaint.withLock { ($0.filter { t in t.value }).keys }
Logger.error { thumbnailsToPaintIds.count }
for id in thumbnailsToPaintIds {
guard let rect = thumbnailRects[id] else { continue }
guard let frame = (thumbnails.withLock { $0[id] }) else { continue }
drawThumbnail(frame: frame, in: rect, encoder: encoder)
thumbnailsToPaint.withLock { $0[id] = false }
}
encoder.endEncoding()
commandBuffer.present(drawable)
commandBuffer.commit()
// BackgroundWork.screenshotsQueue.strongUnderlyingQueue.async(flags: .barrier) {
// self?.thumbnails = nil
// self?.thumbnailRects = nil
// }
}
private func drawThumbnail(frame: ThumbnailFrame, in rect: CGRect, encoder: MTLRenderCommandEncoder) {
let viewSize = bounds.size
// Convert AppKit rect → NDC
let x0 = Float((rect.minX / viewSize.width) * 2 - 1)
let x1 = Float((rect.maxX / viewSize.width) * 2 - 1)
let y0 = Float((rect.minY / viewSize.height) * 2 - 1)
let y1 = Float((rect.maxY / viewSize.height) * 2 - 1)
let vertices: [Float] = [x0, y0, 0, 1, x1, y0, 1, 1, x0, y1, 0, 0, x1, y1, 1, 0]
encoder.setVertexBytes(vertices, length: vertices.count * MemoryLayout<Float>.size, index: 0)
encoder.setFragmentTexture(frame.yTex, index: 0)
encoder.setFragmentTexture(frame.cbcrTex, index: 1)
encoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4)
}
}
struct ThumbnailFrame {
let yTex: MTLTexture
let cbcrTex: MTLTexture
let pts: CMTime
}
import MachO
@inline(__always)
func nowNs() -> UInt64 {
mach_continuous_time()
}
let timebase: mach_timebase_info = {
var info = mach_timebase_info()
mach_timebase_info(&info)
return info
}()
@inline(__always)
func toNs(_ t: UInt64) -> UInt64 {
t * UInt64(timebase.numer) / UInt64(timebase.denom)
}
+17 -12
View File
@@ -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 {
+28 -21
View File
@@ -143,21 +143,27 @@ class ThumbnailView: FlippedView {
}
func showOrHideWindowControls(_ shouldShowWindowControls: Bool) {
guard Preferences.appearanceStyle == .thumbnails else { return }
let shouldShow = shouldShowWindowControls && !Preferences.hideColoredCircles && !Appearance.hideThumbnails
if Preferences.appearanceStyle == .thumbnails {
isShowingWindowControls = shouldShow
for icon in windowControlIcons {
icon.isHidden = !shouldShow
|| (icon.type == .quit && !(window_?.application.canBeQuit() ?? true))
|| (icon.type == .close && !(window_?.canBeClosed() ?? true))
|| ((icon.type == .miniaturize || icon.type == .fullscreen) && !(window_?.canBeMinDeminOrFullscreened() ?? true))
// Force the icons to redraw; otherwise after clicking the fullscreen button,
// it will still appear to be in fullscreen mode.
icon.display()
guard isShowingWindowControls != shouldShow else { return }
isShowingWindowControls = shouldShow
CATransaction.begin()
CATransaction.setDisableActions(true)
for icon in windowControlIcons {
let shouldHide = !shouldShow
|| (icon.type == .quit && !(window_?.application.canBeQuit() ?? true))
|| (icon.type == .close && !(window_?.canBeClosed() ?? true))
|| ((icon.type == .miniaturize || icon.type == .fullscreen) && !(window_?.canBeMinDeminOrFullscreened() ?? true))
if icon.isHidden != shouldHide {
icon.isHidden = shouldHide
icon.needsDisplay = true
}
}
CATransaction.commit()
}
func updateDockLabelIcon(_ dockLabel: String?) {
assignIfDifferent(&dockLabelIcon.isHidden, dockLabel == nil || Preferences.hideAppBadges || Appearance.iconSize == 0)
if !dockLabelIcon.isHidden, let dockLabel {
@@ -185,6 +191,7 @@ class ThumbnailView: FlippedView {
let shadow = ThumbnailView.makeShadow(Appearance.imagesShadowColor)
thumbnailContainer.wantsLayer = true
thumbnailContainer.layer!.masksToBounds = false // let thumbnail shadows show
thumbnail.layer!.opacity = 0
thumbnail.shadow = shadow
windowlessIcon.shadow = shadow
appIcon.shadow = shadow
@@ -317,7 +324,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 +342,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 +373,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 +587,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 +599,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)
@@ -77,16 +77,18 @@ func makeAppropriateEffectView() -> EffectView {
if #available(macOS 26.0, *) {
if Preferences.appearanceStyle == .appIcons {
if LiquidGlassEffectView.canUsePrivateLiquidGlassLook() {
Logger.debug("Using LiquidGlassEffectView(true)")
Logger.debug { "Using LiquidGlassEffectView(true)" }
return LiquidGlassEffectView(true)
}
let os = ProcessInfo.processInfo.operatingSystemVersion
let version = "\(os.majorVersion).\(os.minorVersion).\(os.patchVersion)"
Logger.error("Private API set_variant is no longer available. macOS version: \(version))")
Logger.error {
let os = ProcessInfo.processInfo.operatingSystemVersion
let version = "\(os.majorVersion).\(os.minorVersion).\(os.patchVersion)"
return "Private API set_variant is no longer available. macOS version: \(version))"
}
}
Logger.debug("Using LiquidGlassEffectView(false)")
Logger.debug { "Using LiquidGlassEffectView(false)" }
return LiquidGlassEffectView(false)
}
Logger.debug("Using FrostedGlassEffectView(nil)")
Logger.debug { "Using FrostedGlassEffectView(nil)" }
return FrostedGlassEffectView(nil)
}
+36 -22
View File
@@ -3,6 +3,7 @@ import Cocoa
class ThumbnailsView {
var scrollView: ScrollView!
var contentView: EffectView!
var metalView: MetalThumbnailsView!
var rows = [[ThumbnailView]]()
static var recycledViews = [ThumbnailView]()
static var thumbnailsWidth = CGFloat(0.0)
@@ -10,6 +11,7 @@ class ThumbnailsView {
init() {
updateBackgroundView()
metalView = MetalThumbnailsView()
// TODO: think about this optimization more
(1...20).forEach { _ in ThumbnailsView.recycledViews.append(ThumbnailView()) }
}
@@ -58,7 +60,7 @@ class ThumbnailsView {
}
}
if ((step > 0 && nextRow < currentRow) || (step < 0 && nextRow > currentRow)) &&
(ATShortcut.lastEventIsARepeat || KeyRepeatTimer.timer?.isValid ?? false) {
(ATShortcut.lastEventIsARepeat || !KeyRepeatTimer.timerIsSuspended) {
return nil
}
return rows[nextRow]
@@ -141,6 +143,11 @@ class ThumbnailsView {
rows[rows.count - 1].append(view)
newViews.append(view)
window.rowIndex = rows.count - 1
if let wid = window.cgWindowId {
App.app.thumbnailsPanel.thumbnailsView.metalView.thumbnailRects[wid] = NSRect.init(
origin: view.frame.origin,
size: view.thumbnail.frame.size)
}
} else {
// release images from unused recycledViews; they take lots of RAM
view.thumbnail.releaseImage()
@@ -197,6 +204,8 @@ class ThumbnailsView {
}
scrollView.addTrackingArea(NSTrackingArea(rect: scrollView.bounds,
options: [.mouseMoved, .mouseEnteredAndExited, .activeAlways], owner: scrollView, userInfo: nil))
scrollView.documentView!.addSubview(metalView, positioned: .below, relativeTo: nil)
metalView.frame.size = scrollView.frame.size
}
func centerRows(_ maxX: CGFloat) {
@@ -277,33 +286,38 @@ class ScrollView: NSScrollView {
}
}
override func mouseExited(with event: NSEvent) {
previousTarget = nil
resetHoveredWindow()
}
override func mouseMoved(with event: NSEvent) {
// disable mouse hover during scrolling as it creates jank during elastic bounces at the start/end of the scrollview
// also disable it when ScrollwheelEvents is enabled, as it indicates that a multi-finder gesture is in progress
if isCurrentlyScrolling || ScrollwheelEvents.shouldBeEnabled { return }
if let hit = hitTest(App.app.thumbnailsPanel.mouseLocationOutsideOfEventStream) {
var target: NSView? = hit
while !(target is ThumbnailView) && target != nil {
target = target!.superview
}
if let target = target as? ThumbnailView {
if previousTarget != target {
previousTarget?.showOrHideWindowControls(false)
previousTarget = target
}
target.mouseMoved()
} else {
if !checkIfWithinInterPadding() {
resetHoveredWindow()
}
}
guard let documentView, !isCurrentlyScrolling && !ScrollwheelEvents.shouldBeEnabled else { return }
let location = documentView.convert(App.app.thumbnailsPanel.mouseLocationOutsideOfEventStream, from: nil)
let newTarget = findTarget(location)
guard newTarget !== previousTarget else { return }
CATransaction.begin()
CATransaction.setDisableActions(true)
if let newTarget {
previousTarget?.showOrHideWindowControls(false)
newTarget.mouseMoved()
} else {
resetHoveredWindow()
}
previousTarget = newTarget
CATransaction.commit()
}
override func mouseExited(with event: NSEvent) {
resetHoveredWindow()
private func findTarget(_ location: NSPoint) -> ThumbnailView? {
for i in 0..<Windows.list.count {
let view = ThumbnailsView.recycledViews[i]
let frame = view.frame
let expandedFrame = CGRect(x: frame.minX - (App.shared.userInterfaceLayoutDirection == .leftToRight ? 0 : 1), y: frame.minY, width: frame.width + 1, height: frame.height + 1)
if expandedFrame.contains(location) {
return view
}
}
return nil
}
/// Checks whether the mouse pointer is within the padding area around a thumbnail.
@@ -43,6 +43,7 @@ class PermissionView: StackView {
}
func updatePermissionStatus(_ permissionStatus: PermissionStatus) {
guard status.stringValue.isEmpty || permissionStatus != self.permissionStatus else { return }
self.permissionStatus = permissionStatus
var color: NSColor
var label: String
@@ -13,19 +13,22 @@ class PermissionsWindow: NSWindow {
setupView()
}
func show(_ startupBlock: @escaping () -> Void) {
accessibilityView.updatePermissionStatus(AccessibilityPermission.update())
if #available(macOS 10.15, *) {
screenRecordingView.updatePermissionStatus(ScreenRecordingPermission.update())
}
func show() {
guard !isVisible else { return }
Logger.debug { "" }
SystemPermissions.setFrequentTimer()
updatePermissionViews()
center()
App.shared.activate(ignoringOtherApps: true)
makeKeyAndOrderFront(nil)
if #available(macOS 10.15, *), !SystemPermissions.preStartupPermissionsPassed {
// this call triggers the permission prompt, however it's the only way to force the app to be listed with a checkbox
SLSRequestScreenCaptureAccess()
}
func updatePermissionViews() {
guard isVisible else { return }
accessibilityView.updatePermissionStatus(AccessibilityPermission.status)
if #available(macOS 10.15, *) {
screenRecordingView.updatePermissionStatus(ScreenRecordingPermission.status)
}
SystemPermissions.pollPermissionsToUpdatePermissionsWindow(startupBlock)
}
private func setupWindow() {
@@ -37,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
@@ -84,17 +87,22 @@ class PermissionsWindow: NSWindow {
}
extension PermissionsWindow: NSWindowDelegate {
func windowWillClose(_ notification: Notification) {
Logger.debug(SystemPermissions.preStartupPermissionsPassed)
func windowShouldClose(_ sender: NSWindow) -> Bool {
Logger.debug { "preStartupPermissionsPassed:\(SystemPermissions.preStartupPermissionsPassed), accessibility:\(AccessibilityPermission.status), screenRecording:\(ScreenRecordingPermission.status)" }
if !SystemPermissions.preStartupPermissionsPassed {
if AccessibilityPermission.update() == .notGranted || ScreenRecordingPermission.update() == .notGranted {
Logger.error("Before using this app, you need to give permission in System Settings > Privacy & Security > Accessibility.",
"Please authorize and re-launch.",
"See https://help.rescuetime.com/article/59-how-do-i-enable-accessibility-permissions-on-mac-osx")
if AccessibilityPermission.status == .notGranted || ScreenRecordingPermission.status == .notGranted {
Logger.error {
"""
Before using this app, you need to give permission in System Settings > Privacy & Security > Accessibility.
Please authorize and re-launch.
See https://help.rescuetime.com/article/59-how-do-i-enable-accessibility-permissions-on-mac-osx
"""
}
App.shared.terminate(self)
return false // prevent the close; termination will close everything once
}
} else {
SystemPermissions.timerPermissionsToUpdatePermissionsWindow?.invalidate()
}
SystemPermissions.setInfrequentTimer()
return true
}
}
@@ -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),
@@ -20,6 +20,7 @@ class GeneralTab {
]
static var menubarIconDropdown: NSPopUpButton?
private static var menubarIsVisibleObserver: NSKeyValueObservation?
private static var startAtLoginToggle: NSControl?
static func initTab() -> NSView {
let startAtLogin = TableGroupView.Row(leftTitle: NSLocalizedString("Start at login", comment: ""),
@@ -44,7 +45,7 @@ class GeneralTab {
cell.bezelStyle = .regularSquare
cell.arrowPosition = .arrowAtBottom
cell.imagePosition = .imageOverlaps
startAtLoginCallback(startAtLogin.rightViews[0] as! NSControl)
startAtLoginToggle = startAtLogin.rightViews[0] as? NSControl
Menubar.menubarIconCallback(nil)
enableDraggingOffMenubarIcon(menuIconShownToggle)
let table = TableGroupView(width: PreferencesWindow.width)
@@ -83,8 +84,8 @@ class GeneralTab {
}
/// add/remove plist file in ~/Library/LaunchAgents/ depending on the checkbox state
static func startAtLoginCallback(_ sender: NSControl) {
let sender = sender as! Switch
static func startAtLoginCallback(_: NSControl? = nil) {
let sender = startAtLoginToggle as! Switch
// if the user has added AltTab manually as a LoginItem, we remove it, and add AltTab as a LaunchAgent
// LaunchAgent are the recommended method for open-at-login in recent versions of macos
if (GeneralTab.self as AvoidDeprecationWarnings.Type).removeLoginItemIfPresent() && sender.state == .off {
@@ -94,7 +95,7 @@ class GeneralTab {
do {
try writePlistToDisk(sender)
} catch let error {
Logger.error("Failed to write plist file to disk", error)
Logger.error { "Failed to write plist file to disk. error:\(error)" }
}
}
@@ -103,17 +104,17 @@ class GeneralTab {
launchAgentsPath.appendPathComponent("LaunchAgents", isDirectory: true)
if !FileManager.default.fileExists(atPath: launchAgentsPath.path) {
try FileManager.default.createDirectory(at: launchAgentsPath, withIntermediateDirectories: false)
Logger.debug(launchAgentsPath.absoluteString + " created")
Logger.debug { launchAgentsPath.absoluteString + " created" }
}
launchAgentsPath.appendPathComponent("com.lwouis.alt-tab-macos.plist", isDirectory: false)
if sender.state == .on {
let data = try PropertyListSerialization.data(fromPropertyList: launchAgentPlist, format: .xml, options: 0)
try data.write(to: launchAgentsPath, options: [.atomic])
Logger.debug(launchAgentsPath.absoluteString + " written")
Logger.debug { launchAgentsPath.absoluteString + " written" }
} else {
if FileManager.default.fileExists(atPath: launchAgentsPath.path) {
try FileManager.default.removeItem(at: launchAgentsPath)
Logger.debug(launchAgentsPath.absoluteString + " removed")
Logger.debug { launchAgentsPath.absoluteString + " removed" }
}
}
}
@@ -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: "")
+1 -1
View File
@@ -50,7 +50,7 @@ class ControlsTab {
}
class KeyRepeatTimer {
static func deactivateTimerForRepeatingKey(_ shortcutName: String) {
static func stopTimerForRepeatingKey(_ shortcutName: String) {
}
}