Skip to content

Commit f96f097

Browse files
authored
Merge pull request #412 from michyprima/optional-screen-recording
Implements a limited mode if the user chooses not to enable screen recording permissions
2 parents 151df4b + f0b7fd4 commit f96f097

11 files changed

+200
-36
lines changed

Diff for: Ice/Main/AppDelegate.swift

+3-2
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
4949
}
5050
// If we have the required permissions, set up the shared app state.
5151
// Otherwise, open the permissions window.
52-
if appState.permissionsManager.hasAllPermissions {
52+
switch appState.permissionsManager.permissionsState {
53+
case .hasAllPermissions, .hasRequiredPermissions:
5354
appState.performSetup()
54-
} else {
55+
case .missingPermissions:
5556
appState.activate(withPolicy: .regular)
5657
appState.openPermissionsWindow()
5758
}

Diff for: Ice/Main/AppState.swift

+3-1
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,9 @@ final class AppState: ObservableObject {
146146
return
147147
}
148148
Task.detached {
149-
await self.imageCache.updateCacheWithoutChecks(sections: MenuBarSection.Name.allCases)
149+
if ScreenCapture.cachedCheckPermissions(reset: true) {
150+
await self.imageCache.updateCacheWithoutChecks(sections: MenuBarSection.Name.allCases)
151+
}
150152
}
151153
}
152154
.store(in: &c)

Diff for: Ice/MenuBar/ItemManagement/MenuBarItemImageCache.swift

+6-1
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,9 @@ final class MenuBarItemImageCache: ObservableObject {
6363
return
6464
}
6565
Task.detached {
66-
await self.updateCache()
66+
if ScreenCapture.cachedCheckPermissions() {
67+
await self.updateCache()
68+
}
6769
}
6870
}
6971
.store(in: &c)
@@ -81,6 +83,9 @@ final class MenuBarItemImageCache: ObservableObject {
8183
/// the given section.
8284
@MainActor
8385
func cacheFailed(for section: MenuBarSection.Name) -> Bool {
86+
guard ScreenCapture.cachedCheckPermissions() else {
87+
return true
88+
}
8489
let items = appState?.itemManager.itemCache[section] ?? []
8590
guard !items.isEmpty else {
8691
return false

Diff for: Ice/MenuBar/Search/MenuBarSearchPanel.swift

+3-1
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,9 @@ final class MenuBarSearchPanel: NSPanel {
101101
// Important that we set the navigation state before updating the cache.
102102
appState.navigationState.isSearchPresented = true
103103

104-
await appState.imageCache.updateCache()
104+
if ScreenCapture.cachedCheckPermissions() {
105+
await appState.imageCache.updateCache()
106+
}
105107

106108
let hostingView = MenuBarSearchHostingView(appState: appState, panel: self)
107109
hostingView.setFrameSize(hostingView.intrinsicContentSize)

Diff for: Ice/Permissions/Permission.swift

+9-1
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,16 @@ import ScreenCaptureKit
1313
/// An object that encapsulates the behavior of checking for and requesting
1414
/// a specific permission for the app.
1515
@MainActor
16-
class Permission: ObservableObject {
16+
class Permission: ObservableObject, Identifiable {
1717
/// A Boolean value that indicates whether the app has this permission.
1818
@Published private(set) var hasPermission = false
1919

2020
/// The title of the permission.
2121
let title: String
2222
/// Descriptive details for the permission.
2323
let details: [String]
24+
/// A Boolean value that indicates if the app can work without this permission.
25+
let isRequired: Bool
2426

2527
/// The URL of the settings pane to open.
2628
private let settingsURL: URL?
@@ -39,18 +41,21 @@ class Permission: ObservableObject {
3941
/// - Parameters:
4042
/// - title: The title of the permission.
4143
/// - details: Descriptive details for the permission.
44+
/// - isRequired: A Boolean value that indicates if the app can work without this permission.
4245
/// - settingsURL: The URL of the settings pane to open.
4346
/// - check: A function that checks permissions.
4447
/// - request: A function that requests permissions.
4548
init(
4649
title: String,
4750
details: [String],
51+
isRequired: Bool,
4852
settingsURL: URL?,
4953
check: @escaping () -> Bool,
5054
request: @escaping () -> Void
5155
) {
5256
self.title = title
5357
self.details = details
58+
self.isRequired = isRequired
5459
self.settingsURL = settingsURL
5560
self.check = check
5661
self.request = request
@@ -81,6 +86,7 @@ class Permission: ObservableObject {
8186

8287
/// Asynchronously waits for the app to be granted this permission.
8388
func waitForPermission() async {
89+
configureCancellables()
8490
guard !hasPermission else {
8591
return
8692
}
@@ -117,6 +123,7 @@ final class AccessibilityPermission: Permission {
117123
"Get real-time information about the menu bar.",
118124
"Arrange menu bar items.",
119125
],
126+
isRequired: true,
120127
settingsURL: nil,
121128
check: {
122129
checkIsProcessTrusted()
@@ -138,6 +145,7 @@ final class ScreenRecordingPermission: Permission {
138145
"Edit the menu bar's appearance.",
139146
"Display images of individual menu bar items.",
140147
],
148+
isRequired: false,
141149
settingsURL: URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"),
142150
check: {
143151
ScreenCapture.checkPermissions()

Diff for: Ice/Permissions/PermissionsManager.swift

+44-11
Original file line numberDiff line numberDiff line change
@@ -4,42 +4,75 @@
44
//
55

66
import Combine
7+
import Foundation
78

89
/// A type that manages the permissions of the app.
910
@MainActor
1011
final class PermissionsManager: ObservableObject {
11-
/// A Boolean value that indicates whether the app has been granted all permissions.
12-
@Published var hasAllPermissions: Bool = false
12+
/// The state of the granted permissions for the app.
13+
enum PermissionsState {
14+
case missingPermissions
15+
case hasAllPermissions
16+
case hasRequiredPermissions
17+
}
18+
19+
/// The state of the granted permissions for the app.
20+
@Published var permissionsState = PermissionsState.missingPermissions
21+
22+
let accessibilityPermission: AccessibilityPermission
1323

14-
let accessibilityPermission = AccessibilityPermission()
24+
let screenRecordingPermission: ScreenRecordingPermission
1525

16-
let screenRecordingPermission = ScreenRecordingPermission()
26+
let allPermissions: [Permission]
1727

1828
private(set) weak var appState: AppState?
1929

2030
private var cancellables = Set<AnyCancellable>()
2131

32+
var requiredPermissions: [Permission] {
33+
allPermissions.filter { $0.isRequired }
34+
}
35+
2236
init(appState: AppState) {
2337
self.appState = appState
38+
self.accessibilityPermission = AccessibilityPermission()
39+
self.screenRecordingPermission = ScreenRecordingPermission()
40+
self.allPermissions = [
41+
accessibilityPermission,
42+
screenRecordingPermission,
43+
]
2444
configureCancellables()
2545
}
2646

2747
private func configureCancellables() {
2848
var c = Set<AnyCancellable>()
2949

30-
accessibilityPermission.$hasPermission
31-
.combineLatest(screenRecordingPermission.$hasPermission)
32-
.sink { [weak self] hasPermission1, hasPermission2 in
33-
self?.hasAllPermissions = hasPermission1 && hasPermission2
50+
Publishers.Merge(
51+
accessibilityPermission.$hasPermission.mapToVoid(),
52+
screenRecordingPermission.$hasPermission.mapToVoid()
53+
)
54+
.receive(on: DispatchQueue.main)
55+
.sink { [weak self] in
56+
guard let self else {
57+
return
58+
}
59+
if allPermissions.allSatisfy({ $0.hasPermission }) {
60+
permissionsState = .hasAllPermissions
61+
} else if requiredPermissions.allSatisfy({ $0.hasPermission }) {
62+
permissionsState = .hasRequiredPermissions
63+
} else {
64+
permissionsState = .missingPermissions
3465
}
35-
.store(in: &c)
66+
}
67+
.store(in: &c)
3668

3769
cancellables = c
3870
}
3971

4072
/// Stops running all permissions checks.
4173
func stopAllChecks() {
42-
accessibilityPermission.stopCheck()
43-
screenRecordingPermission.stopCheck()
74+
for permission in allPermissions {
75+
permission.stopCheck()
76+
}
4477
}
4578
}

Diff for: Ice/Permissions/PermissionsView.swift

+38-4
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,22 @@ struct PermissionsView: View {
99
@EnvironmentObject var permissionsManager: PermissionsManager
1010
@Environment(\.openWindow) private var openWindow
1111

12+
private var continueButtonText: LocalizedStringKey {
13+
if case .hasRequiredPermissions = permissionsManager.permissionsState {
14+
"Continue in Limited Mode"
15+
} else {
16+
"Continue"
17+
}
18+
}
19+
20+
private var continueButtonForegroundStyle: some ShapeStyle {
21+
if case .hasRequiredPermissions = permissionsManager.permissionsState {
22+
AnyShapeStyle(.yellow)
23+
} else {
24+
AnyShapeStyle(.primary)
25+
}
26+
}
27+
1228
var body: some View {
1329
VStack(spacing: 0) {
1430
headerView
@@ -72,8 +88,9 @@ struct PermissionsView: View {
7288
@ViewBuilder
7389
private var permissionsGroupStack: some View {
7490
VStack(spacing: 7.5) {
75-
permissionBox(permissionsManager.accessibilityPermission)
76-
permissionBox(permissionsManager.screenRecordingPermission)
91+
ForEach(permissionsManager.allPermissions) { permission in
92+
permissionBox(permission)
93+
}
7794
}
7895
}
7996

@@ -106,10 +123,11 @@ struct PermissionsView: View {
106123
appState.permissionsWindow?.close()
107124
appState.appDelegate?.openSettingsWindow()
108125
} label: {
109-
Text("Continue")
126+
Text(continueButtonText)
110127
.frame(maxWidth: .infinity)
128+
.foregroundStyle(continueButtonForegroundStyle)
111129
}
112-
.disabled(!permissionsManager.hasAllPermissions)
130+
.disabled(permissionsManager.permissionsState == .missingPermissions)
113131
}
114132

115133
@ViewBuilder
@@ -154,6 +172,22 @@ struct PermissionsView: View {
154172
}
155173
}
156174
.allowsHitTesting(!permission.hasPermission)
175+
176+
if !permission.isRequired {
177+
IceGroupBox {
178+
AnnotationView(
179+
alignment: .center,
180+
font: .callout.bold()
181+
) {
182+
Label {
183+
Text("Ice can work in a limited mode without this permission.")
184+
} icon: {
185+
Image(systemName: "checkmark.shield")
186+
.foregroundStyle(.green)
187+
}
188+
}
189+
}
190+
}
157191
}
158192
.padding(10)
159193
.frame(maxWidth: .infinity)

Diff for: Ice/Settings/SettingsPanes/AdvancedSettingsPane.swift

+26
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ struct AdvancedSettingsPane: View {
4141
showOnHoverDelaySlider
4242
tempShowIntervalSlider
4343
}
44+
IceSection("Permissions") {
45+
allPermissions
46+
}
4447
}
4548
}
4649

@@ -134,6 +137,29 @@ struct AdvancedSettingsPane: View {
134137
private var showAllSectionsOnUserDrag: some View {
135138
Toggle("Show all sections when Command + dragging menu bar items", isOn: manager.bindings.showAllSectionsOnUserDrag)
136139
}
140+
141+
@ViewBuilder
142+
private var allPermissions: some View {
143+
ForEach(appState.permissionsManager.allPermissions) { permission in
144+
IceLabeledContent {
145+
if permission.hasPermission {
146+
Label {
147+
Text("Permission Granted")
148+
} icon: {
149+
Image(systemName: "checkmark.circle")
150+
.foregroundStyle(.green)
151+
}
152+
} else {
153+
Button("Grant Permission") {
154+
permission.performRequest()
155+
}
156+
}
157+
} label: {
158+
Text(permission.title)
159+
}
160+
.frame(height: 22)
161+
}
162+
}
137163
}
138164

139165
#Preview {

Diff for: Ice/Settings/SettingsPanes/MenuBarLayoutSettingsPane.swift

+18-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ struct MenuBarLayoutSettingsPane: View {
99
@EnvironmentObject var appState: AppState
1010

1111
var body: some View {
12-
if appState.menuBarManager.isMenuBarHiddenBySystemUserDefaults {
12+
if !ScreenCapture.cachedCheckPermissions() {
13+
missingScreenRecordingPermission
14+
} else if appState.menuBarManager.isMenuBarHiddenBySystemUserDefaults {
1315
cannotArrange
1416
} else {
1517
IceForm(alignment: .leading, spacing: 20) {
@@ -54,6 +56,21 @@ struct MenuBarLayoutSettingsPane: View {
5456
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
5557
}
5658

59+
@ViewBuilder
60+
private var missingScreenRecordingPermission: some View {
61+
VStack {
62+
Text("Menu bar layout requires screen recording permissions")
63+
.font(.title2)
64+
65+
Button {
66+
appState.navigationState.settingsNavigationIdentifier = .advanced
67+
} label: {
68+
Text("Go to Advanced Settings")
69+
}
70+
.buttonStyle(.link)
71+
}
72+
}
73+
5774
@ViewBuilder
5875
private func layoutBar(for section: MenuBarSection.Name) -> some View {
5976
if

0 commit comments

Comments
 (0)