feat(pro): gate multi-connection acceleration without changing saved settings
This commit is contained in:
1 parent
46895e39c6
commit
75a10fbbd0
25 files changed
+678
-63
No files matched your search
@@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindInfrastructure
|
||||
import TermindPresentation
|
||||
import Testing
|
||||
|
||||
@Suite @MainActor
|
||||
struct MultiConnectionSettingsTests {
|
||||
@Test(arguments: SFTPConcurrencyStrategy.allCases)
|
||||
func freeDisplayAndOtherEditsPreserveTheSavedStrategy(_ strategy: SFTPConcurrencyStrategy) {
|
||||
let persistence = SettingsStore(.init(strategy: strategy))
|
||||
let settings = SFTPSettingsModel(persistence: persistence)
|
||||
let pro = ProAccessController.testing(status: .free(trialAvailable: true))
|
||||
let access = MultiConnectionSettingsAccess(isAllowed: false) { pro.require(.sftpMultiConnection) }
|
||||
let selection = access.strategy(for: settings)
|
||||
#expect(selection.wrappedValue == .channelsOnly)
|
||||
#expect(persistence.saves == 0)
|
||||
selection.wrappedValue = .channelsOnly
|
||||
#expect(persistence.saves == 0)
|
||||
selection.wrappedValue = .smart
|
||||
selection.wrappedValue = .connectionsPreferred
|
||||
#expect(pro.isPaywallPresented)
|
||||
#expect(persistence.saves == 0)
|
||||
#expect(settings.set(12, for: \.globalLimit))
|
||||
#expect(persistence.value.strategy == strategy)
|
||||
#expect(persistence.saves == 1)
|
||||
let upgraded = MultiConnectionSettingsAccess(isAllowed: true, requestAccess: {})
|
||||
#expect(upgraded.strategy(for: settings).wrappedValue == strategy)
|
||||
}
|
||||
|
||||
@Test func freeHostEditorSavesOtherFieldsWithoutReplacingItsConnectionCount() {
|
||||
var host = Host(name: "Before", host: "example.invalid", portForwardingMaxConnections: 8)
|
||||
var prompts = 0
|
||||
let saved = Binding(get: { host.portForwardingMaxConnections }, set: { host.portForwardingMaxConnections = $0 })
|
||||
let free = MultiConnectionSettingsAccess(isAllowed: false) { prompts += 1 }.forwardingConnections(saved)
|
||||
#expect(free.wrappedValue == 1)
|
||||
free.wrappedValue = 1
|
||||
free.wrappedValue = 2
|
||||
host.name = "After"
|
||||
#expect(host.portForwardingMaxConnections == 8)
|
||||
#expect(prompts == 1)
|
||||
let paid = MultiConnectionSettingsAccess(isAllowed: true, requestAccess: {}).forwardingConnections(saved)
|
||||
#expect(paid.wrappedValue == 8)
|
||||
paid.wrappedValue = 4
|
||||
#expect(host.portForwardingMaxConnections == 4)
|
||||
}
|
||||
|
||||
@Test(arguments: SFTPConcurrencyStrategy.allCases)
|
||||
func runtimeProjectionPreservesSavedPreferencesWithoutOpeningPaywall(_ strategy: SFTPConcurrencyStrategy) {
|
||||
let saved = SFTPPreferences(globalLimit: 80, perHostLimit: 40, strategy: strategy)
|
||||
let free = ProAccessController.testing(status: .verificationFailed)
|
||||
let effective = free.effectiveSFTPPreferences(saved)
|
||||
#expect(effective == SFTPPreferences(globalLimit: 80, perHostLimit: 40, strategy: .channelsOnly))
|
||||
#expect(free.effectiveForwardingConnections(8) == 1)
|
||||
#expect(!free.isPaywallPresented)
|
||||
let paid = ProAccessController.testing(status: .purchased(.familyShared))
|
||||
#expect(paid.effectiveSFTPPreferences(saved) == saved)
|
||||
#expect(paid.effectiveForwardingConnections(8) == 8)
|
||||
#expect(!paid.isPaywallPresented)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class SettingsStore: SFTPPreferencesPersisting {
|
||||
var value: SFTPPreferences
|
||||
var saves = 0
|
||||
init(_ value: SFTPPreferences) { self.value = value }
|
||||
func loadSFTP() throws -> SFTPPreferences { value }
|
||||
func saveSFTP(_ value: SFTPPreferences) throws {
|
||||
self.value = value
|
||||
saves += 1
|
||||
}
|
||||
}
|
||||
@@ -116,6 +116,23 @@ struct RevenueCatCommerceTests {
|
||||
#expect(!controller.hasProAccess)
|
||||
}
|
||||
|
||||
@Test func accessChangesNotifyRuntimeWithoutAVisibleViewOrPaywall() async {
|
||||
let service = FakeService()
|
||||
let controller = ProAccessController(purchaseService: service, observeUpdates: false)
|
||||
var observed: [Bool] = []
|
||||
controller.accessDidChange = { observed.append(controller.hasProAccess) }
|
||||
defer { controller.accessDidChange = nil }
|
||||
await controller.refresh()
|
||||
service.status = .purchased(.purchased)
|
||||
await controller.refresh()
|
||||
service.status = .purchased(.familyShared)
|
||||
await controller.refresh()
|
||||
service.status = .free(trialAvailable: false)
|
||||
await controller.refresh()
|
||||
#expect(observed == [true, false])
|
||||
#expect(!controller.isPaywallPresented)
|
||||
}
|
||||
|
||||
@Test
|
||||
func newerCustomerInfoWinsOverAnOlderRefreshResponse() async {
|
||||
let service = FakeService()
|
||||
|
||||
@@ -7223,6 +7223,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Connections Preferred (Pro)" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "优先连接(Pro)"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "優先連線(Pro)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Contacting the App Store…" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -15919,6 +15936,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Max Connections (Pro)" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "最大连接数(Pro)"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "連線數上限(Pro)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Max Output" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -26097,6 +26131,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Smart (Pro)" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "智能(Pro)"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "智慧(Pro)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Smart uses channels for files up to 64 MB and prefers additional connections for larger files. Transfers with an unknown size use channels." : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
|
||||
@@ -6,6 +6,8 @@ public nonisolated enum ProFeature: String, CaseIterable, Hashable, Sendable {
|
||||
case mcpServer
|
||||
case vaultSync
|
||||
case unlimitedMetrics
|
||||
case sftpMultiConnection
|
||||
case portForwardingMultiConnection
|
||||
}
|
||||
|
||||
public nonisolated enum ProOwnership: Equatable, Sendable {
|
||||
|
||||
@@ -15,6 +15,8 @@ public final class ProAccessController {
|
||||
public private(set) var status: EntitlementStatus
|
||||
public private(set) var isProcessing = false
|
||||
public var isPaywallPresented = false
|
||||
/// App-owned runtime reconciliation; does not require a visible SwiftUI surface.
|
||||
public var accessDidChange: (() -> Void)?
|
||||
|
||||
public var usesRevenueCat: Bool { purchaseService != nil }
|
||||
public var hasProAccess: Bool { status.hasProAccess }
|
||||
@@ -62,6 +64,17 @@ public final class ProAccessController {
|
||||
|
||||
public func canUse(_: ProFeature) -> Bool { hasProAccess }
|
||||
|
||||
/// Resolve access without changing the user's persisted choice or presenting commerce UI.
|
||||
public func effectiveSFTPPreferences(_ saved: SFTPPreferences) -> SFTPPreferences {
|
||||
var effective = saved
|
||||
if !canUse(.sftpMultiConnection) { effective.strategy = .channelsOnly }
|
||||
return effective
|
||||
}
|
||||
|
||||
public func effectiveForwardingConnections(_ saved: Int) -> Int {
|
||||
canUse(.portForwardingMultiConnection) ? Host.normalizedPortForwardingMaxConnections(saved) : 1
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func require(_ feature: ProFeature) -> Bool {
|
||||
guard !canUse(feature) else { return true }
|
||||
@@ -118,7 +131,9 @@ public final class ProAccessController {
|
||||
}
|
||||
|
||||
private func apply(_ next: EntitlementStatus) {
|
||||
let changed = status.hasProAccess != next.hasProAccess
|
||||
status = next
|
||||
if changed { accessDidChange?() }
|
||||
entitlementExpirationTask?.cancel()
|
||||
if case .subscription(let subscription) = next,
|
||||
subscription.phase.hasProAccess, let expiresAt = subscription.expiresAt, expiresAt > now()
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
|
||||
/// Editing-only projections. Reading a free value never overwrites the saved choice.
|
||||
public struct MultiConnectionSettingsAccess {
|
||||
public var isAllowed: Bool
|
||||
private let requestAccess: () -> Void
|
||||
|
||||
public init(isAllowed: Bool, requestAccess: @escaping () -> Void) {
|
||||
self.isAllowed = isAllowed
|
||||
self.requestAccess = requestAccess
|
||||
}
|
||||
|
||||
public func strategy(for model: SFTPSettingsModel) -> Binding<SFTPConcurrencyStrategy> {
|
||||
Binding(
|
||||
get: { isAllowed ? model.preferences.strategy : .channelsOnly },
|
||||
set: { value in
|
||||
guard isAllowed else {
|
||||
if value != .channelsOnly { requestAccess() }
|
||||
return
|
||||
}
|
||||
model.setStrategy(value)
|
||||
})
|
||||
}
|
||||
|
||||
public func forwardingConnections(_ saved: Binding<Int>) -> Binding<Int> {
|
||||
Binding(
|
||||
get: { isAllowed ? saved.wrappedValue : 1 },
|
||||
set: { value in
|
||||
guard isAllowed else {
|
||||
if value > 1 { requestAccess() }
|
||||
return
|
||||
}
|
||||
saved.wrappedValue = Host.normalizedPortForwardingMaxConnections(value)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,31 +4,35 @@ import TermindDomain
|
||||
|
||||
public struct SFTPSettingsFields: View {
|
||||
let model: SFTPSettingsModel
|
||||
let access: MultiConnectionSettingsAccess
|
||||
|
||||
public init(model: SFTPSettingsModel) { self.model = model }
|
||||
|
||||
public init(
|
||||
model: SFTPSettingsModel,
|
||||
access: MultiConnectionSettingsAccess = .init(isAllowed: true, requestAccess: {})
|
||||
) {
|
||||
self.model = model
|
||||
self.access = access
|
||||
}
|
||||
public var body: some View {
|
||||
Picker("Concurrent Strategy", selection: strategyBinding) {
|
||||
Text("Smart").tag(SFTPConcurrencyStrategy.smart)
|
||||
Text("Channels Only").tag(SFTPConcurrencyStrategy.channelsOnly)
|
||||
Text("Connections Preferred").tag(SFTPConcurrencyStrategy.connectionsPreferred)
|
||||
Picker(AppStrings.SFTP.concurrentStrategy, selection: access.strategy(for: model)) {
|
||||
Text(access.isAllowed ? AppStrings.SFTP.smart : AppStrings.Pro.smartStrategy)
|
||||
.tag(SFTPConcurrencyStrategy.smart)
|
||||
Text(AppStrings.SFTP.channelsOnly).tag(SFTPConcurrencyStrategy.channelsOnly)
|
||||
Text(access.isAllowed ? AppStrings.SFTP.connectionsPreferred : AppStrings.Pro.connectionsPreferredStrategy)
|
||||
.tag(SFTPConcurrencyStrategy.connectionsPreferred)
|
||||
}
|
||||
if model.preferences.strategy == .smart {
|
||||
Text(
|
||||
"Smart uses channels for files up to 64 MB and prefers additional connections for larger files. Transfers with an unknown size use channels."
|
||||
)
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
if access.strategy(for: model).wrappedValue == .smart {
|
||||
Text(AppStrings.SFTP.smartStrategyExplanation)
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Stepper(value: binding(\.globalLimit), in: SFTPPreferences.globalLimitRange) {
|
||||
label("Concurrent Transfers", value: model.preferences.globalLimit)
|
||||
label(AppStrings.SFTP.concurrentTransfers, value: model.preferences.globalLimit)
|
||||
}
|
||||
Stepper(value: binding(\.perHostLimit), in: model.preferences.availablePerHostRange) {
|
||||
label("Per-Host Transfers", value: model.preferences.perHostLimit)
|
||||
label(AppStrings.SFTP.perHostTransfers, value: model.preferences.perHostLimit)
|
||||
}
|
||||
Text(
|
||||
"Changes apply to queued transfers; active transfers continue. If new connections would prompt for authentication, transfers use parallel channels on the existing connection."
|
||||
)
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
Text(AppStrings.SFTP.concurrencyChangesExplanation)
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
if let error = model.storageError { Text(error).foregroundStyle(.red) }
|
||||
}
|
||||
|
||||
@@ -51,7 +55,4 @@ public struct SFTPSettingsFields: View {
|
||||
Binding(get: { model.preferences[keyPath: keyPath] }, set: { model.set($0, for: keyPath) })
|
||||
}
|
||||
|
||||
private var strategyBinding: Binding<SFTPConcurrencyStrategy> {
|
||||
Binding(get: { model.preferences.strategy }, set: { model.setStrategy($0) })
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,10 @@ import Foundation
|
||||
|
||||
extension AppStrings {
|
||||
public nonisolated enum Pro {
|
||||
public static var smartStrategy: LocalizedStringResource { "Smart (Pro)" }
|
||||
public static var connectionsPreferredStrategy: LocalizedStringResource { "Connections Preferred (Pro)" }
|
||||
public static var maxConnections: LocalizedStringResource { "Max Connections (Pro)" }
|
||||
|
||||
public static var contactingTheAppStoreProgress: LocalizedStringResource {
|
||||
"Contacting the App Store…"
|
||||
}
|
||||
|
||||
@@ -2,6 +2,18 @@ import Foundation
|
||||
|
||||
extension AppStrings {
|
||||
public nonisolated enum SFTP {
|
||||
public static var concurrentStrategy: LocalizedStringResource { "Concurrent Strategy" }
|
||||
public static var smart: LocalizedStringResource { "Smart" }
|
||||
public static var channelsOnly: LocalizedStringResource { "Channels Only" }
|
||||
public static var connectionsPreferred: LocalizedStringResource { "Connections Preferred" }
|
||||
public static var concurrentTransfers: LocalizedStringResource { "Concurrent Transfers" }
|
||||
public static var perHostTransfers: LocalizedStringResource { "Per-Host Transfers" }
|
||||
public static var smartStrategyExplanation: LocalizedStringResource {
|
||||
"Smart uses channels for files up to 64 MB and prefers additional connections for larger files. Transfers with an unknown size use channels."
|
||||
}
|
||||
public static var concurrencyChangesExplanation: LocalizedStringResource {
|
||||
"Changes apply to queued transfers; active transfers continue. If new connections would prompt for authentication, transfers use parallel channels on the existing connection."
|
||||
}
|
||||
|
||||
public static var appStoreProgress: LocalizedStringResource {
|
||||
"App Store…"
|
||||
|
||||
@@ -80,7 +80,7 @@ public final class PortForwardingConnection {
|
||||
fileprivate var silentAttempt = false
|
||||
fileprivate var additionalSnapshot: SSHAdditionalConnectionSnapshot?
|
||||
fileprivate var connectionCounts: [PortForwardingRule.ID: Int] = [:]
|
||||
fileprivate let maximumConnections: Int
|
||||
fileprivate var maximumConnections: Int
|
||||
|
||||
fileprivate init(
|
||||
host: Host, route: [Host], maximumConnections: Int, reconnect: SessionReconnectController
|
||||
@@ -100,14 +100,6 @@ public final class PortForwardingController {
|
||||
public nonisolated enum PoolPolicy: Sendable {
|
||||
case singleConnection
|
||||
case hostConfigured
|
||||
|
||||
fileprivate func maximumConnections(for host: Host) -> Int {
|
||||
switch self {
|
||||
case .singleConnection: 1
|
||||
case .hostConfigured:
|
||||
Host.normalizedPortForwardingMaxConnections(host.portForwardingMaxConnections)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public typealias ConnectionFactory =
|
||||
@@ -122,6 +114,8 @@ public final class PortForwardingController {
|
||||
@escaping SSHForwardingConnection.FailureHandler
|
||||
) -> any SSHForwardingConnectionProtocol
|
||||
private let poolPolicy: PoolPolicy
|
||||
private var connectionLimit: Int
|
||||
private var limitUpdateTask: Task<Void, Never>?
|
||||
private let logger: Logger
|
||||
private let makeReconnectController: @MainActor () -> SessionReconnectController
|
||||
public private(set) var states: [PortForwardingRule.ID: PortForwardingRuntimeState] = [:]
|
||||
@@ -135,6 +129,7 @@ public final class PortForwardingController {
|
||||
public init(
|
||||
hosts: HostLibraryModel,
|
||||
poolPolicy: PoolPolicy = .singleConnection,
|
||||
connectionLimit: Int = Host.portForwardingMaxConnectionsRange.upperBound,
|
||||
logger: Logger = ApplicationLogger.disabled("termind.port-forwarding"),
|
||||
makeConnection: ConnectionFactory? = nil,
|
||||
makeReconnectController: @escaping @MainActor () -> SessionReconnectController = {
|
||||
@@ -144,6 +139,7 @@ public final class PortForwardingController {
|
||||
self.hosts = hosts
|
||||
self.logger = logger
|
||||
self.poolPolicy = poolPolicy
|
||||
self.connectionLimit = Host.normalizedPortForwardingMaxConnections(connectionLimit)
|
||||
self.makeReconnectController = makeReconnectController
|
||||
if let makeConnection {
|
||||
self.makeConnection = { _, connectionHandler, failureHandler in
|
||||
@@ -151,7 +147,7 @@ public final class PortForwardingController {
|
||||
}
|
||||
} else {
|
||||
self.makeConnection = { maximumConnections, connectionHandler, failureHandler in
|
||||
if maximumConnections > 1 {
|
||||
if poolPolicy == .hostConfigured {
|
||||
SSHForwardingConnectionPool(
|
||||
maximumConnections: maximumConnections,
|
||||
connectionHandler: connectionHandler, failureHandler: failureHandler, logger: logger)
|
||||
@@ -166,6 +162,30 @@ public final class PortForwardingController {
|
||||
public func state(for id: PortForwardingRule.ID) -> PortForwardingRuntimeState { states[id] ?? .stopped }
|
||||
public func connection(for id: PortForwardingRule.ID) -> PortForwardingConnection? { presentations[id] }
|
||||
|
||||
private func maximumConnections(for host: Host) -> Int {
|
||||
switch poolPolicy {
|
||||
case .singleConnection: 1
|
||||
case .hostConfigured:
|
||||
min(connectionLimit, Host.normalizedPortForwardingMaxConnections(host.portForwardingMaxConnections))
|
||||
}
|
||||
}
|
||||
|
||||
/// The app supplies final connection limits; the transport has no commerce dependency.
|
||||
public func updateConnectionLimit(_ maximum: Int) {
|
||||
connectionLimit = Host.normalizedPortForwardingMaxConnections(maximum)
|
||||
for context in connections.values {
|
||||
if let host = hosts.host(context.hostID) { context.maximumConnections = maximumConnections(for: host) }
|
||||
}
|
||||
let previous = limitUpdateTask
|
||||
limitUpdateTask = Task { [weak self] in
|
||||
await previous?.value
|
||||
guard let self else { return }
|
||||
for context in Array(connections.values) {
|
||||
await context.forwardingConnection?.updateMaximumConnections(context.maximumConnections)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func start(_ id: PortForwardingRule.ID) {
|
||||
guard !state(for: id).isActive, let rule = hosts.portForwarding(id), rule.isValid,
|
||||
let host = hosts.host(rule.hostID), host.sshEnabled
|
||||
@@ -183,7 +203,7 @@ public final class PortForwardingController {
|
||||
} else {
|
||||
context = PortForwardingConnection(
|
||||
host: host, route: hosts.route(for: host),
|
||||
maximumConnections: poolPolicy.maximumConnections(for: host),
|
||||
maximumConnections: maximumConnections(for: host),
|
||||
reconnect: makeReconnectController())
|
||||
connections[host.id] = context
|
||||
}
|
||||
@@ -396,6 +416,7 @@ public final class PortForwardingController {
|
||||
}
|
||||
let attemptSubmissions = silently ? [:] : context.submissions.takeAuthenticationSecrets()
|
||||
let attemptProxySubmissions = silently ? [:] : context.proxySubmissions.takeProxyAuthenticationSecrets()
|
||||
context.maximumConnections = maximumConnections(for: host)
|
||||
let forwardingConnection = makeConnection(
|
||||
context.maximumConnections,
|
||||
{ [weak self, weak context] id, generation, count in
|
||||
@@ -476,6 +497,7 @@ public final class PortForwardingController {
|
||||
context.additionalSnapshot = hosts.additionalConnectionSnapshot(from: configuration)
|
||||
}
|
||||
if let snapshot = context.additionalSnapshot {
|
||||
await forwardingConnection.updateMaximumConnections(context.maximumConnections)
|
||||
await forwardingConnection.configureAdditionalConnections { [weak self] in
|
||||
guard let self else { throw CancellationError() }
|
||||
return try self.hosts.additionalConnectionRequest(from: snapshot)
|
||||
|
||||
@@ -29,6 +29,7 @@ public nonisolated struct SSHForwardingPoolStatus: Hashable, Sendable {
|
||||
public protocol SSHForwardingConnectionProtocol: Actor {
|
||||
func connect(_ request: SSHConnectionRequest, progress: SSHRouteProgressHandler?) async throws
|
||||
func configureAdditionalConnections(_ resolver: @escaping SSHAdditionalConnectionRequestResolver) async
|
||||
func updateMaximumConnections(_ maximum: Int) async
|
||||
func connectionPoolStatus() async -> SSHForwardingPoolStatus
|
||||
func start(_ rule: PortForwardingRule, generation: UUID) async throws
|
||||
func stop(_ id: PortForwardingRule.ID) async
|
||||
@@ -37,6 +38,7 @@ public protocol SSHForwardingConnectionProtocol: Actor {
|
||||
|
||||
extension SSHForwardingConnectionProtocol {
|
||||
public func configureAdditionalConnections(_ resolver: @escaping SSHAdditionalConnectionRequestResolver) async {}
|
||||
public func updateMaximumConnections(_ maximum: Int) async {}
|
||||
public func connectionPoolStatus() async -> SSHForwardingPoolStatus { .single }
|
||||
}
|
||||
|
||||
@@ -77,6 +79,9 @@ public actor SSHForwardingConnection: SSHForwardingConnectionProtocol {
|
||||
await worker.acceptShared(descriptor, ruleID: ruleID, generation: generation)
|
||||
}
|
||||
|
||||
/// Queried only after the pool has stopped assigning flows to this connection.
|
||||
func hasActiveFlows() async -> Bool { await worker.hasActiveFlows() }
|
||||
|
||||
public func stop(_ id: PortForwardingRule.ID) async {
|
||||
await worker.stop(id)
|
||||
}
|
||||
@@ -333,6 +338,12 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
func hasActiveFlows() async -> Bool {
|
||||
await withCheckedContinuation { continuation in
|
||||
queue.async { [self] in continuation.resume(returning: !flows.isEmpty) }
|
||||
}
|
||||
}
|
||||
|
||||
func stop(_ id: PortForwardingRule.ID) async {
|
||||
await withCheckedContinuation { continuation in
|
||||
queue.async { [self] in
|
||||
|
||||
@@ -22,7 +22,7 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
|
||||
let generation: UUID
|
||||
}
|
||||
|
||||
private let maximumConnections: Int
|
||||
private var maximumConnections: Int
|
||||
private let connectionHandler: SSHForwardingConnection.ConnectionHandler
|
||||
private let failureHandler: SSHForwardingConnection.FailureHandler
|
||||
private let logger: Logger
|
||||
@@ -34,6 +34,11 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
|
||||
private var additionalRequest: SSHAdditionalConnectionRequestResolver?
|
||||
private var opening: [UUID: Slot] = [:]
|
||||
private var installing: [UUID: Slot] = [:]
|
||||
private var openingTasks: [UUID: Task<Void, Never>] = [:]
|
||||
private var draining: Set<UUID> = []
|
||||
private var assignments: [UUID: Int] = [:]
|
||||
private var checkingIdle: Set<UUID> = []
|
||||
private var recheckIdle: Set<UUID> = []
|
||||
private var retryTask: Task<Void, Never>?
|
||||
private var stabilityTasks: [UUID: Task<Void, Never>] = [:]
|
||||
private var retryAttempt = 0
|
||||
@@ -46,7 +51,7 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
|
||||
failureHandler: @escaping SSHForwardingConnection.FailureHandler,
|
||||
logger: Logger = ApplicationLogger.disabled("termind.port-forwarding-pool")
|
||||
) {
|
||||
self.maximumConnections = min(max(maximumConnections, 2), Host.portForwardingMaxConnectionsRange.upperBound)
|
||||
self.maximumConnections = Host.normalizedPortForwardingMaxConnections(maximumConnections)
|
||||
self.connectionHandler = connectionHandler
|
||||
self.failureHandler = failureHandler
|
||||
self.logger = logger
|
||||
@@ -87,6 +92,28 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
|
||||
SSHForwardingPoolStatus(connected: slots.count, target: desiredConnections)
|
||||
}
|
||||
|
||||
func updateMaximumConnections(_ maximum: Int) async {
|
||||
maximumConnections = Host.normalizedPortForwardingMaxConnections(maximum)
|
||||
rebalanceSlots()
|
||||
// Pending auxiliaries have no accepted flows and can be cancelled immediately.
|
||||
let pending: [Slot]
|
||||
if slots.count + opening.count + installing.count > desiredConnections {
|
||||
pending = Array(opening.values) + Array(installing.values)
|
||||
opening.removeAll()
|
||||
installing.removeAll()
|
||||
for task in openingTasks.values { task.cancel() }
|
||||
openingTasks.removeAll()
|
||||
} else {
|
||||
pending = []
|
||||
}
|
||||
retryTask?.cancel()
|
||||
retryTask = nil
|
||||
publishPoolCounts()
|
||||
for slot in pending { await slot.connection.disconnect() }
|
||||
for id in Array(draining) { await retireIfIdle(id) }
|
||||
fillPool()
|
||||
}
|
||||
|
||||
func flowDistribution(for ruleID: PortForwardingRule.ID) async -> [Int] {
|
||||
slots.keys.sorted { $0.uuidString < $1.uuidString }.map { counts[$0]?[ruleID] ?? 0 }
|
||||
}
|
||||
@@ -99,6 +126,7 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
|
||||
try await primary.connection.start(rule, generation: generation)
|
||||
primary.rules.insert(rule.id)
|
||||
} else {
|
||||
// Draining slots may be retained again if the limit increases before their flows finish.
|
||||
for slot in slots.values {
|
||||
try await slot.connection.registerShared(rule, generation: generation)
|
||||
slot.rules.insert(rule.id)
|
||||
@@ -133,6 +161,12 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
|
||||
retryTask = nil
|
||||
for task in stabilityTasks.values { task.cancel() }
|
||||
stabilityTasks.removeAll()
|
||||
for task in openingTasks.values { task.cancel() }
|
||||
openingTasks.removeAll()
|
||||
draining.removeAll()
|
||||
assignments.removeAll()
|
||||
checkingIdle.removeAll()
|
||||
recheckIdle.removeAll()
|
||||
let pending = Array(opening.values) + Array(installing.values)
|
||||
opening.removeAll()
|
||||
installing.removeAll()
|
||||
@@ -152,6 +186,40 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
|
||||
private var needsPool: Bool { registrations.values.contains { $0.rule.kind != .remote } }
|
||||
private var desiredConnections: Int { needsPool ? maximumConnections : 1 }
|
||||
|
||||
private func rebalanceSlots() {
|
||||
let auxiliaries = slots.keys.filter { $0 != primaryID }.sorted { $0.uuidString < $1.uuidString }
|
||||
let retained = Set(auxiliaries.prefix(max(0, desiredConnections - 1)))
|
||||
draining = Set(auxiliaries).subtracting(retained)
|
||||
}
|
||||
|
||||
private func retireIfIdle(_ id: UUID) async {
|
||||
guard draining.contains(id), id != primaryID, assignments[id, default: 0] == 0,
|
||||
let slot = slots[id]
|
||||
else { return }
|
||||
guard checkingIdle.insert(id).inserted else {
|
||||
recheckIdle.insert(id)
|
||||
return
|
||||
}
|
||||
let busy = await slot.connection.hasActiveFlows()
|
||||
checkingIdle.remove(id)
|
||||
let needsRecheck = recheckIdle.remove(id) != nil
|
||||
if busy {
|
||||
if needsRecheck { await retireIfIdle(id) }
|
||||
return
|
||||
}
|
||||
// The worker query is authoritative: count callbacks may still be in flight.
|
||||
guard draining.contains(id), id != primaryID, slots[id] === slot,
|
||||
assignments[id, default: 0] == 0
|
||||
else { return }
|
||||
slots[id] = nil
|
||||
counts[id] = nil
|
||||
draining.remove(id)
|
||||
stabilityTasks.removeValue(forKey: id)?.cancel()
|
||||
publishPoolCounts()
|
||||
await slot.connection.disconnect()
|
||||
fillPool()
|
||||
}
|
||||
|
||||
private func makeSlot(id: UUID) -> Slot {
|
||||
let connection = SSHForwardingConnection(
|
||||
connectionHandler: { [weak self] ruleID, generation, count in
|
||||
@@ -173,10 +241,12 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
|
||||
let slot = makeSlot(id: id)
|
||||
opening[id] = slot
|
||||
let resolver = additionalRequest!
|
||||
Task { [weak self] in
|
||||
openingTasks[id] = Task { [weak self] in
|
||||
do {
|
||||
let request = try await resolver()
|
||||
try Task.checkCancellation()
|
||||
try await slot.connection.connect(request, progress: nil)
|
||||
try Task.checkCancellation()
|
||||
await self?.opened(slot)
|
||||
} catch {
|
||||
await self?.openFailed(id: id, error: error)
|
||||
@@ -187,6 +257,7 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
|
||||
}
|
||||
|
||||
private func opened(_ slot: Slot) async {
|
||||
defer { openingTasks[slot.id] = nil }
|
||||
guard opening.removeValue(forKey: slot.id) != nil, !disconnecting,
|
||||
slots.count + installing.count < desiredConnections
|
||||
else {
|
||||
@@ -221,12 +292,15 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
|
||||
}
|
||||
|
||||
private func openFailed(id: UUID, error: any Error) {
|
||||
openingTasks[id] = nil
|
||||
guard opening.removeValue(forKey: id) != nil, !disconnecting else { return }
|
||||
scheduleRetry(for: error)
|
||||
}
|
||||
|
||||
private func scheduleRetry(for error: any Error) {
|
||||
guard !Self.requiresManualRetry(error), retryTask == nil, needsPool else { return }
|
||||
guard !Self.requiresManualRetry(error), retryTask == nil, needsPool,
|
||||
!disconnecting, slots.count + opening.count + installing.count < desiredConnections
|
||||
else { return }
|
||||
let delays = [1, 2, 5, 10, 30]
|
||||
let delay = delays[min(retryAttempt, delays.count - 1)]
|
||||
retryAttempt += 1
|
||||
@@ -258,15 +332,18 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
|
||||
stabilityTasks.removeValue(forKey: slotID)?.cancel()
|
||||
counts[slotID] = nil
|
||||
slots[slotID] = nil
|
||||
draining.remove(slotID)
|
||||
publishPoolCounts()
|
||||
if wasPrimary, let promoted = slots.values.first {
|
||||
primaryID = promoted.id
|
||||
rebalanceSlots()
|
||||
do {
|
||||
for registration in registrations.values where registration.rule.kind == .remote {
|
||||
try await promoted.connection.start(registration.rule, generation: registration.generation)
|
||||
promoted.rules.insert(registration.rule.id)
|
||||
}
|
||||
fillPool()
|
||||
for id in Array(draining) { await retireIfIdle(id) }
|
||||
} catch {
|
||||
failureHandler(error.localizedDescription)
|
||||
}
|
||||
@@ -281,6 +358,8 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
|
||||
private func retireAuxiliaries() async {
|
||||
retryTask?.cancel()
|
||||
retryTask = nil
|
||||
for task in openingTasks.values { task.cancel() }
|
||||
openingTasks.removeAll()
|
||||
let pending = Array(opening.values) + Array(installing.values)
|
||||
opening.removeAll()
|
||||
installing.removeAll()
|
||||
@@ -289,6 +368,7 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
|
||||
for slot in auxiliaries {
|
||||
slots[slot.id] = nil
|
||||
counts[slot.id] = nil
|
||||
draining.remove(slot.id)
|
||||
stabilityTasks.removeValue(forKey: slot.id)?.cancel()
|
||||
}
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
@@ -302,7 +382,7 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
|
||||
Darwin.close(descriptor)
|
||||
return
|
||||
}
|
||||
let eligible = slots.values.filter { $0.rules.contains(ruleID) }
|
||||
let eligible = slots.values.filter { $0.rules.contains(ruleID) && !draining.contains($0.id) }
|
||||
guard !eligible.isEmpty else {
|
||||
Darwin.close(descriptor)
|
||||
return
|
||||
@@ -313,16 +393,21 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
|
||||
}
|
||||
let slot = candidates[roundRobin % candidates.count]
|
||||
roundRobin &+= 1
|
||||
guard await slot.connection.acceptShared(descriptor, ruleID: ruleID, generation: generation) else {
|
||||
assignments[slot.id, default: 0] += 1
|
||||
let accepted = await slot.connection.acceptShared(descriptor, ruleID: ruleID, generation: generation)
|
||||
assignments[slot.id, default: 1] -= 1
|
||||
if assignments[slot.id] == 0 { assignments[slot.id] = nil }
|
||||
if !accepted {
|
||||
Darwin.close(descriptor)
|
||||
return
|
||||
}
|
||||
await retireIfIdle(slot.id)
|
||||
}
|
||||
|
||||
private func receivedCount(_ count: Int, ruleID: UUID, generation: UUID, slotID: UUID) {
|
||||
private func receivedCount(_ count: Int, ruleID: UUID, generation: UUID, slotID: UUID) async {
|
||||
guard registrations[ruleID]?.generation == generation, slots[slotID] != nil else { return }
|
||||
counts[slotID, default: [:]][ruleID] = count
|
||||
report(ruleID, generation: generation)
|
||||
if count == 0 { await retireIfIdle(slotID) }
|
||||
}
|
||||
|
||||
private func report(_ ruleID: UUID, generation: UUID?) {
|
||||
|
||||
@@ -159,8 +159,11 @@ public final class SFTPTransferCoordinator {
|
||||
globalLimit = limits.globalLimit
|
||||
perHostLimit = limits.perHostLimit
|
||||
strategy = limits.strategy
|
||||
preferencesUpdateTask?.cancel()
|
||||
preferencesUpdateTask = Task { await concurrencyScheduler.updatePreferences(limits) }
|
||||
let previous = preferencesUpdateTask
|
||||
preferencesUpdateTask = Task {
|
||||
await previous?.value
|
||||
await concurrencyScheduler.updatePreferences(limits)
|
||||
}
|
||||
schedule()
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ struct MobileAppDependencies {
|
||||
var workspaceConnections: MobileWorkspaceConnectionServices
|
||||
var metrics: MobileMetricsServices
|
||||
var appReview: AppReviewController
|
||||
var proAccess: ProAccessController?
|
||||
var diagnosis = DiagnosisController()
|
||||
var logging: any ApplicationLogManaging = DisabledApplicationLogManager.shared
|
||||
var connectionCoordinator: RemoteConnectionCoordinator = RemoteConnectionCoordinator()
|
||||
|
||||
@@ -217,7 +217,7 @@ final class MobileAppModel {
|
||||
self.defaults = inMemory || hosts != nil ? nil : defaults
|
||||
snippetTimestampFallback = MobileSnippetTimestamp.fallback(in: self.defaults)
|
||||
liveActivities = dependencies.liveActivities ?? MobileLiveActivityController(enabled: !inMemory && hosts == nil)
|
||||
pro = inMemory || hosts != nil ? .unrestrictedForTesting() : .live()
|
||||
pro = dependencies.proAccess ?? (inMemory || hosts != nil ? .unrestrictedForTesting() : .live())
|
||||
let persistence = HostPersistence(context: vaultContext)
|
||||
_ = try persistence.load()
|
||||
let secrets: any SecretStoring =
|
||||
@@ -316,7 +316,8 @@ final class MobileAppModel {
|
||||
service: AppleSpeechTranscriptionService(audioInput: speechAudio)
|
||||
)
|
||||
speechAudio.onInterruption = { [weak speech] in speech?.cancelAll() }
|
||||
let sftpConcurrencyScheduler = SFTPConcurrencyScheduler(preferences: sftpSettings.preferences)
|
||||
let sftpPreferences = pro.effectiveSFTPPreferences(sftpSettings.preferences)
|
||||
let sftpConcurrencyScheduler = SFTPConcurrencyScheduler(preferences: sftpPreferences)
|
||||
assistant = MobileAssistantModel(
|
||||
configuration: ai,
|
||||
repository: MemoryIndexedAgentRepository(
|
||||
@@ -342,9 +343,9 @@ final class MobileAppModel {
|
||||
forwardings = dependencies.makeForwardings(library)
|
||||
transfers = SFTPTransferCoordinator(
|
||||
store: library, clientFactory: { connectionCoordinator.makeSFTPClient() },
|
||||
globalLimit: sftpSettings.preferences.globalLimit,
|
||||
perHostLimit: sftpSettings.preferences.perHostLimit,
|
||||
strategy: sftpSettings.preferences.strategy,
|
||||
globalLimit: sftpPreferences.globalLimit,
|
||||
perHostLimit: sftpPreferences.perHostLimit,
|
||||
strategy: sftpPreferences.strategy,
|
||||
concurrencyScheduler: sftpConcurrencyScheduler)
|
||||
sftpEdits = SFTPEditService(transfers: transfers, host: { [weak library] in library?.host($0) })
|
||||
library.willDeleteHost = { [weak sftpEdits, weak forwardings] hostID, _ in
|
||||
@@ -391,8 +392,13 @@ final class MobileAppModel {
|
||||
connectionHistorySync?.didApplyRemote = { [weak self] _ in self?.connectionHistory.reload() }
|
||||
deviceSync?.didApplyRemote = { [weak self] _ in self?.devices.reload() }
|
||||
vaultSync?.didApplyRemote = { [weak self] in self?.applyRemoteVault($0, keys: $1) }
|
||||
sftpSettings.didChange = { [weak transfers] preferences in
|
||||
transfers?.updatePreferences(preferences)
|
||||
sftpSettings.didChange = { [weak transfers, weak pro] preferences in
|
||||
guard let pro else { return }
|
||||
transfers?.updatePreferences(pro.effectiveSFTPPreferences(preferences))
|
||||
}
|
||||
pro.accessDidChange = { [weak self] in
|
||||
guard let self else { return }
|
||||
transfers.updatePreferences(pro.effectiveSFTPPreferences(sftpSettings.preferences))
|
||||
}
|
||||
sshSettings.didChange = { preferences in
|
||||
connectionCoordinator.updateSSHPreferences(preferences)
|
||||
|
||||
@@ -5,11 +5,12 @@ import TermindPresentation
|
||||
struct MobileConnectionSettingsView: View {
|
||||
let ssh: SSHSettingsModel
|
||||
let sftp: SFTPSettingsModel
|
||||
var sftpAccess = MultiConnectionSettingsAccess(isAllowed: true, requestAccess: {})
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section(AppStrings.Vault.ssh.swiftUIKey) { SSHSettingsFields(model: ssh) }
|
||||
Section(AppStrings.SFTP.sftp.swiftUIKey) { SFTPSettingsFields(model: sftp) }
|
||||
Section(AppStrings.SFTP.sftp.swiftUIKey) { SFTPSettingsFields(model: sftp, access: sftpAccess) }
|
||||
}
|
||||
.navigationTitle(AppStrings.Settings.connections.swiftUIKey)
|
||||
}
|
||||
|
||||
@@ -41,7 +41,11 @@ struct MobileSettingsView: View {
|
||||
Label(AppStrings.Settings.terminal.swiftUIKey, systemImage: "terminal")
|
||||
}
|
||||
NavigationLink {
|
||||
MobileConnectionSettingsView(ssh: app.sshSettings, sftp: app.sftpSettings)
|
||||
MobileConnectionSettingsView(
|
||||
ssh: app.sshSettings, sftp: app.sftpSettings,
|
||||
sftpAccess: .init(isAllowed: app.pro.canUse(.sftpMultiConnection)) {
|
||||
app.pro.require(.sftpMultiConnection)
|
||||
})
|
||||
} label: {
|
||||
Label(AppStrings.Settings.connections.swiftUIKey, systemImage: "network")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import TermindDomain
|
||||
import Testing
|
||||
|
||||
@testable import TermindIOS
|
||||
@testable import TermindInfrastructure
|
||||
|
||||
@Suite @MainActor
|
||||
struct MobileMultiConnectionAccessTests {
|
||||
@Test(arguments: SFTPConcurrencyStrategy.allCases)
|
||||
func entitlementChangesPreserveSavedSettingsWithoutAnyScene(_ strategy: SFTPConcurrencyStrategy) async throws {
|
||||
let service = MobileConnectionPurchaseService()
|
||||
let pro = ProAccessController(purchaseService: service, observeUpdates: false)
|
||||
var dependencies = MobileAppDependencies.testing()
|
||||
dependencies.proAccess = pro
|
||||
let app = try MobileAppModel(dependencies: dependencies)
|
||||
app.sftpSettings.setStrategy(strategy)
|
||||
#expect(app.transfers.strategy == .channelsOnly)
|
||||
service.status = .purchased(.purchased)
|
||||
await pro.refresh()
|
||||
#expect(app.transfers.strategy == strategy)
|
||||
service.status = .free(trialAvailable: false)
|
||||
await pro.refresh()
|
||||
#expect(app.transfers.strategy == .channelsOnly)
|
||||
#expect(app.sftpSettings.preferences.strategy == strategy)
|
||||
app.sftpSettings.set(60, for: \.globalLimit)
|
||||
#expect(app.transfers.globalLimit == 60)
|
||||
#expect(app.transfers.strategy == .channelsOnly)
|
||||
#expect(!pro.isPaywallPresented)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MobileConnectionPurchaseService: ProPurchaseService {
|
||||
let updates = AsyncStream<EntitlementStatus> { $0.finish() }
|
||||
var status = EntitlementStatus.free(trialAvailable: false)
|
||||
func currentStatus() async throws -> EntitlementStatus { status }
|
||||
}
|
||||
+16
-6
@@ -244,7 +244,8 @@ final class AppModel {
|
||||
store: dependencies.assistantModelSelectionStore ?? InMemoryAssistantModelSelectionStore())
|
||||
assistantChatCollaborationStore =
|
||||
dependencies.assistantChatCollaborationStore ?? InMemoryAssistantChatCollaborationStore()
|
||||
let sftpConcurrencyScheduler = SFTPConcurrencyScheduler(preferences: sftpSettings.preferences)
|
||||
let sftpPreferences = proAccess.effectiveSFTPPreferences(sftpSettings.preferences)
|
||||
let sftpConcurrencyScheduler = SFTPConcurrencyScheduler(preferences: sftpPreferences)
|
||||
let agentHostServices = AgentHostServices.live(
|
||||
store: hosts,
|
||||
coordinator: connectionCoordinator,
|
||||
@@ -261,13 +262,15 @@ final class AppModel {
|
||||
metricsSync = dependencies.metricsSync
|
||||
forwarding = PortForwardingController(
|
||||
hosts: hosts, poolPolicy: .hostConfigured,
|
||||
connectionLimit: proAccess.effectiveForwardingConnections(
|
||||
Host.portForwardingMaxConnectionsRange.upperBound),
|
||||
logger: dependencies.logging.makeLogger(label: "termind.port-forwarding"))
|
||||
sftpTransfers = SFTPTransferCoordinator(
|
||||
store: hosts,
|
||||
clientFactory: { connectionCoordinator.makeSFTPClient() },
|
||||
globalLimit: sftpSettings.preferences.globalLimit,
|
||||
perHostLimit: sftpSettings.preferences.perHostLimit,
|
||||
strategy: sftpSettings.preferences.strategy,
|
||||
globalLimit: sftpPreferences.globalLimit,
|
||||
perHostLimit: sftpPreferences.perHostLimit,
|
||||
strategy: sftpPreferences.strategy,
|
||||
concurrencyScheduler: sftpConcurrencyScheduler
|
||||
)
|
||||
sftpEdits = SFTPEditService(transfers: sftpTransfers, host: { [weak hosts] in hosts?.host($0) })
|
||||
@@ -374,8 +377,15 @@ final class AppModel {
|
||||
vaultSync?.didApplyRemote = { [weak self] snapshot, keys in
|
||||
self?.applyRemoteVaultSnapshot(snapshot, changedKeys: keys)
|
||||
}
|
||||
sftpSettings.didChange = { [weak sftpTransfers] preferences in
|
||||
sftpTransfers?.updatePreferences(preferences)
|
||||
sftpSettings.didChange = { [weak sftpTransfers, weak proAccess] preferences in
|
||||
guard let proAccess else { return }
|
||||
sftpTransfers?.updatePreferences(proAccess.effectiveSFTPPreferences(preferences))
|
||||
}
|
||||
proAccess.accessDidChange = { [weak self] in
|
||||
guard let self else { return }
|
||||
self.sftpTransfers.updatePreferences(self.proAccess.effectiveSFTPPreferences(self.sftpSettings.preferences))
|
||||
self.forwarding.updateConnectionLimit(
|
||||
self.proAccess.effectiveForwardingConnections(Host.portForwardingMaxConnectionsRange.upperBound))
|
||||
}
|
||||
sshSettings.didChange = { preferences in
|
||||
connectionCoordinator.updateSSHPreferences(preferences)
|
||||
|
||||
@@ -295,13 +295,15 @@ struct HostEditorView: View {
|
||||
|
||||
Section(AppStrings.Navigation.portForwarding.swiftUIKey) {
|
||||
Stepper(
|
||||
value: $draft.portForwardingMaxConnections,
|
||||
value: forwardingConnections,
|
||||
in: Host.portForwardingMaxConnectionsRange
|
||||
) {
|
||||
HStack {
|
||||
Text(AppStrings.Vault.maxConnections)
|
||||
Text(
|
||||
app.proAccess.canUse(.portForwardingMultiConnection)
|
||||
? AppStrings.Vault.maxConnections : AppStrings.Pro.maxConnections)
|
||||
Spacer()
|
||||
Text(draft.portForwardingMaxConnections, format: .number).monospacedDigit()
|
||||
Text(forwardingConnections.wrappedValue, format: .number).monospacedDigit()
|
||||
}
|
||||
}
|
||||
Text(AppStrings.Vault.forwardingPoolExplanation)
|
||||
@@ -515,6 +517,12 @@ struct HostEditorView: View {
|
||||
|
||||
// MARK: - Picking, and making
|
||||
|
||||
private var forwardingConnections: Binding<Int> {
|
||||
MultiConnectionSettingsAccess(isAllowed: app.proAccess.canUse(.portForwardingMultiConnection)) {
|
||||
app.proAccess.require(.portForwardingMultiConnection)
|
||||
}.forwardingConnections($draft.portForwardingMaxConnections)
|
||||
}
|
||||
|
||||
/// What a picker row means: nothing, one of the saved records, or the row
|
||||
/// that makes a new one.
|
||||
///
|
||||
|
||||
@@ -583,7 +583,13 @@ private struct ConnectionSettings: View {
|
||||
|
||||
var body: some View {
|
||||
Section(AppStrings.Vault.ssh.swiftUIKey) { SSHSettingsFields(model: app.sshSettings) }
|
||||
Section(AppStrings.SFTP.sftp.swiftUIKey) { SFTPSettingsFields(model: app.sftpSettings) }
|
||||
Section(AppStrings.SFTP.sftp.swiftUIKey) {
|
||||
SFTPSettingsFields(
|
||||
model: app.sftpSettings,
|
||||
access: .init(isAllowed: app.proAccess.canUse(.sftpMultiConnection)) {
|
||||
app.proAccess.require(.sftpMultiConnection)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,23 @@ import struct TermindDomain.Host
|
||||
|
||||
@Suite @MainActor
|
||||
struct PortForwardingReconnectTests {
|
||||
@Test func numericLimitsUpdateLiveConnectionsAndSurviveRecoveryWithoutChangingHosts() async throws {
|
||||
let fixture = ReconnectFixture(poolPolicy: .hostConfigured, connectionLimit: 1, hostConnections: 8)
|
||||
defer { fixture.stop() }
|
||||
try await fixture.start([0, 1, 2])
|
||||
#expect(await fixture.sessions[0].maximumConnections == 1)
|
||||
fixture.controller.updateConnectionLimit(64)
|
||||
try await eventually { await fixture.sessions[0].maximumConnections == 8 }
|
||||
fixture.controller.updateConnectionLimit(1)
|
||||
try await eventually { await fixture.sessions[0].maximumConnections == 1 }
|
||||
#expect(fixture.library.host(fixture.host.id)?.portForwardingMaxConnections == 8)
|
||||
await fixture.sessions[0].fail()
|
||||
try await fixture.retry()
|
||||
try await fixture.waitForRunning([0, 1, 2])
|
||||
#expect(await fixture.sessions[1].maximumConnections == 1)
|
||||
#expect(!fixture.context!.journey.isPresented)
|
||||
}
|
||||
|
||||
@Test func compactLabelsHideConnectionCountsAndFailureDetails() {
|
||||
let running = PortForwardingRuntimeState.running(
|
||||
activeConnections: 37, sshConnections: 3, targetSSHConnections: 8)
|
||||
@@ -376,6 +393,8 @@ private final class ReconnectFixture {
|
||||
var sessions: [ReconnectSession] = []
|
||||
var nextError: TermindSSHError?
|
||||
let modes: [ReconnectSession.Mode]
|
||||
let poolPolicy: PortForwardingController.PoolPolicy
|
||||
let connectionLimit: Int
|
||||
lazy var controller = makeController()
|
||||
|
||||
private func makeController() -> PortForwardingController {
|
||||
@@ -387,17 +406,25 @@ private final class ReconnectFixture {
|
||||
return session
|
||||
}
|
||||
return PortForwardingController(
|
||||
hosts: library, makeConnection: makeConnection,
|
||||
hosts: library, poolPolicy: poolPolicy, connectionLimit: connectionLimit, makeConnection: makeConnection,
|
||||
makeReconnectController: { [gate] in
|
||||
SessionReconnectController(policy: .sessionDefault, sleep: { await gate.sleep($0) }, now: Date.init)
|
||||
})
|
||||
}
|
||||
var context: PortForwardingConnection? { controller.connection(for: rules[0].id) }
|
||||
|
||||
init(modes: [ReconnectSession.Mode] = [], usesJump: Bool = false, trusted: Bool = true) {
|
||||
init(
|
||||
modes: [ReconnectSession.Mode] = [], usesJump: Bool = false, trusted: Bool = true,
|
||||
poolPolicy: PortForwardingController.PoolPolicy = .singleConnection, connectionLimit: Int = 64,
|
||||
hostConnections: Int = 1
|
||||
) {
|
||||
self.modes = modes
|
||||
self.poolPolicy = poolPolicy
|
||||
self.connectionLimit = connectionLimit
|
||||
jump = Host(name: "Jump", host: "jump.invalid", identityID: identity.id)
|
||||
host = Host(name: "Host", host: "host.invalid", identityID: identity.id, jumpHostID: usesJump ? jump.id : nil)
|
||||
host = Host(
|
||||
name: "Host", host: "host.invalid", identityID: identity.id, jumpHostID: usesJump ? jump.id : nil,
|
||||
portForwardingMaxConnections: hostConnections)
|
||||
rules = [
|
||||
PortForwardingRule(hostID: host.id, kind: .local, bindPort: 8000),
|
||||
PortForwardingRule(hostID: host.id, kind: .remote, bindPort: 9000),
|
||||
@@ -482,6 +509,8 @@ private actor ReconnectSession: SSHForwardingConnectionProtocol {
|
||||
var generations: [UUID: UUID] = [:]
|
||||
var hasProgress = false
|
||||
var disconnects = 0
|
||||
var maximumConnections = 1
|
||||
func updateMaximumConnections(_ maximum: Int) async { maximumConnections = maximum }
|
||||
private var connectGate: CheckedContinuation<Void, Never>?
|
||||
private var disconnectGate: CheckedContinuation<Void, Never>?
|
||||
private var pendingStarts: [UUID: CheckedContinuation<Void, any Error>] = [:]
|
||||
|
||||
@@ -5,6 +5,30 @@ import Testing
|
||||
|
||||
@Suite
|
||||
struct SFTPConcurrencySchedulerTests {
|
||||
@Test func finalPreferencesAffectQueuedWorkWithoutChangingGrantedPermits() async throws {
|
||||
let scheduler = SFTPConcurrencyScheduler(
|
||||
preferences: .init(globalLimit: 1, perHostLimit: 1, strategy: .connectionsPreferred))
|
||||
let host = UUID()
|
||||
let first = try await scheduler.acquire(
|
||||
anchorHostID: host, hostIDs: [host], source: .workspace(UUID()), byteCount: 80_000_000)
|
||||
let queued = Task {
|
||||
try await scheduler.acquire(
|
||||
anchorHostID: host, hostIDs: [host], source: .agentRun(UUID()), byteCount: 80_000_000)
|
||||
}
|
||||
try await eventually { await scheduler.queuedCount == 1 }
|
||||
await scheduler.updatePreferences(.init(globalLimit: 1, perHostLimit: 1, strategy: .channelsOnly))
|
||||
#expect(first.schedulingMode == .connectionsPreferred)
|
||||
await first.release()
|
||||
let next = try await queued.value
|
||||
#expect(next.schedulingMode == .channelsOnly)
|
||||
await next.release()
|
||||
await scheduler.updatePreferences(.init(strategy: .smart))
|
||||
let upgraded = try await scheduler.acquire(
|
||||
anchorHostID: host, hostIDs: [host], source: .workspace(UUID()), byteCount: 80_000_000)
|
||||
#expect(upgraded.schedulingMode == .connectionsPreferred)
|
||||
await upgraded.release()
|
||||
}
|
||||
|
||||
@Test func smartResolvesTheSixtyFourMBBoundaryAndUnknownSizeToSessions() async throws {
|
||||
let scheduler = SFTPConcurrencyScheduler(
|
||||
preferences: SFTPPreferences(globalLimit: 3, perHostLimit: 3, strategy: .smart))
|
||||
|
||||
@@ -2,12 +2,88 @@ import AppKit
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindInfrastructure
|
||||
import TermindPresentation
|
||||
import Testing
|
||||
|
||||
@testable import Termind
|
||||
@testable import TermindInfrastructure
|
||||
|
||||
@Suite(.serialized) @MainActor
|
||||
struct SFTPSettingsIntegrationTests {
|
||||
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
|
||||
func freeSettingsRenderOffscreenWithoutMutatingTheSavedChoice(language: String) async throws {
|
||||
let settings = SFTPSettingsModel()
|
||||
settings.setStrategy(.connectionsPreferred)
|
||||
let view = Form {
|
||||
SFTPSettingsFields(model: settings, access: .init(isAllowed: false, requestAccess: {}))
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.environment(\.locale, Locale(identifier: language))
|
||||
.frame(width: 620, height: 340)
|
||||
let host = NSHostingView(rootView: view)
|
||||
host.frame = NSRect(x: 0, y: 0, width: 620, height: 340)
|
||||
host.appearance = NSAppearance(named: .aqua)
|
||||
host.layoutSubtreeIfNeeded()
|
||||
try await Task.sleep(for: .milliseconds(100))
|
||||
let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds))
|
||||
host.cacheDisplay(in: host.bounds, to: bitmap)
|
||||
let data = try #require(bitmap.representation(using: .png, properties: [:]))
|
||||
Attachment.record(data, named: "free-sftp-settings-\(language).png")
|
||||
#expect(data.count > 2_000)
|
||||
#expect(settings.preferences.strategy == .connectionsPreferred)
|
||||
}
|
||||
|
||||
@Test(arguments: SFTPConcurrencyStrategy.allCases)
|
||||
func purchaseRestoresSavedStrategyWithoutOpeningSettings(_ strategy: SFTPConcurrencyStrategy) async {
|
||||
let service = ConnectionPurchaseService()
|
||||
let pro = ProAccessController(purchaseService: service, observeUpdates: false)
|
||||
let settings = SFTPSettingsModel()
|
||||
settings.setStrategy(strategy)
|
||||
let app = AppModel(hosts: HostLibraryModel(), sftpSettings: settings, proAccess: pro)
|
||||
defer { app.shutdownForTermination() }
|
||||
#expect(app.sftpTransfers.strategy == .channelsOnly)
|
||||
settings.set(60, for: \.globalLimit)
|
||||
service.status = .purchased(.purchased)
|
||||
await pro.refresh()
|
||||
#expect(app.sftpTransfers.strategy == strategy)
|
||||
#expect(app.sftpTransfers.globalLimit == 60)
|
||||
#expect(settings.preferences.strategy == strategy)
|
||||
#expect(!pro.isPaywallPresented)
|
||||
}
|
||||
@Test(arguments: SFTPConcurrencyStrategy.allCases)
|
||||
func losingAccessChangesRuntimeWithoutRewritingPreferencesOrOpeningPaywall(_ strategy: SFTPConcurrencyStrategy)
|
||||
async
|
||||
{
|
||||
let settings = SFTPSettingsModel()
|
||||
settings.setStrategy(strategy)
|
||||
let pro = ProAccessController.testing(status: .purchased(.purchased))
|
||||
let app = AppModel(hosts: HostLibraryModel(), sftpSettings: settings, proAccess: pro)
|
||||
defer { app.shutdownForTermination() }
|
||||
#expect(app.sftpTransfers.strategy == strategy)
|
||||
await pro.refresh()
|
||||
#expect(app.sftpTransfers.strategy == .channelsOnly)
|
||||
#expect(settings.preferences.strategy == strategy)
|
||||
#expect(!pro.isPaywallPresented)
|
||||
settings.set(50, for: \.globalLimit)
|
||||
#expect(app.sftpTransfers.strategy == .channelsOnly)
|
||||
#expect(app.sftpTransfers.globalLimit == 50)
|
||||
#expect(settings.preferences.strategy == strategy)
|
||||
}
|
||||
|
||||
@Test func freeStartupAndSyncedPreferencesUseOnlyTheEffectiveStrategy() {
|
||||
let settings = SFTPSettingsModel()
|
||||
let pro = ProAccessController.testing(status: .free(trialAvailable: true))
|
||||
let app = AppModel(hosts: HostLibraryModel(), sftpSettings: settings, proAccess: pro)
|
||||
defer { app.shutdownForTermination() }
|
||||
#expect(settings.preferences.strategy == .smart)
|
||||
#expect(app.sftpTransfers.strategy == .channelsOnly)
|
||||
settings.applyRemoteSnapshot(.init(strategy: .connectionsPreferred))
|
||||
#expect(settings.preferences.strategy == .connectionsPreferred)
|
||||
#expect(app.sftpTransfers.strategy == .channelsOnly)
|
||||
#expect(!pro.isPaywallPresented)
|
||||
}
|
||||
|
||||
@Test func localAndRemoteChangesReachTheQueueWithoutOpeningSettings() {
|
||||
let settings = SFTPSettingsModel()
|
||||
let app = AppModel(hosts: HostLibraryModel(), sftpSettings: settings)
|
||||
@@ -60,3 +136,10 @@ struct SFTPSettingsIntegrationTests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class ConnectionPurchaseService: ProPurchaseService {
|
||||
let updates = AsyncStream<EntitlementStatus> { $0.finish() }
|
||||
var status = EntitlementStatus.free(trialAvailable: false)
|
||||
func currentStatus() async throws -> EntitlementStatus { status }
|
||||
}
|
||||
@@ -9,6 +9,76 @@ import Testing
|
||||
/// Uses the loopback Paramiko or forwarding-only OpenSSH fixture in Scripts/.
|
||||
@Suite(.enabled(if: ProcessInfo.processInfo.environment["TERMIND_SSH_FIXTURE"] != nil), .serialized)
|
||||
nonisolated struct SSHForwardingWireTests {
|
||||
@Test func poolShrinksWithoutClosingExistingFlowsAndCanExpandAgain() async throws {
|
||||
let fixture = try fixture()
|
||||
let pool = SSHForwardingConnectionPool(
|
||||
maximumConnections: 1, connectionHandler: { _, _, _ in },
|
||||
failureHandler: { Issue.record(Comment(rawValue: $0)) })
|
||||
try await pool.connect(request(fixture, jumps: 0), progress: nil)
|
||||
await pool.configureAdditionalConnections { request(fixture, jumps: 0).enforcingInteractionPolicy() }
|
||||
let local = try rule(.local, fixture: fixture)
|
||||
try await pool.start(local, generation: UUID())
|
||||
defer { Task { await pool.disconnect() } }
|
||||
#expect(await pool.connectionPoolStatus() == .single)
|
||||
await pool.updateMaximumConnections(3)
|
||||
try await waitForPool(pool, connected: 3, target: 3)
|
||||
var clients: [Int32] = []
|
||||
defer { for client in clients { Darwin.close(client) } }
|
||||
for _ in 0..<12 { clients.append(try SSHSocketConnector.open(host: "127.0.0.1", port: local.bindPort)) }
|
||||
for _ in 0..<1_000 {
|
||||
if await pool.flowDistribution(for: local.id).reduce(0, +) == clients.count { break }
|
||||
try await Task.sleep(for: .milliseconds(5))
|
||||
}
|
||||
#expect(await pool.flowDistribution(for: local.id).filter { $0 > 0 }.count > 1)
|
||||
await pool.updateMaximumConnections(1)
|
||||
#expect(await pool.connectionPoolStatus().target == 1)
|
||||
// Every established TCP stream must still pass data after the downgrade.
|
||||
for client in clients {
|
||||
var sent: UInt8 = 42
|
||||
#expect(Darwin.write(client, &sent, 1) == 1)
|
||||
var timeout = timeval(tv_sec: 5, tv_usec: 0)
|
||||
setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout<timeval>.size))
|
||||
var received: UInt8 = 0
|
||||
#expect(Darwin.read(client, &received, 1) == 1)
|
||||
#expect(received == sent)
|
||||
}
|
||||
// A new stream still works on the retained connection while auxiliaries drain.
|
||||
try await exchange(port: local.bindPort, target: nil, seed: 72, rounds: 2)
|
||||
let addedWhileDraining = try rule(.local, fixture: fixture)
|
||||
try await pool.start(addedWhileDraining, generation: UUID())
|
||||
await pool.updateMaximumConnections(3)
|
||||
try await waitForPool(pool, connected: 3, target: 3)
|
||||
var addedClients: [Int32] = []
|
||||
defer { for client in addedClients { Darwin.close(client) } }
|
||||
for _ in 0..<12 {
|
||||
addedClients.append(try SSHSocketConnector.open(host: "127.0.0.1", port: addedWhileDraining.bindPort))
|
||||
}
|
||||
for _ in 0..<1_000 {
|
||||
if await pool.flowDistribution(for: addedWhileDraining.id).filter({ $0 > 0 }).count > 1 { break }
|
||||
try await Task.sleep(for: .milliseconds(5))
|
||||
}
|
||||
#expect(await pool.flowDistribution(for: addedWhileDraining.id).filter { $0 > 0 }.count > 1)
|
||||
for client in addedClients { Darwin.close(client) }
|
||||
addedClients.removeAll()
|
||||
await pool.updateMaximumConnections(1)
|
||||
for client in clients { Darwin.close(client) }
|
||||
clients.removeAll()
|
||||
try await waitForPool(pool, connected: 1, target: 1)
|
||||
await pool.updateMaximumConnections(3)
|
||||
try await waitForPool(pool, connected: 3, target: 3)
|
||||
await pool.updateMaximumConnections(1)
|
||||
try await waitForPool(pool, connected: 1, target: 1)
|
||||
}
|
||||
|
||||
private func waitForPool(_ pool: SSHForwardingConnectionPool, connected: Int, target: Int) async throws {
|
||||
let expected = SSHForwardingPoolStatus(connected: connected, target: target)
|
||||
for _ in 0..<1_000 {
|
||||
if await pool.connectionPoolStatus() == expected { return }
|
||||
try await Task.sleep(for: .milliseconds(5))
|
||||
}
|
||||
#expect(await pool.connectionPoolStatus() == expected)
|
||||
}
|
||||
|
||||
@Test(arguments: [PortForwardingRule.Kind.local, .dynamic])
|
||||
func disconnectReleasesThePortBeforeReturningEvenWithEstablishedStreams(_ kind: PortForwardingRule.Kind)
|
||||
async throws
|
||||
|
||||
Reference in new issue
Block a user