Files

339 lines
11 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
/// 设置分区:常规、后台代理客户端、系统代理与插件管理,采用
/// 分组卡,行式呈现(主文案 + 次说明 + 行尾控件)。设置项由各自编辑器独立保存;
/// 登录项与静默启动开关绑定独立控制器。
struct SettingsView: View {
let plugins: PluginManagementModel
@ObservedObject var workflow: SettingsWorkflow
@ObservedObject var loginController: LaunchAtLoginController
@ObservedObject var silentLaunch: SilentLaunchController
@State private var presentedEditor: SettingsEditorSheet?
@AppStorage("showStatusBarRuntimeDots") private var showStatusBarRuntimeDots = true
var body: some View {
Form {
generalSection
endpointSection
ConnectivityURLSettings(workflow: workflow)
systemProxySection
PluginManagementSection(model: plugins)
}
.formStyle(.grouped)
.sheet(item: $presentedEditor) { editor in
switch editor {
case .ports(let session):
LocalhostListeningEditorSheet(workflow: workflow, initialDraft: session.initialDraft)
case .lan(let session):
LANListeningEditorSheet(workflow: workflow, initialDraft: session.initialDraft)
case .proxyExceptions(let session):
ProxyExceptionsEditorSheet(workflow: workflow, initialValue: session.initialValue)
}
}
}
// MARK: - 常规
private var generalSection: some View {
Section {
VStack(alignment: .leading, spacing: 4) {
Toggle(
isOn: Binding(
get: { loginController.isEnabled },
set: { loginController.setEnabled($0) })
) {
settingCopy(
String(localized: "登录时启动 ShadowsocksX-NG2"), note: String(localized: "启动菜单栏应用,不会自动开启代理")
)
}
.toggleStyle(.switch)
if loginController.requiresApproval {
Text("请在系统设置 → 登录项 → 允许在后台运行中批准 ShadowsocksX-NG2。")
.font(.caption)
.foregroundStyle(.secondary)
}
if let loginError = loginController.errorMessage {
Text(loginError)
.font(.caption)
.foregroundStyle(.red)
}
}
.padding(.vertical, 4)
VStack(alignment: .leading, spacing: 4) {
Toggle(
isOn: Binding(
get: { silentLaunch.isEnabled },
set: { silentLaunch.setEnabled($0) })
) {
settingCopy(String(localized: "静默启动"), note: String(localized: "启动时后台运行,不显示主窗口;下次启动生效"))
}
.toggleStyle(.switch)
}
.padding(.vertical, 4)
Toggle("显示状态栏运行状态圆点", isOn: $showStatusBarRuntimeDots)
.toggleStyle(.switch)
.padding(.vertical, 4)
LanguageSettingsRow()
} header: {
sectionHeader(String(localized: "常规"))
}
}
// MARK: - 后台代理客户端
private var endpointSection: some View {
Section {
if let failure = workflow.lastFailure {
Label(failure.presentableMessage, systemImage: "exclamationmark.triangle.fill")
.font(.footnote)
.foregroundStyle(.red)
}
portSettingsRow
settingRow(String(localized: "局域网监听")) {
Button {
presentedEditor = .lan(
LANListeningEditorSession(initialDraft: workflow.beginLANListeningEditing()))
} label: {
HStack(spacing: 8) {
VStack(alignment: .trailing, spacing: 3) {
let lan = workflow.committedLANListening
if lan.isEnabled {
Text("\(lan.mode.displayName) SOCKS \(lan.socksPort) / HTTP \(lan.httpPort)")
Text(lanSummary)
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text("关闭")
}
}
Image(systemName: "chevron.right").font(.caption.weight(.semibold))
}
}
.buttonStyle(.bordered)
.accessibilityLabel("局域网监听")
}
} header: {
sectionHeader(String(localized: "后台代理客户端"))
}
}
private var lanSummary: String {
let lan = workflow.committedLANListening
let udp = lan.udpRelayEnabled ? String(localized: "UDP 中继启用") : String(localized: "UDP 中继禁用")
let auth =
lan.authenticationEnabled ? String(localized: "身份验证已启用") : String(localized: "身份验证已禁用")
return "\(udp) / \(auth)"
}
// MARK: - 系统代理
private var systemProxySection: some View {
Section {
settingRow(
String(localized: "额外系统代理例外"),
note: String(localized: "只追加到系统代理;应用固定绕过规则仍生效")
) {
Button {
presentedEditor = .proxyExceptions(
ProxyExceptionsEditorSession(initialValue: workflow.beginProxyExceptionsEditing()))
} label: {
HStack(spacing: 8) {
Text("\(workflow.committedProxyExceptionCount) 项")
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
}
}
.buttonStyle(.bordered)
.accessibilityLabel("额外系统代理例外")
.accessibilityValue("\(workflow.committedProxyExceptionCount) 项")
.accessibilityHint("编辑并保存额外的系统代理例外")
}
} header: {
sectionHeader(String(localized: "系统代理"))
}
}
// MARK: - 呈现组件
/// 分组卡标题行。
private func sectionHeader(_ title: String) -> some View {
Text(title)
.font(.headline)
.padding(.vertical, 2)
}
/// 主文案 + 可选次说明(原型行式;后台代理客户端区不配次说明)。
private func settingCopy(_ title: String, note: String? = nil) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.body)
if let note {
Text(note)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
/// 行式布局:左侧文案,右侧控件。
private func settingRow<Control: View>(
_ title: String, note: String? = nil, @ViewBuilder control: () -> Control
) -> some View {
HStack(spacing: 16) {
settingCopy(title, note: note)
Spacer(minLength: 16)
control()
}
}
}
// MARK: - 端口设置摘要与编辑框
extension SettingsView {
fileprivate var portSettingsRow: some View {
let ports = workflow.committedPortDraft
let socksPort = ports.socksPort.formatted(.number.grouping(.never))
let httpPort = ports.httpPort.formatted(.number.grouping(.never))
return settingRow(String(localized: "本地监听")) {
Button {
presentedEditor = .ports(
PortSettingsEditorSession(initialDraft: workflow.beginPortSettingsEditing()))
} label: {
HStack(spacing: 8) {
Text("SOCKS \(socksPort) / HTTP \(httpPort)")
.monospacedDigit()
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
}
}
.buttonStyle(.bordered)
.accessibilityLabel("本地监听")
.accessibilityValue("SOCKS \(socksPort),HTTP 代理 \(httpPort)")
}
}
}
private enum SettingsEditorSheet: Identifiable {
case ports(PortSettingsEditorSession)
case lan(LANListeningEditorSession)
case proxyExceptions(ProxyExceptionsEditorSession)
var id: UUID {
switch self {
case .ports(let session): session.id
case .lan(let session): session.id
case .proxyExceptions(let session): session.id
}
}
}
private struct PortSettingsEditorSession: Identifiable {
let id = UUID()
let initialDraft: SettingsPortDraft
}
private struct ProxyExceptionsEditorSession: Identifiable {
let id = UUID()
let initialValue: String
}
private struct ProxyExceptionsEditorSheet: View {
@ObservedObject var workflow: SettingsWorkflow
@Environment(\.dismiss) private var dismiss
@State private var draft: String
init(workflow: SettingsWorkflow, initialValue: String) {
self.workflow = workflow
_draft = State(initialValue: initialValue)
}
var body: some View {
VStack(spacing: 0) {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
Text("额外系统代理例外")
.font(.title2)
Text("添加希望 macOS 系统代理跳过的主机名、域名或 IP/CIDR。留空并确定会清除用户添加的条目。")
.font(.caption)
.foregroundStyle(.secondary)
TextEditor(text: $draft)
.font(.system(.body, design: .monospaced))
.scrollContentBackground(.hidden)
.padding(6)
.frame(height: 112)
.background(.background, in: RoundedRectangle(cornerRadius: 8))
.overlay {
RoundedRectangle(cornerRadius: 8)
.stroke(.quaternary, lineWidth: 1)
}
.disabled(workflow.isCommitting)
.accessibilityLabel("额外系统代理例外")
Text(
"格式:域名、主机名或 IP/CIDR,以逗号、顿号、空格或换行分隔。示例:127.0.0.1, 192.168.0.0/16, localhost。"
)
.font(.caption)
.foregroundStyle(.secondary)
Divider()
VStack(alignment: .leading, spacing: 6) {
Text("应用固定的系统代理例外")
.font(.headline)
Text(FixedLocalProxyRanges.systemProxyExceptions.joined(separator: "\n"))
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
Text("应用系统代理配置时,还会固定开启“不包括简单主机名(Exclude simple hostnames)”。")
.font(.caption)
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 6) {
Text("应用固定的 ACL 绕过规则")
.font(.headline)
Text(FixedLocalProxyRanges.aclBypassRules.joined(separator: "\n"))
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
Text("这些规则用于本机 SOCKS/HTTP 入站;上方输入只追加到系统代理例外,不会修改 ACL。")
.font(.caption)
.foregroundStyle(.secondary)
}
if let failure = workflow.lastFailure {
Label(failure.presentableMessage, systemImage: "exclamationmark.triangle.fill")
.font(.footnote)
.foregroundStyle(.red)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.bottom, 16)
}
HStack {
Spacer()
Button("取消", role: .cancel) {
dismiss()
}
.keyboardShortcut(.cancelAction)
.disabled(workflow.isCommitting)
Button(workflow.isCommitting ? String(localized: "保存中…") : String(localized: "确定")) {
Task {
if case .persisted = await workflow.saveProxyExceptions(draft) {
dismiss()
}
}
}
.keyboardShortcut(.defaultAction)
.disabled(workflow.isCommitting)
}
.padding(.top, 12)
}
.padding(24)
.frame(width: 560, height: 640)
.interactiveDismissDisabled(workflow.isCommitting)
}
}