browser-cookie-bridge 1.2.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.
package/README.md CHANGED
@@ -44,7 +44,7 @@
44
44
 
45
45
  ---
46
46
 
47
- > **Version 1.2.0 adds an optional Browserless Cloud destination.** It is destination-only, manual-only, and visually separated from local transfers. Importing into ChatGPT Codex remains an intentionally unsupported direct integration and requires Codex to be completely closed.
47
+ > **Version 1.3.0 makes large Browserless uploads observable and recoverable.** The app measures the local profile and IndexedDB without reading their contents, streams capture progress and elapsed time, supports cancellation with deterministic temporary-data cleanup, reports auto-fit omissions, and verifies the cloud profile after upload.
48
48
 
49
49
  ## Why Browser Cookie Bridge
50
50
 
@@ -57,7 +57,7 @@ Browser Cookie Bridge gives those browser profiles a small, native control panel
57
57
  - 🍪 **Cookie and session transfer** — cookies are enabled by default, including supported domain, path, expiry, security, `SameSite`, and partition attributes.
58
58
  - 🌐 **Seven Chromium browsers** — Brave, Chrome, Edge, Arc, Vivaldi, Opera, and Perplexity Comet can be sources or destinations.
59
59
  - ✨ **ChatGPT Codex import** — merge selected local browser data into Codex's built-in browser; Codex is destination-only.
60
- - ☁️ **Optional Browserless upload** — create or refresh a Browserless authenticated profile with cookies, local storage, and IndexedDB; Browserless is destination-only and manual-only.
60
+ - ☁️ **Optional Browserless upload** — create or refresh a Browserless authenticated profile with cookies, local storage, and IndexedDB; see a local size preflight, live progress, cancellation, and post-upload verification.
61
61
  - 🕘 **Background automation** — sync when you sign in, at a fixed daily time, or whenever you choose.
62
62
  - ◉ **Native menu-bar app** — closing the window removes the Dock icon while the helper continues running.
63
63
  - 🧯 **Backup and rollback** — Codex's database is backed up, modified on a separate copy, integrity-checked, and restored if replacement fails.
@@ -151,7 +151,11 @@ This path uses the official Browserless CLI and is deliberately separate from lo
151
151
  3. Quit the selected source browser so its profile can be copied consistently.
152
152
  4. Review the cloud warning and click **Upload now**.
153
153
 
154
- The upload creates the named Browserless profile the first time and refreshes it on later runs. It may contain cookies, local storage, and IndexedDB; history and saved passwords are excluded. Browserless uploads never run from Daily sync or Sync at login. Browser Cookie Bridge disables Browserless CLI telemetry for this integration. Comet is not currently supported by the Browserless capture CLI.
154
+ The upload creates the named Browserless profile the first time and refreshes it on later runs. It may contain cookies, local storage, and IndexedDB; history and saved passwords are excluded. The app measures the profile, IndexedDB, local storage, and available disk space locally before capture. Progress and elapsed time remain visible, Cancel upload terminates the isolated capture process group, and the dedicated temporary workspace is removed after success, failure, timeout, or cancellation.
155
+
156
+ Browserless currently caps the serialized authenticated-profile artifact at **2 MB**. A large on-disk IndexedDB does not mean all of it will be uploaded: the official CLI's `--auto-fit` behavior drops the heaviest origins until the artifact fits while keeping cookies. Browser Cookie Bridge reports those omissions in the final result and verifies that the named cloud profile can be read back after upload. Use the domain allowlist when you need specific sites or want a faster, smaller capture.
157
+
158
+ Browserless uploads never run from Daily sync or Sync at login. Browser Cookie Bridge disables Browserless CLI telemetry for this integration. Comet is not currently supported by the Browserless capture CLI. The default cloud timeout is 15 minutes; `--timeout` can override it.
155
159
 
156
160
  The official CLI records the Browserless upload-disclaimer acceptance timestamp in `~/.browserless/config.json`. Browser Cookie Bridge does not store its API token there.
157
161
 
@@ -178,6 +182,7 @@ browser-cookie-bridge install-app [--no-open]
178
182
  browser-cookie-bridge setup [--hour 9] [--minute 0] [--no-schedule]
179
183
  browser-cookie-bridge preferences --source brave --target codex --cookies on --history off
180
184
  browser-cookie-bridge sync [--timeout 300] [--allow-cloud-upload]
185
+ browser-cookie-bridge browserless-preflight
181
186
  browser-cookie-bridge doctor
182
187
  browser-cookie-bridge enable-login-sync
183
188
  browser-cookie-bridge disable-login-sync
@@ -2,7 +2,15 @@
2
2
 
3
3
  import { main } from "../src/cli.js";
4
4
 
5
- main(process.argv.slice(2)).catch((error) => {
5
+ const controller = new AbortController();
6
+ const cancel = () => controller.abort();
7
+ process.once("SIGINT", cancel);
8
+ process.once("SIGTERM", cancel);
9
+
10
+ main(process.argv.slice(2), { signal: controller.signal }).catch((error) => {
6
11
  console.error(`Error: ${error.message}`);
7
- process.exitCode = 1;
12
+ process.exitCode = controller.signal.aborted ? 130 : 1;
13
+ }).finally(() => {
14
+ process.removeListener("SIGINT", cancel);
15
+ process.removeListener("SIGTERM", cancel);
8
16
  });
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Browser Cookie Bridge",
4
- "version": "1.2.0",
4
+ "version": "1.3.0",
5
5
  "description": "Transfers selected browser data locally between supported browsers and into ChatGPT Codex.",
6
6
  "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5o6lDkXzU/23Jm2QRk4mfuxNnr3VlEIjRHcZr93Gdo7PI7asHJQhigaMGT6UOz0lkaTn1F+TGIPlsJo9GNBSsxbCfxWoBSXcFQFrPolY5LEfKaMtZQQ6eb4UAnp+bbUwb1den4On0piWU47Yzl6i0Jcdg4EG5H1kCSF/6nJsbKgF7OTaR/3drULu40yRvflzSXRhly3UDO7c5mgHJsaMwVV+VILsxbcPOoRINjuo3zDC737HFs67dpf38xF3tjSVL0HURAWKKobMvfABiDfVEm5O0Lvf+57d1Ib12QLT2qtVXNDy3iYwX+w2S0wH2vIMsfvoFPlRdmeqft70kZOqXwIDAQAB",
7
7
  "permissions": ["alarms", "cookies", "history"],
@@ -10,8 +10,8 @@
10
10
  <key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
11
11
  <key>CFBundleName</key><string>Browser Cookie Bridge</string>
12
12
  <key>CFBundlePackageType</key><string>APPL</string>
13
- <key>CFBundleShortVersionString</key><string>1.2.0</string>
14
- <key>CFBundleVersion</key><string>26</string>
13
+ <key>CFBundleShortVersionString</key><string>1.3.0</string>
14
+ <key>CFBundleVersion</key><string>27</string>
15
15
  <key>LSMinimumSystemVersion</key><string>13.5</string>
16
16
  <key>NSHighResolutionCapable</key><true/>
17
17
  </dict>
@@ -35,6 +35,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
35
35
  weak var model: SyncModel?
36
36
  private weak var mainWindow: NSWindow?
37
37
  private var statusItem: NSStatusItem?
38
+ private var syncMenuItem: NSMenuItem?
38
39
  private var updateMenuItem: NSMenuItem?
39
40
 
40
41
  func applicationDidFinishLaunching(_ notification: Notification) {
@@ -56,6 +57,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
56
57
  name: .updateStateChanged,
57
58
  object: nil
58
59
  )
60
+ NotificationCenter.default.addObserver(
61
+ self,
62
+ selector: #selector(syncStateChanged(_:)),
63
+ name: .syncStateChanged,
64
+ object: nil
65
+ )
59
66
  }
60
67
 
61
68
  func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false }
@@ -94,7 +101,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
94
101
  item.button?.imageScaling = .scaleProportionallyDown
95
102
  let menu = NSMenu()
96
103
  menu.addItem(withTitle: "Show Browser Cookie Bridge", action: #selector(showMainWindowAction), keyEquivalent: "")
97
- menu.addItem(withTitle: "Sync now", action: #selector(syncNowAction), keyEquivalent: "")
104
+ syncMenuItem = menu.addItem(withTitle: "Sync now", action: #selector(syncNowAction), keyEquivalent: "")
98
105
  let updateItem = menu.addItem(withTitle: "Check for Updates…", action: #selector(updateAction), keyEquivalent: "")
99
106
  updateMenuItem = updateItem
100
107
  addProjectLinks(to: menu)
@@ -106,6 +113,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
106
113
  } else if let statusItem {
107
114
  NSStatusBar.system.removeStatusItem(statusItem)
108
115
  self.statusItem = nil
116
+ syncMenuItem = nil
109
117
  updateMenuItem = nil
110
118
  }
111
119
  }
@@ -116,7 +124,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
116
124
  }
117
125
 
118
126
  @objc private func showMainWindowAction() { showMainWindow() }
119
- @objc private func syncNowAction() { model?.syncNow(showMenuBarAlert: true) }
127
+ @objc private func syncNowAction() {
128
+ if model?.isBrowserlessTarget == true, model?.isSyncing == true { model?.cancelSync() }
129
+ else { model?.syncNow(showMenuBarAlert: true) }
130
+ }
120
131
  @objc private func updateAction() {
121
132
  if model?.availableUpdateVersion != nil { model?.installAvailableUpdate() }
122
133
  else { model?.checkForUpdates(showAlert: true) }
@@ -161,6 +172,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
161
172
  }
162
173
  }
163
174
 
175
+ @objc private func syncStateChanged(_ notification: Notification) {
176
+ guard let payload = notification.object as? SyncMenuState else { return }
177
+ if payload.canceling {
178
+ syncMenuItem?.title = "Canceling upload…"
179
+ syncMenuItem?.isEnabled = false
180
+ } else if payload.uploading {
181
+ syncMenuItem?.title = "Cancel upload"
182
+ syncMenuItem?.isEnabled = true
183
+ } else {
184
+ syncMenuItem?.title = "Sync now"
185
+ syncMenuItem?.isEnabled = true
186
+ }
187
+ }
188
+
164
189
  private func showMainWindow() {
165
190
  NSApp.setActivationPolicy(.regular)
166
191
  mainWindow?.makeKeyAndOrderFront(nil)
@@ -290,35 +315,57 @@ struct SyncPanel: View {
290
315
  TargetPicker()
291
316
  }
292
317
  Divider().opacity(0.65)
293
- HStack(spacing: 12) {
294
- VStack(alignment: .leading, spacing: 2) {
295
- Text(model.primaryStatus)
296
- .font(.system(size: 13, weight: .semibold))
297
- Text(model.secondaryStatus)
298
- .font(.system(size: 10.5))
299
- .foregroundStyle(.secondary)
300
- .lineLimit(2)
301
- .help(model.secondaryStatus)
302
- }
303
- Spacer()
304
- Button {
305
- if model.isBrowserlessTarget && !model.browserlessConfigured {
306
- model.showingBrowserlessSetup = true
307
- } else {
308
- model.syncNow()
318
+ VStack(spacing: 8) {
319
+ HStack(spacing: 12) {
320
+ VStack(alignment: .leading, spacing: 2) {
321
+ Text(model.primaryStatus)
322
+ .font(.system(size: 13, weight: .semibold))
323
+ Text(model.secondaryStatus)
324
+ .font(.system(size: 10.5))
325
+ .foregroundStyle(.secondary)
326
+ .lineLimit(2)
327
+ .help(model.secondaryStatus)
309
328
  }
310
- } label: {
311
- HStack(spacing: 7) {
312
- if model.isSyncing { ProgressView().controlSize(.small) }
313
- else { Image(systemName: syncButtonIcon) }
314
- Text(syncButtonTitle)
329
+ Spacer()
330
+ Button {
331
+ if model.isBrowserlessTarget && model.isSyncing {
332
+ model.cancelSync()
333
+ } else if model.isBrowserlessTarget && !model.browserlessConfigured {
334
+ model.showingBrowserlessSetup = true
335
+ } else {
336
+ model.syncNow()
337
+ }
338
+ } label: {
339
+ HStack(spacing: 7) {
340
+ if model.isSyncing && !model.isBrowserlessTarget { ProgressView().controlSize(.small) }
341
+ else { Image(systemName: syncButtonIcon) }
342
+ Text(syncButtonTitle)
343
+ }
344
+ .frame(minWidth: model.syncBlocked ? 116 : 92)
315
345
  }
316
- .frame(minWidth: model.syncBlocked ? 116 : 92)
346
+ .buttonStyle(.borderedProminent)
347
+ .tint(model.isBrowserlessTarget && model.isSyncing ? Theme.active : Theme.accent)
348
+ .disabled(
349
+ model.uploadCanceling
350
+ || (model.isSyncing && !model.isBrowserlessTarget)
351
+ || (!model.isSyncing && model.syncBlocked && !(model.isBrowserlessTarget && !model.browserlessConfigured))
352
+ )
353
+ .keyboardShortcut(.return, modifiers: .command)
354
+ }
355
+ if model.isBrowserlessTarget && model.isSyncing {
356
+ HStack(spacing: 9) {
357
+ ProgressView(value: model.uploadProgress, total: 1)
358
+ .progressViewStyle(.linear)
359
+ .tint(Theme.active)
360
+ Text(model.formattedUploadElapsed)
361
+ .font(.system(size: 9.5, weight: .semibold, design: .monospaced))
362
+ .foregroundStyle(.secondary)
363
+ .frame(width: 34, alignment: .trailing)
364
+ }
365
+ .accessibilityElement(children: .combine)
366
+ .accessibilityLabel("Browserless upload progress")
367
+ .accessibilityValue("\(Int(model.uploadProgress * 100)) percent, \(model.formattedUploadElapsed) elapsed")
317
368
  }
318
- .buttonStyle(.borderedProminent)
319
- .tint(Theme.accent)
320
- .disabled(model.isSyncing || (model.syncBlocked && !(model.isBrowserlessTarget && !model.browserlessConfigured)))
321
- .keyboardShortcut(.return, modifiers: .command)
322
369
  }
323
370
  .padding(model.syncBlocked ? 7 : 0)
324
371
  .background(
@@ -336,7 +383,9 @@ struct SyncPanel: View {
336
383
  }
337
384
 
338
385
  private var syncButtonTitle: String {
339
- if model.isSyncing { return model.isBrowserlessTarget ? "Uploading…" : "Syncing…" }
386
+ if model.isBrowserlessTarget && model.uploadCanceling { return "Canceling…" }
387
+ if model.isBrowserlessTarget && model.isSyncing { return "Cancel upload" }
388
+ if model.isSyncing { return "Syncing…" }
340
389
  if model.codexBlocked { return "Close Codex first" }
341
390
  if model.isBrowserlessTarget && !model.browserlessConfigured { return "Connect Browserless" }
342
391
  if model.isBrowserlessTarget && model.selectedSourceID == "comet" { return "Choose another browser" }
@@ -345,6 +394,7 @@ struct SyncPanel: View {
345
394
  }
346
395
 
347
396
  private var syncButtonIcon: String {
397
+ if model.isBrowserlessTarget && model.isSyncing { return "xmark.circle.fill" }
348
398
  if model.syncBlocked { return model.isBrowserlessTarget && !model.browserlessConfigured ? "key.fill" : "xmark.circle.fill" }
349
399
  return model.isBrowserlessTarget ? "icloud.and.arrow.up.fill" : "arrow.triangle.2.circlepath"
350
400
  }
@@ -499,6 +549,21 @@ struct PreferencesPanel: View {
499
549
  }
500
550
  .controlSize(.small)
501
551
  }
552
+ RowDivider()
553
+ PreferenceRow(icon: "externaldrive.badge.magnifyingglass", color: Theme.accent, title: "Local profile preflight", detail: browserlessPreflightDetail) {
554
+ HStack(spacing: 7) {
555
+ ProfileSizeBadge(severity: model.browserlessAssessment?.severity, scanning: model.isInspectingBrowserlessProfile)
556
+ Button {
557
+ model.refreshBrowserlessPreflight(force: true)
558
+ } label: {
559
+ Image(systemName: "arrow.clockwise")
560
+ }
561
+ .buttonStyle(.plain)
562
+ .foregroundStyle(.secondary)
563
+ .disabled(model.isInspectingBrowserlessProfile || model.isSyncing)
564
+ .help("Rescan local profile size")
565
+ }
566
+ }
502
567
  } else {
503
568
  PreferenceRow(icon: "network", color: Theme.accent, title: "Cookies", detail: "Site sessions and sign-ins") {
504
569
  Toggle("", isOn: Binding(get: { model.cookiesEnabled }, set: { model.setCookiesEnabled($0) }))
@@ -579,6 +644,16 @@ struct PreferencesPanel: View {
579
644
  }
580
645
  }
581
646
  }
647
+
648
+ private var browserlessPreflightDetail: String {
649
+ if model.isInspectingBrowserlessProfile { return "Measuring profile, local storage, and IndexedDB without reading their contents" }
650
+ if let assessment = model.browserlessAssessment {
651
+ return assessment.temporarySpaceWarning
652
+ ? "\(assessment.summary) · free space may be too low for the temporary copy"
653
+ : assessment.summary
654
+ }
655
+ return "Calculated locally before upload; Browserless cloud artifacts are capped at 2 MB"
656
+ }
582
657
  }
583
658
 
584
659
  struct FixedBadge: View {
@@ -595,6 +670,40 @@ struct FixedBadge: View {
595
670
  }
596
671
  }
597
672
 
673
+ struct ProfileSizeBadge: View {
674
+ let severity: String?
675
+ let scanning: Bool
676
+
677
+ var body: some View {
678
+ Text(label)
679
+ .font(.system(size: 9.5, weight: .semibold))
680
+ .foregroundStyle(color)
681
+ .padding(.horizontal, 8).padding(.vertical, 4)
682
+ .background(color.opacity(0.10), in: Capsule())
683
+ }
684
+
685
+ private var label: String {
686
+ if scanning { return "Scanning" }
687
+ switch severity {
688
+ case "elevated": return "Large"
689
+ case "high": return "Very large"
690
+ case "extreme": return "Extreme"
691
+ case "normal": return "Ready"
692
+ default: return "Not scanned"
693
+ }
694
+ }
695
+
696
+ private var color: Color {
697
+ if scanning { return .secondary }
698
+ switch severity {
699
+ case "elevated": return .orange
700
+ case "high", "extreme": return Theme.active
701
+ case "normal": return Theme.accent
702
+ default: return .secondary
703
+ }
704
+ }
705
+ }
706
+
598
707
  struct BrowserlessSetupSheet: View {
599
708
  @EnvironmentObject private var model: SyncModel
600
709
  @Environment(\.dismiss) private var dismiss
@@ -904,11 +1013,11 @@ struct StatusIndicator: View {
904
1013
  }
905
1014
 
906
1015
  private var label: String {
907
- switch state { case .ready: "Ready"; case .syncing: "Syncing"; case .success: "Synced"; case .warning: "Partial"; case .error: "Needs action" }
1016
+ switch state { case .ready: "Ready"; case .syncing: "Syncing"; case .success: "Synced"; case .canceled: "Canceled"; case .warning: "Partial"; case .error: "Needs action" }
908
1017
  }
909
1018
 
910
1019
  private var color: Color {
911
- switch state { case .ready, .syncing, .success: Theme.accent; case .warning: .orange; case .error: .red }
1020
+ switch state { case .ready, .syncing, .success: Theme.accent; case .canceled: .secondary; case .warning: .orange; case .error: .red }
912
1021
  }
913
1022
  }
914
1023
 
@@ -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"),
@@ -67,6 +93,11 @@ final class SyncModel: ObservableObject {
67
93
  @Published var browserlessRegion = "sfo"
68
94
  @Published var browserlessOnlyDomains = ""
69
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
70
101
  @Published var primaryStatus = "Ready to sync"
71
102
  @Published var secondaryStatus = "Choose what to move, then start a transfer"
72
103
 
@@ -80,6 +111,10 @@ final class SyncModel: ObservableObject {
80
111
  private var updateTimer: Timer?
81
112
  private var didCheckAfterLaunch = false
82
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?
83
118
 
84
119
  var selectedBrowser: BrowserChoice {
85
120
  browsers.first(where: { $0.id == selectedSourceID }) ?? browsers[0]
@@ -98,6 +133,11 @@ final class SyncModel: ObservableObject {
98
133
  isBrowserlessTarget && (!browserlessConfigured || sourceBrowserRunning || selectedSourceID == "comet")
99
134
  }
100
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
+ }
101
141
  var sourceIcon: NSImage { browserIcon(selectedBrowser) }
102
142
  var targetIcon: NSImage {
103
143
  isBrowserlessTarget ? browserlessIcon : selectedTargetBrowser.map(browserIcon) ?? codexIcon
@@ -141,6 +181,7 @@ final class SyncModel: ObservableObject {
141
181
  let calendar = Calendar.current
142
182
  scheduleTime = calendar.date(bySettingHour: 9, minute: 0, second: 0, of: Date()) ?? Date()
143
183
  updateEndpointRunningStatus()
184
+ refreshBrowserlessPreflight()
144
185
  endpointStatusTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
145
186
  Task { @MainActor in self?.updateEndpointRunningStatus() }
146
187
  }
@@ -230,6 +271,7 @@ final class SyncModel: ObservableObject {
230
271
  FileManager.default.fileExists(atPath: support.appending(path: "extension-\($0)/manifest.json").path)
231
272
  }
232
273
  updateEndpointRunningStatus()
274
+ refreshBrowserlessPreflight()
233
275
  consumeUpdateResultIfNeeded()
234
276
  if autoCheckUpdates && !didCheckAfterLaunch {
235
277
  didCheckAfterLaunch = true
@@ -240,6 +282,8 @@ final class SyncModel: ObservableObject {
240
282
  func selectSource(_ id: String) {
241
283
  guard browsers.contains(where: { $0.id == id }), id != selectedSourceID, id != selectedTargetID else { return }
242
284
  selectedSourceID = id
285
+ browserlessAssessment = nil
286
+ assessedBrowserID = nil
243
287
  persistPreferences(successMessage: "Export source changed to \(selectedBrowser.name)")
244
288
  }
245
289
 
@@ -250,6 +294,7 @@ final class SyncModel: ObservableObject {
250
294
  persistPreferences(successMessage: "Import destination changed to \(targetName)")
251
295
  updateEndpointRunningStatus()
252
296
  if id == "browserless" && !browserlessConfigured { showingBrowserlessSetup = true }
297
+ if id == "browserless" { refreshBrowserlessPreflight() }
253
298
  }
254
299
 
255
300
  func setCookiesEnabled(_ enabled: Bool) {
@@ -277,6 +322,7 @@ final class SyncModel: ObservableObject {
277
322
  browserlessOnlyDomains = onlyDomains
278
323
  showingBrowserlessSetup = false
279
324
  persistPreferences(successMessage: "Browserless connected — uploads remain manual")
325
+ refreshBrowserlessPreflight(force: true)
280
326
  } catch {
281
327
  postNativeAlert(title: "Could not save Browserless token", message: error.localizedDescription, kind: .error)
282
328
  }
@@ -387,6 +433,10 @@ final class SyncModel: ObservableObject {
387
433
 
388
434
  func syncNow(showMenuBarAlert: Bool = false) {
389
435
  guard !isSyncing else {
436
+ if isBrowserlessTarget {
437
+ cancelSync()
438
+ return
439
+ }
390
440
  if showMenuBarAlert {
391
441
  postNativeAlert(title: "Sync already running", message: "Wait for the current transfer to finish.", kind: .information)
392
442
  }
@@ -400,6 +450,7 @@ final class SyncModel: ObservableObject {
400
450
  return
401
451
  }
402
452
  isSyncing = true
453
+ uploadCanceling = false
403
454
  state = .syncing
404
455
  primaryStatus = isBrowserlessTarget ? "Uploading authenticated state" : "Transferring selected data"
405
456
  secondaryStatus = selectedTargetID == "codex"
@@ -408,7 +459,7 @@ final class SyncModel: ObservableObject {
408
459
  ? "Sending \(selectedBrowser.name) to Browserless \(browserlessRegion.uppercased()) only for this request…"
409
460
  : "Waiting for \(selectedBrowser.name) and \(targetName)…"
410
461
  var environment: [String: String] = [:]
411
- var arguments = ["sync", "--timeout", "300"]
462
+ var arguments = ["sync", "--timeout", isBrowserlessTarget ? "900" : "300"]
412
463
  if isBrowserlessTarget {
413
464
  guard let token = BrowserlessCredentialStore.read() else {
414
465
  isSyncing = false
@@ -418,15 +469,30 @@ final class SyncModel: ObservableObject {
418
469
  }
419
470
  environment["BROWSERLESS_TOKEN"] = token
420
471
  arguments.append("--allow-cloud-upload")
472
+ beginUploadTracking()
421
473
  }
422
- runCLI(arguments, environment: environment) { [weak self] success, output in
474
+ activeSyncProcess = runCLI(arguments, environment: environment, onLine: { [weak self] line in
475
+ self?.handleBrowserlessProgress(line)
476
+ }) { [weak self] success, output in
423
477
  guard let self else { return }
478
+ self.activeSyncProcess = nil
424
479
  self.isSyncing = false
425
- let partial = success && (output.contains("Partially synced:") || output.contains("with warnings"))
426
- 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 {
427
493
  self.state = partial ? .warning : .success
428
494
  self.primaryStatus = self.isBrowserlessTarget
429
- ? "Browserless profile uploaded"
495
+ ? (partial ? "Browserless profile uploaded with omissions" : "Browserless profile uploaded")
430
496
  : self.selectedTargetID == "codex"
431
497
  ? (partial ? "Codex sync completed with warnings" : "Codex sessions updated")
432
498
  : (partial ? "Partially synced" : "Transfer complete")
@@ -445,12 +511,40 @@ final class SyncModel: ObservableObject {
445
511
  self.postNativeAlert(
446
512
  title: self.primaryStatus,
447
513
  message: self.secondaryStatus,
448
- kind: success ? (partial ? .warning : .information) : .error
514
+ kind: canceled ? .information : success ? (partial ? .warning : .information) : .error
449
515
  )
450
516
  }
451
517
  }
452
518
  }
453
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
+
454
548
  func setDailyEnabled(_ enabled: Bool) {
455
549
  guard !isBrowserlessTarget else {
456
550
  postNativeAlert(title: "Cloud uploads are manual-only", message: "Browser Cookie Bridge will never schedule Browserless uploads in the background.", kind: .information)
@@ -556,6 +650,7 @@ final class SyncModel: ObservableObject {
556
650
  FileManager.default.fileExists(atPath: self.support.appending(path: "extension-\($0)/manifest.json").path)
557
651
  }
558
652
  self.updateEndpointRunningStatus()
653
+ self.refreshBrowserlessPreflight()
559
654
  }
560
655
  }
561
656
 
@@ -584,27 +679,48 @@ final class SyncModel: ObservableObject {
584
679
  }
585
680
  }
586
681
 
587
- private func runCLI(_ arguments: [String], environment: [String: 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? {
588
689
  guard let config = loadConfig() else {
589
690
  completion(false, "Configuration missing. Run install-app again.")
590
- return
691
+ return nil
591
692
  }
592
693
  let process = Process()
593
694
  let output = Pipe()
695
+ let collector = ProcessOutputCollector()
594
696
  process.executableURL = URL(fileURLWithPath: config.nodePath)
595
697
  process.arguments = [runtimeCLI.path] + arguments
596
698
  process.environment = ProcessInfo.processInfo.environment.merging(environment) { _, new in new }
597
699
  process.standardOutput = output
598
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
+ }
599
708
  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) }
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
+ }
603
717
  }
604
718
  do {
605
719
  try process.run()
720
+ return process
606
721
  } catch {
607
722
  completion(false, error.localizedDescription)
723
+ return nil
608
724
  }
609
725
  }
610
726
 
@@ -631,6 +747,69 @@ final class SyncModel: ObservableObject {
631
747
  return line.hasPrefix("Error: ") ? String(line.dropFirst(7)) : line
632
748
  }
633
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
+
634
813
  private var formattedTime: String {
635
814
  scheduleTime.formatted(date: .omitted, time: .shortened)
636
815
  }
@@ -827,3 +1006,27 @@ private enum BrowserlessCredentialStore {
827
1006
  SecItemDelete(query as CFDictionary)
828
1007
  }
829
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
+ }
1032
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-cookie-bridge",
3
- "version": "1.2.0",
3
+ "version": "1.3.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
+ }
@@ -1,3 +1,5 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
1
3
  import path from "node:path";
2
4
  import { spawn } from "node:child_process";
3
5
  import { projectRoot } from "./paths.js";
@@ -13,7 +15,10 @@ export async function uploadBrowserlessProfile({
13
15
  onlyDomains = [],
14
16
  token = process.env.BROWSERLESS_TOKEN,
15
17
  root = projectRoot(),
16
- runner = runCLI,
18
+ runner = runBrowserlessCLI,
19
+ signal,
20
+ timeoutMs = 15 * 60 * 1000,
21
+ onProgress = () => {},
17
22
  } = {}) {
18
23
  if (!SUPPORTED_SOURCES.has(browser)) {
19
24
  throw new Error(`${browser === "comet" ? "Comet" : browser} is not supported by Browserless profile capture yet.`);
@@ -34,7 +39,15 @@ export async function uploadBrowserlessProfile({
34
39
  DO_NOT_TRACK: "1",
35
40
  };
36
41
  const client = ["--region", region, "--json"];
37
- const shown = await runner(cliPath, ["profile", "show", profileName.trim(), ...client], environment, runnerPath);
42
+ onProgress({ phase: "validating", fraction: 0.10, detail: "Checking the destination profile…" });
43
+ const shown = await runner(
44
+ cliPath,
45
+ ["profile", "show", profileName.trim(), ...client],
46
+ environment,
47
+ runnerPath,
48
+ { signal, timeoutMs: Math.min(timeoutMs, 60_000) },
49
+ );
50
+ throwForInterruptedRun(shown, timeoutMs);
38
51
  const operation = shown.status === 0 ? "refresh" : isMissingProfile(shown.output) ? "upload" : null;
39
52
  if (!operation) throw new Error(lastLine(shown.output) || "Browserless could not validate the cloud profile.");
40
53
 
@@ -48,8 +61,28 @@ export async function uploadBrowserlessProfile({
48
61
  "--auto-fit",
49
62
  ];
50
63
  for (const domain of onlyDomains) capture.push("--only-domain", domain);
51
- const result = await runner(cliPath, capture, environment, runnerPath);
52
- if (result.status !== 0) throw new Error(lastLine(result.output) || "Browserless profile upload failed.");
64
+ const parseProgress = progressParser(onProgress);
65
+ const result = await runner(cliPath, capture, environment, runnerPath, {
66
+ signal,
67
+ timeoutMs,
68
+ onOutput: parseProgress,
69
+ });
70
+ parseProgress("\n");
71
+ throwForInterruptedRun(result, timeoutMs);
72
+ if (result.status !== 0) throw new Error(actionableFailure(result.output));
73
+
74
+ onProgress({ phase: "verifying", fraction: 0.97, detail: "Verifying the cloud profile…" });
75
+ const verified = await runner(
76
+ cliPath,
77
+ ["profile", "show", profileName.trim(), ...client],
78
+ environment,
79
+ runnerPath,
80
+ { signal, timeoutMs: Math.min(timeoutMs, 60_000) },
81
+ );
82
+ throwForInterruptedRun(verified, timeoutMs);
83
+ if (verified.status !== 0) {
84
+ throw new Error(`The upload finished, but Browserless could not verify the cloud profile: ${lastLine(verified.output) || "profile lookup failed"}`);
85
+ }
53
86
 
54
87
  const details = parseJSON(result.output);
55
88
  const cookies = details?.cookieCount;
@@ -59,28 +92,126 @@ export async function uploadBrowserlessProfile({
59
92
  : "";
60
93
  return {
61
94
  operation,
95
+ verified: true,
62
96
  profileName: details?.name || profileName.trim(),
63
97
  cookieCount: cookies,
64
98
  originCount: origins,
65
- summary: `Browserless profile ${operation === "refresh" ? "updated" : "created"}: ${details?.name || profileName.trim()}${counts}`,
99
+ droppedOriginCount: droppedOrigins(result.output),
100
+ failedOriginCount: failedOrigins(result.output),
101
+ summary: uploadSummary({ operation, details, fallbackName: profileName.trim(), counts, output: result.output }),
66
102
  };
67
103
  }
68
104
 
69
- function runCLI(cliPath, args, environment, runnerPath) {
105
+ export function runBrowserlessCLI(cliPath, args, environment, runnerPath, {
106
+ signal,
107
+ timeoutMs = 15 * 60 * 1000,
108
+ onOutput = () => {},
109
+ } = {}) {
70
110
  return new Promise((resolve, reject) => {
111
+ const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "browser-cookie-bridge-browserless-"));
112
+ fs.chmodSync(temporaryRoot, 0o700);
71
113
  const child = spawn(process.execPath, [runnerPath, cliPath, ...args], {
72
- env: environment,
114
+ env: { ...environment, TMPDIR: temporaryRoot },
73
115
  stdio: ["ignore", "pipe", "pipe"],
116
+ detached: process.platform !== "win32",
74
117
  });
75
118
  let stdout = "";
76
119
  let stderr = "";
77
- child.stdout.on("data", (chunk) => { stdout += chunk; });
78
- child.stderr.on("data", (chunk) => { stderr += chunk; });
79
- child.on("error", reject);
80
- child.on("close", (status) => resolve({ status: status ?? 1, output: `${stderr}${stdout}` }));
120
+ let interruption = null;
121
+ let settled = false;
122
+ const append = (target, chunk) => {
123
+ const text = String(chunk);
124
+ if (target === "stdout") stdout += text;
125
+ else stderr += text;
126
+ onOutput(text);
127
+ };
128
+ const terminate = (reason) => {
129
+ if (settled || interruption) return;
130
+ interruption = reason;
131
+ try {
132
+ if (process.platform !== "win32" && child.pid) process.kill(-child.pid, "SIGTERM");
133
+ else child.kill("SIGTERM");
134
+ } catch {}
135
+ setTimeout(() => {
136
+ if (child.exitCode !== null || child.signalCode !== null) return;
137
+ try {
138
+ if (process.platform !== "win32" && child.pid) process.kill(-child.pid, "SIGKILL");
139
+ else child.kill("SIGKILL");
140
+ } catch {}
141
+ }, 2_000).unref();
142
+ };
143
+ const abort = () => terminate("canceled");
144
+ signal?.addEventListener("abort", abort, { once: true });
145
+ if (signal?.aborted) abort();
146
+ const timer = setTimeout(() => terminate("timedOut"), timeoutMs);
147
+ timer.unref();
148
+ child.stdout.on("data", (chunk) => append("stdout", chunk));
149
+ child.stderr.on("data", (chunk) => append("stderr", chunk));
150
+ child.on("error", (error) => {
151
+ settled = true;
152
+ clearTimeout(timer);
153
+ signal?.removeEventListener("abort", abort);
154
+ fs.rmSync(temporaryRoot, { recursive: true, force: true });
155
+ reject(error);
156
+ });
157
+ child.on("close", (status) => {
158
+ settled = true;
159
+ clearTimeout(timer);
160
+ signal?.removeEventListener("abort", abort);
161
+ fs.rmSync(temporaryRoot, { recursive: true, force: true });
162
+ resolve({
163
+ status: interruption === "canceled" ? 130 : interruption === "timedOut" ? 124 : (status ?? 1),
164
+ output: `${stderr}${stdout}`,
165
+ canceled: interruption === "canceled",
166
+ timedOut: interruption === "timedOut",
167
+ });
168
+ });
81
169
  });
82
170
  }
83
171
 
172
+ export function progressParser(onProgress) {
173
+ let pending = "";
174
+ let lastPhase = "";
175
+ return (chunk) => {
176
+ pending += chunk;
177
+ const lines = pending.split(/\r\n|\n|\r/);
178
+ pending = lines.pop() ?? "";
179
+ for (const raw of lines) {
180
+ const line = raw.trim();
181
+ let progress = null;
182
+ if (/copying profile data/i.test(line)) {
183
+ progress = { phase: "copying", fraction: 0.18, detail: "Copying profile data into an isolated workspace…" };
184
+ } else if (/launching headless browser/i.test(line)) {
185
+ progress = { phase: "launching", fraction: 0.30, detail: "Launching the temporary browser…" };
186
+ } else if (/waiting for browser to be ready/i.test(line)) {
187
+ progress = { phase: "waiting", fraction: 0.38, detail: "Waiting for the temporary browser…" };
188
+ } else if (/capturing per-origin storage/i.test(line)) {
189
+ progress = { phase: "capturing", fraction: 0.42, detail: "Capturing cookies, local storage, and IndexedDB…" };
190
+ } else if (/^\d+\/\d+\s+/.test(line)) {
191
+ const match = line.match(/^(\d+)\/(\d+)\s+(.+)$/);
192
+ if (match) {
193
+ const current = Number(match[1]);
194
+ const total = Number(match[2]);
195
+ progress = {
196
+ phase: "capturing",
197
+ fraction: total > 0 ? 0.42 + 0.42 * Math.min(current / total, 1) : 0.42,
198
+ current,
199
+ total,
200
+ detail: `Capturing ${match[3]}`,
201
+ };
202
+ }
203
+ } else if (/uploading to browserless/i.test(line)) {
204
+ progress = { phase: "uploading", fraction: 0.90, detail: "Uploading the fitted authenticated profile…" };
205
+ }
206
+ if (!progress) continue;
207
+ const identity = `${progress.phase}:${progress.current ?? ""}:${progress.total ?? ""}:${progress.detail}`;
208
+ if (identity === lastPhase) continue;
209
+ lastPhase = identity;
210
+ onProgress(progress);
211
+ }
212
+ };
213
+ }
214
+
84
215
  function isMissingProfile(output) {
85
216
  return /(?:not found|does not exist|404)/i.test(output);
86
217
  }
@@ -106,3 +237,45 @@ function parseJSON(output) {
106
237
  function lastLine(output) {
107
238
  return output.split("\n").map((line) => line.trim()).filter(Boolean).at(-1)?.replace(/^Error:\s*/, "");
108
239
  }
240
+
241
+ function throwForInterruptedRun(result, timeoutMs) {
242
+ if (result?.canceled || result?.status === 130) {
243
+ throw new Error("Browserless upload canceled. Temporary profile data was removed.");
244
+ }
245
+ if (result?.timedOut || result?.status === 124) {
246
+ const minutes = Math.max(1, Math.round(timeoutMs / 60_000));
247
+ throw new Error(`Browserless upload timed out after ${minutes} minute${minutes === 1 ? "" : "s"}. Check the connection, close the source browser, and try again with a smaller domain allowlist.`);
248
+ }
249
+ }
250
+
251
+ function droppedOrigins(output) {
252
+ return Number(output.match(/--auto-fit:\s*dropped\s+(\d+)\s+origin/i)?.[1] || 0);
253
+ }
254
+
255
+ function failedOrigins(output) {
256
+ return Number(output.match(/!\s+(\d+)\s+origin\(s\) failed to capture/i)?.[1] || 0);
257
+ }
258
+
259
+ function uploadSummary({ operation, details, fallbackName, counts, output }) {
260
+ const dropped = droppedOrigins(output);
261
+ const failed = failedOrigins(output);
262
+ const warnings = [];
263
+ if (dropped > 0) warnings.push(`${dropped} heavy origin${dropped === 1 ? "" : "s"} omitted to fit Browserless's 2 MB cap`);
264
+ if (failed > 0) warnings.push(`${failed} origin${failed === 1 ? "" : "s"} could not be captured`);
265
+ const warning = warnings.length > 0 ? `; ${warnings.join("; ")}` : "";
266
+ return `Browserless profile ${operation === "refresh" ? "updated" : "created"} and verified: ${details?.name || fallbackName}${counts}${warning}`;
267
+ }
268
+
269
+ function actionableFailure(output) {
270
+ const detail = lastLine(output) || "Browserless profile upload failed.";
271
+ if (/(?:failed to reach|enotfound|econnreset|econnrefused|network|socket hang up|fetch failed)/i.test(output)) {
272
+ return `Browserless could not be reached. Check your internet connection and region, then try again. ${detail}`;
273
+ }
274
+ if (/(?:profile busy|singletonlock|browser.*running|source browser must be closed)/i.test(output)) {
275
+ return `The source browser is still using this profile. Quit it completely, wait a few seconds, and try again. ${detail}`;
276
+ }
277
+ if (/(?:2 MB|too large|artifact.*cap|payload.*large)/i.test(output)) {
278
+ return `The captured state could not fit Browserless's 2 MB profile cap. Add a domain allowlist for the sites you need, then try again. ${detail}`;
279
+ }
280
+ return detail;
281
+ }
package/src/cli.js CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  } from "./codex-direct-import.js";
11
11
  import { readChromiumProfile } from "./chromium-reader.js";
12
12
  import { uploadBrowserlessProfile } from "./browserless.js";
13
+ import { inspectBrowserlessProfile } from "./browserless-preflight.js";
13
14
  import { installConfig, installRuntime, readConfig, updatePreferences } from "./config.js";
14
15
  import {
15
16
  braveCookiePaths,
@@ -46,6 +47,7 @@ Commands:
46
47
  bootstrap-bundled --app-path /Applications/Browser Cookie Bridge.app
47
48
  preferences --source brave --target codex --cookies on --history off --menu-bar on --auto-check-updates on
48
49
  sync [--timeout 300] [--allow-cloud-upload]
50
+ browserless-preflight
49
51
  doctor
50
52
  enable-login-sync
51
53
  disable-login-sync
@@ -55,13 +57,15 @@ Commands:
55
57
  help
56
58
  `;
57
59
 
58
- export async function main(argv) {
60
+ export async function main(argv, { signal } = {}) {
59
61
  const [command = "help", ...args] = argv;
60
62
  switch (command) {
61
63
  case "setup":
62
64
  return setup(args);
63
65
  case "sync":
64
- return sync(args);
66
+ return sync(args, { signal });
67
+ case "browserless-preflight":
68
+ return browserlessPreflight();
65
69
  case "install-app":
66
70
  return installDesktopApp(args);
67
71
  case "bootstrap-bundled":
@@ -259,11 +263,11 @@ function setup(args) {
259
263
  : "Cookie values are transferred in memory and are not written to logs or disk.");
260
264
  }
261
265
 
262
- async function sync(args) {
266
+ async function sync(args, { signal } = {}) {
263
267
  assertMacOS();
264
- const seconds = integerFlag(args, "--timeout", 300, 5, 3600);
265
268
  const config = readConfig();
266
269
  const target = config.targetBrowser || "codex";
270
+ const seconds = integerFlag(args, "--timeout", target === "browserless" ? 900 : 300, 5, 3600);
267
271
  const isCodexTarget = target === "codex";
268
272
  if (target === "browserless") {
269
273
  if (!args.includes("--allow-cloud-upload")) {
@@ -271,6 +275,15 @@ async function sync(args) {
271
275
  }
272
276
  const source = config.sourceBrowser || "brave";
273
277
  const local = readChromiumProfile({ browser: source, imports: { cookies: false, history: false } });
278
+ emitBrowserlessProgress({ phase: "preflight", fraction: 0.03, detail: "Inspecting the local profile…" });
279
+ const assessment = inspectBrowserlessProfile({ profilePath: local.profilePath });
280
+ emitBrowserlessProgress({
281
+ phase: "preflight-complete",
282
+ fraction: 0.06,
283
+ detail: assessment.summary,
284
+ assessment,
285
+ });
286
+ console.log(`Profile preflight: ${assessment.summary}`);
274
287
  console.log(`Preparing ${source} profile ${local.profileName} for an explicit Browserless cloud upload…`);
275
288
  const result = await uploadBrowserlessProfile({
276
289
  browser: source,
@@ -278,7 +291,11 @@ async function sync(args) {
278
291
  profileName: config.browserless?.profileName || "browser-cookie-bridge",
279
292
  region: config.browserless?.region || "sfo",
280
293
  onlyDomains: config.browserless?.onlyDomains || [],
294
+ timeoutMs: seconds * 1000,
295
+ signal,
296
+ onProgress: emitBrowserlessProgress,
281
297
  });
298
+ emitBrowserlessProgress({ phase: "complete", fraction: 1, detail: result.summary });
282
299
  console.log(result.summary);
283
300
  return result;
284
301
  }
@@ -322,6 +339,21 @@ async function sync(args) {
322
339
  return result;
323
340
  }
324
341
 
342
+ function browserlessPreflight() {
343
+ assertMacOS();
344
+ const config = readConfig();
345
+ const source = config.sourceBrowser || "brave";
346
+ if (source === "comet") throw new Error("Comet is not supported by Browserless profile capture yet.");
347
+ const local = readChromiumProfile({ browser: source, imports: { cookies: false, history: false } });
348
+ const assessment = inspectBrowserlessProfile({ profilePath: local.profilePath });
349
+ console.log(JSON.stringify({ browser: source, profileName: local.profileName, ...assessment }));
350
+ return assessment;
351
+ }
352
+
353
+ function emitBrowserlessProgress(event) {
354
+ console.log(`BCB_PROGRESS ${JSON.stringify(event)}`);
355
+ }
356
+
325
357
  export function directCodexSummary(result) {
326
358
  const imported = result.imported + result.historyImported;
327
359
  const skipped = result.skipped + result.historySkipped;