SFTP Support Edit
This commit is contained in:
1 parent
1b399021a1
commit
ed4d3616fd
26 files changed
+1885
-24
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user