Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Let Ice work without the screen recording permission #412

Merged
merged 5 commits into from
Oct 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Ice/Main/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
4 changes: 3 additions & 1 deletion Ice/Main/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,9 @@ final class AppState: ObservableObject {
return
}
Task.detached {
await self.imageCache.updateCacheWithoutChecks(sections: MenuBarSection.Name.allCases)
if ScreenCapture.cachedCheckPermissions(reset: true) {
await self.imageCache.updateCacheWithoutChecks(sections: MenuBarSection.Name.allCases)
}
}
}
.store(in: &c)
Expand Down
7 changes: 6 additions & 1 deletion Ice/MenuBar/ItemManagement/MenuBarItemImageCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@ final class MenuBarItemImageCache: ObservableObject {
return
}
Task.detached {
await self.updateCache()
if ScreenCapture.cachedCheckPermissions() {
await self.updateCache()
}
}
}
.store(in: &c)
Expand All @@ -81,6 +83,9 @@ final class MenuBarItemImageCache: ObservableObject {
/// the given section.
@MainActor
func cacheFailed(for section: MenuBarSection.Name) -> Bool {
guard ScreenCapture.cachedCheckPermissions() else {
return true
}
let items = appState?.itemManager.itemCache[section] ?? []
guard !items.isEmpty else {
return false
Expand Down
4 changes: 3 additions & 1 deletion Ice/MenuBar/Search/MenuBarSearchPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,9 @@ final class MenuBarSearchPanel: NSPanel {
// Important that we set the navigation state before updating the cache.
appState.navigationState.isSearchPresented = true

await appState.imageCache.updateCache()
if ScreenCapture.cachedCheckPermissions() {
await appState.imageCache.updateCache()
}

let hostingView = MenuBarSearchHostingView(appState: appState, panel: self)
hostingView.setFrameSize(hostingView.intrinsicContentSize)
Expand Down
10 changes: 9 additions & 1 deletion Ice/Permissions/Permission.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@ 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

/// The title of the permission.
let title: String
/// Descriptive details for the permission.
let details: [String]
/// 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?
Expand All @@ -39,18 +41,21 @@ class Permission: ObservableObject {
/// - Parameters:
/// - title: The title of the permission.
/// - details: Descriptive details for the permission.
/// - 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],
isRequired: Bool,
settingsURL: URL?,
check: @escaping () -> Bool,
request: @escaping () -> Void
) {
self.title = title
self.details = details
self.isRequired = isRequired
self.settingsURL = settingsURL
self.check = check
self.request = request
Expand Down Expand Up @@ -81,6 +86,7 @@ class Permission: ObservableObject {

/// Asynchronously waits for the app to be granted this permission.
func waitForPermission() async {
configureCancellables()
guard !hasPermission else {
return
}
Expand Down Expand Up @@ -117,6 +123,7 @@ final class AccessibilityPermission: Permission {
"Get real-time information about the menu bar.",
"Arrange menu bar items.",
],
isRequired: true,
settingsURL: nil,
check: {
checkIsProcessTrusted()
Expand All @@ -138,6 +145,7 @@ final class ScreenRecordingPermission: Permission {
"Edit the menu bar's appearance.",
"Display images of individual menu bar items.",
],
isRequired: false,
settingsURL: URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"),
check: {
ScreenCapture.checkPermissions()
Expand Down
55 changes: 44 additions & 11 deletions Ice/Permissions/PermissionsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnyCancellable>()

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<AnyCancellable>()

accessibilityPermission.$hasPermission
.combineLatest(screenRecordingPermission.$hasPermission)
.sink { [weak self] hasPermission1, hasPermission2 in
self?.hasAllPermissions = hasPermission1 && hasPermission2
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()
}
}
}
42 changes: 38 additions & 4 deletions Ice/Permissions/PermissionsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
26 changes: 26 additions & 0 deletions Ice/Settings/SettingsPanes/AdvancedSettingsPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ struct AdvancedSettingsPane: View {
showOnHoverDelaySlider
tempShowIntervalSlider
}
IceSection("Permissions") {
allPermissions
}
}
}

Expand Down Expand Up @@ -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 {
Expand Down
19 changes: 18 additions & 1 deletion Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -54,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
Expand Down
Loading
Loading