diff --git a/DuckDuckGo/Common/Extensions/BundleExtension.swift b/DuckDuckGo/Common/Extensions/BundleExtension.swift index 79bc0f213a..7fceb79361 100644 --- a/DuckDuckGo/Common/Extensions/BundleExtension.swift +++ b/DuckDuckGo/Common/Extensions/BundleExtension.swift @@ -110,6 +110,19 @@ extension Bundle { return appGroup } + var isInApplicationsDirectory: Bool { + let directoryPaths = NSSearchPathForDirectoriesInDomains(.applicationDirectory, .localDomainMask, true) + + guard let applicationsPath = directoryPaths.first else { + // Default to true to be safe. In theory this should always return a valid path and the else branch will never be run, but some app logic + // depends on this check in order to allow users to proceed, so we should avoid blocking them in case this assumption is ever wrong. + return true + } + + let path = self.bundlePath + return path.hasPrefix(applicationsPath) + } + } enum BundleGroup { diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift index 4739045b13..340c5ad693 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift @@ -95,6 +95,9 @@ final class NetworkProtectionDebugMenu: NSMenu { NSMenuItem(title: "Send Test Notification", action: #selector(NetworkProtectionDebugMenu.sendTestNotification)) .targetting(self) + NSMenuItem(title: "Log Feedback Metadata to Console", action: #selector(NetworkProtectionDebugMenu.logFeedbackMetadataToConsole)) + .targetting(self) + NSMenuItem(title: "Onboarding") .submenu(NetworkProtectionOnboardingMenu()) @@ -236,6 +239,17 @@ final class NetworkProtectionDebugMenu: NSMenu { } } + /// Prints feedback collector metadata to the console. This is to facilitate easier iteration of the metadata collector, without having to go through the feedback form flow every time. + /// + @objc func logFeedbackMetadataToConsole(_ sender: Any?) { + Task { @MainActor in + let collector = DefaultVPNMetadataCollector() + let metadata = await collector.collectMetadata() + + print(metadata.toPrettyPrintedJSON()!) + } + } + /// Sets the selected server. /// @objc func setSelectedServer(_ menuItem: NSMenuItem) { diff --git a/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift b/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift index bf81941a8f..271c09464a 100644 --- a/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift +++ b/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift @@ -32,12 +32,15 @@ struct VPNMetadata: Encodable { let appVersion: String let lastVersionRun: String let isInternalUser: Bool + let isAdminUser: String + let isInApplicationsDirectory: Bool } struct DeviceInfo: Encodable { let osVersion: String let buildFlavor: String let lowPowerModeEnabled: Bool + let cpuArchitecture: String } struct NetworkInfo: Encodable { @@ -151,11 +154,19 @@ final class DefaultVPNMetadataCollector: VPNMetadataCollector { // MARK: - Metadata Collection private func collectAppInfoMetadata() -> VPNMetadata.AppInfo { - let appVersion = AppVersion.shared.versionNumber + let appVersion = AppVersion.shared.versionAndBuildNumber let versionStore = NetworkProtectionLastVersionRunStore() let isInternalUser = NSApp.delegateTyped.internalUserDecider.isInternalUser + let isAdminUser = isAdminUser() + let isInApplicationsDirectory = Bundle.main.isInApplicationsDirectory - return .init(appVersion: appVersion, lastVersionRun: versionStore.lastVersionRun ?? "Unknown", isInternalUser: isInternalUser) + return .init( + appVersion: appVersion, + lastVersionRun: versionStore.lastVersionRun ?? "Unknown", + isInternalUser: isInternalUser, + isAdminUser: isAdminUser, + isInApplicationsDirectory: isInApplicationsDirectory + ) } private func collectDeviceInfoMetadata() -> VPNMetadata.DeviceInfo { @@ -169,7 +180,23 @@ final class DefaultVPNMetadataCollector: VPNMetadataCollector { lowPowerModeEnabled = false } - return .init(osVersion: osVersion, buildFlavor: buildFlavor, lowPowerModeEnabled: lowPowerModeEnabled) + let architecture = getMachineArchitecture() + + return .init(osVersion: osVersion, buildFlavor: buildFlavor, lowPowerModeEnabled: lowPowerModeEnabled, cpuArchitecture: architecture) + } + + private func getMachineArchitecture() -> String { + #if arch(arm) + return "arm" + #elseif arch(arm64) + return "arm64" + #elseif arch(i386) + return "i386" + #elseif arch(x86_64) + return "x86_64" + #else + return "unknown" + #endif } func collectNetworkInformation() async -> VPNMetadata.NetworkInfo { @@ -249,4 +276,59 @@ final class DefaultVPNMetadataCollector: VPNMetadataCollector { } +// MARK: - Admin User + +private enum AdminQueryError: Error { + case queryExecutionFailed + case queriedWithoutResult +} + +extension VPNMetadataCollector { + + private func getUser() throws -> CSIdentity? { + let query = CSIdentityQueryCreateForCurrentUser(kCFAllocatorDefault).takeRetainedValue() + let flags = CSIdentityQueryFlags() + + guard CSIdentityQueryExecute(query, flags, nil) else { + throw AdminQueryError.queryExecutionFailed + } + + let users = CSIdentityQueryCopyResults(query).takeRetainedValue() as? [CSIdentity] + return users?.first + } + + private func getAdminGroup() throws -> CSIdentity { + let privilegeGroup = "admin" as CFString + let authority = CSGetDefaultIdentityAuthority().takeRetainedValue() + let query = CSIdentityQueryCreateForName(kCFAllocatorDefault, + privilegeGroup, + kCSIdentityQueryStringEquals, + kCSIdentityClassGroup, + authority).takeRetainedValue() + let flags = CSIdentityQueryFlags() + + guard CSIdentityQueryExecute(query, flags, nil) else { throw AdminQueryError.queryExecutionFailed } + let groups = CSIdentityQueryCopyResults(query).takeRetainedValue() as? [CSIdentity] + + guard let adminGroup = groups?.first else { + throw AdminQueryError.queriedWithoutResult + } + + return adminGroup + } + + fileprivate func isAdminUser() -> String { + do { + let user = try self.getUser() + let group = try self.getAdminGroup() + + let isAdmin = CSIdentityIsMemberOfGroup(user, group) + return String(describing: isAdmin) + } catch { + return "error checking status: \(error.localizedDescription)" + } + } + +} + #endif diff --git a/UnitTests/VPNFeedbackForm/VPNFeedbackFormViewModelTests.swift b/UnitTests/VPNFeedbackForm/VPNFeedbackFormViewModelTests.swift index 481742c4b6..87e8dfea53 100644 --- a/UnitTests/VPNFeedbackForm/VPNFeedbackFormViewModelTests.swift +++ b/UnitTests/VPNFeedbackForm/VPNFeedbackFormViewModelTests.swift @@ -85,8 +85,21 @@ private class MockVPNMetadataCollector: VPNMetadataCollector { func collectMetadata() async -> VPNMetadata { self.collectedMetadata = true - let appInfo = VPNMetadata.AppInfo(appVersion: "1.2.3", lastVersionRun: "1.2.3", isInternalUser: false) - let deviceInfo = VPNMetadata.DeviceInfo(osVersion: "14.0.0", buildFlavor: "dmg", lowPowerModeEnabled: false) + let appInfo = VPNMetadata.AppInfo( + appVersion: "1.2.3", + lastVersionRun: "1.2.3", + isInternalUser: false, + isAdminUser: "true", + isInApplicationsDirectory: true + ) + + let deviceInfo = VPNMetadata.DeviceInfo( + osVersion: "14.0.0", + buildFlavor: "dmg", + lowPowerModeEnabled: false, + cpuArchitecture: "arm64" + ) + let networkInfo = VPNMetadata.NetworkInfo(currentPath: "path") let vpnState = VPNMetadata.VPNState(