diff --git a/punchnet/Views/AppContext.swift b/punchnet/Views/AppContext.swift index 8f5abe7..08c6551 100644 --- a/punchnet/Views/AppContext.swift +++ b/punchnet/Views/AppContext.swift @@ -18,6 +18,9 @@ class AppContext { // 调用 "/connect" 之后的网络信息 var networkContext: SDLAPIClient.NetworkContext? = nil + + // 当前选择的出口节点 IP,为 nil 表示不设置出口节点 + var selectedExitNodeIp: String? = nil // 在menu里面需要使用 var vpnOptions: [String: NSObject]? = nil @@ -73,6 +76,7 @@ class AppContext { func loginWith(token: String) async throws { let networkSession = try await SDLAPIClient.loginWithToken(token: token) self.loginCredit = .token(token: token, session: networkSession) + self.selectedExitNodeIp = self.loadExitNodeIp(networkId: networkSession.networkId) // 将数据缓存到keychain if let data = token.data(using: .utf8) { try KeychainStore.shared.save(data, account: "token") @@ -82,6 +86,7 @@ class AppContext { func loginWith(username: String, password: String) async throws { let networkSession = try await SDLAPIClient.loginWithAccountAndPassword(username: username, password: password) self.loginCredit = .accountAndPasword(account: username, password: password, session: networkSession) + self.selectedExitNodeIp = self.loadExitNodeIp(networkId: networkSession.networkId) // 将数据缓存到keychain if let data = "\(username):\(password)".data(using: .utf8) { try KeychainStore.shared.save(data, account: "accountAndPasword") @@ -133,7 +138,7 @@ class AppContext { accessToken: session.accessToken, identityId: context.identityId, hostname: context.hostname, - exitNodeIp: self.loadExitNodeIp() + exitNodeIp: self.selectedExitNodeIp ) try await self.vpnManager.enableVpn(options: options) } @@ -147,6 +152,7 @@ class AppContext { func logout() async throws { try await self.vpnManager.disableVpn() self.networkContext = nil + self.selectedExitNodeIp = nil self.loginCredit = nil } @@ -199,20 +205,69 @@ class AppContext { // 处理网络出口数据 extension AppContext { + + private func exitNodeStorageAccount(networkId: Int?) -> String { + if let networkId { + return "exitNodeIp_\(networkId)" + } + + return "exitNodeIp" + } - func loadExitNodeIp() -> String? { - if let data = try? KeychainStore.shared.load(account: "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) } + + if networkId != nil, + let data = try? KeychainStore.shared.load(account: self.exitNodeStorageAccount(networkId: nil)) { + return String(data: data, encoding: .utf8) + } + return nil } - func saveExitNodeIp(exitNodeIp: String) async throws { + func saveExitNodeIp(exitNodeIp: String, networkId: Int? = nil) async throws { + let account = self.exitNodeStorageAccount(networkId: networkId) + // 将数据缓存到keychain 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 + } diff --git a/punchnet/Views/Network/NetworkView.swift b/punchnet/Views/Network/NetworkView.swift index 04c12e8..b1ddb02 100644 --- a/punchnet/Views/Network/NetworkView.swift +++ b/punchnet/Views/Network/NetworkView.swift @@ -104,7 +104,9 @@ struct NetworkStatusBar: View { @Environment(AppContext.self) private var appContext @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 { let isOnBinding = Binding( @@ -147,10 +149,21 @@ struct NetworkStatusBar: View { Text("局域网IP: \(appContext.networkContext?.ip ?? "0.0.0.0")") .font(.system(size: 10, design: .monospaced)) .foregroundColor(.secondary) + } else { + Text("未登录网络") + .font(.system(size: 12, weight: .semibold)) + + Text("登录后可建立连接") + .font(.system(size: 10)) + .foregroundColor(.secondary) } } } + if appContext.networkSession != nil { + exitNodeMenu + } + // 右侧:Switch 开关 // 注意:这里使用 Binding 手动接管连接/断开逻辑 Toggle("", isOn: isOnBinding) @@ -158,22 +171,217 @@ struct NetworkStatusBar: View { .controlSize(.small) // macOS 顶部栏或面板推荐使用 small 尺寸 - TextField("出口节点:", text: $exitNodeIp) - - Button { - Task { - let result = try await self.appContext.changeExitNodeIp(exitNodeIp: self.exitNodeIp) - let reply = try TunnelResponse(serializedBytes: result) - NSLog("change exit node ip: \(reply)") - } - } label: { - Text("启动出口节点") - } +// TextField("出口节点:", text: $exitNodeIp) +// +// Button { +// Task { +// let result = try await self.appContext.changeExitNodeIp(exitNodeIp: self.exitNodeIp) +// let reply = try TunnelResponse(serializedBytes: result) +// NSLog("change exit node ip: \(reply)") +// } +// } label: { +// Text("启动出口节点") +// } } .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 { diff --git a/punchnet/Views/Settings/SettingsNetworkView.swift b/punchnet/Views/Settings/SettingsNetworkView.swift index c0c4bc2..358eeca 100644 --- a/punchnet/Views/Settings/SettingsNetworkView.swift +++ b/punchnet/Views/Settings/SettingsNetworkView.swift @@ -10,8 +10,6 @@ struct SettingsNetworkView: View { @Environment(AppContext.self) var appContext: AppContext @Environment(\.openURL) var openURL - @State private var selectedExitNode: SDLAPIClient.NetworkContext.ExitNode? - var body: some View { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 24) { @@ -59,40 +57,33 @@ struct SettingsNetworkView: View { .cornerRadius(12) // 出口节点项 - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("出口节点") - .font(.caption) - .foregroundColor(.secondary) + HStack(alignment: .top, spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.blue.opacity(0.1)) + .frame(width: 32, height: 32) - if let selectedExitNode = self.selectedExitNode { - Text(selectedExitNode.nodeName) - .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))) + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 14, weight: .medium)) .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) .background(Color.primary.opacity(0.03)) .cornerRadius(12) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.primary.opacity(0.05), lineWidth: 1)) } // MARK: - 授权与安全 @@ -112,9 +103,6 @@ struct SettingsNetworkView: View { .padding(32) .frame(maxWidth: 600, alignment: .leading) } - .onAppear { - self.selectedExitNode = self.appContext.networkContext?.exitNodeList.first - } } // MARK: - 辅助组件