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) { 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) -> 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 } }