Skip to content

Commit 7b12a02

Browse files
committed
SystemPermissionManager
1 parent 25cdbfe commit 7b12a02

3 files changed

Lines changed: 182 additions & 3 deletions

File tree

macOS/DuckDuckGo-macOS.xcodeproj/project.pbxproj

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@
9696
1D4B03DA2CA55DDF00224E99 /* BookmarkUrlExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D4B03D82CA55DDF00224E99 /* BookmarkUrlExtensionTests.swift */; };
9797
1D532BB42ED8D3D300D219FA /* PermissionAuthorizationSwiftUIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D532BB32ED8D3D300D219FA /* PermissionAuthorizationSwiftUIView.swift */; };
9898
1D532BB52ED8D3D300D219FA /* PermissionAuthorizationSwiftUIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D532BB32ED8D3D300D219FA /* PermissionAuthorizationSwiftUIView.swift */; };
99+
1D532BC92EDA1DF800D219FA /* SystemPermissionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D532BC82EDA1DF200D219FA /* SystemPermissionManager.swift */; };
100+
1D532BCA2EDA1DF800D219FA /* SystemPermissionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D532BC82EDA1DF200D219FA /* SystemPermissionManager.swift */; };
99101
1D5C1AF12CFF58220073ED65 /* Logger+WebExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D5C1AF02CFF58170073ED65 /* Logger+WebExtensions.swift */; };
100102
1D6216B229069BBF00386B2C /* BWKeyStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D6216B129069BBF00386B2C /* BWKeyStorage.swift */; };
101103
1D638D612C44F2BA00530DD5 /* ApplicationUpdateDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D638D602C44F2BA00530DD5 /* ApplicationUpdateDetectorTests.swift */; };
@@ -2934,8 +2936,6 @@
29342936
B643BF1427ABF772000BACEC /* NSWorkspaceExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B643BF1327ABF772000BACEC /* NSWorkspaceExtension.swift */; };
29352937
B644B43D29D56829003FA9AB /* SearchNonexistentDomainTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B644B43929D565DB003FA9AB /* SearchNonexistentDomainTests.swift */; };
29362938
B644B43E29D5682B003FA9AB /* SearchNonexistentDomainTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B644B43929D565DB003FA9AB /* SearchNonexistentDomainTests.swift */; };
2937-
B6F6B0022F1E1C2C00A1B2C3 /* WindowOpenSecurityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6F6B0012F1E1C2C00A1B2C3 /* WindowOpenSecurityTests.swift */; };
2938-
B6F6B0032F1E1C2C00A1B2C3 /* WindowOpenSecurityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6F6B0012F1E1C2C00A1B2C3 /* WindowOpenSecurityTests.swift */; };
29392939
B645D8F629FA95440024461F /* WKProcessPoolExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B645D8F529FA95440024461F /* WKProcessPoolExtension.swift */; };
29402940
B645D8F729FA95440024461F /* WKProcessPoolExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B645D8F529FA95440024461F /* WKProcessPoolExtension.swift */; };
29412941
B647EFBB2922584B00BA628D /* AdClickAttributionTabExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B647EFBA2922584B00BA628D /* AdClickAttributionTabExtension.swift */; };
@@ -3203,6 +3203,8 @@
32033203
B6F1B02F2BCE6B47005E863C /* TunnelControllerProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6F1B02D2BCE6B47005E863C /* TunnelControllerProvider.swift */; };
32043204
B6F1C80B2761C45400334924 /* LocalUnprotectedDomains.swift in Sources */ = {isa = PBXBuildFile; fileRef = 336B39E22726B4B700C417D3 /* LocalUnprotectedDomains.swift */; };
32053205
B6F41031264D2B23003DA42C /* ProgressExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6F41030264D2B23003DA42C /* ProgressExtension.swift */; };
3206+
B6F6B0022F1E1C2C00A1B2C3 /* WindowOpenSecurityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6F6B0012F1E1C2C00A1B2C3 /* WindowOpenSecurityTests.swift */; };
3207+
B6F6B0032F1E1C2C00A1B2C3 /* WindowOpenSecurityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6F6B0012F1E1C2C00A1B2C3 /* WindowOpenSecurityTests.swift */; };
32063208
B6F7127E29F6779000594A45 /* QRSharingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6F7127D29F6779000594A45 /* QRSharingService.swift */; };
32073209
B6F7127F29F6779000594A45 /* QRSharingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6F7127D29F6779000594A45 /* QRSharingService.swift */; };
32083210
B6F7128129F681EB00594A45 /* QuickLookUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B6F7128029F681EB00594A45 /* QuickLookUI.framework */; };
@@ -4259,6 +4261,7 @@
42594261
1D4B03D52CA4431C00224E99 /* BookmarkUrlExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkUrlExtension.swift; sourceTree = "<group>"; };
42604262
1D4B03D82CA55DDF00224E99 /* BookmarkUrlExtensionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BookmarkUrlExtensionTests.swift; sourceTree = "<group>"; };
42614263
1D532BB32ED8D3D300D219FA /* PermissionAuthorizationSwiftUIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionAuthorizationSwiftUIView.swift; sourceTree = "<group>"; };
4264+
1D532BC82EDA1DF200D219FA /* SystemPermissionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemPermissionManager.swift; sourceTree = "<group>"; };
42624265
1D5C1AF02CFF58170073ED65 /* Logger+WebExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Logger+WebExtensions.swift"; sourceTree = "<group>"; };
42634266
1D6216B129069BBF00386B2C /* BWKeyStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BWKeyStorage.swift; sourceTree = "<group>"; };
42644267
1D638D602C44F2BA00530DD5 /* ApplicationUpdateDetectorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ApplicationUpdateDetectorTests.swift; sourceTree = "<group>"; };
@@ -5609,7 +5612,6 @@
56095612
B642738127B65BAC0005DFD1 /* SecureVaultReporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureVaultReporter.swift; sourceTree = "<group>"; };
56105613
B643BF1327ABF772000BACEC /* NSWorkspaceExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSWorkspaceExtension.swift; sourceTree = "<group>"; };
56115614
B644B43929D565DB003FA9AB /* SearchNonexistentDomainTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchNonexistentDomainTests.swift; sourceTree = "<group>"; };
5612-
B6F6B0012F1E1C2C00A1B2C3 /* WindowOpenSecurityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowOpenSecurityTests.swift; sourceTree = "<group>"; };
56135615
B645D8F529FA95440024461F /* WKProcessPoolExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WKProcessPoolExtension.swift; sourceTree = "<group>"; };
56145616
B647EFBA2922584B00BA628D /* AdClickAttributionTabExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdClickAttributionTabExtension.swift; sourceTree = "<group>"; };
56155617
B64C84DD2692D7400048FEBE /* PermissionAuthorization.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = PermissionAuthorization.storyboard; sourceTree = "<group>"; };
@@ -5805,6 +5807,7 @@
58055807
B6F1B0292BCE675C005E863C /* NetworkProtectionControllerTabExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionControllerTabExtension.swift; sourceTree = "<group>"; };
58065808
B6F1B02D2BCE6B47005E863C /* TunnelControllerProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelControllerProvider.swift; sourceTree = "<group>"; };
58075809
B6F41030264D2B23003DA42C /* ProgressExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressExtension.swift; sourceTree = "<group>"; };
5810+
B6F6B0012F1E1C2C00A1B2C3 /* WindowOpenSecurityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowOpenSecurityTests.swift; sourceTree = "<group>"; };
58085811
B6F7127D29F6779000594A45 /* QRSharingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRSharingService.swift; sourceTree = "<group>"; };
58095812
B6F7128029F681EB00594A45 /* QuickLookUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuickLookUI.framework; path = System/Library/Frameworks/QuickLookUI.framework; sourceTree = SDKROOT; };
58105813
B6F9BDDB2B45B7EE00677B33 /* WebsiteInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsiteInfo.swift; sourceTree = "<group>"; };
@@ -10900,6 +10903,7 @@
1090010903
845F57282E97FCC900C7F78A /* PermissionManagerMock.swift */,
1090110904
B64C853726944B880048FEBE /* StoredPermission.swift */,
1090210905
B64C853C26944B940048FEBE /* PermissionStore.swift */,
10906+
1D532BC82EDA1DF200D219FA /* SystemPermissionManager.swift */,
1090310907
B64C852E26943BC10048FEBE /* Permissions.xcdatamodeld */,
1090410908
);
1090510909
path = Model;
@@ -14014,6 +14018,7 @@
1401414018
3706FB95293F65D500E42796 /* PermissionType.swift in Sources */,
1401514019
3706FB96293F65D500E42796 /* RecentlyClosedWindow.swift in Sources */,
1401614020
4B9DB0242A983B24000927DB /* WaitlistRequest.swift in Sources */,
14021+
1D532BCA2EDA1DF800D219FA /* SystemPermissionManager.swift in Sources */,
1401714022
B690152D2ACBF4DA00AD0BAB /* MenuPreview.swift in Sources */,
1401814023
1D36F4252A3B85C50052B527 /* TabCleanupPreparer.swift in Sources */,
1401914024
BD7090D72C540D5D009EED82 /* EmptyMetadataCollector.swift in Sources */,
@@ -15968,6 +15973,7 @@
1596815973
B6DB3CFB26A17CB800D459B7 /* PermissionModel.swift in Sources */,
1596915974
4B92929C26670D2A00AD2C21 /* PasteboardFolder.swift in Sources */,
1597015975
B5AE99542E84A5D900F45912 /* ThemeName.swift in Sources */,
15976+
1D532BC92EDA1DF800D219FA /* SystemPermissionManager.swift in Sources */,
1597115977
EEA3EEB32B24EC0600E8333A /* VPNLocationViewModel.swift in Sources */,
1597215978
B6106BAB26A7BF1D0013B453 /* PermissionType.swift in Sources */,
1597315979
9F08B3112DE5A55400C68C0E /* DefaultBrowserAndDockPromptDebugMenu.swift in Sources */,
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
//
2+
// SystemPermissionManager.swift
3+
//
4+
// Copyright © 2025 DuckDuckGo. All rights reserved.
5+
//
6+
// Licensed under the Apache License, Version 2.0 (the "License");
7+
// you may not use this file except in compliance with the License.
8+
// You may obtain a copy of the License at
9+
//
10+
// http://www.apache.org/licenses/LICENSE-2.0
11+
//
12+
// Unless required by applicable law or agreed to in writing, software
13+
// distributed under the License is distributed on an "AS IS" BASIS,
14+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
// See the License for the specific language governing permissions and
16+
// limitations under the License.
17+
//
18+
19+
import Combine
20+
import CoreLocation
21+
22+
/// Represents the authorization state for a system permission
23+
enum SystemPermissionAuthorizationState {
24+
/// Permission has not been requested yet
25+
case notDetermined
26+
/// Permission has been granted
27+
case authorized
28+
/// Permission has been denied by the user
29+
case denied
30+
/// Permission is restricted (parental controls, MDM, etc.)
31+
case restricted
32+
/// Services are disabled system-wide (e.g., Location Services off in System Settings)
33+
case systemDisabled
34+
}
35+
36+
/// Protocol for managing system-level permissions required before website permissions can be granted
37+
protocol SystemPermissionManagerProtocol: AnyObject {
38+
39+
// MARK: - Geolocation
40+
41+
/// Returns the current geolocation authorization state
42+
var geolocationAuthorizationState: SystemPermissionAuthorizationState { get }
43+
44+
/// Publisher that emits the current geolocation authorization state whenever it changes
45+
var geolocationAuthorizationStatePublisher: AnyPublisher<SystemPermissionAuthorizationState, Never> { get }
46+
47+
/// Returns true if geolocation authorization has been granted
48+
var isGeolocationAuthorized: Bool { get }
49+
50+
/// Returns true if geolocation authorization needs to be requested
51+
var isGeolocationAuthorizationRequired: Bool { get }
52+
53+
/// Requests geolocation authorization from the system
54+
/// - Parameter completion: Called with the resulting authorization state after the user responds
55+
/// - Returns: A cancellable that can be used to cancel the observation
56+
@discardableResult
57+
func requestGeolocationAuthorization(completion: @escaping (SystemPermissionAuthorizationState) -> Void) -> AnyCancellable
58+
59+
/// Requests geolocation authorization from the system using async/await
60+
/// - Returns: The authorization state after the user responds
61+
func requestGeolocationAuthorization() async -> SystemPermissionAuthorizationState
62+
}
63+
64+
/// Manages system-level permissions required before website permissions can be granted
65+
final class SystemPermissionManager: SystemPermissionManagerProtocol {
66+
67+
private let geolocationService: GeolocationServiceProtocol
68+
69+
init(geolocationService: GeolocationServiceProtocol = GeolocationService.shared) {
70+
self.geolocationService = geolocationService
71+
}
72+
73+
// MARK: - Geolocation Authorization
74+
75+
/// Returns the current geolocation authorization state
76+
var geolocationAuthorizationState: SystemPermissionAuthorizationState {
77+
guard geolocationService.locationServicesEnabled() else {
78+
return .systemDisabled
79+
}
80+
81+
switch geolocationService.authorizationStatus {
82+
case .notDetermined:
83+
return .notDetermined
84+
case .authorized, .authorizedAlways:
85+
return .authorized
86+
case .denied:
87+
return .denied
88+
case .restricted:
89+
return .restricted
90+
@unknown default:
91+
return .notDetermined
92+
}
93+
}
94+
95+
/// Publisher that emits the current geolocation authorization state whenever it changes
96+
var geolocationAuthorizationStatePublisher: AnyPublisher<SystemPermissionAuthorizationState, Never> {
97+
geolocationService.authorizationStatusPublisher
98+
.map { [weak self] _ in
99+
self?.geolocationAuthorizationState ?? .notDetermined
100+
}
101+
.eraseToAnyPublisher()
102+
}
103+
104+
/// Returns true if geolocation authorization has been granted
105+
var isGeolocationAuthorized: Bool {
106+
geolocationAuthorizationState == .authorized
107+
}
108+
109+
/// Returns true if geolocation authorization needs to be requested
110+
var isGeolocationAuthorizationRequired: Bool {
111+
switch geolocationAuthorizationState {
112+
case .notDetermined, .systemDisabled:
113+
return true
114+
case .authorized, .denied, .restricted:
115+
return false
116+
}
117+
}
118+
119+
/// Requests geolocation authorization from the system
120+
/// - Parameter completion: Called with the resulting authorization state after the user responds
121+
/// - Returns: A cancellable that can be used to cancel the observation
122+
@discardableResult
123+
func requestGeolocationAuthorization(completion: @escaping (SystemPermissionAuthorizationState) -> Void) -> AnyCancellable {
124+
// If already determined, return current state immediately
125+
guard geolocationAuthorizationState == .notDetermined else {
126+
completion(geolocationAuthorizationState)
127+
return AnyCancellable {}
128+
}
129+
130+
var locationCancellable: AnyCancellable?
131+
132+
// Subscribe to authorization status publisher to observe changes
133+
let authorizationCancellable = geolocationService.authorizationStatusPublisher
134+
.dropFirst() // Skip initial value, we want to observe changes
135+
.first() // Only need the first change
136+
.sink { [weak self] _ in
137+
let state = self?.geolocationAuthorizationState ?? .notDetermined
138+
// Cancel location subscription once we have the authorization result
139+
locationCancellable?.cancel()
140+
completion(state)
141+
}
142+
143+
// Subscribe to location publisher to trigger authorization request
144+
// The GeolocationService calls requestWhenInUseAuthorization() when first subscribed
145+
// We keep this subscription alive until authorization is determined
146+
locationCancellable = geolocationService.locationPublisher
147+
.sink { _ in }
148+
149+
return AnyCancellable {
150+
authorizationCancellable.cancel()
151+
locationCancellable?.cancel()
152+
}
153+
}
154+
155+
/// Requests geolocation authorization from the system using async/await
156+
/// - Returns: The authorization state after the user responds
157+
func requestGeolocationAuthorization() async -> SystemPermissionAuthorizationState {
158+
// If already determined, return current state immediately
159+
guard geolocationAuthorizationState == .notDetermined else {
160+
return geolocationAuthorizationState
161+
}
162+
163+
return await withCheckedContinuation { continuation in
164+
var cancellable: AnyCancellable?
165+
cancellable = requestGeolocationAuthorization { state in
166+
continuation.resume(returning: state)
167+
cancellable?.cancel()
168+
}
169+
}
170+
}
171+
}

macOS/DuckDuckGo/Permissions/View/PermissionAuthorizationViewController.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ extension Array where Element == PermissionType {
5656

5757
final class PermissionAuthorizationViewController: NSViewController {
5858

59+
let systemPermissionManager = SystemPermissionManager()
60+
5961
@IBOutlet var descriptionLabel: NSTextField!
6062
@IBOutlet var domainNameLabel: NSTextField!
6163
@IBOutlet var alwaysAllowCheckbox: NSButton!

0 commit comments

Comments
 (0)