refactor: remove obsolete application paths and adapters
Remove the transfer forwarding adapter, borrowed SFTP channel scheduling, legacy assistant send entry points, unreachable Telnet password state, unused Tailscale APIs, and unused host deletion impact projections. Migrate behavioral tests to current paths and cover SFTP passphrase saving. Validation: Swift formatting and shared-copy/localization checks passed. The macOS test run reported failures in ConnectionStatusIconLayoutTests and AgentRuntimeTests; investigation and iOS validation remain pending.
This commit is contained in:
1 parent
7c81fc0b50
commit
ad2bf28790
29 files changed
+146
-422
No files matched your search
@@ -586,7 +586,10 @@ struct TailscaleIntegrationTests {
|
||||
try await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
#expect(await node.selectedExitNode == "exit-one")
|
||||
#expect(model.exitNodeCandidates(groupID: group.id).map(\.stableID) == ["exit-one"])
|
||||
#expect(
|
||||
TailscaleGroupEditor(model: model, group: library.group(group.id)).candidates.map(\.stableID) == [
|
||||
"exit-one"
|
||||
])
|
||||
#expect(!library.hosts.contains { $0.tailscale?.stableNodeID == "exit-one" })
|
||||
let retained = try model.connection(for: group.id)
|
||||
group.tailscale?.exitNodeID = "exit-two"
|
||||
@@ -599,7 +602,9 @@ struct TailscaleIntegrationTests {
|
||||
try await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
#expect(await node.selectedExitNode == "exit-two")
|
||||
try model.saveExitNode("", groupID: group.id)
|
||||
let editor = TailscaleGroupEditor(model: model, group: library.group(group.id))
|
||||
editor.exitNodeID = ""
|
||||
_ = try await editor.save()
|
||||
for _ in 0..<400 {
|
||||
if await node.selectedExitNode == nil { break }
|
||||
try await Task.sleep(for: .milliseconds(10))
|
||||
|
||||
@@ -34305,23 +34305,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Waiting for a Telnet password" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "正在等待Telnet密码"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "正在等待Telnet密碼"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Waiting for an Identity" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
|
||||
@@ -287,16 +287,6 @@ public final class AssistantLibraryModel {
|
||||
return draftConversation
|
||||
}
|
||||
|
||||
/// Promotes the New Chat scratch model on its first send, then prepares a
|
||||
/// fresh scratch model for the next visit to New Chat.
|
||||
@discardableResult
|
||||
public func startDraftConversation() -> ConversationModel? {
|
||||
let conversation = draftConversation
|
||||
guard let started = startConversation(conversation) else { return nil }
|
||||
draftConversation = newConversation()
|
||||
return started
|
||||
}
|
||||
|
||||
public func canPromote(_ conversation: ConversationModel) -> Bool {
|
||||
!conversations.contains(where: { $0.id == conversation.id })
|
||||
}
|
||||
@@ -326,19 +316,6 @@ public final class AssistantLibraryModel {
|
||||
return conversation
|
||||
}
|
||||
|
||||
/// Promotes the sidebar's scratch conversation into history on its first
|
||||
/// real send. Empty drafts remain ephemeral and never create blank rows.
|
||||
@discardableResult
|
||||
public func startConversation(_ conversation: ConversationModel) -> ConversationModel? {
|
||||
guard conversation.canSendDraft else { return nil }
|
||||
guard !conversations.contains(where: { $0.id == conversation.id }) else { return nil }
|
||||
|
||||
conversation.sendDraft()
|
||||
store.insert(conversation)
|
||||
selectedConversationID = conversation.id
|
||||
return conversation
|
||||
}
|
||||
|
||||
public func select(_ id: ConversationModel.ID) {
|
||||
guard conversations.contains(where: { $0.id == id }) else { return }
|
||||
selectedConversationID = id
|
||||
|
||||
@@ -1974,8 +1974,8 @@ public final class ConversationModel: Identifiable {
|
||||
composer.pendingAttachments.removeAll { $0.id == id }
|
||||
}
|
||||
|
||||
/// Records a local user turn. Agent-backed composers use AppModel's runtime
|
||||
/// path; the sidebar promotion flow uses this before starting that run.
|
||||
/// Records a local user turn. Agent-backed composers capture and submit
|
||||
/// their turns through `AssistantExecutionController`.
|
||||
public func sendDraft() {
|
||||
let text = composer.draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard canSendDraft else { return }
|
||||
|
||||
@@ -61,7 +61,6 @@ public final class ConnectionJourney {
|
||||
public enum LocalRequirement: Equatable {
|
||||
case identity
|
||||
case tailscaleUsername
|
||||
case telnetPassword
|
||||
case passphrase
|
||||
case proxyAuthentication
|
||||
}
|
||||
@@ -98,7 +97,6 @@ public final class ConnectionJourney {
|
||||
public var waitsForLocalIdentity: Bool { localRequirement == .identity }
|
||||
public var waitsForLocalPassphrase: Bool { localRequirement == .passphrase }
|
||||
public var waitsForProxyAuthentication: Bool { localRequirement == .proxyAuthentication }
|
||||
public var waitsForTelnetPassword: Bool { localRequirement == .telnetPassword }
|
||||
|
||||
private static var localDeviceName: String {
|
||||
#if os(macOS)
|
||||
@@ -354,20 +352,6 @@ public final class ConnectionJourney {
|
||||
)
|
||||
}
|
||||
|
||||
public func waitForTelnetPassword(at hostID: Host.ID, failure: String? = nil) {
|
||||
guard let index = endpoints.firstIndex(where: { $0.id == .host(hostID) }) else { return }
|
||||
activeEndpointIndex = 0
|
||||
completedEndpointIndex = 0
|
||||
checkpoint = nil
|
||||
localRequirement = .telnetPassword
|
||||
localRequirementEndpointIndex = 0
|
||||
phase = .waiting
|
||||
log(
|
||||
endpoint: endpoints[index].name,
|
||||
message: failure ?? String(localized: "Waiting for a Telnet password")
|
||||
)
|
||||
}
|
||||
|
||||
public func waitForHostKey(at hostID: Host.ID, fingerprint: String) {
|
||||
wait(
|
||||
at: hostID, kind: .hostKey,
|
||||
|
||||
@@ -149,22 +149,6 @@ public final class TailscaleModel: TailscaleConnectionProviding {
|
||||
|
||||
public func exitNodeID(groupID: UUID) -> String { library.group(groupID)?.tailscale?.exitNodeID ?? "" }
|
||||
|
||||
public func exitNodeCandidates(groupID: UUID) -> [TailscalePeer] {
|
||||
(peers[groupID] ?? []).filter { $0.exitNodeOption == true }
|
||||
.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
|
||||
public func saveExitNode(_ stableID: String, groupID: UUID) throws {
|
||||
guard hasAccess() else { throw TailscaleConfigurationError.requiresPro }
|
||||
guard var group = library.group(groupID), group.tailscale != nil else {
|
||||
throw TailscaleConfigurationError.unavailable
|
||||
}
|
||||
guard exitNodeID(groupID: groupID) != stableID else { return }
|
||||
group.tailscale?.exitNodeID = stableID.isEmpty ? nil : stableID
|
||||
library.upsert(group)
|
||||
if let error = library.storageError { throw TermindSSHError.library(error) }
|
||||
}
|
||||
|
||||
public func saveHostname(_ value: String, groupID: UUID) async throws {
|
||||
guard hasAccess() else { throw TailscaleConfigurationError.requiresPro }
|
||||
guard var state = states[groupID] else { throw TailscaleConfigurationError.unavailable }
|
||||
|
||||
@@ -55,7 +55,6 @@ public protocol HostRepository: AnyObject {
|
||||
func changes() -> AsyncStream<RepositoryChange<HostLibrarySnapshot>>
|
||||
func refresh() throws
|
||||
func upsertHost(_ host: Host) throws
|
||||
func deletionImpact(for hostID: Host.ID) throws -> HostDeletionImpact
|
||||
func requestHostDeletion(_ hostID: Host.ID) throws
|
||||
func clearReferencesForDeletedHost(_ hostID: Host.ID) throws
|
||||
func finalizeHostDeletion(_ hostID: Host.ID) throws
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
import Foundation
|
||||
|
||||
public nonisolated struct HostDeletionImpact: Equatable, Sendable {
|
||||
public var jumpHostReferences: Int
|
||||
public var snippetReferences: Int
|
||||
public var forwardingRules: Int
|
||||
public init(jumpHostReferences: Int, snippetReferences: Int, forwardingRules: Int) {
|
||||
self.jumpHostReferences = jumpHostReferences
|
||||
self.snippetReferences = snippetReferences
|
||||
self.forwardingRules = forwardingRules
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public nonisolated struct HostDeletionPayload: Codable, Hashable, Sendable {
|
||||
public var hostID: Host.ID
|
||||
public var forwardingRuleIDs: [PortForwardingRule.ID]
|
||||
|
||||
@@ -50,13 +50,6 @@ public final class InMemoryHostRepository: HostRepository {
|
||||
try publish(.mutation)
|
||||
}
|
||||
|
||||
public func deletionImpact(for hostID: Host.ID) throws -> HostDeletionImpact {
|
||||
HostDeletionImpact(
|
||||
jumpHostReferences: value.hosts.filter { $0.jumpHostID == hostID }.count,
|
||||
snippetReferences: 0, forwardingRules: 0
|
||||
)
|
||||
}
|
||||
|
||||
public func requestHostDeletion(_ hostID: Host.ID) throws {
|
||||
value.hosts.removeAll { $0.id == hostID }
|
||||
try publish(.mutation)
|
||||
|
||||
@@ -63,17 +63,6 @@ public final class SwiftDataHostRepository: HostRepository {
|
||||
try commitMutation()
|
||||
}
|
||||
|
||||
public func deletionImpact(for hostID: Host.ID) throws -> HostDeletionImpact {
|
||||
let hosts = try context.fetch(FetchDescriptor<PersistentHost>())
|
||||
let snippets = try context.fetch(FetchDescriptor<PersistentSnippet>())
|
||||
let rules = try forwardingRules(for: hostID)
|
||||
return HostDeletionImpact(
|
||||
jumpHostReferences: hosts.filter { $0.jumpHostID == hostID }.count,
|
||||
snippetReferences: snippets.filter { $0.targetHostIDs.contains(hostID) }.count,
|
||||
forwardingRules: rules.count
|
||||
)
|
||||
}
|
||||
|
||||
/// The deletion marker, all durable side effects, and the revision become
|
||||
/// visible in one database commit. A crash can therefore see either the
|
||||
/// live host or a complete deletion saga, never half of each.
|
||||
|
||||
@@ -219,7 +219,6 @@ private struct JourneyEndpointView: View {
|
||||
if let localRequirement {
|
||||
switch localRequirement {
|
||||
case .identity, .tailscaleUsername: return "person.fill"
|
||||
case .telnetPassword: return "key.fill"
|
||||
case .passphrase: return "key.fill"
|
||||
case .proxyAuthentication: return "lock.shield.fill"
|
||||
}
|
||||
|
||||
@@ -38,15 +38,9 @@ public actor SFTPConcurrencyScheduler {
|
||||
var hostIDs: Set<UUID>
|
||||
var source: SFTPWorkSource
|
||||
var byteCount: Int64?
|
||||
var exclusiveResourceIDs: Set<UUID>
|
||||
var continuation: CheckedContinuation<SFTPConcurrencyPermit, any Error>
|
||||
}
|
||||
|
||||
private struct ActiveRequest {
|
||||
var hostIDs: Set<UUID>
|
||||
var exclusiveResourceIDs: Set<UUID>
|
||||
}
|
||||
|
||||
private var preferences: SFTPPreferences
|
||||
private var requests: [UUID: Request] = [:]
|
||||
private var queues: [UUID: [SFTPWorkSource: [UUID]]] = [:]
|
||||
@@ -54,9 +48,8 @@ public actor SFTPConcurrencyScheduler {
|
||||
private var sourceOrder: [UUID: [SFTPWorkSource]] = [:]
|
||||
private var lastGrantedHost: UUID?
|
||||
private var lastGrantedSource: [UUID: SFTPWorkSource] = [:]
|
||||
private var active: [UUID: ActiveRequest] = [:]
|
||||
private var active: [UUID: Set<UUID>] = [:]
|
||||
private var activePerHost: [UUID: Int] = [:]
|
||||
private var activeExclusiveResources: Set<UUID> = []
|
||||
|
||||
public init(preferences: SFTPPreferences = SFTPPreferences()) {
|
||||
self.preferences = preferences.normalized
|
||||
@@ -71,8 +64,7 @@ public actor SFTPConcurrencyScheduler {
|
||||
anchorHostID: UUID,
|
||||
hostIDs: Set<UUID>,
|
||||
source: SFTPWorkSource,
|
||||
byteCount: Int64?,
|
||||
exclusiveResourceIDs: Set<UUID> = []
|
||||
byteCount: Int64?
|
||||
) async throws -> SFTPConcurrencyPermit {
|
||||
try Task.checkCancellation()
|
||||
let id = UUID()
|
||||
@@ -85,7 +77,6 @@ public actor SFTPConcurrencyScheduler {
|
||||
hostIDs: normalizedHosts,
|
||||
source: source,
|
||||
byteCount: byteCount,
|
||||
exclusiveResourceIDs: exclusiveResourceIDs,
|
||||
continuation: continuation
|
||||
)
|
||||
requests[id] = request
|
||||
@@ -121,12 +112,11 @@ public actor SFTPConcurrencyScheduler {
|
||||
}
|
||||
|
||||
private func release(_ id: UUID) {
|
||||
guard let request = active.removeValue(forKey: id) else { return }
|
||||
for hostID in request.hostIDs {
|
||||
guard let hostIDs = active.removeValue(forKey: id) else { return }
|
||||
for hostID in hostIDs {
|
||||
let remaining = max(0, activePerHost[hostID, default: 1] - 1)
|
||||
activePerHost[hostID] = remaining == 0 ? nil : remaining
|
||||
}
|
||||
activeExclusiveResources.subtract(request.exclusiveResourceIDs)
|
||||
drain()
|
||||
}
|
||||
|
||||
@@ -135,11 +125,8 @@ public actor SFTPConcurrencyScheduler {
|
||||
requests[request.id] = nil
|
||||
removeQueuedID(request.id, request: request)
|
||||
let permitID = UUID()
|
||||
active[permitID] = ActiveRequest(
|
||||
hostIDs: request.hostIDs,
|
||||
exclusiveResourceIDs: request.exclusiveResourceIDs)
|
||||
active[permitID] = request.hostIDs
|
||||
for hostID in request.hostIDs { activePerHost[hostID, default: 0] += 1 }
|
||||
activeExclusiveResources.formUnion(request.exclusiveResourceIDs)
|
||||
lastGrantedHost = request.anchorHostID
|
||||
lastGrantedSource[request.anchorHostID] = request.source
|
||||
let mode = schedulingMode(byteCount: request.byteCount)
|
||||
@@ -159,7 +146,6 @@ public actor SFTPConcurrencyScheduler {
|
||||
guard let id = hostQueues[source]?.first, let request = requests[id] else { continue }
|
||||
guard request.hostIDs.allSatisfy({ activePerHost[$0, default: 0] < preferences.perHostLimit })
|
||||
else { continue }
|
||||
guard request.exclusiveResourceIDs.isDisjoint(with: activeExclusiveResources) else { continue }
|
||||
return request
|
||||
}
|
||||
}
|
||||
@@ -202,7 +188,7 @@ public actor SFTPConcurrencyScheduler {
|
||||
queues[hostID] = hostQueues
|
||||
}
|
||||
}
|
||||
let activeHosts = Set(active.values.flatMap(\.hostIDs))
|
||||
let activeHosts = Set(active.values.flatMap { $0 })
|
||||
for hostID in Array(lastGrantedSource.keys)
|
||||
where queues[hostID] == nil && !activeHosts.contains(hostID) {
|
||||
lastGrantedSource[hostID] = nil
|
||||
|
||||
@@ -12,15 +12,10 @@ public nonisolated struct SFTPRemoteItemsPayload: Codable, Hashable, Sendable {
|
||||
public nonisolated enum SFTPTransferConnection: Sendable {
|
||||
case saved(SSHAdditionalConnectionSnapshot)
|
||||
case context(SFTPTransferContext)
|
||||
case borrowed(id: UUID, channel: any SFTPChannelProtocol)
|
||||
|
||||
public var managedContext: SFTPTransferContext? {
|
||||
if case .context(let context) = self { context } else { nil }
|
||||
}
|
||||
|
||||
public var borrowedID: UUID? {
|
||||
if case .borrowed(let id, _) = self { id } else { nil }
|
||||
}
|
||||
}
|
||||
|
||||
public nonisolated struct SFTPRemoteCopySource: Sendable {
|
||||
@@ -116,7 +111,6 @@ public final class SFTPTransferCoordinator {
|
||||
private var preferencesUpdateTask: Task<Void, Never>?
|
||||
private var activeTasks: [SFTPTransfer.ID: Task<Void, Never>] = [:]
|
||||
private var activePermits: [SFTPTransfer.ID: SFTPConcurrencyPermit] = [:]
|
||||
private var activeBorrowedConnections: Set<UUID> = []
|
||||
private var remoteCopySources: [UUID: (source: SFTPRemoteCopySource, createdAt: Date)] = [:]
|
||||
|
||||
public private(set) var globalLimit: Int
|
||||
@@ -214,13 +208,6 @@ public final class SFTPTransferCoordinator {
|
||||
}
|
||||
let reservation = UUID()
|
||||
retain(source.connection, for: reservation)
|
||||
let borrowedID = source.connection.borrowedID
|
||||
if let borrowedID {
|
||||
try await reserveBorrowedConnection(borrowedID)
|
||||
}
|
||||
defer {
|
||||
if let borrowedID { releaseBorrowedConnections([borrowedID]) }
|
||||
}
|
||||
var lease: ChannelLease?
|
||||
do {
|
||||
let acquired = try await clientLease(for: source.connection)
|
||||
@@ -343,12 +330,6 @@ public final class SFTPTransferCoordinator {
|
||||
)
|
||||
}
|
||||
|
||||
public func replaceBorrowedChannel(_ connectionID: UUID, with channel: any SFTPChannelProtocol) {
|
||||
for id in Array(runtimes.keys) where runtimes[id]?.connection.borrowedID == connectionID {
|
||||
runtimes[id]?.connection = .borrowed(id: connectionID, channel: channel)
|
||||
}
|
||||
}
|
||||
|
||||
public func canPause(_ id: SFTPTransfer.ID) -> Bool {
|
||||
guard let transfer = store.sftpTransfer(id) else { return false }
|
||||
return runtimes[id] != nil && transfer.state.isActive
|
||||
@@ -430,8 +411,6 @@ public final class SFTPTransferCoordinator {
|
||||
try await runtime.resume.cleanup(using: channel)
|
||||
} else {
|
||||
do {
|
||||
if let id = runtime.connection.borrowedID { try await reserveBorrowedConnection(id) }
|
||||
defer { if let id = runtime.connection.borrowedID { releaseBorrowedConnections([id]) } }
|
||||
let lease = try await clientLease(for: runtime.connection)
|
||||
do { try await runtime.resume.cleanup(using: lease.channel) } catch {
|
||||
await lease.disconnect()
|
||||
@@ -466,21 +445,6 @@ public final class SFTPTransferCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
public func cancelTransfers(usingBorrowedConnection connectionID: UUID) async {
|
||||
let ids = store.sftpTransfers.compactMap { transfer -> SFTPTransfer.ID? in
|
||||
guard !transfer.state.isFinished,
|
||||
borrowedIDs(for: transfer).contains(connectionID)
|
||||
else { return nil }
|
||||
return transfer.id
|
||||
}
|
||||
for id in ids { cancel(id) }
|
||||
let tasks = ids.compactMap { activeTasks[$0] }
|
||||
for task in tasks { await task.value }
|
||||
while activeBorrowedConnections.contains(connectionID) {
|
||||
try? await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
}
|
||||
|
||||
public func clearFinished() { store.clearFinishedSFTPTransfers() }
|
||||
|
||||
public func removeFinished(_ id: SFTPTransfer.ID) { store.removeFinishedSFTPTransfer(id) }
|
||||
@@ -494,7 +458,6 @@ public final class SFTPTransferCoordinator {
|
||||
let hostIDs = Set(hostIDs(for: sftpTransfer))
|
||||
guard let anchorHostID = anchorHostID(for: sftpTransfer), !hostIDs.isEmpty else { continue }
|
||||
let source = workSource(for: sftpTransfer)
|
||||
let exclusiveResourceIDs = Set(borrowedIDs(for: sftpTransfer))
|
||||
let scheduler = concurrencyScheduler
|
||||
let preferencesUpdateTask = preferencesUpdateTask
|
||||
admissionTasks[sftpTransfer.id] = Task { @MainActor [weak self] in
|
||||
@@ -504,8 +467,7 @@ public final class SFTPTransferCoordinator {
|
||||
anchorHostID: anchorHostID,
|
||||
hostIDs: hostIDs,
|
||||
source: source,
|
||||
byteCount: sftpTransfer.byteCount >= 0 ? sftpTransfer.byteCount : nil,
|
||||
exclusiveResourceIDs: exclusiveResourceIDs
|
||||
byteCount: sftpTransfer.byteCount >= 0 ? sftpTransfer.byteCount : nil
|
||||
)
|
||||
guard let self else {
|
||||
await permit.release()
|
||||
@@ -534,10 +496,8 @@ public final class SFTPTransferCoordinator {
|
||||
startRemoteCopy(sftpTransfer, runtime: runtime, mode: mode)
|
||||
return
|
||||
}
|
||||
guard let hostID = sftpTransfer.hostID, let runtime = runtimes[sftpTransfer.id] else { return }
|
||||
guard let runtime = runtimes[sftpTransfer.id] else { return }
|
||||
store.setSFTPTransferState(.running, for: sftpTransfer.id)
|
||||
let borrowedIDs = Array(runtime.connection.borrowedID.map { [$0] } ?? [])
|
||||
activeBorrowedConnections.formUnion(borrowedIDs)
|
||||
let task = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let scoped = runtime.scopedRoot.startAccessingSecurityScopedResource()
|
||||
@@ -606,10 +566,7 @@ public final class SFTPTransferCoordinator {
|
||||
totalBytes: snapshot.source?.byteCount ?? sftpTransfer.byteCount, bytesPerSecond: 0)
|
||||
}
|
||||
await lease?.disconnect()
|
||||
await finish(
|
||||
sftpTransfer.id, hostIDs: [hostID], borrowedIDs: borrowedIDs,
|
||||
state: result
|
||||
)
|
||||
await finish(sftpTransfer.id, state: result)
|
||||
}
|
||||
activeTasks[sftpTransfer.id] = task
|
||||
}
|
||||
@@ -626,13 +583,7 @@ public final class SFTPTransferCoordinator {
|
||||
runtime: RemoteCopyRuntime,
|
||||
mode: SSHChannelSchedulingMode
|
||||
) {
|
||||
let involvedHostIDs = Array(Set([runtime.sourceHostID, runtime.destinationHostID]))
|
||||
store.setSFTPTransferState(.running, for: sftpTransfer.id)
|
||||
let borrowedIDs = [
|
||||
runtime.sourceConnection.borrowedID,
|
||||
runtime.destinationConnection.borrowedID,
|
||||
].compactMap { $0 }
|
||||
activeBorrowedConnections.formUnion(borrowedIDs)
|
||||
let task = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
var sourceLease: ChannelLease?
|
||||
@@ -687,10 +638,7 @@ public final class SFTPTransferCoordinator {
|
||||
}
|
||||
await sourceLease?.disconnect()
|
||||
await destinationLease?.disconnect()
|
||||
await finish(
|
||||
sftpTransfer.id, hostIDs: involvedHostIDs,
|
||||
borrowedIDs: borrowedIDs, state: result
|
||||
)
|
||||
await finish(sftpTransfer.id, state: result)
|
||||
}
|
||||
activeTasks[sftpTransfer.id] = task
|
||||
}
|
||||
@@ -748,8 +696,6 @@ public final class SFTPTransferCoordinator {
|
||||
throw error
|
||||
}
|
||||
return ChannelLease(channel: client) { await client.disconnect() }
|
||||
case .borrowed(_, let channel):
|
||||
return ChannelLease(channel: channel) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -771,32 +717,8 @@ public final class SFTPTransferCoordinator {
|
||||
.sorted { $0.id.uuidString < $1.id.uuidString }
|
||||
}
|
||||
|
||||
private func borrowedIDs(for sftpTransfer: SFTPTransfer) -> [UUID] {
|
||||
if let runtime = remoteCopyRuntimes[sftpTransfer.id] {
|
||||
return [
|
||||
runtime.sourceConnection.borrowedID,
|
||||
runtime.destinationConnection.borrowedID,
|
||||
].compactMap { $0 }
|
||||
}
|
||||
return runtimes[sftpTransfer.id]?.connection.borrowedID.map { [$0] } ?? []
|
||||
}
|
||||
|
||||
private func reserveBorrowedConnection(_ id: UUID) async throws {
|
||||
while activeBorrowedConnections.contains(id) {
|
||||
try Task.checkCancellation()
|
||||
try await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
activeBorrowedConnections.insert(id)
|
||||
}
|
||||
|
||||
private func releaseBorrowedConnections(_ ids: [UUID]) {
|
||||
activeBorrowedConnections.subtract(ids)
|
||||
schedule()
|
||||
}
|
||||
|
||||
private func finish(
|
||||
_ id: SFTPTransfer.ID, hostIDs: [Host.ID], borrowedIDs: [UUID] = [],
|
||||
state: SFTPTransfer.State
|
||||
_ id: SFTPTransfer.ID, state: SFTPTransfer.State
|
||||
) async {
|
||||
for context in managedContexts(for: id) {
|
||||
context.releaseSlot()
|
||||
@@ -813,7 +735,6 @@ public final class SFTPTransferCoordinator {
|
||||
}
|
||||
activeTasks.removeValue(forKey: id)
|
||||
let permit = activePermits.removeValue(forKey: id)
|
||||
releaseBorrowedConnections(borrowedIDs)
|
||||
await permit?.release()
|
||||
schedule()
|
||||
}
|
||||
|
||||
@@ -266,7 +266,6 @@ struct MobileAssistantTests {
|
||||
let app = try MobileAppModel(inMemory: true)
|
||||
let assistant = app.assistant.makeSceneModel()
|
||||
let conversation = ConversationModel()
|
||||
_ = assistant.library.startConversation(conversation)
|
||||
let plan = AgentPlan(
|
||||
title: "Implementation plan", body: "Apply the requested change.",
|
||||
scratchPath: "/tmp/plan.md")
|
||||
@@ -295,7 +294,8 @@ struct MobileAssistantTests {
|
||||
let assistant = app.assistant.makeSceneModel()
|
||||
let conversation = ConversationModel()
|
||||
conversation.composer.draft = "Opening question"
|
||||
_ = assistant.library.startConversation(conversation)
|
||||
conversation.sendDraft()
|
||||
assistant.library.insertAndSelect(conversation)
|
||||
conversation.composer.draft = "Follow-up question"
|
||||
|
||||
assistant.send()
|
||||
@@ -537,7 +537,6 @@ struct MobileAssistantTests {
|
||||
let app = try MobileAppModel(inMemory: true)
|
||||
let assistant = app.assistant.makeSceneModel()
|
||||
let conversation = ConversationModel()
|
||||
_ = assistant.library.startConversation(conversation)
|
||||
let plan = AgentPlan(
|
||||
title: "Implementation plan", body: "Apply the requested change.",
|
||||
scratchPath: "/tmp/plan.md")
|
||||
|
||||
@@ -23,7 +23,8 @@ struct MobileWorkspaceSessionTests {
|
||||
other.agentForwarding.isActive = true
|
||||
let conversation = ConversationModel()
|
||||
conversation.composer.draft = "Agent forwarding"
|
||||
_ = try #require(owner.assistant.library.startConversation(conversation))
|
||||
conversation.sendDraft()
|
||||
owner.assistant.library.insertAndSelect(conversation)
|
||||
owner.openAssistantNotification(conversation.id)
|
||||
let hostID = UUID()
|
||||
let request = SSHConnectionRequest(
|
||||
@@ -59,7 +60,8 @@ struct MobileWorkspaceSessionTests {
|
||||
].sorted { $0.id.uuidString < $1.id.uuidString }
|
||||
let conversation = ConversationModel()
|
||||
conversation.composer.draft = "Target"
|
||||
_ = try #require(scenes[0].assistant.library.startConversation(conversation))
|
||||
conversation.sendDraft()
|
||||
scenes[0].assistant.library.insertAndSelect(conversation)
|
||||
for scene in scenes {
|
||||
scene.openAssistantNotification(conversation.id)
|
||||
if !visible { scene.selectedDestination = .vault }
|
||||
@@ -85,7 +87,8 @@ struct MobileWorkspaceSessionTests {
|
||||
let other = MobileSceneModel(app: app, sessionID: "active-other")
|
||||
let conversation = ConversationModel()
|
||||
conversation.composer.draft = "Target"
|
||||
_ = try #require(owner.assistant.library.startConversation(conversation))
|
||||
conversation.sendDraft()
|
||||
owner.assistant.library.insertAndSelect(conversation)
|
||||
owner.openAssistantNotification(conversation.id)
|
||||
if libraryPresented {
|
||||
owner.assistantLibraryRoute = .conversations
|
||||
@@ -155,8 +158,10 @@ struct MobileWorkspaceSessionTests {
|
||||
let second = ConversationModel()
|
||||
first.composer.draft = "First"
|
||||
second.composer.draft = "Second"
|
||||
_ = try #require(scene.assistant.library.startConversation(first))
|
||||
_ = try #require(scene.assistant.library.startConversation(second))
|
||||
first.sendDraft()
|
||||
scene.assistant.library.insertAndSelect(first)
|
||||
second.sendDraft()
|
||||
scene.assistant.library.insertAndSelect(second)
|
||||
app.sceneChanged(scene.id, phase: .active)
|
||||
var activations: [String] = []
|
||||
scene.assistant.library.select(first.id)
|
||||
@@ -258,7 +263,8 @@ struct MobileWorkspaceSessionTests {
|
||||
let second = MobileSceneModel(app: app, sessionID: "assistant-b")
|
||||
let conversation = ConversationModel()
|
||||
conversation.composer.draft = "Test"
|
||||
_ = try #require(first.assistant.library.startConversation(conversation))
|
||||
conversation.sendDraft()
|
||||
first.assistant.library.insertAndSelect(conversation)
|
||||
first.openAssistantNotification(conversation.id)
|
||||
app.sceneChanged(first.id, phase: .active)
|
||||
#expect(app.isAssistantNotificationFocused(conversation.id))
|
||||
@@ -268,7 +274,8 @@ struct MobileWorkspaceSessionTests {
|
||||
app.sceneChanged(second.id, phase: .active)
|
||||
let peer = ConversationModel()
|
||||
peer.composer.draft = "Second conversation"
|
||||
_ = try #require(second.assistant.library.startConversation(peer))
|
||||
peer.sendDraft()
|
||||
second.assistant.library.insertAndSelect(peer)
|
||||
second.openAssistantNotification(peer.id)
|
||||
#expect(app.isAssistantNotificationFocused(conversation.id))
|
||||
#expect(app.isAssistantNotificationFocused(peer.id))
|
||||
|
||||
@@ -60,7 +60,6 @@ final class AppModel {
|
||||
let credentials: CredentialLibraryModel
|
||||
let snippets: SnippetLibraryModel
|
||||
let forwardings: ForwardingLibraryModel
|
||||
let transfers: TransferLibraryModel
|
||||
let connectionHistory: ConnectionHistoryModel
|
||||
let devices: DeviceDirectoryModel
|
||||
private let externalToolCalls: ToolAuditModel
|
||||
@@ -238,7 +237,6 @@ final class AppModel {
|
||||
credentials = CredentialLibraryModel(library: hosts)
|
||||
snippets = SnippetLibraryModel(library: hosts)
|
||||
forwardings = ForwardingLibraryModel(library: hosts)
|
||||
transfers = TransferLibraryModel(library: hosts)
|
||||
self.connectionHistory = connectionHistory
|
||||
let devices = devices ?? DeviceDirectoryModel(currentDeviceID: connectionHistory.sourceDeviceID)
|
||||
self.devices = devices
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
|
||||
import struct TermindDomain.Host
|
||||
|
||||
/// Saved-vault access exposed to transfer presentation and session entry points.
|
||||
@Observable
|
||||
final class TransferLibraryModel {
|
||||
private let library: HostLibraryModel
|
||||
|
||||
init(library: HostLibraryModel) {
|
||||
self.library = library
|
||||
}
|
||||
}
|
||||
|
||||
extension TransferLibraryModel: RemoteSessionHostStore {
|
||||
func host(_ id: Host.ID) -> Host? { library.host(id) }
|
||||
func networkConfigurationStamp(for host: Host) -> Data { library.networkConfigurationStamp(for: host) }
|
||||
func tailscaleNetwork(for host: Host) throws -> (any SSHNetworkDialing)? {
|
||||
try library.tailscaleNetwork(for: host)
|
||||
}
|
||||
func savedTailscaleUsername(for host: Host) -> String? { library.savedTailscaleUsername(for: host) }
|
||||
func tailscaleUsername(for host: Host) -> String { library.tailscaleUsername(for: host) }
|
||||
@discardableResult func saveTailscaleUsername(_ username: String, hostID: Host.ID) -> Bool {
|
||||
library.saveTailscaleUsername(username, hostID: hostID)
|
||||
}
|
||||
func identity(_ id: Identity.ID) -> Identity? { library.identity(id) }
|
||||
func identity(for host: Host) -> Identity? { library.identity(for: host) }
|
||||
func customIdentity(for hostID: Host.ID, purpose: HostCustomIdentityPurpose) -> Identity? {
|
||||
library.customIdentity(for: hostID, purpose: purpose)
|
||||
}
|
||||
var customPasswordStorage: PasswordStorage { library.customPasswordStorage }
|
||||
func key(_ id: SSHKeyRef.ID) -> SSHKeyRef? { library.key(id) }
|
||||
func knownHosts(for host: Host) -> [KnownHostEntry] { library.knownHosts(for: host) }
|
||||
func route(for host: Host) -> [Host] { library.route(for: host) }
|
||||
func expectedHostKeys(
|
||||
for host: Host, temporaryHostKeys: [Host.ID: SSHHostKeyIdentity],
|
||||
trustPolicy: SSHHostTrustPolicy
|
||||
) throws -> Set<SSHHostKeyIdentity> {
|
||||
try library.expectedHostKeys(
|
||||
for: host, temporaryHostKeys: temporaryHostKeys,
|
||||
trustPolicy: trustPolicy
|
||||
)
|
||||
}
|
||||
func prepareSSHConnection(
|
||||
for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission],
|
||||
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission]
|
||||
) throws -> SSHConnectionPreparation {
|
||||
try library.prepareSSHConnection(
|
||||
for: host, submissions: submissions, proxySubmissions: proxySubmissions)
|
||||
}
|
||||
func sshConnectionConfiguration(
|
||||
for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission],
|
||||
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission],
|
||||
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity],
|
||||
trustPolicy: SSHHostTrustPolicy
|
||||
) throws -> SSHConnectionConfiguration {
|
||||
try library.sshConnectionConfiguration(
|
||||
for: host, submissions: submissions, proxySubmissions: proxySubmissions,
|
||||
temporaryHostKeys: temporaryHostKeys,
|
||||
trustPolicy: trustPolicy
|
||||
)
|
||||
}
|
||||
func sshJumpConfigurations(
|
||||
for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission],
|
||||
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission],
|
||||
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity],
|
||||
trustPolicy: SSHHostTrustPolicy
|
||||
) throws -> [SSHRouteHopConfiguration] {
|
||||
try library.sshJumpConfigurations(
|
||||
for: host, submissions: submissions, proxySubmissions: proxySubmissions,
|
||||
temporaryHostKeys: temporaryHostKeys,
|
||||
trustPolicy: trustPolicy
|
||||
)
|
||||
}
|
||||
func prepareSSHConnection(
|
||||
for route: SSHConnectionRoute, submissions: [Host.ID: SSHAuthenticationSubmission],
|
||||
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission]
|
||||
) throws -> SSHConnectionPreparation {
|
||||
try library.prepareSSHConnection(
|
||||
for: route, submissions: submissions, proxySubmissions: proxySubmissions)
|
||||
}
|
||||
func sshJumpConfigurations(
|
||||
for route: SSHConnectionRoute, submissions: [Host.ID: SSHAuthenticationSubmission],
|
||||
proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission],
|
||||
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity], trustPolicy: SSHHostTrustPolicy
|
||||
) throws -> [SSHRouteHopConfiguration] {
|
||||
try library.sshJumpConfigurations(
|
||||
for: route, submissions: submissions, proxySubmissions: proxySubmissions,
|
||||
temporaryHostKeys: temporaryHostKeys, trustPolicy: trustPolicy
|
||||
)
|
||||
}
|
||||
func saveConnectionRoute(
|
||||
_ route: SSHConnectionRoute, authentication: SSHAuthenticationSubmission?,
|
||||
authenticationHostID: Host.ID?, suppliedPassword: String?
|
||||
) throws -> SSHConnectionRoute {
|
||||
try library.saveConnectionRoute(
|
||||
route, authentication: authentication, authenticationHostID: authenticationHostID,
|
||||
suppliedPassword: suppliedPassword
|
||||
)
|
||||
}
|
||||
func transportProxy(for host: Host) throws -> SSHTransportProxy? {
|
||||
try library.transportProxy(for: host)
|
||||
}
|
||||
func transportProxy(
|
||||
for host: Host, submission: SSHProxyAuthenticationSubmission?
|
||||
) throws -> SSHTransportProxy? {
|
||||
try library.transportProxy(for: host, submission: submission)
|
||||
}
|
||||
func sshCredentials(for identity: Identity) -> SSHCredentialResolution {
|
||||
library.sshCredentials(for: identity)
|
||||
}
|
||||
func passwordSecret(for identity: Identity) throws -> SSHSecret? {
|
||||
try library.passwordSecret(for: identity)
|
||||
}
|
||||
func passwordAvailability(for identity: Identity) -> SecretAvailability {
|
||||
library.passwordAvailability(for: identity)
|
||||
}
|
||||
func keySecretCredential(for key: SSHKeyRef) throws -> SSHResolvedKeyCredential? {
|
||||
try library.keySecretCredential(for: key)
|
||||
}
|
||||
func setConnectionStatus(
|
||||
_ status: ConnectionStatus, connectionID: UUID, hostID: Host.ID
|
||||
) {
|
||||
library.setConnectionStatus(status, connectionID: connectionID, hostID: hostID)
|
||||
}
|
||||
func recordRemoteSystemInfo(_ systemInfo: RemoteSystemInfo?, for hostID: Host.ID) {
|
||||
library.recordRemoteSystemInfo(systemInfo, for: hostID)
|
||||
}
|
||||
func trust(_ entry: KnownHostEntry) { library.trust(entry) }
|
||||
@discardableResult func upsert(_ identity: Identity) -> Bool { library.upsert(identity) }
|
||||
func upsert(_ identity: Identity, passwordUpdate: IdentityPasswordUpdate) throws {
|
||||
try library.upsert(identity, passwordUpdate: passwordUpdate)
|
||||
}
|
||||
func upsert(_ host: Host) { library.upsert(host) }
|
||||
}
|
||||
@@ -309,17 +309,17 @@ struct SFTPBrowserView: View {
|
||||
identityID, username, password, passphrase, keyID, save in
|
||||
sftpChannel.continueAuthentication(
|
||||
identityID: identityID, username: username, password: password,
|
||||
passphrase: passphrase, keyID: keyID, save: save, using: app.transfers
|
||||
passphrase: passphrase, keyID: keyID, save: save, using: app.hostLibrary
|
||||
)
|
||||
},
|
||||
continueHostKey: {
|
||||
sftpChannel.continueWithTemporaryHostKey($0)
|
||||
},
|
||||
trustHostKey: { sftpChannel.trustAndConnect($0, using: app.transfers) },
|
||||
trustHostKey: { sftpChannel.trustAndConnect($0, using: app.hostLibrary) },
|
||||
denyHostKey: {
|
||||
cancelConnection()
|
||||
},
|
||||
retry: { sftpChannel.connect(using: app.transfers) },
|
||||
retry: { sftpChannel.connect(using: app.hostLibrary) },
|
||||
cancel: cancelConnection,
|
||||
reconnectState: sftpChannel.reconnectState,
|
||||
retryReconnectNow: sftpChannel.retryReconnectNow,
|
||||
@@ -337,7 +337,7 @@ struct SFTPBrowserView: View {
|
||||
.background(TermindColor.windowSurface)
|
||||
.onAppear {
|
||||
sftpChannel.bindTransfers(to: app.sftpTransfers)
|
||||
sftpChannel.connectIfNeeded(using: app.transfers)
|
||||
sftpChannel.connectIfNeeded(using: app.hostLibrary)
|
||||
sftpChannel.startAutomaticRefresh()
|
||||
}
|
||||
.alert(AppStrings.SFTP.newFolder, isPresented: $newFolderPresented) {
|
||||
@@ -630,7 +630,7 @@ struct SFTPBrowserView: View {
|
||||
Image(systemName: "folder.badge.questionmark")
|
||||
.font(.largeTitle).foregroundStyle(.tertiary)
|
||||
Text(AppStrings.Connections.notConnected).foregroundStyle(.secondary)
|
||||
Button(AppStrings.Common.connect.swiftUIKey) { sftpChannel.connect(using: app.transfers) }
|
||||
Button(AppStrings.Common.connect.swiftUIKey) { sftpChannel.connect(using: app.hostLibrary) }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
@@ -764,15 +764,6 @@ final class WindowModel {
|
||||
assistantCommands?.edit(id, messageID, text, context, terminalSuggestionsAvailable)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func sendAssistantSidebarDraft() -> ConversationModel? {
|
||||
let draft = sidebar.assistantDraft
|
||||
guard let conversation = assistantLibrary.startConversation(draft) else { return nil }
|
||||
isAssistantArchivePresented = false
|
||||
sidebar.assistantDraft = ConversationModel()
|
||||
return conversation
|
||||
}
|
||||
|
||||
func assistantSidebarConversationDidStart(_ conversation: ConversationModel) {
|
||||
isAssistantArchivePresented = false
|
||||
assistantLibrary.select(conversation.id)
|
||||
|
||||
@@ -17,7 +17,8 @@ struct CommandNotificationRenderingTests {
|
||||
app.registerWindow(windowModel)
|
||||
let conversation = ConversationModel()
|
||||
conversation.composer.draft = "Deploy staging"
|
||||
_ = try #require(windowModel.assistantLibrary.startConversation(conversation))
|
||||
conversation.sendDraft()
|
||||
windowModel.assistantLibrary.insertAndSelect(conversation)
|
||||
let coordinator = AssistantNotificationCoordinator(
|
||||
service: app.notifications, preferences: { .init() }, isFocused: { _ in false },
|
||||
content: AssistantNotificationContent.make)
|
||||
|
||||
@@ -16,7 +16,8 @@ struct CommandNotificationTests {
|
||||
app.registerWindow(window)
|
||||
let conversation = ConversationModel()
|
||||
conversation.composer.draft = "Target"
|
||||
_ = try #require(window.assistantLibrary.startConversation(conversation))
|
||||
conversation.sendDraft()
|
||||
window.assistantLibrary.insertAndSelect(conversation)
|
||||
let workspace = Workspace(pane: Pane(.empty))
|
||||
workspace.splitFocusedPane(axis: .horizontal, with: Pane(.empty))
|
||||
window.open(workspace)
|
||||
@@ -30,7 +31,8 @@ struct CommandNotificationTests {
|
||||
case "peer":
|
||||
let peer = ConversationModel()
|
||||
peer.composer.draft = "Other conversation"
|
||||
_ = try #require(window.assistantLibrary.startConversation(peer))
|
||||
peer.sendDraft()
|
||||
window.assistantLibrary.insertAndSelect(peer)
|
||||
case "archive": window.showAssistantArchiveInSidebar()
|
||||
default: break
|
||||
}
|
||||
@@ -55,7 +57,8 @@ struct CommandNotificationTests {
|
||||
for window in windows { app.registerWindow(window) }
|
||||
let conversation = ConversationModel()
|
||||
conversation.composer.draft = "Target"
|
||||
_ = try #require(windows[0].assistantLibrary.startConversation(conversation))
|
||||
conversation.sendDraft()
|
||||
windows[0].assistantLibrary.insertAndSelect(conversation)
|
||||
for window in windows {
|
||||
window.openAssistantFullScreen(conversation.id)
|
||||
if !visible { window.selectVault() }
|
||||
@@ -79,7 +82,8 @@ struct CommandNotificationTests {
|
||||
app.registerWindow(other)
|
||||
let conversation = ConversationModel()
|
||||
conversation.composer.draft = "Target"
|
||||
_ = try #require(owner.assistantLibrary.startConversation(conversation))
|
||||
conversation.sendDraft()
|
||||
owner.assistantLibrary.insertAndSelect(conversation)
|
||||
owner.openAssistantFullScreen(conversation.id)
|
||||
let workspace = Workspace(pane: Pane(.empty))
|
||||
owner.open(workspace)
|
||||
@@ -105,8 +109,10 @@ struct CommandNotificationTests {
|
||||
let second = ConversationModel()
|
||||
first.composer.draft = "First"
|
||||
second.composer.draft = "Second"
|
||||
_ = try #require(window.assistantLibrary.startConversation(first))
|
||||
_ = try #require(window.assistantLibrary.startConversation(second))
|
||||
first.sendDraft()
|
||||
window.assistantLibrary.insertAndSelect(first)
|
||||
second.sendDraft()
|
||||
window.assistantLibrary.insertAndSelect(second)
|
||||
let activations = AsyncStream<Void>.makeStream()
|
||||
var count = 0
|
||||
let router = DesktopNotificationRouter(app: app) { _ in
|
||||
@@ -213,7 +219,8 @@ struct CommandNotificationTests {
|
||||
app.registerWindow(other)
|
||||
let conversation = ConversationModel()
|
||||
conversation.composer.draft = "Test"
|
||||
_ = try #require(window.assistantLibrary.startConversation(conversation))
|
||||
conversation.sendDraft()
|
||||
window.assistantLibrary.insertAndSelect(conversation)
|
||||
window.openAssistantFullScreen(conversation.id)
|
||||
#expect(app.isAssistantNotificationFocused(conversation.id))
|
||||
#expect(app.isAssistantNotificationFocused(UUID()))
|
||||
@@ -229,7 +236,8 @@ struct CommandNotificationTests {
|
||||
#expect(other.assistantLibrary.selectedConversation === conversation)
|
||||
let peer = ConversationModel()
|
||||
peer.composer.draft = "Second conversation"
|
||||
_ = try #require(window.assistantLibrary.startConversation(peer))
|
||||
peer.sendDraft()
|
||||
window.assistantLibrary.insertAndSelect(peer)
|
||||
window.openAssistantInSidebar(peer.id)
|
||||
window.showsSidebar = true
|
||||
other.open(Workspace(pane: Pane(.empty)))
|
||||
|
||||
@@ -100,6 +100,38 @@ struct DesktopAuthenticationSubmissionTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: [false, true])
|
||||
func sftpPassphraseContinuationSavesOnlyWhenRequested(_ save: Bool) throws {
|
||||
let key = protectedKey()
|
||||
let secrets = InMemorySecretStore()
|
||||
let identity = Identity(name: "Deploy", username: "deploy", keyID: key.id)
|
||||
let host = Host(name: "Server", host: "server.invalid", identityID: identity.id)
|
||||
let library = HostLibraryModel(hosts: [host], keys: [key], identities: [identity], secrets: secrets)
|
||||
try secrets.setKeyCredential(
|
||||
.software(.init(privateKey: "encrypted")), for: key.credentialRef, storage: key.storage)
|
||||
let app = AppModel(hosts: library)
|
||||
let channel = SFTPChannelModel(hostID: host.id)
|
||||
defer { channel.disconnect() }
|
||||
channel.pendingAuthentication = SSHAuthenticationPrompt(
|
||||
host: host, selectedIdentityID: identity.id, username: identity.username,
|
||||
selectedKeyID: key.id, requiresPassphrase: true)
|
||||
|
||||
channel.continueAuthentication(
|
||||
identityID: identity.id, username: identity.username, password: nil,
|
||||
passphrase: "saved", keyID: key.id, save: save, using: app.hostLibrary)
|
||||
|
||||
#expect(channel.pendingAuthentication == nil)
|
||||
guard
|
||||
case .software(let credential)? = try secrets.keyCredential(
|
||||
for: key.credentialRef, storage: key.storage)
|
||||
else {
|
||||
Issue.record("The protected key must remain available")
|
||||
return
|
||||
}
|
||||
#expect(credential.passphrase == (save ? "saved" : nil))
|
||||
#expect(library.host(host.id) == host)
|
||||
}
|
||||
|
||||
private func protectedKey() -> SSHKeyRef {
|
||||
SSHKeyRef(label: "Protected", publicKey: "ssh-ed25519 fixture", hasPassphrase: true)
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ struct MultiWindowTests {
|
||||
let ssh = try #require(sshWorkspace.focusedPane.sshSession)
|
||||
let sftp = try #require(sftpWorkspace.focusedPane.sftpChannel)
|
||||
ssh.connectIfNeeded(using: app.hostLibrary)
|
||||
sftp.connectIfNeeded(using: app.transfers)
|
||||
sftp.connectIfNeeded(using: app.hostLibrary)
|
||||
let sshPrompt = try #require(ssh.pendingAuthentication)
|
||||
for _ in 0..<100 where sftp.pendingAuthentication == nil {
|
||||
try await Task.sleep(for: .milliseconds(10))
|
||||
@@ -221,7 +221,7 @@ struct MultiWindowTests {
|
||||
destination.open(try #require(source.takeWorkspaceForMove(sshWorkspace.id)))
|
||||
destination.open(try #require(source.takeWorkspaceForMove(sftpWorkspace.id)))
|
||||
ssh.connectIfNeeded(using: app.hostLibrary)
|
||||
sftp.connectIfNeeded(using: app.transfers)
|
||||
sftp.connectIfNeeded(using: app.hostLibrary)
|
||||
#expect(ssh.pendingAuthentication?.id == sshPrompt.id)
|
||||
#expect(sftp.pendingAuthentication?.id == sftpPrompt.id)
|
||||
#expect(sftp.path == "/srv")
|
||||
|
||||
@@ -197,12 +197,6 @@ struct RepositoryOutboxTests {
|
||||
try context.save()
|
||||
let repository = SwiftDataHostRepository(context: context)
|
||||
|
||||
#expect(
|
||||
try repository.deletionImpact(for: doomed.id)
|
||||
== HostDeletionImpact(
|
||||
jumpHostReferences: 1, snippetReferences: 1, forwardingRules: 1
|
||||
)
|
||||
)
|
||||
try repository.requestHostDeletion(doomed.id)
|
||||
|
||||
let hiddenSnapshot = try repository.snapshot()
|
||||
|
||||
@@ -282,7 +282,13 @@ struct SFTPEditTests {
|
||||
let client = EditClient()
|
||||
let transfers: SFTPTransferCoordinator
|
||||
let service: SFTPEditService
|
||||
var connection: SFTPTransferConnection { .borrowed(id: hostID, channel: client) }
|
||||
private lazy var context = SFTPTransferContext(
|
||||
snapshot: SSHAdditionalConnectionSnapshot(
|
||||
configuration: SSHConnectionConfiguration(
|
||||
hostID: hostID, host: "fixture.invalid", port: 22,
|
||||
username: "test", credentials: [], expectedHostKeys: [])),
|
||||
channel: client, primaryRole: .ptyConnectionFallback)
|
||||
var connection: SFTPTransferConnection { .context(context) }
|
||||
init() {
|
||||
let client = client
|
||||
transfers = SFTPTransferCoordinator(store: HostLibraryModel(), clientFactory: { client })
|
||||
|
||||
@@ -642,12 +642,15 @@ struct SFTPFeatureTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func borrowedConnectionRunsSeriallyWithoutReconnect() async {
|
||||
func contextSerializesPrimaryChannelAndClosesItAfterOwnerRelease() async {
|
||||
let hostID = UUID()
|
||||
let connectionID = UUID()
|
||||
let store = HostLibraryModel()
|
||||
let gate = SFTPTransferClientGate()
|
||||
let client = BorrowedSFTPTransferClient(gate: gate)
|
||||
let client = PrimarySFTPTransferClient(gate: gate)
|
||||
let context = SFTPTransferContext(
|
||||
snapshot: SSHAdditionalConnectionSnapshot(
|
||||
configuration: makeTestConfiguration(hostID: hostID, host: "fixture.invalid")),
|
||||
channel: client, primaryRole: .ptyConnectionFallback)
|
||||
let coordinator = SFTPTransferCoordinator(
|
||||
store: store, globalLimit: 4, perHostLimit: 4
|
||||
)
|
||||
@@ -656,16 +659,15 @@ struct SFTPFeatureTests {
|
||||
for index in 0..<2 {
|
||||
coordinator.enqueue(
|
||||
direction: .download, batchID: UUID(),
|
||||
localURL: root.appendingPathComponent("borrowed-\(index)"),
|
||||
remotePath: "/borrowed-\(index)", byteCount: 1,
|
||||
hostID: hostID, connection: .borrowed(id: connectionID, channel: client),
|
||||
localURL: root.appendingPathComponent("primary-\(index)"),
|
||||
remotePath: "/primary-\(index)", byteCount: 1,
|
||||
hostID: hostID, connection: .context(context),
|
||||
scopedRoot: root, overwrite: true
|
||||
)
|
||||
}
|
||||
|
||||
await gate.waitForStarts(1)
|
||||
#expect(await gate.maximumActive == 1)
|
||||
#expect(coordinator.activeCount == 1)
|
||||
await gate.releaseNext()
|
||||
await gate.waitForStarts(2)
|
||||
#expect(await gate.maximumActive == 1)
|
||||
@@ -675,6 +677,8 @@ struct SFTPFeatureTests {
|
||||
#expect(await client.connectCount == 0)
|
||||
#expect(await client.disconnectCount == 0)
|
||||
#expect(coordinator.sftpTransfers.allSatisfy { $0.state == .completed })
|
||||
await context.releaseOwner()
|
||||
#expect(await client.disconnectCount == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1390,7 +1394,7 @@ private actor FakeSFTPTransferClient: SFTPClientProtocol {
|
||||
) async throws { await gate.enter(host: host) }
|
||||
}
|
||||
|
||||
private actor BorrowedSFTPTransferClient: SFTPClientProtocol {
|
||||
private actor PrimarySFTPTransferClient: SFTPClientProtocol {
|
||||
let gate: SFTPTransferClientGate
|
||||
private(set) var connectCount = 0
|
||||
private(set) var disconnectCount = 0
|
||||
@@ -1413,12 +1417,12 @@ private actor BorrowedSFTPTransferClient: SFTPClientProtocol {
|
||||
from localURL: URL, to remotePath: String, overwrite: Bool,
|
||||
cancellation: TransferCancellationToken,
|
||||
progress: @escaping @Sendable (SFTPProgress) -> Void
|
||||
) async throws { await gate.enter(host: "borrowed") }
|
||||
) async throws { await gate.enter(host: "primary") }
|
||||
func downloadFile(
|
||||
from remotePath: String, to localURL: URL, overwrite: Bool,
|
||||
cancellation: TransferCancellationToken,
|
||||
progress: @escaping @Sendable (SFTPProgress) -> Void
|
||||
) async throws { await gate.enter(host: "borrowed") }
|
||||
) async throws { await gate.enter(host: "primary") }
|
||||
}
|
||||
|
||||
private actor ManifestSFTPClient: SFTPClientProtocol {
|
||||
|
||||
@@ -29,11 +29,10 @@ nonisolated struct TailscaleSFTPChannelWireTests {
|
||||
dnsName: "fixture.test.ts.net", online: true, sshHostKeys: ["fixture"])
|
||||
], groupID: group.id)
|
||||
let host = try #require(library.hosts.first)
|
||||
let transfers = TransferLibraryModel(library: library)
|
||||
#expect(transfers.saveTailscaleUsername(rejected ? "reject" : "none", hostID: host.id))
|
||||
#expect(library.saveTailscaleUsername(rejected ? "reject" : "none", hostID: host.id))
|
||||
let files = SFTPChannelModel(hostID: host.id)
|
||||
defer { files.disconnect() }
|
||||
files.connect(using: transfers)
|
||||
files.connect(using: library)
|
||||
for _ in 0..<2_000 {
|
||||
if files.status == .connected || files.pendingAuthentication != nil { break }
|
||||
if case .failed = files.status { break }
|
||||
|
||||
@@ -13,7 +13,6 @@ struct TailscaleSessionRoutingTests {
|
||||
var group = HostGroup(name: "Tailnet")
|
||||
group.tailscale = TailscaleGroupConfiguration(network: network)
|
||||
let library = HostLibraryModel(groups: [group])
|
||||
let transfers = TransferLibraryModel(library: library)
|
||||
let provider = RoutingProvider()
|
||||
library.tailscaleConnections = provider
|
||||
library.replaceTailscalePeers(
|
||||
@@ -31,7 +30,7 @@ struct TailscaleSessionRoutingTests {
|
||||
files?.disconnect()
|
||||
}
|
||||
terminal?.connect(using: library)
|
||||
files?.connect(using: transfers)
|
||||
files?.connect(using: library)
|
||||
#expect(terminal?.pendingAuthentication == nil)
|
||||
#expect(files?.pendingAuthentication == nil)
|
||||
for _ in 0..<200 {
|
||||
@@ -47,7 +46,6 @@ struct TailscaleSessionRoutingTests {
|
||||
var group = HostGroup(name: "Tailnet")
|
||||
group.tailscale = TailscaleGroupConfiguration(network: network)
|
||||
let library = HostLibraryModel(groups: [group])
|
||||
let transfers = TransferLibraryModel(library: library)
|
||||
let provider = RoutingProvider()
|
||||
library.tailscaleConnections = provider
|
||||
library.replaceTailscalePeers(
|
||||
@@ -64,7 +62,7 @@ struct TailscaleSessionRoutingTests {
|
||||
files?.disconnect()
|
||||
}
|
||||
terminal?.connect(using: library)
|
||||
files?.connect(using: transfers)
|
||||
files?.connect(using: library)
|
||||
let prompt = try #require(terminal?.pendingAuthentication ?? files?.pendingAuthentication)
|
||||
#expect(prompt.usernameOnly && prompt.username == "root")
|
||||
terminal?.continueAuthentication(
|
||||
@@ -72,7 +70,7 @@ struct TailscaleSessionRoutingTests {
|
||||
keyID: nil, save: true, using: library)
|
||||
files?.continueAuthentication(
|
||||
identityID: nil, username: "operator", password: nil, passphrase: nil,
|
||||
keyID: nil, save: true, using: transfers)
|
||||
keyID: nil, save: true, using: library)
|
||||
for _ in 0..<200 {
|
||||
if await provider.dialer.openedHost != nil { break }
|
||||
try await Task.sleep(for: .milliseconds(10))
|
||||
|
||||
@@ -563,7 +563,8 @@ struct WindowStateTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func newChatJoinsHistoryOnlyAfterItsFirstSend() {
|
||||
func newChatJoinsHistoryOnlyAfterItsFirstSend() async {
|
||||
let app = AppModel(hosts: HostLibraryModel())
|
||||
let window = WindowModel()
|
||||
let draft = window.openNewAssistantChat()
|
||||
|
||||
@@ -571,24 +572,28 @@ struct WindowStateTests {
|
||||
#expect(window.assistantLibrary.conversations.isEmpty)
|
||||
|
||||
draft.composer.draft = "Hello from a new chat"
|
||||
let started = window.assistantLibrary.startDraftConversation()
|
||||
let started = app.sendNewAgentDraft(in: window.assistantLibrary)
|
||||
|
||||
#expect(started === draft)
|
||||
#expect(window.assistantLibrary.selectedConversation === draft)
|
||||
#expect(window.assistantLibrary.conversations.map(\.id) == [draft.id])
|
||||
#expect(window.assistantLibrary.draftConversation !== draft)
|
||||
await started?.stopAndWait()
|
||||
}
|
||||
|
||||
@Test
|
||||
func sidebarDraftCreatesConversationOnlyOnFirstValidSend() {
|
||||
func sidebarDraftCreatesConversationOnlyOnFirstValidSend() async {
|
||||
let app = AppModel(hosts: HostLibraryModel())
|
||||
let workspace = Workspace.sshSession(host: Host(name: "Server", host: "server.example"))
|
||||
let window = WindowModel(workspaces: [workspace])
|
||||
|
||||
#expect(window.sendAssistantSidebarDraft() == nil)
|
||||
#expect(app.sendNewAgentConversation(window.sidebar.assistantDraft, in: window.assistantLibrary) == nil)
|
||||
#expect(window.assistantLibrary.conversations.isEmpty)
|
||||
|
||||
window.sidebar.assistantDraft.composer.draft = " Explain this failure. "
|
||||
let conversation = window.sendAssistantSidebarDraft()
|
||||
let conversation = app.sendNewAgentConversation(
|
||||
window.sidebar.assistantDraft, in: window.assistantLibrary, context: window.sidebar.sessionContext)
|
||||
if let conversation { window.assistantSidebarConversationDidStart(conversation) }
|
||||
|
||||
#expect(conversation?.messages.map(\.text) == ["Explain this failure."])
|
||||
#expect(window.assistantLibrary.conversations.count == 1)
|
||||
@@ -596,6 +601,7 @@ struct WindowStateTests {
|
||||
#expect(window.sidebarAssistantConversation?.id == conversation?.id)
|
||||
#expect(window.selectedDestination == .workspace(workspace.id))
|
||||
#expect(window.sidebar.assistantDraft.isEmpty)
|
||||
await conversation?.stopAndWait()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -604,7 +610,8 @@ struct WindowStateTests {
|
||||
let conversation = ConversationModel()
|
||||
conversation.composer.draft = "Hello"
|
||||
let library = AssistantLibraryModel()
|
||||
_ = library.startConversation(conversation)
|
||||
conversation.sendDraft()
|
||||
library.insertAndSelect(conversation)
|
||||
let window = WindowModel(workspaces: [workspace], assistantLibrary: library)
|
||||
|
||||
window.openAssistantInSidebar(conversation.id)
|
||||
@@ -708,7 +715,8 @@ struct WindowStateTests {
|
||||
/// conversation promoted out of it arrives in the Assistant destination carrying
|
||||
/// nothing about a host it can no longer see.
|
||||
@Test
|
||||
func sendingFromTheSidebarDoesNotAttachSessionContextToTheConversation() {
|
||||
func sendingFromTheSidebarDoesNotAttachSessionContextToTheConversation() async {
|
||||
let app = AppModel(hosts: HostLibraryModel())
|
||||
let host = Host(name: "Server", host: "server.example")
|
||||
let workspace = Workspace.sftpChannel(host: host, initialPath: "/srv/app")
|
||||
workspace.focusedPane.sftpChannel?.homeResolution = .unavailable
|
||||
@@ -718,10 +726,14 @@ struct WindowStateTests {
|
||||
|
||||
#expect(window.sidebar.sessionContext.chips.map(\.label) == ["Server", "/srv/app"])
|
||||
|
||||
let conversation = window.sendAssistantSidebarDraft()
|
||||
let conversation = app.sendNewAgentConversation(
|
||||
window.sidebar.assistantDraft, in: window.assistantLibrary, context: window.sidebar.sessionContext)
|
||||
if let conversation { window.assistantSidebarConversationDidStart(conversation) }
|
||||
|
||||
#expect(conversation?.composer.attachments.isEmpty == true)
|
||||
#expect(conversation?.transcript.first?.contextSnapshot?.sessionContext?.contains("/srv/app") == true)
|
||||
#expect(window.sidebar.sessionContext.chips.map(\.label) == ["Server", "/srv/app"])
|
||||
await conversation?.stopAndWait()
|
||||
}
|
||||
|
||||
/// Leaving every Workspace used to freeze the last host and directory on
|
||||
@@ -745,18 +757,21 @@ struct WindowStateTests {
|
||||
/// the log goes with the message it belongs to rather than sitting in the
|
||||
/// draft as text the user could half-delete.
|
||||
@Test
|
||||
func promotingASidebarDraftSendsItsAttachmentWithTheFirstTurn() {
|
||||
func promotingASidebarDraftSendsItsAttachmentWithTheFirstTurn() async {
|
||||
let app = AppModel(hosts: HostLibraryModel())
|
||||
let conversation = ConversationModel()
|
||||
conversation.attach(
|
||||
ContextChip(kind: .output, label: "Connection error", content: "ssh: refused")
|
||||
)
|
||||
conversation.composer.draft = "Explain this"
|
||||
|
||||
_ = AssistantLibraryModel().startConversation(conversation)
|
||||
#expect(app.sendNewAgentConversation(conversation, in: AssistantLibraryModel()) === conversation)
|
||||
|
||||
#expect(conversation.messages.first?.text == "Explain this")
|
||||
#expect(conversation.transcript.first?.text.contains("ssh: refused") == true)
|
||||
#expect(conversation.composer.attachments.isEmpty, "the attachment went with the turn")
|
||||
#expect(conversation.transcript.first?.contextSnapshot != nil)
|
||||
await conversation.stopAndWait()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -792,7 +807,8 @@ struct WindowStateTests {
|
||||
let conversation = ConversationModel()
|
||||
conversation.composer.draft = "Explain this"
|
||||
let library = AssistantLibraryModel()
|
||||
_ = library.startConversation(conversation)
|
||||
conversation.sendDraft()
|
||||
library.insertAndSelect(conversation)
|
||||
conversation.attach(
|
||||
ContextChip(kind: .output, label: "Connection error", content: "ssh: refused")
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user