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>
210 lines
8.3 KiB
Swift
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
|
|
}
|
|
}
|