fix network

This commit is contained in:
anlicheng 2026-03-19 17:28:21 +08:00
parent 3558d3c102
commit 2b96a5a8cf

View File

@ -1,11 +1,8 @@
// //
// NetworkView.swift // NetworkView.swift
// punchnet // punchnet
//
// Created by on 2026/1/16.
//
import SwiftUI import SwiftUI
import Observation
// //
enum ConnectState { enum ConnectState {
@ -14,353 +11,313 @@ enum ConnectState {
case disconnected case disconnected
} }
// MARK: -
struct NetworkView: View { struct NetworkView: View {
@Environment(UserContext.self) var userContext: UserContext @Environment(UserContext.self) var userContext
@Environment(AppContext.self) var appContext
@Environment(\.openWindow) private var openWindow @Environment(\.openWindow) private var openWindow
@State private var networkModel = NetworkModel() @State private var networkModel = NetworkModel()
@State private var showMode: ShowMode = .resource @State private var showMode: ShowMode = .resource
//
@State private var connectState: ConnectState = .disconnected @State private var connectState: ConnectState = .disconnected
@State private var isOn: Bool = false @State private var isConnecting: Bool = false
@State private var vpnManager: VPNManager = VPNManager.shared
// private var vpnManager = VPNManager.shared
enum ShowMode {
case resource enum ShowMode: String, CaseIterable {
case device case resource = "访问资源"
case device = "成员设备"
} }
var body: some View { var body: some View {
VStack { VStack(spacing: 0) {
HStack { // 使
headerSection
VStack {
HStack(alignment: .center) {
Text(userContext.networkSession?.networkName ?? "未知")
Text(">")
Spacer()
}
HStack {
Toggle("", isOn: $isOn)
.toggleStyle(SwitchToggleStyle(tint: .green))
.disabled(true)
Text("已连接")
Spacer()
}
}
.frame(width: 320)
if self.connectState == .connected {
//
HStack {
Button {
self.showMode = .resource
} label: {
Text("资源")
}
Button {
self.showMode = .device
} label: {
Text("设备")
}
}
}
Spacer()
}
Group { Divider()
switch self.connectState {
case .waitAuth: //
NetworkWaitAuthView(networkModel: self.networkModel) ZStack {
case .connected: VisualEffectView(material: .windowBackground, blendingMode: .behindWindow)
Group { .ignoresSafeArea()
switch self.showMode {
case .resource: ScrollView {
NetworkResourceGroupView(networkModel: self.networkModel) VStack(spacing: 20) {
case .device: switch connectState {
NetworkDeviceGroupView(networkModel: self.networkModel) case .waitAuth:
statusLoadingView
case .connected:
connectedContent
case .disconnected:
disconnectedContent
} }
} }
case .disconnected: .padding(24)
NetworkDisconnctedView(networkModel: self.networkModel) }
.scrollIndicators(.hidden)
}
}
.frame(minWidth: 550, minHeight: 480)
.onAppear {
syncState(vpnManager.vpnStatus)
}
.onChange(of: vpnManager.vpnStatus) { _, newStatus in
withAnimation(.snappy) {
syncState(newStatus)
}
}
}
}
// MARK: -
extension NetworkView {
private var headerSection: some View {
HStack(spacing: 16) {
//
statusIndicator
VStack(alignment: .leading, spacing: 2) {
Text(userContext.networkSession?.networkName ?? "未连接网络")
.font(.system(size: 14, weight: .semibold))
if connectState == .connected {
Text("虚拟局域网 IP: \(networkModel.networkContext.ip)")
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.secondary)
} else {
Text("PunchNet 服务未就绪").font(.caption).foregroundColor(.secondary)
} }
} }
Spacer() Spacer()
}
.padding(.top, 10) if connectState == .connected {
.padding(.leading, 10) Picker("", selection: $showMode) {
.onChange(of: vpnManager.vpnStatus) { _, newState in ForEach(ShowMode.allCases, id: \.self) { Text($0.rawValue).tag($0) }
NSLog("print view change: \(newState)")
switch newState {
case .connected:
self.connectState = .connected
self.isOn = true
case .disconnected:
self.connectState = .disconnected
self.isOn = false
}
}
.toolbar {
if self.connectState == .connected {
ToolbarItem(placement: .primaryAction) {
Button {
openWindow(id: "settings")
} label: {
Image(systemName: "gearshape")
}
} }
.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, 16)
.background(VisualEffectView(material: .headerView, blendingMode: .withinWindow))
} }
}
//
struct NetworkDisconnctedView: View {
@Bindable var networkModel: NetworkModel
@Environment(AppContext.self) var appContext: AppContext
@Environment(UserContext.self) var userContext: UserContext
@State private var showAlert = false private var statusIndicator: some View {
@State private var errorMessage = ""
var body: some View {
ZStack { ZStack {
Color.clear Circle()
.fill(connectState == .connected ? Color.green.opacity(0.15) : Color.primary.opacity(0.05))
.frame(width: 40, height: 40)
VStack { Image(systemName: connectState == .connected ? "checkmark.shield.fill" : "shield.slash.fill")
Button { .symbolRenderingMode(.hierarchical)
Task { @MainActor in .foregroundStyle(connectState == .connected ? Color.green : Color.secondary)
do { .font(.system(size: 18))
try await self.connect() .symbolEffect(.bounce, value: connectState == .connected)
try await self.startVpn() }
} catch let err { }
self.showAlert = true
self.errorMessage = err.localizedDescription private var connectedContent: some View {
} VStack(spacing: 16) {
if showMode == .resource {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 220), spacing: 16)], spacing: 16) {
ForEach(networkModel.networkContext.resourceList, id: \.id) { res in
ResourceItemCard(resource: res)
} }
} label: {
Text("连接")
.font(.system(size: 14, weight: .regular))
.padding([.top, .bottom], 8)
.padding([.leading, .trailing], 30)
.foregroundColor(.white)
}
.background(Color(red: 74/255, green: 207/255, blue: 154/255))
.cornerRadius(5)
.frame(width: 120, height: 35)
Button {
Task {
try await VPNManager.shared.disableVpn()
}
} label: {
Text("关闭")
.font(.system(size: 14, weight: .regular))
.padding([.top, .bottom], 8)
.padding([.leading, .trailing], 30)
.foregroundColor(.white)
}
.background(Color(red: 74/255, green: 207/255, blue: 154/255))
.cornerRadius(5)
.frame(width: 120, height: 35)
}
}
.alert(isPresented: $showAlert) {
Alert(title: Text("提示"), message: Text(self.errorMessage))
}
}
private func connect() async throws {
guard let networkSession = userContext.networkSession else {
return
}
try await networkModel.connect(networkSession: networkSession)
}
//
private func startVpn() async throws {
guard let networkSession = userContext.networkSession else {
return
}
let networkContext = networkModel.networkContext
let options = SystemConfig.getOptions(networkId: UInt32(networkSession.networkId),
networkDomain: networkSession.networkDomain,
ip: networkContext.ip,
maskLen: networkContext.maskLen,
accessToken: networkSession.accessToken,
identityId: networkContext.identityId,
hostname: networkContext.hostname,
noticePort: self.appContext.noticePort)
// token使token
try await VPNManager.shared.enableVpn(options: options!)
}
}
//
//
struct NetworkResourceGroupView: View {
@Bindable var networkModel: NetworkModel
var body: some View {
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 3), spacing: 8) {
ForEach(self.networkModel.networkContext.resourceList, id: \.id) { resource in
NetworkResourceView(resource: resource)
}
}
}
}
struct NetworkResourceView: View {
var resource: Resource
var body: some View {
VStack {
HStack {
Text(resource.connectionStatus)
Text(resource.name)
.font(.system(size: 14, weight: .regular))
}
Text(resource.url)
.font(.system(size: 14, weight: .regular))
.padding(.leading, 30)
}
}
}
//
struct NetworkDeviceGroupView: View {
@Bindable var networkModel: NetworkModel
@State private var selectedId: Int?
var body: some View {
NavigationSplitView {
List(self.networkModel.networkContext.nodeList, id: \.id, selection: $selectedId) { node in
NetworkNodeHeadView(node: node)
}
.listStyle(.sidebar)
.onChange(of: selectedId) {
self.networkModel.changeSelectedNode(nodeId: selectedId)
}
.onAppear {
if selectedId == nil {
selectedId = self.networkModel.networkContext.nodeList.first?.id
}
}
} detail: {
NetworkNodeDetailView(node: networkModel.selectedNode)
}
}
}
struct NetworkNodeHeadView: View {
var node: Node
var body: some View {
VStack {
HStack {
Text(node.connectionStatus)
Text(node.name)
.font(.system(size: 14, weight: .regular))
}
Text(node.ip)
.font(.system(size: 14, weight: .regular))
.padding(.leading, 30)
}
}
}
struct NetworkNodeDetailView: View {
@Environment(UserContext.self) var userContext: UserContext
var node: Node?
@State private var resources: [Resource] = []
var body: some View {
Group {
if let node {
List {
Section {
HStack {
Text("连接状态")
Text("\(node.connectionStatus)")
Spacer()
}
HStack {
Text("虚拟IPv4")
Text("\(node.ip)")
Spacer()
}
HStack {
Text("操作系统")
Text(node.system ?? "未知")
Spacer()
}
}
Section("服务列表") {
ForEach(resources, id: \.id) { resource in
HStack {
Text("\(resource.name)")
Text("\(resource.url)")
}
}
}
}
.task(id: node.id) {
await self.loadNodeResources(id: node.id)
} }
} else { } else {
EmptyView() VStack(spacing: 0) {
let nodes = networkModel.networkContext.nodeList
ForEach(nodes, id: \.id) { node in
DeviceItemRow(node: node, isLast: node.id == nodes.last?.id)
}
}
.background(Color(NSColor.controlBackgroundColor).opacity(0.4))
.cornerRadius(12)
.overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.primary.opacity(0.08), lineWidth: 1))
} }
} }
} }
private func loadNodeResources(id: Int) async { private var disconnectedContent: some View {
guard let networkSession = userContext.networkSession else { VStack(spacing: 24) {
return Spacer().frame(height: 40)
Image(systemName: "antenna.radiowaves.left.and.right")
.font(.system(size: 48, weight: .ultraLight))
.foregroundStyle(.tertiary)
.symbolEffect(.pulse, options: .repeating)
VStack(spacing: 8) {
Text("尚未接入网络").font(.headline)
Text("连接后即可访问内部资源与成员节点").font(.subheadline).foregroundColor(.secondary)
}
Button(action: { startConnection() }) {
if isConnecting {
ProgressView().controlSize(.small).frame(width: 110)
} else {
Text("建立安全连接").fontWeight(.medium).frame(width: 110)
}
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(isConnecting)
Spacer()
} }
}
let params: [String: Any] = [
"client_id": SystemConfig.getClientId(), private var statusLoadingView: some View {
"access_token": networkSession.accessToken, VStack(spacing: 16) {
"id": id ProgressView()
] Text("正在同步网络状态...").font(.subheadline).foregroundColor(.secondary)
}
if let nodeDetail = try? await SDLAPIClient.doPost(path: "/get_node_resources", params: params, as: NodeDetail.self) { .frame(maxWidth: .infinity, minHeight: 300)
self.resources = nodeDetail.resourceList }
private func syncState(_ status: VPNManager.VPNStatus) {
switch status {
case .connected: connectState = .connected
case .disconnected: connectState = .disconnected
@unknown default: connectState = .disconnected
}
}
private func startConnection() {
isConnecting = true
Task {
do {
guard let session = userContext.networkSession else {
await MainActor.run {
isConnecting = false
}
return
}
try await networkModel.connect(networkSession: session)
let context = networkModel.networkContext
if let options = SystemConfig.getOptions(
networkId: UInt32(session.networkId),
networkDomain: session.networkDomain,
ip: context.ip,
maskLen: context.maskLen,
accessToken: session.accessToken,
identityId: context.identityId,
hostname: context.hostname,
noticePort: appContext.noticePort
) {
try await vpnManager.enableVpn(options: options)
}
} catch {
print("Connection error: \(error)")
}
await MainActor.run { isConnecting = false }
} }
} }
} }
struct NetworkWaitAuthView: View { // MARK: -
@Bindable var networkModel: NetworkModel struct ResourceItemCard: View {
let resource: Resource
@State private var isHovered = false
var body: some View { var body: some View {
Color.clear VStack(alignment: .leading, spacing: 10) {
.overlay { HStack {
Text("等待确认中") Image(systemName: "safari.fill")
.foregroundColor(.accentColor)
.font(.title3)
Spacer()
Circle()
.fill(Color.green)
.frame(width: 6, height: 6)
.shadow(color: .green.opacity(0.5), radius: 2)
} }
Text(resource.name)
.font(.system(size: 13, weight: .semibold))
.lineLimit(1)
Text(resource.url)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.secondary)
.lineLimit(1)
}
.padding(14)
.background(isHovered ? Color.primary.opacity(0.05) : Color(NSColor.controlBackgroundColor).opacity(0.3))
.cornerRadius(12)
.overlay(RoundedRectangle(cornerRadius: 12).stroke(isHovered ? Color.accentColor.opacity(0.3) : Color.primary.opacity(0.08), lineWidth: 1))
.onHover { isHovered = $0 }
.contextMenu {
Button("复制链接") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(resource.url, forType: .string)
}
Button("在浏览器打开") {
if let url = URL(string: resource.url) {
NSWorkspace.shared.open(url)
}
}
}
} }
} }
#Preview { // MARK: -
NetworkView() struct DeviceItemRow: View {
let node: Node
let isLast: Bool
@State private var isHovered = false
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 12) {
Image(systemName: "desktopcomputer")
.font(.title3)
.foregroundStyle(isHovered ? .primary : .secondary)
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)
}
Spacer()
HStack(spacing: 4) {
Text("直连").font(.system(size: 9, weight: .bold))
.padding(.horizontal, 4).padding(.vertical, 1)
.background(Color.accentColor.opacity(0.1)).foregroundColor(.accentColor).cornerRadius(3)
Circle().fill(Color.green).frame(width: 6, height: 6)
}
}
.padding(.vertical, 12)
.padding(.horizontal, 16)
.contentShape(Rectangle())
.background(isHovered ? Color.primary.opacity(0.03) : Color.clear)
.onHover { isHovered = $0 }
if !isLast {
Divider().padding(.leading, 44).opacity(0.5)
}
}
.contextMenu {
Button("复制 IP 地址") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(node.ip, forType: .string)
}
Button("终端 SSH 连接") {
/* */
}
}
}
} }