textplus/Sources/SyncEngine.swift
monster 09972ccde1 TextPlus: native macOS plaintext editor with sync, scraping, and a freeform board
A no-Xcode AppKit app (swiftc + build.sh):
- Plaintext editor: 5 themes, line numbers, soft wrap, font controls, find,
  live Markdown preview (split mode), selection-aware Markdown toolbar.
- Freeform Board mode: zoomable/pannable canvas of page cards (portrait/landscape,
  US Letter/A4/Legal/Square/Wide/Note), clean boxes, images (paste/drop/panel),
  freehand pen/shapes, screen-grab eyedropper, nested right-click menu, rich
  status bar. The Editor follows the selected page; layout persists to a
  foo.txt.board sidecar; the .txt stays pure plaintext.
- scp-on-save to a Tailscale Mac (key auth, in-flight/quit drain).
- HTML scraping pipeline: custom selector engine + Discogs profile, ingests to
  Postgres (psql) into a marketplace_snapshots schema.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 16:08:59 +10:00

210 lines
8.3 KiB
Swift

import Foundation
enum SyncSettings {
static let enabledKey = "syncEnabled"
static let remoteKey = "syncRemote"
static let dirKey = "syncRemoteDir"
static let portKey = "syncPort"
static var enabled: Bool { UserDefaults.standard.bool(forKey: enabledKey) }
static var remote: String {
(UserDefaults.standard.string(forKey: remoteKey) ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
static var dir: String {
(UserDefaults.standard.string(forKey: dirKey) ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
static var port: Int {
let raw = (UserDefaults.standard.string(forKey: portKey) ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard let p = Int(raw), (1...65535).contains(p) else { return 22 }
return p
}
static var isConfigured: Bool { !remote.isEmpty }
}
struct SyncError: Error {
let message: String
}
enum SyncEngine {
/// Main-thread-only count of scp uploads that haven't finished yet.
private(set) static var inFlightUploads = 0
/// Called on the main thread when the last in-flight upload finishes.
static var onIdle: (() -> Void)?
private static let sshOptions = [
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=10",
"-o", "ServerAliveInterval=15",
"-o", "ServerAliveCountMax=3",
"-o", "StrictHostKeyChecking=accept-new",
]
private static let watchdogSeconds: TimeInterval = 120
// All invocations run serially: rapid saves of the same file must never
// have two scp processes writing the same remote path at once.
private static let workQueue = DispatchQueue(label: "textplus.sync")
/// Copy the file to the configured remote directory in the background.
/// Call from the main thread; the completion handler runs on the main queue.
static func upload(fileURL: URL, completion: @escaping (Result<Void, SyncError>) -> Void) {
dispatchPrecondition(condition: .onQueue(.main))
inFlightUploads += 1
let destination = scpDestination(remote: SyncSettings.remote, dir: SyncSettings.dir)
let args = sshOptions + ["-P", String(SyncSettings.port), fileURL.path, destination]
run(tool: "/usr/bin/scp", args: args) { status, output, timedOut in
if status == 0 && !timedOut {
completion(.success(()))
} else {
completion(.failure(SyncError(message: friendlyMessage(tool: "scp", status: status, output: output, timedOut: timedOut))))
}
inFlightUploads -= 1
if inFlightUploads == 0 { onIdle?() }
}
}
/// Check that we can reach the remote and that the directory exists.
static func testConnection(remote: String, port: Int, dir: String,
completion: @escaping (Result<String, SyncError>) -> Void) {
let trimmedDir = dir.trimmingCharacters(in: .whitespacesAndNewlines)
let probe = trimmedDir.isEmpty
? "echo __OK__"
: "if [ -d \(probePath(trimmedDir)) ]; then echo __OK__; else echo __NODIR__; fi"
let args = sshOptions + ["-p", String(port), remote, probe]
run(tool: "/usr/bin/ssh", args: args) { status, output, timedOut in
if output.contains("__OK__") {
completion(.success("Connected — folder exists"))
} else if output.contains("__NODIR__") {
completion(.failure(SyncError(message: "Connected, but the remote folder doesn't exist")))
} else {
completion(.failure(SyncError(message: friendlyMessage(tool: "ssh", status: status, output: output, timedOut: timedOut))))
}
}
}
// MARK: - Destination / path handling
/// scp splits the remote spec at the first colon, so IPv6 literals
/// (and other hosts containing ':') must be bracketed.
private static func scpDestination(remote: String, dir: String) -> String {
var d = dir
if !d.isEmpty && !d.hasSuffix("/") { d += "/" }
var user = ""
var host = remote
if let at = remote.lastIndex(of: "@") {
user = String(remote[...at])
host = String(remote[remote.index(after: at)...])
}
if host.contains(":") && !host.hasPrefix("[") {
host = "[\(host)]"
}
return "\(user)\(host):\(d)"
}
/// Quote the directory for the remote shell, but leave a leading ~
/// unquoted so it expands matching scp's SFTP-mode ~ expansion on upload.
private static func probePath(_ dir: String) -> String {
if dir == "~" { return "~" }
if dir.hasPrefix("~/") { return "~/" + shellQuoted(String(dir.dropFirst(2))) }
return shellQuoted(dir)
}
private static func shellQuoted(_ s: String) -> String {
"'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
// MARK: - Error reporting
private static func friendlyMessage(tool: String, status: Int32, output: String, timedOut: Bool) -> String {
if timedOut {
return "Timed out after \(Int(watchdogSeconds)) seconds"
}
var message = output.isEmpty ? "\(tool) exited with status \(status)" : output
if message.contains("Permission denied") {
// The window subtitle shows only the first line, so the hint goes there.
let lines = message.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
let hint = " — SSH key not available? Run 'ssh-add --apple-load-keychain' in Terminal,"
+ " or add 'UseKeychain yes' and 'AddKeysToAgent yes' to ~/.ssh/config"
message = (lines.first ?? message) + hint
if lines.count > 1 {
message += "\n" + lines.dropFirst().joined(separator: "\n")
}
}
return message
}
// MARK: - Process plumbing
private static func run(tool: String, args: [String],
completion: @escaping (Int32, String, Bool) -> Void) {
workQueue.async {
let process = Process()
process.executableURL = URL(fileURLWithPath: tool)
process.arguments = args
let outPipe = Pipe()
let errPipe = Pipe()
process.standardOutput = outPipe
process.standardError = errPipe
process.standardInput = FileHandle.nullDevice
do {
try process.run()
} catch {
DispatchQueue.main.async { completion(-1, error.localizedDescription, false) }
return
}
// Watchdog: unstick a wedged transfer. Weak capture so the canceled
// work item doesn't pin the Process and pipe FDs until its deadline.
let timedOutFlag = AtomicFlag()
let watchdog = DispatchWorkItem { [weak process] in
guard let process, process.isRunning else { return }
timedOutFlag.set()
process.terminate()
}
DispatchQueue.global().asyncAfter(deadline: .now() + watchdogSeconds, execute: watchdog)
// Drain both pipes concurrently so a full stderr buffer can't
// stall the child while we block on stdout.
var errData = Data()
let errDone = DispatchSemaphore(value: 0)
DispatchQueue.global(qos: .utility).async {
errData = errPipe.fileHandleForReading.readDataToEndOfFile()
errDone.signal()
}
let outData = outPipe.fileHandleForReading.readDataToEndOfFile()
errDone.wait()
process.waitUntilExit()
watchdog.cancel()
let combined = (String(decoding: outData, as: UTF8.self) + "\n"
+ String(decoding: errData, as: UTF8.self))
.trimmingCharacters(in: .whitespacesAndNewlines)
let status = process.terminationStatus
let timedOut = timedOutFlag.get()
DispatchQueue.main.async { completion(status, combined, timedOut) }
}
}
}
private final class AtomicFlag {
private let lock = NSLock()
private var value = false
func set() {
lock.lock()
value = true
lock.unlock()
}
func get() -> Bool {
lock.lock()
defer { lock.unlock() }
return value
}
}