Compare commits

..
Author SHA1 Message Date
Suat Kocar c65d1e60f8 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
2025-12-21 00:24:36 +01:00
47 changed files with 331 additions and 695 deletions

No files matched your search

-8
View File
@@ -76,13 +76,11 @@
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 */; };
@@ -376,7 +374,6 @@
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>"; };
@@ -520,7 +517,6 @@
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>"; };
@@ -1599,8 +1595,6 @@
D04BA258B56193958D60978A /* ThumbnailView.swift */,
D04BAA998119CAA8B70A2B67 /* ThumbnailsView.swift */,
AA974BA829B7D84C0099A29E /* PreviewPanel.swift */,
BF0C8E476869D8097FE4A9F1 /* MetalThumbnailsView.swift */,
BF0C80E288D7EBC42B5D165F /* MetalThumbnailsView.metal */,
);
path = "main-window";
sourceTree = "<group>";
@@ -2482,8 +2476,6 @@
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;
};
+4 -3
View File
@@ -83,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) }
@@ -91,7 +91,7 @@ extension AXUIElement {
return
}
// retry
Logger.info { "(pid:\(pid) wid:\(wid)) \(logFromContext(file, function, line, context, callType))" }
Logger.info(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)
}
@@ -290,7 +290,8 @@ extension AXUIElement {
crossoverWindow(app, role, subrole, level) ||
isAlwaysOnTopScrcpy(app, level, role, subrole)
) || (
(
// CGWindowLevel == .normalWindow helps filter out iStats Pro and other top-level pop-overs, and floating windows
level == CGWindow.normalLevel && (
[kAXStandardWindowSubrole, kAXDialogSubrole].contains(subrole) ||
openBoard(app) ||
adobeAudition(app, subrole) ||
+1 -14
View File
@@ -328,7 +328,7 @@ extension DispatchTimeInterval {
}
extension NSRunningApplication {
func debugId() -> String { "(pid:\(processIdentifier) \(bundleIdentifier ?? bundleURL?.absoluteString ?? executableURL?.absoluteString ?? localizedName))" }
var id: String { "pid:\(String(describing: processIdentifier)) app:\(bundleIdentifier ?? bundleURL?.absoluteString ?? executableURL?.absoluteString ?? localizedName ?? "nil")" }
}
// 250ms is similar to human delay in processing changes on screen
@@ -347,16 +347,3 @@ 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(_ 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 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 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 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 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 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 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)
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)
}
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 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 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.keyboardAndMouseAndTrackpadEventsThread.runLoop!, CFRunLoopMode.commonModes.rawValue)
IOHIDManagerScheduleWithRunLoop(manager, BackgroundWork.keyboardAndTrackpadEventsThread.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()) })
Logger.error("CGWindowListCopyWindowInfo extra", windows.sorted { $0.id()! < $1.id()! }.map { ($0.id()!, $0.title() ?? "nil") })
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 { self.id }
Logger.info("executeAction", 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.stopTimerForRepeatingKey(id)
KeyRepeatTimer.deactivateTimerForRepeatingKey(id)
}
}
}
+5 -7
View File
@@ -27,7 +27,7 @@ class Appearance {
static var highlightBorderWidth = CGFloat(3)
// theme: constants
static var enablePanelShadow = true
static let enablePanelShadow = true
static var highlightFocusedBackgroundColor: NSColor { get { NSColor.systemAccentColor.withAlphaComponent(0.2) } }
static var highlightHoveredBackgroundColor: NSColor { get { NSColor.systemAccentColor.withAlphaComponent(0.1) } }
static var highlightFocusedBorderColor: NSColor { get { NSColor.systemAccentColor } }
@@ -75,12 +75,10 @@ class Appearance {
} else {
lightTheme()
}
// for Liquid Glass, we don't want a shadow around the panel
if #available(macOS 26.0, *), currentStyle == .appIcons && LiquidGlassEffectView.canUsePrivateLiquidGlassLook() {
enablePanelShadow = false
} else {
enablePanelShadow = true
}
// // for Liquid Glass, we don't want a shadow around the panel
// if #available(macOS 26.0, *), currentStyle == .appIcons && LiquidGlassEffectView.canUsePrivateLiquidGlassLook() {
// enablePanelShadow = false
// }
}
private static func thumbnailsSize(_ isHorizontalScreen: Bool) {
+7 -10
View File
@@ -19,10 +19,7 @@ class Application: NSObject {
var dockLabel: String?
var focusedWindow: Window? = nil
var alreadyRequestedToQuit = false
func debugId() -> String {
return "(pid:\(pid) \(bundleIdentifier ?? bundleURL?.absoluteString ?? executableURL?.absoluteString ?? localizedName))"
}
var debugId: String { "(pid:\(String(describing: pid)) \(bundleIdentifier ?? bundleURL?.absoluteString ?? executableURL?.absoluteString ?? localizedName ?? "nil"))" }
static let notifications = [
kAXApplicationActivatedNotification,
@@ -68,7 +65,7 @@ class Application: NSObject {
bundleURL = runningApplication.bundleURL
executableURL = runningApplication.executableURL
super.init()
Logger.debug { self.debugId() }
Logger.debug(debugId)
observeEventsIfEligible()
kvObservers = [
runningApplication.observe(\.isFinishedLaunching, options: [.new]) { [weak self] _, _ in
@@ -86,7 +83,7 @@ class Application: NSObject {
}
deinit {
Logger.debug { self.debugId() }
Logger.debug("Deinit app", debugId)
}
func removeWindowslessAppWindow() {
@@ -109,7 +106,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)
@@ -203,17 +200,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.debug { window.debugId() }
Logger.error("----", window.title, window.application.bundleIdentifier)
Windows.removeWindow(index, window.application.pid)
}
}
+32 -37
View File
@@ -2,69 +2,64 @@ import Foundation
// queues and dedicated threads to observe background events such as keyboard inputs, or accessibility events
class BackgroundWork {
// 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!
// we use an OperationQueue tied to the global DispatchQueue to call blocking APIs in parallel
static var screenshotsQueue: LabeledOperationQueue!
static var accessibilityCommandsQueue: LabeledOperationQueue!
static var axCallsFirstAttemptQueue: LabeledOperationQueue!
static var axCallsRetriesQueue: LabeledOperationQueue!
static var axCallsManualDiscoveryQueue: LabeledOperationQueue!
static var crashReportsQueue: LabeledOperationQueue!
static var permissionsCheckOnTimerQueue: LabeledOperationQueue!
static var permissionsSystemCallsQueue: 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!
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("screenshots", .userInteractive, 8)
screenshotsQueue = LabeledOperationQueue("screenshotsQueue", .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("axCommands", .userInteractive, 4)
accessibilityCommandsQueue = LabeledOperationQueue("accessibilityCommandsQueue", .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("axCallsFirst", .userInteractive, 8)
axCallsRetriesQueue = LabeledOperationQueue("axCallsRetry", .userInteractive, 8)
axCallsFirstAttemptQueue = LabeledOperationQueue("axCallsFirstAttemptQueue", .userInteractive, 8)
axCallsRetriesQueue = LabeledOperationQueue("axCallsRetriesQueue", .userInteractive, 8)
// we separate calls to manuallyUpdateWindows since those can be very heavy and numerous
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)
axCallsManualDiscoveryQueue = LabeledOperationQueue("axCallsManualDiscoveryQueue", .userInteractive, 8)
// we observe app and windows notifications. They arrive on this thread, and are handled off the main thread initially
accessibilityEventsThread = BackgroundThreadWithRunLoop("axEvents", .userInteractive)
accessibilityEventsThread = BackgroundThreadWithRunLoop("accessibilityEventsThread", .userInteractive)
// we listen to as any keyboard events as possible on a background thread, as it's more available/reliable than the main thread
keyboardAndMouseAndTrackpadEventsThread = BackgroundThreadWithRunLoop("inputDevices", .userInteractive)
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)
// we main Mission Control state on a background thread. We protect reads from main-thread with an NSLock
missionControlThread = BackgroundThreadWithRunLoop("missionControl", .userInteractive)
missionControlThread = BackgroundThreadWithRunLoop("missionControlThread", .userInteractive)
// we listen to CLI commands (CFMessagePort events)
cliEventsThread = BackgroundThreadWithRunLoop("cliMessages", .userInteractive)
cliEventsThread = BackgroundThreadWithRunLoop("cliEventsThread", .userInteractive)
// logThreadsAndQueuesOnRepeat()
}
static func startCrashReportsQueue() {
if crashReportsQueue == nil {
// crash reports can be sent off the main thread
crashReportsQueue = LabeledOperationQueue("crashReports", .utility, 1)
crashReportsQueue = LabeledOperationQueue("crashReportsQueue", .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 <= 45)
assert(totalPotentialThreadCount <= 43)
}
// useful during development to inspect how many threads are used by AltTab
@@ -87,7 +82,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 {
@@ -114,8 +109,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 {
@@ -133,8 +128,8 @@ class BackgroundWork {
}
override func main() {
Logger.debug { "Thread ready" }
// the RunLoop is lazy; calling this initializes it
Logger.debug(name)
// the RunLoop is lazy; calling this initialize it
runLoop = CFRunLoopGetCurrent()
addDummySourceToPreventRunLoopTermination()
threadStartSemaphore.signal()
+20 -22
View File
@@ -2,54 +2,52 @@ import Cocoa
import ShortcutRecorder
class KeyRepeatTimer {
static var timer = DispatchSource.makeTimerSource(queue: BackgroundWork.repeatingKeyQueue.strongUnderlyingQueue)
static var timerIsSuspended = true
static var timer: Timer?
static var currentTimerShortcutName: String?
static func startRepeatingKeyPreviousWindow() {
static func toggleRepeatingKeyPreviousWindow() {
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 {
startTimerForRepeatingKey(shortcut) {
activateTimerForRepeatingKey(shortcut) {
App.app.previousWindowShortcutWithRepeatingKey()
}
}
}
static func startRepeatingKeyNextWindow() {
static func toggleRepeatingKeyNextWindow() {
if let shortcut = ControlsTab.shortcuts[Preferences.indexToName("nextWindowShortcut", App.app.shortcutIndex)] {
startTimerForRepeatingKey(shortcut) {
activateTimerForRepeatingKey(shortcut) {
ControlsTab.executeAction(Preferences.indexToName("nextWindowShortcut", App.app.shortcutIndex))
}
}
}
static func stopTimerForRepeatingKey(_ shortcutName: String) {
static func deactivateTimerForRepeatingKey(_ shortcutName: String) {
if shortcutName == currentTimerShortcutName {
Logger.debug { shortcutName }
Logger.debug(shortcutName)
currentTimerShortcutName = nil
timer.suspend()
timerIsSuspended = true
timer?.invalidate()
}
}
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 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 handleEvent(_ atShortcut: ATShortcut, _ block: @escaping () -> Void) {
DispatchQueue.main.async {
if atShortcut.state == .up {
stopTimerForRepeatingKey(atShortcut.id)
deactivateTimerForRepeatingKey(atShortcut.id)
} else {
block()
}
-1
View File
@@ -19,7 +19,6 @@ 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()
+72 -65
View File
@@ -4,56 +4,58 @@ 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
private static var timer: DispatchSourceTimer!
static var timerPermissionsToUpdatePermissionsWindow: Timer?
static var timerPermissionsRemovedWhileAltTabIsRunning: Timer?
static func ensurePermissionsAreGranted() {
timer = DispatchSource.makeTimerSource(queue: BackgroundWork.permissionsCheckOnTimerQueue.strongUnderlyingQueue)
timer.setEventHandler(handler: checkPermissionsOnTimer)
setImmediateTimer()
timer.resume()
static func ensurePermissionsAreGranted(_ continueAppStartup: @escaping () -> Void) {
let startupBlock = {
pollPermissionsRemovedWhileAltTabIsRunning()
continueAppStartup()
}
if AccessibilityPermission.update() != .notGranted && ScreenRecordingPermission.update() != .notGranted {
preStartupPermissionsPassed = true
startupBlock()
} else {
App.app.permissionsWindow.show(startupBlock)
}
}
private static func checkPermissionsOnTimer() {
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
DispatchQueue.main.async {
checkPermissionsWhileAltTabIsRunning()
}
}
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()
ScreenRecordingPermission.update()
if !preStartupPermissionsPassed {
checkPermissionsPreStartup()
} else {
checkPermissionsPostStartup()
}
DispatchQueue.main.async {
Menubar.togglePermissionCallout(ScreenRecordingPermission.status == .skipped)
App.app.permissionsWindow.updatePermissionViews()
}
}
private static func checkPermissionsPreStartup() {
Logger.info { "accessibility:\(AccessibilityPermission.status) screenRecording:\(ScreenRecordingPermission.status)" }
if AccessibilityPermission.status != .notGranted && ScreenRecordingPermission.status != .notGranted {
DispatchQueue.main.async {
preStartupPermissionsPassed = true
App.app.permissionsWindow?.close()
setInfrequentTimer()
App.app.continueAppLaunchAfterPermissionsAreGranted()
}
} else {
DispatchQueue.main.async {
App.app.permissionsWindow.show()
}
}
}
private static func checkPermissionsPostStartup() {
Logger.debug { "accessibility:\(AccessibilityPermission.status) screenRecording:\(ScreenRecordingPermission.status)" }
Logger.debug(AccessibilityPermission.status)
if AccessibilityPermission.status == .notGranted {
Logger.error { "Accessibility permission revoked while AltTab was running; restarting" }
DispatchQueue.main.async { App.app.restart() }
Logger.info("accessibilityPermission not granted; restarting")
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.error { "Screen-recording permission revoked while AltTab was running (checked 2 times); restarting" }
DispatchQueue.main.async { App.app.restart() }
Logger.info("screenRecordingPermission not granted 3 times; restarting")
App.app.restart()
} else {
ScreenRecordingPermission.flakyCounter += 1
}
@@ -62,16 +64,30 @@ class SystemPermissions {
}
}
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)
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()
}
}
}
}
}
@@ -100,12 +116,6 @@ 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)
@@ -166,15 +176,12 @@ class ScreenRecordingPermission {
private static func runWithTimeout(_ block: @escaping (@escaping (Bool) -> Void) -> Void) -> Bool {
let semaphore = DispatchSemaphore(value: 0)
var result = false
BackgroundWork.permissionsSystemCallsQueue.addOperation {
block { r in
result = r
semaphore.signal()
}
block { r in
result = r
semaphore.signal()
}
let timeoutResult = semaphore.wait(timeout: .now() + 6)
let timeoutResult = semaphore.wait(timeout: .now() + 1)
if timeoutResult == .timedOut {
Logger.error { "Screen-recording permission call timed out after 6s" }
return false
}
return result
+8 -11
View File
@@ -33,10 +33,7 @@ class Window {
var application: Application
var axObserver: AXObserver?
var rowIndex: Int?
func debugId() -> String {
return "\(application.debugId()) (wid:\(cgWindowId) title:\(title))"
}
var debugId: String { "(wid:\(cgWindowId.map { String(describing: $0) } ?? "nil")) \(title ?? "nil")) \(application.debugId)" }
init(_ axUiElement: AXUIElement, _ application: Application, _ wid: CGWindowID, _ axTitle: String?, _ isFullscreen: Bool, _ isMinimized: Bool, _ position: CGPoint?, _ size: CGSize?) {
self.axUiElement = axUiElement
@@ -55,7 +52,7 @@ class Window {
// It may be responsive now since it has a window; we attempt again
application.observeEventsIfEligible()
checkIfFocused(application, wid)
Logger.debug { self.debugId() }
Logger.debug(debugId)
observeEvents()
}
@@ -64,11 +61,11 @@ class Window {
title = bestEffortTitle(nil)
Window.globalCreationCounter += 1
creationOrder = Window.globalCreationCounter
Logger.debug { self.debugId() }
Logger.debug(debugId)
}
deinit {
Logger.debug { self.debugId() }
Logger.debug(debugId)
}
func isEqualRobust(_ otherWindowAxUiElement: AXUIElement, _ otherWindowWid: CGWindowID?) -> Bool {
@@ -80,12 +77,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)
}
}
@@ -295,7 +292,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
+10 -12
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)
}
@@ -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.timerIsSuspended))
(!allowWrap || ATShortcut.lastEventIsARepeat || KeyRepeatTimer.timer?.isValid ?? false))
// don't cycle to another row, if !allowWrap
|| (!allowWrap && list[nextIndex].rowIndex != list[focusedWindowIndex].rowIndex) {
return
@@ -339,17 +339,15 @@ class Windows {
}
}
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)
}
if Preferences.videoThumbnailsAndPreview && App.app.appIsBeingUsed, #available(macOS 12.3, *) {
WindowCaptureEvents.toggleOn(source != .refreshOnlyThumbnailsAfterShowUi ? eligibleWindows : [])
return
}
if !Preferences.videoThumbnailsAndPreview, #available(macOS 14.0, *) {
WindowCapture.oneTimeScreenshots(eligibleWindows, source)
return
}
WindowCapture.oneTimeScreenshotsPrivateApi(eligibleWindows, source)
}
static func refreshWhichWindowsToShowTheUser() {
+6 -6
View File
@@ -5,8 +5,8 @@ import ApplicationServices.HIServices.AXNotificationConstants
class AccessibilityEvents {
static let axObserverCallback: AXObserverCallback = { _, element, notificationName, _ in
let type = notificationName as String
Logger.debug { type }
AXUIElement.retryAxCallUntilTimeout(context: "(type:\(type)", callType: .axEventEntrypoint) { try handleEvent(type, element) }
Logger.debug(type)
AXUIElement.retryAxCallUntilTimeout(callType: .axEventEntrypoint) { try handleEvent(type, element) }
}
static func updateWindowSizeAndPositionAndFullscreen(_ element: AXUIElement, _ wid: CGWindowID, _ window: Window?) throws {
@@ -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:\(pid) title:\(try? element.title()))" }
Logger.info(type, pid, try element.title() ?? "nil")
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:\(wid) pid:\(pid))", debounceType: .windowTitleChanged, pid: pid, wid: wid, callType: .updateWindow) {
AXUIElement.retryAxCallUntilTimeout(context: "\(wid)", 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:\(wid) pid:\(pid))", debounceType: .windowResizedOrMoved, pid: pid, wid: wid, callType: .updateWindow) {
AXUIElement.retryAxCallUntilTimeout(context: "\(wid)", 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 { "disabled:\(shouldDisable)" }
Logger.info("toggleGlobalShortcuts", shouldDisable)
globalShortcutsAreDisabled = shouldDisable
}
}
@@ -97,7 +97,7 @@ class KeyboardEvents {
userInfo: nil)
if let eventTap {
let runLoopSource = CFMachPortCreateRunLoopSource(nil, eventTap, 0)
CFRunLoopAddSource(BackgroundWork.keyboardAndMouseAndTrackpadEventsThread.runLoop, runLoopSource, .commonModes)
CFRunLoopAddSource(BackgroundWork.keyboardAndTrackpadEventsThread.runLoop, runLoopSource, .commonModes)
} else {
App.app.restart()
}
+1 -13
View File
@@ -13,19 +13,7 @@ class KeyboardEventsTestable {
@discardableResult
func handleKeyboardEvent(_ globalId: Int?, _ shortcutState: ShortcutState?, _ keyCode: UInt32?, _ modifiers: NSEvent.ModifierFlags?, _ isARepeat: Bool) -> Bool {
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)"
}
}
Logger.debug(globalId, shortcutState, keyCode, modifiers, isARepeat, NSEvent.modifierFlags)
var someShortcutTriggered = false
for shortcut in ControlsTab.shortcuts.values {
if shortcut.matches(globalId, shortcutState, keyCode, modifiers) && shortcut.shouldTrigger() {
-1
View File
@@ -30,7 +30,6 @@ 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,23 +2,24 @@ import Cocoa
class RunningApplicationsEvents {
private static var appsObserver: NSKeyValueObservation!
private static var previousValueOfRunningApps: Set<NSRunningApplication>!
static func observe() {
// 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) })
previousValueOfRunningApps = Set(NSWorkspace.shared.runningApplications)
appsObserver = NSWorkspace.shared.observe(\.runningApplications, options: [.old, .new], changeHandler: { (_, _) in handleEvent() })
}
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)
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))
}
if let quit {
Logger.debug { "quit:\(quit.map { $0.debugId() })" }
Applications.removeRunningApplications(quit)
if !removed.isEmpty {
Applications.removeRunningApplications(Array(removed))
}
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) (id:\(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, 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.keyboardAndMouseAndTrackpadEventsThread.runLoop, runLoopSource, .commonModes)
CFRunLoopAddSource(BackgroundWork.keyboardAndTrackpadEventsThread.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) (id:\(Spaces.currentSpaceId))" }
Logger.info("current space", Spaces.currentSpaceIndex, 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.keyboardAndMouseAndTrackpadEventsThread.runLoop, runLoopSource, .commonModes)
CFRunLoopAddSource(BackgroundWork.keyboardAndTrackpadEventsThread.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 ?? "keyPath:nil") updatePolicy:\(Preferences.updatePolicy) policyLock:\(PoliciesTab.policyLock)" }
Logger.debug(keyPath, PoliciesTab.policyLock, Preferences.updatePolicy)
guard !PoliciesTab.policyLock else { return }
let buttons = PoliciesTab.updatesPolicyDropdown!
let id = buttonIdToUpdate()
+7 -31
View File
@@ -108,16 +108,12 @@ class WindowCaptureEvents {
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))
// }
// }
let wid_ = wid
DispatchQueue.main.async {
if let window = (Windows.list.first { $0.cgWindowId == wid_ }) {
window.refreshThumbnail(.pixelBuffer(pixelBuffer))
}
}
}
}
}
@@ -131,19 +127,12 @@ class WindowCaptureEvents {
}
}
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.pixelFormat = kCVPixelFormatType_32BGRA
config.showsCursor = false
if video {
config.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(60))
@@ -172,19 +161,6 @@ extension CMSampleBuffer {
}
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 {
+36 -38
View File
@@ -56,7 +56,7 @@ class App: AppCenterApplication {
}
func hideUi(_ keepPreview: Bool = false) {
Logger.info { "appIsBeingUsed:\(self.appIsBeingUsed)" }
Logger.info(appIsBeingUsed)
guard appIsBeingUsed else { return } // already hidden
appIsBeingUsed = false
isFirstSummon = true
@@ -118,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)
}
@@ -127,7 +127,7 @@ class App: AppCenterApplication {
}
@objc func checkPermissions(_ sender: NSMenuItem) {
permissionsWindow.show()
permissionsWindow.show({})
}
@objc func supportProject() {
@@ -176,7 +176,7 @@ class App: AppCenterApplication {
func previousWindowShortcutWithRepeatingKey() {
cycleSelection(.trailing)
KeyRepeatTimer.startRepeatingKeyPreviousWindow()
KeyRepeatTimer.toggleRepeatingKeyPreviousWindow()
}
func focusSelectedWindow(_ selectedWindow: Window?) {
@@ -220,9 +220,10 @@ class App: AppCenterApplication {
func showUiOrCycleSelection(_ shortcutIndex: Int, _ forceDoNothingOnRelease_: Bool) {
forceDoNothingOnRelease = forceDoNothingOnRelease_
Logger.debug { "isFirstSummon:\(self.isFirstSummon) shortcutIndex:\(shortcutIndex)" }
Logger.debug(shortcutIndex, self.shortcutIndex, isFirstSummon)
App.app.appIsBeingUsed = true
if isFirstSummon || shortcutIndex != self.shortcutIndex {
MainMenu.toggle(enabled: false)
NSScreen.updatePreferred()
if isVeryFirstSummon {
Windows.sortByLevel()
@@ -245,7 +246,7 @@ class App: AppCenterApplication {
}
} else {
cycleSelection(.leading)
KeyRepeatTimer.startRepeatingKeyNextWindow()
KeyRepeatTimer.toggleRepeatingKeyNextWindow()
}
}
@@ -256,7 +257,7 @@ class App: AppCenterApplication {
refreshOpenUi([], .showUi)
guard appIsBeingUsed else { return }
thumbnailsPanel.show()
KeyRepeatTimer.startRepeatingKeyNextWindow()
KeyRepeatTimer.toggleRepeatingKeyNextWindow()
Windows.refreshThumbnailsAsync(Windows.list, .refreshOnlyThumbnailsAfterShowUi)
}
@@ -281,7 +282,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
@@ -290,38 +291,35 @@ extension App: NSApplicationDelegate {
#endif
AXUIElement.setGlobalTimeout()
Preferences.initialize()
BackgroundWork.startSystemPermissionThread()
permissionsWindow = PermissionsWindow()
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
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
// self.showPreferencesWindow()
#endif
#endif
}
}
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
+1 -1
View File
@@ -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:\(response) error:\(error) data:\(data.flatMap { String(data: $0, encoding: .utf8) })" }
Logger.error("HTTP call failed:", response, error, data.flatMap { String(data: $0, encoding: .utf8) })
}
}).resume()
issueTitle.stringValue = ""
+6 -17
View File
@@ -1,6 +1,5 @@
class MainMenu {
private static var mainMenu: NSMenu!
private static var menuItemsWithShortcut = [NSMenuItem: String]()
/// classic keyboard shortcuts like copy-and-paste are missing without a MainMenu. We generated the default menu from XCode and load it
/// see https://stackoverflow.com/a/3746058/2249756
@@ -9,26 +8,16 @@ class MainMenu {
Bundle.main.loadNibNamed("MainMenu", owner: self, topLevelObjects: &menuObjects)
mainMenu = menuObjects?.first { $0 is NSMenu } as? NSMenu
App.shared.mainMenu = mainMenu
rememberMenuItemsWithShortcut()
}
/// we toggle the mainMenu off when showing the main window, and on when showing secondary windows
static func toggle(enabled: Bool) {
for (item, keyEquivalent) in menuItemsWithShortcut {
item.keyEquivalent = enabled ? keyEquivalent : ""
}
}
private static func rememberMenuItemsWithShortcut() {
var stack: [NSMenu] = [mainMenu]
while let menu = stack.popLast() {
for item in menu.items {
if !item.keyEquivalent.isEmpty {
menuItemsWithShortcut[item] = item.keyEquivalent
}
if let submenu = item.submenu {
stack.append(submenu)
}
if enabled {
if App.shared.mainMenu == nil {
App.shared.mainMenu = mainMenu
}
} else {
App.shared.mainMenu = nil
}
}
}
-3
View File
@@ -4,7 +4,6 @@ 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)
@@ -42,8 +41,6 @@ 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)
@@ -24,7 +24,6 @@ class LightImageView: NSView {
"position": NSNull(),
"contentsScale": NSNull()
]
layerContentsRedrawPolicy = .never
}
func updateWithResizedCopy(_ caLayerContents: CALayerContents, _ size: NSSize) {
@@ -41,9 +40,7 @@ class LightImageView: NSView {
default: break
}
layer!.contentsScale = scaleFactor
if frame.size != size {
frame.size = size
}
frame.size = size
}
func releaseImage() {
@@ -1,35 +0,0 @@
#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);
}
@@ -1,192 +0,0 @@
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)
}
+10 -17
View File
@@ -143,27 +143,21 @@ class ThumbnailView: FlippedView {
}
func showOrHideWindowControls(_ shouldShowWindowControls: Bool) {
guard Preferences.appearanceStyle == .thumbnails else { return }
let shouldShow = shouldShowWindowControls && !Preferences.hideColoredCircles && !Appearance.hideThumbnails
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
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()
}
}
CATransaction.commit()
}
func updateDockLabelIcon(_ dockLabel: String?) {
assignIfDifferent(&dockLabelIcon.isHidden, dockLabel == nil || Preferences.hideAppBadges || Appearance.iconSize == 0)
if !dockLabelIcon.isHidden, let dockLabel {
@@ -191,7 +185,6 @@ 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
-9
View File
@@ -99,15 +99,6 @@ extension ThumbnailsPanel: NSWindowDelegate {
if App.app.appIsBeingUsed {
App.app.thumbnailsPanel.makeKeyAndOrderFront(nil)
}
MainMenu.toggle(enabled: true)
}
}
func windowDidBecomeKey(_ notification: Notification) {
// we toggle the mainMenu off when showing the main window
// this avoids command+q from quitting AltTab itself, or command+p from printing
DispatchQueue.main.async {
MainMenu.toggle(enabled: false)
}
}
}
@@ -77,18 +77,16 @@ 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)
}
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))"
}
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.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)
}
+22 -36
View File
@@ -3,7 +3,6 @@ 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)
@@ -11,7 +10,6 @@ class ThumbnailsView {
init() {
updateBackgroundView()
metalView = MetalThumbnailsView()
// TODO: think about this optimization more
(1...20).forEach { _ in ThumbnailsView.recycledViews.append(ThumbnailView()) }
}
@@ -60,7 +58,7 @@ class ThumbnailsView {
}
}
if ((step > 0 && nextRow < currentRow) || (step < 0 && nextRow > currentRow)) &&
(ATShortcut.lastEventIsARepeat || !KeyRepeatTimer.timerIsSuspended) {
(ATShortcut.lastEventIsARepeat || KeyRepeatTimer.timer?.isValid ?? false) {
return nil
}
return rows[nextRow]
@@ -143,11 +141,6 @@ 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()
@@ -204,8 +197,6 @@ 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) {
@@ -286,38 +277,33 @@ class ScrollView: NSScrollView {
}
}
override func mouseExited(with event: NSEvent) {
previousTarget = nil
resetHoveredWindow()
}
override func mouseMoved(with event: NSEvent) {
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()
// 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()
}
}
} else {
resetHoveredWindow()
}
previousTarget = newTarget
CATransaction.commit()
}
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
override func mouseExited(with event: NSEvent) {
resetHoveredWindow()
}
/// Checks whether the mouse pointer is within the padding area around a thumbnail.
@@ -38,12 +38,9 @@ class PermissionView: StackView {
self.status = status
wantsLayer = true
layer!.cornerRadius = GridView.interPadding / 2
setContentHuggingPriority(.required, for: .horizontal)
setContentCompressionResistancePriority(.required, for: .horizontal)
}
func updatePermissionStatus(_ permissionStatus: PermissionStatus) {
guard status.stringValue.isEmpty || permissionStatus != self.permissionStatus else { return }
self.permissionStatus = permissionStatus
var color: NSColor
var label: String
@@ -13,22 +13,19 @@ class PermissionsWindow: NSWindow {
setupView()
}
func show() {
guard !isVisible else { return }
Logger.debug { "" }
SystemPermissions.setFrequentTimer()
updatePermissionViews()
func show(_ startupBlock: @escaping () -> Void) {
accessibilityView.updatePermissionStatus(AccessibilityPermission.update())
if #available(macOS 10.15, *) {
screenRecordingView.updatePermissionStatus(ScreenRecordingPermission.update())
}
center()
App.shared.activate(ignoringOtherApps: true)
makeKeyAndOrderFront(nil)
}
func updatePermissionViews() {
guard isVisible else { return }
accessibilityView.updatePermissionStatus(AccessibilityPermission.status)
if #available(macOS 10.15, *) {
screenRecordingView.updatePermissionStatus(ScreenRecordingPermission.status)
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()
}
SystemPermissions.pollPermissionsToUpdatePermissionsWindow(startupBlock)
}
private func setupWindow() {
@@ -72,37 +69,25 @@ class PermissionsWindow: NSWindow {
)
rows.append([screenRecordingView])
}
let widestRowWidth = rows.reduce(0) { max($0, $1[0]!.fittingSize.width) }
rows.forEach { $0[0]!.fit(widestRowWidth, $0[0]!.fittingSize.height) }
let view = GridView(rows as! [[NSView]])
view.fit()
setContentSize(view.fittingSize)
contentView = view
}
override func close() {
hideAppIfLastWindowIsClosed()
super.close()
}
}
extension PermissionsWindow: NSWindowDelegate {
func windowShouldClose(_ sender: NSWindow) -> Bool {
Logger.debug { "preStartupPermissionsPassed:\(SystemPermissions.preStartupPermissionsPassed), accessibility:\(AccessibilityPermission.status), screenRecording:\(ScreenRecordingPermission.status)" }
func windowWillClose(_ notification: Notification) {
Logger.debug(SystemPermissions.preStartupPermissionsPassed)
if !SystemPermissions.preStartupPermissionsPassed {
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
"""
}
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")
App.shared.terminate(self)
return false // prevent the close; termination will close everything once
}
} else {
SystemPermissions.timerPermissionsToUpdatePermissionsWindow?.invalidate()
}
SystemPermissions.setInfrequentTimer()
return true
}
}
@@ -20,7 +20,6 @@ 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: ""),
@@ -45,7 +44,7 @@ class GeneralTab {
cell.bezelStyle = .regularSquare
cell.arrowPosition = .arrowAtBottom
cell.imagePosition = .imageOverlaps
startAtLoginToggle = startAtLogin.rightViews[0] as? NSControl
startAtLoginCallback(startAtLogin.rightViews[0] as! NSControl)
Menubar.menubarIconCallback(nil)
enableDraggingOffMenubarIcon(menuIconShownToggle)
let table = TableGroupView(width: PreferencesWindow.width)
@@ -84,8 +83,8 @@ class GeneralTab {
}
/// add/remove plist file in ~/Library/LaunchAgents/ depending on the checkbox state
static func startAtLoginCallback(_: NSControl? = nil) {
let sender = startAtLoginToggle as! Switch
static func startAtLoginCallback(_ sender: NSControl) {
let sender = sender 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 {
@@ -95,7 +94,7 @@ class GeneralTab {
do {
try writePlistToDisk(sender)
} catch let error {
Logger.error { "Failed to write plist file to disk. error:\(error)" }
Logger.error("Failed to write plist file to disk", error)
}
}
@@ -104,17 +103,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")
}
}
}
+1 -1
View File
@@ -50,7 +50,7 @@ class ControlsTab {
}
class KeyRepeatTimer {
static func stopTimerForRepeatingKey(_ shortcutName: String) {
static func deactivateTimerForRepeatingKey(_ shortcutName: String) {
}
}