diff --git a/FrameworkTests/HostAccessTests.swift b/FrameworkTests/HostAccessTests.swift new file mode 100644 index 00000000..8cc42d22 --- /dev/null +++ b/FrameworkTests/HostAccessTests.swift @@ -0,0 +1,262 @@ +import Foundation +import SwiftData +import TermindApplication +import TermindDomain +import TermindPersistence +import Testing + +import struct TermindDomain.Host + +@Suite @MainActor +struct HostAccessTests { + private func fixtures(_ count: Int) -> [Host] { + (0..? + library.resolveInitialHostEntitlement = { + await withCheckedContinuation { continuation = $0 } + library.hostAccess.entitlement = .purchased(.purchased) + } + let added = Host(name: "New", host: "new.invalid") + let operation = Task { + try await library.resolveSavingHostsAccess([added]) + return library.saveHost(added) + } + defer { library.resolveInitialHostEntitlement = nil } + for _ in 0..<200 where continuation == nil { try await Task.sleep(for: .milliseconds(5)) } + #expect(continuation != nil) + #expect(library.hosts.count == 3) + #expect(library.host(added.id) == nil) + continuation?.resume() + #expect(try await operation.value) + #expect(library.host(added.id) != nil) + } + + @Test func onlyExcessOperationsWaitForInitialEntitlement() async throws { + let hosts = fixtures(4) + let library = HostLibraryModel(hosts: hosts) + library.hostAccess.entitlement = .loading + var refreshes = 0 + library.resolveInitialHostEntitlement = { + refreshes += 1 + library.hostAccess.entitlement = .purchased(.purchased) + } + defer { library.resolveInitialHostEntitlement = nil } + try await library.resolveHostAccess(hosts[0]) + try await library.resolveSavingHostsAccess([hosts[3]]) + #expect(refreshes == 0) + try await library.resolveHostAccess(hosts[3]) + #expect(refreshes == 1) + try library.requireHostAccess(hosts[3]) + } + + @Test(arguments: [EntitlementStatus.free, .verificationFailed]) + func resolvedFreeAndFailedVerificationDoNotWriteAndOnlyFreePresentsUpgrade(status: EntitlementStatus) async { + let library = HostLibraryModel(hosts: fixtures(3)) + library.hostAccess.entitlement = .loading + var upgrades = 0 + library.requestHostUpgrade = { upgrades += 1 } + library.resolveInitialHostEntitlement = { library.hostAccess.entitlement = status } + defer { library.resolveInitialHostEntitlement = nil } + let added = Host(name: "New", host: "new.invalid") + do { + try await library.resolveSavingHostsAccess([added]) + Issue.record("A non-Pro entitlement cannot admit another host") + } catch { + #expect((error as? HostAccessError) == (status == .free ? .hostLimit : .entitlementVerificationFailed)) + } + #expect(library.hosts.count == 3) + #expect(upgrades == (status == .free ? 1 : 0)) + } + + @Test func creationOrderIgnoresDisplayOrderAndPromotesAfterDeletion() { + var hosts = fixtures(5) + hosts[0].sortIndex = 99 + hosts[0].name = "Z" + let policy = HostAccessPolicy(entitlement: .free) + #expect(policy.unlockedIDs(in: Array(hosts.reversed())) == Set(hosts.prefix(3).map(\.id))) + #expect(policy.isLocked(hosts[3].id, in: hosts)) + hosts.removeFirst() + #expect(!policy.isLocked(hosts[2].id, in: hosts)) + } + + @Test func legacyOrderingIsStableAndPrecedesNewHosts() { + var hosts = fixtures(5) + for index in 0..<4 { hosts[index].createdAt = nil } + let expected = Set(hosts.prefix(4).sorted { $0.id.uuidString < $1.id.uuidString }.prefix(3).map(\.id)) + let policy = HostAccessPolicy(entitlement: .free) + #expect(policy.unlockedIDs(in: hosts) == expected) + #expect(policy.unlockedIDs(in: Array(hosts.reversed())) == expected) + } + + @Test func savedQuotaCountsDistinctAdditionsAndAllowsOverQuotaEditing() throws { + let hosts = fixtures(4) + let policy = HostAccessPolicy(entitlement: .free) + try policy.requireSaving([hosts[0], hosts[0]], in: hosts) + try policy.requireSaving([hosts[2], hosts[2]], in: Array(hosts.prefix(2))) + #expect(throws: HostAccessError.hostLimit) { + try policy.requireSaving([hosts[2], hosts[3]], in: Array(hosts.prefix(2))) + } + try HostAccessPolicy(entitlement: .purchased(.purchased)).requireSaving(hosts, in: []) + } + + @Test func booleanSavePresentsQuotaWithoutAStorageError() { + let library = HostLibraryModel(hosts: fixtures(3)) + library.hostAccess.entitlement = .free + var upgrades = 0 + library.requestHostUpgrade = { upgrades += 1 } + let added = Host(name: "New", host: "new.invalid") + #expect(!library.saveHost(added)) + #expect(library.storageError == nil) + #expect(library.host(added.id) == nil) + #expect(upgrades == 1) + } + + @Test func unresolvedEntitlementsDoNotShowLocksButPreventExcessOperations() { + let hosts = fixtures(4) + for status in [EntitlementStatus.loading, .verificationFailed] { + let policy = HostAccessPolicy(entitlement: status) + #expect(!policy.isLocked(hosts[3].id, in: hosts)) + #expect(throws: HostAccessError.self) { try policy.requireAccess(to: hosts[3].id, in: hosts) } + } + } + + @Test func writesAreRejectedBeforeCustomIdentityOrImportSideEffects() throws { + let library = HostLibraryModel(hosts: fixtures(3)) + library.hostAccess.entitlement = .free + var upgrades = 0 + library.requestHostUpgrade = { upgrades += 1 } + var host = Host(name: "New", host: "new.invalid") + var draft = HostCustomIdentityDraft() + draft.sshUsername = "root" + draft.sshPassword = "not-persisted" + #expect(throws: HostAccessError.hostLimit) { try draft.save(into: &host, using: library) } + #expect(library.identities.isEmpty) + var batch = SSHImportBatch() + batch.hosts = [host] + batch.identities = [Identity(name: "Imported", username: "root")] + #expect(throws: HostAccessError.hostLimit) { try library.commitSSHImport(batch) } + #expect(library.hosts.count == 3) + #expect(library.identities.isEmpty) + #expect(upgrades == 2) + } + + @Test func cachedAdmissionChecksTrackExpiryAndJumpHostLocks() throws { + let hosts = fixtures(4) + let library = HostLibraryModel(hosts: hosts) + let check = try #require(library.connectionAccessCheck(for: [hosts[3].id])) + try check() + library.hostAccess.entitlement = .free + #expect(throws: HostAccessError.lockedHost(hosts[3].id)) { try check() } + var target = hosts[0] + target.jumpHostID = hosts[3].id + #expect(throws: HostAccessError.lockedHost(hosts[3].id)) { try library.requireHostAccess(target) } + library.hostAccess.entitlement = .purchased(.purchased) + try check() + } + + @Test func localCreationStampsOnceAndCopiesGetANewDate() throws { + let library = HostLibraryModel() + var host = Host(name: "New", host: "new.invalid") + #expect(library.saveHost(host)) + let original = try #require(library.host(host.id)?.createdAt) + host.name = "Renamed" + #expect(library.saveHost(host)) + #expect(library.host(host.id)?.createdAt == original) + var copy = try #require(library.host(host.id)) + copy.id = UUID() + copy.createdAt = .distantPast + #expect(library.saveHost(copy)) + #expect(library.host(copy.id)?.createdAt != .distantPast) + } + + @Test func preparedAliasRouteRetainsSavedAdmissionAfterExpiry() async throws { + let hosts = fixtures(4) + let library = HostLibraryModel(hosts: hosts) + var alias = hosts[3] + alias.id = UUID() + let route = SSHConnectionRoute(hosts: [alias], sourceHostIDs: [alias.id: hosts[3].id]) + let preparation = try library.prepareSSHConnection(for: route, submissions: [:]) + let configuration = try await preparation.resolve(discoveryOnly: true) + #expect(configuration.accessHostIDs.contains(hosts[3].id)) + try configuration.accessCheck?() + library.hostAccess.entitlement = .free + #expect(throws: HostAccessError.lockedHost(hosts[3].id)) { try configuration.accessCheck?() } + } + + @Test func historicalStoreMigratesAndCreationDatesSurviveReopening() throws { + let host = Host(name: "Legacy", host: "legacy.invalid") + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("store") + do { + let schema = Schema(versionedSchema: TermindSchemaV57.self) + let old = try ModelContainer( + for: schema, + configurations: ModelConfiguration("Termind", schema: schema, url: url, cloudKitDatabase: .none)) + old.mainContext.insert(TermindSchemaV56HostModels.PersistentHost(host)) + try old.mainContext.save() + } + let created = fixtures(1)[0] + do { + let container = try TermindPersistence.makeContainer(url: url) + let store = HostPersistence(context: container.mainContext) + #expect(try store.load().hosts == [host]) + _ = try store.save(.init(hosts: [host, created])) + } + let reopened = try TermindPersistence.makeContainer(url: url) + let saved = try HostPersistence(context: reopened.mainContext).load().hosts + #expect(saved.first(where: { $0.id == host.id })?.createdAt == nil) + #expect(saved.first(where: { $0.id == created.id })?.createdAt == created.createdAt) + } + + @Test func hostWireRetainsDatesAndRejectsLossyOldEncoding() throws { + let host = fixtures(1)[0] + let payload = VaultRecordPayload.host(host) + let envelope = VaultEnvelope(key: payload.key, version: .baseline(writerID: UUID()), payload: payload) + #expect(try VaultSyncCodec.decode(VaultSyncCodec.encode(envelope)) == envelope) + var old = envelope + old.formatVersion = 7 + #expect(throws: (any Error).self) { try VaultSyncCodec.encode(old) } + var legacy = host + legacy.createdAt = nil + old.payload = .host(legacy) + #expect(try VaultSyncCodec.decode(VaultSyncCodec.encode(old)) == old) + var snapshot = VaultSnapshot(hosts: [host]) + try snapshot.apply(old) + #expect(snapshot.hosts[0].createdAt == host.createdAt) + } + + @Test func legacyRemoteUpdatesPreserveKnownDatesInBothProjectionAndJournal() throws { + let container = try TermindPersistence.makeContainer(inMemory: true) + let host = fixtures(1)[0] + container.mainContext.insert(PersistentHost(host)) + try container.mainContext.save() + var legacy = host + legacy.createdAt = nil + legacy.name = "Updated by an older client" + let payload = VaultRecordPayload.host(legacy) + var envelope = VaultEnvelope( + key: payload.key, version: .baseline(writerID: UUID()), payload: payload) + envelope.formatVersion = 7 + let result = try HostPersistence(context: container.mainContext).applyRemote([envelope]) + #expect(result.snapshot.hosts.first?.name == legacy.name) + #expect(result.snapshot.hosts.first?.createdAt == host.createdAt) + #expect(result.needsUpload.contains(envelope.key)) + let pending = try VaultSyncJournal(context: container.mainContext).pendingEnvelopes() + guard case .host(let saved) = pending.first(where: { $0.key == envelope.key })?.payload else { + Issue.record("The repaired host date must be journaled for upload") + return + } + #expect(saved.createdAt == host.createdAt) + } +} diff --git a/FrameworkTests/HostOperationAdmissionTests.swift b/FrameworkTests/HostOperationAdmissionTests.swift new file mode 100644 index 00000000..6d4bd180 --- /dev/null +++ b/FrameworkTests/HostOperationAdmissionTests.swift @@ -0,0 +1,78 @@ +import Foundation +import TermindApplication +import TermindDomain +import Testing + +@testable import TermindRemoteInfrastructure + +@Suite @MainActor +struct HostOperationAdmissionTests { + @Test func expiryBlocksNewSFTPOperationsWithoutCancellingAnAdmittedTransfer() async throws { + let hosts = (0..<4).map { + TermindDomain.Host( + name: "Host \($0)", host: "host\($0).invalid", + createdAt: Date(timeIntervalSince1970: Double($0))) + } + let library = HostLibraryModel(hosts: hosts) + let raw = AdmissionSFTPChannel() + let channel = AccessControlledSFTPChannel( + channel: raw, accessCheck: library.connectionAccessCheck(for: [hosts[3].id])) + let upload = Task { + try await channel.uploadFile( + from: URL(fileURLWithPath: "/test"), to: "/remote", overwrite: false, + cancellation: TransferCancellationToken(), progress: { _ in }) + } + for _ in 0..<200 where !(await raw.isUploading) { try await Task.sleep(for: .milliseconds(5)) } + #expect(await raw.isUploading) + library.hostAccess.entitlement = .free + await #expect(throws: HostAccessError.lockedHost(hosts[3].id)) { + try await channel.listDirectory(at: "/") + } + #expect(await raw.directoryReads == 0) + #expect(await raw.disconnections == 0) + await raw.finishUpload() + try await upload.value + #expect(await raw.completedUploads == 1) + library.hostAccess.entitlement = .purchased(.purchased) + _ = try await channel.listDirectory(at: "/") + #expect(await raw.directoryReads == 1) + } +} + +private actor AdmissionSFTPChannel: SFTPChannelProtocol { + private var upload: CheckedContinuation? + var isUploading: Bool { upload != nil } + var directoryReads = 0 + var disconnections = 0 + var completedUploads = 0 + + func finishUpload() { + upload?.resume() + upload = nil + } + func disconnect() async { disconnections += 1 } + func homeDirectory() async throws -> String { "/" } + func listDirectory(at path: String) async throws -> [RemoteFile] { + directoryReads += 1 + return [] + } + func attributes(at path: String) async throws -> RemoteFile? { nil } + func setPermissions(at path: String, to permissions: UInt32) async throws {} + func createDirectory(at path: String) async throws {} + func rename(from source: String, to destination: String, overwrite: Bool) async throws {} + func removeFile(at path: String) async throws {} + func removeDirectory(at path: String) async throws {} + func uploadFile( + from localURL: URL, to remotePath: String, overwrite: Bool, + cancellation: TransferCancellationToken, + progress: @escaping @Sendable (SFTPProgress) -> Void + ) async throws { + await withCheckedContinuation { upload = $0 } + completedUploads += 1 + } + func downloadFile( + from remotePath: String, to localURL: URL, overwrite: Bool, + cancellation: TransferCancellationToken, + progress: @escaping @Sendable (SFTPProgress) -> Void + ) async throws {} +} diff --git a/FrameworkTests/LocalNetworkActionTests.swift b/FrameworkTests/LocalNetworkActionTests.swift index 13bd7fd2..29aa771f 100644 --- a/FrameworkTests/LocalNetworkActionTests.swift +++ b/FrameworkTests/LocalNetworkActionTests.swift @@ -41,7 +41,7 @@ struct LocalNetworkActionTests { let originalHosts = library.hosts let originalIdentities = library.identities let sender = ManualWakeSender() - library.wakeOnLAN = WakeOnLANController(sender: sender, hasAccess: { true }) + library.wakeOnLAN = WakeOnLANController(sender: sender) var draft = HostCustomIdentityDraft(host: host, library: library) draft.sshUsername = "draft-user" draft.sshPassword = "draft-password" @@ -98,69 +98,51 @@ struct LocalNetworkActionTests { #expect(library.groups == [group]) } - @Test func manualWakeRequestsAccessBeforeConfigurationAndDoesNotReplay() { - let library = HostLibraryModel() - let sender = ManualWakeSender() - let access = ManualWakeAccess() - library.wakeOnLAN = WakeOnLANController(sender: sender, hasAccess: { access.allowed }) - let missing = Host(name: "Missing", host: "missing.invalid") - var configured = missing - configured.wakeOnLAN.macAddress = "02:11:22:33:44:55" - var requests = 0 - for host in [missing, configured] { - #expect( - library.requestWake(host) { - requests += 1 - return access.allowed - } == .accessDenied) + @Test func lockedManualWakeNeverSendsOrRequestsConfiguration() { + var hosts = (0..<4).map { + Host(name: "Host \($0)", host: "host.invalid", createdAt: Date(timeIntervalSince1970: Double($0))) } - #expect(requests == 2) - access.allowed = true + hosts[3].wakeOnLAN.macAddress = "02:11:22:33:44:55" + let library = HostLibraryModel(hosts: hosts) + library.hostAccess.entitlement = .free + let sender = ManualWakeSender() + library.wakeOnLAN = WakeOnLANController(sender: sender) + #expect(library.requestWake(hosts[3]) == .accessDenied) #expect(sender.sent.isEmpty) - #expect(library.requestWake(missing) { true } == .configurationRequired) - #expect(sender.sent.isEmpty) - #expect(library.requestWake(configured) { true } == .sent) - #expect(sender.sent.map(\.rawValue) == ["02:11:22:33:44:55"]) + library.hostAccess.entitlement = .purchased(.purchased) + #expect(library.requestWake(hosts[3]) == .sent) + #expect(sender.sent.count == 1) } - @Test func manualWakeReadsSavedPreferencesAfterAccessInsteadOfTheStaleCaller() { + @Test func manualWakeReadsLatestSavedPreferences() { var host = Host(name: "Host", host: "host.invalid") host.wakeOnLAN.macAddress = "02:11:22:33:44:55" let library = HostLibraryModel(hosts: [host]) let sender = ManualWakeSender() - library.wakeOnLAN = WakeOnLANController(sender: sender, hasAccess: { true }) - #expect( - library.requestWake(host) { - var updated = host - updated.wakeOnLAN.macAddress = "02:11:22:33:44:66" - library.upsert(updated) - return true - } == .sent) + library.wakeOnLAN = WakeOnLANController(sender: sender) + var updated = host + updated.wakeOnLAN.macAddress = "02:11:22:33:44:66" + library.upsert(updated) + #expect(library.requestWake(host) == .sent) #expect(sender.sent.map(\.rawValue) == ["02:11:22:33:44:66"]) - var missing = host - missing.wakeOnLAN = .init() - library.upsert(missing) - #expect(library.requestWake(host) { true } == .configurationRequired) + updated.wakeOnLAN = .init() + library.upsert(updated) + #expect(library.requestWake(host) == .configurationRequired) #expect(sender.sent.count == 1) } - @Test func manualWakeReportsMissingServiceRevokedAccessAndSendFailure() { + @Test func manualWakeReportsMissingServiceAndSendFailure() { var host = Host(name: "Host", host: "host.invalid") host.wakeOnLAN.macAddress = "02:11:22:33:44:55" let library = HostLibraryModel() - #expect(library.requestWake(host) { true } == .failed) + #expect(library.requestWake(host) == .failed) let sender = ManualWakeSender() - let access = ManualWakeAccess() - library.wakeOnLAN = WakeOnLANController(sender: sender, hasAccess: { access.allowed }) - #expect(library.requestWake(host) { true } == .failed) - #expect(sender.sent.isEmpty) - access.allowed = true + library.wakeOnLAN = WakeOnLANController(sender: sender) sender.fails = true - #expect(library.requestWake(host) { true } == .failed) + #expect(library.requestWake(host) == .failed) #expect(sender.sent.isEmpty) sender.fails = false - #expect(library.requestWake(host) { true } == .sent) - #expect(sender.sent.count == 1) + #expect(library.requestWake(host) == .sent) } private func finish(_ model: HostMACAddressModel) async throws { diff --git a/FrameworkTests/LocalNetworkTests.swift b/FrameworkTests/LocalNetworkTests.swift index c43a30db..f839bfa6 100644 --- a/FrameworkTests/LocalNetworkTests.swift +++ b/FrameworkTests/LocalNetworkTests.swift @@ -32,15 +32,11 @@ struct LocalNetworkTests { } } - @Test func sendingChecksLiveAccessAndOnlyAutoUsesCooldown() throws { + @Test func sendingIsFreeAndOnlyAutoUsesCooldown() throws { let sender = RecordingWakeSender() let state = WakeTestState() - let controller = WakeOnLANController(sender: sender, hasAccess: { state.allowed }, now: { state.clock }) + let controller = WakeOnLANController(sender: sender, now: { state.clock }) let preferences = WakeOnLANPreferences(macAddress: "02:11:22:33:44:55", automaticallyWake: true) - #expect(throws: WakeOnLANError.self) { try controller.wake(preferences) } - controller.automaticallyWake(preferences) - #expect(sender.sent.isEmpty) - state.allowed = true controller.automaticallyWake(preferences) controller.automaticallyWake(preferences) #expect(sender.sent.count == 1) @@ -50,24 +46,16 @@ struct LocalNetworkTests { state.clock += 3 controller.automaticallyWake(preferences) #expect(sender.sent.count == 4) - state.allowed = false - state.clock += 4 - controller.automaticallyWake(preferences) - #expect(sender.sent.count == 4) - state.allowed = true - controller.automaticallyWake(preferences) - #expect(sender.sent.count == 5) - #expect(preferences.automaticallyWake) } @Test func automaticFailuresAndCancellationNeverRequireInteraction() async { let sender = RecordingWakeSender(fails: true) let access = ProAccessController.testing(status: .free) - let free = WakeOnLANController(sender: sender, hasAccess: { access.canUse(.autoWakeOnLAN) }) + let free = WakeOnLANController(sender: sender) let preferences = WakeOnLANPreferences(macAddress: "02:11:22:33:44:55", automaticallyWake: true) free.automaticallyWake(preferences) #expect(!access.isPaywallPresented) - let controller = WakeOnLANController(sender: sender, hasAccess: { true }) + let controller = WakeOnLANController(sender: sender) var failures = 0 controller.reportAutomaticFailure = { _ in failures += 1 } controller.automaticallyWake(preferences) @@ -88,7 +76,7 @@ struct LocalNetworkTests { target.jumpHostID = jump.id let library = HostLibraryModel(hosts: [jump, target], identities: [identity]) let sender = RecordingWakeSender() - library.wakeOnLAN = WakeOnLANController(sender: sender, hasAccess: { true }) + library.wakeOnLAN = WakeOnLANController(sender: sender) let preparation = try library.prepareSSHConnection(for: target, submissions: [:]) let configuration = try await preparation.resolve(trustPolicy: .allowChallenge) jump.wakeOnLAN = .init(macAddress: "02:11:22:33:44:55", automaticallyWake: true) diff --git a/FrameworkTests/MetricsHostAccessTests.swift b/FrameworkTests/MetricsHostAccessTests.swift new file mode 100644 index 00000000..ca9fd0f9 --- /dev/null +++ b/FrameworkTests/MetricsHostAccessTests.swift @@ -0,0 +1,69 @@ +import Foundation +import TermindApplication +import TermindDomain +import Testing + +@Suite @MainActor +struct MetricsHostAccessTests { + @Test(arguments: [false, true]) + func lockedTargetsAndJumpsKeepSelectionsAndCacheWithoutStartingWorkers(lockedJump: Bool) throws { + var hosts = (0..<4).map { + TermindDomain.Host( + name: "Host \($0)", host: "host\($0).invalid", + createdAt: Date(timeIntervalSince1970: Double($0))) + } + if lockedJump { hosts[0].jumpHostID = hosts[3].id } + let target = hosts[lockedJump ? 0 : 3] + let library = HostLibraryModel(hosts: hosts) + let preferences = LockedMetricsPreferences() + let model = ServerMetricsModel( + hosts: library, preferences: preferences, + makeSampler: { preconditionFailure("A locked or paused monitor must not create a sampler") }) + model.suspend() + defer { model.shutdown() } + #expect(model.add(target)) + let server = try #require(model.servers.first) + server.point = MetricsPoint(date: .now, cpu: 75) + server.history = [try #require(server.point)] + library.hostAccess.entitlement = .free + model.reconcile() + model.resume() + model.reconcile() + #expect(server.isHostLocked) + #expect(!server.isConnecting) + #expect(!server.showsConnectionJourney) + #expect(model.snapshot(for: target.id)?.state == .locked) + #expect(server.point?.cpu == 75) + #expect(server.history.count == 1) + #expect(preferences.values.map(\.id) == [target.id]) + model.remove(target.id) + #expect(model.servers.isEmpty) + #expect(preferences.values.isEmpty) + } + + @Test(arguments: [EntitlementStatus.loading, .verificationFailed]) + func unresolvedAccessIsNotPresentedAsAQuotaLock(status: EntitlementStatus) throws { + let hosts = (0..<4).map { + TermindDomain.Host( + name: "Host \($0)", host: "host\($0).invalid", + createdAt: Date(timeIntervalSince1970: Double($0))) + } + let library = HostLibraryModel(hosts: hosts) + library.hostAccess.entitlement = status + let model = ServerMetricsModel( + hosts: library, preferences: LockedMetricsPreferences(), + makeSampler: { preconditionFailure("Unresolved access must not start a sampler") }) + defer { model.shutdown() } + #expect(model.add(hosts[3])) + let server = try #require(model.servers.first) + #expect(!server.isHostLocked) + #expect(server.hostAccessError != nil) + #expect(model.snapshot(for: server.id)?.state == (status == .loading ? .connecting : .failed)) + } +} + +@MainActor private final class LockedMetricsPreferences: MetricsConfigurationStoring { + var values: [MetricsSelection] = [] + func load() -> [MetricsSelection] { values } + func save(_ selections: [MetricsSelection]) { values = selections } +} diff --git a/FrameworkTests/RevenueCatCommerceTests.swift b/FrameworkTests/RevenueCatCommerceTests.swift index 2ea9ec77..68f9d6f9 100644 --- a/FrameworkTests/RevenueCatCommerceTests.swift +++ b/FrameworkTests/RevenueCatCommerceTests.swift @@ -119,8 +119,8 @@ struct RevenueCatCommerceTests { @Test func accessChangesNotifyRuntimeWithoutAVisibleViewOrPaywall() async { let service = FakeService() let controller = ProAccessController(purchaseService: service, observeUpdates: false) - var observed: [Bool] = [] - controller.accessDidChange = { observed.append(controller.hasProAccess) } + var observed: [EntitlementStatus] = [] + controller.accessDidChange = { observed.append(controller.status) } defer { controller.accessDidChange = nil } await controller.refresh() service.status = .purchased(.purchased) @@ -129,7 +129,7 @@ struct RevenueCatCommerceTests { await controller.refresh() service.status = .free await controller.refresh() - #expect(observed == [true, false]) + #expect(observed == [.free, .purchased(.purchased), .purchased(.familyShared), .free]) #expect(!controller.isPaywallPresented) } diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 642619f8..2f203d6c 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -13434,6 +13434,47 @@ } } }, + "Checking Pro access…" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pro-Zugriff wird geprüft…" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vérification de l’accès Pro…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Proのアクセス権を確認中…" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pro 이용 권한 확인 중…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在检查Pro权限…" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在檢查Pro權限…" + } + } + } + }, "Checking iCloud…" : { "extractionState" : "manual", "localizations" : { @@ -80786,6 +80827,47 @@ } } }, + "Unable to verify Pro access. Please try again." : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pro-Zugriff konnte nicht bestätigt werden. Bitte erneut versuchen." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossible de vérifier l’accès Pro. Veuillez réessayer." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Proのアクセス権を確認できません。再試行してください。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pro 이용 권한을 확인할 수 없습니다. 다시 시도하세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法验证Pro权益,请重试。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法驗證Pro權益,請重試。" + } + } + } + }, "Unarchive" : { "extractionState" : "manual", "localizations" : { @@ -82114,6 +82196,47 @@ } } }, + "Upgrade to Pro to unlock this host." : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Führe ein Upgrade auf Pro durch, um diesen Host freizuschalten." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Passez à Pro pour déverrouiller cet hôte." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Proにアップグレードしてこのホストをロック解除。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pro로 업그레이드하여 이 호스트의 잠금을 해제하세요." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "升级到Pro以解锁此主机。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "升級至Pro以解鎖此主機。" + } + } + } + }, "Upload" : { "extractionState" : "manual", "localizations" : { diff --git a/Frameworks/TermindApplication/Connections/HostOperationAccess.swift b/Frameworks/TermindApplication/Connections/HostOperationAccess.swift new file mode 100644 index 00000000..4909536b --- /dev/null +++ b/Frameworks/TermindApplication/Connections/HostOperationAccess.swift @@ -0,0 +1,28 @@ +import Foundation +import TermindDomain + +import struct TermindDomain.Host + +public typealias HostOperationAccessCheck = @Sendable () throws -> Void + +/// Connection snapshots consult current access without retaining the main-actor library. +public nonisolated final class HostOperationAccess: @unchecked Sendable { + private let lock = NSLock() + private var policy = HostAccessPolicy(entitlement: .unrestrictedForTesting) + private var hosts: [Host] = [] + + public init() {} + + public func update(policy: HostAccessPolicy, hosts: [Host]) { + lock.withLock { + self.policy = policy + self.hosts = hosts + } + } + + public func check(_ ids: [UUID]) throws { + try lock.withLock { + for id in ids { try policy.requireAccess(to: id, in: hosts) } + } + } +} diff --git a/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift b/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift index 22b53f0c..351b3654 100644 --- a/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift +++ b/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift @@ -8,6 +8,10 @@ import struct TermindDomain.Host /// the complete Vault mutation surface from `HostLibraryModel`. @MainActor public protocol RemoteSessionHostStore: AnyObject, Sendable { + func connectionAccessCheck(for hostIDs: [UUID]) -> HostOperationAccessCheck? + func requireHostAccess(_ host: Host) throws + func resolveHostAccess(_ host: Host) async throws + func requireSavingHosts(_ proposed: [Host]) throws func connectionWakeAction(for hostIDs: [UUID]) -> ConnectionWakeAction? func host(_ id: Host.ID) -> Host? func networkConfigurationStamp(for host: Host) -> Data @@ -70,6 +74,17 @@ 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) } + public func resolveHostAccess(_ host: Host, route: SSHConnectionRoute?) async throws { + try await resolveHostAccess(host) + for node in route?.hosts ?? [] { try await resolveHostAccess(node) } + for id in route.map({ Array($0.sourceHostIDs.values) }) ?? [] { + if let saved = self.host(id) { try await resolveHostAccess(saved) } + } + } + public func requireSavingHosts(_ proposed: [Host]) throws {} public func connectionWakeAction(for hostIDs: [UUID]) -> ConnectionWakeAction? { nil } public func savedTailscaleUsername(for host: Host) -> String? { nil } public func tailscaleUsername(for host: Host) -> String { "root" } @@ -118,6 +133,7 @@ extension RemoteSessionHostStore { } public func telnetConnectionConfiguration(for host: Host) throws -> TelnetConnectionConfiguration { + try requireHostAccess(host) guard host.telnetEnabled else { throw HostProtocolUnavailableError(useCase: .telnetTerminal) } let identity = identity(for: host, protocol: .telnet) let username = identity?.username.trimmingCharacters(in: .whitespacesAndNewlines) @@ -155,6 +171,7 @@ extension RemoteSessionHostStore { _ submission: SSHAuthenticationSubmission, for host: Host, selectedIdentity: Identity?, suppliedPassword: String? ) throws -> Host { + try requireSavingHosts([host]) if host.isTailscaleManaged { guard saveTailscaleUsername(submission.username, hostID: host.id) else { throw TailscaleConfigurationError.unavailable @@ -213,6 +230,7 @@ extension RemoteSessionHostStore { _ submission: SSHProxyAuthenticationSubmission, for host: Host, selectedIdentity: Identity?, suppliedPassword: String? ) throws -> Host { + try requireSavingHosts([host]) guard var proxy = host.proxy else { throw TermindSSHError.proxy(String(localized: "The proxy configuration is incomplete.")) } @@ -291,6 +309,7 @@ extension RemoteSessionHostStore { ) } var result = SSHAdditionalConnectionSnapshot(target: target, jumps: jumps) + result.accessHostIDs = configuration.accessHostIDs result.tailscaleNodeID = configuration.tailscaleNodeID result.requiresTailscale = configuration.network != nil return result @@ -301,6 +320,12 @@ extension RemoteSessionHostStore { public func additionalConnectionRequest( from snapshot: SSHAdditionalConnectionSnapshot ) throws -> SSHConnectionRequest { + for id in snapshot.accessHostIDs { + if let saved = host(id) { try requireHostAccess(saved) } + } + for node in snapshot.jumps + [snapshot.target] { + if let saved = host(node.id) { try requireHostAccess(saved) } + } var completed = false var resolvedCredentials: [SSHCredentials] = [] var resolvedProxies: [SSHSecret] = [] @@ -413,6 +438,9 @@ extension RemoteSessionHostStore { charset: snapshot.target.charset, authenticationReference: snapshot.target.authentication ) + configuration.accessHostIDs = snapshot.accessHostIDs + configuration.accessCheck = connectionAccessCheck( + for: snapshot.accessHostIDs + snapshot.jumps.map(\.id) + [snapshot.target.id]) configuration.beforeConnect = connectionWakeAction(for: snapshot.jumps.map(\.id) + [snapshot.target.id]) configuration.tailscaleNodeID = snapshot.tailscaleNodeID if let first = host(snapshot.jumps.first?.id ?? snapshot.target.id) { diff --git a/Frameworks/TermindApplication/Connections/ResumableRouteRequest.swift b/Frameworks/TermindApplication/Connections/ResumableRouteRequest.swift index e926cb88..42b795d6 100644 --- a/Frameworks/TermindApplication/Connections/ResumableRouteRequest.swift +++ b/Frameworks/TermindApplication/Connections/ResumableRouteRequest.swift @@ -26,6 +26,7 @@ extension RemoteSessionHostStore { } guard let target = nodes.last else { throw SSHConnectionRoute.invalidRouteError } var snapshot = SSHAdditionalConnectionSnapshot(target: target, jumps: Array(nodes.dropLast())) + snapshot.accessHostIDs = preparation.accessHostIDs snapshot.tailscaleNodeID = host.tailscale?.stableNodeID snapshot.requiresTailscale = preparation.nodes.contains { $0.network != nil } return snapshot @@ -41,6 +42,11 @@ extension RemoteSessionHostStore { hostKeyConstraints: [Host.ID: SSHHostKeyConstraint], authenticatingTarget: Bool = false ) async throws -> SSHConnectionRequest { + let accessIDs = snapshot.accessHostIDs + snapshot.jumps.map(\.id) + [snapshot.target.id] + for id in accessIDs { + if let saved = host(id) { try await resolveHostAccess(saved) } + } + try connectionAccessCheck(for: accessIDs)?() let connectionID = UUID() var resolvedCredentials: [SSHCredentials] = [] var resolvedProxies: [SSHSecret] = [] @@ -157,7 +163,9 @@ extension RemoteSessionHostStore { credentials: target?.credentials ?? [], expectedHostKeys: target?.expectedHostKeys ?? [], proxy: targetProxy, jumps: jumps, charset: snapshot.target.charset, authenticationReference: snapshot.target.authentication) - configuration.beforeConnect = connectionWakeAction(for: snapshot.jumps.map(\.id) + [snapshot.target.id]) + configuration.accessHostIDs = accessIDs + configuration.accessCheck = connectionAccessCheck(for: accessIDs) + configuration.beforeConnect = connectionWakeAction(for: accessIDs) configuration.tailscaleNodeID = snapshot.tailscaleNodeID if let first = host(snapshot.jumps.first?.id ?? snapshot.target.id) { configuration.network = try tailscaleNetwork(for: first) diff --git a/Frameworks/TermindApplication/Connections/SSHAdditionalConnectionSnapshot.swift b/Frameworks/TermindApplication/Connections/SSHAdditionalConnectionSnapshot.swift index 6f1cf391..d75194fe 100644 --- a/Frameworks/TermindApplication/Connections/SSHAdditionalConnectionSnapshot.swift +++ b/Frameworks/TermindApplication/Connections/SSHAdditionalConnectionSnapshot.swift @@ -53,6 +53,7 @@ public nonisolated struct SSHAdditionalConnectionSnapshot: Equatable, Sendable { } } + public var accessHostIDs: [UUID] = [] public var tailscaleNodeID: String? public var requiresTailscale = false public var target: Node @@ -67,6 +68,7 @@ public nonisolated struct SSHAdditionalConnectionSnapshot: Equatable, Sendable { /// explicit Identity/Key provenance. Durable trust may be refreshed by the /// host store before this value is retained for additional connections. public init(configuration: SSHConnectionConfiguration) { + accessHostIDs = configuration.accessHostIDs tailscaleNodeID = configuration.tailscaleNodeID requiresTailscale = configuration.network != nil func proxy(_ value: SSHTransportProxy?) -> Proxy? { diff --git a/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift b/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift index e205d7de..2916e573 100644 --- a/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift +++ b/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift @@ -31,6 +31,8 @@ public nonisolated struct SSHConnectionPreparation: Sendable { } } + public var accessHostIDs: [UUID] = [] + public var accessCheck: HostOperationAccessCheck? public var beforeConnect: ConnectionWakeAction? public var nodes: [Node] public var secrets: any SSHRuntimeSecretResolving @@ -124,6 +126,8 @@ public nonisolated struct SSHConnectionPreparation: Sendable { charset: target.host.charset, authenticationReference: authenticationReference(for: target) ) + configuration.accessHostIDs = accessHostIDs + configuration.accessCheck = accessCheck configuration.beforeConnect = beforeConnect configuration.network = nodes.first?.network configuration.tailscaleNodeID = target.host.tailscale?.stableNodeID diff --git a/Frameworks/TermindApplication/Connections/WakeOnLANController.swift b/Frameworks/TermindApplication/Connections/WakeOnLANController.swift index 2c459ea8..79ca1b24 100644 --- a/Frameworks/TermindApplication/Connections/WakeOnLANController.swift +++ b/Frameworks/TermindApplication/Connections/WakeOnLANController.swift @@ -7,7 +7,6 @@ public nonisolated protocol WakeOnLANSending: Sendable { } public nonisolated enum WakeOnLANError: Error, Sendable { - case requiresPro case invalidAddress case noLocalNetwork case sendFailed(Int32) @@ -19,30 +18,26 @@ public typealias ConnectionWakeAction = @Sendable () async -> Void @MainActor public final class WakeOnLANController { private let sender: any WakeOnLANSending - private let hasAccess: @MainActor () -> Bool private let now: @MainActor () -> TimeInterval private var lastAutomaticSend: [MACAddress: TimeInterval] = [:] public var reportAutomaticFailure: ((any Error) -> Void)? public init( sender: any WakeOnLANSending, - hasAccess: @escaping @MainActor () -> Bool, now: @escaping @MainActor () -> TimeInterval = { ProcessInfo.processInfo.systemUptime } ) { self.sender = sender - self.hasAccess = hasAccess self.now = now } public func wake(_ preferences: WakeOnLANPreferences) throws { try Task.checkCancellation() - guard hasAccess() else { throw WakeOnLANError.requiresPro } guard let address = preferences.address else { throw WakeOnLANError.invalidAddress } try sender.send(to: address) } public func automaticallyWake(_ preferences: WakeOnLANPreferences) { - guard !Task.isCancelled, hasAccess(), preferences.automaticallyWake, + guard !Task.isCancelled, preferences.automaticallyWake, let address = preferences.address else { return } let instant = now() diff --git a/Frameworks/TermindApplication/FeatureModels/HostCustomIdentityDraft.swift b/Frameworks/TermindApplication/FeatureModels/HostCustomIdentityDraft.swift index 0d552887..5fdf471b 100644 --- a/Frameworks/TermindApplication/FeatureModels/HostCustomIdentityDraft.swift +++ b/Frameworks/TermindApplication/FeatureModels/HostCustomIdentityDraft.swift @@ -221,6 +221,7 @@ public struct HostCustomIdentityDraft { } public mutating func save(into host: inout Host, using library: HostLibraryModel) throws { + try library.requireSavingHosts([host]) if usesCustomSSH(for: host) { let identity = try library.saveCustomIdentity( ownerHostID: host.id, purpose: .ssh, displayName: host.name, diff --git a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel+LocalNetwork.swift b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel+LocalNetwork.swift index bfdf5a89..9b4b9b7f 100644 --- a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel+LocalNetwork.swift +++ b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel+LocalNetwork.swift @@ -10,9 +10,9 @@ public nonisolated enum HostWakeOutcome: Equatable, Sendable { } extension HostLibraryModel { - /// Request access before configuration, then read the latest saved preferences. - public func requestWake(_ proposed: Host, requestAccess: () -> Bool) -> HostWakeOutcome { - guard requestAccess() else { return .accessDenied } + /// 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 } 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 1dd5b4b8..93096780 100644 --- a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift @@ -10,6 +10,58 @@ import struct TermindDomain.Host /// types for the views. Previews can still construct an in-memory store directly. @Observable public final class HostLibraryModel: RemoteSessionHostStore { + public var hostAccess = HostAccessPolicy(entitlement: .unrestrictedForTesting) { + didSet { operationAccess.update(policy: hostAccess, hosts: hosts) } + } + @ObservationIgnored private let operationAccess = HostOperationAccess() + public func connectionAccessCheck(for hostIDs: [UUID]) -> HostOperationAccessCheck? { + let access = operationAccess + return { try access.check(hostIDs) } + } + @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) { + try hostAccess.requireAccess(to: node.id, in: hosts) + } + } + + public func resolveHostAccess(_ host: Host) async throws { + do { try requireHostAccess(host) } catch HostAccessError.verifyingEntitlement { + await resolveInitialHostEntitlement?() + try Task.checkCancellation() + try requireHostAccess(host) + } + } + + public func resolveSavingHostsAccess(_ proposed: [Host], requestsUpgrade: Bool = true) async throws { + do { try hostAccess.requireSaving(proposed, in: hosts) } catch { + if case HostAccessError.verifyingEntitlement = error { + await resolveInitialHostEntitlement?() + try Task.checkCancellation() + } + } + if requestsUpgrade { + try requireSavingHosts(proposed) + } else { + try hostAccess.requireSaving(proposed, in: hosts) + } + } + + 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 + } + } @ObservationIgnored public weak var tailscaleConnections: (any TailscaleConnectionProviding)? @ObservationIgnored public var didChangeTailscaleGroups: (() -> Void)? /// Retires managed sessions when a binding is removed or replaced by another network. @@ -17,6 +69,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { private var tailscalePeers: [UUID: [TailscalePeer]] = [:] public private(set) var hosts: [Host] = [] { didSet { + operationAccess.update(policy: hostAccess, hosts: hosts) for old in oldValue where old.agentForwardingEnabled { if !hosts.contains(where: { $0.id == old.id && $0.agentForwardingEnabled }) { agentForwardingRevisions[old.id, default: 0] += 1 @@ -62,6 +115,12 @@ 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 + }) + else { return } for id in ids { guard !Task.isCancelled else { return } if let host = host(id) { wakeOnLAN?.automaticallyWake(host.wakeOnLAN) } @@ -471,6 +530,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { password: password, charset: host.telnetCharset, proxy: try terminalTransportProxy(for: host.telnetProxy, ownerHostID: host.id) ) + configuration.accessCheck = connectionAccessCheck(for: [host.id]) configuration.beforeConnect = connectionWakeAction(for: [host.id]) return configuration } @@ -530,6 +590,10 @@ public final class HostLibraryModel: RemoteSessionHostStore { temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] ) throws -> SSHConnectionPreparation { guard route.isValid else { throw SSHConnectionRoute.invalidRouteError } + for node in route.hosts { try requireHostAccess(node) } + for id in route.sourceHostIDs.values { + if let source = host(id) { try requireHostAccess(source) } + } let submissions = route.submissions(merging: submissions) var preparation = SSHConnectionPreparation( nodes: try route.hosts.map { node in @@ -558,7 +622,9 @@ public final class HostLibraryModel: RemoteSessionHostStore { }, secrets: keychain ) - preparation.beforeConnect = connectionWakeAction(for: route.hosts.map(\.id)) + preparation.accessHostIDs = route.hosts.map(\.id) + Array(route.sourceHostIDs.values) + preparation.accessCheck = connectionAccessCheck(for: preparation.accessHostIDs) + preparation.beforeConnect = connectionWakeAction(for: preparation.accessHostIDs) return preparation } @@ -569,6 +635,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] = [:], trustPolicy: SSHHostTrustPolicy = .requireKnownHost ) throws -> SSHConnectionConfiguration { + try requireHostAccess(host) if host.isTailscaleManaged { let username = (submissions[host.id]?.username ?? tailscaleUsername(for: host)) .trimmingCharacters(in: .whitespacesAndNewlines) @@ -578,6 +645,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { credentials: [], expectedHostKeys: [], charset: host.charset, authenticationReference: SSHAuthenticationReference(username: username, permitsNone: true) ) + configuration.accessCheck = connectionAccessCheck(for: [host.id]) configuration.beforeConnect = connectionWakeAction(for: [host.id]) configuration.network = try tailscaleNetwork(for: host) configuration.tailscaleNodeID = host.tailscale?.stableNodeID @@ -625,6 +693,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { host: host, submission: submissions[host.id] ) ) + configuration.accessCheck = connectionAccessCheck(for: route(for: host).map(\.id)) configuration.beforeConnect = connectionWakeAction(for: route(for: host).map(\.id)) configuration.network = try tailscaleNetwork(for: route(for: host).first ?? host) return configuration @@ -654,6 +723,10 @@ public final class HostLibraryModel: RemoteSessionHostStore { trustPolicy: SSHHostTrustPolicy = .requireKnownHost ) throws -> [SSHRouteHopConfiguration] { guard route.isValid else { throw SSHConnectionRoute.invalidRouteError } + for node in route.hosts { try requireHostAccess(node) } + for id in route.sourceHostIDs.values { + if let source = host(id) { try requireHostAccess(source) } + } return try sshJumpConfigurations( route: route.hosts, submissions: route.submissions(merging: submissions), proxySubmissions: proxySubmissions, @@ -941,11 +1014,13 @@ public final class HostLibraryModel: RemoteSessionHostStore { } public func isEligible(_ host: Host, for useCase: HostUseCase) -> Bool { + guard (try? requireHostAccess(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) guard isEligible(host, for: useCase) else { throw HostProtocolUnavailableError(useCase: useCase) } @@ -981,10 +1056,18 @@ public final class HostLibraryModel: RemoteSessionHostStore { @discardableResult public func saveHost(_ host: Host) -> Bool { + do { try requireSavingHosts([host]) } catch HostAccessError.hostLimit { + storageError = nil + return false + } catch { + storageError = error.localizedDescription + return false + } if self.host(host.id)?.isTailscaleManaged == true || host.isTailscaleManaged { return saveTailscaleHost(host) } var host = host + host.createdAt = self.host(host.id)?.createdAt ?? (self.host(host.id) == nil ? .now : nil) host.name = SavedName.resolve(host.name, default: host.defaultName) host.portForwardingMaxConnections = Host.normalizedPortForwardingMaxConnections( host.portForwardingMaxConnections) @@ -2211,6 +2294,10 @@ public final class HostLibraryModel: RemoteSessionHostStore { authenticationHostID: Host.ID? = nil, suppliedPassword: String? = nil ) throws -> SSHConnectionRoute { guard route.isValid else { throw SSHConnectionRoute.invalidRouteError } + for node in route.hosts { try requireHostAccess(node) } + for id in route.sourceHostIDs.values { + if let source = host(id) { try requireHostAccess(source) } + } if let managed = route.hosts.first(where: \.isTailscaleManaged) { guard route.hosts.count == 1, host(managed.id)?.tailscale == managed.tailscale else { throw TailscaleConfigurationError.managedHost @@ -2272,6 +2359,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { saved.append(node) } var written: [Identity] = [] + try requireSavingHosts(batch.hosts) do { for (identity, password) in passwords { try keychain.setString(password, for: identity.credentialRef, storage: identity.passwordStorage) @@ -2301,6 +2389,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { /// New records only. Existing credentials and host configurations are never overwritten. public func commitSSHImport(_ batch: SSHImportBatch) throws { + try requireSavingHosts(batch.hosts) let oldHosts = hosts let oldIdentities = identities let oldKeys = keys @@ -2328,6 +2417,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { identities.append(identity) } for var host in batch.hosts { + host.createdAt = .now host.sortIndex = nextSortIndex(in: nil) hosts.append(host) } diff --git a/Frameworks/TermindApplication/FeatureModels/SSHImportModel.swift b/Frameworks/TermindApplication/FeatureModels/SSHImportModel.swift index 27fc9fb3..1a19697a 100644 --- a/Frameworks/TermindApplication/FeatureModels/SSHImportModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/SSHImportModel.swift @@ -682,6 +682,7 @@ public final class SSHImportModel { defer { isWorking = false } do { var batch = try makeBatch() + try await library.resolveSavingHostsAccess(batch.hosts) batch.credentials = try await reader.credentials(for: Set(batch.keys.map(\.id))) // Revalidate after awaiting the reader, because another window or sync can write meanwhile. let beforeFinalRefresh = items @@ -700,6 +701,9 @@ public final class SSHImportModel { importedCount = batch.keys.count + batch.identities.count + batch.hosts.count + batch.knownHosts.count completed = true await reader.discard() + } catch HostAccessError.hostLimit { + // The host quota presents the Paywall without replacing the import selection with an error. + self.error = nil } catch { self.error = error.localizedDescription } } diff --git a/Frameworks/TermindApplication/Metrics/MetricsSnapshot.swift b/Frameworks/TermindApplication/Metrics/MetricsSnapshot.swift index c9e860a9..a29a72a2 100644 --- a/Frameworks/TermindApplication/Metrics/MetricsSnapshot.swift +++ b/Frameworks/TermindApplication/Metrics/MetricsSnapshot.swift @@ -8,7 +8,7 @@ public protocol MetricsSnapshotReading: Sendable { } public nonisolated enum MetricsMonitorState: String, Sendable { - case connecting, collecting, live, failed, paused, sleeping, stopped + case connecting, collecting, live, failed, paused, sleeping, stopped, locked case awaitingAuthentication = "awaiting_authentication" } diff --git a/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift b/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift index 177f07cd..0260cfd3 100644 --- a/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift +++ b/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift @@ -10,6 +10,11 @@ public final class ServerMetric: Identifiable { public var name: String public var status = String(localized: "Connecting…") public var collectionState = MetricsMonitorState.connecting + public var hostAccessError: HostAccessError? + public var isHostLocked: Bool { + if case .lockedHost = hostAccessError { return true } + return false + } public var isConnecting = true public var point: MetricsPoint? public var history: [MetricsPoint] = [] @@ -50,7 +55,6 @@ public final class ServerMetric: Identifiable { @MainActor @Observable public final class ServerMetricsModel: MetricsSnapshotReading { - public static let freeCardLimit = 3 public private(set) var servers: [ServerMetric] = [] public private(set) var sleeping = false public private(set) var storageError: String? @@ -62,8 +66,6 @@ public final class ServerMetricsModel: MetricsSnapshotReading { private let preferences: any MetricsConfigurationStoring private let makeSampler: @Sendable () -> any MetricsSampling private let clock: MetricsClock - private let cardLimit: () -> Int? - private let limitReached: () -> Void private var workers: [UUID: Task] = [:] private var generations: [UUID: UUID] = [:] private var samplers: [UUID: any MetricsSampling] = [:] @@ -91,22 +93,20 @@ public final class ServerMetricsModel: MetricsSnapshotReading { public init( hosts: any RemoteSessionHostStore, preferences: any MetricsConfigurationStoring, makeSampler: @escaping @Sendable () -> any MetricsSampling, - clock: MetricsClock = MetricsClock(), - cardLimit: @escaping () -> Int? = { ServerMetricsModel.freeCardLimit }, - limitReached: @escaping () -> Void = {} + clock: MetricsClock = MetricsClock() ) { self.hosts = hosts self.preferences = preferences self.makeSampler = makeSampler self.clock = clock - self.cardLimit = cardLimit - self.limitReached = limitReached } public func snapshot(for hostID: UUID) -> MetricsSnapshot? { guard hosts.host(hostID) != nil, let server = servers.first(where: { $0.id == hostID }) else { return nil } let state: MetricsMonitorState - if !server.selection.enabled { + if server.isHostLocked { + state = .locked + } else if !server.selection.enabled { state = .paused } else if sleeping { state = .sleeping @@ -126,15 +126,12 @@ public final class ServerMetricsModel: MetricsSnapshotReading { ) } - public var remainingCardSlots: Int? { cardLimit().map { max(0, $0 - configuredSelections.count) } } - #if DEBUG /// Frozen product fixtures never load preferences or start an SSH worker. public convenience init(screenshotHosts: any RemoteSessionHostStore, rows: [ServerMetric]) { self.init( hosts: screenshotHosts, preferences: ScreenshotMetricsPreferences(), - makeSampler: { preconditionFailure("Screenshot metrics cannot connect") }, - cardLimit: { nil } + makeSampler: { preconditionFailure("Screenshot metrics cannot connect") } ) servers = rows configuredSelections = rows.map(\.selection) @@ -143,14 +140,6 @@ public final class ServerMetricsModel: MetricsSnapshotReading { } #endif - @discardableResult - public func requestAddition(count: Int = 1) -> Bool { - guard count > 0 else { return true } - guard let remaining = remainingCardSlots, count > remaining else { return true } - limitReached() - return false - } - public func observeDetails(hostID: UUID, observer: UUID) { detailObservers[observer] = hostID } public func stopObservingDetails(_ observer: UUID) { detailObservers[observer] = nil } @@ -187,7 +176,7 @@ public final class ServerMetricsModel: MetricsSnapshotReading { } var seen = Set(configuredSelections.map(\.id)) let additions = hosts.filter { seen.insert($0.id).inserted } - guard requestAddition(count: additions.count) else { return false } + let next = configuredSelections + additions.map { selection(for: $0) } guard persist(next) else { return false } applyConfiguration(next) @@ -212,7 +201,7 @@ public final class ServerMetricsModel: MetricsSnapshotReading { let next = retained + additions.map { id in hosts.host(id).map { selection(for: $0) } ?? MetricsSelection(id: id) } guard next != configuredSelections else { return true } - guard requestAddition(count: max(0, next.count - configuredSelections.count)) else { return false } + guard persist(next) else { return false } applyConfiguration(next) return true @@ -449,11 +438,12 @@ public final class ServerMetricsModel: MetricsSnapshotReading { cancel(server.id) continue } - do { _ = try hosts.tailscaleNetwork(for: host) } catch { - cancel(server.id) - server.isConnecting = false - server.status = error.localizedDescription - server.collectionState = .failed + do { + try hosts.requireHostAccess(host) + server.hostAccessError = nil + _ = try hosts.tailscaleNetwork(for: host) + } catch { + denyAccess(error, for: server) continue } server.name = host.name @@ -476,6 +466,23 @@ public final class ServerMetricsModel: MetricsSnapshotReading { } } + private func requireSamplingAccess(_ id: UUID) throws { + guard let host = hosts.host(id) else { throw CancellationError() } + try hosts.requireHostAccess(host) + } + + private func denyAccess(_ error: any Error, for server: ServerMetric) { + cancel(server.id) + server.hostAccessError = error as? HostAccessError + server.isConnecting = server.hostAccessError == .verifyingEntitlement + server.status = error.localizedDescription + server.collectionState = server.isHostLocked ? .locked : (server.isConnecting ? .connecting : .failed) + server.authenticationHost = nil + server.authenticationPrompt = nil + server.needsAttention = false + server.journey.hide() + } + private func connectionStamp(_ host: Host) -> Data { // Values contain key references and public material, never resolved secrets. let route = hosts.route(for: host) @@ -585,6 +592,15 @@ public final class ServerMetricsModel: MetricsSnapshotReading { } private func launch(_ server: ServerMetric) { + if let host = hosts.host(server.id) { + do { + try hosts.requireHostAccess(host) + server.hostAccessError = nil + } catch { + denyAccess(error, for: server) + return + } + } guard !stopped, !sleeping, server.selection.enabled, !server.needsAttention else { if !server.selection.enabled { server.status = String(localized: "Paused") } return @@ -693,6 +709,7 @@ public final class ServerMetricsModel: MetricsSnapshotReading { var nextFilesystemRefresh = Date.distantPast var probeGPU = true sampleLoop: while await isCurrent(serverID, token: token) { + try await requireSamplingAccess(serverID) let began = clock.now() let sample = try await sampler.sample() guard @@ -807,7 +824,10 @@ public final class ServerMetricsModel: MetricsSnapshotReading { private func isCurrent(_ serverID: UUID, token: UUID) -> Bool { guard current(serverID, token), let host = hosts.host(serverID) else { return false } - do { _ = try hosts.tailscaleNetwork(for: host) } catch { return false } + do { + try hosts.requireHostAccess(host) + _ = try hosts.tailscaleNetwork(for: host) + } catch { return false } return true } @@ -904,6 +924,10 @@ public final class ServerMetricsModel: MetricsSnapshotReading { private func handleRunFailure(_ error: any Error, serverID: UUID, token: UUID) -> Bool { guard current(serverID, token), let server = servers.first(where: { $0.id == serverID }) else { return false } + if error is HostAccessError { + denyAccess(error, for: server) + return false + } server.isConnecting = false server.status = error is CancellationError diff --git a/Frameworks/TermindApplication/SSHConnectionTypes.swift b/Frameworks/TermindApplication/SSHConnectionTypes.swift index 52b7057d..5661db58 100644 --- a/Frameworks/TermindApplication/SSHConnectionTypes.swift +++ b/Frameworks/TermindApplication/SSHConnectionTypes.swift @@ -427,6 +427,8 @@ public nonisolated struct SSHAuthenticationReference: Hashable, Sendable { } public nonisolated struct SSHConnectionConfiguration: Sendable { + public var accessHostIDs: [UUID] = [] + public var accessCheck: HostOperationAccessCheck? public var beforeConnect: ConnectionWakeAction? public var network: (any SSHNetworkDialing)? public var tailscaleNodeID: String? diff --git a/Frameworks/TermindApplication/TelnetConnectionTypes.swift b/Frameworks/TermindApplication/TelnetConnectionTypes.swift index 8c3fa589..3782de21 100644 --- a/Frameworks/TermindApplication/TelnetConnectionTypes.swift +++ b/Frameworks/TermindApplication/TelnetConnectionTypes.swift @@ -4,6 +4,7 @@ import TermindDomain import struct TermindDomain.Host public nonisolated struct TelnetConnectionConfiguration: Sendable { + public var accessCheck: HostOperationAccessCheck? public var beforeConnect: ConnectionWakeAction? public var hostID: Host.ID public var host: String diff --git a/Frameworks/TermindDomain/Commerce/ProAccessTypes.swift b/Frameworks/TermindDomain/Commerce/ProAccessTypes.swift index ccaf3431..6687993e 100644 --- a/Frameworks/TermindDomain/Commerce/ProAccessTypes.swift +++ b/Frameworks/TermindDomain/Commerce/ProAccessTypes.swift @@ -1,14 +1,9 @@ import Foundation public nonisolated enum ProFeature: String, CaseIterable, Hashable, Sendable { - case wakeOnLAN - case autoWakeOnLAN - case tailscale - case assistant - case remoteMCP - case mcpServer case vaultSync - case unlimitedMetrics + case unlimitedHosts + case tailscale case sftpMultiConnection case portForwardingMultiConnection } diff --git a/Frameworks/TermindDomain/Hosts/Host.swift b/Frameworks/TermindDomain/Hosts/Host.swift index abfbf1c0..35ed0b36 100644 --- a/Frameworks/TermindDomain/Hosts/Host.swift +++ b/Frameworks/TermindDomain/Hosts/Host.swift @@ -139,6 +139,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { public static let portForwardingMaxConnectionsRange = 1...64 public var id: UUID = UUID() + public var createdAt: Date? public var name: String /// Present only on the device-local projection of a discovered Tailscale peer. public var tailscale: TailscaleHostReference? @@ -297,6 +298,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { } public init( id: UUID = UUID(), name: String, host: String, port: Int = 22, identityID: Identity.ID? = nil, + createdAt: Date? = nil, jumpHostID: Host.ID? = nil, proxy: HostProxy? = nil, sftpChannelInitialPath: String = "", charset: RemoteCharset = .utf8, sshEnabled: Bool = true, telnetEnabled: Bool = false, telnetPort: Int = 23, @@ -311,6 +313,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { portForwardingMaxConnections: Int = Self.defaultPortForwardingMaxConnections ) { self.id = id + self.createdAt = createdAt self.name = name self.host = host self.sshEnabled = sshEnabled @@ -341,7 +344,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { } private enum CodingKeys: String, CodingKey { - case id, name, host, port, identityID, jumpHostID, proxy, tailscale + case id, name, host, port, identityID, jumpHostID, proxy, tailscale, createdAt case sftpChannelInitialPath = "sftpSessionInitialPath" case charset case sshEnabled, telnetEnabled, telnetPort, telnetIdentityID, telnetCharset, telnetProxy @@ -354,6 +357,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { public init(from decoder: any Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) id = try values.decode(UUID.self, forKey: .id) + createdAt = try values.decodeIfPresent(Date.self, forKey: .createdAt) tailscale = try values.decodeIfPresent(TailscaleHostReference.self, forKey: .tailscale) name = try values.decode(String.self, forKey: .name) host = try values.decode(String.self, forKey: .host) @@ -400,6 +404,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { public func encode(to encoder: any Encoder) throws { var values = encoder.container(keyedBy: CodingKeys.self) try values.encode(id, forKey: .id) + try values.encodeIfPresent(createdAt, forKey: .createdAt) try values.encodeIfPresent(tailscale, forKey: .tailscale) try values.encode(name, forKey: .name) try values.encode(host, forKey: .host) diff --git a/Frameworks/TermindDomain/Hosts/HostAccessPolicy.swift b/Frameworks/TermindDomain/Hosts/HostAccessPolicy.swift new file mode 100644 index 00000000..b029abec --- /dev/null +++ b/Frameworks/TermindDomain/Hosts/HostAccessPolicy.swift @@ -0,0 +1,59 @@ +import Foundation + +public nonisolated enum HostAccessError: Error, Equatable, Sendable { + case hostLimit + case lockedHost(UUID) + case verifyingEntitlement + case entitlementVerificationFailed +} + +/// Locks are derived, never synced. Historical undated records retain stable priority. +public nonisolated struct HostAccessPolicy: Sendable { + public static let freeHostLimit = 3 + public var entitlement: EntitlementStatus + + public init(entitlement: EntitlementStatus) { + self.entitlement = entitlement + } + + public func unlockedIDs(in hosts: [Host]) -> Set { + let ordinary = hosts.filter { !$0.isTailscaleManaged } + guard !entitlement.hasProAccess else { return Set(ordinary.map(\.id)) } + return Set( + ordinary.sorted { + if $0.createdAt != $1.createdAt { + return ($0.createdAt ?? .distantPast) < ($1.createdAt ?? .distantPast) + } + return $0.id.uuidString < $1.id.uuidString + }.prefix(Self.freeHostLimit).map(\.id)) + } + + public func isLocked(_ id: UUID, in hosts: [Host]) -> Bool { + guard entitlement != .loading, entitlement != .verificationFailed, + hosts.contains(where: { $0.id == id && !$0.isTailscaleManaged }) + else { return false } + return !unlockedIDs(in: hosts).contains(id) + } + + public func requireAccess(to id: UUID, in hosts: [Host]) throws { + guard hosts.contains(where: { $0.id == id && !$0.isTailscaleManaged }), + !unlockedIDs(in: hosts).contains(id) + else { return } + try requireResolvedEntitlement() + throw HostAccessError.lockedHost(id) + } + + public func requireSaving(_ proposed: [Host], in hosts: [Host]) throws { + guard !entitlement.hasProAccess else { return } + let existing = Set(hosts.filter { !$0.isTailscaleManaged }.map(\.id)) + let additions = Set(proposed.filter { !$0.isTailscaleManaged }.map(\.id)).subtracting(existing) + guard !additions.isEmpty, existing.count + additions.count > Self.freeHostLimit else { return } + try requireResolvedEntitlement() + throw HostAccessError.hostLimit + } + + private func requireResolvedEntitlement() throws { + if entitlement == .loading { throw HostAccessError.verifyingEntitlement } + if entitlement == .verificationFailed { throw HostAccessError.entitlementVerificationFailed } + } +} diff --git a/Frameworks/TermindDomain/Vault/SyncRecordFormats.swift b/Frameworks/TermindDomain/Vault/SyncRecordFormats.swift index db84b600..16f54f01 100644 --- a/Frameworks/TermindDomain/Vault/SyncRecordFormats.swift +++ b/Frameworks/TermindDomain/Vault/SyncRecordFormats.swift @@ -44,7 +44,7 @@ public nonisolated struct SyncRecordFormats: Sendable { private static let v1 = Format(readingVersions: [1], writingVersion: 1) public static let vault = Self([ - "host": Format(readingVersions: [1, 2, 4, 5, 6, 7], writingVersion: 7), + "host": Format(readingVersions: [1, 2, 4, 5, 6, 7, 8], writingVersion: 8), "hostGroup": Format(readingVersions: [1, 2, 3, 4, 5, 6, 7], writingVersion: 7), "identity": v1, "sshKey": Format(readingVersions: [1, 2, 3], writingVersion: 3), "knownHost": v1, "snippet": Format(readingVersions: [1, 2], writingVersion: 2), "snippetPackage": v1, diff --git a/Frameworks/TermindInfrastructure/Commerce/ProAccessController.swift b/Frameworks/TermindInfrastructure/Commerce/ProAccessController.swift index 3b7d5ea0..7b8d8f89 100644 --- a/Frameworks/TermindInfrastructure/Commerce/ProAccessController.swift +++ b/Frameworks/TermindInfrastructure/Commerce/ProAccessController.swift @@ -120,7 +120,7 @@ public final class ProAccessController { } private func apply(_ next: EntitlementStatus) { - let changed = status.hasProAccess != next.hasProAccess + let changed = status != next status = next if changed { accessDidChange?() } entitlementExpirationTask?.cancel() diff --git a/Frameworks/TermindPersistence/HostPersistence.swift b/Frameworks/TermindPersistence/HostPersistence.swift index ad276d31..1058dbad 100644 --- a/Frameworks/TermindPersistence/HostPersistence.swift +++ b/Frameworks/TermindPersistence/HostPersistence.swift @@ -1000,6 +1000,15 @@ public enum TermindSchemaV57: VersionedSchema { } } +/// Adds creation ordering without inventing dates for historical hosts. +public enum TermindSchemaV58: VersionedSchema { + public static let versionIdentifier = Schema.Version(58, 0, 0) + public static var models: [any PersistentModel.Type] { + TermindSchemaV57.models.filter { $0 != TermindSchemaV56HostModels.PersistentHost.self } + + [TermindSchemaV58HostModels.PersistentHost.self] + } +} + public enum TermindMigrationPlan: SchemaMigrationPlan { public static var schemas: [any VersionedSchema.Type] { [ @@ -1018,6 +1027,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan { TermindSchemaV46.self, TermindSchemaV47.self, TermindSchemaV48.self, TermindSchemaV49.self, TermindSchemaV50.self, TermindSchemaV51.self, TermindSchemaV52.self, TermindSchemaV53.self, TermindSchemaV54.self, TermindSchemaV55.self, TermindSchemaV56.self, TermindSchemaV57.self, + TermindSchemaV58.self, ] } @@ -1134,11 +1144,12 @@ public enum TermindMigrationPlan: SchemaMigrationPlan { MigrationStage.lightweight(fromVersion: TermindSchemaV54.self, toVersion: TermindSchemaV55.self), MigrationStage.lightweight(fromVersion: TermindSchemaV55.self, toVersion: TermindSchemaV56.self), MigrationStage.lightweight(fromVersion: TermindSchemaV56.self, toVersion: TermindSchemaV57.self), + MigrationStage.lightweight(fromVersion: TermindSchemaV57.self, toVersion: TermindSchemaV58.self), ] } } -public typealias PersistentHost = TermindSchemaV56HostModels.PersistentHost +public typealias PersistentHost = TermindSchemaV58HostModels.PersistentHost public typealias PersistentIdentity = TermindSchemaV33IdentityModels.PersistentIdentity public typealias PersistentSSHKey = TermindSchemaV45KeyModels.PersistentSSHKey public typealias PersistentKnownHost = TermindSchemaV30KnownHostModels.PersistentKnownHost diff --git a/Frameworks/TermindPersistence/SwiftDataVaultJournal.swift b/Frameworks/TermindPersistence/SwiftDataVaultJournal.swift index bc32bff0..339d5336 100644 --- a/Frameworks/TermindPersistence/SwiftDataVaultJournal.swift +++ b/Frameworks/TermindPersistence/SwiftDataVaultJournal.swift @@ -76,7 +76,9 @@ extension VaultSnapshot { throw VaultSyncPersistenceError.invalidEnvelope(envelope.key.recordName) } switch payload { - case .host(let value): upsert(value, in: &hosts) + case .host(var value): + value.createdAt = value.createdAt ?? hosts.first(where: { $0.id == value.id })?.createdAt + upsert(value, in: &hosts) case .hostGroup(let value): upsert(value, in: &groups) case .identity(let value): upsert(value, in: &identities) case .sshKey(let value): upsert(value, in: &keys) @@ -257,7 +259,7 @@ public final class VaultSyncJournal { var result = VaultRemoteMerge() let entries = try entriesByName() for rawEnvelope in remote where rawEnvelope.key.kind.isConfiguration { - let envelope = rawEnvelope.withoutDeviceLocalMetadata + var envelope = rawEnvelope.withoutDeviceLocalMetadata try SyncRecordFormats.vault.validate(kind: envelope.key.kind.rawValue, version: envelope.formatVersion) guard envelope.isValid else { throw VaultSyncPersistenceError.invalidEnvelope(envelope.key.recordName) @@ -273,6 +275,14 @@ public final class VaultSyncJournal { continue } if local.version < envelope.version { + if case .host(var incoming) = envelope.payload, incoming.createdAt == nil, + case .host(let existing) = local.payload, let createdAt = existing.createdAt + { + incoming.createdAt = createdAt + envelope.payload = .host(incoming) + envelope.formatVersion = 8 + result.needsUpload.insert(envelope.key) + } result.accepted.append(envelope) } else if envelope.version < local.version { result.needsUpload.insert(local.key) diff --git a/Frameworks/TermindPersistence/SwiftDataVaultStore.swift b/Frameworks/TermindPersistence/SwiftDataVaultStore.swift index e5de1720..0b19d5b2 100644 --- a/Frameworks/TermindPersistence/SwiftDataVaultStore.swift +++ b/Frameworks/TermindPersistence/SwiftDataVaultStore.swift @@ -73,12 +73,28 @@ public final class HostPersistence { do { let journal = VaultSyncJournal(context: context) let deleted = try journal.deletionEnvelopes(deletions) - let decision = try journal.mergeDecision(for: envelopes.filter { !deletions.contains($0.key) } + deleted) var snapshot = try fetchSnapshot() + let knownDates = Dictionary( + uniqueKeysWithValues: snapshot.hosts.compactMap { host in host.createdAt.map { (host.id, $0) } }) + var restoredDates = Set() + let incoming = envelopes.filter { !deletions.contains($0.key) }.map { original in + var envelope = original + if case .host(var host) = envelope.payload, host.createdAt == nil, + let date = knownDates[host.id] + { + host.createdAt = date + envelope.payload = .host(host) + envelope.formatVersion = 8 + restoredDates.insert(envelope.key) + } + return envelope + } + let decision = try journal.mergeDecision(for: incoming + deleted) for envelope in decision.accepted { try snapshot.apply(envelope) } try persist(snapshot) try journal.acceptRemote(decision.accepted) - let needsUpload = decision.needsUpload + let needsUpload = decision.needsUpload.union( + restoredDates.intersection(decision.accepted.map(\.key))) try journal.markNeedsUpload(needsUpload) try beforeSave() try context.save() diff --git a/Frameworks/TermindPersistence/TermindPersistence.swift b/Frameworks/TermindPersistence/TermindPersistence.swift index 39a565a5..5851465d 100644 --- a/Frameworks/TermindPersistence/TermindPersistence.swift +++ b/Frameworks/TermindPersistence/TermindPersistence.swift @@ -20,7 +20,7 @@ public enum TermindPersistence { #endif // Local schema migrations do not change the independently versioned CloudKit envelopes. - public static let schema = Schema(versionedSchema: TermindSchemaV57.self) + public static let schema = Schema(versionedSchema: TermindSchemaV58.self) public static func makeContainer(inMemory: Bool = false, url: URL? = nil) throws -> ModelContainer { precondition(!inMemory || url == nil, "An in-memory store cannot also have a file URL") diff --git a/Frameworks/TermindPersistence/TermindSchemaV58HostModels.swift b/Frameworks/TermindPersistence/TermindSchemaV58HostModels.swift new file mode 100644 index 00000000..02981048 --- /dev/null +++ b/Frameworks/TermindPersistence/TermindSchemaV58HostModels.swift @@ -0,0 +1,152 @@ +import Foundation +import SwiftData +import TermindDomain + +import struct TermindDomain.Host + +public enum TermindSchemaV58HostModels { + @Model + public final class PersistentHost { + @Attribute(.unique) public var id: UUID = UUID() + public var createdAt: Date? + public var name = "" + public var address = "" + public var sshEnabled = true + public var wakeMACAddress = "" + public var automaticallyWake = false + public var terminalTransportsData: Data? + public var port = 22 + public var identityID: UUID? + public var charset = RemoteCharset.utf8.rawValue + public var telnetEnabled = false + public var telnetPort = 23 + public var telnetIdentityID: UUID? + public var telnetCharset = RemoteCharset.utf8.rawValue + public var telnetProxyProtocol: String? + public var telnetProxyHostname = "" + public var telnetProxyPort = 0 + public var telnetProxyUsername = "" + public var telnetProxyIdentityID: UUID? + public var jumpHostID: UUID? + public var proxyProtocol: String? + public var proxyHostname = "" + public var proxyPort = 0 + public var proxyUsername = "" + public var proxyIdentityID: UUID? + public var sftpSessionInitialPath = "" + public var groupID: UUID? + public var tags: [String] = [] + public var colorTag = TagColor.none.rawValue + public var shellIntegrationMode = HostShellIntegrationMode.followGlobalSettings.rawValue + public var sessionPersistenceEnabled = false + public var sessionPersistenceBackend = "tmux" + public var sessionPersistenceOnConnect = "ask" + public var agentForwardingEnabled = false + public var startupSnippetID: UUID? + public var environmentVariableNames: [String] = [] + public var environmentVariableValues: [String] = [] + public var portForwardingMaxConnections: Int? = Host.defaultPortForwardingMaxConnections + public var iconOverrideID: String? + public var detectedDistributionID: String? + public var detectedDistributionLikeIDs: [String] = [] + public var sortIndex = 0.0 + + public init(_ value: Host) { + id = value.id + apply(value) + } + + public func apply(_ value: Host) { + createdAt = value.createdAt + wakeMACAddress = value.wakeOnLAN.macAddress + automaticallyWake = value.wakeOnLAN.automaticallyWake + name = value.name + address = value.host + sshEnabled = value.sshEnabled + terminalTransportsData = try? JSONEncoder().encode(value.terminalTransports) + port = value.sshPort + identityID = value.sshIdentityID + charset = value.sshCharset.rawValue + telnetEnabled = value.telnetEnabled + telnetPort = value.telnetPort + telnetIdentityID = value.telnetIdentityID + telnetCharset = value.telnetCharset.rawValue + telnetProxyProtocol = value.telnetProxy?.kind.rawValue + telnetProxyHostname = value.telnetProxy?.hostname ?? "" + telnetProxyPort = value.telnetProxy?.port ?? 0 + telnetProxyUsername = value.telnetProxy?.username ?? "" + telnetProxyIdentityID = value.telnetProxy?.identityID + jumpHostID = value.jumpHostID + proxyProtocol = value.proxy?.kind.rawValue + proxyHostname = value.proxy?.hostname ?? "" + proxyPort = value.proxy?.port ?? 0 + proxyUsername = value.proxy?.username ?? "" + proxyIdentityID = value.proxy?.identityID + sftpSessionInitialPath = value.sftpChannelInitialPath + groupID = value.groupID + tags = value.tags + colorTag = value.colorTag.rawValue + iconOverrideID = value.iconOverrideID + shellIntegrationMode = value.shellIntegrationMode.rawValue + startupSnippetID = value.startupSnippetID + agentForwardingEnabled = value.agentForwardingEnabled + sessionPersistenceEnabled = value.sessionPersistence.enabled + sessionPersistenceBackend = value.sessionPersistence.backend.rawValue + sessionPersistenceOnConnect = value.sessionPersistence.onConnect.rawValue + environmentVariableNames = value.environmentVariables.map(\.variable) + environmentVariableValues = value.environmentVariables.map(\.value) + portForwardingMaxConnections = Host.normalizedPortForwardingMaxConnections( + value.portForwardingMaxConnections) + detectedDistributionID = value.detectedDistribution?.id + detectedDistributionLikeIDs = value.detectedDistribution?.likeIDs ?? [] + sortIndex = value.sortIndex + } + + public var value: Host { + var host = Host( + id: id, name: name, host: address, port: port, identityID: identityID, + createdAt: createdAt, + charset: RemoteCharset(rawValue: charset) ?? .utf8, + sshEnabled: sshEnabled, telnetEnabled: telnetEnabled, telnetPort: telnetPort, + telnetIdentityID: telnetIdentityID, + telnetCharset: RemoteCharset(rawValue: telnetCharset) ?? .utf8, + groupID: groupID, tags: tags, + colorTag: TagColor(rawValue: colorTag) ?? .none, + detectedDistribution: detectedDistributionID.flatMap { + DetectedDistribution(id: $0, likeIDs: detectedDistributionLikeIDs) + }, + iconOverrideID: iconOverrideID, sortIndex: sortIndex, + shellIntegrationMode: HostShellIntegrationMode(rawValue: shellIntegrationMode) + ?? .followGlobalSettings, + startupSnippetID: startupSnippetID, agentForwardingEnabled: agentForwardingEnabled, + sessionPersistence: SessionPersistencePreferences( + enabled: sessionPersistenceEnabled, backend: .init(rawValue: sessionPersistenceBackend), + onConnect: .init(rawValue: sessionPersistenceOnConnect) ?? .ask), + environmentVariables: zip(environmentVariableNames, environmentVariableValues).map { + HostEnvironmentVariable(variable: $0, value: $1) + }, + portForwardingMaxConnections: portForwardingMaxConnections + ?? Host.defaultPortForwardingMaxConnections + ) + host.wakeOnLAN = .init(macAddress: wakeMACAddress, automaticallyWake: automaticallyWake) + host.terminalTransports = + terminalTransportsData.flatMap { + try? JSONDecoder().decode(HostTerminalTransports.self, from: $0) + } ?? .init() + host.jumpHostID = jumpHostID + host.sftpChannelInitialPath = sftpSessionInitialPath + if let telnetProxyProtocol, let kind = HostProxy.ProtocolKind(rawValue: telnetProxyProtocol) { + host.telnetProxy = HostProxy( + kind: kind, hostname: telnetProxyHostname, port: telnetProxyPort, + username: telnetProxyUsername, identityID: telnetProxyIdentityID) + } + if let proxyProtocol, let kind = HostProxy.ProtocolKind(rawValue: proxyProtocol) { + host.proxy = HostProxy( + kind: kind, hostname: proxyHostname, port: proxyPort, + username: proxyUsername, identityID: proxyIdentityID + ) + } + return host + } + } +} diff --git a/Frameworks/TermindPersistence/VaultSyncPersistence.swift b/Frameworks/TermindPersistence/VaultSyncPersistence.swift index a52f1523..224f84a2 100644 --- a/Frameworks/TermindPersistence/VaultSyncPersistence.swift +++ b/Frameworks/TermindPersistence/VaultSyncPersistence.swift @@ -66,6 +66,12 @@ public nonisolated enum VaultSyncCodec { guard envelope.isValid else { throw VaultSyncPersistenceError.invalidEnvelope(envelope.key.recordName) } let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] + if envelope.formatVersion == 8 { + return try encoder.encode(VaultWireV8.Envelope(envelope)) + } + if case .host(let host) = envelope.payload, host.createdAt != nil { + throw CocoaError(.coderInvalidValue) + } if envelope.formatVersion == 7 { return try encoder.encode(VaultWireV7.Envelope(envelope)) } @@ -142,6 +148,9 @@ public nonisolated enum VaultSyncCodec { decoders[VaultRecordKind.snippet.rawValue]?[2] = { try JSONDecoder().decode(VaultWireV2.Envelope.self, from: $0).domainValue } + decoders[VaultRecordKind.host.rawValue]?[8] = { + try JSONDecoder().decode(VaultWireV8.Envelope.self, from: $0).domainValue + } decoders[VaultRecordKind.host.rawValue]?[2] = { try JSONDecoder().decode(VaultWireV2.Envelope.self, from: $0).domainValue } diff --git a/Frameworks/TermindPersistence/VaultWireV8.swift b/Frameworks/TermindPersistence/VaultWireV8.swift new file mode 100644 index 00000000..fea94e14 --- /dev/null +++ b/Frameworks/TermindPersistence/VaultWireV8.swift @@ -0,0 +1,54 @@ +import Foundation +import TermindDomain + +/// V8 preserves creation order independently of the editable display order. +nonisolated enum VaultWireV8 { + struct Host: Codable { + var base: VaultWireV7.Host + var createdAt: Date? + + init(_ value: TermindDomain.Host) throws { + base = try VaultWireV7.Host(value) + createdAt = value.createdAt + } + + var domainValue: TermindDomain.Host { + var value = base.domainValue + value.createdAt = createdAt + return value + } + } + + enum Payload: Codable { case host(Host) } + + struct Envelope: Codable { + var formatVersion: Int + var key: VaultRecordKey + var version: VaultVersion + var isDeleted: Bool + var payload: Payload? + + init(_ envelope: VaultEnvelope) throws { + guard envelope.key.kind == .host else { throw CocoaError(.coderInvalidValue) } + formatVersion = 8 + key = envelope.key + version = envelope.version + isDeleted = envelope.isDeleted + switch envelope.payload { + case .host(let host): payload = .host(try Host(host)) + case nil: payload = nil + default: throw CocoaError(.coderInvalidValue) + } + } + + var domainValue: VaultEnvelope { + let value: VaultRecordPayload? + switch payload { + case .host(let host): value = .host(host.domainValue) + case nil: value = nil + } + return VaultEnvelope( + key: key, version: version, isDeleted: isDeleted, payload: value, formatVersion: formatVersion) + } + } +} diff --git a/Frameworks/TermindPresentation/HostAccessError+Presentation.swift b/Frameworks/TermindPresentation/HostAccessError+Presentation.swift new file mode 100644 index 00000000..9db92775 --- /dev/null +++ b/Frameworks/TermindPresentation/HostAccessError+Presentation.swift @@ -0,0 +1,21 @@ +import Foundation +import TermindDomain + +// Keep localized copy in Presentation while shared policy failures retain their typed identity. +// swift-format-ignore: AvoidRetroactiveConformances +extension HostAccessError: @retroactive LocalizedError { + /// A static reference also keeps this conformance linked in the static Presentation framework. + public nonisolated static func presentationDescription(of error: any Error) -> String? { + if let accessError = error as? Self { return accessError.errorDescription } + return error.localizedDescription + } + + public nonisolated var errorDescription: String? { + switch self { + case .hostLimit: nil + case .lockedHost: String(localized: AppStrings.Pro.upgradeToUnlockHost) + case .verifyingEntitlement: String(localized: AppStrings.Pro.verifyingAccess) + case .entitlementVerificationFailed: String(localized: AppStrings.Pro.accessVerificationFailed) + } + } +} diff --git a/Frameworks/TermindPresentation/HostWakeOnLANSection.swift b/Frameworks/TermindPresentation/HostWakeOnLANSection.swift index 730d6ee9..660dd4d0 100644 --- a/Frameworks/TermindPresentation/HostWakeOnLANSection.swift +++ b/Frameworks/TermindPresentation/HostWakeOnLANSection.swift @@ -4,16 +4,14 @@ import TermindDomain public struct HostWakeOnLANSection: View { @Binding private var preferences: WakeOnLANPreferences - private let requestAccess: () -> Bool private let fetchMAC: (() -> Void)? @State private var invalidAddress = false public init( preferences: Binding, - requestAccess: @escaping () -> Bool, fetchMAC: (() -> Void)? + fetchMAC: (() -> Void)? ) { _preferences = preferences - self.requestAccess = requestAccess self.fetchMAC = fetchMAC } @@ -34,7 +32,7 @@ public struct HostWakeOnLANSection: View { Toggle( AppStrings.LocalNetwork.autoWake.swiftUIKey, isOn: WakeOnLANSettingsAccess( - requestAccess: requestAccess, invalidAddress: { invalidAddress = true } + invalidAddress: { invalidAddress = true } ).automatic($preferences)) } header: { Text(AppStrings.LocalNetwork.wakeOnLAN) diff --git a/Frameworks/TermindPresentation/MetricsHostLockNotice.swift b/Frameworks/TermindPresentation/MetricsHostLockNotice.swift new file mode 100644 index 00000000..6c823c72 --- /dev/null +++ b/Frameworks/TermindPresentation/MetricsHostLockNotice.swift @@ -0,0 +1,20 @@ +import SwiftUI +import TermindApplication + +public struct MetricsHostLockNotice: View { + public init() {} + + public var body: some View { + VStack(spacing: 7) { + Image(systemName: "lock.fill") + Text(AppStrings.Pro.upgradeToUnlockHost) + .font(.caption) + .multilineTextAlignment(.center) + } + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .frame(minHeight: 64) + .contentShape(Rectangle()) + .accessibilityIdentifier("metrics.hostLocked") + } +} diff --git a/Frameworks/TermindPresentation/MetricsInstrumentBody.swift b/Frameworks/TermindPresentation/MetricsInstrumentBody.swift index 868a99c3..cc626d5a 100644 --- a/Frameworks/TermindPresentation/MetricsInstrumentBody.swift +++ b/Frameworks/TermindPresentation/MetricsInstrumentBody.swift @@ -5,6 +5,7 @@ import TermindDomain /// Shared horizontal instruments and stable-height connection state, without a card surface. public struct MetricsInstrumentBody: View { public enum Layout { case horizontal, adaptive } + enum ConnectionIndicator: Equatable { case progress, attention, paused } @Environment(\.dynamicTypeSize) private var typeSize public init(server: ServerMetric, sleeping: Bool, layout: Layout = .horizontal) { @@ -22,6 +23,16 @@ public struct MetricsInstrumentBody: View { && (server.status.isEmpty || server.status == String(localized: AppStrings.Metrics.live)) } + var connectionIndicator: ConnectionIndicator { + if server.hostAccessError == .entitlementVerificationFailed { return .attention } + if server.isConnecting + || (server.point == nil && !server.needsAttention && server.selection.enabled && !sleeping) + { + return .progress + } + return server.needsAttention ? .attention : .paused + } + public var body: some View { ZStack { if showsMetrics { @@ -32,13 +43,14 @@ public struct MetricsInstrumentBody: View { } } else { VStack(spacing: 7) { - if server.isConnecting - || (server.point == nil && !server.needsAttention - && server.selection.enabled && !sleeping) - { + switch connectionIndicator { + case .progress: ProgressView().controlSize(.small) - } else { - Image(systemName: server.needsAttention ? "exclamationmark.triangle" : "pause.circle") + case .attention: + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.secondary) + case .paused: + Image(systemName: "pause.circle") .foregroundStyle(.secondary) } Text(connectionLabel) diff --git a/Frameworks/TermindPresentation/Strings/Pro.swift b/Frameworks/TermindPresentation/Strings/Pro.swift index 848597fe..f92b8bda 100644 --- a/Frameworks/TermindPresentation/Strings/Pro.swift +++ b/Frameworks/TermindPresentation/Strings/Pro.swift @@ -14,8 +14,10 @@ extension AppStrings { "Termind Pro is not available from the App Store right now." } - public static var freeMetricsLimit: LocalizedStringResource { - "Free includes up to 3 monitoring cards. Upgrade to Pro for more." + public static var upgradeToUnlockHost: LocalizedStringResource { "Upgrade to Pro to unlock this host." } + public static var verifyingAccess: LocalizedStringResource { "Checking Pro access…" } + public static var accessVerificationFailed: LocalizedStringResource { + "Unable to verify Pro access. Please try again." } public static var privacyPolicy: LocalizedStringResource { diff --git a/Frameworks/TermindPresentation/WakeOnLANSettingsAccess.swift b/Frameworks/TermindPresentation/WakeOnLANSettingsAccess.swift index 1c8d4167..b9e21d21 100644 --- a/Frameworks/TermindPresentation/WakeOnLANSettingsAccess.swift +++ b/Frameworks/TermindPresentation/WakeOnLANSettingsAccess.swift @@ -2,11 +2,9 @@ import SwiftUI import TermindDomain public struct WakeOnLANSettingsAccess { - private let requestAccess: () -> Bool private let invalidAddress: () -> Void - public init(requestAccess: @escaping () -> Bool, invalidAddress: @escaping () -> Void) { - self.requestAccess = requestAccess + public init(invalidAddress: @escaping () -> Void) { self.invalidAddress = invalidAddress } @@ -15,7 +13,6 @@ public struct WakeOnLANSettingsAccess { get: { saved.wrappedValue.automaticallyWake }, set: { enabled in if enabled { - guard requestAccess() else { return } guard saved.wrappedValue.address != nil else { invalidAddress() return diff --git a/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift b/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift index 3510d3ec..e7679111 100644 --- a/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift +++ b/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift @@ -189,6 +189,7 @@ public final class AgentHostResolver: Sendable { public func configuration(for id: UUID) throws -> SSHConnectionConfiguration { if let host = store.host(id) { + try store.requireHostAccess(host) guard host.sshEnabled else { throw HostProtocolUnavailableError(useCase: .sshTerminal) } return try store.sshConnectionConfiguration(for: host) } @@ -196,6 +197,16 @@ public final class AgentHostResolver: Sendable { throw AgentToolError.invalidHost } + public func resolvedConfiguration(for id: UUID) async throws -> SSHConnectionConfiguration { + if let host = store.host(id) { try await store.resolveHostAccess(host) } + return try configuration(for: id) + } + + public func resolvedRouteFingerprint(for id: UUID) async throws -> String { + if let host = store.host(id) { try await store.resolveHostAccess(host) } + return try routeFingerprint(for: id) + } + public func canResolve(_ id: UUID) -> Bool { store.host(id)?.sshEnabled == true || temporaryTargets().contains { target in @@ -236,6 +247,7 @@ public final class AgentHostResolver: Sendable { } throw AgentToolError.invalidHost } + try await store.resolveHostAccess(destination) guard destination.sshEnabled else { throw HostProtocolUnavailableError(useCase: .sshTerminal) } @@ -527,6 +539,7 @@ public final class AgentHostResolver: Sendable { } return "temporary:\(id.uuidString)" } + try store.requireHostAccess(host) guard host.sshEnabled else { throw HostProtocolUnavailableError(useCase: .sshTerminal) } let route = store.route(for: host) return route.enumerated().map { index, node in @@ -612,7 +625,10 @@ public actor AgentSSHAuthenticationContext { public func configurationAttempt() async throws -> Attempt { try Task.checkCancellation() - if let cached { return makeAttempt(configuration: cached, generation: generation) } + if let cached { + try cached.accessCheck?() + return makeAttempt(configuration: cached, generation: generation) + } let pendingID: UUID let pendingGeneration: UInt64 diff --git a/Frameworks/TermindRemoteInfrastructure/Agent/AgentHostServices.swift b/Frameworks/TermindRemoteInfrastructure/Agent/AgentHostServices.swift index af4c2e51..7516af3e 100644 --- a/Frameworks/TermindRemoteInfrastructure/Agent/AgentHostServices.swift +++ b/Frameworks/TermindRemoteInfrastructure/Agent/AgentHostServices.swift @@ -31,8 +31,8 @@ public struct AgentHostServices { return AgentHostServices( resolver: resolver, connections: AgentConnectionPool( - configuration: { try await resolver.configuration(for: $0) }, - routeFingerprint: { try await resolver.routeFingerprint(for: $0) }, + configuration: { try await resolver.resolvedConfiguration(for: $0) }, + routeFingerprint: { try await resolver.resolvedRouteFingerprint(for: $0) }, forwardingEnabled: { await store.host($0)?.agentForwardingEnabled == true }, openConnection: { try await coordinator.connectConnection(using: $0) }, openSFTP: { try await coordinator.connectSFTP(using: $0) }, diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/AccessControlledSFTPChannel.swift b/Frameworks/TermindRemoteInfrastructure/SSH/AccessControlledSFTPChannel.swift new file mode 100644 index 00000000..b797c0d4 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/AccessControlledSFTPChannel.swift @@ -0,0 +1,163 @@ +import AgentKit +import Foundation +import TermindApplication +import TermindDomain + +/// Admit new operations without cancelling a transfer already delegated to the channel. +nonisolated final class AccessControlledSFTPChannel: SFTPChannelProtocol, Sendable { + private let channel: any SFTPChannelProtocol + private let accessCheck: HostOperationAccessCheck? + + init(channel: any SFTPChannelProtocol, accessCheck: HostOperationAccessCheck?) { + self.channel = channel + self.accessCheck = accessCheck + } + + func disconnect() async { await channel.disconnect() } + + public func openSiblingChannel() async throws -> any SFTPChannelProtocol { + AccessControlledSFTPChannel( + channel: try await connectedChannel().openSiblingChannel(), accessCheck: accessCheck) + } + + public func homeDirectory() async throws -> String { + try await connectedChannel().homeDirectory() + } + + public func listDirectory(at path: String) async throws -> [RemoteFile] { + try await connectedChannel().listDirectory(at: path) + } + + public func attributes(at path: String) async throws -> RemoteFile? { + try await connectedChannel().attributes(at: path) + } + + public func readFile(at path: String, maximumBytes: Int) async throws -> Data { + try await connectedChannel().readFile(at: path, maximumBytes: maximumBytes) + } + + public func readTextPage(at path: String, offset: Int, limit: Int) async throws -> AgentTextPage { + try await connectedChannel().readTextPage(at: path, offset: offset, limit: limit) + } + + public func writeFile(_ data: Data, at path: String, overwrite: Bool) async throws { + try await connectedChannel().writeFile(data, at: path, overwrite: overwrite) + } + + public func setPermissions(at path: String, to permissions: UInt32) async throws { + try await connectedChannel().setPermissions(at: path, to: permissions) + } + + public func createDirectory(at path: String) async throws { + try await connectedChannel().createDirectory(at: path) + } + + public func rename(from source: String, to destination: String, overwrite: Bool) async throws { + try await connectedChannel().rename( + from: source, to: destination, overwrite: overwrite) + } + + public func removeFile(at path: String) async throws { + try await connectedChannel().removeFile(at: path) + } + + public func removeDirectory(at path: String) async throws { + try await connectedChannel().removeDirectory(at: path) + } + + public func uploadFile( + from localURL: URL, to remotePath: String, overwrite: Bool, + cancellation: TransferCancellationToken, resume: SFTPResumeState, + progress: @escaping @Sendable (SFTPProgress) -> Void + ) async throws { + try await connectedChannel().uploadFile( + from: localURL, to: remotePath, overwrite: overwrite, + cancellation: cancellation, resume: resume, progress: progress) + } + + public func uploadFile( + from localURL: URL, to remotePath: String, overwrite: Bool, + cancellation: TransferCancellationToken, + progress: @escaping @Sendable (SFTPProgress) -> Void + ) async throws { + try await connectedChannel().uploadFile( + from: localURL, to: remotePath, overwrite: overwrite, + cancellation: cancellation, progress: progress) + } + + public func uploadStream( + fromSocket socket: Int32, to remotePath: String, byteCount: Int64, + overwrite: Bool, cancellation: TransferCancellationToken, + resume: SFTPResumeState, + progress: @escaping @Sendable (SFTPProgress) -> Void, + sourceSucceeded: @escaping @Sendable () -> Bool + ) async throws { + try await connectedChannel().uploadStream( + fromSocket: socket, to: remotePath, byteCount: byteCount, + overwrite: overwrite, cancellation: cancellation, resume: resume, + progress: progress, sourceSucceeded: sourceSucceeded) + } + + public func uploadStream( + fromSocket socket: Int32, to remotePath: String, byteCount: Int64, + overwrite: Bool, cancellation: TransferCancellationToken, + progress: @escaping @Sendable (SFTPProgress) -> Void, + sourceSucceeded: @escaping @Sendable () -> Bool + ) async throws { + try await connectedChannel().uploadStream( + fromSocket: socket, to: remotePath, byteCount: byteCount, + overwrite: overwrite, cancellation: cancellation, + progress: progress, sourceSucceeded: sourceSucceeded) + } + + public func downloadFile( + from remotePath: String, to localURL: URL, overwrite: Bool, + cancellation: TransferCancellationToken, resume: SFTPResumeState, + progress: @escaping @Sendable (SFTPProgress) -> Void + ) async throws { + try await connectedChannel().downloadFile( + from: remotePath, to: localURL, overwrite: overwrite, + cancellation: cancellation, resume: resume, progress: progress) + } + + public func downloadFile( + from remotePath: String, to localURL: URL, overwrite: Bool, + cancellation: TransferCancellationToken, + progress: @escaping @Sendable (SFTPProgress) -> Void + ) async throws { + try await connectedChannel().downloadFile( + from: remotePath, to: localURL, overwrite: overwrite, + cancellation: cancellation, progress: progress) + } + + public func openDownloadStream( + at path: String, maximumBytes: Int64, + cancellation: TransferCancellationToken + ) async throws -> any SFTPDownloadStream { + try await connectedChannel().openDownloadStream( + at: path, maximumBytes: maximumBytes, cancellation: cancellation) + } + + public func copyFile( + from sourcePath: String, to destinationPath: String, + on destination: any SFTPChannelProtocol, byteCount: Int64, + overwrite: Bool, cancellation: TransferCancellationToken, + progress: @escaping @Sendable (SFTPProgress) -> Void + ) async throws { + let destinationChannel = + if let destination = destination as? AccessControlledSFTPChannel { + try destination.connectedChannel() + } else { + destination + } + try await connectedChannel().copyFile( + from: sourcePath, to: destinationPath, on: destinationChannel, + byteCount: byteCount, overwrite: overwrite, + cancellation: cancellation, progress: progress) + } + + private func connectedChannel() throws -> any SFTPChannelProtocol { + try accessCheck?() + return channel + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/MoshTerminalEngine.swift b/Frameworks/TermindRemoteInfrastructure/SSH/MoshTerminalEngine.swift index 26bfa32d..caad8c93 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/MoshTerminalEngine.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/MoshTerminalEngine.swift @@ -8,6 +8,7 @@ actor MoshTerminalEngine: ResumableTerminalEngine { private let receive: EventHandler private var session: MoshClientSession? private var reader: Task? + private var accessCheck: HostOperationAccessCheck? private var beforeConnect: ConnectionWakeAction? private var rollbackPID: Int32? private var closed = false @@ -17,6 +18,7 @@ actor MoshTerminalEngine: ResumableTerminalEngine { } func prepare(_ context: ResumableTerminalEngineContext) async throws -> ResumableTerminalEnginePreparation { + accessCheck = context.request.configuration.accessCheck beforeConnect = context.request.configuration.beforeConnect if let payload = context.checkpoint { let restored = try await MoshClientSession.restore(from: Self.decodeCheckpoint(payload)) @@ -132,6 +134,7 @@ actor MoshTerminalEngine: ResumableTerminalEngine { } func beforeResume() async throws { + try accessCheck?() await beforeConnect?() try checkOpen() } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift b/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift index 40903543..b16266bd 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift @@ -448,6 +448,7 @@ public final class PortForwardingController { do { await cleanup?.value try Task.checkCancellation() + try await hosts.resolveHostAccess(host) let configuration: SSHConnectionConfiguration if silently { guard let snapshot = context.additionalSnapshot else { throw TermindSSHError.missingCredential } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/ResumableTerminalClient.swift b/Frameworks/TermindRemoteInfrastructure/SSH/ResumableTerminalClient.swift index 176e055e..0302a231 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/ResumableTerminalClient.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/ResumableTerminalClient.swift @@ -71,6 +71,7 @@ public actor ResumableTerminalClient: ResumableTerminalSession { let bootstrap = SSHConnection() do { try checkOpen() + try request.configuration.accessCheck?() await request.configuration.beforeConnect?() try checkOpen() publish(.connecting) diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnectionWorker.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnectionWorker.swift index 25ebb383..48a2570b 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnectionWorker.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnectionWorker.swift @@ -23,6 +23,7 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { private var timer: (any DispatchSourceTimer)? private var hostID: UUID? private var charset: RemoteCharset = .utf8 + private var accessCheck: HostOperationAccessCheck? private var scheduled = false private var generation = 0 private var controlDirections: Int32 = 0 @@ -65,7 +66,7 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { else { return nil } return .init( connection: connection, buffer: buffer, - channelBudget: channelBudget, charset: self.charset) + channelBudget: channelBudget, charset: self.charset, accessCheck: self.accessCheck) }, updateEvents: { [weak self] session, buffer in self?.updateSharedConnectionEvents(session: session, buffer: buffer) @@ -105,6 +106,7 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { self.connection = opened self.hostID = hostID self.charset = request.configuration.charset + self.accessCheck = request.configuration.accessCheck self.buffer = opened.installBuffer() if let provider = request.agentForwarding { self.buffer?.agentChannels = SSHAgentChannels(provider: provider, queue: self.queue) { @@ -145,7 +147,8 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { _ command: String, workingDirectory: String?, stdin: SSHCommandInput?, timeout: TimeInterval, outputLimit: Int, cancellation: TransferCancellationToken, onOutput: SSHExecOutputHandler? ) async throws -> SSHExecResult { - try await execChannelOwner.execute( + try await queue.perform { [self] in try accessCheck?() } + return try await execChannelOwner.execute( command, workingDirectory: workingDirectory, stdin: stdin, timeout: timeout, outputLimit: outputLimit, cancellation: cancellation, onOutput: onOutput) } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingConnection.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingConnection.swift index 6df18e43..0e82b40f 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingConnection.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingConnection.swift @@ -185,6 +185,8 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable { deinit { cleanup() } + private var accessCheck: HostOperationAccessCheck? + func connect(_ request: SSHConnectionRequest, progress: SSHRouteProgressHandler?) async throws { let operationID = UUID().uuidString.lowercased() let hostID = request.configuration.hostID @@ -199,6 +201,7 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable { let opened = try await SSHConnectionOpener.openAuthenticatedConnection( using: request, timeout: 20, progress: progress, logger: logger) await queue.perform { + self.accessCheck = request.configuration.accessCheck self.retiredStatistics = SSHForwardingStatistics() self.connection = opened self.buffer = opened.installBuffer() @@ -239,6 +242,7 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable { try await withCheckedThrowingContinuation { continuation in queue.async { [self] in do { + try accessCheck?() guard connection != nil else { throw TermindSSHError.disconnected } if rules[rule.id] != nil { continuation.resume() @@ -296,6 +300,7 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable { try await withCheckedThrowingContinuation { continuation in queue.async { [self] in do { + try accessCheck?() guard connection != nil else { throw TermindSSHError.disconnected } guard rule.kind != .remote else { throw TermindSSHError.route(String(localized: "Remote forwarding requires an SSH listener.")) @@ -319,6 +324,10 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable { func acceptShared(_ descriptor: Int32, ruleID: PortForwardingRule.ID, generation: UUID) async -> Bool { await withCheckedContinuation { continuation in queue.async { [self] in + do { try accessCheck?() } catch { + continuation.resume(returning: false) + return + } guard connection != nil, let context = rules[ruleID], context.generation == generation, !context.stopped, context.rule.kind != .remote else { @@ -611,6 +620,10 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable { for _ in 0..<32 { let descriptor = Darwin.accept(context.socket, nil, nil) guard descriptor >= 0 else { break } + do { try accessCheck?() } catch { + Darwin.close(descriptor) + continue + } makeNonblocking(descriptor) var yes: Int32 = 1 setsockopt(descriptor, SOL_SOCKET, SO_NOSIGPIPE, &yes, socklen_t(MemoryLayout.size)) @@ -638,6 +651,11 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable { } progressed = true let flow = Flow(socket: -1, context: context, channel: channel) + do { try accessCheck?() } catch { + register(flow) + close(flow) + continue + } register(flow) let connector = SSHForwardingConnector(queue: queue) { [weak self, weak flow] result in guard let self, let flow, !flow.retired else { diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingConnectionPool.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingConnectionPool.swift index 14d613e3..b05881e6 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingConnectionPool.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingConnectionPool.swift @@ -31,6 +31,7 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol { private var maximumConnections: Int private let connectionHandler: SSHForwardingConnection.ConnectionHandler private let failureHandler: SSHForwardingConnection.FailureHandler + private var accessCheck: HostOperationAccessCheck? private let makeConnection: ConnectionFactory private let listeners: SSHForwardingLocalListeners private var slots: [UUID: Slot] = [:] @@ -80,6 +81,7 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol { func connect(_ request: SSHConnectionRequest, progress: SSHRouteProgressHandler?) async throws { await disconnect() + accessCheck = request.configuration.accessCheck disconnecting = false let id = UUID() let slot = makeSlot(id: id) @@ -130,6 +132,7 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol { } func start(_ rule: PortForwardingRule, generation: UUID) async throws { + try accessCheck?() guard let primary = primarySlot else { throw TermindSSHError.disconnected } registrations[rule.id] = RuleRegistration(rule: rule, generation: generation) do { @@ -393,6 +396,10 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol { } private func assign(_ descriptor: Int32, ruleID: UUID, generation: UUID) async { + do { try accessCheck?() } catch { + Darwin.close(descriptor) + return + } guard let registration = registrations[ruleID], registration.generation == generation else { Darwin.close(descriptor) return diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHMultiplexedSFTPChannelOwner.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHMultiplexedSFTPChannelOwner.swift index 42cff272..894d7e15 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHMultiplexedSFTPChannelOwner.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHMultiplexedSFTPChannelOwner.swift @@ -1,5 +1,6 @@ import CSSH2 import Foundation +import TermindApplication import TermindDomain /// Owns every SFTP subsystem channel attached to one non-blocking SSH connection. @@ -13,6 +14,7 @@ nonisolated final class SSHMultiplexedSFTPChannelOwner: @unchecked Sendable { var buffer: SSHConnectionBuffer var channelBudget: SSHChannelBudget var charset: RemoteCharset + var accessCheck: HostOperationAccessCheck? = nil } private struct OwnedChannel { @@ -63,7 +65,13 @@ nonisolated final class SSHMultiplexedSFTPChannelOwner: @unchecked Sendable { guard let context = context() else { throw SFTPError.notConnected } return context.charset } - return try await SFTPChannel.open(using: driver, charset: charset) + let check = try await queue.perform { [self] in + guard let context = context() else { throw SFTPError.notConnected } + return context.accessCheck + } + try check?() + return AccessControlledSFTPChannel( + channel: try await SFTPChannel.open(using: driver, charset: charset), accessCheck: check) } /// Called by the enclosing SSH owner while already executing on `queue`. diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift index f1b63681..f526dc25 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift @@ -955,6 +955,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { private var retainsControlConnection = false private var hostID: Host.ID? private var charset: RemoteCharset = .utf8 + private var accessCheck: HostOperationAccessCheck? private var pumpTimer: (any DispatchSourceTimer)? private var buffer: SSHConnectionBuffer? private var channelBudget: SSHChannelBudget? @@ -1037,7 +1038,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { else { return nil } return .init( connection: connection, buffer: buffer, - channelBudget: channelBudget, charset: self.charset) + channelBudget: channelBudget, charset: self.charset, accessCheck: self.accessCheck) }, updateEvents: { [weak self] session, buffer in self?.updateSharedConnectionEvents(session: session, buffer: buffer) @@ -1072,6 +1073,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { let request = request.enforcingInteractionPolicy() defer { request.configuration.clearSecrets() } try Task.checkCancellation() + try request.configuration.accessCheck?() await request.configuration.beforeConnect?() try Task.checkCancellation() var resolvedConfiguration = request.configuration @@ -1293,6 +1295,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { connection = openedConnection hostID = request.configuration.hostID charset = request.configuration.charset + accessCheck = request.configuration.accessCheck inputConverter = try RemoteCharsetStreamConverter( charset: request.configuration.charset, direction: .encoding) outputConverter = try RemoteCharsetStreamConverter( @@ -1500,6 +1503,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { retainsControlConnection = true hostID = request.configuration.hostID charset = request.configuration.charset + accessCheck = request.configuration.accessCheck channelBudget = maxSessionsPolicy.makeBudget() buffer = opened.installBuffer() if let provider = request.agentForwarding { diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/TerminalTransportRoute.swift b/Frameworks/TermindRemoteInfrastructure/SSH/TerminalTransportRoute.swift index 712582d5..1a01c6f7 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/TerminalTransportRoute.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/TerminalTransportRoute.swift @@ -30,6 +30,7 @@ public nonisolated enum TerminalTransportRoute { public static func open(using request: SSHConnectionRequest, port: Int) async throws -> SSHRoutedSocket { defer { request.configuration.clearSecrets() } try Task.checkCancellation() + try request.configuration.accessCheck?() await request.configuration.beforeConnect?() try Task.checkCancellation() let config = request.configuration diff --git a/Frameworks/TermindRemoteInfrastructure/Telnet/TelnetConnection.swift b/Frameworks/TermindRemoteInfrastructure/Telnet/TelnetConnection.swift index 86911e84..ff787c9a 100644 --- a/Frameworks/TermindRemoteInfrastructure/Telnet/TelnetConnection.swift +++ b/Frameworks/TermindRemoteInfrastructure/Telnet/TelnetConnection.swift @@ -81,6 +81,7 @@ private nonisolated final class TelnetConnectionWorker: @unchecked Sendable { do { try Task.checkCancellation() + try configuration.accessCheck?() await configuration.beforeConnect?() try Task.checkCancellation() } catch { diff --git a/TFI/App/MobileAppModel+LocalNetwork.swift b/TFI/App/MobileAppModel+LocalNetwork.swift index 400fb30e..e0b1a93e 100644 --- a/TFI/App/MobileAppModel+LocalNetwork.swift +++ b/TFI/App/MobileAppModel+LocalNetwork.swift @@ -12,8 +12,9 @@ extension MobileAppModel { } func requestWake(_ host: Host, configure: () -> Void) -> String? { - switch library.requestWake(host, requestAccess: { pro.require(.wakeOnLAN) }) { + switch library.requestWake(host) { case .accessDenied: + if library.isLocked(host) { pro.presentPaywall() } return nil case .configurationRequired: configure() diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index fbf036fd..53eb0a47 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -300,8 +300,14 @@ final class MobileAppModel { ) let tailscaleDevices = devices let tailscaleAccess = pro + library.hostAccess.entitlement = pro.status + library.requestHostUpgrade = { [pro] in pro.presentPaywall() } + library.resolveInitialHostEntitlement = { [pro, weak library] in + await pro.refresh() + library?.hostAccess.entitlement = pro.status + } library.wakeOnLAN = WakeOnLANController( - sender: LANWakeOnLANSender(), hasAccess: { tailscaleAccess.canUse(.wakeOnLAN) }) + sender: LANWakeOnLANSender()) let wakeLogger = dependencies.logging.makeLogger(label: "termind.wake-on-lan") library.wakeOnLAN?.reportAutomaticFailure = { _ in wakeLogger.warning("wake.automatic.failed") } tailscale = TailscaleModel( @@ -343,7 +349,7 @@ final class MobileAppModel { coordinator: connectionCoordinator, sftpConcurrencyScheduler: sftpConcurrencyScheduler), metrics: metrics.core, skills: skills, - authorize: { [pro] in pro.require(.assistant) }, + authorize: { true }, speech: speech, attachmentLoader: .live, logger: logging.makeLogger(label: "termind.agent") @@ -417,6 +423,7 @@ final class MobileAppModel { } tailscale.start() pro.accessDidChange = { [weak self] in + if let self { self.library.hostAccess.entitlement = self.pro.status } self?.tailscale.reconcile() self?.metrics.core.reconcile() self?.reconcileConnectionPreferences() diff --git a/TFI/App/MobileWorkspaceSessionModel.swift b/TFI/App/MobileWorkspaceSessionModel.swift index 8da66f4e..c291e307 100644 --- a/TFI/App/MobileWorkspaceSessionModel.swift +++ b/TFI/App/MobileWorkspaceSessionModel.swift @@ -154,6 +154,29 @@ 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 { + if case HostAccessError.verifyingEntitlement = error { + Task { [weak self] in + guard let self else { return } + do { + try await app.library.resolveHostAccess(host) + guard !isDiscarded else { return } + _ = open(hostID: hostID, kind: kind, initialPath: initialPath) + } catch { + if case HostAccessError.lockedHost = error { + app.pro.presentPaywall() + } else if !(error is CancellationError) { + app.error = HostAccessError.presentationDescription(of: error) + } + } + } + } else if case HostAccessError.lockedHost = error { + app.pro.presentPaywall() + } else { + app.error = HostAccessError.presentationDescription(of: error) + } + return nil + } let startup: MobileWorkspaceStartup if let initialPath, !initialPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty diff --git a/TFI/Application/Metrics/MobileMetricsModel.swift b/TFI/Application/Metrics/MobileMetricsModel.swift index 9c732962..87a7295c 100644 --- a/TFI/Application/Metrics/MobileMetricsModel.swift +++ b/TFI/Application/Metrics/MobileMetricsModel.swift @@ -9,7 +9,6 @@ import TermindDomain final class MobileMetricsModel { let core: ServerMetricsModel private let hosts: any RemoteSessionHostStore - private let limit = MobileMetricsLimit() var servers: [ServerMetric] { core.servers } var selections: [MetricsSelection] { core.selections } @@ -19,19 +18,17 @@ final class MobileMetricsModel { services: MobileMetricsServices ) { self.hosts = hosts - let limit = limit core = ServerMetricsModel( hosts: hosts, preferences: preferences, - makeSampler: services.makeSampler, - cardLimit: { limit.maximum }) + makeSampler: services.makeSampler) } - func add(_ id: UUID, pro: Bool) -> Bool { - add([id], pro: pro) + func add(_ id: UUID) -> Bool { + add([id]) } - func add(_ ids: [UUID], pro: Bool) -> Bool { - limit.maximum = pro ? nil : ServerMetricsModel.freeCardLimit + func add(_ ids: [UUID]) -> Bool { + let batch = ids.compactMap(hosts.host) guard !batch.isEmpty else { return false } return core.add(batch) @@ -39,8 +36,8 @@ final class MobileMetricsModel { func remove(_ id: UUID) { core.remove(id) } - func replaceHosts(selectedIDs: [UUID], editableHostIDs: Set, pro: Bool) -> Bool { - limit.maximum = pro ? nil : ServerMetricsModel.freeCardLimit + func replaceHosts(selectedIDs: [UUID], editableHostIDs: Set) -> Bool { + return core.replaceHosts(selectedIDs: selectedIDs, editableHostIDs: editableHostIDs) } func pause() { core.suspend() } @@ -49,5 +46,3 @@ final class MobileMetricsModel { core.resume() } } - -@MainActor private final class MobileMetricsLimit { var maximum: Int? = 3 } diff --git a/TFI/Features/AI/MobileAssistantComposer.swift b/TFI/Features/AI/MobileAssistantComposer.swift index cd1b71e0..0100ce5c 100644 --- a/TFI/Features/AI/MobileAssistantComposer.swift +++ b/TFI/Features/AI/MobileAssistantComposer.swift @@ -651,7 +651,7 @@ struct MobileAssistantComposer: View { }, send: model.send, selectModel: { model.selectModel($0) }, - authorizeRemoteMCP: { pro.require(.remoteMCP) }, + authorizeRemoteMCP: { true }, localized: { AppLocalization.string($0, locale: locale) } ) } diff --git a/TFI/Features/HostEditor/MobileHostEditor.swift b/TFI/Features/HostEditor/MobileHostEditor.swift index 2ad4a93c..6c17d212 100644 --- a/TFI/Features/HostEditor/MobileHostEditor.swift +++ b/TFI/Features/HostEditor/MobileHostEditor.swift @@ -23,7 +23,7 @@ struct MobileHostEditor: View { @State private var tailscaleUsername = "" @State private var originalTailscaleUsername = "" @State private var showsDiscovery = false - @State private var showsWakePaywall = false + @State private var showsHostPaywall = false @State private var macModel: HostMACAddressModel? @State private var draft: Host @State private var editorRoute: EditorRoute? @@ -66,6 +66,12 @@ struct MobileHostEditor: View { discardQuestion: app.library.host(original.id) == nil ? AppStrings.Common.discardNewHostQuestion : AppStrings.Common.discardChangesQuestion, save: { + do { + try await library.resolveSavingHostsAccess([draft], requestsUpgrade: false) + } catch HostAccessError.hostLimit { + showsHostPaywall = true + throw CancellationError() + } if draft.isTailscaleManaged { guard library.saveTailscaleHost(draft, username: tailscaleUsername) else { throw MobileEditorError.message( @@ -256,7 +262,7 @@ struct MobileHostEditor: View { } } } - .sheet(isPresented: $showsWakePaywall) { MobileProView(pro: app.pro) } + .sheet(isPresented: $showsHostPaywall) { MobileProView(pro: app.pro) } .sheet( isPresented: Binding( get: { macModel != nil }, @@ -339,11 +345,6 @@ struct MobileHostEditor: View { private var wakeOnLANSection: some View { HostWakeOnLANSection( preferences: $draft.wakeOnLAN, - requestAccess: { - if app.pro.canUse(.autoWakeOnLAN) { return true } - showsWakePaywall = true - return false - }, fetchMAC: draft.sshEnabled ? { macModel = app.macAddressModel( diff --git a/TFI/Features/Metrics/MobileMetricsView.swift b/TFI/Features/Metrics/MobileMetricsView.swift index cfe51494..b6daff77 100644 --- a/TFI/Features/Metrics/MobileMetricsView.swift +++ b/TFI/Features/Metrics/MobileMetricsView.swift @@ -3,6 +3,12 @@ import TermindApplication import TermindDomain import TermindPresentation +extension MobileAppModel { + func activateMetrics(_ server: ServerMetric, inspect: () -> Void) { + if server.isHostLocked { pro.presentPaywall() } else { inspect() } + } +} + struct MobileMetricsView: View { let app: MobileAppModel @Environment(\.dynamicTypeSize) private var typeSize @@ -22,13 +28,17 @@ struct MobileMetricsView: View { MobileCardGrid { ForEach(app.metrics.servers) { server in MobileActionCard { - selectedID = server.id + app.activateMetrics(server) { selectedID = server.id } } content: { VStack(alignment: .leading, spacing: 12) { Text(server.name).font(.subheadline.bold()).lineLimit(1) - MetricsInstrumentBody( - server: server, sleeping: app.metrics.core.sleeping, - layout: typeSize.isAccessibilitySize ? .adaptive : .horizontal) + if server.isHostLocked { + MetricsHostLockNotice() + } else { + MetricsInstrumentBody( + server: server, sleeping: app.metrics.core.sleeping, + layout: typeSize.isAccessibilitySize ? .adaptive : .horizontal) + } } } actions: { Button( @@ -84,11 +94,9 @@ struct MobileMetricsView: View { pendingEditableHostIDs = [] initialHostIDs = [] guard let ids else { return } - if !app.metrics.replaceHosts(selectedIDs: ids, editableHostIDs: editable, pro: app.pro.hasProAccess) { + if !app.metrics.replaceHosts(selectedIDs: ids, editableHostIDs: editable) { if let error = app.metrics.core.storageError { app.error = error - } else { - app.pro.presentPaywall() } } } @@ -105,7 +113,16 @@ struct MobileMetricsDetailView: View { var body: some View { Group { - if server.showsConnectionJourney { + if server.isHostLocked { + Button { + app.pro.presentPaywall() + } label: { + MetricsHostLockNotice() + } + .buttonStyle(.plain) + .padding(24) + .frame(maxHeight: .infinity) + } else if server.showsConnectionJourney { MobileConnectionJourneyView( journey: server.journey, library: app.library, authenticationPrompt: server.authenticationPrompt, hostKey: server.hostKeyPrompt, @@ -148,7 +165,7 @@ struct MobileMetricsDetailView: View { .navigationBarTitleDisplayMode(.inline) .toolbar(removing: toolbarLayout.usesIconSwitcher ? .title : nil) .toolbar { - if !server.showsConnectionJourney && !showsBothPanels { + if !server.isHostLocked && !server.showsConnectionJourney && !showsBothPanels { if toolbarLayout.isVertical { ToolbarItem(placement: .primaryAction) { Menu { diff --git a/TFI/Features/Settings/MobileAISettingsView.swift b/TFI/Features/Settings/MobileAISettingsView.swift index ca5e40d8..aec3e87c 100644 --- a/TFI/Features/Settings/MobileAISettingsView.swift +++ b/TFI/Features/Settings/MobileAISettingsView.swift @@ -32,7 +32,7 @@ struct MobileAISettingsView: View { case .skills: MobileSkillSettings(skills: app.skills) case .mcp: - MobileMCPSettings(ai: app.ai, pro: app.pro) + MobileMCPSettings(ai: app.ai) } } } @@ -59,7 +59,7 @@ enum MobileAISettingsDestination: String, CaseIterable, Identifiable { case .models: "cube" case .skills: "doc.text" case .memory: "brain" - case .mcp: "network" + case .mcp: "wrench" } } } diff --git a/TFI/Features/Settings/MobileMCPSettings.swift b/TFI/Features/Settings/MobileMCPSettings.swift index 4a7456be..e5954fef 100644 --- a/TFI/Features/Settings/MobileMCPSettings.swift +++ b/TFI/Features/Settings/MobileMCPSettings.swift @@ -7,7 +7,6 @@ import UniformTypeIdentifiers struct MobileMCPSettings: View { let ai: AIConfigurationModel - let pro: ProAccessController @State private var adding: MCPServer? @State private var importing = false @State private var importReport: MCPImportReport? @@ -24,7 +23,7 @@ struct MobileMCPSettings: View { } if ai.mcpServers.isEmpty { ContentUnavailableView( - AppStrings.Settings.noMcpServers.swiftUIKey, systemImage: "network", + AppStrings.Settings.noMcpServers.swiftUIKey, systemImage: "wrench", description: Text(AppStrings.Settings.addAnMcpServerToConnectRemoteTools) ) .listRowBackground(Color.clear) @@ -52,13 +51,13 @@ struct MobileMCPSettings: View { .toolbar { ToolbarItem(placement: .primaryAction) { Button(AppStrings.MCP.addServer.swiftUIKey, systemImage: "plus") { - if pro.require(.remoteMCP) { adding = MCPServer(name: "", namespaceID: "") } + adding = MCPServer(name: "", namespaceID: "") } .labelStyle(.iconOnly) } MobileToolbarOverflow { Button(AppStrings.Common.import.swiftUIKey, systemImage: "square.and.arrow.down") { - if pro.require(.remoteMCP) { importing = true } + importing = true } } } @@ -69,7 +68,7 @@ struct MobileMCPSettings: View { { return } - guard pro.require(.remoteMCP) else { return } + importReport = nil importProblem = nil do { @@ -78,35 +77,17 @@ struct MobileMCPSettings: View { importProblem = error as? MCPImportProblem ?? .unreadableFile } } - .navigationDestination(item: $adding) { MobileMCPEditor(ai: ai, pro: pro, server: $0) } + .navigationDestination(item: $adding) { MobileMCPEditor(ai: ai, server: $0) } } @ViewBuilder private func serverLink(_ server: MCPServer) -> some View { - if pro.canUse(.remoteMCP) { - NavigationLink { - MobileMCPEditor(ai: ai, pro: pro, server: server) - } label: { - MobileMCPServerRow( - server: server, - hasNamespaceConflict: ai.conflictingMCPServerIDs.contains(server.id)) - } - } else { - Button { - pro.require(.remoteMCP) - } label: { - HStack { - MobileMCPServerRow( - server: server, - hasNamespaceConflict: ai.conflictingMCPServerIDs.contains(server.id)) - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(.tertiary) - .accessibilityHidden(true) - } - .contentShape(.rect) - } - .buttonStyle(.plain) + NavigationLink { + MobileMCPEditor(ai: ai, server: server) + } label: { + MobileMCPServerRow( + server: server, + hasNamespaceConflict: ai.conflictingMCPServerIDs.contains(server.id)) } } } @@ -117,7 +98,7 @@ private struct MobileMCPServerRow: View { var body: some View { HStack(spacing: 12) { - Image(systemName: "network") + Image(systemName: "wrench") .foregroundStyle(.secondary) .frame(width: 28, height: 28) .background(.quaternary, in: .rect(cornerRadius: 7)) @@ -198,7 +179,6 @@ nonisolated enum MobileMCPServerStatus: Equatable { struct MobileMCPEditor: View { let ai: AIConfigurationModel - let pro: ProAccessController @State private var model: MobileMCPEditorModel @State private var browser = MCPWebAuthenticationPresenter() @State private var editingHeader: MCPHeader? @@ -210,9 +190,8 @@ struct MobileMCPEditor: View { let message: String } - init(ai: AIConfigurationModel, pro: ProAccessController, server: MCPServer) { + init(ai: AIConfigurationModel, server: MCPServer) { self.ai = ai - self.pro = pro _model = State(initialValue: MobileMCPEditorModel(ai: ai, server: server)) } @@ -302,7 +281,7 @@ struct MobileMCPEditor: View { .toolbar { MobileToolbarOverflow { Button(AppStrings.Common.connect.swiftUIKey, systemImage: "bolt") { - if pro.require(.remoteMCP) { model.connect(interaction: browser.interaction) } + model.connect(interaction: browser.interaction) } .disabled(!model.canSave || model.running) if ai.mcpServer(model.original.id) != nil { @@ -319,7 +298,7 @@ struct MobileMCPEditor: View { } .labelStyle(.iconOnly) .accessibilityLabel(Text(AppStrings.Common.save)) - .disabled(!model.canSave || !pro.canUse(.remoteMCP)) + .disabled(!model.canSave) } .mobileProminentAction() } @@ -332,7 +311,6 @@ struct MobileMCPEditor: View { .onChange(of: model.key.text) { _, _ in model.invalidateConnection() } .onChange(of: model.key.action) { _, _ in model.invalidateConnection() } .onChange(of: ai.mcpServer(model.original.id)) { _, _ in model.cancel() } - .onChange(of: pro.hasProAccess) { _, granted in if !granted { model.cancel() } } .background(MCPAuthorizationAnchor(presenter: browser).frame(width: 0, height: 0)) .onChange(of: model.draft.oauth) { _, _ in model.invalidateConnection() @@ -358,7 +336,7 @@ struct MobileMCPEditor: View { (model.oauthSignedIn ? AppStrings.MCP.oauthSignedIn : AppStrings.MCP.oauthSignedOut).swiftUIKey, systemImage: model.oauthSignedIn ? "checkmark.circle" : "person.crop.circle") Button((model.oauthSignedIn ? AppStrings.MCP.oauthReauthorize : AppStrings.MCP.oauthSignIn).swiftUIKey) { - guard pro.require(.remoteMCP) else { return } + model.reauthorize(interaction: browser.interaction) } .disabled( diff --git a/TFI/Features/Vault/MobileVaultView.swift b/TFI/Features/Vault/MobileVaultView.swift index a14d6779..0c91939d 100644 --- a/TFI/Features/Vault/MobileVaultView.swift +++ b/TFI/Features/Vault/MobileVaultView.swift @@ -784,7 +784,7 @@ struct MobileVaultView: View { private func row(_ host: Host) -> some View { MobileActionCard(compact: compactCards) { - activateHost(host.id) + if app.library.isLocked(host) { app.pro.presentPaywall() } else { activateHost(host.id) } } content: { MobileHostCardContent( host: host, @@ -797,6 +797,10 @@ struct MobileVaultView: View { lastConnectedAt: app.lastConnectedAt(for: host.id), compact: compactCards ) + .opacity(app.library.isLocked(host) ? 0.55 : 1) + .overlay(alignment: .topTrailing) { + if app.library.isLocked(host) { Image(systemName: "lock.fill").foregroundStyle(.secondary) } + } } actions: { let sections = MobileHostMenuPolicy.sections( for: host, hasTailscalePeer: app.tailscale.peer(for: host) != nil) diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index 23ff00ae..b1d4aea9 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -484,6 +484,12 @@ 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 + } + } _ = terminal.paste(text: text) terminal.requestFocus() } @@ -1023,6 +1029,7 @@ final class MobileWorkspace: Identifiable { defer { if generation == token { busy = false } } guard generation == token, !Task.isCancelled else { return } do { + try await hosts.resolveHostAccess(host) var resolvedConfiguration: SSHConnectionConfiguration if resumeCheckpoint != nil, transport == .mosh { resolvedConfiguration = SSHConnectionConfiguration( @@ -1036,8 +1043,13 @@ final class MobileWorkspace: Identifiable { resolvedConfiguration = try await preparation.resolve( discoveryOnly: resumeCheckpoint != nil, trustPolicy: .allowChallenge) } + if resolvedConfiguration.accessHostIDs.isEmpty { + resolvedConfiguration.accessHostIDs = hosts.route(for: host).map(\.id) + resolvedConfiguration.accessCheck = hosts.connectionAccessCheck( + for: resolvedConfiguration.accessHostIDs) + } resolvedConfiguration.beforeConnect = hosts.connectionWakeAction( - for: resolvedConfiguration.jumps.map(\.id) + [host.id]) + for: resolvedConfiguration.accessHostIDs) if let resumeUsername { resolvedConfiguration.username = resumeUsername } let config = resolvedConfiguration guard generation == token, !Task.isCancelled else { return } @@ -1411,6 +1423,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) var configuration = try hosts.telnetConnectionConfiguration(for: host) if let usernameHint { configuration.username = usernameHint } if let password = self.advisoryTelnetPassword { diff --git a/TFI/UI/Components/MobileEditorSheet.swift b/TFI/UI/Components/MobileEditorSheet.swift index 3bda67b8..cfcc2daa 100644 --- a/TFI/UI/Components/MobileEditorSheet.swift +++ b/TFI/UI/Components/MobileEditorSheet.swift @@ -1,4 +1,5 @@ import SwiftUI +import TermindDomain import TermindPresentation struct MobileEditorToolbarAction: Identifiable { @@ -110,7 +111,7 @@ struct MobileEditorSheet: View { try Task.checkCancellation() dismiss() } catch is CancellationError { - } catch { self.error = error.localizedDescription } + } catch { self.error = HostAccessError.presentationDescription(of: error) } } } diff --git a/TFITests/MobileAISettingsRenderingTests.swift b/TFITests/MobileAISettingsRenderingTests.swift index 5481dab3..0629d03d 100644 --- a/TFITests/MobileAISettingsRenderingTests.swift +++ b/TFITests/MobileAISettingsRenderingTests.swift @@ -21,11 +21,11 @@ struct MobileAISettingsRenderingTests { let locale = Locale(identifier: language) try await capture( "mcp-empty-without-footer", language: language, - view: NavigationStack { MobileMCPSettings(ai: ai, pro: app.pro) }.environment(\.locale, locale)) + view: NavigationStack { MobileMCPSettings(ai: ai) }.environment(\.locale, locale)) ai.upsert(MCPServer(name: "Documentation", url: "https://example.invalid/mcp")) try await capture( "mcp-list-without-footer", language: language, - view: NavigationStack { MobileMCPSettings(ai: ai, pro: app.pro) }.environment(\.locale, locale)) + view: NavigationStack { MobileMCPSettings(ai: ai) }.environment(\.locale, locale)) try await capture( "skills-without-footer", language: language, view: NavigationStack { MobileSkillSettings(skills: app.skills) }.environment(\.locale, locale)) @@ -126,7 +126,7 @@ struct MobileAISettingsRenderingTests { view: NavigationStack { MobileSkillSettings(skills: app.skills) }.environment(\.locale, locale)) try await capture( "mcp-page", language: language, - view: NavigationStack { MobileMCPSettings(ai: app.ai, pro: app.pro) }.environment(\.locale, locale)) + view: NavigationStack { MobileMCPSettings(ai: app.ai) }.environment(\.locale, locale)) try await capture( "provider", language: language, view: MobileProviderEditor(ai: app.ai, provider: provider).environment(\.locale, locale)) @@ -138,7 +138,7 @@ struct MobileAISettingsRenderingTests { view: MobileSkillEditor(library: app.skills, skill: skill).environment(\.locale, locale)) try await capture( "mcp", language: language, - view: NavigationStack { MobileMCPEditor(ai: app.ai, pro: app.pro, server: server) } + view: NavigationStack { MobileMCPEditor(ai: app.ai, server: server) } .environment(\.locale, locale)) let discovery = MobileModelDiscovery(fetch: { _, _ in var compact = AIModel(id: "compact-chat") @@ -210,7 +210,6 @@ struct MobileAISettingsRenderingTests { view: NavigationStack { MobileAISettingsView(app: try! MobileAppModel(inMemory: true)) }.environment(\.locale, locale)) - let empty = try MobileAppModel(inMemory: true) try await capture( "empty-models", language: language, view: NavigationStack { MobileModelsSettingsView(ai: AIConfigurationModel()) }.environment(\.locale, locale) @@ -220,7 +219,7 @@ struct MobileAISettingsRenderingTests { view: NavigationStack { MobileSkillSettings(skills: SkillLibraryModel()) }.environment(\.locale, locale)) try await capture( "empty-mcp", language: language, - view: NavigationStack { MobileMCPSettings(ai: AIConfigurationModel(), pro: empty.pro) }.environment( + view: NavigationStack { MobileMCPSettings(ai: AIConfigurationModel()) }.environment( \.locale, locale) ) } diff --git a/TFITests/MobileAppTests.swift b/TFITests/MobileAppTests.swift index eb1845af..c0fac38c 100644 --- a/TFITests/MobileAppTests.swift +++ b/TFITests/MobileAppTests.swift @@ -300,12 +300,12 @@ struct MobileWorkspaceTests { let metrics = MobileMetricsModel( preferences: MetricsPreferences(), hosts: library, services: .testing) metrics.pause() - #expect(metrics.add(hosts[0].id, pro: false)) - #expect(metrics.add(hosts[0].id, pro: false)) - #expect(metrics.add(hosts[1].id, pro: false)) - #expect(metrics.add(hosts[2].id, pro: false)) - #expect(!metrics.add(hosts[3].id, pro: false)) - #expect(metrics.add(hosts[4].id, pro: true)) + #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) } } diff --git a/TFITests/MobileCardPageLayoutReviewTests.swift b/TFITests/MobileCardPageLayoutReviewTests.swift index 8e3f639e..3718824a 100644 --- a/TFITests/MobileCardPageLayoutReviewTests.swift +++ b/TFITests/MobileCardPageLayoutReviewTests.swift @@ -57,7 +57,7 @@ struct MobileCardPageLayoutReviewTests { remotePath: "/srv/backups/production-database-backup.tar.gz", byteCount: 100_000, transferredBytes: 42_000, state: state)) } - _ = app.metrics.add(host.id, pro: true) + _ = app.metrics.add(host.id) let server = try #require(app.metrics.servers.first) app.metrics.core.setEnabled(false, for: server) server.isConnecting = false diff --git a/TFITests/MobileHostAccessUIReviewTests.swift b/TFITests/MobileHostAccessUIReviewTests.swift new file mode 100644 index 00000000..7a969c25 --- /dev/null +++ b/TFITests/MobileHostAccessUIReviewTests.swift @@ -0,0 +1,276 @@ +import RevenueCat +import SwiftUI +import TermindApplication +import TermindDomain +import TermindPresentation +import Testing +import UIKit + +@testable import TermindIOS +@testable import TermindInfrastructure + +/// Real mobile screens with in-memory hosts; only the published paywall may use the network. +@Suite( + .requiresKeyWindow, + .enabled(if: ProcessInfo.processInfo.environment["TERMIND_HOST_ACCESS_UI_REVIEW"] == "1"), + .serialized) +@MainActor +struct MobileHostAccessUIReviewTests { + @Test(arguments: ["en", "zh-Hans"]) + func hostLocksAndActualSaveStates(language: String) async throws { + if !Purchases.isConfigured, let configuration = RevenueCatConfiguration.bundled() { + Purchases.configure( + with: Configuration.builder(withAPIKey: configuration.apiKey) + .with(storeKitVersion: .storeKit2) + .with(automaticDeviceIdentifierCollectionEnabled: false) + .with(preferredUILocaleOverride: language)) + } + let service = ReviewPurchaseService() + let pro = ProAccessController(purchaseService: service, observeUpdates: false) + await pro.refresh() + var dependencies = MobileAppDependencies.testing() + dependencies.proAccess = pro + let app = try MobileAppModel(dependencies: dependencies) + defer { + app.metrics.core.shutdown() + app.outbox.stop() + } + app.library.hostAccess.entitlement = .unrestrictedForTesting + let names = ["Production", "Staging", "Database", "Backup"] + for name in names { + app.library.upsert(TermindDomain.Host(name: name, host: "\(name.lowercased()).example.invalid")) + } + app.library.hostAccess.entitlement = .free + let locked = try #require(app.library.hosts.first(where: { $0.name == "Backup" })) + #expect(app.library.isLocked(locked)) + #expect(app.metrics.add(locked.id)) + + try await withWindow( + NavigationStack { + MobileVaultView(app: app, editingHost: .constant(nil), activateHost: { _ in }, open: { _, _ in }) + }, language: language + ) { window, _ in + try await scrollToBottom(in: window) + try capture(window, name: "hosts-\(language)") + } + try await withWindow( + NavigationStack { MobileMetricsView(app: app) }, language: language + ) { window, _ in + let server = try #require(app.metrics.servers.first) + #expect(server.isHostLocked) + try capture(window, name: "metrics-locked-\(language)") + app.library.hostAccess.entitlement = .loading + app.metrics.core.reconcile() + try await settle() + #expect(server.hostAccessError == .verifyingEntitlement) + #expect(server.isConnecting) + #expect(server.status == String(localized: AppStrings.Pro.verifyingAccess)) + try capture(window, name: "metrics-checking-\(language)") + app.library.hostAccess.entitlement = .verificationFailed + app.metrics.core.reconcile() + try await settle() + #expect(server.hostAccessError == .entitlementVerificationFailed) + #expect(!server.isConnecting) + #expect(server.status == String(localized: AppStrings.Pro.accessVerificationFailed)) + try capture(window, name: "metrics-verification-error-\(language)") + app.library.hostAccess.entitlement = .free + app.metrics.core.reconcile() + } + if ProcessInfo.processInfo.environment["TERMIND_HOST_ACCESS_UI_REVIEW_METRICS_ONLY"] == "1" { return } + try await withWindow( + NavigationStack { MobileAISettingsView(app: app) }, language: language + ) { window, _ in + try capture(window, name: "ai-settings-mcp-\(language)") + } + try await withWindow( + NavigationStack { MobileMCPSettings(ai: app.ai) }, language: language + ) { window, _ in + try capture(window, name: "mcp-empty-\(language)") + } + let scene = MobileSceneModel(app: app, sessionID: "host-access-review-\(language)") + try await withWindow(MobileRootView(app: app, scene: scene), language: language) { window, controller in + app.library.hostAccess.entitlement = .verificationFailed + #expect(scene.workspaceSession.open(hostID: locked.id, kind: .ssh) == nil) + try await settle() + let alert = try #require(descendant(of: UIAlertController.self, in: controller)) + #expect(alert.message == String(localized: AppStrings.Pro.accessVerificationFailed)) + try capture(window, name: "connection-verification-error-\(language)") + } + app.error = nil + app.library.hostAccess.entitlement = .free + #expect(app.deleteHost(locked.id)) + let draft = TermindDomain.Host(name: "Backup", host: "backup.example.invalid") + let waiting = ReviewAccessWait() + app.library.hostAccess.entitlement = .loading + app.library.resolveInitialHostEntitlement = { + await waiting.wait() + app.library.hostAccess.entitlement = .verificationFailed + } + defer { waiting.finish() } + let pendingState = ReviewEditorPresentation() + try await withWindow( + ReviewHostEditor(app: app, draft: draft, state: pendingState), language: language + ) { window, controller in + try capture(window, name: "add-host-\(language)") + try activateSave(in: controller) + try await settle() + #expect(waiting.isWaiting) + #expect(app.library.host(draft.id) == nil) + #expect(pendingState.presented) + // The existing editor places its saving spinner at the end of the form. + let editor = try #require(descendant(of: UINavigationController.self, in: controller)) + if let scroll = views(in: editor.view).compactMap({ $0 as? UICollectionView }).first { + if let section = (0.. 0 }) + { + scroll.scrollToItem( + at: IndexPath(item: scroll.numberOfItems(inSection: section) - 1, section: section), + at: .bottom, animated: false) + } + } + try await settle() + try capture(window, name: "save-pending-\(language)") + waiting.finish() + try await settle() + let alert = try #require(descendant(of: UIAlertController.self, in: controller)) + #expect(alert.message == String(localized: AppStrings.Pro.accessVerificationFailed)) + #expect(pendingState.presented) + #expect(app.library.host(draft.id) == nil) + try capture(window, name: "save-verification-error-\(language)") + } + + app.library.hostAccess.entitlement = .free + let paywallState = ReviewEditorPresentation() + try await withWindow( + ReviewHostEditor(app: app, draft: draft, state: paywallState), language: language + ) { window, controller in + try activateSave(in: controller) + try await Task.sleep(for: .seconds(8)) + #expect(paywallState.presented) + #expect(app.library.host(draft.id) == nil) + #expect(descendant(of: UIAlertController.self, in: controller) == nil) + try capture(window, name: "fourth-host-paywall-\(language)") + print("Mobile paywall capture uses actual RevenueCat PaywallView; inspect product loading before sharing.") + } + } + + private func withWindow( + _ content: some View, language: String, + check: (UIWindow, UIViewController) async throws -> Void + ) async throws { + let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) + let previous = scene.keyWindow + let window = UIWindow(windowScene: scene) + window.overrideUserInterfaceStyle = .light + let controller = UIHostingController(rootView: content.environment(\.locale, Locale(identifier: language))) + window.rootViewController = controller + window.windowLevel = .alert + 1 + window.makeKeyAndVisible() + defer { + window.isHidden = true + window.rootViewController = nil + previous?.makeKey() + } + try await settle() + try await check(window, controller) + } + + private func capture(_ window: UIWindow, name: String) throws { + window.layoutIfNeeded() + let data = UIGraphicsImageRenderer(bounds: window.bounds).pngData { _ in + window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + } + let filename = "iphone-host-access-\(name).png" + Attachment.record(data, named: filename) + let directory = FileManager.default.temporaryDirectory.appending(path: "host-access-review") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let path = directory.appending(path: filename) + try data.write(to: path) + print("TERMIND_MOBILE_HOST_ACCESS_REVIEW|\(path.path)") + } + + private func activateSave(in controller: UIViewController) throws { + let navigation = try #require(descendant(of: UINavigationController.self, in: controller)) + let item = try #require(navigation.topViewController?.navigationItem) + let buttons = + item.leadingItemGroups.flatMap(\.barButtonItems) + + item.trailingItemGroups.flatMap(\.barButtonItems) + + (item.leftBarButtonItems ?? []) + (item.rightBarButtonItems ?? []) + let save = try #require( + buttons.first { + ["Save", "保存", "儲存"].contains($0.accessibilityLabel ?? $0.title ?? "") + }) + #expect(save.isEnabled) + let action = try #require(save.action) + #expect(UIApplication.shared.sendAction(action, to: save.target, from: save, for: nil)) + } + + private func descendant(of type: T.Type, in controller: UIViewController) -> T? { + var pending = [controller] + var visited = Set() + while let next = pending.popLast() { + guard visited.insert(ObjectIdentifier(next)).inserted else { continue } + if let result = next as? T { return result } + pending.append(contentsOf: next.children) + if let presented = next.presentedViewController { pending.append(presented) } + } + return nil + } + + private func views(in view: UIView) -> [UIView] { + [view] + view.subviews.flatMap { views(in: $0) } + } + + private func scrollToBottom(in view: UIView) async throws { + for _ in 0..<3 { + view.layoutIfNeeded() + if let scroll = views(in: view).compactMap({ $0 as? UIScrollView }).max(by: { + $0.contentSize.height - $0.bounds.height < $1.contentSize.height - $1.bounds.height + }) { + scroll.setContentOffset( + CGPoint( + x: 0, + y: max(0, scroll.contentSize.height - scroll.bounds.height + scroll.adjustedContentInset.bottom) + ), + animated: false) + } + try await settle() + } + } + + private func settle() async throws { try await Task.sleep(for: .milliseconds(800)) } +} + +@MainActor +private final class ReviewPurchaseService: ProPurchaseService { + var updates: AsyncStream { AsyncStream { $0.finish() } } + func currentStatus() async throws -> EntitlementStatus { .free } +} + +@MainActor +private final class ReviewAccessWait { + private var continuation: CheckedContinuation? + var isWaiting: Bool { continuation != nil } + func wait() async { await withCheckedContinuation { continuation = $0 } } + func finish() { + continuation?.resume() + continuation = nil + } +} + +@Observable @MainActor +private final class ReviewEditorPresentation { + var presented = true +} + +private struct ReviewHostEditor: View { + let app: MobileAppModel + let draft: TermindDomain.Host + @Bindable var state: ReviewEditorPresentation + + var body: some View { + NavigationStack { + MobileVaultView(app: app, editingHost: .constant(nil), activateHost: { _ in }, open: { _, _ in }) + } + .sheet(isPresented: $state.presented) { MobileHostEditor(app: app, host: draft) } + } +} diff --git a/TFITests/MobileHostWakeUIReviewTests.swift b/TFITests/MobileHostWakeUIReviewTests.swift new file mode 100644 index 00000000..86441362 --- /dev/null +++ b/TFITests/MobileHostWakeUIReviewTests.swift @@ -0,0 +1,98 @@ +import RevenueCat +import SwiftUI +import TermindApplication +import TermindDomain +import Testing +import UIKit + +@testable import TermindIOS +@testable import TermindInfrastructure + +/// Holds an isolated host list for an actual Simulator long-press and screenshot. +@Suite( + .requiresKeyWindow, + .enabled(if: ProcessInfo.processInfo.environment["TERMIND_HOST_WAKE_UI_REVIEW"] == "1"), + .serialized) +@MainActor +struct MobileHostWakeUIReviewTests { + @Test(.timeLimit(.minutes(2))) func lockedWakeMenuAndPaywallForManualReview() async throws { + if !Purchases.isConfigured, let configuration = RevenueCatConfiguration.bundled() { + Purchases.configure( + with: Configuration.builder(withAPIKey: configuration.apiKey) + .with(storeKitVersion: .storeKit2) + .with(automaticDeviceIdentifierCollectionEnabled: false) + .with(preferredUILocaleOverride: "en")) + } + let pro = ProAccessController(purchaseService: WakeReviewPurchaseService(), observeUpdates: false) + await pro.refresh() + var dependencies = MobileAppDependencies.testing() + dependencies.proAccess = pro + let app = try MobileAppModel(dependencies: dependencies) + defer { + app.metrics.core.shutdown() + app.outbox.stop() + } + app.library.hostAccess.entitlement = .unrestrictedForTesting + for name in ["Production", "Staging", "Database", "Backup"] { + var host = TermindDomain.Host(name: name, host: "\(name.lowercased()).example.invalid") + host.wakeOnLAN = .init(macAddress: "00:1A:2B:3C:4D:5E") + app.library.upsert(host) + } + app.library.hostAccess.entitlement = .free + app.library.wakeOnLAN = WakeOnLANController(sender: UnexpectedReviewWakeSender()) + let backup = try #require(app.library.hosts.first(where: { $0.name == "Backup" })) + #expect(app.library.isLocked(backup)) + let model = MobileSceneModel(app: app, sessionID: "wake-paywall-review") + let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) + let previous = scene.keyWindow + let window = UIWindow(windowScene: scene) + window.overrideUserInterfaceStyle = .light + window.rootViewController = UIHostingController( + rootView: MobileRootView(app: app, scene: model).environment(\.locale, Locale(identifier: "en"))) + window.windowLevel = .alert + 1 + window.makeKeyAndVisible() + defer { + window.isHidden = true + window.rootViewController = nil + previous?.makeKey() + } + try await Task.sleep(for: .seconds(1)) + if let scroll = descendants(window).compactMap({ $0 as? UIScrollView }) + .first(where: { $0.contentSize.height > $0.bounds.height + 100 }) + { + scroll.setContentOffset( + CGPoint(x: 0, y: scroll.contentSize.height - scroll.bounds.height + scroll.adjustedContentInset.bottom), + animated: false) + } + try await Task.sleep(for: .seconds(1)) + print("TERMIND_MOBILE_WAKE_REVIEW_READY") + fflush(nil) + for _ in 0..<180 where !app.pro.isPaywallPresented { try await Task.sleep(for: .milliseconds(500)) } + try #require(app.pro.isPaywallPresented, "Long-press Backup and tap Wake during the manual review hold") + try await Task.sleep(for: .seconds(8)) + let data = UIGraphicsImageRenderer(bounds: window.bounds).pngData { _ in + window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + } + let filename = "iphone-host-access-wake-paywall-en.png" + Attachment.record(data, named: filename) + let path = FileManager.default.temporaryDirectory.appending(path: filename) + try data.write(to: path) + print("TERMIND_MOBILE_HOST_ACCESS_REVIEW|\(path.path)") + #expect(model.editingHost == nil) + #expect(app.workspaces.isEmpty) + } + + private func descendants(_ view: UIView) -> [UIView] { + [view] + view.subviews.flatMap { descendants($0) } + } +} + +@MainActor +private final class WakeReviewPurchaseService: ProPurchaseService { + var updates: AsyncStream { AsyncStream { $0.finish() } } + func currentStatus() async throws -> EntitlementStatus { .free } +} + +private nonisolated struct UnexpectedReviewWakeSender: WakeOnLANSending { + func send(to address: MACAddress) throws { Issue.record("A locked host must not send a Wake packet") } +} diff --git a/TFITests/MobileMetricsHostAccessTests.swift b/TFITests/MobileMetricsHostAccessTests.swift new file mode 100644 index 00000000..b1d0a613 --- /dev/null +++ b/TFITests/MobileMetricsHostAccessTests.swift @@ -0,0 +1,51 @@ +import Foundation +import TermindApplication +import TermindDomain +import TermindInfrastructure +import Testing + +@testable import TermindIOS +@testable import TermindPresentation + +@Suite @MainActor +struct MobileMetricsHostAccessTests { + @Test func failedAccessUsesErrorIndicatorRatherThanAnIndefiniteSpinner() { + let server = ServerMetric(selection: MetricsSelection(id: UUID()), name: "Host") + server.hostAccessError = .entitlementVerificationFailed + #expect(MetricsInstrumentBody(server: server, sleeping: false).connectionIndicator == .attention) + server.hostAccessError = .verifyingEntitlement + server.isConnecting = true + #expect(MetricsInstrumentBody(server: server, sleeping: false).connectionIndicator == .progress) + } + + @Test func hostQuotaDoesNotProduceAFallbackSaveAlert() { + let error: any Error = HostAccessError.hostLimit + #expect(HostAccessError.presentationDescription(of: error) == nil) + } + + @Test func erasedHostAccessErrorsUseTheLocalizedPresentationMessage() { + let error: any Error = HostAccessError.entitlementVerificationFailed + #expect(error.localizedDescription == String(localized: AppStrings.Pro.accessVerificationFailed)) + } + + @Test func lockedCardActivationOpensPaywallInsteadOfDetails() throws { + let free = ProAccessController.testing(status: .free) + var dependencies = MobileAppDependencies.testing() + dependencies.proAccess = free + let app = try MobileAppModel(dependencies: dependencies) + defer { app.metrics.core.shutdown() } + let server = ServerMetric(selection: MetricsSelection(id: UUID()), name: "Locked") + server.point = MetricsPoint(date: .now, cpu: 50) + server.hostAccessError = .lockedHost(server.id) + var inspected = false + app.activateMetrics(server) { inspected = true } + #expect(free.isPaywallPresented) + #expect(!inspected) + #expect(server.point != nil) + free.isPaywallPresented = false + server.hostAccessError = .entitlementVerificationFailed + app.activateMetrics(server) { inspected = true } + #expect(!free.isPaywallPresented) + #expect(inspected) + } +} diff --git a/TFITests/MobileMetricsJourneyTests.swift b/TFITests/MobileMetricsJourneyTests.swift index 91fe7258..d60c7b6f 100644 --- a/TFITests/MobileMetricsJourneyTests.swift +++ b/TFITests/MobileMetricsJourneyTests.swift @@ -19,7 +19,7 @@ struct MobileMetricsJourneyTests { app.library.upsert(host) let scene = MobileSceneModel(app: app, sessionID: "test") scene.selectedDestination = .metrics - #expect(app.metrics.add(host.id, pro: true)) + #expect(app.metrics.add(host.id)) let server = try #require(app.metrics.servers.first) let route = MetricsJourneyTestRoute() var features = MobileFeatureRegistry() diff --git a/TFITests/MobileMultiHostPickerTests.swift b/TFITests/MobileMultiHostPickerTests.swift index 9cb487df..729f5b8b 100644 --- a/TFITests/MobileMultiHostPickerTests.swift +++ b/TFITests/MobileMultiHostPickerTests.swift @@ -80,14 +80,14 @@ struct MobileMultiHostPickerTests { defer { metrics.core.shutdown() } metrics.pause() - #expect(metrics.add([hosts[0].id], pro: false)) + #expect(metrics.add([hosts[0].id])) let saved = preferences.values - #expect(!metrics.add(hosts[1...3].map(\.id), pro: false)) + #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], pro: false)) + #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), pro: true)) + #expect(metrics.add(hosts[3...4].map(\.id))) #expect(metrics.selections.map(\.id) == hosts.map(\.id)) } @@ -101,7 +101,7 @@ struct MobileMultiHostPickerTests { defer { metrics.core.shutdown() } metrics.pause() - #expect(!metrics.add(hosts.map(\.id), pro: true)) + #expect(!metrics.add(hosts.map(\.id))) #expect(metrics.selections.isEmpty) #expect(preferences.values.isEmpty) #expect(metrics.core.storageError != nil) @@ -115,14 +115,14 @@ struct MobileMultiHostPickerTests { preferences: preferences, hosts: HostLibraryModel(hosts: hosts), services: .testing) defer { metrics.core.shutdown() } metrics.pause() - #expect(metrics.add(Array(hosts.prefix(3)).map(\.id), pro: false)) + #expect(metrics.add(Array(hosts.prefix(3)).map(\.id))) let editable = Set(hosts.map(\.id)) #expect( metrics.replaceHosts( - selectedIDs: [hosts[0].id, hosts[2].id, hosts[3].id], editableHostIDs: editable, pro: false)) + selectedIDs: [hosts[0].id, hosts[2].id, hosts[3].id], editableHostIDs: editable)) #expect(metrics.selections.map(\.id) == [hosts[0].id, hosts[2].id, hosts[3].id]) - #expect(metrics.replaceHosts(selectedIDs: [], editableHostIDs: editable, pro: false)) + #expect(metrics.replaceHosts(selectedIDs: [], editableHostIDs: editable)) #expect(metrics.selections.isEmpty) #expect(preferences.values.isEmpty) } diff --git a/TFITests/MobileWakeAccessTests.swift b/TFITests/MobileWakeAccessTests.swift new file mode 100644 index 00000000..7cbcb97c --- /dev/null +++ b/TFITests/MobileWakeAccessTests.swift @@ -0,0 +1,39 @@ +import Foundation +import TermindApplication +import TermindDomain +import TermindInfrastructure +import Testing + +@testable import TermindIOS + +@Suite @MainActor +struct MobileWakeAccessTests { + @Test(arguments: ["", "02:11:22:33:44:55"]) + func lockedManualWakePresentsPaywallWithoutConfigurationOrPacket(macAddress: String) 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 { + app.library.upsert(TermindDomain.Host(name: "Host \(index)", host: "host.invalid")) + } + var locked = try #require(app.library.hosts.last) + locked.wakeOnLAN.macAddress = macAddress + app.library.upsert(locked) + app.library.hostAccess.entitlement = .free + app.library.wakeOnLAN = WakeOnLANController(sender: UnexpectedMobileWakeSender()) + #expect(app.library.isLocked(locked)) + var configured = false + #expect(app.requestWake(locked) { configured = true } == nil) + #expect(app.pro.isPaywallPresented) + #expect(!configured) + } +} + +private nonisolated struct UnexpectedMobileWakeSender: WakeOnLANSending { + func send(to address: MACAddress) throws { Issue.record("A locked host must not send a Wake packet") } +} diff --git a/TFITests/Screenshots/MobileLocalDiscoveryRenderingTests.swift b/TFITests/Screenshots/MobileLocalDiscoveryRenderingTests.swift index ad5ea059..34b39673 100644 --- a/TFITests/Screenshots/MobileLocalDiscoveryRenderingTests.swift +++ b/TFITests/Screenshots/MobileLocalDiscoveryRenderingTests.swift @@ -24,7 +24,7 @@ struct MobileLocalDiscoveryRenderingTests { Form { HostConnectionFields(host: .constant(host), protocol: connectionProtocol, discover: {}) HostWakeOnLANSection( - preferences: .constant(.init()), requestAccess: { true }, fetchMAC: {}) + preferences: .constant(.init()), fetchMAC: {}) } .navigationTitle(AppStrings.Vault.addHost.swiftUIKey) } diff --git a/TFM/App/AppModel+LocalNetwork.swift b/TFM/App/AppModel+LocalNetwork.swift index 70e37bd9..405b0ef4 100644 --- a/TFM/App/AppModel+LocalNetwork.swift +++ b/TFM/App/AppModel+LocalNetwork.swift @@ -12,8 +12,9 @@ extension AppModel { } func requestWake(_ host: Host, configure: () -> Void) -> String? { - switch hostLibrary.requestWake(host, requestAccess: { proAccess.require(.wakeOnLAN) }) { + switch hostLibrary.requestWake(host) { case .accessDenied: + if hostLibrary.isLocked(host) { proAccess.presentPaywall() } return nil case .configurationRequired: configure() diff --git a/TFM/App/AppModel.swift b/TFM/App/AppModel.swift index e3b9a222..4352ab75 100644 --- a/TFM/App/AppModel.swift +++ b/TFM/App/AppModel.swift @@ -255,14 +255,18 @@ final class AppModel { suppliedMetrics ?? ServerMetricsModel( hosts: hosts, preferences: dependencies.metricsPreferences ?? MetricsPreferences(), - makeSampler: { connectionCoordinator.makeMetricsSampler() }, - cardLimit: { proAccess.canUse(.unlimitedMetrics) ? nil : ServerMetricsModel.freeCardLimit }, - limitReached: { proAccess.require(.unlimitedMetrics) } + makeSampler: { connectionCoordinator.makeMetricsSampler() } ) self.hosts = hosts + hosts.hostAccess.entitlement = proAccess.status + hosts.requestHostUpgrade = { proAccess.presentPaywall() } + hosts.resolveInitialHostEntitlement = { [weak hosts] in + await proAccess.refresh() + hosts?.hostAccess.entitlement = proAccess.status + } self.proAccess = proAccess hosts.wakeOnLAN = WakeOnLANController( - sender: LANWakeOnLANSender(), hasAccess: { proAccess.canUse(.wakeOnLAN) }) + sender: LANWakeOnLANSender()) let wakeLogger = dependencies.logging.makeLogger(label: "termind.wake-on-lan") hosts.wakeOnLAN?.reportAutomaticFailure = { _ in wakeLogger.warning("wake.automatic.failed") } self.onboarding = onboarding @@ -343,9 +347,7 @@ final class AppModel { secrets: dependencies.mcpServerSecrets, hosts: agentHosts, metrics: serverMetrics, connections: agentConnections, authentication: agentAuthentication, background: agentBackgroundCommands, - externalAuthentication: externalAuthentication, history: externalToolCalls, - canUsePro: { proAccess.canUse(.mcpServer) }, - requestPro: { proAccess.require(.mcpServer) } + externalAuthentication: externalAuthentication, history: externalToolCalls ) agentHosts.configureTemporaryTargets( targets: { [weak self] in self?.temporaryAgentTargets() ?? [] }, @@ -440,6 +442,7 @@ final class AppModel { } tailscale.start() proAccess.accessDidChange = { [weak self] in + self?.reconcileProAccess() self?.tailscale.reconcile() self?.serverMetrics.reconcile() self?.reconcileConnectionPreferences() @@ -509,13 +512,13 @@ final class AppModel { } func reconcileProAccess() { + hosts.hostAccess.entitlement = proAccess.status if proAccess.canUse(.vaultSync) { vaultSync?.start() connectionHistorySync?.start() deviceSync?.start() metricsSync?.start() settingsSync?.start() - mcpServer.resumeForProAccess() } else { vaultSync?.pause() connectionHistorySync?.pause() @@ -523,9 +526,6 @@ final class AppModel { metricsSync?.pause() settingsSync?.pause() } - if !proAccess.canUse(.mcpServer), mcpServer.isEnabled { - mcpServer.suspendForProAccess() - } } private func applyRemoteVaultSnapshot( @@ -573,7 +573,7 @@ final class AppModel { observeNotifications: { [weak self] in self?.assistantNotifications.observe($0) }, authorize: { [weak self] in guard let self, !self.isTerminating else { return false } - return self.proAccess.require(.assistant) + return true }, makeRegistry: { [weak self] conversation, model, provider, skills, channel, panes, source in guard let self else { return AgentToolRegistry([AnyAgentTool]()) } @@ -1061,7 +1061,15 @@ final class AppModel { _ host: Host, as target: HostOpenTarget, in window: WindowModel - ) -> Workspace { + ) -> Workspace? { + guard + admitHostOpen( + host, in: window, + whenResolved: { [weak self, weak window] in + guard let self, let window else { return } + self.open(host, as: target, in: window) + }) + else { return nil } configureConnectionHistory(in: window) beginOpening(host, as: target) @@ -1083,13 +1091,35 @@ final class AppModel { } @discardableResult - func openQuickConnect(_ target: QuickConnectTarget, in window: WindowModel) -> Workspace { + func openQuickConnect(_ target: QuickConnectTarget, in window: WindowModel) -> Workspace? { if target.isSaved { return open( target.host, as: target.connectionProtocol == .ssh ? .sshSession : .telnetSession, in: window) } + for node in target.route.hosts { + guard + admitHostOpen( + node, in: window, + whenResolved: { [weak self, weak window] in + guard let self, let window else { return } + self.openQuickConnect(target, in: window) + }) + else { return nil } + } + for id in target.route.sourceHostIDs.values { + if let saved = hosts.host(id), + !admitHostOpen( + saved, in: window, + whenResolved: { [weak self, weak window] in + guard let self, let window else { return } + self.openQuickConnect(target, in: window) + }) + { + return nil + } + } hosts.setStatus(.connecting, for: target.host.id) if target.connectionProtocol == .telnet { return window.openTelnetSession(host: target.host) @@ -1100,11 +1130,23 @@ final class AppModel { } @discardableResult - func saveQuickConnect(_ target: QuickConnectTarget) throws -> Host { + func saveQuickConnect(_ target: QuickConnectTarget) async throws -> Host { + do { + return try commitQuickConnect(target) + } catch HostAccessError.verifyingEntitlement { + await proAccess.refresh() + hosts.hostAccess.entitlement = proAccess.status + try Task.checkCancellation() + return try commitQuickConnect(target) + } + } + + private func commitQuickConnect(_ target: QuickConnectTarget) throws -> Host { if target.connectionProtocol == .telnet { var host = target.host host.sshEnabled = false host.telnetEnabled = true + try hosts.requireSavingHosts([host]) guard hosts.saveHost(host) else { throw TermindSSHError.library( hosts.storageError ?? String(localized: AppStrings.Vault.theHostCouldNotBeSaved)) @@ -1246,10 +1288,26 @@ final class AppModel { layout: BatchLayout = .separateWorkspaces, in window: WindowModel ) { + if let pending = hosts.first(where: { + do { + try self.hosts.requireHostAccess($0) + return false + } catch { + return (error as? HostAccessError) == .verifyingEntitlement + } + }) { + _ = admitHostOpen(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) } + guard !hosts.isEmpty else { return } configureConnectionHistory(in: window) switch layout { case .separateWorkspaces: - let opened = hosts.map { open($0, as: target, in: window) } + let opened = hosts.compactMap { open($0, as: target, in: window) } if let first = opened.first { window.select(first.id) } case .oneWorkspace: for host in hosts { beginOpening(host, as: target) } @@ -1257,6 +1315,36 @@ final class AppModel { } } + private func admitHostOpen( + _ host: Host, in window: WindowModel, whenResolved: (() -> Void)? = nil + ) -> Bool { + do { + try hosts.requireHostAccess(host) + 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) + whenResolved?() + } catch { + if case HostAccessError.lockedHost = error { + proAccess.presentPaywall() + } else if !(error is CancellationError) { + window.hostAccessError = HostAccessError.presentationDescription(of: error) + } + } + } + } else if case HostAccessError.lockedHost = error { + proAccess.presentPaywall() + } else { + window.hostAccessError = HostAccessError.presentationDescription(of: error) + } + return false + } + } + /// Turns Snippet execution into ordinary visible sessions rather than a /// hidden job. `overrideHostIDs` powers explicit host overrides; `fallbackHostIDs` /// is used only by snippets whose saved target list is empty. diff --git a/TFM/App/MCPServerController.swift b/TFM/App/MCPServerController.swift index e9c84a44..d20b53ae 100644 --- a/TFM/App/MCPServerController.swift +++ b/TFM/App/MCPServerController.swift @@ -33,8 +33,7 @@ final class MCPServerController { private let externalAuthentication: ExternalAuthCoordinator private let http = LoopbackHTTPServer() private let protocolServer: MCPServerProtocol - private let canUsePro: @MainActor () -> Bool - private let requestPro: @MainActor () -> Bool + let tools: MCPServerTools private(set) var status: Status = .stopped @@ -51,8 +50,7 @@ final class MCPServerController { background: AgentBackgroundCommands, externalAuthentication: ExternalAuthCoordinator, history: ToolAuditModel, - canUsePro: @escaping @MainActor () -> Bool = { true }, - requestPro: @escaping @MainActor () -> Bool = { true }, + defaults: UserDefaults = .standard ) { tokenStore = MCPServerTokenStore(secrets: secrets) @@ -63,8 +61,7 @@ final class MCPServerController { self.background = background self.externalAuthentication = externalAuthentication self.defaults = defaults - self.canUsePro = canUsePro - self.requestPro = requestPro + isEnabled = defaults.object(forKey: DesktopSettingsKey.mcpServerEnabled) as? Bool ?? DesktopSettingsKey.defaultMCPServerEnabled @@ -80,11 +77,11 @@ final class MCPServerController { ) protocolServer = MCPServerProtocol(tools: tools) reloadToken() - if isEnabled, canUsePro() { Task { [weak self] in await self?.start() } } + if isEnabled { Task { [weak self] in await self?.start() } } } func setEnabled(_ enabled: Bool) { - if enabled, !requestPro() { return } + isEnabled = enabled defaults.set(enabled, forKey: DesktopSettingsKey.mcpServerEnabled) Task { [weak self] in @@ -92,17 +89,8 @@ final class MCPServerController { } } - func suspendForProAccess() { - Task { [weak self] in await self?.stop() } - } - - func resumeForProAccess() { - guard isEnabled, canUsePro() else { return } - Task { [weak self] in await self?.start() } - } - func setPort(_ value: Int) { - guard requestPro() else { return } + let clamped = min(max(value, 1_024), 65_535) guard clamped != port else { return } port = clamped @@ -111,7 +99,7 @@ final class MCPServerController { } func setToolEnabled(_ enabled: Bool, name: String) { - guard requestPro() else { return } + guard MCPServerTools.exportedNames.contains(name) else { return } if enabled { disabledTools.remove(name) } else { disabledTools.insert(name) } defaults.set(Array(disabledTools).sorted(), forKey: DesktopSettingsKey.mcpServerDisabledTools) @@ -125,7 +113,7 @@ final class MCPServerController { } func rotateToken() { - guard requestPro() else { return } + guard let generated = try? Self.makeToken() else { status = .failed(String(localized: AppStrings.MCP.unableToGenerateASecureToken)) return @@ -167,7 +155,7 @@ final class MCPServerController { } private func start() async { - guard isEnabled, canUsePro() else { return } + guard isEnabled else { return } guard let token = ensureToken(), let validPort = UInt16(exactly: port) else { isEnabled = false defaults.set(false, forKey: DesktopSettingsKey.mcpServerEnabled) diff --git a/TFM/App/RootView.swift b/TFM/App/RootView.swift index 39d53f58..87c973fb 100644 --- a/TFM/App/RootView.swift +++ b/TFM/App/RootView.swift @@ -241,6 +241,16 @@ struct RootView: View { } message: { Text(window.wakeNotice ?? "") } + .alert( + AppStrings.Connections.connectionFailed, + isPresented: Binding( + get: { window.hostAccessError != nil }, set: { if !$0 { window.hostAccessError = nil } } + ) + ) { + Button(AppStrings.Common.ok.swiftUIKey) { window.hostAccessError = nil } + } message: { + Text(window.hostAccessError ?? "") + } .proPaywallPresenter(app: app, window: window) .focusedSceneValue(\.window, window) .onChange(of: window.selectedDestination) { diff --git a/TFM/App/TermindApp.swift b/TFM/App/TermindApp.swift index 974fac96..23239c2e 100644 --- a/TFM/App/TermindApp.swift +++ b/TFM/App/TermindApp.swift @@ -325,7 +325,13 @@ struct TermindApp: App { app.serverMetrics.start() metricsActivity.start(app.serverMetrics) metricsMenu.openMainWindow = { [weak app] in app?.mainWindows.openMetrics() } - metricsMenu.openDetails = { [weak app] hostID in app?.mainWindows.openMetrics(hostID: hostID) } + metricsMenu.openDetails = { [weak app] hostID in + guard let app else { return } + app.mainWindows.openMetrics(hostID: hostID) + if app.serverMetrics.servers.first(where: { $0.id == hostID })?.isHostLocked == true { + app.proAccess.presentPaywall() + } + } metricsMenu.start(app.serverMetrics) } } diff --git a/TFM/Features/AI/ConversationTranscriptView.swift b/TFM/Features/AI/ConversationTranscriptView.swift index a75f1a45..fc0712fd 100644 --- a/TFM/Features/AI/ConversationTranscriptView.swift +++ b/TFM/Features/AI/ConversationTranscriptView.swift @@ -325,7 +325,7 @@ private struct ConversationTranscriptSurface: View { conversation.resolvePlan(id, as: outcome) }, implementPlan: { card in - guard app.proAccess.require(.assistant) else { return } + app.sendPlanImplementation( card, in: conversation, context: sessionContext, terminalSuggestionsAvailable: showsTerminalActions, window: window) diff --git a/TFM/Features/AI/ConversationViews.swift b/TFM/Features/AI/ConversationViews.swift index d6c962da..051b2583 100644 --- a/TFM/Features/AI/ConversationViews.swift +++ b/TFM/Features/AI/ConversationViews.swift @@ -985,7 +985,7 @@ struct ComposerView: View { // conversation, or appending a visible user turn. The runtime keeps a // second guard, but by then the UI would already look as though Send // succeeded. - guard app.proAccess.require(.assistant) else { return } + if !conversation.composer.pendingAttachments.isEmpty { return } if conversation.composer.attachments.contains(where: { $0.image != nil }), resolved?.model.isVision != true diff --git a/TFM/Features/HostChooser/HostChooserView.swift b/TFM/Features/HostChooser/HostChooserView.swift index ef702855..fc0d3762 100644 --- a/TFM/Features/HostChooser/HostChooserView.swift +++ b/TFM/Features/HostChooser/HostChooserView.swift @@ -290,13 +290,6 @@ struct HostChooserView: View { ) .padding(.horizontal, Metrics.Space.xl) - if purpose == .metrics, app.serverMetrics.remainingCardSlots != nil { - Text(AppStrings.Pro.freeMetricsLimit) - .font(.caption).foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, Metrics.Space.xl) - } - multiSelectionHostList } .padding(.top, Metrics.Space.xs) diff --git a/TFM/Features/HostEditor/HostEditorView.swift b/TFM/Features/HostEditor/HostEditorView.swift index 267d9fb6..21b7c8b1 100644 --- a/TFM/Features/HostEditor/HostEditorView.swift +++ b/TFM/Features/HostEditor/HostEditorView.swift @@ -64,6 +64,8 @@ struct HostEditorView: View { @State private var originalCustomIdentity = CustomIdentityState(HostCustomIdentityDraft()) @State private var route: Route? @State private var saveError: String? + @State private var isResolvingAccess = false + @State private var saveTask: Task? @State private var didLoad = false @State private var customIdentity = HostCustomIdentityDraft() @State private var selectedProtocol = HostTerminalProtocol.ssh @@ -118,6 +120,7 @@ struct HostEditorView: View { ) { commitChanges() } } .onDisappear { + saveTask?.cancel() discoveryModel?.cancel() macModel?.cancel() guard let editingToken else { return } @@ -397,7 +400,6 @@ struct HostEditorView: View { private var wakeOnLANSection: some View { HostWakeOnLANSection( preferences: $draft.wakeOnLAN, - requestAccess: { app.proAccess.require(.autoWakeOnLAN) }, fetchMAC: draft.sshEnabled ? { macModel = app.macAddressModel( @@ -695,12 +697,15 @@ struct HostEditorView: View { .foregroundStyle(.orange) .lineLimit(2) } + if isResolvingAccess { + ProgressView().controlSize(.small) + } Spacer() Button(AppStrings.Common.cancel.swiftUIKey, action: cancel) Button(AppStrings.Common.save.swiftUIKey) { saveAndClose() } .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) - .disabled(!isValid) + .disabled(!isValid || isResolvingAccess) } .padding(Metrics.Space.l) .termindGlassBar() @@ -768,8 +773,13 @@ struct HostEditorView: View { } else { try customIdentity.save(into: &draft, using: app.hostLibrary) } + } catch HostAccessError.verifyingEntitlement { + saveAndClose() + return false + } catch HostAccessError.hostLimit { + return false } catch { - saveError = error.localizedDescription + saveError = HostAccessError.presentationDescription(of: error) return false } original = draft @@ -781,8 +791,23 @@ struct HostEditorView: View { } private func saveAndClose() { - guard commitChanges() else { return } - closeTab() + guard !isResolvingAccess else { return } + isResolvingAccess = true + saveError = nil + saveTask = Task { + defer { isResolvingAccess = false } + do { + try await app.hostLibrary.resolveSavingHostsAccess([draft]) + try Task.checkCancellation() + guard commitChanges() else { return } + closeTab() + } catch HostAccessError.hostLimit { + // Keep the draft intact underneath the upgrade sheet. + } catch is CancellationError { + } catch { + saveError = HostAccessError.presentationDescription(of: error) + } + } } private func cancel() { diff --git a/TFM/Features/Hosts/HostActions.swift b/TFM/Features/Hosts/HostActions.swift index 9cf4578f..d96ba5cc 100644 --- a/TFM/Features/Hosts/HostActions.swift +++ b/TFM/Features/Hosts/HostActions.swift @@ -201,7 +201,9 @@ enum HostMenuEntries { let showsTailscale = host.isTailscaleManaged || app.tailscale.peer(for: host) != nil if showsWake { entries.append( - NativeMenuEntry(title: AppStrings.LocalNetwork.wake, symbol: "power") { + NativeMenuEntry( + title: AppStrings.LocalNetwork.wake, symbol: "power" + ) { window.wakeNotice = app.requestWake(host) { window.openHostEditor(host: host) } }) } diff --git a/TFM/Features/Hosts/HostsPageView.swift b/TFM/Features/Hosts/HostsPageView.swift index 9ef2b2bf..2c2781d1 100644 --- a/TFM/Features/Hosts/HostsPageView.swift +++ b/TFM/Features/Hosts/HostsPageView.swift @@ -524,6 +524,10 @@ struct HostCard: View { } private func quickConnect() { + if app.hostLibrary.isLocked(host) { + app.proAccess.presentPaywall() + return + } app.open(actionHosts, as: .preferredTerminal, in: window) } @@ -535,9 +539,12 @@ struct HostCard: View { ) VStack(alignment: .leading, spacing: 1) { - Text(host.name) - .font(.headline) - .lineLimit(1) + HStack { + Text(host.name) + if app.hostLibrary.isLocked(host) { Image(systemName: "lock.fill") } + } + .font(.headline) + .lineLimit(1) Text(app.hostLibrary.preferredTerminalLoginAddress(for: host)) .font(.caption) .foregroundStyle(.secondary) diff --git a/TFM/Features/Metrics/MetricsManagedCardPage.swift b/TFM/Features/Metrics/MetricsManagedCardPage.swift index c8234f26..05648791 100644 --- a/TFM/Features/Metrics/MetricsManagedCardPage.swift +++ b/TFM/Features/Metrics/MetricsManagedCardPage.swift @@ -42,6 +42,7 @@ struct MetricsManagedCardPage: ManagedCardPage { ServerMetricsCard( server: item, model: app.serverMetrics, inspect: { window.metricsHostID = item.id }, + upgrade: { app.proAccess.presentPaywall() }, openTerminal: { if let host = app.hostLibrary.host(item.id) { app.open(host, as: .sshSession, in: window) } }, diff --git a/TFM/Features/Metrics/MetricsView.swift b/TFM/Features/Metrics/MetricsView.swift index c75f36a7..c71d0b09 100644 --- a/TFM/Features/Metrics/MetricsView.swift +++ b/TFM/Features/Metrics/MetricsView.swift @@ -17,7 +17,7 @@ struct ServerMetricsView: View { ZStack { if let server = selectedServer { Group { - if server.showsConnectionJourney { + if server.showsConnectionJourney && !server.isHostLocked { MetricsJourneyView(server: server, model: app.serverMetrics) } else { detail(server) @@ -61,9 +61,25 @@ struct ServerMetricsView: View { struct MetricsDetailView: View { let server: ServerMetric + @Environment(AppModel.self) private var app @Environment(WindowModel.self) private var window var body: some View { + if server.isHostLocked { + Button { + app.proAccess.presentPaywall() + } label: { + MetricsHostLockNotice() + .padding(24) + .frame(maxHeight: .infinity) + } + .buttonStyle(.plain) + } else { + detail + } + } + + private var detail: some View { GeometryReader { geometry in let split = MetricsDetailPage.showsBothColumns(width: geometry.size.width) let showsStatus = split || window.metricsDetailPage == .status @@ -102,6 +118,7 @@ struct ServerMetricsCard: View { let model: ServerMetricsModel var compact = false var inspect: (() -> Void)? + var upgrade: (() -> Void)? var openTerminal: (() -> Void)? var isSelected = false var select: (() -> Void)? @@ -113,7 +130,7 @@ struct ServerMetricsCard: View { CardTile( selectionID: server.id, isSelected: isSelected, select: select ?? {}, extendSelection: extendSelection, - activate: inspect, activatesOnReturn: !compact, activatesOnSingleClick: true + activate: activate, activatesOnReturn: !compact, activatesOnSingleClick: true ) { cardContent } @@ -153,8 +170,8 @@ struct ServerMetricsCard: View { private var cardContent: some View { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 10) { - if select == nil, let inspect { - Button(action: inspect) { Text(server.name).font(.subheadline.bold()).lineLimit(1) }.buttonStyle( + if select == nil, inspect != nil { + Button(action: activate) { Text(server.name).font(.subheadline.bold()).lineLimit(1) }.buttonStyle( .plain) } else { Text(server.name).font(.subheadline.bold()).lineLimit(1) @@ -174,7 +191,9 @@ struct ServerMetricsCard: View { .menuStyle(.borderlessButton).fixedSize() } } - if select == nil, let inspect { + if server.isHostLocked { + MetricsHostLockNotice() + } else if select == nil, let inspect { Button(action: inspect) { cardBody }.buttonStyle(.plain) } else { cardBody @@ -183,10 +202,14 @@ struct ServerMetricsCard: View { } } + func activate() { + if server.isHostLocked { upgrade?() } else { inspect?() } + } + @ViewBuilder private var singleServerMenu: some View { - if let inspect { - Button(AppStrings.Metrics.details.swiftUIKey, systemImage: MenuSymbol.details, action: inspect) + if inspect != nil { + Button(AppStrings.Metrics.details.swiftUIKey, systemImage: MenuSymbol.details, action: activate) } Button( server.selection.enabled ? AppStrings.Metrics.pause.swiftUIKey : AppStrings.Metrics.resume.swiftUIKey, @@ -391,7 +414,11 @@ private struct MetricsMenuRow: View { VStack(alignment: .leading, spacing: 12) { Text(server.name).font(.subheadline.bold()).lineLimit(1).help(server.name) .frame(height: 18, alignment: .leading) - MetricsInstrumentBody(server: server, sleeping: sleeping) + if server.isHostLocked { + MetricsHostLockNotice() + } else { + MetricsInstrumentBody(server: server, sleeping: sleeping) + } } .padding(.horizontal, 16) .padding(.vertical, 12) diff --git a/TFM/Features/SFTP/SFTPChannelModel.swift b/TFM/Features/SFTP/SFTPChannelModel.swift index 25abfc31..58ee4f44 100644 --- a/TFM/Features/SFTP/SFTPChannelModel.swift +++ b/TFM/Features/SFTP/SFTPChannelModel.swift @@ -402,6 +402,7 @@ final class SFTPChannelModel: RemotePathDisplaying, Identifiable { connectionTask = Task { [weak self, weak store] in guard let self, let store else { return } do { + try await store.resolveHostAccess(host, route: connectionRoute) let configuration: SSHConnectionConfiguration if let suppliedAuthentication, !host.isTailscaleManaged { let expectedHostKeys = try store.expectedHostKeys( @@ -468,8 +469,12 @@ final class SFTPChannelModel: RemotePathDisplaying, Identifiable { ) } var routedConfiguration = configuration + routedConfiguration.accessHostIDs = + configuration.jumps.map(\.id) + [host.id] + + (connectionRoute.map { Array($0.sourceHostIDs.values) } ?? []) + routedConfiguration.accessCheck = store.connectionAccessCheck(for: routedConfiguration.accessHostIDs) routedConfiguration.beforeConnect = store.connectionWakeAction( - for: configuration.jumps.map(\.id) + [host.id]) + for: routedConfiguration.accessHostIDs) routedConfiguration.network = try store.tailscaleNetwork( for: (connectionRoute?.hosts ?? store.route(for: host)).first ?? host) routedConfiguration.tailscaleNodeID = host.tailscale?.stableNodeID diff --git a/TFM/Features/Settings/MCPServerEditorSheet.swift b/TFM/Features/Settings/MCPServerEditorSheet.swift index 698fd1e4..07151566 100644 --- a/TFM/Features/Settings/MCPServerEditorSheet.swift +++ b/TFM/Features/Settings/MCPServerEditorSheet.swift @@ -132,7 +132,7 @@ struct MCPServerEditorPage: View { isOn: Binding( get: { app.aiConfiguration.mcpServer(serverID)?.isEnabled ?? false }, set: { - guard app.proAccess.require(.remoteMCP) else { return } + app.aiConfiguration.setServerEnabled($0, for: serverID) })) diff --git a/TFM/Features/Settings/MCPServerSettings.swift b/TFM/Features/Settings/MCPServerSettings.swift index 37aece01..30e7c2b2 100644 --- a/TFM/Features/Settings/MCPServerSettings.swift +++ b/TFM/Features/Settings/MCPServerSettings.swift @@ -15,7 +15,7 @@ struct MCPServerSettings: View { Toggle( AppStrings.Settings.enable.swiftUIKey, isOn: Binding( - get: { server.isEnabled && app.proAccess.canUse(.mcpServer) }, + get: { server.isEnabled }, set: { server.setEnabled($0) } )) @@ -96,8 +96,8 @@ struct MCPServerSettings: View { .foregroundStyle(.red) } } - .task(id: app.proAccess.canUse(.mcpServer)) { - if app.proAccess.canUse(.mcpServer) { _ = server.ensureToken() } + .task { + _ = server.ensureToken() } clientSetupSection diff --git a/TFM/Features/Settings/MCPSettings.swift b/TFM/Features/Settings/MCPSettings.swift index d5416b4b..40304b8e 100644 --- a/TFM/Features/Settings/MCPSettings.swift +++ b/TFM/Features/Settings/MCPSettings.swift @@ -31,7 +31,7 @@ struct MCPSettings: View { } FormNavigationAction(title: AppStrings.MCP.addServer) { - guard app.proAccess.require(.remoteMCP) else { return } + let server = MCPServer(name: "", namespaceID: "") app.aiConfiguration.upsert(server) edit(server.id, true) @@ -58,13 +58,13 @@ struct MCPSettings: View { } private func importMCP() { - guard app.proAccess.require(.remoteMCP) else { return } + let panel = NSOpenPanel() panel.allowsMultipleSelection = false panel.canChooseDirectories = false panel.allowedContentTypes = [.json] guard panel.runModal() == .OK, let url = panel.url else { return } - guard app.proAccess.require(.remoteMCP) else { return } + importReport = nil importProblem = nil do { @@ -110,7 +110,7 @@ private struct MCPServerRow: View { if server.tools.isEmpty { CardChip(text: String(localized: AppStrings.Settings.notConnected), symbol: "questionmark.circle") } else { - CardChip(text: "\(server.tools.count)", symbol: "wrench.and.screwdriver") + CardChip(text: "\(server.tools.count)", symbol: "wrench") } Toggle("", isOn: enabled) @@ -120,7 +120,7 @@ private struct MCPServerRow: View { .accessibilityLabel(Text(AppStrings.Settings.enableNamed(server.name))) Button { - guard app.proAccess.require(.remoteMCP) else { return } + edit() } label: { Image(systemName: "chevron.right") @@ -132,7 +132,7 @@ private struct MCPServerRow: View { } .contentShape(.rect) .onTapGesture { - guard app.proAccess.require(.remoteMCP) else { return } + edit() } .help(Text(app.aiConfiguration.mcpStatus(of: server.id).label)) @@ -142,7 +142,7 @@ private struct MCPServerRow: View { Binding( get: { server.isEnabled }, set: { - guard app.proAccess.require(.remoteMCP) else { return } + app.aiConfiguration.setServerEnabled($0, for: server.id) } ) diff --git a/TFM/Features/Settings/SettingsPageView.swift b/TFM/Features/Settings/SettingsPageView.swift index df138d5b..6ed74b07 100644 --- a/TFM/Features/Settings/SettingsPageView.swift +++ b/TFM/Features/Settings/SettingsPageView.swift @@ -153,14 +153,10 @@ struct SettingsPageView: View { case .providerModels(let id): ModelListEditor(modelProviderID: id) case .mcpServer(let id, _): - if app.proAccess.canUse(.remoteMCP) { - MCPServerEditorPage( - serverID: id, - deleted: { navigate(to: .category(.ai)) } - ) - } else { - ProLockedSettingsPage(title: AppStrings.Settings.mcp) - } + MCPServerEditorPage( + serverID: id, + deleted: { navigate(to: .category(.ai)) } + ) case .localMCPServer: SettingsFormPage { MCPServerSettings() } case .skill(let id, _): diff --git a/TFM/Features/Terminal/SSHSessionModel.swift b/TFM/Features/Terminal/SSHSessionModel.swift index 50e5ab6d..32ab01b9 100644 --- a/TFM/Features/Terminal/SSHSessionModel.swift +++ b/TFM/Features/Terminal/SSHSessionModel.swift @@ -345,6 +345,11 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { /// makes broadcast possible — see `Workspace.deliver`; `submit` appends the /// newline that the user's own Return key would otherwise supply. func insert(_ text: String, submit: Bool = false) { + if let statusStore, let host = statusStore.host(hostID), + (try? statusStore.requireHostAccess(host)) == nil + { + return + } invalidateCommandGeneration() writeInputDirectly(Data((text + (submit ? "\r" : "")).utf8)) } @@ -725,6 +730,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { connectionTask = Task { [weak self, weak store] in guard let self, let store else { return } do { + try await store.resolveHostAccess(host, route: connectionRoute) try await openSSH( host: host, username: username, credentials: credentials, expectedHostKeys: expectedHostKeys, proxy: proxy, jumps: jumps, @@ -904,7 +910,10 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable { authenticationReference: authenticationReference ) guard let store = statusStore else { throw TailscaleConfigurationError.unavailable } - configuration.beforeConnect = store.connectionWakeAction(for: jumps.map(\.id) + [host.id]) + configuration.accessHostIDs = + jumps.map(\.id) + [host.id] + (connectionRoute.map { Array($0.sourceHostIDs.values) } ?? []) + configuration.accessCheck = store.connectionAccessCheck(for: configuration.accessHostIDs) + configuration.beforeConnect = store.connectionWakeAction(for: configuration.accessHostIDs) configuration.network = try store.tailscaleNetwork( for: (connectionRoute?.hosts ?? store.route(for: host)).first ?? host) configuration.tailscaleNodeID = host.tailscale?.stableNodeID diff --git a/TFM/Features/Terminal/TelnetSessionModel.swift b/TFM/Features/Terminal/TelnetSessionModel.swift index aa3e4372..2885df6d 100644 --- a/TFM/Features/Terminal/TelnetSessionModel.swift +++ b/TFM/Features/Terminal/TelnetSessionModel.swift @@ -161,6 +161,7 @@ final class TelnetSessionModel: Identifiable { let viewport = latestViewport connectionTask = Task { [weak self] in do { + try await store.resolveHostAccess(host) var configuration = try store.telnetConnectionConfiguration(for: host) if let usernameHint = self?.usernameHint { configuration.username = usernameHint } if let password = self?.advisoryPassword { diff --git a/TFM/Features/Workspaces/WindowModel.swift b/TFM/Features/Workspaces/WindowModel.swift index 53acabfe..246bff5b 100644 --- a/TFM/Features/Workspaces/WindowModel.swift +++ b/TFM/Features/Workspaces/WindowModel.swift @@ -65,6 +65,7 @@ final class WindowModel { private var settingsReturnDestination: WindowDestination = .vault weak var metricsModel: ServerMetricsModel? var wakeNotice: String? + var hostAccessError: String? var pendingMetricsHostIDs: [UUID]? var pendingMetricsEditableHostIDs: Set = [] var metricsHostID: UUID? { diff --git a/TFM/Features/Workspaces/WorkspaceContentView.swift b/TFM/Features/Workspaces/WorkspaceContentView.swift index 2814b684..1923a747 100644 --- a/TFM/Features/Workspaces/WorkspaceContentView.swift +++ b/TFM/Features/Workspaces/WorkspaceContentView.swift @@ -970,11 +970,14 @@ struct WelcomeView: View { } private func save(_ target: QuickConnectTarget) { - do { - let host = try app.saveQuickConnect(target) - query = host.name - } catch { - actionError = error.localizedDescription + Task { + do { + let host = try await app.saveQuickConnect(target) + query = host.name + } catch HostAccessError.hostLimit { + } catch { + actionError = error.localizedDescription + } } } diff --git a/TFMTests/AppLanguageTests.swift b/TFMTests/AppLanguageTests.swift index ddb9920c..5c84d0e2 100644 --- a/TFMTests/AppLanguageTests.swift +++ b/TFMTests/AppLanguageTests.swift @@ -7,6 +7,31 @@ import Testing @testable import Termind struct AppLanguageTests { + @Test func hostQuotaUsesPaywallWithoutAnErrorMessage() { + let error: any Error = HostAccessError.hostLimit + #expect(HostAccessError.presentationDescription(of: error) == nil) + } + + @Test + func hostAccessErrorsKeepTheirLocalizedMessageWhenErasedToError() { + let error: any Error = HostAccessError.entitlementVerificationFailed + #expect(error.localizedDescription == String(localized: AppStrings.Pro.accessVerificationFailed)) + #expect( + HostAccessError.presentationDescription(of: error) + == String(localized: AppStrings.Pro.accessVerificationFailed)) + } + + @Test(arguments: [ + ("en", "Upgrade to Pro to unlock this host."), + ("zh-Hans", "升级到Pro以解锁此主机。"), + ("zh-Hant", "升級至Pro以解鎖此主機。"), + ]) + func lockedMetricsSuggestsTheNextActionWithoutASeparateButton(language: String, expected: String) { + #expect( + AppLocalization.resolve(AppStrings.Pro.upgradeToUnlockHost, locale: Locale(identifier: language)) + == expected) + } + @Test(arguments: [ ("en", "Rescan"), ("zh-Hans", "重新扫描"), diff --git a/TFMTests/ConnectionHistoryTests.swift b/TFMTests/ConnectionHistoryTests.swift index b290f836..680ecfaf 100644 --- a/TFMTests/ConnectionHistoryTests.swift +++ b/TFMTests/ConnectionHistoryTests.swift @@ -118,7 +118,7 @@ struct ConnectionHistoryTests { devices: devices) let window = WindowModel() - let workspace = app.open(host, as: .sshSession, in: window) + let workspace = try #require(app.open(host, as: .sshSession, in: window)) let opened = try #require(history.entries().first) #expect(opened.sourceDeviceID == deviceID) #expect(devices.name(for: opened.sourceDeviceID) == "Test Mac") diff --git a/TFMTests/HostAccessFlowUIReviewTests.swift b/TFMTests/HostAccessFlowUIReviewTests.swift new file mode 100644 index 00000000..2b33e604 --- /dev/null +++ b/TFMTests/HostAccessFlowUIReviewTests.swift @@ -0,0 +1,282 @@ +import AppKit +import RevenueCat +import SwiftUI +import TermindApplication +import TermindDomain +import TermindPresentation +import Testing + +@testable import Termind +@testable import TermindInfrastructure + +@Suite( + .enabled(if: ProcessInfo.processInfo.environment["TERMIND_HOST_ACCESS_FLOW_REVIEW"] == "1"), + .serialized) +@MainActor +struct HostAccessFlowUIReviewTests { + @Test(.requiresVisibleWindow) + func reviewNativeHostAccessFlows() async throws { + let metricsOnly = ProcessInfo.processInfo.environment["TERMIND_HOST_ACCESS_METRICS_REVIEW_ONLY"] == "1" + NSApp.unhideWithoutActivation() + let configuration = try #require(RevenueCatConfiguration.bundled()) + if !Purchases.isConfigured { + Purchases.configure( + with: Configuration.builder(withAPIKey: configuration.apiKey) + .with(automaticDeviceIdentifierCollectionEnabled: false) + .with(preferredUILocaleOverride: "en")) + } + let service = ReviewAccessPurchaseService() + let pro = ProAccessController(purchaseService: service, observeUpdates: false) + await pro.refresh() + var hosts = ["Production", "Staging", "Database", "Backup"].enumerated().map { index, name in + TermindDomain.Host( + name: name, host: "\(name.lowercased()).example.invalid", + createdAt: Date(timeIntervalSince1970: Double(index))) + } + hosts[3].wakeOnLAN.macAddress = "02:11:22:33:44:55" + let library = HostLibraryModel(hosts: hosts) + let metrics = ServerMetricsModel( + hosts: library, preferences: FlowReviewMetricsPreferences(), + makeSampler: { preconditionFailure("UI review must not open a remote connection") }) + metrics.suspend() + let app = AppModel(hosts: library, proAccess: pro, serverMetrics: metrics) + defer { app.shutdownForTermination() } + let navigation = WindowModel() + navigation.openPage(.hosts) + let controller = NSHostingController( + rootView: RootView(window: navigation) + .environment(app) + .environment(\.locale, Locale(identifier: "en")) + .environment(\.colorScheme, .light)) + controller.sizingOptions = [] + let window = NSWindow( + contentRect: CGRect(x: 0, y: 0, width: 1240, height: 820), + styleMask: [.titled, .closable, .resizable, .miniaturizable], + backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: .aqua) + window.contentViewController = controller + window.setContentSize(NSSize(width: 1240, height: 820)) + window.center() + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + defer { + window.orderOut(nil) + window.contentViewController = nil + window.close() + } + try await settle(window) + if !metricsOnly { + try await capture("hosts", window: window) + try showWakeMenu(host: hosts[3], app: app, navigation: navigation, controller: controller, window: window) + #expect(pro.isPaywallPresented) + try await Task.sleep(for: .seconds(5)) + try await capture("wake-paywall", window: window) + pro.isPaywallPresented = false + try await settle(window) + } + + #expect(metrics.add(hosts[3])) + let server = try #require(metrics.servers.first) + server.point = MetricsPoint(date: .now, cpu: 64, memory: 48) + metrics.reconcile() + navigation.selectedDestination = .metrics + try await capture("metrics", window: window) + + library.hostAccess.entitlement = .loading + metrics.reconcile() + try await capture("metrics-checking", window: window) + library.hostAccess.entitlement = .verificationFailed + metrics.reconcile() + try await capture("metrics-verification-failed", window: window) + if metricsOnly { return } + library.hostAccess.entitlement = .free + + metrics.remove(hosts[3].id) + library.projectHostDeletion(hosts[3].id) + navigation.openHostEditor(host: nil) + try await settle(window) + let fields = descendants(controller.view).compactMap { $0 as? NSTextField } + print("HOST_REVIEW_FIELDS \(fields.map { ($0.placeholderString ?? "-", $0.stringValue) })") + let address = try #require( + fields.first { + $0.placeholderString == "IP address or hostname" || ($0.isEditable && $0.accessibilityLabel() == "Host") + }, "The actual host address field must be available") + #expect(window.makeFirstResponder(address)) + let editor = try #require(address.currentEditor() as? NSTextView) + editor.selectAll(nil) + editor.insertText("backup.example.invalid", replacementRange: editor.selectedRange()) + window.makeFirstResponder(nil) + try await settle(window) + try await capture("new-host", window: window) + + service.delay = .seconds(8) + library.hostAccess.entitlement = .loading + try pressSave(window) + try await capture("save-checking", window: window) + for _ in 0..<100 { + if pro.isPaywallPresented { break } + try await Task.sleep(for: .milliseconds(100)) + } + #expect(pro.isPaywallPresented) + #expect(library.hosts.count == 3) + try await Task.sleep(for: .seconds(5)) + #expect(window.attachedSheet != nil) + try await capture("save-paywall", window: window) + + pro.isPaywallPresented = false + try await settle(window) + service.delay = .zero + library.hostAccess.entitlement = .verificationFailed + try pressSave(window) + try await capture("save-verification-failed", window: window) + #expect(library.hosts.count == 3) + + library.hostAccess.entitlement = .free + navigation.openVaultWork(.sshImport(knownHostsOnly: false), on: .hosts) + try await settle(window) + let directory = FileManager.default.temporaryDirectory.appending(path: "HostReview-\(UUID())") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + try """ + Host Backup + HostName backup.example.invalid + User deploy + """.write(to: directory.appending(path: "config"), atomically: true, encoding: .utf8) + let receiver = try #require( + descendants(controller.view).compactMap { $0 as? FileURLDropNSView } + .last { $0.isEnabled && $0.acceptedKinds == [.files] }) + #expect(receiver.receivePayload(NativeDropPayload(fileURLs: [directory]))) + try await Task.sleep(for: .seconds(2)) + try await capture("import-selection", window: window) + let importButton = + accessibilityButton("Import", in: controller.view) + ?? accessibilityButton("Import", in: window) + ?? descendants(controller.view).lazy.compactMap { accessibilityButton("Import", in: $0) }.first + if let importButton { + #expect(importButton.accessibilityPerformPress()) + } else { + // SwiftUI may defer its AX tree until an external accessibility client attaches. + // This opt-in review also accepts a real click on the visible Import button. + print("TERMIND_ACCESS_IMPORT_READY") + fflush(nil) + for _ in 0..<900 { + if pro.isPaywallPresented { break } + try await Task.sleep(for: .milliseconds(100)) + } + } + try await Task.sleep(for: .seconds(4)) + #expect(pro.isPaywallPresented) + #expect(library.hosts.count == 3) + #expect(window.attachedSheet != nil) + try await capture("import-paywall", window: window) + pro.isPaywallPresented = false + try await capture("import-retained", window: window) + + library.hostAccess.entitlement = .unrestrictedForTesting + library.upsert(hosts[3]) + library.hostAccess.entitlement = .verificationFailed + navigation.openPage(.hosts) + #expect(app.open(hosts[3], as: .sshSession, in: navigation) == nil) + #expect(navigation.hostAccessError == String(localized: AppStrings.Pro.accessVerificationFailed)) + try await capture("connection-verification-failed", window: window) + } + + private func pressSave(_ window: NSWindow) throws { + let event = try #require( + NSEvent.keyEvent( + with: .keyDown, location: .zero, modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, context: nil, + characters: "\r", charactersIgnoringModifiers: "\r", isARepeat: false, keyCode: 36)) + #expect(window.performKeyEquivalent(with: event)) + } + + private func settle(_ window: NSWindow) async throws { + window.contentView?.layoutSubtreeIfNeeded() + try await Task.sleep(for: .milliseconds(600)) + window.contentView?.layoutSubtreeIfNeeded() + } + + private func capture(_ name: String, window: NSWindow) async throws { + try await settle(window) + markCapture(name, window: window.attachedSheet ?? window) + try await Task.sleep(for: .seconds(2)) + } + + private func markCapture(_ name: String, window: NSWindow) { + let screenTop = NSScreen.screens.first?.frame.maxY ?? window.frame.maxY + let frame = window.frame + let rect = "\(Int(frame.minX)),\(Int(screenTop - frame.maxY)),\(Int(frame.width)),\(Int(frame.height))" + print("TERMIND_ACCESS_FLOW|\(name)|\(window.windowNumber)|\(rect)") + fflush(nil) + } + + private func showWakeMenu( + host: TermindDomain.Host, app: AppModel, navigation: WindowModel, + controller: NSViewController, window: NSWindow + ) throws { + let menu = NativeMenuEntry.menu( + HostMenuEntries.entries(for: host, app: app, window: navigation, includesDuplicate: true)) + let wake = try #require(menu.items.first { $0.title == "Wake" }) + #expect(app.hostLibrary.isLocked(host)) + #expect(wake.isEnabled) + let menuController = FlowReviewMenuController(menu: menu) + let captureTimer = Timer(timeInterval: 0.5, repeats: false) { _ in + MainActor.assumeIsolated { markCapture("wake-upgrade", window: window) } + } + let dismissTimer = Timer(timeInterval: 2.5, repeats: false) { _ in + MainActor.assumeIsolated { menuController.cancel() } + } + RunLoop.main.add(captureTimer, forMode: .eventTracking) + RunLoop.main.add(dismissTimer, forMode: .eventTracking) + menu.popUp(positioning: nil, at: CGPoint(x: 850, y: 420), in: controller.view) + menu.performActionForItem(at: menu.index(of: wake)) + } + + private func descendants(_ view: NSView) -> [NSView] { + [view] + view.subviews.flatMap(descendants) + } + + private func accessibilityButton(_ title: String, in element: Any) -> (any NSAccessibilityProtocol)? { + guard let accessible = element as? any NSAccessibilityProtocol else { return nil } + if accessible.accessibilityRole() == .button, + accessible.accessibilityLabel() == title || accessible.accessibilityTitle() == title + { + return accessible + } + for child in accessible.accessibilityChildren() ?? [] { + if let button = accessibilityButton(title, in: child) { return button } + } + return nil + } +} + +@MainActor +private final class FlowReviewMenuController { + let menu: NSMenu + + init(menu: NSMenu) { + self.menu = menu + } + + func cancel() { + menu.cancelTracking() + } +} + +@MainActor +private final class ReviewAccessPurchaseService: ProPurchaseService { + let updates = AsyncStream { $0.finish() } + var delay = Duration.zero + func currentStatus() async throws -> EntitlementStatus { + if delay > .zero { try await Task.sleep(for: delay) } + return .free + } +} + +@MainActor +private final class FlowReviewMetricsPreferences: MetricsConfigurationStoring { + func load() -> [MetricsSelection] { [] } + func save(_: [MetricsSelection]) {} +} diff --git a/TFMTests/HostAccessUIReviewTests.swift b/TFMTests/HostAccessUIReviewTests.swift new file mode 100644 index 00000000..8ffa5ad0 --- /dev/null +++ b/TFMTests/HostAccessUIReviewTests.swift @@ -0,0 +1,91 @@ +import AppKit +import SwiftUI +import TermindApplication +import TermindDomain +import TermindInfrastructure +import TermindPresentation +import Testing + +@testable import Termind + +@Suite( + .enabled(if: ProcessInfo.processInfo.environment["TERMIND_HOST_ACCESS_UI_REVIEW"] == "1"), + .serialized) +@MainActor +struct HostAccessUIReviewTests { + @Test(.requiresVisibleWindow, arguments: ["en", "zh-Hans"]) + func renderHostAndMetricsLocks(language: String) async throws { + NSApp.unhideWithoutActivation() + let hosts = ["Production", "Staging", "Database", "Backup"].enumerated().map { index, name in + TermindDomain.Host( + name: name, host: "\(name.lowercased()).example.invalid", + createdAt: Date(timeIntervalSince1970: Double(index))) + } + let library = HostLibraryModel(hosts: hosts) + let model = ServerMetricsModel( + hosts: library, preferences: ReviewMetricsPreferences(), + makeSampler: { preconditionFailure("UI review must not start a remote operation") }) + model.suspend() + let app = AppModel(hosts: library, proAccess: .testing(status: .free), serverMetrics: model) + defer { app.shutdownForTermination() } + let navigation = WindowModel() + let server = ServerMetric(selection: MetricsSelection(id: hosts[3].id), name: hosts[3].name) + server.point = MetricsPoint(date: .now, cpu: 64, memory: 48) + server.hostAccessError = .lockedHost(hosts[3].id) + #expect(library.isLocked(hosts[3])) + #expect(!library.isLocked(hosts[0])) + + let content = VStack(alignment: .leading, spacing: 24) { + Text(AppStrings.Vault.hosts).font(.title2.bold()) + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) { + ForEach(hosts) { host in + HostCard( + host: host, isSelected: false, select: {}, extendSelection: {}, + actionHosts: [host], requestDelete: {}) + } + } + Text(AppStrings.Navigation.metrics).font(.title2.bold()) + ServerMetricsCard( + server: server, model: model, + inspect: {}, upgrade: { app.proAccess.presentPaywall() }, openTerminal: {} + ) + .frame(width: 364) + Spacer(minLength: 0) + } + .padding(28) + .frame(width: 800, height: 490) + .background(TermindColor.windowSurface) + .environment(app) + .environment(navigation) + .environment(\.locale, Locale(identifier: language)) + .environment(\.colorScheme, .light) + let controller = NSHostingController(rootView: content) + controller.sizingOptions = [] + let window = NSWindow( + contentRect: CGRect(x: 0, y: 0, width: 800, height: 490), + styleMask: [.titled, .closable], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.title = "Termind Host Access Review - \(language)" + window.appearance = NSAppearance(named: .aqua) + window.contentViewController = controller + window.setContentSize(NSSize(width: 800, height: 490)) + window.center() + window.orderFrontRegardless() + defer { + window.orderOut(nil) + window.contentViewController = nil + window.close() + } + controller.view.layoutSubtreeIfNeeded() + try await Task.sleep(for: .milliseconds(500)) + print("TERMIND_HOST_ACCESS_REVIEW|\(window.windowNumber)|\(language)") + fflush(nil) + try await Task.sleep(for: .seconds(4)) + } +} + +@MainActor +private final class ReviewMetricsPreferences: MetricsConfigurationStoring { + func load() -> [MetricsSelection] { [] } + func save(_: [MetricsSelection]) {} +} diff --git a/TFMTests/MetricsLimitTests.swift b/TFMTests/MetricsLimitTests.swift index ec7ec0a7..7844442f 100644 --- a/TFMTests/MetricsLimitTests.swift +++ b/TFMTests/MetricsLimitTests.swift @@ -2,127 +2,105 @@ import Foundation import TermindApplication import TermindDomain import TermindInfrastructure -import TermindPresentation import TermindRemoteInfrastructure import Testing import struct TermindDomain.Host @testable import Termind +@testable import TermindPresentation @Suite @MainActor struct MetricsLimitTests { - @Test - func freeLimitCountsPausedCardsAndRejectsTheFourthBeforeSaving() { - let hosts = fixtures(4) - let preferences = LimitMetricsPreferences() - var denied = 0 - let model = model(hosts, preferences: preferences, denied: { denied += 1 }) - defer { model.shutdown() } - #expect(model.add(Array(hosts.prefix(3)))) - model.setEnabled(false, for: model.servers[0]) - let saved = preferences.values - #expect(model.remainingCardSlots == 0) - #expect(!model.add(hosts[3])) - #expect(model.servers.count == 3) - #expect(preferences.values == saved) - #expect(denied == 1) - model.remove(hosts[1].id) - #expect(model.remainingCardSlots == 1) - #expect(model.add(hosts[3])) - #expect(model.servers.count == 3) + @Test func failedAccessUsesErrorIndicatorRatherThanAnIndefiniteSpinner() { + let server = ServerMetric(selection: MetricsSelection(id: UUID()), name: "Host") + server.hostAccessError = .entitlementVerificationFailed + #expect(MetricsInstrumentBody(server: server, sleeping: false).connectionIndicator == .attention) + server.hostAccessError = .verifyingEntitlement + server.isConnecting = true + #expect(MetricsInstrumentBody(server: server, sleeping: false).connectionIndicator == .progress) } - @Test - func batchAdmissionIsAtomicAndDuplicateHostsDoNotConsumeSlots() { - let hosts = fixtures(5) - let preferences = LimitMetricsPreferences() - let model = model(hosts, preferences: preferences) + @Test func lockedCardActivationOpensPaywallInsteadOfDetails() { + let free = ProAccessController.testing(status: .free) + let host = fixtures(1)[0] + let model = model([host]) defer { model.shutdown() } - #expect(model.add(hosts[0])) - #expect(!model.add(Array(hosts[1...3]))) - #expect(model.servers.map(\.id) == [hosts[0].id]) - #expect(model.add([hosts[0], hosts[1], hosts[1], hosts[2]])) - #expect(model.servers.count == 3) - #expect(model.add([hosts[0], hosts[0]])) - #expect(!model.add(hosts[4])) - #expect(preferences.values.count == 3) + let server = ServerMetric(selection: MetricsSelection(id: host.id), name: host.name) + server.point = MetricsPoint(date: .now, cpu: 50) + server.hostAccessError = .lockedHost(host.id) + var inspected = false + let card = ServerMetricsCard( + server: server, model: model, inspect: { inspected = true }, upgrade: { free.presentPaywall() }) + card.activate() + #expect(free.isPaywallPresented) + #expect(!inspected) + #expect(server.point != nil) + free.isPaywallPresented = false + server.hostAccessError = .verifyingEntitlement + card.activate() + #expect(!free.isPaywallPresented) + #expect(inspected) } - @Test - func proAccessCanChangeWithoutRebuildingTheModel() { - let hosts = fixtures(6) - var unlimited = false - let model = model(hosts, limit: { unlimited ? nil : 3 }) - defer { model.shutdown() } - #expect(model.add(Array(hosts.prefix(3)))) - #expect(!model.add(hosts[3])) - unlimited = true - #expect(model.remainingCardSlots == nil) - #expect(model.add(Array(hosts.suffix(3)))) - #expect(model.servers.count == 6) - unlimited = false - #expect(model.remainingCardSlots == 0) - #expect(model.servers.count == 6) - #expect(!model.requestAddition()) - } - - @Test - func restoredConfigurationIsNotDeletedButCountsAgainstNewAdditions() { - let hosts = fixtures(5) - let preferences = LimitMetricsPreferences() - preferences.values = hosts.prefix(4).map { MetricsSelection(id: $0.id, enabled: false) } - let saved = preferences.values - let model = model(hosts, preferences: preferences) - defer { model.shutdown() } - model.start() - #expect(model.servers.count == 4) - #expect(!model.add(hosts[4])) - #expect(preferences.values == saved) - } - - @Test - func fullMetricsToolbarStillOpensTheSelectionChooser() { - let hosts = fixtures(4) - var denied = false - let model = model(hosts, denied: { denied = true }) - defer { model.shutdown() } - model.add(Array(hosts.prefix(3))) + @Test func lockedHostAndJumpDoNotCreateNewWorkspacesOrHistory() { + var hosts = fixtures(4) + for index in hosts.indices { hosts[index].createdAt = Date(timeIntervalSince1970: Double(index)) } + hosts[0].jumpHostID = hosts[3].id + let pro = ProAccessController.testing(status: .free) + let app = AppModel(hosts: HostLibraryModel(hosts: hosts), proAccess: pro) + defer { app.shutdownForTermination() } let window = WindowModel() - window.metricsModel = model - window.selectedDestination = .metrics - window.creationActions.first?.perform() - #expect(!denied) - #expect(window.presentations.hostChooser == .metrics) + #expect(app.open(hosts[3], as: .sshSession, in: window) == nil) + #expect(app.open(hosts[0], as: .sftpChannel, in: window) == nil) + #expect(window.workspaces.isEmpty) + #expect(pro.isPaywallPresented) + app.open([hosts[0], hosts[1], hosts[3]], as: .sshSession, layout: .oneWorkspace, in: window) + #expect(window.workspaces.count == 1) + #expect(window.workspaces.first?.panes.count == 1) + } + + @Test func hostAccessVerificationFailureUsesConnectionErrorRatherThanWakeNotice() { + var hosts = fixtures(4) + for index in hosts.indices { hosts[index].createdAt = Date(timeIntervalSince1970: Double(index)) } + let app = AppModel( + hosts: HostLibraryModel(hosts: hosts), proAccess: .testing(status: .verificationFailed)) + defer { app.shutdownForTermination() } + let window = WindowModel() + #expect(app.open(hosts[3], as: .sshSession, in: window) == nil) + #expect(window.hostAccessError == String(localized: AppStrings.Pro.accessVerificationFailed)) + #expect(window.externalConnectionError == nil) + #expect(window.wakeNotice == nil) + #expect(!app.proAccess.isPaywallPresented) } @Test - func selectionReplacementPreservesSettingsAndAllowsSwapAtTheFreeLimit() { + func freeMetricsHasNoIndependentCardLimitAndDeduplicates() { + let hosts = fixtures(6) + let preferences = LimitMetricsPreferences() + let model = model(hosts, preferences: preferences) + defer { model.shutdown() } + #expect(model.add(Array(hosts.prefix(3)))) + model.setEnabled(false, for: model.servers[0]) + #expect(model.add(hosts + hosts)) + #expect(model.servers.count == 6) + #expect(preferences.values.count == 6) + #expect(!model.selections[0].enabled) + } + + @Test + func selectionReplacementRetainsSettingsWithoutACommercialLimit() { let hosts = fixtures(5) let preferences = LimitMetricsPreferences() let model = model(hosts, preferences: preferences) defer { model.shutdown() } #expect(model.add(Array(hosts.prefix(3)))) model.setEnabled(false, for: model.servers[0]) - model.selectDevices(model.servers[0], disks: ["disk0"], interfaces: ["eth0"]) let preserved = model.selections[0] - let editable = Set(hosts.map(\.id)) - - #expect( - model.replaceHosts( - selectedIDs: [hosts[0].id, hosts[2].id, hosts[3].id], editableHostIDs: editable)) - #expect(model.selections.map(\.id) == [hosts[0].id, hosts[2].id, hosts[3].id]) + #expect(model.replaceHosts(selectedIDs: hosts.map(\.id), editableHostIDs: Set(hosts.map(\.id)))) + #expect(model.selections.count == 5) #expect(model.selections[0] == preserved) - #expect(model.selections[2] == MetricsSelection(id: hosts[3].id)) - #expect(preferences.values == model.selections) - - let saved = model.selections - #expect( - !model.replaceHosts( - selectedIDs: hosts.prefix(4).map(\.id), editableHostIDs: editable)) - #expect(model.selections == saved) - #expect(preferences.values == saved) } @Test @@ -159,23 +137,13 @@ struct MetricsLimitTests { } @Test - func productionWiringUsesTheExistingProPaywallAndIncludesSubscriptionAccess() { + func freeMetricsDoesNotPresentPaywall() { let free = ProAccessController.testing(status: .free) - let app = AppModel(hosts: HostLibraryModel(), proAccess: free) + let hosts = fixtures(5) + let app = AppModel(hosts: HostLibraryModel(hosts: hosts), proAccess: free) defer { app.shutdownForTermination() } - #expect(app.serverMetrics.remainingCardSlots == 3) - #expect(!app.serverMetrics.requestAddition(count: 4)) - #expect(free.isPaywallPresented) - for status in [ - EntitlementStatus.subscription( - ProSubscriptionStatus( - phase: .introductoryTrial, expiresAt: .distantFuture, willAutoRenew: true, - ownership: .purchased)), - .purchased(.purchased), .purchased(.familyShared), - ] { - let access = ProAccessController.testing(status: status) - #expect(access.canUse(.unlimitedMetrics)) - } + #expect(app.serverMetrics.add(hosts)) + #expect(!free.isPaywallPresented) } private func fixtures(_ count: Int) -> [TermindDomain.Host] { @@ -183,13 +151,11 @@ struct MetricsLimitTests { } private func model( - _ hosts: [TermindDomain.Host], preferences: LimitMetricsPreferences = LimitMetricsPreferences(), - limit: @escaping () -> Int? = { 3 }, denied: @escaping () -> Void = {} + _ hosts: [TermindDomain.Host], preferences: LimitMetricsPreferences = LimitMetricsPreferences() ) -> ServerMetricsModel { ServerMetricsModel( hosts: HostLibraryModel(hosts: hosts), preferences: preferences, - makeSampler: { SSHMetricsSampler(connection: RemoteConnectionCoordinator().makeConnection()) }, - cardLimit: limit, limitReached: denied + makeSampler: { SSHMetricsSampler(connection: RemoteConnectionCoordinator().makeConnection()) } ) } } diff --git a/TFMTests/QuickConnectTests.swift b/TFMTests/QuickConnectTests.swift index 0728787a..dbdba087 100644 --- a/TFMTests/QuickConnectTests.swift +++ b/TFMTests/QuickConnectTests.swift @@ -192,7 +192,7 @@ struct QuickConnectRouteTests { let window = WindowModel() let app = AppModel(hosts: library) defer { app.shutdownForTermination() } - let workspace = app.openQuickConnect(target, in: window) + let workspace = try #require(app.openQuickConnect(target, in: window)) let session = try #require(workspace.focusedPane.sshSession) #expect(session.connectionRoute == target.route) #expect(session.connectionHost(session.hostID, using: library)?.port == 2222) diff --git a/TFMTests/SSHImportTests.swift b/TFMTests/SSHImportTests.swift index 11994de0..58ef6d59 100644 --- a/TFMTests/SSHImportTests.swift +++ b/TFMTests/SSHImportTests.swift @@ -27,6 +27,44 @@ struct SSHImportTests { return "|1|\(salt.base64EncodedString())|\(Data(digest).base64EncodedString())" } + @Test func overQuotaImportRetainsSelectionAndWritesNothingUntilEntitlementResolves() async throws { + let url = try root() + defer { try? FileManager.default.removeItem(at: url) } + let material = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: nil) + try material.privateKey.write(to: url.appendingPathComponent("id_ed25519"), atomically: true, encoding: .utf8) + try """ + Host prod stage + HostName %h.example + User deploy + IdentityFile ~/.ssh/id_ed25519 + """.write(to: url.appendingPathComponent("config"), atomically: true, encoding: .utf8) + try "prod.example \(material.publicKey)".write( + to: url.appendingPathComponent("known_hosts"), atomically: true, encoding: .utf8) + let hosts = (0..<3).map { Host(name: "Existing \($0)", host: "existing\($0).invalid") } + let library = HostLibraryModel(hosts: hosts) + library.hostAccess.entitlement = .loading + var upgrades = 0 + library.requestHostUpgrade = { upgrades += 1 } + library.resolveInitialHostEntitlement = { library.hostAccess.entitlement = .free } + defer { library.resolveInitialHostEntitlement = nil } + let model = SSHImportModel(library: library, reader: SSHImportReader()) + await model.scan(url, knownHostsOnly: false) + let selected = model.selectedItems().map(\.id) + #expect(model.canImport) + await model.commit() + #expect(!model.completed) + #expect(model.error == nil) + #expect(upgrades == 1) + #expect(model.selectedItems().map(\.id) == selected) + #expect(library.hosts == hosts) + #expect(library.identities.isEmpty && library.keys.isEmpty && library.knownHosts.isEmpty) + library.hostAccess.entitlement = .purchased(.purchased) + await model.commit() + #expect(model.completed) + #expect(library.hosts.count == 5) + #expect(library.keys.count == 1 && library.knownHosts.count == 1) + } + @Test func appDefaultsNewCredentialsAndSSHImportToThisMacWhenICloudSyncIsOff() throws { let domain = "SSHImportDefaults.\(UUID())" diff --git a/TFMTests/WakeOnLANAccessTests.swift b/TFMTests/WakeOnLANAccessTests.swift index 420a8ab5..172c1f60 100644 --- a/TFMTests/WakeOnLANAccessTests.swift +++ b/TFMTests/WakeOnLANAccessTests.swift @@ -12,6 +12,22 @@ import struct TermindDomain.Host @Suite @MainActor struct WakeOnLANAccessTests { + @Test(arguments: ["", "02:11:22:33:44:55"]) + func lockedManualWakePresentsPaywallWithoutConfigurationOrPacket(macAddress: String) { + var hosts = (0..<4).map { + Host(name: "Host \($0)", host: "host.invalid", createdAt: Date(timeIntervalSince1970: Double($0))) + } + hosts[3].wakeOnLAN.macAddress = macAddress + let pro = ProAccessController.testing(status: .free) + let app = AppModel(hosts: HostLibraryModel(hosts: hosts), proAccess: pro) + defer { app.shutdownForTermination() } + app.hostLibrary.wakeOnLAN = WakeOnLANController(sender: UnexpectedWakeSender()) + var configured = false + #expect(app.requestWake(hosts[3]) { configured = true } == nil) + #expect(pro.isPaywallPresented) + #expect(!configured) + } + @Test(arguments: [LocalDiscoveryProtocol.ssh, .telnet]) func discoveryUsesTheSelectedProtocolWithoutRequestingPro(_ connectionProtocol: LocalDiscoveryProtocol) { let pro = ProAccessController.testing(status: .free) @@ -23,13 +39,13 @@ struct WakeOnLANAccessTests { #expect(!pro.isPaywallPresented) } - @Test func manualWakeRequestsProBeforeOpeningConfigurationAndDoesNotReplay() { + @Test func freeManualWakeOpensConfigurationWithoutPaywall() { 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) - #expect(pro.isPaywallPresented) - #expect(!configured) + #expect(!pro.isPaywallPresented) + #expect(configured) } @Test func proWakeWithoutMACOpensConfigurationRatherThanSending() { @@ -40,13 +56,13 @@ struct WakeOnLANAccessTests { #expect(!app.proAccess.isPaywallPresented) } - @Test func autoPaywallOnlyAppearsWhenEnablingAndNeverErasesSavedPreferences() { + @Test func autoWakeIsFreeAndPreservesPreferences() { let pro = ProAccessController.testing(status: .free) var preferences = WakeOnLANPreferences(macAddress: "02:11:22:33:44:55", automaticallyWake: true) let binding = Binding(get: { preferences }, set: { preferences = $0 }) var invalid = false let access = WakeOnLANSettingsAccess( - requestAccess: { pro.require(.autoWakeOnLAN) }, invalidAddress: { invalid = true }) + invalidAddress: { invalid = true }) let automatic = access.automatic(binding) #expect(automatic.wrappedValue) #expect(preferences.automaticallyWake) @@ -55,8 +71,8 @@ struct WakeOnLANAccessTests { #expect(!pro.isPaywallPresented) #expect(!preferences.automaticallyWake) automatic.wrappedValue = true - #expect(pro.isPaywallPresented) - #expect(!preferences.automaticallyWake) + #expect(!pro.isPaywallPresented) + #expect(preferences.automaticallyWake) #expect(preferences.macAddress == "02:11:22:33:44:55") #expect(!invalid) } @@ -64,9 +80,13 @@ struct WakeOnLANAccessTests { @Test func enablingAutoRequiresValidMACWithoutChangingTheDraft() { var preferences = WakeOnLANPreferences() var invalid = false - let access = WakeOnLANSettingsAccess(requestAccess: { true }, invalidAddress: { invalid = true }) + let access = WakeOnLANSettingsAccess(invalidAddress: { invalid = true }) access.automatic(Binding(get: { preferences }, set: { preferences = $0 })).wrappedValue = true #expect(invalid) #expect(!preferences.automaticallyWake) } } + +private nonisolated struct UnexpectedWakeSender: WakeOnLANSending { + func send(to address: MACAddress) throws { Issue.record("A locked host must not send a Wake packet") } +} diff --git a/TFMTests/WindowStateTests.swift b/TFMTests/WindowStateTests.swift index 15fede07..d1608f2e 100644 --- a/TFMTests/WindowStateTests.swift +++ b/TFMTests/WindowStateTests.swift @@ -318,13 +318,13 @@ struct WindowStateTests { } @Test - func openingRecentHostKeepsTheGlobalHistoryMode() { + func openingRecentHostKeepsTheGlobalHistoryMode() throws { let host = Host(name: "Recent", host: "recent.example") let app = AppModel(hosts: HostLibraryModel(hosts: [host])) let window = WindowModel() window.sidebarMode = .history - let workspace = app.open(host, as: .sshSession, in: window) + let workspace = try #require(app.open(host, as: .sshSession, in: window)) #expect(window.selectedDestination == .workspace(workspace.id)) #expect(window.sidebarMode == .history) @@ -1251,15 +1251,15 @@ struct WindowStateTests { } @Test - func appRoutesHostsToSSHAndSFTPChannelsForWorkspaces() { + func appRoutesHostsToSSHAndSFTPChannelsForWorkspaces() throws { let first = Host(name: "First", host: "first.example") let second = Host(name: "Second", host: "second.example") let store = HostLibraryModel(hosts: [first, second]) let app = AppModel(hosts: store) let window = WindowModel() - let sshTab = app.open(first, as: .sshSession, in: window) - let sftpTab = app.open(second, as: .sftpChannel, in: window) + let sshTab = try #require(app.open(first, as: .sshSession, in: window)) + let sftpTab = try #require(app.open(second, as: .sftpChannel, in: window)) #expect(sshTab.focusedPane.kind == .sshSession) #expect(sftpTab.focusedPane.kind == .sftpChannel) diff --git a/com.akinokaede.termind b/com.akinokaede.termind index d9d3ed3e..a3b803e8 160000 --- a/com.akinokaede.termind +++ b/com.akinokaede.termind @@ -1 +1 @@ -Subproject commit d9d3ed3eb2f9e5f604307c9775e994d37aae711f +Subproject commit a3b803e88e548f1ab84e635e9539979d0685e577