From 93c6438e757bd7dcc73e05ef9f70d487aa893431 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sat, 12 Sep 2026 17:09:07 +0800 Subject: [PATCH] Allow immediate port forwarding toggles --- Frameworks/Resources/Localizable.xcstrings | 640 +++++++++++------- .../FeatureModels/HostLibraryModel.swift | 11 +- .../PortForwardingEditorPolicy.swift | 8 +- .../SSH/PortForwardingController.swift | 30 +- Memory/ARCHITECTURE.md | 22 +- .../HostEditor/MobileHostEditor.swift | 16 +- .../Ports/MobileForwardingEditor.swift | 17 +- TFI/UI/Components/MobileEditorSheet.swift | 18 +- TFITests/MobileVaultFeatureTests.swift | 16 + TFM/Features/HostEditor/HostEditorView.swift | 9 +- .../Ports/PortForwardingEditorState.swift | 7 +- TFM/Features/Ports/PortForwardingView.swift | 71 +- TFMTests/ArchitectureBoundaryTests.swift | 4 + TFMTests/PortForwardingEditorTests.swift | 35 +- TFMTests/PortForwardingFeatureTests.swift | 38 ++ 15 files changed, 630 insertions(+), 312 deletions(-) diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index c1580d72..9526bb21 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -1,167 +1,6 @@ { "sourceLanguage" : "en", "strings" : { - "Copy Cancelled" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { "state" : "translated", "value" : "复制已取消" } - } - } - }, - "Copy Failed" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { "state" : "translated", "value" : "复制失败" } - } - } - }, - "Copy Paused" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { "state" : "translated", "value" : "复制已暂停" } - } - } - }, - "Copy Queued" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { "state" : "translated", "value" : "复制已排队" } - } - } - }, - "Copying" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { "state" : "translated", "value" : "正在复制" } - } - } - }, - "Date" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { "state" : "translated", "value" : "日期" } - } - } - }, - "Download Cancelled" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { "state" : "translated", "value" : "下载已取消" } - } - } - }, - "Download Failed" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { "state" : "translated", "value" : "下载失败" } - } - } - }, - "Download Paused" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { "state" : "translated", "value" : "下载已暂停" } - } - } - }, - "Download Queued" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { "state" : "translated", "value" : "下载已排队" } - } - } - }, - "Downloaded" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { "state" : "translated", "value" : "下载完成" } - } - } - }, - "Downloading" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { "state" : "translated", "value" : "正在下载" } - } - } - }, - "Longer than %@." : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { "state" : "translated", "value" : "超过%@。" } - } - } - }, - "Share" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { "state" : "translated", "value" : "分享" } - } - } - }, - "The copied SFTP items are invalid." : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { "state" : "translated", "value" : "已复制的SFTP项目无效。" } - } - } - }, - "The file is larger than %@." : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { "state" : "translated", "value" : "文件大于%@。" } - } - } - }, - "The normalized image is larger than %@." : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { "state" : "translated", "value" : "处理后的图像大于%@。" } - } - } - }, - "Upload Cancelled" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { "state" : "translated", "value" : "上传已取消" } - } - } - }, - "Upload Failed" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { "state" : "translated", "value" : "上传失败" } - } - } - }, - "Upload Paused" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { "state" : "translated", "value" : "上传已暂停" } - } - } - }, - "Upload Queued" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { "state" : "translated", "value" : "上传已排队" } - } - } - }, - "Uploaded" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { "state" : "translated", "value" : "上传完成" } - } - } - }, - "Uploading" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { "state" : "translated", "value" : "正在上传" } - } - } - }, "" : { "localizations" : { "zh-Hans" : { @@ -182,6 +21,18 @@ } } }, + ".." : { + "comment" : "A parent directory.", + "isCommentAutoGenerated" : true, + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上级目录" + } + } + } + }, ".ssh Folder" : { "localizations" : { "zh-Hans" : { @@ -269,6 +120,7 @@ } }, "%@ → listen %@ → This Mac → %@:%@" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -451,6 +303,7 @@ } }, "%@%@ of %@ (%lld%%)" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -467,6 +320,7 @@ } }, "%@%@ tokens" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -1552,6 +1406,16 @@ } } }, + "Add Forwarding" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加转发" + } + } + } + }, "Add Header" : { "localizations" : { "zh-Hans" : { @@ -1593,16 +1457,6 @@ } } }, - "Add Forwarding" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "添加转发" - } - } - } - }, "Add Provider" : { "localizations" : { "zh-Hans" : { @@ -1700,6 +1554,7 @@ } }, "Advanced" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -3989,6 +3844,26 @@ } } }, + "Copy Cancelled" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "复制已取消" + } + } + } + }, + "Copy Failed" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "复制失败" + } + } + } + }, "Copy Fingerprint" : { "localizations" : { "zh-Hans" : { @@ -4019,6 +3894,16 @@ } } }, + "Copy Paused" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "复制已暂停" + } + } + } + }, "Copy Public Key" : { "localizations" : { "zh-Hans" : { @@ -4029,6 +3914,16 @@ } } }, + "Copy Queued" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "复制已排队" + } + } + } + }, "Copy SSH Command" : { "localizations" : { "zh-Hans" : { @@ -4049,6 +3944,16 @@ } } }, + "Copying" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在复制" + } + } + } + }, "Cores" : { "extractionState" : "stale", "localizations" : { @@ -4294,6 +4199,16 @@ } } }, + "Date" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "日期" + } + } + } + }, "Date Modified" : { "comment" : "A column header for the date modified column.", "isCommentAutoGenerated" : true, @@ -4733,6 +4648,7 @@ } }, "Destination Host from SSH Host" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -4743,6 +4659,7 @@ } }, "Destination Host on This Mac" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -4753,6 +4670,7 @@ } }, "Destination Port" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -4887,6 +4805,30 @@ } } }, + "Disable" : { + "comment" : "A button that disables a forwarding rule.", + "isCommentAutoGenerated" : true, + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "停用" + } + } + } + }, + "Disable All" : { + "comment" : "A button that disables all running port forwarding rules.", + "isCommentAutoGenerated" : true, + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "全部停用" + } + } + } + }, "Disabled" : { "localizations" : { "zh-Hans" : { @@ -5123,6 +5065,66 @@ } } }, + "Download Cancelled" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下载已取消" + } + } + } + }, + "Download Failed" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下载失败" + } + } + } + }, + "Download Paused" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下载已暂停" + } + } + } + }, + "Download Queued" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下载已排队" + } + } + } + }, + "Downloaded" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下载完成" + } + } + } + }, + "Downloading" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在下载" + } + } + } + }, "Draft" : { "comment" : "Text for a card chip indicating that a skill is still in draft form.", "isCommentAutoGenerated" : true, @@ -5333,6 +5335,16 @@ } } }, + "Edit Forwarding" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编辑转发" + } + } + } + }, "Edit Group" : { "localizations" : { "zh-Hans" : { @@ -5416,16 +5428,6 @@ } } }, - "Edit Forwarding" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "编辑转发" - } - } - } - }, "Edit Snippet" : { "localizations" : { "zh-Hans" : { @@ -5517,6 +5519,18 @@ } } }, + "Enable All" : { + "comment" : "A button that enables all forwarding rules.", + "isCommentAutoGenerated" : true, + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "全部启用" + } + } + } + }, "Enable or disable MCP servers" : { "localizations" : { "zh-Hans" : { @@ -5547,6 +5561,18 @@ } } }, + "Enabled is saved immediately on this device. Unsaved forwarding changes apply after the host is saved." : { + "comment" : "A description of how changes to forwarding rules are saved.", + "isCommentAutoGenerated" : true, + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用状态会立即保存在此设备上。未保存的转发更改将在主机保存后应用。" + } + } + } + }, "Encoder Utilization" : { "extractionState" : "stale", "localizations" : { @@ -6373,6 +6399,7 @@ } }, "Forwarding uses a background SSH connection and stops when Termind quits. Save changes before starting a rule." : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -8010,6 +8037,7 @@ } }, "Listen Address on SSH Host" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -8020,6 +8048,7 @@ } }, "Listen Address on This Mac" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -8030,6 +8059,7 @@ } }, "Listen Port" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -8154,7 +8184,18 @@ } } }, + "Longer than %@." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "超过%@。" + } + } + } + }, "Longer than %lld KB." : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -8340,16 +8381,6 @@ } } }, - "Monitored servers, pause state, device selections, and card order." : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "监控的服务器、暂停状态、设备选择和卡片顺序。" - } - } - } - }, "Microphone Unavailable" : { "extractionState" : "stale", "localizations" : { @@ -8521,6 +8552,16 @@ } } }, + "Monitored servers, pause state, device selections, and card order." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "监控的服务器、暂停状态、设备选择和卡片顺序。" + } + } + } + }, "More" : { "localizations" : { "zh-Hans" : { @@ -8720,6 +8761,16 @@ } } }, + "New Forwarding" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "新建转发" + } + } + } + }, "New Group" : { "localizations" : { "zh-Hans" : { @@ -8760,16 +8811,6 @@ } } }, - "New Forwarding" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "新建转发" - } - } - } - }, "New Provider" : { "localizations" : { "zh-Hans" : { @@ -8810,6 +8851,16 @@ } } }, + "Next Location" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下一步" + } + } + } + }, "Next Match" : { "extractionState" : "manual", "localizations" : { @@ -8821,16 +8872,6 @@ } } }, - "Next Location" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "下一步" - } - } - } - }, "Next step" : { "localizations" : { "zh-Hans" : { @@ -10670,6 +10711,16 @@ } } }, + "Previous Location" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上一步" + } + } + } + }, "Previous Match" : { "extractionState" : "manual", "localizations" : { @@ -10681,16 +10732,6 @@ } } }, - "Previous Location" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "上一步" - } - } - } - }, "Previous Workspace" : { "localizations" : { "zh-Hans" : { @@ -11999,6 +12040,7 @@ } }, "Route Preview" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -12247,6 +12289,7 @@ } }, "Save required" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -12257,6 +12300,7 @@ } }, "Save the host and forwarding changes before starting this rule." : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -12742,6 +12786,7 @@ } }, "Served from cache" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -12853,6 +12898,16 @@ } } }, + "Share" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "分享" + } + } + } + }, "Shell Integration" : { "localizations" : { "zh-Hans" : { @@ -13095,6 +13150,7 @@ } }, "Sign in to iCloud to sync Metrics." : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -13317,6 +13373,7 @@ } }, "SOCKS Listen Address on This Mac" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -13327,6 +13384,7 @@ } }, "SOCKS Listen Port" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -13348,6 +13406,7 @@ } }, "SOCKS5 %@ → %@ → Requested Target" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -13369,6 +13428,7 @@ } }, "SOCKS5 on This Mac → SSH Host → Requested Target" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -13580,6 +13640,7 @@ } }, "SSH Host" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -13601,6 +13662,7 @@ } }, "SSH Host → This Mac → Target" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -13649,6 +13711,7 @@ } }, "Start" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -13691,6 +13754,7 @@ } }, "Start All" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -13833,6 +13897,7 @@ } }, "Stop All" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -14460,6 +14525,16 @@ } } }, + "The copied SFTP items are invalid." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已复制的SFTP项目无效。" + } + } + } + }, "The current terminal input is too large to generate." : { "extractionState" : "stale", "localizations" : { @@ -14535,6 +14610,17 @@ } } }, + "The file is larger than %@." : { + "extractionState" : "stale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文件大于%@。" + } + } + } + }, "The file is larger than 25 MB." : { "extractionState" : "stale", "localizations" : { @@ -14681,6 +14767,7 @@ } }, "The iCloud Metrics record %@ is invalid." : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -14883,6 +14970,17 @@ } } }, + "The normalized image is larger than %@." : { + "extractionState" : "stale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "处理后的图像大于%@。" + } + } + } + }, "The normalized image is larger than 5 MB." : { "extractionState" : "stale", "localizations" : { @@ -15201,6 +15299,7 @@ } }, "The server closed the SSH connection while waiting for host-key approval. Retry to connect using the accepted key." : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -15426,6 +15525,7 @@ } }, "This address may expose the listener to other devices." : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -15536,6 +15636,18 @@ } } }, + "This forwarding will start after the host is saved." : { + "comment" : "A description of the state of a forwarding that is waiting for the host to be saved.", + "isCommentAutoGenerated" : true, + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此转发将在主机保存后启动。" + } + } + } + }, "This group contains %@ and %@. Nested groups will be deleted." : { "localizations" : { "en" : { @@ -15590,6 +15702,7 @@ } }, "This Mac → SSH Host → Target" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -15609,6 +15722,17 @@ } } }, + "This Metrics record requires sync format %lld." : { + "extractionState" : "stale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此监控记录需要同步格式%lld。" + } + } + } + }, "This package contains 1 snippet." : { "localizations" : { "zh-Hans" : { @@ -15700,16 +15824,6 @@ } } }, - "This Metrics record requires sync format %lld." : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "此监控记录需要同步格式%lld。" - } - } - } - }, "This settings record requires sync format %lld." : { "extractionState" : "stale", "localizations" : { @@ -16230,6 +16344,7 @@ } }, "Unable to store the token." : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -16522,6 +16637,16 @@ } } }, + "Upload Cancelled" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上传已取消" + } + } + } + }, "Upload Failed" : { "localizations" : { "zh-Hans" : { @@ -16544,6 +16669,26 @@ } } }, + "Upload Paused" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上传已暂停" + } + } + } + }, + "Upload Queued" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上传已排队" + } + } + } + }, "Upload to %@" : { "localizations" : { "zh-Hans" : { @@ -16554,6 +16699,26 @@ } } }, + "Uploaded" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上传完成" + } + } + } + }, + "Uploading" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在上传" + } + } + } + }, "Uptime" : { "extractionState" : "stale", "localizations" : { @@ -16576,6 +16741,7 @@ } }, "Usage" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -16953,6 +17119,18 @@ } } }, + "Waiting for Save" : { + "comment" : "A label displayed in a forwarding's row that indicates that the forwarding is waiting for the host to be saved.", + "isCommentAutoGenerated" : true, + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "等待保存" + } + } + } + }, "Waiting for you…" : { "extractionState" : "stale", "localizations" : { diff --git a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift index 9d194b28..924b6aea 100644 --- a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift @@ -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 } diff --git a/Frameworks/TermindApplication/FeatureModels/PortForwardingEditorPolicy.swift b/Frameworks/TermindApplication/FeatureModels/PortForwardingEditorPolicy.swift index 09fecdef..95983f0a 100644 --- a/Frameworks/TermindApplication/FeatureModels/PortForwardingEditorPolicy.swift +++ b/Frameworks/TermindApplication/FeatureModels/PortForwardingEditorPolicy.swift @@ -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 } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift b/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift index fb4ec4d4..b784891b 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift @@ -53,6 +53,10 @@ public final class PortForwardingController { private let enabledDefaultsKey: String public private(set) var states: [PortForwardingRule.ID: PortForwardingRuntimeState] = [:] private var enabled: Set + /// 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 = [] private var desired: Set = [] private var sessions: [Host.ID: SSHForwardingSession] = [:] private var connectionTasks: [Host.ID: Task] = [:] @@ -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 } diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index f0ebccd1..34f44c5b 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -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) diff --git a/TFI/Features/HostEditor/MobileHostEditor.swift b/TFI/Features/HostEditor/MobileHostEditor.swift index a9b780ac..4c1908e9 100644 --- a/TFI/Features/HostEditor/MobileHostEditor.swift +++ b/TFI/Features/HostEditor/MobileHostEditor.swift @@ -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 { diff --git a/TFI/Features/Ports/MobileForwardingEditor.swift b/TFI/Features/Ports/MobileForwardingEditor.swift index cfc72da6..9d0c28d8 100644 --- a/TFI/Features/Ports/MobileForwardingEditor.swift +++ b/TFI/Features/Ports/MobileForwardingEditor.swift @@ -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]) } + } } diff --git a/TFI/UI/Components/MobileEditorSheet.swift b/TFI/UI/Components/MobileEditorSheet.swift index 30e8187a..5490668b 100644 --- a/TFI/UI/Components/MobileEditorSheet.swift +++ b/TFI/UI/Components/MobileEditorSheet.swift @@ -17,6 +17,7 @@ struct MobileEditorSheet: 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: 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: 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: View { .interactiveDismissDisabled(isDirty || isSaving) .onDisappear { saveTask?.cancel() } } + + private func cancel() { + onCancel() + dismiss() + } } diff --git a/TFITests/MobileVaultFeatureTests.swift b/TFITests/MobileVaultFeatureTests.swift index b49ce572..506e886b 100644 --- a/TFITests/MobileVaultFeatureTests.swift +++ b/TFITests/MobileVaultFeatureTests.swift @@ -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) diff --git a/TFM/Features/HostEditor/HostEditorView.swift b/TFM/Features/HostEditor/HostEditorView.swift index 3931c140..dfa80617 100644 --- a/TFM/Features/HostEditor/HostEditorView.swift +++ b/TFM/Features/HostEditor/HostEditorView.swift @@ -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) diff --git a/TFM/Features/Ports/PortForwardingEditorState.swift b/TFM/Features/Ports/PortForwardingEditorState.swift index ea49dde0..d22dd0ea 100644 --- a/TFM/Features/Ports/PortForwardingEditorState.swift +++ b/TFM/Features/Ports/PortForwardingEditorState.swift @@ -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, diff --git a/TFM/Features/Ports/PortForwardingView.swift b/TFM/Features/Ports/PortForwardingView.swift index 32adf30f..e3637be5 100644 --- a/TFM/Features/Ports/PortForwardingView.swift +++ b/TFM/Features/Ports/PortForwardingView.swift @@ -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) diff --git a/TFMTests/ArchitectureBoundaryTests.swift b/TFMTests/ArchitectureBoundaryTests.swift index 064397c7..0a8e7e7e 100644 --- a/TFMTests/ArchitectureBoundaryTests.swift +++ b/TFMTests/ArchitectureBoundaryTests.swift @@ -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)) diff --git a/TFMTests/PortForwardingEditorTests.swift b/TFMTests/PortForwardingEditorTests.swift index 3bac4cda..38ed1b0e 100644 --- a/TFMTests/PortForwardingEditorTests.swift +++ b/TFMTests/PortForwardingEditorTests.swift @@ -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) } } diff --git a/TFMTests/PortForwardingFeatureTests.swift b/TFMTests/PortForwardingFeatureTests.swift index a78e1473..6aa1eba7 100644 --- a/TFMTests/PortForwardingFeatureTests.swift +++ b/TFMTests/PortForwardingFeatureTests.swift @@ -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)"