Files
ShadowsocksX-NG/ShadowsocksX-NG2/App/ServerFormFields.swift
T
Qiu Yuzhou a955f5ecb8 feat(ui): 插件参数无损表格编辑会话
issue #81 第二部分:新 PluginOptionsDraft 会话草稿模块——带稳定行
身份的有序参数行(无值开关与空串值之分)+ 参数列表/原始文本双模式。
解析与拼装遵循受管 v2ray-plugin v1.3.2 的 SIP003 转义语义(未转义
分号分隔、首个未转义等号分键值、反斜杠转义);未修改行逐字回写原文
片段,仅显式编辑的行重新编码,既有末尾分号无结构修改时保留;不可
靠解析默认并留在原始文本模式,不强行转换。

编辑面:底部空白快速添加行(输入即提升、身份与焦点稳定)、行内
编辑与显式删除、键值⇄开关可逆且保留会话值草稿、长值「编辑值…」
独立 sheet(等宽多行、确认才写回)。未完成行(有值/开关形态却无
参数名)阻止拼装、保存与切向原始文本;模式切换与展开编辑不触发
hasChanges;原始串仍是持久化与运行时唯一载荷。CONTEXT.md 新增
Plugin parameter draft 词条;新文案入 Localizable.xcstrings(中英)。
2026-10-04 04:45:01 +08:00

359 lines
14 KiB
Swift
Raw 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 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?
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: - 校验实现
/// 端口草稿可否作为提交端口:非空、纯 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))
}
}
}