mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-12 23:15:07 +00:00
467 lines
17 KiB
Swift
467 lines
17 KiB
Swift
import Combine
|
||
import SwiftUI
|
||
|
||
/// 表单字段标识:字段级校验错误的挂靠位与首错定位序(表单栅格顺序)。
|
||
enum ServerFormField: Hashable {
|
||
case name
|
||
case address
|
||
case port
|
||
case password
|
||
case pluginOptions
|
||
|
||
static let focusOrder: [ServerFormField] = [.name, .address, .port, .password, .pluginOptions]
|
||
}
|
||
|
||
/// 字段级校验错误(issue #81):只携带字段、原因与上限,不回显字段内容;
|
||
/// 文案归呈现层(`ServerFormFieldsGrid`)。
|
||
enum ServerFormFieldError: Equatable {
|
||
/// 名称必填(原有规则并入统一校验入口)。
|
||
case missingName
|
||
/// 用户可见字符(Swift `Character`)超上限。
|
||
case tooManyCharacters(limit: Int)
|
||
/// UTF-8 字节超上限(插件参数按最终字符串的字节数计量)。
|
||
case tooManyBytes(limit: Int)
|
||
/// 端口草稿为空、含非十进制数字、超过五位或落在 1–65535 之外。
|
||
case invalidPort
|
||
/// 参数列表存在未完成行(有值或开关形态却没有参数名)。
|
||
case unfinishedPluginOptionRow
|
||
}
|
||
|
||
/// 服务器表单草稿(UI 持有):连接字段草稿状态与「字段 ↔ ServerEditForm/
|
||
/// ServerEditDraft 命令」的映射只在此写一次;新建与编辑两个表单面各自
|
||
/// `@StateObject` 持有一份,经 `load(from:)` 装载、`draft` 提交,第三个表单
|
||
/// 面接入只需学这一对方法。栅格渲染见 `ServerFormFieldsGrid`;订阅只读禁用
|
||
/// 与插件区 facts 由 caller 注入。
|
||
@MainActor
|
||
final class ServerFormFields: ObservableObject {
|
||
/// 手动表单的产品输入上限(issue #81):不宣称是 DNS、Shadowsocks 或
|
||
/// SIP003 协议的最大长度;导入、订阅刷新与既有保存数据不执行这些上限。
|
||
static let nameCharacterLimit = 128
|
||
static let addressCharacterLimit = 255
|
||
static let portDigitLimit = 5
|
||
static let passwordCharacterLimit = 1_024
|
||
static let pluginOptionsUTF8Limit = 65_536
|
||
|
||
@Published var address = "" { didSet { clearError(.address) } }
|
||
/// 端口十进制编辑草稿:提交前经 `validateForSubmit()` 验证,不用数值绑定
|
||
/// 以免转换失败时悄悄提交旧绑定值(issue #81)。
|
||
@Published var portText = "" { didSet { clearError(.port) } }
|
||
@Published var encryptionMethod = ""
|
||
@Published var password = "" { didSet { clearError(.password) } }
|
||
@Published var remark = "" {
|
||
didSet {
|
||
if !nameIsEmpty { clearError(.name) }
|
||
}
|
||
}
|
||
@Published var pluginChoice: PluginSelection = .none
|
||
/// 插件参数会话草稿(issue #81):行/模式/原文收在模块内,提交串取
|
||
/// `composedString`;编辑参数即清除参数字段错误,变更通知转发给本类
|
||
/// 观察者(视图只观察 ServerFormFields)。
|
||
let pluginOptions = PluginOptionsDraft()
|
||
private var pluginOptionsChanges: AnyCancellable?
|
||
@Published var showPassword = false
|
||
@Published private(set) var fieldErrors: [ServerFormField: ServerFormFieldError] = [:]
|
||
@Published private var savedDraft: ServerEditDraft?
|
||
|
||
@Published private(set) var serverID: NodeID?
|
||
@Published private(set) var presentation: ServerFormPresentation?
|
||
@Published private(set) var loadFailure: ServerFormLoadError?
|
||
@Published private(set) var hasLoadedServer = false
|
||
|
||
var canSaveServer: Bool {
|
||
hasLoadedServer && loadFailure == nil && presentation?.isEditable == true
|
||
}
|
||
|
||
init() {
|
||
pluginOptionsChanges = pluginOptions.objectWillChange.sink { [weak self] _ in
|
||
guard let self else { return }
|
||
self.clearError(.pluginOptions)
|
||
self.objectWillChange.send()
|
||
}
|
||
}
|
||
|
||
/// 变更检测按提交草稿比较;端口在草稿可解析时按数值比较(如前导零改写
|
||
/// 不算变更),不可解析视为已变更,保证非法输入后保存入口仍可点击并在
|
||
/// 提交时得到行内错误,而不是按钮死锁。
|
||
var hasChanges: Bool {
|
||
draft != savedDraft
|
||
}
|
||
|
||
private var nameIsEmpty: Bool {
|
||
remark.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||
}
|
||
|
||
/// 提交前的全量校验(issue #81):名称必填与五字段输入上限;与装载基线
|
||
/// 逐字相同的超限字段放行(未修改的历史超长值不阻塞其他变更保存)。
|
||
/// 错误逐字段发布,重新编辑对应字段即清除;首次装载不显示。
|
||
@discardableResult
|
||
func validateForSubmit() -> Bool {
|
||
var errors: [ServerFormField: ServerFormFieldError] = [:]
|
||
if nameIsEmpty {
|
||
errors[.name] = .missingName
|
||
} else {
|
||
errors[.name] = characterLimitError(
|
||
raw: remark, measured: remark.trimmingCharacters(in: .whitespacesAndNewlines),
|
||
baseline: savedDraft?.remark, limit: Self.nameCharacterLimit)
|
||
}
|
||
errors[.address] = characterLimitError(
|
||
raw: address, measured: address.trimmingCharacters(in: .whitespaces),
|
||
baseline: savedDraft?.address, limit: Self.addressCharacterLimit)
|
||
if submittablePort == nil {
|
||
errors[.port] = .invalidPort
|
||
}
|
||
errors[.password] = characterLimitError(
|
||
raw: password, measured: password,
|
||
baseline: savedDraft?.password, limit: Self.passwordCharacterLimit)
|
||
if pluginChoice != .none {
|
||
if pluginOptions.hasUnfinishedRows {
|
||
errors[.pluginOptions] = .unfinishedPluginOptionRow
|
||
} else if let options = pluginOptions.composedString {
|
||
errors[.pluginOptions] = utf8LimitError(
|
||
raw: options, baseline: savedDraft?.pluginOptions,
|
||
limit: Self.pluginOptionsUTF8Limit)
|
||
}
|
||
}
|
||
fieldErrors = errors
|
||
return errors.isEmpty
|
||
}
|
||
|
||
/// 首个待修正字段(首错定位用);无错误时为 nil。
|
||
var firstErrorField: ServerFormField? {
|
||
ServerFormField.focusOrder.first { fieldErrors[$0] != nil }
|
||
}
|
||
|
||
/// 新建表单的默认字段:端口 8388、加密 aes-256-gcm、插件「无」。
|
||
static func newForm() -> ServerFormFields {
|
||
let fields = ServerFormFields()
|
||
fields.portText = "8388"
|
||
fields.encryptionMethod = "aes-256-gcm"
|
||
return fields
|
||
}
|
||
|
||
/// 当前 sslocal 能力目录;目录中既有的未知方法原样追加显示,便于用户修复。
|
||
/// 新建面初值恒在集内,追加分支不会触发,与仅列集内值行为一致。
|
||
var methodChoices: [String] {
|
||
var choices = EncryptionMethodCatalog.supported.sorted()
|
||
if !encryptionMethod.isEmpty && !choices.contains(encryptionMethod) {
|
||
choices.append(encryptionMethod)
|
||
}
|
||
return choices
|
||
}
|
||
|
||
/// 从编辑面状态装载(凭据明文只在编辑命令中出现,story 10/11);重置密码
|
||
/// 明文显示。
|
||
func load(from state: ServerEditForm) {
|
||
address = state.address
|
||
portText = String(state.port)
|
||
encryptionMethod = state.encryptionMethod
|
||
password = state.password
|
||
remark = state.remark
|
||
pluginChoice = state.plugin.selection
|
||
pluginOptions.load(state.plugin.options)
|
||
showPassword = false
|
||
fieldErrors = [:]
|
||
savedDraft = draft
|
||
}
|
||
|
||
/// 提交载荷:配置与凭据字段作为一个逻辑变更(story 12)。端口取自十进制
|
||
/// 草稿、插件参数取自会话草稿拼装串;任一不能作为提交值(无效端口或
|
||
/// 未完成参数行)时为 nil——本 getter 仅在 `validateForSubmit()` 通过后
|
||
/// 消费,无效输入不得悄悄回落到旧值(issue #81)。
|
||
var draft: ServerEditDraft? {
|
||
guard let port = submittablePort, let options = pluginOptions.composedString else { return nil }
|
||
return ServerEditDraft(
|
||
address: address,
|
||
port: port,
|
||
encryptionMethod: encryptionMethod,
|
||
password: password,
|
||
remark: remark,
|
||
plugin: pluginChoice,
|
||
pluginOptions: options)
|
||
}
|
||
|
||
// MARK: - Explicit server loading
|
||
|
||
/// Reappearing with the same selection preserves the loaded draft, including failures.
|
||
func showServer(_ id: NodeID, load: (NodeID) throws -> ServerEditForm?) {
|
||
guard serverID != id else { return }
|
||
serverID = id
|
||
clearLoadedServer()
|
||
reloadServer(load: load)
|
||
}
|
||
|
||
func updatePresentation(
|
||
_ facts: ServerFormPresentation?,
|
||
load: ((NodeID) throws -> ServerEditForm?)? = nil
|
||
) {
|
||
if let selection = facts?.plugin.selection,
|
||
presentation?.plugin.selection != selection
|
||
{
|
||
let oldProgram: String?
|
||
switch pluginChoice {
|
||
case .named(let program), .unknown(let program): oldProgram = program
|
||
case .none: oldProgram = nil
|
||
}
|
||
let newProgram: String?
|
||
switch selection {
|
||
case .named(let program), .unknown(let program): newProgram = program
|
||
case .none: newProgram = nil
|
||
}
|
||
if let oldProgram, oldProgram == newProgram,
|
||
savedDraft?.plugin == pluginChoice
|
||
{
|
||
// Only refresh plugin facts. Independent unsaved server fields stay intact.
|
||
if case .unknown = pluginChoice, case .named = selection,
|
||
let serverID, let load,
|
||
pluginOptions.composedString == savedDraft?.pluginOptions
|
||
{
|
||
do {
|
||
if let form = try load(serverID) {
|
||
pluginOptions.load(form.plugin.options)
|
||
savedDraft?.pluginOptions = pluginOptions.composedString
|
||
}
|
||
} catch {
|
||
loadFailure = .credentialsUnavailable
|
||
}
|
||
}
|
||
pluginChoice = selection
|
||
savedDraft?.plugin = selection
|
||
}
|
||
}
|
||
if presentation != facts { presentation = facts }
|
||
}
|
||
|
||
/// Only a manual reset failure may retain a successfully loaded draft.
|
||
func reloadServer(load: (NodeID) throws -> ServerEditForm?) {
|
||
guard let serverID else { return }
|
||
let preserveDraft = hasLoadedServer && presentation?.isEditable == true
|
||
do {
|
||
guard let form = try load(serverID) else { throw ServerFormLoadError.notFound }
|
||
self.load(from: form)
|
||
presentation = ServerFormPresentation(isEditable: form.isEditable, plugin: form.plugin)
|
||
hasLoadedServer = true
|
||
loadFailure = nil
|
||
} catch {
|
||
if !preserveDraft { clearLoadedServer() }
|
||
loadFailure = (error as? ServerFormLoadError) ?? .credentialsUnavailable
|
||
}
|
||
}
|
||
|
||
/// A late save completion must not reset a different server's draft.
|
||
func didSaveServer(_ id: NodeID, load: (NodeID) throws -> ServerEditForm?) {
|
||
guard serverID == id else { return }
|
||
reloadServer(load: load)
|
||
}
|
||
|
||
/// Only successful refreshes of the displayed read-only server trigger a reload.
|
||
func subscriptionDidRefresh(
|
||
affectedServers: Set<NodeID>, load: (NodeID) throws -> ServerEditForm?
|
||
) {
|
||
guard let serverID, affectedServers.contains(serverID),
|
||
presentation?.isEditable == false
|
||
else { return }
|
||
reloadServer(load: load)
|
||
}
|
||
|
||
private func clearLoadedServer() {
|
||
address = ""
|
||
portText = ""
|
||
encryptionMethod = ""
|
||
password = ""
|
||
remark = ""
|
||
pluginChoice = .none
|
||
pluginOptions.load("")
|
||
showPassword = false
|
||
fieldErrors = [:]
|
||
savedDraft = nil
|
||
presentation = nil
|
||
hasLoadedServer = false
|
||
loadFailure = nil
|
||
}
|
||
|
||
// MARK: - 校验实现
|
||
|
||
/// 端口草稿可否作为提交端口:非空、纯 ASCII 十进制数字、不超过五位且
|
||
/// 落在 1–65535。
|
||
private var submittablePort: Int? {
|
||
guard !portText.isEmpty, portText.count <= Self.portDigitLimit,
|
||
portText.allSatisfy({ ("0"..."9").contains($0) }),
|
||
let port = Int(portText)
|
||
else { return nil }
|
||
return (1...65_535).contains(port) ? port : nil
|
||
}
|
||
|
||
/// 字符上限:与基线逐字相同(未修改)的历史超长值放行;名称与地址按
|
||
/// 提交值(现行首尾空白处理后)计量,密码不 trim、不 Unicode 归一化。
|
||
private func characterLimitError(
|
||
raw: String, measured: String, baseline: String?, limit: Int
|
||
) -> ServerFormFieldError? {
|
||
guard baseline == nil || raw != baseline else { return nil }
|
||
return measured.count > limit ? .tooManyCharacters(limit: limit) : nil
|
||
}
|
||
|
||
/// 插件参数字节上限:按最终参数字符串的 UTF-8 字节数计量(含分隔符与
|
||
/// 转义),与基线逐字相同的未修改值放行。
|
||
private func utf8LimitError(
|
||
raw: String, baseline: String?, limit: Int
|
||
) -> ServerFormFieldError? {
|
||
guard baseline == nil || raw != baseline else { return nil }
|
||
return raw.utf8.count > limit ? .tooManyBytes(limit: limit) : nil
|
||
}
|
||
|
||
private func clearError(_ field: ServerFormField) {
|
||
guard fieldErrors[field] != nil else { return }
|
||
fieldErrors[field] = nil
|
||
}
|
||
}
|
||
|
||
/// 新建与编辑共享的名称优先表单;焦点由提交入口持有,校验失败时定位首个
|
||
/// 待修正字段。
|
||
struct ServerFormFieldsGrid: View {
|
||
@ObservedObject var fields: ServerFormFields
|
||
let plugin: PluginSectionState?
|
||
let isEditable: Bool
|
||
let fieldFocus: FocusState<ServerFormField?>.Binding
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 18) {
|
||
column("名称") {
|
||
TextField("名称", text: $fields.remark, prompt: Text("例如:香港服务器"))
|
||
.textFieldStyle(.roundedBorder)
|
||
.focused(fieldFocus, equals: .name)
|
||
.disabled(!isEditable)
|
||
fieldErrorLabel(.name)
|
||
}
|
||
ServerEndpointLayout {
|
||
column("服务器地址") {
|
||
TextField("服务器地址", text: $fields.address)
|
||
.textFieldStyle(.roundedBorder)
|
||
.focused(fieldFocus, equals: .address)
|
||
.disabled(!isEditable)
|
||
fieldErrorLabel(.address)
|
||
}
|
||
column("端口") {
|
||
TextField("端口", text: $fields.portText)
|
||
.textFieldStyle(.roundedBorder)
|
||
.focused(fieldFocus, equals: .port)
|
||
.disabled(!isEditable)
|
||
fieldErrorLabel(.port)
|
||
}
|
||
}
|
||
column("加密方式") {
|
||
Picker("加密方式", selection: $fields.encryptionMethod) {
|
||
ForEach(fields.methodChoices, id: \.self) { Text($0).tag($0) }
|
||
}
|
||
.labelsHidden()
|
||
.accessibilityLabel("加密方式")
|
||
.disabled(!isEditable)
|
||
}
|
||
column("密码") {
|
||
HStack(spacing: 8) {
|
||
Group {
|
||
if fields.showPassword {
|
||
TextField("密码", text: $fields.password)
|
||
} else {
|
||
SecureField("密码", text: $fields.password)
|
||
}
|
||
}
|
||
.textFieldStyle(.roundedBorder)
|
||
.textContentType(nil)
|
||
.autocorrectionDisabled()
|
||
.focused(fieldFocus, equals: .password)
|
||
.disabled(!isEditable)
|
||
Button {
|
||
fields.showPassword.toggle()
|
||
} label: {
|
||
Image(systemName: fields.showPassword ? "eye.slash" : "eye")
|
||
}
|
||
.buttonStyle(.borderless)
|
||
.help(fields.showPassword ? "隐藏密码" : "显示密码")
|
||
.accessibilityLabel(fields.showPassword ? "隐藏密码" : "显示密码")
|
||
}
|
||
fieldErrorLabel(.password)
|
||
}
|
||
column("插件") {
|
||
ServerPluginSection(
|
||
selection: $fields.pluginChoice,
|
||
options: fields.pluginOptions,
|
||
plugin: plugin,
|
||
isEditable: isEditable,
|
||
optionsError: fields.fieldErrors[.pluginOptions],
|
||
optionsFocus: fieldFocus)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func column<Content: View>(
|
||
_ label: LocalizedStringKey, @ViewBuilder content: () -> Content
|
||
) -> some View {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Text(label)
|
||
.font(.callout.weight(.medium))
|
||
.foregroundStyle(.secondary)
|
||
content()
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
|
||
/// 字段行内错误:只呈现字段、原因与上限,不回显字段内容(issue #81)。
|
||
@ViewBuilder
|
||
private func fieldErrorLabel(_ field: ServerFormField) -> some View {
|
||
if let error = fields.fieldErrors[field] {
|
||
Text(errorText(error, field: field))
|
||
.font(.footnote)
|
||
.foregroundStyle(.red)
|
||
}
|
||
}
|
||
|
||
private func errorText(_ error: ServerFormFieldError, field: ServerFormField)
|
||
-> LocalizedStringKey
|
||
{
|
||
switch error {
|
||
case .missingName:
|
||
return "请输入服务器名称"
|
||
case .tooManyCharacters(let limit):
|
||
switch field {
|
||
case .name: return "名称最多 \(limit) 个字符"
|
||
case .address: return "服务器地址最多 \(limit) 个字符"
|
||
case .password: return "密码最多 \(limit) 个字符"
|
||
case .port, .pluginOptions: return ""
|
||
}
|
||
case .tooManyBytes(let limit):
|
||
return "插件参数最多 \(limit) 个字节"
|
||
case .invalidPort:
|
||
return "端口必须是 1–65535 的数字(最多 \(ServerFormFields.portDigitLimit) 位)"
|
||
case .unfinishedPluginOptionRow:
|
||
return ""
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 扣除间距后按 4:1 分配地址和端口宽度,高度由字段自身决定。
|
||
private struct ServerEndpointLayout: Layout {
|
||
private let spacing: CGFloat = 20
|
||
|
||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||
let width = proposal.width ?? 480
|
||
let unit = max(0, width - spacing) / 5
|
||
let height =
|
||
subviews.enumerated().map { index, view in
|
||
view.sizeThatFits(ProposedViewSize(width: unit * (index == 0 ? 4 : 1), height: nil)).height
|
||
}.max() ?? 0
|
||
return CGSize(width: width, height: height)
|
||
}
|
||
|
||
func placeSubviews(
|
||
in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()
|
||
) {
|
||
let unit = max(0, bounds.width - spacing) / 5
|
||
for (index, view) in subviews.enumerated() {
|
||
view.place(
|
||
at: CGPoint(x: bounds.minX + (index == 0 ? 0 : unit * 4 + spacing), y: bounds.minY),
|
||
anchor: .topLeading,
|
||
proposal: ProposedViewSize(width: unit * (index == 0 ? 4 : 1), height: nil))
|
||
}
|
||
}
|
||
}
|