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.
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.4.0 adds an optional one-click Codex restart flow.** When enabled for a manual sync, Browser Cookie Bridge force quits Codex, waits for its database to close, performs the verified local transfer, preserves the imported/skipped/failed counts, and reopens Codex only after success. The option is off by default.
48
48
 
49
49
  ## Why Browser Cookie Bridge
50
50
 
@@ -57,7 +57,8 @@ 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 Codex restart** β€” manual Sync can force quit a running Codex instance and reopen it only after a successful local transfer; off by default.
61
+ - ☁️ **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
62
  - πŸ•˜ **Background automation** β€” sync when you sign in, at a fixed daily time, or whenever you choose.
62
63
  - β—‰ **Native menu-bar app** β€” closing the window removes the Dock icon while the helper continues running.
63
64
  - 🧯 **Backup and rollback** β€” Codex's database is backed up, modified on a separate copy, integrity-checked, and restored if replacement fails.
@@ -125,11 +126,11 @@ The final command compiles the native SwiftUI app, enables **Open at login**, **
125
126
  No browser extension is needed for this path.
126
127
 
127
128
  1. Select a source browser and **ChatGPT Codex** as the destination.
128
- 2. Quit Codex completely. Closing only its browser panel is not enough.
129
+ 2. Quit Codex completely, or enable **Restart Codex automatically**. Closing only its browser panel is not enough.
129
130
  3. Choose **Cookies** and, optionally, **History URLs**.
130
- 4. Press **Sync now** and reopen Codex after the success message.
131
+ 4. Press **Sync now** and reopen Codex after the success message if automatic restart is off.
131
132
 
132
- If Codex is open, the app blocks the transfer and tells you what to close. It never force-quits Codex.
133
+ By default, an open Codex blocks the transfer. The optional **Restart Codex automatically** setting applies only to a manual Sync: it force quits Codex, waits for its database to close, and reopens Codex only after the transfer succeeds. Scheduled and login syncs never force quit it.
133
134
 
134
135
  ### Browser β†’ browser
135
136
 
@@ -151,7 +152,11 @@ This path uses the official Browserless CLI and is deliberately separate from lo
151
152
  3. Quit the selected source browser so its profile can be copied consistently.
152
153
  4. Review the cloud warning and click **Upload now**.
153
154
 
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.
155
+ 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.
156
+
157
+ 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.
158
+
159
+ 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
160
 
156
161
  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
162
 
@@ -167,6 +172,7 @@ The official CLI records the Browserless upload-disclaimer acceptance timestamp
167
172
  | **Sync at login** | Runs once whenever you sign in; on by default |
168
173
  | **Open at login** | Starts the background app after macOS login; on by default |
169
174
  | **Show in menu bar** | Keeps sync, status, updates, and support actions close at hand |
175
+ | **Restart Codex automatically** | Force quits Codex for a manual sync and reopens it only after success; off by default |
170
176
  | **Check for updates** | Finds a newer GitHub release, verifies its DMG, and offers install + relaunch |
171
177
 
172
178
  Automation uses the saved source, destination, and data choices for local transfers. A scheduled Codex sync safely exits without making changes when Codex is open. Browserless cloud uploads are always manual and require an explicit upload action.
@@ -176,8 +182,9 @@ Automation uses the saved source, destination, and data choices for local transf
176
182
  ```bash
177
183
  browser-cookie-bridge install-app [--no-open]
178
184
  browser-cookie-bridge setup [--hour 9] [--minute 0] [--no-schedule]
179
- browser-cookie-bridge preferences --source brave --target codex --cookies on --history off
185
+ browser-cookie-bridge preferences --source brave --target codex --cookies on --history off --auto-restart-codex off
180
186
  browser-cookie-bridge sync [--timeout 300] [--allow-cloud-upload]
187
+ browser-cookie-bridge browserless-preflight
181
188
  browser-cookie-bridge doctor
182
189
  browser-cookie-bridge enable-login-sync
183
190
  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.4.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.4.0</string>
14
+ <key>CFBundleVersion</key><string>28</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)
345
+ }
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)
315
364
  }
316
- .frame(minWidth: model.syncBlocked ? 116 : 92)
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) }))
@@ -513,6 +578,18 @@ struct PreferencesPanel: View {
513
578
  PreferenceRow(icon: "key.slash", color: .secondary, title: "Passwords", detail: "Browser extensions cannot access passwords", muted: true) {
514
579
  FixedBadge("Unavailable")
515
580
  }
581
+ if model.selectedTargetID == "codex" {
582
+ RowDivider()
583
+ PreferenceRow(
584
+ icon: "arrow.clockwise.circle",
585
+ color: Theme.accent,
586
+ title: "Restart Codex automatically",
587
+ detail: "Force quit before manual sync; reopen only after success"
588
+ ) {
589
+ Toggle("", isOn: Binding(get: { model.autoRestartCodex }, set: { model.setAutoRestartCodex($0) }))
590
+ .labelsHidden().toggleStyle(.switch).tint(Theme.active).disabled(model.isWorking || model.isSyncing)
591
+ }
592
+ }
516
593
  }
517
594
 
518
595
  SectionLabel(title: "Automation", detail: "Runs in the background", separated: true)
@@ -579,6 +656,16 @@ struct PreferencesPanel: View {
579
656
  }
580
657
  }
581
658
  }
659
+
660
+ private var browserlessPreflightDetail: String {
661
+ if model.isInspectingBrowserlessProfile { return "Measuring profile, local storage, and IndexedDB without reading their contents" }
662
+ if let assessment = model.browserlessAssessment {
663
+ return assessment.temporarySpaceWarning
664
+ ? "\(assessment.summary) Β· free space may be too low for the temporary copy"
665
+ : assessment.summary
666
+ }
667
+ return "Calculated locally before upload; Browserless cloud artifacts are capped at 2 MB"
668
+ }
582
669
  }
583
670
 
584
671
  struct FixedBadge: View {
@@ -595,6 +682,40 @@ struct FixedBadge: View {
595
682
  }
596
683
  }
597
684
 
685
+ struct ProfileSizeBadge: View {
686
+ let severity: String?
687
+ let scanning: Bool
688
+
689
+ var body: some View {
690
+ Text(label)
691
+ .font(.system(size: 9.5, weight: .semibold))
692
+ .foregroundStyle(color)
693
+ .padding(.horizontal, 8).padding(.vertical, 4)
694
+ .background(color.opacity(0.10), in: Capsule())
695
+ }
696
+
697
+ private var label: String {
698
+ if scanning { return "Scanning" }
699
+ switch severity {
700
+ case "elevated": return "Large"
701
+ case "high": return "Very large"
702
+ case "extreme": return "Extreme"
703
+ case "normal": return "Ready"
704
+ default: return "Not scanned"
705
+ }
706
+ }
707
+
708
+ private var color: Color {
709
+ if scanning { return .secondary }
710
+ switch severity {
711
+ case "elevated": return .orange
712
+ case "high", "extreme": return Theme.active
713
+ case "normal": return Theme.accent
714
+ default: return .secondary
715
+ }
716
+ }
717
+ }
718
+
598
719
  struct BrowserlessSetupSheet: View {
599
720
  @EnvironmentObject private var model: SyncModel
600
721
  @Environment(\.dismiss) private var dismiss
@@ -904,11 +1025,11 @@ struct StatusIndicator: View {
904
1025
  }
905
1026
 
906
1027
  private var label: String {
907
- switch state { case .ready: "Ready"; case .syncing: "Syncing"; case .success: "Synced"; case .warning: "Partial"; case .error: "Needs action" }
1028
+ switch state { case .ready: "Ready"; case .syncing: "Syncing"; case .success: "Synced"; case .canceled: "Canceled"; case .warning: "Partial"; case .error: "Needs action" }
908
1029
  }
909
1030
 
910
1031
  private var color: Color {
911
- switch state { case .ready, .syncing, .success: Theme.accent; case .warning: .orange; case .error: .red }
1032
+ switch state { case .ready, .syncing, .success: Theme.accent; case .canceled: .secondary; case .warning: .orange; case .error: .red }
912
1033
  }
913
1034
  }
914
1035