Persist port forwarding enabled state

This commit is contained in:
Kaede Akino committed 2026-09-12 10:38:01 +08:00
1 parent 01ca95e95f
commit 352be589c8
17 files changed
+382 -83

No files matched your search

@@ -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
@@ -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
}
}
@@ -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"
@@ -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<PortForwardingRule.ID>
private var desired: Set<PortForwardingRule.ID> = []
private var sessions: [Host.ID: SSHForwardingSession] = [:]
private var connectionTasks: [Host.ID: Task<Void, any Error>] = [:]
private var reconnectTasks: [Host.ID: Task<Void, Never>] = [:]
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<Void, Never>] = [:]
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<PortForwardingRule.ID> = []
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) {
+10 -4
View File
@@ -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.
+4
View File
@@ -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
)
+13 -8
View File
@@ -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)
}
+20 -12
View File
@@ -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 {
@@ -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<Bool> {
Binding(
get: { state.isEnabled || state.isRuntimeActive },
set: { value in
guard !value || state.canStart else { return }
app.forwardings.setEnabled(value, for: draft.id)
})
}
}
+7
View File
@@ -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)
}
+18
View File
@@ -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)
+4 -1
View File
@@ -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 {
+10 -6
View File
@@ -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()
+7 -1
View File
@@ -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()
@@ -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)
}
}
+8 -8
View File
@@ -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)
}
})
}
+55
View File
@@ -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(