browser-cookie-bridge 1.0.0 → 1.2.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.
@@ -3,6 +3,8 @@ import SwiftUI
3
3
 
4
4
  private enum ProjectLinks {
5
5
  static let repository = URL(string: "https://github.com/apoorvdarshan/browser-cookie-bridge")!
6
+ static let downloads = URL(string: "https://github.com/apoorvdarshan/browser-cookie-bridge/releases/latest")!
7
+ static let npm = URL(string: "https://www.npmjs.com/package/browser-cookie-bridge")!
6
8
  static let issues = URL(string: "https://github.com/apoorvdarshan/browser-cookie-bridge/issues/new?template=bug_report.yml")!
7
9
  static let license = URL(string: "https://github.com/apoorvdarshan/browser-cookie-bridge/blob/main/LICENSE")!
8
10
  static let koFi = URL(string: "https://ko-fi.com/apoorvdarshan")!
@@ -168,6 +170,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
168
170
  private func addProjectLinks(to menu: NSMenu) {
169
171
  menu.addItem(.separator())
170
172
  addLink("Open-source repository", url: ProjectLinks.repository, to: menu)
173
+ addLink("Download latest DMG", url: ProjectLinks.downloads, to: menu)
174
+ addLink("View on npm", url: ProjectLinks.npm, to: menu)
171
175
  addLink("Report a bug…", url: ProjectLinks.issues, to: menu)
172
176
  addLink("MIT license", url: ProjectLinks.license, to: menu)
173
177
  menu.addItem(.separator())
@@ -209,6 +213,9 @@ struct ContentView: View {
209
213
  .sheet(isPresented: $showingSetup) {
210
214
  ExtensionSetupSheet().environmentObject(model)
211
215
  }
216
+ .sheet(isPresented: $model.showingBrowserlessSetup) {
217
+ BrowserlessSetupSheet().environmentObject(model)
218
+ }
212
219
  }
213
220
 
214
221
  private var header: some View {
@@ -234,7 +241,10 @@ struct ContentView: View {
234
241
 
235
242
  private var footer: some View {
236
243
  HStack(spacing: 10) {
237
- Label("Data stays on this Mac", systemImage: "lock.shield.fill")
244
+ Label(
245
+ model.isBrowserlessTarget ? "Cloud upload only when you choose it" : "Data stays on this Mac",
246
+ systemImage: model.isBrowserlessTarget ? "cloud.fill" : "lock.shield.fill"
247
+ )
238
248
  .foregroundStyle(.secondary)
239
249
  Spacer()
240
250
  if let version = model.availableUpdateVersion {
@@ -258,7 +268,7 @@ struct ContentView: View {
258
268
  .buttonStyle(.plain)
259
269
  .foregroundStyle(.secondary)
260
270
  .help("Refresh status")
261
- if model.selectedTargetID != "codex" {
271
+ if model.selectedTargetID != "codex" && !model.isBrowserlessTarget {
262
272
  Button("Extension setup…") { showingSetup = true }
263
273
  .controlSize(.small)
264
274
  }
@@ -291,26 +301,32 @@ struct SyncPanel: View {
291
301
  .help(model.secondaryStatus)
292
302
  }
293
303
  Spacer()
294
- Button { model.syncNow() } label: {
304
+ Button {
305
+ if model.isBrowserlessTarget && !model.browserlessConfigured {
306
+ model.showingBrowserlessSetup = true
307
+ } else {
308
+ model.syncNow()
309
+ }
310
+ } label: {
295
311
  HStack(spacing: 7) {
296
312
  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"))
313
+ else { Image(systemName: syncButtonIcon) }
314
+ Text(syncButtonTitle)
299
315
  }
300
- .frame(minWidth: model.codexBlocked ? 116 : 92)
316
+ .frame(minWidth: model.syncBlocked ? 116 : 92)
301
317
  }
302
318
  .buttonStyle(.borderedProminent)
303
319
  .tint(Theme.accent)
304
- .disabled(model.isSyncing || model.codexBlocked)
320
+ .disabled(model.isSyncing || (model.syncBlocked && !(model.isBrowserlessTarget && !model.browserlessConfigured)))
305
321
  .keyboardShortcut(.return, modifiers: .command)
306
322
  }
307
- .padding(model.codexBlocked ? 7 : 0)
323
+ .padding(model.syncBlocked ? 7 : 0)
308
324
  .background(
309
- model.codexBlocked ? Theme.active.opacity(0.10) : Color.clear,
325
+ model.syncBlocked ? Theme.active.opacity(0.10) : Color.clear,
310
326
  in: RoundedRectangle(cornerRadius: 9, style: .continuous)
311
327
  )
312
328
  .overlay {
313
- if model.codexBlocked {
329
+ if model.syncBlocked {
314
330
  RoundedRectangle(cornerRadius: 9, style: .continuous)
315
331
  .stroke(Theme.active.opacity(0.38), lineWidth: 1)
316
332
  }
@@ -319,6 +335,20 @@ struct SyncPanel: View {
319
335
  }
320
336
  }
321
337
 
338
+ private var syncButtonTitle: String {
339
+ if model.isSyncing { return model.isBrowserlessTarget ? "Uploading…" : "Syncing…" }
340
+ if model.codexBlocked { return "Close Codex first" }
341
+ if model.isBrowserlessTarget && !model.browserlessConfigured { return "Connect Browserless" }
342
+ if model.isBrowserlessTarget && model.selectedSourceID == "comet" { return "Choose another browser" }
343
+ if model.isBrowserlessTarget && model.sourceBrowserRunning { return "Close \(model.selectedBrowser.name) first" }
344
+ return model.isBrowserlessTarget ? "Upload now" : "Sync now"
345
+ }
346
+
347
+ private var syncButtonIcon: String {
348
+ if model.syncBlocked { return model.isBrowserlessTarget && !model.browserlessConfigured ? "key.fill" : "xmark.circle.fill" }
349
+ return model.isBrowserlessTarget ? "icloud.and.arrow.up.fill" : "arrow.triangle.2.circlepath"
350
+ }
351
+
322
352
  }
323
353
 
324
354
  struct SourcePicker: View {
@@ -374,7 +404,12 @@ struct TargetPicker: View {
374
404
  VStack(spacing: 5) {
375
405
  HStack(spacing: 5) {
376
406
  ForEach(Array(model.browsers.prefix(4))) { browser in targetButton(browser) }
377
- Color.clear.frame(width: 19, height: 40)
407
+ EndpointButton(
408
+ icon: model.browserlessIcon,
409
+ name: "Browserless Cloud",
410
+ selected: model.selectedTargetID == "browserless",
411
+ disabled: model.isWorking || model.isSyncing || model.selectedSourceID == "comet"
412
+ ) { model.selectTarget("browserless") }
378
413
  }
379
414
  HStack(spacing: 5) {
380
415
  ForEach(Array(model.browsers.dropFirst(4))) { browser in targetButton(browser) }
@@ -445,44 +480,69 @@ struct PreferencesPanel: View {
445
480
  Surface(padding: 0) {
446
481
  VStack(spacing: 0) {
447
482
  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())
483
+ if model.isBrowserlessTarget {
484
+ PreferenceRow(icon: "person.crop.circle.badge.checkmark", color: Theme.accent, title: "Authenticated state", detail: "Cookies, local storage, and IndexedDB") {
485
+ FixedBadge("Included")
486
+ }
487
+ RowDivider()
488
+ PreferenceRow(icon: "clock.arrow.circlepath", color: .secondary, title: "History URLs", detail: "Not included in Browserless profiles", muted: true) {
489
+ FixedBadge("Excluded")
490
+ }
491
+ RowDivider()
492
+ PreferenceRow(icon: "key.slash", color: .secondary, title: "Saved passwords", detail: "Never read or uploaded", muted: true) {
493
+ FixedBadge("Excluded")
494
+ }
495
+ RowDivider()
496
+ PreferenceRow(icon: "cloud.fill", color: Theme.accent, title: "Browserless connection", detail: model.browserlessConfigured ? "\(model.browserlessProfileName) · \(model.browserlessRegion.uppercased()) · token in Keychain" : "Not connected") {
497
+ Button(model.browserlessConfigured ? "Configure…" : "Connect…") {
498
+ model.showingBrowserlessSetup = true
499
+ }
500
+ .controlSize(.small)
501
+ }
502
+ } else {
503
+ PreferenceRow(icon: "network", color: Theme.accent, title: "Cookies", detail: "Site sessions and sign-ins") {
504
+ Toggle("", isOn: Binding(get: { model.cookiesEnabled }, set: { model.setCookiesEnabled($0) }))
505
+ .labelsHidden().toggleStyle(.switch).tint(Theme.active).disabled(model.isWorking)
506
+ }
507
+ RowDivider()
508
+ PreferenceRow(icon: "clock.arrow.circlepath", color: Theme.accent, title: "History URLs", detail: "Original visit times are not preserved") {
509
+ Toggle("", isOn: Binding(get: { model.historyEnabled }, set: { model.setHistoryEnabled($0) }))
510
+ .labelsHidden().toggleStyle(.switch).tint(Theme.active).disabled(model.isWorking)
511
+ }
512
+ RowDivider()
513
+ PreferenceRow(icon: "key.slash", color: .secondary, title: "Passwords", detail: "Browser extensions cannot access passwords", muted: true) {
514
+ FixedBadge("Unavailable")
515
+ }
464
516
  }
465
517
 
466
518
  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)
519
+ 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")) {
520
+ if model.isBrowserlessTarget {
521
+ FixedBadge("Manual only")
522
+ } else {
523
+ HStack(spacing: 8) {
524
+ DatePicker("", selection: $model.scheduleTime, displayedComponents: .hourAndMinute)
525
+ .labelsHidden()
526
+ .datePickerStyle(.field)
527
+ .controlSize(.regular)
528
+ .disabled(!model.dailyEnabled || model.isWorking)
529
+ .frame(width: 112, alignment: .center)
530
+ .onChange(of: model.scheduleTime) { _ in
531
+ if model.dailyEnabled && !model.isWorking { model.saveSchedule() }
532
+ }
533
+ Toggle("", isOn: Binding(get: { model.dailyEnabled }, set: { model.setDailyEnabled($0) }))
534
+ .labelsHidden().toggleStyle(.switch).tint(Theme.active).disabled(model.isWorking)
535
+ }
480
536
  }
481
537
  }
482
538
  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)
539
+ 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")) {
540
+ if model.isBrowserlessTarget {
541
+ FixedBadge("Manual only")
542
+ } else {
543
+ Toggle("", isOn: Binding(get: { model.loginSyncEnabled }, set: { model.setLoginSyncEnabled($0) }))
544
+ .labelsHidden().toggleStyle(.switch).tint(Theme.active).disabled(model.isWorking)
545
+ }
486
546
  }
487
547
  RowDivider()
488
548
  PreferenceRow(icon: "power", color: Theme.accent, title: "Open at login", detail: "Start the app after you sign in") {
@@ -503,6 +563,10 @@ struct PreferencesPanel: View {
503
563
  SectionLabel(title: "Project & support", detail: "Open source", separated: true)
504
564
  ProjectLinkRow(icon: "chevron.left.forwardslash.chevron.right", title: "Open-source repository", detail: "View the code on GitHub", url: ProjectLinks.repository)
505
565
  RowDivider()
566
+ ProjectLinkRow(icon: "arrow.down.circle", title: "Download latest DMG", detail: "Apple silicon and Intel builds", url: ProjectLinks.downloads)
567
+ RowDivider()
568
+ ProjectLinkRow(icon: "shippingbox", title: "View on npm", detail: "Install the latest public release", url: ProjectLinks.npm)
569
+ RowDivider()
506
570
  ProjectLinkRow(icon: "ladybug", title: "Report a bug", detail: "Open a GitHub issue", url: ProjectLinks.issues)
507
571
  RowDivider()
508
572
  ProjectLinkRow(icon: "doc.text", title: "MIT license", detail: "Read the open-source license", url: ProjectLinks.license)
@@ -517,6 +581,121 @@ struct PreferencesPanel: View {
517
581
  }
518
582
  }
519
583
 
584
+ struct FixedBadge: View {
585
+ let title: String
586
+
587
+ init(_ title: String) { self.title = title }
588
+
589
+ var body: some View {
590
+ Text(title)
591
+ .font(.system(size: 10, weight: .semibold))
592
+ .foregroundStyle(.tertiary)
593
+ .padding(.horizontal, 8).padding(.vertical, 4)
594
+ .background(Color.secondary.opacity(0.08), in: Capsule())
595
+ }
596
+ }
597
+
598
+ struct BrowserlessSetupSheet: View {
599
+ @EnvironmentObject private var model: SyncModel
600
+ @Environment(\.dismiss) private var dismiss
601
+ @State private var token = ""
602
+ @State private var profileName = "browser-cookie-bridge"
603
+ @State private var region = "sfo"
604
+ @State private var onlyDomains = ""
605
+ @State private var cloudConsent = false
606
+
607
+ var body: some View {
608
+ VStack(alignment: .leading, spacing: 17) {
609
+ HStack(alignment: .top, spacing: 12) {
610
+ Image(nsImage: model.browserlessIcon)
611
+ .resizable()
612
+ .scaledToFit()
613
+ .frame(width: 24, height: 24)
614
+ .frame(width: 40, height: 40)
615
+ .background(Theme.accent.opacity(0.11), in: RoundedRectangle(cornerRadius: 11, style: .continuous))
616
+ VStack(alignment: .leading, spacing: 3) {
617
+ Text("Connect Browserless Cloud")
618
+ .font(.system(size: 18, weight: .bold, design: .rounded))
619
+ Text("Optional authenticated-profile upload")
620
+ .font(.system(size: 11.5)).foregroundStyle(.secondary)
621
+ }
622
+ Spacer()
623
+ FixedBadge(model.browserlessConfigured ? "Connected" : "Not connected")
624
+ }
625
+
626
+ VStack(alignment: .leading, spacing: 12) {
627
+ LabeledContent("API token") {
628
+ SecureField(model.browserlessConfigured ? "Saved in Keychain — leave blank to keep" : "Browserless API token", text: $token)
629
+ .textFieldStyle(.roundedBorder)
630
+ .frame(width: 270)
631
+ }
632
+ LabeledContent("Cloud profile") {
633
+ TextField("browser-cookie-bridge", text: $profileName)
634
+ .textFieldStyle(.roundedBorder)
635
+ .frame(width: 270)
636
+ }
637
+ LabeledContent("Region") {
638
+ Picker("", selection: $region) {
639
+ Text("San Francisco (SFO)").tag("sfo")
640
+ Text("London (LON)").tag("lon")
641
+ Text("Amsterdam (AMS)").tag("ams")
642
+ }
643
+ .labelsHidden()
644
+ .frame(width: 270)
645
+ }
646
+ LabeledContent("Only domains") {
647
+ TextField("Optional: example.com, app.example.com", text: $onlyDomains)
648
+ .textFieldStyle(.roundedBorder)
649
+ .frame(width: 270)
650
+ }
651
+ }
652
+ .font(.system(size: 11.5, weight: .medium))
653
+ .padding(14)
654
+ .background(Color(nsColor: .controlBackgroundColor).opacity(0.72), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
655
+ .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous).stroke(Color.primary.opacity(0.08)))
656
+
657
+ HStack(alignment: .top, spacing: 10) {
658
+ Image(systemName: "exclamationmark.cloud.fill")
659
+ .foregroundStyle(Theme.active)
660
+ 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.")
661
+ .font(.system(size: 10.5))
662
+ .foregroundStyle(.secondary)
663
+ .fixedSize(horizontal: false, vertical: true)
664
+ }
665
+ .padding(12)
666
+ .background(Theme.active.opacity(0.08), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
667
+
668
+ Toggle("I understand the upload contents, have authority to share them, and accept responsibility for the Browserless copy.", isOn: $cloudConsent)
669
+ .toggleStyle(.checkbox)
670
+ .font(.system(size: 10.5, weight: .medium))
671
+
672
+ HStack {
673
+ Link("Get a Browserless API token", destination: URL(string: "https://www.browserless.io/account")!)
674
+ .font(.system(size: 10.5, weight: .medium))
675
+ Link("Privacy", destination: URL(string: "https://www.browserless.io/privacy-policy")!)
676
+ .font(.system(size: 10.5, weight: .medium))
677
+ if model.browserlessConfigured {
678
+ Button("Disconnect", role: .destructive) { model.disconnectBrowserless() }
679
+ }
680
+ Spacer()
681
+ Button("Cancel") { dismiss() }
682
+ Button("Save connection") {
683
+ model.saveBrowserlessSettings(token: token, profileName: profileName, region: region, onlyDomains: onlyDomains)
684
+ }
685
+ .keyboardShortcut(.defaultAction)
686
+ .disabled(profileName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !cloudConsent)
687
+ }
688
+ }
689
+ .padding(22)
690
+ .frame(width: 520)
691
+ .onAppear {
692
+ profileName = model.browserlessProfileName
693
+ region = model.browserlessRegion
694
+ onlyDomains = model.browserlessOnlyDomains
695
+ }
696
+ }
697
+ }
698
+
520
699
  struct ExtensionSetupSheet: View {
521
700
  @EnvironmentObject private var model: SyncModel
522
701
  @Environment(\.dismiss) private var dismiss