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