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