From 7cf04544cecdaf6f3efaad23bb69e355b2cb7985 Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Wed, 14 Oct 2026 12:27:25 +0800 Subject: [PATCH] feat(ng2): add Nushell and Windows proxy environment commands --- GLOSSARY.md | 2 +- .../ProxyControlAdapters.swift | 52 +++++++----- .../ProxyControlWorkflow.swift | 46 ++++++++++- .../App/Presentation/ErrorPresentation.swift | 5 ++ .../App/UI/Home/HomeView+Cards.swift | 35 ++++---- .../ProxyCommandAddressWorkflowTests.swift | 47 +++++++++++ .../ProxyControlWorkflowTests.swift | 17 +++- .../Tests/Runtime/SeparateListenerTests.swift | 51 ++++++++++++ .../terminal-proxy-command-shell-formats.md | 82 +++++++++++++++++++ 9 files changed, 288 insertions(+), 49 deletions(-) create mode 100644 docs/design/terminal-proxy-command-shell-formats.md diff --git a/GLOSSARY.md b/GLOSSARY.md index c7a1e31b..0db42f70 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -219,7 +219,7 @@ _避免用词_:监听范围(Listen scope)。 仅设置本机 HTTP/HTTPS 代理环境变量的终端命令,目标为本地监听入口。 **终端代理环境命令(Terminal proxy environment commands)**: -适用于 zsh/bash 和 fish 的命令,通过 HTTP 与 SOCKS 入口设置代理环境变量,并设置本地主机例外。它只影响遵循这些环境变量的子进程。 +按目标 shell 语法设置 HTTP 与 SOCKS 代理环境变量及本地主机例外的命令。它只影响遵循这些环境变量的子进程。 **终端命令地址(Terminal command address)**: 复制的终端代理环境命令中,HTTP 与 SOCKS 入口共同使用的 IP 地址。它是当前应用会话内的选择,与监听设置和系统代理设置独立。 diff --git a/ShadowsocksX-NG2/App/Application/ProxyControlWorkflow/ProxyControlAdapters.swift b/ShadowsocksX-NG2/App/Application/ProxyControlWorkflow/ProxyControlAdapters.swift index ee05f866..adfe7735 100644 --- a/ShadowsocksX-NG2/App/Application/ProxyControlWorkflow/ProxyControlAdapters.swift +++ b/ShadowsocksX-NG2/App/Application/ProxyControlWorkflow/ProxyControlAdapters.swift @@ -108,8 +108,8 @@ extension HTTPExportCapability { } /// 终端代理环境变量命令的唯一派生点:已保存监听事实 + 生效 -/// 命令地址 → 两种 shell 的可复制命令。HTTP/HTTPS 用已保存 HTTP 端口, -/// SOCKS 用已保存 SOCKS 端口;变量集合、小写变量名、单引号转义、socks5 +/// 命令地址 → 各种 shell 的可复制命令。HTTP/HTTPS 用已保存 HTTP 端口, +/// SOCKS 用已保存 SOCKS 端口;变量集合、小写变量名、socks5 /// 协议与 no_proxy 内容保持不变;IPv6 URL 使用方括号。 extension TerminalProxyEnvironmentCommands { init( @@ -140,30 +140,38 @@ extension TerminalProxyEnvironmentCommands { } userInfo = "\(username):\(encodedPassword)@" } catch { - self.init(zshBash: "", fish: "", failure: .credentialUnreadable) + self.init( + zshBash: "", fish: "", nushell: "", windowsCMD: "", windowsPowerShell: "", + failure: .credentialUnreadable) return } } - let httpEndpoint = "'http://\(userInfo)\(urlHost):\(httpPort)'" - let socksEndpoint = "'socks5://\(userInfo)\(urlHost):\(socksPort)'" - let noProxy = "'localhost,127.0.0.1,::1,.local'" - let zshBash = - [ - "export http_proxy=\(httpEndpoint)", - "export https_proxy=\(httpEndpoint)", - "export all_proxy=\(socksEndpoint)", - "export no_proxy=\(noProxy)", - ].joined(separator: "; ") + ";" - let fish = - [ - "set -gx http_proxy \(httpEndpoint)", - "set -gx https_proxy \(httpEndpoint)", - "set -gx all_proxy \(socksEndpoint)", - "set -gx no_proxy \(noProxy)", - ].joined(separator: "; ") + ";" + let httpEndpoint = "http://\(userInfo)\(urlHost):\(httpPort)" + let socksEndpoint = "socks5://\(userInfo)\(urlHost):\(socksPort)" + let variables = [ + (name: "http_proxy", value: httpEndpoint), + (name: "https_proxy", value: httpEndpoint), + (name: "all_proxy", value: socksEndpoint), + (name: "no_proxy", value: "localhost,127.0.0.1,::1,.local"), + ] + let windowsAssignments = variables.map { "set \"\($0.name)=\($0.value)\"" } + // CMD 会把 %XX% 当作环境变量展开。用交互 FOR 变量插入字面百分号, + // 不写临时环境变量;逐行赋值使最长允许凭据仍不超过单行长度限制。 + let windowsCMD = + userInfo.contains("%") + ? windowsAssignments.map { assignment in + assignment.contains("%") + ? "for %P in (\"%\") do \(assignment.replacingOccurrences(of: "%", with: "%~P"))" + : assignment + }.joined(separator: "\r\n") + : windowsAssignments.joined(separator: " & ") self.init( - zshBash: zshBash, - fish: fish) + zshBash: variables.map { "export \($0.name)='\($0.value)'" }.joined(separator: "; ") + ";", + fish: variables.map { "set -gx \($0.name) '\($0.value)'" }.joined(separator: "; ") + ";", + nushell: variables.map { "$env.\($0.name) = '\($0.value)'" }.joined(separator: "; ") + ";", + windowsCMD: windowsCMD, + windowsPowerShell: + variables.map { "$env:\($0.name) = '\($0.value)'" }.joined(separator: "; ") + ";") } } diff --git a/ShadowsocksX-NG2/App/Application/ProxyControlWorkflow/ProxyControlWorkflow.swift b/ShadowsocksX-NG2/App/Application/ProxyControlWorkflow/ProxyControlWorkflow.swift index 259ce209..ddc3cd0f 100644 --- a/ShadowsocksX-NG2/App/Application/ProxyControlWorkflow/ProxyControlWorkflow.swift +++ b/ShadowsocksX-NG2/App/Application/ProxyControlWorkflow/ProxyControlWorkflow.swift @@ -13,14 +13,34 @@ struct HTTPExportCapability: Equatable, Sendable { /// 首页可复制的 shell 环境变量设置命令。不同 shell 的语法由 workflow 一次投影, /// 呈现层只选择格式并执行剪贴板副作用。 -enum TerminalCommandFailure: Error, Equatable, Sendable { case credentialUnreadable } +enum TerminalCommandFailure: Error, Equatable, Sendable { + case credentialUnreadable + case addressUnavailable +} struct TerminalProxyEnvironmentCommands: Equatable, Sendable { let zshBash: String let fish: String + let nushell: String + let windowsCMD: String + let windowsPowerShell: String var failure: TerminalCommandFailure? } +enum TerminalCommandShell: CaseIterable, Hashable, Sendable { + case zshBash, fish, nushell, windowsCMD, windowsPowerShell + + func command(from commands: TerminalProxyEnvironmentCommands) -> String { + switch self { + case .zshBash: commands.zshBash + case .fish: commands.fish + case .nushell: commands.nushell + case .windowsCMD: commands.windowsCMD + case .windowsPowerShell: commands.windowsPowerShell + } + } +} + /// 活动目标安全事实:存在性 + 可展示路径摘要。身份是不透明 /// `NodeID`(级联勾选用);不含凭据、原始配置 URL 或目录树结构。路径摘要 /// 是目录 projection 的显示名(导入缺名的服务器叶子以地址端口为名称,与目录树 @@ -65,9 +85,15 @@ struct ProxyControlSnapshot: Equatable, Sendable { let httpExport: HTTPExportCapability /// 首页命令地址选择器:可见性、候选与生效选择。 let commandAddressPicker: TerminalCommandAddressPicker - /// 首页终端代理环境变量命令,分别适用于 zsh/bash 与 fish;端点地址为生效 + /// 首页终端代理环境变量命令;端点地址为生效 /// 命令地址选择,端口取已保存监听事实。 let terminalProxyEnvironmentCommands: TerminalProxyEnvironmentCommands + + /// Windows 格式供其他设备使用,只在局域网入口与网络地址都有效时提供。 + var terminalCommandShells: [TerminalCommandShell] { + commandAddressPicker.isVisible && commandAddressPicker.selected.address != "127.0.0.1" + ? TerminalCommandShell.allCases : [.zshBash, .fish, .nushell] + } } // MARK: - 适配缝 @@ -248,13 +274,25 @@ final class ProxyControlWorkflow: ObservableObject { } /// 刷新命令地址候选并整体重发布(进入首页与复制前调用;网络变化经接口 - /// 事实变化通知自动刷新)。返回刷新后的两种 shell 命令供剪贴板副作用 - /// 使用——失效选择已在本次观察中回退,提示投影与返回值一致。 + /// 事实变化通知自动刷新)。失效选择回退到本地地址。 @discardableResult func refreshTerminalCommands() -> TerminalProxyEnvironmentCommands { republish().terminalProxyEnvironmentCommands } + /// 复制前核实按钮呈现时的 IP 仍有效;回退只更新界面,不改写剪贴板。 + /// 显式传入原地址身份,防止网络通知先回退 snapshot 后复制旧按钮。 + func prepareTerminalCommandsForCopy(expectedAddress: TerminalCommandAddressIdentity) throws + -> TerminalProxyEnvironmentCommands + { + let commands = refreshTerminalCommands() + guard snapshot.commandAddressPicker.selected.identity == expectedAddress else { + throw TerminalCommandFailure.addressUnavailable + } + if let failure = commands.failure { throw failure } + return commands + } + // MARK: - 整体观察(implementation,UI 不可见) /// 一次 observation 内整体拼装 snapshot;任何单一事实变化后整体替换, diff --git a/ShadowsocksX-NG2/App/Presentation/ErrorPresentation.swift b/ShadowsocksX-NG2/App/Presentation/ErrorPresentation.swift index cd6c1518..3d72ad1b 100644 --- a/ShadowsocksX-NG2/App/Presentation/ErrorPresentation.swift +++ b/ShadowsocksX-NG2/App/Presentation/ErrorPresentation.swift @@ -67,6 +67,11 @@ extension AppPresentation { return systemProxy(SystemProxyOperationFailure(error)) case let error as TextClipboardFailure: return textClipboard(error) + case let error as TerminalCommandFailure: + switch error { + case .credentialUnreadable: return "局域网身份验证密码不可读,请重试。" + case .addressUnavailable: return "命令地址已失效,请重新选择 IP。" + } case let error as ImageClipboardFailure: return imageClipboard(error) case let error as QrImageSaveFailure: diff --git a/ShadowsocksX-NG2/App/UI/Home/HomeView+Cards.swift b/ShadowsocksX-NG2/App/UI/Home/HomeView+Cards.swift index 9d4f6f4e..9135b202 100644 --- a/ShadowsocksX-NG2/App/UI/Home/HomeView+Cards.swift +++ b/ShadowsocksX-NG2/App/UI/Home/HomeView+Cards.swift @@ -1,7 +1,7 @@ import SwiftUI /// 「复制代理环境变量设置命令」卡:按所选 shell 格式复制 HTTP 与 SOCKS 环境变量。 -/// 局域网开启时以单选下拉框选择命令地址,两种 shell +/// 局域网开启时以单选下拉框选择命令地址,各种 shell /// 的全部代理端点共用该选择;局域网关闭时隐藏下拉框并使用本地入口。 struct TerminalProxyEnvironmentCard: View { @ObservedObject var control: ProxyControlWorkflow @@ -59,8 +59,9 @@ struct TerminalProxyEnvironmentCard: View { Text("局域网身份验证密码不可读,请重试。") .font(.caption).foregroundStyle(.red) } - commandButton(for: .zshBash) - commandButton(for: .fish) + ForEach(control.snapshot.terminalCommandShells, id: \.self) { shell in + commandButton(for: shell) + } } .padding(.top, 6) } @@ -84,8 +85,9 @@ struct TerminalProxyEnvironmentCard: View { private func commandButton(for shell: TerminalCommandShell) -> some View { let isCopied = copiedShell == shell + let expectedAddress = control.snapshot.commandAddressPicker.selected.identity return Button { - copy(shell) + copy(shell, expectedAddress: expectedAddress) } label: { HStack(spacing: 10) { Image(systemName: isCopied ? "checkmark" : "doc.on.doc") @@ -109,12 +111,10 @@ struct TerminalProxyEnvironmentCard: View { .help(shell.command(from: control.snapshot.terminalProxyEnvironmentCommands)) } - private func copy(_ shell: TerminalCommandShell) { - // 复制前刷新:失效选择回退后的命令即剪贴板内容,与按钮 - // 提示保持同一选择。 - let commands = control.refreshTerminalCommands() + private func copy(_ shell: TerminalCommandShell, expectedAddress: TerminalCommandAddressIdentity) + { do { - if let failure = commands.failure { throw failure } + let commands = try control.prepareTerminalCommandsForCopy(expectedAddress: expectedAddress) try clipboard.write(shell.command(from: commands)) feedbackTask?.cancel() copiedShell = shell @@ -133,21 +133,14 @@ struct TerminalProxyEnvironmentCard: View { } } -private enum TerminalCommandShell: Equatable { - case zshBash - case fish - - var title: String { +extension TerminalCommandShell { + fileprivate var title: String { switch self { case .zshBash: "zsh / bash" case .fish: "fish" - } - } - - func command(from commands: TerminalProxyEnvironmentCommands) -> String { - switch self { - case .zshBash: commands.zshBash - case .fish: commands.fish + case .nushell: "Nushell" + case .windowsCMD: "Windows:CMD" + case .windowsPowerShell: "Windows:PowerShell" } } } diff --git a/ShadowsocksX-NG2/Tests/ProxyControl/ProxyCommandAddressWorkflowTests.swift b/ShadowsocksX-NG2/Tests/ProxyControl/ProxyCommandAddressWorkflowTests.swift index 1345b169..41536237 100644 --- a/ShadowsocksX-NG2/Tests/ProxyControl/ProxyCommandAddressWorkflowTests.swift +++ b/ShadowsocksX-NG2/Tests/ProxyControl/ProxyCommandAddressWorkflowTests.swift @@ -5,6 +5,53 @@ import XCTest @MainActor struct LANCommandEnablementTests { + @Test(arguments: [false, true]) + func copyRejectsLostIPIncludingAButtonRenderedBeforeNetworkNotification( + notificationDelivered: Bool + ) { + let runtime = FakeProxyRuntime() + let interfaces = FakeLocalInterfaceFacts() + runtime.listenFacts.lan.isEnabled = true + interfaces.interfaces = [FakeLocalInterfaceFacts.wifi(addresses: [.ipv4("192.0.2.2")])] + let workflow = ProxyControlWorkflow( + runtime: runtime, targetFacts: FakeTargetFacts(), interfaceFacts: interfaces) + let address = workflow.snapshot.commandAddressPicker.candidates[1] + workflow.selectCommandAddress(address) + interfaces.interfaces = [] + if notificationDelivered { interfaces.emitChange() } + #expect(throws: TerminalCommandFailure.addressUnavailable) { + try workflow.prepareTerminalCommandsForCopy(expectedAddress: address.identity) + } + #expect(workflow.snapshot.commandAddressPicker.selected.address == "127.0.0.1") + #expect(workflow.snapshot.terminalCommandShells == [.zshBash, .fish, .nushell]) + #expect(runtime.agentCommands.isEmpty) + #expect(runtime.systemProxyCommands.isEmpty) + } + + @Test func windowsFormatsRequireEnabledLANAndSelectedNetworkIPWithoutRequiringRunningProxy() { + let runtime = FakeProxyRuntime() + let interfaces = FakeLocalInterfaceFacts() + let workflow = ProxyControlWorkflow( + runtime: runtime, targetFacts: FakeTargetFacts(), interfaceFacts: interfaces) + #expect(workflow.snapshot.terminalCommandShells == [.zshBash, .fish, .nushell]) + runtime.listenFacts.lan.isEnabled = true + interfaces.interfaces = [FakeLocalInterfaceFacts.wifi(addresses: [.ipv4("192.0.2.2")])] + runtime.emitChange() + #expect(workflow.snapshot.terminalCommandShells == [.zshBash, .fish, .nushell]) + workflow.selectCommandAddress(workflow.snapshot.commandAddressPicker.candidates[1]) + runtime.runtimeFacts = ProxyRuntimeFacts(status: .off, isOn: false) + runtime.emitChange() + #expect( + workflow.snapshot.terminalCommandShells == [ + .zshBash, .fish, .nushell, .windowsCMD, .windowsPowerShell, + ]) + runtime.listenFacts.lan.isEnabled = false + runtime.emitChange() + #expect(workflow.snapshot.terminalCommandShells == [.zshBash, .fish, .nushell]) + #expect(runtime.agentCommands.isEmpty) + #expect(runtime.systemProxyCommands.isEmpty) + } + @Test func disablingLANRetainsIPv6ModeAndFallsBackFromSelectedNetworkAddress() { let runtime = FakeProxyRuntime() let interfaces = FakeLocalInterfaceFacts() diff --git a/ShadowsocksX-NG2/Tests/ProxyControl/ProxyControlWorkflowTests.swift b/ShadowsocksX-NG2/Tests/ProxyControl/ProxyControlWorkflowTests.swift index fd53ba8e..eaa86242 100644 --- a/ShadowsocksX-NG2/Tests/ProxyControl/ProxyControlWorkflowTests.swift +++ b/ShadowsocksX-NG2/Tests/ProxyControl/ProxyControlWorkflowTests.swift @@ -115,7 +115,22 @@ final class ProxyControlWorkflowTests: XCTestCase { "set -gx http_proxy 'http://127.0.0.1:11087'; " + "set -gx https_proxy 'http://127.0.0.1:11087'; " + "set -gx all_proxy 'socks5://127.0.0.1:11086'; " - + "set -gx no_proxy 'localhost,127.0.0.1,::1,.local';") + + "set -gx no_proxy 'localhost,127.0.0.1,::1,.local';", + nushell: + "$env.http_proxy = 'http://127.0.0.1:11087'; " + + "$env.https_proxy = 'http://127.0.0.1:11087'; " + + "$env.all_proxy = 'socks5://127.0.0.1:11086'; " + + "$env.no_proxy = 'localhost,127.0.0.1,::1,.local';", + windowsCMD: + "set \"http_proxy=http://127.0.0.1:11087\" & " + + "set \"https_proxy=http://127.0.0.1:11087\" & " + + "set \"all_proxy=socks5://127.0.0.1:11086\" & " + + "set \"no_proxy=localhost,127.0.0.1,::1,.local\"", + windowsPowerShell: + "$env:http_proxy = 'http://127.0.0.1:11087'; " + + "$env:https_proxy = 'http://127.0.0.1:11087'; " + + "$env:all_proxy = 'socks5://127.0.0.1:11086'; " + + "$env:no_proxy = 'localhost,127.0.0.1,::1,.local';") // MARK: - 整体 snapshot diff --git a/ShadowsocksX-NG2/Tests/Runtime/SeparateListenerTests.swift b/ShadowsocksX-NG2/Tests/Runtime/SeparateListenerTests.swift index ee2abca9..c290f8c1 100644 --- a/ShadowsocksX-NG2/Tests/Runtime/SeparateListenerTests.swift +++ b/ShadowsocksX-NG2/Tests/Runtime/SeparateListenerTests.swift @@ -4,6 +4,57 @@ import Testing @testable import ShadowsocksX_NG2 struct SeparateListenerTests { + @Test func newFormatsPreserveEncodedLANCredentialsAndBracketIPv6() { + var settings = SslocalListenSettings(listenerMode: .allIPv4AndIPv6Interfaces) + settings.lan.authenticationEnabled = true + settings.lan.username = "u @" + let commands = TerminalProxyEnvironmentCommands( + listen: RuntimeListenFacts(listen: settings), + commandAddress: TerminalCommandAddress( + bsdName: "en0", address: "2001:db8::2", displayName: "Wi-Fi"), + password: { "p':@/$%!\"&^()" }) + let http = "http://u%20%40:p%27%3A%40%2F%24%25%21%22%26%5E%28%29@[2001:db8::2]:11089" + #expect(commands.nushell.contains("$env.http_proxy = '\(http)'")) + #expect(commands.windowsPowerShell.contains("$env:http_proxy = '\(http)'")) + let cmdUserInfo = "u%~P20%~P40:p%~P27%~P3A%~P40%~P2F%~P24%~P25%~P21%~P22%~P26%~P5E%~P28%~P29" + #expect( + commands.windowsCMD + == "for %P in (\"%\") do set \"http_proxy=http://\(cmdUserInfo)@[2001:db8::2]:11089\"\r\n" + + "for %P in (\"%\") do set \"https_proxy=http://\(cmdUserInfo)@[2001:db8::2]:11089\"\r\n" + + "for %P in (\"%\") do set \"all_proxy=socks5://\(cmdUserInfo)@[2001:db8::2]:11088\"\r\n" + + "set \"no_proxy=localhost,127.0.0.1,::1,.local\"") + #expect(commands.failure == nil) + } + + @Test func windowsFormatsSetFourSessionVariablesUsingLANPorts() { + let commands = TerminalProxyEnvironmentCommands( + listen: RuntimeListenFacts(listen: SslocalListenSettings(listenerMode: .allIPv4Interfaces)), + commandAddress: TerminalCommandAddress( + bsdName: "en0", address: "192.0.2.2", displayName: "Wi-Fi")) + #expect( + commands.windowsCMD == "set \"http_proxy=http://192.0.2.2:11089\" & " + + "set \"https_proxy=http://192.0.2.2:11089\" & " + + "set \"all_proxy=socks5://192.0.2.2:11088\" & " + + "set \"no_proxy=localhost,127.0.0.1,::1,.local\"") + #expect( + commands.windowsPowerShell == "$env:http_proxy = 'http://192.0.2.2:11089'; " + + "$env:https_proxy = 'http://192.0.2.2:11089'; " + + "$env:all_proxy = 'socks5://192.0.2.2:11088'; " + + "$env:no_proxy = 'localhost,127.0.0.1,::1,.local';") + } + + @Test func nushellSetsFourSessionVariablesUsingLocalPorts() { + let commands = TerminalProxyEnvironmentCommands( + listen: RuntimeListenFacts(listen: SslocalListenSettings()), + commandAddress: TerminalCommandAddress( + bsdName: "lo0", address: "127.0.0.1", displayName: "lo0")) + #expect( + commands.nushell == "$env.http_proxy = 'http://127.0.0.1:11087'; " + + "$env.https_proxy = 'http://127.0.0.1:11087'; " + + "$env.all_proxy = 'socks5://127.0.0.1:11086'; " + + "$env.no_proxy = 'localhost,127.0.0.1,::1,.local';") + } + @Test func disabledIPv6ConfigurationRoundTripsWithoutAffectingEffectiveRuntime() throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } diff --git a/docs/design/terminal-proxy-command-shell-formats.md b/docs/design/terminal-proxy-command-shell-formats.md new file mode 100644 index 00000000..3ed5cb56 --- /dev/null +++ b/docs/design/terminal-proxy-command-shell-formats.md @@ -0,0 +1,82 @@ +# 首页终端代理环境命令格式扩展 + +日期:2026-10-14。 + +本文记录本次设计访谈与实现验证,不作为持续更新的现状依据。当前地址、端口和身份验证规则以代码与 ADR-0034 为准。 + +## 需求 + +- 首页复制代理环境变量设置命令支持 Nushell。 +- 局域网监听开启且选择局域网 IP 时,支持 Windows:CMD 和 Windows:PowerShell。 + +## 已确认的决策 + +用户在第一轮访谈中选择全部推荐项。 + +- Nushell 始终显示,支持本地地址与局域网地址。 +- Windows 格式仅在局域网监听开启且选择局域网 IP 时显示;条件不满足时隐藏。 +- 代理停止时仍允许复制,沿用已保存的监听设置。 +- 命令供用户直接粘贴到当前终端会话执行;不增加持久化命令,不支持 CMD `.bat` 文件。 +- 新格式沿用四个小写变量:`http_proxy`、`https_proxy`、`all_proxy`、`no_proxy`。 +- `no_proxy` 沿用 `localhost,127.0.0.1,::1,.local`。 +- 局域网命令沿用现有端口与凭据规则;IPv6 URL 使用方括号。 +- 保留纵向复制按钮。顺序为 `zsh / bash`、`fish`、`Nushell`、`Windows:CMD`、`Windows:PowerShell`,最多五个按钮。 +- 保留完整命令 tooltip 和按钮内“已复制”反馈。 +- 第二轮用户指定:复制前刷新发现所选 IP 失效时,所有格式统一停止本次复制,保留剪贴板,并提示重新选择 IP。不得把回退后的本地地址静默写入剪贴板。地址候选刷新和 Windows 格式显隐仍按最新监听事实更新。 +- 第二轮用户选择本地验证:执行本地测试与构建,覆盖格式生成、显示条件、凭据编码和地址失效。真实 Windows 执行与跨设备代理连接标为未验证,不列入本次完成条件。 + +## 验收条件 + +- 本地地址下提供 zsh/bash、fish、Nushell 三个按钮。 +- 局域网监听开启且选择有效局域网 IP 时,额外提供两个 Windows 格式按钮;代理停止不影响这一条件。 +- 新格式复制四个小写变量,HTTP/HTTPS 与 SOCKS 使用对应入口,`no_proxy` 内容不变。 +- 启用局域网认证时,URL 保留完整且正确编码的凭据;读取失败停止复制。 +- 所选 IP 在复制前失效时,每一种格式都停止本次复制,不改写剪贴板,不显示“已复制”,提示“命令地址已失效,请重新选择 IP。”。 +- tooltip 与对应格式的复制内容一致。 +- 本地测试与构建通过;终端执行与跨设备连接按实际证据分别汇报。 + +## 实现前代码事实 + +- `HomeView+Cards.swift` 提供 zsh/bash 和 fish 复制按钮;复制前刷新命令地址。 +- `TerminalCommandAddress.swift` 在地址失效时回退 `127.0.0.1`。 +- `ProxyControlAdapters.swift` 按命令地址选择本地或局域网端口;认证 URL 对凭据中的特殊字符作百分号编码。 +- 凭据读取失败时,现有命令返回失败,界面禁用复制。 +- ADR-0034 规定 tooltip 与复制内容均包含完整命令和真实密码。 + +## 实现约束与验证边界 + +- CMD 交互命令必须保留认证 URL 的百分号编码,不能让已有环境变量改变凭据。生成方式属于实现问题,不降低认证要求。 +- Nushell 与 PowerShell 使用各自的环境变量赋值语法;CMD 使用其交互语法,不采用 `.bat` 百分号转义规则。 +- 当前环境未找到 `nu` 和 `pwsh`。`wine --version` 执行失败,输出为 `zsh:1: bad CPU type in executable: wine`。这些事实不代表目标终端执行通过。 +- CMD 对含百分号编码的 URL 使用交互 `for %P in ("%") do set "NAME=VALUE"`,以 `%~P` 插入百分号;各变量分行赋值,不写临时环境变量。普通 URL 使用四个 `set` 以 ` & ` 连接。分行方案限制最长允许凭据下的单行长度。 +- 上述 CMD 输出通过命令字符串测试与静态审查;官方文档说明 `%~P` 移除外围引号、环境变量展开不递归。原生 Windows 的完整解析行为与同名变量干扰仍未验证,不把字符串断言视为终端执行证据。 +- 用户调用 `implement` 技能,授权按本文实现、审查并提交到当前分支。 + +## 测试边界 + +本地验证沿用第一轮与第二轮确认的格式、显示条件、凭据编码和地址失效范围: + +- `TerminalProxyEnvironmentCommands` 投影:精确命令、监听端口、IPv6 与凭据编码。 +- `ProxyControlWorkflow` 的 snapshot 与复制前准备:格式显隐、代理关闭、网络/监听变化及旧 IP 失效。 +- 测试通过公开投影与工作流动作观察结果,不调用私有生成器或 SwiftUI 私有方法。 + +## 文档来源 + +- [Nushell 环境变量](https://www.nushell.sh/book/environment.html):`$env.NAME = 'value'`。 +- [PowerShell 环境变量](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_environment_variables):`$env:NAME = 'value'`。 +- [CMD set](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/set_1):当前命令解释器的环境变量赋值。 +- [CMD](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/cmd):命令连接与变量展开。 +- [CMD for](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/for):交互循环变量与引号移除修饰符。 +- [CMD 命令行长度限制](https://learn.microsoft.com/en-us/troubleshoot/windows-client/shell-experience/command-line-string-limitation):单行最大长度为 8191 个字符。 + +本变更为可逆的命令格式与界面扩展,不新建 ADR。 + +## 本次实现与验证 + +- 实现五种命令格式、按地址条件显示 Windows 按钮,以及所有格式共用的复制前地址身份检查。 +- Standards 与 Spec 两项只读代码审查均未发现问题。 +- 使用 `CODE_SIGN_IDENTITY=-` 执行 macOS arm64 全量 hostless 测试:1,059 个测试、1,162 次执行,0 失败、0 跳过。 +- 同配置 Debug 构建通过。 +- 外部二进制离线检查 5 个测试、规则快照检查 4 个测试、规则转换行为检查 17 个测试通过。 +- 修改的 Swift 文件通过严格格式检查;仓库 SwiftLint 返回 31 条警告、0 条严重问题。 +- 真实 Nushell、Windows CMD、Windows PowerShell、跨设备代理连接与原生界面交互未验证。剪贴板保留和失败反馈路径经静态审查,不将其描述为原生交互测试。