diff --git a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow+Connectivity.swift b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow+Connectivity.swift new file mode 100644 index 00000000..b14ad8d1 --- /dev/null +++ b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow+Connectivity.swift @@ -0,0 +1,56 @@ +import Foundation + +extension CatalogTreeNode { + var connectivityServerIDs: [NodeID] { + isGroup ? childNodes.flatMap(\.connectivityServerIDs) : [id] + } +} + +extension CatalogWorkflow { + /// Hash connection facts only; display-name and directory edits preserve observations. + func connectivityRevision(for id: NodeID) -> String { + guard let entry = dependencies.coordinator.committedCatalog.entry(for: id), + case .server(var fields) = entry.kind + else { return "missing" } + fields.remark = "" + let plugin = fields.pluginProgram.flatMap { + dependencies.plugins.catalogSnapshot().entry(for: $0) + } + let secrets = [fields.passwordRef, fields.pluginOptionsRef].compactMap { $0 }.map { + (try? dependencies.credentials.secret(for: $0)) ?? "unreadable" + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let facts = + [ + String(data: (try? encoder.encode(fields)) ?? Data(), encoding: .utf8) ?? "", + String(describing: plugin), + ] + secrets + return ProxyACLDocument.digest((try? JSONEncoder().encode(facts)) ?? Data()) + } + + /// Captures all descendants in catalog order, independently of UI collapse or sort. + func connectivityTargets(source: NodeSource?) -> [NodeID] { + let nodes = source == nil ? favorites : tree.roots.filter { $0.source == source } + var seen = Set() + return nodes.flatMap(\.connectivityServerIDs).filter { seen.insert($0).inserted } + } + + /// This local state machine derives a single leaf without publishing activation. + func connectivityConfiguration(for id: NodeID) throws -> SslocalServerDocument { + let catalog = dependencies.coordinator.committedCatalog + guard case .server? = catalog.entry(for: id)?.kind else { throw ServerFormLoadError.notFound } + var derivation = ActivationStateMachine() + if case .server(let fields)? = catalog.entry(for: id)?.kind, + !EncryptionMethodCatalog.isSupported(fields.encryptionMethod) + { + throw ServerConnectivity.ConfigurationFailure.unsupported + } + let config = try derivation.activate( + id, in: catalog, credentials: dependencies.credentials, + plugins: dependencies.plugins, + options: RuntimeDocumentOptions(listen: SslocalListenSettings())) + guard let server = config.document.servers.first else { throw ServerFormLoadError.notFound } + return server + } +} diff --git a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow.swift b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow.swift index 11715608..11399942 100644 --- a/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow.swift +++ b/ShadowsocksX-NG2/App/Application/CatalogWorkflow/CatalogWorkflow.swift @@ -46,6 +46,8 @@ final class CatalogWorkflow: ObservableObject { /// 插件、订阅获取、Legacy 导入服务、导入后回调与激活缝只经此束持有, /// 不再作为 workflow 属性暴露。仅供组合根与测试构造;视图不得访问—— /// 独立 target 拆分前由 architecture deletion check 守护残余可见性。 + var connectivity: ServerConnectivity? + let dependencies: CatalogWorkflowDependencies /// 启动时发现的 Legacy 快照(导入编排用;秘密值不进 projection)。 var discoveredLegacySnapshot: LegacySnapshot? @@ -393,6 +395,7 @@ final class CatalogWorkflow: ObservableObject { /// 提交成功后的 projection 重建(树 + 订阅卡片)。 func republishCommittedState() { + connectivity?.configurationsChanged() let coordinator = dependencies.coordinator favoriteIDs = coordinator.committedFavoriteIDs tree = .build( diff --git a/ShadowsocksX-NG2/App/Application/Connectivity/ConnectivityHTTPRequest.swift b/ShadowsocksX-NG2/App/Application/Connectivity/ConnectivityHTTPRequest.swift new file mode 100644 index 00000000..8d18516a --- /dev/null +++ b/ShadowsocksX-NG2/App/Application/Connectivity/ConnectivityHTTPRequest.swift @@ -0,0 +1,32 @@ +import Foundation + +/// System libcurl exposes complete headers without body sniffing. Each easy +/// handle has isolated connection state and normal system certificate checks. +struct ConnectivityHTTPRequest: Sendable { + func perform(url: URL, proxyPort: Int) async -> ServerConnectivity.Outcome { + let cancellation = Cancellation() + let started = ConnectivityHTTPNow() + return await withTaskCancellationHandler { + if Task.isCancelled { return .network } + return await Task.detached(priority: .utility) { + let result = url.absoluteString.withCString { + ConnectivityHTTPMeasure($0, Int32(proxyPort), started, cancellation.pointer) + } + if result.timed_out != 0 { return ServerConnectivity.Outcome.requestTimeout } + guard result.received_headers != 0 else { return .network } + return (200..<300).contains(result.status) + ? .success(milliseconds: Int(result.milliseconds)) : .http(Int(result.status)) + }.value + } onCancel: { + cancellation.cancel() + } + } + + /// Lifetime is shared by the worker and cancellation handler. The C token is + /// atomic; destruction happens only after both users release this object. + private final class Cancellation: @unchecked Sendable { + let pointer = ConnectivityHTTPCreateCancellation() + func cancel() { ConnectivityHTTPCancel(pointer) } + deinit { ConnectivityHTTPDestroyCancellation(pointer) } + } +} diff --git a/ShadowsocksX-NG2/App/Application/Connectivity/ConnectivityHTTPTransport.c b/ShadowsocksX-NG2/App/Application/Connectivity/ConnectivityHTTPTransport.c new file mode 100644 index 00000000..db8dcf5e --- /dev/null +++ b/ShadowsocksX-NG2/App/Application/Connectivity/ConnectivityHTTPTransport.c @@ -0,0 +1,109 @@ +#include "ConnectivityHTTPTransport.h" +#include +#include +#include +#include +#include +#include + +struct ConnectivityHTTPCancellation { atomic_bool cancelled; }; +static pthread_once_t initialized = PTHREAD_ONCE_INIT; +static void initialize_curl(void) { curl_global_init(CURL_GLOBAL_DEFAULT); } + +uint64_t ConnectivityHTTPNow(void) { + struct timespec time; + clock_gettime(CLOCK_MONOTONIC, &time); + return (uint64_t)time.tv_sec * 1000000000ULL + (uint64_t)time.tv_nsec; +} +ConnectivityHTTPCancellation *ConnectivityHTTPCreateCancellation(void) { + ConnectivityHTTPCancellation *token = malloc(sizeof(*token)); + if (token) atomic_init(&token->cancelled, 0); + return token; +} +void ConnectivityHTTPCancel(ConnectivityHTTPCancellation *token) { + if (token) atomic_store(&token->cancelled, 1); +} +void ConnectivityHTTPDestroyCancellation(ConnectivityHTTPCancellation *token) { free(token); } + +typedef struct { + CURL *curl; + uint64_t started; + ConnectivityHTTPResult result; +} Measurement; + +static size_t headers(char *bytes, size_t size, size_t count, void *opaque) { + Measurement *measurement = opaque; + size_t length = size * count; + if (length == 2 && bytes[0] == '\r' && bytes[1] == '\n') { + long status = 0; + curl_easy_getinfo(measurement->curl, CURLINFO_RESPONSE_CODE, &status); + if (status >= 200 || status == 101) { + uint64_t elapsed = ConnectivityHTTPNow() - measurement->started; + measurement->result.status = (int)status; + measurement->result.milliseconds = (int64_t)(elapsed / 1000000ULL); + measurement->result.received_headers = elapsed < 5000000000ULL; + measurement->result.timed_out = !measurement->result.received_headers; + // Aborting at the empty header line prevents all response-body delivery. + return 0; + } + } + return length; +} +static size_t discard_body(char *bytes, size_t size, size_t count, void *opaque) { + (void)bytes; (void)size; (void)count; (void)opaque; + return 0; +} + +ConnectivityHTTPResult ConnectivityHTTPMeasure(const char *url, int proxy_port, + uint64_t started, ConnectivityHTTPCancellation *token) { + Measurement measurement = {0}; + measurement.started = started; + pthread_once(&initialized, initialize_curl); + CURL *curl = curl_easy_init(); + CURLM *multi = curl_multi_init(); + if (!curl || !multi || !token) goto cleanup; + measurement.curl = curl; + char proxy[64]; + snprintf(proxy, sizeof(proxy), "http://127.0.0.1:%d", proxy_port); + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_PROXY, proxy); + // Explicitly override environment bypasses, including loopback destinations. + curl_easy_setopt(curl, CURLOPT_NOPROXY, ""); + curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "http,https"); + curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); + curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, 5000L); + curl_easy_setopt(curl, CURLOPT_SUPPRESS_CONNECT_HEADERS, 1L); + curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, headers); + curl_easy_setopt(curl, CURLOPT_HEADERDATA, &measurement); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, discard_body); + curl_easy_setopt(curl, CURLOPT_FRESH_CONNECT, 1L); + curl_easy_setopt(curl, CURLOPT_FORBID_REUSE, 1L); + if (curl_multi_add_handle(multi, curl) != CURLM_OK) goto cleanup; + int running = 1; + while (running && !atomic_load(&token->cancelled)) { + if (ConnectivityHTTPNow() - started >= 5000000000ULL) { + measurement.result.timed_out = 1; + break; + } + if (curl_multi_perform(multi, &running) != CURLM_OK) break; + if (!running) break; + int descriptors = 0; + if (curl_multi_poll(multi, NULL, 0, 50, &descriptors) != CURLM_OK) break; + } + int pending = 0; + CURLMsg *message; + while ((message = curl_multi_info_read(multi, &pending))) { + if (message->msg == CURLMSG_DONE && message->data.result == CURLE_OPERATION_TIMEDOUT) + measurement.result.timed_out = 1; + } + if (atomic_load(&token->cancelled)) measurement.result.received_headers = 0; + curl_multi_remove_handle(multi, curl); +cleanup: + if (multi) curl_multi_cleanup(multi); + if (curl) curl_easy_cleanup(curl); + return measurement.result; +} diff --git a/ShadowsocksX-NG2/App/Application/Connectivity/ConnectivityHTTPTransport.h b/ShadowsocksX-NG2/App/Application/Connectivity/ConnectivityHTTPTransport.h new file mode 100644 index 00000000..2d0ff96b --- /dev/null +++ b/ShadowsocksX-NG2/App/Application/Connectivity/ConnectivityHTTPTransport.h @@ -0,0 +1,18 @@ +#ifndef ConnectivityHTTPTransport_h +#define ConnectivityHTTPTransport_h +#include + +typedef struct ConnectivityHTTPCancellation ConnectivityHTTPCancellation; +typedef struct { + int status; + int64_t milliseconds; + int timed_out; + int received_headers; +} ConnectivityHTTPResult; +ConnectivityHTTPCancellation *ConnectivityHTTPCreateCancellation(void); +void ConnectivityHTTPCancel(ConnectivityHTTPCancellation *token); +void ConnectivityHTTPDestroyCancellation(ConnectivityHTTPCancellation *token); +uint64_t ConnectivityHTTPNow(void); +ConnectivityHTTPResult ConnectivityHTTPMeasure(const char *url, int proxy_port, + uint64_t started, ConnectivityHTTPCancellation *token); +#endif diff --git a/ShadowsocksX-NG2/App/Application/Connectivity/ConnectivityRuntime.swift b/ShadowsocksX-NG2/App/Application/Connectivity/ConnectivityRuntime.swift new file mode 100644 index 00000000..3e163a31 --- /dev/null +++ b/ShadowsocksX-NG2/App/Application/Connectivity/ConnectivityRuntime.swift @@ -0,0 +1,140 @@ +import Darwin +import Foundation + +/// An isolated process group owns sslocal and every SIP003 child. No formal +/// runtime file, ACL, activity selection, or system proxy is touched. +@MainActor +struct ConnectivityRuntime { + let executable: URL + var temporaryRoot: URL = FileManager.default.temporaryDirectory + + func measure( + _ server: SslocalServerDocument, url: URL, preparationDeadline deadline: ContinuousClock.Instant + ) async -> ServerConnectivity.Outcome { + let directory = temporaryRoot.appendingPathComponent(UUID().uuidString) + var pid: pid_t = 0 + var reaped = false + var result: ServerConnectivity.Outcome = .startup + do { + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + try Task.checkCancellation() + guard ContinuousClock.now < deadline else { + try? FileManager.default.removeItem(at: directory) + return .preparationTimeout + } + let port = try freePort() + let config = directory.appendingPathComponent("test.json") + // A single TCP HTTP inbound, no ACL: every request must use this server. + let data = try JSONSerialization.data(withJSONObject: [ + "servers": [try JSONSerialization.jsonObject(with: JSONEncoder().encode(server))], + "locals": [ + [ + "protocol": "http", "local_address": "127.0.0.1", + "local_port": port, "mode": "tcp_only", + ] + ], + ]) + try AtomicFileWriter.write(data, to: config) + pid = try spawn(config) + while ContinuousClock.now < deadline { + try Task.checkCancellation() + var status: Int32 = 0 + if waitpid(pid, &status, WNOHANG) == pid { + reaped = true + break + } + if RuntimeSocketOwnershipProbe.process(pid, ownsTCPListenerOn: port), + EndpointHealthProbe.probe(host: "127.0.0.1", port: port, timeout: 0.02) == .reachable + { + if ContinuousClock.now >= deadline { break } + result = await ConnectivityHTTPRequest().perform(url: url, proxyPort: port) + break + } + try await Task.sleep(for: .milliseconds(25)) + } + if ContinuousClock.now >= deadline, result == .startup { result = .preparationTimeout } + } catch is CancellationError { + result = .network + } catch { + result = .startup + } + await cleanup(pid: pid, reaped: reaped, directory: directory) + return result + } + + private func cleanup(pid child: pid_t, reaped alreadyReaped: Bool, directory: URL) async { + // Cleanup is not cancelled with the measurement. Slots remain occupied until + // the group has been terminated and its leader reaped. + await Task { + if child > 0 { + _ = kill(-child, SIGTERM) + let deadline = ContinuousClock.now.advanced(by: .seconds(2)) + var reaped = alreadyReaped + while ContinuousClock.now < deadline { + if !reaped { + var status: Int32 = 0 + reaped = waitpid(child, &status, WNOHANG) == child + } + if kill(-child, 0) != 0 { break } + try? await Task.sleep(for: .milliseconds(25)) + } + _ = kill(-child, SIGKILL) + if !reaped { + var status: Int32 = 0 + _ = waitpid(child, &status, 0) + } + } + try? FileManager.default.removeItem(at: directory) + }.value + } + + private func freePort() throws -> Int { + let descriptor = socket(AF_INET, SOCK_STREAM, 0) + guard descriptor >= 0 else { throw RuntimeError.failed } + defer { close(descriptor) } + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_addr.s_addr = inet_addr("127.0.0.1") + let bound = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(descriptor, $0, socklen_t(MemoryLayout.size)) + } + } + guard bound == 0 else { throw RuntimeError.failed } + var length = socklen_t(MemoryLayout.size) + let read = withUnsafeMutablePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(descriptor, $0, &length) } + } + guard read == 0 else { throw RuntimeError.failed } + return Int(UInt16(bigEndian: address.sin_port)) + } + + private func spawn(_ config: URL) throws -> pid_t { + var attributes: posix_spawnattr_t? + posix_spawnattr_init(&attributes) + defer { posix_spawnattr_destroy(&attributes) } + posix_spawnattr_setflags(&attributes, Int16(POSIX_SPAWN_SETPGROUP)) + posix_spawnattr_setpgroup(&attributes, 0) + var actions: posix_spawn_file_actions_t? + posix_spawn_file_actions_init(&actions) + defer { posix_spawn_file_actions_destroy(&actions) } + // Child output can contain plugin options and credentials; discard it. + for descriptor in [STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO] { + posix_spawn_file_actions_addopen(&actions, descriptor, "/dev/null", O_RDWR, 0) + } + let strings = [executable.path, "-c", config.path].map { strdup($0) } + defer { strings.forEach { free($0) } } + var argv = strings + [nil] + var pid: pid_t = 0 + let status = argv.withUnsafeMutableBufferPointer { + posix_spawn(&pid, executable.path, &actions, &attributes, $0.baseAddress!, environ) + } + guard status == 0 else { throw RuntimeError.failed } + return pid + } + + private enum RuntimeError: Error { case failed } +} diff --git a/ShadowsocksX-NG2/App/Application/Connectivity/ServerConnectivity.swift b/ShadowsocksX-NG2/App/Application/Connectivity/ServerConnectivity.swift new file mode 100644 index 00000000..8378ff5a --- /dev/null +++ b/ShadowsocksX-NG2/App/Application/Connectivity/ServerConnectivity.swift @@ -0,0 +1,235 @@ +import Combine +import Foundation + +/// Process-lifetime observations. Secrets stay inside the configuration adapter. +@MainActor +final class ServerConnectivity: ObservableObject { + enum Outcome: Equatable, Sendable { + case success(milliseconds: Int) + case unsupported, preparationTimeout, requestTimeout, configuration, startup, network + case http(Int) + } + enum ConfigurationFailure: Error { case unsupported } + struct Record: Equatable, Identifiable { + let id = UUID() + let finishedAt: Date + let outcome: Outcome + var networkExpired = false + func isExpired(at date: Date) -> Bool { + networkExpired || date.timeIntervalSince(finishedAt) >= 300 + } + } + struct Batch { + let targets: [NodeID] + let cooling: [NodeID] + var completedIDs: Set = [] + var completed: Int { completedIDs.count } + } + struct Summary { + let serverID: NodeID? + let record: Record? + let hasResults: Bool + let isTesting: Bool + } + static let defaultURL = URL(string: "http://www.gstatic.com/generate_204")! + @Published private(set) var testing: Set = [] + @Published private var records: [NodeID: [Record]] = [:] + @Published private var batches: [UUID: Batch] = [:] + private var versions: [NodeID: String] = [:] + private var cooldown: [NodeID: Date] = [:] + private var jobs: [NodeID: Task] = [:] + private var queue: [(NodeID, UUID)] = [] + private var tokens: [NodeID: UUID] = [:] + private var running = 0 + private var generation = 0 + private var stopped = false + private var targetURL: URL? = defaultURL + private let revision: (NodeID) -> String + private let configuration: (NodeID) throws -> SslocalServerDocument + private let now: () -> Date + private let measure: (SslocalServerDocument, URL, ContinuousClock.Instant) async -> Outcome + + init( + configuration: @escaping (NodeID) throws -> SslocalServerDocument, + revision: ((NodeID) -> String)? = nil, + now: @escaping () -> Date = Date.init, + measure: @escaping (SslocalServerDocument, URL, ContinuousClock.Instant) async -> Outcome + ) { + self.configuration = configuration + self.revision = + revision ?? { id in + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + guard let server = try? configuration(id), let data = try? encoder.encode(server) else { + return "invalid" + } + return ProxyACLDocument.digest(data) + } + self.now = now + self.measure = measure + } + + static func validURL(_ raw: String) -> URL? { + guard let url = URL(string: raw.trimmingCharacters(in: .whitespacesAndNewlines)), + ["http", "https"].contains(url.scheme?.lowercased() ?? ""), + let host = url.host, !host.isEmpty, url.fragment == nil + else { return nil } + return url + } + + func history(for id: NodeID) -> [Record] { records[id] ?? [] } + func batch(_ id: UUID) -> Batch? { batches[id] } + func isCooling(_ id: NodeID) -> Bool { cooldown[id].map { now() < $0 } ?? false } + + /// Captures target identities, reuses queued work, and skips cooling identities. + @discardableResult + func test(_ ids: [NodeID]) -> UUID { + configurationsChanged() + let batchID = UUID() + var seen = Set() + let ids = ids.filter { seen.insert($0).inserted } + let skipped = ids.filter { !testing.contains($0) && isCooling($0) } + let targets = stopped ? [] : ids.filter { !skipped.contains($0) } + batches[batchID] = Batch(targets: targets, cooling: skipped) + // Bound transient batch metadata; active batches are retained. + if batches.count > 100 { + for (key, value) in batches where value.completed == value.targets.count && key != batchID { + batches.removeValue(forKey: key) + } + } + for id in targets where !testing.contains(id) { + let token = UUID() + versions[id] = revision(id) + tokens[id] = token + testing.insert(id) + queue.append((id, token)) + } + pump() + return batchID + } + + func wait(for batchID: UUID) async { + while let batch = batches[batchID], batch.completed < batch.targets.count { + await Task.yield() + } + } + + func summary(for ids: [NodeID], at date: Date) -> Summary { + let values = ids.compactMap { id in records[id]?.first.map { (id, $0) } } + let successes = values.filter { + if case .success = $0.1.outcome { return true } + return false + } + let fresh = successes.filter { !$0.1.isExpired(at: date) } + let candidates = fresh.isEmpty ? successes : fresh + let fastest = candidates.min { lhs, rhs in + guard case .success(let leftMilliseconds) = lhs.1.outcome, + case .success(let rightMilliseconds) = rhs.1.outcome + else { + return false + } + return leftMilliseconds < rightMilliseconds + } + return Summary( + serverID: fastest?.0, record: fastest?.1, + hasResults: !values.isEmpty, isTesting: ids.contains { testing.contains($0) }) + } + + /// Re-read values, including secrets with unchanged references and plugin mappings. + func configurationsChanged() { + for (id, previous) in versions where revision(id) != previous { invalidate(id) } + } + + func setURL(_ url: URL?) { + guard url != targetURL else { return } + targetURL = url + generation += 1 + records = [:] + for id in testing { invalidate(id) } + } + + func networkChanged() { + generation += 1 + for id in records.keys { + records[id] = records[id]?.map { record in + var copy = record + copy.networkExpired = true + return copy + } + } + for id in testing { jobs[id]?.cancel() } + } + + func shutdown() async { + stopped = true + generation += 1 + for id in testing { invalidate(id) } + let active = Array(jobs.values) + for job in active { await job.value } + } + + private func invalidate(_ id: NodeID) { + records.removeValue(forKey: id) + versions.removeValue(forKey: id) + jobs[id]?.cancel() + // Queued identities are completed internally and enter cooldown too. + if jobs[id] == nil, testing.contains(id) { finish(id) } + } + + private func pump() { + while running < 3, !queue.isEmpty, !stopped { + let (id, token) = queue.removeFirst() + guard tokens[id] == token else { continue } + let preparationDeadline = ContinuousClock.now.advanced(by: .seconds(10)) + guard versions[id] == revision(id) else { + records.removeValue(forKey: id) + finish(id) + continue + } + running += 1 + let epoch = generation + let url = targetURL + let version = versions[id] + let server: SslocalServerDocument? + let failure: Outcome + do { + server = try configuration(id) + failure = .configuration + } catch ConfigurationFailure.unsupported { + server = nil + failure = .unsupported + } catch { + server = nil + failure = .configuration + } + jobs[id] = Task { [self] in + let result: Outcome + if let server, let url { + result = + ContinuousClock.now >= preparationDeadline + ? .preparationTimeout : await measure(server, url, preparationDeadline) + } else { + result = url == nil ? .configuration : failure + } + let current = revision(id) + if !Task.isCancelled, epoch == generation, tokens[id] == token, current == version { + let record = Record(finishedAt: now(), outcome: result) + records[id] = Array(([record] + (records[id] ?? [])).prefix(10)) + } + running -= 1 + jobs.removeValue(forKey: id) + finish(id) + pump() + } + } + } + + private func finish(_ id: NodeID) { + tokens.removeValue(forKey: id) + testing.remove(id) + cooldown[id] = now().addingTimeInterval(10) + for key in batches.keys where batches[key]?.targets.contains(id) == true { + batches[key]?.completedIDs.insert(id) + } + } +} diff --git a/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController+SettingsSync.swift b/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController+SettingsSync.swift index a099b4bc..dd68476d 100644 --- a/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController+SettingsSync.swift +++ b/ShadowsocksX-NG2/App/Application/ProxyRuntime/ProxyRuntimeController+SettingsSync.swift @@ -72,11 +72,16 @@ extension ProxyRuntimeController { /// status remains observable through the controller's status stream. func updateSettings(_ proposed: ProxySettings) async throws { let catalog = catalogSnapshotReader.catalogSnapshot + var connectionIndependent = settings + connectionIndependent.connectivityURLReference = proposed.connectivityURLReference + let onlyTestURLChanged = + settings.connectivityURLReference != proposed.connectivityURLReference + && connectionIndependent == proposed try settingsStore.save(proposed) settings = proposed listenSettingsUnreadable = false settingsUnreadable = false - guard state != .off else { return } + guard state != .off, !onlyTestURLChanged else { return } await convergeToReexpanded(catalog) } diff --git a/ShadowsocksX-NG2/App/Application/SettingsWorkflow/SettingsWorkflow+Connectivity.swift b/ShadowsocksX-NG2/App/Application/SettingsWorkflow/SettingsWorkflow+Connectivity.swift new file mode 100644 index 00000000..13d405a6 --- /dev/null +++ b/ShadowsocksX-NG2/App/Application/SettingsWorkflow/SettingsWorkflow+Connectivity.swift @@ -0,0 +1,39 @@ +import Foundation + +extension SettingsWorkflow { + func beginConnectivityURLEditing() throws -> String { + guard let reference = committing.committedSettings.connectivityURLReference else { + return ServerConnectivity.defaultURL.absoluteString + } + guard let secret = try credentials.secret(for: reference), + ServerConnectivity.validURL(secret) != nil + else { + throw ServerFormLoadError.credentialsUnavailable + } + return secret + } + + /// Each custom URL gets a fresh secret; failed preference saves roll it back. + func saveConnectivityURL(_ raw: String) async -> Bool { + guard let url = ServerConnectivity.validURL(raw), startCommit() else { return false } + defer { finishCommit() } + let previous = committing.committedSettings.connectivityURLReference + let reference: CredentialReference? = url == ServerConnectivity.defaultURL ? nil : .fresh() + do { + if let reference { try credentials.save(url.absoluteString, for: reference) } + var proposed = committing.committedSettings + proposed.connectivityURLReference = reference + let outcome = await persistSettings(proposed) + guard outcome == .persisted else { + if let reference { try? credentials.delete(reference) } + return false + } + connectivityURLDidChange?(url) + if let previous { try? credentials.delete(previous) } + return true + } catch { + if let reference { try? credentials.delete(reference) } + return false + } + } +} diff --git a/ShadowsocksX-NG2/App/Application/SettingsWorkflow/SettingsWorkflow.swift b/ShadowsocksX-NG2/App/Application/SettingsWorkflow/SettingsWorkflow.swift index a4f21b29..892aab18 100644 --- a/ShadowsocksX-NG2/App/Application/SettingsWorkflow/SettingsWorkflow.swift +++ b/ShadowsocksX-NG2/App/Application/SettingsWorkflow/SettingsWorkflow.swift @@ -20,6 +20,8 @@ final class SettingsWorkflow: ObservableObject { /// Typed persistence failure for the editor to present. @Published private(set) var lastFailure: SettingsWorkflowFailure? + var connectivityURLDidChange: ((URL) -> Void)? + let committing: SettingsCommitting let credentials: CredentialStoring let occupancyProbe: PortOccupancyProbing diff --git a/ShadowsocksX-NG2/App/Composition/ConnectivityLifecycle.swift b/ShadowsocksX-NG2/App/Composition/ConnectivityLifecycle.swift new file mode 100644 index 00000000..1a1566bf --- /dev/null +++ b/ShadowsocksX-NG2/App/Composition/ConnectivityLifecycle.swift @@ -0,0 +1,43 @@ +import AppKit +import Network + +/// Held by the composition, independently of workspace windows. +@MainActor +final class ConnectivityLifecycle: NSObject, NSApplicationDelegate { + let module: ServerConnectivity + private let monitor = NWPathMonitor() + private var receivedInitialPath = false + private var terminating = false + + init(module: ServerConnectivity) { + self.module = module + super.init() + monitor.pathUpdateHandler = { [weak self] _ in + Task { @MainActor in + guard let self else { return } + if self.receivedInitialPath { self.module.networkChanged() } + self.receivedInitialPath = true + } + } + monitor.start(queue: DispatchQueue(label: "connectivity.network")) + } + + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + guard !terminating else { return .terminateLater } + terminating = true + monitor.cancel() + Task { + await module.shutdown() + sender.reply(toApplicationShouldTerminate: true) + } + return .terminateLater + } +} + +@MainActor +final class ConnectivityApplicationDelegate: NSObject, NSApplicationDelegate { + var lifecycle: ConnectivityLifecycle? + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + lifecycle?.applicationShouldTerminate(sender) ?? .terminateNow + } +} diff --git a/ShadowsocksX-NG2/App/Composition/MainApp.swift b/ShadowsocksX-NG2/App/Composition/MainApp.swift index ec68c122..9792a3fc 100644 --- a/ShadowsocksX-NG2/App/Composition/MainApp.swift +++ b/ShadowsocksX-NG2/App/Composition/MainApp.swift @@ -5,6 +5,8 @@ import SwiftUI struct ShadowsocksXNG2App: App { /// 进程期强持有(组合根装配一次)。body 不读它:事实发布经各 workflow 的 /// didChange 通道,不借 @StateObject 订阅控制器变化。 + @NSApplicationDelegateAdaptor(ConnectivityApplicationDelegate.self) private var appDelegate + private let connectivityLifecycle: ConnectivityLifecycle private let proxyController: ProxyRuntimeController @StateObject private var catalogWorkflow: CatalogWorkflow @StateObject private var proxyControl: ProxyControlWorkflow @@ -28,6 +30,7 @@ struct ShadowsocksXNG2App: App { init() { let composition = AppComposition.make() + connectivityLifecycle = composition.connectivityLifecycle proxyController = composition.controller _catalogWorkflow = StateObject(wrappedValue: composition.catalogWorkflow) _proxyControl = StateObject(wrappedValue: composition.proxyControl) @@ -46,6 +49,7 @@ struct ShadowsocksXNG2App: App { // GUI 事件接入内存环形缓冲:独立诊断窗口日志查看器与 // 诊断导出的来源;wrapper 侧不注册,仍走 stderr → agent.log 收敛。 RuntimeLog.setSink(RuntimeEventStore.shared) + appDelegate.lifecycle = connectivityLifecycle } var body: some Scene { @@ -141,6 +145,7 @@ private struct StatusMenuLabel: View { /// StateObject 状态与 onAppear 副作用在窗口首次打开时才发生。 @MainActor private struct AppComposition { + let connectivityLifecycle: ConnectivityLifecycle let controller: ProxyRuntimeController let catalogWorkflow: CatalogWorkflow let proxyControl: ProxyControlWorkflow @@ -192,6 +197,22 @@ private struct AppComposition { // 写入侧经窄缝 SettingsCommitting,运行时控制器薄扩展即生产实现。 let settingsWorkflow = SettingsWorkflow( committing: controller, credentials: controller.credentials) + let testRuntime = ConnectivityRuntime( + executable: Bundle.main.bundleURL.appendingPathComponent("Contents/Helpers/sslocal")) + let connectivity = ServerConnectivity( + configuration: { [weak catalogWorkflow] id in + guard let catalogWorkflow else { throw ServerFormLoadError.notFound } + return try catalogWorkflow.connectivityConfiguration(for: id) + }, + revision: { [weak catalogWorkflow] in + catalogWorkflow?.connectivityRevision(for: $0) ?? "missing" + }, + measure: testRuntime.measure) + catalogWorkflow.connectivity = connectivity + connectivity.setURL( + (try? settingsWorkflow.beginConnectivityURLEditing()).flatMap(ServerConnectivity.validURL)) + settingsWorkflow.connectivityURLDidChange = { [weak connectivity] in connectivity?.setURL($0) } + let connectivityLifecycle = ConnectivityLifecycle(module: connectivity) // 诊断工作流 module:诊断区唯一 seam,组合根接线一次;进程期 // 实例供独立诊断窗口使用。 let diagnosticsWorkflow = DiagnosticsWorkflow( @@ -226,6 +247,7 @@ private struct AppComposition { configurationGroupFileExporter: dependencies.configurationGroupFileExporter, qrImageSaver: dependencies.qrImageSaver) return AppComposition( + connectivityLifecycle: connectivityLifecycle, controller: controller, catalogWorkflow: catalogWorkflow, proxyControl: proxyControl, diff --git a/ShadowsocksX-NG2/App/Resources/Connectivity.xcstrings b/ShadowsocksX-NG2/App/Resources/Connectivity.xcstrings new file mode 100644 index 00000000..2c3f315b --- /dev/null +++ b/ShadowsocksX-NG2/App/Resources/Connectivity.xcstrings @@ -0,0 +1,358 @@ +{ + "sourceLanguage": "zh-Hans", + "strings": { + "响应延迟": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Response latency" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "响应延迟" + } + } + } + }, + "测试中": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Testing" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "测试中" + } + } + } + }, + "已过期": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Expired" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已过期" + } + } + } + }, + "无成功结果": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No successful result" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无成功结果" + } + } + } + }, + "测试全部": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Test All" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "测试全部" + } + } + } + }, + "已跳过冷却中的服务器": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Skipped servers in cooldown" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已跳过冷却中的服务器" + } + } + } + }, + "测试连通性": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Test Connectivity" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "测试连通性" + } + } + } + }, + "配置不支持": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unsupported configuration" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "配置不支持" + } + } + } + }, + "准备超时": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preparation timed out" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "准备超时" + } + } + } + }, + "请求超时": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Request timed out" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请求超时" + } + } + } + }, + "配置错误": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Configuration error" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "配置错误" + } + } + } + }, + "启动错误": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Startup error" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启动错误" + } + } + } + }, + "网络错误": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Network error" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "网络错误" + } + } + } + }, + "最快响应延迟": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Fastest response latency" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "最快响应延迟" + } + } + } + }, + "连通性测试": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connectivity Test" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "连通性测试" + } + } + } + }, + "测试 URL…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Test URL…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "测试 URL…" + } + } + } + }, + "测试 URL": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Test URL" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "测试 URL" + } + } + } + }, + "无法读取或保存测试 URL": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unable to read or save the test URL" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法读取或保存测试 URL" + } + } + } + }, + "经指定服务器发送 GET 请求,2xx 为成功;不跟随重定向。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Send a GET through the selected server. A 2xx response succeeds. Redirects are not followed." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "经指定服务器发送 GET 请求,2xx 为成功;不跟随重定向。" + } + } + } + }, + "恢复默认": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restore Default" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "恢复默认" + } + } + } + }, + "取消": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + } + } + }, + "保存": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "保存" + } + } + } + } + }, + "version": "1.0" +} diff --git a/ShadowsocksX-NG2/App/UI/Servers/CatalogNodeDetailView.swift b/ShadowsocksX-NG2/App/UI/Servers/CatalogNodeDetailView.swift index 70f2574e..2f5cc20a 100644 --- a/ShadowsocksX-NG2/App/UI/Servers/CatalogNodeDetailView.swift +++ b/ShadowsocksX-NG2/App/UI/Servers/CatalogNodeDetailView.swift @@ -45,6 +45,10 @@ struct CatalogNodeDetailView: View { parameters.reload(load: workflow.serverDetailPluginOptions) } } + } supplement: { + if let module = workflow.connectivity { + ConnectivityDetailView(module: module, node: node, workflow: workflow) + } } .onAppear { if !node.isGroup { diff --git a/ShadowsocksX-NG2/App/UI/Servers/ConnectivityViews.swift b/ShadowsocksX-NG2/App/UI/Servers/ConnectivityViews.swift new file mode 100644 index 00000000..80cd0341 --- /dev/null +++ b/ShadowsocksX-NG2/App/UI/Servers/ConnectivityViews.swift @@ -0,0 +1,148 @@ +import SwiftUI + +/// Observes only the network observations, with a periodic expiry projection. +struct ConnectivityCell: View { + @ObservedObject var module: ServerConnectivity + let node: CatalogTreeNode + let workflow: CatalogWorkflow + + var body: some View { + TimelineView(.periodic(from: .now, by: 1)) { context in + let value = presentation(at: context.date) + HStack(spacing: 5) { + Text(value.text).foregroundStyle(value.expired ? .secondary : .primary) + if value.testing { ProgressView().controlSize(.mini) } + } + .help(value.help) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(ConnectivityCopy.text("响应延迟"))) + .accessibilityValue( + Text( + value.text + " " + value.help + (value.testing ? " " + ConnectivityCopy.text("测试中") : "")) + ) + } + } + + private struct Presentation { + let text: String + let help: String + let expired: Bool + let testing: Bool + } + + private func presentation(at date: Date) -> Presentation { + let record: ServerConnectivity.Record? + let text: String + var prefix = "" + let summary = module.summary(for: node.connectivityServerIDs, at: date) + if node.isGroup { + record = summary.record + text = record.map { ConnectivityCopy.outcome($0.outcome) } ?? (summary.hasResults ? "—" : "") + if let id = summary.serverID { prefix = workflow.displayName(for: id) + " · " } + } else { + record = module.history(for: node.id).first + text = record.map { ConnectivityCopy.outcome($0.outcome) } ?? "" + } + let expired = record?.isExpired(at: date) ?? false + let help = + record.map { + prefix + $0.finishedAt.formatted(date: .numeric, time: .standard) + + (expired ? " · " + ConnectivityCopy.text("已过期") : "") + } ?? (summary.hasResults && node.isGroup ? ConnectivityCopy.text("无成功结果") : "") + return Presentation(text: text, help: help, expired: expired, testing: summary.isTesting) + } +} + +struct ConnectivityBatchButton: View { + @ObservedObject var module: ServerConnectivity + let targets: [NodeID] + @State private var batchID: UUID? + + var body: some View { + HStack(spacing: 6) { + Button { + batchID = module.test(targets) + } label: { + Label(ConnectivityCopy.text("测试全部"), systemImage: "waveform.path.ecg") + } + .disabled(targets.isEmpty) + if let batchID, let batch = module.batch(batchID) { + if batch.completed < batch.targets.count { ProgressView().controlSize(.mini) } + Text("\(batch.completed)/\(batch.targets.count)") + .monospacedDigit() + if !batch.cooling.isEmpty { + Text(ConnectivityCopy.text("已跳过冷却中的服务器") + " (\(batch.cooling.count))") + .foregroundStyle(.secondary) + } + } + } + } +} + +struct ConnectivityTestButton: View { + @ObservedObject var module: ServerConnectivity + let node: CatalogTreeNode + var body: some View { + Button(ConnectivityCopy.text("测试连通性")) { module.test(node.connectivityServerIDs) } + .disabled(!node.containsServerConfiguration || (!node.isGroup && module.isCooling(node.id))) + } +} + +/// Copy is kept at the presentation edge; failures never render raw errors or URLs. +enum ConnectivityCopy { + static func text(_ key: String) -> String { + NSLocalizedString(key, tableName: "Connectivity", bundle: .main, comment: "") + } + static func outcome(_ value: ServerConnectivity.Outcome) -> String { + switch value { + case .success(let milliseconds): "\(milliseconds) ms" + case .unsupported: text("配置不支持") + case .preparationTimeout: text("准备超时") + case .requestTimeout: text("请求超时") + case .configuration: text("配置错误") + case .startup: text("启动错误") + case .network: text("网络错误") + case .http(let status): "HTTP \(status)" + } + } +} + +struct ConnectivityDetailView: View { + @ObservedObject var module: ServerConnectivity + let node: CatalogTreeNode + let workflow: CatalogWorkflow + + var body: some View { + TimelineView(.periodic(from: .now, by: 1)) { context in + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(ConnectivityCopy.text(node.isGroup ? "最快响应延迟" : "连通性测试")).font(.headline) + ConnectivityTestButton(module: module, node: node) + } + if node.isGroup { + ConnectivityCell(module: module, node: node, workflow: workflow) + let summary = module.summary(for: node.connectivityServerIDs, at: context.date) + if let id = summary.serverID, let record = summary.record { + Text(workflow.displayName(for: id)) + Text(timestamp(record, at: context.date)).foregroundStyle(.secondary) + } else if summary.hasResults { + Text(ConnectivityCopy.text("无成功结果")).foregroundStyle(.secondary) + } + } else { + ForEach(module.history(for: node.id)) { record in + HStack { + Text(timestamp(record, at: context.date)).foregroundStyle(.secondary) + Spacer() + Text(ConnectivityCopy.outcome(record.outcome)) + } + } + } + } + } + } + + private func timestamp(_ record: ServerConnectivity.Record, at date: Date) -> String { + record.finishedAt.formatted(date: .numeric, time: .standard) + + (record.isExpired(at: date) ? " · " + ConnectivityCopy.text("已过期") : "") + } +} diff --git a/ShadowsocksX-NG2/App/UI/Servers/FavoriteServersTable.swift b/ShadowsocksX-NG2/App/UI/Servers/FavoriteServersTable.swift index 5de998cd..f10d9138 100644 --- a/ShadowsocksX-NG2/App/UI/Servers/FavoriteServersTable.swift +++ b/ShadowsocksX-NG2/App/UI/Servers/FavoriteServersTable.swift @@ -37,6 +37,12 @@ struct FavoriteServersTable: View { ServerTableNameCell(node: node, activeTargetID: activeTargetID) } .width(min: 140, ideal: 200) + TableColumn(ConnectivityCopy.text("响应延迟")) { node in + if let module = workflow.connectivity { + ConnectivityCell(module: module, node: node, workflow: workflow) + } + } + .width(min: 90, ideal: 110) TableColumn("路径") { node in let path = path(for: node) Text(path) diff --git a/ShadowsocksX-NG2/App/UI/Servers/NodeDetailView.swift b/ShadowsocksX-NG2/App/UI/Servers/NodeDetailView.swift index c97550be..f9d23c65 100644 --- a/ShadowsocksX-NG2/App/UI/Servers/NodeDetailView.swift +++ b/ShadowsocksX-NG2/App/UI/Servers/NodeDetailView.swift @@ -1,9 +1,10 @@ import SwiftUI /// 只接受展示值与外部动作;不查询节点类型、工作流或凭据。 -struct NodeDetailView: View { +struct NodeDetailView: View { let detail: NodeDetailPresentation @ViewBuilder let actions: Actions + @ViewBuilder let supplement: Supplement var body: some View { ScrollView { @@ -40,6 +41,7 @@ struct NodeDetailView: View { } } } + supplement } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 16) diff --git a/ShadowsocksX-NG2/App/UI/Servers/ServerTableNameCell.swift b/ShadowsocksX-NG2/App/UI/Servers/ServerTableNameCell.swift index c930c899..3ef13d57 100644 --- a/ShadowsocksX-NG2/App/UI/Servers/ServerTableNameCell.swift +++ b/ShadowsocksX-NG2/App/UI/Servers/ServerTableNameCell.swift @@ -53,6 +53,9 @@ struct ServerNodeContextMenu: View { @ViewBuilder var body: some View { + if let module = workflow.connectivity { + ConnectivityTestButton(module: module, node: node) + } Button("激活") { Task { @MainActor in do { diff --git a/ShadowsocksX-NG2/App/UI/Servers/ServersView.swift b/ShadowsocksX-NG2/App/UI/Servers/ServersView.swift index 95c9d41f..f639ac5a 100644 --- a/ShadowsocksX-NG2/App/UI/Servers/ServersView.swift +++ b/ShadowsocksX-NG2/App/UI/Servers/ServersView.swift @@ -154,6 +154,12 @@ struct ServersView: View { .padding(.leading, CGFloat(depths[node.id] ?? 0) * 14) } .width(min: 160, ideal: 240) + TableColumn(ConnectivityCopy.text("响应延迟")) { node in + if let module = workflow.connectivity { + ConnectivityCell(module: module, node: node, workflow: workflow) + } + } + .width(min: 90, ideal: 110) TableColumn("收藏") { node in Image(systemName: workflow.isFavorite(node.id) ? "star.fill" : "star") .accessibilityLabel(Text("收藏")) @@ -302,6 +308,12 @@ extension ServersView { @ToolbarContentBuilder private var toolbarContent: some ToolbarContent { + if let module = workflow.connectivity { + ToolbarItem(placement: .primaryAction) { + ConnectivityBatchButton( + module: module, targets: workflow.connectivityTargets(source: section.source)) + } + } if section != .favorites { ToolbarItem(placement: .primaryAction) { Menu { diff --git a/ShadowsocksX-NG2/App/UI/Settings/ConnectivityURLSettings.swift b/ShadowsocksX-NG2/App/UI/Settings/ConnectivityURLSettings.swift new file mode 100644 index 00000000..dbc38b6a --- /dev/null +++ b/ShadowsocksX-NG2/App/UI/Settings/ConnectivityURLSettings.swift @@ -0,0 +1,53 @@ +import SwiftUI + +struct ConnectivityURLSettings: View { + @ObservedObject var workflow: SettingsWorkflow + @State private var editing = false + @State private var value = "" + @State private var failure = false + + var body: some View { + Section { + Button(ConnectivityCopy.text("测试 URL…")) { + do { + value = try workflow.beginConnectivityURLEditing() + failure = false + editing = true + } catch { failure = true } + } + if failure { Text(ConnectivityCopy.text("无法读取或保存测试 URL")).foregroundStyle(.red) } + } header: { + Text(ConnectivityCopy.text("连通性测试")) + } + .sheet(isPresented: $editing) { + VStack(alignment: .leading, spacing: 16) { + Text(ConnectivityCopy.text("测试 URL")).font(.headline) + TextField("HTTP / HTTPS", text: $value).textFieldStyle(.roundedBorder) + Text(ConnectivityCopy.text("经指定服务器发送 GET 请求,2xx 为成功;不跟随重定向。")) + .font(.caption).foregroundStyle(.secondary) + if failure { Text(ConnectivityCopy.text("无法读取或保存测试 URL")).foregroundStyle(.red) } + HStack { + Button(ConnectivityCopy.text("恢复默认")) { + value = ServerConnectivity.defaultURL.absoluteString + } + Spacer() + Button(ConnectivityCopy.text("取消")) { editing = false }.keyboardShortcut(.cancelAction) + Button(ConnectivityCopy.text("保存")) { + Task { + if await workflow.saveConnectivityURL(value) { + editing = false + failure = false + } else { + failure = true + } + } + } + .keyboardShortcut(.defaultAction) + .disabled(ServerConnectivity.validURL(value) == nil || workflow.isCommitting) + } + } + .padding(24).frame(width: 520) + .interactiveDismissDisabled(workflow.isCommitting) + } + } +} diff --git a/ShadowsocksX-NG2/App/UI/Settings/SettingsView.swift b/ShadowsocksX-NG2/App/UI/Settings/SettingsView.swift index 3c7e2e64..6bbcb083 100644 --- a/ShadowsocksX-NG2/App/UI/Settings/SettingsView.swift +++ b/ShadowsocksX-NG2/App/UI/Settings/SettingsView.swift @@ -15,6 +15,7 @@ struct SettingsView: View { Form { generalSection endpointSection + ConnectivityURLSettings(workflow: workflow) systemProxySection PluginManagementSection(model: plugins) } diff --git a/ShadowsocksX-NG2/Domain/Settings/ProxySettings.swift b/ShadowsocksX-NG2/Domain/Settings/ProxySettings.swift index 1a534fcf..1a8341f1 100644 --- a/ShadowsocksX-NG2/Domain/Settings/ProxySettings.swift +++ b/ShadowsocksX-NG2/Domain/Settings/ProxySettings.swift @@ -5,6 +5,7 @@ import Foundation struct ProxySettings: Equatable, Sendable { static let defaultProxyExceptions = "" + var connectivityURLReference: CredentialReference? var listen: SslocalListenSettings /// User-added system-proxy exceptions; these entries do not become ACL rules. var proxyExceptions: String @@ -22,12 +23,14 @@ struct ProxySettings: Equatable, Sendable { init( listen: SslocalListenSettings = SslocalListenSettings(), + connectivityURLReference: CredentialReference? = nil, proxyExceptions: String = ProxySettings.defaultProxyExceptions, preferredMode: ProxyModeKind = .rule, ruleDefaultAction: RuleDefaultAction = .proxyWhenUnmatched, agentEnabled: Bool = false, systemProxyEnabled: Bool = false ) { + self.connectivityURLReference = connectivityURLReference self.listen = listen self.proxyExceptions = proxyExceptions self.preferredMode = preferredMode diff --git a/ShadowsocksX-NG2/Domain/Settings/ProxySettingsFileStore.swift b/ShadowsocksX-NG2/Domain/Settings/ProxySettingsFileStore.swift index 9a8a208d..c4ffe857 100644 --- a/ShadowsocksX-NG2/Domain/Settings/ProxySettingsFileStore.swift +++ b/ShadowsocksX-NG2/Domain/Settings/ProxySettingsFileStore.swift @@ -85,6 +85,7 @@ struct ProxySettingsFileStore: ProxySettingsStoring { return try validated( ProxySettings( listen: listen, + connectivityURLReference: record.connectivityURLReference, proxyExceptions: record.proxyExceptions, preferredMode: record.preferredMode, ruleDefaultAction: record.ruleDefaultAction, @@ -94,6 +95,7 @@ struct ProxySettingsFileStore: ProxySettingsStoring { private func record(from settings: ProxySettings) -> ProxySettingsRecord { var record = ProxySettingsRecord() + record.connectivityURLReference = settings.connectivityURLReference record.localListen = settings.listen.local record.lanListen = settings.listen.lan record.proxyExceptions = settings.proxyExceptions @@ -112,6 +114,7 @@ struct ProxySettingsFileStore: ProxySettingsStoring { } private struct ProxySettingsRecord: Codable, Equatable, Sendable { + var connectivityURLReference: CredentialReference? var localListen = LocalListeningSettings() var lanListen = LANListeningSettings() var proxyExceptions: String = ProxySettings.defaultProxyExceptions @@ -124,6 +127,8 @@ private struct ProxySettingsRecord: Codable, Equatable, Sendable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) + connectivityURLReference = try container.decodeIfPresent( + CredentialReference.self, forKey: .connectivityURLReference) // 未正式发版:忽略旧的单组监听字段,不迁移独立旧监听文件。 localListen = try container.decodeIfPresent(LocalListeningSettings.self, forKey: .localListen) @@ -156,6 +161,7 @@ private struct ProxySettingsRecord: Codable, Equatable, Sendable { extension ProxySettingsRecord { fileprivate enum CodingKeys: String, CodingKey { + case connectivityURLReference case localListen, lanListen case proxyExceptions case preferredMode diff --git a/ShadowsocksX-NG2/Tests/Connectivity/ConnectivityCatalogTests.swift b/ShadowsocksX-NG2/Tests/Connectivity/ConnectivityCatalogTests.swift new file mode 100644 index 00000000..29b88c0a --- /dev/null +++ b/ShadowsocksX-NG2/Tests/Connectivity/ConnectivityCatalogTests.swift @@ -0,0 +1,49 @@ +import Foundation +import Testing + +@testable import ShadowsocksX_NG2 + +@MainActor +struct ConnectivityCatalogTests { + @Test func targetScopesExpandFavoriteGroupsWithoutChangingCatalogOrActivation() async throws { + let subscription = try CatalogFixtures.makeSubscriptionFixture(prefix: UUID().uuidString) + var catalog = subscription.catalog + let credentials = InMemoryCredentialStore() + let group = try catalog.addGroup("Local") + let nested = try catalog.addGroup("Nested", to: group) + let firstTarget = try ActivationFixture.addPlainServer( + "A", to: group, in: &catalog, credentials: credentials) + let secondTarget = try ActivationFixture.addPlainServer( + "B", to: nested, in: &catalog, credentials: credentials) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = CatalogFileStore(fileURL: directory.appendingPathComponent("catalog.json")) + try store.save(CatalogDocument(catalog: catalog)) + let runtime = FakeCatalogRuntime() + let coordinator = CatalogCommitCoordinator(fileStore: store, runtime: runtime) + let workflow = makeCatalogWorkflow(coordinator: coordinator, credentials: credentials) + try workflow.setFavorite(group, isFavorite: true) + try workflow.setFavorite(secondTarget, isFavorite: true) + #expect(workflow.connectivityTargets(source: .manual) == [secondTarget, firstTarget]) + #expect( + workflow.connectivityTargets(source: .subscription) + == Array(subscription.serverIDs.reversed())) + #expect(workflow.connectivityTargets(source: nil) == [secondTarget, firstTarget]) + #expect( + workflow.tree.node(withID: group)?.connectivityServerIDs == [secondTarget, firstTarget]) + let before = try Data(contentsOf: store.fileURL) + let tree = workflow.tree + let version = workflow.connectivityRevision(for: firstTarget) + let derived = try workflow.connectivityConfiguration(for: firstTarget) + #expect(derived.id == firstTarget.rawValue) + #expect(derived.password == "pw-A") + #expect(runtime.convergeCount == 0) + #expect(workflow.tree == tree) + #expect(try Data(contentsOf: store.fileURL) == before) + let fields = try #require(catalog.entry(for: firstTarget)) + guard case .server(let connection) = fields.kind else { return } + try credentials.save("different", for: connection.passwordRef) + #expect(workflow.connectivityRevision(for: firstTarget) != version) + #expect(try workflow.connectivityConfiguration(for: firstTarget).password == "different") + } +} diff --git a/ShadowsocksX-NG2/Tests/Connectivity/ConnectivityCertificateTests.swift b/ShadowsocksX-NG2/Tests/Connectivity/ConnectivityCertificateTests.swift new file mode 100644 index 00000000..157794d5 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/Connectivity/ConnectivityCertificateTests.swift @@ -0,0 +1,104 @@ +import Foundation +import Testing + +@testable import ShadowsocksX_NG2 + +@MainActor +struct ConnectivityCertificateTests { + @Test func HTTPSRejectsUntrustedCertificateThroughRequiredProxy() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let (cert, key) = try makeCertificate(in: root) + let marker = root.appendingPathComponent("tls-error") + let script = try writeProxyScript(in: root) + let proxy = Process() + let output = Pipe() + proxy.executableURL = URL(fileURLWithPath: "/usr/bin/env") + proxy.arguments = ["python3", script.path, cert.path, key.path, marker.path] + proxy.standardOutput = output + proxy.standardError = FileHandle.nullDevice + try proxy.run() + defer { + if proxy.isRunning { + proxy.terminate() + proxy.waitUntilExit() + } + } + var data = Data() + while let byte = try output.fileHandleForReading.read(upToCount: 1), !byte.isEmpty { + if byte == Data([10]) { break } + data.append(byte) + } + let port = try #require( + Int( + (String(data: data, encoding: .utf8) ?? "").trimmingCharacters(in: .whitespacesAndNewlines)) + ) + let id = NodeID.fresh() + let server = SslocalServerDocument( + id: "test", server: "example.com", serverPort: 443, + password: "test", method: "aes-256-gcm", plugin: nil, pluginOpts: nil) + let module = ServerConnectivity( + configuration: { _ in server }, + measure: { _, url, _ in + await ConnectivityHTTPRequest().perform(url: url, proxyPort: port) + }) + module.setURL(URL(string: "https://localhost/test")!) + await module.wait(for: module.test([id])) + #expect(module.history(for: id).first?.outcome == .network) + #expect(FileManager.default.fileExists(atPath: marker.path)) + if FileManager.default.fileExists(atPath: marker.path) { + let failure = try String(contentsOf: marker, encoding: .utf8) + #expect(failure.contains("UNKNOWN_CA") || failure.contains("CERTIFICATE_UNKNOWN")) + } + } + private func makeCertificate(in root: URL) throws -> (URL, URL) { + let cert = root.appendingPathComponent("cert.pem") + let key = root.appendingPathComponent("key.pem") + let openssl = Process() + openssl.executableURL = URL(fileURLWithPath: "/usr/bin/openssl") + openssl.arguments = [ + "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", key.path, + "-out", cert.path, "-days", "1", "-subj", "/CN=localhost", "-addext", + "subjectAltName=DNS:localhost", + ] + openssl.standardOutput = FileHandle.nullDevice + openssl.standardError = FileHandle.nullDevice + try openssl.run() + openssl.waitUntilExit() + #expect(openssl.terminationStatus == 0) + return (cert, key) + } + + private func writeProxyScript(in root: URL) throws -> URL { + let script = root.appendingPathComponent("proxy.py") + try """ + import socket, ssl, sys, pathlib + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(sys.argv[1], sys.argv[2]) + server = socket.socket() + server.bind(('127.0.0.1', 0)); server.listen() + print(server.getsockname()[1], flush=True) + while True: + connection, _ = server.accept() + request = b'' + while b'\\r\\n\\r\\n' not in request: + data = connection.recv(4096) + if not data: break + request += data + if not request.startswith(b'CONNECT localhost:443 '): + connection.close(); continue + connection.sendall(b'HTTP/1.1 200 Connection Established\\r\\n\\r\\n') + try: + secured = context.wrap_socket(connection, server_side=True) + secured.recv(4096) + secured.sendall(b'HTTP/1.1 204 No Content\\r\\n\\r\\n') + secured.close() + except ssl.SSLError as error: + pathlib.Path(sys.argv[3]).write_text(str(error)) + connection.close() + """.write(to: script, atomically: true, encoding: .utf8) + return script + } + +} diff --git a/ShadowsocksX-NG2/Tests/Connectivity/ConnectivityFormalRuntimeTests.swift b/ShadowsocksX-NG2/Tests/Connectivity/ConnectivityFormalRuntimeTests.swift new file mode 100644 index 00000000..88c8e75e --- /dev/null +++ b/ShadowsocksX-NG2/Tests/Connectivity/ConnectivityFormalRuntimeTests.swift @@ -0,0 +1,53 @@ +import Foundation +import Testing + +@testable import ShadowsocksX_NG2 + +@MainActor +struct ConnectivityFormalRuntimeTests { + @Test func changingTestURLDoesNotConvergeTheRunningFormalProxy() async throws { + let runtime = ProxyRuntimeFixture.makeTemporaryRuntime() + defer { try? FileManager.default.removeItem(at: runtime.directory) } + let credentials = InMemoryCredentialStore() + let agent = ProxyRuntimeFixture.FakeLaunchAgent() + let systemProxy = ProxyRuntimeFixture.FakeSystemProxy() + let store = SettingsWorkflowTests.InMemorySettingsStore() + let fileStore = RuntimeFileStore(fileURL: runtime.contract) + agent.onRegister = { + if let document = fileStore.loadDocument() { + try? fileStore.writeRuntimeReceipt(for: document, processID: 42) + } + } + let controller = ProxyRuntimeController( + catalogSnapshotReader: ProxyRuntimeFixture.catalogSnapshotReader( + at: runtime.directory.appendingPathComponent("catalog.json")), + activationFileStore: ActivationStateFileStore( + fileURL: runtime.directory.appendingPathComponent("activation.json")), + runtimeFileStore: fileStore, credentials: credentials, plugins: ActivationFixture.plugins, + listenRestore: RestoredListenSettings( + settings: ActivationFixture.listen, unreadableError: nil), + settingsStore: store, + customRuleStore: CustomRuleStore( + fileURL: runtime.directory.appendingPathComponent("rules.json")), + appBundle: AppArtifact.bundle, + ruleSnapshots: BuiltinRuleSnapshots(loader: ProxyRuntimeFixture.controlFlowRuleSnapshot), + agent: agent, + probe: ProxyRuntimeFixture.FakeProbe.reachable(), systemProxy: systemProxy, + systemProxyHelper: ProxyRuntimeFixture.FakeSystemProxyHelperService(), + launchHealthTimeoutSeconds: 0.05, + sendSignal: { _, _ in 0 }, processIsAlive: { $0 == 42 }) + await controller.setAgentEnabled(true) + let state = controller.state + let calls = agent.registerCount + let target = controller.activeTargetID + let document = try Data(contentsOf: runtime.contract) + let workflow = SettingsWorkflow(committing: controller, credentials: credentials) + #expect(await workflow.saveConnectivityURL("https://example.com/probe?token=private")) + #expect(controller.state == state) + #expect(controller.activeTargetID == target) + #expect(agent.registerCount == calls) + #expect(agent.unregisterCount == 0) + #expect(systemProxy.applied.isEmpty) + #expect(try Data(contentsOf: runtime.contract) == document) + } +} diff --git a/ShadowsocksX-NG2/Tests/Connectivity/ConnectivityHTTPTests.swift b/ShadowsocksX-NG2/Tests/Connectivity/ConnectivityHTTPTests.swift new file mode 100644 index 00000000..ead4c9aa --- /dev/null +++ b/ShadowsocksX-NG2/Tests/Connectivity/ConnectivityHTTPTests.swift @@ -0,0 +1,130 @@ +import Foundation +import Network +import Testing + +@testable import ShadowsocksX_NG2 + +@MainActor +struct ConnectivityHTTPTests { + @Test(arguments: [204, 200, 302, 500]) + func sendsGETThroughProxyAndStopsAtHeaders(status: Int) async throws { + let proxy = try HeaderProxy(status: status) + defer { proxy.stop() } + let port = try await proxy.start() + let started = ContinuousClock.now + // This name cannot resolve; a successful response proves proxy routing. + let id = NodeID.fresh() + let module = ServerConnectivity( + configuration: { _ in Self.server }, + measure: { _, url, _ in + await ConnectivityHTTPRequest().perform(url: url, proxyPort: port) + }) + module.setURL(URL(string: "http://target.invalid/test")!) + await module.wait(for: module.test([id])) + let value = try #require(module.history(for: id).first?.outcome) + if (200..<300).contains(status) { + guard case .success = value else { + Issue.record("Expected successful proxy response, got \(value)") + return + } + } else { + #expect(value == .http(status)) + } + #expect(ContinuousClock.now - started < .seconds(2)) + #expect(proxy.requests.count == 1) + #expect(proxy.requests.first?.hasPrefix("GET http://target.invalid/test HTTP/") == true) + } + + @Test func requestHasOneFiveSecondDeadline() async throws { + let proxy = try HeaderProxy(status: nil) + defer { proxy.stop() } + let port = try await proxy.start() + let id = NodeID.fresh() + let module = ServerConnectivity( + configuration: { _ in Self.server }, + measure: { _, url, _ in + await ConnectivityHTTPRequest().perform(url: url, proxyPort: port) + }) + module.setURL(URL(string: "http://target.invalid/slow")!) + let started = ContinuousClock.now + await module.wait(for: module.test([id])) + #expect(module.history(for: id).first?.outcome == .requestTimeout) + #expect(ContinuousClock.now - started >= .seconds(5)) + #expect(ContinuousClock.now - started < .seconds(6)) + #expect(proxy.requests.count == 1) + } + + @Test func cancellationEndsRequestWithoutWaitingForHeaders() async throws { + let proxy = try HeaderProxy(status: nil) + defer { proxy.stop() } + let port = try await proxy.start() + let id = NodeID.fresh() + let module = ServerConnectivity( + configuration: { _ in Self.server }, + measure: { _, url, _ in + await ConnectivityHTTPRequest().perform(url: url, proxyPort: port) + }) + module.setURL(URL(string: "http://target.invalid/test")!) + let batch = module.test([id]) + for _ in 0..<20 { await Task.yield() } + await module.shutdown() + await module.wait(for: batch) + #expect(module.history(for: id).isEmpty) + #expect(module.isCooling(id)) + } + private static var server: SslocalServerDocument { + SslocalServerDocument( + id: "test", server: "example.com", serverPort: 443, + password: "test", method: "aes-256-gcm", plugin: nil, pluginOpts: nil) + } +} + +/// Controlled HTTP proxy: headers arrive, but the declared body never does. +private final class HeaderProxy: @unchecked Sendable { + private let listener: NWListener + private let status: Int? + private let lock = NSLock() + private var received: [String] = [] + private var connections: [NWConnection] = [] + var requests: [String] { lock.withLock { received } } + + init(status: Int?) throws { + self.status = status + listener = try NWListener(using: .tcp, on: .any) + } + + func start() async throws -> Int { + listener.newConnectionHandler = { [self] connection in + lock.withLock { connections.append(connection) } + connection.start(queue: DispatchQueue(label: UUID().uuidString)) + connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [self] data, _, _, _ in + if let data { lock.withLock { received.append(String(data: data, encoding: .utf8) ?? "") } } + guard let status else { return } + let headers = + "HTTP/1.1 \(status) Response\r\nLocation: http://another.invalid/redirect\r\nContent-Length: 99999\r\n\r\n" + connection.send(content: Data(headers.utf8), completion: .contentProcessed { _ in }) + } + } + return try await withCheckedThrowingContinuation { continuation in + listener.stateUpdateHandler = { [self] state in + switch state { + case .ready: + listener.stateUpdateHandler = nil + continuation.resume(returning: Int(listener.port!.rawValue)) + case .failed(let error): + listener.stateUpdateHandler = nil + continuation.resume(throwing: error) + default: break + } + } + listener.start(queue: DispatchQueue(label: UUID().uuidString)) + } + } + + func stop() { + listener.cancel() + listener.newConnectionHandler = nil + let connections = lock.withLock { self.connections } + connections.forEach { $0.cancel() } + } +} diff --git a/ShadowsocksX-NG2/Tests/Connectivity/ConnectivityRuntimeTests.swift b/ShadowsocksX-NG2/Tests/Connectivity/ConnectivityRuntimeTests.swift new file mode 100644 index 00000000..621195e2 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/Connectivity/ConnectivityRuntimeTests.swift @@ -0,0 +1,44 @@ +import Darwin +import Foundation +import Testing + +@testable import ShadowsocksX_NG2 + +@MainActor +struct ConnectivityRuntimeTests { + @Test func preparationTimeoutKillsProcessGroupAndRemovesSecretsBeforeCompleting() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let environments = root.appendingPathComponent("environments") + let pidFile = root.appendingPathComponent("pid") + let script = root.appendingPathComponent("fake-sslocal") + // Ignore TERM so this test exercises the two-second force termination path. + let text = "#!/bin/sh\ntrap '' TERM\necho $$ > '\(pidFile.path)'\nsleep 60 &\nwait\n" + try text.write(to: script, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: script.path) + let adapter = ConnectivityRuntime(executable: script, temporaryRoot: environments) + let id = NodeID.fresh() + let server = SslocalServerDocument( + id: id.rawValue, server: "example.com", serverPort: 443, + password: "private", method: "aes-256-gcm", plugin: nil, pluginOpts: nil) + let module = ServerConnectivity( + configuration: { _ in server }, + measure: { server, url, _ in + await adapter.measure( + server, url: url, preparationDeadline: .now.advanced(by: .milliseconds(100))) + }) + let started = ContinuousClock.now + await module.wait(for: module.test([id])) + #expect(module.history(for: id).first?.outcome == .preparationTimeout) + #expect(ContinuousClock.now - started >= .seconds(2)) + #expect(try FileManager.default.contentsOfDirectory(atPath: environments.path).isEmpty) + let pid = try #require( + Int32( + String(contentsOf: pidFile, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines) + )) + #expect(kill(pid, 0) != 0) + #expect(module.testing.isEmpty) + #expect(module.isCooling(id)) + } +} diff --git a/ShadowsocksX-NG2/Tests/Connectivity/ConnectivitySettingsTests.swift b/ShadowsocksX-NG2/Tests/Connectivity/ConnectivitySettingsTests.swift new file mode 100644 index 00000000..0315a31c --- /dev/null +++ b/ShadowsocksX-NG2/Tests/Connectivity/ConnectivitySettingsTests.swift @@ -0,0 +1,39 @@ +import Foundation +import Testing + +@testable import ShadowsocksX_NG2 + +@MainActor +struct ConnectivitySettingsTests { + @Test func customURLIsSecretAndFailureDoesNotPublishOrChangeSavedURL() async throws { + let committer = FakeSettingsCommitter() + let credentials = InMemoryCredentialStore() + let workflow = SettingsWorkflow(committing: committer, credentials: credentials) + var published = 0 + workflow.connectivityURLDidChange = { _ in published += 1 } + let secretURL = "https://example.com/probe?token=private" + #expect(await workflow.saveConnectivityURL(secretURL)) + #expect(try workflow.beginConnectivityURLEditing() == secretURL) + #expect(credentials.storageCount == 1) + let saved = committer.committedSettings + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let file = directory.appendingPathComponent("settings.json") + defer { try? FileManager.default.removeItem(at: directory) } + let store = ProxySettingsFileStore(fileURL: file) + try store.save(saved) + #expect(try store.load() == saved) + let text = try String(contentsOf: file, encoding: .utf8) + #expect(!text.contains("private")) + #expect(!text.contains("example.com")) + committer.updateError = ProxySettingsStoreError.ioFailure(detail: "fixture") + #expect(await !workflow.saveConnectivityURL("http://example.com/new")) + #expect(committer.committedSettings == saved) + #expect(credentials.storageCount == 1) + #expect(published == 1) + #expect(try workflow.beginConnectivityURLEditing() == secretURL) + committer.updateError = nil + #expect(await workflow.saveConnectivityURL(ServerConnectivity.defaultURL.absoluteString)) + #expect(committer.committedSettings.connectivityURLReference == nil) + #expect(credentials.storageCount == 0) + } +} diff --git a/ShadowsocksX-NG2/Tests/Connectivity/ServerConnectivityTests.swift b/ShadowsocksX-NG2/Tests/Connectivity/ServerConnectivityTests.swift new file mode 100644 index 00000000..29447a48 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/Connectivity/ServerConnectivityTests.swift @@ -0,0 +1,190 @@ +import Foundation +import Testing + +@testable import ShadowsocksX_NG2 + +@MainActor +struct ServerConnectivityTests { + @Test func repeatedRequestsShareWorkAndCoolingDoesNotAddHistory() async { + let id = NodeID(rawValue: UUID().uuidString) + var calls = 0 + var now = Date(timeIntervalSince1970: 100) + let module = ServerConnectivity( + configuration: { _ in .fixture }, now: { now }, + measure: { _, _, _ in + calls += 1 + await Task.yield() + return .success(milliseconds: 42) + }) + let batch = module.test([id, id]) + _ = module.test([id]) + await module.wait(for: batch) + #expect(calls == 1) + #expect(module.history(for: id).count == 1) + let skipped = module.test([id]) + #expect(module.batch(skipped)?.cooling == [id]) + #expect(module.history(for: id).count == 1) + now.addTimeInterval(10) + await module.wait(for: module.test([id])) + #expect(calls == 2) + } + + @Test func overlappingBatchesShareThreeSlotsUntilCleanupCompletes() async { + let ids = (0..<7).map { _ in NodeID.fresh() } + let gate = AsyncGate() + var active = 0 + var peak = 0 + var calls = 0 + let module = ServerConnectivity( + configuration: { _ in .fixture }, + measure: { _, _, _ in + active += 1 + calls += 1 + peak = max(peak, active) + await gate.wait() + active -= 1 + return .success(milliseconds: 12) + }) + let firstTarget = module.test(Array(ids.prefix(5))) + let secondTarget = module.test(Array(ids.suffix(5))) + for _ in 0..<100 { await Task.yield() } + #expect(calls == 3) + #expect(module.testing.count == 7) + gate.release() + await module.wait(for: firstTarget) + await module.wait(for: secondTarget) + #expect(peak == 3) + #expect(calls == 7) + #expect(ids.allSatisfy { module.history(for: $0).count == 1 }) + } + + @Test func credentialValueChangesSuppressLateResultsAndPreserveCooldown() async { + let id = NodeID.fresh() + var server = SslocalServerDocument.fixture + let gate = AsyncGate() + let module = ServerConnectivity( + configuration: { _ in server }, + measure: { _, _, _ in + await gate.wait() + return .success(milliseconds: 9) + }) + let batch = module.test([id]) + await Task.yield() + server = SslocalServerDocument( + id: "test", server: "example.com", serverPort: 443, + password: "changed", method: "aes-256-gcm", plugin: nil, pluginOpts: nil) + module.configurationsChanged() + gate.release() + await module.wait(for: batch) + #expect(module.history(for: id).isEmpty) + #expect(module.isCooling(id)) + module.setURL(URL(string: "https://example.com/check")!) + #expect(module.isCooling(id)) + } + + @Test func latestFailureReplacesSuccessAndHistoryIsBounded() async { + let id = NodeID.fresh() + var now = Date(timeIntervalSince1970: 100) + var outcome = ServerConnectivity.Outcome.success(milliseconds: 11) + let module = ServerConnectivity( + configuration: { _ in .fixture }, now: { now }, + measure: { _, _, _ in outcome }) + await module.wait(for: module.test([id])) + outcome = .http(302) + for _ in 0..<12 { + now.addTimeInterval(10) + await module.wait(for: module.test([id])) + } + #expect(module.history(for: id).count == 10) + #expect(module.history(for: id).first?.outcome == .http(302)) + let summary = module.summary(for: [id], at: now) + #expect(summary.hasResults) + #expect(summary.record == nil) + #expect(!summary.isTesting) + } + + @Test func freshResultsBeatStaleAndTiesUseCatalogOrder() async { + let ids = (0..<3).map { _ in NodeID.fresh() } + var now = Date(timeIntervalSince1970: 100) + var latency = 1 + let module = ServerConnectivity( + configuration: { _ in .fixture }, now: { now }, + measure: { _, _, _ in .success(milliseconds: latency) }) + await module.wait(for: module.test([ids[0]])) + now.addTimeInterval(300) + latency = 20 + await module.wait(for: module.test(Array(ids.suffix(2)))) + #expect(module.summary(for: ids, at: now).serverID == ids[1]) + module.networkChanged() + #expect(module.summary(for: ids, at: now).serverID == ids[0]) + #expect(module.summary(for: ids, at: now).record?.isExpired(at: now) == true) + module.setURL(URL(string: "https://example.com/new")!) + #expect(!module.summary(for: ids, at: now).hasResults) + } + + @Test func URLAndNetworkChangesSuppressInFlightAndShutdownDrainsQueuedWork() async { + let ids = (0..<5).map { _ in NodeID.fresh() } + let gate = AsyncGate() + let module = ServerConnectivity( + configuration: { _ in .fixture }, + measure: { _, _, _ in + await gate.wait() + return .success(milliseconds: 9) + }) + let batch = module.test(ids) + await Task.yield() + module.networkChanged() + let shutdown = Task { await module.shutdown() } + await Task.yield() + #expect(!module.testing.isEmpty) + gate.release() + await shutdown.value + await module.wait(for: batch) + #expect(module.testing.isEmpty) + #expect(ids.allSatisfy { module.history(for: $0).isEmpty && module.isCooling($0) }) + } + + @Test func queuedConfigurationIsRecheckedBeforeLaunching() async { + let ids = (0..<4).map { _ in NodeID.fresh() } + let gate = AsyncGate() + var changed = false + var calls = 0 + let module = ServerConnectivity( + configuration: { id in + if changed && id == ids[3] { throw ServerFormLoadError.notFound } + return .fixture + }, + measure: { _, _, _ in + calls += 1 + await gate.wait() + return .success(milliseconds: 5) + }) + let batch = module.test(ids) + for _ in 0..<100 { await Task.yield() } + changed = true + gate.release() + await module.wait(for: batch) + #expect(calls == 3) + #expect(module.history(for: ids[3]).isEmpty) + #expect(module.isCooling(ids[3])) + } + + @Test func URLValidationAllowsHTTPAndHTTPSOnly() { + #expect(ServerConnectivity.validURL("http://example.com/check") != nil) + #expect(ServerConnectivity.validURL("https://example.com/check?q=secret") != nil) + for value in [ + "file:///tmp/check", "ftp://example.com", "https:///", "example.com", "http://x/#fragment", + ] { + #expect(ServerConnectivity.validURL(value) == nil) + } + } + +} + +extension SslocalServerDocument { + fileprivate static var fixture: Self { + Self( + id: "test", server: "example.com", serverPort: 443, password: "secret", + method: "aes-256-gcm", plugin: nil, pluginOpts: nil) + } +} diff --git a/ShadowsocksX-NG2/project.yml b/ShadowsocksX-NG2/project.yml index 5f77f5d9..e26fc2df 100644 --- a/ShadowsocksX-NG2/project.yml +++ b/ShadowsocksX-NG2/project.yml @@ -138,6 +138,7 @@ targets: # 模块名让位给 Core 镜像库(ShadowsocksX_NG2):两者 swiftmodule 都落 # 共享 Products 目录,重名会互相覆盖;app 可执行自身不 import 任何 # 消费方,改名零影响。 + SWIFT_OBJC_BRIDGING_HEADER: App/Application/Connectivity/ConnectivityHTTPTransport.h PRODUCT_MODULE_NAME: ShadowsocksX_NG2App ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon MARKETING_VERSION: "2.0.0" @@ -163,6 +164,7 @@ targets: - name: ShadowsocksX-NG2Tests parallelizable: true dependencies: + - sdk: libcurl.tbd # 代理运行时 wrapper(BundleProgram):以独立可执行文件装入 # Contents/MacOS,由 LaunchAgent plist 以 bundle 相对路径引用。 - target: ShadowsocksX-NG2Agent @@ -197,9 +199,12 @@ targets: settings: base: PRODUCT_NAME: ShadowsocksX-NG2Core + SWIFT_OBJC_BRIDGING_HEADER: App/Application/Connectivity/ConnectivityHTTPTransport.h PRODUCT_MODULE_NAME: ShadowsocksX_NG2 # 静态库产物不签名(app 的 DevID 身份不适用)。 CODE_SIGNING_ALLOWED: NO + dependencies: + - sdk: libcurl.tbd ShadowsocksX-NG2Agent: type: tool platform: macOS @@ -264,6 +269,7 @@ targets: path: Tests/Info.plist properties: {} dependencies: + - sdk: libcurl.tbd # 去宿主化(ADR 0021):单测链 Core 静态库镜像而非 app 可执行,测试在 # 独立 xctest runner 中运行,宿主 app 不再被启动。 - target: ShadowsocksX-NG2Core @@ -283,6 +289,7 @@ targets: path: Tests/Smoke/Info.plist properties: {} dependencies: + - sdk: libcurl.tbd # 去宿主化(ADR 0021):smoke 同样链 Core 镜像库;真实 sslocal/agent 从 # scheme 构建的 app 产物取(AppArtifact),宿主 app 不再被启动。 - target: ShadowsocksX-NG2Core