fix(pro): enforce protocol-aware host admission before mutation

Distinguish SSH route admission from Telnet target admission and wire
Telnet access and automatic Wake hooks. Await entitlement resolution for
manual Wake and duplication before host or credential mutation.

Use explicit protocol-extension forwarding overloads so existential
single-argument calls cannot bypass the concrete admission witness.
Remove dead paywall state and update obsolete quota/date test fixtures.
Validation: 81 focused macOS and 58 focused iOS tests passed.
This commit is contained in:
Kaede Akino committed 2026-10-17 09:50:55 +08:00
1 parent f0b71db566
commit 9a36fde226
25 files changed
+819 -167

No files matched your search

+159
View File
@@ -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<Void, Never>?
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<Void, Never>?
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
+103 -14
View File
@@ -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<Void, Never>?
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() }
}
@@ -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
@@ -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 {
@@ -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
@@ -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 {
+16 -10
View File
@@ -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)
}
}
+8 -15
View File
@@ -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? {
+2 -2
View File
@@ -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 {
+2 -2
View File
@@ -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) {
+10 -6
View File
@@ -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 {
+9 -8
View File
@@ -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))
}
}
+88
View File
@@ -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..<count).map {
Host(
name: "Host\($0)", host: "host\($0).invalid",
createdAt: Date(timeIntervalSince1970: Double($0)), telnetEnabled: true)
}
if count == 4 { hosts[0].jumpHostID = hosts[3].id }
var dependencies = MobileAppDependencies.testing()
dependencies.proAccess = .testing(status: status)
let container = try TermindPersistence.makeContainer(inMemory: true)
_ = try HostPersistence(context: container.mainContext).save(.init(hosts: hosts))
dependencies.container = container
return try MobileAppModel(dependencies: dependencies)
}
@Test(arguments: [EntitlementStatus.free, .loading])
func telnetWorkspaceAndPickerIgnoreTheIndependentSSHJump(status: EntitlementStatus) throws {
let app = try makeApp(status: status)
defer {
app.testWorkspaceSession.closeAll()
app.metrics.core.shutdown()
app.outbox.stop()
}
let hosts = app.library.hosts.sorted { $0.name < $1.name }
app.library.resolveInitialHostEntitlement = { Issue.record("The Telnet target is already admitted") }
let opened = app.testWorkspaceSession.open(hostID: hosts[0].id, kind: .telnet)
#expect(opened != nil)
#expect(app.workspaces.first?.descriptor.kind == .telnet)
let telnet = try #require(app.workspaces.first)
try telnet.requireTerminalInputAccess()
let picker = MobileMultiHostPickerModel(library: app.library, useCase: .telnetTerminal, selected: [])
#expect(picker.hosts.contains { $0.id == hosts[0].id })
#expect(!app.pro.isPaywallPresented)
app.library.hostAccess.entitlement = .free
let before = app.workspaces.count
let ssh = app.testWorkspaceSession.open(hostID: hosts[0].id, kind: .ssh)
let sftp = app.testWorkspaceSession.open(hostID: hosts[0].id, kind: .sftp)
let lockedTelnet = app.testWorkspaceSession.open(hostID: hosts[3].id, kind: .telnet)
#expect(ssh == nil && sftp == nil && lockedTelnet == nil)
#expect(app.workspaces.count == before)
#expect(app.pro.isPaywallPresented)
let sshInput = MobileWorkspace(
descriptor: .init(hostID: hosts[0].id, title: hosts[0].name, kind: .ssh), hosts: app.library)
#expect(throws: HostAccessError.lockedHost(hosts[3].id)) { try sshInput.requireTerminalInputAccess() }
}
@Test(arguments: [EntitlementStatus.purchased(.purchased), .free, .verificationFailed])
func duplicateWaitsAndPresentsOnlyTheApplicableAccessOutcome(status: EntitlementStatus) async throws {
let app = try makeApp(status: .loading, count: 3)
defer {
app.metrics.core.shutdown()
app.outbox.stop()
}
let hosts = app.library.hosts
var continuation: CheckedContinuation<Void, Never>?
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))
}
}
@@ -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 {}
}
+13 -10
View File
@@ -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(
+11 -6
View File
@@ -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()
}
+56 -2
View File
@@ -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<Void, Never>?
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 } }
}
+16 -10
View File
@@ -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)
}
}
+19 -9
View File
@@ -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 {
+16 -22
View File
@@ -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)
}
}
@@ -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<Content: View>: View {
@ViewBuilder let content: Content
@@ -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 {
+107
View File
@@ -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<Void, Never>?
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<Void, Never>?
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))
}
}
+4 -1
View File
@@ -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 })
+55 -6
View File
@@ -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<Void, Never>?
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 } }
}