Skip to content

Commit

Permalink
Merge pull request #412 from michyprima/optional-screen-recording
Browse files Browse the repository at this point in the history
Implements a limited mode if the user chooses not to enable screen recording permissions
  • Loading branch information
jordanbaird authored Oct 28, 2024
2 parents 151df4b + f0b7fd4 commit f96f097
Show file tree
Hide file tree
Showing 11 changed files with 200 additions and 36 deletions.
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

0 comments on commit f96f097

Please sign in to comment.