378 lines · 12.8 KB · Swift
RawDownload
import Foundation#if os(macOS)@preconcurrency import AppKitimport Darwinimport os.lockimport 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