import SwiftUI import Foundation struct ClientError: LocalizedError { let message: String var errorDescription: String? { message } } @MainActor final class Client: ObservableObject { @Published var server = "http://127.0.0.1:8000" @Published var email = "" @Published var password = "" @Published var corePath = "" @Published var status = "请登录 XBoard 账户" @Published var busy = false @Published var loggedIn = false @Published var connected = false @Published var nodes: [String] = [] @Published var selected = "" private var authorization = "" private var subscription = "" private var process: Process? private var secret = UUID().uuidString private var directory: URL? func request(_ path: String, method: String = "GET", body: [String: String]? = nil, controller: Bool = false) async throws -> [String: Any] { let base = controller ? "http://127.0.0.1:19091" : server.trimmingCharacters(in: CharacterSet(charactersIn: "/")) guard let url = URL(string: base + path), let scheme = url.scheme, ["https", "http"].contains(scheme) else { throw ClientError(message: "服务器地址无效") } if !controller && scheme == "http" && !["localhost", "127.0.0.1", "::1"].contains(url.host ?? "") { throw ClientError(message: "远程服务器请使用 HTTPS") } var req = URLRequest(url: url) req.timeoutInterval = 15 req.httpMethod = method if controller { req.setValue("Bearer " + secret, forHTTPHeaderField: "Authorization") } else if !authorization.isEmpty { req.setValue(authorization, forHTTPHeaderField: "Authorization") } if let body { req.httpBody = try JSONSerialization.data(withJSONObject: body); req.setValue("application/json", forHTTPHeaderField: "Content-Type") } let (data, response) = try await URLSession.shared.data(for: req) guard let response = response as? HTTPURLResponse else { throw ClientError(message: "服务器未响应") } let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] ?? [:] guard (200..<300).contains(response.statusCode) else { throw ClientError(message: object["message"] as? String ?? "请求失败(\(response.statusCode))") } return object } func login() async { busy = true defer { busy = false } do { let response = try await request("/api/v1/passport/auth/login", method: "POST", body: ["email": email, "password": password]) guard let data = response["data"] as? [String: Any], let auth = data["auth_data"] as? String else { throw ClientError(message: "登录响应缺少认证信息") } authorization = auth password = "" let sub = try await request("/api/v1/user/getSubscribe") guard let data = sub["data"] as? [String: Any], let url = data["subscribe_url"] as? String else { throw ClientError(message: "账户没有订阅地址") } subscription = url loggedIn = true status = "登录成功,请选择 Mihomo 内核后连接" } catch { authorization = ""; status = error.localizedDescription } } func connect() async { busy = true defer { busy = false } do { guard FileManager.default.isExecutableFile(atPath: corePath) else { throw ClientError(message: "请选择可执行的 Mihomo 内核") } guard var components = URLComponents(string: subscription) else { throw ClientError(message: "订阅地址无效") } var query = components.queryItems ?? [] query.removeAll { $0.name == "flag" } query.append(URLQueryItem(name: "flag", value: "clash")) components.queryItems = query guard let subURL = components.url, subURL.scheme == "https" || ["127.0.0.1", "localhost"].contains(subURL.host ?? "") else { throw ClientError(message: "订阅地址必须使用 HTTPS 或本机地址") } let dir = FileManager.default.temporaryDirectory.appendingPathComponent("dplink-" + UUID().uuidString) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) directory = dir let quotedURL = String(data: try JSONSerialization.data(withJSONObject: [subURL.absoluteString]), encoding: .utf8)!.dropFirst().dropLast() let config = """ mixed-port: 17899 allow-lan: false bind-address: 127.0.0.1 mode: rule log-level: warning external-controller: 127.0.0.1:19091 secret: "\(secret)" proxy-providers: account: type: http url: \(quotedURL) path: ./subscription.yaml interval: 3600 proxy-groups: - name: DP-LINK type: select use: [account] rules: - MATCH,DP-LINK """ let file = dir.appendingPathComponent("config.yaml") try config.write(to: file, atomically: true, encoding: .utf8) try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: file.path) let task = Process() task.executableURL = URL(fileURLWithPath: corePath) task.arguments = ["-d", dir.path, "-f", file.path] task.standardOutput = FileHandle.nullDevice task.standardError = FileHandle.nullDevice try task.run() process = task var ready = false for _ in 0..<30 { try await Task.sleep(nanoseconds: 500_000_000) guard task.isRunning else { throw ClientError(message: "内核退出,请检查端口占用、订阅或内核文件") } if let result = try? await request("/proxies", controller: true), let proxies = result["proxies"] as? [String: Any], let group = proxies["DP-LINK"] as? [String: Any], let all = group["all"] as? [String], !all.isEmpty { nodes = all; selected = group["now"] as? String ?? all[0]; ready = true; break } } guard ready else { throw ClientError(message: "订阅加载超时,请检查服务器和网络") } connected = true status = "本地代理已启动:127.0.0.1:17899(HTTP / SOCKS5)" } catch { disconnect(); status = error.localizedDescription } } func chooseNode() async { do { _ = try await request("/proxies/DP-LINK", method: "PUT", body: ["name": selected], controller: true); status = "当前节点:" + selected } catch { status = error.localizedDescription } } func disconnect() { if let process, process.isRunning { process.terminate(); process.waitUntilExit() } process = nil connected = false nodes = [] if let directory { try? FileManager.default.removeItem(at: directory) } directory = nil status = "已断开" } func logout() { disconnect(); authorization = ""; subscription = ""; loggedIn = false } } struct ContentView: View { @ObservedObject var client: Client var body: some View { VStack(alignment: .leading, spacing: 22) { HStack { Image(systemName: "network").font(.system(size: 32)).foregroundStyle(.blue) VStack(alignment: .leading) { Text("DP-LINK").font(.title.bold()); Text("macOS 客户端 · 开发预览").foregroundStyle(.secondary) } Spacer() if client.loggedIn { Button("退出登录") { client.logout() }.disabled(client.busy) } } Divider() if !client.loggedIn { TextField("XBoard 地址", text: $client.server) TextField("邮箱", text: $client.email) SecureField("密码", text: $client.password) Button("登录并获取订阅") { Task { await client.login() } }.buttonStyle(.borderedProminent).disabled(client.busy || client.email.isEmpty || client.password.isEmpty) } else { Text(client.email).foregroundStyle(.secondary) HStack { TextField("Mihomo 可执行文件路径", text: $client.corePath).disabled(client.connected) Button("选择内核") { let panel = NSOpenPanel(); panel.canChooseDirectories = false if panel.runModal() == .OK { client.corePath = panel.url?.path ?? "" } }.disabled(client.busy || client.connected) } if !client.nodes.isEmpty { Picker("节点", selection: $client.selected) { ForEach(client.nodes, id: \.self) { Text($0).tag($0) } } .onChange(of: client.selected) { _ in if client.connected { Task { await client.chooseNode() } } } } Button(client.connected ? "断开连接" : "启动代理") { if client.connected { client.disconnect() } else { Task { await client.connect() } } }.buttonStyle(.borderedProminent).disabled(client.busy) Text("当前预览版提供本地代理。系统代理和 TUN 接管尚未实现;应用需配置为使用 127.0.0.1:17899。") .font(.caption).foregroundStyle(.secondary) } if client.busy { ProgressView().controlSize(.small) } Text(client.status).font(.callout).textSelection(.enabled) Spacer() Text("凭据仅保留在内存中,退出后需重新登录。").font(.caption).foregroundStyle(.secondary) }.textFieldStyle(.roundedBorder).padding(30).frame(minWidth: 560, minHeight: 460) } } @main struct DPLinkApp: App { @StateObject private var client = Client() var body: some Scene { WindowGroup { ContentView(client: client).onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in client.disconnect() } } } }