import Foundation import TermindApplication import TermindDomain import struct TermindDomain.Host @MainActor public final class SFTPEditService { private struct Key: Hashable { let hostID: Host.ID let path: String } private struct Confirmation { let version: SFTPFileVersion? } /// Retain route identity, never credentials, alongside an editable copy. private struct Destination: Equatable { struct Hop: Equatable { let host: String let port: Int let username: String let keys: Set let proxy: [String] } let hops: [Hop] init(_ snapshot: SSHAdditionalConnectionSnapshot) { func proxy(_ value: SSHAdditionalConnectionSnapshot.Proxy?) -> [String] { guard let value else { return [] } return [value.kind.rawValue, value.hostname, String(value.port)] } hops = snapshot.jumps.map { Hop( host: $0.host, port: $0.port, username: $0.authentication.username, keys: $0.expectedHostKeys, proxy: proxy($0.proxy)) } + [ Hop( host: snapshot.target.host, port: snapshot.target.port, username: snapshot.target.authentication.username, keys: snapshot.target.expectedHostKeys, proxy: proxy(snapshot.target.proxy)) ] } } public let model = SFTPEditModel() private let transfers: SFTPTransferCoordinator private let localFiles = SFTPEditLocalFiles() private let host: ((Host.ID) -> Host?)? private var originalHosts: [Host.ID: Host] = [:] private var destinations: [UUID: Destination] = [:] private var opening: [Key: Task] = [:] private var generations: [Host.ID: Int] = [:] private var confirmations: [UUID: Confirmation] = [:] private var observations: [UUID: SFTPEditFileObservation] = [:] private var monitoring: Task? private var monitoringEnabled = true public init(transfers: SFTPTransferCoordinator, host: ((Host.ID) -> Host?)? = nil) { self.transfers = transfers self.host = host } deinit { monitoring?.cancel() } public func open( _ file: RemoteFile, hostID: Host.ID, connection: SFTPTransferConnection, client: any SFTPChannelProtocol, homeDirectory: String? = nil, snapshot: SSHAdditionalConnectionSnapshot? = nil ) async throws -> URL { validateHosts() if let existing = model.file(hostID: hostID, path: file.path) { guard !existing.isInvalidated, matchesDestination(existing.id, connection: connection, snapshot: snapshot) else { throw SFTPEditFailure.unavailable } return existing.localURL } let key = Key(hostID: hostID, path: file.path) if let task = opening[key] { let url = try await task.value if let existing = model.file(hostID: hostID, path: file.path), !matchesDestination(existing.id, connection: connection, snapshot: snapshot) { throw SFTPEditFailure.unavailable } return url } let generation = generations[hostID, default: 0] let originalHost = host?(hostID) let task = Task { [self] in guard RemoteFileName.isValid(file.name) else { throw CocoaError(.fileWriteInvalidFileName) } let original = try await client.attributes(at: file.path) guard let original, !original.isDirectory else { throw SFTPEditFailure.unavailable } let directory = FileManager.default.temporaryDirectory .appendingPathComponent("TermindEdits", isDirectory: true) .appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) let url = directory.appendingPathComponent(file.name) let downloadReceipt = SFTPEditTransferReceipt() try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in transfers.enqueue( direction: .download, batchID: UUID(), localURL: url, remotePath: file.path, byteCount: original.size, hostID: hostID, connection: connection, scopedRoot: directory, overwrite: false, homeDirectory: homeDirectory, editReceipt: downloadReceipt ) { state in switch state { case .completed: continuation.resume() case .failed(let message): continuation.resume(throwing: SFTPError.operation(message)) case .cancelled: continuation.resume(throwing: CancellationError()) case .queued, .running, .paused: break } } } guard generations[hostID, default: 0] == generation else { throw SFTPEditFailure.unavailable } if original.kind == .file { // Production downloads report the version from their own open handle; the browser may be closed. let downloadedVersion: SFTPFileVersion? if let version = downloadReceipt.version { downloadedVersion = version } else { downloadedVersion = try await client.attributes(at: file.path).map(SFTPFileVersion.init) } guard downloadedVersion == SFTPFileVersion(original) else { throw SFTPEditFailure.remoteChanged } let digest = try await localFiles.digest(url) guard generations[hostID, default: 0] == generation else { throw SFTPEditFailure.unavailable } if let originalHost { originalHosts[hostID] = originalHost } let id = model.register(hostID: hostID, file: original, localURL: url, digest: digest) destinations[id] = destination(connection, snapshot: snapshot) validateHosts() startMonitoring() } return url } opening[key] = task defer { opening[key] = nil } return try await task.value } public func recheck(force: Bool = true) async { validateHosts() for entry in model.files { do { let digest = try await localFiles.digest(entry.localURL, force: force) model.update(entry.id) { $0.currentDigest = digest $0.localError = nil } } catch { guard !Task.isCancelled else { return } model.update(entry.id) { $0.localError = error.localizedDescription } } } } public func setMonitoring(_ enabled: Bool) { monitoringEnabled = enabled monitoring?.cancel() monitoring = nil observations.removeAll() if enabled { startMonitoring() } } private func startMonitoring() { guard monitoringEnabled, !model.files.isEmpty else { return } for file in model.files where observations[file.id] == nil { observations[file.id] = SFTPEditFileObservation(url: file.localURL) { [weak self] in await self?.recheck() } } guard monitoring == nil else { return } monitoring = Task { [weak self] in var tick = 0 while !Task.isCancelled { do { try await Task.sleep(for: .seconds(1)) } catch { return } tick += 1 // Re-resolve paths on every pass, including editors' atomic-save replacements. // Foreground and upload checks always hash; periodic full checks cover preserved timestamps. await self?.recheck(force: tick % 30 == 0) } } } private func validateHosts() { guard let host else { return } for (id, original) in originalHosts { if host(id).map({ original.hasSameConnectionRoute(as: $0) }) != true { invalidate(hostID: id) originalHosts[id] = nil } } } public func invalidate(hostID: Host.ID) { generations[hostID, default: 0] += 1 model.invalidate(hostID: hostID) for file in model.files where file.hostID == hostID { if let id = file.transferID { transfers.cancel(id) } confirmations[file.id] = nil } } /// Discard ends tracking. External editors may still own the copy, so never delete it. public func discard(_ id: UUID) async { guard let file = model.file(id), !file.isPreparing else { return } if let transferID = file.transferID { guard transfers.sftpTransfers.first(where: { $0.id == transferID })?.state == .paused else { return } model.update(id) { $0.isPreparing = true $0.error = nil } transfers.cancel(transferID) while model.file(id)?.transferID != nil { do { try await Task.sleep(for: .milliseconds(20)) } catch { model.update(id) { $0.isPreparing = false } return } } model.update(id) { $0.isPreparing = false } // Keep cleanup failures visible, including a possible remote partial-file path. guard model.file(id)?.error == nil else { return } } model.remove(id) observations[id] = nil confirmations[id] = nil destinations[id] = nil await localFiles.forget(file.localURL) if model.files.isEmpty { monitoring?.cancel() monitoring = nil } } /// Returns true only when the UI must ask to replace the version just observed. public func upload( _ id: UUID, connection: SFTPTransferConnection, client: any SFTPChannelProtocol, homeDirectory: String? = nil, replacing: Bool = false, snapshot: SSHAdditionalConnectionSnapshot? = nil, didUpload: @escaping () -> Void = {} ) async -> Bool { validateHosts() guard let entry = model.file(id), !entry.isPreparing, entry.transferID == nil, !entry.isInvalidated else { return false } guard matchesDestination(id, connection: connection, snapshot: snapshot) else { return false } model.update(id) { $0.isPreparing = true $0.error = nil } var snapshot: SFTPEditLocalFiles.Snapshot? defer { model.update(id) { $0.isPreparing = false } } do { let currentDigest = try await localFiles.digest(entry.localURL) model.update(id) { $0.currentDigest = currentDigest } guard currentDigest != entry.uploadedDigest else { return false } let remote = try await client.attributes(at: entry.remotePath) guard remote == nil || remote?.kind == .file else { throw SFTPEditFailure.unsupported } let version = remote.map(SFTPFileVersion.init) let confirmed = replacing && confirmations[id].map { $0.version == version } == true if !confirmed && (version != entry.baseline || version?.modifiedAt == nil) { confirmations[id] = Confirmation(version: version) return true } confirmations[id] = nil let prepared = try await localFiles.snapshot(entry.localURL) snapshot = prepared validateHosts() guard model.file(id)?.isInvalidated == false else { throw SFTPEditFailure.unavailable } let receipt = SFTPEditTransferReceipt() let transferID = transfers.enqueue( direction: .upload, batchID: UUID(), localURL: prepared.url, remotePath: entry.remotePath, byteCount: prepared.size, hostID: entry.hostID, connection: connection, scopedRoot: prepared.url.deletingLastPathComponent(), overwrite: remote != nil, homeDirectory: homeDirectory, editPolicy: SFTPEditUploadPolicy( expected: version, permissions: version?.permissions ?? entry.baseline.permissions), editReceipt: receipt ) { [weak self, localFiles] state in Task { @MainActor in await localFiles.removeSnapshot(prepared) guard let self else { return } await self.recheck() if state == .completed { self.model.didUpload(id, digest: prepared.digest, baseline: receipt.version ?? entry.baseline) didUpload() } else { self.model.update(id) { $0.transferID = nil if case .failed(let message) = state { $0.error = message } } } } } model.update(id) { $0.transferID = transferID } return false } catch { if let snapshot { await localFiles.removeSnapshot(snapshot) } model.update(id) { $0.error = error.localizedDescription } return false } } private func destination( _ connection: SFTPTransferConnection, snapshot: SSHAdditionalConnectionSnapshot? ) -> Destination? { if let snapshot { return Destination(snapshot) } if case .saved(let saved) = connection { return Destination(saved) } if case .context(let session) = connection { return Destination(session.snapshot) } return nil } private func matchesDestination( _ id: UUID, connection: SFTPTransferConnection, snapshot: SSHAdditionalConnectionSnapshot? ) -> Bool { guard destinations[id] == destination(connection, snapshot: snapshot) else { model.update(id) { $0.isInvalidated = true $0.error = SFTPEditFailure.unavailable.localizedDescription } return false } return true } }