diff --git a/SharedPackages/BrowserServicesKit/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift b/SharedPackages/BrowserServicesKit/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift index 2a9b30ef5bf..a422d326cbb 100644 --- a/SharedPackages/BrowserServicesKit/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift +++ b/SharedPackages/BrowserServicesKit/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift @@ -143,6 +143,7 @@ public enum MacOSBrowserConfigSubfeature: String, PrivacySubfeature { /// Tab closing event recreation feature flag (failsafe for removing private API) /// https://app.asana.com/1/137249556945/project/1211834678943996/task/1212206087745586?focus=true case tabClosingEventRecreation + } public enum iOSBrowserConfigSubfeature: String, PrivacySubfeature { diff --git a/macOS/DuckDuckGo-macOS.xcodeproj/project.pbxproj b/macOS/DuckDuckGo-macOS.xcodeproj/project.pbxproj index aa67d5d4397..ae5fd9454b5 100644 --- a/macOS/DuckDuckGo-macOS.xcodeproj/project.pbxproj +++ b/macOS/DuckDuckGo-macOS.xcodeproj/project.pbxproj @@ -39,6 +39,8 @@ 1D0DE9422C3BB9CC0037ABC2 /* ReleaseNotesParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D0DE9402C3BB9CC0037ABC2 /* ReleaseNotesParser.swift */; }; 1D1A33492A6FEB170080ACED /* BurnerMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D1A33482A6FEB170080ACED /* BurnerMode.swift */; }; 1D1A334A2A6FEB170080ACED /* BurnerMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D1A33482A6FEB170080ACED /* BurnerMode.swift */; }; + 1D1B4A462EDF97F400FE43C9 /* PermissionAuthorizationTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D1B4A452EDF97EF00FE43C9 /* PermissionAuthorizationTypeTests.swift */; }; + 1D1B4A472EDF97F400FE43C9 /* PermissionAuthorizationTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D1B4A452EDF97EF00FE43C9 /* PermissionAuthorizationTypeTests.swift */; }; 1D1C36E329FAE8DA001FA40C /* FaviconManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D1C36E229FAE8DA001FA40C /* FaviconManagerTests.swift */; }; 1D1C36E429FAE8DA001FA40C /* FaviconManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D1C36E229FAE8DA001FA40C /* FaviconManagerTests.swift */; }; 1D1C36E629FB019C001FA40C /* HistoryTabExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D1C36E529FB019C001FA40C /* HistoryTabExtensionTests.swift */; }; @@ -94,6 +96,16 @@ 1D4B03D72CA4432000224E99 /* BookmarkUrlExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D4B03D52CA4431C00224E99 /* BookmarkUrlExtension.swift */; }; 1D4B03D92CA55DDF00224E99 /* BookmarkUrlExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D4B03D82CA55DDF00224E99 /* BookmarkUrlExtensionTests.swift */; }; 1D4B03DA2CA55DDF00224E99 /* BookmarkUrlExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D4B03D82CA55DDF00224E99 /* BookmarkUrlExtensionTests.swift */; }; + 1D532BB42ED8D3D300D219FA /* PermissionAuthorizationSwiftUIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D532BB32ED8D3D300D219FA /* PermissionAuthorizationSwiftUIView.swift */; }; + 1D532BB52ED8D3D300D219FA /* PermissionAuthorizationSwiftUIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D532BB32ED8D3D300D219FA /* PermissionAuthorizationSwiftUIView.swift */; }; + 1D532BC92EDA1DF800D219FA /* SystemPermissionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D532BC82EDA1DF200D219FA /* SystemPermissionManager.swift */; }; + 1D532BCA2EDA1DF800D219FA /* SystemPermissionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D532BC82EDA1DF200D219FA /* SystemPermissionManager.swift */; }; + 1D532BDE2EDC3E4800D219FA /* PermissionCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D532BDD2EDC3E3F00D219FA /* PermissionCenterView.swift */; }; + 1D532BDF2EDC3E4800D219FA /* PermissionCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D532BDD2EDC3E3F00D219FA /* PermissionCenterView.swift */; }; + 1D532BE12EDC3E5500D219FA /* PermissionCenterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D532BE02EDC3E4D00D219FA /* PermissionCenterViewController.swift */; }; + 1D532BE22EDC3E5500D219FA /* PermissionCenterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D532BE02EDC3E4D00D219FA /* PermissionCenterViewController.swift */; }; + 1D532BE52EDC3E6D00D219FA /* PermissionCenterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D532BE32EDC3E6100D219FA /* PermissionCenterViewModel.swift */; }; + 1D532BE62EDC3E6D00D219FA /* PermissionCenterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D532BE32EDC3E6100D219FA /* PermissionCenterViewModel.swift */; }; 1D5C1AF12CFF58220073ED65 /* Logger+WebExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D5C1AF02CFF58170073ED65 /* Logger+WebExtensions.swift */; }; 1D6216B229069BBF00386B2C /* BWKeyStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D6216B129069BBF00386B2C /* BWKeyStorage.swift */; }; 1D638D612C44F2BA00530DD5 /* ApplicationUpdateDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D638D602C44F2BA00530DD5 /* ApplicationUpdateDetectorTests.swift */; }; @@ -4238,6 +4250,7 @@ 1D074B262909A433006E4AC3 /* PasswordManagerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordManagerCoordinator.swift; sourceTree = ""; }; 1D0DE9402C3BB9CC0037ABC2 /* ReleaseNotesParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReleaseNotesParser.swift; sourceTree = ""; }; 1D1A33482A6FEB170080ACED /* BurnerMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BurnerMode.swift; sourceTree = ""; }; + 1D1B4A452EDF97EF00FE43C9 /* PermissionAuthorizationTypeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionAuthorizationTypeTests.swift; sourceTree = ""; }; 1D1C36E229FAE8DA001FA40C /* FaviconManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaviconManagerTests.swift; sourceTree = ""; }; 1D1C36E529FB019C001FA40C /* HistoryTabExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryTabExtensionTests.swift; sourceTree = ""; }; 1D1D36212D8B7B8C005ED60C /* PinnedTabsManagerProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedTabsManagerProviderTests.swift; sourceTree = ""; }; @@ -4270,6 +4283,11 @@ 1D43EB3B292B664A0065E5D6 /* BWMessageIdGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BWMessageIdGenerator.swift; sourceTree = ""; }; 1D4B03D52CA4431C00224E99 /* BookmarkUrlExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkUrlExtension.swift; sourceTree = ""; }; 1D4B03D82CA55DDF00224E99 /* BookmarkUrlExtensionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BookmarkUrlExtensionTests.swift; sourceTree = ""; }; + 1D532BB32ED8D3D300D219FA /* PermissionAuthorizationSwiftUIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionAuthorizationSwiftUIView.swift; sourceTree = ""; }; + 1D532BC82EDA1DF200D219FA /* SystemPermissionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemPermissionManager.swift; sourceTree = ""; }; + 1D532BDD2EDC3E3F00D219FA /* PermissionCenterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionCenterView.swift; sourceTree = ""; }; + 1D532BE02EDC3E4D00D219FA /* PermissionCenterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionCenterViewController.swift; sourceTree = ""; }; + 1D532BE32EDC3E6100D219FA /* PermissionCenterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionCenterViewModel.swift; sourceTree = ""; }; 1D5C1AF02CFF58170073ED65 /* Logger+WebExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Logger+WebExtensions.swift"; sourceTree = ""; }; 1D6216B129069BBF00386B2C /* BWKeyStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BWKeyStorage.swift; sourceTree = ""; }; 1D638D602C44F2BA00530DD5 /* ApplicationUpdateDetectorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ApplicationUpdateDetectorTests.swift; sourceTree = ""; }; @@ -6736,6 +6754,14 @@ path = Services; sourceTree = ""; }; + 1D532BE42EDC3E6D00D219FA /* ViewModel */ = { + isa = PBXGroup; + children = ( + 1D532BE32EDC3E6100D219FA /* PermissionCenterViewModel.swift */, + ); + path = ViewModel; + sourceTree = ""; + }; 1D6860512D36BD38006FC53E /* View */ = { isa = PBXGroup; children = ( @@ -10824,6 +10850,7 @@ isa = PBXGroup; children = ( B6106B9F26A7BE0B0013B453 /* PermissionManagerTests.swift */, + 1D1B4A452EDF97EF00FE43C9 /* PermissionAuthorizationTypeTests.swift */, B6106BB026A7D8720013B453 /* PermissionStoreTests.swift */, B63ED0D726AE729600A9DAD1 /* PermissionModelTests.swift */, B6106BAE26A7C6180013B453 /* PermissionStoreMock.swift */, @@ -10906,6 +10933,7 @@ isa = PBXGroup; children = ( B64C84EF269310000048FEBE /* Model */, + 1D532BE42EDC3E6D00D219FA /* ViewModel */, B64C84DC2692D6FC0048FEBE /* View */, ); path = Permissions; @@ -10916,10 +10944,13 @@ children = ( B64C84DD2692D7400048FEBE /* PermissionAuthorization.storyboard */, B64C84E22692DC9F0048FEBE /* PermissionAuthorizationViewController.swift */, + 1D532BB32ED8D3D300D219FA /* PermissionAuthorizationSwiftUIView.swift */, B64C84EA2692DD650048FEBE /* PermissionAuthorizationPopover.swift */, B6BBF17327475B15004F850E /* PopupBlockedPopover.swift */, B64C852926942AC90048FEBE /* PermissionContextMenu.swift */, B64C85412694590B0048FEBE /* PermissionButton.swift */, + 1D532BDD2EDC3E3F00D219FA /* PermissionCenterView.swift */, + 1D532BE02EDC3E4D00D219FA /* PermissionCenterViewController.swift */, ); path = View; sourceTree = ""; @@ -10936,6 +10967,7 @@ 845F57282E97FCC900C7F78A /* PermissionManagerMock.swift */, B64C853726944B880048FEBE /* StoredPermission.swift */, B64C853C26944B940048FEBE /* PermissionStore.swift */, + 1D532BC82EDA1DF200D219FA /* SystemPermissionManager.swift */, B64C852E26943BC10048FEBE /* Permissions.xcdatamodeld */, ); path = Model; @@ -13502,6 +13534,7 @@ 37EE56122D5F197300D53C7C /* BadgeAnimationView.swift in Sources */, 37EE56132D5F197300D53C7C /* BadgeNotificationAnimationModel.swift in Sources */, 37EE56142D5F197300D53C7C /* CookieManagedNotificationContainerView.swift in Sources */, + 1D532BB52ED8D3D300D219FA /* PermissionAuthorizationSwiftUIView.swift in Sources */, 37EE56152D5F197300D53C7C /* CookieManagedNotificationView.swift in Sources */, 37EE56162D5F197300D53C7C /* CookieNotificationAnimationModel.swift in Sources */, 37EE56172D5F197300D53C7C /* NavigationBarBadgeAnimationView.swift in Sources */, @@ -13638,6 +13671,7 @@ 3706FABF293F65D500E42796 /* CrashReportReader.swift in Sources */, B6B4D1C62B0B3B5400C26286 /* DataImportReportModel.swift in Sources */, 3768D8452C2CC884004120AE /* RemoteMessagingConfigMatcherProvider.swift in Sources */, + 1D532BE22EDC3E5500D219FA /* PermissionCenterViewController.swift in Sources */, EE7E0FD82E3BA8FA00E51C8B /* SyncDialogController.swift in Sources */, 3772BEDD2D019CE90019B9EF /* DefaultsFavoritesActionHandler.swift in Sources */, 3706FAC0293F65D500E42796 /* DataTaskProviding.swift in Sources */, @@ -14068,6 +14102,7 @@ 3706FB95293F65D500E42796 /* PermissionType.swift in Sources */, 3706FB96293F65D500E42796 /* RecentlyClosedWindow.swift in Sources */, 4B9DB0242A983B24000927DB /* WaitlistRequest.swift in Sources */, + 1D532BCA2EDA1DF800D219FA /* SystemPermissionManager.swift in Sources */, B690152D2ACBF4DA00AD0BAB /* MenuPreview.swift in Sources */, 1D36F4252A3B85C50052B527 /* TabCleanupPreparer.swift in Sources */, BD7090D72C540D5D009EED82 /* EmptyMetadataCollector.swift in Sources */, @@ -14159,6 +14194,7 @@ 4B9DB0212A983B24000927DB /* ProductWaitlistRequest.swift in Sources */, BBE948D02D6FD11200FAC581 /* DefaultBrowserAndDockPromptStoring.swift in Sources */, 56D145EC29E6C99B00E3488A /* DataImportStatusProviding.swift in Sources */, + 1D532BDF2EDC3E4800D219FA /* PermissionCenterView.swift in Sources */, 85774B002A713D3B00DE0561 /* BookmarksBarMenuFactory.swift in Sources */, B602E81E2A1E25B1006D261F /* NEOnDemandRuleExtension.swift in Sources */, 3706FBC7293F65D500E42796 /* EncryptedHistoryStore.swift in Sources */, @@ -14345,6 +14381,7 @@ 3706FC16293F65D500E42796 /* PasswordManagementLoginModel.swift in Sources */, 3706FC17293F65D500E42796 /* TabViewModel.swift in Sources */, 3706FC18293F65D500E42796 /* TabDragAndDropManager.swift in Sources */, + 1D532BE52EDC3E6D00D219FA /* PermissionCenterViewModel.swift in Sources */, 1DC669712B6CF0D700AA0645 /* TabSnapshotStore.swift in Sources */, 3706FC19293F65D500E42796 /* NSNotificationName+Favicons.swift in Sources */, 3706FC1A293F65D500E42796 /* PinningManager.swift in Sources */, @@ -14672,6 +14709,7 @@ 3706FDE5293F661700E42796 /* URLEventHandlerTests.swift in Sources */, BB9851932E3102ED00FED56A /* NetworkProtectionNavBarButtonModelTests.swift in Sources */, 3785F6EE2EA6520400AF66E9 /* SuggestionLoadingDeciderTests.swift in Sources */, + 1D1B4A462EDF97F400FE43C9 /* PermissionAuthorizationTypeTests.swift in Sources */, 3706FDE6293F661700E42796 /* BookmarkOutlineViewDataSourceTests.swift in Sources */, BBC063E92C5A9E4B007BDC18 /* BookmarkManagementDetailViewModelTests.swift in Sources */, 7BBBD8942ED8A451003A7DA5 /* WebNotificationsHandlerTests.swift in Sources */, @@ -16030,6 +16068,7 @@ B6DB3CFB26A17CB800D459B7 /* PermissionModel.swift in Sources */, 4B92929C26670D2A00AD2C21 /* PasteboardFolder.swift in Sources */, B5AE99542E84A5D900F45912 /* ThemeName.swift in Sources */, + 1D532BC92EDA1DF800D219FA /* SystemPermissionManager.swift in Sources */, EEA3EEB32B24EC0600E8333A /* VPNLocationViewModel.swift in Sources */, B6106BAB26A7BF1D0013B453 /* PermissionType.swift in Sources */, 9F08B3112DE5A55400C68C0E /* DefaultBrowserAndDockPromptDebugMenu.swift in Sources */, @@ -16094,6 +16133,7 @@ 1DF78E0C2CE5F58B00AB898E /* WebExtensionManager.swift in Sources */, C10529462C9F456C0041E502 /* AutofillCredentialsDebugViewModel.swift in Sources */, 4B4D60C02A0C848D00BCD287 /* NetworkProtectionControllerErrorStore.swift in Sources */, + 1D532BE12EDC3E5500D219FA /* PermissionCenterViewController.swift in Sources */, 7BE146072A6A83C700C313B8 /* NetworkProtectionDebugMenu.swift in Sources */, B6085D092743AAB600A9C456 /* FireproofDomains.xcdatamodeld in Sources */, 56BA1E752BAAF70F001CF69F /* SpecialErrorPageTabExtension.swift in Sources */, @@ -16127,6 +16167,7 @@ 1D9A37672BD8EA8800EBC58D /* DockPositionProvider.swift in Sources */, 4B9292D52667123700AD2C21 /* BookmarkManagementDetailViewController.swift in Sources */, F188267C2BBEB3AA00D9AC4F /* GeneralPixel.swift in Sources */, + 1D532BE62EDC3E6D00D219FA /* PermissionCenterViewModel.swift in Sources */, 37A4CEBA282E992F00D75B89 /* StartupPreferences.swift in Sources */, C1CE84692C887CF60068913B /* FreemiumDBPScanResultPolling.swift in Sources */, 37C9F78C2CF1C776004D73A1 /* PrivacyStatsTabExtension.swift in Sources */, @@ -16516,6 +16557,7 @@ C126B35A2C820924005DC2A3 /* FreemiumDebugMenu.swift in Sources */, B6A9E46B2614618A0067D1B9 /* OperatingSystemVersionExtension.swift in Sources */, 4BDFA4AE27BF19E500648192 /* ToggleableScrollView.swift in Sources */, + 1D532BB42ED8D3D300D219FA /* PermissionAuthorizationSwiftUIView.swift in Sources */, 7B0A6DFC2D47CCDF00FDFDC2 /* ExcludedAppsViewController.swift in Sources */, 1D36F4242A3B85C50052B527 /* TabCleanupPreparer.swift in Sources */, 4B4D60DF2A0C875F00BCD287 /* NetworkProtectionOptionKeyExtension.swift in Sources */, @@ -16585,6 +16627,7 @@ 37AFCE8B27DB69BC00471A10 /* PreferencesGeneralView.swift in Sources */, 37FD78112A29EBD100B36DB1 /* SyncErrorHandler.swift in Sources */, 3767318B2C7F32C500EB097B /* GradientBackground.swift in Sources */, + 1D532BDE2EDC3E4800D219FA /* PermissionCenterView.swift in Sources */, C11198312C898AB500F0272C /* FreemiumDBPFirstProfileSavedNotifier.swift in Sources */, AA8EDF2424923E980071C2E8 /* URLExtension.swift in Sources */, B634DBDF293C8F7F00C3C99E /* Tab+UIDelegate.swift in Sources */, @@ -16726,6 +16769,7 @@ CCC87D732E1429570091344D /* ChromiumTopSitesReaderTests.swift in Sources */, 31FBF2312CDD130900626C17 /* AIChatUserScriptTests.swift in Sources */, F1AFDBD42C231B9700710F2C /* SubscriptionErrorReporterTests.swift in Sources */, + 1D1B4A472EDF97F400FE43C9 /* PermissionAuthorizationTypeTests.swift in Sources */, 1D8C2FEA2B70F5A7005E4BBD /* MockWebViewSnapshotRenderer.swift in Sources */, CC3445702E674E7700019518 /* UserScriptErrorTests.swift in Sources */, 1DE717CE2E44B052009BDFFA /* MockNewTabPageTabPreloader.swift in Sources */, diff --git a/macOS/DuckDuckGo/Application/AppDelegate.swift b/macOS/DuckDuckGo/Application/AppDelegate.swift index 32bf20d30f1..bc818213f2a 100644 --- a/macOS/DuckDuckGo/Application/AppDelegate.swift +++ b/macOS/DuckDuckGo/Application/AppDelegate.swift @@ -704,16 +704,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { if AppVersion.runType.requiresEnvironment { fireproofDomains = FireproofDomains(store: FireproofDomainsStore(database: database.db, tableName: "FireproofDomains"), tld: tld) faviconManager = FaviconManager(cacheType: .standard(database.db), bookmarkManager: bookmarkManager, fireproofDomains: fireproofDomains) - permissionManager = PermissionManager(store: LocalPermissionStore(database: database.db)) + permissionManager = PermissionManager(store: LocalPermissionStore(database: database.db), featureFlagger: featureFlagger) } else { fireproofDomains = FireproofDomains(store: FireproofDomainsStore(context: nil), tld: tld) faviconManager = FaviconManager(cacheType: .inMemory, bookmarkManager: bookmarkManager, fireproofDomains: fireproofDomains) - permissionManager = PermissionManager(store: LocalPermissionStore(database: nil)) + permissionManager = PermissionManager(store: LocalPermissionStore(database: nil), featureFlagger: featureFlagger) } #else fireproofDomains = FireproofDomains(store: FireproofDomainsStore(database: database.db, tableName: "FireproofDomains"), tld: tld) faviconManager = FaviconManager(cacheType: .standard(database.db), bookmarkManager: bookmarkManager, fireproofDomains: fireproofDomains) - permissionManager = PermissionManager(store: LocalPermissionStore(database: database.db)) + permissionManager = PermissionManager(store: LocalPermissionStore(database: database.db), featureFlagger: featureFlagger) #endif webCacheManager = WebCacheManager(fireproofDomains: fireproofDomains) diff --git a/macOS/DuckDuckGo/Common/Extensions/NSColorExtension.swift b/macOS/DuckDuckGo/Common/Extensions/NSColorExtension.swift index 3b54b8271c4..6117eadec78 100644 --- a/macOS/DuckDuckGo/Common/Extensions/NSColorExtension.swift +++ b/macOS/DuckDuckGo/Common/Extensions/NSColorExtension.swift @@ -52,6 +52,18 @@ extension NSColor { .blackWhite10 } + /// Background color for permission warning rows (system permission disabled) + /// Light mode: #FFF0C2, Dark mode: #C18010 at 16% opacity + static var permissionWarningBackground: NSColor { + NSColor(name: nil) { appearance in + if appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua { + return NSColor(red: 0xC1 / 255.0, green: 0x80 / 255.0, blue: 0x10 / 255.0, alpha: 0.16) + } else { + return NSColor(red: 0xFF / 255.0, green: 0xF0 / 255.0, blue: 0xC2 / 255.0, alpha: 1.0) + } + } + } + // MARK: - Helpers var ciColor: CIColor { diff --git a/macOS/DuckDuckGo/Common/Localizables/UserText.swift b/macOS/DuckDuckGo/Common/Localizables/UserText.swift index 6fe0050c781..08133dc74bd 100644 --- a/macOS/DuckDuckGo/Common/Localizables/UserText.swift +++ b/macOS/DuckDuckGo/Common/Localizables/UserText.swift @@ -977,11 +977,33 @@ struct UserText { static let permissionPopupLearnMoreLink = NSLocalizedString("permission.popup.learn-more.link", value: "Learn more about location services", comment: "Text of link that leads to web page with more informations about location services.") static let permissionPopupAllowButton = NSLocalizedString("permission.popup.allow.button", value: "Allow", comment: "Button that the user can use to authorise a web site to for, for example access location or camera and microphone etc.") + static let permissionPopupDenyButton = NSLocalizedString("permission.popup.deny.button", value: "Deny", comment: "Button that denies permission for this request only") + static let permissionPopupAlwaysDenyButton = NSLocalizedString("permission.popup.always.deny.button", value: "Never Allow", comment: "Button that denies permission and remembers the decision for future requests") + static let permissionPopupAlwaysAllowButton = NSLocalizedString("permission.popup.always.allow.button", value: "Always Allow", comment: "Button that grants permission and remembers the decision for future requests") + static let privacyDashboardPermissionAsk = NSLocalizedString("dashboard.permission.ask", value: "Ask every time", comment: "Privacy Dashboard: Website should always Ask for permission for input media device access") static let privacyDashboardPermissionAlwaysAllow = NSLocalizedString("dashboard.permission.allow", value: "Always allow", comment: "Privacy Dashboard: Website can always access input media device") static let privacyDashboardPermissionAlwaysDeny = NSLocalizedString("dashboard.permission.deny", value: "Always deny", comment: "Privacy Dashboard: Website can never access input media device") static let permissionPopoverDenyButton = NSLocalizedString("permission.popover.deny", value: "Deny", comment: "Permission Popover: Deny Website input media device access") + // Two-step permission authorization (geolocation) + static let permissionSystemLocationEnable = NSLocalizedString("permission.system.location.enable", value: "Enable System Location", comment: "Button to enable system location services") + static let permissionSystemLocationWaiting = NSLocalizedString("permission.system.location.waiting", value: "Waiting for system permission…", comment: "Text shown while waiting for user to respond to system location permission dialog") + static let permissionSystemLocationEnabled = NSLocalizedString("permission.system.location.enabled", value: "System location enabled!", comment: "Text shown after system location permission has been granted") + static let permissionSystemLocationDisabled = NSLocalizedString("permission.system.location.disabled", value: "System location disabled. Turn it on in ", comment: "Text shown when system location was previously denied. Followed by a link to System Settings") + static let permissionSystemSettingsLocation = NSLocalizedString("permission.system.settings.location", value: "System Settings → Privacy", comment: "Link text to open System Settings Privacy section for location") + static let permissionRestartApp = NSLocalizedString("permission.restart.app", value: "Restart the DuckDuckGo application", comment: "Text shown when app restart is required for permission changes to take effect") + static let permissionGeolocationPromptFormat = NSLocalizedString("permission.geolocation.prompt.format", value: "Allow %@ to use your current location?", comment: "Prompt asking if domain %@ can use location") + static let permissionPopupNeverAllowButton = NSLocalizedString("permission.popup.never.allow.button", value: "Never Allow", comment: "Button that denies permission and remembers the decision for future requests") + + // Permission Center + static let permissionCenterTitle = NSLocalizedString("permission.center.title", value: "Permissions for \"%@\"", comment: "Title for permission center popover, %@ is the domain name") + static let permissionCenterAlwaysAsk = NSLocalizedString("permission.center.always.ask", value: "Always Ask", comment: "Permission center dropdown option to always ask for permission") + static let permissionCenterAlwaysAllow = NSLocalizedString("permission.center.always.allow", value: "Always Allow", comment: "Permission center dropdown option to always allow permission") + static let permissionCenterNeverAllow = NSLocalizedString("permission.center.never.allow", value: "Never Allow", comment: "Permission center dropdown option to never allow permission") + static let permissionCenterExternalSchemeDescription = NSLocalizedString("permission.center.external.scheme.description", value: "%@ to open \"%@\" links", comment: "Description for external scheme permission, first %@ is domain, second %@ is scheme name") + static let permissionCenterExternalApps = NSLocalizedString("permission.center.external.apps", value: "External Apps", comment: "Permission center header for external app permissions") + static let privacyDashboardPopupsAlwaysAsk = NSLocalizedString("dashboard.popups.ask", value: "Notify", comment: "Make pop-up windows always request permission for the current domain") static let settings = NSLocalizedString("settings", value: "Settings", comment: "Menu item for opening settings") diff --git a/macOS/DuckDuckGo/Localization/Localizable.xcstrings b/macOS/DuckDuckGo/Localization/Localizable.xcstrings index a13ec6fd372..8fff15486fb 100644 --- a/macOS/DuckDuckGo/Localization/Localizable.xcstrings +++ b/macOS/DuckDuckGo/Localization/Localizable.xcstrings @@ -60390,6 +60390,78 @@ } } }, + "permission.center.always.allow" : { + "comment" : "Permission center dropdown option to always allow permission", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Always Allow" + } + } + } + }, + "permission.center.always.ask" : { + "comment" : "Permission center dropdown option to always ask for permission", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Always Ask" + } + } + } + }, + "permission.center.external.apps" : { + "comment" : "Permission center header for external app permissions", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "External Apps" + } + } + } + }, + "permission.center.external.scheme.description" : { + "comment" : "Description for external scheme permission, first %@ is domain, second %@ is scheme name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ to open \"%2$@\" links" + } + } + } + }, + "permission.center.never.allow" : { + "comment" : "Permission center dropdown option to never allow permission", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Never Allow" + } + } + } + }, + "permission.center.title" : { + "comment" : "Title for permission center popover, %@ is the domain name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Permissions for \"%@\"" + } + } + } + }, "permission.disabled.app" : { "comment" : "The app (DuckDuckGo: %@ 2) has no access permission to (%@ 1) media device", "extractionState" : "extracted_with_value", @@ -60630,6 +60702,18 @@ } } }, + "permission.geolocation.prompt.format" : { + "comment" : "Prompt asking if domain %@ can use location", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Allow %@ to use your current location?" + } + } + } + }, "permission.microphone" : { "comment" : "Microphone input media device name", "extractionState" : "extracted_with_value", @@ -60990,6 +61074,30 @@ } } }, + "permission.popup.always.allow.button" : { + "comment" : "Button that grants permission and remembers the decision for future requests", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Always Allow" + } + } + } + }, + "permission.popup.always.deny.button" : { + "comment" : "Button that denies permission and remembers the decision for future requests", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Never Allow" + } + } + } + }, "permission.popup.blocked.popover" : { "comment" : "Text of popover warning the user that a pop-up has been blocked", "extractionState" : "extracted_with_value", @@ -61050,6 +61158,18 @@ } } }, + "permission.popup.deny.button" : { + "comment" : "Button that denies permission for this request only", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Deny" + } + } + } + }, "permission.popup.learn-more.link" : { "comment" : "Text of link that leads to web page with more informations about location services.", "extractionState" : "extracted_with_value", @@ -61110,6 +61230,18 @@ } } }, + "permission.popup.never.allow.button" : { + "comment" : "Button that denies permission and remembers the decision for future requests", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Never Allow" + } + } + } + }, "permission.popup.open.format" : { "comment" : "Menu action to open the blocked pop-up at the specified URL", "extractionState" : "extracted_with_value", @@ -61571,6 +61703,78 @@ } } }, + "permission.restart.app" : { + "comment" : "Text shown when app restart is required for permission changes to take effect", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Restart the DuckDuckGo application" + } + } + } + }, + "permission.system.location.disabled" : { + "comment" : "Text shown when system location was previously denied. Followed by a link to System Settings", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "System location disabled. Turn it on in " + } + } + } + }, + "permission.system.location.enable" : { + "comment" : "Button to enable system location services", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Enable System Location" + } + } + } + }, + "permission.system.location.enabled" : { + "comment" : "Text shown after system location permission has been granted", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "System location enabled!" + } + } + } + }, + "permission.system.location.waiting" : { + "comment" : "Text shown while waiting for user to respond to system location permission dialog", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Waiting for system permission…" + } + } + } + }, + "permission.system.settings.location" : { + "comment" : "Link text to open System Settings Privacy section for location", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "System Settings → Privacy" + } + } + } + }, "permission.unmute" : { "comment" : "Resume input media device %@ access for %@ website", "extractionState" : "extracted_with_value", diff --git a/macOS/DuckDuckGo/NavigationBar/View/AddressBarButtonsViewController.swift b/macOS/DuckDuckGo/NavigationBar/View/AddressBarButtonsViewController.swift index 589e0c4996a..08c40d42e8f 100644 --- a/macOS/DuckDuckGo/NavigationBar/View/AddressBarButtonsViewController.swift +++ b/macOS/DuckDuckGo/NavigationBar/View/AddressBarButtonsViewController.swift @@ -61,7 +61,7 @@ final class AddressBarButtonsViewController: NSViewController { private var permissionAuthorizationPopover: PermissionAuthorizationPopover? private func permissionAuthorizationPopoverCreatingIfNeeded() -> PermissionAuthorizationPopover { return permissionAuthorizationPopover ?? { - let popover = PermissionAuthorizationPopover() + let popover = PermissionAuthorizationPopover(featureFlagger: featureFlagger) NotificationCenter.default.addObserver(self, selector: #selector(popoverDidClose), name: NSPopover.didCloseNotification, object: popover) NotificationCenter.default.addObserver(self, selector: #selector(popoverWillShow), name: NSPopover.willShowNotification, object: popover) self.permissionAuthorizationPopover = popover @@ -70,6 +70,8 @@ final class AddressBarButtonsViewController: NSViewController { }() } + private var permissionCenterPopover: PermissionCenterPopover? + private var popupBlockedPopover: PopupBlockedPopover? private func popupBlockedPopoverCreatingIfNeeded() -> PopupBlockedPopover { return popupBlockedPopover ?? { @@ -1610,7 +1612,36 @@ final class AddressBarButtonsViewController: NSViewController { } @IBAction func permissionCenterButtonAction(_ sender: Any) { + guard featureFlagger.isFeatureOn(.newPermissionView) else { return } + guard let tabViewModel else { return } + + // Close existing popover if shown + if let existingPopover = permissionCenterPopover, existingPopover.isShown { + existingPopover.close() + permissionCenterPopover = nil + return + } + let url = tabViewModel.tab.content.urlForWebView ?? .empty + let domain = (url.isFileURL ? .localhost : (url.host ?? "")).droppingWwwPrefix() + + let viewModel = PermissionCenterViewModel( + domain: domain, + usedPermissions: tabViewModel.usedPermissions, + permissionManager: permissionManager, + removePermission: { [weak tabViewModel] permissionType in + tabViewModel?.tab.permissions.remove(permissionType) + }, + dismissPopover: { [weak self] in + self?.permissionCenterPopover?.close() + self?.permissionCenterPopover = nil + } + ) + + let popover = PermissionCenterPopover(viewModel: viewModel) + permissionCenterPopover = popover + + popover.show(relativeTo: permissionCenterButton.bounds, of: permissionCenterButton, preferredEdge: .maxY) } @IBAction func cameraButtonAction(_ sender: NSButton) { @@ -2296,9 +2327,7 @@ extension TabViewModel { let hasRequestedPermission = usedPermissions.values.contains(where: { $0.isRequested }) let shouldShowWhileFocused = (tab.content == .newtab) && hasRequestedPermission - let isAnyPermissionPresent = usedPermissions.values.contains(where: { - !$0.isReloading - }) + let isAnyPermissionPresent = !usedPermissions.values.isEmpty return (shouldShowWhileFocused || (!isTextFieldEditorFirstResponder && isAnyPermissionPresent)) && !isAnyTrackerAnimationPlaying diff --git a/macOS/DuckDuckGo/Permissions/Model/PermissionManager.swift b/macOS/DuckDuckGo/Permissions/Model/PermissionManager.swift index 6d9a4de854d..cab3204f352 100644 --- a/macOS/DuckDuckGo/Permissions/Model/PermissionManager.swift +++ b/macOS/DuckDuckGo/Permissions/Model/PermissionManager.swift @@ -17,8 +17,10 @@ // import Foundation +import BrowserServicesKit import Combine import Common +import FeatureFlags import os.log protocol PermissionManagerProtocol: AnyObject { @@ -33,19 +35,24 @@ protocol PermissionManagerProtocol: AnyObject { func burnPermissions(except fireproofDomains: FireproofDomains, completion: @escaping @MainActor () -> Void) func burnPermissions(of baseDomains: Set, tld: TLD, completion: @escaping @MainActor () -> Void) + /// Removes a specific permission for a domain (clears from storage) + func removePermission(forDomain domain: String, permissionType: PermissionType) + var persistedPermissionTypes: Set { get } } final class PermissionManager: PermissionManagerProtocol { private let store: PermissionStore + private let featureFlagger: FeatureFlagger private var permissions = [String: [PermissionType: StoredPermission]]() private let permissionSubject = PassthroughSubject() var permissionPublisher: AnyPublisher { permissionSubject.eraseToAnyPublisher() } - init(store: PermissionStore) { + init(store: PermissionStore, featureFlagger: FeatureFlagger) { self.store = store + self.featureFlagger = featureFlagger loadPermissions() } @@ -76,8 +83,6 @@ final class PermissionManager: PermissionManagerProtocol { } func setPermission(_ decision: PersistedPermissionDecision, forDomain domain: String, permissionType: PermissionType) { - assert(permissionType.canPersistGrantedDecision || decision != .allow) - assert(permissionType.canPersistDeniedDecision || decision != .deny) let storedPermission: StoredPermission let domain = domain.droppingWwwPrefix() @@ -128,4 +133,19 @@ final class PermissionManager: PermissionManagerProtocol { }) } + func removePermission(forDomain domain: String, permissionType: PermissionType) { + let domain = domain.droppingWwwPrefix() + + guard let storedPermission = permissions[domain]?[permissionType] else { return } + + // Remove from in-memory cache + permissions[domain]?[permissionType] = nil + + // Remove from persistent storage + store.remove(objectWithId: storedPermission.id) + + // Notify subscribers + permissionSubject.send((domain, permissionType, .ask)) + } + } diff --git a/macOS/DuckDuckGo/Permissions/Model/PermissionModel.swift b/macOS/DuckDuckGo/Permissions/Model/PermissionModel.swift index 467826cb877..247a26f93fc 100644 --- a/macOS/DuckDuckGo/Permissions/Model/PermissionModel.swift +++ b/macOS/DuckDuckGo/Permissions/Model/PermissionModel.swift @@ -17,8 +17,10 @@ // import AVFoundation +import BrowserServicesKit import Combine import CoreLocation +import FeatureFlags import Foundation import Navigation import WebKit @@ -36,6 +38,7 @@ final class PermissionModel { private let permissionManager: PermissionManagerProtocol private let geolocationService: GeolocationServiceProtocol + private let featureFlagger: FeatureFlagger weak var webView: WKWebView? { didSet { guard let webView = webView else { return } @@ -46,11 +49,19 @@ final class PermissionModel { } private var cancellables = Set() + /// Returns the domain for the current webView URL, mapping file URLs to "localhost" + private var currentDomain: String? { + guard let url = webView?.url else { return nil } + return url.isFileURL ? .localhost : url.host + } + init(webView: WKWebView? = nil, permissionManager: PermissionManagerProtocol, - geolocationService: GeolocationServiceProtocol = GeolocationService.shared) { + geolocationService: GeolocationServiceProtocol = GeolocationService.shared, + featureFlagger: FeatureFlagger) { self.permissionManager = permissionManager self.geolocationService = geolocationService + self.featureFlagger = featureFlagger if let webView { self.webView = webView self.subscribe(to: webView) @@ -212,7 +223,7 @@ final class PermissionModel { } func revoke(_ permission: PermissionType) { - if let domain = webView?.url?.host, + if let domain = currentDomain, case .allow = permissionManager.permission(forDomain: domain, permissionType: permission) { permissionManager.setPermission(.ask, forDomain: domain, permissionType: permission) } @@ -226,6 +237,27 @@ final class PermissionModel { } } + /// Removes a permission completely (revokes and removes from tracking) + func remove(_ permission: PermissionType) { + // First revoke the permission + switch permission { + case .camera, .microphone, .geolocation: + webView?.revokePermissions([permission]) + case .popups, .externalScheme: + break + } + + // Remove from dictionary (will trigger @Published update) + permissions[permission] = nil + + // Remove from persisted storage + if let domain = currentDomain { + permissionManager.removePermission(forDomain: domain, permissionType: permission) + } else { + assertionFailure("webView URL should not be nil when removing a permission") + } + } + // MARK: - WebView delegated methods // Called before requestMediaCapturePermissionFor: to validate System Permissions @@ -268,9 +300,9 @@ final class PermissionModel { for permission in permissions { var grant: PersistedPermissionDecision let stored = permissionManager.permission(forDomain: domain, permissionType: permission) - if case .allow = stored, permission.canPersistGrantedDecision { + if case .allow = stored, permission.canPersistGrantedDecision(featureFlagger: featureFlagger) { grant = .allow - } else if case .deny = stored, permission.canPersistDeniedDecision { + } else if case .deny = stored, permission.canPersistDeniedDecision(featureFlagger: featureFlagger) { grant = .deny } else if let state = self.permissions[permission] { switch state { diff --git a/macOS/DuckDuckGo/Permissions/Model/PermissionType.swift b/macOS/DuckDuckGo/Permissions/Model/PermissionType.swift index af4a41a9c20..69acf98b845 100644 --- a/macOS/DuckDuckGo/Permissions/Model/PermissionType.swift +++ b/macOS/DuckDuckGo/Permissions/Model/PermissionType.swift @@ -17,7 +17,9 @@ // import Foundation +import BrowserServicesKit import CommonObjCExtensions +import FeatureFlags import WebKit enum PermissionType: Hashable { @@ -69,22 +71,37 @@ extension PermissionType { return [.camera, .microphone, .geolocation] } - var canPersistGrantedDecision: Bool { - switch self { - case .camera, .microphone, .externalScheme: - return true - case .geolocation: - return false - case .popups: - return true + func canPersistGrantedDecision(featureFlagger: FeatureFlagger) -> Bool { + if featureFlagger.isFeatureOn(.newPermissionView) { + switch self { + case .camera, .microphone, .externalScheme, .popups, .geolocation: + return true + } + } else { + switch self { + case .camera, .microphone, .externalScheme, .popups: + return true + case .geolocation: + return false + } } } - var canPersistDeniedDecision: Bool { - switch self { - case .camera, .microphone, .geolocation: - return true - case .popups, .externalScheme: - return false + + func canPersistDeniedDecision(featureFlagger: FeatureFlagger) -> Bool { + if featureFlagger.isFeatureOn(.newPermissionView) { + switch self { + case .camera, .microphone, .geolocation, .externalScheme: + return true + case .popups: + return false + } + } else { + switch self { + case .camera, .microphone, .geolocation: + return true + case .popups, .externalScheme: + return false + } } } diff --git a/macOS/DuckDuckGo/Permissions/Model/SystemPermissionManager.swift b/macOS/DuckDuckGo/Permissions/Model/SystemPermissionManager.swift new file mode 100644 index 00000000000..f43b2f00470 --- /dev/null +++ b/macOS/DuckDuckGo/Permissions/Model/SystemPermissionManager.swift @@ -0,0 +1,167 @@ +// +// SystemPermissionManager.swift +// +// Copyright © 2025 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import CoreLocation + +/// Represents the authorization state for a system permission +enum SystemPermissionAuthorizationState { + /// Permission has not been requested yet + case notDetermined + /// Permission has been granted + case authorized + /// Permission has been denied by the user + case denied + /// Permission is restricted (parental controls, MDM, etc.) + case restricted + /// Services are disabled system-wide (e.g., Location Services off in System Settings) + case systemDisabled +} + +/// Protocol for managing system-level permissions required before website permissions can be granted +protocol SystemPermissionManagerProtocol: AnyObject { + + /// Returns the current authorization state for the given permission type + func authorizationState(for permissionType: PermissionType) -> SystemPermissionAuthorizationState + + /// Returns true if system authorization is required for the given permission type + func isAuthorizationRequired(for permissionType: PermissionType) -> Bool + + /// Requests system authorization for the given permission type + /// - Parameters: + /// - permissionType: The permission type to request authorization for + /// - completion: Called with the resulting authorization state + /// - Returns: A cancellable that can be used to cancel the observation (for permissions that support it) + @discardableResult + func requestAuthorization(for permissionType: PermissionType, completion: @escaping (SystemPermissionAuthorizationState) -> Void) -> AnyCancellable? +} + +/// Manages system-level permissions required before website permissions can be granted +final class SystemPermissionManager: SystemPermissionManagerProtocol { + + private let geolocationService: GeolocationServiceProtocol + + init(geolocationService: GeolocationServiceProtocol = GeolocationService.shared) { + self.geolocationService = geolocationService + } + + // MARK: - Public Methods + + /// Returns the current authorization state for the given permission type + func authorizationState(for permissionType: PermissionType) -> SystemPermissionAuthorizationState { + switch permissionType { + case .geolocation: + return geolocationAuthorizationState + case .camera, .microphone, .popups, .externalScheme: + return .authorized // These don't require system permission through our two-step flow + } + } + + /// Returns true if system authorization is required for the given permission type + func isAuthorizationRequired(for permissionType: PermissionType) -> Bool { + switch permissionType { + case .geolocation: + return isGeolocationAuthorizationRequired + case .camera, .microphone, .popups, .externalScheme: + return false // These don't require system permission through our two-step flow + } + } + + /// Requests system authorization for the given permission type + @discardableResult + func requestAuthorization(for permissionType: PermissionType, completion: @escaping (SystemPermissionAuthorizationState) -> Void) -> AnyCancellable? { + switch permissionType { + case .geolocation: + return requestGeolocationAuthorization(completion: completion) + case .camera, .microphone, .popups, .externalScheme: + // These don't require system permission through our two-step flow + completion(.authorized) + return nil + } + } + + // MARK: - Private Geolocation Implementation + + private var geolocationAuthorizationState: SystemPermissionAuthorizationState { + guard geolocationService.locationServicesEnabled() else { + return .systemDisabled + } + + switch geolocationService.authorizationStatus { + case .notDetermined: + return .notDetermined + case .authorized, .authorizedAlways: + return .authorized + case .denied: + return .denied + case .restricted: + return .restricted + @unknown default: + return .notDetermined + } + } + + private var isGeolocationAuthorizationRequired: Bool { + switch geolocationAuthorizationState { + case .notDetermined, .systemDisabled: + return true + case .authorized, .denied, .restricted: + return false + } + } + + @discardableResult + private func requestGeolocationAuthorization(completion: @escaping (SystemPermissionAuthorizationState) -> Void) -> AnyCancellable { + // If already determined, return current state immediately + guard geolocationAuthorizationState == .notDetermined else { + completion(geolocationAuthorizationState) + return AnyCancellable {} + } + + // Use a holder class to ensure proper capture semantics + // This avoids the issue of capturing a nil variable before assignment + let cancellableHolder = CancellableHolder() + + // Subscribe to authorization status publisher to observe changes + let authorizationCancellable = geolocationService.authorizationStatusPublisher + .dropFirst() // Skip initial value, we want to observe changes + .first() // Only need the first change + .sink { [weak self, cancellableHolder] _ in + let state = self?.geolocationAuthorizationState ?? .notDetermined + // Cancel location subscription once we have the authorization result + cancellableHolder.cancellable?.cancel() + completion(state) + } + + // Subscribe to location publisher to trigger authorization request + // The GeolocationService calls requestWhenInUseAuthorization() when first subscribed + // We keep this subscription alive until authorization is determined + cancellableHolder.cancellable = geolocationService.locationPublisher + .sink { _ in } + + return AnyCancellable { + authorizationCancellable.cancel() + cancellableHolder.cancellable?.cancel() + } + } +} + +/// Helper class to hold a cancellable reference for proper capture semantics in closures +private final class CancellableHolder { + var cancellable: AnyCancellable? +} diff --git a/macOS/DuckDuckGo/Permissions/View/PermissionAuthorizationPopover.swift b/macOS/DuckDuckGo/Permissions/View/PermissionAuthorizationPopover.swift index 172b1681d52..9910a25fa23 100644 --- a/macOS/DuckDuckGo/Permissions/View/PermissionAuthorizationPopover.swift +++ b/macOS/DuckDuckGo/Permissions/View/PermissionAuthorizationPopover.swift @@ -17,12 +17,17 @@ // import Cocoa +import SwiftUI +import BrowserServicesKit +import FeatureFlags final class PermissionAuthorizationPopover: NSPopover { @nonobjc private var didShow: Bool = false + private let featureFlagger: FeatureFlagger - override init() { + init(featureFlagger: FeatureFlagger) { + self.featureFlagger = featureFlagger super.init() behavior = .applicationDefined @@ -42,15 +47,37 @@ final class PermissionAuthorizationPopover: NSPopover { } // swiftlint:disable force_cast - var viewController: PermissionAuthorizationViewController { contentViewController as! PermissionAuthorizationViewController } + var viewController: PermissionAuthorizationViewController { + get { + // Ensure content controller is set up + if contentViewController == nil { + setupContentController() + } + return contentViewController as! PermissionAuthorizationViewController + } + } // swiftlint:enable force_cast - // swiftlint:disable force_cast private func setupContentController() { + let controller: PermissionAuthorizationViewController + + if featureFlagger.isFeatureOn(.newPermissionView) { + // Create programmatically + controller = PermissionAuthorizationViewController(newPermissionView: true) + + } else { + // Load from storyboard + controller = setupStoryboardController() + } + + contentViewController = controller + } + + // swiftlint:disable force_cast + private func setupStoryboardController() -> PermissionAuthorizationViewController { let storyboard = NSStoryboard(name: "PermissionAuthorization", bundle: nil) - let controller = storyboard + return storyboard .instantiateController(withIdentifier: "PermissionAuthorizationViewController") as! PermissionAuthorizationViewController - contentViewController = controller } // swiftlint:enable force_cast diff --git a/macOS/DuckDuckGo/Permissions/View/PermissionAuthorizationSwiftUIView.swift b/macOS/DuckDuckGo/Permissions/View/PermissionAuthorizationSwiftUIView.swift new file mode 100644 index 00000000000..7439552567c --- /dev/null +++ b/macOS/DuckDuckGo/Permissions/View/PermissionAuthorizationSwiftUIView.swift @@ -0,0 +1,581 @@ +// +// PermissionAuthorizationSwiftUIView.swift +// +// Copyright © 2025 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import AppKit +import Combine +import SwiftUI + +// MARK: - PermissionAuthorizationType + +/// UI-only permission type for the authorization SwiftUI view. +/// This handles the combined camera+microphone case without modifying the model layer. +enum PermissionAuthorizationType { + case camera + case microphone + case cameraAndMicrophone + case geolocation + case popups + case externalScheme(scheme: String) + + /// Creates the appropriate type from an array of PermissionType + init(from permissions: [PermissionType]) { + if Set(permissions) == Set([.camera, .microphone]) { + self = .cameraAndMicrophone + } else if let first = permissions.first { + switch first { + case .camera: self = .camera + case .microphone: self = .microphone + case .geolocation: self = .geolocation + case .popups: self = .popups + case .externalScheme(let scheme): self = .externalScheme(scheme: scheme) + } + } else { + assertionFailure("Unexpected permission types combination") + self = .camera // fallback, shouldn't happen + } + } + + var localizedDescription: String { + switch self { + case .camera: + return UserText.permissionCamera + case .microphone: + return UserText.permissionMicrophone + case .cameraAndMicrophone: + return UserText.permissionCameraAndMicrophone + case .geolocation: + return UserText.permissionGeolocation + case .popups: + return UserText.permissionPopups + case .externalScheme(scheme: let scheme): + guard let url = URL(string: scheme + URL.NavigationalScheme.separator), + let app = NSWorkspace.shared.application(toOpen: url) + else { return scheme } + return app + } + } + + /// Whether this permission type requires a two-step authorization flow (system permission first, then website permission) + var requiresSystemPermission: Bool { + switch self { + case .geolocation: + return true + case .camera, .microphone, .cameraAndMicrophone, .popups, .externalScheme: + return false + } + } + + /// Whether this permission uses permanent decisions ("Always Allow" / "Never Allow") vs one-time decisions ("Allow" / "Deny") + var usesPermanentDecisions: Bool { + switch self { + case .camera, .microphone, .cameraAndMicrophone, .popups, .externalScheme, .geolocation: + return true + } + } + + // MARK: - Two-Step UI Localized Strings + + /// Button text for enabling system permission (Step 1) + var systemPermissionEnableText: String { + switch self { + case .geolocation: + return UserText.permissionSystemLocationEnable + case .camera, .microphone, .cameraAndMicrophone, .popups, .externalScheme: + return "" // Not used for these types + } + } + + /// Text shown while waiting for system permission response + var systemPermissionWaitingText: String { + switch self { + case .geolocation: + return UserText.permissionSystemLocationWaiting + case .camera, .microphone, .cameraAndMicrophone, .popups, .externalScheme: + return "" + } + } + + /// Text shown when system permission is granted + var systemPermissionEnabledText: String { + switch self { + case .geolocation: + return UserText.permissionSystemLocationEnabled + case .camera, .microphone, .cameraAndMicrophone, .popups, .externalScheme: + return "" + } + } + + /// Text shown when system permission was previously disabled (prefix before link) + var systemPermissionDisabledText: String { + switch self { + case .geolocation: + return UserText.permissionSystemLocationDisabled + case .camera, .microphone, .cameraAndMicrophone, .popups, .externalScheme: + return "" + } + } + + /// Link text for opening System Settings + var systemSettingsLinkText: String { + switch self { + case .geolocation: + return UserText.permissionSystemSettingsLocation + case .camera, .microphone, .cameraAndMicrophone, .popups, .externalScheme: + return "" + } + } + + /// URL to open the relevant System Settings pane + var systemSettingsURL: URL? { + switch self { + case .geolocation: + return URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices") + case .camera, .microphone, .cameraAndMicrophone, .popups, .externalScheme: + return nil + } + } + + /// Converts back to a single PermissionType for system permission checks. + /// For cameraAndMicrophone, returns .camera as both require the same system permission flow. + var asPermissionType: PermissionType { + switch self { + case .camera: return .camera + case .microphone: return .microphone + case .cameraAndMicrophone: return .camera // Use camera for system permission checks + case .geolocation: return .geolocation + case .popups: return .popups + case .externalScheme(let scheme): return .externalScheme(scheme: scheme) + } + } +} + +// MARK: - PermissionAuthorizationSwiftUIView + +struct PermissionAuthorizationSwiftUIView: View { + let domain: String + let permissionType: PermissionAuthorizationType + let onDeny: () -> Void + let onAlwaysDeny: () -> Void + let onAllow: () -> Void + let onAlwaysAllow: () -> Void + let systemPermissionManager: SystemPermissionManagerProtocol + + /// State for the system permission step in two-step flow + enum SystemPermissionState { + case initial + case waiting + case authorized + case denied + /// Permission was already denied/restricted/disabled before showing the UI + case alreadyDenied + } + + @State private var systemPermissionState: SystemPermissionState = .initial + @State private var authorizationCancellable: AnyCancellable? + + // MARK: - Computed Properties + + /// Whether to show the two-step UI + private var showsTwoStepUI: Bool { + guard permissionType.requiresSystemPermission else { return false } + return systemPermissionManager.isAuthorizationRequired(for: permissionType.asPermissionType) || systemPermissionState != .initial + } + + private var promptText: String { + switch permissionType { + case .geolocation: + return String(format: UserText.permissionGeolocationPromptFormat, domain) + case .camera, .microphone, .cameraAndMicrophone: + return String(format: UserText.devicePermissionAuthorizationFormat, domain, permissionType.localizedDescription.lowercased()) + case .popups: + return String(format: UserText.popupWindowsPermissionAuthorizationFormat, domain, permissionType.localizedDescription.lowercased()) + case .externalScheme: + if domain.isEmpty { + return String(format: UserText.externalSchemePermissionAuthorizationNoDomainFormat, permissionType.localizedDescription) + } else { + return String(format: UserText.externalSchemePermissionAuthorizationFormat, domain, permissionType.localizedDescription) + } + } + } + + // MARK: - Button Titles & Actions + + private var denyButtonTitle: String { + permissionType.usesPermanentDecisions ? UserText.permissionPopupAlwaysDenyButton : UserText.permissionPopupDenyButton + } + + private var allowButtonTitle: String { + permissionType.usesPermanentDecisions ? UserText.permissionPopupAlwaysAllowButton : UserText.permissionPopupAllowButton + } + + private var denyAction: () -> Void { + permissionType.usesPermanentDecisions ? onAlwaysDeny : onDeny + } + + private var allowAction: () -> Void { + permissionType.usesPermanentDecisions ? onAlwaysAllow : onAllow + } + + // MARK: - Body + + var body: some View { + if showsTwoStepUI { + twoStepPermissionView + } else { + standardPermissionView + } + } + + // MARK: - Two-Step Permission View + + private var twoStepPermissionView: some View { + VStack(spacing: 16) { + // Prompt text + Text(promptText) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(Color(designSystemColor: .textPrimary)) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 16) + .padding(.top, 16) + + // Step 1: System permission + stepOneView + .padding(.horizontal, 16) + + // Step 2: Website permission + stepTwoView + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + .frame(width: 360) + .background(Color(designSystemColor: .containerFillPrimary)) + .onAppear { + initializeSystemPermissionState() + } + } + + /// Check if system permission was already denied before showing the UI + private func initializeSystemPermissionState() { + guard systemPermissionState == .initial else { return } + + let authState = systemPermissionManager.authorizationState(for: permissionType.asPermissionType) + switch authState { + case .denied, .restricted, .systemDisabled: + systemPermissionState = .alreadyDenied + case .authorized: + systemPermissionState = .authorized + case .notDetermined: + break // Keep initial state + } + } + + @ViewBuilder + private var stepOneView: some View { + HStack(spacing: 12) { + stepIndicator(step: 1, isActive: systemPermissionState != .authorized) + + switch systemPermissionState { + case .initial: + Button(action: requestSystemPermission) { + Text(permissionType.systemPermissionEnableText) + .font(.system(size: 13)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .frame(height: 36) + .background(Color.accentColor) + .cornerRadius(8) + } + .buttonStyle(PlainButtonStyle()) + .accessibilityIdentifier("PermissionAuthorizationSwiftUIView.enableSystemPermissionButton") + + case .waiting: + Text(permissionType.systemPermissionWaitingText) + .font(.system(size: 13)) + .foregroundColor(Color(designSystemColor: .textSecondary)) + .frame(maxWidth: .infinity) + .frame(height: 36) + .background(Color(designSystemColor: .controlsFillSecondary)) + .cornerRadius(8) + + case .authorized: + HStack(spacing: 8) { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(Color(NSColor.systemGreen)) + .font(.system(size: 20)) + + Text(permissionType.systemPermissionEnabledText) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(Color(NSColor.systemGreen)) + } + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: 36) + + case .alreadyDenied, .denied: + systemPermissionDisabledView + } + } + } + + /// View shown when system permission was already denied - displays link to System Settings + private var systemPermissionDisabledView: some View { + (Text(permissionType.systemPermissionDisabledText) + .font(.system(size: 13)) + .foregroundColor(Color(designSystemColor: .textPrimary)) + + Text(permissionType.systemSettingsLinkText) + .font(.system(size: 13)) + .foregroundColor(.accentColor)) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .onTapGesture { + openSystemSettings() + } + } + + private func openSystemSettings() { + guard let url = permissionType.systemSettingsURL else { return } + NSWorkspace.shared.open(url) + } + + @ViewBuilder + private var stepTwoView: some View { + let isEnabled = systemPermissionState == .authorized + let requiresRestart = systemPermissionState == .alreadyDenied || systemPermissionState == .denied + + HStack(spacing: 12) { + stepIndicator(step: 2, isActive: isEnabled) + + if requiresRestart { + Text(UserText.permissionRestartApp) + .font(.system(size: 13)) + .foregroundColor(Color(designSystemColor: .textSecondary)) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: 36) + } else { + HStack(spacing: 8) { + Button(action: onAlwaysDeny) { + Text(UserText.permissionPopupNeverAllowButton) + .font(.system(size: 13)) + .foregroundColor(isEnabled ? Color(designSystemColor: .textPrimary) : Color(designSystemColor: .textSecondary)) + .frame(maxWidth: .infinity) + .frame(height: 36) + .background(isEnabled ? Color(designSystemColor: .controlsFillPrimary) : Color(designSystemColor: .controlsFillSecondary)) + .cornerRadius(8) + } + .buttonStyle(PlainButtonStyle()) + .disabled(!isEnabled) + .accessibilityIdentifier("PermissionAuthorizationSwiftUIView.neverAllowButton") + + Button(action: onAlwaysAllow) { + Text(UserText.permissionPopupAlwaysAllowButton) + .font(.system(size: 13)) + .foregroundColor(isEnabled ? Color(designSystemColor: .textPrimary) : Color(designSystemColor: .textSecondary)) + .frame(maxWidth: .infinity) + .frame(height: 36) + .background(isEnabled ? Color(designSystemColor: .controlsFillPrimary) : Color(designSystemColor: .controlsFillSecondary)) + .cornerRadius(8) + } + .buttonStyle(PlainButtonStyle()) + .disabled(!isEnabled) + .accessibilityIdentifier("PermissionAuthorizationSwiftUIView.alwaysAllowButton") + } + } + } + } + + private func stepIndicator(step: Int, isActive: Bool) -> some View { + ZStack { + if isActive { + Circle() + .fill(Color.primary) + .frame(width: 28, height: 28) + } else { + Circle() + .stroke(Color.secondary.opacity(0.4), lineWidth: 1) + .frame(width: 28, height: 28) + } + + Text("\(step)") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(isActive ? Color(NSColor.windowBackgroundColor) : Color.secondary.opacity(0.6)) + } + } + + private func requestSystemPermission() { + systemPermissionState = .waiting + + authorizationCancellable = systemPermissionManager.requestAuthorization(for: permissionType.asPermissionType) { state in + DispatchQueue.main.async { + switch state { + case .authorized: + systemPermissionState = .authorized + case .denied, .restricted, .systemDisabled: + systemPermissionState = .denied + case .notDetermined: + systemPermissionState = .initial + } + } + } + } + + // MARK: - Standard Permission View + + private var standardPermissionView: some View { + VStack(spacing: 20) { + Text(promptText) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(Color(designSystemColor: .textPrimary)) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 16) + .padding(.top, 16) + + HStack(spacing: 12) { + Button(action: denyAction) { + Text(denyButtonTitle) + .font(.system(size: 13)) + .foregroundColor(Color(designSystemColor: .textPrimary)) + .frame(maxWidth: .infinity) + .frame(height: 32) + .background(Color(designSystemColor: .controlsFillPrimary)) + .cornerRadius(8) + } + .buttonStyle(PlainButtonStyle()) + .accessibilityIdentifier("PermissionAuthorizationSwiftUIView.denyButton") + + Button(action: allowAction) { + Text(allowButtonTitle) + .font(.system(size: 13)) + .foregroundColor(Color(designSystemColor: .textPrimary)) + .frame(maxWidth: .infinity) + .frame(height: 32) + .background(Color(designSystemColor: .controlsFillPrimary)) + .cornerRadius(8) + } + .buttonStyle(PlainButtonStyle()) + .accessibilityIdentifier("PermissionAuthorizationSwiftUIView.allowButton") + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + .frame(width: 360) + .background(Color(designSystemColor: .containerFillPrimary)) + } +} + +// MARK: - Convenience Initializer + +extension PermissionAuthorizationSwiftUIView { + init( + domain: String, + permissionType: PermissionAuthorizationType, + onDeny: @escaping () -> Void, + onAlwaysDeny: @escaping () -> Void, + onAllow: @escaping () -> Void, + onAlwaysAllow: @escaping () -> Void + ) { + self.domain = domain + self.permissionType = permissionType + self.onDeny = onDeny + self.onAlwaysDeny = onAlwaysDeny + self.onAllow = onAllow + self.onAlwaysAllow = onAlwaysAllow + self.systemPermissionManager = SystemPermissionManager() + } +} + +// MARK: - PermissionType UI Extensions + +extension PermissionType { + + /// Whether this permission type requires a two-step authorization flow (system permission first, then website permission) + var requiresSystemPermission: Bool { + switch self { + case .geolocation: + return true + case .camera, .microphone, .popups, .externalScheme: + return false + } + } + + /// Text shown when system permission was previously disabled (prefix before link) + var systemPermissionDisabledText: String { + switch self { + case .geolocation: + return UserText.permissionSystemLocationDisabled + case .camera, .microphone, .popups, .externalScheme: + return "" + } + } + + /// Link text for opening System Settings + var systemSettingsLinkText: String { + switch self { + case .geolocation: + return UserText.permissionSystemSettingsLocation + case .camera, .microphone, .popups, .externalScheme: + return "" + } + } + + /// URL to open the relevant System Settings pane + var systemSettingsURL: URL? { + switch self { + case .geolocation: + return URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices") + case .camera, .microphone, .popups, .externalScheme: + return nil + } + } +} + +#if DEBUG +struct PermissionAuthorizationSwiftUIView_Previews: PreviewProvider { + static var previews: some View { + PermissionAuthorizationSwiftUIView( + domain: "apple.com", + permissionType: .geolocation, + onDeny: {}, + onAlwaysDeny: {}, + onAllow: {}, + onAlwaysAllow: {} + ) + .previewDisplayName("Geolocation - Two Step") + + PermissionAuthorizationSwiftUIView( + domain: "apple.com", + permissionType: .camera, + onDeny: {}, + onAlwaysDeny: {}, + onAllow: {}, + onAlwaysAllow: {} + ) + .previewDisplayName("Camera") + + PermissionAuthorizationSwiftUIView( + domain: "apple.com", + permissionType: .cameraAndMicrophone, + onDeny: {}, + onAlwaysDeny: {}, + onAllow: {}, + onAlwaysAllow: {} + ) + .previewDisplayName("Camera and Microphone") + } +} +#endif diff --git a/macOS/DuckDuckGo/Permissions/View/PermissionAuthorizationViewController.swift b/macOS/DuckDuckGo/Permissions/View/PermissionAuthorizationViewController.swift index 19a736e3c29..df1a8483484 100644 --- a/macOS/DuckDuckGo/Permissions/View/PermissionAuthorizationViewController.swift +++ b/macOS/DuckDuckGo/Permissions/View/PermissionAuthorizationViewController.swift @@ -17,6 +17,7 @@ // import Cocoa +import SwiftUI extension PermissionType { var localizedDescription: String { @@ -55,6 +56,8 @@ extension Array where Element == PermissionType { final class PermissionAuthorizationViewController: NSViewController { + let systemPermissionManager = SystemPermissionManager() + @IBOutlet var descriptionLabel: NSTextField! @IBOutlet var domainNameLabel: NSTextField! @IBOutlet var alwaysAllowCheckbox: NSButton! @@ -66,17 +69,54 @@ final class PermissionAuthorizationViewController: NSViewController { @IBOutlet weak var linkButton: LinkButton! @IBOutlet weak var allowButton: NSButton! + private var swiftUIHostingView: NSHostingView? + private let newPermissionView: Bool + weak var query: PermissionAuthorizationQuery? { didSet { - updateText() + if newPermissionView { + setupSwiftUIView() + } else { + updateText() + } + } + } + + // Programmatic initializer for SwiftUI mode + init(newPermissionView: Bool) { + self.newPermissionView = newPermissionView + super.init(nibName: nil, bundle: nil) + } + + // Storyboard initializer + required init?(coder: NSCoder) { + self.newPermissionView = false + super.init(coder: coder) + } + + override func loadView() { + if newPermissionView { + // Create a simple container view for SwiftUI + view = NSView() + } else { + // Load from nib/storyboard + super.loadView() } } override func viewDidLoad() { - updateText() + super.viewDidLoad() + + if newPermissionView { + setupSwiftUIView() + } else { + updateText() + } } override func viewWillAppear() { + guard !newPermissionView else { return } + alwaysAllowCheckbox.state = .off if query?.shouldShowCancelInsteadOfDeny == true { denyButton.title = UserText.cancel @@ -87,7 +127,8 @@ final class PermissionAuthorizationViewController: NSViewController { } private func updateText() { - guard isViewLoaded, + guard !newPermissionView, + isViewLoaded, let query = query, !query.permissions.isEmpty else { return } @@ -123,15 +164,18 @@ final class PermissionAuthorizationViewController: NSViewController { } @IBAction func alwaysAllowLabelClick(_ sender: Any) { + guard !newPermissionView else { return } alwaysAllowCheckbox.setNextState() } @IBAction func grantAction(_ sender: NSButton) { + guard !newPermissionView else { return } self.dismiss() query?.handleDecision(grant: true, remember: query!.shouldShowAlwaysAllowCheckbox && alwaysAllowCheckbox.state == .on) } @IBAction func denyAction(_ sender: NSButton) { + guard !newPermissionView else { return } self.dismiss() guard let query = query, !query.shouldShowCancelInsteadOfDeny @@ -141,6 +185,69 @@ final class PermissionAuthorizationViewController: NSViewController { } @IBAction func learnMoreAction(_ sender: NSButton) { + guard !newPermissionView else { return } Application.appDelegate.windowControllersManager.show(url: "https://help.duckduckgo.com/privacy/device-location-services".url, source: .ui, newTab: true) } + + // MARK: - SwiftUI View Setup + + private func setupSwiftUIView() { + guard newPermissionView, let query = query, !query.permissions.isEmpty else { return } + + // Remove all existing subviews to ensure clean state + view.subviews.forEach { $0.removeFromSuperview() } + swiftUIHostingView = nil + + let permissionType = PermissionAuthorizationType(from: query.permissions) + let swiftUIView = PermissionAuthorizationSwiftUIView( + domain: query.domain, + permissionType: permissionType, + onDeny: { [weak self] in + self?.handleDeny() + }, + onAlwaysDeny: { [weak self] in + self?.handleAlwaysDeny() + }, + onAllow: { [weak self] in + self?.handleAllow() + }, + onAlwaysAllow: { [weak self] in + self?.handleAlwaysAllow() + }, + systemPermissionManager: systemPermissionManager + ) + + let hostingView = NSHostingView(rootView: swiftUIView) + hostingView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(hostingView) + + NSLayoutConstraint.activate([ + hostingView.topAnchor.constraint(equalTo: view.topAnchor), + hostingView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + hostingView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + + swiftUIHostingView = hostingView + } + + private func handleDeny() { + dismiss() + query?.handleDecision(grant: false, remember: nil) + } + + private func handleAlwaysDeny() { + dismiss() + query?.handleDecision(grant: false, remember: true) + } + + private func handleAllow() { + dismiss() + query?.handleDecision(grant: true, remember: nil) + } + + private func handleAlwaysAllow() { + dismiss() + query?.handleDecision(grant: true, remember: true) + } } diff --git a/macOS/DuckDuckGo/Permissions/View/PermissionCenterView.swift b/macOS/DuckDuckGo/Permissions/View/PermissionCenterView.swift new file mode 100644 index 00000000000..86bb017f4a1 --- /dev/null +++ b/macOS/DuckDuckGo/Permissions/View/PermissionCenterView.swift @@ -0,0 +1,226 @@ +// +// PermissionCenterView.swift +// +// Copyright © 2025 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import AppKit +import SwiftUI + +// MARK: - PermissionCenterView + +struct PermissionCenterView: View { + + @ObservedObject var viewModel: PermissionCenterViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header + Text(String(format: UserText.permissionCenterTitle, viewModel.domain)) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(Color(designSystemColor: .textPrimary)) + .padding(.leading, 20) + .padding(.trailing, 16) + .padding(.top, 16) + .padding(.bottom, 12) + + // Permission rows in a rounded container + VStack(spacing: 0) { + ForEach(viewModel.permissionItems) { item in + PermissionRowView( + item: item, + onDecisionChanged: { decision in + viewModel.setDecision(decision, for: item.permissionType) + }, + onRemove: { + viewModel.removePermission(item.permissionType) + } + ) + + if item.id != viewModel.permissionItems.last?.id { + Divider() + } + } + } + .background(Color(designSystemColor: .containerFillTertiary)) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color(NSColor.separatorColor), lineWidth: 1) + ) + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + .frame(width: 360) + .background(Color(designSystemColor: .containerFillPrimary)) + } +} + +// MARK: - PermissionRowView + +struct PermissionRowView: View { + + let item: PermissionCenterItem + let onDecisionChanged: (PersistedPermissionDecision) -> Void + let onRemove: () -> Void + + @State private var isRemoveButtonHovered = false + @State private var currentDecision: PersistedPermissionDecision + + init(item: PermissionCenterItem, onDecisionChanged: @escaping (PersistedPermissionDecision) -> Void, onRemove: @escaping () -> Void) { + self.item = item + self.onDecisionChanged = onDecisionChanged + self.onRemove = onRemove + self._currentDecision = State(initialValue: item.decision) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Main row + HStack(spacing: 8) { + // Icon + permissionIcon + .frame(width: 24, height: 24) + + // Permission name + Text(item.displayName) + .font(.system(size: 13)) + .foregroundColor(Color(designSystemColor: .textPrimary)) + + Spacer() + + // Decision dropdown + decisionPopUpButton + + // Remove button with hover effect + Button(action: onRemove) { + Image(systemName: "xmark") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(Color(designSystemColor: .textSecondary)) + } + .buttonStyle(PlainButtonStyle()) + .frame(width: 16, height: 16) + .background( + RoundedRectangle(cornerRadius: 3) + .fill(isRemoveButtonHovered ? Color(.buttonMouseOver) : Color.clear) + ) + .onHover { hovering in + isRemoveButtonHovered = hovering + } + } + .padding(.leading, 12) + .padding(.trailing, 12) + .padding(.vertical, 12) + + // External scheme description (if applicable) + if let description = item.externalSchemeDescription { + Text(description) + .font(.system(size: 12)) + .foregroundColor(Color(designSystemColor: .textSecondary)) + .padding(.leading, 44) + .padding(.trailing, 12) + .padding(.bottom, 12) + } + + // System disabled warning (if applicable) + if item.isSystemDisabled { + systemDisabledWarning + .padding(.leading, 44) + .padding(.trailing, 12) + .padding(.bottom, 12) + } + } + .background(item.isSystemDisabled ? Color(.permissionWarningBackground) : Color.clear) + .onChange(of: currentDecision) { newValue in + onDecisionChanged(newValue) + } + .onChange(of: item.decision) { newValue in + // Sync local state when the item's decision changes from external source + if currentDecision != newValue { + currentDecision = newValue + } + } + } + + // MARK: - Subviews + + @ViewBuilder + private var permissionIcon: some View { + switch item.permissionType { + case .camera: + Image(systemName: "video.fill") + .foregroundColor(Color(NSColor.systemRed)) + case .microphone: + Image(systemName: "mic.fill") + .foregroundColor(Color(designSystemColor: .textSecondary)) + case .geolocation: + Image(systemName: "location.fill") + .foregroundColor(Color(designSystemColor: .textSecondary)) + case .popups: + Image(systemName: "rectangle.on.rectangle") + .foregroundColor(Color(designSystemColor: .textSecondary)) + case .externalScheme: + Image(systemName: "arrow.up.forward.app") + .foregroundColor(Color(designSystemColor: .textSecondary)) + } + } + + private var decisionPopUpButton: some View { + NSPopUpButtonView(selection: $currentDecision) { + let button = NSPopUpButton() + button.bezelStyle = .accessoryBarAction + button.isBordered = true + button.setContentHuggingPriority(.defaultHigh, for: .horizontal) + + for decision in [PersistedPermissionDecision.ask, .allow, .deny] { + let item = button.menu?.addItem(withTitle: decisionDisplayText(for: decision), action: nil, keyEquivalent: "") + item?.representedObject = decision + } + + return button + } + .fixedSize() + } + + private func decisionDisplayText(for decision: PersistedPermissionDecision) -> String { + switch decision { + case .ask: + return UserText.permissionCenterAlwaysAsk + case .allow: + return UserText.permissionCenterAlwaysAllow + case .deny: + return UserText.permissionCenterNeverAllow + } + } + + private var systemDisabledWarning: some View { + (Text(item.permissionType.systemPermissionDisabledText) + .font(.system(size: 12)) + .foregroundColor(Color(designSystemColor: .textSecondary)) + + Text(item.permissionType.systemSettingsLinkText) + .font(.system(size: 12)) + .foregroundColor(.accentColor)) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .onTapGesture { + openSystemSettings() + } + } + + private func openSystemSettings() { + guard let url = item.permissionType.systemSettingsURL else { return } + NSWorkspace.shared.open(url) + } +} diff --git a/macOS/DuckDuckGo/Permissions/View/PermissionCenterViewController.swift b/macOS/DuckDuckGo/Permissions/View/PermissionCenterViewController.swift new file mode 100644 index 00000000000..7feb6aa7f67 --- /dev/null +++ b/macOS/DuckDuckGo/Permissions/View/PermissionCenterViewController.swift @@ -0,0 +1,82 @@ +// +// PermissionCenterViewController.swift +// +// Copyright © 2025 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import AppKit +import SwiftUI + +final class PermissionCenterViewController: NSViewController { + + let viewModel: PermissionCenterViewModel + private var hostingView: NSHostingView? + + init(viewModel: PermissionCenterViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + view = NSView() + } + + override func viewDidLoad() { + super.viewDidLoad() + setupHostingView() + } + + private func setupHostingView() { + let swiftUIView = PermissionCenterView(viewModel: viewModel) + let hostingView = NSHostingView(rootView: swiftUIView) + hostingView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(hostingView) + + NSLayoutConstraint.activate([ + hostingView.topAnchor.constraint(equalTo: view.topAnchor), + hostingView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + hostingView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + + self.hostingView = hostingView + } +} + +// MARK: - PermissionCenterPopover + +final class PermissionCenterPopover: NSPopover { + + let viewController: PermissionCenterViewController + + init(viewModel: PermissionCenterViewModel) { + self.viewController = PermissionCenterViewController(viewModel: viewModel) + super.init() + + self.contentViewController = viewController + self.behavior = .transient + self.animates = true + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/macOS/DuckDuckGo/Permissions/View/PermissionContextMenu.swift b/macOS/DuckDuckGo/Permissions/View/PermissionContextMenu.swift index cc16b5108f8..b9d2ce58ee8 100644 --- a/macOS/DuckDuckGo/Permissions/View/PermissionContextMenu.swift +++ b/macOS/DuckDuckGo/Permissions/View/PermissionContextMenu.swift @@ -170,7 +170,7 @@ final class PermissionContextMenu: NSMenu { // only show one persistence option per permission type let reduced = permissions.reduce(into: [:], { $0[$1.key] = $1.value }) for (permission, state) in reduced { - guard permission.canPersistGrantedDecision || permission.canPersistDeniedDecision else { continue } + guard permission.canPersistGrantedDecision(featureFlagger: featureFlagger) || permission.canPersistDeniedDecision(featureFlagger: featureFlagger) else { continue } if case .disabled = state { continue } addSeparator(if: numberOfItems > 0) @@ -188,11 +188,11 @@ final class PermissionContextMenu: NSMenu { let isNotifyChecked = (permission == .popups && hasTemporaryPopupAllowance) ? false : (persistedValue == .ask) addItem(.alwaysAsk(permission, on: domain, target: self, isChecked: isNotifyChecked)) - if permission.canPersistGrantedDecision { + if permission.canPersistGrantedDecision(featureFlagger: featureFlagger) { addItem(.alwaysAllow(permission, on: domain, target: self, isChecked: persistedValue == .allow)) } - if permission.canPersistDeniedDecision { + if permission.canPersistDeniedDecision(featureFlagger: featureFlagger) { addItem(.alwaysDeny(permission, on: domain, target: self, isChecked: persistedValue == .deny)) } } diff --git a/macOS/DuckDuckGo/Permissions/ViewModel/PermissionCenterViewModel.swift b/macOS/DuckDuckGo/Permissions/ViewModel/PermissionCenterViewModel.swift new file mode 100644 index 00000000000..d723615169f --- /dev/null +++ b/macOS/DuckDuckGo/Permissions/ViewModel/PermissionCenterViewModel.swift @@ -0,0 +1,139 @@ +// +// PermissionCenterViewModel.swift +// +// Copyright © 2025 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation + +/// Represents a permission item displayed in the Permission Center +struct PermissionCenterItem: Identifiable { + let id: PermissionType + let permissionType: PermissionType + let domain: String + var decision: PersistedPermissionDecision + var isSystemDisabled: Bool + + var displayName: String { + if case .externalScheme = permissionType { + return UserText.permissionCenterExternalApps + } + return permissionType.localizedDescription + } + + /// Additional description for external schemes (e.g., "zoom.us to open "zoomus" links") + var externalSchemeDescription: String? { + guard case .externalScheme(let scheme) = permissionType else { return nil } + return String(format: UserText.permissionCenterExternalSchemeDescription, domain, scheme) + } +} + +/// ViewModel for the Permission Center popover +final class PermissionCenterViewModel: ObservableObject { + + // MARK: - Published Properties + + @Published private(set) var domain: String + @Published private(set) var permissionItems: [PermissionCenterItem] = [] + + // MARK: - Dependencies + + private let permissionManager: PermissionManagerProtocol + private let systemPermissionManager: SystemPermissionManagerProtocol + private let usedPermissions: Permissions + private let removePermissionFromTab: (PermissionType) -> Void + private let dismissPopover: () -> Void + private var cancellables = Set() + private var removedPermissions = Set() + + // MARK: - Initialization + + init( + domain: String, + usedPermissions: Permissions, + permissionManager: PermissionManagerProtocol, + removePermission: @escaping (PermissionType) -> Void, + dismissPopover: @escaping () -> Void, + systemPermissionManager: SystemPermissionManagerProtocol = SystemPermissionManager() + ) { + self.domain = domain + self.usedPermissions = usedPermissions + self.permissionManager = permissionManager + self.removePermissionFromTab = removePermission + self.dismissPopover = dismissPopover + self.systemPermissionManager = systemPermissionManager + + loadPermissions() + subscribeToPermissionChanges() + } + + // MARK: - Public Methods + + /// Updates the decision for a permission type + func setDecision(_ decision: PersistedPermissionDecision, for permissionType: PermissionType) { + permissionManager.setPermission(decision, forDomain: domain, permissionType: permissionType) + } + + /// Removes the permission completely (from webview, tracking, and storage) + func removePermission(_ permissionType: PermissionType) { + // Track removed permissions to prevent re-adding on reload + removedPermissions.insert(permissionType) + removePermissionFromTab(permissionType) + // Also remove from UI immediately + permissionItems.removeAll { $0.permissionType == permissionType } + + // Dismiss popover if no permissions left + if permissionItems.isEmpty { + dismissPopover() + } + } + + // MARK: - Private Methods + + private func loadPermissions() { + permissionItems = usedPermissions.keys + .filter { !removedPermissions.contains($0) } + .map { permissionType in + let decision = permissionManager.permission(forDomain: domain, permissionType: permissionType) + let isSystemDisabled = checkSystemDisabled(for: permissionType) + + return PermissionCenterItem( + id: permissionType, + permissionType: permissionType, + domain: domain, + decision: decision, + isSystemDisabled: isSystemDisabled + ) + }.sorted { $0.permissionType.rawValue < $1.permissionType.rawValue } + } + + private func checkSystemDisabled(for permissionType: PermissionType) -> Bool { + guard permissionType.requiresSystemPermission else { return false } + + let authState = systemPermissionManager.authorizationState(for: permissionType) + return authState == .denied || authState == .restricted || authState == .systemDisabled + } + + private func subscribeToPermissionChanges() { + permissionManager.permissionPublisher + .filter { [weak self] in $0.domain == self?.domain } + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.loadPermissions() + } + .store(in: &cancellables) + } +} diff --git a/macOS/DuckDuckGo/PrivacyDashboard/PrivacyDashboardPermissionHandler.swift b/macOS/DuckDuckGo/PrivacyDashboard/PrivacyDashboardPermissionHandler.swift index 88776b9d2e6..560badcb124 100644 --- a/macOS/DuckDuckGo/PrivacyDashboard/PrivacyDashboardPermissionHandler.swift +++ b/macOS/DuckDuckGo/PrivacyDashboard/PrivacyDashboardPermissionHandler.swift @@ -18,17 +18,23 @@ import Foundation import Combine +import FeatureFlags import PrivacyDashboard +import BrowserServicesKit +import AppKit typealias PrivacyDashboardPermissionAuthorizationState = [(permission: PermissionType, state: PermissionAuthorizationState)] final class PrivacyDashboardPermissionHandler { - init(permissionManager: PermissionManagerProtocol) { + init(permissionManager: PermissionManagerProtocol, + featureFlagger: FeatureFlagger = NSApp.delegateTyped.featureFlagger) { self.permissionManager = permissionManager + self.featureFlagger = featureFlagger } private let permissionManager: PermissionManagerProtocol + private let featureFlagger: FeatureFlagger private weak var tabViewModel: TabViewModel? private var onPermissionChange: (([AllowedPermission]) -> Void)? private var cancellables = Set() @@ -61,6 +67,13 @@ final class PrivacyDashboardPermissionHandler { } private func updatePermissions() { + // Skip permission updates when new permission view is enabled + // Permission management is handled by the new Permission Center + if featureFlagger.isFeatureOn(.newPermissionView) { + onPermissionChange?([]) + return + } + guard let usedPermissions = tabViewModel?.usedPermissions else { assertionFailure("PrivacyDashboardViewController: tabViewModel not set") return @@ -102,9 +115,9 @@ final class PrivacyDashboardPermissionHandler { // don't show Permanently Allow if can't persist Granted Decision switch decision { case .grant: - guard item.permission.canPersistGrantedDecision else { return nil } + guard item.permission.canPersistGrantedDecision(featureFlagger: featureFlagger) else { return nil } case .deny: - guard item.permission.canPersistDeniedDecision else { return nil } + guard item.permission.canPersistDeniedDecision(featureFlagger: featureFlagger) else { return nil } case .ask: break } return [ diff --git a/macOS/DuckDuckGo/Tab/Model/Tab.swift b/macOS/DuckDuckGo/Tab/Model/Tab.swift index 3a523c67904..dda4fc80d57 100644 --- a/macOS/DuckDuckGo/Tab/Model/Tab.swift +++ b/macOS/DuckDuckGo/Tab/Model/Tab.swift @@ -301,7 +301,8 @@ protocol TabDelegate: ContentOverlayUserScriptDelegate { webView.setAccessibilityIdentifier("WebView") permissions = PermissionModel(permissionManager: permissionManager, - geolocationService: geolocationService) + geolocationService: geolocationService, + featureFlagger: featureFlagger) let userContentControllerPromise = Future.promise() let userScriptsPublisher = userContentControllerPromise.future diff --git a/macOS/UnitTests/Permissions/PermissionAuthorizationTypeTests.swift b/macOS/UnitTests/Permissions/PermissionAuthorizationTypeTests.swift new file mode 100644 index 00000000000..2b604c405b1 --- /dev/null +++ b/macOS/UnitTests/Permissions/PermissionAuthorizationTypeTests.swift @@ -0,0 +1,211 @@ +// +// PermissionAuthorizationTypeTests.swift +// +// Copyright © 2025 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import DuckDuckGo_Privacy_Browser + +final class PermissionAuthorizationTypeTests: XCTestCase { + + // MARK: - Initialization Tests + + func testInitFromSingleCameraPermission() { + let type = PermissionAuthorizationType(from: [.camera]) + XCTAssertEqual(type, .camera) + } + + func testInitFromSingleMicrophonePermission() { + let type = PermissionAuthorizationType(from: [.microphone]) + XCTAssertEqual(type, .microphone) + } + + func testInitFromSingleGeolocationPermission() { + let type = PermissionAuthorizationType(from: [.geolocation]) + XCTAssertEqual(type, .geolocation) + } + + func testInitFromSinglePopupsPermission() { + let type = PermissionAuthorizationType(from: [.popups]) + XCTAssertEqual(type, .popups) + } + + func testInitFromSingleExternalSchemePermission() { + let type = PermissionAuthorizationType(from: [.externalScheme(scheme: "zoom")]) + XCTAssertEqual(type, .externalScheme(scheme: "zoom")) + } + + func testInitFromCameraAndMicrophonePermissions() { + let type = PermissionAuthorizationType(from: [.camera, .microphone]) + XCTAssertEqual(type, .cameraAndMicrophone) + } + + func testInitFromMicrophoneAndCameraPermissions_OrderIndependent() { + let type = PermissionAuthorizationType(from: [.microphone, .camera]) + XCTAssertEqual(type, .cameraAndMicrophone) + } + + // MARK: - requiresSystemPermission Tests + + func testRequiresSystemPermission_Geolocation_ReturnsTrue() { + XCTAssertTrue(PermissionAuthorizationType.geolocation.requiresSystemPermission) + } + + func testRequiresSystemPermission_Camera_ReturnsFalse() { + XCTAssertFalse(PermissionAuthorizationType.camera.requiresSystemPermission) + } + + func testRequiresSystemPermission_Microphone_ReturnsFalse() { + XCTAssertFalse(PermissionAuthorizationType.microphone.requiresSystemPermission) + } + + func testRequiresSystemPermission_CameraAndMicrophone_ReturnsFalse() { + XCTAssertFalse(PermissionAuthorizationType.cameraAndMicrophone.requiresSystemPermission) + } + + func testRequiresSystemPermission_Popups_ReturnsFalse() { + XCTAssertFalse(PermissionAuthorizationType.popups.requiresSystemPermission) + } + + func testRequiresSystemPermission_ExternalScheme_ReturnsFalse() { + XCTAssertFalse(PermissionAuthorizationType.externalScheme(scheme: "zoom").requiresSystemPermission) + } + + // MARK: - usesPermanentDecisions Tests + + func testUsesPermanentDecisions_AllCases_ReturnTrue() { + XCTAssertTrue(PermissionAuthorizationType.camera.usesPermanentDecisions) + XCTAssertTrue(PermissionAuthorizationType.microphone.usesPermanentDecisions) + XCTAssertTrue(PermissionAuthorizationType.cameraAndMicrophone.usesPermanentDecisions) + XCTAssertTrue(PermissionAuthorizationType.geolocation.usesPermanentDecisions) + XCTAssertTrue(PermissionAuthorizationType.popups.usesPermanentDecisions) + XCTAssertTrue(PermissionAuthorizationType.externalScheme(scheme: "zoom").usesPermanentDecisions) + } + + // MARK: - asPermissionType Tests + + func testAsPermissionType_Camera() { + XCTAssertEqual(PermissionAuthorizationType.camera.asPermissionType, .camera) + } + + func testAsPermissionType_Microphone() { + XCTAssertEqual(PermissionAuthorizationType.microphone.asPermissionType, .microphone) + } + + func testAsPermissionType_CameraAndMicrophone_ReturnsCamera() { + // cameraAndMicrophone maps to .camera for system permission checks + XCTAssertEqual(PermissionAuthorizationType.cameraAndMicrophone.asPermissionType, .camera) + } + + func testAsPermissionType_Geolocation() { + XCTAssertEqual(PermissionAuthorizationType.geolocation.asPermissionType, .geolocation) + } + + func testAsPermissionType_Popups() { + XCTAssertEqual(PermissionAuthorizationType.popups.asPermissionType, .popups) + } + + func testAsPermissionType_ExternalScheme() { + XCTAssertEqual(PermissionAuthorizationType.externalScheme(scheme: "zoom").asPermissionType, .externalScheme(scheme: "zoom")) + } + + // MARK: - localizedDescription Tests + + func testLocalizedDescription_Camera() { + XCTAssertEqual(PermissionAuthorizationType.camera.localizedDescription, UserText.permissionCamera) + } + + func testLocalizedDescription_Microphone() { + XCTAssertEqual(PermissionAuthorizationType.microphone.localizedDescription, UserText.permissionMicrophone) + } + + func testLocalizedDescription_CameraAndMicrophone() { + XCTAssertEqual(PermissionAuthorizationType.cameraAndMicrophone.localizedDescription, UserText.permissionCameraAndMicrophone) + } + + func testLocalizedDescription_Geolocation() { + XCTAssertEqual(PermissionAuthorizationType.geolocation.localizedDescription, UserText.permissionGeolocation) + } + + func testLocalizedDescription_Popups() { + XCTAssertEqual(PermissionAuthorizationType.popups.localizedDescription, UserText.permissionPopups) + } + + // MARK: - systemSettingsURL Tests + + func testSystemSettingsURL_Geolocation_ReturnsValidURL() { + let url = PermissionAuthorizationType.geolocation.systemSettingsURL + XCTAssertNotNil(url) + XCTAssertEqual(url?.absoluteString, "x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices") + } + + func testSystemSettingsURL_NonGeolocation_ReturnsNil() { + XCTAssertNil(PermissionAuthorizationType.camera.systemSettingsURL) + XCTAssertNil(PermissionAuthorizationType.microphone.systemSettingsURL) + XCTAssertNil(PermissionAuthorizationType.cameraAndMicrophone.systemSettingsURL) + XCTAssertNil(PermissionAuthorizationType.popups.systemSettingsURL) + XCTAssertNil(PermissionAuthorizationType.externalScheme(scheme: "zoom").systemSettingsURL) + } + + // MARK: - Two-Step UI String Tests + + func testSystemPermissionEnableText_Geolocation() { + XCTAssertEqual(PermissionAuthorizationType.geolocation.systemPermissionEnableText, UserText.permissionSystemLocationEnable) + } + + func testSystemPermissionEnableText_NonGeolocation_ReturnsEmpty() { + XCTAssertEqual(PermissionAuthorizationType.camera.systemPermissionEnableText, "") + XCTAssertEqual(PermissionAuthorizationType.microphone.systemPermissionEnableText, "") + XCTAssertEqual(PermissionAuthorizationType.cameraAndMicrophone.systemPermissionEnableText, "") + XCTAssertEqual(PermissionAuthorizationType.popups.systemPermissionEnableText, "") + XCTAssertEqual(PermissionAuthorizationType.externalScheme(scheme: "zoom").systemPermissionEnableText, "") + } + + func testSystemPermissionWaitingText_Geolocation() { + XCTAssertEqual(PermissionAuthorizationType.geolocation.systemPermissionWaitingText, UserText.permissionSystemLocationWaiting) + } + + func testSystemPermissionEnabledText_Geolocation() { + XCTAssertEqual(PermissionAuthorizationType.geolocation.systemPermissionEnabledText, UserText.permissionSystemLocationEnabled) + } + + func testSystemPermissionDisabledText_Geolocation() { + XCTAssertEqual(PermissionAuthorizationType.geolocation.systemPermissionDisabledText, UserText.permissionSystemLocationDisabled) + } + + func testSystemSettingsLinkText_Geolocation() { + XCTAssertEqual(PermissionAuthorizationType.geolocation.systemSettingsLinkText, UserText.permissionSystemSettingsLocation) + } +} + +// MARK: - Equatable Conformance for Tests + +extension PermissionAuthorizationType: Equatable { + public static func == (lhs: PermissionAuthorizationType, rhs: PermissionAuthorizationType) -> Bool { + switch (lhs, rhs) { + case (.camera, .camera), + (.microphone, .microphone), + (.cameraAndMicrophone, .cameraAndMicrophone), + (.geolocation, .geolocation), + (.popups, .popups): + return true + case (.externalScheme(let lhsScheme), .externalScheme(let rhsScheme)): + return lhsScheme == rhsScheme + default: + return false + } + } +} diff --git a/macOS/UnitTests/Permissions/PermissionManagerTests.swift b/macOS/UnitTests/Permissions/PermissionManagerTests.swift index c0b7dcfae6b..f74f543f655 100644 --- a/macOS/UnitTests/Permissions/PermissionManagerTests.swift +++ b/macOS/UnitTests/Permissions/PermissionManagerTests.swift @@ -22,17 +22,20 @@ import XCTest final class PermissionManagerTests: XCTestCase { var store: PermissionStoreMock! + var featureFlagger: MockFeatureFlagger! lazy var manager: PermissionManager! = { - PermissionManager(store: store) + PermissionManager(store: store, featureFlagger: featureFlagger) }() override func setUp() { store = PermissionStoreMock() + featureFlagger = MockFeatureFlagger() } override func tearDown() { manager = nil store = nil + featureFlagger = nil } func testWhenPermissionManagerInitializedThenPermissionsAreLoaded() { diff --git a/macOS/UnitTests/Permissions/PermissionModelTests.swift b/macOS/UnitTests/Permissions/PermissionModelTests.swift index 1a5abb23a0d..c75b9e937f6 100644 --- a/macOS/UnitTests/Permissions/PermissionModelTests.swift +++ b/macOS/UnitTests/Permissions/PermissionModelTests.swift @@ -33,6 +33,7 @@ final class PermissionModelTests: XCTestCase { var permissionManagerMock: PermissionManagerMock! var geolocationServiceMock: GeolocationServiceMock! var geolocationProviderMock: GeolocationProviderMock! + var featureFlaggerMock: MockFeatureFlagger! static var processPool: WKProcessPool! var webView: WebViewMock! var model: PermissionModel! @@ -60,6 +61,7 @@ final class PermissionModelTests: XCTestCase { permissionManagerMock = PermissionManagerMock() geolocationServiceMock = GeolocationServiceMock() + featureFlaggerMock = MockFeatureFlagger() let configuration = WKWebViewConfiguration(processPool: Self.processPool) webView = WebViewMock(frame: NSRect(x: 0, y: 0, width: 50, height: 50), configuration: configuration) @@ -69,7 +71,8 @@ final class PermissionModelTests: XCTestCase { webView.configuration.processPool.geolocationProvider = geolocationProviderMock model = PermissionModel(webView: webView, permissionManager: permissionManagerMock, - geolocationService: geolocationServiceMock) + geolocationService: geolocationServiceMock, + featureFlagger: featureFlaggerMock) AVCaptureDeviceMock.authorizationStatuses = nil } @@ -79,6 +82,7 @@ final class PermissionModelTests: XCTestCase { webView = nil permissionManagerMock = nil geolocationServiceMock = nil + featureFlaggerMock = nil pixelKit = nil geolocationProviderMock = nil model = nil diff --git a/macOS/UnitTests/TabExtensionsTests/PopupHandlingTabExtensionTests.swift b/macOS/UnitTests/TabExtensionsTests/PopupHandlingTabExtensionTests.swift index ead312a8da1..eaa235ee375 100644 --- a/macOS/UnitTests/TabExtensionsTests/PopupHandlingTabExtensionTests.swift +++ b/macOS/UnitTests/TabExtensionsTests/PopupHandlingTabExtensionTests.swift @@ -46,7 +46,7 @@ final class PopupHandlingTabExtensionTests: XCTestCase { mockFeatureFlagger = MockFeatureFlagger() mockPopupBlockingConfig = MockPopupBlockingConfiguration() testPermissionManager = TestPermissionManager() - mockPermissionModel = PermissionModel(permissionManager: testPermissionManager) + mockPermissionModel = PermissionModel(permissionManager: testPermissionManager, featureFlagger: mockFeatureFlagger) webView = WebView() configuration = WKWebViewConfiguration() windowFeatures = WKWindowFeatures() @@ -2225,6 +2225,10 @@ class TestPermissionManager: PermissionManagerProtocol { } } + func removePermission(forDomain domain: String, permissionType: PermissionType) { + persistedPermissions[domain]?[permissionType] = nil + } + var persistedPermissionTypes: Set { return [] } }