From 815f49164fde0a3240cf7410c00a83a8ca208a7a Mon Sep 17 00:00:00 2001 From: Jordan Baird Date: Thu, 15 Feb 2024 19:09:22 -0700 Subject: [PATCH] Reimplement `MenuBarAppearancePanel` (#14) * Begin implementing new MenuBarAppearancePanel * Tweaks * Only update a control item's window frame when it intersects with its screen * Update MenuBarAppearancePanel.swift * Refactoring * Prevent MenuBarAppearancePanel from showing again if ordered out * Refactoring * Update MenuBarAppearancePanel.swift * Workaround for full screen * Better workaround for full screen * Refactoring * Make new appearance panel work with `MenuBarShapeKind.none` * Update MenuBarAppearancePanel.swift * Update MenuBarAppearancePanel.swift * Remove old appearance panel and its subtypes * A comment * Move ScreenshotManager functionality to ScreenCaptureManager * Update MenuBarAppearancePanel.swift * Minor tweaks * Fix for multiple appearance panels appearing on the same screen * Update MenuBarAppearancePanel.swift * Remove unnecessary redundancy --- Ice.xcodeproj/project.pbxproj | 44 +- .../NSScreen/NSScreen+displayID.swift | 15 + Ice/MenuBar/ControlItem/ControlItem.swift | 11 +- Ice/MenuBar/MenuBarAppearanceManager.swift | 175 ++--- Ice/MenuBar/MenuBarAppearancePanel.swift | 705 ++++++++++++++++++ .../MenuBarAppearancePanel.swift | 147 ---- .../MenuBarBackingPanel.swift | 141 ---- .../MenuBarOverlayPanel.swift | 390 ---------- Ice/Utilities/ScreenCaptureManager.swift | 254 +++++++ Ice/Utilities/ScreenshotManager.swift | 128 ---- 10 files changed, 1066 insertions(+), 944 deletions(-) create mode 100644 Ice/Extensions/NSScreen/NSScreen+displayID.swift create mode 100644 Ice/MenuBar/MenuBarAppearancePanel.swift delete mode 100644 Ice/MenuBar/MenuBarAppearancePanel/MenuBarAppearancePanel.swift delete mode 100644 Ice/MenuBar/MenuBarAppearancePanel/MenuBarBackingPanel.swift delete mode 100644 Ice/MenuBar/MenuBarAppearancePanel/MenuBarOverlayPanel.swift create mode 100644 Ice/Utilities/ScreenCaptureManager.swift delete mode 100644 Ice/Utilities/ScreenshotManager.swift diff --git a/Ice.xcodeproj/project.pbxproj b/Ice.xcodeproj/project.pbxproj index d36647c2..dbb7cfd6 100644 --- a/Ice.xcodeproj/project.pbxproj +++ b/Ice.xcodeproj/project.pbxproj @@ -18,6 +18,8 @@ 1725FC6A2AED973800A59081 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1725FC692AED973800A59081 /* AppState.swift */; }; 1736F77C2ADBBF340073428E /* CustomGradientPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1736F77B2ADBBF340073428E /* CustomGradientPicker.swift */; }; 1736F7802ADBC02B0073428E /* CustomGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1736F77F2ADBC02B0073428E /* CustomGradient.swift */; }; + 174AA5D62B71D97100E3FE74 /* MenuBarAppearancePanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 174AA5D52B71D97100E3FE74 /* MenuBarAppearancePanel.swift */; }; + 174AA5F52B730A0B00E3FE74 /* ScreenCaptureManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 174AA5F42B730A0B00E3FE74 /* ScreenCaptureManager.swift */; }; 175061912B1543DD003144CD /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = 175061902B1543DD003144CD /* LaunchAtLogin */; }; 1750850E2B683A4C00CFF13A /* StateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1750850D2B683A4C00CFF13A /* StateView.swift */; }; 175085132B69C4C100CFF13A /* CustomTabBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 175085122B69C4C100CFF13A /* CustomTabBuilder.swift */; }; @@ -31,9 +33,6 @@ 1773546A2B1BBACF001CF731 /* MenuBarAppearanceTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 177354692B1BBACF001CF731 /* MenuBarAppearanceTab.swift */; }; 1773546C2B1BBBA1001CF731 /* MenuBarShapePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1773546B2B1BBBA1001CF731 /* MenuBarShapePicker.swift */; }; 177354702B1BFF65001CF731 /* HorizontalEdge+cgRectEdge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1773546F2B1BFF65001CF731 /* HorizontalEdge+cgRectEdge.swift */; }; - 177354792B1F533F001CF731 /* MenuBarOverlayPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 177354782B1F533F001CF731 /* MenuBarOverlayPanel.swift */; }; - 1773547B2B1F539A001CF731 /* MenuBarBackingPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1773547A2B1F539A001CF731 /* MenuBarBackingPanel.swift */; }; - 177354812B1F5F38001CF731 /* ScreenshotManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 177354802B1F5F38001CF731 /* ScreenshotManager.swift */; }; 177354842B1F9AF9001CF731 /* NSBezierPath+union.swift in Sources */ = {isa = PBXBuildFile; fileRef = 177354832B1F9AF9001CF731 /* NSBezierPath+union.swift */; }; 177386F32B092A0700448BBF /* ControlItemImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 177386F22B092A0700448BBF /* ControlItemImage.swift */; }; 177386F52B0A654D00448BBF /* ControlItemImageSet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 177386F42B0A654D00448BBF /* ControlItemImageSet.swift */; }; @@ -44,12 +43,12 @@ 1787C4342B16AECF002F50DF /* PermissionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1787C4332B16AECF002F50DF /* PermissionsView.swift */; }; 1787C4362B17A9EB002F50DF /* Acknowledgements.pdf in Resources */ = {isa = PBXBuildFile; fileRef = 1787C4352B17A9EB002F50DF /* Acknowledgements.pdf */; }; 1787C43B2B187187002F50DF /* MenuBarTintKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1787C43A2B187187002F50DF /* MenuBarTintKind.swift */; }; - 178B36692AE6EC2E00AA7B35 /* MenuBarAppearancePanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 178B36682AE6EC2E00AA7B35 /* MenuBarAppearancePanel.swift */; }; 17928F1A2AC5DF9C0016C615 /* Defaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17928F192AC5DF9C0016C615 /* Defaults.swift */; }; 179F3C432ACE746700A76EE8 /* RemoveSidebarToggle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179F3C422ACE746700A76EE8 /* RemoveSidebarToggle.swift */; }; 17B380F32ADCBC8A0002C9C3 /* OnKeyDown.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17B380F22ADCBC8A0002C9C3 /* OnKeyDown.swift */; }; 17B380F52ADCBE090002C9C3 /* LocalEventMonitorModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17B380F42ADCBE090002C9C3 /* LocalEventMonitorModifier.swift */; }; 17B7F32B2B264C1800CDCF49 /* MenuBarAppearanceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17B7F32A2B264C1800CDCF49 /* MenuBarAppearanceManager.swift */; }; + 17C3C5F72B75A36100B9648C /* NSScreen+displayID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17C3C5F62B75A36100B9648C /* NSScreen+displayID.swift */; }; 17DFF4AC2AD5FB3300B5177A /* MenuBarSettingsPane.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17DFF4AB2AD5FB3300B5177A /* MenuBarSettingsPane.swift */; }; 17DFF4C02AD8DBC500B5177A /* CodableColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17DFF4BF2AD8DBC500B5177A /* CodableColor.swift */; }; 17EC6B582AE0C34A0065F260 /* Comparable+clamped.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17EC6B572AE0C34A0065F260 /* Comparable+clamped.swift */; }; @@ -101,6 +100,8 @@ 1726A3F82B3378B8008B09DD /* Acknowledgements.rtf */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.rtf; path = Acknowledgements.rtf; sourceTree = ""; }; 1736F77B2ADBBF340073428E /* CustomGradientPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomGradientPicker.swift; sourceTree = ""; }; 1736F77F2ADBC02B0073428E /* CustomGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomGradient.swift; sourceTree = ""; }; + 174AA5D52B71D97100E3FE74 /* MenuBarAppearancePanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarAppearancePanel.swift; sourceTree = ""; }; + 174AA5F42B730A0B00E3FE74 /* ScreenCaptureManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenCaptureManager.swift; sourceTree = ""; }; 1750850D2B683A4C00CFF13A /* StateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateView.swift; sourceTree = ""; }; 175085122B69C4C100CFF13A /* CustomTabBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTabBuilder.swift; sourceTree = ""; }; 175085142B69C50C00CFF13A /* CustomTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTab.swift; sourceTree = ""; }; @@ -113,9 +114,6 @@ 177354692B1BBACF001CF731 /* MenuBarAppearanceTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarAppearanceTab.swift; sourceTree = ""; }; 1773546B2B1BBBA1001CF731 /* MenuBarShapePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarShapePicker.swift; sourceTree = ""; }; 1773546F2B1BFF65001CF731 /* HorizontalEdge+cgRectEdge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HorizontalEdge+cgRectEdge.swift"; sourceTree = ""; }; - 177354782B1F533F001CF731 /* MenuBarOverlayPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarOverlayPanel.swift; sourceTree = ""; }; - 1773547A2B1F539A001CF731 /* MenuBarBackingPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarBackingPanel.swift; sourceTree = ""; }; - 177354802B1F5F38001CF731 /* ScreenshotManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotManager.swift; sourceTree = ""; }; 177354832B1F9AF9001CF731 /* NSBezierPath+union.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSBezierPath+union.swift"; sourceTree = ""; }; 177386F22B092A0700448BBF /* ControlItemImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlItemImage.swift; sourceTree = ""; }; 177386F42B0A654D00448BBF /* ControlItemImageSet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlItemImageSet.swift; sourceTree = ""; }; @@ -125,13 +123,13 @@ 1787C4332B16AECF002F50DF /* PermissionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsView.swift; sourceTree = ""; }; 1787C4352B17A9EB002F50DF /* Acknowledgements.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = Acknowledgements.pdf; sourceTree = ""; }; 1787C43A2B187187002F50DF /* MenuBarTintKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarTintKind.swift; sourceTree = ""; }; - 178B36682AE6EC2E00AA7B35 /* MenuBarAppearancePanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarAppearancePanel.swift; sourceTree = ""; }; 17928F192AC5DF9C0016C615 /* Defaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Defaults.swift; sourceTree = ""; }; 179F3C422ACE746700A76EE8 /* RemoveSidebarToggle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoveSidebarToggle.swift; sourceTree = ""; }; 17B380F22ADCBC8A0002C9C3 /* OnKeyDown.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnKeyDown.swift; sourceTree = ""; }; 17B380F42ADCBE090002C9C3 /* LocalEventMonitorModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalEventMonitorModifier.swift; sourceTree = ""; }; 17B7F32A2B264C1800CDCF49 /* MenuBarAppearanceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarAppearanceManager.swift; sourceTree = ""; }; 17C261E22B5AC03C0076F129 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 17C3C5F62B75A36100B9648C /* NSScreen+displayID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSScreen+displayID.swift"; sourceTree = ""; }; 17DFF4AB2AD5FB3300B5177A /* MenuBarSettingsPane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarSettingsPane.swift; sourceTree = ""; }; 17DFF4BF2AD8DBC500B5177A /* CodableColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableColor.swift; sourceTree = ""; }; 17EC6B572AE0C34A0065F260 /* Comparable+clamped.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Comparable+clamped.swift"; sourceTree = ""; }; @@ -287,16 +285,6 @@ path = HorizontalEdge; sourceTree = ""; }; - 177354772B1F530B001CF731 /* MenuBarAppearancePanel */ = { - isa = PBXGroup; - children = ( - 178B36682AE6EC2E00AA7B35 /* MenuBarAppearancePanel.swift */, - 1773547A2B1F539A001CF731 /* MenuBarBackingPanel.swift */, - 177354782B1F533F001CF731 /* MenuBarOverlayPanel.swift */, - ); - path = MenuBarAppearancePanel; - sourceTree = ""; - }; 177354822B1F9AE1001CF731 /* NSBezierPath */ = { isa = PBXGroup; children = ( @@ -370,6 +358,14 @@ path = Main; sourceTree = ""; }; + 17C3C5F52B75A35000B9648C /* NSScreen */ = { + isa = PBXGroup; + children = ( + 17C3C5F62B75A36100B9648C /* NSScreen+displayID.swift */, + ); + path = NSScreen; + sourceTree = ""; + }; 17EC6B522AE0C09F0065F260 /* Pickers */ = { isa = PBXGroup; children = ( @@ -491,7 +487,7 @@ 17928F192AC5DF9C0016C615 /* Defaults.swift */, 170CF88D2B0EDD780073F982 /* LocalizedErrorBox.swift */, 71008DEF2AB907B00036B1F3 /* ObjectAssociation.swift */, - 177354802B1F5F38001CF731 /* ScreenshotManager.swift */, + 174AA5F42B730A0B00E3FE74 /* ScreenCaptureManager.swift */, 170749CB2B120951009DDF73 /* EventMonitors */, 71D36C492A88EDD200D89CD5 /* Swizzling */, ); @@ -508,6 +504,7 @@ 716683442A76811C006ABF84 /* Logger */, 7133ED5F2A855ADA000A7E1B /* NSApplication */, 177354822B1F9AE1001CF731 /* NSBezierPath */, + 17C3C5F52B75A35000B9648C /* NSScreen */, 716683462A76811C006ABF84 /* NSStatusItem */, ); path = Extensions; @@ -533,12 +530,12 @@ isa = PBXGroup; children = ( 17B7F32A2B264C1800CDCF49 /* MenuBarAppearanceManager.swift */, + 174AA5D52B71D97100E3FE74 /* MenuBarAppearancePanel.swift */, 7166834F2A7681AF006ABF84 /* MenuBarManager.swift */, 7162406E2AA0A323003EC671 /* MenuBarSection.swift */, 177354652B1B8502001CF731 /* MenuBarShape.swift */, 1787C43A2B187187002F50DF /* MenuBarTintKind.swift */, 177386F62B0A656100448BBF /* ControlItem */, - 177354772B1F530B001CF731 /* MenuBarAppearancePanel */, ); path = MenuBar; sourceTree = ""; @@ -688,7 +685,6 @@ 716683502A7681AF006ABF84 /* MenuBarManager.swift in Sources */, 1780ED0E2B43D132000AED1C /* BoxObject.swift in Sources */, 175085132B69C4C100CFF13A /* CustomTabBuilder.swift in Sources */, - 177354812B1F5F38001CF731 /* ScreenshotManager.swift in Sources */, 176B23F42ADB76A1008AE86B /* CustomColorPicker.swift in Sources */, 17540BD82B20C0DA00A0F965 /* NSBezierPath+drawShadow.swift in Sources */, 177354842B1F9AF9001CF731 /* NSBezierPath+union.swift in Sources */, @@ -697,7 +693,6 @@ 1773546A2B1BBACF001CF731 /* MenuBarAppearanceTab.swift in Sources */, 17B7F32B2B264C1800CDCF49 /* MenuBarAppearanceManager.swift in Sources */, 7150A7B12AA427F80045EA68 /* Hotkey+Key.swift in Sources */, - 178B36692AE6EC2E00AA7B35 /* MenuBarAppearancePanel.swift in Sources */, 175584152B541D6F00EDC9D3 /* MenuBarLayoutTab.swift in Sources */, 1736F7802ADBC02B0073428E /* CustomGradient.swift in Sources */, 7166834C2A76811C006ABF84 /* NSStatusItem+showMenu.swift in Sources */, @@ -716,9 +711,10 @@ 17DFF4C02AD8DBC500B5177A /* CodableColor.swift in Sources */, 71839BD82A997DC200250044 /* Edge+cgRectEdge.swift in Sources */, 714BB9BB2AAB1D690057FB1D /* AboutSettingsPane.swift in Sources */, - 177354792B1F533F001CF731 /* MenuBarOverlayPanel.swift in Sources */, + 174AA5F52B730A0B00E3FE74 /* ScreenCaptureManager.swift in Sources */, 71623BD32A8B6744002FD331 /* CustomButtonStyle.swift in Sources */, 7133ED712A85AE6A000A7E1B /* SettingsNavigationItem.swift in Sources */, + 174AA5D62B71D97100E3FE74 /* MenuBarAppearancePanel.swift in Sources */, 71008DF02AB907B00036B1F3 /* ObjectAssociation.swift in Sources */, 179F3C432ACE746700A76EE8 /* RemoveSidebarToggle.swift in Sources */, 17DFF4AC2AD5FB3300B5177A /* MenuBarSettingsPane.swift in Sources */, @@ -730,8 +726,8 @@ 170CF88C2B0ED4FA0073F982 /* HotkeyRecordingFailure.swift in Sources */, 170CF88E2B0EDD780073F982 /* LocalizedErrorBox.swift in Sources */, 7166832E2A767E6A006ABF84 /* IceApp.swift in Sources */, - 1773547B2B1F539A001CF731 /* MenuBarBackingPanel.swift in Sources */, 71FEA2502A8D5D590048341A /* HotkeyRecorder.swift in Sources */, + 17C3C5F72B75A36100B9648C /* NSScreen+displayID.swift in Sources */, 7133ED5E2A853FCF000A7E1B /* Constants.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Ice/Extensions/NSScreen/NSScreen+displayID.swift b/Ice/Extensions/NSScreen/NSScreen+displayID.swift new file mode 100644 index 00000000..751b308f --- /dev/null +++ b/Ice/Extensions/NSScreen/NSScreen+displayID.swift @@ -0,0 +1,15 @@ +// +// NSScreen+displayID.swift +// Ice +// + +import Cocoa + +extension NSScreen { + /// The display identifier of the screen. + var displayID: CGDirectDisplayID { + // deviceDescription is guaranteed to always have an NSScreenNumber key, so a force unwrap here is okay + let screenNumber = deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")]! // swiftlint:disable:this force_unwrapping + return screenNumber as! CGDirectDisplayID // swiftlint:disable:this force_cast + } +} diff --git a/Ice/MenuBar/ControlItem/ControlItem.swift b/Ice/MenuBar/ControlItem/ControlItem.swift index b072eadb..37380ea9 100644 --- a/Ice/MenuBar/ControlItem/ControlItem.swift +++ b/Ice/MenuBar/ControlItem/ControlItem.swift @@ -242,8 +242,15 @@ final class ControlItem: ObservableObject { if let window = statusItem.button?.window { window.publisher(for: \.frame) - .sink { [weak self] frame in - self?.windowFrame = frame + .sink { [weak self, weak window] frame in + guard + let self, + let screen = window?.screen, + screen.frame.intersects(frame) + else { + return + } + windowFrame = frame } .store(in: &c) diff --git a/Ice/MenuBar/MenuBarAppearanceManager.swift b/Ice/MenuBar/MenuBarAppearanceManager.swift index 7b7a15bd..223faa75 100644 --- a/Ice/MenuBar/MenuBarAppearanceManager.swift +++ b/Ice/MenuBar/MenuBarAppearanceManager.swift @@ -44,18 +44,6 @@ final class MenuBarAppearanceManager: ObservableObject { /// The user's currently chosen tint gradient. @Published var tintGradient: CustomGradient = .defaultMenuBarTint - /// The current desktop wallpaper, clipped to the bounds - /// of the menu bar. - @Published var desktopWallpaper: CGImage? - - /// A Boolean value that indicates whether the screen - /// is currently locked. - @Published private(set) var screenIsLocked = false - - /// A Boolean value that indicates whether the screen - /// saver is currently active. - @Published private(set) var screenSaverIsActive = false - private var cancellables = Set() private let encoder: JSONEncoder @@ -65,8 +53,26 @@ final class MenuBarAppearanceManager: ObservableObject { private(set) weak var menuBarManager: MenuBarManager? - private lazy var backingPanel = MenuBarBackingPanel(appearanceManager: self) - private lazy var overlayPanel = MenuBarOverlayPanel(appearanceManager: self) + private(set) var appearancePanels = Set() + + /// A Boolean value that indicates whether an app is fullscreen. + var isFullscreen: Bool { + guard let windows = CGWindowListCopyWindowInfo(.optionOnScreenOnly, kCGNullWindowID) else { + return false + } + for window in windows as NSArray { + guard let info = window as? NSDictionary else { + continue + } + if + info[kCGWindowOwnerName] as? String == "Dock", + info[kCGWindowName] as? String == "Fullscreen Backdrop" + { + return true + } + } + return false + } init( menuBarManager: MenuBarManager, @@ -83,10 +89,13 @@ final class MenuBarAppearanceManager: ObservableObject { func performSetup() { loadInitialState() configureCancellables() - Task.detached { @MainActor [self] in - try await Task.sleep(for: .milliseconds(500)) - backingPanel.configureCancellables() - overlayPanel.configureCancellables() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [self] in + // make sure all panels are ordered out before configuring + // TODO: We may not need this...investigate. + while let panel = appearancePanels.popFirst() { + panel.orderOut(self) + } + configureAppearancePanels() } } @@ -125,45 +134,39 @@ final class MenuBarAppearanceManager: ObservableObject { private func configureCancellables() { var c = Set() - DistributedNotificationCenter.default() - .publisher(for: Notification.Name("com.apple.screenIsLocked")) - .sink { [weak self] _ in - self?.screenIsLocked = true - } - .store(in: &c) - - DistributedNotificationCenter.default() - .publisher(for: Notification.Name("com.apple.screenIsUnlocked")) + NotificationCenter.default + .publisher(for: NSApplication.didChangeScreenParametersNotification) .sink { [weak self] _ in - self?.screenIsLocked = false - } - .store(in: &c) - - DistributedNotificationCenter.default() - .publisher(for: Notification.Name("com.apple.screensaver.didstart")) - .sink { [weak self] _ in - self?.screenSaverIsActive = true - } - .store(in: &c) - - DistributedNotificationCenter.default() - .publisher(for: Notification.Name("com.apple.screensaver.didstop")) - .sink { [weak self] _ in - self?.screenSaverIsActive = false + guard let self else { + return + } + while let panel = appearancePanels.popFirst() { + panel.orderOut(self) + } + configureAppearancePanels() } .store(in: &c) NSWorkspace.shared.notificationCenter .publisher(for: NSWorkspace.activeSpaceDidChangeNotification) .sink { [weak self] _ in - self?.updateDesktopWallpaper() - } - .store(in: &c) - - Timer.publish(every: 3, on: .main, in: .common) - .autoconnect() - .sink { [weak self] _ in - self?.updateDesktopWallpaper() + guard let self else { + return + } + if + appearancePanels.isEmpty, + !isFullscreen + { + configureAppearancePanels() + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + if self.isFullscreen { + while let panel = self.appearancePanels.popFirst() { + panel.orderOut(self) + } + } + } + } } .store(in: &c) @@ -263,7 +266,6 @@ final class MenuBarAppearanceManager: ObservableObject { guard let self else { return } - updateDesktopWallpaper() defaults.set(data, forKey: Defaults.menuBarShapeKind) } .store(in: &c) @@ -301,69 +303,18 @@ final class MenuBarAppearanceManager: ObservableObject { cancellables = c } - private func updateDesktopWallpaper() { - guard shapeKind != .none else { - desktopWallpaper = nil - return - } - - guard !screenIsLocked else { - Logger.appearanceManager.debug("Screen is locked") - return - } - - guard !screenSaverIsActive else { - Logger.appearanceManager.debug("Screen saver is active") - return - } - - guard - let appState = menuBarManager?.appState, - appState.permissionsManager.screenRecordingPermission.hasPermission - else { - Logger.appearanceManager.notice("Missing screen capture permissions") - return - } - - Task { @MainActor in - do { - let content = try await SCShareableContent.current - - let wallpaperWindowPredicate: (SCWindow) -> Bool = { window in - // wallpaper window belongs to the Dock process - window.owningApplication?.bundleIdentifier == "com.apple.dock" && - window.isOnScreen && - window.title?.hasPrefix("Wallpaper-") == true - } - let menuBarWindowPredicate: (SCWindow) -> Bool = { window in - // menu bar window belongs to the WindowServer process - // (identified by an empty string) - window.owningApplication?.bundleIdentifier == "" && - window.windowLayer == kCGMainMenuWindowLevel && - window.title == "Menubar" - } - - guard - let wallpaperWindow = content.windows.first(where: wallpaperWindowPredicate), - let menuBarWindow = content.windows.first(where: menuBarWindowPredicate) - else { - return - } - - let image = try await ScreenshotManager.captureImage( - withTimeout: .milliseconds(500), - window: wallpaperWindow, - captureRect: menuBarWindow.frame, - options: .ignoreFraming - ) - - if desktopWallpaper?.dataProvider?.data != image.dataProvider?.data { - desktopWallpaper = image - } - } catch { - Logger.appearanceManager.error("Error updating desktop wallpaper: \(error)") + private func configureAppearancePanels() { + var appearancePanels = Set() + for screen in NSScreen.screens { + let panel = MenuBarAppearancePanel(appearanceManager: self, owningScreen: screen) + appearancePanels.insert(panel) + // panel needs a reference to the menu bar frame, which is retrieved asynchronously; wait a bit before showing + // FIXME: Show after the panel has the menu bar reference instead of waiting an arbitrary amount of time + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + panel.show() } } + self.appearancePanels = appearancePanels } } diff --git a/Ice/MenuBar/MenuBarAppearancePanel.swift b/Ice/MenuBar/MenuBarAppearancePanel.swift new file mode 100644 index 00000000..6a51c42f --- /dev/null +++ b/Ice/MenuBar/MenuBarAppearancePanel.swift @@ -0,0 +1,705 @@ +// +// MenuBarAppearancePanel.swift +// Ice +// + +import AXSwift +import Cocoa +import Combine +import OSLog +import ScreenCaptureKit + +// MARK: - MenuBarAppearancePanel + +/// A subclass of `NSPanel` that sits atop the menu bar +/// to alter its appearance. +class MenuBarAppearancePanel: NSPanel { + private var cancellables = Set() + + /// The appearance manager that manages the panel. + private(set) weak var appearanceManager: MenuBarAppearanceManager? + + /// The screen that owns the panel. + let owningScreen: NSScreen + + /// A Boolean value that indicates whether the screen + /// is currently locked. + private var screenIsLocked = false + + /// A Boolean value that indicates whether the screen + /// saver is currently active. + private var screenSaverIsActive = false + + /// The menu bar associated with the panel. + @Published private(set) var menuBar: UIElement? + + /// The current desktop wallpaper, clipped to the bounds + /// of the menu bar. + @Published private(set) var desktopWallpaper: CGImage? + + /// The frame that should be used to display the panel. + private var frameForDisplay: CGRect? { + guard let menuBarFrame: CGRect = try? menuBar?.attribute(.frame) else { + return nil + } + return CGRect( + x: owningScreen.frame.origin.x, + y: (owningScreen.frame.maxY - menuBarFrame.height) - 5, + width: owningScreen.frame.width, + height: menuBarFrame.height + 5 + ) + } + + /// Creates an appearance panel with the given appearance + /// manager and owning screen. + init(appearanceManager: MenuBarAppearanceManager, owningScreen: NSScreen) { + self.appearanceManager = appearanceManager + self.owningScreen = owningScreen + super.init( + contentRect: .zero, + styleMask: [.borderless, .fullSizeContentView, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + self.level = .statusBar + self.title = String(describing: Self.self) + self.backgroundColor = .clear + self.hasShadow = false + self.ignoresMouseEvents = true + self.collectionBehavior = [.fullScreenNone, .ignoresCycle, .moveToActiveSpace] + self.contentView = MenuBarAppearancePanelContentView(appearancePanel: self) + configureCancellables() + } + + private func configureCancellables() { + var c = Set() + + DistributedNotificationCenter.default() + .publisher(for: Notification.Name("com.apple.screenIsLocked")) + .sink { [weak self] _ in + self?.screenIsLocked = true + } + .store(in: &c) + + DistributedNotificationCenter.default() + .publisher(for: Notification.Name("com.apple.screenIsUnlocked")) + .sink { [weak self] _ in + self?.screenIsLocked = false + } + .store(in: &c) + + DistributedNotificationCenter.default() + .publisher(for: Notification.Name("com.apple.screensaver.didstart")) + .sink { [weak self] _ in + self?.screenSaverIsActive = true + } + .store(in: &c) + + DistributedNotificationCenter.default() + .publisher(for: Notification.Name("com.apple.screensaver.didstop")) + .sink { [weak self] _ in + self?.screenSaverIsActive = false + } + .store(in: &c) + + // show the panel on the active space + NSWorkspace.shared.notificationCenter + .publisher(for: NSWorkspace.activeSpaceDidChangeNotification) + .delay(for: 0.1, scheduler: DispatchQueue.main) + .sink { [weak self] _ in + guard + let self, + let appearanceManager + else { + return + } + if !appearanceManager.isFullscreen && !isOnActiveSpace { + show() + } + } + .store(in: &c) + + ScreenCaptureManager.shared.$windows + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard + let self, + let owningDisplay = getOwningDisplay(), + let wallpaperWindow = getWallpaperWindow(owningDisplay: owningDisplay), + let menuBarWindow = getMenuBarWindow(owningDisplay: owningDisplay) + else { + return + } + updateDesktopWallpaper( + owningDisplay: owningDisplay, + wallpaperWindow: wallpaperWindow, + menuBarWindow: menuBarWindow + ) + updateMenuBar(menuBarWindow: menuBarWindow) + } + .store(in: &c) + + cancellables = c + } + + /// Returns the `SCDisplay` equivalent of the owning screen. + private func getOwningDisplay() -> SCDisplay? { + ScreenCaptureManager.shared.displays.first { display in + display.displayID == owningScreen.displayID + } + } + + /// Returns the wallpaper window for the given display. + private func getWallpaperWindow(owningDisplay: SCDisplay) -> SCWindow? { + ScreenCaptureManager.shared.windows.first { window in + // wallpaper window belongs to the Dock process + window.owningApplication?.bundleIdentifier == "com.apple.dock" && + window.isOnScreen && + window.title?.hasPrefix("Wallpaper-") == true && + owningDisplay.frame.contains(window.frame) + } + } + + /// Returns the menu bar window for the given display. + private func getMenuBarWindow(owningDisplay: SCDisplay) -> SCWindow? { + ScreenCaptureManager.shared.windows.first { window in + // menu bar window belongs to the WindowServer process + // (identified by an empty string) + window.owningApplication?.bundleIdentifier == "" && + window.windowLayer == kCGMainMenuWindowLevel && + window.title == "Menubar" && + owningDisplay.frame.contains(window.frame) + } + } + + /// Stores the area of the desktop wallpaper that is under + /// the menu bar using the given owning display, wallpaper + /// window, and menu bar window. + private func updateDesktopWallpaper( + owningDisplay: SCDisplay, + wallpaperWindow: SCWindow, + menuBarWindow: SCWindow + ) { + if screenIsLocked || screenSaverIsActive { + return + } + Task { + do { + desktopWallpaper = try await ScreenCaptureManager.shared.captureImage( + withTimeout: .milliseconds(500), + window: wallpaperWindow, + display: owningDisplay, + captureRect: CGRect(origin: .zero, size: menuBarWindow.frame.size), + options: .ignoreFraming + ) + } catch { + Logger.appearancePanel.error("Error updating desktop wallpaper: \(error)") + } + } + } + + /// Stores a reference to the menu bar using the given + /// menu bar window. + private func updateMenuBar(menuBarWindow: SCWindow) { + do { + guard + let menuBar = try systemWideElement.elementAtPosition( + Float(menuBarWindow.frame.origin.x), + Float(menuBarWindow.frame.origin.y) + ), + try menuBar.role() == .menuBar + else { + self.menuBar = nil + return + } + self.menuBar = menuBar + } catch { + Logger.appearancePanel.error("Error updating menu bar: \(error)") + } + } + + /// Shows the panel. + func show() { + guard !AppState.shared.isPreview else { + return + } + + guard let frameForDisplay else { + Logger.appearancePanel.notice("Missing frame for display") + return + } + + // only continue if the appearance manager holds + // a reference to this panel + guard + let appearanceManager, + appearanceManager.appearancePanels.contains(self) + else { + Logger.appearancePanel.notice("Appearance panel \(self) not retained") + return + } + + alphaValue = 0 + setFrame(frameForDisplay, display: true) + orderFrontRegardless() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.animator().alphaValue = 1 + } + } + + /// Hides the panel. + func hide() { + close() + } + + override func isAccessibilityElement() -> Bool { + return false + } +} + +// MARK: - ContentView + +private class MenuBarAppearancePanelContentView: NSView { + private var cancellables = Set() + + private weak var appearancePanel: MenuBarAppearancePanel? + + /// The max X position of the main menu. + private var mainMenuMaxX: CGFloat? { + didSet { + needsDisplay = true + } + } + + /// The bounds that the view's drawn content can occupy. + var drawableBounds: CGRect { + CGRect( + x: bounds.origin.x, + y: bounds.origin.y + 5, + width: bounds.width, + height: bounds.height - 5 + ) + } + + init(appearancePanel: MenuBarAppearancePanel) { + self.appearancePanel = appearancePanel + super.init(frame: .zero) + configureCancellables() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configureCancellables() { + var c = Set() + + // update when dark/light mode changes + DistributedNotificationCenter.default() + .publisher(for: Notification.Name("AppleInterfaceThemeChangedNotification")) + .delay(for: 0.1, scheduler: DispatchQueue.global(qos: .background)) + .sink { _ in + ScreenCaptureManager.shared.update() + } + .store(in: &c) + + // update when active space changes + NSWorkspace.shared.notificationCenter + .publisher(for: NSWorkspace.activeSpaceDidChangeNotification) + .receive(on: DispatchQueue.global(qos: .background)) + .sink { _ in + ScreenCaptureManager.shared.update() + } + .store(in: &c) + + // update when frontmost application changes + NSWorkspace.shared + .publisher(for: \.frontmostApplication) + .receive(on: DispatchQueue.global(qos: .background)) + .sink { _ in + ScreenCaptureManager.shared.update() + } + .store(in: &c) + + // redraw whenever ScreenCaptureManager's windows change + ScreenCaptureManager.shared.$windows + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.needsDisplay = true + } + .store(in: &c) + + if let appearancePanel { + appearancePanel.$menuBar + .sink { [weak self] menuBar in + guard + let self, + let menuBar + else { + return + } + updateMainMenuMaxX(menuBar: menuBar) + } + .store(in: &c) + appearancePanel.$desktopWallpaper + .sink { [weak self] _ in + self?.needsDisplay = true + } + .store(in: &c) + + if let appearanceManager = appearancePanel.appearanceManager { + // redraw whenever a control item moves, if its maxX + // remains on screen + for section in appearanceManager.menuBarManager?.sections ?? [] { + section.controlItem.$windowFrame + .filter { [weak section] windowFrame in + guard + let windowFrame, + let screen = section?.controlItem.screen + else { + return false + } + let xRange = screen.frame.minX...screen.frame.maxX + return xRange.contains(windowFrame.maxX) + } + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.needsDisplay = true + } + .store(in: &c) + } + + // redraw whenever appearanceManager's parameters change + appearanceManager.objectWillChange + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.needsDisplay = true + } + .store(in: &c) + } + } + + cancellables = c + } + + /// Stores the maxX position of the menu bar. + private func updateMainMenuMaxX(menuBar: UIElement) { + do { + guard let children: [UIElement] = try menuBar.arrayAttribute(.children) else { + mainMenuMaxX = nil + return + } + mainMenuMaxX = try children.reduce(into: 0) { result, child in + if let frame: CGRect = try child.attribute(.frame) { + result += frame.width + } + } + } catch { + Logger.appearancePanel.error("Error updating main menu maxX: \(error)") + } + } + + /// Returns a path for the ``MenuBarShapeKind/full`` shape kind. + private func pathForFullShapeKind(in rect: CGRect, info: MenuBarFullShapeInfo) -> NSBezierPath { + let shapeBounds = CGRect( + x: rect.height / 2, + y: rect.origin.y, + width: rect.width - rect.height, + height: rect.height + ) + let leadingEndCapBounds = CGRect( + x: rect.origin.x, + y: rect.origin.y, + width: rect.height, + height: rect.height + ) + let trailingEndCapBounds = CGRect( + x: rect.width - rect.height, + y: rect.origin.y, + width: rect.height, + height: rect.height + ) + + var path = NSBezierPath(rect: shapeBounds) + + path = switch info.leadingEndCap { + case .square: path.union(NSBezierPath(rect: leadingEndCapBounds)) + case .round: path.union(NSBezierPath(ovalIn: leadingEndCapBounds)) + } + + path = switch info.trailingEndCap { + case .square: path.union(NSBezierPath(rect: trailingEndCapBounds)) + case .round: path.union(NSBezierPath(ovalIn: trailingEndCapBounds)) + } + + return path + } + + /// Returns a path for the ``MenuBarShapeKind/split`` shape kind. + private func pathForSplitShapeKind(in rect: CGRect, info: MenuBarSplitShapeInfo) -> NSBezierPath { + guard + let menuBarManager = appearancePanel?.appearanceManager?.menuBarManager, + let hiddenSection = menuBarManager.section(withName: .hidden), + let alwaysHiddenSection = menuBarManager.section(withName: .alwaysHidden), + let mainMenuMaxX + else { + return NSBezierPath(rect: rect) + } + + guard alwaysHiddenSection.isHidden else { + let info = MenuBarFullShapeInfo( + leadingEndCap: info.leading.leadingEndCap, + trailingEndCap: info.trailing.trailingEndCap + ) + return pathForFullShapeKind(in: rect, info: info) + } + + let padding: CGFloat = 8 + + let leadingPath: NSBezierPath = { + let shapeBounds = CGRect( + x: rect.height / 2, + y: rect.origin.y, + width: (mainMenuMaxX - (rect.height / 2)) + padding, + height: rect.height + ) + let leadingEndCapBounds = CGRect( + x: rect.origin.x, + y: rect.origin.y, + width: rect.height, + height: rect.height + ) + let trailingEndCapBounds = CGRect( + x: (mainMenuMaxX - (rect.height / 2)) + padding, + y: rect.origin.y, + width: rect.height, + height: rect.height + ) + + var path = NSBezierPath(rect: shapeBounds) + + path = switch info.leading.leadingEndCap { + case .square: path.union(NSBezierPath(rect: leadingEndCapBounds)) + case .round: path.union(NSBezierPath(ovalIn: leadingEndCapBounds)) + } + + path = switch info.leading.trailingEndCap { + case .square: path.union(NSBezierPath(rect: trailingEndCapBounds)) + case .round: path.union(NSBezierPath(ovalIn: trailingEndCapBounds)) + } + + return path + }() + + let trailingPath: NSBezierPath = { + guard + let mainScreen = NSScreen.main, + let owningScreen = appearancePanel?.owningScreen + else { + return NSBezierPath(rect: rect) + } + + let scale = mainScreen.frame.width / owningScreen.frame.width + + var position: CGFloat + if hiddenSection.isHidden { + guard let frame = hiddenSection.controlItem.windowFrame else { + return NSBezierPath(rect: rect) + } + position = (owningScreen.frame.width * scale) - frame.maxX + } else { + guard let frame = alwaysHiddenSection.controlItem.windowFrame else { + return NSBezierPath(rect: rect) + } + position = (owningScreen.frame.width * scale) - frame.maxX + } + + // offset the position by the origin of the main screen + position += mainScreen.frame.origin.x + + // add extra padding after the last menu bar item + position += padding + + // compute the final position based on the maxX of the + // provided rectangle + position = rect.maxX - position + + let shapeBounds = CGRect( + x: position + (rect.height / 2), + y: rect.origin.y, + width: rect.maxX - (position + rect.height), + height: rect.height + ) + let leadingEndCapBounds = CGRect( + x: position, + y: rect.origin.y, + width: rect.height, + height: rect.height + ) + let trailingEndCapBounds = CGRect( + x: rect.maxX - rect.height, + y: rect.origin.y, + width: rect.height, + height: rect.height + ) + + var path = NSBezierPath(rect: shapeBounds) + + path = switch info.trailing.leadingEndCap { + case .square: path.union(NSBezierPath(rect: leadingEndCapBounds)) + case .round: path.union(NSBezierPath(ovalIn: leadingEndCapBounds)) + } + + path = switch info.trailing.trailingEndCap { + case .square: path.union(NSBezierPath(rect: trailingEndCapBounds)) + case .round: path.union(NSBezierPath(ovalIn: trailingEndCapBounds)) + } + + return path + }() + + if leadingPath.intersects(trailingPath) { + let info = MenuBarFullShapeInfo( + leadingEndCap: info.leading.leadingEndCap, + trailingEndCap: info.trailing.trailingEndCap + ) + return pathForFullShapeKind(in: rect, info: info) + } else { + let path = NSBezierPath() + path.append(leadingPath) + path.append(trailingPath) + return path + } + } + + private func drawTint(with appearanceManager: MenuBarAppearanceManager) { + switch appearanceManager.tintKind { + case .none: + break + case .solid: + if let tintColor = NSColor(cgColor: appearanceManager.tintColor)?.withAlphaComponent(0.2) { + tintColor.setFill() + NSBezierPath(rect: drawableBounds).fill() + } + case .gradient: + if let tintGradient = appearanceManager.tintGradient.withAlphaComponent(0.2).nsGradient { + tintGradient.draw(in: drawableBounds, angle: 0) + } + } + } + + override func draw(_ dirtyRect: NSRect) { + guard + let appearanceManager = appearancePanel?.appearanceManager, + let context = NSGraphicsContext.current + else { + return + } + + context.saveGraphicsState() + defer { + context.restoreGraphicsState() + } + + if appearanceManager.isFullscreen { + return + } + + let shapePath = switch appearanceManager.shapeKind { + case .none: + NSBezierPath(rect: drawableBounds) + case .full: + pathForFullShapeKind(in: drawableBounds, info: appearanceManager.fullShapeInfo) + case .split: + pathForSplitShapeKind(in: drawableBounds, info: appearanceManager.splitShapeInfo) + } + + var hasBorder = false + + switch appearanceManager.shapeKind { + case .none: + if appearanceManager.hasShadow { + let gradient = NSGradient( + colors: [ + NSColor(white: 0.0, alpha: 0.0), + NSColor(white: 0.0, alpha: 0.2), + ] + ) + let shadowBounds = CGRect( + x: bounds.minX, + y: bounds.minY, + width: bounds.width, + height: 5 + ) + gradient?.draw(in: shadowBounds, angle: 90) + } + + drawTint(with: appearanceManager) + + if appearanceManager.hasBorder { + let borderBounds = CGRect( + x: bounds.minX, + y: bounds.minY + 5, + width: bounds.width, + height: appearanceManager.borderWidth + ) + NSColor(cgColor: appearanceManager.borderColor)?.setFill() + NSBezierPath(rect: borderBounds).fill() + } + case .full, .split: + if let desktopWallpaper = appearancePanel?.desktopWallpaper { + context.saveGraphicsState() + defer { + context.restoreGraphicsState() + } + + let invertedClipPath = NSBezierPath(rect: drawableBounds) + invertedClipPath.append(shapePath.reversed) + invertedClipPath.setClip() + + context.cgContext.draw(desktopWallpaper, in: drawableBounds) + } + + if appearanceManager.hasShadow { + context.saveGraphicsState() + defer { + context.restoreGraphicsState() + } + + let shadowClipPath = NSBezierPath(rect: bounds) + shadowClipPath.append(shapePath.reversed) + shadowClipPath.setClip() + + shapePath.drawShadow(color: .black.withAlphaComponent(0.5), radius: 5) + } + + if appearanceManager.hasBorder { + hasBorder = true + } + + shapePath.setClip() + + drawTint(with: appearanceManager) + + if + hasBorder, + let borderColor = NSColor(cgColor: appearanceManager.borderColor) + { + // swiftlint:disable:next force_cast + let borderPath = shapePath.copy() as! NSBezierPath + // HACK: insetting a path to get an "inside" stroke is surprisingly + // difficult; we can fake the correct line width by doubling it, as + // anything outside the shape path will be clipped + borderPath.lineWidth = appearanceManager.borderWidth * 2 + borderColor.setStroke() + borderPath.stroke() + } + } + } +} + +// MARK: - Logger +private extension Logger { + static let appearancePanel = Logger(category: "MenuBarAppearancePanel") +} diff --git a/Ice/MenuBar/MenuBarAppearancePanel/MenuBarAppearancePanel.swift b/Ice/MenuBar/MenuBarAppearancePanel/MenuBarAppearancePanel.swift deleted file mode 100644 index 3c4761ef..00000000 --- a/Ice/MenuBar/MenuBarAppearancePanel/MenuBarAppearancePanel.swift +++ /dev/null @@ -1,147 +0,0 @@ -// -// MenuBarAppearancePanel.swift -// Ice -// - -import Cocoa -import Combine - -// MARK: - MenuBarAppearancePanel - -/// A subclass of `NSPanel` that is displayed over the top -/// of, or underneath the menu bar to alter its appearance. -class MenuBarAppearancePanel: NSPanel { - private var cancellables = Set() - - /// The appearance manager that manages the panel. - private(set) weak var appearanceManager: MenuBarAppearanceManager? - - /// Creates a panel with the given window level and menu bar. - /// - /// - Parameters: - /// - level: The window level of the panel. - /// - appearanceManager: The appearance manager that manages the panel. - init(level: Level, appearanceManager: MenuBarAppearanceManager) { - super.init( - contentRect: .zero, - styleMask: [ - .borderless, - .fullSizeContentView, - .nonactivatingPanel, - ], - backing: .buffered, - defer: false - ) - self.appearanceManager = appearanceManager - self.level = level - self.title = String(describing: Self.self) - self.backgroundColor = .clear - self.hasShadow = false - self.ignoresMouseEvents = true - self.collectionBehavior = [ - .fullScreenNone, - .ignoresCycle, - .moveToActiveSpace, - ] - configureCancellables() - } - - private func configureCancellables() { - var c = Set() - - // always show the panel on the active space - NSWorkspace.shared.notificationCenter - .publisher(for: NSWorkspace.activeSpaceDidChangeNotification) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard - let self, - let screen = NSScreen.main - else { - return - } - - // cache isVisible before hiding - let isVisible = isVisible - - hide() - - // if the screen's visible frame and frame are the same, - // the menu bar is hidden; do not allow the panel to show - let canShow = screen.visibleFrame != screen.frame - - if canShow && isVisible { - show() - } - } - .store(in: &c) - - // ensure the panel stays pinned to the top of the screen - // if the size of the screen changes, i.e. when scaling a - // VM window - Timer.publish(every: 1, on: .main, in: .common) - .autoconnect() - .sink { [weak self] _ in - guard - let self, - let screen - else { - return - } - setFrame(menuBarFrame(forScreen: screen), display: true) - } - .store(in: &c) - - cancellables = c - } - - /// Returns the frame on the given screen that the panel - /// should treat as the frame of the menu bar. - /// - /// - Parameter screen: The screen to use to compute the - /// frame of the menu bar. - func menuBarFrame(forScreen screen: NSScreen) -> CGRect { - guard - let menuBarManager = appearanceManager?.menuBarManager, - let menuBarFrame: CGRect = try? menuBarManager.menuBar?.attribute(.frame) - else { - return .zero - } - return CGRect( - x: menuBarFrame.origin.x, - y: screen.frame.maxY - menuBarFrame.origin.y - menuBarFrame.height, - width: menuBarFrame.width, - height: menuBarFrame.height - ) - } - - /// Shows the panel. - func show() { - guard - !AppState.shared.isPreview, - let screen = NSScreen.main - else { - return - } - setFrame(menuBarFrame(forScreen: screen), display: true) - let isVisible = isVisible - if !isVisible { - alphaValue = 0 - } - orderFrontRegardless() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - if !isVisible { - self.animator().alphaValue = 1 - } - } - } - - /// Hides the panel. - func hide() { - close() - } - - override func isAccessibilityElement() -> Bool { - return false - } -} diff --git a/Ice/MenuBar/MenuBarAppearancePanel/MenuBarBackingPanel.swift b/Ice/MenuBar/MenuBarAppearancePanel/MenuBarBackingPanel.swift deleted file mode 100644 index d00fc5c2..00000000 --- a/Ice/MenuBar/MenuBarAppearancePanel/MenuBarBackingPanel.swift +++ /dev/null @@ -1,141 +0,0 @@ -// -// MenuBarBackingPanel.swift -// Ice -// - -import Cocoa -import Combine - -// MARK: - MenuBarBackingPanel - -class MenuBarBackingPanel: MenuBarAppearancePanel { - private var cancellables = Set() - - init(appearanceManager: MenuBarAppearanceManager) { - super.init(level: Level(Int(CGWindowLevelForKey(.desktopIconWindow))), appearanceManager: appearanceManager) - self.contentView = MenuBarBackingPanelView(appearanceManager: appearanceManager) - } - - func configureCancellables() { - var c = Set() - - if let appearanceManager { - Publishers.CombineLatest3( - appearanceManager.$hasShadow, - appearanceManager.$hasBorder, - appearanceManager.$shapeKind - ) - .map { hasShadow, hasBorder, shapeKind in - guard shapeKind == .none else { - return false - } - return hasShadow || hasBorder - } - .receive(on: DispatchQueue.main) - .sink { [weak self] shouldShow in - guard let self else { - return - } - if shouldShow { - show() - } else { - hide() - } - } - .store(in: &c) - } - - cancellables = c - } - - override func menuBarFrame(forScreen screen: NSScreen) -> CGRect { - let rect = super.menuBarFrame(forScreen: screen) - let offset: CGFloat = { - guard - let appearanceManager, - appearanceManager.hasBorder - else { - return 0 - } - return appearanceManager.borderWidth - }() - return CGRect( - x: rect.minX, - y: (rect.minY - offset) - 5, - width: rect.width, - height: (rect.height + offset) + 5 - ) - } -} - -// MARK: - MenuBarBackingPanelView - -private class MenuBarBackingPanelView: NSView { - private weak var appearanceManager: MenuBarAppearanceManager? - private var cancellables = Set() - - init(appearanceManager: MenuBarAppearanceManager) { - super.init(frame: .zero) - self.appearanceManager = appearanceManager - configureCancellables() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func configureCancellables() { - var c = Set() - - if let appearanceManager { - Publishers.CombineLatest4( - appearanceManager.$hasShadow, - appearanceManager.$hasBorder, - appearanceManager.$borderColor, - appearanceManager.$borderWidth - ) - .combineLatest(appearanceManager.$shapeKind) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.needsDisplay = true - } - .store(in: &c) - } - - cancellables = c - } - - override func draw(_ dirtyRect: NSRect) { - guard let appearanceManager else { - return - } - - if appearanceManager.hasShadow { - let gradient = NSGradient( - colors: [ - NSColor(white: 0.0, alpha: 0.0), - NSColor(white: 0.0, alpha: 0.2), - ] - ) - let shadowBounds = CGRect( - x: bounds.minX, - y: bounds.minY, - width: bounds.width, - height: 5 - ) - gradient?.draw(in: shadowBounds, angle: 90) - } - - if appearanceManager.hasBorder { - let borderBounds = CGRect( - x: bounds.minX, - y: bounds.minY + 5, - width: bounds.width, - height: appearanceManager.borderWidth - ) - NSColor(cgColor: appearanceManager.borderColor)?.setFill() - NSBezierPath(rect: borderBounds).fill() - } - } -} diff --git a/Ice/MenuBar/MenuBarAppearancePanel/MenuBarOverlayPanel.swift b/Ice/MenuBar/MenuBarAppearancePanel/MenuBarOverlayPanel.swift deleted file mode 100644 index eb0c1ad8..00000000 --- a/Ice/MenuBar/MenuBarAppearancePanel/MenuBarOverlayPanel.swift +++ /dev/null @@ -1,390 +0,0 @@ -// -// MenuBarOverlayPanel.swift -// Ice -// - -import Cocoa -import Combine -import OSLog - -// MARK: - MenuBarOverlayPanel - -class MenuBarOverlayPanel: MenuBarAppearancePanel { - private var cancellables = Set() - - init(appearanceManager: MenuBarAppearanceManager) { - super.init(level: .statusBar, appearanceManager: appearanceManager) - self.contentView = MenuBarOverlayPanelView(appearanceManager: appearanceManager) - } - - func configureCancellables() { - var c = Set() - - if let appearanceManager { - Publishers.CombineLatest3( - appearanceManager.$tintKind, - appearanceManager.$shapeKind, - appearanceManager.$hasShadow - ) - .map { tintKind, shapeKind, _ in - tintKind != .none || shapeKind != .none - } - .receive(on: DispatchQueue.main) - .sink { [weak self] shouldShow in - guard let self else { - return - } - if shouldShow { - show() - } else { - hide() - } - } - .store(in: &c) - } - - cancellables = c - } - - override func menuBarFrame(forScreen screen: NSScreen) -> CGRect { - let rect = super.menuBarFrame(forScreen: screen) - return CGRect( - x: rect.minX, - y: rect.minY - 5, - width: rect.width, - height: rect.height + 5 - ) - } -} - -// MARK: - MenuBarOverlayPanelView - -private class MenuBarOverlayPanelView: NSView { - private weak var appearanceManager: MenuBarAppearanceManager? - private var cancellables = Set() - - init(appearanceManager: MenuBarAppearanceManager) { - super.init(frame: .zero) - self.appearanceManager = appearanceManager - configureCancellables() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func configureCancellables() { - var c = Set() - - if let menuBarManager = appearanceManager?.menuBarManager { - menuBarManager.$mainMenuMaxX - .sink { [weak self] _ in - self?.needsDisplay = true - } - .store(in: &c) - - for name: MenuBarSection.Name in [.hidden, .alwaysHidden] { - if let section = menuBarManager.section(withName: name) { - section.controlItem.$windowFrame - .combineLatest(section.controlItem.$screen) - .filter { frame, screen in - guard - let frame, - let screen - else { - return false - } - return (screen.frame.minX...screen.frame.maxX).contains(frame.maxX) - } - .receive(on: RunLoop.main) - .sink { [weak self] _ in - self?.needsDisplay = true - } - .store(in: &c) - } - } - } - - if let appearanceManager { - Publishers.CombineLatest4( - appearanceManager.$desktopWallpaper, - appearanceManager.$tintKind, - appearanceManager.$tintColor, - appearanceManager.$tintGradient - ) - .combineLatest( - appearanceManager.$shapeKind, - appearanceManager.$fullShapeInfo, - appearanceManager.$splitShapeInfo - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.needsDisplay = true - } - .store(in: &c) - - Publishers.CombineLatest4( - appearanceManager.$hasShadow, - appearanceManager.$hasBorder, - appearanceManager.$borderColor, - appearanceManager.$borderWidth - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.needsDisplay = true - } - .store(in: &c) - } - - cancellables = c - } - - /// Returns a path for the ``MenuBarShapeKind/full`` shape kind. - private func pathForFullShapeKind(in rect: CGRect, info: MenuBarFullShapeInfo) -> NSBezierPath { - let shapeBounds = CGRect( - x: rect.height / 2, - y: rect.origin.y, - width: rect.width - rect.height, - height: rect.height - ) - let leadingEndCapBounds = CGRect( - x: rect.origin.x, - y: rect.origin.y, - width: rect.height, - height: rect.height - ) - let trailingEndCapBounds = CGRect( - x: rect.width - rect.height, - y: rect.origin.y, - width: rect.height, - height: rect.height - ) - - var path = NSBezierPath(rect: shapeBounds) - - path = switch info.leadingEndCap { - case .square: path.union(NSBezierPath(rect: leadingEndCapBounds)) - case .round: path.union(NSBezierPath(ovalIn: leadingEndCapBounds)) - } - - path = switch info.trailingEndCap { - case .square: path.union(NSBezierPath(rect: trailingEndCapBounds)) - case .round: path.union(NSBezierPath(ovalIn: trailingEndCapBounds)) - } - - return path - } - - /// Returns a path for the ``MenuBarShapeKind/split`` shape kind. - private func pathForSplitShapeKind(in rect: CGRect, info: MenuBarSplitShapeInfo) -> NSBezierPath { - guard - let menuBarManager = appearanceManager?.menuBarManager, - let hiddenSection = menuBarManager.section(withName: .hidden), - let alwaysHiddenSection = menuBarManager.section(withName: .alwaysHidden) - else { - Logger.menuBarOverlayPanel.notice("Unable to create split shape path") - return NSBezierPath(rect: rect) - } - - guard alwaysHiddenSection.isHidden else { - return pathForFullShapeKind( - in: rect, - info: MenuBarFullShapeInfo( - leadingEndCap: info.leading.leadingEndCap, - trailingEndCap: info.trailing.trailingEndCap - ) - ) - } - - let leadingPath: NSBezierPath = { - let shapeBounds = CGRect( - x: rect.height / 2, - y: rect.origin.y, - width: (menuBarManager.mainMenuMaxX - rect.height) + 10, - height: rect.height - ) - let leadingEndCapBounds = CGRect( - x: rect.origin.x, - y: rect.origin.y, - width: rect.height, - height: rect.height - ) - let trailingEndCapBounds = CGRect( - x: (menuBarManager.mainMenuMaxX - rect.height) + 10, - y: rect.origin.y, - width: rect.height, - height: rect.height - ) - - var path = NSBezierPath(rect: shapeBounds) - - path = switch info.leading.leadingEndCap { - case .square: path.union(NSBezierPath(rect: leadingEndCapBounds)) - case .round: path.union(NSBezierPath(ovalIn: leadingEndCapBounds)) - } - - path = switch info.leading.trailingEndCap { - case .square: path.union(NSBezierPath(rect: trailingEndCapBounds)) - case .round: path.union(NSBezierPath(ovalIn: trailingEndCapBounds)) - } - - return path - }() - - let trailingPath: NSBezierPath = { - let position = if hiddenSection.isHidden { - hiddenSection.controlItem.windowFrame?.maxX ?? 0 - } else { - alwaysHiddenSection.controlItem.windowFrame?.maxX ?? 0 - } - let shapeBounds = CGRect( - x: (position + (rect.height / 2)) - 10, - y: rect.origin.y, - width: (rect.maxX - (position + rect.height)) + 10, - height: rect.height - ) - let leadingEndCapBounds = CGRect( - x: position - 10, - y: rect.origin.y, - width: rect.height, - height: rect.height - ) - let trailingEndCapBounds = CGRect( - x: rect.maxX - rect.height, - y: rect.origin.y, - width: rect.height, - height: rect.height - ) - - var path = NSBezierPath(rect: shapeBounds) - - path = switch info.trailing.leadingEndCap { - case .square: path.union(NSBezierPath(rect: leadingEndCapBounds)) - case .round: path.union(NSBezierPath(ovalIn: leadingEndCapBounds)) - } - - path = switch info.trailing.trailingEndCap { - case .square: path.union(NSBezierPath(rect: trailingEndCapBounds)) - case .round: path.union(NSBezierPath(ovalIn: trailingEndCapBounds)) - } - - return path - }() - - guard !leadingPath.intersects(trailingPath) else { - return pathForFullShapeKind( - in: rect, - info: MenuBarFullShapeInfo( - leadingEndCap: info.leading.leadingEndCap, - trailingEndCap: info.trailing.trailingEndCap - ) - ) - } - - let path = NSBezierPath() - - path.append(leadingPath) - path.append(trailingPath) - - return path - } - - override func draw(_ dirtyRect: NSRect) { - guard - let appearanceManager, - let context = NSGraphicsContext.current - else { - return - } - - context.saveGraphicsState() - defer { - context.restoreGraphicsState() - } - - let adjustedBounds = CGRect( - x: bounds.origin.x, - y: bounds.origin.y + 5, - width: bounds.width, - height: bounds.height - 5 - ) - - let shapePath = switch appearanceManager.shapeKind { - case .none: - NSBezierPath(rect: adjustedBounds) - case .full: - pathForFullShapeKind(in: adjustedBounds, info: appearanceManager.fullShapeInfo) - case .split: - pathForSplitShapeKind(in: adjustedBounds, info: appearanceManager.splitShapeInfo) - } - - var hasBorder = false - - if appearanceManager.shapeKind != .none { - if let desktopWallpaper = appearanceManager.desktopWallpaper { - context.saveGraphicsState() - defer { - context.restoreGraphicsState() - } - - let invertedClipPath = NSBezierPath(rect: adjustedBounds) - invertedClipPath.append(shapePath.reversed) - invertedClipPath.setClip() - - context.cgContext.draw(desktopWallpaper, in: adjustedBounds) - } - - if appearanceManager.hasShadow { - context.saveGraphicsState() - defer { - context.restoreGraphicsState() - } - - let shadowClipPath = NSBezierPath(rect: bounds) - shadowClipPath.append(shapePath.reversed) - shadowClipPath.setClip() - - shapePath.drawShadow(color: .black.withAlphaComponent(0.5), radius: 5) - } - - if appearanceManager.hasBorder { - hasBorder = true - } - } - - shapePath.setClip() - - switch appearanceManager.tintKind { - case .none: - break - case .solid: - if let tintColor = NSColor(cgColor: appearanceManager.tintColor)?.withAlphaComponent(0.2) { - tintColor.setFill() - NSBezierPath(rect: adjustedBounds).fill() - } - case .gradient: - if let tintGradient = appearanceManager.tintGradient.withAlphaComponent(0.2).nsGradient { - tintGradient.draw(in: adjustedBounds, angle: 0) - } - } - - if hasBorder { - if let borderColor = NSColor(cgColor: appearanceManager.borderColor) { - // swiftlint:disable:next force_cast - let borderPath = shapePath.copy() as! NSBezierPath - // HACK: insetting a path to get an "inside" stroke is surprisingly - // difficult; we can fake the correct line width by doubling it, as - // anything outside the shape path will be clipped - borderPath.lineWidth = appearanceManager.borderWidth * 2 - borderColor.setStroke() - borderPath.stroke() - } - } - } -} - -// MARK: - Logger -private extension Logger { - static let menuBarOverlayPanel = Logger(category: "MenuBarOverlayPanel") -} diff --git a/Ice/Utilities/ScreenCaptureManager.swift b/Ice/Utilities/ScreenCaptureManager.swift new file mode 100644 index 00000000..4e0a4aca --- /dev/null +++ b/Ice/Utilities/ScreenCaptureManager.swift @@ -0,0 +1,254 @@ +// +// ScreenCaptureManager.swift +// Ice +// + +import Combine +import OSLog +import ScreenCaptureKit + +class ScreenCaptureManager: ObservableObject { + /// Options that affect the image or images returned from a capture. + struct CaptureOptions: OptionSet { + let rawValue: Int + + /// If the `screenBounds` parameter of the capture is `nil`, + /// captures only the window area and ignores the area occupied + /// by any framing effects. + static let ignoreFraming = CaptureOptions(rawValue: 1 << 0) + + /// Captures only the shadow effects of the provided windows. + static let onlyShadows = CaptureOptions(rawValue: 1 << 1) + + /// Fills the partially or fully transparent areas of the capture + /// with a solid white backing color, resulting in an image that + /// is fully opaque. + static let shouldBeOpaque = CaptureOptions(rawValue: 1 << 2) + + /// The cursor is shown in the capture. + static let showsCursor = CaptureOptions(rawValue: 1 << 3) + + /// The output is scaled to fit the configured width and height. + static let scalesToFit = CaptureOptions(rawValue: 1 << 4) + } + + /// An error that can occur during a capture. + enum CaptureError: Error { + /// The screen capture manager does not contain a window that + /// matches the provided window. + case noMatchingWindow + + /// The screen capture manager does not contain a display that + /// matches the provided display. + case noMatchingDisplay + + /// The provided window is not on screen. + case windowOffScreen + + /// The source rectangle of the capture is outside the bounds + /// of the provided window. + case sourceRectOutOfBounds + + /// The capture operation timed out. + case timeout + } + + /// The shared screen capture manager. + static let shared = ScreenCaptureManager(interval: 3, runLoop: .main, mode: .default) + + private var updateTimer: AnyCancellable? + + /// The apps that are available to capture. + @Published private(set) var applications = [SCRunningApplication]() + + /// The displays that are available to capture. + @Published private(set) var displays = [SCDisplay]() + + /// The windows that are available to capture. + @Published private(set) var windows = [SCWindow]() + + /// A Boolean value that indicates whether the manager is + /// continuously updating its content. + var isContinuouslyUpdating: Bool { + updateTimer != nil + } + + /// A Boolean value that indicates whether the app has + /// screen capture permissions. + var hasScreenCapturePermissions: Bool { + CGPreflightScreenCaptureAccess() + } + + /// The time interval at which to continuously update the + /// manager's content. + var interval: TimeInterval { + didSet { + if isContinuouslyUpdating { + // reinitialize the timer using the new interval + startContinuouslyUpdating() + } + } + } + + /// The run loop on which to continuously update the + /// manager's content. + var runLoop: RunLoop { + didSet { + if isContinuouslyUpdating { + // reinitialize the timer using the new run loop + startContinuouslyUpdating() + } + } + } + + /// The run loop mode in which to continuously update the + /// manager's content. + var mode: RunLoop.Mode { + didSet { + if isContinuouslyUpdating { + // reinitialize the timer using the new mode + startContinuouslyUpdating() + } + } + } + + /// Creates a screen capture manager with the given interval, + /// run loop, and run loop mode. + init(interval: TimeInterval, runLoop: RunLoop, mode: RunLoop.Mode) { + self.interval = interval + self.runLoop = runLoop + self.mode = mode + startContinuouslyUpdating() + } + + func update() { + guard hasScreenCapturePermissions else { + Logger.screenCaptureManager.notice("Missing screen capture permissions") + return + } + SCShareableContent.getWithCompletionHandler { content, error in + if let error { + Logger.screenCaptureManager.error("Error updating shareable content: \(error)") + } + self.applications = content?.applications ?? [] + self.displays = content?.displays ?? [] + self.windows = content?.windows ?? [] + } + } + + /// Starts continuously updating the manager's content. + /// + /// The content will update according to the value set by + /// the manager's ``interval`` property. + func startContinuouslyUpdating() { + update() + updateTimer = Timer.publish(every: interval, on: runLoop, in: mode) + .autoconnect() + .sink { [weak self] _ in + guard let self else { + return + } + update() + } + } + + /// Stops the manager's content from continuously updating. + /// + /// The manager will retain the current content, but it will + /// not stay up to date. + func stopContinuouslyUpdating() { + updateTimer?.cancel() + updateTimer = nil + } + + /// Captures the given window as an image. + /// + /// - Parameters: + /// - timeout: Amount of time to wait before throwing a cancellation error. + /// - window: The window to capture. The window must be on screen. + /// - display: The display of the capture. + /// - captureRect: The rectangle to capture, relative to the coordinate + /// space of the window. Pass `nil` to capture the entire window. + /// - resolution: The resolution of the capture. + /// - options: Additional parameters for the capture. + func captureImage( + withTimeout timeout: Duration, + window: SCWindow, + display: SCDisplay, + captureRect: CGRect? = nil, + resolution: SCCaptureResolutionType = .automatic, + options: CaptureOptions = [] + ) async throws -> CGImage { + guard let window = windows.first(where: { $0.windowID == window.windowID }) else { + throw CaptureError.noMatchingWindow + } + guard let display = displays.first(where: { $0.displayID == display.displayID }) else { + throw CaptureError.noMatchingDisplay + } + guard window.isOnScreen else { + throw CaptureError.windowOffScreen + } + + let captureRect = captureRect ?? .null + let windowBounds = CGRect(origin: .zero, size: window.frame.size) + let sourceRect = if captureRect.isNull { + windowBounds + } else { + captureRect + } + + guard windowBounds.contains(sourceRect) else { + throw CaptureError.sourceRectOutOfBounds + } + + let contentFilter = SCContentFilter(desktopIndependentWindow: window) + let configuration = SCStreamConfiguration() + + let displayID = display.displayID + let scale = getDisplayScaleFactor(displayID) + + configuration.sourceRect = sourceRect + configuration.width = Int(sourceRect.width * scale) + configuration.height = Int(sourceRect.height * scale) + configuration.captureResolution = resolution + configuration.ignoreShadowsSingleWindow = options.contains(.ignoreFraming) + configuration.capturesShadowsOnly = options.contains(.onlyShadows) + configuration.shouldBeOpaque = options.contains(.shouldBeOpaque) + configuration.showsCursor = options.contains(.showsCursor) + configuration.scalesToFit = options.contains(.scalesToFit) + + let captureTask = Task { + let image = try await SCScreenshotManager.captureImage( + contentFilter: contentFilter, + configuration: configuration + ) + try Task.checkCancellation() + return image + } + + let timeoutTask = Task { + try await Task.sleep(for: timeout) + captureTask.cancel() + } + + do { + let result = try await captureTask.value + timeoutTask.cancel() + return result + } catch is CancellationError { + throw CaptureError.timeout + } + } + + private func getDisplayScaleFactor(_ displayID: CGDirectDisplayID) -> CGFloat { + guard let mode = CGDisplayCopyDisplayMode(displayID) else { + return 1 + } + return CGFloat(mode.pixelWidth) / CGFloat(mode.width) + } +} + +// MARK: - Logger +private extension Logger { + static let screenCaptureManager = Logger(category: "ScreenCaptureManager") +} diff --git a/Ice/Utilities/ScreenshotManager.swift b/Ice/Utilities/ScreenshotManager.swift deleted file mode 100644 index 3bba4e08..00000000 --- a/Ice/Utilities/ScreenshotManager.swift +++ /dev/null @@ -1,128 +0,0 @@ -// -// ScreenshotManager.swift -// Ice -// - -import ScreenCaptureKit - -/// A type that captures screenshots. -enum ScreenshotManager { - /// Options that affect the image or images returned from a capture. - struct CaptureOptions: OptionSet { - let rawValue: Int - - /// If the `screenBounds` parameter of the capture is `nil`, - /// captures only the window area and ignores the area occupied - /// by any framing effects. - static let ignoreFraming = CaptureOptions(rawValue: 1 << 0) - - /// Captures only the shadow effects of the provided windows. - static let onlyShadows = CaptureOptions(rawValue: 1 << 1) - - /// Fills the partially or fully transparent areas of the capture - /// with a solid white backing color, resulting in an image that - /// is fully opaque. - static let shouldBeOpaque = CaptureOptions(rawValue: 1 << 2) - - /// The cursor is shown in the capture. - static let showsCursor = CaptureOptions(rawValue: 1 << 3) - - /// The output is scaled to fit the configured width and height. - static let scalesToFit = CaptureOptions(rawValue: 1 << 4) - } - - /// An error that can occur during a capture. - enum CaptureError: Error { - /// The provided window is not on screen. - case windowOffScreen - - /// The source rectangle of the capture is outside the bounds - /// of the provided window. - case sourceRectOutOfBounds - - /// The capture operation timed out. - case timeout - } - - /// Captures the given window as an image. - /// - /// - Parameters: - /// - timeout: Amount of time to wait before throwing a cancellation error. - /// - window: The window to capture. - /// - display: The display that determines the scale factor of the - /// capture. Usually this is the display that contains the window. - /// Pass `nil` to use the main display. - /// - captureRect: The rectangle to capture, relative to the coordinate - /// space of the window. Pass `nil` to capture the entire window. - /// - resolution: The resolution of the capture. - /// - options: Additional parameters for the capture. - static func captureImage( - withTimeout timeout: Duration, - window: SCWindow, - display: SCDisplay? = nil, - captureRect: CGRect? = nil, - resolution: SCCaptureResolutionType = .automatic, - options: CaptureOptions = [] - ) async throws -> CGImage { - guard window.isOnScreen else { - throw CaptureError.windowOffScreen - } - - let captureRect = captureRect ?? .null - let windowBounds = CGRect(origin: .zero, size: window.frame.size) - let sourceRect = if captureRect.isNull { - windowBounds - } else { - captureRect - } - - guard windowBounds.contains(sourceRect) else { - throw CaptureError.sourceRectOutOfBounds - } - - let contentFilter = SCContentFilter(desktopIndependentWindow: window) - let configuration = SCStreamConfiguration() - - let displayID = display?.displayID ?? CGMainDisplayID() - let scale = getDisplayScaleFactor(displayID) - - configuration.sourceRect = sourceRect - configuration.width = Int(sourceRect.width * scale) - configuration.height = Int(sourceRect.height * scale) - configuration.captureResolution = resolution - configuration.ignoreShadowsSingleWindow = options.contains(.ignoreFraming) - configuration.capturesShadowsOnly = options.contains(.onlyShadows) - configuration.shouldBeOpaque = options.contains(.shouldBeOpaque) - configuration.showsCursor = options.contains(.showsCursor) - configuration.scalesToFit = options.contains(.scalesToFit) - - let captureTask = Task { - let image = try await SCScreenshotManager.captureImage( - contentFilter: contentFilter, - configuration: configuration - ) - try Task.checkCancellation() - return image - } - - let timeoutTask = Task { - try await Task.sleep(for: timeout) - captureTask.cancel() - } - - do { - let result = try await captureTask.value - timeoutTask.cancel() - return result - } catch is CancellationError { - throw CaptureError.timeout - } - } - - private static func getDisplayScaleFactor(_ displayID: CGDirectDisplayID) -> CGFloat { - guard let mode = CGDisplayCopyDisplayMode(displayID) else { - return 1 - } - return CGFloat(mode.pixelWidth) / CGFloat(mode.width) - } -}