边缘网关 + Agent 的端口中继:Agent 主动连出到网关,把内网目标挂到网关端口上; 通过邀请链接按设备授权,可选审批,首次连接起计时,到期、闲置自动收回。 - portrelay-server:边缘网关(端口池、白名单与授权租约、邀请与授权网页) - portrelay-agent:Agent(yamux 隧道、本地 API 与网页面板、流量统计) - mac-native:SwiftUI 菜单栏 App
3427 lines
140 KiB
Swift
3427 lines
140 KiB
Swift
import SwiftUI
|
||
import AppKit
|
||
import CoreImage.CIFilterBuiltins
|
||
import Charts
|
||
import UserNotifications
|
||
|
||
// MARK: - Models
|
||
|
||
struct TunnelItem: Identifiable, Codable {
|
||
var id: String
|
||
var name: String
|
||
var target: String
|
||
var listen_port: Int
|
||
var whitelist: [String]
|
||
var enabled: Bool
|
||
var owner: String?
|
||
var duration_minutes: Int?
|
||
var started_at: String?
|
||
var expires_at: String?
|
||
var historical_bytes_in: Int64?
|
||
var historical_bytes_out: Int64?
|
||
var last_duration_sec: Int64?
|
||
var max_bandwidth_mb: Double?
|
||
var auth_code: String?
|
||
var notes: String?
|
||
var metrics: TunnelMetrics?
|
||
var idle_close_minutes: Int?
|
||
var effective_idle_close_minutes: Int?
|
||
var close_reason: String?
|
||
var first_connected_at: String?
|
||
var connected_sec: Int64?
|
||
var last_connected_sec: Int64?
|
||
var activity: TunnelActivityItem?
|
||
var rate_history: RateSeriesItem?
|
||
}
|
||
|
||
/// 近 10 分钟流量曲线:固定 120 个点(5 秒一个),最后一点为当前
|
||
struct RateSeriesItem: Codable {
|
||
var end: Int64
|
||
var step: Int
|
||
var up: [Int64]
|
||
var down: [Int64]
|
||
|
||
var peak: Int64 { max(up.max() ?? 0, down.max() ?? 0) }
|
||
}
|
||
|
||
/// 通道当前使用情况:是否在用、闲置起点、预计自动关闭时间、实际连接时长
|
||
struct TunnelActivityItem: Codable {
|
||
var in_use: Bool
|
||
var idle_since: String?
|
||
var auto_close_at: String?
|
||
var connected_sec: Int64
|
||
}
|
||
|
||
struct TunnelMetrics: Codable {
|
||
var tunnel_id: String?
|
||
var listen_port: Int?
|
||
var active_conns: Int?
|
||
var total_conns: Int64?
|
||
var bytes_in: Int64?
|
||
var bytes_out: Int64?
|
||
var rate_in: Int64?
|
||
var rate_out: Int64?
|
||
var blocked_attempts: Int64?
|
||
var status: String?
|
||
var error: String?
|
||
var dynamic_whitelist: [String]?
|
||
var dynamic_leases: [DynamicLeaseItem]?
|
||
var invites: [InviteItem]?
|
||
var pending: [AccessRequestItem]?
|
||
}
|
||
|
||
struct DynamicLeaseItem: Identifiable, Codable {
|
||
var id: String { ip }
|
||
var ip: String
|
||
var expires_at: String
|
||
var name: String?
|
||
var invite: String?
|
||
var duration: String?
|
||
/// 已授权但尚未首次连接:计时未开始,expires_at 为首次连接截止时间
|
||
var awaiting_connect: Bool?
|
||
var started_at: String?
|
||
}
|
||
|
||
struct InviteUseItem: Codable, Hashable {
|
||
var name: String
|
||
var ip: String
|
||
var at: String
|
||
}
|
||
|
||
struct InviteItem: Identifiable, Codable {
|
||
var id: String { code }
|
||
var code: String
|
||
var note: String?
|
||
var created_at: String
|
||
var expires_at: String
|
||
var max_uses: Int
|
||
/// 通过该邀请授权后可访问多久(每个邀请单独设置)
|
||
var lease_duration: String?
|
||
/// 通过该邀请提交的申请是否需要审批
|
||
var require_approval: Bool?
|
||
var uses: [InviteUseItem]?
|
||
}
|
||
|
||
struct AccessRequestItem: Identifiable, Codable {
|
||
var id: String
|
||
var tunnel_id: String
|
||
var name: String
|
||
var ip: String
|
||
var invite: String?
|
||
var at: String
|
||
var status: String
|
||
}
|
||
|
||
struct InviteShareResp: Codable {
|
||
var invite: InviteItem
|
||
var link: String
|
||
var share_text: String
|
||
}
|
||
|
||
struct ConnectionsResp: Codable {
|
||
var active_conns: [ActiveClientInfo]
|
||
var security_events: [SecurityEventItem]
|
||
}
|
||
|
||
struct ActiveClientInfo: Identifiable, Codable {
|
||
var id: String { conn_id }
|
||
var conn_id: String
|
||
var tunnel_id: String
|
||
var client_ip: String
|
||
var connected_at: String
|
||
var bytes_in: Int64
|
||
var bytes_out: Int64
|
||
var rate_in: Int64
|
||
var rate_out: Int64
|
||
}
|
||
|
||
struct ClientHistoryRecord: Identifiable, Codable {
|
||
var id: String
|
||
var tunnel_id: String
|
||
var tunnel_name: String
|
||
var port: Int
|
||
var client_ip: String
|
||
var connected_at: String
|
||
var disconnected_at: String
|
||
var duration_sec: Int64
|
||
var bytes_in: Int64
|
||
var bytes_out: Int64
|
||
}
|
||
|
||
struct SecurityEventItem: Identifiable, Codable {
|
||
var id: String
|
||
var tunnel_id: String
|
||
var port: Int
|
||
var client_ip: String
|
||
var timestamp: String
|
||
var reason: String
|
||
}
|
||
|
||
struct ServerStatusResp: Codable {
|
||
var connected: Bool
|
||
var server_addr: String
|
||
var port_range: String
|
||
var public_host: String?
|
||
var last_error: String?
|
||
var invite_base: String?
|
||
var total_rate_history: RateSeriesItem?
|
||
}
|
||
|
||
struct ProbeResp: Codable {
|
||
var target: String
|
||
var reachable: Bool
|
||
var rtt: String
|
||
var error: String?
|
||
}
|
||
|
||
struct RecommendPortResp: Codable {
|
||
var recommended_port: Int
|
||
}
|
||
|
||
struct SettingsResp: Codable {
|
||
var server_addr: String
|
||
var token: String
|
||
var public_host: String?
|
||
}
|
||
|
||
// Diagnostic Item Model
|
||
enum DiagnosticStatus {
|
||
case ok
|
||
case warning
|
||
case error
|
||
case checking
|
||
}
|
||
|
||
struct DiagnosticItem: Identifiable {
|
||
let id = UUID()
|
||
let title: String
|
||
let desc: String
|
||
let status: DiagnosticStatus
|
||
let statusText: String
|
||
let actionTitle: String?
|
||
let action: (() -> Void)?
|
||
}
|
||
|
||
// MARK: - Helper Formatting
|
||
|
||
func formatBytes(_ bytes: Int64) -> String {
|
||
if bytes <= 0 { return "0 B" }
|
||
let d = Double(bytes)
|
||
let k = 1024.0
|
||
let sizes = ["B", "KB", "MB", "GB", "TB"]
|
||
let i = Int(log(d) / log(k))
|
||
let clampedI = min(i, sizes.count - 1)
|
||
let val = d / pow(k, Double(clampedI))
|
||
return String(format: "%.1f %@", val, sizes[clampedI])
|
||
}
|
||
|
||
func formatRate(_ bytesPerSec: Int64) -> String {
|
||
return "\(formatBytes(bytesPerSec))/s"
|
||
}
|
||
|
||
func parseISO8601(_ str: String?) -> Date? {
|
||
guard let s = str, !s.isEmpty else { return nil }
|
||
let formatter = ISO8601DateFormatter()
|
||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||
if let d = formatter.date(from: s) { return d }
|
||
formatter.formatOptions = [.withInternetDateTime]
|
||
return formatter.date(from: s)
|
||
}
|
||
|
||
func formatRemainingTime(expiresAt: String?) -> (text: String, isExpiringSoon: Bool) {
|
||
guard let exp = parseISO8601(expiresAt) else {
|
||
return ("持续开启 (无限制)", false)
|
||
}
|
||
let diff = exp.timeIntervalSince(Date())
|
||
if diff <= 0 {
|
||
return ("已自动到期停止", true)
|
||
}
|
||
let totalSec = Int(diff)
|
||
let hours = totalSec / 3600
|
||
let mins = (totalSec % 3600) / 60
|
||
let secs = totalSec % 60
|
||
let isSoon = diff < 300 // less than 5 min
|
||
if hours > 0 {
|
||
return (String(format: "剩余 %02d:%02d:%02d", hours, mins, secs), isSoon)
|
||
} else {
|
||
return (String(format: "剩余 %02d:%02d", mins, secs), isSoon)
|
||
}
|
||
}
|
||
|
||
func formatElapsedTime(startedAt: String?) -> String {
|
||
guard let start = parseISO8601(startedAt) else { return "-" }
|
||
let diff = Date().timeIntervalSince(start)
|
||
if diff < 0 { return "00:00" }
|
||
let totalSec = Int(diff)
|
||
let hours = totalSec / 3600
|
||
let mins = (totalSec % 3600) / 60
|
||
let secs = totalSec % 60
|
||
if hours > 0 {
|
||
return String(format: "%02d:%02d:%02d", hours, mins, secs)
|
||
} else {
|
||
return String(format: "%02d:%02d", mins, secs)
|
||
}
|
||
}
|
||
|
||
func formatSecondsToDuration(_ totalSec: Int64) -> String {
|
||
if totalSec <= 0 { return "0秒" }
|
||
let hours = totalSec / 3600
|
||
let mins = (totalSec % 3600) / 60
|
||
let secs = totalSec % 60
|
||
if hours > 0 {
|
||
return "\(hours)小时\(mins)分"
|
||
} else if mins > 0 {
|
||
return "\(mins)分\(secs)秒"
|
||
} else {
|
||
return "\(secs)秒"
|
||
}
|
||
}
|
||
|
||
/// 生成二维码图片
|
||
func makeQRCode(_ text: String, size: CGFloat = 180) -> NSImage? {
|
||
let filter = CIFilter.qrCodeGenerator()
|
||
filter.message = Data(text.utf8)
|
||
filter.correctionLevel = "M"
|
||
guard let output = filter.outputImage else { return nil }
|
||
let scale = size / output.extent.width
|
||
let scaled = output.transformed(by: CGAffineTransform(scaleX: scale, y: scale))
|
||
let rep = NSCIImageRep(ciImage: scaled)
|
||
let image = NSImage(size: rep.size)
|
||
image.addRepresentation(rep)
|
||
return image
|
||
}
|
||
|
||
let leaseDurationOptions: [(value: String, title: String)] = [
|
||
("1h", "1 小时"), ("2h", "2 小时"), ("8h", "8 小时"), ("today", "当天有效"), ("tunnel", "通道开启期间")
|
||
]
|
||
|
||
func leaseDurationTitle(_ v: String?) -> String {
|
||
leaseDurationOptions.first { $0.value == (v ?? "2h") }?.title ?? "2 小时"
|
||
}
|
||
|
||
func copyToPasteboard(_ text: String) {
|
||
NSPasteboard.general.clearContents()
|
||
NSPasteboard.general.setString(text, forType: .string)
|
||
}
|
||
|
||
// MARK: - Shared UI Components
|
||
|
||
/// 列表行统一卡片样式(tint 用于需要强调的行,如待审批)
|
||
struct RowCard: ViewModifier {
|
||
var tint: Color? = nil
|
||
func body(content: Content) -> some View {
|
||
content
|
||
.background(RoundedRectangle(cornerRadius: 8).fill(tint.map { $0.opacity(0.08) } ?? Color.primary.opacity(0.035)))
|
||
.overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(tint.map { $0.opacity(0.35) } ?? Color.primary.opacity(0.08), lineWidth: 1))
|
||
}
|
||
}
|
||
|
||
extension View {
|
||
func rowCard(tint: Color? = nil) -> some View { modifier(RowCard(tint: tint)) }
|
||
}
|
||
|
||
/// 流式布局:一行放不下时自动换行(时间口径与状态标签共用一行,空间不够再折行)
|
||
struct FlowLayout: Layout {
|
||
var spacing: CGFloat = 14
|
||
var lineSpacing: CGFloat = 6
|
||
|
||
private func rows(_ subviews: Subviews, maxWidth: CGFloat) -> [[(Int, CGSize)]] {
|
||
var rows: [[(Int, CGSize)]] = [[]]
|
||
var x: CGFloat = 0
|
||
for (i, v) in subviews.enumerated() {
|
||
let size = v.sizeThatFits(.unspecified)
|
||
let needed = rows[rows.count - 1].isEmpty ? size.width : x + spacing + size.width
|
||
if !rows[rows.count - 1].isEmpty && needed > maxWidth {
|
||
rows.append([])
|
||
x = size.width
|
||
} else {
|
||
x = needed
|
||
}
|
||
rows[rows.count - 1].append((i, size))
|
||
}
|
||
return rows
|
||
}
|
||
|
||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||
let maxWidth = proposal.width ?? .infinity
|
||
let rs = rows(subviews, maxWidth: maxWidth)
|
||
var width: CGFloat = 0
|
||
var height: CGFloat = 0
|
||
for (n, row) in rs.enumerated() where !row.isEmpty {
|
||
let w = row.map { $0.1.width }.reduce(0, +) + spacing * CGFloat(row.count - 1)
|
||
width = max(width, w)
|
||
height += (row.map { $0.1.height }.max() ?? 0) + (n > 0 ? lineSpacing : 0)
|
||
}
|
||
return CGSize(width: proposal.width ?? width, height: height)
|
||
}
|
||
|
||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||
var y = bounds.minY
|
||
for row in rows(subviews, maxWidth: bounds.width) where !row.isEmpty {
|
||
let h = row.map { $0.1.height }.max() ?? 0
|
||
var x = bounds.minX
|
||
for (i, size) in row {
|
||
subviews[i].place(at: CGPoint(x: x, y: y + (h - size.height) / 2), proposal: ProposedViewSize(size))
|
||
x += size.width + spacing
|
||
}
|
||
y += h + lineSpacing
|
||
}
|
||
}
|
||
}
|
||
|
||
let downColor = Color.blue
|
||
let upColor = Color.teal
|
||
|
||
/// 流量曲线(面积 + 平滑线),纵轴随峰值自动缩放
|
||
struct RateSparkline: View {
|
||
let series: RateSeriesItem?
|
||
var height: CGFloat = 40
|
||
var showsArea: Bool = true
|
||
|
||
struct Point: Identifiable {
|
||
let id: String
|
||
let i: Int
|
||
let v: Double
|
||
let dir: String
|
||
}
|
||
|
||
var points: [Point] {
|
||
guard let s = series else { return [] }
|
||
var out: [Point] = []
|
||
for (i, v) in s.down.enumerated() { out.append(Point(id: "d\(i)", i: i, v: Double(v), dir: "down")) }
|
||
for (i, v) in s.up.enumerated() { out.append(Point(id: "u\(i)", i: i, v: Double(v), dir: "up")) }
|
||
return out
|
||
}
|
||
|
||
var body: some View {
|
||
let peak = Double(series?.peak ?? 0)
|
||
let count = max((series?.down.count ?? 120) - 1, 1)
|
||
Chart(points) { p in
|
||
if showsArea {
|
||
AreaMark(x: .value("t", p.i), y: .value("rate", p.v), series: .value("dir", p.dir), stacking: .unstacked)
|
||
.interpolationMethod(.monotone)
|
||
.foregroundStyle(LinearGradient(
|
||
colors: [(p.dir == "down" ? downColor : upColor).opacity(0.30), (p.dir == "down" ? downColor : upColor).opacity(0.02)],
|
||
startPoint: .top, endPoint: .bottom))
|
||
}
|
||
LineMark(x: .value("t", p.i), y: .value("rate", p.v), series: .value("dir", p.dir))
|
||
.interpolationMethod(.monotone)
|
||
.lineStyle(StrokeStyle(lineWidth: 1.4, lineCap: .round, lineJoin: .round))
|
||
.foregroundStyle(p.dir == "down" ? downColor : upColor)
|
||
}
|
||
.chartXScale(domain: 0...count)
|
||
.chartYScale(domain: 0...max(peak * 1.18, 1024))
|
||
.chartXAxis(.hidden)
|
||
.chartYAxis(.hidden)
|
||
.chartLegend(.hidden)
|
||
.frame(height: height)
|
||
}
|
||
}
|
||
|
||
/// 当前速率图例:● ↓ 下行 ● ↑ 上行
|
||
struct RateLegend: View {
|
||
let down: Int64
|
||
let up: Int64
|
||
|
||
var body: some View {
|
||
HStack(spacing: 10) {
|
||
item(downColor, "↓ \(formatRate(down))")
|
||
item(upColor, "↑ \(formatRate(up))")
|
||
}
|
||
.lineLimit(1)
|
||
}
|
||
|
||
func item(_ color: Color, _ text: String) -> some View {
|
||
HStack(spacing: 4) {
|
||
Circle().fill(color).frame(width: 6, height: 6)
|
||
Text(verbatim: text)
|
||
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 卡片内的流量曲线:不加背景,直接融入卡片;底部一条淡基线 + 时间范围与峰值
|
||
struct InlineTrafficChart: View {
|
||
let series: RateSeriesItem?
|
||
|
||
var body: some View {
|
||
let peak = series?.peak ?? 0
|
||
VStack(spacing: 3) {
|
||
ZStack(alignment: .bottom) {
|
||
Rectangle()
|
||
.fill(Color.primary.opacity(0.10))
|
||
.frame(height: 1)
|
||
if peak > 0 {
|
||
RateSparkline(series: series, height: 44)
|
||
} else {
|
||
Text("近 10 分钟无流量")
|
||
.font(.system(size: 10))
|
||
.foregroundColor(.secondary)
|
||
.frame(maxHeight: .infinity)
|
||
}
|
||
}
|
||
.frame(height: 44)
|
||
HStack {
|
||
Text("近 10 分钟")
|
||
Spacer(minLength: 0)
|
||
if peak > 0 {
|
||
Text(verbatim: "峰值 \(formatRate(peak))")
|
||
}
|
||
}
|
||
.font(.system(size: 10))
|
||
.foregroundColor(.secondary)
|
||
.monospacedDigit()
|
||
.lineLimit(1)
|
||
}
|
||
}
|
||
}
|
||
|
||
struct CardWidthKey: PreferenceKey {
|
||
static var defaultValue: CGFloat = 0
|
||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() }
|
||
}
|
||
|
||
/// 小号胶囊标签
|
||
struct TagView: View {
|
||
let text: String
|
||
var color: Color = .secondary
|
||
|
||
init(_ text: String, color: Color = .secondary) {
|
||
self.text = text
|
||
self.color = color
|
||
}
|
||
|
||
var body: some View {
|
||
Text(verbatim: text)
|
||
.font(.system(size: 10, weight: .medium))
|
||
.lineLimit(1)
|
||
.padding(.horizontal, 5)
|
||
.padding(.vertical, 1.5)
|
||
.background(color.opacity(0.13))
|
||
.foregroundColor(color == .secondary ? .primary.opacity(0.75) : color)
|
||
.cornerRadius(4)
|
||
}
|
||
}
|
||
|
||
/// 上下行速率:固定宽度 + 等宽数字,避免数值变化时挤压左侧内容导致换行或跳动
|
||
struct RateText: View {
|
||
let up: Int64
|
||
let down: Int64
|
||
var size: CGFloat = 11
|
||
|
||
var body: some View {
|
||
Text(verbatim: "↑ \(formatRate(up)) ↓ \(formatRate(down))")
|
||
.font(.system(size: size, design: .monospaced))
|
||
.foregroundColor(.secondary)
|
||
.lineLimit(1)
|
||
.frame(width: size * 18.5, alignment: .trailing)
|
||
}
|
||
}
|
||
|
||
/// 统一的选项按钮(开启时长、限速、授权时长、邀请选项)
|
||
struct ChipLabel: View {
|
||
let title: String
|
||
let selected: Bool
|
||
var tint: Color = .accentColor
|
||
|
||
var body: some View {
|
||
Text(verbatim: title)
|
||
.font(.system(size: 11, weight: selected ? .semibold : .regular))
|
||
.lineLimit(1)
|
||
.padding(.horizontal, 8)
|
||
.padding(.vertical, 3)
|
||
.background(selected ? tint.opacity(0.15) : Color(NSColor.controlBackgroundColor))
|
||
.foregroundColor(selected ? tint : .primary)
|
||
.cornerRadius(4)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 4)
|
||
.stroke(selected ? tint.opacity(0.8) : Color(NSColor.separatorColor), lineWidth: 1)
|
||
)
|
||
}
|
||
}
|
||
|
||
struct StringChip: View {
|
||
let title: String
|
||
let value: String
|
||
@Binding var selection: String
|
||
|
||
var body: some View {
|
||
Button(action: { selection = value }) {
|
||
ChipLabel(title: title, selected: selection == value)
|
||
}
|
||
.buttonStyle(.borderless)
|
||
}
|
||
}
|
||
|
||
// MARK: - Tunnel Search
|
||
|
||
/// 通道搜索:多个关键字(空格分隔)需全部命中;不区分大小写;中文支持全拼与拼音首字母
|
||
@MainActor
|
||
enum TunnelSearch {
|
||
private static var pinyinCache: [String: (full: String, initials: String)] = [:]
|
||
|
||
/// "MySQL 数据库" -> ("mysqlshujuku", "msjk")
|
||
static func pinyin(_ text: String) -> (full: String, initials: String) {
|
||
if let hit = pinyinCache[text] { return hit }
|
||
let latin = (text.applyingTransform(.toLatin, reverse: false) ?? text)
|
||
.applyingTransform(.stripDiacritics, reverse: false)?.lowercased() ?? text.lowercased()
|
||
let words = latin.split(whereSeparator: { !$0.isLetter && !$0.isNumber })
|
||
let result = (words.joined(), String(words.compactMap { $0.first }))
|
||
if pinyinCache.count > 2000 { pinyinCache.removeAll() }
|
||
pinyinCache[text] = result
|
||
return result
|
||
}
|
||
|
||
static func fields(_ t: TunnelItem, publicHost: String) -> [String] {
|
||
var f: [String] = [t.name, t.target, "\(t.listen_port)", "\(publicHost):\(t.listen_port)",
|
||
t.owner ?? "", t.notes ?? "", t.whitelist.joined(separator: " ")]
|
||
f.append(t.enabled ? "运行中 开启" : "已暂停 关闭")
|
||
f.append(t.whitelist.isEmpty ? "仅授权链接" : (t.whitelist.contains("*") ? "全部放通" : "固定ip 白名单"))
|
||
f += (t.metrics?.dynamic_leases ?? []).compactMap { $0.name }
|
||
f += (t.metrics?.dynamic_leases ?? []).map { $0.ip }
|
||
f += (t.metrics?.invites ?? []).compactMap { $0.note }
|
||
return f.filter { !$0.isEmpty }
|
||
}
|
||
|
||
static func matches(_ t: TunnelItem, query: String, publicHost: String) -> Bool {
|
||
let tokens = query.lowercased().split(whereSeparator: { $0.isWhitespace }).map(String.init)
|
||
if tokens.isEmpty { return true }
|
||
let fs = fields(t, publicHost: publicHost)
|
||
let plain = fs.joined(separator: " ").lowercased()
|
||
// 拼音只对可能含中文的字段计算(名称、责任人、备注、授权人姓名)
|
||
let py = ([t.name, t.owner ?? "", t.notes ?? ""] + (t.metrics?.dynamic_leases ?? []).compactMap { $0.name })
|
||
.filter { !$0.isEmpty }
|
||
.map { pinyin($0) }
|
||
return tokens.allSatisfy { tok in
|
||
plain.contains(tok) || py.contains { $0.full.contains(tok) || $0.initials.contains(tok) }
|
||
}
|
||
}
|
||
}
|
||
|
||
enum TunnelStatusFilter: String, CaseIterable {
|
||
case all = "全部", running = "运行中", paused = "已暂停"
|
||
|
||
func accepts(_ t: TunnelItem) -> Bool {
|
||
switch self {
|
||
case .all: return true
|
||
case .running: return t.enabled
|
||
case .paused: return !t.enabled
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 从 "ip:port" / "[ipv6]:port" 中取出 IP
|
||
func hostOf(_ addr: String) -> String {
|
||
if addr.hasPrefix("["), let end = addr.firstIndex(of: "]") {
|
||
return String(addr[addr.index(after: addr.startIndex)..<end])
|
||
}
|
||
if addr.filter({ $0 == ":" }).count == 1, let idx = addr.lastIndex(of: ":") {
|
||
return String(addr[..<idx])
|
||
}
|
||
return addr
|
||
}
|
||
|
||
func portOf(_ addr: String) -> String {
|
||
guard let idx = addr.lastIndex(of: ":"), addr.filter({ $0 == ":" }).count == 1 || addr.hasPrefix("[") else { return "" }
|
||
return String(addr[addr.index(after: idx)...])
|
||
}
|
||
|
||
/// 同一 IP 的在线连接合并展示
|
||
struct ClientIPGroup: Identifiable {
|
||
let ip: String
|
||
let conns: [ActiveClientInfo]
|
||
var id: String { ip }
|
||
var bytesIn: Int64 { conns.reduce(0) { $0 + $1.bytes_in } }
|
||
var bytesOut: Int64 { conns.reduce(0) { $0 + $1.bytes_out } }
|
||
var rateIn: Int64 { conns.reduce(0) { $0 + $1.rate_in } }
|
||
var rateOut: Int64 { conns.reduce(0) { $0 + $1.rate_out } }
|
||
var firstConnectedAt: String? { conns.map { $0.connected_at }.min() }
|
||
}
|
||
|
||
/// 距现在的秒数(正数表示过去)
|
||
func secondsSince(_ str: String?) -> Int64 {
|
||
guard let d = parseISO8601(str) else { return 0 }
|
||
return Int64(Date().timeIntervalSince(d))
|
||
}
|
||
|
||
let idleCloseOptions: [(Int, String)] = [(15, "15 分钟"), (30, "30 分钟"), (60, "1 小时"), (120, "2 小时"), (0, "不自动关闭")]
|
||
|
||
func idleCloseTitle(_ minutes: Int) -> String {
|
||
idleCloseOptions.first { $0.0 == minutes }?.1 ?? "\(minutes) 分钟"
|
||
}
|
||
|
||
/// 今天显示 HH:mm:ss,否则显示 MM-dd HH:mm
|
||
func formatDateTimeShort(_ str: String?) -> String {
|
||
guard let d = parseISO8601(str) else { return "-" }
|
||
let df = DateFormatter()
|
||
df.dateFormat = Calendar.current.isDateInToday(d) ? "HH:mm:ss" : "MM-dd HH:mm"
|
||
return df.string(from: d)
|
||
}
|
||
|
||
/// 同一 IP 的历史连接合并展示
|
||
struct HistoryIPGroup: Identifiable {
|
||
let ip: String
|
||
let records: [ClientHistoryRecord] // 按接入时间倒序
|
||
var id: String { ip }
|
||
var bytesIn: Int64 { records.reduce(0) { $0 + $1.bytes_in } }
|
||
var bytesOut: Int64 { records.reduce(0) { $0 + $1.bytes_out } }
|
||
var totalDuration: Int64 { records.reduce(0) { $0 + $1.duration_sec } }
|
||
var lastSeen: String? { records.map { $0.disconnected_at }.max() }
|
||
var firstSeen: String? { records.map { $0.connected_at }.min() }
|
||
}
|
||
|
||
func formatTimeHHmm(_ str: String?) -> String {
|
||
guard let d = parseISO8601(str) else { return "-" }
|
||
let df = DateFormatter()
|
||
df.dateFormat = "HH:mm:ss"
|
||
return df.string(from: d)
|
||
}
|
||
|
||
// MARK: - ViewModel
|
||
|
||
@MainActor
|
||
class PortRelayViewModel: ObservableObject {
|
||
static let shared = PortRelayViewModel()
|
||
weak var appDelegate: AppDelegate?
|
||
|
||
@Published var isConnected: Bool = false
|
||
@Published var agentReachable: Bool = true
|
||
@Published var lastError: String = ""
|
||
@Published var serverAddr: String = ""
|
||
/// 访问者连接通道时使用的网关地址(设置中可修改)
|
||
@Published var publicHost: String = ""
|
||
/// 邀请链接前缀 http://<对外访问地址>:<控制端口>/i/
|
||
@Published var inviteBase: String = ""
|
||
/// 所有通道合计的近 10 分钟流量曲线
|
||
@Published var totalRateHistory: RateSeriesItem? = nil
|
||
/// 已提醒过的审批申请,避免重复通知
|
||
private var notifiedRequests: Set<String> = []
|
||
|
||
var pendingRequests: [(tunnel: TunnelItem, request: AccessRequestItem)] {
|
||
tunnels.flatMap { t in (t.metrics?.pending ?? []).map { (t, $0) } }
|
||
}
|
||
|
||
/// 服务器控制端口(网页授权链接使用),取自控制地址
|
||
var controlPort: String {
|
||
if let idx = serverAddr.lastIndex(of: ":") {
|
||
let port = serverAddr[serverAddr.index(after: idx)...]
|
||
if !port.isEmpty { return String(port) }
|
||
}
|
||
return "33000"
|
||
}
|
||
@Published var tunnels: [TunnelItem] = []
|
||
@Published var activeConns: [ActiveClientInfo] = []
|
||
@Published var securityEvents: [SecurityEventItem] = []
|
||
|
||
@Published var totalActiveConns: Int = 0
|
||
@Published var totalHistoricalConns: Int64 = 0
|
||
@Published var totalRateIn: Int64 = 0
|
||
@Published var totalRateOut: Int64 = 0
|
||
@Published var totalTransferred: Int64 = 0
|
||
@Published var totalBlockedAttempts: Int64 = 0
|
||
|
||
// Diagnostics
|
||
@Published var diagnostics: [DiagnosticItem] = []
|
||
@Published var isRunningDiagnostics: Bool = false
|
||
|
||
private var timer: Timer?
|
||
private var agentProcess: Process?
|
||
private var agentFailures: Int = 0
|
||
private var lastAgentLaunch: Date = .distantPast
|
||
private var lastMenuSignature: String = ""
|
||
|
||
/// preview = true 时不启动后台引擎、不轮询、不请求通知权限(用于界面预览与离屏渲染检查)
|
||
/// 预览模式:网络调用返回下面的模拟数据
|
||
private(set) var isPreview = false
|
||
var previewHistory: [ClientHistoryRecord] = []
|
||
|
||
init(preview: Bool = false) {
|
||
isPreview = preview
|
||
guard !preview else { return }
|
||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in }
|
||
startAgentIfNeeded()
|
||
startPolling()
|
||
runDiagnostics()
|
||
setupSleepWakeNotifications()
|
||
}
|
||
|
||
private func setupSleepWakeNotifications() {
|
||
NSWorkspace.shared.notificationCenter.addObserver(
|
||
forName: NSWorkspace.didWakeNotification,
|
||
object: nil,
|
||
queue: .main
|
||
) { [weak self] _ in
|
||
Task { @MainActor [weak self] in
|
||
self?.handleSystemWake()
|
||
}
|
||
}
|
||
NSWorkspace.shared.notificationCenter.addObserver(
|
||
forName: NSWorkspace.screensDidWakeNotification,
|
||
object: nil,
|
||
queue: .main
|
||
) { [weak self] _ in
|
||
Task { @MainActor [weak self] in
|
||
self?.handleSystemWake()
|
||
}
|
||
}
|
||
}
|
||
|
||
func handleSystemWake() {
|
||
Task {
|
||
// 唤醒时只在会话失效时重连;屏幕唤醒等场景不再切断所有活跃连接
|
||
await triggerReconnect(force: false)
|
||
await refreshData()
|
||
runDiagnostics()
|
||
}
|
||
}
|
||
|
||
/// 彻底退出:通知后台引擎结束(包括不是由本 App 拉起的实例)
|
||
func shutdownAgent() async {
|
||
if let url = URL(string: "http://127.0.0.1:33080/api/shutdown") {
|
||
var req = URLRequest(url: url)
|
||
req.httpMethod = "POST"
|
||
req.timeoutInterval = 2
|
||
_ = try? await URLSession.shared.data(for: req)
|
||
}
|
||
if let p = agentProcess, p.isRunning {
|
||
p.terminate()
|
||
}
|
||
}
|
||
|
||
func triggerReconnect(force: Bool = true) async {
|
||
let endpoint = force ? "http://127.0.0.1:33080/api/reconnect" : "http://127.0.0.1:33080/api/reconnect?mode=check"
|
||
guard let url = URL(string: endpoint) else { return }
|
||
var req = URLRequest(url: url)
|
||
req.httpMethod = "POST"
|
||
_ = try? await URLSession.shared.data(for: req)
|
||
}
|
||
|
||
func startAgentIfNeeded() {
|
||
let url = URL(string: "http://127.0.0.1:33080/api/status")!
|
||
var req = URLRequest(url: url)
|
||
req.timeoutInterval = 0.5
|
||
URLSession.shared.dataTask(with: req) { [weak self] _, _, err in
|
||
if err != nil {
|
||
Task { @MainActor in
|
||
self?.launchAgentProcess()
|
||
}
|
||
}
|
||
}.resume()
|
||
}
|
||
|
||
private func launchAgentProcess() {
|
||
if let p = agentProcess, p.isRunning { return }
|
||
lastAgentLaunch = Date()
|
||
let bundleURL = Bundle.main.bundleURL
|
||
let candidatePaths = [
|
||
bundleURL.appendingPathComponent("Contents/Resources/portrelay-agent").path,
|
||
bundleURL.deletingLastPathComponent().appendingPathComponent("bin/portrelay-agent").path
|
||
]
|
||
|
||
var execPath: String?
|
||
for path in candidatePaths {
|
||
if FileManager.default.fileExists(atPath: path) {
|
||
execPath = path
|
||
break
|
||
}
|
||
}
|
||
|
||
guard let agentPath = execPath else { return }
|
||
|
||
let p = Process()
|
||
p.executableURL = URL(fileURLWithPath: agentPath)
|
||
p.arguments = ["-no-browser"]
|
||
try? p.run()
|
||
self.agentProcess = p
|
||
}
|
||
|
||
func startPolling() {
|
||
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
||
Task { @MainActor in
|
||
await self?.refreshData()
|
||
}
|
||
}
|
||
Task {
|
||
await refreshData()
|
||
}
|
||
}
|
||
|
||
func refreshData() async {
|
||
await fetchStatus()
|
||
await fetchTunnels()
|
||
await fetchConnections()
|
||
let onlineCount = tunnels.filter { $0.enabled }.count
|
||
appDelegate?.updateStatusItemTitle(activeCount: onlineCount)
|
||
}
|
||
|
||
/// 新的审批申请弹出系统通知
|
||
private func notifyNewRequests() {
|
||
for (t, r) in pendingRequests where !notifiedRequests.contains(r.id) {
|
||
notifiedRequests.insert(r.id)
|
||
let content = UNMutableNotificationContent()
|
||
content.title = "访问申请:\(t.name)"
|
||
content.body = "\(r.name)(\(r.ip))申请访问,打开 PortRelay 菜单栏或授权管理进行审批"
|
||
content.sound = .default
|
||
UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: r.id, content: content, trigger: nil))
|
||
}
|
||
}
|
||
|
||
/// 菜单内容有变化时才需要重建(避免每秒重建打断已展开的菜单)
|
||
func menuNeedsRebuild() -> Bool {
|
||
let sig = "\(isConnected)|" + tunnels.map { "\($0.id):\($0.name):\($0.listen_port):\($0.enabled)" }.joined(separator: ",")
|
||
+ "|" + pendingRequests.map { $0.request.id }.joined(separator: ",")
|
||
if sig == lastMenuSignature { return false }
|
||
lastMenuSignature = sig
|
||
return true
|
||
}
|
||
|
||
func fetchStatus() async {
|
||
guard let url = URL(string: "http://127.0.0.1:33080/api/status") else { return }
|
||
do {
|
||
let (data, _) = try await URLSession.shared.data(from: url)
|
||
if let resp = try? JSONDecoder().decode(ServerStatusResp.self, from: data) {
|
||
let wasConnected = self.isConnected
|
||
self.isConnected = resp.connected
|
||
self.serverAddr = resp.server_addr
|
||
if let host = resp.public_host, !host.isEmpty { self.publicHost = host }
|
||
self.inviteBase = resp.invite_base ?? ""
|
||
self.totalRateHistory = resp.total_rate_history
|
||
self.lastError = resp.last_error ?? ""
|
||
self.agentReachable = true
|
||
self.agentFailures = 0
|
||
if wasConnected != resp.connected {
|
||
runDiagnostics()
|
||
}
|
||
}
|
||
} catch {
|
||
let wasConnected = self.isConnected
|
||
self.isConnected = false
|
||
self.agentReachable = false
|
||
self.lastError = "本地后台引擎 (portrelay-agent) 未响应"
|
||
if wasConnected { runDiagnostics() }
|
||
// 后台引擎崩溃或被结束:连续失败后自动拉起(10 秒内最多一次)
|
||
agentFailures += 1
|
||
if agentFailures >= 3 && Date().timeIntervalSince(lastAgentLaunch) > 10 {
|
||
launchAgentProcess()
|
||
}
|
||
}
|
||
}
|
||
|
||
func fetchTunnels() async {
|
||
guard let url = URL(string: "http://127.0.0.1:33080/api/tunnels") else { return }
|
||
do {
|
||
let (data, _) = try await URLSession.shared.data(from: url)
|
||
let items = try JSONDecoder().decode([TunnelItem].self, from: data)
|
||
self.tunnels = items
|
||
|
||
var aConns = 0
|
||
var hConns: Int64 = 0
|
||
var rIn: Int64 = 0
|
||
var rOut: Int64 = 0
|
||
var bytes: Int64 = 0
|
||
var blocked: Int64 = 0
|
||
|
||
for t in items {
|
||
let histBytes = (t.historical_bytes_in ?? 0) + (t.historical_bytes_out ?? 0)
|
||
bytes += histBytes
|
||
if let m = t.metrics {
|
||
aConns += (m.active_conns ?? 0)
|
||
hConns += (m.total_conns ?? 0)
|
||
rIn += (m.rate_in ?? 0)
|
||
rOut += (m.rate_out ?? 0)
|
||
bytes += ((m.bytes_in ?? 0) + (m.bytes_out ?? 0))
|
||
blocked += (m.blocked_attempts ?? 0)
|
||
}
|
||
}
|
||
|
||
self.totalActiveConns = aConns
|
||
self.totalHistoricalConns = hConns
|
||
self.totalRateIn = rIn
|
||
self.totalRateOut = rOut
|
||
self.totalTransferred = bytes
|
||
self.totalBlockedAttempts = blocked
|
||
notifyNewRequests()
|
||
} catch {
|
||
// 拿不到最新数据时不能继续展示旧的“运行中”和速率
|
||
self.tunnels = self.tunnels.map { var t = $0; t.metrics = nil; return t }
|
||
self.activeConns = []
|
||
self.totalActiveConns = 0
|
||
self.totalRateIn = 0
|
||
self.totalRateOut = 0
|
||
}
|
||
}
|
||
|
||
/// enabled 为 nil 时取反;传入目标状态时幂等(避免重复点击来回翻转)
|
||
func toggleTunnel(id: String, enabled: Bool? = nil) async {
|
||
guard let url = URL(string: "http://127.0.0.1:33080/api/tunnels/\(id)/toggle") else { return }
|
||
var req = URLRequest(url: url)
|
||
req.httpMethod = "POST"
|
||
if let enabled = enabled {
|
||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||
req.httpBody = try? JSONSerialization.data(withJSONObject: ["enabled": enabled])
|
||
}
|
||
_ = try? await URLSession.shared.data(for: req)
|
||
await refreshData()
|
||
}
|
||
|
||
func deleteTunnel(id: String) async {
|
||
guard let url = URL(string: "http://127.0.0.1:33080/api/tunnels/\(id)") else { return }
|
||
var req = URLRequest(url: url)
|
||
req.httpMethod = "DELETE"
|
||
_ = try? await URLSession.shared.data(for: req)
|
||
await refreshData()
|
||
}
|
||
|
||
func saveTunnel(
|
||
id: String?,
|
||
name: String,
|
||
target: String,
|
||
port: Int,
|
||
whitelist: [String],
|
||
owner: String,
|
||
durationMinutes: Int,
|
||
idleCloseMinutes: Int,
|
||
maxBandwidthMB: Double
|
||
) async -> (Bool, String?, String?) {
|
||
let isEdit = (id != nil && !id!.isEmpty)
|
||
let endpoint = isEdit ? "http://127.0.0.1:33080/api/tunnels/\(id!)" : "http://127.0.0.1:33080/api/tunnels"
|
||
guard let url = URL(string: endpoint) else { return (false, "无效地址", nil) }
|
||
|
||
var req = URLRequest(url: url)
|
||
req.httpMethod = isEdit ? "PUT" : "POST"
|
||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||
|
||
let body: [String: Any] = [
|
||
"name": name,
|
||
"target": target,
|
||
"listen_port": port,
|
||
// 空数组表示“仅授权链接”
|
||
"whitelist": whitelist,
|
||
"owner": owner,
|
||
"duration_minutes": durationMinutes,
|
||
"idle_close_minutes": idleCloseMinutes,
|
||
"max_bandwidth_mb": maxBandwidthMB
|
||
]
|
||
|
||
guard let httpBody = try? JSONSerialization.data(withJSONObject: body) else {
|
||
return (false, "JSON 序列化失败", nil)
|
||
}
|
||
req.httpBody = httpBody
|
||
|
||
do {
|
||
let (data, resp) = try await URLSession.shared.data(for: req)
|
||
if let httpResp = resp as? HTTPURLResponse, httpResp.statusCode >= 400 {
|
||
if let errJson = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||
let errMsg = errJson["error"] as? String {
|
||
return (false, errMsg, nil)
|
||
}
|
||
return (false, "保存失败", nil)
|
||
}
|
||
let savedId = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["id"] as? String
|
||
await refreshData()
|
||
return (true, nil, savedId)
|
||
} catch {
|
||
return (false, error.localizedDescription, nil)
|
||
}
|
||
}
|
||
|
||
func probeTarget(target: String) async -> (Bool, String) {
|
||
guard let url = URL(string: "http://127.0.0.1:33080/api/probe") else { return (false, "无效地址") }
|
||
var req = URLRequest(url: url)
|
||
req.httpMethod = "POST"
|
||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||
let body = ["target": target]
|
||
req.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
||
|
||
do {
|
||
let (data, _) = try await URLSession.shared.data(for: req)
|
||
if let res = try? JSONDecoder().decode(ProbeResp.self, from: data) {
|
||
if res.reachable {
|
||
return (true, "连接成功 (\(res.rtt))")
|
||
} else {
|
||
return (false, "连接失败: \(res.error ?? "超时")")
|
||
}
|
||
}
|
||
return (false, "解析失败")
|
||
} catch {
|
||
return (false, error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
func recommendPort() async -> Int {
|
||
if isPreview { return 33662 }
|
||
guard let url = URL(string: "http://127.0.0.1:33080/api/recommend-port") else { return 33660 }
|
||
do {
|
||
let (data, _) = try await URLSession.shared.data(from: url)
|
||
if let res = try? JSONDecoder().decode(RecommendPortResp.self, from: data) {
|
||
return res.recommended_port
|
||
}
|
||
} catch {}
|
||
return 33660
|
||
}
|
||
|
||
func fetchHistory(tunnelId: String) async -> [ClientHistoryRecord] {
|
||
if isPreview { return previewHistory.filter { $0.tunnel_id == tunnelId } }
|
||
guard let url = URL(string: "http://127.0.0.1:33080/api/history?tunnel_id=\(tunnelId)") else { return [] }
|
||
do {
|
||
let (data, _) = try await URLSession.shared.data(from: url)
|
||
return (try? JSONDecoder().decode([ClientHistoryRecord].self, from: data)) ?? []
|
||
} catch {}
|
||
return []
|
||
}
|
||
|
||
/// 在线连接与安全事件(之前从未拉取,导致“当前在线客户端”始终为空)
|
||
func fetchConnections() async {
|
||
guard let url = URL(string: "http://127.0.0.1:33080/api/connections") else { return }
|
||
do {
|
||
let (data, _) = try await URLSession.shared.data(from: url)
|
||
let resp = try JSONDecoder().decode(ConnectionsResp.self, from: data)
|
||
self.activeConns = resp.active_conns
|
||
self.securityEvents = resp.security_events
|
||
} catch {
|
||
self.activeConns = []
|
||
}
|
||
}
|
||
|
||
private func apiCall(_ method: String, _ path: String, body: [String: Any]? = nil) async -> (Data?, String?) {
|
||
guard let url = URL(string: "http://127.0.0.1:33080" + path) else { return (nil, "无效地址") }
|
||
var req = URLRequest(url: url)
|
||
req.httpMethod = method
|
||
if let body = body {
|
||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||
req.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
||
}
|
||
do {
|
||
let (data, resp) = try await URLSession.shared.data(for: req)
|
||
if let httpResp = resp as? HTTPURLResponse, httpResp.statusCode >= 400 {
|
||
let errJson = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||
return (nil, (errJson?["error"] as? String) ?? "操作失败 (HTTP \(httpResp.statusCode))")
|
||
}
|
||
return (data, nil)
|
||
} catch {
|
||
return (nil, error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
/// 生成邀请链接(validMinutes / maxUses 为 0 时 maxUses 表示不限设备)
|
||
func createInvite(tunnelId: String, note: String, validMinutes: Int, maxUses: Int, leaseDuration: String, requireApproval: Bool) async -> (InviteShareResp?, String?) {
|
||
if isPreview {
|
||
let link = inviteBase.isEmpty ? "http://192.168.1.10:33000/i/k7m2xq9fpa" : inviteBase + "k7m2xq9fpa"
|
||
let t = tunnels.first { $0.id == tunnelId }
|
||
let deadline = Date().addingTimeInterval(Double(validMinutes) * 60)
|
||
let fmt = DateFormatter(); fmt.dateFormat = "MM-dd HH:mm"
|
||
let text = "【PortRelay 访问邀请】" + (note.isEmpty ? "" : "(给 \(note))") + "\n通道:\(t?.name ?? "MySQL 数据库")\n访问地址:\(publicHost.isEmpty ? "192.168.1.10" : publicHost):\(t?.listen_port ?? 33660)\n请在 \(fmt.string(from: deadline)) 前用浏览器打开下面的链接," + (requireApproval ? "填写姓名提交申请,管理员批准后即可访问" : "填写姓名后即可访问") + "(访问有效期:\(leaseDurationTitle(leaseDuration))):\n\(link)\n此链接仅限 1 台设备使用,请勿转发。"
|
||
return (InviteShareResp(invite: InviteItem(code: "k7m2xq9fpa", note: note, created_at: "", expires_at: "", max_uses: maxUses, lease_duration: leaseDuration, require_approval: requireApproval, uses: nil), link: link, share_text: text), nil)
|
||
}
|
||
let (data, err) = await apiCall("POST", "/api/tunnels/\(tunnelId)/invites",
|
||
body: ["note": note, "valid_minutes": validMinutes, "max_uses": maxUses,
|
||
"lease_duration": leaseDuration, "require_approval": requireApproval])
|
||
guard let data = data else { return (nil, err) }
|
||
let resp = try? JSONDecoder().decode(InviteShareResp.self, from: data)
|
||
await refreshData()
|
||
return (resp, resp == nil ? "解析失败" : nil)
|
||
}
|
||
|
||
func inviteShare(tunnelId: String, code: String) async -> (InviteShareResp?, String?) {
|
||
let (data, err) = await apiCall("GET", "/api/tunnels/\(tunnelId)/invites/\(code)/share")
|
||
guard let data = data else { return (nil, err) }
|
||
return (try? JSONDecoder().decode(InviteShareResp.self, from: data), nil)
|
||
}
|
||
|
||
func revokeInvite(tunnelId: String, code: String) async -> String? {
|
||
let (_, err) = await apiCall("DELETE", "/api/tunnels/\(tunnelId)/invites/\(code)")
|
||
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
||
await refreshData()
|
||
return err
|
||
}
|
||
|
||
func decideRequest(tunnelId: String, requestId: String, approve: Bool) async -> String? {
|
||
let (_, err) = await apiCall("POST", "/api/tunnels/\(tunnelId)/requests/\(requestId)", body: ["approve": approve])
|
||
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [requestId])
|
||
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
||
await refreshData()
|
||
return err
|
||
}
|
||
|
||
/// 撤销临时放通;ip 为 nil 时撤销该通道全部临时放通。返回错误信息,成功为 nil
|
||
func revokeLease(tunnelId: String, ip: String?) async -> String? {
|
||
guard let url = URL(string: "http://127.0.0.1:33080/api/tunnels/\(tunnelId)/leases/revoke") else { return "无效地址" }
|
||
var req = URLRequest(url: url)
|
||
req.httpMethod = "POST"
|
||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||
req.httpBody = try? JSONSerialization.data(withJSONObject: ["ip": ip ?? ""])
|
||
do {
|
||
let (data, resp) = try await URLSession.shared.data(for: req)
|
||
if let httpResp = resp as? HTTPURLResponse, httpResp.statusCode >= 400 {
|
||
let errJson = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||
return (errJson?["error"] as? String) ?? "撤销失败"
|
||
}
|
||
} catch {
|
||
return error.localizedDescription
|
||
}
|
||
// 服务端下一次指标推送(约 1 秒)后列表才会更新
|
||
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
||
await refreshData()
|
||
return nil
|
||
}
|
||
|
||
func killClients(connIds: [String]) async {
|
||
for id in connIds {
|
||
guard let url = URL(string: "http://127.0.0.1:33080/api/connections/kill") else { return }
|
||
var req = URLRequest(url: url)
|
||
req.httpMethod = "POST"
|
||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||
req.httpBody = try? JSONSerialization.data(withJSONObject: ["conn_id": id])
|
||
_ = try? await URLSession.shared.data(for: req)
|
||
}
|
||
await refreshData()
|
||
}
|
||
|
||
func killClient(connId: String) async {
|
||
guard let url = URL(string: "http://127.0.0.1:33080/api/connections/kill") else { return }
|
||
var req = URLRequest(url: url)
|
||
req.httpMethod = "POST"
|
||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||
let body = ["conn_id": connId]
|
||
req.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
||
_ = try? await URLSession.shared.data(for: req)
|
||
await refreshData()
|
||
}
|
||
|
||
func fetchSettings() async -> (String, String, String) {
|
||
if isPreview { return ("192.168.1.10:33000", "preview-token", "192.168.1.10") }
|
||
guard let url = URL(string: "http://127.0.0.1:33080/api/settings") else { return ("", "", "") }
|
||
do {
|
||
let (data, _) = try await URLSession.shared.data(from: url)
|
||
if let s = try? JSONDecoder().decode(SettingsResp.self, from: data) {
|
||
return (s.server_addr, s.token, s.public_host ?? self.publicHost)
|
||
}
|
||
} catch {}
|
||
return (self.serverAddr, "", self.publicHost)
|
||
}
|
||
|
||
/// 返回错误信息,成功时为 nil(后端保存后会自动重连)
|
||
func saveSettings(serverAddr: String, token: String, publicHost: String) async -> String? {
|
||
guard let url = URL(string: "http://127.0.0.1:33080/api/settings") else { return "无效地址" }
|
||
var req = URLRequest(url: url)
|
||
req.httpMethod = "POST"
|
||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||
let body = ["server_addr": serverAddr, "token": token, "public_host": publicHost]
|
||
req.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
||
do {
|
||
let (data, resp) = try await URLSession.shared.data(for: req)
|
||
if let httpResp = resp as? HTTPURLResponse, httpResp.statusCode >= 400 {
|
||
let errJson = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||
return (errJson?["error"] as? String) ?? "保存失败"
|
||
}
|
||
} catch {
|
||
return error.localizedDescription
|
||
}
|
||
await refreshData()
|
||
runDiagnostics()
|
||
return nil
|
||
}
|
||
|
||
// Live Diagnostics Check
|
||
/// 粗略判断服务器地址所在的网络类型,仅用于诊断展示
|
||
var serverNetworkKind: String {
|
||
let host = hostOf(serverAddr)
|
||
let parts = host.split(separator: ".").compactMap { Int($0) }
|
||
guard parts.count == 4 else { return host.isEmpty ? "未配置" : "域名" }
|
||
switch (parts[0], parts[1]) {
|
||
case (127, _): return "本机"
|
||
case (10, _), (192, 168), (172, 16...31): return "私有网络"
|
||
case (100, 64...127): return "虚拟组网"
|
||
default: return "公网"
|
||
}
|
||
}
|
||
|
||
func runDiagnostics() {
|
||
self.isRunningDiagnostics = true
|
||
var items: [DiagnosticItem] = []
|
||
|
||
if self.isConnected {
|
||
items.append(DiagnosticItem(
|
||
title: "本地网络访问权限 (Local Network)",
|
||
desc: "macOS 隐私与安全性授权状态正常,可与本地及内网节点通信。",
|
||
status: .ok,
|
||
statusText: "已授权",
|
||
actionTitle: nil,
|
||
action: nil
|
||
))
|
||
} else {
|
||
items.append(DiagnosticItem(
|
||
title: "本地网络访问权限 (Local Network)",
|
||
desc: "若曾误点击拒绝,系统会阻止本应用访问局域网内的网关或目标服务。",
|
||
status: .warning,
|
||
statusText: "需确认授权",
|
||
actionTitle: "打开系统设置 > 本地网络",
|
||
action: {
|
||
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork") {
|
||
NSWorkspace.shared.open(url)
|
||
}
|
||
}
|
||
))
|
||
}
|
||
|
||
if self.isConnected {
|
||
items.append(DiagnosticItem(
|
||
title: "中继服务器通信",
|
||
desc: "已与 \(self.serverAddr) 建立长连接,端口池处于就绪状态。",
|
||
status: .ok,
|
||
statusText: "连通正常",
|
||
actionTitle: nil,
|
||
action: nil
|
||
))
|
||
} else {
|
||
items.append(DiagnosticItem(
|
||
title: "中继服务器通信",
|
||
desc: "无法与 \(self.serverAddr) 握手。" + (self.lastError.isEmpty ? "请确保中继服务器上的服务正在运行且防火墙已放通控制端口。" : "原因:\(self.lastError)"),
|
||
status: .error,
|
||
statusText: "无法连接",
|
||
actionTitle: "重试连接",
|
||
action: {
|
||
Task { @MainActor in
|
||
await self.refreshData()
|
||
self.runDiagnostics()
|
||
}
|
||
}
|
||
))
|
||
}
|
||
|
||
items.append(DiagnosticItem(
|
||
title: "网络路径",
|
||
desc: "经\(serverNetworkKind)连接边缘网关 \(self.serverAddr)。连接方式不限:局域网、VPN、Tailscale / ZeroTier 等组网或公网均可,只要本机同时能访问目标网段即可。",
|
||
status: .ok,
|
||
statusText: serverNetworkKind,
|
||
actionTitle: nil,
|
||
action: nil
|
||
))
|
||
|
||
items.append(DiagnosticItem(
|
||
title: "后台中继引擎 (PortRelay Core)",
|
||
desc: self.agentReachable ? "负责与服务器保持连接,并把访问流量转发到内网目标服务。" : "本地 127.0.0.1:33080 无响应,正在尝试自动拉起后台引擎。",
|
||
status: self.agentReachable ? .ok : .error,
|
||
statusText: self.agentReachable ? "运行中" : "未运行",
|
||
actionTitle: nil,
|
||
action: nil
|
||
))
|
||
|
||
self.diagnostics = items
|
||
self.isRunningDiagnostics = false
|
||
}
|
||
}
|
||
|
||
// MARK: - Native Views
|
||
|
||
@MainActor
|
||
struct ContentView: View {
|
||
@ObservedObject var vm: PortRelayViewModel
|
||
@State private var showingAddSheet = false
|
||
@State private var showingSettingsSheet = false
|
||
@State private var showingDiagnosticsSheet = false
|
||
// 用 sheet(item:) 直接把要操作的通道传给弹窗;
|
||
// 之前 isPresented + selectedTunnel 的写法会读到旧值,导致编辑弹窗偶尔为空或显示别的通道
|
||
@State private var editingTunnel: TunnelItem?
|
||
/// 邀请弹窗:通道 + 是否打开即生成(新建通道后直接出分享卡片)
|
||
@State private var inviteTarget: InviteSheetTarget?
|
||
@State private var clientsTarget: ClientsSheetTarget?
|
||
// 通道搜索与状态筛选
|
||
@State private var searchText: String = ""
|
||
@State private var statusFilter: TunnelStatusFilter = .all
|
||
@FocusState private var searchFocused: Bool
|
||
|
||
init() {
|
||
self.init(vm: PortRelayViewModel.shared)
|
||
}
|
||
|
||
init(vm: PortRelayViewModel, initialSearch: String = "", initialFilter: TunnelStatusFilter = .all) {
|
||
_vm = ObservedObject(wrappedValue: vm)
|
||
_searchText = State(initialValue: initialSearch)
|
||
_statusFilter = State(initialValue: initialFilter)
|
||
}
|
||
|
||
var filteredTunnels: [TunnelItem] {
|
||
vm.tunnels.filter { statusFilter.accepts($0) && TunnelSearch.matches($0, query: searchText, publicHost: vm.publicHost) }
|
||
}
|
||
|
||
var isFiltering: Bool { !searchText.trimmingCharacters(in: .whitespaces).isEmpty || statusFilter != .all }
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
// macOS Standard Top Bar
|
||
topBarView
|
||
.padding(.horizontal, 18)
|
||
.padding(.vertical, 12)
|
||
.background(Color(NSColor.windowBackgroundColor))
|
||
|
||
Divider()
|
||
|
||
if !vm.isConnected {
|
||
HStack(spacing: 10) {
|
||
Image(systemName: "exclamationmark.circle.fill")
|
||
.foregroundColor(.orange)
|
||
.font(.system(size: 13))
|
||
Text("未连接至中继服务器 (\(vm.serverAddr))" + (vm.lastError.isEmpty ? "" : " · \(vm.lastError)"))
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.primary)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
Spacer()
|
||
Button("查看授权与诊断") {
|
||
showingDiagnosticsSheet = true
|
||
}
|
||
.buttonStyle(.link)
|
||
.font(.system(size: 12))
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.vertical, 7)
|
||
.background(Color.orange.opacity(0.08))
|
||
Divider()
|
||
}
|
||
|
||
ScrollView {
|
||
VStack(spacing: 16) {
|
||
// Minimalist Metrics Strip (Activity Monitor style)
|
||
metricsStripView
|
||
|
||
// Tunnel Section Header: 标题 / 数量 ……… 状态筛选 / 搜索
|
||
HStack(spacing: 12) {
|
||
Text("中继通道")
|
||
.font(.system(size: 13, weight: .semibold))
|
||
.foregroundColor(.secondary)
|
||
Text(verbatim: isFiltering ? "匹配 \(filteredTunnels.count) / 共 \(vm.tunnels.count) 个" : "\(vm.tunnels.count) 个通道")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.secondary)
|
||
.monospacedDigit()
|
||
Spacer()
|
||
if !vm.tunnels.isEmpty {
|
||
Picker("", selection: $statusFilter) {
|
||
ForEach(TunnelStatusFilter.allCases, id: \.self) { f in
|
||
Text(f.rawValue).tag(f)
|
||
}
|
||
}
|
||
.pickerStyle(.segmented)
|
||
.labelsHidden()
|
||
.fixedSize()
|
||
.controlSize(.small)
|
||
|
||
searchField
|
||
}
|
||
}
|
||
.padding(.top, 4)
|
||
|
||
// Tunnel Cards
|
||
if vm.tunnels.isEmpty {
|
||
emptyStateView
|
||
} else if filteredTunnels.isEmpty {
|
||
noMatchView
|
||
} else {
|
||
VStack(spacing: 10) {
|
||
ForEach(filteredTunnels) { t in
|
||
TunnelCardView(
|
||
tunnel: t,
|
||
vm: vm,
|
||
onEdit: {
|
||
editingTunnel = t
|
||
},
|
||
onViewClients: {
|
||
clientsTarget = ClientsSheetTarget(tunnel: t, tab: 0)
|
||
},
|
||
onViewLeases: {
|
||
clientsTarget = ClientsSheetTarget(tunnel: t, tab: 2)
|
||
},
|
||
onInvite: {
|
||
inviteTarget = InviteSheetTarget(tunnel: t, autoGenerate: false)
|
||
}
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(18)
|
||
}
|
||
}
|
||
.frame(minWidth: 920, minHeight: 640)
|
||
.sheet(isPresented: $showingAddSheet) {
|
||
TunnelEditSheet(vm: vm, tunnel: nil, isPresented: $showingAddSheet, onCreated: { id in
|
||
// 新建完成后打开邀请设置(时长、审批在邀请中选择)
|
||
Task { @MainActor in
|
||
try? await Task.sleep(nanoseconds: 600_000_000)
|
||
if let t = vm.tunnels.first(where: { $0.id == id }) {
|
||
inviteTarget = InviteSheetTarget(tunnel: t, autoGenerate: false)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
.sheet(item: $inviteTarget) { target in
|
||
InviteSheet(vm: vm, tunnel: target.tunnel, autoGenerate: target.autoGenerate, isPresented: Binding(
|
||
get: { inviteTarget != nil },
|
||
set: { if !$0 { inviteTarget = nil } }
|
||
))
|
||
}
|
||
.sheet(item: $editingTunnel) { t in
|
||
TunnelEditSheet(vm: vm, tunnel: t, isPresented: Binding(
|
||
get: { editingTunnel != nil },
|
||
set: { if !$0 { editingTunnel = nil } }
|
||
))
|
||
}
|
||
.sheet(isPresented: $showingSettingsSheet) {
|
||
SettingsSheet(vm: vm, isPresented: $showingSettingsSheet)
|
||
}
|
||
.sheet(isPresented: $showingDiagnosticsSheet) {
|
||
DiagnosticsSheet(vm: vm, isPresented: $showingDiagnosticsSheet)
|
||
}
|
||
.sheet(item: $clientsTarget) { target in
|
||
ClientDetailsSheet(vm: vm, tunnel: target.tunnel, initialTab: target.tab, isPresented: Binding(
|
||
get: { clientsTarget != nil },
|
||
set: { if !$0 { clientsTarget = nil } }
|
||
))
|
||
}
|
||
}
|
||
|
||
var topBarView: some View {
|
||
HStack(spacing: 14) {
|
||
HStack(spacing: 8) {
|
||
Image(systemName: "arrow.left.arrow.right.square.fill")
|
||
.font(.system(size: 20))
|
||
.foregroundColor(.accentColor)
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
Text("PortRelay")
|
||
.font(.system(size: 14, weight: .bold))
|
||
Text("端口中继控制台")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
|
||
Spacer()
|
||
|
||
HStack(spacing: 6) {
|
||
Circle()
|
||
.fill(vm.isConnected ? Color.green : Color.orange)
|
||
.frame(width: 7, height: 7)
|
||
Text(vm.isConnected ? "已就绪" : "连接中断")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.secondary)
|
||
}
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 4)
|
||
.background(Color(NSColor.controlBackgroundColor))
|
||
.cornerRadius(6)
|
||
|
||
Button(action: {
|
||
vm.runDiagnostics()
|
||
showingDiagnosticsSheet = true
|
||
}) {
|
||
Label("诊断与权限", systemImage: "shield")
|
||
}
|
||
.buttonStyle(.bordered)
|
||
.controlSize(.small)
|
||
|
||
Button(action: { showingAddSheet = true }) {
|
||
Label("添加通道", systemImage: "plus")
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.controlSize(.small)
|
||
|
||
Button(action: { showingSettingsSheet = true }) {
|
||
Image(systemName: "gearshape")
|
||
}
|
||
.buttonStyle(.bordered)
|
||
.controlSize(.small)
|
||
}
|
||
}
|
||
|
||
var metricsStripView: some View {
|
||
HStack(spacing: 1) {
|
||
MetricStripItem(
|
||
title: "活跃通道",
|
||
value: "\(vm.tunnels.filter { $0.enabled }.count) / \(vm.tunnels.count)",
|
||
caption: "端口池 33001-34000"
|
||
)
|
||
Divider().frame(height: 36)
|
||
HStack(alignment: .center, spacing: 10) {
|
||
MetricStripItem(
|
||
title: "实时速率",
|
||
value: "↑ \(formatRate(vm.totalRateIn)) ↓ \(formatRate(vm.totalRateOut))",
|
||
caption: "上行 / 下行 · 近 10 分钟"
|
||
)
|
||
.fixedSize()
|
||
RateSparkline(series: vm.totalRateHistory, height: 34)
|
||
.frame(minWidth: 60, maxWidth: 140)
|
||
.padding(.trailing, 10)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
Divider().frame(height: 36)
|
||
MetricStripItem(
|
||
title: "活跃连接",
|
||
value: "\(vm.totalActiveConns)",
|
||
caption: "历史累计 \(vm.totalHistoricalConns)"
|
||
)
|
||
Divider().frame(height: 36)
|
||
MetricStripItem(
|
||
title: "拦截未授权",
|
||
value: "\(vm.totalBlockedAttempts)",
|
||
caption: "白名单保护",
|
||
highlight: vm.totalBlockedAttempts > 0
|
||
)
|
||
}
|
||
.padding(.vertical, 10)
|
||
.padding(.horizontal, 12)
|
||
.background(RoundedRectangle(cornerRadius: 10).fill(Color(NSColor.controlBackgroundColor)))
|
||
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(Color.primary.opacity(0.09), lineWidth: 1))
|
||
}
|
||
|
||
var searchField: some View {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: "magnifyingglass")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
TextField("搜索名称、地址、端口、责任人…", text: $searchText)
|
||
.textFieldStyle(.plain)
|
||
.font(.system(size: 12))
|
||
.focused($searchFocused)
|
||
.onExitCommand { searchText = "" }
|
||
if !searchText.isEmpty {
|
||
Button(action: { searchText = "" }) {
|
||
Image(systemName: "xmark.circle.fill")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.help("清除搜索 (Esc)")
|
||
}
|
||
}
|
||
.padding(.horizontal, 8)
|
||
.padding(.vertical, 5)
|
||
.frame(width: 250)
|
||
.background(RoundedRectangle(cornerRadius: 7).fill(Color(NSColor.controlBackgroundColor)))
|
||
.overlay(RoundedRectangle(cornerRadius: 7).strokeBorder(searchFocused ? Color.accentColor.opacity(0.7) : Color.primary.opacity(0.12), lineWidth: 1))
|
||
.help("支持多个关键字(空格分隔),中文可用拼音或首字母,例如 sjk。快捷键 ⌘F")
|
||
.background(
|
||
// ⌘F 聚焦搜索框
|
||
Button("") { searchFocused = true }
|
||
.keyboardShortcut("f", modifiers: .command)
|
||
.opacity(0)
|
||
)
|
||
}
|
||
|
||
var noMatchView: some View {
|
||
VStack(spacing: 8) {
|
||
Image(systemName: "magnifyingglass")
|
||
.font(.system(size: 26))
|
||
.foregroundColor(.secondary)
|
||
Text("没有匹配的通道")
|
||
.font(.system(size: 13, weight: .medium))
|
||
Text(verbatim: searchText.isEmpty ? "当前筛选:\(statusFilter.rawValue)" : "关键字:\(searchText)" + (statusFilter == .all ? "" : " · 筛选:\(statusFilter.rawValue)"))
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.secondary)
|
||
Button("清除搜索与筛选") {
|
||
searchText = ""
|
||
statusFilter = .all
|
||
}
|
||
.controlSize(.small)
|
||
.padding(.top, 2)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 40)
|
||
.rowCard()
|
||
}
|
||
|
||
var emptyStateView: some View {
|
||
VStack(spacing: 8) {
|
||
Image(systemName: "tray")
|
||
.font(.system(size: 32))
|
||
.foregroundColor(.secondary)
|
||
Text("无活动通道")
|
||
.font(.system(size: 13, weight: .medium))
|
||
Text("点击右上角「添加通道」创建反向中继端口")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.secondary)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 48)
|
||
.background(Color(NSColor.controlBackgroundColor).opacity(0.4))
|
||
.cornerRadius(8)
|
||
}
|
||
}
|
||
|
||
struct MetricStripItem: View {
|
||
let title: String
|
||
let value: String
|
||
let caption: String
|
||
var highlight: Bool = false
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(title)
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
Text(value)
|
||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||
.foregroundColor(highlight ? .red : .primary)
|
||
Text(caption)
|
||
.font(.system(size: 10))
|
||
.foregroundColor(.secondary.opacity(0.8))
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.horizontal, 10)
|
||
}
|
||
}
|
||
|
||
// MARK: - Tunnel Card View (With TTL, Owner, Session/History Traffic)
|
||
|
||
struct TunnelCardView: View {
|
||
let tunnel: TunnelItem
|
||
@ObservedObject var vm: PortRelayViewModel
|
||
let onEdit: () -> Void
|
||
let onViewClients: () -> Void
|
||
let onViewLeases: () -> Void
|
||
let onInvite: () -> Void
|
||
|
||
@State private var showDeleteConfirm: Bool = false
|
||
|
||
var isOnline: Bool {
|
||
tunnel.enabled && (tunnel.metrics?.status == "online")
|
||
}
|
||
|
||
var statusBadge: (text: String, color: Color) {
|
||
guard tunnel.enabled else { return ("已暂停", .secondary) }
|
||
switch tunnel.metrics?.status {
|
||
case "online": return ("运行中", .green)
|
||
case "error": return ("异常", .red)
|
||
case "offline": return ("等待连接服务器", .orange)
|
||
default: return ("同步中", .secondary)
|
||
}
|
||
}
|
||
|
||
@State private var cardWidth: CGFloat = 0
|
||
|
||
/// 卡片足够宽时在右侧显示流量面板
|
||
var showsTraffic: Bool { cardWidth == 0 || cardWidth >= 700 }
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
// 主体左右两栏:左侧 名称/地址/时间口径,右侧 速率 + 开关 + 流量曲线(与左侧等高)
|
||
HStack(alignment: .top, spacing: 20) {
|
||
VStack(alignment: .leading, spacing: 9) {
|
||
HStack(alignment: .center, spacing: 8) {
|
||
Text(verbatim: tunnel.name)
|
||
.font(.system(size: 14, weight: .semibold))
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
.layoutPriority(1)
|
||
|
||
if let owner = tunnel.owner, !owner.isEmpty {
|
||
HStack(spacing: 3) {
|
||
Image(systemName: "person.fill")
|
||
.font(.system(size: 9))
|
||
Text(verbatim: owner)
|
||
.font(.system(size: 11, weight: .medium))
|
||
.lineLimit(1)
|
||
}
|
||
.padding(.horizontal, 6)
|
||
.padding(.vertical, 2)
|
||
.background(Color.blue.opacity(0.12))
|
||
.foregroundColor(.blue)
|
||
.cornerRadius(4)
|
||
}
|
||
|
||
Text(verbatim: statusBadge.text)
|
||
.font(.system(size: 10, weight: .medium))
|
||
.padding(.horizontal, 5)
|
||
.padding(.vertical, 1.5)
|
||
.background(statusBadge.color.opacity(0.12))
|
||
.foregroundColor(statusBadge.color)
|
||
.cornerRadius(4)
|
||
.help(tunnel.metrics?.error ?? "")
|
||
|
||
accessTag
|
||
}
|
||
|
||
if tunnel.enabled, tunnel.metrics?.status == "error", let err = tunnel.metrics?.error, !err.isEmpty {
|
||
Label {
|
||
Text(verbatim: "服务器注册失败:\(err)")
|
||
} icon: {
|
||
Image(systemName: "exclamationmark.triangle.fill")
|
||
}
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.red)
|
||
.lineLimit(2)
|
||
}
|
||
|
||
routeRow
|
||
|
||
FlowLayout(spacing: 14, lineSpacing: 7) {
|
||
lifecycleItems
|
||
badgeItems
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
|
||
if tunnel.enabled && showsTraffic {
|
||
VStack(alignment: .trailing, spacing: 6) {
|
||
HStack(spacing: 10) {
|
||
RateLegend(down: tunnel.metrics?.rate_out ?? 0, up: tunnel.metrics?.rate_in ?? 0)
|
||
Spacer(minLength: 0)
|
||
enableToggle
|
||
}
|
||
InlineTrafficChart(series: tunnel.rate_history)
|
||
}
|
||
.frame(width: 250)
|
||
} else {
|
||
// 无曲线时右栏只放开关,固定尺寸,不挤占左侧
|
||
enableToggle
|
||
.fixedSize()
|
||
}
|
||
}
|
||
|
||
Divider()
|
||
|
||
// Footer: 连接 / 本次 / 历史 ……… 操作
|
||
HStack(spacing: 12) {
|
||
let m = tunnel.metrics
|
||
let sessionBytes = (m?.bytes_in ?? 0) + (m?.bytes_out ?? 0)
|
||
let histBytes = (tunnel.historical_bytes_in ?? 0) + (tunnel.historical_bytes_out ?? 0) + (tunnel.enabled ? sessionBytes : 0)
|
||
HStack(spacing: 16) {
|
||
footerStat("连接", "\(m?.active_conns ?? 0)")
|
||
footerStat("本次", formatBytes(sessionBytes))
|
||
footerStat("历史", formatBytes(histBytes))
|
||
if !showsTraffic && tunnel.enabled {
|
||
footerStat("速率", "↑\(formatRate(m?.rate_in ?? 0)) ↓\(formatRate(m?.rate_out ?? 0))")
|
||
}
|
||
}
|
||
.lineLimit(1)
|
||
|
||
Spacer(minLength: 8)
|
||
|
||
HStack(spacing: 6) {
|
||
Button(action: onInvite) {
|
||
Label("邀请", systemImage: "square.and.arrow.up")
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.controlSize(.small)
|
||
.disabled(!tunnel.enabled)
|
||
.help(tunnel.enabled ? "生成邀请链接,发给同事临时访问" : "请先开启通道")
|
||
|
||
Button("连接与授权", action: onViewClients)
|
||
.buttonStyle(.bordered)
|
||
.controlSize(.small)
|
||
|
||
Button("编辑", action: onEdit)
|
||
.buttonStyle(.bordered)
|
||
.controlSize(.small)
|
||
|
||
Button("删除", role: .destructive) {
|
||
showDeleteConfirm = true
|
||
}
|
||
.buttonStyle(.bordered)
|
||
.controlSize(.small)
|
||
}
|
||
}
|
||
}
|
||
.padding(14)
|
||
.background(GeometryReader { g in Color.clear.preference(key: CardWidthKey.self, value: g.size.width) })
|
||
.onPreferenceChange(CardWidthKey.self) { cardWidth = $0 }
|
||
.background(RoundedRectangle(cornerRadius: 10).fill(Color(NSColor.controlBackgroundColor)))
|
||
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(Color.primary.opacity(0.09), lineWidth: 1))
|
||
.shadow(color: .black.opacity(0.04), radius: 2, y: 1)
|
||
.opacity(tunnel.enabled ? 1.0 : 0.6)
|
||
.alert("确认删除通道?", isPresented: $showDeleteConfirm) {
|
||
Button("取消", role: .cancel) {}
|
||
Button("确认删除", role: .destructive) {
|
||
Task { await vm.deleteTunnel(id: tunnel.id) }
|
||
}
|
||
} message: {
|
||
Text(verbatim: "确定要删除通道「\(tunnel.name)」吗?\n\n• 对外访问地址:\(vm.publicHost):\(tunnel.listen_port)\n• 目标服务:\(tunnel.target)\n\n删除后将立即注销中转监听并切断所有活跃连接。此操作不可撤销。")
|
||
}
|
||
}
|
||
|
||
// MARK: Card parts
|
||
|
||
var enableToggle: some View {
|
||
Toggle("", isOn: Binding(
|
||
get: { tunnel.enabled },
|
||
set: { newValue in
|
||
Task { await vm.toggleTunnel(id: tunnel.id, enabled: newValue) }
|
||
}
|
||
))
|
||
.toggleStyle(.switch)
|
||
.controlSize(.mini)
|
||
.labelsHidden()
|
||
.help(tunnel.enabled ? "关闭通道" : "开启通道")
|
||
}
|
||
|
||
var routeRow: some View {
|
||
HStack(spacing: 8) {
|
||
Text(verbatim: "\(vm.publicHost):\(tunnel.listen_port)")
|
||
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
||
.foregroundColor(.accentColor)
|
||
Image(systemName: "arrow.right")
|
||
.font(.system(size: 10))
|
||
.foregroundColor(.secondary)
|
||
Text(verbatim: tunnel.target)
|
||
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
||
.foregroundColor(.secondary)
|
||
Menu {
|
||
ForEach(quickConnectSnippets(), id: \.cmd) { item in
|
||
Button(action: { copyToPasteboard(item.cmd) }) {
|
||
Label(item.title, systemImage: item.icon)
|
||
}
|
||
}
|
||
} label: {
|
||
HStack(spacing: 3) {
|
||
Image(systemName: "doc.on.doc")
|
||
.font(.system(size: 10))
|
||
Text("一键复制")
|
||
.font(.system(size: 11))
|
||
}
|
||
.foregroundColor(.secondary)
|
||
}
|
||
.menuStyle(.borderlessButton)
|
||
.fixedSize()
|
||
.help("一键复制客户端连接命令或访问地址")
|
||
}
|
||
.lineLimit(1)
|
||
}
|
||
|
||
var accessTag: some View {
|
||
Group {
|
||
if tunnel.whitelist.isEmpty {
|
||
capsule("lock.fill", "仅授权链接", .green)
|
||
} else if tunnel.whitelist.contains("*") {
|
||
capsule("exclamationmark.triangle.fill", "全部放通 *", .red)
|
||
.help("任何能访问服务器的设备都可以连接,建议改为“仅授权链接”")
|
||
} else {
|
||
capsule("network", tunnel.whitelist.count == 1 ? "\(tunnel.whitelist[0]) + 授权链接" : "\(tunnel.whitelist.count) 个 IP/网段 + 授权链接", .secondary)
|
||
.help("固定白名单:" + tunnel.whitelist.joined(separator: ", "))
|
||
}
|
||
}
|
||
}
|
||
|
||
func capsule(_ icon: String, _ text: String, _ color: Color) -> some View {
|
||
HStack(spacing: 3) {
|
||
Image(systemName: icon)
|
||
.font(.system(size: 9))
|
||
Text(verbatim: text)
|
||
.font(.system(size: 10, weight: .medium))
|
||
.lineLimit(1)
|
||
}
|
||
.padding(.horizontal, 5)
|
||
.padding(.vertical, 1.5)
|
||
.background(color.opacity(0.12))
|
||
.foregroundColor(color == .secondary ? .primary.opacity(0.7) : color)
|
||
.cornerRadius(4)
|
||
}
|
||
|
||
@ViewBuilder
|
||
var lifecycleItems: some View {
|
||
if tunnel.enabled {
|
||
lifecycleItem("play.circle", "开启 \(formatDateTimeShort(tunnel.started_at))")
|
||
if let first = tunnel.first_connected_at {
|
||
lifecycleItem("person.wave.2", "首次连接 \(formatDateTimeShort(first))")
|
||
} else {
|
||
lifecycleItem("person.wave.2", "尚无连接")
|
||
}
|
||
lifecycleItem("stopwatch", "实际连接 \(formatSecondsToDuration(tunnel.activity?.connected_sec ?? tunnel.connected_sec ?? 0))",
|
||
color: .primary, weight: .medium)
|
||
.help("本次开启后,至少有一个连接在线的累计时长")
|
||
if let act = tunnel.activity {
|
||
if act.in_use {
|
||
lifecycleItem("circle.fill", "使用中", color: .green, weight: .medium, iconSize: 7)
|
||
.help("有在线连接、有效授权、待审批申请或可用的邀请链接")
|
||
} else if let closeAt = act.auto_close_at {
|
||
let left = max(0, -secondsSince(closeAt))
|
||
lifecycleItem("moon.zzz", "已闲置 \(formatSecondsToDuration(secondsSince(act.idle_since))),\(formatSecondsToDuration(left)) 后自动关闭",
|
||
color: left < 300 ? .orange : .secondary)
|
||
} else {
|
||
lifecycleItem("moon.zzz", "已闲置 \(formatSecondsToDuration(secondsSince(act.idle_since)))(不自动关闭)")
|
||
}
|
||
}
|
||
if tunnel.expires_at != nil {
|
||
let remaining = formatRemainingTime(expiresAt: tunnel.expires_at)
|
||
lifecycleItem("hourglass", "最长开启至 \(formatDateTimeShort(tunnel.expires_at))",
|
||
color: remaining.isExpiringSoon ? .orange : .secondary)
|
||
}
|
||
} else {
|
||
if let lastSec = tunnel.last_duration_sec, lastSec > 0 {
|
||
lifecycleItem("clock.arrow.circlepath", "上次运行 \(formatSecondsToDuration(lastSec)) · 实际连接 \(formatSecondsToDuration(tunnel.last_connected_sec ?? 0))")
|
||
}
|
||
if let reason = tunnel.close_reason, !reason.isEmpty {
|
||
lifecycleItem("power", "关闭原因:\(reason)")
|
||
}
|
||
let idle = tunnel.effective_idle_close_minutes ?? 30
|
||
lifecycleItem("moon.zzz", "闲置自动关闭:" + (idle > 0 ? idleCloseTitle(idle) : "关"))
|
||
if let d = tunnel.duration_minutes, d > 0 {
|
||
lifecycleItem("hourglass", "最长开启 \(formatSecondsToDuration(Int64(d) * 60))")
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
var badgeItems: some View {
|
||
if let mb = tunnel.max_bandwidth_mb, mb > 0 {
|
||
capsule("gauge.with.needle", "限速 \(Int(mb)) MB/s", .purple)
|
||
}
|
||
if let pending = tunnel.metrics?.pending, !pending.isEmpty {
|
||
Button(action: onViewLeases) {
|
||
capsule("bell.badge.fill", "待审批 \(pending.count)", .orange)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.help("有同事申请访问,点击审批")
|
||
}
|
||
if let leases = tunnel.metrics?.dynamic_leases, !leases.isEmpty {
|
||
Button(action: onViewLeases) {
|
||
HStack(spacing: 2) {
|
||
capsule("person.crop.circle.badge.checkmark", "已授权 \(leases.count) 台", .green)
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
.help("查看与撤销已授权的设备")
|
||
}
|
||
}
|
||
|
||
func footerStat(_ label: String, _ value: String) -> some View {
|
||
HStack(spacing: 4) {
|
||
Text(verbatim: label)
|
||
.foregroundColor(.secondary)
|
||
Text(verbatim: value)
|
||
.fontWeight(.medium)
|
||
.foregroundColor(.primary.opacity(0.85))
|
||
}
|
||
.font(.system(size: 11))
|
||
.monospacedDigit()
|
||
}
|
||
|
||
func lifecycleItem(_ icon: String, _ text: String, color: Color = .secondary, weight: Font.Weight = .regular, iconSize: CGFloat = 11) -> some View {
|
||
HStack(spacing: 4) {
|
||
Image(systemName: icon)
|
||
.font(.system(size: iconSize))
|
||
Text(verbatim: text)
|
||
.font(.system(size: 11, weight: weight))
|
||
.monospacedDigit()
|
||
}
|
||
.foregroundColor(color)
|
||
}
|
||
|
||
func quickConnectSnippets() -> [(title: String, cmd: String, icon: String)] {
|
||
let p = tunnel.listen_port
|
||
let host = vm.publicHost
|
||
let targetLower = tunnel.target.lowercased()
|
||
let nameLower = tunnel.name.lowercased()
|
||
var list: [(String, String, String)] = []
|
||
|
||
if targetLower.hasSuffix(":22") || nameLower.contains("ssh") {
|
||
list.append(("复制 SSH 命令: ssh -p \(p) root@\(host)", "ssh -p \(p) root@\(host)", "terminal"))
|
||
} else if targetLower.hasSuffix(":3306") || nameLower.contains("mysql") {
|
||
list.append(("复制 MySQL 命令: mysql -h \(host) -P \(p) -u root -p", "mysql -h \(host) -P \(p) -u root -p", "cylinder"))
|
||
} else if targetLower.hasSuffix(":6379") || nameLower.contains("redis") {
|
||
list.append(("复制 Redis 命令: redis-cli -h \(host) -p \(p)", "redis-cli -h \(host) -p \(p)", "server.rack"))
|
||
} else if targetLower.hasSuffix(":80") || targetLower.hasSuffix(":443") || targetLower.hasSuffix(":8080") || nameLower.contains("web") || nameLower.contains("http") {
|
||
list.append(("复制 Web URL: http://\(host):\(p)", "http://\(host):\(p)", "globe"))
|
||
}
|
||
|
||
list.append(("复制 访问地址 (\(host):\(p))", "\(host):\(p)", "network"))
|
||
return list
|
||
}
|
||
}
|
||
|
||
// MARK: - Client Details & Historical Records Sheet
|
||
|
||
/// 客户明细弹窗的目标(通道 + 初始页签),整体作为 sheet(item:) 的参数
|
||
struct ClientsSheetTarget: Identifiable {
|
||
let tunnel: TunnelItem
|
||
let tab: Int
|
||
var id: String { "\(tunnel.id)#\(tab)" }
|
||
}
|
||
|
||
struct ClientDetailsSheet: View {
|
||
@ObservedObject var vm: PortRelayViewModel
|
||
let tunnel: TunnelItem
|
||
@Binding var isPresented: Bool
|
||
|
||
@State private var selectedTab: Int
|
||
@State private var revokeError: String? = nil
|
||
@State private var expandedIPs: Set<String> = []
|
||
@State private var expandedHistoryIPs: Set<String> = []
|
||
@State private var isRevoking: Bool = false
|
||
|
||
init(vm: PortRelayViewModel, tunnel: TunnelItem, initialTab: Int = 0, isPresented: Binding<Bool>) {
|
||
_vm = ObservedObject(wrappedValue: vm)
|
||
self.tunnel = tunnel
|
||
_isPresented = isPresented
|
||
_selectedTab = State(initialValue: initialTab)
|
||
}
|
||
|
||
/// 弹窗打开期间使用最新数据(临时放通列表每秒刷新)
|
||
var liveTunnel: TunnelItem {
|
||
vm.tunnels.first { $0.id == tunnel.id } ?? tunnel
|
||
}
|
||
|
||
var leases: [DynamicLeaseItem] {
|
||
liveTunnel.metrics?.dynamic_leases ?? []
|
||
}
|
||
|
||
var invites: [InviteItem] {
|
||
liveTunnel.metrics?.invites ?? []
|
||
}
|
||
|
||
var pendingRequests: [AccessRequestItem] {
|
||
liveTunnel.metrics?.pending ?? []
|
||
}
|
||
|
||
@State private var showingInviteSheet = false
|
||
@State private var copiedCode: String? = nil
|
||
@State private var historyRecords: [ClientHistoryRecord] = []
|
||
@State private var isLoadingHistory: Bool = false
|
||
|
||
var activeClientsForTunnel: [ActiveClientInfo] {
|
||
vm.activeConns.filter { $0.tunnel_id == tunnel.id }
|
||
}
|
||
|
||
var historyGroups: [HistoryIPGroup] {
|
||
Dictionary(grouping: historyRecords) { hostOf($0.client_ip) }
|
||
.map { HistoryIPGroup(ip: $0.key, records: $0.value.sorted { $0.connected_at > $1.connected_at }) }
|
||
.sorted { ($0.lastSeen ?? "") > ($1.lastSeen ?? "") }
|
||
}
|
||
|
||
var clientGroups: [ClientIPGroup] {
|
||
Dictionary(grouping: activeClientsForTunnel) { hostOf($0.client_ip) }
|
||
.map { ClientIPGroup(ip: $0.key, conns: $0.value.sorted { $0.connected_at < $1.connected_at }) }
|
||
.sorted { $0.conns.count != $1.conns.count ? $0.conns.count > $1.conns.count : $0.ip < $1.ip }
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 14) {
|
||
// Header
|
||
HStack {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(verbatim: "\(tunnel.name) · 连接与授权")
|
||
.font(.system(size: 15, weight: .bold))
|
||
.lineLimit(1)
|
||
.truncationMode(.middle)
|
||
Text(verbatim: "\(vm.publicHost):\(tunnel.listen_port) → \(tunnel.target)")
|
||
.font(.system(size: 12, design: .monospaced))
|
||
.foregroundColor(.secondary)
|
||
.lineLimit(1)
|
||
}
|
||
Spacer()
|
||
Button("完成") { isPresented = false }
|
||
.buttonStyle(.borderedProminent)
|
||
.controlSize(.small)
|
||
}
|
||
|
||
// Tab Picker
|
||
Picker("", selection: $selectedTab) {
|
||
Text(verbatim: clientGroups.isEmpty ? "当前在线客户端" : "当前在线 (\(clientGroups.count) 台 · \(activeClientsForTunnel.count) 连接)").tag(0)
|
||
Text(verbatim: historyGroups.isEmpty ? "历史接入台账" : "历史接入台账 (\(historyGroups.count) 台)").tag(1)
|
||
Text(verbatim: "授权管理 (\(leases.count + pendingRequests.count))").tag(2)
|
||
}
|
||
.pickerStyle(.segmented)
|
||
.padding(.vertical, 4)
|
||
|
||
Divider()
|
||
|
||
// Tab Content
|
||
if selectedTab == 0 {
|
||
// Live Connected Clients
|
||
if activeClientsForTunnel.isEmpty {
|
||
VStack(spacing: 8) {
|
||
Image(systemName: "person.slash")
|
||
.font(.system(size: 28))
|
||
.foregroundColor(.secondary)
|
||
Text("当前暂无客户端连接此端口")
|
||
.font(.system(size: 13))
|
||
.foregroundColor(.secondary)
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
} else {
|
||
ScrollView {
|
||
VStack(spacing: 8) {
|
||
ForEach(clientGroups) { g in
|
||
clientGroupRow(g)
|
||
}
|
||
}
|
||
.padding(.bottom, 8)
|
||
}
|
||
}
|
||
} else if selectedTab == 1 {
|
||
// Historical Connections Log Table
|
||
if isLoadingHistory {
|
||
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
} else if historyRecords.isEmpty {
|
||
VStack(spacing: 8) {
|
||
Image(systemName: "doc.text")
|
||
.font(.system(size: 28))
|
||
.foregroundColor(.secondary)
|
||
Text("暂无已断开的连接历史记录")
|
||
.font(.system(size: 13))
|
||
.foregroundColor(.secondary)
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
} else {
|
||
ScrollView {
|
||
VStack(spacing: 8) {
|
||
ForEach(historyGroups) { g in
|
||
historyGroupRow(g)
|
||
}
|
||
}
|
||
.padding(.bottom, 8)
|
||
}
|
||
}
|
||
} else {
|
||
leasesTab
|
||
}
|
||
}
|
||
.padding(20)
|
||
.frame(minWidth: 720, idealWidth: 760, maxWidth: .infinity, minHeight: 540, idealHeight: 600, maxHeight: .infinity)
|
||
.onAppear {
|
||
loadHistory()
|
||
}
|
||
.onChange(of: selectedTab) { newTab in
|
||
if newTab == 1 { loadHistory() }
|
||
}
|
||
}
|
||
|
||
/// 只有一个 IP 时默认展开;点击切换展开状态
|
||
func isExpanded(_ ip: String, in set: Set<String>, groupCount: Int) -> Bool {
|
||
(groupCount == 1) != set.contains(ip)
|
||
}
|
||
|
||
func toggle(_ ip: String, in set: inout Set<String>) {
|
||
if set.contains(ip) { set.remove(ip) } else { set.insert(ip) }
|
||
}
|
||
|
||
func chevron(_ expanded: Bool, visible: Bool = true) -> some View {
|
||
Image(systemName: "chevron.right")
|
||
.font(.system(size: 10, weight: .semibold))
|
||
.foregroundColor(.secondary)
|
||
.rotationEffect(.degrees(expanded ? 90 : 0))
|
||
.frame(width: 12)
|
||
.opacity(visible ? 1 : 0)
|
||
}
|
||
|
||
@ViewBuilder
|
||
func historyGroupRow(_ g: HistoryIPGroup) -> some View {
|
||
let expanded = isExpanded(g.ip, in: expandedHistoryIPs, groupCount: historyGroups.count)
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
HStack(spacing: 8) {
|
||
chevron(expanded)
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
HStack(spacing: 6) {
|
||
Text(verbatim: g.ip)
|
||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||
.lineLimit(1)
|
||
.truncationMode(.middle)
|
||
TagView("\(g.records.count) 次连接")
|
||
}
|
||
Text(verbatim: "最近 \(formatDateTimeShort(g.lastSeen)) · 首次 \(formatDateTimeShort(g.firstSeen)) · 累计 \(formatSecondsToDuration(g.totalDuration))")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
.lineLimit(1)
|
||
}
|
||
.layoutPriority(1)
|
||
Spacer(minLength: 8)
|
||
VStack(alignment: .trailing, spacing: 3) {
|
||
Text(verbatim: formatBytes(g.bytesIn + g.bytesOut))
|
||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||
Text(verbatim: "↑ \(formatBytes(g.bytesIn)) ↓ \(formatBytes(g.bytesOut))")
|
||
.font(.system(size: 10, design: .monospaced))
|
||
.foregroundColor(.secondary)
|
||
}
|
||
.lineLimit(1)
|
||
.frame(width: 190, alignment: .trailing)
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 10)
|
||
.contentShape(Rectangle())
|
||
.onTapGesture { toggle(g.ip, in: &expandedHistoryIPs) }
|
||
|
||
if expanded {
|
||
Divider().padding(.leading, 32).padding(.trailing, 12)
|
||
VStack(spacing: 0) {
|
||
ForEach(g.records) { rec in
|
||
HStack(spacing: 8) {
|
||
Color.clear.frame(width: 12)
|
||
Text(verbatim: ":\(portOf(rec.client_ip))")
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.frame(width: 56, alignment: .leading)
|
||
Text(verbatim: "\(formatDateTimeShort(rec.connected_at)) – \(formatTimeHHmm(rec.disconnected_at))")
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.foregroundColor(.secondary)
|
||
.frame(width: 180, alignment: .leading)
|
||
Text(verbatim: formatSecondsToDuration(rec.duration_sec))
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
.frame(width: 80, alignment: .leading)
|
||
Spacer(minLength: 8)
|
||
Text(verbatim: "↑ \(formatBytes(rec.bytes_in)) ↓ \(formatBytes(rec.bytes_out))")
|
||
.font(.system(size: 10, design: .monospaced))
|
||
.foregroundColor(.secondary)
|
||
.frame(width: 190, alignment: .trailing)
|
||
}
|
||
.lineLimit(1)
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 5)
|
||
}
|
||
}
|
||
.padding(.vertical, 4)
|
||
}
|
||
}
|
||
.rowCard()
|
||
}
|
||
|
||
func isLeased(_ ip: String) -> Bool {
|
||
leases.contains { $0.ip == ip }
|
||
}
|
||
|
||
func leaseName(_ ip: String) -> String? {
|
||
guard let n = leases.first(where: { $0.ip == ip })?.name, !n.isEmpty else { return nil }
|
||
return n
|
||
}
|
||
|
||
@ViewBuilder
|
||
func clientGroupRow(_ g: ClientIPGroup) -> some View {
|
||
let multi = g.conns.count > 1
|
||
let expanded = multi && isExpanded(g.ip, in: expandedIPs, groupCount: clientGroups.count)
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
HStack(spacing: 8) {
|
||
chevron(expanded, visible: multi)
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
HStack(spacing: 6) {
|
||
Text(verbatim: g.ip)
|
||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||
.foregroundColor(.accentColor)
|
||
.lineLimit(1)
|
||
.truncationMode(.middle)
|
||
TagView("\(g.conns.count) 个连接")
|
||
if isLeased(g.ip) {
|
||
TagView(leaseName(g.ip).map { "已授权 · \($0)" } ?? "已授权", color: .green)
|
||
}
|
||
}
|
||
Text(verbatim: "最早接入 \(formatDateTimeShort(g.firstConnectedAt)) · ↑ \(formatBytes(g.bytesIn)) ↓ \(formatBytes(g.bytesOut))")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
.lineLimit(1)
|
||
}
|
||
.layoutPriority(1)
|
||
Spacer(minLength: 8)
|
||
RateText(up: g.rateIn, down: g.rateOut)
|
||
Button(multi ? "全部断开" : "断开", role: .destructive) {
|
||
Task { await vm.killClients(connIds: g.conns.map { $0.conn_id }) }
|
||
}
|
||
.controlSize(.small)
|
||
.frame(width: 76, alignment: .trailing)
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 10)
|
||
.contentShape(Rectangle())
|
||
.onTapGesture { if multi { toggle(g.ip, in: &expandedIPs) } }
|
||
|
||
if expanded {
|
||
Divider().padding(.leading, 32).padding(.trailing, 12)
|
||
VStack(spacing: 0) {
|
||
ForEach(g.conns) { c in
|
||
HStack(spacing: 8) {
|
||
Color.clear.frame(width: 12)
|
||
Text(verbatim: ":\(portOf(c.client_ip))")
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.frame(width: 56, alignment: .leading)
|
||
Text(verbatim: "接入 \(formatTimeHHmm(c.connected_at))")
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.foregroundColor(.secondary)
|
||
.frame(width: 104, alignment: .leading)
|
||
Text(verbatim: "↑ \(formatBytes(c.bytes_in)) ↓ \(formatBytes(c.bytes_out))")
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.foregroundColor(.secondary)
|
||
Spacer(minLength: 8)
|
||
RateText(up: c.rate_in, down: c.rate_out, size: 10)
|
||
Button("断开") {
|
||
Task { await vm.killClient(connId: c.conn_id) }
|
||
}
|
||
.buttonStyle(.borderless)
|
||
.foregroundColor(.red)
|
||
.font(.system(size: 11))
|
||
.frame(width: 76, alignment: .trailing)
|
||
}
|
||
.lineLimit(1)
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 5)
|
||
}
|
||
}
|
||
.padding(.vertical, 4)
|
||
}
|
||
}
|
||
.rowCard()
|
||
}
|
||
|
||
func sectionHeader(_ title: String, _ count: Int) -> some View {
|
||
Text(verbatim: "\(title) · \(count)")
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundColor(.secondary)
|
||
.padding(.top, 6)
|
||
}
|
||
|
||
func emptyHint(_ text: String) -> some View {
|
||
Text(verbatim: text)
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
.padding(.vertical, 2)
|
||
}
|
||
|
||
var leasesTab: some View {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
HStack(alignment: .center, spacing: 8) {
|
||
Label {
|
||
Text(verbatim: "同事打开邀请链接并填写姓名后获得访问权限;可访问时长与是否需要审批在每个邀请中设置")
|
||
} icon: {
|
||
Image(systemName: "link")
|
||
}
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
.lineLimit(1)
|
||
Spacer()
|
||
if !leases.isEmpty {
|
||
Button("全部撤销", role: .destructive) { revoke(ip: nil) }
|
||
.controlSize(.small)
|
||
.disabled(isRevoking)
|
||
}
|
||
Button {
|
||
showingInviteSheet = true
|
||
} label: {
|
||
Label("新建邀请", systemImage: "plus")
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.controlSize(.small)
|
||
.disabled(!liveTunnel.enabled)
|
||
.help(liveTunnel.enabled ? "生成新的邀请链接" : "请先开启通道")
|
||
}
|
||
|
||
if let err = revokeError {
|
||
Label(err, systemImage: "exclamationmark.triangle.fill")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.red)
|
||
}
|
||
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
if !pendingRequests.isEmpty {
|
||
sectionHeader("待审批", pendingRequests.count)
|
||
ForEach(pendingRequests) { r in
|
||
HStack(spacing: 10) {
|
||
Image(systemName: "bell.badge.fill")
|
||
.foregroundColor(.orange)
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
HStack(spacing: 6) {
|
||
Text(verbatim: r.name)
|
||
.font(.system(size: 13, weight: .semibold))
|
||
.lineLimit(1)
|
||
Text(verbatim: r.ip)
|
||
.font(.system(size: 12, design: .monospaced))
|
||
.foregroundColor(.accentColor)
|
||
}
|
||
Text(verbatim: "申请于 \(formatDateTimeShort(r.at)) · 批准后可访问 \(leaseDurationTitle(invites.first { $0.code == r.invite }?.lease_duration))")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
.lineLimit(1)
|
||
}
|
||
.layoutPriority(1)
|
||
Spacer(minLength: 8)
|
||
Button("拒绝") { decide(r, approve: false) }
|
||
.controlSize(.small)
|
||
.disabled(isRevoking)
|
||
Button("批准") { decide(r, approve: true) }
|
||
.buttonStyle(.borderedProminent)
|
||
.controlSize(.small)
|
||
.disabled(isRevoking)
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 10)
|
||
.rowCard(tint: .orange)
|
||
}
|
||
}
|
||
|
||
sectionHeader("已授权设备", leases.count)
|
||
if leases.isEmpty {
|
||
emptyHint("暂无。点击“新建邀请”把链接发给同事,对方打开并填写姓名后即可访问。")
|
||
}
|
||
ForEach(leases) { lease in
|
||
let remaining = formatRemainingTime(expiresAt: lease.expires_at)
|
||
let online = vm.activeConns.filter { $0.tunnel_id == tunnel.id && hostOf($0.client_ip) == lease.ip }.count
|
||
HStack(spacing: 10) {
|
||
Circle()
|
||
.fill(online > 0 ? Color.green : Color.secondary.opacity(0.35))
|
||
.frame(width: 7, height: 7)
|
||
.help(online > 0 ? "在线" : "当前没有连接")
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
HStack(spacing: 6) {
|
||
Text(verbatim: lease.name?.isEmpty == false ? lease.name! : "未署名")
|
||
.font(.system(size: 13, weight: .semibold))
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
Text(verbatim: lease.ip)
|
||
.font(.system(size: 12, design: .monospaced))
|
||
.foregroundColor(.accentColor)
|
||
.lineLimit(1)
|
||
if online > 0 {
|
||
TagView("在线 \(online) 个连接", color: .green)
|
||
}
|
||
}
|
||
if lease.awaiting_connect == true {
|
||
Text(verbatim: "已授权,尚未连接 · 首次连接后可访问 \(leaseDurationTitle(lease.duration)) · 请在 \(formatDateTimeShort(lease.expires_at)) 前连接")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
.lineLimit(1)
|
||
} else {
|
||
Text(verbatim: (lease.started_at.map { "首次连接 \(formatDateTimeShort($0)) · " } ?? "") + "有效期至 \(formatDateTimeShort(lease.expires_at)) · \(remaining.text)")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(remaining.isExpiringSoon ? .orange : .secondary)
|
||
.lineLimit(1)
|
||
}
|
||
}
|
||
.layoutPriority(1)
|
||
Spacer(minLength: 8)
|
||
Button("撤销", role: .destructive) { revoke(ip: lease.ip) }
|
||
.controlSize(.small)
|
||
.disabled(isRevoking)
|
||
.help("立即收回该设备的访问权限并断开连接")
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 10)
|
||
.rowCard()
|
||
}
|
||
|
||
sectionHeader("有效的邀请链接", invites.count)
|
||
if invites.isEmpty {
|
||
emptyHint("暂无有效的邀请链接")
|
||
}
|
||
ForEach(invites) { inv in
|
||
let used = inv.uses ?? []
|
||
let full = inv.max_uses > 0 && used.count >= inv.max_uses
|
||
HStack(spacing: 10) {
|
||
Image(systemName: "link")
|
||
.foregroundColor(.secondary)
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
HStack(spacing: 6) {
|
||
Text(verbatim: inv.note?.isEmpty == false ? "给 \(inv.note!)" : "未备注的邀请")
|
||
.font(.system(size: 13, weight: .semibold))
|
||
.lineLimit(1)
|
||
TagView("已用 \(used.count)/\(inv.max_uses > 0 ? "\(inv.max_uses)" : "不限")", color: full ? .orange : .secondary)
|
||
TagView("授权后可访问 \(leaseDurationTitle(inv.lease_duration))", color: .blue)
|
||
if inv.require_approval == true {
|
||
TagView("需审批", color: .orange)
|
||
}
|
||
}
|
||
Text(verbatim: "链接有效至 \(formatDateTimeShort(inv.expires_at))" + (used.isEmpty ? " · 尚未使用" : " · \(used.map { $0.name }.joined(separator: "、"))"))
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
}
|
||
.layoutPriority(1)
|
||
Spacer(minLength: 8)
|
||
Button(copiedCode == inv.code ? "已复制 ✓" : "复制分享文案") {
|
||
Task {
|
||
let (resp, _) = await vm.inviteShare(tunnelId: tunnel.id, code: inv.code)
|
||
copyToPasteboard(resp?.share_text ?? (vm.inviteBase + inv.code))
|
||
copiedCode = inv.code
|
||
}
|
||
}
|
||
.controlSize(.small)
|
||
.disabled(full)
|
||
.help(full ? "该链接已用满,请新建邀请" : "复制可直接发给同事的文案")
|
||
Button("作废", role: .destructive) {
|
||
isRevoking = true
|
||
Task {
|
||
revokeError = await vm.revokeInvite(tunnelId: tunnel.id, code: inv.code)
|
||
isRevoking = false
|
||
}
|
||
}
|
||
.controlSize(.small)
|
||
.disabled(isRevoking)
|
||
.help("作废后链接失效,通过它获得的访问权限也会被收回")
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 10)
|
||
.rowCard()
|
||
}
|
||
}
|
||
.padding(.bottom, 8)
|
||
}
|
||
}
|
||
.sheet(isPresented: $showingInviteSheet) {
|
||
InviteSheet(vm: vm, tunnel: liveTunnel, autoGenerate: false, isPresented: $showingInviteSheet)
|
||
}
|
||
}
|
||
|
||
func decide(_ r: AccessRequestItem, approve: Bool) {
|
||
isRevoking = true
|
||
Task {
|
||
revokeError = await vm.decideRequest(tunnelId: tunnel.id, requestId: r.id, approve: approve)
|
||
isRevoking = false
|
||
}
|
||
}
|
||
|
||
func revoke(ip: String?) {
|
||
isRevoking = true
|
||
revokeError = nil
|
||
Task {
|
||
revokeError = await vm.revokeLease(tunnelId: tunnel.id, ip: ip)
|
||
isRevoking = false
|
||
}
|
||
}
|
||
|
||
func loadHistory() {
|
||
isLoadingHistory = true
|
||
Task {
|
||
let list = await vm.fetchHistory(tunnelId: tunnel.id)
|
||
historyRecords = list
|
||
isLoadingHistory = false
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Diagnostics & Permission Status Sheet (Live Check)
|
||
|
||
struct DiagnosticsSheet: View {
|
||
@ObservedObject var vm: PortRelayViewModel
|
||
@Binding var isPresented: Bool
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 16) {
|
||
// Header
|
||
HStack {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text("系统权限与网络环境诊断")
|
||
.font(.system(size: 15, weight: .bold))
|
||
Text("检测本机网络授权、中继服务器通信与后台引擎状态")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.secondary)
|
||
}
|
||
Spacer()
|
||
Button(action: { vm.runDiagnostics() }) {
|
||
Label("重新检测", systemImage: "arrow.clockwise")
|
||
}
|
||
.buttonStyle(.bordered)
|
||
.controlSize(.small)
|
||
|
||
Button("完成") { isPresented = false }
|
||
.buttonStyle(.borderedProminent)
|
||
.controlSize(.small)
|
||
}
|
||
|
||
Divider()
|
||
|
||
// Diagnostic Items List
|
||
VStack(spacing: 10) {
|
||
ForEach(vm.diagnostics) { item in
|
||
HStack(alignment: .top, spacing: 12) {
|
||
Group {
|
||
switch item.status {
|
||
case .ok:
|
||
Image(systemName: "checkmark.circle.fill")
|
||
.foregroundColor(.green)
|
||
case .warning:
|
||
Image(systemName: "exclamationmark.triangle.fill")
|
||
.foregroundColor(.orange)
|
||
case .error:
|
||
Image(systemName: "xmark.circle.fill")
|
||
.foregroundColor(.red)
|
||
case .checking:
|
||
ProgressView().controlSize(.small)
|
||
}
|
||
}
|
||
.font(.system(size: 16))
|
||
.frame(width: 20)
|
||
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
HStack {
|
||
Text(item.title)
|
||
.font(.system(size: 13, weight: .semibold))
|
||
Spacer()
|
||
Text(item.statusText)
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundColor(item.status == .ok ? .green : (item.status == .error ? .red : .orange))
|
||
.padding(.horizontal, 6)
|
||
.padding(.vertical, 1.5)
|
||
.background((item.status == .ok ? Color.green : (item.status == .error ? Color.red : Color.orange)).opacity(0.12))
|
||
.cornerRadius(4)
|
||
}
|
||
|
||
Text(item.desc)
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.secondary)
|
||
.lineSpacing(2)
|
||
|
||
if let actionTitle = item.actionTitle, let action = item.action {
|
||
Button(actionTitle, action: action)
|
||
.buttonStyle(.link)
|
||
.font(.system(size: 12))
|
||
.padding(.top, 2)
|
||
}
|
||
}
|
||
}
|
||
.padding(12)
|
||
.rowCard(tint: item.status == .error ? .red : (item.status == .warning ? .orange : nil))
|
||
}
|
||
}
|
||
|
||
Spacer()
|
||
|
||
Text("说明:若 macOS 弹出「PortRelay 想要查找并连接本地网络上的设备」提示,请选择「允许」。")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
}
|
||
.padding(20)
|
||
.frame(width: 580, height: 460)
|
||
}
|
||
}
|
||
|
||
// MARK: - Edit / Create Sheet (With Owner & Duration TTL)
|
||
|
||
struct TunnelEditSheet: View {
|
||
@ObservedObject var vm: PortRelayViewModel
|
||
let tunnel: TunnelItem?
|
||
@Binding var isPresented: Bool
|
||
|
||
@State private var name: String
|
||
@State private var owner: String
|
||
@State private var target: String
|
||
@State private var portString: String
|
||
@State private var durationMinutes: Int
|
||
@State private var idleCloseMinutes: Int
|
||
@State private var maxBandwidthMB: Double // 0 为不限速
|
||
@State private var whitelistString: String
|
||
@State private var accessMode: AccessMode
|
||
@State private var probeMessage: String = ""
|
||
@State private var isProbing: Bool = false
|
||
@State private var errorMessage: String? = nil
|
||
|
||
var onCreated: ((String) -> Void)? = nil
|
||
|
||
enum AccessMode: String, CaseIterable {
|
||
case linkOnly, specific, all
|
||
}
|
||
|
||
var isEdit: Bool { tunnel != nil }
|
||
|
||
/// 表单字段在创建时就用通道数据填好,不依赖 onAppear(避免第一帧显示空表单)
|
||
init(vm: PortRelayViewModel, tunnel: TunnelItem?, isPresented: Binding<Bool>, onCreated: ((String) -> Void)? = nil) {
|
||
_vm = ObservedObject(wrappedValue: vm)
|
||
self.tunnel = tunnel
|
||
self.onCreated = onCreated
|
||
_isPresented = isPresented
|
||
let wl = tunnel?.whitelist ?? []
|
||
let mode: AccessMode = wl.isEmpty ? .linkOnly : (wl.contains("*") ? .all : .specific)
|
||
_accessMode = State(initialValue: mode)
|
||
_name = State(initialValue: tunnel?.name ?? "")
|
||
_owner = State(initialValue: tunnel.map { $0.owner ?? "" } ?? NSUserName())
|
||
_target = State(initialValue: tunnel?.target ?? "")
|
||
_portString = State(initialValue: tunnel.map { "\($0.listen_port)" } ?? "")
|
||
// duration_minutes 为 0 时 JSON 中会省略,缺省即“不限制”;新建默认 2 小时
|
||
// 最长开启时长:新建默认不限制(主要依靠闲置自动关闭)
|
||
_durationMinutes = State(initialValue: tunnel.map { $0.duration_minutes ?? 0 } ?? 0)
|
||
_idleCloseMinutes = State(initialValue: tunnel?.effective_idle_close_minutes ?? 30)
|
||
_maxBandwidthMB = State(initialValue: tunnel?.max_bandwidth_mb ?? 0)
|
||
_whitelistString = State(initialValue: mode == .specific ? wl.joined(separator: ", ") : "")
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 14) {
|
||
Text(isEdit ? "编辑通道" : "添加通道")
|
||
.font(.system(size: 15, weight: .bold))
|
||
|
||
HStack(spacing: 12) {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("通道名称")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.secondary)
|
||
TextField("例如:MySQL 实习库", text: $name)
|
||
.textFieldStyle(.roundedBorder)
|
||
}
|
||
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("关联责任人")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.secondary)
|
||
TextField("例如:张三 / 数据组", text: $owner)
|
||
.textFieldStyle(.roundedBorder)
|
||
}
|
||
}
|
||
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("目标服务 (内网主机 IP:端口)")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.secondary)
|
||
HStack {
|
||
TextField("例如 10.0.0.10:3306", text: $target)
|
||
.textFieldStyle(.roundedBorder)
|
||
Button(isProbing ? "测试中" : "连通性测试") {
|
||
Task {
|
||
isProbing = true
|
||
let (_, msg) = await vm.probeTarget(target: target)
|
||
probeMessage = msg
|
||
isProbing = false
|
||
}
|
||
}
|
||
.disabled(target.isEmpty || isProbing)
|
||
.controlSize(.small)
|
||
}
|
||
if !probeMessage.isEmpty {
|
||
Text(probeMessage)
|
||
.font(.system(size: 11))
|
||
.foregroundColor(probeMessage.contains("成功") ? .green : .red)
|
||
}
|
||
}
|
||
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("对外监听端口 (范围 33001-34000)")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.secondary)
|
||
HStack {
|
||
TextField("33660", text: $portString)
|
||
.textFieldStyle(.roundedBorder)
|
||
Button("自动分配") {
|
||
Task {
|
||
let p = await vm.recommendPort()
|
||
portString = "\(p)"
|
||
}
|
||
}
|
||
.controlSize(.small)
|
||
}
|
||
}
|
||
|
||
// Auto close: idle (primary) + optional hard cap
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Text("闲置自动关闭(没有连接、有效授权、待审批和可用邀请时开始计时)")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.secondary)
|
||
HStack(spacing: 6) {
|
||
ForEach(idleCloseOptions, id: \.0) { opt in
|
||
DurationChip(title: opt.1, minutes: opt.0, selectedMinutes: $idleCloseMinutes)
|
||
}
|
||
}
|
||
Text("最长开启时长(可选,到时无论是否在用都会关闭)")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.secondary)
|
||
.padding(.top, 2)
|
||
HStack(spacing: 6) {
|
||
DurationChip(title: "不限制", minutes: 0, selectedMinutes: $durationMinutes)
|
||
DurationChip(title: "2 小时", minutes: 120, selectedMinutes: $durationMinutes)
|
||
DurationChip(title: "8 小时", minutes: 480, selectedMinutes: $durationMinutes)
|
||
DurationChip(title: "1 天", minutes: 1440, selectedMinutes: $durationMinutes)
|
||
DurationChip(title: "7 天", minutes: 10080, selectedMinutes: $durationMinutes)
|
||
// 兼容旧配置中的其他时长
|
||
if ![0, 120, 480, 1440, 10080].contains(durationMinutes) {
|
||
DurationChip(title: formatSecondsToDuration(Int64(durationMinutes) * 60), minutes: durationMinutes, selectedMinutes: $durationMinutes)
|
||
}
|
||
}
|
||
}
|
||
|
||
// QoS Bandwidth Limit Option
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("单通道带宽限速 (QoS 防挤占)")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.secondary)
|
||
|
||
HStack(spacing: 6) {
|
||
BandwidthChip(title: "不限速", mb: 0, selectedMB: $maxBandwidthMB)
|
||
BandwidthChip(title: "2 MB/s", mb: 2, selectedMB: $maxBandwidthMB)
|
||
BandwidthChip(title: "5 MB/s", mb: 5, selectedMB: $maxBandwidthMB)
|
||
BandwidthChip(title: "10 MB/s", mb: 10, selectedMB: $maxBandwidthMB)
|
||
BandwidthChip(title: "20 MB/s", mb: 20, selectedMB: $maxBandwidthMB)
|
||
}
|
||
}
|
||
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Text("访问控制")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.secondary)
|
||
Picker("", selection: $accessMode) {
|
||
Text("仅授权链接(推荐)").tag(AccessMode.linkOnly)
|
||
Text("固定 IP / 网段").tag(AccessMode.specific)
|
||
Text("全部放通 *").tag(AccessMode.all)
|
||
}
|
||
.pickerStyle(.segmented)
|
||
.labelsHidden()
|
||
|
||
switch accessMode {
|
||
case .linkOnly:
|
||
Text("只有通过邀请链接授权的设备可以访问,保存后可以直接设置并生成邀请链接。")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
case .specific:
|
||
TextField("例如 192.168.1.100, 192.168.1.0/24(逗号分隔)", text: $whitelistString)
|
||
.textFieldStyle(.roundedBorder)
|
||
Text("名单内的设备始终可以访问;其他人仍可通过邀请链接临时授权。")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
case .all:
|
||
Label("任何能访问服务器的设备都可以连接,不建议用于数据库等敏感服务。", systemImage: "exclamationmark.triangle.fill")
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.red)
|
||
}
|
||
}
|
||
|
||
if let err = errorMessage {
|
||
Text(err)
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.red)
|
||
}
|
||
|
||
HStack {
|
||
Spacer()
|
||
Button("取消") { isPresented = false }
|
||
.controlSize(.small)
|
||
Button("保存") {
|
||
guard let p = Int(portString), p > 33000 && p <= 34000 else {
|
||
errorMessage = "端口须在 33001-34000 范围内"
|
||
return
|
||
}
|
||
var wl: [String] = []
|
||
switch accessMode {
|
||
case .linkOnly:
|
||
wl = []
|
||
case .all:
|
||
wl = ["*"]
|
||
case .specific:
|
||
wl = whitelistString.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
|
||
if wl.isEmpty {
|
||
errorMessage = "请填写允许访问的 IP 或网段,或选择“仅授权链接”"
|
||
return
|
||
}
|
||
if wl.contains("*") {
|
||
errorMessage = "如需放通全部,请选择“全部放通 *”"
|
||
return
|
||
}
|
||
}
|
||
Task {
|
||
let (ok, err, savedId) = await vm.saveTunnel(
|
||
id: tunnel?.id,
|
||
name: name,
|
||
target: target,
|
||
port: p,
|
||
whitelist: wl,
|
||
owner: owner.trimmingCharacters(in: .whitespaces),
|
||
durationMinutes: durationMinutes,
|
||
idleCloseMinutes: idleCloseMinutes,
|
||
maxBandwidthMB: maxBandwidthMB
|
||
)
|
||
if ok {
|
||
isPresented = false
|
||
if !isEdit, accessMode != .all, let id = savedId {
|
||
onCreated?(id)
|
||
}
|
||
} else {
|
||
errorMessage = err ?? "保存失败"
|
||
}
|
||
}
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.controlSize(.small)
|
||
.disabled(name.isEmpty || target.isEmpty)
|
||
}
|
||
.padding(.top, 4)
|
||
}
|
||
.padding(20)
|
||
.frame(width: 490)
|
||
.onAppear {
|
||
if tunnel == nil {
|
||
Task {
|
||
let p = await vm.recommendPort()
|
||
if portString.isEmpty { portString = "\(p)" }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
struct InviteSheetTarget: Identifiable {
|
||
let tunnel: TunnelItem
|
||
let autoGenerate: Bool
|
||
var id: String { tunnel.id }
|
||
}
|
||
|
||
/// 生成邀请链接并展示分享卡片(二维码 + 可直接发送的文案)
|
||
struct InviteSheet: View {
|
||
@ObservedObject var vm: PortRelayViewModel
|
||
let tunnel: TunnelItem
|
||
let autoGenerate: Bool
|
||
@Binding var isPresented: Bool
|
||
|
||
@State private var note: String = ""
|
||
@State private var validMinutes: Int = 24 * 60
|
||
@State private var maxUses: Int = 1
|
||
@State private var leaseDuration: String = "2h"
|
||
@State private var requireApproval: Bool = false
|
||
@State private var result: InviteShareResp? = nil
|
||
@State private var errorMessage: String? = nil
|
||
@State private var isWorking = false
|
||
@State private var copied: String? = nil
|
||
|
||
let validOptions: [(Int, String)] = [(60, "1 小时"), (24 * 60, "24 小时"), (7 * 24 * 60, "7 天")]
|
||
let useOptions: [(Int, String)] = [(1, "1 台"), (3, "3 台"), (0, "不限")]
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 14) {
|
||
HStack {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(verbatim: "邀请访问「\(tunnel.name)」")
|
||
.font(.system(size: 15, weight: .bold))
|
||
Text(verbatim: result == nil
|
||
? "同事打开链接并填写姓名后获得访问权限"
|
||
: "授权后可访问 \(leaseDurationTitle(result?.invite.lease_duration))" + (result?.invite.require_approval == true ? ",需你审批" : ",无需审批"))
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.secondary)
|
||
}
|
||
Spacer()
|
||
Button("完成") { isPresented = false }
|
||
.buttonStyle(.borderedProminent)
|
||
.controlSize(.small)
|
||
}
|
||
|
||
if let r = result {
|
||
HStack(alignment: .top, spacing: 16) {
|
||
if let qr = makeQRCode(r.link) {
|
||
VStack(spacing: 4) {
|
||
Image(nsImage: qr)
|
||
.interpolation(.none)
|
||
.resizable()
|
||
.frame(width: 150, height: 150)
|
||
.background(Color.white)
|
||
.cornerRadius(6)
|
||
Text("手机扫码打开")
|
||
.font(.system(size: 10))
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
ScrollView {
|
||
Text(verbatim: r.share_text)
|
||
.font(.system(size: 12))
|
||
.textSelection(.enabled)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.frame(height: 130)
|
||
.padding(8)
|
||
.background(Color(NSColor.textBackgroundColor))
|
||
.cornerRadius(6)
|
||
.overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(Color(NSColor.separatorColor), lineWidth: 1))
|
||
HStack {
|
||
Button(copied == "text" ? "已复制" : "复制分享文案") {
|
||
copyToPasteboard(r.share_text)
|
||
copied = "text"
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.controlSize(.small)
|
||
Button(copied == "link" ? "已复制" : "仅复制链接") {
|
||
copyToPasteboard(r.link)
|
||
copied = "link"
|
||
}
|
||
.controlSize(.small)
|
||
Spacer()
|
||
Button("再生成一个") {
|
||
result = nil
|
||
copied = nil
|
||
}
|
||
.buttonStyle(.link)
|
||
.font(.system(size: 11))
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("备注(发给谁,可选)")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.secondary)
|
||
TextField("例如:张三", text: $note)
|
||
.textFieldStyle(.roundedBorder)
|
||
}
|
||
HStack(spacing: 6) {
|
||
Text("链接有效期")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.secondary)
|
||
.frame(width: 84, alignment: .leading)
|
||
ForEach(validOptions, id: \.0) { opt in
|
||
DurationChip(title: opt.1, minutes: opt.0, selectedMinutes: $validMinutes)
|
||
}
|
||
}
|
||
HStack(spacing: 6) {
|
||
Text("可用设备数")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.secondary)
|
||
.frame(width: 84, alignment: .leading)
|
||
ForEach(useOptions, id: \.0) { opt in
|
||
DurationChip(title: opt.1, minutes: opt.0, selectedMinutes: $maxUses)
|
||
}
|
||
}
|
||
HStack(spacing: 6) {
|
||
Text("授权后可访问")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.secondary)
|
||
.frame(width: 84, alignment: .leading)
|
||
ForEach(leaseDurationOptions, id: \.value) { opt in
|
||
StringChip(title: opt.title, value: opt.value, selection: $leaseDuration)
|
||
}
|
||
}
|
||
HStack(spacing: 6) {
|
||
Text("审批")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.secondary)
|
||
.frame(width: 84, alignment: .leading)
|
||
Toggle("同事打开链接后需要我批准才放通", isOn: $requireApproval)
|
||
.font(.system(size: 12))
|
||
}
|
||
Text("“链接有效期”是同事必须在多久内打开链接;“授权后可访问”是打开并授权后能使用多久。链接用满设备数后自动失效,也可随时在“授权管理”中作废。")
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
.font(.system(size: 11))
|
||
.foregroundColor(.secondary)
|
||
HStack {
|
||
Spacer()
|
||
Button(isWorking ? "生成中…" : "生成邀请链接") { generate() }
|
||
.buttonStyle(.borderedProminent)
|
||
.controlSize(.small)
|
||
.disabled(isWorking)
|
||
}
|
||
}
|
||
}
|
||
|
||
if let err = errorMessage {
|
||
Text(err)
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.red)
|
||
}
|
||
}
|
||
.padding(20)
|
||
.frame(width: 560)
|
||
.onAppear {
|
||
if autoGenerate { generate() }
|
||
}
|
||
}
|
||
|
||
func generate() {
|
||
isWorking = true
|
||
errorMessage = nil
|
||
Task {
|
||
let (resp, err) = await vm.createInvite(tunnelId: tunnel.id, note: note.trimmingCharacters(in: .whitespaces),
|
||
validMinutes: validMinutes, maxUses: maxUses, leaseDuration: leaseDuration,
|
||
requireApproval: requireApproval)
|
||
result = resp
|
||
errorMessage = err
|
||
isWorking = false
|
||
}
|
||
}
|
||
}
|
||
|
||
struct DurationChip: View {
|
||
let title: String
|
||
let minutes: Int
|
||
@Binding var selectedMinutes: Int
|
||
|
||
var isSelected: Bool { selectedMinutes == minutes }
|
||
|
||
var body: some View {
|
||
Button(action: { selectedMinutes = minutes }) {
|
||
ChipLabel(title: title, selected: isSelected)
|
||
}
|
||
.buttonStyle(.borderless)
|
||
}
|
||
}
|
||
|
||
struct BandwidthChip: View {
|
||
let title: String
|
||
let mb: Double
|
||
@Binding var selectedMB: Double
|
||
|
||
var isSelected: Bool { abs(selectedMB - mb) < 0.01 }
|
||
|
||
var body: some View {
|
||
Button(action: { selectedMB = mb }) {
|
||
ChipLabel(title: title, selected: isSelected, tint: .purple)
|
||
}
|
||
.buttonStyle(.borderless)
|
||
}
|
||
}
|
||
|
||
// MARK: - Settings Sheet
|
||
|
||
struct SettingsSheet: View {
|
||
@ObservedObject var vm: PortRelayViewModel
|
||
@Binding var isPresented: Bool
|
||
|
||
@State private var serverAddr: String = ""
|
||
@State private var token: String = ""
|
||
@State private var publicHost: String = ""
|
||
@State private var errorMessage: String? = nil
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 14) {
|
||
Text("设置")
|
||
.font(.system(size: 15, weight: .bold))
|
||
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("服务器控制地址 (地址:端口)")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.secondary)
|
||
TextField("例如 gw.example.com:33000", text: $serverAddr)
|
||
.textFieldStyle(.roundedBorder)
|
||
}
|
||
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("安全令牌")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.secondary)
|
||
SecureField("与服务器一致的令牌", text: $token)
|
||
.textFieldStyle(.roundedBorder)
|
||
}
|
||
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("对外访问地址 (访问者连接通道时使用)")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.secondary)
|
||
TextField("留空则使用服务器地址", text: $publicHost)
|
||
.textFieldStyle(.roundedBorder)
|
||
}
|
||
|
||
if let err = errorMessage {
|
||
Text(err)
|
||
.font(.system(size: 12))
|
||
.foregroundColor(.red)
|
||
}
|
||
|
||
HStack {
|
||
Spacer()
|
||
Button("取消") { isPresented = false }
|
||
.controlSize(.small)
|
||
Button("保存并重连") {
|
||
Task {
|
||
if let err = await vm.saveSettings(serverAddr: serverAddr, token: token, publicHost: publicHost) {
|
||
errorMessage = err
|
||
} else {
|
||
isPresented = false
|
||
}
|
||
}
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.controlSize(.small)
|
||
}
|
||
.padding(.top, 6)
|
||
}
|
||
.padding(20)
|
||
.frame(width: 400)
|
||
.onAppear {
|
||
Task {
|
||
let (addr, t, host) = await vm.fetchSettings()
|
||
serverAddr = addr
|
||
token = t
|
||
publicHost = host
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - App Delegate & Menu Bar Extra
|
||
|
||
@MainActor
|
||
class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
|
||
var statusItem: NSStatusItem?
|
||
private var mainWindow: NSWindow?
|
||
|
||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||
PortRelayViewModel.shared.appDelegate = self
|
||
setupStatusItem()
|
||
|
||
// Hook window close button so it hides to menu bar instead of quitting
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in
|
||
if let window = NSApp.windows.first(where: { $0.title == "PortRelay" || $0.canBecomeMain }) {
|
||
self?.mainWindow = window
|
||
window.delegate = self
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 关闭窗口:隐藏到菜单栏,同时从 Dock 移除图标
|
||
func windowShouldClose(_ sender: NSWindow) -> Bool {
|
||
sender.orderOut(nil)
|
||
NSApp.setActivationPolicy(.accessory)
|
||
return false
|
||
}
|
||
|
||
/// 彻底退出:先结束后台引擎再退出 App
|
||
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
||
Task { @MainActor in
|
||
await PortRelayViewModel.shared.shutdownAgent()
|
||
sender.reply(toApplicationShouldTerminate: true)
|
||
}
|
||
return .terminateLater
|
||
}
|
||
|
||
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
|
||
if !flag {
|
||
showMainWindow()
|
||
}
|
||
return true
|
||
}
|
||
|
||
private func setupStatusItem() {
|
||
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||
if let button = statusItem?.button {
|
||
button.image = NSImage(systemSymbolName: "arrow.left.arrow.right.circle", accessibilityDescription: "PortRelay")
|
||
button.imagePosition = .imageLeading
|
||
button.title = " 0"
|
||
}
|
||
rebuildMenu()
|
||
}
|
||
|
||
func updateStatusItemTitle(activeCount: Int) {
|
||
guard let button = self.statusItem?.button else { return }
|
||
let isConnected = PortRelayViewModel.shared.isConnected
|
||
let pendingCount = PortRelayViewModel.shared.pendingRequests.count
|
||
var iconName = isConnected ? (activeCount > 0 ? "arrow.left.arrow.right.circle.fill" : "arrow.left.arrow.right.circle") : "exclamationmark.triangle"
|
||
if pendingCount > 0 { iconName = "bell.badge.fill" }
|
||
button.image = NSImage(systemSymbolName: iconName, accessibilityDescription: "PortRelay")
|
||
button.title = pendingCount > 0 ? " 待审批 \(pendingCount)" : " \(activeCount)"
|
||
if PortRelayViewModel.shared.menuNeedsRebuild() {
|
||
self.rebuildMenu()
|
||
}
|
||
}
|
||
|
||
func rebuildMenu() {
|
||
let menu = NSMenu()
|
||
let vm = PortRelayViewModel.shared
|
||
|
||
// Status Title
|
||
let header = NSMenuItem(title: "PortRelay 端口中继", action: nil, keyEquivalent: "")
|
||
header.isEnabled = false
|
||
menu.addItem(header)
|
||
|
||
let connStatus = vm.isConnected ? "🟢 服务器连接正常" : "🔴 服务器未连接 / 重试中"
|
||
let statusItem = NSMenuItem(title: connStatus, action: nil, keyEquivalent: "")
|
||
statusItem.isEnabled = false
|
||
menu.addItem(statusItem)
|
||
|
||
menu.addItem(NSMenuItem.separator())
|
||
|
||
// Pending approvals
|
||
let pending = vm.pendingRequests
|
||
if !pending.isEmpty {
|
||
let title = NSMenuItem(title: "待审批的访问申请 (\(pending.count)):", action: nil, keyEquivalent: "")
|
||
title.isEnabled = false
|
||
menu.addItem(title)
|
||
for (t, r) in pending {
|
||
let item = NSMenuItem(title: "\(r.name)(\(r.ip))→ \(t.name)", action: nil, keyEquivalent: "")
|
||
let sub = NSMenu()
|
||
let approve = NSMenuItem(title: "批准", action: #selector(decideAction(_:)), keyEquivalent: "")
|
||
approve.representedObject = [t.id, r.id, "1"]
|
||
approve.target = self
|
||
let reject = NSMenuItem(title: "拒绝", action: #selector(decideAction(_:)), keyEquivalent: "")
|
||
reject.representedObject = [t.id, r.id, "0"]
|
||
reject.target = self
|
||
sub.addItem(approve)
|
||
sub.addItem(reject)
|
||
item.submenu = sub
|
||
menu.addItem(item)
|
||
}
|
||
menu.addItem(NSMenuItem.separator())
|
||
}
|
||
|
||
// Tunnels section
|
||
if vm.tunnels.isEmpty {
|
||
let emptyItem = NSMenuItem(title: "暂无通道", action: nil, keyEquivalent: "")
|
||
emptyItem.isEnabled = false
|
||
menu.addItem(emptyItem)
|
||
} else {
|
||
let tunnelSection = NSMenuItem(title: "中继通道列表 (\(vm.tunnels.filter { $0.enabled }.count)/\(vm.tunnels.count) 开启):", action: nil, keyEquivalent: "")
|
||
tunnelSection.isEnabled = false
|
||
menu.addItem(tunnelSection)
|
||
|
||
for t in vm.tunnels {
|
||
let check = t.enabled ? "✓ " : " "
|
||
let title = "\(check)\(t.name) (:\(t.listen_port))"
|
||
let item = NSMenuItem(title: title, action: #selector(toggleTunnelAction(_:)), keyEquivalent: "")
|
||
item.representedObject = t.id
|
||
item.target = self
|
||
menu.addItem(item)
|
||
}
|
||
}
|
||
|
||
menu.addItem(NSMenuItem.separator())
|
||
|
||
// Quick Actions
|
||
let showItem = NSMenuItem(title: "打开主控制台", action: #selector(showMainWindowAction), keyEquivalent: "o")
|
||
showItem.target = self
|
||
menu.addItem(showItem)
|
||
|
||
let reconnectItem = NSMenuItem(title: "立即重连网关", action: #selector(reconnectAction), keyEquivalent: "r")
|
||
reconnectItem.target = self
|
||
menu.addItem(reconnectItem)
|
||
|
||
menu.addItem(NSMenuItem.separator())
|
||
|
||
let quitItem = NSMenuItem(title: "彻底退出 PortRelay (含后台引擎)", action: #selector(quitAction), keyEquivalent: "q")
|
||
quitItem.target = self
|
||
menu.addItem(quitItem)
|
||
|
||
self.statusItem?.menu = menu
|
||
}
|
||
|
||
@objc func toggleTunnelAction(_ sender: NSMenuItem) {
|
||
guard let id = sender.representedObject as? String else { return }
|
||
Task {
|
||
await PortRelayViewModel.shared.toggleTunnel(id: id)
|
||
}
|
||
}
|
||
|
||
@objc func decideAction(_ sender: NSMenuItem) {
|
||
guard let v = sender.representedObject as? [String], v.count == 3 else { return }
|
||
Task {
|
||
_ = await PortRelayViewModel.shared.decideRequest(tunnelId: v[0], requestId: v[1], approve: v[2] == "1")
|
||
}
|
||
}
|
||
|
||
@objc func showMainWindowAction() {
|
||
showMainWindow()
|
||
}
|
||
|
||
@objc func reconnectAction() {
|
||
Task {
|
||
await PortRelayViewModel.shared.triggerReconnect(force: true)
|
||
}
|
||
}
|
||
|
||
@objc func quitAction() {
|
||
NSApp.terminate(nil)
|
||
}
|
||
|
||
func showMainWindow() {
|
||
// 打开主窗口时恢复 Dock 图标
|
||
NSApp.setActivationPolicy(.regular)
|
||
NSApp.activate(ignoringOtherApps: true)
|
||
if let window = mainWindow {
|
||
window.makeKeyAndOrderFront(nil)
|
||
} else if let window = NSApp.windows.first(where: { $0.title == "PortRelay" || $0.canBecomeMain }) {
|
||
mainWindow = window
|
||
window.delegate = self
|
||
window.makeKeyAndOrderFront(nil)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - App Main
|
||
|
||
@main
|
||
struct PortRelayApp: App {
|
||
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||
|
||
var body: some Scene {
|
||
WindowGroup("PortRelay") {
|
||
ContentView()
|
||
}
|
||
.windowStyle(.titleBar)
|
||
.defaultSize(width: 920, height: 640)
|
||
}
|
||
}
|
||
|