browser-cookie-bridge 1.2.0 → 1.4.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.
@@ -6,6 +6,7 @@ extension Notification.Name {
6
6
  static let menuBarVisibilityChanged = Notification.Name("BraveCodexSync.menuBarVisibilityChanged")
7
7
  static let nativeAlert = Notification.Name("BraveCodexSync.nativeAlert")
8
8
  static let updateStateChanged = Notification.Name("BraveCodexSync.updateStateChanged")
9
+ static let syncStateChanged = Notification.Name("BraveCodexSync.syncStateChanged")
9
10
  }
10
11
 
11
12
  struct NativeAlert {
@@ -21,6 +22,31 @@ struct UpdateMenuState {
21
22
  let installing: Bool
22
23
  }
23
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
+
24
50
  struct BrowserChoice: Identifiable, Hashable {
25
51
  let id: String
26
52
  let name: String
@@ -31,7 +57,7 @@ struct BrowserChoice: Identifiable, Hashable {
31
57
 
32
58
  @MainActor
33
59
  final class SyncModel: ObservableObject {
34
- enum State { case ready, syncing, success, warning, error }
60
+ enum State { case ready, syncing, success, canceled, warning, error }
35
61
 
36
62
  let browsers = [
37
63
  BrowserChoice(id: "brave", name: "Brave", bundleIdentifier: "com.brave.Browser", applicationName: "Brave Browser", extensionURL: "brave://extensions"),
@@ -51,6 +77,7 @@ final class SyncModel: ObservableObject {
51
77
  @Published var openAtLogin = false
52
78
  @Published var menuBarEnabled = true
53
79
  @Published var autoCheckUpdates = true
80
+ @Published var autoRestartCodex = false
54
81
  @Published var isCheckingForUpdates = false
55
82
  @Published var isInstallingUpdate = false
56
83
  @Published var availableUpdateVersion: String?
@@ -67,6 +94,11 @@ final class SyncModel: ObservableObject {
67
94
  @Published var browserlessRegion = "sfo"
68
95
  @Published var browserlessOnlyDomains = ""
69
96
  @Published var showingBrowserlessSetup = false
97
+ @Published var browserlessAssessment: BrowserlessProfileAssessment?
98
+ @Published var isInspectingBrowserlessProfile = false
99
+ @Published var uploadProgress = 0.0
100
+ @Published var uploadElapsedSeconds = 0
101
+ @Published var uploadCanceling = false
70
102
  @Published var primaryStatus = "Ready to sync"
71
103
  @Published var secondaryStatus = "Choose what to move, then start a transfer"
72
104
 
@@ -80,6 +112,10 @@ final class SyncModel: ObservableObject {
80
112
  private var updateTimer: Timer?
81
113
  private var didCheckAfterLaunch = false
82
114
  private var didConsumeUpdateResult = false
115
+ private var assessedBrowserID: String?
116
+ private var activeSyncProcess: Process?
117
+ private var uploadTimer: Timer?
118
+ private var uploadStartedAt: Date?
83
119
 
84
120
  var selectedBrowser: BrowserChoice {
85
121
  browsers.first(where: { $0.id == selectedSourceID }) ?? browsers[0]
@@ -93,11 +129,16 @@ final class SyncModel: ObservableObject {
93
129
  var targetName: String {
94
130
  isBrowserlessTarget ? "Browserless Cloud" : selectedTargetBrowser?.name ?? "ChatGPT Codex"
95
131
  }
96
- var codexBlocked: Bool { selectedTargetID == "codex" && codexRunning }
132
+ var codexBlocked: Bool { selectedTargetID == "codex" && codexRunning && !autoRestartCodex }
97
133
  var browserlessBlocked: Bool {
98
134
  isBrowserlessTarget && (!browserlessConfigured || sourceBrowserRunning || selectedSourceID == "comet")
99
135
  }
100
136
  var syncBlocked: Bool { codexBlocked || browserlessBlocked }
137
+ var formattedUploadElapsed: String {
138
+ let minutes = uploadElapsedSeconds / 60
139
+ let seconds = uploadElapsedSeconds % 60
140
+ return String(format: "%d:%02d", minutes, seconds)
141
+ }
101
142
  var sourceIcon: NSImage { browserIcon(selectedBrowser) }
102
143
  var targetIcon: NSImage {
103
144
  isBrowserlessTarget ? browserlessIcon : selectedTargetBrowser.map(browserIcon) ?? codexIcon
@@ -141,6 +182,7 @@ final class SyncModel: ObservableObject {
141
182
  let calendar = Calendar.current
142
183
  scheduleTime = calendar.date(bySettingHour: 9, minute: 0, second: 0, of: Date()) ?? Date()
143
184
  updateEndpointRunningStatus()
185
+ refreshBrowserlessPreflight()
144
186
  endpointStatusTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
145
187
  Task { @MainActor in self?.updateEndpointRunningStatus() }
146
188
  }
@@ -220,6 +262,7 @@ final class SyncModel: ObservableObject {
220
262
  historyEnabled = config.imports?.history ?? false
221
263
  menuBarEnabled = config.ui?.menuBar ?? true
222
264
  autoCheckUpdates = config.ui?.autoCheckUpdates ?? true
265
+ autoRestartCodex = config.ui?.autoRestartCodex ?? false
223
266
  browserlessProfileName = config.browserless?.profileName ?? "browser-cookie-bridge"
224
267
  browserlessRegion = config.browserless?.region ?? "sfo"
225
268
  browserlessOnlyDomains = (config.browserless?.onlyDomains ?? []).joined(separator: ", ")
@@ -230,6 +273,7 @@ final class SyncModel: ObservableObject {
230
273
  FileManager.default.fileExists(atPath: support.appending(path: "extension-\($0)/manifest.json").path)
231
274
  }
232
275
  updateEndpointRunningStatus()
276
+ refreshBrowserlessPreflight()
233
277
  consumeUpdateResultIfNeeded()
234
278
  if autoCheckUpdates && !didCheckAfterLaunch {
235
279
  didCheckAfterLaunch = true
@@ -240,6 +284,8 @@ final class SyncModel: ObservableObject {
240
284
  func selectSource(_ id: String) {
241
285
  guard browsers.contains(where: { $0.id == id }), id != selectedSourceID, id != selectedTargetID else { return }
242
286
  selectedSourceID = id
287
+ browserlessAssessment = nil
288
+ assessedBrowserID = nil
243
289
  persistPreferences(successMessage: "Export source changed to \(selectedBrowser.name)")
244
290
  }
245
291
 
@@ -250,6 +296,7 @@ final class SyncModel: ObservableObject {
250
296
  persistPreferences(successMessage: "Import destination changed to \(targetName)")
251
297
  updateEndpointRunningStatus()
252
298
  if id == "browserless" && !browserlessConfigured { showingBrowserlessSetup = true }
299
+ if id == "browserless" { refreshBrowserlessPreflight() }
253
300
  }
254
301
 
255
302
  func setCookiesEnabled(_ enabled: Bool) {
@@ -277,6 +324,7 @@ final class SyncModel: ObservableObject {
277
324
  browserlessOnlyDomains = onlyDomains
278
325
  showingBrowserlessSetup = false
279
326
  persistPreferences(successMessage: "Browserless connected — uploads remain manual")
327
+ refreshBrowserlessPreflight(force: true)
280
328
  } catch {
281
329
  postNativeAlert(title: "Could not save Browserless token", message: error.localizedDescription, kind: .error)
282
330
  }
@@ -301,6 +349,11 @@ final class SyncModel: ObservableObject {
301
349
  if enabled { checkForUpdates() }
302
350
  }
303
351
 
352
+ func setAutoRestartCodex(_ enabled: Bool) {
353
+ autoRestartCodex = enabled
354
+ persistPreferences(successMessage: enabled ? "Automatic Codex restart enabled" : "Automatic Codex restart disabled")
355
+ }
356
+
304
357
  func checkForUpdates(showAlert: Bool = false) {
305
358
  guard !isCheckingForUpdates, !isInstallingUpdate else { return }
306
359
  guard let url = URL(string: "https://api.github.com/repos/apoorvdarshan/browser-cookie-bridge/releases/latest") else { return }
@@ -387,6 +440,10 @@ final class SyncModel: ObservableObject {
387
440
 
388
441
  func syncNow(showMenuBarAlert: Bool = false) {
389
442
  guard !isSyncing else {
443
+ if isBrowserlessTarget {
444
+ cancelSync()
445
+ return
446
+ }
390
447
  if showMenuBarAlert {
391
448
  postNativeAlert(title: "Sync already running", message: "Wait for the current transfer to finish.", kind: .information)
392
449
  }
@@ -399,7 +456,70 @@ final class SyncModel: ObservableObject {
399
456
  }
400
457
  return
401
458
  }
459
+ if selectedTargetID == "codex" && codexRunning && autoRestartCodex {
460
+ forceQuitCodexThenSync(showMenuBarAlert: showMenuBarAlert)
461
+ return
462
+ }
463
+ startSync(showMenuBarAlert: showMenuBarAlert, reopenCodexOnSuccess: false)
464
+ }
465
+
466
+ private func forceQuitCodexThenSync(showMenuBarAlert: Bool) {
467
+ isSyncing = true
468
+ uploadCanceling = false
469
+ state = .syncing
470
+ primaryStatus = "Closing Codex for sync"
471
+ secondaryStatus = "Force quitting ChatGPT Codex and waiting for its browser database to close…"
472
+ let applications = NSWorkspace.shared.runningApplications.filter {
473
+ $0.bundleIdentifier == "com.openai.codex"
474
+ }
475
+ guard !applications.isEmpty, applications.allSatisfy({ $0.forceTerminate() }) else {
476
+ finishCodexPreparationFailure(
477
+ message: "macOS could not force quit ChatGPT Codex. Quit it manually, then try again.",
478
+ showMenuBarAlert: showMenuBarAlert
479
+ )
480
+ return
481
+ }
482
+ waitForCodexToQuit(attemptsRemaining: 50, showMenuBarAlert: showMenuBarAlert)
483
+ }
484
+
485
+ private func waitForCodexToQuit(attemptsRemaining: Int, showMenuBarAlert: Bool) {
486
+ let stillRunning = NSWorkspace.shared.runningApplications.contains {
487
+ $0.bundleIdentifier == "com.openai.codex"
488
+ }
489
+ if !stillRunning {
490
+ codexRunning = false
491
+ secondaryStatus = "Codex is closed — waiting briefly for its database to be released…"
492
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in
493
+ self?.startSync(showMenuBarAlert: showMenuBarAlert, reopenCodexOnSuccess: true)
494
+ }
495
+ return
496
+ }
497
+ guard attemptsRemaining > 0 else {
498
+ finishCodexPreparationFailure(
499
+ message: "ChatGPT Codex did not close within 10 seconds. Quit it manually, then try again.",
500
+ showMenuBarAlert: showMenuBarAlert
501
+ )
502
+ return
503
+ }
504
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
505
+ self?.waitForCodexToQuit(attemptsRemaining: attemptsRemaining - 1, showMenuBarAlert: showMenuBarAlert)
506
+ }
507
+ }
508
+
509
+ private func finishCodexPreparationFailure(message: String, showMenuBarAlert: Bool) {
510
+ isSyncing = false
511
+ state = .error
512
+ primaryStatus = "Could not close Codex"
513
+ secondaryStatus = message
514
+ updateEndpointRunningStatus()
515
+ if showMenuBarAlert {
516
+ postNativeAlert(title: primaryStatus, message: secondaryStatus, kind: .error)
517
+ }
518
+ }
519
+
520
+ private func startSync(showMenuBarAlert: Bool, reopenCodexOnSuccess: Bool) {
402
521
  isSyncing = true
522
+ uploadCanceling = false
403
523
  state = .syncing
404
524
  primaryStatus = isBrowserlessTarget ? "Uploading authenticated state" : "Transferring selected data"
405
525
  secondaryStatus = selectedTargetID == "codex"
@@ -408,7 +528,7 @@ final class SyncModel: ObservableObject {
408
528
  ? "Sending \(selectedBrowser.name) to Browserless \(browserlessRegion.uppercased()) only for this request…"
409
529
  : "Waiting for \(selectedBrowser.name) and \(targetName)…"
410
530
  var environment: [String: String] = [:]
411
- var arguments = ["sync", "--timeout", "300"]
531
+ var arguments = ["sync", "--timeout", isBrowserlessTarget ? "900" : "300"]
412
532
  if isBrowserlessTarget {
413
533
  guard let token = BrowserlessCredentialStore.read() else {
414
534
  isSyncing = false
@@ -418,15 +538,30 @@ final class SyncModel: ObservableObject {
418
538
  }
419
539
  environment["BROWSERLESS_TOKEN"] = token
420
540
  arguments.append("--allow-cloud-upload")
541
+ beginUploadTracking()
421
542
  }
422
- runCLI(arguments, environment: environment) { [weak self] success, output in
543
+ activeSyncProcess = runCLI(arguments, environment: environment, onLine: { [weak self] line in
544
+ self?.handleBrowserlessProgress(line)
545
+ }) { [weak self] success, output in
423
546
  guard let self else { return }
547
+ self.activeSyncProcess = nil
424
548
  self.isSyncing = false
425
- let partial = success && (output.contains("Partially synced:") || output.contains("with warnings"))
426
- if success {
549
+ self.finishUploadTracking()
550
+ let partial = success && (
551
+ output.contains("Partially synced:")
552
+ || output.contains("with warnings")
553
+ || output.contains("omitted to fit")
554
+ || output.contains("could not be captured")
555
+ )
556
+ let canceled = output.contains("Browserless upload canceled") || output.contains("Temporary profile data was removed")
557
+ if canceled {
558
+ self.state = .canceled
559
+ self.primaryStatus = "Browserless upload canceled"
560
+ self.secondaryStatus = "No cloud profile was changed; temporary profile data was removed"
561
+ } else if success {
427
562
  self.state = partial ? .warning : .success
428
563
  self.primaryStatus = self.isBrowserlessTarget
429
- ? "Browserless profile uploaded"
564
+ ? (partial ? "Browserless profile uploaded with omissions" : "Browserless profile uploaded")
430
565
  : self.selectedTargetID == "codex"
431
566
  ? (partial ? "Codex sync completed with warnings" : "Codex sessions updated")
432
567
  : (partial ? "Partially synced" : "Transfer complete")
@@ -441,16 +576,88 @@ final class SyncModel: ObservableObject {
441
576
  : "Keep both browsers open and check the extensions")
442
577
  }
443
578
  self.updateEndpointRunningStatus()
444
- if showMenuBarAlert {
579
+ if success && reopenCodexOnSuccess {
580
+ self.reopenCodexAfterSuccessfulSync(
581
+ partial: partial,
582
+ syncSummary: self.secondaryStatus,
583
+ showMenuBarAlert: showMenuBarAlert
584
+ )
585
+ } else if showMenuBarAlert {
445
586
  self.postNativeAlert(
446
587
  title: self.primaryStatus,
447
588
  message: self.secondaryStatus,
448
- kind: success ? (partial ? .warning : .information) : .error
589
+ kind: canceled ? .information : success ? (partial ? .warning : .information) : .error
449
590
  )
450
591
  }
451
592
  }
452
593
  }
453
594
 
595
+ private func reopenCodexAfterSuccessfulSync(partial: Bool, syncSummary: String, showMenuBarAlert: Bool) {
596
+ let transferResult = syncSummary.replacingOccurrences(
597
+ of: "Reopen Codex to use the updated sessions. ",
598
+ with: ""
599
+ )
600
+ guard let codexURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.openai.codex") else {
601
+ state = .warning
602
+ primaryStatus = "Codex sessions updated, but Codex was not reopened"
603
+ secondaryStatus = "\(transferResult)\n\nCodex could not be found. Open it manually to use the updated sessions."
604
+ if showMenuBarAlert {
605
+ postNativeAlert(title: primaryStatus, message: secondaryStatus, kind: .warning)
606
+ }
607
+ return
608
+ }
609
+ NSWorkspace.shared.openApplication(at: codexURL, configuration: NSWorkspace.OpenConfiguration()) { [weak self] _, error in
610
+ Task { @MainActor in
611
+ guard let self else { return }
612
+ if let error {
613
+ self.state = .warning
614
+ self.primaryStatus = "Codex sessions updated, but Codex was not reopened"
615
+ self.secondaryStatus = "\(transferResult)\n\nCodex could not be reopened: \(error.localizedDescription)"
616
+ } else {
617
+ self.codexRunning = true
618
+ self.state = partial ? .warning : .success
619
+ self.primaryStatus = partial ? "Codex sync completed with warnings" : "Codex sessions updated"
620
+ self.secondaryStatus = "\(transferResult)\n\nChatGPT Codex reopened successfully."
621
+ }
622
+ if showMenuBarAlert {
623
+ self.postNativeAlert(
624
+ title: self.primaryStatus,
625
+ message: self.secondaryStatus,
626
+ kind: self.state == .success ? .information : .warning
627
+ )
628
+ }
629
+ }
630
+ }
631
+ }
632
+
633
+ func cancelSync() {
634
+ guard isBrowserlessTarget, isSyncing, !uploadCanceling else { return }
635
+ uploadCanceling = true
636
+ primaryStatus = "Canceling Browserless upload"
637
+ secondaryStatus = "Stopping the temporary browser and removing its isolated workspace…"
638
+ postSyncState()
639
+ activeSyncProcess?.terminate()
640
+ }
641
+
642
+ func refreshBrowserlessPreflight(force: Bool = false) {
643
+ guard isBrowserlessTarget, selectedSourceID != "comet", !isInspectingBrowserlessProfile else { return }
644
+ if !force, assessedBrowserID == selectedSourceID, browserlessAssessment != nil { return }
645
+ isInspectingBrowserlessProfile = true
646
+ let sourceAtStart = selectedSourceID
647
+ runCLI(["browserless-preflight"]) { [weak self] success, output in
648
+ guard let self else { return }
649
+ self.isInspectingBrowserlessProfile = false
650
+ guard self.selectedSourceID == sourceAtStart else { return }
651
+ if success, let assessment = self.decodeLastJSON(BrowserlessProfileAssessment.self, from: output) {
652
+ self.browserlessAssessment = assessment
653
+ self.assessedBrowserID = sourceAtStart
654
+ } else if force {
655
+ self.browserlessAssessment = nil
656
+ self.assessedBrowserID = nil
657
+ }
658
+ }
659
+ }
660
+
454
661
  func setDailyEnabled(_ enabled: Bool) {
455
662
  guard !isBrowserlessTarget else {
456
663
  postNativeAlert(title: "Cloud uploads are manual-only", message: "Browser Cookie Bridge will never schedule Browserless uploads in the background.", kind: .information)
@@ -533,6 +740,7 @@ final class SyncModel: ObservableObject {
533
740
  "--history", historyEnabled ? "on" : "off",
534
741
  "--menu-bar", menuBarEnabled ? "on" : "off",
535
742
  "--auto-check-updates", autoCheckUpdates ? "on" : "off",
743
+ "--auto-restart-codex", autoRestartCodex ? "on" : "off",
536
744
  "--browserless-profile", browserlessProfileName,
537
745
  "--browserless-region", browserlessRegion,
538
746
  "--browserless-domains", browserlessOnlyDomains,
@@ -556,6 +764,7 @@ final class SyncModel: ObservableObject {
556
764
  FileManager.default.fileExists(atPath: self.support.appending(path: "extension-\($0)/manifest.json").path)
557
765
  }
558
766
  self.updateEndpointRunningStatus()
767
+ self.refreshBrowserlessPreflight()
559
768
  }
560
769
  }
561
770
 
@@ -584,27 +793,48 @@ final class SyncModel: ObservableObject {
584
793
  }
585
794
  }
586
795
 
587
- private func runCLI(_ arguments: [String], environment: [String: String] = [:], completion: @escaping @MainActor (Bool, String) -> Void) {
796
+ @discardableResult
797
+ private func runCLI(
798
+ _ arguments: [String],
799
+ environment: [String: String] = [:],
800
+ onLine: (@MainActor (String) -> Void)? = nil,
801
+ completion: @escaping @MainActor (Bool, String) -> Void
802
+ ) -> Process? {
588
803
  guard let config = loadConfig() else {
589
804
  completion(false, "Configuration missing. Run install-app again.")
590
- return
805
+ return nil
591
806
  }
592
807
  let process = Process()
593
808
  let output = Pipe()
809
+ let collector = ProcessOutputCollector()
594
810
  process.executableURL = URL(fileURLWithPath: config.nodePath)
595
811
  process.arguments = [runtimeCLI.path] + arguments
596
812
  process.environment = ProcessInfo.processInfo.environment.merging(environment) { _, new in new }
597
813
  process.standardOutput = output
598
814
  process.standardError = output
815
+ output.fileHandleForReading.readabilityHandler = { handle in
816
+ let data = handle.availableData
817
+ guard !data.isEmpty else { return }
818
+ let lines = collector.append(data)
819
+ guard let onLine, !lines.isEmpty else { return }
820
+ Task { @MainActor in lines.forEach(onLine) }
821
+ }
599
822
  process.terminationHandler = { process in
600
- let data = output.fileHandleForReading.readDataToEndOfFile()
601
- let text = String(decoding: data, as: UTF8.self)
602
- Task { @MainActor in completion(process.terminationStatus == 0, text) }
823
+ output.fileHandleForReading.readabilityHandler = nil
824
+ let remainder = output.fileHandleForReading.readDataToEndOfFile()
825
+ let lines = collector.append(remainder, finish: true)
826
+ let text = collector.text
827
+ Task { @MainActor in
828
+ if let onLine { lines.forEach(onLine) }
829
+ completion(process.terminationStatus == 0, text)
830
+ }
603
831
  }
604
832
  do {
605
833
  try process.run()
834
+ return process
606
835
  } catch {
607
836
  completion(false, error.localizedDescription)
837
+ return nil
608
838
  }
609
839
  }
610
840
 
@@ -631,6 +861,69 @@ final class SyncModel: ObservableObject {
631
861
  return line.hasPrefix("Error: ") ? String(line.dropFirst(7)) : line
632
862
  }
633
863
 
864
+ private func decodeLastJSON<T: Decodable>(_ type: T.Type, from output: String) -> T? {
865
+ for line in output.split(separator: "\n").reversed() {
866
+ guard let data = String(line).data(using: .utf8),
867
+ let value = try? JSONDecoder().decode(type, from: data) else { continue }
868
+ return value
869
+ }
870
+ return nil
871
+ }
872
+
873
+ private func beginUploadTracking() {
874
+ uploadProgress = 0.01
875
+ uploadElapsedSeconds = 0
876
+ uploadStartedAt = Date()
877
+ uploadTimer?.invalidate()
878
+ uploadTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
879
+ Task { @MainActor in
880
+ guard let self, let started = self.uploadStartedAt else { return }
881
+ self.uploadElapsedSeconds = max(0, Int(Date().timeIntervalSince(started)))
882
+ }
883
+ }
884
+ postSyncState()
885
+ }
886
+
887
+ private func finishUploadTracking() {
888
+ uploadTimer?.invalidate()
889
+ uploadTimer = nil
890
+ uploadStartedAt = nil
891
+ uploadCanceling = false
892
+ postSyncState()
893
+ }
894
+
895
+ private func handleBrowserlessProgress(_ line: String) {
896
+ guard line.hasPrefix("BCB_PROGRESS "),
897
+ let data = String(line.dropFirst("BCB_PROGRESS ".count)).data(using: .utf8),
898
+ let event = try? JSONDecoder().decode(BrowserlessProgressEvent.self, from: data) else { return }
899
+ if let fraction = event.fraction { uploadProgress = min(max(fraction, uploadProgress), 1) }
900
+ if let assessment = event.assessment {
901
+ browserlessAssessment = assessment
902
+ assessedBrowserID = selectedSourceID
903
+ }
904
+ guard !uploadCanceling else { return }
905
+ primaryStatus = switch event.phase {
906
+ case "preflight": "Inspecting the local profile"
907
+ case "preflight-complete": "Profile preflight complete"
908
+ case "validating": "Checking Browserless profile"
909
+ case "copying": "Preparing an isolated profile copy"
910
+ case "launching", "waiting": "Starting the temporary browser"
911
+ case "capturing": "Capturing authenticated state"
912
+ case "uploading": "Uploading fitted profile state"
913
+ case "verifying": "Verifying the Browserless profile"
914
+ case "complete": "Browserless profile uploaded"
915
+ default: "Uploading authenticated state"
916
+ }
917
+ if let detail = event.detail { secondaryStatus = detail }
918
+ }
919
+
920
+ private func postSyncState() {
921
+ NotificationCenter.default.post(
922
+ name: .syncStateChanged,
923
+ object: SyncMenuState(uploading: isBrowserlessTarget && isSyncing, canceling: uploadCanceling)
924
+ )
925
+ }
926
+
634
927
  private var formattedTime: String {
635
928
  scheduleTime.formatted(date: .omitted, time: .shortened)
636
929
  }
@@ -648,7 +941,13 @@ final class SyncModel: ObservableObject {
648
941
  $0.bundleIdentifier == selectedBrowser.bundleIdentifier
649
942
  }
650
943
  guard !isSyncing else { return }
651
- if selectedTargetID == "codex" && codexRunning {
944
+ if selectedTargetID == "codex" && codexRunning && autoRestartCodex {
945
+ if state == .ready || primaryStatus == "Quit Codex before syncing" {
946
+ state = .ready
947
+ primaryStatus = "Ready to sync and restart Codex"
948
+ secondaryStatus = "Sync will force quit Codex and reopen it only after a successful transfer"
949
+ }
950
+ } else if selectedTargetID == "codex" && codexRunning {
652
951
  state = .warning
653
952
  primaryStatus = "Quit Codex before syncing"
654
953
  secondaryStatus = "Close ChatGPT Codex completely so its local cookie database can be updated safely"
@@ -769,6 +1068,7 @@ private struct AppConfig: Decodable {
769
1068
  let menuBar: Bool?
770
1069
  let openAtLogin: Bool?
771
1070
  let autoCheckUpdates: Bool?
1071
+ let autoRestartCodex: Bool?
772
1072
  }
773
1073
 
774
1074
  struct BrowserlessSettings: Decodable {
@@ -827,3 +1127,27 @@ private enum BrowserlessCredentialStore {
827
1127
  SecItemDelete(query as CFDictionary)
828
1128
  }
829
1129
  }
1130
+
1131
+ private final class ProcessOutputCollector: @unchecked Sendable {
1132
+ private let lock = NSLock()
1133
+ private var bytes = Data()
1134
+ private var pending = ""
1135
+
1136
+ var text: String {
1137
+ lock.withLock { String(decoding: bytes, as: UTF8.self) }
1138
+ }
1139
+
1140
+ func append(_ data: Data, finish: Bool = false) -> [String] {
1141
+ lock.withLock {
1142
+ bytes.append(data)
1143
+ pending += String(decoding: data, as: UTF8.self)
1144
+ var lines = pending.components(separatedBy: .newlines)
1145
+ if finish {
1146
+ pending = ""
1147
+ return lines.filter { !$0.isEmpty }
1148
+ }
1149
+ pending = lines.popLast() ?? ""
1150
+ return lines.filter { !$0.isEmpty }
1151
+ }
1152
+ }
1153
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-cookie-bridge",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Local-first cookie and session transfer for macOS with optional Browserless upload",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "test": "node --test test/*.test.js",
11
- "check": "node --check bin/brave-codex-cookie-sync.js && node --check src/app-installer.js && node --check src/browserless.js && node --check src/browserless-runner.js && node --check src/broker.js && node --check src/chromium-reader.js && node --check src/cli.js && node --check src/codex-direct-import.js && node --check src/config.js && node --check src/paths.js && node --check src/scheduler.js && node --check src/updater.js && node --check scripts/build-dmg.js && node --check extension-template/background.js && node --check web/server.js && node --check web/script.js",
11
+ "check": "node --check bin/brave-codex-cookie-sync.js && node --check src/app-installer.js && node --check src/browserless.js && node --check src/browserless-preflight.js && node --check src/browserless-runner.js && node --check src/broker.js && node --check src/chromium-reader.js && node --check src/cli.js && node --check src/codex-direct-import.js && node --check src/config.js && node --check src/paths.js && node --check src/scheduler.js && node --check src/updater.js && node --check scripts/build-dmg.js && node --check extension-template/background.js && node --check web/server.js && node --check web/script.js",
12
12
  "release:check": "node scripts/check-release-version.js",
13
13
  "build:app": "node bin/brave-codex-cookie-sync.js install-app --no-open",
14
14
  "build:dmg": "node scripts/build-dmg.js",
@@ -0,0 +1,113 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export const BROWSERLESS_SERVER_ARTIFACT_CAP_BYTES = 2 * 1024 * 1024;
5
+
6
+ const MEBIBYTE = 1024 * 1024;
7
+ const GIBIBYTE = 1024 * MEBIBYTE;
8
+
9
+ export function inspectBrowserlessProfile({ profilePath } = {}) {
10
+ if (!profilePath || !fs.existsSync(profilePath)) {
11
+ throw new Error("The local browser profile could not be inspected.");
12
+ }
13
+
14
+ const profileBytes = directorySize(profilePath);
15
+ const indexedDBBytes = directorySize(path.join(profilePath, "IndexedDB"));
16
+ const localStorageBytes = directorySize(path.join(profilePath, "Local Storage"));
17
+ const freeBytes = availableBytes(profilePath);
18
+ const severity = sizeSeverity(indexedDBBytes);
19
+ const temporarySpaceWarning = Number.isFinite(freeBytes) && freeBytes < profileBytes + 256 * MEBIBYTE;
20
+
21
+ return {
22
+ profilePath,
23
+ profileBytes,
24
+ indexedDBBytes,
25
+ localStorageBytes,
26
+ freeBytes,
27
+ severity,
28
+ temporarySpaceWarning,
29
+ serverArtifactCapBytes: BROWSERLESS_SERVER_ARTIFACT_CAP_BYTES,
30
+ summary: profileSummary({
31
+ profileBytes,
32
+ indexedDBBytes,
33
+ localStorageBytes,
34
+ freeBytes,
35
+ severity,
36
+ temporarySpaceWarning,
37
+ }),
38
+ };
39
+ }
40
+
41
+ export function sizeSeverity(indexedDBBytes) {
42
+ if (indexedDBBytes >= GIBIBYTE) return "extreme";
43
+ if (indexedDBBytes >= 500 * MEBIBYTE) return "high";
44
+ if (indexedDBBytes >= 100 * MEBIBYTE) return "elevated";
45
+ return "normal";
46
+ }
47
+
48
+ export function formatBytes(bytes) {
49
+ if (!Number.isFinite(bytes) || bytes < 0) return "unknown";
50
+ if (bytes < 1024) return `${bytes} B`;
51
+ const units = ["KB", "MB", "GB", "TB"];
52
+ let value = bytes;
53
+ let unit = -1;
54
+ do {
55
+ value /= 1024;
56
+ unit += 1;
57
+ } while (value >= 1024 && unit < units.length - 1);
58
+ const digits = value >= 100 ? 0 : value >= 10 ? 1 : 2;
59
+ return `${value.toFixed(digits)} ${units[unit]}`;
60
+ }
61
+
62
+ function directorySize(root) {
63
+ try {
64
+ if (fs.lstatSync(root).isSymbolicLink()) return 0;
65
+ } catch {
66
+ return 0;
67
+ }
68
+ let total = 0;
69
+ const pending = [root];
70
+ while (pending.length > 0) {
71
+ const current = pending.pop();
72
+ let entries;
73
+ try {
74
+ entries = fs.readdirSync(current, { withFileTypes: true });
75
+ } catch {
76
+ continue;
77
+ }
78
+ for (const entry of entries) {
79
+ const candidate = path.join(current, entry.name);
80
+ if (entry.isSymbolicLink()) continue;
81
+ if (entry.isDirectory()) {
82
+ pending.push(candidate);
83
+ continue;
84
+ }
85
+ if (!entry.isFile()) continue;
86
+ try {
87
+ total += fs.statSync(candidate).size;
88
+ } catch {}
89
+ }
90
+ }
91
+ return total;
92
+ }
93
+
94
+ function availableBytes(candidate) {
95
+ try {
96
+ const statistics = fs.statfsSync(candidate);
97
+ return Number(statistics.bavail) * Number(statistics.bsize);
98
+ } catch {
99
+ return Number.NaN;
100
+ }
101
+ }
102
+
103
+ function profileSummary({ profileBytes, indexedDBBytes, localStorageBytes, freeBytes, severity, temporarySpaceWarning }) {
104
+ const parts = [
105
+ `${formatBytes(profileBytes)} profile`,
106
+ `${formatBytes(indexedDBBytes)} IndexedDB`,
107
+ `${formatBytes(localStorageBytes)} local storage`,
108
+ ];
109
+ if (Number.isFinite(freeBytes)) parts.push(`${formatBytes(freeBytes)} free`);
110
+ if (temporarySpaceWarning) parts.push("low temporary disk space");
111
+ else if (severity !== "normal") parts.push(`${severity} IndexedDB load`);
112
+ return parts.join(" · ");
113
+ }