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.
- package/LICENSE +21 -0
- package/README.md +221 -0
- package/bin/brave-codex-cookie-sync.js +8 -0
- package/extension-template/background.js +196 -0
- package/extension-template/manifest.json +12 -0
- package/macos-app/Info.plist +18 -0
- package/macos-app/Package.swift +17 -0
- package/macos-app/Resources/AppIcon.icns +0 -0
- package/macos-app/Resources/AppIcon.png +0 -0
- package/macos-app/Resources/BrowserIcons/arc.svg +1 -0
- package/macos-app/Resources/BrowserIcons/brave.svg +17 -0
- package/macos-app/Resources/BrowserIcons/chatgpt-codex.svg +4 -0
- package/macos-app/Resources/BrowserIcons/chrome.svg +1 -0
- package/macos-app/Resources/BrowserIcons/comet.svg +4 -0
- package/macos-app/Resources/BrowserIcons/edge.svg +1 -0
- package/macos-app/Resources/BrowserIcons/opera.svg +1 -0
- package/macos-app/Resources/BrowserIcons/vivaldi.svg +1 -0
- package/macos-app/Resources/MenuBarCookie.svg +15 -0
- package/macos-app/Resources/MenuBarCookieTemplate.png +0 -0
- package/macos-app/Sources/BraveCodexSyncApp/BraveCodexSyncApp.swift +771 -0
- package/macos-app/Sources/BraveCodexSyncApp/SyncModel.swift +609 -0
- package/package.json +44 -0
- package/src/app-installer.js +54 -0
- package/src/broker.js +248 -0
- package/src/chromium-reader.js +190 -0
- package/src/cli.js +368 -0
- package/src/codex-direct-import.js +320 -0
- package/src/config.js +141 -0
- package/src/paths.js +80 -0
- package/src/scheduler.js +150 -0
- package/src/updater.js +144 -0
|
@@ -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
|
+
}
|