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:
2026-10-08 09:56:08 +08:00
parent 4934ad916a
commit 15ad9f355c
11 changed files with 747 additions and 3 deletions
+9
View File
@@ -19,6 +19,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory)
if CommandLine.arguments.contains("--sync-check") {
Task { @MainActor in
await SyncService.shared.runDiagnostic()
NSApp.terminate(nil)
}
return
}
// Single instance: stale copies keep their hotkeys alive after an
// update and eat the events meant for the new instance.
let myPid = ProcessInfo.processInfo.processIdentifier
@@ -43,6 +51,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// Show the panel on launch so the app is discoverable (it has no Dock
// icon and the menu bar icon can be hard to spot on a full bar).
Task { @MainActor in
SyncService.shared.start()
PanelController.shared.show()
}
}
+109 -1
View File
@@ -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 {
+36 -1
View File
@@ -47,7 +47,7 @@ final class Store: ObservableObject {
try? FileManager.default.createDirectory(at: imagesDirectory, withIntermediateDirectories: true)
load()
if sections.isEmpty {
let workspace = Section(name: "Workspace")
let workspace = Section(name: "Default")
sections = [workspace]
activeSectionID = workspace.id
save()
@@ -77,6 +77,41 @@ final class Store: ObservableObject {
let file = StoreFile(sections: sections, activeSectionID: activeSectionID)
guard let data = try? JSONEncoder().encode(file) else { return }
try? data.write(to: storeFile, options: .atomic)
onDidChange?()
}
// MARK: - Sync support
/// Called after every save so `SyncService` can push the change.
var onDidChange: (() -> Void)?
func replaceAll(sections newSections: [Section], activeSectionID newActiveSectionID: UUID?) {
sections = newSections
if let newActiveSectionID, newSections.contains(where: { $0.id == newActiveSectionID }) {
activeSectionID = newActiveSectionID
} else {
activeSectionID = newSections.first?.id
}
save()
}
func imageData(for filename: String) -> Data? {
try? Data(contentsOf: imagesDirectory.appendingPathComponent(filename))
}
func importImage(filename: String, data: Data) {
let url = imagesDirectory.appendingPathComponent(filename)
if let existing = try? Data(contentsOf: url), existing == data { return }
try? data.write(to: url)
imageCache.removeObject(forKey: filename as NSString)
}
func cleanupOrphanedImages(referenced: Set<String>) {
let files = (try? FileManager.default.contentsOfDirectory(at: imagesDirectory, includingPropertiesForKeys: nil)) ?? []
for file in files where !referenced.contains(file.lastPathComponent) {
try? FileManager.default.removeItem(at: file)
imageCache.removeObject(forKey: file.lastPathComponent as NSString)
}
}
// MARK: - Sections
+311
View File
@@ -0,0 +1,311 @@
import AppKit
struct SyncPayload: Codable {
var sections: [Section]
var activeSectionID: UUID?
var images: [String: String] // filename -> base64 PNG
}
private struct SyncGetResponse: Codable {
var version: Int64
var data: SyncPayload?
}
// Syncs the whole store (sections + items + images) to the Go backend.
// No authentication: the 8-digit group code (1234-5678) is the secret.
// Conflict resolution is last-writer-wins at store granularity, coordinated
// by a server-side version counter.
@MainActor
final class SyncService: ObservableObject {
static let shared = SyncService()
@Published private(set) var isSyncing = false
@Published private(set) var lastSyncedAt: Date?
@Published var lastError: String?
@Published var code: String? {
didSet {
defaults.set(code, forKey: Self.codeKey)
restartPolling()
}
}
@Published var serverURL: String {
didSet { defaults.set(serverURL, forKey: Self.urlKey) }
}
private let defaults = UserDefaults.standard
private static let codeKey = "sync.code"
private static let urlKey = "sync.serverURL"
private static let versionKey = "sync.version"
private var lastVersion: Int64 {
get { Int64(defaults.integer(forKey: Self.versionKey)) }
set { defaults.set(newValue, forKey: Self.versionKey) }
}
private var pollTimer: Timer?
private var pushWork: DispatchWorkItem?
private var applyingRemote = false
/// Bytes of the last payload this client pushed or applied. Comparing it
/// before pushing stops an applied remote change from echoing straight
/// back to the server (which would ping-pong between two Macs forever).
private var lastSyncedPayload: Data?
private init() {
code = defaults.string(forKey: Self.codeKey)
serverURL = defaults.string(forKey: Self.urlKey) ?? "http://localhost:8080"
}
func start() {
Store.shared.onDidChange = { [weak self] in
Task { @MainActor in self?.schedulePush() }
}
restartPolling()
}
// MARK: - Group membership
func generateCode() {
guard let url = endpoint("/api/codes") else {
lastError = "Server URL must start with http:// or https://"
return
}
run {
var request = URLRequest(url: url)
request.httpMethod = "POST"
let (data, response) = try await URLSession.shared.data(for: request)
try Self.requireOK(response)
let result = try JSONDecoder().decode([String: String].self, from: data)
guard let code = result["code"] else { throw SyncError.badResponse }
self.code = code
self.push()
}
}
// Joining a group with data REPLACES the local store with the remote one.
// Joining an empty group uploads the local store instead.
func join(rawCode: String) {
guard let formatted = Self.normalizeCode(rawCode) else {
lastError = "Code must be 8 digits, like 1234-5678"
return
}
guard let url = endpoint("/api/sync/\(formatted)") else {
lastError = "Server URL must start with http:// or https://"
return
}
run {
let (data, response) = try await URLSession.shared.data(from: url)
if (response as? HTTPURLResponse)?.statusCode == 404 {
throw SyncError.unknownCode
}
try Self.requireOK(response)
let remote = try JSONDecoder().decode(SyncGetResponse.self, from: data)
self.code = formatted
if let payload = remote.data {
self.apply(payload, version: remote.version)
} else {
self.push()
}
}
}
func disconnect() {
code = nil
lastVersion = 0
lastError = nil
}
// MARK: - Push / poll
func schedulePush() {
guard !applyingRemote, code != nil else { return }
pushWork?.cancel()
let work = DispatchWorkItem { [weak self] in
Task { @MainActor in self?.push() }
}
pushWork = work
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5, execute: work)
}
private func push() {
run { try await self.pushNow() }
}
private func pushNow() async throws {
guard let code, let url = endpoint("/api/sync/\(code)") else { return }
let payload = buildPayload()
guard let encoded = encode(payload) else { return }
guard encoded != lastSyncedPayload else {
syncLog("push skipped: unchanged since last sync")
return
}
var request = URLRequest(url: url)
request.httpMethod = "PUT"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = encoded
let (data, response) = try await URLSession.shared.data(for: request)
try Self.requireOK(response)
let result = try JSONDecoder().decode([String: Int64].self, from: data)
lastVersion = result["version"] ?? lastVersion
lastSyncedPayload = encoded
lastSyncedAt = Date()
syncLog("pushed as v\(lastVersion)")
}
/// Powers `Prompt --sync-check`: pushes any local change and reports both
/// sides so sync can be verified from a terminal on any Mac.
func runDiagnostic() async {
print("Prompt sync check")
print("server: \(serverURL)")
guard let code else {
print("code: (none — generate one in the panel first)")
return
}
print("code: \(code)")
print("local: \(Store.shared.sections.count) sections, last known v\(lastVersion)")
do {
try await pushNow()
print("push: ok (now at v\(lastVersion))")
} catch {
print("push: failed — \(error.localizedDescription)")
}
guard let url = endpoint("/api/sync/\(code)") else { return }
do {
let (data, _) = try await URLSession.shared.data(from: url)
let remote = try JSONDecoder().decode(SyncGetResponse.self, from: data)
print("remote: v\(remote.version), \(remote.data?.sections.count ?? 0) sections")
} catch {
print("remote: failed — \(error.localizedDescription)")
}
}
private func poll() {
guard !isSyncing, let code, let url = endpoint("/api/sync/\(code)") else { return }
Task {
do {
let (data, response) = try await URLSession.shared.data(from: url)
if (response as? HTTPURLResponse)?.statusCode == 404 {
lastError = "Sync group no longer exists on the server"
return
}
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
syncLog("poll HTTP \((response as? HTTPURLResponse)?.statusCode ?? -1)")
return
}
let remote = try JSONDecoder().decode(SyncGetResponse.self, from: data)
syncLog("poll remote v\(remote.version) local v\(lastVersion)")
if remote.version > lastVersion, let payload = remote.data {
apply(payload, version: remote.version)
}
lastError = nil
} catch {
syncLog("poll failed: \(error.localizedDescription)")
// Polling errors are transient; stay quiet unless nothing ever worked.
if lastSyncedAt == nil { lastError = "Cannot reach sync server" }
}
}
}
private func restartPolling() {
pollTimer?.invalidate()
pollTimer = nil
guard code != nil else { return }
pollTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in
Task { @MainActor in self?.poll() }
}
}
// MARK: - Payload mapping
private func buildPayload() -> SyncPayload {
let store = Store.shared
var images: [String: String] = [:]
for item in store.sections.flatMap(\.items) {
if case .image(let filename) = item.content,
let data = store.imageData(for: filename) {
images[filename] = data.base64EncodedString()
}
}
return SyncPayload(sections: store.sections, activeSectionID: store.activeSectionID, images: images)
}
private func apply(_ payload: SyncPayload, version: Int64) {
applyingRemote = true
defer { applyingRemote = false }
let store = Store.shared
for (filename, base64) in payload.images {
if let data = Data(base64Encoded: base64) {
store.importImage(filename: filename, data: data)
}
}
store.cleanupOrphanedImages(referenced: Set(payload.images.keys))
store.replaceAll(sections: payload.sections, activeSectionID: payload.activeSectionID)
lastVersion = version
lastSyncedPayload = encode(payload)
lastSyncedAt = Date()
syncLog("applied remote v\(version): \(payload.sections.count) sections, \(payload.images.count) images")
}
// MARK: - Helpers
private func run(_ operation: @escaping @MainActor () async throws -> Void) {
isSyncing = true
lastError = nil
Task {
do {
try await operation()
} catch {
syncLog("operation failed: \(error.localizedDescription)")
lastError = error.localizedDescription
}
isSyncing = false
}
}
private func endpoint(_ path: String) -> URL? {
var base = serverURL.trimmingCharacters(in: .whitespaces)
while base.hasSuffix("/") { base.removeLast() }
guard base.hasPrefix("http://") || base.hasPrefix("https://") else { return nil }
return URL(string: base + path)
}
private func syncLog(_ message: String) {
NSLog("Prompt sync: %@", message)
}
/// Stable encoding used for change detection: sorted keys keep dictionary
/// ordering deterministic so identical payloads compare equal.
private func encode(_ payload: SyncPayload) -> Data? {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
return try? encoder.encode(payload)
}
static func normalizeCode(_ raw: String) -> String? {
let digits = raw.filter(\.isNumber)
guard digits.count == 8 else { return nil }
let index = digits.index(digits.startIndex, offsetBy: 4)
return "\(digits[..<index])-\(digits[index...])"
}
private static func requireOK(_ response: URLResponse) throws {
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw SyncError.server(statusCode: (response as? HTTPURLResponse)?.statusCode ?? -1)
}
}
}
private enum SyncError: LocalizedError {
case unknownCode
case badResponse
case server(statusCode: Int)
var errorDescription: String? {
switch self {
case .unknownCode: return "Unknown code — generate one first on the other Mac"
case .badResponse: return "Unexpected response from sync server"
case .server(let code): return "Sync server returned HTTP \(code)"
}
}
}