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 +30 -13
- package/THIRD_PARTY_NOTICES.md +11 -0
- package/extension-template/manifest.json +1 -1
- package/macos-app/Info.plist +2 -2
- package/macos-app/Resources/BrowserIcons/browserless.svg +6 -0
- package/macos-app/Sources/BraveCodexSyncApp/BraveCodexSyncApp.swift +214 -43
- package/macos-app/Sources/BraveCodexSyncApp/SyncModel.swift +189 -26
- package/package.json +11 -6
- package/src/browserless-runner.js +13 -0
- package/src/browserless.js +108 -0
- package/src/cli.js +40 -2
- package/src/config.js +38 -4
- package/src/paths.js +1 -1
package/README.md
CHANGED
|
@@ -4,16 +4,16 @@
|
|
|
4
4
|
|
|
5
5
|
<h1>Browser Cookie Bridge</h1>
|
|
6
6
|
|
|
7
|
-
<strong>Move
|
|
7
|
+
<strong>Move signed-in sessions locally—or explicitly upload one to Browserless Cloud.</strong>
|
|
8
8
|
|
|
9
|
-
<p>Brave, Chrome, Edge, Arc, Vivaldi, Opera, and Comet ·
|
|
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-
|
|
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-
|
|
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.
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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"],
|
package/macos-app/Info.plist
CHANGED
|
@@ -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.
|
|
14
|
-
<key>CFBundleVersion</key><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(
|
|
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 {
|
|
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:
|
|
302
|
-
Text(
|
|
313
|
+
else { Image(systemName: syncButtonIcon) }
|
|
314
|
+
Text(syncButtonTitle)
|
|
303
315
|
}
|
|
304
|
-
.frame(minWidth: model.
|
|
316
|
+
.frame(minWidth: model.syncBlocked ? 116 : 92)
|
|
305
317
|
}
|
|
306
318
|
.buttonStyle(.borderedProminent)
|
|
307
319
|
.tint(Theme.accent)
|
|
308
|
-
.disabled(model.isSyncing || model.
|
|
320
|
+
.disabled(model.isSyncing || (model.syncBlocked && !(model.isBrowserlessTarget && !model.browserlessConfigured)))
|
|
309
321
|
.keyboardShortcut(.return, modifiers: .command)
|
|
310
322
|
}
|
|
311
|
-
.padding(model.
|
|
323
|
+
.padding(model.syncBlocked ? 7 : 0)
|
|
312
324
|
.background(
|
|
313
|
-
model.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
489
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
Task { @MainActor in self?.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
343
|
-
guard !
|
|
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
|
-
:
|
|
355
|
-
|
|
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.
|
|
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.
|
|
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 =
|
|
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.
|
|
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
|
|
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
|
-
|
|
563
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
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": ">=
|
|
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
|
|
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:
|
|
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({
|
|
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)), "..");
|