diff --git a/Ice/Main/AppDelegate.swift b/Ice/Main/AppDelegate.swift index 4e93dec..ee23772 100644 --- a/Ice/Main/AppDelegate.swift +++ b/Ice/Main/AppDelegate.swift @@ -49,9 +49,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } // If we have the required permissions, set up the shared app state. // Otherwise, open the permissions window. - if appState.permissionsManager.hasAllPermissions { + switch appState.permissionsManager.permissionsState { + case .hasAllPermissions, .hasRequiredPermissions: appState.performSetup() - } else { + case .missingPermissions: appState.activate(withPolicy: .regular) appState.openPermissionsWindow() } diff --git a/Ice/Permissions/Permission.swift b/Ice/Permissions/Permission.swift index 1bb5f27..dd82abd 100644 --- a/Ice/Permissions/Permission.swift +++ b/Ice/Permissions/Permission.swift @@ -13,7 +13,7 @@ import ScreenCaptureKit /// An object that encapsulates the behavior of checking for and requesting /// a specific permission for the app. @MainActor -class Permission: ObservableObject { +class Permission: ObservableObject, Identifiable { /// A Boolean value that indicates whether the app has this permission. @Published private(set) var hasPermission = false @@ -21,8 +21,8 @@ class Permission: ObservableObject { let title: String /// Descriptive details for the permission. let details: [String] - /// Can Ice work without this? - let required: Bool + /// A Boolean value that indicates if the app can work without this permission. + let isRequired: Bool /// The URL of the settings pane to open. private let settingsURL: URL? @@ -41,21 +41,21 @@ class Permission: ObservableObject { /// - Parameters: /// - title: The title of the permission. /// - details: Descriptive details for the permission. - /// - required: Defines wether this permission is required for Ice to work. + /// - isRequired: A Boolean value that indicates if the app can work without this permission. /// - settingsURL: The URL of the settings pane to open. /// - check: A function that checks permissions. /// - request: A function that requests permissions. init( title: String, details: [String], - required: Bool, + isRequired: Bool, settingsURL: URL?, check: @escaping () -> Bool, request: @escaping () -> Void ) { self.title = title self.details = details - self.required = required + self.isRequired = isRequired self.settingsURL = settingsURL self.check = check self.request = request @@ -86,6 +86,7 @@ class Permission: ObservableObject { /// Asynchronously waits for the app to be granted this permission. func waitForPermission() async { + configureCancellables() guard !hasPermission else { return } @@ -122,7 +123,7 @@ final class AccessibilityPermission: Permission { "Get real-time information about the menu bar.", "Arrange menu bar items.", ], - required: true, + isRequired: true, settingsURL: nil, check: { checkIsProcessTrusted() @@ -144,7 +145,7 @@ final class ScreenRecordingPermission: Permission { "Edit the menu bar's appearance.", "Display images of individual menu bar items.", ], - required: false, + isRequired: false, settingsURL: URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"), check: { ScreenCapture.checkPermissions() diff --git a/Ice/Permissions/PermissionsManager.swift b/Ice/Permissions/PermissionsManager.swift index efa9118..c991d04 100644 --- a/Ice/Permissions/PermissionsManager.swift +++ b/Ice/Permissions/PermissionsManager.swift @@ -4,42 +4,75 @@ // import Combine +import Foundation /// A type that manages the permissions of the app. @MainActor final class PermissionsManager: ObservableObject { - /// A Boolean value that indicates whether the app has been granted all permissions. - @Published var hasAllPermissions: Bool = false + /// The state of the granted permissions for the app. + enum PermissionsState { + case missingPermissions + case hasAllPermissions + case hasRequiredPermissions + } + + /// The state of the granted permissions for the app. + @Published var permissionsState = PermissionsState.missingPermissions + + let accessibilityPermission: AccessibilityPermission - let accessibilityPermission = AccessibilityPermission() + let screenRecordingPermission: ScreenRecordingPermission - let screenRecordingPermission = ScreenRecordingPermission() + let allPermissions: [Permission] private(set) weak var appState: AppState? private var cancellables = Set() + var requiredPermissions: [Permission] { + allPermissions.filter { $0.isRequired } + } + init(appState: AppState) { self.appState = appState + self.accessibilityPermission = AccessibilityPermission() + self.screenRecordingPermission = ScreenRecordingPermission() + self.allPermissions = [ + accessibilityPermission, + screenRecordingPermission, + ] configureCancellables() } private func configureCancellables() { var c = Set() - accessibilityPermission.$hasPermission - .combineLatest(screenRecordingPermission.$hasPermission) - .sink { [weak self] hasPermission1, hasPermission2 in - self?.hasAllPermissions = (hasPermission1 || self?.accessibilityPermission.required == false) && (hasPermission2 || self?.screenRecordingPermission.required == false) + Publishers.Merge( + accessibilityPermission.$hasPermission.mapToVoid(), + screenRecordingPermission.$hasPermission.mapToVoid() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] in + guard let self else { + return + } + if allPermissions.allSatisfy({ $0.hasPermission }) { + permissionsState = .hasAllPermissions + } else if requiredPermissions.allSatisfy({ $0.hasPermission }) { + permissionsState = .hasRequiredPermissions + } else { + permissionsState = .missingPermissions } - .store(in: &c) + } + .store(in: &c) cancellables = c } /// Stops running all permissions checks. func stopAllChecks() { - accessibilityPermission.stopCheck() - screenRecordingPermission.stopCheck() + for permission in allPermissions { + permission.stopCheck() + } } } diff --git a/Ice/Permissions/PermissionsView.swift b/Ice/Permissions/PermissionsView.swift index 6b8c2ae..8b74300 100644 --- a/Ice/Permissions/PermissionsView.swift +++ b/Ice/Permissions/PermissionsView.swift @@ -9,6 +9,22 @@ struct PermissionsView: View { @EnvironmentObject var permissionsManager: PermissionsManager @Environment(\.openWindow) private var openWindow + private var continueButtonText: LocalizedStringKey { + if case .hasRequiredPermissions = permissionsManager.permissionsState { + "Continue in Limited Mode" + } else { + "Continue" + } + } + + private var continueButtonForegroundStyle: some ShapeStyle { + if case .hasRequiredPermissions = permissionsManager.permissionsState { + AnyShapeStyle(.yellow) + } else { + AnyShapeStyle(.primary) + } + } + var body: some View { VStack(spacing: 0) { headerView @@ -72,8 +88,9 @@ struct PermissionsView: View { @ViewBuilder private var permissionsGroupStack: some View { VStack(spacing: 7.5) { - permissionBox(permissionsManager.accessibilityPermission) - permissionBox(permissionsManager.screenRecordingPermission) + ForEach(permissionsManager.allPermissions) { permission in + permissionBox(permission) + } } } @@ -106,10 +123,11 @@ struct PermissionsView: View { appState.permissionsWindow?.close() appState.appDelegate?.openSettingsWindow() } label: { - Text("Continue") + Text(continueButtonText) .frame(maxWidth: .infinity) + .foregroundStyle(continueButtonForegroundStyle) } - .disabled(!permissionsManager.hasAllPermissions) + .disabled(permissionsManager.permissionsState == .missingPermissions) } @ViewBuilder @@ -154,6 +172,22 @@ struct PermissionsView: View { } } .allowsHitTesting(!permission.hasPermission) + + if !permission.isRequired { + IceGroupBox { + AnnotationView( + alignment: .center, + font: .callout.bold() + ) { + Label { + Text("Ice can work in a limited mode without this permission.") + } icon: { + Image(systemName: "checkmark.shield") + .foregroundStyle(.green) + } + } + } + } } .padding(10) .frame(maxWidth: .infinity) diff --git a/Ice/Settings/SettingsPanes/AdvancedSettingsPane.swift b/Ice/Settings/SettingsPanes/AdvancedSettingsPane.swift index 4a7cda6..eeb0f43 100644 --- a/Ice/Settings/SettingsPanes/AdvancedSettingsPane.swift +++ b/Ice/Settings/SettingsPanes/AdvancedSettingsPane.swift @@ -41,6 +41,9 @@ struct AdvancedSettingsPane: View { showOnHoverDelaySlider tempShowIntervalSlider } + IceSection("Permissions") { + allPermissions + } } } @@ -134,6 +137,29 @@ struct AdvancedSettingsPane: View { private var showAllSectionsOnUserDrag: some View { Toggle("Show all sections when Command + dragging menu bar items", isOn: manager.bindings.showAllSectionsOnUserDrag) } + + @ViewBuilder + private var allPermissions: some View { + ForEach(appState.permissionsManager.allPermissions) { permission in + IceLabeledContent { + if permission.hasPermission { + Label { + Text("Permission Granted") + } icon: { + Image(systemName: "checkmark.circle") + .foregroundStyle(.green) + } + } else { + Button("Grant Permission") { + permission.performRequest() + } + } + } label: { + Text(permission.title) + } + .frame(height: 22) + } + } } #Preview { diff --git a/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift b/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift index 3e12bbc..b103824 100644 --- a/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift +++ b/Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift @@ -9,7 +9,9 @@ struct MenuBarLayoutSettingsPane: View { @EnvironmentObject var appState: AppState var body: some View { - if appState.menuBarManager.isMenuBarHiddenBySystemUserDefaults { + if !ScreenCapture.cachedCheckPermissions() { + missingScreenRecordingPermission + } else if appState.menuBarManager.isMenuBarHiddenBySystemUserDefaults { cannotArrange } else { IceForm(alignment: .leading, spacing: 20) { @@ -36,21 +38,6 @@ struct MenuBarLayoutSettingsPane: View { } } } - - if !ScreenCapture.cachedCheckPermissions() { - IceGroupBox { - AnnotationView( - alignment: .center, - font: .callout.bold() - ) { - Label { - Text("This pane requires the screen recording permission to work.") - } icon: { - Image(systemName: "exclamationmark.triangle") - } - } - } - } } @ViewBuilder @@ -69,6 +56,21 @@ struct MenuBarLayoutSettingsPane: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) } + @ViewBuilder + private var missingScreenRecordingPermission: some View { + VStack { + Text("Menu bar layout requires screen recording permissions") + .font(.title2) + + Button { + appState.navigationState.settingsNavigationIdentifier = .advanced + } label: { + Text("Go to Advanced Settings") + } + .buttonStyle(.link) + } + } + @ViewBuilder private func layoutBar(for section: MenuBarSection.Name) -> some View { if diff --git a/Ice/UI/IceBar/IceBar.swift b/Ice/UI/IceBar/IceBar.swift index 1e12b80..6287e05 100644 --- a/Ice/UI/IceBar/IceBar.swift +++ b/Ice/UI/IceBar/IceBar.swift @@ -166,7 +166,7 @@ final class IceBarPanel: NSPanel { await appState.imageCache.updateCache() } - contentView = IceBarHostingView(appState: appState, colorManager: colorManager, section: section) { [weak self] in + contentView = IceBarHostingView(appState: appState, colorManager: colorManager, screen: screen, section: section) { [weak self] in self?.close() } @@ -199,11 +199,12 @@ private final class IceBarHostingView: NSHostingView { init( appState: AppState, colorManager: IceBarColorManager, + screen: NSScreen, section: MenuBarSection.Name, closePanel: @escaping () -> Void ) { super.init( - rootView: IceBarContentView(section: section, closePanel: closePanel) + rootView: IceBarContentView(screen: screen, section: section, closePanel: closePanel) .environmentObject(appState) .environmentObject(appState.imageCache) .environmentObject(appState.itemManager) @@ -239,6 +240,7 @@ private struct IceBarContentView: View { @State private var frame = CGRect.zero @State private var scrollIndicatorsFlashTrigger = 0 + let screen: NSScreen let section: MenuBarSection.Name let closePanel: () -> Void @@ -255,19 +257,14 @@ private struct IceBarContentView: View { } private var verticalPadding: CGFloat { - if let screen = imageCache.screen { - guard !screen.hasNotch else { - return 0 - } - } - return 2 + screen.hasNotch ? 0 : 2 } var contentHeight: CGFloat? { - guard let menuBarHeight = imageCache.menuBarHeight else { + guard let menuBarHeight = imageCache.menuBarHeight ?? screen.getMenuBarHeight() else { return nil } - if configuration.shapeKind != .none && configuration.isInset && imageCache.screen?.hasNotch == true { + if configuration.shapeKind != .none && configuration.isInset && screen.hasNotch { return menuBarHeight - appState.appearanceManager.menuBarInsetAmount * 2 } return menuBarHeight @@ -307,12 +304,27 @@ private struct IceBarContentView: View { @ViewBuilder private var content: some View { - if menuBarManager.isMenuBarHiddenBySystemUserDefaults { + if !ScreenCapture.cachedCheckPermissions() { + HStack { + Text("The Ice Bar requires screen recording permissions.") + + Button { + closePanel() + appState.navigationState.settingsNavigationIdentifier = .advanced + appState.appDelegate?.openSettingsWindow() + } label: { + Text("Open Ice Settings") + } + .buttonStyle(.plain) + .foregroundStyle(.link) + } + .padding(.horizontal, 10) + } else if menuBarManager.isMenuBarHiddenBySystemUserDefaults { Text("Ice cannot display menu bar items for automatically hidden menu bars") - .padding(.horizontal, 5) + .padding(.horizontal, 10) } else if imageCache.cacheFailed(for: section) { Text("Unable to display menu bar items") - .padding(.horizontal, 5) + .padding(.horizontal, 10) } else { ScrollView(.horizontal) { HStack(spacing: 0) {