feat(ng2): main window server tree, ss:// codec and QR share/import (#32)

主窗口服务器分区(D11,issue #32):NavigationSplitView 分组树(订阅子树
只读、仅启用开关;行内 checkbox;右键激活/启停/重命名/新建分组/移动/删除,
非空手动组二次确认;拖拽移动,跨来源拒绝;空组保留可编辑)、服务器详情
表单(插件区只读展示「本版本未提供」状态)、分组详情。添加三入口(剪贴板
ss://、导入 URL、二维码图片识别:文件选择 + 拖图入窗)共用批量导入落点,
每次新建身份不去重;分享区二维码弹窗 + 复制 ss://。SsUri 按 SIP002 编解码
(含 legacy base64 兼容、SIP022 明文 userinfo、IPv6、插件 name;opts 逐字节
同构),二维码走 CoreImage 生成 + Vision 识别。目录提交后接 postCommit 钩
子,代理运行时立即重展开(#27 catalogDidCommit)。
This commit is contained in:
Qiu Yuzhou
2026-09-20 21:37:38 +08:00
parent 6bfd3c0d35
commit 60a31b5df3
15 changed files with 2135 additions and 21 deletions
+291
View File
@@ -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<T>(
_ 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
}
@@ -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
}
@@ -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 "配置目录结构不一致"
}
}
}
@@ -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
}
}
}
}
+248
View File
@@ -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
}
}
}
}
+7 -1
View File
@@ -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)
}
+285
View File
@@ -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 }
}
@@ -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)
}
}
+48
View File
@@ -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) ?? []
}
}
+198
View File
@@ -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
}
}
}
}
+92
View File
@@ -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<Bool> {
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)
}
}
}
}
}
+265
View File
@@ -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[..<hashIndex])
}
if rest.contains("@") {
return try decodeSIP002(rest, remark: remark)
}
return try decodeLegacy(rest, remark: remark)
}
/// SIP002 形态:在最后一个 `@` 处分离 userinfo 与 host:port(+query)。
private static func decodeSIP002(_ rest: String, remark: String?) throws -> SsUri {
guard let atSign = rest.lastIndex(of: "@") else {
throw SsUriError.malformed(detail: "missing userinfo separator")
}
let userInfo = String(rest[..<atSign])
var hostPort = String(rest[rest.index(after: atSign)...])
var query: String?
if let queryIndex = hostPort.firstIndex(of: "?") {
query = String(hostPort[hostPort.index(after: queryIndex)...])
hostPort = String(hostPort[..<queryIndex])
}
hostPort = hostPort.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let (host, port) = try parseHostPort(hostPort)
let (method, password) = try parseUserInfo(userInfo)
let plugin = parsePluginQuery(query)
return SsUri(
method: method, password: password, host: host, port: port,
pluginProgram: plugin?.program, pluginOptions: plugin?.options, remark: remark)
}
/// userinfo 解析:先按 base64url(补齐 padding、兼容两种字母表);解不出或
/// 不含分隔冒号时回退百分号编码明文(SIP022 形态)。
private static func parseUserInfo(_ userInfo: String) throws -> (String, String) {
if let decoded = base64FlexibleDecode(userInfo), let separator = decoded.firstIndex(of: ":") {
return (String(decoded[..<separator]), String(decoded[decoded.index(after: separator)...]))
}
guard let plain = percentDecode(userInfo), let separator = plain.firstIndex(of: ":") else {
throw SsUriError.malformed(
detail: "userinfo is neither base64 nor percent-encoded method:password")
}
return (String(plain[..<separator]), String(plain[plain.index(after: separator)...]))
}
/// Legacy 形态:整体 base64 → `method:password@host:port`。密码为明文
/// (可能含 `@` 与 `:`),故 host:port 侧用「最后一个 @」切分。
private static func decodeLegacy(_ rest: String, remark: String?) throws -> 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[..<atSign])
let hostPort = String(decoded[decoded.index(after: atSign)...])
guard let colon = credentials.firstIndex(of: ":") else {
throw SsUriError.malformed(detail: "legacy payload missing method separator")
}
let (host, port) = try parseHostPort(hostPort)
return SsUri(
method: String(credentials[..<colon]),
password: String(credentials[credentials.index(after: colon)...]),
host: host, port: port, remark: remark)
}
/// host:port 解析:IPv6 必须为方括号形态;否则以最后一个冒号分离端口。
private static func parseHostPort(_ hostPort: String) throws -> (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)..<closing])
let remainder = hostPort[hostPort.index(after: closing)...]
guard remainder.hasPrefix(":") else {
throw SsUriError.malformed(detail: "missing port after IPv6 bracket")
}
portText = String(remainder.dropFirst())
} else {
guard let colon = hostPort.lastIndex(of: ":") else {
throw SsUriError.malformed(detail: "missing port")
}
host = String(hostPort[..<colon])
portText = String(hostPort[hostPort.index(after: colon)...])
}
guard let port = Int(portText), (1...65_535).contains(port) else {
throw SsUriError.malformed(detail: "invalid port \(portText)")
}
guard !host.isEmpty else { throw SsUriError.malformed(detail: "empty host") }
return (host, port)
}
/// query 中的 `plugin` 参数:百分号解码后为 `name;opts`,首个 `;` 切分;
/// opts 原样保留。`plugin=` 空值视为无插件。
private static func parsePluginQuery(
_ query: String?
) -> (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[..<equals])
value = String(pair[pair.index(after: equals)...])
} else {
name = String(pair)
value = ""
}
if percentDecode(name) == "plugin" { pluginValue = percentDecode(value) }
}
guard let pluginValue, !pluginValue.isEmpty else { return nil }
let program: String
let options: String?
if let semicolon = pluginValue.firstIndex(of: ";") {
program = String(pluginValue[..<semicolon])
let rest = String(pluginValue[pluginValue.index(after: semicolon)...])
options = rest.isEmpty ? nil : rest
} else {
program = pluginValue
options = nil
}
guard !program.isEmpty else { return nil }
return (program, options)
}
}
// MARK: - 编码
extension SsUri {
/// 规范 SIP002 形态:`ss://userinfo@host:port[/?plugin=…][#remark]`。
/// AEAD-2022(SIP022,`2022-blake3-*`)按规范用百分号编码明文 userinfo,
/// 其余用 base64url(无 padding)。
func encode() -> 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
}
}
@@ -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)
}
}
@@ -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)))
}
}
+237
View File
@@ -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)
}
}