browser-cookie-bridge 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -31
- package/THIRD_PARTY_NOTICES.md +11 -0
- package/extension-template/manifest.json +1 -1
- package/macos-app/Info.plist +3 -3
- package/macos-app/Resources/BrowserIcons/browserless.svg +6 -0
- package/macos-app/Sources/BraveCodexSyncApp/BraveCodexSyncApp.swift +222 -43
- package/macos-app/Sources/BraveCodexSyncApp/SyncModel.swift +247 -27
- package/package.json +23 -7
- package/src/browserless-runner.js +13 -0
- package/src/browserless.js +108 -0
- package/src/cli.js +109 -4
- package/src/config.js +49 -14
- package/src/paths.js +1 -1
- package/src/updater.js +89 -27
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import AppKit
|
|
2
2
|
import Foundation
|
|
3
|
+
import Security
|
|
3
4
|
|
|
4
5
|
extension Notification.Name {
|
|
5
6
|
static let menuBarVisibilityChanged = Notification.Name("BraveCodexSync.menuBarVisibilityChanged")
|
|
@@ -60,6 +61,12 @@ final class SyncModel: ObservableObject {
|
|
|
60
61
|
@Published var selectedSourceID = "brave"
|
|
61
62
|
@Published var selectedTargetID = "codex"
|
|
62
63
|
@Published var codexRunning = false
|
|
64
|
+
@Published var sourceBrowserRunning = false
|
|
65
|
+
@Published var browserlessConfigured = false
|
|
66
|
+
@Published var browserlessProfileName = "browser-cookie-bridge"
|
|
67
|
+
@Published var browserlessRegion = "sfo"
|
|
68
|
+
@Published var browserlessOnlyDomains = ""
|
|
69
|
+
@Published var showingBrowserlessSetup = false
|
|
63
70
|
@Published var primaryStatus = "Ready to sync"
|
|
64
71
|
@Published var secondaryStatus = "Choose what to move, then start a transfer"
|
|
65
72
|
|
|
@@ -69,7 +76,7 @@ final class SyncModel: ObservableObject {
|
|
|
69
76
|
private var launchAgent: URL { home.appending(path: "Library/LaunchAgents/com.apoorvdarshan.brave-codex-cookie-sync.plist") }
|
|
70
77
|
private var loginSyncAgent: URL { home.appending(path: "Library/LaunchAgents/com.apoorvdarshan.brave-codex-cookie-sync.login-sync.plist") }
|
|
71
78
|
private var appLoginAgent: URL { home.appending(path: "Library/LaunchAgents/com.apoorvdarshan.brave-codex-cookie-sync.app-login.plist") }
|
|
72
|
-
private var
|
|
79
|
+
private var endpointStatusTimer: Timer?
|
|
73
80
|
private var updateTimer: Timer?
|
|
74
81
|
private var didCheckAfterLaunch = false
|
|
75
82
|
private var didConsumeUpdateResult = false
|
|
@@ -82,10 +89,24 @@ final class SyncModel: ObservableObject {
|
|
|
82
89
|
browsers.first(where: { $0.id == selectedTargetID })
|
|
83
90
|
}
|
|
84
91
|
|
|
85
|
-
var
|
|
92
|
+
var isBrowserlessTarget: Bool { selectedTargetID == "browserless" }
|
|
93
|
+
var targetName: String {
|
|
94
|
+
isBrowserlessTarget ? "Browserless Cloud" : selectedTargetBrowser?.name ?? "ChatGPT Codex"
|
|
95
|
+
}
|
|
86
96
|
var codexBlocked: Bool { selectedTargetID == "codex" && codexRunning }
|
|
97
|
+
var browserlessBlocked: Bool {
|
|
98
|
+
isBrowserlessTarget && (!browserlessConfigured || sourceBrowserRunning || selectedSourceID == "comet")
|
|
99
|
+
}
|
|
100
|
+
var syncBlocked: Bool { codexBlocked || browserlessBlocked }
|
|
87
101
|
var sourceIcon: NSImage { browserIcon(selectedBrowser) }
|
|
88
|
-
var targetIcon: NSImage {
|
|
102
|
+
var targetIcon: NSImage {
|
|
103
|
+
isBrowserlessTarget ? browserlessIcon : selectedTargetBrowser.map(browserIcon) ?? codexIcon
|
|
104
|
+
}
|
|
105
|
+
var browserlessIcon: NSImage {
|
|
106
|
+
bundledIcon("browserless")
|
|
107
|
+
?? NSImage(systemSymbolName: "cloud.fill", accessibilityDescription: "Browserless Cloud")
|
|
108
|
+
?? NSImage()
|
|
109
|
+
}
|
|
89
110
|
var codexIcon: NSImage {
|
|
90
111
|
bundledIcon("chatgpt-codex")
|
|
91
112
|
?? chatGPTResource("app.icns")
|
|
@@ -116,11 +137,12 @@ final class SyncModel: ObservableObject {
|
|
|
116
137
|
}
|
|
117
138
|
|
|
118
139
|
init() {
|
|
140
|
+
bootstrapBundledRuntimeIfNeeded()
|
|
119
141
|
let calendar = Calendar.current
|
|
120
142
|
scheduleTime = calendar.date(bySettingHour: 9, minute: 0, second: 0, of: Date()) ?? Date()
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
Task { @MainActor in self?.
|
|
143
|
+
updateEndpointRunningStatus()
|
|
144
|
+
endpointStatusTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
|
|
145
|
+
Task { @MainActor in self?.updateEndpointRunningStatus() }
|
|
124
146
|
}
|
|
125
147
|
updateTimer = Timer.scheduledTimer(withTimeInterval: 24 * 60 * 60, repeats: true) { [weak self] _ in
|
|
126
148
|
Task { @MainActor in
|
|
@@ -130,6 +152,51 @@ final class SyncModel: ObservableObject {
|
|
|
130
152
|
}
|
|
131
153
|
}
|
|
132
154
|
|
|
155
|
+
private func bootstrapBundledRuntimeIfNeeded() {
|
|
156
|
+
guard let resources = Bundle.main.resourceURL else { return }
|
|
157
|
+
let bundledRuntime = resources.appending(path: "runtime")
|
|
158
|
+
let bundledNode = bundledRuntime.appending(path: "node/bin/node")
|
|
159
|
+
let bundledCLI = bundledRuntime.appending(path: "bin/brave-codex-cookie-sync.js")
|
|
160
|
+
guard FileManager.default.isExecutableFile(atPath: bundledNode.path),
|
|
161
|
+
FileManager.default.fileExists(atPath: bundledCLI.path) else { return }
|
|
162
|
+
|
|
163
|
+
if Bundle.main.bundlePath.hasPrefix("/Volumes/") {
|
|
164
|
+
state = .warning
|
|
165
|
+
primaryStatus = "Move the app to Applications"
|
|
166
|
+
secondaryStatus = "Drag Browser Cookie Bridge onto Applications in the DMG window, then open it there"
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let process = Process()
|
|
171
|
+
let output = Pipe()
|
|
172
|
+
process.executableURL = bundledNode
|
|
173
|
+
process.arguments = [
|
|
174
|
+
bundledCLI.path,
|
|
175
|
+
"bootstrap-bundled",
|
|
176
|
+
"--app-path", Bundle.main.bundlePath,
|
|
177
|
+
]
|
|
178
|
+
process.standardOutput = output
|
|
179
|
+
process.standardError = output
|
|
180
|
+
|
|
181
|
+
do {
|
|
182
|
+
try process.run()
|
|
183
|
+
process.waitUntilExit()
|
|
184
|
+
guard process.terminationStatus != 0 else { return }
|
|
185
|
+
let data = output.fileHandleForReading.readDataToEndOfFile()
|
|
186
|
+
let message = String(decoding: data, as: UTF8.self)
|
|
187
|
+
.split(separator: "\n")
|
|
188
|
+
.map(String.init)
|
|
189
|
+
.last(where: { !$0.isEmpty })
|
|
190
|
+
state = .error
|
|
191
|
+
primaryStatus = "Could not prepare the local runtime"
|
|
192
|
+
secondaryStatus = message ?? "Move the app to Applications and reopen it"
|
|
193
|
+
} catch {
|
|
194
|
+
state = .error
|
|
195
|
+
primaryStatus = "Could not prepare the local runtime"
|
|
196
|
+
secondaryStatus = error.localizedDescription
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
133
200
|
func refresh() {
|
|
134
201
|
dailyEnabled = FileManager.default.fileExists(atPath: launchAgent.path)
|
|
135
202
|
loginSyncEnabled = FileManager.default.fileExists(atPath: loginSyncAgent.path)
|
|
@@ -145,7 +212,7 @@ final class SyncModel: ObservableObject {
|
|
|
145
212
|
let configuredSource = config.sourceBrowser ?? "brave"
|
|
146
213
|
selectedSourceID = browsers.contains(where: { $0.id == configuredSource }) ? configuredSource : "brave"
|
|
147
214
|
let configuredTarget = config.targetBrowser ?? "codex"
|
|
148
|
-
selectedTargetID = configuredTarget == "codex" || browsers.contains(where: { $0.id == configuredTarget })
|
|
215
|
+
selectedTargetID = configuredTarget == "codex" || configuredTarget == "browserless" || browsers.contains(where: { $0.id == configuredTarget })
|
|
149
216
|
? configuredTarget
|
|
150
217
|
: "codex"
|
|
151
218
|
if selectedTargetID == selectedSourceID { selectedTargetID = "codex" }
|
|
@@ -153,12 +220,16 @@ final class SyncModel: ObservableObject {
|
|
|
153
220
|
historyEnabled = config.imports?.history ?? false
|
|
154
221
|
menuBarEnabled = config.ui?.menuBar ?? true
|
|
155
222
|
autoCheckUpdates = config.ui?.autoCheckUpdates ?? true
|
|
223
|
+
browserlessProfileName = config.browserless?.profileName ?? "browser-cookie-bridge"
|
|
224
|
+
browserlessRegion = config.browserless?.region ?? "sfo"
|
|
225
|
+
browserlessOnlyDomains = (config.browserless?.onlyDomains ?? []).joined(separator: ", ")
|
|
156
226
|
}
|
|
227
|
+
browserlessConfigured = BrowserlessCredentialStore.read() != nil
|
|
157
228
|
NotificationCenter.default.post(name: .menuBarVisibilityChanged, object: menuBarEnabled)
|
|
158
229
|
extensionsReady = requiredExtensionIDs.allSatisfy {
|
|
159
230
|
FileManager.default.fileExists(atPath: support.appending(path: "extension-\($0)/manifest.json").path)
|
|
160
231
|
}
|
|
161
|
-
|
|
232
|
+
updateEndpointRunningStatus()
|
|
162
233
|
consumeUpdateResultIfNeeded()
|
|
163
234
|
if autoCheckUpdates && !didCheckAfterLaunch {
|
|
164
235
|
didCheckAfterLaunch = true
|
|
@@ -173,11 +244,12 @@ final class SyncModel: ObservableObject {
|
|
|
173
244
|
}
|
|
174
245
|
|
|
175
246
|
func selectTarget(_ id: String) {
|
|
176
|
-
let validTarget = id == "codex" || browsers.contains(where: { $0.id == id })
|
|
247
|
+
let validTarget = id == "codex" || id == "browserless" || browsers.contains(where: { $0.id == id })
|
|
177
248
|
guard validTarget, id != selectedTargetID, id != selectedSourceID else { return }
|
|
178
249
|
selectedTargetID = id
|
|
179
250
|
persistPreferences(successMessage: "Import destination changed to \(targetName)")
|
|
180
|
-
|
|
251
|
+
updateEndpointRunningStatus()
|
|
252
|
+
if id == "browserless" && !browserlessConfigured { showingBrowserlessSetup = true }
|
|
181
253
|
}
|
|
182
254
|
|
|
183
255
|
func setCookiesEnabled(_ enabled: Bool) {
|
|
@@ -190,6 +262,33 @@ final class SyncModel: ObservableObject {
|
|
|
190
262
|
persistPreferences(successMessage: enabled ? "History URL import enabled" : "History import disabled")
|
|
191
263
|
}
|
|
192
264
|
|
|
265
|
+
func saveBrowserlessSettings(token: String, profileName: String, region: String, onlyDomains: String) {
|
|
266
|
+
let cleanedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
267
|
+
let cleanedName = profileName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
268
|
+
guard !cleanedName.isEmpty, !cleanedToken.isEmpty || BrowserlessCredentialStore.read() != nil else {
|
|
269
|
+
postNativeAlert(title: "Browserless connection incomplete", message: "Enter an API token and cloud profile name.", kind: .warning)
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
do {
|
|
273
|
+
if !cleanedToken.isEmpty { try BrowserlessCredentialStore.save(cleanedToken) }
|
|
274
|
+
browserlessConfigured = true
|
|
275
|
+
browserlessProfileName = cleanedName
|
|
276
|
+
browserlessRegion = region
|
|
277
|
+
browserlessOnlyDomains = onlyDomains
|
|
278
|
+
showingBrowserlessSetup = false
|
|
279
|
+
persistPreferences(successMessage: "Browserless connected — uploads remain manual")
|
|
280
|
+
} catch {
|
|
281
|
+
postNativeAlert(title: "Could not save Browserless token", message: error.localizedDescription, kind: .error)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
func disconnectBrowserless() {
|
|
286
|
+
BrowserlessCredentialStore.delete()
|
|
287
|
+
browserlessConfigured = false
|
|
288
|
+
showingBrowserlessSetup = false
|
|
289
|
+
updateEndpointRunningStatus()
|
|
290
|
+
}
|
|
291
|
+
|
|
193
292
|
func setMenuBarEnabled(_ enabled: Bool) {
|
|
194
293
|
menuBarEnabled = enabled
|
|
195
294
|
NotificationCenter.default.post(name: .menuBarVisibilityChanged, object: enabled)
|
|
@@ -204,11 +303,12 @@ final class SyncModel: ObservableObject {
|
|
|
204
303
|
|
|
205
304
|
func checkForUpdates(showAlert: Bool = false) {
|
|
206
305
|
guard !isCheckingForUpdates, !isInstallingUpdate else { return }
|
|
207
|
-
guard let url = URL(string: "https://
|
|
306
|
+
guard let url = URL(string: "https://api.github.com/repos/apoorvdarshan/browser-cookie-bridge/releases/latest") else { return }
|
|
208
307
|
isCheckingForUpdates = true
|
|
209
308
|
postUpdateState()
|
|
210
309
|
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
|
|
211
310
|
request.setValue("Browser-Cookie-Bridge/\(currentVersion)", forHTTPHeaderField: "User-Agent")
|
|
311
|
+
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
|
|
212
312
|
URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
|
|
213
313
|
Task { @MainActor in
|
|
214
314
|
guard let self else { return }
|
|
@@ -292,8 +392,8 @@ final class SyncModel: ObservableObject {
|
|
|
292
392
|
}
|
|
293
393
|
return
|
|
294
394
|
}
|
|
295
|
-
|
|
296
|
-
guard !
|
|
395
|
+
updateEndpointRunningStatus()
|
|
396
|
+
guard !syncBlocked else {
|
|
297
397
|
if showMenuBarAlert {
|
|
298
398
|
postNativeAlert(title: primaryStatus, message: secondaryStatus, kind: .warning)
|
|
299
399
|
}
|
|
@@ -301,17 +401,33 @@ final class SyncModel: ObservableObject {
|
|
|
301
401
|
}
|
|
302
402
|
isSyncing = true
|
|
303
403
|
state = .syncing
|
|
304
|
-
primaryStatus = "Transferring selected data"
|
|
404
|
+
primaryStatus = isBrowserlessTarget ? "Uploading authenticated state" : "Transferring selected data"
|
|
305
405
|
secondaryStatus = selectedTargetID == "codex"
|
|
306
406
|
? "Backing up Codex and merging \(selectedBrowser.name) locally…"
|
|
307
|
-
:
|
|
308
|
-
|
|
407
|
+
: isBrowserlessTarget
|
|
408
|
+
? "Sending \(selectedBrowser.name) to Browserless \(browserlessRegion.uppercased()) only for this request…"
|
|
409
|
+
: "Waiting for \(selectedBrowser.name) and \(targetName)…"
|
|
410
|
+
var environment: [String: String] = [:]
|
|
411
|
+
var arguments = ["sync", "--timeout", "300"]
|
|
412
|
+
if isBrowserlessTarget {
|
|
413
|
+
guard let token = BrowserlessCredentialStore.read() else {
|
|
414
|
+
isSyncing = false
|
|
415
|
+
browserlessConfigured = false
|
|
416
|
+
updateEndpointRunningStatus()
|
|
417
|
+
return
|
|
418
|
+
}
|
|
419
|
+
environment["BROWSERLESS_TOKEN"] = token
|
|
420
|
+
arguments.append("--allow-cloud-upload")
|
|
421
|
+
}
|
|
422
|
+
runCLI(arguments, environment: environment) { [weak self] success, output in
|
|
309
423
|
guard let self else { return }
|
|
310
424
|
self.isSyncing = false
|
|
311
425
|
let partial = success && (output.contains("Partially synced:") || output.contains("with warnings"))
|
|
312
426
|
if success {
|
|
313
427
|
self.state = partial ? .warning : .success
|
|
314
|
-
self.primaryStatus = self.
|
|
428
|
+
self.primaryStatus = self.isBrowserlessTarget
|
|
429
|
+
? "Browserless profile uploaded"
|
|
430
|
+
: self.selectedTargetID == "codex"
|
|
315
431
|
? (partial ? "Codex sync completed with warnings" : "Codex sessions updated")
|
|
316
432
|
: (partial ? "Partially synced" : "Transfer complete")
|
|
317
433
|
self.secondaryStatus = self.lastMeaningfulLine(output) ?? "\(self.selectedBrowser.name) and \(self.targetName) are up to date"
|
|
@@ -320,9 +436,11 @@ final class SyncModel: ObservableObject {
|
|
|
320
436
|
self.primaryStatus = "Sync did not finish"
|
|
321
437
|
self.secondaryStatus = self.lastMeaningfulLine(output) ?? (self.selectedTargetID == "codex"
|
|
322
438
|
? "Quit Codex completely, then try again"
|
|
439
|
+
: self.isBrowserlessTarget
|
|
440
|
+
? "Check the API token, close the source browser, and try again"
|
|
323
441
|
: "Keep both browsers open and check the extensions")
|
|
324
442
|
}
|
|
325
|
-
self.
|
|
443
|
+
self.updateEndpointRunningStatus()
|
|
326
444
|
if showMenuBarAlert {
|
|
327
445
|
self.postNativeAlert(
|
|
328
446
|
title: self.primaryStatus,
|
|
@@ -334,6 +452,10 @@ final class SyncModel: ObservableObject {
|
|
|
334
452
|
}
|
|
335
453
|
|
|
336
454
|
func setDailyEnabled(_ enabled: Bool) {
|
|
455
|
+
guard !isBrowserlessTarget else {
|
|
456
|
+
postNativeAlert(title: "Cloud uploads are manual-only", message: "Browser Cookie Bridge will never schedule Browserless uploads in the background.", kind: .information)
|
|
457
|
+
return
|
|
458
|
+
}
|
|
337
459
|
dailyEnabled = enabled
|
|
338
460
|
applySchedule(enabled)
|
|
339
461
|
}
|
|
@@ -343,6 +465,10 @@ final class SyncModel: ObservableObject {
|
|
|
343
465
|
}
|
|
344
466
|
|
|
345
467
|
func setLoginSyncEnabled(_ enabled: Bool) {
|
|
468
|
+
guard !isBrowserlessTarget else {
|
|
469
|
+
postNativeAlert(title: "Cloud uploads are manual-only", message: "Login sync does not send authenticated state to Browserless.", kind: .information)
|
|
470
|
+
return
|
|
471
|
+
}
|
|
346
472
|
loginSyncEnabled = enabled
|
|
347
473
|
isWorking = true
|
|
348
474
|
runCLI([enabled ? "enable-login-sync" : "disable-login-sync"]) { [weak self] success, output in
|
|
@@ -406,7 +532,10 @@ final class SyncModel: ObservableObject {
|
|
|
406
532
|
"--cookies", cookiesEnabled ? "on" : "off",
|
|
407
533
|
"--history", historyEnabled ? "on" : "off",
|
|
408
534
|
"--menu-bar", menuBarEnabled ? "on" : "off",
|
|
409
|
-
"--auto-check-updates", autoCheckUpdates ? "on" : "off"
|
|
535
|
+
"--auto-check-updates", autoCheckUpdates ? "on" : "off",
|
|
536
|
+
"--browserless-profile", browserlessProfileName,
|
|
537
|
+
"--browserless-region", browserlessRegion,
|
|
538
|
+
"--browserless-domains", browserlessOnlyDomains,
|
|
410
539
|
]
|
|
411
540
|
runCLI(arguments) { [weak self] success, output in
|
|
412
541
|
guard let self else { return }
|
|
@@ -414,7 +543,9 @@ final class SyncModel: ObservableObject {
|
|
|
414
543
|
if success {
|
|
415
544
|
self.state = .ready
|
|
416
545
|
self.primaryStatus = successMessage
|
|
417
|
-
self.secondaryStatus =
|
|
546
|
+
self.secondaryStatus = self.isBrowserlessTarget
|
|
547
|
+
? "Cloud uploads run only after you click Upload"
|
|
548
|
+
: "This choice is saved for manual and daily syncs"
|
|
418
549
|
} else {
|
|
419
550
|
self.state = .error
|
|
420
551
|
self.primaryStatus = "Could not save import settings"
|
|
@@ -424,7 +555,7 @@ final class SyncModel: ObservableObject {
|
|
|
424
555
|
self.extensionsReady = self.requiredExtensionIDs.allSatisfy {
|
|
425
556
|
FileManager.default.fileExists(atPath: self.support.appending(path: "extension-\($0)/manifest.json").path)
|
|
426
557
|
}
|
|
427
|
-
self.
|
|
558
|
+
self.updateEndpointRunningStatus()
|
|
428
559
|
}
|
|
429
560
|
}
|
|
430
561
|
|
|
@@ -453,7 +584,7 @@ final class SyncModel: ObservableObject {
|
|
|
453
584
|
}
|
|
454
585
|
}
|
|
455
586
|
|
|
456
|
-
private func runCLI(_ arguments: [String], completion: @escaping @MainActor (Bool, String) -> Void) {
|
|
587
|
+
private func runCLI(_ arguments: [String], environment: [String: String] = [:], completion: @escaping @MainActor (Bool, String) -> Void) {
|
|
457
588
|
guard let config = loadConfig() else {
|
|
458
589
|
completion(false, "Configuration missing. Run install-app again.")
|
|
459
590
|
return
|
|
@@ -462,6 +593,7 @@ final class SyncModel: ObservableObject {
|
|
|
462
593
|
let output = Pipe()
|
|
463
594
|
process.executableURL = URL(fileURLWithPath: config.nodePath)
|
|
464
595
|
process.arguments = [runtimeCLI.path] + arguments
|
|
596
|
+
process.environment = ProcessInfo.processInfo.environment.merging(environment) { _, new in new }
|
|
465
597
|
process.standardOutput = output
|
|
466
598
|
process.standardError = output
|
|
467
599
|
process.terminationHandler = { process in
|
|
@@ -504,23 +636,44 @@ final class SyncModel: ObservableObject {
|
|
|
504
636
|
}
|
|
505
637
|
|
|
506
638
|
private var requiredExtensionIDs: [String] {
|
|
507
|
-
selectedTargetID == "codex" ? [] : [selectedSourceID, selectedTargetID]
|
|
639
|
+
selectedTargetID == "codex" || selectedTargetID == "browserless" ? [] : [selectedSourceID, selectedTargetID]
|
|
508
640
|
}
|
|
509
641
|
|
|
510
|
-
private func
|
|
642
|
+
private func updateEndpointRunningStatus() {
|
|
511
643
|
let wasRunning = codexRunning
|
|
512
644
|
codexRunning = NSWorkspace.shared.runningApplications.contains {
|
|
513
645
|
$0.bundleIdentifier == "com.openai.codex"
|
|
514
646
|
}
|
|
515
|
-
|
|
516
|
-
|
|
647
|
+
sourceBrowserRunning = NSWorkspace.shared.runningApplications.contains {
|
|
648
|
+
$0.bundleIdentifier == selectedBrowser.bundleIdentifier
|
|
649
|
+
}
|
|
650
|
+
guard !isSyncing else { return }
|
|
651
|
+
if selectedTargetID == "codex" && codexRunning {
|
|
517
652
|
state = .warning
|
|
518
653
|
primaryStatus = "Quit Codex before syncing"
|
|
519
654
|
secondaryStatus = "Close ChatGPT Codex completely so its local cookie database can be updated safely"
|
|
520
|
-
} else if wasRunning && primaryStatus == "Quit Codex before syncing" {
|
|
655
|
+
} else if selectedTargetID == "codex" && wasRunning && primaryStatus == "Quit Codex before syncing" {
|
|
521
656
|
state = .ready
|
|
522
657
|
primaryStatus = "Ready to sync directly"
|
|
523
658
|
secondaryStatus = "Codex is closed — a backup will be created before anything changes"
|
|
659
|
+
} else if isBrowserlessTarget {
|
|
660
|
+
if selectedSourceID == "comet" {
|
|
661
|
+
state = .warning
|
|
662
|
+
primaryStatus = "Comet capture is not supported"
|
|
663
|
+
secondaryStatus = "Choose Brave, Chrome, Edge, Arc, Vivaldi, or Opera for Browserless"
|
|
664
|
+
} else if !browserlessConfigured {
|
|
665
|
+
state = .warning
|
|
666
|
+
primaryStatus = "Connect Browserless"
|
|
667
|
+
secondaryStatus = "Your API token will be stored in macOS Keychain, never in the app configuration"
|
|
668
|
+
} else if sourceBrowserRunning {
|
|
669
|
+
state = .warning
|
|
670
|
+
primaryStatus = "Quit \(selectedBrowser.name) before uploading"
|
|
671
|
+
secondaryStatus = "Browserless captures a temporary copy of the closed profile, including local storage and IndexedDB"
|
|
672
|
+
} else {
|
|
673
|
+
state = .ready
|
|
674
|
+
primaryStatus = "Ready for an explicit cloud upload"
|
|
675
|
+
secondaryStatus = "Only this click sends authenticated state to Browserless \(browserlessRegion.uppercased())"
|
|
676
|
+
}
|
|
524
677
|
}
|
|
525
678
|
}
|
|
526
679
|
|
|
@@ -575,6 +728,16 @@ final class SyncModel: ObservableObject {
|
|
|
575
728
|
|
|
576
729
|
private struct PackageRelease: Decodable {
|
|
577
730
|
let version: String
|
|
731
|
+
|
|
732
|
+
private enum CodingKeys: String, CodingKey {
|
|
733
|
+
case tagName = "tag_name"
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
init(from decoder: Decoder) throws {
|
|
737
|
+
let values = try decoder.container(keyedBy: CodingKeys.self)
|
|
738
|
+
let tag = try values.decode(String.self, forKey: .tagName)
|
|
739
|
+
version = tag.hasPrefix("v") ? String(tag.dropFirst()) : tag
|
|
740
|
+
}
|
|
578
741
|
}
|
|
579
742
|
|
|
580
743
|
private struct UpdateResult: Decodable {
|
|
@@ -590,6 +753,7 @@ private struct AppConfig: Decodable {
|
|
|
590
753
|
let targetBrowser: String?
|
|
591
754
|
let imports: Imports?
|
|
592
755
|
let ui: UISettings?
|
|
756
|
+
let browserless: BrowserlessSettings?
|
|
593
757
|
|
|
594
758
|
struct Schedule: Decodable {
|
|
595
759
|
let hour: Int
|
|
@@ -606,4 +770,60 @@ private struct AppConfig: Decodable {
|
|
|
606
770
|
let openAtLogin: Bool?
|
|
607
771
|
let autoCheckUpdates: Bool?
|
|
608
772
|
}
|
|
773
|
+
|
|
774
|
+
struct BrowserlessSettings: Decodable {
|
|
775
|
+
let profileName: String?
|
|
776
|
+
let region: String?
|
|
777
|
+
let onlyDomains: [String]?
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
private enum BrowserlessCredentialStore {
|
|
782
|
+
private static let service = "com.apoorvdarshan.browser-cookie-bridge.browserless"
|
|
783
|
+
private static let account = "api-token"
|
|
784
|
+
|
|
785
|
+
static func save(_ token: String) throws {
|
|
786
|
+
let identity: [String: Any] = [
|
|
787
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
788
|
+
kSecAttrService as String: service,
|
|
789
|
+
kSecAttrAccount as String: account,
|
|
790
|
+
]
|
|
791
|
+
let update: [String: Any] = [kSecValueData as String: Data(token.utf8)]
|
|
792
|
+
let updateStatus = SecItemUpdate(identity as CFDictionary, update as CFDictionary)
|
|
793
|
+
if updateStatus == errSecSuccess { return }
|
|
794
|
+
guard updateStatus == errSecItemNotFound else {
|
|
795
|
+
throw NSError(domain: NSOSStatusErrorDomain, code: Int(updateStatus), userInfo: nil)
|
|
796
|
+
}
|
|
797
|
+
let item: [String: Any] = identity.merging([
|
|
798
|
+
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
|
799
|
+
kSecValueData as String: Data(token.utf8),
|
|
800
|
+
]) { _, new in new }
|
|
801
|
+
let status = SecItemAdd(item as CFDictionary, nil)
|
|
802
|
+
guard status == errSecSuccess else {
|
|
803
|
+
throw NSError(domain: NSOSStatusErrorDomain, code: Int(status), userInfo: nil)
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
static func read() -> String? {
|
|
808
|
+
let query: [String: Any] = [
|
|
809
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
810
|
+
kSecAttrService as String: service,
|
|
811
|
+
kSecAttrAccount as String: account,
|
|
812
|
+
kSecReturnData as String: true,
|
|
813
|
+
kSecMatchLimit as String: kSecMatchLimitOne,
|
|
814
|
+
]
|
|
815
|
+
var result: CFTypeRef?
|
|
816
|
+
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
|
817
|
+
let data = result as? Data else { return nil }
|
|
818
|
+
return String(data: data, encoding: .utf8)
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
static func delete() {
|
|
822
|
+
let query: [String: Any] = [
|
|
823
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
824
|
+
kSecAttrService as String: service,
|
|
825
|
+
kSecAttrAccount as String: account,
|
|
826
|
+
]
|
|
827
|
+
SecItemDelete(query as CFDictionary)
|
|
828
|
+
}
|
|
609
829
|
}
|
package/package.json
CHANGED
|
@@ -1,28 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "browser-cookie-bridge",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Local-first cookie and session transfer for macOS with optional Browserless upload",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"browser-cookie-bridge": "./bin/brave-codex-cookie-sync.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"test": "node --test test/*.test.js",
|
|
11
|
-
"check": "node --check bin/brave-codex-cookie-sync.js && node --check src/app-installer.js && node --check src/broker.js && node --check src/chromium-reader.js && node --check src/cli.js && node --check src/codex-direct-import.js && node --check src/config.js && node --check src/paths.js && node --check src/scheduler.js && node --check src/updater.js && node --check extension-template/background.js && node --check web/server.js && node --check web/script.js",
|
|
11
|
+
"check": "node --check bin/brave-codex-cookie-sync.js && node --check src/app-installer.js && node --check src/browserless.js && node --check src/browserless-runner.js && node --check src/broker.js && node --check src/chromium-reader.js && node --check src/cli.js && node --check src/codex-direct-import.js && node --check src/config.js && node --check src/paths.js && node --check src/scheduler.js && node --check src/updater.js && node --check scripts/build-dmg.js && node --check extension-template/background.js && node --check web/server.js && node --check web/script.js",
|
|
12
12
|
"release:check": "node scripts/check-release-version.js",
|
|
13
13
|
"build:app": "node bin/brave-codex-cookie-sync.js install-app --no-open",
|
|
14
|
+
"build:dmg": "node scripts/build-dmg.js",
|
|
14
15
|
"web": "node web/server.js",
|
|
15
16
|
"web:deploy": "wrangler deploy --config web/wrangler.jsonc"
|
|
16
17
|
},
|
|
17
18
|
"engines": {
|
|
18
|
-
"node": ">=
|
|
19
|
+
"node": ">=24"
|
|
19
20
|
},
|
|
20
21
|
"license": "MIT",
|
|
21
22
|
"repository": {
|
|
22
23
|
"type": "git",
|
|
23
24
|
"url": "git+https://github.com/apoorvdarshan/browser-cookie-bridge.git"
|
|
24
25
|
},
|
|
25
|
-
"homepage": "https://
|
|
26
|
+
"homepage": "https://cookiebridge.apoorvdarshan.com/",
|
|
26
27
|
"bugs": {
|
|
27
28
|
"url": "https://github.com/apoorvdarshan/browser-cookie-bridge/issues"
|
|
28
29
|
},
|
|
@@ -30,6 +31,17 @@
|
|
|
30
31
|
"access": "public",
|
|
31
32
|
"registry": "https://registry.npmjs.org/"
|
|
32
33
|
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"browser",
|
|
36
|
+
"cookies",
|
|
37
|
+
"sessions",
|
|
38
|
+
"macos",
|
|
39
|
+
"chromium",
|
|
40
|
+
"brave",
|
|
41
|
+
"browserless",
|
|
42
|
+
"codex"
|
|
43
|
+
],
|
|
44
|
+
"funding": "https://ko-fi.com/apoorvdarshan",
|
|
33
45
|
"files": [
|
|
34
46
|
"bin",
|
|
35
47
|
"src",
|
|
@@ -39,6 +51,10 @@
|
|
|
39
51
|
"macos-app/Resources",
|
|
40
52
|
"macos-app/Sources",
|
|
41
53
|
"README.md",
|
|
42
|
-
"LICENSE"
|
|
43
|
-
|
|
54
|
+
"LICENSE",
|
|
55
|
+
"THIRD_PARTY_NOTICES.md"
|
|
56
|
+
],
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"@browserless.io/cli": "0.3.0"
|
|
59
|
+
}
|
|
44
60
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { pathToFileURL } from "node:url";
|
|
2
|
+
|
|
3
|
+
const [, , cliPath, ...argumentsWithoutToken] = process.argv;
|
|
4
|
+
const token = process.env.BROWSERLESS_TOKEN?.trim();
|
|
5
|
+
if (!cliPath) throw new Error("Browserless CLI path is missing.");
|
|
6
|
+
if (!token) throw new Error("Browserless API token is missing.");
|
|
7
|
+
|
|
8
|
+
// Read the Keychain-supplied token once, then remove it before the official CLI
|
|
9
|
+
// launches a temporary browser. Mutating process.argv does not alter the OS
|
|
10
|
+
// command line that started this process, so the token is not exposed by `ps`.
|
|
11
|
+
delete process.env.BROWSERLESS_TOKEN;
|
|
12
|
+
process.argv = [process.execPath, cliPath, ...argumentsWithoutToken, "--token", token];
|
|
13
|
+
await import(pathToFileURL(cliPath).href);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { projectRoot } from "./paths.js";
|
|
4
|
+
|
|
5
|
+
const SUPPORTED_SOURCES = new Set(["brave", "chrome", "edge", "arc", "vivaldi", "opera"]);
|
|
6
|
+
const REGIONS = new Set(["sfo", "lon", "ams"]);
|
|
7
|
+
|
|
8
|
+
export async function uploadBrowserlessProfile({
|
|
9
|
+
browser,
|
|
10
|
+
localProfile,
|
|
11
|
+
profileName,
|
|
12
|
+
region = "sfo",
|
|
13
|
+
onlyDomains = [],
|
|
14
|
+
token = process.env.BROWSERLESS_TOKEN,
|
|
15
|
+
root = projectRoot(),
|
|
16
|
+
runner = runCLI,
|
|
17
|
+
} = {}) {
|
|
18
|
+
if (!SUPPORTED_SOURCES.has(browser)) {
|
|
19
|
+
throw new Error(`${browser === "comet" ? "Comet" : browser} is not supported by Browserless profile capture yet.`);
|
|
20
|
+
}
|
|
21
|
+
if (!localProfile) throw new Error("The local browser profile could not be determined.");
|
|
22
|
+
if (!profileName?.trim()) throw new Error("Choose a Browserless cloud profile name.");
|
|
23
|
+
if (!REGIONS.has(region)) throw new Error("Browserless region must be sfo, lon, or ams.");
|
|
24
|
+
if (!token?.trim()) throw new Error("Connect Browserless first. The API token is missing from macOS Keychain.");
|
|
25
|
+
|
|
26
|
+
const cliPath = path.join(root, "node_modules", "@browserless.io", "cli", "build", "cli.js");
|
|
27
|
+
const runnerPath = path.join(root, "src", "browserless-runner.js");
|
|
28
|
+
const environment = {
|
|
29
|
+
...process.env,
|
|
30
|
+
BROWSERLESS_TOKEN: token.trim(),
|
|
31
|
+
BROWSERLESS_ACCEPT_TERMS: "1",
|
|
32
|
+
BROWSERLESS_TELEMETRY_DISABLED: "1",
|
|
33
|
+
BROWSERLESS_DISABLE_KEYCHAIN: "1",
|
|
34
|
+
DO_NOT_TRACK: "1",
|
|
35
|
+
};
|
|
36
|
+
const client = ["--region", region, "--json"];
|
|
37
|
+
const shown = await runner(cliPath, ["profile", "show", profileName.trim(), ...client], environment, runnerPath);
|
|
38
|
+
const operation = shown.status === 0 ? "refresh" : isMissingProfile(shown.output) ? "upload" : null;
|
|
39
|
+
if (!operation) throw new Error(lastLine(shown.output) || "Browserless could not validate the cloud profile.");
|
|
40
|
+
|
|
41
|
+
const capture = [
|
|
42
|
+
"profile", operation,
|
|
43
|
+
"--browser", browser,
|
|
44
|
+
"--profile", localProfile,
|
|
45
|
+
"--name", profileName.trim(),
|
|
46
|
+
...client,
|
|
47
|
+
"--accept-terms",
|
|
48
|
+
"--auto-fit",
|
|
49
|
+
];
|
|
50
|
+
for (const domain of onlyDomains) capture.push("--only-domain", domain);
|
|
51
|
+
const result = await runner(cliPath, capture, environment, runnerPath);
|
|
52
|
+
if (result.status !== 0) throw new Error(lastLine(result.output) || "Browserless profile upload failed.");
|
|
53
|
+
|
|
54
|
+
const details = parseJSON(result.output);
|
|
55
|
+
const cookies = details?.cookieCount;
|
|
56
|
+
const origins = details?.originCount;
|
|
57
|
+
const counts = Number.isInteger(cookies) && Number.isInteger(origins)
|
|
58
|
+
? ` (${cookies} cookies, ${origins} origins)`
|
|
59
|
+
: "";
|
|
60
|
+
return {
|
|
61
|
+
operation,
|
|
62
|
+
profileName: details?.name || profileName.trim(),
|
|
63
|
+
cookieCount: cookies,
|
|
64
|
+
originCount: origins,
|
|
65
|
+
summary: `Browserless profile ${operation === "refresh" ? "updated" : "created"}: ${details?.name || profileName.trim()}${counts}`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function runCLI(cliPath, args, environment, runnerPath) {
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
const child = spawn(process.execPath, [runnerPath, cliPath, ...args], {
|
|
72
|
+
env: environment,
|
|
73
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
74
|
+
});
|
|
75
|
+
let stdout = "";
|
|
76
|
+
let stderr = "";
|
|
77
|
+
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
|
78
|
+
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
|
79
|
+
child.on("error", reject);
|
|
80
|
+
child.on("close", (status) => resolve({ status: status ?? 1, output: `${stderr}${stdout}` }));
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isMissingProfile(output) {
|
|
85
|
+
return /(?:not found|does not exist|404)/i.test(output);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseJSON(output) {
|
|
89
|
+
const start = output.indexOf("{");
|
|
90
|
+
const end = output.lastIndexOf("}");
|
|
91
|
+
if (start !== -1 && end > start) {
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(output.slice(start, end + 1));
|
|
94
|
+
if (parsed && typeof parsed === "object") return parsed;
|
|
95
|
+
} catch {}
|
|
96
|
+
}
|
|
97
|
+
for (const line of output.split("\n").reverse()) {
|
|
98
|
+
try {
|
|
99
|
+
const parsed = JSON.parse(line);
|
|
100
|
+
if (parsed && typeof parsed === "object") return parsed;
|
|
101
|
+
} catch {}
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function lastLine(output) {
|
|
107
|
+
return output.split("\n").map((line) => line.trim()).filter(Boolean).at(-1)?.replace(/^Error:\s*/, "");
|
|
108
|
+
}
|