browser-cookie-bridge 1.2.0 → 1.4.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 +14 -7
- 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 +152 -31
- package/macos-app/Sources/BraveCodexSyncApp/SyncModel.swift +339 -15
- package/package.json +2 -2
- package/src/browserless-preflight.js +113 -0
- package/src/browserless.js +184 -11
- package/src/cli.js +40 -6
- package/src/config.js +3 -0
|
@@ -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"),
|
|
@@ -51,6 +77,7 @@ final class SyncModel: ObservableObject {
|
|
|
51
77
|
@Published var openAtLogin = false
|
|
52
78
|
@Published var menuBarEnabled = true
|
|
53
79
|
@Published var autoCheckUpdates = true
|
|
80
|
+
@Published var autoRestartCodex = false
|
|
54
81
|
@Published var isCheckingForUpdates = false
|
|
55
82
|
@Published var isInstallingUpdate = false
|
|
56
83
|
@Published var availableUpdateVersion: String?
|
|
@@ -67,6 +94,11 @@ final class SyncModel: ObservableObject {
|
|
|
67
94
|
@Published var browserlessRegion = "sfo"
|
|
68
95
|
@Published var browserlessOnlyDomains = ""
|
|
69
96
|
@Published var showingBrowserlessSetup = false
|
|
97
|
+
@Published var browserlessAssessment: BrowserlessProfileAssessment?
|
|
98
|
+
@Published var isInspectingBrowserlessProfile = false
|
|
99
|
+
@Published var uploadProgress = 0.0
|
|
100
|
+
@Published var uploadElapsedSeconds = 0
|
|
101
|
+
@Published var uploadCanceling = false
|
|
70
102
|
@Published var primaryStatus = "Ready to sync"
|
|
71
103
|
@Published var secondaryStatus = "Choose what to move, then start a transfer"
|
|
72
104
|
|
|
@@ -80,6 +112,10 @@ final class SyncModel: ObservableObject {
|
|
|
80
112
|
private var updateTimer: Timer?
|
|
81
113
|
private var didCheckAfterLaunch = false
|
|
82
114
|
private var didConsumeUpdateResult = false
|
|
115
|
+
private var assessedBrowserID: String?
|
|
116
|
+
private var activeSyncProcess: Process?
|
|
117
|
+
private var uploadTimer: Timer?
|
|
118
|
+
private var uploadStartedAt: Date?
|
|
83
119
|
|
|
84
120
|
var selectedBrowser: BrowserChoice {
|
|
85
121
|
browsers.first(where: { $0.id == selectedSourceID }) ?? browsers[0]
|
|
@@ -93,11 +129,16 @@ final class SyncModel: ObservableObject {
|
|
|
93
129
|
var targetName: String {
|
|
94
130
|
isBrowserlessTarget ? "Browserless Cloud" : selectedTargetBrowser?.name ?? "ChatGPT Codex"
|
|
95
131
|
}
|
|
96
|
-
var codexBlocked: Bool { selectedTargetID == "codex" && codexRunning }
|
|
132
|
+
var codexBlocked: Bool { selectedTargetID == "codex" && codexRunning && !autoRestartCodex }
|
|
97
133
|
var browserlessBlocked: Bool {
|
|
98
134
|
isBrowserlessTarget && (!browserlessConfigured || sourceBrowserRunning || selectedSourceID == "comet")
|
|
99
135
|
}
|
|
100
136
|
var syncBlocked: Bool { codexBlocked || browserlessBlocked }
|
|
137
|
+
var formattedUploadElapsed: String {
|
|
138
|
+
let minutes = uploadElapsedSeconds / 60
|
|
139
|
+
let seconds = uploadElapsedSeconds % 60
|
|
140
|
+
return String(format: "%d:%02d", minutes, seconds)
|
|
141
|
+
}
|
|
101
142
|
var sourceIcon: NSImage { browserIcon(selectedBrowser) }
|
|
102
143
|
var targetIcon: NSImage {
|
|
103
144
|
isBrowserlessTarget ? browserlessIcon : selectedTargetBrowser.map(browserIcon) ?? codexIcon
|
|
@@ -141,6 +182,7 @@ final class SyncModel: ObservableObject {
|
|
|
141
182
|
let calendar = Calendar.current
|
|
142
183
|
scheduleTime = calendar.date(bySettingHour: 9, minute: 0, second: 0, of: Date()) ?? Date()
|
|
143
184
|
updateEndpointRunningStatus()
|
|
185
|
+
refreshBrowserlessPreflight()
|
|
144
186
|
endpointStatusTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
|
|
145
187
|
Task { @MainActor in self?.updateEndpointRunningStatus() }
|
|
146
188
|
}
|
|
@@ -220,6 +262,7 @@ final class SyncModel: ObservableObject {
|
|
|
220
262
|
historyEnabled = config.imports?.history ?? false
|
|
221
263
|
menuBarEnabled = config.ui?.menuBar ?? true
|
|
222
264
|
autoCheckUpdates = config.ui?.autoCheckUpdates ?? true
|
|
265
|
+
autoRestartCodex = config.ui?.autoRestartCodex ?? false
|
|
223
266
|
browserlessProfileName = config.browserless?.profileName ?? "browser-cookie-bridge"
|
|
224
267
|
browserlessRegion = config.browserless?.region ?? "sfo"
|
|
225
268
|
browserlessOnlyDomains = (config.browserless?.onlyDomains ?? []).joined(separator: ", ")
|
|
@@ -230,6 +273,7 @@ final class SyncModel: ObservableObject {
|
|
|
230
273
|
FileManager.default.fileExists(atPath: support.appending(path: "extension-\($0)/manifest.json").path)
|
|
231
274
|
}
|
|
232
275
|
updateEndpointRunningStatus()
|
|
276
|
+
refreshBrowserlessPreflight()
|
|
233
277
|
consumeUpdateResultIfNeeded()
|
|
234
278
|
if autoCheckUpdates && !didCheckAfterLaunch {
|
|
235
279
|
didCheckAfterLaunch = true
|
|
@@ -240,6 +284,8 @@ final class SyncModel: ObservableObject {
|
|
|
240
284
|
func selectSource(_ id: String) {
|
|
241
285
|
guard browsers.contains(where: { $0.id == id }), id != selectedSourceID, id != selectedTargetID else { return }
|
|
242
286
|
selectedSourceID = id
|
|
287
|
+
browserlessAssessment = nil
|
|
288
|
+
assessedBrowserID = nil
|
|
243
289
|
persistPreferences(successMessage: "Export source changed to \(selectedBrowser.name)")
|
|
244
290
|
}
|
|
245
291
|
|
|
@@ -250,6 +296,7 @@ final class SyncModel: ObservableObject {
|
|
|
250
296
|
persistPreferences(successMessage: "Import destination changed to \(targetName)")
|
|
251
297
|
updateEndpointRunningStatus()
|
|
252
298
|
if id == "browserless" && !browserlessConfigured { showingBrowserlessSetup = true }
|
|
299
|
+
if id == "browserless" { refreshBrowserlessPreflight() }
|
|
253
300
|
}
|
|
254
301
|
|
|
255
302
|
func setCookiesEnabled(_ enabled: Bool) {
|
|
@@ -277,6 +324,7 @@ final class SyncModel: ObservableObject {
|
|
|
277
324
|
browserlessOnlyDomains = onlyDomains
|
|
278
325
|
showingBrowserlessSetup = false
|
|
279
326
|
persistPreferences(successMessage: "Browserless connected — uploads remain manual")
|
|
327
|
+
refreshBrowserlessPreflight(force: true)
|
|
280
328
|
} catch {
|
|
281
329
|
postNativeAlert(title: "Could not save Browserless token", message: error.localizedDescription, kind: .error)
|
|
282
330
|
}
|
|
@@ -301,6 +349,11 @@ final class SyncModel: ObservableObject {
|
|
|
301
349
|
if enabled { checkForUpdates() }
|
|
302
350
|
}
|
|
303
351
|
|
|
352
|
+
func setAutoRestartCodex(_ enabled: Bool) {
|
|
353
|
+
autoRestartCodex = enabled
|
|
354
|
+
persistPreferences(successMessage: enabled ? "Automatic Codex restart enabled" : "Automatic Codex restart disabled")
|
|
355
|
+
}
|
|
356
|
+
|
|
304
357
|
func checkForUpdates(showAlert: Bool = false) {
|
|
305
358
|
guard !isCheckingForUpdates, !isInstallingUpdate else { return }
|
|
306
359
|
guard let url = URL(string: "https://api.github.com/repos/apoorvdarshan/browser-cookie-bridge/releases/latest") else { return }
|
|
@@ -387,6 +440,10 @@ final class SyncModel: ObservableObject {
|
|
|
387
440
|
|
|
388
441
|
func syncNow(showMenuBarAlert: Bool = false) {
|
|
389
442
|
guard !isSyncing else {
|
|
443
|
+
if isBrowserlessTarget {
|
|
444
|
+
cancelSync()
|
|
445
|
+
return
|
|
446
|
+
}
|
|
390
447
|
if showMenuBarAlert {
|
|
391
448
|
postNativeAlert(title: "Sync already running", message: "Wait for the current transfer to finish.", kind: .information)
|
|
392
449
|
}
|
|
@@ -399,7 +456,70 @@ final class SyncModel: ObservableObject {
|
|
|
399
456
|
}
|
|
400
457
|
return
|
|
401
458
|
}
|
|
459
|
+
if selectedTargetID == "codex" && codexRunning && autoRestartCodex {
|
|
460
|
+
forceQuitCodexThenSync(showMenuBarAlert: showMenuBarAlert)
|
|
461
|
+
return
|
|
462
|
+
}
|
|
463
|
+
startSync(showMenuBarAlert: showMenuBarAlert, reopenCodexOnSuccess: false)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private func forceQuitCodexThenSync(showMenuBarAlert: Bool) {
|
|
467
|
+
isSyncing = true
|
|
468
|
+
uploadCanceling = false
|
|
469
|
+
state = .syncing
|
|
470
|
+
primaryStatus = "Closing Codex for sync"
|
|
471
|
+
secondaryStatus = "Force quitting ChatGPT Codex and waiting for its browser database to close…"
|
|
472
|
+
let applications = NSWorkspace.shared.runningApplications.filter {
|
|
473
|
+
$0.bundleIdentifier == "com.openai.codex"
|
|
474
|
+
}
|
|
475
|
+
guard !applications.isEmpty, applications.allSatisfy({ $0.forceTerminate() }) else {
|
|
476
|
+
finishCodexPreparationFailure(
|
|
477
|
+
message: "macOS could not force quit ChatGPT Codex. Quit it manually, then try again.",
|
|
478
|
+
showMenuBarAlert: showMenuBarAlert
|
|
479
|
+
)
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
waitForCodexToQuit(attemptsRemaining: 50, showMenuBarAlert: showMenuBarAlert)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
private func waitForCodexToQuit(attemptsRemaining: Int, showMenuBarAlert: Bool) {
|
|
486
|
+
let stillRunning = NSWorkspace.shared.runningApplications.contains {
|
|
487
|
+
$0.bundleIdentifier == "com.openai.codex"
|
|
488
|
+
}
|
|
489
|
+
if !stillRunning {
|
|
490
|
+
codexRunning = false
|
|
491
|
+
secondaryStatus = "Codex is closed — waiting briefly for its database to be released…"
|
|
492
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in
|
|
493
|
+
self?.startSync(showMenuBarAlert: showMenuBarAlert, reopenCodexOnSuccess: true)
|
|
494
|
+
}
|
|
495
|
+
return
|
|
496
|
+
}
|
|
497
|
+
guard attemptsRemaining > 0 else {
|
|
498
|
+
finishCodexPreparationFailure(
|
|
499
|
+
message: "ChatGPT Codex did not close within 10 seconds. Quit it manually, then try again.",
|
|
500
|
+
showMenuBarAlert: showMenuBarAlert
|
|
501
|
+
)
|
|
502
|
+
return
|
|
503
|
+
}
|
|
504
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
|
|
505
|
+
self?.waitForCodexToQuit(attemptsRemaining: attemptsRemaining - 1, showMenuBarAlert: showMenuBarAlert)
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
private func finishCodexPreparationFailure(message: String, showMenuBarAlert: Bool) {
|
|
510
|
+
isSyncing = false
|
|
511
|
+
state = .error
|
|
512
|
+
primaryStatus = "Could not close Codex"
|
|
513
|
+
secondaryStatus = message
|
|
514
|
+
updateEndpointRunningStatus()
|
|
515
|
+
if showMenuBarAlert {
|
|
516
|
+
postNativeAlert(title: primaryStatus, message: secondaryStatus, kind: .error)
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private func startSync(showMenuBarAlert: Bool, reopenCodexOnSuccess: Bool) {
|
|
402
521
|
isSyncing = true
|
|
522
|
+
uploadCanceling = false
|
|
403
523
|
state = .syncing
|
|
404
524
|
primaryStatus = isBrowserlessTarget ? "Uploading authenticated state" : "Transferring selected data"
|
|
405
525
|
secondaryStatus = selectedTargetID == "codex"
|
|
@@ -408,7 +528,7 @@ final class SyncModel: ObservableObject {
|
|
|
408
528
|
? "Sending \(selectedBrowser.name) to Browserless \(browserlessRegion.uppercased()) only for this request…"
|
|
409
529
|
: "Waiting for \(selectedBrowser.name) and \(targetName)…"
|
|
410
530
|
var environment: [String: String] = [:]
|
|
411
|
-
var arguments = ["sync", "--timeout", "300"]
|
|
531
|
+
var arguments = ["sync", "--timeout", isBrowserlessTarget ? "900" : "300"]
|
|
412
532
|
if isBrowserlessTarget {
|
|
413
533
|
guard let token = BrowserlessCredentialStore.read() else {
|
|
414
534
|
isSyncing = false
|
|
@@ -418,15 +538,30 @@ final class SyncModel: ObservableObject {
|
|
|
418
538
|
}
|
|
419
539
|
environment["BROWSERLESS_TOKEN"] = token
|
|
420
540
|
arguments.append("--allow-cloud-upload")
|
|
541
|
+
beginUploadTracking()
|
|
421
542
|
}
|
|
422
|
-
runCLI(arguments, environment: environment
|
|
543
|
+
activeSyncProcess = runCLI(arguments, environment: environment, onLine: { [weak self] line in
|
|
544
|
+
self?.handleBrowserlessProgress(line)
|
|
545
|
+
}) { [weak self] success, output in
|
|
423
546
|
guard let self else { return }
|
|
547
|
+
self.activeSyncProcess = nil
|
|
424
548
|
self.isSyncing = false
|
|
425
|
-
|
|
426
|
-
|
|
549
|
+
self.finishUploadTracking()
|
|
550
|
+
let partial = success && (
|
|
551
|
+
output.contains("Partially synced:")
|
|
552
|
+
|| output.contains("with warnings")
|
|
553
|
+
|| output.contains("omitted to fit")
|
|
554
|
+
|| output.contains("could not be captured")
|
|
555
|
+
)
|
|
556
|
+
let canceled = output.contains("Browserless upload canceled") || output.contains("Temporary profile data was removed")
|
|
557
|
+
if canceled {
|
|
558
|
+
self.state = .canceled
|
|
559
|
+
self.primaryStatus = "Browserless upload canceled"
|
|
560
|
+
self.secondaryStatus = "No cloud profile was changed; temporary profile data was removed"
|
|
561
|
+
} else if success {
|
|
427
562
|
self.state = partial ? .warning : .success
|
|
428
563
|
self.primaryStatus = self.isBrowserlessTarget
|
|
429
|
-
? "Browserless profile uploaded"
|
|
564
|
+
? (partial ? "Browserless profile uploaded with omissions" : "Browserless profile uploaded")
|
|
430
565
|
: self.selectedTargetID == "codex"
|
|
431
566
|
? (partial ? "Codex sync completed with warnings" : "Codex sessions updated")
|
|
432
567
|
: (partial ? "Partially synced" : "Transfer complete")
|
|
@@ -441,16 +576,88 @@ final class SyncModel: ObservableObject {
|
|
|
441
576
|
: "Keep both browsers open and check the extensions")
|
|
442
577
|
}
|
|
443
578
|
self.updateEndpointRunningStatus()
|
|
444
|
-
if
|
|
579
|
+
if success && reopenCodexOnSuccess {
|
|
580
|
+
self.reopenCodexAfterSuccessfulSync(
|
|
581
|
+
partial: partial,
|
|
582
|
+
syncSummary: self.secondaryStatus,
|
|
583
|
+
showMenuBarAlert: showMenuBarAlert
|
|
584
|
+
)
|
|
585
|
+
} else if showMenuBarAlert {
|
|
445
586
|
self.postNativeAlert(
|
|
446
587
|
title: self.primaryStatus,
|
|
447
588
|
message: self.secondaryStatus,
|
|
448
|
-
kind: success ? (partial ? .warning : .information) : .error
|
|
589
|
+
kind: canceled ? .information : success ? (partial ? .warning : .information) : .error
|
|
449
590
|
)
|
|
450
591
|
}
|
|
451
592
|
}
|
|
452
593
|
}
|
|
453
594
|
|
|
595
|
+
private func reopenCodexAfterSuccessfulSync(partial: Bool, syncSummary: String, showMenuBarAlert: Bool) {
|
|
596
|
+
let transferResult = syncSummary.replacingOccurrences(
|
|
597
|
+
of: "Reopen Codex to use the updated sessions. ",
|
|
598
|
+
with: ""
|
|
599
|
+
)
|
|
600
|
+
guard let codexURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.openai.codex") else {
|
|
601
|
+
state = .warning
|
|
602
|
+
primaryStatus = "Codex sessions updated, but Codex was not reopened"
|
|
603
|
+
secondaryStatus = "\(transferResult)\n\nCodex could not be found. Open it manually to use the updated sessions."
|
|
604
|
+
if showMenuBarAlert {
|
|
605
|
+
postNativeAlert(title: primaryStatus, message: secondaryStatus, kind: .warning)
|
|
606
|
+
}
|
|
607
|
+
return
|
|
608
|
+
}
|
|
609
|
+
NSWorkspace.shared.openApplication(at: codexURL, configuration: NSWorkspace.OpenConfiguration()) { [weak self] _, error in
|
|
610
|
+
Task { @MainActor in
|
|
611
|
+
guard let self else { return }
|
|
612
|
+
if let error {
|
|
613
|
+
self.state = .warning
|
|
614
|
+
self.primaryStatus = "Codex sessions updated, but Codex was not reopened"
|
|
615
|
+
self.secondaryStatus = "\(transferResult)\n\nCodex could not be reopened: \(error.localizedDescription)"
|
|
616
|
+
} else {
|
|
617
|
+
self.codexRunning = true
|
|
618
|
+
self.state = partial ? .warning : .success
|
|
619
|
+
self.primaryStatus = partial ? "Codex sync completed with warnings" : "Codex sessions updated"
|
|
620
|
+
self.secondaryStatus = "\(transferResult)\n\nChatGPT Codex reopened successfully."
|
|
621
|
+
}
|
|
622
|
+
if showMenuBarAlert {
|
|
623
|
+
self.postNativeAlert(
|
|
624
|
+
title: self.primaryStatus,
|
|
625
|
+
message: self.secondaryStatus,
|
|
626
|
+
kind: self.state == .success ? .information : .warning
|
|
627
|
+
)
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
func cancelSync() {
|
|
634
|
+
guard isBrowserlessTarget, isSyncing, !uploadCanceling else { return }
|
|
635
|
+
uploadCanceling = true
|
|
636
|
+
primaryStatus = "Canceling Browserless upload"
|
|
637
|
+
secondaryStatus = "Stopping the temporary browser and removing its isolated workspace…"
|
|
638
|
+
postSyncState()
|
|
639
|
+
activeSyncProcess?.terminate()
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
func refreshBrowserlessPreflight(force: Bool = false) {
|
|
643
|
+
guard isBrowserlessTarget, selectedSourceID != "comet", !isInspectingBrowserlessProfile else { return }
|
|
644
|
+
if !force, assessedBrowserID == selectedSourceID, browserlessAssessment != nil { return }
|
|
645
|
+
isInspectingBrowserlessProfile = true
|
|
646
|
+
let sourceAtStart = selectedSourceID
|
|
647
|
+
runCLI(["browserless-preflight"]) { [weak self] success, output in
|
|
648
|
+
guard let self else { return }
|
|
649
|
+
self.isInspectingBrowserlessProfile = false
|
|
650
|
+
guard self.selectedSourceID == sourceAtStart else { return }
|
|
651
|
+
if success, let assessment = self.decodeLastJSON(BrowserlessProfileAssessment.self, from: output) {
|
|
652
|
+
self.browserlessAssessment = assessment
|
|
653
|
+
self.assessedBrowserID = sourceAtStart
|
|
654
|
+
} else if force {
|
|
655
|
+
self.browserlessAssessment = nil
|
|
656
|
+
self.assessedBrowserID = nil
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
454
661
|
func setDailyEnabled(_ enabled: Bool) {
|
|
455
662
|
guard !isBrowserlessTarget else {
|
|
456
663
|
postNativeAlert(title: "Cloud uploads are manual-only", message: "Browser Cookie Bridge will never schedule Browserless uploads in the background.", kind: .information)
|
|
@@ -533,6 +740,7 @@ final class SyncModel: ObservableObject {
|
|
|
533
740
|
"--history", historyEnabled ? "on" : "off",
|
|
534
741
|
"--menu-bar", menuBarEnabled ? "on" : "off",
|
|
535
742
|
"--auto-check-updates", autoCheckUpdates ? "on" : "off",
|
|
743
|
+
"--auto-restart-codex", autoRestartCodex ? "on" : "off",
|
|
536
744
|
"--browserless-profile", browserlessProfileName,
|
|
537
745
|
"--browserless-region", browserlessRegion,
|
|
538
746
|
"--browserless-domains", browserlessOnlyDomains,
|
|
@@ -556,6 +764,7 @@ final class SyncModel: ObservableObject {
|
|
|
556
764
|
FileManager.default.fileExists(atPath: self.support.appending(path: "extension-\($0)/manifest.json").path)
|
|
557
765
|
}
|
|
558
766
|
self.updateEndpointRunningStatus()
|
|
767
|
+
self.refreshBrowserlessPreflight()
|
|
559
768
|
}
|
|
560
769
|
}
|
|
561
770
|
|
|
@@ -584,27 +793,48 @@ final class SyncModel: ObservableObject {
|
|
|
584
793
|
}
|
|
585
794
|
}
|
|
586
795
|
|
|
587
|
-
|
|
796
|
+
@discardableResult
|
|
797
|
+
private func runCLI(
|
|
798
|
+
_ arguments: [String],
|
|
799
|
+
environment: [String: String] = [:],
|
|
800
|
+
onLine: (@MainActor (String) -> Void)? = nil,
|
|
801
|
+
completion: @escaping @MainActor (Bool, String) -> Void
|
|
802
|
+
) -> Process? {
|
|
588
803
|
guard let config = loadConfig() else {
|
|
589
804
|
completion(false, "Configuration missing. Run install-app again.")
|
|
590
|
-
return
|
|
805
|
+
return nil
|
|
591
806
|
}
|
|
592
807
|
let process = Process()
|
|
593
808
|
let output = Pipe()
|
|
809
|
+
let collector = ProcessOutputCollector()
|
|
594
810
|
process.executableURL = URL(fileURLWithPath: config.nodePath)
|
|
595
811
|
process.arguments = [runtimeCLI.path] + arguments
|
|
596
812
|
process.environment = ProcessInfo.processInfo.environment.merging(environment) { _, new in new }
|
|
597
813
|
process.standardOutput = output
|
|
598
814
|
process.standardError = output
|
|
815
|
+
output.fileHandleForReading.readabilityHandler = { handle in
|
|
816
|
+
let data = handle.availableData
|
|
817
|
+
guard !data.isEmpty else { return }
|
|
818
|
+
let lines = collector.append(data)
|
|
819
|
+
guard let onLine, !lines.isEmpty else { return }
|
|
820
|
+
Task { @MainActor in lines.forEach(onLine) }
|
|
821
|
+
}
|
|
599
822
|
process.terminationHandler = { process in
|
|
600
|
-
|
|
601
|
-
let
|
|
602
|
-
|
|
823
|
+
output.fileHandleForReading.readabilityHandler = nil
|
|
824
|
+
let remainder = output.fileHandleForReading.readDataToEndOfFile()
|
|
825
|
+
let lines = collector.append(remainder, finish: true)
|
|
826
|
+
let text = collector.text
|
|
827
|
+
Task { @MainActor in
|
|
828
|
+
if let onLine { lines.forEach(onLine) }
|
|
829
|
+
completion(process.terminationStatus == 0, text)
|
|
830
|
+
}
|
|
603
831
|
}
|
|
604
832
|
do {
|
|
605
833
|
try process.run()
|
|
834
|
+
return process
|
|
606
835
|
} catch {
|
|
607
836
|
completion(false, error.localizedDescription)
|
|
837
|
+
return nil
|
|
608
838
|
}
|
|
609
839
|
}
|
|
610
840
|
|
|
@@ -631,6 +861,69 @@ final class SyncModel: ObservableObject {
|
|
|
631
861
|
return line.hasPrefix("Error: ") ? String(line.dropFirst(7)) : line
|
|
632
862
|
}
|
|
633
863
|
|
|
864
|
+
private func decodeLastJSON<T: Decodable>(_ type: T.Type, from output: String) -> T? {
|
|
865
|
+
for line in output.split(separator: "\n").reversed() {
|
|
866
|
+
guard let data = String(line).data(using: .utf8),
|
|
867
|
+
let value = try? JSONDecoder().decode(type, from: data) else { continue }
|
|
868
|
+
return value
|
|
869
|
+
}
|
|
870
|
+
return nil
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
private func beginUploadTracking() {
|
|
874
|
+
uploadProgress = 0.01
|
|
875
|
+
uploadElapsedSeconds = 0
|
|
876
|
+
uploadStartedAt = Date()
|
|
877
|
+
uploadTimer?.invalidate()
|
|
878
|
+
uploadTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
|
879
|
+
Task { @MainActor in
|
|
880
|
+
guard let self, let started = self.uploadStartedAt else { return }
|
|
881
|
+
self.uploadElapsedSeconds = max(0, Int(Date().timeIntervalSince(started)))
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
postSyncState()
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
private func finishUploadTracking() {
|
|
888
|
+
uploadTimer?.invalidate()
|
|
889
|
+
uploadTimer = nil
|
|
890
|
+
uploadStartedAt = nil
|
|
891
|
+
uploadCanceling = false
|
|
892
|
+
postSyncState()
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
private func handleBrowserlessProgress(_ line: String) {
|
|
896
|
+
guard line.hasPrefix("BCB_PROGRESS "),
|
|
897
|
+
let data = String(line.dropFirst("BCB_PROGRESS ".count)).data(using: .utf8),
|
|
898
|
+
let event = try? JSONDecoder().decode(BrowserlessProgressEvent.self, from: data) else { return }
|
|
899
|
+
if let fraction = event.fraction { uploadProgress = min(max(fraction, uploadProgress), 1) }
|
|
900
|
+
if let assessment = event.assessment {
|
|
901
|
+
browserlessAssessment = assessment
|
|
902
|
+
assessedBrowserID = selectedSourceID
|
|
903
|
+
}
|
|
904
|
+
guard !uploadCanceling else { return }
|
|
905
|
+
primaryStatus = switch event.phase {
|
|
906
|
+
case "preflight": "Inspecting the local profile"
|
|
907
|
+
case "preflight-complete": "Profile preflight complete"
|
|
908
|
+
case "validating": "Checking Browserless profile"
|
|
909
|
+
case "copying": "Preparing an isolated profile copy"
|
|
910
|
+
case "launching", "waiting": "Starting the temporary browser"
|
|
911
|
+
case "capturing": "Capturing authenticated state"
|
|
912
|
+
case "uploading": "Uploading fitted profile state"
|
|
913
|
+
case "verifying": "Verifying the Browserless profile"
|
|
914
|
+
case "complete": "Browserless profile uploaded"
|
|
915
|
+
default: "Uploading authenticated state"
|
|
916
|
+
}
|
|
917
|
+
if let detail = event.detail { secondaryStatus = detail }
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
private func postSyncState() {
|
|
921
|
+
NotificationCenter.default.post(
|
|
922
|
+
name: .syncStateChanged,
|
|
923
|
+
object: SyncMenuState(uploading: isBrowserlessTarget && isSyncing, canceling: uploadCanceling)
|
|
924
|
+
)
|
|
925
|
+
}
|
|
926
|
+
|
|
634
927
|
private var formattedTime: String {
|
|
635
928
|
scheduleTime.formatted(date: .omitted, time: .shortened)
|
|
636
929
|
}
|
|
@@ -648,7 +941,13 @@ final class SyncModel: ObservableObject {
|
|
|
648
941
|
$0.bundleIdentifier == selectedBrowser.bundleIdentifier
|
|
649
942
|
}
|
|
650
943
|
guard !isSyncing else { return }
|
|
651
|
-
if selectedTargetID == "codex" && codexRunning {
|
|
944
|
+
if selectedTargetID == "codex" && codexRunning && autoRestartCodex {
|
|
945
|
+
if state == .ready || primaryStatus == "Quit Codex before syncing" {
|
|
946
|
+
state = .ready
|
|
947
|
+
primaryStatus = "Ready to sync and restart Codex"
|
|
948
|
+
secondaryStatus = "Sync will force quit Codex and reopen it only after a successful transfer"
|
|
949
|
+
}
|
|
950
|
+
} else if selectedTargetID == "codex" && codexRunning {
|
|
652
951
|
state = .warning
|
|
653
952
|
primaryStatus = "Quit Codex before syncing"
|
|
654
953
|
secondaryStatus = "Close ChatGPT Codex completely so its local cookie database can be updated safely"
|
|
@@ -769,6 +1068,7 @@ private struct AppConfig: Decodable {
|
|
|
769
1068
|
let menuBar: Bool?
|
|
770
1069
|
let openAtLogin: Bool?
|
|
771
1070
|
let autoCheckUpdates: Bool?
|
|
1071
|
+
let autoRestartCodex: Bool?
|
|
772
1072
|
}
|
|
773
1073
|
|
|
774
1074
|
struct BrowserlessSettings: Decodable {
|
|
@@ -827,3 +1127,27 @@ private enum BrowserlessCredentialStore {
|
|
|
827
1127
|
SecItemDelete(query as CFDictionary)
|
|
828
1128
|
}
|
|
829
1129
|
}
|
|
1130
|
+
|
|
1131
|
+
private final class ProcessOutputCollector: @unchecked Sendable {
|
|
1132
|
+
private let lock = NSLock()
|
|
1133
|
+
private var bytes = Data()
|
|
1134
|
+
private var pending = ""
|
|
1135
|
+
|
|
1136
|
+
var text: String {
|
|
1137
|
+
lock.withLock { String(decoding: bytes, as: UTF8.self) }
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
func append(_ data: Data, finish: Bool = false) -> [String] {
|
|
1141
|
+
lock.withLock {
|
|
1142
|
+
bytes.append(data)
|
|
1143
|
+
pending += String(decoding: data, as: UTF8.self)
|
|
1144
|
+
var lines = pending.components(separatedBy: .newlines)
|
|
1145
|
+
if finish {
|
|
1146
|
+
pending = ""
|
|
1147
|
+
return lines.filter { !$0.isEmpty }
|
|
1148
|
+
}
|
|
1149
|
+
pending = lines.popLast() ?? ""
|
|
1150
|
+
return lines.filter { !$0.isEmpty }
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "browser-cookie-bridge",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.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
|
+
}
|