browser-cookie-bridge 1.0.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.
@@ -0,0 +1,609 @@
1
+ import AppKit
2
+ import Foundation
3
+
4
+ extension Notification.Name {
5
+ static let menuBarVisibilityChanged = Notification.Name("BraveCodexSync.menuBarVisibilityChanged")
6
+ static let nativeAlert = Notification.Name("BraveCodexSync.nativeAlert")
7
+ static let updateStateChanged = Notification.Name("BraveCodexSync.updateStateChanged")
8
+ }
9
+
10
+ struct NativeAlert {
11
+ enum Kind { case information, warning, error }
12
+ let title: String
13
+ let message: String
14
+ let kind: Kind
15
+ }
16
+
17
+ struct UpdateMenuState {
18
+ let version: String?
19
+ let checking: Bool
20
+ let installing: Bool
21
+ }
22
+
23
+ struct BrowserChoice: Identifiable, Hashable {
24
+ let id: String
25
+ let name: String
26
+ let bundleIdentifier: String
27
+ let applicationName: String
28
+ let extensionURL: String
29
+ }
30
+
31
+ @MainActor
32
+ final class SyncModel: ObservableObject {
33
+ enum State { case ready, syncing, success, warning, error }
34
+
35
+ let browsers = [
36
+ BrowserChoice(id: "brave", name: "Brave", bundleIdentifier: "com.brave.Browser", applicationName: "Brave Browser", extensionURL: "brave://extensions"),
37
+ BrowserChoice(id: "chrome", name: "Chrome", bundleIdentifier: "com.google.Chrome", applicationName: "Google Chrome", extensionURL: "chrome://extensions"),
38
+ BrowserChoice(id: "edge", name: "Edge", bundleIdentifier: "com.microsoft.edgemac", applicationName: "Microsoft Edge", extensionURL: "edge://extensions"),
39
+ BrowserChoice(id: "arc", name: "Arc", bundleIdentifier: "company.thebrowser.Browser", applicationName: "Arc", extensionURL: "chrome://extensions"),
40
+ BrowserChoice(id: "vivaldi", name: "Vivaldi", bundleIdentifier: "com.vivaldi.Vivaldi", applicationName: "Vivaldi", extensionURL: "vivaldi://extensions"),
41
+ BrowserChoice(id: "opera", name: "Opera", bundleIdentifier: "com.operasoftware.Opera", applicationName: "Opera", extensionURL: "opera://extensions"),
42
+ BrowserChoice(id: "comet", name: "Comet", bundleIdentifier: "ai.perplexity.comet", applicationName: "Comet", extensionURL: "chrome://extensions")
43
+ ]
44
+
45
+ @Published var state: State = .ready
46
+ @Published var isSyncing = false
47
+ @Published var isWorking = false
48
+ @Published var dailyEnabled = false
49
+ @Published var loginSyncEnabled = false
50
+ @Published var openAtLogin = false
51
+ @Published var menuBarEnabled = true
52
+ @Published var autoCheckUpdates = true
53
+ @Published var isCheckingForUpdates = false
54
+ @Published var isInstallingUpdate = false
55
+ @Published var availableUpdateVersion: String?
56
+ @Published var scheduleTime = Date()
57
+ @Published var extensionsReady = false
58
+ @Published var cookiesEnabled = true
59
+ @Published var historyEnabled = false
60
+ @Published var selectedSourceID = "brave"
61
+ @Published var selectedTargetID = "codex"
62
+ @Published var codexRunning = false
63
+ @Published var primaryStatus = "Ready to sync"
64
+ @Published var secondaryStatus = "Choose what to move, then start a transfer"
65
+
66
+ private let home = FileManager.default.homeDirectoryForCurrentUser
67
+ private var support: URL { home.appending(path: "Library/Application Support/BraveCodexCookieSync") }
68
+ private var runtimeCLI: URL { support.appending(path: "runtime/bin/brave-codex-cookie-sync.js") }
69
+ private var launchAgent: URL { home.appending(path: "Library/LaunchAgents/com.apoorvdarshan.brave-codex-cookie-sync.plist") }
70
+ private var loginSyncAgent: URL { home.appending(path: "Library/LaunchAgents/com.apoorvdarshan.brave-codex-cookie-sync.login-sync.plist") }
71
+ private var appLoginAgent: URL { home.appending(path: "Library/LaunchAgents/com.apoorvdarshan.brave-codex-cookie-sync.app-login.plist") }
72
+ private var codexStatusTimer: Timer?
73
+ private var updateTimer: Timer?
74
+ private var didCheckAfterLaunch = false
75
+ private var didConsumeUpdateResult = false
76
+
77
+ var selectedBrowser: BrowserChoice {
78
+ browsers.first(where: { $0.id == selectedSourceID }) ?? browsers[0]
79
+ }
80
+
81
+ var selectedTargetBrowser: BrowserChoice? {
82
+ browsers.first(where: { $0.id == selectedTargetID })
83
+ }
84
+
85
+ var targetName: String { selectedTargetBrowser?.name ?? "ChatGPT Codex" }
86
+ var codexBlocked: Bool { selectedTargetID == "codex" && codexRunning }
87
+ var sourceIcon: NSImage { browserIcon(selectedBrowser) }
88
+ var targetIcon: NSImage { selectedTargetBrowser.map(browserIcon) ?? codexIcon }
89
+ var codexIcon: NSImage {
90
+ bundledIcon("chatgpt-codex")
91
+ ?? chatGPTResource("app.icns")
92
+ ?? appIcon(bundleIdentifier: "com.openai.codex", fallbackSymbol: "terminal")
93
+ }
94
+
95
+ func browserIcon(_ browser: BrowserChoice) -> NSImage {
96
+ if browser.id == "brave",
97
+ let bundled = Bundle.main.url(forResource: browser.id, withExtension: "svg", subdirectory: "BrowserIcons"),
98
+ let image = NSImage(contentsOf: bundled) {
99
+ return image
100
+ }
101
+ if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: browser.bundleIdentifier) {
102
+ return NSWorkspace.shared.icon(forFile: appURL.path)
103
+ }
104
+ if let bundled = Bundle.main.url(forResource: browser.id, withExtension: "svg", subdirectory: "BrowserIcons"),
105
+ let image = NSImage(contentsOf: bundled) {
106
+ return image
107
+ }
108
+ return appIcon(bundleIdentifier: browser.bundleIdentifier, fallbackSymbol: "globe")
109
+ }
110
+
111
+ private func bundledIcon(_ name: String) -> NSImage? {
112
+ guard let url = Bundle.main.url(forResource: name, withExtension: "svg", subdirectory: "BrowserIcons") else {
113
+ return nil
114
+ }
115
+ return NSImage(contentsOf: url)
116
+ }
117
+
118
+ init() {
119
+ let calendar = Calendar.current
120
+ scheduleTime = calendar.date(bySettingHour: 9, minute: 0, second: 0, of: Date()) ?? Date()
121
+ updateCodexRunningStatus()
122
+ codexStatusTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
123
+ Task { @MainActor in self?.updateCodexRunningStatus() }
124
+ }
125
+ updateTimer = Timer.scheduledTimer(withTimeInterval: 24 * 60 * 60, repeats: true) { [weak self] _ in
126
+ Task { @MainActor in
127
+ guard let self, self.autoCheckUpdates else { return }
128
+ self.checkForUpdates()
129
+ }
130
+ }
131
+ }
132
+
133
+ func refresh() {
134
+ dailyEnabled = FileManager.default.fileExists(atPath: launchAgent.path)
135
+ loginSyncEnabled = FileManager.default.fileExists(atPath: loginSyncAgent.path)
136
+ openAtLogin = FileManager.default.fileExists(atPath: appLoginAgent.path)
137
+ if let config = loadConfig() {
138
+ let calendar = Calendar.current
139
+ scheduleTime = calendar.date(
140
+ bySettingHour: config.schedule.hour,
141
+ minute: config.schedule.minute,
142
+ second: 0,
143
+ of: Date()
144
+ ) ?? scheduleTime
145
+ let configuredSource = config.sourceBrowser ?? "brave"
146
+ selectedSourceID = browsers.contains(where: { $0.id == configuredSource }) ? configuredSource : "brave"
147
+ let configuredTarget = config.targetBrowser ?? "codex"
148
+ selectedTargetID = configuredTarget == "codex" || browsers.contains(where: { $0.id == configuredTarget })
149
+ ? configuredTarget
150
+ : "codex"
151
+ if selectedTargetID == selectedSourceID { selectedTargetID = "codex" }
152
+ cookiesEnabled = config.imports?.cookies ?? true
153
+ historyEnabled = config.imports?.history ?? false
154
+ menuBarEnabled = config.ui?.menuBar ?? true
155
+ autoCheckUpdates = config.ui?.autoCheckUpdates ?? true
156
+ }
157
+ NotificationCenter.default.post(name: .menuBarVisibilityChanged, object: menuBarEnabled)
158
+ extensionsReady = requiredExtensionIDs.allSatisfy {
159
+ FileManager.default.fileExists(atPath: support.appending(path: "extension-\($0)/manifest.json").path)
160
+ }
161
+ updateCodexRunningStatus()
162
+ consumeUpdateResultIfNeeded()
163
+ if autoCheckUpdates && !didCheckAfterLaunch {
164
+ didCheckAfterLaunch = true
165
+ checkForUpdates()
166
+ }
167
+ }
168
+
169
+ func selectSource(_ id: String) {
170
+ guard browsers.contains(where: { $0.id == id }), id != selectedSourceID, id != selectedTargetID else { return }
171
+ selectedSourceID = id
172
+ persistPreferences(successMessage: "Export source changed to \(selectedBrowser.name)")
173
+ }
174
+
175
+ func selectTarget(_ id: String) {
176
+ let validTarget = id == "codex" || browsers.contains(where: { $0.id == id })
177
+ guard validTarget, id != selectedTargetID, id != selectedSourceID else { return }
178
+ selectedTargetID = id
179
+ persistPreferences(successMessage: "Import destination changed to \(targetName)")
180
+ updateCodexRunningStatus()
181
+ }
182
+
183
+ func setCookiesEnabled(_ enabled: Bool) {
184
+ cookiesEnabled = enabled
185
+ persistPreferences(successMessage: enabled ? "Cookie import enabled" : "Cookie import disabled")
186
+ }
187
+
188
+ func setHistoryEnabled(_ enabled: Bool) {
189
+ historyEnabled = enabled
190
+ persistPreferences(successMessage: enabled ? "History URL import enabled" : "History import disabled")
191
+ }
192
+
193
+ func setMenuBarEnabled(_ enabled: Bool) {
194
+ menuBarEnabled = enabled
195
+ NotificationCenter.default.post(name: .menuBarVisibilityChanged, object: enabled)
196
+ persistPreferences(successMessage: enabled ? "Menu-bar icon enabled" : "Menu-bar icon hidden")
197
+ }
198
+
199
+ func setAutoCheckUpdates(_ enabled: Bool) {
200
+ autoCheckUpdates = enabled
201
+ persistPreferences(successMessage: enabled ? "Automatic update checks enabled" : "Automatic update checks disabled")
202
+ if enabled { checkForUpdates() }
203
+ }
204
+
205
+ func checkForUpdates(showAlert: Bool = false) {
206
+ guard !isCheckingForUpdates, !isInstallingUpdate else { return }
207
+ guard let url = URL(string: "https://registry.npmjs.org/browser-cookie-bridge/latest") else { return }
208
+ isCheckingForUpdates = true
209
+ postUpdateState()
210
+ var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
211
+ request.setValue("Browser-Cookie-Bridge/\(currentVersion)", forHTTPHeaderField: "User-Agent")
212
+ URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
213
+ Task { @MainActor in
214
+ guard let self else { return }
215
+ self.isCheckingForUpdates = false
216
+ let status = (response as? HTTPURLResponse)?.statusCode
217
+ if let error {
218
+ self.postUpdateState()
219
+ if showAlert {
220
+ self.postNativeAlert(title: "Could not check for updates", message: error.localizedDescription, kind: .error)
221
+ }
222
+ return
223
+ }
224
+ guard status == 200,
225
+ let data,
226
+ let release = try? JSONDecoder().decode(PackageRelease.self, from: data) else {
227
+ self.postUpdateState()
228
+ if showAlert {
229
+ let message = status == 404
230
+ ? "No public release is available yet. This development build is already installed."
231
+ : "The update service returned an unexpected response. Try again later."
232
+ self.postNativeAlert(title: "No update information", message: message, kind: status == 404 ? .information : .error)
233
+ }
234
+ return
235
+ }
236
+ if self.isVersion(release.version, newerThan: self.currentVersion) {
237
+ self.availableUpdateVersion = release.version
238
+ if !self.codexBlocked && !self.isSyncing {
239
+ self.state = .ready
240
+ self.primaryStatus = "Update \(release.version) available"
241
+ self.secondaryStatus = "Install it now; the app will relaunch automatically"
242
+ }
243
+ if showAlert {
244
+ self.postNativeAlert(
245
+ title: "Update \(release.version) is available",
246
+ message: "Choose Install Update in the menu bar or click Install in the app.",
247
+ kind: .information
248
+ )
249
+ }
250
+ } else {
251
+ self.availableUpdateVersion = nil
252
+ if showAlert {
253
+ self.postNativeAlert(title: "Browser Cookie Bridge is up to date", message: "Version \(self.currentVersion) is the latest available release.", kind: .information)
254
+ }
255
+ }
256
+ self.postUpdateState()
257
+ }
258
+ }.resume()
259
+ }
260
+
261
+ func installAvailableUpdate() {
262
+ guard let version = availableUpdateVersion, !isInstallingUpdate else { return }
263
+ isInstallingUpdate = true
264
+ state = .syncing
265
+ primaryStatus = "Preparing update \(version)"
266
+ secondaryStatus = "The app will close, install the update, and relaunch automatically"
267
+ postUpdateState()
268
+ runCLI([
269
+ "install-update",
270
+ "--version", version,
271
+ "--app-path", Bundle.main.bundlePath,
272
+ "--app-pid", String(ProcessInfo.processInfo.processIdentifier)
273
+ ]) { [weak self] success, output in
274
+ guard let self else { return }
275
+ if success {
276
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { NSApp.terminate(nil) }
277
+ } else {
278
+ self.isInstallingUpdate = false
279
+ self.state = .error
280
+ self.primaryStatus = "Could not start the update"
281
+ self.secondaryStatus = self.lastMeaningfulLine(output) ?? "Try again from the menu bar"
282
+ self.postUpdateState()
283
+ self.postNativeAlert(title: self.primaryStatus, message: self.secondaryStatus, kind: .error)
284
+ }
285
+ }
286
+ }
287
+
288
+ func syncNow(showMenuBarAlert: Bool = false) {
289
+ guard !isSyncing else {
290
+ if showMenuBarAlert {
291
+ postNativeAlert(title: "Sync already running", message: "Wait for the current transfer to finish.", kind: .information)
292
+ }
293
+ return
294
+ }
295
+ updateCodexRunningStatus()
296
+ guard !codexBlocked else {
297
+ if showMenuBarAlert {
298
+ postNativeAlert(title: primaryStatus, message: secondaryStatus, kind: .warning)
299
+ }
300
+ return
301
+ }
302
+ isSyncing = true
303
+ state = .syncing
304
+ primaryStatus = "Transferring selected data"
305
+ secondaryStatus = selectedTargetID == "codex"
306
+ ? "Backing up Codex and merging \(selectedBrowser.name) locally…"
307
+ : "Waiting for \(selectedBrowser.name) and \(targetName)…"
308
+ runCLI(["sync", "--timeout", "300"]) { [weak self] success, output in
309
+ guard let self else { return }
310
+ self.isSyncing = false
311
+ let partial = success && (output.contains("Partially synced:") || output.contains("with warnings"))
312
+ if success {
313
+ self.state = partial ? .warning : .success
314
+ self.primaryStatus = self.selectedTargetID == "codex"
315
+ ? (partial ? "Codex sync completed with warnings" : "Codex sessions updated")
316
+ : (partial ? "Partially synced" : "Transfer complete")
317
+ self.secondaryStatus = self.lastMeaningfulLine(output) ?? "\(self.selectedBrowser.name) and \(self.targetName) are up to date"
318
+ } else {
319
+ self.state = .error
320
+ self.primaryStatus = "Sync did not finish"
321
+ self.secondaryStatus = self.lastMeaningfulLine(output) ?? (self.selectedTargetID == "codex"
322
+ ? "Quit Codex completely, then try again"
323
+ : "Keep both browsers open and check the extensions")
324
+ }
325
+ self.updateCodexRunningStatus()
326
+ if showMenuBarAlert {
327
+ self.postNativeAlert(
328
+ title: self.primaryStatus,
329
+ message: self.secondaryStatus,
330
+ kind: success ? (partial ? .warning : .information) : .error
331
+ )
332
+ }
333
+ }
334
+ }
335
+
336
+ func setDailyEnabled(_ enabled: Bool) {
337
+ dailyEnabled = enabled
338
+ applySchedule(enabled)
339
+ }
340
+
341
+ func saveSchedule() {
342
+ applySchedule(true)
343
+ }
344
+
345
+ func setLoginSyncEnabled(_ enabled: Bool) {
346
+ loginSyncEnabled = enabled
347
+ isWorking = true
348
+ runCLI([enabled ? "enable-login-sync" : "disable-login-sync"]) { [weak self] success, output in
349
+ guard let self else { return }
350
+ self.isWorking = false
351
+ if success {
352
+ self.state = .ready
353
+ self.primaryStatus = enabled ? "Sync at login enabled" : "Sync at login disabled"
354
+ self.secondaryStatus = enabled
355
+ ? "A sync starts now and whenever you sign in"
356
+ : "The fixed daily schedule is unchanged"
357
+ } else {
358
+ self.loginSyncEnabled.toggle()
359
+ self.state = .error
360
+ self.primaryStatus = "Could not update login sync"
361
+ self.secondaryStatus = self.lastMeaningfulLine(output) ?? "Run install-app again from the CLI"
362
+ }
363
+ self.refresh()
364
+ }
365
+ }
366
+
367
+ func setOpenAtLogin(_ enabled: Bool) {
368
+ openAtLogin = enabled
369
+ isWorking = true
370
+ runCLI([enabled ? "enable-app-login" : "disable-app-login"]) { [weak self] success, output in
371
+ guard let self else { return }
372
+ self.isWorking = false
373
+ if success {
374
+ self.state = .ready
375
+ self.primaryStatus = enabled ? "Opens at login" : "Login launch disabled"
376
+ self.secondaryStatus = enabled ? "The app starts automatically after sign-in" : "Open the app manually when you need it"
377
+ } else {
378
+ self.openAtLogin.toggle()
379
+ self.state = .error
380
+ self.primaryStatus = "Could not update login launch"
381
+ self.secondaryStatus = self.lastMeaningfulLine(output) ?? "Run install-app again from the CLI"
382
+ }
383
+ self.refresh()
384
+ }
385
+ }
386
+
387
+ func openExtensions(for browserID: String) {
388
+ guard let browser = browsers.first(where: { $0.id == browserID }) else { return }
389
+ let process = Process()
390
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/open")
391
+ process.arguments = ["-a", browser.applicationName, browser.extensionURL]
392
+ try? process.run()
393
+ }
394
+
395
+ func revealExtension(_ role: String) {
396
+ let folder = support.appending(path: "extension-\(role)")
397
+ NSWorkspace.shared.activateFileViewerSelecting([folder])
398
+ }
399
+
400
+ private func persistPreferences(successMessage: String) {
401
+ isWorking = true
402
+ let arguments = [
403
+ "preferences",
404
+ "--source", selectedSourceID,
405
+ "--target", selectedTargetID,
406
+ "--cookies", cookiesEnabled ? "on" : "off",
407
+ "--history", historyEnabled ? "on" : "off",
408
+ "--menu-bar", menuBarEnabled ? "on" : "off",
409
+ "--auto-check-updates", autoCheckUpdates ? "on" : "off"
410
+ ]
411
+ runCLI(arguments) { [weak self] success, output in
412
+ guard let self else { return }
413
+ self.isWorking = false
414
+ if success {
415
+ self.state = .ready
416
+ self.primaryStatus = successMessage
417
+ self.secondaryStatus = "This choice is saved for manual and daily syncs"
418
+ } else {
419
+ self.state = .error
420
+ self.primaryStatus = "Could not save import settings"
421
+ self.secondaryStatus = self.lastMeaningfulLine(output) ?? "Run install-app again from the CLI"
422
+ self.refresh()
423
+ }
424
+ self.extensionsReady = self.requiredExtensionIDs.allSatisfy {
425
+ FileManager.default.fileExists(atPath: self.support.appending(path: "extension-\($0)/manifest.json").path)
426
+ }
427
+ self.updateCodexRunningStatus()
428
+ }
429
+ }
430
+
431
+ private func applySchedule(_ enabled: Bool) {
432
+ isWorking = true
433
+ let calendar = Calendar.current
434
+ let hour = calendar.component(.hour, from: scheduleTime)
435
+ let minute = calendar.component(.minute, from: scheduleTime)
436
+ let arguments = enabled
437
+ ? ["setup", "--hour", String(hour), "--minute", String(minute)]
438
+ : ["remove-schedule"]
439
+ runCLI(arguments) { [weak self] success, output in
440
+ guard let self else { return }
441
+ self.isWorking = false
442
+ if success {
443
+ self.state = .ready
444
+ self.primaryStatus = enabled ? "Daily sync enabled" : "Daily sync disabled"
445
+ self.secondaryStatus = enabled ? "Scheduled for \(self.formattedTime)" : "Use Sync now whenever you need it"
446
+ } else {
447
+ self.dailyEnabled.toggle()
448
+ self.state = .error
449
+ self.primaryStatus = "Could not update schedule"
450
+ self.secondaryStatus = self.lastMeaningfulLine(output) ?? "Run setup again from the CLI"
451
+ }
452
+ self.refresh()
453
+ }
454
+ }
455
+
456
+ private func runCLI(_ arguments: [String], completion: @escaping @MainActor (Bool, String) -> Void) {
457
+ guard let config = loadConfig() else {
458
+ completion(false, "Configuration missing. Run install-app again.")
459
+ return
460
+ }
461
+ let process = Process()
462
+ let output = Pipe()
463
+ process.executableURL = URL(fileURLWithPath: config.nodePath)
464
+ process.arguments = [runtimeCLI.path] + arguments
465
+ process.standardOutput = output
466
+ process.standardError = output
467
+ process.terminationHandler = { process in
468
+ let data = output.fileHandleForReading.readDataToEndOfFile()
469
+ let text = String(decoding: data, as: UTF8.self)
470
+ Task { @MainActor in completion(process.terminationStatus == 0, text) }
471
+ }
472
+ do {
473
+ try process.run()
474
+ } catch {
475
+ completion(false, error.localizedDescription)
476
+ }
477
+ }
478
+
479
+ private func appIcon(bundleIdentifier: String, fallbackSymbol: String) -> NSImage {
480
+ if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) {
481
+ return NSWorkspace.shared.icon(forFile: url.path)
482
+ }
483
+ return NSImage(systemSymbolName: fallbackSymbol, accessibilityDescription: nil) ?? NSImage()
484
+ }
485
+
486
+ private func chatGPTResource(_ filename: String) -> NSImage? {
487
+ guard let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.openai.codex") else { return nil }
488
+ return NSImage(contentsOf: appURL.appending(path: "Contents/Resources/\(filename)"))
489
+ }
490
+
491
+ private func loadConfig() -> AppConfig? {
492
+ let url = support.appending(path: "config.json")
493
+ guard let data = try? Data(contentsOf: url) else { return nil }
494
+ return try? JSONDecoder().decode(AppConfig.self, from: data)
495
+ }
496
+
497
+ private func lastMeaningfulLine(_ output: String) -> String? {
498
+ guard let line = output.split(separator: "\n").map(String.init).last(where: { !$0.isEmpty }) else { return nil }
499
+ return line.hasPrefix("Error: ") ? String(line.dropFirst(7)) : line
500
+ }
501
+
502
+ private var formattedTime: String {
503
+ scheduleTime.formatted(date: .omitted, time: .shortened)
504
+ }
505
+
506
+ private var requiredExtensionIDs: [String] {
507
+ selectedTargetID == "codex" ? [] : [selectedSourceID, selectedTargetID]
508
+ }
509
+
510
+ private func updateCodexRunningStatus() {
511
+ let wasRunning = codexRunning
512
+ codexRunning = NSWorkspace.shared.runningApplications.contains {
513
+ $0.bundleIdentifier == "com.openai.codex"
514
+ }
515
+ guard selectedTargetID == "codex", !isSyncing else { return }
516
+ if codexRunning {
517
+ state = .warning
518
+ primaryStatus = "Quit Codex before syncing"
519
+ secondaryStatus = "Close ChatGPT Codex completely so its local cookie database can be updated safely"
520
+ } else if wasRunning && primaryStatus == "Quit Codex before syncing" {
521
+ state = .ready
522
+ primaryStatus = "Ready to sync directly"
523
+ secondaryStatus = "Codex is closed — a backup will be created before anything changes"
524
+ }
525
+ }
526
+
527
+ private var currentVersion: String {
528
+ Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0"
529
+ }
530
+
531
+ private func isVersion(_ candidate: String, newerThan installed: String) -> Bool {
532
+ let lhs = candidate.split(separator: "-", maxSplits: 1)[0].split(separator: ".").map { Int($0) ?? 0 }
533
+ let rhs = installed.split(separator: "-", maxSplits: 1)[0].split(separator: ".").map { Int($0) ?? 0 }
534
+ for index in 0..<max(lhs.count, rhs.count) {
535
+ let left = index < lhs.count ? lhs[index] : 0
536
+ let right = index < rhs.count ? rhs[index] : 0
537
+ if left != right { return left > right }
538
+ }
539
+ return false
540
+ }
541
+
542
+ private func consumeUpdateResultIfNeeded() {
543
+ guard !didConsumeUpdateResult else { return }
544
+ didConsumeUpdateResult = true
545
+ let url = support.appending(path: "update-result.json")
546
+ guard let data = try? Data(contentsOf: url),
547
+ let result = try? JSONDecoder().decode(UpdateResult.self, from: data) else { return }
548
+ try? FileManager.default.removeItem(at: url)
549
+ if result.status == "success" {
550
+ state = .success
551
+ primaryStatus = "Updated to version \(result.version)"
552
+ secondaryStatus = "Browser Cookie Bridge was installed and relaunched successfully"
553
+ } else {
554
+ state = .error
555
+ primaryStatus = "Update \(result.version) failed"
556
+ secondaryStatus = result.message ?? "The previous app has been reopened"
557
+ postNativeAlert(title: primaryStatus, message: secondaryStatus, kind: .error)
558
+ }
559
+ }
560
+
561
+ private func postUpdateState() {
562
+ NotificationCenter.default.post(
563
+ name: .updateStateChanged,
564
+ object: UpdateMenuState(version: availableUpdateVersion, checking: isCheckingForUpdates, installing: isInstallingUpdate)
565
+ )
566
+ }
567
+
568
+ private func postNativeAlert(title: String, message: String, kind: NativeAlert.Kind) {
569
+ NotificationCenter.default.post(
570
+ name: .nativeAlert,
571
+ object: NativeAlert(title: title, message: message, kind: kind)
572
+ )
573
+ }
574
+ }
575
+
576
+ private struct PackageRelease: Decodable {
577
+ let version: String
578
+ }
579
+
580
+ private struct UpdateResult: Decodable {
581
+ let status: String
582
+ let version: String
583
+ let message: String?
584
+ }
585
+
586
+ private struct AppConfig: Decodable {
587
+ let nodePath: String
588
+ let schedule: Schedule
589
+ let sourceBrowser: String?
590
+ let targetBrowser: String?
591
+ let imports: Imports?
592
+ let ui: UISettings?
593
+
594
+ struct Schedule: Decodable {
595
+ let hour: Int
596
+ let minute: Int
597
+ }
598
+
599
+ struct Imports: Decodable {
600
+ let cookies: Bool
601
+ let history: Bool
602
+ }
603
+
604
+ struct UISettings: Decodable {
605
+ let menuBar: Bool?
606
+ let openAtLogin: Bool?
607
+ let autoCheckUpdates: Bool?
608
+ }
609
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "browser-cookie-bridge",
3
+ "version": "1.0.0",
4
+ "description": "Private local cookie and session transfer for macOS",
5
+ "type": "module",
6
+ "bin": {
7
+ "browser-cookie-bridge": "./bin/brave-codex-cookie-sync.js"
8
+ },
9
+ "scripts": {
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",
12
+ "release:check": "node scripts/check-release-version.js",
13
+ "build:app": "node bin/brave-codex-cookie-sync.js install-app --no-open",
14
+ "web": "node web/server.js",
15
+ "web:deploy": "wrangler deploy --config web/wrangler.jsonc"
16
+ },
17
+ "engines": {
18
+ "node": ">=22.5"
19
+ },
20
+ "license": "MIT",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/apoorvdarshan/browser-cookie-bridge.git"
24
+ },
25
+ "homepage": "https://github.com/apoorvdarshan/browser-cookie-bridge#readme",
26
+ "bugs": {
27
+ "url": "https://github.com/apoorvdarshan/browser-cookie-bridge/issues"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public",
31
+ "registry": "https://registry.npmjs.org/"
32
+ },
33
+ "files": [
34
+ "bin",
35
+ "src",
36
+ "extension-template",
37
+ "macos-app/Package.swift",
38
+ "macos-app/Info.plist",
39
+ "macos-app/Resources",
40
+ "macos-app/Sources",
41
+ "README.md",
42
+ "LICENSE"
43
+ ]
44
+ }