mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 12:26:38 -06:00
2ef92d0bbe
* feat(browser): sync system cookies to a remote gateway profile Add `openclaw browser cookie-sync`: decrypt allowlisted macOS Chrome-family cookies locally and push them into a managed profile on a possibly-remote Gateway over the existing operator channel. --watch re-syncs on cookie-DB changes with a single Keychain prompt per session. - New POST /cookies/set-many batch route (mirrors /cookies/set) - Extract one canonical readSystemProfileCookies reused by import + sync - Mandatory domain allowlist (never syncs an unrestricted cookie jar) - Decryption stays host-local (macOS); no cookie values are logged * feat(macos): cookie sync checkbox and configuration UI Add an off-by-default 'Cookie sync' section (Settings > General): a toggle, an editable domain allowlist editor, and a target-profile field, actionable only in remote-gateway mode. CookieSyncManager supervises `openclaw browser cookie-sync --watch` against the connected Gateway when enabled, resolving a LOCAL CLI (never the SSH-redirect path, since decryption is host-local) and injecting gateway URL + token/password via environment, never argv. A status row surfaces running/stopped/error and the last sync summary. * fix(macos): satisfy cookie sync lint gates * chore(i18n): register cookie sync native source strings Regenerate apps/.i18n/native-source.json baseline for the new macOS Cookie sync settings strings (additive only). Satisfies the native:i18n:verify gate; generated locale artifacts are refreshed separately by the locale-refresh job.
413 lines
16 KiB
Swift
413 lines
16 KiB
Swift
import Darwin
|
|
import Foundation
|
|
import Observation
|
|
import OSLog
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class CookieSyncManager: NSObject {
|
|
enum State: Equatable {
|
|
case stopped
|
|
case running
|
|
case error(String)
|
|
}
|
|
|
|
private struct Endpoint: Equatable {
|
|
let url: URL
|
|
let token: String?
|
|
let password: String?
|
|
}
|
|
|
|
private struct SyncIntent: Equatable {
|
|
let domains: [String]
|
|
let profile: String
|
|
let endpoint: Endpoint
|
|
}
|
|
|
|
static let shared = CookieSyncManager()
|
|
|
|
private(set) var state: State = .stopped
|
|
private(set) var lastSummary: String?
|
|
|
|
@ObservationIgnored private let logger = Logger(subsystem: "ai.openclaw", category: "cookie-sync")
|
|
@ObservationIgnored private let queue = DispatchQueue(label: "ai.openclaw.cookie-sync")
|
|
@ObservationIgnored private weak var appState: AppState?
|
|
@ObservationIgnored private var endpointState: GatewayEndpointState?
|
|
@ObservationIgnored private var endpointTask: Task<Void, Never>?
|
|
@ObservationIgnored private var reconcileTask: Task<Void, Never>?
|
|
@ObservationIgnored private var retryTask: Task<Void, Never>?
|
|
@ObservationIgnored private var process: Process?
|
|
@ObservationIgnored private var stdoutPipe: Pipe?
|
|
@ObservationIgnored private var stderrPipe: Pipe?
|
|
@ObservationIgnored private var stdoutSource: DispatchSourceRead?
|
|
@ObservationIgnored private var stderrSource: DispatchSourceRead?
|
|
@ObservationIgnored private var startupWatchdog: DispatchSourceTimer?
|
|
@ObservationIgnored private var stdoutBuffer = Data()
|
|
@ObservationIgnored private var processGeneration: UUID?
|
|
@ObservationIgnored private var runningIntent: SyncIntent?
|
|
@ObservationIgnored private var reconcileGeneration: UInt64 = 0
|
|
@ObservationIgnored private var retryAttempt = 0
|
|
@ObservationIgnored private var isStarted = false
|
|
|
|
func start(state: AppState) {
|
|
self.appState = state
|
|
guard !self.isStarted else {
|
|
self.scheduleReconcile(resetRetry: true)
|
|
return
|
|
}
|
|
self.isStarted = true
|
|
let center = NotificationCenter.default
|
|
center.addObserver(
|
|
self,
|
|
selector: #selector(self.settingsDidChange),
|
|
name: UserDefaults.didChangeNotification,
|
|
object: AppDefaults.standard)
|
|
center.addObserver(
|
|
self,
|
|
selector: #selector(self.settingsDidChange),
|
|
name: .openclawConfigDidChange,
|
|
object: nil)
|
|
center.addObserver(
|
|
self,
|
|
selector: #selector(self.cliDidChange),
|
|
name: .openclawCLIInstalled,
|
|
object: nil)
|
|
|
|
self.endpointTask = Task { [weak self] in
|
|
let states = await GatewayEndpointStore.shared.subscribe()
|
|
for await state in states {
|
|
guard !Task.isCancelled, let self else { return }
|
|
self.endpointState = state
|
|
self.scheduleReconcile(resetRetry: true)
|
|
}
|
|
}
|
|
self.scheduleReconcile(resetRetry: true)
|
|
}
|
|
|
|
func stop() {
|
|
// This singleton's notification lifetime follows its explicit start/stop lifecycle.
|
|
// swiftlint:disable:next notification_center_detachment
|
|
NotificationCenter.default.removeObserver(self)
|
|
self.endpointTask?.cancel()
|
|
self.endpointTask = nil
|
|
self.reconcileTask?.cancel()
|
|
self.reconcileTask = nil
|
|
self.retryTask?.cancel()
|
|
self.retryTask = nil
|
|
self.isStarted = false
|
|
self.stopChild(nextState: .stopped)
|
|
}
|
|
|
|
static func normalizedDomains(_ values: [String]) -> [String] {
|
|
var seen = Set<String>()
|
|
return values
|
|
.flatMap { $0.split(separator: ",", omittingEmptySubsequences: false).map(String.init) }
|
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
.filter { !$0.isEmpty && seen.insert($0.lowercased()).inserted }
|
|
}
|
|
|
|
@objc private nonisolated func settingsDidChange(_: Notification) {
|
|
Task { @MainActor [weak self] in
|
|
self?.scheduleReconcile(resetRetry: true)
|
|
}
|
|
}
|
|
|
|
@objc private nonisolated func cliDidChange(_: Notification) {
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
self.stopChild(nextState: .stopped)
|
|
self.scheduleReconcile(resetRetry: true)
|
|
}
|
|
}
|
|
|
|
private func scheduleReconcile(resetRetry: Bool, delay: Duration = .milliseconds(350)) {
|
|
if resetRetry {
|
|
self.retryAttempt = 0
|
|
self.retryTask?.cancel()
|
|
self.retryTask = nil
|
|
}
|
|
self.reconcileGeneration &+= 1
|
|
let generation = self.reconcileGeneration
|
|
self.reconcileTask?.cancel()
|
|
self.reconcileTask = Task { [weak self] in
|
|
do {
|
|
try await Task.sleep(for: delay)
|
|
} catch {
|
|
return
|
|
}
|
|
guard !Task.isCancelled, let self, generation == self.reconcileGeneration else { return }
|
|
await self.reconcile(generation: generation)
|
|
}
|
|
}
|
|
|
|
private func reconcile(generation: UInt64) async {
|
|
guard let appState = self.appState,
|
|
appState.cookieSyncEnabled,
|
|
appState.connectionMode == .remote
|
|
else {
|
|
self.stopChild(nextState: .stopped)
|
|
return
|
|
}
|
|
|
|
let domains = Self.normalizedDomains(appState.cookieSyncDomains)
|
|
guard !domains.isEmpty else {
|
|
self.stopChild(nextState: .stopped)
|
|
return
|
|
}
|
|
|
|
let profile = appState.cookieSyncIntoProfile
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.nonEmpty ?? "imported"
|
|
guard let endpoint = self.remoteEndpoint else {
|
|
self.stopChild(nextState: .error("no remote gateway credentials available"))
|
|
return
|
|
}
|
|
let intent = SyncIntent(domains: domains, profile: profile, endpoint: endpoint)
|
|
if self.process?.isRunning == true, self.runningIntent == intent {
|
|
return
|
|
}
|
|
self.stopChild(nextState: .stopped)
|
|
|
|
guard let executable = await self.resolveLocalOpenClawExecutable(),
|
|
!Task.isCancelled,
|
|
generation == self.reconcileGeneration
|
|
else {
|
|
if !Task.isCancelled, generation == self.reconcileGeneration {
|
|
self.state = .error("OpenClaw CLI not found locally; cookie sync needs the CLI on this Mac")
|
|
}
|
|
return
|
|
}
|
|
self.launch(executable: executable, intent: intent)
|
|
}
|
|
|
|
private var remoteEndpoint: Endpoint? {
|
|
guard case let .ready(mode, url, rawToken, rawPassword, _) = self.endpointState,
|
|
mode == .remote
|
|
else { return nil }
|
|
let token = rawToken?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
|
let password = rawPassword?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
|
guard token != nil || password != nil else { return nil }
|
|
return Endpoint(url: url, token: token, password: token == nil ? password : nil)
|
|
}
|
|
|
|
private func resolveLocalOpenClawExecutable() async -> String? {
|
|
#if DEBUG
|
|
if let executable = CommandResolver.projectOpenClawExecutable() {
|
|
return executable
|
|
}
|
|
#endif
|
|
if case let .ready(location, _) = await CLIInstaller.status() {
|
|
return location
|
|
}
|
|
return CommandResolver.findExecutable(
|
|
named: "openclaw",
|
|
searchPaths: CommandResolver.preferredPaths())
|
|
}
|
|
|
|
private func launch(executable: String, intent: SyncIntent) {
|
|
let process = Process()
|
|
let stdoutPipe = Pipe()
|
|
let stderrPipe = Pipe()
|
|
let generation = UUID()
|
|
let arguments = [
|
|
"browser",
|
|
"cookie-sync",
|
|
"--watch",
|
|
"--domains",
|
|
intent.domains.joined(separator: ","),
|
|
"--into",
|
|
intent.profile,
|
|
]
|
|
|
|
process.executableURL = URL(fileURLWithPath: executable)
|
|
process.arguments = arguments
|
|
process.standardInput = FileHandle.nullDevice
|
|
process.standardOutput = stdoutPipe
|
|
process.standardError = stderrPipe
|
|
var environment = ProcessInfo.processInfo.environment
|
|
environment["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
|
|
environment["OPENCLAW_GATEWAY_URL"] = intent.endpoint.url.absoluteString
|
|
environment.removeValue(forKey: "OPENCLAW_GATEWAY_TOKEN")
|
|
environment.removeValue(forKey: "OPENCLAW_GATEWAY_PASSWORD")
|
|
if let token = intent.endpoint.token {
|
|
environment["OPENCLAW_GATEWAY_TOKEN"] = token
|
|
} else if let password = intent.endpoint.password {
|
|
environment["OPENCLAW_GATEWAY_PASSWORD"] = password
|
|
}
|
|
process.environment = environment
|
|
process.terminationHandler = { [weak self] process in
|
|
let terminationStatus = process.terminationStatus
|
|
Task { @MainActor [weak self] in
|
|
self?.childTerminated(generation: generation, status: terminationStatus)
|
|
}
|
|
}
|
|
|
|
self.process = process
|
|
self.stdoutPipe = stdoutPipe
|
|
self.stderrPipe = stderrPipe
|
|
self.processGeneration = generation
|
|
self.runningIntent = intent
|
|
self.stdoutBuffer.removeAll(keepingCapacity: true)
|
|
|
|
do {
|
|
try process.run()
|
|
try? stdoutPipe.fileHandleForWriting.close()
|
|
try? stderrPipe.fileHandleForWriting.close()
|
|
self.installReadSources(stdoutPipe: stdoutPipe, stderrPipe: stderrPipe, generation: generation)
|
|
self.installStartupWatchdog(generation: generation)
|
|
self.state = .running
|
|
self.logger.info("cookie sync started for \(intent.domains.count, privacy: .public) domain(s)")
|
|
} catch {
|
|
self.logger.error("cookie sync launch failed: \(error.localizedDescription, privacy: .public)")
|
|
self.stopChild(nextState: .error("Cookie sync could not start: \(error.localizedDescription)"))
|
|
self.scheduleRetry()
|
|
}
|
|
}
|
|
|
|
private func installReadSources(stdoutPipe: Pipe, stderrPipe: Pipe, generation: UUID) {
|
|
let stdoutDescriptor = stdoutPipe.fileHandleForReading.fileDescriptor
|
|
let stdoutSource = DispatchSource.makeReadSource(fileDescriptor: stdoutDescriptor, queue: self.queue)
|
|
stdoutSource.setEventHandler { [weak self] in
|
|
let data = Self.readAvailable(fileDescriptor: stdoutDescriptor, byteCount: stdoutSource.data)
|
|
Task { @MainActor [weak self] in
|
|
self?.consumeStdout(data, generation: generation)
|
|
}
|
|
}
|
|
self.stdoutSource = stdoutSource
|
|
stdoutSource.resume()
|
|
|
|
let stderrDescriptor = stderrPipe.fileHandleForReading.fileDescriptor
|
|
let stderrSource = DispatchSource.makeReadSource(fileDescriptor: stderrDescriptor, queue: self.queue)
|
|
stderrSource.setEventHandler { [weak self] in
|
|
let data = Self.readAvailable(fileDescriptor: stderrDescriptor, byteCount: stderrSource.data)
|
|
Task { @MainActor [weak self] in
|
|
self?.consumeStderr(data, generation: generation)
|
|
}
|
|
}
|
|
self.stderrSource = stderrSource
|
|
stderrSource.resume()
|
|
}
|
|
|
|
private func installStartupWatchdog(generation: UUID) {
|
|
let timer = DispatchSource.makeTimerSource(queue: self.queue)
|
|
timer.schedule(deadline: .now() + 5)
|
|
timer.setEventHandler { [weak self] in
|
|
Task { @MainActor [weak self] in
|
|
guard let self, self.processGeneration == generation else { return }
|
|
self.startupWatchdog?.cancel()
|
|
self.startupWatchdog = nil
|
|
if self.process?.isRunning == true {
|
|
self.retryAttempt = 0
|
|
self.logger.debug("cookie sync startup watchdog passed")
|
|
}
|
|
}
|
|
}
|
|
self.startupWatchdog = timer
|
|
timer.resume()
|
|
}
|
|
|
|
private func consumeStdout(_ data: Data, generation: UUID) {
|
|
guard self.processGeneration == generation else { return }
|
|
guard !data.isEmpty else {
|
|
self.stdoutSource?.cancel()
|
|
self.stdoutSource = nil
|
|
return
|
|
}
|
|
self.stdoutBuffer.append(data)
|
|
if self.stdoutBuffer.count > 64 * 1024 {
|
|
self.stdoutBuffer.removeFirst(self.stdoutBuffer.count - 64 * 1024)
|
|
}
|
|
while let newline = self.stdoutBuffer.firstIndex(of: 0x0A) {
|
|
let lineData = self.stdoutBuffer.prefix(upTo: newline)
|
|
self.stdoutBuffer.removeSubrange(...newline)
|
|
// Preserve lossy decoding so malformed CLI bytes do not hide the rest of a status line.
|
|
// swiftlint:disable:next optional_data_string_conversion
|
|
let line = String(decoding: lineData, as: UTF8.self)
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !line.isEmpty else { continue }
|
|
self.lastSummary = line
|
|
self.logger.info("cookie sync: \(line, privacy: .public)")
|
|
}
|
|
}
|
|
|
|
private func consumeStderr(_ data: Data, generation: UUID) {
|
|
guard self.processGeneration == generation else { return }
|
|
guard !data.isEmpty else {
|
|
self.stderrSource?.cancel()
|
|
self.stderrSource = nil
|
|
return
|
|
}
|
|
// Preserve lossy decoding so malformed CLI bytes do not hide useful diagnostics.
|
|
// swiftlint:disable:next optional_data_string_conversion
|
|
let message = String(decoding: data, as: UTF8.self)
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !message.isEmpty {
|
|
self.logger.error("cookie sync stderr: \(message, privacy: .private)")
|
|
}
|
|
}
|
|
|
|
private func childTerminated(generation: UUID, status: Int32) {
|
|
guard self.processGeneration == generation else { return }
|
|
self.stopChild(nextState: .error("Cookie sync exited with status \(status)"))
|
|
self.scheduleRetry()
|
|
}
|
|
|
|
private func scheduleRetry() {
|
|
guard self.shouldBeActive else { return }
|
|
self.retryAttempt += 1
|
|
let delaySeconds = min(30, 1 << min(self.retryAttempt - 1, 5))
|
|
self.retryTask?.cancel()
|
|
self.retryTask = Task { [weak self] in
|
|
do {
|
|
try await Task.sleep(for: .seconds(delaySeconds))
|
|
} catch {
|
|
return
|
|
}
|
|
guard !Task.isCancelled, let self else { return }
|
|
self.scheduleReconcile(resetRetry: false, delay: .zero)
|
|
}
|
|
}
|
|
|
|
private var shouldBeActive: Bool {
|
|
guard let appState = self.appState else { return false }
|
|
return appState.cookieSyncEnabled &&
|
|
appState.connectionMode == .remote &&
|
|
!Self.normalizedDomains(appState.cookieSyncDomains).isEmpty
|
|
}
|
|
|
|
private func stopChild(nextState: State) {
|
|
self.startupWatchdog?.cancel()
|
|
self.startupWatchdog = nil
|
|
self.stdoutSource?.cancel()
|
|
self.stdoutSource = nil
|
|
self.stderrSource?.cancel()
|
|
self.stderrSource = nil
|
|
let process = self.process
|
|
self.process = nil
|
|
self.processGeneration = nil
|
|
self.runningIntent = nil
|
|
try? self.stdoutPipe?.fileHandleForReading.close()
|
|
try? self.stdoutPipe?.fileHandleForWriting.close()
|
|
try? self.stderrPipe?.fileHandleForReading.close()
|
|
try? self.stderrPipe?.fileHandleForWriting.close()
|
|
self.stdoutPipe = nil
|
|
self.stderrPipe = nil
|
|
self.stdoutBuffer.removeAll(keepingCapacity: false)
|
|
if process?.isRunning == true {
|
|
process?.terminate()
|
|
}
|
|
self.state = nextState
|
|
}
|
|
|
|
private nonisolated static func readAvailable(fileDescriptor: Int32, byteCount: UInt) -> Data {
|
|
let count = max(1, min(Int(byteCount), 64 * 1024))
|
|
var data = Data(count: count)
|
|
let bytesRead = data.withUnsafeMutableBytes { buffer in
|
|
Darwin.read(fileDescriptor, buffer.baseAddress, count)
|
|
}
|
|
guard bytesRead > 0 else { return Data() }
|
|
data.removeSubrange(bytesRead..<data.count)
|
|
return data
|
|
}
|
|
}
|