Add optional sync between Macs with a shared 8-digit code
Notes and photos were stuck on one machine. A small Go/Postgres backend
stores each group's payload under a code that doubles as the shared
secret, so a second Mac just needs the code — no account or login.
Applying a remote change must not push it straight back, otherwise the
two Macs would bump the version back and forth forever, so payloads are
fingerprinted and identical ones are skipped.
💘 Generated with Crush
Assisted-by: Crush:kimi-k3
This commit is contained in:
@@ -4,11 +4,14 @@ import UniformTypeIdentifiers
|
||||
|
||||
struct PanelView: View {
|
||||
@ObservedObject var store: Store
|
||||
@ObservedObject private var sync = SyncService.shared
|
||||
|
||||
@State private var composer = ""
|
||||
@State private var search = ""
|
||||
@State private var showSearch = false
|
||||
@State private var showSectionSwitcher = false
|
||||
@State private var showSync = false
|
||||
@State private var joinCode = ""
|
||||
@State private var selection = Set<UUID>()
|
||||
@State private var copiedID: UUID?
|
||||
@State private var accessibilityGranted = AXIsProcessTrusted()
|
||||
@@ -33,6 +36,7 @@ struct PanelView: View {
|
||||
.frame(minWidth: 300)
|
||||
.background(shortcutButtons)
|
||||
.overlay(sectionSwitcher)
|
||||
.overlay(syncOverlay)
|
||||
.onDrop(of: [UTType.image.identifier, UTType.fileURL.identifier], isTargeted: nil, perform: handleDrop)
|
||||
.onReceive(NotificationCenter.default.publisher(for: .promptPanelDidShow)) { _ in
|
||||
focusedField = .composer
|
||||
@@ -62,6 +66,11 @@ struct PanelView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
Button { withAnimation(.easeInOut(duration: 0.15)) { showSync.toggle() } } label: {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
.foregroundStyle(sync.code == nil ? Color.secondary : Color.accentColor)
|
||||
}
|
||||
.buttonStyle(.plain).help("Sync")
|
||||
Button { toggleSearch() } label: { Image(systemName: "magnifyingglass") }
|
||||
.buttonStyle(.plain).foregroundStyle(.secondary).help("Search (Cmd+F)")
|
||||
Button { addPhotos() } label: { Image(systemName: "photo.on.rectangle.angled") }
|
||||
@@ -144,7 +153,7 @@ struct PanelView: View {
|
||||
}
|
||||
.padding(10)
|
||||
}
|
||||
.onChange(of: store.activeSection.items.count) { _ in
|
||||
.onChange(of: store.activeSection.items.count) {
|
||||
if let last = store.activeSection.items.last {
|
||||
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
|
||||
}
|
||||
@@ -342,6 +351,105 @@ struct PanelView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sync
|
||||
|
||||
@ViewBuilder
|
||||
private var syncOverlay: some View {
|
||||
if showSync {
|
||||
VStack(spacing: 0) {
|
||||
Color.black.opacity(0.001)
|
||||
.onTapGesture { showSync = false }
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text("Sync").font(.headline)
|
||||
Spacer()
|
||||
if sync.isSyncing { ProgressView().controlSize(.small) }
|
||||
}
|
||||
TextField("Server URL", text: $sync.serverURL)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.callout)
|
||||
if let code = sync.code {
|
||||
Divider()
|
||||
Text("Your code").font(.caption).foregroundStyle(.secondary)
|
||||
HStack {
|
||||
Text(code)
|
||||
.font(.system(size: 20, weight: .medium, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
Spacer()
|
||||
Button {
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString(code, forType: .string)
|
||||
} label: {
|
||||
Image(systemName: "doc.on.doc")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Copy code")
|
||||
}
|
||||
Text("Enter this code on your other Mac to sync.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
statusLine
|
||||
Divider()
|
||||
Button("Disconnect", role: .destructive) { sync.disconnect() }
|
||||
.font(.callout)
|
||||
} else {
|
||||
Divider()
|
||||
Button("Generate Code") { sync.generateCode() }
|
||||
.disabled(sync.isSyncing)
|
||||
Text("Creates a new sync group and uploads what's here.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Divider()
|
||||
Text("Or join an existing group").font(.caption).foregroundStyle(.secondary)
|
||||
HStack {
|
||||
TextField("1234-5678", text: $joinCode)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.onExitCommand { showSync = false }
|
||||
Button("Join") {
|
||||
sync.join(rawCode: joinCode)
|
||||
joinCode = ""
|
||||
}
|
||||
.disabled(sync.isSyncing || joinCode.isEmpty)
|
||||
}
|
||||
Text("Joining replaces what's on this Mac with the group's notes.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let error = sync.lastError {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.background(.regularMaterial)
|
||||
.cornerRadius(10)
|
||||
.shadow(radius: 12)
|
||||
.padding(.horizontal, 20)
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
.padding(.top, 44)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusLine: some View {
|
||||
if sync.isSyncing {
|
||||
Text("Syncing…").font(.caption).foregroundStyle(.secondary)
|
||||
} else if let last = sync.lastSyncedAt {
|
||||
Text("Last synced \(last, style: .relative) ago")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("Not synced yet").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Composer
|
||||
|
||||
private var composerField: some View {
|
||||
|
||||
Reference in New Issue
Block a user