Persist port forwarding enabled state
This commit is contained in:
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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user