browser-cookie-bridge 1.2.0 → 1.3.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 +8 -3
- package/bin/brave-codex-cookie-sync.js +10 -2
- package/extension-template/manifest.json +1 -1
- package/macos-app/Info.plist +2 -2
- package/macos-app/Sources/BraveCodexSyncApp/BraveCodexSyncApp.swift +140 -31
- package/macos-app/Sources/BraveCodexSyncApp/SyncModel.swift +215 -12
- package/package.json +2 -2
- package/src/browserless-preflight.js +113 -0
- package/src/browserless.js +184 -11
- package/src/cli.js +36 -4
package/README.md
CHANGED
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
|
|
45
45
|
---
|
|
46
46
|
|
|
47
|
-
> **Version 1.
|
|
47
|
+
> **Version 1.3.0 makes large Browserless uploads observable and recoverable.** The app measures the local profile and IndexedDB without reading their contents, streams capture progress and elapsed time, supports cancellation with deterministic temporary-data cleanup, reports auto-fit omissions, and verifies the cloud profile after upload.
|
|
48
48
|
|
|
49
49
|
## Why Browser Cookie Bridge
|
|
50
50
|
|
|
@@ -57,7 +57,7 @@ Browser Cookie Bridge gives those browser profiles a small, native control panel
|
|
|
57
57
|
- 🍪 **Cookie and session transfer** — cookies are enabled by default, including supported domain, path, expiry, security, `SameSite`, and partition attributes.
|
|
58
58
|
- 🌐 **Seven Chromium browsers** — Brave, Chrome, Edge, Arc, Vivaldi, Opera, and Perplexity Comet can be sources or destinations.
|
|
59
59
|
- ✨ **ChatGPT Codex import** — merge selected local browser data into Codex's built-in browser; Codex is destination-only.
|
|
60
|
-
- ☁️ **Optional Browserless upload** — create or refresh a Browserless authenticated profile with cookies, local storage, and IndexedDB;
|
|
60
|
+
- ☁️ **Optional Browserless upload** — create or refresh a Browserless authenticated profile with cookies, local storage, and IndexedDB; see a local size preflight, live progress, cancellation, and post-upload verification.
|
|
61
61
|
- 🕘 **Background automation** — sync when you sign in, at a fixed daily time, or whenever you choose.
|
|
62
62
|
- ◉ **Native menu-bar app** — closing the window removes the Dock icon while the helper continues running.
|
|
63
63
|
- 🧯 **Backup and rollback** — Codex's database is backed up, modified on a separate copy, integrity-checked, and restored if replacement fails.
|
|
@@ -151,7 +151,11 @@ This path uses the official Browserless CLI and is deliberately separate from lo
|
|
|
151
151
|
3. Quit the selected source browser so its profile can be copied consistently.
|
|
152
152
|
4. Review the cloud warning and click **Upload now**.
|
|
153
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.
|
|
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. The app measures the profile, IndexedDB, local storage, and available disk space locally before capture. Progress and elapsed time remain visible, Cancel upload terminates the isolated capture process group, and the dedicated temporary workspace is removed after success, failure, timeout, or cancellation.
|
|
155
|
+
|
|
156
|
+
Browserless currently caps the serialized authenticated-profile artifact at **2 MB**. A large on-disk IndexedDB does not mean all of it will be uploaded: the official CLI's `--auto-fit` behavior drops the heaviest origins until the artifact fits while keeping cookies. Browser Cookie Bridge reports those omissions in the final result and verifies that the named cloud profile can be read back after upload. Use the domain allowlist when you need specific sites or want a faster, smaller capture.
|
|
157
|
+
|
|
158
|
+
Browserless uploads never run from Daily sync or Sync at login. Browser Cookie Bridge disables Browserless CLI telemetry for this integration. Comet is not currently supported by the Browserless capture CLI. The default cloud timeout is 15 minutes; `--timeout` can override it.
|
|
155
159
|
|
|
156
160
|
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
161
|
|
|
@@ -178,6 +182,7 @@ browser-cookie-bridge install-app [--no-open]
|
|
|
178
182
|
browser-cookie-bridge setup [--hour 9] [--minute 0] [--no-schedule]
|
|
179
183
|
browser-cookie-bridge preferences --source brave --target codex --cookies on --history off
|
|
180
184
|
browser-cookie-bridge sync [--timeout 300] [--allow-cloud-upload]
|
|
185
|
+
browser-cookie-bridge browserless-preflight
|
|
181
186
|
browser-cookie-bridge doctor
|
|
182
187
|
browser-cookie-bridge enable-login-sync
|
|
183
188
|
browser-cookie-bridge disable-login-sync
|
|
@@ -2,7 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
import { main } from "../src/cli.js";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
const controller = new AbortController();
|
|
6
|
+
const cancel = () => controller.abort();
|
|
7
|
+
process.once("SIGINT", cancel);
|
|
8
|
+
process.once("SIGTERM", cancel);
|
|
9
|
+
|
|
10
|
+
main(process.argv.slice(2), { signal: controller.signal }).catch((error) => {
|
|
6
11
|
console.error(`Error: ${error.message}`);
|
|
7
|
-
process.exitCode = 1;
|
|
12
|
+
process.exitCode = controller.signal.aborted ? 130 : 1;
|
|
13
|
+
}).finally(() => {
|
|
14
|
+
process.removeListener("SIGINT", cancel);
|
|
15
|
+
process.removeListener("SIGTERM", cancel);
|
|
8
16
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Browser Cookie Bridge",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.3.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.3.0</string>
|
|
14
|
+
<key>CFBundleVersion</key><string>27</string>
|
|
15
15
|
<key>LSMinimumSystemVersion</key><string>13.5</string>
|
|
16
16
|
<key>NSHighResolutionCapable</key><true/>
|
|
17
17
|
</dict>
|
|
@@ -35,6 +35,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
|
|
|
35
35
|
weak var model: SyncModel?
|
|
36
36
|
private weak var mainWindow: NSWindow?
|
|
37
37
|
private var statusItem: NSStatusItem?
|
|
38
|
+
private var syncMenuItem: NSMenuItem?
|
|
38
39
|
private var updateMenuItem: NSMenuItem?
|
|
39
40
|
|
|
40
41
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
@@ -56,6 +57,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
|
|
|
56
57
|
name: .updateStateChanged,
|
|
57
58
|
object: nil
|
|
58
59
|
)
|
|
60
|
+
NotificationCenter.default.addObserver(
|
|
61
|
+
self,
|
|
62
|
+
selector: #selector(syncStateChanged(_:)),
|
|
63
|
+
name: .syncStateChanged,
|
|
64
|
+
object: nil
|
|
65
|
+
)
|
|
59
66
|
}
|
|
60
67
|
|
|
61
68
|
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false }
|
|
@@ -94,7 +101,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
|
|
|
94
101
|
item.button?.imageScaling = .scaleProportionallyDown
|
|
95
102
|
let menu = NSMenu()
|
|
96
103
|
menu.addItem(withTitle: "Show Browser Cookie Bridge", action: #selector(showMainWindowAction), keyEquivalent: "")
|
|
97
|
-
menu.addItem(withTitle: "Sync now", action: #selector(syncNowAction), keyEquivalent: "")
|
|
104
|
+
syncMenuItem = menu.addItem(withTitle: "Sync now", action: #selector(syncNowAction), keyEquivalent: "")
|
|
98
105
|
let updateItem = menu.addItem(withTitle: "Check for Updates…", action: #selector(updateAction), keyEquivalent: "")
|
|
99
106
|
updateMenuItem = updateItem
|
|
100
107
|
addProjectLinks(to: menu)
|
|
@@ -106,6 +113,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
|
|
|
106
113
|
} else if let statusItem {
|
|
107
114
|
NSStatusBar.system.removeStatusItem(statusItem)
|
|
108
115
|
self.statusItem = nil
|
|
116
|
+
syncMenuItem = nil
|
|
109
117
|
updateMenuItem = nil
|
|
110
118
|
}
|
|
111
119
|
}
|
|
@@ -116,7 +124,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
|
|
|
116
124
|
}
|
|
117
125
|
|
|
118
126
|
@objc private func showMainWindowAction() { showMainWindow() }
|
|
119
|
-
@objc private func syncNowAction() {
|
|
127
|
+
@objc private func syncNowAction() {
|
|
128
|
+
if model?.isBrowserlessTarget == true, model?.isSyncing == true { model?.cancelSync() }
|
|
129
|
+
else { model?.syncNow(showMenuBarAlert: true) }
|
|
130
|
+
}
|
|
120
131
|
@objc private func updateAction() {
|
|
121
132
|
if model?.availableUpdateVersion != nil { model?.installAvailableUpdate() }
|
|
122
133
|
else { model?.checkForUpdates(showAlert: true) }
|
|
@@ -161,6 +172,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
|
|
|
161
172
|
}
|
|
162
173
|
}
|
|
163
174
|
|
|
175
|
+
@objc private func syncStateChanged(_ notification: Notification) {
|
|
176
|
+
guard let payload = notification.object as? SyncMenuState else { return }
|
|
177
|
+
if payload.canceling {
|
|
178
|
+
syncMenuItem?.title = "Canceling upload…"
|
|
179
|
+
syncMenuItem?.isEnabled = false
|
|
180
|
+
} else if payload.uploading {
|
|
181
|
+
syncMenuItem?.title = "Cancel upload"
|
|
182
|
+
syncMenuItem?.isEnabled = true
|
|
183
|
+
} else {
|
|
184
|
+
syncMenuItem?.title = "Sync now"
|
|
185
|
+
syncMenuItem?.isEnabled = true
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
164
189
|
private func showMainWindow() {
|
|
165
190
|
NSApp.setActivationPolicy(.regular)
|
|
166
191
|
mainWindow?.makeKeyAndOrderFront(nil)
|
|
@@ -290,35 +315,57 @@ struct SyncPanel: View {
|
|
|
290
315
|
TargetPicker()
|
|
291
316
|
}
|
|
292
317
|
Divider().opacity(0.65)
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
Spacer()
|
|
304
|
-
Button {
|
|
305
|
-
if model.isBrowserlessTarget && !model.browserlessConfigured {
|
|
306
|
-
model.showingBrowserlessSetup = true
|
|
307
|
-
} else {
|
|
308
|
-
model.syncNow()
|
|
318
|
+
VStack(spacing: 8) {
|
|
319
|
+
HStack(spacing: 12) {
|
|
320
|
+
VStack(alignment: .leading, spacing: 2) {
|
|
321
|
+
Text(model.primaryStatus)
|
|
322
|
+
.font(.system(size: 13, weight: .semibold))
|
|
323
|
+
Text(model.secondaryStatus)
|
|
324
|
+
.font(.system(size: 10.5))
|
|
325
|
+
.foregroundStyle(.secondary)
|
|
326
|
+
.lineLimit(2)
|
|
327
|
+
.help(model.secondaryStatus)
|
|
309
328
|
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
if model.
|
|
313
|
-
|
|
314
|
-
|
|
329
|
+
Spacer()
|
|
330
|
+
Button {
|
|
331
|
+
if model.isBrowserlessTarget && model.isSyncing {
|
|
332
|
+
model.cancelSync()
|
|
333
|
+
} else if model.isBrowserlessTarget && !model.browserlessConfigured {
|
|
334
|
+
model.showingBrowserlessSetup = true
|
|
335
|
+
} else {
|
|
336
|
+
model.syncNow()
|
|
337
|
+
}
|
|
338
|
+
} label: {
|
|
339
|
+
HStack(spacing: 7) {
|
|
340
|
+
if model.isSyncing && !model.isBrowserlessTarget { ProgressView().controlSize(.small) }
|
|
341
|
+
else { Image(systemName: syncButtonIcon) }
|
|
342
|
+
Text(syncButtonTitle)
|
|
343
|
+
}
|
|
344
|
+
.frame(minWidth: model.syncBlocked ? 116 : 92)
|
|
315
345
|
}
|
|
316
|
-
.
|
|
346
|
+
.buttonStyle(.borderedProminent)
|
|
347
|
+
.tint(model.isBrowserlessTarget && model.isSyncing ? Theme.active : Theme.accent)
|
|
348
|
+
.disabled(
|
|
349
|
+
model.uploadCanceling
|
|
350
|
+
|| (model.isSyncing && !model.isBrowserlessTarget)
|
|
351
|
+
|| (!model.isSyncing && model.syncBlocked && !(model.isBrowserlessTarget && !model.browserlessConfigured))
|
|
352
|
+
)
|
|
353
|
+
.keyboardShortcut(.return, modifiers: .command)
|
|
354
|
+
}
|
|
355
|
+
if model.isBrowserlessTarget && model.isSyncing {
|
|
356
|
+
HStack(spacing: 9) {
|
|
357
|
+
ProgressView(value: model.uploadProgress, total: 1)
|
|
358
|
+
.progressViewStyle(.linear)
|
|
359
|
+
.tint(Theme.active)
|
|
360
|
+
Text(model.formattedUploadElapsed)
|
|
361
|
+
.font(.system(size: 9.5, weight: .semibold, design: .monospaced))
|
|
362
|
+
.foregroundStyle(.secondary)
|
|
363
|
+
.frame(width: 34, alignment: .trailing)
|
|
364
|
+
}
|
|
365
|
+
.accessibilityElement(children: .combine)
|
|
366
|
+
.accessibilityLabel("Browserless upload progress")
|
|
367
|
+
.accessibilityValue("\(Int(model.uploadProgress * 100)) percent, \(model.formattedUploadElapsed) elapsed")
|
|
317
368
|
}
|
|
318
|
-
.buttonStyle(.borderedProminent)
|
|
319
|
-
.tint(Theme.accent)
|
|
320
|
-
.disabled(model.isSyncing || (model.syncBlocked && !(model.isBrowserlessTarget && !model.browserlessConfigured)))
|
|
321
|
-
.keyboardShortcut(.return, modifiers: .command)
|
|
322
369
|
}
|
|
323
370
|
.padding(model.syncBlocked ? 7 : 0)
|
|
324
371
|
.background(
|
|
@@ -336,7 +383,9 @@ struct SyncPanel: View {
|
|
|
336
383
|
}
|
|
337
384
|
|
|
338
385
|
private var syncButtonTitle: String {
|
|
339
|
-
if model.
|
|
386
|
+
if model.isBrowserlessTarget && model.uploadCanceling { return "Canceling…" }
|
|
387
|
+
if model.isBrowserlessTarget && model.isSyncing { return "Cancel upload" }
|
|
388
|
+
if model.isSyncing { return "Syncing…" }
|
|
340
389
|
if model.codexBlocked { return "Close Codex first" }
|
|
341
390
|
if model.isBrowserlessTarget && !model.browserlessConfigured { return "Connect Browserless" }
|
|
342
391
|
if model.isBrowserlessTarget && model.selectedSourceID == "comet" { return "Choose another browser" }
|
|
@@ -345,6 +394,7 @@ struct SyncPanel: View {
|
|
|
345
394
|
}
|
|
346
395
|
|
|
347
396
|
private var syncButtonIcon: String {
|
|
397
|
+
if model.isBrowserlessTarget && model.isSyncing { return "xmark.circle.fill" }
|
|
348
398
|
if model.syncBlocked { return model.isBrowserlessTarget && !model.browserlessConfigured ? "key.fill" : "xmark.circle.fill" }
|
|
349
399
|
return model.isBrowserlessTarget ? "icloud.and.arrow.up.fill" : "arrow.triangle.2.circlepath"
|
|
350
400
|
}
|
|
@@ -499,6 +549,21 @@ struct PreferencesPanel: View {
|
|
|
499
549
|
}
|
|
500
550
|
.controlSize(.small)
|
|
501
551
|
}
|
|
552
|
+
RowDivider()
|
|
553
|
+
PreferenceRow(icon: "externaldrive.badge.magnifyingglass", color: Theme.accent, title: "Local profile preflight", detail: browserlessPreflightDetail) {
|
|
554
|
+
HStack(spacing: 7) {
|
|
555
|
+
ProfileSizeBadge(severity: model.browserlessAssessment?.severity, scanning: model.isInspectingBrowserlessProfile)
|
|
556
|
+
Button {
|
|
557
|
+
model.refreshBrowserlessPreflight(force: true)
|
|
558
|
+
} label: {
|
|
559
|
+
Image(systemName: "arrow.clockwise")
|
|
560
|
+
}
|
|
561
|
+
.buttonStyle(.plain)
|
|
562
|
+
.foregroundStyle(.secondary)
|
|
563
|
+
.disabled(model.isInspectingBrowserlessProfile || model.isSyncing)
|
|
564
|
+
.help("Rescan local profile size")
|
|
565
|
+
}
|
|
566
|
+
}
|
|
502
567
|
} else {
|
|
503
568
|
PreferenceRow(icon: "network", color: Theme.accent, title: "Cookies", detail: "Site sessions and sign-ins") {
|
|
504
569
|
Toggle("", isOn: Binding(get: { model.cookiesEnabled }, set: { model.setCookiesEnabled($0) }))
|
|
@@ -579,6 +644,16 @@ struct PreferencesPanel: View {
|
|
|
579
644
|
}
|
|
580
645
|
}
|
|
581
646
|
}
|
|
647
|
+
|
|
648
|
+
private var browserlessPreflightDetail: String {
|
|
649
|
+
if model.isInspectingBrowserlessProfile { return "Measuring profile, local storage, and IndexedDB without reading their contents" }
|
|
650
|
+
if let assessment = model.browserlessAssessment {
|
|
651
|
+
return assessment.temporarySpaceWarning
|
|
652
|
+
? "\(assessment.summary) · free space may be too low for the temporary copy"
|
|
653
|
+
: assessment.summary
|
|
654
|
+
}
|
|
655
|
+
return "Calculated locally before upload; Browserless cloud artifacts are capped at 2 MB"
|
|
656
|
+
}
|
|
582
657
|
}
|
|
583
658
|
|
|
584
659
|
struct FixedBadge: View {
|
|
@@ -595,6 +670,40 @@ struct FixedBadge: View {
|
|
|
595
670
|
}
|
|
596
671
|
}
|
|
597
672
|
|
|
673
|
+
struct ProfileSizeBadge: View {
|
|
674
|
+
let severity: String?
|
|
675
|
+
let scanning: Bool
|
|
676
|
+
|
|
677
|
+
var body: some View {
|
|
678
|
+
Text(label)
|
|
679
|
+
.font(.system(size: 9.5, weight: .semibold))
|
|
680
|
+
.foregroundStyle(color)
|
|
681
|
+
.padding(.horizontal, 8).padding(.vertical, 4)
|
|
682
|
+
.background(color.opacity(0.10), in: Capsule())
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
private var label: String {
|
|
686
|
+
if scanning { return "Scanning" }
|
|
687
|
+
switch severity {
|
|
688
|
+
case "elevated": return "Large"
|
|
689
|
+
case "high": return "Very large"
|
|
690
|
+
case "extreme": return "Extreme"
|
|
691
|
+
case "normal": return "Ready"
|
|
692
|
+
default: return "Not scanned"
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
private var color: Color {
|
|
697
|
+
if scanning { return .secondary }
|
|
698
|
+
switch severity {
|
|
699
|
+
case "elevated": return .orange
|
|
700
|
+
case "high", "extreme": return Theme.active
|
|
701
|
+
case "normal": return Theme.accent
|
|
702
|
+
default: return .secondary
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
598
707
|
struct BrowserlessSetupSheet: View {
|
|
599
708
|
@EnvironmentObject private var model: SyncModel
|
|
600
709
|
@Environment(\.dismiss) private var dismiss
|
|
@@ -904,11 +1013,11 @@ struct StatusIndicator: View {
|
|
|
904
1013
|
}
|
|
905
1014
|
|
|
906
1015
|
private var label: String {
|
|
907
|
-
switch state { case .ready: "Ready"; case .syncing: "Syncing"; case .success: "Synced"; case .warning: "Partial"; case .error: "Needs action" }
|
|
1016
|
+
switch state { case .ready: "Ready"; case .syncing: "Syncing"; case .success: "Synced"; case .canceled: "Canceled"; case .warning: "Partial"; case .error: "Needs action" }
|
|
908
1017
|
}
|
|
909
1018
|
|
|
910
1019
|
private var color: Color {
|
|
911
|
-
switch state { case .ready, .syncing, .success: Theme.accent; case .warning: .orange; case .error: .red }
|
|
1020
|
+
switch state { case .ready, .syncing, .success: Theme.accent; case .canceled: .secondary; case .warning: .orange; case .error: .red }
|
|
912
1021
|
}
|
|
913
1022
|
}
|
|
914
1023
|
|
|
@@ -6,6 +6,7 @@ extension Notification.Name {
|
|
|
6
6
|
static let menuBarVisibilityChanged = Notification.Name("BraveCodexSync.menuBarVisibilityChanged")
|
|
7
7
|
static let nativeAlert = Notification.Name("BraveCodexSync.nativeAlert")
|
|
8
8
|
static let updateStateChanged = Notification.Name("BraveCodexSync.updateStateChanged")
|
|
9
|
+
static let syncStateChanged = Notification.Name("BraveCodexSync.syncStateChanged")
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
struct NativeAlert {
|
|
@@ -21,6 +22,31 @@ struct UpdateMenuState {
|
|
|
21
22
|
let installing: Bool
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
struct SyncMenuState {
|
|
26
|
+
let uploading: Bool
|
|
27
|
+
let canceling: Bool
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
struct BrowserlessProfileAssessment: Decodable {
|
|
31
|
+
let browser: String?
|
|
32
|
+
let profileName: String?
|
|
33
|
+
let profileBytes: Int64
|
|
34
|
+
let indexedDBBytes: Int64
|
|
35
|
+
let localStorageBytes: Int64
|
|
36
|
+
let freeBytes: Int64?
|
|
37
|
+
let severity: String
|
|
38
|
+
let temporarySpaceWarning: Bool
|
|
39
|
+
let serverArtifactCapBytes: Int64
|
|
40
|
+
let summary: String
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private struct BrowserlessProgressEvent: Decodable {
|
|
44
|
+
let phase: String
|
|
45
|
+
let fraction: Double?
|
|
46
|
+
let detail: String?
|
|
47
|
+
let assessment: BrowserlessProfileAssessment?
|
|
48
|
+
}
|
|
49
|
+
|
|
24
50
|
struct BrowserChoice: Identifiable, Hashable {
|
|
25
51
|
let id: String
|
|
26
52
|
let name: String
|
|
@@ -31,7 +57,7 @@ struct BrowserChoice: Identifiable, Hashable {
|
|
|
31
57
|
|
|
32
58
|
@MainActor
|
|
33
59
|
final class SyncModel: ObservableObject {
|
|
34
|
-
enum State { case ready, syncing, success, warning, error }
|
|
60
|
+
enum State { case ready, syncing, success, canceled, warning, error }
|
|
35
61
|
|
|
36
62
|
let browsers = [
|
|
37
63
|
BrowserChoice(id: "brave", name: "Brave", bundleIdentifier: "com.brave.Browser", applicationName: "Brave Browser", extensionURL: "brave://extensions"),
|
|
@@ -67,6 +93,11 @@ final class SyncModel: ObservableObject {
|
|
|
67
93
|
@Published var browserlessRegion = "sfo"
|
|
68
94
|
@Published var browserlessOnlyDomains = ""
|
|
69
95
|
@Published var showingBrowserlessSetup = false
|
|
96
|
+
@Published var browserlessAssessment: BrowserlessProfileAssessment?
|
|
97
|
+
@Published var isInspectingBrowserlessProfile = false
|
|
98
|
+
@Published var uploadProgress = 0.0
|
|
99
|
+
@Published var uploadElapsedSeconds = 0
|
|
100
|
+
@Published var uploadCanceling = false
|
|
70
101
|
@Published var primaryStatus = "Ready to sync"
|
|
71
102
|
@Published var secondaryStatus = "Choose what to move, then start a transfer"
|
|
72
103
|
|
|
@@ -80,6 +111,10 @@ final class SyncModel: ObservableObject {
|
|
|
80
111
|
private var updateTimer: Timer?
|
|
81
112
|
private var didCheckAfterLaunch = false
|
|
82
113
|
private var didConsumeUpdateResult = false
|
|
114
|
+
private var assessedBrowserID: String?
|
|
115
|
+
private var activeSyncProcess: Process?
|
|
116
|
+
private var uploadTimer: Timer?
|
|
117
|
+
private var uploadStartedAt: Date?
|
|
83
118
|
|
|
84
119
|
var selectedBrowser: BrowserChoice {
|
|
85
120
|
browsers.first(where: { $0.id == selectedSourceID }) ?? browsers[0]
|
|
@@ -98,6 +133,11 @@ final class SyncModel: ObservableObject {
|
|
|
98
133
|
isBrowserlessTarget && (!browserlessConfigured || sourceBrowserRunning || selectedSourceID == "comet")
|
|
99
134
|
}
|
|
100
135
|
var syncBlocked: Bool { codexBlocked || browserlessBlocked }
|
|
136
|
+
var formattedUploadElapsed: String {
|
|
137
|
+
let minutes = uploadElapsedSeconds / 60
|
|
138
|
+
let seconds = uploadElapsedSeconds % 60
|
|
139
|
+
return String(format: "%d:%02d", minutes, seconds)
|
|
140
|
+
}
|
|
101
141
|
var sourceIcon: NSImage { browserIcon(selectedBrowser) }
|
|
102
142
|
var targetIcon: NSImage {
|
|
103
143
|
isBrowserlessTarget ? browserlessIcon : selectedTargetBrowser.map(browserIcon) ?? codexIcon
|
|
@@ -141,6 +181,7 @@ final class SyncModel: ObservableObject {
|
|
|
141
181
|
let calendar = Calendar.current
|
|
142
182
|
scheduleTime = calendar.date(bySettingHour: 9, minute: 0, second: 0, of: Date()) ?? Date()
|
|
143
183
|
updateEndpointRunningStatus()
|
|
184
|
+
refreshBrowserlessPreflight()
|
|
144
185
|
endpointStatusTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
|
|
145
186
|
Task { @MainActor in self?.updateEndpointRunningStatus() }
|
|
146
187
|
}
|
|
@@ -230,6 +271,7 @@ final class SyncModel: ObservableObject {
|
|
|
230
271
|
FileManager.default.fileExists(atPath: support.appending(path: "extension-\($0)/manifest.json").path)
|
|
231
272
|
}
|
|
232
273
|
updateEndpointRunningStatus()
|
|
274
|
+
refreshBrowserlessPreflight()
|
|
233
275
|
consumeUpdateResultIfNeeded()
|
|
234
276
|
if autoCheckUpdates && !didCheckAfterLaunch {
|
|
235
277
|
didCheckAfterLaunch = true
|
|
@@ -240,6 +282,8 @@ final class SyncModel: ObservableObject {
|
|
|
240
282
|
func selectSource(_ id: String) {
|
|
241
283
|
guard browsers.contains(where: { $0.id == id }), id != selectedSourceID, id != selectedTargetID else { return }
|
|
242
284
|
selectedSourceID = id
|
|
285
|
+
browserlessAssessment = nil
|
|
286
|
+
assessedBrowserID = nil
|
|
243
287
|
persistPreferences(successMessage: "Export source changed to \(selectedBrowser.name)")
|
|
244
288
|
}
|
|
245
289
|
|
|
@@ -250,6 +294,7 @@ final class SyncModel: ObservableObject {
|
|
|
250
294
|
persistPreferences(successMessage: "Import destination changed to \(targetName)")
|
|
251
295
|
updateEndpointRunningStatus()
|
|
252
296
|
if id == "browserless" && !browserlessConfigured { showingBrowserlessSetup = true }
|
|
297
|
+
if id == "browserless" { refreshBrowserlessPreflight() }
|
|
253
298
|
}
|
|
254
299
|
|
|
255
300
|
func setCookiesEnabled(_ enabled: Bool) {
|
|
@@ -277,6 +322,7 @@ final class SyncModel: ObservableObject {
|
|
|
277
322
|
browserlessOnlyDomains = onlyDomains
|
|
278
323
|
showingBrowserlessSetup = false
|
|
279
324
|
persistPreferences(successMessage: "Browserless connected — uploads remain manual")
|
|
325
|
+
refreshBrowserlessPreflight(force: true)
|
|
280
326
|
} catch {
|
|
281
327
|
postNativeAlert(title: "Could not save Browserless token", message: error.localizedDescription, kind: .error)
|
|
282
328
|
}
|
|
@@ -387,6 +433,10 @@ final class SyncModel: ObservableObject {
|
|
|
387
433
|
|
|
388
434
|
func syncNow(showMenuBarAlert: Bool = false) {
|
|
389
435
|
guard !isSyncing else {
|
|
436
|
+
if isBrowserlessTarget {
|
|
437
|
+
cancelSync()
|
|
438
|
+
return
|
|
439
|
+
}
|
|
390
440
|
if showMenuBarAlert {
|
|
391
441
|
postNativeAlert(title: "Sync already running", message: "Wait for the current transfer to finish.", kind: .information)
|
|
392
442
|
}
|
|
@@ -400,6 +450,7 @@ final class SyncModel: ObservableObject {
|
|
|
400
450
|
return
|
|
401
451
|
}
|
|
402
452
|
isSyncing = true
|
|
453
|
+
uploadCanceling = false
|
|
403
454
|
state = .syncing
|
|
404
455
|
primaryStatus = isBrowserlessTarget ? "Uploading authenticated state" : "Transferring selected data"
|
|
405
456
|
secondaryStatus = selectedTargetID == "codex"
|
|
@@ -408,7 +459,7 @@ final class SyncModel: ObservableObject {
|
|
|
408
459
|
? "Sending \(selectedBrowser.name) to Browserless \(browserlessRegion.uppercased()) only for this request…"
|
|
409
460
|
: "Waiting for \(selectedBrowser.name) and \(targetName)…"
|
|
410
461
|
var environment: [String: String] = [:]
|
|
411
|
-
var arguments = ["sync", "--timeout", "300"]
|
|
462
|
+
var arguments = ["sync", "--timeout", isBrowserlessTarget ? "900" : "300"]
|
|
412
463
|
if isBrowserlessTarget {
|
|
413
464
|
guard let token = BrowserlessCredentialStore.read() else {
|
|
414
465
|
isSyncing = false
|
|
@@ -418,15 +469,30 @@ final class SyncModel: ObservableObject {
|
|
|
418
469
|
}
|
|
419
470
|
environment["BROWSERLESS_TOKEN"] = token
|
|
420
471
|
arguments.append("--allow-cloud-upload")
|
|
472
|
+
beginUploadTracking()
|
|
421
473
|
}
|
|
422
|
-
runCLI(arguments, environment: environment
|
|
474
|
+
activeSyncProcess = runCLI(arguments, environment: environment, onLine: { [weak self] line in
|
|
475
|
+
self?.handleBrowserlessProgress(line)
|
|
476
|
+
}) { [weak self] success, output in
|
|
423
477
|
guard let self else { return }
|
|
478
|
+
self.activeSyncProcess = nil
|
|
424
479
|
self.isSyncing = false
|
|
425
|
-
|
|
426
|
-
|
|
480
|
+
self.finishUploadTracking()
|
|
481
|
+
let partial = success && (
|
|
482
|
+
output.contains("Partially synced:")
|
|
483
|
+
|| output.contains("with warnings")
|
|
484
|
+
|| output.contains("omitted to fit")
|
|
485
|
+
|| output.contains("could not be captured")
|
|
486
|
+
)
|
|
487
|
+
let canceled = output.contains("Browserless upload canceled") || output.contains("Temporary profile data was removed")
|
|
488
|
+
if canceled {
|
|
489
|
+
self.state = .canceled
|
|
490
|
+
self.primaryStatus = "Browserless upload canceled"
|
|
491
|
+
self.secondaryStatus = "No cloud profile was changed; temporary profile data was removed"
|
|
492
|
+
} else if success {
|
|
427
493
|
self.state = partial ? .warning : .success
|
|
428
494
|
self.primaryStatus = self.isBrowserlessTarget
|
|
429
|
-
? "Browserless profile uploaded"
|
|
495
|
+
? (partial ? "Browserless profile uploaded with omissions" : "Browserless profile uploaded")
|
|
430
496
|
: self.selectedTargetID == "codex"
|
|
431
497
|
? (partial ? "Codex sync completed with warnings" : "Codex sessions updated")
|
|
432
498
|
: (partial ? "Partially synced" : "Transfer complete")
|
|
@@ -445,12 +511,40 @@ final class SyncModel: ObservableObject {
|
|
|
445
511
|
self.postNativeAlert(
|
|
446
512
|
title: self.primaryStatus,
|
|
447
513
|
message: self.secondaryStatus,
|
|
448
|
-
kind: success ? (partial ? .warning : .information) : .error
|
|
514
|
+
kind: canceled ? .information : success ? (partial ? .warning : .information) : .error
|
|
449
515
|
)
|
|
450
516
|
}
|
|
451
517
|
}
|
|
452
518
|
}
|
|
453
519
|
|
|
520
|
+
func cancelSync() {
|
|
521
|
+
guard isBrowserlessTarget, isSyncing, !uploadCanceling else { return }
|
|
522
|
+
uploadCanceling = true
|
|
523
|
+
primaryStatus = "Canceling Browserless upload"
|
|
524
|
+
secondaryStatus = "Stopping the temporary browser and removing its isolated workspace…"
|
|
525
|
+
postSyncState()
|
|
526
|
+
activeSyncProcess?.terminate()
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
func refreshBrowserlessPreflight(force: Bool = false) {
|
|
530
|
+
guard isBrowserlessTarget, selectedSourceID != "comet", !isInspectingBrowserlessProfile else { return }
|
|
531
|
+
if !force, assessedBrowserID == selectedSourceID, browserlessAssessment != nil { return }
|
|
532
|
+
isInspectingBrowserlessProfile = true
|
|
533
|
+
let sourceAtStart = selectedSourceID
|
|
534
|
+
runCLI(["browserless-preflight"]) { [weak self] success, output in
|
|
535
|
+
guard let self else { return }
|
|
536
|
+
self.isInspectingBrowserlessProfile = false
|
|
537
|
+
guard self.selectedSourceID == sourceAtStart else { return }
|
|
538
|
+
if success, let assessment = self.decodeLastJSON(BrowserlessProfileAssessment.self, from: output) {
|
|
539
|
+
self.browserlessAssessment = assessment
|
|
540
|
+
self.assessedBrowserID = sourceAtStart
|
|
541
|
+
} else if force {
|
|
542
|
+
self.browserlessAssessment = nil
|
|
543
|
+
self.assessedBrowserID = nil
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
454
548
|
func setDailyEnabled(_ enabled: Bool) {
|
|
455
549
|
guard !isBrowserlessTarget else {
|
|
456
550
|
postNativeAlert(title: "Cloud uploads are manual-only", message: "Browser Cookie Bridge will never schedule Browserless uploads in the background.", kind: .information)
|
|
@@ -556,6 +650,7 @@ final class SyncModel: ObservableObject {
|
|
|
556
650
|
FileManager.default.fileExists(atPath: self.support.appending(path: "extension-\($0)/manifest.json").path)
|
|
557
651
|
}
|
|
558
652
|
self.updateEndpointRunningStatus()
|
|
653
|
+
self.refreshBrowserlessPreflight()
|
|
559
654
|
}
|
|
560
655
|
}
|
|
561
656
|
|
|
@@ -584,27 +679,48 @@ final class SyncModel: ObservableObject {
|
|
|
584
679
|
}
|
|
585
680
|
}
|
|
586
681
|
|
|
587
|
-
|
|
682
|
+
@discardableResult
|
|
683
|
+
private func runCLI(
|
|
684
|
+
_ arguments: [String],
|
|
685
|
+
environment: [String: String] = [:],
|
|
686
|
+
onLine: (@MainActor (String) -> Void)? = nil,
|
|
687
|
+
completion: @escaping @MainActor (Bool, String) -> Void
|
|
688
|
+
) -> Process? {
|
|
588
689
|
guard let config = loadConfig() else {
|
|
589
690
|
completion(false, "Configuration missing. Run install-app again.")
|
|
590
|
-
return
|
|
691
|
+
return nil
|
|
591
692
|
}
|
|
592
693
|
let process = Process()
|
|
593
694
|
let output = Pipe()
|
|
695
|
+
let collector = ProcessOutputCollector()
|
|
594
696
|
process.executableURL = URL(fileURLWithPath: config.nodePath)
|
|
595
697
|
process.arguments = [runtimeCLI.path] + arguments
|
|
596
698
|
process.environment = ProcessInfo.processInfo.environment.merging(environment) { _, new in new }
|
|
597
699
|
process.standardOutput = output
|
|
598
700
|
process.standardError = output
|
|
701
|
+
output.fileHandleForReading.readabilityHandler = { handle in
|
|
702
|
+
let data = handle.availableData
|
|
703
|
+
guard !data.isEmpty else { return }
|
|
704
|
+
let lines = collector.append(data)
|
|
705
|
+
guard let onLine, !lines.isEmpty else { return }
|
|
706
|
+
Task { @MainActor in lines.forEach(onLine) }
|
|
707
|
+
}
|
|
599
708
|
process.terminationHandler = { process in
|
|
600
|
-
|
|
601
|
-
let
|
|
602
|
-
|
|
709
|
+
output.fileHandleForReading.readabilityHandler = nil
|
|
710
|
+
let remainder = output.fileHandleForReading.readDataToEndOfFile()
|
|
711
|
+
let lines = collector.append(remainder, finish: true)
|
|
712
|
+
let text = collector.text
|
|
713
|
+
Task { @MainActor in
|
|
714
|
+
if let onLine { lines.forEach(onLine) }
|
|
715
|
+
completion(process.terminationStatus == 0, text)
|
|
716
|
+
}
|
|
603
717
|
}
|
|
604
718
|
do {
|
|
605
719
|
try process.run()
|
|
720
|
+
return process
|
|
606
721
|
} catch {
|
|
607
722
|
completion(false, error.localizedDescription)
|
|
723
|
+
return nil
|
|
608
724
|
}
|
|
609
725
|
}
|
|
610
726
|
|
|
@@ -631,6 +747,69 @@ final class SyncModel: ObservableObject {
|
|
|
631
747
|
return line.hasPrefix("Error: ") ? String(line.dropFirst(7)) : line
|
|
632
748
|
}
|
|
633
749
|
|
|
750
|
+
private func decodeLastJSON<T: Decodable>(_ type: T.Type, from output: String) -> T? {
|
|
751
|
+
for line in output.split(separator: "\n").reversed() {
|
|
752
|
+
guard let data = String(line).data(using: .utf8),
|
|
753
|
+
let value = try? JSONDecoder().decode(type, from: data) else { continue }
|
|
754
|
+
return value
|
|
755
|
+
}
|
|
756
|
+
return nil
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
private func beginUploadTracking() {
|
|
760
|
+
uploadProgress = 0.01
|
|
761
|
+
uploadElapsedSeconds = 0
|
|
762
|
+
uploadStartedAt = Date()
|
|
763
|
+
uploadTimer?.invalidate()
|
|
764
|
+
uploadTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
|
765
|
+
Task { @MainActor in
|
|
766
|
+
guard let self, let started = self.uploadStartedAt else { return }
|
|
767
|
+
self.uploadElapsedSeconds = max(0, Int(Date().timeIntervalSince(started)))
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
postSyncState()
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
private func finishUploadTracking() {
|
|
774
|
+
uploadTimer?.invalidate()
|
|
775
|
+
uploadTimer = nil
|
|
776
|
+
uploadStartedAt = nil
|
|
777
|
+
uploadCanceling = false
|
|
778
|
+
postSyncState()
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
private func handleBrowserlessProgress(_ line: String) {
|
|
782
|
+
guard line.hasPrefix("BCB_PROGRESS "),
|
|
783
|
+
let data = String(line.dropFirst("BCB_PROGRESS ".count)).data(using: .utf8),
|
|
784
|
+
let event = try? JSONDecoder().decode(BrowserlessProgressEvent.self, from: data) else { return }
|
|
785
|
+
if let fraction = event.fraction { uploadProgress = min(max(fraction, uploadProgress), 1) }
|
|
786
|
+
if let assessment = event.assessment {
|
|
787
|
+
browserlessAssessment = assessment
|
|
788
|
+
assessedBrowserID = selectedSourceID
|
|
789
|
+
}
|
|
790
|
+
guard !uploadCanceling else { return }
|
|
791
|
+
primaryStatus = switch event.phase {
|
|
792
|
+
case "preflight": "Inspecting the local profile"
|
|
793
|
+
case "preflight-complete": "Profile preflight complete"
|
|
794
|
+
case "validating": "Checking Browserless profile"
|
|
795
|
+
case "copying": "Preparing an isolated profile copy"
|
|
796
|
+
case "launching", "waiting": "Starting the temporary browser"
|
|
797
|
+
case "capturing": "Capturing authenticated state"
|
|
798
|
+
case "uploading": "Uploading fitted profile state"
|
|
799
|
+
case "verifying": "Verifying the Browserless profile"
|
|
800
|
+
case "complete": "Browserless profile uploaded"
|
|
801
|
+
default: "Uploading authenticated state"
|
|
802
|
+
}
|
|
803
|
+
if let detail = event.detail { secondaryStatus = detail }
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
private func postSyncState() {
|
|
807
|
+
NotificationCenter.default.post(
|
|
808
|
+
name: .syncStateChanged,
|
|
809
|
+
object: SyncMenuState(uploading: isBrowserlessTarget && isSyncing, canceling: uploadCanceling)
|
|
810
|
+
)
|
|
811
|
+
}
|
|
812
|
+
|
|
634
813
|
private var formattedTime: String {
|
|
635
814
|
scheduleTime.formatted(date: .omitted, time: .shortened)
|
|
636
815
|
}
|
|
@@ -827,3 +1006,27 @@ private enum BrowserlessCredentialStore {
|
|
|
827
1006
|
SecItemDelete(query as CFDictionary)
|
|
828
1007
|
}
|
|
829
1008
|
}
|
|
1009
|
+
|
|
1010
|
+
private final class ProcessOutputCollector: @unchecked Sendable {
|
|
1011
|
+
private let lock = NSLock()
|
|
1012
|
+
private var bytes = Data()
|
|
1013
|
+
private var pending = ""
|
|
1014
|
+
|
|
1015
|
+
var text: String {
|
|
1016
|
+
lock.withLock { String(decoding: bytes, as: UTF8.self) }
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
func append(_ data: Data, finish: Bool = false) -> [String] {
|
|
1020
|
+
lock.withLock {
|
|
1021
|
+
bytes.append(data)
|
|
1022
|
+
pending += String(decoding: data, as: UTF8.self)
|
|
1023
|
+
var lines = pending.components(separatedBy: .newlines)
|
|
1024
|
+
if finish {
|
|
1025
|
+
pending = ""
|
|
1026
|
+
return lines.filter { !$0.isEmpty }
|
|
1027
|
+
}
|
|
1028
|
+
pending = lines.popLast() ?? ""
|
|
1029
|
+
return lines.filter { !$0.isEmpty }
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "browser-cookie-bridge",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Local-first cookie and session transfer for macOS with optional Browserless upload",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,7 @@
|
|
|
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/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",
|
|
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-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",
|
|
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",
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const BROWSERLESS_SERVER_ARTIFACT_CAP_BYTES = 2 * 1024 * 1024;
|
|
5
|
+
|
|
6
|
+
const MEBIBYTE = 1024 * 1024;
|
|
7
|
+
const GIBIBYTE = 1024 * MEBIBYTE;
|
|
8
|
+
|
|
9
|
+
export function inspectBrowserlessProfile({ profilePath } = {}) {
|
|
10
|
+
if (!profilePath || !fs.existsSync(profilePath)) {
|
|
11
|
+
throw new Error("The local browser profile could not be inspected.");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const profileBytes = directorySize(profilePath);
|
|
15
|
+
const indexedDBBytes = directorySize(path.join(profilePath, "IndexedDB"));
|
|
16
|
+
const localStorageBytes = directorySize(path.join(profilePath, "Local Storage"));
|
|
17
|
+
const freeBytes = availableBytes(profilePath);
|
|
18
|
+
const severity = sizeSeverity(indexedDBBytes);
|
|
19
|
+
const temporarySpaceWarning = Number.isFinite(freeBytes) && freeBytes < profileBytes + 256 * MEBIBYTE;
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
profilePath,
|
|
23
|
+
profileBytes,
|
|
24
|
+
indexedDBBytes,
|
|
25
|
+
localStorageBytes,
|
|
26
|
+
freeBytes,
|
|
27
|
+
severity,
|
|
28
|
+
temporarySpaceWarning,
|
|
29
|
+
serverArtifactCapBytes: BROWSERLESS_SERVER_ARTIFACT_CAP_BYTES,
|
|
30
|
+
summary: profileSummary({
|
|
31
|
+
profileBytes,
|
|
32
|
+
indexedDBBytes,
|
|
33
|
+
localStorageBytes,
|
|
34
|
+
freeBytes,
|
|
35
|
+
severity,
|
|
36
|
+
temporarySpaceWarning,
|
|
37
|
+
}),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function sizeSeverity(indexedDBBytes) {
|
|
42
|
+
if (indexedDBBytes >= GIBIBYTE) return "extreme";
|
|
43
|
+
if (indexedDBBytes >= 500 * MEBIBYTE) return "high";
|
|
44
|
+
if (indexedDBBytes >= 100 * MEBIBYTE) return "elevated";
|
|
45
|
+
return "normal";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function formatBytes(bytes) {
|
|
49
|
+
if (!Number.isFinite(bytes) || bytes < 0) return "unknown";
|
|
50
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
51
|
+
const units = ["KB", "MB", "GB", "TB"];
|
|
52
|
+
let value = bytes;
|
|
53
|
+
let unit = -1;
|
|
54
|
+
do {
|
|
55
|
+
value /= 1024;
|
|
56
|
+
unit += 1;
|
|
57
|
+
} while (value >= 1024 && unit < units.length - 1);
|
|
58
|
+
const digits = value >= 100 ? 0 : value >= 10 ? 1 : 2;
|
|
59
|
+
return `${value.toFixed(digits)} ${units[unit]}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function directorySize(root) {
|
|
63
|
+
try {
|
|
64
|
+
if (fs.lstatSync(root).isSymbolicLink()) return 0;
|
|
65
|
+
} catch {
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
let total = 0;
|
|
69
|
+
const pending = [root];
|
|
70
|
+
while (pending.length > 0) {
|
|
71
|
+
const current = pending.pop();
|
|
72
|
+
let entries;
|
|
73
|
+
try {
|
|
74
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
75
|
+
} catch {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
const candidate = path.join(current, entry.name);
|
|
80
|
+
if (entry.isSymbolicLink()) continue;
|
|
81
|
+
if (entry.isDirectory()) {
|
|
82
|
+
pending.push(candidate);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (!entry.isFile()) continue;
|
|
86
|
+
try {
|
|
87
|
+
total += fs.statSync(candidate).size;
|
|
88
|
+
} catch {}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return total;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function availableBytes(candidate) {
|
|
95
|
+
try {
|
|
96
|
+
const statistics = fs.statfsSync(candidate);
|
|
97
|
+
return Number(statistics.bavail) * Number(statistics.bsize);
|
|
98
|
+
} catch {
|
|
99
|
+
return Number.NaN;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function profileSummary({ profileBytes, indexedDBBytes, localStorageBytes, freeBytes, severity, temporarySpaceWarning }) {
|
|
104
|
+
const parts = [
|
|
105
|
+
`${formatBytes(profileBytes)} profile`,
|
|
106
|
+
`${formatBytes(indexedDBBytes)} IndexedDB`,
|
|
107
|
+
`${formatBytes(localStorageBytes)} local storage`,
|
|
108
|
+
];
|
|
109
|
+
if (Number.isFinite(freeBytes)) parts.push(`${formatBytes(freeBytes)} free`);
|
|
110
|
+
if (temporarySpaceWarning) parts.push("low temporary disk space");
|
|
111
|
+
else if (severity !== "normal") parts.push(`${severity} IndexedDB load`);
|
|
112
|
+
return parts.join(" · ");
|
|
113
|
+
}
|
package/src/browserless.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
1
3
|
import path from "node:path";
|
|
2
4
|
import { spawn } from "node:child_process";
|
|
3
5
|
import { projectRoot } from "./paths.js";
|
|
@@ -13,7 +15,10 @@ export async function uploadBrowserlessProfile({
|
|
|
13
15
|
onlyDomains = [],
|
|
14
16
|
token = process.env.BROWSERLESS_TOKEN,
|
|
15
17
|
root = projectRoot(),
|
|
16
|
-
runner =
|
|
18
|
+
runner = runBrowserlessCLI,
|
|
19
|
+
signal,
|
|
20
|
+
timeoutMs = 15 * 60 * 1000,
|
|
21
|
+
onProgress = () => {},
|
|
17
22
|
} = {}) {
|
|
18
23
|
if (!SUPPORTED_SOURCES.has(browser)) {
|
|
19
24
|
throw new Error(`${browser === "comet" ? "Comet" : browser} is not supported by Browserless profile capture yet.`);
|
|
@@ -34,7 +39,15 @@ export async function uploadBrowserlessProfile({
|
|
|
34
39
|
DO_NOT_TRACK: "1",
|
|
35
40
|
};
|
|
36
41
|
const client = ["--region", region, "--json"];
|
|
37
|
-
|
|
42
|
+
onProgress({ phase: "validating", fraction: 0.10, detail: "Checking the destination profile…" });
|
|
43
|
+
const shown = await runner(
|
|
44
|
+
cliPath,
|
|
45
|
+
["profile", "show", profileName.trim(), ...client],
|
|
46
|
+
environment,
|
|
47
|
+
runnerPath,
|
|
48
|
+
{ signal, timeoutMs: Math.min(timeoutMs, 60_000) },
|
|
49
|
+
);
|
|
50
|
+
throwForInterruptedRun(shown, timeoutMs);
|
|
38
51
|
const operation = shown.status === 0 ? "refresh" : isMissingProfile(shown.output) ? "upload" : null;
|
|
39
52
|
if (!operation) throw new Error(lastLine(shown.output) || "Browserless could not validate the cloud profile.");
|
|
40
53
|
|
|
@@ -48,8 +61,28 @@ export async function uploadBrowserlessProfile({
|
|
|
48
61
|
"--auto-fit",
|
|
49
62
|
];
|
|
50
63
|
for (const domain of onlyDomains) capture.push("--only-domain", domain);
|
|
51
|
-
const
|
|
52
|
-
|
|
64
|
+
const parseProgress = progressParser(onProgress);
|
|
65
|
+
const result = await runner(cliPath, capture, environment, runnerPath, {
|
|
66
|
+
signal,
|
|
67
|
+
timeoutMs,
|
|
68
|
+
onOutput: parseProgress,
|
|
69
|
+
});
|
|
70
|
+
parseProgress("\n");
|
|
71
|
+
throwForInterruptedRun(result, timeoutMs);
|
|
72
|
+
if (result.status !== 0) throw new Error(actionableFailure(result.output));
|
|
73
|
+
|
|
74
|
+
onProgress({ phase: "verifying", fraction: 0.97, detail: "Verifying the cloud profile…" });
|
|
75
|
+
const verified = await runner(
|
|
76
|
+
cliPath,
|
|
77
|
+
["profile", "show", profileName.trim(), ...client],
|
|
78
|
+
environment,
|
|
79
|
+
runnerPath,
|
|
80
|
+
{ signal, timeoutMs: Math.min(timeoutMs, 60_000) },
|
|
81
|
+
);
|
|
82
|
+
throwForInterruptedRun(verified, timeoutMs);
|
|
83
|
+
if (verified.status !== 0) {
|
|
84
|
+
throw new Error(`The upload finished, but Browserless could not verify the cloud profile: ${lastLine(verified.output) || "profile lookup failed"}`);
|
|
85
|
+
}
|
|
53
86
|
|
|
54
87
|
const details = parseJSON(result.output);
|
|
55
88
|
const cookies = details?.cookieCount;
|
|
@@ -59,28 +92,126 @@ export async function uploadBrowserlessProfile({
|
|
|
59
92
|
: "";
|
|
60
93
|
return {
|
|
61
94
|
operation,
|
|
95
|
+
verified: true,
|
|
62
96
|
profileName: details?.name || profileName.trim(),
|
|
63
97
|
cookieCount: cookies,
|
|
64
98
|
originCount: origins,
|
|
65
|
-
|
|
99
|
+
droppedOriginCount: droppedOrigins(result.output),
|
|
100
|
+
failedOriginCount: failedOrigins(result.output),
|
|
101
|
+
summary: uploadSummary({ operation, details, fallbackName: profileName.trim(), counts, output: result.output }),
|
|
66
102
|
};
|
|
67
103
|
}
|
|
68
104
|
|
|
69
|
-
function
|
|
105
|
+
export function runBrowserlessCLI(cliPath, args, environment, runnerPath, {
|
|
106
|
+
signal,
|
|
107
|
+
timeoutMs = 15 * 60 * 1000,
|
|
108
|
+
onOutput = () => {},
|
|
109
|
+
} = {}) {
|
|
70
110
|
return new Promise((resolve, reject) => {
|
|
111
|
+
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "browser-cookie-bridge-browserless-"));
|
|
112
|
+
fs.chmodSync(temporaryRoot, 0o700);
|
|
71
113
|
const child = spawn(process.execPath, [runnerPath, cliPath, ...args], {
|
|
72
|
-
env: environment,
|
|
114
|
+
env: { ...environment, TMPDIR: temporaryRoot },
|
|
73
115
|
stdio: ["ignore", "pipe", "pipe"],
|
|
116
|
+
detached: process.platform !== "win32",
|
|
74
117
|
});
|
|
75
118
|
let stdout = "";
|
|
76
119
|
let stderr = "";
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
120
|
+
let interruption = null;
|
|
121
|
+
let settled = false;
|
|
122
|
+
const append = (target, chunk) => {
|
|
123
|
+
const text = String(chunk);
|
|
124
|
+
if (target === "stdout") stdout += text;
|
|
125
|
+
else stderr += text;
|
|
126
|
+
onOutput(text);
|
|
127
|
+
};
|
|
128
|
+
const terminate = (reason) => {
|
|
129
|
+
if (settled || interruption) return;
|
|
130
|
+
interruption = reason;
|
|
131
|
+
try {
|
|
132
|
+
if (process.platform !== "win32" && child.pid) process.kill(-child.pid, "SIGTERM");
|
|
133
|
+
else child.kill("SIGTERM");
|
|
134
|
+
} catch {}
|
|
135
|
+
setTimeout(() => {
|
|
136
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
137
|
+
try {
|
|
138
|
+
if (process.platform !== "win32" && child.pid) process.kill(-child.pid, "SIGKILL");
|
|
139
|
+
else child.kill("SIGKILL");
|
|
140
|
+
} catch {}
|
|
141
|
+
}, 2_000).unref();
|
|
142
|
+
};
|
|
143
|
+
const abort = () => terminate("canceled");
|
|
144
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
145
|
+
if (signal?.aborted) abort();
|
|
146
|
+
const timer = setTimeout(() => terminate("timedOut"), timeoutMs);
|
|
147
|
+
timer.unref();
|
|
148
|
+
child.stdout.on("data", (chunk) => append("stdout", chunk));
|
|
149
|
+
child.stderr.on("data", (chunk) => append("stderr", chunk));
|
|
150
|
+
child.on("error", (error) => {
|
|
151
|
+
settled = true;
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
signal?.removeEventListener("abort", abort);
|
|
154
|
+
fs.rmSync(temporaryRoot, { recursive: true, force: true });
|
|
155
|
+
reject(error);
|
|
156
|
+
});
|
|
157
|
+
child.on("close", (status) => {
|
|
158
|
+
settled = true;
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
signal?.removeEventListener("abort", abort);
|
|
161
|
+
fs.rmSync(temporaryRoot, { recursive: true, force: true });
|
|
162
|
+
resolve({
|
|
163
|
+
status: interruption === "canceled" ? 130 : interruption === "timedOut" ? 124 : (status ?? 1),
|
|
164
|
+
output: `${stderr}${stdout}`,
|
|
165
|
+
canceled: interruption === "canceled",
|
|
166
|
+
timedOut: interruption === "timedOut",
|
|
167
|
+
});
|
|
168
|
+
});
|
|
81
169
|
});
|
|
82
170
|
}
|
|
83
171
|
|
|
172
|
+
export function progressParser(onProgress) {
|
|
173
|
+
let pending = "";
|
|
174
|
+
let lastPhase = "";
|
|
175
|
+
return (chunk) => {
|
|
176
|
+
pending += chunk;
|
|
177
|
+
const lines = pending.split(/\r\n|\n|\r/);
|
|
178
|
+
pending = lines.pop() ?? "";
|
|
179
|
+
for (const raw of lines) {
|
|
180
|
+
const line = raw.trim();
|
|
181
|
+
let progress = null;
|
|
182
|
+
if (/copying profile data/i.test(line)) {
|
|
183
|
+
progress = { phase: "copying", fraction: 0.18, detail: "Copying profile data into an isolated workspace…" };
|
|
184
|
+
} else if (/launching headless browser/i.test(line)) {
|
|
185
|
+
progress = { phase: "launching", fraction: 0.30, detail: "Launching the temporary browser…" };
|
|
186
|
+
} else if (/waiting for browser to be ready/i.test(line)) {
|
|
187
|
+
progress = { phase: "waiting", fraction: 0.38, detail: "Waiting for the temporary browser…" };
|
|
188
|
+
} else if (/capturing per-origin storage/i.test(line)) {
|
|
189
|
+
progress = { phase: "capturing", fraction: 0.42, detail: "Capturing cookies, local storage, and IndexedDB…" };
|
|
190
|
+
} else if (/^\d+\/\d+\s+/.test(line)) {
|
|
191
|
+
const match = line.match(/^(\d+)\/(\d+)\s+(.+)$/);
|
|
192
|
+
if (match) {
|
|
193
|
+
const current = Number(match[1]);
|
|
194
|
+
const total = Number(match[2]);
|
|
195
|
+
progress = {
|
|
196
|
+
phase: "capturing",
|
|
197
|
+
fraction: total > 0 ? 0.42 + 0.42 * Math.min(current / total, 1) : 0.42,
|
|
198
|
+
current,
|
|
199
|
+
total,
|
|
200
|
+
detail: `Capturing ${match[3]}`,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
} else if (/uploading to browserless/i.test(line)) {
|
|
204
|
+
progress = { phase: "uploading", fraction: 0.90, detail: "Uploading the fitted authenticated profile…" };
|
|
205
|
+
}
|
|
206
|
+
if (!progress) continue;
|
|
207
|
+
const identity = `${progress.phase}:${progress.current ?? ""}:${progress.total ?? ""}:${progress.detail}`;
|
|
208
|
+
if (identity === lastPhase) continue;
|
|
209
|
+
lastPhase = identity;
|
|
210
|
+
onProgress(progress);
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
84
215
|
function isMissingProfile(output) {
|
|
85
216
|
return /(?:not found|does not exist|404)/i.test(output);
|
|
86
217
|
}
|
|
@@ -106,3 +237,45 @@ function parseJSON(output) {
|
|
|
106
237
|
function lastLine(output) {
|
|
107
238
|
return output.split("\n").map((line) => line.trim()).filter(Boolean).at(-1)?.replace(/^Error:\s*/, "");
|
|
108
239
|
}
|
|
240
|
+
|
|
241
|
+
function throwForInterruptedRun(result, timeoutMs) {
|
|
242
|
+
if (result?.canceled || result?.status === 130) {
|
|
243
|
+
throw new Error("Browserless upload canceled. Temporary profile data was removed.");
|
|
244
|
+
}
|
|
245
|
+
if (result?.timedOut || result?.status === 124) {
|
|
246
|
+
const minutes = Math.max(1, Math.round(timeoutMs / 60_000));
|
|
247
|
+
throw new Error(`Browserless upload timed out after ${minutes} minute${minutes === 1 ? "" : "s"}. Check the connection, close the source browser, and try again with a smaller domain allowlist.`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function droppedOrigins(output) {
|
|
252
|
+
return Number(output.match(/--auto-fit:\s*dropped\s+(\d+)\s+origin/i)?.[1] || 0);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function failedOrigins(output) {
|
|
256
|
+
return Number(output.match(/!\s+(\d+)\s+origin\(s\) failed to capture/i)?.[1] || 0);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function uploadSummary({ operation, details, fallbackName, counts, output }) {
|
|
260
|
+
const dropped = droppedOrigins(output);
|
|
261
|
+
const failed = failedOrigins(output);
|
|
262
|
+
const warnings = [];
|
|
263
|
+
if (dropped > 0) warnings.push(`${dropped} heavy origin${dropped === 1 ? "" : "s"} omitted to fit Browserless's 2 MB cap`);
|
|
264
|
+
if (failed > 0) warnings.push(`${failed} origin${failed === 1 ? "" : "s"} could not be captured`);
|
|
265
|
+
const warning = warnings.length > 0 ? `; ${warnings.join("; ")}` : "";
|
|
266
|
+
return `Browserless profile ${operation === "refresh" ? "updated" : "created"} and verified: ${details?.name || fallbackName}${counts}${warning}`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function actionableFailure(output) {
|
|
270
|
+
const detail = lastLine(output) || "Browserless profile upload failed.";
|
|
271
|
+
if (/(?:failed to reach|enotfound|econnreset|econnrefused|network|socket hang up|fetch failed)/i.test(output)) {
|
|
272
|
+
return `Browserless could not be reached. Check your internet connection and region, then try again. ${detail}`;
|
|
273
|
+
}
|
|
274
|
+
if (/(?:profile busy|singletonlock|browser.*running|source browser must be closed)/i.test(output)) {
|
|
275
|
+
return `The source browser is still using this profile. Quit it completely, wait a few seconds, and try again. ${detail}`;
|
|
276
|
+
}
|
|
277
|
+
if (/(?:2 MB|too large|artifact.*cap|payload.*large)/i.test(output)) {
|
|
278
|
+
return `The captured state could not fit Browserless's 2 MB profile cap. Add a domain allowlist for the sites you need, then try again. ${detail}`;
|
|
279
|
+
}
|
|
280
|
+
return detail;
|
|
281
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from "./codex-direct-import.js";
|
|
11
11
|
import { readChromiumProfile } from "./chromium-reader.js";
|
|
12
12
|
import { uploadBrowserlessProfile } from "./browserless.js";
|
|
13
|
+
import { inspectBrowserlessProfile } from "./browserless-preflight.js";
|
|
13
14
|
import { installConfig, installRuntime, readConfig, updatePreferences } from "./config.js";
|
|
14
15
|
import {
|
|
15
16
|
braveCookiePaths,
|
|
@@ -46,6 +47,7 @@ Commands:
|
|
|
46
47
|
bootstrap-bundled --app-path /Applications/Browser Cookie Bridge.app
|
|
47
48
|
preferences --source brave --target codex --cookies on --history off --menu-bar on --auto-check-updates on
|
|
48
49
|
sync [--timeout 300] [--allow-cloud-upload]
|
|
50
|
+
browserless-preflight
|
|
49
51
|
doctor
|
|
50
52
|
enable-login-sync
|
|
51
53
|
disable-login-sync
|
|
@@ -55,13 +57,15 @@ Commands:
|
|
|
55
57
|
help
|
|
56
58
|
`;
|
|
57
59
|
|
|
58
|
-
export async function main(argv) {
|
|
60
|
+
export async function main(argv, { signal } = {}) {
|
|
59
61
|
const [command = "help", ...args] = argv;
|
|
60
62
|
switch (command) {
|
|
61
63
|
case "setup":
|
|
62
64
|
return setup(args);
|
|
63
65
|
case "sync":
|
|
64
|
-
return sync(args);
|
|
66
|
+
return sync(args, { signal });
|
|
67
|
+
case "browserless-preflight":
|
|
68
|
+
return browserlessPreflight();
|
|
65
69
|
case "install-app":
|
|
66
70
|
return installDesktopApp(args);
|
|
67
71
|
case "bootstrap-bundled":
|
|
@@ -259,11 +263,11 @@ function setup(args) {
|
|
|
259
263
|
: "Cookie values are transferred in memory and are not written to logs or disk.");
|
|
260
264
|
}
|
|
261
265
|
|
|
262
|
-
async function sync(args) {
|
|
266
|
+
async function sync(args, { signal } = {}) {
|
|
263
267
|
assertMacOS();
|
|
264
|
-
const seconds = integerFlag(args, "--timeout", 300, 5, 3600);
|
|
265
268
|
const config = readConfig();
|
|
266
269
|
const target = config.targetBrowser || "codex";
|
|
270
|
+
const seconds = integerFlag(args, "--timeout", target === "browserless" ? 900 : 300, 5, 3600);
|
|
267
271
|
const isCodexTarget = target === "codex";
|
|
268
272
|
if (target === "browserless") {
|
|
269
273
|
if (!args.includes("--allow-cloud-upload")) {
|
|
@@ -271,6 +275,15 @@ async function sync(args) {
|
|
|
271
275
|
}
|
|
272
276
|
const source = config.sourceBrowser || "brave";
|
|
273
277
|
const local = readChromiumProfile({ browser: source, imports: { cookies: false, history: false } });
|
|
278
|
+
emitBrowserlessProgress({ phase: "preflight", fraction: 0.03, detail: "Inspecting the local profile…" });
|
|
279
|
+
const assessment = inspectBrowserlessProfile({ profilePath: local.profilePath });
|
|
280
|
+
emitBrowserlessProgress({
|
|
281
|
+
phase: "preflight-complete",
|
|
282
|
+
fraction: 0.06,
|
|
283
|
+
detail: assessment.summary,
|
|
284
|
+
assessment,
|
|
285
|
+
});
|
|
286
|
+
console.log(`Profile preflight: ${assessment.summary}`);
|
|
274
287
|
console.log(`Preparing ${source} profile ${local.profileName} for an explicit Browserless cloud upload…`);
|
|
275
288
|
const result = await uploadBrowserlessProfile({
|
|
276
289
|
browser: source,
|
|
@@ -278,7 +291,11 @@ async function sync(args) {
|
|
|
278
291
|
profileName: config.browserless?.profileName || "browser-cookie-bridge",
|
|
279
292
|
region: config.browserless?.region || "sfo",
|
|
280
293
|
onlyDomains: config.browserless?.onlyDomains || [],
|
|
294
|
+
timeoutMs: seconds * 1000,
|
|
295
|
+
signal,
|
|
296
|
+
onProgress: emitBrowserlessProgress,
|
|
281
297
|
});
|
|
298
|
+
emitBrowserlessProgress({ phase: "complete", fraction: 1, detail: result.summary });
|
|
282
299
|
console.log(result.summary);
|
|
283
300
|
return result;
|
|
284
301
|
}
|
|
@@ -322,6 +339,21 @@ async function sync(args) {
|
|
|
322
339
|
return result;
|
|
323
340
|
}
|
|
324
341
|
|
|
342
|
+
function browserlessPreflight() {
|
|
343
|
+
assertMacOS();
|
|
344
|
+
const config = readConfig();
|
|
345
|
+
const source = config.sourceBrowser || "brave";
|
|
346
|
+
if (source === "comet") throw new Error("Comet is not supported by Browserless profile capture yet.");
|
|
347
|
+
const local = readChromiumProfile({ browser: source, imports: { cookies: false, history: false } });
|
|
348
|
+
const assessment = inspectBrowserlessProfile({ profilePath: local.profilePath });
|
|
349
|
+
console.log(JSON.stringify({ browser: source, profileName: local.profileName, ...assessment }));
|
|
350
|
+
return assessment;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function emitBrowserlessProgress(event) {
|
|
354
|
+
console.log(`BCB_PROGRESS ${JSON.stringify(event)}`);
|
|
355
|
+
}
|
|
356
|
+
|
|
325
357
|
export function directCodexSummary(result) {
|
|
326
358
|
const imported = result.imported + result.historyImported;
|
|
327
359
|
const skipped = result.skipped + result.historySkipped;
|