mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-27 20:53:44 +00:00
feat(connectivity): add manual server connectivity tests
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user