browser-cookie-bridge 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,12 @@
1
1
  import AppKit
2
2
  import Foundation
3
+ import Security
3
4
 
4
5
  extension Notification.Name {
5
6
  static let menuBarVisibilityChanged = Notification.Name("BraveCodexSync.menuBarVisibilityChanged")
6
7
  static let nativeAlert = Notification.Name("BraveCodexSync.nativeAlert")
7
8
  static let updateStateChanged = Notification.Name("BraveCodexSync.updateStateChanged")
9
+ static let syncStateChanged = Notification.Name("BraveCodexSync.syncStateChanged")
8
10
  }
9
11
 
10
12
  struct NativeAlert {
@@ -20,6 +22,31 @@ struct UpdateMenuState {
20
22
  let installing: Bool
21
23
  }
22
24
 
25
+ struct SyncMenuState {
26
+ let uploading: Bool
27
+ let canceling: Bool
28
+ }
29
+
30
+ struct BrowserlessProfileAssessment: Decodable {
31
+ let browser: String?
32
+ let profileName: String?
33
+ let profileBytes: Int64
34
+ let indexedDBBytes: Int64
35
+ let localStorageBytes: Int64
36
+ let freeBytes: Int64?
37
+ let severity: String
38
+ let temporarySpaceWarning: Bool
39
+ let serverArtifactCapBytes: Int64
40
+ let summary: String
41
+ }
42
+
43
+ private struct BrowserlessProgressEvent: Decodable {
44
+ let phase: String
45
+ let fraction: Double?
46
+ let detail: String?
47
+ let assessment: BrowserlessProfileAssessment?
48
+ }
49
+
23
50
  struct BrowserChoice: Identifiable, Hashable {
24
51
  let id: String
25
52
  let name: String
@@ -30,7 +57,7 @@ struct BrowserChoice: Identifiable, Hashable {
30
57
 
31
58
  @MainActor
32
59
  final class SyncModel: ObservableObject {
33
- enum State { case ready, syncing, success, warning, error }
60
+ enum State { case ready, syncing, success, canceled, warning, error }
34
61
 
35
62
  let browsers = [
36
63
  BrowserChoice(id: "brave", name: "Brave", bundleIdentifier: "com.brave.Browser", applicationName: "Brave Browser", extensionURL: "brave://extensions"),
@@ -60,6 +87,17 @@ final class SyncModel: ObservableObject {
60
87
  @Published var selectedSourceID = "brave"
61
88
  @Published var selectedTargetID = "codex"
62
89
  @Published var codexRunning = false
90
+ @Published var sourceBrowserRunning = false
91
+ @Published var browserlessConfigured = false
92
+ @Published var browserlessProfileName = "browser-cookie-bridge"
93
+ @Published var browserlessRegion = "sfo"
94
+ @Published var browserlessOnlyDomains = ""
95
+ @Published var showingBrowserlessSetup = false
96
+ @Published var browserlessAssessment: BrowserlessProfileAssessment?
97
+ @Published var isInspectingBrowserlessProfile = false
98
+ @Published var uploadProgress = 0.0
99
+ @Published var uploadElapsedSeconds = 0
100
+ @Published var uploadCanceling = false
63
101
  @Published var primaryStatus = "Ready to sync"
64
102
  @Published var secondaryStatus = "Choose what to move, then start a transfer"
65
103
 
@@ -69,10 +107,14 @@ final class SyncModel: ObservableObject {
69
107
  private var launchAgent: URL { home.appending(path: "Library/LaunchAgents/com.apoorvdarshan.brave-codex-cookie-sync.plist") }
70
108
  private var loginSyncAgent: URL { home.appending(path: "Library/LaunchAgents/com.apoorvdarshan.brave-codex-cookie-sync.login-sync.plist") }
71
109
  private var appLoginAgent: URL { home.appending(path: "Library/LaunchAgents/com.apoorvdarshan.brave-codex-cookie-sync.app-login.plist") }
72
- private var codexStatusTimer: Timer?
110
+ private var endpointStatusTimer: Timer?
73
111
  private var updateTimer: Timer?
74
112
  private var didCheckAfterLaunch = false
75
113
  private var didConsumeUpdateResult = false
114
+ private var assessedBrowserID: String?
115
+ private var activeSyncProcess: Process?
116
+ private var uploadTimer: Timer?
117
+ private var uploadStartedAt: Date?
76
118
 
77
119
  var selectedBrowser: BrowserChoice {
78
120
  browsers.first(where: { $0.id == selectedSourceID }) ?? browsers[0]
@@ -82,10 +124,29 @@ final class SyncModel: ObservableObject {
82
124
  browsers.first(where: { $0.id == selectedTargetID })
83
125
  }
84
126
 
85
- var targetName: String { selectedTargetBrowser?.name ?? "ChatGPT Codex" }
127
+ var isBrowserlessTarget: Bool { selectedTargetID == "browserless" }
128
+ var targetName: String {
129
+ isBrowserlessTarget ? "Browserless Cloud" : selectedTargetBrowser?.name ?? "ChatGPT Codex"
130
+ }
86
131
  var codexBlocked: Bool { selectedTargetID == "codex" && codexRunning }
132
+ var browserlessBlocked: Bool {
133
+ isBrowserlessTarget && (!browserlessConfigured || sourceBrowserRunning || selectedSourceID == "comet")
134
+ }
135
+ var syncBlocked: Bool { codexBlocked || browserlessBlocked }
136
+ var formattedUploadElapsed: String {
137
+ let minutes = uploadElapsedSeconds / 60
138
+ let seconds = uploadElapsedSeconds % 60
139
+ return String(format: "%d:%02d", minutes, seconds)
140
+ }
87
141
  var sourceIcon: NSImage { browserIcon(selectedBrowser) }
88
- var targetIcon: NSImage { selectedTargetBrowser.map(browserIcon) ?? codexIcon }
142
+ var targetIcon: NSImage {
143
+ isBrowserlessTarget ? browserlessIcon : selectedTargetBrowser.map(browserIcon) ?? codexIcon
144
+ }
145
+ var browserlessIcon: NSImage {
146
+ bundledIcon("browserless")
147
+ ?? NSImage(systemSymbolName: "cloud.fill", accessibilityDescription: "Browserless Cloud")
148
+ ?? NSImage()
149
+ }
89
150
  var codexIcon: NSImage {
90
151
  bundledIcon("chatgpt-codex")
91
152
  ?? chatGPTResource("app.icns")
@@ -119,9 +180,10 @@ final class SyncModel: ObservableObject {
119
180
  bootstrapBundledRuntimeIfNeeded()
120
181
  let calendar = Calendar.current
121
182
  scheduleTime = calendar.date(bySettingHour: 9, minute: 0, second: 0, of: Date()) ?? Date()
122
- updateCodexRunningStatus()
123
- codexStatusTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
124
- Task { @MainActor in self?.updateCodexRunningStatus() }
183
+ updateEndpointRunningStatus()
184
+ refreshBrowserlessPreflight()
185
+ endpointStatusTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
186
+ Task { @MainActor in self?.updateEndpointRunningStatus() }
125
187
  }
126
188
  updateTimer = Timer.scheduledTimer(withTimeInterval: 24 * 60 * 60, repeats: true) { [weak self] _ in
127
189
  Task { @MainActor in
@@ -191,7 +253,7 @@ final class SyncModel: ObservableObject {
191
253
  let configuredSource = config.sourceBrowser ?? "brave"
192
254
  selectedSourceID = browsers.contains(where: { $0.id == configuredSource }) ? configuredSource : "brave"
193
255
  let configuredTarget = config.targetBrowser ?? "codex"
194
- selectedTargetID = configuredTarget == "codex" || browsers.contains(where: { $0.id == configuredTarget })
256
+ selectedTargetID = configuredTarget == "codex" || configuredTarget == "browserless" || browsers.contains(where: { $0.id == configuredTarget })
195
257
  ? configuredTarget
196
258
  : "codex"
197
259
  if selectedTargetID == selectedSourceID { selectedTargetID = "codex" }
@@ -199,12 +261,17 @@ final class SyncModel: ObservableObject {
199
261
  historyEnabled = config.imports?.history ?? false
200
262
  menuBarEnabled = config.ui?.menuBar ?? true
201
263
  autoCheckUpdates = config.ui?.autoCheckUpdates ?? true
264
+ browserlessProfileName = config.browserless?.profileName ?? "browser-cookie-bridge"
265
+ browserlessRegion = config.browserless?.region ?? "sfo"
266
+ browserlessOnlyDomains = (config.browserless?.onlyDomains ?? []).joined(separator: ", ")
202
267
  }
268
+ browserlessConfigured = BrowserlessCredentialStore.read() != nil
203
269
  NotificationCenter.default.post(name: .menuBarVisibilityChanged, object: menuBarEnabled)
204
270
  extensionsReady = requiredExtensionIDs.allSatisfy {
205
271
  FileManager.default.fileExists(atPath: support.appending(path: "extension-\($0)/manifest.json").path)
206
272
  }
207
- updateCodexRunningStatus()
273
+ updateEndpointRunningStatus()
274
+ refreshBrowserlessPreflight()
208
275
  consumeUpdateResultIfNeeded()
209
276
  if autoCheckUpdates && !didCheckAfterLaunch {
210
277
  didCheckAfterLaunch = true
@@ -215,15 +282,19 @@ final class SyncModel: ObservableObject {
215
282
  func selectSource(_ id: String) {
216
283
  guard browsers.contains(where: { $0.id == id }), id != selectedSourceID, id != selectedTargetID else { return }
217
284
  selectedSourceID = id
285
+ browserlessAssessment = nil
286
+ assessedBrowserID = nil
218
287
  persistPreferences(successMessage: "Export source changed to \(selectedBrowser.name)")
219
288
  }
220
289
 
221
290
  func selectTarget(_ id: String) {
222
- let validTarget = id == "codex" || browsers.contains(where: { $0.id == id })
291
+ let validTarget = id == "codex" || id == "browserless" || browsers.contains(where: { $0.id == id })
223
292
  guard validTarget, id != selectedTargetID, id != selectedSourceID else { return }
224
293
  selectedTargetID = id
225
294
  persistPreferences(successMessage: "Import destination changed to \(targetName)")
226
- updateCodexRunningStatus()
295
+ updateEndpointRunningStatus()
296
+ if id == "browserless" && !browserlessConfigured { showingBrowserlessSetup = true }
297
+ if id == "browserless" { refreshBrowserlessPreflight() }
227
298
  }
228
299
 
229
300
  func setCookiesEnabled(_ enabled: Bool) {
@@ -236,6 +307,34 @@ final class SyncModel: ObservableObject {
236
307
  persistPreferences(successMessage: enabled ? "History URL import enabled" : "History import disabled")
237
308
  }
238
309
 
310
+ func saveBrowserlessSettings(token: String, profileName: String, region: String, onlyDomains: String) {
311
+ let cleanedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
312
+ let cleanedName = profileName.trimmingCharacters(in: .whitespacesAndNewlines)
313
+ guard !cleanedName.isEmpty, !cleanedToken.isEmpty || BrowserlessCredentialStore.read() != nil else {
314
+ postNativeAlert(title: "Browserless connection incomplete", message: "Enter an API token and cloud profile name.", kind: .warning)
315
+ return
316
+ }
317
+ do {
318
+ if !cleanedToken.isEmpty { try BrowserlessCredentialStore.save(cleanedToken) }
319
+ browserlessConfigured = true
320
+ browserlessProfileName = cleanedName
321
+ browserlessRegion = region
322
+ browserlessOnlyDomains = onlyDomains
323
+ showingBrowserlessSetup = false
324
+ persistPreferences(successMessage: "Browserless connected — uploads remain manual")
325
+ refreshBrowserlessPreflight(force: true)
326
+ } catch {
327
+ postNativeAlert(title: "Could not save Browserless token", message: error.localizedDescription, kind: .error)
328
+ }
329
+ }
330
+
331
+ func disconnectBrowserless() {
332
+ BrowserlessCredentialStore.delete()
333
+ browserlessConfigured = false
334
+ showingBrowserlessSetup = false
335
+ updateEndpointRunningStatus()
336
+ }
337
+
239
338
  func setMenuBarEnabled(_ enabled: Bool) {
240
339
  menuBarEnabled = enabled
241
340
  NotificationCenter.default.post(name: .menuBarVisibilityChanged, object: enabled)
@@ -334,31 +433,67 @@ final class SyncModel: ObservableObject {
334
433
 
335
434
  func syncNow(showMenuBarAlert: Bool = false) {
336
435
  guard !isSyncing else {
436
+ if isBrowserlessTarget {
437
+ cancelSync()
438
+ return
439
+ }
337
440
  if showMenuBarAlert {
338
441
  postNativeAlert(title: "Sync already running", message: "Wait for the current transfer to finish.", kind: .information)
339
442
  }
340
443
  return
341
444
  }
342
- updateCodexRunningStatus()
343
- guard !codexBlocked else {
445
+ updateEndpointRunningStatus()
446
+ guard !syncBlocked else {
344
447
  if showMenuBarAlert {
345
448
  postNativeAlert(title: primaryStatus, message: secondaryStatus, kind: .warning)
346
449
  }
347
450
  return
348
451
  }
349
452
  isSyncing = true
453
+ uploadCanceling = false
350
454
  state = .syncing
351
- primaryStatus = "Transferring selected data"
455
+ primaryStatus = isBrowserlessTarget ? "Uploading authenticated state" : "Transferring selected data"
352
456
  secondaryStatus = selectedTargetID == "codex"
353
457
  ? "Backing up Codex and merging \(selectedBrowser.name) locally…"
354
- : "Waiting for \(selectedBrowser.name) and \(targetName)…"
355
- runCLI(["sync", "--timeout", "300"]) { [weak self] success, output in
458
+ : isBrowserlessTarget
459
+ ? "Sending \(selectedBrowser.name) to Browserless \(browserlessRegion.uppercased()) only for this request…"
460
+ : "Waiting for \(selectedBrowser.name) and \(targetName)…"
461
+ var environment: [String: String] = [:]
462
+ var arguments = ["sync", "--timeout", isBrowserlessTarget ? "900" : "300"]
463
+ if isBrowserlessTarget {
464
+ guard let token = BrowserlessCredentialStore.read() else {
465
+ isSyncing = false
466
+ browserlessConfigured = false
467
+ updateEndpointRunningStatus()
468
+ return
469
+ }
470
+ environment["BROWSERLESS_TOKEN"] = token
471
+ arguments.append("--allow-cloud-upload")
472
+ beginUploadTracking()
473
+ }
474
+ activeSyncProcess = runCLI(arguments, environment: environment, onLine: { [weak self] line in
475
+ self?.handleBrowserlessProgress(line)
476
+ }) { [weak self] success, output in
356
477
  guard let self else { return }
478
+ self.activeSyncProcess = nil
357
479
  self.isSyncing = false
358
- let partial = success && (output.contains("Partially synced:") || output.contains("with warnings"))
359
- if success {
480
+ self.finishUploadTracking()
481
+ let partial = success && (
482
+ output.contains("Partially synced:")
483
+ || output.contains("with warnings")
484
+ || output.contains("omitted to fit")
485
+ || output.contains("could not be captured")
486
+ )
487
+ let canceled = output.contains("Browserless upload canceled") || output.contains("Temporary profile data was removed")
488
+ if canceled {
489
+ self.state = .canceled
490
+ self.primaryStatus = "Browserless upload canceled"
491
+ self.secondaryStatus = "No cloud profile was changed; temporary profile data was removed"
492
+ } else if success {
360
493
  self.state = partial ? .warning : .success
361
- self.primaryStatus = self.selectedTargetID == "codex"
494
+ self.primaryStatus = self.isBrowserlessTarget
495
+ ? (partial ? "Browserless profile uploaded with omissions" : "Browserless profile uploaded")
496
+ : self.selectedTargetID == "codex"
362
497
  ? (partial ? "Codex sync completed with warnings" : "Codex sessions updated")
363
498
  : (partial ? "Partially synced" : "Transfer complete")
364
499
  self.secondaryStatus = self.lastMeaningfulLine(output) ?? "\(self.selectedBrowser.name) and \(self.targetName) are up to date"
@@ -367,20 +502,54 @@ final class SyncModel: ObservableObject {
367
502
  self.primaryStatus = "Sync did not finish"
368
503
  self.secondaryStatus = self.lastMeaningfulLine(output) ?? (self.selectedTargetID == "codex"
369
504
  ? "Quit Codex completely, then try again"
505
+ : self.isBrowserlessTarget
506
+ ? "Check the API token, close the source browser, and try again"
370
507
  : "Keep both browsers open and check the extensions")
371
508
  }
372
- self.updateCodexRunningStatus()
509
+ self.updateEndpointRunningStatus()
373
510
  if showMenuBarAlert {
374
511
  self.postNativeAlert(
375
512
  title: self.primaryStatus,
376
513
  message: self.secondaryStatus,
377
- kind: success ? (partial ? .warning : .information) : .error
514
+ kind: canceled ? .information : success ? (partial ? .warning : .information) : .error
378
515
  )
379
516
  }
380
517
  }
381
518
  }
382
519
 
520
+ func cancelSync() {
521
+ guard isBrowserlessTarget, isSyncing, !uploadCanceling else { return }
522
+ uploadCanceling = true
523
+ primaryStatus = "Canceling Browserless upload"
524
+ secondaryStatus = "Stopping the temporary browser and removing its isolated workspace…"
525
+ postSyncState()
526
+ activeSyncProcess?.terminate()
527
+ }
528
+
529
+ func refreshBrowserlessPreflight(force: Bool = false) {
530
+ guard isBrowserlessTarget, selectedSourceID != "comet", !isInspectingBrowserlessProfile else { return }
531
+ if !force, assessedBrowserID == selectedSourceID, browserlessAssessment != nil { return }
532
+ isInspectingBrowserlessProfile = true
533
+ let sourceAtStart = selectedSourceID
534
+ runCLI(["browserless-preflight"]) { [weak self] success, output in
535
+ guard let self else { return }
536
+ self.isInspectingBrowserlessProfile = false
537
+ guard self.selectedSourceID == sourceAtStart else { return }
538
+ if success, let assessment = self.decodeLastJSON(BrowserlessProfileAssessment.self, from: output) {
539
+ self.browserlessAssessment = assessment
540
+ self.assessedBrowserID = sourceAtStart
541
+ } else if force {
542
+ self.browserlessAssessment = nil
543
+ self.assessedBrowserID = nil
544
+ }
545
+ }
546
+ }
547
+
383
548
  func setDailyEnabled(_ enabled: Bool) {
549
+ guard !isBrowserlessTarget else {
550
+ postNativeAlert(title: "Cloud uploads are manual-only", message: "Browser Cookie Bridge will never schedule Browserless uploads in the background.", kind: .information)
551
+ return
552
+ }
384
553
  dailyEnabled = enabled
385
554
  applySchedule(enabled)
386
555
  }
@@ -390,6 +559,10 @@ final class SyncModel: ObservableObject {
390
559
  }
391
560
 
392
561
  func setLoginSyncEnabled(_ enabled: Bool) {
562
+ guard !isBrowserlessTarget else {
563
+ postNativeAlert(title: "Cloud uploads are manual-only", message: "Login sync does not send authenticated state to Browserless.", kind: .information)
564
+ return
565
+ }
393
566
  loginSyncEnabled = enabled
394
567
  isWorking = true
395
568
  runCLI([enabled ? "enable-login-sync" : "disable-login-sync"]) { [weak self] success, output in
@@ -453,7 +626,10 @@ final class SyncModel: ObservableObject {
453
626
  "--cookies", cookiesEnabled ? "on" : "off",
454
627
  "--history", historyEnabled ? "on" : "off",
455
628
  "--menu-bar", menuBarEnabled ? "on" : "off",
456
- "--auto-check-updates", autoCheckUpdates ? "on" : "off"
629
+ "--auto-check-updates", autoCheckUpdates ? "on" : "off",
630
+ "--browserless-profile", browserlessProfileName,
631
+ "--browserless-region", browserlessRegion,
632
+ "--browserless-domains", browserlessOnlyDomains,
457
633
  ]
458
634
  runCLI(arguments) { [weak self] success, output in
459
635
  guard let self else { return }
@@ -461,7 +637,9 @@ final class SyncModel: ObservableObject {
461
637
  if success {
462
638
  self.state = .ready
463
639
  self.primaryStatus = successMessage
464
- self.secondaryStatus = "This choice is saved for manual and daily syncs"
640
+ self.secondaryStatus = self.isBrowserlessTarget
641
+ ? "Cloud uploads run only after you click Upload"
642
+ : "This choice is saved for manual and daily syncs"
465
643
  } else {
466
644
  self.state = .error
467
645
  self.primaryStatus = "Could not save import settings"
@@ -471,7 +649,8 @@ final class SyncModel: ObservableObject {
471
649
  self.extensionsReady = self.requiredExtensionIDs.allSatisfy {
472
650
  FileManager.default.fileExists(atPath: self.support.appending(path: "extension-\($0)/manifest.json").path)
473
651
  }
474
- self.updateCodexRunningStatus()
652
+ self.updateEndpointRunningStatus()
653
+ self.refreshBrowserlessPreflight()
475
654
  }
476
655
  }
477
656
 
@@ -500,26 +679,48 @@ final class SyncModel: ObservableObject {
500
679
  }
501
680
  }
502
681
 
503
- private func runCLI(_ arguments: [String], completion: @escaping @MainActor (Bool, String) -> Void) {
682
+ @discardableResult
683
+ private func runCLI(
684
+ _ arguments: [String],
685
+ environment: [String: String] = [:],
686
+ onLine: (@MainActor (String) -> Void)? = nil,
687
+ completion: @escaping @MainActor (Bool, String) -> Void
688
+ ) -> Process? {
504
689
  guard let config = loadConfig() else {
505
690
  completion(false, "Configuration missing. Run install-app again.")
506
- return
691
+ return nil
507
692
  }
508
693
  let process = Process()
509
694
  let output = Pipe()
695
+ let collector = ProcessOutputCollector()
510
696
  process.executableURL = URL(fileURLWithPath: config.nodePath)
511
697
  process.arguments = [runtimeCLI.path] + arguments
698
+ process.environment = ProcessInfo.processInfo.environment.merging(environment) { _, new in new }
512
699
  process.standardOutput = output
513
700
  process.standardError = output
701
+ output.fileHandleForReading.readabilityHandler = { handle in
702
+ let data = handle.availableData
703
+ guard !data.isEmpty else { return }
704
+ let lines = collector.append(data)
705
+ guard let onLine, !lines.isEmpty else { return }
706
+ Task { @MainActor in lines.forEach(onLine) }
707
+ }
514
708
  process.terminationHandler = { process in
515
- let data = output.fileHandleForReading.readDataToEndOfFile()
516
- let text = String(decoding: data, as: UTF8.self)
517
- Task { @MainActor in completion(process.terminationStatus == 0, text) }
709
+ output.fileHandleForReading.readabilityHandler = nil
710
+ let remainder = output.fileHandleForReading.readDataToEndOfFile()
711
+ let lines = collector.append(remainder, finish: true)
712
+ let text = collector.text
713
+ Task { @MainActor in
714
+ if let onLine { lines.forEach(onLine) }
715
+ completion(process.terminationStatus == 0, text)
716
+ }
518
717
  }
519
718
  do {
520
719
  try process.run()
720
+ return process
521
721
  } catch {
522
722
  completion(false, error.localizedDescription)
723
+ return nil
523
724
  }
524
725
  }
525
726
 
@@ -546,28 +747,112 @@ final class SyncModel: ObservableObject {
546
747
  return line.hasPrefix("Error: ") ? String(line.dropFirst(7)) : line
547
748
  }
548
749
 
750
+ private func decodeLastJSON<T: Decodable>(_ type: T.Type, from output: String) -> T? {
751
+ for line in output.split(separator: "\n").reversed() {
752
+ guard let data = String(line).data(using: .utf8),
753
+ let value = try? JSONDecoder().decode(type, from: data) else { continue }
754
+ return value
755
+ }
756
+ return nil
757
+ }
758
+
759
+ private func beginUploadTracking() {
760
+ uploadProgress = 0.01
761
+ uploadElapsedSeconds = 0
762
+ uploadStartedAt = Date()
763
+ uploadTimer?.invalidate()
764
+ uploadTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
765
+ Task { @MainActor in
766
+ guard let self, let started = self.uploadStartedAt else { return }
767
+ self.uploadElapsedSeconds = max(0, Int(Date().timeIntervalSince(started)))
768
+ }
769
+ }
770
+ postSyncState()
771
+ }
772
+
773
+ private func finishUploadTracking() {
774
+ uploadTimer?.invalidate()
775
+ uploadTimer = nil
776
+ uploadStartedAt = nil
777
+ uploadCanceling = false
778
+ postSyncState()
779
+ }
780
+
781
+ private func handleBrowserlessProgress(_ line: String) {
782
+ guard line.hasPrefix("BCB_PROGRESS "),
783
+ let data = String(line.dropFirst("BCB_PROGRESS ".count)).data(using: .utf8),
784
+ let event = try? JSONDecoder().decode(BrowserlessProgressEvent.self, from: data) else { return }
785
+ if let fraction = event.fraction { uploadProgress = min(max(fraction, uploadProgress), 1) }
786
+ if let assessment = event.assessment {
787
+ browserlessAssessment = assessment
788
+ assessedBrowserID = selectedSourceID
789
+ }
790
+ guard !uploadCanceling else { return }
791
+ primaryStatus = switch event.phase {
792
+ case "preflight": "Inspecting the local profile"
793
+ case "preflight-complete": "Profile preflight complete"
794
+ case "validating": "Checking Browserless profile"
795
+ case "copying": "Preparing an isolated profile copy"
796
+ case "launching", "waiting": "Starting the temporary browser"
797
+ case "capturing": "Capturing authenticated state"
798
+ case "uploading": "Uploading fitted profile state"
799
+ case "verifying": "Verifying the Browserless profile"
800
+ case "complete": "Browserless profile uploaded"
801
+ default: "Uploading authenticated state"
802
+ }
803
+ if let detail = event.detail { secondaryStatus = detail }
804
+ }
805
+
806
+ private func postSyncState() {
807
+ NotificationCenter.default.post(
808
+ name: .syncStateChanged,
809
+ object: SyncMenuState(uploading: isBrowserlessTarget && isSyncing, canceling: uploadCanceling)
810
+ )
811
+ }
812
+
549
813
  private var formattedTime: String {
550
814
  scheduleTime.formatted(date: .omitted, time: .shortened)
551
815
  }
552
816
 
553
817
  private var requiredExtensionIDs: [String] {
554
- selectedTargetID == "codex" ? [] : [selectedSourceID, selectedTargetID]
818
+ selectedTargetID == "codex" || selectedTargetID == "browserless" ? [] : [selectedSourceID, selectedTargetID]
555
819
  }
556
820
 
557
- private func updateCodexRunningStatus() {
821
+ private func updateEndpointRunningStatus() {
558
822
  let wasRunning = codexRunning
559
823
  codexRunning = NSWorkspace.shared.runningApplications.contains {
560
824
  $0.bundleIdentifier == "com.openai.codex"
561
825
  }
562
- guard selectedTargetID == "codex", !isSyncing else { return }
563
- if codexRunning {
826
+ sourceBrowserRunning = NSWorkspace.shared.runningApplications.contains {
827
+ $0.bundleIdentifier == selectedBrowser.bundleIdentifier
828
+ }
829
+ guard !isSyncing else { return }
830
+ if selectedTargetID == "codex" && codexRunning {
564
831
  state = .warning
565
832
  primaryStatus = "Quit Codex before syncing"
566
833
  secondaryStatus = "Close ChatGPT Codex completely so its local cookie database can be updated safely"
567
- } else if wasRunning && primaryStatus == "Quit Codex before syncing" {
834
+ } else if selectedTargetID == "codex" && wasRunning && primaryStatus == "Quit Codex before syncing" {
568
835
  state = .ready
569
836
  primaryStatus = "Ready to sync directly"
570
837
  secondaryStatus = "Codex is closed — a backup will be created before anything changes"
838
+ } else if isBrowserlessTarget {
839
+ if selectedSourceID == "comet" {
840
+ state = .warning
841
+ primaryStatus = "Comet capture is not supported"
842
+ secondaryStatus = "Choose Brave, Chrome, Edge, Arc, Vivaldi, or Opera for Browserless"
843
+ } else if !browserlessConfigured {
844
+ state = .warning
845
+ primaryStatus = "Connect Browserless"
846
+ secondaryStatus = "Your API token will be stored in macOS Keychain, never in the app configuration"
847
+ } else if sourceBrowserRunning {
848
+ state = .warning
849
+ primaryStatus = "Quit \(selectedBrowser.name) before uploading"
850
+ secondaryStatus = "Browserless captures a temporary copy of the closed profile, including local storage and IndexedDB"
851
+ } else {
852
+ state = .ready
853
+ primaryStatus = "Ready for an explicit cloud upload"
854
+ secondaryStatus = "Only this click sends authenticated state to Browserless \(browserlessRegion.uppercased())"
855
+ }
571
856
  }
572
857
  }
573
858
 
@@ -647,6 +932,7 @@ private struct AppConfig: Decodable {
647
932
  let targetBrowser: String?
648
933
  let imports: Imports?
649
934
  let ui: UISettings?
935
+ let browserless: BrowserlessSettings?
650
936
 
651
937
  struct Schedule: Decodable {
652
938
  let hour: Int
@@ -663,4 +949,84 @@ private struct AppConfig: Decodable {
663
949
  let openAtLogin: Bool?
664
950
  let autoCheckUpdates: Bool?
665
951
  }
952
+
953
+ struct BrowserlessSettings: Decodable {
954
+ let profileName: String?
955
+ let region: String?
956
+ let onlyDomains: [String]?
957
+ }
958
+ }
959
+
960
+ private enum BrowserlessCredentialStore {
961
+ private static let service = "com.apoorvdarshan.browser-cookie-bridge.browserless"
962
+ private static let account = "api-token"
963
+
964
+ static func save(_ token: String) throws {
965
+ let identity: [String: Any] = [
966
+ kSecClass as String: kSecClassGenericPassword,
967
+ kSecAttrService as String: service,
968
+ kSecAttrAccount as String: account,
969
+ ]
970
+ let update: [String: Any] = [kSecValueData as String: Data(token.utf8)]
971
+ let updateStatus = SecItemUpdate(identity as CFDictionary, update as CFDictionary)
972
+ if updateStatus == errSecSuccess { return }
973
+ guard updateStatus == errSecItemNotFound else {
974
+ throw NSError(domain: NSOSStatusErrorDomain, code: Int(updateStatus), userInfo: nil)
975
+ }
976
+ let item: [String: Any] = identity.merging([
977
+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
978
+ kSecValueData as String: Data(token.utf8),
979
+ ]) { _, new in new }
980
+ let status = SecItemAdd(item as CFDictionary, nil)
981
+ guard status == errSecSuccess else {
982
+ throw NSError(domain: NSOSStatusErrorDomain, code: Int(status), userInfo: nil)
983
+ }
984
+ }
985
+
986
+ static func read() -> String? {
987
+ let query: [String: Any] = [
988
+ kSecClass as String: kSecClassGenericPassword,
989
+ kSecAttrService as String: service,
990
+ kSecAttrAccount as String: account,
991
+ kSecReturnData as String: true,
992
+ kSecMatchLimit as String: kSecMatchLimitOne,
993
+ ]
994
+ var result: CFTypeRef?
995
+ guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
996
+ let data = result as? Data else { return nil }
997
+ return String(data: data, encoding: .utf8)
998
+ }
999
+
1000
+ static func delete() {
1001
+ let query: [String: Any] = [
1002
+ kSecClass as String: kSecClassGenericPassword,
1003
+ kSecAttrService as String: service,
1004
+ kSecAttrAccount as String: account,
1005
+ ]
1006
+ SecItemDelete(query as CFDictionary)
1007
+ }
1008
+ }
1009
+
1010
+ private final class ProcessOutputCollector: @unchecked Sendable {
1011
+ private let lock = NSLock()
1012
+ private var bytes = Data()
1013
+ private var pending = ""
1014
+
1015
+ var text: String {
1016
+ lock.withLock { String(decoding: bytes, as: UTF8.self) }
1017
+ }
1018
+
1019
+ func append(_ data: Data, finish: Bool = false) -> [String] {
1020
+ lock.withLock {
1021
+ bytes.append(data)
1022
+ pending += String(decoding: data, as: UTF8.self)
1023
+ var lines = pending.components(separatedBy: .newlines)
1024
+ if finish {
1025
+ pending = ""
1026
+ return lines.filter { !$0.isEmpty }
1027
+ }
1028
+ pending = lines.popLast() ?? ""
1029
+ return lines.filter { !$0.isEmpty }
1030
+ }
1031
+ }
666
1032
  }