Files
steipete--codexbar/Sources/CodexBarCore/BrowserDetection.swift
T
2026-07-13 12:22:33 +08:00

379 lines
13 KiB
Swift

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