browser-cookie-bridge 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  import AppKit
2
2
  import Foundation
3
+ import Security
3
4
 
4
5
  extension Notification.Name {
5
6
  static let menuBarVisibilityChanged = Notification.Name("BraveCodexSync.menuBarVisibilityChanged")
@@ -60,6 +61,12 @@ final class SyncModel: ObservableObject {
60
61
  @Published var selectedSourceID = "brave"
61
62
  @Published var selectedTargetID = "codex"
62
63
  @Published var codexRunning = false
64
+ @Published var sourceBrowserRunning = false
65
+ @Published var browserlessConfigured = false
66
+ @Published var browserlessProfileName = "browser-cookie-bridge"
67
+ @Published var browserlessRegion = "sfo"
68
+ @Published var browserlessOnlyDomains = ""
69
+ @Published var showingBrowserlessSetup = false
63
70
  @Published var primaryStatus = "Ready to sync"
64
71
  @Published var secondaryStatus = "Choose what to move, then start a transfer"
65
72
 
@@ -69,7 +76,7 @@ final class SyncModel: ObservableObject {
69
76
  private var launchAgent: URL { home.appending(path: "Library/LaunchAgents/com.apoorvdarshan.brave-codex-cookie-sync.plist") }
70
77
  private var loginSyncAgent: URL { home.appending(path: "Library/LaunchAgents/com.apoorvdarshan.brave-codex-cookie-sync.login-sync.plist") }
71
78
  private var appLoginAgent: URL { home.appending(path: "Library/LaunchAgents/com.apoorvdarshan.brave-codex-cookie-sync.app-login.plist") }
72
- private var codexStatusTimer: Timer?
79
+ private var endpointStatusTimer: Timer?
73
80
  private var updateTimer: Timer?
74
81
  private var didCheckAfterLaunch = false
75
82
  private var didConsumeUpdateResult = false
@@ -82,10 +89,24 @@ final class SyncModel: ObservableObject {
82
89
  browsers.first(where: { $0.id == selectedTargetID })
83
90
  }
84
91
 
85
- var targetName: String { selectedTargetBrowser?.name ?? "ChatGPT Codex" }
92
+ var isBrowserlessTarget: Bool { selectedTargetID == "browserless" }
93
+ var targetName: String {
94
+ isBrowserlessTarget ? "Browserless Cloud" : selectedTargetBrowser?.name ?? "ChatGPT Codex"
95
+ }
86
96
  var codexBlocked: Bool { selectedTargetID == "codex" && codexRunning }
97
+ var browserlessBlocked: Bool {
98
+ isBrowserlessTarget && (!browserlessConfigured || sourceBrowserRunning || selectedSourceID == "comet")
99
+ }
100
+ var syncBlocked: Bool { codexBlocked || browserlessBlocked }
87
101
  var sourceIcon: NSImage { browserIcon(selectedBrowser) }
88
- var targetIcon: NSImage { selectedTargetBrowser.map(browserIcon) ?? codexIcon }
102
+ var targetIcon: NSImage {
103
+ isBrowserlessTarget ? browserlessIcon : selectedTargetBrowser.map(browserIcon) ?? codexIcon
104
+ }
105
+ var browserlessIcon: NSImage {
106
+ bundledIcon("browserless")
107
+ ?? NSImage(systemSymbolName: "cloud.fill", accessibilityDescription: "Browserless Cloud")
108
+ ?? NSImage()
109
+ }
89
110
  var codexIcon: NSImage {
90
111
  bundledIcon("chatgpt-codex")
91
112
  ?? chatGPTResource("app.icns")
@@ -116,11 +137,12 @@ final class SyncModel: ObservableObject {
116
137
  }
117
138
 
118
139
  init() {
140
+ bootstrapBundledRuntimeIfNeeded()
119
141
  let calendar = Calendar.current
120
142
  scheduleTime = calendar.date(bySettingHour: 9, minute: 0, second: 0, of: Date()) ?? Date()
121
- updateCodexRunningStatus()
122
- codexStatusTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
123
- Task { @MainActor in self?.updateCodexRunningStatus() }
143
+ updateEndpointRunningStatus()
144
+ endpointStatusTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
145
+ Task { @MainActor in self?.updateEndpointRunningStatus() }
124
146
  }
125
147
  updateTimer = Timer.scheduledTimer(withTimeInterval: 24 * 60 * 60, repeats: true) { [weak self] _ in
126
148
  Task { @MainActor in
@@ -130,6 +152,51 @@ final class SyncModel: ObservableObject {
130
152
  }
131
153
  }
132
154
 
155
+ private func bootstrapBundledRuntimeIfNeeded() {
156
+ guard let resources = Bundle.main.resourceURL else { return }
157
+ let bundledRuntime = resources.appending(path: "runtime")
158
+ let bundledNode = bundledRuntime.appending(path: "node/bin/node")
159
+ let bundledCLI = bundledRuntime.appending(path: "bin/brave-codex-cookie-sync.js")
160
+ guard FileManager.default.isExecutableFile(atPath: bundledNode.path),
161
+ FileManager.default.fileExists(atPath: bundledCLI.path) else { return }
162
+
163
+ if Bundle.main.bundlePath.hasPrefix("/Volumes/") {
164
+ state = .warning
165
+ primaryStatus = "Move the app to Applications"
166
+ secondaryStatus = "Drag Browser Cookie Bridge onto Applications in the DMG window, then open it there"
167
+ return
168
+ }
169
+
170
+ let process = Process()
171
+ let output = Pipe()
172
+ process.executableURL = bundledNode
173
+ process.arguments = [
174
+ bundledCLI.path,
175
+ "bootstrap-bundled",
176
+ "--app-path", Bundle.main.bundlePath,
177
+ ]
178
+ process.standardOutput = output
179
+ process.standardError = output
180
+
181
+ do {
182
+ try process.run()
183
+ process.waitUntilExit()
184
+ guard process.terminationStatus != 0 else { return }
185
+ let data = output.fileHandleForReading.readDataToEndOfFile()
186
+ let message = String(decoding: data, as: UTF8.self)
187
+ .split(separator: "\n")
188
+ .map(String.init)
189
+ .last(where: { !$0.isEmpty })
190
+ state = .error
191
+ primaryStatus = "Could not prepare the local runtime"
192
+ secondaryStatus = message ?? "Move the app to Applications and reopen it"
193
+ } catch {
194
+ state = .error
195
+ primaryStatus = "Could not prepare the local runtime"
196
+ secondaryStatus = error.localizedDescription
197
+ }
198
+ }
199
+
133
200
  func refresh() {
134
201
  dailyEnabled = FileManager.default.fileExists(atPath: launchAgent.path)
135
202
  loginSyncEnabled = FileManager.default.fileExists(atPath: loginSyncAgent.path)
@@ -145,7 +212,7 @@ final class SyncModel: ObservableObject {
145
212
  let configuredSource = config.sourceBrowser ?? "brave"
146
213
  selectedSourceID = browsers.contains(where: { $0.id == configuredSource }) ? configuredSource : "brave"
147
214
  let configuredTarget = config.targetBrowser ?? "codex"
148
- selectedTargetID = configuredTarget == "codex" || browsers.contains(where: { $0.id == configuredTarget })
215
+ selectedTargetID = configuredTarget == "codex" || configuredTarget == "browserless" || browsers.contains(where: { $0.id == configuredTarget })
149
216
  ? configuredTarget
150
217
  : "codex"
151
218
  if selectedTargetID == selectedSourceID { selectedTargetID = "codex" }
@@ -153,12 +220,16 @@ final class SyncModel: ObservableObject {
153
220
  historyEnabled = config.imports?.history ?? false
154
221
  menuBarEnabled = config.ui?.menuBar ?? true
155
222
  autoCheckUpdates = config.ui?.autoCheckUpdates ?? true
223
+ browserlessProfileName = config.browserless?.profileName ?? "browser-cookie-bridge"
224
+ browserlessRegion = config.browserless?.region ?? "sfo"
225
+ browserlessOnlyDomains = (config.browserless?.onlyDomains ?? []).joined(separator: ", ")
156
226
  }
227
+ browserlessConfigured = BrowserlessCredentialStore.read() != nil
157
228
  NotificationCenter.default.post(name: .menuBarVisibilityChanged, object: menuBarEnabled)
158
229
  extensionsReady = requiredExtensionIDs.allSatisfy {
159
230
  FileManager.default.fileExists(atPath: support.appending(path: "extension-\($0)/manifest.json").path)
160
231
  }
161
- updateCodexRunningStatus()
232
+ updateEndpointRunningStatus()
162
233
  consumeUpdateResultIfNeeded()
163
234
  if autoCheckUpdates && !didCheckAfterLaunch {
164
235
  didCheckAfterLaunch = true
@@ -173,11 +244,12 @@ final class SyncModel: ObservableObject {
173
244
  }
174
245
 
175
246
  func selectTarget(_ id: String) {
176
- let validTarget = id == "codex" || browsers.contains(where: { $0.id == id })
247
+ let validTarget = id == "codex" || id == "browserless" || browsers.contains(where: { $0.id == id })
177
248
  guard validTarget, id != selectedTargetID, id != selectedSourceID else { return }
178
249
  selectedTargetID = id
179
250
  persistPreferences(successMessage: "Import destination changed to \(targetName)")
180
- updateCodexRunningStatus()
251
+ updateEndpointRunningStatus()
252
+ if id == "browserless" && !browserlessConfigured { showingBrowserlessSetup = true }
181
253
  }
182
254
 
183
255
  func setCookiesEnabled(_ enabled: Bool) {
@@ -190,6 +262,33 @@ final class SyncModel: ObservableObject {
190
262
  persistPreferences(successMessage: enabled ? "History URL import enabled" : "History import disabled")
191
263
  }
192
264
 
265
+ func saveBrowserlessSettings(token: String, profileName: String, region: String, onlyDomains: String) {
266
+ let cleanedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
267
+ let cleanedName = profileName.trimmingCharacters(in: .whitespacesAndNewlines)
268
+ guard !cleanedName.isEmpty, !cleanedToken.isEmpty || BrowserlessCredentialStore.read() != nil else {
269
+ postNativeAlert(title: "Browserless connection incomplete", message: "Enter an API token and cloud profile name.", kind: .warning)
270
+ return
271
+ }
272
+ do {
273
+ if !cleanedToken.isEmpty { try BrowserlessCredentialStore.save(cleanedToken) }
274
+ browserlessConfigured = true
275
+ browserlessProfileName = cleanedName
276
+ browserlessRegion = region
277
+ browserlessOnlyDomains = onlyDomains
278
+ showingBrowserlessSetup = false
279
+ persistPreferences(successMessage: "Browserless connected — uploads remain manual")
280
+ } catch {
281
+ postNativeAlert(title: "Could not save Browserless token", message: error.localizedDescription, kind: .error)
282
+ }
283
+ }
284
+
285
+ func disconnectBrowserless() {
286
+ BrowserlessCredentialStore.delete()
287
+ browserlessConfigured = false
288
+ showingBrowserlessSetup = false
289
+ updateEndpointRunningStatus()
290
+ }
291
+
193
292
  func setMenuBarEnabled(_ enabled: Bool) {
194
293
  menuBarEnabled = enabled
195
294
  NotificationCenter.default.post(name: .menuBarVisibilityChanged, object: enabled)
@@ -204,11 +303,12 @@ final class SyncModel: ObservableObject {
204
303
 
205
304
  func checkForUpdates(showAlert: Bool = false) {
206
305
  guard !isCheckingForUpdates, !isInstallingUpdate else { return }
207
- guard let url = URL(string: "https://registry.npmjs.org/browser-cookie-bridge/latest") else { return }
306
+ guard let url = URL(string: "https://api.github.com/repos/apoorvdarshan/browser-cookie-bridge/releases/latest") else { return }
208
307
  isCheckingForUpdates = true
209
308
  postUpdateState()
210
309
  var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
211
310
  request.setValue("Browser-Cookie-Bridge/\(currentVersion)", forHTTPHeaderField: "User-Agent")
311
+ request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
212
312
  URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
213
313
  Task { @MainActor in
214
314
  guard let self else { return }
@@ -292,8 +392,8 @@ final class SyncModel: ObservableObject {
292
392
  }
293
393
  return
294
394
  }
295
- updateCodexRunningStatus()
296
- guard !codexBlocked else {
395
+ updateEndpointRunningStatus()
396
+ guard !syncBlocked else {
297
397
  if showMenuBarAlert {
298
398
  postNativeAlert(title: primaryStatus, message: secondaryStatus, kind: .warning)
299
399
  }
@@ -301,17 +401,33 @@ final class SyncModel: ObservableObject {
301
401
  }
302
402
  isSyncing = true
303
403
  state = .syncing
304
- primaryStatus = "Transferring selected data"
404
+ primaryStatus = isBrowserlessTarget ? "Uploading authenticated state" : "Transferring selected data"
305
405
  secondaryStatus = selectedTargetID == "codex"
306
406
  ? "Backing up Codex and merging \(selectedBrowser.name) locally…"
307
- : "Waiting for \(selectedBrowser.name) and \(targetName)…"
308
- runCLI(["sync", "--timeout", "300"]) { [weak self] success, output in
407
+ : isBrowserlessTarget
408
+ ? "Sending \(selectedBrowser.name) to Browserless \(browserlessRegion.uppercased()) only for this request…"
409
+ : "Waiting for \(selectedBrowser.name) and \(targetName)…"
410
+ var environment: [String: String] = [:]
411
+ var arguments = ["sync", "--timeout", "300"]
412
+ if isBrowserlessTarget {
413
+ guard let token = BrowserlessCredentialStore.read() else {
414
+ isSyncing = false
415
+ browserlessConfigured = false
416
+ updateEndpointRunningStatus()
417
+ return
418
+ }
419
+ environment["BROWSERLESS_TOKEN"] = token
420
+ arguments.append("--allow-cloud-upload")
421
+ }
422
+ runCLI(arguments, environment: environment) { [weak self] success, output in
309
423
  guard let self else { return }
310
424
  self.isSyncing = false
311
425
  let partial = success && (output.contains("Partially synced:") || output.contains("with warnings"))
312
426
  if success {
313
427
  self.state = partial ? .warning : .success
314
- self.primaryStatus = self.selectedTargetID == "codex"
428
+ self.primaryStatus = self.isBrowserlessTarget
429
+ ? "Browserless profile uploaded"
430
+ : self.selectedTargetID == "codex"
315
431
  ? (partial ? "Codex sync completed with warnings" : "Codex sessions updated")
316
432
  : (partial ? "Partially synced" : "Transfer complete")
317
433
  self.secondaryStatus = self.lastMeaningfulLine(output) ?? "\(self.selectedBrowser.name) and \(self.targetName) are up to date"
@@ -320,9 +436,11 @@ final class SyncModel: ObservableObject {
320
436
  self.primaryStatus = "Sync did not finish"
321
437
  self.secondaryStatus = self.lastMeaningfulLine(output) ?? (self.selectedTargetID == "codex"
322
438
  ? "Quit Codex completely, then try again"
439
+ : self.isBrowserlessTarget
440
+ ? "Check the API token, close the source browser, and try again"
323
441
  : "Keep both browsers open and check the extensions")
324
442
  }
325
- self.updateCodexRunningStatus()
443
+ self.updateEndpointRunningStatus()
326
444
  if showMenuBarAlert {
327
445
  self.postNativeAlert(
328
446
  title: self.primaryStatus,
@@ -334,6 +452,10 @@ final class SyncModel: ObservableObject {
334
452
  }
335
453
 
336
454
  func setDailyEnabled(_ enabled: Bool) {
455
+ guard !isBrowserlessTarget else {
456
+ postNativeAlert(title: "Cloud uploads are manual-only", message: "Browser Cookie Bridge will never schedule Browserless uploads in the background.", kind: .information)
457
+ return
458
+ }
337
459
  dailyEnabled = enabled
338
460
  applySchedule(enabled)
339
461
  }
@@ -343,6 +465,10 @@ final class SyncModel: ObservableObject {
343
465
  }
344
466
 
345
467
  func setLoginSyncEnabled(_ enabled: Bool) {
468
+ guard !isBrowserlessTarget else {
469
+ postNativeAlert(title: "Cloud uploads are manual-only", message: "Login sync does not send authenticated state to Browserless.", kind: .information)
470
+ return
471
+ }
346
472
  loginSyncEnabled = enabled
347
473
  isWorking = true
348
474
  runCLI([enabled ? "enable-login-sync" : "disable-login-sync"]) { [weak self] success, output in
@@ -406,7 +532,10 @@ final class SyncModel: ObservableObject {
406
532
  "--cookies", cookiesEnabled ? "on" : "off",
407
533
  "--history", historyEnabled ? "on" : "off",
408
534
  "--menu-bar", menuBarEnabled ? "on" : "off",
409
- "--auto-check-updates", autoCheckUpdates ? "on" : "off"
535
+ "--auto-check-updates", autoCheckUpdates ? "on" : "off",
536
+ "--browserless-profile", browserlessProfileName,
537
+ "--browserless-region", browserlessRegion,
538
+ "--browserless-domains", browserlessOnlyDomains,
410
539
  ]
411
540
  runCLI(arguments) { [weak self] success, output in
412
541
  guard let self else { return }
@@ -414,7 +543,9 @@ final class SyncModel: ObservableObject {
414
543
  if success {
415
544
  self.state = .ready
416
545
  self.primaryStatus = successMessage
417
- self.secondaryStatus = "This choice is saved for manual and daily syncs"
546
+ self.secondaryStatus = self.isBrowserlessTarget
547
+ ? "Cloud uploads run only after you click Upload"
548
+ : "This choice is saved for manual and daily syncs"
418
549
  } else {
419
550
  self.state = .error
420
551
  self.primaryStatus = "Could not save import settings"
@@ -424,7 +555,7 @@ final class SyncModel: ObservableObject {
424
555
  self.extensionsReady = self.requiredExtensionIDs.allSatisfy {
425
556
  FileManager.default.fileExists(atPath: self.support.appending(path: "extension-\($0)/manifest.json").path)
426
557
  }
427
- self.updateCodexRunningStatus()
558
+ self.updateEndpointRunningStatus()
428
559
  }
429
560
  }
430
561
 
@@ -453,7 +584,7 @@ final class SyncModel: ObservableObject {
453
584
  }
454
585
  }
455
586
 
456
- private func runCLI(_ arguments: [String], completion: @escaping @MainActor (Bool, String) -> Void) {
587
+ private func runCLI(_ arguments: [String], environment: [String: String] = [:], completion: @escaping @MainActor (Bool, String) -> Void) {
457
588
  guard let config = loadConfig() else {
458
589
  completion(false, "Configuration missing. Run install-app again.")
459
590
  return
@@ -462,6 +593,7 @@ final class SyncModel: ObservableObject {
462
593
  let output = Pipe()
463
594
  process.executableURL = URL(fileURLWithPath: config.nodePath)
464
595
  process.arguments = [runtimeCLI.path] + arguments
596
+ process.environment = ProcessInfo.processInfo.environment.merging(environment) { _, new in new }
465
597
  process.standardOutput = output
466
598
  process.standardError = output
467
599
  process.terminationHandler = { process in
@@ -504,23 +636,44 @@ final class SyncModel: ObservableObject {
504
636
  }
505
637
 
506
638
  private var requiredExtensionIDs: [String] {
507
- selectedTargetID == "codex" ? [] : [selectedSourceID, selectedTargetID]
639
+ selectedTargetID == "codex" || selectedTargetID == "browserless" ? [] : [selectedSourceID, selectedTargetID]
508
640
  }
509
641
 
510
- private func updateCodexRunningStatus() {
642
+ private func updateEndpointRunningStatus() {
511
643
  let wasRunning = codexRunning
512
644
  codexRunning = NSWorkspace.shared.runningApplications.contains {
513
645
  $0.bundleIdentifier == "com.openai.codex"
514
646
  }
515
- guard selectedTargetID == "codex", !isSyncing else { return }
516
- if codexRunning {
647
+ sourceBrowserRunning = NSWorkspace.shared.runningApplications.contains {
648
+ $0.bundleIdentifier == selectedBrowser.bundleIdentifier
649
+ }
650
+ guard !isSyncing else { return }
651
+ if selectedTargetID == "codex" && codexRunning {
517
652
  state = .warning
518
653
  primaryStatus = "Quit Codex before syncing"
519
654
  secondaryStatus = "Close ChatGPT Codex completely so its local cookie database can be updated safely"
520
- } else if wasRunning && primaryStatus == "Quit Codex before syncing" {
655
+ } else if selectedTargetID == "codex" && wasRunning && primaryStatus == "Quit Codex before syncing" {
521
656
  state = .ready
522
657
  primaryStatus = "Ready to sync directly"
523
658
  secondaryStatus = "Codex is closed — a backup will be created before anything changes"
659
+ } else if isBrowserlessTarget {
660
+ if selectedSourceID == "comet" {
661
+ state = .warning
662
+ primaryStatus = "Comet capture is not supported"
663
+ secondaryStatus = "Choose Brave, Chrome, Edge, Arc, Vivaldi, or Opera for Browserless"
664
+ } else if !browserlessConfigured {
665
+ state = .warning
666
+ primaryStatus = "Connect Browserless"
667
+ secondaryStatus = "Your API token will be stored in macOS Keychain, never in the app configuration"
668
+ } else if sourceBrowserRunning {
669
+ state = .warning
670
+ primaryStatus = "Quit \(selectedBrowser.name) before uploading"
671
+ secondaryStatus = "Browserless captures a temporary copy of the closed profile, including local storage and IndexedDB"
672
+ } else {
673
+ state = .ready
674
+ primaryStatus = "Ready for an explicit cloud upload"
675
+ secondaryStatus = "Only this click sends authenticated state to Browserless \(browserlessRegion.uppercased())"
676
+ }
524
677
  }
525
678
  }
526
679
 
@@ -575,6 +728,16 @@ final class SyncModel: ObservableObject {
575
728
 
576
729
  private struct PackageRelease: Decodable {
577
730
  let version: String
731
+
732
+ private enum CodingKeys: String, CodingKey {
733
+ case tagName = "tag_name"
734
+ }
735
+
736
+ init(from decoder: Decoder) throws {
737
+ let values = try decoder.container(keyedBy: CodingKeys.self)
738
+ let tag = try values.decode(String.self, forKey: .tagName)
739
+ version = tag.hasPrefix("v") ? String(tag.dropFirst()) : tag
740
+ }
578
741
  }
579
742
 
580
743
  private struct UpdateResult: Decodable {
@@ -590,6 +753,7 @@ private struct AppConfig: Decodable {
590
753
  let targetBrowser: String?
591
754
  let imports: Imports?
592
755
  let ui: UISettings?
756
+ let browserless: BrowserlessSettings?
593
757
 
594
758
  struct Schedule: Decodable {
595
759
  let hour: Int
@@ -606,4 +770,60 @@ private struct AppConfig: Decodable {
606
770
  let openAtLogin: Bool?
607
771
  let autoCheckUpdates: Bool?
608
772
  }
773
+
774
+ struct BrowserlessSettings: Decodable {
775
+ let profileName: String?
776
+ let region: String?
777
+ let onlyDomains: [String]?
778
+ }
779
+ }
780
+
781
+ private enum BrowserlessCredentialStore {
782
+ private static let service = "com.apoorvdarshan.browser-cookie-bridge.browserless"
783
+ private static let account = "api-token"
784
+
785
+ static func save(_ token: String) throws {
786
+ let identity: [String: Any] = [
787
+ kSecClass as String: kSecClassGenericPassword,
788
+ kSecAttrService as String: service,
789
+ kSecAttrAccount as String: account,
790
+ ]
791
+ let update: [String: Any] = [kSecValueData as String: Data(token.utf8)]
792
+ let updateStatus = SecItemUpdate(identity as CFDictionary, update as CFDictionary)
793
+ if updateStatus == errSecSuccess { return }
794
+ guard updateStatus == errSecItemNotFound else {
795
+ throw NSError(domain: NSOSStatusErrorDomain, code: Int(updateStatus), userInfo: nil)
796
+ }
797
+ let item: [String: Any] = identity.merging([
798
+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
799
+ kSecValueData as String: Data(token.utf8),
800
+ ]) { _, new in new }
801
+ let status = SecItemAdd(item as CFDictionary, nil)
802
+ guard status == errSecSuccess else {
803
+ throw NSError(domain: NSOSStatusErrorDomain, code: Int(status), userInfo: nil)
804
+ }
805
+ }
806
+
807
+ static func read() -> String? {
808
+ let query: [String: Any] = [
809
+ kSecClass as String: kSecClassGenericPassword,
810
+ kSecAttrService as String: service,
811
+ kSecAttrAccount as String: account,
812
+ kSecReturnData as String: true,
813
+ kSecMatchLimit as String: kSecMatchLimitOne,
814
+ ]
815
+ var result: CFTypeRef?
816
+ guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
817
+ let data = result as? Data else { return nil }
818
+ return String(data: data, encoding: .utf8)
819
+ }
820
+
821
+ static func delete() {
822
+ let query: [String: Any] = [
823
+ kSecClass as String: kSecClassGenericPassword,
824
+ kSecAttrService as String: service,
825
+ kSecAttrAccount as String: account,
826
+ ]
827
+ SecItemDelete(query as CFDictionary)
828
+ }
609
829
  }
package/package.json CHANGED
@@ -1,28 +1,29 @@
1
1
  {
2
2
  "name": "browser-cookie-bridge",
3
- "version": "1.0.0",
4
- "description": "Private local cookie and session transfer for macOS",
3
+ "version": "1.2.0",
4
+ "description": "Local-first cookie and session transfer for macOS with optional Browserless upload",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "browser-cookie-bridge": "./bin/brave-codex-cookie-sync.js"
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/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 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-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
+ "build:dmg": "node scripts/build-dmg.js",
14
15
  "web": "node web/server.js",
15
16
  "web:deploy": "wrangler deploy --config web/wrangler.jsonc"
16
17
  },
17
18
  "engines": {
18
- "node": ">=22.5"
19
+ "node": ">=24"
19
20
  },
20
21
  "license": "MIT",
21
22
  "repository": {
22
23
  "type": "git",
23
24
  "url": "git+https://github.com/apoorvdarshan/browser-cookie-bridge.git"
24
25
  },
25
- "homepage": "https://github.com/apoorvdarshan/browser-cookie-bridge#readme",
26
+ "homepage": "https://cookiebridge.apoorvdarshan.com/",
26
27
  "bugs": {
27
28
  "url": "https://github.com/apoorvdarshan/browser-cookie-bridge/issues"
28
29
  },
@@ -30,6 +31,17 @@
30
31
  "access": "public",
31
32
  "registry": "https://registry.npmjs.org/"
32
33
  },
34
+ "keywords": [
35
+ "browser",
36
+ "cookies",
37
+ "sessions",
38
+ "macos",
39
+ "chromium",
40
+ "brave",
41
+ "browserless",
42
+ "codex"
43
+ ],
44
+ "funding": "https://ko-fi.com/apoorvdarshan",
33
45
  "files": [
34
46
  "bin",
35
47
  "src",
@@ -39,6 +51,10 @@
39
51
  "macos-app/Resources",
40
52
  "macos-app/Sources",
41
53
  "README.md",
42
- "LICENSE"
43
- ]
54
+ "LICENSE",
55
+ "THIRD_PARTY_NOTICES.md"
56
+ ],
57
+ "dependencies": {
58
+ "@browserless.io/cli": "0.3.0"
59
+ }
44
60
  }
@@ -0,0 +1,13 @@
1
+ import { pathToFileURL } from "node:url";
2
+
3
+ const [, , cliPath, ...argumentsWithoutToken] = process.argv;
4
+ const token = process.env.BROWSERLESS_TOKEN?.trim();
5
+ if (!cliPath) throw new Error("Browserless CLI path is missing.");
6
+ if (!token) throw new Error("Browserless API token is missing.");
7
+
8
+ // Read the Keychain-supplied token once, then remove it before the official CLI
9
+ // launches a temporary browser. Mutating process.argv does not alter the OS
10
+ // command line that started this process, so the token is not exposed by `ps`.
11
+ delete process.env.BROWSERLESS_TOKEN;
12
+ process.argv = [process.execPath, cliPath, ...argumentsWithoutToken, "--token", token];
13
+ await import(pathToFileURL(cliPath).href);
@@ -0,0 +1,108 @@
1
+ import path from "node:path";
2
+ import { spawn } from "node:child_process";
3
+ import { projectRoot } from "./paths.js";
4
+
5
+ const SUPPORTED_SOURCES = new Set(["brave", "chrome", "edge", "arc", "vivaldi", "opera"]);
6
+ const REGIONS = new Set(["sfo", "lon", "ams"]);
7
+
8
+ export async function uploadBrowserlessProfile({
9
+ browser,
10
+ localProfile,
11
+ profileName,
12
+ region = "sfo",
13
+ onlyDomains = [],
14
+ token = process.env.BROWSERLESS_TOKEN,
15
+ root = projectRoot(),
16
+ runner = runCLI,
17
+ } = {}) {
18
+ if (!SUPPORTED_SOURCES.has(browser)) {
19
+ throw new Error(`${browser === "comet" ? "Comet" : browser} is not supported by Browserless profile capture yet.`);
20
+ }
21
+ if (!localProfile) throw new Error("The local browser profile could not be determined.");
22
+ if (!profileName?.trim()) throw new Error("Choose a Browserless cloud profile name.");
23
+ if (!REGIONS.has(region)) throw new Error("Browserless region must be sfo, lon, or ams.");
24
+ if (!token?.trim()) throw new Error("Connect Browserless first. The API token is missing from macOS Keychain.");
25
+
26
+ const cliPath = path.join(root, "node_modules", "@browserless.io", "cli", "build", "cli.js");
27
+ const runnerPath = path.join(root, "src", "browserless-runner.js");
28
+ const environment = {
29
+ ...process.env,
30
+ BROWSERLESS_TOKEN: token.trim(),
31
+ BROWSERLESS_ACCEPT_TERMS: "1",
32
+ BROWSERLESS_TELEMETRY_DISABLED: "1",
33
+ BROWSERLESS_DISABLE_KEYCHAIN: "1",
34
+ DO_NOT_TRACK: "1",
35
+ };
36
+ const client = ["--region", region, "--json"];
37
+ const shown = await runner(cliPath, ["profile", "show", profileName.trim(), ...client], environment, runnerPath);
38
+ const operation = shown.status === 0 ? "refresh" : isMissingProfile(shown.output) ? "upload" : null;
39
+ if (!operation) throw new Error(lastLine(shown.output) || "Browserless could not validate the cloud profile.");
40
+
41
+ const capture = [
42
+ "profile", operation,
43
+ "--browser", browser,
44
+ "--profile", localProfile,
45
+ "--name", profileName.trim(),
46
+ ...client,
47
+ "--accept-terms",
48
+ "--auto-fit",
49
+ ];
50
+ 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.");
53
+
54
+ const details = parseJSON(result.output);
55
+ const cookies = details?.cookieCount;
56
+ const origins = details?.originCount;
57
+ const counts = Number.isInteger(cookies) && Number.isInteger(origins)
58
+ ? ` (${cookies} cookies, ${origins} origins)`
59
+ : "";
60
+ return {
61
+ operation,
62
+ profileName: details?.name || profileName.trim(),
63
+ cookieCount: cookies,
64
+ originCount: origins,
65
+ summary: `Browserless profile ${operation === "refresh" ? "updated" : "created"}: ${details?.name || profileName.trim()}${counts}`,
66
+ };
67
+ }
68
+
69
+ function runCLI(cliPath, args, environment, runnerPath) {
70
+ return new Promise((resolve, reject) => {
71
+ const child = spawn(process.execPath, [runnerPath, cliPath, ...args], {
72
+ env: environment,
73
+ stdio: ["ignore", "pipe", "pipe"],
74
+ });
75
+ let stdout = "";
76
+ 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}` }));
81
+ });
82
+ }
83
+
84
+ function isMissingProfile(output) {
85
+ return /(?:not found|does not exist|404)/i.test(output);
86
+ }
87
+
88
+ function parseJSON(output) {
89
+ const start = output.indexOf("{");
90
+ const end = output.lastIndexOf("}");
91
+ if (start !== -1 && end > start) {
92
+ try {
93
+ const parsed = JSON.parse(output.slice(start, end + 1));
94
+ if (parsed && typeof parsed === "object") return parsed;
95
+ } catch {}
96
+ }
97
+ for (const line of output.split("\n").reverse()) {
98
+ try {
99
+ const parsed = JSON.parse(line);
100
+ if (parsed && typeof parsed === "object") return parsed;
101
+ } catch {}
102
+ }
103
+ return null;
104
+ }
105
+
106
+ function lastLine(output) {
107
+ return output.split("\n").map((line) => line.trim()).filter(Boolean).at(-1)?.replace(/^Error:\s*/, "");
108
+ }