mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-26 03:39:49 +00:00
feat(ng2): add Nushell and Windows proxy environment commands
This commit is contained in:
+1
-1
@@ -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 地址。它是当前应用会话内的选择,与监听设置和系统代理设置独立。
|
||||
|
||||
@@ -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: "; ") + ";")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;任何单一事实变化后整体替换,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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、跨设备代理连接与原生界面交互未验证。剪贴板保留和失败反馈路径经静态审查,不将其描述为原生交互测试。
|
||||
Reference in New Issue
Block a user