mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-14 10:13:54 +00:00
issue #81 第一部分:手动表单产品上限(名称 128、地址 255、密码 1024 用户可见字符;插件参数 65,536 UTF-8 字节;端口最多五位十进制数字)。 校验收进 ServerFormFields 唯一入口 validateForSubmit(),字段错误 typed 呈现、修正即清除、首错按栅格顺序定位;validateName/hasNameError 退役。端口改十进制字符串草稿,draft 在草稿不可解析时为 nil、仅在 校验通过后消费,数值等价改写不算变更——无效输入不再悄悄提交旧绑定 值。与装载基线逐字相同的超长字段放行(历史数据兼容);导入与订阅 路径不受限。新错误文案入 Localizable.xcstrings(中英)。
337 lines
13 KiB
Swift
337 lines
13 KiB
Swift
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
|
||
}
|
||
|
||
/// 服务器表单草稿(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
|
||
@Published var pluginOptionsText = "" { didSet { clearError(.pluginOptions) } }
|
||
@Published var showPassword = false
|
||
@Published private(set) var fieldErrors: [ServerFormField: ServerFormFieldError] = [:]
|
||
@Published private var savedDraft: ServerEditDraft?
|
||
|
||
/// 变更检测按提交草稿比较;端口在草稿可解析时按数值比较(如前导零改写
|
||
/// 不算变更),不可解析视为已变更,保证非法输入后保存入口仍可点击并在
|
||
/// 提交时得到行内错误,而不是按钮死锁。
|
||
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 {
|
||
errors[.pluginOptions] = utf8LimitError(
|
||
raw: pluginOptionsText, 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
|
||
pluginOptionsText = state.plugin.options
|
||
showPassword = false
|
||
fieldErrors = [:]
|
||
savedDraft = draft
|
||
}
|
||
|
||
/// 提交载荷:配置与凭据字段作为一个逻辑变更(story 12)。端口取自十进制
|
||
/// 草稿;草稿不能作为提交端口时为 nil——本 getter 仅在 `validateForSubmit()`
|
||
/// 通过后消费,无效端口不得悄悄回落到旧值(issue #81)。
|
||
var draft: ServerEditDraft? {
|
||
guard let port = submittablePort else { return nil }
|
||
return ServerEditDraft(
|
||
address: address,
|
||
port: port,
|
||
encryptionMethod: encryptionMethod,
|
||
password: password,
|
||
remark: remark,
|
||
plugin: pluginChoice,
|
||
pluginOptions: pluginOptionsText)
|
||
}
|
||
|
||
// 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,
|
||
optionsText: $fields.pluginOptionsText,
|
||
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) 位)"
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 扣除间距后按 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))
|
||
}
|
||
}
|
||
}
|