diff --git a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift index 748b2d89..ac9bd46c 100644 --- a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift @@ -28,6 +28,8 @@ public final class HostLibraryModel { /// references are removed and the controller can stop listeners cleanly. public var willDeleteHost: ((Host.ID, [PortForwardingRule.ID]) -> Void)? public var willDeletePortForwardings: (([PortForwardingRule.ID]) -> Void)? + /// Restarts locally enabled rules after their saved configuration has been replaced or rolled back. + public var didReplacePortForwardings: (([PortForwardingRule.ID]) -> Void)? public var didPersistCore: (() -> Void)? { get { vault.didPersist } set { vault.didPersist = newValue } @@ -671,7 +673,11 @@ public final class HostLibraryModel { if let existingHost, existingHost.sshConnectionFields != host.sshConnectionFields { rulesToStop.formUnion(existingRules.map(\.id)) } - if !rulesToStop.isEmpty { willDeletePortForwardings?(Array(rulesToStop)) } + let affectedRuleIDs = Array(rulesToStop) + if !affectedRuleIDs.isEmpty { willDeletePortForwardings?(affectedRuleIDs) } + defer { + if !affectedRuleIDs.isEmpty { didReplacePortForwardings?(affectedRuleIDs) } + } if let index = hosts.firstIndex(where: { $0.id == host.id }) { var updated = host diff --git a/Frameworks/TermindApplication/FeatureModels/PortForwardingEditorPolicy.swift b/Frameworks/TermindApplication/FeatureModels/PortForwardingEditorPolicy.swift new file mode 100644 index 00000000..09fecdef --- /dev/null +++ b/Frameworks/TermindApplication/FeatureModels/PortForwardingEditorPolicy.swift @@ -0,0 +1,48 @@ +import Foundation +import TermindDomain + +import struct TermindDomain.Host + +/// Shared edit and start policy for the platform-specific forwarding editors. +public nonisolated struct PortForwardingEditorPolicy { + public let hasSavedConfiguration: Bool + public let isEnabled: Bool + public let isRuntimeActive: Bool + + public var canStart: Bool { hasSavedConfiguration && !isEnabled && !isRuntimeActive } + public var canToggle: Bool { isEnabled || isRuntimeActive || canStart } + public var canEdit: Bool { !isEnabled && !isRuntimeActive } + + public init( + rule: PortForwardingRule?, host: Host, + savedRule: PortForwardingRule?, savedHost: Host?, + isEnabled: Bool, isRuntimeActive: Bool + ) { + hasSavedConfiguration = + rule != nil && rule?.isValid == true && rule == savedRule && host == savedHost + self.isEnabled = isEnabled + self.isRuntimeActive = isRuntimeActive + } + + public static func normalized(_ rule: PortForwardingRule) -> PortForwardingRule? { + normalized( + rule, hostID: rule.hostID, bindPort: String(rule.bindPort), + targetHost: rule.targetHost ?? "", targetPort: rule.targetPort.map(String.init) ?? "") + } + + /// Uses the exact save candidate for validation, including hidden dynamic targets. + public static func normalized( + _ rule: PortForwardingRule, hostID: Host.ID, + bindPort: String, targetHost: String, targetPort: String + ) -> PortForwardingRule? { + guard let port = Int(bindPort.trimmingCharacters(in: .whitespacesAndNewlines)) else { return nil } + var value = rule + value.hostID = hostID + value.bindHost = value.bindHost.trimmingCharacters(in: .whitespacesAndNewlines) + value.bindPort = port + value.targetHost = rule.kind == .dynamic ? nil : targetHost.trimmingCharacters(in: .whitespacesAndNewlines) + value.targetPort = + rule.kind == .dynamic ? nil : Int(targetPort.trimmingCharacters(in: .whitespacesAndNewlines)) + return value.isValid ? value : nil + } +} diff --git a/Frameworks/TermindApplication/SettingsKey.swift b/Frameworks/TermindApplication/SettingsKey.swift index 5d3f1b51..244004d7 100644 --- a/Frameworks/TermindApplication/SettingsKey.swift +++ b/Frameworks/TermindApplication/SettingsKey.swift @@ -10,6 +10,8 @@ public nonisolated enum SettingsKey { public static let defaultWebSearchBuiltIn = true public static let defaultWebSearchEnabled = true public static let iCloudSyncEnabled = "icloud.sync.enabled" + /// Device-local forwarding intent. It is deliberately excluded from Settings and Vault sync. + public static let portForwardingEnabledRuleIDs = "portForwarding.enabledRuleIDs" public static let remoteWindowTitleInMenuBar = "terminal.remoteWindowTitleInMenuBar" public static let seededSkillGeneration = "assistant.skills.seededGeneration" public static let settingsSyncEnabled = "settings.sync.enabled" diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift b/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift index 403c1ae3..fb4ec4d4 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift @@ -44,50 +44,112 @@ public nonisolated enum PortForwardingRuntimeState: Hashable, Sendable { } } -/// Process-local intent and live resources. No state in this type is persisted, -/// which guarantees that launching Termind never silently exposes a saved port. +/// Keeps device-local enabled intent separate from process-local SSH resources. +/// Enabled rule IDs are not part of Vault or Settings sync. @Observable public final class PortForwardingController { private let hosts: HostLibraryModel + private let defaults: UserDefaults? + private let enabledDefaultsKey: String public private(set) var states: [PortForwardingRule.ID: PortForwardingRuntimeState] = [:] + private var enabled: Set private var desired: Set = [] private var sessions: [Host.ID: SSHForwardingSession] = [:] private var connectionTasks: [Host.ID: Task] = [:] private var reconnectTasks: [Host.ID: Task] = [:] private var reconnectAttempts: [Host.ID: Int] = [:] private var connectedAt: [Host.ID: Date] = [:] + private var ruleHostIDs: [PortForwardingRule.ID: Host.ID] + private var stoppingTasks: [PortForwardingRule.ID: Task] = [:] + private var stoppingGenerations: [PortForwardingRule.ID: UUID] = [:] - public init(hosts: HostLibraryModel) { self.hosts = hosts } + public init( + hosts: HostLibraryModel, defaults: UserDefaults? = nil, + enabledDefaultsKey: String = SettingsKey.portForwardingEnabledRuleIDs + ) { + self.hosts = hosts + self.defaults = defaults + self.enabledDefaultsKey = enabledDefaultsKey + enabled = Set( + defaults?.stringArray(forKey: enabledDefaultsKey)?.compactMap(UUID.init(uuidString:)) ?? []) + ruleHostIDs = Dictionary(uniqueKeysWithValues: hosts.portForwardings.map { ($0.id, $0.hostID) }) + } public func state(for id: PortForwardingRule.ID) -> PortForwardingRuntimeState { states[id] ?? .stopped } - public func isEnabled(_ id: PortForwardingRule.ID) -> Bool { desired.contains(id) } + public func isEnabled(_ id: PortForwardingRule.ID) -> Bool { enabled.contains(id) } - public func start(_ id: PortForwardingRule.ID) { - guard let rule = hosts.portForwarding(id), !desired.contains(id) else { return } - desired.insert(id) - states[id] = .preparing - Task { await start(rule) } - } - - public func start(_ ids: [PortForwardingRule.ID]) { ids.forEach(start) } - - public func stop(_ id: PortForwardingRule.ID) { - desired.remove(id) - states[id] = .stopped - guard let rule = hosts.portForwarding(id), let session = sessions[rule.hostID] else { return } - Task { - await session.stop(id) - await disconnectHostIfIdle(rule.hostID) + public func setEnabled(_ value: Bool, for id: PortForwardingRule.ID) { + if value { + guard hosts.portForwarding(id)?.isValid == true else { return } + enabled.insert(id) + persistEnabled() + activate(id) + } else { + enabled.remove(id) + persistEnabled() + suspend(id) } } - public func stop(_ ids: [PortForwardingRule.ID]) { ids.forEach(stop) } + public func setEnabled(_ value: Bool, for ids: [PortForwardingRule.ID]) { + for id in ids { setEnabled(value, for: id) } + } - public func stopAll() { - let all = Array(desired) + /// Restores all, or a changed subset, after launch, foregrounding, or a saved configuration replacement. + public func resumeEnabled(_ ids: [PortForwardingRule.ID]? = nil) { + var valid: Set = [] + for id in enabled { + guard let rule = hosts.portForwarding(id), rule.isValid, hosts.host(rule.hostID) != nil else { continue } + ruleHostIDs[rule.id] = rule.hostID + valid.insert(id) + } + if valid != enabled { + enabled = valid + persistEnabled() + } + let targets = ids.map { valid.intersection($0) } ?? valid + targets.forEach(activate) + } + + /// Stops live resources while preserving the device-local enabled intent. + public func suspend(_ id: PortForwardingRule.ID) { + let hostID = hosts.portForwarding(id)?.hostID ?? ruleHostIDs[id] + let session = hostID.flatMap { sessions[$0] } + desired.remove(id) + states[id] = .stopped + let previous = stoppingTasks[id] + let generation = UUID() + stoppingGenerations[id] = generation + let task = Task { + if let previous { await previous.value } + if let session { await session.stop(id) } + } + stoppingTasks[id] = task + Task { [weak self] in + await task.value + guard let self, self.stoppingGenerations[id] == generation else { return } + self.stoppingTasks[id] = nil + self.stoppingGenerations[id] = nil + if let hostID { await self.disconnectHostIfIdle(hostID) } + } + } + + public func suspend(_ ids: [PortForwardingRule.ID]) { ids.forEach(suspend) } + + public func remove(_ ids: [PortForwardingRule.ID]) { + for id in ids { + enabled.remove(id) + suspend(id) + ruleHostIDs[id] = nil + } + persistEnabled() + } + + public func suspendAll() { + let all = Array(enabled.union(desired)) desired.removeAll() for id in all { states[id] = .stopped } let active = sessions @@ -98,9 +160,31 @@ public final class PortForwardingController { reconnectTasks.removeAll() reconnectAttempts.removeAll() connectedAt.removeAll() + for task in stoppingTasks.values { task.cancel() } + stoppingTasks.removeAll() + stoppingGenerations.removeAll() Task { for session in active.values { await session.disconnect() } } } + private func activate(_ id: PortForwardingRule.ID) { + guard enabled.contains(id), !desired.contains(id) else { return } + if let rule = hosts.portForwarding(id) { ruleHostIDs[id] = rule.hostID } + desired.insert(id) + states[id] = .preparing + let stopping = stoppingTasks[id] + Task { [weak self] in + if let stopping { await stopping.value } + guard let self, self.desired.contains(id), self.enabled.contains(id), + let rule = self.hosts.portForwarding(id) + else { return } + await self.start(rule) + } + } + + private func persistEnabled() { + defaults?.set(enabled.map(\.uuidString).sorted(), forKey: enabledDefaultsKey) + } + private func start(_ rule: PortForwardingRule) async { do { guard rule.isValid else { @@ -140,8 +224,12 @@ public final class PortForwardingController { } states[rule.id] = .running(activeConnections: 0) } catch { - if desired.contains(rule.id), - let sshError = error as? TermindSSHError, + guard desired.contains(rule.id) else { + states[rule.id] = .stopped + await disconnectHostIfIdle(rule.hostID) + return + } + if let sshError = error as? TermindSSHError, sshError.isRetryableForwardingFailure { if let session = sessions.removeValue(forKey: rule.hostID) { diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index adf832d4..ae0aadc4 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -150,7 +150,10 @@ infrastructure, persistence, or presentation responsibility. suppressing its window, because a quiet login launch must not present an empty foreground window. Repository data is persistent truth. Runtime connection state is kept in a -connection registry and is never persisted onto domain records. +connection registry and is never persisted onto domain records. Port forwarding's +Enabled choice is a device-local `UserDefaults` intent rather than runtime state: +it is not included in Vault/Settings sync, while macOS restores it at launch and +iOS restores it whenever the app becomes active after background suspension. SSH command history is created only after an authenticated shell-integration start event. A matching completion event enriches that row with exit status and @@ -685,6 +688,8 @@ Mobile host deletion records the existing deletion saga and starts the shared ou runner. Restored pending deletions are hidden before workspace restoration. The runner's start/stop generation prevents a cancelled background loop from clearing a replacement foreground loop. New port forwarding rules never start merely because they were saved. +Rules explicitly marked Enabled keep that device-local intent through background expiration +and resume when the app next becomes active. Mobile Snippets retain the shared Vault records, nested packages, ordering and target host references. A card opens a read-only detail; execution is always an explicit action. @@ -708,8 +713,9 @@ commands retain unknown completion. App-wide history is bounded to 1,000 rows pe Mobile sync runs while a scene is active and Pro access permits it. Backgrounding pauses both engines, retaining upload journals; foregrounding fetches again. Entitlement changes reconcile the same gate. Remote host deletion closes its workspaces, cancels transfers and -forwardings, and clears local command history. Modified connections are disconnected rather -than automatically replayed. Mobile AI/Terminal edits preserve all other Settings record kinds, +forwardings, and clears local command history. Modified workspaces are disconnected rather +than automatically replayed; locally Enabled forwarding rules restart with the accepted configuration. +Mobile AI/Terminal edits preserve all other Settings record kinds, including Skills and MCP configurations. Skills use the shared Application library and the synchronized persistence adapter on both platforms. Active chat requests retain the provider/model/credential captured at start. Assistant now registers the same tools as the desktop independent Assistant through the shared registry. @@ -717,7 +723,7 @@ Mobile navigation and Metrics reuse (2026-09-11): - Transfers belongs to Workspaces. Host cards connect to SSH on a single activation and expose SFTP/edit/delete through a visible More menu and a long-press context menu. SSH folder import is desktop-only; mobile retains private-key and certificate file import. - Port forwarding rules belong to the host draft and commit with it. Only unchanged, saved rules can start; the - mobile host editor exposes runtime start/stop per rule and has no bulk controls. + mobile host editor exposes a persistent, device-local Enabled switch per rule and has no bulk controls. - Keychain lists Keys before Identities. Identity notes remain preserved data without an editor field. Generation defaults match macOS: iCloud, Ed25519, ECDSA P-256, RSA 3072, AES-256 and 100 rounds; encrypted generation requires a matching confirmation. The shared bounded/scoped key-file reader accepts explicit private-key and certificate files. - ServerMetricsModel, ServerMetric, MetricsClock and ConnectionJourney live in Application. Their samplers/discovery functions remain injected. Mobile delegates its collector to this shared model, including authentication, temporary trust, process/filesystem sampling and detail observer lifetimes. - Metrics instruments/system panels, Journey route/checkpoint animations, and SSH decision forms live in Presentation. AppKit's keyboard monitor stays in TFM and is injected through ConnectionFormKeyboardAdapting; Presentation has no native UI imports. diff --git a/TFI/App/MobileAppDependencies.swift b/TFI/App/MobileAppDependencies.swift index d034c3e0..01d3f478 100644 --- a/TFI/App/MobileAppDependencies.swift +++ b/TFI/App/MobileAppDependencies.swift @@ -6,6 +6,7 @@ struct MobileAppDependencies { var hosts: (any HostRepository)? var inMemory: Bool var defaults: UserDefaults? + var forwardingDefaults: UserDefaults? var background: any MobileBackgroundTaskManaging var workspaceConnections: MobileWorkspaceConnectionServices @@ -14,6 +15,7 @@ struct MobileAppDependencies { hosts: nil, inMemory: false, defaults: .standard, + forwardingDefaults: .standard, background: MobileBackgroundController(), workspaceConnections: .live ) @@ -22,6 +24,7 @@ struct MobileAppDependencies { static func testing( hosts: (any HostRepository)? = nil, defaults: UserDefaults? = nil, + forwardingDefaults: UserDefaults? = nil, background: any MobileBackgroundTaskManaging = MobileBackgroundController(), workspaceConnections: MobileWorkspaceConnectionServices = .live ) -> MobileAppDependencies { @@ -29,6 +32,7 @@ struct MobileAppDependencies { hosts: hosts, inMemory: true, defaults: defaults, + forwardingDefaults: forwardingDefaults, background: background, workspaceConnections: workspaceConnections ) diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index db6b0969..c68f4972 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -70,13 +70,15 @@ final class MobileAppModel { convenience init( hosts: (any HostRepository)? = nil, inMemory: Bool = false, defaults: UserDefaults? = .standard, background: any MobileBackgroundTaskManaging = MobileBackgroundController(), - workspaceConnectionServices: MobileWorkspaceConnectionServices = .live + workspaceConnectionServices: MobileWorkspaceConnectionServices = .live, + forwardingDefaults: UserDefaults? = nil ) throws { try self.init( dependencies: MobileAppDependencies( hosts: hosts, inMemory: inMemory, defaults: defaults, + forwardingDefaults: forwardingDefaults ?? (inMemory || hosts != nil ? nil : defaults), background: background, workspaceConnections: workspaceConnectionServices )) @@ -144,9 +146,10 @@ final class MobileAppModel { snippets = SnippetLibraryModel(library: library) knownHosts = KnownHostsLibraryModel(library: library) forwardingLibrary = ForwardingLibraryModel(library: library) - forwardings = PortForwardingController(hosts: library) + forwardings = PortForwardingController(hosts: library, defaults: dependencies.forwardingDefaults) transfers = SFTPTransferCoordinator(store: library, clientFactory: { SFTPClient() }) - library.willDeletePortForwardings = { [weak forwardings] ids in forwardings?.stop(ids) } + library.willDeletePortForwardings = { [weak forwardings] ids in forwardings?.suspend(ids) } + library.didReplacePortForwardings = { [weak forwardings] ids in forwardings?.resumeEnabled(ids) } let visibleHostIDs = Set(try self.hosts.snapshot().value.hosts.map(\.id)) for host in library.hosts where !visibleHostIDs.contains(host.id) { library.projectHostDeletion(host.id) } outbox.register( @@ -163,7 +166,7 @@ final class MobileAppModel { }) outbox.register( RuntimeHostDeletionHandler(handlerID: HostDeletionOutbox.stopForwardings) { [weak forwardings] payload in - forwardings?.stop(payload.forwardingRuleIDs) + forwardings?.remove(payload.forwardingRuleIDs) }) outbox.register( RepositoryHostDeletionHandler(handlerID: HostDeletionOutbox.clearReferences, repository: self.hosts)) @@ -259,7 +262,7 @@ final class MobileAppModel { do { for workspace in workspaces.filter({ $0.descriptor.hostID == id }) { close(workspace.id) } transfers.cancelAll(for: id) - forwardings.stop(library.portForwardings(for: id).map(\.id)) + forwardings.remove(library.portForwardings(for: id).map(\.id)) metrics.remove(id) try hosts.requestHostDeletion(id) library.projectHostDeletion(id) @@ -289,7 +292,7 @@ final class MobileAppModel { background.begin { [weak self] in guard let self else { return } for workspace in workspaces { workspace.suspendForBackgroundExpiration() } - forwardings.stopAll() + forwardings.suspendAll() transfers.cancelAll() assistant.expireBackground() } @@ -297,6 +300,7 @@ final class MobileAppModel { } else if phase == .active { background.finish() for workspace in workspaces { workspace.resumeAfterBackground() } + forwardings.resumeEnabled() reconcileSync() outbox.start() if hasActivated { @@ -342,15 +346,16 @@ final class MobileAppModel { if removed { close(workspace.id) } else { workspace.disconnect() } } transfers.cancelAll(for: id) - forwardings.stop(library.portForwardings(for: id).map(\.id)) + forwardings.suspend(library.portForwardings(for: id).map(\.id)) } if removed { commandHistory.clear(for: id) metrics.remove(id) } } - forwardings.stop(keys.filter { $0.kind == .portForwarding }.map(\.id)) + forwardings.suspend(keys.filter { $0.kind == .portForwarding }.map(\.id)) library.replacePersistedSnapshot(snapshot) + forwardings.resumeEnabled() liveActivities.update(workspaceActivitySessions) } diff --git a/TFI/Features/HostEditor/MobileHostEditor.swift b/TFI/Features/HostEditor/MobileHostEditor.swift index 472a7e04..907293fd 100644 --- a/TFI/Features/HostEditor/MobileHostEditor.swift +++ b/TFI/Features/HostEditor/MobileHostEditor.swift @@ -49,6 +49,9 @@ struct MobileHostEditor: View { } Section("Port Forwarding") { ForEach(draftForwardings) { rule in + let runtime = app.forwardings.state(for: rule.id) + let state = forwardingState(for: rule, runtimeIsActive: runtime.isActive) + let isRunning = state.isEnabled || state.isRuntimeActive HStack { Button { editingForwarding = rule @@ -56,8 +59,7 @@ struct MobileHostEditor: View { Label { VStack(alignment: .leading) { Text(rule.label.isEmpty ? rule.bindAddress : rule.label) - Text(app.forwardings.state(for: rule.id).label).font(.caption).foregroundStyle( - .secondary) + Text(runtime.label).font(.caption).foregroundStyle(.secondary) } } icon: { Image(systemName: "arrow.left.arrow.right").accessibilityHidden(true) @@ -65,16 +67,11 @@ struct MobileHostEditor: View { } Spacer() Button( - app.forwardings.isEnabled(rule.id) - ? String(localized: "Stop") : String(localized: "Start"), - systemImage: app.forwardings.isEnabled(rule.id) ? "stop" : "play" + isRunning ? String(localized: "Stop") : String(localized: "Start"), + systemImage: isRunning ? "stop" : "play" ) { - if app.forwardings.isEnabled(rule.id) { - app.forwardings.stop(rule.id) - } else { - app.forwardings.start(rule.id) - } - }.disabled(app.forwardingLibrary.portForwarding(rule.id) != rule) + app.forwardings.setEnabled(!isRunning, for: rule.id) + }.disabled(!state.canToggle) } .contextMenu { Button("Duplicate", systemImage: "plus.square.on.square") { @@ -82,9 +79,11 @@ struct MobileHostEditor: View { copy.id = UUID() draftForwardings.append(copy) } + .disabled(!state.canEdit) Button("Delete", systemImage: "trash", role: .destructive) { draftForwardings.removeAll { $0.id == rule.id } } + .disabled(!state.canEdit) } } Button("Add Port Forward", systemImage: "plus") { @@ -107,7 +106,7 @@ struct MobileHostEditor: View { } ) .sheet(item: $editingForwarding) { rule in - MobileForwardingEditor(rule: rule) { updated in + MobileForwardingEditor(app: app, host: draft, rule: rule) { updated in if let index = draftForwardings.firstIndex(where: { $0.id == updated.id }) { draftForwardings[index] = updated } else { @@ -116,6 +115,15 @@ struct MobileHostEditor: View { } } } + + private func forwardingState( + for rule: PortForwardingRule, runtimeIsActive: Bool + ) -> PortForwardingEditorPolicy { + PortForwardingEditorPolicy( + rule: rule, host: draft, + savedRule: app.forwardingLibrary.portForwarding(rule.id), savedHost: library.host(draft.id), + isEnabled: app.forwardings.isEnabled(rule.id), isRuntimeActive: runtimeIsActive) + } } nonisolated enum MobileEditorError: LocalizedError { diff --git a/TFI/Features/Ports/MobileForwardingEditor.swift b/TFI/Features/Ports/MobileForwardingEditor.swift index 3e89c5be..1dd7c153 100644 --- a/TFI/Features/Ports/MobileForwardingEditor.swift +++ b/TFI/Features/Ports/MobileForwardingEditor.swift @@ -1,14 +1,25 @@ import SwiftUI +import TermindApplication import TermindDomain import TermindPresentation +import TermindRemoteInfrastructure + +import struct TermindDomain.Host /// Rules belong to the host draft and commit in its one Vault transaction. struct MobileForwardingEditor: View { + let app: MobileAppModel + let host: Host let original: PortForwardingRule let save: (PortForwardingRule) -> Void @State private var draft: PortForwardingRule - init(rule: PortForwardingRule, save: @escaping (PortForwardingRule) -> Void) { + init( + app: MobileAppModel, host: Host, rule: PortForwardingRule, + save: @escaping (PortForwardingRule) -> Void + ) { + self.app = app + self.host = host original = rule self.save = save _draft = State(initialValue: rule) @@ -17,6 +28,39 @@ struct MobileForwardingEditor: View { var body: some View { MobileEditorSheet( title: String(localized: "Port Forwarding"), isDirty: draft != original, - canSave: draft.isValid, save: { save(draft) }, content: { PortForwardingFields(rule: $draft) }) + canSave: draft.isValid && state.canEdit, + save: { save(draft) }, + content: { + Section { + Toggle("Enabled", isOn: enabled) + .disabled(!state.canToggle) + LabeledContent("Status", value: runtime.label) + if !state.hasSavedConfiguration { + Text("Save the host and forwarding changes before starting this rule.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + PortForwardingFields(rule: $draft) + .disabled(!state.canEdit) + }) + } + + private var runtime: PortForwardingRuntimeState { app.forwardings.state(for: draft.id) } + + private var state: PortForwardingEditorPolicy { + PortForwardingEditorPolicy( + rule: draft, host: host, + savedRule: app.forwardingLibrary.portForwarding(draft.id), savedHost: app.library.host(host.id), + isEnabled: app.forwardings.isEnabled(draft.id), isRuntimeActive: runtime.isActive) + } + + private var enabled: Binding { + Binding( + get: { state.isEnabled || state.isRuntimeActive }, + set: { value in + guard !value || state.canStart else { return } + app.forwardings.setEnabled(value, for: draft.id) + }) } } diff --git a/TFITests/MobileIconRenderingTests.swift b/TFITests/MobileIconRenderingTests.swift index 572cc788..84b9eca3 100644 --- a/TFITests/MobileIconRenderingTests.swift +++ b/TFITests/MobileIconRenderingTests.swift @@ -31,6 +31,9 @@ struct MobileIconRenderingTests { @Test func renderIconNavigationAndForms() async throws { guard let language = ProcessInfo.processInfo.environment["TERMIND_ICON_SCREENSHOTS"] else { return } let app = try MobileAppModel(inMemory: true) + let forwardingHost = Host(name: "Example", host: "example.invalid") + let forwardingRule = PortForwardingRule(hostID: forwardingHost.id, label: "Web") + #expect(app.library.upsert(forwardingHost, replacingPortForwardings: [forwardingRule])) let snippetApp = try MobileAppModel(inMemory: true) let snippetHost = Host(name: "Production", host: "production.example.invalid") let snippetPackage = SnippetPackage(name: "Operations") @@ -121,6 +124,10 @@ struct MobileIconRenderingTests { MobileHostEditor(app: app, host: Host(name: "Example", host: "example.invalid")) .environment(\.locale, locale) } + try await capture("forwarding-editor-\(suffix)", language: language, scheme: scheme) { + MobileForwardingEditor(app: app, host: forwardingHost, rule: forwardingRule) { _ in } + .environment(\.locale, locale) + } try await capture("add-\(suffix)", language: language, scheme: scheme) { MobileAddModelSheet(existing: [], add: { _ in }).environment(\.locale, locale) } diff --git a/TFITests/MobileVaultFeatureTests.swift b/TFITests/MobileVaultFeatureTests.swift index fef99122..b49ce572 100644 --- a/TFITests/MobileVaultFeatureTests.swift +++ b/TFITests/MobileVaultFeatureTests.swift @@ -10,6 +10,24 @@ import Testing @Suite @MainActor struct MobileVaultFeatureTests { + @Test func forwardingEnabledIntentUsesInjectedDevicePreferences() throws { + let suite = "MobileForwardingIntentTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let app = try MobileAppModel(inMemory: true, forwardingDefaults: defaults) + let host = TermindDomain.Host(name: "Remote", host: "example.invalid") + let rule = PortForwardingRule(hostID: host.id) + #expect(app.library.upsert(host, replacingPortForwardings: [rule])) + + app.forwardings.setEnabled(true, for: rule.id) + #expect(app.forwardings.isEnabled(rule.id)) + #expect(defaults.stringArray(forKey: SettingsKey.portForwardingEnabledRuleIDs) == [rule.id.uuidString]) + app.forwardings.suspendAll() + #expect(app.forwardings.isEnabled(rule.id)) + app.forwardings.setEnabled(false, for: rule.id) + #expect(!app.forwardings.isEnabled(rule.id)) + } + @Test func encryptedKeyGenerationImportAndMetadataEditingKeepTheSecretOutOfRecords() async throws { let app = try MobileAppModel(inMemory: true) let generator = MobileKeyEditorModel(mode: .generate, credentials: app.credentials) diff --git a/TFM/App/AppDependencies.swift b/TFM/App/AppDependencies.swift index e970daa4..7a147c94 100644 --- a/TFM/App/AppDependencies.swift +++ b/TFM/App/AppDependencies.swift @@ -16,6 +16,7 @@ struct AppDependencies { let agentRunRepository: (any AgentRunPersisting)? let settingsSync: SettingsSyncCoordinator? let vaultSync: VaultSyncCoordinator? + let forwardingDefaults: UserDefaults? init( hostRepository: any HostRepository, outboxRunner: OutboxRunner?, @@ -23,7 +24,8 @@ struct AppDependencies { serialPortService: any SerialPortServing = SystemSerialPortService(), agentRunRepository: (any AgentRunPersisting)? = nil, vaultSync: VaultSyncCoordinator? = nil, - settingsSync: SettingsSyncCoordinator? = nil + settingsSync: SettingsSyncCoordinator? = nil, + forwardingDefaults: UserDefaults? = nil ) { self.hostRepository = hostRepository self.outboxRunner = outboxRunner @@ -32,6 +34,7 @@ struct AppDependencies { self.agentRunRepository = agentRunRepository self.vaultSync = vaultSync self.settingsSync = settingsSync + self.forwardingDefaults = forwardingDefaults } static func inMemory(hosts: [Host], groups: [HostGroup]) -> Self { diff --git a/TFM/App/AppModel.swift b/TFM/App/AppModel.swift index ea0a3bb4..c76fc40d 100644 --- a/TFM/App/AppModel.swift +++ b/TFM/App/AppModel.swift @@ -186,7 +186,7 @@ final class AppModel { hostRepository = dependencies.hostRepository vaultSync = dependencies.vaultSync settingsSync = dependencies.settingsSync - forwarding = PortForwardingController(hosts: hosts) + forwarding = PortForwardingController(hosts: hosts, defaults: dependencies.forwardingDefaults) sftpTransfers = SFTPTransferCoordinator( store: hosts, clientFactory: { connectionCoordinator.makeSFTPClient() } @@ -217,14 +217,17 @@ final class AppModel { ) hosts.willDeleteHost = { [weak forwarding, weak serverMetrics, agentConnections] hostID, ruleIDs in serverMetrics?.remove(hostID) - forwarding?.stop(ruleIDs) + forwarding?.suspend(ruleIDs) // Not a `HostDeletionOutbox` step like the others: a live socket // cannot outlive the process, so there is nothing to replay after a // crash and nothing to make durable. Task { await agentConnections.disconnect(hostID: hostID) } } hosts.willDeletePortForwardings = { [weak forwarding] ruleIDs in - forwarding?.stop(ruleIDs) + forwarding?.suspend(ruleIDs) + } + hosts.didReplacePortForwardings = { [weak forwarding] ruleIDs in + forwarding?.resumeEnabled(ruleIDs) } hosts.didPersistCore = { [weak repository = dependencies.hostRepository] in try? repository?.refresh() @@ -253,7 +256,7 @@ final class AppModel { outboxRunner.register( RuntimeHostDeletionHandler(handlerID: HostDeletionOutbox.stopForwardings) { [weak self] deletion in - self?.forwarding.stop(deletion.forwardingRuleIDs) + self?.forwarding.remove(deletion.forwardingRuleIDs) } ) outboxRunner.register( @@ -330,8 +333,9 @@ final class AppModel { connectionHistory.clear(for: hostID) } } - if !changedRuleIDs.isEmpty { forwarding.stop(Array(changedRuleIDs)) } + if !changedRuleIDs.isEmpty { forwarding.suspend(Array(changedRuleIDs)) } hosts.replacePersistedSnapshot(snapshot) + forwarding.resumeEnabled() } @ObservationIgnored private lazy var assistantOperations = AssistantConversationOperations( @@ -616,7 +620,7 @@ final class AppModel { windows.removeAll() for window in liveWindows { window.closeAll() } sftpTransfers.cancelAll() - forwarding.stopAll() + forwarding.suspendAll() Task { [mcpServer, agentConnections, agentBackgroundCommands] in await mcpServer.stop() await agentBackgroundCommands.closeAll() diff --git a/TFM/App/TermindApp.swift b/TFM/App/TermindApp.swift index 0c6a7103..f7d90b03 100644 --- a/TFM/App/TermindApp.swift +++ b/TFM/App/TermindApp.swift @@ -103,7 +103,8 @@ struct TermindApp: App { agentRunRepository: SwiftDataAgentRunRepository( context: container.mainContext ), - vaultSync: vaultSync, settingsSync: settingsSync + vaultSync: vaultSync, settingsSync: settingsSync, + forwardingDefaults: .standard ), proAccess: proAccess, appReview: .live() @@ -170,6 +171,10 @@ struct TermindApp: App { didSet { guard let app else { return } guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil else { return } + if !didRestoreForwardings { + didRestoreForwardings = true + app.forwarding.resumeEnabled() + } app.serverMetrics.start() metricsSleep.start(app.serverMetrics) metricsMenu.start(app.serverMetrics) @@ -177,6 +182,7 @@ struct TermindApp: App { } private let metricsSleep = MetricsSleepObserver() private let metricsMenu = MetricsMenuBarController() + private var didRestoreForwardings = false private var quietLaunchObserver: (any NSObjectProtocol)? private let appearanceController = AppAppearanceController() diff --git a/TFM/Features/Ports/PortForwardingEditorState.swift b/TFM/Features/Ports/PortForwardingEditorState.swift index 555ec18f..d75e39b8 100644 --- a/TFM/Features/Ports/PortForwardingEditorState.swift +++ b/TFM/Features/Ports/PortForwardingEditorState.swift @@ -1,4 +1,5 @@ import Foundation +import TermindApplication import TermindDomain import TermindRemoteInfrastructure @@ -19,9 +20,11 @@ struct PortForwardingEditorState { savedRule: PortForwardingRule?, savedHost: Host?, isEnabled: Bool, runtime: PortForwardingRuntimeState ) { - hasSavedConfiguration = - rule != nil && rule?.isValid == true && rule == savedRule && host == savedHost - self.isEnabled = isEnabled + let shared = PortForwardingEditorPolicy( + rule: rule, host: host, savedRule: savedRule, savedHost: savedHost, + isEnabled: isEnabled, isRuntimeActive: runtime.isActive) + hasSavedConfiguration = shared.hasSavedConfiguration + self.isEnabled = shared.isEnabled self.runtime = runtime } @@ -33,9 +36,7 @@ struct PortForwardingEditorState { } static func normalized(_ rule: PortForwardingRule) -> PortForwardingRule? { - normalized( - rule, hostID: rule.hostID, bindPort: String(rule.bindPort), - targetHost: rule.targetHost ?? "", targetPort: rule.targetPort.map(String.init) ?? "") + PortForwardingEditorPolicy.normalized(rule) } /// Use the exact save candidate for validation and start eligibility, including hidden dynamic targets. @@ -43,14 +44,8 @@ struct PortForwardingEditorState { _ rule: PortForwardingRule, hostID: Host.ID, bindPort: String, targetHost: String, targetPort: String ) -> PortForwardingRule? { - guard let port = Int(bindPort.trimmingCharacters(in: .whitespacesAndNewlines)) else { return nil } - var value = rule - value.hostID = hostID - value.bindHost = value.bindHost.trimmingCharacters(in: .whitespacesAndNewlines) - value.bindPort = port - value.targetHost = rule.kind == .dynamic ? nil : targetHost.trimmingCharacters(in: .whitespacesAndNewlines) - value.targetPort = - rule.kind == .dynamic ? nil : Int(targetPort.trimmingCharacters(in: .whitespacesAndNewlines)) - return value.isValid ? value : nil + PortForwardingEditorPolicy.normalized( + rule, hostID: hostID, bindPort: bindPort, + targetHost: targetHost, targetPort: targetPort) } } diff --git a/TFM/Features/Ports/PortForwardingView.swift b/TFM/Features/Ports/PortForwardingView.swift index 73ffde2a..dae12e5f 100644 --- a/TFM/Features/Ports/PortForwardingView.swift +++ b/TFM/Features/Ports/PortForwardingView.swift @@ -72,11 +72,11 @@ struct PortForwardingEditorSection: View { } private func startAll() { - app.forwarding.start(startableRules.map(\.id)) + app.forwarding.setEnabled(true, for: startableRules.map(\.id)) } private func stopAll() { - app.forwarding.stop(runningRules.map(\.id)) + app.forwarding.setEnabled(false, for: runningRules.map(\.id)) } } @@ -155,9 +155,9 @@ private struct HostForwardingRow: View { set: { value in if value { guard state.canStart else { return } - app.forwarding.start(rule.id) + app.forwarding.setEnabled(true, for: rule.id) } else { - app.forwarding.stop(rule.id) + app.forwarding.setEnabled(false, for: rule.id) } } ) @@ -203,9 +203,9 @@ private struct HostForwardingContextMenu: View { private func toggle() { if state.isEnabled || state.runtime.isActive { - app.forwarding.stop(rule.id) + app.forwarding.setEnabled(false, for: rule.id) } else if state.canStart { - app.forwarding.start(rule.id) + app.forwarding.setEnabled(true, for: rule.id) } } @@ -368,9 +368,9 @@ struct PortForwardingEditorSheet: View { set: { value in if value { guard state.canStart else { return } - app.forwarding.start(draft.id) + app.forwarding.setEnabled(true, for: draft.id) } else { - app.forwarding.stop(draft.id) + app.forwarding.setEnabled(false, for: draft.id) } }) } diff --git a/TFMTests/PortForwardingFeatureTests.swift b/TFMTests/PortForwardingFeatureTests.swift index f9ab1d29..a78e1473 100644 --- a/TFMTests/PortForwardingFeatureTests.swift +++ b/TFMTests/PortForwardingFeatureTests.swift @@ -15,6 +15,61 @@ import struct TermindDomain.Host @Suite @MainActor struct PortForwardingFeatureTests { + @Test + func enabledIntentPersistsLocallyAndPrunesMissingRulesWithoutStarting() throws { + let suite = "PortForwardingIntentTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let host = Host(name: "Server", host: "server.invalid") + let rule = PortForwardingRule(hostID: host.id) + let missingID = UUID() + let library = HostLibraryModel(hosts: [host], portForwardings: [rule]) + defaults.set( + [rule.id.uuidString, missingID.uuidString, "invalid"], + forKey: SettingsKey.portForwardingEnabledRuleIDs) + + let controller = PortForwardingController(hosts: library, defaults: defaults) + #expect(controller.isEnabled(rule.id)) + #expect(controller.isEnabled(missingID)) + controller.resumeEnabled([]) + #expect(controller.isEnabled(rule.id)) + #expect(!controller.isEnabled(missingID)) + #expect(defaults.stringArray(forKey: SettingsKey.portForwardingEnabledRuleIDs) == [rule.id.uuidString]) + + let restored = PortForwardingController(hosts: library, defaults: defaults) + #expect(restored.isEnabled(rule.id)) + restored.suspend(rule.id) + #expect(restored.isEnabled(rule.id)) + restored.remove([rule.id]) + #expect(!restored.isEnabled(rule.id)) + #expect(defaults.stringArray(forKey: SettingsKey.portForwardingEnabledRuleIDs)?.isEmpty == true) + } + + @Test + func failedAutomaticStartKeepsEnabledIntentUntilTheUserDisablesIt() async throws { + let suite = "PortForwardingFailureIntentTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let host = Host(name: "Server", host: "server.invalid") + let rule = PortForwardingRule(hostID: host.id) + let library = HostLibraryModel(hosts: [host], portForwardings: [rule]) + defaults.set([rule.id.uuidString], forKey: SettingsKey.portForwardingEnabledRuleIDs) + let controller = PortForwardingController(hosts: library, defaults: defaults) + + controller.resumeEnabled() + for _ in 0..<100 { + if case .failed = controller.state(for: rule.id) { break } + await Task.yield() + } + guard case .failed = controller.state(for: rule.id) else { + Issue.record("Expected the missing identity to fail forwarding startup") + return + } + #expect(controller.isEnabled(rule.id)) + controller.setEnabled(false, for: rule.id) + #expect(!controller.isEnabled(rule.id)) + } + @Test func proxyIdentityRequiresUsernameAndSavedPassword() { let eligible = Identity(