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,771 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import SwiftUI
|
|
3
|
+
|
|
4
|
+
private enum ProjectLinks {
|
|
5
|
+
static let repository = URL(string: "https://github.com/apoorvdarshan/browser-cookie-bridge")!
|
|
6
|
+
static let issues = URL(string: "https://github.com/apoorvdarshan/browser-cookie-bridge/issues/new?template=bug_report.yml")!
|
|
7
|
+
static let license = URL(string: "https://github.com/apoorvdarshan/browser-cookie-bridge/blob/main/LICENSE")!
|
|
8
|
+
static let koFi = URL(string: "https://ko-fi.com/apoorvdarshan")!
|
|
9
|
+
static let x = URL(string: "https://x.com/apoorvdarshan")!
|
|
10
|
+
static let productHunt = URL(string: "https://www.producthunt.com/products/browser-cookie-bridge")!
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
@main
|
|
14
|
+
struct BraveCodexSyncApp: App {
|
|
15
|
+
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
|
16
|
+
@StateObject private var model = SyncModel()
|
|
17
|
+
|
|
18
|
+
var body: some Scene {
|
|
19
|
+
Window("Browser Cookie Bridge", id: "main") {
|
|
20
|
+
ContentView(appDelegate: appDelegate)
|
|
21
|
+
.environmentObject(model)
|
|
22
|
+
.frame(width: 644, height: 730)
|
|
23
|
+
.background(AppBackground())
|
|
24
|
+
}
|
|
25
|
+
.windowResizability(.contentSize)
|
|
26
|
+
.commands { CommandGroup(replacing: .newItem) {} }
|
|
27
|
+
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@MainActor
|
|
32
|
+
final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
|
|
33
|
+
weak var model: SyncModel?
|
|
34
|
+
private weak var mainWindow: NSWindow?
|
|
35
|
+
private var statusItem: NSStatusItem?
|
|
36
|
+
private var updateMenuItem: NSMenuItem?
|
|
37
|
+
|
|
38
|
+
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
39
|
+
NotificationCenter.default.addObserver(
|
|
40
|
+
self,
|
|
41
|
+
selector: #selector(menuBarVisibilityChanged(_:)),
|
|
42
|
+
name: .menuBarVisibilityChanged,
|
|
43
|
+
object: nil
|
|
44
|
+
)
|
|
45
|
+
NotificationCenter.default.addObserver(
|
|
46
|
+
self,
|
|
47
|
+
selector: #selector(showNativeAlert(_:)),
|
|
48
|
+
name: .nativeAlert,
|
|
49
|
+
object: nil
|
|
50
|
+
)
|
|
51
|
+
NotificationCenter.default.addObserver(
|
|
52
|
+
self,
|
|
53
|
+
selector: #selector(updateStateChanged(_:)),
|
|
54
|
+
name: .updateStateChanged,
|
|
55
|
+
object: nil
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false }
|
|
60
|
+
|
|
61
|
+
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
|
|
62
|
+
if !flag { showMainWindow() }
|
|
63
|
+
return true
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func windowShouldClose(_ sender: NSWindow) -> Bool {
|
|
67
|
+
sender.orderOut(nil)
|
|
68
|
+
NSApp.setActivationPolicy(.accessory)
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
func attach(model: SyncModel) {
|
|
73
|
+
self.model = model
|
|
74
|
+
DispatchQueue.main.async { [weak self] in
|
|
75
|
+
guard let self else { return }
|
|
76
|
+
let candidate = NSApp.windows.first(where: { $0.canBecomeMain }) ?? NSApp.windows.first
|
|
77
|
+
self.mainWindow = candidate
|
|
78
|
+
candidate?.delegate = self
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
func updateMenuBar(enabled: Bool) {
|
|
83
|
+
if enabled {
|
|
84
|
+
guard statusItem == nil else { return }
|
|
85
|
+
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
|
86
|
+
let cookieIcon = Bundle.main.url(forResource: "MenuBarCookieTemplate", withExtension: "png")
|
|
87
|
+
.flatMap(NSImage.init(contentsOf:))
|
|
88
|
+
cookieIcon?.size = NSSize(width: 18, height: 18)
|
|
89
|
+
cookieIcon?.isTemplate = true
|
|
90
|
+
cookieIcon?.accessibilityDescription = "Browser Cookie Bridge"
|
|
91
|
+
item.button?.image = cookieIcon
|
|
92
|
+
item.button?.imageScaling = .scaleProportionallyDown
|
|
93
|
+
let menu = NSMenu()
|
|
94
|
+
menu.addItem(withTitle: "Show Browser Cookie Bridge", action: #selector(showMainWindowAction), keyEquivalent: "")
|
|
95
|
+
menu.addItem(withTitle: "Sync now", action: #selector(syncNowAction), keyEquivalent: "")
|
|
96
|
+
let updateItem = menu.addItem(withTitle: "Check for Updates…", action: #selector(updateAction), keyEquivalent: "")
|
|
97
|
+
updateMenuItem = updateItem
|
|
98
|
+
addProjectLinks(to: menu)
|
|
99
|
+
menu.addItem(.separator())
|
|
100
|
+
menu.addItem(withTitle: "Quit", action: #selector(quitAction), keyEquivalent: "q")
|
|
101
|
+
for menuItem in menu.items { menuItem.target = self }
|
|
102
|
+
item.menu = menu
|
|
103
|
+
statusItem = item
|
|
104
|
+
} else if let statusItem {
|
|
105
|
+
NSStatusBar.system.removeStatusItem(statusItem)
|
|
106
|
+
self.statusItem = nil
|
|
107
|
+
updateMenuItem = nil
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
@objc private func menuBarVisibilityChanged(_ notification: Notification) {
|
|
112
|
+
guard let enabled = notification.object as? Bool else { return }
|
|
113
|
+
updateMenuBar(enabled: enabled)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
@objc private func showMainWindowAction() { showMainWindow() }
|
|
117
|
+
@objc private func syncNowAction() { model?.syncNow(showMenuBarAlert: true) }
|
|
118
|
+
@objc private func updateAction() {
|
|
119
|
+
if model?.availableUpdateVersion != nil { model?.installAvailableUpdate() }
|
|
120
|
+
else { model?.checkForUpdates(showAlert: true) }
|
|
121
|
+
}
|
|
122
|
+
@objc private func quitAction() { NSApp.terminate(nil) }
|
|
123
|
+
|
|
124
|
+
@objc private func openProjectLink(_ sender: NSMenuItem) {
|
|
125
|
+
guard let address = sender.representedObject as? String,
|
|
126
|
+
let url = URL(string: address) else { return }
|
|
127
|
+
NSWorkspace.shared.open(url)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
@objc private func showNativeAlert(_ notification: Notification) {
|
|
131
|
+
guard let payload = notification.object as? NativeAlert else { return }
|
|
132
|
+
let alert = NSAlert()
|
|
133
|
+
alert.messageText = payload.title
|
|
134
|
+
alert.informativeText = payload.message
|
|
135
|
+
alert.alertStyle = switch payload.kind {
|
|
136
|
+
case .information: .informational
|
|
137
|
+
case .warning: .warning
|
|
138
|
+
case .error: .critical
|
|
139
|
+
}
|
|
140
|
+
alert.addButton(withTitle: "OK")
|
|
141
|
+
NSApp.activate(ignoringOtherApps: true)
|
|
142
|
+
alert.runModal()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
@objc private func updateStateChanged(_ notification: Notification) {
|
|
146
|
+
guard let payload = notification.object as? UpdateMenuState else { return }
|
|
147
|
+
if payload.installing {
|
|
148
|
+
updateMenuItem?.title = "Installing Update…"
|
|
149
|
+
updateMenuItem?.isEnabled = false
|
|
150
|
+
} else if payload.checking {
|
|
151
|
+
updateMenuItem?.title = "Checking for Updates…"
|
|
152
|
+
updateMenuItem?.isEnabled = false
|
|
153
|
+
} else if let version = payload.version {
|
|
154
|
+
updateMenuItem?.title = "Install Update \(version)…"
|
|
155
|
+
updateMenuItem?.isEnabled = true
|
|
156
|
+
} else {
|
|
157
|
+
updateMenuItem?.title = "Check for Updates…"
|
|
158
|
+
updateMenuItem?.isEnabled = true
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private func showMainWindow() {
|
|
163
|
+
NSApp.setActivationPolicy(.regular)
|
|
164
|
+
mainWindow?.makeKeyAndOrderFront(nil)
|
|
165
|
+
NSApp.activate(ignoringOtherApps: true)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private func addProjectLinks(to menu: NSMenu) {
|
|
169
|
+
menu.addItem(.separator())
|
|
170
|
+
addLink("Open-source repository", url: ProjectLinks.repository, to: menu)
|
|
171
|
+
addLink("Report a bug…", url: ProjectLinks.issues, to: menu)
|
|
172
|
+
addLink("MIT license", url: ProjectLinks.license, to: menu)
|
|
173
|
+
menu.addItem(.separator())
|
|
174
|
+
addLink("Support on Ko-fi", url: ProjectLinks.koFi, to: menu)
|
|
175
|
+
addLink("Follow @apoorvdarshan on X", url: ProjectLinks.x, to: menu)
|
|
176
|
+
addLink("View on Product Hunt", url: ProjectLinks.productHunt, to: menu)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private func addLink(_ title: String, url: URL, to menu: NSMenu) {
|
|
180
|
+
let item = menu.addItem(withTitle: title, action: #selector(openProjectLink(_:)), keyEquivalent: "")
|
|
181
|
+
item.target = self
|
|
182
|
+
item.representedObject = url.absoluteString
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
struct ContentView: View {
|
|
187
|
+
@EnvironmentObject private var model: SyncModel
|
|
188
|
+
@State private var showingSetup = false
|
|
189
|
+
let appDelegate: AppDelegate
|
|
190
|
+
|
|
191
|
+
var body: some View {
|
|
192
|
+
VStack(spacing: 12) {
|
|
193
|
+
header
|
|
194
|
+
ScrollView {
|
|
195
|
+
VStack(spacing: 12) {
|
|
196
|
+
SyncPanel()
|
|
197
|
+
PreferencesPanel()
|
|
198
|
+
}
|
|
199
|
+
.padding(.trailing, 2)
|
|
200
|
+
}
|
|
201
|
+
footer
|
|
202
|
+
}
|
|
203
|
+
.padding(18)
|
|
204
|
+
.onAppear {
|
|
205
|
+
appDelegate.attach(model: model)
|
|
206
|
+
model.refresh()
|
|
207
|
+
appDelegate.updateMenuBar(enabled: model.menuBarEnabled)
|
|
208
|
+
}
|
|
209
|
+
.sheet(isPresented: $showingSetup) {
|
|
210
|
+
ExtensionSetupSheet().environmentObject(model)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private var header: some View {
|
|
215
|
+
HStack(spacing: 11) {
|
|
216
|
+
Image(nsImage: NSApp.applicationIconImage)
|
|
217
|
+
.resizable()
|
|
218
|
+
.scaledToFill()
|
|
219
|
+
.frame(width: 43, height: 43)
|
|
220
|
+
.frame(width: 36, height: 36)
|
|
221
|
+
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
|
222
|
+
VStack(alignment: .leading, spacing: 1) {
|
|
223
|
+
Text("Browser Cookie Bridge")
|
|
224
|
+
.font(.system(size: 19, weight: .bold, design: .rounded))
|
|
225
|
+
Text("Local cookie and session transfer")
|
|
226
|
+
.font(.system(size: 11.5, weight: .medium))
|
|
227
|
+
.foregroundStyle(.secondary)
|
|
228
|
+
}
|
|
229
|
+
Spacer()
|
|
230
|
+
StatusIndicator(state: model.state)
|
|
231
|
+
}
|
|
232
|
+
.padding(.horizontal, 2)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private var footer: some View {
|
|
236
|
+
HStack(spacing: 10) {
|
|
237
|
+
Label("Data stays on this Mac", systemImage: "lock.shield.fill")
|
|
238
|
+
.foregroundStyle(.secondary)
|
|
239
|
+
Spacer()
|
|
240
|
+
if let version = model.availableUpdateVersion {
|
|
241
|
+
Button(model.isInstallingUpdate ? "Installing…" : "Install \(version)") {
|
|
242
|
+
model.installAvailableUpdate()
|
|
243
|
+
}
|
|
244
|
+
.controlSize(.small)
|
|
245
|
+
.disabled(model.isInstallingUpdate)
|
|
246
|
+
} else {
|
|
247
|
+
Button(model.isCheckingForUpdates ? "Checking…" : "Check for updates…") {
|
|
248
|
+
model.checkForUpdates(showAlert: true)
|
|
249
|
+
}
|
|
250
|
+
.controlSize(.small)
|
|
251
|
+
.disabled(model.isCheckingForUpdates || model.isInstallingUpdate)
|
|
252
|
+
}
|
|
253
|
+
Button {
|
|
254
|
+
model.refresh()
|
|
255
|
+
} label: {
|
|
256
|
+
Image(systemName: "arrow.clockwise")
|
|
257
|
+
}
|
|
258
|
+
.buttonStyle(.plain)
|
|
259
|
+
.foregroundStyle(.secondary)
|
|
260
|
+
.help("Refresh status")
|
|
261
|
+
if model.selectedTargetID != "codex" {
|
|
262
|
+
Button("Extension setup…") { showingSetup = true }
|
|
263
|
+
.controlSize(.small)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
.font(.system(size: 10.5, weight: .medium))
|
|
267
|
+
.padding(.horizontal, 3)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
struct SyncPanel: View {
|
|
272
|
+
@EnvironmentObject private var model: SyncModel
|
|
273
|
+
|
|
274
|
+
var body: some View {
|
|
275
|
+
Surface {
|
|
276
|
+
VStack(spacing: 14) {
|
|
277
|
+
HStack(spacing: 12) {
|
|
278
|
+
SourcePicker()
|
|
279
|
+
RelayPath(active: model.isSyncing)
|
|
280
|
+
TargetPicker()
|
|
281
|
+
}
|
|
282
|
+
Divider().opacity(0.65)
|
|
283
|
+
HStack(spacing: 12) {
|
|
284
|
+
VStack(alignment: .leading, spacing: 2) {
|
|
285
|
+
Text(model.primaryStatus)
|
|
286
|
+
.font(.system(size: 13, weight: .semibold))
|
|
287
|
+
Text(model.secondaryStatus)
|
|
288
|
+
.font(.system(size: 10.5))
|
|
289
|
+
.foregroundStyle(.secondary)
|
|
290
|
+
.lineLimit(2)
|
|
291
|
+
.help(model.secondaryStatus)
|
|
292
|
+
}
|
|
293
|
+
Spacer()
|
|
294
|
+
Button { model.syncNow() } label: {
|
|
295
|
+
HStack(spacing: 7) {
|
|
296
|
+
if model.isSyncing { ProgressView().controlSize(.small) }
|
|
297
|
+
else { Image(systemName: model.codexBlocked ? "xmark.circle.fill" : "arrow.triangle.2.circlepath") }
|
|
298
|
+
Text(model.isSyncing ? "Syncing…" : (model.codexBlocked ? "Close Codex first" : "Sync now"))
|
|
299
|
+
}
|
|
300
|
+
.frame(minWidth: model.codexBlocked ? 116 : 92)
|
|
301
|
+
}
|
|
302
|
+
.buttonStyle(.borderedProminent)
|
|
303
|
+
.tint(Theme.accent)
|
|
304
|
+
.disabled(model.isSyncing || model.codexBlocked)
|
|
305
|
+
.keyboardShortcut(.return, modifiers: .command)
|
|
306
|
+
}
|
|
307
|
+
.padding(model.codexBlocked ? 7 : 0)
|
|
308
|
+
.background(
|
|
309
|
+
model.codexBlocked ? Theme.active.opacity(0.10) : Color.clear,
|
|
310
|
+
in: RoundedRectangle(cornerRadius: 9, style: .continuous)
|
|
311
|
+
)
|
|
312
|
+
.overlay {
|
|
313
|
+
if model.codexBlocked {
|
|
314
|
+
RoundedRectangle(cornerRadius: 9, style: .continuous)
|
|
315
|
+
.stroke(Theme.active.opacity(0.38), lineWidth: 1)
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
struct SourcePicker: View {
|
|
325
|
+
@EnvironmentObject private var model: SyncModel
|
|
326
|
+
|
|
327
|
+
var body: some View {
|
|
328
|
+
VStack(alignment: .leading, spacing: 6) {
|
|
329
|
+
HStack {
|
|
330
|
+
Text("EXPORT FROM")
|
|
331
|
+
.tracking(0.65)
|
|
332
|
+
.foregroundStyle(.tertiary)
|
|
333
|
+
Spacer()
|
|
334
|
+
Text(model.selectedBrowser.name)
|
|
335
|
+
.foregroundStyle(Theme.accent)
|
|
336
|
+
}
|
|
337
|
+
.font(.system(size: 8, weight: .bold))
|
|
338
|
+
VStack(spacing: 5) {
|
|
339
|
+
HStack(spacing: 5) {
|
|
340
|
+
ForEach(Array(model.browsers.prefix(4))) { browser in sourceButton(browser) }
|
|
341
|
+
}
|
|
342
|
+
HStack(spacing: 5) {
|
|
343
|
+
ForEach(Array(model.browsers.dropFirst(4))) { browser in sourceButton(browser) }
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
.frame(width: 175)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private func sourceButton(_ browser: BrowserChoice) -> some View {
|
|
351
|
+
EndpointButton(
|
|
352
|
+
icon: model.browserIcon(browser),
|
|
353
|
+
name: browser.name,
|
|
354
|
+
selected: model.selectedSourceID == browser.id,
|
|
355
|
+
disabled: model.isWorking || model.isSyncing || model.selectedTargetID == browser.id
|
|
356
|
+
) { model.selectSource(browser.id) }
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
struct TargetPicker: View {
|
|
361
|
+
@EnvironmentObject private var model: SyncModel
|
|
362
|
+
|
|
363
|
+
var body: some View {
|
|
364
|
+
VStack(alignment: .leading, spacing: 6) {
|
|
365
|
+
HStack {
|
|
366
|
+
Text("IMPORT INTO")
|
|
367
|
+
.tracking(0.65)
|
|
368
|
+
.foregroundStyle(.tertiary)
|
|
369
|
+
Spacer()
|
|
370
|
+
Text(model.targetName)
|
|
371
|
+
.foregroundStyle(Theme.accent)
|
|
372
|
+
}
|
|
373
|
+
.font(.system(size: 8, weight: .bold))
|
|
374
|
+
VStack(spacing: 5) {
|
|
375
|
+
HStack(spacing: 5) {
|
|
376
|
+
ForEach(Array(model.browsers.prefix(4))) { browser in targetButton(browser) }
|
|
377
|
+
Color.clear.frame(width: 19, height: 40)
|
|
378
|
+
}
|
|
379
|
+
HStack(spacing: 5) {
|
|
380
|
+
ForEach(Array(model.browsers.dropFirst(4))) { browser in targetButton(browser) }
|
|
381
|
+
EndpointButton(
|
|
382
|
+
icon: model.codexIcon,
|
|
383
|
+
name: "ChatGPT Codex",
|
|
384
|
+
selected: model.selectedTargetID == "codex",
|
|
385
|
+
disabled: model.isWorking || model.isSyncing,
|
|
386
|
+
buttonWidth: 64,
|
|
387
|
+
iconWidth: 66,
|
|
388
|
+
iconHeight: 38
|
|
389
|
+
) { model.selectTarget("codex") }
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
.frame(width: 199)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private func targetButton(_ browser: BrowserChoice) -> some View {
|
|
397
|
+
EndpointButton(
|
|
398
|
+
icon: model.browserIcon(browser),
|
|
399
|
+
name: browser.name,
|
|
400
|
+
selected: model.selectedTargetID == browser.id,
|
|
401
|
+
disabled: model.isWorking || model.isSyncing || model.selectedSourceID == browser.id
|
|
402
|
+
) { model.selectTarget(browser.id) }
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
struct EndpointButton: View {
|
|
407
|
+
let icon: NSImage
|
|
408
|
+
let name: String
|
|
409
|
+
let selected: Bool
|
|
410
|
+
let disabled: Bool
|
|
411
|
+
var buttonWidth: CGFloat = 40
|
|
412
|
+
var iconWidth: CGFloat = 31
|
|
413
|
+
var iconHeight: CGFloat = 31
|
|
414
|
+
let action: () -> Void
|
|
415
|
+
|
|
416
|
+
var body: some View {
|
|
417
|
+
Button(action: action) {
|
|
418
|
+
Image(nsImage: icon)
|
|
419
|
+
.resizable()
|
|
420
|
+
.scaledToFit()
|
|
421
|
+
.frame(width: iconWidth, height: iconHeight)
|
|
422
|
+
.frame(width: buttonWidth, height: 40)
|
|
423
|
+
.background(
|
|
424
|
+
selected ? Theme.accent.opacity(0.13) : Color.primary.opacity(0.025),
|
|
425
|
+
in: RoundedRectangle(cornerRadius: 10, style: .continuous)
|
|
426
|
+
)
|
|
427
|
+
.overlay(
|
|
428
|
+
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
|
429
|
+
.stroke(selected ? Theme.accent.opacity(0.85) : Color.primary.opacity(0.07), lineWidth: selected ? 1.25 : 1)
|
|
430
|
+
)
|
|
431
|
+
}
|
|
432
|
+
.buttonStyle(.plain)
|
|
433
|
+
.disabled(disabled)
|
|
434
|
+
.opacity(disabled && !selected ? 0.52 : 1)
|
|
435
|
+
.help(disabled && !selected ? "Already selected on the other side" : name)
|
|
436
|
+
.accessibilityLabel(name)
|
|
437
|
+
.accessibilityAddTraits(selected ? .isSelected : [])
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
struct PreferencesPanel: View {
|
|
442
|
+
@EnvironmentObject private var model: SyncModel
|
|
443
|
+
|
|
444
|
+
var body: some View {
|
|
445
|
+
Surface(padding: 0) {
|
|
446
|
+
VStack(spacing: 0) {
|
|
447
|
+
SectionLabel(title: "Sync data", detail: "Saved automatically")
|
|
448
|
+
PreferenceRow(icon: "network", color: Theme.accent, title: "Cookies", detail: "Site sessions and sign-ins") {
|
|
449
|
+
Toggle("", isOn: Binding(get: { model.cookiesEnabled }, set: { model.setCookiesEnabled($0) }))
|
|
450
|
+
.labelsHidden().toggleStyle(.switch).tint(Theme.active).disabled(model.isWorking)
|
|
451
|
+
}
|
|
452
|
+
RowDivider()
|
|
453
|
+
PreferenceRow(icon: "clock.arrow.circlepath", color: Theme.accent, title: "History URLs", detail: "Original visit times are not preserved") {
|
|
454
|
+
Toggle("", isOn: Binding(get: { model.historyEnabled }, set: { model.setHistoryEnabled($0) }))
|
|
455
|
+
.labelsHidden().toggleStyle(.switch).tint(Theme.active).disabled(model.isWorking)
|
|
456
|
+
}
|
|
457
|
+
RowDivider()
|
|
458
|
+
PreferenceRow(icon: "key.slash", color: .secondary, title: "Passwords", detail: "Browser extensions cannot access passwords", muted: true) {
|
|
459
|
+
Text("Unavailable")
|
|
460
|
+
.font(.system(size: 10, weight: .semibold))
|
|
461
|
+
.foregroundStyle(.tertiary)
|
|
462
|
+
.padding(.horizontal, 8).padding(.vertical, 4)
|
|
463
|
+
.background(Color.secondary.opacity(0.08), in: Capsule())
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
SectionLabel(title: "Automation", detail: "Runs in the background", separated: true)
|
|
467
|
+
PreferenceRow(icon: "clock.badge.checkmark", color: Theme.accent, title: "Daily sync", detail: model.dailyEnabled ? "At the selected local time" : "Off") {
|
|
468
|
+
HStack(spacing: 8) {
|
|
469
|
+
DatePicker("", selection: $model.scheduleTime, displayedComponents: .hourAndMinute)
|
|
470
|
+
.labelsHidden()
|
|
471
|
+
.datePickerStyle(.field)
|
|
472
|
+
.controlSize(.regular)
|
|
473
|
+
.disabled(!model.dailyEnabled || model.isWorking)
|
|
474
|
+
.frame(width: 112, alignment: .center)
|
|
475
|
+
.onChange(of: model.scheduleTime) { _ in
|
|
476
|
+
if model.dailyEnabled && !model.isWorking { model.saveSchedule() }
|
|
477
|
+
}
|
|
478
|
+
Toggle("", isOn: Binding(get: { model.dailyEnabled }, set: { model.setDailyEnabled($0) }))
|
|
479
|
+
.labelsHidden().toggleStyle(.switch).tint(Theme.active).disabled(model.isWorking)
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
RowDivider()
|
|
483
|
+
PreferenceRow(icon: "sunrise.fill", color: Theme.accent, title: "Sync at login", detail: model.loginSyncEnabled ? "Once whenever you sign in" : "Off") {
|
|
484
|
+
Toggle("", isOn: Binding(get: { model.loginSyncEnabled }, set: { model.setLoginSyncEnabled($0) }))
|
|
485
|
+
.labelsHidden().toggleStyle(.switch).tint(Theme.active).disabled(model.isWorking)
|
|
486
|
+
}
|
|
487
|
+
RowDivider()
|
|
488
|
+
PreferenceRow(icon: "power", color: Theme.accent, title: "Open at login", detail: "Start the app after you sign in") {
|
|
489
|
+
Toggle("", isOn: Binding(get: { model.openAtLogin }, set: { model.setOpenAtLogin($0) }))
|
|
490
|
+
.labelsHidden().toggleStyle(.switch).tint(Theme.active).disabled(model.isWorking)
|
|
491
|
+
}
|
|
492
|
+
RowDivider()
|
|
493
|
+
PreferenceRow(icon: "menubar.rectangle", color: Theme.accent, title: "Show in menu bar", detail: "Quick access while the app is running") {
|
|
494
|
+
Toggle("", isOn: Binding(get: { model.menuBarEnabled }, set: { model.setMenuBarEnabled($0) }))
|
|
495
|
+
.labelsHidden().toggleStyle(.switch).tint(Theme.active).disabled(model.isWorking)
|
|
496
|
+
}
|
|
497
|
+
RowDivider()
|
|
498
|
+
PreferenceRow(icon: "arrow.triangle.2.circlepath.circle", color: Theme.accent, title: "Automatically check for updates", detail: model.autoCheckUpdates ? "Once a day while the app is running" : "Manual checks only") {
|
|
499
|
+
Toggle("", isOn: Binding(get: { model.autoCheckUpdates }, set: { model.setAutoCheckUpdates($0) }))
|
|
500
|
+
.labelsHidden().toggleStyle(.switch).tint(Theme.active).disabled(model.isWorking)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
SectionLabel(title: "Project & support", detail: "Open source", separated: true)
|
|
504
|
+
ProjectLinkRow(icon: "chevron.left.forwardslash.chevron.right", title: "Open-source repository", detail: "View the code on GitHub", url: ProjectLinks.repository)
|
|
505
|
+
RowDivider()
|
|
506
|
+
ProjectLinkRow(icon: "ladybug", title: "Report a bug", detail: "Open a GitHub issue", url: ProjectLinks.issues)
|
|
507
|
+
RowDivider()
|
|
508
|
+
ProjectLinkRow(icon: "doc.text", title: "MIT license", detail: "Read the open-source license", url: ProjectLinks.license)
|
|
509
|
+
RowDivider()
|
|
510
|
+
ProjectLinkRow(icon: "heart", title: "Support on Ko-fi", detail: "Sponsor development", url: ProjectLinks.koFi)
|
|
511
|
+
RowDivider()
|
|
512
|
+
ProjectLinkRow(icon: "person.crop.circle.badge.plus", title: "Follow @apoorvdarshan on X", detail: "Developer updates", url: ProjectLinks.x)
|
|
513
|
+
RowDivider()
|
|
514
|
+
ProjectLinkRow(icon: "megaphone", title: "View on Product Hunt", detail: "Follow the launch and leave feedback", url: ProjectLinks.productHunt)
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
struct ExtensionSetupSheet: View {
|
|
521
|
+
@EnvironmentObject private var model: SyncModel
|
|
522
|
+
@Environment(\.dismiss) private var dismiss
|
|
523
|
+
|
|
524
|
+
var body: some View {
|
|
525
|
+
VStack(alignment: .leading, spacing: 18) {
|
|
526
|
+
HStack(alignment: .top, spacing: 12) {
|
|
527
|
+
Image(systemName: "puzzlepiece.extension.fill")
|
|
528
|
+
.font(.system(size: 24, weight: .semibold))
|
|
529
|
+
.foregroundStyle(Theme.accent)
|
|
530
|
+
.frame(width: 38, height: 38)
|
|
531
|
+
.background(Theme.accent.opacity(0.12), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
|
532
|
+
VStack(alignment: .leading, spacing: 3) {
|
|
533
|
+
Text("Extension setup").font(.system(size: 18, weight: .bold, design: .rounded))
|
|
534
|
+
Text("Load the folders for the two selected endpoints once.")
|
|
535
|
+
.font(.system(size: 11.5)).foregroundStyle(.secondary)
|
|
536
|
+
}
|
|
537
|
+
Spacer()
|
|
538
|
+
SetupStateBadge(ready: model.extensionsReady)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
VStack(spacing: 0) {
|
|
542
|
+
SetupEndpointRow(icon: model.sourceIcon, title: model.selectedBrowser.name, detail: "Load the source extension") {
|
|
543
|
+
Button("Open page") { model.openExtensions(for: model.selectedSourceID) }
|
|
544
|
+
Button("Show folder") { model.revealExtension(model.selectedSourceID) }
|
|
545
|
+
}
|
|
546
|
+
if model.selectedTargetID != "codex" {
|
|
547
|
+
Divider().padding(.leading, 58)
|
|
548
|
+
SetupEndpointRow(icon: model.targetIcon, title: model.targetName, detail: "Load the destination extension") {
|
|
549
|
+
Button("Open page") { model.openExtensions(for: model.selectedTargetID) }
|
|
550
|
+
Button("Show folder") { model.revealExtension(model.selectedTargetID) }
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
.background(Color(nsColor: .controlBackgroundColor).opacity(0.65), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
555
|
+
.overlay(RoundedRectangle(cornerRadius: 12, style: .continuous).stroke(Color.primary.opacity(0.07)))
|
|
556
|
+
|
|
557
|
+
Text(model.selectedTargetID == "codex"
|
|
558
|
+
? "Codex uses a direct local merge, so no extension is required. Quit Codex before syncing. Password access is never requested."
|
|
559
|
+
: "In both endpoints, enable Developer mode and choose Load unpacked. Password access is never requested.")
|
|
560
|
+
.font(.system(size: 10.5))
|
|
561
|
+
.foregroundStyle(.secondary)
|
|
562
|
+
.fixedSize(horizontal: false, vertical: true)
|
|
563
|
+
|
|
564
|
+
HStack {
|
|
565
|
+
Button("Refresh") { model.refresh() }
|
|
566
|
+
Spacer()
|
|
567
|
+
Button("Done") { dismiss() }
|
|
568
|
+
.keyboardShortcut(.defaultAction)
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
.padding(22)
|
|
572
|
+
.frame(width: 460)
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
struct AppBackground: View {
|
|
577
|
+
var body: some View {
|
|
578
|
+
ZStack {
|
|
579
|
+
Color(nsColor: .windowBackgroundColor)
|
|
580
|
+
LinearGradient(
|
|
581
|
+
colors: [Theme.accent.opacity(0.035), Color.clear, Theme.accent.opacity(0.012)],
|
|
582
|
+
startPoint: .topLeading,
|
|
583
|
+
endPoint: .bottomTrailing
|
|
584
|
+
)
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
struct Surface<Content: View>: View {
|
|
590
|
+
var padding: CGFloat = 14
|
|
591
|
+
@ViewBuilder var content: Content
|
|
592
|
+
|
|
593
|
+
var body: some View {
|
|
594
|
+
content
|
|
595
|
+
.padding(padding)
|
|
596
|
+
.background(Color(nsColor: .controlBackgroundColor).opacity(0.74))
|
|
597
|
+
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
598
|
+
.overlay(RoundedRectangle(cornerRadius: 12, style: .continuous).stroke(Color.primary.opacity(0.075)))
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
struct RelayPath: View {
|
|
603
|
+
let active: Bool
|
|
604
|
+
|
|
605
|
+
var body: some View {
|
|
606
|
+
ZStack {
|
|
607
|
+
Capsule()
|
|
608
|
+
.fill(Theme.accent.opacity(0.38))
|
|
609
|
+
.frame(height: 3)
|
|
610
|
+
Image(systemName: active ? "ellipsis" : "arrow.right")
|
|
611
|
+
.font(.system(size: 10, weight: .bold))
|
|
612
|
+
.foregroundStyle(.white)
|
|
613
|
+
.frame(width: 24, height: 24)
|
|
614
|
+
.background(Theme.accent, in: Circle())
|
|
615
|
+
.shadow(color: Theme.accent.opacity(0.22), radius: 5, y: 2)
|
|
616
|
+
}
|
|
617
|
+
.frame(maxWidth: .infinity)
|
|
618
|
+
.accessibilityLabel(active ? "Transfer in progress" : "Transfers from the selected source to the selected destination")
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
struct SectionLabel: View {
|
|
623
|
+
let title: String
|
|
624
|
+
let detail: String
|
|
625
|
+
var separated = false
|
|
626
|
+
|
|
627
|
+
var body: some View {
|
|
628
|
+
HStack {
|
|
629
|
+
Text(title).font(.system(size: 11, weight: .bold)).foregroundStyle(.secondary)
|
|
630
|
+
Spacer()
|
|
631
|
+
Text(detail).font(.system(size: 9.5, weight: .medium)).foregroundStyle(.tertiary)
|
|
632
|
+
}
|
|
633
|
+
.padding(.horizontal, 14)
|
|
634
|
+
.padding(.top, separated ? 12 : 11)
|
|
635
|
+
.padding(.bottom, 7)
|
|
636
|
+
.overlay(alignment: .top) {
|
|
637
|
+
if separated { Divider() }
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
struct PreferenceRow<Trailing: View>: View {
|
|
643
|
+
let icon: String
|
|
644
|
+
let color: Color
|
|
645
|
+
let title: String
|
|
646
|
+
let detail: String
|
|
647
|
+
var muted = false
|
|
648
|
+
@ViewBuilder var trailing: Trailing
|
|
649
|
+
|
|
650
|
+
var body: some View {
|
|
651
|
+
HStack(spacing: 11) {
|
|
652
|
+
Image(systemName: icon)
|
|
653
|
+
.font(.system(size: 15, weight: .medium))
|
|
654
|
+
.foregroundStyle(muted ? Color.secondary : color)
|
|
655
|
+
.frame(width: 22)
|
|
656
|
+
VStack(alignment: .leading, spacing: 1) {
|
|
657
|
+
Text(title).font(.system(size: 12.5, weight: .semibold))
|
|
658
|
+
Text(detail).font(.system(size: 10)).foregroundStyle(.secondary)
|
|
659
|
+
}
|
|
660
|
+
.opacity(muted ? 0.62 : 1)
|
|
661
|
+
Spacer()
|
|
662
|
+
trailing
|
|
663
|
+
}
|
|
664
|
+
.padding(.horizontal, 14)
|
|
665
|
+
.frame(height: 42)
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
struct ProjectLinkRow: View {
|
|
670
|
+
let icon: String
|
|
671
|
+
let title: String
|
|
672
|
+
let detail: String
|
|
673
|
+
let url: URL?
|
|
674
|
+
|
|
675
|
+
var body: some View {
|
|
676
|
+
Group {
|
|
677
|
+
if let url {
|
|
678
|
+
Link(destination: url) { row }
|
|
679
|
+
.buttonStyle(.plain)
|
|
680
|
+
} else {
|
|
681
|
+
row.opacity(0.45)
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
.accessibilityElement(children: .combine)
|
|
685
|
+
.accessibilityLabel(url == nil ? "\(title), \(detail)" : title)
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
private var row: some View {
|
|
689
|
+
HStack(spacing: 11) {
|
|
690
|
+
Image(systemName: icon)
|
|
691
|
+
.font(.system(size: 14, weight: .medium))
|
|
692
|
+
.foregroundStyle(url == nil ? Color.secondary : Theme.accent)
|
|
693
|
+
.frame(width: 22)
|
|
694
|
+
VStack(alignment: .leading, spacing: 1) {
|
|
695
|
+
Text(title).font(.system(size: 12.5, weight: .semibold))
|
|
696
|
+
Text(detail).font(.system(size: 10)).foregroundStyle(.secondary)
|
|
697
|
+
}
|
|
698
|
+
Spacer()
|
|
699
|
+
Image(systemName: url == nil ? "hourglass" : "arrow.up.right")
|
|
700
|
+
.font(.system(size: 10, weight: .semibold))
|
|
701
|
+
.foregroundStyle(.tertiary)
|
|
702
|
+
}
|
|
703
|
+
.padding(.horizontal, 14)
|
|
704
|
+
.frame(height: 42)
|
|
705
|
+
.contentShape(Rectangle())
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
struct RowDivider: View {
|
|
710
|
+
var body: some View { Divider().padding(.leading, 48) }
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
struct StatusIndicator: View {
|
|
714
|
+
let state: SyncModel.State
|
|
715
|
+
|
|
716
|
+
var body: some View {
|
|
717
|
+
HStack(spacing: 6) {
|
|
718
|
+
Circle().fill(color).frame(width: 7, height: 7)
|
|
719
|
+
Text(label)
|
|
720
|
+
}
|
|
721
|
+
.font(.system(size: 10.5, weight: .semibold))
|
|
722
|
+
.foregroundStyle(color)
|
|
723
|
+
.padding(.horizontal, 9).padding(.vertical, 5)
|
|
724
|
+
.background(color.opacity(0.1), in: Capsule())
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
private var label: String {
|
|
728
|
+
switch state { case .ready: "Ready"; case .syncing: "Syncing"; case .success: "Synced"; case .warning: "Partial"; case .error: "Needs action" }
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
private var color: Color {
|
|
732
|
+
switch state { case .ready, .syncing, .success: Theme.accent; case .warning: .orange; case .error: .red }
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
struct SetupEndpointRow<Actions: View>: View {
|
|
737
|
+
let icon: NSImage
|
|
738
|
+
let title: String
|
|
739
|
+
let detail: String
|
|
740
|
+
@ViewBuilder var actions: Actions
|
|
741
|
+
|
|
742
|
+
var body: some View {
|
|
743
|
+
HStack(spacing: 12) {
|
|
744
|
+
Image(nsImage: icon).resizable().scaledToFit().frame(width: 34, height: 34)
|
|
745
|
+
VStack(alignment: .leading, spacing: 2) {
|
|
746
|
+
Text(title).font(.system(size: 13, weight: .semibold))
|
|
747
|
+
Text(detail).font(.system(size: 10.5)).foregroundStyle(.secondary)
|
|
748
|
+
}
|
|
749
|
+
Spacer()
|
|
750
|
+
HStack(spacing: 7) { actions }.controlSize(.small)
|
|
751
|
+
}
|
|
752
|
+
.padding(13)
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
struct SetupStateBadge: View {
|
|
757
|
+
let ready: Bool
|
|
758
|
+
|
|
759
|
+
var body: some View {
|
|
760
|
+
Text(ready ? "Folders ready" : "Setup needed")
|
|
761
|
+
.font(.system(size: 10, weight: .semibold))
|
|
762
|
+
.foregroundStyle(ready ? Theme.accent : .secondary)
|
|
763
|
+
.padding(.horizontal, 8).padding(.vertical, 5)
|
|
764
|
+
.background((ready ? Theme.accent : Color.secondary).opacity(0.1), in: Capsule())
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
enum Theme {
|
|
769
|
+
static let accent = Color(red: 0.46, green: 0.46, blue: 0.48)
|
|
770
|
+
static let active = Color(red: 0.54, green: 0.12, blue: 0.18)
|
|
771
|
+
}
|