diff --git a/FrameworkTests/SFTPEditModelTests.swift b/FrameworkTests/SFTPEditModelTests.swift new file mode 100644 index 00000000..18237ed7 --- /dev/null +++ b/FrameworkTests/SFTPEditModelTests.swift @@ -0,0 +1,72 @@ +import Foundation +import TermindApplication +import TermindDomain +import Testing + +@Suite @MainActor +struct SFTPEditModelTests { + @Test func onlyChangedFilesAreVisibleAndRevertingHidesThem() { + let model = SFTPEditModel() + let hostID = UUID() + let file = remoteFile() + let id = model.register(hostID: hostID, file: file, localURL: URL(filePath: "/tmp/file"), digest: "original") + #expect(model.modifiedFiles(for: hostID).isEmpty) + model.update(id) { $0.currentDigest = "edited" } + #expect(model.modifiedFiles(for: hostID).map(\.id) == [id]) + #expect(model.modifiedFiles(for: UUID()).isEmpty) + model.update(id) { $0.currentDigest = "original" } + #expect(!model.hasPendingChanges) + } + + @Test func uploadingOneVersionDoesNotClearLaterChanges() { + let model = SFTPEditModel() + let hostID = UUID() + let file = remoteFile() + let id = model.register(hostID: hostID, file: file, localURL: URL(filePath: "/tmp/file"), digest: "one") + model.update(id) { + $0.currentDigest = "three" + $0.transferID = UUID() + } + model.didUpload(id, digest: "two", baseline: SFTPFileVersion(file)) + #expect(model.file(id)?.isModified == true) + #expect(model.file(id)?.transferID == nil) + model.didUpload(id, digest: "three", baseline: SFTPFileVersion(file)) + #expect(!model.hasPendingChanges) + } + + @Test func reopeningDoesNotReplaceAnEditedCopyAndInvalidationRetainsIt() { + let model = SFTPEditModel() + let hostID = UUID() + let id = model.register(hostID: hostID, file: remoteFile(), localURL: URL(filePath: "/tmp/one"), digest: "one") + model.update(id) { $0.currentDigest = "two" } + let duplicate = model.register( + hostID: hostID, file: remoteFile(), localURL: URL(filePath: "/tmp/new"), digest: "new") + #expect(id == duplicate) + #expect(model.file(id)?.localURL.path == "/tmp/one") + model.invalidate(hostID: hostID) + #expect(model.modifiedFiles(for: hostID).count == 1) + #expect(model.file(id)?.isInvalidated == true) + } + + @Test func uploadGuardRejectsChangedMissingAndNonregularDestinations() throws { + let original = remoteFile() + let policy = SFTPEditUploadPolicy(expected: SFTPFileVersion(original), permissions: 0o755) + try policy.validate(original) + for kind in [RemoteFile.Kind.symbolicLink, .directory, .special, .unknown] { + var replacement = original + replacement.kind = kind + #expect(throws: SFTPEditFailure.self) { try policy.validate(replacement) } + } + var changed = original + changed.modifiedAt = Date(timeIntervalSince1970: 2) + #expect(throws: SFTPEditFailure.self) { try policy.validate(changed) } + #expect(throws: SFTPEditFailure.self) { try policy.validate(nil) } + try SFTPEditUploadPolicy(expected: nil, permissions: 0o755).validate(nil) + } + + private func remoteFile() -> RemoteFile { + RemoteFile( + path: "/srv/file", name: "file", kind: .file, size: 4, + modifiedAt: Date(timeIntervalSince1970: 1), permissions: 0o755, ownerUID: 0, ownerGID: 0) + } +} diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 06f34535..94a1d2f5 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -1,6 +1,26 @@ { "sourceLanguage" : "en", "strings" : { + "Error Details" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"错误详情"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"錯誤詳細資訊"}}}}, + "The remote file changed. Review it before uploading again." : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"远端文件已更改。请检查后再上传。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"遠端檔案已變更。請檢查後再上傳。"}}}}, + "The original connection is no longer available." : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"原连接已不可用。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"原連線已無法使用。"}}}}, + "The local file changed while preparing the upload. Try again." : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"准备上传时本地文件发生了更改。请重试。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"準備上傳時本機檔案發生了變更。請重試。"}}}}, + "This file cannot be uploaded back to its original path." : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"无法将此文件上传回原路径。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"無法將此檔案上傳回原路徑。"}}}}, + "The remote file has changed" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"远端文件已更改"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"遠端檔案已變更"}}}}, + "Overwrite Remote File" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"覆盖远端文件"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"覆寫遠端檔案"}}}}, + "The remote file changed or could not be verified. Replace it with your saved local changes?" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"远端文件已更改或无法验证。要用已保存的本地修改替换它吗?"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"遠端檔案已變更或無法驗證。要用已儲存的本機修改取代它嗎?"}}}}, + "Uploading changes" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"正在上传修改"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"正在上傳修改"}}}}, + "Reconnect to upload changes" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"重新连接以上传修改"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"重新連線以上傳修改"}}}}, + "Open Local Copy" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"打开本地副本"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"打開本機副本"}}}}, + "Keep Local Copy" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"保留本地副本"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"保留本機副本"}}}}, + "Quit with unuploaded changes?" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"仍有修改未上传,确定退出吗?"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"仍有修改未上傳,確定結束嗎?"}}}}, + "Your edited files have not all been uploaded. Pending edits will not be restored next time." : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"编辑过的文件尚未全部上传。下次启动时不会恢复待上传记录。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"編輯過的檔案尚未全部上傳。下次啟動時不會還原待上傳記錄。"}}}}, + "Quit Anyway" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"仍然退出"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"仍然結束"}}}}, + "The edited copy uses a different format" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"编辑后的副本使用了不同格式"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"編輯後的副本使用了不同格式"}}}}, + "Save this copy separately to keep the original remote file format." : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"请单独保存此副本,以保留远端文件的原始格式。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"請另外儲存此副本,以保留遠端檔案的原始格式。"}}}}, + "Unable to preserve remote file permissions" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"无法保留远端文件权限"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"無法保留遠端檔案權限"}}}}, + "Unable to inspect edited file" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"无法检查编辑后的文件"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"無法檢查編輯後的檔案"}}}}, + "Unable to inspect remote edit destination" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"无法检查远端编辑目标"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"無法檢查遠端編輯目標"}}}}, "Google Vertex AI" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"Google Vertex AI"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Google Vertex AI"}}}}, "Vertex AI" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"Vertex AI"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Vertex AI"}}}}, "Project ID" : {"extractionState":"manual","localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"项目ID"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"專案ID"}}}}, diff --git a/Frameworks/TermindApplication/Connections/VaultRemoteChangePlan.swift b/Frameworks/TermindApplication/Connections/VaultRemoteChangePlan.swift index 8d1675e5..4818f996 100644 --- a/Frameworks/TermindApplication/Connections/VaultRemoteChangePlan.swift +++ b/Frameworks/TermindApplication/Connections/VaultRemoteChangePlan.swift @@ -39,7 +39,7 @@ public nonisolated struct VaultRemoteChangePlan: Sendable { } extension Host { - fileprivate nonisolated func hasSameConnectionRoute(as other: Host) -> Bool { + public nonisolated func hasSameConnectionRoute(as other: Host) -> Bool { host == other.host && port == other.port && identityID == other.identityID diff --git a/Frameworks/TermindApplication/Transfers/SFTPEditModel.swift b/Frameworks/TermindApplication/Transfers/SFTPEditModel.swift new file mode 100644 index 00000000..8c0df326 --- /dev/null +++ b/Frameworks/TermindApplication/Transfers/SFTPEditModel.swift @@ -0,0 +1,123 @@ +import Foundation +import Observation +import TermindDomain + +import struct TermindDomain.Host + +/// Metadata is a best-effort concurrency guard, not a server-side compare-and-swap. +public nonisolated struct SFTPFileVersion: Equatable, Sendable { + public let kind: RemoteFile.Kind + public let size: Int64 + public let modifiedAt: Date? + public let permissions: UInt32 + + public init(_ file: RemoteFile) { + kind = file.kind + size = file.size + modifiedAt = file.modifiedAt + permissions = file.permissionMode + } +} + +public nonisolated struct SFTPEditUploadPolicy: Sendable { + public let expected: SFTPFileVersion? + public let permissions: UInt32 + + public init(expected: SFTPFileVersion?, permissions: UInt32) { + self.expected = expected + self.permissions = permissions + } + + public func validate(_ file: RemoteFile?) throws { + guard file.map(SFTPFileVersion.init) == expected, + file == nil || file?.kind == .file + else { throw SFTPEditFailure.remoteChanged } + } +} + +public nonisolated enum SFTPEditFailure: LocalizedError, Sendable { + case remoteChanged, unavailable, localChanged, unsupported + + public var errorDescription: String? { + switch self { + case .remoteChanged: String(localized: "The remote file changed. Review it before uploading again.") + case .unavailable: String(localized: "The original connection is no longer available.") + case .localChanged: String(localized: "The local file changed while preparing the upload. Try again.") + case .unsupported: String(localized: "This file cannot be uploaded back to its original path.") + } + } +} + +public nonisolated struct SFTPEditedFile: Identifiable, Sendable { + public let id: UUID + public let hostID: Host.ID + public let remotePath: String + public let localURL: URL + public var baseline: SFTPFileVersion + public var uploadedDigest: String + public var currentDigest: String + public var isPreparing = false + public var transferID: UUID? + public var error: String? + public var localError: String? + public var isInvalidated = false + public var name: String { localURL.lastPathComponent } + public var isModified: Bool { currentDigest != uploadedDigest } + public var displayedError: String? { error ?? localError } + public var isVisible: Bool { isModified || isPreparing || transferID != nil } +} + +/// Process-local edit records outlive individual browsers and workspaces. +@MainActor +@Observable +public final class SFTPEditModel { + public private(set) var files: [SFTPEditedFile] = [] + public init() {} + + public var hasPendingChanges: Bool { files.contains(where: \.isVisible) } + + public func modifiedFiles(for hostID: Host.ID) -> [SFTPEditedFile] { + files.filter { $0.hostID == hostID && $0.isVisible } + } + + public func file(_ id: UUID) -> SFTPEditedFile? { files.first { $0.id == id } } + + public func file(hostID: Host.ID, path: String) -> SFTPEditedFile? { + files.first { $0.hostID == hostID && $0.remotePath == path } + } + + @discardableResult + public func register(hostID: Host.ID, file: RemoteFile, localURL: URL, digest: String) -> UUID { + if let existing = self.file(hostID: hostID, path: file.path) { return existing.id } + let id = UUID() + files.append( + SFTPEditedFile( + id: id, hostID: hostID, remotePath: file.path, localURL: localURL, + baseline: SFTPFileVersion(file), uploadedDigest: digest, currentDigest: digest)) + return id + } + + public func update(_ id: UUID, _ change: (inout SFTPEditedFile) -> Void) { + guard let index = files.firstIndex(where: { $0.id == id }) else { return } + change(&files[index]) + } + + public func remove(_ id: UUID) { files.removeAll { $0.id == id } } + + public func didUpload(_ id: UUID, digest: String, baseline: SFTPFileVersion) { + update(id) { + $0.uploadedDigest = digest + $0.baseline = baseline + $0.transferID = nil + $0.isPreparing = false + $0.error = nil + } + } + + public func invalidate(hostID: Host.ID) { + for index in files.indices where files[index].hostID == hostID { + files[index].isInvalidated = true + files[index].error = SFTPEditFailure.unavailable.localizedDescription + } + } +} diff --git a/Frameworks/TermindPresentation/SFTP/SFTPEditUploadOverlay.swift b/Frameworks/TermindPresentation/SFTP/SFTPEditUploadOverlay.swift new file mode 100644 index 00000000..2da14df5 --- /dev/null +++ b/Frameworks/TermindPresentation/SFTP/SFTPEditUploadOverlay.swift @@ -0,0 +1,192 @@ +import SwiftUI +import TermindApplication +import TermindDomain + +/// Hosts own placement, connections and file presentation. Only dirty records reach this view. +public struct SFTPEditUploadOverlay: View { + let files: [SFTPEditedFile] + let transfers: [SFTPTransfer] + let connected: Bool + let upload: (UUID, Bool) async -> Bool + let resume: (UUID) -> Void + let open: (URL) -> Void + let discard: (UUID) async -> Void + @State private var conflictID: UUID? + @State private var errorDetails: String? + @ScaledMetric private var rowHeight = 76.0 + + public init( + files: [SFTPEditedFile], transfers: [SFTPTransfer], connected: Bool, + upload: @escaping (UUID, Bool) async -> Bool, + resume: @escaping (UUID) -> Void, open: @escaping (URL) -> Void, + discard: @escaping (UUID) async -> Void + ) { + self.files = files + self.transfers = transfers + self.connected = connected + self.upload = upload + self.resume = resume + self.open = open + self.discard = discard + } + + public var body: some View { + if !files.isEmpty { + rows + .frame(height: rowHeight * Double(min(files.count, 3)) + Double(min(files.count, 3) - 1)) + .frame(maxWidth: 540) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) + .overlay { + RoundedRectangle(cornerRadius: 16).strokeBorder(.primary.opacity(0.08), lineWidth: 0.5) + } + .shadow(color: .black.opacity(0.12), radius: 10, y: 3) + .confirmationDialog( + "The remote file has changed", + isPresented: Binding(get: { conflictID != nil }, set: { if !$0 { conflictID = nil } }), + titleVisibility: .visible + ) { + if let id = conflictID { + Button("Overwrite Remote File", role: .destructive) { send(id, replacing: true) } + } + Button("Cancel", role: .cancel) { conflictID = nil } + } message: { + Text("The remote file changed or could not be verified. Replace it with your saved local changes?") + } + .alert( + "SFTP Error", + isPresented: Binding( + get: { errorDetails != nil }, set: { if !$0 { errorDetails = nil } }) + ) { + Button("OK", role: .cancel) { errorDetails = nil } + } message: { + Text(errorDetails ?? "") + } + } + } + + @ViewBuilder private var rows: some View { + #if os(iOS) + List(files) { file in + row(file).frame(height: rowHeight) + .overlay(alignment: .bottom) { + if file.id != files.last?.id { Divider().padding(.horizontal, 12) } + } + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if canDiscard(file) { + Button("Discard", role: .destructive) { Task { await discard(file.id) } } + } + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .environment(\.defaultMinListRowHeight, rowHeight) + .clipShape(RoundedRectangle(cornerRadius: 16)) + #else + ScrollView { + VStack(spacing: 0) { + ForEach(files) { file in + row(file).frame(height: rowHeight) + if file.id != files.last?.id { Divider() } + } + } + } + #endif + } + + private func canDiscard(_ file: SFTPEditedFile) -> Bool { + guard !file.isPreparing else { return false } + guard let id = file.transferID else { return true } + return transfers.first { $0.id == id }?.state == .paused + } + + private func row(_ file: SFTPEditedFile) -> some View { + let transfer = transfers.first { $0.id == file.transferID } + return HStack(spacing: 10) { + Image(systemName: file.displayedError == nil ? "doc" : "exclamationmark.circle") + .font(.system(size: 20, weight: .regular)) + .foregroundStyle(file.displayedError == nil ? AnyShapeStyle(.secondary) : AnyShapeStyle(.orange)) + .frame(width: 24) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 4) { + Text(file.name).font(.subheadline.weight(.medium)).lineLimit(1).truncationMode(.middle) + Text(file.remotePath).font(.caption).foregroundStyle(.secondary) + .lineLimit(1).truncationMode(.middle) + if let transfer, transfer.state == .running || transfer.state == .queued { + ProgressView( + value: transfer.byteCount > 0 + ? min(1, Double(transfer.transferredBytes) / Double(transfer.byteCount)) : nil + ) + .accessibilityLabel("Uploading changes") + } else if let error = file.displayedError { + Text(error).font(.caption2).foregroundStyle(.red).lineLimit(1).help(error) + } else if !connected { + Text("Reconnect to upload changes").font(.caption2).foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + action(file, transfer: transfer).controlSize(.small) + #if os(macOS) + Menu { + fileActions(file) + } label: { + Image(systemName: "ellipsis").frame(width: 16, height: 28) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .accessibilityLabel("More") + #endif + } + .padding(.horizontal, 12) + .contentShape(Rectangle()) + .contextMenu { fileActions(file) } + } + + @ViewBuilder private func fileActions(_ file: SFTPEditedFile) -> some View { + if let error = file.displayedError { + Button("Error Details", systemImage: "exclamationmark.circle") { errorDetails = error } + Divider() + } + Button("Open Local Copy", systemImage: "arrow.up.right") { open(file.localURL) } + ShareLink(item: file.localURL) { Label("Keep Local Copy", systemImage: "square.and.arrow.up") } + Divider() + Button("Discard", systemImage: "xmark", role: .destructive) { Task { await discard(file.id) } } + .disabled(!canDiscard(file)) + } + + @ViewBuilder + private func action(_ file: SFTPEditedFile, transfer: SFTPTransfer?) -> some View { + if file.isPreparing { + ProgressView().controlSize(.small) + } else if let transfer { + if transfer.state == .paused { + Button("Resume", systemImage: "play.fill") { resume(transfer.id) } + .disabled(!connected || file.isInvalidated) + } else { + ProgressView().controlSize(.small) + } + } else { + Button { + send(file.id, replacing: false) + } label: { + Text(file.displayedError == nil ? "Upload" : "Retry") + .font(.caption.weight(.semibold)) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + } + .accessibilityLabel(file.displayedError == nil ? Text("Upload") : Text("Retry")) + .buttonStyle(.borderedProminent) + .disabled(!connected || file.isInvalidated) + } + } + + private func send(_ id: UUID, replacing: Bool) { + conflictID = nil + Task { + if await upload(id, replacing) { conflictID = id } + } + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SFTPClient.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SFTPClient.swift index 3fa5a95b..f5643309 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SFTPClient.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SFTPClient.swift @@ -161,6 +161,7 @@ nonisolated extension SFTPClientProtocol { cancellation: TransferCancellationToken, resume: SFTPResumeState, progress: @escaping @Sendable (SFTPProgress) -> Void ) async throws { + guard resume.snapshot.editPolicy == nil else { throw SFTPEditFailure.unsupported } guard resume.snapshot.offset == 0 else { throw SFTPResumeFailure.unsupported } try await uploadFile( from: localURL, to: remotePath, overwrite: overwrite, @@ -1031,6 +1032,17 @@ private nonisolated final class SFTPWorker: @unchecked Sendable { guard meter.completed == identity.byteCount, try self.remoteIdentity(handle) == identity else { throw SFTPResumeFailure.sourceChanged } + if resume?.snapshot.capturesFileVersion == true { + var downloaded = LIBSSH2_SFTP_ATTRIBUTES() + guard libssh2_sftp_fstat_ex(handle, &downloaded, 0) == 0 else { + throw self.error(String(localized: "Unable to inspect edited file")) + } + resume?.recordCommittedVersion( + SFTPFileVersion( + Self.remoteFile( + path: remotePath, name: (remotePath as NSString).lastPathComponent, + attributes: downloaded))) + } guard !cancellation.isCancelled else { throw SFTPError.cancelled } try file.synchronize() meter.finish() @@ -1243,6 +1255,9 @@ private nonisolated final class SFTPWorker: @unchecked Sendable { } try verifyRemotePrefix(handle, snapshot: resume.snapshot, cancellation: cancellation) } + if let file = try editTarget(sftp: sftp, path: destination) { + resume.recordCommittedVersion(SFTPFileVersion(file)) + } resume.didCommit() return } @@ -1250,6 +1265,8 @@ private nonisolated final class SFTPWorker: @unchecked Sendable { attributes.permissions & 0o170000 == 0o100000 else { throw SFTPResumeFailure.partialChanged } } + let editPolicy = resume?.snapshot.editPolicy + if let editPolicy { try editPolicy.validate(try editTarget(sftp: sftp, path: destination)) } let flags = resume == nil ? UInt(LIBSSH2_FXF_WRITE | LIBSSH2_FXF_CREAT | LIBSSH2_FXF_TRUNC) @@ -1287,10 +1304,28 @@ private nonisolated final class SFTPWorker: @unchecked Sendable { throw SFTPResumeFailure.partialChanged } } + if let editPolicy { + var mode = LIBSSH2_SFTP_ATTRIBUTES() + mode.flags = UInt(LIBSSH2_SFTP_ATTR_PERMISSIONS) + mode.permissions = UInt(editPolicy.permissions & 0o7777) + guard libssh2_sftp_fstat_ex(handle, &mode, 1) == 0 else { + throw self.error(String(localized: "Unable to preserve remote file permissions")) + } + var committed = LIBSSH2_SFTP_ATTRIBUTES() + guard libssh2_sftp_fstat_ex(handle, &committed, 0) == 0 else { + throw self.error(String(localized: "Unable to inspect edited file")) + } + resume?.recordCommittedVersion( + SFTPFileVersion( + Self.remoteFile( + path: destination, name: (destination as NSString).lastPathComponent, attributes: committed) + )) + } let closed = libssh2_sftp_close_handle(handle) handleOpen = false guard closed == 0 else { throw error("Unable to close \(temporary)") } try validateCommit() + if let editPolicy { try editPolicy.validate(try editTarget(sftp: sftp, path: destination)) } // Do not turn an upload into destructive directory replacement. var attributes = LIBSSH2_SFTP_ATTRIBUTES() let exists = @@ -1315,17 +1350,41 @@ private nonisolated final class SFTPWorker: @unchecked Sendable { } } + private func editTarget(sftp: OpaquePointer, path: String) throws -> RemoteFile? { + var attributes = LIBSSH2_SFTP_ATTRIBUTES() + let result = path.withCString { + libssh2_sftp_stat_ex(sftp, $0, UInt32(path.utf8.count), Int32(LIBSSH2_SFTP_LSTAT), &attributes) + } + if result != 0 { + if libssh2_sftp_last_error(sftp) == LIBSSH2_FX_NO_SUCH_FILE { return nil } + throw error(String(localized: "Unable to inspect remote edit destination")) + } + return Self.remoteFile(path: path, name: (path as NSString).lastPathComponent, attributes: attributes) + } + private func renameOnQueue(sftp: OpaquePointer, from source: String, to destination: String, overwrite: Bool) throws { let flags = overwrite ? Int(LIBSSH2_SFTP_RENAME_OVERWRITE) : 0 - let result = source.withCString { sourceBuffer in - destination.withCString { destinationBuffer in - libssh2_sftp_rename_ex( - sftp, sourceBuffer, UInt32(source.utf8.count), - destinationBuffer, UInt32(destination.utf8.count), flags - ) - } - } + let result = SFTPRenameOperation.perform( + overwrite: overwrite, + posixRename: { + source.withCString { sourceBuffer in + destination.withCString { destinationBuffer in + libssh2_sftp_posix_rename_ex( + sftp, sourceBuffer, source.utf8.count, destinationBuffer, destination.utf8.count) + } + } + }, + standardRename: { + source.withCString { sourceBuffer in + destination.withCString { destinationBuffer in + libssh2_sftp_rename_ex( + sftp, sourceBuffer, UInt32(source.utf8.count), + destinationBuffer, UInt32(destination.utf8.count), flags) + } + } + }, + lastStatus: { libssh2_sftp_last_error(sftp) }) guard result == 0 else { throw error("Unable to finish \(destination)") } } diff --git a/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPEditFileObservation.swift b/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPEditFileObservation.swift new file mode 100644 index 00000000..960c8793 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPEditFileObservation.swift @@ -0,0 +1,54 @@ +import Darwin +import Foundation + +/// Watch both the inode and its directory: many editors save by replacing the inode. +@MainActor +final class SFTPEditFileObservation { + private let url: URL + private let changed: () async -> Void + private var fileSource: (any DispatchSourceFileSystemObject)? + private var directorySource: (any DispatchSourceFileSystemObject)? + private var debounce: Task? + + init(url: URL, changed: @escaping () async -> Void) { + self.url = url + self.changed = changed + directorySource = source(for: url.deletingLastPathComponent()) + reattachFile() + } + + deinit { + fileSource?.cancel() + directorySource?.cancel() + debounce?.cancel() + } + + private func source(for url: URL) -> (any DispatchSourceFileSystemObject)? { + let descriptor = Darwin.open(url.path, O_EVTONLY) + guard descriptor >= 0 else { return nil } + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: descriptor, eventMask: [.write, .extend, .attrib, .rename, .delete, .revoke], + queue: .global(qos: .utility)) + source.setCancelHandler { @Sendable in Darwin.close(descriptor) } + source.setEventHandler { @Sendable [weak self] in + Task { @MainActor [weak self] in self?.scheduleChange() } + } + source.resume() + return source + } + + private func reattachFile() { + fileSource?.cancel() + fileSource = source(for: url) + } + + private func scheduleChange() { + debounce?.cancel() + debounce = Task { [weak self] in + do { try await Task.sleep(for: .milliseconds(250)) } catch { return } + guard let self else { return } + await changed() + reattachFile() + } + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPEditLocalFiles.swift b/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPEditLocalFiles.swift new file mode 100644 index 00000000..525e1f01 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPEditLocalFiles.swift @@ -0,0 +1,71 @@ +import CryptoKit +import Foundation +import TermindApplication + +/// Streams arbitrary regular files; never decodes content or filters filename extensions. +public actor SFTPEditLocalFiles { + public struct Snapshot: Sendable { + public let url: URL + public let digest: String + public let size: Int64 + } + + private struct Signature: Equatable { + let size: UInt64 + let modified: Date? + let inode: UInt64 + } + + private var cache: [URL: (Signature, String)] = [:] + public init() {} + + public func digest(_ url: URL, force: Bool = true) throws -> String { + let before = try signature(url) + if !force, let cached = cache[url], cached.0 == before { return cached.1 } + let file = try FileHandle(forReadingFrom: url) + defer { try? file.close() } + var hash = SHA256() + while let data = try file.read(upToCount: 64 * 1024), !data.isEmpty { + try Task.checkCancellation() + hash.update(data: data) + } + guard try signature(url) == before else { throw SFTPEditFailure.localChanged } + let digest = hash.finalize().map { String(format: "%02x", $0) }.joined() + cache[url] = (before, digest) + return digest + } + + public func snapshot(_ url: URL) throws -> Snapshot { + let original = try digest(url) + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("TermindEditUploads", isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let copy = directory.appendingPathComponent(url.lastPathComponent) + do { + try FileManager.default.copyItem(at: url, to: copy) + let copied = try digest(copy) + guard copied == original, try digest(url) == original else { throw SFTPEditFailure.localChanged } + return Snapshot(url: copy, digest: copied, size: Int64(try signature(copy).size)) + } catch { + try? FileManager.default.removeItem(at: directory) + throw error + } + } + + public func removeSnapshot(_ snapshot: Snapshot) { + cache[snapshot.url] = nil + try? FileManager.default.removeItem(at: snapshot.url.deletingLastPathComponent()) + } + + public func forget(_ url: URL) { cache[url] = nil } + + private func signature(_ url: URL) throws -> Signature { + let values = try FileManager.default.attributesOfItem(atPath: url.path) + guard values[.type] as? FileAttributeType == .typeRegular else { throw SFTPEditFailure.unsupported } + return Signature( + size: (values[.size] as? NSNumber)?.uint64Value ?? 0, + modified: values[.modificationDate] as? Date, + inode: (values[.systemFileNumber] as? NSNumber)?.uint64Value ?? 0) + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPEditService.swift b/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPEditService.swift new file mode 100644 index 00000000..94965e20 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPEditService.swift @@ -0,0 +1,323 @@ +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(_ configuration: SSHConnectionConfiguration) { + func proxy(_ value: SSHTransportProxy?) -> [String] { + guard let value else { return [] } + return [value.kind.rawValue, value.hostname, String(value.port), value.username] + } + hops = + configuration.jumps.map { + Hop( + host: $0.host, port: $0.port, username: $0.username, keys: $0.expectedHostKeys, + proxy: proxy($0.proxy)) + } + [ + Hop( + host: configuration.host, port: configuration.port, username: configuration.username, + keys: configuration.expectedHostKeys, proxy: proxy(configuration.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 SFTPClientProtocol, homeDirectory: String? = nil, + configuration: SSHConnectionConfiguration? = nil + ) async throws -> URL { + validateHosts() + if let existing = model.file(hostID: hostID, path: file.path) { + guard !existing.isInvalidated, + matchesDestination(existing.id, connection: connection, configuration: configuration) + 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, configuration: configuration) + { + 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, configuration: configuration) + 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 SFTPClientProtocol, + homeDirectory: String? = nil, replacing: Bool = false, + configuration: SSHConnectionConfiguration? = 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, configuration: configuration) 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, configuration: SSHConnectionConfiguration? + ) -> Destination? { + if let configuration { return Destination(configuration) } + if case .saved(let saved) = connection { return Destination(saved) } + return nil + } + + private func matchesDestination( + _ id: UUID, connection: SFTPTransferConnection, configuration: SSHConnectionConfiguration? + ) -> Bool { + guard destinations[id] == destination(connection, configuration: configuration) else { + model.update(id) { + $0.isInvalidated = true + $0.error = SFTPEditFailure.unavailable.localizedDescription + } + return false + } + return true + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPRenameOperation.swift b/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPRenameOperation.swift new file mode 100644 index 00000000..59e8bf03 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPRenameOperation.swift @@ -0,0 +1,21 @@ +import CSSH2 + +nonisolated enum SFTPRenameOperation { + /// SFTP v3 ignores RENAME flags. OpenSSH requires its POSIX extension to replace an existing file. + /// Never unlink the original as a fallback: a failed commit must preserve it. + static func perform( + overwrite: Bool, + posixRename: () -> Int32, + standardRename: () -> Int32, + lastStatus: () -> UInt + ) -> Int32 { + guard overwrite else { return standardRename() } + let result = posixRename() + if result == Int32(LIBSSH2_FX_OP_UNSUPPORTED) + || (result == LIBSSH2_ERROR_SFTP_PROTOCOL && lastStatus() == UInt(LIBSSH2_FX_OP_UNSUPPORTED)) + { + return standardRename() + } + return result + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPResumeState.swift b/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPResumeState.swift index a694a177..139bc13b 100644 --- a/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPResumeState.swift +++ b/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPResumeState.swift @@ -1,10 +1,14 @@ import CryptoKit import Foundation +import TermindApplication /// Process-local state only. The digest covers acknowledged destination writes, /// never bytes merely read from the source or queued on a socket. public nonisolated final class SFTPResumeState: @unchecked Sendable { public struct Snapshot: Sendable { + public var capturesFileVersion = false + public var editPolicy: SFTPEditUploadPolicy? + public var committedVersion: SFTPFileVersion? public var temporaryPath: String? public var offset: Int64 = 0 public var digest = SHA256() @@ -17,7 +21,10 @@ public nonisolated final class SFTPResumeState: @unchecked Sendable { private let lock = NSLock() private var value = Snapshot() - public init() {} + public init(editPolicy: SFTPEditUploadPolicy? = nil, capturesFileVersion: Bool = false) { + value.editPolicy = editPolicy + value.capturesFileVersion = capturesFileVersion + } public var snapshot: Snapshot { lock.withLock { value } } func temporaryPath(for destination: String) -> String { @@ -60,6 +67,10 @@ public nonisolated final class SFTPResumeState: @unchecked Sendable { } catch { throw SFTPTemporaryFileCleanupError(path: path, cause: error) } } + func recordCommittedVersion(_ version: SFTPFileVersion) { + lock.withLock { value.committedVersion = version } + } + func willCommit() { lock.withLock { value.committing = true } } func didCommit() { lock.withLock { value.committed = true } } } diff --git a/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferCoordinator.swift b/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferCoordinator.swift index 580adfe3..03e65003 100644 --- a/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferCoordinator.swift +++ b/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferCoordinator.swift @@ -59,6 +59,12 @@ public nonisolated struct SFTPTransferSummary: Equatable, Sendable { public var aggregateProgress: Double? } +@MainActor +public final class SFTPEditTransferReceipt { + public internal(set) var version: SFTPFileVersion? + public init() {} +} + @Observable public final class SFTPTransferCoordinator { public typealias ClientFactory = @Sendable () -> any SFTPClientProtocol @@ -72,6 +78,7 @@ public final class SFTPTransferCoordinator { var cancellation: TransferCancellationToken var completion: ((SFTPTransfer.State) -> Void)? var resume = SFTPResumeState() + var editReceipt: SFTPEditTransferReceipt? var pauseRequested = false var cancelRequested = false var generation = UUID() @@ -209,14 +216,17 @@ public final class SFTPTransferCoordinator { } } + @discardableResult public func enqueue( direction: SFTPTransfer.Direction, batchID: UUID, localURL: URL, remotePath: String, byteCount: Int64, hostID: Host.ID, connection: SFTPTransferConnection, scopedRoot: URL, overwrite: Bool, homeDirectory: String? = nil, + editPolicy: SFTPEditUploadPolicy? = nil, + editReceipt: SFTPEditTransferReceipt? = nil, completion: ((SFTPTransfer.State) -> Void)? = nil - ) { + ) -> SFTPTransfer.ID { let sftpTransfer = SFTPTransfer( batchID: batchID, hostID: hostID, sourceHostID: direction == .download ? hostID : nil, @@ -232,9 +242,12 @@ public final class SFTPTransferCoordinator { connection: connection, localURL: localURL, remotePath: remotePath, overwrite: overwrite, scopedRoot: scopedRoot, cancellation: TransferCancellationToken(), - completion: completion + completion: completion, + resume: SFTPResumeState(editPolicy: editPolicy, capturesFileVersion: editReceipt != nil), + editReceipt: editReceipt ) schedule() + return sftpTransfer.id } public func enqueue( @@ -511,6 +524,7 @@ public final class SFTPTransferCoordinator { throw SFTPError.operation("Invalid copy transfer runtime.") } result = .completed + runtime.editReceipt?.version = runtime.resume.snapshot.committedVersion } catch { let current = runtimes[sftpTransfer.id] if current?.cancelRequested == true { diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index 6d3bedb4..d463b954 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -184,7 +184,7 @@ Transfers own dedicated connections and outlive browser closure. Upload/download checkpoints, file references, acknowledged offsets and incremental digests live only in memory. Pause and connection loss retain the destination partial file and release the connection; explicit resume verifies the source and partial prefix before seeking. -No source staging, bookmark persistence, or restart recovery is added. Cancellation +Ordinary transfers add no source staging, bookmark persistence, or restart recovery. Cancellation and host deletion remove partial files, reporting a path if cleanup fails. Mobile background expiration pauses uploads/downloads and cancels remote copies; borrowed session retirement still cancels its transfers. macOS termination gives asynchronous @@ -197,6 +197,37 @@ Remote-copy enqueueing reports directory mutations so desktop refreshes also cov copies without file jobs. App sessions retain pending-batch presentation, cancellation, error handling, and completion refresh; interactive browser deletion stays app-owned. +SFTP external edits have one process-local Application model per app, independent of +browser/workspace lifetime. Remote Infrastructure owns managed local copies, streamed +SHA-256 checks, file/directory observation (including atomic-save replacement), and +immutable upload snapshots. Snapshots are an edit-specific exception to ordinary +transfers' no-staging rule; paused uploads retain them and terminal completion removes +them. Editing never automatically uploads. Only changed records appear in the floating +bottom overlay; clean records remain internal so repeated opens reuse their local copy. +Discard stops tracking and preserves the local copy for any external editor; the next +remote open downloads a fresh copy. iOS exposes Discard through native trailing swipe +actions, and both platforms expose it in the row menu. Running/preparing uploads cannot +be discarded; paused uploads are cancelled and cleaned up before their record is removed. +Hosts own activation, background, termination and native preview/open adapters. +macOS checks edits before quitting; iOS cannot promise a quit warning or recovery after +process termination. Background expiration still pauses uploads, with explicit resume. + +Edit upload policy validates the original destination's kind, size, modification time +and mode both before writing and immediately before remote rename, preserving its +permission bits on the temporary file. Overwriting commits use the OpenSSH POSIX rename +extension; standard rename is used only when the extension is unsupported. Ordinary +non-overwriting renames never use that extension, and failures never unlink the original +as a replacement workaround. This is a best-effort metadata guard, not atomic +compare-and-swap against unrelated remote writers. Missing metadata or changed/missing +files require explicit confirmation; nonregular destinations fail closed. Successful +commit acknowledges only the snapshot digest, so later saves remain dirty. Host removal +or route changes invalidate old records without deleting their editable local copies. +There are no extension allowlists: SHA-256 compares all regular-file contents. The iOS +Quick Look delegate enables native editing where supported, receives in-place updates +and takes ownership of temporary edited copies. Different-format copies are exported +separately instead of overwriting the original remote format. Shared Presentation owns +the dirty-file card; AppKit/UIKit and their lifecycle adapters stay in their apps. + `sftp_copy` is Termind-owned and acting-only in Assistant, with Ask/sequential policy. It copies regular files and directory trees into an existing directory; unsupported descendants fail before writes. Its awaited operation shares browser diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index 96c9c170..b5016802 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -43,6 +43,7 @@ final class MobileAppModel { let knownHosts: KnownHostsLibraryModel let forwardingLibrary: ForwardingLibraryModel let forwardings: PortForwardingController + let sftpEdits: SFTPEditService let transfers: SFTPTransferCoordinator private(set) var workspaces: [MobileWorkspace] = [] var error: String? @@ -177,6 +178,8 @@ final class MobileAppModel { forwardingLibrary = ForwardingLibraryModel(library: library) forwardings = PortForwardingController(hosts: library, defaults: dependencies.forwardingDefaults) transfers = SFTPTransferCoordinator(store: library, clientFactory: { SFTPClient() }) + sftpEdits = SFTPEditService(transfers: transfers, host: { [weak library] in library?.host($0) }) + library.willDeleteHost = { [weak sftpEdits] hostID, _ in sftpEdits?.invalidate(hostID: hostID) } library.willDeletePortForwardings = { [weak forwardings] ids in forwardings?.suspend(ids) } library.didReplacePortForwardings = { [weak forwardings] ids in forwardings?.resumeEnabled(ids) } let visibleHostIDs = Set(try self.hosts.snapshot().value.hosts.map(\.id)) @@ -192,6 +195,7 @@ final class MobileAppModel { }, cancelTransfers: { [weak self] payload in guard let self else { return } + sftpEdits.invalidate(hostID: payload.hostID) transfers.cancelAll(for: payload.hostID) }, stopForwardings: { [weak forwardings] payload in @@ -276,6 +280,7 @@ final class MobileAppModel { do { assistant.disconnectHost(id) for workspace in workspaces.filter({ $0.descriptor.hostID == id }) { close(workspace.id) } + sftpEdits.invalidate(hostID: id) transfers.cancelAll(for: id) forwardings.remove(library.portForwardings(for: id).map(\.id)) metrics.remove(id) @@ -299,6 +304,7 @@ final class MobileAppModel { scenePhases[id] = phase let isBackground = scenePhases.values.allSatisfy { $0 == .background } if isBackground && !wasBackground { + sftpEdits.setMonitoring(false) memory.suspend() vaultSync?.pause() metricsSync?.pause() @@ -315,6 +321,8 @@ final class MobileAppModel { } liveActivities.update(workspaceActivitySessions) } else if phase == .active { + sftpEdits.setMonitoring(true) + Task { await sftpEdits.recheck() } Task { [weak self] in await self?.pro.refresh() } background.finish() for workspace in workspaces { workspace.resumeAfterBackground() } @@ -358,6 +366,7 @@ final class MobileAppModel { ) for change in plan.hosts { if change.routeChanged { + sftpEdits.invalidate(hostID: change.id) for workspace in workspaces.filter({ $0.descriptor.hostID == change.id }) { if change.isRemoved { close(workspace.id) } else { workspace.disconnect() } } @@ -415,7 +424,7 @@ final class MobileAppModel { return MobileWorkspace( descriptor: MobileWorkspaceDescriptor( hostID: host.id, title: title, kind: kind, duplicateIndex: duplicateIndex), - hosts: library, transfers: transfers, terminalSettings: terminalSettings, + hosts: library, transfers: transfers, sftpEdits: sftpEdits, terminalSettings: terminalSettings, commandHistory: commandHistory, sftpHistory: sftpHistory, connectionServices: workspaceConnectionServices, initialCommand: initialCommand, diff --git a/TFI/Features/SFTP/MobileSFTPEditPreview.swift b/TFI/Features/SFTP/MobileSFTPEditPreview.swift new file mode 100644 index 00000000..91398d9f --- /dev/null +++ b/TFI/Features/SFTP/MobileSFTPEditPreview.swift @@ -0,0 +1,83 @@ +import QuickLook +import SwiftUI +import UniformTypeIdentifiers + +struct MobileSFTPEditPreview: UIViewControllerRepresentable { + let url: URL + let allowsEditing: Bool + let changed: () -> Void + let export: (URL) -> Void + let failed: (String) -> Void + let dismiss: () -> Void + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + func makeUIViewController(context: Context) -> UINavigationController { + let preview = QLPreviewController() + preview.dataSource = context.coordinator + preview.delegate = context.coordinator + preview.navigationItem.leftBarButtonItem = UIBarButtonItem( + systemItem: .done, primaryAction: UIAction { _ in dismiss() }) + return UINavigationController(rootViewController: preview) + } + + func updateUIViewController(_ controller: UINavigationController, context: Context) { + context.coordinator.parent = self + } + + /// No editable-type allowlist: Quick Look decides whether to offer editing tools. + /// A new format must not silently replace a remote file with a different representation. + static func canReplaceOriginal(_ original: URL, with edited: URL) -> Bool { + let originalType = UTType(filenameExtension: original.pathExtension) + let editedType = UTType(filenameExtension: edited.pathExtension) + if let originalType, let editedType { return originalType == editedType } + return original.pathExtension.lowercased() == edited.pathExtension.lowercased() + } + + final class Coordinator: NSObject, QLPreviewControllerDataSource, QLPreviewControllerDelegate { + var parent: MobileSFTPEditPreview + init(_ parent: MobileSFTPEditPreview) { self.parent = parent } + + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 } + + func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> any QLPreviewItem { + parent.url as NSURL + } + + func previewController( + _ controller: QLPreviewController, editingModeFor previewItem: any QLPreviewItem + ) -> QLPreviewItemEditingMode { + parent.allowsEditing ? .updateContents : .disabled + } + + func previewController(_ controller: QLPreviewController, didUpdateContentsOf previewItem: any QLPreviewItem) { + parent.changed() + } + + func previewController( + _ controller: QLPreviewController, didSaveEditedCopyOf previewItem: any QLPreviewItem, + at modifiedContentsURL: URL + ) { + do { + // The callback's URL is temporary; take ownership before returning to Quick Look. + let directory = parent.url.deletingLastPathComponent() + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let copy = directory.appendingPathComponent(modifiedContentsURL.lastPathComponent) + try FileManager.default.copyItem(at: modifiedContentsURL, to: copy) + if MobileSFTPEditPreview.canReplaceOriginal(parent.url, with: copy) { + _ = try FileManager.default.replaceItemAt(parent.url, withItemAt: copy) + try? FileManager.default.removeItem(at: directory) + parent.changed() + } else { + parent.export(copy) + } + } catch { parent.failed(error.localizedDescription) } + } + + func previewControllerDidDismiss(_ controller: QLPreviewController) { + parent.changed() + parent.dismiss() + } + } +} diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index 2b6076b2..81100489 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -80,6 +80,7 @@ final class MobileWorkspace: Identifiable { } private var submissions: [UUID: SSHAuthenticationSubmission] = [:] let sftpSession: MobileSFTPSessionModel + let sftpEdits: SFTPEditService? let transfers: SFTPTransferCoordinator? private var transferConfiguration: SSHConnectionConfiguration? private(set) var uploadNotice: WorkspaceUploadNotice? @@ -197,6 +198,7 @@ final class MobileWorkspace: Identifiable { init( descriptor: MobileWorkspaceDescriptor, hosts: any RemoteSessionHostStore, transfers: SFTPTransferCoordinator? = nil, + sftpEdits: SFTPEditService? = nil, terminalSettings: TerminalSettingsModel = TerminalSettingsModel(), commandHistory: CommandHistoryModel = CommandHistoryModel(), sftpHistory: SFTPHistoryModel = SFTPHistoryModel(), @@ -211,6 +213,7 @@ final class MobileWorkspace: Identifiable { self.sftpHistory = sftpHistory sftpSession = MobileSFTPSessionModel(hostID: descriptor.hostID, history: sftpHistory) self.transfers = transfers + self.sftpEdits = sftpEdits self.descriptor = descriptor self.hosts = hosts activitySession = WorkspaceActivityAttributes.Session( @@ -1306,6 +1309,17 @@ final class MobileWorkspace: Identifiable { guard let transfers, let configuration = transferConfiguration, connected, !file.isDirectory else { return false } + if let sftpEdits, let sftp { + Task { + do { + let url = try await sftpEdits.open( + file, hostID: descriptor.hostID, connection: .saved(configuration), client: sftp) + sftpHistory.record(kind: .file, path: file.path, for: descriptor.hostID) + completion(url) + } catch { operationError = error.localizedDescription } + } + return true + } do { guard RemoteFileName.isValid(file.name) else { throw CocoaError(.fileWriteInvalidFileName) } let directory = FileManager.default.temporaryDirectory.appending( @@ -1330,6 +1344,14 @@ final class MobileWorkspace: Identifiable { } } + func uploadEdit(_ id: UUID, replacing: Bool) async -> Bool { + guard connected, let sftpEdits, let sftp, let configuration = transferConfiguration else { return false } + return await sftpEdits.upload(id, connection: .saved(configuration), client: sftp, replacing: replacing) { + [weak self] in + Task { await self?.sftpSession.reload() } + } + } + func reopenHistoryFile(at path: String, completion: @escaping (URL) -> Void = { _ in }) async -> Bool { guard let sftp, connected else { operationError = String(localized: "The SFTP connection is not available.") diff --git a/TFI/Features/Workspaces/MobileWorkspaceView.swift b/TFI/Features/Workspaces/MobileWorkspaceView.swift index 8f34a47a..ede09f56 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceView.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceView.swift @@ -42,6 +42,13 @@ struct MobileWorkspaceView: View { @Environment(\.scenePhase) private var scenePhase @State private var downloadTarget: RemoteFile? @State private var previewURL: URL? + @State private var exportedEditURL: URL? + @State private var showsExportedEdit = false + @ScaledMetric private var editRowHeight = 76.0 + + private var editedFiles: [SFTPEditedFile] { + workspace.sftpEdits?.model.modifiedFiles(for: workspace.descriptor.hostID) ?? [] + } var body: some View { workspaceAlerts @@ -213,7 +220,37 @@ struct MobileWorkspaceView: View { } }.mobileSheetPresentation(compact: true) } - .quickLookPreview($previewURL) + .sheet( + isPresented: Binding(get: { previewURL != nil }, set: { if !$0 { previewURL = nil } }), + onDismiss: { + Task { await workspace.sftpEdits?.recheck() } + showsExportedEdit = exportedEditURL != nil + } + ) { + if let previewURL { + MobileSFTPEditPreview( + url: previewURL, + allowsEditing: workspace.sftpEdits?.model.files.contains { $0.localURL == previewURL } == true, + changed: { Task { await workspace.sftpEdits?.recheck() } }, + export: { exportedEditURL = $0 }, + failed: { workspace.operationError = $0 }, + dismiss: { self.previewURL = nil } + ) + } + } + .sheet(isPresented: $showsExportedEdit) { + if let exportedEditURL { + VStack(spacing: 20) { + Text("The edited copy uses a different format").font(.headline) + Text("Save this copy separately to keep the original remote file format.") + ShareLink(item: exportedEditURL) { + Label("Keep Local Copy", systemImage: "square.and.arrow.up") + } + } + .padding() + .mobileSheetPresentation(compact: true) + } + } .fileImporter( isPresented: $importing, allowedContentTypes: [.data, .folder], @@ -341,15 +378,36 @@ struct MobileWorkspaceView: View { editPermissions: { editingPermissions = $0 }, delete: { deleting = $0 } ) + .safeAreaInset(edge: .bottom, spacing: 0) { + if !editedFiles.isEmpty { + Color.clear.frame(height: (editRowHeight + 1) * Double(min(3, editedFiles.count)) + 24) + } + } + .overlay(alignment: .bottom) { + SFTPEditUploadOverlay( + files: editedFiles, transfers: workspace.transfers?.sftpTransfers ?? [], + connected: workspace.connected, + upload: { await workspace.uploadEdit($0, replacing: $1) }, + resume: { workspace.transfers?.resume($0) }, + open: presentPreview, + discard: { await workspace.sftpEdits?.discard($0) } + ) + .padding(12) + } } } + private func presentPreview(_ url: URL) { + exportedEditURL = nil + previewURL = url + } + private func open(_ file: RemoteFile) { if file.isDirectory { Task { await workspace.sftpSession.openDirectory(file.path) } } else { _ = workspace.preview(file) { url in - Task { @MainActor in previewURL = url } + Task { @MainActor in presentPreview(url) } } } } @@ -361,7 +419,7 @@ struct MobileWorkspaceView: View { case .file: Task { _ = await workspace.reopenHistoryFile(at: entry.path) { url in - Task { @MainActor in previewURL = url } + Task { @MainActor in presentPreview(url) } } } } diff --git a/TFITests/MobileSFTPEditPreviewTests.swift b/TFITests/MobileSFTPEditPreviewTests.swift new file mode 100644 index 00000000..fa248c5e --- /dev/null +++ b/TFITests/MobileSFTPEditPreviewTests.swift @@ -0,0 +1,144 @@ +import Foundation +import QuickLook +import SwiftUI +import TermindApplication +import TermindDomain +import TermindPresentation +import Testing +import UIKit + +@testable import TermindIOS + +@Suite(.serialized) @MainActor +struct MobileSFTPEditPreviewTests { + @Test func previewEnablesSystemEditingAndReceivesInPlaceChanges() { + let url = URL(filePath: "/tmp/document.pdf") + var changes = 0 + let preview = MobileSFTPEditPreview( + url: url, allowsEditing: true, changed: { changes += 1 }, export: { _ in }, failed: { _ in }, dismiss: {}) + let delegate = preview.makeCoordinator() + let controller = QLPreviewController() + #expect(delegate.previewController(controller, editingModeFor: url as NSURL) == .updateContents) + delegate.previewController(controller, didUpdateContentsOf: url as NSURL) + #expect(changes == 1) + delegate.parent = MobileSFTPEditPreview( + url: url, allowsEditing: false, changed: {}, export: { _ in }, failed: { _ in }, dismiss: {}) + #expect(delegate.previewController(controller, editingModeFor: url as NSURL) == .disabled) + } + + @Test func editedCopiesAreOwnedBeforeCallbackReturnsAndDifferentFormatsAreExported() throws { + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let original = directory.appending(path: "original.pdf") + let sameType = directory.appending(path: "edited.pdf") + let differentType = directory.appending(path: "edited.png") + try Data([1]).write(to: original) + try Data([2]).write(to: sameType) + try Data([3]).write(to: differentType) + var changes = 0 + var exported: URL? + let delegate = MobileSFTPEditPreview( + url: original, allowsEditing: true, changed: { changes += 1 }, + export: { exported = $0 }, failed: { Issue.record("\($0)") }, dismiss: {} + ).makeCoordinator() + let controller = QLPreviewController() + delegate.previewController(controller, didSaveEditedCopyOf: original as NSURL, at: sameType) + try FileManager.default.removeItem(at: sameType) + #expect(try Data(contentsOf: original) == Data([2])) + #expect(changes == 1) + delegate.previewController(controller, didSaveEditedCopyOf: original as NSURL, at: differentType) + try FileManager.default.removeItem(at: differentType) + #expect(try Data(contentsOf: original) == Data([2])) + #expect(try Data(contentsOf: #require(exported)) == Data([3])) + #expect(changes == 1) + } + + @Test func nativeImagePreviewOffersTheSystemEditingSurface() async throws { + let directory = FileManager.default.temporaryDirectory.appending(path: "sftp-edit-screenshots") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appending(path: "preview-fixture.png") + let fixture = UIGraphicsImageRenderer(size: CGSize(width: 400, height: 300)).image { context in + UIColor.systemTeal.setFill() + context.fill(CGRect(x: 0, y: 0, width: 400, height: 300)) + UIColor.white.setFill() + context.fill(CGRect(x: 60, y: 60, width: 280, height: 180)) + } + try #require(fixture.pngData()).write(to: url) + #expect(QLPreviewController.canPreview(url as NSURL)) + let preview = MobileSFTPEditPreview( + url: url, allowsEditing: true, changed: {}, export: { _ in }, + failed: { Issue.record("\($0)") }, dismiss: {}) + let controller = UIHostingController(rootView: preview.ignoresSafeArea()) + let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) + let window = UIWindow(windowScene: scene) + window.windowLevel = .alert + 1 + window.overrideUserInterfaceStyle = .light + window.rootViewController = controller + window.makeKeyAndVisible() + defer { + window.isHidden = true + window.rootViewController = nil + } + controller.view.frame = window.bounds + controller.view.layoutIfNeeded() + try await Task.sleep(for: .seconds(2)) + let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in + _ = window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + } + let snapshot = directory.appending(path: "ios-native-editable-preview.png") + try #require(image.pngData()).write(to: snapshot) + print("SFTP native preview snapshot: \(snapshot.path)") + } + + @Test func floatingChangesFitPhoneAndTabletWidthsInBothLanguages() async throws { + let model = SFTPEditModel() + let hostID = UUID() + for index in 0..<5 { + let file = RemoteFile( + path: "/srv/config/production-\(index).json", name: "production-\(index).json", kind: .file, + size: 100, permissions: 0o644, ownerUID: 0, ownerGID: 0) + let id = model.register( + hostID: hostID, file: file, localURL: URL(filePath: "/tmp/\(file.name)"), digest: "one") + model.update(id) { $0.currentDigest = "two" } + } + for width in [320.0, 768.0] { + for locale in ["en", "zh-Hans", "zh-Hant"] { + let controller = UIHostingController( + rootView: + SFTPEditUploadOverlay( + files: model.modifiedFiles(for: hostID), transfers: [], connected: true, + upload: { _, _ in false }, resume: { _ in }, open: { _ in }, discard: { _ in } + ) + .environment(\.locale, Locale(identifier: locale)) + .padding(12).frame(width: width).background(Color(uiColor: .systemGroupedBackground))) + controller.safeAreaRegions = [] + let size = controller.sizeThatFits(in: CGSize(width: width, height: 2_000)) + #expect(size.height >= 252 && size.height < 285) + #expect(size.width <= width) + let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) + let window = UIWindow(windowScene: scene) + window.frame = CGRect(origin: .zero, size: size) + window.windowLevel = .alert + 1 + window.overrideUserInterfaceStyle = .light + window.rootViewController = controller + window.makeKeyAndVisible() + defer { + window.isHidden = true + window.rootViewController = nil + } + controller.view.frame = CGRect(origin: .zero, size: size) + controller.view.layoutIfNeeded() + try await Task.sleep(for: .milliseconds(700)) + let image = UIGraphicsImageRenderer(bounds: controller.view.bounds).image { _ in + _ = controller.view.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true) + } + let directory = FileManager.default.temporaryDirectory.appending(path: "sftp-edit-screenshots") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appending(path: "ios-\(Int(width))-\(locale).png") + try #require(image.pngData()).write(to: url) + print("SFTP edit snapshot: \(url.path)") + } + } + } +} diff --git a/TFM/App/AppModel.swift b/TFM/App/AppModel.swift index cd41cbca..0b6cc7aa 100644 --- a/TFM/App/AppModel.swift +++ b/TFM/App/AppModel.swift @@ -84,6 +84,7 @@ final class AppModel { let metricsSync: MetricsSyncCoordinator? let vaultSync: VaultSyncCoordinator? let forwarding: PortForwardingController + let sftpEdits: SFTPEditService let sftpTransfers: SFTPTransferCoordinator /// Providers, models, role assignments, MCP servers. /// @@ -184,6 +185,7 @@ final class AppModel { store: hosts, clientFactory: { connectionCoordinator.makeSFTPClient() } ) + sftpEdits = SFTPEditService(transfers: sftpTransfers, host: { [weak hosts] in hosts?.host($0) }) self.ai = ai self.skills = skills self.memory = memory @@ -211,7 +213,8 @@ final class AppModel { } ) hosts.willDeleteHost = { - [weak forwarding, weak serverMetrics, weak mcpServer, agentConnections] hostID, ruleIDs in + [weak forwarding, weak serverMetrics, weak mcpServer, agentConnections, weak sftpEdits] hostID, ruleIDs in + sftpEdits?.invalidate(hostID: hostID) mcpServer?.tools.revokeHost(hostID) serverMetrics?.remove(hostID) forwarding?.suspend(ruleIDs) @@ -244,6 +247,7 @@ final class AppModel { self?.closeWorkspaces(for: deletion.hostID) }, cancelTransfers: { [weak self] deletion in + self?.sftpEdits.invalidate(hostID: deletion.hostID) self?.sftpTransfers.cancelAll(for: deletion.hostID) self?.mcpServer.tools.revokeHost(deletion.hostID) if let self { Task { await self.mcpServer.tools.cancelHostTransfers(deletion.hostID) } } @@ -305,6 +309,7 @@ final class AppModel { } if change.isRemoved { closeWorkspaces(for: change.id) + sftpEdits.invalidate(hostID: change.id) sftpTransfers.cancelAll(for: change.id) mcpServer.tools.revokeHost(change.id) commandHistory.clear(for: change.id) @@ -597,6 +602,7 @@ final class AppModel { @discardableResult func shutdownForTermination() -> Task { memory.suspend() + sftpEdits.setMonitoring(false) if let terminationTask { return terminationTask } serverMetrics.shutdown() speechTranscription.cancelAll() diff --git a/TFM/App/TermindApp.swift b/TFM/App/TermindApp.swift index 2e31d9c1..76df5615 100644 --- a/TFM/App/TermindApp.swift +++ b/TFM/App/TermindApp.swift @@ -318,6 +318,29 @@ struct TermindApp: App { guard let app else { return .terminateNow } if awaitingTermination { return .terminateLater } awaitingTermination = true + Task { @MainActor [weak self] in + await app.sftpEdits.recheck() + if app.sftpEdits.model.hasPendingChanges { + let alert = NSAlert() + alert.messageText = String(localized: "Quit with unuploaded changes?") + alert.informativeText = String( + localized: + "Your edited files have not all been uploaded. Pending edits will not be restored next time." + ) + alert.addButton(withTitle: String(localized: "Cancel")) + alert.addButton(withTitle: String(localized: "Quit Anyway")) + guard alert.runModal() == .alertSecondButtonReturn else { + self?.awaitingTermination = false + NSApp.reply(toApplicationShouldTerminate: false) + return + } + } + self?.beginTermination(app) + } + return .terminateLater + } + + private func beginTermination(_ app: AppModel) { let cleanup = app.shutdownForTermination() Task { @MainActor [weak self] in await cleanup.value @@ -327,7 +350,6 @@ struct TermindApp: App { try? await Task.sleep(for: .seconds(3)) self?.finishTermination() } - return .terminateLater } private func finishTermination() { diff --git a/TFM/Features/SFTP/SFTPBrowserView.swift b/TFM/Features/SFTP/SFTPBrowserView.swift index 0c90c17d..b5433756 100644 --- a/TFM/Features/SFTP/SFTPBrowserView.swift +++ b/TFM/Features/SFTP/SFTPBrowserView.swift @@ -1,5 +1,6 @@ import ListViewKit import SwiftUI +import TermindApplication import TermindDomain import TermindPresentation import TermindRemoteInfrastructure @@ -201,6 +202,10 @@ struct SFTPBrowserView: View { @FocusState private var listFocused: Bool @FocusState private var pathFocused: Bool + @ScaledMetric private var editRowHeight = 76.0 + private var editedFiles: [SFTPEditedFile] { app.sftpEdits.model.modifiedFiles(for: sftpSession.hostID) } + private var editOverlayHeight: CGFloat { (editRowHeight + 1) * Double(min(3, editedFiles.count)) } + private var deleteConfirmationMessage: String { let paths = deleteTargets.prefix(6).map { sftpSession.displayPath($0.path) } .joined(separator: "\n") @@ -265,6 +270,11 @@ struct SFTPBrowserView: View { .background(TermindColor.windowSurface) browserContent(layout: layout) + .safeAreaInset(edge: .bottom, spacing: 0) { + if !editedFiles.isEmpty { + Color.clear.frame(height: editOverlayHeight + Metrics.StatusBar.overlayClearance) + } + } } if sftpSession.connectionJourney.isPresented { @@ -295,6 +305,23 @@ struct SFTPBrowserView: View { .zIndex(10) } } + .overlay(alignment: .bottom) { + SFTPEditUploadOverlay( + files: editedFiles, transfers: app.sftpTransfers.sftpTransfers, + connected: sftpSession.status == .connected, + upload: { id, replacing in + await sftpSession.uploadEdit(id, using: app.sftpEdits, replacing: replacing) + }, + resume: { app.sftpTransfers.resume($0) }, + open: { NSWorkspace.shared.open($0) }, + discard: { await app.sftpEdits.discard($0) } + ) + .padding(.horizontal, 12) + .padding(.bottom, Metrics.StatusBar.overlayClearance) + } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + Task { await app.sftpEdits.recheck() } + } .contextMenu { browserLocationMenu } .background(TermindColor.windowSurface) .onAppear { @@ -990,7 +1017,7 @@ struct SFTPBrowserView: View { if file.isDirectory { sftpSession.navigate(to: file.path) } else { - sftpSession.open(file, coordinator: app.sftpTransfers) { localURL in + sftpSession.open(file, coordinator: app.sftpTransfers, edits: app.sftpEdits) { localURL in if NSWorkspace.shared.open(localURL) { sftpSession.recordOpenedFile(at: file.path) } diff --git a/TFM/Features/SFTP/SFTPSessionModel.swift b/TFM/Features/SFTP/SFTPSessionModel.swift index 140c4cc2..e7621303 100644 --- a/TFM/Features/SFTP/SFTPSessionModel.swift +++ b/TFM/Features/SFTP/SFTPSessionModel.swift @@ -841,10 +841,21 @@ final class SFTPSessionModel: Identifiable { func open( _ file: RemoteFile, coordinator: SFTPTransferCoordinator, - completion: @escaping (URL) -> Void + edits: SFTPEditService? = nil, completion: @escaping (URL) -> Void ) { bindTransfers(to: coordinator) guard !file.isDirectory, let connection = transferConnection else { return } + if let edits, let client { + Task { + do { + let url = try await edits.open( + file, hostID: hostID, connection: connection, client: client, + homeDirectory: homeResolution.resolvedDirectory, configuration: configuration) + completion(url) + } catch { operationError = error.localizedDescription } + } + return + } let directory = FileManager.default.temporaryDirectory .appendingPathComponent("Termind", isDirectory: true) .appendingPathComponent("Open", isDirectory: true) @@ -865,13 +876,21 @@ final class SFTPSessionModel: Identifiable { } catch { operationError = error.localizedDescription } } + func uploadEdit(_ id: UUID, using edits: SFTPEditService, replacing: Bool) async -> Bool { + guard status == .connected, let connection = transferConnection, let client else { return false } + return await edits.upload( + id, connection: connection, client: client, + homeDirectory: homeResolution.resolvedDirectory, replacing: replacing, configuration: configuration + ) { [weak self] in self?.scheduleSilentRefresh() } + } + func recordOpenedFile(at path: String) { historyStore?.record(kind: .file, path: path, for: hostID) } func reopenHistoryFile( at path: String, coordinator: SFTPTransferCoordinator, - completion: @escaping (URL) -> Void + edits: SFTPEditService? = nil, completion: @escaping (URL) -> Void ) { guard let client, status == .connected else { operationError = String(localized: "The SFTP connection is not available.") @@ -883,7 +902,7 @@ final class SFTPSessionModel: Identifiable { operationError = String(localized: "The remote file is no longer available.") return } - open(file, coordinator: coordinator, completion: completion) + open(file, coordinator: coordinator, edits: edits, completion: completion) } catch { operationError = error.localizedDescription } diff --git a/TFM/Features/Sidebar/WindowSidebarView.swift b/TFM/Features/Sidebar/WindowSidebarView.swift index 93f100ac..eeaba3f9 100644 --- a/TFM/Features/Sidebar/WindowSidebarView.swift +++ b/TFM/Features/Sidebar/WindowSidebarView.swift @@ -1105,7 +1105,7 @@ private struct SFTPBrowseHistorySidebar: View { case .directory: session.navigate(to: entry.path) case .file: - session.reopenHistoryFile(at: entry.path, coordinator: app.sftpTransfers) { + session.reopenHistoryFile(at: entry.path, coordinator: app.sftpTransfers, edits: app.sftpEdits) { if NSWorkspace.shared.open($0) { session.recordOpenedFile(at: entry.path) } diff --git a/TFMTests/SFTPEditRenderingTests.swift b/TFMTests/SFTPEditRenderingTests.swift new file mode 100644 index 00000000..04df0fab --- /dev/null +++ b/TFMTests/SFTPEditRenderingTests.swift @@ -0,0 +1,55 @@ +import AppKit +import Foundation +import SwiftUI +import TermindApplication +import TermindDomain +import TermindPresentation +import Testing + +@Suite(.serialized) @MainActor +struct SFTPEditRenderingTests { + @Test func floatingChangesFitNarrowPanesAndOnlyShowThreeRows() throws { + let model = SFTPEditModel() + let hostID = UUID() + for index in 0..<5 { + let file = RemoteFile( + path: "/srv/config/production-\(index).json", name: "production-\(index).json", kind: .file, + size: 100, permissions: 0o644, ownerUID: 0, ownerGID: 0) + let id = model.register( + hostID: hostID, file: file, localURL: URL(filePath: "/tmp/\(file.name)"), digest: "one") + model.update(id) { $0.currentDigest = "two" } + } + for width in [260.0, 560.0] { + for locale in ["en", "zh-Hans", "zh-Hant"] { + let view = NSHostingView( + rootView: + SFTPEditUploadOverlay( + files: model.modifiedFiles(for: hostID), transfers: [], connected: true, + upload: { _, _ in false }, resume: { _ in }, open: { _ in }, discard: { _ in } + ) + .environment(\.locale, Locale(identifier: locale)) + .padding(12).frame(width: width).background(Color(nsColor: .windowBackgroundColor))) + let size = view.fittingSize + #expect(size.height >= 252 && size.height < 285) + #expect(size.width <= width) + view.frame = CGRect(origin: .zero, size: size) + let window = NSWindow( + contentRect: view.frame, styleMask: [.borderless], backing: .buffered, defer: false) + window.contentView = view + window.orderFront(nil) + defer { + window.orderOut(nil) + window.contentView = nil + } + view.layoutSubtreeIfNeeded() + let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) + view.cacheDisplay(in: view.bounds, to: bitmap) + let directory = FileManager.default.temporaryDirectory.appending(path: "sftp-edit-screenshots") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appending(path: "mac-\(Int(width))-\(locale).png") + try #require(bitmap.representation(using: .png, properties: [:])).write(to: url) + print("SFTP edit snapshot: \(url.path)") + } + } + } +} diff --git a/TFMTests/SFTPEditTests.swift b/TFMTests/SFTPEditTests.swift new file mode 100644 index 00000000..641d6e06 --- /dev/null +++ b/TFMTests/SFTPEditTests.swift @@ -0,0 +1,353 @@ +import CryptoKit +import Foundation +import TermindApplication +import TermindDomain +import Testing + +@testable import TermindRemoteInfrastructure + +@Suite @MainActor +struct SFTPEditTests { + @Test(arguments: ["config", "file.txt", "image.png", "archive.zip", "data.sqlite", "document.pdf"]) + func arbitraryBinaryContentsAndAtomicSavesAreDetected(_ name: String) async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent(name) + let original = Data([0, 255, 13, 10, 127]) + try original.write(to: url) + let files = SFTPEditLocalFiles() + let before = try await files.digest(url) + let edited = Data([255, 0, 13, 10, 127]) + try edited.write(to: url, options: .atomic) + let after = try await files.digest(url, force: false) + #expect(before != after) + let snapshot = try await files.snapshot(url) + try original.write(to: url, options: .atomic) + #expect(try Data(contentsOf: snapshot.url) == edited) + #expect(try await files.digest(url) == before) + await files.removeSnapshot(snapshot) + #expect(!FileManager.default.fileExists(atPath: snapshot.url.path)) + } + + @Test func duplicateOpenReusesCopyAndSuccessfulUploadHidesTheCard() async throws { + let fixture = Fixture() + let url = try await fixture.open() + defer { fixture.clean(url) } + let original = await fixture.client.file + #expect(try await fixture.open() == url) + #expect(await fixture.client.downloads == 1) + #expect(fixture.service.model.modifiedFiles(for: fixture.hostID).isEmpty) + try Data([4, 3, 2, 1]).write(to: url, options: .atomic) + await fixture.service.recheck() + let id = try #require(fixture.service.model.files.first?.id) + #expect(fixture.service.model.modifiedFiles(for: fixture.hostID).count == 1) + #expect(await fixture.upload(id) == false) + try await wait { fixture.service.model.file(id)?.transferID == nil } + #expect(await fixture.client.data == Data([4, 3, 2, 1])) + #expect(await fixture.client.lastPolicy?.permissions == original.permissionMode) + #expect(fixture.service.model.modifiedFiles(for: fixture.hostID).isEmpty) + } + + @Test func downloadReceiptAllowsTheBrowserConnectionToCloseBeforeEditingStarts() async throws { + let fixture = Fixture() + await fixture.client.retireBrowserOnDownload() + let url = try await fixture.open() + defer { fixture.clean(url) } + #expect(fixture.service.model.files.count == 1) + #expect(try Data(contentsOf: url) == Data([1, 2, 3, 4])) + } + + @Test func conflictRequiresConfirmationAndConfirmationExpiresOnAnotherRemoteChange() async throws { + let fixture = Fixture() + let url = try await fixture.open() + defer { fixture.clean(url) } + try Data([9]).write(to: url) + let id = try #require(fixture.service.model.files.first?.id) + await fixture.client.remoteChange() + #expect(await fixture.upload(id)) + #expect(await fixture.client.uploads == 0) + await fixture.client.remoteChange() + #expect(await fixture.upload(id, replacing: true)) + #expect(await fixture.client.uploads == 0) + #expect(await fixture.upload(id, replacing: true) == false) + try await wait { fixture.service.model.file(id)?.transferID == nil } + #expect(await fixture.client.data == Data([9])) + } + + @Test func savesDuringUploadRemainPendingAndUseAnImmutableSnapshot() async throws { + let fixture = Fixture() + let url = try await fixture.open() + defer { fixture.clean(url) } + let id = try #require(fixture.service.model.files.first?.id) + await fixture.client.hold() + try Data([2]).write(to: url) + _ = await fixture.upload(id) + try await wait { await fixture.client.uploads == 1 } + try Data([3]).write(to: url, options: .atomic) + await fixture.service.recheck() + await fixture.client.release() + try await wait { fixture.service.model.file(id)?.transferID == nil } + #expect(await fixture.client.data == Data([2])) + #expect(fixture.service.model.file(id)?.isModified == true) + } + + @Test func connectionLossPausesUntilExplicitResumeAndRetainsTheSnapshot() async throws { + let fixture = Fixture() + let url = try await fixture.open() + defer { fixture.clean(url) } + try Data([6]).write(to: url) + let id = try #require(fixture.service.model.files.first?.id) + await fixture.client.loseNextUpload() + _ = await fixture.upload(id) + let transfer = try #require(fixture.service.model.file(id)?.transferID) + try await wait { fixture.transfers.sftpTransfers.first { $0.id == transfer }?.state == .paused } + let snapshot = try #require(fixture.transfers.sftpTransfers.first { $0.id == transfer }?.localPath) + #expect(FileManager.default.fileExists(atPath: snapshot)) + fixture.transfers.resume(transfer) + try await wait { fixture.service.model.file(id)?.transferID == nil } + #expect(await fixture.client.data == Data([6])) + #expect(!FileManager.default.fileExists(atPath: snapshot)) + } + + @Test func hostInvalidationRetainsLocalChangesAndPreventsUpload() async throws { + let fixture = Fixture() + let url = try await fixture.open() + defer { fixture.clean(url) } + try Data([8]).write(to: url) + await fixture.service.recheck() + let id = try #require(fixture.service.model.files.first?.id) + fixture.service.invalidate(hostID: fixture.hostID) + #expect(await fixture.upload(id) == false) + #expect(await fixture.client.uploads == 0) + #expect(fixture.service.model.modifiedFiles(for: fixture.hostID).count == 1) + #expect(try Data(contentsOf: url) == Data([8])) + } + + @Test func fileObservationDetectsRepeatedAtomicSavesWithoutForegroundRefresh() async throws { + let fixture = Fixture() + let url = try await fixture.open() + defer { + fixture.service.setMonitoring(false) + fixture.clean(url) + } + fixture.service.setMonitoring(true) + let id = try #require(fixture.service.model.files.first?.id) + let original = try Data(contentsOf: url) + try Data([7, 7, 7, 7]).write(to: url, options: .atomic) + try await wait { fixture.service.model.file(id)?.isModified == true } + try original.write(to: url, options: .atomic) + try await wait { fixture.service.model.file(id)?.isModified == false } + } + + @Test func routeChangesAreRejectedEvenWhenTheHostIdentifierStaysTheSame() async throws { + let fixture = Fixture() + var currentHost = TermindDomain.Host(id: fixture.hostID, name: "Test", host: "first.invalid") + let service = SFTPEditService(transfers: fixture.transfers, host: { _ in currentHost }) + service.setMonitoring(false) + let url = try await service.open( + fixture.client.file, hostID: fixture.hostID, connection: fixture.connection, client: fixture.client) + defer { fixture.clean(url) } + try Data([9]).write(to: url) + currentHost.host = "second.invalid" + await service.recheck() + let id = try #require(service.model.files.first?.id) + #expect(service.model.file(id)?.isInvalidated == true) + #expect(await service.upload(id, connection: fixture.connection, client: fixture.client) == false) + #expect(await fixture.client.uploads == 0) + } + + @Test func reconnectingWithADifferentAccountCannotUploadAnOldCopy() async throws { + let fixture = Fixture() + var configuration = SSHConnectionConfiguration( + hostID: fixture.hostID, host: "test.invalid", port: 22, username: "first", credentials: [], + expectedHostKeys: []) + let url = try await fixture.service.open( + fixture.client.file, hostID: fixture.hostID, connection: fixture.connection, + client: fixture.client, configuration: configuration) + defer { fixture.clean(url) } + try Data([9]).write(to: url) + let id = try #require(fixture.service.model.files.first?.id) + configuration.username = "second" + #expect( + await fixture.service.upload( + id, connection: fixture.connection, client: fixture.client, configuration: configuration) == false) + #expect(fixture.service.model.file(id)?.isInvalidated == true) + #expect(await fixture.client.uploads == 0) + } + + @Test func failedAndCancelledUploadsKeepChangesAndReleaseTheirSnapshots() async throws { + let fixture = Fixture() + let url = try await fixture.open() + defer { fixture.clean(url) } + try Data([5]).write(to: url) + let id = try #require(fixture.service.model.files.first?.id) + await fixture.client.failNextUpload() + _ = await fixture.upload(id) + try await wait { fixture.service.model.file(id)?.transferID == nil } + #expect(fixture.service.model.file(id)?.isModified == true) + #expect(fixture.service.model.file(id)?.error != nil) + await fixture.client.hold() + _ = await fixture.upload(id) + let transferID = try #require(fixture.service.model.file(id)?.transferID) + let transfer = try #require(fixture.transfers.sftpTransfers.first { $0.id == transferID }) + fixture.transfers.cancel(transferID) + try await wait { fixture.service.model.file(id)?.transferID == nil } + #expect(!FileManager.default.fileExists(atPath: transfer.localPath)) + #expect(fixture.service.model.file(id)?.isModified == true) + await fixture.client.release() + _ = await fixture.upload(id) + try await wait { fixture.service.model.file(id)?.transferID == nil } + #expect(fixture.service.model.file(id)?.isModified == false) + } + + @Test func discardingStopsTrackingWithoutDeletingTheCopyOrChangingTheRemote() async throws { + let fixture = Fixture() + let url = try await fixture.open() + defer { fixture.clean(url) } + try Data([7]).write(to: url) + await fixture.service.recheck() + let id = try #require(fixture.service.model.files.first?.id) + await fixture.service.discard(id) + #expect(fixture.service.model.files.isEmpty) + #expect(try Data(contentsOf: url) == Data([7])) + #expect(await fixture.client.uploads == 0) + let reopened = try await fixture.open() + defer { fixture.clean(reopened) } + #expect(reopened != url) + #expect(try Data(contentsOf: reopened) == Data([1, 2, 3, 4])) + } + + @Test func discardingAPausedUploadCleansItsSnapshotButCannotDiscardAnActiveUpload() async throws { + let fixture = Fixture() + let url = try await fixture.open() + defer { fixture.clean(url) } + try Data([8]).write(to: url) + let id = try #require(fixture.service.model.files.first?.id) + await fixture.client.hold() + _ = await fixture.upload(id) + let transferID = try #require(fixture.service.model.file(id)?.transferID) + try await wait { await fixture.client.uploads == 1 } + await fixture.service.discard(id) + #expect(fixture.service.model.file(id) != nil) + fixture.transfers.pause(transferID) + try await wait { fixture.transfers.sftpTransfers.first { $0.id == transferID }?.state == .paused } + let snapshot = try #require(fixture.transfers.sftpTransfers.first { $0.id == transferID }?.localPath) + await fixture.service.discard(id) + #expect(fixture.service.model.files.isEmpty) + #expect(!FileManager.default.fileExists(atPath: snapshot)) + #expect(try Data(contentsOf: url) == Data([8])) + #expect(await fixture.client.data == Data([1, 2, 3, 4])) + } + + private func wait(_ condition: () async -> Bool) async throws { + for _ in 0..<500 { + if await condition() { return } + try await Task.sleep(for: .milliseconds(10)) + } + Issue.record("Edit transfer did not reach its expected state") + throw SFTPError.notConnected + } + + private final class Fixture { + let hostID = UUID() + let client = EditClient() + let transfers: SFTPTransferCoordinator + let service: SFTPEditService + var connection: SFTPTransferConnection { .borrowed(id: hostID, client: client) } + init() { + transfers = SFTPTransferCoordinator(store: HostLibraryModel(), clientFactory: { EditClient() }) + service = SFTPEditService(transfers: transfers) + service.setMonitoring(false) + } + func open() async throws -> URL { + try await service.open(client.file, hostID: hostID, connection: connection, client: client) + } + func upload(_ id: UUID, replacing: Bool = false) async -> Bool { + await service.upload(id, connection: connection, client: client, replacing: replacing) + } + func clean(_ url: URL) { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + } +} + +private actor EditClient: SFTPClientProtocol { + var data = Data([1, 2, 3, 4]) + var file = RemoteFile( + path: "/srv/data", name: "data", kind: .file, size: 4, + modifiedAt: Date(timeIntervalSince1970: 1), permissions: 0o754, ownerUID: 0, ownerGID: 0) + var downloads = 0 + var uploads = 0 + var lastPolicy: SFTPEditUploadPolicy? + private var held = false + private var losesUpload = false + private var failsUpload = false + private var retiresBrowser = false + private var browserRetired = false + func hold() { held = true } + func release() { held = false } + func loseNextUpload() { losesUpload = true } + func failNextUpload() { failsUpload = true } + func retireBrowserOnDownload() { retiresBrowser = true } + func remoteChange() { file.modifiedAt = file.modifiedAt?.addingTimeInterval(1) } + func uploadFile( + from localURL: URL, to remotePath: String, overwrite: Bool, + cancellation: TransferCancellationToken, resume: SFTPResumeState, + progress: @escaping @Sendable (SFTPProgress) -> Void + ) async throws { + uploads += 1 + lastPolicy = resume.snapshot.editPolicy + if failsUpload { + failsUpload = false + throw SFTPError.operation("Permission denied") + } + if losesUpload { + losesUpload = false + throw SFTPError.connectionLost("test disconnect") + } + try lastPolicy?.validate(file) + while held { + if cancellation.isCancelled { throw SFTPError.cancelled } + try await Task.sleep(for: .milliseconds(5)) + } + try lastPolicy?.validate(file) + data = try Data(contentsOf: localURL) + file.size = Int64(data.count) + remoteChange() + resume.acknowledge(data) + resume.recordCommittedVersion(SFTPFileVersion(file)) + } + func downloadFile( + from remotePath: String, to localURL: URL, overwrite: Bool, + cancellation: TransferCancellationToken, resume: SFTPResumeState, + progress: @escaping @Sendable (SFTPProgress) -> Void + ) async throws { + downloads += 1 + try data.write(to: localURL) + resume.recordCommittedVersion(SFTPFileVersion(file)) + if retiresBrowser { browserRetired = true } + } + func downloadFile( + from remotePath: String, to localURL: URL, overwrite: Bool, + cancellation: TransferCancellationToken, progress: @escaping @Sendable (SFTPProgress) -> Void + ) async throws { + downloads += 1 + try data.write(to: localURL) + } + func uploadFile( + from localURL: URL, to remotePath: String, overwrite: Bool, + cancellation: TransferCancellationToken, progress: @escaping @Sendable (SFTPProgress) -> Void + ) async throws { throw SFTPResumeFailure.unsupported } + func connect(using configuration: SSHConnectionConfiguration, progress: SSHRouteProgressHandler?) async throws {} + func disconnect() async {} + func homeDirectory() async throws -> String { "/" } + func listDirectory(at path: String) async throws -> [RemoteFile] { [file] } + func attributes(at path: String) async throws -> RemoteFile? { + if browserRetired { throw SFTPError.notConnected } + return path == file.path ? file : nil + } + func setPermissions(at path: String, to permissions: UInt32) async throws {} + func createDirectory(at path: String) async throws {} + func rename(from source: String, to destination: String, overwrite: Bool) async throws {} + func removeFile(at path: String) async throws {} + func removeDirectory(at path: String) async throws {} +} diff --git a/TFMTests/SFTPRenameTests.swift b/TFMTests/SFTPRenameTests.swift new file mode 100644 index 00000000..b0f38069 --- /dev/null +++ b/TFMTests/SFTPRenameTests.swift @@ -0,0 +1,72 @@ +import CSSH2 +import Testing + +@testable import TermindRemoteInfrastructure + +@Suite struct SFTPRenameTests { + @Test func overwriteUsesPOSIXRenameWithoutFirstFailingStandardRename() { + var calls: [String] = [] + let result = SFTPRenameOperation.perform( + overwrite: true, + posixRename: { + calls.append("posix") + return 0 + }, + standardRename: { + calls.append("standard") + return LIBSSH2_ERROR_SFTP_PROTOCOL + }, + lastStatus: { UInt(LIBSSH2_FX_FAILURE) }) + #expect(result == 0) + #expect(calls == ["posix"]) + } + + @Test func nonOverwritingRenameNeverUsesAnOverwritingExtension() { + var calls: [String] = [] + let result = SFTPRenameOperation.perform( + overwrite: false, + posixRename: { + calls.append("posix") + return 0 + }, + standardRename: { + calls.append("standard") + return LIBSSH2_ERROR_SFTP_PROTOCOL + }, + lastStatus: { UInt(LIBSSH2_FX_FAILURE) }) + #expect(result == LIBSSH2_ERROR_SFTP_PROTOCOL) + #expect(calls == ["standard"]) + } + + @Test(arguments: [Int32(LIBSSH2_FX_OP_UNSUPPORTED), LIBSSH2_ERROR_SFTP_PROTOCOL]) + func unsupportedExtensionFallsBackToStandardRename(_ result: Int32) { + var calls: [String] = [] + #expect( + SFTPRenameOperation.perform( + overwrite: true, + posixRename: { + calls.append("posix") + return result + }, + standardRename: { + calls.append("standard") + return 0 + }, + lastStatus: { UInt(LIBSSH2_FX_OP_UNSUPPORTED) }) == 0) + #expect(calls == ["posix", "standard"]) + } + + @Test(arguments: [LIBSSH2_ERROR_SFTP_PROTOCOL, LIBSSH2_ERROR_SOCKET_RECV, LIBSSH2_ERROR_EAGAIN]) + func realFailuresDoNotFallBackOrRemoveTheOriginal(_ failure: Int32) { + var usedFallback = false + let result = SFTPRenameOperation.perform( + overwrite: true, posixRename: { failure }, + standardRename: { + usedFallback = true + return 0 + }, + lastStatus: { UInt(LIBSSH2_FX_FAILURE) }) + #expect(result == failure) + #expect(!usedFallback) + } +}