browser-cookie-bridge 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,6 +35,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
35
35
  weak var model: SyncModel?
36
36
  private weak var mainWindow: NSWindow?
37
37
  private var statusItem: NSStatusItem?
38
+ private var syncMenuItem: NSMenuItem?
38
39
  private var updateMenuItem: NSMenuItem?
39
40
 
40
41
  func applicationDidFinishLaunching(_ notification: Notification) {
@@ -56,6 +57,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
56
57
  name: .updateStateChanged,
57
58
  object: nil
58
59
  )
60
+ NotificationCenter.default.addObserver(
61
+ self,
62
+ selector: #selector(syncStateChanged(_:)),
63
+ name: .syncStateChanged,
64
+ object: nil
65
+ )
59
66
  }
60
67
 
61
68
  func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false }
@@ -94,7 +101,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
94
101
  item.button?.imageScaling = .scaleProportionallyDown
95
102
  let menu = NSMenu()
96
103
  menu.addItem(withTitle: "Show Browser Cookie Bridge", action: #selector(showMainWindowAction), keyEquivalent: "")
97
- menu.addItem(withTitle: "Sync now", action: #selector(syncNowAction), keyEquivalent: "")
104
+ syncMenuItem = menu.addItem(withTitle: "Sync now", action: #selector(syncNowAction), keyEquivalent: "")
98
105
  let updateItem = menu.addItem(withTitle: "Check for Updates…", action: #selector(updateAction), keyEquivalent: "")
99
106
  updateMenuItem = updateItem
100
107
  addProjectLinks(to: menu)
@@ -106,6 +113,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
106
113
  } else if let statusItem {
107
114
  NSStatusBar.system.removeStatusItem(statusItem)
108
115
  self.statusItem = nil
116
+ syncMenuItem = nil
109
117
  updateMenuItem = nil
110
118
  }
111
119
  }
@@ -116,7 +124,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
116
124
  }
117
125
 
118
126
  @objc private func showMainWindowAction() { showMainWindow() }
119
- @objc private func syncNowAction() { model?.syncNow(showMenuBarAlert: true) }
127
+ @objc private func syncNowAction() {
128
+ if model?.isBrowserlessTarget == true, model?.isSyncing == true { model?.cancelSync() }
129
+ else { model?.syncNow(showMenuBarAlert: true) }
130
+ }
120
131
  @objc private func updateAction() {
121
132
  if model?.availableUpdateVersion != nil { model?.installAvailableUpdate() }
122
133
  else { model?.checkForUpdates(showAlert: true) }
@@ -161,6 +172,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
161
172
  }
162
173
  }
163
174
 
175
+ @objc private func syncStateChanged(_ notification: Notification) {
176
+ guard let payload = notification.object as? SyncMenuState else { return }
177
+ if payload.canceling {
178
+ syncMenuItem?.title = "Canceling upload…"
179
+ syncMenuItem?.isEnabled = false
180
+ } else if payload.uploading {
181
+ syncMenuItem?.title = "Cancel upload"
182
+ syncMenuItem?.isEnabled = true
183
+ } else {
184
+ syncMenuItem?.title = "Sync now"
185
+ syncMenuItem?.isEnabled = true
186
+ }
187
+ }
188
+
164
189
  private func showMainWindow() {
165
190
  NSApp.setActivationPolicy(.regular)
166
191
  mainWindow?.makeKeyAndOrderFront(nil)
@@ -213,6 +238,9 @@ struct ContentView: View {
213
238
  .sheet(isPresented: $showingSetup) {
214
239
  ExtensionSetupSheet().environmentObject(model)
215
240
  }
241
+ .sheet(isPresented: $model.showingBrowserlessSetup) {
242
+ BrowserlessSetupSheet().environmentObject(model)
243
+ }
216
244
  }
217
245
 
218
246
  private var header: some View {
@@ -238,7 +266,10 @@ struct ContentView: View {
238
266
 
239
267
  private var footer: some View {
240
268
  HStack(spacing: 10) {
241
- Label("Data stays on this Mac", systemImage: "lock.shield.fill")
269
+ Label(
270
+ model.isBrowserlessTarget ? "Cloud upload only when you choose it" : "Data stays on this Mac",
271
+ systemImage: model.isBrowserlessTarget ? "cloud.fill" : "lock.shield.fill"
272
+ )
242
273
  .foregroundStyle(.secondary)
243
274
  Spacer()
244
275
  if let version = model.availableUpdateVersion {
@@ -262,7 +293,7 @@ struct ContentView: View {
262
293
  .buttonStyle(.plain)
263
294
  .foregroundStyle(.secondary)
264
295
  .help("Refresh status")
265
- if model.selectedTargetID != "codex" {
296
+ if model.selectedTargetID != "codex" && !model.isBrowserlessTarget {
266
297
  Button("Extension setup…") { showingSetup = true }
267
298
  .controlSize(.small)
268
299
  }
@@ -284,37 +315,65 @@ struct SyncPanel: View {
284
315
  TargetPicker()
285
316
  }
286
317
  Divider().opacity(0.65)
287
- HStack(spacing: 12) {
288
- VStack(alignment: .leading, spacing: 2) {
289
- Text(model.primaryStatus)
290
- .font(.system(size: 13, weight: .semibold))
291
- Text(model.secondaryStatus)
292
- .font(.system(size: 10.5))
293
- .foregroundStyle(.secondary)
294
- .lineLimit(2)
295
- .help(model.secondaryStatus)
318
+ VStack(spacing: 8) {
319
+ HStack(spacing: 12) {
320
+ VStack(alignment: .leading, spacing: 2) {
321
+ Text(model.primaryStatus)
322
+ .font(.system(size: 13, weight: .semibold))
323
+ Text(model.secondaryStatus)
324
+ .font(.system(size: 10.5))
325
+ .foregroundStyle(.secondary)
326
+ .lineLimit(2)
327
+ .help(model.secondaryStatus)
328
+ }
329
+ Spacer()
330
+ Button {
331
+ if model.isBrowserlessTarget && model.isSyncing {
332
+ model.cancelSync()
333
+ } else if model.isBrowserlessTarget && !model.browserlessConfigured {
334
+ model.showingBrowserlessSetup = true
335
+ } else {
336
+ model.syncNow()
337
+ }
338
+ } label: {
339
+ HStack(spacing: 7) {
340
+ if model.isSyncing && !model.isBrowserlessTarget { ProgressView().controlSize(.small) }
341
+ else { Image(systemName: syncButtonIcon) }
342
+ Text(syncButtonTitle)
343
+ }
344
+ .frame(minWidth: model.syncBlocked ? 116 : 92)
345
+ }
346
+ .buttonStyle(.borderedProminent)
347
+ .tint(model.isBrowserlessTarget && model.isSyncing ? Theme.active : Theme.accent)
348
+ .disabled(
349
+ model.uploadCanceling
350
+ || (model.isSyncing && !model.isBrowserlessTarget)
351
+ || (!model.isSyncing && model.syncBlocked && !(model.isBrowserlessTarget && !model.browserlessConfigured))
352
+ )
353
+ .keyboardShortcut(.return, modifiers: .command)
296
354
  }
297
- Spacer()
298
- Button { model.syncNow() } label: {
299
- HStack(spacing: 7) {
300
- if model.isSyncing { ProgressView().controlSize(.small) }
301
- else { Image(systemName: model.codexBlocked ? "xmark.circle.fill" : "arrow.triangle.2.circlepath") }
302
- Text(model.isSyncing ? "Syncing…" : (model.codexBlocked ? "Close Codex first" : "Sync now"))
355
+ if model.isBrowserlessTarget && model.isSyncing {
356
+ HStack(spacing: 9) {
357
+ ProgressView(value: model.uploadProgress, total: 1)
358
+ .progressViewStyle(.linear)
359
+ .tint(Theme.active)
360
+ Text(model.formattedUploadElapsed)
361
+ .font(.system(size: 9.5, weight: .semibold, design: .monospaced))
362
+ .foregroundStyle(.secondary)
363
+ .frame(width: 34, alignment: .trailing)
303
364
  }
304
- .frame(minWidth: model.codexBlocked ? 116 : 92)
365
+ .accessibilityElement(children: .combine)
366
+ .accessibilityLabel("Browserless upload progress")
367
+ .accessibilityValue("\(Int(model.uploadProgress * 100)) percent, \(model.formattedUploadElapsed) elapsed")
305
368
  }
306
- .buttonStyle(.borderedProminent)
307
- .tint(Theme.accent)
308
- .disabled(model.isSyncing || model.codexBlocked)
309
- .keyboardShortcut(.return, modifiers: .command)
310
369
  }
311
- .padding(model.codexBlocked ? 7 : 0)
370
+ .padding(model.syncBlocked ? 7 : 0)
312
371
  .background(
313
- model.codexBlocked ? Theme.active.opacity(0.10) : Color.clear,
372
+ model.syncBlocked ? Theme.active.opacity(0.10) : Color.clear,
314
373
  in: RoundedRectangle(cornerRadius: 9, style: .continuous)
315
374
  )
316
375
  .overlay {
317
- if model.codexBlocked {
376
+ if model.syncBlocked {
318
377
  RoundedRectangle(cornerRadius: 9, style: .continuous)
319
378
  .stroke(Theme.active.opacity(0.38), lineWidth: 1)
320
379
  }
@@ -323,6 +382,23 @@ struct SyncPanel: View {
323
382
  }
324
383
  }
325
384
 
385
+ private var syncButtonTitle: String {
386
+ if model.isBrowserlessTarget && model.uploadCanceling { return "Canceling…" }
387
+ if model.isBrowserlessTarget && model.isSyncing { return "Cancel upload" }
388
+ if model.isSyncing { return "Syncing…" }
389
+ if model.codexBlocked { return "Close Codex first" }
390
+ if model.isBrowserlessTarget && !model.browserlessConfigured { return "Connect Browserless" }
391
+ if model.isBrowserlessTarget && model.selectedSourceID == "comet" { return "Choose another browser" }
392
+ if model.isBrowserlessTarget && model.sourceBrowserRunning { return "Close \(model.selectedBrowser.name) first" }
393
+ return model.isBrowserlessTarget ? "Upload now" : "Sync now"
394
+ }
395
+
396
+ private var syncButtonIcon: String {
397
+ if model.isBrowserlessTarget && model.isSyncing { return "xmark.circle.fill" }
398
+ if model.syncBlocked { return model.isBrowserlessTarget && !model.browserlessConfigured ? "key.fill" : "xmark.circle.fill" }
399
+ return model.isBrowserlessTarget ? "icloud.and.arrow.up.fill" : "arrow.triangle.2.circlepath"
400
+ }
401
+
326
402
  }
327
403
 
328
404
  struct SourcePicker: View {
@@ -378,7 +454,12 @@ struct TargetPicker: View {
378
454
  VStack(spacing: 5) {
379
455
  HStack(spacing: 5) {
380
456
  ForEach(Array(model.browsers.prefix(4))) { browser in targetButton(browser) }
381
- Color.clear.frame(width: 19, height: 40)
457
+ EndpointButton(
458
+ icon: model.browserlessIcon,
459
+ name: "Browserless Cloud",
460
+ selected: model.selectedTargetID == "browserless",
461
+ disabled: model.isWorking || model.isSyncing || model.selectedSourceID == "comet"
462
+ ) { model.selectTarget("browserless") }
382
463
  }
383
464
  HStack(spacing: 5) {
384
465
  ForEach(Array(model.browsers.dropFirst(4))) { browser in targetButton(browser) }
@@ -449,44 +530,84 @@ struct PreferencesPanel: View {
449
530
  Surface(padding: 0) {
450
531
  VStack(spacing: 0) {
451
532
  SectionLabel(title: "Sync data", detail: "Saved automatically")
452
- PreferenceRow(icon: "network", color: Theme.accent, title: "Cookies", detail: "Site sessions and sign-ins") {
453
- Toggle("", isOn: Binding(get: { model.cookiesEnabled }, set: { model.setCookiesEnabled($0) }))
454
- .labelsHidden().toggleStyle(.switch).tint(Theme.active).disabled(model.isWorking)
455
- }
456
- RowDivider()
457
- PreferenceRow(icon: "clock.arrow.circlepath", color: Theme.accent, title: "History URLs", detail: "Original visit times are not preserved") {
458
- Toggle("", isOn: Binding(get: { model.historyEnabled }, set: { model.setHistoryEnabled($0) }))
459
- .labelsHidden().toggleStyle(.switch).tint(Theme.active).disabled(model.isWorking)
460
- }
461
- RowDivider()
462
- PreferenceRow(icon: "key.slash", color: .secondary, title: "Passwords", detail: "Browser extensions cannot access passwords", muted: true) {
463
- Text("Unavailable")
464
- .font(.system(size: 10, weight: .semibold))
465
- .foregroundStyle(.tertiary)
466
- .padding(.horizontal, 8).padding(.vertical, 4)
467
- .background(Color.secondary.opacity(0.08), in: Capsule())
533
+ if model.isBrowserlessTarget {
534
+ PreferenceRow(icon: "person.crop.circle.badge.checkmark", color: Theme.accent, title: "Authenticated state", detail: "Cookies, local storage, and IndexedDB") {
535
+ FixedBadge("Included")
536
+ }
537
+ RowDivider()
538
+ PreferenceRow(icon: "clock.arrow.circlepath", color: .secondary, title: "History URLs", detail: "Not included in Browserless profiles", muted: true) {
539
+ FixedBadge("Excluded")
540
+ }
541
+ RowDivider()
542
+ PreferenceRow(icon: "key.slash", color: .secondary, title: "Saved passwords", detail: "Never read or uploaded", muted: true) {
543
+ FixedBadge("Excluded")
544
+ }
545
+ RowDivider()
546
+ PreferenceRow(icon: "cloud.fill", color: Theme.accent, title: "Browserless connection", detail: model.browserlessConfigured ? "\(model.browserlessProfileName) · \(model.browserlessRegion.uppercased()) · token in Keychain" : "Not connected") {
547
+ Button(model.browserlessConfigured ? "Configure…" : "Connect…") {
548
+ model.showingBrowserlessSetup = true
549
+ }
550
+ .controlSize(.small)
551
+ }
552
+ RowDivider()
553
+ PreferenceRow(icon: "externaldrive.badge.magnifyingglass", color: Theme.accent, title: "Local profile preflight", detail: browserlessPreflightDetail) {
554
+ HStack(spacing: 7) {
555
+ ProfileSizeBadge(severity: model.browserlessAssessment?.severity, scanning: model.isInspectingBrowserlessProfile)
556
+ Button {
557
+ model.refreshBrowserlessPreflight(force: true)
558
+ } label: {
559
+ Image(systemName: "arrow.clockwise")
560
+ }
561
+ .buttonStyle(.plain)
562
+ .foregroundStyle(.secondary)
563
+ .disabled(model.isInspectingBrowserlessProfile || model.isSyncing)
564
+ .help("Rescan local profile size")
565
+ }
566
+ }
567
+ } else {
568
+ PreferenceRow(icon: "network", color: Theme.accent, title: "Cookies", detail: "Site sessions and sign-ins") {
569
+ Toggle("", isOn: Binding(get: { model.cookiesEnabled }, set: { model.setCookiesEnabled($0) }))
570
+ .labelsHidden().toggleStyle(.switch).tint(Theme.active).disabled(model.isWorking)
571
+ }
572
+ RowDivider()
573
+ PreferenceRow(icon: "clock.arrow.circlepath", color: Theme.accent, title: "History URLs", detail: "Original visit times are not preserved") {
574
+ Toggle("", isOn: Binding(get: { model.historyEnabled }, set: { model.setHistoryEnabled($0) }))
575
+ .labelsHidden().toggleStyle(.switch).tint(Theme.active).disabled(model.isWorking)
576
+ }
577
+ RowDivider()
578
+ PreferenceRow(icon: "key.slash", color: .secondary, title: "Passwords", detail: "Browser extensions cannot access passwords", muted: true) {
579
+ FixedBadge("Unavailable")
580
+ }
468
581
  }
469
582
 
470
583
  SectionLabel(title: "Automation", detail: "Runs in the background", separated: true)
471
- PreferenceRow(icon: "clock.badge.checkmark", color: Theme.accent, title: "Daily sync", detail: model.dailyEnabled ? "At the selected local time" : "Off") {
472
- HStack(spacing: 8) {
473
- DatePicker("", selection: $model.scheduleTime, displayedComponents: .hourAndMinute)
474
- .labelsHidden()
475
- .datePickerStyle(.field)
476
- .controlSize(.regular)
477
- .disabled(!model.dailyEnabled || model.isWorking)
478
- .frame(width: 112, alignment: .center)
479
- .onChange(of: model.scheduleTime) { _ in
480
- if model.dailyEnabled && !model.isWorking { model.saveSchedule() }
481
- }
482
- Toggle("", isOn: Binding(get: { model.dailyEnabled }, set: { model.setDailyEnabled($0) }))
483
- .labelsHidden().toggleStyle(.switch).tint(Theme.active).disabled(model.isWorking)
584
+ PreferenceRow(icon: "clock.badge.checkmark", color: Theme.accent, title: "Daily sync", detail: model.isBrowserlessTarget ? "Unavailable for cloud uploads" : (model.dailyEnabled ? "At the selected local time" : "Off")) {
585
+ if model.isBrowserlessTarget {
586
+ FixedBadge("Manual only")
587
+ } else {
588
+ HStack(spacing: 8) {
589
+ DatePicker("", selection: $model.scheduleTime, displayedComponents: .hourAndMinute)
590
+ .labelsHidden()
591
+ .datePickerStyle(.field)
592
+ .controlSize(.regular)
593
+ .disabled(!model.dailyEnabled || model.isWorking)
594
+ .frame(width: 112, alignment: .center)
595
+ .onChange(of: model.scheduleTime) { _ in
596
+ if model.dailyEnabled && !model.isWorking { model.saveSchedule() }
597
+ }
598
+ Toggle("", isOn: Binding(get: { model.dailyEnabled }, set: { model.setDailyEnabled($0) }))
599
+ .labelsHidden().toggleStyle(.switch).tint(Theme.active).disabled(model.isWorking)
600
+ }
484
601
  }
485
602
  }
486
603
  RowDivider()
487
- PreferenceRow(icon: "sunrise.fill", color: Theme.accent, title: "Sync at login", detail: model.loginSyncEnabled ? "Once whenever you sign in" : "Off") {
488
- Toggle("", isOn: Binding(get: { model.loginSyncEnabled }, set: { model.setLoginSyncEnabled($0) }))
489
- .labelsHidden().toggleStyle(.switch).tint(Theme.active).disabled(model.isWorking)
604
+ PreferenceRow(icon: "sunrise.fill", color: Theme.accent, title: "Sync at login", detail: model.isBrowserlessTarget ? "Unavailable for cloud uploads" : (model.loginSyncEnabled ? "Once whenever you sign in" : "Off")) {
605
+ if model.isBrowserlessTarget {
606
+ FixedBadge("Manual only")
607
+ } else {
608
+ Toggle("", isOn: Binding(get: { model.loginSyncEnabled }, set: { model.setLoginSyncEnabled($0) }))
609
+ .labelsHidden().toggleStyle(.switch).tint(Theme.active).disabled(model.isWorking)
610
+ }
490
611
  }
491
612
  RowDivider()
492
613
  PreferenceRow(icon: "power", color: Theme.accent, title: "Open at login", detail: "Start the app after you sign in") {
@@ -523,6 +644,165 @@ struct PreferencesPanel: View {
523
644
  }
524
645
  }
525
646
  }
647
+
648
+ private var browserlessPreflightDetail: String {
649
+ if model.isInspectingBrowserlessProfile { return "Measuring profile, local storage, and IndexedDB without reading their contents" }
650
+ if let assessment = model.browserlessAssessment {
651
+ return assessment.temporarySpaceWarning
652
+ ? "\(assessment.summary) · free space may be too low for the temporary copy"
653
+ : assessment.summary
654
+ }
655
+ return "Calculated locally before upload; Browserless cloud artifacts are capped at 2 MB"
656
+ }
657
+ }
658
+
659
+ struct FixedBadge: View {
660
+ let title: String
661
+
662
+ init(_ title: String) { self.title = title }
663
+
664
+ var body: some View {
665
+ Text(title)
666
+ .font(.system(size: 10, weight: .semibold))
667
+ .foregroundStyle(.tertiary)
668
+ .padding(.horizontal, 8).padding(.vertical, 4)
669
+ .background(Color.secondary.opacity(0.08), in: Capsule())
670
+ }
671
+ }
672
+
673
+ struct ProfileSizeBadge: View {
674
+ let severity: String?
675
+ let scanning: Bool
676
+
677
+ var body: some View {
678
+ Text(label)
679
+ .font(.system(size: 9.5, weight: .semibold))
680
+ .foregroundStyle(color)
681
+ .padding(.horizontal, 8).padding(.vertical, 4)
682
+ .background(color.opacity(0.10), in: Capsule())
683
+ }
684
+
685
+ private var label: String {
686
+ if scanning { return "Scanning" }
687
+ switch severity {
688
+ case "elevated": return "Large"
689
+ case "high": return "Very large"
690
+ case "extreme": return "Extreme"
691
+ case "normal": return "Ready"
692
+ default: return "Not scanned"
693
+ }
694
+ }
695
+
696
+ private var color: Color {
697
+ if scanning { return .secondary }
698
+ switch severity {
699
+ case "elevated": return .orange
700
+ case "high", "extreme": return Theme.active
701
+ case "normal": return Theme.accent
702
+ default: return .secondary
703
+ }
704
+ }
705
+ }
706
+
707
+ struct BrowserlessSetupSheet: View {
708
+ @EnvironmentObject private var model: SyncModel
709
+ @Environment(\.dismiss) private var dismiss
710
+ @State private var token = ""
711
+ @State private var profileName = "browser-cookie-bridge"
712
+ @State private var region = "sfo"
713
+ @State private var onlyDomains = ""
714
+ @State private var cloudConsent = false
715
+
716
+ var body: some View {
717
+ VStack(alignment: .leading, spacing: 17) {
718
+ HStack(alignment: .top, spacing: 12) {
719
+ Image(nsImage: model.browserlessIcon)
720
+ .resizable()
721
+ .scaledToFit()
722
+ .frame(width: 24, height: 24)
723
+ .frame(width: 40, height: 40)
724
+ .background(Theme.accent.opacity(0.11), in: RoundedRectangle(cornerRadius: 11, style: .continuous))
725
+ VStack(alignment: .leading, spacing: 3) {
726
+ Text("Connect Browserless Cloud")
727
+ .font(.system(size: 18, weight: .bold, design: .rounded))
728
+ Text("Optional authenticated-profile upload")
729
+ .font(.system(size: 11.5)).foregroundStyle(.secondary)
730
+ }
731
+ Spacer()
732
+ FixedBadge(model.browserlessConfigured ? "Connected" : "Not connected")
733
+ }
734
+
735
+ VStack(alignment: .leading, spacing: 12) {
736
+ LabeledContent("API token") {
737
+ SecureField(model.browserlessConfigured ? "Saved in Keychain — leave blank to keep" : "Browserless API token", text: $token)
738
+ .textFieldStyle(.roundedBorder)
739
+ .frame(width: 270)
740
+ }
741
+ LabeledContent("Cloud profile") {
742
+ TextField("browser-cookie-bridge", text: $profileName)
743
+ .textFieldStyle(.roundedBorder)
744
+ .frame(width: 270)
745
+ }
746
+ LabeledContent("Region") {
747
+ Picker("", selection: $region) {
748
+ Text("San Francisco (SFO)").tag("sfo")
749
+ Text("London (LON)").tag("lon")
750
+ Text("Amsterdam (AMS)").tag("ams")
751
+ }
752
+ .labelsHidden()
753
+ .frame(width: 270)
754
+ }
755
+ LabeledContent("Only domains") {
756
+ TextField("Optional: example.com, app.example.com", text: $onlyDomains)
757
+ .textFieldStyle(.roundedBorder)
758
+ .frame(width: 270)
759
+ }
760
+ }
761
+ .font(.system(size: 11.5, weight: .medium))
762
+ .padding(14)
763
+ .background(Color(nsColor: .controlBackgroundColor).opacity(0.72), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
764
+ .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous).stroke(Color.primary.opacity(0.08)))
765
+
766
+ HStack(alignment: .top, spacing: 10) {
767
+ Image(systemName: "exclamationmark.cloud.fill")
768
+ .foregroundStyle(Theme.active)
769
+ Text("This destination is not local. Clicking Upload sends cookies, local storage, and IndexedDB from a temporary profile copy to Browserless. Upload only profiles you are authorized to share; use the domain allowlist for sensitive profiles. Background and login sync never perform cloud uploads. CLI telemetry is disabled.")
770
+ .font(.system(size: 10.5))
771
+ .foregroundStyle(.secondary)
772
+ .fixedSize(horizontal: false, vertical: true)
773
+ }
774
+ .padding(12)
775
+ .background(Theme.active.opacity(0.08), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
776
+
777
+ Toggle("I understand the upload contents, have authority to share them, and accept responsibility for the Browserless copy.", isOn: $cloudConsent)
778
+ .toggleStyle(.checkbox)
779
+ .font(.system(size: 10.5, weight: .medium))
780
+
781
+ HStack {
782
+ Link("Get a Browserless API token", destination: URL(string: "https://www.browserless.io/account")!)
783
+ .font(.system(size: 10.5, weight: .medium))
784
+ Link("Privacy", destination: URL(string: "https://www.browserless.io/privacy-policy")!)
785
+ .font(.system(size: 10.5, weight: .medium))
786
+ if model.browserlessConfigured {
787
+ Button("Disconnect", role: .destructive) { model.disconnectBrowserless() }
788
+ }
789
+ Spacer()
790
+ Button("Cancel") { dismiss() }
791
+ Button("Save connection") {
792
+ model.saveBrowserlessSettings(token: token, profileName: profileName, region: region, onlyDomains: onlyDomains)
793
+ }
794
+ .keyboardShortcut(.defaultAction)
795
+ .disabled(profileName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !cloudConsent)
796
+ }
797
+ }
798
+ .padding(22)
799
+ .frame(width: 520)
800
+ .onAppear {
801
+ profileName = model.browserlessProfileName
802
+ region = model.browserlessRegion
803
+ onlyDomains = model.browserlessOnlyDomains
804
+ }
805
+ }
526
806
  }
527
807
 
528
808
  struct ExtensionSetupSheet: View {
@@ -733,11 +1013,11 @@ struct StatusIndicator: View {
733
1013
  }
734
1014
 
735
1015
  private var label: String {
736
- switch state { case .ready: "Ready"; case .syncing: "Syncing"; case .success: "Synced"; case .warning: "Partial"; case .error: "Needs action" }
1016
+ switch state { case .ready: "Ready"; case .syncing: "Syncing"; case .success: "Synced"; case .canceled: "Canceled"; case .warning: "Partial"; case .error: "Needs action" }
737
1017
  }
738
1018
 
739
1019
  private var color: Color {
740
- switch state { case .ready, .syncing, .success: Theme.accent; case .warning: .orange; case .error: .red }
1020
+ switch state { case .ready, .syncing, .success: Theme.accent; case .canceled: .secondary; case .warning: .orange; case .error: .red }
741
1021
  }
742
1022
  }
743
1023