309 lines
11 KiB
Swift
309 lines
11 KiB
Swift
import Foundation
|
|
#if os(macOS)
|
|
import SweetCookieKit
|
|
#endif
|
|
|
|
#if os(macOS)
|
|
enum WindsurfDevinSessionImporter {
|
|
nonisolated(unsafe) static var importSessionsOverrideForTesting:
|
|
((BrowserDetection, ((String) -> Void)?) -> [SessionInfo])?
|
|
nonisolated(unsafe) static var importPreferredSessionsOverrideForTesting:
|
|
((BrowserDetection, ((String) -> Void)?) -> [SessionInfo])?
|
|
nonisolated(unsafe) static var importFallbackSessionsOverrideForTesting:
|
|
((BrowserDetection, ((String) -> Void)?) -> [SessionInfo])?
|
|
static let defaultPreferredBrowsers: [Browser] = [.chrome]
|
|
static let fallbackBrowsers: [Browser] = [
|
|
.chromeBeta,
|
|
.chromeCanary,
|
|
.edge,
|
|
.edgeBeta,
|
|
.edgeCanary,
|
|
.brave,
|
|
.braveBeta,
|
|
.braveNightly,
|
|
.vivaldi,
|
|
.arc,
|
|
.arcBeta,
|
|
.arcCanary,
|
|
.dia,
|
|
.chatgptAtlas,
|
|
.chromium,
|
|
.helium,
|
|
]
|
|
|
|
struct SessionInfo: Equatable {
|
|
let session: WindsurfDevinSessionAuth
|
|
let sourceLabel: String
|
|
}
|
|
|
|
static func importSessions(
|
|
browserDetection: BrowserDetection,
|
|
logger: ((String) -> Void)? = nil) -> [SessionInfo]
|
|
{
|
|
if let override = self.importSessionsOverrideForTesting {
|
|
return override(browserDetection, logger)
|
|
}
|
|
|
|
let log: (String) -> Void = { msg in logger?("[windsurf-storage] \(msg)") }
|
|
let preferredSessions = self.importSessions(
|
|
browserDetection: browserDetection,
|
|
browsers: self.defaultPreferredBrowsers,
|
|
logger: log)
|
|
if !preferredSessions.isEmpty {
|
|
return preferredSessions
|
|
}
|
|
|
|
log("No Windsurf devin session found in Chrome; trying fallback Chromium browsers")
|
|
let sessions = self.importSessions(
|
|
browserDetection: browserDetection,
|
|
browsers: self.fallbackBrowsersExcluding(self.defaultPreferredBrowsers),
|
|
logger: log)
|
|
|
|
if sessions.isEmpty {
|
|
log("No Windsurf devin session found in browser local storage")
|
|
}
|
|
|
|
return sessions
|
|
}
|
|
|
|
static func importPreferredSessions(
|
|
browserDetection: BrowserDetection,
|
|
logger: ((String) -> Void)? = nil) -> [SessionInfo]
|
|
{
|
|
if let override = self.importPreferredSessionsOverrideForTesting {
|
|
return override(browserDetection, logger)
|
|
}
|
|
let log: (String) -> Void = { msg in logger?("[windsurf-storage] \(msg)") }
|
|
return self.importSessions(
|
|
browserDetection: browserDetection,
|
|
browsers: self.defaultPreferredBrowsers,
|
|
logger: log)
|
|
}
|
|
|
|
static func importFallbackSessions(
|
|
browserDetection: BrowserDetection,
|
|
logger: ((String) -> Void)? = nil) -> [SessionInfo]
|
|
{
|
|
if let override = self.importFallbackSessionsOverrideForTesting {
|
|
return override(browserDetection, logger)
|
|
}
|
|
let log: (String) -> Void = { msg in logger?("[windsurf-storage] \(msg)") }
|
|
return self.importSessions(
|
|
browserDetection: browserDetection,
|
|
browsers: self.fallbackBrowsersExcluding(self.defaultPreferredBrowsers),
|
|
logger: log)
|
|
}
|
|
|
|
static func fallbackBrowsersExcluding(_ preferredBrowsers: [Browser]) -> [Browser] {
|
|
let preferred = Set(preferredBrowsers)
|
|
return self.fallbackBrowsers.filter { !preferred.contains($0) }
|
|
}
|
|
|
|
static func deduplicateSessions(_ sessions: [SessionInfo]) -> [SessionInfo] {
|
|
var deduplicated: [SessionInfo] = []
|
|
var seenSessionTokens = Set<String>()
|
|
|
|
for session in sessions {
|
|
guard seenSessionTokens.insert(session.session.sessionToken).inserted else { continue }
|
|
deduplicated.append(session)
|
|
}
|
|
|
|
return deduplicated
|
|
}
|
|
|
|
static func session(from storage: [String: String], sourceLabel: String) -> SessionInfo? {
|
|
guard let sessionToken = storage["devin_session_token"],
|
|
let auth1Token = storage["devin_auth1_token"],
|
|
let accountID = storage["devin_account_id"],
|
|
let primaryOrgID = storage["devin_primary_org_id"]
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
return SessionInfo(
|
|
session: WindsurfDevinSessionAuth(
|
|
sessionToken: sessionToken,
|
|
auth1Token: auth1Token,
|
|
accountID: accountID,
|
|
primaryOrgID: primaryOrgID),
|
|
sourceLabel: sourceLabel)
|
|
}
|
|
|
|
static func decodedStorageValue(_ value: String) -> String {
|
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return trimmed }
|
|
|
|
if let data = trimmed.data(using: .utf8),
|
|
let decoded = try? JSONDecoder().decode(String.self, from: data)
|
|
{
|
|
return decoded.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
return trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
struct LocalStorageCandidate {
|
|
let label: String
|
|
let url: URL
|
|
}
|
|
|
|
struct LocalStorageSnapshot: Equatable {
|
|
let storage: [String: String]
|
|
let sourceSuffix: String?
|
|
}
|
|
|
|
typealias LocalStorageOriginEntries = (
|
|
origin: URL,
|
|
entries: [SweetCookieKit.ChromiumLocalStorageEntry])
|
|
|
|
static let localStorageOrigins = [
|
|
URL(string: "https://app.devin.ai")!,
|
|
URL(string: "https://windsurf.com")!,
|
|
]
|
|
|
|
private static func importSessions(
|
|
browserDetection: BrowserDetection,
|
|
browsers: [Browser],
|
|
logger: @escaping (String) -> Void) -> [SessionInfo]
|
|
{
|
|
var sessions: [SessionInfo] = []
|
|
let candidates = self.chromeLocalStorageCandidates(
|
|
browserDetection: browserDetection,
|
|
browsers: browsers)
|
|
if !candidates.isEmpty {
|
|
logger("Chrome local storage candidates: \(candidates.count)")
|
|
}
|
|
|
|
for candidate in candidates {
|
|
let snapshots = self.readLocalStorageSnapshots(from: candidate.url, logger: logger)
|
|
for snapshot in snapshots {
|
|
let sourceLabel = self.sourceLabel(candidate.label, suffix: snapshot.sourceSuffix)
|
|
guard let session = self.session(from: snapshot.storage, sourceLabel: sourceLabel) else { continue }
|
|
logger("Found Windsurf devin session in \(sourceLabel)")
|
|
sessions.append(session)
|
|
}
|
|
}
|
|
|
|
return self.deduplicateSessions(sessions)
|
|
}
|
|
|
|
static func chromeLocalStorageCandidates(
|
|
browserDetection: BrowserDetection,
|
|
browsers: [Browser]) -> [LocalStorageCandidate]
|
|
{
|
|
let installedBrowsers = browsers.browsersWithProfileData(using: browserDetection)
|
|
let roots = ChromiumProfileLocator
|
|
.roots(for: installedBrowsers, homeDirectories: BrowserCookieClient.defaultHomeDirectories())
|
|
.map { (url: $0.url, labelPrefix: $0.labelPrefix) }
|
|
|
|
var candidates: [LocalStorageCandidate] = []
|
|
for root in roots {
|
|
candidates.append(contentsOf: self.chromeProfileLocalStorageDirs(
|
|
root: root.url,
|
|
labelPrefix: root.labelPrefix))
|
|
}
|
|
return candidates
|
|
}
|
|
|
|
private static func chromeProfileLocalStorageDirs(root: URL, labelPrefix: String) -> [LocalStorageCandidate] {
|
|
guard let entries = try? FileManager.default.contentsOfDirectory(
|
|
at: root,
|
|
includingPropertiesForKeys: [.isDirectoryKey],
|
|
options: [.skipsHiddenFiles])
|
|
else { return [] }
|
|
|
|
let profileDirs = entries.filter { url in
|
|
guard let isDir = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory), isDir else {
|
|
return false
|
|
}
|
|
let name = url.lastPathComponent
|
|
return name == "Default" || name.hasPrefix("Profile ") || name.hasPrefix("user-")
|
|
}
|
|
.sorted { $0.lastPathComponent < $1.lastPathComponent }
|
|
|
|
return profileDirs.compactMap { dir in
|
|
let levelDBURL = dir.appendingPathComponent("Local Storage").appendingPathComponent("leveldb")
|
|
guard FileManager.default.fileExists(atPath: levelDBURL.path) else { return nil }
|
|
let label = "\(labelPrefix) \(dir.lastPathComponent)"
|
|
return LocalStorageCandidate(label: label, url: levelDBURL)
|
|
}
|
|
}
|
|
|
|
private static func readLocalStorageSnapshots(
|
|
from levelDBURL: URL,
|
|
logger: ((String) -> Void)? = nil) -> [LocalStorageSnapshot]
|
|
{
|
|
let originEntries = Self.localStorageOrigins.map { origin in
|
|
let entries = SweetCookieKit.ChromiumLocalStorageReader.readEntries(
|
|
for: origin.absoluteString,
|
|
in: levelDBURL,
|
|
logger: logger)
|
|
return (origin: origin, entries: entries)
|
|
}
|
|
|
|
let textEntries = SweetCookieKit.ChromiumLocalStorageReader.readTextEntries(
|
|
in: levelDBURL,
|
|
logger: logger)
|
|
return self.localStorageSnapshots(from: originEntries, textEntries: textEntries)
|
|
}
|
|
|
|
static func localStorageSnapshots(
|
|
from originEntries: [LocalStorageOriginEntries],
|
|
textEntries: [SweetCookieKit.ChromiumLevelDBTextEntry]) -> [LocalStorageSnapshot]
|
|
{
|
|
var snapshots = self.localStorageSnapshots(from: originEntries)
|
|
let textStorage = self.storage(from: textEntries)
|
|
if textStorage.count == Self.targetKeys.count {
|
|
snapshots.append(LocalStorageSnapshot(storage: textStorage, sourceSuffix: nil))
|
|
}
|
|
|
|
return snapshots
|
|
}
|
|
|
|
static func localStorageSnapshots(from originEntries: [LocalStorageOriginEntries]) -> [LocalStorageSnapshot] {
|
|
originEntries.compactMap { originEntry in
|
|
let storage = self.storage(from: originEntry.entries)
|
|
guard storage.count == Self.targetKeys.count else { return nil }
|
|
return LocalStorageSnapshot(
|
|
storage: storage,
|
|
sourceSuffix: originEntry.origin.host ?? originEntry.origin.absoluteString)
|
|
}
|
|
}
|
|
|
|
private static func storage(
|
|
from entries: [SweetCookieKit.ChromiumLocalStorageEntry]) -> [String: String]
|
|
{
|
|
var storage: [String: String] = [:]
|
|
for entry in entries where storage[entry.key] == nil && Self.targetKeys.contains(entry.key) {
|
|
storage[entry.key] = self.decodedStorageValue(entry.value)
|
|
}
|
|
return storage
|
|
}
|
|
|
|
private static func storage(
|
|
from entries: [SweetCookieKit.ChromiumLevelDBTextEntry]) -> [String: String]
|
|
{
|
|
var storage: [String: String] = [:]
|
|
for entry in entries {
|
|
guard storage[entry.key] == nil, Self.targetKeys.contains(entry.key) else { continue }
|
|
storage[entry.key] = self.decodedStorageValue(entry.value)
|
|
}
|
|
|
|
return storage
|
|
}
|
|
|
|
private static func sourceLabel(_ label: String, suffix: String?) -> String {
|
|
guard let suffix else { return label }
|
|
return "\(label) (\(suffix))"
|
|
}
|
|
|
|
private static let targetKeys: Set<String> = [
|
|
"devin_session_token",
|
|
"devin_auth1_token",
|
|
"devin_account_id",
|
|
"devin_primary_org_id",
|
|
]
|
|
}
|
|
#endif
|