From d2cce7d2ce6dab461e6cfe04a50991d2882df08b Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sat, 17 Oct 2026 06:13:38 +0800 Subject: [PATCH] feat: make core features free and add host-based Pro limits Make AI, MCP, Metrics, and Wake-on-LAN free while retaining iCloud sync as a Pro feature. Limit free accounts to three saved hosts and preserve excess hosts with remote-operation locks. Gate host creation, import, and runtime access; preserve stable host admission across persistence and sync. Add upgrade states on macOS and iOS, open the paywall from locked Wake actions, and use wrench icons for MCP. Include regression coverage, opt-in UI review fixtures, localized copy, and the updated pricing website revision. --- FrameworkTests/HostAccessTests.swift | 262 ++++++++++++++++ .../HostOperationAdmissionTests.swift | 78 +++++ FrameworkTests/LocalNetworkActionTests.swift | 72 ++--- FrameworkTests/LocalNetworkTests.swift | 22 +- FrameworkTests/MetricsHostAccessTests.swift | 69 +++++ FrameworkTests/RevenueCatCommerceTests.swift | 6 +- Frameworks/Resources/Localizable.xcstrings | 123 ++++++++ .../Connections/HostOperationAccess.swift | 28 ++ .../Connections/RemoteSessionHostStore.swift | 28 ++ .../Connections/ResumableRouteRequest.swift | 10 +- .../SSHAdditionalConnectionSnapshot.swift | 2 + .../SSHConnectionPreparation.swift | 4 + .../Connections/WakeOnLANController.swift | 7 +- .../HostCustomIdentityDraft.swift | 1 + .../HostLibraryModel+LocalNetwork.swift | 6 +- .../FeatureModels/HostLibraryModel.swift | 92 +++++- .../FeatureModels/SSHImportModel.swift | 4 + .../Metrics/MetricsSnapshot.swift | 2 +- .../Metrics/ServerMetricsModel.swift | 82 +++-- .../SSHConnectionTypes.swift | 2 + .../TelnetConnectionTypes.swift | 1 + .../Commerce/ProAccessTypes.swift | 9 +- Frameworks/TermindDomain/Hosts/Host.swift | 7 +- .../Hosts/HostAccessPolicy.swift | 59 ++++ .../Vault/SyncRecordFormats.swift | 2 +- .../Commerce/ProAccessController.swift | 2 +- .../TermindPersistence/HostPersistence.swift | 13 +- .../SwiftDataVaultJournal.swift | 14 +- .../SwiftDataVaultStore.swift | 20 +- .../TermindPersistence.swift | 2 +- .../TermindSchemaV58HostModels.swift | 152 ++++++++++ .../VaultSyncPersistence.swift | 9 + .../TermindPersistence/VaultWireV8.swift | 54 ++++ .../HostAccessError+Presentation.swift | 21 ++ .../HostWakeOnLANSection.swift | 6 +- .../MetricsHostLockNotice.swift | 20 ++ .../MetricsInstrumentBody.swift | 24 +- .../TermindPresentation/Strings/Pro.swift | 6 +- .../WakeOnLANSettingsAccess.swift | 5 +- .../Agent/AgentConnections.swift | 18 +- .../Agent/AgentHostServices.swift | 4 +- .../SSH/AccessControlledSFTPChannel.swift | 163 ++++++++++ .../SSH/MoshTerminalEngine.swift | 3 + .../SSH/PortForwardingController.swift | 1 + .../SSH/ResumableTerminalClient.swift | 1 + .../SSH/SSHConnectionWorker.swift | 7 +- .../SSH/SSHForwardingConnection.swift | 18 ++ .../SSH/SSHForwardingConnectionPool.swift | 7 + .../SSH/SSHMultiplexedSFTPChannelOwner.swift | 10 +- .../SSH/SSHPTYChannel.swift | 6 +- .../SSH/TerminalTransportRoute.swift | 1 + .../Telnet/TelnetConnection.swift | 1 + TFI/App/MobileAppModel+LocalNetwork.swift | 3 +- TFI/App/MobileAppModel.swift | 11 +- TFI/App/MobileWorkspaceSessionModel.swift | 23 ++ .../Metrics/MobileMetricsModel.swift | 19 +- TFI/Features/AI/MobileAssistantComposer.swift | 2 +- .../HostEditor/MobileHostEditor.swift | 15 +- TFI/Features/Metrics/MobileMetricsView.swift | 35 ++- .../Settings/MobileAISettingsView.swift | 4 +- TFI/Features/Settings/MobileMCPSettings.swift | 54 +--- TFI/Features/Vault/MobileVaultView.swift | 6 +- TFI/Features/Workspaces/MobileWorkspace.swift | 15 +- TFI/UI/Components/MobileEditorSheet.swift | 3 +- TFITests/MobileAISettingsRenderingTests.swift | 11 +- TFITests/MobileAppTests.swift | 12 +- .../MobileCardPageLayoutReviewTests.swift | 2 +- TFITests/MobileHostAccessUIReviewTests.swift | 276 +++++++++++++++++ TFITests/MobileHostWakeUIReviewTests.swift | 98 ++++++ TFITests/MobileMetricsHostAccessTests.swift | 51 ++++ TFITests/MobileMetricsJourneyTests.swift | 2 +- TFITests/MobileMultiHostPickerTests.swift | 16 +- TFITests/MobileWakeAccessTests.swift | 39 +++ .../MobileLocalDiscoveryRenderingTests.swift | 2 +- TFM/App/AppModel+LocalNetwork.swift | 3 +- TFM/App/AppModel.swift | 120 +++++++- TFM/App/MCPServerController.swift | 30 +- TFM/App/RootView.swift | 10 + TFM/App/TermindApp.swift | 8 +- .../AI/ConversationTranscriptView.swift | 2 +- TFM/Features/AI/ConversationViews.swift | 2 +- .../HostChooser/HostChooserView.swift | 7 - TFM/Features/HostEditor/HostEditorView.swift | 35 ++- TFM/Features/Hosts/HostActions.swift | 4 +- TFM/Features/Hosts/HostsPageView.swift | 13 +- .../Metrics/MetricsManagedCardPage.swift | 1 + TFM/Features/Metrics/MetricsView.swift | 43 ++- TFM/Features/SFTP/SFTPChannelModel.swift | 7 +- .../Settings/MCPServerEditorSheet.swift | 2 +- TFM/Features/Settings/MCPServerSettings.swift | 6 +- TFM/Features/Settings/MCPSettings.swift | 14 +- TFM/Features/Settings/SettingsPageView.swift | 12 +- TFM/Features/Terminal/SSHSessionModel.swift | 11 +- .../Terminal/TelnetSessionModel.swift | 1 + TFM/Features/Workspaces/WindowModel.swift | 1 + .../Workspaces/WorkspaceContentView.swift | 13 +- TFMTests/AppLanguageTests.swift | 25 ++ TFMTests/ConnectionHistoryTests.swift | 2 +- TFMTests/HostAccessFlowUIReviewTests.swift | 282 ++++++++++++++++++ TFMTests/HostAccessUIReviewTests.swift | 91 ++++++ TFMTests/MetricsLimitTests.swift | 192 +++++------- TFMTests/QuickConnectTests.swift | 2 +- TFMTests/SSHImportTests.swift | 38 +++ TFMTests/WakeOnLANAccessTests.swift | 36 ++- TFMTests/WindowStateTests.swift | 10 +- com.akinokaede.termind | 2 +- 106 files changed, 2827 insertions(+), 463 deletions(-) create mode 100644 FrameworkTests/HostAccessTests.swift create mode 100644 FrameworkTests/HostOperationAdmissionTests.swift create mode 100644 FrameworkTests/MetricsHostAccessTests.swift create mode 100644 Frameworks/TermindApplication/Connections/HostOperationAccess.swift create mode 100644 Frameworks/TermindDomain/Hosts/HostAccessPolicy.swift create mode 100644 Frameworks/TermindPersistence/TermindSchemaV58HostModels.swift create mode 100644 Frameworks/TermindPersistence/VaultWireV8.swift create mode 100644 Frameworks/TermindPresentation/HostAccessError+Presentation.swift create mode 100644 Frameworks/TermindPresentation/MetricsHostLockNotice.swift create mode 100644 Frameworks/TermindRemoteInfrastructure/SSH/AccessControlledSFTPChannel.swift create mode 100644 TFITests/MobileHostAccessUIReviewTests.swift create mode 100644 TFITests/MobileHostWakeUIReviewTests.swift create mode 100644 TFITests/MobileMetricsHostAccessTests.swift create mode 100644 TFITests/MobileWakeAccessTests.swift create mode 100644 TFMTests/HostAccessFlowUIReviewTests.swift create mode 100644 TFMTests/HostAccessUIReviewTests.swift 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