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,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
+ }