From 60a31b5df30c619bb02c34bdc8221add1a080ff8 Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Sun, 20 Sep 2026 21:37:38 +0800 Subject: [PATCH] feat(ng2): main window server tree, ss:// codec and QR share/import (#32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主窗口服务器分区(D11,issue #32):NavigationSplitView 分组树(订阅子树 只读、仅启用开关;行内 checkbox;右键激活/启停/重命名/新建分组/移动/删除, 非空手动组二次确认;拖拽移动,跨来源拒绝;空组保留可编辑)、服务器详情 表单(插件区只读展示「本版本未提供」状态)、分组详情。添加三入口(剪贴板 ss://、导入 URL、二维码图片识别:文件选择 + 拖图入窗)共用批量导入落点, 每次新建身份不去重;分享区二维码弹窗 + 复制 ss://。SsUri 按 SIP002 编解码 (含 legacy base64 兼容、SIP022 明文 userinfo、IPv6、插件 name;opts 逐字节 同构),二维码走 CoreImage 生成 + Vision 识别。目录提交后接 postCommit 钩 子,代理运行时立即重展开(#27 catalogDidCommit)。 --- ShadowsocksX-NG2/App/CatalogViewModel.swift | 291 ++++++++++++++++++ .../App/CatalogViewModelTypes.swift | 32 ++ ShadowsocksX-NG2/App/ErrorPresentation.swift | 70 +++++ ShadowsocksX-NG2/App/GroupDetailView.swift | 63 ++++ ShadowsocksX-NG2/App/ImportSheets.swift | 248 +++++++++++++++ ShadowsocksX-NG2/App/MainApp.swift | 8 +- ShadowsocksX-NG2/App/MainWindowView.swift | 285 +++++++++++++++++ .../App/PlaceholderMainWindow.swift | 20 -- ShadowsocksX-NG2/App/QrCodeCodec.swift | 48 +++ ShadowsocksX-NG2/App/ServerDetailView.swift | 198 ++++++++++++ ShadowsocksX-NG2/App/SidebarRow.swift | 92 ++++++ ShadowsocksX-NG2/Domain/SsUri.swift | 265 ++++++++++++++++ .../Tests/CatalogViewModelTests.swift | 274 +++++++++++++++++ ShadowsocksX-NG2/Tests/QrCodeCodecTests.swift | 25 ++ ShadowsocksX-NG2/Tests/SsUriTests.swift | 237 ++++++++++++++ 15 files changed, 2135 insertions(+), 21 deletions(-) create mode 100644 ShadowsocksX-NG2/App/CatalogViewModel.swift create mode 100644 ShadowsocksX-NG2/App/CatalogViewModelTypes.swift create mode 100644 ShadowsocksX-NG2/App/ErrorPresentation.swift create mode 100644 ShadowsocksX-NG2/App/GroupDetailView.swift create mode 100644 ShadowsocksX-NG2/App/ImportSheets.swift create mode 100644 ShadowsocksX-NG2/App/MainWindowView.swift delete mode 100644 ShadowsocksX-NG2/App/PlaceholderMainWindow.swift create mode 100644 ShadowsocksX-NG2/App/QrCodeCodec.swift create mode 100644 ShadowsocksX-NG2/App/ServerDetailView.swift create mode 100644 ShadowsocksX-NG2/App/SidebarRow.swift create mode 100644 ShadowsocksX-NG2/Domain/SsUri.swift create mode 100644 ShadowsocksX-NG2/Tests/CatalogViewModelTests.swift create mode 100644 ShadowsocksX-NG2/Tests/QrCodeCodecTests.swift create mode 100644 ShadowsocksX-NG2/Tests/SsUriTests.swift diff --git a/ShadowsocksX-NG2/App/CatalogViewModel.swift b/ShadowsocksX-NG2/App/CatalogViewModel.swift new file mode 100644 index 00000000..82080511 --- /dev/null +++ b/ShadowsocksX-NG2/App/CatalogViewModel.swift @@ -0,0 +1,291 @@ +import Foundation +import SwiftUI + +/// 主窗口服务器分区的视图模型(issue #32):把 ConfigurationCatalog 领域语义 +/// 接到树操作、详情表单、添加三入口与分享。所有变更走「副本变更 → 落盘 → +/// 发布 → postCommit 重展开」,失败即整体不变更;凭据读写只在模型层出现。 +@MainActor +final class CatalogViewModel: ObservableObject { + @Published private(set) var catalog: ConfigurationCatalog + @Published var selectedNodeID: NodeID? + /// 需要弹窗呈现的错误(领域拒绝、导入失败、凭据失败)。 + @Published var presentedError: String? + + private let fileStore: CatalogFileStore + private let credentials: CredentialStoring + private let plugins: ManagedPluginProviding + /// 目录提交后的运行时重展开(生产接线 `ProxyRuntimeController.catalogDidCommit`)。 + var postCommit: (() async -> Void)? + + init( + fileStore: CatalogFileStore = CatalogFileStore(fileURL: CatalogFileStore.defaultFileURL()), + credentials: CredentialStoring = KeychainCredentialStore(), + plugins: ManagedPluginProviding = NoManagedPluginProvider() + ) { + self.fileStore = fileStore + self.credentials = credentials + self.plugins = plugins + catalog = (try? fileStore.load()) ?? ConfigurationCatalog() + } + + // MARK: - 查询面 + + func entry(for id: NodeID) -> CatalogEntry? { catalog.entry(for: id) } + + func parentID(of id: NodeID) -> NodeID? { (try? catalog.parentID(of: id)) ?? nil } + + func isEffectivelyEnabled(_ id: NodeID) -> Bool { + (try? catalog.isEffectivelyEnabled(id)) ?? false + } + + /// 行显示名:服务器备注优先、回退地址(含订阅 remarks 回退语义);分组用名称。 + func displayName(for id: NodeID) -> String { + guard let entry = catalog.entry(for: id) else { return "" } + switch entry.kind { + case .group(let fields): + return fields.name + case .server(let fields): + return fields.remark.isEmpty ? fields.address : fields.remark + } + } + + // MARK: - 侧栏树快照 + + func sidebarNodes() -> [SidebarNode] { + sidebarChildren(of: nil) + } + + private func sidebarChildren(of parent: NodeID?) -> [SidebarNode] { + let ids = (try? catalog.children(of: parent)) ?? [] + return ids.compactMap { id in + guard let entry = catalog.entry(for: id) else { return nil } + let isGroup: Bool = { + if case .group = entry.kind { return true } + return false + }() + return SidebarNode( + id: id, + name: displayName(for: id), + isGroup: isGroup, + source: entry.source, + enabled: entry.enabled, + effectivelyEnabled: isEffectivelyEnabled(id), + children: isGroup ? sidebarChildren(of: id) : nil) + } + } + + // MARK: - 变更面(CatalogError / ServerFormError / CredentialStoreError 上抛) + + /// ss:// 批量导入(剪贴板、导入 URL、二维码识别三入口的共同落点)。 + /// 逐行解码,可解析行全部添加(每次新建身份,不按内容去重);返回成功条数 + /// 与失败行的点名原因。 + func addServers(fromURIs text: String, into parent: NodeID?) async throws -> ( + added: Int, failures: [String] + ) { + var parsed: [SsUri] = [] + var failures: [String] = [] + for line in text.split(whereSeparator: \.isNewline) { + do { + parsed.append(try SsUri.decode(String(line))) + } catch { + let digest = line.count > 24 ? "\(line.prefix(24))…" : line + let reason = (error as? SsUriError).map { String(describing: $0) } ?? "未知错误" + failures.append("「\(digest)」:\(reason)") + } + } + try await commit { catalog in + for uri in parsed { + let fields = try Self.serverFields(from: uri, credentials: credentials) + try catalog.addServer(fields, to: parent) + } + } + return (parsed.count, failures) + } + + @discardableResult + func addGroup(named name: String, into parent: NodeID?) async throws -> NodeID { + let trimmed = name.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { throw ServerFormError.emptyName } + return try await commit { catalog in + try catalog.addGroup(trimmed, to: parent) + } + } + + func renameGroup(_ id: NodeID, to name: String) async throws { + let trimmed = name.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { throw ServerFormError.emptyName } + try await commit { catalog in + try catalog.renameGroup(id, to: trimmed) + } + } + + func setEnabled(_ id: NodeID, _ enabled: Bool) async throws { + try await commit { catalog in + try catalog.setEnabled(id, enabled) + } + } + + /// 拖拽/移动菜单落点。跨来源、成环、移动进自身由领域拒绝,原样上抛呈现。 + func move(_ id: NodeID, to parent: NodeID?) async throws { + try await commit { catalog in + try catalog.move(id, to: parent) + } + } + + /// 删除(手动分组递归删整棵子树),并尽力清理被删节点的 Keychain 秘密。 + func remove(_ id: NodeID) async throws { + let removed: [CatalogEntry] = try await commit { catalog in + try catalog.remove(id) + } + if let selection = selectedNodeID, removed.contains(where: { $0.id == selection }) { + selectedNodeID = nil + } + for entry in removed { + if let fields = serverFields(of: entry) { + try? credentials.delete(fields.passwordRef) + if let optionsRef = fields.pluginOptionsRef { try? credentials.delete(optionsRef) } + } + } + } + + /// 详情表单提交(手动服务器)。密码经凭据存储覆盖写;插件区本票只读不在此改。 + func updateServer( + _ id: NodeID, + address: String, + port: Int, + encryptionMethod: String, + password: String, + remark: String + ) async throws { + let trimmedAddress = address.trimmingCharacters(in: .whitespaces) + guard !trimmedAddress.isEmpty else { throw ServerFormError.invalidAddress } + guard (1...65_535).contains(port) else { throw ServerFormError.invalidPort } + try await commit { catalog in + guard let entry = catalog.entry(for: id), case .server(var fields) = entry.kind else { + throw CatalogError.notAServer(id) + } + try credentials.save(password, for: fields.passwordRef) + fields.address = trimmedAddress + fields.port = port + fields.encryptionMethod = encryptionMethod + fields.remark = remark.trimmingCharacters(in: .whitespaces) + try catalog.updateServer(id, with: fields) + } + } + + // MARK: - 详情表单 + + func serverFormState(for id: NodeID) -> ServerFormState? { + guard let entry = catalog.entry(for: id), case .server(let fields) = entry.kind else { + return nil + } + let password = (try? credentials.secret(for: fields.passwordRef)) ?? "" + let plugin = fields.pluginProgram.map { program in + PluginDisplay( + program: program, + provided: plugins.executablePath(forProgram: program) != nil, + optionsPresent: fields.pluginOptionsRef != nil) + } + return ServerFormState( + address: fields.address, + port: fields.port, + encryptionMethod: fields.encryptionMethod, + password: password, + remark: fields.remark, + plugin: plugin, + isEditable: entry.source == .manual) + } + + func isPluginProvided(_ program: String) -> Bool { + plugins.executablePath(forProgram: program) != nil + } + + // MARK: - 分享 + + /// 服务器 → ss://(SIP002):凭据从存储解析;无凭据即点名失败(不分享空档)。 + func ssUri(for id: NodeID) throws -> String { + guard let entry = catalog.entry(for: id), case .server(let fields) = entry.kind else { + throw CatalogError.notAServer(id) + } + guard let password = try credentials.secret(for: fields.passwordRef) else { + throw CredentialStoreError.keychainStatus(errSecItemNotFound) + } + var pluginOptions: String? + if let optionsRef = fields.pluginOptionsRef { + pluginOptions = try credentials.secret(for: optionsRef) ?? "" + } + return SsUri( + method: fields.encryptionMethod, + password: password, + host: fields.address, + port: fields.port, + pluginProgram: fields.pluginProgram, + pluginOptions: pluginOptions, + remark: fields.remark.isEmpty ? nil : fields.remark + ).encode() + } + + /// 添加落点:选中手动分组 → 组内;选中服务器 → 其父组(仅手动);其余 → 根。 + func importTargetParent(for selection: NodeID?) -> NodeID? { + guard let selection, let entry = catalog.entry(for: selection) else { return nil } + switch entry.kind { + case .group: + return entry.source == .manual ? selection : nil + case .server: + guard let parent = parentID(of: selection) else { return nil } + return catalog.entry(for: parent)?.source == .manual ? parent : nil + } + } + + // MARK: - 提交管线 + + /// 副本变更 → 落盘 → 发布 → postCommit;任一步失败则已发布状态不动。 + private func commit( + _ mutate: (inout ConfigurationCatalog) throws -> T + ) async throws -> T { + var working = catalog + let result = try mutate(&working) + try fileStore.save(working) + catalog = working + await postCommit?() + return result + } + + /// URI → 服务器叶子字段:密码与插件参数入凭据存储、目录只持引用。 + private static func serverFields( + from uri: SsUri, credentials: CredentialStoring + ) throws -> ServerFields { + let passwordRef = CredentialReference.fresh() + try credentials.save(uri.password, for: passwordRef) + var pluginProgram: String? + var pluginOptionsRef: CredentialReference? + if let program = uri.pluginProgram { + pluginProgram = program + if let options = uri.pluginOptions { + let ref = CredentialReference.fresh() + try credentials.save(options, for: ref) + pluginOptionsRef = ref + } + } + return ServerFields( + address: uri.host, + port: uri.port, + encryptionMethod: uri.method, + passwordRef: passwordRef, + remark: uri.remark ?? "", + pluginProgram: pluginProgram, + pluginOptionsRef: pluginOptionsRef) + } + + private func serverFields(of entry: CatalogEntry) -> ServerFields? { + if case .server(let fields) = entry.kind { return fields } + return nil + } +} + +/// 表单级校验失败(地址/端口/名称);领域拒绝仍以 CatalogError 上抛。 +enum ServerFormError: Error, Equatable { + case invalidAddress + case invalidPort + case emptyName +} diff --git a/ShadowsocksX-NG2/App/CatalogViewModelTypes.swift b/ShadowsocksX-NG2/App/CatalogViewModelTypes.swift new file mode 100644 index 00000000..374ce42d --- /dev/null +++ b/ShadowsocksX-NG2/App/CatalogViewModelTypes.swift @@ -0,0 +1,32 @@ +import Foundation + +/// 侧栏树行快照(由 CatalogViewModel 派生,订阅分组同样入树供浏览)。 +struct SidebarNode: Identifiable { + let id: NodeID + let name: String + let isGroup: Bool + let source: NodeSource + let enabled: Bool + let effectivelyEnabled: Bool + /// 服务器叶子为 `nil`;分组持有子树快照。 + let children: [SidebarNode]? +} + +/// 服务器详情表单状态:凭据已解析为明文(仅本窗口内呈现);插件区只读(#38)。 +struct ServerFormState: Equatable { + let address: String + let port: Int + let encryptionMethod: String + let password: String + let remark: String + /// `nil` 即「无插件」;否则显示程序引用与「本版本是否提供」状态(D10)。 + let plugin: PluginDisplay? + let isEditable: Bool +} + +/// 插件区只读展示三要素:程序引用、本版本是否提供、参数是否已配置。 +struct PluginDisplay: Equatable { + let program: String + let provided: Bool + let optionsPresent: Bool +} diff --git a/ShadowsocksX-NG2/App/ErrorPresentation.swift b/ShadowsocksX-NG2/App/ErrorPresentation.swift new file mode 100644 index 00000000..ef508ae6 --- /dev/null +++ b/ShadowsocksX-NG2/App/ErrorPresentation.swift @@ -0,0 +1,70 @@ +import Foundation + +/// 错误 → 用户可读文案(主窗口弹窗呈现)。领域拒绝逐条点名原因,与 spec #21 +/// 的「无静默回退、点名失败」语义一致。 +extension Error { + var presentableMessage: String { + switch self { + case let error as CatalogError: + return error.presentedMessage + case let error as ServerFormError: + switch error { + case .invalidAddress: + return "服务器地址不能为空" + case .invalidPort: + return "端口必须是 1–65535 之间的整数" + case .emptyName: + return "名称不能为空" + } + case let error as SsUriError: + switch error { + case .notSsUri: + return "不是 ss:// 链接" + case .malformed(let detail): + return "无法解析 ss:// 链接:\(detail)" + } + case let error as CredentialStoreError: + switch error { + case .keychainStatus(let status): + return "钥匙串操作失败(错误码 \(status))" + case .secretNotUTF8: + return "钥匙串中的凭据不是文本" + } + default: + return String(describing: self) + } + } +} + +extension CatalogError { + var presentedMessage: String { + switch self { + case .nodeNotFound: + return "节点不存在(可能已被删除)" + case .parentNotFound: + return "目标分组不存在" + case .parentNotAGroup: + return "目标位置不是分组" + case .notAServer: + return "该节点不是服务器" + case .notAGroup: + return "该节点不是分组" + case .duplicateID: + return "节点身份冲突" + case .crossSourcePlacement(_, let container): + return container == .subscription + ? "不能把手动节点移入订阅子树" + : "不能把订阅节点移入手动子树" + case .subscriptionNodeImmutable: + return "订阅节点由远端管理:结构、连接字段与名称只读,仅启用开关可调" + case .subscriptionServerAtRoot: + return "订阅服务器不能移出其订阅分组" + case .cycleDetected: + return "不能把分组移动进它自己的子树" + case .indexOutOfRange: + return "插入位置超出范围" + case .invalidStructure: + return "配置目录结构不一致" + } + } +} diff --git a/ShadowsocksX-NG2/App/GroupDetailView.swift b/ShadowsocksX-NG2/App/GroupDetailView.swift new file mode 100644 index 00000000..13e05f95 --- /dev/null +++ b/ShadowsocksX-NG2/App/GroupDetailView.swift @@ -0,0 +1,63 @@ +import SwiftUI + +/// 分组详情(issue #32):名称编辑(手动组)、直接子节点数与激活入口。 +/// 订阅固定分组名称由远端管理,只读。空组保留可编辑(可先搭结构再填内容)。 +struct GroupDetailView: View { + let viewModel: CatalogViewModel + let groupID: NodeID + let proxyController: ProxyRuntimeController + + @State private var name = "" + + private var entry: CatalogEntry? { viewModel.entry(for: groupID) } + private var isManual: Bool { entry?.source == .manual } + + var body: some View { + Form { + Section("分组") { + TextField("名称", text: $name) + .disabled(!isManual) + if isManual { + Button("保存名称") { saveName() } + } + } + Section { + LabeledContent("直接子节点", value: "\(directChildCount)") + LabeledContent("来源", value: isManual ? "手动" : "订阅") + Button { + Task { await proxyController.activate(groupID) } + } label: { + Label("激活此分组", systemImage: "bolt.fill") + } + .disabled(directChildCount == 0) + if directChildCount == 0 { + Text("空分组保留可编辑,但不能激活。") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + .formStyle(.grouped) + .navigationTitle(viewModel.displayName(for: groupID)) + .onAppear(perform: loadName) + .onChange(of: groupID) { _, _ in loadName() } + } + + private var directChildCount: Int { + (try? viewModel.catalog.children(of: groupID))?.count ?? 0 + } + + private func loadName() { + name = viewModel.displayName(for: groupID) + } + + private func saveName() { + Task { + do { + try await viewModel.renameGroup(groupID, to: name) + } catch { + viewModel.presentedError = error.presentableMessage + } + } + } +} diff --git a/ShadowsocksX-NG2/App/ImportSheets.swift b/ShadowsocksX-NG2/App/ImportSheets.swift new file mode 100644 index 00000000..4cd94772 --- /dev/null +++ b/ShadowsocksX-NG2/App/ImportSheets.swift @@ -0,0 +1,248 @@ +import SwiftUI +import UniformTypeIdentifiers + +/// 「通过 URL 导入」表单(issue #32 添加入口之二):粘贴 ss:// 链接(每行一条), +/// 与剪贴板入口共用视图模型的批量落点。 +struct ImportURLSheet: View { + @ObservedObject var viewModel: CatalogViewModel + @Environment(\.dismiss) private var dismiss + + @State private var text = "" + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("粘贴 ss:// 链接,每行一条;无法解析的行会点名报告。") + .font(.callout) + .foregroundStyle(.secondary) + TextEditor(text: $text) + .font(.monospaced(.callout)()) + .frame(minWidth: 460, minHeight: 140) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(.separator) + ) + HStack { + Button("粘贴自剪贴板") { + if let clipboard = NSPasteboard.general.string(forType: .string) { + text = clipboard + } + } + Spacer() + Button("取消", role: .cancel) { dismiss() } + Button("导入") { importText() } + .keyboardShortcut(.defaultAction) + .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .padding(20) + .frame(minWidth: 520, minHeight: 260) + } + + private func importText() { + let parent = viewModel.importTargetParent(for: viewModel.selectedNodeID) + Task { + do { + let outcome = try await viewModel.addServers(fromURIs: text, into: parent) + if !outcome.failures.isEmpty { + viewModel.presentedError = + "已添加 \(outcome.added) 台服务器;以下条目无法解析:\n" + + outcome.failures.joined(separator: "\n") + } + dismiss() + } catch { + viewModel.presentedError = error.presentableMessage + } + } + } +} + +/// 「从二维码图片识别」表单(issue #32 添加入口之三):文件选择 + 拖图入窗。 +/// 屏幕扫码明确不做(spec #21);识别结果按全新身份导入,不与既有节点合并去重。 +struct QRImportSheet: View { + @ObservedObject var viewModel: CatalogViewModel + @Environment(\.dismiss) private var dismiss + + @State private var detectedURIs: [String] = [] + @State private var decodeFailures: [String] = [] + @State private var showFileImporter = false + @State private var hovering = false + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + dropZone + Button("选择图片文件…") { showFileImporter = true } + if !decodeFailures.isEmpty { + Text("无法识别:\(decodeFailures.joined(separator: "、"))") + .font(.footnote) + .foregroundStyle(.secondary) + } + if !detectedURIs.isEmpty { + Text("识别到 \(detectedURIs.count) 条 ss:// 链接:") + .font(.callout) + ForEach(detectedURIs, id: \.self) { uri in + Text(uri) + .font(.monospaced(.footnote)()) + .lineLimit(1) + .truncationMode(.middle) + .help(uri) + } + HStack { + Spacer() + Button("清除") { detectedURIs = [] } + Button("导入 \(detectedURIs.count) 台服务器") { importDetected() } + .keyboardShortcut(.defaultAction) + } + } + Spacer() + } + .padding(20) + .frame(minWidth: 480, minHeight: 300) + .fileImporter( + isPresented: $showFileImporter, + allowedContentTypes: [.image], + allowsMultipleSelection: true + ) { result in + let urls = (try? result.get()) ?? [] + processImageSources(urls.map { try? Data(contentsOf: $0) }) + } + } + + private var dropZone: some View { + VStack(spacing: 8) { + Image(systemName: "photo.on.rectangle.angled") + .font(.system(size: 36)) + .foregroundStyle(hovering ? .orange : .secondary) + Text("把二维码图片拖到这里") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .frame(height: 120) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(hovering ? Color.orange.opacity(0.08) : Color.primary.opacity(0.03)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [5])) + .foregroundStyle(hovering ? AnyShapeStyle(Color.orange) : AnyShapeStyle(.separator)) + ) + .dropDestination(for: Data.self) { payloads, _ in + processImageSources(payloads) + return !payloads.isEmpty + } isTargeted: { + hovering = $0 + } + } + + /// 逐张识别二维码文本负载,仅保留 ss:// 行;非图片或无负载的来源点名。 + private func processImageSources(_ sources: [Data?]) { + Task.detached(priority: .userInitiated) { + var uris: [String] = [] + var failures: [String] = [] + for (index, data) in sources.enumerated() { + guard let data else { + failures.append("来源 \(index + 1)(不是可用图片)") + continue + } + let payloads = (try? QrCodeCodec.detectPayloads(in: data)) ?? [] + let ssPayloads = payloads.filter { $0.lowercased().hasPrefix("ss://") } + if ssPayloads.isEmpty { + failures.append("来源 \(index + 1)(未识别到 ss:// 二维码)") + } else { + uris.append(contentsOf: ssPayloads) + } + } + await MainActor.run { + detectedURIs.append(contentsOf: uris) + decodeFailures = failures + } + } + } + + private func importDetected() { + let parent = viewModel.importTargetParent(for: viewModel.selectedNodeID) + Task { + do { + let text = detectedURIs.joined(separator: "\n") + let outcome = try await viewModel.addServers(fromURIs: text, into: parent) + if !outcome.failures.isEmpty { + viewModel.presentedError = + "已添加 \(outcome.added) 台服务器;以下条目无法解析:\n" + + outcome.failures.joined(separator: "\n") + } + dismiss() + } catch { + viewModel.presentedError = error.presentableMessage + } + } + } +} + +/// 「移动到」表单:目的地为目录根与除自身子树外的全部手动组(跨来源与成环 +/// 由领域层最终拒绝)。 +struct MoveNodeSheet: View { + @ObservedObject var viewModel: CatalogViewModel + let nodeID: NodeID + @Environment(\.dismiss) private var dismiss + + @State private var destination: NodeID? + + private struct Destination: Identifiable { + let id: NodeID? + let title: String + } + + private var destinations: [Destination] { + var result = [Destination(id: nil, title: "目录根")] + func walk(_ nodes: [SidebarNode], depth: Int) { + for node in nodes { + guard node.isGroup, node.source == .manual else { continue } + let ancestors = viewModel.catalog.ancestors(of: node.id) + guard node.id != nodeID, !ancestors.contains(nodeID) else { continue } + let indent = String(repeating: " ", count: depth) + result.append(Destination(id: node.id, title: indent + node.name)) + walk(node.children ?? [], depth: depth + 1) + } + } + walk(viewModel.sidebarNodes(), depth: 0) + return result + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("把「\(viewModel.displayName(for: nodeID))」移动到:") + .font(.callout) + Picker( + "目的地", + selection: Binding( + get: { destination ?? destinations.first?.id }, + set: { destination = $0 } + ) + ) { + ForEach(destinations) { target in + Text(target.title).tag(target.id as NodeID?) + } + } + .pickerStyle(.radioGroup) + HStack { + Spacer() + Button("取消", role: .cancel) { dismiss() } + Button("移动") { move() } + .keyboardShortcut(.defaultAction) + } + } + .padding(20) + .frame(minWidth: 380) + } + + private func move() { + Task { + do { + try await viewModel.move(nodeID, to: destination) + dismiss() + } catch { + viewModel.presentedError = error.presentableMessage + } + } + } +} diff --git a/ShadowsocksX-NG2/App/MainApp.swift b/ShadowsocksX-NG2/App/MainApp.swift index 39c690f2..6e0cdde0 100644 --- a/ShadowsocksX-NG2/App/MainApp.swift +++ b/ShadowsocksX-NG2/App/MainApp.swift @@ -4,6 +4,7 @@ import SwiftUI struct ShadowsocksXNG2App: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @StateObject private var proxyController = ProxyRuntimeController() + @StateObject private var catalogViewModel = CatalogViewModel() var body: some Scene { MenuBarExtra("ShadowsocksX-NG 2.0", systemImage: "network") { @@ -13,7 +14,12 @@ struct ShadowsocksXNG2App: App { .menuBarExtraStyle(.menu) Window("ShadowsocksX-NG 2.0", id: "main") { - PlaceholderMainWindow() + MainWindowView(viewModel: catalogViewModel, proxyController: proxyController) + .task { + // 目录提交 → 代理运行时立即重展开(spec #21 D3「已提交编辑立即跟随」)。 + let controller = proxyController + catalogViewModel.postCommit = { await controller.catalogDidCommit() } + } } .defaultLaunchBehavior(.suppressed) } diff --git a/ShadowsocksX-NG2/App/MainWindowView.swift b/ShadowsocksX-NG2/App/MainWindowView.swift new file mode 100644 index 00000000..5c6c39a3 --- /dev/null +++ b/ShadowsocksX-NG2/App/MainWindowView.swift @@ -0,0 +1,285 @@ +import SwiftUI + +/// 主窗口(spec #21 D11,issue #32):NavigationSplitView 服务器分区。侧栏是 +/// 配置目录分组树(订阅子树只读、仅启用开关),详情区按选中形态呈现服务器 +/// 表单或分组编辑。所有编辑类操作只在主窗口。 +struct MainWindowView: View { + @ObservedObject var viewModel: CatalogViewModel + @ObservedObject var proxyController: ProxyRuntimeController + + @State private var renameTarget: NodeID? + @State private var renameText = "" + @State private var newGroupParent: NodeID? + @State private var newGroupName = "" + @State private var deleteTarget: NodeID? + @State private var moveTarget: NodeID? + @State private var showImportURLSheet = false + @State private var showQRImportSheet = false + @State private var rootDropHovering = false + + var body: some View { + NavigationSplitView { + sidebar + } detail: { + detailPane + } + .frame(minWidth: 720, minHeight: 420) + .alert( + "重命名分组", + isPresented: Binding( + get: { renameTarget != nil }, + set: { if !$0 { renameTarget = nil } }) + ) { + TextField("名称", text: $renameText) + Button("确定") { commitRename() } + Button("取消", role: .cancel) { renameTarget = nil } + } + .alert( + "新建分组", + isPresented: Binding( + get: { newGroupParent != nil }, + set: { if !$0 { newGroupParent = nil } }) + ) { + TextField("名称", text: $newGroupName) + Button("创建") { commitNewGroup() } + Button("取消", role: .cancel) { newGroupParent = nil } + } + .alert( + "操作失败", + isPresented: Binding( + get: { viewModel.presentedError != nil }, + set: { if !$0 { viewModel.presentedError = nil } }) + ) { + Button("好", role: .cancel) {} + } message: { + Text(viewModel.presentedError ?? "") + } + .confirmationDialog( + deleteTitle, + isPresented: Binding( + get: { deleteTarget != nil }, + set: { if !$0 { deleteTarget = nil } }), + titleVisibility: .visible + ) { + Button("删除", role: .destructive) { commitDelete() } + Button("取消", role: .cancel) { deleteTarget = nil } + } message: { + Text(deleteMessage) + } + .sheet(isPresented: $showImportURLSheet) { + ImportURLSheet(viewModel: viewModel) + } + .sheet(isPresented: $showQRImportSheet) { + QRImportSheet(viewModel: viewModel) + } + .sheet( + item: Binding( + get: { moveTarget.map(MoveContext.init) }, + set: { moveTarget = $0?.nodeID }) + ) { context in + MoveNodeSheet(viewModel: viewModel, nodeID: context.nodeID) + } + } + + // MARK: - 侧栏树 + + private var sidebar: some View { + List(selection: $viewModel.selectedNodeID) { + OutlineGroup(viewModel.sidebarNodes(), children: \.children) { node in + SidebarRow( + node: node, + viewModel: viewModel, + proxyController: proxyController, + onRename: { id in + renameTarget = id + renameText = viewModel.displayName(for: id) + }, + onNewGroup: { parent in + newGroupParent = parent + newGroupName = "" + }, + onMove: { moveTarget = $0 }, + onDelete: { deleteTarget = $0 } + ) + .onDrag { + // 订阅节点结构只读:携带空负载,落点校验节点存在性后自动忽略。 + node.source == .manual + ? NSItemProvider(object: node.id.rawValue as NSString) + : NSItemProvider() + } + .dropDestination(for: String.self) { payload, _ in + handleDrop(payload, onto: node.id) + } isTargeted: { _ in + } + } + } + .listStyle(.sidebar) + .overlay(alignment: .center) { + if viewModel.catalog.isEmpty { + ContentUnavailableView( + "暂无服务器", systemImage: "server.rack", + description: Text("用工具栏「添加」导入 ss:// 链接或新建分组") + ) + .allowsHitTesting(false) + } + } + .dropDestination(for: String.self) { payload, _ in + handleDrop(payload, onto: nil) + } isTargeted: { hovering in + rootDropHovering = hovering + } + .toolbar { toolbarContent } + } + + /// 落点语义:拖到分组行 = 移入该组(仅手动组接受),拖到列表空白/根 = 移到根。 + /// 跨来源由视图过滤 + 领域拒绝双重保证;无效负载不接收。 + private func handleDrop(_ payload: [String], onto target: NodeID?) -> Bool { + guard let raw = payload.first else { return false } + let dragged = NodeID(rawValue: raw) + guard dragged != target, viewModel.catalog.contains(dragged) else { return false } + Task { + do { + try await viewModel.move(dragged, to: target) + } catch { + viewModel.presentedError = error.presentableMessage + } + } + return true + } + + // MARK: - 详情区 + + @ViewBuilder + private var detailPane: some View { + if let id = viewModel.selectedNodeID, let entry = viewModel.entry(for: id) { + switch entry.kind { + case .server: + ServerDetailView( + viewModel: viewModel, serverID: id, proxyController: proxyController) + case .group: + GroupDetailView(viewModel: viewModel, groupID: id, proxyController: proxyController) + } + } else { + ContentUnavailableView( + "未选择节点", systemImage: "sidebar.left", + description: Text("在左侧选择服务器或分组查看与编辑详情")) + } + } + + // MARK: - 工具栏(添加三入口 + 新建分组) + + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .primaryAction) { + Menu { + Button("从剪贴板导入 ss://") { importFromClipboard() } + Button("通过 URL 导入…") { showImportURLSheet = true } + Button("从二维码图片导入…") { showQRImportSheet = true } + Divider() + Button("新建分组…") { + newGroupParent = viewModel.importTargetParent(for: viewModel.selectedNodeID) + newGroupName = "" + } + } label: { + Label("添加", systemImage: "plus") + } + } + } + + /// 剪贴板入口:整段剪贴板文本按行解析导入当前落点。 + private func importFromClipboard() { + let text = NSPasteboard.general.string(forType: .string) ?? "" + performImport(text) + } + + private func performImport(_ text: String) { + Task { + do { + let parent = viewModel.importTargetParent(for: viewModel.selectedNodeID) + let outcome = try await viewModel.addServers(fromURIs: text, into: parent) + if outcome.added == 0 && outcome.failures.isEmpty { + viewModel.presentedError = "剪贴板没有可导入的文本" + } else if !outcome.failures.isEmpty { + viewModel.presentedError = + "已添加 \(outcome.added) 台服务器;以下条目无法解析:\n" + + outcome.failures.joined(separator: "\n") + } + } catch { + viewModel.presentedError = error.presentableMessage + } + } + } + + // MARK: - 告警动作 + + private func commitRename() { + guard let id = renameTarget else { return } + renameTarget = nil + Task { + do { + try await viewModel.renameGroup(id, to: renameText) + } catch { + viewModel.presentedError = error.presentableMessage + } + } + } + + private func commitNewGroup() { + guard let parent = newGroupParent else { return } + newGroupParent = nil + Task { + do { + _ = try await viewModel.addGroup(named: newGroupName, into: parent) + } catch { + viewModel.presentedError = error.presentableMessage + } + } + } + + private var deleteTargetName: String { + deleteTarget.map { viewModel.displayName(for: $0) } ?? "" + } + + /// 删除语义:非空手动组点名子树规模(二次确认),空组与服务器单次确认。 + private var deleteTitle: String { + guard let id = deleteTarget, + let entry = viewModel.entry(for: id), + case .group = entry.kind, + entry.source == .manual + else { return "删除「\(deleteTargetName)」?" } + return "删除分组「\(deleteTargetName)」及其整棵子树?" + } + + private var deleteMessage: String { + guard let id = deleteTarget, let entry = viewModel.entry(for: id), + case .group(let fields) = entry.kind, entry.source == .manual + else { return "此操作不可撤销。" } + let count = subtreeCount(fields.children) + return "将递归删除 \(count) 个节点(含其中的服务器与凭据),此操作不可撤销。" + } + + private func subtreeCount(_ children: [NodeID]) -> Int { + children.reduce(0) { total, child in + let grandchildren = (try? viewModel.catalog.children(of: child)) ?? [] + return total + 1 + subtreeCount(grandchildren) + } + } + + private func commitDelete() { + guard let id = deleteTarget else { return } + deleteTarget = nil + Task { + do { + try await viewModel.remove(id) + } catch { + viewModel.presentedError = error.presentableMessage + } + } + } +} + +/// moveTarget 的 sheet(item:) 适配壳。 +private struct MoveContext: Identifiable { + let nodeID: NodeID + var id: NodeID { nodeID } +} diff --git a/ShadowsocksX-NG2/App/PlaceholderMainWindow.swift b/ShadowsocksX-NG2/App/PlaceholderMainWindow.swift deleted file mode 100644 index 00c812b9..00000000 --- a/ShadowsocksX-NG2/App/PlaceholderMainWindow.swift +++ /dev/null @@ -1,20 +0,0 @@ -import SwiftUI - -/// 占位主窗口,由后续工单替换为服务器与订阅管理界面。 -struct PlaceholderMainWindow: View { - var body: some View { - VStack(spacing: 12) { - Image(systemName: "network") - .font(.system(size: 44)) - .foregroundStyle(.secondary) - Text("ShadowsocksX-NG 2.0") - .font(.title2.bold()) - Text("这是工程骨架的占位主窗口,后续工单将逐个替换为真实的管理界面。") - .font(.callout) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - .padding(32) - .frame(width: 420, height: 240) - } -} diff --git a/ShadowsocksX-NG2/App/QrCodeCodec.swift b/ShadowsocksX-NG2/App/QrCodeCodec.swift new file mode 100644 index 00000000..ad5b32be --- /dev/null +++ b/ShadowsocksX-NG2/App/QrCodeCodec.swift @@ -0,0 +1,48 @@ +import AppKit +import CoreImage +import Foundation +import Vision + +/// 二维码编解码(issue #32:分享生成二维码、识别二维码图片导入)。生成走 +/// CoreImage `CIQRCodeGenerator`(纠错级 M),识别走 Vision 条码检测,返回 +/// 全部文本负载。屏幕扫码明确不做(spec #21 D11),此处只处理静态图片。 +enum QrCodeCodec { + enum QrCodeError: Error, Equatable { + /// 系统二维码滤镜产出为空(理论不可达,防御位)。 + case generationFailed + /// 输入不是可解码的位图。 + case undecodableImage + } + + /// 生成二维码 PNG 数据;`scale` 控制输出像素密度(每个模块的像素数)。 + static func generatePNG(for payload: String, scale: CGFloat = 8) throws -> Data { + guard let filter = CIFilter(name: "CIQRCodeGenerator") else { + throw QrCodeError.generationFailed + } + filter.setValue(Data(payload.utf8), forKey: "inputMessage") + filter.setValue("M", forKey: "inputCorrectionLevel") + guard let output = filter.outputImage else { throw QrCodeError.generationFailed } + let scaled = output.transformed(by: CGAffineTransform(scaleX: scale, y: scale)) + let context = CIContext() + guard let cgImage = context.createCGImage(scaled, from: scaled.extent) else { + throw QrCodeError.generationFailed + } + let rep = NSBitmapImageRep(cgImage: cgImage) + guard let png = rep.representation(using: .png, properties: [:]) else { + throw QrCodeError.generationFailed + } + return png + } + + /// 识别静态图片中的全部二维码/条码文本负载。 + static func detectPayloads(in imageData: Data) throws -> [String] { + let request = VNDetectBarcodesRequest() + let handler = VNImageRequestHandler(data: imageData, options: [:]) + do { + try handler.perform([request]) + } catch { + throw QrCodeError.undecodableImage + } + return request.results?.compactMap(\.payloadStringValue) ?? [] + } +} diff --git a/ShadowsocksX-NG2/App/ServerDetailView.swift b/ShadowsocksX-NG2/App/ServerDetailView.swift new file mode 100644 index 00000000..ccae9bba --- /dev/null +++ b/ShadowsocksX-NG2/App/ServerDetailView.swift @@ -0,0 +1,198 @@ +import SwiftUI + +/// 服务器详情表单(issue #32):地址、端口、加密、密码、备注可编辑(仅手动 +/// 节点);插件区本票只读展示(选择器/参数编辑归 #38);分享区(二维码 + +/// 复制 ss://)。订阅服务器整表只读,仅树中启用开关可调。 +struct ServerDetailView: View { + let viewModel: CatalogViewModel + let serverID: NodeID + let proxyController: ProxyRuntimeController + + @State private var address = "" + @State private var port = 8388 + @State private var encryptionMethod = "" + @State private var password = "" + @State private var remark = "" + @State private var showPassword = false + @State private var showQR = false + @State private var qrImage: NSImage? + + private var formState: ServerFormState? { + viewModel.serverFormState(for: serverID) + } + + private var isEditable: Bool { + formState?.isEditable ?? false + } + + /// 常用加密方法(sslocal v1.25 支持);目录里既有的非常见方法原样追加显示。 + private var methodChoices: [String] { + var choices = [ + "aes-128-gcm", "aes-256-gcm", "chacha20-ietf-poly1305", + "2022-blake3-aes-128-gcm", "2022-blake3-aes-256-gcm", + "2022-blake3-chacha20-poly1305", "none", + ] + if !encryptionMethod.isEmpty && !choices.contains(encryptionMethod) { + choices.append(encryptionMethod) + } + return choices + } + + var body: some View { + Form { + Section("连接") { + TextField("地址", text: $address) + .disabled(!isEditable) + TextField("端口", value: $port, format: .number.grouping(.never)) + .disabled(!isEditable) + Picker("加密方法", selection: $encryptionMethod) { + ForEach(methodChoices, id: \.self) { Text($0).tag($0) } + } + .disabled(!isEditable) + HStack { + if showPassword { + TextField("密码", text: $password) + .disabled(!isEditable) + } else { + SecureField("密码", text: $password) + .disabled(!isEditable) + } + Button { + showPassword.toggle() + } label: { + Image(systemName: showPassword ? "eye.slash" : "eye") + } + .buttonStyle(.borderless) + .help(showPassword ? "隐藏密码" : "显示密码") + } + TextField("备注", text: $remark) + .disabled(!isEditable) + } + + Section("插件(本版本只读)") { + pluginSection + } + + if isEditable { + Section { + Button("保存修改") { save() } + } + } else { + Section { + Label("订阅节点由远端管理:连接字段只读,仅树中的启用开关可调。", systemImage: "info.circle") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + Section("分享") { + Button { + copySsUri() + } label: { + Label("复制 ss:// 链接", systemImage: "doc.on.doc") + } + Button { + generateQR() + } label: { + Label("二维码…", systemImage: "qrcode") + } + .disabled(qrPayload() == nil) + } + } + .formStyle(.grouped) + .navigationTitle(viewModel.displayName(for: serverID)) + .popover(isPresented: $showQR) { + qrPopover + } + .onAppear(perform: loadForm) + .onChange(of: serverID) { _, _ in loadForm() } + } + + @ViewBuilder + private var pluginSection: some View { + if let plugin = formState?.plugin { + LabeledContent("插件程序", value: plugin.program) + LabeledContent("状态") { + if plugin.provided { + Label("本版本提供", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + } else { + Label("本版本未提供该插件", systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .help("该服务器在插件可用前是无效激活候选(点名拒绝)") + } + } + LabeledContent("插件参数", value: plugin.optionsPresent ? "已配置(存于钥匙串)" : "未配置") + } else { + LabeledContent("插件程序", value: "无") + } + } + + private var qrPopover: some View { + VStack(spacing: 12) { + if let qrImage { + Image(nsImage: qrImage) + .interpolation(.none) + .resizable() + .scaledToFit() + .frame(width: 220, height: 220) + } else { + ProgressView() + .frame(width: 220, height: 220) + } + Text("用其他设备的客户端扫描此二维码") + .font(.footnote) + .foregroundStyle(.secondary) + Button("复制 ss:// 链接") { copySsUri() } + } + .padding(20) + } + + private func loadForm() { + guard let state = formState else { return } + address = state.address + port = state.port + encryptionMethod = state.encryptionMethod + password = state.password + remark = state.remark + showPassword = false + } + + private func save() { + Task { + do { + try await viewModel.updateServer( + serverID, + address: address, + port: port, + encryptionMethod: encryptionMethod, + password: password, + remark: remark) + } catch { + viewModel.presentedError = error.presentableMessage + } + } + } + + private func qrPayload() -> String? { + try? viewModel.ssUri(for: serverID) + } + + private func copySsUri() { + guard let uri = qrPayload() else { return } + let board = NSPasteboard.general + board.clearContents() + board.setString(uri, forType: .string) + } + + private func generateQR() { + guard let payload = qrPayload() else { return } + showQR = true + Task.detached(priority: .userInitiated) { + let image = (try? QrCodeCodec.generatePNG(for: payload)).flatMap { NSImage(data: $0) } + await MainActor.run { + qrImage = image + } + } + } +} diff --git a/ShadowsocksX-NG2/App/SidebarRow.swift b/ShadowsocksX-NG2/App/SidebarRow.swift new file mode 100644 index 00000000..dcb8ccff --- /dev/null +++ b/ShadowsocksX-NG2/App/SidebarRow.swift @@ -0,0 +1,92 @@ +import SwiftUI + +/// 侧栏树行:行内启用 checkbox、来源标识、活动目标标记与右键菜单。 +/// 订阅行只暴露激活/启停(结构操作整体缺席,领域层兜底拒绝)。 +struct SidebarRow: View { + let node: SidebarNode + let viewModel: CatalogViewModel + let proxyController: ProxyRuntimeController + let onRename: (NodeID) -> Void + let onNewGroup: (NodeID?) -> Void + let onMove: (NodeID) -> Void + let onDelete: (NodeID) -> Void + + private var isSubscription: Bool { node.source == .subscription } + + var body: some View { + HStack(spacing: 6) { + Toggle("", isOn: enableBinding) + .labelsHidden() + .toggleStyle(.checkbox) + Image(systemName: node.isGroup ? "folder" : "server.rack") + .foregroundStyle(.secondary) + Text(node.name) + .foregroundStyle(node.effectivelyEnabled ? .primary : .secondary) + if isSubscription { + Image(systemName: "arrow.triangle.2.circlepath") + .foregroundStyle(.tertiary) + .help("订阅节点:由远端管理,仅启用开关可调") + } + if proxyController.machine.activeTargetID == node.id { + Image(systemName: "bolt.fill") + .foregroundStyle(.orange) + .help("活动目标") + } + } + .contextMenu { contextMenu } + } + + private var enableBinding: Binding { + Binding( + get: { viewModel.entry(for: node.id)?.enabled ?? false }, + set: { newValue in + Task { + do { + try await viewModel.setEnabled(node.id, newValue) + } catch { + viewModel.presentedError = error.presentableMessage + } + } + }) + } + + /// 空手动组直接删除;服务器与非空手动组走确认弹窗(issue #32)。 + private var isEmptyManualGroup: Bool { + node.isGroup && node.source == .manual && (node.children?.isEmpty ?? true) + } + + @ViewBuilder + private var contextMenu: some View { + Button("激活") { + Task { await proxyController.activate(node.id) } + } + Button(node.enabled ? "停用" : "启用") { + enableBinding.wrappedValue.toggle() + } + if !isSubscription { + Divider() + if node.isGroup { + Button("重命名…") { onRename(node.id) } + } + Button("新建分组…") { + onNewGroup(node.isGroup ? node.id : viewModel.parentID(of: node.id)) + } + Divider() + Button("移动到…") { onMove(node.id) } + Divider() + Button("删除…", role: .destructive) { + if isEmptyManualGroup { + Task { + do { + try await viewModel.remove(node.id) + } catch { + viewModel.presentedError = error.presentableMessage + } + } + } else { + onDelete(node.id) + } + } + } + } +} diff --git a/ShadowsocksX-NG2/Domain/SsUri.swift b/ShadowsocksX-NG2/Domain/SsUri.swift new file mode 100644 index 00000000..46b2734e --- /dev/null +++ b/ShadowsocksX-NG2/Domain/SsUri.swift @@ -0,0 +1,265 @@ +import Foundation + +/// SIP002 `ss://` URI 模型(https://shadowsocks.org/doc/sip002.html)。 +/// 插件字段持 SIP003 `SS_PLUGIN_OPTIONS` 形态的原样字符串(如 +/// `mode=websocket;host=example.com`):URI 编解码只做 RFC 3986 百分号编码层, +/// 反斜杠转义按原样透传(插件自解),往返保持 `plugin=name;opts` 逐字节同构 +/// (spec #21 D10/D12)。 +struct SsUri: Equatable, Sendable { + var method: String + var password: String + var host: String + var port: Int + /// 插件程序名;无插件为 `nil`。 + var pluginProgram: String? + /// 插件参数(SS_PLUGIN_OPTIONS 原样形态);无插件或仅有程序名为 `nil`。 + var pluginOptions: String? + /// 备注(URI fragment);缺失或空为 `nil`。 + var remark: String? + + init( + method: String, + password: String, + host: String, + port: Int, + pluginProgram: String? = nil, + pluginOptions: String? = nil, + remark: String? = nil + ) { + self.method = method + self.password = password + self.host = host + self.port = port + self.pluginProgram = pluginProgram + self.pluginOptions = pluginOptions + self.remark = remark + } +} + +/// 解码失败原因:输入不是可解析的 `ss://` URI。 +enum SsUriError: Error, Equatable { + /// 不以 `ss://` 开头。 + case notSsUri + /// scheme 正确但结构或字段非法(缺端口、base64 解不开、端口越界等)。 + case malformed(detail: String) +} + +// MARK: - 解码 + +extension SsUri { + /// 解码单个 URI 行。同时接受: + /// - SIP002 形态:`ss://userinfo@host:port/?plugin=…#tag`(userinfo 为 + /// base64url(`method:password`) 或百分号编码明文——AEAD-2022 规定明文)。 + /// - Legacy 兼容形态:`ss://base64(method:password@host:port)#tag`(无 `@`, + /// 密码明文,非 RFC 3986)。 + static func decode(_ rawInput: String) throws -> SsUri { + let input = rawInput.trimmingCharacters(in: .whitespacesAndNewlines) + let schemePrefix = "ss://" + guard input.lowercased().hasPrefix(schemePrefix) else { throw SsUriError.notSsUri } + var rest = String(input.dropFirst(schemePrefix.count)) + + // fragment(备注):base64 字母表与百分号编码都不含裸 `#`,取首个裸 `#` 安全。 + var remark: String? + if let hashIndex = rest.firstIndex(of: "#") { + remark = percentDecode(String(rest[rest.index(after: hashIndex)...])) + rest = String(rest[.. SsUri { + guard let atSign = rest.lastIndex(of: "@") else { + throw SsUriError.malformed(detail: "missing userinfo separator") + } + let userInfo = String(rest[.. (String, String) { + if let decoded = base64FlexibleDecode(userInfo), let separator = decoded.firstIndex(of: ":") { + return (String(decoded[.. SsUri { + let trimmed = rest.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard let decoded = base64FlexibleDecode(trimmed) else { + throw SsUriError.malformed(detail: "legacy payload is not valid base64") + } + guard let atSign = decoded.lastIndex(of: "@") else { + throw SsUriError.malformed(detail: "legacy payload missing @") + } + let credentials = String(decoded[.. (String, Int) { + let host: String + let portText: String + if hostPort.hasPrefix("[") { + guard let closing = hostPort.firstIndex(of: "]") else { + throw SsUriError.malformed(detail: "unclosed IPv6 bracket") + } + host = String(hostPort[hostPort.index(hostPort.startIndex, offsetBy: 1).. (program: String, options: String?)? { + guard let query else { return nil } + var pluginValue: String? + for pair in query.split(separator: "&") { + let name: String + let value: String + if let equals = pair.firstIndex(of: "=") { + name = String(pair[.. String { + let userInfo: String + if method.hasPrefix("2022-blake3-") { + userInfo = "\(Self.percentEncode(method)):\(Self.percentEncode(password))" + } else { + userInfo = Self.base64URLEncodeNoPadding("\(method):\(password)") + } + var uri = "ss://\(userInfo)@\(encodedHost):\(port)" + if let program = pluginProgram { + let pluginValue = pluginOptions.map { "\(program);\($0)" } ?? program + uri += "/?plugin=\(Self.percentEncode(pluginValue))" + } + if let remark, !remark.isEmpty { + uri += "#\(Self.percentEncode(remark))" + } + return uri + } + + /// 解码得到的 IPv6(裸冒号形态)编码时重新加方括号。 + private var encodedHost: String { + host.contains(":") ? "[\(host)]" : host + } +} + +// MARK: - base64 与百分号编码工具 + +extension SsUri { + /// base64url 无 padding(SIP002 userinfo 的规范形态)。 + static func base64URLEncodeNoPadding(_ text: String) -> String { + Data(text.utf8) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + /// 宽容 base64 解码:兼容标准/url-safe 两种字母表与缺失 padding + /// (社区分享链接普遍省略 padding)。 + static func base64FlexibleDecode(_ text: String) -> String? { + var normalized = + text + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + .replacingOccurrences(of: "\n", with: "") + .replacingOccurrences(of: "\r", with: "") + .trimmingCharacters(in: .whitespaces) + let remainder = normalized.count % 4 + if remainder == 1 { return nil } // 不可能是合法 base64 长度 + if remainder > 0 { normalized.append(String(repeating: "=", count: 4 - remainder)) } + guard let data = Data(base64Encoded: normalized) else { return nil } + return String(data: data, encoding: .utf8) + } + + /// 百分号编码:仅保留 RFC 3986 unreserved(字母数字与 `-._~`),其余全部 + /// 转义——与官方示例的 `obfs-local%3Bobfs%3Dhttp` 形态一致。 + static func percentEncode(_ text: String) -> String { + let allowed = CharacterSet( + charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") + return text.addingPercentEncoding(withAllowedCharacters: allowed) ?? text + } + + private static func percentDecode(_ text: String) -> String? { + text.removingPercentEncoding + } +} diff --git a/ShadowsocksX-NG2/Tests/CatalogViewModelTests.swift b/ShadowsocksX-NG2/Tests/CatalogViewModelTests.swift new file mode 100644 index 00000000..55fe0de3 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/CatalogViewModelTests.swift @@ -0,0 +1,274 @@ +import XCTest + +@testable import ShadowsocksX_NG2 + +/// 主窗口视图模型(issue #32):三入口导入、凭据生命周期、订阅子树只读夹具、 +/// 分享互逆与 postCommit 接线。全部走内存凭据存储与临时目录文件存储。 +@MainActor +final class CatalogViewModelTests: XCTestCase { + private var workDir: URL! + private var fileURL: URL! + private var credentials: InMemoryCredentialStore! + private var viewModel: CatalogViewModel! + private var commitCounter: CommitCounter! + + override func setUp() async throws { + try await super.setUp() + // 专用工作目录:写入器会把父目录强制 0700(不得指向临时根)。 + workDir = FileManager.default.temporaryDirectory + .appendingPathComponent("catalog-vm-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true) + fileURL = workDir.appendingPathComponent("catalog.json") + credentials = InMemoryCredentialStore() + commitCounter = CommitCounter() + viewModel = makeViewModel() + } + + override func tearDown() async throws { + try? FileManager.default.removeItem(at: workDir) + try await super.tearDown() + } + + private func makeViewModel() -> CatalogViewModel { + let model = CatalogViewModel( + fileStore: CatalogFileStore(fileURL: fileURL), + credentials: credentials, + plugins: NoManagedPluginProvider()) + let counter = commitCounter! + model.postCommit = { await counter.increment() } + return model + } + + private func makeSubscriptionCatalog() throws { + let fixture = try CatalogFixtures.makeSubscriptionFixture() + try CatalogFileStore(fileURL: fileURL).save(fixture.catalog) + viewModel = makeViewModel() + } + + private func serverFields(of id: NodeID) -> ServerFields? { + guard case .server(let fields) = viewModel.entry(for: id)?.kind else { return nil } + return fields + } + + // MARK: - 三入口导入(共同落点) + + func testImportMixedLinesAddsParsedAndReportsFailures() async throws { + let validSIP002 = "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQxMjM@203.0.113.7:8388#香港 01" + let legacy = + "ss://YWVzLTI1Ni1nY206dGVzdEAyMDMuMC4xMTMuNzo4Mzg4" + let outcome = try await viewModel.addServers( + fromURIs: "\(validSIP002)\n垃圾行\n\(legacy)", into: nil) + XCTAssertEqual(outcome.added, 2) + XCTAssertEqual(outcome.failures.count, 1) + + let roots = viewModel.catalog.rootChildren + XCTAssertEqual(roots.count, 2) + guard case .server(let first) = viewModel.entry(for: roots[0])?.kind else { + return XCTFail("首个导入应为服务器叶子") + } + XCTAssertEqual(first.remark, "香港 01") + let secret = try credentials.secret(for: first.passwordRef) + XCTAssertEqual(secret, "password123") + // 两个身份均为全新 UUID 形态(不按内容去重)。 + XCTAssertNotEqual(roots[0], roots[1]) + } + + func testImportWithPluginStoresProgramAndOptionsRef() async throws { + let uri = + "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQxMjM@203.0.113.7:8388" + + "/?plugin=v2ray-plugin%3Bmode%3Dwebsocket%3Bhost%3Dexample.com" + _ = try await viewModel.addServers(fromURIs: uri, into: nil) + guard + case .server(let fields) = viewModel.entry(for: viewModel.catalog.rootChildren[0])? + .kind + else { return XCTFail("应为服务器叶子") } + XCTAssertEqual(fields.pluginProgram, "v2ray-plugin") + let optionsRef = try XCTUnwrap(fields.pluginOptionsRef) + XCTAssertEqual(try credentials.secret(for: optionsRef), "mode=websocket;host=example.com") + } + + func testImportIntoSelectedManualGroup() async throws { + let groupID = try await viewModel.addGroup(named: "手动组", into: nil) + let target = try XCTUnwrap(viewModel.importTargetParent(for: groupID)) + XCTAssertEqual(target, groupID) + _ = try await viewModel.addServers( + fromURIs: "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQxMjM@203.0.113.7:8388", into: target) + XCTAssertEqual(try viewModel.catalog.children(of: groupID).count, 1) + // 选中服务器时落到其手动父组;无选中落到根。 + let serverID = try XCTUnwrap(try viewModel.catalog.children(of: groupID).first) + XCTAssertEqual(viewModel.importTargetParent(for: serverID), groupID) + XCTAssertNil(viewModel.importTargetParent(for: nil)) + } + + func testImportTargetSkipsSubscriptionSubtree() async throws { + try makeSubscriptionCatalog() + let fixture = try CatalogFixtures.makeSubscriptionFixture() + XCTAssertEqual(viewModel.importTargetParent(for: fixture.groupID), nil) + XCTAssertEqual(viewModel.importTargetParent(for: fixture.serverIDs[0]), nil) + } + + // MARK: - 表单编辑与凭据生命周期 + + func testUpdateServerPersistsFieldsAndPassword() async throws { + _ = try await viewModel.addServers( + fromURIs: "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQxMjM@203.0.113.7:8388", into: nil) + let id = try XCTUnwrap(viewModel.catalog.rootChildren.first) + let oldRef = try XCTUnwrap(serverFields(of: id)?.passwordRef) + try await viewModel.updateServer( + id, address: "198.51.100.9", port: 9999, encryptionMethod: "chacha20-ietf-poly1305", + password: "新密码", remark: "改过的") + let fields = try XCTUnwrap(serverFields(of: id)) + XCTAssertEqual(fields.address, "198.51.100.9") + XCTAssertEqual(fields.port, 9999) + XCTAssertEqual(fields.remark, "改过的") + XCTAssertEqual(fields.passwordRef, oldRef, "同一引用始终对应最新秘密") + XCTAssertEqual(try credentials.secret(for: oldRef), "新密码") + let commitCount = await commitCounter.count + XCTAssertEqual(commitCount, 2, "导入 + 更新各一次提交") + } + + func testUpdateServerRejectsInvalidAddressAndPort() async throws { + _ = try await viewModel.addServers( + fromURIs: "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQxMjM@203.0.113.7:8388", into: nil) + let id = try XCTUnwrap(viewModel.catalog.rootChildren.first) + await expectThrowsAsync( + { + try await viewModel.updateServer( + id, address: " ", port: 8388, encryptionMethod: "aes-256-gcm", password: "p", + remark: "") + }, + onThrow: { error in + XCTAssertEqual(error as? ServerFormError, .invalidAddress) + }) + await expectThrowsAsync( + { + try await viewModel.updateServer( + id, address: "203.0.113.7", port: 0, encryptionMethod: "aes-256-gcm", password: "p", + remark: "") + }, + onThrow: { error in + XCTAssertEqual(error as? ServerFormError, .invalidPort) + }) + } + + func testRemoveServerCleansCredentialsAndClearsSelection() async throws { + _ = try await viewModel.addServers( + fromURIs: "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQxMjM@203.0.113.7:8388" + + "/?plugin=v2ray-plugin%3Bmode%3Dwebsocket", into: nil) + let id = try XCTUnwrap(viewModel.catalog.rootChildren.first) + let refs = serverFields(of: id).map { ($0.passwordRef, $0.pluginOptionsRef) } + let (passwordRef, optionsRef) = try XCTUnwrap(refs) + viewModel.selectedNodeID = id + try await viewModel.remove(id) + XCTAssertFalse(viewModel.catalog.contains(id)) + XCTAssertNil(viewModel.selectedNodeID, "删除选中节点后清除选中") + XCTAssertNil(try credentials.secret(for: passwordRef), "密码秘密已清理") + XCTAssertEqual(try credentials.secret(for: try XCTUnwrap(optionsRef)), nil, "插件参数秘密已清理") + } + + func testRemoveNonEmptyGroupRemovesWholeSubtree() async throws { + let groupID = try await viewModel.addGroup(named: "组", into: nil) + _ = try await viewModel.addServers( + fromURIs: "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQxMjM@203.0.113.7:8388", into: groupID) + let serverID = try XCTUnwrap(try viewModel.catalog.children(of: groupID).first) + try await viewModel.remove(groupID) + XCTAssertFalse(viewModel.catalog.contains(groupID)) + XCTAssertFalse(viewModel.catalog.contains(serverID)) + XCTAssertTrue(viewModel.catalog.isEmpty) + } + + // MARK: - 订阅子树只读(夹具验证:结构操作被拒、启用开关可用) + + func testSubscriptionStructureOperationsRejected() async throws { + try makeSubscriptionCatalog() + let fixture = try CatalogFixtures.makeSubscriptionFixture() + let subscriptionServer = fixture.serverIDs[0] + let manualGroupID = try await viewModel.addGroup(named: "手动组", into: nil) + + await expectThrowsAsync { try await viewModel.move(subscriptionServer, to: manualGroupID) } + await expectThrowsAsync { try await viewModel.remove(subscriptionServer) } + await expectThrowsAsync { try await viewModel.renameGroup(fixture.groupID, to: "改名") } + await expectThrowsAsync { + try await viewModel.updateServer( + subscriptionServer, address: "0.0.0.0", port: 1, encryptionMethod: "x", password: "p", + remark: "") + } + // 手动节点移进订阅子树同样被拒(跨来源)。 + await expectThrowsAsync { try await viewModel.move(manualGroupID, to: fixture.groupID) } + } + + func testSubscriptionEnableToggleAllowedAndPersisted() async throws { + try makeSubscriptionCatalog() + let fixture = try CatalogFixtures.makeSubscriptionFixture() + let server = fixture.serverIDs[0] + try await viewModel.setEnabled(server, false) + let entry = try XCTUnwrap(viewModel.entry(for: server)) + XCTAssertFalse(entry.enabled, "启用开关是订阅节点唯一可写状态") + let commitCount = await commitCounter.count + XCTAssertEqual(commitCount, 1) + // 持久化到磁盘可读回。 + let reloaded = try CatalogFileStore(fileURL: fileURL).load() + XCTAssertFalse(try XCTUnwrap(reloaded.entry(for: server)).enabled) + XCTAssertTrue(try reloaded.isEffectivelyEnabled(fixture.serverIDs[1]), "兄弟节点不受影响") + } + + // MARK: - 分享(与添加互逆) + + func testShareSsUriRoundTripsBackThroughImport() async throws { + let source = + "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQxMjM@203.0.113.7:8388" + + "/?plugin=v2ray-plugin%3Bmode%3Dwebsocket%3Bhost%3Dexample.com#分享节点" + _ = try await viewModel.addServers(fromURIs: source, into: nil) + let id = try XCTUnwrap(viewModel.catalog.rootChildren.first) + let shared = try viewModel.ssUri(for: id) + let decoded = try SsUri.decode(shared) + XCTAssertEqual(decoded.method, "aes-256-gcm") + XCTAssertEqual(decoded.password, "password123") + XCTAssertEqual(decoded.host, "203.0.113.7") + XCTAssertEqual(decoded.port, 8388) + XCTAssertEqual(decoded.pluginProgram, "v2ray-plugin") + XCTAssertEqual(decoded.pluginOptions, "mode=websocket;host=example.com") + XCTAssertEqual(decoded.remark, "分享节点") + } + + func testShareFailsWhenCredentialMissing() async throws { + var catalog = ConfigurationCatalog() + var fields = CatalogFixtures.serverFields(remark: "无凭据") + fields.passwordRef = CredentialReference.fresh() + try catalog.addServer(fields) + try CatalogFileStore(fileURL: fileURL).save(catalog) + viewModel = makeViewModel() + let id = try XCTUnwrap(viewModel.catalog.rootChildren.first) + await expectThrowsAsync { try viewModel.ssUri(for: id) } + } + + // MARK: - 持久化 + + func testChangesPersistAcrossViewModelInstances() async throws { + let groupID = try await viewModel.addGroup(named: "持久组", into: nil) + let reloaded = makeViewModel() + XCTAssertTrue(reloaded.catalog.contains(groupID)) + XCTAssertEqual(reloaded.displayName(for: groupID), "持久组") + } +} + +/// postCommit 触发计数。 +actor CommitCounter { + private(set) var count = 0 + func increment() { count += 1 } +} + +/// `XCTAssertThrowsError` 的 async 版本(表达式在 await 之后才能检查)。 +func expectThrowsAsync( + _ expression: () async throws -> Void, + onThrow errorHandler: (Error) -> Void = { _ in }, + file: StaticString = #filePath, + line: UInt = #line +) async { + do { + _ = try await expression() + XCTFail("预期抛错,但成功返回", file: file, line: line) + } catch { + errorHandler(error) + } +} diff --git a/ShadowsocksX-NG2/Tests/QrCodeCodecTests.swift b/ShadowsocksX-NG2/Tests/QrCodeCodecTests.swift new file mode 100644 index 00000000..1a0384c6 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/QrCodeCodecTests.swift @@ -0,0 +1,25 @@ +import XCTest + +@testable import ShadowsocksX_NG2 + +/// 二维码分享/识别互逆(issue #32 验收:分享与添加互逆)。 +final class QrCodeCodecTests: XCTestCase { + func testGenerateThenDetectRoundTrip() throws { + let payload = "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQxMjM@203.0.113.7:8388#香港 01" + let png = try QrCodeCodec.generatePNG(for: payload) + let payloads = try QrCodeCodec.detectPayloads(in: png) + XCTAssertEqual(payloads, [payload]) + } + + func testDetectHandlesChineseAndPluginPayload() throws { + let payload = + "ss://cmM0LW1kNTpwYXNzd29yZA@192.168.100.1:8888" + + "/?plugin=obfs-local%3Bobfs%3Dhttp%3Bobfs-host%3Dexample.com#示例节点 🇭🇰" + let png = try QrCodeCodec.generatePNG(for: payload) + XCTAssertEqual(try QrCodeCodec.detectPayloads(in: png), [payload]) + } + + func testDetectGarbageDataThrows() { + XCTAssertThrowsError(try QrCodeCodec.detectPayloads(in: Data("not an image".utf8))) + } +} diff --git a/ShadowsocksX-NG2/Tests/SsUriTests.swift b/ShadowsocksX-NG2/Tests/SsUriTests.swift new file mode 100644 index 00000000..67ed78ec --- /dev/null +++ b/ShadowsocksX-NG2/Tests/SsUriTests.swift @@ -0,0 +1,237 @@ +import Foundation +import XCTest + +@testable import ShadowsocksX_NG2 + +/// SIP002 ss:// 编解码(issue #32 验收:编解码往返、插件同构、特殊字符)。 +/// base64 期望值用系统 `base64` 独立计算,不依赖被测代码。 +final class SsUriTests: XCTestCase { + // MARK: - 解码:SIP002 形态 + + func testDecodeSIP002Minimal() throws { + let uri = "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQxMjM=@203.0.113.7:8388" + let decoded = try SsUri.decode(uri) + XCTAssertEqual(decoded.method, "aes-256-gcm") + XCTAssertEqual(decoded.password, "password123") + XCTAssertEqual(decoded.host, "203.0.113.7") + XCTAssertEqual(decoded.port, 8388) + XCTAssertNil(decoded.pluginProgram) + XCTAssertNil(decoded.pluginOptions) + XCTAssertNil(decoded.remark) + } + + func testDecodeSIP002WithTrailingSlashAndRemark() throws { + let uri = "ss://cmM0LW1kNTpwYXNzd29yZA==@192.168.100.1:8888/#Example1" + let decoded = try SsUri.decode(uri) + XCTAssertEqual(decoded.method, "rc4-md5") + XCTAssertEqual(decoded.password, "password") + XCTAssertEqual(decoded.remark, "Example1") + } + + func testDecodeSIP002PluginQuery() throws { + // 官方示例形态:query 值整体百分号编码(; : = → %3B %3A %3D)。 + let uri = + "ss://cmM0LW1kNTpwYXNzd29yZA==@192.168.100.1:8888/" + + "?plugin=obfs-local%3Bobfs%3Dhttp%3Bobfs-host%3Dexample.com#Example1" + let decoded = try SsUri.decode(uri) + XCTAssertEqual(decoded.pluginProgram, "obfs-local") + XCTAssertEqual(decoded.pluginOptions, "obfs=http;obfs-host=example.com") + } + + func testDecodeSIP002PluginWithoutOptions() throws { + let uri = "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQxMjM=@203.0.113.7:8388/?plugin=v2ray-plugin" + let decoded = try SsUri.decode(uri) + XCTAssertEqual(decoded.pluginProgram, "v2ray-plugin") + XCTAssertNil(decoded.pluginOptions) + } + + func testDecodeSIP002EmptyPluginValueIsNoPlugin() throws { + let uri = "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQxMjM=@203.0.113.7:8388/?plugin=" + let decoded = try SsUri.decode(uri) + XCTAssertNil(decoded.pluginProgram) + } + + func testDecodeSIP002IPv6Host() throws { + let uri = "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQxMjM=@[2001:db8::1]:8388" + let decoded = try SsUri.decode(uri) + XCTAssertEqual(decoded.host, "2001:db8::1") + XCTAssertEqual(decoded.port, 8388) + } + + /// AEAD-2022 客户端按 SIP022 用百分号编码明文 userinfo。 + func testDecodeSIP022PlaintextUserInfo() throws { + let uri = + "ss://2022-blake3-aes-256-gcm:hmackyEyXXXXXXXXXXXXXXXXXXXw%3D%3D@203.0.113.7:8388" + let decoded = try SsUri.decode(uri) + XCTAssertEqual(decoded.method, "2022-blake3-aes-256-gcm") + XCTAssertEqual(decoded.password, "hmackyEyXXXXXXXXXXXXXXXXXXXw==") + } + + func testDecodeLegacyBase64Form() throws { + let uri = "ss://YWVzLTI1Ni1nY206dGVzdEAyMDMuMC4xMTMuNzo4Mzg4#香港 01" + let decoded = try SsUri.decode(uri) + XCTAssertEqual(decoded.method, "aes-256-gcm") + XCTAssertEqual(decoded.password, "test") + XCTAssertEqual(decoded.host, "203.0.113.7") + XCTAssertEqual(decoded.port, 8388) + XCTAssertEqual(decoded.remark, "香港 01") + } + + /// Legacy 密码为明文:凭据必须在最后一个 @ 处切分,密码中的 `@` 与 `:` 原样保留。 + func testDecodeLegacyPasswordContainingAtAndColon() throws { + let uri = "ss://YWVzLTI1Ni1nY206dEBzdDpwd0AyMDMuMC4xMTMuNzo4Mzg4" + let decoded = try SsUri.decode(uri) + XCTAssertEqual(decoded.method, "aes-256-gcm") + XCTAssertEqual(decoded.password, "t@st:pw") + XCTAssertEqual(decoded.host, "203.0.113.7") + XCTAssertEqual(decoded.port, 8388) + } + + func testDecodeToleratesMissingBase64PaddingAndURLSafeAlphabet() throws { + // `chacha20-ietf-poly1305:pw` 的 base64 末尾 padding 常被省略。 + let padded = Data("chacha20-ietf-poly1305:pw".utf8).base64EncodedString() + let unpadded = padded.replacingOccurrences(of: "=", with: "") + let uriSafe = unpadded.replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + let decoded = try SsUri.decode("ss://\(uriSafe)@203.0.113.7:8388") + XCTAssertEqual(decoded.method, "chacha20-ietf-poly1305") + XCTAssertEqual(decoded.password, "pw") + } + + func testDecodeTrimsSurroundingWhitespace() throws { + let decoded = try SsUri.decode(" ss://YWVzLTI1Ni1nY206cGFzc3dvcmQxMjM=@203.0.113.7:8388\n") + XCTAssertEqual(decoded.port, 8388) + } + + // MARK: - 解码失败 + + func testDecodeRejectsNonSsScheme() { + XCTAssertThrowsError(try SsUri.decode("http://example.com")) { error in + XCTAssertEqual(error as? SsUriError, .notSsUri) + } + XCTAssertThrowsError(try SsUri.decode("plain text")) { error in + XCTAssertEqual(error as? SsUriError, .notSsUri) + } + } + + func testDecodeRejectsMissingPort() { + XCTAssertThrowsError(try SsUri.decode("ss://YWVz@203.0.113.7")) { error in + guard case SsUriError.malformed = error else { return XCTFail("\(error)") } + } + } + + func testDecodeRejectsInvalidPort() { + XCTAssertThrowsError(try SsUri.decode("ss://YWVz@203.0.113.7:notaport")) + XCTAssertThrowsError(try SsUri.decode("ss://YWVz@203.0.113.7:0")) + XCTAssertThrowsError(try SsUri.decode("ss://YWVz@203.0.113.7:70000")) + } + + func testDecodeRejectsGarbagePayload() { + XCTAssertThrowsError(try SsUri.decode("ss://!!!not-base64@203.0.113.7:8388")) + XCTAssertThrowsError(try SsUri.decode("ss://!!!!")) + } + + // MARK: - 编码 + + func testEncodeCanonicalSIP002() { + let uri = SsUri( + method: "aes-256-gcm", password: "password123", host: "203.0.113.7", port: 8388) + // 生态惯例(Outline / shadowsocks-rust):url-safe 字母表、无 padding。 + XCTAssertEqual(uri.encode(), "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQxMjM@203.0.113.7:8388") + } + + func testEncodeBase64URLAlphabetWithoutPadding() { + // 含 `+`/`/` 的明文:编码必须落在 url-safe 字母表且无 padding。 + let uri = SsUri(method: "aes-128-gcm", password: "??>>", host: "203.0.113.7", port: 1) + let encoded = uri.encode() + let userInfo = String(encoded.dropFirst("ss://".count).prefix { $0 != "@" }) + XCTAssertFalse(userInfo.contains("+")) + XCTAssertFalse(userInfo.contains("/")) + XCTAssertFalse(userInfo.contains("=")) + } + + func testEncodePluginUsesPercentEncoding() { + let uri = SsUri( + method: "rc4-md5", password: "password", host: "192.168.100.1", port: 8888, + pluginProgram: "obfs-local", pluginOptions: "obfs=http;obfs-host=example.com", + remark: "Example1") + XCTAssertEqual( + uri.encode(), + "ss://cmM0LW1kNTpwYXNzd29yZA@192.168.100.1:8888" + + "/?plugin=obfs-local%3Bobfs%3Dhttp%3Bobfs-host%3Dexample.com#Example1") + } + + func testEncodeSIP022UsesPlaintextPercentEncodedUserInfo() { + let uri = SsUri( + method: "2022-blake3-aes-256-gcm", password: "hmackyEyXXXXXXXXXXXXXXXXXXXw==", + host: "203.0.113.7", port: 8388) + XCTAssertEqual( + uri.encode(), + "ss://2022-blake3-aes-256-gcm:hmackyEyXXXXXXXXXXXXXXXXXXXw%3D%3D@203.0.113.7:8388") + } + + func testEncodeIPv6Rebrackets() { + let uri = SsUri(method: "aes-256-gcm", password: "p", host: "2001:db8::1", port: 8388) + XCTAssertTrue(uri.encode().hasSuffix("@[2001:db8::1]:8388")) + } + + // MARK: - 往返同构(issue #32 验收核心) + + func testRoundTripCorpusPreservesAllFields() throws { + let corpus: [SsUri] = [ + SsUri(method: "aes-256-gcm", password: "password123", host: "203.0.113.7", port: 8388), + SsUri( + method: "rc4-md5", password: "password", host: "192.168.100.1", port: 8888, + remark: "Example1"), + // 特殊字符:密码含 URI 结构字符、空白、中文。 + SsUri( + method: "chacha20-ietf-poly1305", password: "p@ss:wo/rd#1&=%+ 空格", + host: "203.0.113.7", port: 8388, remark: "香港 01 #备注"), + // 插件同构:name;opts 原样保留(含 `=`、`:`、`/`、`%`、中文)。 + SsUri( + method: "aes-256-gcm", password: "p", host: "203.0.113.7", port: 8388, + pluginProgram: "v2ray-plugin", + pluginOptions: "mode=websocket;path=/wss%20x;host=例.com;loglevel=warn"), + // 插件参数含 URI 分隔符与 `&`、`;`(透传形态)。 + SsUri( + method: "aes-256-gcm", password: "p", host: "203.0.113.7", port: 8388, + pluginProgram: "v2ray-plugin", pluginOptions: "mux=1&t=11;path=/a;b"), + // 仅有插件名、无参数。 + SsUri( + method: "aes-256-gcm", password: "p", host: "203.0.113.7", port: 8388, + pluginProgram: "v2ray-plugin"), + // SIP022 明文 userinfo 往返。 + SsUri( + method: "2022-blake3-aes-256-gcm", password: "hmackyEyXXXXXXXXXXXXXXXXXXXw==", + host: "203.0.113.7", port: 8388), + // IPv6 往返。 + SsUri(method: "aes-256-gcm", password: "p", host: "2001:db8::1", port: 9101), + // 空密码合法(如占位)。 + SsUri(method: "aes-256-gcm", password: "", host: "203.0.113.7", port: 8388), + ] + for original in corpus { + let decoded = try SsUri.decode(original.encode()) + XCTAssertEqual(decoded, original, "往返失真:\(original)") + } + } + + func testCanonicalInputSurvivesDecodeEncodeVerbatim() throws { + let canonical = + "ss://cmM0LW1kNTpwYXNzd29yZA@192.168.100.1:8888" + + "/?plugin=obfs-local%3Bobfs%3Dhttp%3Bobfs-host%3Dexample.com#Example1" + XCTAssertEqual(try SsUri.decode(canonical).encode(), canonical) + } + + /// 密码与插件参数里的 `@`/`#`/`?`/`&` 不得破坏 URI 结构。 + func testStructureCharactersInSecretsStayOpaque() throws { + let hostile = SsUri( + method: "aes-256-gcm", + password: "a@b#c?d&e=f g/h;i%3B", + host: "203.0.113.7", port: 8388, + pluginProgram: "v2ray-plugin", + pluginOptions: "path=/ws?x=1;host=h@ck#er", + remark: "名#字?1") + let decoded = try SsUri.decode(hostile.encode()) + XCTAssertEqual(decoded, hostile) + } +}