dpl/macos-client/Sources/DPLink/App.swift

185 lines
9.9 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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() } }
}
}