browser-cookie-bridge 1.1.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.
package/README.md CHANGED
@@ -4,16 +4,16 @@
4
4
 
5
5
  <h1>Browser Cookie Bridge</h1>
6
6
 
7
- <strong>Move cookies and signed-in sessions between browserslocally on your Mac.</strong>
7
+ <strong>Move signed-in sessions locallyor explicitly upload one to Browserless Cloud.</strong>
8
8
 
9
- <p>Brave, Chrome, Edge, Arc, Vivaldi, Opera, and Comet · browser-to-browser transfer · import into ChatGPT Codex.</p>
9
+ <p>Brave, Chrome, Edge, Arc, Vivaldi, Opera, and Comet · ChatGPT Codex import · optional Browserless authenticated profiles.</p>
10
10
 
11
11
  <p>
12
12
  <img src="https://img.shields.io/badge/macOS-13.5%2B-111111?logo=apple&logoColor=white" alt="macOS 13.5+" />
13
13
  <img src="https://img.shields.io/badge/Swift-6-8E2735?logo=swift&logoColor=white" alt="Swift 6" />
14
- <img src="https://img.shields.io/badge/Node.js-22.5%2B-3C873A?logo=nodedotjs&logoColor=white" alt="Node.js 22.5+" />
14
+ <img src="https://img.shields.io/badge/Node.js-24%2B-3C873A?logo=nodedotjs&logoColor=white" alt="Node.js 24+" />
15
15
  <a href="https://www.npmjs.com/package/browser-cookie-bridge"><img src="https://img.shields.io/npm/v/browser-cookie-bridge?logo=npm&color=CB3837" alt="browser-cookie-bridge on npm" /></a>
16
- <img src="https://img.shields.io/badge/local--first-no%20cloud-C68B3C" alt="Local-first, no cloud" />
16
+ <img src="https://img.shields.io/badge/local--first-cloud%20optional-C68B3C" alt="Local-first, cloud optional" />
17
17
  <img src="https://img.shields.io/github/stars/apoorvdarshan/browser-cookie-bridge?logo=github&color=C68B3C" alt="GitHub stars" />
18
18
  <a href="https://www.producthunt.com/products/browser-cookie-bridge"><img src="https://img.shields.io/badge/Product%20Hunt-View%20launch-DA552F?logo=producthunt&logoColor=white" alt="Browser Cookie Bridge on Product Hunt" /></a>
19
19
  <img src="https://img.shields.io/badge/license-MIT-3DA639" alt="MIT License" />
@@ -44,24 +44,25 @@
44
44
 
45
45
  ---
46
46
 
47
- > **Version 1.1.0 adds self-contained DMG downloads.** Importing into ChatGPT Codex is an intentionally unsupported direct integration and requires Codex to be completely closed.
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.
48
48
 
49
49
  ## Why Browser Cookie Bridge
50
50
 
51
51
  Signing into the same sites across several browsers is repetitive. Export files are awkward, password managers do not move active sessions, and ChatGPT Codex does not currently offer a Brave import button.
52
52
 
53
- Browser Cookie Bridge gives those local browser profiles a small, native control panel. Pick where data comes from, pick where it goes, choose cookies or history, and sync. Transfers stay on the Mac you are using.
53
+ Browser Cookie Bridge gives those browser profiles a small, native control panel. Local browser and Codex transfers stay on the Mac. If you explicitly select Browserless Cloud and click Upload, the app can instead send authenticated profile state to your own Browserless account.
54
54
 
55
55
  ## Features
56
56
 
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
61
  - 🕘 **Background automation** — sync when you sign in, at a fixed daily time, or whenever you choose.
61
62
  - ◉ **Native menu-bar app** — closing the window removes the Dock icon while the helper continues running.
62
63
  - 🧯 **Backup and rollback** — Codex's database is backed up, modified on a separate copy, integrity-checked, and restored if replacement fails.
63
64
  - ⬆️ **Built-in updates** — checks for GitHub releases, verifies the DMG checksum, installs in place, and relaunches the app.
64
- - 🔒 **Local-first** — no account, analytics, cloud service, cookie logs, or remote relay.
65
+ - 🔒 **Local-first** — local paths use no account, analytics, cookie logs, or remote relay; the separate Browserless path runs only after explicit selection and confirmation.
65
66
 
66
67
  ## What it transfers
67
68
 
@@ -69,6 +70,7 @@ Browser Cookie Bridge gives those local browser profiles a small, native control
69
70
  |---|---:|---|
70
71
  | **Cookies and sessions** | ✅ Default | Transfers supported cookie values and attributes |
71
72
  | **History URLs** | ◐ Optional | Original visit times and page titles cannot be preserved |
73
+ | **Local storage and IndexedDB** | ◐ Browserless only | Included only in an explicit Browserless authenticated-profile upload |
72
74
  | **Passwords** | — Never | Chromium extensions cannot read the browser password store |
73
75
  | **Bookmarks, autofill, payments** | — Never | Not requested or accessed |
74
76
  | **iCloud Keychain** | — Never | Remains completely separate |
@@ -80,7 +82,7 @@ Some websites bind sessions to a specific device or browser and may ask you to s
80
82
  - **macOS 13.5+**
81
83
  - A supported Chromium browser, or ChatGPT Codex as the destination
82
84
 
83
- The DMG is self-contained. Node.js 22.5+ and Xcode Command Line Tools are required only for npm or source installation.
85
+ The DMG is self-contained. Node.js 24+ and Xcode Command Line Tools are required only for npm or source installation.
84
86
 
85
87
  ## Installation
86
88
 
@@ -140,12 +142,25 @@ Browser-to-browser transfers use a small unpacked extension at each selected end
140
142
 
141
143
  Generated extensions live under `~/Library/Application Support/BraveCodexCookieSync/` and contain a random, user-only local broker token. Do not share those folders.
142
144
 
145
+ ### Browser → Browserless Cloud (optional)
146
+
147
+ This path uses the official Browserless CLI and is deliberately separate from local sync.
148
+
149
+ 1. Select **Browserless Cloud** as the destination.
150
+ 2. Enter your Browserless API token, cloud profile name, region, and optional domain allowlist. The token is stored in macOS Keychain; it is never written to the app configuration or command arguments.
151
+ 3. Quit the selected source browser so its profile can be copied consistently.
152
+ 4. Review the cloud warning and click **Upload now**.
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.
155
+
156
+ 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
+
143
158
  ## Usage
144
159
 
145
160
  | Control | What it does |
146
161
  |---|---|
147
162
  | **Export from** | Selects the browser whose data will be read |
148
- | **Import into** | Selects a different browser or ChatGPT Codex |
163
+ | **Import into** | Selects a different browser, ChatGPT Codex, or optional Browserless Cloud |
149
164
  | **Cookies** | Moves cookies and supported session attributes; on by default |
150
165
  | **History URLs** | Adds visited URLs without their original timestamps or titles |
151
166
  | **Daily sync** | Runs at one fixed local time; off by default |
@@ -154,7 +169,7 @@ Generated extensions live under `~/Library/Application Support/BraveCodexCookieS
154
169
  | **Show in menu bar** | Keeps sync, status, updates, and support actions close at hand |
155
170
  | **Check for updates** | Finds a newer GitHub release, verifies its DMG, and offers install + relaunch |
156
171
 
157
- Automation always uses the source, destination, and data choices currently saved in the app. A scheduled Codex sync safely exits without making changes when Codex is open.
172
+ 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.
158
173
 
159
174
  ## CLI
160
175
 
@@ -162,7 +177,7 @@ Automation always uses the source, destination, and data choices currently saved
162
177
  browser-cookie-bridge install-app [--no-open]
163
178
  browser-cookie-bridge setup [--hour 9] [--minute 0] [--no-schedule]
164
179
  browser-cookie-bridge preferences --source brave --target codex --cookies on --history off
165
- browser-cookie-bridge sync [--timeout 300]
180
+ browser-cookie-bridge sync [--timeout 300] [--allow-cloud-upload]
166
181
  browser-cookie-bridge doctor
167
182
  browser-cookie-bridge enable-login-sync
168
183
  browser-cookie-bridge disable-login-sync
@@ -171,7 +186,7 @@ browser-cookie-bridge disable-app-login
171
186
  browser-cookie-bridge remove-schedule
172
187
  ```
173
188
 
174
- Supported source IDs are `brave`, `chrome`, `edge`, `arc`, `vivaldi`, `opera`, and `comet`. Target IDs are the same plus `codex`. The same browser cannot be both endpoints.
189
+ Supported source IDs are `brave`, `chrome`, `edge`, `arc`, `vivaldi`, `opera`, and `comet`. Target IDs are the same plus `codex` and `browserless`. The same browser cannot be both endpoints. Browserless requires `BROWSERLESS_TOKEN` and the explicit `--allow-cloud-upload` flag; the native app supplies the token from Keychain without placing it in the OS command line or app configuration.
175
190
 
176
191
  ## How it works
177
192
 
@@ -179,6 +194,7 @@ Supported source IDs are `brave`, `chrome`, `edge`, `arc`, `vivaldi`, `opera`, a
179
194
  |---|---|
180
195
  | **Browser → browser** | Unpacked extensions connect to a short-lived broker on IPv4 loopback. Selected data stays in memory and is never written to logs. |
181
196
  | **Browser → Codex** | The app reads the selected local Chromium profile, creates a consistent Codex SQLite backup, merges into a working copy, validates it, then replaces the destination atomically. |
197
+ | **Browser → Browserless** | The bundled official Browserless CLI copies a closed local profile, captures cookies/local storage/IndexedDB, and uploads it directly to the selected Browserless region. |
182
198
 
183
199
  The broker validates a random token and extension origin, limits payload size, and normally exits after five minutes. Only the endpoints selected in the app respond to a transfer.
184
200
 
@@ -190,6 +206,7 @@ The broker validates a random token and extension origin, limits payload size, a
190
206
  - The app refuses unknown Codex database schemas instead of guessing.
191
207
  - Imported Codex rows use an empty `encrypted_value` because OpenAI's Safe Storage key is protected by a private macOS Keychain access group. Those imported rows can therefore remain readable to software running as your macOS user until the website refreshes them.
192
208
  - Anyone who can use your logged-in macOS account or modify a generated extension may be able to access transferred browser sessions.
209
+ - The optional Browserless destination sends authenticated state to Browserless under their terms and privacy practices. Its API token is stored in macOS Keychain, uploads are manual, and Browserless CLI telemetry is disabled by the app.
193
210
 
194
211
  Cookies are credentials. Review the source, protect your macOS account, and transfer only between profiles you trust.
195
212
 
@@ -237,4 +254,4 @@ npm run web
237
254
 
238
255
  [MIT](LICENSE) © 2026 [Apoorv Darshan](https://github.com/apoorvdarshan)
239
256
 
240
- <sub>Not affiliated with Brave, Google, Microsoft, The Browser Company, Vivaldi, Opera, Perplexity, or OpenAI. Their names and marks belong to their respective owners.</sub>
257
+ <sub>Not affiliated with Brave, Google, Microsoft, The Browser Company, Vivaldi, Opera, Perplexity, OpenAI, or Browserless. Their names and marks belong to their respective owners.</sub>
@@ -0,0 +1,11 @@
1
+ # Third-party notices
2
+
3
+ Browser Cookie Bridge includes the official Browserless CLI for the optional, user-initiated Browserless Cloud destination.
4
+
5
+ ## @browserless.io/cli
6
+
7
+ - Version: 0.3.0
8
+ - License: MIT
9
+ - Source: https://github.com/browserless/browserless-cli
10
+
11
+ The Browserless CLI's license and the licenses shipped with its npm dependencies remain included inside `node_modules` in self-contained app builds. Browser Cookie Bridge disables Browserless CLI telemetry for uploads started through this app.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Browser Cookie Bridge",
4
- "version": "1.1.0",
4
+ "version": "1.2.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.1.0</string>
14
- <key>CFBundleVersion</key><string>25</string>
13
+ <key>CFBundleShortVersionString</key><string>1.2.0</string>
14
+ <key>CFBundleVersion</key><string>26</string>
15
15
  <key>LSMinimumSystemVersion</key><string>13.5</string>
16
16
  <key>NSHighResolutionCapable</key><true/>
17
17
  </dict>
@@ -0,0 +1,6 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <svg xmlns="http://www.w3.org/2000/svg" width="256px" height="256px" viewBox="0 0 256 256">
3
+ <style>.s0{fill:black}@media (prefers-color-scheme:dark){.s0{fill:white}}</style>
4
+ <path class="s0" d="M 60.96875 0 C 61.0625 0.03125 67.5625 3.75 89.75 16.625 L 89.75 119.5625 C 89.75 217.46875 89.78125 222.5 90.1875 222.375 C 90.4375 222.3125 107.625 213.875 166.21875 185.125 L 166.25 169.6875 C 166.25 157.4375 166.1875 154.28125 165.9375 154.34375 C 165.78125 154.40625 155.03125 159.34375 142.0625 165.34375 C 129.09375 171.34375 118.4375 176.21875 118.375 176.1875 C 118.3125 176.15625 118.3125 144.09375 118.375 33.78125 L 147.25 50.78125 L 147.53125 114.875 L 195.375 142.71875 L 195.5 203.625 L 142.21875 229.75 C 112.9375 244.125 88.75 255.90625 88.53125 255.9375 C 88.3125 256 81.90625 251.875 60.46875 237.625 L 60.5 163.5 C 60.5 122.71875 60.5625 69.28125 60.6875 44.6875 C 60.75 20.09375 60.90625 0 60.96875 0 Z" />
5
+ </svg>
6
+
@@ -213,6 +213,9 @@ struct ContentView: View {
213
213
  .sheet(isPresented: $showingSetup) {
214
214
  ExtensionSetupSheet().environmentObject(model)
215
215
  }
216
+ .sheet(isPresented: $model.showingBrowserlessSetup) {
217
+ BrowserlessSetupSheet().environmentObject(model)
218
+ }
216
219
  }
217
220
 
218
221
  private var header: some View {
@@ -238,7 +241,10 @@ struct ContentView: View {
238
241
 
239
242
  private var footer: some View {
240
243
  HStack(spacing: 10) {
241
- Label("Data stays on this Mac", systemImage: "lock.shield.fill")
244
+ Label(
245
+ model.isBrowserlessTarget ? "Cloud upload only when you choose it" : "Data stays on this Mac",
246
+ systemImage: model.isBrowserlessTarget ? "cloud.fill" : "lock.shield.fill"
247
+ )
242
248
  .foregroundStyle(.secondary)
243
249
  Spacer()
244
250
  if let version = model.availableUpdateVersion {
@@ -262,7 +268,7 @@ struct ContentView: View {
262
268
  .buttonStyle(.plain)
263
269
  .foregroundStyle(.secondary)
264
270
  .help("Refresh status")
265
- if model.selectedTargetID != "codex" {
271
+ if model.selectedTargetID != "codex" && !model.isBrowserlessTarget {
266
272
  Button("Extension setup…") { showingSetup = true }
267
273
  .controlSize(.small)
268
274
  }
@@ -295,26 +301,32 @@ struct SyncPanel: View {
295
301
  .help(model.secondaryStatus)
296
302
  }
297
303
  Spacer()
298
- Button { model.syncNow() } label: {
304
+ Button {
305
+ if model.isBrowserlessTarget && !model.browserlessConfigured {
306
+ model.showingBrowserlessSetup = true
307
+ } else {
308
+ model.syncNow()
309
+ }
310
+ } label: {
299
311
  HStack(spacing: 7) {
300
312
  if model.isSyncing { ProgressView().controlSize(.small) }
301
- else { Image(systemName: model.codexBlocked ? "xmark.circle.fill" : "arrow.triangle.2.circlepath") }
302
- Text(model.isSyncing ? "Syncing…" : (model.codexBlocked ? "Close Codex first" : "Sync now"))
313
+ else { Image(systemName: syncButtonIcon) }
314
+ Text(syncButtonTitle)
303
315
  }
304
- .frame(minWidth: model.codexBlocked ? 116 : 92)
316
+ .frame(minWidth: model.syncBlocked ? 116 : 92)
305
317
  }
306
318
  .buttonStyle(.borderedProminent)
307
319
  .tint(Theme.accent)
308
- .disabled(model.isSyncing || model.codexBlocked)
320
+ .disabled(model.isSyncing || (model.syncBlocked && !(model.isBrowserlessTarget && !model.browserlessConfigured)))
309
321
  .keyboardShortcut(.return, modifiers: .command)
310
322
  }
311
- .padding(model.codexBlocked ? 7 : 0)
323
+ .padding(model.syncBlocked ? 7 : 0)
312
324
  .background(
313
- model.codexBlocked ? Theme.active.opacity(0.10) : Color.clear,
325
+ model.syncBlocked ? Theme.active.opacity(0.10) : Color.clear,
314
326
  in: RoundedRectangle(cornerRadius: 9, style: .continuous)
315
327
  )
316
328
  .overlay {
317
- if model.codexBlocked {
329
+ if model.syncBlocked {
318
330
  RoundedRectangle(cornerRadius: 9, style: .continuous)
319
331
  .stroke(Theme.active.opacity(0.38), lineWidth: 1)
320
332
  }
@@ -323,6 +335,20 @@ struct SyncPanel: View {
323
335
  }
324
336
  }
325
337
 
338
+ private var syncButtonTitle: String {
339
+ if model.isSyncing { return model.isBrowserlessTarget ? "Uploading…" : "Syncing…" }
340
+ if model.codexBlocked { return "Close Codex first" }
341
+ if model.isBrowserlessTarget && !model.browserlessConfigured { return "Connect Browserless" }
342
+ if model.isBrowserlessTarget && model.selectedSourceID == "comet" { return "Choose another browser" }
343
+ if model.isBrowserlessTarget && model.sourceBrowserRunning { return "Close \(model.selectedBrowser.name) first" }
344
+ return model.isBrowserlessTarget ? "Upload now" : "Sync now"
345
+ }
346
+
347
+ private var syncButtonIcon: String {
348
+ if model.syncBlocked { return model.isBrowserlessTarget && !model.browserlessConfigured ? "key.fill" : "xmark.circle.fill" }
349
+ return model.isBrowserlessTarget ? "icloud.and.arrow.up.fill" : "arrow.triangle.2.circlepath"
350
+ }
351
+
326
352
  }
327
353
 
328
354
  struct SourcePicker: View {
@@ -378,7 +404,12 @@ struct TargetPicker: View {
378
404
  VStack(spacing: 5) {
379
405
  HStack(spacing: 5) {
380
406
  ForEach(Array(model.browsers.prefix(4))) { browser in targetButton(browser) }
381
- Color.clear.frame(width: 19, height: 40)
407
+ EndpointButton(
408
+ icon: model.browserlessIcon,
409
+ name: "Browserless Cloud",
410
+ selected: model.selectedTargetID == "browserless",
411
+ disabled: model.isWorking || model.isSyncing || model.selectedSourceID == "comet"
412
+ ) { model.selectTarget("browserless") }
382
413
  }
383
414
  HStack(spacing: 5) {
384
415
  ForEach(Array(model.browsers.dropFirst(4))) { browser in targetButton(browser) }
@@ -449,44 +480,69 @@ struct PreferencesPanel: View {
449
480
  Surface(padding: 0) {
450
481
  VStack(spacing: 0) {
451
482
  SectionLabel(title: "Sync data", detail: "Saved automatically")
452
- PreferenceRow(icon: "network", color: Theme.accent, title: "Cookies", detail: "Site sessions and sign-ins") {
453
- Toggle("", isOn: Binding(get: { model.cookiesEnabled }, set: { model.setCookiesEnabled($0) }))
454
- .labelsHidden().toggleStyle(.switch).tint(Theme.active).disabled(model.isWorking)
455
- }
456
- RowDivider()
457
- PreferenceRow(icon: "clock.arrow.circlepath", color: Theme.accent, title: "History URLs", detail: "Original visit times are not preserved") {
458
- Toggle("", isOn: Binding(get: { model.historyEnabled }, set: { model.setHistoryEnabled($0) }))
459
- .labelsHidden().toggleStyle(.switch).tint(Theme.active).disabled(model.isWorking)
460
- }
461
- RowDivider()
462
- PreferenceRow(icon: "key.slash", color: .secondary, title: "Passwords", detail: "Browser extensions cannot access passwords", muted: true) {
463
- Text("Unavailable")
464
- .font(.system(size: 10, weight: .semibold))
465
- .foregroundStyle(.tertiary)
466
- .padding(.horizontal, 8).padding(.vertical, 4)
467
- .background(Color.secondary.opacity(0.08), in: Capsule())
483
+ if model.isBrowserlessTarget {
484
+ PreferenceRow(icon: "person.crop.circle.badge.checkmark", color: Theme.accent, title: "Authenticated state", detail: "Cookies, local storage, and IndexedDB") {
485
+ FixedBadge("Included")
486
+ }
487
+ RowDivider()
488
+ PreferenceRow(icon: "clock.arrow.circlepath", color: .secondary, title: "History URLs", detail: "Not included in Browserless profiles", muted: true) {
489
+ FixedBadge("Excluded")
490
+ }
491
+ RowDivider()
492
+ PreferenceRow(icon: "key.slash", color: .secondary, title: "Saved passwords", detail: "Never read or uploaded", muted: true) {
493
+ FixedBadge("Excluded")
494
+ }
495
+ RowDivider()
496
+ PreferenceRow(icon: "cloud.fill", color: Theme.accent, title: "Browserless connection", detail: model.browserlessConfigured ? "\(model.browserlessProfileName) · \(model.browserlessRegion.uppercased()) · token in Keychain" : "Not connected") {
497
+ Button(model.browserlessConfigured ? "Configure…" : "Connect…") {
498
+ model.showingBrowserlessSetup = true
499
+ }
500
+ .controlSize(.small)
501
+ }
502
+ } else {
503
+ PreferenceRow(icon: "network", color: Theme.accent, title: "Cookies", detail: "Site sessions and sign-ins") {
504
+ Toggle("", isOn: Binding(get: { model.cookiesEnabled }, set: { model.setCookiesEnabled($0) }))
505
+ .labelsHidden().toggleStyle(.switch).tint(Theme.active).disabled(model.isWorking)
506
+ }
507
+ RowDivider()
508
+ PreferenceRow(icon: "clock.arrow.circlepath", color: Theme.accent, title: "History URLs", detail: "Original visit times are not preserved") {
509
+ Toggle("", isOn: Binding(get: { model.historyEnabled }, set: { model.setHistoryEnabled($0) }))
510
+ .labelsHidden().toggleStyle(.switch).tint(Theme.active).disabled(model.isWorking)
511
+ }
512
+ RowDivider()
513
+ PreferenceRow(icon: "key.slash", color: .secondary, title: "Passwords", detail: "Browser extensions cannot access passwords", muted: true) {
514
+ FixedBadge("Unavailable")
515
+ }
468
516
  }
469
517
 
470
518
  SectionLabel(title: "Automation", detail: "Runs in the background", separated: true)
471
- PreferenceRow(icon: "clock.badge.checkmark", color: Theme.accent, title: "Daily sync", detail: model.dailyEnabled ? "At the selected local time" : "Off") {
472
- HStack(spacing: 8) {
473
- DatePicker("", selection: $model.scheduleTime, displayedComponents: .hourAndMinute)
474
- .labelsHidden()
475
- .datePickerStyle(.field)
476
- .controlSize(.regular)
477
- .disabled(!model.dailyEnabled || model.isWorking)
478
- .frame(width: 112, alignment: .center)
479
- .onChange(of: model.scheduleTime) { _ in
480
- if model.dailyEnabled && !model.isWorking { model.saveSchedule() }
481
- }
482
- Toggle("", isOn: Binding(get: { model.dailyEnabled }, set: { model.setDailyEnabled($0) }))
483
- .labelsHidden().toggleStyle(.switch).tint(Theme.active).disabled(model.isWorking)
519
+ PreferenceRow(icon: "clock.badge.checkmark", color: Theme.accent, title: "Daily sync", detail: model.isBrowserlessTarget ? "Unavailable for cloud uploads" : (model.dailyEnabled ? "At the selected local time" : "Off")) {
520
+ if model.isBrowserlessTarget {
521
+ FixedBadge("Manual only")
522
+ } else {
523
+ HStack(spacing: 8) {
524
+ DatePicker("", selection: $model.scheduleTime, displayedComponents: .hourAndMinute)
525
+ .labelsHidden()
526
+ .datePickerStyle(.field)
527
+ .controlSize(.regular)
528
+ .disabled(!model.dailyEnabled || model.isWorking)
529
+ .frame(width: 112, alignment: .center)
530
+ .onChange(of: model.scheduleTime) { _ in
531
+ if model.dailyEnabled && !model.isWorking { model.saveSchedule() }
532
+ }
533
+ Toggle("", isOn: Binding(get: { model.dailyEnabled }, set: { model.setDailyEnabled($0) }))
534
+ .labelsHidden().toggleStyle(.switch).tint(Theme.active).disabled(model.isWorking)
535
+ }
484
536
  }
485
537
  }
486
538
  RowDivider()
487
- PreferenceRow(icon: "sunrise.fill", color: Theme.accent, title: "Sync at login", detail: model.loginSyncEnabled ? "Once whenever you sign in" : "Off") {
488
- Toggle("", isOn: Binding(get: { model.loginSyncEnabled }, set: { model.setLoginSyncEnabled($0) }))
489
- .labelsHidden().toggleStyle(.switch).tint(Theme.active).disabled(model.isWorking)
539
+ PreferenceRow(icon: "sunrise.fill", color: Theme.accent, title: "Sync at login", detail: model.isBrowserlessTarget ? "Unavailable for cloud uploads" : (model.loginSyncEnabled ? "Once whenever you sign in" : "Off")) {
540
+ if model.isBrowserlessTarget {
541
+ FixedBadge("Manual only")
542
+ } else {
543
+ Toggle("", isOn: Binding(get: { model.loginSyncEnabled }, set: { model.setLoginSyncEnabled($0) }))
544
+ .labelsHidden().toggleStyle(.switch).tint(Theme.active).disabled(model.isWorking)
545
+ }
490
546
  }
491
547
  RowDivider()
492
548
  PreferenceRow(icon: "power", color: Theme.accent, title: "Open at login", detail: "Start the app after you sign in") {
@@ -525,6 +581,121 @@ struct PreferencesPanel: View {
525
581
  }
526
582
  }
527
583
 
584
+ struct FixedBadge: View {
585
+ let title: String
586
+
587
+ init(_ title: String) { self.title = title }
588
+
589
+ var body: some View {
590
+ Text(title)
591
+ .font(.system(size: 10, weight: .semibold))
592
+ .foregroundStyle(.tertiary)
593
+ .padding(.horizontal, 8).padding(.vertical, 4)
594
+ .background(Color.secondary.opacity(0.08), in: Capsule())
595
+ }
596
+ }
597
+
598
+ struct BrowserlessSetupSheet: View {
599
+ @EnvironmentObject private var model: SyncModel
600
+ @Environment(\.dismiss) private var dismiss
601
+ @State private var token = ""
602
+ @State private var profileName = "browser-cookie-bridge"
603
+ @State private var region = "sfo"
604
+ @State private var onlyDomains = ""
605
+ @State private var cloudConsent = false
606
+
607
+ var body: some View {
608
+ VStack(alignment: .leading, spacing: 17) {
609
+ HStack(alignment: .top, spacing: 12) {
610
+ Image(nsImage: model.browserlessIcon)
611
+ .resizable()
612
+ .scaledToFit()
613
+ .frame(width: 24, height: 24)
614
+ .frame(width: 40, height: 40)
615
+ .background(Theme.accent.opacity(0.11), in: RoundedRectangle(cornerRadius: 11, style: .continuous))
616
+ VStack(alignment: .leading, spacing: 3) {
617
+ Text("Connect Browserless Cloud")
618
+ .font(.system(size: 18, weight: .bold, design: .rounded))
619
+ Text("Optional authenticated-profile upload")
620
+ .font(.system(size: 11.5)).foregroundStyle(.secondary)
621
+ }
622
+ Spacer()
623
+ FixedBadge(model.browserlessConfigured ? "Connected" : "Not connected")
624
+ }
625
+
626
+ VStack(alignment: .leading, spacing: 12) {
627
+ LabeledContent("API token") {
628
+ SecureField(model.browserlessConfigured ? "Saved in Keychain — leave blank to keep" : "Browserless API token", text: $token)
629
+ .textFieldStyle(.roundedBorder)
630
+ .frame(width: 270)
631
+ }
632
+ LabeledContent("Cloud profile") {
633
+ TextField("browser-cookie-bridge", text: $profileName)
634
+ .textFieldStyle(.roundedBorder)
635
+ .frame(width: 270)
636
+ }
637
+ LabeledContent("Region") {
638
+ Picker("", selection: $region) {
639
+ Text("San Francisco (SFO)").tag("sfo")
640
+ Text("London (LON)").tag("lon")
641
+ Text("Amsterdam (AMS)").tag("ams")
642
+ }
643
+ .labelsHidden()
644
+ .frame(width: 270)
645
+ }
646
+ LabeledContent("Only domains") {
647
+ TextField("Optional: example.com, app.example.com", text: $onlyDomains)
648
+ .textFieldStyle(.roundedBorder)
649
+ .frame(width: 270)
650
+ }
651
+ }
652
+ .font(.system(size: 11.5, weight: .medium))
653
+ .padding(14)
654
+ .background(Color(nsColor: .controlBackgroundColor).opacity(0.72), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
655
+ .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous).stroke(Color.primary.opacity(0.08)))
656
+
657
+ HStack(alignment: .top, spacing: 10) {
658
+ Image(systemName: "exclamationmark.cloud.fill")
659
+ .foregroundStyle(Theme.active)
660
+ Text("This destination is not local. Clicking Upload sends cookies, local storage, and IndexedDB from a temporary profile copy to Browserless. Upload only profiles you are authorized to share; use the domain allowlist for sensitive profiles. Background and login sync never perform cloud uploads. CLI telemetry is disabled.")
661
+ .font(.system(size: 10.5))
662
+ .foregroundStyle(.secondary)
663
+ .fixedSize(horizontal: false, vertical: true)
664
+ }
665
+ .padding(12)
666
+ .background(Theme.active.opacity(0.08), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
667
+
668
+ Toggle("I understand the upload contents, have authority to share them, and accept responsibility for the Browserless copy.", isOn: $cloudConsent)
669
+ .toggleStyle(.checkbox)
670
+ .font(.system(size: 10.5, weight: .medium))
671
+
672
+ HStack {
673
+ Link("Get a Browserless API token", destination: URL(string: "https://www.browserless.io/account")!)
674
+ .font(.system(size: 10.5, weight: .medium))
675
+ Link("Privacy", destination: URL(string: "https://www.browserless.io/privacy-policy")!)
676
+ .font(.system(size: 10.5, weight: .medium))
677
+ if model.browserlessConfigured {
678
+ Button("Disconnect", role: .destructive) { model.disconnectBrowserless() }
679
+ }
680
+ Spacer()
681
+ Button("Cancel") { dismiss() }
682
+ Button("Save connection") {
683
+ model.saveBrowserlessSettings(token: token, profileName: profileName, region: region, onlyDomains: onlyDomains)
684
+ }
685
+ .keyboardShortcut(.defaultAction)
686
+ .disabled(profileName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !cloudConsent)
687
+ }
688
+ }
689
+ .padding(22)
690
+ .frame(width: 520)
691
+ .onAppear {
692
+ profileName = model.browserlessProfileName
693
+ region = model.browserlessRegion
694
+ onlyDomains = model.browserlessOnlyDomains
695
+ }
696
+ }
697
+ }
698
+
528
699
  struct ExtensionSetupSheet: View {
529
700
  @EnvironmentObject private var model: SyncModel
530
701
  @Environment(\.dismiss) private var dismiss
@@ -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")
@@ -119,9 +140,9 @@ final class SyncModel: ObservableObject {
119
140
  bootstrapBundledRuntimeIfNeeded()
120
141
  let calendar = Calendar.current
121
142
  scheduleTime = calendar.date(bySettingHour: 9, minute: 0, second: 0, of: Date()) ?? Date()
122
- updateCodexRunningStatus()
123
- codexStatusTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
124
- Task { @MainActor in self?.updateCodexRunningStatus() }
143
+ updateEndpointRunningStatus()
144
+ endpointStatusTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
145
+ Task { @MainActor in self?.updateEndpointRunningStatus() }
125
146
  }
126
147
  updateTimer = Timer.scheduledTimer(withTimeInterval: 24 * 60 * 60, repeats: true) { [weak self] _ in
127
148
  Task { @MainActor in
@@ -191,7 +212,7 @@ final class SyncModel: ObservableObject {
191
212
  let configuredSource = config.sourceBrowser ?? "brave"
192
213
  selectedSourceID = browsers.contains(where: { $0.id == configuredSource }) ? configuredSource : "brave"
193
214
  let configuredTarget = config.targetBrowser ?? "codex"
194
- selectedTargetID = configuredTarget == "codex" || browsers.contains(where: { $0.id == configuredTarget })
215
+ selectedTargetID = configuredTarget == "codex" || configuredTarget == "browserless" || browsers.contains(where: { $0.id == configuredTarget })
195
216
  ? configuredTarget
196
217
  : "codex"
197
218
  if selectedTargetID == selectedSourceID { selectedTargetID = "codex" }
@@ -199,12 +220,16 @@ final class SyncModel: ObservableObject {
199
220
  historyEnabled = config.imports?.history ?? false
200
221
  menuBarEnabled = config.ui?.menuBar ?? true
201
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: ", ")
202
226
  }
227
+ browserlessConfigured = BrowserlessCredentialStore.read() != nil
203
228
  NotificationCenter.default.post(name: .menuBarVisibilityChanged, object: menuBarEnabled)
204
229
  extensionsReady = requiredExtensionIDs.allSatisfy {
205
230
  FileManager.default.fileExists(atPath: support.appending(path: "extension-\($0)/manifest.json").path)
206
231
  }
207
- updateCodexRunningStatus()
232
+ updateEndpointRunningStatus()
208
233
  consumeUpdateResultIfNeeded()
209
234
  if autoCheckUpdates && !didCheckAfterLaunch {
210
235
  didCheckAfterLaunch = true
@@ -219,11 +244,12 @@ final class SyncModel: ObservableObject {
219
244
  }
220
245
 
221
246
  func selectTarget(_ id: String) {
222
- let validTarget = id == "codex" || browsers.contains(where: { $0.id == id })
247
+ let validTarget = id == "codex" || id == "browserless" || browsers.contains(where: { $0.id == id })
223
248
  guard validTarget, id != selectedTargetID, id != selectedSourceID else { return }
224
249
  selectedTargetID = id
225
250
  persistPreferences(successMessage: "Import destination changed to \(targetName)")
226
- updateCodexRunningStatus()
251
+ updateEndpointRunningStatus()
252
+ if id == "browserless" && !browserlessConfigured { showingBrowserlessSetup = true }
227
253
  }
228
254
 
229
255
  func setCookiesEnabled(_ enabled: Bool) {
@@ -236,6 +262,33 @@ final class SyncModel: ObservableObject {
236
262
  persistPreferences(successMessage: enabled ? "History URL import enabled" : "History import disabled")
237
263
  }
238
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
+
239
292
  func setMenuBarEnabled(_ enabled: Bool) {
240
293
  menuBarEnabled = enabled
241
294
  NotificationCenter.default.post(name: .menuBarVisibilityChanged, object: enabled)
@@ -339,8 +392,8 @@ final class SyncModel: ObservableObject {
339
392
  }
340
393
  return
341
394
  }
342
- updateCodexRunningStatus()
343
- guard !codexBlocked else {
395
+ updateEndpointRunningStatus()
396
+ guard !syncBlocked else {
344
397
  if showMenuBarAlert {
345
398
  postNativeAlert(title: primaryStatus, message: secondaryStatus, kind: .warning)
346
399
  }
@@ -348,17 +401,33 @@ final class SyncModel: ObservableObject {
348
401
  }
349
402
  isSyncing = true
350
403
  state = .syncing
351
- primaryStatus = "Transferring selected data"
404
+ primaryStatus = isBrowserlessTarget ? "Uploading authenticated state" : "Transferring selected data"
352
405
  secondaryStatus = selectedTargetID == "codex"
353
406
  ? "Backing up Codex and merging \(selectedBrowser.name) locally…"
354
- : "Waiting for \(selectedBrowser.name) and \(targetName)…"
355
- runCLI(["sync", "--timeout", "300"]) { [weak self] success, output in
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
356
423
  guard let self else { return }
357
424
  self.isSyncing = false
358
425
  let partial = success && (output.contains("Partially synced:") || output.contains("with warnings"))
359
426
  if success {
360
427
  self.state = partial ? .warning : .success
361
- self.primaryStatus = self.selectedTargetID == "codex"
428
+ self.primaryStatus = self.isBrowserlessTarget
429
+ ? "Browserless profile uploaded"
430
+ : self.selectedTargetID == "codex"
362
431
  ? (partial ? "Codex sync completed with warnings" : "Codex sessions updated")
363
432
  : (partial ? "Partially synced" : "Transfer complete")
364
433
  self.secondaryStatus = self.lastMeaningfulLine(output) ?? "\(self.selectedBrowser.name) and \(self.targetName) are up to date"
@@ -367,9 +436,11 @@ final class SyncModel: ObservableObject {
367
436
  self.primaryStatus = "Sync did not finish"
368
437
  self.secondaryStatus = self.lastMeaningfulLine(output) ?? (self.selectedTargetID == "codex"
369
438
  ? "Quit Codex completely, then try again"
439
+ : self.isBrowserlessTarget
440
+ ? "Check the API token, close the source browser, and try again"
370
441
  : "Keep both browsers open and check the extensions")
371
442
  }
372
- self.updateCodexRunningStatus()
443
+ self.updateEndpointRunningStatus()
373
444
  if showMenuBarAlert {
374
445
  self.postNativeAlert(
375
446
  title: self.primaryStatus,
@@ -381,6 +452,10 @@ final class SyncModel: ObservableObject {
381
452
  }
382
453
 
383
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
+ }
384
459
  dailyEnabled = enabled
385
460
  applySchedule(enabled)
386
461
  }
@@ -390,6 +465,10 @@ final class SyncModel: ObservableObject {
390
465
  }
391
466
 
392
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
+ }
393
472
  loginSyncEnabled = enabled
394
473
  isWorking = true
395
474
  runCLI([enabled ? "enable-login-sync" : "disable-login-sync"]) { [weak self] success, output in
@@ -453,7 +532,10 @@ final class SyncModel: ObservableObject {
453
532
  "--cookies", cookiesEnabled ? "on" : "off",
454
533
  "--history", historyEnabled ? "on" : "off",
455
534
  "--menu-bar", menuBarEnabled ? "on" : "off",
456
- "--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,
457
539
  ]
458
540
  runCLI(arguments) { [weak self] success, output in
459
541
  guard let self else { return }
@@ -461,7 +543,9 @@ final class SyncModel: ObservableObject {
461
543
  if success {
462
544
  self.state = .ready
463
545
  self.primaryStatus = successMessage
464
- 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"
465
549
  } else {
466
550
  self.state = .error
467
551
  self.primaryStatus = "Could not save import settings"
@@ -471,7 +555,7 @@ final class SyncModel: ObservableObject {
471
555
  self.extensionsReady = self.requiredExtensionIDs.allSatisfy {
472
556
  FileManager.default.fileExists(atPath: self.support.appending(path: "extension-\($0)/manifest.json").path)
473
557
  }
474
- self.updateCodexRunningStatus()
558
+ self.updateEndpointRunningStatus()
475
559
  }
476
560
  }
477
561
 
@@ -500,7 +584,7 @@ final class SyncModel: ObservableObject {
500
584
  }
501
585
  }
502
586
 
503
- 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) {
504
588
  guard let config = loadConfig() else {
505
589
  completion(false, "Configuration missing. Run install-app again.")
506
590
  return
@@ -509,6 +593,7 @@ final class SyncModel: ObservableObject {
509
593
  let output = Pipe()
510
594
  process.executableURL = URL(fileURLWithPath: config.nodePath)
511
595
  process.arguments = [runtimeCLI.path] + arguments
596
+ process.environment = ProcessInfo.processInfo.environment.merging(environment) { _, new in new }
512
597
  process.standardOutput = output
513
598
  process.standardError = output
514
599
  process.terminationHandler = { process in
@@ -551,23 +636,44 @@ final class SyncModel: ObservableObject {
551
636
  }
552
637
 
553
638
  private var requiredExtensionIDs: [String] {
554
- selectedTargetID == "codex" ? [] : [selectedSourceID, selectedTargetID]
639
+ selectedTargetID == "codex" || selectedTargetID == "browserless" ? [] : [selectedSourceID, selectedTargetID]
555
640
  }
556
641
 
557
- private func updateCodexRunningStatus() {
642
+ private func updateEndpointRunningStatus() {
558
643
  let wasRunning = codexRunning
559
644
  codexRunning = NSWorkspace.shared.runningApplications.contains {
560
645
  $0.bundleIdentifier == "com.openai.codex"
561
646
  }
562
- guard selectedTargetID == "codex", !isSyncing else { return }
563
- if codexRunning {
647
+ sourceBrowserRunning = NSWorkspace.shared.runningApplications.contains {
648
+ $0.bundleIdentifier == selectedBrowser.bundleIdentifier
649
+ }
650
+ guard !isSyncing else { return }
651
+ if selectedTargetID == "codex" && codexRunning {
564
652
  state = .warning
565
653
  primaryStatus = "Quit Codex before syncing"
566
654
  secondaryStatus = "Close ChatGPT Codex completely so its local cookie database can be updated safely"
567
- } else if wasRunning && primaryStatus == "Quit Codex before syncing" {
655
+ } else if selectedTargetID == "codex" && wasRunning && primaryStatus == "Quit Codex before syncing" {
568
656
  state = .ready
569
657
  primaryStatus = "Ready to sync directly"
570
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
+ }
571
677
  }
572
678
  }
573
679
 
@@ -647,6 +753,7 @@ private struct AppConfig: Decodable {
647
753
  let targetBrowser: String?
648
754
  let imports: Imports?
649
755
  let ui: UISettings?
756
+ let browserless: BrowserlessSettings?
650
757
 
651
758
  struct Schedule: Decodable {
652
759
  let hour: Int
@@ -663,4 +770,60 @@ private struct AppConfig: Decodable {
663
770
  let openAtLogin: Bool?
664
771
  let autoCheckUpdates: Bool?
665
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
+ }
666
829
  }
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "browser-cookie-bridge",
3
- "version": "1.1.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 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-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",
@@ -16,7 +16,7 @@
16
16
  "web:deploy": "wrangler deploy --config web/wrangler.jsonc"
17
17
  },
18
18
  "engines": {
19
- "node": ">=22.5"
19
+ "node": ">=24"
20
20
  },
21
21
  "license": "MIT",
22
22
  "repository": {
@@ -38,6 +38,7 @@
38
38
  "macos",
39
39
  "chromium",
40
40
  "brave",
41
+ "browserless",
41
42
  "codex"
42
43
  ],
43
44
  "funding": "https://ko-fi.com/apoorvdarshan",
@@ -50,6 +51,10 @@
50
51
  "macos-app/Resources",
51
52
  "macos-app/Sources",
52
53
  "README.md",
53
- "LICENSE"
54
- ]
54
+ "LICENSE",
55
+ "THIRD_PARTY_NOTICES.md"
56
+ ],
57
+ "dependencies": {
58
+ "@browserless.io/cli": "0.3.0"
59
+ }
55
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
+ }
package/src/cli.js CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  isCodexRunning,
10
10
  } from "./codex-direct-import.js";
11
11
  import { readChromiumProfile } from "./chromium-reader.js";
12
+ import { uploadBrowserlessProfile } from "./browserless.js";
12
13
  import { installConfig, installRuntime, readConfig, updatePreferences } from "./config.js";
13
14
  import {
14
15
  braveCookiePaths,
@@ -44,7 +45,7 @@ Commands:
44
45
  install-app [--no-open]
45
46
  bootstrap-bundled --app-path /Applications/Browser Cookie Bridge.app
46
47
  preferences --source brave --target codex --cookies on --history off --menu-bar on --auto-check-updates on
47
- sync [--timeout 300]
48
+ sync [--timeout 300] [--allow-cloud-upload]
48
49
  doctor
49
50
  enable-login-sync
50
51
  disable-login-sync
@@ -123,6 +124,9 @@ function preferences(args) {
123
124
  menuBar: booleanFlag(args, "--menu-bar", existing.ui?.menuBar === true),
124
125
  openAtLogin: booleanFlag(args, "--open-at-login", existing.ui?.openAtLogin !== false),
125
126
  autoCheckUpdates: booleanFlag(args, "--auto-check-updates", existing.ui?.autoCheckUpdates !== false),
127
+ browserlessProfileName: stringFlag(args, "--browserless-profile", existing.browserless?.profileName || "browser-cookie-bridge"),
128
+ browserlessRegion: stringFlag(args, "--browserless-region", existing.browserless?.region || "sfo"),
129
+ browserlessOnlyDomains: optionalStringFlag(args, "--browserless-domains", (existing.browserless?.onlyDomains || []).join(",")),
126
130
  });
127
131
  console.log(
128
132
  `Saved: source=${config.sourceBrowser}, target=${config.targetBrowser}, cookies=${config.imports.cookies ? "on" : "off"}, history=${config.imports.history ? "on" : "off"}, menu-bar=${config.ui.menuBar ? "on" : "off"}, open-at-login=${config.ui.openAtLogin ? "on" : "off"}, auto-check-updates=${config.ui.autoCheckUpdates ? "on" : "off"}`,
@@ -239,6 +243,10 @@ function setup(args) {
239
243
  if (config.targetBrowser === "codex") {
240
244
  console.log(`Source browser: ${config.sourceBrowser} (read locally; no extension required)`);
241
245
  console.log("Target integration: direct local Codex merge (Codex must be closed)");
246
+ } else if (config.targetBrowser === "browserless") {
247
+ console.log(`Source browser: ${config.sourceBrowser} (captured locally by the Browserless CLI)`);
248
+ console.log(`Cloud destination: Browserless ${config.browserless?.region || "sfo"} / ${config.browserless?.profileName || "browser-cookie-bridge"}`);
249
+ console.log("Browserless uploads are manual-only and require --allow-cloud-upload.");
242
250
  } else {
243
251
  console.log(`Source extension (${config.sourceBrowser}): ${installedExtensionDir(undefined, config.sourceBrowser)}`);
244
252
  console.log(`Target extension (${config.targetBrowser}): ${installedExtensionDir(undefined, config.targetBrowser)}`);
@@ -255,7 +263,25 @@ async function sync(args) {
255
263
  assertMacOS();
256
264
  const seconds = integerFlag(args, "--timeout", 300, 5, 3600);
257
265
  const config = readConfig();
258
- const isCodexTarget = (config.targetBrowser || "codex") === "codex";
266
+ const target = config.targetBrowser || "codex";
267
+ const isCodexTarget = target === "codex";
268
+ if (target === "browserless") {
269
+ if (!args.includes("--allow-cloud-upload")) {
270
+ throw new Error("Browserless cloud uploads are manual-only. Start one from the app or pass --allow-cloud-upload explicitly.");
271
+ }
272
+ const source = config.sourceBrowser || "brave";
273
+ const local = readChromiumProfile({ browser: source, imports: { cookies: false, history: false } });
274
+ console.log(`Preparing ${source} profile ${local.profileName} for an explicit Browserless cloud upload…`);
275
+ const result = await uploadBrowserlessProfile({
276
+ browser: source,
277
+ localProfile: local.profileName,
278
+ profileName: config.browserless?.profileName || "browser-cookie-bridge",
279
+ region: config.browserless?.region || "sfo",
280
+ onlyDomains: config.browserless?.onlyDomains || [],
281
+ });
282
+ console.log(result.summary);
283
+ return result;
284
+ }
259
285
  if (isCodexTarget) {
260
286
  if (isCodexRunning()) throw new Error(CODEX_RUNNING_ERROR);
261
287
  const payload = readChromiumProfile({
@@ -331,6 +357,8 @@ function doctor() {
331
357
  console.log(
332
358
  target === "codex"
333
359
  ? "Target integration: direct local Codex merge (Codex must be closed)"
360
+ : target === "browserless"
361
+ ? `Target integration: optional Browserless cloud upload (${config?.browserless?.region || "sfo"}); manual only`
334
362
  : `Target extension: ${status(installedExtensionDir(home, target))}`,
335
363
  );
336
364
  console.log(`Daily schedule: ${status(launchAgentPath(home))}`);
@@ -355,6 +383,8 @@ function enableLoginSync() {
355
383
  const config = readConfig();
356
384
  console.log(config.targetBrowser === "codex"
357
385
  ? "A sync starts when you sign in and updates Codex only when Codex is closed."
386
+ : config.targetBrowser === "browserless"
387
+ ? "Browserless uploads remain manual-only; login sync will not send data to the cloud."
358
388
  : "A sync starts when you sign in and waits up to five minutes for both browser extensions.");
359
389
  }
360
390
 
@@ -373,6 +403,9 @@ function setAppLogin(enabled) {
373
403
  menuBar: existing.ui?.menuBar === true,
374
404
  openAtLogin: enabled,
375
405
  autoCheckUpdates: existing.ui?.autoCheckUpdates !== false,
406
+ browserlessProfileName: existing.browserless?.profileName,
407
+ browserlessRegion: existing.browserless?.region,
408
+ browserlessOnlyDomains: existing.browserless?.onlyDomains,
376
409
  });
377
410
  if (enabled) {
378
411
  const appPath = installedAppPath();
@@ -422,6 +455,11 @@ function stringFlag(args, name, fallback) {
422
455
  return value;
423
456
  }
424
457
 
458
+ function optionalStringFlag(args, name, fallback) {
459
+ const index = args.indexOf(name);
460
+ return index === -1 ? fallback : (args[index + 1] ?? "");
461
+ }
462
+
425
463
  function existing(paths) {
426
464
  return paths.filter((candidate) => fs.existsSync(candidate));
427
465
  }
package/src/config.js CHANGED
@@ -35,7 +35,7 @@ export function installConfig({ home, hour = 9, minute = 0, nodePath = process.e
35
35
  const sourceBrowser = SOURCE_BROWSERS.includes(existing.sourceBrowser) ? existing.sourceBrowser : "brave";
36
36
  const configuredTarget = TARGET_BROWSERS.includes(existing.targetBrowser) ? existing.targetBrowser : "codex";
37
37
  const config = {
38
- version: 1,
38
+ version: 2,
39
39
  token: existing.token || crypto.randomBytes(32).toString("base64url"),
40
40
  port: existing.port || DEFAULT_PORT,
41
41
  nodePath,
@@ -51,6 +51,11 @@ export function installConfig({ home, hour = 9, minute = 0, nodePath = process.e
51
51
  openAtLogin: existing.ui?.openAtLogin !== false,
52
52
  autoCheckUpdates: existing.ui?.autoCheckUpdates !== false,
53
53
  },
54
+ browserless: {
55
+ profileName: cleanProfileName(existing.browserless?.profileName) || "browser-cookie-bridge",
56
+ region: ["sfo", "lon", "ams"].includes(existing.browserless?.region) ? existing.browserless.region : "sfo",
57
+ onlyDomains: cleanDomains(existing.browserless?.onlyDomains),
58
+ },
54
59
  schedule: { hour, minute },
55
60
  createdAt: existing.createdAt || new Date().toISOString(),
56
61
  updatedAt: new Date().toISOString(),
@@ -63,7 +68,19 @@ export function installConfig({ home, hour = 9, minute = 0, nodePath = process.e
63
68
  return config;
64
69
  }
65
70
 
66
- export function updatePreferences({ home, cookies, history, sourceBrowser, targetBrowser, menuBar, openAtLogin, autoCheckUpdates }) {
71
+ export function updatePreferences({
72
+ home,
73
+ cookies,
74
+ history,
75
+ sourceBrowser,
76
+ targetBrowser,
77
+ menuBar,
78
+ openAtLogin,
79
+ autoCheckUpdates,
80
+ browserlessProfileName,
81
+ browserlessRegion,
82
+ browserlessOnlyDomains,
83
+ }) {
67
84
  const config = readConfig(home);
68
85
  if (!SOURCE_BROWSERS.includes(sourceBrowser)) {
69
86
  throw new Error(`Unsupported source browser: ${sourceBrowser}`);
@@ -82,6 +99,13 @@ export function updatePreferences({ home, cookies, history, sourceBrowser, targe
82
99
  openAtLogin: Boolean(openAtLogin),
83
100
  autoCheckUpdates: Boolean(autoCheckUpdates),
84
101
  };
102
+ const region = browserlessRegion ?? config.browserless?.region ?? "sfo";
103
+ if (!["sfo", "lon", "ams"].includes(region)) throw new Error("Browserless region must be sfo, lon, or ams");
104
+ config.browserless = {
105
+ profileName: cleanProfileName(browserlessProfileName ?? config.browserless?.profileName) || "browser-cookie-bridge",
106
+ region,
107
+ onlyDomains: cleanDomains(browserlessOnlyDomains ?? config.browserless?.onlyDomains),
108
+ };
85
109
  config.updatedAt = new Date().toISOString();
86
110
  writePrivateJson(configPath(home), config);
87
111
  return config;
@@ -92,10 +116,11 @@ export function installRuntime(home, source = projectRoot()) {
92
116
  if (path.resolve(source) === path.resolve(target)) return target;
93
117
 
94
118
  fs.mkdirSync(target, { recursive: true, mode: 0o700 });
95
- for (const name of ["bin", "src", "extension-template"]) {
119
+ for (const name of ["bin", "src", "extension-template", "node_modules"]) {
96
120
  fs.rmSync(path.join(target, name), { recursive: true, force: true });
97
121
  fs.cpSync(path.join(source, name), path.join(target, name), { recursive: true, force: true });
98
122
  }
123
+ fs.rmSync(path.join(target, "node_modules", ".bin"), { recursive: true, force: true });
99
124
  const appSource = path.join(source, "macos-app");
100
125
  const appTarget = path.join(target, "macos-app");
101
126
  fs.rmSync(appTarget, { recursive: true, force: true });
@@ -108,13 +133,22 @@ export function installRuntime(home, source = projectRoot()) {
108
133
  fs.copyFileSync(path.join(appSource, name), path.join(appTarget, name));
109
134
  }
110
135
  }
111
- for (const name of ["package.json", "README.md", "LICENSE"]) {
136
+ for (const name of ["package.json", "README.md", "LICENSE", "THIRD_PARTY_NOTICES.md"]) {
112
137
  fs.copyFileSync(path.join(source, name), path.join(target, name));
113
138
  }
114
139
  fs.chmodSync(path.join(target, "bin", "brave-codex-cookie-sync.js"), 0o700);
115
140
  return target;
116
141
  }
117
142
 
143
+ function cleanProfileName(value) {
144
+ return typeof value === "string" ? value.trim().slice(0, 100) : "";
145
+ }
146
+
147
+ function cleanDomains(value) {
148
+ const items = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
149
+ return [...new Set(items.map((item) => String(item).trim().toLowerCase()).filter(Boolean))].slice(0, 50);
150
+ }
151
+
118
152
  function installExtension(config, home, browser, role) {
119
153
  const source = path.join(projectRoot(), "extension-template");
120
154
  const target = installedExtensionDir(home, browser);
package/src/paths.js CHANGED
@@ -10,7 +10,7 @@ export const DEFAULT_PORT = 43128;
10
10
  export const EXTENSION_ID = "ihanfnkcipmlhmokbcinlkdfcfheofjb";
11
11
  export const EXTENSION_ORIGIN = `chrome-extension://${EXTENSION_ID}`;
12
12
  export const SOURCE_BROWSERS = ["brave", "chrome", "edge", "arc", "vivaldi", "opera", "comet"];
13
- export const TARGET_BROWSERS = [...SOURCE_BROWSERS, "codex"];
13
+ export const TARGET_BROWSERS = [...SOURCE_BROWSERS, "codex", "browserless"];
14
14
 
15
15
  export function projectRoot() {
16
16
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");