185 lines
9.9 KiB
Swift
185 lines
9.9 KiB
Swift
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() } }
|
||
}
|
||
}
|