This commit is contained in:
anlicheng 2026-04-17 11:20:26 +08:00
parent a4267e0d30
commit 58507e2f06
3 changed files with 300 additions and 49 deletions

View File

@ -19,6 +19,9 @@ class AppContext {
// "/connect" // "/connect"
var networkContext: SDLAPIClient.NetworkContext? = nil var networkContext: SDLAPIClient.NetworkContext? = nil
// IP nil
var selectedExitNodeIp: String? = nil
// menu使 // menu使
var vpnOptions: [String: NSObject]? = nil var vpnOptions: [String: NSObject]? = nil
@ -73,6 +76,7 @@ class AppContext {
func loginWith(token: String) async throws { func loginWith(token: String) async throws {
let networkSession = try await SDLAPIClient.loginWithToken(token: token) let networkSession = try await SDLAPIClient.loginWithToken(token: token)
self.loginCredit = .token(token: token, session: networkSession) self.loginCredit = .token(token: token, session: networkSession)
self.selectedExitNodeIp = self.loadExitNodeIp(networkId: networkSession.networkId)
// keychain // keychain
if let data = token.data(using: .utf8) { if let data = token.data(using: .utf8) {
try KeychainStore.shared.save(data, account: "token") try KeychainStore.shared.save(data, account: "token")
@ -82,6 +86,7 @@ class AppContext {
func loginWith(username: String, password: String) async throws { func loginWith(username: String, password: String) async throws {
let networkSession = try await SDLAPIClient.loginWithAccountAndPassword(username: username, password: password) let networkSession = try await SDLAPIClient.loginWithAccountAndPassword(username: username, password: password)
self.loginCredit = .accountAndPasword(account: username, password: password, session: networkSession) self.loginCredit = .accountAndPasword(account: username, password: password, session: networkSession)
self.selectedExitNodeIp = self.loadExitNodeIp(networkId: networkSession.networkId)
// keychain // keychain
if let data = "\(username):\(password)".data(using: .utf8) { if let data = "\(username):\(password)".data(using: .utf8) {
try KeychainStore.shared.save(data, account: "accountAndPasword") try KeychainStore.shared.save(data, account: "accountAndPasword")
@ -133,7 +138,7 @@ class AppContext {
accessToken: session.accessToken, accessToken: session.accessToken,
identityId: context.identityId, identityId: context.identityId,
hostname: context.hostname, hostname: context.hostname,
exitNodeIp: self.loadExitNodeIp() exitNodeIp: self.selectedExitNodeIp
) )
try await self.vpnManager.enableVpn(options: options) try await self.vpnManager.enableVpn(options: options)
} }
@ -147,6 +152,7 @@ class AppContext {
func logout() async throws { func logout() async throws {
try await self.vpnManager.disableVpn() try await self.vpnManager.disableVpn()
self.networkContext = nil self.networkContext = nil
self.selectedExitNodeIp = nil
self.loginCredit = nil self.loginCredit = nil
} }
@ -200,20 +206,69 @@ class AppContext {
// //
extension AppContext { extension AppContext {
func loadExitNodeIp() -> String? { private func exitNodeStorageAccount(networkId: Int?) -> String {
if let data = try? KeychainStore.shared.load(account: "exitNodeIp") { if let networkId {
return "exitNodeIp_\(networkId)"
}
return "exitNodeIp"
}
func loadExitNodeIp(networkId: Int? = nil) -> String? {
let account = self.exitNodeStorageAccount(networkId: networkId)
if let data = try? KeychainStore.shared.load(account: account) {
return String(data: data, encoding: .utf8) return String(data: data, encoding: .utf8)
} }
if networkId != nil,
let data = try? KeychainStore.shared.load(account: self.exitNodeStorageAccount(networkId: nil)) {
return String(data: data, encoding: .utf8)
}
return nil return nil
} }
func saveExitNodeIp(exitNodeIp: String) async throws { func saveExitNodeIp(exitNodeIp: String, networkId: Int? = nil) async throws {
let account = self.exitNodeStorageAccount(networkId: networkId)
// keychain // keychain
if let data = exitNodeIp.data(using: .utf8) { if let data = exitNodeIp.data(using: .utf8) {
try KeychainStore.shared.save(data, account: "exitNodeIp") try KeychainStore.shared.save(data, account: account)
} }
} }
func clearExitNodeIp(networkId: Int? = nil) throws {
try KeychainStore.shared.delete(account: self.exitNodeStorageAccount(networkId: networkId))
}
func updateExitNodeIp(exitNodeIp: String?) async throws {
let normalizedExitNodeIp = exitNodeIp?.trimmingCharacters(in: .whitespacesAndNewlines)
let finalExitNodeIp = normalizedExitNodeIp?.isEmpty == true ? nil : normalizedExitNodeIp
let networkId = self.networkSession?.networkId
if vpnManager.isConnected {
let result = try await self.changeExitNodeIp(exitNodeIp: finalExitNodeIp ?? "0.0.0.0")
let reply = try TunnelResponse(serializedBytes: result)
guard reply.code == 0 else {
throw AppContextError(message: reply.message)
}
}
if let finalExitNodeIp {
try await self.saveExitNodeIp(exitNodeIp: finalExitNodeIp, networkId: networkId)
} else {
try self.clearExitNodeIp(networkId: networkId)
}
if networkId != nil {
try? self.clearExitNodeIp(networkId: nil)
}
self.selectedExitNodeIp = finalExitNodeIp
}
} }

View File

@ -104,7 +104,9 @@ struct NetworkStatusBar: View {
@Environment(AppContext.self) private var appContext @Environment(AppContext.self) private var appContext
@State private var vpnManger = VPNManager.shared @State private var vpnManger = VPNManager.shared
@State private var exitNodeIp: String = "" @State private var isUpdatingExitNode: Bool = false
@State private var showExitNodeError: Bool = false
@State private var exitNodeErrorMessage: String = ""
var body: some View { var body: some View {
let isOnBinding = Binding( let isOnBinding = Binding(
@ -147,10 +149,21 @@ struct NetworkStatusBar: View {
Text("局域网IP: \(appContext.networkContext?.ip ?? "0.0.0.0")") Text("局域网IP: \(appContext.networkContext?.ip ?? "0.0.0.0")")
.font(.system(size: 10, design: .monospaced)) .font(.system(size: 10, design: .monospaced))
.foregroundColor(.secondary) .foregroundColor(.secondary)
} else {
Text("未登录网络")
.font(.system(size: 12, weight: .semibold))
Text("登录后可建立连接")
.font(.system(size: 10))
.foregroundColor(.secondary)
} }
} }
} }
if appContext.networkSession != nil {
exitNodeMenu
}
// Switch // Switch
// 使 Binding / // 使 Binding /
Toggle("", isOn: isOnBinding) Toggle("", isOn: isOnBinding)
@ -158,22 +171,217 @@ struct NetworkStatusBar: View {
.controlSize(.small) // macOS 使 small .controlSize(.small) // macOS 使 small
TextField("出口节点:", text: $exitNodeIp) // TextField(":", text: $exitNodeIp)
//
Button { // Button {
Task { // Task {
let result = try await self.appContext.changeExitNodeIp(exitNodeIp: self.exitNodeIp) // let result = try await self.appContext.changeExitNodeIp(exitNodeIp: self.exitNodeIp)
let reply = try TunnelResponse(serializedBytes: result) // let reply = try TunnelResponse(serializedBytes: result)
NSLog("change exit node ip: \(reply)") // NSLog("change exit node ip: \(reply)")
} // }
} label: { // } label: {
Text("启动出口节点") // Text("")
} // }
} }
.padding(.vertical, 5) .padding(.vertical, 5)
.alert("出口节点切换失败", isPresented: $showExitNodeError) {
Button("确定", role: .cancel) {
}
} message: {
Text(exitNodeErrorMessage)
}
} }
private var exitNodeMenu: some View {
Menu {
Button {
applyExitNodeSelection(nil)
} label: {
if selectedExitNode == nil {
Label("不设置出口节点", systemImage: "checkmark")
} else {
Text("不设置出口节点")
}
}
if !exitNodeOptions.isEmpty {
Divider()
ForEach(exitNodeOptions) { option in
Button {
applyExitNodeSelection(option.ip)
} label: {
if selectedExitNode?.ip == option.ip {
Label(option.nodeNameWithIp, systemImage: "checkmark")
} else {
Text(option.nodeNameWithIp)
}
}
}
}
} label: {
HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 3) {
Text("出口节点")
.font(.system(size: 10, weight: .medium))
.foregroundColor(.secondary)
Text(exitNodeTitle)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.primary)
.lineLimit(1)
Text(exitNodeSubtitle)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.secondary)
.lineLimit(1)
}
Spacer(minLength: 0)
if isUpdatingExitNode {
ProgressView()
.controlSize(.small)
} else {
Image(systemName: "chevron.down")
.font(.system(size: 10, weight: .semibold))
.foregroundColor(.secondary)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.frame(width: 220, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.primary.opacity(0.04))
)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color.primary.opacity(0.06), lineWidth: 1)
)
}
.buttonStyle(.plain)
.disabled(isUpdatingExitNode || !canUpdateExitNode)
.opacity(canUpdateExitNode ? 1 : 0.7)
.help(exitNodeHelpText)
}
private var exitNodeOptions: [ExitNodeOption] {
guard let networkContext = appContext.networkContext else {
return []
}
return networkContext.exitNodeList.compactMap { exitNode in
guard let node = networkContext.getNode(id: exitNode.nnid) else {
return nil
}
return ExitNodeOption(
id: exitNode.nnid,
nodeName: exitNode.nodeName,
ip: node.ip,
system: node.system
)
}
}
private var selectedExitNode: ExitNodeOption? {
guard let selectedExitNodeIp = appContext.selectedExitNodeIp else {
return nil
}
return exitNodeOptions.first(where: { $0.ip == selectedExitNodeIp })
?? ExitNodeOption(
id: -1,
nodeName: "已保存出口节点",
ip: selectedExitNodeIp,
system: nil
)
}
private var exitNodeTitle: String {
if isUpdatingExitNode {
return "正在切换..."
}
if let selectedExitNode {
return selectedExitNode.nodeName
}
return "未设置"
}
private var exitNodeSubtitle: String {
if let selectedExitNode {
if let system = selectedExitNode.system, !system.isEmpty {
return "\(selectedExitNode.ip) · \(system)"
}
return selectedExitNode.ip
}
if appContext.networkContext == nil {
return "连接后可选择"
}
if exitNodeOptions.isEmpty {
return "当前网络没有可用节点"
}
return "当前流量保持默认出口"
}
private var canUpdateExitNode: Bool {
appContext.networkContext != nil || appContext.selectedExitNodeIp != nil
}
private var exitNodeHelpText: String {
if isUpdatingExitNode {
return "正在更新出口节点"
}
if appContext.networkContext == nil {
return "建立连接后可选择当前网络的出口节点"
}
return "切换当前网络流量的出口节点,也可以保持未设置"
}
private func applyExitNodeSelection(_ ip: String?) {
guard !isUpdatingExitNode else {
return
}
Task { @MainActor in
self.isUpdatingExitNode = true
defer {
self.isUpdatingExitNode = false
}
do {
try await self.appContext.updateExitNodeIp(exitNodeIp: ip)
} catch let err as AppContextError {
self.exitNodeErrorMessage = err.message
self.showExitNodeError = true
} catch {
self.exitNodeErrorMessage = error.localizedDescription
self.showExitNodeError = true
}
}
}
}
private struct ExitNodeOption: Identifiable, Equatable {
let id: Int
let nodeName: String
let ip: String
let system: String?
var nodeNameWithIp: String {
"\(nodeName) (\(ip))"
}
} }
struct NetworkConnectedView: View { struct NetworkConnectedView: View {

View File

@ -10,8 +10,6 @@ struct SettingsNetworkView: View {
@Environment(AppContext.self) var appContext: AppContext @Environment(AppContext.self) var appContext: AppContext
@Environment(\.openURL) var openURL @Environment(\.openURL) var openURL
@State private var selectedExitNode: SDLAPIClient.NetworkContext.ExitNode?
var body: some View { var body: some View {
ScrollView(.vertical, showsIndicators: false) { ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading, spacing: 24) { VStack(alignment: .leading, spacing: 24) {
@ -59,40 +57,33 @@ struct SettingsNetworkView: View {
.cornerRadius(12) .cornerRadius(12)
// //
HStack { HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 4) { ZStack {
Text("出口节点") RoundedRectangle(cornerRadius: 8, style: .continuous)
.font(.caption) .fill(Color.blue.opacity(0.1))
.foregroundColor(.secondary) .frame(width: 32, height: 32)
if let selectedExitNode = self.selectedExitNode { Image(systemName: "arrow.triangle.branch")
Text(selectedExitNode.nodeName) .font(.system(size: 14, weight: .medium))
.font(.system(size: 15, weight: .medium))
}
}
Spacer()
Menu {
ForEach(appContext.networkContext?.exitNodeList ?? [], id: \.uuid) { node in
Button {
self.selectedExitNode = node
} label: {
Text(node.nodeName)
}
}
} label: {
Text("更改")
.font(.subheadline)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(Capsule().fill(Color.blue.opacity(0.1)))
.foregroundColor(.blue) .foregroundColor(.blue)
} }
.buttonStyle(.plain)
VStack(alignment: .leading, spacing: 4) {
Text("出口节点")
.font(.system(size: 14, weight: .medium))
Text("已迁移到主界面顶部状态栏,可结合当前连接状态以下拉方式切换,也支持保持未设置。")
.font(.system(size: 12))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
} }
.padding(16) .padding(16)
.background(Color.primary.opacity(0.03)) .background(Color.primary.opacity(0.03))
.cornerRadius(12) .cornerRadius(12)
.overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.primary.opacity(0.05), lineWidth: 1))
} }
// MARK: - // MARK: -
@ -112,9 +103,6 @@ struct SettingsNetworkView: View {
.padding(32) .padding(32)
.frame(maxWidth: 600, alignment: .leading) .frame(maxWidth: 600, alignment: .leading)
} }
.onAppear {
self.selectedExitNode = self.appContext.networkContext?.exitNodeList.first
}
} }
// MARK: - // MARK: -