feat(connectivity): add manual server connectivity tests

This commit is contained in:
Qiu Yuzhou
2026-10-14 00:53:52 +08:00
parent 3a6d143b17
commit ebcd7ab2e2
31 changed files with 1918 additions and 2 deletions
@@ -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<NodeID>()
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
}
}
@@ -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(
@@ -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) }
}
}
@@ -0,0 +1,109 @@
#include "ConnectivityHTTPTransport.h"
#include <curl/curl.h>
#include <pthread.h>
#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
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;
}
@@ -0,0 +1,18 @@
#ifndef ConnectivityHTTPTransport_h
#define ConnectivityHTTPTransport_h
#include <stdint.h>
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
@@ -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<sockaddr_in>.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<sockaddr_in>.size))
}
}
guard bound == 0 else { throw RuntimeError.failed }
var length = socklen_t(MemoryLayout<sockaddr_in>.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 }
}
@@ -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<NodeID> = []
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<NodeID> = []
@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<Void, Never>] = [:]
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<NodeID>()
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)
}
}
}
@@ -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)
}
@@ -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
}
}
}
@@ -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
@@ -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
}
}
@@ -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,
@@ -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"
}
@@ -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 {
@@ -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("已过期") : "")
}
}
@@ -37,6 +37,12 @@ struct FavoriteServersTable<MenuContent: View>: 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)
@@ -1,9 +1,10 @@
import SwiftUI
/// 只接受展示值与外部动作;不查询节点类型、工作流或凭据。
struct NodeDetailView<Actions: View>: View {
struct NodeDetailView<Actions: View, Supplement: View>: View {
let detail: NodeDetailPresentation
@ViewBuilder let actions: Actions
@ViewBuilder let supplement: Supplement
var body: some View {
ScrollView {
@@ -40,6 +41,7 @@ struct NodeDetailView<Actions: View>: View {
}
}
}
supplement
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
@@ -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 {
@@ -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 {
@@ -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)
}
}
}
@@ -15,6 +15,7 @@ struct SettingsView: View {
Form {
generalSection
endpointSection
ConnectivityURLSettings(workflow: workflow)
systemProxySection
PluginManagementSection(model: plugins)
}
@@ -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
@@ -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
@@ -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")
}
}
@@ -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
}
}
@@ -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)
}
}
@@ -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() }
}
}
@@ -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))
}
}
@@ -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)
}
}
@@ -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)
}
}
+7
View File
@@ -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