mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-29 13:43:49 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) ?? []
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user