browser-cookie-bridge 1.4.1 → 1.4.3
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 +26 -6
- package/extension-template/manifest.json +1 -1
- package/macos-app/Info.plist +2 -2
- package/macos-app/Sources/BraveCodexSyncApp/BraveCodexSyncApp.swift +32 -8
- package/macos-app/Sources/BraveCodexSyncApp/SyncModel.swift +147 -12
- package/package.json +2 -2
- package/src/app-installer.js +16 -3
- package/src/chromium-reader.js +29 -4
- package/src/cli.js +29 -11
- package/src/codex-direct-import.js +100 -0
- package/src/config.js +11 -1
- package/src/paths.js +14 -1
package/README.md
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
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
14
|
<img src="https://img.shields.io/badge/Node.js-24%2B-3C873A?logo=nodedotjs&logoColor=white" alt="Node.js 24+" />
|
|
15
|
+
<img src="https://img.shields.io/badge/Homebrew-Cask-FBB040?logo=homebrew&logoColor=black" alt="Homebrew Cask" />
|
|
15
16
|
<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
17
|
<img src="https://img.shields.io/badge/local--first-cloud%20optional-C68B3C" alt="Local-first, cloud optional" />
|
|
17
18
|
<img src="https://img.shields.io/github/stars/apoorvdarshan/browser-cookie-bridge?logo=github&color=C68B3C" alt="GitHub stars" />
|
|
@@ -22,6 +23,7 @@
|
|
|
22
23
|
|
|
23
24
|
<p>
|
|
24
25
|
<a href="#installation"><b>Install</b></a> ·
|
|
26
|
+
<a href="https://github.com/apoorvdarshan/homebrew-tap">Homebrew</a> ·
|
|
25
27
|
<a href="https://github.com/apoorvdarshan/browser-cookie-bridge/releases/latest">DMG</a> ·
|
|
26
28
|
<a href="https://www.npmjs.com/package/browser-cookie-bridge">npm</a> ·
|
|
27
29
|
<a href="#how-it-works">How it works</a> ·
|
|
@@ -61,9 +63,11 @@ Browser Cookie Bridge currently ships for macOS. A separate Windows app is being
|
|
|
61
63
|
## Features
|
|
62
64
|
|
|
63
65
|
- 🍪 **Cookie and session transfer** — cookies are enabled by default, including supported domain, path, expiry, security, `SameSite`, and partition attributes.
|
|
66
|
+
- 🗃️ **Optional full site data for Codex** — replace Codex's compatible Local Storage, IndexedDB, Session Storage, service-worker, and related origin stores from a closed source profile, with backup and rollback. Off by default.
|
|
64
67
|
- 🌐 **Seven Chromium browsers** — Brave, Chrome, Edge, Arc, Vivaldi, Opera, and Perplexity Comet can be sources or destinations.
|
|
65
68
|
- ✨ **ChatGPT Codex import** — merge selected local browser data into Codex's built-in browser; Codex is destination-only.
|
|
66
69
|
- 🔁 **Optional Codex restart** — manual Sync can force quit a running Codex instance and reopen it only after a successful local transfer; off by default.
|
|
70
|
+
- 🔄 **Optional two-app restart** — Full site data can force quit the selected source browser and Codex, then reopen only the apps that were running after a successful transfer; off by default.
|
|
67
71
|
- ☁️ **Optional Browserless upload** — create or refresh a Browserless authenticated profile with cookies, local storage, and IndexedDB; see a local size preflight, live progress, cancellation, and post-upload verification.
|
|
68
72
|
- 🕘 **Background automation** — sync when you sign in, at a fixed daily time, or whenever you choose.
|
|
69
73
|
- ◉ **Native menu-bar app** — closing the window removes the Dock icon while the helper continues running.
|
|
@@ -76,8 +80,9 @@ Browser Cookie Bridge currently ships for macOS. A separate Windows app is being
|
|
|
76
80
|
| Data | Support | Notes |
|
|
77
81
|
|---|---:|---|
|
|
78
82
|
| **Cookies and sessions** | ✅ Default | Transfers supported cookie values and attributes |
|
|
83
|
+
| **Full site data → Codex** | Optional | Replaces compatible origin storage from a closed Chromium profile; backs up Codex first |
|
|
79
84
|
| **History URLs** | ◐ Optional | Original visit times and page titles cannot be preserved |
|
|
80
|
-
| **Local storage and IndexedDB** | ◐
|
|
85
|
+
| **Local storage and IndexedDB** | ◐ Optional | Full site data into Codex, or an explicit Browserless authenticated-profile upload |
|
|
81
86
|
| **Passwords** | — Never | Chromium extensions cannot read the browser password store |
|
|
82
87
|
| **Bookmarks, autofill, payments** | — Never | Not requested or accessed |
|
|
83
88
|
| **iCloud Keychain** | — Never | Remains completely separate |
|
|
@@ -93,6 +98,15 @@ The DMG is self-contained. Node.js 24+ and Xcode Command Line Tools are required
|
|
|
93
98
|
|
|
94
99
|
## Installation
|
|
95
100
|
|
|
101
|
+
### Via Homebrew — recommended
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
brew install --cask apoorvdarshan/tap/browser-cookie-bridge
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Homebrew selects the correct Apple-silicon or Intel DMG, verifies its SHA-256
|
|
108
|
+
checksum, and installs the signed and notarized app into `/Applications`.
|
|
109
|
+
|
|
96
110
|
### Download a DMG — recommended
|
|
97
111
|
|
|
98
112
|
- **[Apple silicon DMG](https://github.com/apoorvdarshan/browser-cookie-bridge/releases/latest/download/Browser-Cookie-Bridge-arm64.dmg)** — M1, M2, M3, M4, and newer
|
|
@@ -108,9 +122,13 @@ Run the published package directly:
|
|
|
108
122
|
npx browser-cookie-bridge install-app
|
|
109
123
|
```
|
|
110
124
|
|
|
111
|
-
No administrator password is needed. The app is built from source on your Mac and installed in your user Applications folder
|
|
125
|
+
No administrator password is needed. The app is built from source on your Mac and installed in your user Applications folder. If a signed `/Applications/Browser Cookie Bridge.app` already exists, the CLI preserves it instead of replacing its Developer ID signature with an ad-hoc local build; use the app's built-in updater for signed releases.
|
|
112
126
|
|
|
113
|
-
|
|
127
|
+
All installation methods use the same bundle identifier and settings under
|
|
128
|
+
`~/Library/Application Support/BraveCodexCookieSync`. Homebrew and the DMG
|
|
129
|
+
install the canonical system Applications copy. Installing either after npm
|
|
130
|
+
does not create a separate product identity: a stale matching user Applications
|
|
131
|
+
copy is moved to Trash on launch, and future updates replace the same app.
|
|
114
132
|
|
|
115
133
|
**[View `browser-cookie-bridge` on npm →](https://www.npmjs.com/package/browser-cookie-bridge)**
|
|
116
134
|
|
|
@@ -123,7 +141,7 @@ npm test
|
|
|
123
141
|
npm run build:app
|
|
124
142
|
```
|
|
125
143
|
|
|
126
|
-
The final command compiles the native SwiftUI app, enables **Open at login**, **Sync at login**, and the **menu-bar helper**, then launches it. A first install uses `~/Applications/Browser Cookie Bridge.app` without requesting administrator access. If an existing `/Applications/Browser Cookie Bridge.app` is present,
|
|
144
|
+
The final command compiles the native SwiftUI app, enables **Open at login**, **Sync at login**, and the **menu-bar helper**, then launches it. A first install uses `~/Applications/Browser Cookie Bridge.app` without requesting administrator access. If an existing signed `/Applications/Browser Cookie Bridge.app` is present, the CLI keeps that system copy canonical and leaves app updates to the signed-DMG updater. Developers who intentionally need to replace it with a local ad-hoc build can pass `--replace-system-from-source`. Daily sync stays off until you enable it.
|
|
127
145
|
|
|
128
146
|
## Setup
|
|
129
147
|
|
|
@@ -186,9 +204,9 @@ Automation uses the saved source, destination, and data choices for local transf
|
|
|
186
204
|
## CLI
|
|
187
205
|
|
|
188
206
|
```bash
|
|
189
|
-
browser-cookie-bridge install-app [--no-open]
|
|
207
|
+
browser-cookie-bridge install-app [--no-open] [--replace-system-from-source]
|
|
190
208
|
browser-cookie-bridge setup [--hour 9] [--minute 0] [--no-schedule]
|
|
191
|
-
browser-cookie-bridge preferences --source brave --target codex --cookies on --history off --auto-restart-codex off
|
|
209
|
+
browser-cookie-bridge preferences --source brave --target codex --cookies on --history off --site-storage off --auto-restart-codex off --auto-restart-both off
|
|
192
210
|
browser-cookie-bridge sync [--timeout 300] [--allow-cloud-upload]
|
|
193
211
|
browser-cookie-bridge browserless-preflight
|
|
194
212
|
browser-cookie-bridge doctor
|
|
@@ -216,6 +234,7 @@ The broker validates a random token and extension origin, limits payload size, a
|
|
|
216
234
|
- Cookie values and history URLs are never logged.
|
|
217
235
|
- Browser-to-browser data is held only in broker memory.
|
|
218
236
|
- Codex backups are stored with user-only permissions under `~/Library/Application Support/BraveCodexCookieSync/backups/codex`; the newest 14 are retained.
|
|
237
|
+
- Full site-data import is opt-in and requires both Codex and the source browser to be closed. It replaces compatible Codex origin-storage directories rather than attempting an unsafe LevelDB merge; the previous directories are included in the same backup.
|
|
219
238
|
- The app refuses unknown Codex database schemas instead of guessing.
|
|
220
239
|
- 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.
|
|
221
240
|
- Anyone who can use your logged-in macOS account or modify a generated extension may be able to access transferred browser sessions.
|
|
@@ -247,6 +266,7 @@ If Browser Cookie Bridge is useful to you:
|
|
|
247
266
|
|
|
248
267
|
- ⭐ **Star** the repository
|
|
249
268
|
- ⬇️ **[Download the latest DMG](https://github.com/apoorvdarshan/browser-cookie-bridge/releases/latest)**
|
|
269
|
+
- 🍺 **[Install from Homebrew](https://github.com/apoorvdarshan/homebrew-tap)**
|
|
250
270
|
- 📦 **[Install from npm](https://www.npmjs.com/package/browser-cookie-bridge)**
|
|
251
271
|
- 🐛 **[Report a bug](https://github.com/apoorvdarshan/browser-cookie-bridge/issues/new?template=bug_report.yml)** — never include cookie values or tokens
|
|
252
272
|
- ☕ **[Support on Ko-fi](https://ko-fi.com/apoorvdarshan)**
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Browser Cookie Bridge",
|
|
4
|
-
"version": "1.4.
|
|
4
|
+
"version": "1.4.3",
|
|
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.4.
|
|
14
|
-
<key>CFBundleVersion</key><string>
|
|
13
|
+
<key>CFBundleShortVersionString</key><string>1.4.3</string>
|
|
14
|
+
<key>CFBundleVersion</key><string>31</string>
|
|
15
15
|
<key>LSMinimumSystemVersion</key><string>13.5</string>
|
|
16
16
|
<key>NSHighResolutionCapable</key><true/>
|
|
17
17
|
</dict>
|
|
@@ -569,6 +569,18 @@ struct PreferencesPanel: View {
|
|
|
569
569
|
Toggle("", isOn: Binding(get: { model.cookiesEnabled }, set: { model.setCookiesEnabled($0) }))
|
|
570
570
|
.labelsHidden().toggleStyle(.switch).tint(Theme.active).disabled(model.isWorking)
|
|
571
571
|
}
|
|
572
|
+
if model.selectedTargetID == "codex" {
|
|
573
|
+
RowDivider()
|
|
574
|
+
PreferenceRow(
|
|
575
|
+
icon: "externaldrive.badge.plus",
|
|
576
|
+
color: Theme.accent,
|
|
577
|
+
title: "Full site data",
|
|
578
|
+
detail: "Local Storage, IndexedDB, sessions, and service workers · both apps must be closed"
|
|
579
|
+
) {
|
|
580
|
+
Toggle("", isOn: Binding(get: { model.siteStorageEnabled }, set: { model.setSiteStorageEnabled($0) }))
|
|
581
|
+
.labelsHidden().toggleStyle(.switch).tint(Theme.active).disabled(model.isWorking || model.isSyncing)
|
|
582
|
+
}
|
|
583
|
+
}
|
|
572
584
|
RowDivider()
|
|
573
585
|
PreferenceRow(icon: "clock.arrow.circlepath", color: Theme.accent, title: "History URLs", detail: "Original visit times are not preserved") {
|
|
574
586
|
Toggle("", isOn: Binding(get: { model.historyEnabled }, set: { model.setHistoryEnabled($0) }))
|
|
@@ -580,14 +592,26 @@ struct PreferencesPanel: View {
|
|
|
580
592
|
}
|
|
581
593
|
if model.selectedTargetID == "codex" {
|
|
582
594
|
RowDivider()
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
595
|
+
if model.siteStorageEnabled {
|
|
596
|
+
PreferenceRow(
|
|
597
|
+
icon: "arrow.triangle.2.circlepath",
|
|
598
|
+
color: Theme.accent,
|
|
599
|
+
title: "Restart both automatically",
|
|
600
|
+
detail: "Manual sync force quits both; reopens running apps only after success"
|
|
601
|
+
) {
|
|
602
|
+
Toggle("", isOn: Binding(get: { model.autoRestartBoth }, set: { model.setAutoRestartBoth($0) }))
|
|
603
|
+
.labelsHidden().toggleStyle(.switch).tint(Theme.active).disabled(model.isWorking || model.isSyncing)
|
|
604
|
+
}
|
|
605
|
+
} else {
|
|
606
|
+
PreferenceRow(
|
|
607
|
+
icon: "arrow.clockwise.circle",
|
|
608
|
+
color: Theme.accent,
|
|
609
|
+
title: "Restart Codex automatically",
|
|
610
|
+
detail: "Force quit before manual sync; reopen only after success"
|
|
611
|
+
) {
|
|
612
|
+
Toggle("", isOn: Binding(get: { model.autoRestartCodex }, set: { model.setAutoRestartCodex($0) }))
|
|
613
|
+
.labelsHidden().toggleStyle(.switch).tint(Theme.active).disabled(model.isWorking || model.isSyncing)
|
|
614
|
+
}
|
|
591
615
|
}
|
|
592
616
|
}
|
|
593
617
|
}
|
|
@@ -78,6 +78,7 @@ final class SyncModel: ObservableObject {
|
|
|
78
78
|
@Published var menuBarEnabled = true
|
|
79
79
|
@Published var autoCheckUpdates = true
|
|
80
80
|
@Published var autoRestartCodex = false
|
|
81
|
+
@Published var autoRestartBoth = false
|
|
81
82
|
@Published var isCheckingForUpdates = false
|
|
82
83
|
@Published var isInstallingUpdate = false
|
|
83
84
|
@Published var availableUpdateVersion: String?
|
|
@@ -85,6 +86,7 @@ final class SyncModel: ObservableObject {
|
|
|
85
86
|
@Published var extensionsReady = false
|
|
86
87
|
@Published var cookiesEnabled = true
|
|
87
88
|
@Published var historyEnabled = false
|
|
89
|
+
@Published var siteStorageEnabled = false
|
|
88
90
|
@Published var selectedSourceID = "brave"
|
|
89
91
|
@Published var selectedTargetID = "codex"
|
|
90
92
|
@Published var codexRunning = false
|
|
@@ -129,11 +131,16 @@ final class SyncModel: ObservableObject {
|
|
|
129
131
|
var targetName: String {
|
|
130
132
|
isBrowserlessTarget ? "Browserless Cloud" : selectedTargetBrowser?.name ?? "ChatGPT Codex"
|
|
131
133
|
}
|
|
132
|
-
var codexBlocked: Bool {
|
|
134
|
+
var codexBlocked: Bool {
|
|
135
|
+
selectedTargetID == "codex" && codexRunning && !autoRestartCodex && !(siteStorageEnabled && autoRestartBoth)
|
|
136
|
+
}
|
|
137
|
+
var sourceSiteDataBlocked: Bool {
|
|
138
|
+
selectedTargetID == "codex" && siteStorageEnabled && sourceBrowserRunning && !autoRestartBoth
|
|
139
|
+
}
|
|
133
140
|
var browserlessBlocked: Bool {
|
|
134
141
|
isBrowserlessTarget && (!browserlessConfigured || sourceBrowserRunning || selectedSourceID == "comet")
|
|
135
142
|
}
|
|
136
|
-
var syncBlocked: Bool { codexBlocked || browserlessBlocked }
|
|
143
|
+
var syncBlocked: Bool { codexBlocked || sourceSiteDataBlocked || browserlessBlocked }
|
|
137
144
|
var formattedUploadElapsed: String {
|
|
138
145
|
let minutes = uploadElapsedSeconds / 60
|
|
139
146
|
let seconds = uploadElapsedSeconds % 60
|
|
@@ -260,9 +267,11 @@ final class SyncModel: ObservableObject {
|
|
|
260
267
|
if selectedTargetID == selectedSourceID { selectedTargetID = "codex" }
|
|
261
268
|
cookiesEnabled = config.imports?.cookies ?? true
|
|
262
269
|
historyEnabled = config.imports?.history ?? false
|
|
270
|
+
siteStorageEnabled = config.imports?.siteStorage ?? false
|
|
263
271
|
menuBarEnabled = config.ui?.menuBar ?? true
|
|
264
272
|
autoCheckUpdates = config.ui?.autoCheckUpdates ?? true
|
|
265
273
|
autoRestartCodex = config.ui?.autoRestartCodex ?? false
|
|
274
|
+
autoRestartBoth = config.ui?.autoRestartBoth ?? false
|
|
266
275
|
browserlessProfileName = config.browserless?.profileName ?? "browser-cookie-bridge"
|
|
267
276
|
browserlessRegion = config.browserless?.region ?? "sfo"
|
|
268
277
|
browserlessOnlyDomains = (config.browserless?.onlyDomains ?? []).joined(separator: ", ")
|
|
@@ -309,6 +318,11 @@ final class SyncModel: ObservableObject {
|
|
|
309
318
|
persistPreferences(successMessage: enabled ? "History URL import enabled" : "History import disabled")
|
|
310
319
|
}
|
|
311
320
|
|
|
321
|
+
func setSiteStorageEnabled(_ enabled: Bool) {
|
|
322
|
+
siteStorageEnabled = enabled
|
|
323
|
+
persistPreferences(successMessage: enabled ? "Full site-data import enabled" : "Full site-data import disabled")
|
|
324
|
+
}
|
|
325
|
+
|
|
312
326
|
func saveBrowserlessSettings(token: String, profileName: String, region: String, onlyDomains: String) {
|
|
313
327
|
let cleanedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
314
328
|
let cleanedName = profileName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
@@ -354,6 +368,11 @@ final class SyncModel: ObservableObject {
|
|
|
354
368
|
persistPreferences(successMessage: enabled ? "Automatic Codex restart enabled" : "Automatic Codex restart disabled")
|
|
355
369
|
}
|
|
356
370
|
|
|
371
|
+
func setAutoRestartBoth(_ enabled: Bool) {
|
|
372
|
+
autoRestartBoth = enabled
|
|
373
|
+
persistPreferences(successMessage: enabled ? "Automatic source and Codex restart enabled" : "Automatic source and Codex restart disabled")
|
|
374
|
+
}
|
|
375
|
+
|
|
357
376
|
func checkForUpdates(showAlert: Bool = false) {
|
|
358
377
|
guard !isCheckingForUpdates, !isInstallingUpdate else { return }
|
|
359
378
|
guard let url = URL(string: "https://api.github.com/repos/apoorvdarshan/browser-cookie-bridge/releases/latest") else { return }
|
|
@@ -456,6 +475,10 @@ final class SyncModel: ObservableObject {
|
|
|
456
475
|
}
|
|
457
476
|
return
|
|
458
477
|
}
|
|
478
|
+
if selectedTargetID == "codex" && siteStorageEnabled && autoRestartBoth && (sourceBrowserRunning || codexRunning) {
|
|
479
|
+
forceQuitBothThenSync(showMenuBarAlert: showMenuBarAlert)
|
|
480
|
+
return
|
|
481
|
+
}
|
|
459
482
|
if selectedTargetID == "codex" && codexRunning && autoRestartCodex {
|
|
460
483
|
forceQuitCodexThenSync(showMenuBarAlert: showMenuBarAlert)
|
|
461
484
|
return
|
|
@@ -463,6 +486,75 @@ final class SyncModel: ObservableObject {
|
|
|
463
486
|
startSync(showMenuBarAlert: showMenuBarAlert, reopenCodexOnSuccess: false)
|
|
464
487
|
}
|
|
465
488
|
|
|
489
|
+
private func forceQuitBothThenSync(showMenuBarAlert: Bool) {
|
|
490
|
+
let sourceApplications = NSWorkspace.shared.runningApplications.filter {
|
|
491
|
+
$0.bundleIdentifier == selectedBrowser.bundleIdentifier
|
|
492
|
+
}
|
|
493
|
+
let codexApplications = NSWorkspace.shared.runningApplications.filter {
|
|
494
|
+
$0.bundleIdentifier == "com.openai.codex"
|
|
495
|
+
}
|
|
496
|
+
let reopenSource = !sourceApplications.isEmpty
|
|
497
|
+
let reopenCodex = !codexApplications.isEmpty
|
|
498
|
+
let applications = sourceApplications + codexApplications
|
|
499
|
+
|
|
500
|
+
isSyncing = true
|
|
501
|
+
uploadCanceling = false
|
|
502
|
+
state = .syncing
|
|
503
|
+
primaryStatus = "Closing both apps for full sync"
|
|
504
|
+
secondaryStatus = "Force quitting \(selectedBrowser.name) and ChatGPT Codex, then waiting for their browser storage to close…"
|
|
505
|
+
guard applications.allSatisfy({ $0.forceTerminate() }) else {
|
|
506
|
+
finishCodexPreparationFailure(
|
|
507
|
+
message: "macOS could not force quit both apps. Close \(selectedBrowser.name) and ChatGPT Codex manually, then try again.",
|
|
508
|
+
showMenuBarAlert: showMenuBarAlert
|
|
509
|
+
)
|
|
510
|
+
return
|
|
511
|
+
}
|
|
512
|
+
waitForBothToQuit(
|
|
513
|
+
attemptsRemaining: 50,
|
|
514
|
+
reopenSource: reopenSource,
|
|
515
|
+
reopenCodex: reopenCodex,
|
|
516
|
+
showMenuBarAlert: showMenuBarAlert
|
|
517
|
+
)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private func waitForBothToQuit(
|
|
521
|
+
attemptsRemaining: Int,
|
|
522
|
+
reopenSource: Bool,
|
|
523
|
+
reopenCodex: Bool,
|
|
524
|
+
showMenuBarAlert: Bool
|
|
525
|
+
) {
|
|
526
|
+
let runningBundleIDs = Set(NSWorkspace.shared.runningApplications.compactMap(\.bundleIdentifier))
|
|
527
|
+
let stillRunning = runningBundleIDs.contains(selectedBrowser.bundleIdentifier) || runningBundleIDs.contains("com.openai.codex")
|
|
528
|
+
if !stillRunning {
|
|
529
|
+
sourceBrowserRunning = false
|
|
530
|
+
codexRunning = false
|
|
531
|
+
secondaryStatus = "Both apps are closed — waiting briefly for browser storage to be released…"
|
|
532
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in
|
|
533
|
+
self?.startSync(
|
|
534
|
+
showMenuBarAlert: showMenuBarAlert,
|
|
535
|
+
reopenCodexOnSuccess: reopenCodex,
|
|
536
|
+
reopenSourceOnSuccess: reopenSource
|
|
537
|
+
)
|
|
538
|
+
}
|
|
539
|
+
return
|
|
540
|
+
}
|
|
541
|
+
guard attemptsRemaining > 0 else {
|
|
542
|
+
finishCodexPreparationFailure(
|
|
543
|
+
message: "The apps did not close within 10 seconds. Close both manually, then try again.",
|
|
544
|
+
showMenuBarAlert: showMenuBarAlert
|
|
545
|
+
)
|
|
546
|
+
return
|
|
547
|
+
}
|
|
548
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
|
|
549
|
+
self?.waitForBothToQuit(
|
|
550
|
+
attemptsRemaining: attemptsRemaining - 1,
|
|
551
|
+
reopenSource: reopenSource,
|
|
552
|
+
reopenCodex: reopenCodex,
|
|
553
|
+
showMenuBarAlert: showMenuBarAlert
|
|
554
|
+
)
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
466
558
|
private func forceQuitCodexThenSync(showMenuBarAlert: Bool) {
|
|
467
559
|
isSyncing = true
|
|
468
560
|
uploadCanceling = false
|
|
@@ -517,7 +609,7 @@ final class SyncModel: ObservableObject {
|
|
|
517
609
|
}
|
|
518
610
|
}
|
|
519
611
|
|
|
520
|
-
private func startSync(showMenuBarAlert: Bool, reopenCodexOnSuccess: Bool) {
|
|
612
|
+
private func startSync(showMenuBarAlert: Bool, reopenCodexOnSuccess: Bool, reopenSourceOnSuccess: Bool = false) {
|
|
521
613
|
isSyncing = true
|
|
522
614
|
uploadCanceling = false
|
|
523
615
|
state = .syncing
|
|
@@ -576,10 +668,12 @@ final class SyncModel: ObservableObject {
|
|
|
576
668
|
: "Keep both browsers open and check the extensions")
|
|
577
669
|
}
|
|
578
670
|
self.updateEndpointRunningStatus()
|
|
579
|
-
if success && reopenCodexOnSuccess {
|
|
580
|
-
self.
|
|
671
|
+
if success && (reopenCodexOnSuccess || reopenSourceOnSuccess) {
|
|
672
|
+
self.reopenApplicationsAfterSuccessfulSync(
|
|
581
673
|
partial: partial,
|
|
582
674
|
syncSummary: self.secondaryStatus,
|
|
675
|
+
reopenCodex: reopenCodexOnSuccess,
|
|
676
|
+
reopenSource: reopenSourceOnSuccess,
|
|
583
677
|
showMenuBarAlert: showMenuBarAlert
|
|
584
678
|
)
|
|
585
679
|
} else if showMenuBarAlert {
|
|
@@ -592,15 +686,44 @@ final class SyncModel: ObservableObject {
|
|
|
592
686
|
}
|
|
593
687
|
}
|
|
594
688
|
|
|
595
|
-
private func
|
|
689
|
+
private func reopenApplicationsAfterSuccessfulSync(
|
|
690
|
+
partial: Bool,
|
|
691
|
+
syncSummary: String,
|
|
692
|
+
reopenCodex: Bool,
|
|
693
|
+
reopenSource: Bool,
|
|
694
|
+
showMenuBarAlert: Bool
|
|
695
|
+
) {
|
|
596
696
|
let transferResult = syncSummary.replacingOccurrences(
|
|
597
697
|
of: "Reopen Codex to use the updated sessions. ",
|
|
598
698
|
with: ""
|
|
599
699
|
)
|
|
700
|
+
var restartMessages: [String] = []
|
|
701
|
+
var sourceRestartFailed = false
|
|
702
|
+
if reopenSource {
|
|
703
|
+
if let sourceURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: selectedBrowser.bundleIdentifier),
|
|
704
|
+
NSWorkspace.shared.open(sourceURL) {
|
|
705
|
+
sourceBrowserRunning = true
|
|
706
|
+
restartMessages.append("\(selectedBrowser.name) reopened successfully.")
|
|
707
|
+
} else {
|
|
708
|
+
sourceRestartFailed = true
|
|
709
|
+
restartMessages.append("\(selectedBrowser.name) could not be reopened; open it manually.")
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
let completedRestartMessages = restartMessages
|
|
713
|
+
let didSourceRestartFail = sourceRestartFailed
|
|
714
|
+
guard reopenCodex else {
|
|
715
|
+
state = partial || didSourceRestartFail ? .warning : .success
|
|
716
|
+
primaryStatus = didSourceRestartFail ? "Sync complete, but the source did not reopen" : (partial ? "Codex sync completed with warnings" : "Full site-data sync complete")
|
|
717
|
+
secondaryStatus = ([transferResult] + completedRestartMessages).joined(separator: "\n\n")
|
|
718
|
+
if showMenuBarAlert {
|
|
719
|
+
postNativeAlert(title: primaryStatus, message: secondaryStatus, kind: state == .success ? .information : .warning)
|
|
720
|
+
}
|
|
721
|
+
return
|
|
722
|
+
}
|
|
600
723
|
guard let codexURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.openai.codex") else {
|
|
601
724
|
state = .warning
|
|
602
725
|
primaryStatus = "Codex sessions updated, but Codex was not reopened"
|
|
603
|
-
secondaryStatus =
|
|
726
|
+
secondaryStatus = ([transferResult] + completedRestartMessages + ["Codex could not be found. Open it manually to use the updated sessions."]).joined(separator: "\n\n")
|
|
604
727
|
if showMenuBarAlert {
|
|
605
728
|
postNativeAlert(title: primaryStatus, message: secondaryStatus, kind: .warning)
|
|
606
729
|
}
|
|
@@ -612,12 +735,12 @@ final class SyncModel: ObservableObject {
|
|
|
612
735
|
if let error {
|
|
613
736
|
self.state = .warning
|
|
614
737
|
self.primaryStatus = "Codex sessions updated, but Codex was not reopened"
|
|
615
|
-
self.secondaryStatus =
|
|
738
|
+
self.secondaryStatus = ([transferResult] + completedRestartMessages + ["Codex could not be reopened: \(error.localizedDescription)"]).joined(separator: "\n\n")
|
|
616
739
|
} else {
|
|
617
740
|
self.codexRunning = true
|
|
618
|
-
self.state = partial ? .warning : .success
|
|
619
|
-
self.primaryStatus = partial ? "Codex sync completed with warnings" : "Codex sessions updated"
|
|
620
|
-
self.secondaryStatus =
|
|
741
|
+
self.state = partial || didSourceRestartFail ? .warning : .success
|
|
742
|
+
self.primaryStatus = didSourceRestartFail ? "Sync complete, but the source did not reopen" : (partial ? "Codex sync completed with warnings" : "Codex sessions updated")
|
|
743
|
+
self.secondaryStatus = ([transferResult] + completedRestartMessages + ["ChatGPT Codex reopened successfully."]).joined(separator: "\n\n")
|
|
621
744
|
}
|
|
622
745
|
if showMenuBarAlert {
|
|
623
746
|
self.postNativeAlert(
|
|
@@ -738,9 +861,11 @@ final class SyncModel: ObservableObject {
|
|
|
738
861
|
"--target", selectedTargetID,
|
|
739
862
|
"--cookies", cookiesEnabled ? "on" : "off",
|
|
740
863
|
"--history", historyEnabled ? "on" : "off",
|
|
864
|
+
"--site-storage", siteStorageEnabled ? "on" : "off",
|
|
741
865
|
"--menu-bar", menuBarEnabled ? "on" : "off",
|
|
742
866
|
"--auto-check-updates", autoCheckUpdates ? "on" : "off",
|
|
743
867
|
"--auto-restart-codex", autoRestartCodex ? "on" : "off",
|
|
868
|
+
"--auto-restart-both", autoRestartBoth ? "on" : "off",
|
|
744
869
|
"--browserless-profile", browserlessProfileName,
|
|
745
870
|
"--browserless-region", browserlessRegion,
|
|
746
871
|
"--browserless-domains", browserlessOnlyDomains,
|
|
@@ -941,7 +1066,15 @@ final class SyncModel: ObservableObject {
|
|
|
941
1066
|
$0.bundleIdentifier == selectedBrowser.bundleIdentifier
|
|
942
1067
|
}
|
|
943
1068
|
guard !isSyncing else { return }
|
|
944
|
-
if selectedTargetID == "codex" &&
|
|
1069
|
+
if selectedTargetID == "codex" && siteStorageEnabled && autoRestartBoth && (sourceBrowserRunning || codexRunning) {
|
|
1070
|
+
state = .ready
|
|
1071
|
+
primaryStatus = "Ready to sync and restart both apps"
|
|
1072
|
+
secondaryStatus = "Manual sync will force quit \(selectedBrowser.name) and Codex, then reopen only the apps that were running"
|
|
1073
|
+
} else if sourceSiteDataBlocked {
|
|
1074
|
+
state = .warning
|
|
1075
|
+
primaryStatus = "Quit \(selectedBrowser.name) before syncing"
|
|
1076
|
+
secondaryStatus = "Full site data uses live LevelDB files. Close the source browser completely so they can be copied safely"
|
|
1077
|
+
} else if selectedTargetID == "codex" && codexRunning && autoRestartCodex {
|
|
945
1078
|
if state == .ready || primaryStatus == "Quit Codex before syncing" {
|
|
946
1079
|
state = .ready
|
|
947
1080
|
primaryStatus = "Ready to sync and restart Codex"
|
|
@@ -1062,6 +1195,7 @@ private struct AppConfig: Decodable {
|
|
|
1062
1195
|
struct Imports: Decodable {
|
|
1063
1196
|
let cookies: Bool
|
|
1064
1197
|
let history: Bool
|
|
1198
|
+
let siteStorage: Bool?
|
|
1065
1199
|
}
|
|
1066
1200
|
|
|
1067
1201
|
struct UISettings: Decodable {
|
|
@@ -1069,6 +1203,7 @@ private struct AppConfig: Decodable {
|
|
|
1069
1203
|
let openAtLogin: Bool?
|
|
1070
1204
|
let autoCheckUpdates: Bool?
|
|
1071
1205
|
let autoRestartCodex: Bool?
|
|
1206
|
+
let autoRestartBoth: Bool?
|
|
1072
1207
|
}
|
|
1073
1208
|
|
|
1074
1209
|
struct BrowserlessSettings: Decodable {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "browser-cookie-bridge",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.3",
|
|
4
4
|
"description": "Local-first cookie and session transfer for macOS with optional Browserless upload",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"test:web-worker": "node --test web/worker/*.test.mjs",
|
|
12
12
|
"check": "node --check bin/brave-codex-cookie-sync.js && node --check src/app-installer.js && node --check src/browserless.js && node --check src/browserless-preflight.js && node --check src/browserless-runner.js && node --check src/broker.js && node --check src/chromium-reader.js && node --check src/cli.js && node --check src/codex-direct-import.js && node --check src/config.js && node --check src/paths.js && node --check src/scheduler.js && node --check src/updater.js && node --check scripts/build-dmg.js && node --check extension-template/background.js && node --check web/server.js && node --check web/script.js",
|
|
13
13
|
"release:check": "node scripts/check-release-version.js",
|
|
14
|
-
"build:app": "node bin/brave-codex-cookie-sync.js install-app --no-open",
|
|
14
|
+
"build:app": "node bin/brave-codex-cookie-sync.js install-app --no-open --replace-system-from-source",
|
|
15
15
|
"build:dmg": "node scripts/build-dmg.js",
|
|
16
16
|
"web": "node web/server.js",
|
|
17
17
|
"web:deploy": "wrangler deploy --config web/wrangler.jsonc"
|
package/src/app-installer.js
CHANGED
|
@@ -2,11 +2,25 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
appSupportDir,
|
|
7
|
+
installedAppPath,
|
|
8
|
+
projectRoot,
|
|
9
|
+
systemInstalledAppPath,
|
|
10
|
+
} from "./paths.js";
|
|
6
11
|
|
|
7
|
-
export function installApp({ home = os.homedir(), open = true } = {}) {
|
|
12
|
+
export function installApp({ home = os.homedir(), open = true, preserveSystemApp = true } = {}) {
|
|
8
13
|
if (process.platform !== "darwin") throw new Error("The desktop app supports macOS only.");
|
|
9
14
|
|
|
15
|
+
const destination = installedAppPath(home);
|
|
16
|
+
const isSystemInstall = path.resolve(destination) === path.resolve(systemInstalledAppPath());
|
|
17
|
+
if (isSystemInstall && preserveSystemApp) {
|
|
18
|
+
console.log(`Preserving the signed installation at ${destination}`);
|
|
19
|
+
console.log("Use Check for Updates in the app to install signed releases.");
|
|
20
|
+
if (open) run("open", [destination]);
|
|
21
|
+
return destination;
|
|
22
|
+
}
|
|
23
|
+
|
|
10
24
|
const packagePath = path.join(projectRoot(), "macos-app");
|
|
11
25
|
run("swift", ["build", "-c", "release", "--package-path", packagePath]);
|
|
12
26
|
const binPath = run("swift", [
|
|
@@ -32,7 +46,6 @@ export function installApp({ home = os.homedir(), open = true } = {}) {
|
|
|
32
46
|
|
|
33
47
|
run("codesign", ["--force", "--deep", "--sign", "-", staging]);
|
|
34
48
|
|
|
35
|
-
const destination = installedAppPath(home);
|
|
36
49
|
fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o755 });
|
|
37
50
|
fs.rmSync(destination, { recursive: true, force: true });
|
|
38
51
|
fs.cpSync(staging, destination, { recursive: true, force: true });
|
package/src/chromium-reader.js
CHANGED
|
@@ -12,40 +12,57 @@ const BROWSERS = {
|
|
|
12
12
|
root: ["BraveSoftware", "Brave-Browser"],
|
|
13
13
|
safeStorageService: "Brave Safe Storage",
|
|
14
14
|
safeStorageAccount: "Brave",
|
|
15
|
+
processPattern: /\/Brave Browser\.app\/Contents\/MacOS\/Brave Browser(?:\s|$)/,
|
|
15
16
|
},
|
|
16
17
|
chrome: {
|
|
17
18
|
root: ["Google", "Chrome"],
|
|
18
19
|
safeStorageService: "Chrome Safe Storage",
|
|
19
20
|
safeStorageAccount: "Chrome",
|
|
21
|
+
processPattern: /\/Google Chrome\.app\/Contents\/MacOS\/Google Chrome(?:\s|$)/,
|
|
20
22
|
},
|
|
21
23
|
edge: {
|
|
22
24
|
root: ["Microsoft Edge"],
|
|
23
25
|
safeStorageService: "Microsoft Edge Safe Storage",
|
|
24
26
|
safeStorageAccount: "Microsoft Edge",
|
|
27
|
+
processPattern: /\/Microsoft Edge\.app\/Contents\/MacOS\/Microsoft Edge(?:\s|$)/,
|
|
25
28
|
},
|
|
26
29
|
arc: {
|
|
27
30
|
root: ["Arc", "User Data"],
|
|
28
31
|
safeStorageService: "Arc Safe Storage",
|
|
29
32
|
safeStorageAccount: "Arc",
|
|
33
|
+
processPattern: /\/Arc\.app\/Contents\/MacOS\/Arc(?:\s|$)/,
|
|
30
34
|
},
|
|
31
35
|
vivaldi: {
|
|
32
36
|
root: ["Vivaldi"],
|
|
33
37
|
safeStorageService: "Vivaldi Safe Storage",
|
|
34
38
|
safeStorageAccount: "Vivaldi",
|
|
39
|
+
processPattern: /\/Vivaldi\.app\/Contents\/MacOS\/Vivaldi(?:\s|$)/,
|
|
35
40
|
},
|
|
36
41
|
opera: {
|
|
37
42
|
root: ["com.operasoftware.Opera"],
|
|
38
43
|
directProfile: true,
|
|
39
44
|
safeStorageService: "Opera Safe Storage",
|
|
40
45
|
safeStorageAccount: "Opera",
|
|
46
|
+
processPattern: /\/Opera\.app\/Contents\/MacOS\/Opera(?:\s|$)/,
|
|
41
47
|
},
|
|
42
48
|
comet: {
|
|
43
49
|
root: ["Comet"],
|
|
44
50
|
safeStorageService: "Comet Safe Storage",
|
|
45
51
|
safeStorageAccount: "Comet",
|
|
52
|
+
processPattern: /\/Comet\.app\/Contents\/MacOS\/Comet(?:\s|$)/,
|
|
46
53
|
},
|
|
47
54
|
};
|
|
48
55
|
|
|
56
|
+
export function isChromiumBrowserRunning({ browser, processList } = {}) {
|
|
57
|
+
const definition = BROWSERS[browser];
|
|
58
|
+
if (!definition) throw new Error(`Unsupported Chromium source: ${browser}`);
|
|
59
|
+
const output = processList ?? spawnSync("/bin/ps", ["-axo", "command="], {
|
|
60
|
+
encoding: "utf8",
|
|
61
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
62
|
+
}).stdout;
|
|
63
|
+
return String(output).split("\n").some((command) => definition.processPattern.test(command.trim()));
|
|
64
|
+
}
|
|
65
|
+
|
|
49
66
|
export function readChromiumProfile({
|
|
50
67
|
browser,
|
|
51
68
|
imports = { cookies: true, history: false },
|
|
@@ -61,7 +78,7 @@ export function readChromiumProfile({
|
|
|
61
78
|
throw new Error(`${browserDisplayName(browser)} profile not found at ${profilePath}`);
|
|
62
79
|
}
|
|
63
80
|
|
|
64
|
-
const
|
|
81
|
+
const cookieResult = imports.cookies
|
|
65
82
|
? readCookies({
|
|
66
83
|
databasePath: firstExisting([
|
|
67
84
|
path.join(profilePath, "Network", "Cookies"),
|
|
@@ -69,12 +86,18 @@ export function readChromiumProfile({
|
|
|
69
86
|
]),
|
|
70
87
|
password: password ?? readSafeStoragePassword(definition, browser),
|
|
71
88
|
})
|
|
72
|
-
: [];
|
|
89
|
+
: { cookies: [], total: 0, skipped: 0 };
|
|
73
90
|
const history = imports.history
|
|
74
91
|
? readHistory(path.join(profilePath, "History"))
|
|
75
92
|
: [];
|
|
76
93
|
|
|
77
|
-
return {
|
|
94
|
+
return {
|
|
95
|
+
cookies: cookieResult.cookies,
|
|
96
|
+
cookieStats: { total: cookieResult.total, imported: cookieResult.cookies.length, skipped: cookieResult.skipped },
|
|
97
|
+
history,
|
|
98
|
+
profileName,
|
|
99
|
+
profilePath,
|
|
100
|
+
};
|
|
78
101
|
}
|
|
79
102
|
|
|
80
103
|
export function decryptChromiumCookieValue({ domain, encryptedValue, password }) {
|
|
@@ -105,6 +128,7 @@ function readCookies({ databasePath, password }) {
|
|
|
105
128
|
FROM cookies
|
|
106
129
|
`).all();
|
|
107
130
|
const cookies = [];
|
|
131
|
+
let skipped = 0;
|
|
108
132
|
for (const row of rows) {
|
|
109
133
|
try {
|
|
110
134
|
const value = row.value || decryptChromiumCookieValue({
|
|
@@ -133,9 +157,10 @@ function readCookies({ databasePath, password }) {
|
|
|
133
157
|
});
|
|
134
158
|
} catch {
|
|
135
159
|
// An individual malformed or obsolete cookie must not block the rest of the profile.
|
|
160
|
+
skipped += 1;
|
|
136
161
|
}
|
|
137
162
|
}
|
|
138
|
-
return cookies;
|
|
163
|
+
return { cookies, total: rows.length, skipped };
|
|
139
164
|
} finally {
|
|
140
165
|
database.close();
|
|
141
166
|
}
|
package/src/cli.js
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
directImportToCodex,
|
|
9
9
|
isCodexRunning,
|
|
10
10
|
} from "./codex-direct-import.js";
|
|
11
|
-
import { readChromiumProfile } from "./chromium-reader.js";
|
|
11
|
+
import { isChromiumBrowserRunning, readChromiumProfile } from "./chromium-reader.js";
|
|
12
12
|
import { uploadBrowserlessProfile } from "./browserless.js";
|
|
13
13
|
import { inspectBrowserlessProfile } from "./browserless-preflight.js";
|
|
14
14
|
import { installConfig, installRuntime, readConfig, updatePreferences } from "./config.js";
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
systemInstalledAppPath,
|
|
25
25
|
userInstalledAppPath,
|
|
26
26
|
APP_ID,
|
|
27
|
+
APP_BUNDLE_ID,
|
|
27
28
|
SOURCE_BROWSERS,
|
|
28
29
|
TARGET_BROWSERS,
|
|
29
30
|
} from "./paths.js";
|
|
@@ -43,9 +44,9 @@ Local cookie and session transfer between Chromium browsers and into ChatGPT Cod
|
|
|
43
44
|
|
|
44
45
|
Commands:
|
|
45
46
|
setup [--hour 9] [--minute 0] [--no-schedule]
|
|
46
|
-
install-app [--no-open]
|
|
47
|
+
install-app [--no-open] [--replace-system-from-source]
|
|
47
48
|
bootstrap-bundled --app-path /Applications/Browser Cookie Bridge.app
|
|
48
|
-
preferences --source brave --target codex --cookies on --history off --menu-bar on --auto-check-updates on --auto-restart-codex off
|
|
49
|
+
preferences --source brave --target codex --cookies on --history off --site-storage off --menu-bar on --auto-check-updates on --auto-restart-codex off --auto-restart-both off
|
|
49
50
|
sync [--timeout 300] [--allow-cloud-upload]
|
|
50
51
|
browserless-preflight
|
|
51
52
|
doctor
|
|
@@ -123,18 +124,20 @@ function preferences(args) {
|
|
|
123
124
|
const config = updatePreferences({
|
|
124
125
|
cookies: booleanFlag(args, "--cookies", existing.imports?.cookies !== false),
|
|
125
126
|
history: booleanFlag(args, "--history", existing.imports?.history === true),
|
|
127
|
+
siteStorage: booleanFlag(args, "--site-storage", existing.imports?.siteStorage === true),
|
|
126
128
|
sourceBrowser: stringFlag(args, "--source", existing.sourceBrowser || "brave"),
|
|
127
129
|
targetBrowser: stringFlag(args, "--target", existing.targetBrowser || "codex"),
|
|
128
130
|
menuBar: booleanFlag(args, "--menu-bar", existing.ui?.menuBar === true),
|
|
129
131
|
openAtLogin: booleanFlag(args, "--open-at-login", existing.ui?.openAtLogin !== false),
|
|
130
132
|
autoCheckUpdates: booleanFlag(args, "--auto-check-updates", existing.ui?.autoCheckUpdates !== false),
|
|
131
133
|
autoRestartCodex: booleanFlag(args, "--auto-restart-codex", existing.ui?.autoRestartCodex === true),
|
|
134
|
+
autoRestartBoth: booleanFlag(args, "--auto-restart-both", existing.ui?.autoRestartBoth === true),
|
|
132
135
|
browserlessProfileName: stringFlag(args, "--browserless-profile", existing.browserless?.profileName || "browser-cookie-bridge"),
|
|
133
136
|
browserlessRegion: stringFlag(args, "--browserless-region", existing.browserless?.region || "sfo"),
|
|
134
137
|
browserlessOnlyDomains: optionalStringFlag(args, "--browserless-domains", (existing.browserless?.onlyDomains || []).join(",")),
|
|
135
138
|
});
|
|
136
139
|
console.log(
|
|
137
|
-
`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"}, auto-restart-codex=${config.ui.autoRestartCodex ? "on" : "off"}`,
|
|
140
|
+
`Saved: source=${config.sourceBrowser}, target=${config.targetBrowser}, cookies=${config.imports.cookies ? "on" : "off"}, history=${config.imports.history ? "on" : "off"}, site-storage=${config.imports.siteStorage ? "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"}, auto-restart-codex=${config.ui.autoRestartCodex ? "on" : "off"}, auto-restart-both=${config.ui.autoRestartBoth ? "on" : "off"}`,
|
|
138
141
|
);
|
|
139
142
|
}
|
|
140
143
|
|
|
@@ -147,8 +150,11 @@ function installDesktopApp(args) {
|
|
|
147
150
|
hour: existing?.schedule?.hour ?? 9,
|
|
148
151
|
minute: existing?.schedule?.minute ?? 0,
|
|
149
152
|
});
|
|
150
|
-
console.log("
|
|
151
|
-
const destination = installApp({
|
|
153
|
+
console.log("Preparing the native macOS app…");
|
|
154
|
+
const destination = installApp({
|
|
155
|
+
open: !args.includes("--no-open"),
|
|
156
|
+
preserveSystemApp: !args.includes("--replace-system-from-source"),
|
|
157
|
+
});
|
|
152
158
|
if (config.ui.openAtLogin) {
|
|
153
159
|
installAppLogin({ appPath: destination, bootstrapNow: false });
|
|
154
160
|
} else {
|
|
@@ -214,7 +220,7 @@ function archiveDuplicateUserApp(currentAppPath) {
|
|
|
214
220
|
if (!fs.existsSync(duplicate)) return;
|
|
215
221
|
const infoPath = path.join(duplicate, "Contents", "Info.plist");
|
|
216
222
|
const info = fs.existsSync(infoPath) ? fs.readFileSync(infoPath, "utf8") : "";
|
|
217
|
-
if (!info.includes(`<string>${
|
|
223
|
+
if (!info.includes(`<string>${APP_BUNDLE_ID}</string>`)) return;
|
|
218
224
|
|
|
219
225
|
const trash = path.join(os.homedir(), ".Trash");
|
|
220
226
|
fs.mkdirSync(trash, { recursive: true, mode: 0o700 });
|
|
@@ -302,14 +308,21 @@ async function sync(args, { signal } = {}) {
|
|
|
302
308
|
}
|
|
303
309
|
if (isCodexTarget) {
|
|
304
310
|
if (isCodexRunning()) throw new Error(CODEX_RUNNING_ERROR);
|
|
311
|
+
const siteStorage = config.imports?.siteStorage === true;
|
|
312
|
+
if (siteStorage && isChromiumBrowserRunning({ browser: source })) {
|
|
313
|
+
throw new Error(`Quit ${source} completely before importing full site data into Codex.`);
|
|
314
|
+
}
|
|
305
315
|
const payload = readChromiumProfile({
|
|
306
316
|
browser: config.sourceBrowser || "brave",
|
|
307
317
|
imports: config.imports || { cookies: true, history: false },
|
|
308
318
|
});
|
|
309
|
-
console.log(`Read ${payload.cookies.length} cookies and ${payload.history.length} history URLs from ${config.sourceBrowser || "brave"}.`);
|
|
319
|
+
console.log(`Read ${payload.cookies.length} of ${payload.cookieStats.total} cookies (${payload.cookieStats.skipped} unavailable) and ${payload.history.length} history URLs from ${config.sourceBrowser || "brave"}.`);
|
|
310
320
|
const result = directImportToCodex({
|
|
311
321
|
cookies: payload.cookies,
|
|
312
322
|
history: payload.history,
|
|
323
|
+
sourceProfilePath: payload.profilePath,
|
|
324
|
+
siteStorage,
|
|
325
|
+
sourceCookieSkipped: payload.cookieStats.skipped,
|
|
313
326
|
codexRunning: false,
|
|
314
327
|
});
|
|
315
328
|
console.log(directCodexSummary(result));
|
|
@@ -359,10 +372,15 @@ export function directCodexSummary(result) {
|
|
|
359
372
|
const imported = result.imported + result.historyImported;
|
|
360
373
|
const skipped = result.skipped + result.historySkipped;
|
|
361
374
|
const failures = result.failed + result.historyFailed;
|
|
362
|
-
|
|
363
|
-
|
|
375
|
+
const unavailable = result.sourceCookieSkipped || 0;
|
|
376
|
+
const siteStorage = result.siteStorageImported > 0
|
|
377
|
+
? ` Full site data: ${result.siteStorageImported} storage area${result.siteStorageImported === 1 ? "" : "s"} replaced from the source profile.`
|
|
378
|
+
: "";
|
|
379
|
+
if (failures > 0 || unavailable > 0) {
|
|
380
|
+
const sourceNote = unavailable > 0 ? ` ${unavailable} source cookie${unavailable === 1 ? " was" : "s were"} unreadable or unsupported.` : "";
|
|
381
|
+
return `Direct Codex sync completed with warnings: ${imported} imported, ${skipped} skipped, ${failures} failed.${sourceNote}${siteStorage} Backup: ${result.backupPath}`;
|
|
364
382
|
}
|
|
365
|
-
return `Direct Codex sync complete: ${imported} imported and ${skipped} skipped
|
|
383
|
+
return `Direct Codex sync complete: ${imported} imported and ${skipped} skipped.${siteStorage} Reopen Codex to use the updated sessions. Backup: ${result.backupPath}`;
|
|
366
384
|
}
|
|
367
385
|
|
|
368
386
|
export function transferSummary(result) {
|
|
@@ -8,6 +8,16 @@ import { appSupportDir, codexCookiePaths } from "./paths.js";
|
|
|
8
8
|
const CHROMIUM_EPOCH_OFFSET_SECONDS = 11_644_473_600;
|
|
9
9
|
const CODEX_RUNNING_ERROR = "ChatGPT Codex is open. Quit it completely, then click Sync now again.";
|
|
10
10
|
const MAX_BACKUPS = 14;
|
|
11
|
+
const SITE_STORAGE_DIRECTORIES = [
|
|
12
|
+
"Local Storage",
|
|
13
|
+
"IndexedDB",
|
|
14
|
+
"Session Storage",
|
|
15
|
+
"Service Worker",
|
|
16
|
+
"File System",
|
|
17
|
+
"WebStorage",
|
|
18
|
+
"shared_proto_db",
|
|
19
|
+
"Shared Dictionary",
|
|
20
|
+
];
|
|
11
21
|
|
|
12
22
|
export function isCodexRunning({ processList } = {}) {
|
|
13
23
|
const output = processList ?? spawnSync("/bin/ps", ["-axo", "command="], {
|
|
@@ -22,6 +32,9 @@ export function isCodexRunning({ processList } = {}) {
|
|
|
22
32
|
export function directImportToCodex({
|
|
23
33
|
cookies,
|
|
24
34
|
history = [],
|
|
35
|
+
sourceProfilePath,
|
|
36
|
+
siteStorage = false,
|
|
37
|
+
sourceCookieSkipped = 0,
|
|
25
38
|
home = os.homedir(),
|
|
26
39
|
codexRunning = isCodexRunning(),
|
|
27
40
|
now = new Date(),
|
|
@@ -36,6 +49,7 @@ export function directImportToCodex({
|
|
|
36
49
|
throw new Error("Codex browser storage was not found. Open the Codex browser once, quit Codex, then try again.");
|
|
37
50
|
}
|
|
38
51
|
const historyPath = path.join(path.dirname(cookiePath), "History");
|
|
52
|
+
const codexProfilePath = codexProfileRoot(cookiePath);
|
|
39
53
|
const backupRoot = path.join(appSupportDir(home), "backups", "codex");
|
|
40
54
|
const backupPath = path.join(backupRoot, backupDirectoryName(now));
|
|
41
55
|
fs.mkdirSync(backupPath, { recursive: true, mode: 0o700 });
|
|
@@ -76,6 +90,20 @@ export function directImportToCodex({
|
|
|
76
90
|
fs.chmodSync(historyWorking, 0o600);
|
|
77
91
|
}
|
|
78
92
|
|
|
93
|
+
let siteStoragePlan = [];
|
|
94
|
+
if (siteStorage) {
|
|
95
|
+
try {
|
|
96
|
+
if (!sourceProfilePath || !fs.existsSync(sourceProfilePath)) {
|
|
97
|
+
throw new Error("The selected source profile was not found for full site-data import.");
|
|
98
|
+
}
|
|
99
|
+
siteStoragePlan = prepareSiteStorageTransfer({ sourceProfilePath, codexProfilePath, backupPath });
|
|
100
|
+
} catch (error) {
|
|
101
|
+
fs.rmSync(cookieWorking, { force: true });
|
|
102
|
+
if (historyWorking) fs.rmSync(historyWorking, { force: true });
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
79
107
|
let cookieResult;
|
|
80
108
|
let historyResult = { imported: 0, skipped: 0, failed: 0 };
|
|
81
109
|
try {
|
|
@@ -87,13 +115,16 @@ export function directImportToCodex({
|
|
|
87
115
|
replaceDatabase(historyWorking, historyPath);
|
|
88
116
|
historyResult.replaced = true;
|
|
89
117
|
}
|
|
118
|
+
commitSiteStorageTransfer(siteStoragePlan);
|
|
90
119
|
} catch (error) {
|
|
91
120
|
restoreDatabase(cookieBackup, cookiePath);
|
|
92
121
|
if (historyBackup) restoreDatabase(historyBackup, historyPath);
|
|
122
|
+
restoreSiteStorageTransfer(siteStoragePlan);
|
|
93
123
|
throw new Error(`Codex data was restored from backup after the sync failed: ${error.message}`);
|
|
94
124
|
} finally {
|
|
95
125
|
fs.rmSync(cookieWorking, { force: true });
|
|
96
126
|
if (historyWorking) fs.rmSync(historyWorking, { force: true });
|
|
127
|
+
cleanupSiteStorageTransfer(siteStoragePlan);
|
|
97
128
|
}
|
|
98
129
|
|
|
99
130
|
pruneBackups(backupRoot, MAX_BACKUPS);
|
|
@@ -107,9 +138,78 @@ export function directImportToCodex({
|
|
|
107
138
|
backupPath,
|
|
108
139
|
targetPath: cookiePath,
|
|
109
140
|
directCodexImport: true,
|
|
141
|
+
siteStorageImported: siteStoragePlan.filter((item) => item.committed).length,
|
|
142
|
+
siteStorageNames: siteStoragePlan.filter((item) => item.committed).map((item) => item.name),
|
|
143
|
+
sourceCookieSkipped: Number.isInteger(sourceCookieSkipped) && sourceCookieSkipped > 0 ? sourceCookieSkipped : 0,
|
|
110
144
|
};
|
|
111
145
|
}
|
|
112
146
|
|
|
147
|
+
function codexProfileRoot(cookiePath) {
|
|
148
|
+
const parent = path.dirname(cookiePath);
|
|
149
|
+
return path.basename(parent) === "Network" ? path.dirname(parent) : parent;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function prepareSiteStorageTransfer({ sourceProfilePath, codexProfilePath, backupPath }) {
|
|
153
|
+
const plan = [];
|
|
154
|
+
try {
|
|
155
|
+
fs.mkdirSync(codexProfilePath, { recursive: true, mode: 0o700 });
|
|
156
|
+
for (const name of SITE_STORAGE_DIRECTORIES) {
|
|
157
|
+
const source = path.join(sourceProfilePath, name);
|
|
158
|
+
if (!fs.existsSync(source)) continue;
|
|
159
|
+
const destination = path.join(codexProfilePath, name);
|
|
160
|
+
const backup = path.join(backupPath, "Site Storage", name);
|
|
161
|
+
const stage = `${destination}.browser-cookie-bridge-${process.pid}-${Date.now()}.stage`;
|
|
162
|
+
const existed = fs.existsSync(destination);
|
|
163
|
+
const item = { name, destination, backup, stage, existed, touched: false, committed: false };
|
|
164
|
+
plan.push(item);
|
|
165
|
+
if (existed) copyStorageTree(destination, backup);
|
|
166
|
+
copyStorageTree(source, stage);
|
|
167
|
+
}
|
|
168
|
+
if (plan.length === 0) {
|
|
169
|
+
throw new Error("No compatible Local Storage, IndexedDB, session, or service-worker data was found in the source profile.");
|
|
170
|
+
}
|
|
171
|
+
return plan;
|
|
172
|
+
} catch (error) {
|
|
173
|
+
cleanupSiteStorageTransfer(plan);
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function commitSiteStorageTransfer(plan) {
|
|
179
|
+
for (const item of plan) {
|
|
180
|
+
item.touched = true;
|
|
181
|
+
fs.rmSync(item.destination, { recursive: true, force: true });
|
|
182
|
+
fs.renameSync(item.stage, item.destination);
|
|
183
|
+
item.committed = true;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function restoreSiteStorageTransfer(plan) {
|
|
188
|
+
for (const item of plan) {
|
|
189
|
+
if (!item.touched) continue;
|
|
190
|
+
fs.rmSync(item.destination, { recursive: true, force: true });
|
|
191
|
+
if (item.existed && fs.existsSync(item.backup)) copyStorageTree(item.backup, item.destination);
|
|
192
|
+
item.touched = false;
|
|
193
|
+
item.committed = false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function cleanupSiteStorageTransfer(plan) {
|
|
198
|
+
for (const item of plan) fs.rmSync(item.stage, { recursive: true, force: true });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function copyStorageTree(source, destination) {
|
|
202
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 });
|
|
203
|
+
fs.cpSync(source, destination, {
|
|
204
|
+
recursive: true,
|
|
205
|
+
force: true,
|
|
206
|
+
preserveTimestamps: true,
|
|
207
|
+
filter(candidate) {
|
|
208
|
+
try { return !fs.lstatSync(candidate).isSymbolicLink(); } catch { return false; }
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
113
213
|
function mergeCookies(databasePath, cookies, now) {
|
|
114
214
|
const database = new DatabaseSync(databasePath);
|
|
115
215
|
let imported = 0;
|
package/src/config.js
CHANGED
|
@@ -45,12 +45,14 @@ export function installConfig({ home, hour = 9, minute = 0, nodePath = process.e
|
|
|
45
45
|
cookies: existing.imports?.cookies !== false,
|
|
46
46
|
passwords: false,
|
|
47
47
|
history: existing.imports?.history === true,
|
|
48
|
+
siteStorage: existing.imports?.siteStorage === true,
|
|
48
49
|
},
|
|
49
50
|
ui: {
|
|
50
51
|
menuBar: existing.ui?.menuBar !== false,
|
|
51
52
|
openAtLogin: existing.ui?.openAtLogin !== false,
|
|
52
53
|
autoCheckUpdates: existing.ui?.autoCheckUpdates !== false,
|
|
53
54
|
autoRestartCodex: existing.ui?.autoRestartCodex === true,
|
|
55
|
+
autoRestartBoth: existing.ui?.autoRestartBoth === true,
|
|
54
56
|
},
|
|
55
57
|
browserless: {
|
|
56
58
|
profileName: cleanProfileName(existing.browserless?.profileName) || "browser-cookie-bridge",
|
|
@@ -73,12 +75,14 @@ export function updatePreferences({
|
|
|
73
75
|
home,
|
|
74
76
|
cookies,
|
|
75
77
|
history,
|
|
78
|
+
siteStorage,
|
|
76
79
|
sourceBrowser,
|
|
77
80
|
targetBrowser,
|
|
78
81
|
menuBar,
|
|
79
82
|
openAtLogin,
|
|
80
83
|
autoCheckUpdates,
|
|
81
84
|
autoRestartCodex,
|
|
85
|
+
autoRestartBoth,
|
|
82
86
|
browserlessProfileName,
|
|
83
87
|
browserlessRegion,
|
|
84
88
|
browserlessOnlyDomains,
|
|
@@ -95,12 +99,18 @@ export function updatePreferences({
|
|
|
95
99
|
}
|
|
96
100
|
config.sourceBrowser = sourceBrowser;
|
|
97
101
|
config.targetBrowser = targetBrowser;
|
|
98
|
-
config.imports = {
|
|
102
|
+
config.imports = {
|
|
103
|
+
cookies: Boolean(cookies),
|
|
104
|
+
passwords: false,
|
|
105
|
+
history: Boolean(history),
|
|
106
|
+
siteStorage: Boolean(siteStorage),
|
|
107
|
+
};
|
|
99
108
|
config.ui = {
|
|
100
109
|
menuBar: Boolean(menuBar),
|
|
101
110
|
openAtLogin: Boolean(openAtLogin),
|
|
102
111
|
autoCheckUpdates: Boolean(autoCheckUpdates),
|
|
103
112
|
autoRestartCodex: Boolean(autoRestartCodex),
|
|
113
|
+
autoRestartBoth: Boolean(autoRestartBoth),
|
|
104
114
|
};
|
|
105
115
|
const region = browserlessRegion ?? config.browserless?.region ?? "sfo";
|
|
106
116
|
if (!["sfo", "lon", "ams"].includes(region)) throw new Error("Browserless region must be sfo, lon, or ams");
|
package/src/paths.js
CHANGED
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
|
|
6
6
|
export const APP_ID = "com.apoorvdarshan.brave-codex-cookie-sync";
|
|
7
|
+
export const APP_BUNDLE_ID = "com.apoorvdarshan.browser-cookie-bridge";
|
|
7
8
|
export const LOGIN_SYNC_APP_ID = `${APP_ID}.login-sync`;
|
|
8
9
|
export const APP_LOGIN_APP_ID = `${APP_ID}.app-login`;
|
|
9
10
|
export const DEFAULT_PORT = 43128;
|
|
@@ -48,10 +49,22 @@ export function systemInstalledAppPath() {
|
|
|
48
49
|
return path.join("/Applications", "Browser Cookie Bridge.app");
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
export function isAppBundleWithIdentifier(appPath, identifier = APP_BUNDLE_ID) {
|
|
53
|
+
const infoPath = path.join(appPath, "Contents", "Info.plist");
|
|
54
|
+
if (!fs.existsSync(infoPath)) return false;
|
|
55
|
+
try {
|
|
56
|
+
const info = fs.readFileSync(infoPath, "utf8");
|
|
57
|
+
const escaped = identifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
58
|
+
return new RegExp(`<key>CFBundleIdentifier</key>\\s*<string>${escaped}</string>`).test(info);
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
51
64
|
export function installedAppPath(home = os.homedir()) {
|
|
52
65
|
const systemApp = systemInstalledAppPath();
|
|
53
66
|
const isCurrentUser = path.resolve(home) === path.resolve(os.homedir());
|
|
54
|
-
return isCurrentUser &&
|
|
67
|
+
return isCurrentUser && isAppBundleWithIdentifier(systemApp) ? systemApp : userInstalledAppPath(home);
|
|
55
68
|
}
|
|
56
69
|
|
|
57
70
|
export function braveCookiePaths(home = os.homedir()) {
|