Allow immediate port forwarding toggles
This commit is contained in:
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
@@ -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)
|
||||
|
||||
|
||||
@@ -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]) }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
|
||||
Reference in new issue
Block a user