browser-cookie-bridge 1.1.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 +35 -13
- package/THIRD_PARTY_NOTICES.md +11 -0
- 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/Resources/BrowserIcons/browserless.svg +6 -0
- package/macos-app/Sources/BraveCodexSyncApp/BraveCodexSyncApp.swift +342 -62
- package/macos-app/Sources/BraveCodexSyncApp/SyncModel.swift +400 -34
- package/package.json +11 -6
- package/src/browserless-preflight.js +113 -0
- package/src/browserless-runner.js +13 -0
- package/src/browserless.js +281 -0
- package/src/cli.js +76 -6
- package/src/config.js +38 -4
- package/src/paths.js +1 -1
|
@@ -1,10 +1,12 @@
|
|
|
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")
|
|
6
7
|
static let nativeAlert = Notification.Name("BraveCodexSync.nativeAlert")
|
|
7
8
|
static let updateStateChanged = Notification.Name("BraveCodexSync.updateStateChanged")
|
|
9
|
+
static let syncStateChanged = Notification.Name("BraveCodexSync.syncStateChanged")
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
struct NativeAlert {
|
|
@@ -20,6 +22,31 @@ struct UpdateMenuState {
|
|
|
20
22
|
let installing: Bool
|
|
21
23
|
}
|
|
22
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
|
+
|
|
23
50
|
struct BrowserChoice: Identifiable, Hashable {
|
|
24
51
|
let id: String
|
|
25
52
|
let name: String
|
|
@@ -30,7 +57,7 @@ struct BrowserChoice: Identifiable, Hashable {
|
|
|
30
57
|
|
|
31
58
|
@MainActor
|
|
32
59
|
final class SyncModel: ObservableObject {
|
|
33
|
-
enum State { case ready, syncing, success, warning, error }
|
|
60
|
+
enum State { case ready, syncing, success, canceled, warning, error }
|
|
34
61
|
|
|
35
62
|
let browsers = [
|
|
36
63
|
BrowserChoice(id: "brave", name: "Brave", bundleIdentifier: "com.brave.Browser", applicationName: "Brave Browser", extensionURL: "brave://extensions"),
|
|
@@ -60,6 +87,17 @@ final class SyncModel: ObservableObject {
|
|
|
60
87
|
@Published var selectedSourceID = "brave"
|
|
61
88
|
@Published var selectedTargetID = "codex"
|
|
62
89
|
@Published var codexRunning = false
|
|
90
|
+
@Published var sourceBrowserRunning = false
|
|
91
|
+
@Published var browserlessConfigured = false
|
|
92
|
+
@Published var browserlessProfileName = "browser-cookie-bridge"
|
|
93
|
+
@Published var browserlessRegion = "sfo"
|
|
94
|
+
@Published var browserlessOnlyDomains = ""
|
|
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
|
|
63
101
|
@Published var primaryStatus = "Ready to sync"
|
|
64
102
|
@Published var secondaryStatus = "Choose what to move, then start a transfer"
|
|
65
103
|
|
|
@@ -69,10 +107,14 @@ final class SyncModel: ObservableObject {
|
|
|
69
107
|
private var launchAgent: URL { home.appending(path: "Library/LaunchAgents/com.apoorvdarshan.brave-codex-cookie-sync.plist") }
|
|
70
108
|
private var loginSyncAgent: URL { home.appending(path: "Library/LaunchAgents/com.apoorvdarshan.brave-codex-cookie-sync.login-sync.plist") }
|
|
71
109
|
private var appLoginAgent: URL { home.appending(path: "Library/LaunchAgents/com.apoorvdarshan.brave-codex-cookie-sync.app-login.plist") }
|
|
72
|
-
private var
|
|
110
|
+
private var endpointStatusTimer: Timer?
|
|
73
111
|
private var updateTimer: Timer?
|
|
74
112
|
private var didCheckAfterLaunch = false
|
|
75
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?
|
|
76
118
|
|
|
77
119
|
var selectedBrowser: BrowserChoice {
|
|
78
120
|
browsers.first(where: { $0.id == selectedSourceID }) ?? browsers[0]
|
|
@@ -82,10 +124,29 @@ final class SyncModel: ObservableObject {
|
|
|
82
124
|
browsers.first(where: { $0.id == selectedTargetID })
|
|
83
125
|
}
|
|
84
126
|
|
|
85
|
-
var
|
|
127
|
+
var isBrowserlessTarget: Bool { selectedTargetID == "browserless" }
|
|
128
|
+
var targetName: String {
|
|
129
|
+
isBrowserlessTarget ? "Browserless Cloud" : selectedTargetBrowser?.name ?? "ChatGPT Codex"
|
|
130
|
+
}
|
|
86
131
|
var codexBlocked: Bool { selectedTargetID == "codex" && codexRunning }
|
|
132
|
+
var browserlessBlocked: Bool {
|
|
133
|
+
isBrowserlessTarget && (!browserlessConfigured || sourceBrowserRunning || selectedSourceID == "comet")
|
|
134
|
+
}
|
|
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
|
+
}
|
|
87
141
|
var sourceIcon: NSImage { browserIcon(selectedBrowser) }
|
|
88
|
-
var targetIcon: NSImage {
|
|
142
|
+
var targetIcon: NSImage {
|
|
143
|
+
isBrowserlessTarget ? browserlessIcon : selectedTargetBrowser.map(browserIcon) ?? codexIcon
|
|
144
|
+
}
|
|
145
|
+
var browserlessIcon: NSImage {
|
|
146
|
+
bundledIcon("browserless")
|
|
147
|
+
?? NSImage(systemSymbolName: "cloud.fill", accessibilityDescription: "Browserless Cloud")
|
|
148
|
+
?? NSImage()
|
|
149
|
+
}
|
|
89
150
|
var codexIcon: NSImage {
|
|
90
151
|
bundledIcon("chatgpt-codex")
|
|
91
152
|
?? chatGPTResource("app.icns")
|
|
@@ -119,9 +180,10 @@ final class SyncModel: ObservableObject {
|
|
|
119
180
|
bootstrapBundledRuntimeIfNeeded()
|
|
120
181
|
let calendar = Calendar.current
|
|
121
182
|
scheduleTime = calendar.date(bySettingHour: 9, minute: 0, second: 0, of: Date()) ?? Date()
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
183
|
+
updateEndpointRunningStatus()
|
|
184
|
+
refreshBrowserlessPreflight()
|
|
185
|
+
endpointStatusTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
|
|
186
|
+
Task { @MainActor in self?.updateEndpointRunningStatus() }
|
|
125
187
|
}
|
|
126
188
|
updateTimer = Timer.scheduledTimer(withTimeInterval: 24 * 60 * 60, repeats: true) { [weak self] _ in
|
|
127
189
|
Task { @MainActor in
|
|
@@ -191,7 +253,7 @@ final class SyncModel: ObservableObject {
|
|
|
191
253
|
let configuredSource = config.sourceBrowser ?? "brave"
|
|
192
254
|
selectedSourceID = browsers.contains(where: { $0.id == configuredSource }) ? configuredSource : "brave"
|
|
193
255
|
let configuredTarget = config.targetBrowser ?? "codex"
|
|
194
|
-
selectedTargetID = configuredTarget == "codex" || browsers.contains(where: { $0.id == configuredTarget })
|
|
256
|
+
selectedTargetID = configuredTarget == "codex" || configuredTarget == "browserless" || browsers.contains(where: { $0.id == configuredTarget })
|
|
195
257
|
? configuredTarget
|
|
196
258
|
: "codex"
|
|
197
259
|
if selectedTargetID == selectedSourceID { selectedTargetID = "codex" }
|
|
@@ -199,12 +261,17 @@ final class SyncModel: ObservableObject {
|
|
|
199
261
|
historyEnabled = config.imports?.history ?? false
|
|
200
262
|
menuBarEnabled = config.ui?.menuBar ?? true
|
|
201
263
|
autoCheckUpdates = config.ui?.autoCheckUpdates ?? true
|
|
264
|
+
browserlessProfileName = config.browserless?.profileName ?? "browser-cookie-bridge"
|
|
265
|
+
browserlessRegion = config.browserless?.region ?? "sfo"
|
|
266
|
+
browserlessOnlyDomains = (config.browserless?.onlyDomains ?? []).joined(separator: ", ")
|
|
202
267
|
}
|
|
268
|
+
browserlessConfigured = BrowserlessCredentialStore.read() != nil
|
|
203
269
|
NotificationCenter.default.post(name: .menuBarVisibilityChanged, object: menuBarEnabled)
|
|
204
270
|
extensionsReady = requiredExtensionIDs.allSatisfy {
|
|
205
271
|
FileManager.default.fileExists(atPath: support.appending(path: "extension-\($0)/manifest.json").path)
|
|
206
272
|
}
|
|
207
|
-
|
|
273
|
+
updateEndpointRunningStatus()
|
|
274
|
+
refreshBrowserlessPreflight()
|
|
208
275
|
consumeUpdateResultIfNeeded()
|
|
209
276
|
if autoCheckUpdates && !didCheckAfterLaunch {
|
|
210
277
|
didCheckAfterLaunch = true
|
|
@@ -215,15 +282,19 @@ final class SyncModel: ObservableObject {
|
|
|
215
282
|
func selectSource(_ id: String) {
|
|
216
283
|
guard browsers.contains(where: { $0.id == id }), id != selectedSourceID, id != selectedTargetID else { return }
|
|
217
284
|
selectedSourceID = id
|
|
285
|
+
browserlessAssessment = nil
|
|
286
|
+
assessedBrowserID = nil
|
|
218
287
|
persistPreferences(successMessage: "Export source changed to \(selectedBrowser.name)")
|
|
219
288
|
}
|
|
220
289
|
|
|
221
290
|
func selectTarget(_ id: String) {
|
|
222
|
-
let validTarget = id == "codex" || browsers.contains(where: { $0.id == id })
|
|
291
|
+
let validTarget = id == "codex" || id == "browserless" || browsers.contains(where: { $0.id == id })
|
|
223
292
|
guard validTarget, id != selectedTargetID, id != selectedSourceID else { return }
|
|
224
293
|
selectedTargetID = id
|
|
225
294
|
persistPreferences(successMessage: "Import destination changed to \(targetName)")
|
|
226
|
-
|
|
295
|
+
updateEndpointRunningStatus()
|
|
296
|
+
if id == "browserless" && !browserlessConfigured { showingBrowserlessSetup = true }
|
|
297
|
+
if id == "browserless" { refreshBrowserlessPreflight() }
|
|
227
298
|
}
|
|
228
299
|
|
|
229
300
|
func setCookiesEnabled(_ enabled: Bool) {
|
|
@@ -236,6 +307,34 @@ final class SyncModel: ObservableObject {
|
|
|
236
307
|
persistPreferences(successMessage: enabled ? "History URL import enabled" : "History import disabled")
|
|
237
308
|
}
|
|
238
309
|
|
|
310
|
+
func saveBrowserlessSettings(token: String, profileName: String, region: String, onlyDomains: String) {
|
|
311
|
+
let cleanedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
312
|
+
let cleanedName = profileName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
313
|
+
guard !cleanedName.isEmpty, !cleanedToken.isEmpty || BrowserlessCredentialStore.read() != nil else {
|
|
314
|
+
postNativeAlert(title: "Browserless connection incomplete", message: "Enter an API token and cloud profile name.", kind: .warning)
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
do {
|
|
318
|
+
if !cleanedToken.isEmpty { try BrowserlessCredentialStore.save(cleanedToken) }
|
|
319
|
+
browserlessConfigured = true
|
|
320
|
+
browserlessProfileName = cleanedName
|
|
321
|
+
browserlessRegion = region
|
|
322
|
+
browserlessOnlyDomains = onlyDomains
|
|
323
|
+
showingBrowserlessSetup = false
|
|
324
|
+
persistPreferences(successMessage: "Browserless connected — uploads remain manual")
|
|
325
|
+
refreshBrowserlessPreflight(force: true)
|
|
326
|
+
} catch {
|
|
327
|
+
postNativeAlert(title: "Could not save Browserless token", message: error.localizedDescription, kind: .error)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
func disconnectBrowserless() {
|
|
332
|
+
BrowserlessCredentialStore.delete()
|
|
333
|
+
browserlessConfigured = false
|
|
334
|
+
showingBrowserlessSetup = false
|
|
335
|
+
updateEndpointRunningStatus()
|
|
336
|
+
}
|
|
337
|
+
|
|
239
338
|
func setMenuBarEnabled(_ enabled: Bool) {
|
|
240
339
|
menuBarEnabled = enabled
|
|
241
340
|
NotificationCenter.default.post(name: .menuBarVisibilityChanged, object: enabled)
|
|
@@ -334,31 +433,67 @@ final class SyncModel: ObservableObject {
|
|
|
334
433
|
|
|
335
434
|
func syncNow(showMenuBarAlert: Bool = false) {
|
|
336
435
|
guard !isSyncing else {
|
|
436
|
+
if isBrowserlessTarget {
|
|
437
|
+
cancelSync()
|
|
438
|
+
return
|
|
439
|
+
}
|
|
337
440
|
if showMenuBarAlert {
|
|
338
441
|
postNativeAlert(title: "Sync already running", message: "Wait for the current transfer to finish.", kind: .information)
|
|
339
442
|
}
|
|
340
443
|
return
|
|
341
444
|
}
|
|
342
|
-
|
|
343
|
-
guard !
|
|
445
|
+
updateEndpointRunningStatus()
|
|
446
|
+
guard !syncBlocked else {
|
|
344
447
|
if showMenuBarAlert {
|
|
345
448
|
postNativeAlert(title: primaryStatus, message: secondaryStatus, kind: .warning)
|
|
346
449
|
}
|
|
347
450
|
return
|
|
348
451
|
}
|
|
349
452
|
isSyncing = true
|
|
453
|
+
uploadCanceling = false
|
|
350
454
|
state = .syncing
|
|
351
|
-
primaryStatus = "Transferring selected data"
|
|
455
|
+
primaryStatus = isBrowserlessTarget ? "Uploading authenticated state" : "Transferring selected data"
|
|
352
456
|
secondaryStatus = selectedTargetID == "codex"
|
|
353
457
|
? "Backing up Codex and merging \(selectedBrowser.name) locally…"
|
|
354
|
-
:
|
|
355
|
-
|
|
458
|
+
: isBrowserlessTarget
|
|
459
|
+
? "Sending \(selectedBrowser.name) to Browserless \(browserlessRegion.uppercased()) only for this request…"
|
|
460
|
+
: "Waiting for \(selectedBrowser.name) and \(targetName)…"
|
|
461
|
+
var environment: [String: String] = [:]
|
|
462
|
+
var arguments = ["sync", "--timeout", isBrowserlessTarget ? "900" : "300"]
|
|
463
|
+
if isBrowserlessTarget {
|
|
464
|
+
guard let token = BrowserlessCredentialStore.read() else {
|
|
465
|
+
isSyncing = false
|
|
466
|
+
browserlessConfigured = false
|
|
467
|
+
updateEndpointRunningStatus()
|
|
468
|
+
return
|
|
469
|
+
}
|
|
470
|
+
environment["BROWSERLESS_TOKEN"] = token
|
|
471
|
+
arguments.append("--allow-cloud-upload")
|
|
472
|
+
beginUploadTracking()
|
|
473
|
+
}
|
|
474
|
+
activeSyncProcess = runCLI(arguments, environment: environment, onLine: { [weak self] line in
|
|
475
|
+
self?.handleBrowserlessProgress(line)
|
|
476
|
+
}) { [weak self] success, output in
|
|
356
477
|
guard let self else { return }
|
|
478
|
+
self.activeSyncProcess = nil
|
|
357
479
|
self.isSyncing = false
|
|
358
|
-
|
|
359
|
-
|
|
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 {
|
|
360
493
|
self.state = partial ? .warning : .success
|
|
361
|
-
self.primaryStatus = self.
|
|
494
|
+
self.primaryStatus = self.isBrowserlessTarget
|
|
495
|
+
? (partial ? "Browserless profile uploaded with omissions" : "Browserless profile uploaded")
|
|
496
|
+
: self.selectedTargetID == "codex"
|
|
362
497
|
? (partial ? "Codex sync completed with warnings" : "Codex sessions updated")
|
|
363
498
|
: (partial ? "Partially synced" : "Transfer complete")
|
|
364
499
|
self.secondaryStatus = self.lastMeaningfulLine(output) ?? "\(self.selectedBrowser.name) and \(self.targetName) are up to date"
|
|
@@ -367,20 +502,54 @@ final class SyncModel: ObservableObject {
|
|
|
367
502
|
self.primaryStatus = "Sync did not finish"
|
|
368
503
|
self.secondaryStatus = self.lastMeaningfulLine(output) ?? (self.selectedTargetID == "codex"
|
|
369
504
|
? "Quit Codex completely, then try again"
|
|
505
|
+
: self.isBrowserlessTarget
|
|
506
|
+
? "Check the API token, close the source browser, and try again"
|
|
370
507
|
: "Keep both browsers open and check the extensions")
|
|
371
508
|
}
|
|
372
|
-
self.
|
|
509
|
+
self.updateEndpointRunningStatus()
|
|
373
510
|
if showMenuBarAlert {
|
|
374
511
|
self.postNativeAlert(
|
|
375
512
|
title: self.primaryStatus,
|
|
376
513
|
message: self.secondaryStatus,
|
|
377
|
-
kind: success ? (partial ? .warning : .information) : .error
|
|
514
|
+
kind: canceled ? .information : success ? (partial ? .warning : .information) : .error
|
|
378
515
|
)
|
|
379
516
|
}
|
|
380
517
|
}
|
|
381
518
|
}
|
|
382
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
|
+
|
|
383
548
|
func setDailyEnabled(_ enabled: Bool) {
|
|
549
|
+
guard !isBrowserlessTarget else {
|
|
550
|
+
postNativeAlert(title: "Cloud uploads are manual-only", message: "Browser Cookie Bridge will never schedule Browserless uploads in the background.", kind: .information)
|
|
551
|
+
return
|
|
552
|
+
}
|
|
384
553
|
dailyEnabled = enabled
|
|
385
554
|
applySchedule(enabled)
|
|
386
555
|
}
|
|
@@ -390,6 +559,10 @@ final class SyncModel: ObservableObject {
|
|
|
390
559
|
}
|
|
391
560
|
|
|
392
561
|
func setLoginSyncEnabled(_ enabled: Bool) {
|
|
562
|
+
guard !isBrowserlessTarget else {
|
|
563
|
+
postNativeAlert(title: "Cloud uploads are manual-only", message: "Login sync does not send authenticated state to Browserless.", kind: .information)
|
|
564
|
+
return
|
|
565
|
+
}
|
|
393
566
|
loginSyncEnabled = enabled
|
|
394
567
|
isWorking = true
|
|
395
568
|
runCLI([enabled ? "enable-login-sync" : "disable-login-sync"]) { [weak self] success, output in
|
|
@@ -453,7 +626,10 @@ final class SyncModel: ObservableObject {
|
|
|
453
626
|
"--cookies", cookiesEnabled ? "on" : "off",
|
|
454
627
|
"--history", historyEnabled ? "on" : "off",
|
|
455
628
|
"--menu-bar", menuBarEnabled ? "on" : "off",
|
|
456
|
-
"--auto-check-updates", autoCheckUpdates ? "on" : "off"
|
|
629
|
+
"--auto-check-updates", autoCheckUpdates ? "on" : "off",
|
|
630
|
+
"--browserless-profile", browserlessProfileName,
|
|
631
|
+
"--browserless-region", browserlessRegion,
|
|
632
|
+
"--browserless-domains", browserlessOnlyDomains,
|
|
457
633
|
]
|
|
458
634
|
runCLI(arguments) { [weak self] success, output in
|
|
459
635
|
guard let self else { return }
|
|
@@ -461,7 +637,9 @@ final class SyncModel: ObservableObject {
|
|
|
461
637
|
if success {
|
|
462
638
|
self.state = .ready
|
|
463
639
|
self.primaryStatus = successMessage
|
|
464
|
-
self.secondaryStatus =
|
|
640
|
+
self.secondaryStatus = self.isBrowserlessTarget
|
|
641
|
+
? "Cloud uploads run only after you click Upload"
|
|
642
|
+
: "This choice is saved for manual and daily syncs"
|
|
465
643
|
} else {
|
|
466
644
|
self.state = .error
|
|
467
645
|
self.primaryStatus = "Could not save import settings"
|
|
@@ -471,7 +649,8 @@ final class SyncModel: ObservableObject {
|
|
|
471
649
|
self.extensionsReady = self.requiredExtensionIDs.allSatisfy {
|
|
472
650
|
FileManager.default.fileExists(atPath: self.support.appending(path: "extension-\($0)/manifest.json").path)
|
|
473
651
|
}
|
|
474
|
-
self.
|
|
652
|
+
self.updateEndpointRunningStatus()
|
|
653
|
+
self.refreshBrowserlessPreflight()
|
|
475
654
|
}
|
|
476
655
|
}
|
|
477
656
|
|
|
@@ -500,26 +679,48 @@ final class SyncModel: ObservableObject {
|
|
|
500
679
|
}
|
|
501
680
|
}
|
|
502
681
|
|
|
503
|
-
|
|
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? {
|
|
504
689
|
guard let config = loadConfig() else {
|
|
505
690
|
completion(false, "Configuration missing. Run install-app again.")
|
|
506
|
-
return
|
|
691
|
+
return nil
|
|
507
692
|
}
|
|
508
693
|
let process = Process()
|
|
509
694
|
let output = Pipe()
|
|
695
|
+
let collector = ProcessOutputCollector()
|
|
510
696
|
process.executableURL = URL(fileURLWithPath: config.nodePath)
|
|
511
697
|
process.arguments = [runtimeCLI.path] + arguments
|
|
698
|
+
process.environment = ProcessInfo.processInfo.environment.merging(environment) { _, new in new }
|
|
512
699
|
process.standardOutput = output
|
|
513
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
|
+
}
|
|
514
708
|
process.terminationHandler = { process in
|
|
515
|
-
|
|
516
|
-
let
|
|
517
|
-
|
|
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
|
+
}
|
|
518
717
|
}
|
|
519
718
|
do {
|
|
520
719
|
try process.run()
|
|
720
|
+
return process
|
|
521
721
|
} catch {
|
|
522
722
|
completion(false, error.localizedDescription)
|
|
723
|
+
return nil
|
|
523
724
|
}
|
|
524
725
|
}
|
|
525
726
|
|
|
@@ -546,28 +747,112 @@ final class SyncModel: ObservableObject {
|
|
|
546
747
|
return line.hasPrefix("Error: ") ? String(line.dropFirst(7)) : line
|
|
547
748
|
}
|
|
548
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
|
+
|
|
549
813
|
private var formattedTime: String {
|
|
550
814
|
scheduleTime.formatted(date: .omitted, time: .shortened)
|
|
551
815
|
}
|
|
552
816
|
|
|
553
817
|
private var requiredExtensionIDs: [String] {
|
|
554
|
-
selectedTargetID == "codex" ? [] : [selectedSourceID, selectedTargetID]
|
|
818
|
+
selectedTargetID == "codex" || selectedTargetID == "browserless" ? [] : [selectedSourceID, selectedTargetID]
|
|
555
819
|
}
|
|
556
820
|
|
|
557
|
-
private func
|
|
821
|
+
private func updateEndpointRunningStatus() {
|
|
558
822
|
let wasRunning = codexRunning
|
|
559
823
|
codexRunning = NSWorkspace.shared.runningApplications.contains {
|
|
560
824
|
$0.bundleIdentifier == "com.openai.codex"
|
|
561
825
|
}
|
|
562
|
-
|
|
563
|
-
|
|
826
|
+
sourceBrowserRunning = NSWorkspace.shared.runningApplications.contains {
|
|
827
|
+
$0.bundleIdentifier == selectedBrowser.bundleIdentifier
|
|
828
|
+
}
|
|
829
|
+
guard !isSyncing else { return }
|
|
830
|
+
if selectedTargetID == "codex" && codexRunning {
|
|
564
831
|
state = .warning
|
|
565
832
|
primaryStatus = "Quit Codex before syncing"
|
|
566
833
|
secondaryStatus = "Close ChatGPT Codex completely so its local cookie database can be updated safely"
|
|
567
|
-
} else if wasRunning && primaryStatus == "Quit Codex before syncing" {
|
|
834
|
+
} else if selectedTargetID == "codex" && wasRunning && primaryStatus == "Quit Codex before syncing" {
|
|
568
835
|
state = .ready
|
|
569
836
|
primaryStatus = "Ready to sync directly"
|
|
570
837
|
secondaryStatus = "Codex is closed — a backup will be created before anything changes"
|
|
838
|
+
} else if isBrowserlessTarget {
|
|
839
|
+
if selectedSourceID == "comet" {
|
|
840
|
+
state = .warning
|
|
841
|
+
primaryStatus = "Comet capture is not supported"
|
|
842
|
+
secondaryStatus = "Choose Brave, Chrome, Edge, Arc, Vivaldi, or Opera for Browserless"
|
|
843
|
+
} else if !browserlessConfigured {
|
|
844
|
+
state = .warning
|
|
845
|
+
primaryStatus = "Connect Browserless"
|
|
846
|
+
secondaryStatus = "Your API token will be stored in macOS Keychain, never in the app configuration"
|
|
847
|
+
} else if sourceBrowserRunning {
|
|
848
|
+
state = .warning
|
|
849
|
+
primaryStatus = "Quit \(selectedBrowser.name) before uploading"
|
|
850
|
+
secondaryStatus = "Browserless captures a temporary copy of the closed profile, including local storage and IndexedDB"
|
|
851
|
+
} else {
|
|
852
|
+
state = .ready
|
|
853
|
+
primaryStatus = "Ready for an explicit cloud upload"
|
|
854
|
+
secondaryStatus = "Only this click sends authenticated state to Browserless \(browserlessRegion.uppercased())"
|
|
855
|
+
}
|
|
571
856
|
}
|
|
572
857
|
}
|
|
573
858
|
|
|
@@ -647,6 +932,7 @@ private struct AppConfig: Decodable {
|
|
|
647
932
|
let targetBrowser: String?
|
|
648
933
|
let imports: Imports?
|
|
649
934
|
let ui: UISettings?
|
|
935
|
+
let browserless: BrowserlessSettings?
|
|
650
936
|
|
|
651
937
|
struct Schedule: Decodable {
|
|
652
938
|
let hour: Int
|
|
@@ -663,4 +949,84 @@ private struct AppConfig: Decodable {
|
|
|
663
949
|
let openAtLogin: Bool?
|
|
664
950
|
let autoCheckUpdates: Bool?
|
|
665
951
|
}
|
|
952
|
+
|
|
953
|
+
struct BrowserlessSettings: Decodable {
|
|
954
|
+
let profileName: String?
|
|
955
|
+
let region: String?
|
|
956
|
+
let onlyDomains: [String]?
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
private enum BrowserlessCredentialStore {
|
|
961
|
+
private static let service = "com.apoorvdarshan.browser-cookie-bridge.browserless"
|
|
962
|
+
private static let account = "api-token"
|
|
963
|
+
|
|
964
|
+
static func save(_ token: String) throws {
|
|
965
|
+
let identity: [String: Any] = [
|
|
966
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
967
|
+
kSecAttrService as String: service,
|
|
968
|
+
kSecAttrAccount as String: account,
|
|
969
|
+
]
|
|
970
|
+
let update: [String: Any] = [kSecValueData as String: Data(token.utf8)]
|
|
971
|
+
let updateStatus = SecItemUpdate(identity as CFDictionary, update as CFDictionary)
|
|
972
|
+
if updateStatus == errSecSuccess { return }
|
|
973
|
+
guard updateStatus == errSecItemNotFound else {
|
|
974
|
+
throw NSError(domain: NSOSStatusErrorDomain, code: Int(updateStatus), userInfo: nil)
|
|
975
|
+
}
|
|
976
|
+
let item: [String: Any] = identity.merging([
|
|
977
|
+
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
|
978
|
+
kSecValueData as String: Data(token.utf8),
|
|
979
|
+
]) { _, new in new }
|
|
980
|
+
let status = SecItemAdd(item as CFDictionary, nil)
|
|
981
|
+
guard status == errSecSuccess else {
|
|
982
|
+
throw NSError(domain: NSOSStatusErrorDomain, code: Int(status), userInfo: nil)
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
static func read() -> String? {
|
|
987
|
+
let query: [String: Any] = [
|
|
988
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
989
|
+
kSecAttrService as String: service,
|
|
990
|
+
kSecAttrAccount as String: account,
|
|
991
|
+
kSecReturnData as String: true,
|
|
992
|
+
kSecMatchLimit as String: kSecMatchLimitOne,
|
|
993
|
+
]
|
|
994
|
+
var result: CFTypeRef?
|
|
995
|
+
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
|
996
|
+
let data = result as? Data else { return nil }
|
|
997
|
+
return String(data: data, encoding: .utf8)
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
static func delete() {
|
|
1001
|
+
let query: [String: Any] = [
|
|
1002
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
1003
|
+
kSecAttrService as String: service,
|
|
1004
|
+
kSecAttrAccount as String: account,
|
|
1005
|
+
]
|
|
1006
|
+
SecItemDelete(query as CFDictionary)
|
|
1007
|
+
}
|
|
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
|
+
}
|
|
666
1032
|
}
|