build: migrate NG2 to Swift 6 and clear warnings

This commit is contained in:
Qiu Yuzhou
2026-09-28 08:44:53 +08:00
parent 2946c34ddb
commit 5abb6c6033
36 changed files with 148 additions and 112 deletions
+1
View File
@@ -3,6 +3,7 @@ import AppKit
/// 混合应用形态的 AppKit 生命周期家:LSUIElement 让进程默认纯后台
/// (accessory),这里负责前台形态的进入与退出——任意路径使主窗口可见
/// 时切成普通应用(regular),最后一个窗口关闭后切回纯后台。
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
/// 混合形态的硬性要求:关窗不退进程,代理 GUI 仍驻留菜单栏。
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
@@ -185,6 +185,7 @@ enum ActivationCommandOutcome: Equatable, Sendable {
/// 激活缝(issue #41):目录命令面经此发出激活意图。生产 adapter 为
/// `ProxyRuntimeController`;测试注入假 adapter 观察目标传递。
@MainActor
protocol Activating: AnyObject {
func activate(_ target: NodeID) async throws -> ActivationCommandOutcome
}
@@ -128,8 +128,8 @@ final class DiagnosticsWorkflow: ObservableObject {
private let events: RuntimeEventStore
private let catalogFacts: @MainActor () -> DiagnosticCatalogFacts?
private let agentLogTail: () -> String?
private let fileFacts: () -> [DiagnosticFileFacts]
private let managedPlugins: () -> [DiagnosticPluginFacts]
private let fileFacts: @MainActor () -> [DiagnosticFileFacts]
private let managedPlugins: @MainActor () -> [DiagnosticPluginFacts]
private let homePath: () -> String?
private let clock: () -> Date
private let reportRendering: (DiagnosticSnapshot) -> String?
@@ -145,8 +145,9 @@ final class DiagnosticsWorkflow: ObservableObject {
agentLogTail: @escaping () -> String? = {
AgentLogTail.readLastLines(of: RuntimePaths.agentLogURL())
},
fileFacts: @escaping () -> [DiagnosticFileFacts] = DiagnosticsWorkflow.productionFileFacts,
managedPlugins: @escaping () -> [DiagnosticPluginFacts] =
fileFacts: @escaping @MainActor () -> [DiagnosticFileFacts] =
DiagnosticsWorkflow.productionFileFacts,
managedPlugins: @escaping @MainActor () -> [DiagnosticPluginFacts] =
DiagnosticsWorkflow.productionManagedPlugins,
homePath: @escaping () -> String? = { NSHomeDirectory() },
clock: @escaping () -> Date = { Date() },
@@ -141,7 +141,7 @@ enum RuleCoverage {
/// 代理动作的 IP 规则是否与固定本地 IP 范围冲突。
static func ipConflictsWithFixedLocal(_ match: RuleMatch) -> Bool {
for range in FixedLocalProxyRanges.ipRanges {
if let fixed = try? parseFixedLocalCIDR(range), ipIntersects(match, fixed) {
if let fixed = parseFixedLocalCIDR(range), ipIntersects(match, fixed) {
return true
}
}
@@ -218,7 +218,7 @@ struct GFWListConverter {
)
}
var rules = RuleSet(rules: shadowing.keptProxy + shadowing.keptExceptions).rules
let rules = RuleSet(rules: shadowing.keptProxy + shadowing.keptExceptions).rules
report.convertedCount = rules.count
report.absorbedCount = shadowing.shadowed.count
try validateRuleCount(rules.count, previousRuleCount: previousRuleCount)
@@ -40,14 +40,25 @@ enum GeolocationParser {
}
/// typed 前缀 → 条目构造的对照表;裸域名(无前缀)按 bareDomain 处理。
private static let typedPrefixes:
[(prefix: String, make: (String, [String]) -> GeolocationEntry)] = [
("include:", { .include(name: $0, attributes: $1) }),
("domain:", { .domain($0, attributes: $1) }),
("full:", { .full($0, attributes: $1) }),
("keyword:", { .keyword($0, attributes: $1) }),
("regexp:", { .regexp($0, attributes: $1) }),
]
private enum TypedPrefix: String, CaseIterable, Sendable {
case include = "include:"
case domain = "domain:"
case full = "full:"
case keyword = "keyword:"
case regexp = "regexp:"
func make(value: String, attributes: [String]) -> GeolocationEntry {
switch self {
case .include: .include(name: value, attributes: attributes)
case .domain: .domain(value, attributes: attributes)
case .full: .full(value, attributes: attributes)
case .keyword: .keyword(value, attributes: attributes)
case .regexp: .regexp(value, attributes: attributes)
}
}
}
private static let typedPrefixes = TypedPrefix.allCases
/// 去掉整行 `#` 注释与行内 ` #` 注释并修剪空白;空行返回 nil。
private static func codeLine(from rawLine: String) -> String? {
@@ -81,11 +92,11 @@ enum GeolocationParser {
let (content, attributes) = extractingAttributes(from: line)
guard !content.isEmpty else { return nil }
for typed in Self.typedPrefixes {
guard content.hasPrefix(typed.prefix) else { continue }
let value = String(content.dropFirst(typed.prefix.count))
guard content.hasPrefix(typed.rawValue) else { continue }
let value = String(content.dropFirst(typed.rawValue.count))
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { return nil }
return typed.make(value, attributes)
return typed.make(value: value, attributes: attributes)
}
return .bareDomain(content, attributes: attributes)
}
+12 -2
View File
@@ -173,7 +173,9 @@ enum RuleMatch: Equatable, Hashable, Codable, Sendable {
guard inet_ntop(AF_INET, &networkAddress, &buffer, socklen_t(INET_ADDRSTRLEN)) != nil else {
throw RuleMatchError.invalidCIDR(raw)
}
let dotted = String(cString: buffer)
guard let dotted = Self.string(from: buffer) else {
throw RuleMatchError.invalidCIDR(raw)
}
return "\(dotted)/\(prefixLength)"
}
@@ -220,7 +222,15 @@ enum RuleMatch: Equatable, Hashable, Codable, Sendable {
guard inet_ntop(AF_INET6, &networkAddress, &buffer, socklen_t(INET6_ADDRSTRLEN)) != nil else {
throw RuleMatchError.invalidCIDR(raw)
}
return "\(String(cString: buffer))/\(prefixLength)"
guard let address = Self.string(from: buffer) else {
throw RuleMatchError.invalidCIDR(raw)
}
return "\(address)/\(prefixLength)"
}
private static func string(from buffer: [CChar]) -> String? {
let bytes = buffer.prefix(while: { $0 != 0 }).map { UInt8(bitPattern: $0) }
return String(bytes: bytes, encoding: .utf8)
}
}
@@ -84,7 +84,7 @@ final class ActivationStateFileStoreTests: XCTestCase {
let credentials = InMemoryCredentialStore()
let leaf = try ActivationFixture.addPlainServer("a", in: &catalog, credentials: credentials)
var machine = ActivationStateMachine()
try ActivationFixture.activate(&machine, leaf, in: catalog, credentials: credentials)
_ = try ActivationFixture.activate(&machine, leaf, in: catalog, credentials: credentials)
try store.save(activeTargetID: machine.activeTargetID)
// 下次启动:从文件恢复目标,载入目录后立即重校验(D5「重新校验同步」)。
@@ -178,7 +178,7 @@ final class CatalogCommitCoordinatorTests: XCTestCase {
await waitUntilRuntimeSettles(runtime.convergeCount == 1)
try coordinator.commit { catalog, _ in try catalog.renameGroup(groupID, to: "改名") }
await waitUntilRuntimeSettles(runtime.convergeCount == 2)
try coordinator.commit { catalog, _ in try catalog.remove(serverID) }
_ = try coordinator.commit { catalog, _ in try catalog.remove(serverID) }
await waitUntilRuntimeSettles(runtime.convergeCount == 3)
XCTAssertEqual(runtime.convergeCount, 3, "移动、改名、删除各触发一次同步")
@@ -196,7 +196,7 @@ final class CatalogCommitCoordinatorTests: XCTestCase {
runtime.hasActiveTarget = true
runtime.enqueueOutcomes([.clearedAndStopped(.targetNotFound(targetID))])
try coordinator.commit { catalog, _ in try catalog.remove(targetID) }
_ = try coordinator.commit { catalog, _ in try catalog.remove(targetID) }
await waitUntilRuntimeSettles(coordinator.syncStatus != .syncing(generation: 1))
XCTAssertEqual(runtime.convergeCount, 1)
@@ -230,8 +230,8 @@ final class CatalogCommitCoordinatorTests: XCTestCase {
func testSupersededSyncBeforeStartNeverCallsRuntime() async throws {
runtime.hasActiveTarget = true
try coordinator.commit { catalog, _ in try catalog.addTestServer("第一台") }
try coordinator.commit { catalog, _ in try catalog.addTestServer("第二台") }
_ = try coordinator.commit { catalog, _ in try catalog.addTestServer("第一台") }
_ = try coordinator.commit { catalog, _ in try catalog.addTestServer("第二台") }
// 同一 MainActor 回合内的两次提交:旧代次同步尚未启动即被取代。
await waitUntilRuntimeSettles(runtime.convergeCount == 1)
@@ -251,10 +251,10 @@ final class CatalogCommitCoordinatorTests: XCTestCase {
])
runtime.armGate()
try coordinator.commit { catalog, _ in try catalog.addTestServer("第一台") }
_ = try coordinator.commit { catalog, _ in try catalog.addTestServer("第一台") }
await waitUntilRuntimeSettles(runtime.convergeCount == 1)
try coordinator.commit { catalog, _ in try catalog.addTestServer("第二台") }
_ = try coordinator.commit { catalog, _ in try catalog.addTestServer("第二台") }
runtime.openGate()
await waitUntilRuntimeSettles(runtime.convergeCount == 2)
await waitUntilRuntimeSettles(coordinator.syncStatus != .syncing(generation: 2))
@@ -45,7 +45,7 @@ final class CatalogFileStoreTests: XCTestCase {
try store.save(CatalogDocument(catalog: catalog))
try catalog.remove(first)
let second = try catalog.addTestServer("second")
_ = try catalog.addTestServer("second")
try store.save(CatalogDocument(catalog: catalog))
XCTAssertEqual(try store.load().catalog, catalog)
+3 -2
View File
@@ -87,9 +87,10 @@ extension ConfigurationCatalog {
}
/// `XCTAssertThrowsError` 的 async 版本(表达式在 await 之后才能检查)。
@MainActor
func expectThrowsAsync(
_ expression: () async throws -> Void,
onThrow errorHandler: (Error) -> Void = { _ in },
_ expression: @MainActor () async throws -> Void,
onThrow errorHandler: @MainActor (Error) -> Void = { _ in },
file: StaticString = #filePath,
line: UInt = #line
) async {
@@ -3,6 +3,7 @@ import XCTest
@testable import ShadowsocksX_NG2
@MainActor
extension CatalogWorkflowSubscriptionTests {
// MARK: 失败保留语义
@@ -88,6 +89,7 @@ extension CatalogWorkflowSubscriptionTests {
}
}
@MainActor
extension CatalogWorkflowSubscriptionTests {
// MARK: Typed failure projection
@@ -4,6 +4,7 @@ import XCTest
@testable import ShadowsocksX_NG2
@MainActor
extension CatalogWorkflowSubscriptionTests {
// MARK: 批量刷新与持久化
@@ -46,27 +47,28 @@ extension CatalogWorkflowSubscriptionTests {
let record = try await seedSuccessfulSubscription()
// 用门控获取器重建工作流:刷新停在 fetch 内部,验证并发守卫。
let gated = GatedFetcher()
workflow = makeWorkflow(fetcher: gated)
let refreshWorkflow = makeWorkflow(fetcher: gated)
workflow = refreshWorkflow
async let first: Void = workflow.refreshSubscription(record.id)
async let first: Void = refreshWorkflow.refreshSubscription(record.id)
// 轮询而非阻塞等待:主线程阻塞会饿死继承 MainActor 的 async let 子任务。
while !gated.didEnterFetch {
try await Task.sleep(nanoseconds: 10_000_000)
}
await workflow.refreshSubscription(record.id)
await refreshWorkflow.refreshSubscription(record.id)
XCTAssertEqual(gated.requestCount, 1, "同一订阅刷新进行中不重入")
gated.releaseAll()
await first
XCTAssertTrue(workflow.refreshingSubscriptionIDs.isEmpty, "完成后守卫释放")
XCTAssertTrue(refreshWorkflow.refreshingSubscriptionIDs.isEmpty, "完成后守卫释放")
}
}
/// 可门控获取缝:让第一次刷新停在 fetch 内部,验证并发守卫。fetch 在协作
/// 线程池上阻塞等待放行信号,不占用 MainActor。
/// 可门控获取缝:让第一次刷新停在 fetch 内部,验证并发守卫。fetch 挂起等待
/// 异步门闩,不阻塞 MainActor 或协作线程池。
final class GatedFetcher: SubscriptionFetching, @unchecked Sendable {
private let lock = NSLock()
private let release = DispatchSemaphore(value: 0)
private let release = AsyncGate()
private var count = 0
private var entered = false
@@ -83,15 +85,15 @@ final class GatedFetcher: SubscriptionFetching, @unchecked Sendable {
}
func fetch(_ url: URL) async throws -> Data {
lock.lock()
count += 1
entered = true
lock.unlock()
_ = release.wait(timeout: .now() + 5)
lock.withLock {
count += 1
entered = true
}
await release.wait()
return SubscriptionDocs.flat(serverCount: 0)
}
func releaseAll() {
release.signal()
release.release()
}
}
@@ -2,6 +2,7 @@ import XCTest
@testable import ShadowsocksX_NG2
@MainActor
extension CatalogWorkflowTests {
// MARK: - 重命名与移动
@@ -24,7 +25,7 @@ extension CatalogWorkflowTests {
let manualGroupID = try await workflow.createGroup(named: "手动组", into: nil)
await expectThrowsAsync { try await workflow.move(fixture.serverIDs[0], to: manualGroupID) }
await expectThrowsAsync { try await workflow.remove(fixture.serverIDs[0]) }
await expectThrowsAsync { _ = try await workflow.remove(fixture.serverIDs[0]) }
await expectThrowsAsync { try await workflow.renameGroup(fixture.groupID, to: "改名") }
await expectThrowsAsync {
try await workflow.updateServer(
@@ -220,7 +220,7 @@ final class CatalogWorkflowTests: XCTestCase {
func testRemoveServerCleansCredentials() async throws {
// 已知凭据引用直建目录(模块投影不含引用;身份经文件夹具固定)。
var catalog = ConfigurationCatalog()
var fields = ServerFields(
let fields = ServerFields(
address: "203.0.113.7", port: 8388, encryptionMethod: "aes-256-gcm",
passwordRef: CredentialReference(rawValue: "ref-pw"), remark: "删除我",
pluginProgram: "v2ray-plugin",
@@ -26,8 +26,8 @@ class DiagnosticsWorkflowTestCase: XCTestCase {
var facts: FakeRuntimeFacts!
var events: RuntimeEventStore!
override func setUp() {
super.setUp()
override func setUp() async throws {
try await super.setUp()
workDir = FileManager.default.temporaryDirectory
.appendingPathComponent("ssxng-diagwf-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true)
@@ -37,18 +37,18 @@ class DiagnosticsWorkflowTestCase: XCTestCase {
RuntimeLog.setSink(events)
}
override func tearDownWithError() throws {
override func tearDown() async throws {
RuntimeLog.setSink(RuntimeEventStore.shared)
try? FileManager.default.removeItem(at: workDir)
try super.tearDownWithError()
try await super.tearDown()
}
/// 标准 workflow:事实源注入替身、固定时钟(报告生成时间可精确断言)。
func makeWorkflow(
catalog: @escaping @MainActor () -> DiagnosticCatalogFacts? = { nil },
agentLog: @escaping () -> String? = { nil },
fileFacts: @escaping () -> [DiagnosticFileFacts] = { [] },
managedPlugins: @escaping () -> [DiagnosticPluginFacts] = { [] },
fileFacts: @escaping @MainActor () -> [DiagnosticFileFacts] = { [] },
managedPlugins: @escaping @MainActor () -> [DiagnosticPluginFacts] = { [] },
homePath: @escaping () -> String? = { nil },
render: @escaping (DiagnosticSnapshot) -> String? = {
DiagnosticReportBuilder.markdown(from: $0)
@@ -47,7 +47,7 @@ final class DiagnosticsWorkflowLifecycleTests: DiagnosticsWorkflowTestCase {
try Data("{\"servers\":1}".utf8).write(to: fileURL)
try FileManager.default.setAttributes(
[.posixPermissions: 0o600], ofItemAtPath: fileURL.path)
let fileFacts: () -> [DiagnosticFileFacts] = {
let fileFacts: @MainActor @Sendable () -> [DiagnosticFileFacts] = {
[DiagnosticFileCollector.collect(label: "catalog.json", url: fileURL)]
}
let beforeFacts = fileFacts()
@@ -8,7 +8,7 @@ final class EndpointHealthProbeTests: XCTestCase {
/// 绑定回环临时端口;调用方负责 close 返回的描述符。
private func bindLoopbackListener() throws -> (descriptor: Int32, port: Int) {
let socketFD = socket(AF_INET, SOCK_STREAM, 0)
try XCTUnwrap(socketFD >= 0 ? socketFD : nil, "创建探测用 socket 失败")
_ = try XCTUnwrap(socketFD >= 0 ? socketFD : nil, "创建探测用 socket 失败")
var address = sockaddr_in()
address.sin_family = sa_family_t(AF_INET)
@@ -15,8 +15,8 @@ final class ProxyControlWorkflowIntegrationTests: XCTestCase {
var agent: ProxyRuntimeFixture.FakeLaunchAgent!
var systemProxy: ProxyRuntimeFixture.FakeSystemProxy!
override func setUpWithError() throws {
try super.setUpWithError()
override func setUp() async throws {
try await super.setUp()
runtime = ProxyRuntimeFixture.makeTemporaryRuntime()
catalogFileURL = runtime.directory.appendingPathComponent("catalog.json")
activationFileURL = runtime.directory.appendingPathComponent("activation.json")
@@ -25,9 +25,9 @@ final class ProxyControlWorkflowIntegrationTests: XCTestCase {
systemProxy = ProxyRuntimeFixture.FakeSystemProxy()
}
override func tearDownWithError() throws {
override func tearDown() async throws {
try? FileManager.default.removeItem(at: runtime.directory)
try super.tearDownWithError()
try await super.tearDown()
}
/// 控制器、目录工作流与代理控制工作流的生产组合(同一控制器经两条缝)。
@@ -81,8 +81,8 @@ final class ProxyControlWorkflowTests: XCTestCase {
private var targetFacts: FakeTargetFacts!
private var workflow: ProxyControlWorkflow!
override func setUp() {
super.setUp()
override func setUp() async throws {
try await super.setUp()
runtime = FakeProxyRuntime()
targetFacts = FakeTargetFacts()
workflow = ProxyControlWorkflow(runtime: runtime, targetFacts: targetFacts)
@@ -33,8 +33,8 @@ final class CatalogRuntimeSnapshotIntegrationTests: XCTestCase {
private var agent: ProxyRuntimeFixture.FakeLaunchAgent!
private var systemProxy: ProxyRuntimeFixture.FakeSystemProxy!
override func setUpWithError() throws {
try super.setUpWithError()
override func setUp() async throws {
try await super.setUp()
runtime = ProxyRuntimeFixture.makeTemporaryRuntime()
catalogFileURL = runtime.directory.appendingPathComponent("catalog.json")
activationFileURL = runtime.directory.appendingPathComponent("activation.json")
@@ -43,9 +43,9 @@ final class CatalogRuntimeSnapshotIntegrationTests: XCTestCase {
systemProxy = ProxyRuntimeFixture.FakeSystemProxy()
}
override func tearDownWithError() throws {
override func tearDown() async throws {
try? FileManager.default.removeItem(at: runtime.directory)
try super.tearDownWithError()
try await super.tearDown()
}
func testCommitWithoutActiveTargetFeedsLaterActivationThroughSharedSnapshot() async throws {
@@ -85,7 +85,7 @@ final class CatalogRuntimeSnapshotIntegrationTests: XCTestCase {
runtime: ProxyRuntimeSyncAdapter(controller: controller),
bootstrap: bootstrap)
try coordinator.commit { catalog, _ in try catalog.remove(server) }
_ = try coordinator.commit { catalog, _ in try catalog.remove(server) }
await waitUntilRuntimeSettles(
{
@@ -212,7 +212,7 @@ extension ProxyRuntimeControllerTests {
try CatalogFileStore(fileURL: catalogFileURL).save(CatalogDocument(catalog: catalog))
let committed = try CatalogFileStore(fileURL: catalogFileURL).load().catalog
await controller.catalogDidCommit(snapshot: committed)
_ = await controller.catalogDidCommit(snapshot: committed)
XCTAssertNil(controller.activeTargetID)
XCTAssertNotNil(controller.lastActivationFailure, "点名原因独立呈现")
@@ -119,7 +119,7 @@ extension ProxyRuntimeControllerTests {
XCTAssertEqual(controller.proxyMode, .rule, "新实例未验证时恢复旧模式")
XCTAssertEqual(controller.settings.preferredMode, .rule)
XCTAssertEqual(settingsStore.saved?.preferredMode, .rule)
XCTAssertEqual(try runtimeStore.loadDocument(), previousDocument)
XCTAssertEqual(runtimeStore.loadDocument(), previousDocument)
XCTAssertEqual(controller.state, .running, "旧运行时恢复后重新呈现健康")
XCTAssertEqual(controller.systemProxyState, .applied)
XCTAssertEqual(systemProxy.applied.count, previousApplicationCount)
@@ -124,7 +124,7 @@ extension ProxyRuntimeControllerTests {
XCTAssertEqual(controller.proxyMode, .rule, "新实例未验证时恢复旧模式")
XCTAssertEqual(controller.settings.preferredMode, .rule)
XCTAssertEqual(settingsStore.saved?.preferredMode, .rule)
XCTAssertEqual(try runtimeStore.loadDocument(), previousDocument)
XCTAssertEqual(runtimeStore.loadDocument(), previousDocument)
XCTAssertEqual(controller.state, .running, "旧运行时恢复后重新呈现健康")
XCTAssertEqual(controller.systemProxyState, .applied)
XCTAssertEqual(systemProxy.applied.count, previousApplicationCount)
@@ -50,8 +50,8 @@ final class ProxyRuntimeControllerTests: XCTestCase {
}
}
override func setUpWithError() throws {
try super.setUpWithError()
override func setUp() async throws {
try await super.setUp()
runtime = ProxyRuntimeFixture.makeTemporaryRuntime()
catalogFileURL = runtime.directory.appendingPathComponent("catalog.json")
activationFileURL = runtime.directory.appendingPathComponent("activation.json")
@@ -61,9 +61,9 @@ final class ProxyRuntimeControllerTests: XCTestCase {
signals = SignalRecorder()
}
override func tearDownWithError() throws {
override func tearDown() async throws {
try? FileManager.default.removeItem(at: runtime.directory)
try super.tearDownWithError()
try await super.tearDown()
}
/// 建一个含单台服务器的目录并落盘(服务器密码进内存凭据存储)。
@@ -293,7 +293,7 @@ final class ProxyRuntimeControllerTests: XCTestCase {
// 协调器生产适配入口(issue #40):以刚提交的内存快照重展开。
let committed = try CatalogFileStore(fileURL: catalogFileURL).load().catalog
await controller.catalogDidCommit(snapshot: committed)
_ = await controller.catalogDidCommit(snapshot: committed)
XCTAssertEqual(controller.state, .running)
let expectedRemark = "新加坡 01"
@@ -76,7 +76,7 @@ final class RuntimeFileStoreTests: XCTestCase {
XCTAssertEqual(
try permissions(of: store.aclVariantFileURL(summary: "direct")), 0o600,
"变体文件 0600;链接权限由文件系统决定,不作断言")
XCTAssertEqual(try store.loadDocument(), document)
XCTAssertEqual(store.loadDocument(), document)
}
/// ADR-0011:变体落在 `acl-<summary>.ini`,契约路径是稳定的 `acl-active.ini`
@@ -162,7 +162,7 @@ final class RuntimeFileStoreTests: XCTestCase {
failingStore.aclVariantFileURL(summary: "direct").lastPathComponent,
"契约写失败后链接回到此前指向")
XCTAssertEqual(
try failingStore.loadDocument(), previous,
failingStore.loadDocument(), previous,
"契约未写成功,读取侧仍看到此前文档")
}
@@ -201,19 +201,19 @@ final class RuntimeFileStoreTests: XCTestCase {
let document = ProxyRuntimeFixture.makeDocument()
try store.write(document)
XCTAssertEqual(try store.loadDocument(), document)
XCTAssertEqual(try store.readData(), try document.jsonData())
XCTAssertEqual(store.loadDocument(), document)
XCTAssertEqual(store.readData(), try document.jsonData())
}
func testLoadDocumentReturnsNilForMissingFile() throws {
XCTAssertNil(try store.loadDocument())
XCTAssertNil(try store.readData())
XCTAssertNil(store.loadDocument())
XCTAssertNil(store.readData())
}
func testLoadDocumentReturnsNilForCorruptJSON() throws {
try Data("not json {".utf8).write(to: runtime.contract)
XCTAssertNil(try store.loadDocument())
XCTAssertNil(store.loadDocument())
}
func testLoadDocumentReturnsNilForStructurallyInvalidDocument() throws {
@@ -221,7 +221,7 @@ final class RuntimeFileStoreTests: XCTestCase {
SslocalRuntimeDocument(
servers: [], listen: SslocalListenSettings(socksPort: 0)))
XCTAssertNil(try store.loadDocument(), "本地端口无效属结构性无效(读取侧防御)")
XCTAssertNil(store.loadDocument(), "本地端口无效属结构性无效(读取侧防御)")
}
// MARK: 显式停止清理
@@ -9,8 +9,8 @@ final class SettingsWorkflowInterfaceTests: XCTestCase {
var committing: FakeSettingsCommitter!
var probe: FakeOccupancyProbe!
override func setUpWithError() throws {
try super.setUpWithError()
override func setUp() async throws {
try await super.setUp()
committing = FakeSettingsCommitter()
probe = FakeOccupancyProbe()
}
@@ -4,6 +4,7 @@ import XCTest
@testable import ShadowsocksX_NG2
/// 轮询等待异步投影落定(设置工作流测试共享)。
@MainActor
func waitUntil(
_ condition: @autoclosure () -> Bool,
timeout: TimeInterval = 2,
@@ -13,8 +13,8 @@ final class SettingsWorkflowTests: XCTestCase {
private var systemProxy: ProxyRuntimeFixture.FakeSystemProxy!
private var settingsStore: InMemorySettingsStore!
override func setUpWithError() throws {
try super.setUpWithError()
override func setUp() async throws {
try await super.setUp()
runtime = ProxyRuntimeFixture.makeTemporaryRuntime()
catalogFileURL = runtime.directory.appendingPathComponent("catalog.json")
activationFileURL = runtime.directory.appendingPathComponent("activation.json")
@@ -24,9 +24,9 @@ final class SettingsWorkflowTests: XCTestCase {
settingsStore = InMemorySettingsStore()
}
override func tearDownWithError() throws {
override func tearDown() async throws {
try? FileManager.default.removeItem(at: runtime.directory)
try super.tearDownWithError()
try await super.tearDown()
}
func testListenerModeSavePersistsWithoutStartingAnOffAgent() async throws {
+3 -1
View File
@@ -2,13 +2,15 @@ import XCTest
@testable import ShadowsocksX_NG2
@MainActor
final class SkeletonTests: XCTestCase {
// 以下两个 Bundle.main 断言依赖测试包以 app 为 TEST_HOST 注入(target dependency);
// 若移除该依赖,Bundle.main 不再是 app bundle,断言会失效。
func testMenuBarAppDoesNotTerminateAfterLastWindowClosed() {
let delegate = AppDelegate()
XCTAssertFalse(delegate.applicationShouldTerminateAfterLastWindowClosed(.shared))
let shouldTerminate = delegate.applicationShouldTerminateAfterLastWindowClosed(.shared)
XCTAssertFalse(shouldTerminate)
}
func testAppBundleIDIsDistinctFromLegacy() {
@@ -198,13 +198,15 @@ extension RealSslocalSmokeTests {
listen: SslocalListenSettings, fakeSS: ConnectionCountingServer
) throws {
let beforeHit = fakeSS.connectionCount
performSocksConnectReply(socksPort: listen.socksPort, targetHost: "8.8.8.8", targetPort: 53)
_ = performSocksConnectReply(
socksPort: listen.socksPort, targetHost: "8.8.8.8", targetPort: 53)
Thread.sleep(forTimeInterval: 0.5)
XCTAssertEqual(
fakeSS.connectionCount, beforeHit,
"命中中国 IPv4 CIDR 的目标应直连,不得触达 Shadowsocks 出口")
performSocksConnectReply(socksPort: listen.socksPort, targetHost: "1.1.1.1", targetPort: 443)
_ = performSocksConnectReply(
socksPort: listen.socksPort, targetHost: "1.1.1.1", targetPort: 443)
XCTAssertTrue(
try waitForCondition(timeout: 5) { fakeSS.connectionCount > beforeHit },
"未命中 CIDR 的公网目标应连接 Shadowsocks 出口")
@@ -242,7 +244,8 @@ extension RealSslocalSmokeTests {
) throws {
// .cn 域名候选直连:SOCKS CONNECT example.cn 不应触达 SS 出口。
let beforeCN = fakeSS.connectionCount
performSocksConnectReply(socksPort: listen.socksPort, targetHost: "example.cn", targetPort: 443)
_ = performSocksConnectReply(
socksPort: listen.socksPort, targetHost: "example.cn", targetPort: 443)
Thread.sleep(forTimeInterval: 0.5)
XCTAssertEqual(
fakeSS.connectionCount, beforeCN,
@@ -60,21 +60,21 @@ extension RealSslocalSmokeTests {
listen: SslocalListenSettings, fakeSS: ConnectionCountingServer
) throws {
let beforeDirect = fakeSS.connectionCount
performSocksConnectReply(
_ = performSocksConnectReply(
socksPort: listen.socksPort, targetHost: "sub.direct.example", targetPort: 443)
Thread.sleep(forTimeInterval: 0.5)
XCTAssertEqual(
fakeSS.connectionCount, beforeDirect,
"自定义域名后缀直连规则不得触达 Shadowsocks 出口")
performSocksConnectReply(
_ = performSocksConnectReply(
socksPort: listen.socksPort, targetHost: "exact.direct.example", targetPort: 443)
Thread.sleep(forTimeInterval: 0.5)
XCTAssertEqual(
fakeSS.connectionCount, beforeDirect,
"自定义完整域名直连规则不得触达 Shadowsocks 出口")
performSocksConnectReply(
_ = performSocksConnectReply(
socksPort: listen.socksPort,
request: socksIPv4ConnectRequest([203, 0, 113, 10], port: 443))
Thread.sleep(forTimeInterval: 0.5)
@@ -88,7 +88,7 @@ extension RealSslocalSmokeTests {
listen: SslocalListenSettings, fakeSS: ConnectionCountingServer
) throws {
let beforeHit = fakeSS.connectionCount
performSocksConnectReply(
_ = performSocksConnectReply(
socksPort: listen.socksPort, targetHost: "unmatched.example.org", targetPort: 443)
XCTAssertTrue(
try waitForCondition(timeout: 5) { fakeSS.connectionCount > beforeHit },
@@ -157,14 +157,14 @@ extension RealSslocalSmokeTests {
listen: SslocalListenSettings, fakeSS: ConnectionCountingServer
) throws {
let beforeHit = fakeSS.connectionCount
performSocksConnectReply(
_ = performSocksConnectReply(
socksPort: listen.socksPort, targetHost: "sub.blocked.example", targetPort: 443)
XCTAssertTrue(
try waitForCondition(timeout: 5) { fakeSS.connectionCount > beforeHit },
"自定义代理候选应连接 Shadowsocks 出口")
let afterHit = fakeSS.connectionCount
performSocksConnectReply(
_ = performSocksConnectReply(
socksPort: listen.socksPort, targetHost: "unmatched.example.org", targetPort: 443)
Thread.sleep(forTimeInterval: 0.5)
XCTAssertEqual(
@@ -129,7 +129,7 @@ extension RealSslocalSmokeTests {
listen: SslocalListenSettings, fakeSS: ConnectionCountingServer
) throws {
let beforeDomain = fakeSS.connectionCount
performSocksConnectReply(
_ = performSocksConnectReply(
socksPort: listen.socksPort, targetHost: "sub.example.com", targetPort: 443)
XCTAssertTrue(
try waitForCondition(timeout: 5) { fakeSS.connectionCount > beforeDomain },
@@ -142,7 +142,7 @@ extension RealSslocalSmokeTests {
listen: SslocalListenSettings, fakeSS: ConnectionCountingServer
) throws {
let beforeIP = fakeSS.connectionCount
performSocksConnectReply(
_ = performSocksConnectReply(
socksPort: listen.socksPort,
request: socksIPv4ConnectRequest([8, 8, 8, 8], port: 53))
Thread.sleep(forTimeInterval: 0.5)
@@ -150,7 +150,7 @@ extension RealSslocalSmokeTests {
fakeSS.connectionCount, beforeIP,
"IP 优先级:bypass_list 应优先于 proxy_list")
performSocksConnectReply(
_ = performSocksConnectReply(
socksPort: listen.socksPort,
request: socksIPv4ConnectRequest([8, 8, 8, 1], port: 53))
XCTAssertTrue(
@@ -164,28 +164,28 @@ extension RealSslocalSmokeTests {
listen: SslocalListenSettings, fakeSS: ConnectionCountingServer
) throws {
let beforeHit = fakeSS.connectionCount
performSocksConnectReply(
_ = performSocksConnectReply(
socksPort: listen.socksPort, targetHost: "blocked.example", targetPort: 443)
XCTAssertTrue(
try waitForCondition(timeout: 5) { fakeSS.connectionCount > beforeHit },
"GFWList 代理候选应连接 Shadowsocks 出口")
let afterHit = fakeSS.connectionCount
performSocksConnectReply(
_ = performSocksConnectReply(
socksPort: listen.socksPort, targetHost: "cdn.blocked.example", targetPort: 443)
XCTAssertTrue(
try waitForCondition(timeout: 5) { fakeSS.connectionCount > afterHit },
"GFWList 子域代理候选应连接 Shadowsocks 出口")
let afterMatched = fakeSS.connectionCount
performSocksConnectReply(
_ = performSocksConnectReply(
socksPort: listen.socksPort, targetHost: "unmatched.example.org", targetPort: 443)
Thread.sleep(forTimeInterval: 0.5)
XCTAssertEqual(
fakeSS.connectionCount, afterMatched,
"未匹配域名在 bypass_all 下应直连,不得触达 Shadowsocks 出口")
performSocksConnectReply(
_ = performSocksConnectReply(
socksPort: listen.socksPort,
request: socksIPv4ConnectRequest([1, 1, 1, 1], port: 443))
Thread.sleep(forTimeInterval: 0.5)
@@ -169,10 +169,10 @@ final class FakeSubscriptionFetcher: SubscriptionFetching, @unchecked Sendable {
}
func fetch(_ url: URL) async throws -> Data {
lock.lock()
requests.append(url)
let behavior = self.behavior
lock.unlock()
let behavior = lock.withLock {
requests.append(url)
return self.behavior
}
switch behavior {
case .success(let data):
return data
@@ -13,8 +13,8 @@ final class UnreadableListenSettingsTests: XCTestCase {
private var agent: ProxyRuntimeFixture.FakeLaunchAgent!
private var systemProxy: ProxyRuntimeFixture.FakeSystemProxy!
override func setUpWithError() throws {
try super.setUpWithError()
override func setUp() async throws {
try await super.setUp()
runtime = ProxyRuntimeFixture.makeTemporaryRuntime()
catalogFileURL = runtime.directory.appendingPathComponent("catalog.json")
activationFileURL = runtime.directory.appendingPathComponent("activation.json")
@@ -23,9 +23,9 @@ final class UnreadableListenSettingsTests: XCTestCase {
systemProxy = ProxyRuntimeFixture.FakeSystemProxy()
}
override func tearDownWithError() throws {
override func tearDown() async throws {
try? FileManager.default.removeItem(at: runtime.directory)
try super.tearDownWithError()
try await super.tearDown()
}
private func makeController(
+2 -2
View File
@@ -7,7 +7,7 @@ options:
settings:
base:
ARCHS: arm64
SWIFT_VERSION: "5.0"
SWIFT_VERSION: "6.0"
CODE_SIGN_STYLE: Manual
DEVELOPMENT_TEAM: S878RH3PA8
ENABLE_HARDENED_RUNTIME: YES
@@ -164,7 +164,7 @@ schemes:
ShadowsocksX-NG2Smoke:
build:
targets:
ShadowsocksX-NG2: [build]
ShadowsocksX-NG2: [run, test]
ShadowsocksX-NG2SmokeTests: [test]
test:
targets: