punchnet-macos/punchnet/Views/Network/NetworkView.swift
2026-04-17 11:20:26 +08:00

661 lines
22 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.

//
// NetworkView.swift
// punchnet
import SwiftUI
import Observation
// MARK: -
enum ConnectState {
case waitAuth
case connected
case disconnected
}
//
enum NetworkShowMode: String, CaseIterable {
case resource = "访问资源"
case device = "成员设备"
}
// MARK: -
struct NetworkView: View {
@Environment(AppContext.self) var appContext: AppContext
@Environment(\.openWindow) var openWindow
@State private var showMode: NetworkShowMode = .resource
@State private var connectState: ConnectState = .disconnected
private var vpnManager = VPNManager.shared
var body: some View {
VStack(spacing: 0) {
// 1. (Header)
HStack(spacing: 16) {
NetworkStatusBar()
Spacer()
if connectState == .connected {
Picker("", selection: $showMode) {
ForEach(NetworkShowMode.allCases, id: \.self) {
Text($0.rawValue).tag($0)
}
}
.pickerStyle(.segmented)
.frame(width: 160)
}
Button {
openWindow(id: "settings")
} label: {
Image(systemName: "slider.horizontal.3")
.font(.system(size: 14))
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
.help("配置中心")
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
.background(VisualEffectView(material: .headerView, blendingMode: .withinWindow))
Divider()
// 2. (Content)
Group {
switch connectState {
case .waitAuth:
NetworkWaitAuthView()
case .connected:
NetworkConnectedView(showMode: $showMode)
case .disconnected:
NetworkDisconnectedView()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(VisualEffectView(material: .windowBackground, blendingMode: .behindWindow))
}
.frame(minWidth: 700, minHeight: 500) // SplitView
.onAppear {
syncState(vpnManager.vpnStatus)
}
.onChange(of: vpnManager.vpnStatus) { _, newStatus in
withAnimation(.snappy) {
syncState(newStatus)
}
}
}
// VPN
private func syncState(_ status: VPNManager.VPNStatus) {
switch status {
case .connected:
connectState = .connected
case .disconnected:
connectState = .disconnected
@unknown default:
connectState = .disconnected
}
}
}
struct NetworkStatusBar: View {
@Environment(AppContext.self) private var appContext
@State private var vpnManger = VPNManager.shared
@State private var isUpdatingExitNode: Bool = false
@State private var showExitNodeError: Bool = false
@State private var exitNodeErrorMessage: String = ""
var body: some View {
let isOnBinding = Binding(
get: { vpnManger.isConnected },
set: { newValue in
if newValue {
Task {
if self.appContext.networkContext == nil {
try? await self.appContext.connectNetwork()
}
try? await self.appContext.startTun()
}
} else {
Task {
try? await self.appContext.stopTun()
}
}
}
)
HStack(spacing: 12) {
//
HStack(spacing: 20) {
ZStack {
Circle()
.fill(vpnManger.isConnected ? Color.green.opacity(0.15) : Color.primary.opacity(0.05))
.frame(width: 36, height: 36)
Image(systemName: vpnManger.isConnected ? "checkmark.shield.fill" : "shield.slash.fill")
.symbolRenderingMode(.hierarchical)
.foregroundStyle(vpnManger.isConnected ? Color.green : Color.secondary)
.font(.system(size: 16))
}
VStack(alignment: .leading, spacing: 1) {
if let networkSession = appContext.networkSession {
Text(networkSession.networkName)
.font(.system(size: 12, weight: .semibold))
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)
.toggleStyle(.switch)
.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("")
// }
}
.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 {
@Environment(AppContext.self) private var appContext: AppContext
@Binding var showMode: NetworkShowMode
var body: some View {
if showMode == .resource {
//
ScrollView {
LazyVGrid(columns: [
GridItem(.flexible(), spacing: 8),
GridItem(.flexible(), spacing: 8),
GridItem(.flexible(), spacing: 8)
], spacing: 10) {
ForEach(appContext.networkContext?.resourceList ?? [], id: \.uuid) { res in
ResourceItemCard(resource: res)
}
}
.padding(20)
}
.transition(.opacity)
.frame(maxWidth: .infinity)
} else {
//
NetworkDeviceGroupView()
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .opacity))
}
}
}
struct NetworkDisconnectedView: View {
@Environment(AppContext.self) private var appContext: AppContext
@State private var isConnecting: Bool = false
@State private var showAlert: Bool = false
@State private var errorMessage: String = ""
var body: some View {
VStack(spacing: 20) {
Spacer()
Image(systemName: "antenna.radiowaves.left.and.right")
.font(.system(size: 40, weight: .ultraLight))
.foregroundStyle(.tertiary)
.symbolEffect(.pulse, options: .repeating)
Text("尚未接入网络")
.font(.headline)
Button(action: {
Task { @MainActor in
await startConnection()
}
}) {
if isConnecting {
ProgressView()
.controlSize(.small)
.frame(width: 80)
} else {
Text("建立安全连接")
.frame(width: 80)
}
}
.buttonStyle(.borderedProminent)
.disabled(isConnecting)
Spacer()
}
.alert(isPresented: $showAlert) {
Alert(title: Text("提示"), message: Text(errorMessage))
}
}
private func startConnection() async {
self.isConnecting = true
defer {
self.isConnecting = false
}
do {
try await self.appContext.connectNetwork()
try await self.appContext.startTun()
} catch let err as SDLAPIError {
self.showAlert = true
self.errorMessage = err.message
} catch let err as AppContextError {
self.showAlert = true
self.errorMessage = err.message
} catch let err {
self.showAlert = true
self.errorMessage = err.localizedDescription
}
}
}
// MARK: - (NavigationSplitView)
struct NetworkDeviceGroupView: View {
@Environment(AppContext.self) private var appContext: AppContext
@State private var selectedId: Int?
//
private let sidebarWidth: CGFloat = 240
var body: some View {
HStack(spacing: 0) {
// --- 1. (Sidebar) ---
VStack(alignment: .leading, spacing: 0) {
// macOS 绿
// WindowStyle .hiddenTitleBar Padding
Color.clear.frame(height: 28)
List(appContext.networkContext?.nodeList ?? [], id: \.id, selection: $selectedId) { node in
NetworkNodeHeadView(node: node)
// HStack tag List selection
.tag(node.id)
.listRowSeparator(.hidden)
}
.listStyle(.inset) // 使 inset
.scrollContentBackground(.hidden) //
}
.frame(width: sidebarWidth)
Divider() // 线
// --- 2. (Detail) ---
ZStack {
if let selectedNode = appContext.networkContext?.getNode(id: selectedId) {
NetworkNodeDetailView(node: selectedNode)
.transition(.opacity.animation(.easeInOut(duration: 0.2)))
} else {
ContentUnavailableView(
"选择成员设备",
systemImage: "macbook.and.iphone",
description: Text("查看详细网络信息和服务")
)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(nsColor: .windowBackgroundColor)) // 使
}
.ignoresSafeArea() //
.onAppear {
if selectedId == nil {
selectedId = appContext.networkContext?.firstNodeId()
}
}
}
}
// MARK: -
struct NetworkNodeHeadView: View {
var node: SDLAPIClient.NetworkContext.Node
var body: some View {
HStack(spacing: 10) {
Circle()
.fill(node.connectionStatus == "在线" ? Color.green : Color.secondary.opacity(0.4))
.frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 2) {
Text(node.name)
.font(.system(size: 13, weight: .medium))
Text(node.ip)
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.secondary)
}
}
.padding(.vertical, 4)
}
}
struct NetworkNodeDetailView: View {
@Environment(AppContext.self) private var appContext: AppContext
var node: SDLAPIClient.NetworkContext.Node
@State private var resources: [SDLAPIClient.NetworkContext.Resource] = []
@State private var isLoading = false
var body: some View {
List {
Section("节点信息") {
LabeledContent("连接状态", value: node.connectionStatus)
LabeledContent("虚拟IPv4", value: node.ip)
LabeledContent("系统环境", value: node.system ?? "未知")
}
Section("提供的服务") {
if isLoading {
ProgressView()
.controlSize(.small)
} else if resources.isEmpty {
Text("该节点暂未发布资源")
.foregroundColor(.secondary)
.font(.callout)
} else {
ForEach(resources, id: \.id) { res in
VStack(alignment: .leading) {
Text(res.name)
.font(.body)
Text(res.url)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
}
.task {
await loadNodeResources(id: node.id)
}
}
//
private func loadNodeResources(id: Int) async {
guard let session = appContext.networkSession else {
return
}
self.isLoading = true
defer {
self.isLoading = false
}
self.resources = await SDLAPIClient.loadNodeResources(accesToken: session.accessToken, id: id)
}
}
struct ResourceItemCard: View {
let resource: SDLAPIClient.NetworkContext.Resource
@State private var isHovered = false
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Image(systemName: "safari.fill")
.foregroundColor(.accentColor)
.font(.title3)
Text(resource.name)
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)
Text(resource.url)
.font(.caption2)
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
.padding()
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(Color.gray, lineWidth: 1)
)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(Color(isHovered ? NSColor.selectedControlColor : NSColor.controlBackgroundColor))
)
.onHover {
isHovered = $0
}
}
}
struct NetworkWaitAuthView: View {
var body: some View {
VStack(spacing: 16) {
ProgressView()
Text("等待认证确认中...")
.foregroundColor(.secondary)
}
}
}