Allow immediate port forwarding toggles

This commit is contained in:
Kaede Akino committed 2026-09-12 17:09:07 +08:00
1 parent 4f02ec354a
commit 93c6438e75
15 files changed
+630 -312

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -28,7 +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.
/// Reconciles locally enabled rules after their saved configuration has been added,
/// replaced, removed, or rolled back.
public var didReplacePortForwardings: (([PortForwardingRule.ID]) -> Void)?
public var didPersistCore: (() -> Void)? {
get { vault.didPersist }
@@ -679,6 +680,7 @@ public final class HostLibraryModel {
let oldRules = portForwardings
let existingHost = self.host(host.id)
let existingRules = portForwardings(for: host.id)
let existingRuleIDs = Set(existingRules.map(\.id))
let nextByID = Dictionary(uniqueKeysWithValues: normalizedRules.map { ($0.id, $0) })
var rulesToStop = Set(
existingRules.compactMap { old in nextByID[old.id] == old ? nil : old.id }
@@ -688,9 +690,6 @@ public final class HostLibraryModel {
}
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
@@ -706,8 +705,12 @@ public final class HostLibraryModel {
guard persistCore() else {
hosts = oldHosts
portForwardings = oldRules
if !affectedRuleIDs.isEmpty { didReplacePortForwardings?(affectedRuleIDs) }
return false
}
let addedRuleIDs = Set(normalizedRules.map(\.id)).subtracting(existingRuleIDs)
let savedRuleIDs = Array(rulesToStop.union(addedRuleIDs))
if !savedRuleIDs.isEmpty { didReplacePortForwardings?(savedRuleIDs) }
return true
}
@@ -10,8 +10,9 @@ public nonisolated struct PortForwardingEditorPolicy {
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 var canToggle: Bool { true }
public var canEdit: Bool { !isRuntimeActive }
public var isWaitingForSave: Bool { isEnabled && !hasSavedConfiguration && !isRuntimeActive }
public init(
rule: PortForwardingRule?, host: Host,
@@ -19,7 +20,8 @@ public nonisolated struct PortForwardingEditorPolicy {
isEnabled: Bool, isRuntimeActive: Bool
) {
hasSavedConfiguration =
rule != nil && rule?.isValid == true && rule == savedRule && host == savedHost
savedRule?.isValid == true && savedRule?.hostID == savedHost?.id
&& (rule == nil || rule?.id == savedRule?.id)
self.isEnabled = isEnabled
self.isRuntimeActive = isRuntimeActive
}
@@ -53,6 +53,10 @@ public final class PortForwardingController {
private let enabledDefaultsKey: String
public private(set) var states: [PortForwardingRule.ID: PortForwardingRuntimeState] = [:]
private var enabled: Set<PortForwardingRule.ID>
/// Enabled IDs created in this process before their rules have been saved.
/// Unlike stale IDs restored from defaults, these survive reconciliation until
/// their editor explicitly discards them or a save makes them runnable.
private var pending: Set<PortForwardingRule.ID> = []
private var desired: Set<PortForwardingRule.ID> = []
private var sessions: [Host.ID: SSHForwardingSession] = [:]
private var connectionTasks: [Host.ID: Task<Void, any Error>] = [:]
@@ -83,12 +87,17 @@ public final class PortForwardingController {
public func setEnabled(_ value: Bool, for id: PortForwardingRule.ID) {
if value {
guard hosts.portForwarding(id)?.isValid == true else { return }
enabled.insert(id)
if let rule = hosts.portForwarding(id), rule.isValid, hosts.host(rule.hostID) != nil {
pending.remove(id)
} else {
pending.insert(id)
}
persistEnabled()
activate(id)
if !pending.contains(id) { activate(id) }
} else {
enabled.remove(id)
pending.remove(id)
persistEnabled()
suspend(id)
}
@@ -106,8 +115,10 @@ public final class PortForwardingController {
ruleHostIDs[rule.id] = rule.hostID
valid.insert(id)
}
if valid != enabled {
enabled = valid
pending.subtract(valid)
let reconciled = valid.union(pending.intersection(enabled))
if reconciled != enabled {
enabled = reconciled
persistEnabled()
}
let targets = ids.map { valid.intersection($0) } ?? valid
@@ -139,9 +150,20 @@ public final class PortForwardingController {
public func suspend(_ ids: [PortForwardingRule.ID]) { ids.forEach(suspend) }
/// Forgets enabled intent only for rules that were enabled before their first save.
public func discardPending(_ ids: [PortForwardingRule.ID]) {
let discarded = pending.intersection(ids)
guard !discarded.isEmpty else { return }
pending.subtract(discarded)
enabled.subtract(discarded)
for id in discarded { states[id] = nil }
persistEnabled()
}
public func remove(_ ids: [PortForwardingRule.ID]) {
for id in ids {
enabled.remove(id)
pending.remove(id)
suspend(id)
ruleHostIDs[id] = nil
}
+12 -10
View File
@@ -745,8 +745,9 @@ the provider/model/credential captured at start. Assistant now registers the sam
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 a persistent, device-local Enabled switch per rule and has no bulk controls.
- Port forwarding rules belong to the host draft and commit with it. Enabled intent is persisted immediately on
the local device, independently of the draft: saved rules run with their last saved Host and rule configuration,
while a newly enabled rule waits for its first successful Host save. The mobile host editor 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.
@@ -858,14 +859,15 @@ Terminal custom-configuration editors expose the same immediate-persistence enab
preference as the parent settings page. Both light and dark editors remain reachable
while disabled; toggling never clears or replaces their independently autosaved text.
Desktop forwarding lists and rule details share one start gate: the normalized rule
and its host draft must match persisted records. Rule Save still updates only the
host draft; saving the host persists the collection and never starts it. Runtime
start/stop remains process-local and uses the existing forwarding controller. Active
rules can open their details, with configuration edits, Save and Delete locked until
stopped. Stop remains available even when drafts differ from persisted records,
including through Stop All. Closing or cancelling an editor does not undo runtime
start/stop actions.
Desktop forwarding lists and rule details share immediate, device-local Enabled intent.
An existing rule can be enabled while its Host or rule draft differs, and the runtime
uses the last saved configuration until the Host is saved. A new rule can be enabled
before its first save; it remains Waiting for Save and starts only after the Host
transaction succeeds. Active runtime phases lock configuration edits until disabled,
but an enabled rule that has not started remains editable. Cancelling an existing rule
editor does not undo enable/disable actions; cancelling an unsaved rule or its Host
editor removes that rule's pending intent. Enable All and Disable All follow the same
policy.
### Mobile card presentation and color tags (2026-09-11)
+13 -3
View File
@@ -28,6 +28,7 @@ struct MobileHostEditor: View {
title: String(localized: "Host"), isDirty: draft != original || draftForwardings != originalForwardings,
canSave: !draft.host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& (1...65535).contains(draft.port) && draftForwardings.allSatisfy(\.isValid),
onCancel: cancel,
save: {
if draft.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { draft.name = draft.host }
_ = library.upsert(draft, replacingPortForwardings: draftForwardings)
@@ -59,7 +60,11 @@ struct MobileHostEditor: View {
Label {
VStack(alignment: .leading) {
Text(rule.label.isEmpty ? rule.bindAddress : rule.label)
Text(runtime.label).font(.caption).foregroundStyle(.secondary)
Text(
state.isWaitingForSave
? String(localized: "Waiting for Save") : runtime.label
)
.font(.caption).foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "arrow.left.arrow.right").accessibilityHidden(true)
@@ -67,7 +72,7 @@ struct MobileHostEditor: View {
}
Spacer()
Button(
isRunning ? String(localized: "Stop") : String(localized: "Start"),
isRunning ? String(localized: "Disable") : String(localized: "Enable"),
systemImage: isRunning ? "stop" : "play"
) {
app.forwardings.setEnabled(!isRunning, for: rule.id)
@@ -81,6 +86,7 @@ struct MobileHostEditor: View {
}
.disabled(!state.canEdit)
Button("Delete", systemImage: "trash", role: .destructive) {
app.forwardings.discardPending([rule.id])
draftForwardings.removeAll { $0.id == rule.id }
}
.disabled(!state.canEdit)
@@ -90,7 +96,7 @@ struct MobileHostEditor: View {
editingForwarding = PortForwardingRule(hostID: draft.id)
}
Text(
"Forwarding uses a background SSH connection and stops when Termind quits. Save changes before starting a rule."
"Enabled is saved immediately on this device. Unsaved forwarding changes apply after the host is saved."
)
.font(.caption).foregroundStyle(.secondary)
}
@@ -119,6 +125,10 @@ struct MobileHostEditor: View {
}
}
private func cancel() {
app.forwardings.discardPending(draftForwardings.map(\.id))
}
private func forwardingState(
for rule: PortForwardingRule, runtimeIsActive: Bool
) -> PortForwardingEditorPolicy {
@@ -30,16 +30,20 @@ struct MobileForwardingEditor: View {
var body: some View {
MobileEditorSheet(
title: isNew ? String(localized: "New Forwarding") : String(localized: "Edit Forwarding"),
isDirty: draft != original,
isDirty: draft != original || (isNew && state.isEnabled),
canSave: draft.isValid && state.canEdit,
onCancel: cancel,
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.")
LabeledContent(
"Status",
value: state.isWaitingForSave ? String(localized: "Waiting for Save") : runtime.label
)
if state.isWaitingForSave {
Text("This forwarding will start after the host is saved.")
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -62,8 +66,11 @@ struct MobileForwardingEditor: View {
Binding(
get: { state.isEnabled || state.isRuntimeActive },
set: { value in
guard !value || state.canStart else { return }
app.forwardings.setEnabled(value, for: draft.id)
})
}
private func cancel() {
if isNew { app.forwardings.discardPending([draft.id]) }
}
}
+15 -3
View File
@@ -17,6 +17,7 @@ struct MobileEditorSheet<Content: View>: View {
var canSave = true
var compact = false
var actions: [MobileEditorToolbarAction] = []
var onCancel: () -> Void = {}
let save: () async throws -> Void
@ViewBuilder let content: () -> Content
@Environment(\.dismiss) private var dismiss
@@ -32,8 +33,14 @@ struct MobileEditorSheet<Content: View>: View {
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel", systemImage: "xmark") { if isDirty { discard = true } else { dismiss() } }
.labelStyle(.iconOnly).disabled(isSaving)
Button("Cancel", systemImage: "xmark") {
if isDirty {
discard = true
} else {
cancel()
}
}
.labelStyle(.iconOnly).disabled(isSaving)
}
ToolbarItem(placement: .primaryAction) {
if !actions.isEmpty {
@@ -70,7 +77,7 @@ struct MobileEditorSheet<Content: View>: View {
}
}
.confirmationDialog("Discard Changes?", isPresented: $discard, titleVisibility: .visible) {
Button("Discard Changes", role: .destructive) { dismiss() }
Button("Discard Changes", role: .destructive, action: cancel)
}
.alert("Unable to Save", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
Button("OK", role: .cancel) { error = nil }
@@ -82,4 +89,9 @@ struct MobileEditorSheet<Content: View>: View {
.interactiveDismissDisabled(isDirty || isSaving)
.onDisappear { saveTask?.cancel() }
}
private func cancel() {
onCancel()
dismiss()
}
}
+16
View File
@@ -28,6 +28,22 @@ struct MobileVaultFeatureTests {
#expect(!app.forwardings.isEnabled(rule.id))
}
@Test func unsavedForwardingIntentWaitsUntilTheHostIsSaved() throws {
let suite = "MobilePendingForwardingIntentTests.\(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)
app.forwardings.setEnabled(true, for: rule.id)
#expect(app.forwardings.isEnabled(rule.id))
#expect(app.forwardings.state(for: rule.id) == .stopped)
#expect(app.library.upsert(host, replacingPortForwardings: [rule]))
#expect(app.forwardings.state(for: rule.id) != .stopped)
app.forwardings.setEnabled(false, for: rule.id)
}
@Test func encryptedKeyGenerationImportAndMetadataEditingKeepTheSecretOutOfRecords() async throws {
let app = try MobileAppModel(inMemory: true)
let generator = MobileKeyEditorModel(mode: .generate, credentials: app.credentials)
+7 -2
View File
@@ -35,7 +35,7 @@ struct HostEditorView: View {
} else {
VaultDetailScaffold(
title: hostID == nil ? "New Host" : "Edit Host",
symbol: "server.rack", close: closeTab
symbol: "server.rack", close: cancel
) {
form
} footer: {
@@ -419,7 +419,7 @@ struct HostEditorView: View {
.lineLimit(2)
}
Spacer()
Button("Cancel") { closeTab() }
Button("Cancel", action: cancel)
.keyboardShortcut(.cancelAction)
Button("Save") { save() }
.keyboardShortcut(.defaultAction)
@@ -473,6 +473,11 @@ struct HostEditorView: View {
}
}
private func cancel() {
app.forwarding.discardPending(draftForwardings.map(\.id))
closeTab()
}
/// Return to Hosts so the saved or retained record is immediately visible.
private func closeTab() {
window.openPage(.hosts)
@@ -5,15 +5,16 @@ import TermindRemoteInfrastructure
import struct TermindDomain.Host
/// The list and detail use the same saved-configuration gate; stopping never requires a clean draft.
/// The list and detail keep enabled intent separate from the saved configuration and live runtime.
struct PortForwardingEditorState {
let hasSavedConfiguration: Bool
let isEnabled: Bool
let runtime: PortForwardingRuntimeState
var canStart: Bool { hasSavedConfiguration && !isEnabled && !runtime.isActive }
var canToggle: Bool { isEnabled || runtime.isActive || canStart }
var canEdit: Bool { !isEnabled && !runtime.isActive }
var canToggle: Bool { true }
var canEdit: Bool { !runtime.isActive }
var isWaitingForSave: Bool { isEnabled && !hasSavedConfiguration && !runtime.isActive }
init(
rule: PortForwardingRule?, host: Host,
+35 -36
View File
@@ -24,7 +24,7 @@ struct PortForwardingEditorSection: View {
rule: rule,
edit: { openEditor(rule) },
duplicate: { duplicate(rule) },
delete: { rules.removeAll { $0.id == rule.id } }
delete: { delete(rule) }
)
}
FormNavigationAction(title: "Add Forwarding") {
@@ -34,7 +34,7 @@ struct PortForwardingEditorSection: View {
forwardingActions
}
Text(
"Forwarding uses a background SSH connection and stops when Termind quits. Save changes before starting a rule."
"Enabled is saved immediately on this device. Unsaved forwarding changes apply after the host is saved."
)
.font(.caption)
.foregroundStyle(.secondary)
@@ -45,19 +45,15 @@ struct PortForwardingEditorSection: View {
private var forwardingActions: some View {
HStack(spacing: Metrics.Space.m) {
Spacer()
Button("Start All", action: startAll)
.disabled(startableRules.isEmpty)
Button("Stop All", action: stopAll)
Button("Enable All", action: enableAll)
.disabled(enableableRules.isEmpty)
Button("Disable All", action: disableAll)
.disabled(runningRules.isEmpty)
}
}
private var startableRules: [PortForwardingRule] {
rules.filter {
PortForwardingEditorState(
ruleID: $0.id, rule: PortForwardingEditorState.normalized($0), host: host, app: app
).canStart
}
private var enableableRules: [PortForwardingRule] {
rules.filter { !app.forwarding.isEnabled($0.id) }
}
private var runningRules: [PortForwardingRule] {
@@ -71,11 +67,16 @@ struct PortForwardingEditorSection: View {
rules.append(copy)
}
private func startAll() {
app.forwarding.setEnabled(true, for: startableRules.map(\.id))
private func delete(_ rule: PortForwardingRule) {
app.forwarding.discardPending([rule.id])
rules.removeAll { $0.id == rule.id }
}
private func stopAll() {
private func enableAll() {
app.forwarding.setEnabled(true, for: enableableRules.map(\.id))
}
private func disableAll() {
app.forwarding.setEnabled(false, for: runningRules.map(\.id))
}
}
@@ -138,14 +139,14 @@ private struct HostForwardingRow: View {
@ViewBuilder
private var statusChip: some View {
if state.hasSavedConfiguration || !state.canEdit {
if state.isWaitingForSave {
CardChip(text: String(localized: "Waiting for Save"), symbol: "clock")
} else {
CardChip(
text: runtime.label,
symbol: runtime.isActive ? "circle.fill" : "circle",
tint: runtime.isActive ? Color.green : nil
)
} else {
CardChip(text: String(localized: "Save required"), symbol: "circle")
}
}
@@ -153,12 +154,7 @@ private struct HostForwardingRow: View {
Binding(
get: { state.isEnabled || state.runtime.isActive },
set: { value in
if value {
guard state.canStart else { return }
app.forwarding.setEnabled(true, for: rule.id)
} else {
app.forwarding.setEnabled(false, for: rule.id)
}
app.forwarding.setEnabled(value, for: rule.id)
}
)
}
@@ -183,7 +179,7 @@ private struct HostForwardingContextMenu: View {
var body: some View {
let isRunning = state.isEnabled || state.runtime.isActive
Button(
isRunning ? String(localized: "Stop") : String(localized: "Start"),
isRunning ? String(localized: "Disable") : String(localized: "Enable"),
systemImage: isRunning ? MenuSymbol.stop : MenuSymbol.start,
action: toggle
)
@@ -204,7 +200,7 @@ private struct HostForwardingContextMenu: View {
private func toggle() {
if state.isEnabled || state.runtime.isActive {
app.forwarding.setEnabled(false, for: rule.id)
} else if state.canStart {
} else {
app.forwarding.setEnabled(true, for: rule.id)
}
}
@@ -240,16 +236,19 @@ struct PortForwardingEditorSheet: View {
var body: some View {
VaultDetailScaffold(
title: isNew ? "New Forwarding" : "Edit Forwarding",
symbol: "arrow.left.arrow.right", close: back,
symbol: "arrow.left.arrow.right", close: cancel,
closeLabel: "Back", closeSymbol: "chevron.backward"
) {
Form {
Section {
Toggle("Enabled", isOn: enabled)
.disabled(!state.canToggle)
LabeledContent("Status", value: state.runtime.label)
if !state.hasSavedConfiguration {
Text("Save the host and forwarding changes before starting this rule.")
LabeledContent(
"Status",
value: state.isWaitingForSave ? String(localized: "Waiting for Save") : state.runtime.label
)
if state.isWaitingForSave {
Text("This forwarding will start after the host is saved.")
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -269,7 +268,7 @@ struct PortForwardingEditorSheet: View {
.disabled(!state.canEdit)
}
Spacer()
Button("Cancel", action: back).keyboardShortcut(.cancelAction)
Button("Cancel", action: cancel).keyboardShortcut(.cancelAction)
Button("Save") { commit() }
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
@@ -291,15 +290,15 @@ struct PortForwardingEditorSheet: View {
Binding(
get: { state.isEnabled || state.runtime.isActive },
set: { value in
if value {
guard state.canStart else { return }
app.forwarding.setEnabled(true, for: draft.id)
} else {
app.forwarding.setEnabled(false, for: draft.id)
}
app.forwarding.setEnabled(value, for: draft.id)
})
}
private func cancel() {
if isNew { app.forwarding.discardPending([draft.id]) }
back()
}
private func commit() {
guard state.canEdit, let value = normalizedDraft else { return }
save(value)
+4
View File
@@ -623,7 +623,11 @@ struct ArchitectureBoundaryTests {
#expect(editor.contains("Edit Forwarding"))
}
#expect(desktop.contains("FormNavigationAction(title: \"Add Forwarding\")"))
#expect(desktop.contains("Button(\"Enable All\""))
#expect(desktop.contains("Button(\"Disable All\""))
#expect(desktop.contains("Waiting for Save"))
#expect(mobileHost.contains("Button(\"Add Forwarding\", systemImage: \"plus\")"))
#expect(mobileHost.contains("Waiting for Save"))
#expect(mobileHost.contains("isNew: !draftForwardings.contains"))
for removed in ["Add Port Forward", "New Port Forward", "Edit Port Forward"] {
#expect(!desktop.contains(removed))
+27 -8
View File
@@ -13,7 +13,7 @@ import struct TermindDomain.Host
@Suite @MainActor
struct PortForwardingEditorTests {
@Test func startingRequiresBothSavedRuleAndSavedHost() {
@Test func savedConfigurationIsIndependentFromTheCurrentDraft() {
let host = Host(name: "Server", host: "server.invalid")
let rule = PortForwardingRule(hostID: host.id)
func state(_ candidate: PortForwardingRule?, _ candidateHost: Host, saved: Bool = true)
@@ -25,16 +25,18 @@ struct PortForwardingEditorTests {
}
#expect(state(rule, host).canStart)
#expect(state(rule, host).canEdit)
#expect(!state(rule, host, saved: false).canToggle)
#expect(!state(nil, host).canStart)
#expect(state(rule, host, saved: false).canToggle)
// A nil normalized draft can still use the matching saved configuration.
#expect(state(nil, host).canStart)
var changedRule = rule
changedRule.bindPort += 1
#expect(!state(changedRule, host).canStart)
#expect(state(changedRule, host).canStart)
var changedHost = host
changedHost.host = "changed.invalid"
#expect(!state(rule, changedHost).canToggle)
#expect(state(rule, changedHost).canStart)
let unsavedRule = PortForwardingRule(hostID: host.id)
#expect(!state(unsavedRule, host).canStart)
#expect(state(unsavedRule, host).canToggle)
let deletedHost = PortForwardingEditorState(
rule: rule, host: host, savedRule: rule, savedHost: nil, isEnabled: false, runtime: .stopped)
#expect(!deletedHost.canStart)
@@ -56,7 +58,24 @@ struct PortForwardingEditorTests {
let stopped = PortForwardingEditorState(
rule: nil, host: host, savedRule: nil, savedHost: nil, isEnabled: false, runtime: .stopped)
#expect(stopped.canEdit)
#expect(!stopped.canToggle)
#expect(stopped.canToggle)
}
@Test func enabledUnsavedRulesWaitForSaveAndRemainEditable() {
let host = Host(name: "Server", host: "server.invalid")
let rule = PortForwardingRule(hostID: host.id)
let waiting = PortForwardingEditorState(
rule: rule, host: host, savedRule: nil, savedHost: nil,
isEnabled: true, runtime: .stopped)
#expect(waiting.isWaitingForSave)
#expect(waiting.canEdit)
#expect(waiting.canToggle)
let saved = PortForwardingEditorState(
rule: rule, host: host, savedRule: rule, savedHost: host,
isEnabled: true, runtime: .failed("Connection refused"))
#expect(!saved.isWaitingForSave)
#expect(saved.canEdit)
}
@Test func failedSavedRulesCanBeEditedAndRetried() {
@@ -110,7 +129,7 @@ struct PortForwardingEditorTests {
#expect(normalized.targetPort == nil)
}
@Test func savingHostAndRuleDoesNotStartForwardingAndOtherWindowEditsInvalidateStart() throws {
@Test func savingHostAndRuleDoesNotStartForwardingAndLaterSavedEditsRemainStartable() throws {
let host = Host(name: "Server", host: "server.invalid")
let rule = PortForwardingRule(hostID: host.id)
let library = HostLibraryModel()
@@ -126,6 +145,6 @@ struct PortForwardingEditorTests {
var remotelyChanged = rule
remotelyChanged.bindPort = 9000
library.upsert(remotelyChanged)
#expect(!PortForwardingEditorState(ruleID: rule.id, rule: rule, host: savedHost, app: app).canStart)
#expect(PortForwardingEditorState(ruleID: rule.id, rule: rule, host: savedHost, app: app).canStart)
}
}
+38
View File
@@ -15,6 +15,44 @@ import struct TermindDomain.Host
@Suite
@MainActor
struct PortForwardingFeatureTests {
@Test
func unsavedEnabledIntentWaitsForSaveAndCanBeDiscarded() throws {
let suite = "PortForwardingPendingIntentTests.\(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()
let controller = PortForwardingController(hosts: library, defaults: defaults)
controller.setEnabled(true, for: rule.id)
#expect(controller.isEnabled(rule.id))
#expect(controller.state(for: rule.id) == .stopped)
#expect(defaults.stringArray(forKey: SettingsKey.portForwardingEnabledRuleIDs) == [rule.id.uuidString])
controller.resumeEnabled([])
#expect(controller.isEnabled(rule.id))
controller.discardPending([rule.id])
#expect(!controller.isEnabled(rule.id))
#expect(defaults.stringArray(forKey: SettingsKey.portForwardingEnabledRuleIDs)?.isEmpty == true)
}
@Test
func savingAPendingRuleMakesItRunnable() throws {
let host = Host(name: "Server", host: "server.invalid")
let rule = PortForwardingRule(hostID: host.id)
let library = HostLibraryModel()
let controller = PortForwardingController(hosts: library)
library.didReplacePortForwardings = { controller.resumeEnabled($0) }
controller.setEnabled(true, for: rule.id)
#expect(controller.state(for: rule.id) == .stopped)
#expect(library.upsert(host, replacingPortForwardings: [rule]))
#expect(controller.isEnabled(rule.id))
#expect(controller.state(for: rule.id) != .stopped)
controller.setEnabled(false, for: rule.id)
}
@Test
func enabledIntentPersistsLocallyAndPrunesMissingRulesWithoutStarting() throws {
let suite = "PortForwardingIntentTests.\(UUID().uuidString)"