fix view
This commit is contained in:
parent
a4267e0d30
commit
58507e2f06
@ -19,6 +19,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
|
||||
}
|
||||
|
||||
@ -200,20 +206,69 @@ class AppContext {
|
||||
// 处理网络出口数据
|
||||
extension AppContext {
|
||||
|
||||
func loadExitNodeIp() -> String? {
|
||||
if let data = try? KeychainStore.shared.load(account: "exitNodeIp") {
|
||||
private func exitNodeStorageAccount(networkId: Int?) -> String {
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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: - 辅助组件
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user