diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index f82b69b9..fd0e13c7 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -11,7 +11,7 @@ A80397D22A93287C006D2796 /* MenuBarExtraAccess in Frameworks */ = {isa = PBXBuildFile; productRef = A80397D12A93287C006D2796 /* MenuBarExtraAccess */; }; A80397D42A932993006D2796 /* MenubarIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80397D32A932993006D2796 /* MenubarIconView.swift */; }; A82521EC29E234EB00139654 /* AboutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A82521EB29E234EB00139654 /* AboutViewController.swift */; }; - A82521EE29E235AC00139654 /* AccessibilityAccessManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A82521ED29E235AC00139654 /* AccessibilityAccessManager.swift */; }; + A82521EE29E235AC00139654 /* PermissionsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A82521ED29E235AC00139654 /* PermissionsManager.swift */; }; A8330ABD2A3AC0CA00673C8D /* Bundle+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8330ABC2A3AC0CA00673C8D /* Bundle+Extensions.swift */; }; A8330AC12A3AC13100673C8D /* Defaults+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8330AC02A3AC13100673C8D /* Defaults+Extensions.swift */; }; A8330AC52A3AC15900673C8D /* Notification+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8330AC42A3AC15900673C8D /* Notification+Extensions.swift */; }; @@ -33,6 +33,7 @@ A88266002980931C00BCB197 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A88265FF2980931C00BCB197 /* SettingsView.swift */; }; A882660829809F6F00BCB197 /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A882660729809F6F00BCB197 /* GeneralSettingsView.swift */; }; A883642F298B7288005D6C19 /* ServiceManagement.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A883642E298B7288005D6C19 /* ServiceManagement.framework */; }; + A8878A252AA3B2C800850A66 /* WindowTransformAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8878A242AA3B2C800850A66 /* WindowTransformAnimation.swift */; }; A8A2ABE72A3FB0370067B5A9 /* KeybindMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8A2ABE62A3FB0370067B5A9 /* KeybindMonitor.swift */; }; A8A2ABEB2A3FBFBA0067B5A9 /* VisualEffectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8A2ABEA2A3FBFBA0067B5A9 /* VisualEffectView.swift */; }; A8D5A7D62A91384D004EA5BB /* DirectionSelectorSquareSegment.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8D5A7D52A91384D004EA5BB /* DirectionSelectorSquareSegment.swift */; }; @@ -58,7 +59,7 @@ A80397D32A932993006D2796 /* MenubarIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenubarIconView.swift; sourceTree = ""; }; A80521312A84878200BF7E22 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; }; A82521EB29E234EB00139654 /* AboutViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutViewController.swift; sourceTree = ""; }; - A82521ED29E235AC00139654 /* AccessibilityAccessManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityAccessManager.swift; sourceTree = ""; }; + A82521ED29E235AC00139654 /* PermissionsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsManager.swift; sourceTree = ""; }; A8291D6D2A4513D200C5CB69 /* .swiftlint.yml */ = {isa = PBXFileReference; indentWidth = 2; lastKnownFileType = text.yaml; path = .swiftlint.yml; sourceTree = ""; tabWidth = 2; }; A8330ABC2A3AC0CA00673C8D /* Bundle+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Extensions.swift"; sourceTree = ""; }; A8330AC02A3AC13100673C8D /* Defaults+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Defaults+Extensions.swift"; sourceTree = ""; }; @@ -82,6 +83,7 @@ A88265FF2980931C00BCB197 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; A882660729809F6F00BCB197 /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; A883642E298B7288005D6C19 /* ServiceManagement.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ServiceManagement.framework; path = System/Library/Frameworks/ServiceManagement.framework; sourceTree = SDKROOT; }; + A8878A242AA3B2C800850A66 /* WindowTransformAnimation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowTransformAnimation.swift; sourceTree = ""; }; A8A2ABE62A3FB0370067B5A9 /* KeybindMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeybindMonitor.swift; sourceTree = ""; }; A8A2ABEA2A3FBFBA0067B5A9 /* VisualEffectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectView.swift; sourceTree = ""; }; A8D5A7D52A91384D004EA5BB /* DirectionSelectorSquareSegment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectionSelectorSquareSegment.swift; sourceTree = ""; }; @@ -131,16 +133,17 @@ A8330ABB2A3AC05200673C8D /* Helpers */ = { isa = PBXGroup; children = ( + A848D8A62A8C2F3F00060834 /* LoopManager.swift */, A86CB7322A3D22E7006A78F2 /* WindowEngine.swift */, A87376F52AA288EB001890F4 /* Window.swift */, + A8878A242AA3B2C800850A66 /* WindowTransformAnimation.swift */, A8330AD32A3AC27600673C8D /* WindowDirection.swift */, A8A2ABE62A3FB0370067B5A9 /* KeybindMonitor.swift */, - A82521ED29E235AC00139654 /* AccessibilityAccessManager.swift */, + A82521ED29E235AC00139654 /* PermissionsManager.swift */, A8EF1F08299C87DF00633440 /* IconManager.swift */, A8E6D2002A416494005751D4 /* LoopTriggerKeys.swift */, A8504D2C2A85832F00C2EFDA /* SoftwareUpdater.swift */, A8A2ABEA2A3FBFBA0067B5A9 /* VisualEffectView.swift */, - A848D8A62A8C2F3F00060834 /* LoopManager.swift */, ); path = Helpers; sourceTree = ""; @@ -383,6 +386,7 @@ A8DCC98A2981F43F00D41065 /* PreviewSettingsView.swift in Sources */, A87376F62AA288EB001890F4 /* Window.swift in Sources */, A8330AC52A3AC15900673C8D /* Notification+Extensions.swift in Sources */, + A8878A252AA3B2C800850A66 /* WindowTransformAnimation.swift in Sources */, A8D5A7D82A913862004EA5BB /* DirectionSelectorCircleSegment.swift in Sources */, A83667C82A3D7D910001D630 /* AXUIElement+Extensions.swift in Sources */, A8330AD42A3AC27600673C8D /* WindowDirection.swift in Sources */, @@ -396,7 +400,7 @@ A8330ACB2A3AC1C000673C8D /* Angle+Extensions.swift in Sources */, A8504D2D2A85832F00C2EFDA /* SoftwareUpdater.swift in Sources */, A8330ACF2A3AC1E900673C8D /* View+Extensions.swift in Sources */, - A82521EE29E235AC00139654 /* AccessibilityAccessManager.swift in Sources */, + A82521EE29E235AC00139654 /* PermissionsManager.swift in Sources */, A8E59C4A297F98670064D4BA /* RadialMenuController.swift in Sources */, A86949862A8F2BB70051AAAF /* CGKeyCode+Extensions.swift in Sources */, A80397D42A932993006D2796 /* MenubarIconView.swift in Sources */, diff --git a/Loop/Helpers/AccessibilityAccessManager.swift b/Loop/Helpers/AccessibilityAccessManager.swift deleted file mode 100644 index 44fbd1b2..00000000 --- a/Loop/Helpers/AccessibilityAccessManager.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// AccessibilityAccessManager.swift -// Loop -// -// Created by Kai Azim on 2023-04-08. -// - -import SwiftUI -import Defaults - -class AccessibilityAccessManager { - @discardableResult - func getStatus() -> Bool { - // Get current state for accessibility access - let options: NSDictionary = [kAXTrustedCheckOptionPrompt.takeRetainedValue() as NSString: false] - let status = AXIsProcessTrustedWithOptions(options) - - return status - } - - @discardableResult - func requestAccess() -> Bool { - let alert = NSAlert() - alert.messageText = "\(Bundle.main.appName) Needs Accessibility Permissions" - alert.informativeText = "Welcome to \(Bundle.main.appName)! Please grant accessibility access to be able to resize windows." - alert.runModal() - - // Get current state for accessibility access - let options: NSDictionary = [kAXTrustedCheckOptionPrompt.takeRetainedValue() as NSString: true] - let status = AXIsProcessTrustedWithOptions(options) - - return status - } -} diff --git a/Loop/Helpers/KeybindMonitor.swift b/Loop/Helpers/KeybindMonitor.swift index 16d0df9d..7161bcaf 100644 --- a/Loop/Helpers/KeybindMonitor.swift +++ b/Loop/Helpers/KeybindMonitor.swift @@ -8,8 +8,6 @@ import Cocoa class KeybindMonitor { - - static private let accessibilityAccessManager = AccessibilityAccessManager() static let shared = KeybindMonitor() private var eventTap: CFMachPort? @@ -89,7 +87,7 @@ class KeybindMonitor { self.eventTap = newEventTap - if KeybindMonitor.accessibilityAccessManager.getStatus() { + if PermissionsManager.Accessibility.getStatus() { let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, newEventTap, 0) CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) CGEvent.tapEnable(tap: newEventTap!, enable: true) diff --git a/Loop/Helpers/LoopManager.swift b/Loop/Helpers/LoopManager.swift index c636f933..a7038bc0 100644 --- a/Loop/Helpers/LoopManager.swift +++ b/Loop/Helpers/LoopManager.swift @@ -10,7 +10,7 @@ import Defaults class LoopManager { - private let accessibilityAccessManager = AccessibilityAccessManager() + private let accessibilityAccessManager = PermissionsManager() private let keybindMonitor = KeybindMonitor.shared private let iconManager = IconManager() @@ -80,7 +80,7 @@ class LoopManager { frontmostWindow = nil // Loop will only open if accessibility access has been granted - if accessibilityAccessManager.getStatus() { + if PermissionsManager.Accessibility.getStatus() { self.frontmostWindow = WindowEngine.getFrontmostWindow() self.screenWithMouse = NSScreen.screenWithMouse diff --git a/Loop/Helpers/PermissionsManager.swift b/Loop/Helpers/PermissionsManager.swift new file mode 100644 index 00000000..00541d76 --- /dev/null +++ b/Loop/Helpers/PermissionsManager.swift @@ -0,0 +1,50 @@ +// +// AccessibilityAccessManager.swift +// Loop +// +// Created by Kai Azim on 2023-04-08. +// + +import SwiftUI +import Defaults + +class PermissionsManager { + class Accessibility { + static func getStatus() -> Bool { + // Get current state for accessibility access + let options: NSDictionary = [kAXTrustedCheckOptionPrompt.takeRetainedValue() as NSString: false] + let status = AXIsProcessTrustedWithOptions(options) + + return status + } + + static func requestAccess() { + if PermissionsManager.Accessibility.getStatus() { + return + } + let alert = NSAlert() + alert.messageText = "\(Bundle.main.appName) Needs Accessibility Permissions" + alert.informativeText = "Please grant accessibility access to be able to resize windows." + alert.runModal() + } + } + + class ScreenRecording { + static func getStatus() -> Bool { + return CGPreflightScreenCaptureAccess() + } + + static func requestAccess() { + if PermissionsManager.ScreenRecording.getStatus() { + return + } + + let alert = NSAlert() + alert.messageText = "\(Bundle.main.appName) Needs Screen Recording Permissions" + alert.informativeText = "Please grant screen recording access to be able to resize windows (with animation)." + alert.runModal() + + CGRequestScreenCaptureAccess() + } + } +} diff --git a/Loop/Helpers/Window.swift b/Loop/Helpers/Window.swift index 7a4249db..8aa2774a 100644 --- a/Loop/Helpers/Window.swift +++ b/Loop/Helpers/Window.swift @@ -98,11 +98,63 @@ class Window { return CGRect(origin: self.origin, size: self.size) } - @discardableResult - func setFrame(_ rect: CGRect) -> Bool { - if self.setOrigin(rect.origin) && self.setSize(rect.size) { - return true + func setFrame(_ rect: CGRect, animate: Bool = false) { + if animate { + let animation = WindowTransformAnimation(rect, window: self) + animation.startInBackground() + } else { + self.setOrigin(rect.origin) + self.setSize(rect.size) + } + } + + /// MacOS doesn't provide us a way to find the minimum size of a window from the accessibility API. + /// So we deliberately force-resize the window to 0x0 and see how small it goes, take note of the frame, + /// then we resotere the original window size. However, this does have one big consequence. The user + /// can see a single frame when the window is being resized to 0x0, then restored. So to counteract this, + /// we take a screenshot of the screen, overlay it, and get the minimum size then close the overlay window. + /// - Parameters: + /// - screen: The screen the window is on + /// - completion: What to do with the minimum size + func getMinSize(screen: NSScreen, completion: @escaping (CGSize) -> Void) { + // Take screenshot of screen + guard let displayID = screen.displayID else { return } + let imageRef = CGDisplayCreateImage(displayID) + let image = NSImage(cgImage: imageRef!, size: .zero) + + // Initialize the overlay NSPanel + let panel = NSPanel( + contentRect: .zero, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.hasShadow = false + panel.backgroundColor = NSColor.white.withAlphaComponent(0.00001) + panel.level = .floating + panel.ignoresMouseEvents = true + panel.setFrame(screen.frame, display: false) + panel.contentView = NSImageView(image: image) + panel.orderFrontRegardless() + + var minSize: CGSize = .zero + DispatchQueue.main.async { + + // Force-resize the window to 0x0 + let startingSize = self.size + self.setSize(CGSize(width: 0, height: 0)) + + // Take note of the minimum size + minSize = self.size + + // Restore original window size + self.setSize(startingSize) + + DispatchQueue.main.async { + // Close window, then activate completion handler + panel.close() + completion(minSize) + } } - return false } } diff --git a/Loop/Helpers/WindowEngine.swift b/Loop/Helpers/WindowEngine.swift index 8d406a80..73d8d368 100644 --- a/Loop/Helpers/WindowEngine.swift +++ b/Loop/Helpers/WindowEngine.swift @@ -13,18 +13,21 @@ struct WindowEngine { window.setFullscreen(false) let oldWindowFrame = window.frame - guard let screenFrame = screen.safeScreenFrame, - let newWindowFrame = WindowEngine.generateWindowFrame(oldWindowFrame, screenFrame, direction) - else { return } + guard let screenFrame = screen.safeScreenFrame else { return } + guard var targetWindowFrame = WindowEngine.generateWindowFrame(oldWindowFrame, screenFrame, direction) else { return } - window.setFrame(newWindowFrame) + // Calculate the window's minimum window size and change the target accordingly + window.getMinSize(screen: screen) { minSize in + if (targetWindowFrame.minX + minSize.width) > screen.frame.maxX { + targetWindowFrame.origin.x = screen.frame.maxX - minSize.width + } - if window.frame != newWindowFrame { - WindowEngine.handleSizeConstrainedWindow( - window: window, - windowFrame: window.frame, - screenFrame: screenFrame - ) + if (targetWindowFrame.minY + minSize.height) > screen.frame.maxY { + targetWindowFrame.origin.y = screen.frame.maxY - minSize.height + } + + // Resize window + window.setFrame(targetWindowFrame, animate: true) } } @@ -33,7 +36,7 @@ struct WindowEngine { let window = Window(pid: app.processIdentifier) else { return nil } #if DEBUG - print("=== NEW WINDOW ===") + print("===== NEW WINDOW =====") print("Frontmost app: \(app)") print("kAXWindowRole: \(window.role ?? "N/A")") print("kAXStandardWindowSubrole: \(window.subrole ?? "N/A")") @@ -98,25 +101,4 @@ struct WindowEngine { return nil } } - - private static func handleSizeConstrainedWindow(window: Window, windowFrame: CGRect, screenFrame: CGRect) { - - // If the window is fully shown on the screen - if (windowFrame.maxX <= screenFrame.maxX) && (windowFrame.maxY <= screenFrame.maxY) { - return - } - - // If not, then Loop will auto re-adjust the window size to be fully shown on the screen - var fixedWindowFrame = windowFrame - - if fixedWindowFrame.maxX > screenFrame.maxX { - fixedWindowFrame.origin.x = screenFrame.maxX - fixedWindowFrame.width - } - - if fixedWindowFrame.maxY > screenFrame.maxY { - fixedWindowFrame.origin.y = screenFrame.maxY - fixedWindowFrame.height - } - - window.setOrigin(fixedWindowFrame.origin) - } } diff --git a/Loop/Helpers/WindowTransformAnimation.swift b/Loop/Helpers/WindowTransformAnimation.swift new file mode 100644 index 00000000..286ad07a --- /dev/null +++ b/Loop/Helpers/WindowTransformAnimation.swift @@ -0,0 +1,49 @@ +// +// WindowTransformAnimation.swift +// Loop +// +// Created by Kai Azim on 2023-09-02. +// + +import SwiftUI + +/// Animate a window's resize! +class WindowTransformAnimation: NSAnimation { + private var targetFrame: CGRect + private let oldFrame: CGRect + private let window: Window + + init(_ newRect: CGRect, window: Window) { + self.targetFrame = newRect + self.oldFrame = window.frame + self.window = window + super.init(duration: 0.2, animationCurve: .easeOut) + self.frameRate = 60.0 + self.animationBlockingMode = .nonblocking + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func startInBackground() { + DispatchQueue.global().async { [self] in + self.start() + RunLoop.current.run() + } + } + + override public var currentProgress: NSAnimation.Progress { + didSet { + let value = CGFloat(self.currentValue) + let newFrame = CGRect( + x: oldFrame.origin.x + value * (targetFrame.origin.x - oldFrame.origin.x), + y: oldFrame.origin.y + value * (targetFrame.origin.y - oldFrame.origin.y), + width: oldFrame.size.width + value * (targetFrame.size.width - oldFrame.size.width), + height: oldFrame.size.height + value * (targetFrame.size.height - oldFrame.size.height) + ) + + window.setFrame(newFrame) + } + } +} diff --git a/Loop/LoopApp.swift b/Loop/LoopApp.swift index 44872023..9c5f04ca 100644 --- a/Loop/LoopApp.swift +++ b/Loop/LoopApp.swift @@ -86,7 +86,6 @@ struct LoopApp: App { class AppDelegate: NSObject, NSApplicationDelegate { - private let accessibilityAccessManager = AccessibilityAccessManager() private let iconManager = IconManager() private let loopManager = LoopManager() @@ -95,9 +94,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Check accessibility access, then if access is not granted, // show a more informative alert asking for accessibility access - if !accessibilityAccessManager.getStatus() { - accessibilityAccessManager.requestAccess() - } + PermissionsManager.Accessibility.requestAccess() + PermissionsManager.ScreenRecording.requestAccess() + iconManager.restoreCurrentAppIcon() loopManager.startObservingKeys() diff --git a/Loop/Settings/Views/GeneralSettingsView.swift b/Loop/Settings/Views/GeneralSettingsView.swift index d7b10775..b2ce46a9 100644 --- a/Loop/Settings/Views/GeneralSettingsView.swift +++ b/Loop/Settings/Views/GeneralSettingsView.swift @@ -22,9 +22,9 @@ struct GeneralSettingsView: View { @Default(.timesLooped) var timesLooped let iconManager = IconManager() - let accessibilityAccessManager = AccessibilityAccessManager() @State var isAccessibilityAccessGranted = false + @State var isScreenRecordingAccessGranted = false var body: some View { Form { @@ -81,6 +81,22 @@ struct GeneralSettingsView: View { .foregroundColor(isAccessibilityAccessGranted ? .green : .red) .shadow(color: isAccessibilityAccessGranted ? .green : .red, radius: 8) } + + VStack(alignment: .leading) { + HStack { + Text("Screen Recording Access") + Spacer() + Text(isScreenRecordingAccessGranted ? "Granted" : "Not Granted") + Circle() + .frame(width: 8, height: 8) + .padding(.trailing, 5) + .foregroundColor(isScreenRecordingAccessGranted ? .green : .red) + .shadow(color: isScreenRecordingAccessGranted ? .green : .red, radius: 8) + } + Text("This is only needed to animate windows being resized.") + .font(.caption) + .foregroundColor(.secondary) + } }, header: { HStack { Text("Permissions") @@ -88,7 +104,11 @@ struct GeneralSettingsView: View { Spacer() Button("Refresh Status", action: { - self.isAccessibilityAccessGranted = accessibilityAccessManager.requestAccess() + PermissionsManager.Accessibility.requestAccess() + self.isAccessibilityAccessGranted = PermissionsManager.Accessibility.getStatus() + + PermissionsManager.ScreenRecording.requestAccess() + self.isScreenRecordingAccessGranted = PermissionsManager.ScreenRecording.getStatus() }) .buttonStyle(.link) .foregroundStyle(Color.accentColor) @@ -96,7 +116,8 @@ struct GeneralSettingsView: View { .opacity(isAccessibilityAccessGranted ? 0.6 : 1) .help("Refresh the current accessibility permissions") .onAppear { - self.isAccessibilityAccessGranted = accessibilityAccessManager.getStatus() + self.isAccessibilityAccessGranted = PermissionsManager.Accessibility.getStatus() + self.isScreenRecordingAccessGranted = PermissionsManager.ScreenRecording.getStatus() } } })