mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-02 08:33:46 +00:00
feat(ng2): regroup settings into card sections with header actions (#57)
常规/代理端点/高级三张分组卡,行式「主文案 + 次说明 + 行尾控件」;监听范围 改 segmented + 「不会自动换端口」徽标,LAN 警告与防火墙指引保留;端口行 issues/占用/建议空闲端口不变;保存/恢复默认迁入分区头(确认弹窗仍由本视图 alert 呈现);保存失败横幅置代理端点卡顶,dirty/占用提示置高级卡尾。 地图 #52 · 票 #57;task build + test 全绿,hermetic 冒烟通过。
This commit is contained in:
@@ -72,7 +72,7 @@ struct MainWindowView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 分区头动作槽位(票 #53/#56):页级动作放在分区大标题右侧。
|
||||
/// 分区头动作槽位(票 #53/#56/#57):页级动作放在分区大标题右侧。
|
||||
@ViewBuilder
|
||||
private var headerActions: some View {
|
||||
switch route.destination {
|
||||
@@ -90,7 +90,19 @@ struct MainWindowView: View {
|
||||
}
|
||||
.disabled(workflow.subscriptions.isEmpty)
|
||||
}
|
||||
case .home, .servers, .settings, .diagnostics:
|
||||
case .settings:
|
||||
HStack(spacing: 8) {
|
||||
Button("恢复默认") {
|
||||
Task { _ = await settingsWorkflow.reset() }
|
||||
}
|
||||
.disabled(settingsWorkflow.isCommitting)
|
||||
Button(settingsWorkflow.isCommitting ? "保存中…" : "保存设置") {
|
||||
Task { _ = await settingsWorkflow.save() }
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(!settingsWorkflow.canSave)
|
||||
}
|
||||
case .home, .servers, .diagnostics:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Native macOS settings for issue #33, bound to the settings workflow seam
|
||||
/// (issue #44): field bindings edit the flat UI-shaped draft, issues render
|
||||
/// beside their fields from the seam's field-scoped projection, port rows read
|
||||
/// typed field state, and every discrete action is a named typed command.
|
||||
/// Whether a confirmation is required and its summary come from the seam's
|
||||
/// unified fact; this view only owns alert presentation. The login-item toggle
|
||||
/// binds its own independent controller (separate preference domain).
|
||||
/// 设置分区(issue #33/#44,地图 #52 票 #57):按原型重排为常规 / 代理端点 /
|
||||
/// 高级三张分组卡,行式呈现(主文案 + 次说明 + 行尾控件);保存/恢复默认动作
|
||||
/// 在分区头动作槽位(由主窗口壳提供,确认弹窗仍由本视图的 alert 呈现)。
|
||||
/// 字段绑定编辑 workflow 的扁平 UI-shaped draft;issues 按字段渲染;端口行读
|
||||
/// typed field state;是否需要确认及摘要来自 seam 的统一事实;登录项开关
|
||||
/// 绑定独立控制器。
|
||||
struct SettingsView: View {
|
||||
@ObservedObject var workflow: SettingsWorkflow
|
||||
@ObservedObject var loginController: LaunchAtLoginController
|
||||
@@ -14,11 +13,12 @@ struct SettingsView: View {
|
||||
var body: some View {
|
||||
Form {
|
||||
generalSection
|
||||
endpointSection
|
||||
advancedSection
|
||||
actionSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.padding()
|
||||
.padding(.leading, 20)
|
||||
.padding(.trailing, 24)
|
||||
.onAppear {
|
||||
Task { _ = await workflow.reloadFromCommitted() }
|
||||
}
|
||||
@@ -36,198 +36,200 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 常规
|
||||
|
||||
private var generalSection: some View {
|
||||
Section("常规") {
|
||||
Toggle(
|
||||
"登录时启动 ShadowsocksX-NG",
|
||||
isOn: Binding(
|
||||
get: { loginController.isEnabled },
|
||||
set: { loginController.setEnabled($0) }))
|
||||
if loginController.requiresApproval {
|
||||
Text("请在系统设置 → 登录项 → 允许在后台运行中批准 ShadowsocksX-NG。")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let loginError = loginController.errorMessage {
|
||||
Text(loginError)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
|
||||
Toggle("启用 UDP 中继", isOn: $workflow.draft.udpRelayEnabled)
|
||||
Stepper(value: $workflow.draft.timeoutSeconds, in: 1...86_400) {
|
||||
Text("超时:" + String(workflow.draft.timeoutSeconds) + " 秒")
|
||||
}
|
||||
fieldIssues(.timeoutSeconds)
|
||||
Toggle("详细日志(verbose)", isOn: $workflow.draft.verboseLogging)
|
||||
}
|
||||
}
|
||||
|
||||
private var advancedSection: some View {
|
||||
Section("高级") {
|
||||
Picker("监听范围", selection: $workflow.draft.isHostScope) {
|
||||
Text("仅本机(127.0.0.1)").tag(false)
|
||||
Text("局域网(主机地址)").tag(true)
|
||||
}
|
||||
|
||||
if workflow.draft.isHostScope {
|
||||
TextField("对外公布的 IPv4 地址", text: $workflow.draft.advertisedAddress)
|
||||
fieldIssues(.advertisedAddress)
|
||||
if workflow.draft.advertisedAddress.isEmpty {
|
||||
Text("请输入本机可路由的局域网 IPv4 地址。")
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Toggle(
|
||||
isOn: Binding(
|
||||
get: { loginController.isEnabled },
|
||||
set: { loginController.setEnabled($0) })
|
||||
) {
|
||||
settingCopy("登录时启动 ShadowsocksX-NG", note: "启动菜单栏应用,不会自动开启代理")
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
if loginController.requiresApproval {
|
||||
Text("请在系统设置 → 登录项 → 允许在后台运行中批准 ShadowsocksX-NG。")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let loginError = loginController.errorMessage {
|
||||
Text(loginError)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
Label(
|
||||
"局域网模式会把无鉴权的代理端口开放给局域网;请确认防火墙允许所需程序,并只在可信网络使用。",
|
||||
systemImage: "exclamationmark.triangle"
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
Text(
|
||||
"若状态显示防火墙阻止,请前往系统设置 → 网络 → 防火墙 → 选项,允许 ShadowsocksX-NG2Agent 接收入站连接。"
|
||||
)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
Toggle(isOn: $workflow.draft.udpRelayEnabled) {
|
||||
settingCopy("启用 UDP 中继", note: "为支持 UDP 的服务器转发数据报")
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
settingRow("超时", note: "连接超时秒数(1–86400)") {
|
||||
Stepper(value: $workflow.draft.timeoutSeconds, in: 1...86_400) {
|
||||
Text("\(workflow.draft.timeoutSeconds) 秒")
|
||||
.monospacedDigit()
|
||||
}
|
||||
.frame(maxWidth: 160, alignment: .trailing)
|
||||
}
|
||||
issuesRow(.timeoutSeconds)
|
||||
Toggle(isOn: $workflow.draft.verboseLogging) {
|
||||
settingCopy("详细日志(verbose)", note: "仅在需要排障时临时启用")
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
} header: {
|
||||
sectionHeader("常规", subtitle: "应用启动与常用代理行为")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 代理端点
|
||||
|
||||
private var endpointSection: some View {
|
||||
Section {
|
||||
if let failure = workflow.lastFailure {
|
||||
Label(failure.presentableMessage, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
settingRow("监听范围", note: listenScopeNote) {
|
||||
Picker("监听范围", selection: $workflow.draft.isHostScope) {
|
||||
Text("仅本机").tag(false)
|
||||
Text("局域网").tag(true)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
.frame(width: 170)
|
||||
}
|
||||
if workflow.draft.isHostScope {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
LabeledContent {
|
||||
TextField("对外公布的 IPv4 地址", text: $workflow.draft.advertisedAddress)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 170)
|
||||
} label: {
|
||||
settingCopy("对外公布的地址", note: "本机可路由的局域网 IPv4 地址")
|
||||
}
|
||||
issuesRow(.advertisedAddress)
|
||||
if workflow.draft.advertisedAddress.isEmpty {
|
||||
Text("请输入本机可路由的局域网 IPv4 地址。")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
Label(
|
||||
"局域网模式会把无鉴权的代理端口开放给局域网;请确认防火墙允许所需程序,并只在可信网络使用。",
|
||||
systemImage: "exclamationmark.triangle"
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
Text(
|
||||
"若状态显示防火墙阻止,请前往系统设置 → 网络 → 防火墙 → 选项,允许 ShadowsocksX-NG2Agent 接收入站连接。"
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
portRow(.socks, title: "SOCKS5 端口", note: "系统全局模式使用")
|
||||
Toggle(isOn: $workflow.draft.httpProxyEnabled) {
|
||||
settingCopy("启用 HTTP 代理", note: "可单独关闭 HTTP 监听")
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
portRow(.http, title: "HTTP 代理端口", note: "可单独关闭 HTTP 监听")
|
||||
.disabled(!workflow.draft.httpProxyEnabled)
|
||||
portRow(.pac, title: "PAC 端口", note: "分享出去的 PAC URL 会包含此端口")
|
||||
} header: {
|
||||
sectionHeader("代理端点", subtitle: "监听范围和本地服务端口", trailing: "不会自动换端口")
|
||||
}
|
||||
}
|
||||
|
||||
private var listenScopeNote: String {
|
||||
workflow.draft.isHostScope ? "局域网 · 对外公布地址" : "仅本机 · 127.0.0.1"
|
||||
}
|
||||
|
||||
// MARK: - 高级
|
||||
|
||||
private var advancedSection: some View {
|
||||
Section {
|
||||
TextField(
|
||||
"绕过列表", text: $workflow.draft.proxyExceptions,
|
||||
prompt: Text("127.0.0.1, 192.168.0.0/16, localhost …"))
|
||||
Text("逗号或空格分隔域名,这些目标不走代理。")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
portRow(.socks)
|
||||
Toggle("启用 HTTP 代理", isOn: $workflow.draft.httpProxyEnabled)
|
||||
portRow(.http)
|
||||
.disabled(!workflow.draft.httpProxyEnabled)
|
||||
portRow(.pac)
|
||||
|
||||
TextField("绕过列表(逗号或空格分隔)", text: $workflow.draft.proxyExceptions)
|
||||
TextField("GFW List URL", text: $workflow.draft.gfwListURL)
|
||||
fieldIssues(.gfwListURL)
|
||||
TextField("GFW List URL", text: $workflow.draft.gfwListURL, prompt: Text("https://…"))
|
||||
issuesRow(.gfwListURL)
|
||||
Text("远程内容只保存 URL;本设置页不负责远程内容校验或自动更新。")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("PAC 用户规则")
|
||||
settingCopy("PAC 用户规则", note: "仅以 @@ 开头的域名例外规则会生成 DIRECT;其他规则保持默认代理链")
|
||||
TextEditor(text: $workflow.draft.pacUserRules)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.frame(minHeight: 120)
|
||||
.frame(minHeight: 110)
|
||||
.overlay(RoundedRectangle(cornerRadius: 5).stroke(.quaternary))
|
||||
Text("仅以 @@ 开头的域名例外规则会生成 DIRECT;其他规则保持默认代理链。")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var actionSection: some View {
|
||||
Section {
|
||||
if let failure = workflow.lastFailure {
|
||||
Text(failure.presentableMessage)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
if workflow.hasBlockingPortOccupancy {
|
||||
Text("检测到端口已被占用;请先使用对应的“建议空闲端口”,再保存设置。")
|
||||
.font(.caption)
|
||||
Label("检测到端口已被占用;请先使用对应的「建议空闲端口」,再保存设置。", systemImage: "exclamationmark.triangle")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
if workflow.isDirty {
|
||||
Text("存在未保存的修改。")
|
||||
Label("存在未保存的修改;点击分区头「保存设置」生效。", systemImage: "pencil")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} header: {
|
||||
sectionHeader("高级", subtitle: "细化系统代理与 PAC 规则")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 呈现组件
|
||||
|
||||
/// 分区头:标题 + 副题 + 可选尾随徽标(票 #57)。
|
||||
private func sectionHeader(_ title: String, subtitle: String, trailing: String? = nil)
|
||||
-> some View
|
||||
{
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("重置偏好") {
|
||||
Task { _ = await workflow.reset() }
|
||||
}
|
||||
.disabled(workflow.isCommitting)
|
||||
Button(workflow.isCommitting ? "保存中…" : "保存") {
|
||||
Task { _ = await workflow.save() }
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(!workflow.canSave)
|
||||
Spacer(minLength: 12)
|
||||
if let trailing {
|
||||
Text(trailing)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.tint)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.accentColor.opacity(0.12), in: Capsule())
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
|
||||
extension SettingsView {
|
||||
private func portRow(_ id: SettingsPortID) -> some View {
|
||||
let state = workflow.portFieldState(for: id)
|
||||
return VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(title(for: id) + " 端口")
|
||||
Spacer()
|
||||
TextField(
|
||||
title(for: id) + " 端口",
|
||||
value: portBinding(for: id),
|
||||
format: .number
|
||||
)
|
||||
.frame(width: 90)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.labelsHidden()
|
||||
}
|
||||
ForEach(Array(state.issues.enumerated()), id: \.offset) { _, issue in
|
||||
Text(AppPresentation.message(for: issue))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
if let occupancy = state.occupancy {
|
||||
Text(occupancyText(occupancy, state: state))
|
||||
.font(.caption)
|
||||
.foregroundStyle(occupancyColor(occupancy))
|
||||
}
|
||||
if state.canSuggestFreePort {
|
||||
Button("建议空闲端口") {
|
||||
Task { _ = await workflow.suggestFreePort(for: id) }
|
||||
}
|
||||
/// 主文案 + 次说明(票 #57 原型行式)。
|
||||
private func settingCopy(_ title: String, note: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.body)
|
||||
Text(note)
|
||||
.font(.caption)
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func portBinding(for id: SettingsPortID) -> Binding<Int> {
|
||||
Binding(
|
||||
get: { workflow.draft.portValue(for: id) },
|
||||
set: { workflow.draft.setPortValue($0, for: id) })
|
||||
}
|
||||
|
||||
private func fieldIssues(_ field: SettingsFieldID) -> some View {
|
||||
ForEach(Array(workflow.issues(for: field).enumerated()), id: \.offset) { _, issue in
|
||||
Text(AppPresentation.message(for: issue))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
|
||||
private func title(for id: SettingsPortID) -> String {
|
||||
switch id {
|
||||
case .socks: "SOCKS5"
|
||||
case .http: "HTTP"
|
||||
case .pac: "PAC"
|
||||
}
|
||||
}
|
||||
|
||||
private func occupancyText(
|
||||
_ occupancy: SettingsPortOccupancy, state: SettingsPortFieldState
|
||||
) -> String {
|
||||
switch occupancy {
|
||||
case .free:
|
||||
return "当前端口可用"
|
||||
case .occupied(let occupier):
|
||||
if state.isRuntimePortException {
|
||||
return "当前代理正在使用此端口,保存其他设置不会触发冲突"
|
||||
}
|
||||
return "当前端口已占用" + (occupier.map { "(" + $0 + ")" } ?? "")
|
||||
case .unknown(let detail):
|
||||
return "端口状态无法确定:" + detail
|
||||
}
|
||||
}
|
||||
|
||||
private func occupancyColor(_ occupancy: SettingsPortOccupancy) -> Color {
|
||||
switch occupancy {
|
||||
case .free: .secondary
|
||||
case .occupied: .orange
|
||||
case .unknown: .secondary
|
||||
/// 行式布局:左侧文案,右侧控件(票 #57)。
|
||||
private func settingRow<Control: View>(
|
||||
_ title: String, note: String, @ViewBuilder control: () -> Control
|
||||
) -> some View {
|
||||
HStack(spacing: 16) {
|
||||
settingCopy(title, note: note)
|
||||
Spacer(minLength: 16)
|
||||
control()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,3 +274,82 @@ extension SettingsView {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 端口行(同文件扩展,保持 private 访问)
|
||||
|
||||
extension SettingsView {
|
||||
private func portRow(_ id: SettingsPortID, title: String, note: String) -> some View {
|
||||
let state = workflow.portFieldState(for: id)
|
||||
return settingRow(title, note: note) {
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
TextField(
|
||||
title,
|
||||
value: portBinding(for: id),
|
||||
format: .number.grouping(.never)
|
||||
)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.labelsHidden()
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(width: 96)
|
||||
ForEach(Array(state.issues.enumerated()), id: \.offset) { _, issue in
|
||||
Text(AppPresentation.message(for: issue))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.frame(width: 220, alignment: .trailing)
|
||||
}
|
||||
if let occupancy = state.occupancy {
|
||||
Text(occupancyText(occupancy, state: state))
|
||||
.font(.caption)
|
||||
.foregroundStyle(occupancyColor(occupancy))
|
||||
.frame(width: 220, alignment: .trailing)
|
||||
}
|
||||
if state.canSuggestFreePort {
|
||||
Button("建议空闲端口") {
|
||||
Task { _ = await workflow.suggestFreePort(for: id) }
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
private func portBinding(for id: SettingsPortID) -> Binding<Int> {
|
||||
Binding(
|
||||
get: { workflow.draft.portValue(for: id) },
|
||||
set: { workflow.draft.setPortValue($0, for: id) })
|
||||
}
|
||||
|
||||
private func issuesRow(_ field: SettingsFieldID) -> some View {
|
||||
ForEach(Array(workflow.issues(for: field).enumerated()), id: \.offset) { _, issue in
|
||||
Text(AppPresentation.message(for: issue))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
|
||||
private func occupancyText(
|
||||
_ occupancy: SettingsPortOccupancy, state: SettingsPortFieldState
|
||||
) -> String {
|
||||
switch occupancy {
|
||||
case .free:
|
||||
return "当前端口可用"
|
||||
case .occupied(let occupier):
|
||||
if state.isRuntimePortException {
|
||||
return "当前代理正在使用此端口,保存其他设置不会触发冲突"
|
||||
}
|
||||
return "当前端口已占用" + (occupier.map { "(" + $0 + ")" } ?? "")
|
||||
case .unknown(let detail):
|
||||
return "端口状态无法确定:" + detail
|
||||
}
|
||||
}
|
||||
|
||||
private func occupancyColor(_ occupancy: SettingsPortOccupancy) -> Color {
|
||||
switch occupancy {
|
||||
case .free: .secondary
|
||||
case .occupied: .orange
|
||||
case .unknown: .secondary
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user