887 lines
28 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
//
enum NetworkShowMode: String, CaseIterable {
case resource = "访问资源"
case device = "成员设备"
}
// MARK: -
enum NetworkConnectionPhase {
case disconnected
case connecting
case connected
case disconnecting
}
@MainActor
@Observable
final class NetworkModel {
@ObservationIgnored
private weak var appContext: AppContext?
@ObservationIgnored
private let vpnManager = VPNManager.shared
@ObservationIgnored
private var vpnStatusTask: Task<Void, Never>?
var showMode: NetworkShowMode = .resource
var phase: NetworkConnectionPhase = .disconnected
var networkSession: SDLAPIClient.NetworkSession?
var networkContext: SDLAPIClient.NetworkContext?
var selectedExitNodeIp: String?
var selectedNodeId: Int?
private(set) var nodeResourcesById: [Int: [SDLAPIClient.NetworkContext.Resource]] = [:]
private(set) var loadingNodeIDs: Set<Int> = []
private(set) var isUpdatingExitNode: Bool = false
private(set) var errorMessage: String?
deinit {
self.vpnStatusTask?.cancel()
}
var isBusy: Bool {
switch self.phase {
case .connecting, .disconnecting:
return true
case .connected, .disconnected:
return self.isUpdatingExitNode
}
}
var isTunnelEnabled: Bool {
switch self.phase {
case .connecting, .connected:
return true
case .disconnecting, .disconnected:
return false
}
}
var shouldShowModePicker: Bool {
self.phase == .connected
}
var resourceList: [SDLAPIClient.NetworkContext.Resource] {
self.networkContext?.resourceList ?? []
}
var nodeList: [SDLAPIClient.NetworkContext.Node] {
self.networkContext?.nodeList ?? []
}
var selectedNode: SDLAPIClient.NetworkContext.Node? {
self.networkContext?.getNode(id: self.selectedNodeId)
}
var canSelectExitNode: Bool {
guard self.networkSession != nil else {
return false
}
guard !self.isUpdatingExitNode else {
return false
}
switch self.phase {
case .connected:
return self.networkContext != nil || self.selectedExitNodeIp != nil
case .disconnected:
return self.selectedExitNodeIp != nil
case .connecting, .disconnecting:
return false
}
}
var exitNodeOptions: [ExitNodeOption] {
guard let networkContext = self.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
)
}
}
var selectedExitNode: ExitNodeOption? {
guard let selectedExitNodeIp = self.selectedExitNodeIp else {
return nil
}
return self.exitNodeOptions.first(where: { $0.ip == selectedExitNodeIp })
?? ExitNodeOption(
id: -1,
nodeName: "已保存出口节点",
ip: selectedExitNodeIp,
system: nil
)
}
var exitNodeTitle: String {
if self.isUpdatingExitNode {
return "正在切换..."
}
if let selectedExitNode = self.selectedExitNode {
return selectedExitNode.nodeName
}
return "未设置"
}
var exitNodeSubtitle: String {
if let selectedExitNode = self.selectedExitNode {
if let system = selectedExitNode.system, !system.isEmpty {
return "\(selectedExitNode.ip) · \(system)"
}
return selectedExitNode.ip
}
if self.networkContext == nil {
return "连接后可选择"
}
if self.exitNodeOptions.isEmpty {
return "当前网络没有可用节点"
}
return "当前流量保持默认出口"
}
var exitNodeHelpText: String {
if self.isUpdatingExitNode {
return "正在更新出口节点"
}
if self.networkContext == nil {
return "建立连接后可选择当前网络的出口节点"
}
return "切换当前网络流量的出口节点,也可以保持未设置"
}
func activate(appContext: AppContext) async {
if self.appContext !== appContext {
self.appContext = appContext
}
self.startObservingVPNStatusIfNeeded()
await self.handleVPNStatusChange(self.vpnManager.vpnStatus)
}
func clearError() {
self.errorMessage = nil
}
func setConnectionEnabled(_ enabled: Bool) async {
if enabled {
await self.connect()
} else {
await self.disconnect()
}
}
func connect() async {
guard let appContext = self.appContext else {
return
}
guard !self.isBusy else {
return
}
self.errorMessage = nil
self.phase = .connecting
do {
if appContext.networkContext == nil {
try await appContext.connectNetwork()
}
self.syncSharedStateFromAppContext()
await self.applyNetworkContext(appContext.networkContext)
try await appContext.startTun()
if self.vpnManager.vpnStatus == .connected {
self.phase = .connected
}
} catch let err as SDLAPIError {
self.errorMessage = err.message
self.handleDisconnectedState(syncAppContext: true)
} catch let err as AppContextError {
self.errorMessage = err.message
self.handleDisconnectedState(syncAppContext: true)
} catch {
self.errorMessage = error.localizedDescription
self.handleDisconnectedState(syncAppContext: true)
}
}
func disconnect() async {
guard let appContext = self.appContext else {
return
}
guard !self.isBusy else {
return
}
self.errorMessage = nil
self.phase = .disconnecting
do {
try await appContext.stopTun()
self.handleDisconnectedState(syncAppContext: false)
} catch let err as AppContextError {
self.errorMessage = err.message
await self.handleVPNStatusChange(self.vpnManager.vpnStatus)
} catch {
self.errorMessage = error.localizedDescription
await self.handleVPNStatusChange(self.vpnManager.vpnStatus)
}
}
func selectNode(id: Int?) {
self.selectedNodeId = id
guard let id else {
return
}
Task { @MainActor in
await self.loadResourcesIfNeeded(for: id)
}
}
func resources(for nodeId: Int) -> [SDLAPIClient.NetworkContext.Resource] {
self.nodeResourcesById[nodeId] ?? []
}
func isLoadingResources(for nodeId: Int) -> Bool {
self.loadingNodeIDs.contains(nodeId)
}
func loadResourcesIfNeeded(for nodeId: Int) async {
guard let session = self.networkSession else {
return
}
guard self.nodeResourcesById[nodeId] == nil else {
return
}
guard !self.loadingNodeIDs.contains(nodeId) else {
return
}
let currentContextIdentity = self.contextIdentity(self.networkContext)
self.loadingNodeIDs.insert(nodeId)
defer {
self.loadingNodeIDs.remove(nodeId)
}
let resources = await SDLAPIClient.loadNodeResources(accesToken: session.accessToken, id: nodeId)
guard currentContextIdentity == self.contextIdentity(self.networkContext) else {
return
}
self.nodeResourcesById[nodeId] = resources
}
func updateExitNodeSelection(_ ip: String?) async {
guard let appContext = self.appContext else {
return
}
guard !self.isUpdatingExitNode else {
return
}
self.errorMessage = nil
self.isUpdatingExitNode = true
defer {
self.isUpdatingExitNode = false
}
do {
try await appContext.updateExitNodeIp(exitNodeIp: ip)
self.syncSharedStateFromAppContext()
} catch let err as AppContextError {
self.errorMessage = err.message
} catch {
self.errorMessage = error.localizedDescription
}
}
private func startObservingVPNStatusIfNeeded() {
guard self.vpnStatusTask == nil else {
return
}
let vpnStatusStream = self.vpnManager.vpnStatusStream
self.vpnStatusTask = Task { [weak self, vpnStatusStream] in
guard let self else {
return
}
for await status in vpnStatusStream {
if Task.isCancelled {
return
}
await self.handleVPNStatusChange(status)
}
}
}
private func syncSharedStateFromAppContext() {
self.networkSession = self.appContext?.networkSession
self.selectedExitNodeIp = self.appContext?.selectedExitNodeIp
}
private func handleVPNStatusChange(_ status: VPNManager.VPNStatus) async {
self.syncSharedStateFromAppContext()
switch status {
case .connecting:
self.phase = .connecting
await self.applyNetworkContext(self.appContext?.networkContext)
case .connected:
self.phase = .connected
await self.applyNetworkContext(self.appContext?.networkContext)
case .disconnecting:
self.phase = .disconnecting
case .disconnected:
self.handleDisconnectedState(syncAppContext: true)
}
}
private func handleDisconnectedState(syncAppContext: Bool) {
if syncAppContext {
self.appContext?.networkContext = nil
}
self.phase = .disconnected
self.networkContext = nil
self.selectedNodeId = nil
self.nodeResourcesById.removeAll()
self.loadingNodeIDs.removeAll()
self.showMode = .resource
self.syncSharedStateFromAppContext()
}
private func applyNetworkContext(_ newContext: SDLAPIClient.NetworkContext?) async {
let contextChanged = self.contextIdentity(self.networkContext) != self.contextIdentity(newContext)
self.networkContext = newContext
if contextChanged {
self.nodeResourcesById.removeAll()
self.loadingNodeIDs.removeAll()
self.selectedNodeId = nil
}
guard let newContext else {
self.selectedNodeId = nil
return
}
if let selectedNodeId = self.selectedNodeId,
newContext.getNode(id: selectedNodeId) != nil {
await self.loadResourcesIfNeeded(for: selectedNodeId)
return
}
self.selectedNodeId = newContext.firstNodeId()
if let selectedNodeId = self.selectedNodeId {
await self.loadResourcesIfNeeded(for: selectedNodeId)
}
}
private func contextIdentity(_ context: SDLAPIClient.NetworkContext?) -> String? {
guard let context else {
return nil
}
return "\(context.identityId)-\(context.ip)"
}
}
// MARK: -
struct NetworkView: View {
@Environment(AppContext.self) var appContext: AppContext
@Environment(\.openWindow) var openWindow
@State private var networkModel = NetworkModel()
var body: some View {
@Bindable var networkModel = self.networkModel
VStack(spacing: 0) {
// 1. (Header)
HStack(spacing: 16) {
NetworkStatusBar(model: self.networkModel)
Spacer()
if self.networkModel.shouldShowModePicker {
Picker("", selection: $networkModel.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 self.networkModel.phase {
case .connecting, .disconnecting:
NetworkWaitAuthView(phase: self.networkModel.phase)
case .connected:
NetworkConnectedView(model: self.networkModel)
case .disconnected:
NetworkDisconnectedView(model: self.networkModel)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(VisualEffectView(material: .windowBackground, blendingMode: .behindWindow))
}
.frame(minWidth: 700, minHeight: 500) // SplitView
.task {
await self.networkModel.activate(appContext: self.appContext)
}
.alert("提示", isPresented: self.errorPresented) {
Button("确定", role: .cancel) {
self.networkModel.clearError()
}
} message: {
Text(self.networkModel.errorMessage ?? "")
}
}
private var errorPresented: Binding<Bool> {
Binding(
get: { self.networkModel.errorMessage != nil },
set: { isPresented in
if !isPresented {
self.networkModel.clearError()
}
}
)
}
}
struct NetworkStatusBar: View {
var model: NetworkModel
var body: some View {
let isOnBinding = Binding(
get: { self.model.isTunnelEnabled },
set: { newValue in
Task { @MainActor in
await self.model.setConnectionEnabled(newValue)
}
}
)
HStack(spacing: 12) {
//
HStack(spacing: 20) {
ZStack {
Circle()
.fill(self.model.isTunnelEnabled ? Color.green.opacity(0.15) : Color.primary.opacity(0.05))
.frame(width: 36, height: 36)
Image(systemName: self.model.isTunnelEnabled ? "checkmark.shield.fill" : "shield.slash.fill")
.symbolRenderingMode(.hierarchical)
.foregroundStyle(self.model.isTunnelEnabled ? Color.green : Color.secondary)
.font(.system(size: 16))
}
VStack(alignment: .leading, spacing: 1) {
if let networkSession = self.model.networkSession {
Text(networkSession.networkName)
.font(.system(size: 12, weight: .semibold))
Text("局域网IP: \(self.model.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 self.model.networkSession != nil {
exitNodeMenu
}
// Switch
// 使 Binding /
Toggle("", isOn: isOnBinding)
.toggleStyle(.switch)
.controlSize(.small) // macOS 使 small
.disabled(self.model.phase == .connecting || self.model.phase == .disconnecting || self.model.networkSession == nil)
}
.padding(.vertical, 5)
}
private var exitNodeMenu: some View {
Menu {
Button {
Task { @MainActor in
await self.model.updateExitNodeSelection(nil)
}
} label: {
if self.model.selectedExitNode == nil {
Label("不设置出口节点", systemImage: "checkmark")
} else {
Text("不设置出口节点")
}
}
if !self.model.exitNodeOptions.isEmpty {
Divider()
ForEach(self.model.exitNodeOptions) { option in
Button {
Task { @MainActor in
await self.model.updateExitNodeSelection(option.ip)
}
} label: {
if self.model.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(self.model.exitNodeTitle)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.primary)
.lineLimit(1)
Text(self.model.exitNodeSubtitle)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.secondary)
.lineLimit(1)
}
Spacer(minLength: 0)
if self.model.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(!self.model.canSelectExitNode)
.opacity(self.model.canSelectExitNode ? 1 : 0.7)
.help(self.model.exitNodeHelpText)
}
}
struct ExitNodeOption: Identifiable, Equatable {
let id: Int
let nodeName: String
let ip: String
let system: String?
var nodeNameWithIp: String {
"\(nodeName) (\(ip))"
}
}
struct NetworkConnectedView: View {
var model: NetworkModel
var body: some View {
if self.model.showMode == .resource {
//
ScrollView {
LazyVGrid(columns: [
GridItem(.flexible(), spacing: 8),
GridItem(.flexible(), spacing: 8),
GridItem(.flexible(), spacing: 8)
], spacing: 10) {
ForEach(self.model.resourceList, id: \.uuid) { res in
ResourceItemCard(resource: res)
}
}
.padding(20)
}
.transition(.opacity)
.frame(maxWidth: .infinity)
} else {
//
NetworkDeviceGroupView(model: self.model)
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .opacity))
}
}
}
struct NetworkDisconnectedView: View {
var model: NetworkModel
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 self.model.connect()
}
}) {
Text("建立安全连接")
.frame(width: 80)
}
.buttonStyle(.borderedProminent)
.disabled(self.model.phase == .connecting || self.model.networkSession == nil)
Spacer()
}
}
}
// MARK: - (NavigationSplitView)
struct NetworkDeviceGroupView: View {
var model: NetworkModel
//
private let sidebarWidth: CGFloat = 240
var body: some View {
let selectedIdBinding = Binding(
get: { self.model.selectedNodeId },
set: { newValue in
self.model.selectNode(id: newValue)
}
)
HStack(spacing: 0) {
// --- 1. (Sidebar) ---
VStack(alignment: .leading, spacing: 0) {
// macOS 绿
// WindowStyle .hiddenTitleBar Padding
Color.clear.frame(height: 28)
List(self.model.nodeList, id: \.id, selection: selectedIdBinding) { 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 = self.model.selectedNode {
NetworkNodeDetailView(model: self.model, 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() //
}
}
// 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 {
var model: NetworkModel
var node: SDLAPIClient.NetworkContext.Node
var body: some View {
List {
Section("节点信息") {
LabeledContent("连接状态", value: node.connectionStatus)
LabeledContent("虚拟IPv4", value: node.ip)
LabeledContent("系统环境", value: node.system ?? "未知")
}
Section("提供的服务") {
if self.model.isLoadingResources(for: node.id) {
ProgressView()
.controlSize(.small)
} else if self.model.resources(for: node.id).isEmpty {
Text("该节点暂未发布资源")
.foregroundColor(.secondary)
.font(.callout)
} else {
ForEach(self.model.resources(for: node.id), id: \.id) { res in
VStack(alignment: .leading) {
Text(res.name)
.font(.body)
Text(res.url)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
}
.task(id: self.node.id) {
await self.model.loadResourcesIfNeeded(for: self.node.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 phase: NetworkConnectionPhase
var body: some View {
VStack(spacing: 16) {
ProgressView()
Text(self.phase == .disconnecting ? "正在断开网络..." : "正在建立安全连接...")
.foregroundColor(.secondary)
}
}
}