diff --git a/FrameworkTests/HostAccessTests.swift b/FrameworkTests/HostAccessTests.swift index 8cc42d22..3bc9d36d 100644 --- a/FrameworkTests/HostAccessTests.swift +++ b/FrameworkTests/HostAccessTests.swift @@ -17,6 +17,165 @@ struct HostAccessTests { } } + @Test(arguments: [EntitlementStatus.free, .loading, .verificationFailed]) + func existentialSSHAdmissionUsesThePolicyForBothTargetsAndJumps(status: EntitlementStatus) throws { + var hosts = fixtures(4) + hosts[0].telnetEnabled = true + hosts[0].jumpHostID = hosts[3].id + let library = HostLibraryModel(hosts: hosts) + library.hostAccess.entitlement = status + let store: any RemoteSessionHostStore = library + let expected: HostAccessError = + status == .free + ? .lockedHost(hosts[3].id) + : (status == .loading ? .verifyingEntitlement : .entitlementVerificationFailed) + for host in [hosts[0], hosts[3]] { + #expect(throws: expected) { try store.requireHostAccess(host) } + #expect(throws: expected) { try store.requireHostAccess(host, protocol: .ssh) } + } + try store.requireHostAccess(hosts[0], protocol: .telnet) + } + + @Test(arguments: [false, true]) + func existentialSSHResolutionWaitsForTheConcreteEntitlementResolver(lockedJump: Bool) async throws { + var hosts = fixtures(4) + hosts[0].jumpHostID = hosts[3].id + let library = HostLibraryModel(hosts: hosts) + library.hostAccess.entitlement = .loading + let store: any RemoteSessionHostStore = library + var continuation: CheckedContinuation? + var resolutions = 0 + library.resolveInitialHostEntitlement = { + resolutions += 1 + await withCheckedContinuation { continuation = $0 } + library.hostAccess.entitlement = .purchased(.purchased) + } + defer { library.resolveInitialHostEntitlement = nil } + let target = hosts[lockedJump ? 0 : 3] + let operation = Task { try await store.resolveHostAccess(target) } + for _ in 0..<200 where continuation == nil { try await Task.sleep(for: .milliseconds(5)) } + #expect(continuation != nil) + #expect(resolutions == 1) + #expect(throws: HostAccessError.verifyingEntitlement) { try store.requireHostAccess(target) } + continuation?.resume() + try await operation.value + try store.requireHostAccess(target) + #expect(resolutions == 1) + } + + @Test(arguments: [EntitlementStatus.free, .loading], [false, true]) + func telnetAdmissionIgnoresTheIndependentSSHJump(status: EntitlementStatus, sshEnabled: Bool) async throws { + var hosts = fixtures(4) + hosts[0].sshEnabled = sshEnabled + hosts[0].telnetEnabled = true + hosts[0].jumpHostID = hosts[3].id + let library = HostLibraryModel(hosts: hosts) + library.hostAccess.entitlement = status + var refreshes = 0 + library.resolveInitialHostEntitlement = { refreshes += 1 } + defer { library.resolveInitialHostEntitlement = nil } + + try await library.resolveHostAccess(hosts[0], protocol: .telnet) + try library.require(hosts[0], for: .telnetTerminal) + #expect(library.isEligible(hosts[0], for: .telnetTerminal)) + #expect(library.isEligible(hosts[0], for: .preferredTerminal) == !sshEnabled) + #expect(library.hosts(matching: "", for: .telnetTerminal).map(\.id) == [hosts[0].id]) + #expect(refreshes == 0) + for useCase in [HostUseCase.sshTerminal, .sftp, .metrics] { + if sshEnabled { + #expect(throws: HostAccessError.self) { try library.require(hosts[0], for: useCase) } + } else { + #expect(throws: HostProtocolUnavailableError(useCase: useCase)) { + try library.require(hosts[0], for: useCase) + } + } + } + #expect(throws: HostAccessError.self) { try library.requireHostAccess(hosts[3], protocol: .telnet) } + } + + @Test(arguments: [EntitlementStatus.purchased(.purchased), .free, .verificationFailed]) + func duplicateWaitsForAdmissionWithoutWritingHostsOrCredentials(status: EntitlementStatus) async throws { + var hosts = fixtures(3) + let sourceID = hosts[0].id + let custom = Identity( + name: "Custom", username: "deploy", credentialRef: "source-secret", + ownerHostID: sourceID, customPurpose: .ssh) + hosts[0].identityID = custom.id + let library = HostLibraryModel(hosts: hosts, identities: [custom]) + library.hostAccess.entitlement = .loading + var upgrades = 0 + library.requestHostUpgrade = { upgrades += 1 } + var continuation: CheckedContinuation? + library.resolveInitialHostEntitlement = { + await withCheckedContinuation { continuation = $0 } + library.hostAccess.entitlement = status + } + defer { library.resolveInitialHostEntitlement = nil } + let original = hosts[0] + let operation = Task { try await library.duplicateHost(original, name: "Copy") } + for _ in 0..<200 where continuation == nil { try await Task.sleep(for: .milliseconds(5)) } + #expect(continuation != nil) + #expect(library.hosts == hosts) + #expect(library.identities == [custom]) + continuation?.resume() + do { + let saved = try await operation.value + #expect(saved) + #expect(status.hasProAccess) + let copy = try #require(library.hosts.first { !hosts.map(\.id).contains($0.id) }) + #expect(library.hosts.count == 4) + #expect(copy.identityID == nil) + #expect(copy.createdAt != nil && copy.createdAt != original.createdAt) + #expect(library.host(sourceID) == original) + } catch { + #expect(!status.hasProAccess) + #expect((error as? HostAccessError) == (status == .free ? .hostLimit : .entitlementVerificationFailed)) + #expect(library.hosts == hosts) + } + #expect(library.identities == [custom]) + #expect(upgrades == (status == .free ? 1 : 0)) + } + + @Test func underLimitDuplicationDoesNotWaitForEntitlement() async throws { + let host = fixtures(1)[0] + let library = HostLibraryModel(hosts: [host]) + library.hostAccess.entitlement = .loading + library.resolveInitialHostEntitlement = { Issue.record("An under-limit duplicate must not wait") } + let saved = try await library.duplicateHost(host, name: "Copy") + #expect(saved) + #expect(library.hosts.count == 2) + } + + @Test(arguments: [false, true]) + func duplicateDetachesOnlyHostOwnedIdentities(custom: Bool) async throws { + var host = fixtures(1)[0] + let purposes: [HostCustomIdentityPurpose] = [.ssh, .telnet, .proxy, .telnetProxy] + let identities = purposes.map { + Identity( + name: $0.rawValue, username: "deploy", + ownerHostID: custom ? host.id : nil, customPurpose: custom ? $0 : nil) + } + host.identityID = identities[0].id + host.telnetEnabled = true + host.telnetIdentityID = identities[1].id + host.proxy = HostProxy(hostname: "proxy.invalid", username: "ssh-proxy", identityID: identities[2].id) + host.telnetProxy = HostProxy(hostname: "proxy.invalid", username: "telnet-proxy", identityID: identities[3].id) + let library = HostLibraryModel(hosts: [host], identities: identities) + library.hostAccess.entitlement = .free + let saved = try await library.duplicateHost(host, name: "Copy") + #expect(saved) + let copy = try #require(library.hosts.first { $0.id != host.id }) + #expect(copy.identityID == (custom ? nil : host.identityID)) + #expect(copy.telnetIdentityID == (custom ? nil : host.telnetIdentityID)) + #expect(copy.proxy?.identityID == (custom ? nil : host.proxy?.identityID)) + #expect(copy.telnetProxy?.identityID == (custom ? nil : host.telnetProxy?.identityID)) + #expect(copy.proxy?.username == (custom ? "" : "ssh-proxy")) + #expect(copy.telnetProxy?.username == (custom ? "" : "telnet-proxy")) + #expect(library.host(host.id) == host) + #expect(library.identities.count == identities.count) + for identity in identities { #expect(library.identity(identity.id) == identity) } + } + @Test func initialEntitlementResolutionFinishesBeforeNewHostMutation() async throws { let library = HostLibraryModel(hosts: fixtures(3)) library.hostAccess.entitlement = .loading diff --git a/FrameworkTests/LocalNetworkActionTests.swift b/FrameworkTests/LocalNetworkActionTests.swift index 29aa771f..2587cfe5 100644 --- a/FrameworkTests/LocalNetworkActionTests.swift +++ b/FrameworkTests/LocalNetworkActionTests.swift @@ -1,6 +1,7 @@ import Foundation import TermindApplication import TermindDomain +import TermindRemoteInfrastructure import Testing import struct TermindDomain.Host @@ -9,6 +10,90 @@ import struct TermindDomain.Host struct LocalNetworkActionTests { enum IdentitySelection: CaseIterable { case unsaved, hostCustom, reusable } + @Test(arguments: [false, true]) + func telnetConfigurationWakesOnlyItsTargetAndRejectsRetainedAccessAfterExpiry(sshEnabled: Bool) async throws { + var hosts = (0..<4).map { + Host(name: "Host \($0)", host: "host.invalid", createdAt: Date(timeIntervalSince1970: Double($0))) + } + hosts[0].sshEnabled = sshEnabled + hosts[0].telnetEnabled = true + hosts[0].jumpHostID = hosts[3].id + hosts[0].wakeOnLAN = .init(macAddress: "02:11:22:33:44:55", automaticallyWake: true) + hosts[3].telnetEnabled = true + hosts[3].wakeOnLAN = .init(macAddress: "02:11:22:33:44:66", automaticallyWake: true) + let library = HostLibraryModel(hosts: hosts) + let sender = ManualWakeSender() + library.wakeOnLAN = WakeOnLANController(sender: sender) + let retained = try library.telnetConnectionConfiguration(for: hosts[3]) + library.hostAccess.entitlement = .free + let allowed = try library.telnetConnectionConfiguration(for: hosts[0]) + try allowed.accessCheck?() + await allowed.beforeConnect?() + #expect(sender.sent.map(\.rawValue) == ["02:11:22:33:44:55"]) + let connection = TelnetConnection(output: { _ in }) + defer { connection.disconnect() } + await #expect(throws: HostAccessError.lockedHost(hosts[3].id)) { + try await connection.connect(using: retained) + } + await retained.beforeConnect?() + #expect(sender.sent.count == 1) + } + + @Test(arguments: [false, true]) + func pendingManualWakeUsesFreshPreferencesAndHonorsCancellation(cancelled: Bool) async throws { + var hosts = (0..<4).map { + Host(name: "Host \($0)", host: "host.invalid", createdAt: Date(timeIntervalSince1970: Double($0))) + } + hosts[3].wakeOnLAN.macAddress = "02:11:22:33:44:55" + let library = HostLibraryModel(hosts: hosts) + let sender = ManualWakeSender() + library.wakeOnLAN = WakeOnLANController(sender: sender) + library.hostAccess.entitlement = .loading + var continuation: CheckedContinuation? + library.resolveInitialHostEntitlement = { + await withCheckedContinuation { continuation = $0 } + library.hostAccess.entitlement = .purchased(.purchased) + } + defer { library.resolveInitialHostEntitlement = nil } + let target = hosts[3] + let operation = Task { try await library.requestWake(target) } + for _ in 0..<200 where continuation == nil { try await Task.sleep(for: .milliseconds(5)) } + #expect(continuation != nil) + #expect(sender.sent.isEmpty) + var updated = target + updated.wakeOnLAN.macAddress = "02:11:22:33:44:66" + library.upsert(updated) + if cancelled { operation.cancel() } + continuation?.resume() + do { + let outcome = try await operation.value + #expect(!cancelled) + #expect(outcome == .sent) + } catch { + #expect(cancelled && error is CancellationError) + } + #expect(sender.sent.map(\.rawValue) == (cancelled ? [] : ["02:11:22:33:44:66"])) + } + + @Test func deletingTheHostDuringEntitlementResolutionCancelsManualWake() async throws { + var hosts = (0..<4).map { + Host(name: "Host \($0)", host: "host.invalid", createdAt: Date(timeIntervalSince1970: Double($0))) + } + hosts[3].wakeOnLAN.macAddress = "02:11:22:33:44:55" + let target = hosts[3] + let library = HostLibraryModel(hosts: hosts) + let sender = ManualWakeSender() + library.wakeOnLAN = WakeOnLANController(sender: sender) + library.hostAccess.entitlement = .loading + library.resolveInitialHostEntitlement = { + library.projectHostDeletion(target.id) + library.hostAccess.entitlement = .purchased(.purchased) + } + defer { library.resolveInitialHostEntitlement = nil } + await #expect(throws: CancellationError.self) { try await library.requestWake(target) } + #expect(sender.sent.isEmpty) + } + @Test(arguments: IdentitySelection.allCases) func macLookupUsesOnlyTheSelectedEditorAuthentication(_ selection: IdentitySelection) async throws { let secrets = InMemorySecretStore() @@ -98,7 +183,7 @@ struct LocalNetworkActionTests { #expect(library.groups == [group]) } - @Test func lockedManualWakeNeverSendsOrRequestsConfiguration() { + @Test func lockedManualWakeNeverSendsOrRequestsConfiguration() async throws { var hosts = (0..<4).map { Host(name: "Host \($0)", host: "host.invalid", createdAt: Date(timeIntervalSince1970: Double($0))) } @@ -107,14 +192,17 @@ struct LocalNetworkActionTests { library.hostAccess.entitlement = .free let sender = ManualWakeSender() library.wakeOnLAN = WakeOnLANController(sender: sender) - #expect(library.requestWake(hosts[3]) == .accessDenied) + await #expect(throws: HostAccessError.lockedHost(hosts[3].id)) { + try await library.requestWake(hosts[3]) + } #expect(sender.sent.isEmpty) library.hostAccess.entitlement = .purchased(.purchased) - #expect(library.requestWake(hosts[3]) == .sent) + let outcome = try await library.requestWake(hosts[3]) + #expect(outcome == .sent) #expect(sender.sent.count == 1) } - @Test func manualWakeReadsLatestSavedPreferences() { + @Test func manualWakeReadsLatestSavedPreferences() async throws { var host = Host(name: "Host", host: "host.invalid") host.wakeOnLAN.macAddress = "02:11:22:33:44:55" let library = HostLibraryModel(hosts: [host]) @@ -123,26 +211,31 @@ struct LocalNetworkActionTests { var updated = host updated.wakeOnLAN.macAddress = "02:11:22:33:44:66" library.upsert(updated) - #expect(library.requestWake(host) == .sent) + let sent = try await library.requestWake(host) + #expect(sent == .sent) #expect(sender.sent.map(\.rawValue) == ["02:11:22:33:44:66"]) updated.wakeOnLAN = .init() library.upsert(updated) - #expect(library.requestWake(host) == .configurationRequired) + let unconfigured = try await library.requestWake(host) + #expect(unconfigured == .configurationRequired) #expect(sender.sent.count == 1) } - @Test func manualWakeReportsMissingServiceAndSendFailure() { + @Test func manualWakeReportsMissingServiceAndSendFailure() async throws { var host = Host(name: "Host", host: "host.invalid") host.wakeOnLAN.macAddress = "02:11:22:33:44:55" let library = HostLibraryModel() - #expect(library.requestWake(host) == .failed) + let unavailable = try await library.requestWake(host) + #expect(unavailable == .failed) let sender = ManualWakeSender() library.wakeOnLAN = WakeOnLANController(sender: sender) sender.fails = true - #expect(library.requestWake(host) == .failed) + let failed = try await library.requestWake(host) + #expect(failed == .failed) #expect(sender.sent.isEmpty) sender.fails = false - #expect(library.requestWake(host) == .sent) + let sent = try await library.requestWake(host) + #expect(sent == .sent) } private func finish(_ model: HostMACAddressModel) async throws { @@ -223,10 +316,6 @@ private nonisolated final class ManualWakeSender: WakeOnLANSending, @unchecked S } } -@MainActor private final class ManualWakeAccess { - var allowed = false -} - @MainActor private final class MACLookupTailscaleProvider: TailscaleConnectionProviding { func connection(for groupID: UUID) throws -> any SSHNetworkDialing { MACLookupDialer() } } diff --git a/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift b/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift index 351b3654..99cb20a9 100644 --- a/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift +++ b/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift @@ -9,8 +9,8 @@ import struct TermindDomain.Host @MainActor public protocol RemoteSessionHostStore: AnyObject, Sendable { func connectionAccessCheck(for hostIDs: [UUID]) -> HostOperationAccessCheck? - func requireHostAccess(_ host: Host) throws - func resolveHostAccess(_ host: Host) async throws + func requireHostAccess(_ host: Host, protocol connectionProtocol: HostTerminalProtocol) throws + func resolveHostAccess(_ host: Host, protocol connectionProtocol: HostTerminalProtocol) async throws func requireSavingHosts(_ proposed: [Host]) throws func connectionWakeAction(for hostIDs: [UUID]) -> ConnectionWakeAction? func host(_ id: Host.ID) -> Host? @@ -75,8 +75,15 @@ public protocol RemoteSessionHostStore: AnyObject, Sendable { extension RemoteSessionHostStore { public func connectionAccessCheck(for hostIDs: [UUID]) -> HostOperationAccessCheck? { nil } - public func requireHostAccess(_ host: Host) throws {} - public func resolveHostAccess(_ host: Host) async throws { try requireHostAccess(host) } + // Explicit overloads preserve witness dispatch; default arguments on a fallback can bypass it. + public func requireHostAccess(_ host: Host) throws { try requireHostAccess(host, protocol: .ssh) } + public func requireHostAccess(_ host: Host, protocol connectionProtocol: HostTerminalProtocol) throws {} + public func resolveHostAccess(_ host: Host) async throws { try await resolveHostAccess(host, protocol: .ssh) } + public func resolveHostAccess( + _ host: Host, protocol connectionProtocol: HostTerminalProtocol + ) async throws { + try requireHostAccess(host, protocol: connectionProtocol) + } public func resolveHostAccess(_ host: Host, route: SSHConnectionRoute?) async throws { try await resolveHostAccess(host) for node in route?.hosts ?? [] { try await resolveHostAccess(node) } @@ -133,7 +140,7 @@ extension RemoteSessionHostStore { } public func telnetConnectionConfiguration(for host: Host) throws -> TelnetConnectionConfiguration { - try requireHostAccess(host) + try requireHostAccess(host, protocol: .telnet) guard host.telnetEnabled else { throw HostProtocolUnavailableError(useCase: .telnetTerminal) } let identity = identity(for: host, protocol: .telnet) let username = identity?.username.trimmingCharacters(in: .whitespacesAndNewlines) @@ -144,10 +151,13 @@ extension RemoteSessionHostStore { } else { password = nil } - return TelnetConnectionConfiguration( + var configuration = TelnetConnectionConfiguration( hostID: host.id, host: host.host, port: host.telnetPort, username: username?.isEmpty == false ? username : nil, password: password, charset: host.telnetCharset) + configuration.accessCheck = connectionAccessCheck(for: [host.id]) + configuration.beforeConnect = connectionWakeAction(for: [host.id]) + return configuration } /// Captures a desktop authentication form without saving its input. A diff --git a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel+LocalNetwork.swift b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel+LocalNetwork.swift index 9b4b9b7f..d47994d4 100644 --- a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel+LocalNetwork.swift +++ b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel+LocalNetwork.swift @@ -3,7 +3,6 @@ import TermindDomain import struct TermindDomain.Host public nonisolated enum HostWakeOutcome: Equatable, Sendable { - case accessDenied case configurationRequired case sent case failed @@ -11,8 +10,16 @@ public nonisolated enum HostWakeOutcome: Equatable, Sendable { extension HostLibraryModel { /// Lock checks precede configuration and packet transmission. - public func requestWake(_ proposed: Host) -> HostWakeOutcome { - guard (try? hostAccess.requireAccess(to: proposed.id, in: hosts)) != nil else { return .accessDenied } + public func requestWake(_ proposed: Host) async throws -> HostWakeOutcome { + let wasSaved = host(proposed.id) != nil + do { + try hostAccess.requireAccess(to: proposed.id, in: hosts) + } catch HostAccessError.verifyingEntitlement { + await resolveInitialHostEntitlement?() + } + try Task.checkCancellation() + guard !wasSaved || host(proposed.id) != nil else { throw CancellationError() } + try hostAccess.requireAccess(to: proposed.id, in: hosts) let host = host(proposed.id) ?? proposed guard host.wakeOnLAN.address != nil else { return .configurationRequired } do { diff --git a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift index 93096780..704af367 100644 --- a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift @@ -20,21 +20,21 @@ public final class HostLibraryModel: RemoteSessionHostStore { } @ObservationIgnored public var requestHostUpgrade: (() -> Void)? @ObservationIgnored public var resolveInitialHostEntitlement: (() async -> Void)? - public var lastHostAccessError: HostAccessError? - public func isLocked(_ host: Host) -> Bool { hostAccess.isLocked(host.id, in: hosts) } - public func requireHostAccess(_ host: Host) throws { - for node in route(for: host) { + public func requireHostAccess(_ host: Host, protocol connectionProtocol: HostTerminalProtocol) throws { + for node in connectionProtocol == .telnet ? [host] : route(for: host) { try hostAccess.requireAccess(to: node.id, in: hosts) } } - public func resolveHostAccess(_ host: Host) async throws { - do { try requireHostAccess(host) } catch HostAccessError.verifyingEntitlement { + public func resolveHostAccess( + _ host: Host, protocol connectionProtocol: HostTerminalProtocol + ) async throws { + do { try requireHostAccess(host, protocol: connectionProtocol) } catch HostAccessError.verifyingEntitlement { await resolveInitialHostEntitlement?() try Task.checkCancellation() - try requireHostAccess(host) + try requireHostAccess(host, protocol: connectionProtocol) } } @@ -55,9 +55,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { public func requireSavingHosts(_ proposed: [Host]) throws { do { try hostAccess.requireSaving(proposed, in: hosts) - lastHostAccessError = nil } catch let error as HostAccessError { - lastHostAccessError = error if error == .hostLimit { requestHostUpgrade?() } throw error } @@ -117,8 +115,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { private func wakeBeforeConnecting(_ ids: [UUID]) { guard ids.allSatisfy({ id in - guard let host = host(id) else { return true } - return (try? requireHostAccess(host)) != nil + (try? hostAccess.requireAccess(to: id, in: hosts)) != nil }) else { return } for id in ids { @@ -1014,13 +1011,13 @@ public final class HostLibraryModel: RemoteSessionHostStore { } public func isEligible(_ host: Host, for useCase: HostUseCase) -> Bool { - guard (try? requireHostAccess(host)) != nil else { return false } + guard (try? requireHostAccess(host, protocol: useCase.accessProtocol(for: host))) != nil else { return false } if host.isTailscaleManaged && [.jumpHost, .keyExport, .portForwarding].contains(useCase) { return false } return host.supports(useCase.protocolRequirement) } public func require(_ host: Host, for useCase: HostUseCase) throws { - try requireHostAccess(host) + try requireHostAccess(host, protocol: useCase.accessProtocol(for: host)) guard isEligible(host, for: useCase) else { throw HostProtocolUnavailableError(useCase: useCase) } @@ -1052,6 +1049,29 @@ public final class HostLibraryModel: RemoteSessionHostStore { // MARK: - Writes + @discardableResult + public func duplicateHost(_ proposed: Host, name: String) async throws -> Bool { + guard let source = host(proposed.id), !source.isTailscaleManaged else { return false } + var copy = source + if source.identityID.flatMap(identity)?.isHostCustom == true { copy.identityID = nil } + if source.telnetIdentityID.flatMap(identity)?.isHostCustom == true { copy.telnetIdentityID = nil } + if source.proxy?.identityID.flatMap(identity)?.isHostCustom == true { + copy.proxy?.identityID = nil + copy.proxy?.username = "" + } + if source.telnetProxy?.identityID.flatMap(identity)?.isHostCustom == true { + copy.telnetProxy?.identityID = nil + copy.telnetProxy?.username = "" + } + copy.id = UUID() + copy.name = name + copy.createdAt = nil + try await resolveSavingHostsAccess([copy]) + try Task.checkCancellation() + guard host(source.id) != nil else { return false } + return saveHost(copy) + } + public func upsert(_ host: Host) { _ = saveHost(host) } @discardableResult diff --git a/Frameworks/TermindApplication/HostRepository.swift b/Frameworks/TermindApplication/HostRepository.swift index 1cc76146..ebe85988 100644 --- a/Frameworks/TermindApplication/HostRepository.swift +++ b/Frameworks/TermindApplication/HostRepository.swift @@ -21,6 +21,14 @@ public nonisolated enum HostUseCase: Hashable, Sendable { case .sshTerminal, .sftp, .jumpHost, .metrics, .portForwarding, .snippetExecution, .keyExport: .ssh } } + + public func accessProtocol(for host: Host) -> HostTerminalProtocol { + switch self { + case .preferredTerminal: host.preferredTerminalProtocol ?? .ssh + case .telnetTerminal: .telnet + default: .ssh + } + } } public nonisolated struct HostProtocolUnavailableError: LocalizedError, Equatable, Sendable { diff --git a/TFI/App/MobileAppModel+LocalNetwork.swift b/TFI/App/MobileAppModel+LocalNetwork.swift index e0b1a93e..8a53a645 100644 --- a/TFI/App/MobileAppModel+LocalNetwork.swift +++ b/TFI/App/MobileAppModel+LocalNetwork.swift @@ -11,18 +11,24 @@ extension MobileAppModel { LocalDiscoveryModel(service: LANHostDiscovery(connectionProtocol: connectionProtocol)) } - func requestWake(_ host: Host, configure: () -> Void) -> String? { - switch library.requestWake(host) { - case .accessDenied: - if library.isLocked(host) { pro.presentPaywall() } + func requestWake(_ host: Host, configure: () -> Void) async -> String? { + do { + switch try await library.requestWake(host) { + case .configurationRequired: + configure() + return nil + case .sent: + return String(localized: AppStrings.LocalNetwork.sent) + case .failed: + return String(localized: AppStrings.LocalNetwork.wakeFailed) + } + } catch HostAccessError.lockedHost { + pro.presentPaywall() return nil - case .configurationRequired: - configure() + } catch is CancellationError { return nil - case .sent: - return String(localized: AppStrings.LocalNetwork.sent) - case .failed: - return String(localized: AppStrings.LocalNetwork.wakeFailed) + } catch { + return HostAccessError.presentationDescription(of: error) } } diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index a0e08089..89e6e773 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -547,22 +547,15 @@ final class MobileAppModel { if let storageError = library.storageError { error = storageError } } - func duplicateHost(_ host: Host) { - guard !host.isTailscaleManaged else { return } - var copy = host - if host.identityID.flatMap(library.identity)?.isHostCustom == true { copy.identityID = nil } - if host.telnetIdentityID.flatMap(library.identity)?.isHostCustom == true { copy.telnetIdentityID = nil } - if host.proxy?.identityID.flatMap(library.identity)?.isHostCustom == true { - copy.proxy?.identityID = nil - copy.proxy?.username = "" + func duplicateHost(_ host: Host) async { + do { + let saved = try await library.duplicateHost( + host, name: String(localized: AppStrings.Vault.duplicatedHostName(host.name))) + if !saved { error = library.storageError } + } catch is CancellationError { + } catch { + self.error = HostAccessError.presentationDescription(of: error) } - if host.telnetProxy?.identityID.flatMap(library.identity)?.isHostCustom == true { - copy.telnetProxy?.identityID = nil - copy.telnetProxy?.username = "" - } - copy.id = UUID() - copy.name = String(localized: AppStrings.Vault.duplicatedHostName(host.name)) - library.upsert(copy) } func lastConnectedAt(for hostID: Host.ID) -> Date? { diff --git a/TFI/App/MobileWorkspaceSessionModel.swift b/TFI/App/MobileWorkspaceSessionModel.swift index ea731947..901c6dd6 100644 --- a/TFI/App/MobileWorkspaceSessionModel.swift +++ b/TFI/App/MobileWorkspaceSessionModel.swift @@ -151,12 +151,12 @@ final class MobileWorkspaceSessionModel { initialPath: String? = nil ) -> UUID? { guard !isDiscarded, let host = app.library.host(hostID) else { return nil } - do { try app.library.requireHostAccess(host) } catch { + do { try app.library.requireHostAccess(host, protocol: kind.terminalProtocol ?? .ssh) } catch { if case HostAccessError.verifyingEntitlement = error { Task { [weak self] in guard let self else { return } do { - try await app.library.resolveHostAccess(host) + try await app.library.resolveHostAccess(host, protocol: kind.terminalProtocol ?? .ssh) guard !isDiscarded else { return } _ = open(hostID: hostID, kind: kind, initialPath: initialPath) } catch { diff --git a/TFI/Features/Vault/MobileVaultView.swift b/TFI/Features/Vault/MobileVaultView.swift index 0c91939d..8f00363c 100644 --- a/TFI/Features/Vault/MobileVaultView.swift +++ b/TFI/Features/Vault/MobileVaultView.swift @@ -855,7 +855,7 @@ struct MobileVaultView: View { } case .wake: Button(AppStrings.LocalNetwork.wake.swiftUIKey, systemImage: "power") { - wakeNotice = app.requestWake(host) { editingHost = host } + Task { wakeNotice = await app.requestWake(host) { editingHost = host } } } case .edit: Button(AppStrings.Common.edit.swiftUIKey, systemImage: "pencil") { editingHost = host } @@ -865,7 +865,7 @@ struct MobileVaultView: View { } case .duplicate: Button(AppStrings.Common.duplicate.swiftUIKey, systemImage: "plus.square.on.square") { - app.duplicateHost(host) + Task { await app.duplicateHost(host) } }.disabled(host.isTailscaleManaged) case .delete: Button(AppStrings.Common.delete.swiftUIKey, systemImage: "trash", role: .destructive) { diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index 35b25dc0..e2a6d187 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -485,16 +485,20 @@ final class MobileWorkspace: Identifiable { func insertTerminalText(_ text: String) { guard connected else { return } - if let host = connectionHost(descriptor.hostID) { - do { try hosts.requireHostAccess(host) } catch { - operationError = error.localizedDescription - return - } + do { try requireTerminalInputAccess() } catch { + operationError = error.localizedDescription + return } _ = terminal.paste(text: text) terminal.requestFocus() } + func requireTerminalInputAccess() throws { + if let host = connectionHost(descriptor.hostID) { + try hosts.requireHostAccess(host, protocol: descriptor.kind.terminalProtocol ?? .ssh) + } + } + func generateCommand( using generator: any CommandGenerating, readViewport: @MainActor (MobileWorkspace) -> String? = { $0.readViewportText() } @@ -1438,7 +1442,7 @@ final class MobileWorkspace: Identifiable { attempt = Task { [weak self, weak hosts] in guard let self, let hosts else { return } do { - try await hosts.resolveHostAccess(host) + try await hosts.resolveHostAccess(host, protocol: .telnet) var configuration = try hosts.telnetConnectionConfiguration(for: host) if let usernameHint { configuration.username = usernameHint } if let password = self.advisoryTelnetPassword { diff --git a/TFITests/MobileAppTests.swift b/TFITests/MobileAppTests.swift index c0fac38c..b3f1b073 100644 --- a/TFITests/MobileAppTests.swift +++ b/TFITests/MobileAppTests.swift @@ -293,20 +293,21 @@ struct MobileWorkspaceTests { #expect(try store.load().hosts.first?.id == host.id) } - @Test func freeMetricsLimitCountsAllSelectionsAndDeduplicates() { + @Test func metricsHasNoIndependentSelectionLimitAndDeduplicates() { let library = HostLibraryModel() let hosts = (0..<5).map { TermindDomain.Host(name: "Host \($0)", host: "example.invalid") } for host in hosts { library.upsert(host) } let metrics = MobileMetricsModel( preferences: MetricsPreferences(), hosts: library, services: .testing) metrics.pause() - #expect(metrics.add(hosts[0].id)) - #expect(metrics.add(hosts[0].id)) - #expect(metrics.add(hosts[1].id)) - #expect(metrics.add(hosts[2].id)) - #expect(!metrics.add(hosts[3].id)) - #expect(metrics.add(hosts[4].id)) - #expect(metrics.selections.count == 4) + defer { metrics.core.shutdown() } + for host in hosts { + let added = metrics.add(host.id) + #expect(added) + } + let repeated = metrics.add(hosts[0].id) + #expect(repeated) + #expect(metrics.selections.map(\.id) == hosts.map(\.id)) } } diff --git a/TFITests/MobileHostAdmissionTests.swift b/TFITests/MobileHostAdmissionTests.swift new file mode 100644 index 00000000..351bba3c --- /dev/null +++ b/TFITests/MobileHostAdmissionTests.swift @@ -0,0 +1,88 @@ +import Foundation +import TermindApplication +import TermindDomain +import TermindInfrastructure +import TermindPersistence +import TermindPresentation +import Testing + +import struct TermindDomain.Host + +@testable import TermindIOS + +@Suite @MainActor +struct MobileHostAdmissionTests { + private func makeApp(status: EntitlementStatus, count: Int = 4) throws -> MobileAppModel { + var hosts = (0..? + app.library.resolveInitialHostEntitlement = { + await withCheckedContinuation { continuation = $0 } + app.library.hostAccess.entitlement = status + } + defer { app.library.resolveInitialHostEntitlement = nil } + let operation = Task { await app.duplicateHost(hosts[0]) } + for _ in 0..<200 where continuation == nil { try await Task.sleep(for: .milliseconds(5)) } + #expect(continuation != nil) + #expect(app.library.hosts == hosts) + continuation?.resume() + await operation.value + #expect(app.library.hosts.count == (status.hasProAccess ? 4 : 3)) + #expect(app.pro.isPaywallPresented == (status == .free)) + #expect( + app.error + == (status == .verificationFailed + ? HostAccessError.entitlementVerificationFailed.localizedDescription : nil)) + } +} diff --git a/TFITests/MobileMetricsHostAccessTests.swift b/TFITests/MobileMetricsHostAccessTests.swift index b1d0a613..e943e8ab 100644 --- a/TFITests/MobileMetricsHostAccessTests.swift +++ b/TFITests/MobileMetricsHostAccessTests.swift @@ -9,6 +9,40 @@ import Testing @Suite @MainActor struct MobileMetricsHostAccessTests { + @Test func lockedSelectionsPersistWithoutSamplersAndResumeAfterUpgrade() async throws { + let identity = Identity(name: "Metrics", username: "root", credentialRef: "metrics-password") + let secrets = InMemorySecretStore() + try secrets.setString("test-only", for: identity.credentialRef, storage: identity.passwordStorage) + let hosts = (0..<4).map { + TermindDomain.Host( + name: "Host \($0)", host: "host.invalid", identityID: identity.id, + createdAt: Date(timeIntervalSince1970: Double($0))) + } + let library = HostLibraryModel(hosts: hosts, identities: [identity], secrets: secrets) + library.hostAccess.entitlement = .free + let preferences = MobileLockedMetricsPreferences() + let recorder = MobileAdmissionSamplerRecorder() + let metrics = MobileMetricsModel( + preferences: preferences, hosts: library, + services: MobileMetricsServices(makeSampler: { recorder.makeSampler() })) + defer { metrics.core.shutdown() } + let added = metrics.add(hosts[3].id) + #expect(added) + let server = try #require(metrics.servers.first) + metrics.core.reconcile() + #expect(server.isHostLocked) + #expect(preferences.values.map(\.id) == [hosts[3].id]) + #expect(metrics.selections.map(\.id) == [hosts[3].id]) + #expect(recorder.count == 0) + + library.hostAccess.entitlement = .purchased(.purchased) + metrics.core.reconcile() + for _ in 0..<200 where recorder.count == 0 { try await Task.sleep(for: .milliseconds(5)) } + #expect(!server.isHostLocked) + #expect(recorder.count == 1) + #expect(preferences.values.map(\.id) == [hosts[3].id]) + } + @Test func failedAccessUsesErrorIndicatorRatherThanAnIndefiniteSpinner() { let server = ServerMetric(selection: MetricsSelection(id: UUID()), name: "Host") server.hostAccessError = .entitlementVerificationFailed @@ -49,3 +83,26 @@ struct MobileMetricsHostAccessTests { #expect(inspected) } } + +@MainActor private final class MobileLockedMetricsPreferences: MetricsConfigurationStoring { + var values: [MetricsSelection] = [] + func load() -> [MetricsSelection] { values } + func save(_ selections: [MetricsSelection]) { values = selections } +} + +private nonisolated final class MobileAdmissionSamplerRecorder: @unchecked Sendable { + private let lock = NSLock() + private var creations = 0 + var count: Int { lock.withLock { creations } } + func makeSampler() -> any MetricsSampling { + lock.withLock { creations += 1 } + return MobileAdmissionMetricsSampler() + } +} + +private actor MobileAdmissionMetricsSampler: MetricsSampling { + func connect(_ configuration: SSHConnectionConfiguration) async throws { throw CancellationError() } + func sample() async throws -> MetricsSample { throw CancellationError() } + func gpu() async throws -> NvidiaSnapshot? { nil } + func disconnect() async {} +} diff --git a/TFITests/MobileMultiHostPickerTests.swift b/TFITests/MobileMultiHostPickerTests.swift index 729f5b8b..11a6f495 100644 --- a/TFITests/MobileMultiHostPickerTests.swift +++ b/TFITests/MobileMultiHostPickerTests.swift @@ -72,7 +72,7 @@ struct MobileMultiHostPickerTests { } @Test - func batchMetricsAdditionIsAtomicAndUsesTheCurrentEntitlement() { + func batchMetricsAdditionHasNoIndependentLimitAndDeduplicates() { let hosts = (0..<5).map { Host(name: "Host \($0)", host: "host\($0).invalid") } let preferences = PickerMetricsPreferences() let metrics = MobileMetricsModel( @@ -80,15 +80,18 @@ struct MobileMultiHostPickerTests { defer { metrics.core.shutdown() } metrics.pause() - #expect(metrics.add([hosts[0].id])) - let saved = preferences.values - #expect(!metrics.add(hosts[1...3].map(\.id))) - #expect(preferences.values == saved) - #expect(metrics.selections.map(\.id) == [hosts[0].id]) - #expect(metrics.add([hosts[0].id, hosts[1].id, hosts[1].id, hosts[2].id])) - #expect(metrics.selections.count == 3) - #expect(metrics.add(hosts[3...4].map(\.id))) + let initial = metrics.add([hosts[0].id]) + #expect(initial) + let batch = metrics.add(hosts[1...3].map(\.id)) + #expect(batch) + #expect(metrics.selections.map(\.id) == Array(hosts.prefix(4)).map(\.id)) + let repeated = metrics.add([hosts[0].id, hosts[1].id, hosts[1].id, hosts[2].id]) + #expect(repeated) + #expect(metrics.selections.count == 4) + let final = metrics.add(hosts[3...4].map(\.id)) + #expect(final) #expect(metrics.selections.map(\.id) == hosts.map(\.id)) + #expect(preferences.values.map(\.id) == hosts.map(\.id)) } @Test @@ -108,7 +111,7 @@ struct MobileMultiHostPickerTests { } @Test - func mobileMetricsCanSwapAtTheFreeLimitAndRemoveAllHosts() { + func mobileMetricsCanReplaceAndRemoveAllHosts() { let hosts = (0..<4).map { Host(name: "Host \($0)", host: "host\($0).invalid") } let preferences = PickerMetricsPreferences() let metrics = MobileMetricsModel( diff --git a/TFITests/MobileVaultFeatureTests.swift b/TFITests/MobileVaultFeatureTests.swift index 547cfa6a..0b1983e4 100644 --- a/TFITests/MobileVaultFeatureTests.swift +++ b/TFITests/MobileVaultFeatureTests.swift @@ -170,23 +170,25 @@ struct MobileVaultFeatureTests { ]) } - @Test func duplicatingAHostPersistsAnIndependentCopyWithTheSameConfiguration() throws { - let app = try MobileAppModel(inMemory: true) + @Test func duplicatingAHostPersistsAnIndependentCopyWithTheSameConfiguration() async throws { let group = HostGroup(name: "Production") let jump = TermindDomain.Host(name: "Jump", host: "jump.example.invalid") - app.library.upsert(group) - app.library.upsert(jump) let host = TermindDomain.Host( name: "Remote", host: "remote.example.invalid", port: 2222, identityID: UUID(), + createdAt: Date(timeIntervalSince1970: 100), jumpHostID: jump.id, proxy: HostProxy(kind: .socks5, hostname: "proxy.example.invalid", port: 1080), sftpChannelInitialPath: "/srv/app", groupID: group.id, tags: ["production", "api"], colorTag: .teal, detectedDistribution: DetectedDistribution(id: "debian"), shellIntegrationMode: .enabled, portForwardingMaxConnections: 12) - app.library.upsert(host) + var dependencies = MobileAppDependencies.testing() + let container = try TermindPersistence.makeContainer(inMemory: true) + _ = try HostPersistence(context: container.mainContext).save(.init(hosts: [jump, host], groups: [group])) + dependencies.container = container + let app = try MobileAppModel(dependencies: dependencies) let original = try #require(app.library.host(host.id)) - app.duplicateHost(original) + await app.duplicateHost(original) #expect(app.library.hosts.count == 3) #expect(app.library.host(original.id) == original) @@ -197,6 +199,9 @@ struct MobileVaultFeatureTests { expected.id = copy.id expected.name = copy.name expected.sortIndex = copy.sortIndex + #expect(copy.createdAt != nil) + #expect(copy.createdAt != original.createdAt) + expected.createdAt = copy.createdAt #expect(copy == expected) app.outbox.stop() } diff --git a/TFITests/MobileWakeAccessTests.swift b/TFITests/MobileWakeAccessTests.swift index 7cbcb97c..668da608 100644 --- a/TFITests/MobileWakeAccessTests.swift +++ b/TFITests/MobileWakeAccessTests.swift @@ -2,14 +2,60 @@ import Foundation import TermindApplication import TermindDomain import TermindInfrastructure +import TermindPresentation import Testing @testable import TermindIOS @Suite @MainActor struct MobileWakeAccessTests { + @Test(arguments: [EntitlementStatus.purchased(.purchased), .free, .verificationFailed]) + func pendingManualWakeResumesOnceWithTypedAccessPresentation(status: EntitlementStatus) async throws { + var dependencies = MobileAppDependencies.testing() + dependencies.proAccess = .testing(status: .free) + let app = try MobileAppModel(dependencies: dependencies) + defer { + app.metrics.core.shutdown() + app.outbox.stop() + } + app.library.hostAccess.entitlement = .unrestrictedForTesting + for index in 0..<4 { + var host = TermindDomain.Host(name: "Host \(index)", host: "host.invalid") + host.wakeOnLAN.macAddress = "02:11:22:33:44:55" + app.library.upsert(host) + } + let target = try #require(app.library.hosts.last) + let sender = RecordedMobileWakeSender() + app.library.wakeOnLAN = WakeOnLANController(sender: sender) + app.library.hostAccess.entitlement = .loading + var continuation: CheckedContinuation? + app.library.resolveInitialHostEntitlement = { + await withCheckedContinuation { continuation = $0 } + app.library.hostAccess.entitlement = status + } + defer { app.library.resolveInitialHostEntitlement = nil } + var configured = false + let operation = Task { await app.requestWake(target) { configured = true } } + for _ in 0..<200 where continuation == nil { try await Task.sleep(for: .milliseconds(5)) } + #expect(continuation != nil) + #expect(sender.count == 0) + #expect(!app.pro.isPaywallPresented) + continuation?.resume() + let notice = await operation.value + #expect(!configured) + #expect(app.pro.isPaywallPresented == (status == .free)) + #expect(sender.count == (status.hasProAccess ? 1 : 0)) + if status.hasProAccess { + #expect(notice == String(localized: AppStrings.LocalNetwork.sent)) + } else if status == .verificationFailed { + #expect(notice == HostAccessError.entitlementVerificationFailed.localizedDescription) + } else { + #expect(notice == nil) + } + } + @Test(arguments: ["", "02:11:22:33:44:55"]) - func lockedManualWakePresentsPaywallWithoutConfigurationOrPacket(macAddress: String) throws { + func lockedManualWakePresentsPaywallWithoutConfigurationOrPacket(macAddress: String) async throws { var dependencies = MobileAppDependencies.testing() dependencies.proAccess = .testing(status: .free) let app = try MobileAppModel(dependencies: dependencies) @@ -28,7 +74,8 @@ struct MobileWakeAccessTests { app.library.wakeOnLAN = WakeOnLANController(sender: UnexpectedMobileWakeSender()) #expect(app.library.isLocked(locked)) var configured = false - #expect(app.requestWake(locked) { configured = true } == nil) + let notice = await app.requestWake(locked) { configured = true } + #expect(notice == nil) #expect(app.pro.isPaywallPresented) #expect(!configured) } @@ -37,3 +84,10 @@ struct MobileWakeAccessTests { private nonisolated struct UnexpectedMobileWakeSender: WakeOnLANSending { func send(to address: MACAddress) throws { Issue.record("A locked host must not send a Wake packet") } } + +private nonisolated final class RecordedMobileWakeSender: WakeOnLANSending, @unchecked Sendable { + private let lock = NSLock() + private var packets = 0 + var count: Int { lock.withLock { packets } } + func send(to address: MACAddress) { lock.withLock { packets += 1 } } +} diff --git a/TFM/App/AppModel+LocalNetwork.swift b/TFM/App/AppModel+LocalNetwork.swift index 405b0ef4..234c41dd 100644 --- a/TFM/App/AppModel+LocalNetwork.swift +++ b/TFM/App/AppModel+LocalNetwork.swift @@ -11,18 +11,24 @@ extension AppModel { LocalDiscoveryModel(service: LANHostDiscovery(connectionProtocol: connectionProtocol)) } - func requestWake(_ host: Host, configure: () -> Void) -> String? { - switch hostLibrary.requestWake(host) { - case .accessDenied: - if hostLibrary.isLocked(host) { proAccess.presentPaywall() } + func requestWake(_ host: Host, configure: () -> Void) async -> String? { + do { + switch try await hostLibrary.requestWake(host) { + case .configurationRequired: + configure() + return nil + case .sent: + return String(localized: AppStrings.LocalNetwork.sent) + case .failed: + return String(localized: AppStrings.LocalNetwork.wakeFailed) + } + } catch HostAccessError.lockedHost { + proAccess.presentPaywall() return nil - case .configurationRequired: - configure() + } catch is CancellationError { return nil - case .sent: - return String(localized: AppStrings.LocalNetwork.sent) - case .failed: - return String(localized: AppStrings.LocalNetwork.wakeFailed) + } catch { + return HostAccessError.presentationDescription(of: error) } } diff --git a/TFM/App/AppModel.swift b/TFM/App/AppModel.swift index 4352ab75..9fa2ee82 100644 --- a/TFM/App/AppModel.swift +++ b/TFM/App/AppModel.swift @@ -34,6 +34,14 @@ nonisolated enum HostOpenTarget: Hashable, Sendable { case eternalTerminalSession case telnetSession case sftpChannel + + func accessProtocol(for host: Host) -> HostTerminalProtocol { + switch self { + case .preferredTerminal: host.preferredTerminalProtocol ?? .ssh + case .telnetSession: .telnet + default: .ssh + } + } } /// App-wide state shared by every window: the saved hosts, and the one code path @@ -1064,7 +1072,7 @@ final class AppModel { ) -> Workspace? { guard admitHostOpen( - host, in: window, + host, protocol: target.accessProtocol(for: host), in: window, whenResolved: { [weak self, weak window] in guard let self, let window else { return } self.open(host, as: target, in: window) @@ -1101,7 +1109,7 @@ final class AppModel { for node in target.route.hosts { guard admitHostOpen( - node, in: window, + node, protocol: target.connectionProtocol, in: window, whenResolved: { [weak self, weak window] in guard let self, let window else { return } self.openQuickConnect(target, in: window) @@ -1111,7 +1119,7 @@ final class AppModel { for id in target.route.sourceHostIDs.values { if let saved = hosts.host(id), !admitHostOpen( - saved, in: window, + saved, protocol: target.connectionProtocol, in: window, whenResolved: { [weak self, weak window] in guard let self, let window else { return } self.openQuickConnect(target, in: window) @@ -1290,19 +1298,20 @@ final class AppModel { ) { if let pending = hosts.first(where: { do { - try self.hosts.requireHostAccess($0) + try self.hosts.requireHostAccess($0, protocol: target.accessProtocol(for: $0)) return false } catch { return (error as? HostAccessError) == .verifyingEntitlement } }) { - _ = admitHostOpen(pending, in: window) { [weak self, weak window] in + _ = admitHostOpen(pending, protocol: target.accessProtocol(for: pending), in: window) { + [weak self, weak window] in guard let self, let window else { return } self.open(hosts, as: target, layout: layout, in: window) } return } - let hosts = hosts.filter { admitHostOpen($0, in: window) } + let hosts = hosts.filter { admitHostOpen($0, protocol: target.accessProtocol(for: $0), in: window) } guard !hosts.isEmpty else { return } configureConnectionHistory(in: window) switch layout { @@ -1316,17 +1325,18 @@ final class AppModel { } private func admitHostOpen( - _ host: Host, in window: WindowModel, whenResolved: (() -> Void)? = nil + _ host: Host, protocol connectionProtocol: HostTerminalProtocol, + in window: WindowModel, whenResolved: (() -> Void)? = nil ) -> Bool { do { - try hosts.requireHostAccess(host) + try hosts.requireHostAccess(host, protocol: connectionProtocol) return true } catch { if case HostAccessError.verifyingEntitlement = error { Task { [weak self, weak window] in guard let self, let window else { return } do { - try await hosts.resolveHostAccess(host) + try await hosts.resolveHostAccess(host, protocol: connectionProtocol) whenResolved?() } catch { if case HostAccessError.lockedHost = error { diff --git a/TFM/Features/Hosts/HostActions.swift b/TFM/Features/Hosts/HostActions.swift index d96ba5cc..7f3537ee 100644 --- a/TFM/Features/Hosts/HostActions.swift +++ b/TFM/Features/Hosts/HostActions.swift @@ -45,7 +45,9 @@ struct HostContextMenu: View { let showsTailscale = host.isTailscaleManaged || app.tailscale.peer(for: host) != nil if showsWake { Button(AppStrings.LocalNetwork.wake.swiftUIKey, systemImage: "power") { - window.wakeNotice = app.requestWake(host) { window.openHostEditor(host: host) } + Task { + window.wakeNotice = await app.requestWake(host) { window.openHostEditor(host: host) } + } } } if showsTailscale { @@ -60,7 +62,7 @@ struct HostContextMenu: View { window.openHostEditor(host: host) } Button(AppStrings.Common.duplicate.swiftUIKey, systemImage: MenuSymbol.duplicate) { - HostMenuEntries.duplicate(host, in: app) + Task { await HostMenuEntries.duplicate(host, in: app, window: window) } } .disabled(host.isTailscaleManaged) Divider() @@ -204,7 +206,9 @@ enum HostMenuEntries { NativeMenuEntry( title: AppStrings.LocalNetwork.wake, symbol: "power" ) { - window.wakeNotice = app.requestWake(host) { window.openHostEditor(host: host) } + Task { + window.wakeNotice = await app.requestWake(host) { window.openHostEditor(host: host) } + } }) } if showsTailscale { @@ -226,31 +230,21 @@ enum HostMenuEntries { title: AppStrings.Common.duplicate, symbol: MenuSymbol.duplicate, isEnabled: !host.isTailscaleManaged ) { - duplicate(host, in: app) + Task { await duplicate(host, in: app, window: window) } }) } return entries } - static func duplicate(_ host: Host, in app: AppModel) { - var copy = host - if host.identityID.flatMap(app.hostLibrary.identity)?.isHostCustom == true { - copy.identityID = nil + static func duplicate(_ host: Host, in app: AppModel, window: WindowModel) async { + do { + let saved = try await app.hostLibrary.duplicateHost( + host, name: String(localized: AppStrings.Vault.duplicatedHostName(host.name))) + if !saved { window.hostAccessError = app.hostLibrary.storageError } + } catch is CancellationError { + } catch { + window.hostAccessError = HostAccessError.presentationDescription(of: error) } - if host.proxy?.identityID.flatMap(app.hostLibrary.identity)?.isHostCustom == true { - copy.proxy?.identityID = nil - copy.proxy?.username = "" - } - if host.telnetIdentityID.flatMap(app.hostLibrary.identity)?.isHostCustom == true { - copy.telnetIdentityID = nil - } - if host.telnetProxy?.identityID.flatMap(app.hostLibrary.identity)?.isHostCustom == true { - copy.telnetProxy?.identityID = nil - copy.telnetProxy?.username = "" - } - copy.id = UUID() - copy.name = String(localized: AppStrings.Vault.duplicatedHostName(host.name)) - app.hostLibrary.upsert(copy) } } diff --git a/TFM/Features/Settings/SettingsPageView.swift b/TFM/Features/Settings/SettingsPageView.swift index 6ed74b07..a191ea0f 100644 --- a/TFM/Features/Settings/SettingsPageView.swift +++ b/TFM/Features/Settings/SettingsPageView.swift @@ -386,27 +386,6 @@ enum SettingsDraftPolicy { } } -private struct ProLockedSettingsPage: View { - @Environment(AppModel.self) private var app - let title: LocalizedStringResource - - var body: some View { - VStack(spacing: Metrics.Space.l) { - Spacer() - Image(systemName: "lock.fill") - .font(.system(size: 34)) - .foregroundStyle(.tint) - Text(title).font(.title2.bold()) - Text(AppStrings.Settings.thisConfigurationIsAvailableWithTermindPro) - .foregroundStyle(.secondary) - Button(AppStrings.Settings.viewTermindPro.swiftUIKey) { app.proAccess.presentPaywall() } - .buttonStyle(.borderedProminent) - Spacer() - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } -} - struct SettingsFormPage: View { @ViewBuilder let content: Content diff --git a/TFM/Features/Terminal/TelnetSessionModel.swift b/TFM/Features/Terminal/TelnetSessionModel.swift index 2885df6d..8e484882 100644 --- a/TFM/Features/Terminal/TelnetSessionModel.swift +++ b/TFM/Features/Terminal/TelnetSessionModel.swift @@ -161,7 +161,7 @@ final class TelnetSessionModel: Identifiable { let viewport = latestViewport connectionTask = Task { [weak self] in do { - try await store.resolveHostAccess(host) + try await store.resolveHostAccess(host, protocol: .telnet) var configuration = try store.telnetConnectionConfiguration(for: host) if let usernameHint = self?.usernameHint { configuration.username = usernameHint } if let password = self?.advisoryPassword { diff --git a/TFMTests/HostAdmissionTests.swift b/TFMTests/HostAdmissionTests.swift new file mode 100644 index 00000000..c0a87982 --- /dev/null +++ b/TFMTests/HostAdmissionTests.swift @@ -0,0 +1,107 @@ +import Foundation +import TermindApplication +import TermindDomain +import TermindInfrastructure +import TermindPresentation +import Testing + +import struct TermindDomain.Host + +@testable import Termind + +@Suite @MainActor +struct HostAdmissionTests { + private func fixtures() -> [Host] { + var hosts = (0..<4).map { + Host( + name: "Host\($0)", host: "host\($0).invalid", + createdAt: Date(timeIntervalSince1970: Double($0)), telnetEnabled: true) + } + hosts[0].jumpHostID = hosts[3].id + return hosts + } + + @Test(arguments: [EntitlementStatus.free, .loading]) + func telnetSingleBatchAndQuickConnectIgnoreTheSSHJump(status: EntitlementStatus) throws { + let hosts = fixtures() + let app = AppModel(hosts: HostLibraryModel(hosts: hosts), proAccess: .testing(status: status)) + let window = WindowModel() + defer { + window.closeAll() + app.shutdownForTermination() + } + app.hostLibrary.resolveInitialHostEntitlement = { Issue.record("The Telnet target is already admitted") } + let opened = app.open(hosts[0], as: .telnetSession, in: window) + #expect(opened?.focusedPane.telnetSession != nil) + app.open([hosts[0], hosts[1]], as: .telnetSession, layout: .oneWorkspace, in: window) + #expect(window.workspaces.last?.panes.map(\.hostID) == [hosts[0].id, hosts[1].id]) + guard case .connection(let input) = QuickConnectParser.parse("telnet Host0 2323") else { + Issue.record("Expected a Telnet command") + return + } + let target = try QuickConnectResolver.resolve(input, in: app.hostLibrary) + let quick = app.openQuickConnect(target, in: window) + #expect(quick?.focusedPane.telnetSession != nil) + #expect(!app.proAccess.isPaywallPresented) + + app.hostLibrary.hostAccess.entitlement = .free + let before = window.workspaces.count + let ssh = app.open(hosts[0], as: .sshSession, in: window) + let sftp = app.open(hosts[0], as: .sftpChannel, in: window) + let lockedTelnet = app.open(hosts[3], as: .telnetSession, in: window) + #expect(ssh == nil && sftp == nil && lockedTelnet == nil) + #expect(window.workspaces.count == before) + #expect(app.proAccess.isPaywallPresented) + } + + @Test func loadingBatchWaitsBeforeOpeningAnyTelnetPanes() async throws { + let hosts = fixtures() + let app = AppModel(hosts: HostLibraryModel(hosts: hosts), proAccess: .testing(status: .loading)) + let window = WindowModel() + defer { + window.closeAll() + app.shutdownForTermination() + } + var continuation: CheckedContinuation? + app.hostLibrary.resolveInitialHostEntitlement = { + await withCheckedContinuation { continuation = $0 } + app.hostLibrary.hostAccess.entitlement = .purchased(.purchased) + } + defer { app.hostLibrary.resolveInitialHostEntitlement = nil } + app.open([hosts[0], hosts[3]], as: .telnetSession, layout: .oneWorkspace, in: window) + for _ in 0..<200 where continuation == nil { try await Task.sleep(for: .milliseconds(5)) } + #expect(continuation != nil) + #expect(window.workspaces.isEmpty) + continuation?.resume() + for _ in 0..<200 where window.workspaces.isEmpty { try await Task.sleep(for: .milliseconds(5)) } + #expect(window.workspaces.count == 1) + #expect(window.workspaces.first?.panes.map(\.hostID) == [hosts[0].id, hosts[3].id]) + #expect(!app.proAccess.isPaywallPresented) + } + + @Test(arguments: [EntitlementStatus.purchased(.purchased), .free, .verificationFailed]) + func duplicateWaitsAndPresentsOnlyTheApplicableAccessOutcome(status: EntitlementStatus) async throws { + let hosts = Array(fixtures().prefix(3)) + let app = AppModel(hosts: HostLibraryModel(hosts: hosts), proAccess: .testing(status: .loading)) + let window = WindowModel() + defer { app.shutdownForTermination() } + var continuation: CheckedContinuation? + app.hostLibrary.resolveInitialHostEntitlement = { + await withCheckedContinuation { continuation = $0 } + app.hostLibrary.hostAccess.entitlement = status + } + defer { app.hostLibrary.resolveInitialHostEntitlement = nil } + let operation = Task { await HostMenuEntries.duplicate(hosts[1], in: app, window: window) } + for _ in 0..<200 where continuation == nil { try await Task.sleep(for: .milliseconds(5)) } + #expect(continuation != nil) + #expect(app.hostLibrary.hosts == hosts) + continuation?.resume() + await operation.value + #expect(app.hostLibrary.hosts.count == (status.hasProAccess ? 4 : 3)) + #expect(app.proAccess.isPaywallPresented == (status == .free)) + #expect( + window.hostAccessError + == (status == .verificationFailed + ? HostAccessError.entitlementVerificationFailed.localizedDescription : nil)) + } +} diff --git a/TFMTests/QuickOpenTests.swift b/TFMTests/QuickOpenTests.swift index 1246502b..bbd20c8f 100644 --- a/TFMTests/QuickOpenTests.swift +++ b/TFMTests/QuickOpenTests.swift @@ -24,7 +24,7 @@ struct QuickOpenTests { } @Test - func nativeHostMenuCanDuplicateWithoutChangingTheOriginalHost() throws { + func nativeHostMenuCanDuplicateWithoutChangingTheOriginalHost() async throws { let host = Host(name: "Production", host: "server.invalid", port: 2222) let app = AppModel(hosts: HostLibraryModel(hosts: [host])) defer { app.shutdownForTermination() } @@ -34,6 +34,9 @@ struct QuickOpenTests { #expect(duplicate.isEnabled) duplicate.action() + for _ in 0..<200 where app.hostLibrary.hosts.count == 1 { + try await Task.sleep(for: .milliseconds(5)) + } #expect(app.hostLibrary.hosts.count == 2) let copy = try #require(app.hostLibrary.hosts.first { $0.id != host.id }) diff --git a/TFMTests/WakeOnLANAccessTests.swift b/TFMTests/WakeOnLANAccessTests.swift index 172c1f60..0805842f 100644 --- a/TFMTests/WakeOnLANAccessTests.swift +++ b/TFMTests/WakeOnLANAccessTests.swift @@ -12,8 +12,47 @@ import struct TermindDomain.Host @Suite @MainActor struct WakeOnLANAccessTests { + @Test(arguments: [EntitlementStatus.purchased(.purchased), .free, .verificationFailed]) + func pendingManualWakeResumesOnceWithTypedAccessPresentation(status: EntitlementStatus) async throws { + var hosts = (0..<4).map { + Host(name: "Host \($0)", host: "host.invalid", createdAt: Date(timeIntervalSince1970: Double($0))) + } + hosts[3].wakeOnLAN.macAddress = "02:11:22:33:44:55" + let pro = ProAccessController.testing(status: .free) + let app = AppModel(hosts: HostLibraryModel(hosts: hosts), proAccess: pro) + defer { app.shutdownForTermination() } + let sender = RecordedDesktopWakeSender() + app.hostLibrary.wakeOnLAN = WakeOnLANController(sender: sender) + app.hostLibrary.hostAccess.entitlement = .loading + var continuation: CheckedContinuation? + app.hostLibrary.resolveInitialHostEntitlement = { + await withCheckedContinuation { continuation = $0 } + app.hostLibrary.hostAccess.entitlement = status + } + defer { app.hostLibrary.resolveInitialHostEntitlement = nil } + var configured = false + let target = hosts[3] + let operation = Task { await app.requestWake(target) { configured = true } } + for _ in 0..<200 where continuation == nil { try await Task.sleep(for: .milliseconds(5)) } + #expect(continuation != nil) + #expect(sender.count == 0) + #expect(!pro.isPaywallPresented) + continuation?.resume() + let notice = await operation.value + #expect(!configured) + #expect(pro.isPaywallPresented == (status == .free)) + #expect(sender.count == (status.hasProAccess ? 1 : 0)) + if status.hasProAccess { + #expect(notice == String(localized: AppStrings.LocalNetwork.sent)) + } else if status == .verificationFailed { + #expect(notice == HostAccessError.entitlementVerificationFailed.localizedDescription) + } else { + #expect(notice == nil) + } + } + @Test(arguments: ["", "02:11:22:33:44:55"]) - func lockedManualWakePresentsPaywallWithoutConfigurationOrPacket(macAddress: String) { + func lockedManualWakePresentsPaywallWithoutConfigurationOrPacket(macAddress: String) async { var hosts = (0..<4).map { Host(name: "Host \($0)", host: "host.invalid", createdAt: Date(timeIntervalSince1970: Double($0))) } @@ -23,7 +62,8 @@ struct WakeOnLANAccessTests { defer { app.shutdownForTermination() } app.hostLibrary.wakeOnLAN = WakeOnLANController(sender: UnexpectedWakeSender()) var configured = false - #expect(app.requestWake(hosts[3]) { configured = true } == nil) + let notice = await app.requestWake(hosts[3]) { configured = true } + #expect(notice == nil) #expect(pro.isPaywallPresented) #expect(!configured) } @@ -39,19 +79,21 @@ struct WakeOnLANAccessTests { #expect(!pro.isPaywallPresented) } - @Test func freeManualWakeOpensConfigurationWithoutPaywall() { + @Test func freeManualWakeOpensConfigurationWithoutPaywall() async { let pro = ProAccessController.testing(status: .free) let app = AppModel(hosts: HostLibraryModel(), proAccess: pro) var configured = false - #expect(app.requestWake(Host(name: "Host", host: "server.invalid")) { configured = true } == nil) + let notice = await app.requestWake(Host(name: "Host", host: "server.invalid")) { configured = true } + #expect(notice == nil) #expect(!pro.isPaywallPresented) #expect(configured) } - @Test func proWakeWithoutMACOpensConfigurationRatherThanSending() { + @Test func proWakeWithoutMACOpensConfigurationRatherThanSending() async { let app = AppModel(hosts: HostLibraryModel()) var configured = false - #expect(app.requestWake(Host(name: "Host", host: "server.invalid")) { configured = true } == nil) + let notice = await app.requestWake(Host(name: "Host", host: "server.invalid")) { configured = true } + #expect(notice == nil) #expect(configured) #expect(!app.proAccess.isPaywallPresented) } @@ -90,3 +132,10 @@ struct WakeOnLANAccessTests { private nonisolated struct UnexpectedWakeSender: WakeOnLANSending { func send(to address: MACAddress) throws { Issue.record("A locked host must not send a Wake packet") } } + +private nonisolated final class RecordedDesktopWakeSender: WakeOnLANSending, @unchecked Sendable { + private let lock = NSLock() + private var packets = 0 + var count: Int { lock.withLock { packets } } + func send(to address: MACAddress) { lock.withLock { packets += 1 } } +}