import Foundation #if os(macOS) @preconcurrency import AppKit import Darwin import os.lock import SweetCookieKit enum BrowserProfileAccessIssue: Equatable { case accessDenied case unreadable } /// Browser presence + profile heuristics. /// /// Primary goal: avoid triggering unnecessary Keychain prompts (e.g. Chromium “Safe Storage”) by skipping /// cookie imports from browsers that have no profile data on disk. public final class BrowserDetection: Sendable { public static let defaultCacheTTL: TimeInterval = 60 * 10 private let cache = OSAllocatedUnfairLock<[CacheKey: CachedResult]>(initialState: [:]) private let homeDirectory: String private let cacheTTL: TimeInterval private let now: @Sendable () -> Date private let fileExists: @Sendable (String) -> Bool private let directoryContents: @Sendable (String) -> [String]? private let applicationURLs: @Sendable (String) -> [URL] private let profileAccessIssue: @Sendable (String) -> BrowserProfileAccessIssue? private struct CachedResult { let value: Bool let timestamp: Date } private enum ProbeKind: Int, Hashable { case appInstalled case usableProfileData case usableCookieStore } private struct CacheKey: Hashable { let browser: Browser let kind: ProbeKind } public convenience init( homeDirectory: String = FileManager.default.homeDirectoryForCurrentUser.path, cacheTTL: TimeInterval = BrowserDetection.defaultCacheTTL, now: @escaping @Sendable () -> Date = Date.init, fileExists: @escaping @Sendable (String) -> Bool = { path in FileManager.default.fileExists(atPath: path) }, directoryContents: @escaping @Sendable (String) -> [String]? = { path in try? FileManager.default.contentsOfDirectory(atPath: path) }) { self.init( homeDirectory: homeDirectory, cacheTTL: cacheTTL, now: now, fileExists: fileExists, directoryContents: directoryContents, applicationURLs: Self.registeredApplicationURLs, profileAccessIssue: Self.probeProfileAccessIssue) } init( homeDirectory: String, cacheTTL: TimeInterval, now: @escaping @Sendable () -> Date, fileExists: @escaping @Sendable (String) -> Bool, directoryContents: @escaping @Sendable (String) -> [String]?, applicationURLs: @escaping @Sendable (String) -> [URL], profileAccessIssue: @escaping @Sendable (String) -> BrowserProfileAccessIssue?) { self.homeDirectory = homeDirectory self.cacheTTL = cacheTTL self.now = now self.fileExists = fileExists self.directoryContents = directoryContents self.applicationURLs = applicationURLs self.profileAccessIssue = profileAccessIssue } public func isAppInstalled(_ browser: Browser) -> Bool { // Safari is always available on macOS. if browser == .safari { return true } return self.cachedBool(browser: browser, kind: .appInstalled) { self.detectAppInstalled(for: browser) } } /// Returns true when a cookie import attempt for this browser should be allowed. /// /// This is intentionally stricter than `isAppInstalled`: non-Safari browsers must still be installed, /// and Chromium browsers must have profile data (to avoid stale sources and unnecessary Keychain prompts). public func isCookieSourceAvailable(_ browser: Browser) -> Bool { let homeURL = URL(fileURLWithPath: self.homeDirectory, isDirectory: true) guard BrowserCookieAccessGate.cookieStoreAccessDecision(homeDirectories: [homeURL]) == .allowed else { return false } // Safari does not need Keychain decryption and can still yield cookies if its storage path changes. if browser == .safari { return true } // Do not cache app presence here: uninstalling a browser must remove it from the next import attempt. guard self.detectAppInstalled(for: browser) else { return false } // For browsers that typically require keychain-backed decryption, ensure an actual cookie store exists. if self.requiresProfileValidation(browser) { return self.hasUsableCookieStore(browser) } return self.hasUsableProfileData(browser) } func cookieSourceProfileAccessIssue(_ browser: Browser) -> BrowserProfileAccessIssue? { let homeURL = URL(fileURLWithPath: self.homeDirectory, isDirectory: true) guard BrowserCookieAccessGate.cookieStoreAccessDecision(homeDirectories: [homeURL]) == .allowed, browser != .safari, self.detectAppInstalled(for: browser), let profilePath = self.profilePath(for: browser, homeDirectory: self.homeDirectory) else { return nil } return self.profileAccessIssue(profilePath) } public func hasUsableProfileData(_ browser: Browser) -> Bool { self.cachedBool(browser: browser, kind: .usableProfileData) { self.detectUsableProfileData(for: browser) } } private func hasUsableCookieStore(_ browser: Browser) -> Bool { self.cachedBool(browser: browser, kind: .usableCookieStore) { self.detectUsableCookieStore(for: browser) } } public func clearCache() { self.cache.withLock { cache in cache.removeAll() } } // MARK: - Detection Logic private func cachedBool(browser: Browser, kind: ProbeKind, compute: () -> Bool) -> Bool { let now = self.now() let key = CacheKey(browser: browser, kind: kind) if let cached = self.cache.withLock({ cache in cache[key] }) { if now.timeIntervalSince(cached.timestamp) < self.cacheTTL { return cached.value } } let result = compute() self.cache.withLock { cache in cache[key] = CachedResult(value: result, timestamp: now) } return result } private func detectAppInstalled(for browser: Browser) -> Bool { let appPaths = self.applicationPaths(for: browser) for path in appPaths where self.fileExists(path) { return true } guard let appName = self.applicationName(for: browser) else { return false } return self.applicationURLs(appName).contains { self.fileExists($0.path) } } private func detectUsableProfileData(for browser: Browser) -> Bool { guard let profilePath = self.profilePath(for: browser, homeDirectory: self.homeDirectory) else { return false } guard self.fileExists(profilePath) else { return false } // For Chromium-based browsers (and Firefox), verify actual profile data exists. if self.requiresProfileValidation(browser) { return self.hasValidProfileDirectory(for: browser, at: profilePath) } return true } private func detectUsableCookieStore(for browser: Browser) -> Bool { guard let profilePath = self.profilePath(for: browser, homeDirectory: self.homeDirectory) else { return false } guard self.fileExists(profilePath) else { return false } return self.hasValidCookieStore(for: browser, at: profilePath) } private func applicationPaths(for browser: Browser) -> [String] { guard let appName = self.applicationName(for: browser) else { return [] } return [ "/Applications/\(appName).app", "\(self.homeDirectory)/Applications/\(appName).app", ] } private func applicationName(for browser: Browser) -> String? { browser.appBundleName } private static func registeredApplicationURLs(named appName: String) -> [URL] { let probeURL = URL(string: "https://chatgpt.com")! let bundleName = "\(appName).app" return NSWorkspace.shared.urlsForApplications(toOpen: probeURL) .filter { $0.lastPathComponent == bundleName } } private func profilePath(for browser: Browser, homeDirectory: String) -> String? { if browser == .safari { return "\(homeDirectory)/Library/Cookies/Cookies.binarycookies" } if let relativePath = browser.chromiumProfileRelativePath { return "\(homeDirectory)/Library/Application Support/\(relativePath)" } if let geckoFolder = browser.geckoProfilesFolder { return "\(homeDirectory)/Library/Application Support/\(geckoFolder)/Profiles" } return nil } private func requiresProfileValidation(_ browser: Browser) -> Bool { // Chromium-based browsers should have Default/ or Profile*/ subdirectories if browser == .safari { return false } if browser == .helium { // Helium doesn't use the Default/Profile* pattern return false } if browser.usesGeckoProfileStore { // Firefox should have at least one *.default* directory return true } if browser.usesChromiumProfileStore { return true } return false } private func hasValidProfileDirectory(for browser: Browser, at profilePath: String) -> Bool { guard let contents = self.directoryContents(profilePath) else { return false } // Check for Default/ or Profile*/ subdirectories for Chromium browsers let hasProfile = contents.contains { name in name == "Default" || name.hasPrefix("Profile ") || name.hasPrefix("user-") } if browser.usesGeckoProfileStore { return contents.contains { name in name.range(of: ".default", options: [.caseInsensitive]) != nil } } return hasProfile } private func hasValidCookieStore(for browser: Browser, at profilePath: String) -> Bool { guard let contents = self.directoryContents(profilePath) else { return false } if browser.usesGeckoProfileStore { for name in contents where name.range(of: ".default", options: [.caseInsensitive]) != nil { let cookieDB = "\(profilePath)/\(name)/cookies.sqlite" if self.fileExists(cookieDB) { return true } } return false } for name in contents where name == "Default" || name.hasPrefix("Profile ") || name.hasPrefix("user-") { let cookieDBLegacy = "\(profilePath)/\(name)/Cookies" let cookieDBNetwork = "\(profilePath)/\(name)/Network/Cookies" if self.fileExists(cookieDBLegacy) || self.fileExists(cookieDBNetwork) { return true } } return false } private static func probeProfileAccessIssue(_ path: String) -> BrowserProfileAccessIssue? { do { _ = try FileManager.default.contentsOfDirectory(atPath: path) return nil } catch { if self.isPermissionError(error) { return .accessDenied } let nsError = error as NSError if nsError.domain == NSCocoaErrorDomain, nsError.code == CocoaError.fileNoSuchFile.rawValue { return nil } return .unreadable } } private static func isPermissionError(_ error: Error) -> Bool { let nsError = error as NSError if nsError.domain == NSCocoaErrorDomain, nsError.code == CocoaError.fileReadNoPermission.rawValue { return true } if nsError.domain == NSPOSIXErrorDomain, nsError.code == Int(EACCES) || nsError.code == Int(EPERM) { return true } guard let underlying = nsError.userInfo[NSUnderlyingErrorKey] as? Error else { return false } return Self.isPermissionError(underlying) } } #else // MARK: - Non-macOS stub public struct BrowserDetection: Sendable { public static let defaultCacheTTL: TimeInterval = 0 public init( homeDirectory: String = "", cacheTTL: TimeInterval = BrowserDetection.defaultCacheTTL, now: @escaping @Sendable () -> Date = Date.init, fileExists: @escaping @Sendable (String) -> Bool = { _ in false }, directoryContents: @escaping @Sendable (String) -> [String]? = { _ in nil }) { _ = homeDirectory _ = cacheTTL _ = now _ = fileExists _ = directoryContents } public func isAppInstalled(_ browser: Browser) -> Bool { false } public func isCookieSourceAvailable(_ browser: Browser) -> Bool { false } public func hasUsableProfileData(_ browser: Browser) -> Bool { false } public func clearCache() {} } #endif