SFTP Support Edit

This commit is contained in:
Kaede Akino committed 2026-09-17 13:50:06 +08:00
1 parent 1b399021a1
commit ed4d3616fd
26 files changed
+1885 -24

No files matched your search

+72
View File
@@ -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)
}
}
@@ -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"}}}},
@@ -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
@@ -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
}
}
}
@@ -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 }
}
}
}
@@ -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)") }
}
@@ -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<Void, Never>?
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()
}
}
}
@@ -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)
}
}
@@ -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<SSHHostKeyIdentity>
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<URL, any Error>] = [:]
private var generations: [Host.ID: Int] = [:]
private var confirmations: [UUID: Confirmation] = [:]
private var observations: [UUID: SFTPEditFileObservation] = [:]
private var monitoring: Task<Void, Never>?
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<Void, any Error>) 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
}
}
@@ -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
}
}
@@ -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 } }
}
@@ -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 {
+32 -1
View File
@@ -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
+10 -1
View File
@@ -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,
@@ -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()
}
}
}
@@ -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.")
@@ -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) }
}
}
}
+144
View File
@@ -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)")
}
}
}
}
+7 -1
View File
@@ -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<Void, Never> {
memory.suspend()
sftpEdits.setMonitoring(false)
if let terminationTask { return terminationTask }
serverMetrics.shutdown()
speechTranscription.cancelAll()
+23 -1
View File
@@ -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() {
+28 -1
View File
@@ -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)
}
+22 -3
View File
@@ -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
}
+1 -1
View File
@@ -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)
}
+55
View File
@@ -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)")
}
}
}
}
+353
View File
@@ -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 {}
}
+72
View File
@@ -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)
}
}